PicoBot/web/src/components/Chat/ModelSelector.tsx
oudecheng eaee29841d feat(web): 对齐 deepseek-harness 设计语言,重构前端视觉与三栏布局
- 设计 token 重映射为 DeepSeek 调色板(深浅双主题),正文改无衬线字体栈,全面去霓虹化
- 移除顶部 Header,控件迁入左栏(Logo/连接/通道/会话/tabs/底部操作区),左右栏支持拖拽调宽并持久化,折叠为图标 rail
- 对话区对齐 DeepSeek 聊天风:hero/docked 状态机、736px 居中列、用户右对齐柔和气泡、助手无气泡平铺、composer 卡片与反差发送键;子智能体点击导航完整保留
- 全功能区同步换肤:侧栏列表、右栏面板、ConfigPage、弹窗、选择器等
- fix: 修复 virtual-core 3.17.x 陈旧行高导致消息行重叠(宽度变化/滚动停止后强制 resizeItem 重测)
- fix: 修复 ConfigPage/Modal 引用不存在的 fadeIn/scaleIn keyframes 致入场动画失效
2026-08-14 19:15:45 +08:00

267 lines
10 KiB
TypeScript
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.

import { useState, useEffect, useRef, useCallback } from 'react';
import { Cpu, ChevronDown, Loader2, Check } from 'lucide-react';
import { listModelOptions, selectModel, getSelectedModel } from '../../api/experts';
import type { ModelOptionsResponse } from '../Settings/types';
interface ModelSelectorProps {
sessionId: string | null;
/** 设置弹窗关闭信号(每次关闭递增,用于触发刷新) */
settingsClosedTick?: number;
/** 选择变化回调(参数为生效的 provider/model未覆盖时为 current 默认) */
onSelectionChange?: (effective: { provider: string; model: string; overridden: boolean }) => void;
}
export function ModelSelector({
sessionId,
settingsClosedTick,
onSelectionChange,
}: ModelSelectorProps) {
const [modelOptions, setModelOptions] = useState<ModelOptionsResponse | null>(null);
const [userProvider, setUserProvider] = useState<string | null>(null);
const [userModel, setUserModel] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// 草稿:用户在 dropdown 中暂存的选择,点击应用后才提交
const [draftProvider, setDraftProvider] = useState<string>('');
const [draftModel, setDraftModel] = useState<string>('');
const containerRef = useRef<HTMLDivElement>(null);
// 刷新当前会话的用户模型覆盖
const refreshSelection = useCallback(() => {
if (!sessionId) {
setUserProvider(null);
setUserModel(null);
return;
}
setLoading(true);
setError(null);
getSelectedModel(sessionId)
.then((data) => {
setUserProvider(data.provider);
setUserModel(data.model);
})
.catch(() => {
setUserProvider(null);
setUserModel(null);
})
.finally(() => setLoading(false));
}, [sessionId]);
// 加载模型选项(全局缓存,仅加载一次)
useEffect(() => {
if (modelOptions) return;
listModelOptions().then((data) => {
if (data) setModelOptions(data);
});
}, [modelOptions]);
// sessionId 变化时刷新用户选择
useEffect(() => {
refreshSelection();
}, [refreshSelection]);
// 设置弹窗关闭时刷新(处理 config.json 中 provider/model 变更)
useEffect(() => {
if (settingsClosedTick === undefined) return;
refreshSelection();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settingsClosedTick]);
// 计算生效模型并通知父组件
const overridden = userProvider !== null || userModel !== null;
const effectiveProvider = userProvider ?? modelOptions?.current.provider ?? '';
const effectiveModel = userModel ?? modelOptions?.current.model ?? '';
useEffect(() => {
if (!modelOptions) return;
onSelectionChange?.({
provider: effectiveProvider,
model: effectiveModel,
overridden,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [effectiveProvider, effectiveModel, overridden, modelOptions]);
// 点击外部关闭 dropdown
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
const handleToggleOpen = () => {
const next = !open;
setOpen(next);
if (next) {
// 打开时刷新选项与当前选择,同步草稿
if (!modelOptions) {
listModelOptions().then((data) => {
if (data) setModelOptions(data);
});
}
refreshSelection();
setDraftProvider(userProvider ?? '');
setDraftModel(userModel ?? '');
}
};
const handleApply = async () => {
if (!sessionId) return;
const provider = draftProvider.trim() || null;
const model = draftModel.trim() || null;
setSaving(true);
setError(null);
try {
const result = await selectModel(sessionId, provider, model);
if (!result.success) {
setError(result.error || '切换模型失败');
setTimeout(() => setError(null), 3000);
return;
}
setUserProvider(provider);
setUserModel(model);
setOpen(false);
} catch {
setError('网络错误,切换模型失败');
setTimeout(() => setError(null), 3000);
} finally {
setSaving(false);
}
};
const handleReset = () => {
setDraftProvider('');
setDraftModel('');
};
if (!sessionId) return null;
// 草稿是否与已保存状态不同(用于启用"应用"按钮)
const draftChanged =
(draftProvider || null) !== (userProvider ?? null) ||
(draftModel || null) !== (userModel ?? null);
const buttonLabel = overridden
? `${effectiveProvider}/${effectiveModel}`
: `默认 ${modelOptions?.current.provider ?? ''}/${modelOptions?.current.model ?? ''}`;
return (
<div ref={containerRef} className="relative shrink-0 flex items-center gap-2">
<div className="relative">
<button
onClick={handleToggleOpen}
disabled={loading}
className="group inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg border border-[var(--border-color)] bg-[var(--bg-tertiary)]/60 hover:border-[var(--accent-cyan)]/40 hover:bg-[var(--bg-tertiary)] transition-colors text-xs disabled:opacity-50"
title={
overridden
? `用户覆盖: ${effectiveProvider}/${effectiveModel}`
: `继承默认: ${effectiveProvider}/${effectiveModel}`
}
>
{loading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-[var(--text-muted)]" />
) : (
<Cpu
className={`h-3.5 w-3.5 ${overridden ? 'text-[var(--accent-cyan)]' : 'text-[var(--text-muted)]'}`}
/>
)}
<span
className={`truncate max-w-[200px] ${overridden ? 'text-[var(--text-primary)] font-medium' : 'text-[var(--text-muted)]'}`}
>
{buttonLabel}
</span>
<ChevronDown
className={`h-3 w-3 text-[var(--text-muted)] transition-transform ${open ? 'rotate-180' : ''}`}
/>
</button>
{open && (
<div className="absolute z-30 bottom-full mb-1 left-1/2 -translate-x-1/2 w-80 max-w-[90vw] rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)] shadow-2xl backdrop-blur-md overflow-hidden p-3 space-y-3">
<div className="text-xs text-[var(--text-muted)]">
{overridden
? `当前: ${effectiveProvider}/${effectiveModel}(已覆盖)`
: `当前: 继承默认(${modelOptions?.current.provider ?? '-'}/${modelOptions?.current.model ?? '-'}`}
</div>
<div className="space-y-2">
<label className="block text-xs font-medium text-[var(--text-secondary)]">
Provider
<select
value={draftProvider}
onChange={(e) => setDraftProvider(e.target.value)}
className="mt-1 w-full rounded-md border border-[var(--border-color)] bg-[var(--bg-tertiary)] px-2 py-1.5 text-sm text-[var(--text-primary)] focus:border-[var(--accent-cyan)] focus:outline-none"
>
<option value="">
{modelOptions ? `${modelOptions.current.provider}` : ''}
</option>
{(modelOptions?.providers ?? []).map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
</label>
<label className="block text-xs font-medium text-[var(--text-secondary)]">
Model
<select
value={draftModel}
onChange={(e) => setDraftModel(e.target.value)}
className="mt-1 w-full rounded-md border border-[var(--border-color)] bg-[var(--bg-tertiary)] px-2 py-1.5 text-sm text-[var(--text-primary)] focus:border-[var(--accent-cyan)] focus:outline-none"
>
<option value="">
{modelOptions ? `${modelOptions.current.model}` : ''}
</option>
{(modelOptions?.models ?? []).map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
</label>
</div>
{error && <div className="text-xs text-[rgb(242,90,90)] truncate">{error}</div>}
<div className="flex items-center justify-between gap-2 pt-1">
<button
onClick={handleReset}
disabled={saving}
className="text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50"
>
</button>
<div className="flex items-center gap-2">
{draftChanged && (
<Check className="h-3 w-3 text-[var(--accent-cyan)] animate-pulse" />
)}
<button
onClick={handleApply}
disabled={saving || !draftChanged}
className="inline-flex items-center gap-1 px-3 py-1 rounded-md text-xs font-medium bg-[var(--accent-cyan)]/20 text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/30 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{saving ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Check className="h-3 w-3" />
)}
</button>
</div>
</div>
</div>
)}
</div>
{error && <span className="text-xs text-[rgb(242,90,90)] truncate">{error}</span>}
</div>
);
}