SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
14 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
18.2 KB · 393 lines tsx
Raw Blame History
1"use client";2import * as React from "react";3import { Check, Columns3, Loader2, Play, Square, X } from "lucide-react";4import { useApp } from "@/components/app/store";5import { api, streamEvents, ClientApiError } from "@/lib/client/api";6import type { ChatAdoptResponse, PolyModel, PolyProviderErrorShape } from "@/lib/client/types";7import type { ChatSettings } from "./model-config";8import { ModelSelector } from "./model-selector";9import { MessageError } from "./message-error";10import { Markdown } from "@/components/markdown/markdown";11import { ProviderIcon } from "@/components/brand/provider-icon";12import { Button } from "@/components/ui/button";13import { Textarea } from "@/components/ui/input";14import { Tooltip } from "@/components/ui/tooltip";15import { toast } from "@/components/ui/toast";16import { useIsMobile, useSnapCarousel } from "@/lib/client/hooks";17import { PROVIDERS } from "@/lib/client/providers";18import { cn, formatMs, formatTokens, formatUsd } from "@/lib/utils";1920/** Wire shape of an Arena response row (subset we need). */21interface ArenaResponseLite {22  id: string;23  modelKey: string;24  content: string;25  status: string;26  usage?: Record<string, number> | null;27  latencyMs?: number | null;28  ttftMs?: number | null;29  costUsd?: number | null;30  error?: { code: string; message: string } | null;31}3233type ArenaEvent =34  | { type: "meta"; responseId: string; modelKey: string }35  | { type: "text-delta"; text: string }36  | { type: "reasoning-delta"; text: string }37  | { type: "citation"; citation: { url?: string; title?: string; snippet?: string } }38  | { type: "server-tool"; name: string; status: "started" | "completed" }39  | { type: "error"; error: PolyProviderErrorShape }40  | { type: "done"; response: ArenaResponseLite; status: "complete" | "stopped" | "error" };4142type Status = "waiting" | "thinking" | "streaming" | "done" | "error" | "stopped";4344interface Col {45  modelKey: string;46  responseId: string | null;47  status: Status;48  text: string;49  reasoning: string;50  response: ArenaResponseLite | null;51  error: PolyProviderErrorShape | { code: string; message: string } | null;52  errorAt?: number;53  startedAt: number;54  firstTokenAt?: number;55}5657export interface CompareInlineProps {58  prompt: string;59  /** Editable prompt (new chat / empty state). */60  onPromptChange?: (p: string) => void;61  systemPrompt?: string | null;62  settings?: ChatSettings;63  attachmentIds?: string[];64  initialModelKeys: string[];65  conversationId?: string | null;66  projectId?: string | null;67  onClose: () => void;68  /** Called after "Continue with <model>" succeeded. */69  onAdopted: (res: ChatAdoptResponse, modelKey: string) => void;70}7172const MAX = 4;73const MIN = 2;7475function cleanSettings(s: ChatSettings | undefined): ChatSettings | undefined {76  if (!s) return undefined;77  const { tools: _tools, ...rest } = s;78  void _tools;79  const out = Object.fromEntries(Object.entries(rest).filter(([, v]) => v !== undefined && v !== null && !(Array.isArray(v) && v.length === 0))) as ChatSettings;80  return Object.keys(out).length ? out : undefined;81}8283function emptyCol(modelKey: string): Col {84  return { modelKey, responseId: null, status: "waiting", text: "", reasoning: "", response: null, error: null, startedAt: Date.now() };85}8687/**88 * Inline "Compare with…" — 2–4 models answer the same prompt inside the chat. Runs through the Arena API89 * (so the comparison is stored as an Arena session) and lets the user continue the conversation with any90 * answer via POST /api/chat/adopt.91 */92export function CompareInline({ prompt, onPromptChange, systemPrompt, settings, attachmentIds, initialModelKeys, conversationId, projectId, onClose, onAdopted }: CompareInlineProps) {93  const { modelsByKey, connectedProviders, preferences } = useApp();94  const isMobile = useIsMobile();95  const [selected, setSelected] = React.useState<string[]>(() => initialModelKeys.filter((k) => modelsByKey.has(k)).slice(0, MAX));96  const [columns, setColumns] = React.useState<Col[]>([]);97  const [running, setRunning] = React.useState(false);98  const [adopting, setAdopting] = React.useState<string | null>(null);99  const mapRef = React.useRef(new Map<string, Col>());100  const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);101  const abortRef = React.useRef<AbortController | null>(null);102  const { ref: rowRef, index, scrollTo } = useSnapCarousel<HTMLDivElement>(columns.length);103104  const flush = React.useCallback(() => {105    timer.current = null;106    setColumns([...mapRef.current.values()].map((c) => ({ ...c })));107  }, []);108  const schedule = React.useCallback(() => {109    if (!timer.current) timer.current = setTimeout(flush, 40);110  }, [flush]);111  React.useEffect(112    () => () => {113      if (timer.current) clearTimeout(timer.current);114      abortRef.current?.abort();115    },116    [],117  );118119  const selectedSet = React.useMemo(() => new Set(selected), [selected]);120  const toggle = (key: string) =>121    setSelected((s) => {122      if (s.includes(key)) return s.filter((k) => k !== key);123      if (s.length >= MAX) {124        toast.warning(`Up to ${MAX} models`, "Remove one before adding another.");125        return s;126      }127      return [...s, key];128    });129  const disconnected = selected.map((k) => modelsByKey.get(k)).filter((m): m is PolyModel => Boolean(m) && !connectedProviders.has(m!.provider));130  const canRun = !running && selected.length >= MIN && prompt.trim().length > 0 && disconnected.length === 0;131  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;132133  const streamOne = async (sessionId: string, modelKey: string, signal: AbortSignal) => {134    const col = () => mapRef.current.get(modelKey);135    try {136      await streamEvents(137        "/api/arena/stream",138        { sessionId, modelKey },139        (raw) => {140          const c = col();141          if (!c) return;142          const ev = raw as unknown as ArenaEvent;143          switch (ev.type) {144            case "meta":145              c.responseId = ev.responseId;146              break;147            case "text-delta":148              if (!c.firstTokenAt) c.firstTokenAt = Date.now();149              c.text += ev.text;150              c.status = "streaming";151              break;152            case "reasoning-delta":153              if (!c.firstTokenAt) c.firstTokenAt = Date.now();154              c.reasoning += ev.text;155              if (c.status === "waiting") c.status = "thinking";156              break;157            case "error":158              c.error = ev.error;159              c.errorAt = Date.now();160              break;161            case "done":162              c.response = ev.response;163              c.responseId = ev.response.id;164              c.status = ev.status === "complete" ? "done" : ev.status === "stopped" ? "stopped" : "error";165              if (ev.response.error && !c.error) {166                c.error = ev.response.error;167                c.errorAt = Date.now();168              }169              break;170            default:171              break;172          }173          schedule();174        },175        signal,176      );177      const c = col();178      if (c && (c.status === "waiting" || c.status === "thinking" || c.status === "streaming")) c.status = c.error ? "error" : "stopped";179    } catch (e) {180      const c = col();181      if (!c) return;182      if ((e as Error).name === "AbortError") c.status = "stopped";183      else {184        const err = e as ClientApiError;185        c.status = "error";186        c.error = { code: err.code ?? "HTTP_ERROR", message: err.message };187        c.errorAt = Date.now();188      }189    }190    flush();191  };192193  const run = async () => {194    if (!canRun) return;195    setRunning(true);196    const keys = [...selected];197    mapRef.current = new Map(keys.map((k) => [k, emptyCol(k)]));198    flush();199    const controller = new AbortController();200    abortRef.current = controller;201    try {202      const res = await api<{ session: { id: string } }>("/api/arena", {203        method: "POST",204        json: { prompt: prompt.trim(), systemPrompt: systemPrompt?.trim() || undefined, modelKeys: keys, settings: cleanSettings(settings), attachmentIds: attachmentIds?.length ? attachmentIds.slice(0, 6) : undefined },205      });206      await Promise.allSettled(keys.map((k) => streamOne(res.session.id, k, controller.signal)));207    } catch (e) {208      const err = e as ClientApiError;209      toast.error("Could not start the comparison", err.message);210      mapRef.current = new Map();211      flush();212    } finally {213      setRunning(false);214      abortRef.current = null;215    }216  };217218  const stop = () => abortRef.current?.abort();219220  const adopt = async (c: Col) => {221    if (!c.responseId && !c.text) return;222    setAdopting(c.modelKey);223    try {224      const res = await api<ChatAdoptResponse>("/api/chat/adopt", {225        method: "POST",226        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 },227      });228      onAdopted(res, c.modelKey);229    } catch (e) {230      toast.error("Could not continue with this answer", (e as Error).message);231    } finally {232      setAdopting(null);233    }234  };235236  const allSettled = columns.length > 0 && columns.every((c) => c.status === "done" || c.status === "error" || c.status === "stopped");237238  return (239    <section className="rounded-2xl border border-border bg-bg-elevated shadow-sm" aria-label="Compare models" data-no-edge-swipe>240      {/* Header */}241      <header className="flex flex-wrap items-center gap-2 border-b border-border px-3 py-2">242        <span className="flex size-7 items-center justify-center rounded-md bg-accent-soft text-accent">243          <Columns3 className="size-4" />244        </span>245        <h3 className="text-[14px] font-semibold tracking-tight">Compare with…</h3>246        <span className="hidden text-[12px] text-fg-muted sm:inline">{selected.length}/{MAX} models</span>247        <div className="ml-auto flex items-center gap-1.5">248          {running ? (249            <Button size="sm" variant="secondary" onClick={stop}>250              <Square className="size-3.5 fill-current" /> Stop251            </Button>252          ) : (253            <Tooltip content={reason ?? "Run the comparison"}>254              <span className="inline-flex">255                <Button size="sm" variant="accent" disabled={!canRun} onClick={run}>256                  <Play className="size-3.5 fill-current" /> {columns.length ? "Run again" : "Run"}257                </Button>258              </span>259            </Tooltip>260          )}261          <Button size="icon-sm" variant="ghost" onClick={onClose} aria-label="Close comparison" disabled={running}>262            <X />263          </Button>264        </div>265      </header>266267      {/* Setup */}268      <div className="space-y-2 px-3 py-2.5">269        <div className="flex flex-wrap items-center gap-1.5">270          {selected.map((k) => {271            const m = modelsByKey.get(k);272            return (273              <span key={k} className="inline-flex h-8 items-center gap-1.5 rounded-full border border-border bg-bg-subtle pl-2 pr-1 text-[12.5px]">274                <ProviderIcon provider={m?.provider ?? k.split("/")[0]} size={13} />275                <span className="max-w-[140px] truncate">{m?.displayName ?? k}</span>276                <button type="button" onClick={() => toggle(k)} disabled={running} className="tap rounded-full p-0.5 text-fg-subtle hover:text-danger" aria-label={`Remove ${m?.displayName ?? k}`}>277                  <X className="size-3" />278                </button>279              </span>280            );281          })}282          {!running ? <ModelSelector value={null} multiple selected={selectedSet} onToggle={toggle} size="sm" buttonLabel={selected.length < MAX ? "+ Add model" : "Models"} className="h-8 rounded-full" /> : null}283        </div>284        {onPromptChange ? (285          <Textarea value={prompt} onChange={(e) => onPromptChange(e.target.value)} placeholder="One prompt for every model…" className="min-h-[64px] text-[14px]" disabled={running} aria-label="Comparison prompt" />286        ) : (287          <p className="line-clamp-3 rounded-lg bg-bg-subtle px-3 py-2 text-[13px] text-fg-muted" title={prompt}>288            {prompt}289          </p>290        )}291        {reason && !columns.length ? <p className="text-[12px] text-fg-subtle">{reason}.</p> : null}292      </div>293294      {/* Results */}295      {columns.length ? (296        isMobile ? (297          <div className="border-t border-border">298            <div className="sticky top-0 z-10 flex gap-1 overflow-x-auto bg-bg-elevated/95 px-2 py-2 backdrop-blur scrollbar-none" role="tablist">299              {columns.map((c, i) => {300                const m = modelsByKey.get(c.modelKey);301                return (302                  <button key={c.modelKey} role="tab" aria-selected={index === i} onClick={() => scrollTo(i)} className={cn("inline-flex h-9 shrink-0 items-center gap-1.5 rounded-full border px-3 text-[13px] font-medium", index === i ? "border-fg bg-fg text-bg" : "border-border text-fg-muted")}>303                    <StatusDot status={c.status} />304                    <span className="max-w-[140px] truncate">{m?.displayName ?? c.modelKey}</span>305                  </button>306                );307              })}308            </div>309            <div ref={rowRef} className="snap-row">310              {columns.map((c) => (311                <div key={c.modelKey} className="px-3 pb-3">312                  <Panel col={c} model={modelsByKey.get(c.modelKey)} wrapCode={preferences.codeWrap} showCosts={preferences.showCosts} onAdopt={allSettled ? () => adopt(c) : undefined} adopting={adopting === c.modelKey} />313                </div>314              ))}315            </div>316          </div>317        ) : (318          <div className={cn("grid gap-3 border-t border-border p-3", columns.length === 2 ? "md:grid-cols-2" : columns.length === 3 ? "md:grid-cols-3" : "md:grid-cols-2 xl:grid-cols-4")}>319            {columns.map((c) => (320              <Panel key={c.modelKey} col={c} model={modelsByKey.get(c.modelKey)} wrapCode={preferences.codeWrap} showCosts={preferences.showCosts} onAdopt={allSettled ? () => adopt(c) : undefined} adopting={adopting === c.modelKey} />321            ))}322          </div>323        )324      ) : null}325      {allSettled ? <p className="px-3 pb-3 text-[12px] text-fg-subtle">Saved to the Arena history. Pick an answer to continue the conversation with that model.</p> : null}326    </section>327  );328}329330const STATUS: Record<Status, { label: string; dot: string; pulse: boolean }> = {331  waiting: { label: "Waiting", dot: "bg-fg-subtle", pulse: true },332  thinking: { label: "Thinking", dot: "bg-accent", pulse: true },333  streaming: { label: "Streaming", dot: "bg-info", pulse: true },334  done: { label: "Done", dot: "bg-success", pulse: false },335  error: { label: "Failed", dot: "bg-danger", pulse: false },336  stopped: { label: "Stopped", dot: "bg-warning", pulse: false },337};338339function StatusDot({ status }: { status: Status }) {340  const s = STATUS[status];341  return <span className={cn("inline-block size-2 shrink-0 rounded-full", s.dot, s.pulse && "animate-pulse-soft")} aria-hidden />;342}343344function Panel({ col: c, model, wrapCode, showCosts, onAdopt, adopting }: { col: Col; model?: PolyModel; wrapCode?: boolean; showCosts?: boolean; onAdopt?: () => void; adopting?: boolean }) {345  const name = model?.displayName ?? c.modelKey.split("/").slice(1).join("/");346  const live = c.status === "waiting" || c.status === "thinking" || c.status === "streaming";347  const u = (c.response?.usage ?? null) as { inputTokens?: number; outputTokens?: number } | null;348  const gen = c.response?.latencyMs ? Math.max(1, c.response.latencyMs - (c.response.ttftMs ?? 0)) : null;349  const tps = u?.outputTokens && gen ? Math.round((u.outputTokens / gen) * 1000) : null;350  return (351    <article className="flex min-w-0 flex-col overflow-hidden rounded-xl border border-border bg-bg" aria-label={`${name} response`} aria-busy={live}>352      <header className="flex items-center gap-2 border-b border-border px-3 py-2">353        <ProviderIcon provider={model?.provider ?? c.modelKey.split("/")[0]} size={14} />354        <span className="min-w-0 flex-1 truncate text-[13px] font-semibold">{name}</span>355        <span className="inline-flex items-center gap-1.5 text-[11px] text-fg-subtle">356          <StatusDot status={c.status} /> {STATUS[c.status].label}357        </span>358      </header>359      <div className="max-h-[420px] min-h-[96px] overflow-y-auto px-3 py-2 text-[14px] scrollbar-thin">360        {c.status === "waiting" && !c.text ? (361          <p className="flex items-center gap-2 text-[13px] text-fg-muted">362            <Loader2 className="size-3.5 animate-spin" /> Waiting for the model…363          </p>364        ) : null}365        {c.status === "thinking" && !c.text ? <p className="text-[13px] text-fg-muted">Thinking…</p> : null}366        {c.text ? <Markdown content={c.text} wrap={wrapCode} streaming={live} className="text-[14px]" /> : null}367        {c.error && !live ? (368          <div className="mt-2">369            <MessageError error={c.error} provider={model?.provider ?? c.modelKey.split("/")[0]} since={c.errorAt} compact />370          </div>371        ) : null}372      </div>373      <footer className="flex flex-wrap items-center gap-x-2 gap-y-1 border-t border-border px-3 py-2 text-[11px] tabular-nums text-fg-subtle">374        {c.response?.ttftMs ? <span title="Time to first token">TTFT {formatMs(c.response.ttftMs)}</span> : null}375        {c.response?.latencyMs ? <span>{formatMs(c.response.latencyMs)}</span> : null}376        {tps ? <span>{tps} tok/s</span> : null}377        {u ? (378          <span>379            {formatTokens(u.inputTokens ?? 0)} in · {formatTokens(u.outputTokens ?? 0)} out380          </span>381        ) : null}382        {showCosts && c.response?.costUsd !== null && c.response?.costUsd !== undefined ? <span>≈ {formatUsd(c.response.costUsd, { precise: c.response.costUsd < 0.01 })}</span> : null}383        <span className="flex-1" />384        {onAdopt && c.status === "done" && c.text ? (385          <Button size="xs" variant="outline" className="bg-bg-elevated" onClick={onAdopt} loading={adopting}>386            <Check /> Continue with {name}387          </Button>388        ) : null}389      </footer>390    </article>391  );392}393