PicoBot/web/src/hooks/chat/useMessages.ts
oudecheng a629486ad3 perf(chat): 长话题历史分页——messages 按 seq keyset 增量加载
后端新增 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 自愈;流式输出中加载历史不清空流式累加器,避免已流出文本丢失。
2026-08-18 07:51:31 +08:00

673 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
/** 处理主视图的消息类 casetask_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 chunksrAF 批量 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_enddirty 可能已清)。
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 统一 triggerApp.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,
};
}