Files
metona-ai-desktop/src/components/chat/AssistantMessage.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

255 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* AssistantMessage — Agent 回复消息卡片
*
* 分为三个阶段,流式时实时展示:
* 1. 💭 思考阶段 — ThoughtBlock 自动展开,流式追加 reasoning content
* 2. 🔧 工具调用 — ToolCallCard 展示调用和结果
* 3. ✏️ 回复阶段 — Markdown 渲染,打字光标指示流式输出
*
* F1 性能优化: 使用 React.memo 包裹,避免历史消息在流式 delta 时重渲染。
* 配合 agentStatus 选择器优化:历史消息(isStreaming=false)不订阅真实 agentStatus
* 避免 agentStatus 频繁变化(thinking/executing/idle)触发所有 AssistantMessage 重渲染。
*/
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';
// 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';
import { ToolCallCard } from './ToolCallCard';
import { ToolResultBlock } from './ToolResultBlock';
import { formatTime } from '@renderer/lib/formatters';
import { ContextMenu, createContextMenuItems } from '@renderer/components/ContextMenu';
interface AssistantMessageProps {
message: ChatMessage;
isStreaming?: boolean;
}
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);
// 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 });
const hasThinking = !!message.reasoningContent;
const hasTools = !!message.toolCalls?.length;
const hasContent = !!content;
const isThinking = agentStatus === 'thinking' && isStreaming;
// F7: useMemo 缓存 ReactMarkdown 元素,避免非流式时因 isStreaming/isLast 变化重新创建元素
// 流式时此元素不被渲染(用纯文本),useMemo 仍会更新但仅创建轻量 React 元素对象
// 非流式时 content 不变,useMemo 复用缓存,避免重新创建 ReactMarkdown 组件实例
const markdownElement = useMemo(() => (
<Box className="prose-metona">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight]}
components={{
code({ className, children, ...props }) {
const match = /language-(\w+)/.exec(className ?? '');
// rehype-highlight 会把代码替换为 <span> 高亮元素,需提取纯文本
const codeStr = extractTextContent(children).replace(/\n$/, '');
if (!match) return <code className={className} {...props}>{children}</code>;
return <CodeBlock language={match[1]} code={codeStr} />;
},
}}
>
{content}
</ReactMarkdown>
</Box>
), [content]);
return (
<Stack
direction="row"
spacing={1.5}
sx={{ animation: 'fadeInUp 300ms ease-out' }}
onContextMenu={(e: React.MouseEvent) => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY }); }}
>
<Avatar sx={{ width: 32, height: 32, bgcolor: 'secondary.main', color: 'primary.main', flexShrink: 0 }}>
<Bot size={16} />
</Avatar>
<Box
sx={{
flex: 1, minWidth: 0, maxWidth: 768, borderRadius: 3, px: 2, py: 1.5,
bgcolor: 'background.paper', border: '1px solid', borderColor: 'divider',
transition: 'border-color 200ms, box-shadow 200ms',
...(isStreaming ? { borderColor: 'rgba(129,140,248,0.3)', boxShadow: '0 0 0 1px rgba(129,140,248,0.1)' } : {}),
}}
>
{/* ===== 阶段 1: 思考过程 ===== */}
{hasThinking && (
<>
<Stack direction="row" spacing={1} sx={{ mb: 0.5, alignItems: 'center' }}>
<Typography variant="caption" sx={{ color: '#fbbf24', fontWeight: 600, fontSize: 10, textTransform: 'uppercase', letterSpacing: 0.5 }}>
💭 {isThinking ? '正在思考...' : '思考过程'}
</Typography>
{isThinking && (
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: '#fbbf24', animation: 'pulse 1.5s infinite' }} />
)}
</Stack>
<ThoughtBlock content={message.reasoningContent!} defaultExpanded={!!isStreaming} />
</>
)}
{/* ===== 阶段 2: 工具调用 ===== */}
{hasTools && (
<>
{hasThinking && <Box sx={{ height: 8 }} />}
<Stack direction="row" spacing={1} sx={{ mb: 0.5, alignItems: 'center' }}>
<Typography variant="caption" sx={{ color: '#a855f7', fontWeight: 600, fontSize: 10, textTransform: 'uppercase', letterSpacing: 0.5 }}>
🔧 工具调用
</Typography>
</Stack>
{message.toolCalls!.map((tc) => (
<Box key={tc.id}>
<ToolCallCard toolCall={tc} />
{tc.status === 'success' && tc.result != null && (
<ToolResultBlock toolCall={tc} />
)}
</Box>
))}
</>
)}
{/* ===== 阶段 3: 回复内容 ===== */}
{(hasContent || isStreaming) && (
<>
{(hasThinking || hasTools) && (
<Stack direction="row" spacing={1} sx={{ mt: 0.5, mb: 0.5, alignItems: 'center' }}>
<Typography variant="caption" sx={{ color: '#34d399', fontWeight: 600, fontSize: 10, textTransform: 'uppercase', letterSpacing: 0.5 }}>
✏️ 回复
</Typography>
{isStreaming && !isThinking && (
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: '#34d399', animation: 'pulse 1.5s infinite' }} />
)}
</Stack>
)}
{hasContent ? (
isStreaming ? (
// F3: 流式时用纯文本渲染,避免 ReactMarkdown 对长内容重新解析导致卡顿。
// 根因:每个 delta 触发 ReactMarkdown 全量重新解析 + rehypeHighlight 高亮,
// 内容长度增加时单次解析耗时 O(n) 退化,30+ delta/秒下呈 O(n²) 卡死。
// 流结束后自动切换到 ReactMarkdown 一次性渲染(一次性 O(n),可接受)。
<Box
component="pre"
className="prose-metona prose-streaming"
sx={{
margin: 0,
padding: 0,
fontFamily: 'inherit',
fontSize: 14,
lineHeight: 1.6,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
overflowWrap: 'break-word',
color: 'text.primary',
}}
>
{content}
</Box>
) : (
// F7: 使用 useMemo 缓存的 ReactMarkdown 元素
markdownElement
)
) : isStreaming ? (
<Typography variant="body2" sx={{ color: 'text.disabled', fontStyle: 'italic', py: 1 }}>
{isThinking ? '正在思考...' : '正在生成回复...'}
<Box component="span" sx={{ display: 'inline-block', width: 8, height: 16, ml: 0.5, bgcolor: 'primary.main', animation: 'blink 1s step-end infinite', verticalAlign: 'middle' }} />
</Typography>
) : null}
{/* 流式打字光标 */}
{isStreaming && hasContent && (
<Box component="span" sx={{ display: 'inline-block', width: 8, height: 16, ml: 0.25, bgcolor: 'primary.main', animation: 'blink 1s step-end infinite', verticalAlign: 'middle' }} />
)}
</>
)}
{/* 时间戳 */}
{!isStreaming && (
<Typography variant="caption" sx={{ mt: 0.5, display: 'block', color: 'text.disabled' }}>
{formatTime(message.timestamp)}
</Typography>
)}
</Box>
{contextMenu && (
<ContextMenu
x={contextMenu.x}
y={contextMenu.y}
items={contextMenuItems}
onClose={() => setContextMenu(null)}
/>
)}
</Stack>
);
}
/**
* 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 () => {
try { await navigator.clipboard.writeText(code); } catch { const ta = document.createElement('textarea'); ta.value = code; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); }
setCopied(true); setTimeout(() => setCopied(false), 2000);
}, [code]);
return (
<Box sx={{ position: 'relative', my: 1, '&:hover .copy-btn': { opacity: 1 } }}>
<Stack direction="row" sx={{ px: 1.5, py: 0.75, borderTopLeftRadius: 8, borderTopRightRadius: 8, borderBottom: '1px solid', borderColor: 'divider', bgcolor: 'background.paper', fontSize: 11, color: 'text.secondary', justifyContent: 'space-between', alignItems: 'center' }}>
<span>{language}</span>
<Tooltip title={copied ? '已复制' : '复制'}>
<IconButton className="copy-btn" size="small" onClick={handleCopy} sx={{ opacity: 0, transition: 'opacity 150ms', color: 'text.secondary' }}>
{copied ? <Check size={10} /> : <Copy size={10} />}
</IconButton>
</Tooltip>
</Stack>
<Box component="pre" sx={{ m: 0, bgcolor: 'background.default', border: '1px solid', borderColor: 'divider', borderTop: 'none', borderBottomLeftRadius: 8, borderBottomRightRadius: 8, p: 1.5, overflowX: 'auto', fontSize: 13, lineHeight: 1.6, color: 'text.primary' }}>
<code className={language ? `language-${language}` : ''}>{code}</code>
</Box>
</Box>
);
}
/**
* 从 React children(可能含高亮 span)递归提取纯文本
*
* L-1 修复: 添加 maxDepth 参数防止循环引用导致的栈溢出
* React children 正常深度不会超过 10,50 已足够安全裕量
*/
function extractTextContent(children: React.ReactNode, maxDepth = 50): string {
if (maxDepth <= 0) return ''; // 超出深度上限,停止递归
if (typeof children === 'string') return children;
if (typeof children === 'number') return String(children);
if (!children) return '';
if (Array.isArray(children)) return children.map((c) => extractTextContent(c, maxDepth - 1)).join('');
if (typeof children === 'object' && 'props' in children) {
// 使用 React.ReactElement<{ children?: React.ReactNode }> 显式声明 props.children 类型
return extractTextContent(
(children as React.ReactElement<{ children?: React.ReactNode }>).props.children as React.ReactNode,
maxDepth - 1,
);
}
return '';
}