feat: v0.4.0 四阶段迭代 — 安全加固 + 工程基线 + 架构重构 + 双 Provider 扩展

P0 安全修复:
- API Key 加密存储(safeStorage 密钥链,版本化前缀,历史明文平滑兼容)
- 间接提示注入防护(SecurityScanHook 工具结果深扫描,网络工具脱敏/本地工具警示分级)
- error:report IPC 断链修复(渲染进程错误上报落 electron-log + 审计)
- abort 信号贯通工具层(run_command/dev-tools 子进程随会话中断终止)
- run_command 沙箱加固(cd 系统目录/敏感文件读取拦截 + chcp 前缀剥离防解析退化)
- .env 真实生效(dotenv 回退加载,应用内配置优先)

P1 工程基础:
- ESLint 9 flat config + 全部 34 条存量 warnings 清零(零容忍基线)
- 测试基线 118 用例 11 文件(token/文件防护/权限/沙箱/注入/命令/引擎/注册表/审计链/摘要分层)
- test:electron 双模式(ELECTRON_RUN_AS_NODE 跑 Electron ABI,SQLite 套件全执行)
- SessionRecorder 多会话隔离 + 9 种 TRACE 事件补全(含最终轮 iteration_end)
- Provider 故障转移(重试耗尽/不可重试一次性切换 fallback + 前端通知)
- MCP 真就绪(等待全部连接完成再广播 tools:ready)
- SLO/HealthChecker 真实接入(60s 巡检 + 托盘状态)
- CONFIG_DEFAULTS 单一来源(消除 SEED 双源漂移)

P2 架构升级:
- handlers.ts 1940 行拆分为 13 个 IPC 域模块(防重入注册 + 多窗口广播)
- AgentEngineManager 每会话独立引擎(LRU 30 + adapter 工厂隔离 abort 信号)
- TaskOrchestrator EngineProvider 改造 + abortByParent 联动中断 SubAgent
- 会话摘要分层上下文(session_summaries 滚动摘要 + 截断游标清理防因果污染)
- 消息编辑重发/重新生成(truncateAfter IPC + store 动作 + UI)
- Markdown 导出 / WebSearch 并行抓取(并发 3)/ 记忆 TF 缓存 / 版本构建期注入

P3 能力扩展:
- OpenAI Adapter(o 系列推理模型 reasoning_effort/max_completion_tokens)
- Anthropic Adapter(原生 Messages API:tool_use 块/角色合并/thinking budget/图片 base64/SSE 事件机)
- 设置页/Onboarding 六 Provider 全链路接入
This commit is contained in:
2026-08-20 23:17:02 +08:00
parent b9f7ec5118
commit 2230bcec3f
90 changed files with 6581 additions and 2771 deletions
+124 -10
View File
@@ -107,7 +107,7 @@ function useConfig<T>(key: string, defaultValue: T): [T, (v: T) => void] {
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' };
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' };
function WorkspaceSettings() {
const [workspacePath, setWorkspacePath] = useConfig('workspace.path', '');
@@ -365,7 +365,16 @@ function LLMSettings() {
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);
@@ -378,7 +387,7 @@ function LLMSettings() {
return;
}
try {
const [p, m, k, u, nc, ds, ag, mi] = await Promise.all([
const results = await Promise.all([
window.metona.config.get('llm.provider'),
window.metona.config.get('llm.model'),
window.metona.config.get('llm.apiKey'),
@@ -387,8 +396,16 @@ function LLMSettings() {
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) ?? '');
@@ -397,6 +414,12 @@ function LLMSettings() {
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('[SettingsModal]', err);
} finally {
@@ -423,8 +446,12 @@ function LLMSettings() {
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]);
}, [provider, numCtx, dsCtxWindow, agnesCtxWindow, mimoCtxWindow, oaCtxWindow, anthropicCtxWindow]);
// ===== 字段级 inline 校验 =====
// Base URL:非空时必须以 http:// 或 https:// 开头(避免漏写协议头导致发消息时报 Invalid URL
@@ -436,12 +463,16 @@ function LLMSettings() {
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 === 'mimo' && mimoCtxError) ||
(provider === 'openai' && oaCtxError) ||
(provider === 'anthropic' && anthropicCtxError);
// 切换 Provider 时:清空 apiKey + 清空 model + 自动填充默认 URL
// 不同 Provider 的 key/model 互不通用,避免用旧值调用新 API 导致 401 / model not found
@@ -477,10 +508,6 @@ function LLMSettings() {
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 },
@@ -490,6 +517,13 @@ function LLMSettings() {
{ 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) {
@@ -524,6 +558,8 @@ function LLMSettings() {
<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
@@ -540,7 +576,7 @@ function LLMSettings() {
label="模型名称"
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder="如 deepseek-v4-pro、qwen3:latest"
placeholder="如 deepseek-v4-pro、gpt-4o、claude-sonnet-4-5"
error={modelHasSpace}
helperText={modelHasSpace ? '模型名称不能包含空格' : ' '}
/>
@@ -615,6 +651,85 @@ function LLMSettings() {
helperText={mimoCtxError ? '最小值为 4096' : '默认 10000001M),用于上下文压缩判断'}
/>
)}
{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 默认 128Kgpt-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' }}>
@@ -1230,7 +1345,6 @@ function LogsSettings() {
}
} catch (e) {
// 获取失败不阻塞 UI
// eslint-disable-next-line no-console
console.warn('[LogsSettings] Failed to get app data path:', e);
} finally {
if (!cancelled) setLogPathLoading(false);