feat(ui): migrate to TanStack Query, add server-side pagination, and improve error handling

- Add @tanstack/react-query with shared cache and centralized query keys
- Migrate all 14 CRUD hooks from manual useState/useCallback to useQuery/useMutation
- Add WS event-driven query invalidation (sessions, traces auto-refresh on run completion)
- Consolidate LlmConfigSection to delegate provider/model UI to ProviderModelSelect
- Add server-side pagination for custom-tools and channel-instances (Go store + HTTP handler)
- Extract shared types into dedicated files (provider, custom-tool, mcp, channel, skill, trace)
- Add network error handling in HTTP client and connectivity check on login
- Add disconnect banner in app layout when gateway connection is lost

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
viettranxandClaude Opus 4.6 committed 2026-02-28 16:25:59 +07:00
1 parent 552ddc059e
commit 813c602bca
41 files changed
+1079 -813

No files matched your search

+29 -2
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"log/slog"
"net/http"
"strconv"
"github.com/google/uuid"
@@ -61,19 +62,45 @@ func (h *ChannelInstancesHandler) emitCacheInvalidate() {
}
func (h *ChannelInstancesHandler) handleList(w http.ResponseWriter, r *http.Request) {
instances, err := h.store.ListAll(r.Context())
opts := store.ChannelInstanceListOpts{
Limit: 50,
Offset: 0,
}
if v := r.URL.Query().Get("search"); v != "" {
opts.Search = v
}
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 200 {
opts.Limit = n
}
}
if v := r.URL.Query().Get("offset"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
opts.Offset = n
}
}
instances, err := h.store.ListPaged(r.Context(), opts)
if err != nil {
slog.Error("channel_instances.list", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list instances"})
return
}
total, _ := h.store.CountInstances(r.Context(), opts)
result := make([]map[string]interface{}, 0, len(instances))
for _, inst := range instances {
result = append(result, maskInstanceHTTP(inst))
}
writeJSON(w, http.StatusOK, map[string]interface{}{"instances": result})
writeJSON(w, http.StatusOK, map[string]interface{}{
"instances": result,
"total": total,
"limit": opts.Limit,
"offset": opts.Offset,
})
}
func (h *ChannelInstancesHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
+32 -11
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"log/slog"
"net/http"
"strconv"
"github.com/google/uuid"
@@ -63,28 +64,48 @@ func (h *CustomToolsHandler) emitCacheInvalidate(key string) {
}
func (h *CustomToolsHandler) handleList(w http.ResponseWriter, r *http.Request) {
agentIDStr := r.URL.Query().Get("agent_id")
opts := store.CustomToolListOpts{
Limit: 50,
Offset: 0,
}
var result []store.CustomToolDef
var err error
if agentIDStr != "" {
agentID, parseErr := uuid.Parse(agentIDStr)
if parseErr != nil {
if v := r.URL.Query().Get("agent_id"); v != "" {
id, err := uuid.Parse(v)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent_id"})
return
}
result, err = h.store.ListByAgent(r.Context(), agentID)
} else {
result, err = h.store.ListAll(r.Context())
opts.AgentID = &id
}
if v := r.URL.Query().Get("search"); v != "" {
opts.Search = v
}
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 200 {
opts.Limit = n
}
}
if v := r.URL.Query().Get("offset"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
opts.Offset = n
}
}
result, err := h.store.ListPaged(r.Context(), opts)
if err != nil {
slog.Error("custom_tools.list", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list tools"})
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"tools": result})
total, _ := h.store.CountTools(r.Context(), opts)
writeJSON(w, http.StatusOK, map[string]interface{}{
"tools": result,
"total": total,
"limit": opts.Limit,
"offset": opts.Offset,
})
}
func (h *CustomToolsHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
+9
View File
@@ -35,6 +35,13 @@ func IsDefaultChannelInstance(name string) bool {
return false
}
// ChannelInstanceListOpts configures channel instance listing with optional pagination and filtering.
type ChannelInstanceListOpts struct {
Search string
Limit int
Offset int
}
// ChannelInstanceStore manages channel instance definitions (managed mode only).
type ChannelInstanceStore interface {
Create(ctx context.Context, inst *ChannelInstanceData) error
@@ -44,4 +51,6 @@ type ChannelInstanceStore interface {
Delete(ctx context.Context, id uuid.UUID) error
ListEnabled(ctx context.Context) ([]ChannelInstanceData, error)
ListAll(ctx context.Context) ([]ChannelInstanceData, error)
ListPaged(ctx context.Context, opts ChannelInstanceListOpts) ([]ChannelInstanceData, error)
CountInstances(ctx context.Context, opts ChannelInstanceListOpts) (int, error)
}
+10
View File
@@ -22,6 +22,14 @@ type CustomToolDef struct {
CreatedBy string `json:"created_by"`
}
// CustomToolListOpts configures custom tool listing with optional pagination and filtering.
type CustomToolListOpts struct {
AgentID *uuid.UUID
Search string
Limit int
Offset int
}
// CustomToolStore manages custom tool definitions (managed mode only).
type CustomToolStore interface {
Create(ctx context.Context, def *CustomToolDef) error
@@ -31,4 +39,6 @@ type CustomToolStore interface {
ListGlobal(ctx context.Context) ([]CustomToolDef, error)
ListByAgent(ctx context.Context, agentID uuid.UUID) ([]CustomToolDef, error)
ListAll(ctx context.Context) ([]CustomToolDef, error)
ListPaged(ctx context.Context, opts CustomToolListOpts) ([]CustomToolDef, error)
CountTools(ctx context.Context, opts CustomToolListOpts) (int, error)
}
+41
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"log/slog"
"strings"
"time"
"github.com/google/uuid"
@@ -190,3 +191,43 @@ func (s *PGChannelInstanceStore) ListAll(ctx context.Context) ([]store.ChannelIn
}
return s.scanInstances(rows)
}
func buildChannelInstanceWhere(opts store.ChannelInstanceListOpts) (string, []interface{}) {
var conditions []string
var args []interface{}
argIdx := 1
if opts.Search != "" {
conditions = append(conditions, fmt.Sprintf("(name ILIKE $%d OR display_name ILIKE $%d OR channel_type ILIKE $%d)", argIdx, argIdx, argIdx))
args = append(args, "%"+opts.Search+"%")
}
where := ""
if len(conditions) > 0 {
where = " WHERE " + strings.Join(conditions, " AND ")
}
return where, args
}
func (s *PGChannelInstanceStore) ListPaged(ctx context.Context, opts store.ChannelInstanceListOpts) ([]store.ChannelInstanceData, error) {
where, args := buildChannelInstanceWhere(opts)
limit := opts.Limit
if limit <= 0 {
limit = 50
}
q := `SELECT ` + channelInstanceSelectCols + ` FROM channel_instances` + where +
fmt.Sprintf(" ORDER BY name OFFSET %d LIMIT %d", opts.Offset, limit)
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
return s.scanInstances(rows)
}
func (s *PGChannelInstanceStore) CountInstances(ctx context.Context, opts store.ChannelInstanceListOpts) (int, error) {
where, args := buildChannelInstanceWhere(opts)
var count int
err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM channel_instances"+where, args...).Scan(&count)
return count, err
}
+42
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"fmt"
"log/slog"
"strings"
"time"
"github.com/google/uuid"
@@ -191,3 +192,44 @@ func (s *PGCustomToolStore) ListAll(ctx context.Context) ([]store.CustomToolDef,
}
return s.scanTools(rows)
}
func buildCustomToolWhere(opts store.CustomToolListOpts) (string, []interface{}) {
conditions := []string{"enabled = true"}
var args []interface{}
argIdx := 1
if opts.AgentID != nil {
conditions = append(conditions, fmt.Sprintf("agent_id = $%d", argIdx))
args = append(args, *opts.AgentID)
argIdx++
}
if opts.Search != "" {
conditions = append(conditions, fmt.Sprintf("(name ILIKE $%d OR description ILIKE $%d)", argIdx, argIdx))
args = append(args, "%"+opts.Search+"%")
}
return " WHERE " + strings.Join(conditions, " AND "), args
}
func (s *PGCustomToolStore) ListPaged(ctx context.Context, opts store.CustomToolListOpts) ([]store.CustomToolDef, error) {
where, args := buildCustomToolWhere(opts)
limit := opts.Limit
if limit <= 0 {
limit = 50
}
q := `SELECT ` + customToolSelectCols + ` FROM custom_tools` + where +
fmt.Sprintf(" ORDER BY name OFFSET %d LIMIT %d", opts.Offset, limit)
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
return s.scanTools(rows)
}
func (s *PGCustomToolStore) CountTools(ctx context.Context, opts store.CustomToolListOpts) (int, error) {
where, args := buildCustomToolWhere(opts)
var count int
err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM custom_tools"+where, args...).Scan(&count)
return count, err
}
+1
View File
@@ -11,6 +11,7 @@
},
"dependencies": {
"@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+18
View File
@@ -11,6 +11,9 @@ importers:
'@tailwindcss/typography':
specifier: ^0.5.19
version: 0.5.19(tailwindcss@4.2.0)
'@tanstack/react-query':
specifier: ^5.90.21
version: 5.90.21(react@19.2.4)
'@tanstack/react-table':
specifier: ^8.21.3
version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -1284,6 +1287,14 @@ packages:
peerDependencies:
vite: ^5.2.0 || ^6 || ^7
'@tanstack/query-core@5.90.20':
resolution: {integrity: sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==}
'@tanstack/react-query@5.90.21':
resolution: {integrity: sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==}
peerDependencies:
react: ^18 || ^19
'@tanstack/react-table@8.21.3':
resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==}
engines: {node: '>=12'}
@@ -3232,6 +3243,13 @@ snapshots:
tailwindcss: 4.2.0
vite: 6.4.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)
'@tanstack/query-core@5.90.20': {}
'@tanstack/react-query@5.90.21(react@19.2.4)':
dependencies:
'@tanstack/query-core': 5.90.20
react: 19.2.4
'@tanstack/react-table@8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@tanstack/table-core': 8.21.3
+9 -4
View File
@@ -78,10 +78,15 @@ export class HttpClient {
}
private async request<T>(url: string, init: RequestInit): Promise<T> {
const res = await fetch(url, {
...init,
headers: { ...this.headers(), ...(init.headers as Record<string, string>) },
});
let res: Response;
try {
res = await fetch(url, {
...init,
headers: { ...this.headers(), ...(init.headers as Record<string, string>) },
});
} catch {
throw new ApiError("NETWORK_ERROR", "Cannot connect to server. Check if the gateway is running.");
}
if (!res.ok) {
if (res.status === 401) {
@@ -1,16 +1,25 @@
import { Outlet } from "react-router";
import { WifiOff } from "lucide-react";
import { Sidebar } from "./sidebar";
import { Topbar } from "./topbar";
import { useUiStore } from "@/stores/use-ui-store";
import { useAuthStore } from "@/stores/use-auth-store";
export function AppLayout() {
const sidebarCollapsed = useUiStore((s) => s.sidebarCollapsed);
const connected = useAuthStore((s) => s.connected);
return (
<div className="flex h-screen overflow-hidden">
<Sidebar collapsed={sidebarCollapsed} />
<div className="flex flex-1 flex-col overflow-hidden">
<Topbar />
{!connected && (
<div className="flex items-center gap-2 border-b border-destructive/30 bg-destructive/10 px-4 py-2 text-sm text-destructive">
<WifiOff className="h-4 w-4 shrink-0" />
<span>Disconnected from gateway. Attempting to reconnect...</span>
</div>
)}
<main className="flex-1 overflow-y-auto">
<Outlet />
</main>
@@ -1,10 +1,26 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ThemeProvider } from "./theme-provider";
import { WsProvider } from "./ws-provider";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
gcTime: 5 * 60_000,
retry: 1,
refetchOnWindowFocus: true,
},
},
});
export { queryClient };
export function AppProviders({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider>
<WsProvider>{children}</WsProvider>
</ThemeProvider>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<WsProvider>{children}</WsProvider>
</ThemeProvider>
</QueryClientProvider>
);
}
@@ -3,6 +3,7 @@ import { WsClient, type ConnectionState } from "@/api/ws-client";
import { HttpClient } from "@/api/http-client";
import { WsContext } from "@/hooks/use-ws";
import { useAuthStore } from "@/stores/use-auth-store";
import { useWsQueryInvalidation } from "@/hooks/use-query-invalidation";
// In dev mode, connect directly to backend WS (bypass Vite proxy).
// In production, use relative "/ws" path.
@@ -55,5 +56,15 @@ export function WsProvider({ children }: { children: React.ReactNode }) {
const value = useMemo(() => ({ ws, http }), [ws, http]);
return <WsContext.Provider value={value}>{children}</WsContext.Provider>;
return (
<WsContext.Provider value={value}>
<WsQueryInvalidation />
{children}
</WsContext.Provider>
);
}
function WsQueryInvalidation() {
useWsQueryInvalidation();
return null;
}
@@ -1,4 +1,4 @@
import { useMemo } from "react";
import { useMemo, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Combobox } from "@/components/ui/combobox";
@@ -25,8 +25,14 @@ interface ProviderModelSelectProps {
modelLabel?: string;
providerPlaceholder?: string;
modelPlaceholder?: string;
/** Show a "Check" verify button. */
/** Show a "Check" verify button. When true, always shows. When omitted, auto-shows if savedProvider/savedModel are provided and values differ. */
showVerify?: boolean;
/** Saved provider value — when provided, verify button auto-shows on change. */
savedProvider?: string;
/** Saved model value — when provided, verify button auto-shows on change. */
savedModel?: string;
/** Called when verification status changes. True = save should be blocked (changed but not verified). */
onSaveBlockedChange?: (blocked: boolean) => void;
}
export function ProviderModelSelect({
@@ -40,7 +46,10 @@ export function ProviderModelSelect({
modelLabel = "Model",
providerPlaceholder = "Select provider",
modelPlaceholder = "Enter or select model",
showVerify = false,
showVerify,
savedProvider,
savedModel,
onSaveBlockedChange,
}: ProviderModelSelectProps) {
const { providers } = useProviders();
const enabledProviders = providers.filter((p) => p.enabled);
@@ -52,15 +61,21 @@ export function ProviderModelSelect({
const { models, loading: modelsLoading } = useProviderModels(selectedProviderId);
const { verify, verifying, result: verifyResult, reset: resetVerify } = useProviderVerify();
const hasSavedValues = savedProvider !== undefined && savedModel !== undefined;
const llmChanged = hasSavedValues && (provider !== savedProvider || model !== savedModel);
const shouldShowVerify = showVerify ?? llmChanged;
useEffect(() => {
resetVerify();
}, [provider, model, resetVerify]);
useEffect(() => {
onSaveBlockedChange?.(!!llmChanged && !verifyResult?.valid);
}, [llmChanged, verifyResult, onSaveBlockedChange]);
const handleProviderChange = (v: string) => {
onProviderChange(v);
onModelChange("");
resetVerify();
};
const handleModelChange = (v: string) => {
onModelChange(v);
resetVerify();
};
const handleVerify = async () => {
@@ -99,12 +114,12 @@ export function ProviderModelSelect({
<div className="flex-1">
<Combobox
value={model}
onChange={handleModelChange}
onChange={onModelChange}
options={models.map((m) => ({ value: m.id, label: m.name }))}
placeholder={modelsLoading ? "Loading models..." : modelPlaceholder}
/>
</div>
{showVerify && (
{shouldShowVerify && (
<Button
type="button"
variant="outline"
@@ -117,7 +132,7 @@ export function ProviderModelSelect({
</Button>
)}
</div>
{showVerify && verifyResult && (
{shouldShowVerify && verifyResult && (
<p className={`text-xs ${verifyResult.valid ? "text-success" : "text-destructive"}`}>
{verifyResult.valid ? "Model verified" : verifyResult.error || "Verification failed"}
</p>
@@ -0,0 +1,29 @@
import { useCallback } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useWsEvent } from "./use-ws-event";
import { Events } from "@/api/protocol";
import { queryKeys } from "@/lib/query-keys";
import type { AgentEventPayload } from "@/types/chat";
/**
* Listens to WebSocket events and invalidates relevant TanStack Query caches.
* Mount once at app level (e.g., in WsProvider or AppProviders).
*/
export function useWsQueryInvalidation() {
const queryClient = useQueryClient();
// When an agent run completes/fails → refresh sessions + traces
const handleAgentEvent = useCallback(
(payload: unknown) => {
const event = payload as AgentEventPayload;
if (!event) return;
if (event.type === "run.completed" || event.type === "run.failed") {
queryClient.invalidateQueries({ queryKey: queryKeys.sessions.all });
queryClient.invalidateQueries({ queryKey: queryKeys.traces.all });
}
},
[queryClient],
);
useWsEvent(Events.AGENT, handleAgentEvent);
}
+58
View File
@@ -0,0 +1,58 @@
export const queryKeys = {
providers: {
all: ["providers"] as const,
models: (providerId: string) => ["providers", providerId, "models"] as const,
},
agents: {
all: ["agents"] as const,
detail: (id: string) => ["agents", id] as const,
files: (agentKey: string) => ["agents", agentKey, "files"] as const,
links: (agentId: string) => ["agents", agentId, "links"] as const,
},
sessions: {
all: ["sessions"] as const,
list: (params: Record<string, unknown>) => ["sessions", params] as const,
},
traces: {
all: ["traces"] as const,
list: (params: Record<string, unknown>) => ["traces", params] as const,
},
customTools: {
all: ["customTools"] as const,
list: (params: Record<string, unknown>) => ["customTools", params] as const,
},
mcp: {
all: ["mcp"] as const,
},
channels: {
all: ["channels"] as const,
list: (params: Record<string, unknown>) => ["channels", params] as const,
},
skills: {
all: ["skills"] as const,
},
cron: {
all: ["cron"] as const,
},
builtinTools: {
all: ["builtinTools"] as const,
},
config: {
all: ["config"] as const,
},
tts: {
all: ["tts"] as const,
},
usage: {
all: ["usage"] as const,
records: (params: Record<string, unknown>) => ["usage", "records", params] as const,
},
delegations: {
all: ["delegations"] as const,
list: (params: Record<string, unknown>) => ["delegations", params] as const,
},
teams: {
all: ["teams"] as const,
detail: (id: string) => ["teams", id] as const,
},
};
@@ -1,18 +1,6 @@
import { useMemo, useEffect } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Combobox } from "@/components/ui/combobox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useProviders } from "@/pages/providers/hooks/use-providers";
import { useProviderModels } from "@/pages/providers/hooks/use-provider-models";
import { useProviderVerify } from "@/pages/providers/hooks/use-provider-verify";
import { ProviderModelSelect } from "@/components/shared/provider-model-select";
interface LlmConfigSectionProps {
provider: string;
@@ -42,97 +30,21 @@ export function LlmConfigSection({
savedModel,
onSaveBlockedChange,
}: LlmConfigSectionProps) {
const { providers } = useProviders();
const enabledProviders = providers.filter((p) => p.enabled);
const selectedProviderId = useMemo(
() => enabledProviders.find((p) => p.name === provider)?.id,
[enabledProviders, provider],
);
const { models, loading: modelsLoading } = useProviderModels(selectedProviderId);
const { verify, verifying, result: verifyResult, reset: resetVerify } = useProviderVerify();
const llmChanged = provider !== savedProvider || model !== savedModel;
useEffect(() => {
resetVerify();
}, [provider, model, resetVerify]);
// Report save-blocked status to parent
useEffect(() => {
onSaveBlockedChange?.(llmChanged && !verifyResult?.valid);
}, [llmChanged, verifyResult, onSaveBlockedChange]);
const handleVerify = async () => {
if (!selectedProviderId || !model.trim()) return;
await verify(selectedProviderId, model.trim());
};
return (
<section className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground">LLM Configuration</h3>
<div className="space-y-4 rounded-lg border p-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Provider</Label>
{enabledProviders.length > 0 ? (
<Select
value={provider}
onValueChange={(v) => {
onProviderChange(v);
onModelChange("");
}}
>
<SelectTrigger>
<SelectValue placeholder="Select provider" />
</SelectTrigger>
<SelectContent>
{enabledProviders.map((p) => (
<SelectItem key={p.name} value={p.name}>
{p.display_name || p.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={provider}
onChange={(e) => onProviderChange(e.target.value)}
placeholder="openrouter"
/>
)}
</div>
<div className="space-y-2">
<Label htmlFor="model">Model</Label>
<div className="flex gap-2">
<div className="flex-1">
<Combobox
value={model}
onChange={onModelChange}
options={models.map((m) => ({ value: m.id, label: m.name }))}
placeholder={modelsLoading ? "Loading models..." : "Enter or select model"}
/>
</div>
{llmChanged && (
<Button
type="button"
variant="outline"
size="sm"
className="h-9 px-3"
disabled={!selectedProviderId || !model.trim() || verifying}
onClick={handleVerify}
>
{verifying ? "..." : "Check"}
</Button>
)}
</div>
{verifyResult && (
<p className={`text-xs ${verifyResult.valid ? "text-success" : "text-destructive"}`}>
{verifyResult.valid ? "Model verified" : verifyResult.error || "Verification failed"}
</p>
)}
</div>
</div>
<ProviderModelSelect
provider={provider}
onProviderChange={onProviderChange}
model={model}
onModelChange={onModelChange}
savedProvider={savedProvider}
savedModel={savedModel}
onSaveBlockedChange={onSaveBlockedChange}
providerTip="LLM provider name. Must match a configured provider."
modelTip="Model ID to use."
/>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="contextWindow">Context Window</Label>
@@ -1,28 +1,32 @@
import { useState, useEffect, useCallback } from "react";
import { useCallback } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useHttp, useWs } from "@/hooks/use-ws";
import { Methods } from "@/api/protocol";
import { queryKeys } from "@/lib/query-keys";
import type { AgentData, BootstrapFile } from "@/types/agent";
interface AgentDetailData {
agent: AgentData;
files: BootstrapFile[];
}
export function useAgentDetail(agentId: string | undefined) {
const http = useHttp();
const ws = useWs();
const [agent, setAgent] = useState<AgentData | null>(null);
const [files, setFiles] = useState<BootstrapFile[]>([]);
const [loading, setLoading] = useState(false);
const queryClient = useQueryClient();
const load = useCallback(async () => {
if (!agentId) return;
setLoading(true);
try {
const { data, isLoading: loading } = useQuery({
queryKey: queryKeys.agents.detail(agentId ?? ""),
queryFn: async (): Promise<AgentDetailData> => {
// Try HTTP first (may fail with 403 if user isn't owner/shared)
let ag: AgentData | null = null;
let ag: AgentData;
try {
ag = await http.get<AgentData>(`/v1/agents/${agentId}`);
} catch {
// HTTP failed - construct minimal agent from agentId (which is the agent_key)
ag = {
id: agentId,
agent_key: agentId,
id: agentId!,
agent_key: agentId!,
owner_id: "",
provider: "",
model: "",
@@ -35,34 +39,41 @@ export function useAgentDetail(agentId: string | undefined) {
status: "active",
};
}
setAgent(ag);
// Load files via WS (no access control)
let files: BootstrapFile[] = [];
if (ws.isConnected) {
const filesRes = await ws.call<{ files: BootstrapFile[] }>(
Methods.AGENTS_FILES_LIST,
{ agentId: ag.agent_key },
);
setFiles(filesRes.files ?? []);
try {
const filesRes = await ws.call<{ files: BootstrapFile[] }>(
Methods.AGENTS_FILES_LIST,
{ agentId: ag.agent_key },
);
files = filesRes.files ?? [];
} catch {
// ignore
}
}
} catch {
// ignore
} finally {
setLoading(false);
}
}, [agentId, http, ws]);
useEffect(() => {
load();
}, [load]);
return { agent: ag, files };
},
enabled: !!agentId,
});
const agent = data?.agent ?? null;
const files = data?.files ?? [];
const invalidate = useCallback(() => {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId ?? "") });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.all });
}, [queryClient, agentId]);
const updateAgent = useCallback(
async (updates: Record<string, unknown>) => {
if (!agentId) return;
await http.put(`/v1/agents/${agentId}`, updates);
load();
await invalidate();
},
[agentId, http, load],
[agentId, http, invalidate],
);
const getFile = useCallback(
@@ -85,9 +96,9 @@ export function useAgentDetail(agentId: string | undefined) {
name,
content,
});
load();
await invalidate();
},
[agent, ws, load],
[agent, ws, invalidate],
);
const regenerateAgent = useCallback(
@@ -103,5 +114,5 @@ export function useAgentDetail(agentId: string | undefined) {
await http.post(`/v1/agents/${agentId}/resummon`);
}, [agentId, http]);
return { agent, files, loading, updateAgent, getFile, setFile, regenerateAgent, resummonAgent, refresh: load };
return { agent, files, loading, updateAgent, getFile, setFile, regenerateAgent, resummonAgent, refresh: invalidate };
}
+31 -40
View File
@@ -1,7 +1,9 @@
import { useState, useEffect, useCallback } from "react";
import { useCallback } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useWs, useHttp } from "@/hooks/use-ws";
import { useAuthStore } from "@/stores/use-auth-store";
import { Methods } from "@/api/protocol";
import { queryKeys } from "@/lib/query-keys";
import type { AgentData } from "@/types/agent";
interface AgentInfoWs {
@@ -14,33 +16,25 @@ export function useAgents() {
const ws = useWs();
const http = useHttp();
const connected = useAuthStore((s) => s.connected);
const [agents, setAgents] = useState<AgentData[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const queryClient = useQueryClient();
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const { data: agents = [], isLoading: loading, error: queryError } = useQuery({
queryKey: queryKeys.agents.all,
queryFn: async () => {
// Try HTTP first (returns full agent data, filtered by user access)
const res = await http.get<{ agents: AgentData[] }>("/v1/agents");
if (res.agents && res.agents.length > 0) {
setAgents(res.agents);
setLoading(false);
return;
try {
const res = await http.get<{ agents: AgentData[] }>("/v1/agents");
if (res.agents && res.agents.length > 0) {
return res.agents;
}
} catch {
// HTTP may fail if user doesn't have access - fall through to WS
}
} catch {
// HTTP may fail if user doesn't have access - fall through to WS
}
// Fallback: WS agents.list returns all running agents (no access filter)
try {
if (!ws.isConnected) {
setLoading(false);
return;
}
// Fallback: WS agents.list returns all running agents (no access filter)
if (!ws.isConnected) return [];
const res = await ws.call<{ agents: AgentInfoWs[] }>(Methods.AGENTS_LIST);
const wsAgents: AgentData[] = (res.agents ?? []).map((a) => ({
return (res.agents ?? []).map((a): AgentData => ({
id: a.id,
agent_key: a.id,
owner_id: "",
@@ -54,36 +48,33 @@ export function useAgents() {
is_default: false,
status: a.isRunning ? "running" : "idle",
}));
setAgents(wsAgents);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load agents");
} finally {
setLoading(false);
}
}, [http, ws]);
},
enabled: connected,
});
useEffect(() => {
if (connected) {
load();
}
}, [connected, load]);
const error = queryError instanceof Error ? queryError.message : queryError ? "Failed to load agents" : null;
const invalidate = useCallback(
() => queryClient.invalidateQueries({ queryKey: queryKeys.agents.all }),
[queryClient],
);
const createAgent = useCallback(
async (data: Partial<AgentData>) => {
const res = await http.post<AgentData>("/v1/agents", data);
setAgents((prev) => [...prev, res]);
await invalidate();
return res;
},
[http],
[http, invalidate],
);
const deleteAgent = useCallback(
async (id: string) => {
await http.delete(`/v1/agents/${id}`);
setAgents((prev) => prev.filter((a) => a.id !== id));
await invalidate();
},
[http],
[http, invalidate],
);
return { agents, loading, error, refresh: load, createAgent, deleteAgent };
return { agents, loading, error, refresh: invalidate, createAgent, deleteAgent };
}
@@ -1,5 +1,7 @@
import { useState, useEffect, useCallback } from "react";
import { useCallback } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useHttp } from "@/hooks/use-ws";
import { queryKeys } from "@/lib/query-keys";
export interface BuiltinToolData {
name: string;
@@ -16,32 +18,28 @@ export interface BuiltinToolData {
export function useBuiltinTools() {
const http = useHttp();
const [tools, setTools] = useState<BuiltinToolData[]>([]);
const [loading, setLoading] = useState(false);
const queryClient = useQueryClient();
const load = useCallback(async () => {
setLoading(true);
try {
const { data: tools = [], isLoading: loading } = useQuery({
queryKey: queryKeys.builtinTools.all,
queryFn: async () => {
const res = await http.get<{ tools: BuiltinToolData[] }>("/v1/tools/builtin");
setTools(res.tools ?? []);
} catch {
// ignore
} finally {
setLoading(false);
}
}, [http]);
return res.tools ?? [];
},
});
useEffect(() => {
load();
}, [load]);
const invalidate = useCallback(
() => queryClient.invalidateQueries({ queryKey: queryKeys.builtinTools.all }),
[queryClient],
);
const updateTool = useCallback(
async (name: string, data: { enabled?: boolean; settings?: Record<string, unknown> }) => {
await http.put(`/v1/tools/builtin/${name}`, data);
await load();
await invalidate();
},
[http, load],
[http, invalidate],
);
return { tools, loading, refresh: load, updateTool };
return { tools, loading, refresh: invalidate, updateTool };
}
+41 -31
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useRef } from "react";
import { Radio, Plus, RefreshCw, Pencil, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@@ -15,25 +15,46 @@ import { ChannelsStatusView, channelTypeLabels } from "./channels-status-view";
import { useAgents } from "@/pages/agents/hooks/use-agents";
import { useMinLoading } from "@/hooks/use-min-loading";
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
import { usePagination } from "@/hooks/use-pagination";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
export function ChannelsPage() {
const { channels, loading: statusLoading, refresh: refreshStatus } = useChannels();
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [formOpen, setFormOpen] = useState(false);
const [editInstance, setEditInstance] = useState<ChannelInstanceData | null>(null);
const [deleteTarget, setDeleteTarget] = useState<ChannelInstanceData | null>(null);
const [deleteLoading, setDeleteLoading] = useState(false);
const pendingSearchRef = useRef("");
const flushSearch = useDebouncedCallback(() => {
setDebouncedSearch(pendingSearchRef.current);
setPage(1);
}, 300);
const handleSearchChange = (v: string) => {
setSearch(v);
pendingSearchRef.current = v;
flushSearch();
};
const {
instances, loading: instancesLoading, supported,
instances, total, loading: instancesLoading, supported,
refresh: refreshInstances, createInstance, updateInstance, deleteInstance,
} = useChannelInstances();
} = useChannelInstances({
search: debouncedSearch || undefined,
limit: pageSize,
offset: (page - 1) * pageSize,
});
const { agents } = useAgents();
const loading = statusLoading || instancesLoading;
const spinning = useMinLoading(loading);
const showSkeleton = useDeferredLoading(loading && instances.length === 0);
const [search, setSearch] = useState("");
const [formOpen, setFormOpen] = useState(false);
const [editInstance, setEditInstance] = useState<ChannelInstanceData | null>(null);
const [deleteTarget, setDeleteTarget] = useState<ChannelInstanceData | null>(null);
const [deleteLoading, setDeleteLoading] = useState(false);
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const refresh = () => {
refreshStatus();
@@ -45,17 +66,6 @@ export function ChannelsPage() {
return <ChannelsStatusView channels={channels} loading={statusLoading} spinning={spinning} refresh={refreshStatus} />;
}
const filtered = instances.filter(
(inst) =>
inst.name.toLowerCase().includes(search.toLowerCase()) ||
(inst.display_name || "").toLowerCase().includes(search.toLowerCase()) ||
inst.channel_type.toLowerCase().includes(search.toLowerCase()),
);
const { pageItems, pagination, setPage, setPageSize, resetPage } = usePagination(filtered);
useEffect(() => { resetPage(); }, [search, resetPage]);
const handleCreate = async (data: ChannelInstanceInput) => {
await createInstance(data);
};
@@ -105,7 +115,7 @@ export function ChannelsPage() {
<div className="mt-4">
<SearchInput
value={search}
onChange={setSearch}
onChange={handleSearchChange}
placeholder="Search channels..."
className="max-w-sm"
/>
@@ -114,11 +124,11 @@ export function ChannelsPage() {
<div className="mt-4">
{showSkeleton ? (
<TableSkeleton rows={5} />
) : filtered.length === 0 ? (
) : instances.length === 0 ? (
<EmptyState
icon={Radio}
title={search ? "No matching channels" : "No channels"}
description={search ? "Try a different search term." : "Add your first channel instance to get started."}
title={debouncedSearch ? "No matching channels" : "No channels"}
description={debouncedSearch ? "Try a different search term." : "Add your first channel instance to get started."}
/>
) : (
<div className="rounded-md border">
@@ -134,7 +144,7 @@ export function ChannelsPage() {
</tr>
</thead>
<tbody>
{pageItems.map((inst) => {
{instances.map((inst) => {
const status = getStatus(inst.name);
return (
<tr key={inst.id} className="border-b last:border-0 hover:bg-muted/30">
@@ -203,12 +213,12 @@ export function ChannelsPage() {
</tbody>
</table>
<Pagination
page={pagination.page}
pageSize={pagination.pageSize}
total={pagination.total}
totalPages={pagination.totalPages}
page={page}
pageSize={pageSize}
total={total}
totalPages={totalPages}
onPageChange={setPage}
onPageSizeChange={setPageSize}
onPageSizeChange={(size) => { setPageSize(size); setPage(1); }}
/>
</div>
)}
@@ -1,80 +1,79 @@
import { useState, useEffect, useCallback } from "react";
import { useState, useCallback } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useHttp } from "@/hooks/use-ws";
import { queryKeys } from "@/lib/query-keys";
import type { ChannelInstanceData, ChannelInstanceInput } from "@/types/channel";
export interface ChannelInstanceData {
id: string;
name: string;
display_name: string;
channel_type: string;
agent_id: string;
config: Record<string, unknown> | null;
enabled: boolean;
is_default: boolean;
created_by: string;
created_at: string;
updated_at: string;
export type { ChannelInstanceData, ChannelInstanceInput };
export interface ChannelInstanceFilters {
search?: string;
limit?: number;
offset?: number;
}
export interface ChannelInstanceInput {
name: string;
display_name?: string;
channel_type: string;
agent_id: string;
credentials?: Record<string, unknown>; // JSON object, encrypted server-side
config?: Record<string, unknown>;
enabled?: boolean;
}
export function useChannelInstances() {
export function useChannelInstances(filters: ChannelInstanceFilters = {}) {
const http = useHttp();
const [instances, setInstances] = useState<ChannelInstanceData[]>([]);
const [loading, setLoading] = useState(false);
const queryClient = useQueryClient();
const [supported, setSupported] = useState(true); // false if standalone mode (404)
const load = useCallback(async () => {
setLoading(true);
try {
const res = await http.get<{ instances: ChannelInstanceData[] }>("/v1/channels/instances");
setInstances(res.instances ?? []);
setSupported(true);
} catch (err: unknown) {
// 404 means standalone mode — channel instances not available
if (err instanceof Error && err.message.includes("404")) {
setSupported(false);
}
} finally {
setLoading(false);
}
}, [http]);
const queryKey = queryKeys.channels.list({ ...filters });
useEffect(() => {
load();
}, [load]);
const { data, isLoading: loading } = useQuery({
queryKey,
queryFn: async () => {
try {
const params: Record<string, string> = {};
if (filters.search) params.search = filters.search;
if (filters.limit) params.limit = String(filters.limit);
if (filters.offset !== undefined) params.offset = String(filters.offset);
const res = await http.get<{ instances: ChannelInstanceData[]; total?: number }>("/v1/channels/instances", params);
setSupported(true);
return { instances: res.instances ?? [], total: res.total ?? 0 };
} catch (err: unknown) {
// 404 means standalone mode — channel instances not available
if (err instanceof Error && err.message.includes("404")) {
setSupported(false);
}
return { instances: [], total: 0 };
}
},
placeholderData: (prev) => prev,
});
const instances = data?.instances ?? [];
const total = data?.total ?? 0;
const invalidate = useCallback(
() => queryClient.invalidateQueries({ queryKey: queryKeys.channels.all }),
[queryClient],
);
const createInstance = useCallback(
async (data: ChannelInstanceInput) => {
const res = await http.post<{ id: string }>("/v1/channels/instances", data);
await load();
await invalidate();
return res;
},
[http, load],
[http, invalidate],
);
const updateInstance = useCallback(
async (id: string, data: Partial<ChannelInstanceInput>) => {
await http.put(`/v1/channels/instances/${id}`, data);
await load();
await invalidate();
},
[http, load],
[http, invalidate],
);
const deleteInstance = useCallback(
async (id: string) => {
await http.delete(`/v1/channels/instances/${id}`);
await load();
await invalidate();
},
[http, load],
[http, invalidate],
);
return { instances, loading, supported, refresh: load, createInstance, updateInstance, deleteInstance };
return { instances, total, loading, supported, refresh: invalidate, createInstance, updateInstance, deleteInstance };
}
+35 -33
View File
@@ -1,38 +1,40 @@
import { useState, useEffect, useCallback } from "react";
import { useState, useCallback, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useWs } from "@/hooks/use-ws";
import { Methods } from "@/api/protocol";
import { queryKeys } from "@/lib/query-keys";
interface ConfigData {
config: Record<string, unknown>;
hash: string;
path: string;
}
export function useConfig() {
const ws = useWs();
const [config, setConfig] = useState<Record<string, unknown> | null>(null);
const [hash, setHash] = useState("");
const [configPath, setConfigPath] = useState("");
const [loading, setLoading] = useState(false);
const queryClient = useQueryClient();
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const hashRef = useRef("");
const load = useCallback(async () => {
if (!ws.isConnected) return;
setLoading(true);
try {
const res = await ws.call<{
config: Record<string, unknown>;
hash: string;
path: string;
}>(Methods.CONFIG_GET);
setConfig(res.config);
setHash(res.hash);
setConfigPath(res.path);
} catch {
// ignore
} finally {
setLoading(false);
}
}, [ws]);
const { data, isLoading: loading } = useQuery({
queryKey: queryKeys.config.all,
queryFn: async (): Promise<ConfigData> => {
if (!ws.isConnected) return { config: {}, hash: "", path: "" };
const res = await ws.call<ConfigData>(Methods.CONFIG_GET);
hashRef.current = res.hash;
return res;
},
});
useEffect(() => {
load();
}, [load]);
const config = data?.config ?? null;
const hash = data?.hash ?? "";
const configPath = data?.path ?? "";
const invalidate = useCallback(
() => queryClient.invalidateQueries({ queryKey: queryKeys.config.all }),
[queryClient],
);
const applyRaw = useCallback(
async (raw: string) => {
@@ -41,10 +43,10 @@ export function useConfig() {
try {
const res = await ws.call<{ hash: string }>(Methods.CONFIG_APPLY, {
raw,
baseHash: hash,
baseHash: hashRef.current,
});
setHash(res.hash);
load();
hashRef.current = res.hash;
await invalidate();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to apply config");
throw err;
@@ -52,7 +54,7 @@ export function useConfig() {
setSaving(false);
}
},
[ws, hash, load],
[ws, invalidate],
);
const patch = useCallback(
@@ -61,7 +63,7 @@ export function useConfig() {
setError(null);
try {
await ws.call(Methods.CONFIG_PATCH, updates);
load();
await invalidate();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to patch config");
throw err;
@@ -69,8 +71,8 @@ export function useConfig() {
setSaving(false);
}
},
[ws, load],
[ws, invalidate],
);
return { config, hash, configPath, loading, saving, error, refresh: load, applyRaw, patch };
return { config, hash, configPath, loading, saving, error, refresh: invalidate, applyRaw, patch };
}
+22 -24
View File
@@ -1,6 +1,8 @@
import { useState, useEffect, useCallback } from "react";
import { useCallback } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useWs } from "@/hooks/use-ws";
import { Methods } from "@/api/protocol";
import { queryKeys } from "@/lib/query-keys";
export interface CronSchedule {
kind: "at" | "every" | "cron";
@@ -46,27 +48,23 @@ export interface CronRunLogEntry {
export function useCron() {
const ws = useWs();
const [jobs, setJobs] = useState<CronJob[]>([]);
const [loading, setLoading] = useState(false);
const queryClient = useQueryClient();
const load = useCallback(async () => {
if (!ws.isConnected) return;
setLoading(true);
try {
const { data: jobs = [], isLoading: loading } = useQuery({
queryKey: queryKeys.cron.all,
queryFn: async () => {
if (!ws.isConnected) return [];
const res = await ws.call<{ jobs: CronJob[] }>(Methods.CRON_LIST, {
includeDisabled: true,
});
setJobs(res.jobs ?? []);
} catch {
// ignore
} finally {
setLoading(false);
}
}, [ws]);
return res.jobs ?? [];
},
});
useEffect(() => {
load();
}, [load]);
const invalidate = useCallback(
() => queryClient.invalidateQueries({ queryKey: queryKeys.cron.all }),
[queryClient],
);
const createJob = useCallback(
async (params: {
@@ -79,25 +77,25 @@ export function useCron() {
to?: string;
}) => {
await ws.call(Methods.CRON_CREATE, params);
load();
await invalidate();
},
[ws, load],
[ws, invalidate],
);
const toggleJob = useCallback(
async (jobId: string, enabled: boolean) => {
await ws.call(Methods.CRON_TOGGLE, { jobId, enabled });
load();
await invalidate();
},
[ws, load],
[ws, invalidate],
);
const deleteJob = useCallback(
async (jobId: string) => {
await ws.call(Methods.CRON_DELETE, { jobId });
load();
await invalidate();
},
[ws, load],
[ws, invalidate],
);
const runJob = useCallback(
@@ -119,5 +117,5 @@ export function useCron() {
[ws],
);
return { jobs, loading, refresh: load, createJob, toggleJob, deleteJob, runJob, getRunLog };
return { jobs, loading, refresh: invalidate, createJob, toggleJob, deleteJob, runJob, getRunLog };
}
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState } from "react";
import { Wrench, Plus, RefreshCw, Pencil, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@@ -12,27 +12,39 @@ import { useCustomTools, type CustomToolData, type CustomToolInput } from "./hoo
import { CustomToolFormDialog } from "./custom-tool-form-dialog";
import { useMinLoading } from "@/hooks/use-min-loading";
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
import { usePagination } from "@/hooks/use-pagination";
import { useRef } from "react";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
export function CustomToolsPage() {
const { tools, loading, refresh, createTool, updateTool, deleteTool } = useCustomTools();
const spinning = useMinLoading(loading);
const showSkeleton = useDeferredLoading(loading && tools.length === 0);
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [formOpen, setFormOpen] = useState(false);
const [editTool, setEditTool] = useState<CustomToolData | null>(null);
const [deleteTarget, setDeleteTarget] = useState<CustomToolData | null>(null);
const [deleteLoading, setDeleteLoading] = useState(false);
const filtered = tools.filter(
(t) =>
t.name.toLowerCase().includes(search.toLowerCase()) ||
t.description.toLowerCase().includes(search.toLowerCase()),
);
const pendingSearchRef = useRef("");
const flushSearch = useDebouncedCallback(() => {
setDebouncedSearch(pendingSearchRef.current);
setPage(1);
}, 300);
const { pageItems, pagination, setPage, setPageSize, resetPage } = usePagination(filtered);
const handleSearchChange = (v: string) => {
setSearch(v);
pendingSearchRef.current = v;
flushSearch();
};
useEffect(() => { resetPage(); }, [search, resetPage]);
const { tools, total, loading, refresh, createTool, updateTool, deleteTool } = useCustomTools({
search: debouncedSearch || undefined,
limit: pageSize,
offset: (page - 1) * pageSize,
});
const spinning = useMinLoading(loading);
const showSkeleton = useDeferredLoading(loading && tools.length === 0);
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const handleCreate = async (data: CustomToolInput) => {
await createTool(data);
@@ -74,7 +86,7 @@ export function CustomToolsPage() {
<div className="mt-4">
<SearchInput
value={search}
onChange={setSearch}
onChange={handleSearchChange}
placeholder="Search tools..."
className="max-w-sm"
/>
@@ -83,11 +95,11 @@ export function CustomToolsPage() {
<div className="mt-4">
{showSkeleton ? (
<TableSkeleton rows={5} />
) : filtered.length === 0 ? (
) : tools.length === 0 ? (
<EmptyState
icon={Wrench}
title={search ? "No matching tools" : "No custom tools"}
description={search ? "Try a different search term." : "Create your first custom tool to get started."}
title={debouncedSearch ? "No matching tools" : "No custom tools"}
description={debouncedSearch ? "Try a different search term." : "Create your first custom tool to get started."}
/>
) : (
<div className="rounded-md border">
@@ -103,7 +115,7 @@ export function CustomToolsPage() {
</tr>
</thead>
<tbody>
{pageItems.map((tool) => (
{tools.map((tool) => (
<tr key={tool.id} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
@@ -150,12 +162,12 @@ export function CustomToolsPage() {
</tbody>
</table>
<Pagination
page={pagination.page}
pageSize={pagination.pageSize}
total={pagination.total}
totalPages={pagination.totalPages}
page={page}
pageSize={pageSize}
total={total}
totalPages={totalPages}
onPageChange={setPage}
onPageSizeChange={setPageSize}
onPageSizeChange={(size) => { setPageSize(size); setPage(1); }}
/>
</div>
)}
@@ -1,77 +1,69 @@
import { useState, useEffect, useCallback } from "react";
import { useCallback } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useHttp } from "@/hooks/use-ws";
import { queryKeys } from "@/lib/query-keys";
import type { CustomToolData, CustomToolInput } from "@/types/custom-tool";
export interface CustomToolData {
id: string;
name: string;
description: string;
parameters: Record<string, unknown> | null;
command: string;
working_dir: string;
timeout_seconds: number;
agent_id: string | null;
enabled: boolean;
created_by: string;
created_at: string;
updated_at: string;
export type { CustomToolData, CustomToolInput };
export interface CustomToolFilters {
search?: string;
limit?: number;
offset?: number;
}
export interface CustomToolInput {
name: string;
description?: string;
parameters?: Record<string, unknown>;
command: string;
working_dir?: string;
timeout_seconds?: number;
agent_id?: string;
enabled?: boolean;
}
export function useCustomTools() {
export function useCustomTools(filters: CustomToolFilters = {}) {
const http = useHttp();
const [tools, setTools] = useState<CustomToolData[]>([]);
const [loading, setLoading] = useState(false);
const queryClient = useQueryClient();
const load = useCallback(async () => {
setLoading(true);
try {
const res = await http.get<{ tools: CustomToolData[] }>("/v1/tools/custom");
setTools(res.tools ?? []);
} catch {
// ignore
} finally {
setLoading(false);
}
}, [http]);
const queryKey = queryKeys.customTools.list({ ...filters });
useEffect(() => {
load();
}, [load]);
const { data, isLoading: loading } = useQuery({
queryKey,
queryFn: async () => {
const params: Record<string, string> = {};
if (filters.search) params.search = filters.search;
if (filters.limit) params.limit = String(filters.limit);
if (filters.offset !== undefined) params.offset = String(filters.offset);
const res = await http.get<{ tools: CustomToolData[]; total?: number }>("/v1/tools/custom", params);
return { tools: res.tools ?? [], total: res.total ?? 0 };
},
placeholderData: (prev) => prev,
});
const tools = data?.tools ?? [];
const total = data?.total ?? 0;
const invalidate = useCallback(
() => queryClient.invalidateQueries({ queryKey: queryKeys.customTools.all }),
[queryClient],
);
const createTool = useCallback(
async (data: CustomToolInput) => {
const res = await http.post<{ id: string }>("/v1/tools/custom", data);
await load();
await invalidate();
return res;
},
[http, load],
[http, invalidate],
);
const updateTool = useCallback(
async (id: string, data: Partial<CustomToolInput>) => {
await http.put(`/v1/tools/custom/${id}`, data);
await load();
await invalidate();
},
[http, load],
[http, invalidate],
);
const deleteTool = useCallback(
async (id: string) => {
await http.delete(`/v1/tools/custom/${id}`);
await load();
await invalidate();
},
[http, load],
[http, invalidate],
);
return { tools, loading, refresh: load, createTool, updateTool, deleteTool };
return { tools, total, loading, refresh: invalidate, createTool, updateTool, deleteTool };
}
+46 -6
View File
@@ -1,4 +1,5 @@
import { useState } from "react";
import { AlertCircle } from "lucide-react";
interface TokenFormProps {
onSubmit: (userId: string, token: string) => void;
@@ -7,11 +8,41 @@ interface TokenFormProps {
export function TokenForm({ onSubmit }: TokenFormProps) {
const [userId, setUserId] = useState("");
const [token, setToken] = useState("");
const [connecting, setConnecting] = useState(false);
const [error, setError] = useState<string | null>(null);
function handleSubmit(e: React.FormEvent) {
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!token.trim() || !userId.trim()) return;
onSubmit(userId.trim(), token.trim());
setConnecting(true);
setError(null);
try {
// Verify connectivity and credentials before navigating
const res = await fetch("/v1/agents", {
headers: {
Authorization: `Bearer ${token.trim()}`,
"X-GoClaw-User-Id": userId.trim(),
},
});
if (res.status === 401) {
setError("Invalid credentials. Check your token and user ID.");
return;
}
if (!res.ok) {
setError(`Server returned ${res.status}. Please try again.`);
return;
}
onSubmit(userId.trim(), token.trim());
} catch {
setError("Cannot connect to gateway. Check if the server is running.");
} finally {
setConnecting(false);
}
}
return (
@@ -24,10 +55,11 @@ export function TokenForm({ onSubmit }: TokenFormProps) {
id="userId"
type="text"
value={userId}
onChange={(e) => setUserId(e.target.value)}
onChange={(e) => { setUserId(e.target.value); setError(null); }}
placeholder="your-user-id"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
autoFocus
disabled={connecting}
/>
</div>
@@ -39,18 +71,26 @@ export function TokenForm({ onSubmit }: TokenFormProps) {
id="token"
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
onChange={(e) => { setToken(e.target.value); setError(null); }}
placeholder="Bearer token"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
disabled={connecting}
/>
</div>
{error && (
<div className="flex items-start gap-2 rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
)}
<button
type="submit"
disabled={!token.trim() || !userId.trim()}
disabled={!token.trim() || !userId.trim() || connecting}
className="inline-flex h-9 w-full items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90 disabled:pointer-events-none disabled:opacity-50"
>
Connect
{connecting ? "Connecting..." : "Connect"}
</button>
</form>
);
+23 -65
View File
@@ -1,98 +1,56 @@
import { useState, useEffect, useCallback } from "react";
import { useCallback } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useHttp } from "@/hooks/use-ws";
import { queryKeys } from "@/lib/query-keys";
import type { MCPServerData, MCPServerInput, MCPAgentGrant } from "@/types/mcp";
export interface MCPServerData {
id: string;
name: string;
display_name: string;
transport: "stdio" | "sse" | "streamable-http";
command: string;
args: string[] | null;
url: string;
headers: Record<string, string> | null;
env: Record<string, string> | null;
tool_prefix: string;
timeout_sec: number;
enabled: boolean;
created_by: string;
created_at: string;
updated_at: string;
}
export interface MCPServerInput {
name: string;
display_name?: string;
transport: string;
command?: string;
args?: string[];
url?: string;
headers?: Record<string, string>;
tool_prefix?: string;
timeout_sec?: number;
enabled?: boolean;
}
export interface MCPAgentGrant {
id: string;
server_id: string;
agent_id: string;
enabled: boolean;
tool_allow: string[] | null;
tool_deny: string[] | null;
granted_by: string;
created_at: string;
}
export type { MCPServerData, MCPServerInput, MCPAgentGrant };
export function useMCP() {
const http = useHttp();
const [servers, setServers] = useState<MCPServerData[]>([]);
const [loading, setLoading] = useState(false);
const queryClient = useQueryClient();
const load = useCallback(async () => {
setLoading(true);
try {
const { data: servers = [], isLoading: loading } = useQuery({
queryKey: queryKeys.mcp.all,
queryFn: async () => {
const res = await http.get<{ servers: MCPServerData[] }>("/v1/mcp/servers");
setServers(res.servers ?? []);
} catch {
// ignore
} finally {
setLoading(false);
}
}, [http]);
return res.servers ?? [];
},
});
useEffect(() => {
load();
}, [load]);
const invalidate = useCallback(
() => queryClient.invalidateQueries({ queryKey: queryKeys.mcp.all }),
[queryClient],
);
const createServer = useCallback(
async (data: MCPServerInput) => {
const res = await http.post<MCPServerData>("/v1/mcp/servers", data);
await load();
await invalidate();
return res;
},
[http, load],
[http, invalidate],
);
const updateServer = useCallback(
async (id: string, data: Partial<MCPServerInput>) => {
await http.put(`/v1/mcp/servers/${id}`, data);
await load();
await invalidate();
},
[http, load],
[http, invalidate],
);
const deleteServer = useCallback(
async (id: string) => {
await http.delete(`/v1/mcp/servers/${id}`);
await load();
await invalidate();
},
[http, load],
[http, invalidate],
);
const listAgentGrants = useCallback(
async (_serverId: string) => {
// Backend provides per-agent listing, not per-server.
// Grants dialog handles this by accepting agent IDs.
return [] as MCPAgentGrant[];
},
[],
@@ -127,7 +85,7 @@ export function useMCP() {
return {
servers,
loading,
refresh: load,
refresh: invalidate,
createServer,
updateServer,
deleteServer,
@@ -1,40 +1,23 @@
import { useState, useEffect, useCallback } from "react";
import { useQuery } from "@tanstack/react-query";
import { useHttp } from "@/hooks/use-ws";
import { queryKeys } from "@/lib/query-keys";
import type { ModelInfo } from "@/types/provider";
export interface ModelInfo {
id: string;
name?: string;
}
export type { ModelInfo };
export function useProviderModels(providerId: string | undefined) {
const http = useHttp();
const [models, setModels] = useState<ModelInfo[]>([]);
const [loading, setLoading] = useState(false);
const load = useCallback(
async (id: string) => {
setLoading(true);
try {
const res = await http.get<{ models: ModelInfo[] }>(
`/v1/providers/${id}/models`,
);
setModels(res.models ?? []);
} catch {
setModels([]);
} finally {
setLoading(false);
}
const { data: models = [], isLoading: loading } = useQuery({
queryKey: queryKeys.providers.models(providerId ?? ""),
queryFn: async () => {
const res = await http.get<{ models: ModelInfo[] }>(
`/v1/providers/${providerId}/models`,
);
return res.models ?? [];
},
[http],
);
useEffect(() => {
if (!providerId) {
setModels([]);
return;
}
load(providerId);
}, [providerId, load]);
enabled: !!providerId,
});
return { models, loading };
}
@@ -1,77 +1,57 @@
import { useState, useEffect, useCallback } from "react";
import { useCallback } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useHttp } from "@/hooks/use-ws";
import { queryKeys } from "@/lib/query-keys";
import type { ProviderData, ProviderInput } from "@/types/provider";
export interface ProviderData {
id: string;
name: string;
display_name: string;
provider_type: string;
api_base: string;
api_key: string; // masked "***" from server
enabled: boolean;
created_at: string;
updated_at: string;
}
export interface ProviderInput {
name: string;
display_name?: string;
provider_type: string;
api_base?: string;
api_key?: string;
enabled?: boolean;
}
export type { ProviderData, ProviderInput };
export function useProviders() {
const http = useHttp();
const [providers, setProviders] = useState<ProviderData[]>([]);
const [loading, setLoading] = useState(false);
const queryClient = useQueryClient();
const load = useCallback(async () => {
setLoading(true);
try {
const { data: providers = [], isLoading: loading } = useQuery({
queryKey: queryKeys.providers.all,
queryFn: async () => {
const res = await http.get<{ providers: ProviderData[] }>("/v1/providers");
setProviders(res.providers ?? []);
} catch {
// ignore
} finally {
setLoading(false);
}
}, [http]);
return res.providers ?? [];
},
});
useEffect(() => {
load();
}, [load]);
const invalidate = useCallback(
() => queryClient.invalidateQueries({ queryKey: queryKeys.providers.all }),
[queryClient],
);
const createProvider = useCallback(
async (data: ProviderInput) => {
const res = await http.post<ProviderData>("/v1/providers", data);
await load();
await invalidate();
return res;
},
[http, load],
[http, invalidate],
);
const updateProvider = useCallback(
async (id: string, data: Partial<ProviderInput>) => {
await http.put(`/v1/providers/${id}`, data);
await load();
await invalidate();
},
[http, load],
[http, invalidate],
);
const deleteProvider = useCallback(
async (id: string) => {
await http.delete(`/v1/providers/${id}`);
await load();
await invalidate();
},
[http, load],
[http, invalidate],
);
return {
providers,
loading,
refresh: load,
refresh: invalidate,
createProvider,
updateProvider,
deleteProvider,
+38 -48
View File
@@ -1,54 +1,44 @@
import { useState, useEffect, useCallback } from "react";
import { useCallback } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useWs } from "@/hooks/use-ws";
import { useWsEvent } from "@/hooks/use-ws-event";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import { Methods, Events } from "@/api/protocol";
import { Methods } from "@/api/protocol";
import { queryKeys } from "@/lib/query-keys";
import type { SessionInfo, SessionPreview, Message } from "@/types/session";
import type { AgentEventPayload } from "@/types/chat";
export function useSessions(agentFilter?: string) {
interface UseSessionsOptions {
agentFilter?: string;
limit?: number;
offset?: number;
}
export function useSessions(opts: UseSessionsOptions = {}) {
const ws = useWs();
const [sessions, setSessions] = useState<SessionInfo[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const queryClient = useQueryClient();
const { agentFilter, limit, offset } = opts;
const load = useCallback(async (opts?: { limit?: number; offset?: number }) => {
if (!ws.isConnected) return;
setLoading(true);
try {
const queryKey = queryKeys.sessions.list({ agentFilter, limit, offset });
const { data, isLoading: loading } = useQuery({
queryKey,
queryFn: async () => {
if (!ws.isConnected) return { sessions: [] as SessionInfo[], total: 0 };
const res = await ws.call<{ sessions: SessionInfo[]; total?: number }>(Methods.SESSIONS_LIST, {
agentId: agentFilter || undefined,
limit: opts?.limit,
offset: opts?.offset,
limit,
offset,
});
setSessions(res.sessions ?? []);
setTotal(res.total ?? 0);
} catch {
// ignore
} finally {
setLoading(false);
}
}, [ws, agentFilter]);
useEffect(() => {
load();
}, [load]);
// Auto-refresh when any agent run completes (from any channel)
const debouncedRefresh = useDebouncedCallback(load, 2000);
const handleAgentEvent = useCallback(
(payload: unknown) => {
const event = payload as AgentEventPayload;
if (!event) return;
if (event.type === "run.completed" || event.type === "run.failed") {
debouncedRefresh();
}
return { sessions: res.sessions ?? [], total: res.total ?? 0 };
},
[debouncedRefresh],
);
placeholderData: (prev) => prev,
});
useWsEvent(Events.AGENT, handleAgentEvent);
const sessions = data?.sessions ?? [];
const total = data?.total ?? 0;
const invalidate = useCallback(
() => queryClient.invalidateQueries({ queryKey: queryKeys.sessions.all }),
[queryClient],
);
const preview = useCallback(
async (key: string): Promise<SessionPreview | null> => {
@@ -66,28 +56,28 @@ export function useSessions(agentFilter?: string) {
async (key: string) => {
if (!ws.isConnected) return;
await ws.call(Methods.SESSIONS_DELETE, { key });
setSessions((prev) => prev.filter((s) => s.key !== key));
await invalidate();
},
[ws],
[ws, invalidate],
);
const resetSession = useCallback(
async (key: string) => {
if (!ws.isConnected) return;
await ws.call(Methods.SESSIONS_RESET, { key });
load();
await invalidate();
},
[ws, load],
[ws, invalidate],
);
const patchSession = useCallback(
async (key: string, updates: { label?: string; model?: string }) => {
if (!ws.isConnected) return;
await ws.call(Methods.SESSIONS_PATCH, { key, ...updates });
load();
await invalidate();
},
[ws, load],
[ws, invalidate],
);
return { sessions, total, loading, refresh: load, preview, deleteSession, resetSession, patchSession };
return { sessions, total, loading, refresh: invalidate, preview, deleteSession, resetSession, patchSession };
}
+7 -7
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState } from "react";
import { useParams, useNavigate } from "react-router";
import { History } from "lucide-react";
import { PageHeader } from "@/components/shared/page-header";
@@ -17,17 +17,17 @@ import type { SessionInfo } from "@/types/session";
export function SessionsPage() {
const { key: detailKey } = useParams<{ key: string }>();
const navigate = useNavigate();
const { sessions, total, loading, refresh, preview, deleteSession, resetSession } = useSessions();
const showSkeleton = useDeferredLoading(loading && sessions.length === 0);
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const { sessions, total, loading, preview, deleteSession, resetSession } = useSessions({
limit: pageSize,
offset: (page - 1) * pageSize,
});
const showSkeleton = useDeferredLoading(loading && sessions.length === 0);
useEffect(() => {
refresh({ limit: pageSize, offset: (page - 1) * pageSize });
}, [page, pageSize]);
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const detailSession = detailKey
? sessions.find((s) => s.key === decodeURIComponent(detailKey))
+26 -28
View File
@@ -1,36 +1,30 @@
import { useState, useEffect, useCallback } from "react";
import { useCallback } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useWs, useHttp } from "@/hooks/use-ws";
import { Methods } from "@/api/protocol";
import { queryKeys } from "@/lib/query-keys";
import type { SkillInfo } from "@/types/skill";
export interface SkillInfo {
id?: string;
name: string;
description: string;
source: string;
}
export type { SkillInfo };
export function useSkills() {
const ws = useWs();
const http = useHttp();
const [skills, setSkills] = useState<SkillInfo[]>([]);
const [loading, setLoading] = useState(false);
const queryClient = useQueryClient();
const load = useCallback(async () => {
if (!ws.isConnected) return;
setLoading(true);
try {
const { data: skills = [], isLoading: loading } = useQuery({
queryKey: queryKeys.skills.all,
queryFn: async () => {
if (!ws.isConnected) return [];
const res = await ws.call<{ skills: SkillInfo[] }>(Methods.SKILLS_LIST);
setSkills(res.skills ?? []);
} catch {
// ignore
} finally {
setLoading(false);
}
}, [ws]);
return res.skills ?? [];
},
});
useEffect(() => {
load();
}, [load]);
const invalidate = useCallback(
() => queryClient.invalidateQueries({ queryKey: queryKeys.skills.all }),
[queryClient],
);
const getSkill = useCallback(
async (name: string) => {
@@ -44,20 +38,24 @@ export function useSkills() {
async (file: File) => {
const formData = new FormData();
formData.append("file", file);
return http.upload<{ id: string; slug: string; version: number; name: string }>(
const res = await http.upload<{ id: string; slug: string; version: number; name: string }>(
"/v1/skills/upload",
formData,
);
await invalidate();
return res;
},
[http],
[http, invalidate],
);
const deleteSkill = useCallback(
async (id: string) => {
return http.delete<{ ok: string }>(`/v1/skills/${id}`);
const res = await http.delete<{ ok: string }>(`/v1/skills/${id}`);
await invalidate();
return res;
},
[http],
[http, invalidate],
);
return { skills, loading, refresh: load, getSkill, uploadSkill, deleteSkill };
return { skills, loading, refresh: invalidate, getSkill, uploadSkill, deleteSkill };
}
+31 -77
View File
@@ -1,59 +1,12 @@
import { useState, useCallback } from "react";
import { useCallback } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useHttp } from "@/hooks/use-ws";
import { queryKeys } from "@/lib/query-keys";
import type { TraceData, SpanData } from "@/types/trace";
export interface TraceData {
id: string;
parent_trace_id?: string;
agent_id?: string;
user_id: string;
session_key: string;
run_id: string;
start_time: string;
end_time?: string;
duration_ms: number;
name: string;
channel: string;
input_preview: string;
output_preview: string;
total_input_tokens: number;
total_output_tokens: number;
total_cost: number;
span_count: number;
llm_call_count: number;
tool_call_count: number;
status: string;
error?: string;
tags?: string[];
metadata?: { total_cache_read_tokens?: number; total_cache_creation_tokens?: number };
created_at: string;
}
export type { TraceData, SpanData };
export interface SpanData {
id: string;
trace_id: string;
parent_span_id?: string;
agent_id?: string;
span_type: string;
name: string;
start_time: string;
end_time?: string;
duration_ms: number;
status: string;
error?: string;
model: string;
provider: string;
input_tokens: number;
output_tokens: number;
total_cost: number;
finish_reason: string;
tool_name: string;
tool_call_id: string;
input_preview: string;
output_preview: string;
metadata?: { cache_creation_tokens?: number; cache_read_tokens?: number; thinking_tokens?: number };
}
interface TraceFilters {
export interface TraceFilters {
agentId?: string;
userId?: string;
status?: string;
@@ -61,33 +14,34 @@ interface TraceFilters {
offset?: number;
}
export function useTraces() {
export function useTraces(filters: TraceFilters = {}) {
const http = useHttp();
const [traces, setTraces] = useState<TraceData[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const queryClient = useQueryClient();
const load = useCallback(
async (filters: TraceFilters = {}) => {
setLoading(true);
try {
const params: Record<string, string> = {};
if (filters.agentId) params.agent_id = filters.agentId;
if (filters.userId) params.user_id = filters.userId;
if (filters.status) params.status = filters.status;
if (filters.limit) params.limit = String(filters.limit);
if (filters.offset !== undefined) params.offset = String(filters.offset);
const queryKey = queryKeys.traces.list({ ...filters });
const res = await http.get<{ traces: TraceData[]; total?: number }>("/v1/traces", params);
setTraces(res.traces ?? []);
setTotal(res.total ?? 0);
} catch {
// ignore
} finally {
setLoading(false);
}
const { data, isLoading: loading } = useQuery({
queryKey,
queryFn: async () => {
const params: Record<string, string> = {};
if (filters.agentId) params.agent_id = filters.agentId;
if (filters.userId) params.user_id = filters.userId;
if (filters.status) params.status = filters.status;
if (filters.limit) params.limit = String(filters.limit);
if (filters.offset !== undefined) params.offset = String(filters.offset);
const res = await http.get<{ traces: TraceData[]; total?: number }>("/v1/traces", params);
return { traces: res.traces ?? [], total: res.total ?? 0 };
},
[http],
placeholderData: (prev) => prev,
});
const traces = data?.traces ?? [];
const total = data?.total ?? 0;
const invalidate = useCallback(
() => queryClient.invalidateQueries({ queryKey: queryKeys.traces.all }),
[queryClient],
);
const getTrace = useCallback(
@@ -101,5 +55,5 @@ export function useTraces() {
[http],
);
return { traces, total, loading, load, getTrace };
return { traces, total, loading, refresh: invalidate, getTrace };
}
+13 -56
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { useState } from "react";
import { Activity, RefreshCw, Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@@ -7,76 +7,33 @@ import { PageHeader } from "@/components/shared/page-header";
import { EmptyState } from "@/components/shared/empty-state";
import { Pagination } from "@/components/shared/pagination";
import { TableSkeleton } from "@/components/shared/loading-skeleton";
import { useWsEvent } from "@/hooks/use-ws-event";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import { Events } from "@/api/protocol";
import { formatDate, formatDuration, formatTokens, computeDurationMs } from "@/lib/format";
import { useTraces, type TraceData } from "./hooks/use-traces";
import { TraceDetailDialog } from "./trace-detail-dialog";
import { useMinLoading } from "@/hooks/use-min-loading";
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
import type { AgentEventPayload } from "@/types/chat";
export function TracesPage() {
const { traces, total, loading, load, getTrace } = useTraces();
const spinning = useMinLoading(loading);
const showSkeleton = useDeferredLoading(loading && traces.length === 0);
const [agentFilter, setAgentFilter] = useState("");
const [appliedAgentFilter, setAppliedAgentFilter] = useState("");
const [selectedTraceId, setSelectedTraceId] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const { traces, total, loading, refresh, getTrace } = useTraces({
agentId: appliedAgentFilter || undefined,
limit: pageSize,
offset: (page - 1) * pageSize,
});
const spinning = useMinLoading(loading);
const showSkeleton = useDeferredLoading(loading && traces.length === 0);
const totalPages = Math.max(1, Math.ceil(total / pageSize));
// Ref to capture current filter for debounced callback
const agentFilterRef = useRef(agentFilter);
agentFilterRef.current = agentFilter;
const pageRef = useRef(page);
pageRef.current = page;
const pageSizeRef = useRef(pageSize);
pageSizeRef.current = pageSize;
useEffect(() => {
load({ limit: pageSize, offset: (page - 1) * pageSize });
}, [load, page, pageSize]);
const handleRefresh = () => {
load({
agentId: agentFilter || undefined,
limit: pageSize,
offset: (page - 1) * pageSize,
});
};
// Auto-refresh when any agent run starts/completes (traces are created synchronously at run start)
const debouncedRefresh = useDebouncedCallback(() => {
load({
agentId: agentFilterRef.current || undefined,
limit: pageSizeRef.current,
offset: (pageRef.current - 1) * pageSizeRef.current,
});
}, 3000);
const handleAgentEvent = useCallback(
(payload: unknown) => {
const event = payload as AgentEventPayload;
if (!event) return;
if (
event.type === "run.started" ||
event.type === "run.completed" ||
event.type === "run.failed"
) {
debouncedRefresh();
}
},
[debouncedRefresh],
);
useWsEvent(Events.AGENT, handleAgentEvent);
const handleFilterSubmit = (e: React.FormEvent) => {
e.preventDefault();
handleRefresh();
setAppliedAgentFilter(agentFilter);
setPage(1);
};
return (
@@ -85,7 +42,7 @@ export function TracesPage() {
title="Traces"
description="LLM call traces and performance data"
actions={
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={spinning} className="gap-1">
<Button variant="outline" size="sm" onClick={refresh} disabled={spinning} className="gap-1">
<RefreshCw className={"h-3.5 w-3.5" + (spinning ? " animate-spin" : "")} /> Refresh
</Button>
}
+18 -20
View File
@@ -1,6 +1,8 @@
import { useState, useEffect, useCallback } from "react";
import { useState, useCallback } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useWs } from "@/hooks/use-ws";
import { Methods } from "@/api/protocol";
import { queryKeys } from "@/lib/query-keys";
export interface TtsProviderConfig {
api_key?: string;
@@ -41,28 +43,24 @@ const DEFAULT_TTS: TtsConfig = {
export function useTtsConfig() {
const ws = useWs();
const [tts, setTts] = useState<TtsConfig>(DEFAULT_TTS);
const [loading, setLoading] = useState(false);
const queryClient = useQueryClient();
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
if (!ws.isConnected) return;
setLoading(true);
try {
const { data: tts = DEFAULT_TTS, isLoading: loading } = useQuery({
queryKey: queryKeys.tts.all,
queryFn: async () => {
if (!ws.isConnected) return DEFAULT_TTS;
const res = await ws.call<{ config: Record<string, unknown> }>(Methods.CONFIG_GET);
const ttsConfig = (res.config?.tts as TtsConfig) ?? DEFAULT_TTS;
setTts({ ...DEFAULT_TTS, ...ttsConfig });
} catch {
// ignore
} finally {
setLoading(false);
}
}, [ws]);
return { ...DEFAULT_TTS, ...ttsConfig };
},
});
useEffect(() => {
load();
}, [load]);
const invalidate = useCallback(
() => queryClient.invalidateQueries({ queryKey: queryKeys.tts.all }),
[queryClient],
);
const save = useCallback(
async (updates: Partial<TtsConfig>) => {
@@ -70,7 +68,7 @@ export function useTtsConfig() {
setError(null);
try {
await ws.call(Methods.CONFIG_PATCH, { tts: updates });
await load();
await invalidate();
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to save TTS config";
setError(msg);
@@ -79,8 +77,8 @@ export function useTtsConfig() {
setSaving(false);
}
},
[ws, load],
[ws, invalidate],
);
return { tts, loading, saving, error, refresh: load, save };
return { tts, loading, saving, error, refresh: invalidate, save };
}
+23
View File
@@ -0,0 +1,23 @@
export interface ChannelInstanceData {
id: string;
name: string;
display_name: string;
channel_type: string;
agent_id: string;
config: Record<string, unknown> | null;
enabled: boolean;
is_default: boolean;
created_by: string;
created_at: string;
updated_at: string;
}
export interface ChannelInstanceInput {
name: string;
display_name?: string;
channel_type: string;
agent_id: string;
credentials?: Record<string, unknown>;
config?: Record<string, unknown>;
enabled?: boolean;
}
+25
View File
@@ -0,0 +1,25 @@
export interface CustomToolData {
id: string;
name: string;
description: string;
parameters: Record<string, unknown> | null;
command: string;
working_dir: string;
timeout_seconds: number;
agent_id: string | null;
enabled: boolean;
created_by: string;
created_at: string;
updated_at: string;
}
export interface CustomToolInput {
name: string;
description?: string;
parameters?: Record<string, unknown>;
command: string;
working_dir?: string;
timeout_seconds?: number;
agent_id?: string;
enabled?: boolean;
}
+41
View File
@@ -0,0 +1,41 @@
export interface MCPServerData {
id: string;
name: string;
display_name: string;
transport: "stdio" | "sse" | "streamable-http";
command: string;
args: string[] | null;
url: string;
headers: Record<string, string> | null;
env: Record<string, string> | null;
tool_prefix: string;
timeout_sec: number;
enabled: boolean;
created_by: string;
created_at: string;
updated_at: string;
}
export interface MCPServerInput {
name: string;
display_name?: string;
transport: string;
command?: string;
args?: string[];
url?: string;
headers?: Record<string, string>;
tool_prefix?: string;
timeout_sec?: number;
enabled?: boolean;
}
export interface MCPAgentGrant {
id: string;
server_id: string;
agent_id: string;
enabled: boolean;
tool_allow: string[] | null;
tool_deny: string[] | null;
granted_by: string;
created_at: string;
}
+25
View File
@@ -0,0 +1,25 @@
export interface ProviderData {
id: string;
name: string;
display_name: string;
provider_type: string;
api_base: string;
api_key: string; // masked "***" from server
enabled: boolean;
created_at: string;
updated_at: string;
}
export interface ProviderInput {
name: string;
display_name?: string;
provider_type: string;
api_base?: string;
api_key?: string;
enabled?: boolean;
}
export interface ModelInfo {
id: string;
name?: string;
}
+6
View File
@@ -0,0 +1,6 @@
export interface SkillInfo {
id?: string;
name: string;
description: string;
source: string;
}
+51
View File
@@ -0,0 +1,51 @@
export interface TraceData {
id: string;
parent_trace_id?: string;
agent_id?: string;
user_id: string;
session_key: string;
run_id: string;
start_time: string;
end_time?: string;
duration_ms: number;
name: string;
channel: string;
input_preview: string;
output_preview: string;
total_input_tokens: number;
total_output_tokens: number;
total_cost: number;
span_count: number;
llm_call_count: number;
tool_call_count: number;
status: string;
error?: string;
tags?: string[];
metadata?: { total_cache_read_tokens?: number; total_cache_creation_tokens?: number };
created_at: string;
}
export interface SpanData {
id: string;
trace_id: string;
parent_span_id?: string;
agent_id?: string;
span_type: string;
name: string;
start_time: string;
end_time?: string;
duration_ms: number;
status: string;
error?: string;
model: string;
provider: string;
input_tokens: number;
output_tokens: number;
total_cost: number;
finish_reason: string;
tool_name: string;
tool_call_id: string;
input_preview: string;
output_preview: string;
metadata?: { cache_creation_tokens?: number; cache_read_tokens?: number; thinking_tokens?: number };
}