From 5de4cfbabbd98dbc1ac68aa396b578137caf70b0 Mon Sep 17 00:00:00 2001 From: thzxx Date: Thu, 16 Apr 2026 19:29:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20web=5Fsearch=20?= =?UTF-8?q?=E8=81=94=E7=BD=91=E6=90=9C=E7=B4=A2=E5=B7=A5=E5=85=B7=20(21?= =?UTF-8?q?=E4=B8=AA=E5=B7=A5=E5=85=B7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 web_search 工具:AI 可主动联网搜索获取实时信息 - 搜索引擎:DuckDuckGo 优先,Bing 作为备用 - 返回结构化结果:标题、URL、摘要 - 自动启用,无需用户确认(只读操作) - 更新 README 工具列表和计数 --- README.md | 7 +- src/main/ipc.ts | 3 + src/main/tool-handlers.ts | 150 +++++++++++++++++++++++++ src/renderer/services/tool-registry.ts | 23 +++- 4 files changed, 177 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 724b48c..55569e2 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ AI 可以在对话中主动调用本地工具来完成任务,所有操作在 | 📦 `move_file` | 移动/重命名文件或目录 | 需确认 | | 📋 `copy_file` | 复制文件或目录 | 需确认 | | 🌐 `web_fetch` | 抓取网页内容(HTTP/HTTPS,自动提取文本) | 自动 | +| 🔍 `web_search` | 联网搜索(DuckDuckGo/Bing,返回标题+链接+摘要) | 自动 | | ➕ `append_file` | 追加内容到文件末尾 | 需确认 | | ✂️ `edit_file` | 查找替换文件中的文本(比重写整个文件高效) | 需确认 | | ℹ️ `get_file_info` | 获取文件/目录详细信息(大小、日期、权限) | 自动 | @@ -185,7 +186,7 @@ v3.3 四大子系统协调运作: │ ┌─────────────────────────────┴──────────────────────────────┐ │ │ │ 主进程 (Main) │ │ │ │ main.ts menu.ts tray.ts ipc.ts preload.ts │ │ -│ │ tool-handlers.ts (20 个工具) tool-security.ts workspace.ts│ │ +│ │ tool-handlers.ts (21 个工具) tool-security.ts workspace.ts│ │ │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ │ │ workspace.ts: spawn 进程管理 · 流式输出 · 安全检查 │ │ │ │ │ └──────────────────────────────────────────────────────┘ │ │ @@ -269,7 +270,7 @@ metona-ollama-desktop/ │ │ ├── tray.ts # 系统托盘 │ │ ├── ipc.ts # IPC(Tool Calling + Workspace 流式通信) │ │ ├── utils.ts # 工具函数 -│ │ ├── tool-handlers.ts # 🔧 20 个工具实现 +│ │ ├── tool-handlers.ts # 🔧 21 个工具实现 │ │ ├── tool-security.ts # 🔧 路径/命令安全检查 │ │ └── workspace.ts # 🖥️ spawn 进程管理、流式输出 │ └── renderer/ # 渲染进程 @@ -300,7 +301,7 @@ metona-ollama-desktop/ │ │ ├── memory-manager.ts # 🧠 记忆管理核心 │ │ ├── vector-memory.ts # 🧠 记忆向量索引(IVF 索引) │ │ ├── agent-engine.ts # 🔧 Agent Loop 引擎 -│ │ ├── tool-registry.ts # 🔧 工具注册调度(20 个工具定义) +│ │ ├── tool-registry.ts # 🔧 工具注册调度(21 个工具定义) │ │ ├── vector-store.ts # 向量存储 + IVF 索引 │ │ ├── document-processor.ts # 文档分块 │ │ ├── log-service.ts # 结构化执行日志 diff --git a/src/main/ipc.ts b/src/main/ipc.ts index d7184e4..1167e24 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -24,6 +24,7 @@ import { handleMoveFile, handleCopyFile, handleWebFetch, + handleWebSearch, handleAppendFile, handleEditFile, handleGetFileInfo, @@ -50,6 +51,7 @@ function summarizeResult(toolName: string, result: Record): str case 'move_file': return `${result.source} → ${result.destination}`; case 'copy_file': return `${result.source} → ${result.destination}`; case 'web_fetch': return `${result.status} | ${result.length} chars`; + case 'web_search': return `"${result.query}" → ${result.total} 条结果`; case 'append_file': return `${result.path} (${result.newSize}B)`; case 'edit_file': return `${result.path} (${result.replaceCount} 处替换)`; case 'get_file_info': return `${result.name} (${result.type}, ${result.size}B)`; @@ -151,6 +153,7 @@ export function setupIPC(): void { case 'move_file': result = await handleMoveFile(args as { source: string; destination: string }); break; case 'copy_file': result = await handleCopyFile(args as { source: string; destination: string; recursive?: boolean }); break; case 'web_fetch': result = await handleWebFetch(args as { url: string; max_chars?: number; extract_mode?: string }); break; + case 'web_search': result = await handleWebSearch(args as { query: string; max_results?: number }); break; case 'append_file': result = await handleAppendFile(args as { path: string; content: string; newline?: boolean }); break; case 'edit_file': result = await handleEditFile(args as { path: string; old_text: string; new_text: string; all?: boolean }); break; case 'get_file_info': result = await handleGetFileInfo(args as { path: string }); break; diff --git a/src/main/tool-handlers.ts b/src/main/tool-handlers.ts index 240cf3f..0ea9d72 100644 --- a/src/main/tool-handlers.ts +++ b/src/main/tool-handlers.ts @@ -484,6 +484,156 @@ export async function handleWebFetch(params: { url: string; max_chars?: number; } } +/** + * Web Search — 联网搜索 + * 使用 DuckDuckGo HTML 搜索并提取结果 + */ +export async function handleWebSearch(params: { query: string; max_results?: number }): Promise { + try { + const query = params.query; + if (!query || query.trim().length === 0) { + return { success: false, error: '搜索关键词不能为空' }; + } + + const maxResults = Math.min(params.max_results || 5, 10); + + sendLog('info', `🔍 web_search`, `"${query}" (${maxResults} 条)`); + + // 1. 尝试 DuckDuckGo HTML 搜索 + let results: Array<{ title: string; url: string; snippet: string }> = []; + + try { + const ddgUrl = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`; + const resp = await fetch(ddgUrl, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept': 'text/html', + 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8' + }, + signal: AbortSignal.timeout(15000) + }); + + if (resp.ok) { + const html = await resp.text(); + results = parseDDGResults(html, maxResults); + } + } catch (e) { + sendLog('warn', `🔍 DuckDuckGo 搜索失败`, (e as Error).message); + } + + // 2. 如果 DuckDuckGo 无结果,尝试备用方案 + if (results.length === 0) { + try { + const bingUrl = `https://www.bing.com/search?q=${encodeURIComponent(query)}&count=${maxResults}`; + const resp = await fetch(bingUrl, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept': 'text/html', + 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8' + }, + signal: AbortSignal.timeout(15000) + }); + + if (resp.ok) { + const html = await resp.text(); + results = parseBingResults(html, maxResults); + } + } catch (e) { + sendLog('warn', `🔍 Bing 搜索失败`, (e as Error).message); + } + } + + if (results.length === 0) { + return { success: false, error: '未找到搜索结果,请尝试其他关键词' }; + } + + // 构建格式化的搜索结果 + const formatted = results.map((r, i) => + `[${i + 1}] ${r.title}\n URL: ${r.url}\n ${r.snippet}` + ).join('\n\n'); + + sendLog('success', `🔍 web_search 完成`, `"${query}" → ${results.length} 条结果`); + + return { + success: true, + query, + results, + total: results.length, + formatted + }; + } catch (err) { + return { success: false, error: (err as Error).message }; + } +} + +/** 解析 DuckDuckGo HTML 搜索结果 */ +function parseDDGResults(html: string, maxResults: number): Array<{ title: string; url: string; snippet: string }> { + const results: Array<{ title: string; url: string; snippet: string }> = []; + + // 匹配结果块:title ... snippet + const resultRegex = /]*class="result__a"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>[\s\S]*?]*class="result__snippet"[^>]*>([\s\S]*?)<\/a>/gi; + + let match; + while ((match = resultRegex.exec(html)) !== null && results.length < maxResults) { + let url = match[1].trim(); + // DuckDuckGo 使用重定向链接,提取真实 URL + const uddgMatch = url.match(/[?&]uddg=([^&]+)/); + if (uddgMatch) { + url = decodeURIComponent(uddgMatch[1]); + } + + const title = decodeHTML(match[2].replace(/<[^>]+>/g, '').trim()); + const snippet = decodeHTML(match[3].replace(/<[^>]+>/g, '').trim()); + + if (title && url.startsWith('http')) { + results.push({ title, url, snippet }); + } + } + + return results; +} + +/** 解析 Bing HTML 搜索结果 */ +function parseBingResults(html: string, maxResults: number): Array<{ title: string; url: string; snippet: string }> { + const results: Array<{ title: string; url: string; snippet: string }> = []; + + // 匹配
  • 块 + const blockRegex = /
  • ]*>([\s\S]*?)<\/li>/gi; + let blockMatch; + + while ((blockMatch = blockRegex.exec(html)) !== null && results.length < maxResults) { + const block = blockMatch[1]; + + const titleMatch = block.match(/]*href="(https?:\/\/[^"]*)"[^>]*>([\s\S]*?)<\/a>/); + const snippetMatch = block.match(/]*>([\s\S]*?)<\/p>/) || block.match(/
    ]*>([\s\S]*?)<\/div>/); + + if (titleMatch) { + const url = titleMatch[1].trim(); + const title = decodeHTML(titleMatch[2].replace(/<[^>]+>/g, '').trim()); + const snippet = snippetMatch ? decodeHTML(snippetMatch[1].replace(/<[^>]+>/g, '').trim()) : ''; + + if (title && url.startsWith('http')) { + results.push({ title, url, snippet }); + } + } + } + + return results; +} + +/** 简单 HTML 实体解码 */ +function decodeHTML(text: string): string { + return text + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/ /g, ' ') + .replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16))) + .replace(/&#(\d+);/g, (_, dec) => String.fromCharCode(parseInt(dec, 10))); +} + export async function handleAppendFile(params: { path: string; content: string; newline?: boolean }): Promise { try { const filePath = resolvePath(params.path); diff --git a/src/renderer/services/tool-registry.ts b/src/renderer/services/tool-registry.ts index b481439..37bfc00 100644 --- a/src/renderer/services/tool-registry.ts +++ b/src/renderer/services/tool-registry.ts @@ -335,6 +335,21 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [ } } } + }, + { + type: 'function', + function: { + name: 'web_search', + description: 'Search the web and return relevant results. Use this when you need to find current information, news, facts, or answer questions that require up-to-date knowledge beyond your training data.', + parameters: { + type: 'object', + required: ['query'], + properties: { + query: { type: 'string', description: 'The search query. Be specific and concise for best results.' }, + max_results: { type: 'integer', description: 'Maximum number of results to return. Default: 5, max: 10.' } + } + } + } } ]; @@ -348,7 +363,7 @@ let enabledTools: Set = new Set([ 'read_file', 'list_directory', 'search_files', 'write_file', 'create_directory', 'delete_file', 'run_command', - 'move_file', 'copy_file', 'web_fetch', 'append_file', 'edit_file', + 'move_file', 'copy_file', 'web_fetch', 'web_search', 'append_file', 'edit_file', 'get_file_info', 'tree', 'download_file', 'diff_files', 'replace_in_files', 'read_multiple_files', 'git', 'compress' ]); @@ -436,7 +451,8 @@ export function getToolIcon(name: string): string { replace_in_files: '🔄', read_multiple_files: '📚', git: '🔖', - compress: '🗜️' + compress: '🗜️', + web_search: '🔍' }; return icons[name] || '🔧'; } @@ -462,7 +478,8 @@ export function formatToolName(name: string): string { replace_in_files: '批量替换', read_multiple_files: '批量读取', git: 'Git 操作', - compress: '压缩/解压' + compress: '压缩/解压', + web_search: '联网搜索' }; return names[name] || name; }