fix: 修复搜索引擎/DuckDuckGo超时/web_fetch UA/Agent去重误杀,优化搜索fallback指导,版本号更新至5.0.1

This commit is contained in:
thzxx
2026-04-18 11:44:48 +08:00
parent 615e5fdf4f
commit 9d68a07b47
9 changed files with 149 additions and 45 deletions
+2 -2
View File
@@ -6,7 +6,7 @@
全离线运行 · 数据本地存储 · 零外部依赖
[![版本](https://img.shields.io/badge/version-5.0.0-brightgreen?style=flat-square)](https://gitee.com/thzxx/metona-ollama-desktop/releases)
[![版本](https://img.shields.io/badge/version-5.0.1-brightgreen?style=flat-square)](https://gitee.com/thzxx/metona-ollama-desktop/releases)
[![平台](https://img.shields.io/badge/platform-Windows%20x64-blue?style=flat-square)](https://gitee.com/thzxx/metona-ollama-desktop/releases)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.7-3178c6?style=flat-square&logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
[![Electron](https://img.shields.io/badge/Electron-33-47848f?style=flat-square&logo=electron&logoColor=white)](https://www.electronjs.org/)
@@ -294,7 +294,7 @@ src/
| 文件 | 类型 |
|------|------|
| `Metona Ollama Setup 5.0.0.exe` | NSIS 安装包(可选目录、创建快捷方式) |
| `Metona Ollama Setup 5.0.1.exe` | NSIS 安装包(可选目录、创建快捷方式) |
> ⚠️ 未签名版本,首次运行 Windows 可能弹出安全警告,点击「仍要运行」即可。
+10 -1
View File
@@ -14,7 +14,7 @@
# 1. 克隆并安装依赖
git clone https://gitee.com/thzxx/metona-ollama-desktop.git
cd metona-ollama-desktop
git checkout metona-ollama-desktop-v5.0.0
git checkout metona-ollama-desktop-v5.0.1
npm config set registry https://registry.npmmirror.com
npm install
@@ -176,6 +176,15 @@ electron-builder 会自动处理所有依赖下载(Electron、NSIS、winCodeSi
## 构建日志
### v5.0.12026-04-18 — metona-ollama-desktop 仓库
- 🔧 修复 DuckDuckGo 搜索超时过长(15s → 8s),添加 Google 作为第三搜索引擎兜底
- 🔧 修复 web_fetch User-AgentMetonaOllama/1.0 → Chrome UA),增加超时至 30 秒
- 🔧 修复 Agent Loop 跨轮去重误杀失败工具的重试(仅对成功的调用去重,失败的允许重试)
- 🔧 优化 TOOL_USAGE_GUIDE:新增搜索 fallback 规则(fetch 失败时直接用搜索结果回答)、禁止抓取 SPA 页面、搜索查询进化指导
- 🔧 更新 AGENT_SYSTEM_PROMPT:工具数 25 → 34,新增 fetch 失败 fallback 规则
- 📝 版本号更新至 5.0.1package.json / package-lock.json / index.html / README.md / CHANGELOG.md / BUILD.md / DEVELOPMENT.md
### v5.0.02026-04-18 — metona-ollama-desktop 仓库
- ✨ 新增 8 个浏览器控制工具(browser_open/screenshot/evaluate/extract/click/type/scroll/close
+28 -1
View File
@@ -3,7 +3,7 @@
## 分支结构
**桌面版主线**`master`):
- `metona-ollama-desktop-v1.1``metona-ollama-desktop-v2``metona-ollama-desktop-v3.0.0``metona-ollama-desktop-v3.1.0``metona-ollama-desktop-v3.1.1``metona-ollama-desktop-v3.2.0``metona-ollama-desktop-v3.2.1``metona-ollama-desktop-v3.2.2``metona-ollama-desktop-v3.2.3``metona-ollama-desktop-v3.2.4``metona-ollama-desktop-v3.2.5``metona-ollama-desktop-v3.2.6``metona-ollama-desktop-v3.3.0``metona-ollama-desktop-v3.3.1``metona-ollama-desktop-v3.3.2``metona-ollama-desktop-v3.3.3``metona-ollama-desktop-v3.3.4``metona-ollama-desktop-v4.0.0``metona-ollama-desktop-v5.0.0`(当前发布分支)
- `metona-ollama-desktop-v3.2.6``metona-ollama-desktop-v3.3.4``metona-ollama-desktop-v4.0.0``metona-ollama-desktop-v5.0.0``metona-ollama-desktop-v5.0.1`(当前发布分支)
**Web 版历史**已独立为 [metona-ollama-web](https://gitee.com/thzxx/metona-ollama-web) 仓库(v1.1.0 ~ v4.0.3)。
@@ -11,6 +11,33 @@
## 桌面版(Desktop
### Desktop v5.0.1 — 搜索引擎优化 & Agent 去重修复
- 🔧 **修复 DuckDuckGo 搜索超时过长**
- 超时从 15 秒降至 8 秒(国内环境 DuckDuckGo 通常不可达,减少等待)
- 添加 Google 作为第三搜索引擎兜底(DuckDuckGo → Bing → Google
- 新增 `parseGoogleResults()` 解析函数
- 搜索 User-Agent 统一更新为 Chrome 131
- 🔧 **修复 web_fetch 无法抓取部分网站**
- User-Agent 从 `MetonaOllama/1.0` 改为 Chrome 131 UA(部分网站屏蔽非浏览器 UA)
- 超时从 15 秒增加至 30 秒(大页面加载更慢)
- 新增 `Accept``Accept-Language` 请求头
- 🔧 **修复 Agent Loop 跨轮去重误杀失败工具的重试**
- 原逻辑:连续两轮工具调用(工具名+参数)完全相同即终止
- 问题:上一轮 `web_fetch` 超时失败,下一轮同样调用是合理重试,却被当作重复终止
- 修复:仅对成功的工具调用做去重,失败的允许重试
- 新增 `prevLoopSuccessKeys` 替代 `prevLoopToolKeys`
- 🔧 **优化 TOOL_USAGE_GUIDE(新增 3 条规则)**
- 新增规则 2web_fetch 失败时直接用 web_search 摘要回答,不要反复重试同一 URL
- 新增规则 3:禁止抓取 SPA 页面(GitHub releases、npm 等),直接从搜索结果提取信息
- 新增规则 4:搜索查询要进化,不要重复相同的查询
- 🔧 **更新 AGENT_SYSTEM_PROMPT**
- 工具数量描述从 25 → 34
- 核心规则新增:web_search 结果中的 snippet 已包含关键信息,不要忽视
- 核心规则细化:同一 URL 连续失败 2 次后不要再尝试
- 多工具协同示例新增:web_fetch 失败时直接用搜索结果回答
- 📝 **版本号更新至 5.0.1**7 个文件同步更新)
### Desktop v5.0.0 — 浏览器控制 & MCP & 技能系统 & 全面重构
-**MCP (Model Context Protocol) 完整实现**
+1 -1
View File
@@ -1,6 +1,6 @@
# Metona Ollama Desktop — 开发规范
> 版本: 5.0.0 | 更新: 2026-04-18 | 维护: 项目团队
> 版本: 5.0.1 | 更新: 2026-04-18 | 维护: 项目团队
---
+4 -4
View File
@@ -1,12 +1,12 @@
{
"name": "metona-ollama-desktop",
"version": "5.0.0",
"version": "5.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "metona-ollama-desktop",
"version": "5.0.0",
"version": "5.0.1",
"license": "MIT",
"dependencies": {
"sql.js": "^1.11.0"
@@ -4398,7 +4398,7 @@
}
},
"node_modules/make-fetch-happen/node_modules/http-proxy-agent": {
"version": "5.0.0",
"version": "5.0.1",
"resolved": "https://registry.npmmirror.com/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz",
"integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==",
"dev": true,
@@ -5791,7 +5791,7 @@
}
},
"node_modules/tar/node_modules/minipass": {
"version": "5.0.0",
"version": "5.0.1",
"resolved": "https://registry.npmmirror.com/minipass/-/minipass-5.0.0.tgz",
"integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==",
"dev": true,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "metona-ollama-desktop",
"version": "5.0.0",
"version": "5.0.1",
"description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端",
"main": "dist/main/main.js",
"author": "thzxx",
+68 -16
View File
@@ -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
+1 -1
View File
@@ -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"/>
+34 -18
View File
@@ -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'}`