TypeScript 98.3%
CSS 0.9%
Shell 0.7%
1"use client";2// Modèles : catalogue OpenRouter avec surcharges (activer/désactiver, favori, note)3// et éditeur des préréglages ordonnés.4import { useMemo, useState } from "react";5import { ArrowDown, ArrowUp, Check, Pencil, Plus, Search, Sparkles, Star, X } from "lucide-react";6import { PageHeader } from "@/components/app-shell";7import { Badge, Button, Card, EmptyState, Input, Skeleton, Spinner, Tabs, cn } from "@/components/ui";8import { ErrorBanner, SectionTitle, SuccessFlash, Toggle, fmtInt, postJson, useFetchJson } from "./shared";910type ModelInfo = {11 id: string;12 name: string;13 provider: string;14 description: string;15 contextLength: number;16 pricing: { prompt: number; completion: number };17 supportsImages: boolean;18 supportsFiles: boolean;19 supportsTools: boolean;20 supportsReasoning: boolean;21 supportsStructured: boolean;22 isFree: boolean;23 costTier: "économique" | "modéré" | "coûteux";24 enabled: boolean;25 favorite: boolean;26 note: string;27};2829type Preset = { label: string; models: string[]; description: string };30type ModelsResponse = { models: ModelInfo[]; presets: Record<string, Preset>; defaultPresets: Record<string, Preset> };3132const FILTERS = [33 { key: "all", label: "Tous" },34 { key: "enabled", label: "Activés" },35 { key: "disabled", label: "Désactivés" },36 { key: "vision", label: "Vision" },37 { key: "free", label: "Gratuits" },38];3940const TIER_TONE: Record<ModelInfo["costTier"], "green" | "amber" | "red"> = {41 "économique": "green",42 "modéré": "amber",43 "coûteux": "red",44};45const TIER_LABEL: Record<ModelInfo["costTier"], string> = {46 "économique": "Économique",47 "modéré": "Modéré",48 "coûteux": "Coûteux",49};5051const PAGE_SIZE = 50;5253export function AdminModels() {54 const { data, error, loading, reload, setData } = useFetchJson<ModelsResponse>("/api/admin/models");55 const [search, setSearch] = useState("");56 const [filter, setFilter] = useState("all");57 const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);58 const [actionError, setActionError] = useState<string | null>(null);59 const [editingNote, setEditingNote] = useState<string | null>(null);60 const [noteDraft, setNoteDraft] = useState("");61 const [savingNote, setSavingNote] = useState(false);6263 // Préréglages en édition locale64 const [presets, setPresets] = useState<Record<string, Preset> | null>(null);65 const [savingPresets, setSavingPresets] = useState(false);66 const [presetsFlash, setPresetsFlash] = useState<string | null>(null);67 const effectivePresets = presets ?? data?.presets ?? null;6869 const models = useMemo(() => data?.models ?? [], [data]);70 const filtered = useMemo(() => {71 const q = search.trim().toLowerCase();72 return models.filter((m) => {73 if (filter === "enabled" && !m.enabled) return false;74 if (filter === "disabled" && m.enabled) return false;75 if (filter === "vision" && !m.supportsImages) return false;76 if (filter === "free" && !m.isFree) return false;77 if (q && !m.name.toLowerCase().includes(q) && !m.id.toLowerCase().includes(q) && !m.provider.toLowerCase().includes(q)) return false;78 return true;79 });80 }, [models, search, filter]);8182 async function override(modelId: string, patch: { enabled?: boolean; favorite?: boolean; note?: string }) {83 setActionError(null);84 const before = models;85 // Mise à jour optimiste86 setData((d) =>87 d ? { ...d, models: d.models.map((m) => (m.id === modelId ? { ...m, ...patch } : m)) } : d88 );89 try {90 await postJson("/api/admin/models", { action: "override", modelId, ...patch });91 } catch (e) {92 setData((d) => (d ? { ...d, models: before } : d));93 setActionError(e instanceof Error ? e.message : "L'action a échoué.");94 }95 }9697 async function saveNote(modelId: string) {98 setSavingNote(true);99 await override(modelId, { note: noteDraft.slice(0, 300) });100 setSavingNote(false);101 setEditingNote(null);102 }103104 async function savePresets() {105 if (!effectivePresets) return;106 setSavingPresets(true);107 setActionError(null);108 setPresetsFlash(null);109 try {110 await postJson("/api/admin/models", { action: "presets", presets: effectivePresets });111 setPresetsFlash("Préréglages enregistrés.");112 setTimeout(() => setPresetsFlash(null), 3500);113 } catch (e) {114 setActionError(e instanceof Error ? e.message : "L'enregistrement des préréglages a échoué.");115 } finally {116 setSavingPresets(false);117 }118 }119120 function mutatePreset(key: string, fn: (p: Preset) => Preset) {121 if (!effectivePresets) return;122 setPresets({ ...effectivePresets, [key]: fn(effectivePresets[key]) });123 }124125 if (loading) {126 return (127 <div className="animate-fade-up">128 <PageHeader title="Modèles" subtitle="Catalogue OpenRouter, surcharges et préréglages" />129 <Skeleton className="mb-3 h-10 w-full max-w-md" />130 <Skeleton className="h-96" />131 </div>132 );133 }134 if (error || !data) {135 return (136 <div>137 <PageHeader title="Modèles" />138 <ErrorBanner message={error ?? "Données indisponibles."} onRetry={reload} />139 </div>140 );141 }142143 const visible = filtered.slice(0, visibleCount);144145 return (146 <div className="animate-fade-up">147 <PageHeader148 title="Modèles"149 subtitle={`${fmtInt(models.length)} modèles au catalogue · ${fmtInt(models.filter((m) => m.enabled).length)} activés`}150 />151152 {actionError && <div className="mb-4"><ErrorBanner message={actionError} /></div>}153154 <div className="mb-4 flex flex-wrap items-center gap-3">155 <div className="relative w-full max-w-xs">156 <Search size={15} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted" />157 <Input158 value={search}159 onChange={(e) => { setSearch(e.target.value); setVisibleCount(PAGE_SIZE); }}160 placeholder="Rechercher un modèle…"161 className="pl-9"162 aria-label="Rechercher un modèle"163 />164 </div>165 <Tabs tabs={FILTERS} active={filter} onChange={(k) => { setFilter(k); setVisibleCount(PAGE_SIZE); }} />166 </div>167168 {filtered.length === 0 ? (169 <Card>170 <EmptyState icon={<Search />} title="Aucun modèle ne correspond" description="Modifiez la recherche ou le filtre." />171 </Card>172 ) : (173 <Card className="overflow-hidden">174 <div className="overflow-x-auto">175 <table className="w-full text-[13px]">176 <thead>177 <tr className="border-b border-app text-left text-[12px] text-muted">178 <th className="px-4 py-2 font-medium">Modèle</th>179 <th className="px-3 py-2 font-medium">Fournisseur</th>180 <th className="px-3 py-2 text-right font-medium">Entrée / sortie ($/M)</th>181 <th className="px-3 py-2 text-right font-medium">Contexte</th>182 <th className="px-3 py-2 font-medium">Capacités</th>183 <th className="px-3 py-2 font-medium">Palier</th>184 <th className="px-3 py-2 text-center font-medium">Activé</th>185 <th className="px-3 py-2 text-center font-medium">Favori</th>186 <th className="px-4 py-2 font-medium">Note</th>187 </tr>188 </thead>189 <tbody>190 {visible.map((m) => (191 <tr key={m.id} className={cn("border-b border-app last:border-0", !m.enabled && "opacity-55")}>192 <td className="max-w-[260px] px-4 py-2">193 <p className="truncate font-medium text-fg" title={m.name}>{m.name}</p>194 <p className="truncate font-mono text-[11px] text-muted" title={m.id}>{m.id}</p>195 </td>196 <td className="px-3 py-2 text-muted">{m.provider}</td>197 <td className="whitespace-nowrap px-3 py-2 text-right tabular-nums text-fg">198 {m.isFree ? <Badge tone="gold">Gratuit</Badge> : `${m.pricing.prompt.toFixed(2)} / ${m.pricing.completion.toFixed(2)}`}199 </td>200 <td className="px-3 py-2 text-right tabular-nums text-muted">{Math.round(m.contextLength / 1000)} k</td>201 <td className="px-3 py-2">202 <div className="flex flex-wrap gap-1">203 {m.supportsImages && <Badge tone="brand">Vision</Badge>}204 {m.supportsFiles && <Badge tone="brand">Fichiers</Badge>}205 {m.supportsReasoning && <Badge tone="brand">Raisonnement</Badge>}206 {m.supportsTools && <Badge tone="brand">Outils</Badge>}207 </div>208 </td>209 <td className="px-3 py-2"><Badge tone={TIER_TONE[m.costTier]}>{TIER_LABEL[m.costTier]}</Badge></td>210 <td className="px-3 py-2 text-center">211 <Toggle checked={m.enabled} onChange={(v) => override(m.id, { enabled: v })} label={`Activer ${m.name}`} />212 </td>213 <td className="px-3 py-2 text-center">214 <button215 type="button"216 onClick={() => override(m.id, { favorite: !m.favorite })}217 aria-label={m.favorite ? `Retirer ${m.name} des favoris` : `Ajouter ${m.name} aux favoris`}218 aria-pressed={m.favorite}219 className="rounded-md p-1 hover:bg-surface-2 dark:hover:bg-brand-900/40"220 >221 <Star size={16} className={m.favorite ? "fill-gold-500 text-gold-500" : "text-muted"} />222 </button>223 </td>224 <td className="min-w-[180px] px-4 py-2">225 {editingNote === m.id ? (226 <div className="flex items-center gap-1.5">227 <Input228 value={noteDraft}229 onChange={(e) => setNoteDraft(e.target.value)}230 maxLength={300}231 autoFocus232 className="h-8 text-[12.5px]"233 onKeyDown={(e) => {234 if (e.key === "Enter") saveNote(m.id);235 if (e.key === "Escape") setEditingNote(null);236 }}237 aria-label={`Note pour ${m.name}`}238 />239 <Button size="icon" variant="ghost" onClick={() => saveNote(m.id)} disabled={savingNote} aria-label="Enregistrer la note">240 {savingNote ? <Spinner /> : <Check size={15} />}241 </Button>242 <Button size="icon" variant="ghost" onClick={() => setEditingNote(null)} aria-label="Annuler">243 <X size={15} />244 </Button>245 </div>246 ) : (247 <button248 type="button"249 onClick={() => { setEditingNote(m.id); setNoteDraft(m.note); }}250 className="group flex w-full items-center gap-1.5 text-left"251 >252 <span className={cn("min-w-0 flex-1 truncate text-[12.5px]", m.note ? "text-fg" : "italic text-muted")}>253 {m.note || "Ajouter une note…"}254 </span>255 <Pencil size={13} className="shrink-0 text-muted opacity-0 transition-opacity group-hover:opacity-100" />256 </button>257 )}258 </td>259 </tr>260 ))}261 </tbody>262 </table>263 </div>264 {filtered.length > visibleCount && (265 <div className="border-t border-app p-3 text-center">266 <Button size="sm" variant="secondary" onClick={() => setVisibleCount((c) => c + PAGE_SIZE)}>267 Afficher plus ({fmtInt(filtered.length - visibleCount)} restants)268 </Button>269 </div>270 )}271 </Card>272 )}273274 {/* ---------------- Préréglages ---------------- */}275 <section className="mt-10">276 <div className="mb-3 flex flex-wrap items-center justify-between gap-3">277 <SectionTitle sub="Listes ordonnées : le premier modèle disponible et activé de chaque liste est utilisé.">278 Préréglages279 </SectionTitle>280 <div className="flex items-center gap-3">281 <SuccessFlash message={presetsFlash} />282 <Button size="sm" onClick={savePresets} disabled={savingPresets || !presets}>283 {savingPresets && <Spinner />}284 Enregistrer les préréglages285 </Button>286 </div>287 </div>288 {!effectivePresets ? (289 <Card>290 <EmptyState icon={<Sparkles />} title="Aucun préréglage" description="Les préréglages par défaut apparaîtront après le premier chargement des modèles." />291 </Card>292 ) : (293 <div className="grid gap-4 md:grid-cols-2">294 {Object.entries(effectivePresets).map(([key, preset]) => (295 <PresetEditor296 key={key}297 presetKey={key}298 preset={preset}299 models={models}300 onChange={(fn) => mutatePreset(key, fn)}301 />302 ))}303 </div>304 )}305 </section>306 </div>307 );308}309310function PresetEditor({311 presetKey,312 preset,313 models,314 onChange,315}: {316 presetKey: string;317 preset: Preset;318 models: ModelInfo[];319 onChange: (fn: (p: Preset) => Preset) => void;320}) {321 const [query, setQuery] = useState("");322 const matches = useMemo(() => {323 const q = query.trim().toLowerCase();324 if (!q) return [];325 return models326 .filter((m) => m.enabled && !preset.models.includes(m.id) && (m.name.toLowerCase().includes(q) || m.id.toLowerCase().includes(q)))327 .slice(0, 8);328 }, [query, models, preset.models]);329330 function move(index: number, delta: number) {331 onChange((p) => {332 const next = [...p.models];333 const j = index + delta;334 if (j < 0 || j >= next.length) return p;335 [next[index], next[j]] = [next[j], next[index]];336 return { ...p, models: next };337 });338 }339340 return (341 <Card className="p-4">342 <div className="mb-2 flex items-baseline justify-between gap-2">343 <div>344 <h3 className="text-[14px] font-semibold text-fg">{preset.label}</h3>345 <p className="text-[12px] text-muted">{preset.description}</p>346 </div>347 <span className="shrink-0 font-mono text-[11px] text-muted">{presetKey}</span>348 </div>349350 {preset.models.length === 0 ? (351 <p className="py-3 text-center text-[12.5px] italic text-muted">Aucun modèle — ajoutez-en ci-dessous.</p>352 ) : (353 <ul className="mb-2 space-y-1">354 {preset.models.map((id, i) => {355 const m = models.find((x) => x.id === id);356 return (357 <li key={id} className="flex items-center gap-2 rounded-lg border border-app px-2.5 py-1.5">358 <span className="w-4 shrink-0 text-right text-[11px] tabular-nums text-muted">{i + 1}.</span>359 <div className="min-w-0 flex-1">360 <p className="truncate text-[12.5px] font-medium text-fg">{m?.name ?? id}</p>361 {(!m || !m.enabled) && <p className="text-[11px] text-amber-600 dark:text-amber-400">{!m ? "Introuvable au catalogue" : "Désactivé"}</p>}362 </div>363 <button type="button" onClick={() => move(i, -1)} disabled={i === 0} aria-label="Monter" className="rounded p-1 text-muted hover:bg-surface-2 hover:text-fg disabled:opacity-30 dark:hover:bg-brand-900/40">364 <ArrowUp size={13} />365 </button>366 <button type="button" onClick={() => move(i, 1)} disabled={i === preset.models.length - 1} aria-label="Descendre" className="rounded p-1 text-muted hover:bg-surface-2 hover:text-fg disabled:opacity-30 dark:hover:bg-brand-900/40">367 <ArrowDown size={13} />368 </button>369 <button370 type="button"371 onClick={() => onChange((p) => ({ ...p, models: p.models.filter((x) => x !== id) }))}372 aria-label="Retirer"373 className="rounded p-1 text-muted hover:bg-red-500/10 hover:text-red-600 dark:hover:text-red-400"374 >375 <X size={13} />376 </button>377 </li>378 );379 })}380 </ul>381 )}382383 {preset.models.length < 10 && (384 <div className="relative">385 <div className="relative">386 <Plus size={14} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted" />387 <Input388 value={query}389 onChange={(e) => setQuery(e.target.value)}390 placeholder="Ajouter un modèle (recherche)…"391 className="h-8 pl-8 text-[12.5px]"392 aria-label={`Ajouter un modèle au préréglage ${preset.label}`}393 />394 </div>395 {matches.length > 0 && (396 <ul className="absolute z-20 mt-1 w-full overflow-hidden rounded-lg border border-app bg-card shadow-lg">397 {matches.map((m) => (398 <li key={m.id}>399 <button400 type="button"401 onClick={() => {402 onChange((p) => ({ ...p, models: [...p.models, m.id] }));403 setQuery("");404 }}405 className="flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-[12.5px] hover:bg-surface-2 dark:hover:bg-brand-900/40"406 >407 <span className="truncate font-medium text-fg">{m.name}</span>408 <span className="shrink-0 font-mono text-[10.5px] text-muted">{m.id}</span>409 </button>410 </li>411 ))}412 </ul>413 )}414 </div>415 )}416 </Card>417 );418}419