/** * Database Service — SQLite 数据库管理 * * 使用 better-sqlite3(同步、高性能、主进程专用)。 * 负责数据库初始化、Schema 迁移、连接管理。 * * @see docs/MetonaAI-Desktop 架构与交互设计.html — 数据库配置 * @see standard/开发规范.md — 禁止自写数据库层,使用 better-sqlite3 */ import Database from 'better-sqlite3'; import { join } from 'path'; import { app } from 'electron'; import { existsSync, mkdirSync } from 'fs'; import log from 'electron-log'; /** * L-8 修复: 提取 toErrorMessage 工具函数,消除 5 处重复的 error instanceof Error 三元表达式 */ function toErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } export class DatabaseService { private db: Database.Database | null = null; private dbPath: string; constructor(workspacePath?: string) { const baseDir = workspacePath ?? join(app.getPath('userData'), 'MetonaWorkspaces', 'default'); const metonaDir = join(baseDir, '.metona'); // 确保 .metona 目录存在 if (!existsSync(metonaDir)) { mkdirSync(metonaDir, { recursive: true }); } this.dbPath = join(metonaDir, 'agent.db'); } /** * 初始化数据库(创建表结构) */ initialize(): void { if (this.db) { log.warn('Database already initialized'); return; } log.info(`Initializing database: ${this.dbPath}`); this.db = new Database(this.dbPath); // 启用 WAL 模式(更好的并发性能) this.db.pragma('journal_mode = WAL'); this.db.pragma('foreign_keys = ON'); this.createTables(); this.runMigrations(); this.seedDefaults(); log.info('Database initialized successfully'); } /** * 获取数据库实例 */ getDB(): Database.Database { if (!this.db) { throw new Error('Database not initialized. Call initialize() first.'); } return this.db; } /** * 关闭数据库 */ close(): void { if (this.db) { this.db.close(); this.db = null; log.info('Database closed'); } } /** * 创建表结构 */ private createTables(): void { const db = this.db!; db.exec(` -- ===== 会话表 ===== CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, title TEXT NOT NULL DEFAULT '新会话', created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000), updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000), message_count INTEGER NOT NULL DEFAULT 0, total_tokens INTEGER NOT NULL DEFAULT 0, pinned INTEGER NOT NULL DEFAULT 0, archived INTEGER NOT NULL DEFAULT 0, metadata TEXT DEFAULT '{}' ); -- ===== 消息表 ===== -- C-6 修复: content 允许 NULL — assistant 消息仅有 tool_calls 时 content 必须为 null -- @see project_memory.md — Assistant messages with tool_calls must set content to null CREATE TABLE IF NOT EXISTS messages ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL, role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system', 'tool')), content TEXT, reasoning_content TEXT, tool_calls TEXT, tool_result TEXT, attachments TEXT, iteration INTEGER, created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000), FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE ); -- ===== 配置表 ===== CREATE TABLE IF NOT EXISTS app_config ( key TEXT PRIMARY KEY, value TEXT NOT NULL, category TEXT NOT NULL DEFAULT 'general', updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000) ); -- ===== 审计日志表 ===== CREATE TABLE IF NOT EXISTS audit_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT, iteration INTEGER, event_type TEXT NOT NULL, actor TEXT NOT NULL DEFAULT 'system', target TEXT NOT NULL, details TEXT, outcome TEXT, duration_ms INTEGER, created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000), prev_hash TEXT, current_hash TEXT ); -- ===== MCP 服务配置表 ===== CREATE TABLE IF NOT EXISTS mcp_servers ( id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, transport TEXT NOT NULL CHECK(transport IN ('stdio', 'sse')), command TEXT, args TEXT, url TEXT, headers TEXT, enabled INTEGER NOT NULL DEFAULT 1, last_connected INTEGER, error_message TEXT, created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000), updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000) ); -- ===== 情节记忆表 ===== CREATE TABLE IF NOT EXISTS episodic_memories ( id TEXT PRIMARY KEY, session_id TEXT, content TEXT NOT NULL, summary TEXT, source TEXT NOT NULL, importance REAL DEFAULT 0.5, created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000), expires_at INTEGER ); -- ===== 语义记忆表 ===== CREATE TABLE IF NOT EXISTS semantic_memories ( id TEXT PRIMARY KEY, key TEXT NOT NULL UNIQUE, value TEXT NOT NULL, category TEXT, confidence REAL DEFAULT 0.8, source_session TEXT, created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000), updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000), access_count INTEGER DEFAULT 0 ); -- ===== 工作记忆表 ===== CREATE TABLE IF NOT EXISTS working_memories ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL, task_id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000), UNIQUE(session_id, task_id, key) ); -- ===== v0.2.0: 任务表 ===== CREATE TABLE IF NOT EXISTS tasks ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL, title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'in_progress', 'completed', 'blocked', 'cancelled')), priority TEXT NOT NULL DEFAULT 'medium' CHECK(priority IN ('low', 'medium', 'high', 'critical')), parent_id TEXT, assigned_to TEXT, order_idx INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000), updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000), completed_at INTEGER, FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE, FOREIGN KEY (parent_id) REFERENCES tasks(id) ON DELETE CASCADE ); -- ===== 索引 ===== CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at); CREATE INDEX IF NOT EXISTS idx_messages_role ON messages(role); CREATE INDEX IF NOT EXISTS idx_sessions_updated ON sessions(updated_at DESC); CREATE INDEX IF NOT EXISTS idx_sessions_pinned ON sessions(pinned DESC, updated_at DESC); CREATE INDEX IF NOT EXISTS idx_audit_session ON audit_logs(session_id); CREATE INDEX IF NOT EXISTS idx_audit_type ON audit_logs(event_type); CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_logs(created_at); CREATE INDEX IF NOT EXISTS idx_config_category ON app_config(category); CREATE INDEX IF NOT EXISTS idx_semantic_key ON semantic_memories(key); CREATE INDEX IF NOT EXISTS idx_semantic_category ON semantic_memories(category); CREATE INDEX IF NOT EXISTS idx_episodic_session ON episodic_memories(session_id); CREATE INDEX IF NOT EXISTS idx_episodic_importance ON episodic_memories(importance DESC); CREATE INDEX IF NOT EXISTS idx_working_session_task ON working_memories(session_id, task_id); CREATE INDEX IF NOT EXISTS idx_tasks_session ON tasks(session_id, order_idx); CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(session_id, status); CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_id); `); // 审计日志防篡改触发器(INSERT-ONLY) db.exec(` CREATE TRIGGER IF NOT EXISTS audit_no_update BEFORE UPDATE ON audit_logs BEGIN SELECT RAISE(ABORT, 'Audit logs are INSERT-ONLY. Modification is not allowed.'); END; `); db.exec(` CREATE TRIGGER IF NOT EXISTS audit_no_delete BEFORE DELETE ON audit_logs BEGIN SELECT RAISE(ABORT, 'Audit logs are INSERT-ONLY. Deletion is not allowed.'); END; `); log.info('Database tables created'); } /** * 运行数据库迁移 */ private runMigrations(): void { const db = this.db!; // L-6 修复: 提取 tryAddColumn 辅助方法,消除 4 处重复的 try/catch 模式 // L-8 修复: 使用 toErrorMessage 替代重复的 error instanceof Error 三元表达式 const tryAddColumn = (table: string, column: string, type: string, migrationName: string) => { try { db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`); log.info(`[DB] Migration: added ${column} column to ${table}`); } catch (error) { // 只忽略 "duplicate column" 错误(列已存在),其他错误必须抛出 const msg = toErrorMessage(error); if (!msg.includes('duplicate column')) { throw error; } } }; // 迁移 1: messages 表添加 attachments 列 tryAddColumn('messages', 'attachments', 'TEXT', 'attachments'); // 迁移 2: audit_logs 表添加 iteration 列 tryAddColumn('audit_logs', 'iteration', 'INTEGER', 'iteration'); // v0.2.0 迁移 3: audit_logs 表添加 prev_hash 列(链式哈希) tryAddColumn('audit_logs', 'prev_hash', 'TEXT', 'prev_hash'); // v0.2.0 迁移 4: audit_logs 表添加 current_hash 列(链式哈希) tryAddColumn('audit_logs', 'current_hash', 'TEXT', 'current_hash'); // C-6 修复 迁移 5: 重建 messages 表,将 content 列从 NOT NULL 改为允许 NULL // @see project_memory.md — Assistant messages with tool_calls must set content to null // SQLite 不支持 ALTER COLUMN,需要重建表 try { // 检测 content 列是否有 NOT NULL 约束 const columns = db.prepare('PRAGMA table_info(messages)').all() as Array<{ name: string; notnull: number }>; const contentCol = columns.find((c) => c.name === 'content'); if (contentCol && contentCol.notnull === 1) { log.info('[DB] Migration: rebuilding messages table to allow NULL content'); db.exec(` CREATE TABLE IF NOT EXISTS messages_new ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL, role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system', 'tool')), content TEXT, reasoning_content TEXT, tool_calls TEXT, tool_result TEXT, attachments TEXT, iteration INTEGER, created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000), FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE ); INSERT INTO messages_new (id, session_id, role, content, reasoning_content, tool_calls, tool_result, attachments, iteration, created_at) SELECT id, session_id, role, content, reasoning_content, tool_calls, tool_result, attachments, iteration, created_at FROM messages; DROP TABLE messages; ALTER TABLE messages_new RENAME TO messages; `); // 重建索引 db.exec(` CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at); CREATE INDEX IF NOT EXISTS idx_messages_role ON messages(role); `); log.info('[DB] Migration: messages table rebuilt successfully (content now allows NULL)'); } } catch (error) { // L-8 修复: 使用 toErrorMessage 替代重复的三元表达式 const msg = toErrorMessage(error); log.warn(`[DB] Migration 5 (messages content NULL) skipped: ${msg}`); // 非致命错误 — 如果迁移失败,NOT NULL 约束仍生效,saveMessage 会保存空字符串 } } /** * 插入默认配置 */ private seedDefaults(): void { const db = this.db!; const defaults: Array<{ key: string; value: string; category: string }> = [ // LLM 配置(无硬编码值,用户必须手动配置) { key: 'llm.provider', value: '""', category: 'llm' }, { key: 'llm.model', value: '""', category: 'llm' }, { key: 'llm.apiKey', value: '""', category: 'llm' }, { key: 'llm.baseURL', value: '""', category: 'llm' }, { key: 'llm.temperature', value: '0', category: 'llm' }, { key: 'llm.maxTokens', value: '63488', category: 'llm' }, { key: 'llm.fallbackProvider', value: '""', category: 'llm' }, { key: 'llm.fallbackModel', value: '""', category: 'llm' }, // Agent 配置 { key: 'agent.maxIterations', value: '20', category: 'agent' }, { key: 'agent.totalTimeoutMs', value: '600000', category: 'agent' }, { key: 'agent.enableThinking', value: 'true', category: 'agent' }, { key: 'agent.thinkingEffort', value: '"high"', category: 'agent' }, { key: 'agent.enableReflection', value: 'false', category: 'agent' }, // C-10 修复: 补充缺失的 agent 配置默认值 // @see project_memory.md — Tool confirmation timeout is configurable via agent.confirmationTimeoutMs (30s~600s, default 120s) { key: 'agent.confirmationTimeoutMs', value: '120000', category: 'agent' }, { key: 'agent.toolExecutionTimeoutMs', value: '120000', category: 'agent' }, // 安全配置 { key: 'security.requireWriteConfirmation', value: 'true', category: 'security' }, { key: 'security.maxFileWriteSizeKB', value: '1024', category: 'security' }, { key: 'security.promptInjectionDefense', value: 'true', category: 'security' }, // UI 配置 { key: 'ui.theme', value: '"auto"', category: 'ui' }, { key: 'ui.animationMode', value: '"auto"', category: 'ui' }, { key: 'ui.fontSize', value: '"medium"', category: 'ui' }, // 日志配置 { key: 'logging.level', value: '"info"', category: 'logging' }, { key: 'logging.auditEnabled', value: 'true', category: 'logging' }, { key: 'logging.traceEnabled', value: 'true', category: 'logging' }, // Ollama 配置 { key: 'ollama.numCtx', value: 'null', category: 'ollama' }, // Provider 上下文窗口配置(用于 Engine 压缩判断和 UI 显示) // v0.3.1: DeepSeek/Agnes 改为可配置,不再写死 1M { key: 'deepseek.contextWindow', value: '1000000', category: 'deepseek' }, { key: 'agnes.contextWindow', value: '1000000', category: 'agnes' }, // v0.3.4: MiMo 上下文窗口(官方未公布,保守设为 131072) { key: 'mimo.contextWindow', value: '131072', category: 'mimo' }, // Onboarding { key: 'onboarding.completed', value: 'false', category: 'general' }, ]; const insert = db.prepare(` INSERT OR IGNORE INTO app_config (key, value, category) VALUES (?, ?, ?) `); const insertMany = db.transaction((items: typeof defaults) => { for (const item of items) { insert.run(item.key, item.value, item.category); } }); insertMany(defaults); log.info('Default config seeded'); } }