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',
|
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';
|
||||||
|
|||||||
@ -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();
|
||||||
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user