perf(web): 子代理 stream_delta rAF 批处理 + 消息槽 memo 化 + base64 分块解码

- useSubAgentView 镜像主视图:delta 累加到 ref,rAF 批量一次 setState,避免逐 token 重渲染
- 所有非 delta 消息处理前同步落盘 pending delta,保证顺序与内容完整性
- MessageList 消息 ID 映射 useMemo,稳定回调引用减少重渲染
- MessageBubble base64 下载改分块解码(32K),大附件峰值内存从数百 MB 降为单块级
This commit is contained in:
oudecheng 2026-08-17 09:55:11 +08:00
parent 52f858bfb4
commit 5e2bdaf757
5 changed files with 263 additions and 143 deletions

View File

@ -423,6 +423,16 @@ function App() {
sendMessage({ type: 'command', payload: JSON.stringify(cmd) }); sendMessage({ type: 'command', payload: JSON.stringify(cmd) });
}, [sendMessage, handleCommand, handleStop]); }, [sendMessage, handleCommand, handleStop]);
// 稳定引用:只读视图(子智能体/定时任务)下的空发送回调,
// 避免内联箭头函数每次渲染产生新引用、破坏下游 memo 化。
const noopSendMessage = useCallback(() => {}, []);
// 稳定引用:打开专家设置页
const openExpertsSettings = useCallback(() => {
setConfigInitialTab('experts');
setConfigPageOpen(true);
}, []);
const handleCreateTopic = useCallback(() => { const handleCreateTopic = useCallback(() => {
if (isReadOnly || !sessionId) { if (isReadOnly || !sessionId) {
return; return;
@ -1007,7 +1017,7 @@ function App() {
channels.find((c) => c.id === selectedChannel)?.name ?? channels.find((c) => c.id === selectedChannel)?.name ??
'PicoBot') 'PicoBot')
} }
onSendMessage={subAgentView || schedulerView ? () => {} : handleSendMessage} onSendMessage={subAgentView || schedulerView ? noopSendMessage : handleSendMessage}
onNavigateToSubAgent={handleNavigateToSubAgent} onNavigateToSubAgent={handleNavigateToSubAgent}
onStop={handleStopExecution} onStop={handleStopExecution}
showThinking={showThinking} showThinking={showThinking}
@ -1015,10 +1025,7 @@ function App() {
highlightedMessageId={highlightedMessageId} highlightedMessageId={highlightedMessageId}
sessionId={sessionId} sessionId={sessionId}
settingsClosedTick={settingsClosedTick} settingsClosedTick={settingsClosedTick}
onOpenSettings={() => { onOpenSettings={openExpertsSettings}
setConfigInitialTab('experts');
setConfigPageOpen(true);
}}
/> />
</div> </div>
</div> </div>

View File

@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useCallback } from 'react';
import { MessageList } from './MessageList'; import { MessageList } from './MessageList';
import { MessageInput } from './MessageInput'; import { MessageInput } from './MessageInput';
import { ExpertSelector } from './ExpertSelector'; import { ExpertSelector } from './ExpertSelector';
@ -55,6 +55,13 @@ export function ChatContainer({
model: string; model: string;
} | null>(null); } | null>(null);
// 稳定引用,避免内联箭头破坏下游 memo 化
const handleModelSelectionChange = useCallback(
(effective: { provider: string; model: string }) =>
setEffectiveModel({ provider: effective.provider, model: effective.model }),
[],
);
const selectors = ( const selectors = (
<div className="flex flex-wrap items-center gap-1 px-3 pt-2"> <div className="flex flex-wrap items-center gap-1 px-3 pt-2">
<ExpertSelector <ExpertSelector
@ -67,9 +74,7 @@ export function ChatContainer({
sessionId={sessionId ?? null} sessionId={sessionId ?? null}
topicId={topicId ?? null} topicId={topicId ?? null}
settingsClosedTick={settingsClosedTick} settingsClosedTick={settingsClosedTick}
onSelectionChange={(effective) => onSelectionChange={handleModelSelectionChange}
setEffectiveModel({ provider: effective.provider, model: effective.model })
}
/> />
</div> </div>
); );

View File

@ -132,6 +132,24 @@ function formatDuration(ms: number): string {
return `${minutes}m ${seconds}s`; return `${minutes}m ${seconds}s`;
} }
/**
* base64 Blob
* atob + + Uint8Array
* 50MB MB Blob
* atob +
*/
function base64ToBlob(base64: string, mimeType: string): Blob {
const byteChars = atob(base64);
const total = byteChars.length;
const CHUNK_SIZE = 0x8000; // 32K
const parts: Uint8Array<ArrayBuffer>[] = [];
for (let offset = 0; offset < total; offset += CHUNK_SIZE) {
const slice = byteChars.slice(offset, offset + CHUNK_SIZE);
parts.push(Uint8Array.from(slice, (c) => c.charCodeAt(0)));
}
return new Blob(parts, { type: mimeType });
}
function AttachmentCard({ attachment }: { attachment: Attachment }) { function AttachmentCard({ attachment }: { attachment: Attachment }) {
const fileName = attachment.file_name || getFileName(attachment.path); const fileName = attachment.file_name || getFileName(attachment.path);
@ -140,13 +158,7 @@ function AttachmentCard({ attachment }: { attachment: Attachment }) {
e.preventDefault(); e.preventDefault();
const mimeType = attachment.mime_type || 'application/octet-stream'; const mimeType = attachment.mime_type || 'application/octet-stream';
const byteChars = atob(attachment.content_base64); const blob = base64ToBlob(attachment.content_base64, mimeType);
const byteNums = new Array(byteChars.length);
for (let i = 0; i < byteChars.length; i++) {
byteNums[i] = byteChars.charCodeAt(i);
}
const byteArr = new Uint8Array(byteNums);
const blob = new Blob([byteArr], { type: mimeType });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
@ -210,13 +222,7 @@ function ImageLightbox({
const handleDownload = (e: React.MouseEvent) => { const handleDownload = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
const byteChars = atob(src); const blob = base64ToBlob(src, mimeType);
const byteNums = new Array(byteChars.length);
for (let i = 0; i < byteChars.length; i++) {
byteNums[i] = byteChars.charCodeAt(i);
}
const byteArr = new Uint8Array(byteNums);
const blob = new Blob([byteArr], { type: mimeType });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;

View File

@ -1,4 +1,4 @@
import { useEffect, useLayoutEffect, useRef, useState, useCallback } from 'react'; import { useEffect, useLayoutEffect, useRef, useState, useCallback, useMemo } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import { MessageBubble } from './MessageBubble'; import { MessageBubble } from './MessageBubble';
import type { ChatMessage } from '../../types/protocol'; import type { ChatMessage } from '../../types/protocol';
@ -55,8 +55,13 @@ export function MessageList({
: undefined, : undefined,
}); });
// 消息 id → virtualizer index 映射,用于 highlight 滚动定位 // 消息 id → virtualizer index 映射,用于 highlight 滚动定位。
const messageIdToIndex = useRef<Map<string, number>>(new Map()); // useMemo 化:仅在 messages 变化时重建,而非每次渲染(流式期间每帧一次)都全量重建。
const messageIdToIndex = useMemo(() => {
const map = new Map<string, number>();
messages.forEach((m, i) => map.set(m.id, i));
return map;
}, [messages]);
// ---- scroll helpers ---- // ---- scroll helpers ----
@ -167,7 +172,7 @@ export function MessageList({
useEffect(() => { useEffect(() => {
if (!highlightedMessageId) return; if (!highlightedMessageId) return;
const idx = messageIdToIndex.current.get(highlightedMessageId); const idx = messageIdToIndex.get(highlightedMessageId);
if (idx === undefined) return; if (idx === undefined) return;
virtualizer.scrollToIndex(idx, { align: 'center', behavior: 'smooth' }); virtualizer.scrollToIndex(idx, { align: 'center', behavior: 'smooth' });
@ -183,7 +188,7 @@ export function MessageList({
targetElement.classList.remove('todo-highlight'); targetElement.classList.remove('todo-highlight');
}, 2000); }, 2000);
}); });
}, [highlightedMessageId, virtualizer]); }, [highlightedMessageId, messageIdToIndex, virtualizer]);
// ---- 行高强制重测(修复 tanstack virtual-core 3.17.x 陈旧高度导致行重叠)---- // ---- 行高强制重测(修复 tanstack virtual-core 3.17.x 陈旧高度导致行重叠)----
// 3.17.x 在滚动状态会跳过同步测量、对缓冲区外的行跳过 RO 更新并复用缓存高度; // 3.17.x 在滚动状态会跳过同步测量、对缓冲区外的行跳过 RO 更新并复用缓存高度;
@ -241,10 +246,6 @@ export function MessageList({
); );
} }
// 构建消息 id → index 映射(每次渲染更新,供 highlight 查找)
messageIdToIndex.current.clear();
messages.forEach((m, i) => messageIdToIndex.current.set(m.id, i));
// ---- main render ---- // ---- main render ----
const virtualItems = virtualizer.getVirtualItems(); const virtualItems = virtualizer.getVirtualItems();

View File

@ -55,6 +55,99 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
const subAgentStackRef = useRef<SubAgentView[]>([]); const subAgentStackRef = useRef<SubAgentView[]>([]);
const pendingTaskNavsRef = useRef<Map<string, string>>(new Map()); const pendingTaskNavsRef = useRef<Map<string, string>>(new Map());
// ---- 流式 delta 批处理(镜像主视图 useMessages 的 ref 累加 + rAF flush 范式)----
// 每个 stream_delta 只累加到 refrAF 批量合并为一次 setState
// 避免逐 token 触发整棵子树重渲染与双层数组拷贝。
interface PendingSubAgentDelta {
/** 'top' 表示栈顶层;否则按 taskId 定位栈中层 */
target: 'top' | string;
id: string;
contentChunks: string[];
reasoningChunks: string[];
/** 首个 delta 到达时创建的消息壳(含首段 content后续 delta 追加到其后 */
shell: ChatMessage | null;
}
const pendingDeltasRef = useRef<Map<string, PendingSubAgentDelta>>(new Map());
const deltaRafRef = useRef<number | null>(null);
const deltaRafScheduledRef = useRef(false);
const flushSubAgentDeltas = useCallback(() => {
deltaRafScheduledRef.current = false;
const pending = pendingDeltasRef.current;
if (pending.size === 0) return;
const entries = Array.from(pending.values());
pending.clear();
setSubAgentStack((prev) => {
if (prev.length === 0) return prev;
let stack = prev;
for (const entry of entries) {
const layerIdx =
entry.target === 'top'
? stack.length - 1
: stack.findIndex((v) => v.taskId === entry.target);
if (layerIdx < 0) continue;
const layer = stack[layerIdx];
const deltaContent = entry.contentChunks.join('');
const deltaReasoning =
entry.reasoningChunks.length > 0 ? entry.reasoningChunks.join('') : null;
const idx = layer.messages.findIndex((m) => m.id === entry.id && m.type === 'message');
const layerCopy = { ...layer };
if (idx >= 0) {
const updated = [...layer.messages];
const existing = updated[idx];
updated[idx] = {
...existing,
content: existing.content + deltaContent,
reasoningContent: deltaReasoning
? (existing.reasoningContent || '') + deltaReasoning
: existing.reasoningContent,
};
layerCopy.messages = updated;
} else if (entry.shell) {
layerCopy.messages = [
...layer.messages,
{
...entry.shell,
content: entry.shell.content + deltaContent,
reasoningContent: deltaReasoning
? (entry.shell.reasoningContent || '') + deltaReasoning || undefined
: entry.shell.reasoningContent,
},
];
} else {
continue;
}
const next = [...stack];
next[layerIdx] = layerCopy;
stack = next;
}
return stack;
});
}, []);
const scheduleSubAgentDeltaFlush = useCallback(() => {
if (deltaRafScheduledRef.current) return;
deltaRafScheduledRef.current = true;
deltaRafRef.current = requestAnimationFrame(() => flushSubAgentDeltas());
}, [flushSubAgentDeltas]);
/** 立即落盘待处理 delta取消已排队的 rAF。非 delta 消息处理前调用以保证顺序。 */
const flushSubAgentDeltasSync = useCallback(() => {
if (deltaRafRef.current !== null) {
cancelAnimationFrame(deltaRafRef.current);
deltaRafRef.current = null;
}
flushSubAgentDeltas();
}, [flushSubAgentDeltas]);
// 卸载时取消未执行的 flush
useEffect(
() => () => {
if (deltaRafRef.current !== null) cancelAnimationFrame(deltaRafRef.current);
},
[],
);
// ref 同步:确保回调中读到最新值 // ref 同步:确保回调中读到最新值
useEffect(() => { useEffect(() => {
subAgentViewRef.current = subAgentView; subAgentViewRef.current = subAgentView;
@ -70,33 +163,24 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
if (message.type === 'assistant_response') { if (message.type === 'assistant_response') {
bumpTopicRefreshTrigger(); bumpTopicRefreshTrigger();
} }
// stream_delta: accumulate into existing message by ID, or create new // stream_delta: 累加到批处理缓冲区rAF 统一 flush不再逐 token setState
if (message.type === 'stream_delta') { if (message.type === 'stream_delta') {
const msg = message as StreamDelta; const msg = message as StreamDelta;
setSubAgentStack((prev) => { const pending = pendingDeltasRef.current;
if (prev.length === 0) return prev; const entry = pending.get(msg.id);
const top = prev[prev.length - 1]; if (entry) {
const existingIdx = top.messages.findIndex((m) => m.id === msg.id && m.type === 'message'); if (msg.delta) entry.contentChunks.push(msg.delta);
if (existingIdx >= 0) { if (msg.reasoning_delta) entry.reasoningChunks.push(msg.reasoning_delta);
const updated = [...top.messages]; } else {
const existing = updated[existingIdx]; pending.set(msg.id, {
updated[existingIdx] = { target: 'top',
...existing, id: msg.id,
content: existing.content + msg.delta, contentChunks: [],
reasoningContent: msg.reasoning_delta reasoningChunks: [],
? (existing.reasoningContent || '') + msg.reasoning_delta shell: serverMessageToChatMessage(message),
: existing.reasoningContent, });
}; }
const newStack = [...prev]; scheduleSubAgentDeltaFlush();
newStack[newStack.length - 1] = { ...top, messages: updated };
return newStack;
}
const chatMsg = serverMessageToChatMessage(message);
if (!chatMsg) return prev;
const newStack = [...prev];
newStack[newStack.length - 1] = { ...top, messages: [...top.messages, chatMsg] };
return newStack;
});
return; return;
} }
// stream_end: no-op, assistant_response will replace // stream_end: no-op, assistant_response will replace
@ -168,85 +252,89 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
return newStack; return newStack;
}); });
} }
}, [bumpTopicRefreshTrigger]); }, [bumpTopicRefreshTrigger, scheduleSubAgentDeltaFlush]);
// 追加消息到栈中非栈顶的匹配层(按 taskId 匹配) // 追加消息到栈中非栈顶的匹配层(按 taskId 匹配)
const appendToSubAgentLayerMessage = useCallback((taskId: string, message: WsOutbound) => { const appendToSubAgentLayerMessage = useCallback(
setSubAgentStack((prev) => { (taskId: string, message: WsOutbound) => {
const idx = prev.findIndex((v) => v.taskId === taskId); // stream_delta 在 setState 之外累加到批处理缓冲区updater 内不允许副作用)
if (idx < 0) return prev;
const layer = prev[idx];
if (message.type === 'execution_completed') {
const newStack = [...prev];
newStack[idx] = { ...layer, status: 'completed' };
return newStack;
}
if (message.type === 'error') {
const errMsg = message as WsError;
const errorChatMsg: ChatMessage = {
id: generateMessageId(),
role: 'assistant',
content: `Error: ${errMsg.message}`,
timestamp: errMsg.timestamp ?? Math.floor(Date.now() / 1000),
type: 'message',
};
const newStack = [...prev];
newStack[idx] = { ...layer, status: 'error', messages: [...layer.messages, errorChatMsg] };
return newStack;
}
if (message.type === 'stream_delta') { if (message.type === 'stream_delta') {
const msg = message as StreamDelta; const msg = message as StreamDelta;
const existingIdx = layer.messages.findIndex( const pending = pendingDeltasRef.current;
(m) => m.id === msg.id && m.type === 'message', const entry = pending.get(msg.id);
); if (entry) {
if (existingIdx >= 0) { if (msg.delta) entry.contentChunks.push(msg.delta);
const updated = [...layer.messages]; if (msg.reasoning_delta) entry.reasoningChunks.push(msg.reasoning_delta);
const existing = updated[existingIdx]; } else {
updated[existingIdx] = { pending.set(msg.id, {
...existing, target: taskId,
content: existing.content + msg.delta, id: msg.id,
reasoningContent: msg.reasoning_delta contentChunks: [],
? (existing.reasoningContent || '') + msg.reasoning_delta reasoningChunks: [],
: existing.reasoningContent, shell: serverMessageToChatMessage(message),
}; });
}
scheduleSubAgentDeltaFlush();
return;
}
setSubAgentStack((prev) => {
const idx = prev.findIndex((v) => v.taskId === taskId);
if (idx < 0) return prev;
const layer = prev[idx];
if (message.type === 'execution_completed') {
const newStack = [...prev]; const newStack = [...prev];
newStack[idx] = { ...layer, messages: updated }; newStack[idx] = { ...layer, status: 'completed' };
return newStack; return newStack;
} }
if (message.type === 'error') {
const errMsg = message as WsError;
const errorChatMsg: ChatMessage = {
id: generateMessageId(),
role: 'assistant',
content: `Error: ${errMsg.message}`,
timestamp: errMsg.timestamp ?? Math.floor(Date.now() / 1000),
type: 'message',
};
const newStack = [...prev];
newStack[idx] = {
...layer,
status: 'error',
messages: [...layer.messages, errorChatMsg],
};
return newStack;
}
if (message.type === 'stream_end') return prev;
const chatMsg = serverMessageToChatMessage(message); const chatMsg = serverMessageToChatMessage(message);
if (!chatMsg) return prev; if (!chatMsg) return prev;
if (message.type === 'assistant_response') {
const existingIdx = layer.messages.findIndex(
(m) => m.id === chatMsg.id && m.type === 'message',
);
if (existingIdx >= 0) {
const updated = [...layer.messages];
updated[existingIdx] = chatMsg;
const newStack = [...prev];
newStack[idx] = { ...layer, messages: updated };
return newStack;
}
} else if (
message.type === 'tool_call' ||
message.type === 'tool_result' ||
message.type === 'tool_pending'
) {
const exists = layer.messages.some(
(m) => m.id === chatMsg.id && m.type === chatMsg.type,
);
if (exists) return prev;
}
const newStack = [...prev]; const newStack = [...prev];
newStack[idx] = { ...layer, messages: [...layer.messages, chatMsg] }; newStack[idx] = { ...layer, messages: [...layer.messages, chatMsg] };
return newStack; return newStack;
} });
if (message.type === 'stream_end') return prev; },
const chatMsg = serverMessageToChatMessage(message); [scheduleSubAgentDeltaFlush],
if (!chatMsg) return prev; );
if (message.type === 'assistant_response') {
const existingIdx = layer.messages.findIndex(
(m) => m.id === chatMsg.id && m.type === 'message',
);
if (existingIdx >= 0) {
const updated = [...layer.messages];
updated[existingIdx] = chatMsg;
const newStack = [...prev];
newStack[idx] = { ...layer, messages: updated };
return newStack;
}
} else if (
message.type === 'tool_call' ||
message.type === 'tool_result' ||
message.type === 'tool_pending'
) {
const exists = layer.messages.some((m) => m.id === chatMsg.id && m.type === chatMsg.type);
if (exists) return prev;
}
const newStack = [...prev];
newStack[idx] = { ...layer, messages: [...layer.messages, chatMsg] };
return newStack;
});
}, []);
const enterSubAgentView = useCallback( const enterSubAgentView = useCallback(
(taskId: string, description: string, subagentType?: string): Command => { (taskId: string, description: string, subagentType?: string): Command => {
@ -267,6 +355,8 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
); );
const exitSubAgentView = useCallback((): Command | null => { const exitSubAgentView = useCallback((): Command | null => {
// 栈变更前先落盘待处理 delta避免丢失或写入清空后的新栈
flushSubAgentDeltasSync();
const current = subAgentStackRef.current; const current = subAgentStackRef.current;
if (current.length <= 1) { if (current.length <= 1) {
subAgentViewRef.current = null; subAgentViewRef.current = null;
@ -282,26 +372,31 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
subAgentStackRef.current = clearedStack; subAgentStackRef.current = clearedStack;
setSubAgentStack(clearedStack); setSubAgentStack(clearedStack);
return { type: 'load_task_messages', task_id: newTop.taskId }; return { type: 'load_task_messages', task_id: newTop.taskId };
}, []); }, [flushSubAgentDeltasSync]);
const navigateToSubAgentLevel = useCallback((index: number): Command | null => { const navigateToSubAgentLevel = useCallback(
const current = subAgentStackRef.current; (index: number): Command | null => {
if (index < 0) { // 栈变更前先落盘待处理 delta避免丢失或写入清空后的新栈
subAgentViewRef.current = null; flushSubAgentDeltasSync();
subAgentStackRef.current = []; const current = subAgentStackRef.current;
setSubAgentStack([]); if (index < 0) {
return null; subAgentViewRef.current = null;
} subAgentStackRef.current = [];
if (index >= current.length) return null; setSubAgentStack([]);
const newStack = current.slice(0, index + 1); return null;
const newTop = newStack[newStack.length - 1]; }
subAgentViewRef.current = newTop; if (index >= current.length) return null;
const clearedStack = [...newStack]; const newStack = current.slice(0, index + 1);
clearedStack[clearedStack.length - 1] = { ...newTop, messages: [], status: 'loading' }; const newTop = newStack[newStack.length - 1];
subAgentStackRef.current = clearedStack; subAgentViewRef.current = newTop;
setSubAgentStack(clearedStack); const clearedStack = [...newStack];
return { type: 'load_task_messages', task_id: newTop.taskId }; clearedStack[clearedStack.length - 1] = { ...newTop, messages: [], status: 'loading' };
}, []); subAgentStackRef.current = clearedStack;
setSubAgentStack(clearedStack);
return { type: 'load_task_messages', task_id: newTop.taskId };
},
[flushSubAgentDeltasSync],
);
/** Tier 2 路由:子智能体视图激活时处理消息,返回是否已处理 */ /** Tier 2 路由:子智能体视图激活时处理消息,返回是否已处理 */
const handleSubAgentMessage = useCallback( const handleSubAgentMessage = useCallback(
@ -309,6 +404,11 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
const currentSubAgentView = subAgentViewRef.current; const currentSubAgentView = subAgentViewRef.current;
if (!currentSubAgentView) return false; if (!currentSubAgentView) return false;
// 非 delta 消息处理前先落盘待处理的 delta保证消息顺序与内容完整性
if (message.type !== 'stream_delta' && pendingDeltasRef.current.size > 0) {
flushSubAgentDeltasSync();
}
if (message.type === 'task_messages_loaded') { if (message.type === 'task_messages_loaded') {
const msg = message as TaskMessagesLoaded; const msg = message as TaskMessagesLoaded;
setSubAgentStack((prev) => { setSubAgentStack((prev) => {
@ -431,6 +531,7 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
appendToSubAgentLayerMessage, appendToSubAgentLayerMessage,
sendCommand, sendCommand,
requestSubAgentTodoList, requestSubAgentTodoList,
flushSubAgentDeltasSync,
], ],
); );