Network activity
Browse blocks
Text Insights
Four small text-analysis blocks you can install on their own. Each one runs entirely in your browser, and nothing downloads until you press a button.
Sentiment Analyzer
Score text as positive or negative, one message at a time or thousands at once, with live progress, running totals, and a scrollable list of results.
Install this block
npx shadcn@latest add @localmode/ui/blocks/text-insights/sentiment-analyzerSentiment Analyzer: DistilBERT SST-2. Model loads only behind an explicit action.
Preparing…
'use client';/** * @file sentiment-analyzer.tsx * @description Sentiment Analyzer block — DistilBERT SST-2 sentiment for one or many texts (one per line, or a .txt/.csv first column) with streaming results, determinate throughput, aggregate stats, and a 100-row windowed list; model download gated behind Run. */import { useEffect, useRef, useState } from 'react';import { FileUp, Play, Sparkles, Square, Trash2 } from 'lucide-react';import { useModelLoad, useSequentialBatch, toAppError, type UseModelLoadReturn,} from '@localmode/react';import { classify, type ClassificationModel } from '@localmode/core';import { transformers, isModelCached } from '@localmode/transformers';import { ConfidenceScoreBadge } from '@/components/confidence-score-badge';import { EntityStatsBar } from '@/components/entity-stats-bar';import { ModelLoadingPanel } from '@/components/model-loading-panel';import { CacheBadge } from '@/components/cache-badge';import { ErrorAlert } from '@/components/error-alert';import { ModeErrorBoundary } from '@/components/mode-error-boundary';import { cn } from '@/lib/utils';const SENTIMENT_MODEL_ID = 'Xenova/distilbert-base-uncased-finetuned-sst-2-english';const SENTIMENT_MODEL_NAME = 'DistilBERT Sentiment';const SENTIMENT_MODEL_SIZE = '67 MB';const SAMPLE_REVIEWS = [ 'This product is amazing! Best purchase I ever made.', 'Terrible experience. The item broke after one day.', 'Pretty average product, nothing special about it.', 'I love how easy this is to use. Highly recommend!', 'Waste of money. Customer support was unhelpful too.', 'Great quality and fast shipping. Will buy again.',];const WINDOW_CAP = 100;function formatScore(score: number) { return `${(score * 100).toFixed(1)}%`;}function formatDuration(ms: number) { if (ms < 1000) return `${Math.round(ms)}ms`; return `${(ms / 1000).toFixed(1)}s`;}function parseItems(raw: string, options?: { csv?: boolean }): string[] { return raw .split('\n') .map((line) => { const trimmed = line.trim(); if (!trimmed) return ''; return options?.csv && trimmed.includes(',') ? trimmed.split(',')[0].trim() : trimmed; }) .filter((line) => line.length > 0);}interface SentimentItem { text: string; label: string; score: number; status: 'ok' | 'error';}function SentimentLabel({ label }: { label: string }) { const positive = label.toUpperCase() === 'POSITIVE'; return ( <span className={cn( 'inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide', positive ? 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400' : 'bg-rose-500/15 text-rose-700 dark:text-rose-400', )} > {label} </span> );}export function SentimentAnalyzerBlock() { return ( <div className="mx-auto flex max-w-4xl flex-col gap-4 p-4"> <p className="text-xs text-muted-foreground"> Sentiment Analyzer: DistilBERT SST-2. Model loads only behind an explicit action. </p> <ModeErrorBoundary> <AnalyzeInner /> </ModeErrorBoundary> </div> );}function AnalyzeInner() { const load = useModelLoad<ClassificationModel>({ key: `text-insights-sentiment:${SENTIMENT_MODEL_ID}`, create: (onProgress) => transformers.classifier(SENTIMENT_MODEL_ID, { onProgress: (p) => onProgress(p as Parameters<typeof onProgress>[0]), }), warmup: (model) => classify({ model, text: 'ready' }), isCached: () => isModelCached(SENTIMENT_MODEL_ID), }); const model = load.model; if (!model) return <p className="text-sm text-muted-foreground">Preparing…</p>; return <AnalyzeSurface load={load} model={model} />;}function AnalyzeSurface({ load, model,}: { load: UseModelLoadReturn<ClassificationModel>; model: ClassificationModel;}) { const [input, setInput] = useState(''); const [fileName, setFileName] = useState<string | null>(null); const [isCsv, setIsCsv] = useState(false); const [runItems, setRunItems] = useState<string[]>([]); const [elapsedMs, setElapsedMs] = useState(0); const startRef = useRef(0); const fileInputRef = useRef<HTMLInputElement>(null); const batch = useSequentialBatch<string, { label: string; score: number }>({ fn: async (text, signal) => { const r = await classify({ model, text, abortSignal: signal }); return { label: r.label, score: r.score }; }, }); useEffect(() => { if (!batch.isRunning) return; const id = window.setInterval(() => setElapsedMs(Date.now() - startRef.current), 200); return () => window.clearInterval(id); }, [batch.isRunning]); const items = parseItems(input, { csv: isCsv }); const lineCount = items.length; const charCount = input.length; const appErr = toAppError(batch.error) ?? (load.error ? toAppError(load.error) : null); const results: SentimentItem[] = []; for (let i = 0; i < batch.results.length; i++) { const r = batch.results[i]; const text = runItems[i] ?? ''; if (r !== null) { results.push({ text, label: r.label, score: r.score, status: 'ok' }); } else if (batch.itemErrors[i]) { results.push({ text, label: 'ERROR', score: 0, status: 'error' }); } } const ok = results.filter((r) => r.status === 'ok'); const positive = ok.filter((r) => r.label.toUpperCase() === 'POSITIVE').length; const negative = ok.filter((r) => r.label.toUpperCase() === 'NEGATIVE').length; const total = ok.length; const avgScore = total > 0 ? ok.reduce((s, r) => s + r.score, 0) / total : 0; const done = batch.progress.current; const totalItems = batch.progress.total; const elapsedSec = elapsedMs / 1000; const rate = elapsedSec > 0 ? done / elapsedSec : 0; const etaSec = rate > 0 ? (totalItems - done) / rate : 0; const run = async () => { if (items.length === 0 || batch.isRunning) return; setRunItems(items); setElapsedMs(0); startRef.current = Date.now(); try { await load.load(); } catch { return; } await batch.execute(items); setElapsedMs(Date.now() - startRef.current); }; const loadSample = () => { setInput(SAMPLE_REVIEWS.join('\n')); setFileName(null); setIsCsv(false); }; const clear = () => { setInput(''); setFileName(null); setIsCsv(false); setRunItems([]); batch.reset(); }; const onFile = async (file: File | undefined) => { if (!file) return; const text = await file.text(); setInput(text); setFileName(file.name); setIsCsv(/\.csv$/i.test(file.name)); }; const onKeyDown = (e: React.KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); void run(); } }; const visible = results.length > WINDOW_CAP ? results.slice(0, WINDOW_CAP) : results; const windowed = results.length > WINDOW_CAP; return ( <div className="flex flex-col gap-4"> <div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground"> <span> {SENTIMENT_MODEL_NAME} · {SENTIMENT_MODEL_SIZE} </span> {load.cached === true && ( <span> <CacheBadge cached latencyMs={undefined} label="model cached" /> </span> )} </div> {} <div> <textarea aria-label="Texts to analyze, one per line" value={input} onChange={(e) => { setInput(e.target.value); setIsCsv(false); }} onKeyDown={onKeyDown} placeholder="Enter one text per line: reviews, comments, messages…" rows={6} className="w-full resize-y rounded-md border border-border bg-background p-2.5 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" /> <div className="mt-1 flex flex-wrap items-center justify-between gap-2"> <span className="text-xs text-muted-foreground"> {lineCount} {lineCount === 1 ? 'line' : 'lines'} · {charCount} chars {fileName && <span className="ml-1">· {fileName}</span>} </span> <div className="flex items-center gap-1.5"> <button type="button" onClick={loadSample} className="inline-flex h-7 items-center gap-1 rounded-md border border-border px-2 text-xs font-medium hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" > <Sparkles className="h-3 w-3" aria-hidden /> Load samples </button> <button type="button" onClick={() => fileInputRef.current?.click()} className="inline-flex h-7 items-center gap-1 rounded-md border border-border px-2 text-xs font-medium hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" > <FileUp className="h-3 w-3" aria-hidden /> Upload </button> <input ref={fileInputRef} type="file" aria-label="Upload a .txt or .csv file" accept=".txt,.csv,text/plain,text/csv" className="hidden" onChange={(e) => void onFile(e.target.files?.[0])} /> </div> </div> </div> {} <div className="flex items-center gap-2"> <button type="button" onClick={() => void run()} disabled={items.length === 0 || batch.isRunning} className="inline-flex h-8 items-center gap-1.5 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-2 focus-visible:ring-ring disabled:opacity-50" > <Play className="h-3.5 w-3.5" aria-hidden /> Analyze </button> {batch.isRunning && ( <button type="button" onClick={batch.cancel} className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-3 text-sm font-medium hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" > <Square className="h-3.5 w-3.5" aria-hidden /> Stop </button> )} {results.length > 0 && !batch.isRunning && ( <button type="button" onClick={clear} className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-3 text-sm font-medium hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" > <Trash2 className="h-3.5 w-3.5" aria-hidden /> Clear </button> )} </div> {} {load.status === 'loading' && ( <div> <ModelLoadingPanel name={SENTIMENT_MODEL_NAME} size={SENTIMENT_MODEL_SIZE} progress={load.progressValue} cached={load.cached === true} /> </div> )} {appErr && ( <span> <ErrorAlert message={appErr.message} onRetry={() => void run()} onDismiss={batch.reset} /> </span> )} {} {(batch.isRunning || totalItems > 0) && ( <div className="flex flex-col gap-2 rounded-md border border-border p-3"> <div className="flex items-center justify-between text-xs"> <span data-current={done} data-total={totalItems} data-running={batch.isRunning} > {done} / {totalItems} analyzed </span> <span role="status" aria-label="Throughput" data-rate={rate.toFixed(2)} data-elapsed={elapsedMs} data-eta={etaSec.toFixed(1)} className="font-mono text-muted-foreground" > {rate.toFixed(1)}/s · {formatDuration(elapsedMs)} {batch.isRunning && ` · ETA ${etaSec.toFixed(0)}s`} </span> </div> {batch.isRunning && ( <div className="h-1.5 w-full overflow-hidden rounded-full bg-muted"> <div className="h-full rounded-full bg-primary transition-all" style={{ width: `${totalItems > 0 ? (done / totalItems) * 100 : 0}%` }} /> </div> )} </div> )} {} {total > 0 && ( <div data-positive={positive} data-negative={negative} data-total={total} data-avg={avgScore.toFixed(4)} className="flex flex-col gap-2 rounded-md border border-border p-3" > <EntityStatsBar counts={{ POSITIVE: positive, NEGATIVE: negative }} itemNoun="result" registry={{ POSITIVE: { label: 'Positive', color: 'var(--color-emerald-500, #10b981)' }, NEGATIVE: { label: 'Negative', color: 'var(--color-rose-500, #f43f5e)' }, }} /> <div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground"> <span>Positive: {total > 0 ? formatScore(positive / total) : '0%'}</span> <span>Negative: {total > 0 ? formatScore(negative / total) : '0%'}</span> <span>Total analyzed: {total}</span> <span>Avg confidence: {formatScore(avgScore)}</span> </div> </div> )} {} {results.length > 0 && ( <div className="flex flex-col gap-1.5"> {windowed && ( <p className="text-xs text-muted-foreground"> Showing first {WINDOW_CAP} of {results.length} results (stats cover all). </p> )} <div role="list" aria-label="Sentiment results" className="flex flex-col gap-1.5"> {visible.map((r, i) => ( <div key={i} role="listitem" data-label={r.label} data-score={r.score.toFixed(4)} data-status={r.status} className={cn( 'flex items-center gap-3 rounded-md border p-2 text-sm', r.status === 'error' ? 'border-destructive/40 bg-destructive/5' : 'border-border', )} > <span className="min-w-0 flex-1 truncate" title={r.text}> {r.text} </span> <SentimentLabel label={r.label} /> {r.status === 'ok' && <ConfidenceScoreBadge score={r.score} />} </div> ))} </div> </div> )} </div> );}Text Classifier
Sort text into your own set of labels. Add or remove categories, then see the winning label and how the rest ranked.
Install this block
npx shadcn@latest add @localmode/ui/blocks/text-insights/text-classifierText Classifier: MobileBERT MNLI zero-shot. Model loads only behind an explicit action.
Preparing…
'use client';/** * @file text-classifier.tsx * @description Text Classifier block — MobileBERT MNLI custom-label zero-shot routing with an editable label set, a top-result hero over ranked candidate scores, and a message/email sample loader; model download gated behind Run. */import { useState } from 'react';import { Play, Sparkles, Square } from 'lucide-react';import { useClassifyZeroShot, useModelLoad, toAppError, type UseModelLoadReturn,} from '@localmode/react';import { classifyZeroShot, type ZeroShotClassificationModel } from '@localmode/core';import { transformers, isModelCached } from '@localmode/transformers';import { EditableLabelSet } from '@/components/editable-label-set';import { TopResultCard } from '@/components/top-result-card';import { ScoredResultBarList } from '@/components/scored-result-bar-list';import { ModelLoadingPanel } from '@/components/model-loading-panel';import { CacheBadge } from '@/components/cache-badge';import { ErrorAlert } from '@/components/error-alert';import { ModeErrorBoundary } from '@/components/mode-error-boundary';const ZEROSHOT_MODEL_ID = 'Xenova/mobilebert-uncased-mnli';const ZEROSHOT_MODEL_NAME = 'MobileBERT Zero-Shot';const ZEROSHOT_MODEL_SIZE = '27 MB';const DEFAULT_LABELS = ['Support', 'Sales', 'Billing', 'Spam', 'General Inquiry'];const SAMPLE_EMAILS = [ 'I cannot log into my account. I have tried resetting my password multiple times but keep getting an error.', 'We are interested in your enterprise plan. Can we schedule a demo for our team of 50 people?', 'I was charged twice for my subscription this month. Please process a refund immediately.', 'CONGRATULATIONS! You have won a $1000 gift card! Click here to claim your prize now!!!', 'When will the new version be released? We are excited about the upcoming features.',];export function TextClassifierBlock() { return ( <div className="mx-auto flex max-w-4xl flex-col gap-4 p-4"> <p className="text-xs text-muted-foreground"> Text Classifier: MobileBERT MNLI zero-shot. Model loads only behind an explicit action. </p> <ModeErrorBoundary> <ClassifyInner /> </ModeErrorBoundary> </div> );}function ClassifyInner() { const load = useModelLoad<ZeroShotClassificationModel>({ key: `text-insights-zeroshot:${ZEROSHOT_MODEL_ID}`, create: (onProgress) => transformers.zeroShot(ZEROSHOT_MODEL_ID, { onProgress: (p) => onProgress(p as Parameters<typeof onProgress>[0]), }), warmup: (model) => classifyZeroShot({ model, text: 'ready', candidateLabels: ['yes', 'no'] }), isCached: () => isModelCached(ZEROSHOT_MODEL_ID), }); const model = load.model; if (!model) return <p className="text-sm text-muted-foreground">Preparing…</p>; return <ClassifySurface load={load} model={model} />;}function ClassifySurface({ load, model,}: { load: UseModelLoadReturn<ZeroShotClassificationModel>; model: ZeroShotClassificationModel;}) { const [input, setInput] = useState(''); const [labels, setLabels] = useState<string[]>(DEFAULT_LABELS); const { data, isLoading, error, execute, cancel, reset } = useClassifyZeroShot({ model }); const appErr = toAppError(error) ?? (load.error ? toAppError(load.error) : null); const canRun = input.trim().length > 0 && labels.length > 0; const addLabel = (label: string) => { const trimmed = label.trim(); if (!trimmed) return; setLabels((prev) => (prev.some((l) => l.toLowerCase() === trimmed.toLowerCase()) ? prev : [...prev, trimmed])); }; const removeLabel = (_: string, index: number) => { setLabels((prev) => prev.filter((_, i) => i !== index)); }; const run = async () => { if (!canRun || isLoading) return; try { await load.load(); } catch { return; } await execute({ text: input, candidateLabels: labels }); }; const loadSample = () => { setInput(SAMPLE_EMAILS[Math.floor(Math.random() * SAMPLE_EMAILS.length)]); }; const ranked = data ? data.labels.map((label, i) => ({ label, score: data.scores[i] })) : []; const top = ranked[0]; return ( <div className="flex flex-col gap-4"> <div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground"> <span> {ZEROSHOT_MODEL_NAME} · {ZEROSHOT_MODEL_SIZE} </span> {load.cached === true && ( <span> <CacheBadge cached label="model cached" /> </span> )} </div> {} <div> <div className="mb-1.5 flex items-center justify-between"> <span className="text-xs font-medium text-muted-foreground">Categories</span> <span className="text-xs text-muted-foreground"> {labels.length} {labels.length === 1 ? 'label' : 'labels'} </span> </div> <div> <EditableLabelSet labels={labels} onAdd={addLabel} onRemove={removeLabel} placeholder="Add a category…" /> </div> {labels.length === 0 && ( <p className="mt-1 text-xs text-amber-600 dark:text-amber-500"> Add at least one category to classify. </p> )} </div> {} <div> <textarea aria-label="Email or message to route" value={input} onChange={(e) => setInput(e.target.value)} placeholder="Paste an email or message to route…" rows={5} className="w-full resize-y rounded-md border border-border bg-background p-2.5 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" /> <div className="mt-1 flex justify-end"> <button type="button" onClick={loadSample} className="inline-flex h-7 items-center gap-1 rounded-md border border-border px-2 text-xs font-medium hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" > <Sparkles className="h-3 w-3" aria-hidden /> Load sample </button> </div> </div> {} <div className="flex items-center gap-2"> <button type="button" onClick={() => void run()} disabled={!canRun || isLoading} className="inline-flex h-8 items-center gap-1.5 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-2 focus-visible:ring-ring disabled:opacity-50" > <Play className="h-3.5 w-3.5" aria-hidden /> Classify </button> {isLoading && ( <button type="button" onClick={cancel} className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-3 text-sm font-medium hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" > <Square className="h-3.5 w-3.5" aria-hidden /> Stop </button> )} </div> {} {load.status === 'loading' && ( <div> <ModelLoadingPanel name={ZEROSHOT_MODEL_NAME} size={ZEROSHOT_MODEL_SIZE} progress={load.progressValue} cached={load.cached === true} /> </div> )} {appErr && ( <span> <ErrorAlert message={appErr.message} onRetry={() => void run()} onDismiss={reset} /> </span> )} {} {(isLoading || ranked.length > 0) && ( <div className="flex flex-col gap-3"> {top && ( <div role="status" aria-label="Top routing result" data-label={top.label} data-score={top.score.toFixed(4)} > <TopResultCard label={top.label} score={top.score} title="Routed to" /> </div> )} <div> <ScoredResultBarList results={ranked} isLoading={isLoading && ranked.length === 0} /> </div> </div> )} </div> );}Model Evaluator
Measure how accurate a text classifier is on a labeled set, with accuracy, precision, recall, F1, a confusion matrix, and JSON export.
Install this block
npx shadcn@latest add @localmode/ui/blocks/text-insights/model-evaluatorModel Evaluator: classifier evaluation over labeled datasets. Model loads only behind an explicit action.
Model
Dataset
'use client';/** * @file model-evaluator.tsx * @description Model Evaluator block — classifier evaluation over labeled datasets: radio model/dataset selectors, `useEvaluateModel` with completed/total progress + cancel, accuracy + macro P/R/F1, run duration, a color-coded confusion matrix, and JSON export. Model download gated behind Run. */import { useState } from 'react';import { Download, Play, Square, Trash2 } from 'lucide-react';import { useEvaluateModel, useModelLoad, toAppError } from '@localmode/react';import { classify, accuracy, precision, recall, f1Score, confusionMatrix, type ClassificationModel, type ConfusionMatrix,} from '@localmode/core';import { transformers, isModelCached } from '@localmode/transformers';import { EvaluationMetricsDashboard } from '@/components/evaluation-metrics-dashboard';import { ModelLoadingPanel } from '@/components/model-loading-panel';import { CacheBadge } from '@/components/cache-badge';import { ErrorAlert } from '@/components/error-alert';import { ModeErrorBoundary } from '@/components/mode-error-boundary';import { cn } from '@/lib/utils';interface ModelOption { id: string; name: string; description: string; size: string;}const CLASSIFIER_MODELS: ModelOption[] = [ { id: 'Xenova/distilbert-base-uncased-finetuned-sst-2-english', name: 'DistilBERT Sentiment', description: 'Fine-tuned for binary sentiment classification (POSITIVE / NEGATIVE)', size: '67 MB', }, { id: 'Xenova/mobilebert-uncased-mnli', name: 'MobileBERT Zero-Shot', description: 'Zero-shot classification via natural language inference', size: '27 MB', },];interface DatasetEntry { input: string; expected: string;}interface SampleDataset { id: string; name: string; description: string; entries: DatasetEntry[];}const SAMPLE_DATASETS: SampleDataset[] = [ { id: 'sentiment', name: 'Sentiment Analysis', description: 'Product reviews labeled as POSITIVE or NEGATIVE', entries: [ { input: 'This product is amazing! Best purchase I ever made.', expected: 'POSITIVE' }, { input: 'Terrible quality. Broke after one day of use.', expected: 'NEGATIVE' }, { input: 'I love how easy this is to set up. Highly recommend!', expected: 'POSITIVE' }, { input: 'Waste of money. Very disappointed with this item.', expected: 'NEGATIVE' }, { input: 'Great value for the price. Works perfectly.', expected: 'POSITIVE' }, { input: 'Awful customer service and the product is defective.', expected: 'NEGATIVE' }, { input: 'Exceeded my expectations. Beautiful design and build.', expected: 'POSITIVE' }, { input: 'Cheap materials, poor construction. Do not buy.', expected: 'NEGATIVE' }, { input: 'Fast shipping and excellent packaging. Very happy!', expected: 'POSITIVE' }, { input: 'The worst purchase I have ever made. Total scam.', expected: 'NEGATIVE' }, { input: 'Absolutely fantastic! My whole family loves it.', expected: 'POSITIVE' }, { input: 'Returned immediately. Nothing like the description.', expected: 'NEGATIVE' }, { input: 'Perfect gift idea. Arrived on time and looks great.', expected: 'POSITIVE' }, { input: 'Flimsy and cheaply made. Falls apart easily.', expected: 'NEGATIVE' }, { input: 'Outstanding performance. Best in its class.', expected: 'POSITIVE' }, { input: 'Overpriced for what you get. Not worth it.', expected: 'NEGATIVE' }, { input: 'So glad I bought this. Life-changing product!', expected: 'POSITIVE' }, { input: 'Stopped working after a week. No refund offered.', expected: 'NEGATIVE' }, { input: 'Sleek design and works as advertised. Five stars.', expected: 'POSITIVE' }, { input: 'Unbelievably bad. Save your money and avoid this.', expected: 'NEGATIVE' }, { input: 'My favorite purchase this year. Truly impressed.', expected: 'POSITIVE' }, { input: 'Misleading product images. Very poor quality.', expected: 'NEGATIVE' }, { input: 'Incredible sound quality for the price. Love it!', expected: 'POSITIVE' }, { input: 'Complete garbage. Threw it away after one use.', expected: 'NEGATIVE' }, ], }, { id: 'topic', name: 'News Topic Classification', description: 'News headlines labeled by category', entries: [ { input: 'Stocks rally as Fed signals rate cuts ahead', expected: 'business' }, { input: 'Lakers defeat Celtics in overtime thriller', expected: 'sports' }, { input: 'New AI chip promises 10x faster inference', expected: 'technology' }, { input: 'Senate passes bipartisan infrastructure bill', expected: 'politics' }, { input: 'Tesla reports record quarterly earnings', expected: 'business' }, { input: 'World Cup final draws 1 billion viewers', expected: 'sports' }, { input: 'Apple unveils next-generation MacBook Pro', expected: 'technology' }, { input: 'President signs executive order on climate', expected: 'politics' }, { input: 'Inflation falls to lowest level in two years', expected: 'business' }, { input: 'Olympic swimmer breaks three world records', expected: 'sports' }, { input: 'Google launches open-source language model', expected: 'technology' }, { input: 'Election results spark debate over voting laws', expected: 'politics' }, { input: 'Amazon acquires streaming platform for $5B', expected: 'business' }, { input: 'Champions League draw reveals exciting matchups', expected: 'sports' }, { input: 'Quantum computer solves protein folding puzzle', expected: 'technology' }, { input: 'Supreme Court rules on digital privacy case', expected: 'politics' }, { input: 'Startup raises $200M in Series C funding round', expected: 'business' }, { input: 'Tennis star announces retirement after 20 seasons', expected: 'sports' }, { input: 'SpaceX successfully lands reusable rocket booster', expected: 'technology' }, { input: 'Governor proposes sweeping education reform plan', expected: 'politics' }, ], },];function formatScore(score: number) { return `${(score * 100).toFixed(1)}%`;}function formatDuration(ms: number) { if (ms < 1000) return `${Math.round(ms)}ms`; return `${(ms / 1000).toFixed(1)}s`;}interface RichOption { id: string; name: string; description: string; meta?: string;}interface OptionCardListProps { options: RichOption[]; selectedId: string; onSelect: (id: string) => void; label: string; disabled?: boolean;}function OptionCardList({ options, selectedId, onSelect, label, disabled,}: OptionCardListProps) { return ( <div role="radiogroup" aria-label={label} className="flex flex-col gap-2"> {options.map((option) => { const active = option.id === selectedId; return ( <button key={option.id} type="button" role="radio" aria-checked={active} disabled={disabled} data-active={active} onClick={() => onSelect(option.id)} className={cn( 'flex items-start gap-3 rounded-lg border p-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-60', active ? 'border-primary bg-primary/5' : 'border-border hover:bg-muted', )} > <span className={cn( 'mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full border', active ? 'border-primary' : 'border-muted-foreground/50', )} aria-hidden > {active && <span className="h-2 w-2 rounded-full bg-primary" />} </span> <span className="min-w-0 flex-1"> <span className="flex flex-wrap items-center gap-2"> <span className="text-sm font-medium text-foreground">{option.name}</span> {option.meta && ( <span className="rounded-full bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground"> {option.meta} </span> )} </span> <span className="mt-0.5 block text-xs text-muted-foreground">{option.description}</span> </span> </button> ); })} </div> );}interface EvalResults { accuracy: number; precision: number; recall: number; f1: number; predictions: string[]; expected: string[]; matrix: ConfusionMatrix; datasetSize: number; durationMs: number; modelId: string; datasetName: string;}function downloadJson(data: object, filename: string) { const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url);}const MODEL_OPTIONS: RichOption[] = CLASSIFIER_MODELS.map((m) => ({ id: m.id, name: m.name, description: m.description, meta: m.size,}));const DATASET_OPTIONS: RichOption[] = SAMPLE_DATASETS.map((d) => ({ id: d.id, name: d.name, description: d.description, meta: `${d.entries.length} items`,}));export function ModelEvaluatorBlock() { return ( <div className="mx-auto flex max-w-4xl flex-col gap-4 p-4"> <p className="text-xs text-muted-foreground"> Model Evaluator: classifier evaluation over labeled datasets. Model loads only behind an explicit action. </p> <ModeErrorBoundary> <EvaluateInner /> </ModeErrorBoundary> </div> );}function EvaluateInner() { const [modelId, setModelId] = useState(CLASSIFIER_MODELS[0].id); const [datasetId, setDatasetId] = useState(SAMPLE_DATASETS[0].id); const [results, setResults] = useState<EvalResults | null>(null); const [progress, setProgress] = useState<{ completed: number; total: number } | null>(null); const { isLoading, error, execute, cancel, reset } = useEvaluateModel<string, string>(); const load = useModelLoad<ClassificationModel>({ key: `text-insights-eval:${modelId}`, create: (onProgress) => transformers.classifier(modelId, { onProgress: (p) => onProgress(p as Parameters<typeof onProgress>[0]), }), warmup: (model) => classify({ model, text: 'ready' }), isCached: () => isModelCached(modelId), }); const appErr = toAppError(error) ?? (load.error ? toAppError(load.error) : null); const modelMeta = CLASSIFIER_MODELS.find((m) => m.id === modelId); const resetResults = () => { setResults(null); setProgress(null); reset(); }; const selectModel = (id: string) => { setModelId(id); resetResults(); }; const selectDataset = (id: string) => { setDatasetId(id); resetResults(); }; const run = async () => { const dataset = SAMPLE_DATASETS.find((d) => d.id === datasetId); if (!dataset || isLoading) return; setResults(null); setProgress({ completed: 0, total: dataset.entries.length }); try { await load.load(); } catch { setProgress(null); return; } const model = load.model; if (!model) { setProgress(null); return; } const inputs = dataset.entries.map((e) => e.input); const expected = dataset.entries.map((e) => e.expected); const evalResult = await execute({ dataset: { inputs, expected }, predict: async (text: string, signal: AbortSignal) => { const r = await classify({ model, text, abortSignal: signal }); return r.label; }, metric: accuracy, onProgress: (completed: number, total: number) => setProgress({ completed, total }), }); if (evalResult) { const preds = evalResult.predictions; setResults({ accuracy: evalResult.score, precision: precision(preds, expected), recall: recall(preds, expected), f1: f1Score(preds, expected), predictions: preds, expected, matrix: confusionMatrix(preds, expected), datasetSize: evalResult.datasetSize, durationMs: evalResult.durationMs, modelId, datasetName: dataset.name, }); } setProgress(null); }; const exportJson = () => { if (!results) return; downloadJson( { modelId: results.modelId, datasetName: results.datasetName, datasetSize: results.datasetSize, durationMs: results.durationMs, metrics: { accuracy: results.accuracy, precision: results.precision, recall: results.recall, f1: results.f1, }, predictions: results.predictions, expected: results.expected, }, 'evaluation-results.json', ); }; return ( <div className="flex flex-col gap-4"> <div className="grid gap-4 sm:grid-cols-2"> <div> <p className="mb-1.5 text-xs font-medium text-muted-foreground">Model</p> <OptionCardList options={MODEL_OPTIONS} selectedId={modelId} onSelect={selectModel} label="Classifier model" disabled={isLoading} /> </div> <div> <p className="mb-1.5 text-xs font-medium text-muted-foreground">Dataset</p> <OptionCardList options={DATASET_OPTIONS} selectedId={datasetId} onSelect={selectDataset} label="Labeled dataset" disabled={isLoading} /> </div> </div> <div className="flex flex-wrap items-center gap-2"> <button type="button" onClick={() => void run()} disabled={isLoading} className="inline-flex h-8 items-center gap-1.5 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-2 focus-visible:ring-ring disabled:opacity-50" > <Play className="h-3.5 w-3.5" aria-hidden /> Evaluate </button> {isLoading && ( <button type="button" onClick={cancel} className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-3 text-sm font-medium hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" > <Square className="h-3.5 w-3.5" aria-hidden /> Stop </button> )} {results && ( <> <button type="button" onClick={exportJson} className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-3 text-sm font-medium hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" > <Download className="h-3.5 w-3.5" aria-hidden /> Export JSON </button> <button type="button" onClick={resetResults} className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-3 text-sm font-medium hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" > <Trash2 className="h-3.5 w-3.5" aria-hidden /> Clear </button> </> )} {load.cached === true && ( <span> <CacheBadge cached label="model cached" /> </span> )} </div> {load.status === 'loading' && ( <div> <ModelLoadingPanel name={modelMeta?.name ?? modelId} size={modelMeta?.size} progress={load.progressValue} cached={load.cached === true} /> </div> )} {appErr && ( <span> <ErrorAlert message={appErr.message} onRetry={() => void run()} onDismiss={reset} /> </span> )} {progress && ( <div className="flex flex-col gap-1.5 rounded-md border border-border p-3"> <span data-completed={progress.completed} data-total={progress.total} className="text-xs text-muted-foreground" > {progress.completed} / {progress.total} evaluated </span> <div className="h-1.5 w-full overflow-hidden rounded-full bg-muted"> <div className="h-full rounded-full bg-primary transition-all" style={{ width: `${progress.total > 0 ? (progress.completed / progress.total) * 100 : 0}%` }} /> </div> </div> )} {results && ( <div className="flex flex-col gap-3"> {} <span role="status" aria-label="Evaluation metrics" data-accuracy={results.accuracy.toFixed(6)} data-precision={results.precision.toFixed(6)} data-recall={results.recall.toFixed(6)} data-f1={results.f1.toFixed(6)} className="sr-only" /> <span role="status" aria-label="Confusion matrix data" data-labels={JSON.stringify(results.matrix.labels)} data-matrix={JSON.stringify(results.matrix.matrix)} className="sr-only" /> <p className="text-xs text-muted-foreground"> Ran in {formatDuration(results.durationMs)} · {results.datasetSize} items ·{' '} {formatScore(results.accuracy)} accuracy </p> <EvaluationMetricsDashboard stats={[ { label: 'Dataset size', value: results.datasetSize }, { label: 'Duration', value: formatDuration(results.durationMs) }, ]} metrics={[ { label: 'Accuracy', value: results.accuracy }, { label: 'Precision', value: results.precision }, { label: 'Recall', value: results.recall }, { label: 'F1', value: results.f1 }, ]} confusionMatrix={{ labels: results.matrix.labels, matrix: results.matrix.matrix }} /> </div> )} </div> );}Threshold Calibrator
Pick a good similarity cutoff straight from your own examples, and compare it against the built-in default with a view of the score distribution.
Install this block
npx shadcn@latest add @localmode/ui/blocks/text-insights/threshold-calibratorThreshold Calibrator: similarity-threshold calibration from corpus embeddings. Model loads only behind an explicit action.
Embedding model
Corpus
Preparing…
'use client';/** * @file threshold-calibrator.tsx * @description Threshold Calibrator block — similarity-threshold calibration from corpus embeddings via `useCalibrateThreshold` (percentile 90) rendered through threshold-calibration-panel: calibrated threshold vs the model's `getDefaultThreshold` preset, distribution stats, and the `MODEL_THRESHOLD_PRESETS` reference; model download gated behind Calibrate. */import { useState } from 'react';import { Play, Square } from 'lucide-react';import { useCalibrateThreshold, useModelLoad, toAppError, type UseModelLoadReturn,} from '@localmode/react';import { embed, getDefaultThreshold, MODEL_THRESHOLD_PRESETS, type EmbeddingModel,} from '@localmode/core';import { transformers, isModelCached } from '@localmode/transformers';import { ThresholdCalibrationPanel } from '@/components/threshold-calibration-panel';import { ModelLoadingPanel } from '@/components/model-loading-panel';import { CacheBadge } from '@/components/cache-badge';import { ErrorAlert } from '@/components/error-alert';import { ModeErrorBoundary } from '@/components/mode-error-boundary';import { cn } from '@/lib/utils';const DEFAULT_PERCENTILE = 90;interface ModelOption { id: string; name: string; description: string; size: string;}const EMBEDDING_MODELS: ModelOption[] = [ { id: 'Xenova/bge-small-en-v1.5', name: 'BGE Small', description: 'Compact English embedding model, 384 dimensions', size: '33 MB', }, { id: 'Xenova/all-MiniLM-L6-v2', name: 'MiniLM L6 v2', description: 'Fast general-purpose embeddings, 384 dimensions', size: '23 MB', },];interface SampleCorpus { id: string; name: string; description: string; texts: string[];}const SAMPLE_CORPORA: SampleCorpus[] = [ { id: 'general', name: 'General Knowledge', description: 'Diverse sentences covering common topics', texts: [ 'The Eiffel Tower is located in Paris, France.', 'Water boils at 100 degrees Celsius at sea level.', 'The human heart beats about 100,000 times per day.', 'Python is a popular programming language for data science.', 'The Great Wall of China is visible from space.', 'Photosynthesis converts sunlight into chemical energy.', 'Shakespeare wrote Romeo and Juliet in the 16th century.', 'The speed of light is approximately 300,000 km per second.', 'DNA carries the genetic instructions for all living organisms.', 'The Amazon rainforest produces 20% of the world oxygen.', 'Mount Everest is the tallest mountain above sea level.', 'The internet was originally developed for military communication.', 'Elephants are the largest land animals on Earth.', 'The Pacific Ocean is the largest and deepest ocean.', 'Coffee is the second most traded commodity after oil.', 'The human brain contains approximately 86 billion neurons.', 'Mars is known as the Red Planet due to iron oxide.', 'Classical music can improve concentration and focus.', 'The stock market operates on supply and demand principles.', 'Antibiotics cannot treat viral infections like the common cold.', ], }, { id: 'technical', name: 'Technical Documentation', description: 'Software engineering and ML terminology', texts: [ 'Neural networks consist of interconnected layers of nodes.', 'RESTful APIs use HTTP methods for CRUD operations.', 'Gradient descent optimizes model parameters iteratively.', 'Docker containers package applications with their dependencies.', 'Transformers use self-attention mechanisms for sequence modeling.', 'Kubernetes orchestrates containerized applications at scale.', 'Convolutional neural networks excel at image recognition tasks.', 'GraphQL provides a flexible query language for APIs.', 'Reinforcement learning agents learn through trial and error.', 'Microservices architecture splits applications into small services.', 'BERT uses bidirectional context for language understanding.', 'CI/CD pipelines automate software testing and deployment.', 'Embeddings represent words as dense vectors in high-dimensional space.', 'Load balancers distribute incoming traffic across multiple servers.', 'Attention mechanisms allow models to focus on relevant inputs.', 'Version control systems track changes in source code over time.', 'Fine-tuning adapts pretrained models to specific downstream tasks.', 'WebAssembly enables near-native performance in web browsers.', 'Batch normalization stabilizes and accelerates neural network training.', 'Event-driven architectures process data as streams of events.', ], },];interface RichOption { id: string; name: string; description: string; meta?: string;}interface OptionCardListProps { options: RichOption[]; selectedId: string; onSelect: (id: string) => void; label: string; disabled?: boolean;}function OptionCardList({ options, selectedId, onSelect, label, disabled,}: OptionCardListProps) { return ( <div role="radiogroup" aria-label={label} className="flex flex-col gap-2"> {options.map((option) => { const active = option.id === selectedId; return ( <button key={option.id} type="button" role="radio" aria-checked={active} disabled={disabled} data-active={active} onClick={() => onSelect(option.id)} className={cn( 'flex items-start gap-3 rounded-lg border p-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-60', active ? 'border-primary bg-primary/5' : 'border-border hover:bg-muted', )} > <span className={cn( 'mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full border', active ? 'border-primary' : 'border-muted-foreground/50', )} aria-hidden > {active && <span className="h-2 w-2 rounded-full bg-primary" />} </span> <span className="min-w-0 flex-1"> <span className="flex flex-wrap items-center gap-2"> <span className="text-sm font-medium text-foreground">{option.name}</span> {option.meta && ( <span className="rounded-full bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground"> {option.meta} </span> )} </span> <span className="mt-0.5 block text-xs text-muted-foreground">{option.description}</span> </span> </button> ); })} </div> );}const PRESETS = Object.entries(MODEL_THRESHOLD_PRESETS).map(([modelId, threshold]) => ({ modelId, threshold,}));const MODEL_OPTIONS: RichOption[] = EMBEDDING_MODELS.map((m) => ({ id: m.id, name: m.name, description: m.description, meta: m.size,}));const CORPUS_OPTIONS: RichOption[] = SAMPLE_CORPORA.map((c) => ({ id: c.id, name: c.name, description: c.description, meta: `${c.texts.length} texts`,}));export function ThresholdCalibratorBlock() { return ( <div className="mx-auto flex max-w-4xl flex-col gap-4 p-4"> <p className="text-xs text-muted-foreground"> Threshold Calibrator: similarity-threshold calibration from corpus embeddings. Model loads only behind an explicit action. </p> <ModeErrorBoundary> <CalibrateInner /> </ModeErrorBoundary> </div> );}function CalibrateInner() { const [modelId, setModelId] = useState(EMBEDDING_MODELS[0].id); const [corpusId, setCorpusId] = useState(SAMPLE_CORPORA[0].id); const corpus = SAMPLE_CORPORA.find((c) => c.id === corpusId) ?? SAMPLE_CORPORA[0]; const modelMeta = EMBEDDING_MODELS.find((m) => m.id === modelId); return ( <div className="flex flex-col gap-4"> <div className="grid gap-4 sm:grid-cols-2"> <div> <p className="mb-1.5 text-xs font-medium text-muted-foreground">Embedding model</p> <OptionCardList options={MODEL_OPTIONS} selectedId={modelId} onSelect={setModelId} label="Embedding model" /> </div> <div> <p className="mb-1.5 text-xs font-medium text-muted-foreground">Corpus</p> <OptionCardList options={CORPUS_OPTIONS} selectedId={corpusId} onSelect={setCorpusId} label="Calibration corpus" /> </div> </div> {} <CalibrateRun key={modelId} modelId={modelId} corpus={corpus} modelName={modelMeta?.name} modelSize={modelMeta?.size} /> </div> );}function CalibrateRun({ modelId, corpus, modelName, modelSize,}: { modelId: string; corpus: SampleCorpus; modelName?: string; modelSize?: string;}) { const load = useModelLoad<EmbeddingModel>({ key: `text-insights-embed:${modelId}`, create: (onProgress) => transformers.embedding(modelId, { onProgress: (p) => onProgress(p as Parameters<typeof onProgress>[0]), }), warmup: (model) => embed({ model, value: 'ready' }), isCached: () => isModelCached(modelId), }); const model = load.model; if (!model) return <p className="text-sm text-muted-foreground">Preparing…</p>; return ( <CalibrateSurface load={load} model={model} modelId={modelId} corpus={corpus} modelName={modelName} modelSize={modelSize} /> );}function CalibrateSurface({ load, model, modelId, corpus, modelName, modelSize,}: { load: UseModelLoadReturn<EmbeddingModel>; model: EmbeddingModel; modelId: string; corpus: SampleCorpus; modelName?: string; modelSize?: string;}) { const { calibration, isCalibrating, error, calibrate, cancel, clearError } = useCalibrateThreshold({ model, percentile: DEFAULT_PERCENTILE, }); const [ran, setRan] = useState(false); const appErr = toAppError(error) ?? (load.error ? toAppError(load.error) : null); const presetThreshold = getDefaultThreshold(modelId); const panelCalibration = calibration ? { ...calibration, modelId: calibration.modelId.replace(/^transformers:/, '') } : null; const run = async () => { if (isCalibrating) return; setRan(true); try { await load.load(); } catch { return; } await calibrate(corpus.texts); }; return ( <> <div className="flex flex-wrap items-center gap-2"> <button type="button" onClick={() => void run()} disabled={isCalibrating} className="inline-flex h-8 items-center gap-1.5 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-2 focus-visible:ring-ring disabled:opacity-50" > <Play className="h-3.5 w-3.5" aria-hidden /> Calibrate </button> {isCalibrating && ( <button type="button" onClick={cancel} className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-3 text-sm font-medium hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" > <Square className="h-3.5 w-3.5" aria-hidden /> Stop </button> )} {load.cached === true && ( <span> <CacheBadge cached label="model cached" /> </span> )} </div> {load.status === 'loading' && ( <div> <ModelLoadingPanel name={modelName ?? modelId} size={modelSize} progress={load.progressValue} cached={load.cached === true} /> </div> )} {appErr && ( <span> <ErrorAlert message={appErr.message} onRetry={() => void run()} onDismiss={clearError} /> </span> )} {} {(ran || calibration) && ( <div> <ThresholdCalibrationPanel calibration={panelCalibration} presetThreshold={presetThreshold} presets={PRESETS} isCalibrating={isCalibrating} onCalibrate={() => void run()} onCancel={cancel} /> </div> )} </> );}