/** * 网络工具(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, _context: ToolExecutionContext): Promise { 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; 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> | 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, _context: ToolExecutionContext): Promise { 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(/]*>[\s\S]*?<\/script>/gi, '') .replace(/]*>[\s\S]*?<\/style>/gi, '') .replace(/<[^>]+>/g, ' ') .replace(/ /g, ' ') .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/\s+/g, ' ') .trim(); } }