- 同步阻塞操作(附件处理、历史加载、scheduler/memory_search 的 SQLite 调用) 移入 spawn_blocking,避免占用 async worker - LLM Provider reqwest::Client 按超时配置缓存复用,减少 TLS/连接开销 - agent loop:图片过滤加廉价预判避免全量深拷贝;请求克隆改借用;工具定义 Arc 化 - 定向 COUNT/LIMIT 1 查询替代全量加载计数(wait_coordinator、task session 重建) - 前端:面板/侧栏/聊天组件 memo 化;merged_tool 对象按值复用缓存; 流式 delta rAF 节流批量 flush;useMemo 缓存分组排序结果
289 lines
11 KiB
TypeScript
289 lines
11 KiB
TypeScript
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<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);
|
||
// 竞态防护:快速切换话题时,旧请求的响应晚于新请求返回会覆盖新状态。
|
||
// 每次发起刷新递增 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 (
|
||
<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>
|
||
);
|
||
});
|