Files
archy/packages/app/vite-web-search.ts
T
DorianandClaude Opus 4.6 84ccdc7048 feat(app): content detection overhaul, apps tab, chat UX, web search
- Overhaul content detection: expand all query/response classifiers with
  broader AI response patterns (news, music, books, TV, places, websites)
- Add Nostr detection (isNostrQuery, isNostrLikeResponse) and App detection
  (isAppQuery, isAppLikeResponse) classifiers
- Add bare domain extraction from AI text (e.g. "check out damus.io")
- Add Apps tab with curated database of ~30 Nostr + Bitcoin ecosystem apps
  (clients, wallets, privacy tools, node software, dev tools)
- Create AppsGrid + AppDetail components with search, filtering, how-to
- Wire app extraction and Nostr detection into useContentPanel
- Add PromptIndex badges for Apps and Nostr tabs
- Chat UX: dedicated history button, settings modal (memory + advanced),
  fix collapsed chat v-if/v-else chain bug
- Web search: add Brave Search API as primary backend, expand SearXNG pool
- iOS HIG: comprehensive mobile UX rules in cursor rules + CLAUDE.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 05:03:03 +00:00

205 lines
7.2 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
}
// SearXNG public instances — rotated on each request to spread load
const SEARXNG_INSTANCES = [
'https://searx.tiekoetter.com',
'https://search.bus-hit.me',
'https://paulgo.io',
'https://search.sapti.me',
'https://search.ononoki.org',
'https://priv.au',
'https://opnxng.com',
'https://etsi.me',
]
// Rotate starting instance per-request to avoid hammering a single one
let instanceRotation = 0
async function fetchFromSearXNG(
searchUrl: 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
const text = await searchRes.text()
// Guard against HTML responses from captcha/blocking pages
if (text.startsWith('<') || text.startsWith('<!')) return null
return JSON.parse(text)
} catch {
return null
}
}
/** Brave Search API — free tier (2000 queries/month). Set BRAVE_SEARCH_API_KEY in .env.local */
async function fetchFromBrave(
q: string,
apiKey: string,
): Promise<WebSearchResult[]> {
try {
const res = await fetch(
`https://api.search.brave.com/res/v1/web/search?${new URLSearchParams({ q, count: '6' })}`,
{
headers: {
Accept: 'application/json',
'Accept-Encoding': 'gzip',
'X-Subscription-Token': apiKey,
},
signal: AbortSignal.timeout(8000),
},
)
if (!res.ok) return []
const data = (await res.json()) as {
web?: { results?: { title?: string; url?: string; description?: string; thumbnail?: { src?: string } }[] }
}
return (data.web?.results ?? [])
.filter((r) => r.title && r.url)
.slice(0, 6)
.map((r) => ({
title: r.title ?? '',
url: r.url ?? '',
content: r.description ?? undefined,
imgSrc: r.thumbnail?.src ?? undefined,
engine: 'brave',
}))
} catch {
return []
}
}
function createWebSearchMiddleware(searxUrl: string | undefined, braveApiKey: 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 sendResults = (results: WebSearchResult[]) => {
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 }))
}
// 1. Brave Search API (most reliable if configured)
if (braveApiKey) {
console.log('[web-search]', q.slice(0, 40), '→ Brave API')
const braveResults = await fetchFromBrave(q, braveApiKey)
if (braveResults.length > 0) {
console.log('[web-search]', braveResults.length, 'results from Brave')
sendResults(braveResults)
return
}
console.warn('[web-search] Brave API returned no results, falling through')
}
// 2. SearXNG instances (rotate to spread load)
const instances = searxUrl ? [searxUrl] : SEARXNG_INSTANCES
const startIdx = instanceRotation % instances.length
instanceRotation++
let data: { results?: { title?: string; url?: string; content?: string; img_src?: string; thumbnail?: string; engine?: string }[] } | null = null
let lastError = ''
for (let i = 0; i < instances.length; i++) {
const baseUrl = instances[(startIdx + i) % instances.length].replace(/\/$/, '')
const searchUrl = `${baseUrl}/search?${new URLSearchParams({ q, format: 'json', pageno: '1' })}`
console.log('[web-search]', q.slice(0, 40), '→', baseUrl)
data = await fetchFromSearXNG(searchUrl)
if (data?.results && data.results.length > 0) {
console.log('[web-search]', data.results.length, 'results from', baseUrl)
break
}
lastError = data ? 'no results' : 'request failed'
console.warn('[web-search]', baseUrl, lastError, '— trying next')
data = null
}
if (data?.results) {
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,
}))
sendResults(results)
return
}
// 3. DuckDuckGo fallback
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')
sendResults(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. Set BRAVE_SEARCH_API_KEY or SEARXNG_URL in .env.local.' }))
}
}
export function webSearchPlugin(): Plugin {
let searxUrl: string | undefined
let braveApiKey: string | undefined
return {
name: 'aiui-web-search',
configResolved(config) {
const env = loadEnv(config.mode, process.cwd(), '')
searxUrl = env.SEARXNG_URL ?? env.VITE_SEARXNG_URL
braveApiKey = env.BRAVE_SEARCH_API_KEY ?? env.VITE_BRAVE_SEARCH_API_KEY
if (braveApiKey) {
console.log('[web-search] Brave Search API configured')
} else if (searxUrl) {
console.log('[web-search] SearXNG:', searxUrl)
} else {
console.log('[web-search] No search API key configured — using public SearXNG instances (unreliable)')
console.log('[web-search] For reliable search, set BRAVE_SEARCH_API_KEY in .env.local (free: https://brave.com/search/api/)')
}
},
configureServer(server) {
server.middlewares.use('/api/web-search', createWebSearchMiddleware(searxUrl, braveApiKey))
},
configurePreviewServer(server) {
server.middlewares.use('/api/web-search', createWebSearchMiddleware(searxUrl, braveApiKey))
},
}
}