/** * SettingsModal — 设置弹窗 */ import { useState, useEffect, useCallback } from 'react'; import { Dialog, DialogContent, DialogTitle, 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 } from 'lucide-react'; import { useUIStore, type ThemeMode } from '@renderer/stores/ui-store'; import { useAgentStore } from '@renderer/stores/agent-store'; import { PROVIDER_LABELS } from '@renderer/lib/constants'; 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); 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) => { setValue(v); window.metona?.config?.set(key, v).catch((err) => { console.error('[SettingsModal]', err); }); }, [key]); return [value, set]; } const PROVIDER_URLS: Record = { 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 [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); } } // 保存新路径到配置 setWorkspacePath(pendingPath); // 弹出重启确认对话框 setShowRestartDialog(true); // 清理中间状态 setPendingPath(null); setCheckResult(null); } catch (err) { console.error('[SettingsModal]', err); } finally { setApplying(false); } }; const handleCancel = () => { setPendingPath(null); setCheckResult(null); }; const handleOpen = () => { if (workspacePath) window.metona?.app?.showItemInFolder(workspacePath); }; 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() { 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 [numCtx, setNumCtx] = useConfig('ollama.numCtx', null as number | null); const [showKey, setShowKey] = useState(false); // 同步 Provider/Model 到 Agent Store(含 contextWindow) useEffect(() => { useAgentStore.getState().setProvider(provider, model); }, [provider, model]); // Ollama numCtx 变化时同步 contextWindow useEffect(() => { if (provider === 'ollama' && numCtx != null && numCtx > 0) { useAgentStore.setState({ contextWindow: numCtx }); } }, [provider, numCtx]); // 切换 Provider 时自动填充默认 URL + 清空 apiKey(不同 Provider 的 key 不通用) const handleProviderChange = (newProvider: string) => { const oldProvider = provider; setProvider(newProvider); // 切换 Provider 时清空 apiKey(不同 Provider 的 key 格式不同,避免用旧 key 调用新 API 导致 401) // Ollama 不需要 apiKey,也一并清空 if (oldProvider !== newProvider && apiKey) { setApiKey(''); } // 自动填充默认 URL(仅在 URL 为空或与旧 provider 默认 URL 匹配时) const currentUrl = baseURL.trim(); const isDefaultUrl = Object.values(PROVIDER_URLS).includes(currentUrl); if (isDefaultUrl || !currentUrl) { setBaseURL(PROVIDER_URLS[newProvider] ?? ''); } }; return ( LLM 配置 Provider setBaseURL(e.target.value)} placeholder="如 https://api.deepseek.com" /> setModel(e.target.value)} placeholder="如 deepseek-v4-pro、qwen3:latest" /> {provider !== 'ollama' && ( <> setApiKey(e.target.value)} placeholder="sk-..." slotProps={{ input: { endAdornment: setShowKey(!showKey)}>{showKey ? : } } }} /> {!apiKey && ( ⚠ 请输入 {PROVIDER_LABELS[provider] ?? provider} 的 API Key,否则发送消息会报错 )} )} {provider === 'ollama' && ( { const v = e.target.value; setNumCtx(v === '' ? null : Number(v)); }} placeholder="默认由模型决定(如 2048、4096、128000)" slotProps={{ htmlInput: { min: 512, step: 512 } }} /> )} ); } 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 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 >= 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 = (name: string, enabled: boolean) => { setTools((p) => p.map((t) => t.name === name ? { ...t, enabled } : t)); window.metona?.tools?.toggle(name, enabled).catch((err) => { console.error('[SettingsModal]', err); }); }; // 设置/取消自动执行 const handleSetAutoExec = async (name: string, enabled: boolean) => { if (!window.metona?.tool?.setAutoExecute) return; const r = await window.metona.tool.setAutoExecute(name, enabled); if (r.success) { setAutoExecList((p) => enabled ? [...p, name] : p.filter((n) => n !== name)); } }; const riskColors: Record = { safe: 'success', low: 'info', medium: 'warning', high: '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(''); const loadServers = () => { if (window.metona?.mcp?.listServers) window.metona.mcp.listServers().then((l) => setServers(l as MetonaMCPServerStatus[])).catch((err) => { console.error('[SettingsModal]', err); }); }; 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+/) : [], enabled: true }); setNewName(''); setNewCommand(''); setNewArgs(''); setShowAdd(false); loadServers(); }; const statusColors: Record = { connected: 'success.main', connecting: 'warning.main', disconnected: 'text.secondary', error: 'error.main' }; return ( MCP 服务 {servers.length === 0 ? 暂无 MCP 服务 : servers.map((s) => ( {s.name} {s.status} {s.toolCount > 0 && {s.toolCount} 工具} ))} {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); 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 = { 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 ( 日志与数据 日志级别 数据管理 ); }