perf(web): 优化流式 delta 处理,避免每 token 全数组拷贝
用 ref 累加 delta chunks + requestAnimationFrame 批量 flush, 将每秒数十次 O(n) 数组拷贝合并为一帧一次状态更新。 - useMessages.ts: 新增 streamingRef 累加器 + rAF 调度,所有 delta 只 push 到 ref,由 flushStreaming 批量落盘;用 rafScheduledRef boolean 追踪调度状态(避免 rAF handle 为 0 时与 null 检查冲突); flushStreaming 基于 prev 实际状态判断创建/更新消息(批处理安全) - useChat.ts: 5 处 setMessages([]) 改为 clearMessages(),切话题/ 会话/通道时重置流式 ref,避免脏状态残留 - test/setup.ts: 同步化 rAF 并返回递增非零 handle,保持测试同步语义 - useChat.test.ts: 新增对抗性测试(批量 delta 累加、stream_end 后 assistant_response 替换含 reasoning) 复杂度:O(n²·L) → O(n) 每帧,15 个测试全部通过
This commit is contained in:
parent
510520e08e
commit
f8c984aef4
@ -2,6 +2,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
useCallback,
|
useCallback,
|
||||||
useRef,
|
useRef,
|
||||||
|
useEffect,
|
||||||
type Dispatch,
|
type Dispatch,
|
||||||
type SetStateAction,
|
type SetStateAction,
|
||||||
type MutableRefObject,
|
type MutableRefObject,
|
||||||
@ -63,6 +64,110 @@ export function useMessages(options: UseMessagesOptions): UseMessagesReturn {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// ---- 流式 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[]) => {
|
const handleMessage = useCallback((content: string, attachments?: Attachment[]) => {
|
||||||
setMessages((prev) => [
|
setMessages((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
@ -79,8 +184,9 @@ export function useMessages(options: UseMessagesOptions): UseMessagesReturn {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const clearMessages = useCallback(() => {
|
const clearMessages = useCallback(() => {
|
||||||
|
clearStreaming();
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
}, []);
|
}, [clearStreaming]);
|
||||||
|
|
||||||
const handleStop = useCallback((): Command => {
|
const handleStop = useCallback((): Command => {
|
||||||
return { type: 'stop_execution' };
|
return { type: 'stop_execution' };
|
||||||
@ -88,6 +194,10 @@ export function useMessages(options: UseMessagesOptions): UseMessagesReturn {
|
|||||||
|
|
||||||
const handleMainViewMessage = useCallback(
|
const handleMainViewMessage = useCallback(
|
||||||
(message: WsOutbound): boolean => {
|
(message: WsOutbound): boolean => {
|
||||||
|
// 非流式消息到达前,先把 pending 的流式内容落盘,避免被后续消息覆盖或丢失
|
||||||
|
if (message.type !== 'stream_delta') {
|
||||||
|
finishStreaming();
|
||||||
|
}
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
case 'task_started': {
|
case 'task_started': {
|
||||||
const msg = message as TaskStarted;
|
const msg = message as TaskStarted;
|
||||||
@ -131,32 +241,26 @@ export function useMessages(options: UseMessagesOptions): UseMessagesReturn {
|
|||||||
case 'stream_delta': {
|
case 'stream_delta': {
|
||||||
const msg = message as StreamDelta;
|
const msg = message as StreamDelta;
|
||||||
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true;
|
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true;
|
||||||
setMessages((prev) => {
|
|
||||||
const existingIdx = prev.findIndex((m) => m.id === msg.id && m.type === 'message');
|
// 所有 delta 都只累加到 ref,由 rAF 批量 flush 到 state。
|
||||||
if (existingIdx >= 0) {
|
// 首 delta 由 flushStreaming 在消息不存在时创建壳;后续 delta 走 index 直接更新。
|
||||||
const updated = [...prev];
|
let stream = streamingRef.current;
|
||||||
const existing = updated[existingIdx];
|
if (!stream || stream.id !== msg.id) {
|
||||||
updated[existingIdx] = {
|
stream = {
|
||||||
...existing,
|
|
||||||
content: existing.content + msg.delta,
|
|
||||||
reasoningContent: msg.reasoning_delta
|
|
||||||
? (existing.reasoningContent || '') + msg.reasoning_delta
|
|
||||||
: existing.reasoningContent,
|
|
||||||
};
|
|
||||||
return updated;
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
id: msg.id,
|
id: msg.id,
|
||||||
role: 'assistant' as const,
|
contentChunks: [],
|
||||||
content: msg.delta,
|
reasoningChunks: [],
|
||||||
|
index: -1,
|
||||||
timestamp: Math.floor(Date.now() / 1000),
|
timestamp: Math.floor(Date.now() / 1000),
|
||||||
type: 'message' as const,
|
dirty: false,
|
||||||
reasoningContent: msg.reasoning_delta,
|
};
|
||||||
},
|
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);
|
if (msg.user_message_id) applyUserMessageId(msg.user_message_id);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@ -305,7 +409,14 @@ export function useMessages(options: UseMessagesOptions): UseMessagesReturn {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[selectedTopicRef, topicsRef, bumpTopicRefreshTrigger, applyUserMessageId],
|
[
|
||||||
|
selectedTopicRef,
|
||||||
|
topicsRef,
|
||||||
|
bumpTopicRefreshTrigger,
|
||||||
|
applyUserMessageId,
|
||||||
|
finishStreaming,
|
||||||
|
scheduleFlush,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -423,4 +423,46 @@ describe('useChat - handleServerMessage characterization', () => {
|
|||||||
act(() => result.current.handleServerMessage(otherTopicStream));
|
act(() => result.current.handleServerMessage(otherTopicStream));
|
||||||
expect(result.current.messages.find((m) => m.id === 'm-other')).toBeUndefined();
|
expect(result.current.messages.find((m) => m.id === 'm-other')).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('14. multiple stream_delta in one act() batch accumulate correctly', () => {
|
||||||
|
const { result } = renderUseChat();
|
||||||
|
// 模拟同一同步批次内连续到达多个 delta(验证 chunks 累加 + index 跟踪)
|
||||||
|
act(() => {
|
||||||
|
result.current.handleServerMessage({ type: 'stream_delta', id: 'batch1', delta: 'A' });
|
||||||
|
result.current.handleServerMessage({ type: 'stream_delta', id: 'batch1', delta: 'B' });
|
||||||
|
result.current.handleServerMessage({ type: 'stream_delta', id: 'batch1', delta: 'C' });
|
||||||
|
});
|
||||||
|
expect(result.current.messages).toHaveLength(1);
|
||||||
|
expect(result.current.messages[0].id).toBe('batch1');
|
||||||
|
expect(result.current.messages[0].content).toBe('ABC');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('15. stream_end then assistant_response replaces streamed content including reasoning', () => {
|
||||||
|
const { result } = renderUseChat();
|
||||||
|
act(() => {
|
||||||
|
result.current.handleServerMessage({
|
||||||
|
type: 'stream_delta',
|
||||||
|
id: 'r1',
|
||||||
|
delta: 'text',
|
||||||
|
reasoning_delta: 'think',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(result.current.messages[0].content).toBe('text');
|
||||||
|
expect(result.current.messages[0].reasoningContent).toBe('think');
|
||||||
|
// stream_end 触发 finishStreaming(flush pending + clear)
|
||||||
|
act(() => result.current.handleServerMessage({ type: 'stream_end', id: 'r1' }));
|
||||||
|
// assistant_response 替换整条消息
|
||||||
|
act(() =>
|
||||||
|
result.current.handleServerMessage({
|
||||||
|
type: 'assistant_response',
|
||||||
|
id: 'r1',
|
||||||
|
content: 'final text',
|
||||||
|
role: 'assistant',
|
||||||
|
reasoning_content: 'final reasoning',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.current.messages).toHaveLength(1);
|
||||||
|
expect(result.current.messages[0].content).toBe('final text');
|
||||||
|
expect(result.current.messages[0].reasoningContent).toBe('final reasoning');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -169,8 +169,7 @@ export function useChat(): UseChatReturn {
|
|||||||
const prevSid = sessions.selectedSessionIdRef.current;
|
const prevSid = sessions.selectedSessionIdRef.current;
|
||||||
const prevSessionExists =
|
const prevSessionExists =
|
||||||
prevSid !== null && message.sessions.some((s) => s.session_id === prevSid);
|
prevSid !== null && message.sessions.some((s) => s.session_id === prevSid);
|
||||||
const isReconnect =
|
const isReconnect = topics.selectedTopicRef.current !== null && prevSessionExists;
|
||||||
topics.selectedTopicRef.current !== null && prevSessionExists;
|
|
||||||
sessions.setSessions(message.sessions);
|
sessions.setSessions(message.sessions);
|
||||||
if (isReconnect) {
|
if (isReconnect) {
|
||||||
// 原 session 仍在,保持选中
|
// 原 session 仍在,保持选中
|
||||||
@ -184,7 +183,7 @@ export function useChat(): UseChatReturn {
|
|||||||
// 首次连接、切换通道、或原 session 已被删除:清空旧数据避免污染
|
// 首次连接、切换通道、或原 session 已被删除:清空旧数据避免污染
|
||||||
topics.setTopics([]);
|
topics.setTopics([]);
|
||||||
topics.setSelectedTopic(null);
|
topics.setSelectedTopic(null);
|
||||||
messages.setMessages([]);
|
messages.clearMessages();
|
||||||
sessions.setSelectedSessionId((prev) =>
|
sessions.setSelectedSessionId((prev) =>
|
||||||
prev && message.sessions.some((s) => s.session_id === prev)
|
prev && message.sessions.some((s) => s.session_id === prev)
|
||||||
? prev
|
? prev
|
||||||
@ -204,7 +203,7 @@ export function useChat(): UseChatReturn {
|
|||||||
|
|
||||||
case 'topic_list': {
|
case 'topic_list': {
|
||||||
const autoFocused = topics.handleTopicList(message);
|
const autoFocused = topics.handleTopicList(message);
|
||||||
if (autoFocused) messages.setMessages([]);
|
if (autoFocused) messages.clearMessages();
|
||||||
messages.setIsLoading(false);
|
messages.setIsLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -276,7 +275,7 @@ export function useChat(): UseChatReturn {
|
|||||||
// ---- selectTopic: 切换话题,清空消息和子智能体栈 ----
|
// ---- selectTopic: 切换话题,清空消息和子智能体栈 ----
|
||||||
const selectTopic = useCallback((topicId: string) => {
|
const selectTopic = useCallback((topicId: string) => {
|
||||||
topics.setSelectedTopic(topicId);
|
topics.setSelectedTopic(topicId);
|
||||||
messages.setMessages([]);
|
messages.clearMessages();
|
||||||
// ref + state 双写,消除竞态窗口(与 enter/exitSubAgentView 一致)
|
// ref + state 双写,消除竞态窗口(与 enter/exitSubAgentView 一致)
|
||||||
subAgent.subAgentViewRef.current = null;
|
subAgent.subAgentViewRef.current = null;
|
||||||
subAgent.subAgentStackRef.current = [];
|
subAgent.subAgentStackRef.current = [];
|
||||||
@ -292,7 +291,7 @@ export function useChat(): UseChatReturn {
|
|||||||
sessions.setSelectedSessionId(null);
|
sessions.setSelectedSessionId(null);
|
||||||
topics.setTopics([]);
|
topics.setTopics([]);
|
||||||
topics.setSelectedTopic(null);
|
topics.setSelectedTopic(null);
|
||||||
messages.setMessages([]);
|
messages.clearMessages();
|
||||||
subAgent.subAgentViewRef.current = null;
|
subAgent.subAgentViewRef.current = null;
|
||||||
subAgent.subAgentStackRef.current = [];
|
subAgent.subAgentStackRef.current = [];
|
||||||
subAgent.setSubAgentStack([]);
|
subAgent.setSubAgentStack([]);
|
||||||
@ -308,7 +307,7 @@ export function useChat(): UseChatReturn {
|
|||||||
sessions.setSelectedSessionId(sessionId);
|
sessions.setSelectedSessionId(sessionId);
|
||||||
topics.setTopics([]);
|
topics.setTopics([]);
|
||||||
topics.setSelectedTopic(null);
|
topics.setSelectedTopic(null);
|
||||||
messages.setMessages([]);
|
messages.clearMessages();
|
||||||
subAgent.subAgentViewRef.current = null;
|
subAgent.subAgentViewRef.current = null;
|
||||||
subAgent.subAgentStackRef.current = [];
|
subAgent.subAgentStackRef.current = [];
|
||||||
subAgent.setSubAgentStack([]);
|
subAgent.setSubAgentStack([]);
|
||||||
|
|||||||
@ -1 +1,10 @@
|
|||||||
import '@testing-library/jest-dom/vitest';
|
import '@testing-library/jest-dom/vitest';
|
||||||
|
|
||||||
|
// 让 requestAnimationFrame 在测试中同步触发,使 rAF 批量更新的状态在 act() 内立即可见。
|
||||||
|
// 返回递增的非零 handle,与浏览器真实 rAF 语义一致(0 是合法 ID 但会与 null 检查冲突)。
|
||||||
|
let rafIdCounter = 1;
|
||||||
|
globalThis.requestAnimationFrame = (cb: FrameRequestCallback): number => {
|
||||||
|
cb(performance.now());
|
||||||
|
return rafIdCounter++;
|
||||||
|
};
|
||||||
|
globalThis.cancelAnimationFrame = (): void => {};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user