SPB Git forge

spb/immbot-ai

Public
1commits 1branches 0releases
1.5 MBsize
maindefault branch
20 days agolast push
TypeScript 98.3% CSS 0.9% Shell 0.7%
17.9 KB · 419 lines tsx
Raw Blame History
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