diff options
| author | Adam Malczewski <[email protected]> | 2026-05-20 15:04:26 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-20 15:04:26 +0900 |
| commit | adc8bd185b54935e7a31aae04da3175b7989927a (patch) | |
| tree | 031fa53009a4708ce0cc15c0e3dcb2f2a73cf2d9 /packages/frontend/src/lib/components/ConfigPanel.svelte | |
| parent | 52af4ca33b1f83b8f863a6267d447944f55e729d (diff) | |
| download | dispatch-adc8bd185b54935e7a31aae04da3175b7989927a.tar.gz dispatch-adc8bd185b54935e7a31aae04da3175b7989927a.zip | |
feat: phase 3 — config, skills, model groups, task list, and sidebar UI
- Config system: TOML-based dispatch.toml with hot-reload via chokidar
- Model/key resolution: tag-based model selection, key fallback chains
- Skills system: directory loader with TOML frontmatter, agent mappings
- Task list tool: add/update/list/get operations with WebSocket events
- API routes: GET /config, /skills, /skills/:name, /models, /models/resolve
- Frontend: sidebar with model status, task list, config viewer, skills browser, permission log
- Sliding sidebar animation using CSS transitions (not Svelte transitions)
Diffstat (limited to 'packages/frontend/src/lib/components/ConfigPanel.svelte')
| -rw-r--r-- | packages/frontend/src/lib/components/ConfigPanel.svelte | 244 |
1 files changed, 244 insertions, 0 deletions
diff --git a/packages/frontend/src/lib/components/ConfigPanel.svelte b/packages/frontend/src/lib/components/ConfigPanel.svelte new file mode 100644 index 0000000..25cbe98 --- /dev/null +++ b/packages/frontend/src/lib/components/ConfigPanel.svelte @@ -0,0 +1,244 @@ +<script lang="ts"> + interface AgentTemplate { + name: string; + description?: string; + model_tag?: string; + tools?: string[]; + } + + interface ModelEntry { + id: string; + provider?: string; + tags?: string[]; + } + + interface KeyEntry { + id: string; + provider?: string; + status?: string; + lastError?: string; + exhaustedAt?: string; + } + + interface ConfigData { + agents?: Record<string, AgentTemplate>; + models?: Record<string, { provider?: string; tags?: string[] }>; + keys?: Record<string, { env?: string }>; + fallback?: string[]; + permissions?: Record<string, unknown>; + } + + interface ModelsData { + models?: ModelEntry[]; + tags?: Record<string, string[]>; + keys?: KeyEntry[]; + } + + const { apiBase }: { apiBase: string } = $props(); + + let configData = $state<ConfigData | null>(null); + let modelsData = $state<ModelsData | null>(null); + let error = $state<string | null>(null); + let loading = $state(false); + + async function fetchData() { + loading = true; + error = null; + try { + const [configRes, modelsRes] = await Promise.all([ + fetch(`${apiBase}/config`), + fetch(`${apiBase}/models`), + ]); + if (!configRes.ok) throw new Error(`/config returned ${configRes.status}`); + if (!modelsRes.ok) throw new Error(`/models returned ${modelsRes.status}`); + const configJson = await configRes.json(); + const modelsJson = await modelsRes.json(); + configData = configJson.config ?? configJson; + modelsData = modelsJson; + } catch (e) { + error = e instanceof Error ? e.message : String(e); + } finally { + loading = false; + } + } + + $effect(() => { + fetchData(); + }); + + const modelCount = $derived(modelsData?.models?.length ?? 0); + const keyCount = $derived(modelsData?.keys?.length ?? 0); + + function formatDate(iso: string | undefined): string { + if (!iso) return ""; + try { + return new Date(iso).toLocaleString(); + } catch { + return iso; + } + } + + function permissionEntries(permissions: Record<string, unknown>): Array<{ name: string; value: unknown }> { + return Object.entries(permissions).map(([name, value]) => ({ name, value })); + } + + function isSimpleRule(value: unknown): value is { action: string } { + return typeof value === "object" && value !== null && "action" in value && Object.keys(value).length === 1; + } + + function isPatternRule(value: unknown): value is Record<string, { action: string }> { + return typeof value === "object" && value !== null && !("action" in value); + } +</script> + +<details class="collapse collapse-arrow bg-base-200 mt-2"> + <summary class="collapse-title text-sm font-medium flex items-center gap-2"> + <span>Configuration</span> + {#if modelCount > 0 || keyCount > 0} + <span class="badge badge-sm badge-neutral">{modelCount} models</span> + <span class="badge badge-sm badge-neutral">{keyCount} keys</span> + {/if} + {#if loading} + <span class="loading loading-spinner loading-xs ml-auto"></span> + {/if} + </summary> + + <div class="collapse-content text-xs"> + {#if error} + <div class="alert alert-error alert-sm py-1 mb-2 text-xs"> + <span>Failed to load config: {error}</span> + </div> + {/if} + + <div class="flex justify-end mb-2"> + <button + type="button" + class="btn btn-xs btn-ghost" + onclick={fetchData} + disabled={loading} + > + {loading ? "Loading…" : "Refresh"} + </button> + </div> + + <!-- Agent Templates --> + {#if configData?.agents && Object.keys(configData.agents).length > 0} + <div class="mb-3"> + <div class="text-xs font-semibold text-base-content/60 uppercase tracking-wide mb-1">Agent Templates</div> + {#each Object.entries(configData.agents) as [name, template]} + <div class="bg-base-100 rounded p-2 mb-1"> + <div class="flex items-center gap-2 flex-wrap"> + <span class="font-medium">{name}</span> + {#if template.model_tag} + <span class="badge badge-xs badge-info">{template.model_tag}</span> + {/if} + </div> + {#if template.description} + <p class="text-base-content/60 mt-0.5">{template.description}</p> + {/if} + {#if template.tools && template.tools.length > 0} + <div class="flex flex-wrap gap-1 mt-1"> + {#each template.tools as tool} + <span class="badge badge-xs badge-ghost">{tool}</span> + {/each} + </div> + {/if} + </div> + {/each} + </div> + {/if} + + <!-- Models --> + {#if modelsData?.models && modelsData.models.length > 0} + <div class="mb-3"> + <div class="text-xs font-semibold text-base-content/60 uppercase tracking-wide mb-1">Models</div> + {#each modelsData.models as model} + <div class="flex items-center gap-2 flex-wrap py-0.5 border-b border-base-300"> + <span class="font-mono">{model.id}</span> + {#if model.provider} + <span class="badge badge-xs badge-primary">{model.provider}</span> + {/if} + {#if model.tags && model.tags.length > 0} + {#each model.tags as tag} + <span class="badge badge-xs badge-ghost">{tag}</span> + {/each} + {/if} + </div> + {/each} + </div> + {/if} + + <!-- Keys --> + {#if modelsData?.keys && modelsData.keys.length > 0} + <div class="mb-3"> + <div class="text-xs font-semibold text-base-content/60 uppercase tracking-wide mb-1">API Keys</div> + {#each modelsData.keys as key} + <div class="bg-base-100 rounded p-1.5 mb-1"> + <div class="flex items-center gap-2 flex-wrap"> + <span class="font-mono">{key.id}</span> + {#if key.provider} + <span class="badge badge-xs badge-secondary">{key.provider}</span> + {/if} + {#if key.status === "exhausted"} + <span class="badge badge-xs badge-error">exhausted</span> + {:else} + <span class="badge badge-xs badge-success">active</span> + {/if} + </div> + {#if key.status === "exhausted" && key.lastError} + <p class="text-error/80 mt-0.5 truncate">{key.lastError}</p> + {/if} + {#if key.exhaustedAt} + <p class="text-base-content/40 mt-0.5">Since {formatDate(key.exhaustedAt)}</p> + {/if} + </div> + {/each} + </div> + {/if} + + <!-- Fallback Order --> + {#if configData?.fallback && configData.fallback.length > 0} + <div class="mb-3"> + <div class="text-xs font-semibold text-base-content/60 uppercase tracking-wide mb-1">Fallback Order</div> + <ol class="list-none space-y-0.5"> + {#each configData.fallback as keyId, i} + <li class="flex items-center gap-2"> + <span class="badge badge-xs badge-outline">{i + 1}</span> + <span class="font-mono">{keyId}</span> + </li> + {/each} + </ol> + </div> + {/if} + + <!-- Permissions --> + {#if configData?.permissions && Object.keys(configData.permissions).length > 0} + <div class="mb-1"> + <div class="text-xs font-semibold text-base-content/60 uppercase tracking-wide mb-1">Permissions</div> + {#each permissionEntries(configData.permissions) as entry} + <div class="py-0.5 border-b border-base-300"> + <span class="font-medium text-base-content/80">{entry.name}</span> + {#if isSimpleRule(entry.value)} + <span class="ml-2 badge badge-xs {entry.value.action === 'allow' ? 'badge-success' : 'badge-error'}">{entry.value.action}</span> + {:else if isPatternRule(entry.value)} + {#each Object.entries(entry.value) as [pattern, rule]} + <div class="pl-2 flex items-center gap-2"> + <span class="text-base-content/50 font-mono truncate max-w-32">{pattern}</span> + {#if typeof rule === "object" && rule !== null && "action" in rule} + <span class="badge badge-xs {(rule as { action: string }).action === 'allow' ? 'badge-success' : 'badge-error'}">{(rule as { action: string }).action}</span> + {/if} + </div> + {/each} + {:else} + <span class="text-base-content/40 ml-2">{JSON.stringify(entry.value)}</span> + {/if} + </div> + {/each} + </div> + {/if} + + {#if !loading && !error && !configData && !modelsData} + <p class="text-base-content/40 italic">No configuration loaded.</p> + {/if} + </div> +</details> |
