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:
@@ -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<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 engineStats: Record<string, string> = {};
|
||||
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') {
|
||||
const html = await response.text();
|
||||
// HTML 模式:简单提取链接和标题
|
||||
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)) {
|
||||
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(/<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 ?? []) {
|
||||
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 };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user