feat(web): 话题级模型选择前端(ModelSelector topicId 化 + 竞态防护)+ 专家系统开关隐藏
- ModelSelector 支持按 topicId 读写话题级选择(topic 优先,session 兜底) - 快速切换话题时用递增令牌丢弃过期响应,防止乱序覆盖 - ChatContainer 透传 topicId,向后端双写新话题默认模型 - ExpertSelector 依据后端 experts_system_enabled 隐藏选择器并清除已选专家
This commit is contained in:
parent
dc693fa80b
commit
8f59b6b93a
@ -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';
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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 = (
|
||||
<div className="flex flex-wrap items-center gap-1 px-3 pt-2">
|
||||
@ -58,7 +63,14 @@ export function ChatContainer({
|
||||
onSelectionChange={setSelectedExpert}
|
||||
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>
|
||||
);
|
||||
|
||||
@ -107,6 +119,7 @@ export function ChatContainer({
|
||||
showThinking={showThinking}
|
||||
viewKey={viewKey}
|
||||
highlightedMessageId={highlightedMessageId}
|
||||
effectiveModel={effectiveModel}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -36,6 +36,8 @@ export function ExpertSelector({
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [listLoading, setListLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// 专家系统开关(设置页可关闭);默认 true 避免加载期间闪烁隐藏
|
||||
const [systemEnabled, setSystemEnabled] = useState(true);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(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 (
|
||||
<div ref={containerRef} className="relative shrink-0 flex items-center gap-2">
|
||||
|
||||
@ -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<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);
|
||||
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);
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user