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
+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"/>
</svg>
</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>
</header>
@@ -699,6 +705,100 @@
<!-- ═══════════════ Toast 通知容器 ═══════════════ -->
<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>
</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 { initLogPanel, addLog } 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';
// ─── v4.0 数据迁移:IndexedDB → SQLite ───
@@ -337,12 +338,14 @@ async function init(): Promise<void> {
initTokenDashboard();
initWorkspacePanel();
setupDesktopIntegration();
initSearxngModal();
bindGlobalEvents();
await checkConnection();
await loadModels();
await initMemoryManager();
await loadSearxngConfig(db);
logInit('所有组件已就绪');
const savedModel = await db.getSetting('selectedModel', '');
@@ -458,6 +461,7 @@ function bindGlobalEvents(): void {
closeLightbox();
closeToolsModal();
closeTokenDashboard();
closeSearxngModal();
(document.querySelector('#helpModal') 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; }
}
/* ═══ 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 {
background: rgba(232, 115, 74, 0.2);