SPB Git
21.8 KB · 209 lines tsx
1"use client";2import { useEffect, useMemo, useRef, useState } from "react";3import { useRouter, useSearchParams } from "next/navigation";4import useSWR from "swr";5import { Columns2, X, Play, Square, History, Trophy, ArrowRight, Scale, AlertTriangle, Brain, ChevronDown, Code2, Plus } from "lucide-react";6import { useModels, shortName } from "@/lib/client/use-models";7import type { AIModel } from "@/lib/client/types";8import { api, fetcher, readSSE, fmtMs, fmtTokens, fmtUsd, relTime } from "@/lib/client/fetcher";9import { cn, Badge, Button, toast, Spinner, Toggle, Dialog } from "@/components/ui";10import { Markdown } from "@/components/chat/markdown";1112interface Result { modelId: string; provider?: string; text: string; reasoning?: string; usage?: { input: number; output: number; cached?: number; reasoning?: number } | null; costUsd?: number | null; latencyMs?: number | null; ttftMs?: number | null; error?: string | null; done: boolean }13interface Judge { judgeModel: string; text: string; mapping: Record<string, string>; costUsd: number | null; disclaimer: string; at: string }14interface HistoryItem { id: string; prompt: string; model_ids: string[]; preferred_model: string | null; created_at: string; cost: number | null }15type Effort = "low" | "medium" | "high" | "xhigh" | "max";1617const blended = (m: AIModel) => (m.pricing?.input != null && m.pricing?.output != null ? (3 * m.pricing.input + m.pricing.output) / 4 : null);18const firstCode = (t: string) => { const m = t.match(/```([\w+-]+)?\n([\s\S]*?)```/); return m ? { lang: m[1] ?? "", code: m[2] } : null; };1920export function CompareView() {21  const { chat, byId, isLoading } = useModels(); const router = useRouter(); const sp = useSearchParams();22  const [selected, setSelected] = useState<string[]>([]); const [prompt, setPrompt] = useState(sp.get("prompt") ?? ""); const [system, setSystem] = useState(""); const [showSystem, setShowSystem] = useState(false); const [effort, setEffort] = useState<Effort | "">("");23  const [results, setResults] = useState<Record<string, Result>>({}); const [order, setOrder] = useState<string[]>([]); const [running, setRunning] = useState(false); const [cmpId, setCmpId] = useState<string | null>(null); const [preferred, setPreferred] = useState<string | null>(null); const [readOnly, setReadOnly] = useState(false);24  const [judge, setJudge] = useState<Judge | null>(null); const [judgeModel, setJudgeModel] = useState<string>(""); const [judging, setJudging] = useState(false);25  const [codeSide, setCodeSide] = useState(false); const [histOpen, setHistOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false); const [pickerQ, setPickerQ] = useState("");26  const abortRef = useRef<AbortController | null>(null);27  const { data: hist, mutate: mutateHist } = useSWR<{ comparisons: HistoryItem[] }>("/api/compare", fetcher);28  const initDone = useRef(false);2930  useEffect(() => {31    if (initDone.current || !chat.length) return; initDone.current = true;32    const fromUrl = (sp.get("models") ?? "").split(",").filter(id => chat.some(m => m.id === id));33    setSelected(fromUrl.length ? fromUrl.slice(0, 6) : [...chat.filter(m => m.favorite).slice(0, 3).map(m => m.id)]);34    const anth = chat.filter(m => m.provider === "anthropic" && m.kind === "chat").sort((a, b) => (blended(b) ?? -1) - (blended(a) ?? -1));35    setJudgeModel(anth[0]?.id ?? chat[0]?.id ?? "");36  }, [chat, sp]);3738  const items = useMemo(() => order.map(id => results[id]).filter(Boolean), [order, results]);39  const codeItems = useMemo(() => items.map(r => ({ r, c: firstCode(r.text) })).filter(x => x.c), [items]);40  const canRun = selected.length >= 2 && selected.length <= 6 && prompt.trim().length > 0 && !running;4142  async function run() {43    if (!canRun) return;44    const ctrl = new AbortController(); abortRef.current = ctrl;45    setRunning(true); setReadOnly(false); setJudge(null); setPreferred(null); setCmpId(null); setCodeSide(false);46    const init: Record<string, Result> = {}; for (const id of selected) init[id] = { modelId: id, provider: byId(id)?.provider, text: "", done: false };47    setResults(init); setOrder([...selected]);48    const bufs: Record<string, { text: string; reasoning: string }> = {}; let raf = 0;49    const flush = () => { raf = 0; setResults(prev => { const n = { ...prev }; for (const [id, b] of Object.entries(bufs)) if (n[id]) n[id] = { ...n[id], text: b.text, reasoning: b.reasoning || n[id].reasoning }; return n; }); };50    const schedule = () => { if (!raf) raf = requestAnimationFrame(flush); };51    try {52      const res = await fetch("/api/compare", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ prompt, modelIds: selected, system: system.trim() || undefined, settings: effort ? { effort } : undefined }), signal: ctrl.signal, credentials: "same-origin" });53      for await (const { event, data } of readSSE(res)) {54        const d = data as Record<string, unknown>;55        if (event === "start") setCmpId(d.id as string);56        else if (event === "text") { const id = d.model as string; (bufs[id] ??= { text: "", reasoning: "" }).text += d.delta as string; schedule(); }57        else if (event === "reasoning") { const id = d.model as string; (bufs[id] ??= { text: "", reasoning: "" }).reasoning += d.delta as string; schedule(); }58        else if (event === "model_error") setResults(p => ({ ...p, [d.model as string]: { ...p[d.model as string], error: d.message as string, done: true } }));59        else if (event === "model_done") { const r = d as unknown as Result; flush(); setResults(p => ({ ...p, [r.modelId]: { ...p[r.modelId], ...r, text: r.text || p[r.modelId]?.text || "", done: true } })); }60        else if (event === "done") { setCmpId(d.id as string); void mutateHist(); }61        else if (event === "error") toast(String(d.message), "error");62      }63    } catch (e) { if ((e as Error).name !== "AbortError") toast((e as Error).message, "error"); }64    finally { if (raf) cancelAnimationFrame(raf); flush(); setRunning(false); abortRef.current = null; setResults(p => { const n = { ...p }; for (const k of Object.keys(n)) n[k] = { ...n[k], done: true }; return n; }); }65  }66  function stop() { abortRef.current?.abort(); }67  async function prefer(id: string) { setPreferred(id); if (cmpId) { try { await api("/api/compare", { method: "PATCH", json: { id: cmpId, preferredModel: id } }); void mutateHist(); toast(`${shortName(byId(id), id)} marqué comme réponse préférée`, "ok"); } catch (e) { toast((e as Error).message, "error"); } } }68  async function continueWith(id: string) {69    try {70      const r = await api<{ conversation: { id: string } }>("/api/conversations", { method: "POST", json: { modelId: id, title: prompt.trim().slice(0, 60) || "Suite de comparaison" } });71      void fetch("/api/chat", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ conversationId: r.conversation.id, text: prompt, modelId: id, routingMode: "manual" }), credentials: "same-origin", keepalive: true });72      router.push(`/c/${r.conversation.id}`);73    } catch (e) { toast((e as Error).message, "error"); }74  }75  async function runJudge() {76    if (!cmpId || !judgeModel) return; setJudging(true);77    try { const r = await api<{ judge: Judge }>("/api/compare/judge", { method: "POST", json: { id: cmpId, judgeModel } }); setJudge(r.judge); } catch (e) { toast((e as Error).message, "error"); } finally { setJudging(false); }78  }79  async function loadHistory(id: string) {80    try {81      const r = await api<{ comparison: { id: string; prompt: string; model_ids: string[]; results: Result[]; judge: Judge | null; preferred_model: string | null } | null }>(`/api/compare?id=${id}`);82      if (!r.comparison) return; const c = r.comparison;83      setPrompt(c.prompt); setSelected(c.model_ids.filter(m => chat.some(x => x.id === m)).slice(0, 6)); setCmpId(c.id); setPreferred(c.preferred_model); setJudge(c.judge); setReadOnly(true); setCodeSide(false);84      const rs: Record<string, Result> = {}; for (const x of c.results) rs[x.modelId] = { ...x, done: true }; setResults(rs); setOrder(c.results.map(x => x.modelId)); setHistOpen(false);85    } catch (e) { toast((e as Error).message, "error"); }86  }87  const pickerList = chat.filter(m => !selected.includes(m.id) && (!pickerQ || m.id.includes(pickerQ.toLowerCase()) || m.displayName.toLowerCase().includes(pickerQ.toLowerCase())));88  const cols = items.length <= 1 ? "grid-cols-1" : items.length === 2 ? "md:grid-cols-2" : "md:grid-cols-2 xl:grid-cols-3";89  const labelOf = (id: string) => Object.entries(judge?.mapping ?? {}).find(([, m]) => m === id)?.[0];9091  return (92    <div className="flex min-h-0 flex-1 flex-col overflow-y-auto">93      <div className="mx-auto w-full max-w-7xl px-4 py-6 md:px-6">94        <div className="mb-5 flex flex-wrap items-start justify-between gap-3">95          <div><h1 className="flex items-center gap-2 text-xl font-semibold tracking-tight"><Columns2 className="h-5 w-5 text-accent" /> Comparaison multi-modèles</h1><p className="mt-1 text-sm text-muted">Même invite envoyée simultanément à 2 à 6 modèles vérifiés ; réponses, latence, jetons et coût côte à côte.</p></div>96          <Button variant="outline" onClick={() => setHistOpen(true)}><History className="h-4 w-4" /> Historique{hist?.comparisons.length ? ` (${hist.comparisons.length})` : ""}</Button>97        </div>9899        {/* setup */}100        <div className="card mb-5 p-4">101          <div className="mb-2 flex flex-wrap items-center gap-1.5">102            <span className="mr-1 text-[12px] font-medium uppercase tracking-wider text-muted">Modèles</span>103            {selected.map(id => { const m = byId(id); return <span key={id} className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-surface-2 px-2 py-1 text-[13px]"><span className={cn("h-2 w-2 rounded-full", m?.provider === "anthropic" ? "bg-accent-2" : "bg-accent")} />{shortName(m, id)}{!readOnly && <button onClick={() => setSelected(s => s.filter(x => x !== id))} className="text-muted hover:text-fg" aria-label="Retirer"><X className="h-3 w-3" /></button>}</span>; })}104            {!readOnly && selected.length < 6 && <button onClick={() => setPickerOpen(true)} className="inline-flex items-center gap-1 rounded-lg border border-dashed border-border-strong px-2 py-1 text-[13px] text-fg-2 hover:bg-surface-2 hover:text-fg"><Plus className="h-3.5 w-3.5" /> Ajouter</button>}105            {isLoading && <Spinner />}106            <span className="ml-auto text-[11px] text-muted">{selected.length}/6 · minimum 2</span>107          </div>108          <textarea value={prompt} onChange={e => setPrompt(e.target.value)} readOnly={readOnly} rows={4} placeholder="Invite commune à tous les modèles…" className="input mt-2 text-[15px]" onKeyDown={e => { if ((e.metaKey || e.ctrlKey) && e.key === "Enter") run(); }} />109          {showSystem && <textarea value={system} onChange={e => setSystem(e.target.value)} readOnly={readOnly} rows={3} placeholder="Instructions système optionnelles (ajoutées au contexte de chaque modèle)" className="input mt-2 text-[13px]" />}110          <div className="mt-2 flex flex-wrap items-center gap-3 text-[13px]">111            <button onClick={() => setShowSystem(s => !s)} className="text-fg-2 hover:text-fg">{showSystem ? "Masquer" : "Instructions système"}</button>112            <label className="flex items-center gap-2 text-fg-2">Effort <select value={effort} onChange={e => setEffort(e.target.value as Effort | "")} disabled={readOnly} className="input !w-auto !py-1 !px-2 text-[12px]"><option value="">par défaut</option><option value="low">faible</option><option value="medium">moyen</option><option value="high">élevé</option><option value="xhigh">très élevé</option><option value="max">maximal</option></select></label>113            <div className="flex-1" />114            {readOnly && <Button variant="ghost" onClick={() => { setReadOnly(false); setCmpId(null); setJudge(null); }}>Modifier et relancer</Button>}115            {running ? <Button variant="danger" onClick={stop}><Square className="h-3.5 w-3.5 fill-current" /> Arrêter</Button> : <Button variant="primary" onClick={run} disabled={!canRun || readOnly}><Play className="h-4 w-4" /> Lancer <span className="ml-1 hidden text-[11px] opacity-70 sm:inline">⌘↵</span></Button>}116          </div>117        </div>118119        {/* results */}120        {items.length > 0 && (121          <>122            <div className="mb-3 flex flex-wrap items-center gap-3 text-[13px]">123              <span className="text-muted">{items.filter(r => r.done).length}/{items.length} terminés{cmpId ? ` · ${fmtUsd(items.reduce((s, r) => s + (r.costUsd ?? 0), 0))} total` : ""}</span>124              {codeItems.length >= 2 && <label className="flex items-center gap-2 text-fg-2"><Code2 className="h-3.5 w-3.5" /> Code côte à côte <Toggle checked={codeSide} onChange={setCodeSide} /></label>}125            </div>126            <div className={cn("grid gap-3", cols)}>127              {items.map(r => { const m = byId(r.modelId); const lbl = labelOf(r.modelId); const code = codeSide ? firstCode(r.text) : null; return (128                <div key={r.modelId} className={cn("card flex min-h-[280px] flex-col overflow-hidden", preferred === r.modelId && "ring-1 ring-accent-2/60")}>129                  <div className="flex items-center gap-2 border-b border-border px-3 py-2">130                    <span className={cn("h-2 w-2 rounded-full", m?.provider === "anthropic" ? "bg-accent-2" : "bg-accent")} />131                    <span className="min-w-0 flex-1 truncate text-[13px] font-medium">{shortName(m, r.modelId)}</span>132                    {lbl && <Badge tone="neutral">réponse {lbl}</Badge>}133                    {preferred === r.modelId && <Badge tone="amber"><Trophy className="h-3 w-3" /> préférée</Badge>}134                    {!r.done && <Spinner />}135                  </div>136                  <div className="min-h-0 flex-1 overflow-auto px-4 py-3" style={{ maxHeight: "60vh" }}>137                    {r.reasoning && <Reasoning text={r.reasoning} live={!r.done && !r.text} />}138                    {r.error && <div className="flex items-start gap-2 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-[13px] text-danger"><AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />{r.error}</div>}139                    {code ? <div className="prose-ai"><div className="code-head"><span>{code.lang || "code"}</span></div><pre><code>{code.code}</code></pre></div> : r.text ? <Markdown text={r.text} /> : !r.done && !r.reasoning ? <div className="flex items-center gap-1 py-2"><span className="typing-dot" /><span className="typing-dot" /><span className="typing-dot" /></div> : !r.error && r.done && !r.text ? <div className="text-[13px] text-muted">Réponse vide.</div> : null}140                  </div>141                  <div className="border-t border-border px-3 py-2">142                    <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] tabular-nums text-muted">143                      <span title="Latence totale">{fmtMs(r.latencyMs)}</span>{r.ttftMs != null && <span title="Premier jeton">1er jeton {fmtMs(r.ttftMs)}</span>}144                      {r.usage && <span title={`entrée ${r.usage.input} · sortie ${r.usage.output}${r.usage.reasoning ? ` · raisonnement ${r.usage.reasoning}` : ""}`}>{fmtTokens(r.usage.input)} → {fmtTokens(r.usage.output)}</span>}145                      <span className={cn(r.costUsd == null && r.done && "italic")} title="Coût estimé d'après les prix vérifiés">{r.done ? (r.costUsd != null ? fmtUsd(r.costUsd) : "coût inconnu") : "…"}</span>146                    </div>147                    {r.done && !r.error && <div className="mt-1.5 flex items-center gap-1">148                      <button onClick={() => prefer(r.modelId)} disabled={preferred === r.modelId} className="inline-flex items-center gap-1 rounded-md px-1.5 py-1 text-[12px] text-fg-2 hover:bg-surface-2 hover:text-fg disabled:opacity-50"><Trophy className="h-3.5 w-3.5" /> Préférer</button>149                      <button onClick={() => continueWith(r.modelId)} className="inline-flex items-center gap-1 rounded-md px-1.5 py-1 text-[12px] text-fg-2 hover:bg-surface-2 hover:text-fg"><ArrowRight className="h-3.5 w-3.5" /> Continuer avec ce modèle</button>150                    </div>}151                  </div>152                </div>); })}153            </div>154155            {/* judge */}156            <div className="card mt-5 p-4">157              <div className="flex flex-wrap items-center gap-3">158                <h2 className="flex items-center gap-2 text-[15px] font-semibold"><Scale className="h-4 w-4 text-accent" /> Évaluation assistée par IA</h2>159                <Badge tone="warn"><AlertTriangle className="h-3 w-3" /> Opinion générée par un modèle, pas une vérité objective</Badge>160                <div className="flex-1" />161                <select value={judgeModel} onChange={e => setJudgeModel(e.target.value)} className="input !w-auto !py-1.5 text-[13px]">{chat.filter(m => m.kind !== "chat-pro").map(m => <option key={m.id} value={m.id}>{m.displayName}</option>)}</select>162                <Button onClick={runJudge} loading={judging} disabled={!cmpId || running || items.filter(r => r.done && !r.error && r.text).length < 2}>{judge ? "Réévaluer" : "Évaluer les réponses"}</Button>163              </div>164              {!cmpId && !running && <p className="mt-2 text-[12px] text-muted">L'évaluation est disponible une fois la comparaison enregistrée.</p>}165              {judge && <div className="mt-4 border-t border-border pt-4">166                <div className="mb-2 flex flex-wrap gap-2 text-[12px] text-muted"><span>Juge : {shortName(byId(judge.judgeModel), judge.judgeModel)}</span><span>· {fmtUsd(judge.costUsd)}</span><span>· {Object.entries(judge.mapping).map(([l, m]) => `${l} = ${shortName(byId(m), m)}`).join(" · ")}</span></div>167                <div className="rounded-xl border border-warn/30 bg-warn/5 px-4 py-3"><Markdown text={judge.text} /></div>168                <p className="mt-2 text-[12px] text-muted">{judge.disclaimer}</p>169              </div>}170            </div>171          </>172        )}173        {!items.length && !isLoading && <div className="py-12 text-center text-sm text-muted">Choisis au moins deux modèles, écris une invite et lance la comparaison.</div>}174      </div>175176      {/* model picker */}177      <Dialog open={pickerOpen} onClose={() => setPickerOpen(false)} title="Ajouter des modèles" width="max-w-lg">178        <div className="p-3">179          <input autoFocus value={pickerQ} onChange={e => setPickerQ(e.target.value)} placeholder="Filtrer…" className="input mb-2" />180          <div className="max-h-[55dvh] overflow-y-auto">181            {pickerList.map(m => <button key={m.id} onClick={() => { setSelected(s => s.length < 6 ? [...s, m.id] : s); if (selected.length + 1 >= 6) setPickerOpen(false); }} className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left hover:bg-surface">182              <span className={cn("h-2 w-2 rounded-full", m.provider === "anthropic" ? "bg-accent-2" : "bg-accent")} />183              <span className="min-w-0 flex-1"><span className="flex items-center gap-2 text-[13px] font-medium"><span className="truncate">{m.displayName}</span>{m.kind === "chat-pro" && <Badge tone="amber">pro · lent</Badge>}</span><span className="block text-[11px] text-muted">{m.benchmark?.qualityScore != null ? `${m.benchmark.qualityScore} % · ` : ""}{m.benchmark?.p50LatencyMs != null ? `${m.benchmark.p50LatencyMs} ms · ` : ""}{m.pricing?.input != null && m.pricing?.output != null ? `${m.pricing.input} $ / ${m.pricing.output} $ par M` : "prix non vérifié"}</span></span>184            </button>)}185            {!pickerList.length && <div className="py-6 text-center text-sm text-muted">Aucun autre modèle vérifié.</div>}186          </div>187        </div>188      </Dialog>189190      {/* history drawer */}191      {histOpen && <div className="fixed inset-0 z-50" onClick={() => setHistOpen(false)}><div className="absolute inset-0 bg-black/55" /><aside onClick={e => e.stopPropagation()} className="absolute inset-y-0 right-0 flex w-[92vw] max-w-md flex-col border-l border-border bg-bg-elev shadow-soft anim-fade-up">192        <div className="flex items-center justify-between border-b border-border px-4 py-3"><h2 className="flex items-center gap-2 text-[15px] font-semibold"><History className="h-4 w-4" /> Comparaisons passées</h2><button onClick={() => setHistOpen(false)} className="rounded-lg p-1.5 text-fg-2 hover:bg-surface-2"><X className="h-4 w-4" /></button></div>193        <div className="min-h-0 flex-1 overflow-y-auto p-2">194          {!hist?.comparisons.length && <div className="py-10 text-center text-sm text-muted">Aucune comparaison enregistrée.</div>}195          {hist?.comparisons.map(h => <button key={h.id} onClick={() => loadHistory(h.id)} className={cn("mb-1 flex w-full flex-col gap-1 rounded-xl px-3 py-2.5 text-left hover:bg-surface", h.id === cmpId && "bg-surface-2")}>196            <span className="truncate-2 text-[13px]">{h.prompt}</span>197            <span className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] text-muted"><span>{relTime(h.created_at)}</span><span>· {h.model_ids.length} modèles</span>{h.cost != null && <span>· {fmtUsd(Number(h.cost))}</span>}{h.preferred_model && <span className="inline-flex items-center gap-1 text-accent-2"><Trophy className="h-3 w-3" />{shortName(byId(h.preferred_model), h.preferred_model)}</span>}</span>198          </button>)}199        </div>200      </aside></div>}201    </div>202  );203}204205function Reasoning({ text, live }: { text: string; live: boolean }) {206  const [open, setOpen] = useState(false);207  return <div className="mb-2"><button onClick={() => setOpen(o => !o)} className="inline-flex items-center gap-1.5 text-[12px] text-muted hover:text-fg"><Brain className="h-3.5 w-3.5" /> Raisonnement {live ? "en cours…" : "résumé"} <ChevronDown className={cn("h-3 w-3 transition-transform", (open || live) && "rotate-180")} /></button>{(open || live) && <div className="mt-1.5 max-h-48 overflow-y-auto whitespace-pre-wrap rounded-xl border border-border bg-surface-2/50 px-3 py-2 text-[12.5px] leading-relaxed text-fg-2">{text}</div>}</div>;208}209
Files · main
Menu

Theme