fix: 修复搜索引擎/DuckDuckGo超时/web_fetch UA/Agent去重误杀,优化搜索fallback指导,版本号更新至5.0.1
This commit is contained in:
+68
-16
@@ -437,8 +437,12 @@ export async function handleWebFetch(params: { url: string; max_chars?: number;
|
||||
|
||||
sendLog('info', `🌐 web_fetch`, `${url} (${maxChars > 0 ? maxChars + ' chars' : '完整内容'})`);
|
||||
const resp = await fetch(url, {
|
||||
headers: { 'User-Agent': 'MetonaOllama/1.0' },
|
||||
signal: AbortSignal.timeout(15000)
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8'
|
||||
},
|
||||
signal: AbortSignal.timeout(30000)
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
@@ -499,18 +503,20 @@ export async function handleWebSearch(params: { query: string; max_results?: num
|
||||
|
||||
sendLog('info', `🔍 web_search`, `"${query}" (${maxResults} 条)`);
|
||||
|
||||
// 1. 尝试 DuckDuckGo HTML 搜索
|
||||
const searchHeaders = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||
'Accept': 'text/html',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8'
|
||||
};
|
||||
|
||||
// 1. 尝试 DuckDuckGo HTML 搜索(8 秒超时,国内环境通常不可用)
|
||||
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)
|
||||
headers: searchHeaders,
|
||||
signal: AbortSignal.timeout(8000)
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
@@ -518,19 +524,15 @@ export async function handleWebSearch(params: { query: string; max_results?: num
|
||||
results = parseDDGResults(html, maxResults);
|
||||
}
|
||||
} catch (e) {
|
||||
sendLog('warn', `🔍 DuckDuckGo 搜索失败`, (e as Error).message);
|
||||
sendLog('warn', `🔍 DuckDuckGo 搜索失败(8s 超时)`, (e as Error).message);
|
||||
}
|
||||
|
||||
// 2. 如果 DuckDuckGo 无结果,尝试备用方案
|
||||
// 2. DuckDuckGo 无结果,尝试 Bing
|
||||
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'
|
||||
},
|
||||
headers: searchHeaders,
|
||||
signal: AbortSignal.timeout(15000)
|
||||
});
|
||||
|
||||
@@ -543,6 +545,24 @@ export async function handleWebSearch(params: { query: string; max_results?: num
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Bing 也失败,尝试 Google(通过 google.com/search)
|
||||
if (results.length === 0) {
|
||||
try {
|
||||
const googleUrl = `https://www.google.com/search?q=${encodeURIComponent(query)}&num=${maxResults}`;
|
||||
const resp = await fetch(googleUrl, {
|
||||
headers: searchHeaders,
|
||||
signal: AbortSignal.timeout(15000)
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
const html = await resp.text();
|
||||
results = parseGoogleResults(html, maxResults);
|
||||
}
|
||||
} catch (e) {
|
||||
sendLog('warn', `🔍 Google 搜索失败`, (e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
return { success: false, error: '未找到搜索结果,请尝试其他关键词' };
|
||||
}
|
||||
@@ -621,6 +641,38 @@ function parseBingResults(html: string, maxResults: number): Array<{ title: stri
|
||||
return results;
|
||||
}
|
||||
|
||||
/** 解析 Google HTML 搜索结果 */
|
||||
function parseGoogleResults(html: string, maxResults: number): Array<{ title: string; url: string; snippet: string }> {
|
||||
const results: Array<{ title: string; url: string; snippet: string }> = [];
|
||||
|
||||
// Google 结果块:<div class="g">...</div>,内含 <a href="..."><h3>title</h3></a> 和摘要
|
||||
const blockRegex = /<div class="g"[^>]*>([\s\S]*?)(?=<div class="g"|<\/div>\s*<\/div>\s*<\/div>)/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]*?<h3[^>]*>([\s\S]*?)<\/h3>/);
|
||||
if (!titleMatch) continue;
|
||||
|
||||
const url = titleMatch[1].trim();
|
||||
const title = decodeHTML(titleMatch[2].replace(/<[^>]+>/g, '').trim());
|
||||
|
||||
// 摘要:尝试多种模式
|
||||
const snippetMatch =
|
||||
block.match(/<div[^>]*data-sncf="[^"]*"[^>]*>([\s\S]*?)<\/div>/) ||
|
||||
block.match(/<span[^>]*class="[^"]*st[^"]*"[^>]*>([\s\S]*?)<\/span>/) ||
|
||||
block.match(/<div[^>]*class="[^"]*VwiC3b[^"]*"[^>]*>([\s\S]*?)<\/div>/);
|
||||
const snippet = snippetMatch ? decodeHTML(snippetMatch[1].replace(/<[^>]+>/g, '').trim()) : '';
|
||||
|
||||
if (title && url.startsWith('http') && !url.includes('google.com')) {
|
||||
results.push({ title, url, snippet });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/** 简单 HTML 实体解码 */
|
||||
function decodeHTML(text: string): string {
|
||||
return text
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<div class="header-left">
|
||||
<span class="logo">🦙</span>
|
||||
<span class="app-title">Metona Ollama</span>
|
||||
<span class="app-version">v5.0.0</span>
|
||||
<span class="app-version">v5.0.1</span>
|
||||
<button class="icon-btn help-btn" id="btnHelp" title="使用帮助">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
|
||||
|
||||
@@ -37,22 +37,23 @@ const MAX_RETRIES = 2; // 工具错误自动重试次数
|
||||
/** v4.1: 工具并行执行 — 依赖检测用 */
|
||||
const TOOLS_WITH_DATA_DEPS = new Set(['web_fetch', 'edit_file', 'append_file', 'move_file', 'delete_file']);
|
||||
|
||||
const AGENT_SYSTEM_PROMPT = `你是一个具备工具调用能力的 AI 助手。你有 25 个工具可以调用,包括文件操作、联网搜索、网页抓取、命令执行、记忆管理等。
|
||||
const AGENT_SYSTEM_PROMPT = `你是一个具备工具调用能力的 AI 助手。你有 34 个工具可以调用,包括文件操作、联网搜索、网页抓取、命令执行、浏览器控制、记忆管理、MCP 工具等。
|
||||
|
||||
## 核心规则
|
||||
|
||||
1. 直接调用工具,不要写"我来帮你搜索"然后结束。有工具就调用,调用完根据结果继续下一步。
|
||||
2. 多步任务必须逐步完成:搜到结果 → 抓取详情 → 给出答案。不要在中间步骤停下。
|
||||
2. 多步任务必须逐步完成:搜到结果 → 给出答案。如果需要详情再抓取,但抓取失败时直接用搜索结果回答,不要卡在抓取上。
|
||||
3. 需要最新信息(版本号、新闻、日期等)时,必须先用工具获取,不要凭记忆猜测。
|
||||
4. 工具出错时分析原因并换方法重试,最多重试 2 次。
|
||||
4. 工具出错时分析原因并换方法重试,最多重试 2 次。同一 URL 连续失败 2 次后不要再尝试,换其他方式。
|
||||
5. 不要重复调用相同参数的同一工具。
|
||||
6. web_search 结果中的标题、URL、snippet 已经包含关键信息,不要忽视它们。
|
||||
|
||||
## 多工具协同
|
||||
|
||||
你可以在一轮回复中调用多个工具(并行调用),也可以在一个工具的结果基础上继续调用下一个工具(链式调用)。这是正常且鼓励的工作方式。
|
||||
|
||||
常见链式调用模式:
|
||||
- web_search → web_fetch(搜索 → 抓取详情)
|
||||
- web_search → web_fetch(搜索 → 抓取详情,但 web_fetch 失败时直接用搜索结果回答)
|
||||
- list_directory → read_file(列出目录 → 读取文件)
|
||||
- search_files → edit_file(搜索 → 修改)
|
||||
- run_command → read_file(执行命令 → 读取输出文件)
|
||||
@@ -63,13 +64,16 @@ const AGENT_SYSTEM_PROMPT = `你是一个具备工具调用能力的 AI 助手
|
||||
|
||||
const TOOL_USAGE_GUIDE = `【工具链规则(强制执行)】
|
||||
|
||||
1. 搜索后必须抓取:web_search 搜到结果后,必须选择最相关的 1-3 个 URL,用 web_fetch 抓取详细内容。只看搜索摘要就回答是不够的。这是强制规则。
|
||||
2. 先搜索再回答:需要最新信息时,先 search → 再 fetch → 再回答。不要凭记忆猜测版本号、日期、价格等实时数据。
|
||||
3. 文件路径上下文:用户说"修改这个文件"等模糊指代时,从最近的工具调用结果中提取实际路径。不要猜测。
|
||||
4. 不要重复调用:已经成功调用过的工具+参数组合不要再调用。
|
||||
5. 记忆工具:可以用 memory_search 搜索过去记忆,用 memory_add 添加重要信息。
|
||||
6. 会话工具:可以用 session_list 列出历史会话,用 session_read 读取会话内容。
|
||||
7. 并行调用:当多个工具调用互相独立时,可以在同一次回复中同时调用它们。`;
|
||||
1. 搜索后优先抓取:web_search 搜到结果后,选择最相关的 URL 用 web_fetch 抓取详情。
|
||||
2. 抓取失败时直接用搜索结果:如果 web_fetch 失败(超时、网络错误等),不要反复重试同一个 URL。直接利用 web_search 返回的摘要和标题回答用户问题。搜索结果的 snippet 已经包含了足够的信息。
|
||||
3. 不要抓取 SPA 页面:GitHub releases、npm 包页面等是 SPA(单页应用),纯 HTTP 抓取拿不到有效内容。直接从搜索结果中提取版本号等信息即可。
|
||||
4. 搜索查询要进化:如果第一次搜索结果不理想,换更精确的关键词再搜,不要重复相同的查询。
|
||||
5. 先搜索再回答:需要最新信息(版本号、新闻、日期等)时,先 search → 再回答。不要凭记忆猜测。
|
||||
6. 文件路径上下文:用户说"修改这个文件"等模糊指代时,从最近的工具调用结果中提取实际路径。不要猜测。
|
||||
7. 不要重复调用:已经成功调用过的工具+参数组合不要再调用。
|
||||
8. 记忆工具:可以用 memory_search 搜索过去记忆,用 memory_add 添加重要信息。
|
||||
9. 会话工具:可以用 session_list 列出历史会话,用 session_read 读取会话内容。
|
||||
10. 并行调用:当多个工具调用互相独立时,可以在同一次回复中同时调用它们。`;
|
||||
|
||||
/** 工具名白名单:用于文本解析兜底时过滤非法工具名 */
|
||||
const VALID_TOOL_NAMES = new Set([
|
||||
@@ -467,7 +471,8 @@ export async function runAgentLoop(
|
||||
const loopStartTime = Date.now();
|
||||
let content = '';
|
||||
let totalEvalCount = 0;
|
||||
let prevLoopToolKeys: string[] = [];
|
||||
/** 跨轮去重:仅跟踪成功的工具调用(失败的允许重试) */
|
||||
let prevLoopSuccessKeys: string[] = [];
|
||||
/** 每轮工具调用计数(用于去重) */
|
||||
let allCallsThisLoop: ToolCall[] = [];
|
||||
|
||||
@@ -645,16 +650,22 @@ export async function runAgentLoop(
|
||||
return;
|
||||
}
|
||||
|
||||
// 跨轮次完整重复检测
|
||||
// 跨轮次重复检测:仅当连续两轮调用完全相同且全部成功时才终止(失败的允许重试)
|
||||
const currentLoopKeys = toolCalls.map(c => getToolCacheKey(c.function.name, c.function.arguments)).sort();
|
||||
const currentKeysStr = JSON.stringify(currentLoopKeys);
|
||||
const prevKeysStr = JSON.stringify([...prevLoopToolKeys].sort());
|
||||
const prevKeysStr = JSON.stringify([...prevLoopSuccessKeys].sort());
|
||||
if (currentKeysStr === prevKeysStr && currentLoopKeys.length > 0) {
|
||||
logWarn('检测到连续两轮工具调用完全相同,终止 ReAct Loop', currentKeysStr.slice(0, 200));
|
||||
callbacks.onDone(content || '(检测到重复工具调用,已自动停止)', allToolRecords, makeStats());
|
||||
return;
|
||||
// 检查是否有上一轮失败的工具——如果有,不终止,允许重试
|
||||
const hasFailedInPrev = allToolRecords
|
||||
.filter(r => prevLoopSuccessKeys.includes(getToolCacheKey(r.name, r.arguments)))
|
||||
.some(r => r.status !== 'success');
|
||||
if (!hasFailedInPrev) {
|
||||
logWarn('检测到连续两轮工具调用完全相同且全部成功,终止 ReAct Loop', currentKeysStr.slice(0, 200));
|
||||
callbacks.onDone(content || '(检测到重复工具调用,已自动停止)', allToolRecords, makeStats());
|
||||
return;
|
||||
}
|
||||
logInfo('检测到重复调用但上一轮有失败,允许重试');
|
||||
}
|
||||
prevLoopToolKeys = currentLoopKeys;
|
||||
|
||||
// 记录 ReAct 轨迹
|
||||
const traceStep = {
|
||||
@@ -811,6 +822,11 @@ export async function runAgentLoop(
|
||||
}
|
||||
}
|
||||
|
||||
// 更新跨轮去重:仅跟踪本轮成功的工具调用
|
||||
prevLoopSuccessKeys = allToolRecords
|
||||
.filter(r => r.status === 'success')
|
||||
.map(r => getToolCacheKey(r.name, r.arguments));
|
||||
|
||||
// 保存本轮轨迹
|
||||
traceStep.observation = allToolRecords.slice(-toolCalls.length).map(r =>
|
||||
`${r.name}: ${r.result?.success ? 'success' : 'error'}`
|
||||
|
||||
Reference in New Issue
Block a user