# Text Insights

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

## Sentiment Analyzer

Score text as positive or negative, one message at a time or thousands at once. Watch live progress and speed, see the running positive and negative totals, and browse results in a scrollable list. The model loads only when you press Run.

**Install**

```bash
npx shadcn@latest add @localmode/ui/blocks/text-insights/sentiment-analyzer
```

**Full block (all files):** https://localmode.ai/r/ui/blocks/text-insights/sentiment-analyzer.json

```tsx
'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 any message into your own set of labels. Add or remove categories, then see which label wins and how every other label ranked. The model loads only when you press Run.

**Install**

```bash
npx shadcn@latest add @localmode/ui/blocks/text-insights/text-classifier
```

**Full block (all files):** https://localmode.ai/r/ui/blocks/text-insights/text-classifier.json

```tsx
'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. Get accuracy along with precision, recall, and F1, a color-coded confusion matrix, and one-click JSON export of the results. The model loads only when you press Run.

**Install**

```bash
npx shadcn@latest add @localmode/ui/blocks/text-insights/model-evaluator
```

**Full block (all files):** https://localmode.ai/r/ui/blocks/text-insights/model-evaluator.json

```tsx
'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 instead of guessing. See the value it suggests next to the built-in default, along with a view of how your scores are distributed. The model loads only when you press Calibrate.

**Install**

```bash
npx shadcn@latest add @localmode/ui/blocks/text-insights/threshold-calibrator
```

**Full block (all files):** https://localmode.ai/r/ui/blocks/text-insights/threshold-calibrator.json

```tsx
'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>
      )}
    </>
  );
}
```
