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 全链路接入
420 lines
13 KiB
TypeScript
420 lines
13 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|
||
}
|
||
}
|