/** * AssistantMessage — Agent 回复消息卡片 * * 分为三个阶段,流式时实时展示: * 1. 💭 思考阶段 — ThoughtBlock 自动展开,流式追加 reasoning content * 2. 🔧 工具调用 — ToolCallCard 展示调用和结果 * 3. ✏️ 回复阶段 — Markdown 渲染,打字光标指示流式输出 */ import { useState, useCallback } 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'; import rehypeRaw from '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 { formatTime } from '@renderer/lib/formatters'; import { ContextMenu, createContextMenuItems } from '@renderer/components/ContextMenu'; interface AssistantMessageProps { message: ChatMessage; isStreaming?: boolean; streamContent?: string; } export function AssistantMessage({ message, isStreaming, streamContent }: AssistantMessageProps): React.JSX.Element { const content = isStreaming ? streamContent ?? '' : message.content; const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); const sendMessage = useAgentStore((s) => s.sendMessage); const agentStatus = useAgentStore((s) => s.agentStatus); const handleRegenerate = useCallback(() => { const lastUserMsg = [...useAgentStore.getState().messages].reverse().find((m) => m.role === 'user'); if (lastUserMsg) sendMessage(lastUserMsg.content); }, [sendMessage]); const contextMenuItems = createContextMenuItems('message', { content }).map((item) => item.id === 'regenerate' ? { ...item, action: handleRegenerate } : item, ); const hasThinking = !!message.reasoningContent; const hasTools = !!message.toolCalls?.length; const hasContent = !!content; const isThinking = agentStatus === 'thinking' && isStreaming; return ( { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY }); }} > {/* ===== 阶段 1: 思考过程 ===== */} {hasThinking && ( <> 💭 {isThinking ? '正在思考...' : '思考过程'} {isThinking && ( )} )} {/* ===== 阶段 2: 工具调用 ===== */} {hasTools && ( <> {hasThinking && } 🔧 工具调用 {message.toolCalls!.map((tc) => ( ))} )} {/* ===== 阶段 3: 回复内容 ===== */} {(hasContent || isStreaming) && ( <> {(hasThinking || hasTools) && ( ✏️ 回复 {isStreaming && !isThinking && ( )} )} {hasContent ? ( 高亮元素,需提取纯文本 const codeStr = extractTextContent(children).replace(/\n$/, ''); if (!match) return {children}; return ; }, }} > {content} ) : isStreaming ? ( {isThinking ? '正在思考...' : '正在生成回复...'} ) : null} {/* 流式打字光标 */} {isStreaming && hasContent && ( )} )} {/* 时间戳 */} {!isStreaming && ( {formatTime(message.timestamp)} )} {contextMenu && ( setContextMenu(null)} /> )} ); } 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)递归提取纯文本 */ function extractTextContent(children: React.ReactNode): string { if (typeof children === 'string') return children; if (typeof children === 'number') return String(children); if (!children) return ''; if (Array.isArray(children)) return children.map(extractTextContent).join(''); if (typeof children === 'object' && 'props' in children) { return extractTextContent((children as React.ReactElement).props.children); } return ''; }