Ready
Network activity
0 requests
Connected - models run on-device
No recent network activity
0 groups0 total requests
Browse blocks
Semantic Search
Build a searchable knowledge base right in your browser. Add content three ways: paste text, upload PDFs, or scan images with OCR. Then search by meaning instead of exact keywords, with the most relevant passages ranked to the top. Everything runs on your device, and nothing downloads until you start.
Install this block
npx shadcn@latest add @localmode/ui/blocks/knowledge/semantic-searchidle - load the sample corpus or add documents, then search
0 docs · 0 chunks
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> );}