Files
platform/seed/tools/web-search/index.ts
T

125 lines
3.2 KiB
TypeScript

const TIMEOUT_MS = 10_000;
const DEFAULT_MAX_RESULTS = 10;
const HARD_MAX_RESULTS = 20;
type SearxngResult = {
title: string;
url: string;
content?: string;
engine?: string;
score?: number;
};
type SearxngResponse = {
query: string;
number_of_results: number;
results: SearxngResult[];
};
type OnUpdate = (partial: { content: Array<{ type: string; text: string }> }) => void;
function update(onUpdate: OnUpdate | undefined, text: string): void {
onUpdate?.({ content: [{ type: 'text', text }] });
}
function formatResults(results: SearxngResult[]): string {
if (results.length === 0) return 'No results found.';
return results
.map((r, i) => {
const lines = [`${i + 1}. **${r.title}**`, ` ${r.url}`];
if (r.content?.trim()) lines.push(` ${r.content.trim()}`);
return lines.join('\n');
})
.join('\n\n');
}
export async function execute(
_toolCallId: string,
params: { query: string; max_results?: number },
_signal: AbortSignal | undefined,
onUpdate?: OnUpdate,
) {
const searxngUrl = process.env.PI_SEARXNG_URL;
if (!searxngUrl) {
return {
content: [{ type: 'text', text: 'Web search is not configured. PI_SEARXNG_URL is not set.' }],
details: { error: 'not_configured' },
isError: true,
};
}
const { query } = params;
const maxResults = Math.min(params.max_results ?? DEFAULT_MAX_RESULTS, HARD_MAX_RESULTS);
if (!query?.trim()) {
return {
content: [{ type: 'text', text: 'Query cannot be empty.' }],
details: { error: 'empty_query' },
isError: true,
};
}
update(onUpdate, `Searching for: ${query}`);
const searchUrl = new URL('/search', searxngUrl);
searchUrl.searchParams.set('q', query);
searchUrl.searchParams.set('format', 'json');
searchUrl.searchParams.set('categories', 'general');
let response: Response;
try {
response = await fetch(searchUrl.toString(), {
signal: AbortSignal.timeout(TIMEOUT_MS),
headers: { Accept: 'application/json' },
});
} catch (err) {
return {
content: [{ type: 'text', text: `Failed to reach SearXNG at ${searxngUrl}: ${String(err)}` }],
details: { error: 'fetch_failed', url: searxngUrl },
isError: true,
};
}
if (!response.ok) {
return {
content: [{ type: 'text', text: `SearXNG returned HTTP ${response.status}` }],
details: { error: 'http_error', status: response.status },
isError: true,
};
}
let data: SearxngResponse;
try {
data = (await response.json()) as SearxngResponse;
} catch {
return {
content: [{ type: 'text', text: 'SearXNG returned an invalid response.' }],
details: { error: 'invalid_json' },
isError: true,
};
}
const results = (data.results ?? []).slice(0, maxResults);
update(onUpdate, `Found ${data.number_of_results ?? results.length} results, returning top ${results.length}`);
const output = [
`Search: "${query}"`,
`Results: ${results.length}`,
``,
formatResults(results),
].join('\n');
return {
content: [{ type: 'text', text: output }],
details: {
query,
total: data.number_of_results ?? results.length,
returned: results.length,
results: results.map((r) => ({ title: r.title, url: r.url })),
},
};
}