UI kit

RagSearchBox

SearchReact · Next.js

A search input over your pipeline. Debounces typing, calls /query, and renders the top retrieved chunks as snippets with the query terms highlighted. Pass onResult to wire the raw sources into your own UI.

<RagSearchBox />

Live preview

Type to search a small in-memory corpus.

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 { RagSearchBox } from "@/components/RagSearchBox";

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

Props

PropTypeDefaultDescription
endpointrequiredstringPipeline query URL. POST { query } → { sources }.
placeholderstring"Search…"Input placeholder text.
debounceMsnumber300Delay before firing a search after typing stops.
apiKeystringSent as X-API-Key when your serve.py has API_KEY set.
onResult(sources: Source[]) => voidCalled with each result set.
fetchertypeof fetchOverride the transport. Defaults to window.fetch.

Component source copy-paste ready

RagSearchBox.tsx
"use client";

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

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

interface RagSearchBoxProps {
  /** Pipeline query endpoint. POST { query } → { sources: Source[] }. */
  endpoint: string;
  placeholder?: string;
  /** Debounce before firing a search, in ms. Default: 300. */
  debounceMs?: number;
  /** Sent as the X-API-Key header when your serve.py has API_KEY set. */
  apiKey?: string;
  onResult?: (sources: Source[]) => void;
  /** Override the transport (e.g. for testing). Defaults to window.fetch. */
  fetcher?: typeof fetch;
  className?: string;
}

function highlight(text: string, query: string): ReactNode {
  const terms = query.trim().split(/\s+/).filter((t) => t.length > 2);
  if (!terms.length) return text;
  const re = new RegExp(`(${terms.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})`, "ig");
  return text.split(re).map((part, i) =>
    re.test(part) ? (
      <mark key={i} className="rounded bg-yellow-200 px-0.5 text-inherit dark:bg-yellow-500/30">
        {part}
      </mark>
    ) : (
      part
    )
  );
}

/**
 * RagSearchBox — a debounced search input over a Ragiment pipeline. Renders the
 * top retrieved chunks as snippets with the query terms highlighted.
 */
export function RagSearchBox({
  endpoint,
  placeholder = "Search…",
  debounceMs = 300,
  apiKey,
  onResult,
  fetcher,
  className = "",
}: RagSearchBoxProps) {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState<Source[]>([]);
  const [loading, setLoading] = useState(false);
  const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
  const onResultRef = useRef(onResult);

  useEffect(() => {
    onResultRef.current = onResult;
  }, [onResult]);

  useEffect(() => {
    if (!query.trim()) {
      setResults([]);
      return;
    }
    const f = fetcher ?? fetch;
    clearTimeout(timer.current);
    timer.current = setTimeout(async () => {
      setLoading(true);
      try {
        const res = await f(endpoint, {
          method: "POST",
          headers: { "Content-Type": "application/json", ...(apiKey ? { "X-API-Key": apiKey } : {}) },
          body: JSON.stringify({ query }),
        });
        const data = await res.json();
        const sources: Source[] = data.sources ?? (Array.isArray(data) ? data : []);
        setResults(sources);
        onResultRef.current?.(sources);
      } catch {
        setResults([]);
      } finally {
        setLoading(false);
      }
    }, debounceMs);
    return () => clearTimeout(timer.current);
  }, [query, endpoint, debounceMs, apiKey, fetcher]);

  return (
    <div className={`w-full ${className}`}>
      <div className="relative">
        <svg
          className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400"
          width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
        >
          <circle cx="11" cy="11" r="8" />
          <path d="m21 21-4.3-4.3" />
        </svg>
        <input
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          placeholder={placeholder}
          className="w-full rounded-lg border border-zinc-200 bg-white py-2.5 pl-9 pr-9 text-sm text-zinc-800 outline-none transition focus:border-zinc-400 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100"
        />
        {loading && (
          <span className="absolute right-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 animate-spin rounded-full border-2 border-zinc-300 border-t-zinc-600" />
        )}
      </div>

      {results.length > 0 && (
        <ul className="mt-2 flex flex-col gap-1.5">
          {results.map((r, i) => (
            <li key={i} className="rounded-lg border border-zinc-200 bg-white px-3 py-2 text-[13px] dark:border-zinc-800 dark:bg-zinc-900">
              <div className="mb-0.5 flex items-center justify-between">
                <span className="truncate text-xs font-medium text-zinc-500">{r.source || `Result ${i + 1}`}</span>
                {typeof r.score === "number" && (
                  <span className="text-[11px] text-zinc-400">{(r.score * 100).toFixed(0)}%</span>
                )}
              </div>
              <p className="leading-relaxed text-zinc-600 dark:text-zinc-300">
                {highlight(r.content.slice(0, 220), query)}
                {r.content.length > 220 ? "…" : ""}
              </p>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}