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

315 lines
12 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 App Handlers — 应用工具域(P2-9 从 handlers.ts 拆分)
*
* 版本/路径查询、外部链接、文件夹选择、重启、SearXNG 连接测试、
* 审计日志链验证/查询、渲染进程错误上报(P0-3 修复断链)。
*/
import { ipcMain, shell, app, dialog, BrowserWindow } from 'electron';
import type { IPCContext } from './context';
import type { AuditEventType } from '../services/audit.service';
import { UpdateService } from '../services/update.service';
import { assertSafeConfigTarget } from '../harness/tools/built-in/ssrf-guard';
import log from 'electron-log';
export function registerAppHandlers(ctx: IPCContext): void {
const { mainWindow, auditService } = ctx;
ipcMain.handle('app:getVersion', async () => {
return app.getVersion();
});
ipcMain.handle('app:getAppDataPath', async () => {
return app.getPath('userData');
});
// v0.6.4 P4-2: 手动检查更新(feed 比对式 —— electron-updater 接入时仅替换实现)
ipcMain.handle('app:updateCheck', async () => {
const feedUrl = ctx.configService.get<string>('app.updateFeedUrl') ?? '';
const service = new UpdateService(
() => app.getVersion(),
() => (feedUrl ? feedUrl : null),
);
const result = await service.check();
log.info('[Update] check result:', JSON.stringify(result));
return result;
});
// ===== v0.8.0 P2-3: 下载并安装更新(electron-updater;未启用时明确失败) =====
ipcMain.handle('app:updateInstall', async () => {
const { getAutoUpdaterHandle } = await import('../services/update.service');
const handle = getAutoUpdaterHandle();
if (!handle) {
return { success: false, error: '自动更新未启用(未配置更新源或开发模式)' };
}
// fire-and-forget:进度经 update:status 广播,完成后 quitAndInstall
void handle.downloadAndInstall();
return { success: true };
});
// ===== v0.7.3 P3-4: 健康快照(SLO 指标 + 最近健康检查报告)=====
ipcMain.handle('app:healthSnapshot', async () => {
try {
return { success: true, data: ctx.getHealthSnapshot() };
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
// ===== v0.7.3 P3-3: JSONL 录制文件统计与清理(设置页展示 + 手动清理)=====
ipcMain.handle('logs:traceStats', async () => {
try {
return { success: true, data: ctx.sessionRecorder.getRecordingStats() };
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
ipcMain.handle('logs:pruneTraceFiles', async () => {
try {
const deleted = ctx.sessionRecorder.pruneOldRecordings();
log.info(`[LOGS] Manual trace prune: ${deleted} file(s) removed`);
return { success: true, data: { deleted } };
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
ipcMain.handle('app:openExternal', async (_event, url: unknown) => {
// M-12 修复: URL 协议白名单校验,防止打开 file:///smb:// 等危险协议
if (typeof url !== 'string' || !url) {
return { success: false, error: 'Invalid URL' };
}
try {
const parsed = new URL(url);
const ALLOWED_PROTOCOLS = ['http:', 'https:', 'mailto:'];
if (!ALLOWED_PROTOCOLS.includes(parsed.protocol)) {
log.warn(`[IPC] openExternal blocked: protocol "${parsed.protocol}" not in whitelist`);
return { success: false, error: `Protocol not allowed: ${parsed.protocol}` };
}
await shell.openExternal(url);
return { success: true };
} catch (error) {
log.warn('[IPC] openExternal failed:', (error as Error).message);
return { success: false, error: (error as Error).message };
}
});
ipcMain.handle('app:showItemInFolder', async (_event, path: unknown) => {
// M-36 修复: path 类型校验
if (typeof path !== 'string' || !path) {
return { success: false, error: 'Invalid path' };
}
try {
shell.showItemInFolder(path);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
ipcMain.handle('app:selectFolder', async (event, defaultPath?: string) => {
// F-4 补充: 从 event.sender 动态解析窗口 —— ctx.mainWindow 是注册时捕获的引用,
// macOS activate 重建窗口后为已销毁实例(dialog 挂 destroyed 窗口行为未定义)。
// fromWebContents 对任何存活窗口(含未来多窗口)都正确,解析失败回退无父对话框。
const callerWin = BrowserWindow.fromWebContents(event.sender) ?? mainWindow;
const result = await dialog.showOpenDialog(callerWin, {
properties: ['openDirectory', 'createDirectory'],
defaultPath: defaultPath ?? app.getPath('home'),
title: '选择工作空间目录',
});
if (result.canceled || result.filePaths.length === 0) return { canceled: true, path: '' };
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');
// 延迟 200ms 让 IPC 响应先返回
setTimeout(() => {
app.relaunch();
app.exit(0);
}, 200);
return { success: true };
});
// ===== P0-3 修复: 渲染进程错误上报通道(原 preload 发送端存在但主进程无 handler,上报被静默丢弃) =====
ipcMain.on('error:report', (_event, payload: unknown) => {
try {
if (!payload || typeof payload !== 'object') return;
const p = payload as Record<string, unknown>;
// 截断超长字段(stack 可达数十 KB),防止日志膨胀
const truncateStr = (v: unknown, max = 4000): unknown =>
typeof v === 'string' ? v.slice(0, max) : v;
const details = {
type: truncateStr(p.type, 100),
error: truncateStr(p.error, 1000),
stack: truncateStr(p.stack),
componentStack: truncateStr(p.componentStack, 4000),
timestamp: p.timestamp,
};
log.error('[Renderer Error]', JSON.stringify(details));
// 同步写入审计日志(TOOL 层),便于事后追溯
auditService.log({
sessionId: '',
eventType: 'error',
actor: 'user',
target: 'renderer',
details,
outcome: 'error',
});
} catch (err) {
// 错误上报自身失败仅记录,不抛出(单向通道无响应方)
log.warn('[IPC] error:report handler failed:', err);
}
});
// ===== SearXNG 连接测试 =====
ipcMain.handle(
'searxng:testConnection',
async (_event, url: string, authKey: string, authType: string) => {
try {
if (!url || !/^https?:\/\//.test(url)) {
return { success: false, error: 'URL 需以 http:// 或 https:// 开头' };
}
// v0.7.4 P2-9: 高危目标校验(云元数据/链路本地/组播/保留段拦截;
// 本地/私网 SearXNG 实例放行——自建实例常见于局域网)
assertSafeConfigTarget(url);
const startTime = Date.now();
const headers: Record<string, string> = {};
if (authKey) {
if (authType === 'bearer') {
headers['Authorization'] = `Bearer ${authKey}`;
} else if (authType === 'basic') {
headers['Authorization'] = `Basic ${Buffer.from(authKey).toString('base64')}`;
}
}
const testUrl = `${url.replace(/\/$/, '')}/search?q=test&format=json&pageno=1`;
const response = await fetch(testUrl, {
headers,
signal: AbortSignal.timeout(10_000),
});
const latencyMs = Date.now() - startTime;
if (response.ok) {
return { success: true, statusCode: response.status, latencyMs };
}
return {
success: false,
statusCode: response.status,
latencyMs,
error: `HTTP ${response.status} ${response.statusText}`,
};
} catch (error) {
return { success: false, error: (error as Error).message };
}
},
);
// ===== v0.2.0: 审计日志链式哈希验证 =====
ipcMain.handle('audit:verifyChain', async () => {
try {
const result = auditService.verifyChain();
log.info(
`[AUDIT] Chain verification: ${result.valid ? 'valid' : 'TAMPERED'} (${result.verifiedRecords}/${result.totalRecords})`,
);
return { success: true, ...result };
} catch (error) {
log.error('[AUDIT] Chain verification failed:', error);
return { success: false, error: (error as Error).message };
}
});
// v0.5.0: 审计日志导出(JSONL / CSV 两种格式)
ipcMain.handle('audit:export', async (_event, format: unknown) => {
const fmt = format === 'csv' ? 'csv' : 'jsonl';
try {
const content = fmt === 'csv' ? auditService.exportCSV() : auditService.exportJSONL();
const recordCount = content.trim()
? fmt === 'csv'
? content.trim().split('\n').length - 1
: content.trim().split('\n').length
: 0;
log.info(`[AUDIT] Exported ${recordCount} records as ${fmt.toUpperCase()}`);
return { success: true, data: content, format: fmt, recordCount };
} catch (error) {
log.error('[AUDIT] Export failed:', error);
return { success: false, error: (error as Error).message };
}
});
ipcMain.handle('audit:query', async (_event, filters?: unknown) => {
// M-45 修复: 校验 filters 参数类型和范围
const VALID_AUDIT_EVENT_TYPES: readonly AuditEventType[] = [
'tool_call',
'permission_check',
'error',
'llm_request',
'llm_response',
'session_start',
'session_end',
'config_change',
];
const safeFilters: { sessionId?: string; eventType?: AuditEventType; limit?: number } = {};
if (filters && typeof filters === 'object') {
const f = filters as Record<string, unknown>;
if (typeof f.sessionId === 'string' && f.sessionId) safeFilters.sessionId = f.sessionId;
if (typeof f.eventType === 'string' && f.eventType) {
// 校验 eventType 是否在合法枚举内
if (!(VALID_AUDIT_EVENT_TYPES as readonly string[]).includes(f.eventType)) {
return {
success: false,
error: `Invalid eventType (must be one of: ${VALID_AUDIT_EVENT_TYPES.join(', ')})`,
};
}
safeFilters.eventType = f.eventType as AuditEventType;
}
if (f.limit !== undefined) {
const limit = Number(f.limit);
// 限制 1-1000 范围,防止过大查询拖慢性能
if (!Number.isFinite(limit) || limit < 1 || limit > 1000) {
return { success: false, error: 'Invalid limit (must be 1-1000)' };
}
safeFilters.limit = Math.floor(limit);
}
}
try {
return { success: true, data: auditService.query(safeFilters) };
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
}