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 dev

With 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.