import { Suspense, use, useActionState, useState } from "react";
import { compareModels, getModels } from "../api";
import type { CompareItem, ModelSummary } from "../api";
import ConfidenceBars from "./ConfidenceBars";
/**
* Mirrors the backend's DEFAULT_COMPARE_MODELS: only these two are loaded at
* startup. The other registry entries (finbert, xlm-twitter) are loaded
* lazily by the backend on first use, so the UI keeps them off by default
* and warns about the one-time load cost.
*/
const DEFAULT_COMPARE = ["twitter-roberta", "distilbert-sst2"];
const LABEL_BADGE: Record = {
negative: "bg-red-100 text-red-700",
neutral: "bg-slate-100 text-slate-700",
positive: "bg-emerald-100 text-emerald-700",
};
interface CompareState {
rows: CompareItem[];
error: string | null;
}
export default function CompareModels() {
// React 19 data fetching: create the registry promise once (useState
// initializer runs a single time, so its identity is stable) and let a
// child read it with use() under Suspense - no useEffect/setState dance.
const [registryPromise] = useState(() =>
getModels("sentiment")
.then((r) => r.models)
.catch(() => [] as ModelSummary[]),
);
return (
Loading model registry…
}>
);
}
function CompareForm({ registryPromise }: { registryPromise: Promise }) {
const registry = use(registryPromise);
const [text, setText] = useState("Our quarterly revenue outlook improved");
const [selected, setSelected] = useState(DEFAULT_COMPARE);
// The comparison is a classic form action: submit -> pending -> result or
// error. useActionState owns that lifecycle, so there is no hand-rolled
// loading/error useState pair here.
const [result, formAction, isPending] = useActionState(
async () => {
try {
return { rows: (await compareModels(text, selected)).results, error: null };
} catch (e) {
return { rows: [], error: e instanceof Error ? e.message : "Compare failed" };
}
},
{ rows: [], error: null },
);
const toggle = (id: string) => {
setSelected((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
);
};
if (registry.length === 0) {
return Model registry unavailable. Is the backend running?
;
}
return (
{result.error && (
{result.error}
)}
{result.rows.length > 0 && (
// Side-by-side readouts make disagreement the point of the page:
// same sentence, different training data, different verdicts.
{result.rows.map((r) => (
))}
)}
);
}
function ModelReadout({ item }: { item: CompareItem }) {
return (
{item.model_id}
{item.domain}
{item.label}
{/* Dynamic scores: a 2-class model renders exactly its own keys, so a
missing "neutral" bar is honest, not a bug. */}
- confidence
- {(item.confidence * 100).toFixed(1)}%
- latency
- {item.latency_ms.toFixed(0)} ms
{item.note && {item.note}
}
);
}