import { useState, useEffect, useRef, useCallback, memo } from 'react'; import { Cpu, ChevronDown, Loader2, Check } from 'lucide-react'; import { listModelOptions, selectModel, getSelectedModel, selectTopicModel, getSelectedTopicModel, } from '../../api/experts'; import type { ModelOptionsResponse } from '../Settings/types'; interface ModelSelectorProps { sessionId: string | null; /** 当前话题 ID:提供时按话题级选择读写(topic 优先,session 兜底) */ topicId?: string | null; /** 设置弹窗关闭信号(每次关闭递增,用于触发刷新) */ settingsClosedTick?: number; /** 选择变化回调(参数为生效的 provider/model,未覆盖时为 current 默认) */ onSelectionChange?: (effective: { provider: string; model: string; overridden: boolean }) => void; } // memo:props 全部稳定(useCallback/原始值),流式期间跳过重渲染 export const ModelSelector = memo(function ModelSelector({ sessionId, topicId, settingsClosedTick, onSelectionChange, }: ModelSelectorProps) { const [modelOptions, setModelOptions] = useState(null); const [userProvider, setUserProvider] = useState(null); const [userModel, setUserModel] = useState(null); const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); // 草稿:用户在 dropdown 中暂存的选择,点击应用后才提交 const [draftProvider, setDraftProvider] = useState(''); const [draftModel, setDraftModel] = useState(''); const containerRef = useRef(null); // 竞态防护:快速切换话题时,旧请求的响应晚于新请求返回会覆盖新状态。 // 每次发起刷新递增 token,响应落地时校验 token 未变才应用。 const refreshTokenRef = useRef(0); // 刷新当前话题/会话的用户模型覆盖(topic 级优先,session 级兜底) const refreshSelection = useCallback(() => { if (!sessionId) { setUserProvider(null); setUserModel(null); return; } const token = ++refreshTokenRef.current; setLoading(true); setError(null); const fetcher = topicId ? getSelectedTopicModel(topicId) : getSelectedModel(sessionId); fetcher .then((data) => { if (refreshTokenRef.current !== token) return; // 已被更新的刷新取代,丢弃 setUserProvider(data.provider); setUserModel(data.model); }) .catch(() => { if (refreshTokenRef.current !== token) return; setUserProvider(null); setUserModel(null); }) .finally(() => { if (refreshTokenRef.current === token) setLoading(false); }); }, [sessionId, topicId]); // 加载模型选项(全局缓存,仅加载一次) 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 { // 有话题时写话题级(后端双写 topics 行 + session store);否则写 session 级 const result = topicId ? await selectTopicModel(sessionId, topicId, provider, model) : 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 (
{open && (
{overridden ? `当前: ${effectiveProvider}/${effectiveModel}(已覆盖)` : `当前: 继承默认(${modelOptions?.current.provider ?? '-'}/${modelOptions?.current.model ?? '-'})`}
{error &&
{error}
}
{draftChanged && ( )}
)}
{error && {error}}
); });