- 新增 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 之前的隐含顺序依赖
1360 lines
63 KiB
TypeScript
1360 lines
63 KiB
TypeScript
/**
|
||
* 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.md、AGENTS.md、MEMORY.md、USERS.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.md、AGENTS.md、MEMORY.md、USERS.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>
|
||
);
|
||
}
|