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