Files
metona-ai-desktop/electron/services/database.service.ts
T
thzxx 49c9b25538
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m25s
CI / 全量测试 (Electron ABI, experimental) (push) Failing after 5m19s
CI / 产物编译验证 (push) Successful in 10m3s
feat: v0.4.1 质量加固版 — 工程化基线 + 安全加固 + 测试补齐 + 体验升级
工程化(从零到一):
- 新增 Gitea Actions CI(debian-latest):类型检查 + Lint + 单元测试 + 产物编译验证
- 新增 husky + lint-staged 预提交钩子(lint-staged + typecheck 门禁)
- 移除坏脚本 test:e2e(无 Playwright 配置必失败);prebuild 改用内置 fs.rmSync
- 依赖清理:移除死依赖 sql.js(2MB)/@playwright/test,@types/shell-quote 移至 devDependencies

安全加固:
- PolicyEngine 频率限制按会话隔离(多会话并发不再互抢配额)
- ConfirmationHook 拒绝记忆加 10 分钟 TTL + 恢复询问入口(新增 2 个 IPC 通道)
- Windows run_command 白名单工具(git/node/npm/npx/pnpm/yarn/tsc)改走 cmd.exe /c + 参数数组执行,收窄 shell 注入面
- web_search 四引擎 HTML 解析迁移 node-html-parser(结构化主层 + 正则降级)

缺陷修复(测试驱动发现):
- mapError 大小写缺陷:网络错误码永远落入 UNKNOWN 无法触发重试
- 搜狗解析器自我过滤:相对链接补全后又被 sogou.com 过滤导致结果全丢
- 百度复合类名重复收录:class="result c-container" 被双重匹配

测试补齐(113 → 194 用例):
- 新增 5 个测试文件:sse-stream / base-adapter / confirmation-hook / ipc-agent 编排链路 / web-search 解析器
- 覆盖 sendMessage 全分支、SSE 流解析、错误映射、确认钩子竞态/超时/批量审批

体验升级:
- OutputValidator 验证结果可见化(VALIDATION 流事件 → 聊天流提示卡)
- SettingsModal 巨型组件拆分(1503 行 → 10 个文件,可独立维护)
- MessageList 接入 react-virtuoso 真虚拟滚动(千条消息恒定开销)
- MCP 新增 streamable HTTP 传输支持(SDK 内置传输 + DB 迁移 6 + UI 双模式)
2026-08-21 13:58:48 +08:00

500 lines
20 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.
/**
* 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);
}
/** 配置默认值条目(P1-13: 单一来源,global-config.service.ts 的 SEED_DEFAULTS 由此派生) */
export interface ConfigDefaultEntry {
key: string;
value: unknown;
category: string;
}
/**
* 全部配置默认值(唯一维护点)
*
* 注意:新增/修改配置默认值时只需改这里,全局配置层(SEED_DEFAULTS 判定"未配置"的依据)
* 会自动同步,避免双源漂移。
*/
export const CONFIG_DEFAULTS: ConfigDefaultEntry[] = [
// 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' },
// P1: Provider 故障转移配置
{ key: 'llm.fallbackProvider', value: '', category: 'llm' },
{ key: 'llm.fallbackModel', value: '', category: 'llm' },
{ key: 'llm.fallbackApiKey', value: '', category: 'llm' },
{ key: 'llm.fallbackBaseURL', 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 显示)
{ key: 'deepseek.contextWindow', value: 1000000, category: 'deepseek' },
{ key: 'agnes.contextWindow', value: 1000000, category: 'agnes' },
{ key: 'mimo.contextWindow', value: 1000000, category: 'mimo' },
// P3: OpenAI / Anthropic Provider
{ key: 'openai.contextWindow', value: 128000, category: 'openai' },
{ key: 'anthropic.contextWindow', value: 200000, category: 'anthropic' },
// Onboarding
{ key: 'onboarding.completed', value: false, category: 'general' },
];
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
);
-- ===== P2: 会话摘要表(分层上下文——超长会话早期消息压缩为摘要,LLM 只加载摘要 + 近期原文) =====
CREATE TABLE IF NOT EXISTS session_summaries (
session_id TEXT PRIMARY KEY,
summary TEXT NOT NULL,
summarized_until_rowid INTEGER NOT NULL,
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
-- ===== 索引 =====
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) => {
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;
}
}
};
// #33 修复: 整个迁移批次包裹在事务中,保证原子性
// 若某个 migration 部分失败(如 ALTER TABLE 成功,CREATE INDEX 失败),
// 事务回滚,数据库不会处于部分变更的不一致状态,下次启动可安全重试。
// better-sqlite3 的事务是同步原子的,嵌套事务使用 SAVEPOINT 实现。
const runAllMigrations = db.transaction(() => {
// 迁移 1: messages 表添加 attachments 列
tryAddColumn('messages', 'attachments', 'TEXT');
// 迁移 2: audit_logs 表添加 iteration 列
tryAddColumn('audit_logs', 'iteration', 'INTEGER');
// v0.2.0 迁移 3: audit_logs 表添加 prev_hash 列(链式哈希)
tryAddColumn('audit_logs', 'prev_hash', 'TEXT');
// v0.2.0 迁移 4: audit_logs 表添加 current_hash 列(链式哈希)
tryAddColumn('audit_logs', 'current_hash', 'TEXT');
// P2: 记忆 TF 缓存列(存储 tokenize 结果,避免每次检索重复分词)
tryAddColumn('episodic_memories', 'tf_cache', 'TEXT');
tryAddColumn('semantic_memories', 'tf_cache', 'TEXT');
tryAddColumn('working_memories', 'tf_cache', 'TEXT');
// v0.4.1 迁移 6: 重建 mcp_servers 表,transport CHECK 约束放宽以支持 'streamable-http'
// 旧约束 CHECK(transport IN ('stdio','sse')) 会拒绝新传输方式写入
try {
const schemaRow = db
.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'mcp_servers'")
.get() as { sql: string } | undefined;
// 检测现有 CHECK 约束是否已包含 streamable-http(新表跳过重建)
if (schemaRow && schemaRow.sql && !schemaRow.sql.includes('streamable-http')) {
log.info(
'[DB] Migration: rebuilding mcp_servers table to support streamable-http transport',
);
const rebuildMcpServers = db.transaction(() => {
db.exec(`
CREATE TABLE IF NOT EXISTS mcp_servers_new (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
transport TEXT NOT NULL CHECK(transport IN ('stdio', 'sse', 'streamable-http')),
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)
);
INSERT INTO mcp_servers_new (id, name, transport, command, args, url, headers, enabled, last_connected, error_message, created_at, updated_at)
SELECT id, name, transport, command, args, url, headers, enabled, last_connected, error_message, created_at, updated_at
FROM mcp_servers;
DROP TABLE mcp_servers;
ALTER TABLE mcp_servers_new RENAME TO mcp_servers;
`);
});
rebuildMcpServers();
log.info(
'[DB] Migration: mcp_servers table rebuilt successfully (transport now supports streamable-http)',
);
}
} catch (error) {
const msg = toErrorMessage(error);
log.warn(`[DB] Migration 6 (mcp_servers transport CHECK) skipped: ${msg}`);
// 非致命 — 迁移失败时仅无法添加 streamable-http 服务器,stdio/sse 不受影响
}
// 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');
// #33 修复: 重建表的多步骤包裹在嵌套事务中,部分失败时回滚
// 避免 CREATE messages_new 成功但 DROP/RENAME 失败导致数据丢失或 schema 不一致
const rebuildMessages = db.transaction(() => {
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;
`);
// 重建索引
// #42 确认: idx_messages_session 已是 (session_id, created_at) 复合索引,
// 覆盖 getMessages 的 WHERE session_id = ? ORDER BY created_at ASC 查询,
// 工单描述"仅有 session_id 单字段索引"不准确,无需额外添加 idx_messages_session_timestamp
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);
`);
});
rebuildMessages();
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 会保存空字符串
}
});
runAllMigrations();
}
/**
* 插入默认配置(P1-13: 从 CONFIG_DEFAULTS 单一来源派生)
*/
private seedDefaults(): void {
const db = this.db!;
const insert = db.prepare(`
INSERT OR IGNORE INTO app_config (key, value, category) VALUES (?, ?, ?)
`);
const insertMany = db.transaction((items: ConfigDefaultEntry[]) => {
for (const item of items) {
insert.run(item.key, JSON.stringify(item.value), item.category);
}
});
insertMany(CONFIG_DEFAULTS);
log.info('Default config seeded');
}
}