Recipe: streaming UI

Rendering an answer as it arrives, with citations and a cancel button.

Browser Embed key dev · https://api.dev.oprag.ai
src/useAsk.ts TSX
import { useCallback, useRef, useState } from "react";
import { createClient, type CitationSource } from "@oprag/sdk";

const oprag = createClient({
  apiUrl: import.meta.env.VITE_OPRAG_API_URL,
  apiKey: import.meta.env.VITE_OPRAG_EMBED_KEY,
});

export function useAsk() {
  const [answer, setAnswer] = useState("");
  const [sources, setSources] = useState<CitationSource[]>([]);
  const [pending, setPending] = useState(false);
  const controller = useRef<AbortController>();

  const ask = useCallback(async (question: string, sessionId?: string) => {
    controller.current?.abort(); // one question at a time
    const next = new AbortController();
    controller.current = next;

    setAnswer("");
    setSources([]);
    setPending(true);

    try {
      const final = await oprag.chat.stream(
        { question, sessionId },
        {
          // `accumulated` is the whole answer so far — no buffer needed here.
          onToken: (_token, accumulated) => setAnswer(accumulated),
          onSources: (next) => setSources(next ?? []),
          signal: next.signal,
        },
      );

      if (final.type === "lead_capture_prompt") {
        return { kind: "lead" as const, prompt: final.promptMessage, fields: final.fields };
      }
      return { kind: "answer" as const };
    } catch (err) {
      // An abort is not a failure — it is the user asking something else.
      if (next.signal.aborted) return { kind: "aborted" as const };
      throw err;
    } finally {
      setPending(false);
    }
  }, []);

  return { ask, answer, sources, pending, cancel: () => controller.current?.abort() };
}

Citations arrive through onSources once, not per token, so render them when they land rather than on every frame.

Ready to ship?

Get started free