feat: v0.4.1 质量加固版 — 工程化基线 + 安全加固 + 测试补齐 + 体验升级
工程化(从零到一): - 新增 Gitea Actions CI(debian-latest):类型检查 + Lint + 单元测试 + 产物编译验证 - 新增 husky + lint-staged 预提交钩子(lint-staged + typecheck 门禁) - 移除坏脚本 test:e2e(无 Playwright 配置必失败);prebuild 改用内置 fs.rmSync - 依赖清理:移除死依赖 sql.js(2MB)/@playwright/test,@types/shell-quote 移至 devDependencies 安全加固: - PolicyEngine 频率限制按会话隔离(多会话并发不再互抢配额) - ConfirmationHook 拒绝记忆加 10 分钟 TTL + 恢复询问入口(新增 2 个 IPC 通道) - Windows run_command 白名单工具(git/node/npm/npx/pnpm/yarn/tsc)改走 cmd.exe /c + 参数数组执行,收窄 shell 注入面 - web_search 四引擎 HTML 解析迁移 node-html-parser(结构化主层 + 正则降级) 缺陷修复(测试驱动发现): - mapError 大小写缺陷:网络错误码永远落入 UNKNOWN 无法触发重试 - 搜狗解析器自我过滤:相对链接补全后又被 sogou.com 过滤导致结果全丢 - 百度复合类名重复收录:class="result c-container" 被双重匹配 测试补齐(113 → 194 用例): - 新增 5 个测试文件:sse-stream / base-adapter / confirmation-hook / ipc-agent 编排链路 / web-search 解析器 - 覆盖 sendMessage 全分支、SSE 流解析、错误映射、确认钩子竞态/超时/批量审批 体验升级: - OutputValidator 验证结果可见化(VALIDATION 流事件 → 聊天流提示卡) - SettingsModal 巨型组件拆分(1503 行 → 10 个文件,可独立维护) - MessageList 接入 react-virtuoso 真虚拟滚动(千条消息恒定开销) - MCP 新增 streamable HTTP 传输支持(SDK 内置传输 + DB 迁移 6 + UI 双模式)
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* AgentSettings — Agent 配置 Tab
|
||||
*
|
||||
* 从 SettingsModal.tsx 提取(v0.4.1 拆分)。
|
||||
* 功能:迭代次数 / 总超时 / 工具超时 / 确认超时 / 思考模式配置。
|
||||
*/
|
||||
|
||||
import {
|
||||
TextField,
|
||||
Select,
|
||||
MenuItem,
|
||||
Stack,
|
||||
Typography,
|
||||
Checkbox,
|
||||
FormControlLabel,
|
||||
InputLabel,
|
||||
FormControl,
|
||||
} from '@mui/material';
|
||||
import { useConfig } from './useConfig';
|
||||
|
||||
export 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* AppearanceSettings — 外观设置 Tab
|
||||
*
|
||||
* 从 SettingsModal.tsx 提取(v0.4.1 拆分)。
|
||||
* 功能:主题切换(深色/浅色/跟随系统)。
|
||||
*/
|
||||
|
||||
import { Button, Stack, Typography, Box } from '@mui/material';
|
||||
import type { ThemeMode } from '@renderer/stores/ui-store';
|
||||
|
||||
export 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
/**
|
||||
* LLMSettings — LLM 配置 Tab
|
||||
*
|
||||
* 从 SettingsModal.tsx 提取(v0.4.1 拆分)。
|
||||
* 功能:主 Provider / 故障转移 Provider / 上下文窗口配置,批量保存。
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Button,
|
||||
TextField,
|
||||
Select,
|
||||
MenuItem,
|
||||
Stack,
|
||||
Typography,
|
||||
Divider,
|
||||
InputLabel,
|
||||
FormControl,
|
||||
IconButton,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
import { PROVIDER_LABELS } from '@renderer/lib/constants';
|
||||
import { PROVIDER_URLS } from './useConfig';
|
||||
|
||||
export 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>(1000000);
|
||||
// P3: OpenAI/Anthropic contextWindow
|
||||
const [oaCtxWindow, setOaCtxWindow] = useState<number>(128000);
|
||||
const [anthropicCtxWindow, setAnthropicCtxWindow] = useState<number>(200000);
|
||||
// P1: 故障转移 Provider 配置
|
||||
const [fbProvider, setFbProvider] = useState<string>('');
|
||||
const [fbModel, setFbModel] = useState<string>('');
|
||||
const [fbApiKey, setFbApiKey] = useState<string>('');
|
||||
const [fbBaseURL, setFbBaseURL] = useState<string>('');
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [showFbKey, setShowFbKey] = 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 results = 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'),
|
||||
window.metona.config.get('openai.contextWindow'),
|
||||
window.metona.config.get('anthropic.contextWindow'),
|
||||
// P1: 故障转移配置
|
||||
window.metona.config.get('llm.fallbackProvider'),
|
||||
window.metona.config.get('llm.fallbackModel'),
|
||||
window.metona.config.get('llm.fallbackApiKey'),
|
||||
window.metona.config.get('llm.fallbackBaseURL'),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
const [p, m, k, u, nc, ds, ag, mi, oa, an, fbp, fbm, fbk, fbu] = results;
|
||||
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);
|
||||
if (typeof oa === 'number' && oa > 0) setOaCtxWindow(oa);
|
||||
if (typeof an === 'number' && an > 0) setAnthropicCtxWindow(an);
|
||||
setFbProvider((fbp as string) ?? '');
|
||||
setFbModel((fbm as string) ?? '');
|
||||
setFbApiKey((fbk as string) ?? '');
|
||||
setFbBaseURL((fbu as string) ?? '');
|
||||
} catch (err) {
|
||||
console.error('[LLMSettings]', 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 });
|
||||
} else if (provider === 'openai') {
|
||||
if (oaCtxWindow != null && oaCtxWindow > 0)
|
||||
useAgentStore.setState({ contextWindow: oaCtxWindow });
|
||||
} else if (provider === 'anthropic') {
|
||||
if (anthropicCtxWindow != null && anthropicCtxWindow > 0)
|
||||
useAgentStore.setState({ contextWindow: anthropicCtxWindow });
|
||||
}
|
||||
}, [
|
||||
provider,
|
||||
numCtx,
|
||||
dsCtxWindow,
|
||||
agnesCtxWindow,
|
||||
mimoCtxWindow,
|
||||
oaCtxWindow,
|
||||
anthropicCtxWindow,
|
||||
]);
|
||||
|
||||
// ===== 字段级 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;
|
||||
const oaCtxError = !Number.isFinite(oaCtxWindow) || oaCtxWindow < 4096;
|
||||
const anthropicCtxError = !Number.isFinite(anthropicCtxWindow) || anthropicCtxWindow < 4096;
|
||||
|
||||
// 是否存在阻断保存的错误(API Key 为空只警告,不阻断 — 允许先填其他字段再回来填 key)
|
||||
const hasBlockingError =
|
||||
urlError ||
|
||||
modelHasSpace ||
|
||||
numCtxError ||
|
||||
(provider === 'deepseek' && dsCtxError) ||
|
||||
(provider === 'agnes' && agnesCtxError) ||
|
||||
(provider === 'mimo' && mimoCtxError) ||
|
||||
(provider === 'openai' && oaCtxError) ||
|
||||
(provider === 'anthropic' && anthropicCtxError);
|
||||
|
||||
// 切换 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-team/metona-toast')
|
||||
.then((mod) => mod.default.error('请修正表单中的错误后再保存'))
|
||||
.catch(() => {});
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const setBatch = window.metona?.config?.setBatch;
|
||||
if (!setBatch) {
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error('配置 API 不可用'))
|
||||
.catch(() => {});
|
||||
return;
|
||||
}
|
||||
// v0.3.9: 批量保存,避免串行保存中间态触发 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 },
|
||||
{ key: 'openai.contextWindow', value: oaCtxWindow },
|
||||
{ key: 'anthropic.contextWindow', value: anthropicCtxWindow },
|
||||
// P1: 故障转移 Provider(主 Provider 失败时切换)
|
||||
{ key: 'llm.fallbackProvider', value: fbProvider },
|
||||
{ key: 'llm.fallbackModel', value: fbModel },
|
||||
{ key: 'llm.fallbackApiKey', value: fbApiKey },
|
||||
{ key: 'llm.fallbackBaseURL', value: fbBaseURL },
|
||||
];
|
||||
const r = await setBatch(entries);
|
||||
if (r && !r.success) {
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(r.error ?? '配置保存失败'))
|
||||
.catch(() => {});
|
||||
} else {
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.success('配置已保存'))
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
import('@metona-team/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>
|
||||
<MenuItem value="openai">OpenAI</MenuItem>
|
||||
<MenuItem value="anthropic">Anthropic</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、gpt-4o、claude-sonnet-4-5"
|
||||
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) || 1000000)}
|
||||
placeholder="如 65536、131072、1000000"
|
||||
slotProps={{ htmlInput: { min: 4096, step: 4096 } }}
|
||||
error={mimoCtxError}
|
||||
helperText={mimoCtxError ? '最小值为 4096' : '默认 1000000(1M),用于上下文压缩判断'}
|
||||
/>
|
||||
)}
|
||||
{provider === 'openai' && (
|
||||
<TextField
|
||||
size="small"
|
||||
label="上下文窗口 (contextWindow)"
|
||||
type="number"
|
||||
value={oaCtxWindow}
|
||||
onChange={(e) => setOaCtxWindow(Number(e.target.value) || 128000)}
|
||||
placeholder="如 128000、200000、1000000"
|
||||
slotProps={{ htmlInput: { min: 4096, step: 4096 } }}
|
||||
error={oaCtxError}
|
||||
helperText={oaCtxError ? '最小值为 4096' : 'gpt-4o 默认 128K,gpt-4.1 默认 1M'}
|
||||
/>
|
||||
)}
|
||||
{provider === 'anthropic' && (
|
||||
<TextField
|
||||
size="small"
|
||||
label="上下文窗口 (contextWindow)"
|
||||
type="number"
|
||||
value={anthropicCtxWindow}
|
||||
onChange={(e) => setAnthropicCtxWindow(Number(e.target.value) || 200000)}
|
||||
placeholder="如 200000"
|
||||
slotProps={{ htmlInput: { min: 4096, step: 4096 } }}
|
||||
error={anthropicCtxError}
|
||||
helperText={anthropicCtxError ? '最小值为 4096' : 'Claude 默认 200K'}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ===== P1: 故障转移 Provider(主 Provider 请求失败时自动切换) ===== */}
|
||||
<Divider />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
故障转移(可选)
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
主 Provider 请求失败(重试耗尽或密钥失效)时自动切换到备用 Provider 重发。留空禁用。
|
||||
</Typography>
|
||||
<FormControl size="small">
|
||||
<InputLabel>备用 Provider</InputLabel>
|
||||
<Select
|
||||
value={fbProvider}
|
||||
label="备用 Provider"
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setFbProvider(v);
|
||||
setFbModel('');
|
||||
setFbApiKey('');
|
||||
setFbBaseURL(PROVIDER_URLS[v] ?? '');
|
||||
}}
|
||||
>
|
||||
<MenuItem value="">
|
||||
<em>禁用</em>
|
||||
</MenuItem>
|
||||
<MenuItem value="deepseek">DeepSeek</MenuItem>
|
||||
<MenuItem value="agnes">Agnes AI</MenuItem>
|
||||
<MenuItem value="mimo">MiMo (小米)</MenuItem>
|
||||
<MenuItem value="ollama">Ollama (本地)</MenuItem>
|
||||
<MenuItem value="openai">OpenAI</MenuItem>
|
||||
<MenuItem value="anthropic">Anthropic</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
{fbProvider && (
|
||||
<>
|
||||
<TextField
|
||||
size="small"
|
||||
label="备用 Base URL"
|
||||
value={fbBaseURL}
|
||||
onChange={(e) => setFbBaseURL(e.target.value)}
|
||||
placeholder="如 https://api.deepseek.com"
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
label="备用模型名称"
|
||||
value={fbModel}
|
||||
onChange={(e) => setFbModel(e.target.value)}
|
||||
placeholder="如 deepseek-v4-flash"
|
||||
/>
|
||||
{fbProvider !== 'ollama' && (
|
||||
<TextField
|
||||
size="small"
|
||||
label="备用 API Key"
|
||||
type={showFbKey ? 'text' : 'password'}
|
||||
value={fbApiKey}
|
||||
onChange={(e) => setFbApiKey(e.target.value)}
|
||||
placeholder="sk-..."
|
||||
slotProps={{
|
||||
input: {
|
||||
endAdornment: (
|
||||
<IconButton size="small" onClick={() => setShowFbKey(!showFbKey)}>
|
||||
{showFbKey ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</IconButton>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* LogsSettings — 日志与数据 Tab
|
||||
*
|
||||
* 从 SettingsModal.tsx 提取(v0.4.1 拆分)。
|
||||
* 功能:日志级别、日志文件路径(打开/复制)、数据导出与清理(Dialog 确认)。
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogActions,
|
||||
Button,
|
||||
TextField,
|
||||
Select,
|
||||
MenuItem,
|
||||
Stack,
|
||||
Typography,
|
||||
Divider,
|
||||
Box,
|
||||
InputLabel,
|
||||
FormControl,
|
||||
} from '@mui/material';
|
||||
import { Folder, Copy } from 'lucide-react';
|
||||
import { useConfig } from './useConfig';
|
||||
import { useUIStore } from '@renderer/stores/ui-store';
|
||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
import { useSessionStore } from '@renderer/stores/session-store';
|
||||
|
||||
export 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
|
||||
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-team/metona-toast')
|
||||
.then((mod) => mod.default.error(`打开失败: ${r.error ?? '未知错误'}`))
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch (e) {
|
||||
import('@metona-team/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-team/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-team/metona-toast')
|
||||
.then((mod) => mod.default.error(`导出失败: ${r.error ?? '未知错误'}`))
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[LogsSettings]', err);
|
||||
import('@metona-team/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-team/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-team/metona-toast')
|
||||
.then((mod) => mod.default.error(`失败: ${r?.error ?? '未知错误'}`))
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch (e) {
|
||||
import('@metona-team/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 ? '正在获取路径...' : '路径不可用'}
|
||||
slotProps={{ input: { readOnly: true, 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* MCPSettings — MCP 服务管理 Tab
|
||||
*
|
||||
* 从 SettingsModal.tsx 提取(v0.4.1 拆分)。
|
||||
* 功能:MCP Server 列表/连接/断开/移除(Dialog 二次确认)/添加。
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogActions,
|
||||
Button,
|
||||
TextField,
|
||||
Stack,
|
||||
Typography,
|
||||
Box,
|
||||
Alert,
|
||||
} from '@mui/material';
|
||||
|
||||
export 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('');
|
||||
// v0.4.1: 新增传输方式选择(stdio / streamable-http)与 URL 字段
|
||||
const [newTransport, setNewTransport] = useState<'stdio' | 'streamable-http'>('stdio');
|
||||
const [newUrl, setNewUrl] = 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('[MCPSettings]', 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('[MCPSettings]', err);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
const handleAdd = async () => {
|
||||
// v0.4.1: 按传输方式校验必填字段(stdio→命令,streamable-http→URL)
|
||||
if (!newName.trim()) return;
|
||||
if (newTransport === 'stdio' && !newCommand.trim()) return;
|
||||
if (newTransport === 'streamable-http' && !newUrl.trim()) return;
|
||||
try {
|
||||
const config =
|
||||
newTransport === 'stdio'
|
||||
? {
|
||||
name: newName.trim(),
|
||||
transport: 'stdio' as const,
|
||||
command: newCommand.trim(),
|
||||
args: newArgs.trim() ? newArgs.trim().split(/\s+/) : [],
|
||||
enabled: true,
|
||||
}
|
||||
: {
|
||||
name: newName.trim(),
|
||||
transport: 'streamable-http' as const,
|
||||
url: newUrl.trim(),
|
||||
enabled: true,
|
||||
};
|
||||
const r = await window.metona?.mcp?.addServer(config);
|
||||
if (r?.success) {
|
||||
setNewName('');
|
||||
setNewCommand('');
|
||||
setNewArgs('');
|
||||
setNewUrl('');
|
||||
setNewTransport('stdio');
|
||||
setShowAdd(false);
|
||||
loadServers();
|
||||
} else {
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(r?.error ?? '添加 MCP 服务失败'))
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[MCPSettings]', err);
|
||||
import('@metona-team/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-team/metona-toast')
|
||||
.then((mod) => mod.default.error(r?.error ?? '操作失败'))
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[MCPSettings]', err);
|
||||
import('@metona-team/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="服务名称"
|
||||
/>
|
||||
{/* v0.4.1: 传输方式选择(stdio 本地命令 / streamable-http 远程) */}
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button
|
||||
variant={newTransport === 'stdio' ? 'contained' : 'outlined'}
|
||||
size="small"
|
||||
onClick={() => setNewTransport('stdio')}
|
||||
sx={{ flex: 1 }}
|
||||
>
|
||||
本地 (stdio)
|
||||
</Button>
|
||||
<Button
|
||||
variant={newTransport === 'streamable-http' ? 'contained' : 'outlined'}
|
||||
size="small"
|
||||
onClick={() => setNewTransport('streamable-http')}
|
||||
sx={{ flex: 1 }}
|
||||
>
|
||||
远程 (HTTP)
|
||||
</Button>
|
||||
</Stack>
|
||||
{newTransport === 'stdio' ? (
|
||||
<>
|
||||
<TextField
|
||||
size="small"
|
||||
value={newCommand}
|
||||
onChange={(e) => setNewCommand(e.target.value)}
|
||||
placeholder="命令路径(如 npx / node / python)"
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
value={newArgs}
|
||||
onChange={(e) => setNewArgs(e.target.value)}
|
||||
placeholder="参数 (空格分隔)"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<TextField
|
||||
size="small"
|
||||
value={newUrl}
|
||||
onChange={(e) => setNewUrl(e.target.value)}
|
||||
placeholder="Streamable HTTP URL(如 https://example.com/mcp)"
|
||||
/>
|
||||
)}
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={handleAdd}
|
||||
disabled={
|
||||
!newName.trim() || (newTransport === 'stdio' ? !newCommand.trim() : !newUrl.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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* SearXNGSettings — SearXNG 元搜索配置 Tab
|
||||
*
|
||||
* 从 SettingsModal.tsx 提取(v0.4.1 拆分)。
|
||||
* 功能:SearXNG 实例配置(12 项)+ 连接测试。
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
TextField,
|
||||
Select,
|
||||
MenuItem,
|
||||
Stack,
|
||||
Typography,
|
||||
Divider,
|
||||
Chip,
|
||||
Switch,
|
||||
Alert,
|
||||
Box,
|
||||
FormControlLabel,
|
||||
InputLabel,
|
||||
FormControl,
|
||||
IconButton,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import { useConfig } from './useConfig';
|
||||
|
||||
export 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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* ToolsSettings — 工具管理 Tab
|
||||
*
|
||||
* 从 SettingsModal.tsx 提取(v0.4.1 拆分)。
|
||||
* 功能:工具启用/禁用开关、自动执行工具管理(乐观更新 + 失败回滚)。
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button, Stack, Typography, Checkbox, Divider, Chip, Box } from '@mui/material';
|
||||
import { alpha } from '@mui/material/styles';
|
||||
|
||||
export 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('[ToolsSettings]', err);
|
||||
});
|
||||
}
|
||||
};
|
||||
const loadAutoExec = () => {
|
||||
if (window.metona?.tool?.getAutoExecuteList) {
|
||||
window.metona.tool
|
||||
.getAutoExecuteList()
|
||||
.then((r) => {
|
||||
if (r.success) setAutoExecList(r.data);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[ToolsSettings]', 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-team/metona-toast')
|
||||
.then((mod) => mod.default.error(r.error ?? '切换工具失败'))
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[ToolsSettings]', err);
|
||||
setTools((p) => p.map((t) => (t.name === name ? { ...t, enabled: !enabled } : t)));
|
||||
import('@metona-team/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-team/metona-toast')
|
||||
.then((mod) => mod.default.error(r.error ?? '设置自动执行失败'))
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[ToolsSettings]', err);
|
||||
import('@metona-team/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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
/**
|
||||
* WorkspaceSettings — 工作空间设置 Tab
|
||||
*
|
||||
* 从 SettingsModal.tsx 提取(v0.4.1 拆分)。
|
||||
* 功能:工作空间路径选择/校验/切换、SOUL.md 与数据库继承、重启确认。
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogActions,
|
||||
Button,
|
||||
TextField,
|
||||
Stack,
|
||||
Typography,
|
||||
Checkbox,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
FormControlLabel,
|
||||
} from '@mui/material';
|
||||
import { useConfig } from './useConfig';
|
||||
|
||||
export 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);
|
||||
// 数据库继承:默认勾选(历史会话/消息/记忆/Trace 丢失不可逆,默认带过去更安全)
|
||||
const [inheritDatabase, setInheritDatabase] = 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('[WorkspaceSettings]', err);
|
||||
setCheckResult({ valid: false, reason: (err as Error).message });
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleApply = async () => {
|
||||
if (!pendingPath || !checkResult?.valid) return;
|
||||
setApplying(true);
|
||||
try {
|
||||
// 如果勾选了继承文件,从旧工作空间复制到新工作空间
|
||||
// - SOUL.md: Agent 身份定义(仅目标缺少时才继承,避免覆盖已有定义)
|
||||
// - .metona/agent.db: 数据库(仅目标不存在时才继承,避免覆盖已有数据)
|
||||
const filesToInherit: string[] = [];
|
||||
if (inheritSoul && checkResult?.missingFiles?.includes('SOUL.md'))
|
||||
filesToInherit.push('SOUL.md');
|
||||
if (inheritDatabase && !checkResult?.dbExists) filesToInherit.push('.metona/agent.db');
|
||||
|
||||
// #51 修复: 继承数据库前校验源数据库完整性,避免继承损坏的数据库导致新工作空间数据丢失
|
||||
if (inheritDatabase && currentPath) {
|
||||
try {
|
||||
const integrityResult =
|
||||
await window.metona?.workspace?.checkDatabaseIntegrity(currentPath);
|
||||
if (!integrityResult?.success) {
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) =>
|
||||
mod.default.error(`源数据库校验失败:${integrityResult?.error ?? '未知错误'}`),
|
||||
)
|
||||
.catch(() => {});
|
||||
setApplying(false);
|
||||
return;
|
||||
}
|
||||
if (!integrityResult.ok) {
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) =>
|
||||
mod.default.error(`源数据库损坏(${integrityResult.detail}),无法继承`),
|
||||
)
|
||||
.catch(() => {});
|
||||
setApplying(false);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[WorkspaceSettings] Database integrity check failed:', err);
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(`源数据库校验异常:${(err as Error).message}`))
|
||||
.catch(() => {});
|
||||
setApplying(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (filesToInherit.length > 0 && currentPath) {
|
||||
try {
|
||||
await window.metona.workspace.inheritFiles({
|
||||
targetPath: pendingPath,
|
||||
sourcePath: currentPath,
|
||||
files: filesToInherit,
|
||||
});
|
||||
} catch (err) {
|
||||
// 用户勾选了继承文件,失败时必须告知,否则切到新空间才发现文件是空的,潜在数据丢失风险
|
||||
console.error('[WorkspaceSettings] Inherit failed:', err);
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) =>
|
||||
mod.default.warning(`部分文件继承失败:${(err as Error).message},请手动检查`),
|
||||
)
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// 保存新路径到配置
|
||||
setWorkspacePath(pendingPath);
|
||||
// 弹出重启确认对话框
|
||||
setShowRestartDialog(true);
|
||||
// 清理中间状态
|
||||
setPendingPath(null);
|
||||
setCheckResult(null);
|
||||
} catch (err) {
|
||||
console.error('[WorkspaceSettings]', err);
|
||||
// 用户主动操作(切换工作空间)失败必须有反馈
|
||||
import('@metona-team/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-team/metona-toast')
|
||||
.then((mod) => mod.default.error(r.error ?? '打开文件夹失败'))
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[WorkspaceSettings]', err);
|
||||
import('@metona-team/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、MEMORY.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 } }}>
|
||||
新工作空间 — 切换后将自动创建 2 个必需文件(SOUL.md、MEMORY.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 } }}>
|
||||
已有工作空间 — 2 个必需文件均已就绪,将直接加载现有配置。
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 继承选项(当前有工作空间、目标不是同一目录、且目标有可继承的文件时显示) */}
|
||||
{currentPath &&
|
||||
currentPath !== pendingPath &&
|
||||
(checkResult.missingFiles?.includes('SOUL.md') || !checkResult.dbExists) && (
|
||||
<Stack spacing={0.5} sx={{ mt: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>
|
||||
从当前工作空间继承:
|
||||
</Typography>
|
||||
{/* SOUL.md 继承:目标缺少 SOUL.md 时才允许勾选,避免覆盖已有定义 */}
|
||||
{checkResult.missingFiles && checkResult.missingFiles.includes('SOUL.md') && (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={inheritSoul}
|
||||
onChange={(e) => setInheritSoul(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label={<Typography variant="caption">SOUL.md(身份与角色定义)</Typography>}
|
||||
/>
|
||||
)}
|
||||
{/* 数据库继承:目标 .metona/agent.db 不存在时才显示,避免覆盖已有工作空间数据 */}
|
||||
{!checkResult.dbExists && (
|
||||
<>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={inheritDatabase}
|
||||
onChange={(e) => setInheritDatabase(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Typography variant="caption">
|
||||
数据库 agent.db(会话/消息/记忆/Trace 历史记录)
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ color: 'text.disabled', fontSize: 10, pl: 3 }}
|
||||
>
|
||||
勾选后将复制当前工作空间的全部历史数据到新工作空间(通过 SQLite backup API
|
||||
原子性导出)
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* useConfig — 设置面板共享配置读写 Hook
|
||||
*
|
||||
* 从 SettingsModal.tsx 提取(v0.4.1 拆分),供各设置 Tab 组件复用。
|
||||
*
|
||||
* 特性:
|
||||
* - 配置读取:挂载时异步加载,null/undefined 保持默认值
|
||||
* - 配置写入:失败时回滚 UI 并 toast 提示,避免 UI 与 DB 状态不一致
|
||||
* - 竞态保护:seqRef 防止连续修改时旧请求失败回滚覆盖新值
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
|
||||
export 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('[useConfig]', 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-team/metona-toast')
|
||||
.then((mod) => mod.default.error(r.error ?? '配置保存失败'))
|
||||
.catch(() => {});
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error('[useConfig]', err);
|
||||
if (seqRef.current === seq) setValue(prev);
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error('配置保存失败'))
|
||||
.catch(() => {});
|
||||
});
|
||||
},
|
||||
[key],
|
||||
);
|
||||
return [value, set];
|
||||
}
|
||||
|
||||
/** 各 Provider 的默认 Base URL(切换 Provider 时自动填充) */
|
||||
export 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',
|
||||
openai: 'https://api.openai.com/v1',
|
||||
anthropic: 'https://api.anthropic.com',
|
||||
};
|
||||
Reference in New Issue
Block a user