diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 88276bc..5283909 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -22,6 +22,8 @@ export const API = { expertsSelect: '/api/experts/select', sessionSelectModel: '/api/session/select-model', sessionSelectedModel: '/api/session/selected-model', + topicSelectModel: '/api/topic/select-model', + topicSelectedModel: '/api/topic/selected-model', } as const; const TOKEN_KEY = 'picobot-gateway-token'; diff --git a/web/src/api/experts.ts b/web/src/api/experts.ts index 246dc37..fe91c3e 100644 --- a/web/src/api/experts.ts +++ b/web/src/api/experts.ts @@ -106,3 +106,35 @@ export async function getSelectedModel( if (!resp.ok) return { provider: null, model: null }; return resp.json(); } + +/** + * 设置(或清除)话题级模型选择。后端双写:topics 行 + session store(新话题默认值)。 + * provider/model 均为 null 时清除话题级选择(恢复继承 session 级)。 + */ +export async function selectTopicModel( + sessionId: string, + topicId: string, + provider: string | null, + model: string | null, +): Promise<{ success: boolean; error?: string }> { + const resp = await authedFetch(API.topicSelectModel, { + method: 'POST', + body: { session_id: sessionId, topic_id: topicId, provider, model }, + }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok || !data.success) return { success: false, error: data.error || '切换模型失败' }; + return { success: true }; +} + +/** + * 读取话题生效的用户模型选择(topic 级优先,miss 回退 session 级)。 + * 与 session 端点同语义:只反映用户选择,不解析 expert/config 默认。 + */ +export async function getSelectedTopicModel( + topicId: string, +): Promise<{ provider: string | null; model: string | null }> { + const params = new URLSearchParams({ topic_id: topicId }); + const resp = await authedFetch(`${API.topicSelectedModel}?${params}`); + if (!resp.ok) return { provider: null, model: null }; + return resp.json(); +} diff --git a/web/src/components/Chat/ChatContainer.tsx b/web/src/components/Chat/ChatContainer.tsx index fbb9d4b..b6e5c91 100644 --- a/web/src/components/Chat/ChatContainer.tsx +++ b/web/src/components/Chat/ChatContainer.tsx @@ -49,6 +49,11 @@ export function ChatContainer({ name: string; description: string; } | null>(null); + // 当前生效模型(供 Task 卡片差异显示:子代理模型 ≠ 主代理模型时提示) + const [effectiveModel, setEffectiveModel] = useState<{ + provider: string; + model: string; + } | null>(null); const selectors = (
@@ -58,7 +63,14 @@ export function ChatContainer({ onSelectionChange={setSelectedExpert} settingsClosedTick={settingsClosedTick} /> - + + setEffectiveModel({ provider: effective.provider, model: effective.model }) + } + />
); @@ -107,6 +119,7 @@ export function ChatContainer({ showThinking={showThinking} viewKey={viewKey} highlightedMessageId={highlightedMessageId} + effectiveModel={effectiveModel} /> )} diff --git a/web/src/components/Chat/ExpertSelector.tsx b/web/src/components/Chat/ExpertSelector.tsx index 75b5b27..454eca2 100644 --- a/web/src/components/Chat/ExpertSelector.tsx +++ b/web/src/components/Chat/ExpertSelector.tsx @@ -36,6 +36,8 @@ export function ExpertSelector({ const [loading, setLoading] = useState(false); const [listLoading, setListLoading] = useState(false); const [error, setError] = useState(null); + // 专家系统开关(设置页可关闭);默认 true 避免加载期间闪烁隐藏 + const [systemEnabled, setSystemEnabled] = useState(true); const containerRef = useRef(null); @@ -72,10 +74,27 @@ export function ExpertSelector({ refreshSelection(); }, [refreshSelection]); - // 设置弹窗关闭时刷新选中状态(处理已选专家被禁用/删除的情况) + // 检查专家系统是否启用(设置页 experts.enabled 开关); + // 关闭时隐藏输入框上方的专家选择器,并清除已选专家 + const checkSystemEnabled = useCallback(async () => { + const data = await listExperts(); + if (!data) return; // 网络失败保持现状 + setSystemEnabled(data.experts_system_enabled); + if (!data.experts_system_enabled) { + setSelectedExpert(null); + onSelectionChange?.(null); + } + }, [onSelectionChange]); + + useEffect(() => { + checkSystemEnabled(); + }, [checkSystemEnabled]); + + // 设置弹窗关闭时刷新选中状态(处理已选专家被禁用/删除/系统开关切换的情况) useEffect(() => { if (settingsClosedTick === undefined) return; refreshSelection(); + checkSystemEnabled(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [settingsClosedTick]); @@ -143,8 +162,8 @@ export function ExpertSelector({ onManageExperts?.(); }; - // If sessionId is null, render nothing - if (!sessionId) return null; + // If sessionId is null or expert system disabled in settings, render nothing + if (!sessionId || !systemEnabled) return null; return (
diff --git a/web/src/components/Chat/ModelSelector.tsx b/web/src/components/Chat/ModelSelector.tsx index d364a01..3e6198c 100644 --- a/web/src/components/Chat/ModelSelector.tsx +++ b/web/src/components/Chat/ModelSelector.tsx @@ -1,10 +1,18 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { Cpu, ChevronDown, Loader2, Check } from 'lucide-react'; -import { listModelOptions, selectModel, getSelectedModel } from '../../api/experts'; +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 默认) */ @@ -13,6 +21,7 @@ interface ModelSelectorProps { export function ModelSelector({ sessionId, + topicId, settingsClosedTick, onSelectionChange, }: ModelSelectorProps) { @@ -29,27 +38,36 @@ export function ModelSelector({ 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); - getSelectedModel(sessionId) + 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(() => setLoading(false)); - }, [sessionId]); + .finally(() => { + if (refreshTokenRef.current === token) setLoading(false); + }); + }, [sessionId, topicId]); // 加载模型选项(全局缓存,仅加载一次) useEffect(() => { @@ -120,7 +138,10 @@ export function ModelSelector({ setSaving(true); setError(null); try { - const result = await selectModel(sessionId, provider, model); + // 有话题时写话题级(后端双写 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); diff --git a/web/src/types/protocol.ts b/web/src/types/protocol.ts index 1c9c97e..958fd97 100644 --- a/web/src/types/protocol.ts +++ b/web/src/types/protocol.ts @@ -519,6 +519,10 @@ export interface TaskToolResult { summary: string; output: string; task_id: string; + /** 子代理实际使用的 provider 名(running 占位结果携带,供卡片差异显示) */ + provider?: string; + /** 子代理实际使用的 model id(同上) */ + model?: string; } export interface Topic {