diff --git a/src/main/tool-handlers-system.ts b/src/main/tool-handlers-system.ts index 5dd31e5..3207da1 100644 --- a/src/main/tool-handlers-system.ts +++ b/src/main/tool-handlers-system.ts @@ -9,6 +9,7 @@ import { checkPathAllowed, checkCommandAllowed } from './tool-security.js'; import { mainWindow } from './main.js'; import { sendLog, resolvePath, type ToolResult } from './tool-handlers-shared.js'; import { getWorkspaceDir } from './workspace.js'; +import { getSetting } from './db/sqlite.js'; /** 当前工具命令进程(用于用户手动终止) */ let _toolProc: ReturnType | null = null; @@ -435,9 +436,114 @@ export async function handleWebFetch(params: { url: string; max_chars?: number; return { success: false, error: lastError || '所有重试均失败' }; } +/** + * SearXNG 元搜索引擎 — JSON API 搜索 + * 支持 70+ 引擎聚合、隐私保护、无广告 + */ +async function handleWebSearchSearxng( + query: string, + maxResults: number, + timeRange: string, + _enhanceSnippets: boolean +): Promise { + const url = getSetting('searxng_url', 'https://search.metona.cn'); + const engines = getSetting('searxng_engines', 'google,bing,duckduckgo,wikipedia'); + const language = getSetting('searxng_language', 'zh-CN'); + const safesearch = getSetting('searxng_safesearch', 1); + const cfgTimeRange = getSetting('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('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 = {}; + 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 缓存 + 智能排序 + 时间过滤 + 摘要增强) * 同时请求 Bing / 百度 / 搜狗 / 360搜索,智能排序后返回 + * 如果启用了 SearXNG,则使用 SearXNG JSON API 替代 */ export async function handleWebSearch(params: { query: string; max_results?: number; time_range?: string; enhance_snippets?: boolean }): Promise { 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 timeRange = params.time_range || ''; const enhanceSnippets = params.enhance_snippets !== false; // 默认开启 + + // ── SearXNG 模式:如果启用了 SearXNG,走 JSON API ── + const searxngEnabled = getSetting('searxng_enabled', false); + if (searxngEnabled) { + return handleWebSearchSearxng(query, maxResults, timeRange, enhanceSnippets); + } + const cacheKey = `${query}|${maxResults}|${timeRange}`; // ── 时间范围 → URL 参数映射 ── diff --git a/src/renderer/components/searxng-modal.ts b/src/renderer/components/searxng-modal.ts new file mode 100644 index 0000000..03a30af --- /dev/null +++ b/src/renderer/components/searxng-modal.ts @@ -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 { + 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 { + 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(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(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(KEYS.DB); + if (db) await saveConfig(db); + updateEnabledUI(searxngConfig.enabled); + }); + + initialized = true; +} + +/** 外部调用关闭模态框(ESC 键等) */ +export function closeSearxngModal(): void { + if (modalEl) modalEl.style.display = 'none'; +} diff --git a/src/renderer/index.html b/src/renderer/index.html index ba38dd9..e51db8f 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -72,6 +72,12 @@ + @@ -699,6 +705,100 @@
+ + + diff --git a/src/renderer/main.ts b/src/renderer/main.ts index 08e5a09..e79513e 100644 --- a/src/renderer/main.ts +++ b/src/renderer/main.ts @@ -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 { 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'; } diff --git a/src/renderer/styles/style.css b/src/renderer/styles/style.css index 452d4f6..ceb4b04 100644 --- a/src/renderer/styles/style.css +++ b/src/renderer/styles/style.css @@ -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);