后端新增 load_messages_for_topic_page(seq < cursor + limit 分页,走 (session_id, seq) 索引替代 OFFSET 深翻页),ChatMessage 增加 seq 游标;历史批次消息带 topic_id 下发,前端以 seq+topic_id 双重判定批次归属,规避切话题瞬间在途旧批次污染。 前端触顶增量加载:批次缓存在 pendingHistoryRef,收到 topic_history_end 一次性去重 prepend,scrollTop 按新增高度补偿锚定原头部消息;不足一屏自动补页,loading 超时 10s 自愈;流式输出中加载历史不清空流式累加器,避免已流出文本丢失。
673 lines
24 KiB
TypeScript
673 lines
24 KiB
TypeScript
import {
|
||
useState,
|
||
useCallback,
|
||
useRef,
|
||
useEffect,
|
||
type Dispatch,
|
||
type SetStateAction,
|
||
type MutableRefObject,
|
||
} from 'react';
|
||
import type {
|
||
ChatMessage,
|
||
WsOutbound,
|
||
Topic,
|
||
StreamDelta,
|
||
AssistantResponse,
|
||
ToolCall,
|
||
ToolResult,
|
||
ToolPending,
|
||
TopicHistoryEnd,
|
||
ExecutionCompleted,
|
||
WsError,
|
||
TaskStarted,
|
||
Attachment,
|
||
Command,
|
||
} from '../../types/protocol';
|
||
import { generateMessageId, getSubagentTaskId } from './messageMappers';
|
||
|
||
interface UseMessagesOptions {
|
||
/** 选中话题 state(用于派生 isLoading,确保 ref 异步写不导致派生值过期) */
|
||
selectedTopic: string | null;
|
||
selectedTopicRef: MutableRefObject<string | null>;
|
||
topicsRef: MutableRefObject<Topic[]>;
|
||
bumpTopicRefreshTrigger: () => void;
|
||
}
|
||
|
||
export interface UseMessagesReturn {
|
||
messages: ChatMessage[];
|
||
setMessages: Dispatch<SetStateAction<ChatMessage[]>>;
|
||
/** 派生值:仅当前选中话题在处理中时为 true */
|
||
isLoading: boolean;
|
||
/** 当前正在处理的 topic_id 集合(按话题隔离) */
|
||
processingTopicIds: Set<string>;
|
||
/** 供重连对账使用:直接设置整个处理集合 */
|
||
setProcessingTopicIds: Dispatch<SetStateAction<Set<string>>>;
|
||
/** 标记某话题为处理中 */
|
||
markTopicProcessing: (topicId: string) => void;
|
||
/** 标记某话题处理完成 */
|
||
markTopicDone: (topicId: string) => void;
|
||
handleMessage: (content: string, attachments?: Attachment[]) => void;
|
||
clearMessages: () => void;
|
||
finishStreaming: () => void;
|
||
handleStop: () => Command;
|
||
/** 处理主视图的消息类 case(task_started, stream_*, tool_*, execution_*, error),返回是否已处理 */
|
||
handleMainViewMessage: (message: WsOutbound) => boolean;
|
||
/** 历史分页:是否还有更早的消息可加载 */
|
||
hasMoreOlder: boolean;
|
||
/** 历史分页:当前最早已加载消息的 seq 游标 */
|
||
oldestSeq: number | null;
|
||
/** 历史分页:是否正在加载更早一页 */
|
||
loadingOlder: boolean;
|
||
/** 触顶时请求加载更早一页(返回待发送命令,由调用方发送) */
|
||
requestLoadOlder: () => Command | null;
|
||
}
|
||
|
||
export function useMessages(options: UseMessagesOptions): UseMessagesReturn {
|
||
const { selectedTopic, selectedTopicRef, topicsRef, bumpTopicRefreshTrigger } = options;
|
||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||
// 按话题隔离的处理状态:智能体执行是 per-topic 的,
|
||
// 切换话题不应清空原话题的处理状态。
|
||
const [processingTopicIds, setProcessingTopicIds] = useState<Set<string>>(
|
||
new Set(),
|
||
);
|
||
|
||
// 派生:仅当前选中话题在处理中时才禁用输入框/显示 STOP 按钮。
|
||
// 使用 selectedTopic state(非 ref)作为依赖,确保 selectedTopic 变化时
|
||
// isLoading 立即重算,不受 selectedTopicRef 异步 useEffect 写入延迟影响。
|
||
const isLoading =
|
||
selectedTopic !== null && processingTopicIds.has(selectedTopic);
|
||
|
||
const markTopicProcessing = useCallback((topicId: string) => {
|
||
setProcessingTopicIds((prev) => {
|
||
if (prev.has(topicId)) return prev;
|
||
const next = new Set(prev);
|
||
next.add(topicId);
|
||
return next;
|
||
});
|
||
}, []);
|
||
|
||
const markTopicDone = useCallback((topicId: string) => {
|
||
setProcessingTopicIds((prev) => {
|
||
if (!prev.has(topicId)) return prev;
|
||
const next = new Set(prev);
|
||
next.delete(topicId);
|
||
return next;
|
||
});
|
||
}, []);
|
||
|
||
const syncedUserMessageIdsRef = useRef<Set<string>>(new Set());
|
||
|
||
const applyUserMessageId = useCallback((userMessageId: string) => {
|
||
if (syncedUserMessageIdsRef.current.has(userMessageId)) return;
|
||
syncedUserMessageIdsRef.current.add(userMessageId);
|
||
setMessages((prev) => {
|
||
for (let i = prev.length - 1; i >= 0; i--) {
|
||
if (prev[i].role === 'user') {
|
||
const updated = [...prev];
|
||
updated[i] = { ...updated[i], id: userMessageId };
|
||
return updated;
|
||
}
|
||
}
|
||
return prev;
|
||
});
|
||
}, []);
|
||
|
||
// ---- 流式 delta 累加器 ----
|
||
// 用 ref 暂存 delta chunks,rAF 批量 flush 到 state,避免每个 token 都拷贝整个消息数组。
|
||
// 首 delta 同步写入消息壳,保证消息立即存在(assistant_response 可按 id 替换)。
|
||
const streamingRef = useRef<{
|
||
id: string;
|
||
contentChunks: string[];
|
||
reasoningChunks: string[];
|
||
index: number;
|
||
timestamp: number;
|
||
dirty: boolean;
|
||
} | null>(null);
|
||
const rafHandleRef = useRef<number | null>(null);
|
||
// 独立的调度标志:rAF handle 在某些环境可能为 0(与 null 检查冲突),
|
||
// 用 boolean 显式追踪是否已有 pending flush,语义更稳健。
|
||
const rafScheduledRef = useRef(false);
|
||
|
||
// flush pending 流式内容到 state。force=true 时即使 dirty=false 也强制 flush一次,
|
||
// 用于 finishStreaming 确保壳已创建(边界:首 delta 后立即 stream_end,dirty 可能已清)。
|
||
const flushStreaming = useCallback((force = false) => {
|
||
const stream = streamingRef.current;
|
||
if (!stream) return;
|
||
if (!force && !stream.dirty) return;
|
||
stream.dirty = false;
|
||
const content = stream.contentChunks.join('');
|
||
const reasoning =
|
||
stream.reasoningChunks.length > 0 ? stream.reasoningChunks.join('') : undefined;
|
||
setMessages((prev) => {
|
||
// 优先用缓存的 index 直接定位(O(1)),并校验 id 仍匹配
|
||
if (stream.index >= 0 && stream.index < prev.length && prev[stream.index].id === stream.id) {
|
||
const updated = prev.slice();
|
||
const existing = updated[stream.index];
|
||
updated[stream.index] = {
|
||
...existing,
|
||
content,
|
||
reasoningContent: reasoning ?? existing.reasoningContent,
|
||
};
|
||
return updated;
|
||
}
|
||
// 回退:按 id 查找
|
||
const idx = prev.findIndex((m) => m.id === stream.id && m.type === 'message');
|
||
if (idx >= 0) {
|
||
stream.index = idx;
|
||
const updated = prev.slice();
|
||
const existing = updated[idx];
|
||
updated[idx] = {
|
||
...existing,
|
||
content,
|
||
reasoningContent: reasoning ?? existing.reasoningContent,
|
||
};
|
||
return updated;
|
||
}
|
||
// 消息壳尚未存在:创建(基于 prev 实际状态判断,不依赖外部标志,批处理安全)
|
||
stream.index = prev.length;
|
||
return [
|
||
...prev,
|
||
{
|
||
id: stream.id,
|
||
role: 'assistant' as const,
|
||
content,
|
||
timestamp: stream.timestamp,
|
||
type: 'message' as const,
|
||
reasoningContent: reasoning,
|
||
},
|
||
];
|
||
});
|
||
}, []);
|
||
|
||
const clearStreaming = useCallback(() => {
|
||
if (rafScheduledRef.current) {
|
||
if (rafHandleRef.current !== null) {
|
||
cancelAnimationFrame(rafHandleRef.current);
|
||
}
|
||
rafHandleRef.current = null;
|
||
rafScheduledRef.current = false;
|
||
}
|
||
streamingRef.current = null;
|
||
}, []);
|
||
|
||
const scheduleFlush = useCallback(() => {
|
||
if (rafScheduledRef.current) return;
|
||
rafScheduledRef.current = true;
|
||
rafHandleRef.current = requestAnimationFrame(() => {
|
||
rafHandleRef.current = null;
|
||
rafScheduledRef.current = false;
|
||
flushStreaming(false);
|
||
});
|
||
}, [flushStreaming]);
|
||
|
||
const finishStreaming = useCallback(() => {
|
||
flushStreaming(true);
|
||
clearStreaming();
|
||
}, [flushStreaming, clearStreaming]);
|
||
|
||
// 卸载时取消未决的 rAF,避免在已卸载组件上触发状态更新
|
||
useEffect(() => {
|
||
return () => {
|
||
if (rafScheduledRef.current && rafHandleRef.current !== null) {
|
||
cancelAnimationFrame(rafHandleRef.current);
|
||
}
|
||
rafHandleRef.current = null;
|
||
rafScheduledRef.current = false;
|
||
};
|
||
}, []);
|
||
|
||
const handleMessage = useCallback((content: string, attachments?: Attachment[]) => {
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{
|
||
id: generateMessageId(),
|
||
role: 'user',
|
||
content,
|
||
timestamp: Math.floor(Date.now() / 1000),
|
||
type: 'message',
|
||
attachments: attachments || [],
|
||
},
|
||
]);
|
||
// 乐观标记当前话题为处理中,execution_completed 负责移除
|
||
if (selectedTopicRef.current) markTopicProcessing(selectedTopicRef.current);
|
||
}, [selectedTopicRef, markTopicProcessing]);
|
||
|
||
// ---- 历史分页(load_older_messages) ----
|
||
// 历史批次消息(带 seq)先缓存在 ref,收到 topic_history_end 时一次性
|
||
// 去重后 prepend 到列表头部:避免逐条 setState 且保证批次内顺序稳定。
|
||
const pendingHistoryRef = useRef<ChatMessage[]>([]);
|
||
const [olderHistory, setOlderHistory] = useState<{
|
||
hasMore: boolean;
|
||
oldestSeq: number | null;
|
||
loading: boolean;
|
||
}>({ hasMore: false, oldestSeq: null, loading: false });
|
||
|
||
const oldestSeqRef = useRef<number | null>(null);
|
||
oldestSeqRef.current = olderHistory.oldestSeq;
|
||
|
||
const clearMessages = useCallback(() => {
|
||
clearStreaming();
|
||
setMessages([]);
|
||
// 重置历史分页状态与未 flush 的批次缓存
|
||
pendingHistoryRef.current = [];
|
||
setOlderHistory({ hasMore: false, oldestSeq: null, loading: false });
|
||
}, [clearStreaming]);
|
||
|
||
const requestLoadOlder = useCallback((): Command | null => {
|
||
const topicId = selectedTopicRef.current;
|
||
const before = oldestSeqRef.current;
|
||
if (!topicId || before === null || olderHistory.loading || !olderHistory.hasMore) {
|
||
return null;
|
||
}
|
||
setOlderHistory((prev) => (prev.loading ? prev : { ...prev, loading: true }));
|
||
// 超时自愈:topic_history_end 因断连/异常永不到达时解除 loading 锁,
|
||
// 避免历史分页永久卡死(幂等:end 先到则 loading 已为 false,无副作用)
|
||
setTimeout(() => {
|
||
setOlderHistory((prev) => (prev.loading ? { ...prev, loading: false } : prev));
|
||
}, 10000);
|
||
return { type: 'load_older_messages', topic_id: topicId, before_seq: before };
|
||
}, [olderHistory.loading, olderHistory.hasMore, selectedTopicRef]);
|
||
|
||
/** topic_history_end 到达:flush 历史批次 + 更新分页游标 */
|
||
const handleTopicHistoryEnd = useCallback((msg: TopicHistoryEnd) => {
|
||
if (msg.topic_id !== selectedTopicRef.current) return;
|
||
const batch = pendingHistoryRef.current;
|
||
pendingHistoryRef.current = [];
|
||
if (batch.length > 0) {
|
||
setMessages((prev) => {
|
||
const existing = new Set(prev.map((m) => m.id));
|
||
const fresh = batch.filter((m) => !existing.has(m.id));
|
||
if (fresh.length === 0) return prev;
|
||
return [...fresh, ...prev];
|
||
});
|
||
}
|
||
setOlderHistory({
|
||
hasMore: msg.has_more,
|
||
oldestSeq: msg.oldest_seq ?? null,
|
||
loading: false,
|
||
});
|
||
}, [selectedTopicRef]);
|
||
|
||
/** 历史批次消息(带 seq)转 ChatMessage 缓存;返回 true 表示已处理 */
|
||
const tryCollectHistoryMessage = useCallback(
|
||
(message: WsOutbound): boolean => {
|
||
const seq = (message as { seq?: number }).seq;
|
||
if (seq === undefined) return false;
|
||
// 历史批次必带 topic_id(后端历史路径填充)。不匹配(切话题瞬间在途的
|
||
// 旧批次)或不带(任务会话消息)的交回常规分支:前者被 per-case 的
|
||
// topic_id 检查丢弃,后者维持原有的实时消息处理路径。
|
||
const batchTopicId = (message as { topic_id?: string }).topic_id;
|
||
if (batchTopicId !== selectedTopicRef.current) return false;
|
||
let converted: ChatMessage | null = null;
|
||
const m = message as
|
||
| AssistantResponse
|
||
| ToolCall
|
||
| ToolResult
|
||
| ToolPending;
|
||
switch (m.type) {
|
||
case 'assistant_response':
|
||
converted = {
|
||
id: m.id,
|
||
role: m.role === 'user' || m.role === 'tool' ? m.role : 'assistant',
|
||
content: m.content,
|
||
timestamp: m.timestamp ?? Math.floor(Date.now() / 1000),
|
||
seq,
|
||
type: 'message',
|
||
attachments: m.attachments,
|
||
reasoningContent: m.reasoning_content,
|
||
};
|
||
break;
|
||
case 'tool_call':
|
||
converted = {
|
||
id: m.id,
|
||
role: 'tool',
|
||
content: m.content,
|
||
timestamp: m.timestamp ?? Math.floor(Date.now() / 1000),
|
||
seq,
|
||
type: 'tool_call',
|
||
toolName: m.tool_name,
|
||
toolCallId: m.tool_call_id,
|
||
arguments: m.arguments,
|
||
reasoningContent: m.reasoning_content,
|
||
};
|
||
break;
|
||
case 'tool_result':
|
||
converted = {
|
||
id: m.id,
|
||
role: 'tool',
|
||
content: m.content,
|
||
timestamp: m.timestamp ?? Math.floor(Date.now() / 1000),
|
||
seq,
|
||
type: 'tool_result',
|
||
toolName: m.tool_name,
|
||
toolCallId: m.tool_call_id,
|
||
durationMs: m.duration_ms,
|
||
};
|
||
break;
|
||
case 'tool_pending':
|
||
converted = {
|
||
id: m.id,
|
||
role: 'tool',
|
||
content: `${m.content}\n\n${m.resume_hint}`,
|
||
timestamp: m.timestamp ?? Math.floor(Date.now() / 1000),
|
||
seq,
|
||
type: 'tool_pending',
|
||
toolName: m.tool_name,
|
||
toolCallId: m.tool_call_id,
|
||
};
|
||
break;
|
||
default:
|
||
return false;
|
||
}
|
||
pendingHistoryRef.current.push(converted);
|
||
return true;
|
||
},
|
||
[],
|
||
);
|
||
|
||
const handleStop = useCallback((): Command => {
|
||
return { type: 'stop_execution' };
|
||
}, []);
|
||
|
||
const handleMainViewMessage = useCallback(
|
||
(message: WsOutbound): boolean => {
|
||
// 非流式消息到达前,先把 pending 的流式内容落盘,避免被后续消息覆盖或丢失。
|
||
// 例外:历史分页批次(带 seq)与 topic_history_end 是历史数据回放,
|
||
// 与活动流无关——若在流式输出进行中触顶加载历史,误清累加器会导致
|
||
// 后续 delta 从零累积并覆写壳内容,造成已流出文本丢失。
|
||
if (
|
||
message.type !== 'stream_delta' &&
|
||
message.type !== 'topic_history_end' &&
|
||
(message as { seq?: number }).seq === undefined
|
||
) {
|
||
finishStreaming();
|
||
}
|
||
switch (message.type) {
|
||
case 'topic_history_end': {
|
||
handleTopicHistoryEnd(message as TopicHistoryEnd);
|
||
return true;
|
||
}
|
||
|
||
case 'task_started': {
|
||
const msg = message as TaskStarted;
|
||
// 只 backfill 当前话题的 task tool_call,避免跨话题串扰
|
||
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true;
|
||
// 孙智能体的 TaskStarted 不应 backfill 到主视图
|
||
if (msg.parent_task_id) return true;
|
||
|
||
setMessages((prev) => {
|
||
// 优先:按 tool_call_id 精确匹配
|
||
if (msg.tool_call_id) {
|
||
const idx = prev.findIndex(
|
||
(m) =>
|
||
m.toolCallId === msg.tool_call_id &&
|
||
m.type === 'tool_call' &&
|
||
m.toolName === 'task',
|
||
);
|
||
if (idx >= 0 && !prev[idx].navigateToTaskId) {
|
||
const updated = [...prev];
|
||
updated[idx] = { ...updated[idx], navigateToTaskId: msg.task_id };
|
||
return updated;
|
||
}
|
||
}
|
||
// 回退:backward-search (兼容无 tool_call_id 的旧版本)
|
||
for (let i = prev.length - 1; i >= 0; i--) {
|
||
if (
|
||
prev[i].type === 'tool_call' &&
|
||
prev[i].toolName === 'task' &&
|
||
!prev[i].navigateToTaskId
|
||
) {
|
||
const updated = [...prev];
|
||
updated[i] = { ...updated[i], navigateToTaskId: msg.task_id };
|
||
return updated;
|
||
}
|
||
}
|
||
return prev;
|
||
});
|
||
return true;
|
||
}
|
||
|
||
case 'stream_delta': {
|
||
const msg = message as StreamDelta;
|
||
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true;
|
||
|
||
// 所有 delta 都只累加到 ref,由 rAF 批量 flush 到 state。
|
||
// 首 delta 由 flushStreaming 在消息不存在时创建壳;后续 delta 走 index 直接更新。
|
||
let stream = streamingRef.current;
|
||
if (!stream || stream.id !== msg.id) {
|
||
stream = {
|
||
id: msg.id,
|
||
contentChunks: [],
|
||
reasoningChunks: [],
|
||
index: -1,
|
||
timestamp: Math.floor(Date.now() / 1000),
|
||
dirty: false,
|
||
};
|
||
streamingRef.current = stream;
|
||
}
|
||
if (msg.delta) stream.contentChunks.push(msg.delta);
|
||
if (msg.reasoning_delta) stream.reasoningChunks.push(msg.reasoning_delta);
|
||
stream.dirty = true;
|
||
scheduleFlush();
|
||
|
||
if (msg.user_message_id) applyUserMessageId(msg.user_message_id);
|
||
return true;
|
||
}
|
||
|
||
case 'stream_end': {
|
||
return true;
|
||
}
|
||
|
||
case 'execution_completed': {
|
||
const msg = message as ExecutionCompleted;
|
||
if (getSubagentTaskId(message)) {
|
||
// 子代理执行完成:bump 统一 trigger,App.tsx 根据 subAgentView 分派 load_task_messages
|
||
bumpTopicRefreshTrigger();
|
||
|
||
// 更新主视图中 task tool result 占位消息的状态
|
||
// task 工具返回时 status='running'(黄色转圈),子代理完成后需更新为最终状态
|
||
if (msg.subagent_task_id && msg.subagent_status) {
|
||
const taskId = msg.subagent_task_id;
|
||
const newStatus = msg.subagent_status;
|
||
const newSummary = msg.subagent_summary;
|
||
setMessages((prev) => {
|
||
let changed = false;
|
||
const updated = prev.map((m) => {
|
||
if (m.type !== 'tool_result' || m.toolName !== 'task') return m;
|
||
if (!m.content) return m;
|
||
// content 是 TaskToolResult JSON(可能带 loop_detector 前缀)
|
||
const jsonStart = m.content.indexOf('{');
|
||
if (jsonStart < 0) return m;
|
||
try {
|
||
const parsed = JSON.parse(m.content.slice(jsonStart));
|
||
if (parsed.task_id !== taskId) return m;
|
||
if (parsed.status === newStatus) return m;
|
||
parsed.status = newStatus;
|
||
if (newSummary !== undefined) parsed.summary = newSummary;
|
||
const newJson = JSON.stringify(parsed);
|
||
changed = true;
|
||
const prefix = jsonStart > 0 ? m.content.slice(0, jsonStart) : '';
|
||
return { ...m, content: prefix + newJson };
|
||
} catch {
|
||
return m;
|
||
}
|
||
});
|
||
return changed ? updated : prev;
|
||
});
|
||
}
|
||
|
||
return true;
|
||
}
|
||
// 按 topic_id 移除处理状态,不论当前选中哪个话题。
|
||
// 这样切走话题后收到的完成信号也能正确清理原话题状态。
|
||
if (msg.topic_id) markTopicDone(msg.topic_id);
|
||
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true;
|
||
// 主代理本次执行结束:刷新 topic 列表以更新 token 统计
|
||
bumpTopicRefreshTrigger();
|
||
return true;
|
||
}
|
||
|
||
case 'assistant_response': {
|
||
if (tryCollectHistoryMessage(message)) return true;
|
||
const msg = message as AssistantResponse;
|
||
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true;
|
||
const role = msg.role === 'user' || msg.role === 'tool' ? msg.role : 'assistant';
|
||
setMessages((prev) => {
|
||
const existingIdx = prev.findIndex((m) => m.id === msg.id && m.type === 'message');
|
||
const newMsg: ChatMessage = {
|
||
id: msg.id,
|
||
role,
|
||
content: msg.content,
|
||
timestamp: msg.timestamp ?? Math.floor(Date.now() / 1000),
|
||
type: 'message',
|
||
attachments: msg.attachments,
|
||
reasoningContent: msg.reasoning_content,
|
||
};
|
||
if (existingIdx >= 0) {
|
||
const updated = [...prev];
|
||
updated[existingIdx] = newMsg;
|
||
return updated;
|
||
}
|
||
return [...prev, newMsg];
|
||
});
|
||
// 每轮 assistant 响应到达即刷新 token 统计(复用 500ms 防抖)
|
||
const currentTopic = topicsRef.current.find((t) => t.id === selectedTopicRef.current);
|
||
if (currentTopic) {
|
||
bumpTopicRefreshTrigger();
|
||
}
|
||
if (msg.user_message_id) applyUserMessageId(msg.user_message_id);
|
||
return true;
|
||
}
|
||
|
||
case 'tool_call': {
|
||
if (tryCollectHistoryMessage(message)) return true;
|
||
const msg = message as ToolCall;
|
||
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true;
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{
|
||
id: msg.id,
|
||
role: 'tool',
|
||
content: msg.content,
|
||
timestamp: msg.timestamp ?? Math.floor(Date.now() / 1000),
|
||
type: 'tool_call',
|
||
toolName: msg.tool_name,
|
||
toolCallId: msg.tool_call_id,
|
||
arguments: msg.arguments,
|
||
subagentTaskId: msg.subagent_task_id,
|
||
reasoningContent: msg.reasoning_content,
|
||
},
|
||
]);
|
||
if (msg.user_message_id) applyUserMessageId(msg.user_message_id);
|
||
return true;
|
||
}
|
||
|
||
case 'tool_result': {
|
||
if (tryCollectHistoryMessage(message)) return true;
|
||
const msg = message as ToolResult;
|
||
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true;
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{
|
||
id: msg.id,
|
||
role: 'tool',
|
||
content: msg.content,
|
||
timestamp: msg.timestamp ?? Math.floor(Date.now() / 1000),
|
||
type: 'tool_result',
|
||
toolName: msg.tool_name,
|
||
toolCallId: msg.tool_call_id,
|
||
subagentTaskId: msg.subagent_task_id,
|
||
durationMs: msg.duration_ms,
|
||
},
|
||
]);
|
||
return true;
|
||
}
|
||
|
||
case 'tool_pending': {
|
||
if (tryCollectHistoryMessage(message)) return true;
|
||
const msg = message as ToolPending;
|
||
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true;
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{
|
||
id: msg.id,
|
||
role: 'tool',
|
||
content: `${msg.content}\n\n${msg.resume_hint}`,
|
||
timestamp: msg.timestamp ?? Math.floor(Date.now() / 1000),
|
||
type: 'tool_pending',
|
||
toolName: msg.tool_name,
|
||
toolCallId: msg.tool_call_id,
|
||
},
|
||
]);
|
||
return true;
|
||
}
|
||
|
||
case 'execution_cancelled': {
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{
|
||
id: generateMessageId(),
|
||
role: 'assistant',
|
||
content: (message as { type: 'execution_cancelled'; message: string }).message,
|
||
timestamp: message.timestamp ?? Math.floor(Date.now() / 1000),
|
||
type: 'message',
|
||
},
|
||
]);
|
||
// execution_cancelled 无 topic_id 字段,保守清空所有处理状态
|
||
setProcessingTopicIds(new Set());
|
||
return true;
|
||
}
|
||
|
||
case 'error': {
|
||
if (getSubagentTaskId(message)) return true;
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{
|
||
id: generateMessageId(),
|
||
role: 'assistant',
|
||
content: `Error: ${(message as WsError).message}`,
|
||
timestamp: message.timestamp ?? Math.floor(Date.now() / 1000),
|
||
type: 'message',
|
||
},
|
||
]);
|
||
// WsError 无 topic_id 字段,保守清空所有处理状态,避免卡死
|
||
setProcessingTopicIds(new Set());
|
||
return true;
|
||
}
|
||
|
||
default:
|
||
return false;
|
||
}
|
||
},
|
||
[
|
||
selectedTopicRef,
|
||
topicsRef,
|
||
bumpTopicRefreshTrigger,
|
||
applyUserMessageId,
|
||
finishStreaming,
|
||
scheduleFlush,
|
||
markTopicDone,
|
||
setProcessingTopicIds,
|
||
],
|
||
);
|
||
|
||
return {
|
||
messages,
|
||
setMessages,
|
||
isLoading,
|
||
processingTopicIds,
|
||
setProcessingTopicIds,
|
||
markTopicProcessing,
|
||
markTopicDone,
|
||
handleMessage,
|
||
clearMessages,
|
||
finishStreaming,
|
||
handleStop,
|
||
handleMainViewMessage,
|
||
hasMoreOlder: olderHistory.hasMore,
|
||
oldestSeq: olderHistory.oldestSeq,
|
||
loadingOlder: olderHistory.loading,
|
||
requestLoadOlder,
|
||
};
|
||
}
|