diff --git a/electron/harness/tools/built-in/web-search.ts b/electron/harness/tools/built-in/web-search.ts index 14f40b3..3573332 100644 --- a/electron/harness/tools/built-in/web-search.ts +++ b/electron/harness/tools/built-in/web-search.ts @@ -228,10 +228,8 @@ export class WebSearchTool implements IMetonaTool { type: 'object', properties: { query: { type: 'string', description: 'Search query keywords' }, - max_results: { type: 'number', description: 'Maximum results (default 30, max 30)' }, time_range: { type: 'string', description: 'Time filter: day, week, month, year (optional)' }, enhance_snippets: { type: 'boolean', description: 'Auto-enhance short snippets (default true)' }, - fetch_top: { type: 'number', description: 'Auto-fetch full content for top N results (3-8, default 5)' }, }, required: ['query'], }, @@ -252,21 +250,19 @@ export class WebSearchTool implements IMetonaTool { // 读取 SearXNG 配置(提前读取,使工具参数默认值与配置一致) const searxngConfig = this.readSearXNGConfig(); - // max_results: 配置面板为下限,AI 参数不得低于配置值,上限 30 - const argMaxResults = (args.max_results as number) ?? undefined; - const cfgMaxResults = searxngConfig.max_results > 0 ? searxngConfig.max_results : undefined; - const maxResults = Math.min(30, Math.max(1, argMaxResults ?? 30, cfgMaxResults ?? 0)); + // max_results: 仅由配置面板控制,AI 不可覆盖 + const maxResults = Math.min(50, Math.max(1, searxngConfig.max_results > 0 ? searxngConfig.max_results : 30)); const timeRange = (args.time_range as string) ?? ''; const enhanceSnippets = (args.enhance_snippets as boolean) ?? true; - // fetch_top: 配置面板为下限,AI 参数不得低于配置值,上限 8 - const argFetchTop = (args.fetch_top as number) ?? undefined; - const cfgFetchCount = searxngConfig.fetch_count > 0 ? searxngConfig.fetch_count : undefined; - const fetchTop = Math.min(8, Math.max(3, argFetchTop ?? 5, cfgFetchCount ?? 0)); + // fetch_top: 仅由配置面板控制,AI 不可覆盖 + const fetchTop = Math.min(8, Math.max(3, searxngConfig.fetch_count > 0 ? searxngConfig.fetch_count : 5)); - // 缓存检查(缓存 key 含 SearXNG 模式,避免切换后返回旧缓存) - const cacheKey = `${searxngConfig.enabled ? 'searxng' : 'builtin'}:${normalizeUrl(query).toLowerCase()}`; + logTool('web_search', `Config: maxResults=${maxResults}, fetchTop=${fetchTop}, searxng=${searxngConfig.enabled}`); + + // 缓存检查(key 含模式 + maxResults + fetchTop,避免配置变更后返回旧缓存) + const cacheKey = `${searxngConfig.enabled ? 'searxng' : 'builtin'}:${maxResults}:${fetchTop}:${normalizeUrl(query).toLowerCase()}`; const cached = searchCache.get(cacheKey); if (cached) { logTool('web_search', `Cache hit: "${query}"`); @@ -349,58 +345,82 @@ export class WebSearchTool implements IMetonaTool { maxResults: number, timeRange: string, ): Promise<{ results: SearchResult[]; engineStats: Record }> { - const params = new URLSearchParams(); - params.set('q', query); - params.set('format', config.format || 'json'); - if (config.engines) params.set('engines', config.engines); - if (config.language && config.language !== 'auto') params.set('language', config.language); - params.set('safesearch', String(config.safesearch)); - const tr = timeRange || config.time_range; - if (tr) params.set('time_range', tr); - const limit = Math.max(maxResults, config.max_results); - if (limit > 0) params.set('results_count', String(limit)); - - const searchUrl = `${config.url.replace(/\/$/, '')}/search?${params.toString()}`; - const headers = buildSearXNGAuthHeaders(config.auth_key, config.auth_type); - - logTool('web_search', `[SearXNG] Fetching: ${searchUrl}`); - - const response = await fetchWithTimeout(searchUrl, { headers }, 15_000); - if (!response.ok) { - throw new Error(`SearXNG API error: ${response.status} ${response.statusText}`); - } - const results: SearchResult[] = []; const engineStats: Record = {}; const engineCounts = new Map(); + const baseUrl = config.url.replace(/\/$/, ''); + const headers = buildSearXNGAuthHeaders(config.auth_key, config.auth_type); + const tr = timeRange || config.time_range; - if (config.format === 'html') { - const html = await response.text(); - // HTML 模式:简单提取链接和标题 - const matches = html.matchAll(/]*href="(https?:\/\/[^"]+)"[^>]*>([\s\S]*?)<\/a>/gi); - for (const m of matches) { - const url = m[1]; - const title = m[2].replace(/<[^>]+>/g, '').trim(); - if (title && url && !url.includes(config.url)) { - results.push({ title, url, snippet: '', engine: 'searxng', weight: 85 }); + // SearXNG 标准分页:每页由实例配置决定(通常 10 条),用 pageno 翻页直到达到 maxResults + const maxPages = Math.ceil(maxResults / 5) + 1; // 保守估计,每页至少 5 条 + let page = 1; + + while (results.length < maxResults && page <= maxPages) { + const params = new URLSearchParams(); + params.set('q', query); + params.set('format', config.format || 'json'); + params.set('pageno', String(page)); + if (config.engines) params.set('engines', config.engines); + if (config.language && config.language !== 'auto') params.set('language', config.language); + params.set('safesearch', String(config.safesearch)); + if (tr) params.set('time_range', tr); + + const searchUrl = `${baseUrl}/search?${params.toString()}`; + logTool('web_search', `[SearXNG] Fetching page ${page}: ${searchUrl}`); + + let pageResults: SearchResult[] = []; + + try { + const response = await fetchWithTimeout(searchUrl, { headers }, 15_000); + if (!response.ok) { + throw new Error(`SearXNG API error: ${response.status} ${response.statusText}`); } + + if (config.format === 'html') { + const html = await response.text(); + const matches = html.matchAll(/]*href="(https?:\/\/[^"]+)"[^>]*>([\s\S]*?)<\/a>/gi); + for (const m of matches) { + const url = m[1]; + const title = m[2].replace(/<[^>]+>/g, '').trim(); + if (title && url && !url.includes(config.url)) { + pageResults.push({ title, url, snippet: '', engine: 'searxng', weight: 85 }); + } + } + } else { + const data = await response.json() as { results?: Array> }; + for (const item of data.results ?? []) { + const url = item.url as string; + const title = item.title as string; + const snippet = item.content as string; + const engine = (item.engine as string) ?? 'searxng'; + pageResults.push({ title, url, snippet: snippet ?? '', engine, weight: 85 }); + engineCounts.set(engine, (engineCounts.get(engine) ?? 0) + 1); + } + } + } catch (err) { + logTool('web_search', `[SearXNG] Page ${page} error: ${(err as Error).message}`); + // 第一页就失败则抛出,后续页失败则用已获取的结果 + if (page === 1) throw err; + break; } - } else { - const data = await response.json() as { results?: Array> }; - for (const item of data.results ?? []) { - const url = item.url as string; - const title = item.title as string; - const snippet = item.content as string; - const engine = (item.engine as string) ?? 'searxng'; - results.push({ title, url, snippet: snippet ?? '', engine, weight: 85 }); - engineCounts.set(engine, (engineCounts.get(engine) ?? 0) + 1); + + // 本页无新结果 → 已到末尾 + if (pageResults.length === 0) { + logTool('web_search', `[SearXNG] Page ${page} returned 0 results, stopping pagination`); + break; } + + results.push(...pageResults); + logTool('web_search', `[SearXNG] Page ${page}: +${pageResults.length} (total ${results.length})`); + page++; } for (const [engine, count] of engineCounts) { engineStats[engine] = `${count} 条`; } + logTool('web_search', `[SearXNG] Total: ${results.length} raw results (target ${maxResults})`); return { results, engineStats }; }