niceui

Knowledge Base New

Sources that index through upload, parse, chunk and embed, with a retrieval tester that scores and highlights matching chunks.

ReactTailwind CSSTypeScriptAI AgentsMIT · Free
Live preview

Installation

01

Install with the shadcn CLI. The NiceUI theme comes along the first time.

$ npx shadcn@latest add https://niceui.dev/r/ai-knowledge-base.json
02

Or copy the source below. Dependencies: lucide-react.

Source

components/niceui/ai/KnowledgeBase.tsx
import { Fragment, useEffect, useMemo, useRef, useState, type FormEvent } from 'react';
import { Check, ChevronDown, Database, FileCode2, FileSpreadsheet, FileText, Plus, Search } from 'lucide-react';
import { cn } from '@/lib/cn';

export type KbFile = {
  id: string;
  name: string;
  kind: 'pdf' | 'md' | 'csv' | 'doc';
  size: string;
  units: number;
  chunks: number;
  tokens: number;
  /** 0 → 4 through upload, parse, chunk, embed. 4 means ready. */
  stage: number;
};
export type KbChunk = { file: string; where: string; text: string };

const STAGES = ['Upload', 'Parse', 'Chunk', 'Embed'];
const ICON = { pdf: FileText, doc: FileText, md: FileCode2, csv: FileSpreadsheet };
const UNIT = { pdf: 'pages', doc: 'pages', md: 'sections', csv: 'rows' };
const STOP = new Set(['the', 'and', 'for', 'how', 'what', 'does', 'with', 'are', 'can', 'our', 'your', 'you', 'work', 'is', 'do']);

const fmt = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(Math.round(n)));
const terms = (q: string) => q.toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 2 && !STOP.has(w)).map((w) => w.slice(0, 5));

function Highlight({ text, words }: { text: string; words: string[] }) {
  if (!words.length) return <>{text}</>;
  const re = new RegExp(`(\\b(?:${words.join('|')})[a-z]*)`, 'gi');
  return (
    <>
      {text.split(re).map((part, i) =>
        i % 2 ? (
          <mark key={i} className="rounded-[3px] bg-brand-400/20 px-0.5 text-brand-100">
            {part}
          </mark>
        ) : (
          <Fragment key={i}>{part}</Fragment>
        ),
      )}
    </>
  );
}

function FileRow({ f }: { f: KbFile }) {
  const Icon = ICON[f.kind];
  const ready = f.stage >= 4;
  const k = Math.min(3, Math.floor(f.stage));
  const units = f.stage >= 2 ? f.units : 0;
  const chunks = f.chunks * Math.min(1, Math.max(0, f.stage - 2));
  const tokens = f.tokens * Math.min(1, Math.max(0, f.stage - 3));
  return (
    <li className="rounded-xl bg-white/[0.02] p-3 ring-1 ring-white/5 animate-fade-up">
      <div className="flex items-center gap-2.5">
        <span className="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-white/5 text-ink-300">
          <Icon className="h-4 w-4" />
        </span>
        <span className="min-w-0 flex-1">
          <span className="block truncate text-sm text-ink-100">{f.name}</span>
          <span className="block font-mono text-[11px] tabular-nums text-ink-500">
            {f.size} · {units ? `${units} ${UNIT[f.kind]}` : '—'} · {fmt(chunks)} chunks · {fmt(tokens)} tokens
          </span>
        </span>
        {ready ? (
          <span className="flex shrink-0 items-center gap-1 rounded-full bg-emerald-500/10 px-2 py-0.5 text-[11px] font-medium text-emerald-300">
            <Check className="h-3 w-3" /> Ready
          </span>
        ) : (
          <span className="shrink-0 font-mono text-[11px] tabular-nums text-brand-300">{Math.round((f.stage / 4) * 100)}%</span>
        )}
      </div>
      {!ready && (
        <div className="mt-2.5">
          <div className="grid grid-cols-4 gap-1">
            {STAGES.map((s, i) => (
              <span key={s} className="h-1 overflow-hidden rounded-full bg-white/[0.06]">
                <span className={cn('block h-full rounded-full', i < k ? 'bg-brand-400/70' : 'bg-brand-400')} style={{ width: `${Math.min(1, Math.max(0, f.stage - i)) * 100}%` }} />
              </span>
            ))}
          </div>
          <p className="mt-1.5 text-[11px] text-ink-500">
            <span className="shimmer-text font-medium">{['Uploading', 'Parsing', 'Chunking', 'Embedding'][k]}…</span>
          </p>
        </div>
      )}
    </li>
  );
}

export type KnowledgeBaseProps = {
  name: string;
  files: KbFile[];
  chunks: KbChunk[];
  library?: KbFile[];
  examples?: string[];
  defaultQuery?: string;
  className?: string;
};

/** Sources indexed through upload, parse, chunk and embed, plus a retrieval tester that only searches ready files. */
export function KnowledgeBase({ name, files: initial, chunks, library = [], examples = [], defaultQuery = '', className }: KnowledgeBaseProps) {
  const [files, setFiles] = useState(initial);
  const [menu, setMenu] = useState(false);
  const [query, setQuery] = useState(defaultQuery);
  const [asked, setAsked] = useState(defaultQuery);
  const menuRef = useRef<HTMLDivElement>(null);
  const indexing = files.some((f) => f.stage < 4);

  useEffect(() => {
    if (!indexing) return;
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
      setFiles((fs) => fs.map((f) => ({ ...f, stage: 4 })));
      return;
    }
    const id = window.setInterval(() => setFiles((fs) => fs.map((f) => (f.stage < 4 ? { ...f, stage: Math.min(4, f.stage + 0.025) } : f))), 50);
    return () => window.clearInterval(id);
  }, [indexing]);

  useEffect(() => {
    if (!menu) return;
    const close = (e: PointerEvent) => !menuRef.current?.contains(e.target as Node) && setMenu(false);
    document.addEventListener('pointerdown', close);
    return () => document.removeEventListener('pointerdown', close);
  }, [menu]);

  const ready = new Set(files.filter((f) => f.stage >= 4).map((f) => f.name));
  const words = terms(asked);
  const results = useMemo(() => {
    if (!words.length) return [];
    return chunks
      .filter((c) => ready.has(c.file))
      .map((c) => {
        const t = c.text.toLowerCase();
        const hits = words.reduce((n, w) => n + (t.match(new RegExp(`\\b${w}`, 'g'))?.length ?? 0), 0);
        const covered = words.filter((w) => new RegExp(`\\b${w}`).test(t)).length / words.length;
        return { c, score: hits ? 0.55 + covered * 0.33 + Math.min(0.09, hits * 0.02) : 0 };
      })
      .filter((r) => r.score > 0)
      .sort((a, b) => b.score - a.score)
      .slice(0, 3);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [asked, chunks, files.filter((f) => f.stage >= 4).length]);

  const remaining = library.filter((l) => !files.some((f) => f.name === l.name));
  const total = files.reduce((a, f) => ({ chunks: a.chunks + (f.stage >= 4 ? f.chunks : 0), tokens: a.tokens + (f.stage >= 4 ? f.tokens : 0) }), { chunks: 0, tokens: 0 });

  const ask = (q: string) => {
    setQuery(q);
    setAsked(q);
  };
  const submit = (e: FormEvent) => {
    e.preventDefault();
    setAsked(query);
  };

  return (
    <div className={cn('grid w-full grid-cols-[minmax(0,1fr)] gap-3 md:grid-cols-2', className)}>
      <section className="rounded-2xl bg-ink-900 p-4 shadow-card">
        <header className="flex items-center gap-2.5">
          <span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-white/5 text-ink-200">
            <Database className="h-[18px] w-[18px]" />
          </span>
          <div className="min-w-0 flex-1">
            <p className="truncate text-sm font-medium text-ink-50">{name}</p>
            <p className="font-mono text-[11px] tabular-nums text-ink-500">
              {files.length} sources · {fmt(total.chunks)} chunks · {fmt(total.tokens)} tokens
            </p>
          </div>
          <div ref={menuRef} className="relative shrink-0">
            <button onClick={() => setMenu((m) => !m)} disabled={!remaining.length} aria-expanded={menu} aria-haspopup="menu" className="focus-ring flex h-8 items-center gap-1 rounded-lg px-2.5 text-xs font-medium text-ink-200 ring-1 ring-white/10 transition-colors hover:bg-white/5 disabled:opacity-40">
              <Plus className="h-3.5 w-3.5" /> Add
              <ChevronDown className={cn('h-3 w-3 transition-transform', menu && 'rotate-180')} />
            </button>
            {menu && (
              <div role="menu" className="absolute right-0 top-[calc(100%+6px)] z-20 w-56 rounded-xl bg-ink-850 p-1 shadow-pop animate-scale-in">
                {remaining.map((l) => {
                  const Icon = ICON[l.kind];
                  return (
                    <button
                      key={l.id}
                      role="menuitem"
                      onClick={() => {
                        setFiles((fs) => [...fs, { ...l, stage: 0 }]);
                        setMenu(false);
                      }}
                      className="focus-ring flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs text-ink-200 hover:bg-white/5"
                    >
                      <Icon className="h-3.5 w-3.5 text-ink-400" />
                      <span className="min-w-0 flex-1 truncate">{l.name}</span>
                      <span className="font-mono text-[10px] text-ink-500">{l.size}</span>
                    </button>
                  );
                })}
              </div>
            )}
          </div>
        </header>
        <ul className="mt-3 space-y-2">
          {files.map((f) => (
            <FileRow key={f.id} f={f} />
          ))}
        </ul>
      </section>

      <section className="flex min-w-0 flex-col rounded-2xl bg-ink-900 p-4 shadow-card">
        <p className="text-sm font-medium text-ink-50">Test retrieval</p>
        <p className="text-xs text-ink-500">Searches only sources that finished indexing.</p>
        <form onSubmit={submit} className="mt-3 flex items-center gap-2 rounded-xl bg-ink-950/60 px-3 ring-1 ring-white/10 transition-shadow focus-within:ring-brand-400/50">
          <Search className="h-4 w-4 shrink-0 text-ink-500" />
          <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Ask your knowledge base…" aria-label="Retrieval query" className="h-10 min-w-0 flex-1 bg-transparent text-sm text-ink-100 outline-none placeholder:text-ink-600" />
          <kbd className="hidden rounded bg-white/5 px-1.5 font-mono text-[10px] text-ink-500 sm:block">↵</kbd>
        </form>
        <div className="mt-2 flex flex-wrap gap-1.5">
          {examples.map((q) => (
            <button key={q} onClick={() => ask(q)} className={cn('focus-ring rounded-full px-2.5 py-1 text-[11px] transition-colors', asked === q ? 'bg-brand-500/15 text-brand-200' : 'bg-white/[0.04] text-ink-400 hover:text-ink-100')}>
              {q}
            </button>
          ))}
        </div>
        <div className="mt-3 min-h-0 flex-1 space-y-2" aria-live="polite">
          {asked && (
            <p className="font-mono text-[11px] text-ink-500">
              {results.length ? `${results.length} chunks · ${42 + asked.length * 3}ms` : 'No matches in ready sources yet'}
            </p>
          )}
          {results.map(({ c, score }, i) => (
            <article key={`${asked}-${c.file}-${c.where}`} className="rounded-xl bg-white/[0.02] p-3 ring-1 ring-white/5 animate-fade-up" style={{ animationDelay: `${i * 70}ms` }}>
              <div className="flex items-center gap-2">
                <span className="font-mono text-[11px] tabular-nums text-brand-300">{score.toFixed(2)}</span>
                <span className="h-1 w-16 overflow-hidden rounded-full bg-white/[0.06]">
                  <span className="block h-full rounded-full bg-brand-400" style={{ width: `${score * 100}%` }} />
                </span>
                <span className="ml-auto truncate font-mono text-[10.5px] text-ink-500">
                  {c.file} · {c.where}
                </span>
              </div>
              <p className="mt-1.5 text-[13px] leading-relaxed text-ink-300">
                <Highlight text={c.text} words={words} />
              </p>
            </article>
          ))}
        </div>
      </section>
    </div>
  );
}

/* ——— demo ——— */

const FILES: KbFile[] = [
  { id: 'f1', name: 'refund-policy.pdf', kind: 'pdf', size: '1.2 MB', units: 12, chunks: 48, tokens: 18200, stage: 4 },
  { id: 'f2', name: 'api-reference.md', kind: 'md', size: '340 KB', units: 34, chunks: 126, tokens: 51400, stage: 4 },
  { id: 'f3', name: 'pricing-2026.pdf', kind: 'pdf', size: '2.8 MB', units: 18, chunks: 64, tokens: 23900, stage: 1.6 },
];
const LIBRARY: KbFile[] = [
  { id: 'l1', name: 'support-macros.csv', kind: 'csv', size: '88 KB', units: 240, chunks: 31, tokens: 9800, stage: 0 },
  { id: 'l2', name: 'onboarding-guide.docx', kind: 'doc', size: '760 KB', units: 9, chunks: 27, tokens: 10400, stage: 0 },
];
const CHUNKS: KbChunk[] = [
  { file: 'refund-policy.pdf', where: 'p.4', text: 'Refunds are issued to the original payment method within 5 business days. Annual plans can be refunded in full within 30 days of purchase.' },
  { file: 'refund-policy.pdf', where: 'p.6', text: 'Duplicate charges are refunded automatically once billing confirms the second transaction. No ticket is needed.' },
  { file: 'api-reference.md', where: '§ Rate limits', text: 'The API allows 600 requests per minute per workspace. Exceeding the rate limit returns 429 with a Retry-After header.' },
  { file: 'api-reference.md', where: '§ Exports', text: 'Large exports run as background jobs. Poll the job endpoint or subscribe to the export.completed webhook.' },
  { file: 'pricing-2026.pdf', where: 'p.2', text: 'Enterprise pricing starts at 40 seats with SSO, audit logs and a dedicated success manager. Annual billing only.' },
  { file: 'pricing-2026.pdf', where: 'p.5', text: 'Seats can be added at any time and are prorated. Refunds for removed seats are applied as account credit.' },
  { file: 'support-macros.csv', where: 'row 112', text: 'Macro: double charge. Apologize, confirm the refund timeline of 5 business days, and link the refund policy.' },
  { file: 'onboarding-guide.docx', where: 'p.3', text: 'Invite teammates from Settings → Members. New members inherit the workspace rate limits and export permissions.' },
];

export function KnowledgeBaseDemo() {
  return (
    <KnowledgeBase
      name="Support knowledge"
      files={FILES}
      library={LIBRARY}
      chunks={CHUNKS}
      examples={['How do refunds work?', 'API rate limits', 'Enterprise pricing']}
      defaultQuery="How do refunds work?"
    />
  );
}