P1 修复面收口: Prompt Cache 根治(日期/记忆/附件三类易变内容出 system 入用户消息 前置块 user-context.ts, system 跨 run 字节级稳定; Anthropic system 块数组化 + cache_control ephemeral 断言, DeepSeek 自动缓存前缀命中 — 多轮对话输入 token 成本降数量级); 编辑重发/重新生成幽灵 Trace 双侧根治(DB truncateMessagesAfter 同步过滤 metadata.traceSteps + 前端 trimTraceStepsByAnchor 镜像, 严格小于锚点 时间戳, 同毫秒等值判废); sessions:deleteMessage 死通道全链路删除(渲染层零调用 + message_count 漂移面); Ollama vision 能力门控全链路(MetonaModelInfo .supportsVision 贯穿 adapter/IPC/store/UI, model-capabilities.ts 三道判定纯函数, 未知保守放行); 记忆固化节流(consolidation-policy 纯函数: 总开关 + 内容门控 [回答>=200字符或存在成功工具调用] + 会话级 10 分钟频率窗口, 三 memory.* 配置键) P2 安全纵深: SSRF DNS Pinning 关闭 rebinding 窗口(ssrf-guard 重构 resolvePublicAddresses 单源; ssrf-dispatcher 以 undici Agent.connect.lookup 钉死校验 IP, TLS SNI 保持原域名, 一次性 dispatcher 用后即毁; 代理激活显式 退化为仅入口校验); web_fetch 重写手动逐跳重定向循环(每跳先校验后连接, 替代 redirect:follow 内核跟跳的中间跳裸奔, 上限 5 跳); http_request 换用 pinned fetch; web_search 可达性预检加固(私有 URL 零请求 + 不跟跳, 3xx 视为 可达); Agent 浏览器 CORS 通配收紧为 Origin 回显 + Vary: Origin; ConfirmationHook.forgetSession 会话终态清理(会话删除/abort 联动/SubAgent 终结三处接线, 根治 rememberedDecisions 泄漏) P3 架构还债: agent.enableReflection 死配置全链路接线(main→shared→引擎→ Orchestrator→设置开关, REFLECTING 状态真实可达); AgentLoopConfig.timeoutMs 死字段删除; MemoryManager.cleanupExpired 挂入健康检查周期(expires_at 回收 管道真实化); buildSafeEnv 收敛 utils/safe-env.ts 单源(run_command 与 MCP stdio 共用, 终结双实现漂移); Trace 生命周期治理(metadata 只保留最近 20 个 run — keepRecentRuns 纯函数; JSONL 录制启动自动清理保留 200 个 + 设置页 手动清理); SLO/健康快照可视化(app:healthSnapshot IPC + 设置页只读卡片 + 审计链一键校验) P4 能力演进: 会话标题 LLM 自动生成(TitleGenerator — 每会话幂等/并发重入复用 同一 Promise/自定义标题不覆盖/失败静默回退, Sidebar 经 config:changed 实时 刷新); MCP 自动重连(5s/15s/60s 退避最多 3 次, reconnecting 状态机, teardownConnection 内部拆除保留簿记 — 用户断开/开关关闭即时取消, 设置页 显示第 N/3 次); 死循环检测 ABAB 乒乓模式(最近4轮 A→B→A→B 交替判定, 补齐 docs 第五章"两状态反复切换"检测契约); i18n 第三阶段(ChatInput/LLMSettings/ OnboardingWizard/MemoryViewer 主链路文案出层, zh-CN + en-US 双字典补齐) 测试: 737 → 824 用例(+87, 新增 8 个测试文件 + 扩展 3 个)。新覆盖: user-context 分组/空值收缩/拼接契约、context-builder 字节级稳定性、Anthropic cache_control 四态、consolidation-policy 九路判定矩阵、ssrf-dispatcher(pinned lookup/重定向 解析/IP 校验)、forget-session 会话隔离、trace-lifecycle run 淘汰、 trace-trim 严格小于边界、safe-env 净化矩阵、mcp-reconnect 退避状态机 (fake timers)、title-generator 并发重入、SQLite 侧 truncate×TRACE 联动 (Electron ABI)。测试驱动修复: GIT_*/ 注释终止块注释、重连计数被自身重试 前置断开重置(拆 teardownConnection 保留簿记)、TitleGenerator 幂等占位与 并发去重的检查顺序竞态(去重先于幂等) 版本: 0.7.3; README 同步(配置表新增 agent.enableReflection/memory.*/mcp.autoReconnect) 回归: typecheck 双端 0 错误; ESLint 0/0; 系统 Node 771 通过 53 跳过 (better-sqlite3 ABI); Electron ABI 全量 824/824 零跳过
218 lines
8.3 KiB
TypeScript
218 lines
8.3 KiB
TypeScript
/**
|
||
* IPC Shared — 配置写入的共享副作用逻辑(P2-9)
|
||
*
|
||
* 提取原 handlers.ts 中 config:set 与 config:setBatch 两处重复的:
|
||
* 敏感值脱敏、Provider 切换清空 apiKey、Engine/Orchestrator 配置同步、
|
||
* 日志级别应用、配置变更广播、工作空间路径持久化。
|
||
*/
|
||
|
||
import log from 'electron-log';
|
||
import type { IPCContext } from './context';
|
||
import { broadcast } from './context';
|
||
import { isSensitiveConfigKey } from '../utils/secure-config';
|
||
|
||
/** LLM 相关配置 key(变更时触发热重载 Adapter) */
|
||
export const LLM_CONFIG_KEYS = [
|
||
'llm.provider',
|
||
'llm.model',
|
||
'llm.apiKey',
|
||
'llm.baseURL',
|
||
'llm.fallbackProvider',
|
||
'llm.fallbackModel',
|
||
'llm.fallbackApiKey',
|
||
'llm.fallbackBaseURL',
|
||
'ollama.numCtx',
|
||
'deepseek.contextWindow',
|
||
'agnes.contextWindow',
|
||
'mimo.contextWindow',
|
||
'openai.contextWindow',
|
||
'anthropic.contextWindow',
|
||
];
|
||
|
||
/** 敏感配置值脱敏(审计日志用:长值保留后 4 位,短值完全掩码) */
|
||
export function maskSensitive(key: string, value: unknown): unknown {
|
||
if (isSensitiveConfigKey(key) && typeof value === 'string' && value.length > 0) {
|
||
return value.length > 4 ? '***' + value.slice(-4) : '***';
|
||
}
|
||
return value;
|
||
}
|
||
|
||
/**
|
||
* 导出配置脱敏(S-1 修复):对 configService.getAll() 的结果逐 key 脱敏。
|
||
*
|
||
* 背景:getAll() 对敏感 key 解密后返回明文(ConfigService 的读取契约),
|
||
* data:export 直接透传会把明文 API Key / 认证密钥写入用户下载的 JSON 文件,
|
||
* 绕过 safeStorage 密钥链加密。此函数确保任何导出路径不泄露明文密钥。
|
||
*
|
||
* @param config configService.getAll() 的完整配置快照
|
||
* @returns 脱敏后的副本(敏感值替换为掩码,原对象不修改)
|
||
*/
|
||
export function sanitizeExportConfig(config: Record<string, unknown>): Record<string, unknown> {
|
||
const out: Record<string, unknown> = {};
|
||
for (const [key, value] of Object.entries(config)) {
|
||
out[key] = maskSensitive(key, value);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Provider 切换时清空 API key(C-1 修复,供 set/setBatch 共用)
|
||
*
|
||
* 必须在写入 entries 之前执行:若前端把 llm.apiKey 放在 llm.provider 之前,
|
||
* 先 set apiKey 再处理 provider 会把用户刚填的 key 清空。
|
||
*/
|
||
export function clearApiKeyOnProviderChange(
|
||
ctx: IPCContext,
|
||
entries: Array<{ key: string; value: unknown }>,
|
||
): void {
|
||
const providerEntry = entries.find((e) => e.key === 'llm.provider');
|
||
if (!providerEntry) return;
|
||
const oldProvider = ctx.configService.get<string>('llm.provider') ?? '';
|
||
const newProvider = (providerEntry.value as string) ?? '';
|
||
if (oldProvider && newProvider && oldProvider !== newProvider) {
|
||
ctx.configService.set('llm.apiKey', '');
|
||
log.info(
|
||
`[CONFIG] Provider changed (${oldProvider} → ${newProvider}), API key cleared to prevent incompatible key usage`,
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 应用单条配置的引擎/编排器副作用(Engine/Orchestrator/ConfirmationHook 同步)
|
||
*/
|
||
export function applyEngineConfigKey(ctx: IPCContext, key: string, value: unknown): void {
|
||
const { agentEngineManager, orchestrator, confirmationHook } = ctx;
|
||
switch (key) {
|
||
case 'agent.maxIterations':
|
||
agentEngineManager.updateConfigAll({ maxIterations: value as number });
|
||
break;
|
||
case 'agent.totalTimeoutMs':
|
||
agentEngineManager.updateConfigAll({ totalTimeoutMs: value as number });
|
||
break;
|
||
case 'agent.enableThinking':
|
||
agentEngineManager.updateConfigAll({ thinkingEnabled: value as boolean });
|
||
orchestrator.updateDefaultConfig({ thinkingEnabled: value as boolean });
|
||
break;
|
||
case 'agent.thinkingEffort':
|
||
agentEngineManager.updateConfigAll({
|
||
thinkingEffort: value as 'low' | 'medium' | 'high' | 'max',
|
||
});
|
||
orchestrator.updateDefaultConfig({
|
||
thinkingEffort: value as 'low' | 'medium' | 'high' | 'max',
|
||
});
|
||
break;
|
||
// v0.7.3 P3-1: enableReflection 接线(此前为死配置)— 引擎 REFLECTING 状态开关
|
||
case 'agent.enableReflection':
|
||
agentEngineManager.updateConfigAll({ enableReflection: value === true });
|
||
orchestrator.updateDefaultConfig({ enableReflection: value === true });
|
||
break;
|
||
case 'agent.toolExecutionTimeoutMs':
|
||
agentEngineManager.updateConfigAll({ toolExecutionTimeoutMs: value as number });
|
||
break;
|
||
case 'agent.confirmationTimeoutMs':
|
||
confirmationHook.setConfirmationTimeout(value as number);
|
||
break;
|
||
case 'ollama.numCtx':
|
||
agentEngineManager.updateConfigAll({ contextLength: (value as number) || undefined });
|
||
orchestrator.updateDefaultConfig({ contextLength: (value as number) || undefined });
|
||
break;
|
||
case 'deepseek.contextWindow':
|
||
case 'agnes.contextWindow':
|
||
case 'mimo.contextWindow':
|
||
case 'openai.contextWindow':
|
||
case 'anthropic.contextWindow':
|
||
// reloadAdapter 已重建 adapter 并同步 contextWindow,此处确保 Engine 配置同步(兜底)
|
||
agentEngineManager.updateConfigAll({ contextWindow: (value as number) || undefined });
|
||
orchestrator.updateDefaultConfig({ contextWindow: (value as number) || undefined });
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 配置写入后的统一副作用(供 config:set / config:setBatch 共用)
|
||
*
|
||
* 1. LLM key 变更 → 统一 reloadAdapter 一次(避免中间态失败)
|
||
* 2. Engine/Orchestrator 配置同步
|
||
* 3. 日志级别即时应用
|
||
* 4. 广播配置变更(前端 store 实时更新)
|
||
* 5. 工作空间路径写独立文件(下次启动生效)
|
||
*
|
||
* @returns 错误信息(成功为 null)
|
||
*/
|
||
export async function applyConfigSideEffects(
|
||
ctx: IPCContext,
|
||
entries: Array<{ key: string; value: unknown }>,
|
||
): Promise<string | null> {
|
||
// 1. LLM 配置变更 → 统一热重载 Adapter(一次)
|
||
if (entries.some((e) => LLM_CONFIG_KEYS.includes(e.key))) {
|
||
if (!ctx.reloadAdapter()) {
|
||
log.warn('[CONFIG] Adapter reload failed after config save');
|
||
return 'LLM 配置不完整,请检查 Provider、API Key、Base URL 和 Model 是否都已填写';
|
||
}
|
||
}
|
||
|
||
// 2. Engine/Orchestrator/ConfirmationHook 配置同步
|
||
for (const { key, value } of entries) {
|
||
applyEngineConfigKey(ctx, key, value);
|
||
}
|
||
|
||
// 3. 日志级别即时应用
|
||
const logLevelEntry = entries.find((e) => e.key === 'logging.level');
|
||
if (logLevelEntry && typeof logLevelEntry.value === 'string') {
|
||
const { transports } = await import('electron-log');
|
||
transports.file.level = logLevelEntry.value as
|
||
| 'error'
|
||
| 'warn'
|
||
| 'info'
|
||
| 'debug'
|
||
| 'verbose'
|
||
| 'silly';
|
||
transports.console.level = logLevelEntry.value as
|
||
| 'error'
|
||
| 'warn'
|
||
| 'info'
|
||
| 'debug'
|
||
| 'verbose'
|
||
| 'silly';
|
||
log.info(`[CONFIG] Log level updated to ${logLevelEntry.value}`);
|
||
}
|
||
|
||
// 4. 广播所有配置变更事件(前端监听后更新 store)
|
||
for (const { key, value } of entries) {
|
||
broadcast('config:changed', { key, value });
|
||
}
|
||
|
||
// 5. 工作空间路径写入独立文件(下次启动生效)
|
||
const workspaceEntry = entries.find(
|
||
(e) => e.key === 'workspace.path' && typeof e.value === 'string',
|
||
);
|
||
if (workspaceEntry) {
|
||
try {
|
||
const { writeWorkspacePathToFile } = await import('../main');
|
||
writeWorkspacePathToFile(workspaceEntry.value as string);
|
||
log.info(`[CONFIG] Workspace path saved (restart required): ${workspaceEntry.value}`);
|
||
} catch (err) {
|
||
log.error('[CONFIG] Failed to save workspace path:', err);
|
||
// v0.3.10: 写入失败必须告知用户,否则下次启动仍使用旧路径
|
||
return `工作空间路径保存失败:${(err as Error).message}`;
|
||
}
|
||
}
|
||
|
||
// v0.6.4 P4-5: 网络代理变更 → 重应用 session 级代理(default + agent-browser 分区)
|
||
if (entries.some((e) => e.key === 'network.proxyUrl')) {
|
||
const { applySessionProxy } = await import('../utils/network-proxy');
|
||
const proxyValue = entries.find((e) => e.key === 'network.proxyUrl')?.value;
|
||
await applySessionProxy(typeof proxyValue === 'string' ? proxyValue : null);
|
||
}
|
||
|
||
// v0.7.3 P4-2: MCP 自动重连开关变更 → 即时联动(关闭时取消全部已排程重连)
|
||
const autoReconnectEntry = entries.find((e) => e.key === 'mcp.autoReconnect');
|
||
if (autoReconnectEntry) {
|
||
ctx.mcpManager.setAutoReconnect(autoReconnectEntry.value !== false);
|
||
}
|
||
|
||
return null;
|
||
}
|