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:
@@ -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
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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
@@ -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()
|
||||
});
|
||||
|
||||
@@ -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,6 +653,26 @@ async function handleWebSearchSearxng(
|
||||
headers['Authorization'] = `Bearer ${authKey}`;
|
||||
}
|
||||
}
|
||||
return headers;
|
||||
};
|
||||
|
||||
// 构建单页请求 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 {
|
||||
@@ -666,9 +681,6 @@ async function handleWebSearchSearxng(
|
||||
_searxng_url: apiUrl
|
||||
};
|
||||
}
|
||||
|
||||
// ── HTML 模式:返回原始网页文本,让 AI 自行分析 ──
|
||||
if (isHtml) {
|
||||
const html = await resp.text();
|
||||
const text = htmlToText(html);
|
||||
sendLog('success', `🔍 SearXNG HTML 完成`, `${text.length} 字符`);
|
||||
@@ -685,52 +697,99 @@ async function handleWebSearchSearxng(
|
||||
};
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
|
||||
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;
|
||||
}>;
|
||||
results?: Array<{ title: string; url: string; content: string; engine: string; score?: number; category?: string }>;
|
||||
unresponsive_engines?: Array<[string, string]>;
|
||||
};
|
||||
|
||||
const rawResults = (data.results || []).slice(0, effectiveMax);
|
||||
if (rawResults.length === 0) {
|
||||
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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user