PicoBot/web/src/components/Chat/ModelSelector.tsx
oudecheng 0159227828 perf: 第二批性能修复——blocking 线程池隔离、HTTP 客户端复用、前端 memo 与流式节流
- 同步阻塞操作(附件处理、历史加载、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 缓存分组排序结果
2026-08-18 06:55:21 +08:00

289 lines
11 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, 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;
}
// memoprops 全部稳定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>
);
});