# Knowledge

4 on-device blocks in the Knowledge category — each installs and runs on its own, entirely in the browser.

## Semantic Search

Build a searchable knowledge base right in your browser. Add content three ways: paste text, upload PDFs, or scan images with OCR. Then search by meaning instead of exact keywords, with the most relevant passages ranked to the top. Everything runs on your device, and nothing downloads until you start.

**Install**

```bash
npx shadcn@latest add @localmode/ui/blocks/knowledge/semantic-search
```

**Full block (all files):** https://localmode.ai/r/ui/blocks/knowledge/semantic-search.json

```tsx
'use client';

/**
 * @file semantic-search.tsx
 * @description Semantic Search block — a two-tab Ingest / Search knowledge base over ONE shared corpus via `useKnowledgeBase`, with a core-pipeline ⇄ LangChain engine toggle, three-lane ingest (text + PDF + OCR) with live chunk-boundary preview, and reranked vector search with facets and preset/calibrated thresholds. No model bytes download until an explicit in-block action.
 */

import { useEffect, useRef, useState, type ReactNode } from 'react';
import {
  Check,
  ClipboardPaste,
  Copy,
  FileText,
  Loader2,
  ScanText,
  Search,
  Trash2,
  Upload,
  X,
} from 'lucide-react';
import {
  getDefaultThreshold,
  isWebGPUSupported,
  recursiveChunk,
  type ChunkingMode,
  type EmbeddingModel,
  type EngineStats,
  type KBSearchResult,
  type KnowledgeBaseEngine,
  type OCRModel,
  type RawDocument,
  type RerankerModel,
} from '@localmode/core';
import {
  useCalibrateThreshold,
  useExtractText,
  useKnowledgeBase,
  useRerank,
} from '@localmode/react';
import { isModelCached, transformers } from '@localmode/transformers';
import { wllama } from '@localmode/wllama';

import { CapabilityGate } from '@/components/capability-gate';
import { CategoryFacetList } from '@/components/category-facet-list';
import { ChunkBoundaryVisualizer, type ChunkInfo } from '@/components/chunk-boundary-visualizer';
import { CosineSimilarityMeter } from '@/components/cosine-similarity-meter';
import { FileDropzone, type RejectedFile } from '@/components/file-dropzone';
import { IndexedDocumentCard } from '@/components/indexed-document-card';
import { MediaDropzone, type MediaDropzoneRejection } from '@/components/media-dropzone';
import { DownloadProgress, ModelDownloader } from '@/components/model-downloader';
import { OptionList, type Option } from '@/components/option-list';
import { ParameterSlider } from '@/components/parameter-slider';
import { MultiStepPipelineTracker } from '@/components/pipeline-tracker';
import { ScoredResultBarList } from '@/components/scored-result-bar-list';
import { SegmentedModePicker } from '@/components/segmented-mode-picker';
import { TopResultCard } from '@/components/top-result-card';
import { formatBytes, readFileAsDataUrl } from '@/lib/browser-utils';
import { cn } from '@/lib/utils';


const DEFAULT_EMBEDDING_MODEL_ID = 'Xenova/bge-small-en-v1.5';

const ANSWER_MODEL_ID = 'onnx-community/granite-4.0-350m-ONNX-web';

const ANSWER_MAX_TOKENS = 512;

const EMBEDDING_MODEL_META: Record<string, { name: string; size: string }> = {
  'Xenova/bge-small-en-v1.5': { name: 'BGE Small EN v1.5', size: '34 MB' },
  'Xenova/all-MiniLM-L6-v2': { name: 'all-MiniLM-L6-v2', size: '23 MB' },
};

type EngineKind = KnowledgeBaseEngine['kind'];

const ENGINE_LABELS: Record<EngineKind, string> = {
  core: 'Core',
  langchain: 'LangChain',
};

const IDLE_STATUS = 'idle - load the sample corpus or add documents, then search';

const TABS = [
  { id: 'ingest', label: 'Ingest' },
  { id: 'search', label: 'Search' },
] as const;

type TabId = (typeof TABS)[number]['id'];

const TAB_ICONS: Record<TabId, typeof Upload> = { ingest: Upload, search: Search };

export interface SemanticSearchSession {
  engine: KnowledgeBaseEngine;
  engineKind: EngineKind;
  documents: RawDocument[];
  addDocuments: (docs: Array<Omit<RawDocument, 'id' | 'addedAt'>>) => Promise<void>;
  removeDocument: (docId: string) => Promise<void>;
  clearAll: () => Promise<void>;
  chunking: ChunkingMode;
  setChunking: (mode: ChunkingMode) => void;
  chunkSize: number;
  setChunkSize: (n: number) => void;
  embeddingModelId: string;
  setEmbeddingModelId: (id: string) => void;
  busy: boolean;
  error: string | null;
}


const MAX_FILE_SIZE = 10 * 1024 * 1024;

const OCR_ACCEPT = ['image/jpeg', 'image/png', 'image/webp'];

const EMBEDDING_MODELS = [
  { id: 'Xenova/bge-small-en-v1.5', label: 'bge-small-en-v1.5 · 384d (default)' },
  { id: 'Xenova/all-MiniLM-L6-v2', label: 'all-MiniLM-L6-v2 · 384d' },
] as const;

interface OCRModelEntry {
  id: string;
  name: string;
  size: string;
  generative: boolean;
  description: string;
}

const OCR_MODELS: OCRModelEntry[] = [
  {
    id: 'Xenova/trocr-small-printed',
    name: 'TrOCR Small',
    size: '~120MB',
    generative: false,
    description: 'Fast line-level printed text recognition',
  },
  {
    id: 'onnx-community/GLM-OCR-ONNX',
    name: 'GLM-OCR',
    size: '~652MB',
    generative: true,
    description: 'Generative - document-level OCR with table & formula support',
  },
  {
    id: 'onnx-community/LightOnOCR-2-1B-ONNX',
    name: 'LightOnOCR-2',
    size: '~700MB',
    generative: true,
    description: 'Generative - fast end-to-end document OCR, 11 languages',
  },
];

const OCR_MODES = [
  { id: 'text', label: 'Text', prompt: 'Text Recognition:' },
  { id: 'table', label: 'Table', prompt: 'Table Recognition:' },
  { id: 'formula', label: 'Formula', prompt: 'Formula Recognition:' },
] as const;

type OCRModeId = (typeof OCR_MODES)[number]['id'];

const SAMPLE_CORPUS: Array<Omit<RawDocument, 'id' | 'addedAt'>> = [
  {
    title: 'Privacy and encryption on device',
    category: 'security',
    source: 'sample',
    text: 'Privacy and encryption go hand in hand: encrypting personal data with AES-GCM keys derived on the device keeps private information confidential, and no plaintext ever leaves the browser.',
  },
  {
    title: 'Spring vegetable gardening',
    category: 'home',
    source: 'sample',
    text: 'Plant tomatoes and peppers after the last frost. Water seedlings daily and mulch the beds to keep weeds down through the warm months.',
  },
  {
    title: 'Road cycling basics',
    category: 'sports',
    source: 'sample',
    text: 'A correct saddle height prevents knee pain on long rides. Carry a spare tube, tire levers, and a mini pump on every road ride.',
  },
  {
    title: 'Fresh pasta dough',
    category: 'food',
    source: 'sample',
    text: 'Combine flour and eggs, knead for ten minutes, and rest the dough for half an hour before rolling thin sheets for tagliatelle.',
  },
  {
    title: 'Backyard astronomy',
    category: 'science',
    source: 'sample',
    text: 'A small refractor telescope shows the rings of Saturn and the moons of Jupiter. Dark skies away from city lights reveal the Milky Way.',
  },
  {
    title: 'Budgeting for beginners',
    category: 'money',
    source: 'sample',
    text: 'Track monthly income and expenses, build a three-month emergency fund first, and automate transfers into savings on payday.',
  },
  {
    title: 'Marathon training plan',
    category: 'sports',
    source: 'sample',
    text: 'Increase weekly mileage by no more than ten percent. Long slow runs on weekends build the aerobic base needed for race day.',
  },
  {
    title: 'A year of soups',
    category: 'food',
    source: 'sample',
    text: 'In spring, light broths with peas, asparagus, and fresh herbs make a bright start to the season. A simple stock simmered from vegetable trimmings carries delicate flavors without overpowering them. Summer calls for chilled soups: gazpacho blends ripe tomatoes, cucumber, and peppers into a refreshing bowl that needs no stove at all. When autumn arrives, roasted squash and root vegetables become velvety purees, finished with cream and a pinch of nutmeg. Winter is the season of slow simmering: beans, lentils, and smoked meats braise for hours until the broth turns rich and deeply savory, perfect with crusty bread by the fire.',
  },
];


function deriveTitle(text: string) {
  const firstLine = text
    .split('\n')
    .map((l) => l.replace(/^#+\s*/, '').trim())
    .find((l) => l.length > 0);
  if (!firstLine) return 'Untitled note';
  return firstLine.length > 64 ? `${firstLine.slice(0, 64).trimEnd()}…` : firstLine;
}

function countWords(text: string) {
  return text.trim().split(/\s+/).filter(Boolean).length;
}

function formatRelativeTime(timestamp: number) {
  const deltaMs = Date.now() - timestamp;
  const minutes = Math.floor(deltaMs / 60_000);
  if (minutes < 1) return 'just now';
  if (minutes < 60) return `${minutes}m ago`;
  const hours = Math.floor(minutes / 60);
  if (hours < 24) return `${hours}h ago`;
  const days = Math.floor(hours / 24);
  return `${days}d ago`;
}

function estimateChunkCount(textLength: number, mode: ChunkingMode, chunkSize: number) {
  if (mode === 'off') return 1;
  const size = mode === 'semantic' ? 500 : Math.max(1, chunkSize);
  return Math.max(1, Math.ceil(textLength / size));
}

const SOURCE_LABELS: Record<RawDocument['source'], string> = {
  text: 'Text',
  sample: 'Sample',
  pdf: 'PDF',
  ocr: 'OCR',
  import: 'Import',
};

const ocrModelCache = new Map<string, OCRModel>();

function getOCRModel(modelId: string) {
  let model = ocrModelCache.get(modelId);
  if (!model) {
    model = transformers.ocr(modelId);
    ocrModelCache.set(modelId, model);
  }
  return model;
}


interface PdfPipelineState {
  step: 'extract' | 'ingest';
  fileIndex: number;
  fileCount: number;
  fileName: string;
}

interface PdfFileError {
  fileName: string;
  message: string;
}

interface OcrImage {
  fileName: string;
  sizeBytes: number;
  dataUrl: string;
}

const PDF_STEPS = ['Extract', 'Chunk · Embed · Store'];


export function IngestPanel({ session }: { session: SemanticSearchSession }) {
  const [draft, setDraft] = useState('');

  const [pdfPipeline, setPdfPipeline] = useState<PdfPipelineState | null>(null);
  const [pdfErrors, setPdfErrors] = useState<PdfFileError[]>([]);
  const pdfAbortRef = useRef<AbortController | null>(null);

  const [ocrModelId, setOcrModelId] = useState(OCR_MODELS[0].id);
  const [ocrModeId, setOcrModeId] = useState<OCRModeId>('text');
  const [ocrImage, setOcrImage] = useState<OcrImage | null>(null);
  const [ocrLaneError, setOcrLaneError] = useState<string | null>(null);
  const [copied, setCopied] = useState(false);

  const [deletingDocId, setDeletingDocId] = useState<string | null>(null);
  const [confirmingClear, setConfirmingClear] = useState(false);

  const selectedOcrModel = OCR_MODELS.find((m) => m.id === ocrModelId) ?? OCR_MODELS[0];
  const selectedOcrMode = OCR_MODES.find((m) => m.id === ocrModeId) ?? OCR_MODES[0];

  const extract = useExtractText({
    model: getOCRModel(ocrModelId),
    prompt: selectedOcrModel.generative ? selectedOcrMode.prompt : undefined,
  });
  const extractedText = extract.data?.text ?? '';

  const busy = session.busy;

  const draftTrimmed = draft.trim();
  const previewChunks: ChunkInfo[] = !draftTrimmed
    ? []
    : session.chunking === 'off'
      ? [{ text: draftTrimmed, chunkIndex: 0, rightSimilarity: null }]
      : recursiveChunk(draftTrimmed, { size: session.chunkSize }).map((c) => ({
          text: c.text,
          chunkIndex: c.index,
          rightSimilarity: null,
        }));
  const previewChars = previewChunks.reduce((sum, c) => sum + c.text.length, 0);
  const previewAvg = previewChunks.length > 0 ? Math.round(previewChars / previewChunks.length) : 0;


  const addDraft = async () => {
    if (!draftTrimmed || busy) return;
    await session.addDocuments([
      { title: deriveTitle(draftTrimmed), text: draftTrimmed, source: 'text' },
    ]);
    setDraft('');
  };

  const loadSamples = async () => {
    if (busy || session.documents.length > 0) return;
    await session.addDocuments(SAMPLE_CORPUS);
  };


  const ingestPDFs = async (files: File[]) => {
    if (busy || pdfPipeline) return;
    setPdfErrors([]);

    const controller = new AbortController();
    pdfAbortRef.current = controller;

    const docs: Array<Omit<RawDocument, 'id' | 'addedAt'>> = [];
    const errors: PdfFileError[] = [];

    for (let i = 0; i < files.length; i++) {
      const file = files[i];
      setPdfPipeline({ step: 'extract', fileIndex: i, fileCount: files.length, fileName: file.name });
      try {
        const { extractPDFText } = await import('@localmode/pdfjs');
        const result = await extractPDFText(file, {
          includePageNumbers: false,
          pageSeparator: '\n\n',
          abortSignal: controller.signal,
        });
        if (!result.text.trim()) {
          errors.push({
            fileName: file.name,
            message: 'No extractable text - the PDF may be scanned images or protected.',
          });
          continue;
        }
        docs.push({
          title: file.name,
          text: result.text,
          source: 'pdf',
          meta: { pages: result.pageCount, sizeBytes: file.size },
          pages: result.pages.map((p) => ({ page: p.pageNumber, text: p.text })),
        });
      } catch (err) {
        if (controller.signal.aborted || (err instanceof DOMException && err.name === 'AbortError')) {
          setPdfPipeline(null);
          setPdfErrors(errors);
          return;
        }
        errors.push({ fileName: file.name, message: err instanceof Error ? err.message : String(err) });
      }
    }

    setPdfErrors(errors);

    if (docs.length > 0) {
      setPdfPipeline({ step: 'ingest', fileIndex: files.length, fileCount: files.length, fileName: '' });
      try {
        await session.addDocuments(docs);
      } finally {
        setPdfPipeline(null);
      }
    } else {
      setPdfPipeline(null);
    }
  };

  const cancelPdfExtract = () => {
    pdfAbortRef.current?.abort();
  };

  const onPdfReject = (rejected: RejectedFile[]) => {
    setPdfErrors((prev) => [
      ...prev,
      ...rejected.map((r) => ({ fileName: r.file.name, message: r.reason })),
    ]);
  };


  const onOcrFiles = async (files: File[]) => {
    const file = files[0];
    if (!file) return;
    setOcrLaneError(null);
    extract.reset();
    try {
      const dataUrl = await readFileAsDataUrl(file);
      setOcrImage({ fileName: file.name, sizeBytes: file.size, dataUrl });
    } catch (err) {
      setOcrLaneError(err instanceof Error ? err.message : String(err));
    }
  };

  const onOcrReject = (rejections: MediaDropzoneRejection[]) => {
    setOcrLaneError(rejections[0]?.reason ?? 'File rejected.');
  };

  const runOcr = async () => {
    if (!ocrImage || extract.isLoading) return;
    setOcrLaneError(null);
    await extract.execute(ocrImage.dataUrl);
  };

  const copyExtracted = async () => {
    if (!extractedText) return;
    await navigator.clipboard.writeText(extractedText);
    setCopied(true);
    setTimeout(() => setCopied(false), 2000);
  };

  const indexOcrText = async () => {
    if (!ocrImage || !extractedText.trim() || busy) return;
    await session.addDocuments([
      {
        title: ocrImage.fileName,
        text: extractedText.trim(),
        source: 'ocr',
        meta: { ocrModel: ocrModelId, sizeBytes: ocrImage.sizeBytes },
      },
    ]);
    resetOcrLane();
  };

  const resetOcrLane = () => {
    extract.reset();
    setOcrImage(null);
    setOcrLaneError(null);
    setCopied(false);
  };

  const selectOcrModel = (option: Option) => {
    if (option.id === ocrModelId) return;
    setOcrModelId(option.id);
    extract.reset();
  };


  const deleteDocument = async (docId: string) => {
    if (busy || deletingDocId) return;
    setDeletingDocId(docId);
    try {
      await session.removeDocument(docId);
    } finally {
      setDeletingDocId(null);
    }
  };

  const clearAll = async () => {
    if (busy) return;
    setConfirmingClear(false);
    await session.clearAll();
  };

  const ocrWordCount = countWords(extractedText);


  return (
    <div className="flex flex-col gap-8">
      {session.error && (
        <p role="alert" className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
          {session.error}
        </p>
      )}

      {}
      <section className="flex flex-col gap-3 rounded-xl border border-border bg-card p-4">
        <header className="flex items-start gap-2.5">
          <span className="mt-0.5 grid size-8 shrink-0 place-items-center rounded-lg bg-muted text-muted-foreground">
            <ClipboardPaste className="size-4" aria-hidden="true" />
          </span>
          <div className="flex flex-col gap-0.5">
            <h2 className="text-sm font-semibold">Paste text</h2>
            <p className="text-xs text-muted-foreground">
              Paste a note or document. The first line becomes its title.
            </p>
          </div>
        </header>
        <textarea
          value={draft}
          onChange={(e) => setDraft(e.target.value)}
          rows={5}
          placeholder="Paste text to index into the corpus…"
          aria-label="Text to index"
          className="w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
        />
        <div className="flex flex-wrap items-center gap-2">
          <button
            type="button"
            onClick={() => void addDraft()}
            disabled={!draftTrimmed || busy}
            className="inline-flex h-8 items-center rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground disabled:opacity-50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background"
          >
            Add to corpus
          </button>
          {session.documents.length === 0 && (
            <button
              type="button"
              onClick={() => void loadSamples()}
              disabled={busy}
              className="inline-flex h-8 items-center rounded-md border border-border px-3 text-sm disabled:opacity-50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
            >
              Load sample corpus
            </button>
          )}
          {busy && (
            <span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
              <Loader2 className="size-3.5 animate-spin" aria-hidden="true" />
              Indexing corpus…
            </span>
          )}
        </div>
      </section>

      {}
      <section className="flex flex-col gap-3">
        <header>
          <h2 className="text-sm font-semibold">Chunking</h2>
          <p className="text-xs text-muted-foreground">
            Applied by the engine on every ingest. Off stores one vector per document.
          </p>
        </header>
        <div>
          <SegmentedModePicker<ChunkingMode>
            aria-label="Chunking mode"
            items={[
              { id: 'off', label: 'Off' },
              { id: 'recursive', label: 'Recursive' },
              { id: 'semantic', label: 'Semantic' },
            ]}
            selectedId={session.chunking}
            onSelect={session.setChunking}
          />
        </div>
        {session.chunking === 'recursive' && (
          <div className="max-w-sm">
            <ParameterSlider
              label="Chunk size"
              value={session.chunkSize}
              onChange={session.setChunkSize}
              min={128}
              max={1024}
              step={32}
              unit="chars"
              disabled={busy}
              description="Target characters per recursive chunk."
            />
          </div>
        )}
        <div className="flex flex-col gap-2">
          <p className="text-xs text-muted-foreground">
            {previewChunks.length > 0
              ? `Draft preview: ${previewChunks.length} ${previewChunks.length === 1 ? 'chunk' : 'chunks'} · avg ${previewAvg} chars · ${countWords(draftTrimmed)} words`
              : 'Draft preview: paste text above to preview its chunks.'}
          </p>
          {session.chunking === 'semantic' && previewChunks.length > 0 && (
            <p className="text-xs text-muted-foreground">
              Semantic boundaries and similarity scores are computed with the embedding model during
              ingest - this preview shows an approximate recursive split.
            </p>
          )}
          <ChunkBoundaryVisualizer mode={session.chunking} chunks={previewChunks} maxCharsPerChunk={200} />
        </div>
      </section>

      {}
      <section className="flex flex-col gap-3 rounded-xl border border-border bg-card p-4">
        <header className="flex items-start gap-2.5">
          <span className="mt-0.5 grid size-8 shrink-0 place-items-center rounded-lg bg-muted text-muted-foreground">
            <FileText className="size-4" aria-hidden="true" />
          </span>
          <div className="flex flex-col gap-0.5">
            <h2 className="text-sm font-semibold">Upload PDF documents</h2>
            <p className="text-xs text-muted-foreground">
              Text is extracted per page, so search results carry page attribution.
            </p>
          </div>
        </header>
        <div role="group" aria-label="PDF upload">
          <FileDropzone
            accept={['application/pdf']}
            maxSize={MAX_FILE_SIZE}
            multiple
            disabled={busy && !pdfPipeline}
            processing={pdfPipeline !== null}
            processingLabel={
              pdfPipeline?.step === 'extract'
                ? `Extracting ${pdfPipeline.fileName}…`
                : 'Indexing into the corpus…'
            }
            label="Drop PDFs or click to browse"
            onUpload={(files) => void ingestPDFs(files)}
            onReject={onPdfReject}
          />
        </div>
        {pdfPipeline && (
          <div className="flex flex-col gap-2">
            <MultiStepPipelineTracker
              steps={PDF_STEPS}
              completed={pdfPipeline.step === 'extract' ? 0 : 1}
              currentStep={pdfPipeline.step === 'extract' ? PDF_STEPS[0] : PDF_STEPS[1]}
            />
            <div className="flex items-center gap-3 text-xs text-muted-foreground">
              {pdfPipeline.step === 'extract' ? (
                <>
                  <span>
                    Extracting file {pdfPipeline.fileIndex + 1}/{pdfPipeline.fileCount} -{' '}
                    {pdfPipeline.fileName}
                  </span>
                  <button
                    type="button"
                    onClick={cancelPdfExtract}
                    className="inline-flex h-6 items-center rounded-md border border-border px-2 text-xs focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
                  >
                    Cancel
                  </button>
                </>
              ) : (
                <span>
                  Chunking, embedding, and storing through the engine - the session reports a single
                  busy phase for this span.
                </span>
              )}
            </div>
          </div>
        )}
        {pdfErrors.length > 0 && (
          <div role="alert" className="flex flex-col gap-1 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2">
            <div className="flex items-center justify-between gap-2">
              <p className="text-xs font-medium text-destructive">
                {pdfErrors.length} {pdfErrors.length === 1 ? 'file' : 'files'} failed
              </p>
              <button
                type="button"
                onClick={() => setPdfErrors([])}
                aria-label="Dismiss PDF errors"
                className="text-destructive/70 hover:text-destructive focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
              >
                <X className="size-3.5" aria-hidden="true" />
              </button>
            </div>
            {pdfErrors.map((e, i) => (
              <p key={`${e.fileName}-${i}`} className="text-xs text-destructive">
                {e.fileName}: {e.message}
              </p>
            ))}
          </div>
        )}
      </section>

      {}
      <section className="flex flex-col gap-3 rounded-xl border border-border bg-card p-4">
        <header className="flex items-start gap-2.5">
          <span className="mt-0.5 grid size-8 shrink-0 place-items-center rounded-lg bg-muted text-muted-foreground">
            <ScanText className="size-4" aria-hidden="true" />
          </span>
          <div className="flex flex-col gap-0.5">
            <h2 className="text-sm font-semibold">Scan images (OCR)</h2>
            <p className="text-xs text-muted-foreground">
              Extract text from an image, then index it into the corpus. The OCR model downloads on
              the first run.
            </p>
          </div>
        </header>

        <div
          role="group"
          aria-label="OCR model selector"
          className="max-w-lg"
        >
          <OptionList
            prompt="OCR model"
            options={OCR_MODELS.map((m) => ({
              id: m.id,
              label: `${m.name} · ${m.size}`,
              description: m.description,
            }))}
            selectedId={ocrModelId}
            onSelect={selectOcrModel}
            disabled={extract.isLoading}
          />
        </div>

        {selectedOcrModel.generative && (
          <div className="flex flex-col gap-1.5">
            <p className="text-xs font-medium text-muted-foreground">Recognition mode</p>
            <SegmentedModePicker<OCRModeId>
              aria-label="OCR recognition mode"
              items={OCR_MODES.map((m) => ({ id: m.id, label: m.label }))}
              selectedId={ocrModeId}
              onSelect={(id) => {
                setOcrModeId(id);
                extract.reset();
              }}
            />
          </div>
        )}

        {!ocrImage ? (
          <div role="group" aria-label="OCR image upload">
            <MediaDropzone
              accept={OCR_ACCEPT}
              maxSize={MAX_FILE_SIZE}
              multiple={false}
              title="Drop an image here"
              subtitle="or click to browse"
              onFiles={(files) => void onOcrFiles(files)}
              onReject={onOcrReject}
            />
          </div>
        ) : (
          <div className="grid gap-4 md:grid-cols-2">
            <figure className="flex flex-col gap-2">
              {}
              <img
                src={ocrImage.dataUrl}
                alt={`Source image: ${ocrImage.fileName}`}
                className="max-h-80 w-full rounded-lg border border-border object-contain"
              />
              <figcaption className="truncate text-xs text-muted-foreground" title={ocrImage.fileName}>
                {ocrImage.fileName}
              </figcaption>
              <div className="flex flex-wrap items-center gap-2">
                <button
                  type="button"
                  onClick={() => void runOcr()}
                  disabled={extract.isLoading}
                  className="inline-flex h-8 items-center gap-1.5 rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground disabled:opacity-50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background"
                >
                  {extract.isLoading && <Loader2 className="size-3.5 animate-spin" aria-hidden="true" />}
                  {extract.isLoading ? 'Extracting…' : 'Extract text'}
                </button>
                {extract.isLoading && (
                  <button
                    type="button"
                    onClick={extract.cancel}
                    className="inline-flex h-8 items-center rounded-md border border-border px-3 text-sm focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
                  >
                    Cancel
                  </button>
                )}
                <button
                  type="button"
                  onClick={resetOcrLane}
                  disabled={extract.isLoading}
                  className="inline-flex h-8 items-center rounded-md border border-border px-3 text-sm disabled:opacity-50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
                >
                  Reset
                </button>
              </div>
            </figure>

            <div className="flex flex-col gap-2">
              <div className="flex items-center justify-between gap-2">
                <p className="text-xs font-medium text-muted-foreground">
                  Extracted text
                  {extractedText && (
                    <span className="ml-1.5 font-normal">
                      · {ocrWordCount} {ocrWordCount === 1 ? 'word' : 'words'}
                    </span>
                  )}
                </p>
                {extractedText && (
                  <button
                    type="button"
                    onClick={() => void copyExtracted()}
                    className={cn(
                      'inline-flex h-7 items-center gap-1 rounded-md border border-border px-2 text-xs focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50',
                      copied && 'border-primary/40 text-primary',
                    )}
                  >
                    {copied ? (
                      <Check className="size-3.5" aria-hidden="true" />
                    ) : (
                      <Copy className="size-3.5" aria-hidden="true" />
                    )}
                    {copied ? 'Copied' : 'Copy'}
                  </button>
                )}
              </div>
              <pre
                role="group"
                aria-label="Extracted OCR text"
                className="min-h-40 flex-1 overflow-auto whitespace-pre-wrap rounded-lg border border-border bg-card p-3 text-sm text-card-foreground"
              >
                {extractedText ||
                  (extract.isLoading ? 'Extracting…' : 'Run extraction to see the text here.')}
              </pre>
              <button
                type="button"
                onClick={() => void indexOcrText()}
                disabled={!extractedText.trim() || busy || extract.isLoading}
                className="inline-flex h-8 w-fit items-center rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground disabled:opacity-50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background"
              >
                Index into corpus
              </button>
            </div>
          </div>
        )}

        {(ocrLaneError || extract.error) && (
          <p role="alert" className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">
            {ocrLaneError ?? extract.error?.message}
          </p>
        )}
      </section>

      {}
      <section className="flex flex-col gap-3">
        <header className="flex flex-wrap items-end justify-between gap-3">
          <div>
            <h2 className="text-sm font-semibold">
              Corpus ({session.documents.length}{' '}
              {session.documents.length === 1 ? 'document' : 'documents'})
            </h2>
            <p className="text-xs text-muted-foreground">
              Chunk counts are estimates under the current chunking config.
            </p>
          </div>
          {session.documents.length > 0 &&
            (confirmingClear ? (
              <span className="inline-flex items-center gap-2">
                <button
                  type="button"
                  onClick={() => void clearAll()}
                  disabled={busy}
                  className="inline-flex h-8 items-center rounded-md bg-destructive px-3 text-sm font-medium text-white disabled:opacity-50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background"
                >
                  Confirm clear all
                </button>
                <button
                  type="button"
                  onClick={() => setConfirmingClear(false)}
                  className="inline-flex h-8 items-center rounded-md border border-border px-3 text-sm focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
                >
                  Cancel
                </button>
              </span>
            ) : (
              <button
                type="button"
                onClick={() => setConfirmingClear(true)}
                disabled={busy}
                className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-3 text-sm text-destructive disabled:opacity-50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
              >
                <Trash2 className="size-3.5" aria-hidden="true" />
                Clear all
              </button>
            ))}
        </header>

        <div className="flex max-w-md flex-col gap-1.5">
          <label htmlFor="semantic-search-embed-model-select" className="text-xs font-medium">
            Embedding model
          </label>
          <select
            id="semantic-search-embed-model-select"
            value={session.embeddingModelId}
            onChange={(e) => session.setEmbeddingModelId(e.target.value)}
            disabled={busy}
            className="h-9 rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-50"
          >
            {EMBEDDING_MODELS.map((m) => (
              <option key={m.id} value={m.id}>
                {m.label}
              </option>
            ))}
          </select>
          <p className="text-xs text-muted-foreground">
            Switching re-indexes the corpus with the new model.
          </p>
        </div>

        <div className="flex flex-col gap-2">
          {session.documents.length === 0 ? (
            <p className="rounded-lg border border-dashed border-border bg-card px-4 py-6 text-center text-sm text-muted-foreground">
              No documents yet - add text, load the sample corpus, drop a PDF, or scan an image.
            </p>
          ) : (
            session.documents.map((doc) => {
              const pageCount =
                doc.pages?.length ??
                (typeof doc.meta?.pages === 'number' ? doc.meta.pages : undefined);
              const sizeBytes =
                typeof doc.meta?.sizeBytes === 'number' ? doc.meta.sizeBytes : undefined;
              return (
                <article
                  key={doc.id}
                  data-doc-id={doc.id}
                  aria-label="Indexed document"
                  className="flex flex-col gap-1"
                >
                  <IndexedDocumentCard
                    filename={doc.title}
                    chunkCount={estimateChunkCount(doc.text.length, session.chunking, session.chunkSize)}
                    pageCount={pageCount}
                    sizeBytes={sizeBytes}
                  />
                  <div className="flex flex-wrap items-center gap-2 px-1 text-xs text-muted-foreground">
                    <span className="rounded bg-muted px-1.5 py-0.5 font-medium text-foreground">
                      {SOURCE_LABELS[doc.source]}
                    </span>
                    {doc.category && (
                      <span className="rounded border border-border px-1.5 py-0.5">{doc.category}</span>
                    )}
                    {typeof doc.meta?.ocrModel === 'string' && (
                      <span className="truncate">via {doc.meta.ocrModel}</span>
                    )}
                    <span>{formatRelativeTime(doc.addedAt)}</span>
                    <button
                      type="button"
                      onClick={() => void deleteDocument(doc.id)}
                      disabled={busy || deletingDocId !== null}
                      aria-label={`Delete ${doc.title}`}
                      aria-busy={deletingDocId === doc.id}
                      className="ml-auto inline-flex h-6 items-center gap-1 rounded-md px-1.5 text-destructive hover:bg-destructive/10 disabled:opacity-50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
                    >
                      {deletingDocId === doc.id ? (
                        <Loader2 className="size-3.5 animate-spin" aria-hidden="true" />
                      ) : (
                        <Trash2 className="size-3.5" aria-hidden="true" />
                      )}
                      Delete
                    </button>
                  </div>
                </article>
              );
            })
          )}
        </div>
      </section>
    </div>
  );
}


const RERANK_OVERFETCH = 3;

const FALLBACK_PRESET_THRESHOLD = 0.5;

type RerankerProvider = 'transformers' | 'wllama';

interface RerankerOption {
  id: string;
  provider: RerankerProvider;
  label: string;
  size: string;
}

const RERANKER_OPTIONS: RerankerOption[] = [
  {
    id: 'Xenova/ms-marco-MiniLM-L-6-v2',
    provider: 'transformers',
    label: 'MS MARCO MiniLM L-6 (cross-encoder)',
    size: '~23MB',
  },
  {
    id: 'Xenova/bge-reranker-base',
    provider: 'transformers',
    label: 'BGE Reranker Base (cross-encoder)',
    size: '~112MB',
  },
  {
    id: 'jina-reranker-v2-base-multilingual-Q4_K_M',
    provider: 'wllama',
    label: 'Jina Reranker v2 (GGUF)',
    size: '163MB',
  },
  {
    id: 'bge-reranker-v2-m3-Q4_K_M',
    provider: 'wllama',
    label: 'BGE Reranker v2 M3 (GGUF)',
    size: '218MB',
  },
];

type ThresholdMode = 'preset' | 'calibrated';

const THRESHOLD_MODES: Array<{ id: ThresholdMode; label: string }> = [
  { id: 'preset', label: 'Preset' },
  { id: 'calibrated', label: 'Calibrated' },
];

type QuantMode = 'off' | 'sq8' | 'pq';

const QUANT_MODES: Array<{ id: QuantMode; label: string }> = [
  { id: 'off', label: 'Off (float32)' },
  { id: 'sq8', label: 'SQ8 (4×)' },
  { id: 'pq', label: 'PQ (8-32×)' },
];


const rerankerCache = new Map<string, RerankerModel>();

function getRerankerModel(option: RerankerOption): RerankerModel {
  const cached = rerankerCache.get(option.id);
  if (cached) return cached;
  const model =
    option.provider === 'wllama' ? wllama.reranker(option.id) : transformers.reranker(option.id);
  rerankerCache.set(option.id, model);
  return model;
}

const embeddingCache = new Map<string, EmbeddingModel>();

function getEmbeddingModel(modelId: string): EmbeddingModel {
  const cached = embeddingCache.get(modelId);
  if (cached) return cached;
  const model = transformers.embedding(modelId);
  embeddingCache.set(modelId, model);
  return model;
}


function clamp01(value: number): number {
  return Math.min(1, Math.max(0, value));
}

function displayScore(result: KBSearchResult): number {
  return clamp01(result.rerankScore ?? result.score);
}

function ResultBadges({ result }: { result: KBSearchResult }) {
  const { source, category, page } = result.metadata;
  return (
    <span className="inline-flex flex-wrap items-center gap-1">
      <Badge>{source}</Badge>
      {category && <Badge tone="accent">{category}</Badge>}
      {page !== undefined && <Badge tone="page">p. {page}</Badge>}
    </span>
  );
}

function Badge({
  children,
  tone = 'muted',
}: {
  children: ReactNode;
  tone?: 'muted' | 'accent' | 'page';
}) {
  return (
    <span
      className={cn(
        'inline-flex items-center rounded-full px-1.5 py-0.5 text-[0.65rem] font-medium',
        tone === 'muted' && 'bg-muted text-muted-foreground',
        tone === 'accent' && 'bg-primary/10 text-primary',
        tone === 'page' && 'border border-border bg-card text-card-foreground',
      )}
    >
      {children}
    </span>
  );
}


export function SearchPanel({ session }: { session: SemanticSearchSession }) {
  const [query, setQuery] = useState('');
  const [topK, setTopK] = useState(5);
  const [rerankEnabled, setRerankEnabled] = useState(true);
  const [rerankerId, setRerankerId] = useState(RERANKER_OPTIONS[0].id);
  const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
  const [thresholdMode, setThresholdMode] = useState<ThresholdMode>('preset');

  const [results, setResults] = useState<KBSearchResult[] | null>(null);
  const [isSearching, setIsSearching] = useState(false);
  const [searchError, setSearchError] = useState<string | null>(null);
  const [searchMs, setSearchMs] = useState<number | null>(null);
  const [rerankMs, setRerankMs] = useState<number | null>(null);

  const [quantMode, setQuantMode] = useState<QuantMode>('off');
  const [gpuPreview, setGpuPreview] = useState(false);
  const [stats, setStats] = useState<EngineStats | null>(null);

  const selectedReranker =
    RERANKER_OPTIONS.find((option) => option.id === rerankerId) ?? RERANKER_OPTIONS[0];

  const rerankOp = useRerank({ model: getRerankerModel(selectedReranker) });

  const {
    calibration,
    isCalibrating,
    error: calibrationError,
    calibrate,
  } = useCalibrateThreshold({ model: getEmbeddingModel(session.embeddingModelId) });

  useEffect(() => {
    let active = true;
    session.engine
      .stats()
      .then((next) => {
        if (active) setStats(next);
      })
      .catch(() => {
        if (active) setStats(null);
      });
    return () => {
      active = false;
    };
  }, [session.engine, session.documents]);

  const documents = session.documents;
  const corpusEmpty = documents.length === 0;

  const presetThreshold =
    getDefaultThreshold(session.embeddingModelId) ?? FALLBACK_PRESET_THRESHOLD;
  const activeThreshold =
    thresholdMode === 'calibrated' && calibration ? calibration.threshold : presetThreshold;
  const thresholdLabel =
    thresholdMode === 'calibrated'
      ? isCalibrating
        ? 'calibrating…'
        : calibration
          ? `calibrated ${calibration.threshold.toFixed(2)} from ${calibration.sampleSize} docs`
          : `preset ${presetThreshold.toFixed(2)} (calibration pending)`
      : `preset ${presetThreshold.toFixed(2)}`;

  const categoryCounts = documents.reduce<Record<string, number>>((acc, doc) => {
    if (doc.category) acc[doc.category] = (acc[doc.category] ?? 0) + 1;
    return acc;
  }, {});
  const categories = Object.keys(categoryCounts);

  const searched = results !== null;
  const top = results?.[0] ?? null;

  const rawBytes = stats ? stats.chunks * stats.dimensions * 4 : null;

  const errorText =
    searchError ?? rerankOp.error?.message ?? calibrationError?.message ?? null;


  const runSearch = async (category: string | null = selectedCategory) => {
    const trimmed = query.trim();
    if (!trimmed || isSearching || session.busy || corpusEmpty) return;

    setSearchError(null);
    setIsSearching(true);
    setSearchMs(null);
    setRerankMs(null);

    try {
      const fetchK = rerankEnabled ? topK * RERANK_OVERFETCH : topK;
      const searchStartedAt = performance.now();
      const hits = await session.engine.search(trimmed, {
        topK: fetchK,
        filter: category ? { category } : undefined,
        minScore: activeThreshold,
      });
      setSearchMs(Math.round(performance.now() - searchStartedAt));

      let ranked = hits;
      if (rerankEnabled && hits.length > 0) {
        const rerankStartedAt = performance.now();
        const reranked = await rerankOp.execute({
          query: trimmed,
          documents: hits.map((hit) => hit.metadata.text),
        });
        if (reranked) {
          ranked = reranked.results.map((doc) => ({
            ...hits[doc.index],
            rerankScore: doc.score,
          }));
          setRerankMs(Math.round(performance.now() - rerankStartedAt));
        }
      }

      setResults(ranked.slice(0, topK));
    } catch (err) {
      setSearchError(err instanceof Error ? err.message : String(err));
    } finally {
      setIsSearching(false);
    }
  };

  const handleCategorySelect = (category: string | null) => {
    setSelectedCategory(category);
    if (searched) void runSearch(category);
  };

  const handleThresholdMode = (mode: ThresholdMode) => {
    setThresholdMode(mode);
    if (mode === 'calibrated' && !calibration && !isCalibrating && documents.length >= 2) {
      void calibrate(documents.map((doc) => doc.text));
    }
  };

  return (
    <div className="flex flex-col gap-4">
      {}
      <div className="flex flex-wrap items-center gap-2">
        <input
          value={query}
          onChange={(event) => setQuery(event.target.value)}
          onKeyDown={(event) => {
            if (event.key === 'Enter') void runSearch();
          }}
          placeholder="Search the knowledge base…"
          aria-label="Search query"
          className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
        />
        <button
          type="button"
          onClick={() => void runSearch()}
          disabled={!query.trim() || isSearching || session.busy || corpusEmpty}
          className="inline-flex h-8 items-center rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground disabled:opacity-50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background"
        >
          {isSearching ? (rerankOp.isLoading ? 'Reranking…' : 'Searching…') : 'Search'}
        </button>
        <span
          data-ms={searchMs === null ? undefined : String(searchMs)}
          className="text-xs tabular-nums text-muted-foreground"
        >
          {searchMs !== null ? `search ${searchMs}ms` : 'search -'}
        </span>
        <span
          data-ms={rerankMs === null ? undefined : String(rerankMs)}
          role="group"
          aria-label="Rerank latency"
          className="text-xs tabular-nums text-muted-foreground"
        >
          {rerankMs !== null ? `rerank ${rerankMs}ms` : rerankEnabled ? 'rerank -' : 'rerank off'}
        </span>
      </div>

      {errorText && <p className="text-xs text-destructive">{errorText}</p>}
      {corpusEmpty && (
        <p className="text-xs text-muted-foreground">
          The corpus is empty - ingest documents in the Ingest tab, then search here.
        </p>
      )}

      {}
      <section className="grid gap-4 rounded-lg border border-border bg-card p-4 md:grid-cols-3">
        <div data-value={String(topK)}>
          <ParameterSlider
            label="Top-K"
            value={topK}
            onChange={(value) => setTopK(Math.round(value))}
            min={1}
            max={20}
            step={1}
            unit="results"
            description={`Results returned; rerank over-fetches ×${RERANK_OVERFETCH} candidates.`}
          />
        </div>

        <div className="flex flex-col gap-2">
          <div className="flex items-center justify-between gap-2">
            <span className="text-sm font-medium">Rerank stage</span>
            <button
              type="button"
              role="switch"
              aria-checked={rerankEnabled}
              aria-label="Rerank stage"
              data-state={rerankEnabled ? 'on' : 'off'}
              onClick={() => setRerankEnabled((enabled) => !enabled)}
              className={cn(
                'inline-flex h-7 items-center rounded-full border px-2.5 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50',
                rerankEnabled
                  ? 'border-primary bg-primary text-primary-foreground'
                  : 'border-border bg-muted text-muted-foreground',
              )}
            >
              {rerankEnabled ? 'On' : 'Off'}
            </button>
          </div>
          <select
            aria-label="Reranker model"
            value={rerankerId}
            onChange={(event) => setRerankerId(event.target.value)}
            disabled={!rerankEnabled || rerankOp.isLoading}
            className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm disabled:opacity-50"
          >
            <optgroup label="Transformers (ONNX cross-encoders)">
              {RERANKER_OPTIONS.filter((option) => option.provider === 'transformers').map(
                (option) => (
                  <option key={option.id} value={option.id}>
                    {option.label} · {option.size}
                  </option>
                ),
              )}
            </optgroup>
            <optgroup label="wllama (GGUF cross-encoders)">
              {RERANKER_OPTIONS.filter((option) => option.provider === 'wllama').map((option) => (
                <option key={option.id} value={option.id}>
                  {option.label} · {option.size}
                </option>
              ))}
            </optgroup>
          </select>
          <p className="text-xs text-muted-foreground">
            Reranker model ({selectedReranker.size}) downloads on the first reranked search - nothing
            is fetched until then.
          </p>
        </div>

        <div className="flex flex-col gap-2">
          <span className="text-sm font-medium">Minimum score</span>
          <div data-value={thresholdMode}>
            <SegmentedModePicker
              aria-label="Similarity threshold source"
              items={THRESHOLD_MODES}
              selectedId={thresholdMode}
              onSelect={handleThresholdMode}
            />
          </div>
          <span
            data-threshold={String(activeThreshold)}
            className="text-xs tabular-nums text-muted-foreground"
            aria-busy={isCalibrating}
          >
            {thresholdLabel}
          </span>
          {thresholdMode === 'calibrated' && (
            <button
              type="button"
              onClick={() => void calibrate(documents.map((doc) => doc.text))}
              disabled={isCalibrating || documents.length < 2}
              className="inline-flex h-7 w-fit items-center rounded-md border border-border px-2.5 text-xs disabled:opacity-50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
            >
              {isCalibrating ? 'Calibrating…' : 'Recalibrate'}
            </button>
          )}
          {thresholdMode === 'calibrated' && documents.length < 2 && (
            <p className="text-xs text-muted-foreground">
              Calibration needs at least 2 documents in the corpus.
            </p>
          )}
        </div>
      </section>

      {}
      <div className="grid gap-4 md:grid-cols-[220px_1fr]">
        <div>
          <h2 className="mb-2 text-sm font-medium">Categories</h2>
          <CategoryFacetList
            categories={categories}
            counts={categoryCounts}
            selected={selectedCategory}
            onSelect={handleCategorySelect}
          />
          {categories.length === 0 && (
            <p className="mt-1 text-xs text-muted-foreground">
              No categories yet - the sample corpus and categorized ingests populate this list.
            </p>
          )}
        </div>

        <div
          role="region"
          aria-label="Search results"
          className="flex min-w-0 flex-col gap-4"
        >
          {}
          <div className="flex flex-wrap items-center gap-3 text-sm">
            <span className="font-medium">
              Results:{' '}
              <span
                role="group"
                aria-label="Result count"
              >
                {results?.length ?? 0}
              </span>
            </span>
            {top && (
              <>
                <span
                  role="group"
                  aria-label="Top result title"
                >
                  {top.metadata.docTitle}
                </span>
                <span
                  data-score={String(top.score)}
                  role="group"
                  aria-label="Top result score"
                  className="rounded-full bg-muted px-2 py-0.5 text-xs tabular-nums text-muted-foreground"
                  title="Raw vector similarity of the best hit"
                >
                  {(clamp01(top.score) * 100).toFixed(1)}%
                </span>
              </>
            )}
          </div>

          {top && (
            <div className="grid items-start gap-4 lg:grid-cols-[1fr_auto]">
              <TopResultCard
                label={top.metadata.docTitle}
                score={displayScore(top)}
                title={
                  top.rerankScore !== undefined
                    ? `Top result - reranked by ${selectedReranker.label}`
                    : 'Top result - vector ranked'
                }
                description={
                  <span className="flex flex-col gap-1.5">
                    <ResultBadges result={top} />
                    <span className="line-clamp-3">{top.metadata.text}</span>
                    <span className="tabular-nums">
                      vector {top.score.toFixed(3)}
                      {top.rerankScore !== undefined && ` · rerank ${top.rerankScore.toFixed(3)}`}
                    </span>
                  </span>
                }
              />
              <CosineSimilarityMeter
                similarity={clamp01(top.score)}
                caption="raw vector score (query ↔ top chunk)"
              />
            </div>
          )}

          {}
          <ScoredResultBarList
            isLoading={isSearching}
            sort={false}
            results={(results ?? []).slice(1).map((result) => ({
              label: `${result.metadata.docTitle} - chunk ${result.metadata.chunkIndex + 1}`,
              score: displayScore(result),
            }))}
            emptyState={
              searched
                ? results && results.length > 0
                  ? 'No further results'
                  : 'No results above the minimum score'
                : 'Run a search to see ranked results'
            }
          />

          {}
          {results && results.length > 0 && (
            <ol className="flex flex-col gap-2">
              {results.map((result, rank) => (
                <li
                  key={result.id}
                  data-id={result.id}
                  data-raw-score={String(result.score)}
                  data-rerank-score={result.rerankScore === undefined ? '' : String(result.rerankScore)}
                  className="rounded-md border border-border bg-card px-3 py-2"
                >
                  <div className="flex flex-wrap items-center gap-2 text-sm">
                    <span className="font-mono text-xs text-muted-foreground">#{rank + 1}</span>
                    <span className="min-w-0 flex-1 truncate font-medium">
                      {result.metadata.docTitle}
                    </span>
                    <ResultBadges result={result} />
                  </div>
                  <p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
                    {result.metadata.text}
                  </p>
                  <p className="mt-1 text-xs tabular-nums text-muted-foreground">
                    vector {result.score.toFixed(3)}
                    {result.rerankScore !== undefined && ` · rerank ${result.rerankScore.toFixed(3)}`}
                  </p>
                </li>
              ))}
            </ol>
          )}
        </div>
      </div>

      {}
      <section className="flex flex-col gap-3 rounded-lg border border-border bg-card p-4">
        <div className="flex flex-col gap-0.5">
          <h2 className="text-sm font-medium">
            Vector storage &amp; acceleration{' '}
            <span className="font-normal text-muted-foreground">(preview)</span>
          </h2>
          <a
            href="https://localmode.dev/docs/core/vector-quantization"
            target="_blank"
            rel="noopener noreferrer"
            className="w-fit text-xs text-primary underline underline-offset-2 hover:text-primary/80 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
          >
            View the implementation
          </a>
        </div>
        <div data-value={quantMode}>
          <SegmentedModePicker
            aria-label="Vector quantization preview"
            items={QUANT_MODES}
            selectedId={quantMode}
            onSelect={setQuantMode}
          />
        </div>
        <p className="text-xs tabular-nums text-muted-foreground">
          {stats && rawBytes !== null
            ? quantMode === 'off'
              ? `${stats.chunks} chunks × ${stats.dimensions} dims ≈ ${formatBytes(rawBytes)} at float32`
              : quantMode === 'sq8'
                ? `SQ8 would store ≈ ${formatBytes(rawBytes / 4)} (4× smaller than ${formatBytes(rawBytes)} float32)`
                : `PQ would store ≈ ${formatBytes(rawBytes / 32)} - ${formatBytes(rawBytes / 8)} (8-32× smaller than ${formatBytes(rawBytes)} float32)`
            : 'Ingest documents to see live storage estimates.'}
        </p>
        <CapabilityGate
          requires="webgpu"
          fallback={
            <p className="text-xs text-muted-foreground">
              WebGPU is not available on this device - vector search runs on the CPU/WASM fallback
              path. Chrome/Edge 113+ ship WebGPU.
            </p>
          }
        >
          <div className="flex items-center gap-2">
            <button
              type="button"
              role="switch"
              aria-checked={gpuPreview}
              data-state={gpuPreview ? 'on' : 'off'}
              onClick={() => setGpuPreview((enabled) => !enabled)}
              className={cn(
                'inline-flex h-7 items-center rounded-full border px-2.5 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50',
                gpuPreview
                  ? 'border-primary bg-primary text-primary-foreground'
                  : 'border-border bg-muted text-muted-foreground',
              )}
            >
              WebGPU-accelerated search {gpuPreview ? 'on' : 'off'}
            </button>
            <span className="text-xs text-muted-foreground">WebGPU detected on this device.</span>
          </div>
        </CapabilityGate>
        <p className="text-xs text-muted-foreground">
          Preview only: the knowledge-base engine contract exposes no quantization or GPU options, so
          these controls do not change how vectors are stored or searched. Sizes are honest estimates
          from live engine stats.
        </p>
      </section>
    </div>
  );
}


export function SemanticSearchBlock() {
  const [activeTab, setActiveTab] = useState<TabId>('ingest');

  const idRef = useRef(DEFAULT_EMBEDDING_MODEL_ID);

  const kb = useKnowledgeBase({
    embeddingModelId: DEFAULT_EMBEDDING_MODEL_ID,
    chunking: 'recursive',
    chunkSize: 512,
    createEmbeddingModel: (id, onProgress) =>
      transformers.embedding(id, {
        onProgress: (p) => onProgress(p as Parameters<typeof onProgress>[0]),
      }),
    isModelCached: (id) => isModelCached(id),
    createEngine: async (kind) => {
      const embeddingModel = transformers.embedding(idRef.current);
      const getLanguageModel = async () => {
        const device = (await isWebGPUSupported()) ? 'webgpu' : 'wasm';
        return transformers.languageModel(ANSWER_MODEL_ID, { device });
      };
      if (kind === 'langchain') {
        const { createLangChainKnowledgeBaseEngine, ChatLocalMode } = await import(
          '@localmode/langchain'
        );
        return createLangChainKnowledgeBaseEngine({
          embeddingModel,
          getChatModel: async () =>
            new ChatLocalMode({ model: await getLanguageModel(), maxTokens: ANSWER_MAX_TOKENS }),
        });
      }
      const { createKnowledgeBaseEngine } = await import('@localmode/core');
      return createKnowledgeBaseEngine({ embeddingModel, getLanguageModel });
    },
  });
  idRef.current = kb.embeddingModelId;

  const requestEngineKind = (kind: EngineKind) => {
    if (kb.busy || !kb.engine || kind === kb.engineKind) return;
    kb.setEngineKind(kind);
  };

  const session: SemanticSearchSession | null = kb.engine
    ? {
        engine: kb.engine,
        engineKind: kb.engineKind,
        documents: kb.documents,
        addDocuments: kb.addDocuments,
        removeDocument: kb.removeDocument,
        clearAll: kb.clearAll,
        chunking: kb.chunking,
        setChunking: kb.setChunking,
        chunkSize: kb.chunkSize,
        setChunkSize: kb.setChunkSize,
        embeddingModelId: kb.embeddingModelId,
        setEmbeddingModelId: kb.setEmbeddingModelId,
        busy: kb.busy,
        error: kb.error,
      }
    : null;

  const errorText = kb.error;
  const progressTick = kb.reingestProgress ?? kb.ingestProgress;
  const working = kb.busy || (!kb.engine && kb.documents.length > 0);
  const statusText = working
    ? kb.modelStatus === 'loading'
      ? `loading embedding model… ${Math.round(kb.modelProgress * 100)}%`
      : progressTick
        ? `indexing - ${progressTick.phase} ${progressTick.completed}/${progressTick.total}`
        : kb.switching
          ? `re-ingesting ${kb.documents.length} docs through the ${ENGINE_LABELS[kb.engineKind]} engine…`
          : 'indexing…'
    : errorText
      ? 'error'
      : kb.documents.length > 0
        ? `ready - ${kb.documents.length} docs indexed, ${kb.stats?.chunks ?? 0} chunks`
        : IDLE_STATUS;

  const modelMeta =
    EMBEDDING_MODEL_META[kb.embeddingModelId] ?? { name: kb.embeddingModelId, size: '' };
  const reingest = kb.reingestProgress;
  const reingestFraction = reingest && reingest.total > 0 ? reingest.completed / reingest.total : 0;

  return (
    <div className="mx-auto flex max-w-5xl flex-col gap-4 p-4">
      {}
      <p
        role="status"
        aria-live="polite"
        className="text-xs text-muted-foreground"
      >
        {statusText}
      </p>
      {errorText && (
        <p className="text-xs text-destructive">
          {errorText}
        </p>
      )}

      {}
      <div className="flex flex-wrap items-center gap-2">
        <div
          data-engine={kb.engineKind}
          role="group"
          aria-label="Pipeline engine"
          className="inline-flex items-center rounded-md border border-border bg-muted/40 p-0.5"
        >
          {(Object.keys(ENGINE_LABELS) as EngineKind[]).map((kind) => (
            <button
              key={kind}
              type="button"
              data-engine-option={kind}
              aria-pressed={kb.engineKind === kind}
              onClick={() => requestEngineKind(kind)}
              disabled={!kb.engine || kb.busy}
              className={cn(
                'inline-flex h-7 items-center rounded px-2.5 text-xs font-medium transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50',
                kb.engineKind === kind
                  ? 'bg-background text-foreground shadow-sm'
                  : 'text-muted-foreground hover:text-foreground',
              )}
            >
              {ENGINE_LABELS[kind]} engine
            </button>
          ))}
        </div>

        <span
          data-docs={kb.documents.length}
          data-chunks={kb.stats?.chunks ?? 0}
          role="group"
          aria-label="Corpus size"
          className="ml-auto text-xs tabular-nums text-muted-foreground"
        >
          {kb.documents.length} docs · {kb.stats?.chunks ?? 0} chunks
          {kb.stats ? ` · ${kb.stats.dimensions}d` : ''}
        </span>
      </div>

      {}
      <div
        data-status={kb.modelStatus}
        data-model-id={kb.embeddingModelId}
        role="group"
        aria-label="Embedding model status"
      >
        {kb.modelStatus === 'idle' ? (
          <p className="text-xs text-muted-foreground">
            Embedding model: <span className="font-medium">{modelMeta.name}</span>
            {modelMeta.size ? ` (${modelMeta.size})` : ''} - not loaded. It downloads on the first
            ingest or an engine/model switch.
          </p>
        ) : (
          <ModelDownloader
            name={modelMeta.name}
            size={modelMeta.size || undefined}
            category="Embedding"
            progress={kb.modelProgressValue}
            cached={kb.modelCached}
            ready={kb.modelStatus === 'ready'}
            className="max-w-sm"
          />
        )}
      </div>

      {}
      {kb.switching && (
        <div
          data-phase={reingest?.phase ?? 'model'}
          role="status"
          aria-live="polite"
          aria-label="Re-ingest progress"
          className="flex flex-col gap-1.5 rounded-xl border border-border bg-card p-3"
        >
          <p className="text-xs font-medium">
            Re-ingesting {kb.documents.length} document{kb.documents.length === 1 ? '' : 's'} through
            the {ENGINE_LABELS[kb.engineKind]} engine
            {reingest ? ` - ${reingest.phase} ${reingest.completed}/${reingest.total}` : '…'}
          </p>
          <DownloadProgress value={reingestFraction} complete={false} />
        </div>
      )}

      {}
      <div
        role="tablist"
        aria-label="Semantic search"
        className="grid grid-cols-2 gap-1 rounded-lg border border-border bg-muted/50 p-1"
      >
        {TABS.map((tab) => {
          const Icon = TAB_ICONS[tab.id];
          const selected = activeTab === tab.id;
          return (
            <button
              key={tab.id}
              type="button"
              role="tab"
              aria-selected={selected}
              onClick={() => setActiveTab(tab.id)}
              className={cn(
                'inline-flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50',
                selected
                  ? 'bg-background text-foreground shadow-sm'
                  : 'text-muted-foreground hover:text-foreground',
              )}
            >
              <Icon className="size-4" aria-hidden="true" />
              {tab.label}
            </button>
          );
        })}
      </div>

      {}
      <div
        data-tab={activeTab}
        role="group"
        aria-label="Active tab"
        className="min-h-48"
      >
        {session ? (
          <>
            <div role="tabpanel" aria-label="Ingest" className={cn(activeTab !== 'ingest' && 'hidden')}>
              <IngestPanel session={session} />
            </div>
            <div role="tabpanel" aria-label="Search" className={cn(activeTab !== 'search' && 'hidden')}>
              <SearchPanel session={session} />
            </div>
          </>
        ) : (
          <p className="p-4 text-sm text-muted-foreground">
            {kb.switching || kb.documents.length > 0
              ? 'Re-ingesting the corpus…'
              : 'Preparing engine…'}
          </p>
        )}
      </div>
    </div>
  );
}
```

## Document QA

Ask questions about your own documents and get direct answers, all on your device. It pulls each answer straight from your text and shows how confident it is. You can also ask about an uploaded image, like an invoice or a scanned page. Nothing downloads until you start.

**Install**

```bash
npx shadcn@latest add @localmode/ui/blocks/knowledge/document-qa
```

**Full block (all files):** https://localmode.ai/r/ui/blocks/knowledge/document-qa.json

```tsx
'use client';

/**
 * @file document-qa.tsx
 * @description Document QA block — extractive SQuAD QA grounded on a core-engine corpus (topK-1 search) or a pasted document, plus Donut invoice/document QA over an uploaded image, each with High/Medium/Low confidence tiers.
 * @constraint Core engine only — never imports @localmode/langchain or @localmode/wllama.
 */

import { useRef, useState } from 'react';
import { Loader2, Trash2, X } from 'lucide-react';
import {
  answerQuestion,
  askDocument,
  createKnowledgeBaseEngine,
  isWebGPUSupported,
  type DocumentQAModel,
  type KBSearchResult,
  type KnowledgeBaseEngine,
  type QuestionAnsweringModel,
  type RawDocument,
} from '@localmode/core';
import {
  readFileAsDataUrl,
  useAnswerQuestion,
  useAskDocument,
  useKnowledgeBase,
  useModelLoad,
  type UseModelLoadReturn,
} from '@localmode/react';
import { isModelCached, transformers } from '@localmode/transformers';

import {
  ConfidenceScoreBadge,
  resolveTier,
  type ConfidenceTier,
} from '@/components/confidence-score-badge';
import { ModelDownloader } from '@/components/model-downloader';
import { FileDropzone, type RejectedFile } from '@/components/file-dropzone';
import { IndexedDocumentCard } from '@/components/indexed-document-card';
import { MediaDropzone, type MediaDropzoneRejection } from '@/components/media-dropzone';
import { MultiStepPipelineTracker } from '@/components/pipeline-tracker';


const EMBEDDING_MODEL_ID = 'Xenova/bge-small-en-v1.5';
const EMBEDDING_MODEL_META = { name: 'BGE Small EN v1.5', size: '~34 MB' };

const ANSWER_MODEL_ID = 'onnx-community/granite-4.0-350m-ONNX-web';

const QA_MODEL_ID = 'Xenova/distilbert-base-cased-distilled-squad';
const QA_MODEL_SIZE = '~260 MB';

const DONUT_MODEL_ID = 'Xenova/donut-base-finetuned-docvqa';
const DONUT_MODEL_SIZE = '~800 MB';

const DOC_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/webp'];
const MAX_FILE_SIZE = 10_000_000;

const QA_SAMPLE_CONTEXT = `The Amazon rainforest, also known as Amazonia, is a moist broadleaf tropical rainforest in the Amazon biome that covers most of the Amazon basin of South America. This basin encompasses 7,000,000 km2, of which 5,500,000 km2 are covered by the rainforest. This region includes territory belonging to nine nations and 3,344 formally acknowledged indigenous territories. The majority of the forest is contained within Brazil, with 60% of the rainforest, followed by Peru with 13%, Colombia with 10%, and with minor amounts in Bolivia, Ecuador, French Guiana, Guyana, Suriname, and Venezuela. The Amazon represents over half of the planet's remaining rainforests.`;

const QA_SAMPLE_QUESTIONS = [
  'How large is the Amazon basin?',
  'Which country has the most rainforest?',
  'How many nations have territory in the basin?',
  'What share of the planet’s remaining rainforests does the Amazon represent?',
];

const DONUT_EXAMPLE_QUESTIONS = [
  'What is the total amount?',
  'What is the invoice number?',
  'Who is the sender?',
  'What is the date?',
  'What is the billing address?',
];

const TIER_LABEL: Record<ConfidenceTier, string> = {
  high: 'High',
  medium: 'Medium',
  low: 'Low',
};

const SOURCE_LABELS: Record<RawDocument['source'], string> = {
  text: 'Text',
  sample: 'Sample',
  pdf: 'PDF',
  ocr: 'OCR',
  import: 'Import',
};

const SAMPLE_CORPUS: Array<Omit<RawDocument, 'id' | 'addedAt'>> = [
  {
    title: 'Privacy and encryption on device',
    category: 'security',
    source: 'sample',
    text: 'Privacy and encryption go hand in hand: encrypting personal data with AES-GCM keys derived on the device keeps private information confidential, and no plaintext ever leaves the browser.',
  },
  {
    title: 'Spring vegetable gardening',
    category: 'home',
    source: 'sample',
    text: 'Plant tomatoes and peppers after the last frost. Water seedlings daily and mulch the beds to keep weeds down through the warm months.',
  },
  {
    title: 'Road cycling basics',
    category: 'sports',
    source: 'sample',
    text: 'A correct saddle height prevents knee pain on long rides. Carry a spare tube, tire levers, and a mini pump on every road ride.',
  },
  {
    title: 'Fresh pasta dough',
    category: 'food',
    source: 'sample',
    text: 'Combine flour and eggs, knead for ten minutes, and rest the dough for half an hour before rolling thin sheets for tagliatelle.',
  },
  {
    title: 'Backyard astronomy',
    category: 'science',
    source: 'sample',
    text: 'A small refractor telescope shows the rings of Saturn and the moons of Jupiter. Dark skies away from city lights reveal the Milky Way.',
  },
  {
    title: 'Budgeting for beginners',
    category: 'money',
    source: 'sample',
    text: 'Track monthly income and expenses, build a three-month emergency fund first, and automate transfers into savings on payday.',
  },
  {
    title: 'Marathon training plan',
    category: 'sports',
    source: 'sample',
    text: 'Increase weekly mileage by no more than ten percent. Long slow runs on weekends build the aerobic base needed for race day.',
  },
  {
    title: 'A year of soups',
    category: 'food',
    source: 'sample',
    text: 'In spring, light broths with peas, asparagus, and fresh herbs make a bright start to the season. A simple stock simmered from vegetable trimmings carries delicate flavors without overpowering them. Summer calls for chilled soups: gazpacho blends ripe tomatoes, cucumber, and peppers into a refreshing bowl that needs no stove at all. When autumn arrives, roasted squash and root vegetables become velvety purees, finished with cream and a pinch of nutmeg. Winter is the season of slow simmering: beans, lentils, and smoked meats braise for hours until the broth turns rich and deeply savory, perfect with crusty bread by the fire.',
  },
];

const BTN_PRIMARY =
  'inline-flex h-8 items-center rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground disabled:opacity-50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background';
const BTN_SECONDARY =
  'inline-flex h-8 items-center rounded-md border border-border px-3 text-sm disabled:opacity-50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50';
const PILL =
  'rounded-full border border-border bg-card px-3 py-1 text-xs text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground disabled:opacity-50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50';
const INPUT =
  'h-8 min-w-0 flex-1 rounded-md border border-input bg-background px-3 text-sm focus-visible:ring-[3px] focus-visible:ring-ring/50';


function sourceTitle(result: KBSearchResult) {
  const { docTitle, page } = result.metadata;
  return page != null ? `${docTitle} · p. ${page}` : docTitle;
}

function errorMessage(err: unknown) {
  return err instanceof Error ? err.message : String(err);
}

function isAbort(err: unknown) {
  return err instanceof DOMException && err.name === 'AbortError';
}

function deriveTitle(text: string) {
  const firstLine = text
    .split('\n')
    .map((l) => l.replace(/^#+\s*/, '').trim())
    .find((l) => l.length > 0);
  if (!firstLine) return 'Untitled note';
  return firstLine.length > 64 ? `${firstLine.slice(0, 64).trimEnd()}…` : firstLine;
}

function estimateChunkCount(textLength: number, chunkSize: number) {
  return Math.max(1, Math.ceil(textLength / Math.max(1, chunkSize)));
}

function segButtonClass(active: boolean) {
  return `rounded-md px-3 py-1.5 text-sm transition-colors focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 ${
    active
      ? 'bg-background font-medium text-foreground shadow-sm'
      : 'text-muted-foreground hover:text-foreground'
  }`;
}


const ASK_MODES = [
  { id: 'extractive', label: 'Extractive QA', testid: 'document-qa:mode-extractive' },
  { id: 'donut', label: 'Document QA', testid: 'document-qa:mode-donut' },
] as const;

type AskMode = (typeof ASK_MODES)[number]['id'];

interface Corpus {
  engine: KnowledgeBaseEngine;
  documents: RawDocument[];
  busy: boolean;
}

export function DocumentQaBlock() {
  const idRef = useRef(EMBEDDING_MODEL_ID);

  const kb = useKnowledgeBase({
    embeddingModelId: EMBEDDING_MODEL_ID,
    engineKind: 'core',
    createEmbeddingModel: (id, onProgress) =>
      transformers.embedding(id, {
        onProgress: (p) => onProgress(p as Parameters<typeof onProgress>[0]),
      }),
    isModelCached: (id) => isModelCached(id),
    createEngine: () => {
      const embeddingModel = transformers.embedding(idRef.current);
      const getLanguageModel = async () => {
        const device = (await isWebGPUSupported()) ? 'webgpu' : 'wasm';
        return transformers.languageModel(ANSWER_MODEL_ID, { device });
      };
      return createKnowledgeBaseEngine({ embeddingModel, getLanguageModel });
    },
  });
  idRef.current = kb.embeddingModelId;

  const [mode, setMode] = useState<AskMode>('extractive');
  const [draft, setDraft] = useState('');
  const [deletingDocId, setDeletingDocId] = useState<string | null>(null);
  const [confirmingClear, setConfirmingClear] = useState(false);

  const [pdfPipeline, setPdfPipeline] = useState<{
    step: 'extract' | 'ingest';
    fileIndex: number;
    fileCount: number;
    fileName: string;
  } | null>(null);
  const [pdfErrors, setPdfErrors] = useState<Array<{ fileName: string; message: string }>>([]);
  const pdfAbortRef = useRef<AbortController | null>(null);

  const busy = kb.busy;
  const draftTrimmed = draft.trim();
  const hasDocs = kb.documents.length > 0;


  const addDraft = async () => {
    if (!draftTrimmed || busy) return;
    await kb.addDocuments([
      { title: deriveTitle(draftTrimmed), text: draftTrimmed, source: 'text' },
    ]);
    setDraft('');
  };

  const loadSamples = async () => {
    if (busy || hasDocs) return;
    await kb.addDocuments(SAMPLE_CORPUS);
  };

  const ingestPDFs = async (files: File[]) => {
    if (busy || pdfPipeline) return;
    setPdfErrors([]);

    const controller = new AbortController();
    pdfAbortRef.current = controller;

    const docs: Array<Omit<RawDocument, 'id' | 'addedAt'>> = [];
    const errors: Array<{ fileName: string; message: string }> = [];

    for (let i = 0; i < files.length; i++) {
      const file = files[i];
      setPdfPipeline({ step: 'extract', fileIndex: i, fileCount: files.length, fileName: file.name });
      try {
        const { extractPDFText } = await import('@localmode/pdfjs');
        const result = await extractPDFText(file, {
          includePageNumbers: false,
          pageSeparator: '\n\n',
          abortSignal: controller.signal,
        });
        if (!result.text.trim()) {
          errors.push({
            fileName: file.name,
            message: 'No extractable text - the PDF may be scanned images or protected.',
          });
          continue;
        }
        docs.push({
          title: file.name,
          text: result.text,
          source: 'pdf',
          meta: { pages: result.pageCount, sizeBytes: file.size },
          pages: result.pages.map((p) => ({ page: p.pageNumber, text: p.text })),
        });
      } catch (err) {
        if (controller.signal.aborted || isAbort(err)) {
          setPdfPipeline(null);
          setPdfErrors(errors);
          return;
        }
        errors.push({ fileName: file.name, message: errorMessage(err) });
      }
    }

    setPdfErrors(errors);

    if (docs.length > 0) {
      setPdfPipeline({ step: 'ingest', fileIndex: files.length, fileCount: files.length, fileName: '' });
      try {
        await kb.addDocuments(docs);
      } finally {
        setPdfPipeline(null);
      }
    } else {
      setPdfPipeline(null);
    }
  };

  const deleteDocument = async (docId: string) => {
    if (busy || deletingDocId) return;
    setDeletingDocId(docId);
    try {
      await kb.removeDocument(docId);
    } finally {
      setDeletingDocId(null);
    }
  };

  const clearAll = async () => {
    if (busy) return;
    setConfirmingClear(false);
    await kb.clearAll();
  };


  const errorText = kb.error;
  const tick = kb.ingestProgress;
  const working = busy || (!kb.engine && hasDocs);
  const statusText = working
    ? kb.modelStatus === 'loading'
      ? `loading embedding model… ${Math.round(kb.modelProgress * 100)}%`
      : tick
        ? `indexing - ${tick.phase} ${tick.completed}/${tick.total}`
        : 'indexing…'
    : errorText
      ? 'error'
      : hasDocs
        ? `ready - ${kb.documents.length} docs indexed, ${kb.stats?.chunks ?? 0} chunks`
        : 'idle - paste text, load the sample corpus, or upload a document image to begin';

  return (
    <div className="mx-auto flex max-w-4xl flex-col gap-4 p-4">
      {}
      <p
        role="status"
        aria-live="polite"
        className="text-xs text-muted-foreground"
      >
        {statusText}
      </p>
      {errorText && (
        <p className="text-xs text-destructive">
          {errorText}
        </p>
      )}

      {}
      <div
        data-status={kb.modelStatus}
        data-model-id={kb.embeddingModelId}
        role="group"
        aria-label="Embedding model status"
      >
        {kb.modelStatus === 'idle' ? (
          <p className="text-xs text-muted-foreground">
            Corpus embedding model: <span className="font-medium">{EMBEDDING_MODEL_META.name}</span>{' '}
            ({EMBEDDING_MODEL_META.size}) - not loaded. It downloads on the first corpus ingest.
          </p>
        ) : (
          <ModelDownloader
            name={EMBEDDING_MODEL_META.name}
            size={EMBEDDING_MODEL_META.size}
            category="Embedding"
            progress={kb.modelProgressValue}
            cached={kb.modelCached}
            ready={kb.modelReady}
            className="max-w-sm"
          />
        )}
      </div>

      {}
      <section className="flex flex-col gap-3 rounded-xl border border-border bg-card p-4">
        <header>
          <h2 className="text-sm font-semibold">Corpus</h2>
          <p className="text-xs text-muted-foreground">
            Optional - feed the extractive “Corpus top chunk” grounding. The Donut Document QA tool
            works standalone.
          </p>
        </header>

        <textarea
          value={draft}
          onChange={(e) => setDraft(e.target.value)}
          rows={4}
          placeholder="Paste text to index into the corpus…"
          aria-label="Text to index"
          className="w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
        />
        <div className="flex flex-wrap items-center gap-2">
          <button
            type="button"
            onClick={() => void addDraft()}
            disabled={!draftTrimmed || busy}
            className={BTN_PRIMARY}
          >
            Add to corpus
          </button>
          {!hasDocs && (
            <button
              type="button"
              onClick={() => void loadSamples()}
              disabled={busy}
              className={BTN_SECONDARY}
            >
              Load sample corpus
            </button>
          )}
          {busy && (
            <span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
              <Loader2 className="size-3.5 animate-spin" aria-hidden="true" />
              Indexing corpus…
            </span>
          )}
        </div>

        {}
        <div>
          <FileDropzone
            accept={['application/pdf']}
            maxSize={MAX_FILE_SIZE}
            multiple
            disabled={busy && !pdfPipeline}
            processing={pdfPipeline !== null}
            processingLabel={
              pdfPipeline?.step === 'extract'
                ? `Extracting ${pdfPipeline.fileName}…`
                : 'Indexing into the corpus…'
            }
            label="Drop PDFs or click to browse"
            onUpload={(files) => void ingestPDFs(files)}
            onReject={(rejected: RejectedFile[]) =>
              setPdfErrors((prev) => [
                ...prev,
                ...rejected.map((r) => ({ fileName: r.file.name, message: r.reason })),
              ])
            }
          />
        </div>
        {pdfPipeline && (
          <div className="flex flex-col gap-2">
            <MultiStepPipelineTracker
              steps={['Extract', 'Chunk · Embed · Store']}
              completed={pdfPipeline.step === 'extract' ? 0 : 1}
              currentStep={pdfPipeline.step === 'extract' ? 'Extract' : 'Chunk · Embed · Store'}
            />
            {pdfPipeline.step === 'extract' && (
              <div className="flex items-center gap-3 text-xs text-muted-foreground">
                <span>
                  Extracting file {pdfPipeline.fileIndex + 1}/{pdfPipeline.fileCount} -{' '}
                  {pdfPipeline.fileName}
                </span>
                <button
                  type="button"
                  onClick={() => pdfAbortRef.current?.abort()}
                  className="inline-flex h-6 items-center rounded-md border border-border px-2 text-xs focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
                >
                  Cancel
                </button>
              </div>
            )}
          </div>
        )}
        {pdfErrors.length > 0 && (
          <div
            role="alert"
            className="flex flex-col gap-1 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2"
          >
            <div className="flex items-center justify-between gap-2">
              <p className="text-xs font-medium text-destructive">
                {pdfErrors.length} {pdfErrors.length === 1 ? 'file' : 'files'} failed
              </p>
              <button
                type="button"
                onClick={() => setPdfErrors([])}
                aria-label="Dismiss PDF errors"
                className="text-destructive/70 hover:text-destructive focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
              >
                <X className="size-3.5" aria-hidden="true" />
              </button>
            </div>
            {pdfErrors.map((e, i) => (
              <p key={`${e.fileName}-${i}`} className="text-xs text-destructive">
                {e.fileName}: {e.message}
              </p>
            ))}
          </div>
        )}

        {}
        {hasDocs && (
          <div className="flex flex-col gap-2">
            <div className="flex items-center justify-between">
              <span
                data-docs={kb.documents.length}
                data-chunks={kb.stats?.chunks ?? 0}
                role="group"
                aria-label="Corpus size"
                className="text-xs tabular-nums text-muted-foreground"
              >
                {kb.documents.length} docs · {kb.stats?.chunks ?? 0} chunks
                {kb.stats ? ` · ${kb.stats.dimensions}d` : ''}
              </span>
              {confirmingClear ? (
                <span className="inline-flex items-center gap-2">
                  <button
                    type="button"
                    onClick={() => void clearAll()}
                    disabled={busy}
                    className="inline-flex h-7 items-center rounded-md bg-destructive px-3 text-xs font-medium text-white disabled:opacity-50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
                  >
                    Confirm clear all
                  </button>
                  <button
                    type="button"
                    onClick={() => setConfirmingClear(false)}
                    className="inline-flex h-7 items-center rounded-md border border-border px-3 text-xs focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
                  >
                    Cancel
                  </button>
                </span>
              ) : (
                <button
                  type="button"
                  onClick={() => setConfirmingClear(true)}
                  disabled={busy}
                  className="inline-flex h-7 items-center gap-1.5 rounded-md border border-border px-3 text-xs text-destructive disabled:opacity-50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
                >
                  <Trash2 className="size-3.5" aria-hidden="true" />
                  Clear all
                </button>
              )}
            </div>
            <div className="flex flex-col gap-2">
              {kb.documents.map((doc) => {
                const pageCount =
                  doc.pages?.length ??
                  (typeof doc.meta?.pages === 'number' ? doc.meta.pages : undefined);
                const sizeBytes =
                  typeof doc.meta?.sizeBytes === 'number' ? doc.meta.sizeBytes : undefined;
                return (
                  <article
                    key={doc.id}
                    data-doc-id={doc.id}
                    className="flex flex-col gap-1"
                  >
                    <IndexedDocumentCard
                      filename={doc.title}
                      chunkCount={estimateChunkCount(doc.text.length, kb.chunkSize)}
                      pageCount={pageCount}
                      sizeBytes={sizeBytes}
                    />
                    <div className="flex flex-wrap items-center gap-2 px-1 text-xs text-muted-foreground">
                      <span className="rounded bg-muted px-1.5 py-0.5 font-medium text-foreground">
                        {SOURCE_LABELS[doc.source]}
                      </span>
                      {doc.category && (
                        <span className="rounded border border-border px-1.5 py-0.5">
                          {doc.category}
                        </span>
                      )}
                      <button
                        type="button"
                        onClick={() => void deleteDocument(doc.id)}
                        disabled={busy || deletingDocId !== null}
                        aria-label={`Delete ${doc.title}`}
                        aria-busy={deletingDocId === doc.id}
                        className="ml-auto inline-flex h-6 items-center gap-1 rounded-md px-1.5 text-destructive hover:bg-destructive/10 disabled:opacity-50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
                      >
                        {deletingDocId === doc.id ? (
                          <Loader2 className="size-3.5 animate-spin" aria-hidden="true" />
                        ) : (
                          <Trash2 className="size-3.5" aria-hidden="true" />
                        )}
                        Delete
                      </button>
                    </div>
                  </article>
                );
              })}
            </div>
          </div>
        )}
      </section>

      {}
      <div
        data-mode={mode}
        role="group"
        aria-label="QA mode"
        className="inline-flex w-fit items-center gap-1 rounded-lg border border-border bg-muted/50 p-1"
      >
        {ASK_MODES.map((m) => (
          <button
            key={m.id}
            type="button"
            aria-pressed={mode === m.id}
            onClick={() => setMode(m.id)}
            className={segButtonClass(mode === m.id)}
          >
            {m.label}
          </button>
        ))}
      </div>

      {}
      <section hidden={mode !== 'extractive'} aria-label="Extractive QA">
        {kb.engine ? (
          <ExtractiveQaPanel
            corpus={{ engine: kb.engine, documents: kb.documents, busy: kb.busy }}
          />
        ) : (
          <p className="p-4 text-sm text-muted-foreground">Preparing engine…</p>
        )}
      </section>
      <section hidden={mode !== 'donut'} aria-label="Document QA">
        <DonutQaPanel />
      </section>
    </div>
  );
}


interface ExtractiveEntry {
  id: string;
  question: string;
  answer: string;
  score: number;
  contextLabel: string;
}

function ExtractiveQaPanel({ corpus }: { corpus: Corpus }) {
  const load = useModelLoad<QuestionAnsweringModel>({
    key: QA_MODEL_ID,
    create: (onProgress) => transformers.questionAnswering(QA_MODEL_ID, { onProgress }),
    warmup: (model) =>
      answerQuestion({
        model,
        question: 'What is ready?',
        context: 'The extractive QA model is ready.',
      }),
  });

  if (!load.model) return null;
  return <ExtractiveQaSurface corpus={corpus} load={load} model={load.model} />;
}

function ExtractiveQaSurface({
  corpus,
  load,
  model,
}: {
  corpus: Corpus;
  load: UseModelLoadReturn<QuestionAnsweringModel>;
  model: QuestionAnsweringModel;
}) {
  const qa = useAnswerQuestion({ model });

  const [contextSource, setContextSource] = useState<'corpus' | 'pasted'>('pasted');
  const [pastedContext, setPastedContext] = useState('');
  const [question, setQuestion] = useState('');
  const [entries, setEntries] = useState<ExtractiveEntry[]>([]);
  const [isGrounding, setIsGrounding] = useState(false);
  const [localError, setLocalError] = useState<string | null>(null);
  const groundingAbortRef = useRef<AbortController | null>(null);

  const busy = isGrounding || qa.isLoading;
  const hasCorpus = corpus.documents.length > 0;
  const wordCount = pastedContext.trim() ? pastedContext.trim().split(/\s+/).length : 0;

  const canRun =
    load.status === 'ready' &&
    !busy &&
    question.trim().length > 0 &&
    (contextSource === 'pasted'
      ? pastedContext.trim().length > 0
      : hasCorpus && !corpus.busy);

  const ask = async () => {
    const q = question.trim();
    if (!canRun || !q) return;
    setLocalError(null);

    let context: string;
    let contextLabel: string;
    if (contextSource === 'corpus') {
      const controller = new AbortController();
      groundingAbortRef.current = controller;
      setIsGrounding(true);
      try {
        const hits = await corpus.engine.search(q, { topK: 1, abortSignal: controller.signal });
        const top = hits[0];
        if (!top) {
          setLocalError(
            'No indexed chunk matched the question - ingest documents or switch to a pasted context.',
          );
          return;
        }
        context = top.metadata.text;
        contextLabel = sourceTitle(top);
      } catch (err) {
        if (!controller.signal.aborted && !isAbort(err)) {
          setLocalError(errorMessage(err));
        }
        return;
      } finally {
        setIsGrounding(false);
        groundingAbortRef.current = null;
      }
    } else {
      context = pastedContext.trim();
      contextLabel = 'pasted document';
    }

    const result = await qa.execute({ question: q, context });
    if (result) {
      setEntries((prev) => [
        { id: crypto.randomUUID(), question: q, answer: result.answer, score: result.score, contextLabel },
        ...prev,
      ]);
      setQuestion('');
    }
  };

  const cancel = () => {
    groundingAbortRef.current?.abort();
    qa.cancel();
  };

  const errorText = localError ?? qa.error?.message ?? load.error?.message ?? null;

  return (
    <div className="flex flex-col gap-4">
      {}
      {load.status !== 'ready' && (
        <div className="flex flex-wrap items-center gap-2">
          <button
            type="button"
            onClick={() => void load.load().catch(() => {})}
            disabled={load.status === 'loading'}
            className={BTN_PRIMARY}
          >
            {load.status === 'loading'
              ? `Loading DistilBERT-SQuAD… ${Math.round(load.progress * 100)}%`
              : `Load DistilBERT-SQuAD (${QA_MODEL_SIZE})`}
          </button>
          <span className="text-xs text-muted-foreground">
            Extractive answers run a local SQuAD model - nothing downloads until you click.
          </span>
        </div>
      )}

      {errorText && (
        <p className="text-xs text-destructive">
          {errorText}
        </p>
      )}

      {}
      <div
        role="group"
        aria-label="Context source"
        className="inline-flex w-fit items-center gap-1 rounded-lg border border-border bg-muted/50 p-1"
      >
        <button
          type="button"
          aria-pressed={contextSource === 'corpus'}
          onClick={() => setContextSource('corpus')}
          className={segButtonClass(contextSource === 'corpus')}
        >
          Corpus top chunk
        </button>
        <button
          type="button"
          aria-pressed={contextSource === 'pasted'}
          onClick={() => setContextSource('pasted')}
          className={segButtonClass(contextSource === 'pasted')}
        >
          Pasted document
        </button>
      </div>

      {contextSource === 'corpus' ? (
        <p className="text-xs text-muted-foreground">
          {hasCorpus
            ? `Each ask retrieves the best-matching chunk from the ${corpus.documents.length}-document corpus and extracts the answer from it.`
            : 'The corpus is empty - add documents above, or switch to a pasted document.'}
        </p>
      ) : (
        <div className="flex flex-col gap-2">
          <textarea
            value={pastedContext}
            onChange={(e) => setPastedContext(e.target.value)}
            placeholder="Paste a paragraph of text to ask questions about…"
            rows={5}
            aria-label="Document context"
            className="w-full rounded-md border border-input bg-background p-3 text-sm leading-relaxed"
          />
          <div className="flex flex-wrap items-center gap-2">
            <button
              type="button"
              onClick={() => setPastedContext(QA_SAMPLE_CONTEXT)}
              className={BTN_SECONDARY}
            >
              Load sample document
            </button>
            {wordCount > 0 && (
              <span className="text-xs text-muted-foreground">
                {wordCount.toLocaleString()} words
              </span>
            )}
          </div>
          {pastedContext.trim().length > 0 && (
            <div
              role="group"
              aria-label="Suggested questions"
              className="flex flex-wrap items-center gap-2"
            >
              <span className="text-xs text-muted-foreground">Try:</span>
              {QA_SAMPLE_QUESTIONS.map((q) => (
                <button
                  key={q}
                  type="button"
                  onClick={() => setQuestion(q)}
                  className={PILL}
                >
                  {q}
                </button>
              ))}
            </div>
          )}
        </div>
      )}

      {}
      <div className="flex flex-wrap items-center gap-2">
        <input
          value={question}
          onChange={(e) => setQuestion(e.target.value)}
          onKeyDown={(e) => {
            if (e.key === 'Enter') {
              e.preventDefault();
              void ask();
            }
          }}
          placeholder="Ask about the context…"
          aria-label="Question"
          className={INPUT}
        />
        <button
          type="button"
          onClick={() => void ask()}
          disabled={!canRun}
          className={BTN_PRIMARY}
        >
          {busy ? 'Answering…' : 'Ask'}
        </button>
        {busy && (
          <button
            type="button"
            onClick={cancel}
            className={BTN_SECONDARY}
          >
            Cancel
          </button>
        )}
      </div>

      {}
      {entries.length > 0 && (
        <div className="flex flex-col gap-2">
          <div className="flex items-center justify-between">
            <p className="text-xs font-medium text-muted-foreground">
              {entries.length} {entries.length === 1 ? 'answer' : 'answers'}
            </p>
            <button
              type="button"
              onClick={() => setEntries([])}
              className={BTN_SECONDARY}
            >
              Clear history
            </button>
          </div>
          <ol
            data-count={String(entries.length)}
            aria-label="Answer history"
            className="flex flex-col gap-2"
          >
            {entries.map((entry, i) => (
              <li
                key={entry.id}
                className="rounded-lg border border-border bg-card p-3"
              >
                <div className="flex items-start justify-between gap-2">
                  <p className="text-sm font-medium">{entry.question}</p>
                  <button
                    type="button"
                    aria-label={`Delete answer to "${entry.question}"`}
                    onClick={() => setEntries((prev) => prev.filter((e) => e.id !== entry.id))}
                    className="rounded p-1 text-muted-foreground hover:text-destructive focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
                  >
                    <X className="size-3.5" />
                  </button>
                </div>
                <p
                  className="mt-1 whitespace-pre-wrap text-sm leading-relaxed"
                  {...(i === 0
                    ? {
                        role: 'group',
                        'aria-label': 'Latest answer',
                      }
                    : {})}
                >
                  {entry.answer}
                </p>
                <div className="mt-2 flex flex-wrap items-center gap-2">
                  <span
                    {...(i === 0
                      ? {
                          'data-score': String(entry.score),
                          role: 'group',
                          'aria-label': 'Answer confidence',
                        }
                      : {})}
                  >
                    <ConfidenceScoreBadge
                      score={entry.score}
                      precision={1}
                      label={`${TIER_LABEL[resolveTier(entry.score)]} confidence`}
                    />
                  </span>
                  <span className="text-xs text-muted-foreground">
                    grounded on: {entry.contextLabel}
                  </span>
                </div>
              </li>
            ))}
          </ol>
        </div>
      )}
    </div>
  );
}


interface DonutEntry {
  id: string;
  question: string;
  answer: string;
  score: number;
}

function warmupDocumentImage() {
  const canvas = document.createElement('canvas');
  canvas.width = 96;
  canvas.height = 48;
  const ctx = canvas.getContext('2d');
  if (ctx) {
    ctx.fillStyle = '#ffffff';
    ctx.fillRect(0, 0, canvas.width, canvas.height);
    ctx.fillStyle = '#000000';
    ctx.font = '12px sans-serif';
    ctx.fillText('TOTAL: $42', 8, 24);
  }
  return canvas.toDataURL('image/png');
}

function DonutQaPanel() {
  const load = useModelLoad<DocumentQAModel>({
    key: DONUT_MODEL_ID,
    create: (onProgress) => transformers.documentQA(DONUT_MODEL_ID, { onProgress }),
    warmup: (model) =>
      askDocument({ model, document: warmupDocumentImage(), question: 'What is the total?' }),
  });

  if (!load.model) return null;
  return <DonutQaSurface load={load} model={load.model} />;
}

function DonutQaSurface({
  load,
  model,
}: {
  load: UseModelLoadReturn<DocumentQAModel>;
  model: DocumentQAModel;
}) {
  const doc = useAskDocument({ model });

  const [imageDataUrl, setImageDataUrl] = useState<string | null>(null);
  const [question, setQuestion] = useState('');
  const [entries, setEntries] = useState<DonutEntry[]>([]);
  const [uploadError, setUploadError] = useState<string | null>(null);

  const canAsk =
    load.status === 'ready' &&
    !doc.isLoading &&
    imageDataUrl !== null &&
    question.trim().length > 0;

  const onFiles = async (files: File[]) => {
    const file = files[0];
    if (!file) return;
    setUploadError(null);
    try {
      const dataUrl = await readFileAsDataUrl(file);
      setImageDataUrl(dataUrl);
      setEntries([]);
      doc.reset();
    } catch {
      setUploadError('Failed to read the uploaded file');
    }
  };

  const ask = async () => {
    const q = question.trim();
    if (!canAsk || !imageDataUrl || !q) return;
    const result = await doc.execute({ document: imageDataUrl, question: q });
    if (result) {
      setEntries((prev) => [
        { id: crypto.randomUUID(), question: q, answer: result.answer, score: result.score },
        ...prev,
      ]);
      setQuestion('');
    }
  };

  const reset = () => {
    setImageDataUrl(null);
    setEntries([]);
    setQuestion('');
    setUploadError(null);
    doc.reset();
  };

  const errorText = uploadError ?? doc.error?.message ?? load.error?.message ?? null;

  return (
    <div className="flex flex-col gap-4">
      {}
      {load.status !== 'ready' && (
        <div className="flex flex-wrap items-center gap-2">
          <button
            type="button"
            onClick={() => void load.load().catch(() => {})}
            disabled={load.status === 'loading'}
            className={BTN_PRIMARY}
          >
            {load.status === 'loading'
              ? `Loading Donut DocVQA… ${Math.round(load.progress * 100)}%`
              : `Load Donut DocVQA (${DONUT_MODEL_SIZE})`}
          </button>
          <span className="text-xs text-muted-foreground">
            Document QA runs Donut locally - this is a large download; nothing fetches until you
            click.
          </span>
        </div>
      )}

      {errorText && (
        <p className="text-xs text-destructive">
          {errorText}
        </p>
      )}

      <div role="group" aria-label="Document image upload">
        {imageDataUrl ? (
          <div className="flex flex-col gap-2">
            {}
            <img
              src={imageDataUrl}
              alt="Uploaded document"
              className="max-h-80 w-auto self-start rounded-lg border border-border"
            />
            <MediaDropzone
              addAnother
              accept={DOC_IMAGE_TYPES}
              maxSize={MAX_FILE_SIZE}
              multiple={false}
              processing={doc.isLoading}
              processingLabel="Answering…"
              onFiles={(files) => void onFiles(files)}
              onReject={(rejections: MediaDropzoneRejection[]) =>
                setUploadError(rejections[0]?.reason ?? 'File rejected')
              }
            />
          </div>
        ) : (
          <MediaDropzone
            accept={DOC_IMAGE_TYPES}
            maxSize={MAX_FILE_SIZE}
            multiple={false}
            title="Drop a document image"
            subtitle="Invoice, receipt, or form - then ask about it"
            onFiles={(files) => void onFiles(files)}
            onReject={(rejections: MediaDropzoneRejection[]) =>
              setUploadError(rejections[0]?.reason ?? 'File rejected')
            }
          />
        )}
      </div>

      {imageDataUrl && (
        <>
          <div
            role="group"
            aria-label="Suggested document questions"
            className="flex flex-wrap items-center gap-2"
          >
            <span className="text-xs text-muted-foreground">Try:</span>
            {DONUT_EXAMPLE_QUESTIONS.map((q) => (
              <button
                key={q}
                type="button"
                onClick={() => setQuestion(q)}
                className={PILL}
              >
                {q}
              </button>
            ))}
          </div>

          <div className="flex flex-wrap items-center gap-2">
            <input
              value={question}
              onChange={(e) => setQuestion(e.target.value)}
              onKeyDown={(e) => {
                if (e.key === 'Enter') {
                  e.preventDefault();
                  void ask();
                }
              }}
              placeholder="What is the total amount?"
              aria-label="Document question"
              className={INPUT}
            />
            <button
              type="button"
              onClick={() => void ask()}
              disabled={!canAsk}
              className={BTN_PRIMARY}
            >
              {doc.isLoading ? 'Answering…' : 'Ask'}
            </button>
            {doc.isLoading && (
              <button
                type="button"
                onClick={doc.cancel}
                className={BTN_SECONDARY}
              >
                Cancel
              </button>
            )}
            <button
              type="button"
              onClick={reset}
              className={BTN_SECONDARY}
            >
              Reset
            </button>
          </div>
        </>
      )}

      {}
      {entries.length > 0 && (
        <div className="flex flex-col gap-2">
          <div className="flex items-center justify-between">
            <p className="text-xs font-medium text-muted-foreground">
              {entries.length} {entries.length === 1 ? 'answer' : 'answers'}
            </p>
            <button
              type="button"
              onClick={() => setEntries([])}
              className={BTN_SECONDARY}
            >
              Clear history
            </button>
          </div>
          <ol
            data-count={String(entries.length)}
            className="flex flex-col gap-2"
          >
            {entries.map((entry, i) => (
              <li key={entry.id} className="rounded-lg border border-border bg-card p-3">
                <p className="text-sm font-medium">{entry.question}</p>
                <p
                  className="mt-1 whitespace-pre-wrap text-sm leading-relaxed"
                  {...(i === 0
                    ? {
                        role: 'group',
                        'aria-label': 'Latest document answer',
                      }
                    : {})}
                >
                  {entry.answer}
                </p>
                <span
                  className="mt-2 inline-flex"
                  {...(i === 0
                    ? {
                        'data-score': String(entry.score),
                        role: 'group',
                        'aria-label': 'Document answer confidence',
                      }
                    : {})}
                >
                  <ConfidenceScoreBadge
                    score={entry.score}
                    precision={1}
                    label={`${TIER_LABEL[resolveTier(entry.score)]} confidence`}
                  />
                </span>
              </li>
            ))}
          </ol>
        </div>
      )}
    </div>
  );
}
```

## RAG Chat

Chat with your own documents and get answers grounded in what you added. Paste text or drop in PDFs, then ask a question and watch the reply stream in. Each answer links back to the exact sources and pages it came from, so you can check the facts. Nothing downloads until you start.

**Install**

```bash
npx shadcn@latest add @localmode/ui/blocks/knowledge/rag-chat
```

**Full block (all files):** https://localmode.ai/r/ui/blocks/knowledge/rag-chat.json

```tsx
'use client';

/**
 * @file rag-chat.tsx
 * @description Grounded RAG chat over your own corpus (text paste + sample corpus + PDF ingest with off/recursive/semantic chunking) — streaming token-by-token answers with inline citations and page-attributed sources, over a Core ⇄ LangChain engine toggle.
 * @constraint No model bytes on page load: the embedding model downloads on first ingest, the granite answer model on first ask (engine-owned lazy singletons).
 */

import { useCallback, useRef, useState, type ReactNode } from 'react';
import { Loader2, Trash2, X } from 'lucide-react';
import {
  createKnowledgeBaseEngine,
  isWebGPUSupported,
  recursiveChunk,
  type ChunkingMode,
  type DocumentSource,
  type KBSearchResult,
  type KnowledgeBaseEngine,
  type RawDocument,
} from '@localmode/core';
import { useKnowledgeBase } from '@localmode/react';
import { transformers, isModelCached } from '@localmode/transformers';

import { DownloadProgress, ModelDownloader } from '@/components/model-downloader';
import {
  ChunkBoundaryVisualizer,
  type ChunkInfo,
} from '@/components/chunk-boundary-visualizer';
import { FileDropzone, type RejectedFile } from '@/components/file-dropzone';
import { IndexedDocumentCard } from '@/components/indexed-document-card';
import { SegmentedModePicker } from '@/components/segmented-mode-picker';
import { ParameterSlider } from '@/components/parameter-slider';
import { MultiStepPipelineTracker } from '@/components/pipeline-tracker';
import {
  Source,
  Sources,
  SourcesContent,
  SourcesTrigger,
} from '@/components/sources';
import { SourceCitationList } from '@/components/source-citation-list';
import {
  InlineCitation,
  InlineCitationCard,
  InlineCitationCardBody,
  InlineCitationCardTrigger,
  InlineCitationCarousel,
  InlineCitationSource,
  InlineCitationQuote,
} from '@/components/inline-citation';
import { cn } from '@/lib/utils';


const DEFAULT_EMBEDDING_MODEL_ID = 'Xenova/bge-small-en-v1.5';

const EMBEDDING_MODEL_META: Record<string, { name: string; size: string }> = {
  'Xenova/bge-small-en-v1.5': { name: 'BGE Small EN v1.5', size: '34 MB' },
};

const ANSWER_MODEL_ID = 'onnx-community/granite-4.0-350m-ONNX-web';

const GENERATION_MAX_TOKENS = 512;

const RAG_TOP_K = 4;

const MAX_FILE_SIZE = 10 * 1024 * 1024;

const IDLE_STATUS =
  'idle - load the sample corpus (or add text / a PDF) to index and ask grounded questions';

type EngineKind = KnowledgeBaseEngine['kind'];

const ENGINE_LABELS: Record<EngineKind, string> = {
  core: 'Core',
  langchain: 'LangChain',
};

const SOURCE_LABELS: Record<DocumentSource, string> = {
  text: 'Text',
  sample: 'Sample',
  pdf: 'PDF',
  ocr: 'OCR',
  import: 'Import',
};

const PDF_STEPS = ['Extract', 'Chunk · Embed · Store'];

const RAG_SEED_QUESTIONS = [
  'How is personal data kept private and encrypted on the device?',
];

const BTN_PRIMARY =
  'inline-flex h-8 items-center rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground disabled:opacity-50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background';
const BTN_SECONDARY =
  'inline-flex h-8 items-center rounded-md border border-border px-3 text-sm disabled:opacity-50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50';
const PILL =
  'rounded-full border border-border bg-card px-3 py-1 text-xs text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground disabled:opacity-50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50';
const INPUT =
  'h-8 min-w-0 flex-1 rounded-md border border-input bg-background px-3 text-sm focus-visible:ring-[3px] focus-visible:ring-ring/50';

const SAMPLE_CORPUS: Array<Omit<RawDocument, 'id' | 'addedAt'>> = [
  {
    title: 'Privacy and encryption on device',
    category: 'security',
    source: 'sample',
    text: 'Privacy and encryption go hand in hand: encrypting personal data with AES-GCM keys derived on the device keeps private information confidential, and no plaintext ever leaves the browser.',
  },
  {
    title: 'Spring vegetable gardening',
    category: 'home',
    source: 'sample',
    text: 'Plant tomatoes and peppers after the last frost. Water seedlings daily and mulch the beds to keep weeds down through the warm months.',
  },
  {
    title: 'Road cycling basics',
    category: 'sports',
    source: 'sample',
    text: 'A correct saddle height prevents knee pain on long rides. Carry a spare tube, tire levers, and a mini pump on every road ride.',
  },
  {
    title: 'Fresh pasta dough',
    category: 'food',
    source: 'sample',
    text: 'Combine flour and eggs, knead for ten minutes, and rest the dough for half an hour before rolling thin sheets for tagliatelle.',
  },
  {
    title: 'Backyard astronomy',
    category: 'science',
    source: 'sample',
    text: 'A small refractor telescope shows the rings of Saturn and the moons of Jupiter. Dark skies away from city lights reveal the Milky Way.',
  },
  {
    title: 'Budgeting for beginners',
    category: 'money',
    source: 'sample',
    text: 'Track monthly income and expenses, build a three-month emergency fund first, and automate transfers into savings on payday.',
  },
  {
    title: 'Marathon training plan',
    category: 'sports',
    source: 'sample',
    text: 'Increase weekly mileage by no more than ten percent. Long slow runs on weekends build the aerobic base needed for race day.',
  },
  {
    title: 'A year of soups',
    category: 'food',
    source: 'sample',
    text: 'In spring, light broths with peas, asparagus, and fresh herbs make a bright start to the season. A simple stock simmered from vegetable trimmings carries delicate flavors without overpowering them. Summer calls for chilled soups: gazpacho blends ripe tomatoes, cucumber, and peppers into a refreshing bowl that needs no stove at all. When autumn arrives, roasted squash and root vegetables become velvety purees, finished with cream and a pinch of nutmeg. Winter is the season of slow simmering: beans, lentils, and smoked meats braise for hours until the broth turns rich and deeply savory, perfect with crusty bread by the fire.',
  },
];


interface RagSession {
  engine: KnowledgeBaseEngine;
  engineKind: EngineKind;
  documents: RawDocument[];
  addDocuments: (docs: Array<Omit<RawDocument, 'id' | 'addedAt'>>) => Promise<void>;
  removeDocument: (docId: string) => Promise<void>;
  clearAll: () => Promise<void>;
  chunking: ChunkingMode;
  setChunking: (mode: ChunkingMode) => void;
  chunkSize: number;
  setChunkSize: (n: number) => void;
  busy: boolean;
  error: string | null;
}


function sourceTitle(result: KBSearchResult) {
  const { docTitle, page } = result.metadata;
  return page != null ? `${docTitle} · p. ${page}` : docTitle;
}

function sourceScore(result: KBSearchResult) {
  return result.rerankScore ?? result.score;
}

function clip(text: string, max: number) {
  return text.length > max ? `${text.slice(0, max - 1)}…` : text;
}

function formatDuration(ms: number) {
  return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms)}ms`;
}

function errorMessage(err: unknown) {
  return err instanceof Error ? err.message : String(err);
}

function isAbort(err: unknown) {
  return err instanceof DOMException && err.name === 'AbortError';
}

function deriveTitle(text: string) {
  const firstLine = text
    .split('\n')
    .map((l) => l.replace(/^#+\s*/, '').trim())
    .find((l) => l.length > 0);
  if (!firstLine) return 'Untitled note';
  return firstLine.length > 64 ? `${firstLine.slice(0, 64).trimEnd()}…` : firstLine;
}

function countWords(text: string) {
  return text.trim().split(/\s+/).filter(Boolean).length;
}

function formatRelativeTime(timestamp: number) {
  const deltaMs = Date.now() - timestamp;
  const minutes = Math.floor(deltaMs / 60_000);
  if (minutes < 1) return 'just now';
  if (minutes < 60) return `${minutes}m ago`;
  const hours = Math.floor(minutes / 60);
  if (hours < 24) return `${hours}h ago`;
  const days = Math.floor(hours / 24);
  return `${days}d ago`;
}

function estimateChunkCount(textLength: number, mode: ChunkingMode, chunkSize: number) {
  if (mode === 'off') return 1;
  const size = mode === 'semantic' ? 500 : Math.max(1, chunkSize);
  return Math.max(1, Math.ceil(textLength / size));
}


interface PdfPipelineState {
  step: 'extract' | 'ingest';
  fileIndex: number;
  fileCount: number;
  fileName: string;
}

interface PdfFileError {
  fileName: string;
  message: string;
}


export function RagChatBlock() {
  const idRef = useRef(DEFAULT_EMBEDDING_MODEL_ID);

  const createEngine = useCallback(async (kind: EngineKind): Promise<KnowledgeBaseEngine> => {
    const embeddingModel = transformers.embedding(idRef.current);
    const getLanguageModel = async () => {
      const device = (await isWebGPUSupported()) ? 'webgpu' : 'wasm';
      return transformers.languageModel(ANSWER_MODEL_ID, { device });
    };
    if (kind === 'langchain') {
      const { createLangChainKnowledgeBaseEngine, ChatLocalMode } = await import(
        '@localmode/langchain'
      );
      return createLangChainKnowledgeBaseEngine({
        embeddingModel,
        getChatModel: async () =>
          new ChatLocalMode({ model: await getLanguageModel(), maxTokens: GENERATION_MAX_TOKENS }),
      });
    }
    return createKnowledgeBaseEngine({ embeddingModel, getLanguageModel });
  }, []);

  const kb = useKnowledgeBase({
    embeddingModelId: DEFAULT_EMBEDDING_MODEL_ID,
    createEmbeddingModel: (id, onProgress) =>
      transformers.embedding(id, {
        onProgress: (p) => onProgress(p as Parameters<typeof onProgress>[0]),
      }),
    isModelCached: (id) => isModelCached(id),
    createEngine,
  });
  idRef.current = kb.embeddingModelId;

  const requestEngineKind = (kind: EngineKind) => {
    if (kb.busy || !kb.engine || kind === kb.engineKind) return;
    kb.setEngineKind(kind);
  };

  const progressTick = kb.reingestProgress ?? kb.ingestProgress;
  const working = kb.busy || (!kb.engine && kb.documents.length > 0);
  const statusText = working
    ? kb.modelStatus === 'loading'
      ? `loading embedding model… ${Math.round(kb.modelProgress * 100)}%`
      : progressTick
        ? `indexing - ${progressTick.phase} ${progressTick.completed}/${progressTick.total}`
        : kb.switching
          ? `re-ingesting ${kb.documents.length} docs through the ${ENGINE_LABELS[kb.engineKind]} engine…`
          : 'indexing…'
    : kb.error
      ? 'error'
      : kb.documents.length > 0
        ? `ready - ${kb.documents.length} docs indexed, ${kb.stats?.chunks ?? 0} chunks`
        : IDLE_STATUS;

  const modelMeta =
    EMBEDDING_MODEL_META[kb.embeddingModelId] ?? { name: kb.embeddingModelId, size: '' };
  const reingestFraction =
    kb.reingestProgress && kb.reingestProgress.total > 0
      ? kb.reingestProgress.completed / kb.reingestProgress.total
      : 0;

  const session: RagSession | null = kb.engine
    ? {
        engine: kb.engine,
        engineKind: kb.engineKind,
        documents: kb.documents,
        addDocuments: kb.addDocuments,
        removeDocument: kb.removeDocument,
        clearAll: kb.clearAll,
        chunking: kb.chunking,
        setChunking: kb.setChunking,
        chunkSize: kb.chunkSize,
        setChunkSize: kb.setChunkSize,
        busy: kb.busy,
        error: kb.error,
      }
    : null;

  return (
    <div className="mx-auto flex max-w-4xl flex-col gap-4 p-4">
      {}
      <p role="status" aria-live="polite" className="text-xs text-muted-foreground">
        {statusText}
      </p>
      {kb.error && (
        <p className="text-xs text-destructive">
          {kb.error}
        </p>
      )}

      {}
      <div className="flex flex-wrap items-center gap-2">
        <div
          data-engine={kb.engineKind}
          role="group"
          aria-label="Pipeline engine"
          className="inline-flex items-center rounded-md border border-border bg-muted/40 p-0.5"
        >
          {(Object.keys(ENGINE_LABELS) as EngineKind[]).map((kind) => (
            <button
              key={kind}
              type="button"
              data-engine-option={kind}
              aria-pressed={kb.engineKind === kind}
              onClick={() => requestEngineKind(kind)}
              disabled={!kb.engine || kb.busy}
              className={cn(
                'inline-flex h-7 items-center rounded px-2.5 text-xs font-medium transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50',
                kb.engineKind === kind
                  ? 'bg-background text-foreground shadow-sm'
                  : 'text-muted-foreground hover:text-foreground',
              )}
            >
              {ENGINE_LABELS[kind]} engine
            </button>
          ))}
        </div>

        <span
          data-docs={kb.documents.length}
          data-chunks={kb.stats?.chunks ?? 0}
          role="group"
          aria-label="Corpus size"
          className="ml-auto text-xs tabular-nums text-muted-foreground"
        >
          {kb.documents.length} docs · {kb.stats?.chunks ?? 0} chunks
          {kb.stats ? ` · ${kb.stats.dimensions}d` : ''}
        </span>
      </div>

      {}
      <div
        data-status={kb.modelStatus}
        data-model-id={kb.embeddingModelId}
        role="group"
        aria-label="Embedding model status"
      >
        {kb.modelStatus === 'idle' ? (
          <p className="text-xs text-muted-foreground">
            Embedding model: <span className="font-medium">{modelMeta.name}</span>
            {modelMeta.size ? ` (${modelMeta.size})` : ''} - not loaded. It downloads on the first
            ingest or an engine switch; the granite answer model downloads on the first ask.
          </p>
        ) : (
          <ModelDownloader
            name={modelMeta.name}
            size={modelMeta.size || undefined}
            category="Embedding"
            progress={kb.modelProgressValue}
            cached={kb.modelCached}
            ready={kb.modelReady}
            className="max-w-sm"
          />
        )}
      </div>

      {}
      {kb.switching && (
        <div
          data-phase={kb.reingestProgress?.phase ?? 'model'}
          role="status"
          aria-live="polite"
          aria-label="Re-ingest progress"
          className="flex flex-col gap-1.5 rounded-xl border border-border bg-card p-3"
        >
          <p className="text-xs font-medium">
            Re-ingesting {kb.documents.length} document{kb.documents.length === 1 ? '' : 's'} through
            the {ENGINE_LABELS[kb.engineKind]} engine
            {kb.reingestProgress
              ? ` - ${kb.reingestProgress.phase} ${kb.reingestProgress.completed}/${kb.reingestProgress.total}`
              : '…'}
          </p>
          <DownloadProgress value={reingestFraction} complete={false} />
        </div>
      )}

      {}
      {session ? (
        <div className="flex flex-col gap-8">
          <IngestSection session={session} />
          <RagPanel session={session} />
        </div>
      ) : (
        <p className="p-4 text-sm text-muted-foreground">
          {kb.switching || kb.documents.length > 0 ? 'Re-ingesting the corpus…' : 'Preparing engine…'}
        </p>
      )}
    </div>
  );
}


function IngestSection({ session }: { session: RagSession }) {
  const [draft, setDraft] = useState('');
  const [pdfPipeline, setPdfPipeline] = useState<PdfPipelineState | null>(null);
  const [pdfErrors, setPdfErrors] = useState<PdfFileError[]>([]);
  const pdfAbortRef = useRef<AbortController | null>(null);
  const [deletingDocId, setDeletingDocId] = useState<string | null>(null);
  const [confirmingClear, setConfirmingClear] = useState(false);

  const busy = session.busy;
  const draftTrimmed = draft.trim();

  const previewChunks: ChunkInfo[] = !draftTrimmed
    ? []
    : session.chunking === 'off'
      ? [{ text: draftTrimmed, chunkIndex: 0, rightSimilarity: null }]
      : recursiveChunk(draftTrimmed, { size: session.chunkSize }).map((c) => ({
          text: c.text,
          chunkIndex: c.index,
          rightSimilarity: null,
        }));
  const previewChars = previewChunks.reduce((sum, c) => sum + c.text.length, 0);
  const previewAvg =
    previewChunks.length > 0 ? Math.round(previewChars / previewChunks.length) : 0;

  const addDraft = async () => {
    if (!draftTrimmed || busy) return;
    await session.addDocuments([
      { title: deriveTitle(draftTrimmed), text: draftTrimmed, source: 'text' },
    ]);
    setDraft('');
  };

  const loadSamples = async () => {
    if (busy || session.documents.length > 0) return;
    await session.addDocuments(SAMPLE_CORPUS);
  };

  const ingestPDFs = async (files: File[]) => {
    if (busy || pdfPipeline) return;
    setPdfErrors([]);

    const controller = new AbortController();
    pdfAbortRef.current = controller;

    const docs: Array<Omit<RawDocument, 'id' | 'addedAt'>> = [];
    const errors: PdfFileError[] = [];

    for (let i = 0; i < files.length; i++) {
      const file = files[i];
      setPdfPipeline({ step: 'extract', fileIndex: i, fileCount: files.length, fileName: file.name });
      try {
        const { extractPDFText } = await import('@localmode/pdfjs');
        const result = await extractPDFText(file, {
          includePageNumbers: false,
          pageSeparator: '\n\n',
          abortSignal: controller.signal,
        });
        if (!result.text.trim()) {
          errors.push({
            fileName: file.name,
            message: 'No extractable text - the PDF may be scanned images or protected.',
          });
          continue;
        }
        docs.push({
          title: file.name,
          text: result.text,
          source: 'pdf',
          meta: { pages: result.pageCount, sizeBytes: file.size },
          pages: result.pages.map((p) => ({ page: p.pageNumber, text: p.text })),
        });
      } catch (err) {
        if (controller.signal.aborted || isAbort(err)) {
          setPdfPipeline(null);
          setPdfErrors(errors);
          return;
        }
        errors.push({ fileName: file.name, message: errorMessage(err) });
      }
    }

    setPdfErrors(errors);

    if (docs.length > 0) {
      setPdfPipeline({ step: 'ingest', fileIndex: files.length, fileCount: files.length, fileName: '' });
      try {
        await session.addDocuments(docs);
      } finally {
        setPdfPipeline(null);
      }
    } else {
      setPdfPipeline(null);
    }
  };

  const onPdfReject = (rejected: RejectedFile[]) => {
    setPdfErrors((prev) => [
      ...prev,
      ...rejected.map((r) => ({ fileName: r.file.name, message: r.reason })),
    ]);
  };

  const deleteDocument = async (docId: string) => {
    if (busy || deletingDocId) return;
    setDeletingDocId(docId);
    try {
      await session.removeDocument(docId);
    } finally {
      setDeletingDocId(null);
    }
  };

  const clearAll = async () => {
    if (busy) return;
    setConfirmingClear(false);
    await session.clearAll();
  };

  return (
    <div className="flex flex-col gap-8">
      {session.error && (
        <p role="alert" className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
          {session.error}
        </p>
      )}

      {}
      <section className="flex flex-col gap-3">
        <header>
          <h2 className="text-sm font-semibold">Add text</h2>
          <p className="text-xs text-muted-foreground">
            Paste a note or document. The first line becomes its title.
          </p>
        </header>
        <textarea
          value={draft}
          onChange={(e) => setDraft(e.target.value)}
          rows={5}
          placeholder="Paste text to index into the corpus…"
          aria-label="Text to index"
          className="w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
        />
        <div className="flex flex-wrap items-center gap-2">
          <button
            type="button"
            onClick={() => void addDraft()}
            disabled={!draftTrimmed || busy}
            className={BTN_PRIMARY}
          >
            Add to corpus
          </button>
          {session.documents.length === 0 && (
            <button
              type="button"
              onClick={() => void loadSamples()}
              disabled={busy}
              className={BTN_SECONDARY}
            >
              Load sample corpus
            </button>
          )}
          {busy && (
            <span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
              <Loader2 className="size-3.5 animate-spin" aria-hidden="true" />
              Indexing corpus…
            </span>
          )}
        </div>
      </section>

      {}
      <section className="flex flex-col gap-3">
        <header>
          <h2 className="text-sm font-semibold">Chunking</h2>
          <p className="text-xs text-muted-foreground">
            Applied by the engine on every ingest. Off stores one vector per document.
          </p>
        </header>
        <div>
          <SegmentedModePicker<ChunkingMode>
            aria-label="Chunking mode"
            items={[
              { id: 'off', label: 'Off' },
              { id: 'recursive', label: 'Recursive' },
              { id: 'semantic', label: 'Semantic' },
            ]}
            selectedId={session.chunking}
            onSelect={session.setChunking}
          />
        </div>
        {session.chunking === 'recursive' && (
          <div className="max-w-sm">
            <ParameterSlider
              label="Chunk size"
              value={session.chunkSize}
              onChange={session.setChunkSize}
              min={128}
              max={1024}
              step={32}
              unit="chars"
              disabled={busy}
              description="Target characters per recursive chunk."
            />
          </div>
        )}
        <div className="flex flex-col gap-2">
          <p className="text-xs text-muted-foreground">
            {previewChunks.length > 0
              ? `Draft preview: ${previewChunks.length} ${previewChunks.length === 1 ? 'chunk' : 'chunks'} · avg ${previewAvg} chars · ${countWords(draftTrimmed)} words`
              : 'Draft preview: paste text above to preview its chunks.'}
          </p>
          {session.chunking === 'semantic' && previewChunks.length > 0 && (
            <p className="text-xs text-muted-foreground">
              Semantic boundaries and similarity scores are computed with the embedding model during
              ingest - this preview shows an approximate recursive split.
            </p>
          )}
          <ChunkBoundaryVisualizer
            mode={session.chunking}
            chunks={previewChunks}
            maxCharsPerChunk={200}
          />
        </div>
      </section>

      {}
      <section className="flex flex-col gap-3">
        <header>
          <h2 className="text-sm font-semibold">PDF documents</h2>
          <p className="text-xs text-muted-foreground">
            Text is extracted per page, so grounded answers cite the page a source came from.
          </p>
        </header>
        <div>
          <FileDropzone
            accept={['application/pdf']}
            maxSize={MAX_FILE_SIZE}
            multiple
            disabled={busy && !pdfPipeline}
            processing={pdfPipeline !== null}
            processingLabel={
              pdfPipeline?.step === 'extract'
                ? `Extracting ${pdfPipeline.fileName}…`
                : 'Indexing into the corpus…'
            }
            label="Drop PDFs or click to browse"
            onUpload={(files) => void ingestPDFs(files)}
            onReject={onPdfReject}
          />
        </div>
        {pdfPipeline && (
          <div className="flex flex-col gap-2">
            <MultiStepPipelineTracker
              steps={PDF_STEPS}
              completed={pdfPipeline.step === 'extract' ? 0 : 1}
              currentStep={pdfPipeline.step === 'extract' ? PDF_STEPS[0] : PDF_STEPS[1]}
            />
            <div className="flex items-center gap-3 text-xs text-muted-foreground">
              {pdfPipeline.step === 'extract' ? (
                <>
                  <span>
                    Extracting file {pdfPipeline.fileIndex + 1}/{pdfPipeline.fileCount} -{' '}
                    {pdfPipeline.fileName}
                  </span>
                  <button
                    type="button"
                    onClick={() => pdfAbortRef.current?.abort()}
                    className="inline-flex h-6 items-center rounded-md border border-border px-2 text-xs focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
                  >
                    Cancel
                  </button>
                </>
              ) : (
                <span>
                  Chunking, embedding, and storing through the engine - the session reports a single
                  busy phase for this span.
                </span>
              )}
            </div>
          </div>
        )}
        {pdfErrors.length > 0 && (
          <div role="alert" className="flex flex-col gap-1 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2">
            <div className="flex items-center justify-between gap-2">
              <p className="text-xs font-medium text-destructive">
                {pdfErrors.length} {pdfErrors.length === 1 ? 'file' : 'files'} failed
              </p>
              <button
                type="button"
                onClick={() => setPdfErrors([])}
                aria-label="Dismiss PDF errors"
                className="text-destructive/70 hover:text-destructive focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
              >
                <X className="size-3.5" aria-hidden="true" />
              </button>
            </div>
            {pdfErrors.map((e, i) => (
              <p key={`${e.fileName}-${i}`} className="text-xs text-destructive">
                {e.fileName}: {e.message}
              </p>
            ))}
          </div>
        )}
      </section>

      {}
      <section className="flex flex-col gap-3">
        <header className="flex flex-wrap items-end justify-between gap-3">
          <div>
            <h2 className="text-sm font-semibold">
              Corpus ({session.documents.length}{' '}
              {session.documents.length === 1 ? 'document' : 'documents'})
            </h2>
            <p className="text-xs text-muted-foreground">
              Chunk counts are estimates under the current chunking config.
            </p>
          </div>
          {session.documents.length > 0 &&
            (confirmingClear ? (
              <span className="inline-flex items-center gap-2">
                <button
                  type="button"
                  onClick={() => void clearAll()}
                  disabled={busy}
                  className="inline-flex h-8 items-center rounded-md bg-destructive px-3 text-sm font-medium text-white disabled:opacity-50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background"
                >
                  Confirm clear all
                </button>
                <button
                  type="button"
                  onClick={() => setConfirmingClear(false)}
                  className={BTN_SECONDARY}
                >
                  Cancel
                </button>
              </span>
            ) : (
              <button
                type="button"
                onClick={() => setConfirmingClear(true)}
                disabled={busy}
                className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-3 text-sm text-destructive disabled:opacity-50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
              >
                <Trash2 className="size-3.5" aria-hidden="true" />
                Clear all
              </button>
            ))}
        </header>

        <div className="flex flex-col gap-2">
          {session.documents.length === 0 ? (
            <p className="rounded-lg border border-dashed border-border bg-card px-4 py-6 text-center text-sm text-muted-foreground">
              No documents yet - add text, load the sample corpus, or drop a PDF.
            </p>
          ) : (
            session.documents.map((doc) => {
              const pageCount =
                doc.pages?.length ??
                (typeof doc.meta?.pages === 'number' ? doc.meta.pages : undefined);
              const sizeBytes =
                typeof doc.meta?.sizeBytes === 'number' ? doc.meta.sizeBytes : undefined;
              return (
                <article
                  key={doc.id}
                  data-doc-id={doc.id}
                  className="flex flex-col gap-1"
                >
                  <IndexedDocumentCard
                    filename={doc.title}
                    chunkCount={estimateChunkCount(doc.text.length, session.chunking, session.chunkSize)}
                    pageCount={pageCount}
                    sizeBytes={sizeBytes}
                  />
                  <div className="flex flex-wrap items-center gap-2 px-1 text-xs text-muted-foreground">
                    <span className="rounded bg-muted px-1.5 py-0.5 font-medium text-foreground">
                      {SOURCE_LABELS[doc.source]}
                    </span>
                    {doc.category && (
                      <span className="rounded border border-border px-1.5 py-0.5">{doc.category}</span>
                    )}
                    <span>{formatRelativeTime(doc.addedAt)}</span>
                    <button
                      type="button"
                      onClick={() => void deleteDocument(doc.id)}
                      disabled={busy || deletingDocId !== null}
                      aria-label={`Delete ${doc.title}`}
                      aria-busy={deletingDocId === doc.id}
                      className="ml-auto inline-flex h-6 items-center gap-1 rounded-md px-1.5 text-destructive hover:bg-destructive/10 disabled:opacity-50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
                    >
                      {deletingDocId === doc.id ? (
                        <Loader2 className="size-3.5 animate-spin" aria-hidden="true" />
                      ) : (
                        <Trash2 className="size-3.5" aria-hidden="true" />
                      )}
                      Delete
                    </button>
                  </div>
                </article>
              );
            })
          )}
        </div>
      </section>
    </div>
  );
}


interface RagEntry {
  id: string;
  question: string;
  answer: string;
  sources: KBSearchResult[];
  durationMs: number;
}

function RagPanel({ session }: { session: RagSession }) {
  const [question, setQuestion] = useState('');
  const [entries, setEntries] = useState<RagEntry[]>([]);
  const [streaming, setStreaming] = useState('');
  const [isAsking, setIsAsking] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const abortRef = useRef<AbortController | null>(null);

  const hasCorpus = session.documents.length > 0;
  const canAsk = hasCorpus && !isAsking && !session.busy && question.trim().length > 0;

  const suggestions = [
    ...(hasCorpus ? RAG_SEED_QUESTIONS : []),
    ...session.documents.slice(0, 2).map((doc) => `What does "${doc.title}" cover?`),
  ];

  const ask = async () => {
    const q = question.trim();
    if (!canAsk || !q) return;
    setError(null);
    setStreaming('');
    setIsAsking(true);

    const controller = new AbortController();
    abortRef.current = controller;
    try {
      const result = await session.engine.ask(q, {
        topK: RAG_TOP_K,
        onToken: (text) => setStreaming((prev) => prev + text),
        abortSignal: controller.signal,
      });
      setEntries((prev) => [{ id: crypto.randomUUID(), question: q, ...result }, ...prev]);
      setQuestion('');
    } catch (err) {
      if (!controller.signal.aborted && !isAbort(err)) {
        setError(errorMessage(err));
      }
    } finally {
      setIsAsking(false);
      setStreaming('');
      abortRef.current = null;
    }
  };

  const cancel = () => abortRef.current?.abort();

  const latest = entries[0] ?? null;
  const historyEntries = isAsking ? entries : entries.slice(1);

  return (
    <section className="flex flex-col gap-4">
      <header>
        <h2 className="text-sm font-semibold">Ask your corpus</h2>
        <p className="text-xs text-muted-foreground">
          Answers stream token-by-token through the {ENGINE_LABELS[session.engineKind]} engine
          (granite-4.0-350M), grounded on the retrieved sources with inline citations.
        </p>
      </header>

      {error && (
        <p className="text-xs text-destructive">
          {error}
        </p>
      )}

      {!hasCorpus && (
        <p className="text-xs text-muted-foreground">
          The corpus is empty - add text, load the sample corpus, or drop a PDF above to ask grounded
          questions.
        </p>
      )}

      {suggestions.length > 0 && (
        <div className="flex flex-wrap items-center gap-2">
          <span className="text-xs text-muted-foreground">Try:</span>
          {suggestions.map((q) => (
            <button
              key={q}
              type="button"
              onClick={() => setQuestion(q)}
              className={PILL}
            >
              {q}
            </button>
          ))}
        </div>
      )}

      <div className="flex flex-wrap items-center gap-2">
        <input
          value={question}
          onChange={(e) => setQuestion(e.target.value)}
          onKeyDown={(e) => {
            if (e.key === 'Enter') {
              e.preventDefault();
              void ask();
            }
          }}
          placeholder="Ask a question about your documents…"
          aria-label="Question about your documents"
          className={INPUT}
        />
        <button
          type="button"
          onClick={() => void ask()}
          disabled={!canAsk}
          className={BTN_PRIMARY}
        >
          {isAsking ? 'Answering…' : 'Ask'}
        </button>
        {isAsking && (
          <button
            type="button"
            onClick={cancel}
            className={BTN_SECONDARY}
          >
            Cancel
          </button>
        )}
      </div>

      {}
      {(isAsking || latest) && (
        <div className="rounded-lg border border-border bg-card p-3">
          {isAsking ? (
            <>
              <p className="text-xs text-muted-foreground">Retrieving + generating…</p>
              <p
                role="group"
                aria-label="Answer"
                className="mt-1 whitespace-pre-wrap text-sm leading-relaxed"
              >
                {streaming || '…'}
              </p>
            </>
          ) : latest ? (
            <>
              <p className="text-sm font-medium">{latest.question}</p>
              <div role="group" aria-label="Answer" className="mt-1">
                <CitedAnswer answer={latest.answer} sources={latest.sources} />
              </div>
              <p className="mt-2 text-xs text-muted-foreground">
                <span data-ms={String(latest.durationMs)} role="group" aria-label="Answer duration">
                  generated in {formatDuration(latest.durationMs)}
                </span>
                {' · '}
                {latest.sources.length} retrieved{' '}
                {latest.sources.length === 1 ? 'chunk' : 'chunks'}
                {' · '}
                {ENGINE_LABELS[session.engineKind]} engine
              </p>
              <div className="mt-2 flex flex-col gap-2">
                <div
                  role="group"
                  aria-label="Retrieved sources"
                  data-count={String(latest.sources.length)}
                >
                  <Sources
                    data-count={String(latest.sources.length)}
                    defaultOpen
                  >
                    <SourcesTrigger count={latest.sources.length} />
                    <SourcesContent>
                      {latest.sources.map((s, i) => (
                        <Source
                          key={s.id}
                          source={{
                            id: s.id,
                            title: `${i + 1}. ${sourceTitle(s)}`,
                            excerpt: clip(s.metadata.text, 180),
                            score: sourceScore(s),
                          }}
                        />
                      ))}
                    </SourcesContent>
                  </Sources>
                </div>
              </div>
            </>
          ) : null}
        </div>
      )}

      {}
      {historyEntries.length > 0 && (
        <div
          data-count={String(entries.length)}
          className="flex flex-col gap-2"
        >
          <p className="text-xs font-medium text-muted-foreground">Earlier answers</p>
          {historyEntries.map((entry) => (
            <div key={entry.id} className="rounded-lg border border-border p-3">
              <p className="text-sm font-medium">{entry.question}</p>
              <div className="mt-1">
                <CitedAnswer answer={entry.answer} sources={entry.sources} />
              </div>
              <p className="mt-1 text-xs text-muted-foreground">
                generated in {formatDuration(entry.durationMs)}
              </p>
              <SourceCitationList
                sources={entry.sources.map((s) => ({
                  title: sourceTitle(s),
                  text: s.metadata.text,
                  score: sourceScore(s),
                }))}
              />
            </div>
          ))}
        </div>
      )}
    </section>
  );
}


function CitedAnswer({ answer, sources }: { answer: string; sources: KBSearchResult[] }) {
  const nodes: ReactNode[] = [];
  const markers = /\[(\d+)\]/g;
  let last = 0;
  let key = 0;
  let match: RegExpExecArray | null;

  while ((match = markers.exec(answer)) !== null) {
    const n = Number(match[1]);
    const source = n >= 1 && n <= sources.length ? sources[n - 1] : undefined;
    if (!source) continue;

    if (match.index > last) {
      nodes.push(<span key={key++}>{answer.slice(last, match.index)}</span>);
    }
    nodes.push(
      <InlineCitation key={key++}>
        <InlineCitationCard>
          <InlineCitationCardTrigger label={n} />
          <InlineCitationCardBody>
            <InlineCitationCarousel count={1}>
              <InlineCitationSource
                title={sourceTitle(source)}
                excerpt={`similarity ${(sourceScore(source) * 100).toFixed(0)}%`}
              >
                <InlineCitationQuote>{clip(source.metadata.text, 220)}</InlineCitationQuote>
              </InlineCitationSource>
            </InlineCitationCarousel>
          </InlineCitationCardBody>
        </InlineCitationCard>
      </InlineCitation>,
    );
    last = match.index + match[0].length;
  }
  nodes.push(<span key={key++}>{answer.slice(last)}</span>);

  return <p className="whitespace-pre-wrap text-sm leading-relaxed">{nodes}</p>;
}
```

## Vector Data Manager

Manage the data behind your knowledge base. Import vectors from formats like Pinecone, ChromaDB, CSV, or JSON, preview them, then export your data back out whenever you want. Keep an eye on storage use and re-index when your embedding model changes. Nothing downloads until you start.

**Install**

```bash
npx shadcn@latest add @localmode/ui/blocks/knowledge/vector-data-manager
```

**Full block (all files):** https://localmode.ai/r/ui/blocks/knowledge/vector-data-manager.json

```tsx
'use client';

/**
 * @file vector-data-manager.tsx
 * @description Core-only knowledge-base data manager over an owned corpus: 4-format vector import (Pinecone/ChromaDB/CSV/JSONL + native JSON) with auto-detect + preview + re-embed toggle, native JSON/CSV/JSONL export (round-trippable), embedding-drift banner + cancellable reindex, and storage + vector observability with adaptive batch sizing.
 * @constraint Core engine only — never imports @localmode/langchain, @localmode/wllama, or @localmode/pdfjs; getLanguageModel is lazy and never invoked (this block never generates).
 */

import { useEffect, useRef, useState, type ReactNode } from 'react';
import {
  AlertTriangle,
  CheckCircle2,
  ClipboardPaste,
  FileText,
  Play,
  Plus,
  RefreshCw,
  RotateCcw,
  Square,
  Upload,
} from 'lucide-react';
import {
  downloadBlob,
  useAdaptiveBatchSize,
  useImportExport,
  useKnowledgeBase,
  useStorageQuota,
  type AnyLoadProgress,
} from '@localmode/react';
import {
  createKnowledgeBaseEngine,
  exportToCSV,
  exportToJSONL,
  isWebGPUSupported,
} from '@localmode/core';
import type {
  ChunkingMode,
  DocumentSource,
  EngineStats,
  ImportRecord,
  KnowledgeBaseEngine,
  RawDocument,
} from '@localmode/core';
import { transformers, isModelCached } from '@localmode/transformers';

import {
  VectorImportFlow,
  type ImportProgressLike,
  type PreviewRecord as FlowPreviewRecord,
} from '@/components/vector-import-flow';
import { VectorExportPanel } from '@/components/vector-export-panel';
import { FormatDetectionBadge } from '@/components/format-detection-badge';
import { StorageMeter } from '@/components/storage-meter';
import { VectorStorageObservability } from '@/components/vector-storage-observability';
import {
  EmbeddingDriftBanner,
  type ReindexProgressLike,
} from '@/components/embedding-drift-banner';
import { AdaptiveBatchCard } from '@/components/adaptive-batch-card';
import { ModelDownloader } from '@/components/model-downloader';
import { cn } from '@/lib/utils';


const DEFAULT_EMBEDDING_MODEL_ID = 'Xenova/bge-small-en-v1.5';

const EMBEDDING_MODEL_META: Record<string, { name: string; size: string }> = {
  'Xenova/bge-small-en-v1.5': { name: 'BGE Small EN v1.5', size: '34 MB' },
  'Xenova/all-MiniLM-L6-v2': { name: 'all-MiniLM-L6-v2', size: '23 MB' },
};
const EMBEDDING_MODEL_IDS = Object.keys(EMBEDDING_MODEL_META);

const ANSWER_MODEL_ID = 'onnx-community/granite-4.0-350m-ONNX-web';

const MAX_FILE_SIZE = 50 * 1024 * 1024;

const SUPPORTED_EXTENSIONS = ['.json', '.jsonl', '.csv'];

const MAX_PREVIEW_RECORDS = 10;

const DEFAULT_DIMENSIONS = 384;

const NATIVE_MARKER = 'localmode-kb';
const NATIVE_FORMAT_ID = 'native-json';

const FORMAT_LABELS: Record<string, string> = {
  pinecone: 'Pinecone JSON',
  chroma: 'ChromaDB JSON',
  csv: 'CSV',
  jsonl: 'JSONL',
  [NATIVE_FORMAT_ID]: 'Native JSON (knowledge base)',
};

const FORMAT_COLOR_MAP = {
  [NATIVE_FORMAT_ID]: {
    badge: 'bg-primary/10 text-primary border-primary/30',
    dot: 'bg-primary',
  },
};

const EXPORT_FORMATS = [
  {
    id: NATIVE_FORMAT_ID,
    label: 'Native JSON',
    description:
      'Lossless document array (titles, text, sources, categories, pages, metadata) - round-trip re-importable.',
  },
  {
    id: 'csv',
    label: 'CSV',
    description: 'id + text + metadata columns (no vectors) for spreadsheets.',
  },
  {
    id: 'jsonl',
    label: 'JSONL',
    description: 'One id + text + metadata record per line (no vectors).',
  },
];

const VALID_SOURCES: DocumentSource[] = ['text', 'sample', 'pdf', 'ocr', 'import'];

const SAMPLE_NOTE =
  'Privacy and encryption on device: encrypting personal data with AES-GCM keys derived on the device keeps private information confidential, and no plaintext ever leaves the browser. Local-first vector search then runs entirely offline over the encrypted-at-rest corpus.';


interface Session {
  engine: KnowledgeBaseEngine;
  engineKind: 'core';
  documents: RawDocument[];
  addDocuments: (docs: Array<Omit<RawDocument, 'id' | 'addedAt'>>) => Promise<void>;
  removeDocument: (docId: string) => Promise<void>;
  clearAll: () => Promise<void>;
  chunking: ChunkingMode;
  setChunking: (mode: ChunkingMode) => void;
  chunkSize: number;
  setChunkSize: (n: number) => void;
  embeddingModelId: string;
  setEmbeddingModelId: (id: string) => void;
  busy: boolean;
  error: string | null;
}


type DocDraft = Omit<RawDocument, 'id' | 'addedAt'>;

interface NativeExport {
  format: typeof NATIVE_MARKER;
  version: 1;
  exportedAt: string;
  embeddingModelId: string;
  engine: { kind: 'core' | 'langchain'; documents: number; chunks: number; dimensions: number };
  documents: RawDocument[];
}

interface ImportLanes {
  direct: DocDraft[];
  needsEmbed: DocDraft[];
  vectorOnly: number;
  vectorOnlyMismatched: number;
  empty: number;
}

interface ImportPlan {
  formatId: string;
  fileName: string | null;
  totalRecords: number;
  recordsWithVectors: number;
  recordsWithTextOnly: number;
  dimensions: number | null;
  lanes: ImportLanes;
  previewRows: FlowPreviewRecord[];
}

interface ImportStats {
  imported: number;
  skipped: number;
  reEmbedded: number;
  totalParsed: number;
  formatId: string;
  durationMs: number;
  cancelled: boolean;
}

interface IngestFingerprint {
  modelId: string;
  docsKey: string;
}


function firstLine(text: string) {
  return text.split('\n').find((l) => l.trim().length > 0)?.trim().slice(0, 80) ?? '';
}

function truncate(text: string, max = 80) {
  return text.length > max ? `${text.slice(0, max)}…` : text;
}

function formatDuration(ms: number) {
  return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms)}ms`;
}

function toDraft(doc: RawDocument): DocDraft {
  return {
    title: doc.title,
    text: doc.text,
    source: doc.source,
    ...(doc.category !== undefined ? { category: doc.category } : {}),
    ...(doc.meta !== undefined ? { meta: doc.meta } : {}),
    ...(doc.pages !== undefined ? { pages: doc.pages } : {}),
  };
}

function coerceNativeDocument(raw: unknown, index: number): RawDocument {
  if (raw === null || typeof raw !== 'object') {
    throw new Error(`Native export document #${index + 1} is not an object.`);
  }
  const obj = raw as Record<string, unknown>;
  if (typeof obj.title !== 'string' || typeof obj.text !== 'string') {
    throw new Error(
      `Native export document #${index + 1} is missing its "title"/"text" string fields.`,
    );
  }
  const source = VALID_SOURCES.includes(obj.source as DocumentSource)
    ? (obj.source as DocumentSource)
    : 'import';
  const meta: Record<string, string | number> = {};
  if (obj.meta !== null && typeof obj.meta === 'object') {
    for (const [k, v] of Object.entries(obj.meta as Record<string, unknown>)) {
      if (typeof v === 'string' || typeof v === 'number') meta[k] = v;
    }
  }
  const pages = Array.isArray(obj.pages)
    ? obj.pages.filter(
        (p): p is { page: number; text: string } =>
          p !== null &&
          typeof p === 'object' &&
          typeof (p as { page?: unknown }).page === 'number' &&
          typeof (p as { text?: unknown }).text === 'string',
      )
    : undefined;
  return {
    id: typeof obj.id === 'string' && obj.id ? obj.id : `import-${index}`,
    title: obj.title,
    text: obj.text,
    source,
    ...(typeof obj.category === 'string' ? { category: obj.category } : {}),
    ...(Object.keys(meta).length > 0 ? { meta } : {}),
    ...(pages && pages.length > 0 ? { pages } : {}),
    addedAt: typeof obj.addedAt === 'number' ? obj.addedAt : Date.now(),
  };
}

function parseNativeExport(content: string): NativeExport | null {
  const trimmed = content.trim();
  if (!trimmed.startsWith('{')) return null;
  let parsed: unknown;
  try {
    parsed = JSON.parse(trimmed);
  } catch {
    return null;
  }
  if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
  const obj = parsed as Record<string, unknown>;
  if (obj.format !== NATIVE_MARKER) return null;
  if (!Array.isArray(obj.documents)) {
    throw new Error('Native knowledge-base export is malformed: expected a "documents" array.');
  }
  const documents = obj.documents.map(coerceNativeDocument);
  const engine =
    obj.engine !== null && typeof obj.engine === 'object'
      ? (obj.engine as Record<string, unknown>)
      : {};
  return {
    format: NATIVE_MARKER,
    version: 1,
    exportedAt: typeof obj.exportedAt === 'string' ? obj.exportedAt : '',
    embeddingModelId: typeof obj.embeddingModelId === 'string' ? obj.embeddingModelId : '',
    engine: {
      kind: engine.kind === 'langchain' ? 'langchain' : 'core',
      documents: typeof engine.documents === 'number' ? engine.documents : documents.length,
      chunks: typeof engine.chunks === 'number' ? engine.chunks : 0,
      dimensions: typeof engine.dimensions === 'number' ? engine.dimensions : 0,
    },
    documents,
  };
}

function externalRecordToDraft(record: ImportRecord, formatId: string): DocDraft {
  const md = record.metadata ?? {};
  const title =
    typeof md.title === 'string' && md.title.trim().length > 0
      ? md.title
      : firstLine(record.text ?? '') || record.id;
  const meta: Record<string, string | number> = {
    importFormat: formatId,
    importId: record.id,
  };
  for (const [k, v] of Object.entries(md)) {
    if (k === 'title' || k === 'category') continue;
    if (typeof v === 'string' || typeof v === 'number') meta[k] = v;
  }
  return {
    title,
    text: record.text ?? '',
    source: 'import',
    ...(typeof md.category === 'string' ? { category: md.category } : {}),
    meta,
  };
}

function buildExternalLanes(
  records: ImportRecord[],
  formatId: string,
  targetDims: number | null,
): ImportLanes {
  const direct: DocDraft[] = [];
  const needsEmbed: DocDraft[] = [];
  let vectorOnly = 0;
  let vectorOnlyMismatched = 0;
  let empty = 0;
  for (const r of records) {
    const hasText = typeof r.text === 'string' && r.text.trim().length > 0;
    const dimsUsable = r.vector != null && (targetDims === null || r.vector.length === targetDims);
    if (hasText && dimsUsable) {
      direct.push(externalRecordToDraft(r, formatId));
    } else if (hasText) {
      needsEmbed.push(externalRecordToDraft(r, formatId));
    } else if (r.vector != null) {
      vectorOnly++;
      if (targetDims !== null && r.vector.length !== targetDims) vectorOnlyMismatched++;
    } else {
      empty++;
    }
  }
  return { direct, needsEmbed, vectorOnly, vectorOnlyMismatched, empty };
}

function docToImportRecord(doc: RawDocument): ImportRecord {
  return {
    id: doc.id,
    text: doc.text,
    metadata: {
      title: doc.title,
      source: doc.source,
      ...(doc.category !== undefined ? { category: doc.category } : {}),
      ...(doc.meta ?? {}),
    },
  };
}

function countBy(docs: RawDocument[], key: (d: RawDocument) => string) {
  const counts: Record<string, number> = {};
  for (const d of docs) {
    const k = key(d);
    counts[k] = (counts[k] ?? 0) + 1;
  }
  return Object.entries(counts).sort((a, b) => b[1] - a[1]);
}


function SectionCard({ title, children }: { title: string; children: ReactNode }) {
  return (
    <section className="flex flex-col gap-3 rounded-xl border border-border bg-card p-4 text-card-foreground">
      <h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
        {title}
      </h2>
      {children}
    </section>
  );
}

function PreviewStat({ label, value }: { label: string; value: string }) {
  return (
    <div className="flex flex-col gap-0.5 rounded-md border border-border bg-background px-2 py-1.5">
      <span className="text-[10px] uppercase tracking-wide text-muted-foreground">{label}</span>
      <span className="text-sm font-semibold tabular-nums">{value}</span>
    </div>
  );
}


function DataManager({ session }: { session: Session }) {
  const { quota, refresh: refreshQuota } = useStorageQuota();

  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);

  const [engineStats, setEngineStats] = useState<EngineStats | null>(null);
  useEffect(() => {
    if (session.busy) return;
    let alive = true;
    void session.engine
      .stats()
      .then((s) => {
        if (alive) setEngineStats(s);
      })
      .catch(() => {
        if (alive) setEngineStats(null);
      });
    return () => {
      alive = false;
    };
  }, [session.engine, session.busy, session.documents]);

  const targetDims = engineStats && engineStats.dimensions > 0 ? engineStats.dimensions : null;

  const batch = useAdaptiveBatchSize({
    taskType: 'ingestion',
    modelDimensions: targetDims ?? DEFAULT_DIMENSIONS,
  });

  const {
    parsePreview,
    isParsing,
    error: parseError,
    reset: resetParse,
  } = useImportExport({
    db: { dimensions: targetDims ?? DEFAULT_DIMENSIONS, addMany: async () => {} },
  });

  const [stage, setStage] = useState<'idle' | 'preview' | 'importing' | 'complete'>('idle');
  const [plan, setPlan] = useState<ImportPlan | null>(null);
  const [reEmbed, setReEmbed] = useState(false);
  const [pasteText, setPasteText] = useState('');
  const [importProgress, setImportProgress] = useState<ImportProgressLike | null>(null);
  const [importStats, setImportStats] = useState<ImportStats | null>(null);
  const [importError, setImportError] = useState<string | null>(null);
  const importAbortRef = useRef<AbortController | null>(null);
  const fileInputRef = useRef<HTMLInputElement>(null);

  const [exportingFormat, setExportingFormat] = useState<string | null>(null);
  const [lastExport, setLastExport] = useState<{
    formatId: string;
    records: number;
    bytes: number;
    at: string;
  } | null>(null);

  const [lastIngest, setLastIngest] = useState<IngestFingerprint | null>(null);
  const [reindexRunning, setReindexRunning] = useState(false);
  const [reindexProgress, setReindexProgress] = useState<ReindexProgressLike | null>(null);
  const [reindexError, setReindexError] = useState<string | null>(null);
  const reindexAbortRef = useRef<AbortController | null>(null);
  const prevBusyRef = useRef(session.busy);

  const docsKey = session.documents.map((d) => d.id).join('|');

  useEffect(() => {
    const wasBusy = prevBusyRef.current;
    prevBusyRef.current = session.busy;
    if (session.busy) return;
    if (session.documents.length === 0) {
      setLastIngest(null);
      return;
    }
    if (session.error) return;
    if (
      wasBusy ||
      lastIngest === null ||
      (lastIngest.docsKey !== docsKey && lastIngest.modelId === session.embeddingModelId)
    ) {
      setLastIngest({ modelId: session.embeddingModelId, docsKey });
    }
  }, [
    session.busy,
    session.error,
    session.documents.length,
    session.embeddingModelId,
    docsKey,
    lastIngest,
  ]);

  const drift =
    lastIngest !== null &&
    session.documents.length > 0 &&
    lastIngest.modelId !== session.embeddingModelId;

  const importing = stage === 'importing';
  const busyAny =
    session.busy || importing || reindexRunning || exportingFormat !== null || isParsing;


  const resetImport = () => {
    importAbortRef.current?.abort();
    resetParse();
    setStage('idle');
    setPlan(null);
    setReEmbed(false);
    setImportProgress(null);
    setImportStats(null);
    setImportError(null);
    if (fileInputRef.current) fileInputRef.current.value = '';
  };

  const handleContent = async (content: string, fileName: string | null) => {
    resetParse();
    setImportError(null);
    setImportStats(null);
    setImportProgress(null);
    setPlan(null);

    if (!content.trim()) {
      setImportError('The provided content is empty.');
      return;
    }

    let native: NativeExport | null = null;
    try {
      native = parseNativeExport(content);
    } catch (err) {
      setImportError(err instanceof Error ? err.message : String(err));
      return;
    }
    if (native) {
      if (native.documents.length === 0) {
        setImportError('Native knowledge-base export contains no documents.');
        return;
      }
      setPlan({
        formatId: NATIVE_FORMAT_ID,
        fileName,
        totalRecords: native.documents.length,
        recordsWithVectors: 0,
        recordsWithTextOnly: native.documents.length,
        dimensions: native.engine.dimensions > 0 ? native.engine.dimensions : null,
        lanes: {
          direct: native.documents.map(toDraft),
          needsEmbed: [],
          vectorOnly: 0,
          vectorOnlyMismatched: 0,
          empty: 0,
        },
        previewRows: native.documents.slice(0, MAX_PREVIEW_RECORDS).map((d) => ({
          id: d.id,
          text: truncate(`${d.title} - ${d.text}`),
          hasVector: false,
        })),
      });
      setStage('preview');
      return;
    }

    const result = await parsePreview({ content });
    if (!result) return;
    setPlan({
      formatId: result.format,
      fileName,
      totalRecords: result.totalRecords,
      recordsWithVectors: result.recordsWithVectors,
      recordsWithTextOnly: result.recordsWithTextOnly,
      dimensions: result.dimensions,
      lanes: buildExternalLanes(result.records, result.format, targetDims),
      previewRows: result.records.slice(0, MAX_PREVIEW_RECORDS).map((r) => ({
        id: r.id,
        ...(r.text ? { text: truncate(r.text) } : {}),
        hasVector: r.vector != null,
      })),
    });
    setStage('preview');
  };

  const handleFile = async (file: File) => {
    if (file.size > MAX_FILE_SIZE) {
      setImportError(
        `File is ${(file.size / (1024 * 1024)).toFixed(1)} MB - the import limit is 50 MB.`,
      );
      return;
    }
    const content = await file.text();
    await handleContent(content, file.name);
  };

  const runImport = async () => {
    if (!plan || stage !== 'preview' || session.busy || reindexRunning) return;
    const docs = reEmbed ? [...plan.lanes.direct, ...plan.lanes.needsEmbed] : plan.lanes.direct;
    if (docs.length === 0) return;

    const controller = new AbortController();
    importAbortRef.current = controller;
    setStage('importing');
    setImportError(null);
    setImportProgress({ phase: 'validating', overallCompleted: 0, overallTotal: docs.length });

    const started = performance.now();
    const batchSize = Math.max(1, batch.batchSize);
    let imported = 0;
    try {
      for (let i = 0; i < docs.length; i += batchSize) {
        if (controller.signal.aborted) break;
        setImportProgress({
          phase: 'embedding',
          overallCompleted: imported,
          overallTotal: docs.length,
        });
        await session.addDocuments(docs.slice(i, i + batchSize));
        imported += Math.min(batchSize, docs.length - i);
        setImportProgress({
          phase: 'importing',
          overallCompleted: imported,
          overallTotal: docs.length,
        });
      }
      setImportStats({
        imported,
        skipped: plan.totalRecords - imported,
        reEmbedded: reEmbed ? Math.max(0, imported - plan.lanes.direct.length) : 0,
        totalParsed: plan.totalRecords,
        formatId: plan.formatId,
        durationMs: performance.now() - started,
        cancelled: controller.signal.aborted,
      });
      setStage('complete');
    } catch (err) {
      setImportError(err instanceof Error ? err.message : String(err));
      setStage('preview');
    } finally {
      setImportProgress(null);
      importAbortRef.current = null;
      void refreshQuota();
    }
  };

  const cancelImport = () => importAbortRef.current?.abort();


  const exportDisabled = busyAny || session.documents.length === 0;

  const handleExport = (formatId: string) => {
    if (exportDisabled) return;
    setExportingFormat(formatId);
    try {
      let content: string;
      let filename: string;
      let mime: string;
      if (formatId === NATIVE_FORMAT_ID) {
        const envelope: NativeExport = {
          format: NATIVE_MARKER,
          version: 1,
          exportedAt: new Date().toISOString(),
          embeddingModelId: session.embeddingModelId,
          engine: {
            kind: session.engineKind,
            documents: session.documents.length,
            chunks: engineStats?.chunks ?? 0,
            dimensions: engineStats?.dimensions ?? 0,
          },
          documents: session.documents,
        };
        content = JSON.stringify(envelope, null, 2);
        filename = 'knowledge-base-export.json';
        mime = 'application/json';
      } else {
        const records = session.documents.map(docToImportRecord);
        if (formatId === 'csv') {
          content = exportToCSV(records, { includeVectors: false });
          filename = 'knowledge-base-export.csv';
          mime = 'text/csv';
        } else {
          content = exportToJSONL(records, { includeVectors: false });
          filename = 'knowledge-base-export.jsonl';
          mime = 'application/jsonl';
        }
      }
      downloadBlob(content, filename, mime);
      setLastExport({
        formatId,
        records: session.documents.length,
        bytes: new Blob([content]).size,
        at: new Date().toISOString(),
      });
    } catch (err) {
      setImportError(err instanceof Error ? err.message : String(err));
    } finally {
      setExportingFormat(null);
    }
  };


  const reindex = async () => {
    if (session.busy || reindexRunning || importing || session.documents.length === 0) return;
    const controller = new AbortController();
    reindexAbortRef.current = controller;
    setReindexRunning(true);
    setReindexError(null);
    const snapshot = session.documents.map(toDraft);
    setReindexProgress({ completed: 0, total: snapshot.length, phase: 'embedding' });
    try {
      session.setEmbeddingModelId(session.embeddingModelId);
      await session.clearAll();
      const batchSize = Math.max(1, batch.batchSize);
      let completed = 0;
      for (let i = 0; i < snapshot.length; i += batchSize) {
        if (controller.signal.aborted) break;
        await session.addDocuments(snapshot.slice(i, i + batchSize));
        completed += Math.min(batchSize, snapshot.length - i);
        setReindexProgress({
          completed,
          total: snapshot.length,
          phase: completed === snapshot.length ? 'indexing' : 'embedding',
        });
      }
      if (!controller.signal.aborted) {
        setLastIngest({ modelId: session.embeddingModelId, docsKey: '' });
      }
    } catch (err) {
      setReindexError(err instanceof Error ? err.message : String(err));
    } finally {
      setReindexRunning(false);
      setReindexProgress(null);
      reindexAbortRef.current = null;
      void refreshQuota();
    }
  };

  const cancelReindex = () => reindexAbortRef.current?.abort();


  const docsToImport = plan
    ? reEmbed
      ? plan.lanes.direct.length + plan.lanes.needsEmbed.length
      : plan.lanes.direct.length
    : 0;
  const importErrorMessage = importError ?? parseError?.message ?? null;
  const dimensionMismatch =
    plan !== null &&
    plan.dimensions !== null &&
    targetDims !== null &&
    plan.dimensions !== targetDims;

  const chunkCount = engineStats?.chunks ?? 0;
  const dims = engineStats?.dimensions ?? 0;
  const estimatedBytes = chunkCount * dims * 4;
  const sourceCounts = countBy(session.documents, (d) => d.source);
  const categoryCounts = countBy(session.documents, (d) => d.category ?? 'uncategorized');

  return (
    <div className="flex flex-col gap-4">
      {}
      {drift && lastIngest && (
        <div
          data-stored-model={lastIngest.modelId}
          data-current-model={session.embeddingModelId}
          data-affected-docs={session.documents.length}
          data-affected-chunks={chunkCount}
          className="flex flex-col gap-1.5"
        >
          <EmbeddingDriftBanner
            className="max-w-none"
            storedModelId={lastIngest.modelId}
            currentModelId={session.embeddingModelId}
            isReindexing={reindexRunning}
            progress={reindexProgress}
            onReindex={() => void reindex()}
            onCancel={cancelReindex}
          />
          <p className="text-xs text-muted-foreground">
            Affected: {session.documents.length} document
            {session.documents.length === 1 ? '' : 's'} · {chunkCount} chunk
            {chunkCount === 1 ? '' : 's'}
            {session.busy && ' · corpus ingest in progress - reindex is available when idle'}
          </p>
        </div>
      )}

      <div className="grid items-start gap-4 lg:grid-cols-2">
        {}
        <div className="flex min-w-0 flex-col gap-4">
          <SectionCard title="Import vectors">
            <div className="flex flex-wrap items-center gap-2">
              <span className="text-xs text-muted-foreground">
                Pinecone JSON · ChromaDB JSON · CSV · JSONL · Native JSON (≤50{' '}MB)
              </span>
              {(plan || isParsing) && (
                <span
                  role="group"
                  aria-label="Detected import format"
                  className="inline-flex"
                >
                  <FormatDetectionBadge format={plan?.formatId ?? null} colorMap={FORMAT_COLOR_MAP} />
                </span>
              )}
            </div>

            {importErrorMessage && (
              <p
                className="flex items-start gap-1.5 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive"
              >
                <AlertTriangle className="mt-0.5 size-3.5 shrink-0" aria-hidden="true" />
                <span className="min-w-0 flex-1">{importErrorMessage}</span>
              </p>
            )}

            {}
            {stage === 'idle' && (
              <div className="flex flex-col gap-3">
                <div
                  role="button"
                  aria-label="Import file upload"
                  tabIndex={0}
                  onClick={() => fileInputRef.current?.click()}
                  onKeyDown={(e) => {
                    if (e.key === 'Enter' || e.key === ' ') fileInputRef.current?.click();
                  }}
                  onDragOver={(e) => e.preventDefault()}
                  onDrop={(e) => {
                    e.preventDefault();
                    const file = e.dataTransfer.files[0];
                    if (file) void handleFile(file);
                  }}
                  className="flex cursor-pointer flex-col items-center gap-2 rounded-xl border-2 border-dashed border-border p-6 text-center transition-colors hover:border-primary/50"
                >
                  <Upload className="size-6 text-muted-foreground" aria-hidden="true" />
                  <p className="text-sm font-medium">Drop an export file or click to browse</p>
                  <p className="text-xs text-muted-foreground">
                    {SUPPORTED_EXTENSIONS.join(', ')} - the format is auto-detected from content
                  </p>
                  <input
                    ref={fileInputRef}
                    type="file"
                    accept={SUPPORTED_EXTENSIONS.join(',')}
                    className="hidden"
                    onChange={(e) => {
                      const file = e.target.files?.[0];
                      if (file) void handleFile(file);
                    }}
                  />
                </div>
                <div className="flex flex-col gap-1.5">
                  <label
                    htmlFor="vdm-import-paste-input"
                    className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground"
                  >
                    <ClipboardPaste className="size-3.5" aria-hidden="true" />
                    Or paste export content
                  </label>
                  <textarea
                    id="vdm-import-paste-input"
                    value={pasteText}
                    onChange={(e) => setPasteText(e.target.value)}
                    rows={3}
                    placeholder='{"vectors": [...]} · {"ids": [...]} · CSV · JSONL'
                    className="w-full resize-y rounded-lg border border-border bg-background px-3 py-2 font-mono text-xs outline-none focus-visible:ring-[3px] focus-visible:ring-ring/40"
                  />
                  <button
                    type="button"
                    disabled={!pasteText.trim() || isParsing}
                    onClick={() => void handleContent(pasteText, null)}
                    className="self-end rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50"
                  >
                    {isParsing ? 'Parsing…' : 'Parse pasted content'}
                  </button>
                </div>
              </div>
            )}

            {}
            {plan && (stage === 'preview' || stage === 'importing') && (
              <div
                role="group"
                aria-label="Import preview"
                data-total-records={plan.totalRecords}
                data-with-vectors={plan.recordsWithVectors}
                data-text-only={plan.recordsWithTextOnly}
                data-dimensions={plan.dimensions ?? ''}
                data-importable={docsToImport}
                className="flex min-w-0 flex-col gap-3 overflow-x-auto"
              >
                <p className="flex items-center gap-1.5 text-xs text-muted-foreground">
                  <FileText className="size-3.5 shrink-0" aria-hidden="true" />
                  <span className="min-w-0 truncate font-medium text-foreground">
                    {plan.fileName ?? 'Pasted content'}
                  </span>
                  <span>· {FORMAT_LABELS[plan.formatId] ?? plan.formatId}</span>
                </p>

                <dl className="grid grid-cols-2 gap-2 text-xs sm:grid-cols-4">
                  <PreviewStat label="Total" value={plan.totalRecords.toLocaleString()} />
                  <PreviewStat label="With vectors" value={plan.recordsWithVectors.toLocaleString()} />
                  <PreviewStat label="Text-only" value={plan.recordsWithTextOnly.toLocaleString()} />
                  <PreviewStat
                    label="Dimensions"
                    value={plan.dimensions !== null ? String(plan.dimensions) : '-'}
                  />
                </dl>

                {dimensionMismatch && (
                  <p className="flex items-start gap-1.5 text-xs text-amber-600 dark:text-amber-400">
                    <AlertTriangle className="mt-0.5 size-3.5 shrink-0" aria-hidden="true" />
                    <span>
                      Dimension mismatch: source {plan.dimensions}d vs current index {targetDims}d.
                      Text-bearing records are re-embedded with the session model.
                    </span>
                  </p>
                )}

                {}
                {plan.lanes.vectorOnly > 0 && (
                  <p className="flex items-start gap-1.5 text-xs text-amber-600 dark:text-amber-400">
                    <AlertTriangle className="mt-0.5 size-3.5 shrink-0" aria-hidden="true" />
                    <span>
                      {plan.lanes.vectorOnly} vector-only record
                      {plan.lanes.vectorOnly === 1 ? '' : 's'} will be skipped: the corpus only
                      accepts text documents (raw-vector insert is not part of the engine&apos;s ingest
                      API).
                      {plan.lanes.vectorOnlyMismatched > 0 &&
                        ` ${plan.lanes.vectorOnlyMismatched} of them also mismatch the current index dimensions.`}
                    </span>
                  </p>
                )}

                {plan.recordsWithVectors > 0 && plan.lanes.direct.length > 0 && (
                  <p className="text-xs text-muted-foreground">
                    Text-bearing records import as documents and are re-embedded by the engine -
                    their source vectors are not inserted.
                  </p>
                )}

                {}
                {plan.lanes.needsEmbed.length > 0 && (
                  <div className="flex items-center justify-between gap-3 rounded-lg border border-border bg-background px-3 py-2">
                    <div className="min-w-0">
                      <p className="text-xs font-medium">Re-embed records without usable vectors</p>
                      <p className="text-xs text-muted-foreground">
                        {plan.lanes.needsEmbed.length} record
                        {plan.lanes.needsEmbed.length === 1 ? ' has' : 's have'} text but a missing
                        or dimension-mismatched vector. Enable to embed them with the session model (
                        {session.embeddingModelId}); otherwise they are skipped.
                      </p>
                    </div>
                    <button
                      type="button"
                      role="switch"
                      aria-checked={reEmbed}
                      disabled={importing}
                      onClick={() => setReEmbed((v) => !v)}
                      className={`relative h-5 w-9 shrink-0 rounded-full transition-colors focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 ${
                        reEmbed ? 'bg-primary' : 'bg-muted'
                      }`}
                    >
                      <span
                        className={`absolute top-0.5 size-4 rounded-full bg-background shadow transition-all ${
                          reEmbed ? 'left-[1.125rem]' : 'left-0.5'
                        }`}
                      />
                      <span className="sr-only">Re-embed records without usable vectors</span>
                    </button>
                  </div>
                )}

                {}
                <VectorImportFlow
                  className="max-w-none"
                  records={plan.previewRows}
                  progress={importProgress}
                  isImporting={importing}
                />

                {}
                {importing && importProgress && (
                  <span
                    className="sr-only"
                    data-phase={importProgress.phase}
                    data-completed={importProgress.overallCompleted}
                    data-total={importProgress.overallTotal}
                  >
                    {importProgress.phase} {importProgress.overallCompleted}/
                    {importProgress.overallTotal}
                  </span>
                )}

                <div className="flex items-center justify-end gap-2">
                  <button
                    type="button"
                    onClick={resetImport}
                    disabled={importing}
                    className="rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50"
                  >
                    Change file
                  </button>
                  {importing ? (
                    <button
                      type="button"
                      onClick={cancelImport}
                      className="inline-flex items-center gap-1.5 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-1.5 text-xs font-medium text-destructive transition-colors hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
                    >
                      <Square className="size-3.5" aria-hidden="true" />
                      Cancel import
                    </button>
                  ) : (
                    <button
                      type="button"
                      disabled={docsToImport === 0 || session.busy || reindexRunning}
                      onClick={() => void runImport()}
                      className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50"
                    >
                      <Play className="size-3.5" aria-hidden="true" />
                      Import {docsToImport.toLocaleString()} of {plan.totalRecords.toLocaleString()}{' '}
                      records
                    </button>
                  )}
                </div>
              </div>
            )}

            {}
            {stage === 'complete' && importStats && (
              <div
                role="group"
                aria-label="Import result"
                data-imported={importStats.imported}
                data-skipped={importStats.skipped}
                data-reembedded={importStats.reEmbedded}
                data-total={importStats.totalParsed}
                data-format={importStats.formatId}
                data-cancelled={importStats.cancelled ? 'true' : 'false'}
                data-duration-ms={Math.round(importStats.durationMs)}
                className="flex flex-col gap-2"
              >
                <p className="flex items-center gap-1.5 text-sm font-medium">
                  {importStats.cancelled ? (
                    <>
                      <AlertTriangle className="size-4 shrink-0 text-amber-500" aria-hidden="true" />
                      Import cancelled
                    </>
                  ) : (
                    <>
                      <CheckCircle2 className="size-4 shrink-0 text-emerald-500" aria-hidden="true" />
                      Import complete
                    </>
                  )}
                </p>
                <p className="text-xs text-muted-foreground">
                  Imported {importStats.imported.toLocaleString()} · Skipped{' '}
                  {importStats.skipped.toLocaleString()} · Re-embedded{' '}
                  {importStats.reEmbedded.toLocaleString()} ·{' '}
                  {FORMAT_LABELS[importStats.formatId] ?? importStats.formatId} ·{' '}
                  {formatDuration(importStats.durationMs)}
                </p>
                <button
                  type="button"
                  onClick={resetImport}
                  className="inline-flex items-center gap-1.5 self-start rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
                >
                  <RotateCcw className="size-3.5" aria-hidden="true" />
                  Import another file
                </button>
              </div>
            )}
          </SectionCard>

          <SectionCard title="Export corpus">
            <div
              role="group"
              aria-label="Export panel"
              data-record-count={session.documents.length}
              data-exporting={exportingFormat ?? undefined}
              data-last-format={lastExport?.formatId ?? undefined}
              data-last-records={lastExport?.records ?? undefined}
              data-last-bytes={lastExport?.bytes ?? undefined}
              className="min-w-0 overflow-x-auto"
            >
              <VectorExportPanel
                className="max-w-none"
                formats={EXPORT_FORMATS}
                recordCount={session.documents.length}
                exporting={exportingFormat ?? false}
                {...(lastExport ? { lastExport } : {})}
                onExport={handleExport}
                disabled={exportDisabled}
              />
            </div>
            {}
            <div className="sr-only">
              {EXPORT_FORMATS.map((f) => (
                <button
                  key={f.id}
                  type="button"
                  disabled={exportDisabled}
                  onClick={() => handleExport(f.id)}
                  className="focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
                >
                  Export {f.label}
                </button>
              ))}
            </div>
            <p className="text-xs text-muted-foreground">
              Exports carry the raw documents and metadata. Stored vectors are regenerated by
              re-embedding on import. Native JSON is lossless and round-trip re-importable; CSV/JSONL
              round-trip text + metadata.
            </p>
          </SectionCard>
        </div>

        {}
        <div className="flex min-w-0 flex-col gap-4">
          <SectionCard title="Storage">
            <div
              role="group"
              aria-label="Storage usage"
              data-used-bytes={quota?.usedBytes ?? undefined}
              data-quota-bytes={quota?.quotaBytes ?? undefined}
              className="min-w-0 overflow-x-auto"
            >
              <StorageMeter
                className="max-w-none"
                quota={
                  quota && quota.quotaBytes > 0
                    ? { usedBytes: quota.usedBytes, quotaBytes: quota.quotaBytes }
                    : undefined
                }
              />
            </div>
          </SectionCard>

          <SectionCard title="Vector storage">
            <div
              role="group"
              aria-label="Vector storage stats"
              data-docs={session.documents.length}
              data-chunks={chunkCount}
              data-dimensions={dims}
              className="flex min-w-0 flex-col gap-3 overflow-x-auto"
            >
              <VectorStorageObservability
                className="max-w-none"
                stats={{
                  ratio: 1,
                  originalSizeBytes: estimatedBytes,
                  compressedSizeBytes: estimatedBytes,
                  vectorCount: chunkCount,
                }}
                tier="raw"
              />
              <p className="text-xs text-muted-foreground">
                {session.documents.length} document{session.documents.length === 1 ? '' : 's'} ·{' '}
                {chunkCount} chunk{chunkCount === 1 ? '' : 's'} ·{' '}
                {dims > 0 ? `${dims}d vectors` : 'dimensions unknown'} (engine: {session.engineKind})
              </p>
              {(sourceCounts.length > 0 || categoryCounts.length > 0) && (
                <dl className="flex flex-col gap-1 text-xs text-muted-foreground">
                  {sourceCounts.length > 0 && (
                    <div className="flex flex-wrap gap-x-3 gap-y-1">
                      <dt className="font-medium text-foreground">By source:</dt>
                      {sourceCounts.map(([source, count]) => (
                        <dd key={source} className="tabular-nums">
                          {source} · {count}
                        </dd>
                      ))}
                    </div>
                  )}
                  {categoryCounts.length > 0 && (
                    <div className="flex flex-wrap gap-x-3 gap-y-1">
                      <dt className="font-medium text-foreground">By category:</dt>
                      {categoryCounts.map(([category, count]) => (
                        <dd key={category} className="tabular-nums">
                          {category} · {count}
                        </dd>
                      ))}
                    </div>
                  )}
                </dl>
              )}
              {}
              <p className="text-xs text-muted-foreground">
                Sizes are estimated as chunks × dimensions × 4 bytes (Raw F32). Live compression
                stats via <code className="font-mono">getCompressionStats()</code> need the
                underlying VectorDB, which the core engine encapsulates; per-document chunk counts
                are likewise unavailable (<code className="font-mono">engine.stats()</code> reports
                totals only).
              </p>
            </div>
          </SectionCard>

          <SectionCard title="Maintenance">
            <div className="flex items-center justify-between gap-3">
              <div className="min-w-0">
                <p className="text-xs font-medium">Reindex corpus</p>
                <p className="text-xs text-muted-foreground">
                  Re-embeds all {session.documents.length} document
                  {session.documents.length === 1 ? '' : 's'} ({chunkCount} chunk
                  {chunkCount === 1 ? '' : 's'}) with the active model and chunking mode (
                  {session.chunking}).
                </p>
              </div>
              {reindexRunning ? (
                <button
                  type="button"
                  onClick={cancelReindex}
                  className="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-1.5 text-xs font-medium text-destructive transition-colors hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
                >
                  <Square className="size-3.5" aria-hidden="true" />
                  Cancel
                </button>
              ) : (
                <button
                  type="button"
                  disabled={session.busy || importing || session.documents.length === 0}
                  onClick={() => void reindex()}
                  className="inline-flex shrink-0 items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50"
                >
                  <RefreshCw className="size-3.5" aria-hidden="true" />
                  Reindex
                </button>
              )}
            </div>
            {reindexRunning && reindexProgress && (
              <p className="text-xs tabular-nums text-muted-foreground">
                {reindexProgress.phase === 'indexing' ? 'Rebuilding index' : 'Re-embedding'} -{' '}
                {reindexProgress.completed}/{reindexProgress.total} documents
              </p>
            )}
            {reindexError && (
              <p className="flex items-start gap-1.5 text-xs text-destructive">
                <AlertTriangle className="mt-0.5 size-3.5 shrink-0" aria-hidden="true" />
                {reindexError}
              </p>
            )}
            <p className="text-xs text-muted-foreground">
              Reindex snapshots the documents, clears the index, and re-adds them in batches of{' '}
              {Math.max(1, batch.batchSize)}. Cancelling stops between batches - documents from the
              snapshot not yet re-added are not restored.
            </p>
          </SectionCard>

          <div
            data-batch-size={batch.batchSize}
            className="flex flex-col gap-1.5"
          >
            {mounted && <AdaptiveBatchCard className="max-w-none" result={batch} />}
            <p className="text-xs text-muted-foreground">
              Used as the ingest batch size for imports and reindexing on this device.
            </p>
          </div>
        </div>
      </div>
    </div>
  );
}


export function VectorDataManagerBlock() {
  const idRef = useRef(DEFAULT_EMBEDDING_MODEL_ID);
  const kb = useKnowledgeBase({
    embeddingModelId: DEFAULT_EMBEDDING_MODEL_ID,
    engineKind: 'core',
    createEmbeddingModel: (id, onProgress) =>
      transformers.embedding(id, { onProgress: (p) => onProgress(p as AnyLoadProgress) }),
    isModelCached: (id) => isModelCached(id),
    createEngine: () => {
      const embeddingModel = transformers.embedding(idRef.current);
      const getLanguageModel = async () => {
        const device = (await isWebGPUSupported()) ? 'webgpu' : 'wasm';
        return transformers.languageModel(ANSWER_MODEL_ID, { device });
      };
      return createKnowledgeBaseEngine({ embeddingModel, getLanguageModel });
    },
  });
  useEffect(() => {
    idRef.current = kb.embeddingModelId;
  });

  const [seedText, setSeedText] = useState('');

  const addSeed = async () => {
    const text = seedText.trim();
    if (!text || !kb.engine || kb.busy) return;
    await kb.addDocuments([{ title: firstLine(text) || 'Untitled note', text, source: 'text' }]);
    setSeedText('');
  };

  const session: Session | null = kb.engine
    ? {
        engine: kb.engine,
        engineKind: 'core',
        documents: kb.documents,
        addDocuments: kb.addDocuments,
        removeDocument: kb.removeDocument,
        clearAll: kb.clearAll,
        chunking: kb.chunking,
        setChunking: kb.setChunking,
        chunkSize: kb.chunkSize,
        setChunkSize: kb.setChunkSize,
        embeddingModelId: kb.embeddingModelId,
        setEmbeddingModelId: kb.setEmbeddingModelId,
        busy: kb.busy,
        error: kb.error,
      }
    : null;

  const modelMeta = EMBEDDING_MODEL_META[kb.embeddingModelId] ?? {
    name: kb.embeddingModelId,
    size: '',
  };

  const statusText = kb.busy
    ? kb.modelStatus === 'loading'
      ? `loading embedding model… ${Math.round(kb.modelProgress * 100)}%`
      : kb.switching
        ? `re-embedding ${kb.documents.length} docs through ${modelMeta.name}…`
        : 'working…'
    : kb.error
      ? 'error'
      : kb.documents.length > 0
        ? `ready - ${kb.documents.length} docs · ${kb.stats?.chunks ?? 0} chunks`
        : 'idle - import an export file or add a note to build the corpus';

  return (
    <div className="mx-auto flex max-w-5xl flex-col gap-4 p-4">
      {}
      <p
        role="status"
        aria-live="polite"
        className="text-xs text-muted-foreground"
      >
        {statusText}
      </p>
      {kb.error && (
        <p className="text-xs text-destructive">
          {kb.error}
        </p>
      )}

      {}
      <div
        role="group"
        aria-label="Embedding model status"
        data-status={kb.modelStatus}
        data-model-id={kb.embeddingModelId}
        className="flex flex-col gap-2 rounded-xl border border-border bg-card p-4"
      >
        <div className="flex flex-wrap items-center gap-2">
          <span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
            Embedding model
          </span>
          <div
            role="group"
            aria-label="Embedding model"
            className="inline-flex items-center rounded-md border border-border bg-muted/40 p-0.5"
          >
            {EMBEDDING_MODEL_IDS.map((id) => (
              <button
                key={id}
                type="button"
                aria-pressed={kb.embeddingModelId === id}
                disabled={kb.busy || !kb.engine || kb.embeddingModelId === id}
                onClick={() => kb.setEmbeddingModelId(id)}
                className={cn(
                  'inline-flex h-7 items-center rounded px-2.5 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50',
                  kb.embeddingModelId === id
                    ? 'bg-background text-foreground shadow-sm'
                    : 'text-muted-foreground hover:text-foreground',
                  (kb.busy || !kb.engine) && kb.embeddingModelId !== id && 'opacity-60',
                )}
              >
                {EMBEDDING_MODEL_META[id].name}
              </button>
            ))}
          </div>
        </div>

        {kb.modelStatus === 'idle' ? (
          <div className="flex flex-wrap items-center gap-2">
            <p className="text-xs text-muted-foreground">
              <span className="font-medium">{modelMeta.name}</span>
              {modelMeta.size ? ` (${modelMeta.size})` : ''} - not loaded. It downloads on Load, the
              first import/seed, or a model switch.
            </p>
            <button
              type="button"
              disabled={!kb.engine || kb.busy}
              onClick={() => void kb.loadModel()}
              className="inline-flex h-7 items-center rounded-md bg-primary px-3 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:opacity-50"
            >
              Load embedding model
            </button>
          </div>
        ) : (
          <ModelDownloader
            name={modelMeta.name}
            size={modelMeta.size || undefined}
            category="Embedding"
            progress={kb.modelProgressValue}
            cached={kb.modelCached}
            ready={kb.modelReady}
            className="max-w-sm"
          />
        )}
        <p className="text-xs text-muted-foreground">
          Switching the model re-embeds the whole corpus through the new space (drift banner appears
          until the re-ingest settles).
        </p>
      </div>

      {}
      <div className="flex flex-col gap-1.5 rounded-xl border border-border bg-card p-4">
        <div className="flex items-center justify-between">
          <span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
            Seed the corpus
          </span>
          <button
            type="button"
            onClick={() => setSeedText(SAMPLE_NOTE)}
            className="inline-flex h-7 items-center rounded-md border border-border px-2.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
          >
            Load sample
          </button>
        </div>
        <textarea
          aria-label="Seed text"
          value={seedText}
          onChange={(e) => setSeedText(e.target.value)}
          rows={3}
          placeholder="Paste a note or passage to add it as a document…"
          className="w-full resize-y rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus-visible:ring-[3px] focus-visible:ring-ring/40"
        />
        <button
          type="button"
          disabled={!kb.engine || kb.busy || !seedText.trim()}
          onClick={() => void addSeed()}
          className="inline-flex h-8 items-center gap-1.5 self-start rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:opacity-50"
        >
          <Plus className="size-3.5" aria-hidden="true" />
          {kb.busy ? 'Adding…' : 'Add note'}
        </button>
      </div>

      {}
      {session ? (
        <DataManager session={session} />
      ) : (
        <p className="p-4 text-sm text-muted-foreground">Preparing engine…</p>
      )}
    </div>
  );
}
```
