Files
metona-ai-desktop/electron/ipc/data.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

199 lines
7.7 KiB
TypeScript
Raw Permalink 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 Data Handlers — 数据导出与清理域(P2-9 从 handlers.ts 拆分)
*/
import { ipcMain } from 'electron';
import type { IPCContext } from './context';
import log from 'electron-log';
import { sanitizeExportConfig } from './shared';
/**
* 导出限流常量:单会话导出的最大消息条数。
*
* 背景:导出会把所有会话×所有消息×完整 toolResult 一次性 JSON.stringify 成单个 Blob
* 传到渲染进程。含 view_image 的会话单条 tool_result 就有 ~6.7MB base64 dataUrl
* 全量导出会直接撑爆渲染进程堆导致 OOM 崩溃。
*
* 双重防护:
* 1. 剥离每条 tool 消息中的 dataUrl 等超大 base64sanitizeExportMessage
* 2. 全量导出时按会话限制消息条数(MAX_EXPORT_MESSAGES_PER_SESSION
*/
const MAX_EXPORT_MESSAGES_PER_SESSION = 2000;
/**
* 剥离导出消息中的超大 base64 字段(如 view_image 的 dataUrl)。
* 仅用于导出快照——原数据库数据不受影响。
*/
const sanitizeExportMessage = (msg: Record<string, unknown>): Record<string, unknown> => {
const toolResult = msg.toolResult as Record<string, unknown> | undefined;
if (toolResult && typeof toolResult === 'object' && 'result' in toolResult) {
const result = toolResult.result;
// result 含 dataUrlview_image 等图片结果):剥离 dataUrl,保留 path/size/mimeType 等小字段
if (result && typeof result === 'object' && !Array.isArray(result) && 'dataUrl' in result) {
const { dataUrl: _omit, ...rest } = result as Record<string, unknown>;
void _omit;
return {
...msg,
toolResult: {
...toolResult,
result: { ...rest, _displayNote: '[image base64 omitted in export]' },
},
};
}
}
// attachments[].preview(用户上传图片的 base64 缩略图)也一并剥离,避免导出文件膨胀
const attachments = msg.attachments;
if (Array.isArray(attachments)) {
const sanitized = attachments.map((att) => {
if (att && typeof att === 'object' && 'preview' in att) {
const { preview: _omit, ...rest } = att as Record<string, unknown>;
void _omit;
return { ...rest, _displayNote: '[preview omitted in export]' };
}
return att;
});
return { ...msg, attachments: sanitized };
}
return msg;
};
export function registerDataHandlers(ctx: IPCContext): void {
const { sessionService, configService, auditService } = ctx;
ipcMain.handle('data:export', async (_event, sessionId?: string) => {
try {
if (sessionId) {
// 导出单个会话:剥离超大 base64 后返回
const messages = sessionService.getMessages(sessionId);
const sanitized = messages.map((m) =>
sanitizeExportMessage(m as unknown as Record<string, unknown>),
);
return { success: true, data: sanitized };
}
// 导出所有会话:剥离 dataUrl + 每会话限条数,防渲染进程 Blob 序列化 OOM
// S-1 修复:config 走 sanitizeExportConfig 脱敏 —— getAll() 对敏感 key
// 解密返回明文,直接透传会把明文 API Key 写入用户下载的导出文件
const sessions = sessionService.list();
const allData: Record<string, unknown> = {
sessions: [],
config: sanitizeExportConfig(configService.getAll()),
};
for (const session of sessions) {
const rawMessages = sessionService.getMessages(session.id, {
limit: MAX_EXPORT_MESSAGES_PER_SESSION,
});
const sanitizedMessages = rawMessages.map((m) =>
sanitizeExportMessage(m as unknown as Record<string, unknown>),
);
(allData.sessions as Array<Record<string, unknown>>).push({
...session,
messages: sanitizedMessages,
truncated: rawMessages.length >= MAX_EXPORT_MESSAGES_PER_SESSION,
});
}
return { success: true, data: allData };
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
ipcMain.handle('data:clearSessions', async () => {
// M-40 修复: 危险操作审计日志,便于追踪异常调用
log.warn('[DATA] DANGER: clearSessions invoked — all sessions and messages will be deleted');
const db = sessionService.getDB();
try {
db.exec('BEGIN');
const msgCount = db.prepare('SELECT COUNT(*) as c FROM messages').get() as { c: number };
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`,
);
return { success: true, deletedSessions: sessCount.c, deletedMessages: msgCount.c };
} catch (error) {
try {
db.exec('ROLLBACK');
} catch {
/* 忽略回滚错误 */
}
log.error('[DATA] clearSessions failed:', (error as Error).message);
return { success: false, error: (error as Error).message };
}
});
ipcMain.handle('data:clearMemories', async () => {
// M-40 修复: 危险操作审计日志
log.warn('[DATA] DANGER: clearMemories invoked — all memories will be deleted');
const db = sessionService.getDB();
try {
// M-41 修复: 三个 DELETE 操作用事务包裹,防止部分失败导致三类记忆数据不一致
const epiCount = db.prepare('SELECT COUNT(*) as c FROM episodic_memories').get() as {
c: number;
};
const semCount = db.prepare('SELECT COUNT(*) as c FROM semantic_memories').get() as {
c: number;
};
const workCount = db.prepare('SELECT COUNT(*) as c FROM working_memories').get() as {
c: number;
};
db.exec('BEGIN');
db.exec('DELETE FROM episodic_memories');
db.exec('DELETE FROM semantic_memories');
db.exec('DELETE FROM working_memories');
db.exec('COMMIT');
log.info(
`[DATA] All memories cleared: ${epiCount.c} episodic, ${semCount.c} semantic, ${workCount.c} working memories deleted`,
);
return {
success: true,
deletedEpisodic: epiCount.c,
deletedSemantic: semCount.c,
deletedWorking: workCount.c,
};
} catch (error) {
// M-41 修复: 失败时回滚事务,确保数据一致性
try {
db.exec('ROLLBACK');
} catch {
/* 忽略回滚错误 */
}
log.error('[DATA] clearMemories failed:', (error as Error).message);
return { success: false, error: (error as Error).message };
}
});
ipcMain.handle('data:clearAuditLogs', async () => {
// 审计日志是 INSERT-ONLY,需要先禁用触发器
const db = sessionService.getDB();
try {
db.exec('BEGIN');
db.exec('DROP TRIGGER IF EXISTS audit_no_delete');
db.exec('DELETE FROM audit_logs');
db.exec(`
CREATE TRIGGER audit_no_delete BEFORE DELETE ON audit_logs
BEGIN
SELECT RAISE(ABORT, 'Audit logs are INSERT-ONLY. Deletion is not allowed.');
END
`);
db.exec('COMMIT');
// v0.7.4 P1-5: 清空后重置链式哈希缓存 —— 否则下一条 log() 的 prev_hash
// 引用已删除的末行,audit:verifyChain 会误报 TAMPERED
auditService.resetChainCache();
log.info('[DATA] Audit logs cleared');
return { success: true };
} catch (error) {
try {
db.exec('ROLLBACK');
} catch {
/* 忽略回滚错误 */
}
return { success: false, error: (error as Error).message };
}
});
}