新增 MiMo (小米) LLM Provider 适配器,支持 mimo-v2.5-pro 和 mimo-v2.5 两个文本模型,复用 OpenAI 兼容 SSE 流式解析,支持 Thinking 模式和 Function Calling。同步校准全量 docs 文档与 README 使其与实际代码一致。 主要变更: - 新增 mimo.adapter.ts 适配器(SSE + thinking.type + max_completion_tokens) - 修复 thinking 逻辑 bug:禁用思考时未传 temperature/top_p - 补全 sse-stream.ts 的 MiMo 缓存字段映射(prompt_tokens_details.cached_tokens) - 补全 sse-stream.ts 的 finish_reason 映射(repetition_truncation) - 注册 MiMo 适配器到 adapters/index.ts、main.ts 工厂 - handlers.ts 添加 mimo.contextWindow 热重载触发 - database.service.ts seed 添加 mimo 默认配置 - SettingsModal/OnboardingWizard/Header 添加 MiMo Provider UI - constants.ts PROVIDER_LABELS 添加 mimo - .env.example 添加 MIMO_API_KEY/MIMO_BASE_URL - 反向修改 4 个 docs HTML 设计文档(工具数量/版本日期/适配器列表/数据库表) - 反向修改 Agent网络工具通用设计-v2.md 附录 B 文件索引 - 完全重写 README.md(v0.3.4、27 工具、4 适配器、9 表)
402 lines
16 KiB
TypeScript
402 lines
16 KiB
TypeScript
/**
|
||
* 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');
|
||
}
|
||
}
|