Files
metona-ai-desktop/electron/ipc/sessions.ts
T
thzxx 9b45c445bf
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m8s
CI / 全量测试 (Electron ABI) (push) Failing after 6m0s
CI / 产物编译验证 (push) Successful in 10m58s
feat: v0.8.1 记忆深化 · 观测闭环 · 体验收口 — 窗口/输出上限全局单一配置 · 2478 用例全量回归 + E2E 冒烟
硬性契约:删除代码中一切写死的上下文窗口与最大输出上限(含六家模型元信息
钳制与全部兜底值)——唯一合法来源是设置面板「上下文长度」(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 全项留档。
2026-09-08 09:35:58 +08:00

285 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* IPC Session Handlers — 会话管理域(P2-9 从 handlers.ts 拆分)
*
* 会话 CRUD、消息加载/删除/清空、Trace 持久化。
* P2-11: 新增 sessions:truncateAfter(编辑重发/重新生成的消息截断)。
*/
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 =>
typeof id === 'string' && id.length > 0 && id.length <= 200;
/** v0.8.0 P2-2: 会话录制文件名白名单(防路径逃逸/任意文件读取) */
const RECORDING_NAME_RE = /^session_[A-Za-z0-9_-]+\.jsonl$/;
/** v0.8.0 P2-2: 单个录制文件读取上限(防超大文件撑爆 IPC) */
const MAX_RECORDING_BYTES = 20 * 1024 * 1024;
export function registerSessionHandlers(ctx: IPCContext): void {
const { sessionService, confirmationHook, agentEngineManager } = ctx;
ipcMain.handle('sessions:list', async () => {
return sessionService.list();
});
ipcMain.handle('sessions:create', async (_event, title?: string) => {
return sessionService.create(title);
});
ipcMain.handle('sessions:rename', async (_event, sessionId: unknown, title: unknown) => {
// M-34 修复: 校验 sessionId 和 title
if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' };
if (typeof title !== 'string' || !title.trim())
return { success: false, error: 'Invalid title' };
return { success: sessionService.rename(sessionId, title) };
});
ipcMain.handle('sessions:delete', async (_event, sessionId: unknown) => {
// M-34 修复: 校验 sessionId
if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' };
// v0.7.4 P3-5: 删除会话前先中断其进行中的 run(若在运行)
// —— 防止删除后引擎仍在后台执行工具副作用
if (agentEngineManager.isRunning(sessionId)) {
agentEngineManager.abort(sessionId);
await agentEngineManager.waitForAbort(sessionId);
}
// v0.8.0 P2-1: 软删除 —— 会话进入回收站(deleted_at),30 天后自动清理,
// 可经 sessions:restore 恢复 / sessions:purge 彻底删除
const result = { success: sessionService.delete(sessionId) };
// v0.7.3 P2-3: 会话删除 = 会话终态 —— 决策记忆/pending 确认一并清理
// (防 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);
}
return result;
});
// ===== v0.8.0 P2-1: 回收站 =====
ipcMain.handle('sessions:listTrash', async () => {
return sessionService.listDeleted();
});
ipcMain.handle('sessions:restore', async (_event, sessionId: unknown) => {
if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' };
return { success: sessionService.restore(sessionId) };
});
ipcMain.handle('sessions:purge', async (_event, sessionId: unknown) => {
if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' };
// 彻底删除 = 终态:决策记忆/引擎与软删除同口径清理
const result = { success: sessionService.purge(sessionId) };
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, options?: unknown) => {
// M-34 修复: 校验 sessionId
if (!isValidSessionId(sessionId)) return [];
// 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: 更新单条用户消息内容(编辑"仅保存"落库)。
// 此前"仅保存"只改前端 store,会话重载即丢失(与"保存并重发"的截断语义割裂)。
ipcMain.handle(
'sessions:updateMessageContent',
async (_event, sessionId: unknown, messageId: unknown, content: unknown) => {
if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' };
if (typeof messageId !== 'string' || !messageId)
return { success: false, error: 'Invalid messageId' };
if (typeof content !== 'string') return { success: false, error: 'Invalid content' };
try {
const updated = sessionService.updateMessageContent(sessionId, messageId, content);
return { success: updated };
} catch (error) {
return { success: false, error: (error as Error).message };
}
},
);
ipcMain.handle('sessions:pin', async (_event, sessionId: unknown, pinned: unknown) => {
if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' };
if (typeof pinned !== 'boolean') return { success: false, error: 'Invalid pinned flag' };
return { success: sessionService.pin(sessionId, pinned) };
});
ipcMain.handle('sessions:archive', async (_event, sessionId: unknown, archived: unknown) => {
if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' };
if (typeof archived !== 'boolean') return { success: false, error: 'Invalid archived flag' };
return { success: sessionService.archive(sessionId, archived) };
});
// v0.7.3 P1-3: sessions:deleteMessage 已删除 —— 渲染层零调用方(死通道),
// 且原实现不回减 sessions.message_count(计数漂移面)。消息删除语义由
// sessions:truncateAfter(编辑重发/重新生成)与会话删除级联完整覆盖。
// v0.7.2 A1 根治: 语义修正 —— "清空会话"的成功判定是"操作完成"而非"有行被删除"。
// 原实现透传 DELETE 影响行数(changes > 0),空会话清空会返回 success:false
// 导致前端误报失败而不清空本地状态(/clear 断链的 IPC 侧根源之一)。
ipcMain.handle('sessions:clearMessages', async (_event, sessionId: unknown) => {
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 };
}
});
// P2-11: 消息截断(编辑重发 / 重新生成)
ipcMain.handle(
'sessions:truncateAfter',
async (_event, sessionId: unknown, messageId: unknown, inclusive: unknown) => {
if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' };
if (typeof messageId !== 'string' || !messageId)
return { success: false, error: 'Invalid messageId' };
const isInclusive = typeof inclusive === 'boolean' ? inclusive : true;
try {
const truncated = sessionService.truncateMessagesAfter(sessionId, messageId, isInclusive);
return { success: true, truncated };
} catch (error) {
return { success: false, error: (error as Error).message };
}
},
);
// v0.5.0: 全文搜索会话内容(FTS5,跨会话)
ipcMain.handle('sessions:searchContent', async (_event, query: unknown) => {
if (typeof query !== 'string' || !query.trim())
return { success: false, error: 'Invalid query' };
try {
const results = sessionService.searchMessages(query);
return { success: true, data: results };
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
ipcMain.handle('sessions:saveTrace', async (_event, sessionId: unknown, data: unknown) => {
// M-34 修复: 校验 sessionId 和 data
if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' };
// 严格校验 data 结构:traceSteps 必须为数组,tokenUsage 必须存在
if (!data || typeof data !== 'object') return { success: false, error: 'Invalid trace data' };
const traceData = data as { traceSteps?: unknown; tokenUsage?: unknown };
if (!Array.isArray(traceData.traceSteps)) {
return { success: false, error: 'Invalid trace data: traceSteps must be an array' };
}
if (traceData.tokenUsage === undefined) {
return { success: false, error: 'Invalid trace data: tokenUsage is required' };
}
try {
sessionService.saveTraceData(sessionId, {
traceSteps: traceData.traceSteps,
tokenUsage: traceData.tokenUsage,
});
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
ipcMain.handle('sessions:getTrace', async (_event, sessionId: unknown) => {
if (!isValidSessionId(sessionId)) return null;
return sessionService.getTraceData(sessionId);
});
// ===== v0.8.0 P2-2: 会话回放(JSONL 录制读取) =====
ipcMain.handle('sessions:listRecordings', async (_event, sessionId: unknown) => {
if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' };
try {
const logsDir = join(ctx.workspaceService.getPath(), 'logs');
if (!existsSync(logsDir)) return { success: true, data: [] };
const prefix = `session_${sessionId}_`;
const files = readdirSync(logsDir)
.filter((n) => n.startsWith(prefix) && RECORDING_NAME_RE.test(n))
.map((name) => {
try {
const st = statSync(join(logsDir, name));
return { name, size: st.size, mtime: st.mtimeMs };
} catch {
return null;
}
})
.filter((f): f is { name: string; size: number; mtime: number } => f !== null)
.sort((a, b) => b.mtime - a.mtime);
return { success: true, data: files };
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
ipcMain.handle('sessions:readRecording', async (_event, name: unknown) => {
if (typeof name !== 'string' || !RECORDING_NAME_RE.test(name)) {
return { success: false, error: 'Invalid recording file name' };
}
try {
const logsDir = resolvePath(ctx.workspaceService.getPath(), 'logs');
const filePath = resolvePath(logsDir, name);
// 目录边界校验(双保险:文件名白名单已杜绝子目录,此处防 symlink/拼接逃逸)
if (!filePath.startsWith(logsDir + sep)) {
return { success: false, error: 'Recording path escapes logs directory' };
}
if (!existsSync(filePath)) return { success: false, error: 'Recording file not found' };
const size = statSync(filePath).size;
if (size > MAX_RECORDING_BYTES) {
return { success: false, error: `Recording file too large (${size} bytes)` };
}
const raw = readFileSync(filePath, 'utf-8');
// 逐行解析,坏行跳过并计数(录制文件允许尾部截断)
const events: Array<Record<string, unknown>> = [];
let skipped = 0;
for (const line of raw.split('\n')) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
events.push(JSON.parse(trimmed) as Record<string, unknown>);
} catch {
skipped++;
}
}
return { success: true, data: { name, events, skipped } };
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
}