根因: rehype-highlight 将代码文本替换为 <span class='hljs-*'> 元素后, children 变成 React 元素数组, String(children) → '[object Object]' 修复: extractTextContent 递归提取纯文本,绕过语法高亮 span
201 lines
9.1 KiB
TypeScript
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 '';
|
|
}
|