/** * 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; } /** * 清空会话所有消息 * * v0.6.4 修复(B-2): 必须同步删除该会话的滚动摘要记录。 * * 根因:messages 表使用隐式 rowid(非 AUTOINCREMENT),全表 DELETE 后 * 新插入消息的 rowid 从 1 重新分配。若仅清空消息而保留 session_summaries 中 * 的 summarized_until_rowid 游标(例如 =500),后续 buildHistoryMessages 的 * `rowid > 500` 过滤会让所有新消息全部落空 —— 新对话永远不进入 LLM 历史; * maybeSummarize 的 `rowid > lastSummarized` 增量判定也长期误报"无增量"。 * 对比 truncateMessagesAfter(本文件 :269-271 一带)早已做了游标清理, * 此处是同一契约的遗漏点。删除摘要后游标从零重建,历史分层自然复位。 */ clearMessages(sessionId: string): boolean { const db = this.getDBFn(); const result = db.prepare('DELETE FROM messages WHERE session_id = ?').run(sessionId); // B-2: 同步清除滚动摘要(含 summarized_until_rowid 游标) db.prepare('DELETE FROM session_summaries 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; } /** * v0.5.0: 全文搜索会话内容(FTS5) * * 跨会话搜索消息内容,按会话聚合返回(每个会话取最新一条匹配 + 匹配总数)。 * 查询词按 FTS5 短语语法转义(内部双引号翻倍 + 整体加引号),防止用户输入的 * FTS 运算符(AND/OR/NOT/*)导致语法错误或语义偏移。 * * @param query 搜索关键词 * @param options.maxSessions 返回的会话数上限(默认 20) * @param options.scanLimit 匹配行扫描上限(默认 200,避免超大结果集拖慢聚合) */ searchMessages( query: string, options: { maxSessions?: number; scanLimit?: number } = {}, ): Array<{ sessionId: string; sessionTitle: string; matchCount: number; lastMessageId: string; lastRole: string; snippet: string; timestamp: number; }> { const db = this.getDBFn(); const { maxSessions = 20, scanLimit = 200 } = options; const trimmed = query.trim(); if (!trimmed) return []; // FTS5 短语转义:内部双引号翻倍,整体作为带引号短语匹配 const phrase = `"${trimmed.replace(/"/g, '""')}"`; interface MatchRow { id: string; session_id: string; role: string; created_at: number; snip: string; } let rows: MatchRow[]; try { rows = db .prepare( ` SELECT m.id, m.session_id, m.role, m.created_at, snippet(messages_fts, 0, '[', '] ', ' … ', 12) AS snip FROM messages_fts f JOIN messages m ON m.rowid = f.rowid WHERE messages_fts MATCH ? ORDER BY m.created_at DESC LIMIT ? `, ) .all(phrase, scanLimit) as MatchRow[]; } catch (err) { log.warn('[SessionService] FTS search failed:', (err as Error).message); return []; } // 按会话聚合:取每个会话的最新匹配(rows 已按时间倒序,首次出现即最新) const aggregated = new Map< string, { matchCount: number; lastMessageId: string; lastRole: string; snippet: string; timestamp: number; } >(); for (const row of rows) { const existing = aggregated.get(row.session_id); if (existing) { existing.matchCount++; } else { aggregated.set(row.session_id, { matchCount: 1, lastMessageId: row.id, lastRole: row.role, snippet: row.snip, timestamp: row.created_at, }); } } if (aggregated.size === 0) return []; // 补全会话标题(archived 一起搜——搜索不应因归档而缺结果) const sessionIds = Array.from(aggregated.keys()); const placeholders = sessionIds.map(() => '?').join(','); const titleRows = db .prepare(`SELECT id, title FROM sessions WHERE id IN (${placeholders})`) .all(...sessionIds) as Array<{ id: string; title: string }>; const titleMap = new Map(titleRows.map((r) => [r.id, r.title])); return Array.from(aggregated.entries()) .map(([sessionId, info]) => ({ sessionId, sessionTitle: titleMap.get(sessionId) ?? '(已删除会话)', ...info, })) .slice(0, maxSessions); } /** * 保存会话的 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; } } }