"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import useSWR from "swr"; import { Columns2, X, Play, Square, History, Trophy, ArrowRight, Scale, AlertTriangle, Brain, ChevronDown, Code2, Plus } from "lucide-react"; import { useModels, shortName } from "@/lib/client/use-models"; import type { AIModel } from "@/lib/client/types"; import { api, fetcher, readSSE, fmtMs, fmtTokens, fmtUsd, relTime } from "@/lib/client/fetcher"; import { cn, Badge, Button, toast, Spinner, Toggle, Dialog } from "@/components/ui"; import { Markdown } from "@/components/chat/markdown"; interface 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 } interface Judge { judgeModel: string; text: string; mapping: Record; costUsd: number | null; disclaimer: string; at: string } interface HistoryItem { id: string; prompt: string; model_ids: string[]; preferred_model: string | null; created_at: string; cost: number | null } type Effort = "low" | "medium" | "high" | "xhigh" | "max"; const blended = (m: AIModel) => (m.pricing?.input != null && m.pricing?.output != null ? (3 * m.pricing.input + m.pricing.output) / 4 : null); const firstCode = (t: string) => { const m = t.match(/```([\w+-]+)?\n([\s\S]*?)```/); return m ? { lang: m[1] ?? "", code: m[2] } : null; }; export function CompareView() { const { chat, byId, isLoading } = useModels(); const router = useRouter(); const sp = useSearchParams(); const [selected, setSelected] = useState([]); const [prompt, setPrompt] = useState(sp.get("prompt") ?? ""); const [system, setSystem] = useState(""); const [showSystem, setShowSystem] = useState(false); const [effort, setEffort] = useState(""); const [results, setResults] = useState>({}); const [order, setOrder] = useState([]); const [running, setRunning] = useState(false); const [cmpId, setCmpId] = useState(null); const [preferred, setPreferred] = useState(null); const [readOnly, setReadOnly] = useState(false); const [judge, setJudge] = useState(null); const [judgeModel, setJudgeModel] = useState(""); const [judging, setJudging] = useState(false); const [codeSide, setCodeSide] = useState(false); const [histOpen, setHistOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false); const [pickerQ, setPickerQ] = useState(""); const abortRef = useRef(null); const { data: hist, mutate: mutateHist } = useSWR<{ comparisons: HistoryItem[] }>("/api/compare", fetcher); const initDone = useRef(false); useEffect(() => { if (initDone.current || !chat.length) return; initDone.current = true; const fromUrl = (sp.get("models") ?? "").split(",").filter(id => chat.some(m => m.id === id)); setSelected(fromUrl.length ? fromUrl.slice(0, 6) : [...chat.filter(m => m.favorite).slice(0, 3).map(m => m.id)]); const anth = chat.filter(m => m.provider === "anthropic" && m.kind === "chat").sort((a, b) => (blended(b) ?? -1) - (blended(a) ?? -1)); setJudgeModel(anth[0]?.id ?? chat[0]?.id ?? ""); }, [chat, sp]); const items = useMemo(() => order.map(id => results[id]).filter(Boolean), [order, results]); const codeItems = useMemo(() => items.map(r => ({ r, c: firstCode(r.text) })).filter(x => x.c), [items]); const canRun = selected.length >= 2 && selected.length <= 6 && prompt.trim().length > 0 && !running; async function run() { if (!canRun) return; const ctrl = new AbortController(); abortRef.current = ctrl; setRunning(true); setReadOnly(false); setJudge(null); setPreferred(null); setCmpId(null); setCodeSide(false); const init: Record = {}; for (const id of selected) init[id] = { modelId: id, provider: byId(id)?.provider, text: "", done: false }; setResults(init); setOrder([...selected]); const bufs: Record = {}; let raf = 0; 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; }); }; const schedule = () => { if (!raf) raf = requestAnimationFrame(flush); }; try { 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" }); for await (const { event, data } of readSSE(res)) { const d = data as Record; if (event === "start") setCmpId(d.id as string); else if (event === "text") { const id = d.model as string; (bufs[id] ??= { text: "", reasoning: "" }).text += d.delta as string; schedule(); } else if (event === "reasoning") { const id = d.model as string; (bufs[id] ??= { text: "", reasoning: "" }).reasoning += d.delta as string; schedule(); } else if (event === "model_error") setResults(p => ({ ...p, [d.model as string]: { ...p[d.model as string], error: d.message as string, done: true } })); 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 } })); } else if (event === "done") { setCmpId(d.id as string); void mutateHist(); } else if (event === "error") toast(String(d.message), "error"); } } catch (e) { if ((e as Error).name !== "AbortError") toast((e as Error).message, "error"); } 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; }); } } function stop() { abortRef.current?.abort(); } 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"); } } } async function continueWith(id: string) { try { const r = await api<{ conversation: { id: string } }>("/api/conversations", { method: "POST", json: { modelId: id, title: prompt.trim().slice(0, 60) || "Suite de comparaison" } }); 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 }); router.push(`/c/${r.conversation.id}`); } catch (e) { toast((e as Error).message, "error"); } } async function runJudge() { if (!cmpId || !judgeModel) return; setJudging(true); 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); } } async function loadHistory(id: string) { try { 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}`); if (!r.comparison) return; const c = r.comparison; 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); const rs: Record = {}; for (const x of c.results) rs[x.modelId] = { ...x, done: true }; setResults(rs); setOrder(c.results.map(x => x.modelId)); setHistOpen(false); } catch (e) { toast((e as Error).message, "error"); } } const pickerList = chat.filter(m => !selected.includes(m.id) && (!pickerQ || m.id.includes(pickerQ.toLowerCase()) || m.displayName.toLowerCase().includes(pickerQ.toLowerCase()))); const cols = items.length <= 1 ? "grid-cols-1" : items.length === 2 ? "md:grid-cols-2" : "md:grid-cols-2 xl:grid-cols-3"; const labelOf = (id: string) => Object.entries(judge?.mapping ?? {}).find(([, m]) => m === id)?.[0]; return (

Comparaison multi-modèles

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.

{/* setup */}
Modèles {selected.map(id => { const m = byId(id); return {shortName(m, id)}{!readOnly && }; })} {!readOnly && selected.length < 6 && } {isLoading && } {selected.length}/6 · minimum 2