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/lib | |
| 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/lib')
8 files changed, 342 insertions, 20 deletions
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; |
