feat: MetonaAI Desktop 初始项目
- Electron + React + TypeScript 架构 - 三栏布局: Sidebar | ChatPanel | DetailPanel - 9 个内置工具 (文件系统/网络/记忆/命令) - SQLite 持久化 (better-sqlite3) - MUI 暗色/亮色主题系统 - Agent Loop ReAct 状态机引擎 - DeepSeek / Agnes AI / Ollama Provider 适配器 - MCP 协议集成 - 系统托盘 + 全局快捷键 - Tailwind CSS v4 + Tailwind Merge - 修复: Sidebar 缺失 TextField 导入导致黑屏
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* AssistantMessage — Agent 回复消息
|
||||
*/
|
||||
|
||||
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 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);
|
||||
|
||||
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' }}>
|
||||
{message.reasoningContent && <ThoughtBlock content={message.reasoningContent} />}
|
||||
{message.toolCalls?.map((tc) => <ToolCallCard key={tc.id} toolCall={tc} />)}
|
||||
{content && (
|
||||
<Box className="prose-metona">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight, rehypeRaw]} components={{
|
||||
code({ className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className ?? '');
|
||||
const codeStr = String(children).replace(/\n$/, '');
|
||||
if (!match) return <code className={className} {...props}>{children}</code>;
|
||||
return <CodeBlock language={match[1]} code={codeStr} />;
|
||||
},
|
||||
}}>{content}</ReactMarkdown>
|
||||
</Box>
|
||||
)}
|
||||
{isStreaming && !streamContent && <Box component="span" sx={{ display: 'inline-block', width: 8, height: 16, ml: 0.5, bgcolor: 'primary.main', animation: 'blink 1s step-end infinite' }} />}
|
||||
{!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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user