"use client"; import { useMemo, useRef, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; import { ChevronDown, Play, Square, Columns3, Diff } from "lucide-react"; import { api, sse } from "@/lib/client/api"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/input"; import { Spinner } from "@/components/ui/primitives"; import { toast } from "@/components/ui/toast"; import { cn } from "@/lib/utils"; import type { ModelsPayload } from "@/components/ai/models/format"; import { ModelPicker, MIN_MODELS, MAX_MODELS } from "./ModelPicker"; import { ResultColumn, type CompareResult } from "./ResultColumn"; import { History, type HistoryItem } from "./History"; export function ComparePanel() { const qc = useQueryClient(); const router = useRouter(); const [prompt, setPrompt] = useState(""); const [system, setSystem] = useState(""); const [showSystem, setShowSystem] = useState(false); const [selected, setSelected] = useState(["claude-haiku-5-5", "gpt-5.4-mini"]); const [results, setResults] = useState([]); const [running, setRunning] = useState(false); const [savedId, setSavedId] = useState(null); const [diff, setDiff] = useState(false); const [historyOpen, setHistoryOpen] = useState(false); const abort = useRef(null); const { data: catalogue } = useQuery({ queryKey: ["ai-models", "all"], queryFn: () => api("/api/ai/models?all=1"), staleTime: 60_000 }); const { data: history = [] } = useQuery({ queryKey: ["compare-history"], queryFn: () => api("/api/ai/compare"), staleTime: 10_000 }); const { data: voteData } = useQuery<{ votes: Record; tally: Record }>({ queryKey: ["compare-votes"], queryFn: () => api("/api/ai/compare/vote"), staleTime: 10_000 }); const byId = useMemo(() => new Map((catalogue?.models ?? []).map((m) => [m.id, m])), [catalogue]); const best = savedId ? voteData?.votes?.[savedId]?.model ?? null : null; const canRun = prompt.trim().length > 0 && selected.length >= MIN_MODELS && selected.length <= MAX_MODELS && !running; async function run() { if (!canRun) return; setRunning(true); setSavedId(null); setDiff(false); setResults(selected.map((model) => ({ model, provider: byId.get(model)?.provider, pending: true }))); const ac = new AbortController(); abort.current = ac; await sse("/api/ai/compare", { prompt: prompt.trim(), models: selected, ...(system.trim() ? { system: system.trim() } : {}) }, (ev) => { if (ev.type === "result") setResults((rs) => rs.map((r) => (r.model === ev.model ? { ...ev, pending: false } : r))); else if (ev.type === "saved") { setSavedId(ev.id); qc.invalidateQueries({ queryKey: ["compare-history"] }); } else if (ev.type === "error") toast("Comparison error", { description: ev.message, tone: "danger" }); }, ac.signal).catch(() => {}); setResults((rs) => rs.map((r) => (r.pending ? { ...r, pending: false, error: ac.signal.aborted ? "Stopped" : "No result received" } : r))); setRunning(false); qc.invalidateQueries({ queryKey: ["ai-usage"] }); } async function open(id: string) { try { const c = await api(`/api/ai/compare?id=${id}`); if (!c) return; setPrompt(c.prompt); setSelected(c.models); setResults((c.results as any[]).map((r) => ({ ...r, pending: false }))); setSavedId(c.id); setHistoryOpen(false); setDiff(false); window.scrollTo({ top: 0 }); } catch (e: any) { toast("Could not open comparison", { description: e.message, tone: "danger" }); } } async function vote(model: string) { if (!savedId) return; const next = best === model ? null : model; try { await api("/api/ai/compare/vote", { method: "POST", json: { comparisonId: savedId, model: next } }); qc.invalidateQueries({ queryKey: ["compare-votes"] }); toast(next ? `${model} marked as best` : "Vote removed", { tone: "success" }); } catch (e: any) { toast("Vote failed", { description: e.message, tone: "danger" }); } } async function saveNote(r: CompareResult) { try { const { markdownToDoc } = await import("@/lib/editor/markdown-client"); const title = `${prompt.trim().split("\n")[0].slice(0, 60) || "Comparison"} — ${r.model}`; const body = `> [!AI] ${r.model}\n> Prompt: ${prompt.trim().slice(0, 300).replace(/\n/g, " ")}\n\n${r.text ?? ""}`; const p = await api("/api/pages", { method: "POST", json: { title, content: { type: "doc", content: markdownToDoc(body) }, properties: { ai_model: r.model, ai_cost_usd: r.costUsd ?? null, ai_comparison: savedId } } }); qc.invalidateQueries({ queryKey: ["tree"] }); toast("Saved as a note", { tone: "success" }); router.push(`/p/${p.id}`); } catch (e: any) { toast("Could not save", { description: e.message, tone: "danger" }); } } const tally = voteData?.tally ?? {}; return (

Compare models

Same prompt, {MIN_MODELS}–{MAX_MODELS} models side by side. Every run is recorded in usage and saved to history.

{historyOpen &&
}