summaryrefslogtreecommitdiffhomepage
path: root/packages/frontend/src/lib
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-20 20:40:35 +0900
committerAdam Malczewski <[email protected]>2026-05-20 20:40:35 +0900
commit8151447758e6826a578363758a755c6cebd1c05f (patch)
tree6afa780c28ca6e4622c1ab30238665caaad4371e /packages/frontend/src/lib
parentf05099d450748cc7508f8cbde4e6539db2105f6d (diff)
downloaddispatch-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')
-rw-r--r--packages/frontend/src/lib/chat.svelte.ts76
-rw-r--r--packages/frontend/src/lib/components/ChatMessage.svelte13
-rw-r--r--packages/frontend/src/lib/components/Header.svelte2
-rw-r--r--packages/frontend/src/lib/components/MarkdownRenderer.svelte7
-rw-r--r--packages/frontend/src/lib/components/ModelSelector.svelte187
-rw-r--r--packages/frontend/src/lib/components/SidebarPanel.svelte53
-rw-r--r--packages/frontend/src/lib/components/ToolCallDisplay.svelte2
-rw-r--r--packages/frontend/src/lib/types.ts22
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;