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