API: /answer retrieves top-6 from Meili and has Claude (haiku-4.5) synthesize a 2-4 sentence cited answer over ONLY those sources (no hallucinated facts/URLs), with a 1h in-memory cache. Frontend: async answer card above results with inline [n] citations linking to sources — loads without blocking the results render. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
165 lines
5.3 KiB
JavaScript
165 lines
5.3 KiB
JavaScript
import { Hono } from 'hono'
|
|
import { serve } from '@hono/node-server'
|
|
|
|
const app = new Hono()
|
|
const PORT = parseInt(process.env.PORT || '3000')
|
|
const MEILI_URL = process.env.MEILI_URL || 'http://localhost:7700'
|
|
const MEILI_KEY = process.env.MEILI_KEY || 'masterKey'
|
|
const NODE_ID = process.env.NODE_ID || 'main'
|
|
|
|
// In-memory peer registry { id, url, lastSeen }
|
|
const peers = new Map()
|
|
|
|
async function meiliSearch(q, offset = 0, limit = 10) {
|
|
const res = await fetch(`${MEILI_URL}/indexes/pages/search`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${MEILI_KEY}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
q,
|
|
limit,
|
|
offset,
|
|
attributesToHighlight: ['title', 'description'],
|
|
highlightPreTag: '<mark>',
|
|
highlightPostTag: '</mark>',
|
|
attributesToRetrieve: ['url', 'domain', 'title', 'description', 'crawledAt'],
|
|
}),
|
|
})
|
|
return res.json()
|
|
}
|
|
|
|
async function queryPeer(peer, q) {
|
|
try {
|
|
const res = await fetch(`${peer.url}/search?q=${encodeURIComponent(q)}&local=1`, {
|
|
signal: AbortSignal.timeout(3000),
|
|
headers: { 'X-Peer-Id': NODE_ID },
|
|
})
|
|
const data = await res.json()
|
|
return data.hits ?? []
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
// Search endpoint — fans out to peers when not local
|
|
app.get('/search', async (c) => {
|
|
const q = c.req.query('q')?.trim()
|
|
const page = Math.max(0, parseInt(c.req.query('page') ?? '0'))
|
|
const local = c.req.query('local') === '1'
|
|
|
|
if (!q) return c.json({ error: 'missing q' }, 400)
|
|
|
|
const offset = page * 10
|
|
const local$ = meiliSearch(q, offset)
|
|
const peer$ = local ? [] : [...peers.values()].map(p => queryPeer(p, q))
|
|
|
|
const [localResult, ...peerResults] = await Promise.all([local$, ...peer$])
|
|
|
|
// Deduplicate and merge peer hits
|
|
const seen = new Set(localResult.hits?.map(h => h.url) ?? [])
|
|
const peerHits = peerResults
|
|
.flat()
|
|
.filter(h => !seen.has(h.url))
|
|
.slice(0, 5)
|
|
|
|
const hits = [...(localResult.hits ?? []), ...peerHits]
|
|
|
|
return c.json({
|
|
hits,
|
|
total: (localResult.estimatedTotalHits ?? 0) + peerHits.length,
|
|
page,
|
|
query: q,
|
|
nodeId: NODE_ID,
|
|
peers: [...peers.keys()],
|
|
})
|
|
})
|
|
|
|
// AI answer box — cited RAG over the index with Claude.
|
|
const ANTHROPIC_KEY = process.env.ANTHROPIC_API_KEY
|
|
const ANSWER_MODEL = process.env.ANSWER_MODEL || 'claude-haiku-4-5'
|
|
const answerCache = new Map() // q -> { at, payload }
|
|
|
|
app.get('/answer', async (c) => {
|
|
const q = c.req.query('q')?.trim()
|
|
if (!q) return c.json({ error: 'missing q' }, 400)
|
|
if (!ANTHROPIC_KEY) return c.json({ error: 'answers disabled' }, 503)
|
|
|
|
const key = q.toLowerCase()
|
|
const cached = answerCache.get(key)
|
|
if (cached && Date.now() - cached.at < 3_600_000) return c.json(cached.payload)
|
|
|
|
const result = await meiliSearch(q, 0, 6)
|
|
const hits = result.hits ?? []
|
|
if (hits.length === 0) return c.json({ answer: null, sources: [], query: q })
|
|
|
|
const sources = hits.map((h, i) => ({
|
|
n: i + 1, title: h.title, url: h.url, domain: h.domain,
|
|
snippet: (h.description || '').slice(0, 300),
|
|
}))
|
|
const context = sources.map(s => `[${s.n}] ${s.title} — ${s.domain}\n${s.snippet}`).join('\n\n')
|
|
|
|
try {
|
|
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
|
method: 'POST',
|
|
headers: {
|
|
'x-api-key': ANTHROPIC_KEY,
|
|
'anthropic-version': '2023-06-01',
|
|
'content-type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
model: ANSWER_MODEL,
|
|
max_tokens: 400,
|
|
system: 'You are a search answer engine over the open web. Answer the query in 2-4 concise sentences using ONLY the numbered sources. Cite claims inline like [1][2]. If the sources do not contain the answer, say so briefly. Never invent facts or URLs.',
|
|
messages: [{ role: 'user', content: `Query: ${q}\n\nSources:\n${context}` }],
|
|
}),
|
|
signal: AbortSignal.timeout(20000),
|
|
})
|
|
const data = await res.json()
|
|
const answer = data.content?.map(b => b.text).join('') ?? null
|
|
const payload = { answer, sources, query: q, model: ANSWER_MODEL }
|
|
answerCache.set(key, { at: Date.now(), payload })
|
|
return c.json(payload)
|
|
} catch (e) {
|
|
return c.json({ answer: null, sources, query: q, error: e.message }, 502)
|
|
}
|
|
})
|
|
|
|
// P2P: register as peer
|
|
app.post('/peers/register', async (c) => {
|
|
const { id, url } = await c.req.json()
|
|
if (!id || !url) return c.json({ error: 'id and url required' }, 400)
|
|
peers.set(id, { id, url, lastSeen: Date.now() })
|
|
return c.json({ ok: true, peerId: NODE_ID, peers: [...peers.keys()] })
|
|
})
|
|
|
|
// P2P: list peers
|
|
app.get('/peers', (c) => {
|
|
return c.json({ nodeId: NODE_ID, peers: [...peers.values()] })
|
|
})
|
|
|
|
// Health
|
|
app.get('/health', async (c) => {
|
|
try {
|
|
const res = await fetch(`${MEILI_URL}/health`, {
|
|
headers: { 'Authorization': `Bearer ${MEILI_KEY}` }
|
|
})
|
|
const meili = await res.json()
|
|
return c.json({ ok: true, meili, nodeId: NODE_ID })
|
|
} catch (e) {
|
|
return c.json({ ok: false, error: e.message }, 503)
|
|
}
|
|
})
|
|
|
|
// Stats
|
|
app.get('/stats', async (c) => {
|
|
const res = await fetch(`${MEILI_URL}/indexes/pages/stats`, {
|
|
headers: { 'Authorization': `Bearer ${MEILI_KEY}` }
|
|
})
|
|
const stats = await res.json()
|
|
return c.json({ ...stats, peers: peers.size, nodeId: NODE_ID })
|
|
})
|
|
|
|
serve({ fetch: app.fetch, port: PORT })
|
|
console.log(`Search API running on :${PORT} [node=${NODE_ID}]`)
|