feat: MetonaAI Desktop 初始项目
- Electron + React + TypeScript 架构 - 三栏布局: Sidebar | ChatPanel | DetailPanel - 9 个内置工具 (文件系统/网络/记忆/命令) - SQLite 持久化 (better-sqlite3) - MUI 暗色/亮色主题系统 - Agent Loop ReAct 状态机引擎 - DeepSeek / Agnes AI / Ollama Provider 适配器 - MCP 协议集成 - 系统托盘 + 全局快捷键 - Tailwind CSS v4 + Tailwind Merge - 修复: Sidebar 缺失 TextField 导入导致黑屏
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
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 '{}'
|
||||
);
|
||||
|
||||
-- ===== 消息表 =====
|
||||
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 NOT NULL,
|
||||
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,
|
||||
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)
|
||||
);
|
||||
|
||||
-- ===== 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)
|
||||
);
|
||||
|
||||
-- ===== 索引 =====
|
||||
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);
|
||||
`);
|
||||
|
||||
// 审计日志防篡改触发器(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!;
|
||||
|
||||
// 迁移 1: messages 表添加 attachments 列
|
||||
try {
|
||||
db.exec('ALTER TABLE messages ADD COLUMN attachments TEXT');
|
||||
log.info('[DB] Migration: added attachments column to messages');
|
||||
} catch {
|
||||
// 列已存在,忽略
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 插入默认配置
|
||||
*/
|
||||
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: '8192', 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' },
|
||||
|
||||
// 安全配置
|
||||
{ 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' },
|
||||
|
||||
// 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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user