summaryrefslogtreecommitdiffhomepage
path: root/packages/frontend/src/lib/components/ModelSelector.svelte
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-04 21:21:20 +0900
committerAdam Malczewski <[email protected]>2026-06-04 21:21:20 +0900
commit394f1ed37ce860da6fdc385769bf29f9737105cd (patch)
tree4b825dc642cb6eb9a060e54bf8d69288fbee4904 /packages/frontend/src/lib/components/ModelSelector.svelte
parent81a9cdbadf8c9d940d4fe9a2a0de607dee1f5f1a (diff)
downloaddispatch-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.svelte636
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">&#x2714;</span>
- {:else if cwdExists === false}
- <span class="text-warning text-sm" title="Will be created">&#x2716;</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}