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:
thzxx
2026-06-27 21:33:27 +08:00
commit 1d185db6b3
109 changed files with 39155 additions and 0 deletions
+222
View File
@@ -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;
}>;
}
}
+100
View File
@@ -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;
}
}
+290
View File
@@ -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');
}
}
+359
View File
@@ -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);
}
}
}
+326
View File
@@ -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,
};
}
}
+211
View File
@@ -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 {
// 优先使用 icoWindows),其次 pngmacOS/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);
}
}
+78
View File
@@ -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 });
}
}
}
+214
View File
@@ -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;
}
}
+329
View File
@@ -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 '';
}
}
}