125 lines
3.2 KiB
TypeScript
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 })),
|
|
},
|
|
};
|
|
}
|