Add AI answer box: cited Claude RAG over the index

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>
This commit is contained in:
King Omar 2026-07-22 02:30:07 +10:00
parent c8cb4cbbe4
commit 83329d0d58
2 changed files with 90 additions and 0 deletions

View file

@ -76,6 +76,56 @@ app.get('/search', async (c) => {
})
})
// 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()

View file

@ -63,6 +63,16 @@ const CSS = `
}
.results-logo { font-size: 20px; font-weight: 700; white-space: nowrap; color: var(--title); }
.results-logo span { color: var(--accent); }
/* AI answer card */
.answer-card { background: var(--surface); border: 1px solid var(--border); border-left: 3px solid var(--accent); padding: 15px 18px; margin: 0 0 24px; }
.answer-card.hidden { display: none; }
.answer-label { font-size: 10px; letter-spacing: 2px; text-transform: uppercase; color: var(--accent); margin-bottom: 9px; }
.answer-body { font-size: 14px; line-height: 1.65; color: var(--text); }
.answer-body sup a { color: var(--accent); font-size: 10px; padding: 0 1px; text-decoration: none; }
.answer-sources { margin-top: 12px; display: flex; flex-wrap: wrap; gap: 6px; }
.answer-sources a { font-size: 11px; color: var(--muted); border: 1px solid var(--border); padding: 2px 7px; }
.answer-sources a:hover { border-color: var(--accent); color: var(--accent); }
.results-form { display: flex; gap: 8px; flex: 1; }
.results-input {
flex: 1; background: var(--surface); border: 1px solid var(--border);
@ -182,6 +192,36 @@ function resultsPage(q: string, data: any, page: number) {
~${total.toLocaleString()} results
${peers.length ? `<span class="peers">// ${peers.length} peer${peers.length !== 1 ? 's' : ''} federated</span>` : ''}
</div>
<div class="answer-card hidden" id="answer">
<div class="answer-label"> AI answer</div>
<div class="answer-body" id="answer-body"></div>
<div class="answer-sources" id="answer-sources"></div>
</div>
<script>
(function () {
var q = ${JSON.stringify(q)};
var card = document.getElementById('answer'),
body = document.getElementById('answer-body'),
srcEl = document.getElementById('answer-sources');
function esc(t){ return String(t).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
fetch('/api/answer?q=' + encodeURIComponent(q))
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d || !d.answer) return;
var srcs = d.sources || [];
var html = esc(d.answer).replace(/\\[(\\d+)\\]/g, function (m, n) {
var s = srcs[parseInt(n) - 1];
return s ? '<sup><a href="' + esc(s.url) + '" rel="noopener" title="' + esc(s.domain) + '">[' + n + ']</a></sup>' : '';
});
body.innerHTML = html;
srcEl.innerHTML = srcs.map(function (s) {
return '<a href="' + esc(s.url) + '" rel="noopener">' + esc(s.domain) + '</a>';
}).join('');
card.classList.remove('hidden');
})
.catch(function () {});
})();
</script>
${results}
${hits.length ? `<div class="pagination">${prevLink}${nextLink}</div>` : ''}
<div class="footer">