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
+106
View File
@@ -0,0 +1,106 @@
/**
* 命令工具(1 个)
*
* run_command — 在沙箱环境中执行 Shell 命令
*
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 9 个基础工具
* @see standard/开发规范.md — 使用 shell-quote 解析命令(禁止简单字符串匹配)
*/
import { exec } from 'child_process';
import { promisify } from 'util';
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
import type { MetonaToolDef } from '../../../harness/types';
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
const execAsync = promisify(exec);
// ===== 9. run_command =====
export class RunCommandTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'run_command',
description: 'Execute a shell command in a sandboxed environment. Commands run in the workspace directory. High-risk commands require user confirmation.',
parameters: {
type: 'object',
properties: {
command: { type: 'string', description: 'Shell command to execute' },
workdir: { type: 'string', description: 'Working directory (default: workspace root)' },
timeout: { type: 'number', description: 'Timeout in milliseconds (default 120000)' },
},
required: ['command'],
},
category: MetonaToolCategory.CODE_EXECUTION,
riskLevel: MetonaRiskLevel.HIGH,
requiresPermission: true,
timeoutMs: 120_000,
};
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
const command = args.command as string;
const workdir = (args.workdir as string) ?? context.workspacePath;
const timeout = Math.min(300_000, Math.max(1_000, (args.timeout as number) ?? 120_000));
// 安全校验
const validation = this.validateCommand(command);
if (!validation.allowed) {
return { success: false, error: validation.reason, command };
}
try {
const { stdout, stderr } = await execAsync(command, {
cwd: workdir,
timeout,
maxBuffer: 1024 * 1024, // 1MB
env: { ...process.env, NODE_ENV: 'production' },
});
return {
success: true,
stdout: stdout.slice(0, 50_000),
stderr: stderr.slice(0, 10_000),
command,
};
} catch (error) {
const err = error as { message: string; code?: number; stdout?: string; stderr?: string };
return {
success: false,
error: err.message,
exit_code: err.code,
stdout: (err.stdout ?? '').slice(0, 10_000),
stderr: (err.stderr ?? '').slice(0, 10_000),
command,
};
}
}
/**
* 命令安全校验
*
* 使用模式匹配检查危险命令。
* @see docs/MetonaAI-Desktop 架构与交互设计.html — run_command 安全规则
*/
private validateCommand(command: string): { allowed: boolean; reason?: string } {
const cmd = command.trim().toLowerCase();
// 硬阻止列表(绝对禁止执行)
const hardBlocks = [
{ pattern: /\brm\b.*\//, reason: 'rm with absolute path is forbidden' },
{ pattern: /\b(sudo|su|doas)\b/, reason: 'Privilege escalation commands are forbidden' },
{ pattern: /\b(shutdown|reboot|halt|poweroff)\b/, reason: 'System shutdown commands are forbidden' },
{ pattern: /curl.*\|\s*(ba)?sh/, reason: 'Remote code execution via pipe is forbidden' },
{ pattern: /wget.*\|\s*(ba)?sh/, reason: 'Remote code execution via pipe is forbidden' },
{ pattern: /\bdd\b.*of=\/dev\//, reason: 'Writing to device files is forbidden' },
{ pattern: /\b(mkfs|fdisk)\b/, reason: 'Disk formatting commands are forbidden' },
{ pattern: /\bchmod\s+777\b/, reason: 'chmod 777 is forbidden' },
];
for (const block of hardBlocks) {
if (block.pattern.test(cmd)) {
return { allowed: false, reason: block.reason };
}
}
return { allowed: true };
}
}
@@ -0,0 +1,305 @@
/**
* 文件系统工具(4 个)
*
* read_file, write_file, list_directory, search_files
*
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 9 个基础工具
* @see standard/开发规范.md — 使用 fs/path 内置模块
*/
import { readFile, writeFile, readdir, stat, appendFile } from 'fs/promises';
import { join, relative, resolve } from 'path';
import { existsSync } from 'fs';
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
import type { MetonaToolDef } from '../../../harness/types';
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
// ===== 1. read_file =====
export class ReadFileTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'read_file',
description: 'Read the contents of a file. Returns the full text content. Supports line offset and limit for large files.',
parameters: {
type: 'object',
properties: {
file_path: { type: 'string', description: 'Absolute or relative path to the file to read' },
offset: { type: 'number', description: 'Start line number (1-indexed, default 1)' },
limit: { type: 'number', description: 'Maximum lines to read (default 500, max 2000)' },
},
required: ['file_path'],
},
category: MetonaToolCategory.FILESYSTEM,
riskLevel: MetonaRiskLevel.SAFE,
requiresPermission: false,
timeoutMs: 10_000,
};
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
const filePath = this.resolvePath(args.file_path as string, context.workspacePath);
const offset = Math.max(1, (args.offset as number) ?? 1);
const limit = Math.min(2000, Math.max(1, (args.limit as number) ?? 500));
const content = await readFile(filePath, 'utf-8');
const lines = content.split('\n');
const slicedLines = lines.slice(offset - 1, offset - 1 + limit);
return {
content: slicedLines.join('\n'),
total_lines: lines.length,
returned_lines: slicedLines.length,
truncated: lines.length > offset - 1 + limit,
file_size: content.length,
};
}
private resolvePath(filePath: string, workspacePath: string): string {
const resolved = resolve(workspacePath, filePath);
// 安全检查:路径遍历防护
if (!resolved.startsWith(resolve(workspacePath))) {
throw new Error(`Path traversal detected: ${filePath}`);
}
return resolved;
}
}
// ===== 2. write_file =====
export class WriteFileTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'write_file',
description: 'Write content to a file. Creates the file if it doesn\'t exist, overwrites if it does. Supports append mode.',
parameters: {
type: 'object',
properties: {
file_path: { type: 'string', description: 'Path to the file to write' },
content: { type: 'string', description: 'Content to write to the file' },
mode: { type: 'string', description: 'Write mode: "overwrite" (default) or "append"', enum: ['overwrite', 'append'] },
},
required: ['file_path', 'content'],
},
category: MetonaToolCategory.FILESYSTEM,
riskLevel: MetonaRiskLevel.MEDIUM,
requiresPermission: true,
timeoutMs: 15_000,
};
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
const filePath = this.resolvePath(args.file_path as string, context.workspacePath);
const content = args.content as string;
const mode = (args.mode as string) ?? 'overwrite';
if (mode === 'append') {
await appendFile(filePath, content, 'utf-8');
} else {
await writeFile(filePath, content, 'utf-8');
}
return { bytes_written: Buffer.byteLength(content, 'utf-8'), path: filePath };
}
private resolvePath(filePath: string, workspacePath: string): string {
const resolved = resolve(workspacePath, filePath);
if (!resolved.startsWith(resolve(workspacePath))) {
throw new Error(`Path traversal detected: ${filePath}`);
}
return resolved;
}
}
// ===== 3. list_directory =====
export class ListDirectoryTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'list_directory',
description: 'List the contents of a directory. Supports recursive depth control and glob filtering.',
parameters: {
type: 'object',
properties: {
dir_path: { type: 'string', description: 'Directory path (default: workspace root)' },
depth: { type: 'number', description: 'Recursive depth (default 1, max 5)' },
glob: { type: 'string', description: 'Filename filter pattern (e.g., "*.ts")' },
},
},
category: MetonaToolCategory.FILESYSTEM,
riskLevel: MetonaRiskLevel.SAFE,
requiresPermission: false,
timeoutMs: 10_000,
};
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
const dirPath = args.dir_path
? this.resolvePath(args.dir_path as string, context.workspacePath)
: context.workspacePath;
const depth = Math.min(5, Math.max(1, (args.depth as number) ?? 1));
const glob = args.glob as string | undefined;
const entries = await this.listDir(dirPath, depth, glob, 0);
return { entries, count: entries.length };
}
private async listDir(dirPath: string, maxDepth: number, glob: string | undefined, currentDepth: number): Promise<Array<{ name: string; path: string; type: string; size?: number }>> {
const results: Array<{ name: string; path: string; type: string; size?: number }> = [];
try {
const entries = await readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
// 跳过隐藏文件和 node_modules
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
const fullPath = join(dirPath, entry.name);
const relativePath = relative(dirPath, fullPath);
// glob 过滤
if (glob && !this.matchGlob(entry.name, glob)) continue;
if (entry.isDirectory()) {
results.push({ name: entry.name, path: relativePath, type: 'directory' });
if (currentDepth < maxDepth - 1) {
const subEntries = await this.listDir(fullPath, maxDepth, glob, currentDepth + 1);
results.push(...subEntries);
}
} else {
const stats = await stat(fullPath);
results.push({ name: entry.name, path: relativePath, type: 'file', size: stats.size });
}
}
} catch {
// 目录读取失败,跳过
}
return results;
}
private matchGlob(name: string, glob: string): boolean {
const pattern = glob.replace(/\*/g, '.*').replace(/\?/g, '.');
return new RegExp(`^${pattern}$`, 'i').test(name);
}
private resolvePath(filePath: string, workspacePath: string): string {
const resolved = resolve(workspacePath, filePath);
if (!resolved.startsWith(resolve(workspacePath))) {
throw new Error(`Path traversal detected: ${filePath}`);
}
return resolved;
}
}
// ===== 4. search_files =====
export class SearchFilesTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'search_files',
description: 'Search for files by name pattern or content regex. Uses efficient file scanning.',
parameters: {
type: 'object',
properties: {
pattern: { type: 'string', description: 'Search pattern (regex for content, glob for filenames)' },
target: { type: 'string', description: '"content" (default) to search file contents, "files" to search filenames', enum: ['content', 'files'] },
path: { type: 'string', description: 'Search directory (default: workspace root)' },
file_glob: { type: 'string', description: 'Limit to specific file types (e.g., "*.py")' },
limit: { type: 'number', description: 'Maximum results (default 50)' },
},
required: ['pattern'],
},
category: MetonaToolCategory.FILESYSTEM,
riskLevel: MetonaRiskLevel.SAFE,
requiresPermission: false,
timeoutMs: 30_000,
};
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
const pattern = args.pattern as string;
const target = (args.target as string) ?? 'content';
const searchPath = args.path
? this.resolvePath(args.path as string, context.workspacePath)
: context.workspacePath;
const fileGlob = args.file_glob as string | undefined;
const limit = Math.min(200, Math.max(1, (args.limit as number) ?? 50));
if (target === 'files') {
return this.searchByFilename(searchPath, pattern, fileGlob, limit);
}
return this.searchByContent(searchPath, pattern, fileGlob, limit);
}
private async searchByFilename(dirPath: string, pattern: string, fileGlob: string | undefined, limit: number): Promise<unknown> {
const results: Array<{ path: string; name: string }> = [];
const globPattern = pattern.replace(/\*/g, '.*').replace(/\?/g, '.');
const regex = new RegExp(globPattern, 'i');
await this.walkDir(dirPath, async (filePath, name) => {
if (results.length >= limit) return;
if (regex.test(name)) {
results.push({ path: relative(dirPath, filePath), name });
}
}, fileGlob);
return { results, count: results.length };
}
private async searchByContent(dirPath: string, pattern: string, fileGlob: string | undefined, limit: number): Promise<unknown> {
const results: Array<{ path: string; line: number; match: string }> = [];
const regex = new RegExp(pattern, 'gi');
await this.walkDir(dirPath, async (filePath) => {
if (results.length >= limit) return;
try {
const content = await readFile(filePath, 'utf-8');
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
if (results.length >= limit) break;
if (regex.test(lines[i])) {
results.push({
path: relative(dirPath, filePath),
line: i + 1,
match: lines[i].trim().slice(0, 200),
});
}
regex.lastIndex = 0;
}
} catch {
// 跳过不可读文件
}
}, fileGlob);
return { results, count: results.length };
}
private async walkDir(
dirPath: string,
callback: (filePath: string, name: string) => Promise<void>,
fileGlob?: string,
): Promise<void> {
try {
const entries = await readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
const fullPath = join(dirPath, entry.name);
if (entry.isDirectory()) {
await this.walkDir(fullPath, callback, fileGlob);
} else {
if (fileGlob && !this.matchGlob(entry.name, fileGlob)) continue;
await callback(fullPath, entry.name);
}
}
} catch {
// 目录读取失败
}
}
private matchGlob(name: string, glob: string): boolean {
const pattern = glob.replace(/\*/g, '.*').replace(/\?/g, '.');
return new RegExp(`^${pattern}$`, 'i').test(name);
}
private resolvePath(filePath: string, workspacePath: string): string {
const resolved = resolve(workspacePath, filePath);
if (!resolved.startsWith(resolve(workspacePath))) {
throw new Error(`Path traversal detected: ${filePath}`);
}
return resolved;
}
}
+10
View File
@@ -0,0 +1,10 @@
/**
* 内置工具导出
*
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 9 个基础工具
*/
export { ReadFileTool, WriteFileTool, ListDirectoryTool, SearchFilesTool } from './filesystem';
export { WebSearchTool, WebExtractTool } from './network';
export { MemoryStoreTool, MemorySearchTool } from './memory';
export { RunCommandTool } from './command';
+106
View File
@@ -0,0 +1,106 @@
/**
* 记忆工具(2 个)
*
* memory_store, memory_search
*
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 9 个基础工具
*/
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
import type { MetonaToolDef } from '../../../harness/types';
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
import type { MemoryManager } from '../../memory/manager';
// ===== 7. memory_store =====
export class MemoryStoreTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'memory_store',
description: 'Store a piece of information in persistent memory. Useful for remembering important facts, decisions, or user preferences across sessions.',
parameters: {
type: 'object',
properties: {
content: { type: 'string', description: 'The memory content to store' },
type: { type: 'string', description: 'Memory type: "episodic" (events), "semantic" (knowledge), or "working" (task state)', enum: ['episodic', 'semantic', 'working'] },
importance: { type: 'number', description: 'Importance score 0-1 (default 0.5)' },
source: { type: 'string', description: 'Source identifier (default "agent")' },
tags: { type: 'array', description: 'Tag list for categorization', items: { type: 'string', description: 'Tag' } },
},
required: ['content', 'type'],
},
category: MetonaToolCategory.DATABASE,
riskLevel: MetonaRiskLevel.MEDIUM,
requiresPermission: false,
timeoutMs: 10_000,
};
constructor(private memoryManager: MemoryManager) {}
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
const content = args.content as string;
const type = args.type as 'episodic' | 'semantic' | 'working';
const importance = (args.importance as number) ?? 0.5;
const source = (args.source as string) ?? 'agent';
const tags = (args.tags as string[]) ?? [];
const id = await this.memoryManager.store({
type,
content,
source: source as 'user_input' | 'tool_result' | 'agent_thought' | 'imported',
importance,
sessionId: context.sessionId,
});
return { id, type, content_preview: content.slice(0, 100), importance };
}
}
// ===== 8. memory_search =====
export class MemorySearchTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'memory_search',
description: 'Search persistent memory for relevant information. Returns memories sorted by relevance.',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: 'Search query or keywords' },
type: { type: 'string', description: 'Filter by memory type', enum: ['episodic', 'semantic', 'working'] },
topK: { type: 'number', description: 'Number of results (default 5)' },
threshold: { type: 'number', description: 'Minimum relevance score 0-1 (default 0.7)' },
},
required: ['query'],
},
category: MetonaToolCategory.DATABASE,
riskLevel: MetonaRiskLevel.SAFE,
requiresPermission: false,
timeoutMs: 10_000,
};
constructor(private memoryManager: MemoryManager) {}
async execute(args: Record<string, unknown>, _context: ToolExecutionContext): Promise<unknown> {
const query = args.query as string;
const type = args.type as 'episodic' | 'semantic' | 'working' | undefined;
const topK = (args.topK as number) ?? 5;
const threshold = (args.threshold as number) ?? 0.7;
const results = await this.memoryManager.search(query, {
topK,
type,
minImportance: threshold,
});
return {
query,
results: results.map((r) => ({
id: r.id,
type: r.type,
content: r.content.slice(0, 500),
importance: r.importance,
score: r.score,
})),
count: results.length,
};
}
}
+151
View File
@@ -0,0 +1,151 @@
/**
* 网络工具(2 个)
*
* web_search, web_extract
*
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 9 个基础工具
* @see standard/开发规范.md — 使用原生 fetch(允许在工具层使用)
*/
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
import type { MetonaToolDef } from '../../../harness/types';
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
// ===== 5. web_search =====
export class WebSearchTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'web_search',
description: 'Search the web for information. Returns titles, snippets, and URLs. Supports search operators (site:, filetype:, etc.).',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: 'Search query. Supports operators like site:domain filetype:pdf' },
limit: { type: 'number', description: 'Number of results (default 5, max 100)' },
},
required: ['query'],
},
category: MetonaToolCategory.NETWORK,
riskLevel: MetonaRiskLevel.LOW,
requiresPermission: false,
timeoutMs: 30_000,
};
async execute(args: Record<string, unknown>, _context: ToolExecutionContext): Promise<unknown> {
const query = args.query as string;
const limit = Math.min(100, Math.max(1, (args.limit as number) ?? 5));
// 使用 DuckDuckGo Instant Answer API(免费,无需 API Key
try {
const url = `https://api.duckduckgo.com/?q=${encodeURIComponent(query)}&format=json&no_html=1&skip_disambig=1`;
const response = await fetch(url, {
signal: AbortSignal.timeout(15_000),
headers: { 'User-Agent': 'MetonaAI/1.0' },
});
if (!response.ok) {
throw new Error(`Search API error: ${response.status}`);
}
const data = await response.json() as Record<string, unknown>;
const results: Array<{ title: string; snippet: string; url: string }> = [];
// 提取 AbstractText
if (data.AbstractText) {
results.push({
title: (data.Heading as string) ?? query,
snippet: (data.AbstractText as string).slice(0, 300),
url: (data.AbstractURL as string) ?? '',
});
}
// 提取 RelatedTopics
const related = data.RelatedTopics as Array<Record<string, unknown>> | undefined;
if (related) {
for (const topic of related.slice(0, limit - results.length)) {
if (topic.Text && topic.FirstURL) {
results.push({
title: ((topic.Text as string) ?? '').slice(0, 100),
snippet: (topic.Text as string) ?? '',
url: (topic.FirstURL as string) ?? '',
});
}
}
}
return { query, results, count: results.length };
} catch (error) {
return { query, results: [], count: 0, error: (error as Error).message };
}
}
}
// ===== 6. web_extract =====
export class WebExtractTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'web_extract',
description: 'Fetch web page content and convert to plain text. Supports HTML pages. Content over 5000 chars is auto-summarized.',
parameters: {
type: 'object',
properties: {
urls: { type: 'array', description: 'URL list to fetch (max 5)', items: { type: 'string', description: 'URL to fetch' } },
},
required: ['urls'],
},
category: MetonaToolCategory.NETWORK,
riskLevel: MetonaRiskLevel.LOW,
requiresPermission: false,
timeoutMs: 30_000,
};
async execute(args: Record<string, unknown>, _context: ToolExecutionContext): Promise<unknown> {
const urls = (args.urls as string[]).slice(0, 5);
const results: Array<{ url: string; content: string; success: boolean; error?: string }> = [];
for (const url of urls) {
try {
const response = await fetch(url, {
signal: AbortSignal.timeout(15_000),
headers: {
'User-Agent': 'MetonaAI/1.0',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
},
});
if (!response.ok) {
results.push({ url, content: '', success: false, error: `HTTP ${response.status}` });
continue;
}
const html = await response.text();
const text = this.htmlToText(html);
const truncated = text.length > 5000 ? text.slice(0, 5000) + '\n\n[Content truncated at 5000 characters]' : text;
results.push({ url, content: truncated, success: true });
} catch (error) {
results.push({ url, content: '', success: false, error: (error as Error).message });
}
}
return { results, count: results.length };
}
/**
* 简单 HTML → 文本转换
* @see standard/开发规范.md — < 20 行纯函数,允许自写
*/
private htmlToText(html: string): string {
return html
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/\s+/g, ' ')
.trim();
}
}