feat: v0.8.1 记忆深化 · 观测闭环 · 体验收口 — 窗口/输出上限全局单一配置 · 2478 用例全量回归 + E2E 冒烟
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m8s
CI / 全量测试 (Electron ABI) (push) Failing after 6m0s
CI / 产物编译验证 (push) Successful in 10m58s

硬性契约:删除代码中一切写死的上下文窗口与最大输出上限(含六家模型元信息
钳制与全部兜底值)——唯一合法来源是设置面板「上下文长度」(llm.contextWindow)
与「最大输出上限」(llm.maxTokens),跨 Provider/模型原样透传。

P0 正确性收口:
- 迁移 11/12(SCHEMA_VERSION 5):记忆表 embedding 列 + 分 Provider 窗口键清理
- 记忆生命周期接线:会话终态清理 working memory / episodic 90 天 TTL / access_count 回写
- 回放缓冲模块化 + 会话终态清理(杜绝 4MB/会话内存滞留)
- i18n 收口:主进程 main-locale(zh/en,ui.locale 热切换)+ 渲染层 17 处出层

P1 能力演进:
- 本地向量混合检索:0.6×向量余弦 + 0.4×TF-IDF,Ollama embeddings 首次投产,
  存量记忆惰性回填,嵌入不可用自动回退 TF-IDF
- MEMORY.md 维护闭环:固化去重消除截断盲区;两阶段维护(AI 建议 → 用户确认 →
  原子改写 + 语义记忆双轨同步 + 审计);>50KB 告警
- 可观测闭环:cacheTokens 引擎→前端透传(Token 面板命中率/成本行)+ 输入框
  上下文占用指示条
- MCP Prompts/Resources 对话可用:/mcp:{server}:{prompt} 与 @mcp:{server}:{uri}

P2 体验补全:
- 工具自定义策略(正则白/黑名单 + 频率 + 强制确认,热生效)
- 连续 ≥3 同类工具确认聚合为单弹框
- 会话消息游标分页(首屏 200 条向上翻页)
- 开机自启;Playwright + Electron E2E 冒烟(本地 mock LLM 零外联)

Review 回归修复:MCP 大小写失配 / 分页状态复位 / 清空=未配置语义(Number(null)=0
隐患)/ MEMORY.md 告警位置 / working_memories FK(迁移 13)/ 全局配置层废键清理;
附带根治权限加固启动时序、代理回环放行、safeStorage 降级、悬空 symlink 逃逸。

验证:typecheck/lint 0 问题;test:electron 2478/2478(0 跳过);E2E 2/2;
docs/v0.8.1-迭代实施清单.md 全项留档。
This commit is contained in:
2026-09-08 09:35:58 +08:00
parent 839860083f
commit 9b45c445bf
85 changed files with 5286 additions and 1158 deletions
+1 -1
View File
@@ -127,7 +127,7 @@ function makeCtx(overrides: Record<string, unknown> = {}) {
logSessionEnd: vi.fn(),
log: vi.fn(),
},
memoryManager: { search: vi.fn(() => []) },
memoryManager: { search: vi.fn(() => []), clearWorkingMemory: vi.fn() },
promptInjectionDefender: {
detect: vi.fn(() => ({
isInjection: false,
@@ -0,0 +1,80 @@
/**
* Replay Buffer 单元测试(v0.8.1 P0-3 模块化收口)
*
* 锁定有界缓冲契约:条数/字节双上限溢出丢最旧、truncated 标记、runId 记录、
* TERMINATED 保留 + 新内容兜底重置、终态清除(会话删除联动)。
*/
import { describe, it, expect, beforeEach } from 'vitest';
import {
appendReplay,
resetReplayBuffer,
markReplayTerminated,
clearReplayBuffer,
getReplayBufferData,
} from '../replay-buffer';
describe('replay-buffer — 有界回放缓冲(v0.8.1 P0-3', () => {
beforeEach(() => {
clearReplayBuffer('s1');
});
it('append + get:按序返回事件与 runId', () => {
appendReplay('s1', { channel: 'stateChange', payload: { runId: 'run_a' }, ts: 1 });
appendReplay('s1', { channel: 'streamEvent', payload: { type: 'text_delta' }, ts: 2 });
const data = getReplayBufferData('s1');
expect(data.events).toHaveLength(2);
expect(data.events[0].channel).toBe('stateChange');
expect(data.runId).toBe('run_a');
expect(data.truncated).toBe(false);
});
it('条数上限:超过 2000 条丢弃最旧并置 truncated', () => {
for (let i = 0; i < 2005; i++) {
appendReplay('s1', { channel: 'streamEvent', payload: { i }, ts: i });
}
const data = getReplayBufferData('s1');
expect(data.events.length).toBeLessThanOrEqual(2000);
expect(data.truncated).toBe(true);
// 最旧的已被丢弃
expect((data.events[0].payload as { i: number }).i).toBeGreaterThan(0);
});
it('字节上限:单条 1MB 的超大 payload 被丢弃并置 truncated', () => {
const big = 'x'.repeat(1024 * 1024);
for (let i = 0; i < 6; i++) {
appendReplay('s1', { channel: 'streamEvent', payload: { big }, ts: i });
}
const data = getReplayBufferData('s1');
expect(data.truncated).toBe(true);
// 缓冲字节被控制在 4MB 上限附近(至少留最后一条)
expect(data.events.length).toBeGreaterThanOrEqual(1);
expect(data.events.length).toBeLessThan(6);
});
it('TERMINATED 保留缓冲;新内容到达兜底重置', () => {
appendReplay('s1', { channel: 'stateChange', payload: { state: 'TERMINATED' }, ts: 1 });
markReplayTerminated('s1');
// 完成后仍可回放最终内容
expect(getReplayBufferData('s1').events).toHaveLength(1);
// 下一次 run 的新内容 → 缓冲重置
appendReplay('s1', { channel: 'streamEvent', payload: { type: 'text_delta' }, ts: 2 });
const data = getReplayBufferData('s1');
expect(data.events).toHaveLength(1);
expect(data.truncated).toBe(false);
});
it('INIT 重置:清除上一 run 的缓冲', () => {
appendReplay('s1', { channel: 'streamEvent', payload: { a: 1 }, ts: 1 });
resetReplayBuffer('s1');
expect(getReplayBufferData('s1').events).toHaveLength(0);
});
it('clearReplayBuffer:终态清除(会话删除联动)', () => {
appendReplay('s1', { channel: 'streamEvent', payload: { a: 1 }, ts: 1 });
clearReplayBuffer('s1');
const data = getReplayBufferData('s1');
expect(data.events).toHaveLength(0);
expect(data.runId).toBeNull();
});
});
@@ -115,7 +115,8 @@ describe('sessions 域 — 参数校验矩阵', () => {
expect(await getHandler('sessions:getMessages')(null, 42)).toEqual([]);
await getHandler('sessions:getMessages')(null, 's1');
expect(svc.getMessages).toHaveBeenCalledWith('s1');
// v0.8.1 P2-3: 未传选项时以 undefined 透传(全量语义向后兼容)
expect(svc.getMessages).toHaveBeenCalledWith('s1', undefined);
});
it('saveTrace 严格校验:traceSteps 必须为数组且 tokenUsage 必填', async () => {
@@ -149,6 +150,8 @@ describe('sessions:clearMessages — v0.7.2 A1 语义修正', () => {
function makeCtx(): IPCContext {
return {
sessionService: { clearMessages: vi.fn() },
// v0.8.1 P0-2: clearMessages 联动清理工作记忆
memoryManager: { clearWorkingMemory: vi.fn() },
} as unknown as IPCContext;
}
@@ -442,6 +445,8 @@ describe('sessions 域 — 补充校验矩阵', () => {
disposeEngine: vi.fn(),
},
confirmationHook: { forgetSession: vi.fn() },
// v0.8.1 P0-2: delete/purge/clearMessages 联动清理工作记忆
memoryManager: { clearWorkingMemory: vi.fn() },
};
return { ctx: raw as unknown as IPCContext, raw };
}
+58 -103
View File
@@ -29,6 +29,8 @@ import { buildUserContextPrefix, withUserContextPrefix } from '../harness/prompt
// v0.7.3 P1-5: 记忆固化触发决策(纯函数)
import { shouldConsolidate } from '../harness/memory/consolidation-policy';
import log from 'electron-log';
// v0.8.1 P0-4: 主进程 toast 文案双语(ui.locale 驱动)
import { mt } from '../utils/main-locale';
/** 构建 "时区名 (UTC±N)" 标签(注入用户上下文前置块;失败回退 UTC) */
function buildTimezoneLabel(): string {
@@ -67,34 +69,14 @@ interface IterationTrace {
finishReason?: string;
}
/**
* v0.8.0 P1-1a: 单会话的流式回放缓冲 —— 渲染层切走会话期间,事件管道仍照常
* 投递(按会话隔离被渲染层准入拒绝),此缓冲按序保存 streamEvent + stateChange
* 双通道事件;用户切回会话时经 agent:getReplayState 拉取并灌入渲染层事件总线,
* 实现"后台会话运行内容不丢失"。
*
* 有界设计:单会话上限 MAX_REPLAY_EVENTS 条 / MAX_REPLAY_BYTES 字节,溢出丢弃
* 最旧并置 truncated 标记;TERMINATED 后保留(供"完成后切回"看到最终内容),
* 下一次 run 启动(INIT stateChange)时清空重建。
*/
interface ReplayEntry {
channel: 'streamEvent' | 'stateChange';
payload: unknown;
ts: number;
}
interface ReplayBuffer {
events: ReplayEntry[];
bytes: number;
truncated: boolean;
/** 最近一次观察到的 runId(供渲染层回放后对齐 run 守卫) */
runId?: string;
terminated: boolean;
}
const MAX_REPLAY_EVENTS = 2000;
const MAX_REPLAY_BYTES = 4 * 1024 * 1024;
const MAX_REPLAY_SESSIONS = 50;
// v0.8.1 P0-3: 回放缓冲抽为独立模块(replay-buffer.ts)—— 会话删除/彻底删除
// 的终态路径可显式清除缓冲,杜绝已删会话最多 4MB/会话的内存滞留
import {
appendReplay,
resetReplayBuffer,
markReplayTerminated,
getReplayBufferData,
} from './replay-buffer';
export function registerAgentHandlers(ctx: IPCContext): void {
const {
@@ -118,54 +100,9 @@ export function registerAgentHandlers(ctx: IPCContext): void {
// ===== 常驻事件管道:text_delta 按会话节流(F8 =====
const throttleStates = new Map<string, ThrottleState>();
const iterationTraces = new Map<string, IterationTrace>();
// v0.8.0 P1-1a: 每会话流式回放缓冲(后台会话内容恢复)
const replayBuffers = new Map<string, ReplayBuffer>();
/** 追加一条事件到会话回放缓冲(有界:条数/字节双上限,溢出丢最旧) */
const appendReplay = (sessionId: string, entry: ReplayEntry): void => {
let buf = replayBuffers.get(sessionId);
if (!buf) {
// 会话数上限保护:超出后淘汰最早的缓冲(Map 迭代序 = 插入序)
if (replayBuffers.size >= MAX_REPLAY_SESSIONS) {
const oldest = replayBuffers.keys().next().value as string | undefined;
if (oldest !== undefined) replayBuffers.delete(oldest);
}
buf = { events: [], bytes: 0, truncated: false, terminated: false };
replayBuffers.set(sessionId, buf);
}
// run 启动(INIT)后旧 run 缓冲作废 —— 由 stateChange 监听器在 INIT 时清空,
// 此处仅在 terminated 缓冲上遇到新内容时兜底重置(正常路径 INIT 先行)
if (buf.terminated) {
replayBuffers.delete(sessionId);
buf = { events: [], bytes: 0, truncated: false, terminated: false };
replayBuffers.set(sessionId, buf);
}
let size = 0;
try {
size = JSON.stringify(entry.payload).length;
} catch {
size = 256; // 序列化失败按保守值计入
}
buf.events.push(entry);
buf.bytes += size;
while (
buf.events.length > MAX_REPLAY_EVENTS ||
(buf.bytes > MAX_REPLAY_BYTES && buf.events.length > 1)
) {
const dropped = buf.events.shift();
buf.truncated = true;
if (dropped) {
try {
buf.bytes -= JSON.stringify(dropped.payload).length;
} catch {
buf.bytes -= 256;
}
}
}
// 记录 runId(首个携带者)
const payload = entry.payload as { runId?: string } | null;
if (!buf.runId && payload?.runId) buf.runId = payload.runId;
};
// v0.8.1 review: MEMORY.md 体积告警去重(每会话一次)
const memoryOversizeWarned = new Set<string>();
// v0.7.3 P1-5: 会话级记忆固化时间戳(consolidation-policy 频率门控的状态持有方)
// v0.7.4 P3-5: LRU 化 —— 旧实现每会话一条永不清除,长期运行后无界增长。
@@ -340,12 +277,11 @@ export function registerAgentHandlers(ctx: IPCContext): void {
if (sessionId) {
const stateRaw = data.state ?? data.current ?? '';
if (stateRaw === 'INIT') {
replayBuffers.delete(sessionId);
resetReplayBuffer(sessionId);
} else {
appendReplay(sessionId, { channel: 'stateChange', payload: data, ts: Date.now() });
if (stateRaw === 'TERMINATED') {
const buf = replayBuffers.get(sessionId);
if (buf) buf.terminated = true;
markReplayTerminated(sessionId);
}
}
}
@@ -434,7 +370,11 @@ export function registerAgentHandlers(ctx: IPCContext): void {
// toast 通知用户压缩已发生
broadcast('toast:show', {
type: 'info',
message: `上下文压缩: ${data.originalTokens ?? '?'}${data.compressedTokens ?? '?'} tokens(节省 ${savedTokens}`,
message: mt('agent.toast.compressed', {
original: data.originalTokens ?? '?',
compressed: data.compressedTokens ?? '?',
saved: savedTokens,
}),
});
// 通过 streamEvent 转发,前端 useAgentStream 监听 'compressed' 类型后更新 store
broadcast('agent:streamEvent', {
@@ -454,7 +394,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
log.warn(`[AGENT] Dead loop detected at iteration ${data.iteration ?? '?'}`);
broadcast('toast:show', {
type: 'warning',
message: `检测到死循环(第 ${data.iteration ?? '?'} 轮):连续3轮重复相同工具调用,已自动终止`,
message: mt('agent.toast.deadLoop', { iteration: data.iteration ?? '?' }),
});
});
@@ -470,7 +410,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
});
broadcast('toast:show', {
type: 'warning',
message: `Provider 故障转移: ${data.from ?? '?'}${data.to ?? '?'}(主 Provider 请求失败)`,
message: mt('agent.toast.providerSwitched', { from: data.from ?? '?', to: data.to ?? '?' }),
});
},
);
@@ -498,7 +438,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
if (!sessionId || typeof sessionId !== 'string') {
log.warn('[AGENT] sendMessage rejected: invalid sessionId');
sendErrorEvent('无效的会话 ID', sessionId ?? '');
sendErrorEvent(mt('agent.error.invalidSessionId'), sessionId ?? '');
return { success: false, error: 'Invalid sessionId' };
}
if (
@@ -507,7 +447,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
typeof userMessage.content !== 'string'
) {
log.warn('[AGENT] sendMessage rejected: invalid userMessage');
sendErrorEvent('无效的消息格式', sessionId);
sendErrorEvent(mt('agent.error.invalidMessage'), sessionId);
return { success: false, error: 'Invalid message format' };
}
log.info('[AGENT] sendMessage:', sessionId, (userMessage.content ?? '').slice(0, 80));
@@ -517,7 +457,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
// 排队 30s 后强制 abort 旧 run,用户看到"上一次操作未完成")。此处直接拒绝并
// 广播明确错误事件,前端 isStreaming 正常收尾。
if (agentEngineManager.isRunning(sessionId)) {
const busyMsg = '该会话正在执行任务,请等待完成或先中断后再发送';
const busyMsg = mt('agent.error.sessionBusy');
log.warn(`[AGENT] sendMessage rejected: session ${sessionId} is already running`);
sendErrorEvent(busyMsg, sessionId);
return { success: false, error: busyMsg };
@@ -529,7 +469,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
// 此处提前校验并走统一的 sendErrorEvent + stopRecording 收尾路径。
const sessionExists = sessionService.getSession(sessionId) != null;
if (!sessionExists) {
const missingMsg = '会话不存在或已被删除,请刷新后重试';
const missingMsg = mt('agent.error.sessionMissing');
log.warn(`[AGENT] sendMessage rejected: session ${sessionId} not found`);
sendErrorEvent(missingMsg, sessionId);
await sessionRecorder.stopRecording(sessionId, {
@@ -543,8 +483,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
// 发送消息前确保 Adapter 使用最新配置(失败则中止,防止用旧 Provider 的 adapter 发送)
if (!ctx.reloadAdapter()) {
const errorMsg =
'Adapter 加载失败,请检查 LLM 配置(Provider、API Key、Base URL、Model 是否完整)';
const errorMsg = mt('agent.error.adapterLoadFailed');
log.error('[AGENT]', errorMsg);
sendErrorEvent(errorMsg, sessionId);
await sessionRecorder.stopRecording(sessionId, {
@@ -593,8 +532,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
if (contextBuilder.isUsingFallbackRole()) {
broadcast('toast:show', {
type: 'info',
message:
'未找到 SOUL.md 或内容为空,已使用默认 Metona 身份。可在工作空间根目录创建 SOUL.md 自定义 Agent 人格',
message: mt('agent.soul.fallbackToast'),
});
}
@@ -603,7 +541,8 @@ export function registerAgentHandlers(ctx: IPCContext): void {
// 每条消息都改变 system 字节 → 跨 run 缓存全 miss。现随首条 user 消息注入
// LLM 语义等价),system prompt 保持跨 run 字节级稳定。
try {
const memories = memoryManager.search(userMessage.content, {
// v0.8.1: search 升级 async(向量混合检索)
const memories = await memoryManager.search(userMessage.content, {
topK: 5,
minImportance: 0.3,
});
@@ -850,7 +789,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
);
broadcast('toast:show', {
type: 'info',
message: `AI 已将 ${result.appended} 条重要记忆写入 MEMORY.md`,
message: mt('agent.toast.consolidated', { count: result.appended }),
});
}
})
@@ -861,6 +800,18 @@ export function registerAgentHandlers(ctx: IPCContext): void {
log.debug(`[AGENT] Memory consolidation skipped (${decision.reason})`);
}
// v0.8.1 review 修复: MEMORY.md 体积告警移出固化分支 —— 每次成功 run 后
// 检查(此前仅固化触发时检查,跳过固化的大文件永不告警);每会话只告警
// 一次,避免连续消息刷屏。
const memorySize = (workspaceService.getFiles().memory ?? '').length;
if (memorySize > 51_200 && !memoryOversizeWarned.has(sessionId)) {
memoryOversizeWarned.add(sessionId);
broadcast('toast:show', {
type: 'warning',
message: mt('agent.toast.memoryOversize'),
});
}
// v0.7.3 P4-1: 首个完成的 run 之后生成精炼会话标题(每会话幂等,失败静默)
if (output.terminationReason === 'completed') {
titleGenerator
@@ -955,7 +906,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
}
const balance = await adapter.getBalance();
if (!balance) {
return { success: false, error: '余额查询失败(API Key 无效或网络错误)' };
return { success: false, error: mt('llm.balance.queryFailed') };
}
return { success: true, data: balance };
} catch (error) {
@@ -975,18 +926,18 @@ export function registerAgentHandlers(ctx: IPCContext): void {
const model = configService.get<string>('llm.model') ?? '';
const apiKey = configService.get<string>('llm.apiKey') || '';
if (!provider || !model) {
return { success: false, error: 'LLM 未配置(Provider/Model 为空),无法获取模型列表' };
return { success: false, error: mt('llm.listModels.notConfigured') };
}
if (!apiKey && provider !== 'ollama') {
return { success: false, error: 'API Key 未配置,无法获取模型列表' };
return { success: false, error: mt('llm.listModels.noApiKey') };
}
// 列表基于当前已保存配置 —— 先幂等重载 adapter(配置签名未变时为 no-op
if (!ctx.reloadAdapter()) {
return { success: false, error: 'LLM 配置校验失败,请先在设置中修正配置' };
return { success: false, error: mt('llm.listModels.configInvalid') };
}
const adapter = agentEngineManager.getAdapter();
if (!adapter.listModels) {
return { success: false, error: '当前 Provider 不支持模型列表查询' };
return { success: false, error: mt('llm.listModels.unsupported') };
}
const models = await adapter.listModels();
return { success: true, data: models };
@@ -1014,10 +965,10 @@ export function registerAgentHandlers(ctx: IPCContext): void {
}
const adapter = agentEngineManager.getAdapter();
if (!(adapter instanceof OllamaAdapter)) {
return { success: false, error: '仅 Ollama Provider 支持模型下载' };
return { success: false, error: mt('llm.pull.ollamaOnly') };
}
if (ollamaPullController) {
return { success: false, error: '已有模型下载任务进行中,请先取消' };
return { success: false, error: mt('llm.pull.inProgress') };
}
const controller = new AbortController();
ollamaPullController = controller;
@@ -1045,7 +996,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
ipcMain.handle('llm:ollamaPullCancel', async () => {
if (!ollamaPullController) {
return { success: false, error: '没有进行中的下载任务' };
return { success: false, error: mt('llm.pull.none') };
}
ollamaPullController.abort();
return { success: true };
@@ -1071,6 +1022,8 @@ export function registerAgentHandlers(ctx: IPCContext): void {
// 旧实现只覆盖 taskCompleted/taskError 路径,被 abort 的 SubAgent 残留 Map 条目
for (const taskId of abortedTaskIds) {
confirmationHook.forgetSession(taskId);
// v0.8.1 P0-2: 被中止 SubAgent 的工作记忆一并清理
memoryManager.clearWorkingMemory(taskId);
subTraces.delete(taskId);
subMeta.delete(taskId);
// 中止的 SubAgent 录制文件也收尾(TRACE 完整性:标记为中断终止)
@@ -1104,14 +1057,14 @@ export function registerAgentHandlers(ctx: IPCContext): void {
if (typeof sessionId !== 'string' || !sessionId) {
return { success: false, error: 'Invalid sessionId' };
}
const buf = replayBuffers.get(sessionId);
const data = getReplayBufferData(sessionId);
return {
success: true,
data: {
isRunning: agentEngineManager.isRunning(sessionId),
runId: buf?.runId ?? null,
truncated: buf?.truncated ?? false,
events: buf?.events ?? [],
runId: data.runId,
truncated: data.truncated,
events: data.events,
},
};
});
@@ -1151,6 +1104,8 @@ export function registerAgentHandlers(ctx: IPCContext): void {
subMeta.delete(taskId);
// v0.7.3 P2-3: SubAgent 终态 —— 决策记忆随任务终结清理(防长期运行泄漏)
confirmationHook.forgetSession(taskId);
// v0.8.1 P0-2: SubAgent 终态联动清理工作记忆(taskId 作为 sessionId 写入)
memoryManager.clearWorkingMemory(taskId);
};
orchestrator.on(
+26
View File
@@ -122,6 +122,32 @@ export function registerAppHandlers(ctx: IPCContext): void {
return { canceled: false, path: result.filePaths[0] };
});
// ===== v0.8.1 P2-4: 开机自启(app.setLoginItemSettings,跨平台尽力语义)=====
ipcMain.handle('app:setLoginItem', async (_event, enabled: unknown) => {
if (typeof enabled !== 'boolean') {
return { success: false, error: 'Invalid enabled flag' };
}
try {
app.setLoginItemSettings({ openAtLogin: enabled });
// 设置后回读真实状态(Linux 等 setLoginItemSettings 不可用平台保持 false
const actual = app.getLoginItemSettings().openAtLogin;
if (enabled && !actual) {
log.warn('[APP] setLoginItemSettings not effective on this platform (Linux?)');
}
return { success: true, data: { openAtLogin: actual } };
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
ipcMain.handle('app:getLoginItem', async () => {
try {
return { success: true, data: { openAtLogin: app.getLoginItemSettings().openAtLogin } };
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
// 重启应用(工作空间切换后调用)
ipcMain.handle('app:restart', async () => {
log.info('[APP] Restart requested');
+6
View File
@@ -13,6 +13,7 @@ import type { WorkspaceService } from '../services/workspace.service';
import type { ContextBuilder } from '../harness/prompts/context-builder';
import type { AgentEngineManager } from '../services/agent-engine-manager.service';
import type { ToolRegistry } from '../harness/tools/registry';
import type { PolicyEngine } from '../harness/sandbox/permissions';
import type { AuditService } from '../services/audit.service';
import type { SessionRecorder } from '../services/session-recorder.service';
import type { MemoryManager } from '../harness/memory/manager';
@@ -21,6 +22,7 @@ import type { PromptInjectionDefender } from '../harness/security/prompt-injecti
import type { OutputValidator } from '../harness/verification/output-validator';
import type { ConfirmationHook } from '../harness/hooks/confirmation-hook';
import type { MemoryConsolidator } from '../harness/memory/consolidator';
import type { MemoryMaintainer } from '../harness/memory/maintainer';
import type { TaskOrchestrator } from '../harness/orchestration/orchestrator';
import type { SessionSummaryService } from '../services/session-summary.service';
import type { TitleGenerator } from '../services/title-generator.service';
@@ -49,6 +51,8 @@ export interface IPCContext {
contextBuilder: ContextBuilder;
agentEngineManager: AgentEngineManager;
toolRegistry: ToolRegistry;
/** v0.8.1 P2-1: 策略引擎(tools.{name}.policy 用户自定义策略热加载) */
policyEngine: PolicyEngine;
auditService: AuditService;
sessionRecorder: SessionRecorder;
memoryManager: MemoryManager;
@@ -58,6 +62,8 @@ export interface IPCContext {
outputValidator: OutputValidator;
confirmationHook: ConfirmationHook;
memoryConsolidator: MemoryConsolidator;
/** v0.8.1 P1-2: MEMORY.md 维护闭环(memory:analyzeMaintenance / memory:applyMaintenance */
memoryMaintainer: MemoryMaintainer;
orchestrator: TaskOrchestrator;
sessionSummaryService: SessionSummaryService;
/** v0.7.3 P4-1: 会话标题生成器 */
+3
View File
@@ -107,6 +107,9 @@ export function registerDataHandlers(ctx: IPCContext): void {
const sessCount = db.prepare('SELECT COUNT(*) as c FROM sessions').get() as { c: number };
db.exec('DELETE FROM messages');
db.exec('DELETE FROM sessions');
// v0.8.1 P0-2: working_memories 无 sessions 外键(历史 schema),此处显式清理
// 防止全量清空后工作记忆成为无主孤数据
db.exec('DELETE FROM working_memories');
db.exec('COMMIT');
log.info(
`[DATA] All sessions cleared: ${sessCount.c} sessions, ${msgCount.c} messages deleted`,
+56
View File
@@ -152,4 +152,60 @@ export function registerMCPHandlers(ctx: IPCContext): void {
}
return { success: true, data: mcpManager.getServerContents(name) };
});
// ===== v0.8.1 P1-4: Prompt 渲染(prompts/getChatInput 斜杠菜单消费) =====
ipcMain.handle(
'mcp:getPrompt',
async (_event, serverName: unknown, promptName: unknown, args: unknown) => {
if (typeof serverName !== 'string' || !serverName.trim()) {
return { success: false, error: 'Invalid server name' };
}
if (typeof promptName !== 'string' || !promptName.trim()) {
return { success: false, error: 'Invalid prompt name' };
}
// args 可选,必须是 string→string 扁平对象(MCP prompts/get arguments 契约)
let promptArgs: Record<string, string> | undefined;
if (args !== undefined && args !== null) {
if (!args || typeof args !== 'object' || Array.isArray(args)) {
return { success: false, error: 'Invalid arguments' };
}
promptArgs = {};
for (const [k, v] of Object.entries(args as Record<string, unknown>)) {
if (typeof v !== 'string') {
return { success: false, error: `Argument "${k.slice(0, 40)}" must be a string` };
}
promptArgs[k] = v;
}
}
try {
const result = await mcpManager.getPrompt(serverName, promptName, promptArgs);
return { success: true, data: result };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
},
);
// ===== v0.8.1 P1-4: Resource 读取(resources/read@mcp 提及消费;512KB 上限) =====
const MAX_RESOURCE_BYTES = 512 * 1024;
ipcMain.handle('mcp:readResource', async (_event, serverName: unknown, uri: unknown) => {
if (typeof serverName !== 'string' || !serverName.trim()) {
return { success: false, error: 'Invalid server name' };
}
if (typeof uri !== 'string' || !uri.trim()) {
return { success: false, error: 'Invalid resource uri' };
}
try {
const result = await mcpManager.readResource(serverName, uri);
if (result && result.text.length > MAX_RESOURCE_BYTES) {
return {
success: true,
data: { text: result.text.slice(0, MAX_RESOURCE_BYTES), truncated: true },
};
}
return { success: true, data: { text: result?.text ?? '', truncated: false } };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
});
}
+110 -12
View File
@@ -5,11 +5,72 @@
import { ipcMain } from 'electron';
import type { IPCContext } from './context';
import type { MemoryType } from '../harness/memory/manager';
import type { MemoryMaintenanceAction } from '../harness/memory/maintainer';
import log from 'electron-log';
const VALID_MEMORY_TYPES: readonly MemoryType[] = ['episodic', 'semantic', 'working'];
export function registerMemoryHandlers(ctx: IPCContext): void {
const { memoryManager, sessionService } = ctx;
const { memoryManager, sessionService, memoryMaintainer, auditService } = ctx;
// ===== v0.8.1 P1-2: MEMORY.md 维护闭环(分析/应用两阶段) =====
// 阶段一:LLM 分析当前 MEMORY.md → 结构化维护建议(不改任何文件/DB)
ipcMain.handle('memory:analyzeMaintenance', async () => {
try {
const proposal = await memoryMaintainer.analyze();
return { success: true, data: proposal };
} catch (error) {
log.warn('[IPC] memory:analyzeMaintenance failed:', (error as Error).message);
return { success: false, error: (error as Error).message };
}
});
// 阶段二:应用用户确认(勾选)后的动作 —— 精确匹配校验 + 重写 MEMORY.md +
// 同步 semantic_memories + 审计留痕
ipcMain.handle('memory:applyMaintenance', async (_event, actions: unknown) => {
if (!Array.isArray(actions)) {
return { success: false, error: 'Invalid actions: must be an array' };
}
// 结构校验:只透传合法字段,其余拒绝
const valid: MemoryMaintenanceAction[] = [];
for (const item of actions.slice(0, 30)) {
if (!item || typeof item !== 'object') continue;
const a = item as Record<string, unknown>;
if (a.action !== 'delete' && a.action !== 'update') continue;
if (typeof a.section !== 'string' || typeof a.entry !== 'string') continue;
if (a.action === 'update' && typeof a.newEntry !== 'string') continue;
valid.push({
action: a.action,
section: a.section,
entry: a.entry,
newEntry: typeof a.newEntry === 'string' ? a.newEntry : undefined,
reason: typeof a.reason === 'string' ? a.reason : undefined,
});
}
try {
const result = memoryMaintainer.apply(valid);
auditService.log({
sessionId: '',
eventType: 'tool_call',
actor: 'user',
target: 'memory_maintenance',
details: { requested: valid.length, applied: result.applied, skipped: result.skipped },
outcome: 'success',
});
return { success: true, data: result };
} catch (error) {
auditService.log({
sessionId: '',
eventType: 'error',
actor: 'user',
target: 'memory_maintenance',
details: { error: (error as Error).message },
outcome: 'error',
});
return { success: false, error: (error as Error).message };
}
});
ipcMain.handle('db:searchMemories', async (_event, query: unknown, options: unknown) => {
// M-37 修复: query 和 options 校验
@@ -17,7 +78,12 @@ export function registerMemoryHandlers(ctx: IPCContext): void {
return [];
}
// 构造合法的搜索选项(仅保留已知字段,强制类型安全)
const searchOptions: { topK?: number; sessionId?: string; type?: MemoryType; minImportance?: number } = {};
const searchOptions: {
topK?: number;
sessionId?: string;
type?: MemoryType;
minImportance?: number;
} = {};
if (options && typeof options === 'object') {
const opts = options as Record<string, unknown>;
// topK 限制范围 1-100(防止过大查询)
@@ -26,12 +92,15 @@ export function registerMemoryHandlers(ctx: IPCContext): void {
if (Number.isFinite(topK) && topK >= 1 && topK <= 100) {
searchOptions.topK = topK;
} else {
searchOptions.topK = 10; // 默认值
searchOptions.topK = 10; // 默认值
}
}
if (typeof opts.sessionId === 'string') searchOptions.sessionId = opts.sessionId;
// type 必须是合法的 MemoryType 枚举值
if (typeof opts.type === 'string' && (VALID_MEMORY_TYPES as readonly string[]).includes(opts.type)) {
if (
typeof opts.type === 'string' &&
(VALID_MEMORY_TYPES as readonly string[]).includes(opts.type)
) {
searchOptions.type = opts.type as MemoryType;
}
if (typeof opts.minImportance === 'number' && Number.isFinite(opts.minImportance)) {
@@ -52,7 +121,10 @@ export function registerMemoryHandlers(ctx: IPCContext): void {
const opts = options as Record<string, unknown>;
if (opts.type !== undefined) {
if (typeof opts.type !== 'string' || !VALID_MEMORY_TYPES_LIST.includes(opts.type)) {
return { success: false, error: `Invalid type (must be one of: ${VALID_MEMORY_TYPES_LIST.join(', ')})` };
return {
success: false,
error: `Invalid type (must be one of: ${VALID_MEMORY_TYPES_LIST.join(', ')})`,
};
}
type = opts.type;
}
@@ -69,16 +141,34 @@ export function registerMemoryHandlers(ctx: IPCContext): void {
const results: Record<string, unknown[]> = {};
try {
if (!type || type === 'episodic') {
const rows = db.prepare('SELECT * FROM episodic_memories ORDER BY created_at DESC LIMIT ?').all(limit) as Array<Record<string, unknown>>;
const rows = db
.prepare('SELECT * FROM episodic_memories ORDER BY created_at DESC LIMIT ?')
.all(limit) as Array<Record<string, unknown>>;
results.episodic = rows.map((r) => ({ ...r, type: 'episodic', content: r.content ?? '' }));
}
if (!type || type === 'semantic') {
const rows = db.prepare('SELECT * FROM semantic_memories ORDER BY updated_at DESC LIMIT ?').all(limit) as Array<Record<string, unknown>>;
results.semantic = rows.map((r) => ({ ...r, type: 'semantic', content: r.value ?? r.key ?? '', importance: r.confidence ?? 0, created_at: r.created_at ?? r.updated_at }));
const rows = db
.prepare('SELECT * FROM semantic_memories ORDER BY updated_at DESC LIMIT ?')
.all(limit) as Array<Record<string, unknown>>;
results.semantic = rows.map((r) => ({
...r,
type: 'semantic',
content: r.value ?? r.key ?? '',
importance: r.confidence ?? 0,
created_at: r.created_at ?? r.updated_at,
}));
}
if (!type || type === 'working') {
const rows = db.prepare('SELECT * FROM working_memories ORDER BY updated_at DESC LIMIT ?').all(limit) as Array<Record<string, unknown>>;
results.working = rows.map((r) => ({ ...r, type: 'working', content: r.value ?? r.key ?? '', importance: 0.5, created_at: r.updated_at ?? Date.now() }));
const rows = db
.prepare('SELECT * FROM working_memories ORDER BY updated_at DESC LIMIT ?')
.all(limit) as Array<Record<string, unknown>>;
results.working = rows.map((r) => ({
...r,
type: 'working',
content: r.value ?? r.key ?? '',
importance: 0.5,
created_at: r.updated_at ?? Date.now(),
}));
}
return { success: true, data: results };
} catch (error) {
@@ -89,14 +179,22 @@ export function registerMemoryHandlers(ctx: IPCContext): void {
ipcMain.handle('memory:delete', async (_event, type: unknown, id: unknown) => {
// M-50 修复: 校验 type 枚举(防止三元表达式默认映射到 working_memories)和 id 类型
if (typeof type !== 'string' || !VALID_MEMORY_TYPES_LIST.includes(type)) {
return { success: false, error: `Invalid type (must be one of: ${VALID_MEMORY_TYPES_LIST.join(', ')})` };
return {
success: false,
error: `Invalid type (must be one of: ${VALID_MEMORY_TYPES_LIST.join(', ')})`,
};
}
if (typeof id !== 'string' || !id) {
return { success: false, error: 'Invalid memory id' };
}
const db = sessionService.getDB();
try {
const table = type === 'episodic' ? 'episodic_memories' : type === 'semantic' ? 'semantic_memories' : 'working_memories';
const table =
type === 'episodic'
? 'episodic_memories'
: type === 'semantic'
? 'semantic_memories'
: 'working_memories';
db.prepare(`DELETE FROM ${table} WHERE id = ?`).run(id);
return { success: true };
} catch (error) {
+112
View File
@@ -0,0 +1,112 @@
/**
* Replay Buffer — 每会话流式回放缓冲(v0.8.0 P1-1a 引入;v0.8.1 P0-3 模块化收口)
*
* 渲染层切走会话期间,事件管道仍照常投递(按会话隔离被渲染层准入拒绝),
* 此缓冲按序保存 streamEvent + stateChange 双通道事件;用户切回会话时经
* agent:getReplayState 拉取并灌入渲染层事件总线,实现"后台会话运行内容不丢失"。
*
* v0.8.1 P0-3 根治:缓冲原为 ipc/agent.ts 内部 Map —— 会话删除/彻底删除时无
* 联动清理,仅靠 50 会话 LRU 兜底,已删会话的缓冲(最多 4MB/会话)滞留内存。
* 现抽为独立模块,sessions:delete / sessions:purge 终态路径显式清除。
*
* 有界设计:单会话上限 MAX_REPLAY_EVENTS 条 / MAX_REPLAY_BYTES 字节,溢出丢弃
* 最旧并置 truncated 标记;TERMINATED 后保留(供"完成后切回"看到最终内容),
* 下一次 run 启动(INIT stateChange)时清空重建。
*/
const MAX_REPLAY_EVENTS = 2000;
const MAX_REPLAY_BYTES = 4 * 1024 * 1024;
const MAX_REPLAY_SESSIONS = 50;
export interface ReplayEntry {
channel: 'streamEvent' | 'stateChange';
payload: unknown;
ts: number;
}
export interface ReplayBuffer {
events: ReplayEntry[];
bytes: number;
truncated: boolean;
/** 最近一次观察到的 runId(供渲染层回放后对齐 run 守卫) */
runId?: string;
terminated: boolean;
}
const replayBuffers = new Map<string, ReplayBuffer>();
/** 追加一条事件到会话回放缓冲(有界:条数/字节双上限,溢出丢最旧) */
export function appendReplay(sessionId: string, entry: ReplayEntry): void {
let buf = replayBuffers.get(sessionId);
if (!buf) {
// 会话数上限保护:超出后淘汰最早的缓冲(Map 迭代序 = 插入序)
if (replayBuffers.size >= MAX_REPLAY_SESSIONS) {
const oldest = replayBuffers.keys().next().value as string | undefined;
if (oldest !== undefined) replayBuffers.delete(oldest);
}
buf = { events: [], bytes: 0, truncated: false, terminated: false };
replayBuffers.set(sessionId, buf);
}
// run 启动(INIT)后旧 run 缓冲作废 —— 由 stateChange 监听器在 INIT 时清空,
// 此处仅在 terminated 缓冲上遇到新内容时兜底重置(正常路径 INIT 先行)
if (buf.terminated) {
replayBuffers.delete(sessionId);
buf = { events: [], bytes: 0, truncated: false, terminated: false };
replayBuffers.set(sessionId, buf);
}
let size = 0;
try {
size = JSON.stringify(entry.payload).length;
} catch {
size = 256; // 序列化失败按保守值计入
}
buf.events.push(entry);
buf.bytes += size;
while (
buf.events.length > MAX_REPLAY_EVENTS ||
(buf.bytes > MAX_REPLAY_BYTES && buf.events.length > 1)
) {
const dropped = buf.events.shift();
buf.truncated = true;
if (dropped) {
try {
buf.bytes -= JSON.stringify(dropped.payload).length;
} catch {
buf.bytes -= 256;
}
}
}
// 记录 runId(首个携带者)
const payload = entry.payload as { runId?: string } | null;
if (!buf.runId && payload?.runId) buf.runId = payload.runId;
}
/** run 启动(INIT)时清空上一 run 的缓冲 */
export function resetReplayBuffer(sessionId: string): void {
replayBuffers.delete(sessionId);
}
/** TERMINATED 时置终态标记(保留缓冲供"完成后切回"回放最终内容) */
export function markReplayTerminated(sessionId: string): void {
const buf = replayBuffers.get(sessionId);
if (buf) buf.terminated = true;
}
/** v0.8.1 P0-3: 会话终态(删除/彻底删除)时清除缓冲,杜绝内存滞留 */
export function clearReplayBuffer(sessionId: string): void {
replayBuffers.delete(sessionId);
}
/** 拉取会话回放状态(agent:getReplayState 消费) */
export function getReplayBufferData(sessionId: string): {
runId: string | null;
truncated: boolean;
events: ReplayEntry[];
} {
const buf = replayBuffers.get(sessionId);
return {
runId: buf?.runId ?? null,
truncated: buf?.truncated ?? false,
events: buf?.events ?? [],
};
}
+32 -2
View File
@@ -9,6 +9,8 @@ import { ipcMain } from 'electron';
import { join, resolve as resolvePath, sep } from 'path';
import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
import type { IPCContext } from './context';
// v0.8.1 P0-3: 会话终态清除流式回放缓冲
import { clearReplayBuffer } from './replay-buffer';
/** M-34 修复: 统一 sessionId 校验辅助函数 */
const isValidSessionId = (id: unknown): id is string =>
@@ -54,6 +56,11 @@ export function registerSessionHandlers(ctx: IPCContext): void {
// (防 rememberedDecisions 随会话数累积泄漏)
if (result.success) {
confirmationHook.forgetSession(sessionId);
// v0.8.1 P0-2: 会话终态联动清理工作记忆(此前 clearWorkingMemory 零调用方,
// working_memories 随会话数永久残留)
ctx.memoryManager.clearWorkingMemory(sessionId);
// v0.8.1 P0-3: 清除该会话的流式回放缓冲(最多 4MB/会话)
clearReplayBuffer(sessionId);
// v0.7.4 P3-5: 显式销毁引擎与 adapter 实例(内存收口)
// —— 旧实现只靠 LRU 上限 30 淘汰,会话删除后引擎仍驻留
agentEngineManager.disposeEngine(sessionId);
@@ -78,14 +85,35 @@ export function registerSessionHandlers(ctx: IPCContext): void {
if (result.success) {
confirmationHook.forgetSession(sessionId);
agentEngineManager.disposeEngine(sessionId);
// v0.8.1 P0-2: 终态联动清理工作记忆
ctx.memoryManager.clearWorkingMemory(sessionId);
// v0.8.1 P0-3: 清除该会话的流式回放缓冲
clearReplayBuffer(sessionId);
}
return result;
});
ipcMain.handle('sessions:getMessages', async (_event, sessionId: unknown) => {
ipcMain.handle('sessions:getMessages', async (_event, sessionId: unknown, options?: unknown) => {
// M-34 修复: 校验 sessionId
if (!isValidSessionId(sessionId)) return [];
return sessionService.getMessages(sessionId);
// v0.8.1 P2-3: 游标分页选项(limit 1-1000 / beforeRowid 正整数),非法值忽略
let safeOptions: { limit?: number; beforeRowid?: number } | undefined;
if (options && typeof options === 'object') {
const o = options as Record<string, unknown>;
safeOptions = {};
if (typeof o.limit === 'number' && Number.isFinite(o.limit) && o.limit >= 1) {
safeOptions.limit = Math.min(1000, Math.floor(o.limit));
}
if (
typeof o.beforeRowid === 'number' &&
Number.isFinite(o.beforeRowid) &&
o.beforeRowid > 0
) {
safeOptions.beforeRowid = Math.floor(o.beforeRowid);
}
if (Object.keys(safeOptions).length === 0) safeOptions = undefined;
}
return sessionService.getMessages(sessionId, safeOptions);
});
// v0.7.4 P3-10: 更新单条用户消息内容(编辑"仅保存"落库)。
@@ -129,6 +157,8 @@ export function registerSessionHandlers(ctx: IPCContext): void {
if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' };
try {
sessionService.clearMessages(sessionId);
// v0.8.1 P0-2: 清空会话 = 会话内容重置 —— 工作记忆一并清理
ctx.memoryManager.clearWorkingMemory(sessionId);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
+58 -14
View File
@@ -10,11 +10,15 @@ import log from 'electron-log';
import type { IPCContext } from './context';
import { broadcast } from './context';
import { isSensitiveConfigKey } from '../utils/secure-config';
// v0.8.1 P2-1: 工具自定义策略解析
import { parseToolPolicy } from '../harness/sandbox/permissions';
// v0.8.0 P1-5: 配置 URL 深校验(域名真实 DNS 解析,拦"解析到云元数据 IP"绕过)
import {
assertSafeConfigTargetDeep,
DeepCheckSoftFailure,
} from '../harness/tools/built-in/ssrf-guard';
// v0.8.1 P0-4: 主进程文案双语
import { setMainLocale, mt } from '../utils/main-locale';
/**
* v0.7.4 P2-9-C: URL 类配置键 —— 写入时须过 assertSafeConfigTarget 高危目标校验。
@@ -65,16 +69,12 @@ export const LLM_CONFIG_KEYS = [
'llm.model',
'llm.apiKey',
'llm.baseURL',
// v0.8.1: 全局单一「上下文长度」—— 重建 adapter(携带新窗口)+ 引擎配置同步
'llm.contextWindow',
'llm.fallbackProvider',
'llm.fallbackModel',
'llm.fallbackApiKey',
'llm.fallbackBaseURL',
'ollama.numCtx',
'deepseek.contextWindow',
'agnes.contextWindow',
'mimo.contextWindow',
'openai.contextWindow',
'anthropic.contextWindow',
];
/** 敏感配置值脱敏(审计日志用:长值保留后 4 位,短值完全掩码) */
@@ -129,7 +129,7 @@ export function clearApiKeyOnProviderChange(
* 应用单条配置的引擎/编排器副作用(Engine/Orchestrator/ConfirmationHook 同步)
*/
export function applyEngineConfigKey(ctx: IPCContext, key: string, value: unknown): void {
const { agentEngineManager, orchestrator, confirmationHook } = ctx;
const { agentEngineManager, orchestrator, confirmationHook, configService } = ctx;
switch (key) {
case 'agent.maxIterations':
agentEngineManager.updateConfigAll({ maxIterations: value as number });
@@ -162,8 +162,15 @@ export function applyEngineConfigKey(ctx: IPCContext, key: string, value: unknow
orchestrator.updateDefaultConfig({ temperature: Number(value) });
break;
case 'llm.maxTokens':
agentEngineManager.updateConfigAll({ maxTokens: Number(value) });
orchestrator.updateDefaultConfig({ maxTokens: Number(value) });
// v0.8.1 review 修复: null/空串 = 用户清空「最大输出上限」→ 未配置语义
//undefined 下发,由 Provider 服务端默认值决定);此前 Number(null)=0
// 会把引擎预算清零。
agentEngineManager.updateConfigAll({
maxTokens: value == null || value === '' ? undefined : Number(value) || undefined,
});
orchestrator.updateDefaultConfig({
maxTokens: value == null || value === '' ? undefined : Number(value) || undefined,
});
break;
case 'agent.toolExecutionTimeoutMs':
agentEngineManager.updateConfigAll({ toolExecutionTimeoutMs: value as number });
@@ -172,17 +179,34 @@ export function applyEngineConfigKey(ctx: IPCContext, key: string, value: unknow
confirmationHook.setConfirmationTimeout(value as number);
break;
case 'ollama.numCtx':
// v0.8.1: ollama.numCtx 已废除 —— 与「上下文长度」合并为 llm.contextWindow。
// 保留此分支仅为兼容存量库中的遗留键写入(迁移 12 已清理),行为同 llm.contextWindow。
agentEngineManager.updateConfigAll({ contextLength: (value as number) || undefined });
orchestrator.updateDefaultConfig({ contextLength: (value as number) || undefined });
break;
// v0.8.1: 全局单一「上下文长度」—— 替代旧的分 Provider contextWindow 键与
// ollama.numCtx。Ollama Provider 下同时作为 num_ctxcontextLength)下发。
case 'llm.contextWindow': {
const ctx = (value as number) || undefined;
const isOllama = (configService.get<string>('llm.provider') ?? '') === 'ollama';
agentEngineManager.updateConfigAll({
contextWindow: ctx,
contextLength: isOllama ? ctx : undefined,
});
orchestrator.updateDefaultConfig({
contextWindow: ctx,
contextLength: isOllama ? ctx : 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 });
// v0.8.1: 分 Provider 键已废除 —— 兼容分支收敛为空操作(迁移 12 已清理
// 存量库;若前端旧版本仍写入,静默忽略以防双源语义复活)。
log.warn(`[CONFIG] Deprecated provider contextWindow key ignored: ${key}`);
break;
default:
break;
@@ -208,13 +232,27 @@ export async function applyConfigSideEffects(
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 是否都已填写';
return mt('config.error.configIncomplete');
}
}
// 2. Engine/Orchestrator/ConfirmationHook 配置同步
for (const { key, value } of entries) {
applyEngineConfigKey(ctx, key, value);
// v0.8.1 P2-1: 工具自定义策略热加载(tools.{name}.policyJSON 字符串)
const m = /^tools\.(.+)\.policy$/.exec(key);
if (m) {
const toolName = m[1];
const known = ctx.toolRegistry.listAllTools().some((t) => t.name === toolName);
if (known) {
const parsed =
typeof value === 'string' && value.trim() !== '' ? parseToolPolicy(value) : null;
ctx.policyEngine.setPolicyOverride(toolName, parsed);
if (typeof value === 'string' && value.trim() !== '' && !parsed) {
log.warn(`[Policy] Ignored invalid policy config for tool: ${toolName}`);
}
}
}
}
// 3. 日志级别即时应用
@@ -255,7 +293,7 @@ export async function applyConfigSideEffects(
} catch (err) {
log.error('[CONFIG] Failed to save workspace path:', err);
// v0.3.10: 写入失败必须告知用户,否则下次启动仍使用旧路径
return `工作空间路径保存失败:${(err as Error).message}`;
return mt('config.error.workspaceSaveFailed', { message: (err as Error).message });
}
}
@@ -266,6 +304,12 @@ export async function applyConfigSideEffects(
await applySessionProxy(typeof proxyValue === 'string' ? proxyValue : null);
}
// v0.8.1 P0-4: 界面语言变更 → 主进程 toast/通知语言热切换(无需重启)
const localeEntry = entries.find((e) => e.key === 'ui.locale');
if (localeEntry && typeof localeEntry.value === 'string') {
setMainLocale(localeEntry.value);
}
// v0.7.3 P4-2: MCP 自动重连开关变更 → 即时联动(关闭时取消全部已排程重连)
const autoReconnectEntry = entries.find((e) => e.key === 'mcp.autoReconnect');
if (autoReconnectEntry) {