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:
parent
52f858bfb4
commit
5e2bdaf757
@ -423,6 +423,16 @@ function App() {
|
||||
sendMessage({ type: 'command', payload: JSON.stringify(cmd) });
|
||||
}, [sendMessage, handleCommand, handleStop]);
|
||||
|
||||
// 稳定引用:只读视图(子智能体/定时任务)下的空发送回调,
|
||||
// 避免内联箭头函数每次渲染产生新引用、破坏下游 memo 化。
|
||||
const noopSendMessage = useCallback(() => {}, []);
|
||||
|
||||
// 稳定引用:打开专家设置页
|
||||
const openExpertsSettings = useCallback(() => {
|
||||
setConfigInitialTab('experts');
|
||||
setConfigPageOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleCreateTopic = useCallback(() => {
|
||||
if (isReadOnly || !sessionId) {
|
||||
return;
|
||||
@ -1007,7 +1017,7 @@ function App() {
|
||||
channels.find((c) => c.id === selectedChannel)?.name ??
|
||||
'PicoBot')
|
||||
}
|
||||
onSendMessage={subAgentView || schedulerView ? () => {} : handleSendMessage}
|
||||
onSendMessage={subAgentView || schedulerView ? noopSendMessage : handleSendMessage}
|
||||
onNavigateToSubAgent={handleNavigateToSubAgent}
|
||||
onStop={handleStopExecution}
|
||||
showThinking={showThinking}
|
||||
@ -1015,10 +1025,7 @@ function App() {
|
||||
highlightedMessageId={highlightedMessageId}
|
||||
sessionId={sessionId}
|
||||
settingsClosedTick={settingsClosedTick}
|
||||
onOpenSettings={() => {
|
||||
setConfigInitialTab('experts');
|
||||
setConfigPageOpen(true);
|
||||
}}
|
||||
onOpenSettings={openExpertsSettings}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { MessageList } from './MessageList';
|
||||
import { MessageInput } from './MessageInput';
|
||||
import { ExpertSelector } from './ExpertSelector';
|
||||
@ -55,6 +55,13 @@ export function ChatContainer({
|
||||
model: string;
|
||||
} | null>(null);
|
||||
|
||||
// 稳定引用,避免内联箭头破坏下游 memo 化
|
||||
const handleModelSelectionChange = useCallback(
|
||||
(effective: { provider: string; model: string }) =>
|
||||
setEffectiveModel({ provider: effective.provider, model: effective.model }),
|
||||
[],
|
||||
);
|
||||
|
||||
const selectors = (
|
||||
<div className="flex flex-wrap items-center gap-1 px-3 pt-2">
|
||||
<ExpertSelector
|
||||
@ -67,9 +74,7 @@ export function ChatContainer({
|
||||
sessionId={sessionId ?? null}
|
||||
topicId={topicId ?? null}
|
||||
settingsClosedTick={settingsClosedTick}
|
||||
onSelectionChange={(effective) =>
|
||||
setEffectiveModel({ provider: effective.provider, model: effective.model })
|
||||
}
|
||||
onSelectionChange={handleModelSelectionChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -132,6 +132,24 @@ function formatDuration(ms: number): string {
|
||||
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 }) {
|
||||
const fileName = attachment.file_name || getFileName(attachment.path);
|
||||
|
||||
@ -140,13 +158,7 @@ function AttachmentCard({ attachment }: { attachment: Attachment }) {
|
||||
|
||||
e.preventDefault();
|
||||
const mimeType = attachment.mime_type || 'application/octet-stream';
|
||||
const byteChars = atob(attachment.content_base64);
|
||||
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 blob = base64ToBlob(attachment.content_base64, mimeType);
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const a = document.createElement('a');
|
||||
@ -210,13 +222,7 @@ function ImageLightbox({
|
||||
|
||||
const handleDownload = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const byteChars = atob(src);
|
||||
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 blob = base64ToBlob(src, mimeType);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
|
||||
@ -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 { MessageBubble } from './MessageBubble';
|
||||
import type { ChatMessage } from '../../types/protocol';
|
||||
@ -55,8 +55,13 @@ export function MessageList({
|
||||
: undefined,
|
||||
});
|
||||
|
||||
// 消息 id → virtualizer index 映射,用于 highlight 滚动定位
|
||||
const messageIdToIndex = useRef<Map<string, number>>(new Map());
|
||||
// 消息 id → virtualizer index 映射,用于 highlight 滚动定位。
|
||||
// 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 ----
|
||||
|
||||
@ -167,7 +172,7 @@ export function MessageList({
|
||||
|
||||
useEffect(() => {
|
||||
if (!highlightedMessageId) return;
|
||||
const idx = messageIdToIndex.current.get(highlightedMessageId);
|
||||
const idx = messageIdToIndex.get(highlightedMessageId);
|
||||
if (idx === undefined) return;
|
||||
|
||||
virtualizer.scrollToIndex(idx, { align: 'center', behavior: 'smooth' });
|
||||
@ -183,7 +188,7 @@ export function MessageList({
|
||||
targetElement.classList.remove('todo-highlight');
|
||||
}, 2000);
|
||||
});
|
||||
}, [highlightedMessageId, virtualizer]);
|
||||
}, [highlightedMessageId, messageIdToIndex, virtualizer]);
|
||||
|
||||
// ---- 行高强制重测(修复 tanstack virtual-core 3.17.x 陈旧高度导致行重叠)----
|
||||
// 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 ----
|
||||
|
||||
const virtualItems = virtualizer.getVirtualItems();
|
||||
|
||||
@ -55,6 +55,99 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
|
||||
const subAgentStackRef = useRef<SubAgentView[]>([]);
|
||||
const pendingTaskNavsRef = useRef<Map<string, string>>(new Map());
|
||||
|
||||
// ---- 流式 delta 批处理(镜像主视图 useMessages 的 ref 累加 + rAF flush 范式)----
|
||||
// 每个 stream_delta 只累加到 ref,rAF 批量合并为一次 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 同步:确保回调中读到最新值
|
||||
useEffect(() => {
|
||||
subAgentViewRef.current = subAgentView;
|
||||
@ -70,33 +163,24 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
|
||||
if (message.type === 'assistant_response') {
|
||||
bumpTopicRefreshTrigger();
|
||||
}
|
||||
// stream_delta: accumulate into existing message by ID, or create new
|
||||
// stream_delta: 累加到批处理缓冲区,rAF 统一 flush(不再逐 token setState)
|
||||
if (message.type === 'stream_delta') {
|
||||
const msg = message as StreamDelta;
|
||||
setSubAgentStack((prev) => {
|
||||
if (prev.length === 0) return prev;
|
||||
const top = prev[prev.length - 1];
|
||||
const existingIdx = top.messages.findIndex((m) => m.id === msg.id && m.type === 'message');
|
||||
if (existingIdx >= 0) {
|
||||
const updated = [...top.messages];
|
||||
const existing = updated[existingIdx];
|
||||
updated[existingIdx] = {
|
||||
...existing,
|
||||
content: existing.content + msg.delta,
|
||||
reasoningContent: msg.reasoning_delta
|
||||
? (existing.reasoningContent || '') + msg.reasoning_delta
|
||||
: existing.reasoningContent,
|
||||
};
|
||||
const newStack = [...prev];
|
||||
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;
|
||||
const pending = pendingDeltasRef.current;
|
||||
const entry = pending.get(msg.id);
|
||||
if (entry) {
|
||||
if (msg.delta) entry.contentChunks.push(msg.delta);
|
||||
if (msg.reasoning_delta) entry.reasoningChunks.push(msg.reasoning_delta);
|
||||
} else {
|
||||
pending.set(msg.id, {
|
||||
target: 'top',
|
||||
id: msg.id,
|
||||
contentChunks: [],
|
||||
reasoningChunks: [],
|
||||
shell: serverMessageToChatMessage(message),
|
||||
});
|
||||
}
|
||||
scheduleSubAgentDeltaFlush();
|
||||
return;
|
||||
}
|
||||
// stream_end: no-op, assistant_response will replace
|
||||
@ -168,10 +252,31 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
|
||||
return newStack;
|
||||
});
|
||||
}
|
||||
}, [bumpTopicRefreshTrigger]);
|
||||
}, [bumpTopicRefreshTrigger, scheduleSubAgentDeltaFlush]);
|
||||
|
||||
// 追加消息到栈中非栈顶的匹配层(按 taskId 匹配)
|
||||
const appendToSubAgentLayerMessage = useCallback((taskId: string, message: WsOutbound) => {
|
||||
const appendToSubAgentLayerMessage = useCallback(
|
||||
(taskId: string, message: WsOutbound) => {
|
||||
// stream_delta 在 setState 之外累加到批处理缓冲区(updater 内不允许副作用)
|
||||
if (message.type === 'stream_delta') {
|
||||
const msg = message as StreamDelta;
|
||||
const pending = pendingDeltasRef.current;
|
||||
const entry = pending.get(msg.id);
|
||||
if (entry) {
|
||||
if (msg.delta) entry.contentChunks.push(msg.delta);
|
||||
if (msg.reasoning_delta) entry.reasoningChunks.push(msg.reasoning_delta);
|
||||
} else {
|
||||
pending.set(msg.id, {
|
||||
target: taskId,
|
||||
id: msg.id,
|
||||
contentChunks: [],
|
||||
reasoningChunks: [],
|
||||
shell: serverMessageToChatMessage(message),
|
||||
});
|
||||
}
|
||||
scheduleSubAgentDeltaFlush();
|
||||
return;
|
||||
}
|
||||
setSubAgentStack((prev) => {
|
||||
const idx = prev.findIndex((v) => v.taskId === taskId);
|
||||
if (idx < 0) return prev;
|
||||
@ -192,32 +297,11 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
|
||||
type: 'message',
|
||||
};
|
||||
const newStack = [...prev];
|
||||
newStack[idx] = { ...layer, status: 'error', messages: [...layer.messages, errorChatMsg] };
|
||||
return newStack;
|
||||
}
|
||||
if (message.type === 'stream_delta') {
|
||||
const msg = message as StreamDelta;
|
||||
const existingIdx = layer.messages.findIndex(
|
||||
(m) => m.id === msg.id && m.type === 'message',
|
||||
);
|
||||
if (existingIdx >= 0) {
|
||||
const updated = [...layer.messages];
|
||||
const existing = updated[existingIdx];
|
||||
updated[existingIdx] = {
|
||||
...existing,
|
||||
content: existing.content + msg.delta,
|
||||
reasoningContent: msg.reasoning_delta
|
||||
? (existing.reasoningContent || '') + msg.reasoning_delta
|
||||
: existing.reasoningContent,
|
||||
newStack[idx] = {
|
||||
...layer,
|
||||
status: 'error',
|
||||
messages: [...layer.messages, errorChatMsg],
|
||||
};
|
||||
const newStack = [...prev];
|
||||
newStack[idx] = { ...layer, messages: updated };
|
||||
return newStack;
|
||||
}
|
||||
const chatMsg = serverMessageToChatMessage(message);
|
||||
if (!chatMsg) return prev;
|
||||
const newStack = [...prev];
|
||||
newStack[idx] = { ...layer, messages: [...layer.messages, chatMsg] };
|
||||
return newStack;
|
||||
}
|
||||
if (message.type === 'stream_end') return prev;
|
||||
@ -239,14 +323,18 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
|
||||
message.type === 'tool_result' ||
|
||||
message.type === 'tool_pending'
|
||||
) {
|
||||
const exists = layer.messages.some((m) => m.id === chatMsg.id && m.type === chatMsg.type);
|
||||
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;
|
||||
});
|
||||
}, []);
|
||||
},
|
||||
[scheduleSubAgentDeltaFlush],
|
||||
);
|
||||
|
||||
const enterSubAgentView = useCallback(
|
||||
(taskId: string, description: string, subagentType?: string): Command => {
|
||||
@ -267,6 +355,8 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
|
||||
);
|
||||
|
||||
const exitSubAgentView = useCallback((): Command | null => {
|
||||
// 栈变更前先落盘待处理 delta,避免丢失或写入清空后的新栈
|
||||
flushSubAgentDeltasSync();
|
||||
const current = subAgentStackRef.current;
|
||||
if (current.length <= 1) {
|
||||
subAgentViewRef.current = null;
|
||||
@ -282,9 +372,12 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
|
||||
subAgentStackRef.current = clearedStack;
|
||||
setSubAgentStack(clearedStack);
|
||||
return { type: 'load_task_messages', task_id: newTop.taskId };
|
||||
}, []);
|
||||
}, [flushSubAgentDeltasSync]);
|
||||
|
||||
const navigateToSubAgentLevel = useCallback((index: number): Command | null => {
|
||||
const navigateToSubAgentLevel = useCallback(
|
||||
(index: number): Command | null => {
|
||||
// 栈变更前先落盘待处理 delta,避免丢失或写入清空后的新栈
|
||||
flushSubAgentDeltasSync();
|
||||
const current = subAgentStackRef.current;
|
||||
if (index < 0) {
|
||||
subAgentViewRef.current = null;
|
||||
@ -301,7 +394,9 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
|
||||
subAgentStackRef.current = clearedStack;
|
||||
setSubAgentStack(clearedStack);
|
||||
return { type: 'load_task_messages', task_id: newTop.taskId };
|
||||
}, []);
|
||||
},
|
||||
[flushSubAgentDeltasSync],
|
||||
);
|
||||
|
||||
/** Tier 2 路由:子智能体视图激活时处理消息,返回是否已处理 */
|
||||
const handleSubAgentMessage = useCallback(
|
||||
@ -309,6 +404,11 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
|
||||
const currentSubAgentView = subAgentViewRef.current;
|
||||
if (!currentSubAgentView) return false;
|
||||
|
||||
// 非 delta 消息处理前先落盘待处理的 delta,保证消息顺序与内容完整性
|
||||
if (message.type !== 'stream_delta' && pendingDeltasRef.current.size > 0) {
|
||||
flushSubAgentDeltasSync();
|
||||
}
|
||||
|
||||
if (message.type === 'task_messages_loaded') {
|
||||
const msg = message as TaskMessagesLoaded;
|
||||
setSubAgentStack((prev) => {
|
||||
@ -431,6 +531,7 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
|
||||
appendToSubAgentLayerMessage,
|
||||
sendCommand,
|
||||
requestSubAgentTodoList,
|
||||
flushSubAgentDeltasSync,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user