From e2cb87a0608393d4151a9c59a7aed1bd65bbf361 Mon Sep 17 00:00:00 2001 From: thzxx Date: Sun, 19 Apr 2026 17:08:46 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=90=9C=E7=B4=A2=E5=BC=95=E6=93=8EDuck?= =?UTF-8?q?DuckGo=E2=86=92=E7=99=BE=E5=BA=A6,=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E5=85=A8=E9=83=A8=E8=B6=85=E6=97=B6=E9=99=90=E5=88=B6,=20?= =?UTF-8?q?=E9=BB=98=E8=AE=A485=E8=BD=AE=E5=8F=AF=E9=85=8D=E7=BD=AE,=20?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E6=97=A5=E5=BF=97=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 搜索引擎从 DuckDuckGo 换成百度(Baidu),解决国内不可达问题 - 移除全部超时限制:Agent Loop、流式响应、工具执行、web_fetch、download_file - Agent 最大轮数从 15 改为默认 85 轮,设置面板可配置 - 日志面板新增导出按钮,支持导出为 TXT 文件 - TOOL_USAGE_GUIDE 新增禁止猜 URL 和中文搜索结果利用规则 - 版本号更新至 5.1.1 --- docs/BUILD.md | 9 +++ docs/CHANGELOG.md | 28 ++++++++++ src/main/tool-handlers.ts | 68 ++++++++++++----------- src/renderer/components/settings-modal.ts | 67 +++++++++++++++++++++- src/renderer/index.html | 7 +++ src/renderer/main.ts | 5 ++ src/renderer/services/agent-engine.ts | 59 ++++++-------------- src/renderer/services/log-service.ts | 5 ++ 8 files changed, 173 insertions(+), 75 deletions(-) diff --git a/docs/BUILD.md b/docs/BUILD.md index aa14f8f..8ad0308 100644 --- a/docs/BUILD.md +++ b/docs/BUILD.md @@ -176,6 +176,15 @@ electron-builder 会自动处理所有依赖下载(Electron、NSIS、winCodeSi ## 构建日志 +### v5.1.1(2026-04-19) — metona-ollama-desktop 仓库 + +- 🔧 搜索引擎:DuckDuckGo → 百度(Baidu),解决国内不可达问题 +- 🔧 移除全部超时限制:Agent Loop、流式响应、工具执行、web_fetch、download_file +- 🔧 Agent 最大轮数默认 85 轮,设置面板可配置 +- ✨ 执行日志导出功能(日志面板新增导出按钮) +- 🔧 TOOL_USAGE_GUIDE 新增禁止猜 URL 和中文搜索结果利用规则 +- 📝 版本号更新至 5.1.1 + ### v5.1.0(2026-04-18) — metona-ollama-desktop 仓库 - ✨ 新增 4 个工具:memory_replace、memory_remove、skill_list、skill_view(总数 38 个) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 38c2d8c..a2a8a30 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -11,6 +11,34 @@ ## 桌面版(Desktop) +### Desktop v5.1.1 — 搜索引擎替换 & 超时移除 & 日志导出 + +- 🔧 **搜索引擎:DuckDuckGo → 百度** + - 移除 DuckDuckGo 搜索引擎(国内不可达,每次浪费 8 秒超时) + - 首选百度搜索(Baidu),Bing 和 Google 作为备用 + - 新增 `parseBaiduResults()` 解析函数 + - 移除所有搜索引擎的超时限制 +- 🔧 **移除全部超时限制** + - 移除 Agent Loop 全局 10 分钟总超时(`MAX_LOOP_TIME`) + - 移除流式响应 2 分钟超时(`STREAM_TIMEOUT`) + - 移除工具执行 30 秒超时(`TOOL_EXEC_TIMEOUT`) + - 移除 web_fetch 30 秒超时 + - 移除 download_file 60 秒超时 + - 现在所有操作无超时限制,宁可等待也不中断 +- 🔧 **Agent 最大轮数改为 85 轮(默认)** + - 从硬编码 15 轮改为从 state 读取,默认 85 轮 + - 设置面板新增「Agent 最大轮数」配置项 + - 启动时从数据库恢复设置值 +- ✨ **执行日志导出** + - 日志面板新增「📥 导出」按钮 + - 导出为带时间戳的 TXT 文件 + - 支持原生保存对话框(桌面端)和浏览器下载(回退) +- 🔧 **改进 TOOL_USAGE_GUIDE** + - 新增规则 13:严禁猜测或编造 URL 路径,所有 URL 必须从搜索结果获取 + - 新增规则 14:不要忽视来自知乎、CSDN 等中文来源的搜索结果 + - 修复工具去重缓存 key 逻辑,失败工具不再被误杀 +- 📝 **版本号更新至 5.1.1**(7 个文件同步更新) + ### Desktop v5.1.0 — Hermes 借鉴 & 记忆/Skill 系统增强 - ✨ **记忆 replace/remove 操作**(借鉴 Hermes Agent) diff --git a/src/main/tool-handlers.ts b/src/main/tool-handlers.ts index e6103c4..9e86110 100644 --- a/src/main/tool-handlers.ts +++ b/src/main/tool-handlers.ts @@ -441,8 +441,7 @@ export async function handleWebFetch(params: { url: string; max_chars?: number; '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) { @@ -490,7 +489,7 @@ export async function handleWebFetch(params: { url: string; max_chars?: number; /** * Web Search — 联网搜索 - * 使用 DuckDuckGo HTML 搜索并提取结果 + * 使用百度搜索(国内可用)+ Bing 作为备用 */ export async function handleWebSearch(params: { query: string; max_results?: number }): Promise { try { @@ -509,31 +508,29 @@ export async function handleWebSearch(params: { query: string; max_results?: num 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8' }; - // 1. 尝试 DuckDuckGo HTML 搜索(8 秒超时,国内环境通常不可用) + // 1. 百度搜索(国内首选) 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, { + const baiduUrl = `https://www.baidu.com/s?wd=${encodeURIComponent(query)}&rn=${maxResults}`; + const resp = await fetch(baiduUrl, { headers: searchHeaders, - signal: AbortSignal.timeout(8000) }); if (resp.ok) { const html = await resp.text(); - results = parseDDGResults(html, maxResults); + results = parseBaiduResults(html, maxResults); } } catch (e) { - sendLog('warn', `🔍 DuckDuckGo 搜索失败(8s 超时)`, (e as Error).message); + sendLog('warn', `🔍 百度搜索失败`, (e as Error).message); } - // 2. DuckDuckGo 无结果,尝试 Bing + // 2. 百度无结果,尝试 Bing if (results.length === 0) { try { const bingUrl = `https://www.bing.com/search?q=${encodeURIComponent(query)}&count=${maxResults}`; const resp = await fetch(bingUrl, { headers: searchHeaders, - signal: AbortSignal.timeout(15000) }); if (resp.ok) { @@ -545,13 +542,12 @@ export async function handleWebSearch(params: { query: string; max_results?: num } } - // 3. Bing 也失败,尝试 Google(通过 google.com/search) + // 3. Bing 也失败,尝试 Google 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) { @@ -586,27 +582,39 @@ export async function handleWebSearch(params: { query: string; max_results?: num } } -/** 解析 DuckDuckGo HTML 搜索结果 */ -function parseDDGResults(html: string, maxResults: number): Array<{ title: string; url: string; snippet: string }> { +/** 解析百度 HTML 搜索结果 */ +function parseBaiduResults(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; + // 百度结果块:
+ // 标题:

内的 title + // 摘要:
...
+ const blockRegex = /
]*>([\s\S]*?)(?=
]*class="[^"]*t[^"]*"[^>]*>[\s\S]*?]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/); + if (!titleMatch) continue; + + let url = titleMatch[1].trim(); + const title = decodeHTML(titleMatch[2].replace(/<[^>]+>/g, '').trim()); + + // 百度有时使用 data-url 属性 + if (!url.startsWith('http')) { + const dataUrlMatch = block.match(/data-url="(https?:\/\/[^"]*)"/); + if (dataUrlMatch) url = dataUrlMatch[1]; } - const title = decodeHTML(match[2].replace(/<[^>]+>/g, '').trim()); - const snippet = decodeHTML(match[3].replace(/<[^>]+>/g, '').trim()); + // 提取摘要 + const snippetMatch = block.match(/
]*>([\s\S]*?)<\/div>/) || + block.match(/]*>([\s\S]*?)<\/span>/); + const snippet = snippetMatch ? decodeHTML(snippetMatch[1].replace(/<[^>]+>/g, '').trim()) : ''; - if (title && url.startsWith('http')) { - results.push({ title, url, snippet }); + if (title) { + results.push({ title, url: url.startsWith('http') ? url : `https://www.baidu.com${url}`, snippet }); } } @@ -829,9 +837,7 @@ export async function handleDownloadFile(params: { url: string; destination: str } sendLog('info', `⬇️ download_file`, `${params.url} → ${destPath}`); - const resp = await fetch(params.url, { - signal: AbortSignal.timeout(60000) - }); + const resp = await fetch(params.url); if (!resp.ok) return { success: false, error: `HTTP ${resp.status}: ${resp.statusText}` }; const buf = Buffer.from(await resp.arrayBuffer()); diff --git a/src/renderer/components/settings-modal.ts b/src/renderer/components/settings-modal.ts index 881a6ee..374cfc7 100644 --- a/src/renderer/components/settings-modal.ts +++ b/src/renderer/components/settings-modal.ts @@ -6,7 +6,7 @@ import { state, KEYS } from '../state/state.js'; import { debounce } from '../utils/utils.js'; import { encryptData, decryptData } from '../services/crypto.js'; import { setMemoryEnabled, setEmbeddingModel, getEmbeddingModel, isVectorMemoryEnabled, getMemoryCache } from '../services/memory-manager.js'; -import { logSetting, logInfo, logWarn, logError, logSuccess } from '../services/log-service.js'; +import { logSetting, logInfo, logWarn, logError, logSuccess, getLogs } from '../services/log-service.js'; import { checkConnection, updateConnectionInfo, updateRunningModels } from './header.js'; import { loadModels } from './model-bar.js'; import { showToast } from './toast.js'; @@ -73,6 +73,20 @@ export function initSettingsModal(): void { }, 300); tempSlider.addEventListener('change', saveTemperature); + // ── v5.1.1 Agent 最大轮数 ── + const saveMaxTurns = debounce(async () => { + const db = state.get(KEYS.DB); + const val = (document.querySelector('#inputMaxTurns') as HTMLInputElement).value.trim(); + const maxTurns = val ? parseInt(val) : 85; + state.set('maxTurns', maxTurns); + if (db) await db.saveSetting('maxTurns', maxTurns); + logSetting('Agent 最大轮数', `${maxTurns} 轮`); + }, 500); + document.querySelector('#inputMaxTurns')!.addEventListener('input', saveMaxTurns); + + // ── v5.1.1 执行日志导出 ── + document.querySelector('#btnExportLog')!.addEventListener('click', exportExecutionLog); + // ── v4.2 人格模式切换 ── const PERSONALITY_TEMPERATURES: Record = { default: -1, // 使用用户设置 @@ -378,6 +392,57 @@ export function closeSettingsModal(): void { settingsModalEl.style.display = 'none'; } +/** 导出执行日志为 TXT 文件 */ +async function exportExecutionLog(): Promise { + const logs = getLogs(); + if (logs.length === 0) { + showToast('没有可导出的日志', 'warning'); + return; + } + + const formatTime = (ts: number): string => { + const d = new Date(ts); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`; + }; + + const lines = logs.map(e => { + let line = `[${formatTime(e.time)}] ${e.level.toUpperCase()} ${e.icon} ${e.message}`; + if (e.detail) line += `\n ${e.detail}`; + return line; + }); + + const content = `Metona Ollama Desktop — 执行日志\n导出时间: ${formatTime(Date.now())}\n日志条数: ${logs.length}\n${'═'.repeat(60)}\n\n${lines.join('\n')}`; + + try { + const bridge = (window as any).metonaDesktop; + if (bridge) { + const ts = new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-'); + const filePath = await bridge.dialog.saveFile({ + defaultPath: `metona-log-${ts}.txt`, + filters: [{ name: '文本文件', extensions: ['txt'] }] + }); + if (!filePath) return; + const result = await bridge.fs.writeFile(filePath, content, 'utf-8'); + if (!result.success) { + showToast(`导出失败: ${result.error}`, 'error'); + return; + } + } else { + const blob = new Blob([content], { type: 'text/plain;charset=utf-8' }); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = `metona-log-${Date.now()}.txt`; + a.click(); + } + + showToast(`已导出 ${logs.length} 条日志`, 'success'); + logSuccess(`日志导出完成: ${logs.length} 条`); + } catch (err) { + showToast(`导出失败: ${(err as Error).message}`, 'error'); + logError('日志导出失败', (err as Error).message); + } +} + async function exportAllSessions(): Promise { const db = state.get(KEYS.DB); if (!db) return; diff --git a/src/renderer/index.html b/src/renderer/index.html index 6a51354..905b16f 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -15,6 +15,7 @@ @@ -259,6 +260,12 @@

低值更精确稳定,高值更有创意多样(0 = 确定性,2 = 最大随机)

+ +
+ + +
+

Agent Loop 最大工具调用轮数。默认 85 轮,复杂任务可能需要更多轮次。

diff --git a/src/renderer/main.ts b/src/renderer/main.ts index e85c673..d669519 100644 --- a/src/renderer/main.ts +++ b/src/renderer/main.ts @@ -353,6 +353,11 @@ async function init(): Promise { (document.querySelector('#inputTemperature') as HTMLInputElement).value = String(temperature); document.querySelector('#tempValue')!.textContent = parseFloat(temperature).toFixed(1); + // ── v5.1.1 Agent 最大轮数 ── + const maxTurns = await db.getSetting('maxTurns', 85); + state.set('maxTurns', maxTurns); + (document.querySelector('#inputMaxTurns') as HTMLInputElement).value = String(maxTurns); + // ── Tool Calling 设置 ── const toolCallingEnabled = await db.getSetting('toolCallingEnabled', false); const runCommandMode = await db.getSetting('runCommandMode', 'confirm') as 'auto' | 'confirm' | 'disabled'; diff --git a/src/renderer/services/agent-engine.ts b/src/renderer/services/agent-engine.ts index 04a41d5..4d992c2 100644 --- a/src/renderer/services/agent-engine.ts +++ b/src/renderer/services/agent-engine.ts @@ -28,9 +28,6 @@ import type { TraceEntry } from '../types.js'; -const MAX_LOOP_TIME = 600000; // 10 分钟总超时 -const TOOL_EXEC_TIMEOUT = 30000; // 工具执行超时 30 秒 -const STREAM_TIMEOUT = 120000; // 单次流式调用超时 2 分钟 const MAX_RETRIES = 2; // 工具错误自动重试次数 /** 每个工具返回给模型的最大字符数 */ @@ -89,7 +86,9 @@ const TOOL_USAGE_GUIDE = `【工具链规则(强制执行)】 9. 会话工具:可以用 session_list 列出历史会话,用 session_read 读取会话内容。 10. 并行调用:当多个工具调用互相独立时,可以在同一次回复中同时调用它们。 11. 管理记忆:可以用 memory_replace 更新已有记忆(子串匹配 old_text),用 memory_remove 删除不再需要的记忆。 -12. 查看技能:可以用 skill_list 列出所有可用技能,用 skill_view 查看特定技能的详细工具链。`; +12. 查看技能:可以用 skill_list 列出所有可用技能,用 skill_view 查看特定技能的详细工具链。 +13. 【严禁猜 URL】绝对不要猜测或编造 URL 路径(如 https://example.com/docs/install)。所有 URL 必须从搜索结果中获取。如果你猜了一个 URL 并得到 404 错误,不要继续猜其他路径,应该回到搜索结果中找正确的链接。 +14. 搜索结果来源:搜索结果中每条都带有标题、URL 和摘要。摘要已经包含关键信息,优先从摘要中提取答案。不要忽视来自知乎、CSDN、博客园等中文来源的结果,它们通常包含有效的技术信息。`;`; /** 工具名白名单:用于文本解析兜底时过滤非法工具名 */ const VALID_TOOL_NAMES = new Set([ @@ -488,8 +487,8 @@ export async function runAgentLoop( logInfo(`ReAct Agent Loop 启动: ${model}`, `工具: ${useTools ? '开启' : '关闭'}, 记忆: ${isMemoryEnabled() ? '开启' : '关闭'}, tokens≈${estimateTokens(messages.map(m => m.content || '').join(''))}`); - // 迭代预算:从 state 读取,默认 15 - const maxLoops = state.get('maxTurns', 15); + // 迭代预算:从 state 读取,默认 85 + const maxLoops = state.get('maxTurns', 85); let loopCount = 0; const allToolRecords: ToolCallRecord[] = []; @@ -509,13 +508,6 @@ export async function runAgentLoop( while (loopCount < maxLoops) { loopCount++; - // 全局超时检查 - if (Date.now() - loopStartTime > MAX_LOOP_TIME) { - logWarn('ReAct Agent Loop 达到最大运行时间(10分钟),自动停止'); - callbacks.onDone(content || '(达到最大运行时间限制)', allToolRecords, makeStats()); - return; - } - // 检查是否已中止 if (state.get(KEYS.ABORT_CONTROLLER)?.signal.aborted) { logInfo('ReAct Agent Loop 已中止'); @@ -534,9 +526,8 @@ export async function runAgentLoop( state.set(KEYS.ABORT_CONTROLLER, abortController); try { - // 带超时保护的流式调用 - await Promise.race([ - api.chatStream( + // 流式调用(无超时限制) + await api.chatStream( { model, messages, @@ -578,11 +569,7 @@ export async function runAgentLoop( } }, abortController - ), - new Promise((_, reject) => - setTimeout(() => reject(new Error('模型响应超时(2分钟无数据)')), STREAM_TIMEOUT) - ) - ]); + ); } catch (err) { if (abortController.signal.aborted) { logInfo('流式调用已中止'); @@ -592,15 +579,12 @@ export async function runAgentLoop( callbacks.onDone(content, allToolRecords.length > 0 ? allToolRecords : undefined, makeStats()); return; } - if ((err as Error).message.includes('超时')) { - logError('流式调用超时', (err as Error).message); - if (content || thinking) { - messages.push({ role: 'assistant', content: content || '(模型响应超时)', ...(thinking && { thinking }) }); - } - callbacks.onDone(content || '(模型响应超时,已自动中断)', allToolRecords.length > 0 ? allToolRecords : undefined, makeStats()); - return; + logError('流式调用异常', (err as Error).message); + if (content || thinking) { + messages.push({ role: 'assistant', content: content || '(模型响应异常)', ...(thinking && { thinking }) }); } - throw err; + callbacks.onDone(content || '(模型响应异常,已自动中断)', allToolRecords.length > 0 ? allToolRecords : undefined, makeStats()); + return; } // 提取 ReAct 思考过程 @@ -781,24 +765,13 @@ export async function runAgentLoop( let lastError = ''; for (let retry = 0; retry <= MAX_RETRIES; retry++) { try { - let result: ToolResult; - if (call.function.name === 'run_command') { - result = await executeTool(call.function.name, call.function.arguments); - } else { - result = await Promise.race([ - executeTool(call.function.name, call.function.arguments).catch(err => - ({ success: false, error: err?.message || String(err) }) as ToolResult - ), - new Promise((_, reject) => - setTimeout(() => reject(new Error('工具执行超时(30秒)')), TOOL_EXEC_TIMEOUT) - ) - ]); - } + const result = await executeTool(call.function.name, call.function.arguments) + .catch(err => ({ success: false, error: err?.message || String(err) }) as ToolResult); logToolResult(call.function.name, result.success, result.success ? undefined : result.error); return [{ name: call.function.name, arguments: call.function.arguments, result, status: result.success ? 'success' as const : 'error' as const, timestamp: Date.now() - }, result.success && call.function.name !== 'run_command' ? cacheKey : null]; + }, result.success ? cacheKey : null]; } catch (err) { lastError = (err as Error).message; if (retry < MAX_RETRIES) { diff --git a/src/renderer/services/log-service.ts b/src/renderer/services/log-service.ts index a0f83c4..f5c06cc 100644 --- a/src/renderer/services/log-service.ts +++ b/src/renderer/services/log-service.ts @@ -185,3 +185,8 @@ export function logSkill(action: string, detail?: string): void { addLog('debug' export function logRAG(action: string, detail?: string): void { addLog('info', `向量: ${action}`, detail); } export function logSetting(key: string, value: string): void { addLog('debug', `设置: ${key}`, value); } export function logInit(msg: string): void { addLog('info', `初始化: ${msg}`); } + +/** 获取当前所有日志条目(供导出使用) */ +export function getLogs(): LogEntry[] { + return [...logs]; +}