Spaces:
Runtime error
Runtime error
| "use client" | |
| import { useState, useEffect, useMemo } from "react" | |
| import { useRouter } from "next/navigation" | |
| import { Input } from "@/components/ui/input" | |
| import { Button } from "@/components/ui/button" | |
| import { Card, CardContent } from "@/components/ui/card" | |
| import { Separator } from "@/components/ui/separator" | |
| import { | |
| Search, Zap, BookOpen, BrainCircuit, Cpu, Globe2, Eye, | |
| Cloud, ArrowRight, Lock, Loader2, XCircle, Filter, | |
| Trophy, Users, Clock, BarChart3, | |
| } from "lucide-react" | |
| import { cn } from "@/lib/utils" | |
| import { formatDistanceToNow } from "date-fns" | |
| const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000" | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Types | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| type View = "catalog" | "leaderboard" | |
| type Category = "all" | "encoder" | "seq2seq" | "decoder" | "embedding" | "vision" | "api" | |
| type Provider = "all" | "huggingface" | "openai" | "cohere" | "google" | |
| type QualityTier = "excellent" | "good" | "balanced" | "fast" | |
| export interface CatalogModel { | |
| model_id: string | |
| display_name: string | |
| category: string | |
| provider: string | |
| param_count: string | |
| param_count_m: number | |
| task_types: string[] | |
| quality_tier: QualityTier | |
| inference_speed: string | |
| lora_compatible: boolean | |
| qlora_compatible: boolean | |
| languages: string[] | |
| description: string | |
| best_for: string | |
| tags: string[] | |
| requires_token: boolean | |
| license: string | |
| } | |
| interface LeaderboardEntry { | |
| rank: number | |
| model_id: string | |
| display_name: string | |
| category: string | |
| provider: string | |
| param_count: string | |
| quality_tier: string | |
| lora_compatible: boolean | |
| run_count: number | |
| best_f1: number | null | |
| avg_f1: number | null | |
| avg_accuracy: number | null | |
| task_types: string[] | |
| last_run_at: string | null | |
| } | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Constants | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const CATEGORY_TABS: { key: Category; label: string; icon: React.ElementType }[] = [ | |
| { key: "all", label: "All Models", icon: BookOpen }, | |
| { key: "encoder", label: "Encoder", icon: BrainCircuit }, | |
| { key: "seq2seq", label: "Seq2Seq", icon: Filter }, | |
| { key: "decoder", label: "Decoder/LLM", icon: Cpu }, | |
| { key: "embedding", label: "Embedding", icon: Zap }, | |
| { key: "vision", label: "Vision", icon: Eye }, | |
| { key: "api", label: "Cloud API", icon: Cloud }, | |
| ] | |
| const QUALITY_STYLES: Record<string, string> = { | |
| excellent: "bg-violet-500/10 text-violet-500 border-violet-500/25", | |
| good: "bg-blue-500/10 text-blue-500 border-blue-500/25", | |
| balanced: "bg-teal-500/10 text-teal-500 border-teal-500/25", | |
| fast: "bg-amber-500/10 text-amber-500 border-amber-500/25", | |
| } | |
| const PROVIDER_STYLES: Record<string, string> = { | |
| huggingface: "bg-orange-500/10 text-orange-500 border-orange-500/25", | |
| openai: "bg-green-500/10 text-green-500 border-green-500/25", | |
| cohere: "bg-purple-500/10 text-purple-500 border-purple-500/25", | |
| google: "bg-blue-500/10 text-blue-500 border-blue-500/25", | |
| } | |
| const PROVIDER_LABELS: Record<string, string> = { | |
| huggingface: "HuggingFace", | |
| openai: "OpenAI", | |
| cohere: "Cohere", | |
| google: "Google", | |
| } | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Main component | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| export function ModelsClient() { | |
| const router = useRouter() | |
| // View toggle | |
| const [view, setView] = useState<View>("catalog") | |
| // Catalog state | |
| const [catalog, setCatalog] = useState<CatalogModel[]>([]) | |
| const [loading, setLoading] = useState(true) | |
| const [error, setError] = useState<string | null>(null) | |
| // Catalog filters | |
| const [q, setQ] = useState("") | |
| const [category, setCategory] = useState<Category>("all") | |
| const [provider, setProvider] = useState<Provider>("all") | |
| const [loraOnly, setLoraOnly] = useState(false) | |
| // Leaderboard state | |
| const [leaderboard, setLeaderboard] = useState<LeaderboardEntry[]>([]) | |
| const [lbLoading, setLbLoading] = useState(false) | |
| const [lbError, setLbError] = useState<string | null>(null) | |
| // Fetch catalog once on mount | |
| useEffect(() => { | |
| fetch(`${API_URL}/models`) | |
| .then(r => { | |
| if (!r.ok) throw new Error(`API returned ${r.status}`) | |
| return r.json() as Promise<CatalogModel[]> | |
| }) | |
| .then(data => { setCatalog(data); setLoading(false) }) | |
| .catch(e => { setError(String(e)); setLoading(false) }) | |
| }, []) | |
| // Fetch leaderboard when tab is active; poll every 30s | |
| useEffect(() => { | |
| if (view !== "leaderboard") return | |
| let alive = true | |
| const load = async () => { | |
| if (!alive) return | |
| setLbLoading(prev => leaderboard.length === 0 ? true : prev) | |
| try { | |
| const r = await fetch(`${API_URL}/leaderboard`) | |
| if (!r.ok) throw new Error(`API returned ${r.status}`) | |
| const data = await r.json() as LeaderboardEntry[] | |
| if (alive) { setLeaderboard(data); setLbError(null) } | |
| } catch (e) { | |
| if (alive) setLbError(String(e)) | |
| } finally { | |
| if (alive) setLbLoading(false) | |
| } | |
| } | |
| load() | |
| const iv = setInterval(load, 30_000) | |
| return () => { alive = false; clearInterval(iv) } | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, [view]) | |
| const filtered = useMemo(() => { | |
| let results = catalog | |
| if (category !== "all") results = results.filter(m => m.category === category) | |
| if (provider !== "all") results = results.filter(m => m.provider === provider) | |
| if (loraOnly) results = results.filter(m => m.lora_compatible) | |
| if (q.trim()) { | |
| const ql = q.toLowerCase() | |
| results = results.filter(m => | |
| m.display_name.toLowerCase().includes(ql) || | |
| m.description.toLowerCase().includes(ql) || | |
| m.best_for.toLowerCase().includes(ql) || | |
| m.model_id.toLowerCase().includes(ql) || | |
| m.tags.some(t => t.includes(ql)) | |
| ) | |
| } | |
| return results | |
| }, [catalog, category, provider, loraOnly, q]) | |
| // Build a CatalogModel from leaderboard entry + catalog lookup for trainWithModel | |
| function trainWithLeaderboardEntry(entry: LeaderboardEntry) { | |
| const catalogMatch = catalog.find(m => m.model_id === entry.model_id) | |
| const synthetic: CatalogModel = catalogMatch ?? { | |
| model_id: entry.model_id, | |
| display_name: entry.display_name, | |
| category: entry.category, | |
| provider: entry.provider, | |
| param_count: entry.param_count, | |
| param_count_m: 0, | |
| task_types: entry.task_types, | |
| quality_tier: (entry.quality_tier as QualityTier) || "balanced", | |
| inference_speed: "medium", | |
| lora_compatible: entry.lora_compatible, | |
| qlora_compatible: false, | |
| languages: ["en"], | |
| description: "", | |
| best_for: "", | |
| tags: [], | |
| requires_token: false, | |
| license: "", | |
| } | |
| localStorage.setItem("modelforge_preselect_model", JSON.stringify(synthetic)) | |
| router.push("/train") | |
| } | |
| function trainWithModel(model: CatalogModel) { | |
| localStorage.setItem("modelforge_preselect_model", JSON.stringify(model)) | |
| router.push("/train") | |
| } | |
| // ββ Global catalog loading/error states ββββββββββββββββββββββββββββββββββββ | |
| if (loading) { | |
| return ( | |
| <div className="flex items-center justify-center h-64"> | |
| <Loader2 className="h-7 w-7 animate-spin text-primary" /> | |
| </div> | |
| ) | |
| } | |
| if (error) { | |
| return ( | |
| <div className="flex flex-col items-center justify-center h-64 gap-3 text-center px-4"> | |
| <XCircle className="h-8 w-8 text-destructive" /> | |
| <p className="text-sm text-muted-foreground max-w-xs"> | |
| Could not load catalog. Make sure the backend is running.<br /> | |
| <span className="text-xs font-mono opacity-60">{error}</span> | |
| </p> | |
| </div> | |
| ) | |
| } | |
| return ( | |
| <div className="p-6 max-w-7xl mx-auto space-y-5"> | |
| {/* Header + view toggle */} | |
| <div className="flex items-start justify-between gap-4 flex-wrap"> | |
| <div> | |
| <h1 className="text-2xl font-bold tracking-tight"> | |
| {view === "catalog" ? "Model Catalog" : "Community Leaderboard"} | |
| </h1> | |
| <p className="text-sm text-muted-foreground mt-1"> | |
| {view === "catalog" | |
| ? `${catalog.length} models β encoders, LLMs, embedding, vision, and cloud APIs.` | |
| : "Base models ranked by best F1 across all platform training runs Β· refreshes every 30s"} | |
| </p> | |
| </div> | |
| {/* View toggle pills */} | |
| <div className="flex rounded-lg border border-border overflow-hidden shrink-0"> | |
| <button | |
| onClick={() => setView("catalog")} | |
| className={cn( | |
| "flex items-center gap-1.5 px-4 py-2 text-sm font-medium transition-colors", | |
| view === "catalog" | |
| ? "bg-primary text-primary-foreground" | |
| : "bg-secondary text-muted-foreground hover:text-foreground hover:bg-secondary/70" | |
| )} | |
| > | |
| <BookOpen className="h-3.5 w-3.5" /> | |
| Model Catalog | |
| </button> | |
| <button | |
| onClick={() => setView("leaderboard")} | |
| className={cn( | |
| "flex items-center gap-1.5 px-4 py-2 text-sm font-medium transition-colors border-l border-border", | |
| view === "leaderboard" | |
| ? "bg-primary text-primary-foreground" | |
| : "bg-secondary text-muted-foreground hover:text-foreground hover:bg-secondary/70" | |
| )} | |
| > | |
| <Trophy className="h-3.5 w-3.5" /> | |
| Community Leaderboard | |
| </button> | |
| </div> | |
| </div> | |
| {/* ββ CATALOG VIEW βββββββββββββββββββββββββββββββββββββββββββββββββββββββ */} | |
| {view === "catalog" && ( | |
| <> | |
| {/* Search */} | |
| <div className="relative"> | |
| <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" /> | |
| <Input | |
| placeholder="Search by name, task, or keywordβ¦" | |
| value={q} | |
| onChange={e => setQ(e.target.value)} | |
| className="pl-9" | |
| /> | |
| </div> | |
| {/* Category tabs */} | |
| <div className="flex gap-2 flex-wrap"> | |
| {CATEGORY_TABS.map(tab => { | |
| const Icon = tab.icon | |
| const active = category === tab.key | |
| return ( | |
| <button | |
| key={tab.key} | |
| onClick={() => setCategory(tab.key)} | |
| className={cn( | |
| "inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors", | |
| active | |
| ? "bg-primary text-primary-foreground" | |
| : "bg-secondary text-muted-foreground hover:text-foreground hover:bg-secondary/70" | |
| )} | |
| > | |
| <Icon className="h-3.5 w-3.5" /> | |
| {tab.label} | |
| </button> | |
| ) | |
| })} | |
| </div> | |
| {/* Sub-filters */} | |
| <div className="flex items-center gap-2 flex-wrap"> | |
| <button | |
| onClick={() => setLoraOnly(v => !v)} | |
| className={cn( | |
| "inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors", | |
| loraOnly | |
| ? "border-primary bg-primary/10 text-primary" | |
| : "border-border text-muted-foreground hover:text-foreground hover:bg-secondary/50" | |
| )} | |
| > | |
| <Zap className="h-3.5 w-3.5" /> | |
| LoRA compatible | |
| </button> | |
| <Separator orientation="vertical" className="h-5" /> | |
| {(["all", "huggingface", "openai", "cohere", "google"] as Provider[]).map(p => ( | |
| <button | |
| key={p} | |
| onClick={() => setProvider(p)} | |
| className={cn( | |
| "px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors", | |
| provider === p | |
| ? "border-primary bg-primary/10 text-primary" | |
| : "border-border text-muted-foreground hover:text-foreground hover:bg-secondary/50" | |
| )} | |
| > | |
| {p === "all" ? "All Providers" : PROVIDER_LABELS[p] ?? p} | |
| </button> | |
| ))} | |
| <span className="ml-auto text-xs text-muted-foreground"> | |
| {filtered.length} model{filtered.length !== 1 ? "s" : ""} | |
| </span> | |
| </div> | |
| {/* Model grid */} | |
| {filtered.length === 0 ? ( | |
| <div className="flex flex-col items-center justify-center py-20 gap-3 text-center"> | |
| <BookOpen className="h-10 w-10 text-muted-foreground/40" /> | |
| <p className="text-sm text-muted-foreground">No models match your filters.</p> | |
| <Button variant="outline" size="sm" onClick={() => { setQ(""); setCategory("all"); setProvider("all"); setLoraOnly(false) }}> | |
| Clear filters | |
| </Button> | |
| </div> | |
| ) : ( | |
| <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4"> | |
| {filtered.map(model => ( | |
| <ModelCard key={model.model_id} model={model} onTrain={trainWithModel} /> | |
| ))} | |
| </div> | |
| )} | |
| </> | |
| )} | |
| {/* ββ LEADERBOARD VIEW βββββββββββββββββββββββββββββββββββββββββββββββββββ */} | |
| {view === "leaderboard" && ( | |
| <LeaderboardView | |
| entries={leaderboard} | |
| loading={lbLoading} | |
| error={lbError} | |
| onTrain={trainWithLeaderboardEntry} | |
| /> | |
| )} | |
| </div> | |
| ) | |
| } | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Leaderboard view | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function LeaderboardView({ | |
| entries, | |
| loading, | |
| error, | |
| onTrain, | |
| }: { | |
| entries: LeaderboardEntry[] | |
| loading: boolean | |
| error: string | null | |
| onTrain: (e: LeaderboardEntry) => void | |
| }) { | |
| if (loading && entries.length === 0) { | |
| return ( | |
| <div className="space-y-3"> | |
| {Array.from({ length: 5 }).map((_, i) => ( | |
| <div key={i} className="h-24 rounded-xl bg-secondary/40 animate-pulse" /> | |
| ))} | |
| </div> | |
| ) | |
| } | |
| if (error) { | |
| return ( | |
| <div className="flex flex-col items-center justify-center py-20 gap-3 text-center"> | |
| <XCircle className="h-8 w-8 text-destructive" /> | |
| <p className="text-sm text-muted-foreground max-w-xs"> | |
| Could not load leaderboard. Make sure the backend is running.<br /> | |
| <span className="text-xs font-mono opacity-60">{error}</span> | |
| </p> | |
| </div> | |
| ) | |
| } | |
| if (entries.length === 0) { | |
| return ( | |
| <div className="flex flex-col items-center justify-center py-24 gap-4 text-center"> | |
| <Trophy className="h-12 w-12 text-muted-foreground/30" /> | |
| <div> | |
| <p className="font-semibold text-foreground/80">No completed runs yet</p> | |
| <p className="text-sm text-muted-foreground mt-1"> | |
| Train your first model to claim the #1 spot. | |
| </p> | |
| </div> | |
| <Button variant="outline" size="sm" onClick={() => window.location.href = "/train"}> | |
| Start training | |
| <ArrowRight className="h-3.5 w-3.5 ml-1.5" /> | |
| </Button> | |
| </div> | |
| ) | |
| } | |
| return ( | |
| <div className="space-y-3"> | |
| {/* Column header */} | |
| <div className="grid grid-cols-[3rem_1fr_auto] md:grid-cols-[3rem_1fr_14rem_auto] items-center gap-4 px-4 text-[11px] font-medium text-muted-foreground uppercase tracking-wide"> | |
| <span className="text-center">Rank</span> | |
| <span>Model</span> | |
| <span className="hidden md:block text-right">F1 / Accuracy</span> | |
| <span /> | |
| </div> | |
| {entries.map(entry => ( | |
| <LeaderboardRow key={entry.model_id} entry={entry} onTrain={onTrain} /> | |
| ))} | |
| {/* Staleness hint */} | |
| <p className="text-center text-[11px] text-muted-foreground/50 pt-2"> | |
| <Clock className="inline h-3 w-3 mr-1 opacity-60" /> | |
| Auto-refreshes every 30 seconds | |
| </p> | |
| </div> | |
| ) | |
| } | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Leaderboard row | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const RANK_STYLES: Record<number, { bg: string; text: string; label: string }> = { | |
| 1: { bg: "bg-amber-500/15 border-amber-500/40", text: "text-amber-400 font-bold", label: "#1" }, | |
| 2: { bg: "bg-slate-400/10 border-slate-400/30", text: "text-slate-300 font-bold", label: "#2" }, | |
| 3: { bg: "bg-orange-700/15 border-orange-700/40", text: "text-orange-500 font-bold", label: "#3" }, | |
| } | |
| function fmtF1(v: number | null): string { | |
| if (v === null) return "β" | |
| return (v * 100).toFixed(1) + "%" | |
| } | |
| function LeaderboardRow({ | |
| entry, | |
| onTrain, | |
| }: { | |
| entry: LeaderboardEntry | |
| onTrain: (e: LeaderboardEntry) => void | |
| }) { | |
| const rank = RANK_STYLES[entry.rank] | |
| const qualStyle = QUALITY_STYLES[entry.quality_tier] ?? "bg-secondary text-muted-foreground border-border" | |
| const provStyle = PROVIDER_STYLES[entry.provider] ?? "bg-secondary text-muted-foreground border-border" | |
| return ( | |
| <Card className={cn( | |
| "hover:border-primary/40 transition-colors", | |
| entry.rank <= 3 ? "border-primary/20" : "" | |
| )}> | |
| <CardContent className="p-4"> | |
| <div className="grid grid-cols-[3rem_1fr_auto] md:grid-cols-[3rem_1fr_14rem_auto] items-center gap-4"> | |
| {/* Rank badge */} | |
| <div className="flex justify-center"> | |
| <span className={cn( | |
| "inline-flex items-center justify-center w-9 h-9 rounded-lg border text-sm", | |
| rank | |
| ? `${rank.bg} ${rank.text}` | |
| : "bg-secondary/60 border-border text-muted-foreground font-medium" | |
| )}> | |
| {rank ? rank.label : `#${entry.rank}`} | |
| </span> | |
| </div> | |
| {/* Model info */} | |
| <div className="min-w-0 space-y-2"> | |
| <div> | |
| <p className="font-semibold text-sm leading-snug">{entry.display_name}</p> | |
| <p className="text-[11px] text-muted-foreground font-mono mt-0.5 truncate">{entry.model_id}</p> | |
| </div> | |
| <div className="flex flex-wrap gap-1.5"> | |
| {entry.quality_tier && ( | |
| <span className={cn("text-[11px] px-2 py-0.5 rounded border font-medium capitalize", qualStyle)}> | |
| {entry.quality_tier} | |
| </span> | |
| )} | |
| {entry.provider && ( | |
| <span className={cn("text-[11px] px-2 py-0.5 rounded border font-medium", provStyle)}> | |
| {PROVIDER_LABELS[entry.provider] ?? entry.provider} | |
| </span> | |
| )} | |
| {entry.param_count && ( | |
| <span className="text-[11px] px-2 py-0.5 rounded border bg-secondary/80 text-muted-foreground font-mono"> | |
| {entry.param_count} | |
| </span> | |
| )} | |
| {entry.lora_compatible && ( | |
| <span className="text-[11px] px-2 py-0.5 rounded border bg-emerald-500/10 text-emerald-600 border-emerald-500/25 flex items-center gap-1"> | |
| <Zap className="h-2.5 w-2.5" /> LoRA | |
| </span> | |
| )} | |
| </div> | |
| <div className="flex flex-wrap items-center gap-2"> | |
| {/* Task type chips */} | |
| <div className="flex flex-wrap gap-1"> | |
| {entry.task_types.slice(0, 3).map(t => ( | |
| <span key={t} className="text-[10px] px-1.5 py-0.5 rounded bg-secondary text-muted-foreground"> | |
| {t.replace(/_/g, " ")} | |
| </span> | |
| ))} | |
| {entry.task_types.length > 3 && ( | |
| <span className="text-[10px] px-1.5 py-0.5 rounded bg-secondary text-muted-foreground"> | |
| +{entry.task_types.length - 3} | |
| </span> | |
| )} | |
| </div> | |
| {/* Run count */} | |
| <span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground"> | |
| <Users className="h-3 w-3" /> | |
| {entry.run_count} run{entry.run_count !== 1 ? "s" : ""} | |
| </span> | |
| {/* Last trained */} | |
| {entry.last_run_at && ( | |
| <span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground"> | |
| <Clock className="h-3 w-3" /> | |
| {formatDistanceToNow(new Date(entry.last_run_at), { addSuffix: true })} | |
| </span> | |
| )} | |
| </div> | |
| </div> | |
| {/* Metrics column (md+) */} | |
| <div className="hidden md:flex flex-col items-end gap-1"> | |
| <div className="flex items-baseline gap-1.5"> | |
| <BarChart3 className="h-3.5 w-3.5 text-emerald-500 shrink-0" /> | |
| <span className="text-xl font-bold text-emerald-400 tabular-nums"> | |
| {fmtF1(entry.best_f1)} | |
| </span> | |
| </div> | |
| <div className="text-[11px] text-muted-foreground space-x-2 text-right"> | |
| <span>avg F1 {fmtF1(entry.avg_f1)}</span> | |
| <span>Β·</span> | |
| <span>acc {entry.avg_accuracy !== null ? (entry.avg_accuracy * 100).toFixed(1) + "%" : "β"}</span> | |
| </div> | |
| </div> | |
| {/* CTA */} | |
| <Button size="sm" className="gap-1.5 shrink-0" onClick={() => onTrain(entry)}> | |
| <span className="hidden sm:inline">Train</span> | |
| <ArrowRight className="h-3.5 w-3.5" /> | |
| </Button> | |
| </div> | |
| {/* Metrics row on mobile */} | |
| <div className="md:hidden mt-3 pt-3 border-t border-border flex items-center gap-4"> | |
| <div className="flex items-baseline gap-1"> | |
| <span className="text-xs text-muted-foreground">Best F1</span> | |
| <span className="text-base font-bold text-emerald-400 tabular-nums ml-1.5"> | |
| {fmtF1(entry.best_f1)} | |
| </span> | |
| </div> | |
| <div className="text-[11px] text-muted-foreground"> | |
| avg {fmtF1(entry.avg_f1)} Β· acc {entry.avg_accuracy !== null ? (entry.avg_accuracy * 100).toFixed(1) + "%" : "β"} | |
| </div> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| ) | |
| } | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Model card (catalog view) | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function ModelCard({ model, onTrain }: { model: CatalogModel; onTrain: (m: CatalogModel) => void }) { | |
| const qualityStyle = QUALITY_STYLES[model.quality_tier] ?? "bg-secondary text-muted-foreground border-border" | |
| const providerStyle = PROVIDER_STYLES[model.provider] ?? "bg-secondary text-muted-foreground border-border" | |
| return ( | |
| <Card className="group flex flex-col hover:border-primary/40 transition-colors"> | |
| <CardContent className="flex flex-col gap-3 p-4 flex-1"> | |
| {/* Name + provider */} | |
| <div className="flex items-start justify-between gap-2"> | |
| <div className="min-w-0"> | |
| <p className="font-semibold text-sm leading-snug truncate">{model.display_name}</p> | |
| <p className="text-[11px] text-muted-foreground font-mono mt-0.5 truncate">{model.model_id}</p> | |
| </div> | |
| <span className={cn("shrink-0 text-[11px] px-2 py-0.5 rounded border font-medium", providerStyle)}> | |
| {PROVIDER_LABELS[model.provider] ?? model.provider} | |
| </span> | |
| </div> | |
| {/* Badge row */} | |
| <div className="flex flex-wrap gap-1.5"> | |
| <span className={cn("text-[11px] px-2 py-0.5 rounded border font-medium capitalize", qualityStyle)}> | |
| {model.quality_tier} | |
| </span> | |
| <span className="text-[11px] px-2 py-0.5 rounded border bg-secondary/80 text-muted-foreground font-mono"> | |
| {model.param_count} | |
| </span> | |
| <span className="text-[11px] px-2 py-0.5 rounded border bg-secondary/80 text-muted-foreground capitalize"> | |
| {model.category} | |
| </span> | |
| {model.lora_compatible && ( | |
| <span className="text-[11px] px-2 py-0.5 rounded border bg-emerald-500/10 text-emerald-600 border-emerald-500/25 flex items-center gap-1"> | |
| <Zap className="h-2.5 w-2.5" /> LoRA | |
| </span> | |
| )} | |
| {model.requires_token && ( | |
| <span className="text-[11px] px-2 py-0.5 rounded border bg-amber-500/10 text-amber-600 border-amber-500/25 flex items-center gap-1"> | |
| <Lock className="h-2.5 w-2.5" /> Gated | |
| </span> | |
| )} | |
| {model.languages.includes("multilingual") && ( | |
| <span className="text-[11px] px-2 py-0.5 rounded border bg-blue-500/10 text-blue-600 border-blue-500/25 flex items-center gap-1"> | |
| <Globe2 className="h-2.5 w-2.5" /> Multilingual | |
| </span> | |
| )} | |
| </div> | |
| {/* Description */} | |
| <p className="text-xs text-muted-foreground leading-relaxed line-clamp-2">{model.description}</p> | |
| {/* Best for */} | |
| <div className="rounded-md bg-secondary/50 px-2.5 py-2 text-xs"> | |
| <span className="font-medium text-muted-foreground">Best for: </span> | |
| <span className="text-foreground/80">{model.best_for}</span> | |
| </div> | |
| {/* Task types */} | |
| <div className="flex flex-wrap gap-1"> | |
| {model.task_types.slice(0, 3).map(t => ( | |
| <span key={t} className="text-[10px] px-1.5 py-0.5 rounded bg-secondary text-muted-foreground"> | |
| {t.replace(/_/g, " ")} | |
| </span> | |
| ))} | |
| {model.task_types.length > 3 && ( | |
| <span className="text-[10px] px-1.5 py-0.5 rounded bg-secondary text-muted-foreground"> | |
| +{model.task_types.length - 3} | |
| </span> | |
| )} | |
| </div> | |
| {/* CTA */} | |
| <Button size="sm" className="mt-auto w-full gap-1.5" onClick={() => onTrain(model)}> | |
| Train with this model | |
| <ArrowRight className="h-3.5 w-3.5" /> | |
| </Button> | |
| </CardContent> | |
| </Card> | |
| ) | |
| } | |