"use client"; import * as React from "react"; import { Check, Columns3, Loader2, Play, Square, X } from "lucide-react"; import { useApp } from "@/components/app/store"; import { api, streamEvents, ClientApiError } from "@/lib/client/api"; import type { ChatAdoptResponse, PolyModel, PolyProviderErrorShape } from "@/lib/client/types"; import type { ChatSettings } from "./model-config"; import { ModelSelector } from "./model-selector"; import { MessageError } from "./message-error"; import { Markdown } from "@/components/markdown/markdown"; import { ProviderIcon } from "@/components/brand/provider-icon"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/input"; import { Tooltip } from "@/components/ui/tooltip"; import { toast } from "@/components/ui/toast"; import { useIsMobile, useSnapCarousel } from "@/lib/client/hooks"; import { PROVIDERS } from "@/lib/client/providers"; import { cn, formatMs, formatTokens, formatUsd } from "@/lib/utils"; /** Wire shape of an Arena response row (subset we need). */ interface ArenaResponseLite { id: string; modelKey: string; content: string; status: string; usage?: Record | null; latencyMs?: number | null; ttftMs?: number | null; costUsd?: number | null; error?: { code: string; message: string } | null; } type ArenaEvent = | { type: "meta"; responseId: string; modelKey: string } | { type: "text-delta"; text: string } | { type: "reasoning-delta"; text: string } | { type: "citation"; citation: { url?: string; title?: string; snippet?: string } } | { type: "server-tool"; name: string; status: "started" | "completed" } | { type: "error"; error: PolyProviderErrorShape } | { type: "done"; response: ArenaResponseLite; status: "complete" | "stopped" | "error" }; type Status = "waiting" | "thinking" | "streaming" | "done" | "error" | "stopped"; interface Col { modelKey: string; responseId: string | null; status: Status; text: string; reasoning: string; response: ArenaResponseLite | null; error: PolyProviderErrorShape | { code: string; message: string } | null; errorAt?: number; startedAt: number; firstTokenAt?: number; } export interface CompareInlineProps { prompt: string; /** Editable prompt (new chat / empty state). */ onPromptChange?: (p: string) => void; systemPrompt?: string | null; settings?: ChatSettings; attachmentIds?: string[]; initialModelKeys: string[]; conversationId?: string | null; projectId?: string | null; onClose: () => void; /** Called after "Continue with " succeeded. */ onAdopted: (res: ChatAdoptResponse, modelKey: string) => void; } const MAX = 4; const MIN = 2; function cleanSettings(s: ChatSettings | undefined): ChatSettings | undefined { if (!s) return undefined; const { tools: _tools, ...rest } = s; void _tools; const out = Object.fromEntries(Object.entries(rest).filter(([, v]) => v !== undefined && v !== null && !(Array.isArray(v) && v.length === 0))) as ChatSettings; return Object.keys(out).length ? out : undefined; } function emptyCol(modelKey: string): Col { return { modelKey, responseId: null, status: "waiting", text: "", reasoning: "", response: null, error: null, startedAt: Date.now() }; } /** * Inline "Compare with…" — 2–4 models answer the same prompt inside the chat. Runs through the Arena API * (so the comparison is stored as an Arena session) and lets the user continue the conversation with any * answer via POST /api/chat/adopt. */ export function CompareInline({ prompt, onPromptChange, systemPrompt, settings, attachmentIds, initialModelKeys, conversationId, projectId, onClose, onAdopted }: CompareInlineProps) { const { modelsByKey, connectedProviders, preferences } = useApp(); const isMobile = useIsMobile(); const [selected, setSelected] = React.useState(() => initialModelKeys.filter((k) => modelsByKey.has(k)).slice(0, MAX)); const [columns, setColumns] = React.useState([]); const [running, setRunning] = React.useState(false); const [adopting, setAdopting] = React.useState(null); const mapRef = React.useRef(new Map()); const timer = React.useRef | null>(null); const abortRef = React.useRef(null); const { ref: rowRef, index, scrollTo } = useSnapCarousel(columns.length); const flush = React.useCallback(() => { timer.current = null; setColumns([...mapRef.current.values()].map((c) => ({ ...c }))); }, []); const schedule = React.useCallback(() => { if (!timer.current) timer.current = setTimeout(flush, 40); }, [flush]); React.useEffect( () => () => { if (timer.current) clearTimeout(timer.current); abortRef.current?.abort(); }, [], ); const selectedSet = React.useMemo(() => new Set(selected), [selected]); const toggle = (key: string) => setSelected((s) => { if (s.includes(key)) return s.filter((k) => k !== key); if (s.length >= MAX) { toast.warning(`Up to ${MAX} models`, "Remove one before adding another."); return s; } return [...s, key]; }); const disconnected = selected.map((k) => modelsByKey.get(k)).filter((m): m is PolyModel => Boolean(m) && !connectedProviders.has(m!.provider)); const canRun = !running && selected.length >= MIN && prompt.trim().length > 0 && disconnected.length === 0; const reason = running ? null : selected.length < MIN ? `Pick at least ${MIN} models` : !prompt.trim() ? "Write a prompt first" : disconnected.length ? `${disconnected.map((m) => PROVIDERS[m.provider].shortName).join(", ")} not connected` : null; const streamOne = async (sessionId: string, modelKey: string, signal: AbortSignal) => { const col = () => mapRef.current.get(modelKey); try { await streamEvents( "/api/arena/stream", { sessionId, modelKey }, (raw) => { const c = col(); if (!c) return; const ev = raw as unknown as ArenaEvent; switch (ev.type) { case "meta": c.responseId = ev.responseId; break; case "text-delta": if (!c.firstTokenAt) c.firstTokenAt = Date.now(); c.text += ev.text; c.status = "streaming"; break; case "reasoning-delta": if (!c.firstTokenAt) c.firstTokenAt = Date.now(); c.reasoning += ev.text; if (c.status === "waiting") c.status = "thinking"; break; case "error": c.error = ev.error; c.errorAt = Date.now(); break; case "done": c.response = ev.response; c.responseId = ev.response.id; c.status = ev.status === "complete" ? "done" : ev.status === "stopped" ? "stopped" : "error"; if (ev.response.error && !c.error) { c.error = ev.response.error; c.errorAt = Date.now(); } break; default: break; } schedule(); }, signal, ); const c = col(); if (c && (c.status === "waiting" || c.status === "thinking" || c.status === "streaming")) c.status = c.error ? "error" : "stopped"; } catch (e) { const c = col(); if (!c) return; if ((e as Error).name === "AbortError") c.status = "stopped"; else { const err = e as ClientApiError; c.status = "error"; c.error = { code: err.code ?? "HTTP_ERROR", message: err.message }; c.errorAt = Date.now(); } } flush(); }; const run = async () => { if (!canRun) return; setRunning(true); const keys = [...selected]; mapRef.current = new Map(keys.map((k) => [k, emptyCol(k)])); flush(); const controller = new AbortController(); abortRef.current = controller; try { const res = await api<{ session: { id: string } }>("/api/arena", { method: "POST", json: { prompt: prompt.trim(), systemPrompt: systemPrompt?.trim() || undefined, modelKeys: keys, settings: cleanSettings(settings), attachmentIds: attachmentIds?.length ? attachmentIds.slice(0, 6) : undefined }, }); await Promise.allSettled(keys.map((k) => streamOne(res.session.id, k, controller.signal))); } catch (e) { const err = e as ClientApiError; toast.error("Could not start the comparison", err.message); mapRef.current = new Map(); flush(); } finally { setRunning(false); abortRef.current = null; } }; const stop = () => abortRef.current?.abort(); const adopt = async (c: Col) => { if (!c.responseId && !c.text) return; setAdopting(c.modelKey); try { const res = await api("/api/chat/adopt", { method: "POST", json: { conversationId: conversationId ?? undefined, modelKey: c.modelKey, arenaResponseId: c.responseId ?? undefined, content: c.responseId ? undefined : c.text, userText: conversationId ? undefined : prompt.trim(), systemPrompt: conversationId ? undefined : systemPrompt ?? null, settings: conversationId ? undefined : cleanSettings(settings), projectId: conversationId ? undefined : projectId ?? null }, }); onAdopted(res, c.modelKey); } catch (e) { toast.error("Could not continue with this answer", (e as Error).message); } finally { setAdopting(null); } }; const allSettled = columns.length > 0 && columns.every((c) => c.status === "done" || c.status === "error" || c.status === "stopped"); return (
{/* Header */}

Compare with…

{selected.length}/{MAX} models
{running ? ( ) : ( )}
{/* Setup */}
{selected.map((k) => { const m = modelsByKey.get(k); return ( {m?.displayName ?? k} ); })} {!running ? : null}
{onPromptChange ? (