SPB Git
20.2 KB · 159 lines tsx
1"use client";2import { useMemo, useState, useEffect, useRef } from "react";3import { useSearchParams } from "next/navigation";4import useSWR from "swr";5import { Star, EyeOff, Eye, ChevronDown, ChevronRight, RefreshCw, CheckCircle2, XCircle, HelpCircle, Ban, BadgeCheck, Search, FlaskConical, ArrowUpDown, Columns2 } from "lucide-react";6import Link from "next/link";7import { useModels, PROVIDER_LABEL } from "@/lib/client/use-models";8import type { AIModel } from "@/lib/client/types";9import { api, fetcher, fmtMs, relTime } from "@/lib/client/fetcher";10import { cn, Badge, Button, Tabs, Toggle, toast, Spinner } from "@/components/ui";11import { CapIcons } from "@/components/chat/model-picker";12import { Markdown } from "@/components/chat/markdown";1314type Status = "verified" | "unavailable" | "untested" | "unsupported";15type SortKey = "quality" | "latency" | "ttft" | "price" | "name" | "context";16const TESTS = ["basic", "reasoning", "math", "code", "french", "english", "json", "tools", "vision", "longctx", "streaming"];17const TEST_LABEL: Record<string, string> = { basic: "Base", reasoning: "Raisonnement", math: "Maths", code: "Code", french: "Français", english: "Anglais", json: "JSON", tools: "Outils", vision: "Vision", longctx: "Long contexte", streaming: "Streaming", image: "Image", tts: "TTS", stt: "STT", embedding: "Embedding", moderation: "Modération", "audio-chat": "Audio" };18const STATUS_LABEL: Record<Status, string> = { verified: "vérifié", unavailable: "indisponible", untested: "non testé", unsupported: "non pris en charge" };19const STATUS_TONE: Record<Status, "ok" | "danger" | "neutral" | "warn"> = { verified: "ok", unavailable: "danger", untested: "neutral", unsupported: "warn" };20const KIND_LABEL: Record<string, string> = { chat: "chat", "chat-pro": "pro", search: "recherche web", image: "image", tts: "TTS", stt: "STT", embedding: "embedding", moderation: "modération", realtime: "temps réel", video: "vidéo", "legacy-completion": "legacy", "deep-research": "deep research", "audio-chat": "audio" };2122const blended = (m: AIModel) => (m.pricing?.input != null && m.pricing?.output != null ? (3 * m.pricing.input + m.pricing.output) / 4 : null);23const isChat = (m: AIModel) => ["chat", "chat-pro", "search"].includes(m.kind);2425export function ModelLab() {26  const { models, defaultModel, registry, isLoading, mutate } = useModels(true);27  const sp = useSearchParams(); const select = sp.get("select");28  const [tab, setTab] = useState<"catalog" | "report" | "routing">("catalog");29  const [provider, setProvider] = useState<string>("all"); const [status, setStatus] = useState<Status | "all">("all"); const [kind, setKind] = useState<string>("all");30  const [caps, setCaps] = useState<Set<string>>(new Set()); const [q, setQ] = useState(""); const [showHidden, setShowHidden] = useState(false);31  const [sort, setSort] = useState<SortKey>("quality"); const [dir, setDir] = useState<1 | -1>(-1);32  const [open, setOpen] = useState<Set<string>>(new Set(select ? [select] : []));33  const [refreshing, setRefreshing] = useState(false);34  const { data: report } = useSWR<string>(tab === "report" ? "/api/benchmarks?file=benchmark-report.md" : null, fetcher);35  const { data: routing } = useSWR<string>(tab === "routing" ? "/api/benchmarks?file=routing-recommendations.md" : null, fetcher);36  const selRef = useRef<HTMLDivElement>(null);37  useEffect(() => { if (select && selRef.current && !isLoading) selRef.current.scrollIntoView({ block: "center" }); }, [select, isLoading]);3839  const kinds = useMemo(() => [...new Set(models.map(m => m.kind))].sort(), [models]);40  const list = useMemo(() => {41    const ql = q.trim().toLowerCase();42    const f = models.filter(m => (showHidden || !m.hidden || m.id === select) && (provider === "all" || m.provider === provider) && (status === "all" || m.status === status) && (kind === "all" || m.kind === kind)43      && (!ql || m.id.toLowerCase().includes(ql) || m.displayName.toLowerCase().includes(ql))44      && [...caps].every(c => m.capabilities[c as keyof AIModel["capabilities"]] === true));45    const val = (m: AIModel): number | string => sort === "quality" ? m.benchmark?.qualityScore ?? -1 : sort === "latency" ? m.benchmark?.p50LatencyMs ?? Infinity : sort === "ttft" ? m.benchmark?.p50TtftMs ?? Infinity : sort === "price" ? blended(m) ?? Infinity : sort === "context" ? m.contextWindow ?? -1 : m.displayName.toLowerCase();46    return f.sort((a, b) => { const x = val(a), y = val(b); if (x === y) return a.id.localeCompare(b.id); if (typeof x === "string" || typeof y === "string") return dir * String(x).localeCompare(String(y)); return dir * ((x as number) - (y as number)); });47  }, [models, q, provider, status, kind, caps, showHidden, sort, dir, select]);4849  const verifiedChat = models.filter(m => m.status === "verified" && isChat(m)).length;50  const unavailable = models.filter(m => m.status === "unavailable").length;51  const specialized = models.filter(m => m.status === "verified" && !isChat(m)).length;52  const untested = models.filter(m => m.status === "untested" || m.status === "unsupported").length;5354  async function patch(id: string, p: Record<string, unknown>, msg?: string) { try { await api("/api/models", { method: "PATCH", json: { id, ...p } }); await mutate(); if (msg) toast(msg, "ok"); } catch (e) { toast((e as Error).message, "error"); } }55  async function refresh() { setRefreshing(true); try { const r = await api<{ log: string[] }>("/api/models/refresh", { method: "POST" }); await mutate(); toast(r.log.at(-1) ?? "Catalogue rafraîchi", "ok"); } catch (e) { toast((e as Error).message, "error"); } finally { setRefreshing(false); } }56  function toggleSort(k: SortKey) { if (sort === k) setDir(d => (d === 1 ? -1 : 1)); else { setSort(k); setDir(k === "name" ? 1 : k === "quality" || k === "context" ? -1 : 1); } }57  const toggleOpen = (id: string) => setOpen(s => { const n = new Set(s); if (n.has(id)) n.delete(id); else n.add(id); return n; });5859  return (60    <div className="flex min-h-0 flex-1 flex-col overflow-y-auto">61      <div className="mx-auto w-full max-w-6xl px-4 py-6 md:px-6">62        <div className="mb-5 flex flex-wrap items-start justify-between gap-3">63          <div><h1 className="flex items-center gap-2 text-xl font-semibold tracking-tight"><FlaskConical className="h-5 w-5 text-accent" /> Laboratoire de modèles</h1><p className="mt-1 text-sm text-muted">Catalogue vivant des deux comptes : chaque modèle est vérifié par requête réelle, mesuré et tarifé d'après les pages officielles. Rien n'est supposé.</p></div>64          <div className="flex items-center gap-2"><Link href="/compare" className="inline-flex h-9 items-center gap-1.5 rounded-lg border border-border px-3 text-sm text-fg-2 hover:bg-surface-2 hover:text-fg"><Columns2 className="h-4 w-4" /> Comparer</Link><Button onClick={refresh} loading={refreshing}><RefreshCw className="h-4 w-4" /> Rafraîchir le catalogue</Button></div>65        </div>66        <div className="mb-5 grid grid-cols-2 gap-2 md:grid-cols-4">67          <Tile label="Modèles de chat vérifiés" value={verifiedChat} tone="ok" />68          <Tile label="Listés mais indisponibles" value={unavailable} tone="danger" hint="404 / dépréciés chez le fournisseur" />69          <Tile label="Spécialisés vérifiés" value={specialized} hint="image, TTS, STT, embeddings, modération" />70          <Tile label="Non testés / non exercés" value={untested} hint={registry?.refreshedAt ? `catalogue rafraîchi ${relTime(registry.refreshedAt)}` : undefined} />71        </div>72        <Tabs value={tab} onChange={setTab} className="mb-4" items={[{ value: "catalog", label: "Catalogue" }, { value: "report", label: "Rapport de benchmark" }, { value: "routing", label: "Routage" }]} />7374        {tab === "catalog" && (75          <>76            <div className="mb-3 flex flex-wrap items-center gap-2 text-[13px]">77              <div className="relative min-w-48 flex-1"><Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-muted" /><input value={q} onChange={e => setQ(e.target.value)} placeholder="Filtrer par nom ou id…" className="input !pl-8 !py-2" /></div>78              <select value={provider} onChange={e => setProvider(e.target.value)} className="input !w-auto !py-2"><option value="all">Tous les fournisseurs</option><option value="openai">OpenAI</option><option value="anthropic">Anthropic</option><option value="local">MacLustr local</option></select>79              <select value={status} onChange={e => setStatus(e.target.value as Status | "all")} className="input !w-auto !py-2"><option value="all">Tous les états</option>{(Object.keys(STATUS_LABEL) as Status[]).map(s => <option key={s} value={s}>{STATUS_LABEL[s]}</option>)}</select>80              <select value={kind} onChange={e => setKind(e.target.value)} className="input !w-auto !py-2"><option value="all">Tous les types</option>{kinds.map(k => <option key={k} value={k}>{KIND_LABEL[k] ?? k}</option>)}</select>81              <div className="flex items-center gap-1">{[["vision", "vision"], ["tools", "outils"], ["structuredOutput", "JSON"], ["reasoning", "raisonnement"]].map(([k, l]) => <button key={k} onClick={() => setCaps(s => { const n = new Set(s); if (n.has(k)) n.delete(k); else n.add(k); return n; })} className={cn("rounded-md border px-2 py-1 text-[12px] transition-colors", caps.has(k) ? "border-accent bg-accent/10 text-accent" : "border-border text-fg-2 hover:bg-surface-2")}>{l}</button>)}</div>82              <label className="ml-auto flex items-center gap-2 text-fg-2">Masqués <Toggle checked={showHidden} onChange={setShowHidden} /></label>83            </div>84            <div className="card overflow-hidden">85              <div className="hidden grid-cols-[1.6fr_.7fr_.6fr_.6fr_.9fr_.5fr_.7fr_.6fr] items-center gap-2 border-b border-border bg-surface-2/60 px-3 py-2 text-[11px] font-semibold uppercase tracking-wider text-muted md:grid">86                <Th k="name" cur={sort} dir={dir} onClick={toggleSort}>Modèle</Th><Th k="quality" cur={sort} dir={dir} onClick={toggleSort}>Qualité</Th><Th k="latency" cur={sort} dir={dir} onClick={toggleSort}>p50</Th><Th k="ttft" cur={sort} dir={dir} onClick={toggleSort}>1er jeton</Th><Th k="price" cur={sort} dir={dir} onClick={toggleSort}>Prix in/out · mixte</Th><Th k="context" cur={sort} dir={dir} onClick={toggleSort}>Ctx</Th><span>Capacités</span><span className="text-right">Actions</span>87              </div>88              {isLoading && <div className="flex items-center justify-center gap-2 py-12 text-sm text-muted"><Spinner /> Chargement du registre…</div>}89              {!isLoading && !list.length && <div className="py-12 text-center text-sm text-muted">Aucun modèle ne correspond aux filtres.</div>}90              {list.map(m => <Row key={m.id} m={m} isDefault={defaultModel === m.id} isOpen={open.has(m.id)} selected={select === m.id} refProp={select === m.id ? selRef : undefined} onToggle={() => toggleOpen(m.id)} onFav={() => patch(m.id, { favorite: !m.favorite })} onHide={() => patch(m.id, { hidden: !m.hidden }, m.hidden ? "Modèle réaffiché" : "Modèle masqué du sélecteur")} onDefault={() => patch(m.id, { default: true }, `${m.displayName} est le modèle par défaut`)} />)}91            </div>92            <p className="mt-3 text-[12px] text-muted">{list.length} modèle{list.length > 1 ? "s" : ""} affiché{list.length > 1 ? "s" : ""}. Score de qualité = part des 9 tests notés réussis (une passe par test, résultats indicatifs). Prix en $ US par million de jetons ; « mixte » = (3 × entrée + sortie) / 4.</p>93          </>94        )}95        {tab === "report" && <div className="card p-5 md:p-7">{report ? <Markdown text={report} /> : <div className="flex items-center gap-2 text-sm text-muted"><Spinner /> Chargement du rapport…</div>}</div>}96        {tab === "routing" && <div className="card p-5 md:p-7">{routing ? <Markdown text={routing} /> : <div className="flex items-center gap-2 text-sm text-muted"><Spinner /> Chargement…</div>}</div>}97      </div>98    </div>99  );100}101102function Tile({ label, value, tone, hint }: { label: string; value: number; tone?: "ok" | "danger"; hint?: string }) {103  return <div className="card px-4 py-3"><div className="text-[11px] font-medium uppercase tracking-wider text-muted">{label}</div><div className={cn("mt-1 text-2xl font-semibold tabular-nums", tone === "ok" && "text-ok", tone === "danger" && "text-danger")}>{value}</div>{hint && <div className="mt-0.5 truncate text-[11px] text-muted">{hint}</div>}</div>;104}105function Th({ k, cur, dir, onClick, children }: { k: SortKey; cur: SortKey; dir: 1 | -1; onClick: (k: SortKey) => void; children: React.ReactNode }) {106  return <button onClick={() => onClick(k)} className={cn("inline-flex items-center gap-1 text-left hover:text-fg", cur === k && "text-fg")}>{children}<ArrowUpDown className={cn("h-3 w-3", cur === k ? "opacity-100" : "opacity-30")} />{cur === k && <span className="text-[9px]">{dir === 1 ? "↑" : "↓"}</span>}</button>;107}108109function Row({ m, isDefault, isOpen, selected, refProp, onToggle, onFav, onHide, onDefault }: { m: AIModel; isDefault: boolean; isOpen: boolean; selected: boolean; refProp?: React.RefObject<HTMLDivElement | null>; onToggle: () => void; onFav: () => void; onHide: () => void; onDefault: () => void }) {110  const b = m.benchmark; const p = m.pricing; const bl = blended(m);111  const chat = isChat(m);112  return (113    <div ref={refProp} className={cn("border-b border-border last:border-b-0 transition-colors", selected && "bg-accent/5 ring-1 ring-inset ring-accent/40", m.hidden && "opacity-60")}>114      <div className="grid grid-cols-1 gap-2 px-3 py-2.5 md:grid-cols-[1.6fr_.7fr_.6fr_.6fr_.9fr_.5fr_.7fr_.6fr] md:items-center">115        <button onClick={onToggle} className="flex min-w-0 items-center gap-2 text-left">116          {isOpen ? <ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted" /> : <ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted" />}117          <span className={cn("h-2 w-2 shrink-0 rounded-full", m.provider === "anthropic" ? "bg-accent-2" : m.provider === "openai" ? "bg-accent" : "bg-muted")} />118          <span className="min-w-0"><span className="flex flex-wrap items-center gap-1.5"><span className="truncate text-[13px] font-medium">{m.displayName}</span>{isDefault && <Badge tone="accent">défaut</Badge>}{m.favorite && <Star className="h-3 w-3 fill-accent-2 text-accent-2" />}<Badge tone={STATUS_TONE[m.status]}>{STATUS_LABEL[m.status]}</Badge>{m.kind !== "chat" && <Badge tone="amber">{KIND_LABEL[m.kind] ?? m.kind}</Badge>}</span><span className="block truncate font-mono text-[11px] text-muted">{m.id} · {PROVIDER_LABEL[m.provider] ?? m.provider}</span></span>119        </button>120        <div className="flex items-center gap-2 text-[12px] tabular-nums"><span className="md:hidden text-muted">Qualité</span>{b?.qualityScore != null ? <><span className="inline-block h-1.5 w-16 overflow-hidden rounded-full bg-surface-2"><span className={cn("block h-full", b.qualityScore >= 100 ? "bg-ok" : b.qualityScore >= 89 ? "bg-accent" : "bg-warn")} style={{ width: `${b.qualityScore}%` }} /></span>{b.qualityScore} %</> : <span className="text-muted">{chat ? "—" : b?.passed ? "✓ modalité" : "—"}</span>}</div>121        <div className="text-[12px] tabular-nums"><span className="md:hidden text-muted">p50 </span>{b?.p50LatencyMs != null ? fmtMs(b.p50LatencyMs) : <span className="text-muted">—</span>}</div>122        <div className="text-[12px] tabular-nums"><span className="md:hidden text-muted">1er jeton </span>{b?.p50TtftMs != null ? fmtMs(b.p50TtftMs) : <span className="text-muted">—</span>}</div>123        <div className="text-[12px] tabular-nums">{p?.input != null && p?.output != null ? <>{p.input} $ / {p.output} $ <span className="text-muted">· {bl!.toFixed(2)} $</span></> : p && Object.keys(p).length ? <span className="text-muted" title={JSON.stringify(p)}>tarif spécialisé</span> : <span className="italic text-muted">prix non vérifié</span>}</div>124        <div className="text-[12px] tabular-nums text-fg-2">{m.contextWindow ? `${Math.round(m.contextWindow / 1000)}k` : <span className="text-muted">inconnu</span>}</div>125        <div className="flex items-center gap-1 text-[12px]"><CapIcons m={m} />{Object.values(m.capabilities).some(v => v === "unknown") && <span className="text-[11px] text-muted" title="certaines capacités non déterminées">inconnu</span>}{!Object.keys(m.capabilities).length && <span className="text-[11px] text-muted">—</span>}</div>126        <div className="flex items-center justify-end gap-0.5">127          <button onClick={onFav} title="Favori" className={cn("rounded-md p-1.5 hover:bg-surface-2", m.favorite ? "text-accent-2" : "text-muted hover:text-fg")}><Star className={cn("h-3.5 w-3.5", m.favorite && "fill-current")} /></button>128          <button onClick={onHide} title={m.hidden ? "Réafficher dans le sélecteur" : "Masquer du sélecteur"} className="rounded-md p-1.5 text-muted hover:bg-surface-2 hover:text-fg">{m.hidden ? <Eye className="h-3.5 w-3.5" /> : <EyeOff className="h-3.5 w-3.5" />}</button>129          <button onClick={onDefault} disabled={!m.available || !chat || isDefault} title="Définir par défaut" className="rounded-md p-1.5 text-muted hover:bg-surface-2 hover:text-fg disabled:opacity-30"><BadgeCheck className={cn("h-3.5 w-3.5", isDefault && "text-accent")} /></button>130        </div>131      </div>132      {isOpen && (133        <div className="border-t border-border/60 bg-surface-2/30 px-4 py-3 text-[13px] anim-fade-up">134          {m.unavailableReason && <div className={cn("mb-3 flex items-start gap-2 rounded-lg border px-3 py-2", m.status === "unavailable" ? "border-danger/30 bg-danger/5 text-danger" : "border-border bg-surface text-fg-2")}>{m.status === "unavailable" ? <Ban className="mt-0.5 h-4 w-4 shrink-0" /> : <HelpCircle className="mt-0.5 h-4 w-4 shrink-0" />}<span>{m.unavailableReason}</span></div>}135          {b && Object.keys(b.perTest).length > 0 && (136            <div className="mb-3">137              <div className="mb-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted">Résultats par test ({b.passed}/{b.tests})</div>138              <div className="grid grid-cols-3 gap-1.5 sm:grid-cols-4 lg:grid-cols-6">139                {(chat ? TESTS : Object.keys(b.perTest)).map(t => { const v = b.perTest[t]; return <div key={t} className="flex items-center gap-1.5 rounded-lg border border-border bg-surface px-2 py-1.5 text-[12px]">{v === "pass" ? <CheckCircle2 className="h-3.5 w-3.5 text-ok" /> : v === "fail" ? <XCircle className="h-3.5 w-3.5 text-danger" /> : v === "error" ? <span className="text-warn">⚠️</span> : <span className="text-muted">·</span>}<span className={cn(!v && "text-muted")}>{TEST_LABEL[t] ?? t}</span></div>; })}140              </div>141            </div>142          )}143          <div className="grid gap-x-6 gap-y-1 text-[12px] text-fg-2 sm:grid-cols-2 lg:grid-cols-4">144            <Info k="Fournisseur" v={PROVIDER_LABEL[m.provider] ?? m.provider} /><Info k="API" v={m.api ?? "—"} /><Info k="Type" v={KIND_LABEL[m.kind] ?? m.kind} />145            <Info k="Sortie max" v={m.maxOutputTokens ? `${Math.round(m.maxOutputTokens / 1000)}k jetons` : "inconnue"} />146            <Info k="Dernière vérification" v={m.lastVerifiedAt ? new Date(m.lastVerifiedAt).toLocaleString("fr-CA") : "jamais"} />147            <Info k="Coût du benchmark" v={b ? `${b.totalCostUsd.toFixed(4)} $` : "—"} /><Info k="Sortie moyenne" v={b?.avgOutputTokens != null ? `${b.avgOutputTokens} jetons` : "—"} />148            <Info k="Cache (lecture)" v={p?.cachedInput != null ? `${p.cachedInput} $ / M` : "—"} />149          </div>150          {p?.notes && <div className="mt-2 text-[12px] text-muted">Note tarifaire : {String(p.notes)}</div>}151          {b?.errors?.length ? <div className="mt-3"><div className="mb-1 text-[11px] font-semibold uppercase tracking-wider text-muted">Erreurs rencontrées</div><ul className="space-y-1">{b.errors.map((e, i) => <li key={i} className="rounded-lg bg-danger/5 px-2 py-1 font-mono text-[11px] text-danger">{e}</li>)}</ul></div> : null}152          {chat && m.available && <div className="mt-3 flex gap-2"><Link href={`/compare?models=${m.id}`} className="inline-flex h-8 items-center gap-1 rounded-lg border border-border px-2.5 text-[12px] text-fg-2 hover:bg-surface-2 hover:text-fg"><Columns2 className="h-3.5 w-3.5" /> Comparer ce modèle</Link></div>}153        </div>154      )}155    </div>156  );157}158function Info({ k, v }: { k: string; v: string }) { return <div><span className="text-muted">{k} : </span><span>{v}</span></div>; }159
Files · main
Menu

Theme