/** * SettingsModal — 设置弹窗 */ import { useState, useEffect, useCallback, useRef } from 'react'; import { Dialog, DialogContent, DialogTitle, DialogActions, Button, TextField, Select, MenuItem, Tabs, Tab, Checkbox, Box, Typography, Stack, Divider, FormControlLabel, InputLabel, FormControl, IconButton, Chip, Switch, Alert, CircularProgress } from '@mui/material'; import { alpha } from '@mui/material/styles'; import { X, Settings, Bot, Wrench, Server, Palette, FileText, Eye, EyeOff, FolderOpen, Globe, Folder, Copy } from 'lucide-react'; import { useUIStore, type ThemeMode } from '@renderer/stores/ui-store'; import { useAgentStore } from '@renderer/stores/agent-store'; import { useSessionStore } from '@renderer/stores/session-store'; import { PROVIDER_LABELS } from '@renderer/lib/constants'; import { ErrorBoundary } from '@renderer/components/common/ErrorBoundary'; type SettingsTab = 'workspace' | 'llm' | 'agent' | 'tools' | 'mcp' | 'searxng' | '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: 'searxng', label: 'SearXNG', icon: Globe }, { 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('llm'); if (!settingsOpen) return null; return ( 设置 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) => } iconPosition="start" />)} {tab === 'workspace' && } {tab === 'llm' && } {tab === 'agent' && } {tab === 'tools' && } {tab === 'mcp' && } {tab === 'searxng' && } {tab === 'appearance' && } {tab === 'logs' && } ); } function useConfig(key: string, defaultValue: T): [T, (v: T) => void] { const [value, setValue] = useState(defaultValue); const valueRef = useRef(value); valueRef.current = value; // v0.3.6 修复: 配置保存失败时回滚 UI 并提示用户,避免 UI 与 DB 状态不一致 // seqRef 防止竞态:连续修改时旧请求失败不回滚覆盖新值 const seqRef = useRef(0); useEffect(() => { if (window.metona?.config?.get) window.metona.config.get(key).then((v) => { if (v != null) setValue(v as T); }).catch((err) => { console.error('[SettingsModal]', err); }); }, [key]); const set = useCallback((v: T) => { const seq = ++seqRef.current; const prev = valueRef.current; setValue(v); window.metona?.config?.set(key, v).then((r: { success?: boolean; error?: string } | undefined) => { if (r && !r.success) { // 只有当没有后续 set 操作时才回滚,避免覆盖用户的新修改 if (seqRef.current === seq) setValue(prev); import('metona-toast').then((mod) => mod.default.error(r.error ?? '配置保存失败')).catch(() => {}); } }).catch((err: unknown) => { console.error('[SettingsModal]', err); if (seqRef.current === seq) setValue(prev); import('metona-toast').then((mod) => mod.default.error('配置保存失败')).catch(() => {}); }); }, [key]); return [value, set]; } const PROVIDER_URLS: Record = { deepseek: 'https://api.deepseek.com', agnes: 'https://apihub.agnes-ai.com/v1', mimo: 'https://api.xiaomimimo.com/v1', ollama: 'http://localhost:11434' }; function WorkspaceSettings() { const [workspacePath, setWorkspacePath] = useConfig('workspace.path', ''); // 切换工作空间的中间状态 const [pendingPath, setPendingPath] = useState(null); const [checkResult, setCheckResult] = useState(null); const [checking, setChecking] = useState(false); const [inheritSoul, setInheritSoul] = useState(true); const [inheritAgents, setInheritAgents] = useState(true); const [inheritUsers, setInheritUsers] = useState(true); const [applying, setApplying] = useState(false); const [showRestartDialog, setShowRestartDialog] = useState(false); const currentPath = workspacePath; const handleSelect = async () => { if (!window.metona?.app?.selectFolder) return; const r = await window.metona.app.selectFolder(currentPath || undefined); if (!r.canceled && r.path) { // 立即校验新路径 setPendingPath(r.path); setChecking(true); setCheckResult(null); try { const result = await window.metona.workspace.check(r.path); setCheckResult(result); } catch (err) { console.error('[SettingsModal]', err); setCheckResult({ valid: false, reason: (err as Error).message }); } finally { setChecking(false); } } }; const handleApply = async () => { if (!pendingPath || !checkResult?.valid) return; setApplying(true); try { // 如果勾选了继承文件,从旧工作空间复制到新工作空间 const filesToInherit: string[] = []; if (inheritSoul) filesToInherit.push('SOUL.md'); if (inheritAgents) filesToInherit.push('AGENTS.md'); if (inheritUsers) filesToInherit.push('USERS.md'); if (filesToInherit.length > 0 && currentPath) { try { await window.metona.workspace.inheritFiles({ targetPath: pendingPath, sourcePath: currentPath, files: filesToInherit, }); } catch (err) { // 用户勾选了继承文件,失败时必须告知,否则切到新空间才发现文件是空的,潜在数据丢失风险 console.error('[SettingsModal] Inherit failed:', err); import('metona-toast').then((mod) => mod.default.warning(`部分文件继承失败:${(err as Error).message},请手动检查`)).catch(() => {}); } } // 保存新路径到配置 setWorkspacePath(pendingPath); // 弹出重启确认对话框 setShowRestartDialog(true); // 清理中间状态 setPendingPath(null); setCheckResult(null); } catch (err) { console.error('[SettingsModal]', err); // 用户主动操作(切换工作空间)失败必须有反馈 import('metona-toast').then((mod) => mod.default.error(`切换工作空间失败:${(err as Error).message}`)).catch(() => {}); } finally { setApplying(false); } }; const handleCancel = () => { setPendingPath(null); setCheckResult(null); }; const handleOpen = async () => { if (!workspacePath) return; try { const r = await window.metona?.app?.showItemInFolder(workspacePath); if (r && !r.success) { import('metona-toast').then((mod) => mod.default.error(r.error ?? '打开文件夹失败')).catch(() => {}); } } catch (err) { console.error('[SettingsModal]', err); import('metona-toast').then((mod) => mod.default.error('打开文件夹失败')).catch(() => {}); } }; return ( 工作空间 工作空间是 Metona 的组织核心,包含 SOUL.md、AGENTS.md、MEMORY.md、USERS.md 四个必需文件。 setWorkspacePath(e.target.value)} placeholder="~/MetonaWorkspaces/default/" sx={{ flex: 1 }} /> {workspacePath && ( )} {/* 校验中状态 */} {checking && ( 正在校验工作空间... )} {/* 校验失败 */} {pendingPath && checkResult && !checkResult.valid && ( 路径无效:{checkResult.reason} )} {/* 校验成功 — 显示工作空间状态 + 继承选项 */} {pendingPath && checkResult?.valid && ( 目标工作空间:{checkResult.path} {checkResult.isNewWorkspace ? ( 新工作空间 — 切换后将自动创建 4 个必需文件(SOUL.md、AGENTS.md、MEMORY.md、USERS.md) ) : checkResult.missingFiles && checkResult.missingFiles.length > 0 ? ( 已有目录但缺少 {checkResult.missingFiles.length} 个文件:{checkResult.missingFiles.join(', ')}。缺失文件将自动创建。 ) : ( 已有工作空间 — 4 个必需文件均已就绪,将直接加载现有配置。 )} {/* 继承选项(仅当当前有工作空间且新工作空间需要创建文件时显示) */} {currentPath && currentPath !== pendingPath && (checkResult.isNewWorkspace || (checkResult.missingFiles && checkResult.missingFiles.length > 0)) && ( 从当前工作空间继承基础设置: setInheritSoul(e.target.checked)} />} label={SOUL.md(身份与角色定义)} /> setInheritAgents(e.target.checked)} />} label={AGENTS.md(行为规则)} /> setInheritUsers(e.target.checked)} />} label={USERS.md(用户画像)} /> MEMORY.md 不继承(记忆与工作空间项目上下文绑定) )} )} 修改工作空间路径后需重启应用生效。 {/* 重启确认对话框 */} setShowRestartDialog(false)} maxWidth="xs" fullWidth> 工作空间已切换 工作空间已更新为: {workspacePath} 需要重启应用以加载新工作空间的配置和文件。是否立即重启? ); } function LLMSettings() { // 改为本地 state + Save 按钮统一提交,避免 onChange 实时落库导致: // 1. 改 Base URL 时被回滚卡死(useConfig seqRef 机制与连续输入冲突) // 2. 每按一个字符就触发一次 IPC + DB + reloadAdapter,浪费且会打断输入 // 3. 错误提示笼统(不指向具体字段) const [provider, setProvider] = useState(''); const [model, setModel] = useState(''); const [apiKey, setApiKey] = useState(''); const [baseURL, setBaseURL] = useState(''); const [numCtx, setNumCtx] = useState(null); // v0.3.1: DeepSeek/Agnes/MiMo contextWindow 可配置(不再写死) const [dsCtxWindow, setDsCtxWindow] = useState(1000000); const [agnesCtxWindow, setAgnesCtxWindow] = useState(1000000); const [mimoCtxWindow, setMimoCtxWindow] = useState(131072); const [showKey, setShowKey] = useState(false); const [loaded, setLoaded] = useState(false); const [saving, setSaving] = useState(false); // 初始化:一次性加载所有 LLM 配置字段 useEffect(() => { let cancelled = false; const load = async () => { if (!window.metona?.config?.get) { setLoaded(true); return; } try { const [p, m, k, u, nc, ds, ag, mi] = await Promise.all([ window.metona.config.get('llm.provider'), window.metona.config.get('llm.model'), window.metona.config.get('llm.apiKey'), window.metona.config.get('llm.baseURL'), window.metona.config.get('ollama.numCtx'), window.metona.config.get('deepseek.contextWindow'), window.metona.config.get('agnes.contextWindow'), window.metona.config.get('mimo.contextWindow'), ]); if (cancelled) return; setProvider((p as string) ?? ''); setModel((m as string) ?? ''); setApiKey((k as string) ?? ''); setBaseURL((u as string) ?? ''); setNumCtx((nc as number | null) ?? null); if (typeof ds === 'number' && ds > 0) setDsCtxWindow(ds); if (typeof ag === 'number' && ag > 0) setAgnesCtxWindow(ag); if (typeof mi === 'number' && mi > 0) setMimoCtxWindow(mi); } catch (err) { console.error('[SettingsModal]', err); } finally { if (!cancelled) setLoaded(true); } }; load(); return () => { cancelled = true; }; }, []); // 同步 Provider/Model 到 Agent Store(含 contextWindow) // 注意:仅同步运行时状态,不落库 useEffect(() => { useAgentStore.getState().setProvider(provider, model); }, [provider, model]); // v0.3.1: contextWindow 变化时同步到 Agent Store(支持所有 Provider) useEffect(() => { if (provider === 'ollama') { if (numCtx != null && numCtx > 0) useAgentStore.setState({ contextWindow: numCtx }); } else if (provider === 'deepseek') { if (dsCtxWindow != null && dsCtxWindow > 0) useAgentStore.setState({ contextWindow: dsCtxWindow }); } else if (provider === 'agnes') { if (agnesCtxWindow != null && agnesCtxWindow > 0) useAgentStore.setState({ contextWindow: agnesCtxWindow }); } else if (provider === 'mimo') { if (mimoCtxWindow != null && mimoCtxWindow > 0) useAgentStore.setState({ contextWindow: mimoCtxWindow }); } }, [provider, numCtx, dsCtxWindow, agnesCtxWindow, mimoCtxWindow]); // ===== 字段级 inline 校验 ===== // Base URL:非空时必须以 http:// 或 https:// 开头(避免漏写协议头导致发消息时报 Invalid URL) const urlError = !!baseURL && !/^https?:\/\/.+/.test(baseURL); // Model:非空时不允许包含空格(OpenAI API 会把空格后的部分当作额外参数) const modelHasSpace = !!model && /\s/.test(model); // contextWindow / numCtx:必须为有限正数且不低于最小值 const numCtxError = numCtx != null && (!Number.isFinite(numCtx) || numCtx < 512); const dsCtxError = !Number.isFinite(dsCtxWindow) || dsCtxWindow < 4096; const agnesCtxError = !Number.isFinite(agnesCtxWindow) || agnesCtxWindow < 4096; const mimoCtxError = !Number.isFinite(mimoCtxWindow) || mimoCtxWindow < 4096; // 是否存在阻断保存的错误(API Key 为空只警告,不阻断 — 允许先填其他字段再回来填 key) const hasBlockingError = urlError || modelHasSpace || numCtxError || (provider === 'deepseek' && dsCtxError) || (provider === 'agnes' && agnesCtxError) || (provider === 'mimo' && mimoCtxError); // 切换 Provider 时:清空 apiKey + 清空 model + 自动填充默认 URL // 不同 Provider 的 key/model 互不通用,避免用旧值调用新 API 导致 401 / model not found const handleProviderChange = (newProvider: string) => { const oldProvider = provider; setProvider(newProvider); // 切换 Provider 时清空 apiKey(不同 Provider 的 key 格式不同) if (oldProvider !== newProvider && apiKey) { setApiKey(''); } // 切换 Provider 时清空 model(不同 Provider 支持的模型名不同,如 deepseek-v4-pro 不适用于 ollama) if (oldProvider !== newProvider && model) { setModel(''); } // 自动填充默认 URL(仅在 URL 为空或与旧 provider 默认 URL 匹配时覆盖) const currentUrl = baseURL.trim(); const isDefaultUrl = Object.values(PROVIDER_URLS).includes(currentUrl); if (isDefaultUrl || !currentUrl) { setBaseURL(PROVIDER_URLS[newProvider] ?? ''); } }; const handleSave = async () => { if (hasBlockingError) { import('metona-toast').then((mod) => mod.default.error('请修正表单中的错误后再保存')).catch(() => {}); return; } setSaving(true); try { const setBatch = window.metona?.config?.setBatch; if (!setBatch) { import('metona-toast').then((mod) => mod.default.error('配置 API 不可用')).catch(() => {}); return; } // v0.3.9: 批量保存,避免串行保存中间态触发 reloadAdapter 失败 // 旧实现:串行 config:set 8 次,provider 切换后第 1 步会清空 apiKey, // 此时 reloadAdapter 读到空 apiKey 返回 false,前端 toast 报"配置不全", // 但所有字段实际已写入,第二次点保存才显示"已保存"。 // 新实现:一次性传所有字段,后端先写入全部,最后统一 reloadAdapter 一次。 const entries: Array<{ key: string; value: unknown }> = [ { key: 'llm.provider', value: provider }, { key: 'llm.model', value: model }, { key: 'llm.apiKey', value: apiKey }, { key: 'llm.baseURL', value: baseURL }, { key: 'ollama.numCtx', value: numCtx }, { key: 'deepseek.contextWindow', value: dsCtxWindow }, { key: 'agnes.contextWindow', value: agnesCtxWindow }, { key: 'mimo.contextWindow', value: mimoCtxWindow }, ]; const r = await setBatch(entries); if (r && !r.success) { import('metona-toast').then((mod) => mod.default.error(r.error ?? '配置保存失败')).catch(() => {}); } else { import('metona-toast').then((mod) => mod.default.success('配置已保存')).catch(() => {}); } } catch (err) { import('metona-toast').then((mod) => mod.default.error(`保存失败:${(err as Error).message}`)).catch(() => {}); } finally { setSaving(false); } }; if (!loaded) { return ( LLM 配置 加载中... ); } const apiKeyEmpty = provider !== 'ollama' && !apiKey.trim(); return ( LLM 配置 Provider setBaseURL(e.target.value)} placeholder="如 https://api.deepseek.com" error={urlError} helperText={urlError ? '需以 http:// 或 https:// 开头' : ' '} /> setModel(e.target.value)} placeholder="如 deepseek-v4-pro、qwen3:latest" error={modelHasSpace} helperText={modelHasSpace ? '模型名称不能包含空格' : ' '} /> {provider !== 'ollama' && ( <> setApiKey(e.target.value)} placeholder="sk-..." error={apiKeyEmpty} helperText={apiKeyEmpty ? `必填,未填 ${PROVIDER_LABELS[provider] ?? provider} 的 API Key 会 401` : ' '} slotProps={{ input: { endAdornment: setShowKey(!showKey)}>{showKey ? : } } }} /> )} {provider === 'ollama' && ( { const v = e.target.value; setNumCtx(v === '' ? null : Number(v)); }} placeholder="默认由模型决定(如 2048、4096、128000)" slotProps={{ htmlInput: { min: 512, step: 512 } }} error={numCtxError} helperText={numCtxError ? '最小值为 512' : ' '} /> )} {/* v0.3.1: DeepSeek/Agnes 上下文窗口配置(用于 Engine 压缩判断和 UI 显示,不传给 API) */} {provider === 'deepseek' && ( setDsCtxWindow(Number(e.target.value) || 1000000)} placeholder="如 64000、128000、1000000" slotProps={{ htmlInput: { min: 4096, step: 4096 } }} error={dsCtxError} helperText={dsCtxError ? '最小值为 4096' : '用于上下文压缩判断,不传给 API'} /> )} {provider === 'agnes' && ( setAgnesCtxWindow(Number(e.target.value) || 1000000)} placeholder="如 64000、128000、1000000" slotProps={{ htmlInput: { min: 4096, step: 4096 } }} error={agnesCtxError} helperText={agnesCtxError ? '最小值为 4096' : '用于上下文压缩判断,不传给 API'} /> )} {provider === 'mimo' && ( setMimoCtxWindow(Number(e.target.value) || 131072)} placeholder="如 32768、65536、131072" slotProps={{ htmlInput: { min: 4096, step: 4096 } }} error={mimoCtxError} helperText={mimoCtxError ? '最小值为 4096' : '官方未公布具体值,默认 131072,用于压缩判断'} /> )} {/* Save 按钮:批量提交,取消 onChange 实时落库 */} {hasBlockingError && ( 请修正表单错误后再保存 )} ); } 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'); const [confirmTimeout, setConfirmTimeout] = useConfig('agent.confirmationTimeoutMs', 120000); const [toolExecTimeout, setToolExecTimeout] = useConfig('agent.toolExecutionTimeoutMs', 120000); const MAX_ITER_OPTIONS = [10, 20, 50, 85, 128, 256, 512]; return ( Agent 配置 最大迭代次数 { const v = Number(e.target.value); if (v >= 120 && v <= 3600) setTimeout_(v * 1000); }} slotProps={{ htmlInput: { min: 120, max: 3600, step: 30 } }} /> { const v = Number(e.target.value); if (v >= 10 && v <= 600) setToolExecTimeout(v * 1000); }} slotProps={{ htmlInput: { min: 10, max: 600, step: 10 } }} helperText="单个工具执行的最大时长,超时自动终止(10~600 秒)" /> { const v = Number(e.target.value); if (v >= 30 && v <= 600) setConfirmTimeout(v * 1000); }} slotProps={{ htmlInput: { min: 30, max: 600, step: 10 } }} helperText="用户未响应工具确认时,超时自动视为拒绝(30~600 秒)" /> setThinking(e.target.checked)} size="small" />} label={启用思考模式} /> {thinking && ( 思考强度 )} ); } function ToolsSettings() { const [tools, setTools] = useState>([]); const [autoExecList, setAutoExecList] = useState([]); const loadTools = () => { if (window.metona?.tools?.list) { window.metona.tools.list().then((l) => setTools((l as MetonaToolInfo[]).map((t) => ({ name: t.name, description: t.description, riskLevel: t.riskLevel, requiresPermission: t.requiresPermission, enabled: t.enabled, })))).catch((err) => { console.error('[SettingsModal]', err); }); } }; const loadAutoExec = () => { if (window.metona?.tool?.getAutoExecuteList) { window.metona.tool.getAutoExecuteList().then((r) => { if (r.success) setAutoExecList(r.data); }).catch((err) => { console.error('[SettingsModal]', err); }); } }; useEffect(() => { loadTools(); loadAutoExec(); }, []); const handleToggle = async (name: string, enabled: boolean) => { // v0.3.6 修复: 乐观更新失败时回滚 UI,避免开关显示与实际状态不一致 // 注意: 只回滚失败的单个工具(用 !enabled),不能用 setTools(prev) 整体回滚, // 否则会覆盖 await 期间用户对其他工具的并发修改 setTools((p) => p.map((t) => t.name === name ? { ...t, enabled } : t)); try { const r = await window.metona?.tools?.toggle(name, enabled); if (r && !r.success) { setTools((p) => p.map((t) => t.name === name ? { ...t, enabled: !enabled } : t)); import('metona-toast').then((mod) => mod.default.error(r.error ?? '切换工具失败')).catch(() => {}); } } catch (err) { console.error('[SettingsModal]', err); setTools((p) => p.map((t) => t.name === name ? { ...t, enabled: !enabled } : t)); import('metona-toast').then((mod) => mod.default.error('切换工具失败')).catch(() => {}); } }; // 设置/取消自动执行 const handleSetAutoExec = async (name: string, enabled: boolean) => { if (!window.metona?.tool?.setAutoExecute) return; try { const r = await window.metona.tool.setAutoExecute(name, enabled); if (r.success) { setAutoExecList((p) => enabled ? [...p, name] : p.filter((n) => n !== name)); } else { import('metona-toast').then((mod) => mod.default.error(r.error ?? '设置自动执行失败')).catch(() => {}); } } catch (err) { console.error('[SettingsModal]', err); import('metona-toast').then((mod) => mod.default.error('设置自动执行失败')).catch(() => {}); } }; // v0.3.1: 添加 critical 键,防止 critical 级别工具 Chip 渲染为 undefined color const riskColors: Record = { safe: 'success', low: 'info', medium: 'warning', high: 'error', critical: 'error' }; // 需要确认的工具(high/critical 或 requiresPermission) const needsConfirmTools = tools.filter((t) => t.riskLevel === 'high' || t.riskLevel === 'critical' || t.requiresPermission, ); // 需要确认但未设为自动执行的工具 const pendingConfirmTools = needsConfirmTools.filter((t) => !autoExecList.includes(t.name)); // 已设为自动执行的工具详情 const autoExecToolDetails = autoExecList .map((name) => tools.find((t) => t.name === name)) .filter((t): t is NonNullable => t !== undefined); return ( 工具管理 {tools.length === 0 ? 加载中... : tools.map((t) => ( {t.name} {t.description.slice(0, 40)} handleToggle(t.name, e.target.checked)} size="small" /> ))} {/* ===== 自动执行工具管理 ===== */} 自动执行工具 0 ? 'success' : 'default'} variant="outlined" sx={{ height: 18, fontSize: 10 }} /> 已设为自动执行的工具将跳过用户确认步骤,直接执行。此设置跨会话持久化。 {/* 已自动执行的工具列表 */} {autoExecToolDetails.length === 0 ? ( 暂无自动执行工具 ) : ( autoExecToolDetails.map((t) => ( ({ py: 1, px: 1.5, borderRadius: 1.5, // 用主题 success 色的 12% 透明度做底,文字和按钮保持不透明 bgcolor: alpha(theme.palette.success.main, 0.12), // 左侧绿色状态条,强化"已自动执行"视觉 boxShadow: `inset 3px 0 0 ${theme.palette.success.main}`, justifyContent: 'space-between', alignItems: 'center', })} > {t.name} )) )} {/* 可设为自动执行的工具(需要确认但未设置) */} {pendingConfirmTools.length > 0 && ( <> 可设为自动执行(当前需要确认) {pendingConfirmTools.map((t) => ( {t.name} ))} )} ); } function MCPSettings() { const [servers, setServers] = useState>([]); const [showAdd, setShowAdd] = useState(false); const [newName, setNewName] = useState(''); const [newCommand, setNewCommand] = useState(''); const [newArgs, setNewArgs] = useState(''); // L-11 修复: 用 MUI Dialog 替换浏览器原生 confirm(),保持 UI 一致性 const [confirmRemove, setConfirmRemove] = useState(null); // L-20 修复: loadServers 改为多行 async/await 写法,提升可读性 const loadServers = useCallback(async () => { if (!window.metona?.mcp?.listServers) return; try { const list = await window.metona.mcp.listServers(); setServers(list as MetonaMCPServerStatus[]); } catch (err) { console.error('[SettingsModal]', err); } }, []); // 审计补充修复: useEffect 添加 cancelled 标志,防止卸载后 setState useEffect(() => { let cancelled = false; (async () => { if (!window.metona?.mcp?.listServers) return; try { const list = await window.metona.mcp.listServers(); if (!cancelled) setServers(list as MetonaMCPServerStatus[]); } catch (err) { if (!cancelled) console.error('[SettingsModal]', err); } })(); return () => { cancelled = true; }; }, []); const handleAdd = async () => { if (!newName.trim() || !newCommand.trim()) return; try { const r = await window.metona?.mcp?.addServer({ name: newName.trim(), transport: 'stdio', command: newCommand.trim(), args: newArgs.trim() ? newArgs.trim().split(/\s+/) : [], enabled: true }); if (r?.success) { setNewName(''); setNewCommand(''); setNewArgs(''); setShowAdd(false); loadServers(); } else { import('metona-toast').then((mod) => mod.default.error(r?.error ?? '添加 MCP 服务失败')).catch(() => {}); } } catch (err) { console.error('[SettingsModal]', err); import('metona-toast').then((mod) => mod.default.error(`添加 MCP 服务失败:${(err as Error).message}`)).catch(() => {}); } }; const statusColors: Record = { connected: 'success.main', connecting: 'warning.main', disconnected: 'text.secondary', error: 'error.main' }; // L-11 修复: 确认移除 MCP 服务 // 审计补充修复: 添加 try/catch,避免 removeServer reject 时 Dialog 卡死无法关闭 const [removeError, setRemoveError] = useState(null); const handleConfirmRemove = async () => { if (!confirmRemove) return; try { setRemoveError(null); const r = await window.metona?.mcp?.removeServer(confirmRemove); if (r?.success) { setConfirmRemove(null); loadServers(); } else { setRemoveError(r?.error ?? '移除失败'); } } catch (err) { setRemoveError((err as Error).message ?? '移除失败'); } }; return ( MCP 服务 {servers.length === 0 ? 暂无 MCP 服务 : servers.map((s) => ( {s.name} {s.status} {s.toolCount > 0 && {s.toolCount} 工具} {/* L-11 修复: 点击移除打开 MUI Dialog 二次确认,而非原生 confirm() */} ))} {/* L-11 修复: MUI Dialog 替代原生 confirm() */} { setConfirmRemove(null); setRemoveError(null); }} maxWidth="xs" fullWidth > 确认移除 确定移除 MCP 服务 "{confirmRemove}"?此操作不可撤销。 {removeError && ( 移除失败:{removeError} )} {showAdd ? ( setNewName(e.target.value)} placeholder="服务名称" /> setNewCommand(e.target.value)} placeholder="命令路径" /> setNewArgs(e.target.value)} placeholder="参数 (空格分隔)" /> ) : } ); } function SearXNGSettings() { // ===== 12 项配置(useConfig 实时持久化) ===== const [enabled, setEnabled] = useConfig('searxng.enabled', false); const [url, setUrl] = useConfig('searxng.url', ''); const [engines, setEngines] = useConfig('searxng.engines', ''); const [language, setLanguage] = useConfig('searxng.language', 'zh-CN'); const [safesearch, setSafesearch] = useConfig('searxng.safesearch', 1); const [timeRange, setTimeRange] = useConfig('searxng.time_range', ''); const [maxResults, setMaxResults] = useConfig('searxng.max_results', 0); const [authKey, setAuthKey] = useConfig('searxng.auth_key', ''); const [authType, setAuthType] = useConfig('searxng.auth_type', 'bearer'); const [format, setFormat] = useConfig('searxng.format', 'json'); const [fetchCount, setFetchCount] = useConfig('searxng.fetch_count', 0); const [fetchMode, setFetchMode] = useConfig('searxng.fetch_mode', 'sequential'); const [showKey, setShowKey] = useState(false); const [testing, setTesting] = useState(false); const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null); const urlError = !!url && !/^https?:\/\//.test(url); const handleTest = async () => { if (!url.trim() || urlError) return; setTesting(true); setTestResult(null); try { const result = await window.metona?.searxng?.testConnection(url.trim(), authKey, authType); if (result?.success) { setTestResult({ success: true, message: `连接成功(${result.latencyMs}ms)` }); } else { setTestResult({ success: false, message: result?.error || `连接失败(HTTP ${result?.statusCode})` }); } } catch (e) { setTestResult({ success: false, message: (e as Error).message }); } setTesting(false); }; return ( {/* 标题 + 状态徽章 */} SearXNG 元搜索 SearXNG 是开源元搜索引擎,支持 70+ 搜索引擎聚合。启用后替代内置四引擎搜索通道,未启用时回退到 Bing + 百度 + 搜狗 + 360 搜索。 {/* 启用开关 */} setEnabled(e.target.checked)} size="small" />} label={启用 SearXNG} /> {/* API 地址 + 连接测试 */} setUrl(e.target.value)} placeholder="如 https://searxng.example.com" error={urlError} helperText={urlError ? '需以 http:// 或 https:// 开头' : '实例根地址(不含 /search 路径)'} sx={{ flex: 1 }} /> {/* 测试结果 */} {testResult && ( {testResult.message} )} {/* 搜索引擎 */} setEngines(e.target.value)} placeholder="如 google,bing,duckduckgo(留空使用实例默认)" /> {/* 语言 + 安全搜索 */} 语言 安全搜索 {/* 时间范围 + 返回格式 */} 时间范围 返回格式 {/* 最大结果数 + 自动抓取条数 */} setMaxResults(Number(e.target.value))} placeholder="0 表示使用默认" slotProps={{ htmlInput: { min: 0, max: 50 } }} /> setFetchCount(Number(e.target.value))} placeholder="0 表示由 AI 决定" slotProps={{ htmlInput: { min: 0, max: 8 } }} /> {/* 抓取类型 */} 抓取类型 {/* 认证设置 */} 认证设置 认证类型 setAuthKey(e.target.value)} placeholder={authType === 'bearer' ? '访问令牌原值' : 'username:password'} slotProps={{ input: { endAdornment: setShowKey(!showKey)}>{showKey ? : } } }} /> {authType === 'bearer' ? 'Bearer: 直接填写令牌原值,原样透传到 Authorization 头。建议配合 HTTPS 使用。' : 'Basic: 填写 username:password 明文串,系统自动 Base64 编码。必须配合 HTTPS 使用。'} ); } function AppearanceSettings({ theme, setTheme }: { theme: ThemeMode; setTheme: (t: ThemeMode) => void }) { return ( 外观 主题 {(['dark', 'light', 'auto'] as ThemeMode[]).map((t) => )} ); } function LogsSettings() { const [logLevel, setLogLevel] = useConfig('logging.level', 'info'); const [clearing, setClearing] = useState(null); // L-11 修复(审计补充): 用 MUI Dialog 替换原生 confirm(),保持 UI 一致性 const [confirmClear, setConfirmClear] = useState<'sessions' | 'memories' | 'auditLogs' | null>(null); // P3-13: 显示日志文件路径,并提供"打开日志文件夹"按钮 // electron-log 默认写入路径为 ${userData}/logs/main.log const [logFilePath, setLogFilePath] = useState(''); const [logPathLoading, setLogPathLoading] = useState(true); const [copyState, setCopyState] = useState<'idle' | 'success' | 'error'>('idle'); // P3-13: 组件挂载时获取日志文件路径 useEffect(() => { let cancelled = false; (async () => { try { const appData = await window.metona?.app?.getAppDataPath?.(); if (cancelled) return; if (appData) { // electron-log 默认日志路径: ${userData}/logs/main.log // 路径分隔符由系统决定,直接拼接避免引入 path 模块 const sep = appData.includes('/') && !appData.includes('\\') ? '/' : '\\'; setLogFilePath(`${appData}${sep}logs${sep}main.log`); } } catch (e) { // 获取失败不阻塞 UI // eslint-disable-next-line no-console console.warn('[LogsSettings] Failed to get app data path:', e); } finally { if (!cancelled) setLogPathLoading(false); } })(); return () => { cancelled = true; }; }, []); const handleOpenLogFolder = async () => { if (!logFilePath) return; try { const r = await window.metona?.app?.showItemInFolder?.(logFilePath); if (r && !r.success) { import('metona-toast').then((mod) => mod.default.error(`打开失败: ${r.error ?? '未知错误'}`)).catch(() => {}); } } catch (e) { import('metona-toast').then((mod) => mod.default.error(`打开失败: ${(e as Error).message}`)).catch(() => {}); } }; const handleCopyLogPath = async () => { if (!logFilePath) return; try { await navigator.clipboard.writeText(logFilePath); setCopyState('success'); setTimeout(() => setCopyState('idle'), 1500); } catch (e) { setCopyState('error'); setTimeout(() => setCopyState('idle'), 1500); import('metona-toast').then((mod) => mod.default.error(`复制失败: ${(e as Error).message}`)).catch(() => {}); } }; const handleExport = async () => { if (!window.metona?.data?.exportData) return; try { 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(); } else { import('metona-toast').then((mod) => mod.default.error(`导出失败: ${r.error ?? '未知错误'}`)).catch(() => {}); } } catch (err) { console.error('[LogsSettings]', err); import('metona-toast').then((mod) => mod.default.error(`导出失败: ${(err as Error).message}`)).catch(() => {}); } }; const labels: Record = { sessions: '所有会话', memories: '所有记忆', auditLogs: '审计日志' }; // L-11 修复(审计补充): 清理数据改用 Dialog 确认,结果用 toast 反馈 const handleClearConfirm = async () => { if (!confirmClear) return; const type = confirmClear; setClearing(type); setConfirmClear(null); 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) { import('metona-toast').then((mod) => mod.default.success(`${labels[type]}已清理`)).catch(() => {}); // 修复: 清理会话后同步清空前端状态,无需重启应用 if (type === 'sessions') { useSessionStore.getState().setSessions([]); useSessionStore.getState().setCurrentSession(null); // 同时清空当前消息列表,防止聊天面板显示已删除的会话内容 useAgentStore.getState().setMessages([]); } // v0.3.6 修复: 清理记忆后触发 MemoryViewer 重新加载(之前需重启应用才看到效果) if (type === 'memories') { useUIStore.getState().bumpMemoryVersion(); } } else { import('metona-toast').then((mod) => mod.default.error(`失败: ${r?.error ?? '未知错误'}`)).catch(() => {}); } } catch (e) { import('metona-toast').then((mod) => mod.default.error(`失败: ${(e as Error).message}`)).catch(() => {}); } setClearing(null); }; return ( 日志与数据 日志级别 {/* P3-13: 日志文件路径展示与打开按钮 */} 日志文件 日志级别变更重启后生效。日志文件按日期滚动,旧日志保留在 logs 目录下。 数据管理 {/* L-11 修复(审计补充): 清理数据确认 Dialog(替代原生 confirm()) */} setConfirmClear(null)} maxWidth="xs" fullWidth > 确认清理 确定清理{confirmClear ? labels[confirmClear] : ''}?此操作不可撤销。 ); }