From 5e2bdaf757764a3a68562ca55bd95ffbddd874f8 Mon Sep 17 00:00:00 2001 From: oudecheng <13802883547@139.com> Date: Mon, 17 Aug 2026 09:55:11 +0800 Subject: [PATCH] =?UTF-8?q?perf(web):=20=E5=AD=90=E4=BB=A3=E7=90=86=20stre?= =?UTF-8?q?am=5Fdelta=20rAF=20=E6=89=B9=E5=A4=84=E7=90=86=20+=20=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E6=A7=BD=20memo=20=E5=8C=96=20+=20base64=20=E5=88=86?= =?UTF-8?q?=E5=9D=97=E8=A7=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useSubAgentView 镜像主视图:delta 累加到 ref,rAF 批量一次 setState,避免逐 token 重渲染 - 所有非 delta 消息处理前同步落盘 pending delta,保证顺序与内容完整性 - MessageList 消息 ID 映射 useMemo,稳定回调引用减少重渲染 - MessageBubble base64 下载改分块解码(32K),大附件峰值内存从数百 MB 降为单块级 --- web/src/App.tsx | 17 +- web/src/components/Chat/ChatContainer.tsx | 13 +- web/src/components/Chat/MessageBubble.tsx | 34 ++- web/src/components/Chat/MessageList.tsx | 19 +- web/src/hooks/chat/useSubAgentView.ts | 323 ++++++++++++++-------- 5 files changed, 263 insertions(+), 143 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index a4bd73b..7b049e2 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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} /> diff --git a/web/src/components/Chat/ChatContainer.tsx b/web/src/components/Chat/ChatContainer.tsx index b6e5c91..94a2c35 100644 --- a/web/src/components/Chat/ChatContainer.tsx +++ b/web/src/components/Chat/ChatContainer.tsx @@ -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 = (
- setEffectiveModel({ provider: effective.provider, model: effective.model }) - } + onSelectionChange={handleModelSelectionChange} />
); diff --git a/web/src/components/Chat/MessageBubble.tsx b/web/src/components/Chat/MessageBubble.tsx index 3706a4d..5dfcace 100644 --- a/web/src/components/Chat/MessageBubble.tsx +++ b/web/src/components/Chat/MessageBubble.tsx @@ -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[] = []; + 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; diff --git a/web/src/components/Chat/MessageList.tsx b/web/src/components/Chat/MessageList.tsx index 1c67cd5..1dedf7d 100644 --- a/web/src/components/Chat/MessageList.tsx +++ b/web/src/components/Chat/MessageList.tsx @@ -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>(new Map()); + // 消息 id → virtualizer index 映射,用于 highlight 滚动定位。 + // useMemo 化:仅在 messages 变化时重建,而非每次渲染(流式期间每帧一次)都全量重建。 + const messageIdToIndex = useMemo(() => { + const map = new Map(); + 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(); diff --git a/web/src/hooks/chat/useSubAgentView.ts b/web/src/hooks/chat/useSubAgentView.ts index 49e44ed..5429f99 100644 --- a/web/src/hooks/chat/useSubAgentView.ts +++ b/web/src/hooks/chat/useSubAgentView.ts @@ -55,6 +55,99 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie const subAgentStackRef = useRef([]); const pendingTaskNavsRef = useRef>(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>(new Map()); + const deltaRafRef = useRef(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,85 +252,89 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie return newStack; }); } - }, [bumpTopicRefreshTrigger]); + }, [bumpTopicRefreshTrigger, scheduleSubAgentDeltaFlush]); // 追加消息到栈中非栈顶的匹配层(按 taskId 匹配) - const appendToSubAgentLayerMessage = useCallback((taskId: string, message: WsOutbound) => { - 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]; - 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; - } + const appendToSubAgentLayerMessage = useCallback( + (taskId: string, message: WsOutbound) => { + // stream_delta 在 setState 之外累加到批处理缓冲区(updater 内不允许副作用) 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, - }; + 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; + const layer = prev[idx]; + + if (message.type === 'execution_completed') { const newStack = [...prev]; - newStack[idx] = { ...layer, messages: updated }; + 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_end') return prev; const chatMsg = serverMessageToChatMessage(message); 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; - } - if (message.type === 'stream_end') return prev; - const chatMsg = serverMessageToChatMessage(message); - 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; - }); - }, []); + }); + }, + [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,26 +372,31 @@ 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 current = subAgentStackRef.current; - if (index < 0) { - subAgentViewRef.current = null; - subAgentStackRef.current = []; - setSubAgentStack([]); - return null; - } - if (index >= current.length) return null; - const newStack = current.slice(0, index + 1); - const newTop = newStack[newStack.length - 1]; - subAgentViewRef.current = newTop; - const clearedStack = [...newStack]; - clearedStack[clearedStack.length - 1] = { ...newTop, messages: [], status: 'loading' }; - subAgentStackRef.current = clearedStack; - setSubAgentStack(clearedStack); - return { type: 'load_task_messages', task_id: newTop.taskId }; - }, []); + const navigateToSubAgentLevel = useCallback( + (index: number): Command | null => { + // 栈变更前先落盘待处理 delta,避免丢失或写入清空后的新栈 + flushSubAgentDeltasSync(); + const current = subAgentStackRef.current; + if (index < 0) { + subAgentViewRef.current = null; + subAgentStackRef.current = []; + setSubAgentStack([]); + return null; + } + if (index >= current.length) return null; + const newStack = current.slice(0, index + 1); + const newTop = newStack[newStack.length - 1]; + subAgentViewRef.current = newTop; + const clearedStack = [...newStack]; + 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 路由:子智能体视图激活时处理消息,返回是否已处理 */ 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, ], );