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',
|
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,40 +345,46 @@ 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 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;
|
||||||
|
|
||||||
|
// 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();
|
const params = new URLSearchParams();
|
||||||
params.set('q', query);
|
params.set('q', query);
|
||||||
params.set('format', config.format || 'json');
|
params.set('format', config.format || 'json');
|
||||||
|
params.set('pageno', String(page));
|
||||||
if (config.engines) params.set('engines', config.engines);
|
if (config.engines) params.set('engines', config.engines);
|
||||||
if (config.language && config.language !== 'auto') params.set('language', config.language);
|
if (config.language && config.language !== 'auto') params.set('language', config.language);
|
||||||
params.set('safesearch', String(config.safesearch));
|
params.set('safesearch', String(config.safesearch));
|
||||||
const tr = timeRange || config.time_range;
|
|
||||||
if (tr) params.set('time_range', tr);
|
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 searchUrl = `${baseUrl}/search?${params.toString()}`;
|
||||||
const headers = buildSearXNGAuthHeaders(config.auth_key, config.auth_type);
|
logTool('web_search', `[SearXNG] Fetching page ${page}: ${searchUrl}`);
|
||||||
|
|
||||||
logTool('web_search', `[SearXNG] Fetching: ${searchUrl}`);
|
let pageResults: SearchResult[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
const response = await fetchWithTimeout(searchUrl, { headers }, 15_000);
|
const response = await fetchWithTimeout(searchUrl, { headers }, 15_000);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`SearXNG API error: ${response.status} ${response.statusText}`);
|
throw new Error(`SearXNG API error: ${response.status} ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const results: SearchResult[] = [];
|
|
||||||
const engineStats: Record<string, string> = {};
|
|
||||||
const engineCounts = new Map<string, number>();
|
|
||||||
|
|
||||||
if (config.format === 'html') {
|
if (config.format === 'html') {
|
||||||
const html = await response.text();
|
const html = await response.text();
|
||||||
// HTML 模式:简单提取链接和标题
|
|
||||||
const matches = html.matchAll(/<a[^>]*href="(https?:\/\/[^"]+)"[^>]*>([\s\S]*?)<\/a>/gi);
|
const matches = html.matchAll(/<a[^>]*href="(https?:\/\/[^"]+)"[^>]*>([\s\S]*?)<\/a>/gi);
|
||||||
for (const m of matches) {
|
for (const m of matches) {
|
||||||
const url = m[1];
|
const url = m[1];
|
||||||
const title = m[2].replace(/<[^>]+>/g, '').trim();
|
const title = m[2].replace(/<[^>]+>/g, '').trim();
|
||||||
if (title && url && !url.includes(config.url)) {
|
if (title && url && !url.includes(config.url)) {
|
||||||
results.push({ title, url, snippet: '', engine: 'searxng', weight: 85 });
|
pageResults.push({ title, url, snippet: '', engine: 'searxng', weight: 85 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -392,15 +394,33 @@ export class WebSearchTool implements IMetonaTool {
|
|||||||
const title = item.title as string;
|
const title = item.title as string;
|
||||||
const snippet = item.content as string;
|
const snippet = item.content as string;
|
||||||
const engine = (item.engine as string) ?? 'searxng';
|
const engine = (item.engine as string) ?? 'searxng';
|
||||||
results.push({ title, url, snippet: snippet ?? '', engine, weight: 85 });
|
pageResults.push({ title, url, snippet: snippet ?? '', engine, weight: 85 });
|
||||||
engineCounts.set(engine, (engineCounts.get(engine) ?? 0) + 1);
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 本页无新结果 → 已到末尾
|
||||||
|
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) {
|
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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user