| import { motion } from "framer-motion"; |
| import { |
| ChevronDown, |
| ChevronUp, |
| AlertTriangle, |
| CheckCircle2, |
| Clock, |
| Cpu, |
| Beaker, |
| Sparkle, |
| } from "lucide-react"; |
| import { useState } from "react"; |
| import type { ModelResult } from "@/types/inference"; |
| import { |
| classifyStatus, |
| cn, |
| confidenceColor, |
| formatMs, |
| formatPercent, |
| formatParams, |
| } from "@/lib/utils"; |
| import ConfidenceGauge from "./ConfidenceGauge"; |
|
|
| interface Props { |
| result: ModelResult; |
| } |
|
|
| const PAPER_IDS = new Set([ |
| "nes2net", |
| "sonar", |
| "bicrossmamba_st", |
| "voiceradar", |
| "holi_antispoof", |
| ]); |
|
|
| export default function ModelCard({ result }: Props) { |
| const [open, setOpen] = useState(false); |
| const isFake = result.prediction === "fake"; |
| const status = classifyStatus(result.status); |
| const confColor = confidenceColor(result.confidence, result.prediction); |
| const isPaper = PAPER_IDS.has(result.model_id); |
|
|
| const ribbon = isPaper ? "bg-warn" : "bg-cyber"; |
| const familyLabel = isPaper ? "paper architecture" : "production"; |
| const FamilyIcon = isPaper ? Beaker : Sparkle; |
|
|
| return ( |
| <motion.div |
| layout |
| initial={{ opacity: 0, y: 8 }} |
| animate={{ opacity: 1, y: 0 }} |
| transition={{ duration: 0.3 }} |
| className={cn( |
| "panel relative flex h-full flex-col overflow-hidden p-4 pl-5", |
| isFake ? "shadow-danger" : "shadow-cyber" |
| )} |
| > |
| {/* Family ribbon along the left edge */} |
| <div className={cn("absolute left-0 top-0 h-full w-1.5", ribbon, "opacity-70")} /> |
| |
| {/* Soft accent glow */} |
| <div |
| className={cn( |
| "pointer-events-none absolute -right-16 -top-16 h-36 w-36 rounded-full blur-3xl", |
| isFake ? "bg-danger/20" : "bg-cyber/15" |
| )} |
| /> |
| |
| {/* ββ Header ββββββββββββββββββββββββββββββββββββββββββββββββββ */} |
| <div className="flex items-start justify-between gap-2"> |
| <div className="min-w-0 flex-1"> |
| <span className="font-display text-base font-semibold text-ink truncate block"> |
| {result.display_name} |
| </span> |
| <div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] text-ink-dim"> |
| <span |
| className={cn( |
| "inline-flex items-center gap-1 font-mono uppercase", |
| isPaper ? "text-warn" : "text-cyber" |
| )} |
| > |
| <FamilyIcon className="h-3 w-3" /> {familyLabel} |
| </span> |
| <span className="text-ink-mute">Β·</span> |
| <span className="truncate">{result.paradigm}</span> |
| </div> |
| </div> |
| <div |
| className={cn( |
| "shrink-0 rounded-md border px-2 py-1 font-mono text-xs font-bold", |
| isFake |
| ? "border-danger/40 bg-danger/10 text-danger" |
| : "border-cyber/40 bg-cyber/10 text-cyber" |
| )} |
| > |
| {result.prediction.toUpperCase()} |
| </div> |
| </div> |
| |
| {/* ββ Status pill row βββββββββββββββββββββββββββββββββββββββββ */} |
| <div className="mt-2 flex flex-wrap items-center gap-1.5"> |
| <span |
| className={cn( |
| "inline-flex items-center gap-1 rounded-full px-2 py-0.5 font-mono text-[10px] uppercase tracking-wider", |
| status === "live" && "bg-cyber/15 text-cyber", |
| status === "fallback" && "bg-warn/15 text-warn", |
| status === "error" && "bg-danger/15 text-danger", |
| status === "loading" && "bg-surface-alt text-ink-dim" |
| )} |
| title={result.status} |
| > |
| {status === "live" && <CheckCircle2 className="h-2.5 w-2.5" />} |
| {status === "fallback" && <AlertTriangle className="h-2.5 w-2.5" />} |
| {status} |
| </span> |
| <span className="inline-flex items-center gap-1 rounded-full border border-line bg-surface-alt/60 px-2 py-0.5 font-mono text-[10px] text-ink-dim"> |
| <Clock className="h-2.5 w-2.5" /> {formatMs(result.inference_time_ms)} |
| </span> |
| <span className="inline-flex items-center gap-1 rounded-full border border-line bg-surface-alt/60 px-2 py-0.5 font-mono text-[10px] text-ink-dim"> |
| <Cpu className="h-2.5 w-2.5" /> {formatParams(result.params_k)} |
| </span> |
| </div> |
| |
| {/* ββ Gauge + numbers ββββββββββββββββββββββββββββββββββββββββ */} |
| <div className="mt-3 grid grid-cols-[auto,1fr] gap-4"> |
| <ConfidenceGauge value={result.confidence} prediction={result.prediction} /> |
| <div className="flex flex-col justify-center gap-1 font-mono text-[11px]"> |
| <div className="flex items-baseline gap-2"> |
| <span className="text-ink-mute">confidence</span> |
| <span className={cn("text-base font-bold", confColor)}> |
| {formatPercent(result.confidence)} |
| </span> |
| </div> |
| {result.eer_reference?.df21 != null && ( |
| <div className="text-ink-dim"> |
| DF21 EER:{" "} |
| <span className="text-ink">{result.eer_reference.df21}%</span> |
| </div> |
| )} |
| {result.eer_reference?.in_the_wild != null && ( |
| <div className="text-ink-dim"> |
| ITW EER:{" "} |
| <span className="text-ink"> |
| {result.eer_reference.in_the_wild}% |
| </span> |
| </div> |
| )} |
| {result.eer_reference?.la21 != null && ( |
| <div className="text-ink-dim"> |
| LA21 EER:{" "} |
| <span className="text-ink">{result.eer_reference.la21}%</span> |
| </div> |
| )} |
| </div> |
| </div> |
| |
| {/* Spacer pushes the details block to the bottom for visual alignment */} |
| <div className="flex-1" /> |
| |
| {/* ββ Status detail strip ββββββββββββββββββββββββββββββββββββ */} |
| {status === "fallback" && ( |
| <div className="mt-3 flex items-start gap-2 rounded-md border border-warn/30 bg-warn/5 p-2 text-[11px] text-warn"> |
| <AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" /> |
| <span> |
| No checkpoint β routed to{" "} |
| <span className="font-bold">{result.fallback_to ?? "fallback"}</span>. |
| Drop weights in{" "} |
| <code className="font-mono">backend/checkpoints/</code> to go live. |
| </span> |
| </div> |
| )} |
| |
| {/* ββ Why? expander βββββββββββββββββββββββββββββββββββββββββ */} |
| <button |
| onClick={() => setOpen((v) => !v)} |
| className="mt-3 inline-flex items-center gap-1 self-start font-mono text-[11px] text-ink-dim transition hover:text-cyber" |
| > |
| {open ? "hide details" : "details"} |
| {open ? ( |
| <ChevronUp className="h-3 w-3" /> |
| ) : ( |
| <ChevronDown className="h-3 w-3" /> |
| )} |
| </button> |
| |
| {open && ( |
| <motion.div |
| initial={{ opacity: 0, height: 0 }} |
| animate={{ opacity: 1, height: "auto" }} |
| className="mt-2 overflow-hidden border-t border-line pt-2 text-[11px] leading-relaxed text-ink-dim" |
| > |
| {result.notes && ( |
| <p className="mb-2 whitespace-normal">{result.notes}</p> |
| )} |
| <FeatureGrid features={result.features} /> |
| </motion.div> |
| )} |
| </motion.div> |
| ); |
| } |
|
|
| function FeatureGrid({ features }: { features: Record<string, unknown> }) { |
| const flat = Object.entries(features).filter( |
| ([, v]) => |
| typeof v === "number" || typeof v === "string" || typeof v === "boolean" |
| ); |
| if (!flat.length) return null; |
| return ( |
| <div className="grid grid-cols-2 gap-x-3 gap-y-1 font-mono text-[10px]"> |
| {flat.slice(0, 12).map(([k, v]) => ( |
| <div key={k} className="flex justify-between gap-2 truncate"> |
| <span className="text-ink-mute truncate">{k}</span> |
| <span className="text-ink truncate">{String(v)}</span> |
| </div> |
| ))} |
| </div> |
| ); |
| } |
|
|