import type { Plugin } from 'vite' import type { Connect } from 'vite' import { loadEnv } from 'vite' import { search as searchDuckDuckGo } from 'duck-duck-scrape' export interface WebSearchResult { title: string url: string content?: string imgSrc?: string engine?: string } const FALLBACK_INSTANCES = [ 'https://searx.tiekoetter.com', 'https://search.bus-hit.me', 'https://paulgo.io', ] async function fetchFromSearXNG( searchUrl: string, q: string, ): Promise<{ results?: { title?: string; url?: string; content?: string; img_src?: string; thumbnail?: string; engine?: string }[] } | null> { try { const searchRes = await fetch(searchUrl, { headers: { Accept: 'application/json', 'User-Agent': 'AIUI/1.0' }, signal: AbortSignal.timeout(6000), }) if (!searchRes.ok) return null return (await searchRes.json()) as { results?: { title?: string; url?: string; content?: string; img_src?: string; thumbnail?: string; engine?: string }[] } } catch { return null } } function createWebSearchMiddleware(searxUrl: string | undefined) { return async (req: Connect.IncomingMessage, res: any, next: () => void) => { if (req.method !== 'GET') return next() const url = new URL(req.url ?? '', `http://${req.headers?.host ?? 'localhost'}`) const q = url.searchParams.get('q')?.trim() if (!q) { res.writeHead(400, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: 'Missing q (query)' })) return } const instances = searxUrl ? [searxUrl] : FALLBACK_INSTANCES let data: { results?: { title?: string; url?: string; content?: string; img_src?: string; thumbnail?: string; engine?: string }[] } | null = null let lastError = '' for (const baseUrl of instances) { const normalized = baseUrl.replace(/\/$/, '') const searchUrl = `${normalized}/search?${new URLSearchParams({ q, format: 'json', pageno: '1' })}` console.log('[web-search]', q.slice(0, 40), '→', normalized) data = await fetchFromSearXNG(searchUrl, q) if (data?.results && data.results.length > 0) { console.log('[web-search]', data.results.length, 'results from', normalized) break } lastError = data ? 'no results' : 'request failed' console.warn('[web-search]', normalized, lastError, '— trying next') } if (!data) { console.warn('[web-search] SearXNG failed, trying DuckDuckGo fallback') try { const ddg = await searchDuckDuckGo(q) if (ddg?.results?.length) { const results: WebSearchResult[] = ddg.results .filter((r: { title?: string; url?: string }) => r.title && r.url) .slice(0, 6) .map((r: { title: string; url: string; description?: string; icon?: string }) => ({ title: r.title, url: r.url, content: r.description ?? undefined, imgSrc: r.icon ?? undefined, })) console.log('[web-search] DuckDuckGo fallback:', results.length, 'results') res.setHeader('Content-Type', 'application/json') res.setHeader('Access-Control-Allow-Origin', '*') res.setHeader('Cache-Control', 'public, max-age=300') res.end(JSON.stringify({ results })) return } } catch (ddgErr) { console.warn('[web-search] DuckDuckGo fallback failed:', ddgErr) } console.error('[web-search] All search backends failed:', lastError) res.writeHead(502, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: 'Web search unavailable. Try SEARXNG_URL in .env.local.' })) return } try { const results: WebSearchResult[] = (data.results ?? []) .filter((r) => r.title && r.url) .slice(0, 6) .map((r) => ({ title: r.title ?? '', url: r.url ?? '', content: r.content ?? undefined, imgSrc: r.img_src || r.thumbnail || undefined, engine: r.engine ?? undefined, })) res.setHeader('Content-Type', 'application/json') res.setHeader('Access-Control-Allow-Origin', '*') res.setHeader('Cache-Control', 'public, max-age=300') res.end(JSON.stringify({ results })) } catch (err) { console.error('[web-search]', err) res.writeHead(502, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: String(err) })) } } } export function webSearchPlugin(): Plugin { let searxUrl: string | undefined return { name: 'aiui-web-search', configResolved(config) { const env = loadEnv(config.mode, process.cwd(), '') searxUrl = env.SEARXNG_URL ?? env.VITE_SEARXNG_URL }, configureServer(server) { server.middlewares.use('/api/web-search', createWebSearchMiddleware(searxUrl)) }, configurePreviewServer(server) { server.middlewares.use('/api/web-search', createWebSearchMiddleware(searxUrl)) }, } }