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
+72
View File
@@ -0,0 +1,72 @@
/**
* MetonaAI Desktop — 根组件
*
* 三栏布局:Header + [Sidebar | Chat | DetailPanel] + StatusBar
* + ToastContainer + SettingsModal + OnboardingWizard
*
* 连接所有 Store,注册快捷键,初始化主题。
*/
import { ThemeProvider, CssBaseline } from '@mui/material';
import { useEffect } from 'react';
import { Header } from '@renderer/components/layout/Header';
import { Sidebar } from '@renderer/components/layout/Sidebar';
import { DetailPanel } from '@renderer/components/layout/DetailPanel';
import { StatusBar } from '@renderer/components/layout/StatusBar';
import { ChatPanel } from '@renderer/components/chat/ChatPanel';
import { SettingsModal } from '@renderer/components/settings/SettingsModal';
import { OnboardingWizard } from '@renderer/components/onboarding/OnboardingWizard';
import { CommandPalette } from '@renderer/components/CommandPalette';
import { ToastContainer } from '@renderer/components/ToastContainer';
import { useTheme } from '@renderer/hooks/useTheme';
import { useKeyboardShortcuts } from '@renderer/hooks/useKeyboardShortcuts';
import { useAgentStream } from '@renderer/hooks/useAgentStream';
import { useUIStore } from '@renderer/stores/ui-store';
import { useAgentStore } from '@renderer/stores/agent-store';
import { metonaDarkTheme, metonaLightTheme } from '@renderer/lib/theme';
export default function App(): React.JSX.Element {
useTheme();
useKeyboardShortcuts();
useAgentStream();
const resolvedTheme = useUIStore((s) => s.resolvedTheme);
const muiTheme = resolvedTheme === 'dark' ? metonaDarkTheme : metonaLightTheme;
// 启动时从数据库恢复状态
useEffect(() => {
if (window.metona?.config?.get) {
window.metona.config.get('onboarding.completed').then((v) => {
if (v === true) useUIStore.getState().setOnboardingCompleted(true);
}).catch(() => {});
Promise.all([
window.metona.config.get('llm.provider'),
window.metona.config.get('llm.model'),
]).then(([provider, model]) => {
if (provider || model) useAgentStore.getState().setProvider(provider ?? '', model ?? '');
}).catch(() => {});
}
}, []);
return (
<ThemeProvider theme={muiTheme}>
<CssBaseline />
<div
className="h-screen w-screen flex flex-col overflow-hidden select-none"
style={{ background: muiTheme.palette.background.default, color: muiTheme.palette.text.primary }}
>
<Header />
<div className="flex-1 flex overflow-hidden min-h-0">
<Sidebar />
<ChatPanel />
<DetailPanel />
</div>
<StatusBar />
<SettingsModal />
<OnboardingWizard />
<CommandPalette />
<ToastContainer />
</div>
</ThemeProvider>
);
}
+74
View File
@@ -0,0 +1,74 @@
/**
* CommandPalette — 快速搜索面板 (Cmd+K)
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import { Dialog, DialogContent, InputBase, List, ListItemButton, ListItemIcon, ListItemText, Typography, Box, Divider } from '@mui/material';
import { Search, MessageSquare, Settings, Plus, Trash2 } from 'lucide-react';
import { useUIStore } from '@renderer/stores/ui-store';
import { useSessionStore } from '@renderer/stores/session-store';
import { useAgentStore } from '@renderer/stores/agent-store';
interface CommandItem { id: string; icon: typeof Search; label: string; description?: string; action: () => void; }
export function CommandPalette(): React.JSX.Element {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const h = (e: KeyboardEvent) => { if (e.key === 'k' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); setOpen((p) => !p); setQuery(''); setSelectedIndex(0); } };
window.addEventListener('keydown', h); return () => window.removeEventListener('keydown', h);
}, []);
useEffect(() => { if (open) setTimeout(() => inputRef.current?.focus(), 50); }, [open]);
const getCommands = useCallback((): CommandItem[] => {
const cmds: CommandItem[] = [
{ id: 'new-session', icon: Plus, label: '新建会话', description: '创建一个新的对话会话', action: async () => { if (window.metona?.sessions?.create) { const r = await window.metona.sessions.create() as any; useSessionStore.getState().addSession(r); useSessionStore.getState().setCurrentSession(r.id); useAgentStore.getState().setCurrentSession(r.id); } setOpen(false); } },
{ id: 'clear', icon: Trash2, label: '清空当前会话', action: () => { useAgentStore.getState().clearMessages(); setOpen(false); } },
{ id: 'settings', icon: Settings, label: '打开设置', action: () => { useUIStore.getState().openSettings(); setOpen(false); } },
];
if (query) {
const filtered = useSessionStore.getState().sessions.filter((s) => s.title.toLowerCase().includes(query.toLowerCase()));
for (const s of filtered.slice(0, 5)) cmds.push({ id: `s-${s.id}`, icon: MessageSquare, label: s.title, description: `${s.messageCount} 条消息`, action: () => { useSessionStore.getState().setCurrentSession(s.id); useAgentStore.getState().setCurrentSession(s.id); setOpen(false); } });
}
return cmds;
}, [query]);
const commands = getCommands();
return (
<Dialog open={open} onClose={() => setOpen(false)} maxWidth="sm" fullWidth PaperProps={{ sx: { mt: '20vh', maxWidth: 520, borderRadius: 3, overflow: 'hidden' } }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, px: 2, py: 1.5, borderBottom: 1, borderColor: 'divider' }}>
<Search size={16} style={{ color: '#8b8fa7', flexShrink: 0 }} />
<InputBase inputRef={inputRef} value={query} onChange={(e) => { setQuery(e.target.value); setSelectedIndex(0); }}
onKeyDown={(e) => {
if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIndex((p) => Math.min(p + 1, commands.length - 1)); }
else if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex((p) => Math.max(p - 1, 0)); }
else if (e.key === 'Enter') { e.preventDefault(); commands[selectedIndex]?.action(); }
}}
placeholder="搜索会话、命令..." sx={{ flex: 1, fontSize: 13 }}
/>
</Box>
<List sx={{ maxHeight: 300, overflowY: 'auto', py: 0.5 }}>
{commands.length === 0 ? (
<Typography variant="caption" sx={{ textAlign: 'center', py: 3, display: 'block', color: 'text.secondary' }}></Typography>
) : commands.map((cmd, i) => {
const Icon = cmd.icon;
return (
<ListItemButton key={cmd.id} selected={i === selectedIndex} onClick={cmd.action} sx={{ px: 2, py: 1.25 }}>
<ListItemIcon sx={{ minWidth: 32 }}><Icon size={14} style={{ color: '#818cf8' }} /></ListItemIcon>
<ListItemText primary={<Typography variant="body2" sx={{ fontSize: 12 }}>{cmd.label}</Typography>} secondary={cmd.description ? <Typography variant="caption">{cmd.description}</Typography> : undefined} />
</ListItemButton>
);
})}
</List>
<Divider />
<Box sx={{ display: 'flex', gap: 2, px: 2, py: 1, fontSize: 10, color: 'text.disabled' }}>
<span> </span><span> </span><span>Esc </span>
</Box>
</Dialog>
);
}
+122
View File
@@ -0,0 +1,122 @@
/**
* ContextMenu — 右键菜单组件
*
* 支持 5 种对象的右键菜单:消息、工具调用卡片、会话项、代码块、Trace 步骤。
*
* @see docs/MetonaAI-Desktop UI UX 设计集成方案.html — 右键菜单设计
*/
import { Menu, MenuItem, ListItemIcon, ListItemText } from '@mui/material';
import { Copy, Quote, Edit, Trash2, RotateCcw, Eye, Code, ExternalLink, Pin, Archive, FileDown } from 'lucide-react';
import { useAgentStore } from '@renderer/stores/agent-store';
import { useSessionStore } from '@renderer/stores/session-store';
export type ContextMenuType = 'message' | 'tool-call' | 'session' | 'code-block' | 'trace-step';
interface ContextMenuItem {
id: string;
icon: typeof Copy;
label: string;
action: () => void;
disabled?: boolean;
}
interface ContextMenuProps {
type: ContextMenuType;
x: number;
y: number;
onClose: () => void;
items: ContextMenuItem[];
}
export function ContextMenu({ x, y, onClose, items }: ContextMenuProps): React.JSX.Element {
return (
<Menu open onClose={onClose} anchorReference="anchorPosition" anchorPosition={{ top: y, left: x }} PaperProps={{ sx: { minWidth: 160 } }}>
{items.map((item) => {
const Icon = item.icon;
return (
<MenuItem key={item.id} onClick={() => { if (!item.disabled) { item.action(); onClose(); } }} disabled={item.disabled} dense>
<ListItemIcon sx={{ minWidth: 28 }}><Icon size={12} /></ListItemIcon>
<ListItemText primaryTypographyProps={{ fontSize: 12 }}>{item.label}</ListItemText>
</MenuItem>
);
})}
</Menu>
);
}
/**
* 创建右键菜单项的工厂函数
*/
export function createContextMenuItems(type: ContextMenuType, data?: unknown): ContextMenuItem[] {
switch (type) {
case 'message': {
const content = (data as { content?: string })?.content ?? '';
return [
{ id: 'copy', icon: Copy, label: '复制', action: () => navigator.clipboard.writeText(content).catch(() => {}) },
{ id: 'quote', icon: Quote, label: '引用回复', action: () => {
const input = document.querySelector<HTMLTextAreaElement>('[data-chat-input]');
if (input) { input.value = content.split('\n').map((l: string) => `> ${l}`).join('\n') + '\n\n'; input.dispatchEvent(new Event('input', { bubbles: true })); input.focus(); }
}},
];
}
case 'tool-call': {
const tc = data as { args?: Record<string, unknown>; result?: unknown } | undefined;
return [
{ id: 'view-params', icon: Eye, label: '查看参数', action: () => { if (tc?.args) navigator.clipboard.writeText(JSON.stringify(tc.args, null, 2)).catch(() => {}); }},
{ id: 'view-result', icon: Eye, label: '查看完整结果', action: () => { if (tc?.result) navigator.clipboard.writeText(JSON.stringify(tc.result, null, 2)).catch(() => {}); }},
{ id: 'copy-result', icon: Copy, label: '复制结果', action: () => { if (tc?.result) navigator.clipboard.writeText(typeof tc.result === 'string' ? tc.result : JSON.stringify(tc.result)).catch(() => {}); }},
{ id: 're-execute', icon: RotateCcw, label: '重新执行', action: () => {
const m = [...useAgentStore.getState().messages].reverse().find((m) => m.role === 'user');
if (m) useAgentStore.getState().sendMessage(m.content);
}},
];
}
case 'session': {
const sid = (data as { sessionId?: string })?.sessionId;
return [
{ id: 'rename', icon: Edit, label: '重命名', action: () => {
if (sid) { const t = prompt('新会话名称:'); if (t?.trim()) { window.metona?.sessions.rename(sid, t.trim()); useSessionStore.getState().updateSession(sid, { title: t.trim() }); } }
}},
{ id: 'pin', icon: Pin, label: '置顶', action: () => {
if (sid) { const s = useSessionStore.getState().sessions.find((x) => x.id === sid); if (s) { window.metona?.sessions.pin(sid, !s.pinned); useSessionStore.getState().pinSession(sid, !s.pinned); } }
}},
{ id: 'archive', icon: Archive, label: '归档', action: () => { if (sid) useSessionStore.getState().archiveSession(sid, true); }},
{ id: 'export', icon: FileDown, label: '导出', action: () => {
if (sid) window.metona?.sessions.getMessages(sid).then((msgs) => {
const b = new Blob([JSON.stringify(msgs, null, 2)], { type: 'application/json' });
const a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = `session-${sid}.json`; a.click();
}).catch(() => {});
}},
{ id: 'delete', icon: Trash2, label: '删除', action: () => {
if (sid && confirm('确定删除此会话?')) { window.metona?.sessions.delete(sid); useSessionStore.getState().removeSession(sid); }
}},
];
}
case 'code-block': {
const code = (data as { code?: string })?.code;
return [
{ id: 'copy-code', icon: Copy, label: '复制代码', action: () => { if (code) navigator.clipboard.writeText(code).catch(() => {}); }},
{ id: 'open-editor', icon: Code, label: '在编辑器中打开', action: () => { if (code) window.open(URL.createObjectURL(new Blob([code], { type: 'text/plain' })), '_blank'); }},
];
}
case 'trace-step': {
const step = data as { thought?: string; toolCalls?: Array<{ name: string; args: Record<string, unknown> }> } | undefined;
return [
{ id: 'copy-thought', icon: Copy, label: '复制 Thought', action: () => { if (step?.thought) navigator.clipboard.writeText(step.thought).catch(() => {}); }},
{ id: 'copy-params', icon: Copy, label: '复制工具参数', action: () => {
if (step?.toolCalls) navigator.clipboard.writeText(step.toolCalls.map((tc) => `${tc.name}: ${JSON.stringify(tc.args, null, 2)}`).join('\n')).catch(() => {});
}},
{ id: 'export', icon: ExternalLink, label: '导出步骤详情', action: () => {
if (step) { const b = new Blob([JSON.stringify(step, null, 2)], { type: 'application/json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = `trace-step-${Date.now()}.json`; a.click(); }
}},
];
}
default: return [];
}
}
+73
View File
@@ -0,0 +1,73 @@
/**
* ToastContainer — Toast 通知容器
*
* 配置 metona-toast 并监听主进程通知桥接。
*
* @see docs/MetonaAI-Desktop UI UX 设计集成方案.html — MetonaToast 集成方案
*/
import { useEffect } from 'react';
/**
* Toast 容器组件
*
* 在 App 根组件中渲染,负责:
* 1. 配置 MeToast 全局参数
* 2. 监听主进程 toast:show 事件
* 3. 桥接到 MeToast 显示
*/
export function ToastContainer(): null {
useEffect(() => {
// 动态导入 metona-toast 并配置
import('metona-toast').then((mod) => {
const MeToast = mod.default;
// 全局配置(设计规范指定的参数)
MeToast.configure({
position: 'top-right',
duration: 4000,
max: 6,
theme: 'auto',
animation: 'slide',
pauseOnHover: true,
closeOnClick: true,
showProgress: true,
draggable: true,
locale: 'zh-CN',
});
// 安装插件
try {
MeToast.use('keyboard'); // ESC 关闭所有
MeToast.use('persistence'); // 配置持久化
MeToast.use('accessibility'); // 屏幕阅读器
} catch {
// 插件可能已安装
}
}).catch(() => {
// metona-toast 未安装时静默
});
// 监听主进程通知桥接
if (window.metona?.toast?.onShow) {
const unsubscribe = window.metona.toast.onShow(async (data) => {
try {
const MeToast = (await import('metona-toast')).default;
const { type, message, options } = data;
switch (type) {
case 'success': MeToast.success(message, options); break;
case 'error': MeToast.error(message, options); break;
case 'warning': MeToast.warning(message, options); break;
case 'info': MeToast.info(message, options); break;
default: MeToast.info(message, options);
}
} catch {
// 静默
}
});
return unsubscribe;
}
}, []);
return null;
}
+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>
);
}
+295
View File
@@ -0,0 +1,295 @@
/**
* ChatInput — 聊天输入框
*
* 附件系统:
* - 图片(png/jpg/gif/webp):缩略图预览,发送时转 base64 通过 images 字段传给 LLM
* - 文本文件(txt/md/json/csv/ts/js/py等):读取内容注入消息上下文
* - 其他文件:显示文件名+大小卡片,提示 LLM 文件信息
*
* @see docs/MetonaAI-Desktop UI UX 设计集成方案.html — 输入框智能特性
*/
import { useState, useCallback, useRef, useEffect } from 'react';
import { Box, Typography, IconButton, Tooltip, Stack, Button, Paper } from '@mui/material';
import { Send, Paperclip, Square, X, FileText, Image as ImageIcon } from 'lucide-react';
import { useAgentStore } from '@renderer/stores/agent-store';
import { useSessionStore } from '@renderer/stores/session-store';
import { formatTokens, formatFileSize } from '@renderer/lib/formatters';
const SLASH_COMMANDS = [
{ id: 'tool', label: '/tool', description: '选择工具' },
{ id: 'memory', label: '/memory', description: '搜索记忆' },
{ id: 'clear', label: '/clear', description: '清空会话' },
{ id: 'export', label: '/export', description: '导出会话' },
];
const IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
const TEXT_EXTENSIONS = ['txt', 'md', 'json', 'csv', 'ts', 'tsx', 'js', 'jsx', 'py', 'rb', 'go', 'rs', 'java', 'c', 'cpp', 'h', 'css', 'html', 'xml', 'yaml', 'yml', 'toml', 'ini', 'sh', 'bash', 'zsh', 'fish', 'sql', 'env', 'gitignore', 'dockerfile', 'makefile', 'log'];
interface Attachment {
id: string;
file: File;
type: 'image' | 'text' | 'other';
preview?: string; // 图片 base64 data URL
textContent?: string; // 文本文件内容
}
export function ChatInput(): React.JSX.Element {
const [input, setInput] = useState('');
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [showSlashMenu, setShowSlashMenu] = useState(false);
const [slashFilter, setSlashFilter] = useState('');
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const sendMessage = useAgentStore((s) => s.sendMessage);
const abort = useAgentStore((s) => s.abort);
const isStreaming = useAgentStore((s) => s.isStreaming);
const tokenUsage = useAgentStore((s) => s.tokenUsage);
const currentSessionId = useSessionStore((s) => s.currentSessionId);
// 草稿自动保存
useEffect(() => { if (currentSessionId) { const d = sessionStorage.getItem(`draft-${currentSessionId}`); setInput(d ?? ''); } }, [currentSessionId]);
useEffect(() => { if (currentSessionId && input) sessionStorage.setItem(`draft-${currentSessionId}`, input); }, [input, currentSessionId]);
// ===== 附件处理 =====
const classifyFile = useCallback((file: File): Attachment['type'] => {
if (IMAGE_TYPES.includes(file.type)) return 'image';
const ext = file.name.split('.').pop()?.toLowerCase() ?? '';
if (TEXT_EXTENSIONS.includes(ext)) return 'text';
return 'other';
}, []);
const processFile = useCallback(async (file: File): Promise<Attachment> => {
const type = classifyFile(file);
const attachment: Attachment = { id: `att_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, file, type };
if (type === 'image') {
// 图片转 base64 data URL
attachment.preview = await new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.readAsDataURL(file);
});
} else if (type === 'text') {
// 文本文件读取内容
attachment.textContent = await new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.readAsText(file);
});
}
return attachment;
}, [classifyFile]);
const addFiles = useCallback(async (files: FileList | File[]) => {
const fileArray = Array.from(files).slice(0, 5); // 最多 5 个附件
const newAttachments = await Promise.all(fileArray.map(processFile));
setAttachments((prev) => [...prev, ...newAttachments]);
}, [processFile]);
const removeAttachment = useCallback((id: string) => {
setAttachments((prev) => prev.filter((a) => a.id !== id));
}, []);
// 文件选择
const handleFileSelect = useCallback(() => { fileInputRef.current?.click(); }, []);
const handleFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) addFiles(e.target.files);
e.target.value = '';
}, [addFiles]);
// 拖拽
const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); }, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault(); e.stopPropagation();
if (e.dataTransfer.files.length) addFiles(e.dataTransfer.files);
}, [addFiles]);
// 粘贴
const handlePaste = useCallback((e: React.ClipboardEvent) => {
const items = Array.from(e.clipboardData.items);
const files: File[] = [];
for (const item of items) {
if (item.kind === 'file') {
const f = item.getAsFile();
if (f) files.push(f);
}
}
if (files.length) { e.preventDefault(); addFiles(files); }
}, [addFiles]);
// ===== 发送消息 =====
const handleSend = useCallback(async () => {
const trimmed = input.trim();
if (!trimmed && attachments.length === 0) return;
if (isStreaming) return;
// 处理 / 命令
if (trimmed.startsWith('/')) {
const cmd = trimmed.split(' ')[0].toLowerCase();
if (cmd === '/clear') { useAgentStore.getState().clearMessages(); setInput(''); setAttachments([]); return; }
if (cmd === '/export') {
const blob = new Blob([JSON.stringify(useAgentStore.getState().messages, null, 2)], { type: 'application/json' });
const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `session-${Date.now()}.json`; a.click(); setInput(''); return;
}
}
// 构建用户可见内容(纯文本 + 附件描述隐藏)
let messageContent = trimmed;
const images: Array<{ url: string; detail?: 'low' | 'high' | 'auto' }> = [];
// 附件元数据(用于 UI 渲染)
const attachmentInfos = attachments.map((att) => ({
id: att.id,
name: att.file.name,
type: att.type,
size: att.file.size,
preview: att.preview,
textContent: att.type === 'text' ? att.textContent : undefined,
}));
// 图片加入 images 数组
for (const att of attachments) {
if (att.type === 'image' && att.preview) {
images.push({ url: att.preview, detail: 'auto' });
}
}
sendMessage(messageContent, images.length > 0 ? images : undefined, attachmentInfos.length > 0 ? attachmentInfos : undefined);
setInput('');
setAttachments([]);
setShowSlashMenu(false);
if (currentSessionId) sessionStorage.removeItem(`draft-${currentSessionId}`);
if (textareaRef.current) textareaRef.current.style.height = 'auto';
}, [input, attachments, isStreaming, sendMessage, currentSessionId]);
const handleAbort = useCallback(() => { abort(); }, [abort]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
// Ctrl+Enter — 换行
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
const t = e.currentTarget as HTMLTextAreaElement;
const s = t.selectionStart;
const en = t.selectionEnd;
setInput((p) => p.slice(0, s) + '\n' + p.slice(en));
requestAnimationFrame(() => { t.selectionStart = t.selectionEnd = s + 1; });
return;
}
// Enter — 发送
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); return; }
if (e.key === 'Escape' && showSlashMenu) { setShowSlashMenu(false); return; }
}, [handleSend, showSlashMenu]);
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
const v = e.target.value; setInput(v);
if (v === '/') { setShowSlashMenu(true); setSlashFilter(''); }
else if (v.startsWith('/') && !v.includes(' ')) { setShowSlashMenu(true); setSlashFilter(v.slice(1).toLowerCase()); }
else { setShowSlashMenu(false); }
}, []);
const filteredCommands = SLASH_COMMANDS.filter((c) => c.label.toLowerCase().includes(`/${slashFilter}`));
return (
<Box sx={{ flexShrink: 0, px: 2, pb: 1.5 }}>
<Paper sx={{ maxWidth: 768, mx: 'auto', borderRadius: 3, p: 1.5, bgcolor: 'background.paper', border: '1px solid', borderColor: 'divider', position: 'relative' }}
onDragOver={handleDragOver} onDrop={handleDrop}
>
{/* 附件预览区 */}
{attachments.length > 0 && (
<Stack direction="row" spacing={1} sx={{ mb: 1, flexWrap: 'wrap', gap: 1 }}>
{attachments.map((att) => (
<AttachmentPreview key={att.id} attachment={att} onRemove={() => removeAttachment(att.id)} />
))}
</Stack>
)}
{/* / 命令菜单 */}
{showSlashMenu && filteredCommands.length > 0 && (
<div style={{ position: 'absolute', bottom: '100%', left: 0, right: 0, marginBottom: 4, padding: '4px 0', background: 'var(--bg-secondary, #1a1d27)', border: '1px solid var(--border-color, #2a2d3a)', borderRadius: 8, boxShadow: '0 -4px 12px rgba(0,0,0,0.2)', zIndex: 10 }}>
{filteredCommands.map((cmd) => (
<div key={cmd.id} onClick={() => { setInput(cmd.label + ' '); setShowSlashMenu(false); textareaRef.current?.focus(); }}
style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 12px', fontSize: 12, cursor: 'pointer', color: 'var(--text-primary, #e1e4ed)' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,0.05)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
<span style={{ color: '#818cf8', fontSize: 14, fontWeight: 700, flexShrink: 0 }}>/</span>
<span style={{ fontFamily: 'monospace', fontSize: 12 }}>{cmd.label}</span>
<span style={{ color: 'var(--text-secondary, #64748b)', fontSize: 11 }}>{cmd.description}</span>
</div>
))}
</div>
)}
<input ref={fileInputRef} type="file" multiple accept="image/*,.txt,.md,.json,.csv,.ts,.tsx,.js,.jsx,.py,.rb,.go,.rs,.java,.c,.cpp,.h,.css,.html,.xml,.yaml,.yml,.toml,.ini,.sh,.sql,.log,.pdf" style={{ display: 'none' }} onChange={handleFileChange} />
<textarea
ref={textareaRef} data-chat-input value={input} onChange={handleChange} onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder="输入消息... (Enter 发送, Ctrl+Enter 换行, / 命令)" disabled={isStreaming}
rows={1}
style={{ width: '100%', background: 'transparent', border: 'none', outline: 'none', color: 'inherit', fontSize: 13, lineHeight: '24px', resize: 'none', fontFamily: 'inherit', minHeight: 24, maxHeight: 144 }}
/>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mt: 1, minHeight: 32 }}>
{/* 左侧:附件按钮 */}
<Tooltip title="附加文件(图片/文本/代码)">
<IconButton size="small" sx={{ color: 'text.secondary' }} onClick={handleFileSelect}>
<Paperclip size={14} />
</IconButton>
</Tooltip>
{/* 右侧:发送按钮 */}
<Stack direction="row" spacing={1} alignItems="center">
{isStreaming ? (
<Button variant="contained" color="error" size="small" onClick={handleAbort} sx={{ height: 28, fontSize: 12 }}>
<Square size={12} style={{ marginRight: 6 }} />
</Button>
) : (
<Button variant="contained" size="small" onClick={handleSend} disabled={!input.trim() && attachments.length === 0} sx={{ height: 28, fontSize: 12, opacity: (input.trim() || attachments.length > 0) ? 1 : 0.5 }}>
<Send size={12} style={{ marginRight: 6 }} />
</Button>
)}
</Stack>
</Stack>
</Paper>
</Box>
);
}
// ===== 附件预览组件 =====
function AttachmentPreview({ attachment, onRemove }: { attachment: Attachment; onRemove: () => void }) {
const { type, file, preview } = attachment;
if (type === 'image' && preview) {
return (
<Box sx={{ position: 'relative', width: 64, height: 64, borderRadius: 1.5, overflow: 'hidden', border: '1px solid', borderColor: 'divider', flexShrink: 0 }}>
<Box component="img" src={preview} alt={file.name} sx={{ width: '100%', height: '100%', objectFit: 'cover' }} />
<IconButton size="small" onClick={onRemove} sx={{ position: 'absolute', top: 0, right: 0, width: 20, height: 20, bgcolor: 'rgba(0,0,0,0.6)', '&:hover': { bgcolor: 'rgba(0,0,0,0.8)' }, color: '#fff' }}>
<X size={12} />
</IconButton>
<Typography variant="caption" sx={{ position: 'absolute', bottom: 0, left: 0, right: 0, bgcolor: 'rgba(0,0,0,0.6)', color: '#fff', fontSize: 9, textAlign: 'center', py: 0.25, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', px: 0.5 }}>
{file.name}
</Typography>
</Box>
);
}
// 文本文件 / 其他文件
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 1, borderRadius: 1.5, bgcolor: 'secondary.main', border: '1px solid', borderColor: 'divider', maxWidth: 200, flexShrink: 0 }}>
{type === 'text' ? <FileText size={16} style={{ color: '#22d3ee', flexShrink: 0 }} /> : <ImageIcon size={16} style={{ color: '#8b8fa7', flexShrink: 0 }} />}
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography variant="caption" sx={{ display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 11, color: 'text.primary' }}>{file.name}</Typography>
<Typography variant="caption" sx={{ fontSize: 9, color: 'text.secondary' }}>{formatFileSize(file.size)}</Typography>
</Box>
<IconButton size="small" onClick={onRemove} sx={{ width: 20, height: 20, flexShrink: 0, color: 'text.secondary' }}>
<X size={12} />
</IconButton>
</Box>
);
}
+18
View File
@@ -0,0 +1,18 @@
/**
* ChatPanel — 聊天主面板
*
* 组装 MessageList + ChatInput。
* 占据中间弹性区域。
*/
import { MessageList } from './MessageList';
import { ChatInput } from './ChatInput';
export function ChatPanel(): React.JSX.Element {
return (
<main className="flex-1 flex flex-col min-w-0 overflow-hidden">
<MessageList />
<ChatInput />
</main>
);
}
+41
View File
@@ -0,0 +1,41 @@
/**
* MessageItem — 单条消息路由组件
*
* 根据消息 role 路由到对应的子组件渲染。
*/
import type { ChatMessage } from '@renderer/stores/agent-store';
import { UserMessage } from './UserMessage';
import { AssistantMessage } from './AssistantMessage';
import { SystemMessage } from './SystemMessage';
interface MessageItemProps {
message: ChatMessage;
isLast?: boolean;
isStreaming?: boolean;
streamContent?: string;
}
export function MessageItem({ message, isLast, isStreaming, streamContent }: MessageItemProps): React.JSX.Element {
switch (message.role) {
case 'user':
return <UserMessage message={message} />;
case 'assistant':
return (
<AssistantMessage
message={message}
isStreaming={isLast && isStreaming}
streamContent={streamContent}
/>
);
case 'tool':
// 工具消息渲染为系统消息样式
return <SystemMessage message={message} />;
case 'system':
default:
return <SystemMessage message={message} />;
}
}
+43
View File
@@ -0,0 +1,43 @@
/**
* MessageList — 消息列表容器
*/
import { useEffect, useRef } from 'react';
import { Box, Typography } from '@mui/material';
import { useAgentStore } from '@renderer/stores/agent-store';
import { MessageItem } from './MessageItem';
import { StreamingIndicator } from './StreamingIndicator';
export function MessageList(): React.JSX.Element {
const messages = useAgentStore((s) => s.messages);
const isStreaming = useAgentStore((s) => s.isStreaming);
const streamingContent = useAgentStore((s) => s.streamingContent);
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages, streamingContent]);
if (messages.length === 0 && !isStreaming) {
return (
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Box sx={{ textAlign: 'center', animation: 'fadeIn 200ms ease-out' }}>
<Box component="img" src="./assets/logo.png" alt="Metona" sx={{ width: 64, height: 64, mx: 'auto', mb: 2, borderRadius: 2 }} />
<Typography variant="h6" sx={{ color: 'text.primary', mb: 0.5 }}>MetonaAI Desktop</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}> AI Agent </Typography>
<Typography variant="caption" sx={{ mt: 2, display: 'block', opacity: 0.6 }}>Agent · Provider </Typography>
</Box>
</Box>
);
}
return (
<Box sx={{ flex: 1, overflowY: 'auto', px: 2, py: 3 }}>
<Box sx={{ maxWidth: 768, mx: 'auto', display: 'flex', flexDirection: 'column', gap: 3 }}>
{messages.map((msg, i) => (
<MessageItem key={msg.id} message={msg} isLast={i === messages.length - 1} isStreaming={isStreaming} streamContent={streamingContent} />
))}
<StreamingIndicator />
<div ref={messagesEndRef} />
</Box>
</Box>
);
}
@@ -0,0 +1,25 @@
/**
* StreamingIndicator — 流式加载指示器
*/
import { Box, Typography, CircularProgress } from '@mui/material';
import { Brain, Loader2 } from 'lucide-react';
import { useAgentStore } from '@renderer/stores/agent-store';
export function StreamingIndicator(): React.JSX.Element | null {
const isStreaming = useAgentStore((s) => s.isStreaming);
const agentStatus = useAgentStore((s) => s.agentStatus);
if (!isStreaming) return null;
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 1.5, animation: 'fadeIn 200ms ease-out' }}>
<Box sx={{ width: 32, height: 32, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: 'secondary.main', color: 'primary.main' }}>
<Brain size={16} style={{ animation: 'pulse 2s infinite' }} />
</Box>
<Loader2 size={14} style={{ animation: 'spin 1s linear infinite', color: '#818cf8' }} />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{agentStatus === 'thinking' ? '思考中...' : agentStatus === 'executing' ? '执行中...' : '处理中...'}
</Typography>
</Box>
);
}
+22
View File
@@ -0,0 +1,22 @@
/**
* SystemMessage — 系统通知消息
*/
import { Box, Typography } from '@mui/material';
import type { ChatMessage } from '@renderer/stores/agent-store';
import { formatTime } from '@renderer/lib/formatters';
interface SystemMessageProps { message: ChatMessage; }
export function SystemMessage({ message }: SystemMessageProps): React.JSX.Element {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 1, animation: 'fadeIn 200ms ease-out' }}>
<Typography
variant="caption"
sx={{ px: 1.5, py: 0.5, borderRadius: 99, color: 'text.disabled', bgcolor: 'secondary.main', fontSize: 11 }}
>
{message.content}
</Typography>
</Box>
);
}
+29
View File
@@ -0,0 +1,29 @@
/**
* ThoughtBlock — 思考过程展示
*/
import { useState } from 'react';
import { Box, Typography, IconButton, Collapse } from '@mui/material';
import { ChevronDown, ChevronRight, Brain } from 'lucide-react';
interface ThoughtBlockProps { content: string; defaultExpanded?: boolean; }
export function ThoughtBlock({ content, defaultExpanded = false }: ThoughtBlockProps): React.JSX.Element {
const [expanded, setExpanded] = useState(defaultExpanded);
if (!content) return <></>;
return (
<Box sx={{ borderRadius: 1.5, border: '1px dashed', borderColor: 'divider', bgcolor: 'secondary.main', mb: expanded ? 1.5 : 0.5, transition: 'all 200ms' }}>
<IconButton size="small" onClick={() => setExpanded(!expanded)} sx={{ width: '100%', justifyContent: 'flex-start', gap: 1, px: 1.5, py: 1, borderRadius: '10px 10px 0 0', color: 'text.secondary', fontSize: 12 }}>
{expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
<Brain size={12} style={{ color: '#fbbf24' }} />
<span></span>
</IconButton>
<Collapse in={expanded}>
<Box sx={{ px: 1.5, pb: 1.5, fontFamily: "'SF Mono','Fira Code',monospace", fontSize: 12, lineHeight: 1.6, color: 'text.secondary', whiteSpace: 'pre-wrap' }}>
{content}
</Box>
</Collapse>
</Box>
);
}
+62
View File
@@ -0,0 +1,62 @@
/**
* ToolCallCard — 工具调用卡片
*/
import { Box, Typography, Stack, Chip } from '@mui/material';
import { Wrench, Clock, CheckCircle, XCircle, Ban, Loader2 } from 'lucide-react';
import type { ToolCallInfo } from '@renderer/stores/agent-store';
import { formatDuration } from '@renderer/lib/formatters';
interface ToolCallCardProps { toolCall: ToolCallInfo; }
const STATUS_ICONS = { pending: Clock, executing: Loader2, success: CheckCircle, error: XCircle, blocked: Ban };
const STATUS_LABELS = { pending: '等待中', executing: '执行中', success: '成功', error: '失败', blocked: '已阻止' };
const STATUS_COLORS: Record<string, string> = { pending: '#fbbf24', executing: '#a855f7', success: '#34d399', error: '#f87171', blocked: '#fb923c' };
const CHIP_VARIANTS: Record<string, 'outlined' | 'filled'> = { pending: 'outlined', executing: 'filled', success: 'filled', error: 'filled', blocked: 'outlined' };
export function ToolCallCard({ toolCall }: ToolCallCardProps): React.JSX.Element {
const Icon = STATUS_ICONS[toolCall.status];
const color = STATUS_COLORS[toolCall.status];
const label = STATUS_LABELS[toolCall.status];
return (
<Box
sx={{
borderRadius: 1.5, border: '1px solid', borderColor: 'divider', borderLeft: '3px solid', borderLeftColor: color,
bgcolor: 'secondary.main', px: 1.5, py: 1.25, mb: 1,
animation: toolCall.status === 'executing' ? 'pulse 2s infinite' : 'fadeInUp 300ms ease-out',
}}
>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 0.75 }}>
<Wrench size={12} style={{ color: '#a855f7' }} />
<Typography sx={{ fontSize: 12, fontWeight: 600, fontFamily: 'monospace', color: 'text.primary' }}>{toolCall.name}</Typography>
<Chip
icon={<Icon size={10} style={{ animation: toolCall.status === 'executing' ? 'spin 1s linear infinite' : 'none' }} />}
label={label}
size="small"
variant={CHIP_VARIANTS[toolCall.status]}
sx={{ height: 18, fontSize: 10, color, borderColor: color, '& .MuiChip-icon': { color } }}
/>
{toolCall.durationMs != null && (
<Typography variant="caption" sx={{ ml: 'auto', color: 'text.secondary' }}>{formatDuration(toolCall.durationMs)}</Typography>
)}
</Stack>
{Object.keys(toolCall.args).length > 0 && (
<Box component="pre" sx={{ fontSize: 11, borderRadius: 1, px: 1, py: 0.75, overflowX: 'auto', bgcolor: 'background.default', color: 'text.secondary', fontFamily: "'SF Mono',monospace", maxHeight: 80, m: 0 }}>
{JSON.stringify(toolCall.args, null, 2)}
</Box>
)}
{toolCall.status === 'error' && toolCall.error && (
<Typography variant="caption" sx={{ mt: 0.75, display: 'block', color: 'error.main' }}>{toolCall.error}</Typography>
)}
{toolCall.status === 'success' && toolCall.result != null && (
<Typography variant="caption" sx={{ mt: 0.75, display: 'block', color: 'text.secondary', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{typeof toolCall.result === 'string' ? toolCall.result.slice(0, 200) : JSON.stringify(toolCall.result).slice(0, 200)}
</Typography>
)}
</Box>
);
}
+28
View File
@@ -0,0 +1,28 @@
/**
* ToolResultBlock — 工具结果块
*/
import { Box, Typography, Stack } from '@mui/material';
import { FileText } from 'lucide-react';
import type { ToolCallInfo } from '@renderer/stores/agent-store';
import { formatDuration } from '@renderer/lib/formatters';
interface ToolResultBlockProps { toolCall: ToolCallInfo; }
export function ToolResultBlock({ toolCall }: ToolResultBlockProps): React.JSX.Element {
if (toolCall.status !== 'success' || toolCall.result == null) return <></>;
const resultStr = typeof toolCall.result === 'string' ? toolCall.result : JSON.stringify(toolCall.result, null, 2);
return (
<Box sx={{ borderRadius: 1.5, border: '1px solid', borderColor: 'divider', borderLeft: '3px solid', borderLeftColor: 'info.main', bgcolor: 'secondary.main', px: 1.5, py: 1, mb: 1, animation: 'fadeInUp 300ms ease-out' }}>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 0.5 }}>
<FileText size={12} style={{ color: '#22d3ee' }} />
<Typography variant="caption" sx={{ fontWeight: 500, color: 'text.primary' }}>{toolCall.name} </Typography>
{toolCall.durationMs != null && <Typography variant="caption" sx={{ ml: 'auto', color: 'text.secondary' }}>{formatDuration(toolCall.durationMs)}</Typography>}
</Stack>
<Box component="pre" sx={{ fontSize: 11, borderRadius: 1, px: 1, py: 0.75, overflowX: 'auto', bgcolor: 'background.default', color: 'text.secondary', fontFamily: "'SF Mono',monospace", maxHeight: 120, m: 0 }}>
{resultStr.length > 500 ? resultStr.slice(0, 500) + '\n... [截断]' : resultStr}
</Box>
</Box>
);
}
+110
View File
@@ -0,0 +1,110 @@
/**
* UserMessage — 用户消息卡片
*
* 全宽卡片流布局,左侧头像。
* 支持附件可视化:图片缩略图、文件卡片。
*/
import { useState, useCallback, useRef, useEffect } from 'react';
import { Box, Typography, Avatar, Stack, TextareaAutosize } from '@mui/material';
import { User, FileText, Image as ImageIcon, X } from 'lucide-react';
import type { ChatMessage, AttachmentInfo } from '@renderer/stores/agent-store';
import { useAgentStore } from '@renderer/stores/agent-store';
import { formatTime, formatFileSize } from '@renderer/lib/formatters';
import { ContextMenu, createContextMenuItems } from '@renderer/components/ContextMenu';
interface UserMessageProps { message: ChatMessage; }
export function UserMessage({ message }: UserMessageProps): React.JSX.Element {
const [editing, setEditing] = useState(false);
const [editContent, setEditContent] = useState(message.content);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const updateMessage = useAgentStore((s) => s.updateMessage);
const handleDoubleClick = useCallback(() => { setEditing(true); setEditContent(message.content); }, [message.content]);
const handleEditSave = useCallback(() => {
if (editContent.trim() && editContent !== message.content) updateMessage(message.id, { content: editContent.trim() });
setEditing(false);
}, [editContent, message.content, message.id, updateMessage]);
const handleEditKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Escape') { setEditing(false); setEditContent(message.content); }
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) handleEditSave();
}, [handleEditSave, message.content]);
useEffect(() => { if (editing) textareaRef.current?.focus(); }, [editing]);
const hasAttachments = message.attachments && message.attachments.length > 0;
return (
<Stack direction="row" spacing={1.5} sx={{ animation: 'fadeInUp 300ms ease-out' }}>
<Avatar sx={{ width: 32, height: 32, bgcolor: 'action.hover', color: 'primary.main', flexShrink: 0 }}><User size={16} /></Avatar>
<Box
sx={{ maxWidth: 768, borderRadius: 3, px: 2, py: 1.5, cursor: 'default', bgcolor: 'background.paper', border: '1px solid', borderColor: 'divider' }}
onDoubleClick={handleDoubleClick}
onContextMenu={(e: React.MouseEvent) => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY }); }}
>
{/* 附件预览区 */}
{hasAttachments && (
<Stack direction="row" spacing={1} sx={{ mb: message.content ? 1.5 : 0, flexWrap: 'wrap', gap: 1 }}>
{message.attachments!.map((att) => (
<AttachmentPreview key={att.id} attachment={att} />
))}
</Stack>
)}
{/* 文本内容 */}
{editing ? (
<TextareaAutosize ref={textareaRef} value={editContent} onChange={(e) => setEditContent(e.target.value)} onKeyDown={handleEditKeyDown} onBlur={handleEditSave} minRows={3} style={{ width: '100%', background: 'transparent', border: 'none', outline: 'none', color: 'inherit', fontSize: 13, lineHeight: 1.7, resize: 'none', fontFamily: 'inherit' }} />
) : message.content ? (
<Typography sx={{ whiteSpace: 'pre-wrap', fontSize: 13, lineHeight: 1.7, color: 'text.primary' }}>{message.content}</Typography>
) : null}
<Typography variant="caption" sx={{ mt: 0.75, display: 'block', color: 'text.disabled' }}>{formatTime(message.timestamp)}</Typography>
</Box>
{contextMenu && <ContextMenu type="message" x={contextMenu.x} y={contextMenu.y} items={createContextMenuItems('message', { content: message.content })} onClose={() => setContextMenu(null)} />}
</Stack>
);
}
// ===== 附件预览组件 =====
function AttachmentPreview({ attachment }: { attachment: AttachmentInfo }) {
const { type, name, size, preview } = attachment;
if (type === 'image' && preview) {
return (
<Box sx={{ position: 'relative', width: 120, height: 120, borderRadius: 2, overflow: 'hidden', border: '1px solid', borderColor: 'divider', flexShrink: 0 }}>
<Box component="img" src={preview} alt={name} sx={{ width: '100%', height: '100%', objectFit: 'cover' }} />
<Box sx={{ position: 'absolute', bottom: 0, left: 0, right: 0, bgcolor: 'rgba(0,0,0,0.6)', px: 1, py: 0.5 }}>
<Typography variant="caption" sx={{ color: '#fff', fontSize: 10, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>
{name}
</Typography>
</Box>
</Box>
);
}
// 文本文件
if (type === 'text') {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 1, borderRadius: 1.5, bgcolor: 'action.hover', border: '1px solid', borderColor: 'divider', maxWidth: 200, flexShrink: 0 }}>
<FileText size={20} style={{ color: '#22d3ee', flexShrink: 0 }} />
<Box sx={{ minWidth: 0 }}>
<Typography variant="caption" sx={{ display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 11, color: 'text.primary' }}>{name}</Typography>
<Typography variant="caption" sx={{ fontSize: 9, color: 'text.secondary' }}>{formatFileSize(size)}</Typography>
</Box>
</Box>
);
}
// 其他文件
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 1, borderRadius: 1.5, bgcolor: 'action.hover', border: '1px solid', borderColor: 'divider', maxWidth: 200, flexShrink: 0 }}>
<ImageIcon size={20} style={{ color: '#8b8fa7', flexShrink: 0 }} />
<Box sx={{ minWidth: 0 }}>
<Typography variant="caption" sx={{ display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 11, color: 'text.primary' }}>{name}</Typography>
<Typography variant="caption" sx={{ fontSize: 9, color: 'text.secondary' }}>{formatFileSize(size)}</Typography>
</Box>
</Box>
);
}
+75
View File
@@ -0,0 +1,75 @@
/**
* AgentMonitor — Agent 状态指示器
*/
import { Box, Typography, Stack } from '@mui/material';
import { Activity, Cpu, Clock } from 'lucide-react';
import { useEffect } from 'react';
import { useAgentStore, type AgentStatus } from '@renderer/stores/agent-store';
import { AGENT_STATUS_COLORS, AGENT_STATUS_LABELS, PROVIDER_LABELS } from '@renderer/lib/constants';
import { formatDuration } from '@renderer/lib/formatters';
const STATUS_ICONS: Record<AgentStatus, typeof Activity> = { idle: Activity, thinking: Cpu, executing: Cpu, error: Activity };
export function AgentMonitor(): React.JSX.Element {
const agentStatus = useAgentStore((s) => s.agentStatus);
const provider = useAgentStore((s) => s.provider);
const model = useAgentStore((s) => s.model);
const currentIteration = useAgentStore((s) => s.currentIteration);
const maxIterations = useAgentStore((s) => s.maxIterations);
const traceSteps = useAgentStore((s) => s.traceSteps);
const setMaxIterations = useAgentStore((s) => s.setMaxIterations);
// 启动时从数据库读取 maxIterations
useEffect(() => {
if (window.metona?.config?.get) {
window.metona.config.get('agent.maxIterations').then((v) => {
if (typeof v === 'number' && v > 0) setMaxIterations(v);
}).catch(() => {});
}
}, [setMaxIterations]);
// 监听配置变更
useEffect(() => {
const interval = setInterval(() => {
if (window.metona?.config?.get) {
window.metona.config.get('agent.maxIterations').then((v) => {
if (typeof v === 'number' && v > 0) setMaxIterations(v);
}).catch(() => {});
}
}, 3000);
return () => clearInterval(interval);
}, [setMaxIterations]);
const StatusIcon = STATUS_ICONS[agentStatus];
const statusColor = AGENT_STATUS_COLORS[agentStatus];
const statusLabel = AGENT_STATUS_LABELS[agentStatus];
const totalDuration = traceSteps.reduce((sum, s) => sum + (s.completedAt ? s.completedAt - s.startedAt : 0), 0);
const InfoRow = ({ icon: Icon, label, value }: { icon: typeof Activity; label: string; value: string }) => (
<Stack direction="row" alignItems="center" sx={{ gap: 1 }}>
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ color: 'text.secondary', flexShrink: 0, minWidth: 60 }}>
<Icon size={10} /><Typography variant="caption">{label}</Typography>
</Stack>
<Typography variant="caption" sx={{ fontFamily: 'monospace', color: 'text.primary', flex: 1, textAlign: 'right', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{value}</Typography>
</Stack>
);
return (
<Box sx={{ mt: 2, pt: 2, borderTop: 1, borderColor: 'divider' }}>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1.5 }}>
<Cpu size={14} style={{ color: '#818cf8' }} />
<Typography variant="caption" sx={{ fontWeight: 600, textTransform: 'uppercase', letterSpacing: 1, color: 'text.secondary' }}>Agent </Typography>
</Stack>
<Stack direction="row" spacing={1} alignItems="center" sx={{ px: 1.5, py: 1, borderRadius: 1.5, mb: 1, bgcolor: 'background.default' }}>
<StatusIcon size={14} style={{ color: statusColor, animation: (agentStatus === 'thinking' || agentStatus === 'executing') ? 'pulse 2s infinite' : 'none' }} />
<Typography variant="caption" sx={{ fontWeight: 500, color: statusColor }}>{statusLabel}</Typography>
</Stack>
<Stack spacing={0.75}>
<InfoRow icon={Activity} label="Provider" value={PROVIDER_LABELS[provider] ?? provider} />
<InfoRow icon={Cpu} label="模型" value={model} />
<InfoRow icon={Activity} label="迭代" value={`${currentIteration} / ${maxIterations}`} />
{totalDuration > 0 && <InfoRow icon={Clock} label="总耗时" value={formatDuration(totalDuration)} />}
</Stack>
</Box>
);
}
+23
View File
@@ -0,0 +1,23 @@
/**
* DetailPanel — 右侧详情面板
*
* 始终显示,包含 TraceViewer、TokenUsage、AgentMonitor。
*/
import { Box } from '@mui/material';
import { TraceViewer } from '@renderer/components/trace/TraceViewer';
import { TokenUsage } from '@renderer/components/trace/TokenUsage';
import { AgentMonitor } from '@renderer/components/layout/AgentMonitor';
import { LAYOUT } from '@renderer/lib/constants';
export function DetailPanel(): React.JSX.Element {
return (
<Box component="aside" sx={{ flexShrink: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', bgcolor: 'background.paper', borderLeft: 1, borderColor: 'divider', width: LAYOUT.DETAIL_WIDTH }}>
<Box sx={{ p: 1.5, flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<TraceViewer />
<TokenUsage />
<AgentMonitor />
</Box>
</Box>
);
}
+44
View File
@@ -0,0 +1,44 @@
/**
* Header — 标题栏
*
* Logo + 会话标题 + 设置按钮。
*/
import { Box, IconButton, Tooltip, Stack, Typography } from '@mui/material';
import { Settings } from 'lucide-react';
import { useUIStore } from '@renderer/stores/ui-store';
import { useSessionStore } from '@renderer/stores/session-store';
type WebkitCSS = React.CSSProperties & { WebkitAppRegion?: string };
export function Header(): React.JSX.Element {
const openSettings = useUIStore((s) => s.openSettings);
const currentSessionId = useSessionStore((s) => s.currentSessionId);
const sessions = useSessionStore((s) => s.sessions);
const currentSession = sessions.find((s) => s.id === currentSessionId);
return (
<header
style={{
flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0 16px', height: 40, userSelect: 'none',
background: 'var(--bg-secondary)', borderBottom: '1px solid var(--border-color)',
WebkitAppRegion: 'drag',
} as WebkitCSS}
>
<Stack direction="row" spacing={1} alignItems="center" sx={{ minWidth: 0 }}>
<Box component="img" src="./assets/logo.png" alt="Metona" sx={{ width: 20, height: 20, borderRadius: 0.5 }} />
<Typography variant="body2" sx={{ fontWeight: 600, color: 'primary.main', fontSize: 13 }}>MetonaAI</Typography>
{currentSession && (
<>
<Typography variant="body2" sx={{ color: 'divider' }}>·</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{currentSession.title}</Typography>
</>
)}
</Stack>
<Stack direction="row" spacing={0.5} sx={{ WebkitAppRegion: 'no-drag' } as WebkitCSS}>
<Tooltip title="设置 (Ctrl+,)"><IconButton size="small" onClick={openSettings} sx={{ color: 'text.secondary' }}><Settings size={14} /></IconButton></Tooltip>
</Stack>
</header>
);
}
+195
View File
@@ -0,0 +1,195 @@
/**
* Sidebar — 左侧边栏
*
* 始终显示,包含会话列表、新建会话按钮、搜索。
*/
import { useState, useEffect } from 'react';
import { Box, Typography, Button, IconButton, InputBase, Stack, Collapse, List, ListItemButton, ListItemIcon, ListItemText, Badge, Divider, Dialog, DialogTitle, DialogContent, DialogActions, TextField } from '@mui/material';
import Fuse from 'fuse.js';
import { Plus, Search, MessageSquare, Pin, Wrench, Database, ChevronDown, ChevronRight, Trash2 } from 'lucide-react';
import { useSessionStore, type Session } from '@renderer/stores/session-store';
import { useAgentStore } from '@renderer/stores/agent-store';
import { formatRelativeTime } from '@renderer/lib/formatters';
import { LAYOUT } from '@renderer/lib/constants';
export function Sidebar(): React.JSX.Element {
const sessions = useSessionStore((s) => s.sessions);
const currentSessionId = useSessionStore((s) => s.currentSessionId);
const setCurrentSession = useSessionStore((s) => s.setCurrentSession);
const loadSessionMessages = useAgentStore((s) => s.setCurrentSession);
const searchQuery = useSessionStore((s) => s.searchQuery);
const setSearchQuery = useSessionStore((s) => s.setSearchQuery);
const agentStatus = useAgentStore((s) => s.agentStatus);
useEffect(() => {
if (window.metona?.sessions?.list) window.metona.sessions.list().then((list) => useSessionStore.getState().setSessions((list as any[]).map((s) => ({ id: s.id, title: s.title, createdAt: s.createdAt, updatedAt: s.updatedAt, messageCount: s.messageCount, pinned: s.pinned, archived: s.archived })))).catch(() => {});
}, []);
const filteredSessions = (() => {
let list = sessions.filter((s) => !s.archived);
if (searchQuery) { const fuse = new Fuse(list, { keys: ['title'], threshold: 0.4, ignoreLocation: true }); list = fuse.search(searchQuery).map((r) => r.item); }
return list.sort((a, b) => a.pinned === b.pinned ? b.updatedAt - a.updatedAt : a.pinned ? -1 : 1);
})();
const handleNewSession = async () => {
if (window.metona?.sessions?.create) {
try {
const r = await window.metona.sessions.create() as { id: string; title: string; createdAt: number; updatedAt: number; messageCount: number; pinned: boolean; archived: boolean };
useSessionStore.getState().addSession({ id: r.id, title: r.title, createdAt: r.createdAt, updatedAt: r.updatedAt, messageCount: r.messageCount, pinned: r.pinned, archived: r.archived });
setCurrentSession(r.id);
loadSessionMessages(r.id);
return;
} catch {}
}
const newSession: Session = { id: `s_${Date.now()}`, title: '新会话', createdAt: Date.now(), updatedAt: Date.now(), messageCount: 0, pinned: false, archived: false };
useSessionStore.getState().addSession(newSession);
setCurrentSession(newSession.id);
loadSessionMessages(newSession.id);
};
return (
<Box component="aside" sx={{ flexShrink: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', bgcolor: 'background.paper', borderRight: 1, borderColor: 'divider', width: LAYOUT.SIDEBAR_WIDTH }}>
<Box sx={{ p: 1.5, flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<Button variant="outlined" fullWidth size="small" onClick={handleNewSession} sx={{ mb: 1.5, fontSize: 12 }}>
<Plus size={14} style={{ marginRight: 8 }} />
</Button>
<Stack direction="row" alignItems="center" spacing={1} sx={{ px: 1.5, py: 0.75, borderRadius: 1.5, mb: 1.5, bgcolor: 'secondary.main', border: '1px solid', borderColor: 'divider' }}>
<Search size={12} style={{ color: '#8b8fa7', flexShrink: 0 }} />
<InputBase value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder="搜索会话..." sx={{ flex: 1, fontSize: 12 }} />
</Stack>
<Box sx={{ flex: 1, overflowY: 'auto' }}>
{filteredSessions.length === 0 ? (
<Typography variant="caption" sx={{ textAlign: 'center', py: 4, display: 'block', color: 'text.secondary' }}>{searchQuery ? '无匹配结果' : '暂无会话'}</Typography>
) : filteredSessions.map((session) => (
<SessionItem key={session.id} session={session} isActive={session.id === currentSessionId} isAgentActive={session.id === currentSessionId && (agentStatus === 'thinking' || agentStatus === 'executing')} onClick={() => { setCurrentSession(session.id); loadSessionMessages(session.id); }} />
))}
</Box>
<Divider sx={{ mt: 'auto', mb: 1 }} />
<ToolManagerPanel />
<MemorySearchPanel />
</Box>
</Box>
);
}
function SessionItem({ session, isActive, isAgentActive, onClick }: { session: Session; isActive: boolean; isAgentActive: boolean; onClick: () => void }) {
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const handleDelete = (e: React.MouseEvent) => {
e.stopPropagation();
setShowDeleteDialog(true);
};
const confirmDelete = () => {
window.metona?.sessions.delete(session.id).catch(() => {});
useSessionStore.getState().removeSession(session.id);
setShowDeleteDialog(false);
};
return (
<>
<ListItemButton onClick={onClick} selected={isActive} dense sx={{ borderRadius: 1.5, mb: 0.25, px: 1.5, py: 0.75, borderRight: isActive ? '2px solid' : '2px solid transparent', borderColor: isActive ? 'primary.main' : 'transparent', '&:hover .delete-btn': { opacity: 1 } }}>
<ListItemIcon sx={{ minWidth: 24 }}>
{session.pinned ? <Pin size={10} style={{ color: '#818cf8' }} /> : isAgentActive ? <Badge color="success" variant="dot" sx={{ '& .MuiBadge-dot': { animation: 'pulse 2s infinite', width: 8, height: 8 } }}><MessageSquare size={12} style={{ color: '#8b8fa7' }} /></Badge> : <MessageSquare size={12} style={{ color: '#8b8fa7' }} />}
</ListItemIcon>
<ListItemText primary={<Typography variant="body2" sx={{ fontSize: 12, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: isActive ? 'text.primary' : 'text.secondary' }}>{session.title}</Typography>}
secondary={<Typography variant="caption" sx={{ fontSize: 10 }}>{formatRelativeTime(session.updatedAt)}{session.messageCount > 0 ? ` · ${session.messageCount}` : ''}</Typography>}
/>
<IconButton
className="delete-btn"
size="small"
onClick={handleDelete}
sx={{ opacity: 0, transition: 'opacity 150ms', color: 'text.disabled', '&:hover': { color: 'error.main' }, width: 20, height: 20 }}
>
<Trash2 size={12} />
</IconButton>
</ListItemButton>
<Dialog open={showDeleteDialog} onClose={() => setShowDeleteDialog(false)} maxWidth="xs" fullWidth>
<DialogTitle sx={{ fontSize: 14 }}></DialogTitle>
<DialogContent>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{session.title}
</Typography>
</DialogContent>
<DialogActions>
<Button size="small" onClick={() => setShowDeleteDialog(false)} sx={{ color: 'text.secondary' }}></Button>
<Button size="small" color="error" variant="contained" onClick={confirmDelete}></Button>
</DialogActions>
</Dialog>
</>
);
}
function ToolManagerPanel() {
const [expanded, setExpanded] = useState(false);
const tools = [
{ name: 'read_file', label: '读取文件', risk: 'SAFE' }, { name: 'write_file', label: '写入文件', risk: 'MEDIUM' },
{ name: 'list_directory', label: '列出目录', risk: 'SAFE' }, { name: 'search_files', label: '搜索文件', risk: 'SAFE' },
{ name: 'web_search', label: '网络搜索', risk: 'LOW' }, { name: 'web_extract', label: '网页抓取', risk: 'LOW' },
{ name: 'memory_store', label: '存储记忆', risk: 'MEDIUM' }, { name: 'memory_search', label: '搜索记忆', risk: 'SAFE' },
{ name: 'run_command', label: '执行命令', risk: 'HIGH' },
];
const riskColors: Record<string, string> = { SAFE: 'success.main', LOW: 'info.main', MEDIUM: 'warning.main', HIGH: 'error.main' };
return (
<Box>
<ListItemButton onClick={() => setExpanded(!expanded)} dense sx={{ borderRadius: 1, px: 1.5, py: 0.75 }}>
<ListItemIcon sx={{ minWidth: 24 }}>{expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}<Wrench size={12} style={{ marginLeft: 4 }} /></ListItemIcon>
<ListItemText primary={<Typography variant="body2" sx={{ fontSize: 12 }}></Typography>} />
<Typography variant="caption" sx={{ color: 'success.main', fontSize: 10 }}>9 </Typography>
</ListItemButton>
<Collapse in={expanded}>
<List dense disablePadding sx={{ pl: 3 }}>
{tools.map((t) => (
<ListItemButton key={t.name} dense sx={{ py: 0.25, px: 1, borderRadius: 1 }}>
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: riskColors[t.risk], mr: 1, flexShrink: 0 }} />
<Typography variant="caption" sx={{ flex: 1, fontSize: 11, color: 'text.secondary' }}>{t.label}</Typography>
<Typography variant="caption" sx={{ fontSize: 9, color: riskColors[t.risk] }}>{t.risk}</Typography>
</ListItemButton>
))}
</List>
</Collapse>
</Box>
);
}
function MemorySearchPanel() {
const [expanded, setExpanded] = useState(false);
const [query, setQuery] = useState('');
const [results, setResults] = useState<Array<{ id: string; type: string; content: string; importance: number }>>([]);
const [searching, setSearching] = useState(false);
const handleSearch = async () => { if (!query.trim() || !window.metona?.memory?.search) return; setSearching(true); try { setResults((await window.metona.memory.search(query, { topK: 5 })) as any); } catch { setResults([]); } setSearching(false); };
return (
<Box>
<ListItemButton onClick={() => setExpanded(!expanded)} dense sx={{ borderRadius: 1, px: 1.5, py: 0.75 }}>
<ListItemIcon sx={{ minWidth: 24 }}>{expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}<Database size={12} style={{ marginLeft: 4 }} /></ListItemIcon>
<ListItemText primary={<Typography variant="body2" sx={{ fontSize: 12 }}></Typography>} />
</ListItemButton>
<Collapse in={expanded}>
<Stack spacing={1} sx={{ pl: 3, pr: 1, pb: 1 }}>
<Stack direction="row" spacing={0.5}>
<TextField size="small" value={query} onChange={(e) => setQuery(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') handleSearch(); }} placeholder="搜索记忆..." sx={{ flex: 1, '& .MuiInputBase-root': { fontSize: 11, height: 28 } }} />
<Button variant="outlined" size="small" onClick={handleSearch} disabled={searching} sx={{ minWidth: 40, height: 28, fontSize: 10 }}>{searching ? '...' : '搜索'}</Button>
</Stack>
{results.length > 0 && (
<Box sx={{ maxHeight: 200, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{results.map((r) => (
<Box key={r.id} sx={{ px: 1, py: 0.75, borderRadius: 1, bgcolor: 'background.default', fontSize: 10, color: 'text.secondary' }}>
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ mb: 0.25 }}>
<Box sx={{ px: 0.5, borderRadius: 0.5, bgcolor: 'action.hover', color: 'primary.main', fontSize: 9 }}>{r.type}</Box>
<Typography variant="caption" sx={{ fontSize: 9 }}>: {r.importance.toFixed(1)}</Typography>
</Stack>
<Typography variant="caption" sx={{ fontSize: 10, display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.content.slice(0, 100)}</Typography>
</Box>
))}
</Box>
)}
</Stack>
</Collapse>
</Box>
);
}
+54
View File
@@ -0,0 +1,54 @@
/**
* StatusBar — 底部状态栏
*
* 显示 Agent 状态指示灯、Provider 名称、Token 统计、版本号。
*/
import { Box, Typography, Stack, Chip } from '@mui/material';
import { useAgentStore } from '@renderer/stores/agent-store';
import { AGENT_STATUS_COLORS, AGENT_STATUS_LABELS, PROVIDER_LABELS } from '@renderer/lib/constants';
import { formatTokens } from '@renderer/lib/formatters';
export function StatusBar(): React.JSX.Element {
const agentStatus = useAgentStore((s) => s.agentStatus);
const provider = useAgentStore((s) => s.provider);
const model = useAgentStore((s) => s.model);
const tokenUsage = useAgentStore((s) => s.tokenUsage);
return (
<Box
component="footer"
sx={{
flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between',
px: 2, height: 24, bgcolor: 'background.paper', borderTop: 1, borderColor: 'divider', userSelect: 'none',
}}
>
<Stack direction="row" spacing={1.5} alignItems="center">
<Stack direction="row" spacing={0.75} alignItems="center">
<Box
sx={{
width: 8, height: 8, borderRadius: '50%', bgcolor: AGENT_STATUS_COLORS[agentStatus],
animation: (agentStatus === 'thinking' || agentStatus === 'executing') ? 'pulse 2s infinite' : 'none',
}}
/>
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: 11 }}>
{AGENT_STATUS_LABELS[agentStatus]}
</Typography>
</Stack>
<Typography variant="caption" sx={{ color: 'divider' }}>|</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: 11 }}>
{PROVIDER_LABELS[provider] ?? provider} {model}
</Typography>
</Stack>
<Stack direction="row" spacing={1.5} alignItems="center">
{tokenUsage.totalTokens > 0 && (
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: 11 }}>
Tokens: {formatTokens(tokenUsage.inputTokens)} {formatTokens(tokenUsage.outputTokens)}
</Typography>
)}
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: 11 }}>MetonaAI v0.1.0</Typography>
</Stack>
</Box>
);
}
@@ -0,0 +1,117 @@
/**
* OnboardingWizard — 首次使用引导向导
*/
import { useState } from 'react';
import { Dialog, DialogContent, Button, TextField, Select, MenuItem, Stepper, Step, StepLabel, Box, Typography, Stack, FormControl, InputLabel, IconButton, InputAdornment } from '@mui/material';
import { ArrowRight, ArrowLeft, FolderOpen, Play, CheckCircle, Eye, EyeOff } from 'lucide-react';
import { useUIStore } from '@renderer/stores/ui-store';
import { useAgentStore } from '@renderer/stores/agent-store';
const STEPS = ['欢迎', '配置 LLM', '自定义 Agent', '工作空间', '开始使用'];
export function OnboardingWizard(): React.JSX.Element | null {
const onboardingCompleted = useUIStore((s) => s.onboardingCompleted);
const setOnboardingCompleted = useUIStore((s) => s.setOnboardingCompleted);
const [step, setStep] = useState(0);
const [provider, setProvider] = useState('');
const [baseURL, setBaseURL] = useState('');
const [model, setModel] = useState('');
const [apiKey, setApiKey] = useState('');
const [showKey, setShowKey] = useState(false);
const [workspacePath, setWorkspacePath] = useState('');
if (onboardingCompleted) return null;
const handleNext = () => {
if (step < STEPS.length - 1) { setStep(step + 1); return; }
if (window.metona?.config) {
if (provider.trim()) window.metona.config.set('llm.provider', provider.trim());
if (baseURL.trim()) window.metona.config.set('llm.baseURL', baseURL.trim());
if (model.trim()) window.metona.config.set('llm.model', model.trim());
if (apiKey.trim()) window.metona.config.set('llm.apiKey', apiKey.trim());
if (workspacePath.trim()) window.metona.config.set('workspace.path', workspacePath.trim());
useAgentStore.getState().setProvider(provider.trim() || 'deepseek', model.trim() || '');
}
window.metona?.config?.set('onboarding.completed', true); setOnboardingCompleted(true);
};
return (
<Dialog open maxWidth="sm" PaperProps={{ sx: { borderRadius: 3, overflow: 'hidden' } }}>
<Box sx={{ px: 3, pt: 2, pb: 0 }}>
<Stepper activeStep={step} alternativeLabel sx={{ '& .MuiStepLabel-label': { fontSize: 11 } }}>
{STEPS.map((s) => <Step key={s}><StepLabel>{s}</StepLabel></Step>)}
</Stepper>
</Box>
<DialogContent sx={{ minHeight: 280, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
{step === 0 && (
<Box sx={{ textAlign: 'center' }}>
<Box component="img" src="./assets/logo.png" alt="Metona" sx={{ width: 64, height: 64, mx: 'auto', mb: 2, borderRadius: 2 }} />
<Typography variant="h6" sx={{ mb: 1 }}>使 MetonaAI Desktop</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}> AI Agent MCP </Typography>
<Typography variant="caption"> 1 </Typography>
</Box>
)}
{step === 1 && (
<Box sx={{ width: '100%' }}>
<Typography variant="h6" sx={{ mb: 2 }}> LLM Provider</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}> Provider API Base URL </Typography>
<Stack spacing={2}>
<FormControl size="small"><InputLabel>Provider</InputLabel>
<Select value={provider} label="Provider" onChange={(e) => setProvider(e.target.value)}>
<MenuItem value="deepseek">DeepSeek</MenuItem>
<MenuItem value="agnes">Agnes AI</MenuItem>
<MenuItem value="ollama">Ollama ()</MenuItem>
</Select>
</FormControl>
<TextField size="small" label="API Base URL" value={baseURL} onChange={(e) => setBaseURL(e.target.value)} placeholder="如 https://api.deepseek.com" />
<TextField size="small" label="模型名称" value={model} onChange={(e) => setModel(e.target.value)} placeholder="如 deepseek-v4-pro、qwen3:latest" />
<TextField size="small" label="API Key" type={showKey ? 'text' : 'password'} value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="sk-...(本地模型可留空)"
slotProps={{ input: { endAdornment: <InputAdornment position="end"><IconButton size="small" onClick={() => setShowKey(!showKey)}>{showKey ? <EyeOff size={14} /> : <Eye size={14} />}</IconButton></InputAdornment> } }}
/>
</Stack>
</Box>
)}
{step === 2 && (
<Box sx={{ width: '100%' }}>
<Typography variant="h6" sx={{ mb: 2 }}> Agent</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}> SOUL.md USERS.md Agent </Typography>
<Box sx={{ px: 2, py: 1.5, borderRadius: 2, bgcolor: 'action.hover', border: '1px solid', borderColor: 'divider', fontSize: 12, color: 'text.secondary' }}>
<div><strong style={{ color: '#e1e4ed' }}>SOUL.md</strong> Agent </div>
<div><strong style={{ color: '#e1e4ed' }}>AGENTS.md</strong> 使</div>
<div><strong style={{ color: '#e1e4ed' }}>USERS.md</strong> </div>
<div style={{ marginTop: 8, fontSize: 11, opacity: 0.7 }}></div>
</Box>
</Box>
)}
{step === 3 && (
<Box sx={{ width: '100%' }}>
<Typography variant="h6" sx={{ mb: 2 }}></Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>使</Typography>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 2 }}>
<TextField size="small" value={workspacePath} onChange={(e) => setWorkspacePath(e.target.value)} placeholder="~/MetonaWorkspaces/default/" sx={{ flex: 1 }} />
<Button variant="outlined" size="small" onClick={async () => {
if (window.metona?.app?.selectFolder) { const r = await window.metona.app.selectFolder(workspacePath || undefined); if (!r.canceled && r.path) setWorkspacePath(r.path); }
}}></Button>
</Stack>
<Typography variant="caption" sx={{ color: 'text.secondary' }}> SOUL.mdAGENTS.mdMEMORY.mdUSERS.md </Typography>
</Box>
)}
{step === 4 && (
<Box sx={{ textAlign: 'center' }}>
<CheckCircle size={48} style={{ color: '#34d399', margin: '0 auto 16px' }} />
<Typography variant="h6" sx={{ mb: 1 }}></Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 1 }}>MetonaAI Desktop AI Agent </Typography>
<Typography variant="caption"> Ctrl+Enter / </Typography>
</Box>
)}
</DialogContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', px: 3, py: 1.5, borderTop: 1, borderColor: 'divider' }}>
<Button startIcon={<ArrowLeft size={12} />} onClick={() => setStep(step - 1)} disabled={step === 0} size="small" sx={{ color: 'text.secondary' }}></Button>
<Button variant="contained" endIcon={step < STEPS.length - 1 ? <ArrowRight size={12} /> : undefined} onClick={handleNext} size="small">
{step === STEPS.length - 1 ? '开始使用' : '下一步'}
</Button>
</Box>
</Dialog>
);
}
+257
View File
@@ -0,0 +1,257 @@
/**
* SettingsModal — 设置弹窗
*/
import { useState, useEffect, useCallback } from 'react';
import { Dialog, DialogContent, DialogTitle, Button, TextField, Select, MenuItem, Tabs, Tab, Slider, Checkbox, Box, Typography, Stack, Divider, FormControlLabel, InputLabel, FormControl, IconButton, Chip } from '@mui/material';
import { X, Settings, Bot, Wrench, Server, Palette, FileText, Eye, EyeOff, FolderOpen } from 'lucide-react';
import { useUIStore, type ThemeMode } from '@renderer/stores/ui-store';
import { useAgentStore } from '@renderer/stores/agent-store';
type SettingsTab = 'workspace' | 'llm' | 'agent' | 'tools' | 'mcp' | 'appearance' | 'logs';
const TABS: { id: SettingsTab; label: string; icon: typeof Settings }[] = [
{ id: 'workspace', label: '工作空间', icon: FolderOpen },
{ id: 'llm', label: 'LLM 配置', icon: Bot }, { id: 'agent', label: 'Agent 配置', icon: Settings },
{ id: 'tools', label: '工具管理', icon: Wrench }, { id: 'mcp', label: 'MCP 服务', icon: Server },
{ id: 'appearance', label: '外观', icon: Palette }, { id: 'logs', label: '日志与数据', icon: FileText },
];
export function SettingsModal(): React.JSX.Element | null {
const settingsOpen = useUIStore((s) => s.settingsOpen);
const closeSettings = useUIStore((s) => s.closeSettings);
const theme = useUIStore((s) => s.theme);
const setTheme = useUIStore((s) => s.setTheme);
const [tab, setTab] = useState<SettingsTab>('llm');
if (!settingsOpen) return null;
return (
<Dialog open onClose={closeSettings} maxWidth="md" fullWidth PaperProps={{ sx: { maxWidth: 640, height: '80vh', borderRadius: 3, display: 'flex', flexDirection: 'column' } }}>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ px: 2.5, py: 1.5, borderBottom: 1, borderColor: 'divider' }}>
<Typography variant="h6"></Typography>
<IconButton size="small" onClick={closeSettings}><X size={14} /></IconButton>
</Stack>
<Box sx={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
<Tabs orientation="vertical" value={tab} onChange={(_, v) => setTab(v)}
sx={{
borderRight: 1, borderColor: 'divider', minWidth: 130,
'& .MuiTab-root': {
alignItems: 'center', fontSize: 12,
textTransform: 'none', gap: '2px', px: 2, justifyContent: 'flex-start',
minHeight: 40,
},
'& .MuiTab-iconWrapper': { display: 'flex', alignItems: 'center', marginRight: 0 },
}}
>
{TABS.map((t) => <Tab key={t.id} value={t.id} label={t.label} icon={<t.icon size={14} />} iconPosition="start" />)}
</Tabs>
<Box sx={{ flex: 1, overflowY: 'auto', p: 2.5 }}>
{tab === 'workspace' && <WorkspaceSettings />}
{tab === 'llm' && <LLMSettings />}
{tab === 'agent' && <AgentSettings />}
{tab === 'tools' && <ToolsSettings />}
{tab === 'mcp' && <MCPSettings />}
{tab === 'appearance' && <AppearanceSettings theme={theme} setTheme={setTheme} />}
{tab === 'logs' && <LogsSettings />}
</Box>
</Box>
</Dialog>
);
}
function useConfig<T>(key: string, defaultValue: T): [T, (v: T) => void] {
const [value, setValue] = useState<T>(defaultValue);
useEffect(() => { if (window.metona?.config?.get) window.metona.config.get(key).then((v) => { if (v != null) setValue(v as T); }).catch(() => {}); }, [key]);
const set = useCallback((v: T) => { setValue(v); window.metona?.config?.set(key, v).catch(() => {}); }, [key]);
return [value, set];
}
const PROVIDER_URLS: Record<string, string> = { deepseek: 'https://api.deepseek.com', agnes: 'https://apihub.agnes-ai.com/v1', ollama: 'http://localhost:11434' };
function WorkspaceSettings() {
const [workspacePath, setWorkspacePath] = useConfig('workspace.path', '');
const handleSelect = async () => {
if (!window.metona?.app?.selectFolder) return;
const r = await window.metona.app.selectFolder(workspacePath || undefined);
if (!r.canceled && r.path) setWorkspacePath(r.path);
};
const handleOpen = () => { if (workspacePath) window.metona?.app?.showItemInFolder(workspacePath); };
return (
<Stack spacing={2}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}></Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}> Metona SOUL.mdAGENTS.mdMEMORY.mdUSERS.md </Typography>
<Stack direction="row" spacing={1} alignItems="center">
<TextField size="small" value={workspacePath} onChange={(e) => setWorkspacePath(e.target.value)} placeholder="~/MetonaWorkspaces/default/" sx={{ flex: 1 }} />
<Button variant="outlined" size="small" onClick={handleSelect}></Button>
</Stack>
{workspacePath && (
<Button variant="text" size="small" onClick={handleOpen} sx={{ alignSelf: 'flex-start', fontSize: 11, color: 'text.secondary' }}>
📂
</Button>
)}
<Typography variant="caption" sx={{ color: 'text.disabled' }}></Typography>
</Stack>
);
}
function LLMSettings() {
const [provider, setProvider] = useConfig('llm.provider', '');
const [model, setModel] = useConfig('llm.model', '');
const [apiKey, setApiKey] = useConfig('llm.apiKey', '');
const [baseURL, setBaseURL] = useConfig('llm.baseURL', '');
const [showKey, setShowKey] = useState(false);
return (
<Stack spacing={2}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>LLM </Typography>
<FormControl size="small"><InputLabel>Provider</InputLabel>
<Select value={provider} label="Provider" onChange={(e) => setProvider(e.target.value)}>
<MenuItem value="deepseek">DeepSeek</MenuItem>
<MenuItem value="agnes">Agnes AI</MenuItem>
<MenuItem value="ollama">Ollama ()</MenuItem>
</Select>
</FormControl>
<TextField size="small" label="API Base URL" value={baseURL} onChange={(e) => setBaseURL(e.target.value)} placeholder="如 https://api.deepseek.com" />
<TextField size="small" label="模型名称" value={model} onChange={(e) => setModel(e.target.value)} placeholder="如 deepseek-v4-pro、qwen3:latest" />
<TextField size="small" label="API Key" type={showKey ? 'text' : 'password'} value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="sk-...(本地模型可留空)"
slotProps={{ input: { endAdornment: <IconButton size="small" onClick={() => setShowKey(!showKey)}>{showKey ? <EyeOff size={14} /> : <Eye size={14} />}</IconButton> } }}
/>
</Stack>
);
}
function AgentSettings() {
const [maxIter, setMaxIter] = useConfig('agent.maxIterations', 20);
const [timeout, setTimeout_] = useConfig('agent.totalTimeoutMs', 600000);
const [thinking, setThinking] = useConfig('agent.enableThinking', true);
const [thinkingEffort, setThinkingEffort] = useConfig('agent.thinkingEffort', 'high');
return (
<Stack spacing={2}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>Agent </Typography>
<Box>
<Stack direction="row" justifyContent="space-between"><Typography variant="caption" sx={{ color: 'text.secondary' }}></Typography><Typography variant="caption" sx={{ fontFamily: 'monospace' }}>{maxIter}</Typography></Stack>
<Slider value={maxIter} onChange={(_, v) => setMaxIter(v as number)} min={1} max={50} size="small" />
</Box>
<Box>
<Stack direction="row" justifyContent="space-between"><Typography variant="caption" sx={{ color: 'text.secondary' }}> ()</Typography><Typography variant="caption" sx={{ fontFamily: 'monospace' }}>{timeout / 1000}s</Typography></Stack>
<Slider value={timeout / 1000} onChange={(_, v) => setTimeout_((v as number) * 1000)} min={60} max={1800} step={60} size="small" />
</Box>
<FormControlLabel control={<Checkbox checked={thinking} onChange={(e) => setThinking(e.target.checked)} size="small" />} label={<Typography variant="body2"></Typography>} />
{thinking && (
<FormControl size="small"><InputLabel></InputLabel>
<Select value={thinkingEffort} label="思考强度" onChange={(e) => setThinkingEffort(e.target.value)}>
<MenuItem value="low">Low</MenuItem><MenuItem value="medium">Medium</MenuItem><MenuItem value="high">High</MenuItem><MenuItem value="max">Max</MenuItem>
</Select>
</FormControl>
)}
</Stack>
);
}
function ToolsSettings() {
const [tools, setTools] = useState<Array<{ name: string; description: string; riskLevel: string; enabled: boolean }>>([]);
useEffect(() => { if (window.metona?.tools?.list) window.metona.tools.list().then((l) => setTools((l as any[]).map((t) => ({ ...t, enabled: true })))).catch(() => {}); }, []);
const handleToggle = (name: string, enabled: boolean) => { setTools((p) => p.map((t) => t.name === name ? { ...t, enabled } : t)); window.metona?.tools?.toggle(name, enabled).catch(() => {}); };
const riskColors: Record<string, 'success' | 'info' | 'warning' | 'error'> = { safe: 'success', low: 'info', medium: 'warning', high: 'error' };
return (
<Stack spacing={2}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}></Typography>
{tools.length === 0 ? <Typography variant="caption" sx={{ textAlign: 'center', py: 4, color: 'text.secondary' }}>...</Typography> : tools.map((t) => (
<Stack key={t.name} direction="row" alignItems="center" justifyContent="space-between" sx={{ py: 1, px: 1.5, borderRadius: 1.5, bgcolor: 'secondary.main' }}>
<Stack direction="row" spacing={1} alignItems="center" sx={{ minWidth: 0, flex: 1 }}>
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: 12 }}>{t.name}</Typography>
<Typography variant="caption" sx={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{t.description.slice(0, 40)}</Typography>
</Stack>
<Stack direction="row" spacing={1} alignItems="center">
<Chip label={t.riskLevel.toUpperCase()} size="small" color={riskColors[t.riskLevel]} variant="outlined" sx={{ height: 18, fontSize: 9 }} />
<Checkbox checked={t.enabled} onChange={(e) => handleToggle(t.name, e.target.checked)} size="small" />
</Stack>
</Stack>
))}
</Stack>
);
}
function MCPSettings() {
const [servers, setServers] = useState<Array<{ name: string; status: string; toolCount: number; error?: string }>>([]);
const [showAdd, setShowAdd] = useState(false);
const [newName, setNewName] = useState('');
const [newCommand, setNewCommand] = useState('');
const [newArgs, setNewArgs] = useState('');
const loadServers = () => { if (window.metona?.mcp?.listServers) window.metona.mcp.listServers().then((l) => setServers(l as any[])).catch(() => {}); };
useEffect(() => { loadServers(); }, []);
const handleAdd = async () => { if (!newName.trim() || !newCommand.trim()) return; await window.metona?.mcp?.addServer({ name: newName.trim(), transport: 'stdio', command: newCommand.trim(), args: newArgs.trim() ? newArgs.trim().split(/\s+/) : [] }); setNewName(''); setNewCommand(''); setNewArgs(''); setShowAdd(false); loadServers(); };
const statusColors: Record<string, string> = { connected: 'success.main', connecting: 'warning.main', disconnected: 'text.secondary', error: 'error.main' };
return (
<Stack spacing={2}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>MCP </Typography>
{servers.length === 0 ? <Typography variant="caption" sx={{ textAlign: 'center', py: 4, color: 'text.secondary' }}> MCP </Typography> : servers.map((s) => (
<Stack key={s.name} direction="row" alignItems="center" justifyContent="space-between" sx={{ py: 1, px: 1.5, borderRadius: 1.5, bgcolor: 'secondary.main' }}>
<Stack direction="row" spacing={1} alignItems="center">
<Box sx={{ width: 8, height: 8, borderRadius: '50', bgcolor: statusColors[s.status] }} />
<Typography variant="body2" sx={{ fontWeight: 500 }}>{s.name}</Typography>
<Typography variant="caption" sx={{ color: statusColors[s.status] }}>{s.status}</Typography>
{s.toolCount > 0 && <Typography variant="caption" sx={{ color: 'text.secondary' }}>{s.toolCount} </Typography>}
</Stack>
<Stack direction="row" spacing={0.5}>
<Button size="small" sx={{ fontSize: 10, minWidth: 40 }} onClick={async () => { await window.metona?.mcp?.toggleServer(s.name, s.status !== 'connected'); loadServers(); }}>{s.status === 'connected' ? '断开' : '连接'}</Button>
<Button size="small" color="error" sx={{ fontSize: 10, minWidth: 40 }} onClick={async () => { if (confirm(`确定移除 "${s.name}"`)) { await window.metona?.mcp?.removeServer(s.name); loadServers(); } }}></Button>
</Stack>
</Stack>
))}
{showAdd ? (
<Stack spacing={1} sx={{ p: 1.5, borderRadius: 1.5, bgcolor: 'secondary.main', border: '1px solid', borderColor: 'divider' }}>
<TextField size="small" value={newName} onChange={(e) => setNewName(e.target.value)} placeholder="服务名称" />
<TextField size="small" value={newCommand} onChange={(e) => setNewCommand(e.target.value)} placeholder="命令路径" />
<TextField size="small" value={newArgs} onChange={(e) => setNewArgs(e.target.value)} placeholder="参数 (空格分隔)" />
<Stack direction="row" spacing={1}>
<Button variant="contained" size="small" onClick={handleAdd} disabled={!newName.trim() || !newCommand.trim()} sx={{ flex: 1 }}></Button>
<Button variant="outlined" size="small" onClick={() => setShowAdd(false)} sx={{ flex: 1 }}></Button>
</Stack>
</Stack>
) : <Button variant="outlined" fullWidth size="small" onClick={() => setShowAdd(true)}>+ MCP </Button>}
</Stack>
);
}
function AppearanceSettings({ theme, setTheme }: { theme: ThemeMode; setTheme: (t: ThemeMode) => void }) {
return (
<Stack spacing={2}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}></Typography>
<Box><Typography variant="caption" sx={{ color: 'text.secondary', mb: 1, display: 'block' }}></Typography>
<Stack direction="row" spacing={1}>
{(['dark', 'light', 'auto'] as ThemeMode[]).map((t) => <Button key={t} variant={theme === t ? 'contained' : 'outlined'} size="small" onClick={() => setTheme(t)}>{t === 'dark' ? '深色' : t === 'light' ? '浅色' : '跟随系统'}</Button>)}
</Stack>
</Box>
</Stack>
);
}
function LogsSettings() {
const [logLevel, setLogLevel] = useConfig('logging.level', 'info');
const [clearing, setClearing] = useState<string | null>(null);
const handleExport = async () => { if (!window.metona?.data?.exportData) return; const r = await window.metona.data.exportData(); if (r.success && r.data) { const b = new Blob([JSON.stringify(r.data, null, 2)], { type: 'application/json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = `metona-export-${Date.now()}.json`; a.click(); } };
const handleClear = async (type: 'sessions' | 'memories' | 'auditLogs') => { const labels: Record<string, string> = { sessions: '所有会话', memories: '所有记忆', auditLogs: '审计日志' }; if (!confirm(`确定清理${labels[type]}`)) return; setClearing(type); try { let r; if (type === 'sessions') r = await window.metona?.data?.clearSessions(); else if (type === 'memories') r = await window.metona?.data?.clearMemories(); else r = await window.metona?.data?.clearAuditLogs(); if (r?.success) alert(`${labels[type]}已清理`); else alert(`失败: ${r?.error}`); } catch (e) { alert(`失败: ${(e as Error).message}`); } setClearing(null); };
return (
<Stack spacing={2}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}></Typography>
<FormControl size="small"><InputLabel></InputLabel>
<Select value={logLevel} label="日志级别" onChange={(e) => setLogLevel(e.target.value)}>
<MenuItem value="debug">DEBUG</MenuItem><MenuItem value="info">INFO</MenuItem><MenuItem value="warn">WARN</MenuItem><MenuItem value="error">ERROR</MenuItem>
</Select>
</FormControl>
<Divider />
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}></Typography>
<Button variant="outlined" fullWidth size="small" onClick={handleExport}>📦 JSON</Button>
<Button variant="outlined" fullWidth size="small" color="error" onClick={() => handleClear('sessions')} disabled={clearing !== null}>{clearing === 'sessions' ? '清理中...' : '🗑️ 清理所有会话'}</Button>
<Button variant="outlined" fullWidth size="small" color="error" onClick={() => handleClear('memories')} disabled={clearing !== null}>{clearing === 'memories' ? '清理中...' : '🗑️ 清理所有记忆'}</Button>
<Button variant="outlined" fullWidth size="small" color="error" onClick={() => handleClear('auditLogs')} disabled={clearing !== null}>{clearing === 'auditLogs' ? '清理中...' : '🗑️ 清理审计日志'}</Button>
</Stack>
);
}
+82
View File
@@ -0,0 +1,82 @@
/**
* TokenUsage — Token 统计图表
*
* 进度条可视化 + 数值显示。
*/
import { Box, Typography, Stack, LinearProgress } from '@mui/material';
import { Zap } from 'lucide-react';
import { useAgentStore } from '@renderer/stores/agent-store';
import { formatTokens } from '@renderer/lib/formatters';
import { cn } from '@renderer/lib/cn';
export function TokenUsage(): React.JSX.Element {
const tokenUsage = useAgentStore((s) => s.tokenUsage);
const maxIterations = useAgentStore((s) => s.maxIterations);
const currentIteration = useAgentStore((s) => s.currentIteration);
if (tokenUsage.totalTokens === 0) {
return (
<Box sx={{ mt: 2, pt: 2, borderTop: 1, borderColor: 'divider' }}>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1.5 }}>
<Zap size={14} style={{ color: '#fbbf24' }} />
<Typography variant="caption" sx={{ fontWeight: 600, textTransform: 'uppercase', letterSpacing: 1, color: 'text.secondary' }}>Token </Typography>
</Stack>
<Typography variant="caption" sx={{ textAlign: 'center', py: 1, display: 'block', color: 'text.disabled' }}> Agent ...</Typography>
</Box>
);
}
const maxTokens = 128_000;
const usagePercent = Math.min((tokenUsage.totalTokens / maxTokens) * 100, 100);
const inputPercent = tokenUsage.totalTokens > 0 ? (tokenUsage.inputTokens / tokenUsage.totalTokens) * 100 : 0;
return (
<Box sx={{ mt: 2, pt: 2, borderTop: 1, borderColor: 'divider' }}>
{/* 标题 */}
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1.5 }}>
<Zap size={14} style={{ color: '#fbbf24' }} />
<Typography variant="caption" sx={{ fontWeight: 600, textTransform: 'uppercase', letterSpacing: 1, color: 'text.secondary' }}>Token </Typography>
</Stack>
{/* 进度条 */}
<Box sx={{ width: '100%', height: 6, borderRadius: 3, overflow: 'hidden', mb: 2, bgcolor: 'action.hover', display: 'flex' }}>
<Box sx={{ height: '100%', width: `${inputPercent}%`, bgcolor: '#22d3ee', transition: 'width 300ms', borderRadius: '3px 0 0 3px' }} />
<Box sx={{ height: '100%', width: `${100 - inputPercent}%`, bgcolor: '#a855f7', transition: 'width 300ms', borderRadius: '0 3px 3px 0' }} />
</Box>
{/* 数值网格 */}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5, mb: 1.5 }}>
<StatCard label="输入" value={formatTokens(tokenUsage.inputTokens)} color="#22d3ee" />
<StatCard label="输出" value={formatTokens(tokenUsage.outputTokens)} color="#a855f7" />
</Box>
{/* 底部汇总 */}
<Stack spacing={0.5}>
<SummaryRow label="总计" value={formatTokens(tokenUsage.totalTokens)} highlight />
<SummaryRow label="上下文" value={`${usagePercent.toFixed(1)}%`}
color={usagePercent > 80 ? 'error.main' : usagePercent > 60 ? 'warning.main' : 'text.secondary'} />
<SummaryRow label="迭代" value={`${currentIteration} / ${maxIterations}`} />
</Stack>
</Box>
);
}
function StatCard({ label, value, color }: { label: string; value: string; color: string }) {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 1, borderRadius: 1.5, bgcolor: 'action.hover' }}>
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: color, flexShrink: 0 }} />
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: 11 }}>{label}</Typography>
<Typography variant="caption" sx={{ ml: 'auto', fontFamily: 'monospace', fontWeight: 600, color: 'text.primary', fontSize: 12 }}>{value}</Typography>
</Box>
);
}
function SummaryRow({ label, value, highlight, color }: { label: string; value: string; highlight?: boolean; color?: string }) {
return (
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ py: 0.25 }}>
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: 11 }}>{label}</Typography>
<Typography variant="caption" sx={{ fontFamily: 'monospace', fontWeight: highlight ? 600 : 400, color: color ?? (highlight ? 'text.primary' : 'text.secondary'), fontSize: highlight ? 12 : 11 }}>{value}</Typography>
</Stack>
);
}
+48
View File
@@ -0,0 +1,48 @@
/**
* TraceStep — 单个 Trace 步骤
*/
import { useState } from 'react';
import { Box, Typography, IconButton, Collapse, Stack } from '@mui/material';
import { ChevronDown, ChevronRight, CircleDot, CheckCircle, Loader2 } from 'lucide-react';
import { TRACE_STATE_COLORS, TRACE_STATE_LABELS } from '@renderer/lib/constants';
import { formatDuration, formatTokens } from '@renderer/lib/formatters';
import type { TraceStep as TraceStepType } from '@renderer/stores/agent-store';
interface TraceStepProps { step: TraceStepType; isCurrent?: boolean; }
export function TraceStep({ step, isCurrent }: TraceStepProps): React.JSX.Element {
const [expanded, setExpanded] = useState(false);
const color = TRACE_STATE_COLORS[step.state] ?? '#8b8fa7';
const label = TRACE_STATE_LABELS[step.state] ?? step.state;
const duration = step.completedAt ? step.completedAt - step.startedAt : null;
return (
<Box sx={{ borderRadius: 1, border: '1px solid', borderColor: 'divider', bgcolor: 'secondary.main', transition: 'all 150ms', ...(isCurrent ? { boxShadow: `0 0 0 1px ${color}` } : {}) }}>
<IconButton size="small" onClick={() => setExpanded(!expanded)} sx={{ width: '100%', justifyContent: 'flex-start', gap: 1, px: 1.5, py: 1, borderRadius: '4px 4px 0 0', color: 'text.primary', fontSize: 12 }}>
{expanded ? <ChevronDown size={10} /> : <ChevronRight size={10} />}
{isCurrent ? <Loader2 size={12} style={{ color, animation: 'spin 1s linear infinite' }} /> : step.completedAt ? <CheckCircle size={12} style={{ color }} /> : <CircleDot size={12} style={{ color }} />}
<Typography component="span" sx={{ fontWeight: 600, color, fontSize: 12 }}>#{step.iteration}</Typography>
<Typography component="span" sx={{ textTransform: 'uppercase', color: 'text.secondary', fontSize: 12 }}>{label}</Typography>
{duration != null && <Typography variant="caption" sx={{ ml: 'auto', color: 'text.secondary' }}>{formatDuration(duration)}</Typography>}
{step.tokenUsage && <Typography variant="caption" sx={{ color: 'text.secondary' }}>{formatTokens(step.tokenUsage.totalTokens)} tok</Typography>}
</IconButton>
<Collapse in={expanded}>
<Box sx={{ px: 1.5, pb: 1.5, borderTop: 1, borderColor: 'divider' }}>
{step.thought && (
<Box sx={{ mt: 1 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: 'warning.main', fontSize: 10 }}>💭 Thought</Typography>
<Box component="pre" sx={{ fontSize: 11, whiteSpace: 'pre-wrap', color: 'text.secondary', fontFamily: "'SF Mono',monospace", m: 0 }}>{step.thought}</Box>
</Box>
)}
{step.toolCalls?.map((tc) => (
<Box key={tc.id} sx={{ mt: 1 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#a855f7', fontSize: 10 }}>🔧 {tc.name}</Typography>
<Box component="pre" sx={{ fontSize: 10, overflowX: 'auto', color: 'text.secondary', fontFamily: "'SF Mono',monospace", m: 0 }}>{JSON.stringify(tc.args, null, 2)}</Box>
</Box>
))}
</Box>
</Collapse>
</Box>
);
}
+47
View File
@@ -0,0 +1,47 @@
/**
* TraceViewer — Trace 时间轴查看器
*/
import { Box, Typography, Stack } from '@mui/material';
import { Activity } from 'lucide-react';
import { useAgentStore } from '@renderer/stores/agent-store';
import { TraceStep } from './TraceStep';
import { formatTokens } from '@renderer/lib/formatters';
export function TraceViewer(): React.JSX.Element {
const traceSteps = useAgentStore((s) => s.traceSteps);
const tokenUsage = useAgentStore((s) => s.tokenUsage);
const agentStatus = useAgentStore((s) => s.agentStatus);
const provider = useAgentStore((s) => s.provider);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1.5 }}>
<Activity size={14} style={{ color: '#818cf8' }} />
<Typography variant="caption" sx={{ fontWeight: 600, textTransform: 'uppercase', letterSpacing: 1, color: 'text.secondary' }}>
Trace Viewer
</Typography>
</Stack>
<Box sx={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{traceSteps.length === 0 ? (
<Typography variant="caption" sx={{ textAlign: 'center', py: 2, color: 'text.secondary' }}> Agent ...</Typography>
) : traceSteps.map((step, i) => (
<TraceStep key={`${step.iteration}-${step.state}-${i}`} step={step} isCurrent={i === traceSteps.length - 1 && agentStatus !== 'idle'} />
))}
</Box>
{tokenUsage.totalTokens > 0 && (
<Box sx={{ mt: 1.5, pt: 1.5, borderTop: 1, borderColor: 'divider', fontSize: 11, color: 'text.secondary' }}>
<Stack direction="row" justifyContent="space-between">
<span>Token:</span>
<span> {formatTokens(tokenUsage.inputTokens)} | {formatTokens(tokenUsage.outputTokens)} | {formatTokens(tokenUsage.totalTokens)}</span>
</Stack>
<Stack direction="row" justifyContent="space-between">
<span>Provider:</span><span>{provider}</span>
</Stack>
</Box>
)}
</Box>
);
}
+220
View File
@@ -0,0 +1,220 @@
/**
* useAgentStream — Agent 流式事件监听 Hook
*
* 监听 window.metona.agent.onStreamEvent,将事件分发到 Zustand Store。
* 负责流式文本追加、工具调用状态更新、思考内容处理。
*/
import { useEffect, useRef } from 'react';
import { useAgentStore, type ToolCallInfo } from '@renderer/stores/agent-store';
import { useSessionStore } from '@renderer/stores/session-store';
/**
* Agent 流式事件监听 Hook
*
* 在 App 根组件调用一次,自动监听当前会话的流式事件。
*/
export function useAgentStream(): void {
const cleanupRef = useRef<(() => void) | null>(null);
useEffect(() => {
// 检查 IPC 桥是否可用
if (!window.metona?.agent?.onStreamEvent) return;
const unsubscribe = window.metona.agent.onStreamEvent((event: unknown) => {
const data = event as {
type?: string;
requestId?: string;
sessionId?: string;
iteration?: number;
delta?: string;
content?: string;
toolCall?: { id: string; name: string; args: Record<string, unknown> };
toolResult?: { toolCallId: string; success: boolean; result?: unknown; error?: string; durationMs?: number };
usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number };
error?: { code: string; message: string };
state?: string;
};
const store = useAgentStore.getState();
switch (data.type) {
// 思考开始
case 'thinking_start':
store.setAgentStatus('thinking');
store.addTraceStep({
iteration: data.iteration ?? store.currentIteration + 1,
state: 'THINKING',
startedAt: Date.now(),
});
break;
// 推理内容增量
case 'reasoning_delta':
if (data.delta) {
const messages = store.messages;
const lastMsg = messages[messages.length - 1];
if (lastMsg?.role === 'assistant') {
// 追加到最后一条 assistant 消息
store.updateMessage(lastMsg.id, {
reasoningContent: (lastMsg.reasoningContent ?? '') + data.delta,
});
} else {
// 还没有 assistant 消息,先创建一条(仅含思考内容)
store.addMessage({
id: `msg_${Date.now()}_assistant`,
role: 'assistant',
content: '',
reasoningContent: data.delta,
timestamp: Date.now(),
});
}
}
break;
// 思考结束
case 'thinking_end':
if (data.iteration) {
store.updateTraceStep(data.iteration, { completedAt: Date.now() });
}
break;
// 文本增量
case 'text_delta':
if (data.delta) {
store.setStreaming(true);
store.updateLastAssistantMessage(data.delta);
}
break;
// 工具调用完成
case 'tool_call_complete':
if (data.toolCall) {
const tc: ToolCallInfo = {
id: data.toolCall.id,
name: data.toolCall.name,
args: data.toolCall.args,
status: 'pending',
};
// 追加到当前 assistant 消息
const messages = store.messages;
const lastMsg = messages[messages.length - 1];
if (lastMsg?.role === 'assistant') {
store.updateMessage(lastMsg.id, {
toolCalls: [...(lastMsg.toolCalls ?? []), tc],
});
}
store.setAgentStatus('executing');
store.addTraceStep({
iteration: data.iteration ?? store.currentIteration,
state: 'EXECUTING',
startedAt: Date.now(),
toolCalls: [tc],
});
}
break;
// 工具执行结果
case 'tool_result': {
if (data.toolResult) {
const messages = store.messages;
const lastMsg = messages[messages.length - 1];
if (lastMsg?.toolCalls) {
const updatedToolCalls = lastMsg.toolCalls.map((tc) =>
tc.id === data.toolResult!.toolCallId
? {
...tc,
status: data.toolResult!.success ? 'success' as const : 'error' as const,
result: data.toolResult!.result,
error: data.toolResult!.error,
durationMs: data.toolResult!.durationMs,
}
: tc,
);
store.updateMessage(lastMsg.id, { toolCalls: updatedToolCalls });
}
}
break;
}
// Token 使用统计
case 'usage':
if (data.usage) {
store.updateTokenUsage({
inputTokens: data.usage.inputTokens ?? 0,
outputTokens: data.usage.outputTokens ?? 0,
totalTokens: data.usage.totalTokens ?? 0,
});
}
break;
// 流结束
case 'done':
store.setStreaming(false);
store.setAgentStatus('idle');
// 保存 trace 数据到数据库
store.saveTraceData();
break;
// 错误
case 'error':
store.setStreaming(false);
store.setAgentStatus('error');
store.addMessage({
id: `msg_${Date.now()}_error`,
role: 'system',
content: `错误: ${data.error?.message ?? '未知错误'}`,
timestamp: Date.now(),
});
break;
// 状态变化
case 'state_change':
if (data.state) {
store.addTraceStep({
iteration: data.iteration ?? store.currentIteration,
state: data.state,
startedAt: Date.now(),
});
}
break;
}
});
cleanupRef.current = unsubscribe;
return () => {
cleanupRef.current?.();
};
}, []);
// 监听状态变化事件
useEffect(() => {
if (!window.metona?.agent?.onStateChange) return;
const unsubscribe = window.metona.agent.onStateChange((state: unknown) => {
// 状态变化由主进程推送,UI 已通过 streamEvent 处理
});
return () => unsubscribe();
}, []);
// 监听 Provider 切换通知
useEffect(() => {
if (!window.metona?.agent?.onProviderSwitched) return;
const unsubscribe = window.metona.agent.onProviderSwitched((data: unknown) => {
const { from, to, reason } = data as { from?: string; to?: string; reason?: string };
useAgentStore.getState().addMessage({
id: `msg_${Date.now()}_system`,
role: 'system',
content: `Provider 已切换: ${from ?? '未知'}${to ?? '未知'}${reason ? ` (${reason})` : ''}`,
timestamp: Date.now(),
});
});
return () => unsubscribe();
}, []);
}
+136
View File
@@ -0,0 +1,136 @@
/**
* useKeyboardShortcuts — 全局快捷键 Hook
*
* 注册全局键盘快捷键,处理 Ctrl/Cmd 修饰键。
*/
import { useEffect } from 'react';
import { useUIStore } from '@renderer/stores/ui-store';
import { useAgentStore } from '@renderer/stores/agent-store';
import { useSessionStore } from '@renderer/stores/session-store';
import { toggleTheme } from './useTheme';
/**
* 检测是否为 macOS
*/
function isMac(): boolean {
return navigator.platform.toUpperCase().indexOf('MAC') >= 0;
}
/**
* 检查修饰键是否匹配
*/
function matchModifier(event: KeyboardEvent, ctrl?: boolean, shift?: boolean): boolean {
const modKey = isMac() ? event.metaKey : event.ctrlKey;
if (ctrl && !modKey) return false;
if (!ctrl && modKey) return false;
if (shift && !event.shiftKey) return false;
if (!shift && event.shiftKey) return false;
return true;
}
/**
* 全局快捷键 Hook
*
* 在 App 根组件调用一次即可。
*/
export function useKeyboardShortcuts(): void {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent): void => {
const key = e.key.toLowerCase();
// Esc — 关闭弹窗
if (key === 'escape') {
const { settingsOpen, closeSettings } = useUIStore.getState();
if (settingsOpen) {
closeSettings();
e.preventDefault();
return;
}
}
// Ctrl+N — 新建会话
if (key === 'n' && matchModifier(e, true)) {
if (window.metona?.sessions?.create) {
window.metona.sessions.create().then((result: any) => {
useSessionStore.getState().addSession(result);
useSessionStore.getState().setCurrentSession(result.id);
}).catch(() => {});
}
e.preventDefault();
return;
}
// Ctrl+L — 聚焦输入框
if (key === 'l' && matchModifier(e, true)) {
const input = document.querySelector<HTMLTextAreaElement>('[data-chat-input]');
input?.focus();
e.preventDefault();
return;
}
// Ctrl+D — 切换主题
if (key === 'd' && matchModifier(e, true)) {
toggleTheme();
e.preventDefault();
return;
}
// Ctrl+, — 打开设置
if (key === ',' && matchModifier(e, true)) {
useUIStore.getState().openSettings();
e.preventDefault();
return;
}
// Ctrl+. — 中断 Agent
if (key === '.' && matchModifier(e, true)) {
useAgentStore.getState().abort();
e.preventDefault();
return;
}
// Ctrl+K — 快速搜索(CommandPalette 自身监听此快捷键)
if (key === 'k' && matchModifier(e, true)) {
e.preventDefault();
return;
}
// Ctrl+[ — 上一个会话
if (key === '[' && matchModifier(e, true)) {
const { sessions, currentSessionId, setCurrentSession } = useSessionStore.getState();
if (sessions.length > 0 && currentSessionId) {
const idx = sessions.findIndex((s) => s.id === currentSessionId);
if (idx > 0) setCurrentSession(sessions[idx - 1].id);
}
e.preventDefault();
return;
}
// Ctrl+] — 下一个会话
if (key === ']' && matchModifier(e, true)) {
const { sessions, currentSessionId, setCurrentSession } = useSessionStore.getState();
if (sessions.length > 0 && currentSessionId) {
const idx = sessions.findIndex((s) => s.id === currentSessionId);
if (idx < sessions.length - 1) setCurrentSession(sessions[idx + 1].id);
}
e.preventDefault();
return;
}
// Ctrl+Shift+C — 复制最后一条 Agent 回复
if (key === 'c' && matchModifier(e, true, true)) {
const { messages } = useAgentStore.getState();
const lastAssistant = [...messages].reverse().find((m) => m.role === 'assistant');
if (lastAssistant?.content) {
navigator.clipboard.writeText(lastAssistant.content).catch(() => {});
}
e.preventDefault();
return;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
}
+73
View File
@@ -0,0 +1,73 @@
/**
* useTheme — 主题切换 Hook
*
* 管理主题切换逻辑:监听系统 prefers-color-scheme,同步 data-theme 属性。
* 支持 dark / light / auto 三种模式。
*/
import { useEffect } from 'react';
import { useUIStore, type ThemeMode } from '@renderer/stores/ui-store';
/**
* 应用主题到 DOM
*/
function applyTheme(resolved: 'dark' | 'light'): void {
document.documentElement.setAttribute('data-theme', resolved);
}
/**
* 解析系统主题
*/
function getSystemTheme(): 'dark' | 'light' {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
/**
* 主题切换 Hook
*
* 在 App 根组件调用一次即可。
*/
export function useTheme(): void {
const theme = useUIStore((s) => s.theme);
const setResolvedTheme = useUIStore((s) => s.setResolvedTheme);
// 初始化:从 localStorage 恢复主题偏好
useEffect(() => {
const saved = localStorage.getItem('metona-theme') as ThemeMode | null;
if (saved && ['dark', 'light', 'auto'].includes(saved)) {
useUIStore.getState().setTheme(saved);
}
}, []);
// 监听系统主题变化
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (): void => {
if (theme === 'auto') {
const resolved = getSystemTheme();
applyTheme(resolved);
setResolvedTheme(resolved);
}
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [theme, setResolvedTheme]);
// 主题变化时应用
useEffect(() => {
const resolved = theme === 'auto' ? getSystemTheme() : theme;
applyTheme(resolved);
setResolvedTheme(resolved);
localStorage.setItem('metona-theme', theme);
}, [theme, setResolvedTheme]);
}
/**
* 切换主题(dark ↔ light
*/
export function toggleTheme(): void {
const { resolvedTheme, setTheme } = useUIStore.getState();
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
}
+7
View File
@@ -0,0 +1,7 @@
/**
* className 合并工具
*
* @see standard/开发规范.md — 第一铁律
*/
export { default as cn } from 'clsx';
+93
View File
@@ -0,0 +1,93 @@
/**
* 常量定义
*
* 快捷键映射、状态颜色、消息类型等全局常量。
*/
// ===== 布局尺寸 =====
export const LAYOUT = {
HEADER_HEIGHT: 40,
STATUS_BAR_HEIGHT: 24,
SIDEBAR_WIDTH: 260,
DETAIL_WIDTH: 320,
CHAT_MAX_WIDTH: 768,
} as const;
// ===== Agent 状态颜色 =====
export const AGENT_STATUS_COLORS = {
idle: 'var(--green)',
thinking: 'var(--amber)',
executing: 'var(--purple)',
error: 'var(--red)',
} as const;
export const AGENT_STATUS_LABELS = {
idle: 'Agent Ready',
thinking: '思考中...',
executing: '执行中...',
error: '错误',
} as const;
// ===== Trace 步骤状态 =====
export const TRACE_STATE_COLORS: Record<string, string> = {
INIT: 'var(--cyan)',
THINKING: 'var(--amber)',
PARSING: 'var(--orange)',
EXECUTING: 'var(--purple)',
OBSERVING: 'var(--cyan)',
REFLECTING: 'var(--purple)',
COMPRESSING: 'var(--amber)',
TERMINATED: 'var(--green)',
};
export const TRACE_STATE_LABELS: Record<string, string> = {
INIT: '初始化',
THINKING: '思考',
PARSING: '解析',
EXECUTING: '执行',
OBSERVING: '观察',
REFLECTING: '反思',
COMPRESSING: '压缩',
TERMINATED: '终止',
};
// ===== 工具调用状态 =====
export const TOOL_CALL_STATUS_COLORS = {
pending: 'var(--amber)',
executing: 'var(--purple)',
success: 'var(--green)',
error: 'var(--red)',
blocked: 'var(--orange)',
} as const;
// ===== 快捷键定义 =====
export const SHORTCUTS = {
NEW_SESSION: { key: 'n', ctrl: true },
QUICK_SEARCH: { key: 'k', ctrl: true },
SEND_MESSAGE: { key: 'Enter', ctrl: true },
NEW_LINE: { key: 'Enter', ctrl: true, shift: true },
ABORT: { key: '.', ctrl: true },
FOCUS_MODE: { key: 'f', ctrl: true, shift: true },
TOGGLE_SIDEBAR: { key: 'b', ctrl: true },
TOGGLE_DETAIL: { key: 'j', ctrl: true },
OPEN_SETTINGS: { key: ',', ctrl: true },
PREV_SESSION: { key: '[', ctrl: true },
NEXT_SESSION: { key: ']', ctrl: true },
CLOSE_POPUP: { key: 'Escape' },
FOCUS_INPUT: { key: 'l', ctrl: true },
COPY_LAST_REPLY: { key: 'c', ctrl: true, shift: true },
TOGGLE_THEME: { key: 'd', ctrl: true },
} as const;
// ===== Provider 信息 =====
export const PROVIDER_LABELS: Record<string, string> = {
deepseek: 'DeepSeek',
agnes: 'Agnes AI',
ollama: 'Ollama',
};
+58
View File
@@ -0,0 +1,58 @@
/**
* 格式化工具函数
*
* 日期格式化使用 date-fns(成熟第三方库,禁止自写)。
* Token 数、文件大小等简单格式化为 < 20 行纯函数,允许自写。
*
* @see standard/开发规范.md — 第一铁律
*/
import { formatDistanceToNow, format } from 'date-fns';
import { zhCN } from 'date-fns/locale';
// ===== 日期格式化(使用 date-fns=====
/** 相对时间(如 "3 分钟前"、"昨天" */
export function formatRelativeTime(timestamp: number): string {
return formatDistanceToNow(new Date(timestamp), { addSuffix: true, locale: zhCN });
}
/** 短时间格式(如 "14:30" */
export function formatTime(timestamp: number): string {
return format(new Date(timestamp), 'HH:mm');
}
// ===== Token 格式化(< 20 行纯函数,允许自写)=====
export function formatTokens(count: number): string {
if (count < 1000) return String(count);
if (count < 10000) return `${(count / 1000).toFixed(1)}K`;
if (count < 1000000) return `${Math.round(count / 1000)}K`;
return `${(count / 1000000).toFixed(1)}M`;
}
// ===== 文件大小格式化(< 20 行纯函数,允许自写)=====
export function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
// ===== 耗时格式化(< 20 行纯函数,允许自写)=====
export function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
const minutes = Math.floor(ms / 60000);
const seconds = Math.round((ms % 60000) / 1000);
return `${minutes}m${seconds}s`;
}
// ===== 截断文本(< 20 行纯函数,允许自写)=====
export function truncate(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return text.slice(0, maxLength - 1) + '…';
}
+177
View File
@@ -0,0 +1,177 @@
/**
* IPC Client — 渲染进程 IPC 封装
*
* 为渲染进程提供类型安全的 IPC 调用封装。
* 所有调用通过 window.metona 桥接层(由 preload.ts contextBridge 暴露)。
*
* @see electron/preload.ts — contextBridge 安全暴露
* @see src/types/global.d.ts — 类型声明
*/
import type { MetonaBridge, MetonaSessionInfo, MetonaStreamEventData, MetonaMCPServerStatus, MetonaMemorySearchResult } from '@renderer/types/global';
// ===== 桥接层访问 =====
function getBridge(): MetonaBridge {
if (!window.metona) {
throw new Error('Metona bridge not available. Ensure preload script is loaded.');
}
return window.metona;
}
// ===== Agent API =====
export const agentAPI = {
/**
* 发送用户消息到 Agent
*/
sendMessage(message: { role: 'user'; content: string; timestamp: number }, sessionId: string): Promise<{ success: boolean }> {
return getBridge().agent.sendMessage(message, sessionId);
},
/**
* 监听流式事件
* @returns 取消监听函数
*/
onStreamEvent(callback: (event: MetonaStreamEventData) => void): () => void {
return getBridge().agent.onStreamEvent(callback);
},
/**
* 监听状态变化
* @returns 取消监听函数
*/
onStateChange(callback: (state: { sessionId: string; state: string }) => void): () => void {
return getBridge().agent.onStateChange(callback);
},
/**
* 中断当前会话
*/
abortSession(sessionId: string): Promise<{ success: boolean }> {
return getBridge().agent.abortSession(sessionId);
},
/**
* 监听 Provider 切换通知
* @returns 取消监听函数
*/
onProviderSwitched(callback: (data: { from?: string; to?: string; reason?: string }) => void): () => void {
return getBridge().agent.onProviderSwitched(callback);
},
};
// ===== Sessions API =====
export const sessionsAPI = {
list(): Promise<MetonaSessionInfo[]> {
return getBridge().sessions.list();
},
create(title?: string): Promise<MetonaSessionInfo> {
return getBridge().sessions.create(title);
},
rename(sessionId: string, title: string): Promise<{ success: boolean }> {
return getBridge().sessions.rename(sessionId, title);
},
delete(sessionId: string): Promise<{ success: boolean }> {
return getBridge().sessions.delete(sessionId);
},
getMessages(sessionId: string): Promise<Array<{
id: string;
role: string;
content: string;
reasoningContent?: string;
toolCalls?: unknown[];
timestamp: number;
}>> {
return getBridge().sessions.getMessages(sessionId);
},
pin(sessionId: string, pinned: boolean): Promise<{ success: boolean }> {
return getBridge().sessions.pin(sessionId, pinned);
},
};
// ===== MCP API =====
export const mcpAPI = {
listServers(): Promise<MetonaMCPServerStatus[]> {
return getBridge().mcp.listServers();
},
addServer(config: { name: string; transport: 'stdio' | 'sse'; command?: string; args?: string[]; url?: string }): Promise<{ success: boolean }> {
return getBridge().mcp.addServer(config);
},
removeServer(name: string): Promise<{ success: boolean }> {
return getBridge().mcp.removeServer(name);
},
toggleServer(name: string, enabled: boolean): Promise<{ success: boolean }> {
return getBridge().mcp.toggleServer(name, enabled);
},
};
// ===== Memory API =====
export const memoryAPI = {
search(query: string, options?: {
topK?: number;
type?: 'episodic' | 'semantic' | 'working';
minImportance?: number;
}): Promise<MetonaMemorySearchResult[]> {
return getBridge().memory.search(query, options);
},
};
// ===== Config API =====
export const configAPI = {
get<T = unknown>(key: string): Promise<T | null> {
return getBridge().config.get(key) as Promise<T | null>;
},
set(key: string, value: unknown): Promise<{ success: boolean }> {
return getBridge().config.set(key, value);
},
};
// ===== App API =====
export const appAPI = {
getVersion(): Promise<string> {
return getBridge().app.getVersion();
},
getAppDataPath(): Promise<string> {
return getBridge().app.getAppDataPath();
},
openExternal(url: string): Promise<void> {
return getBridge().app.openExternal(url);
},
showItemInFolder(path: string): void {
getBridge().app.showItemInFolder(path);
},
};
// ===== Toast API =====
export const toastAPI = {
/**
* 监听主进程 Toast 通知桥接
* @returns 取消监听函数
*/
onShow(callback: (data: {
type: 'success' | 'error' | 'warning' | 'info';
message: string;
options?: Record<string, unknown>;
}) => void): () => void {
return getBridge().toast.onShow(callback);
},
};
+168
View File
@@ -0,0 +1,168 @@
/**
* MUI 主题配置
*
* 匹配 Metona 暗色/亮色配色方案。
* @see docs/MetonaAI-Desktop UI UX 设计集成方案.html — 主题系统
*/
import { createTheme, type ThemeOptions } from '@mui/material/styles';
const darkTheme: ThemeOptions = {
palette: {
mode: 'dark',
primary: { main: '#818cf8' },
secondary: { main: '#252836' },
error: { main: '#f87171' },
warning: { main: '#fbbf24' },
info: { main: '#22d3ee' },
success: { main: '#34d399' },
background: {
default: '#0f1117',
paper: '#1a1d27',
},
text: {
primary: '#e1e4ed',
secondary: '#64748b',
disabled: '#8b8fa7',
},
divider: '#2a2d3a',
},
typography: {
fontFamily: `-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif`,
fontSize: 13,
h6: { fontSize: 14, fontWeight: 600 },
body2: { fontSize: 12, color: '#8b8fa7' },
caption: { fontSize: 11, color: '#8b8fa7' },
},
shape: { borderRadius: 10 },
components: {
MuiButton: {
styleOverrides: {
root: { textTransform: 'none' as const, fontWeight: 500, fontSize: 12, borderRadius: 8 },
sizeSmall: { height: 28, padding: '0 12px' },
},
},
MuiTextField: {
styleOverrides: {
root: {
'& .MuiOutlinedInput-root': {
fontSize: 12,
borderRadius: 8,
backgroundColor: '#252836',
'& fieldset': { borderColor: '#2a2d3a' },
'&:hover fieldset': { borderColor: '#818cf8' },
'&.Mui-focused fieldset': { borderColor: '#818cf8' },
},
'& .MuiInputLabel-root': { fontSize: 12 },
},
},
},
MuiDialog: {
styleOverrides: {
paper: { backgroundImage: 'none', backgroundColor: '#1a1d27', border: '1px solid #2a2d3a' },
},
},
MuiSelect: {
styleOverrides: {
root: { fontSize: 12, borderRadius: 8, backgroundColor: '#252836' },
},
},
MuiTabs: {
styleOverrides: {
root: { minHeight: 36 },
indicator: { height: 2 },
},
},
MuiTab: {
styleOverrides: {
root: { textTransform: 'none' as const, fontSize: 12, minHeight: 36 },
},
},
MuiTooltip: {
styleOverrides: { tooltip: { fontSize: 11, backgroundColor: '#252836', border: '1px solid #2a2d3a' } },
},
MuiMenu: {
styleOverrides: { paper: { backgroundImage: 'none', backgroundColor: '#1a1d27', border: '1px solid #2a2d3a' } },
},
MuiMenuItem: {
styleOverrides: { root: { fontSize: 12 } },
},
MuiBadge: {
styleOverrides: { standard: { fontSize: 10 } },
},
MuiCard: {
styleOverrides: { root: { backgroundImage: 'none', backgroundColor: '#1a1d27', border: '1px solid #2a2d3a' } },
},
MuiSlider: {
styleOverrides: { root: { color: '#818cf8' } },
},
MuiSwitch: {
styleOverrides: { root: { '& .MuiSwitch-switchBase.Mui-checked': { color: '#818cf8' } } },
},
MuiCheckbox: {
styleOverrides: { root: { color: '#8b8fa7', '&.Mui-checked': { color: '#818cf8' } } },
},
MuiDivider: {
styleOverrides: { root: { borderColor: '#2a2d3a' } },
},
MuiPaper: {
styleOverrides: { root: { backgroundImage: 'none' } },
},
MuiChip: {
styleOverrides: { root: { fontSize: 10 } },
},
MuiIconButton: {
styleOverrides: { root: { borderRadius: 8 } },
},
MuiAvatar: {
styleOverrides: { root: { width: 32, height: 32, fontSize: 14 } },
},
},
};
const lightTheme: ThemeOptions = {
...darkTheme,
palette: {
mode: 'light',
primary: { main: '#6366f1' },
secondary: { main: '#f1f5f9' },
error: { main: '#dc2626' },
warning: { main: '#d97706' },
info: { main: '#0891b2' },
success: { main: '#059669' },
background: { default: '#ffffff', paper: '#f8fafc' },
text: { primary: '#0f172a', secondary: '#334155', disabled: '#64748b' },
divider: '#e2e8f0',
},
components: {
...darkTheme.components,
MuiTextField: {
styleOverrides: {
root: {
'& .MuiOutlinedInput-root': {
fontSize: 12, borderRadius: 8, backgroundColor: '#f1f5f9',
'& fieldset': { borderColor: '#e2e8f0' },
'&:hover fieldset': { borderColor: '#6366f1' },
'&.Mui-focused fieldset': { borderColor: '#6366f1' },
},
'& .MuiInputLabel-root': { fontSize: 12 },
},
},
},
MuiDialog: {
styleOverrides: { paper: { backgroundImage: 'none', backgroundColor: '#f8fafc', border: '1px solid #e2e8f0' } },
},
MuiMenu: {
styleOverrides: { paper: { backgroundImage: 'none', backgroundColor: '#f8fafc', border: '1px solid #e2e8f0' } },
},
MuiTooltip: {
styleOverrides: { tooltip: { fontSize: 11, backgroundColor: '#f1f5f9', border: '1px solid #e2e8f0', color: '#0f172a' } },
},
MuiSelect: {
styleOverrides: { root: { fontSize: 12, borderRadius: 8, backgroundColor: '#f1f5f9' } },
},
},
};
export const metonaDarkTheme = createTheme(darkTheme);
export const metonaLightTheme = createTheme(lightTheme);
+20
View File
@@ -0,0 +1,20 @@
/**
* MetonaAI Desktop — React 渲染进程入口
*/
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './styles/globals.css';
const root = document.getElementById('root');
if (!root) {
throw new Error('Root element not found');
}
ReactDOM.createRoot(root).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
+332
View File
@@ -0,0 +1,332 @@
/**
* Zustand Store — Agent 状态管理
*
* 管理 Agent 的实时状态:当前会话、消息流、流式输出、工具调用状态、Token 统计。
*/
import { create } from 'zustand';
import { useSessionStore } from './session-store';
// ===== 消息类型 =====
export type MessageRole = 'user' | 'assistant' | 'system' | 'tool';
export interface ToolCallInfo {
id: string;
name: string;
args: Record<string, unknown>;
status: 'pending' | 'executing' | 'success' | 'error' | 'blocked';
result?: unknown;
durationMs?: number;
error?: string;
}
export interface AttachmentInfo {
id: string;
name: string;
type: 'image' | 'text' | 'other';
size: number;
preview?: string; // 图片 base64 data URL
textContent?: string; // 文本文件内容
}
export interface ChatMessage {
id: string;
role: MessageRole;
content: string;
timestamp: number;
iteration?: number;
toolCalls?: ToolCallInfo[];
reasoningContent?: string;
attachments?: AttachmentInfo[];
}
// ===== Agent 状态 =====
export type AgentStatus = 'idle' | 'thinking' | 'executing' | 'error';
// ===== Token 统计 =====
export interface TokenUsage {
inputTokens: number;
outputTokens: number;
totalTokens: number;
}
// ===== Trace 步骤 =====
export interface TraceStep {
iteration: number;
state: string;
startedAt: number;
completedAt?: number;
thought?: string;
toolCalls?: ToolCallInfo[];
tokenUsage?: { promptTokens: number; completionTokens: number; totalTokens: number };
}
// ===== Store 状态 =====
interface AgentState {
// 会话
currentSessionId: string | null;
messages: ChatMessage[];
agentStatus: AgentStatus;
currentIteration: number;
maxIterations: number;
// Token 统计
tokenUsage: TokenUsage;
// Trace
traceSteps: TraceStep[];
// 流式
isStreaming: boolean;
streamingContent: string;
// Provider
provider: string;
model: string;
// Actions
setCurrentSession: (id: string | null) => void;
setMessages: (messages: ChatMessage[]) => void;
addMessage: (message: ChatMessage) => void;
updateMessage: (id: string, updates: Partial<ChatMessage>) => void;
sendMessage: (content: string, images?: Array<{ url: string; detail?: 'low' | 'high' | 'auto' }>, attachments?: AttachmentInfo[]) => void;
updateLastAssistantMessage: (delta: string) => void;
setAgentStatus: (status: AgentStatus) => void;
setStreaming: (streaming: boolean) => void;
appendStreamingContent: (delta: string) => void;
clearStreamingContent: () => void;
addTraceStep: (step: TraceStep) => void;
updateTraceStep: (iteration: number, updates: Partial<TraceStep>) => void;
updateTokenUsage: (usage: Partial<TokenUsage>) => void;
setProvider: (provider: string, model: string) => void;
setMaxIterations: (max: number) => void;
saveTraceData: () => void;
clearMessages: () => void;
abort: () => void;
}
export const useAgentStore = create<AgentState>((set, get) => ({
// ===== 初始状态 =====
currentSessionId: null,
messages: [],
agentStatus: 'idle',
currentIteration: 0,
maxIterations: 20,
tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
traceSteps: [],
isStreaming: false,
streamingContent: '',
provider: '',
model: '',
// ===== Actions =====
setCurrentSession: (id) => {
set({ currentSessionId: id, messages: [], traceSteps: [], tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 } });
// 从数据库加载该会话的消息
if (id && window.metona?.sessions?.getMessages) {
window.metona.sessions.getMessages(id).then((msgs) => {
const messages = (msgs as Array<{
id: string; role: string; content: string;
reasoningContent?: string; toolCalls?: unknown[];
attachments?: Array<{ id: string; name: string; type: string; size: number; preview?: string; textContent?: string }>;
timestamp: number;
}>).map((m) => ({
id: m.id,
role: m.role as ChatMessage['role'],
content: m.content,
reasoningContent: m.reasoningContent,
toolCalls: m.toolCalls as ToolCallInfo[] | undefined,
attachments: m.attachments as AttachmentInfo[] | undefined,
timestamp: m.timestamp,
}));
set({ messages });
}).catch(() => {});
}
// 从数据库加载该会话的 trace 步骤和 token 用量
if (id && window.metona?.sessions?.getTrace) {
window.metona.sessions.getTrace(id).then((data) => {
if (data) {
if (data.traceSteps) set({ traceSteps: data.traceSteps as TraceStep[] });
if (data.tokenUsage) set({ tokenUsage: data.tokenUsage as TokenUsage });
}
}).catch(() => {});
}
},
setMessages: (messages) => set({ messages }),
addMessage: (message) =>
set((s) => ({ messages: [...s.messages, message] })),
updateMessage: (id, updates) =>
set((s) => ({
messages: s.messages.map((m) =>
m.id === id ? { ...m, ...updates } : m,
),
})),
sendMessage: async (content: string, images?: Array<{ url: string; detail?: 'low' | 'high' | 'auto' }>, attachments?: AttachmentInfo[]) => {
let sessionId = get().currentSessionId;
// 没有当前会话时自动创建
if (!sessionId && window.metona?.sessions?.create) {
try {
const session = await window.metona.sessions.create() as { id: string; title: string; createdAt: number; updatedAt: number; messageCount: number; pinned: boolean; archived: boolean };
useSessionStore.getState().addSession({
id: session.id, title: session.title, createdAt: session.createdAt,
updatedAt: session.updatedAt, messageCount: session.messageCount,
pinned: session.pinned, archived: session.archived,
});
sessionId = session.id;
set({ currentSessionId: sessionId });
} catch {
// 创建失败时静默
}
}
const userMessage: ChatMessage = {
id: `msg_${Date.now()}_user`,
role: 'user',
content, // 用户可见内容(纯文本)
timestamp: Date.now(),
attachments: attachments && attachments.length > 0 ? attachments : undefined,
};
set((s) => ({
messages: [...s.messages, userMessage],
agentStatus: 'thinking',
isStreaming: true,
streamingContent: '',
}));
// 自动更新会话标题为用户第一条消息
if (sessionId && window.metona?.sessions?.getMessages) {
window.metona.sessions.getMessages(sessionId).then((msgs) => {
if (msgs.length <= 1) {
// 这是第一条消息,更新标题
const title = content.length > 30 ? content.slice(0, 30) + '...' : content;
window.metona?.sessions?.rename(sessionId, title);
useSessionStore.getState().updateSession(sessionId, { title });
}
}).catch(() => {});
}
if (sessionId && window.metona?.agent?.sendMessage) {
// 构建发给 LLM 的 content:纯用户文本 + 文件 JSON(无文字描述)
let llmContent = content;
const images: Array<{ url: string; detail?: 'low' | 'high' | 'auto' }> = [];
if (attachments && attachments.length > 0) {
const parts: string[] = [];
for (const att of attachments) {
if (att.type === 'image' && att.preview) {
// 图片:直接加入 images 数组
images.push({ url: att.preview, detail: 'auto' });
} else {
// 所有文件:JSON 格式,Base64 编码内容
const ext = att.name.split('.').pop() ?? 'unknown';
let base64Content = '';
if (att.textContent) {
base64Content = btoa(unescape(encodeURIComponent(att.textContent)));
} else if (att.preview) {
// 二进制文件从 data URL 提取 base64
base64Content = att.preview.split(',')[1] ?? '';
}
parts.push(JSON.stringify({
file_name: att.name,
file_type: ext,
context_encode: 'Base64',
context: base64Content,
}));
}
}
llmContent = parts.join('\n') + (content ? '\n\n' + content : '');
}
const messageWithImages = {
...userMessage,
content: llmContent,
images: images.length > 0 ? images : undefined,
};
window.metona.agent.sendMessage(messageWithImages, sessionId).catch(() => {});
}
},
updateLastAssistantMessage: (delta: string) => {
set((s) => {
const messages = [...s.messages];
const lastMsg = messages[messages.length - 1];
if (lastMsg?.role === 'assistant') {
lastMsg.content += delta;
} else {
messages.push({
id: `msg_${Date.now()}_assistant`,
role: 'assistant',
content: delta,
timestamp: Date.now(),
});
}
return { messages };
});
},
setAgentStatus: (status) => set({ agentStatus: status }),
setStreaming: (streaming) => set({ isStreaming: streaming }),
appendStreamingContent: (delta) =>
set((s) => ({ streamingContent: s.streamingContent + delta })),
clearStreamingContent: () => set({ streamingContent: '' }),
addTraceStep: (step) =>
set((s) => ({ traceSteps: [...s.traceSteps, step] })),
updateTraceStep: (iteration, updates) =>
set((s) => ({
traceSteps: s.traceSteps.map((t) =>
t.iteration === iteration ? { ...t, ...updates } : t,
),
})),
updateTokenUsage: (usage) =>
set((s) => ({ tokenUsage: { ...s.tokenUsage, ...usage } })),
setProvider: (provider, model) => set({ provider, model }),
setMaxIterations: (max) => set({ maxIterations: max }),
saveTraceData: () => {
const { currentSessionId, traceSteps, tokenUsage } = get();
if (currentSessionId && window.metona?.sessions?.saveTrace) {
window.metona.sessions.saveTrace(currentSessionId, { traceSteps, tokenUsage }).catch(() => {});
}
},
clearMessages: () =>
set({
messages: [],
agentStatus: 'idle',
currentIteration: 0,
tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
traceSteps: [],
isStreaming: false,
streamingContent: '',
}),
abort: () => {
const sessionId = get().currentSessionId;
set({ agentStatus: 'idle', isStreaming: false });
if (sessionId && window.metona?.agent?.abortSession) {
window.metona.agent.abortSession(sessionId).catch(() => {});
}
},
}));
+70
View File
@@ -0,0 +1,70 @@
/**
* Zustand Store — 会话管理
*
* 会话列表、当前会话、搜索、置顶、归档。
*/
import { create } from 'zustand';
export interface Session {
id: string;
title: string;
createdAt: number;
updatedAt: number;
messageCount: number;
pinned: boolean;
archived: boolean;
}
interface SessionState {
sessions: Session[];
currentSessionId: string | null;
searchQuery: string;
// Actions
setSessions: (sessions: Session[]) => void;
setCurrentSession: (id: string | null) => void;
setSearchQuery: (query: string) => void;
addSession: (session: Session) => void;
updateSession: (id: string, updates: Partial<Session>) => void;
removeSession: (id: string) => void;
pinSession: (id: string, pinned: boolean) => void;
archiveSession: (id: string, archived: boolean) => void;
}
export const useSessionStore = create<SessionState>((set) => ({
// 初始状态
sessions: [],
currentSessionId: null,
searchQuery: '',
// Actions
setSessions: (sessions) => set({ sessions }),
setCurrentSession: (id) => set({ currentSessionId: id }),
setSearchQuery: (query) => set({ searchQuery: query }),
addSession: (session) =>
set((s) => ({ sessions: [session, ...s.sessions] })),
updateSession: (id, updates) =>
set((s) => ({
sessions: s.sessions.map((sess) =>
sess.id === id ? { ...sess, ...updates } : sess,
),
})),
removeSession: (id) =>
set((s) => ({
sessions: s.sessions.filter((sess) => sess.id !== id),
currentSessionId: s.currentSessionId === id ? null : s.currentSessionId,
})),
pinSession: (id, pinned) =>
set((s) => ({
sessions: s.sessions.map((sess) =>
sess.id === id ? { ...sess, pinned } : sess,
),
})),
archiveSession: (id, archived) =>
set((s) => ({
sessions: s.sessions.map((sess) =>
sess.id === id ? { ...sess, archived } : sess,
),
})),
}));
+39
View File
@@ -0,0 +1,39 @@
/**
* Zustand Store — UI 状态管理
*
* 主题、设置弹窗、引导向导等全局 UI 状态。
*/
import { create } from 'zustand';
export type ThemeMode = 'dark' | 'light' | 'auto';
interface UIState {
// 主题
theme: ThemeMode;
resolvedTheme: 'dark' | 'light';
// 弹窗
settingsOpen: boolean;
onboardingCompleted: boolean;
// Actions
setTheme: (theme: ThemeMode) => void;
setResolvedTheme: (theme: 'dark' | 'light') => void;
openSettings: () => void;
closeSettings: () => void;
setOnboardingCompleted: (completed: boolean) => void;
}
export const useUIStore = create<UIState>((set) => ({
theme: 'auto',
resolvedTheme: 'dark',
settingsOpen: false,
onboardingCompleted: false,
setTheme: (theme) => set({ theme }),
setResolvedTheme: (resolvedTheme) => set({ resolvedTheme }),
openSettings: () => set({ settingsOpen: true }),
closeSettings: () => set({ settingsOpen: false }),
setOnboardingCompleted: (completed) => set({ onboardingCompleted: completed }),
}));
+263
View File
@@ -0,0 +1,263 @@
@import "tailwindcss";
/* ===== MetonaAI Desktop — 全局样式 ===== */
/* CSS 变量 — 暗色主题(默认) */
:root,
:root[data-theme="dark"] {
color-scheme: dark;
--bg-primary: #0f1117;
--bg-secondary: #1a1d27;
--bg-tertiary: #252836;
--bg-hover: rgba(99, 102, 241, 0.06);
--bg-card: #1a1d27;
--text-primary: #e1e4ed;
--text-secondary: #64748b;
--text-dim: #8b8fa7;
--border-color: #2a2d3a;
--accent: #818cf8;
--accent-soft: rgba(99, 102, 241, 0.15);
--green: #34d399;
--orange: #fb923c;
--red: #f87171;
--amber: #fbbf24;
--cyan: #22d3ee;
--purple: #a855f7;
--pink: #f472b6;
--background: #0f1117;
--foreground: #e1e4ed;
--primary: #818cf8;
--primary-foreground: #0f1117;
--secondary: #252836;
--secondary-foreground: #e1e4ed;
--muted: #252836;
--muted-foreground: #8b8fa7;
--destructive: #f87171;
--destructive-foreground: #e1e4ed;
--border: #2a2d3a;
--input: #2a2d3a;
--ring: #818cf8;
--radius: 0.625rem;
}
/* 亮色主题 */
:root[data-theme="light"] {
color-scheme: light;
--bg-primary: #ffffff;
--bg-secondary: #f8fafc;
--bg-tertiary: #f1f5f9;
--bg-hover: rgba(99, 102, 241, 0.04);
--bg-card: #f8fafc;
--text-primary: #0f172a;
--text-secondary: #334155;
--text-dim: #64748b;
--border-color: #e2e8f0;
--accent: #6366f1;
--accent-soft: rgba(99, 102, 241, 0.1);
--green: #059669;
--orange: #ea580c;
--red: #dc2626;
--amber: #d97706;
--cyan: #0891b2;
--purple: #9333ea;
--pink: #db2777;
--background: #ffffff;
--foreground: #0f172a;
--primary: #6366f1;
--primary-foreground: #ffffff;
--secondary: #f1f5f9;
--secondary-foreground: #0f172a;
--muted: #f1f5f9;
--muted-foreground: #64748b;
--destructive: #dc2626;
--destructive-foreground: #ffffff;
--border: #e2e8f0;
--input: #e2e8f0;
--ring: #6366f1;
--radius: 0.625rem;
}
/* Tailwind v4 主题注册 */
@theme {
--color-bg-primary: var(--bg-primary);
--color-bg-secondary: var(--bg-secondary);
--color-bg-tertiary: var(--bg-tertiary);
--color-bg-hover: var(--bg-hover);
--color-text-primary: var(--text-primary);
--color-text-secondary: var(--text-secondary);
--color-text-dim: var(--text-dim);
--color-border-color: var(--border-color);
--color-green: var(--green);
--color-orange: var(--orange);
--color-red: var(--red);
--color-amber: var(--amber);
--color-cyan: var(--cyan);
--color-purple: var(--purple);
--color-pink: var(--pink);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius: var(--radius);
}
:root[data-theme="light"] {
color-scheme: light;
}
/* ===== 全局基础样式 ===== */
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body, #root { width: 100%; height: 100%; overflow: hidden; }
body {
background: var(--bg-primary);
color: var(--text-primary);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
-webkit-font-smoothing: antialiased;
}
/* ===== 滚动条样式 ===== */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #2a2d3a; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #8b8fa7; }
/* ===== 代码字体 ===== */
code, pre, .font-mono { font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; }
/* ===== 选择文本颜色 ===== */
::selection { background: rgba(99, 102, 241, 0.15); }
/* ===== 焦点样式 ===== */
:focus-visible { outline: 2px solid #818cf8; outline-offset: 2px; }
/* ===== 动画 Keyframes ===== */
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes scaleIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
/* ===== Markdown Prose 样式 ===== */
.prose-metona {
color: inherit;
line-height: 1.7;
font-size: 14px;
}
.prose-metona h1,
.prose-metona h2,
.prose-metona h3,
.prose-metona h4 {
font-weight: 600;
margin-top: 1.5em;
margin-bottom: 0.5em;
}
.prose-metona h1 { font-size: 1.5em; }
.prose-metona h2 { font-size: 1.25em; }
.prose-metona h3 { font-size: 1.125em; }
.prose-metona p { margin-bottom: 0.75em; }
.prose-metona a { color: #818cf8; text-decoration: none; }
.prose-metona a:hover { text-decoration: underline; }
.prose-metona code {
color: #22d3ee;
background: rgba(34, 211, 238, 0.1);
padding: 1px 5px;
border-radius: 3px;
font-size: 0.875em;
}
.prose-metona pre {
background: #252836;
border: 1px solid #2a2d3a;
border-radius: 8px;
padding: 16px;
overflow-x: auto;
margin-bottom: 1em;
}
.prose-metona pre code {
color: inherit;
background: transparent;
padding: 0;
font-size: 13px;
line-height: 1.6;
}
.prose-metona blockquote {
border-left: 3px solid #818cf8;
padding-left: 16px;
color: #64748b;
margin-bottom: 0.75em;
}
.prose-metona ul, .prose-metona ol { padding-left: 1.5em; margin-bottom: 0.75em; }
.prose-metona li { margin-bottom: 0.25em; }
.prose-metona table { width: 100%; border-collapse: collapse; margin-bottom: 1em; font-size: 13px; }
.prose-metona th { text-align: left; padding: 8px 12px; background: #252836; border-bottom: 1px solid #2a2d3a; color: #64748b; font-weight: 600; }
.prose-metona td { padding: 8px 12px; border-bottom: 1px solid #2a2d3a; }
.prose-metona hr { border: none; height: 1px; background: #2a2d3a; margin: 1.5em 0; }
.prose-metona img { max-width: 100%; border-radius: 8px; }
/* ===== 动效降级 ===== */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
+215
View File
@@ -0,0 +1,215 @@
/**
* MetonaAI Desktop — 全局类型声明
*
* 为渲染进程暴露的 window.metona API 提供类型支持。
* 使用具体类型替代 unknown,确保类型安全。
*/
// ===== Metona IR 类型(渲染进程侧子集) =====
/** MetonaMessage — IR 标准消息类型(渲染进程可构造的部分) */
interface MetonaMessageInput {
role: 'user' | 'assistant' | 'system' | 'tool';
content: string;
reasoningContent?: string;
toolCalls?: Array<{ id: string; name: string; args: Record<string, unknown> }>;
timestamp: number;
iteration?: number;
images?: Array<{ url: string; detail?: 'low' | 'high' | 'auto' }>;
}
// ===== Agent API =====
interface MetonaStreamEventData {
type: string;
requestId?: string;
sessionId?: string;
iteration?: number;
seq?: number;
timestamp?: number;
delta?: string;
content?: string;
toolCall?: {
id: string;
name: string;
args: Record<string, unknown>;
};
toolResult?: {
toolCallId: string;
toolName: string;
success: boolean;
result?: unknown;
error?: string;
durationMs?: number;
};
usage?: {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
};
error?: {
code: string;
message: string;
retryable?: boolean;
};
state?: string;
}
interface MetonaAgentAPI {
/** 发送 MetonaMessageIR 类型)+ sessionId 到主进程 */
sendMessage: (message: MetonaMessageInput, sessionId: string) => Promise<{ success: boolean }>;
onStreamEvent: (callback: (event: MetonaStreamEventData) => void) => () => void;
onStateChange: (callback: (state: { sessionId: string; state: string }) => void) => () => void;
abortSession: (sessionId: string) => Promise<{ success: boolean }>;
onProviderSwitched: (callback: (data: { from?: string; to?: string; reason?: string }) => void) => () => void;
}
// ===== Sessions API =====
interface MetonaSessionInfo {
id: string;
title: string;
createdAt: number;
updatedAt: number;
messageCount: number;
pinned: boolean;
archived: boolean;
}
interface MetonaSessionsAPI {
list: () => Promise<MetonaSessionInfo[]>;
create: (title?: string) => Promise<MetonaSessionInfo>;
rename: (sessionId: string, title: string) => Promise<{ success: boolean }>;
delete: (sessionId: string) => Promise<{ success: boolean }>;
getMessages: (sessionId: string) => Promise<Array<{
id: string;
role: string;
content: string;
reasoningContent?: string;
toolCalls?: unknown[];
attachments?: Array<{ id: string; name: string; type: string; size: number; preview?: string; textContent?: string }>;
timestamp: number;
}>>;
pin: (sessionId: string, pinned: boolean) => Promise<{ success: boolean }>;
deleteMessage: (messageId: string) => Promise<{ success: boolean }>;
clearMessages: (sessionId: string) => Promise<{ success: boolean }>;
saveTrace: (sessionId: string, data: { traceSteps: unknown[]; tokenUsage: unknown }) => Promise<{ success: boolean }>;
getTrace: (sessionId: string) => Promise<{ traceSteps: unknown[]; tokenUsage: unknown } | null>;
}
// ===== MCP API =====
interface MetonaMCPServerConfig {
name: string;
transport: 'stdio' | 'sse';
command?: string;
args?: string[];
url?: string;
enabled: boolean;
}
interface MetonaMCPServerStatus {
name: string;
status: 'connecting' | 'connected' | 'disconnected' | 'error';
toolCount: number;
error?: string;
}
interface MetonaMCPAPI {
listServers: () => Promise<MetonaMCPServerStatus[]>;
addServer: (config: MetonaMCPServerConfig) => Promise<{ success: boolean }>;
removeServer: (name: string) => Promise<{ success: boolean }>;
toggleServer: (name: string, enabled: boolean) => Promise<{ success: boolean }>;
}
// ===== Memory API =====
interface MetonaMemorySearchResult {
id: string;
type: 'episodic' | 'semantic' | 'working';
content: string;
summary?: string;
importance: number;
relevanceScore: number;
createdAt: number;
}
interface MetonaMemoryAPI {
search: (query: string, options?: {
topK?: number;
type?: 'episodic' | 'semantic' | 'working';
minImportance?: number;
}) => Promise<MetonaMemorySearchResult[]>;
}
// ===== Config API =====
interface MetonaConfigAPI {
get: (key: string) => Promise<string | null>;
set: (key: string, value: unknown) => Promise<{ success: boolean }>;
}
// ===== App API =====
interface MetonaAppAPI {
getVersion: () => Promise<string>;
getAppDataPath: () => Promise<string>;
openExternal: (url: string) => Promise<void>;
showItemInFolder: (path: string) => void;
selectFolder: (defaultPath?: string) => Promise<{ canceled: boolean; path: string }>;
}
// ===== Toast API =====
interface MetonaToastAPI {
onShow: (callback: (data: {
type: 'success' | 'error' | 'warning' | 'info';
message: string;
options?: Record<string, unknown>;
}) => void) => () => void;
}
// ===== Tools API =====
interface MetonaToolInfo {
name: string;
description: string;
category: string;
riskLevel: string;
requiresPermission: boolean;
}
interface MetonaToolsAPI {
list: () => Promise<MetonaToolInfo[]>;
toggle: (toolName: string, enabled: boolean) => Promise<{ success: boolean }>;
}
// ===== Data API =====
interface MetonaDataAPI {
exportData: (sessionId?: string) => Promise<{ success: boolean; data?: unknown; error?: string }>;
clearSessions: () => Promise<{ success: boolean; error?: string }>;
clearMemories: () => Promise<{ success: boolean; error?: string }>;
clearAuditLogs: () => Promise<{ success: boolean; error?: string }>;
}
// ===== Bridge 接口 =====
export interface MetonaBridge {
agent: MetonaAgentAPI;
sessions: MetonaSessionsAPI;
mcp: MetonaMCPAPI;
memory: MetonaMemoryAPI;
config: MetonaConfigAPI;
app: MetonaAppAPI;
toast: MetonaToastAPI;
tools: MetonaToolsAPI;
data: MetonaDataAPI;
}
declare global {
interface Window {
metona: MetonaBridge;
electron: unknown;
}
}