The one thing the browser knows
Open web/lib/api.ts. The entire front-end configuration is a single public URL:
// Thin fetch wrappers around the two Edge Functions. The browser only ever
// talks to Supabase (these URLs) and Vercel — never to Gemini.
const FUNCTIONS_URL = process.env.NEXT_PUBLIC_FUNCTIONS_URL!;
export type IngestResult = { ok: true; source: string; chunks: number };
export type Source = { n: number; source: string; similarity: number };
export type AskResult = { answer: string; sources: Source[] };
async function post<T>(fn: string, body: unknown): Promise<T> {
const res = await fetch(`${FUNCTIONS_URL}/${fn}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? res.statusText);
return data as T;
}
/** WRITE phase: send text or a URL to be chunked, embedded, and stored. */
export function ingest(body: { text?: string; url?: string; source?: string }) {
return post<IngestResult>("ingest", body);
}
/** READ phase: ask a question, get a grounded answer plus its sources. */
export function ask(question: string) {
return post<AskResult>("query", { question });
}NEXT_PUBLIC_FUNCTIONS_URL is the only environment value the front-end has, and it's a plain URL — safe to ship in the bundle. No service-role key, no Gemini key, nothing to steal. That's the two-keys rule made concrete: the browser posts JSON to ingest and query and never learns anything a scraper would want. The AskResult type mirrors the { answer, sources } the read phase returns in Step 4.
PDFs are parsed in the browser
web/lib/pdf.ts extracts text client-side, so the Edge Function only ever receives plain text — never a binary file to parse:
// Client-side PDF → text. We extract in the browser so the Edge Function only
// ever deals with plain text. pdfjs is loaded lazily (it's browser-only) and
// its worker comes from a CDN pinned to the installed version.
export async function extractPdfText(file: File): Promise<string> {
const pdfjs = await import("pdfjs-dist");
pdfjs.GlobalWorkerOptions.workerSrc =
`https://unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`;
const data = await file.arrayBuffer();
const pdf = await pdfjs.getDocument({ data }).promise;
let text = "";
for (let page = 1; page <= pdf.numPages; page++) {
const content = await (await pdf.getPage(page)).getTextContent();
text += content.items
.map((item) => ("str" in item ? item.str : ""))
.join(" ") + "\n";
}
return text;
}pdfjs is loaded lazily with a dynamic import because it's browser-only. Doing extraction here keeps the function simple: whether the source was pasted text, a URL, or a PDF, ingest always receives text.
The page
web/app/page.tsx is one "use client" component with two sections. The state is just React useState — write-phase inputs on top, read-phase results below.
Section 1 — Add knowledge. Three ways in, all funnelling into ingest():
async function handleIngestText() {
if (!text.trim()) return;
setIngesting(true);
setStatus("");
try {
const r = await ingest({ text, source: "pasted text" });
setStatus(`Stored ${r.chunks} chunks from ${r.source}.`);
setText("");
} catch (e) {
setStatus(`Error: ${(e as Error).message}`);
} finally {
setIngesting(false);
}
}The URL handler calls ingest({ url }); the PDF handler runs extractPdfText(file) first, then ingest({ text: pdfText, source: file.name }) — passing the filename as the source so the citations name the actual file. All three report back the chunk count the function returned.
Section 2 — Ask. The ask box calls ask() and renders the answer with its sources:
{result && (
<div className="answer">
<p className="answer-text">{result.answer}</p>
{result.sources.length > 0 && (
<div className="sources">
<h3>Sources</h3>
<ol>
{result.sources.map((s) => (
<li key={s.n}>
<code>[{s.n}]</code> {s.source}{" "}
<span className="score">({s.similarity})</span>
</li>
))}
</ol>
</div>
)}
</div>
)}Each list item shows the [n] marker, the source label, and the similarity score. Those [n] numbers are the same ones Gemini cited inline in answer-text (Step 4), so the reader can trace every claim back to a document. That's the payoff of the whole build rendered in a dozen lines of JSX.
Point it at your functions locally
cd web
cp .env.local.example .env.local # set NEXT_PUBLIC_FUNCTIONS_URL
npm install
npm run devWith NEXT_PUBLIC_FUNCTIONS_URL pointed at your deployed functions (Step 6), the local page is fully functional. Step 6 is the deploy — the handful of manual steps an AI can't do for you.