summaryrefslogtreecommitdiffhomepage
path: root/packages/frontend/src/lib/components/ModelSelector.svelte
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/components/ModelSelector.svelte
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/components/ModelSelector.svelte')
-rw-r--r--packages/frontend/src/lib/components/ModelSelector.svelte187
1 files changed, 187 insertions, 0 deletions
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}