/** * 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; } } }