Network activity
Browse blocks
Knowledge
Four on-device knowledge blocks that each build and use their own document collection, entirely in the browser. Search your content by meaning, chat with it, ask direct questions, or manage the data behind it. Nothing downloads until you start.
Semantic Search
Add content by pasting text, uploading PDFs, or scanning images, then search it by meaning. The most relevant passages are ranked to the top, all on your device.
Install this block
npx shadcn@latest add @localmode/ui/blocks/knowledge/semantic-searchidle - load the sample corpus or add documents, then search
Embedding model: BGE Small EN v1.5 (34 MB) - not loaded. It downloads on the first ingest or an engine/model switch.
Preparing engine…
'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 & 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 text or an uploaded image, like an invoice, and get a direct answer pulled from the source with a confidence rating.
Install this block
npx shadcn@latest add @localmode/ui/blocks/knowledge/document-qaidle - paste text, load the sample corpus, or upload a document image to begin
Corpus embedding model: BGE Small EN v1.5 (~34 MB) - not loaded. It downloads on the first corpus ingest.
Corpus
Optional - feed the extractive “Corpus top chunk” grounding. The Donut Document QA tool works standalone.
Drop PDFs or click to browse
PDF · up to 10 MB
Preparing engine…
'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 documents and get streaming answers grounded in your own text, with links back to the exact sources and pages they came from.
Install this block
npx shadcn@latest add @localmode/ui/blocks/knowledge/rag-chatidle - load the sample corpus (or add text / a PDF) to index and ask grounded questions
Embedding model: BGE Small EN v1.5 (34 MB) - not loaded. It downloads on the first ingest or an engine switch; the granite answer model downloads on the first ask.
Preparing engine…
'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
Import, preview, and export the vector data behind your knowledge base, watch storage use, and re-index when your embedding model changes.
Install this block
npx shadcn@latest add @localmode/ui/blocks/knowledge/vector-data-manageridle - import an export file or add a note to build the corpus
BGE Small EN v1.5 (34 MB) - not loaded. It downloads on Load, the first import/seed, or a model switch.
Switching the model re-embeds the whole corpus through the new space (drift banner appears until the re-ingest settles).
Preparing engine…
'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'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> );}