fix: SearXNG 配置完全失效 — 三层根因修复

根因1: 工具定义暴露 max_results/fetch_top 给 AI, AI 自行决定传 10 覆盖配置面板的 30

修复1: 从工具参数移除 max_results/fetch_top, 配置面板为唯一权威, AI 不可覆盖

根因2: 缓存 key 不含 maxResults/fetchTop, 配置变更后命中旧缓存返回错误结果数

修复2: 缓存 key 加入 maxResults 和 fetchTop

根因3: SearXNG API 用非标准 results_count 参数, 标准实例不认, 每页只返回约 10 条

修复3: 改用标准 pageno 分页, 多页获取直到达到 maxResults 或无更多结果

附加: 添加日志输出实际 maxResults/fetchTop 便于调试
This commit is contained in:
thzxx
2026-07-07 22:24:32 +08:00
parent 9b6bdaea32
commit b07e226bc1
+71 -51
View File
@@ -228,10 +228,8 @@ export class WebSearchTool implements IMetonaTool {
type: 'object', type: 'object',
properties: { properties: {
query: { type: 'string', description: 'Search query keywords' }, 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)' }, time_range: { type: 'string', description: 'Time filter: day, week, month, year (optional)' },
enhance_snippets: { type: 'boolean', description: 'Auto-enhance short snippets (default true)' }, 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'], required: ['query'],
}, },
@@ -252,21 +250,19 @@ export class WebSearchTool implements IMetonaTool {
// 读取 SearXNG 配置(提前读取,使工具参数默认值与配置一致) // 读取 SearXNG 配置(提前读取,使工具参数默认值与配置一致)
const searxngConfig = this.readSearXNGConfig(); const searxngConfig = this.readSearXNGConfig();
// max_results: 配置面板为下限AI 参数不得低于配置值,上限 30 // max_results: 仅由配置面板控制AI 不可覆盖
const argMaxResults = (args.max_results as number) ?? undefined; const maxResults = Math.min(50, Math.max(1, searxngConfig.max_results > 0 ? searxngConfig.max_results : 30));
const cfgMaxResults = searxngConfig.max_results > 0 ? searxngConfig.max_results : undefined;
const maxResults = Math.min(30, Math.max(1, argMaxResults ?? 30, cfgMaxResults ?? 0));
const timeRange = (args.time_range as string) ?? ''; const timeRange = (args.time_range as string) ?? '';
const enhanceSnippets = (args.enhance_snippets as boolean) ?? true; const enhanceSnippets = (args.enhance_snippets as boolean) ?? true;
// fetch_top: 配置面板为下限AI 参数不得低于配置值,上限 8 // fetch_top: 仅由配置面板控制AI 不可覆盖
const argFetchTop = (args.fetch_top as number) ?? undefined; const fetchTop = Math.min(8, Math.max(3, searxngConfig.fetch_count > 0 ? searxngConfig.fetch_count : 5));
const cfgFetchCount = searxngConfig.fetch_count > 0 ? searxngConfig.fetch_count : undefined;
const fetchTop = Math.min(8, Math.max(3, argFetchTop ?? 5, cfgFetchCount ?? 0));
// 缓存检查(缓存 key 含 SearXNG 模式,避免切换后返回旧缓存) logTool('web_search', `Config: maxResults=${maxResults}, fetchTop=${fetchTop}, searxng=${searxngConfig.enabled}`);
const cacheKey = `${searxngConfig.enabled ? 'searxng' : 'builtin'}:${normalizeUrl(query).toLowerCase()}`;
// 缓存检查(key 含模式 + maxResults + fetchTop,避免配置变更后返回旧缓存)
const cacheKey = `${searxngConfig.enabled ? 'searxng' : 'builtin'}:${maxResults}:${fetchTop}:${normalizeUrl(query).toLowerCase()}`;
const cached = searchCache.get(cacheKey); const cached = searchCache.get(cacheKey);
if (cached) { if (cached) {
logTool('web_search', `Cache hit: "${query}"`); logTool('web_search', `Cache hit: "${query}"`);
@@ -349,58 +345,82 @@ export class WebSearchTool implements IMetonaTool {
maxResults: number, maxResults: number,
timeRange: string, timeRange: string,
): Promise<{ results: SearchResult[]; engineStats: Record<string, string> }> { ): Promise<{ results: SearchResult[]; engineStats: Record<string, string> }> {
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 results: SearchResult[] = [];
const engineStats: Record<string, string> = {}; const engineStats: Record<string, string> = {};
const engineCounts = new Map<string, number>(); const engineCounts = new Map<string, number>();
const baseUrl = config.url.replace(/\/$/, '');
const headers = buildSearXNGAuthHeaders(config.auth_key, config.auth_type);
const tr = timeRange || config.time_range;
if (config.format === 'html') { // SearXNG 标准分页:每页由实例配置决定(通常 10 条),用 pageno 翻页直到达到 maxResults
const html = await response.text(); const maxPages = Math.ceil(maxResults / 5) + 1; // 保守估计,每页至少 5 条
// HTML 模式:简单提取链接和标题 let page = 1;
const matches = html.matchAll(/<a[^>]*href="(https?:\/\/[^"]+)"[^>]*>([\s\S]*?)<\/a>/gi);
for (const m of matches) { while (results.length < maxResults && page <= maxPages) {
const url = m[1]; const params = new URLSearchParams();
const title = m[2].replace(/<[^>]+>/g, '').trim(); params.set('q', query);
if (title && url && !url.includes(config.url)) { params.set('format', config.format || 'json');
results.push({ title, url, snippet: '', engine: 'searxng', weight: 85 }); 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(/<a[^>]*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<Record<string, unknown>> };
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<Record<string, unknown>> }; // 本页无新结果 → 已到末尾
for (const item of data.results ?? []) { if (pageResults.length === 0) {
const url = item.url as string; logTool('web_search', `[SearXNG] Page ${page} returned 0 results, stopping pagination`);
const title = item.title as string; break;
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);
} }
results.push(...pageResults);
logTool('web_search', `[SearXNG] Page ${page}: +${pageResults.length} (total ${results.length})`);
page++;
} }
for (const [engine, count] of engineCounts) { for (const [engine, count] of engineCounts) {
engineStats[engine] = `${count}`; engineStats[engine] = `${count}`;
} }
logTool('web_search', `[SearXNG] Total: ${results.length} raw results (target ${maxResults})`);
return { results, engineStats }; return { results, engineStats };
} }