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
+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>
);
}