Files
metona-ai-desktop/electron/services/session-summary.service.ts
T
thzxx 2230bcec3f feat: v0.4.0 四阶段迭代 — 安全加固 + 工程基线 + 架构重构 + 双 Provider 扩展
P0 安全修复:
- API Key 加密存储(safeStorage 密钥链,版本化前缀,历史明文平滑兼容)
- 间接提示注入防护(SecurityScanHook 工具结果深扫描,网络工具脱敏/本地工具警示分级)
- error:report IPC 断链修复(渲染进程错误上报落 electron-log + 审计)
- abort 信号贯通工具层(run_command/dev-tools 子进程随会话中断终止)
- run_command 沙箱加固(cd 系统目录/敏感文件读取拦截 + chcp 前缀剥离防解析退化)
- .env 真实生效(dotenv 回退加载,应用内配置优先)

P1 工程基础:
- ESLint 9 flat config + 全部 34 条存量 warnings 清零(零容忍基线)
- 测试基线 118 用例 11 文件(token/文件防护/权限/沙箱/注入/命令/引擎/注册表/审计链/摘要分层)
- test:electron 双模式(ELECTRON_RUN_AS_NODE 跑 Electron ABI,SQLite 套件全执行)
- SessionRecorder 多会话隔离 + 9 种 TRACE 事件补全(含最终轮 iteration_end)
- Provider 故障转移(重试耗尽/不可重试一次性切换 fallback + 前端通知)
- MCP 真就绪(等待全部连接完成再广播 tools:ready)
- SLO/HealthChecker 真实接入(60s 巡检 + 托盘状态)
- CONFIG_DEFAULTS 单一来源(消除 SEED 双源漂移)

P2 架构升级:
- handlers.ts 1940 行拆分为 13 个 IPC 域模块(防重入注册 + 多窗口广播)
- AgentEngineManager 每会话独立引擎(LRU 30 + adapter 工厂隔离 abort 信号)
- TaskOrchestrator EngineProvider 改造 + abortByParent 联动中断 SubAgent
- 会话摘要分层上下文(session_summaries 滚动摘要 + 截断游标清理防因果污染)
- 消息编辑重发/重新生成(truncateAfter IPC + store 动作 + UI)
- Markdown 导出 / WebSearch 并行抓取(并发 3)/ 记忆 TF 缓存 / 版本构建期注入

P3 能力扩展:
- OpenAI Adapter(o 系列推理模型 reasoning_effort/max_completion_tokens)
- Anthropic Adapter(原生 Messages API:tool_use 块/角色合并/thinking budget/图片 base64/SSE 事件机)
- 设置页/Onboarding 六 Provider 全链路接入
2026-08-20 23:17:02 +08:00

201 lines
7.9 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.
/**
* Session Summary Service — 会话摘要分层上下文(P2-11)
*
* 解决"历史消息全量加载"问题:sendMessage 原实现从 DB 加载全部历史消息进 LLM
* 上下文,超长会话(数百条消息)token 成本线性膨胀,只能依赖运行时压缩兜底。
*
* 分层策略(DB 层持久化,跨 run 生效):
* 1. 会话消息数超过阈值(50 条)后,将较早消息(保留尾部 20 条原文)交由 LLM
* 生成滚动摘要,持久化到 session_summaries 表(含 summarized_until_rowid 游标)
* 2. 下次 sendMessage 只加载:[摘要消息] + [rowid > 游标的近期原文消息]
* 3. 摘要随新消息增量滚动更新(旧摘要作为上下文参与新一轮总结)
*
* 与 engine 运行时压缩(compressMessages)的关系:
* 运行时压缩处理"单次 run 内"的上下文膨胀;本服务处理"跨 run"的 DB 历史分层,
* 两者互补,运行时压缩触发频率将显著下降。
*
* @see electron/services/database.service.ts — session_summaries 表结构
*/
import { nanoid } from 'nanoid';
import type Database from 'better-sqlite3';
import log from 'electron-log';
import type { IMetonaProviderAdapter, MetonaMessage, MetonaRequest } from '../harness/types';
import type { SessionService } from './session.service';
/** 触发摘要的最小消息总数(低于此值保持全量加载) */
const MIN_MESSAGES_TO_SUMMARIZE = 50;
/** 摘要后保留的尾部原文条数 */
const TAIL_KEEP = 20;
/** 每次摘要需新增的最小未总结消息数(避免每条消息都触发 LLM 摘要) */
const MIN_NEW_TO_SUMMARIZE = 15;
/** LLM 摘要调用超时 */
const SUMMARY_TIMEOUT_MS = 30_000;
/** 传给 LLM 的单条消息内容截断 */
const PER_MESSAGE_TRUNCATE = 600;
/** 传给 LLM 的总字符上限 */
const MAX_DIGEST_CHARS = 24_000;
export class SessionSummaryService {
constructor(
private getDB: () => Database.Database,
private sessionService: SessionService,
private adapterGetter: () => IMetonaProviderAdapter,
) {}
/**
* 构建分层历史消息(sendMessage 的加载入口)
*
* @returns MetonaMessage 数组:存在摘要时为 [摘要消息, 近期原文...],否则全量原文
*/
buildHistoryMessages(sessionId: string): MetonaMessage[] {
const existing = this.getSummary(sessionId);
const tail = this.sessionService.getMessages(sessionId, {
afterRowid: existing?.summarizedUntilRowid ?? 0,
});
const messages: MetonaMessage[] = tail
.filter((m) => m.role !== 'system')
.map((m) => ({
role: m.role as MetonaMessage['role'],
content: m.content,
reasoningContent: m.reasoningContent,
toolCalls: m.toolCalls as MetonaMessage['toolCalls'],
toolResult: m.toolResult as MetonaMessage['toolResult'],
timestamp: m.timestamp,
iteration: m.iteration,
}));
if (existing && messages.length > 0) {
// 摘要以 assistant 角色注入(与 engine 运行时压缩的注入策略一致)
const summaryMessage: MetonaMessage = {
role: 'assistant',
content: `[Context Summary] The following is a rolling summary of earlier conversation history:\n\n${existing.summary}`,
timestamp: 0,
};
return [summaryMessage, ...messages];
}
return messages;
}
/**
* 会话结束后评估并生成滚动摘要(fire-and-forget 调用,失败仅记录日志)
*/
async maybeSummarize(sessionId: string): Promise<void> {
const rows = this.sessionService.getMessages(sessionId);
if (rows.length < MIN_MESSAGES_TO_SUMMARIZE) return;
const existing = this.getSummary(sessionId);
const lastSummarized = existing?.summarizedUntilRowid ?? 0;
const unsummarized = rows.filter((r) => (r.rowId ?? 0) > lastSummarized);
// 未总结增量不足(保留尾部 TAIL_KEEP 后仍需 ≥ MIN_NEW_TO_SUMMARIZE 条)
if (unsummarized.length <= TAIL_KEEP + MIN_NEW_TO_SUMMARIZE) return;
const toSummarize = unsummarized.slice(0, unsummarized.length - TAIL_KEEP);
if (toSummarize.length < MIN_NEW_TO_SUMMARIZE) return;
const summary = await this.summarizeViaLLM(existing?.summary ?? '', toSummarize);
if (!summary) return;
const untilRowid = toSummarize[toSummarize.length - 1].rowId ?? 0;
this.saveSummary(sessionId, summary, untilRowid);
log.info(
`[SessionSummary] session ${sessionId}: summarized ${toSummarize.length} messages (up to rowid ${untilRowid}), kept ${TAIL_KEEP} recent`,
);
}
// ===== 摘要表 CRUD =====
getSummary(sessionId: string): { summary: string; summarizedUntilRowid: number } | null {
const db = this.getDB();
const row = db
.prepare('SELECT summary, summarized_until_rowid FROM session_summaries WHERE session_id = ?')
.get(sessionId) as { summary: string; summarized_until_rowid: number } | undefined;
return row ? { summary: row.summary, summarizedUntilRowid: row.summarized_until_rowid } : null;
}
saveSummary(sessionId: string, summary: string, untilRowid: number): void {
const db = this.getDB();
db.prepare(`
INSERT INTO session_summaries (session_id, summary, summarized_until_rowid, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(session_id) DO UPDATE SET
summary = excluded.summary,
summarized_until_rowid = excluded.summarized_until_rowid,
updated_at = excluded.updated_at
`).run(sessionId, summary, untilRowid, Date.now());
}
// ===== LLM 摘要 =====
/**
* 调用 LLM 生成滚动摘要(复用主 Provider adapter
*
* @param priorSummary 既有摘要(滚动总结上下文,可为空)
* @param messages 本轮待总结的消息
*/
private async summarizeViaLLM(
priorSummary: string,
messages: Array<{ role: string; content: string | null }>,
): Promise<string | null> {
let total = 0;
const transcript = messages
.map((m) => {
const content = (m.content ?? '').slice(0, PER_MESSAGE_TRUNCATE);
if (total < MAX_DIGEST_CHARS) {
total += content.length;
return `[${m.role.toUpperCase()}] ${content}`;
}
return null;
})
.filter((s): s is string => s !== null)
.join('\n\n');
const request: MetonaRequest = {
meta: {
sessionId: 'session-summary',
iteration: 0,
requestId: `ss_${nanoid(12)}`,
timestamp: Date.now(),
agentVersion: '1.0.0',
},
systemPrompt: {
roleDefinition: 'You are a conversation summarizer for an AI agent application.',
outputConstraints:
'Produce a rolling summary of the conversation history below. ' +
'If a prior summary exists, merge it with the new content into one updated summary. ' +
'Preserve key facts, decisions, tool outcomes, file paths, and open questions needed for future reasoning. ' +
'Output in the same language as the conversation. Maximum 400 words. Output ONLY the summary text.',
safetyGuidelines: 'Do not include sensitive data like passwords or API keys in the summary.',
},
messages: [
{
role: 'user',
content: `${priorSummary ? `## Prior summary (merge and update):\n\n${priorSummary}\n\n` : ''}## New messages to summarize:\n\n${transcript}`,
timestamp: Date.now(),
},
],
params: {
maxTokens: 2048,
temperature: 0.0,
stream: false,
thinkingEnabled: false,
thinkingEffort: 'low',
},
};
try {
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error('summary timeout')), SUMMARY_TIMEOUT_MS);
});
const response = await Promise.race([this.adapterGetter().send(request), timeoutPromise]);
const summary = response.content.trim();
return summary || null;
} catch (err) {
log.warn('[SessionSummary] LLM summarization failed:', (err as Error).message);
return null;
}
}
}