From 8f59b6b93a71c9b644a1fff757a15a3542ab7edb Mon Sep 17 00:00:00 2001
From: oudecheng <13802883547@139.com>
Date: Sat, 15 Aug 2026 15:34:38 +0800
Subject: [PATCH] =?UTF-8?q?feat(web):=20=E8=AF=9D=E9=A2=98=E7=BA=A7?=
=?UTF-8?q?=E6=A8=A1=E5=9E=8B=E9=80=89=E6=8B=A9=E5=89=8D=E7=AB=AF=EF=BC=88?=
=?UTF-8?q?ModelSelector=20topicId=20=E5=8C=96=20+=20=E7=AB=9E=E6=80=81?=
=?UTF-8?q?=E9=98=B2=E6=8A=A4=EF=BC=89+=20=E4=B8=93=E5=AE=B6=E7=B3=BB?=
=?UTF-8?q?=E7=BB=9F=E5=BC=80=E5=85=B3=E9=9A=90=E8=97=8F?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- ModelSelector 支持按 topicId 读写话题级选择(topic 优先,session 兜底)
- 快速切换话题时用递增令牌丢弃过期响应,防止乱序覆盖
- ChatContainer 透传 topicId,向后端双写新话题默认模型
- ExpertSelector 依据后端 experts_system_enabled 隐藏选择器并清除已选专家
---
web/src/api/client.ts | 2 ++
web/src/api/experts.ts | 32 +++++++++++++++++++++
web/src/components/Chat/ChatContainer.tsx | 15 +++++++++-
web/src/components/Chat/ExpertSelector.tsx | 25 ++++++++++++++--
web/src/components/Chat/ModelSelector.tsx | 33 ++++++++++++++++++----
web/src/types/protocol.ts | 4 +++
6 files changed, 101 insertions(+), 10 deletions(-)
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 {