- 设计 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 致入场动画失效
267 lines
10 KiB
TypeScript
267 lines
10 KiB
TypeScript
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>
|
||
);
|
||
}
|