SPB Git
7.6 KB · 82 lines tsx
1"use client";2import { useMemo, useState } from "react";3import { ChevronDown, Star, Eye, Wrench, Braces, Brain, Zap, Gauge, Coins, Sparkles, Check, Search } from "lucide-react";4import { useModels, PROVIDER_LABEL, shortName } from "@/lib/client/use-models";5import type { AIModel, RoutingMode } from "@/lib/client/types";6import { api } from "@/lib/client/fetcher";7import { cn, Dialog, Badge } from "../ui";89const MODES: { id: RoutingMode; label: string; desc: string; icon: React.ComponentType<{ className?: string }> }[] = [10  { id: "auto", label: "Automatique", desc: "Classe la tâche et choisit le meilleur compromis", icon: Sparkles },11  { id: "fast", label: "Rapide", desc: "Latence la plus basse (≥ 89 % au benchmark)", icon: Zap },12  { id: "quality", label: "Qualité", desc: "Score maximal, palier le plus élevé", icon: Gauge },13  { id: "economy", label: "Économie", desc: "Prix le plus bas (score 100 %)", icon: Coins },14];1516export function ModelPicker({ value, mode, onChange, compact }: { value: string | null; mode: RoutingMode; onChange: (v: { modelId: string | null; mode: RoutingMode }) => void; compact?: boolean }) {17  const { chat, byId, mutate } = useModels(); const [open, setOpen] = useState(false); const [q, setQ] = useState("");18  const current = byId(value);19  const label = mode !== "manual" ? MODES.find(m => m.id === mode)!.label : shortName(current, value);20  const groups = useMemo(() => {21    const ql = q.toLowerCase();22    const list = chat.filter(m => !ql || m.id.includes(ql) || m.displayName.toLowerCase().includes(ql));23    const fav = list.filter(m => m.favorite);24    const g: Record<string, AIModel[]> = {}; for (const m of list) (g[m.provider] ??= []).push(m);25    return { fav, g };26  }, [chat, q]);27  async function toggleFav(e: React.MouseEvent, m: AIModel) { e.stopPropagation(); await api("/api/models", { method: "PATCH", json: { id: m.id, favorite: !m.favorite } }); void mutate(); }28  return (29    <>30      <button type="button" data-testid="model-picker" onClick={() => setOpen(true)} className={cn("inline-flex max-w-[52vw] items-center gap-1.5 rounded-lg border border-border bg-surface px-2.5 text-[13px] font-medium text-fg hover:border-border-strong transition-colors", compact ? "h-8" : "h-9")}>31        {mode !== "manual" ? <Sparkles className="h-3.5 w-3.5 text-accent-2" /> : <span className={cn("h-2 w-2 rounded-full", current?.provider === "anthropic" ? "bg-accent-2" : "bg-accent")} />}32        <span className="truncate">{label}</span><ChevronDown className="h-3.5 w-3.5 text-muted" />33      </button>34      <Dialog open={open} onClose={() => setOpen(false)} width="max-w-2xl" title="Choisir un modèle">35        <div className="p-3">36          <div className="mb-3 grid grid-cols-2 gap-2 md:grid-cols-4">37            {MODES.map(m => <button key={m.id} onClick={() => { onChange({ modelId: value, mode: m.id }); setOpen(false); }} className={cn("flex flex-col items-start gap-1 rounded-xl border p-3 text-left transition-colors", mode === m.id ? "border-accent bg-accent/10" : "border-border hover:border-border-strong hover:bg-surface")}><span className="flex items-center gap-1.5 text-[13px] font-medium"><m.icon className="h-3.5 w-3.5 text-accent-2" />{m.label}</span><span className="text-[11px] leading-snug text-muted">{m.desc}</span></button>)}38          </div>39          <div className="relative mb-2"><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 les modèles vérifiés…" className="input !pl-8" autoFocus /></div>40          <div className="max-h-[52dvh] overflow-y-auto pr-1">41            {groups.fav.length > 0 && !q && <Group title="Favoris">{groups.fav.map(m => <Row key={m.id} m={m} selected={mode === "manual" && m.id === value} onSelect={() => { onChange({ modelId: m.id, mode: "manual" }); setOpen(false); }} onFav={e => toggleFav(e, m)} />)}</Group>}42            {Object.entries(groups.g).map(([prov, ms]) => <Group key={prov} title={PROVIDER_LABEL[prov] ?? prov} count={ms.length}>{ms.map(m => <Row key={m.id} m={m} selected={mode === "manual" && m.id === value} onSelect={() => { onChange({ modelId: m.id, mode: "manual" }); setOpen(false); }} onFav={e => toggleFav(e, m)} />)}</Group>)}43            <Group title="MacLustr local"><div className="px-3 py-2 text-[12px] text-muted">Intégration future de l'inférence locale sur le cluster — aucun modèle vérifié pour l'instant.</div></Group>44          </div>45          <div className="mt-2 flex items-center justify-between px-1 text-[11px] text-muted"><span>Seuls les modèles vérifiés par requête réelle apparaissent ici.</span><a href="/models" className="text-accent hover:underline">Laboratoire de modèles →</a></div>46        </div>47      </Dialog>48    </>49  );50}5152function Group({ title, count, children }: { title: string; count?: number; children: React.ReactNode }) {53  return <div className="mb-2"><div className="sticky top-0 z-10 bg-bg-elev px-2 py-1 text-[11px] font-semibold uppercase tracking-wider text-muted">{title}{count != null && <span className="ml-1 font-normal">· {count}</span>}</div>{children}</div>;54}5556export function CapIcons({ m, className }: { m: AIModel; className?: string }) {57  const c = m.capabilities;58  return <span className={cn("inline-flex items-center gap-1 text-muted", className)}>59    {c.vision === true && <Eye className="h-3 w-3" aria-label="vision" />}{c.tools === true && <Wrench className="h-3 w-3" aria-label="outils" />}{c.structuredOutput === true && <Braces className="h-3 w-3" aria-label="JSON" />}{c.reasoning === true && <Brain className="h-3 w-3" aria-label="raisonnement" />}60  </span>;61}6263function Row({ m, selected, onSelect, onFav }: { m: AIModel; selected: boolean; onSelect: () => void; onFav: (e: React.MouseEvent) => void }) {64  const p = m.pricing; const b = m.benchmark;65  return (66    <div role="button" tabIndex={0} onClick={onSelect} onKeyDown={e => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onSelect(); } }} className={cn("flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-left transition-colors", selected ? "bg-accent/10" : "hover:bg-surface")}>67      <span className={cn("h-2 w-2 shrink-0 rounded-full", m.provider === "anthropic" ? "bg-accent-2" : "bg-accent")} />68      <div className="min-w-0 flex-1">69        <div className="flex items-center gap-2"><span className="truncate text-[13px] font-medium">{m.displayName}</span>{m.kind === "chat-pro" && <Badge tone="amber">pro · lent</Badge>}{m.kind === "search" && <Badge tone="accent">recherche web</Badge>}<CapIcons m={m} /></div>70        <div className="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[11px] text-muted">71          {b?.qualityScore != null && <span className="inline-flex items-center gap-1"><span className="inline-block h-1.5 w-12 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>}72          {b?.p50LatencyMs != null && <span>{b.p50LatencyMs} ms</span>}73          {p?.input != null && p?.output != null ? <span>{p.input} $ / {p.output} $ par M</span> : <span className="italic">prix non vérifié</span>}74          {m.contextWindow && <span>{Math.round(m.contextWindow / 1000)}k ctx</span>}75        </div>76      </div>77      <button onClick={onFav} className={cn("rounded p-1 hover:bg-surface-2", m.favorite ? "text-accent-2" : "text-muted/60 hover:text-fg")} aria-label="Favori"><Star className={cn("h-3.5 w-3.5", m.favorite && "fill-current")} /></button>78      {selected && <Check className="h-4 w-4 text-accent" />}79    </div>80  );81}82
Files · main
Menu

Theme