v0.14.12: SearXNG pageno pagination + max_results HTML mode UI

- SearXNG: pageno分页+URL去重+早退, 准确满足max_results
- SearXNG: HTML模式禁用单次结果数输入框+提示
- menu.ts: 关于对话框版本号同步
- 版本号0.14.12
This commit is contained in:
紫影233
2026-07-10 12:55:51 +08:00
parent e3b44a0357
commit c824562f68
7 changed files with 137 additions and 60 deletions
+3 -3
View File
@@ -14,7 +14,7 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/version-v0.14.11-E8734A?style=flat-square" alt="version">
<img src="https://img.shields.io/badge/version-v0.14.12-E8734A?style=flat-square" alt="version">
<img src="https://img.shields.io/badge/electron-33+-47848F?style=flat-square&logo=electron" alt="electron">
<img src="https://img.shields.io/badge/typescript-5.7+-3178C6?style=flat-square&logo=typescript" alt="typescript">
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="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
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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()
});
+110 -51
View File
@@ -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<string>('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<string>('searxng_auth_key', '');
const authType = getSetting<string>('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<string>(); // URL 归一化去重
const allResults: Array<{ title: string; url: string; content: string; engine: string }> = [];
const allEngineNames = new Set<string>();
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<string, string> = {};
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,
+17
View File
@@ -66,6 +66,17 @@ export async function loadSearxngConfig(db: ChatDB): Promise<void> {
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();
+3 -2
View File
@@ -28,7 +28,7 @@
<div class="header-left">
<img class="logo" src="./assets/icons/llama.png" alt="logo" />
<span class="app-title">Metona Ollama</span>
<span class="app-version">v0.14.11</span>
<span class="app-version">v0.14.12</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"/>
@@ -854,7 +854,8 @@
</div>
<div class="setting-group">
<label class="setting-label">单次结果数</label>
<input class="setting-input" id="searxngMaxResults" type="number" min="0" max="50" step="1" placeholder="0=使用默认值" style="margin-bottom:0;">
<input class="setting-input" id="searxngMaxResults" type="number" min="0" max="100" step="1" placeholder="0=使用默认值(30)" style="margin-bottom:0;">
<p class="text-muted" id="searxngMaxResultsHint" style="font-size:11px;margin-top:4px;display:none;color:var(--text-disabled);">HTML 模式下此选项不生效,返回整页文本</p>
</div>
<div class="setting-group">
<label class="setting-label">返回格式</label>