feat(web): 话题级模型选择前端(ModelSelector topicId 化 + 竞态防护)+ 专家系统开关隐藏

- ModelSelector 支持按 topicId 读写话题级选择(topic 优先,session 兜底)
- 快速切换话题时用递增令牌丢弃过期响应,防止乱序覆盖
- ChatContainer 透传 topicId,向后端双写新话题默认模型
- ExpertSelector 依据后端 experts_system_enabled 隐藏选择器并清除已选专家
This commit is contained in:
oudecheng 2026-08-15 15:34:38 +08:00
parent dc693fa80b
commit 8f59b6b93a
6 changed files with 101 additions and 10 deletions

View File

@ -22,6 +22,8 @@ export const API = {
expertsSelect: '/api/experts/select', expertsSelect: '/api/experts/select',
sessionSelectModel: '/api/session/select-model', sessionSelectModel: '/api/session/select-model',
sessionSelectedModel: '/api/session/selected-model', sessionSelectedModel: '/api/session/selected-model',
topicSelectModel: '/api/topic/select-model',
topicSelectedModel: '/api/topic/selected-model',
} as const; } as const;
const TOKEN_KEY = 'picobot-gateway-token'; const TOKEN_KEY = 'picobot-gateway-token';

View File

@ -106,3 +106,35 @@ export async function getSelectedModel(
if (!resp.ok) return { provider: null, model: null }; if (!resp.ok) return { provider: null, model: null };
return resp.json(); 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();
}

View File

@ -49,6 +49,11 @@ export function ChatContainer({
name: string; name: string;
description: string; description: string;
} | null>(null); } | null>(null);
// 当前生效模型(供 Task 卡片差异显示:子代理模型 ≠ 主代理模型时提示)
const [effectiveModel, setEffectiveModel] = useState<{
provider: string;
model: string;
} | null>(null);
const selectors = ( const selectors = (
<div className="flex flex-wrap items-center gap-1 px-3 pt-2"> <div className="flex flex-wrap items-center gap-1 px-3 pt-2">
@ -58,7 +63,14 @@ export function ChatContainer({
onSelectionChange={setSelectedExpert} onSelectionChange={setSelectedExpert}
settingsClosedTick={settingsClosedTick} settingsClosedTick={settingsClosedTick}
/> />
<ModelSelector sessionId={sessionId ?? null} settingsClosedTick={settingsClosedTick} /> <ModelSelector
sessionId={sessionId ?? null}
topicId={topicId ?? null}
settingsClosedTick={settingsClosedTick}
onSelectionChange={(effective) =>
setEffectiveModel({ provider: effective.provider, model: effective.model })
}
/>
</div> </div>
); );
@ -107,6 +119,7 @@ export function ChatContainer({
showThinking={showThinking} showThinking={showThinking}
viewKey={viewKey} viewKey={viewKey}
highlightedMessageId={highlightedMessageId} highlightedMessageId={highlightedMessageId}
effectiveModel={effectiveModel}
/> />
</div> </div>
)} )}

View File

@ -36,6 +36,8 @@ export function ExpertSelector({
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [listLoading, setListLoading] = useState(false); const [listLoading, setListLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// 专家系统开关(设置页可关闭);默认 true 避免加载期间闪烁隐藏
const [systemEnabled, setSystemEnabled] = useState(true);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@ -72,10 +74,27 @@ export function ExpertSelector({
refreshSelection(); refreshSelection();
}, [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(() => { useEffect(() => {
if (settingsClosedTick === undefined) return; if (settingsClosedTick === undefined) return;
refreshSelection(); refreshSelection();
checkSystemEnabled();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [settingsClosedTick]); }, [settingsClosedTick]);
@ -143,8 +162,8 @@ export function ExpertSelector({
onManageExperts?.(); onManageExperts?.();
}; };
// If sessionId is null, render nothing // If sessionId is null or expert system disabled in settings, render nothing
if (!sessionId) return null; if (!sessionId || !systemEnabled) return null;
return ( return (
<div ref={containerRef} className="relative shrink-0 flex items-center gap-2"> <div ref={containerRef} className="relative shrink-0 flex items-center gap-2">

View File

@ -1,10 +1,18 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { Cpu, ChevronDown, Loader2, Check } from 'lucide-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'; import type { ModelOptionsResponse } from '../Settings/types';
interface ModelSelectorProps { interface ModelSelectorProps {
sessionId: string | null; sessionId: string | null;
/** 当前话题 ID提供时按话题级选择读写topic 优先session 兜底) */
topicId?: string | null;
/** 设置弹窗关闭信号(每次关闭递增,用于触发刷新) */ /** 设置弹窗关闭信号(每次关闭递增,用于触发刷新) */
settingsClosedTick?: number; settingsClosedTick?: number;
/** 选择变化回调(参数为生效的 provider/model未覆盖时为 current 默认) */ /** 选择变化回调(参数为生效的 provider/model未覆盖时为 current 默认) */
@ -13,6 +21,7 @@ interface ModelSelectorProps {
export function ModelSelector({ export function ModelSelector({
sessionId, sessionId,
topicId,
settingsClosedTick, settingsClosedTick,
onSelectionChange, onSelectionChange,
}: ModelSelectorProps) { }: ModelSelectorProps) {
@ -29,27 +38,36 @@ export function ModelSelector({
const [draftModel, setDraftModel] = useState<string>(''); const [draftModel, setDraftModel] = useState<string>('');
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
// 竞态防护:快速切换话题时,旧请求的响应晚于新请求返回会覆盖新状态。
// 每次发起刷新递增 token响应落地时校验 token 未变才应用。
const refreshTokenRef = useRef(0);
// 刷新当前会话的用户模型覆盖 // 刷新当前话题/会话的用户模型覆盖topic 级优先session 级兜底)
const refreshSelection = useCallback(() => { const refreshSelection = useCallback(() => {
if (!sessionId) { if (!sessionId) {
setUserProvider(null); setUserProvider(null);
setUserModel(null); setUserModel(null);
return; return;
} }
const token = ++refreshTokenRef.current;
setLoading(true); setLoading(true);
setError(null); setError(null);
getSelectedModel(sessionId) const fetcher = topicId ? getSelectedTopicModel(topicId) : getSelectedModel(sessionId);
fetcher
.then((data) => { .then((data) => {
if (refreshTokenRef.current !== token) return; // 已被更新的刷新取代,丢弃
setUserProvider(data.provider); setUserProvider(data.provider);
setUserModel(data.model); setUserModel(data.model);
}) })
.catch(() => { .catch(() => {
if (refreshTokenRef.current !== token) return;
setUserProvider(null); setUserProvider(null);
setUserModel(null); setUserModel(null);
}) })
.finally(() => setLoading(false)); .finally(() => {
}, [sessionId]); if (refreshTokenRef.current === token) setLoading(false);
});
}, [sessionId, topicId]);
// 加载模型选项(全局缓存,仅加载一次) // 加载模型选项(全局缓存,仅加载一次)
useEffect(() => { useEffect(() => {
@ -120,7 +138,10 @@ export function ModelSelector({
setSaving(true); setSaving(true);
setError(null); setError(null);
try { 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) { if (!result.success) {
setError(result.error || '切换模型失败'); setError(result.error || '切换模型失败');
setTimeout(() => setError(null), 3000); setTimeout(() => setError(null), 3000);

View File

@ -519,6 +519,10 @@ export interface TaskToolResult {
summary: string; summary: string;
output: string; output: string;
task_id: string; task_id: string;
/** 子代理实际使用的 provider 名running 占位结果携带,供卡片差异显示) */
provider?: string;
/** 子代理实际使用的 model id同上 */
model?: string;
} }
export interface Topic { export interface Topic {