mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 18:15:34 +00:00
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:
1 parent
552ddc059e
commit
813c602bca
41 files changed
+1079
-813
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Generated
+18
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface SkillInfo {
|
||||
id?: string;
|
||||
name: string;
|
||||
description: string;
|
||||
source: string;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user