feat: 添加 web_search 联网搜索工具 (21个工具)
- 新增 web_search 工具:AI 可主动联网搜索获取实时信息 - 搜索引擎:DuckDuckGo 优先,Bing 作为备用 - 返回结构化结果:标题、URL、摘要 - 自动启用,无需用户确认(只读操作) - 更新 README 工具列表和计数
This commit is contained in:
@@ -37,6 +37,7 @@ AI 可以在对话中主动调用本地工具来完成任务,所有操作在
|
|||||||
| 📦 `move_file` | 移动/重命名文件或目录 | 需确认 |
|
| 📦 `move_file` | 移动/重命名文件或目录 | 需确认 |
|
||||||
| 📋 `copy_file` | 复制文件或目录 | 需确认 |
|
| 📋 `copy_file` | 复制文件或目录 | 需确认 |
|
||||||
| 🌐 `web_fetch` | 抓取网页内容(HTTP/HTTPS,自动提取文本) | 自动 |
|
| 🌐 `web_fetch` | 抓取网页内容(HTTP/HTTPS,自动提取文本) | 自动 |
|
||||||
|
| 🔍 `web_search` | 联网搜索(DuckDuckGo/Bing,返回标题+链接+摘要) | 自动 |
|
||||||
| ➕ `append_file` | 追加内容到文件末尾 | 需确认 |
|
| ➕ `append_file` | 追加内容到文件末尾 | 需确认 |
|
||||||
| ✂️ `edit_file` | 查找替换文件中的文本(比重写整个文件高效) | 需确认 |
|
| ✂️ `edit_file` | 查找替换文件中的文本(比重写整个文件高效) | 需确认 |
|
||||||
| ℹ️ `get_file_info` | 获取文件/目录详细信息(大小、日期、权限) | 自动 |
|
| ℹ️ `get_file_info` | 获取文件/目录详细信息(大小、日期、权限) | 自动 |
|
||||||
@@ -185,7 +186,7 @@ v3.3 四大子系统协调运作:
|
|||||||
│ ┌─────────────────────────────┴──────────────────────────────┐ │
|
│ ┌─────────────────────────────┴──────────────────────────────┐ │
|
||||||
│ │ 主进程 (Main) │ │
|
│ │ 主进程 (Main) │ │
|
||||||
│ │ main.ts menu.ts tray.ts ipc.ts preload.ts │ │
|
│ │ 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 进程管理 · 流式输出 · 安全检查 │ │ │
|
│ │ │ workspace.ts: spawn 进程管理 · 流式输出 · 安全检查 │ │ │
|
||||||
│ │ └──────────────────────────────────────────────────────┘ │ │
|
│ │ └──────────────────────────────────────────────────────┘ │ │
|
||||||
@@ -269,7 +270,7 @@ metona-ollama-desktop/
|
|||||||
│ │ ├── tray.ts # 系统托盘
|
│ │ ├── tray.ts # 系统托盘
|
||||||
│ │ ├── ipc.ts # IPC(Tool Calling + Workspace 流式通信)
|
│ │ ├── ipc.ts # IPC(Tool Calling + Workspace 流式通信)
|
||||||
│ │ ├── utils.ts # 工具函数
|
│ │ ├── utils.ts # 工具函数
|
||||||
│ │ ├── tool-handlers.ts # 🔧 20 个工具实现
|
│ │ ├── tool-handlers.ts # 🔧 21 个工具实现
|
||||||
│ │ ├── tool-security.ts # 🔧 路径/命令安全检查
|
│ │ ├── tool-security.ts # 🔧 路径/命令安全检查
|
||||||
│ │ └── workspace.ts # 🖥️ spawn 进程管理、流式输出
|
│ │ └── workspace.ts # 🖥️ spawn 进程管理、流式输出
|
||||||
│ └── renderer/ # 渲染进程
|
│ └── renderer/ # 渲染进程
|
||||||
@@ -300,7 +301,7 @@ metona-ollama-desktop/
|
|||||||
│ │ ├── memory-manager.ts # 🧠 记忆管理核心
|
│ │ ├── memory-manager.ts # 🧠 记忆管理核心
|
||||||
│ │ ├── vector-memory.ts # 🧠 记忆向量索引(IVF 索引)
|
│ │ ├── vector-memory.ts # 🧠 记忆向量索引(IVF 索引)
|
||||||
│ │ ├── agent-engine.ts # 🔧 Agent Loop 引擎
|
│ │ ├── agent-engine.ts # 🔧 Agent Loop 引擎
|
||||||
│ │ ├── tool-registry.ts # 🔧 工具注册调度(20 个工具定义)
|
│ │ ├── tool-registry.ts # 🔧 工具注册调度(21 个工具定义)
|
||||||
│ │ ├── vector-store.ts # 向量存储 + IVF 索引
|
│ │ ├── vector-store.ts # 向量存储 + IVF 索引
|
||||||
│ │ ├── document-processor.ts # 文档分块
|
│ │ ├── document-processor.ts # 文档分块
|
||||||
│ │ ├── log-service.ts # 结构化执行日志
|
│ │ ├── log-service.ts # 结构化执行日志
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
handleMoveFile,
|
handleMoveFile,
|
||||||
handleCopyFile,
|
handleCopyFile,
|
||||||
handleWebFetch,
|
handleWebFetch,
|
||||||
|
handleWebSearch,
|
||||||
handleAppendFile,
|
handleAppendFile,
|
||||||
handleEditFile,
|
handleEditFile,
|
||||||
handleGetFileInfo,
|
handleGetFileInfo,
|
||||||
@@ -50,6 +51,7 @@ function summarizeResult(toolName: string, result: Record<string, unknown>): str
|
|||||||
case 'move_file': return `${result.source} → ${result.destination}`;
|
case 'move_file': return `${result.source} → ${result.destination}`;
|
||||||
case 'copy_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_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 'append_file': return `${result.path} (${result.newSize}B)`;
|
||||||
case 'edit_file': return `${result.path} (${result.replaceCount} 处替换)`;
|
case 'edit_file': return `${result.path} (${result.replaceCount} 处替换)`;
|
||||||
case 'get_file_info': return `${result.name} (${result.type}, ${result.size}B)`;
|
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 '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 '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_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 '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 '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;
|
case 'get_file_info': result = await handleGetFileInfo(args as { path: string }); break;
|
||||||
|
|||||||
@@ -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<ToolResult> {
|
||||||
|
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 }> = [];
|
||||||
|
|
||||||
|
// 匹配结果块:<a class="result__a" href="...">title</a> ... <a class="result__snippet">snippet</a>
|
||||||
|
const resultRegex = /<a[^>]*class="result__a"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>[\s\S]*?<a[^>]*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 }> = [];
|
||||||
|
|
||||||
|
// 匹配 <li class="b_algo"> 块
|
||||||
|
const blockRegex = /<li class="b_algo"[^>]*>([\s\S]*?)<\/li>/gi;
|
||||||
|
let blockMatch;
|
||||||
|
|
||||||
|
while ((blockMatch = blockRegex.exec(html)) !== null && results.length < maxResults) {
|
||||||
|
const block = blockMatch[1];
|
||||||
|
|
||||||
|
const titleMatch = block.match(/<a[^>]*href="(https?:\/\/[^"]*)"[^>]*>([\s\S]*?)<\/a>/);
|
||||||
|
const snippetMatch = block.match(/<p[^>]*>([\s\S]*?)<\/p>/) || block.match(/<div class="b_caption"[^>]*>([\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<ToolResult> {
|
export async function handleAppendFile(params: { path: string; content: string; newline?: boolean }): Promise<ToolResult> {
|
||||||
try {
|
try {
|
||||||
const filePath = resolvePath(params.path);
|
const filePath = resolvePath(params.path);
|
||||||
|
|||||||
@@ -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<string> = new Set([
|
|||||||
'read_file', 'list_directory', 'search_files',
|
'read_file', 'list_directory', 'search_files',
|
||||||
'write_file', 'create_directory', 'delete_file',
|
'write_file', 'create_directory', 'delete_file',
|
||||||
'run_command',
|
'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',
|
'get_file_info', 'tree', 'download_file', 'diff_files', 'replace_in_files',
|
||||||
'read_multiple_files', 'git', 'compress'
|
'read_multiple_files', 'git', 'compress'
|
||||||
]);
|
]);
|
||||||
@@ -436,7 +451,8 @@ export function getToolIcon(name: string): string {
|
|||||||
replace_in_files: '🔄',
|
replace_in_files: '🔄',
|
||||||
read_multiple_files: '📚',
|
read_multiple_files: '📚',
|
||||||
git: '🔖',
|
git: '🔖',
|
||||||
compress: '🗜️'
|
compress: '🗜️',
|
||||||
|
web_search: '🔍'
|
||||||
};
|
};
|
||||||
return icons[name] || '🔧';
|
return icons[name] || '🔧';
|
||||||
}
|
}
|
||||||
@@ -462,7 +478,8 @@ export function formatToolName(name: string): string {
|
|||||||
replace_in_files: '批量替换',
|
replace_in_files: '批量替换',
|
||||||
read_multiple_files: '批量读取',
|
read_multiple_files: '批量读取',
|
||||||
git: 'Git 操作',
|
git: 'Git 操作',
|
||||||
compress: '压缩/解压'
|
compress: '压缩/解压',
|
||||||
|
web_search: '联网搜索'
|
||||||
};
|
};
|
||||||
return names[name] || name;
|
return names[name] || name;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user