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
+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();
}
}