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:
thzxx
2026-06-27 21:33:27 +08:00
commit 1d185db6b3
109 changed files with 39155 additions and 0 deletions
+81
View File
@@ -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>
);
}