Files
zk-data-agent/docker/web/ModelSelector.svelte
T
2026-07-26 19:18:28 +08:00

362 lines
12 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { models } from '$lib/stores';
import { flyAndScale } from '$lib/utils/transitions';
import WorkspaceFiles from './WorkspaceFiles.svelte';
export let selectedModels = [''];
export let disabled = false;
export let showSetDefault = true;
const modelOptions = [
{
id: 'luna',
label: 'Luna',
description: '快速、轻量的本地 Agent',
location: '本地',
accent: 'bg-violet-100 text-violet-700 dark:bg-violet-500/15 dark:text-violet-300',
thinking: '开启',
thinkingTitle: '思考模式',
thinkingHelp: '当前固定开启。此模型只支持思考开关,不提供轻、中、高强度档位。'
},
{
id: 'terra',
label: 'Terra',
description: '速度与任务能力均衡',
location: '本地',
accent: 'bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300',
thinking: '开启',
thinkingTitle: '思考模式',
thinkingHelp: '当前固定开启。此模型只支持思考开关,不提供轻、中、高强度档位。'
},
{
id: 'sol',
label: 'Sol',
description: '面向复杂任务的本地 Agent',
location: '本地',
accent: 'bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300',
thinking: '开启',
thinkingTitle: '思考模式',
thinkingHelp: '当前固定开启。此模型只支持思考开关,不提供轻、中、高强度档位。'
},
{
id: 'deepseek-v4-pro',
label: 'DeepSeek V4 Pro',
description: '云端高能力 Agent',
location: '云端',
accent: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300',
thinking: '极高',
thinkingTitle: '推理强度',
thinkingHelp: '当前固定为极高(max),由 Agent 服务端管理。'
}
];
const allowed = new Set(modelOptions.map((item) => item.id));
let showWorkspace = false;
let showModels = false;
let showThinking = false;
let modelTrigger: HTMLElement | null = null;
let modelPanel: HTMLElement | null = null;
let thinkingTrigger: HTMLElement | null = null;
let thinkingPanel: HTMLElement | null = null;
let modelPosition = { top: 0, left: 0, width: 320 };
let thinkingPosition = { top: 0, left: 0, width: 288 };
$: selected = allowed.has(selectedModels?.[0]) ? selectedModels[0] : 'terra';
$: selectedOption = modelOptions.find((item) => item.id === selected) ?? modelOptions[1];
$: availableIds = new Set($models.map((model) => model.id));
$: if ($models.length > 0 && !availableIds.has(selectedModels?.[0])) {
const fallback = availableIds.has('terra')
? 'terra'
: modelOptions.find((item) => availableIds.has(item.id))?.id;
if (fallback) selectedModels = [fallback];
}
const portal = (node: HTMLElement) => {
document.body.appendChild(node);
return {
destroy() {
node.remove();
}
};
};
const positionFor = (trigger: HTMLElement | null, preferredWidth: number) => {
if (!trigger || typeof window === 'undefined') {
return { top: 0, left: 0, width: preferredWidth };
}
const rect = trigger.getBoundingClientRect();
const width = Math.min(preferredWidth, window.innerWidth - 16);
return {
top: rect.bottom + 8,
left: Math.max(8, Math.min(rect.left, window.innerWidth - width - 8)),
width
};
};
const updatePositions = () => {
if (showModels) modelPosition = positionFor(modelTrigger, 320);
if (showThinking) thinkingPosition = positionFor(thinkingTrigger, 288);
};
const toggleModels = () => {
if (disabled) return;
showThinking = false;
showModels = !showModels;
if (showModels) modelPosition = positionFor(modelTrigger, 320);
};
const toggleThinking = () => {
showModels = false;
showThinking = !showThinking;
if (showThinking) thinkingPosition = positionFor(thinkingTrigger, 288);
};
const chooseModel = (modelId: string) => {
if (disabled || ($models.length > 0 && !availableIds.has(modelId))) return;
selectedModels = [modelId];
showModels = false;
};
const closePopovers = (event: PointerEvent) => {
const target = event.target as Node;
if (
(modelTrigger?.contains(target) ?? false) ||
(modelPanel?.contains(target) ?? false) ||
(thinkingTrigger?.contains(target) ?? false) ||
(thinkingPanel?.contains(target) ?? false)
) {
return;
}
showModels = false;
showThinking = false;
};
const handleKeydown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
const focusTarget = showThinking ? thinkingTrigger : modelTrigger;
showModels = false;
showThinking = false;
focusTarget?.focus();
};
</script>
<svelte:window
on:pointerdown={closePopovers}
on:keydown={handleKeydown}
on:resize={updatePositions}
on:scroll={updatePositions}
/>
<div class="flex max-w-full select-none items-center gap-0.5">
<button
bind:this={modelTrigger}
type="button"
class="group flex min-w-0 max-w-[13rem] items-center gap-1.5 rounded-xl px-2.5 py-2 text-sm font-semibold text-gray-900 outline-hidden transition hover:bg-gray-100 focus-visible:ring-2 focus-visible:ring-gray-300 disabled:cursor-not-allowed disabled:opacity-50 dark:text-white dark:hover:bg-gray-800 dark:focus-visible:ring-gray-600 sm:max-w-[16rem]"
on:click={toggleModels}
disabled={disabled}
aria-label="选择模型,当前为 {selectedOption.label}"
aria-haspopup="listbox"
aria-expanded={showModels}
>
<span class="truncate">{selectedOption.label}</span>
<svg
viewBox="0 0 20 20"
class="size-3.5 shrink-0 text-gray-400 transition-transform duration-150 {showModels
? 'rotate-180'
: ''}"
fill="none"
stroke="currentColor"
stroke-width="1.8"
aria-hidden="true"
>
<path d="m6 8 4 4 4-4" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<div class="mx-0.5 h-4 w-px bg-gray-200 dark:bg-gray-700" aria-hidden="true" />
<button
bind:this={thinkingTrigger}
type="button"
class="group flex shrink-0 items-center gap-1.5 rounded-xl px-2.5 py-2 text-xs font-medium text-gray-500 outline-hidden transition hover:bg-gray-100 hover:text-gray-900 focus-visible:ring-2 focus-visible:ring-gray-300 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white dark:focus-visible:ring-gray-600"
on:click={toggleThinking}
aria-label="{selectedOption.thinkingTitle}{selectedOption.thinking}"
aria-haspopup="dialog"
aria-expanded={showThinking}
>
<svg
viewBox="0 0 20 20"
class="size-3.5 text-gray-400 group-hover:text-gray-600 dark:group-hover:text-gray-200"
fill="none"
stroke="currentColor"
stroke-width="1.6"
aria-hidden="true"
>
<path
d="M10 2.5c.3 2.4 1.5 3.6 4 4-2.5.4-3.7 1.6-4 4-.4-2.4-1.6-3.6-4-4 2.4-.4 3.6-1.6 4-4Z"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path d="M4.5 11.5c.2 1.7 1 2.5 2.7 2.7-1.7.3-2.5 1.1-2.7 2.8-.3-1.7-1.1-2.5-2.8-2.8 1.7-.2 2.5-1 2.8-2.7Z" />
<path d="M15 12.5c.2 1.2.8 1.8 2 2-1.2.2-1.8.8-2 2-.2-1.2-.8-1.8-2-2 1.2-.2 1.8-.8 2-2Z" />
</svg>
<span class="hidden sm:inline">{selectedOption.thinkingTitle}</span>
<span class="text-gray-800 dark:text-gray-200">{selectedOption.thinking}</span>
<svg
viewBox="0 0 20 20"
class="size-3 shrink-0 text-gray-400 transition-transform duration-150 {showThinking
? 'rotate-180'
: ''}"
fill="none"
stroke="currentColor"
stroke-width="1.8"
aria-hidden="true"
>
<path d="m6 8 4 4 4-4" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<button
type="button"
class="ml-0.5 rounded-xl p-2 text-gray-500 outline-hidden transition hover:bg-gray-100 hover:text-gray-900 focus-visible:ring-2 focus-visible:ring-gray-300 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white dark:focus-visible:ring-gray-600"
on:click={() => (showWorkspace = true)}
title="工作区文件"
aria-label="工作区文件"
>
<svg
viewBox="0 0 24 24"
class="size-4"
fill="none"
stroke="currentColor"
stroke-width="1.8"
aria-hidden="true"
>
<path d="M3.5 6.5h6l2 2h9v10h-17z" stroke-linejoin="round" />
</svg>
</button>
</div>
{#if showModels}
<div
use:portal
bind:this={modelPanel}
class="fixed z-[9999]"
style="top: {modelPosition.top}px; left: {modelPosition.left}px; width: {modelPosition.width}px;"
transition:flyAndScale
>
<div
class="overflow-hidden rounded-2xl border border-gray-200/70 bg-white p-1.5 text-gray-900 shadow-xl shadow-black/10 dark:border-gray-700/70 dark:bg-gray-850 dark:text-white dark:shadow-black/30"
role="listbox"
aria-label="可用模型"
>
<div class="px-2.5 pb-1.5 pt-1 text-xs font-medium text-gray-400 dark:text-gray-500">
选择模型
</div>
{#each modelOptions as item}
{@const unavailable = $models.length > 0 && !availableIds.has(item.id)}
<button
type="button"
role="option"
aria-selected={selected === item.id}
class="group flex w-full items-center gap-3 rounded-xl px-2.5 py-2.5 text-left outline-hidden transition hover:bg-gray-100 focus-visible:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:bg-gray-800 dark:focus-visible:bg-gray-800"
on:click={() => chooseModel(item.id)}
disabled={disabled || unavailable}
>
<span
class="flex size-8 shrink-0 items-center justify-center rounded-full text-xs font-bold {item.accent}"
aria-hidden="true"
>
{item.label.slice(0, 1)}
</span>
<span class="min-w-0 flex-1">
<span class="flex items-center gap-2">
<span class="truncate text-sm font-medium">{item.label}</span>
<span
class="rounded-full bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-500 dark:bg-gray-800 dark:text-gray-400"
>
{item.location}
</span>
</span>
<span class="mt-0.5 block truncate text-xs text-gray-500 dark:text-gray-400">
{unavailable ? '当前不可用' : item.description}
</span>
</span>
{#if selected === item.id}
<svg
viewBox="0 0 20 20"
class="size-4 shrink-0 text-gray-900 dark:text-white"
fill="none"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path d="m5 10 3 3 7-7" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{/if}
</button>
{/each}
</div>
</div>
{/if}
{#if showThinking}
<div
use:portal
bind:this={thinkingPanel}
class="fixed z-[9999]"
style="top: {thinkingPosition.top}px; left: {thinkingPosition.left}px; width: {thinkingPosition.width}px;"
transition:flyAndScale
>
<div
class="rounded-2xl border border-gray-200/70 bg-white p-2 text-gray-900 shadow-xl shadow-black/10 dark:border-gray-700/70 dark:bg-gray-850 dark:text-white dark:shadow-black/30"
role="dialog"
aria-label="{selectedOption.thinkingTitle}说明"
>
<div class="flex items-center gap-3 rounded-xl bg-gray-50 px-3 py-2.5 dark:bg-gray-800/70">
<span
class="flex size-8 shrink-0 items-center justify-center rounded-full bg-gray-900 text-white dark:bg-white dark:text-gray-900"
aria-hidden="true"
>
<svg
viewBox="0 0 20 20"
class="size-4"
fill="none"
stroke="currentColor"
stroke-width="1.6"
>
<path
d="M10 2.5c.3 2.4 1.5 3.6 4 4-2.5.4-3.7 1.6-4 4-.4-2.4-1.6-3.6-4-4 2.4-.4 3.6-1.6 4-4Z"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path d="M5 12c.2 1.7 1 2.5 2.7 2.7C6 15 5.2 15.8 5 17.5c-.3-1.7-1.1-2.5-2.8-2.8C3.9 14.5 4.7 13.7 5 12Z" />
</svg>
</span>
<span class="min-w-0 flex-1">
<span class="block text-xs text-gray-500 dark:text-gray-400">
{selectedOption.thinkingTitle}
</span>
<span class="block text-sm font-semibold">{selectedOption.thinking}</span>
</span>
<svg
viewBox="0 0 20 20"
class="size-4 shrink-0"
fill="none"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path d="m5 10 3 3 7-7" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
<p class="px-2 pb-1 pt-2.5 text-xs leading-5 text-gray-500 dark:text-gray-400">
{selectedOption.thinkingHelp}
</p>
</div>
</div>
{/if}
<WorkspaceFiles bind:show={showWorkspace} />