diff --git a/dashboard-frontend/src/App.tsx b/dashboard-frontend/src/App.tsx index 7e330bd..33226b3 100644 --- a/dashboard-frontend/src/App.tsx +++ b/dashboard-frontend/src/App.tsx @@ -7,6 +7,7 @@ import Config from './pages/Config' import Alerts from './pages/Alerts' import Notifications from './pages/Notifications' import Reference from './pages/Reference' +import AdapterConfig from './pages/AdapterConfig' import { ToastProvider } from './components/ToastProvider' function App() { @@ -21,6 +22,7 @@ function App() { } /> } /> } /> + } /> diff --git a/dashboard-frontend/src/components/Layout.tsx b/dashboard-frontend/src/components/Layout.tsx index 82bcd6a..e966260 100644 --- a/dashboard-frontend/src/components/Layout.tsx +++ b/dashboard-frontend/src/components/Layout.tsx @@ -8,6 +8,7 @@ import { Bell, BellRing, BookOpen, + Sliders, } from 'lucide-react' import { fetchStatus, type SystemStatus } from '@/lib/api' import { useWebSocket } from '@/hooks/useWebSocket' @@ -25,6 +26,7 @@ const navItems = [ { path: '/alerts', label: 'Alerts', icon: Bell }, { path: '/notifications', label: 'Notifications', icon: BellRing }, { path: '/reference', label: 'Reference', icon: BookOpen }, + { path: '/adapter-config', label: 'Adapter Config', icon: Sliders }, ] function formatUptime(seconds: number): string { diff --git a/dashboard-frontend/src/pages/AdapterConfig.tsx b/dashboard-frontend/src/pages/AdapterConfig.tsx new file mode 100644 index 0000000..a0b1d61 --- /dev/null +++ b/dashboard-frontend/src/pages/AdapterConfig.tsx @@ -0,0 +1,416 @@ +// v0.6-3c Adapter Config editor. +// +// Renders one card per adapter. Each card shows: +// - display_name + include_in_llm_context toggle at the top +// - expandable list of (config key, type-aware widget, reset button) +// +// Auto-saves on blur (text/number inputs) or change (bool toggle + select). +// Cache invalidation is server-side -- every PUT triggers it. The handler +// reads via the in-process accessor on its next call. + +import { useEffect, useState, useCallback } from 'react' +import { + ChevronDown, ChevronRight, RotateCcw, Loader2, Check, AlertCircle, + Sliders, +} from 'lucide-react' + +interface ConfigRow { + adapter: string + key: string + value: unknown + default: unknown + type: 'int' | 'float' | 'str' | 'bool' | 'json' + description: string + updated_at: number +} + +interface MetaRow { + display_name: string + include_in_llm_context: boolean + description: string +} + +type GroupedConfig = Record +type MetaMap = Record + +type SaveStatus = 'idle' | 'saving' | 'saved' | 'error' + +// Brief animation after a successful save. +const SAVED_BADGE_MS = 1500 + +export default function AdapterConfig() { + const [config, setConfig] = useState({}) + const [meta, setMeta] = useState({}) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [expanded, setExpanded] = useState>({}) + + // Per-key save status: keyed on `${adapter}.${key}` or `meta:${adapter}`. + const [saveStatus, setSaveStatus] = useState>({}) + const [saveError, setSaveError] = useState>({}) + + const refresh = useCallback(async () => { + setLoading(true) + setError(null) + try { + const [cfgRes, metaRes] = await Promise.all([ + fetch('/api/adapter-config'), + fetch('/api/adapter-meta'), + ]) + if (!cfgRes.ok) throw new Error(`GET /adapter-config: ${cfgRes.status}`) + if (!metaRes.ok) throw new Error(`GET /adapter-meta: ${metaRes.status}`) + setConfig(await cfgRes.json()) + setMeta(await metaRes.json()) + } catch (e) { + setError(String(e)) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { refresh() }, [refresh]) + + const markStatus = useCallback((id: string, status: SaveStatus, errMsg?: string) => { + setSaveStatus((s) => ({ ...s, [id]: status })) + if (errMsg) setSaveError((s) => ({ ...s, [id]: errMsg })) + if (status === 'saved') { + setTimeout(() => { + setSaveStatus((s) => (s[id] === 'saved' ? { ...s, [id]: 'idle' } : s)) + }, SAVED_BADGE_MS) + } + }, []) + + // ---------- key-level mutations ---------------------------------------- + + const putValue = useCallback(async (adapter: string, key: string, value: unknown) => { + const id = `${adapter}.${key}` + markStatus(id, 'saving') + try { + const res = await fetch(`/api/adapter-config/${adapter}/${key}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ value }), + }) + if (!res.ok) { + const body = await res.json().catch(() => ({})) + const detail = body.detail || res.statusText + markStatus(id, 'error', String(detail)) + return + } + const updated: ConfigRow = await res.json() + setConfig((c) => ({ + ...c, + [adapter]: (c[adapter] || []).map((row) => row.key === key ? updated : row), + })) + markStatus(id, 'saved') + } catch (e) { + markStatus(id, 'error', String(e)) + } + }, [markStatus]) + + const resetValue = useCallback(async (adapter: string, key: string) => { + const id = `${adapter}.${key}` + markStatus(id, 'saving') + try { + const res = await fetch(`/api/adapter-config/${adapter}/${key}/reset`, { + method: 'POST', + }) + if (!res.ok) { + markStatus(id, 'error', `reset failed (${res.status})`) + return + } + const updated: ConfigRow = await res.json() + setConfig((c) => ({ + ...c, + [adapter]: (c[adapter] || []).map((row) => row.key === key ? updated : row), + })) + markStatus(id, 'saved') + } catch (e) { + markStatus(id, 'error', String(e)) + } + }, [markStatus]) + + const putMeta = useCallback(async (adapter: string, body: Partial) => { + const id = `meta:${adapter}` + markStatus(id, 'saving') + try { + const res = await fetch(`/api/adapter-meta/${adapter}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + if (!res.ok) { + const b = await res.json().catch(() => ({})) + markStatus(id, 'error', String(b.detail || res.statusText)) + return + } + const updated: MetaRow = await res.json() + setMeta((m) => ({ ...m, [adapter]: updated })) + markStatus(id, 'saved') + } catch (e) { + markStatus(id, 'error', String(e)) + } + }, [markStatus]) + + // ---------- render ------------------------------------------------------ + + if (loading) { + return ( +
+ Loading adapter config… +
+ ) + } + + if (error) { + return ( +
+ + Failed to load: {error} +
+ ) + } + + // Union of all adapters: any meta row + any config-having adapter. + const allAdapters = Array.from(new Set([ + ...Object.keys(meta), + ...Object.keys(config), + ])).sort() + + return ( +
+
+ +

Adapter Config

+ + {Object.values(config).reduce((n, l) => n + l.length, 0)} settings across {allAdapters.length} adapters + +
+

+ Per-adapter tunables (thresholds, freshness windows, toggles, curation lists). + Changes take effect on the next handler call -- no container restart needed. + Sentence templates, emoji, and translation maps live in code by design. +

+ + {allAdapters.map((adapter) => { + const m = meta[adapter] || { + display_name: adapter, + include_in_llm_context: true, + description: '', + } + const rows = config[adapter] || [] + const isExpanded = expanded[adapter] ?? false + const metaId = `meta:${adapter}` + const metaStatus = saveStatus[metaId] || 'idle' + return ( +
+ {/* Card header */} +
+ +
+
+

{m.display_name}

+ {adapter} + {rows.length > 0 && ( + ({rows.length} settings) + )} + {rows.length === 0 && ( + (meta only) + )} +
+ {m.description && ( +

{m.description}

+ )} +
+ + {/* include_in_llm_context toggle */} + +
+ + {/* Expanded body */} + {isExpanded && rows.length > 0 && ( +
+ {rows.map((row) => ( + putValue(adapter, row.key, v)} + onReset={() => resetValue(adapter, row.key)} + /> + ))} +
+ )} +
+ ) + })} +
+ ) +} + + +// ---------- KeyRow --------------------------------------------------------- + + +interface KeyRowProps { + row: ConfigRow + status: SaveStatus + error?: string + onCommit: (v: unknown) => void + onReset: () => void +} + +function KeyRow({ row, status, error, onCommit, onReset }: KeyRowProps) { + // Use a local draft so number/text inputs don't fight the parent state + // mid-edit. Commit on blur. + const [draft, setDraft] = useState(stringifyForInput(row)) + + // If the parent value changes (e.g. via reset), refresh the local draft. + useEffect(() => { + setDraft(stringifyForInput(row)) + }, [row.value, row.type]) + + const isDirty = draft !== stringifyForInput(row) + const isDefault = JSON.stringify(row.value) === JSON.stringify(row.default) + + const commit = () => { + const parsed = parseFromInput(draft, row.type) + if (parsed.error) return // error shown inline; do not PUT + if (!parsed.changed(row.value)) return + onCommit(parsed.value) + } + + return ( +
+
+
+ {row.key} + [{row.type}] + {!isDefault && ( + edited + )} +
+ {row.description && ( +

{row.description}

+ )} +
+ +
+ {row.type === 'bool' ? ( + onCommit(e.target.checked)} + className="w-5 h-5 accent-cyan-500" + /> + ) : row.type === 'json' ? ( +