/** * 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(() => ( 高亮元素,需提取纯文本 const codeStr = extractTextContent(children).replace(/\n$/, ''); if (!match) return {children}; return ; }, }} > {content} ), [content]); return ( { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY }); }} > {/* ===== 阶段 1: 思考过程 ===== */} {hasThinking && ( <> 💭 {isThinking ? '正在思考...' : '思考过程'} {isThinking && ( )} )} {/* ===== 阶段 2: 工具调用 ===== */} {hasTools && ( <> {hasThinking && } 🔧 工具调用 {message.toolCalls!.map((tc) => ( {tc.status === 'success' && tc.result != null && ( )} ))} )} {/* ===== 阶段 3: 回复内容 ===== */} {(hasContent || isStreaming) && ( <> {(hasThinking || hasTools) && ( ✏️ 回复 {isStreaming && !isThinking && ( )} )} {hasContent ? ( isStreaming ? ( // F3: 流式时用纯文本渲染,避免 ReactMarkdown 对长内容重新解析导致卡顿。 // 根因:每个 delta 触发 ReactMarkdown 全量重新解析 + rehypeHighlight 高亮, // 内容长度增加时单次解析耗时 O(n) 退化,30+ delta/秒下呈 O(n²) 卡死。 // 流结束后自动切换到 ReactMarkdown 一次性渲染(一次性 O(n),可接受)。 {content} ) : ( // F7: 使用 useMemo 缓存的 ReactMarkdown 元素 markdownElement ) ) : isStreaming ? ( {isThinking ? '正在思考...' : '正在生成回复...'} ) : null} {/* 流式打字光标 */} {isStreaming && hasContent && ( )} )} {/* 时间戳 */} {!isStreaming && ( {formatTime(message.timestamp)} )} {contextMenu && ( setContextMenu(null)} /> )} ); } /** * 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 ( {language} {copied ? : } {code} ); } /** * 从 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 ''; }