From 8973ea6d47b228da90f24c03555d6199b5c0ca0c Mon Sep 17 00:00:00 2001 From: thzxx <1440196015@qq.com> Date: Tue, 21 Jul 2026 14:38:27 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8D=87=E7=BA=A7=E8=87=B3=20v0.3.11?= =?UTF-8?q?=20=E2=80=94=20=E6=B5=81=E5=BC=8F=E8=BE=93=E5=87=BA=E6=80=A7?= =?UTF-8?q?=E8=83=BD=E4=BC=98=E5=8C=96=2012=20=E9=A1=B9=EF=BC=8C=E8=A7=A3?= =?UTF-8?q?=E5=86=B3=E9=95=BF=E5=86=85=E5=AE=B9=E5=8D=A1=E6=AD=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 针对 AI 流式输出长内容时应用卡死问题,实施 12 项性能优化: 渲染层优化(F1/F3/F7): - F1: React.memo 包裹 MessageItem/AssistantMessage,跳过历史消息重渲染 - F3: 流式时用纯文本渲染,避免 ReactMarkdown 全量重解析 O(n²) - F7: useMemo 缓存 ReactMarkdown 元素,非流式时复用实例 滚动与布局(F2/F4/F10/F12): - F2: 滚动节流 rAF + 100ms throttle + trailing 兜底,避免高频布局 - F4: CSS content-visibility 准虚拟滚动,跳过不可见区域布局 - F10: CSS contain 隔离 markdown 布局计算 - F12: 滚动容器 transform: translateZ(0) GPU 加速 状态与 IPC 批处理(F5/F8/F9): - F5: 渲染进程 text_delta rAF 批处理,合并多次 store 写入 - F8: 主进程 text_delta 32ms 节流合并,降低 IPC 频率 - F9: reasoning_delta traceSteps thought 走 rAF 批处理 代码清理(F11): - F11: 移除 rehypeRaw,节省 raw HTML 解析开销 + 防 AI 输出注入 验证:tsc --noEmit 通过,flush 逻辑在 done/error/cleanup 三处正确执行。 --- README.md | 2 +- electron/ipc/handlers.ts | 74 ++++++++++++- package-lock.json | 4 +- package.json | 2 +- src/components/chat/AssistantMessage.tsx | 83 +++++++++++---- src/components/chat/MessageItem.tsx | 15 ++- src/components/chat/MessageList.tsx | 92 ++++++++++++++-- src/hooks/useAgentStream.ts | 128 +++++++++++++++++++---- src/styles/globals.css | 4 + 9 files changed, 350 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 893856a..7e4fdb1 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ > 生产级通用 AI Agent 智能体桌面应用 -[![Version](https://img.shields.io/badge/version-0.3.10-blue)](./package.json) +[![Version](https://img.shields.io/badge/version-0.3.11-blue)](./package.json) [![License](https://img.shields.io/badge/license-MIT-green)](./LICENSE) [![Electron](https://img.shields.io/badge/Electron-35-47848F)](https://www.electronjs.org/) [![React](https://img.shields.io/badge/React-19-61DAFB)](https://react.dev/) diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 8fd35a9..5cb809d 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -163,10 +163,73 @@ export function registerAllIPCHandlers( } // 监听 Agent Loop 事件 - const onStreamEvent = (event: MetonaStreamEvent) => { - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send('agent:streamEvent', event); + // F8: 主进程 text_delta 节流合并 + // 问题:主进程 1:1 转发所有流式事件,text_delta 频率 30-50 次/秒, + // 每次 IPC 调用都有跨进程开销,叠加渲染进程处理成本。 + // 方案:在主进程聚合 text_delta,32ms 间隔合并转发(IPC 频率降至 ~30 次/秒)。 + // 非 text_delta 事件立即转发(先 flush 缓冲区保证顺序)。 + // done/error 时立即 flush,避免最后一段 delta 丢失。 + // 与 F5 渲染进程 rAF 批处理叠加,总延迟约 48ms(人眼不敏感)。 + let textDeltaBuffer = ''; + let lastTextEventMeta: Pick | null = null; + let flushTimer: ReturnType | null = null; + + const flushTextDeltaBuffer = () => { + flushTimer = null; + if (!textDeltaBuffer || !lastTextEventMeta || mainWindow.isDestroyed()) { + textDeltaBuffer = ''; + lastTextEventMeta = null; + return; } + // 构建合并的 text_delta 事件,保留最后一个 delta 的元数据 + const mergedEvent: MetonaStreamEvent = { + ...lastTextEventMeta, + type: MetonaStreamEventType.TEXT_DELTA, + delta: textDeltaBuffer, + seq: lastTextEventMeta.seq, // 使用最后一个 delta 的 seq + timestamp: Date.now(), + }; + mainWindow.webContents.send('agent:streamEvent', mergedEvent); + textDeltaBuffer = ''; + lastTextEventMeta = null; + }; + + const onStreamEvent = (event: MetonaStreamEvent) => { + if (mainWindow.isDestroyed()) return; + + // F8: text_delta 聚合,其他事件立即转发(先 flush 保证顺序) + if (event.type === MetonaStreamEventType.TEXT_DELTA && event.delta) { + if (textDeltaBuffer === '') { + lastTextEventMeta = { + requestId: event.requestId, + sessionId: event.sessionId, + iteration: event.iteration, + seq: event.seq, + timestamp: event.timestamp, + runId: event.runId, + }; + } else { + // 更新 seq 为最新 delta 的 seq(保证前端 seq 连续性判断) + lastTextEventMeta = { + ...lastTextEventMeta!, + seq: event.seq, + timestamp: event.timestamp, + runId: event.runId, + }; + } + textDeltaBuffer += event.delta; + if (flushTimer === null) { + flushTimer = setTimeout(flushTextDeltaBuffer, 32); + } + return; + } + + // 非 text_delta 事件:先 flush 缓冲区,再立即转发(保证事件顺序) + if (flushTimer !== null) { + clearTimeout(flushTimer); + flushTextDeltaBuffer(); + } + mainWindow.webContents.send('agent:streamEvent', event); }; const onStateChange = (data: { previous?: string; current?: string; sessionId?: string; iteration?: number; state?: string; runId?: string }) => { @@ -413,6 +476,11 @@ export function registerAllIPCHandlers( return { success: false, error: (error as Error).message }; } finally { + // F8: 注销前 flush 残留的 text_delta 缓冲区,避免丢失最后一段内容 + if (flushTimer !== null) { + clearTimeout(flushTimer); + flushTextDeltaBuffer(); + } agentLoop.off('streamEvent', onStreamEvent); agentLoop.off('stateChange', onStateChange); agentLoop.off('compressed', onCompressed); diff --git a/package-lock.json b/package-lock.json index 04eed08..28ba837 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "metona-ai-desktop", - "version": "0.3.10", + "version": "0.3.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "metona-ai-desktop", - "version": "0.3.10", + "version": "0.3.11", "license": "MIT", "dependencies": { "@emotion/react": "^11.14.0", diff --git a/package.json b/package.json index dea156c..14d0162 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metona-ai-desktop", - "version": "0.3.10", + "version": "0.3.11", "description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用", "main": "dist-electron/main/main.js", "author": "Metona Team", diff --git a/src/components/chat/AssistantMessage.tsx b/src/components/chat/AssistantMessage.tsx index 76168a5..ee48e28 100644 --- a/src/components/chat/AssistantMessage.tsx +++ b/src/components/chat/AssistantMessage.tsx @@ -5,15 +5,20 @@ * 1. 💭 思考阶段 — ThoughtBlock 自动展开,流式追加 reasoning content * 2. 🔧 工具调用 — ToolCallCard 展示调用和结果 * 3. ✏️ 回复阶段 — Markdown 渲染,打字光标指示流式输出 + * + * F1 性能优化: 使用 React.memo 包裹,避免历史消息在流式 delta 时重渲染。 + * 配合 agentStatus 选择器优化:历史消息(isStreaming=false)不订阅真实 agentStatus, + * 避免 agentStatus 频繁变化(thinking/executing/idle)触发所有 AssistantMessage 重渲染。 */ -import { useState, useCallback } from 'react'; +import { useState, useCallback, memo, useMemo } from 'react'; import { Box, Typography, Avatar, Stack, IconButton, Tooltip } from '@mui/material'; import { Bot, Copy, Check } from 'lucide-react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import rehypeHighlight from 'rehype-highlight'; -import rehypeRaw from 'rehype-raw'; +// F11: 移除 rehypeRaw — 避免解析 raw HTML 的开销 + 安全考虑(防止 AI 输出 HTML 注入) +// 如需恢复内联 HTML 渲染,可重新引入 rehype-raw import type { ChatMessage } from '@renderer/stores/agent-store'; import { useAgentStore } from '@renderer/stores/agent-store'; import { ThoughtBlock } from './ThoughtBlock'; @@ -27,11 +32,14 @@ interface AssistantMessageProps { isStreaming?: boolean; } -export function AssistantMessage({ message, isStreaming }: AssistantMessageProps): React.JSX.Element { +function AssistantMessageImpl({ message, isStreaming }: AssistantMessageProps): React.JSX.Element { // 直接使用 message.content — updateLastAssistantMessage 已实时更新此字段 const content = message.content; const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); - const agentStatus = useAgentStore((s) => s.agentStatus); + // F1: 历史消息(isStreaming=false)固定返回 'idle',避免 agentStatus 变化触发重渲染 + // 逻辑等价:原 isThinking = agentStatus === 'thinking' && isStreaming + // 新 isThinking = agentStatus === 'thinking'(agentStatus 在非流式时恒为 'idle') + const agentStatus = useAgentStore((s) => (isStreaming ? s.agentStatus : 'idle')); const contextMenuItems = createContextMenuItems('message', { content }); @@ -40,6 +48,29 @@ export function AssistantMessage({ message, isStreaming }: AssistantMessageProps const hasContent = !!content; const isThinking = agentStatus === 'thinking' && isStreaming; + // F7: useMemo 缓存 ReactMarkdown 元素,避免非流式时因 isStreaming/isLast 变化重新创建元素 + // 流式时此元素不被渲染(用纯文本),useMemo 仍会更新但仅创建轻量 React 元素对象 + // 非流式时 content 不变,useMemo 复用缓存,避免重新创建 ReactMarkdown 组件实例 + const markdownElement = useMemo(() => ( + + 高亮元素,需提取纯文本 + const codeStr = extractTextContent(children).replace(/\n$/, ''); + if (!match) return {children}; + return ; + }, + }} + > + {content} + + + ), [content]); + return ( - 高亮元素,需提取纯文本 - const codeStr = extractTextContent(children).replace(/\n$/, ''); - if (!match) return {children}; - return ; - }, + isStreaming ? ( + // F3: 流式时用纯文本渲染,避免 ReactMarkdown 对长内容重新解析导致卡顿。 + // 根因:每个 delta 触发 ReactMarkdown 全量重新解析 + rehypeHighlight 高亮, + // 内容长度增加时单次解析耗时 O(n) 退化,30+ delta/秒下呈 O(n²) 卡死。 + // 流结束后自动切换到 ReactMarkdown 一次性渲染(一次性 O(n),可接受)。 + {content} - - + + ) : ( + // F7: 使用 useMemo 缓存的 ReactMarkdown 元素 + markdownElement + ) ) : isStreaming ? ( {isThinking ? '正在思考...' : '正在生成回复...'} @@ -160,6 +200,13 @@ export function AssistantMessage({ message, isStreaming }: AssistantMessageProps ); } +/** + * F1: memo 包裹 AssistantMessage。 + * - 流式 delta 时仅最后一条 message 引用变化,历史消息跳过 re-render + * - agentStatus 订阅已优化(非流式时返回 'idle'),历史消息不受 agentStatus 变化影响 + */ +export const AssistantMessage = memo(AssistantMessageImpl); + function CodeBlock({ language, code }: { language: string; code: string }): React.JSX.Element { const [copied, setCopied] = useState(false); const handleCopy = useCallback(async () => { diff --git a/src/components/chat/MessageItem.tsx b/src/components/chat/MessageItem.tsx index 1941fa7..4cf55b7 100644 --- a/src/components/chat/MessageItem.tsx +++ b/src/components/chat/MessageItem.tsx @@ -2,8 +2,13 @@ * MessageItem — 单条消息路由组件 * * 根据消息 role 路由到对应的子组件渲染。 + * + * F1 性能优化: 使用 React.memo 包裹,避免流式 delta 触发历史消息重渲染。 + * 浅比较策略:message 引用变化(store 仅替换最后一条)或 isStreaming 变化时才重渲染。 + * 历史消息的 message 引用在 delta 流中保持不变,因此可跳过 re-render。 */ +import { memo } from 'react'; import { Box, Typography, Stack } from '@mui/material'; import { Terminal } from 'lucide-react'; import type { ChatMessage } from '@renderer/stores/agent-store'; @@ -18,7 +23,7 @@ interface MessageItemProps { isStreaming?: boolean; } -export function MessageItem({ message, isLast, isStreaming }: MessageItemProps): React.JSX.Element { +function MessageItemImpl({ message, isLast, isStreaming }: MessageItemProps): React.JSX.Element { switch (message.role) { case 'user': return ; @@ -41,6 +46,14 @@ export function MessageItem({ message, isLast, isStreaming }: MessageItemProps): } } +/** + * F1: memo 默认浅比较 props。 + * - message 是对象引用,store 更新最后一条时只替换最后一条,历史消息引用不变 → 跳过 re-render + * - isStreaming 是 boolean,流式过程中值不变(持续 true),流结束才变化一次 → 历史消息仅重渲染一次 + * - isLast 是 boolean,仅在最后一条切换时变化 + */ +export const MessageItem = memo(MessageItemImpl); + /** ToolMessage — 独立的工具结果消息(role=tool) */ function ToolMessage({ message }: { message: ChatMessage }): React.JSX.Element { return ( diff --git a/src/components/chat/MessageList.tsx b/src/components/chat/MessageList.tsx index 5700a7e..f682a95 100644 --- a/src/components/chat/MessageList.tsx +++ b/src/components/chat/MessageList.tsx @@ -13,13 +13,72 @@ export function MessageList(): React.JSX.Element { const isStreaming = useAgentStore((s) => s.isStreaming); const messagesEndRef = useRef(null); - // 流式输出时高频触发,使用 throttle 避免滚动卡顿 + // F2: 滚动节流优化 + // 问题:原实现用 setTimeout(80) debounce + 'smooth',高频 delta 时 trailing 永不触发, + // 且 'smooth' 在长列表上触发主线程布局动画,加剧卡顿。 + // 方案: + // - 流式时:100ms 节流 + trailing 兜底 + 'auto' 行为(同步布局,无动画占主线程) + // - 非流式时:立即 'smooth' 滚动(新消息发送/接收完成) + // - 用 rAF 同步到下一帧,与 React 渲染合并,避免一帧内多次布局 + const lastScrollRef = useRef(0); + const trailingTimerRef = useRef(null); + const rafRef = useRef(null); + useEffect(() => { - const timer = setTimeout(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, 80); - return () => clearTimeout(timer); - }, [messages]); + const now = performance.now(); + const elapsed = now - lastScrollRef.current; + + // 调度一次 rAF 滚动(自动取消上一次挂起的 rAF) + const doScroll = (behavior: ScrollBehavior) => { + lastScrollRef.current = performance.now(); + if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); + rafRef.current = requestAnimationFrame(() => { + rafRef.current = null; + messagesEndRef.current?.scrollIntoView({ behavior }); + }); + }; + + // 非流式或首次进入:立即平滑滚动(新消息出现) + if (!isStreaming || lastScrollRef.current === 0) { + if (trailingTimerRef.current !== null) { + clearTimeout(trailingTimerRef.current); + trailingTimerRef.current = null; + } + doScroll('smooth'); + return; + } + + // 流式中:100ms 节流 + trailing 兜底 + if (elapsed >= 100) { + // 已过节流窗口,立即执行 + if (trailingTimerRef.current !== null) { + clearTimeout(trailingTimerRef.current); + trailingTimerRef.current = null; + } + doScroll('auto'); + } else if (trailingTimerRef.current === null) { + // 在节流窗口内,安排 trailing 滚动(保证最后一次 delta 后到底) + const remaining = 100 - elapsed; + trailingTimerRef.current = window.setTimeout(() => { + trailingTimerRef.current = null; + doScroll('auto'); + }, remaining); + } + }, [messages, isStreaming]); + + // 组件卸载时清理所有挂起的 timer 和 rAF + useEffect(() => { + return () => { + if (trailingTimerRef.current !== null) { + clearTimeout(trailingTimerRef.current); + trailingTimerRef.current = null; + } + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + }; + }, []); if (messages.length === 0 && !isStreaming) { return ( @@ -35,10 +94,27 @@ export function MessageList(): React.JSX.Element { } return ( - + // F12: GPU 加速 — transform: translateZ(0) 将滚动容器提升为合成层 + // 滚动时由合成器线程处理,避免主线程重绘叠加卡顿 + // 风险评估:已确认 chat 目录内无 position: fixed 元素; + // ContextMenu 用 MUI Portal 渲染到 document.body,不受 transform 影响 + {messages.map((msg, i) => ( - + // F4: content-visibility 准虚拟滚动 + // 浏览器原生支持,不可见区域跳过布局和绘制(DOM 节点保留但渲染开销 O(1))。 + // 配合 F1 memo(跳过 re-render)+ F3 流式纯文本,历史消息开销降至最低。 + // contain-intrinsic-size 提供估算高度,避免滚动时高度抖动。 + // 注:如后续需真正虚拟滚动(移除 DOM 节点),可升级到 react-virtuoso。 + + + ))}
diff --git a/src/hooks/useAgentStream.ts b/src/hooks/useAgentStream.ts index cbba275..35b6b87 100644 --- a/src/hooks/useAgentStream.ts +++ b/src/hooks/useAgentStream.ts @@ -25,6 +25,66 @@ export function useAgentStream(): void { useEffect(() => { if (!window.metona?.agent?.onStreamEvent) return; + // F5: text_delta rAF 批处理 + // 问题:每个 text_delta 直接调用 updateLastAssistantMessage + updateLastTraceStep, + // 频率 30-50 次/秒,每次触发 store 更新 + React re-render。 + // 方案:累积 delta 到缓冲区,用 rAF 每帧 commit 一次,合并多次 store 写入。 + // done/error 时立即 flush,避免最后一段 delta 丢失。 + // 新迭代卡片创建逻辑(needsNewCard)立即处理,不缓冲。 + let textDeltaBuffer = ''; + let textBufferSessionId: string | undefined; + let textRafId: number | null = null; + + const flushTextDelta = () => { + textRafId = null; + if (!textDeltaBuffer) return; + const delta = textDeltaBuffer; + const bufferedSessionId = textBufferSessionId; + textDeltaBuffer = ''; + textBufferSessionId = undefined; + + const store = useAgentStore.getState(); + // 会话切换保护:如果缓冲时的会话与当前会话不一致,丢弃(避免跨会话污染) + if (bufferedSessionId && store.currentSessionId !== bufferedSessionId) return; + + store.updateLastAssistantMessage(delta); + + // 无 reasoning 模式下,将文本内容也记入 Trace thought + const messages = store.messages; + const lastMsg = messages[messages.length - 1]; + const steps = store.traceSteps; + const step = steps[steps.length - 1]; + if (step && lastMsg?.role === 'assistant' && !lastMsg.reasoningContent) { + store.updateLastTraceStep({ + thought: (step.thought ?? '') + delta, + }); + } + }; + + // F9: reasoning_delta 的 traceSteps thought 更新走 rAF 批处理 + // 问题:reasoning_delta 每个 delta 调用 updateLastTraceStep,频率 10-30 次/秒, + // 触发 TraceViewer 等订阅者 re-render(即使不可见也有 selector 调用开销)。 + // 方案:累积 reasoning delta 到缓冲区,rAF 每帧 commit 一次。 + // message.reasoningContent 保持即时更新(ThoughtBlock 需实时显示思考内容)。 + // 新迭代卡片创建时的 thought 更新保持即时(新卡片需立即显示)。 + let traceThoughtBuffer = ''; + let traceRafId: number | null = null; + + const flushTraceThought = () => { + traceRafId = null; + if (!traceThoughtBuffer) return; + const delta = traceThoughtBuffer; + traceThoughtBuffer = ''; + const store = useAgentStore.getState(); + const steps = store.traceSteps; + const step = steps[steps.length - 1]; + if (step) { + store.updateLastTraceStep({ + thought: (step.thought ?? '') + delta, + }); + } + }; + const unsubscribe = window.metona.agent.onStreamEvent((event: unknown) => { const data = event as { type?: string; @@ -121,13 +181,11 @@ export function useAgentStream(): void { }); } - // 同步更新当前 Trace 步骤的 thought 字段 - const traceSteps = getStore().traceSteps; - const curStep = traceSteps[traceSteps.length - 1]; - if (curStep) { - getStore().updateLastTraceStep({ - thought: (curStep.thought ?? '') + data.delta, - }); + // F9: traceSteps thought 更新走 rAF 批处理(减少 TraceViewer re-render 频率) + // message.reasoningContent 保持即时更新(ThoughtBlock 需实时显示) + traceThoughtBuffer += data.delta; + if (traceRafId === null) { + traceRafId = requestAnimationFrame(flushTraceThought); } } break; @@ -142,6 +200,11 @@ export function useAgentStream(): void { const needsNewCard = !last || last.role !== 'assistant' || (last.iteration != null && last.iteration !== data.iteration); if (needsNewCard) { + // F5: 新迭代前先 flush 旧缓冲区(属于上一条消息的 delta) + if (textRafId !== null) { + cancelAnimationFrame(textRafId); + flushTextDelta(); + } getStore().addMessage({ id: genMsgId('assistant'), role: 'assistant', @@ -155,19 +218,14 @@ export function useAgentStream(): void { break; } } - // isStreaming 已在 sendMessage 时设置为 true,无需每个 delta 重复设置 - getStore().updateLastAssistantMessage(data.delta); - - // 无 reasoning 模式下,将文本内容也记入 Trace thought(便于追踪) - // 当 assistant 消息无 reasoningContent 时,持续累积 text delta 到 thought - const messages = getStore().messages; - const lastMsg = messages[messages.length - 1]; - const steps = getStore().traceSteps; - const step = steps[steps.length - 1]; - if (step && lastMsg?.role === 'assistant' && !lastMsg.reasoningContent) { - getStore().updateLastTraceStep({ - thought: (step.thought ?? '') + data.delta, - }); + // F5: 累积 delta 到缓冲区,用 rAF 每帧 commit 一次 + // 首次缓冲时记录 sessionId(用于会话切换保护) + if (textDeltaBuffer === '') { + textBufferSessionId = data.sessionId; + } + textDeltaBuffer += data.delta; + if (textRafId === null) { + textRafId = requestAnimationFrame(flushTextDelta); } } break; @@ -305,6 +363,16 @@ export function useAgentStream(): void { // 流结束 case 'done': + // F5: 流结束前立即 flush 缓冲区,避免最后一段 delta 丢失 + if (textRafId !== null) { + cancelAnimationFrame(textRafId); + flushTextDelta(); + } + // F9: flush traceThought 缓冲区,避免最后一段 reasoning delta 丢失 + if (traceRafId !== null) { + cancelAnimationFrame(traceRafId); + flushTraceThought(); + } getStore().setStreaming(false); getStore().setCurrentRunId(null); // 不覆盖 error 状态 — error handler 已设置 agentStatus='error' @@ -318,6 +386,16 @@ export function useAgentStream(): void { // 错误 case 'error': + // F5: 错误前立即 flush 缓冲区,保留已接收的内容 + if (textRafId !== null) { + cancelAnimationFrame(textRafId); + flushTextDelta(); + } + // F9: flush traceThought 缓冲区,保留已接收的 reasoning 内容 + if (traceRafId !== null) { + cancelAnimationFrame(traceRafId); + flushTraceThought(); + } getStore().setStreaming(false); getStore().setCurrentRunId(null); getStore().setAgentStatus('error'); @@ -335,6 +413,16 @@ export function useAgentStream(): void { cleanupRef.current = unsubscribe; return () => { + // F5: 组件卸载时清理挂起的 rAF,并 flush 残留 delta(保留已接收内容) + if (textRafId !== null) { + cancelAnimationFrame(textRafId); + flushTextDelta(); + } + // F9: 清理 traceThought 的 rAF + if (traceRafId !== null) { + cancelAnimationFrame(traceRafId); + flushTraceThought(); + } cleanupRef.current?.(); }; }, []); diff --git a/src/styles/globals.css b/src/styles/globals.css index 8442091..1e74041 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -187,6 +187,10 @@ code, pre, .font-mono { font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', mo color: inherit; line-height: 1.7; font-size: 14px; + /* F10: CSS containment 隔离布局计算 + contain: layout style 隔离 markdown 渲染区域的布局和样式计算, + 避免 reflow 扩散到外部容器。不含 paint 以避免裁剪代码块横向滚动溢出。 */ + contain: layout style; } .prose-metona h1,