- Updated ChatMessage and ChatWindow components to support inline web search results and articles. - Integrated new web search and RSS plugins into the chat system for real-time information retrieval. - Enhanced useContentPanel to manage web search results alongside existing media types. - Added ArticleOverlay component for displaying selected articles from search results. - Improved UI elements and styles for better user interaction with web search features. Made-with: Cursor
136 lines
4.9 KiB
TypeScript
136 lines
4.9 KiB
TypeScript
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))
|
|
},
|
|
}
|
|
}
|