针对 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 三处正确执行。
125 lines
5.2 KiB
TypeScript
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>
|
|
);
|
|
}
|