Files
metona-ai-desktop/src/components/settings/SettingsModal.tsx
T
thzxx cd681b949a feat: 升级至 v0.3.10 — LLM 配置批量保存 + workspace.path 失败感知 + provider 切换防御性修复
- 新增 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 之前的隐含顺序依赖
2026-07-21 13:14:42 +08:00

1360 lines
63 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<SettingsTab>('llm');
if (!settingsOpen) return null;
return (
<Dialog open onClose={closeSettings} maxWidth="md" fullWidth slotProps={{ paper: { sx: { maxWidth: 640, height: '80vh', borderRadius: 3, display: 'flex', flexDirection: 'column' } } }}>
<Stack direction="row" sx={{ px: 2.5, py: 1.5, borderBottom: 1, borderColor: 'divider', justifyContent: 'space-between', alignItems: 'center' }}>
<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 }}>
<ErrorBoundary fallbackTitle="工作空间设置渲染失败">
{tab === 'workspace' && <WorkspaceSettings />}
</ErrorBoundary>
<ErrorBoundary fallbackTitle="LLM 配置渲染失败">
{tab === 'llm' && <LLMSettings />}
</ErrorBoundary>
<ErrorBoundary fallbackTitle="Agent 配置渲染失败">
{tab === 'agent' && <AgentSettings />}
</ErrorBoundary>
<ErrorBoundary fallbackTitle="工具管理渲染失败">
{tab === 'tools' && <ToolsSettings />}
</ErrorBoundary>
<ErrorBoundary fallbackTitle="MCP 服务渲染失败">
{tab === 'mcp' && <MCPSettings />}
</ErrorBoundary>
<ErrorBoundary fallbackTitle="SearXNG 渲染失败">
{tab === 'searxng' && <SearXNGSettings />}
</ErrorBoundary>
<ErrorBoundary fallbackTitle="外观设置渲染失败">
{tab === 'appearance' && <AppearanceSettings theme={theme} setTheme={setTheme} />}
</ErrorBoundary>
<ErrorBoundary fallbackTitle="日志与数据渲染失败">
{tab === 'logs' && <LogsSettings />}
</ErrorBoundary>
</Box>
</Box>
</Dialog>
);
}
function useConfig<T>(key: string, defaultValue: T): [T, (v: T) => void] {
const [value, setValue] = useState<T>(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<string, string> = { 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<string | null>(null);
const [checkResult, setCheckResult] = useState<MetonaWorkspaceCheckResult | null>(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 (
<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} sx={{ 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>
)}
{/* 校验中状态 */}
{checking && (
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', py: 1 }}>
<CircularProgress size={14} />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>正在校验工作空间...</Typography>
</Stack>
)}
{/* 校验失败 */}
{pendingPath && checkResult && !checkResult.valid && (
<Alert severity="error" sx={{ py: 0.5 }}>
<Typography variant="caption">路径无效:{checkResult.reason}</Typography>
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
<Button size="small" onClick={handleCancel}>取消</Button>
</Stack>
</Alert>
)}
{/* 校验成功 — 显示工作空间状态 + 继承选项 */}
{pendingPath && checkResult?.valid && (
<Stack spacing={1.5} sx={{ p: 1.5, borderRadius: 1.5, bgcolor: 'background.default', border: '1px solid', borderColor: 'divider' }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.primary' }}>
目标工作空间:{checkResult.path}
</Typography>
{checkResult.isNewWorkspace ? (
<Alert severity="info" sx={{ py: 0.5, '& .MuiAlert-message': { fontSize: 12 } }}>
新工作空间 切换后将自动创建 4 个必需文件(SOUL.mdAGENTS.mdMEMORY.mdUSERS.md
</Alert>
) : checkResult.missingFiles && checkResult.missingFiles.length > 0 ? (
<Alert severity="warning" sx={{ py: 0.5, '& .MuiAlert-message': { fontSize: 12 } }}>
已有目录但缺少 {checkResult.missingFiles.length} 个文件:{checkResult.missingFiles.join(', ')}。缺失文件将自动创建。
</Alert>
) : (
<Alert severity="success" sx={{ py: 0.5, '& .MuiAlert-message': { fontSize: 12 } }}>
已有工作空间 4 个必需文件均已就绪,将直接加载现有配置。
</Alert>
)}
{/* 继承选项(仅当当前有工作空间且新工作空间需要创建文件时显示) */}
{currentPath && currentPath !== pendingPath && (checkResult.isNewWorkspace || (checkResult.missingFiles && checkResult.missingFiles.length > 0)) && (
<Stack spacing={0.5} sx={{ mt: 0.5 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>
从当前工作空间继承基础设置:
</Typography>
<FormControlLabel
control={<Checkbox size="small" checked={inheritSoul} onChange={(e) => setInheritSoul(e.target.checked)} />}
label={<Typography variant="caption">SOUL.md(身份与角色定义)</Typography>}
/>
<FormControlLabel
control={<Checkbox size="small" checked={inheritAgents} onChange={(e) => setInheritAgents(e.target.checked)} />}
label={<Typography variant="caption">AGENTS.md(行为规则)</Typography>}
/>
<FormControlLabel
control={<Checkbox size="small" checked={inheritUsers} onChange={(e) => setInheritUsers(e.target.checked)} />}
label={<Typography variant="caption">USERS.md(用户画像)</Typography>}
/>
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: 10, pl: 3 }}>
MEMORY.md 不继承(记忆与工作空间项目上下文绑定)
</Typography>
</Stack>
)}
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
<Button
variant="contained"
size="small"
onClick={handleApply}
disabled={applying}
startIcon={applying ? <CircularProgress size={12} /> : undefined}
>
{applying ? '应用中...' : '确认切换'}
</Button>
<Button variant="outlined" size="small" onClick={handleCancel} disabled={applying}>
取消
</Button>
</Stack>
</Stack>
)}
<Typography variant="caption" sx={{ color: 'text.disabled' }}>修改工作空间路径后需重启应用生效。</Typography>
{/* 重启确认对话框 */}
<Dialog open={showRestartDialog} onClose={() => setShowRestartDialog(false)} maxWidth="xs" fullWidth>
<DialogTitle sx={{ fontSize: 14 }}>工作空间已切换</DialogTitle>
<DialogContent>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
工作空间已更新为:
</Typography>
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: 12, mt: 0.5, p: 1, borderRadius: 1, bgcolor: 'background.default' }}>
{workspacePath}
</Typography>
<Typography variant="body2" sx={{ mt: 1.5, color: 'text.secondary' }}>
需要重启应用以加载新工作空间的配置和文件。是否立即重启?
</Typography>
</DialogContent>
<DialogActions>
<Button size="small" onClick={() => setShowRestartDialog(false)}>稍后手动重启</Button>
<Button size="small" variant="contained" color="primary" onClick={() => window.metona?.app?.restart()}>立即重启</Button>
</DialogActions>
</Dialog>
</Stack>
);
}
function LLMSettings() {
// 改为本地 state + Save 按钮统一提交,避免 onChange 实时落库导致:
// 1. 改 Base URL 时被回滚卡死(useConfig seqRef 机制与连续输入冲突)
// 2. 每按一个字符就触发一次 IPC + DB + reloadAdapter,浪费且会打断输入
// 3. 错误提示笼统(不指向具体字段)
const [provider, setProvider] = useState<string>('');
const [model, setModel] = useState<string>('');
const [apiKey, setApiKey] = useState<string>('');
const [baseURL, setBaseURL] = useState<string>('');
const [numCtx, setNumCtx] = useState<number | null>(null);
// v0.3.1: DeepSeek/Agnes/MiMo contextWindow 可配置(不再写死)
const [dsCtxWindow, setDsCtxWindow] = useState<number>(1000000);
const [agnesCtxWindow, setAgnesCtxWindow] = useState<number>(1000000);
const [mimoCtxWindow, setMimoCtxWindow] = useState<number>(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 (
<Stack spacing={2}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>LLM 配置</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>加载中...</Typography>
</Stack>
);
}
const apiKeyEmpty = provider !== 'ollama' && !apiKey.trim();
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) => handleProviderChange(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"
error={urlError}
helperText={urlError ? '需以 http:// 或 https:// 开头' : ' '}
/>
<TextField
size="small"
label="模型名称"
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder="如 deepseek-v4-pro、qwen3:latest"
error={modelHasSpace}
helperText={modelHasSpace ? '模型名称不能包含空格' : ' '}
/>
{provider !== 'ollama' && (
<>
<TextField
size="small"
label="API Key"
type={showKey ? 'text' : 'password'}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="sk-..."
error={apiKeyEmpty}
helperText={apiKeyEmpty ? `必填,未填 ${PROVIDER_LABELS[provider] ?? provider} 的 API Key 会 401` : ' '}
slotProps={{ input: { endAdornment: <IconButton size="small" onClick={() => setShowKey(!showKey)}>{showKey ? <EyeOff size={14} /> : <Eye size={14} />}</IconButton> } }}
/>
</>
)}
{provider === 'ollama' && (
<TextField
size="small"
label="上下文长度 (num_ctx)"
type="number"
value={numCtx ?? ''}
onChange={(e) => {
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' && (
<TextField
size="small"
label="上下文窗口 (contextWindow)"
type="number"
value={dsCtxWindow}
onChange={(e) => setDsCtxWindow(Number(e.target.value) || 1000000)}
placeholder="如 64000、128000、1000000"
slotProps={{ htmlInput: { min: 4096, step: 4096 } }}
error={dsCtxError}
helperText={dsCtxError ? '最小值为 4096' : '用于上下文压缩判断,不传给 API'}
/>
)}
{provider === 'agnes' && (
<TextField
size="small"
label="上下文窗口 (contextWindow)"
type="number"
value={agnesCtxWindow}
onChange={(e) => setAgnesCtxWindow(Number(e.target.value) || 1000000)}
placeholder="如 64000、128000、1000000"
slotProps={{ htmlInput: { min: 4096, step: 4096 } }}
error={agnesCtxError}
helperText={agnesCtxError ? '最小值为 4096' : '用于上下文压缩判断,不传给 API'}
/>
)}
{provider === 'mimo' && (
<TextField
size="small"
label="上下文窗口 (contextWindow)"
type="number"
value={mimoCtxWindow}
onChange={(e) => setMimoCtxWindow(Number(e.target.value) || 131072)}
placeholder="如 32768、65536、131072"
slotProps={{ htmlInput: { min: 4096, step: 4096 } }}
error={mimoCtxError}
helperText={mimoCtxError ? '最小值为 4096' : '官方未公布具体值,默认 131072,用于压缩判断'}
/>
)}
{/* Save 按钮:批量提交,取消 onChange 实时落库 */}
<Stack direction="row" spacing={1} sx={{ mt: 1, alignItems: 'center' }}>
<Button
variant="contained"
size="small"
onClick={handleSave}
disabled={saving || hasBlockingError}
startIcon={saving ? <CircularProgress size={12} /> : undefined}
>
{saving ? '保存中...' : '保存配置'}
</Button>
{hasBlockingError && (
<Typography variant="caption" sx={{ color: 'error.main', fontSize: 11 }}>
请修正表单错误后再保存
</Typography>
)}
</Stack>
</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');
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 (
<Stack spacing={2}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>Agent 配置</Typography>
<FormControl size="small">
<InputLabel>最大迭代次数</InputLabel>
<Select value={maxIter} label="最大迭代次数" onChange={(e) => setMaxIter(e.target.value as number)}>
{MAX_ITER_OPTIONS.map((n) => (
<MenuItem key={n} value={n}>{n}</MenuItem>
))}
</Select>
</FormControl>
<TextField
size="small"
label="总超时(秒)"
type="number"
value={timeout / 1000}
onChange={(e) => {
const v = Number(e.target.value);
if (v >= 120 && v <= 3600) setTimeout_(v * 1000);
}}
slotProps={{ htmlInput: { min: 120, max: 3600, step: 30 } }}
/>
<TextField
size="small"
label="工具执行超时(秒)"
type="number"
value={toolExecTimeout / 1000}
onChange={(e) => {
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 秒)"
/>
<TextField
size="small"
label="工具确认超时(秒)"
type="number"
value={confirmTimeout / 1000}
onChange={(e) => {
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 秒)"
/>
<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; requiresPermission: boolean; enabled: boolean }>>([]);
const [autoExecList, setAutoExecList] = useState<string[]>([]);
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<string, 'success' | 'info' | 'warning' | 'error'> = { 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<typeof t> => t !== undefined);
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" sx={{ py: 1, px: 1.5, borderRadius: 1.5, bgcolor: 'secondary.main', justifyContent: 'space-between', alignItems: 'center' }}>
<Stack direction="row" spacing={1} sx={{ minWidth: 0, flex: 1, alignItems: 'center' }}>
<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} sx={{ 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>
))}
<Divider sx={{ my: 1 }} />
{/* ===== 自动执行工具管理 ===== */}
<Stack direction="row" sx={{ alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>自动执行工具</Typography>
<Chip label={`${autoExecList.length} 个`} size="small" color={autoExecList.length > 0 ? 'success' : 'default'} variant="outlined" sx={{ height: 18, fontSize: 10 }} />
</Stack>
<Typography variant="caption" sx={{ color: 'text.secondary', lineHeight: 1.5 }}>
已设为自动执行的工具将跳过用户确认步骤,直接执行。此设置跨会话持久化。
</Typography>
{/* 已自动执行的工具列表 */}
{autoExecToolDetails.length === 0 ? (
<Typography variant="caption" sx={{ textAlign: 'center', py: 2, color: 'text.disabled', fontStyle: 'italic' }}>
暂无自动执行工具
</Typography>
) : (
autoExecToolDetails.map((t) => (
<Stack
key={t.name}
direction="row"
sx={(theme) => ({
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',
})}
>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', minWidth: 0, flex: 1 }}>
<Box
component="span"
sx={{
width: 6, height: 6, borderRadius: '50%',
bgcolor: 'success.main', flexShrink: 0,
}}
/>
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: 12, color: 'text.primary', fontWeight: 600 }}>
{t.name}
</Typography>
<Chip label="自动" size="small" color="success" sx={{ height: 16, fontSize: 9 }} />
</Stack>
<Button
size="small"
color="error"
variant="contained"
sx={{ fontSize: 11, minWidth: 72, fontWeight: 600 }}
onClick={() => handleSetAutoExec(t.name, false)}
>
取消自动
</Button>
</Stack>
))
)}
{/* 可设为自动执行的工具(需要确认但未设置) */}
{pendingConfirmTools.length > 0 && (
<>
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary', mt: 1 }}>
可设为自动执行(当前需要确认)
</Typography>
{pendingConfirmTools.map((t) => (
<Stack key={t.name} direction="row" sx={{ py: 1, px: 1.5, borderRadius: 1.5, bgcolor: 'secondary.main', border: '1px dashed', borderColor: 'divider', justifyContent: 'space-between', alignItems: 'center' }}>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', minWidth: 0, flex: 1 }}>
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: 12, color: 'text.primary' }}>{t.name}</Typography>
<Chip label={t.riskLevel.toUpperCase()} size="small" color={riskColors[t.riskLevel]} variant="outlined" sx={{ height: 16, fontSize: 9 }} />
</Stack>
<Button size="small" color="success" variant="contained" sx={{ fontSize: 11, minWidth: 72, fontWeight: 600 }} onClick={() => handleSetAutoExec(t.name, true)}>
设为自动
</Button>
</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('');
// L-11 修复: 用 MUI Dialog 替换浏览器原生 confirm(),保持 UI 一致性
const [confirmRemove, setConfirmRemove] = useState<string | null>(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<string, string> = { connected: 'success.main', connecting: 'warning.main', disconnected: 'text.secondary', error: 'error.main' };
// L-11 修复: 确认移除 MCP 服务
// 审计补充修复: 添加 try/catch,避免 removeServer reject 时 Dialog 卡死无法关闭
const [removeError, setRemoveError] = useState<string | null>(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 (
<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" sx={{ py: 1, px: 1.5, borderRadius: 1.5, bgcolor: 'secondary.main', justifyContent: 'space-between', alignItems: 'center' }}>
<Stack direction="row" spacing={1} sx={{ 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 () => {
try {
const r = await window.metona?.mcp?.toggleServer(s.name, s.status !== 'connected');
if (r?.success) {
loadServers();
} 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(`操作失败:${(err as Error).message}`)).catch(() => {});
}
}}>{s.status === 'connected' ? '断开' : '连接'}</Button>
{/* L-11 修复: 点击移除打开 MUI Dialog 二次确认,而非原生 confirm() */}
<Button size="small" color="error" sx={{ fontSize: 10, minWidth: 40 }} onClick={() => setConfirmRemove(s.name)}>移除</Button>
</Stack>
</Stack>
))}
{/* L-11 修复: MUI Dialog 替代原生 confirm() */}
<Dialog
open={confirmRemove !== null}
onClose={() => { setConfirmRemove(null); setRemoveError(null); }}
maxWidth="xs"
fullWidth
>
<DialogTitle>确认移除</DialogTitle>
<DialogContent>
<Typography variant="body2">
确定移除 MCP 服务 "{confirmRemove}"?此操作不可撤销。
</Typography>
{removeError && (
<Alert severity="error" sx={{ mt: 1, fontSize: 12 }}>移除失败:{removeError}</Alert>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => { setConfirmRemove(null); setRemoveError(null); }} color="inherit">取消</Button>
<Button onClick={handleConfirmRemove} color="error" variant="contained">移除</Button>
</DialogActions>
</Dialog>
{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 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 (
<Stack spacing={2}>
{/* 标题 + 状态徽章 */}
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>SearXNG 元搜索</Typography>
<Chip label={enabled ? '已启用' : '未启用'} size="small" color={enabled ? 'success' : 'default'} variant={enabled ? 'filled' : 'outlined'} />
</Stack>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
SearXNG 是开源元搜索引擎,支持 70+ 搜索引擎聚合。启用后替代内置四引擎搜索通道,未启用时回退到 Bing + 百度 + 搜狗 + 360 搜索。
</Typography>
{/* 启用开关 */}
<FormControlLabel control={<Switch checked={enabled} onChange={(e) => setEnabled(e.target.checked)} size="small" />} label={<Typography variant="body2">启用 SearXNG</Typography>} />
<Divider />
{/* API 地址 + 连接测试 */}
<Stack direction="row" spacing={1} sx={{ alignItems: 'flex-start' }}>
<TextField
size="small"
label="API 地址"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="如 https://searxng.example.com"
error={urlError}
helperText={urlError ? '需以 http:// 或 https:// 开头' : '实例根地址(不含 /search 路径)'}
sx={{ flex: 1 }}
/>
<Button variant="outlined" size="small" onClick={handleTest} disabled={!url.trim() || urlError || testing} sx={{ mt: 0.5, minWidth: 90, height: 40 }}>
{testing ? <CircularProgress size={14} /> : '测试连接'}
</Button>
</Stack>
{/* 测试结果 */}
{testResult && (
<Alert severity={testResult.success ? 'success' : 'error'} sx={{ py: 0.5, '& .MuiAlert-message': { fontSize: 12 } }}>
{testResult.message}
</Alert>
)}
{/* 搜索引擎 */}
<TextField
size="small"
label="搜索引擎(逗号分隔)"
value={engines}
onChange={(e) => setEngines(e.target.value)}
placeholder="如 google,bing,duckduckgo(留空使用实例默认)"
/>
{/* 语言 + 安全搜索 */}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
<FormControl size="small"><InputLabel>语言</InputLabel>
<Select value={language} label="语言" onChange={(e) => setLanguage(e.target.value)}>
<MenuItem value="zh-CN">简体中文</MenuItem>
<MenuItem value="zh-TW">繁體中文</MenuItem>
<MenuItem value="en">English</MenuItem>
<MenuItem value="ja">日本語</MenuItem>
<MenuItem value="ko">한국어</MenuItem>
<MenuItem value="auto">自动检测</MenuItem>
</Select>
</FormControl>
<FormControl size="small"><InputLabel>安全搜索</InputLabel>
<Select value={safesearch} label="安全搜索" onChange={(e) => setSafesearch(e.target.value as number)}>
<MenuItem value={0}>关闭</MenuItem>
<MenuItem value={1}>中等</MenuItem>
<MenuItem value={2}>严格</MenuItem>
</Select>
</FormControl>
</Box>
{/* 时间范围 + 返回格式 */}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
<FormControl size="small"><InputLabel>时间范围</InputLabel>
<Select value={timeRange} label="时间范围" onChange={(e) => setTimeRange(e.target.value)}>
<MenuItem value="">不限</MenuItem>
<MenuItem value="day">一天</MenuItem>
<MenuItem value="week">一周</MenuItem>
<MenuItem value="month">一月</MenuItem>
<MenuItem value="year">一年</MenuItem>
</Select>
</FormControl>
<FormControl size="small"><InputLabel>返回格式</InputLabel>
<Select value={format} label="返回格式" onChange={(e) => setFormat(e.target.value)}>
<MenuItem value="json">JSON(结构化解析)</MenuItem>
<MenuItem value="html">HTML(原始网页)</MenuItem>
</Select>
</FormControl>
</Box>
{/* 最大结果数 + 自动抓取条数 */}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
<TextField
size="small"
label="最大结果数"
type="number"
value={maxResults}
onChange={(e) => setMaxResults(Number(e.target.value))}
placeholder="0 表示使用默认"
slotProps={{ htmlInput: { min: 0, max: 50 } }}
/>
<TextField
size="small"
label="自动抓取条数"
type="number"
value={fetchCount}
onChange={(e) => setFetchCount(Number(e.target.value))}
placeholder="0 表示由 AI 决定"
slotProps={{ htmlInput: { min: 0, max: 8 } }}
/>
</Box>
{/* 抓取类型 */}
<FormControl size="small"><InputLabel>抓取类型</InputLabel>
<Select value={fetchMode} label="抓取类型" onChange={(e) => setFetchMode(e.target.value)}>
<MenuItem value="sequential">顺序抓取</MenuItem>
<MenuItem value="random">随机抓取</MenuItem>
</Select>
</FormControl>
<Divider />
{/* 认证设置 */}
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>认证设置</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: 2 }}>
<FormControl size="small"><InputLabel>认证类型</InputLabel>
<Select value={authType} label="认证类型" onChange={(e) => setAuthType(e.target.value)}>
<MenuItem value="bearer">Bearer Token</MenuItem>
<MenuItem value="basic">Basic Auth</MenuItem>
</Select>
</FormControl>
<TextField
size="small"
label={authType === 'bearer' ? 'Token' : '用户名:密码'}
type={showKey ? 'text' : 'password'}
value={authKey}
onChange={(e) => setAuthKey(e.target.value)}
placeholder={authType === 'bearer' ? '访问令牌原值' : 'username:password'}
slotProps={{ input: { endAdornment: <IconButton size="small" onClick={() => setShowKey(!showKey)}>{showKey ? <EyeOff size={14} /> : <Eye size={14} />}</IconButton> } }}
/>
</Box>
<Typography variant="caption" sx={{ color: 'text.disabled' }}>
{authType === 'bearer'
? 'Bearer: 直接填写令牌原值,原样透传到 Authorization 头。建议配合 HTTPS 使用。'
: 'Basic: 填写 username:password 明文串,系统自动 Base64 编码。必须配合 HTTPS 使用。'}
</Typography>
</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);
// 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<string>('');
const [logPathLoading, setLogPathLoading] = useState<boolean>(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<string, string> = { 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 (
<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>
{/* P3-13: 日志文件路径展示与打开按钮 */}
<Box>
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary', mb: 0.5, display: 'block' }}>日志文件</Typography>
<TextField
size="small"
fullWidth
value={logFilePath}
placeholder={logPathLoading ? '正在获取路径...' : '路径不可用'}
readOnly
slotProps={{ input: { sx: { fontSize: 11, fontFamily: 'monospace' } } }}
/>
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
<Button
variant="outlined"
size="small"
startIcon={<Folder size={14} />}
onClick={handleOpenLogFolder}
disabled={!logFilePath}
>
打开日志文件夹
</Button>
<Button
variant="outlined"
size="small"
startIcon={<Copy size={14} />}
onClick={handleCopyLogPath}
disabled={!logFilePath}
color={copyState === 'success' ? 'success' : copyState === 'error' ? 'error' : 'inherit'}
>
{copyState === 'success' ? '已复制' : copyState === 'error' ? '复制失败' : '复制路径'}
</Button>
</Stack>
<Typography variant="caption" sx={{ color: 'text.disabled', mt: 0.5, display: 'block' }}>
日志级别变更重启后生效。日志文件按日期滚动,旧日志保留在 logs 目录下。
</Typography>
</Box>
<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={() => setConfirmClear('sessions')} disabled={clearing !== null}>{clearing === 'sessions' ? '清理中...' : '🗑️ 清理所有会话'}</Button>
<Button variant="outlined" fullWidth size="small" color="error" onClick={() => setConfirmClear('memories')} disabled={clearing !== null}>{clearing === 'memories' ? '清理中...' : '🗑️ 清理所有记忆'}</Button>
<Button variant="outlined" fullWidth size="small" color="error" onClick={() => setConfirmClear('auditLogs')} disabled={clearing !== null}>{clearing === 'auditLogs' ? '清理中...' : '🗑️ 清理审计日志'}</Button>
{/* L-11 修复(审计补充): 清理数据确认 Dialog(替代原生 confirm() */}
<Dialog
open={confirmClear !== null}
onClose={() => setConfirmClear(null)}
maxWidth="xs"
fullWidth
>
<DialogTitle>确认清理</DialogTitle>
<DialogContent>
<Typography variant="body2">
确定清理{confirmClear ? labels[confirmClear] : ''}?此操作不可撤销。
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setConfirmClear(null)} color="inherit">取消</Button>
<Button onClick={handleClearConfirm} color="error" variant="contained">清理</Button>
</DialogActions>
</Dialog>
</Stack>
);
}