UI kit

RagChat

ChatReact · Next.js

A complete chat interface for a Ragiment pipeline. Sends each question to your /query endpoint, renders the grounded answer with a typing indicator, keeps message history, and shows the source chunks behind every reply as inline chips.

<RagChat />

Live preview

Ask the docs

Ask anything about your documents.

Installation

No npm dependencies — just React + Tailwind CSS. There are two ways to use it:

  • React — copy the source below; it calls your pipeline directly from the browser.
  • Next.js — switch to the Next.js tab in the source below; it keeps your pipeline URL + key server-side behind a Route Handler (adds a couple of .env.local vars).

Usage

usage.tsx
import { RagChat } from "@/components/RagChat";

export default function Example() {
  return (
    <RagChat endpoint="https://api.example.com/query" />
  );
}

Props

PropTypeDefaultDescription
endpointrequiredstringPipeline query URL. POST { query } → { answer, sources }.
apiKeystringSent as X-API-Key when your serve.py has API_KEY set.
placeholderstring"Ask a question…"Input placeholder text.
titlestring"Ask the docs"Header label.
fetchertypeof fetchOverride the transport (testing/proxying). Defaults to window.fetch.
classNamestringExtra classes on the outer container.

Component source copy-paste ready

RagChat.tsx
"use client";

import { useEffect, useRef, useState } from "react";

export interface Source {
  content: string;
  source?: string;
  page?: number;
  score?: number;
}

interface Message {
  role: "user" | "assistant";
  content: string;
  sources?: Source[];
}

interface RagChatProps {
  /** Pipeline query endpoint. POST { query } → { answer, sources }. */
  endpoint: string;
  placeholder?: string;
  /** Sent as the X-API-Key header when your serve.py has API_KEY set. */
  apiKey?: string;
  title?: string;
  /** Override the transport (e.g. for testing). Defaults to window.fetch. */
  fetcher?: typeof fetch;
  className?: string;
}

/**
 * RagChat — a full chat interface for a Ragiment pipeline. Sends each question
 * to your `/query` endpoint and renders the grounded answer with inline source
 * chips. Drop it in and point `endpoint` at your serve.py.
 */
export function RagChat({
  endpoint,
  placeholder = "Ask a question…",
  apiKey,
  title = "Ask the docs",
  fetcher,
  className = "",
}: RagChatProps) {
  const [messages, setMessages] = useState<Message[]>([]);
  const [input, setInput] = useState("");
  const [loading, setLoading] = useState(false);
  const scroller = useRef<HTMLDivElement>(null);

  useEffect(() => {
    scroller.current?.scrollTo({ top: scroller.current.scrollHeight, behavior: "smooth" });
  }, [messages, loading]);

  async function send() {
    const q = input.trim();
    if (!q || loading) return;
    setInput("");
    setMessages((m) => [...m, { role: "user", content: q }]);
    setLoading(true);
    try {
      const f = fetcher ?? fetch;
      const res = await f(endpoint, {
        method: "POST",
        headers: { "Content-Type": "application/json", ...(apiKey ? { "X-API-Key": apiKey } : {}) },
        body: JSON.stringify({ query: q }),
      });
      const data = await res.json();
      setMessages((m) => [...m, { role: "assistant", content: data.answer ?? "(no answer)", sources: data.sources ?? [] }]);
    } catch {
      setMessages((m) => [...m, { role: "assistant", content: "Something went wrong reaching the endpoint." }]);
    } finally {
      setLoading(false);
    }
  }

  return (
    <div className={`flex h-[460px] flex-col overflow-hidden rounded-xl border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-950 ${className}`}>
      <div className="border-b border-zinc-200 px-4 py-3 text-sm font-semibold text-zinc-700 dark:border-zinc-800 dark:text-zinc-200">
        {title}
      </div>

      <div ref={scroller} className="flex-1 space-y-4 overflow-y-auto p-4">
        {messages.length === 0 && (
          <p className="mt-12 text-center text-sm text-zinc-400">Ask anything about your documents.</p>
        )}
        {messages.map((m, i) => (
          <div key={i} className={`flex ${m.role === "user" ? "justify-end" : "justify-start"}`}>
            <div
              className={`max-w-[85%] rounded-2xl px-3.5 py-2 text-[13.5px] leading-relaxed ${
                m.role === "user"
                  ? "bg-zinc-900 text-white dark:bg-white dark:text-zinc-900"
                  : "bg-zinc-100 text-zinc-800 dark:bg-zinc-800 dark:text-zinc-100"
              }`}
            >
              <p className="whitespace-pre-wrap">{m.content}</p>
              {m.sources && m.sources.length > 0 && (
                <div className="mt-2 flex flex-wrap gap-1">
                  {m.sources.map((s, j) => (
                    <span
                      key={j}
                      title={s.content}
                      className="rounded-md bg-white/70 px-1.5 py-0.5 text-[11px] text-zinc-500 dark:bg-black/30 dark:text-zinc-400"
                    >
                      [{j + 1}] {s.source || "source"}
                    </span>
                  ))}
                </div>
              )}
            </div>
          </div>
        ))}
        {loading && (
          <div className="flex justify-start">
            <div className="rounded-2xl bg-zinc-100 px-3.5 py-2.5 dark:bg-zinc-800">
              <span className="flex gap-1">
                <Dot /><Dot delay={0.15} /><Dot delay={0.3} />
              </span>
            </div>
          </div>
        )}
      </div>

      <div className="border-t border-zinc-200 p-3 dark:border-zinc-800">
        <div className="flex items-center gap-2">
          <input
            value={input}
            onChange={(e) => setInput(e.target.value)}
            onKeyDown={(e) => e.key === "Enter" && send()}
            placeholder={placeholder}
            className="flex-1 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm outline-none focus:border-zinc-400 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100"
          />
          <button
            onClick={send}
            disabled={loading || !input.trim()}
            className="rounded-lg bg-zinc-900 px-3.5 py-2 text-sm font-medium text-white transition disabled:opacity-40 dark:bg-white dark:text-zinc-900"
          >
            Send
          </button>
        </div>
      </div>
    </div>
  );
}

function Dot({ delay = 0 }: { delay?: number }) {
  return <span className="h-1.5 w-1.5 animate-bounce rounded-full bg-zinc-400" style={{ animationDelay: `${delay}s` }} />;
}