diff options
| author | Adam Malczewski <[email protected]> | 2026-05-20 20:40:35 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-20 20:40:35 +0900 |
| commit | 8151447758e6826a578363758a755c6cebd1c05f (patch) | |
| tree | 6afa780c28ca6e4622c1ab30238665caaad4371e /packages/frontend/src | |
| parent | f05099d450748cc7508f8cbde4e6539db2105f6d (diff) | |
| download | dispatch-8151447758e6826a578363758a755c6cebd1c05f.tar.gz dispatch-8151447758e6826a578363758a755c6cebd1c05f.zip | |
feat: claude max oauth support with multi-account switching, reasoning effort, and dynamic model listing
Diffstat (limited to 'packages/frontend/src')
| -rw-r--r-- | packages/frontend/src/App.svelte | 67 | ||||
| -rw-r--r-- | packages/frontend/src/lib/chat.svelte.ts | 76 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/ChatMessage.svelte | 13 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/Header.svelte | 2 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/MarkdownRenderer.svelte | 7 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/ModelSelector.svelte | 187 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/SidebarPanel.svelte | 53 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/ToolCallDisplay.svelte | 2 | ||||
| -rw-r--r-- | packages/frontend/src/lib/types.ts | 22 |
9 files changed, 364 insertions, 65 deletions
diff --git a/packages/frontend/src/App.svelte b/packages/frontend/src/App.svelte index b221d61..a73dcac 100644 --- a/packages/frontend/src/App.svelte +++ b/packages/frontend/src/App.svelte @@ -4,32 +4,16 @@ import ChatInput from "./lib/components/ChatInput.svelte"; import ChatPanel from "./lib/components/ChatPanel.svelte"; import Header from "./lib/components/Header.svelte"; import PermissionPrompt from "./lib/components/PermissionPrompt.svelte"; -import PermissionLog from "./lib/components/PermissionLog.svelte"; -import ConfigPanel from "./lib/components/ConfigPanel.svelte"; -import SkillsBrowser from "./lib/components/SkillsBrowser.svelte"; -import TaskListPanel from "./lib/components/TaskListPanel.svelte"; -import ModelStatus from "./lib/components/ModelStatus.svelte"; +import ModelSelector from "./lib/components/ModelSelector.svelte"; +import SidebarPanel from "./lib/components/SidebarPanel.svelte"; import HotReloadIndicator from "./lib/components/HotReloadIndicator.svelte"; import { chatStore } from "./lib/chat.svelte.js"; import { wsClient } from "./lib/ws.svelte.js"; import { config } from "./lib/config.js"; +import type { KeyInfo, ModelInfo } from "./lib/types.js"; const STORAGE_KEY = "dispatch-theme"; -interface KeyInfo { - id: string; - provider: string; - status: "active" | "exhausted"; - lastError: string | null; - exhaustedAt: number | null; -} - -interface ModelInfo { - id: string; - provider: string; - tags: string[]; -} - let modelsData = $state<{ models: ModelInfo[]; keys: KeyInfo[]; tags: string[] }>({ models: [], keys: [], @@ -97,34 +81,27 @@ onMount(() => { class:w-0={!sidebarOpen} > <div - class="w-80 flex-1 min-h-0 overflow-y-auto bg-base-100 border-l border-base-300 px-2 py-2 flex flex-col gap-2 transition-transform duration-300 ease-out" + class="w-80 flex-1 min-h-0 overflow-y-auto bg-base-100 border-l border-base-300 px-2 py-2 flex flex-col gap-2 [&>*]:shrink-0 transition-transform duration-300 ease-out" style="transform: translateX({sidebarOpen ? '0' : '100%'})" > - <div class="collapse collapse-arrow bg-base-200"> - <input type="checkbox" checked /> - <div class="collapse-title text-sm font-medium">Model Status</div> - <div class="collapse-content"> - <ModelStatus - models={modelsData.models} - keys={modelsData.keys} - tags={modelsData.tags} - /> - </div> - </div> - - <div class="collapse collapse-arrow bg-base-200"> - <input type="checkbox" checked /> - <div class="collapse-title text-sm font-medium">Tasks</div> - <div class="collapse-content"> - <TaskListPanel tasks={chatStore.tasks} /> - </div> - </div> - - <ConfigPanel apiBase={config.apiBase} /> - - <SkillsBrowser apiBase={config.apiBase} /> - - <PermissionLog entries={chatStore.permissionLog} /> + <ModelSelector + keys={modelsData.keys} + activeKeyId={chatStore.activeKeyId} + activeModelId={chatStore.activeModelId} + reasoningEffort={chatStore.reasoningEffort} + onKeyChange={(keyId) => chatStore.setKey(keyId)} + onModelChange={(keyId, modelId) => chatStore.changeModel(keyId, modelId)} + onReasoningChange={(effort) => { chatStore.reasoningEffort = effort; }} + /> + + <SidebarPanel + models={modelsData.models} + keys={modelsData.keys} + tags={modelsData.tags} + tasks={chatStore.tasks} + permissionLog={chatStore.permissionLog} + apiBase={config.apiBase} + /> </div> </div> </div> diff --git a/packages/frontend/src/lib/chat.svelte.ts b/packages/frontend/src/lib/chat.svelte.ts index 9559f20..e211203 100644 --- a/packages/frontend/src/lib/chat.svelte.ts +++ b/packages/frontend/src/lib/chat.svelte.ts @@ -16,13 +16,23 @@ function makeDebugInfo(overrides: Partial<DebugInfo> = {}): DebugInfo { }; } -function formatConversation(msgs: ChatMessage[]): string { +function formatConversation(msgs: ChatMessage[], activeModelId: string | null): string { const lines: string[] = []; lines.push("=== Dispatch Conversation ==="); lines.push(`Exported: ${new Date().toISOString()}`); + lines.push(`Active Model: ${activeModelId ?? "default"}`); lines.push(""); for (const msg of msgs) { + if (msg.role === "system") { + lines.push(`--- System ---`); + for (const seg of msg.content) { + if (seg.type === "text") lines.push(seg.text); + } + lines.push(""); + continue; + } + const role = msg.role === "user" ? "User" : "Assistant"; lines.push(`--- ${role} ---`); @@ -67,6 +77,9 @@ function formatConversation(msgs: ChatMessage[]): string { function createChatStore() { let messages: ChatMessage[] = $state([]); let agentStatus: "idle" | "running" | "error" = $state("idle"); + let activeKeyId: string | null = $state(null); + let activeModelId: string | null = $state(null); + let reasoningEffort: string = $state("max"); let isConnected = $state(false); let currentAssistantId: string | null = null; let pendingPermissions: PermissionPrompt[] = $state([]); @@ -147,16 +160,15 @@ function createChatStore() { ensureCurrentAssistantMessage(); messages = messages.map((m) => { if (m.id === currentAssistantId) { - const segments: ContentSegment[] = [ - ...m.content, - { - type: "tool-call", - id: event.toolCall.id, - name: event.toolCall.name, - arguments: event.toolCall.arguments, - isExpanded: false, - }, - ]; + const segments: ContentSegment[] = [ + ...m.content, + { + type: "tool-call", + id: event.toolCall.id, + name: event.toolCall.name, + arguments: event.toolCall.arguments, + }, + ]; return { ...m, content: segments }; } return m; @@ -266,7 +278,11 @@ function createChatStore() { const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message: text }), + body: JSON.stringify({ + message: text, + ...(activeKeyId && activeModelId ? { keyId: activeKeyId, modelId: activeModelId } : {}), + reasoningEffort, + }), }); if (!res.ok) { const body = await res.text(); @@ -301,7 +317,35 @@ function createChatStore() { } function copyConversation(): string { - return formatConversation(messages); + return formatConversation(messages, activeModelId); + } + + function changeModel(keyId: string, modelId: string) { + const previousModel = activeModelId; + activeKeyId = keyId; + activeModelId = modelId; + + if (previousModel && previousModel !== modelId) { + const systemMsg: ChatMessage = { + id: generateId(), + role: "system", + content: [{ type: "text", text: `Model changed from ${previousModel} to ${modelId}` }], + }; + messages = [...messages, systemMsg]; + } else if (!previousModel) { + const systemMsg: ChatMessage = { + id: generateId(), + role: "system", + content: [{ type: "text", text: `Model set to ${modelId}` }], + }; + messages = [...messages, systemMsg]; + } + } + + function setKey(keyId: string) { + activeKeyId = keyId; + // Clear model when key changes since available models depend on the key + activeModelId = null; } function replyPermission(id: string, reply: "once" | "always" | "reject") { @@ -358,6 +402,12 @@ function createChatStore() { replyPermission, copyConversation, clear, + changeModel, + setKey, + get activeKeyId() { return activeKeyId; }, + get activeModelId() { return activeModelId; }, + get reasoningEffort() { return reasoningEffort; }, + set reasoningEffort(value: string) { reasoningEffort = value; }, }; } diff --git a/packages/frontend/src/lib/components/ChatMessage.svelte b/packages/frontend/src/lib/components/ChatMessage.svelte index 387dbdc..c8b3f6e 100644 --- a/packages/frontend/src/lib/components/ChatMessage.svelte +++ b/packages/frontend/src/lib/components/ChatMessage.svelte @@ -6,8 +6,20 @@ import ToolCallDisplay from "./ToolCallDisplay.svelte"; const { message }: { message: ChatMessage } = $props(); const isUser = $derived(message.role === "user"); +const isSystem = $derived(message.role === "system"); </script> +{#if isSystem} + <div class="flex justify-center my-2"> + <div class="badge badge-ghost gap-1 text-xs opacity-60"> + {#each message.content as segment} + {#if segment.type === "text"} + {segment.text} + {/if} + {/each} + </div> + </div> +{:else} <div class="chat chat-start mb-2"> <div class="chat-bubble max-w-[80%] break-words {isUser ? 'chat-bubble-primary' : 'bg-transparent'}"> {#if message.thinking} @@ -31,3 +43,4 @@ const isUser = $derived(message.role === "user"); {/if} </div> </div> +{/if} diff --git a/packages/frontend/src/lib/components/Header.svelte b/packages/frontend/src/lib/components/Header.svelte index 5d14593..721a773 100644 --- a/packages/frontend/src/lib/components/Header.svelte +++ b/packages/frontend/src/lib/components/Header.svelte @@ -30,7 +30,7 @@ async function handleCopy() { </div> <div class="navbar-end flex items-center gap-3"> <span class="text-xs text-base-content/60 hidden sm:block"> - DeepSeek V4 Flash via OpenCode Go + {chatStore.activeModelId ?? "Default Model"} </span> <button type="button" diff --git a/packages/frontend/src/lib/components/MarkdownRenderer.svelte b/packages/frontend/src/lib/components/MarkdownRenderer.svelte index f9925c6..f73140f 100644 --- a/packages/frontend/src/lib/components/MarkdownRenderer.svelte +++ b/packages/frontend/src/lib/components/MarkdownRenderer.svelte @@ -92,9 +92,10 @@ const promise = (async () => { try { - // Vite resolves this template literal at build time into a glob - // over all matching language modules, so each is a separate chunk. - const mod = await import(`highlight.js/lib/languages/${name}`); + // Dynamic import for languages not in the hot set above. + // @vite-ignore: the variable `name` is intentionally dynamic; + // missing modules are caught by the try/catch below. + const mod = await import(/* @vite-ignore */ `highlight.js/lib/languages/${name}`); hljs.registerLanguage(name, mod.default); return true; } catch { diff --git a/packages/frontend/src/lib/components/ModelSelector.svelte b/packages/frontend/src/lib/components/ModelSelector.svelte new file mode 100644 index 0000000..b88b2e3 --- /dev/null +++ b/packages/frontend/src/lib/components/ModelSelector.svelte @@ -0,0 +1,187 @@ +<script module> + const modelCache = new Map<string, string[]>(); +</script> + +<script lang="ts"> + import type { KeyInfo } from "../types.js"; + import { config } from "../config.js"; + + // 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(); + }, + }; + } + + const { + keys = [], + activeKeyId = null, + activeModelId = null, + reasoningEffort = "max", + onKeyChange, + onModelChange, + onReasoningChange, + }: { + keys?: KeyInfo[]; + activeKeyId?: string | null; + activeModelId?: string | null; + reasoningEffort?: string; + onKeyChange: (keyId: string) => void; + onModelChange: (keyId: string, modelId: string) => void; + onReasoningChange: (effort: string) => void; + } = $props(); + + let showKeyModal = $state(false); + let showModelModal = $state(false); + let availableModels = $state<string[]>([]); + let loadingModels = $state(false); + let modelError = $state<string | null>(null); + + function selectKey(keyId: string) { + showKeyModal = false; + onKeyChange(keyId); + } + + async function openModelModal() { + if (!activeKeyId) return; + showModelModal = true; + modelError = null; + + // Check session cache + if (modelCache.has(activeKeyId)) { + availableModels = modelCache.get(activeKeyId)!; + loadingModels = false; + return; + } + + loadingModels = true; + availableModels = []; + + try { + const res = await fetch( + `${config.apiBase}/models/available?keyId=${encodeURIComponent(activeKeyId)}`, + ); + 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(activeKeyId, 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"> + <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)} + > + <option value="none">Off</option> + <option value="low">Low</option> + <option value="medium">Medium</option> + <option value="high">High</option> + <option value="max">Max</option> + </select> + </div> + {/if} +</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} + <div class="mt-4 flex flex-col gap-1 max-h-96 overflow-y-auto"> + {#each availableModels as model} + <button + class="btn {model === activeModelId + ? 'btn-primary' + : 'btn-ghost'} justify-start font-mono text-base" + onclick={() => selectModel(model)} + > + {model} + </button> + {/each} + </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} diff --git a/packages/frontend/src/lib/components/SidebarPanel.svelte b/packages/frontend/src/lib/components/SidebarPanel.svelte new file mode 100644 index 0000000..fe92486 --- /dev/null +++ b/packages/frontend/src/lib/components/SidebarPanel.svelte @@ -0,0 +1,53 @@ +<script lang="ts"> + import ModelStatus from "./ModelStatus.svelte"; + import TaskListPanel from "./TaskListPanel.svelte"; + import ConfigPanel from "./ConfigPanel.svelte"; + import SkillsBrowser from "./SkillsBrowser.svelte"; + import PermissionLog from "./PermissionLog.svelte"; + import type { TaskItem, LogEntry, KeyInfo, ModelInfo } from "../types.js"; + + const { + models = [], + keys = [], + tags = [], + tasks = [], + permissionLog = [], + apiBase = "", + }: { + models?: ModelInfo[]; + keys?: KeyInfo[]; + tags?: string[]; + tasks?: TaskItem[]; + permissionLog?: LogEntry[]; + apiBase?: string; + } = $props(); + + let selected = $state("Tasks"); + + const options = ["Model Status", "Tasks", "Config", "Skills", "Permission Log"]; +</script> + +<div class="bg-base-200 rounded-lg p-3"> + <select + class="select select-bordered select-sm w-full" + bind:value={selected} + > + {#each options as option} + <option value={option}>{option}</option> + {/each} + </select> + + <div class="mt-2"> + {#if selected === "Model Status"} + <ModelStatus {models} {keys} {tags} /> + {:else if selected === "Tasks"} + <TaskListPanel {tasks} /> + {:else if selected === "Config"} + <ConfigPanel {apiBase} /> + {:else if selected === "Skills"} + <SkillsBrowser {apiBase} /> + {:else if selected === "Permission Log"} + <PermissionLog entries={permissionLog} /> + {/if} + </div> +</div> diff --git a/packages/frontend/src/lib/components/ToolCallDisplay.svelte b/packages/frontend/src/lib/components/ToolCallDisplay.svelte index d85372c..805de6d 100644 --- a/packages/frontend/src/lib/components/ToolCallDisplay.svelte +++ b/packages/frontend/src/lib/components/ToolCallDisplay.svelte @@ -3,7 +3,7 @@ import type { ToolCallDisplay } from "../types.js"; const { toolCall }: { toolCall: ToolCallDisplay } = $props(); -let isExpanded = $state(toolCall.isExpanded); +let isExpanded = $state(false); function toggle() { isExpanded = !isExpanded; diff --git a/packages/frontend/src/lib/types.ts b/packages/frontend/src/lib/types.ts index fcf6f92..9c83eac 100644 --- a/packages/frontend/src/lib/types.ts +++ b/packages/frontend/src/lib/types.ts @@ -4,7 +4,6 @@ export interface ToolCallDisplay { arguments: Record<string, unknown>; result?: string; isError?: boolean; - isExpanded: boolean; shellOutput?: { stdout: string; stderr: string }; } @@ -26,7 +25,7 @@ export type ContentSegment = export interface ChatMessage { id: string; - role: "user" | "assistant"; + role: "user" | "assistant" | "system"; content: ContentSegment[]; thinking?: string; isStreaming?: boolean; @@ -82,6 +81,25 @@ export interface PermissionPrompt { metadata: Record<string, unknown>; } +export interface ModelOverride { + keyId: string; + modelId: string; +} + +export interface KeyInfo { + id: string; + provider: string; + status: "active" | "exhausted"; + lastError: string | null; + exhaustedAt: number | null; +} + +export interface ModelInfo { + id: string; + provider: string; + tags: string[]; +} + export interface LogEntry { id: string; permission: string; |
