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