Files
metona-ai-desktop/electron/services/session.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

420 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.
/**
* Session Service — 会话 CRUD + 消息持久化
*
* 管理会话的创建、查询、更新、删除,以及消息的存取。
* 所有操作通过 better-sqlite3 同步执行。
*
* @see docs/MetonaAI-Desktop 架构与交互设计.html
*/
import { nanoid } from 'nanoid';
import type Database from 'better-sqlite3';
import log from 'electron-log';
// ===== 类型定义 =====
export interface SessionRow {
id: string;
title: string;
created_at: number;
updated_at: number;
message_count: number;
total_tokens: number;
pinned: number;
archived: number;
metadata: string;
}
export interface MessageRow {
id: string;
/** P2: rowid(插入序号,消息截断与分层加载的游标) */
row_id?: number;
session_id: string;
role: string;
// C-6 修复: content 允许 null — assistant 消息仅有 tool_calls 时为 null
content: string | null;
reasoning_content: string | null;
tool_calls: string | null;
tool_result: string | null;
attachments: string | null;
iteration: number | null;
created_at: number;
}
export interface SessionInfo {
id: string;
title: string;
createdAt: number;
updatedAt: number;
messageCount: number;
totalTokens: number;
pinned: boolean;
archived: boolean;
}
export interface MessageInfo {
id: string;
/** P2: rowid(插入序号;会话摘要分层加载与消息截断的游标) */
rowId?: number;
role: string;
// C-6 修复: content 允许 null — assistant 消息仅有 tool_calls 时为 null
content: string | null;
reasoningContent?: string;
toolCalls?: unknown[];
toolResult?: unknown;
attachments?: unknown[];
iteration?: number;
timestamp: number;
}
// ===== 服务类 =====
export class SessionService {
/**
* 获取数据库实例
*/
getDB(): Database.Database {
return this.getDBFn();
}
constructor(private getDBFn: () => Database.Database) {}
/**
* 列出所有会话
*/
list(options?: { archived?: boolean }): SessionInfo[] {
const db = this.getDBFn();
const archived = options?.archived ?? false;
const rows = db.prepare(`
SELECT * FROM sessions
WHERE archived = ?
ORDER BY pinned DESC, updated_at DESC
`).all(archived ? 1 : 0) as SessionRow[];
return rows.map((row) => this.toSessionInfo(row));
}
/**
* 创建新会话
*/
create(title?: string): SessionInfo {
const db = this.getDBFn();
const id = `s_${nanoid(12)}`;
const now = Date.now();
const sessionTitle = title ?? '新会话';
db.prepare(`
INSERT INTO sessions (id, title, created_at, updated_at)
VALUES (?, ?, ?, ?)
`).run(id, sessionTitle, now, now);
log.info(`Session created: ${id} (${sessionTitle})`);
return {
id,
title: sessionTitle,
createdAt: now,
updatedAt: now,
messageCount: 0,
totalTokens: 0,
pinned: false,
archived: false,
};
}
/**
* 重命名会话
*/
rename(sessionId: string, title: string): boolean {
const db = this.getDBFn();
const result = db.prepare(`
UPDATE sessions SET title = ?, updated_at = ? WHERE id = ?
`).run(title, Date.now(), sessionId);
if (result.changes > 0) {
log.info(`Session renamed: ${sessionId}${title}`);
return true;
}
return false;
}
/**
* 删除会话(级联删除消息)
*/
delete(sessionId: string): boolean {
const db = this.getDBFn();
const result = db.prepare('DELETE FROM sessions WHERE id = ?').run(sessionId);
if (result.changes > 0) {
log.info(`Session deleted: ${sessionId}`);
return true;
}
return false;
}
/**
* 置顶/取消置顶
*/
pin(sessionId: string, pinned: boolean): boolean {
const db = this.getDBFn();
const result = db.prepare(`
UPDATE sessions SET pinned = ?, updated_at = ? WHERE id = ?
`).run(pinned ? 1 : 0, Date.now(), sessionId);
return result.changes > 0;
}
/**
* 归档/取消归档
*/
archive(sessionId: string, archived: boolean): boolean {
const db = this.getDBFn();
const result = db.prepare(`
UPDATE sessions SET archived = ?, updated_at = ? WHERE id = ?
`).run(archived ? 1 : 0, Date.now(), sessionId);
return result.changes > 0;
}
/**
* 获取会话消息列表
*
* #44 修复: 添加 limit/offset 参数支持分页,避免超长会话一次性加载导致 OOM
* 默认不限制(limit=0),保持向后兼容;调用者可传 limit 限制返回条数
* P2-11: 新增 afterRowid 参数——仅返回 rowid 大于该值的消息(分层上下文加载游标);
* 排序改用 rowid(插入序号),与截断/摘要游标语义一致
*/
getMessages(
sessionId: string,
options: { limit?: number; offset?: number; afterRowid?: number } = {},
): MessageInfo[] {
const db = this.getDBFn();
const { limit = 0, offset = 0, afterRowid = 0 } = options;
let sql = 'SELECT rowid AS row_id, * FROM messages WHERE session_id = ?';
const params: unknown[] = [sessionId];
if (afterRowid > 0) {
sql += ' AND rowid > ?';
params.push(afterRowid);
}
sql += ' ORDER BY rowid ASC';
if (limit > 0) {
sql += ' LIMIT ? OFFSET ?';
params.push(limit, offset);
}
const rows = db.prepare(sql).all(...params) as MessageRow[];
return rows.map((row) => this.toMessageInfo(row));
}
/**
* P2-11: 截断消息——删除指定消息(含/不含)之后的所有消息
*
* 用途:
* - 编辑重发:删除原用户消息及其后所有消息(inclusive=true),重新发送修订版
* - 重新生成:删除最后一条用户消息之后的所有回复(inclusive=true 于该用户消息)
*
* 审查修复(全量复检 #1: 同步清理 session_summaries 摘要游标。
* 若摘要游标(summarized_until_rowid)落在被删除范围内而不清理,会导致两个缺陷:
* 1. buildHistoryMessages 的 afterRowid 过滤返回空 tail —— 截断点之前的原文永远不加载
* 2. 摘要内容包含已被撤销的消息("未来"内容因果污染——用户回退历史但 LLM 仍记得)
*
* @param sessionId 会话 ID
* @param messageId 锚点消息 ID
* @param inclusive true=连同锚点消息一起删除;false=仅删除其后消息
* @returns 是否有消息被删除
*/
truncateMessagesAfter(sessionId: string, messageId: string, inclusive = true): boolean {
const db = this.getDBFn();
const op = inclusive ? '>=' : '>';
// 先查锚点 rowid(删除后无法再定位)
const anchor = db
.prepare('SELECT rowid AS rid FROM messages WHERE session_id = ? AND id = ?')
.get(sessionId, messageId) as { rid: number } | undefined;
if (!anchor) return false;
const result = db
.prepare(`DELETE FROM messages WHERE session_id = ? AND rowid ${op} ?`)
.run(sessionId, anchor.rid);
if (result.changes > 0) {
// 同步修正会话消息计数(避免侧栏计数与实际不一致)
db.prepare(
`UPDATE sessions SET message_count = (SELECT COUNT(*) FROM messages WHERE session_id = ?), updated_at = ? WHERE id = ?`,
).run(sessionId, Date.now(), sessionId);
// 摘要游标清理:游标覆盖到被删除范围即删摘要(下次消息量达标后由 maybeSummarize 重建)
// - inclusive=true:锚点本身被删,游标 >= 锚点即视为被覆盖
// - inclusive=false:锚点保留,游标 > 锚点才被覆盖(游标==锚点时摘要与现存消息仍一致)
db.prepare(
'DELETE FROM session_summaries WHERE session_id = ? AND summarized_until_rowid >= ?',
).run(sessionId, anchor.rid + (inclusive ? 0 : 1));
log.info(`Session truncated: ${sessionId} (${result.changes} messages removed after ${messageId})`);
}
return result.changes > 0;
}
/**
* 保存一条消息
*/
saveMessage(params: {
sessionId: string;
role: string;
// C-6 修复: content 允许 null — assistant 消息仅有 tool_calls 时为 null
content: string | null;
reasoningContent?: string;
toolCalls?: unknown[];
toolResult?: unknown;
attachments?: unknown[];
iteration?: number;
}): MessageInfo {
const db = this.getDBFn();
const id = `msg_${nanoid(12)}`;
const now = Date.now();
// #34 修复: 包裹事务保证 INSERT messages 和 UPDATE sessions 原子执行
// message_count = message_count + 1 已是原子 SQL 表达式(避免读-改-写竞态)
// 事务进一步保证消息插入和计数更新要么全部成功,要么全部回滚
const saveMessageTxn = db.transaction(() => {
db.prepare(`
INSERT INTO messages (id, session_id, role, content, reasoning_content, tool_calls, tool_result, attachments, iteration, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
params.sessionId,
params.role,
params.content,
params.reasoningContent ?? null,
params.toolCalls ? JSON.stringify(params.toolCalls) : null,
params.toolResult ? JSON.stringify(params.toolResult) : null,
params.attachments ? JSON.stringify(params.attachments) : null,
params.iteration ?? null,
now,
);
// 更新会话的 updated_at 和 message_count
db.prepare(`
UPDATE sessions
SET updated_at = ?, message_count = message_count + 1
WHERE id = ?
`).run(now, params.sessionId);
});
saveMessageTxn();
return {
id,
role: params.role,
content: params.content,
reasoningContent: params.reasoningContent,
toolCalls: params.toolCalls,
toolResult: params.toolResult,
attachments: params.attachments,
iteration: params.iteration,
timestamp: now,
};
}
/**
* 更新会话 Token 统计
*/
updateTokenUsage(sessionId: string, tokens: number): void {
const db = this.getDBFn();
db.prepare(`
UPDATE sessions SET total_tokens = total_tokens + ? WHERE id = ?
`).run(tokens, sessionId);
}
/**
* 删除一条消息
*/
deleteMessage(messageId: string): boolean {
const db = this.getDBFn();
const result = db.prepare('DELETE FROM messages WHERE id = ?').run(messageId);
return result.changes > 0;
}
/**
* 清空会话所有消息
*/
clearMessages(sessionId: string): boolean {
const db = this.getDBFn();
const result = db.prepare('DELETE FROM messages WHERE session_id = ?').run(sessionId);
db.prepare('UPDATE sessions SET message_count = 0, updated_at = ? WHERE id = ?').run(Date.now(), sessionId);
return result.changes > 0;
}
/**
* 保存会话的 trace 步骤和 token 用量(存入 metadata JSON
*/
saveTraceData(sessionId: string, data: { traceSteps: unknown[]; tokenUsage: unknown }): void {
const db = this.getDBFn();
const metadata = JSON.stringify({ traceSteps: data.traceSteps, tokenUsage: data.tokenUsage });
db.prepare(`
UPDATE sessions SET metadata = ?, updated_at = ? WHERE id = ?
`).run(metadata, Date.now(), sessionId);
}
/**
* 加载会话的 trace 步骤和 token 用量
*/
getTraceData(sessionId: string): { traceSteps: unknown[]; tokenUsage: unknown } | null {
const db = this.getDBFn();
const row = db.prepare('SELECT metadata FROM sessions WHERE id = ?').get(sessionId) as { metadata: string } | undefined;
if (!row?.metadata) return null;
try {
const data = JSON.parse(row.metadata);
if (data.traceSteps || data.tokenUsage) return data;
return null;
} catch {
return null;
}
}
// ===== 私有转换方法 =====
private toSessionInfo(row: SessionRow): SessionInfo {
return {
id: row.id,
title: row.title,
createdAt: row.created_at,
updatedAt: row.updated_at,
messageCount: row.message_count,
totalTokens: row.total_tokens,
pinned: row.pinned === 1,
archived: row.archived === 1,
};
}
private toMessageInfo(row: MessageRow): MessageInfo {
// M-55 修复: 运行时收窄,防止数据库被篡改或老版本数据格式不一致导致下游 .map/.length 崩溃
const parsedToolCalls = row.tool_calls ? this.safeJsonParse(row.tool_calls) : undefined;
const parsedAttachments = row.attachments ? this.safeJsonParse(row.attachments) : undefined;
return {
id: row.id,
rowId: row.row_id,
role: row.role,
content: row.content,
reasoningContent: row.reasoning_content ?? undefined,
toolCalls: Array.isArray(parsedToolCalls) ? parsedToolCalls : undefined,
toolResult: row.tool_result ? this.safeJsonParse(row.tool_result) : undefined,
attachments: Array.isArray(parsedAttachments) ? parsedAttachments : undefined,
iteration: row.iteration ?? undefined,
timestamp: row.created_at,
};
}
/**
* 安全 JSON 解析:解析失败时返回 undefined 而非抛出异常
*/
private safeJsonParse(json: string): unknown {
try {
return JSON.parse(json);
} catch {
return undefined;
}
}
}