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%
10.1 KB · 226 lines tsx
Raw Blame History
1"use client";2// Prompts système : sélection, édition monospace, historique des versions,3// publication d'une nouvelle version avec confirmation.4import { useCallback, useEffect, useState } from "react";5import { FileCode2, Send } from "lucide-react";6import { PageHeader } from "@/components/app-shell";7import { Badge, Button, Card, EmptyState, Modal, Skeleton, Spinner, Textarea, cn } from "@/components/ui";8import { ErrorBanner, SectionTitle, SuccessFlash, fmtDate, fmtInt, postJson, useFetchJson } from "./shared";910type HistoryRow = { id: number; version: number; active: number; created_by: string | null; created_at: string };11type PromptDetail = { name: string; content: string; history: HistoryRow[] };1213const MAX_CHARS = 20_000;14const MIN_CHARS = 10;1516export function AdminPrompts() {17  const { data, error, loading, reload } = useFetchJson<{ names: string[] }>("/api/admin/prompts");18  const [selected, setSelected] = useState<string | null>(null);19  const [detail, setDetail] = useState<PromptDetail | null>(null);20  const [detailLoading, setDetailLoading] = useState(false);21  const [detailError, setDetailError] = useState<string | null>(null);22  const [content, setContent] = useState("");23  const [confirmOpen, setConfirmOpen] = useState(false);24  const [publishing, setPublishing] = useState(false);25  const [publishError, setPublishError] = useState<string | null>(null);26  const [flash, setFlash] = useState<string | null>(null);2728  const loadDetail = useCallback(async (name: string) => {29    setDetailLoading(true);30    setDetailError(null);31    try {32      const res = await fetch(`/api/admin/prompts?name=${encodeURIComponent(name)}`);33      const j = (await res.json().catch(() => null)) as (PromptDetail & { error?: string }) | null;34      if (!res.ok || !j) throw new Error(j?.error || `Erreur ${res.status}`);35      setDetail(j);36      setContent(j.content);37    } catch (e) {38      setDetailError(e instanceof Error ? e.message : "Impossible de charger le prompt.");39    } finally {40      setDetailLoading(false);41    }42  }, []);4344  useEffect(() => {45    if (!selected && data?.names.length) {46      setSelected(data.names[0]);47    }48  }, [data, selected]);4950  useEffect(() => {51    if (selected) loadDetail(selected);52  }, [selected, loadDetail]);5354  const dirty = detail !== null && content !== detail.content;55  const tooShort = content.trim().length < MIN_CHARS;56  const tooLong = content.length > MAX_CHARS;57  const currentVersion = detail?.history.find((h) => h.active)?.version ?? detail?.history[0]?.version ?? 0;5859  async function publish() {60    if (!selected) return;61    setPublishing(true);62    setPublishError(null);63    try {64      const r = await postJson<{ ok: boolean; version: number }>("/api/admin/prompts", { name: selected, content });65      setConfirmOpen(false);66      setFlash(`Version ${r.version} de « ${selected} » publiée — elle s'applique dès les prochaines conversations.`);67      setTimeout(() => setFlash(null), 5000);68      await loadDetail(selected);69    } catch (e) {70      setPublishError(e instanceof Error ? e.message : "La publication a échoué.");71    } finally {72      setPublishing(false);73    }74  }7576  return (77    <div className="animate-fade-up">78      <PageHeader79        title="Prompts système"80        subtitle="Instructions modulaires de l'assistant — versionnées, publication immédiate"81      />8283      {flash && <div className="mb-4"><SuccessFlash message={flash} /></div>}8485      {loading ? (86        <div className="grid gap-4 lg:grid-cols-[220px_1fr]">87          <Skeleton className="h-72" />88          <Skeleton className="h-[480px]" />89        </div>90      ) : error || !data ? (91        <ErrorBanner message={error ?? "Données indisponibles."} onRetry={reload} />92      ) : data.names.length === 0 ? (93        <Card>94          <EmptyState95            icon={<FileCode2 />}96            title="Aucun prompt"97            description="Aucun prompt système n'a été trouvé (dossier prompts/ vide et base de données sans versions)."98          />99        </Card>100      ) : (101        <div className="grid items-start gap-4 lg:grid-cols-[230px_1fr]">102          {/* Liste des prompts */}103          <Card className="p-2 lg:sticky lg:top-16">104            <nav aria-label="Prompts" className="flex gap-1 overflow-x-auto lg:flex-col lg:overflow-visible">105              {data.names.map((name) => (106                <button107                  key={name}108                  type="button"109                  onClick={() => setSelected(name)}110                  className={cn(111                    "whitespace-nowrap rounded-lg px-3 py-2 text-left font-mono text-[12.5px] transition-colors lg:whitespace-normal",112                    selected === name113                      ? "bg-brand-100 font-semibold text-brand-700 dark:bg-brand-900/60 dark:text-brand-200"114                      : "text-muted hover:bg-surface-2 hover:text-fg dark:hover:bg-brand-900/30"115                  )}116                >117                  {name}118                </button>119              ))}120            </nav>121          </Card>122123          {/* Éditeur */}124          <div className="min-w-0 space-y-4">125            {detailLoading ? (126              <Skeleton className="h-[480px]" />127            ) : detailError ? (128              <ErrorBanner message={detailError} onRetry={() => selected && loadDetail(selected)} />129            ) : detail ? (130              <>131                <Card className="p-5">132                  <div className="mb-3 flex flex-wrap items-center justify-between gap-3">133                    <div className="flex items-center gap-2.5">134                      <h2 className="font-mono text-[15px] font-semibold text-fg">{detail.name}</h2>135                      <Badge tone="brand">v{currentVersion}</Badge>136                      {dirty && <Badge tone="amber">Modifications non publiées</Badge>}137                    </div>138                    <Button139                      size="sm"140                      onClick={() => setConfirmOpen(true)}141                      disabled={!dirty || tooShort || tooLong}142                    >143                      <Send size={14} />144                      Publier une nouvelle version145                    </Button>146                  </div>147                  <Textarea148                    value={content}149                    onChange={(e) => setContent(e.target.value)}150                    spellCheck={false}151                    className="min-h-[420px] resize-y font-mono text-[12.5px] leading-relaxed"152                    aria-label={`Contenu du prompt ${detail.name}`}153                  />154                  <div className="mt-2 flex flex-wrap items-center justify-between gap-2 text-[12px]">155                    <span className={cn("tabular-nums", tooLong || tooShort ? "font-medium text-red-600 dark:text-red-400" : "text-muted")}>156                      {fmtInt(content.length)} / {fmtInt(MAX_CHARS)} caractères157                      {tooShort && " — minimum 10 caractères"}158                      {tooLong && " — limite dépassée"}159                    </span>160                    <span className="text-muted">161                      Les changements publiés s'appliquent immédiatement aux prochaines conversations — aucune remise en route requise.162                    </span>163                  </div>164                </Card>165166                <Card className="p-5">167                  <SectionTitle>Historique des versions</SectionTitle>168                  {detail.history.length === 0 ? (169                    <p className="text-sm text-muted">170                      Aucune version en base — le contenu affiché provient du fichier <span className="font-mono">prompts/{detail.name}.md</span>.171                      La première publication créera la version 1.172                    </p>173                  ) : (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="py-2 pr-3 font-medium">Version</th>179                            <th className="py-2 pr-3 font-medium">Date</th>180                            <th className="py-2 pr-3 font-medium">Auteur</th>181                            <th className="py-2 font-medium">Statut</th>182                          </tr>183                        </thead>184                        <tbody>185                          {detail.history.map((h) => (186                            <tr key={h.id} className="border-b border-app last:border-0">187                              <td className="py-2 pr-3 tabular-nums font-medium text-fg">v{h.version}</td>188                              <td className="py-2 pr-3 tabular-nums text-muted">{fmtDate(h.created_at)}</td>189                              <td className="py-2 pr-3 text-muted">{h.created_by ?? "—"}</td>190                              <td className="py-2">{h.active ? <Badge tone="green">Active</Badge> : <Badge>Archivée</Badge>}</td>191                            </tr>192                          ))}193                        </tbody>194                      </table>195                    </div>196                  )}197                </Card>198              </>199            ) : null}200          </div>201        </div>202      )}203204      <Modal open={confirmOpen} onClose={() => (publishing ? null : setConfirmOpen(false))} title="Publier une nouvelle version">205        <p className="text-sm text-fg">206          Publier la version <b>v{currentVersion + 1}</b> du prompt <span className="font-mono">{selected}</span>&nbsp;?207        </p>208        <p className="mt-2 text-[13px] text-muted">209          La nouvelle version devient active immédiatement : toutes les prochaines conversations l'utiliseront.210          Les versions précédentes restent consultables dans l'historique.211        </p>212        {publishError && <div className="mt-3"><ErrorBanner message={publishError} /></div>}213        <div className="mt-5 flex justify-end gap-2">214          <Button variant="secondary" onClick={() => setConfirmOpen(false)} disabled={publishing}>215            Annuler216          </Button>217          <Button onClick={publish} disabled={publishing}>218            {publishing && <Spinner />}219            Publier220          </Button>221        </div>222      </Modal>223    </div>224  );225}226