refine model and thinking selectors

This commit is contained in:
wuyang
2026-07-26 19:18:28 +08:00
parent d95d06233f
commit bc30a480ad
+314 -28
View File
@@ -1,20 +1,66 @@
<script lang="ts"> <script lang="ts">
import { models } from '$lib/stores'; import { models } from '$lib/stores';
import { flyAndScale } from '$lib/utils/transitions';
import WorkspaceFiles from './WorkspaceFiles.svelte'; import WorkspaceFiles from './WorkspaceFiles.svelte';
export let selectedModels = ['']; export let selectedModels = [''];
export let disabled = false; export let disabled = false;
export let showSetDefault = true; export let showSetDefault = true;
let showWorkspace = false;
const modelOptions = [ const modelOptions = [
{ id: 'luna', label: 'Luna', thinking: '开启 · 不分档' }, {
{ id: 'terra', label: 'Terra', thinking: '开启 · 不分档' }, id: 'luna',
{ id: 'sol', label: 'Sol', thinking: '开启 · 不分档' }, label: 'Luna',
{ id: 'deepseek-v4-pro', label: 'DeepSeek', thinking: '极高' } 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)); 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'; $: selected = allowed.has(selectedModels?.[0]) ? selectedModels[0] : 'terra';
$: selectedOption = modelOptions.find((item) => item.id === selected) ?? modelOptions[1]; $: selectedOption = modelOptions.find((item) => item.id === selected) ?? modelOptions[1];
$: availableIds = new Set($models.map((model) => model.id)); $: availableIds = new Set($models.map((model) => model.id));
@@ -26,50 +72,290 @@
if (fallback) selectedModels = [fallback]; 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) => { const chooseModel = (modelId: string) => {
if (disabled || !availableIds.has(modelId)) return; if (disabled || ($models.length > 0 && !availableIds.has(modelId))) return;
selectedModels = [modelId]; 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> </script>
<div class="flex max-w-full items-center gap-2 select-none"> <svelte:window
<div on:pointerdown={closePopovers}
class="flex items-center overflow-x-auto rounded-xl border border-gray-200/80 p-1 text-xs dark:border-gray-700/80" on:keydown={handleKeydown}
aria-label="模型" on:resize={updatePositions}
> on:scroll={updatePositions}
{#each modelOptions as item} />
<div class="flex max-w-full select-none items-center gap-0.5">
<button <button
bind:this={modelTrigger}
type="button" type="button"
class="whitespace-nowrap rounded-lg px-2.5 py-1.5 font-medium transition {selected === item.id 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]"
? 'bg-gray-900 text-white dark:bg-white dark:text-gray-900' on:click={toggleModels}
: 'text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800'}" disabled={disabled}
on:click={() => chooseModel(item.id)} aria-label="选择模型,当前为 {selectedOption.label}"
disabled={disabled || ($models.length > 0 && !availableIds.has(item.id))} aria-haspopup="listbox"
title={item.id === 'deepseek-v4-pro' ? 'DeepSeek V4 Pro' : item.label} aria-expanded={showModels}
> >
{item.label} <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> </button>
{/each}
</div>
<div <div class="mx-0.5 h-4 w-px bg-gray-200 dark:bg-gray-700" aria-hidden="true" />
class="whitespace-nowrap rounded-xl bg-gray-100/90 px-2.5 py-2 text-xs text-gray-500 dark:bg-gray-800/90 dark:text-gray-400"
title="模型与思考能力是两个独立概念" <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}
> >
思考 <span class="font-medium text-gray-800 dark:text-gray-200">{selectedOption.thinking}</span> <svg
</div> 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 <button
type="button" type="button"
class="rounded-xl border border-gray-200/80 p-2 text-gray-500 transition hover:bg-gray-100 hover:text-gray-900 dark:border-gray-700/80 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white" 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)} on:click={() => (showWorkspace = true)}
title="工作区文件" title="工作区文件"
aria-label="工作区文件" aria-label="工作区文件"
> >
<svg viewBox="0 0 24 24" class="size-4" fill="none" stroke="currentColor" stroke-width="1.8"> <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" /> <path d="M3.5 6.5h6l2 2h9v10h-17z" stroke-linejoin="round" />
</svg> </svg>
</button> </button>
</div> </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} /> <WorkspaceFiles bind:show={showWorkspace} />