diff options
| author | Adam Malczewski <[email protected]> | 2026-06-04 21:21:20 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-04 21:21:20 +0900 |
| commit | 394f1ed37ce860da6fdc385769bf29f9737105cd (patch) | |
| tree | 4b825dc642cb6eb9a060e54bf8d69288fbee4904 /packages/frontend/src/lib/components/ModelSelector.svelte | |
| parent | 81a9cdbadf8c9d940d4fe9a2a0de607dee1f5f1a (diff) | |
| download | dispatch-394f1ed37ce860da6fdc385769bf29f9737105cd.tar.gz dispatch-394f1ed37ce860da6fdc385769bf29f9737105cd.zip | |
chore: genesis — remove all files to rebuild from scratch (arch rewrite)
Diffstat (limited to 'packages/frontend/src/lib/components/ModelSelector.svelte')
| -rw-r--r-- | packages/frontend/src/lib/components/ModelSelector.svelte | 636 |
1 files changed, 0 insertions, 636 deletions
diff --git a/packages/frontend/src/lib/components/ModelSelector.svelte b/packages/frontend/src/lib/components/ModelSelector.svelte deleted file mode 100644 index 64036d4..0000000 --- a/packages/frontend/src/lib/components/ModelSelector.svelte +++ /dev/null @@ -1,636 +0,0 @@ -<script module lang="ts"> -const modelCache = new Map<string, string[]>(); -</script> - -<script lang="ts"> - import { - DEFAULT_REASONING_EFFORT, - isReasoningEffort, - REASONING_EFFORTS, - REASONING_EFFORT_LABELS, - } from "@dispatch/core/src/types/index.js"; - import type { KeyInfo } from "../types.js"; - import { - cacheWarming, - WARM_INTERVAL_MS, - } from "../cache-warming.svelte.js"; - import { config } from "../config.js"; - import { router } from "../router.svelte.js"; - import { tabStore } from "../tabs.svelte.js"; - - interface AgentInfo { - name: string; - slug: string; - scope: string; - description: string; - skills: string[]; - tools: string[]; - models: Array<{ key_id: string; model_id: string; effort?: string }>; - cwd?: string; - is_subagent?: boolean; - } - - // Moves an element to document.body so modals escape the sidebar's - // transform stacking context and cover the full viewport. - function portal(node: HTMLElement) { - document.body.appendChild(node); - return { - destroy() { - node.remove(); - }, - }; - } - - /** - * Human-readable effort label for a (possibly-unset) per-model effort. When - * the model has no explicit override, the badge reflects what will ACTUALLY - * run: the per-tab selector if valid, else the system default. This mirrors - * the backend resolution order (per-model → per-tab → default) so the UI - * never misrepresents the effective effort. - */ - function effortLabel(effort: string | undefined): string { - if (isReasoningEffort(effort)) return REASONING_EFFORT_LABELS[effort]; - const tab = isReasoningEffort(reasoningEffort) ? reasoningEffort : DEFAULT_REASONING_EFFORT; - return REASONING_EFFORT_LABELS[tab]; - } - - const { - keys = [], - activeTabId = null, - activeKeyId = null, - activeModelId = null, - reasoningEffort = "max", - activeAgentSlug = null, - activeTabParentId = null as string | null, - activeAgentModels = null as Array<{ key_id: string; model_id: string; effort?: string }> | null, - workingDirectory = null, - onKeyChange, - onModelChange, - onReasoningChange, - onAgentChange = (_agent: AgentInfo | null) => {}, - onWorkingDirectoryChange = (_dir: string | null) => {}, - onCompact = () => {}, - canCompact = false, - compacting = false, - }: { - keys?: KeyInfo[]; - activeTabId?: string | null; - activeKeyId?: string | null; - activeModelId?: string | null; - reasoningEffort?: string; - activeAgentSlug?: string | null; - activeTabParentId?: string | null; - activeAgentModels?: Array<{ key_id: string; model_id: string; effort?: string }> | null; - workingDirectory?: string | null; - onKeyChange: (keyId: string) => void; - onModelChange: (keyId: string, modelId: string) => void; - onReasoningChange: (effort: string) => void; - onAgentChange?: (agent: AgentInfo | null) => void; - onWorkingDirectoryChange?: (dir: string | null) => void; - onCompact?: () => void; - canCompact?: boolean; - compacting?: boolean; - } = $props(); - - let showKeyModal = $state(false); - let showModelModal = $state(false); - let availableModels = $state<string[]>([]); - let loadingModels = $state(false); - let modelError = $state<string | null>(null); - let sliderDragging = $state<number | null>(null); - let modelSearch = $state(""); - - // ─── Prompt-cache warming (debug strip lives at the bottom) ────── - // Reactive per-tab warming state from the singleton store. `warm.now` is a - // 1s ticking clock so the countdown re-renders while a fire is pending. - const warm = $derived(cacheWarming.stateFor(activeTabId)); - const warmCountdown = $derived.by(() => { - const next = warm.nextFireAt; - if (next === null) return null; - const ms = Math.max(0, next - cacheWarming.now); - const total = Math.round(ms / 1000); - const m = Math.floor(total / 60); - const s = total % 60; - return `${m}:${s.toString().padStart(2, "0")}`; - }); - const warmIntervalLabel = `${Math.round(WARM_INTERVAL_MS / 60000)} min`; - - function toggleCacheWarming(enabled: boolean): void { - if (!activeTabId) return; - tabStore.setCacheWarmingEnabled(activeTabId, enabled); - } - - let cwdExists = $state<boolean | null>(null); - let cwdCheckTimer: ReturnType<typeof setTimeout> | null = null; - - $effect(() => { - const cwd = workingDirectory; - if (!cwd) { - cwdExists = null; - return; - } - cwdExists = null; - if (cwdCheckTimer) clearTimeout(cwdCheckTimer); - cwdCheckTimer = setTimeout(async () => { - try { - const res = await fetch( - `${config.apiBase}/agents/check-dir?path=${encodeURIComponent(cwd)}`, - ); - if (res.ok) { - const data = await res.json(); - cwdExists = data.exists ?? false; - } - } catch { - cwdExists = null; - } - }, 300); - }); - - let modeOverride = $state<"manual" | "agent" | null>(null); - let mode = $derived( - activeTabParentId && activeAgentSlug - ? "subagent" - : modeOverride ?? (activeAgentSlug ? "agent" : "manual"), - ); - let agents = $state<AgentInfo[]>([]); - let visibleAgents = $derived(agents.filter((a) => !a.is_subagent)); - let loadingAgents = $state(false); - - $effect(() => { - fetchAgents(); - }); - - async function fetchAgents() { - loadingAgents = true; - try { - const res = await fetch(`${config.apiBase}/agents`); - if (res.ok) { - const data = await res.json(); - agents = data.agents ?? []; - } - } catch { - /* ignore */ - } finally { - loadingAgents = false; - } - } - - function selectKey(keyId: string) { - showKeyModal = false; - onKeyChange(keyId); - // Immediately open model selection for the new key - openModelModal(keyId); - } - - async function openModelModal(keyIdOverride?: string) { - const keyId = keyIdOverride ?? activeKeyId; - if (!keyId) return; - showModelModal = true; - modelError = null; - modelSearch = ""; - - // Check session cache - if (modelCache.has(keyId)) { - availableModels = modelCache.get(keyId)!; - loadingModels = false; - return; - } - - loadingModels = true; - availableModels = []; - - try { - const res = await fetch( - `${config.apiBase}/models/available?keyId=${encodeURIComponent(keyId)}`, - ); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - modelError = data.error ?? `Failed to fetch models (HTTP ${res.status})`; - return; - } - const data = await res.json(); - availableModels = data.models ?? []; - // Cache for session - modelCache.set(keyId, availableModels); - } catch (err) { - modelError = err instanceof Error ? err.message : "Failed to fetch models"; - } finally { - loadingModels = false; - } - } - - function selectModel(model: string) { - showModelModal = false; - if (activeKeyId) { - onModelChange(activeKeyId, model); - } - } -</script> - -<div class="bg-base-200 rounded-lg p-3"> - <!-- Working Directory --> - <div class="form-control mb-3"> - <label class="label py-0" for="cwd-input"> - <span class="label-text text-xs font-semibold">Working Directory</span> - </label> - <div class="flex items-center gap-1.5 mt-1"> - <input - id="cwd-input" - type="text" - class="input input-bordered input-sm font-mono text-xs flex-1" - placeholder="default (project root)" - value={workingDirectory ?? ""} - onchange={(e) => { - const val = e.currentTarget.value.trim(); - onWorkingDirectoryChange(val || null); - }} - /> - {#if workingDirectory} - {#if cwdExists === true} - <span class="text-success text-sm" title="Directory exists">✔</span> - {:else if cwdExists === false} - <span class="text-warning text-sm" title="Will be created">✖</span> - {:else} - <span class="loading loading-spinner loading-xs"></span> - {/if} - {/if} - </div> - </div> - - <!-- Compact conversation --> - <div class="mb-3"> - <button - type="button" - class="btn btn-sm btn-outline w-full" - disabled={!canCompact || compacting} - onclick={onCompact} - title="Summarize older turns into a compact anchor, preserving the most recent turns. Opens a new tab while it works; the conversation continues here once done." - > - {#if compacting} - <span class="loading loading-spinner loading-xs"></span> - Compacting… - {:else} - Compact conversation - {/if} - </button> - </div> - - <!-- Toggle --> - <div class="flex items-center gap-2 mb-3"> - <button - class="btn btn-xs {mode === 'manual' ? 'btn-primary' : 'btn-ghost'}" - onclick={() => { modeOverride = "manual"; onAgentChange(null); }} - disabled={mode === 'subagent'} - > - Manual - </button> - <button - class="btn btn-xs {mode === 'agent' ? 'btn-primary' : 'btn-ghost'}" - onclick={async () => { - modeOverride = "agent"; - await fetchAgents(); - // Re-apply the active agent's settings (including cwd) - const current = visibleAgents.find(a => a.slug === activeAgentSlug); - const agentToApply = current ?? visibleAgents[0] ?? null; - if (agentToApply) { - onAgentChange(agentToApply); - // Force-update the input since the prop may not change (already set) - const cwdEl = document.getElementById("cwd-input") as HTMLInputElement | null; - if (cwdEl) cwdEl.value = agentToApply.cwd ?? ""; - } - }} - disabled={mode === 'subagent'} - > - Agent - </button> - <button - class="btn btn-xs {mode === 'subagent' ? 'btn-primary' : 'btn-ghost'}" - disabled={true} - > - SubAgent - </button> - </div> - - {#if mode === "manual"} - <div class="flex items-center justify-between"> - <span class="text-sm font-medium">Key</span> - <button class="btn btn-sm btn-outline" onclick={() => (showKeyModal = true)}> - {activeKeyId ?? "Select Key"} - </button> - </div> - - <div class="flex items-center justify-between mt-2"> - <span class="text-sm font-medium">Model</span> - <button class="btn btn-sm btn-outline" onclick={() => openModelModal()} disabled={!activeKeyId}> - {activeModelId ?? "Select Model"} - </button> - </div> - - {#if activeModelId} - <div class="flex items-center justify-between mt-2"> - <span class="text-sm font-medium">Thinking</span> - <select - class="select select-bordered select-sm" - value={reasoningEffort} - onchange={(e) => onReasoningChange(e.currentTarget.value)} - > - {#each REASONING_EFFORTS as effort} - <option value={effort}>{REASONING_EFFORT_LABELS[effort]}</option> - {/each} - </select> - </div> - {/if} - {:else if mode === "subagent"} - <!-- SubAgent read-only info --> - {@const subModels = activeAgentModels ?? []} - {@const hasSubModels = subModels.length > 1} - {@const subActiveIdx = subModels.findIndex( - (m) => m.key_id === activeKeyId && m.model_id === activeModelId, - )} - <div class="flex flex-col gap-2"> - <div class="flex items-center justify-between"> - <span class="text-sm font-medium">SubAgent</span> - <span class="badge badge-sm">{activeAgentSlug}</span> - </div> - <div class="flex items-center justify-between"> - <span class="text-sm font-medium">Key</span> - <span class="font-mono text-xs">{activeKeyId ?? "default"}</span> - </div> - <div class="flex items-center justify-between"> - <span class="text-sm font-medium">Model</span> - <span class="font-mono text-xs">{activeModelId ?? "default"}</span> - </div> - {#if hasSubModels} - {@const displayIdx = subActiveIdx >= 0 ? subActiveIdx : 0} - <div class="mt-1 pt-2 border-t border-base-content/20"> - <div class="text-xs font-semibold mb-1">Model fallback chain</div> - <input - type="range" - min="0" - max={subModels.length - 1} - value={displayIdx} - class="range range-xs" - step="1" - disabled - /> - <div class="flex w-full justify-between px-0.5 text-xs opacity-50 mt-0.5"> - {#each subModels as _, i} - <span>{i + 1}</span> - {/each} - </div> - <div class="mt-1 flex flex-col gap-0.5"> - {#each subModels as m, i} - <div class="text-xs font-mono truncate flex items-center gap-1 {i === displayIdx ? 'opacity-100 font-semibold' : 'opacity-50'}"> - <span class="truncate">{i + 1}. {m.key_id} / {m.model_id}</span> - <span class="badge badge-xs badge-ghost shrink-0">{effortLabel(m.effort)}</span> - </div> - {/each} - </div> - </div> - {/if} - <p class="text-xs text-base-content/50 mt-1">This tab was spawned by a parent agent. Settings cannot be changed.</p> - </div> - {:else} - <!-- Agent selection UI --> - {#if loadingAgents} - <div class="flex items-center gap-2 py-2 text-base-content/60"> - <span class="loading loading-spinner loading-xs"></span> - Loading agents... - </div> - {:else if visibleAgents.length === 0} - <p class="text-base-content/50 text-sm py-2">No agents configured.</p> - {:else} - <div class="flex flex-col gap-1.5"> - {#each visibleAgents as agent (agent.slug + ":" + agent.scope)} - {@const isActive = activeAgentSlug === agent.slug} - {@const hasMultipleModels = agent.models.length > 1} - {@const currentIdx = isActive - ? agent.models.findIndex( - (m) => m.key_id === activeKeyId && m.model_id === activeModelId, - ) - : -1} - <div - role="button" - tabindex="0" - class="w-full text-left rounded-lg px-3 py-2 transition-colors {isActive ? 'bg-primary text-primary-content' : 'bg-base-300 hover:bg-base-200'}" - onclick={() => { - // Only switch agent — don't reset the slider position - onAgentChange(agent); - const cwdEl = document.getElementById("cwd-input") as HTMLInputElement | null; - if (cwdEl) cwdEl.value = agent.cwd ?? ""; - }} - onkeydown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onAgentChange(agent); - const cwdEl = document.getElementById("cwd-input") as HTMLInputElement | null; - if (cwdEl) cwdEl.value = agent.cwd ?? ""; - } - }} - > - <div class="flex items-center justify-between gap-2"> - <span class="font-medium text-sm">{agent.name}</span> - <div class="flex gap-1 shrink-0"> - <span class="badge badge-xs">{agent.models.length} model{agent.models.length !== 1 ? "s" : ""}</span> - <span class="badge badge-xs badge-outline">{agent.scope === "global" ? "global" : "project"}</span> - </div> - </div> - {#if agent.description} - <p class="text-xs opacity-60 mt-0.5">{agent.description}</p> - {/if} - {#if isActive && hasMultipleModels} - {@const displayIdx = sliderDragging !== null ? sliderDragging : (currentIdx >= 0 ? currentIdx : 0)} - {@const displayModel = agent.models[displayIdx]} - <div class="mt-2 pt-2 border-t border-primary-content/20"> - <div class="text-xs font-semibold mb-1 truncate flex items-center gap-1"> - <span class="truncate">{displayModel ? `${displayModel.key_id} / ${displayModel.model_id}` : `${activeKeyId} / ${activeModelId}`}</span> - <span class="badge badge-xs shrink-0">{effortLabel(displayModel?.effort)}</span> - </div> - <input - type="range" - min="0" - max={agent.models.length - 1} - value={currentIdx >= 0 ? currentIdx : 0} - class="range range-xs" - step="1" - oninput={(e) => { - sliderDragging = Number(e.currentTarget.value); - }} - onchange={(e) => { - const idx = Number(e.currentTarget.value); - const m = agent.models[idx]; - if (m) onModelChange(m.key_id, m.model_id); - sliderDragging = null; - }} - onclick={(e) => e.stopPropagation()} - onkeydown={(e) => e.stopPropagation()} - /> - <div class="flex w-full justify-between px-0.5 text-xs opacity-50 mt-0.5"> - {#each agent.models as _, i} - <span>{i + 1}</span> - {/each} - </div> - </div> - {/if} - </div> - {/each} - </div> - {/if} - - <button - type="button" - class="btn btn-outline btn-sm w-full mt-2 hover:bg-base-300 hover:border-base-300 text-base-content/60" - onclick={() => router.navigate("agent-builder")} - > - Agent Settings - </button> - {/if} - - <!-- Prompt-cache warming (bottom of the Chat Settings panel) --> - <div class="mt-3 pt-3 border-t border-base-300"> - <label class="flex items-center gap-2 cursor-pointer"> - <input - type="checkbox" - class="checkbox checkbox-sm rounded-sm" - checked={warm.enabled} - disabled={!activeTabId} - onchange={(e) => toggleCacheWarming(e.currentTarget.checked)} - /> - <span class="text-xs font-semibold">Keep prompt cache warm</span> - </label> - <p class="text-[10px] text-base-content/40 mt-1 leading-snug"> - While this tab is idle, replays the cached conversation every {warmIntervalLabel} - so the provider cache stays warm for your next message. Warming traffic is - debug-only — it never touches history, the Cache Rate metric, or context size. - </p> - - {#if warm.enabled} - <div class="mt-2 flex flex-col gap-2 bg-base-300/40 rounded-lg p-2"> - <!-- Warming "last request" cache rate (separate from the real metric) --> - <div class="flex flex-col gap-0.5"> - <div class="flex items-center justify-between"> - <span class="text-xs text-base-content/50">Last request (warming)</span> - <span class="text-xs font-mono">{warm.lastPct === null ? "-%" : `${warm.lastPct}%`}</span> - </div> - <progress - class="progress w-full h-2 {warm.lastPct === null - ? '' - : warm.lastPct >= 70 - ? 'progress-success' - : warm.lastPct >= 30 - ? 'progress-warning' - : 'progress-error'}" - value={warm.lastPct ?? 0} - max="100" - ></progress> - </div> - - <!-- Countdown to the next warming fire --> - <div class="flex items-center justify-between"> - <span class="text-xs text-base-content/50">Next warm in</span> - <span class="text-xs font-mono"> - {#if warm.firing} - warming… - {:else if warmCountdown !== null} - {warmCountdown} - {:else} - — - {/if} - </span> - </div> - - {#if warm.error} - <div class="text-[10px] text-error break-words"> - {warm.error} - </div> - {/if} - </div> - {/if} - </div> -</div> - -{#if showKeyModal} - <div class="modal modal-open" use:portal> - <div class="modal-box"> - <h3 class="font-bold text-xl">Select Key</h3> - <div class="mt-4 flex flex-col gap-2"> - {#each keys as key} - <button - class="btn {key.id === activeKeyId - ? 'btn-primary' - : 'btn-ghost'} justify-start text-base" - onclick={() => selectKey(key.id)} - > - <span class="font-mono">{key.id}</span> - <span class="badge ml-auto">{key.provider}</span> - <span - class="badge {key.status === 'active' - ? 'badge-success' - : 'badge-error'}">{key.status}</span - > - </button> - {/each} - </div> - <div class="modal-action"> - <button class="btn" onclick={() => (showKeyModal = false)}>Cancel</button> - </div> - </div> - <button type="button" class="modal-backdrop" onclick={() => (showKeyModal = false)} aria-label="Close modal"></button> - </div> -{/if} - -{#if showModelModal} - <div class="modal modal-open" use:portal> - <div class="modal-box"> - <h3 class="font-bold text-xl">Select Model</h3> - {#if loadingModels} - <div class="flex justify-center py-8"> - <span class="loading loading-spinner loading-lg"></span> - </div> - {:else if modelError} - <div class="alert alert-error mt-4 text-base"> - <span>{modelError}</span> - </div> - {:else} - {@const search = modelSearch.toLowerCase().trim()} - {@const searchRegex = search - ? new RegExp( - search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/ /g, "[ _-]"), - ) - : null} - {@const filteredModels = searchRegex - ? availableModels.filter((m) => searchRegex.test(m.toLowerCase())) - : availableModels} - <div class="mt-4 flex flex-col gap-1"> - <input - type="text" - class="input input-bordered input-sm w-full" - placeholder="Filter models..." - bind:value={modelSearch} - /> - <div class="mt-2 max-h-96 overflow-y-auto flex flex-col gap-1"> - {#each filteredModels as model} - <button - class="btn {model === activeModelId - ? 'btn-primary' - : 'btn-ghost'} justify-start font-mono text-base" - onclick={() => selectModel(model)} - > - {model} - </button> - {/each} - {#if filteredModels.length === 0} - <p class="text-xs text-base-content/50 py-2 text-center"> - {search ? 'No models match your search.' : 'No models available.'} - </p> - {/if} - </div> - </div> - {/if} - <div class="modal-action"> - <button class="btn" onclick={() => (showModelModal = false)}>Cancel</button> - </div> - </div> - <button type="button" class="modal-backdrop" onclick={() => (showModelModal = false)} aria-label="Close modal"></button> - </div> -{/if} |
