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