fix(web): 扫描修复——列表响应乱序覆盖与话题切换竞态
- topic_list/session_list 响应增加归属校验(比对 session_id/channel_name),切换瞬间的迟到大响应不再覆盖当前列表 - pendingNewTopic 改为差分消费:仅当响应真正携带新话题时消费聚焦标志,不再被恰好撞上的刷新响应劫持;切换 session/channel 时清除残留标志 - selectTopic 同步双写 selectedTopicRef,消除 setState→effect 同步窗口内旧话题流式消息污染新列表的竞态 - reconcileProcessingTopics 改走 apiGetSilent:裸 fetch 丢认证头导致远程部署 401、对账静默失败话题卡 loading
This commit is contained in:
parent
f010006a66
commit
4218c83890
@ -20,8 +20,10 @@ export interface UseTopicsReturn {
|
||||
topicsRef: MutableRefObject<Topic[]>;
|
||||
selectedTopicRef: MutableRefObject<string | null>;
|
||||
pendingNewTopicRef: MutableRefObject<boolean>;
|
||||
/** 处理 topic_list 消息:映射格式并按 pendingNewTopic 自动聚焦,返回是否自动聚焦了新话题 */
|
||||
handleTopicList: (msg: TopicList) => boolean;
|
||||
/** 处理 topic_list 消息:校验归属后映射格式,按 pendingNewTopic 自动聚焦。
|
||||
* currentSessionId 用于丢弃其他 session 的迟到响应(乱序覆盖防护)。
|
||||
* 返回是否自动聚焦了新话题 */
|
||||
handleTopicList: (msg: TopicList, currentSessionId: string | null) => boolean;
|
||||
/** 处理 topic_renamed 消息:用刷新后的列表替换本地状态(不改 selectedTopic) */
|
||||
handleTopicRenamed: (msg: TopicRenamed) => void;
|
||||
createTopic: (title?: string) => Command;
|
||||
@ -67,20 +69,36 @@ export function useTopics(): UseTopicsReturn {
|
||||
setTopicRefreshTrigger((n) => n + 1);
|
||||
}, []);
|
||||
|
||||
const handleTopicList = useCallback((msg: TopicList): boolean => {
|
||||
const newTopics = mapTopicSummaries(msg.topics);
|
||||
setTopics(newTopics);
|
||||
|
||||
// 新建话题后自动聚焦到新话题(列表按 last_active_at DESC 排序,第一个即最新)
|
||||
if (pendingNewTopicRef.current) {
|
||||
pendingNewTopicRef.current = false;
|
||||
if (newTopics.length > 0) {
|
||||
setSelectedTopic(newTopics[0].id);
|
||||
return true;
|
||||
const handleTopicList = useCallback(
|
||||
(msg: TopicList, currentSessionId: string | null): boolean => {
|
||||
// 归属校验:topic_list 有多个发射源(初始加载/token 防抖刷新/手动刷新/
|
||||
// 重连恢复),切换 session 瞬间旧 session 的在途响应可能晚到。
|
||||
// 协议携带 session_id,不匹配当前 session 的响应直接丢弃,
|
||||
// 防止旧列表覆盖新列表并触发链式的错误自动选中。
|
||||
if (msg.session_id && currentSessionId && msg.session_id !== currentSessionId) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
const newTopics = mapTopicSummaries(msg.topics);
|
||||
setTopics(newTopics);
|
||||
|
||||
// 新建话题后自动聚焦:用差分找出本响应新增的话题(而非盲取第一项)——
|
||||
// 仅当响应确实携带新话题时才消费 pending 标志,避免被恰好撞上的
|
||||
// token 刷新/重连刷新响应错误消费。
|
||||
if (pendingNewTopicRef.current) {
|
||||
const knownIds = new Set(topicsRef.current.map((t) => t.id));
|
||||
const fresh = newTopics.find((t) => !knownIds.has(t.id));
|
||||
if (fresh) {
|
||||
pendingNewTopicRef.current = false;
|
||||
setSelectedTopic(fresh.id);
|
||||
selectedTopicRef.current = fresh.id;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleTopicRenamed = useCallback((msg: TopicRenamed): void => {
|
||||
// 后端返回刷新后的完整列表,直接替换;selectedTopic 基于 id 不变,无需调整
|
||||
|
||||
@ -23,6 +23,7 @@ import { useTopics } from './chat/useTopics';
|
||||
import { useMessages } from './chat/useMessages';
|
||||
import { useSubAgentView } from './chat/useSubAgentView';
|
||||
import { useSchedulerView } from './chat/useSchedulerView';
|
||||
import { apiGetSilent } from '../api/client';
|
||||
|
||||
// 简化后的层级状态
|
||||
interface UseChatReturn {
|
||||
@ -142,13 +143,11 @@ interface UseChatReturn {
|
||||
async function reconcileProcessingTopics(
|
||||
setProcessingTopicIds: Dispatch<SetStateAction<Set<string>>>,
|
||||
) {
|
||||
try {
|
||||
const res = await fetch('/api/executions');
|
||||
if (!res.ok) return;
|
||||
const data = (await res.json()) as { topic_ids?: string[] };
|
||||
// 走统一 apiGetSilent:自动注入认证头(远程部署带 token 时裸 fetch 会 401,
|
||||
// 导致对账静默失败、话题永久卡 loading)。返回 null 时保留前端现有状态。
|
||||
const data = await apiGetSilent<{ topic_ids?: string[] }>('/api/executions');
|
||||
if (data) {
|
||||
setProcessingTopicIds(new Set(data.topic_ids ?? []));
|
||||
} catch {
|
||||
// 查询失败:保留前端现有状态
|
||||
}
|
||||
}
|
||||
|
||||
@ -198,6 +197,11 @@ export function useChat(): UseChatReturn {
|
||||
return;
|
||||
|
||||
case 'session_list': {
|
||||
// 归属校验:切换通道后旧通道的在途 session_list 可能晚到,
|
||||
// 不得用旧通道的会话列表覆盖当前通道(协议携带 channel_name)
|
||||
if (message.channel_name && message.channel_name !== selectedChannelRef.current) {
|
||||
return;
|
||||
}
|
||||
// 重连恢复:重连前已有选中 topic,且原 session 仍存在于新列表中
|
||||
// 保留断连前的 messages(用户仍可查看之前的对话),仅刷新 session/topic 列表
|
||||
// 注意:不发 load_chat_messages——历史消息不带 topic_id(ChatMessage 结构无此字段),
|
||||
@ -238,7 +242,10 @@ export function useChat(): UseChatReturn {
|
||||
return;
|
||||
|
||||
case 'topic_list': {
|
||||
const autoFocused = topics.handleTopicList(message);
|
||||
const autoFocused = topics.handleTopicList(
|
||||
message,
|
||||
sessions.selectedSessionIdRef.current,
|
||||
);
|
||||
if (autoFocused) messages.clearMessages();
|
||||
return;
|
||||
}
|
||||
@ -311,6 +318,10 @@ export function useChat(): UseChatReturn {
|
||||
// ---- selectTopic: 切换话题,清空消息和子智能体栈 ----
|
||||
const selectTopic = useCallback((topicId: string) => {
|
||||
topics.setSelectedTopic(topicId);
|
||||
// 同步双写 ref:setState 后的 effect 同步存在竞态窗口,窗口内到达的
|
||||
// 旧话题 stream_delta/tool_* 消息会按旧 ref 过滤,污染刚清空的消息列表
|
||||
// (与 enter/exitSubAgentView 的既有模式一致)
|
||||
topics.selectedTopicRef.current = topicId;
|
||||
messages.clearMessages();
|
||||
// ref + state 双写,消除竞态窗口(与 enter/exitSubAgentView 一致)
|
||||
subAgent.subAgentViewRef.current = null;
|
||||
@ -328,6 +339,10 @@ export function useChat(): UseChatReturn {
|
||||
sessions.setSelectedSessionId(null);
|
||||
topics.setTopics([]);
|
||||
topics.setSelectedTopic(null);
|
||||
topics.selectedTopicRef.current = null;
|
||||
// 丢弃未消费的新建话题聚焦标志:新通道的首个 topic_list 中所有话题
|
||||
// 都是"新话题",残留标志会被差分逻辑错误消费
|
||||
topics.pendingNewTopicRef.current = false;
|
||||
messages.clearMessages();
|
||||
subAgent.subAgentViewRef.current = null;
|
||||
subAgent.subAgentStackRef.current = [];
|
||||
@ -346,6 +361,10 @@ export function useChat(): UseChatReturn {
|
||||
sessions.setSelectedSessionId(sessionId);
|
||||
topics.setTopics([]);
|
||||
topics.setSelectedTopic(null);
|
||||
topics.selectedTopicRef.current = null;
|
||||
// 丢弃未消费的新建话题聚焦标志:新 session 的首个 topic_list 中所有
|
||||
// 话题都是"新话题",残留标志会被差分逻辑错误消费
|
||||
topics.pendingNewTopicRef.current = false;
|
||||
messages.clearMessages();
|
||||
subAgent.subAgentViewRef.current = null;
|
||||
subAgent.subAgentStackRef.current = [];
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user