Files
metona-ai-desktop/src/components/chat/AssistantMessage.tsx
T
thzxx 2de89ad631 fix: 代码块 [object Object] — rehype-highlight 替换 span 后 String() 失败
根因: rehype-highlight 将代码文本替换为 <span class='hljs-*'> 元素后,
children 变成 React 元素数组, String(children) → '[object Object]'

修复: extractTextContent 递归提取纯文本,绕过语法高亮 span
2026-06-27 23:00:16 +08:00

201 lines
9.1 KiB
TypeScript

/**
* 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 (
<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} alignItems="center" sx={{ mb: 0.5 }}>
<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} alignItems="center" sx={{ mb: 0.5 }}>
<Typography variant="caption" sx={{ color: '#a855f7', fontWeight: 600, fontSize: 10, textTransform: 'uppercase', letterSpacing: 0.5 }}>
🔧 工具调用
</Typography>
</Stack>
{message.toolCalls!.map((tc) => (
<ToolCallCard key={tc.id} toolCall={tc} />
))}
</>
)}
{/* ===== 阶段 3: 回复内容 ===== */}
{(hasContent || isStreaming) && (
<>
{(hasThinking || hasTools) && (
<Stack direction="row" spacing={1} alignItems="center" sx={{ mt: 0.5, mb: 0.5 }}>
<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 ? (
<Box className="prose-metona">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight, rehypeRaw]}
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>
) : 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
type="message"
x={contextMenu.x}
y={contextMenu.y}
items={contextMenuItems}
onClose={() => setContextMenu(null)}
/>
)}
</Stack>
);
}
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" justifyContent="space-between" alignItems="center" sx={{ px: 1.5, py: 0.75, borderTopLeftRadius: 8, borderTopRightRadius: 8, borderBottom: '1px solid', borderColor: 'divider', bgcolor: 'background.default', fontSize: 11, color: 'text.secondary' }}>
<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: 'secondary.main', border: '1px solid', borderColor: 'divider', borderTop: 'none', borderBottomLeftRadius: 8, borderBottomRightRadius: 8, p: 1.5, overflowX: 'auto', fontSize: 13, lineHeight: 1.6 }}>
<code className={language ? `language-${language}` : ''}>{code}</code>
</Box>
</Box>
);
}
/** 从 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 '';
}