- 新增 config:setBatch IPC:批量写入 8 个 LLM 字段后统一 reloadAdapter, 解决设置页串行 config:set 在中间态触发"LLM 配置不完整"错误的问题 - SettingsModal.handleSave 和 OnboardingWizard.handleNext 改用 setBatch - workspace.path 写入独立文件失败时返回 success:false(config:set 和 setBatch 一致), 避免用户误以为保存成功但下次启动仍使用旧路径 - setBatch 将 provider 切换清空 apiKey 的逻辑移到循环前执行, 消除对 entries 中 llm.provider 必须出现在 llm.apiKey 之前的隐含顺序依赖
150 lines
9.5 KiB
TypeScript
150 lines
9.5 KiB
TypeScript
/**
|
||
* 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 = async () => {
|
||
if (step < STEPS.length - 1) { setStep(step + 1); return; }
|
||
try {
|
||
if (window.metona?.config?.setBatch) {
|
||
// v0.3.9: 改用批量保存,避免并行 config.set 中间态触发 reloadAdapter 失败
|
||
// 旧实现问题:Promise.allSettled 并行 6 个 config.set,
|
||
// - llm.provider 写入会清空 llm.apiKey(与 llm.apiKey 写入竞态)
|
||
// - reloadAdapter 被调用 4 次,中间态必然失败
|
||
// - 代码只检查 status === 'rejected',完全忽略 { success: false } 的情况
|
||
// - 实际配置失败但前端显示成功并关闭向导
|
||
const entries: Array<{ key: string; value: unknown }> = [];
|
||
if (provider.trim()) entries.push({ key: 'llm.provider', value: provider.trim() });
|
||
if (baseURL.trim()) entries.push({ key: 'llm.baseURL', value: baseURL.trim() });
|
||
if (model.trim()) entries.push({ key: 'llm.model', value: model.trim() });
|
||
if (apiKey.trim()) entries.push({ key: 'llm.apiKey', value: apiKey.trim() });
|
||
if (workspacePath.trim()) entries.push({ key: 'workspace.path', value: workspacePath.trim() });
|
||
entries.push({ key: 'onboarding.completed', value: true });
|
||
|
||
const r = await window.metona.config.setBatch(entries);
|
||
if (r && !r.success) {
|
||
console.error('[OnboardingWizard]', 'Batch config save failed:', r.error);
|
||
import('metona-toast').then((mod) => mod.default.error(r.error ?? '配置保存失败,请重试')).catch(() => {});
|
||
return;
|
||
}
|
||
useAgentStore.getState().setProvider(provider.trim() || 'deepseek', model.trim() || '');
|
||
// P1-6 修复: Onboarding 完成后标记配置已加载
|
||
useAgentStore.getState().setConfigLoaded(true);
|
||
}
|
||
setOnboardingCompleted(true);
|
||
} catch (err) {
|
||
console.error('[OnboardingWizard]', 'Failed to save configuration:', err);
|
||
// 用户主动操作失败必须有反馈,否则向导不关闭、用户卡死
|
||
import('metona-toast').then((mod) => mod.default.error(`保存配置失败:${(err as Error).message}`)).catch(() => {});
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Dialog open maxWidth="sm" slotProps={{ paper: { 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="./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="mimo">MiMo (小米)</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} sx={{ mb: 2, 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={async () => {
|
||
if (window.metona?.app?.selectFolder) {
|
||
try {
|
||
const r = await window.metona.app.selectFolder(workspacePath || undefined);
|
||
if (!r.canceled && r.path) setWorkspacePath(r.path);
|
||
} catch (err) {
|
||
console.error('[OnboardingWizard]', err);
|
||
import('metona-toast').then((mod) => mod.default.error('选择文件夹失败')).catch(() => {});
|
||
}
|
||
}
|
||
}}>选择文件夹</Button>
|
||
</Stack>
|
||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>包含 SOUL.md、AGENTS.md、MEMORY.md、USERS.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>
|
||
);
|
||
}
|