From c824562f6891e94a0b59e90b6bd11e2f9ff6f0aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=B4=AB=E5=BD=B1233?= Date: Fri, 10 Jul 2026 12:55:51 +0800 Subject: [PATCH] v0.14.12: SearXNG pageno pagination + max_results HTML mode UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SearXNG: pageno分页+URL去重+早退, 准确满足max_results - SearXNG: HTML模式禁用单次结果数输入框+提示 - menu.ts: 关于对话框版本号同步 - 版本号0.14.12 --- README.md | 6 +- package-lock.json | 4 +- package.json | 2 +- src/main/menu.ts | 2 +- src/main/tool-handlers-system.ts | 161 ++++++++++++++++------- src/renderer/components/searxng-modal.ts | 17 +++ src/renderer/index.html | 5 +- 7 files changed, 137 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index d55cb7d..553e37c 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@

- version + version electron typescript license @@ -253,7 +253,7 @@ npm start ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist ``` -产出:`release/Metona Ollama Setup v0.14.11.exe` +产出:`release/Metona Ollama Setup v0.14.12.exe` ## 🛠️ 常用命令 @@ -501,7 +501,7 @@ npm start ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist ``` -Output: `release/Metona Ollama Setup v0.14.11.exe` +Output: `release/Metona Ollama Setup v0.14.12.exe` ## 🛠️ Common Commands diff --git a/package-lock.json b/package-lock.json index 62f9479..35f7b81 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "metona-ollama-desktop", - "version": "0.14.11", + "version": "0.14.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "metona-ollama-desktop", - "version": "0.14.11", + "version": "0.14.12", "license": "MIT", "dependencies": { "ffmpeg-static": "^5.2.0", diff --git a/package.json b/package.json index f0f4f4a..87be502 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metona-ollama-desktop", - "version": "0.14.11", + "version": "0.14.12", "description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端", "main": "dist/main/main.js", "author": "thzxx", diff --git a/src/main/menu.ts b/src/main/menu.ts index 061bde3..50b5980 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -101,7 +101,7 @@ export function createMenu(): void { dialog.showMessageBox(mainWindow!, { type: 'info', title: '关于 Metona Ollama', - message: 'Metona Ollama Desktop v0.14.11', + message: 'Metona Ollama Desktop v0.14.12', detail: 'TypeScript + Electron Ollama AI 聊天客户端\n\nhttps://gitee.com/thzxx/metona-ollama', icon: getIconPath() }); diff --git a/src/main/tool-handlers-system.ts b/src/main/tool-handlers-system.ts index 5e1b8bb..736647e 100644 --- a/src/main/tool-handlers-system.ts +++ b/src/main/tool-handlers-system.ts @@ -614,6 +614,7 @@ export async function handleWebFetch(params: { url: string; max_chars?: number; /** * SearXNG 元搜索引擎 — JSON API 搜索 * 支持 70+ 引擎聚合、隐私保护、无广告 + * v2: pageno 分页 + URL 去重 + 早退,准确满足 max_results */ async function handleWebSearchSearxng( query: string, @@ -636,20 +637,14 @@ async function handleWebSearchSearxng( const format = getSetting('searxng_format', 'json'); const isHtml = format === 'html'; - const params = new URLSearchParams({ q: query }); - if (!isHtml) params.set('format', 'json'); - if (engines) params.set('engines', engines); - if (language) params.set('language', language); - if (safesearch > 0) params.set('safesearch', String(safesearch)); - if (effectiveTimeRange) params.set('time_range', effectiveTimeRange); - const authKey = getSetting('searxng_auth_key', ''); const authType = getSetting('searxng_auth_type', 'bearer'); - const apiUrl = `${url.replace(/\/+$/, '')}/search?${params.toString()}`; - sendLog('info', `🔍 SearXNG 搜索`, `${query} → ${url} (${format})`); + const baseUrl = url.replace(/\/+$/, ''); + sendLog('info', `🔍 SearXNG 搜索`, `"${query}" → ${baseUrl} (${format}) 期望=${effectiveMax}条`); - try { + // 构建请求头 + const buildHeaders = (apiUrl: string) => { const headers = buildFetchHeaders(apiUrl, 0); if (authKey) { if (authType === 'basic') { @@ -658,17 +653,34 @@ async function handleWebSearchSearxng( headers['Authorization'] = `Bearer ${authKey}`; } } - const resp = await fetchWithTimeout(apiUrl, HTTP_TIMEOUT, headers); - if (!resp || !resp.ok) { - return { - success: false, - error: `SearXNG 搜索失败 (HTTP ${resp?.status || 'timeout'})。请检查 SearXNG 实例是否运行正常:${url}。`, - _searxng_url: apiUrl - }; - } + return headers; + }; - // ── HTML 模式:返回原始网页文本,让 AI 自行分析 ── + // 构建单页请求 URL + const buildPageUrl = (pageno: number) => { + const params = new URLSearchParams({ q: query }); + if (!isHtml) params.set('format', 'json'); + if (engines) params.set('engines', engines); + if (language) params.set('language', language); + if (safesearch > 0) params.set('safesearch', String(safesearch)); + if (effectiveTimeRange) params.set('time_range', effectiveTimeRange); + if (pageno > 1) params.set('pageno', String(pageno)); + return `${baseUrl}/search?${params.toString()}`; + }; + + try { + // ── HTML 模式:单页返回原始网页文本,让 AI 自行分析(不分页)── if (isHtml) { + const apiUrl = buildPageUrl(1); + const headers = buildHeaders(apiUrl); + const resp = await fetchWithTimeout(apiUrl, HTTP_TIMEOUT, headers); + if (!resp || !resp.ok) { + return { + success: false, + error: `SearXNG 搜索失败 (HTTP ${resp?.status || 'timeout'})。请检查 SearXNG 实例是否运行正常:${url}。`, + _searxng_url: apiUrl + }; + } const html = await resp.text(); const text = htmlToText(html); sendLog('success', `🔍 SearXNG HTML 完成`, `${text.length} 字符`); @@ -685,52 +697,99 @@ async function handleWebSearchSearxng( }; } - const data = await resp.json() as { - query: string; - number_of_results?: number; - results?: Array<{ - title: string; - url: string; - content: string; - engine: string; - score?: number; - category?: string; - }>; - unresponsive_engines?: Array<[string, string]>; - }; + // ── JSON 模式:分页请求 + URL 去重 + 早退 ── + const MAX_PAGES = 5; // 最大翻页数,防滥用 + const seenUrls = new Set(); // URL 归一化去重 + const allResults: Array<{ title: string; url: string; content: string; engine: string }> = []; + const allEngineNames = new Set(); + const allUnresponsive: Array<[string, string]> = []; + let pagesFetched = 0; - const rawResults = (data.results || []).slice(0, effectiveMax); - if (rawResults.length === 0) { + for (let pageno = 1; pageno <= MAX_PAGES; pageno++) { + // 已够,早退 + if (allResults.length >= effectiveMax) break; + + const apiUrl = buildPageUrl(pageno); + const headers = buildHeaders(apiUrl); + const resp = await fetchWithTimeout(apiUrl, HTTP_TIMEOUT, headers); + if (!resp || !resp.ok) { + if (pageno === 1) { + return { + success: false, + error: `SearXNG 搜索失败 (HTTP ${resp?.status || 'timeout'})。请检查 SearXNG 实例是否运行正常:${url}。`, + _searxng_url: apiUrl + }; + } + // 后续页失败,用已有结果 + sendLog('warn', `🔍 SearXNG 翻页失败`, `pageno=${pageno} HTTP ${resp?.status || 'timeout'}, 使用已有 ${allResults.length} 条结果`); + break; + } + + const data = await resp.json() as { + query: string; + number_of_results?: number; + results?: Array<{ title: string; url: string; content: string; engine: string; score?: number; category?: string }>; + unresponsive_engines?: Array<[string, string]>; + }; + + pagesFetched++; + const pageResults = data.results || []; + if (data.unresponsive_engines) allUnresponsive.push(...data.unresponsive_engines); + + let newCount = 0; + for (const r of pageResults) { + // URL 归一化去重:去除 fragment → 小写 → 去除尾部斜杠 + const normalizedUrl = (r.url || '').split('#')[0].toLowerCase().replace(/\/+$/, ''); + if (!normalizedUrl || seenUrls.has(normalizedUrl)) continue; + seenUrls.add(normalizedUrl); + if (r.engine) allEngineNames.add(r.engine); + allResults.push({ + title: (r.title || '').replace(/<[^>]+>/g, '').trim(), + url: r.url, + content: (r.content || '').replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim(), + engine: r.engine || 'searxng', + }); + newCount++; + if (allResults.length >= effectiveMax) break; + } + + sendLog('info', `🔍 SearXNG 翻页`, `pageno=${pageno} → ${pageResults.length}条, 去重后${newCount}条新 (累计${allResults.length})`); + + // 本页没有新结果 → 没有更多了,早退 + if (newCount === 0) { + sendLog('info', `🔍 SearXNG 早退`, `pageno=${pageno} 无新结果,停止翻页`); + break; + } + // 本页返回的原始结果数 < 预期(通常是引擎已耗尽),也停止 + if (pageResults.length < 5 && allResults.length < effectiveMax) { + sendLog('info', `🔍 SearXNG 早退`, `pageno=${pageno} 仅${pageResults.length}条,引擎可能已耗尽`); + break; + } + } + + if (allResults.length === 0) { return { success: false, error: 'SearXNG 未返回任何结果,请尝试其他关键词或调整搜索引擎配置' }; } - // 转换为兼容格式 - const results = rawResults.map(r => ({ - title: (r.title || '').replace(/<[^>]+>/g, '').trim(), - url: r.url, - snippet: (r.content || '').replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim(), - engine: r.engine || 'searxng', - weight: 80, - reachable: true, - })); + // 最终裁剪到 effectiveMax + const finalResults = allResults.slice(0, effectiveMax); - const engineNames = [...new Set(rawResults.map(r => r.engine))].filter(Boolean); - const unresponsive = data.unresponsive_engines || []; + // 构建引擎统计 const engineStats: Record = {}; - for (const engine of engineNames) { - engineStats[engine] = `${rawResults.filter(r => r.engine === engine).length} 条`; + for (const engine of allEngineNames) { + engineStats[engine] = `${finalResults.filter(r => r.engine === engine).length} 条`; } - for (const [eng, reason] of unresponsive) { + for (const [eng, reason] of allUnresponsive) { engineStats[eng] = `⚠️ ${reason}`; } - sendLog('success', `🔍 SearXNG 完成`, `${results.length} 条结果, 引擎: ${engineNames.join(', ')}`); + sendLog('success', `🔍 SearXNG 完成`, `${finalResults.length} 条结果 (${pagesFetched}页), 引擎: ${[...allEngineNames].join(', ')}`); const result = buildSearchResponse( - results.map(r => ({ + finalResults.map(r => ({ title: r.title, url: r.url, - snippet: r.snippet, + snippet: r.content, engine: `searxng:${r.engine}`, weight: 80, reachable: true, diff --git a/src/renderer/components/searxng-modal.ts b/src/renderer/components/searxng-modal.ts index c2e5f10..c8d21b0 100644 --- a/src/renderer/components/searxng-modal.ts +++ b/src/renderer/components/searxng-modal.ts @@ -66,6 +66,17 @@ export async function loadSearxngConfig(db: ChatDB): Promise { updateEnabledUI(searxngConfig.enabled); } +/** 根据返回格式动态启用/禁用「单次结果数」输入框 */ +function updateMaxResultsVisibility(): void { + const format = (document.querySelector('#searxngFormat') as HTMLSelectElement).value; + const isHtml = format === 'html'; + const input = document.querySelector('#searxngMaxResults') as HTMLInputElement; + const hint = document.querySelector('#searxngMaxResultsHint') as HTMLElement; + input.disabled = isHtml; + input.style.opacity = isHtml ? '0.4' : ''; + hint.style.display = isHtml ? '' : 'none'; +} + /** 刷新 UI 控件以匹配当前配置 */ function syncFormFromConfig(): void { (document.querySelector('#searxngUrl') as HTMLInputElement).value = searxngConfig.url; @@ -82,6 +93,7 @@ function syncFormFromConfig(): void { const toggle = document.querySelector('#toggleSearxngEnabled') as HTMLInputElement; toggle.checked = searxngConfig.enabled; updateEnabledUI(searxngConfig.enabled); + updateMaxResultsVisibility(); } /** 启用/禁用状态切换 UI */ @@ -130,6 +142,11 @@ export function initSearxngModal(): void { logInfo('SearXNG', checked ? '已启用' : '已禁用'); }); + // ── 返回格式切换 → 动态启用/禁用「单次结果数」 ── + document.querySelector('#searxngFormat')!.addEventListener('change', () => { + updateMaxResultsVisibility(); + }); + // ── 保存按钮 ── document.querySelector('#btnSaveSearxng')!.addEventListener('click', async () => { const url = (document.querySelector('#searxngUrl') as HTMLInputElement).value.trim(); diff --git a/src/renderer/index.html b/src/renderer/index.html index 177b15d..3bea339 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -28,7 +28,7 @@

Metona Ollama - v0.14.11 + v0.14.12
- + +