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>
|
||||||
|
|
||||||
<p align="center">
|
<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/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/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">
|
<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
|
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
|
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
|
## 🛠️ Common Commands
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "metona-ollama-desktop",
|
"name": "metona-ollama-desktop",
|
||||||
"version": "0.14.11",
|
"version": "0.14.12",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "metona-ollama-desktop",
|
"name": "metona-ollama-desktop",
|
||||||
"version": "0.14.11",
|
"version": "0.14.12",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ffmpeg-static": "^5.2.0",
|
"ffmpeg-static": "^5.2.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "metona-ollama-desktop",
|
"name": "metona-ollama-desktop",
|
||||||
"version": "0.14.11",
|
"version": "0.14.12",
|
||||||
"description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端",
|
"description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端",
|
||||||
"main": "dist/main/main.js",
|
"main": "dist/main/main.js",
|
||||||
"author": "thzxx",
|
"author": "thzxx",
|
||||||
|
|||||||
+1
-1
@@ -101,7 +101,7 @@ export function createMenu(): void {
|
|||||||
dialog.showMessageBox(mainWindow!, {
|
dialog.showMessageBox(mainWindow!, {
|
||||||
type: 'info',
|
type: 'info',
|
||||||
title: '关于 Metona Ollama',
|
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',
|
detail: 'TypeScript + Electron Ollama AI 聊天客户端\n\nhttps://gitee.com/thzxx/metona-ollama',
|
||||||
icon: getIconPath()
|
icon: getIconPath()
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -614,6 +614,7 @@ export async function handleWebFetch(params: { url: string; max_chars?: number;
|
|||||||
/**
|
/**
|
||||||
* SearXNG 元搜索引擎 — JSON API 搜索
|
* SearXNG 元搜索引擎 — JSON API 搜索
|
||||||
* 支持 70+ 引擎聚合、隐私保护、无广告
|
* 支持 70+ 引擎聚合、隐私保护、无广告
|
||||||
|
* v2: pageno 分页 + URL 去重 + 早退,准确满足 max_results
|
||||||
*/
|
*/
|
||||||
async function handleWebSearchSearxng(
|
async function handleWebSearchSearxng(
|
||||||
query: string,
|
query: string,
|
||||||
@@ -636,20 +637,14 @@ async function handleWebSearchSearxng(
|
|||||||
const format = getSetting<string>('searxng_format', 'json');
|
const format = getSetting<string>('searxng_format', 'json');
|
||||||
const isHtml = format === 'html';
|
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 authKey = getSetting<string>('searxng_auth_key', '');
|
||||||
const authType = getSetting<string>('searxng_auth_type', 'bearer');
|
const authType = getSetting<string>('searxng_auth_type', 'bearer');
|
||||||
|
|
||||||
const apiUrl = `${url.replace(/\/+$/, '')}/search?${params.toString()}`;
|
const baseUrl = url.replace(/\/+$/, '');
|
||||||
sendLog('info', `🔍 SearXNG 搜索`, `${query} → ${url} (${format})`);
|
sendLog('info', `🔍 SearXNG 搜索`, `"${query}" → ${baseUrl} (${format}) 期望=${effectiveMax}条`);
|
||||||
|
|
||||||
try {
|
// 构建请求头
|
||||||
|
const buildHeaders = (apiUrl: string) => {
|
||||||
const headers = buildFetchHeaders(apiUrl, 0);
|
const headers = buildFetchHeaders(apiUrl, 0);
|
||||||
if (authKey) {
|
if (authKey) {
|
||||||
if (authType === 'basic') {
|
if (authType === 'basic') {
|
||||||
@@ -658,6 +653,26 @@ async function handleWebSearchSearxng(
|
|||||||
headers['Authorization'] = `Bearer ${authKey}`;
|
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);
|
const resp = await fetchWithTimeout(apiUrl, HTTP_TIMEOUT, headers);
|
||||||
if (!resp || !resp.ok) {
|
if (!resp || !resp.ok) {
|
||||||
return {
|
return {
|
||||||
@@ -666,9 +681,6 @@ async function handleWebSearchSearxng(
|
|||||||
_searxng_url: apiUrl
|
_searxng_url: apiUrl
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── HTML 模式:返回原始网页文本,让 AI 自行分析 ──
|
|
||||||
if (isHtml) {
|
|
||||||
const html = await resp.text();
|
const html = await resp.text();
|
||||||
const text = htmlToText(html);
|
const text = htmlToText(html);
|
||||||
sendLog('success', `🔍 SearXNG HTML 完成`, `${text.length} 字符`);
|
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 {
|
const data = await resp.json() as {
|
||||||
query: string;
|
query: string;
|
||||||
number_of_results?: number;
|
number_of_results?: number;
|
||||||
results?: Array<{
|
results?: Array<{ title: string; url: string; content: string; engine: string; score?: number; category?: string }>;
|
||||||
title: string;
|
|
||||||
url: string;
|
|
||||||
content: string;
|
|
||||||
engine: string;
|
|
||||||
score?: number;
|
|
||||||
category?: string;
|
|
||||||
}>;
|
|
||||||
unresponsive_engines?: Array<[string, string]>;
|
unresponsive_engines?: Array<[string, string]>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const rawResults = (data.results || []).slice(0, effectiveMax);
|
pagesFetched++;
|
||||||
if (rawResults.length === 0) {
|
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 未返回任何结果,请尝试其他关键词或调整搜索引擎配置' };
|
return { success: false, error: 'SearXNG 未返回任何结果,请尝试其他关键词或调整搜索引擎配置' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转换为兼容格式
|
// 最终裁剪到 effectiveMax
|
||||||
const results = rawResults.map(r => ({
|
const finalResults = allResults.slice(0, effectiveMax);
|
||||||
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,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const engineNames = [...new Set(rawResults.map(r => r.engine))].filter(Boolean);
|
// 构建引擎统计
|
||||||
const unresponsive = data.unresponsive_engines || [];
|
|
||||||
const engineStats: Record<string, string> = {};
|
const engineStats: Record<string, string> = {};
|
||||||
for (const engine of engineNames) {
|
for (const engine of allEngineNames) {
|
||||||
engineStats[engine] = `${rawResults.filter(r => r.engine === engine).length} 条`;
|
engineStats[engine] = `${finalResults.filter(r => r.engine === engine).length} 条`;
|
||||||
}
|
}
|
||||||
for (const [eng, reason] of unresponsive) {
|
for (const [eng, reason] of allUnresponsive) {
|
||||||
engineStats[eng] = `⚠️ ${reason}`;
|
engineStats[eng] = `⚠️ ${reason}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
sendLog('success', `🔍 SearXNG 完成`, `${results.length} 条结果, 引擎: ${engineNames.join(', ')}`);
|
sendLog('success', `🔍 SearXNG 完成`, `${finalResults.length} 条结果 (${pagesFetched}页), 引擎: ${[...allEngineNames].join(', ')}`);
|
||||||
|
|
||||||
const result = buildSearchResponse(
|
const result = buildSearchResponse(
|
||||||
results.map(r => ({
|
finalResults.map(r => ({
|
||||||
title: r.title,
|
title: r.title,
|
||||||
url: r.url,
|
url: r.url,
|
||||||
snippet: r.snippet,
|
snippet: r.content,
|
||||||
engine: `searxng:${r.engine}`,
|
engine: `searxng:${r.engine}`,
|
||||||
weight: 80,
|
weight: 80,
|
||||||
reachable: true,
|
reachable: true,
|
||||||
|
|||||||
@@ -66,6 +66,17 @@ export async function loadSearxngConfig(db: ChatDB): Promise<void> {
|
|||||||
updateEnabledUI(searxngConfig.enabled);
|
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 控件以匹配当前配置 */
|
/** 刷新 UI 控件以匹配当前配置 */
|
||||||
function syncFormFromConfig(): void {
|
function syncFormFromConfig(): void {
|
||||||
(document.querySelector('#searxngUrl') as HTMLInputElement).value = searxngConfig.url;
|
(document.querySelector('#searxngUrl') as HTMLInputElement).value = searxngConfig.url;
|
||||||
@@ -82,6 +93,7 @@ function syncFormFromConfig(): void {
|
|||||||
const toggle = document.querySelector('#toggleSearxngEnabled') as HTMLInputElement;
|
const toggle = document.querySelector('#toggleSearxngEnabled') as HTMLInputElement;
|
||||||
toggle.checked = searxngConfig.enabled;
|
toggle.checked = searxngConfig.enabled;
|
||||||
updateEnabledUI(searxngConfig.enabled);
|
updateEnabledUI(searxngConfig.enabled);
|
||||||
|
updateMaxResultsVisibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 启用/禁用状态切换 UI */
|
/** 启用/禁用状态切换 UI */
|
||||||
@@ -130,6 +142,11 @@ export function initSearxngModal(): void {
|
|||||||
logInfo('SearXNG', checked ? '已启用' : '已禁用');
|
logInfo('SearXNG', checked ? '已启用' : '已禁用');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── 返回格式切换 → 动态启用/禁用「单次结果数」 ──
|
||||||
|
document.querySelector('#searxngFormat')!.addEventListener('change', () => {
|
||||||
|
updateMaxResultsVisibility();
|
||||||
|
});
|
||||||
|
|
||||||
// ── 保存按钮 ──
|
// ── 保存按钮 ──
|
||||||
document.querySelector('#btnSaveSearxng')!.addEventListener('click', async () => {
|
document.querySelector('#btnSaveSearxng')!.addEventListener('click', async () => {
|
||||||
const url = (document.querySelector('#searxngUrl') as HTMLInputElement).value.trim();
|
const url = (document.querySelector('#searxngUrl') as HTMLInputElement).value.trim();
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<img class="logo" src="./assets/icons/llama.png" alt="logo" />
|
<img class="logo" src="./assets/icons/llama.png" alt="logo" />
|
||||||
<span class="app-title">Metona Ollama</span>
|
<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="使用帮助">
|
<button class="icon-btn help-btn" id="btnHelp" title="使用帮助">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<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"/>
|
<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>
|
||||||
<div class="setting-group">
|
<div class="setting-group">
|
||||||
<label class="setting-label">单次结果数</label>
|
<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>
|
||||||
<div class="setting-group">
|
<div class="setting-group">
|
||||||
<label class="setting-label">返回格式</label>
|
<label class="setting-label">返回格式</label>
|
||||||
|
|||||||
Reference in New Issue
Block a user