feat: 集成 SearXNG 元搜索引擎,支持 JSON API 搜索替代四引擎 HTML 解析

- 新增 SearXNG 配置模态框,支持 API 地址、引擎列表、语言、安全搜索等参数
- 顶部添加独立 🔍 搜索按钮,带启用状态小绿点
- 启用后 web_search 走 SearXNG JSON API(70+ 引擎聚合)
- 禁用时回退原有 Bing+百度+搜狗+360 四引擎 HTML 解析方案
- 认证 Key 通过 HTTP Header Authorization: Bearer 传递,非必填
- 配置持久化到 SQLite settings 表
This commit is contained in:
thzxx
2026-06-12 12:44:41 +08:00
parent fe6de8e30e
commit 7030e69234
5 changed files with 386 additions and 0 deletions
+113
View File
@@ -9,6 +9,7 @@ import { checkPathAllowed, checkCommandAllowed } from './tool-security.js';
import { mainWindow } from './main.js'; import { mainWindow } from './main.js';
import { sendLog, resolvePath, type ToolResult } from './tool-handlers-shared.js'; import { sendLog, resolvePath, type ToolResult } from './tool-handlers-shared.js';
import { getWorkspaceDir } from './workspace.js'; import { getWorkspaceDir } from './workspace.js';
import { getSetting } from './db/sqlite.js';
/** 当前工具命令进程(用于用户手动终止) */ /** 当前工具命令进程(用于用户手动终止) */
let _toolProc: ReturnType<typeof spawn> | null = null; let _toolProc: ReturnType<typeof spawn> | null = null;
@@ -435,9 +436,114 @@ export async function handleWebFetch(params: { url: string; max_chars?: number;
return { success: false, error: lastError || '所有重试均失败' }; return { success: false, error: lastError || '所有重试均失败' };
} }
/**
* SearXNG 元搜索引擎 — JSON API 搜索
* 支持 70+ 引擎聚合、隐私保护、无广告
*/
async function handleWebSearchSearxng(
query: string,
maxResults: number,
timeRange: string,
_enhanceSnippets: boolean
): Promise<ToolResult> {
const url = getSetting<string>('searxng_url', 'https://search.metona.cn');
const engines = getSetting<string>('searxng_engines', 'google,bing,duckduckgo,wikipedia');
const language = getSetting<string>('searxng_language', 'zh-CN');
const safesearch = getSetting<number>('searxng_safesearch', 1);
const cfgTimeRange = getSetting<string>('searxng_time_range', '');
const effectiveTimeRange = timeRange || cfgTimeRange;
const params = new URLSearchParams({
q: query,
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 apiUrl = `${url.replace(/\/+$/, '')}/search?${params.toString()}`;
sendLog('info', `🔍 SearXNG 搜索`, `"${query}" → ${url}`);
try {
const headers = buildFetchHeaders(apiUrl, false);
if (authKey) headers['Authorization'] = `Bearer ${authKey}`;
const resp = await fetchWithTimeout(apiUrl, HTTP_TIMEOUT, headers);
if (!resp || !resp.ok) {
const fallbackUrl = encodeURIComponent(query);
return {
success: false,
error: `SearXNG 搜索失败 (HTTP ${resp?.status || 'timeout'})。请检查 SearXNG 实例是否运行正常:${url}。你也可以在设置中禁用 SearXNG 回退到内置四引擎搜索。`,
_searxng_fallback: `${url}/search?q=${fallbackUrl}`
};
}
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]>;
};
const rawResults = (data.results || []).slice(0, maxResults);
if (rawResults.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,
}));
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 [eng, reason] of unresponsive) {
engineStats[eng] = `⚠️ ${reason}`;
}
sendLog('success', `🔍 SearXNG 完成`, `${results.length} 条结果, 引擎: ${engineNames.join(', ')}`);
return buildSearchResponse(
results.map(r => ({
title: r.title,
url: r.url,
snippet: r.snippet,
engine: `searxng:${r.engine}`,
weight: 80,
reachable: true,
})),
maxResults,
false,
engineStats
);
} catch (err) {
sendLog('error', `🔍 SearXNG 异常`, (err as Error).message);
return { success: false, error: `SearXNG 搜索异常: ${(err as Error).message}` };
}
}
/** /**
* Web Search — 联网搜索(并行多引擎 + LRU 缓存 + 智能排序 + 时间过滤 + 摘要增强) * Web Search — 联网搜索(并行多引擎 + LRU 缓存 + 智能排序 + 时间过滤 + 摘要增强)
* 同时请求 Bing / 百度 / 搜狗 / 360搜索,智能排序后返回 * 同时请求 Bing / 百度 / 搜狗 / 360搜索,智能排序后返回
* 如果启用了 SearXNG,则使用 SearXNG JSON API 替代
*/ */
export async function handleWebSearch(params: { query: string; max_results?: number; time_range?: string; enhance_snippets?: boolean }): Promise<ToolResult> { export async function handleWebSearch(params: { query: string; max_results?: number; time_range?: string; enhance_snippets?: boolean }): Promise<ToolResult> {
try { try {
@@ -449,6 +555,13 @@ export async function handleWebSearch(params: { query: string; max_results?: num
const maxResults = Math.min(params.max_results || 15, 20); const maxResults = Math.min(params.max_results || 15, 20);
const timeRange = params.time_range || ''; const timeRange = params.time_range || '';
const enhanceSnippets = params.enhance_snippets !== false; // 默认开启 const enhanceSnippets = params.enhance_snippets !== false; // 默认开启
// ── SearXNG 模式:如果启用了 SearXNG,走 JSON API ──
const searxngEnabled = getSetting<boolean>('searxng_enabled', false);
if (searxngEnabled) {
return handleWebSearchSearxng(query, maxResults, timeRange, enhanceSnippets);
}
const cacheKey = `${query}|${maxResults}|${timeRange}`; const cacheKey = `${query}|${maxResults}|${timeRange}`;
// ── 时间范围 → URL 参数映射 ── // ── 时间范围 → URL 参数映射 ──
+134
View File
@@ -0,0 +1,134 @@
/**
* SearXNG 配置模态框
* 配置 SearXNG 元搜索引擎的 API 地址和参数,支持启用/禁用切换
*/
import { state, KEYS } from '../state/state.js';
import type { ChatDB } from '../db/chat-db.js';
import { logInfo, logSuccess } from '../services/log-service.js';
/** 默认 SearXNG 配置 */
const DEFAULTS = {
enabled: false,
url: 'https://search.metona.cn',
engines: 'google,bing,duckduckgo,wikipedia',
language: 'zh-CN',
safesearch: 1,
time_range: '',
max_results: 20,
auth_key: '',
};
let modalEl: HTMLElement;
let initialized = false;
/** SearXNG 运行时配置缓存(主进程搜索时通过 IPC 读取,渲染进程通过此变量快速访问) */
export let searxngConfig = { ...DEFAULTS };
/** 写入配置到 SQLite + 更新运行时缓存 */
async function saveConfig(db: ChatDB): Promise<void> {
await db.saveSetting('searxng_enabled', searxngConfig.enabled);
await db.saveSetting('searxng_url', searxngConfig.url);
await db.saveSetting('searxng_engines', searxngConfig.engines);
await db.saveSetting('searxng_language', searxngConfig.language);
await db.saveSetting('searxng_safesearch', searxngConfig.safesearch);
await db.saveSetting('searxng_time_range', searxngConfig.time_range);
await db.saveSetting('searxng_max_results', searxngConfig.max_results);
await db.saveSetting('searxng_auth_key', searxngConfig.auth_key);
logSuccess('SearXNG 配置已保存');
}
/** 从 SQLite 加载配置到运行时缓存 */
export async function loadSearxngConfig(db: ChatDB): Promise<void> {
searxngConfig.enabled = await db.getSetting('searxng_enabled', DEFAULTS.enabled);
searxngConfig.url = await db.getSetting('searxng_url', DEFAULTS.url);
searxngConfig.engines = await db.getSetting('searxng_engines', DEFAULTS.engines);
searxngConfig.language = await db.getSetting('searxng_language', DEFAULTS.language);
searxngConfig.safesearch = await db.getSetting('searxng_safesearch', DEFAULTS.safesearch);
searxngConfig.time_range = await db.getSetting('searxng_time_range', DEFAULTS.time_range);
searxngConfig.max_results = await db.getSetting('searxng_max_results', DEFAULTS.max_results);
searxngConfig.auth_key = await db.getSetting('searxng_auth_key', DEFAULTS.auth_key);
logInfo('SearXNG 配置已加载', searxngConfig.enabled ? `已启用: ${searxngConfig.url}` : '未启用');
}
/** 刷新 UI 控件以匹配当前配置 */
function syncFormFromConfig(): void {
(document.querySelector('#searxngUrl') as HTMLInputElement).value = searxngConfig.url;
(document.querySelector('#searxngEngines') as HTMLInputElement).value = searxngConfig.engines;
(document.querySelector('#searxngLanguage') as HTMLSelectElement).value = searxngConfig.language;
(document.querySelector('#searxngSafeSearch') as HTMLSelectElement).value = String(searxngConfig.safesearch);
(document.querySelector('#searxngTimeRange') as HTMLSelectElement).value = searxngConfig.time_range || '';
(document.querySelector('#searxngMaxResults') as HTMLInputElement).value = String(searxngConfig.max_results);
(document.querySelector('#searxngAuthKey') as HTMLInputElement).value = searxngConfig.auth_key;
const toggle = document.querySelector('#toggleSearxngEnabled') as HTMLInputElement;
toggle.checked = searxngConfig.enabled;
updateEnabledUI(searxngConfig.enabled);
}
/** 启用/禁用状态切换 UI */
function updateEnabledUI(enabled: boolean): void {
const badge = document.querySelector('#searxngBadge') as HTMLElement;
const toggle = document.querySelector('#toggleSearxngEnabled') as HTMLInputElement;
const iconDot = document.querySelector('#searxngIconDot') as HTMLElement;
if (badge) {
badge.textContent = enabled ? '🟢 已启用' : '⚪ 未启用';
badge.className = enabled ? 'searxng-badge enabled' : 'searxng-badge';
}
if (toggle) toggle.checked = enabled;
if (iconDot) {
iconDot.className = enabled ? 'searxng-icon-dot enabled' : 'searxng-icon-dot';
}
}
export function initSearxngModal(): void {
if (initialized) return;
modalEl = document.querySelector('#searxngModal') as HTMLElement;
// ── 打开模态框 ──
document.querySelector('#btnSearxng')!.addEventListener('click', async () => {
// 重新加载配置(可能在别处被修改)
const db = state.get<ChatDB | null>(KEYS.DB);
if (db) await loadSearxngConfig(db);
syncFormFromConfig();
modalEl.style.display = '';
});
// ── 关闭模态框 ──
document.querySelector('#btnCloseSearxng')!.addEventListener('click', () => {
modalEl.style.display = 'none';
});
modalEl.addEventListener('click', (e) => {
if (e.target === modalEl) modalEl.style.display = 'none';
});
// ── 启用/禁用开关 ──
document.querySelector('#toggleSearxngEnabled')!.addEventListener('change', async (e) => {
const checked = (e.target as HTMLInputElement).checked;
searxngConfig.enabled = checked;
updateEnabledUI(checked);
const db = state.get<ChatDB | null>(KEYS.DB);
if (db) await db.saveSetting('searxng_enabled', checked);
logInfo('SearXNG', checked ? '已启用' : '已禁用');
});
// ── 保存按钮 ──
document.querySelector('#btnSaveSearxng')!.addEventListener('click', async () => {
searxngConfig.url = (document.querySelector('#searxngUrl') as HTMLInputElement).value.trim();
searxngConfig.engines = (document.querySelector('#searxngEngines') as HTMLInputElement).value.trim();
searxngConfig.language = (document.querySelector('#searxngLanguage') as HTMLSelectElement).value;
searxngConfig.safesearch = parseInt((document.querySelector('#searxngSafeSearch') as HTMLSelectElement).value);
searxngConfig.time_range = (document.querySelector('#searxngTimeRange') as HTMLSelectElement).value;
searxngConfig.max_results = parseInt((document.querySelector('#searxngMaxResults') as HTMLInputElement).value) || 20;
searxngConfig.auth_key = (document.querySelector('#searxngAuthKey') as HTMLInputElement).value.trim();
const db = state.get<ChatDB | null>(KEYS.DB);
if (db) await saveConfig(db);
updateEnabledUI(searxngConfig.enabled);
});
initialized = true;
}
/** 外部调用关闭模态框(ESC 键等) */
export function closeSearxngModal(): void {
if (modalEl) modalEl.style.display = 'none';
}
+100
View File
@@ -72,6 +72,12 @@
<circle cx="12" cy="12" r="3"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/> <circle cx="12" cy="12" r="3"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
</svg> </svg>
</button> </button>
<button class="icon-btn searxng-btn" id="btnSearxng" title="SearXNG 搜索配置">
<span class="searxng-icon-dot" id="searxngIconDot"></span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
</button>
</div> </div>
</header> </header>
@@ -699,6 +705,100 @@
<!-- ═══════════════ Toast 通知容器 ═══════════════ --> <!-- ═══════════════ Toast 通知容器 ═══════════════ -->
<div class="toast-container" id="toastContainer"></div> <div class="toast-container" id="toastContainer"></div>
<!-- ═══════════════ SearXNG 配置模态框 ═══════════════ -->
<div class="modal-overlay" id="searxngModal" style="display:none;">
<div class="modal" style="max-width:640px;">
<div class="modal-header">
<h3>🔍 SearXNG 元搜索引擎</h3>
<button class="icon-btn" id="btnCloseSearxng">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="modal-body">
<div class="setting-group">
<label class="setting-label">
<span class="searxng-badge" id="searxngBadge">⚪ 未启用</span>
<label class="toggle-label" style="margin-left:auto;display:inline-flex;">
<input type="checkbox" id="toggleSearxngEnabled">
<span class="toggle-slider"></span>
</label>
</label>
<p class="text-muted" style="font-size:11px;margin-top:4px;">
启用后,联网搜索将使用 SearXNG 元搜索引擎(隐私保护、70+ 引擎聚合),替代内置的四引擎 HTML 解析方案。
</p>
</div>
<div class="setting-group">
<label class="setting-label">SearXNG API 地址</label>
<input class="setting-input" id="searxngUrl" type="url" placeholder="https://search.metona.cn" style="margin-bottom:6px;">
<p class="text-muted" style="font-size:11px;">SearXNG 实例的根地址,无需带 /search 路径</p>
</div>
<div class="setting-group">
<label class="setting-label">搜索引擎</label>
<input class="setting-input" id="searxngEngines" type="text" placeholder="google,bing,duckduckgo,wikipedia" style="margin-bottom:6px;">
<p class="text-muted" style="font-size:11px;">逗号分隔的引擎列表。常用: google, bing, duckduckgo, wikipedia, brave, qwant, startpage</p>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;">
<div class="setting-group">
<label class="setting-label">语言</label>
<select class="setting-input" id="searxngLanguage" style="margin-bottom:0;">
<option value="zh-CN">简体中文</option>
<option value="zh-TW">繁體中文</option>
<option value="en">English</option>
<option value="ja">日本語</option>
<option value="ko">한국어</option>
<option value="">自动检测</option>
</select>
</div>
<div class="setting-group">
<label class="setting-label">安全搜索</label>
<select class="setting-input" id="searxngSafeSearch" style="margin-bottom:0;">
<option value="0">关闭</option>
<option value="1">中等</option>
<option value="2">严格</option>
</select>
</div>
<div class="setting-group">
<label class="setting-label">时间范围</label>
<select class="setting-input" id="searxngTimeRange" style="margin-bottom:0;">
<option value="">不限</option>
<option value="day">最近一天</option>
<option value="week">最近一周</option>
<option value="month">最近一月</option>
<option value="year">最近一年</option>
</select>
</div>
<div class="setting-group">
<label class="setting-label">单次结果数</label>
<input class="setting-input" id="searxngMaxResults" type="number" min="1" max="50" step="1" value="20" style="margin-bottom:0;">
</div>
</div>
<div class="setting-group">
<label class="setting-label">🔑 认证 Key(非必填)</label>
<input class="setting-input" id="searxngAuthKey" type="password" placeholder="留空则不发送认证" style="margin-bottom:6px;">
<p class="text-muted" style="font-size:11px;">SearXNG 实例的 API 认证密钥,通过 HTTP Header <code>Authorization: Bearer xxx</code> 传递。需配合 Nginx 反向代理验证</p>
</div>
<div class="modal-actions">
<div style="flex:1;"></div>
<button class="btn btn-primary" id="btnSaveSearxng">💾 保存配置</button>
</div>
<div style="margin-top:16px;padding:12px;background:var(--bg-layer);border-radius:var(--radius-md);">
<p style="font-size:11px;color:var(--text-secondary);margin:0;">
💡 <strong>提示:</strong>SearXNG 是开源元搜索引擎,不追踪用户、不记录隐私。
需要自行部署或使用公共实例。API 端点格式:<code>{{URL}}/search?q={{query}}&format=json</code>
</p>
</div>
</div>
</div>
</div>
<!-- ═══════════════ 应用入口 ════════════════ --> <!-- ═══════════════ 应用入口 ════════════════ -->
<script type="module" src="./main.ts"></script> <script type="module" src="./main.ts"></script>
</body> </body>
+4
View File
@@ -30,6 +30,7 @@ import { initToolConfirmModal } from './components/tool-confirm-modal.js';
import { initWorkspacePanel, clearToolCardsExternal, clearTerminalExternal, switchToTab } from './components/workspace-panel.js'; import { initWorkspacePanel, clearToolCardsExternal, clearTerminalExternal, switchToTab } from './components/workspace-panel.js';
import { initLogPanel, addLog } from './services/log-service.js'; import { initLogPanel, addLog } from './services/log-service.js';
import { logInfo, logSuccess, logError, logDebug, logInit, logWarn } from './services/log-service.js'; import { logInfo, logSuccess, logError, logDebug, logInit, logWarn } from './services/log-service.js';
import { initSearxngModal, closeSearxngModal, loadSearxngConfig } from './components/searxng-modal.js';
import type { ChatSession } from './types.js'; import type { ChatSession } from './types.js';
// ─── v4.0 数据迁移:IndexedDB → SQLite ─── // ─── v4.0 数据迁移:IndexedDB → SQLite ───
@@ -337,12 +338,14 @@ async function init(): Promise<void> {
initTokenDashboard(); initTokenDashboard();
initWorkspacePanel(); initWorkspacePanel();
setupDesktopIntegration(); setupDesktopIntegration();
initSearxngModal();
bindGlobalEvents(); bindGlobalEvents();
await checkConnection(); await checkConnection();
await loadModels(); await loadModels();
await initMemoryManager(); await initMemoryManager();
await loadSearxngConfig(db);
logInit('所有组件已就绪'); logInit('所有组件已就绪');
const savedModel = await db.getSetting('selectedModel', ''); const savedModel = await db.getSetting('selectedModel', '');
@@ -458,6 +461,7 @@ function bindGlobalEvents(): void {
closeLightbox(); closeLightbox();
closeToolsModal(); closeToolsModal();
closeTokenDashboard(); closeTokenDashboard();
closeSearxngModal();
(document.querySelector('#helpModal') as HTMLElement).style.display = 'none'; (document.querySelector('#helpModal') as HTMLElement).style.display = 'none';
(document.querySelector('#memoryModal') as HTMLElement).style.display = 'none'; (document.querySelector('#memoryModal') as HTMLElement).style.display = 'none';
} }
+35
View File
@@ -1983,6 +1983,41 @@ html, body {
.messages { max-width: 900px; } .messages { max-width: 900px; }
} }
/* ═══ SearXNG 搜索配置 ═══ */
.searxng-btn {
position: relative;
}
.searxng-icon-dot {
position: absolute;
top: 4px;
right: 4px;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-disabled);
transition: var(--transition);
z-index: 2;
}
.searxng-icon-dot.enabled {
background: var(--success);
box-shadow: 0 0 6px rgba(74, 174, 107, 0.5);
}
.searxng-badge {
display: inline-flex;
align-items: center;
font-size: 13px;
font-weight: 600;
color: var(--text-tertiary);
transition: var(--transition);
}
.searxng-badge.enabled {
color: var(--success);
}
/* ── 选中文本 ── */ /* ── 选中文本 ── */
::selection { ::selection {
background: rgba(232, 115, 74, 0.2); background: rgba(232, 115, 74, 0.2);