Files
metona-ai-desktop/src/components/chat/MessageList.tsx
T
thzxx 8973ea6d47 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 三处正确执行。
2026-07-21 14:38:27 +08:00

125 lines
5.2 KiB
TypeScript

/**
* MessageList — 消息列表容器
*/
import { useEffect, useRef } from 'react';
import { Box, Typography } from '@mui/material';
import { useAgentStore } from '@renderer/stores/agent-store';
import { MessageItem } from './MessageItem';
import { StreamingIndicator } from './StreamingIndicator';
export function MessageList(): React.JSX.Element {
const messages = useAgentStore((s) => s.messages);
const isStreaming = useAgentStore((s) => s.isStreaming);
const messagesEndRef = useRef<HTMLDivElement>(null);
// F2: 滚动节流优化
// 问题:原实现用 setTimeout(80) debounce + 'smooth',高频 delta 时 trailing 永不触发,
// 且 'smooth' 在长列表上触发主线程布局动画,加剧卡顿。
// 方案:
// - 流式时:100ms 节流 + trailing 兜底 + 'auto' 行为(同步布局,无动画占主线程)
// - 非流式时:立即 'smooth' 滚动(新消息发送/接收完成)
// - 用 rAF 同步到下一帧,与 React 渲染合并,避免一帧内多次布局
const lastScrollRef = useRef(0);
const trailingTimerRef = useRef<number | null>(null);
const rafRef = useRef<number | null>(null);
useEffect(() => {
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 (
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Box sx={{ textAlign: 'center', animation: 'fadeIn 200ms ease-out' }}>
<Box component="img" src="./logo.png" alt="Metona" sx={{ width: 80, height: 80, mx: 'auto', mb: 2.5, borderRadius: 2 }} />
<Typography variant="h5" sx={{ color: 'text.primary', mb: 0.5, fontWeight: 700 }}>MetonaAI Desktop</Typography>
<Typography variant="body1" sx={{ color: 'text.secondary' }}>生产级通用 AI Agent 智能体桌面应用</Typography>
<Typography variant="body2" sx={{ mt: 2, display: 'block', opacity: 0.6 }}>Agent 就绪 · 选择一个 Provider 开始对话</Typography>
</Box>
</Box>
);
}
return (
// F12: GPU 加速 — transform: translateZ(0) 将滚动容器提升为合成层
// 滚动时由合成器线程处理,避免主线程重绘叠加卡顿
// 风险评估:已确认 chat 目录内无 position: fixed 元素;
// ContextMenu 用 MUI Portal 渲染到 document.body,不受 transform 影响
<Box sx={{ flex: 1, overflowY: 'auto', px: 2, py: 3, transform: 'translateZ(0)' }}>
<Box sx={{ maxWidth: 768, mx: 'auto', display: 'flex', flexDirection: 'column', gap: 3 }}>
{messages.map((msg, i) => (
// F4: content-visibility 准虚拟滚动
// 浏览器原生支持,不可见区域跳过布局和绘制(DOM 节点保留但渲染开销 O(1))。
// 配合 F1 memo(跳过 re-render)+ F3 流式纯文本,历史消息开销降至最低。
// contain-intrinsic-size 提供估算高度,避免滚动时高度抖动。
// 注:如后续需真正虚拟滚动(移除 DOM 节点),可升级到 react-virtuoso。
<Box
key={msg.id}
sx={{
'content-visibility': 'auto',
'contain-intrinsic-size': 'auto 300px',
}}
>
<MessageItem message={msg} isLast={i === messages.length - 1} isStreaming={isStreaming} />
</Box>
))}
<StreamingIndicator />
<div ref={messagesEndRef} />
</Box>
</Box>
);
}