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; topicsRef: MutableRefObject; bumpTopicRefreshTrigger: () => void; } export interface UseMessagesReturn { messages: ChatMessage[]; setMessages: Dispatch>; /** 派生值:仅当前选中话题在处理中时为 true */ isLoading: boolean; /** 当前正在处理的 topic_id 集合(按话题隔离) */ processingTopicIds: Set; /** 供重连对账使用:直接设置整个处理集合 */ setProcessingTopicIds: Dispatch>>; /** 标记某话题为处理中 */ 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([]); // 按话题隔离的处理状态:智能体执行是 per-topic 的, // 切换话题不应清空原话题的处理状态。 const [processingTopicIds, setProcessingTopicIds] = useState>( 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>(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(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([]); const [olderHistory, setOlderHistory] = useState<{ hasMore: boolean; oldestSeq: number | null; loading: boolean; }>({ hasMore: false, oldestSeq: null, loading: false }); const oldestSeqRef = useRef(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, }; }