spb/chat-spboucher-ai
Public
1"use client";2import { useRef, useState } from "react";3import Link from "next/link";4import useSWR from "swr";5import { Image as ImageIcon, Download, Wand2, MessageSquare, Sparkles, X } from "lucide-react";6import { api, fetcher, fmtUsd, relTime } from "@/lib/client/fetcher";7import { useModels } from "@/lib/client/use-models";8import { Button, Empty, Spinner, cn, toast, Badge } from "../ui";910interface GenImage { id: string; filename: string; meta: { prompt?: string; model?: string; revisedPrompt?: string; size?: string; quality?: string; editedFrom?: string }; conversation_id: string | null; created_at: string }11const SIZES = ["1024x1024", "1536x1024", "1024x1536"]; const QUALITIES = [["low", "basse"], ["medium", "moyenne"], ["high", "haute"]];1213export function ImageStudio() {14 const { models } = useModels(true);15 const imageModels = models.filter(m => m.kind === "image" && m.available);16 const { data, mutate } = useSWR<{ images: GenImage[] }>("/api/images", fetcher);17 const [prompt, setPrompt] = useState(""); const [model, setModel] = useState(""); const [size, setSize] = useState(SIZES[0]); const [quality, setQuality] = useState("medium"); const [edit, setEdit] = useState<GenImage | null>(null);18 const [busy, setBusy] = useState(false); const [result, setResult] = useState<{ attachmentId: string; model: string; costUsd?: number | null } | null>(null);19 const ta = useRef<HTMLTextAreaElement>(null);20 const chosen = model || (imageModels.find(m => m.id === "gpt-image-1.5")?.id ?? imageModels[0]?.id ?? "");21 async function generate() {22 setBusy(true); setResult(null);23 try { const r = await api<{ attachmentId: string; model: string; costUsd?: number | null; error?: string }>("/api/images", { method: "POST", json: { prompt: prompt.trim(), model: chosen || undefined, size, quality, editAttachmentId: edit?.id } }); if (r.error) throw new Error(r.error); setResult(r); setEdit(null); void mutate(); toast(edit ? "Image modifiée" : "Image générée", "ok"); }24 catch (e) { toast((e as Error).message, "error"); } finally { setBusy(false); }25 }26 function startEdit(img: GenImage) { setEdit(img); setPrompt(""); ta.current?.focus(); window.scrollTo({ top: 0, behavior: "smooth" }); }27 return (28 <div className="mx-auto w-full max-w-5xl px-4 py-6 md:px-8">29 <h1 className="flex items-center gap-2 text-xl font-semibold tracking-tight"><ImageIcon className="h-5 w-5 text-accent-2" /> Images</h1>30 <p className="mt-1 mb-5 text-[13px] text-muted">Génération et retouche avec les modèles image OpenAI vérifiés. Chaque image est conservée dans l'historique ci-dessous.</p>31 <div className="card p-4">32 {edit && <div className="mb-3 flex items-center gap-3 rounded-xl border border-accent/30 bg-accent/10 px-3 py-2 text-[13px]"><img src={`/api/attachments/${edit.id}`} alt="" className="h-12 w-12 rounded-lg object-cover" /><span className="min-w-0 flex-1"><span className="block font-medium">Retouche de l'image sélectionnée</span><span className="block truncate text-muted">{edit.meta.prompt}</span></span><button onClick={() => setEdit(null)} className="rounded p-1 text-muted hover:text-fg"><X className="h-4 w-4" /></button></div>}33 <textarea ref={ta} value={prompt} onChange={e => setPrompt(e.target.value)} placeholder={edit ? "Décris la modification à apporter…" : "Décris l'image : sujet, style, composition, éclairage…"} className="input min-h-24 text-[14px]" />34 <div className="mt-3 flex flex-wrap items-center gap-2">35 <select value={chosen} onChange={e => setModel(e.target.value)} className="input !w-auto !py-1.5 text-[13px]">{imageModels.map(m => <option key={m.id} value={m.id}>{m.displayName}</option>)}{!imageModels.length && <option value="">Aucun modèle image vérifié</option>}</select>36 {!edit && <select value={size} onChange={e => setSize(e.target.value)} className="input !w-auto !py-1.5 text-[13px]">{SIZES.map(s => <option key={s} value={s}>{s.replace("x", " × ")}</option>)}</select>}37 {!edit && <div className="inline-flex rounded-lg border border-border bg-surface p-0.5">{QUALITIES.map(([v, l]) => <button key={v} onClick={() => setQuality(v)} className={cn("rounded-md px-2.5 py-1 text-[12px]", quality === v ? "bg-surface-2 text-fg" : "text-muted hover:text-fg")}>{l}</button>)}</div>}38 <span className="flex-1" />39 <Button variant="primary" loading={busy} disabled={!prompt.trim() || !chosen} onClick={generate}><Wand2 className="h-4 w-4" /> {edit ? "Modifier" : "Générer"}</Button>40 </div>41 {busy && <div className="mt-4 skeleton aspect-square w-full max-w-sm rounded-xl" />}42 {result && !busy && <div className="mt-4 anim-fade-up"><a href={`/api/attachments/${result.attachmentId}`} target="_blank" rel="noreferrer"><img src={`/api/attachments/${result.attachmentId}`} alt={prompt} className="max-h-[520px] rounded-xl border border-border" /></a><div className="mt-1.5 flex items-center gap-2 text-[12px] text-muted"><Sparkles className="h-3.5 w-3.5" /> {result.model}{result.costUsd != null && <span>· {fmtUsd(result.costUsd)}</span>}<a href={`/api/attachments/${result.attachmentId}?download=1`} className="ml-auto inline-flex items-center gap-1 hover:text-fg"><Download className="h-3.5 w-3.5" /> Télécharger</a></div></div>}43 </div>4445 <h2 className="mt-8 mb-3 text-[12px] font-semibold uppercase tracking-wider text-muted">Historique · {data?.images.length ?? ""}</h2>46 {!data && <Spinner />}47 {data && !data.images.length && <Empty icon={<ImageIcon className="h-8 w-8" />} title="Aucune image générée" hint="Génère une image ci-dessus ou demande-en une dans une conversation (outil generate_image)." />}48 <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">{data?.images.map(img => (49 <figure key={img.id} className="group card overflow-hidden">50 <a href={`/api/attachments/${img.id}`} target="_blank" rel="noreferrer" className="block aspect-square bg-surface-2"><img src={`/api/attachments/${img.id}`} alt={img.meta.prompt ?? ""} loading="lazy" className="h-full w-full object-cover transition-transform group-hover:scale-[1.02]" /></a>51 <figcaption className="p-2.5">52 <div className="truncate-2 text-[12px] leading-snug" title={img.meta.prompt}>{img.meta.prompt ?? img.filename}</div>53 <div className="mt-1.5 flex flex-wrap items-center gap-1 text-[11px] text-muted">{img.meta.model && <Badge>{img.meta.model}</Badge>}{img.meta.editedFrom && <Badge tone="amber">retouche</Badge>}<span className="ml-auto">{relTime(img.created_at)}</span></div>54 <div className="mt-2 flex items-center gap-1 text-[11px]">55 <button onClick={() => startEdit(img)} className="inline-flex items-center gap-1 rounded-md px-1.5 py-1 text-fg-2 hover:bg-surface-2 hover:text-fg"><Wand2 className="h-3 w-3" /> Modifier</button>56 <a href={`/api/attachments/${img.id}?download=1`} className="inline-flex items-center gap-1 rounded-md px-1.5 py-1 text-fg-2 hover:bg-surface-2 hover:text-fg"><Download className="h-3 w-3" /> Télécharger</a>57 {img.conversation_id && <Link href={`/c/${img.conversation_id}`} className="ml-auto inline-flex items-center gap-1 rounded-md px-1.5 py-1 text-fg-2 hover:bg-surface-2 hover:text-fg" title="Conversation source"><MessageSquare className="h-3 w-3" /></Link>}58 </div>59 </figcaption>60 </figure>))}</div>61 </div>62 );63}64