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,222 @@
|
||||
/**
|
||||
* Audit Service — 审计日志服务(TOOL 层)
|
||||
*
|
||||
* 负责将工具调用、权限检查、错误等审计记录写入 SQLite audit_logs 表。
|
||||
* 设计原则:
|
||||
* 1. 所有重要操作必须记录
|
||||
* 2. 日志不可篡改(INSERT-ONLY)
|
||||
* 3. 支持按时间/类型/会话查询
|
||||
*
|
||||
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 日志分层
|
||||
* @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第十章
|
||||
*/
|
||||
|
||||
import type Database from 'better-sqlite3';
|
||||
import log from 'electron-log';
|
||||
|
||||
export type AuditEventType = 'tool_call' | 'permission_check' | 'error' | 'llm_request' | 'llm_response' | 'session_start' | 'session_end' | 'config_change';
|
||||
export type AuditActor = 'agent' | 'user' | 'system';
|
||||
export type AuditOutcome = 'success' | 'denied' | 'error';
|
||||
|
||||
export interface AuditEntry {
|
||||
sessionId: string;
|
||||
iteration?: number;
|
||||
eventType: AuditEventType;
|
||||
actor: AuditActor;
|
||||
target: string;
|
||||
details?: Record<string, unknown>;
|
||||
outcome?: AuditOutcome;
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
export class AuditService {
|
||||
constructor(private getDB: () => Database.Database) {}
|
||||
|
||||
/**
|
||||
* 记录审计条目
|
||||
*/
|
||||
log(entry: AuditEntry): void {
|
||||
try {
|
||||
const db = this.getDB();
|
||||
db.prepare(`
|
||||
INSERT INTO audit_logs (session_id, iteration, event_type, actor, target, details, outcome, duration_ms, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
entry.sessionId,
|
||||
entry.iteration ?? null,
|
||||
entry.eventType,
|
||||
entry.actor,
|
||||
entry.target,
|
||||
entry.details ? JSON.stringify(entry.details) : null,
|
||||
entry.outcome ?? null,
|
||||
entry.durationMs ?? null,
|
||||
Date.now(),
|
||||
);
|
||||
} catch (error) {
|
||||
// 审计日志写入失败不应影响主流程
|
||||
log.error('Audit log write failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录工具调用
|
||||
*/
|
||||
logToolCall(params: {
|
||||
sessionId: string;
|
||||
iteration: number;
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
outcome: AuditOutcome;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
durationMs: number;
|
||||
}): void {
|
||||
this.log({
|
||||
sessionId: params.sessionId,
|
||||
iteration: params.iteration,
|
||||
eventType: 'tool_call',
|
||||
actor: 'agent',
|
||||
target: params.toolName,
|
||||
details: {
|
||||
args: params.args,
|
||||
result: typeof params.result === 'string' ? params.result.slice(0, 1000) : params.result,
|
||||
error: params.error,
|
||||
},
|
||||
outcome: params.outcome,
|
||||
durationMs: params.durationMs,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录 LLM 请求
|
||||
*/
|
||||
logLLMRequest(params: {
|
||||
sessionId: string;
|
||||
iteration: number;
|
||||
provider: string;
|
||||
model: string;
|
||||
tokenCount: number;
|
||||
}): void {
|
||||
this.log({
|
||||
sessionId: params.sessionId,
|
||||
iteration: params.iteration,
|
||||
eventType: 'llm_request',
|
||||
actor: 'agent',
|
||||
target: `${params.provider}/${params.model}`,
|
||||
details: { tokenCount: params.tokenCount },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录 LLM 响应
|
||||
*/
|
||||
logLLMResponse(params: {
|
||||
sessionId: string;
|
||||
iteration: number;
|
||||
provider: string;
|
||||
model: string;
|
||||
tokenUsage: { input: number; output: number; total: number };
|
||||
durationMs: number;
|
||||
}): void {
|
||||
this.log({
|
||||
sessionId: params.sessionId,
|
||||
iteration: params.iteration,
|
||||
eventType: 'llm_response',
|
||||
actor: 'agent',
|
||||
target: `${params.provider}/${params.model}`,
|
||||
details: { tokenUsage: params.tokenUsage },
|
||||
outcome: 'success',
|
||||
durationMs: params.durationMs,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录会话开始
|
||||
*/
|
||||
logSessionStart(sessionId: string): void {
|
||||
this.log({
|
||||
sessionId,
|
||||
eventType: 'session_start',
|
||||
actor: 'user',
|
||||
target: 'session',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录会话结束
|
||||
*/
|
||||
logSessionEnd(params: {
|
||||
sessionId: string;
|
||||
totalIterations: number;
|
||||
totalTokens: number;
|
||||
durationMs: number;
|
||||
terminationReason: string;
|
||||
}): void {
|
||||
this.log({
|
||||
sessionId: params.sessionId,
|
||||
eventType: 'session_end',
|
||||
actor: 'agent',
|
||||
target: 'session',
|
||||
details: {
|
||||
totalIterations: params.totalIterations,
|
||||
totalTokens: params.totalTokens,
|
||||
terminationReason: params.terminationReason,
|
||||
},
|
||||
outcome: 'success',
|
||||
durationMs: params.durationMs,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询审计日志
|
||||
*/
|
||||
query(filters?: {
|
||||
sessionId?: string;
|
||||
eventType?: AuditEventType;
|
||||
limit?: number;
|
||||
}): Array<{
|
||||
id: number;
|
||||
session_id: string;
|
||||
iteration: number | null;
|
||||
event_type: string;
|
||||
actor: string;
|
||||
target: string;
|
||||
details: string | null;
|
||||
outcome: string | null;
|
||||
duration_ms: number | null;
|
||||
created_at: number;
|
||||
}> {
|
||||
const db = this.getDB();
|
||||
let sql = 'SELECT * FROM audit_logs WHERE 1=1';
|
||||
const params: unknown[] = [];
|
||||
|
||||
if (filters?.sessionId) {
|
||||
sql += ' AND session_id = ?';
|
||||
params.push(filters.sessionId);
|
||||
}
|
||||
if (filters?.eventType) {
|
||||
sql += ' AND event_type = ?';
|
||||
params.push(filters.eventType);
|
||||
}
|
||||
|
||||
sql += ' ORDER BY created_at DESC';
|
||||
|
||||
if (filters?.limit) {
|
||||
sql += ' LIMIT ?';
|
||||
params.push(filters.limit);
|
||||
}
|
||||
|
||||
return db.prepare(sql).all(...params) as Array<{
|
||||
id: number;
|
||||
session_id: string;
|
||||
iteration: number | null;
|
||||
event_type: string;
|
||||
actor: string;
|
||||
target: string;
|
||||
details: string | null;
|
||||
outcome: string | null;
|
||||
duration_ms: number | null;
|
||||
created_at: number;
|
||||
}>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Config Service — 配置读写
|
||||
*
|
||||
* 管理 app_config 表的读写操作。
|
||||
* 所有配置值以 JSON 格式存储。
|
||||
*
|
||||
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 数据库配置
|
||||
*/
|
||||
|
||||
import type Database from 'better-sqlite3';
|
||||
import log from 'electron-log';
|
||||
|
||||
export interface ConfigRow {
|
||||
key: string;
|
||||
value: string;
|
||||
category: string;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export class ConfigService {
|
||||
constructor(private getDB: () => Database.Database) {}
|
||||
|
||||
/**
|
||||
* 获取配置值
|
||||
*/
|
||||
get<T = unknown>(key: string): T | null {
|
||||
const db = this.getDB();
|
||||
const row = db.prepare('SELECT value FROM app_config WHERE key = ?').get(key) as ConfigRow | undefined;
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(row.value) as T;
|
||||
} catch {
|
||||
return row.value as T;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置配置值
|
||||
*/
|
||||
set(key: string, value: unknown): void {
|
||||
const db = this.getDB();
|
||||
const jsonValue = typeof value === 'string' ? value : JSON.stringify(value);
|
||||
|
||||
db.prepare(`
|
||||
INSERT OR REPLACE INTO app_config (key, value, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
`).run(key, jsonValue, Date.now());
|
||||
|
||||
log.debug(`Config set: ${key}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有配置
|
||||
*/
|
||||
getAll(): Record<string, unknown> {
|
||||
const db = this.getDB();
|
||||
const rows = db.prepare('SELECT key, value FROM app_config').all() as ConfigRow[];
|
||||
|
||||
const config: Record<string, unknown> = {};
|
||||
for (const row of rows) {
|
||||
try {
|
||||
config[row.key] = JSON.parse(row.value);
|
||||
} catch {
|
||||
config[row.key] = row.value;
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定分类的所有配置
|
||||
*/
|
||||
getByCategory(category: string): Record<string, unknown> {
|
||||
const db = this.getDB();
|
||||
const rows = db.prepare('SELECT key, value FROM app_config WHERE category = ?').all(category) as ConfigRow[];
|
||||
|
||||
const config: Record<string, unknown> = {};
|
||||
for (const row of rows) {
|
||||
try {
|
||||
config[row.key] = JSON.parse(row.value);
|
||||
} catch {
|
||||
config[row.key] = row.value;
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除配置
|
||||
*/
|
||||
delete(key: string): boolean {
|
||||
const db = this.getDB();
|
||||
const result = db.prepare('DELETE FROM app_config WHERE key = ?').run(key);
|
||||
return result.changes > 0;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* MCP Manager Service — MCP Server 生命周期管理
|
||||
*
|
||||
* 负责:
|
||||
* 1. MCP Server 的连接/断开/重连
|
||||
* 2. 工具发现与动态注册到 ToolRegistry
|
||||
* 3. Server 状态管理与健康检查
|
||||
*
|
||||
* 使用 @modelcontextprotocol/sdk 官方库。
|
||||
*
|
||||
* @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第七章
|
||||
* @see standard/开发规范.md — 优先使用第三方成熟库
|
||||
*/
|
||||
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import type { Tool } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type Database from 'better-sqlite3';
|
||||
import log from 'electron-log';
|
||||
import type { ToolRegistry } from '../harness/tools/registry';
|
||||
import type { IMetonaTool, ToolExecutionContext } from '../harness/types/metona-tool';
|
||||
import type { MetonaToolDef } from '../harness/types';
|
||||
import { MetonaToolCategory, MetonaRiskLevel } from '../harness/types';
|
||||
|
||||
// ===== 类型定义 =====
|
||||
|
||||
export type MCPServerStatus = 'connecting' | 'connected' | 'disconnected' | 'error';
|
||||
|
||||
export interface MCPServerConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
transport: 'stdio' | 'sse';
|
||||
command?: string;
|
||||
args?: string[];
|
||||
url?: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface MCPServerState {
|
||||
config: MCPServerConfig;
|
||||
status: MCPServerStatus;
|
||||
client: Client | null;
|
||||
tools: Tool[];
|
||||
error?: string;
|
||||
connectedAt?: number;
|
||||
}
|
||||
|
||||
// ===== MCP Tool Adapter =====
|
||||
|
||||
/**
|
||||
* 将 MCP Tool 适配为 IMetonaTool 接口
|
||||
*/
|
||||
class MCPToolAdapter implements IMetonaTool {
|
||||
readonly definition: MetonaToolDef;
|
||||
|
||||
constructor(
|
||||
private mcpTool: Tool,
|
||||
private client: Client,
|
||||
private serverName: string,
|
||||
) {
|
||||
this.definition = {
|
||||
name: `mcp_${serverName}_${mcpTool.name}`,
|
||||
description: mcpTool.description ?? `MCP tool from ${serverName}`,
|
||||
parameters: this.convertSchema(mcpTool.inputSchema),
|
||||
category: MetonaToolCategory.MCP,
|
||||
riskLevel: MetonaRiskLevel.MEDIUM,
|
||||
requiresPermission: false,
|
||||
timeoutMs: 30_000,
|
||||
};
|
||||
}
|
||||
|
||||
async execute(args: Record<string, unknown>, _context: ToolExecutionContext): Promise<unknown> {
|
||||
const result = await this.client.callTool({
|
||||
name: this.mcpTool.name,
|
||||
arguments: args,
|
||||
});
|
||||
return result.content;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 MCP JSON Schema 转换为 MetonaToolParams
|
||||
*/
|
||||
private convertSchema(schema: Record<string, unknown>): MetonaToolDef['parameters'] {
|
||||
const properties: Record<string, { type: 'string' | 'number' | 'boolean' | 'object' | 'array'; description: string }> = {};
|
||||
const schemaProps = (schema.properties ?? {}) as Record<string, Record<string, unknown>>;
|
||||
|
||||
for (const [key, prop] of Object.entries(schemaProps)) {
|
||||
properties[key] = {
|
||||
type: (prop.type as 'string' | 'number' | 'boolean' | 'object' | 'array') ?? 'string',
|
||||
description: (prop.description as string) ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'object',
|
||||
properties,
|
||||
required: schema.required as string[] | undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ===== MCP Manager =====
|
||||
|
||||
export class MCPManager {
|
||||
private servers = new Map<string, MCPServerState>();
|
||||
|
||||
constructor(
|
||||
private getDB: () => Database.Database,
|
||||
private toolRegistry: ToolRegistry,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 初始化:从数据库加载已启用的 MCP Server 并连接
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
const db = this.getDB();
|
||||
const rows = db.prepare(`
|
||||
SELECT * FROM mcp_servers WHERE enabled = 1
|
||||
`).all() as Array<{
|
||||
id: string; name: string; transport: string;
|
||||
command: string | null; args: string | null; url: string | null;
|
||||
}>;
|
||||
|
||||
for (const row of rows) {
|
||||
const config: MCPServerConfig = {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
transport: row.transport as 'stdio' | 'sse',
|
||||
command: row.command ?? undefined,
|
||||
args: row.args ? JSON.parse(row.args) : undefined,
|
||||
url: row.url ?? undefined,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
// 异步连接,不阻塞启动
|
||||
this.connectServer(config).catch((err) => {
|
||||
log.warn(`MCP server "${config.name}" auto-connect failed: ${err}`);
|
||||
});
|
||||
}
|
||||
|
||||
log.info(`MCP Manager initialized: ${rows.length} server(s) configured`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接 MCP Server
|
||||
*/
|
||||
async connectServer(config: MCPServerConfig): Promise<void> {
|
||||
const { name } = config;
|
||||
|
||||
// 断开已有连接
|
||||
if (this.servers.has(name)) {
|
||||
await this.disconnectServer(name);
|
||||
}
|
||||
|
||||
this.servers.set(name, {
|
||||
config,
|
||||
status: 'connecting',
|
||||
client: null,
|
||||
tools: [],
|
||||
});
|
||||
|
||||
try {
|
||||
let transport;
|
||||
|
||||
if (config.transport === 'stdio' && config.command) {
|
||||
// stdio 模式
|
||||
const args = config.args ?? [];
|
||||
transport = new StdioClientTransport({
|
||||
command: config.command,
|
||||
args,
|
||||
});
|
||||
} else {
|
||||
throw new Error(`Unsupported transport: ${config.transport}. Only 'stdio' is currently supported.`);
|
||||
}
|
||||
|
||||
const client = new Client(
|
||||
{ name: 'metona-ai-desktop', version: '1.0.0' },
|
||||
{ capabilities: { tools: {} } },
|
||||
);
|
||||
|
||||
await client.connect(transport);
|
||||
|
||||
// 发现工具
|
||||
const toolsResult = await client.listTools();
|
||||
const tools = toolsResult.tools ?? [];
|
||||
|
||||
// 注册到 ToolRegistry
|
||||
for (const tool of tools) {
|
||||
const adapter = new MCPToolAdapter(tool, client, name);
|
||||
this.toolRegistry.registerMCP(name, adapter);
|
||||
}
|
||||
|
||||
// 更新状态
|
||||
const state = this.servers.get(name)!;
|
||||
state.status = 'connected';
|
||||
state.client = client;
|
||||
state.tools = tools;
|
||||
state.connectedAt = Date.now();
|
||||
state.error = undefined;
|
||||
|
||||
// 更新数据库
|
||||
const db = this.getDB();
|
||||
db.prepare(`
|
||||
UPDATE mcp_servers SET last_connected = ?, error_message = NULL WHERE name = ?
|
||||
`).run(Date.now(), name);
|
||||
|
||||
log.info(`MCP server "${name}" connected: ${tools.length} tool(s)`);
|
||||
} catch (error) {
|
||||
const state = this.servers.get(name);
|
||||
if (state) {
|
||||
state.status = 'error';
|
||||
state.error = (error as Error).message;
|
||||
}
|
||||
|
||||
// 更新数据库
|
||||
const db = this.getDB();
|
||||
db.prepare(`
|
||||
UPDATE mcp_servers SET error_message = ? WHERE name = ?
|
||||
`).run((error as Error).message, name);
|
||||
|
||||
log.error(`MCP server "${name}" connection failed:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开 MCP Server
|
||||
*/
|
||||
async disconnectServer(name: string): Promise<void> {
|
||||
const state = this.servers.get(name);
|
||||
if (!state) return;
|
||||
|
||||
// 从 ToolRegistry 注销
|
||||
this.toolRegistry.unregisterMCPTools(name);
|
||||
|
||||
// 关闭客户端
|
||||
if (state.client) {
|
||||
try {
|
||||
await state.client.close();
|
||||
} catch {
|
||||
// 忽略关闭错误
|
||||
}
|
||||
}
|
||||
|
||||
state.status = 'disconnected';
|
||||
state.client = null;
|
||||
state.tools = [];
|
||||
|
||||
log.info(`MCP server "${name}" disconnected`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换 Server 启用/禁用
|
||||
*/
|
||||
async toggleServer(name: string, enabled: boolean): Promise<void> {
|
||||
const db = this.getDB();
|
||||
db.prepare(`
|
||||
UPDATE mcp_servers SET enabled = ?, updated_at = ? WHERE name = ?
|
||||
`).run(enabled ? 1 : 0, Date.now(), name);
|
||||
|
||||
if (enabled) {
|
||||
const row = db.prepare('SELECT * FROM mcp_servers WHERE name = ?').get(name) as {
|
||||
id: string; name: string; transport: string;
|
||||
command: string | null; args: string | null; url: string | null;
|
||||
} | undefined;
|
||||
if (row) {
|
||||
await this.connectServer({
|
||||
id: row.id, name: row.name,
|
||||
transport: row.transport as 'stdio' | 'sse',
|
||||
command: row.command ?? undefined,
|
||||
args: row.args ? JSON.parse(row.args) : undefined,
|
||||
url: row.url ?? undefined,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await this.disconnectServer(name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加新的 MCP Server
|
||||
*/
|
||||
async addServer(config: Omit<MCPServerConfig, 'id'>): Promise<void> {
|
||||
const db = this.getDB();
|
||||
const id = `mcp_${nanoid(8)}`;
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO mcp_servers (id, name, transport, command, args, url, enabled)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 1)
|
||||
`).run(
|
||||
id, config.name, config.transport,
|
||||
config.command ?? null,
|
||||
config.args ? JSON.stringify(config.args) : null,
|
||||
config.url ?? null,
|
||||
);
|
||||
|
||||
if (config.enabled !== false) {
|
||||
await this.connectServer({ ...config, id, enabled: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除 MCP Server
|
||||
*/
|
||||
async removeServer(name: string): Promise<void> {
|
||||
await this.disconnectServer(name);
|
||||
const db = this.getDB();
|
||||
db.prepare('DELETE FROM mcp_servers WHERE name = ?').run(name);
|
||||
this.servers.delete(name);
|
||||
log.info(`MCP server "${name}" removed`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有 Server 状态
|
||||
*/
|
||||
getServerStates(): Array<{
|
||||
name: string;
|
||||
status: MCPServerStatus;
|
||||
toolCount: number;
|
||||
error?: string;
|
||||
}> {
|
||||
return Array.from(this.servers.values()).map((s) => ({
|
||||
name: s.config.name,
|
||||
status: s.status,
|
||||
toolCount: s.tools.length,
|
||||
error: s.error,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个 Server 状态
|
||||
*/
|
||||
getServerState(name: string): {
|
||||
name: string;
|
||||
status: MCPServerStatus;
|
||||
toolCount: number;
|
||||
error?: string;
|
||||
} | null {
|
||||
const state = this.servers.get(name);
|
||||
if (!state) return null;
|
||||
return {
|
||||
name: state.config.name,
|
||||
status: state.status,
|
||||
toolCount: state.tools.length,
|
||||
error: state.error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭所有连接
|
||||
*/
|
||||
async shutdown(): Promise<void> {
|
||||
const names = Array.from(this.servers.keys());
|
||||
await Promise.allSettled(names.map((n) => this.disconnectServer(n)));
|
||||
log.info('MCP Manager shut down');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Session Recorder — 会话录制器(TRACE 层)
|
||||
*
|
||||
* 负责将完整的会话执行轨迹写入 session_*.jsonl 文件。
|
||||
* 每行一条 JSON 事件,支持事后回放和分析。
|
||||
*
|
||||
* 日志格式:SSE-like JSON Lines
|
||||
* 事件类型:session_start, context_built, iteration_start, llm_request,
|
||||
* llm_response, tool_call, tool_result, iteration_end, session_end
|
||||
*
|
||||
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 全链路透明可追踪
|
||||
* @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第十一章
|
||||
*/
|
||||
|
||||
import { join } from 'path';
|
||||
import { appendFileSync, existsSync, mkdirSync } from 'fs';
|
||||
import log from 'electron-log';
|
||||
|
||||
// ===== 事件类型 =====
|
||||
|
||||
export type TraceEventType =
|
||||
| 'session_start'
|
||||
| 'context_built'
|
||||
| 'iteration_start'
|
||||
| 'llm_request'
|
||||
| 'llm_response'
|
||||
| 'tool_call'
|
||||
| 'tool_result'
|
||||
| 'iteration_end'
|
||||
| 'session_end';
|
||||
|
||||
export interface TraceEvent {
|
||||
seq: number;
|
||||
ts: string;
|
||||
event: TraceEventType;
|
||||
sessionId: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// ===== 服务类 =====
|
||||
|
||||
export class SessionRecorder {
|
||||
private filePath: string | null = null;
|
||||
private seq = 0;
|
||||
private sessionId: string | null = null;
|
||||
|
||||
constructor(private workspacePath: string) {}
|
||||
|
||||
/**
|
||||
* 开始录制会话
|
||||
*/
|
||||
startRecording(sessionId: string): void {
|
||||
this.sessionId = sessionId;
|
||||
this.seq = 0;
|
||||
|
||||
const logsDir = join(this.workspacePath, 'logs');
|
||||
if (!existsSync(logsDir)) {
|
||||
mkdirSync(logsDir, { recursive: true });
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
this.filePath = join(logsDir, `session_${sessionId}_${timestamp}.jsonl`);
|
||||
|
||||
// 写入 session_start 事件
|
||||
this.writeEvent({
|
||||
event: 'session_start',
|
||||
sessionId,
|
||||
workspace: this.workspacePath,
|
||||
});
|
||||
|
||||
log.info(`Session recording started: ${this.filePath}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止录制
|
||||
*/
|
||||
stopRecording(params: {
|
||||
totalIterations: number;
|
||||
totalTokens: number;
|
||||
durationMs: number;
|
||||
terminationReason: string;
|
||||
}): void {
|
||||
if (!this.sessionId || !this.filePath) return;
|
||||
|
||||
this.writeEvent({
|
||||
event: 'session_end',
|
||||
sessionId: this.sessionId,
|
||||
totalIterations: params.totalIterations,
|
||||
totalTokens: params.totalTokens,
|
||||
durationMs: params.durationMs,
|
||||
terminationReason: params.terminationReason,
|
||||
});
|
||||
|
||||
log.info(`Session recording stopped: ${this.filePath}`);
|
||||
this.filePath = null;
|
||||
this.sessionId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录上下文构建
|
||||
*/
|
||||
recordContextBuilt(params: { tokenCount: number; usageRatio: number }): void {
|
||||
this.writeEvent({
|
||||
event: 'context_built',
|
||||
sessionId: this.sessionId!,
|
||||
tokens: params.tokenCount,
|
||||
ratio: params.usageRatio,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录迭代开始
|
||||
*/
|
||||
recordIterationStart(iteration: number): void {
|
||||
this.writeEvent({
|
||||
event: 'iteration_start',
|
||||
sessionId: this.sessionId!,
|
||||
iteration,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录 LLM 请求
|
||||
*/
|
||||
recordLLMRequest(params: {
|
||||
iteration: number;
|
||||
provider: string;
|
||||
model: string;
|
||||
messageCount: number;
|
||||
}): void {
|
||||
this.writeEvent({
|
||||
event: 'llm_request',
|
||||
sessionId: this.sessionId!,
|
||||
iteration: params.iteration,
|
||||
provider: params.provider,
|
||||
model: params.model,
|
||||
messageCount: params.messageCount,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录 LLM 响应
|
||||
*/
|
||||
recordLLMResponse(params: {
|
||||
iteration: number;
|
||||
content: string;
|
||||
finishReason: string;
|
||||
tokenUsage: { input: number; output: number; total: number };
|
||||
}): void {
|
||||
this.writeEvent({
|
||||
event: 'llm_response',
|
||||
sessionId: this.sessionId!,
|
||||
iteration: params.iteration,
|
||||
contentPreview: params.content.slice(0, 200),
|
||||
finishReason: params.finishReason,
|
||||
tokenUsage: params.tokenUsage,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录工具调用
|
||||
*/
|
||||
recordToolCall(params: {
|
||||
iteration: number;
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
}): void {
|
||||
this.writeEvent({
|
||||
event: 'tool_call',
|
||||
sessionId: this.sessionId!,
|
||||
iteration: params.iteration,
|
||||
tool: params.toolName,
|
||||
args: params.args,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录工具结果
|
||||
*/
|
||||
recordToolResult(params: {
|
||||
iteration: number;
|
||||
toolName: string;
|
||||
success: boolean;
|
||||
durationMs: number;
|
||||
resultPreview?: string;
|
||||
error?: string;
|
||||
}): void {
|
||||
this.writeEvent({
|
||||
event: 'tool_result',
|
||||
sessionId: this.sessionId!,
|
||||
iteration: params.iteration,
|
||||
tool: params.toolName,
|
||||
success: params.success,
|
||||
durationMs: params.durationMs,
|
||||
resultPreview: params.resultPreview?.slice(0, 500),
|
||||
error: params.error,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录迭代结束
|
||||
*/
|
||||
recordIterationEnd(params: {
|
||||
iteration: number;
|
||||
durationMs: number;
|
||||
}): void {
|
||||
this.writeEvent({
|
||||
event: 'iteration_end',
|
||||
sessionId: this.sessionId!,
|
||||
iteration: params.iteration,
|
||||
durationMs: params.durationMs,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取录制文件路径
|
||||
*/
|
||||
getFilePath(): string | null {
|
||||
return this.filePath;
|
||||
}
|
||||
|
||||
// ===== 私有方法 =====
|
||||
|
||||
/**
|
||||
* 写入事件到 JSONL 文件
|
||||
*/
|
||||
private writeEvent(data: Record<string, unknown>): void {
|
||||
if (!this.filePath) return;
|
||||
|
||||
const event: TraceEvent = {
|
||||
seq: this.seq++,
|
||||
ts: new Date().toISOString(),
|
||||
sessionId: this.sessionId!,
|
||||
...data,
|
||||
} as TraceEvent;
|
||||
|
||||
try {
|
||||
appendFileSync(this.filePath, JSON.stringify(event) + '\n', 'utf-8');
|
||||
} catch (error) {
|
||||
log.error('Trace event write failed:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* 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;
|
||||
session_id: string;
|
||||
role: string;
|
||||
content: string;
|
||||
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;
|
||||
role: string;
|
||||
content: string;
|
||||
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(this.toSessionInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新会话
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话消息列表
|
||||
*/
|
||||
getMessages(sessionId: string): MessageInfo[] {
|
||||
const db = this.getDBFn();
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT * FROM messages
|
||||
WHERE session_id = ?
|
||||
ORDER BY created_at ASC
|
||||
`).all(sessionId) as MessageRow[];
|
||||
|
||||
return rows.map(this.toMessageInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存一条消息
|
||||
*/
|
||||
saveMessage(params: {
|
||||
sessionId: string;
|
||||
role: string;
|
||||
content: string;
|
||||
reasoningContent?: string;
|
||||
toolCalls?: unknown[];
|
||||
toolResult?: unknown;
|
||||
attachments?: unknown[];
|
||||
iteration?: number;
|
||||
}): MessageInfo {
|
||||
const db = this.getDBFn();
|
||||
const id = `msg_${nanoid(12)}`;
|
||||
const now = Date.now();
|
||||
|
||||
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);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空会话所有消息
|
||||
*/
|
||||
clearMessages(sessionId: string): boolean {
|
||||
const db = this.getDBFn();
|
||||
const result = db.prepare('DELETE FROM messages 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存会话的 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 {
|
||||
return {
|
||||
id: row.id,
|
||||
role: row.role,
|
||||
content: row.content,
|
||||
reasoningContent: row.reasoning_content ?? undefined,
|
||||
toolCalls: row.tool_calls ? JSON.parse(row.tool_calls) : undefined,
|
||||
toolResult: row.tool_result ? JSON.parse(row.tool_result) : undefined,
|
||||
attachments: row.attachments ? JSON.parse(row.attachments) : undefined,
|
||||
iteration: row.iteration ?? undefined,
|
||||
timestamp: row.created_at,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Tray Manager — 系统托盘管理
|
||||
*
|
||||
* 职责:
|
||||
* 1. 创建系统托盘图标
|
||||
* 2. 托盘右键菜单(新建会话、显示窗口、退出)
|
||||
* 3. 托盘图标状态指示(空闲/思考中/执行中)
|
||||
* 4. 点击托盘图标显示/隐藏窗口
|
||||
*
|
||||
* @see docs/MetonaAI-Desktop UI UX 设计集成方案.html — 窗口管理
|
||||
*/
|
||||
|
||||
import { Tray, Menu, BrowserWindow, app, nativeImage, Notification } from 'electron';
|
||||
import { join } from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import log from 'electron-log';
|
||||
|
||||
export type TrayStatus = 'idle' | 'thinking' | 'executing' | 'error';
|
||||
|
||||
export class TrayManager {
|
||||
private tray: Tray | null = null;
|
||||
private mainWindow: BrowserWindow | null = null;
|
||||
private currentStatus: TrayStatus = 'idle';
|
||||
private static isQuitting = false;
|
||||
|
||||
constructor(private resourcesPath: string) {}
|
||||
|
||||
/**
|
||||
* 初始化系统托盘
|
||||
*/
|
||||
initialize(mainWindow: BrowserWindow): void {
|
||||
this.mainWindow = mainWindow;
|
||||
|
||||
// 创建托盘图标
|
||||
const iconPath = this.getTrayIconPath();
|
||||
if (!iconPath) {
|
||||
log.warn('Tray icon not found, skipping tray initialization');
|
||||
return;
|
||||
}
|
||||
|
||||
this.tray = new Tray(iconPath);
|
||||
this.tray.setToolTip('MetonaAI Desktop');
|
||||
|
||||
// 构建右键菜单
|
||||
this.updateContextMenu();
|
||||
|
||||
// 点击托盘图标:显示/隐藏窗口
|
||||
this.tray.on('click', () => {
|
||||
if (this.mainWindow) {
|
||||
if (this.mainWindow.isVisible()) {
|
||||
this.mainWindow.hide();
|
||||
} else {
|
||||
this.mainWindow.show();
|
||||
this.mainWindow.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 窗口关闭时隐藏到托盘(不退出)
|
||||
mainWindow.on('close', (event) => {
|
||||
if (!TrayManager.isQuitting) {
|
||||
event.preventDefault();
|
||||
mainWindow.hide();
|
||||
}
|
||||
});
|
||||
|
||||
log.info('System tray initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新托盘状态
|
||||
*/
|
||||
setStatus(status: TrayStatus): void {
|
||||
if (!this.tray) return;
|
||||
this.currentStatus = status;
|
||||
|
||||
const statusLabels: Record<TrayStatus, string> = {
|
||||
idle: 'MetonaAI — 空闲',
|
||||
thinking: 'MetonaAI — 思考中...',
|
||||
executing: 'MetonaAI — 执行中...',
|
||||
error: 'MetonaAI — 错误',
|
||||
};
|
||||
|
||||
this.tray.setToolTip(statusLabels[status]);
|
||||
this.updateContextMenu();
|
||||
|
||||
// 更新图标(如果有多状态图标)
|
||||
this.updateTrayIcon(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送系统通知
|
||||
*/
|
||||
sendNotification(title: string, body: string, onClick?: () => void): void {
|
||||
if (!Notification.isSupported()) return;
|
||||
|
||||
const notification = new Notification({
|
||||
title,
|
||||
body,
|
||||
icon: this.getTrayIconPath() ?? undefined,
|
||||
});
|
||||
|
||||
if (onClick) {
|
||||
notification.on('click', onClick);
|
||||
}
|
||||
|
||||
notification.show();
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁托盘
|
||||
*/
|
||||
destroy(): void {
|
||||
if (this.tray) {
|
||||
this.tray.destroy();
|
||||
this.tray = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 私有方法 =====
|
||||
|
||||
/**
|
||||
* 获取托盘图标路径
|
||||
*/
|
||||
private getTrayIconPath(): string | null {
|
||||
// 优先使用 ico(Windows),其次 png(macOS/Linux)
|
||||
const candidates = [
|
||||
join(this.resourcesPath, 'logo.ico'),
|
||||
join(this.resourcesPath, 'logo.png'),
|
||||
join(this.resourcesPath, 'icon.ico'),
|
||||
join(this.resourcesPath, 'icon.png'),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新托盘图标(根据状态)
|
||||
*/
|
||||
private updateTrayIcon(status: TrayStatus): void {
|
||||
if (!this.tray) return;
|
||||
|
||||
// 基础图标
|
||||
const iconPath = this.getTrayIconPath();
|
||||
if (!iconPath) return;
|
||||
|
||||
// 对于 thinking/executing 状态,可以使用 overlay 图标
|
||||
// 当前实现使用基础图标,后续可扩展
|
||||
const image = nativeImage.createFromPath(iconPath);
|
||||
this.tray.setImage(image.resize({ width: 16, height: 16 }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新右键菜单
|
||||
*/
|
||||
private updateContextMenu(): void {
|
||||
if (!this.tray) return;
|
||||
|
||||
const statusIcons: Record<TrayStatus, string> = {
|
||||
idle: '⚪',
|
||||
thinking: '🔵',
|
||||
executing: '🟢',
|
||||
error: '🔴',
|
||||
};
|
||||
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: `MetonaAI Desktop`,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
label: `状态: ${statusIcons[this.currentStatus]} ${this.currentStatus}`,
|
||||
enabled: false,
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: '显示窗口',
|
||||
click: () => {
|
||||
if (this.mainWindow) {
|
||||
this.mainWindow.show();
|
||||
this.mainWindow.focus();
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '新建会话',
|
||||
click: () => {
|
||||
if (this.mainWindow) {
|
||||
this.mainWindow.show();
|
||||
this.mainWindow.focus();
|
||||
this.mainWindow.webContents.send('tray:newSession');
|
||||
}
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: '退出',
|
||||
click: () => {
|
||||
TrayManager.isQuitting = true;
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
this.tray.setContextMenu(contextMenu);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Update Service — 自动更新服务
|
||||
*
|
||||
* @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第十二章
|
||||
*/
|
||||
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import log from 'electron-log';
|
||||
|
||||
interface UpdateInfo {
|
||||
version: string;
|
||||
releaseNotes?: string;
|
||||
}
|
||||
|
||||
interface DownloadProgress {
|
||||
bytesPerSecond: number;
|
||||
percent: number;
|
||||
transferred: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export class UpdateService {
|
||||
private autoUpdater: {
|
||||
checkForUpdates: () => void;
|
||||
quitAndInstall: () => void;
|
||||
on: (event: string, callback: (...args: unknown[]) => void) => void;
|
||||
logger: unknown;
|
||||
autoInstallOnAppQuit: boolean;
|
||||
autoDownload: boolean;
|
||||
} | null = null;
|
||||
|
||||
constructor(private mainWindow: BrowserWindow) {}
|
||||
|
||||
initialize(): void {
|
||||
try {
|
||||
// electron-updater 可能未安装
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { autoUpdater } = require('electron-updater');
|
||||
this.autoUpdater = autoUpdater;
|
||||
if (!this.autoUpdater) return;
|
||||
this.autoUpdater.logger = log;
|
||||
this.autoUpdater.autoInstallOnAppQuit = true;
|
||||
this.autoUpdater.autoDownload = true;
|
||||
|
||||
this.autoUpdater.on('checking-for-update', () => this.sendStatus('checking'));
|
||||
this.autoUpdater.on('update-available', (info: unknown) => {
|
||||
this.sendStatus('available', { version: (info as UpdateInfo).version });
|
||||
});
|
||||
this.autoUpdater.on('download-progress', (progress: unknown) => {
|
||||
this.sendStatus('downloading', { progress });
|
||||
});
|
||||
this.autoUpdater.on('update-downloaded', (info: unknown) => {
|
||||
this.sendStatus('downloaded', { version: (info as UpdateInfo).version });
|
||||
});
|
||||
this.autoUpdater.on('error', (err: unknown) => {
|
||||
this.sendStatus('error', { error: (err as Error).message });
|
||||
});
|
||||
|
||||
setTimeout(() => this.autoUpdater?.checkForUpdates(), 5_000);
|
||||
} catch {
|
||||
log.warn('electron-updater not available, auto-update disabled');
|
||||
}
|
||||
}
|
||||
|
||||
checkNow(): void {
|
||||
this.autoUpdater?.checkForUpdates();
|
||||
}
|
||||
|
||||
installAndRestart(): void {
|
||||
this.autoUpdater?.quitAndInstall();
|
||||
}
|
||||
|
||||
private sendStatus(stage: string, data?: Record<string, unknown>): void {
|
||||
if (!this.mainWindow.isDestroyed()) {
|
||||
this.mainWindow.webContents.send('update:status', { stage, ...data });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Window Manager — 多窗口管理
|
||||
*
|
||||
* 职责:
|
||||
* 1. 创建和管理多个窗口(每个工作空间可独立开窗口)
|
||||
* 2. 窗口状态持久化(位置、大小)
|
||||
* 3. 全局快捷键注册(Cmd+Shift+M 切换到 Metona)
|
||||
* 4. 关闭到托盘行为
|
||||
*
|
||||
* @see docs/MetonaAI-Desktop UI UX 设计集成方案.html — 窗口管理
|
||||
*/
|
||||
|
||||
import { BrowserWindow, globalShortcut, app, shell } from 'electron';
|
||||
import { join } from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import { is } from '@electron-toolkit/utils';
|
||||
import log from 'electron-log';
|
||||
|
||||
export interface WindowState {
|
||||
x?: number;
|
||||
y?: number;
|
||||
width: number;
|
||||
height: number;
|
||||
isMaximized?: boolean;
|
||||
}
|
||||
|
||||
export class WindowManager {
|
||||
private windows = new Map<string, BrowserWindow>();
|
||||
private activeWindowId: string | null = null;
|
||||
|
||||
/**
|
||||
* 创建新窗口
|
||||
*/
|
||||
createWindow(options: {
|
||||
id?: string;
|
||||
workspacePath?: string;
|
||||
title?: string;
|
||||
state?: WindowState;
|
||||
}): BrowserWindow {
|
||||
const id = options.id ?? `window_${Date.now()}`;
|
||||
const state = options.state ?? { width: 1440, height: 900 };
|
||||
|
||||
const win = new BrowserWindow({
|
||||
width: state.width,
|
||||
height: state.height,
|
||||
x: state.x,
|
||||
y: state.y,
|
||||
minWidth: 960,
|
||||
minHeight: 600,
|
||||
icon: this.getIconPath(),
|
||||
show: false,
|
||||
titleBarStyle: 'hiddenInset',
|
||||
title: options.title ?? 'MetonaAI Desktop',
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/preload.mjs'),
|
||||
sandbox: false,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
});
|
||||
|
||||
// 加载页面
|
||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||
win.loadURL(process.env['ELECTRON_RENDERER_URL']);
|
||||
} else {
|
||||
win.loadFile(join(__dirname, '../../dist/index.html'));
|
||||
}
|
||||
|
||||
// 窗口事件
|
||||
win.on('ready-to-show', () => {
|
||||
if (state.isMaximized) {
|
||||
win.maximize();
|
||||
}
|
||||
win.show();
|
||||
});
|
||||
|
||||
win.on('closed', () => {
|
||||
this.windows.delete(id);
|
||||
if (this.activeWindowId === id) {
|
||||
this.activeWindowId = this.windows.size > 0 ? this.windows.keys().next().value ?? null : null;
|
||||
}
|
||||
});
|
||||
|
||||
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||
shell.openExternal(url);
|
||||
return { action: 'deny' };
|
||||
});
|
||||
|
||||
this.windows.set(id, win);
|
||||
this.activeWindowId = id;
|
||||
|
||||
log.info(`Window created: ${id} (${options.title ?? 'MetonaAI Desktop'})`);
|
||||
|
||||
return win;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取窗口
|
||||
*/
|
||||
getWindow(id: string): BrowserWindow | undefined {
|
||||
return this.windows.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取活动窗口
|
||||
*/
|
||||
getActiveWindow(): BrowserWindow | null {
|
||||
if (this.activeWindowId) {
|
||||
return this.windows.get(this.activeWindowId) ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有窗口
|
||||
*/
|
||||
getAllWindows(): BrowserWindow[] {
|
||||
return Array.from(this.windows.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* 聚焦到窗口(从任意应用切换)
|
||||
*/
|
||||
focusWindow(): void {
|
||||
const win = this.getActiveWindow();
|
||||
if (win) {
|
||||
if (win.isMinimized()) {
|
||||
win.restore();
|
||||
}
|
||||
win.show();
|
||||
win.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册全局快捷键
|
||||
*/
|
||||
registerGlobalShortcuts(): void {
|
||||
// Cmd/Ctrl+Shift+M — 从任意应用切换到 Metona
|
||||
const registered = globalShortcut.register('CommandOrControl+Shift+M', () => {
|
||||
this.focusWindow();
|
||||
log.info('Global shortcut: Switch to Metona');
|
||||
});
|
||||
|
||||
if (!registered) {
|
||||
log.warn('Failed to register global shortcut: Cmd+Shift+M');
|
||||
} else {
|
||||
log.info('Global shortcut registered: Cmd+Shift+M');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 注销全局快捷键
|
||||
*/
|
||||
unregisterGlobalShortcuts(): void {
|
||||
globalShortcut.unregisterAll();
|
||||
log.info('Global shortcuts unregistered');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取窗口状态(用于持久化)
|
||||
*/
|
||||
getWindowState(id: string): WindowState | null {
|
||||
const win = this.windows.get(id);
|
||||
if (!win) return null;
|
||||
|
||||
const bounds = win.getBounds();
|
||||
return {
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
isMaximized: win.isMaximized(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭所有窗口
|
||||
*/
|
||||
closeAll(): void {
|
||||
for (const [id, win] of this.windows) {
|
||||
try {
|
||||
win.destroy();
|
||||
} catch {
|
||||
log.warn(`Failed to close window: ${id}`);
|
||||
}
|
||||
}
|
||||
this.windows.clear();
|
||||
this.activeWindowId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取窗口数量
|
||||
*/
|
||||
get count(): number {
|
||||
return this.windows.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取应用图标路径
|
||||
*/
|
||||
private getIconPath(): string | undefined {
|
||||
const candidates = [
|
||||
join(__dirname, '../../assets/logo.ico'),
|
||||
join(__dirname, '../../assets/logo.png'),
|
||||
join(process.resourcesPath || '', 'assets/logo.ico'),
|
||||
join(process.resourcesPath || '', 'assets/logo.png'),
|
||||
];
|
||||
for (const p of candidates) {
|
||||
if (existsSync(p)) return p;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* Workspace Service — 工作空间管理
|
||||
*
|
||||
* 负责:
|
||||
* 1. 工作空间目录的创建和验证
|
||||
* 2. 4 个必需磁盘文件的加载和自动创建
|
||||
* 3. MEMORY.md 格式校验和自动修正
|
||||
*
|
||||
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 工作空间
|
||||
* @see standard/开发规范.md — 使用 fs/path 内置模块(非第三方库)
|
||||
*/
|
||||
|
||||
import { join } from 'path';
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { app } from 'electron';
|
||||
import log from 'electron-log';
|
||||
|
||||
// ===== 类型定义 =====
|
||||
|
||||
export interface WorkspaceFiles {
|
||||
soul: string;
|
||||
agents: string;
|
||||
memory: string;
|
||||
users: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceInfo {
|
||||
path: string;
|
||||
files: WorkspaceFiles;
|
||||
isValid: boolean;
|
||||
missingFiles: string[];
|
||||
}
|
||||
|
||||
// ===== 常量 =====
|
||||
|
||||
const REQUIRED_FILES = ['SOUL.md', 'AGENTS.md', 'MEMORY.md', 'USERS.md'] as const;
|
||||
|
||||
const AUTO_CREATED_DIRS = ['logs', 'traces', '.metona'] as const;
|
||||
|
||||
const MEMORY_TEMPLATE = `# MEMORY.md — AI 持久记忆
|
||||
#
|
||||
# 格式版本: 1.0
|
||||
# 创建时间: ${new Date().toISOString()}
|
||||
# 最后更新: ${new Date().toISOString()}
|
||||
# 工作空间: __WORKSPACE_PATH__
|
||||
#
|
||||
# 此文件由 Metona Agent 自动维护,用户可手动编辑。
|
||||
# 格式规范详见文档,Agent 写入时会自动校验格式。
|
||||
|
||||
## 用户偏好
|
||||
# 格式: - [类别] 内容描述
|
||||
# 示例: - [沟通风格] 用户喜欢简洁的回答
|
||||
|
||||
## 项目上下文
|
||||
# 格式: - [项目名] 关键信息
|
||||
# 示例: - [MyApp] 技术栈: React + TypeScript
|
||||
|
||||
## 重要决策
|
||||
# 格式: - YYYY-MM-DD: 决策内容
|
||||
# 示例: - 2026-06-25: 选择 sql.js 作为数据库方案
|
||||
|
||||
## 待办事项
|
||||
# 格式: - [状态] 任务描述 (状态: pending/done/cancelled)
|
||||
# 示例: - [pending] 实现用户登录功能
|
||||
|
||||
## 已知问题
|
||||
# 格式: - 问题描述 | 影响范围 | 解决方案
|
||||
# 示例: - 首次加载慢 | 启动 | 预加载优化
|
||||
`;
|
||||
|
||||
// ===== 服务类 =====
|
||||
|
||||
export class WorkspaceService {
|
||||
private workspacePath: string;
|
||||
private files: WorkspaceFiles = { soul: '', agents: '', memory: '', users: '' };
|
||||
|
||||
constructor(workspacePath?: string) {
|
||||
this.workspacePath = workspacePath ?? join(app.getPath('userData'), 'MetonaWorkspaces', 'default');
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化工作空间
|
||||
*
|
||||
* 1. 创建目录结构
|
||||
* 2. 验证/创建 4 个必需文件
|
||||
* 3. 加载文件内容
|
||||
*/
|
||||
initialize(): WorkspaceInfo {
|
||||
log.info(`Initializing workspace: ${this.workspacePath}`);
|
||||
|
||||
// 创建工作空间目录
|
||||
this.ensureDirectory(this.workspacePath);
|
||||
|
||||
// 创建自动目录(logs/, traces/, .metona/)
|
||||
for (const dir of AUTO_CREATED_DIRS) {
|
||||
this.ensureDirectory(join(this.workspacePath, dir));
|
||||
}
|
||||
|
||||
// 验证/创建必需文件
|
||||
const missingFiles: string[] = [];
|
||||
for (const fileName of REQUIRED_FILES) {
|
||||
const filePath = join(this.workspacePath, fileName);
|
||||
if (!existsSync(filePath)) {
|
||||
missingFiles.push(fileName);
|
||||
this.createFile(fileName);
|
||||
}
|
||||
}
|
||||
|
||||
// 加载文件内容
|
||||
this.loadFiles();
|
||||
|
||||
// 校验 MEMORY.md 格式
|
||||
this.validateMemoryFormat();
|
||||
|
||||
const isValid = missingFiles.length === 0;
|
||||
if (missingFiles.length > 0) {
|
||||
log.info(`Auto-created missing files: ${missingFiles.join(', ')}`);
|
||||
}
|
||||
|
||||
log.info(`Workspace initialized: ${this.workspacePath} (valid: ${isValid})`);
|
||||
|
||||
return {
|
||||
path: this.workspacePath,
|
||||
files: { ...this.files },
|
||||
isValid,
|
||||
missingFiles,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取工作空间路径
|
||||
*/
|
||||
getPath(): string {
|
||||
return this.workspacePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件内容
|
||||
*/
|
||||
getFiles(): WorkspaceFiles {
|
||||
return { ...this.files };
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新加载文件(用户可能在外部编辑)
|
||||
*/
|
||||
reload(): WorkspaceFiles {
|
||||
this.loadFiles();
|
||||
this.validateMemoryFormat();
|
||||
return { ...this.files };
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 MEMORY.md 的最后更新时间戳
|
||||
*/
|
||||
updateMemoryTimestamp(): void {
|
||||
const memoryPath = join(this.workspacePath, 'MEMORY.md');
|
||||
if (!existsSync(memoryPath)) return;
|
||||
|
||||
let content = readFileSync(memoryPath, 'utf-8');
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// 替换最后更新时间戳
|
||||
content = content.replace(
|
||||
/# 最后更新: .*/,
|
||||
`# 最后更新: ${now}`,
|
||||
);
|
||||
|
||||
writeFileSync(memoryPath, content, 'utf-8');
|
||||
this.files.memory = content;
|
||||
log.info('MEMORY.md timestamp updated');
|
||||
}
|
||||
|
||||
/**
|
||||
* 追加记忆到 MEMORY.md
|
||||
*/
|
||||
appendMemory(section: string, entry: string): void {
|
||||
const memoryPath = join(this.workspacePath, 'MEMORY.md');
|
||||
if (!existsSync(memoryPath)) return;
|
||||
|
||||
let content = readFileSync(memoryPath, 'utf-8');
|
||||
|
||||
// 查找目标 section
|
||||
const sectionRegex = new RegExp(`## ${section}\\b`);
|
||||
const sectionMatch = content.match(sectionRegex);
|
||||
|
||||
if (sectionMatch) {
|
||||
// 在 section 末尾追加
|
||||
const sectionIndex = content.indexOf(sectionMatch[0]) + sectionMatch[0].length;
|
||||
const nextSectionIndex = content.indexOf('\n## ', sectionIndex);
|
||||
const insertPoint = nextSectionIndex === -1 ? content.length : nextSectionIndex;
|
||||
|
||||
const before = content.slice(0, insertPoint).trimEnd();
|
||||
const after = content.slice(insertPoint);
|
||||
content = `${before}\n- ${entry}\n${after}`;
|
||||
} else {
|
||||
// section 不存在,追加到文件末尾
|
||||
content += `\n## ${section}\n- ${entry}\n`;
|
||||
}
|
||||
|
||||
// 更新时间戳
|
||||
content = content.replace(
|
||||
/# 最后更新: .*/,
|
||||
`# 最后更新: ${new Date().toISOString()}`,
|
||||
);
|
||||
|
||||
writeFileSync(memoryPath, content, 'utf-8');
|
||||
this.files.memory = content;
|
||||
log.info(`MEMORY.md: appended to section "${section}"`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 MEMORY.md 格式(6 条规则)
|
||||
*
|
||||
* 规则:
|
||||
* 1. 元数据头 — 必须包含 # 格式版本、# 创建时间、# 最后更新、# 工作空间
|
||||
* 2. 分区结构 — 必须包含 ## 用户偏好、## 项目上下文、## 重要决策
|
||||
* 3. 条目前缀 — 每个条目必须以 - 开头,后跟 [类别/标签]
|
||||
* 4. 日期格式 — 决策条目必须使用 YYYY-MM-DD
|
||||
* 5. 状态标记 — 待办事项必须包含 [pending/done/cancelled]
|
||||
* 6. 时间戳更新 — 每次写入时自动更新 # 最后更新 时间戳
|
||||
*
|
||||
* @see docs/MetonaAI-Desktop 架构与交互设计.html — MEMORY.md 格式校验规则
|
||||
*/
|
||||
validateMemoryFormat(): boolean {
|
||||
const memoryPath = join(this.workspacePath, 'MEMORY.md');
|
||||
if (!existsSync(memoryPath)) return false;
|
||||
|
||||
let content = readFileSync(memoryPath, 'utf-8');
|
||||
let modified = false;
|
||||
|
||||
// 规则 1: 元数据头
|
||||
const requiredMeta = ['# 格式版本', '# 创建时间', '# 最后更新', '# 工作空间'];
|
||||
for (const meta of requiredMeta) {
|
||||
if (!content.includes(meta)) {
|
||||
// 自动补充缺失的元数据
|
||||
const metaLine = meta === '# 工作空间'
|
||||
? `# 工作空间: ${this.workspacePath}`
|
||||
: `${meta}: ${new Date().toISOString()}`;
|
||||
content = content.replace(/^# MEMORY/m, `# MEMORY\n#\n# ${metaLine.replace('# ', '')}`);
|
||||
modified = true;
|
||||
log.info(`MEMORY.md: auto-added missing metadata "${meta}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// 规则 2: 分区结构
|
||||
const requiredSections = ['## 用户偏好', '## 项目上下文', '## 重要决策'];
|
||||
for (const section of requiredSections) {
|
||||
if (!content.includes(section)) {
|
||||
content += `\n${section}\n# 格式: - [类别] 内容描述\n`;
|
||||
modified = true;
|
||||
log.info(`MEMORY.md: auto-added missing section "${section}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// 规则 6: 时间戳更新
|
||||
const now = new Date().toISOString();
|
||||
content = content.replace(/# 最后更新: .*/, `# 最后更新: ${now}`);
|
||||
|
||||
if (modified) {
|
||||
writeFileSync(memoryPath, content, 'utf-8');
|
||||
this.files.memory = content;
|
||||
log.info('MEMORY.md: format validation completed, auto-corrected');
|
||||
}
|
||||
|
||||
return !modified;
|
||||
}
|
||||
|
||||
// ===== 私有方法 =====
|
||||
|
||||
/**
|
||||
* 确保目录存在
|
||||
*/
|
||||
private ensureDirectory(dirPath: string): void {
|
||||
if (!existsSync(dirPath)) {
|
||||
mkdirSync(dirPath, { recursive: true });
|
||||
log.debug(`Created directory: ${dirPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建必需文件
|
||||
*/
|
||||
private createFile(fileName: string): void {
|
||||
const filePath = join(this.workspacePath, fileName);
|
||||
|
||||
switch (fileName) {
|
||||
case 'MEMORY.md': {
|
||||
const content = MEMORY_TEMPLATE.replace('__WORKSPACE_PATH__', this.workspacePath);
|
||||
writeFileSync(filePath, content, 'utf-8');
|
||||
break;
|
||||
}
|
||||
case 'SOUL.md':
|
||||
writeFileSync(filePath, '# SOUL.md — AI 灵魂定义\n\n# 用户可在此定义 Agent 的身份、性格和核心价值观\n', 'utf-8');
|
||||
break;
|
||||
case 'AGENTS.md':
|
||||
writeFileSync(filePath, '# AGENTS.md — AI 行为定义\n\n# 用户可在此定义 Agent 的行为规则和边界\n', 'utf-8');
|
||||
break;
|
||||
case 'USERS.md':
|
||||
writeFileSync(filePath, '# USERS.md — 用户信息画像\n\n# 用户可在此描述自己的背景、技能和偏好\n', 'utf-8');
|
||||
break;
|
||||
}
|
||||
|
||||
log.info(`Auto-created file: ${fileName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载所有文件内容
|
||||
*/
|
||||
private loadFiles(): void {
|
||||
this.files.soul = this.readFile('SOUL.md');
|
||||
this.files.agents = this.readFile('AGENTS.md');
|
||||
this.files.memory = this.readFile('MEMORY.md');
|
||||
this.files.users = this.readFile('USERS.md');
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取单个文件
|
||||
*/
|
||||
private readFile(fileName: string): string {
|
||||
const filePath = join(this.workspacePath, fileName);
|
||||
try {
|
||||
return readFileSync(filePath, 'utf-8');
|
||||
} catch {
|
||||
log.warn(`Failed to read file: ${filePath}`);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user