Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
215 lines
6.8 KiB
TypeScript
215 lines
6.8 KiB
TypeScript
/**
|
|
* Web search provider implementations.
|
|
*
|
|
* Each provider function normalises its API response into a common
|
|
* `{ results: Array<{ title, url, content }> }` shape so callers don't need
|
|
* to know about provider-specific quirks.
|
|
*
|
|
* All HTTP requests go through `bridge.aiFetch()` to avoid CORS issues in the
|
|
* renderer process.
|
|
*/
|
|
|
|
import type { NetcattyBridge } from '../cattyAgent/executor';
|
|
import type { WebSearchConfig } from '../types';
|
|
import { WEB_SEARCH_PROVIDER_PRESETS } from '../types';
|
|
|
|
export interface WebSearchResult {
|
|
title: string;
|
|
url: string;
|
|
content: string;
|
|
}
|
|
|
|
interface BridgeFetchResponse {
|
|
ok: boolean;
|
|
status?: number;
|
|
data?: string;
|
|
error?: string;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helper
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function resolveApiHost(config: WebSearchConfig): string {
|
|
return config.apiHost || WEB_SEARCH_PROVIDER_PRESETS[config.providerId].defaultApiHost;
|
|
}
|
|
|
|
async function fetchJson(
|
|
bridge: NetcattyBridge,
|
|
url: string,
|
|
method: string,
|
|
headers: Record<string, string>,
|
|
body?: string,
|
|
): Promise<unknown> {
|
|
const aiFetch = (bridge as unknown as Record<string, (...args: unknown[]) => Promise<unknown>>).aiFetch;
|
|
if (!aiFetch) throw new Error('aiFetch is not available on the bridge');
|
|
// Search API hosts are added to the allowlist via aiSyncWebSearch, no skipHostCheck needed
|
|
const resp = await aiFetch(url, method, headers, body) as BridgeFetchResponse;
|
|
if (!resp.ok) throw new Error(resp.error || `HTTP ${resp.status}`);
|
|
return JSON.parse(resp.data || '{}');
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tavily
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function searchTavily(
|
|
bridge: NetcattyBridge,
|
|
config: WebSearchConfig,
|
|
query: string,
|
|
maxResults: number,
|
|
): Promise<WebSearchResult[]> {
|
|
const host = resolveApiHost(config);
|
|
const data = await fetchJson(bridge, `${host}/search`, 'POST', {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${config.apiKey}`,
|
|
}, JSON.stringify({
|
|
query,
|
|
max_results: maxResults,
|
|
search_depth: 'basic',
|
|
})) as { results?: Array<{ title?: string; url?: string; content?: string }> };
|
|
|
|
return (data.results || []).map(r => ({
|
|
title: r.title || '',
|
|
url: r.url || '',
|
|
content: r.content || '',
|
|
}));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Exa
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function searchExa(
|
|
bridge: NetcattyBridge,
|
|
config: WebSearchConfig,
|
|
query: string,
|
|
maxResults: number,
|
|
): Promise<WebSearchResult[]> {
|
|
const host = resolveApiHost(config);
|
|
const data = await fetchJson(bridge, `${host}/search`, 'POST', {
|
|
'Content-Type': 'application/json',
|
|
'x-api-key': config.apiKey || '',
|
|
}, JSON.stringify({
|
|
query,
|
|
numResults: maxResults,
|
|
contents: { text: true },
|
|
})) as { results?: Array<{ title?: string; url?: string; text?: string }> };
|
|
|
|
return (data.results || []).map(r => ({
|
|
title: r.title || '',
|
|
url: r.url || '',
|
|
content: r.text || '',
|
|
}));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bocha
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function searchBocha(
|
|
bridge: NetcattyBridge,
|
|
config: WebSearchConfig,
|
|
query: string,
|
|
maxResults: number,
|
|
): Promise<WebSearchResult[]> {
|
|
const host = resolveApiHost(config);
|
|
const data = await fetchJson(bridge, `${host}/v1/web-search`, 'POST', {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${config.apiKey}`,
|
|
}, JSON.stringify({
|
|
query,
|
|
count: maxResults,
|
|
summary: true,
|
|
})) as { webPages?: { value?: Array<{ name?: string; url?: string; snippet?: string; summary?: string }> } };
|
|
|
|
return (data.webPages?.value || []).map(r => ({
|
|
title: r.name || '',
|
|
url: r.url || '',
|
|
content: r.summary || r.snippet || '',
|
|
}));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Zhipu
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function searchZhipu(
|
|
bridge: NetcattyBridge,
|
|
config: WebSearchConfig,
|
|
query: string,
|
|
_maxResults: number,
|
|
): Promise<WebSearchResult[]> {
|
|
const host = resolveApiHost(config);
|
|
const data = await fetchJson(bridge, `${host}/web_search`, 'POST', {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${config.apiKey}`,
|
|
}, JSON.stringify({
|
|
search_query: query,
|
|
search_engine: 'search_std',
|
|
})) as { search_result?: Array<{ title?: string; link?: string; content?: string }> };
|
|
|
|
return (data.search_result || []).map(r => ({
|
|
title: r.title || '',
|
|
url: r.link || '',
|
|
content: r.content || '',
|
|
}));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SearXNG
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function searchSearxng(
|
|
bridge: NetcattyBridge,
|
|
config: WebSearchConfig,
|
|
query: string,
|
|
_maxResults: number,
|
|
): Promise<WebSearchResult[]> {
|
|
const host = resolveApiHost(config);
|
|
if (!host) throw new Error('SearXNG requires an API Host to be configured');
|
|
const url = `${host}/search?q=${encodeURIComponent(query)}&format=json`;
|
|
const data = await fetchJson(bridge, url, 'GET', {}) as {
|
|
results?: Array<{ title?: string; url?: string; content?: string }>;
|
|
};
|
|
|
|
return (data.results || []).map(r => ({
|
|
title: r.title || '',
|
|
url: r.url || '',
|
|
content: r.content || '',
|
|
}));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Dispatcher
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const PROVIDER_SEARCH_FNS: Record<string, typeof searchTavily> = {
|
|
tavily: searchTavily,
|
|
exa: searchExa,
|
|
bocha: searchBocha,
|
|
zhipu: searchZhipu,
|
|
searxng: searchSearxng,
|
|
};
|
|
|
|
/**
|
|
* Placeholder token for the web search API key.
|
|
* The renderer sends this in HTTP headers; the main process replaces it
|
|
* with the real decrypted key before the request is sent, so plaintext
|
|
* keys never enter the renderer.
|
|
*/
|
|
const WEB_SEARCH_KEY_PLACEHOLDER = '__WEB_SEARCH_KEY__';
|
|
|
|
export async function executeWebSearchProvider(
|
|
bridge: NetcattyBridge,
|
|
config: WebSearchConfig,
|
|
query: string,
|
|
maxResults: number,
|
|
): Promise<WebSearchResult[]> {
|
|
const fn = PROVIDER_SEARCH_FNS[config.providerId];
|
|
if (!fn) throw new Error(`Unsupported web search provider: ${config.providerId}`);
|
|
// Use placeholder — main process replaces with real decrypted key before HTTP request
|
|
const safeConfig = { ...config, apiKey: WEB_SEARCH_KEY_PLACEHOLDER };
|
|
return fn(bridge, safeConfig, query, maxResults);
|
|
}
|