feat: 升级至 v0.3.11 — 流式输出性能优化 12 项,解决长内容卡死

针对 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 三处正确执行。
This commit is contained in:
2026-07-21 14:38:27 +08:00
parent cd681b949a
commit 8973ea6d47
9 changed files with 350 additions and 54 deletions
+108 -20
View File
@@ -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?.();
};
}, []);