/**
* 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 '';
}