SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
13 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
12.6 KB · 267 lines tsx
Raw Blame History
1"use client";2import * as React from "react";3import { Brain, Check, ChevronDown, ChevronRight, Coins, Copy, ExternalLink, Eye, Globe, Loader2, Trophy, Wrench, X, Zap } from "lucide-react";4import type { PolyModel } from "@/lib/client/types";5import { Markdown } from "@/components/markdown/markdown";6import { ProviderIcon } from "@/components/brand/provider-icon";7import { Badge } from "@/components/ui/badge";8import { Tooltip } from "@/components/ui/tooltip";9import { PROVIDERS } from "@/lib/client/providers";10import { liveMetrics } from "@/lib/arena/metrics";11import { blindLabel, type Criterion } from "@/lib/arena/scoring";12import { cn, formatMs, formatTokens } from "@/lib/utils";13import { isFinal, type ColumnState } from "./types";14import { MetricsStrip, STATUS_META, StatusDot } from "./metrics-strip";15import { VotePanel } from "./vote-panel";16import { BlindAvatar, Flip } from "./blind";1718export { STATUS_META, StatusDot };1920interface Props {21  column: ColumnState;22  model?: PolyModel;23  /** Display position (0-based) — drives the Blind Arena letter. */24  index: number;25  /** Blind Arena with identity still hidden. */26  hidden?: boolean;27  /** Reveal flip in progress. */28  flipping?: boolean;29  onReveal?: () => void;30  winners: { fastest: string | null; cheapest: string | null };31  isWinner?: boolean;32  criteria: Criterion[];33  /** Criterion ids this response has won. */34  won: ReadonlySet<string>;35  onVote: (responseId: string, criterionId: string, on: boolean) => void;36  onAddCriterion?: (label: string) => void;37  onRemoveCriterion?: (id: string) => void;38  voteBusy?: boolean;39  /** Prompt (+ system prompt, attachments) token estimate for live cost. */40  inputTokens: number;41  /** Shared clock from the parent (`useTick`). */42  now: number;43  wrapCode?: boolean;44  showReasoning?: boolean;45  showCosts?: boolean;46  /** Sync-scroll plumbing (optional). */47  bodyRef?: (el: HTMLDivElement | null) => void;48  onBodyScroll?: (el: HTMLDivElement) => void;49  className?: string;50  style?: React.CSSProperties;51}5253export const ArenaColumn = React.memo(function ArenaColumn({ column: c, model, index, hidden, flipping, onReveal, winners, isWinner, criteria, won, onVote, onAddCriterion, onRemoveCriterion, voteBusy, inputTokens, now, wrapCode, showReasoning = true, showCosts = true, bodyRef, onBodyScroll, className, style }: Props) {54  const provider = model?.provider ?? c.response?.provider ?? c.modelKey.split("/")[0];55  const realName = model?.displayName ?? c.modelKey.split("/").slice(1).join("/");56  const name = hidden ? blindLabel(index) : realName;57  const live = c.status === "waiting" || c.status === "thinking" || c.status === "streaming";58  const final = isFinal(c.status);59  const metrics = React.useMemo(() => liveMetrics(c, model, inputTokens, now), [c, model, inputTokens, now]);60  const [copied, setCopied] = React.useState(false);6162  // Keep the body pinned to the bottom while streaming unless the user scrolled up.63  const scrollEl = React.useRef<HTMLDivElement | null>(null);64  const pinned = React.useRef(true);65  React.useEffect(() => {66    const el = scrollEl.current;67    if (el && live && pinned.current) el.scrollTop = el.scrollHeight;68  }, [c.text, c.reasoning, live]);6970  const copy = async () => {71    await navigator.clipboard.writeText(c.text).catch(() => {});72    setCopied(true);73    setTimeout(() => setCopied(false), 1400);74  };7576  const status = STATUS_META[c.status];7778  return (79    <section className={cn("flex min-w-0 flex-col overflow-hidden rounded-xl border bg-bg-elevated transition-[border-color,box-shadow]", isWinner ? "border-accent/60 shadow-glow" : "border-border", className)} style={style} aria-label={`${name} response`}>80      {/* Header */}81      <header className="flex items-center gap-2 border-b border-border px-3 py-2">82        <Flip flipping={Boolean(flipping)}>83          <span className="flex size-7 shrink-0 items-center justify-center rounded-md border border-border bg-bg-subtle">{hidden ? <BlindAvatar index={index} size={18} /> : <ProviderIcon provider={provider} size={15} />}</span>84        </Flip>85        <div className="min-w-0 flex-1">86          <Flip flipping={Boolean(flipping)} className="flex w-full">87            <div className="flex min-w-0 items-center gap-1.5">88              <span className="truncate text-[13px] font-semibold leading-5">{name}</span>89              {!hidden && model?.capabilities.reasoning ? <Brain className="size-3.5 shrink-0 text-fg-subtle" aria-label="Reasoning model" /> : null}90            </div>91          </Flip>92          <div className="flex items-center gap-1.5 text-[11.5px] text-fg-muted">93            <StatusDot status={c.status} />94            <span>{status.label}</span>95            {live ? <span className="tabular-nums text-fg-subtle">· {(metrics.elapsedMs / 1000).toFixed(1)} s</span> : null}96            {!hidden ? <span className="truncate text-fg-subtle">· {PROVIDERS[provider as keyof typeof PROVIDERS]?.shortName ?? provider}</span> : <span className="text-fg-subtle">· identity hidden</span>}97          </div>98        </div>99        <div className="flex shrink-0 items-center gap-1">100          {isWinner ? (101            <Badge variant="accent" className="gap-1">102              <Trophy /> Winner103            </Badge>104          ) : null}105          {winners.fastest === c.modelKey ? (106            <Tooltip content="Fastest time to first token">107              <Badge variant="success" className="hidden gap-1 sm:inline-flex">108                <Zap /> Fastest109              </Badge>110            </Tooltip>111          ) : null}112          {winners.cheapest === c.modelKey && showCosts ? (113            <Tooltip content="Lowest estimated cost">114              <Badge variant="info" className="hidden gap-1 sm:inline-flex">115                <Coins /> Cheapest116              </Badge>117            </Tooltip>118          ) : null}119          {hidden && onReveal && final ? (120            <Tooltip content="Reveal this model">121              <button onClick={onReveal} className="tap rounded p-1 text-fg-subtle transition-colors hover:bg-bg-muted hover:text-fg [&_svg]:size-3.5" aria-label="Reveal model identity">122                <Eye />123              </button>124            </Tooltip>125          ) : null}126          {c.text ? (127            <Tooltip content={copied ? "Copied" : "Copy response"}>128              <button onClick={copy} className="tap rounded p-1 text-fg-subtle transition-colors hover:bg-bg-muted hover:text-fg [&_svg]:size-3.5" aria-label="Copy response">129                {copied ? <Check className="text-success" /> : <Copy />}130              </button>131            </Tooltip>132          ) : null}133        </div>134      </header>135136      {/* Body */}137      <div138        ref={(el) => {139          scrollEl.current = el;140          bodyRef?.(el);141        }}142        onScroll={(e) => {143          const el = e.currentTarget;144          pinned.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48;145          onBodyScroll?.(el);146        }}147        className="min-h-[140px] max-h-[min(58dvh,720px)] flex-1 space-y-2.5 overflow-y-auto overscroll-contain px-3.5 py-3 scrollbar-thin md:max-h-[min(62vh,720px)]"148      >149        {(c.reasoning || c.status === "thinking") && showReasoning ? <ReasoningBlock text={c.reasoning} streaming={live && !c.text} /> : null}150151        {c.serverTools.length ? (152          <div className="flex flex-wrap gap-1.5">153            {dedupe(c.serverTools).map((t, i) => (154              <Badge key={i} variant="info" className="gap-1">155                {t.name === "web_search" ? <Globe /> : <Wrench />} {t.name.replace(/_/g, " ")} {t.status === "started" ? <Loader2 className="animate-spin" /> : null}156              </Badge>157            ))}158          </div>159        ) : null}160161        {c.status === "waiting" ? (162          <div className="flex items-center gap-2 py-1 text-[13px] text-fg-muted">163            <span className="inline-flex gap-1">164              <i className="size-1.5 animate-pulse-soft rounded-full bg-fg-subtle" />165              <i className="size-1.5 animate-pulse-soft rounded-full bg-fg-subtle [animation-delay:200ms]" />166              <i className="size-1.5 animate-pulse-soft rounded-full bg-fg-subtle [animation-delay:400ms]" />167            </span>168            Waiting for {name}…169          </div>170        ) : null}171172        {c.text ? <Markdown content={c.text} wrap={wrapCode} streaming={c.status === "streaming"} /> : null}173174        {final && !c.text && !c.error ? <p className="text-[13px] italic text-fg-subtle">{c.status === "stopped" ? "Stopped before any output." : "The model returned an empty response."}</p> : null}175176        {c.error ? (177          <div className="flex items-start gap-2 rounded-lg border border-danger/30 bg-danger-soft px-3 py-2 text-[13px] text-danger">178            <X className="mt-0.5 size-4 shrink-0" />179            <div className="min-w-0">180              <div className="font-medium break-words">{c.error.message}</div>181              <div className="font-mono text-[11px] opacity-80">{c.error.code}</div>182            </div>183          </div>184        ) : null}185186        {c.citations.length ? <Citations items={c.citations} /> : null}187      </div>188189      {/* Footer: live metrics + votes */}190      {c.status !== "idle" ? (191        <footer className="border-t border-border bg-bg-subtle/50 px-3 py-2">192          <MetricsStrip metrics={metrics} showCosts={showCosts} fastest={winners.fastest === c.modelKey} cheapest={winners.cheapest === c.modelKey} />193          {final && c.responseId ? <VotePanel className="mt-2" criteria={criteria} won={won} busy={voteBusy} onToggle={(id, on) => onVote(c.responseId!, id, on)} onAddCriterion={onAddCriterion} onRemoveCriterion={onRemoveCriterion} /> : null}194          {final && c.status === "error" && !c.responseId ? <p className="mt-1.5 text-[11.5px] text-fg-subtle">Latency {formatMs(metrics.elapsedMs)}</p> : null}195        </footer>196      ) : null}197    </section>198  );199});200201function ReasoningBlock({ text, streaming }: { text: string; streaming: boolean }) {202  const [open, setOpen] = React.useState(streaming);203  React.useEffect(() => {204    if (!streaming) {205      // collapse automatically once the answer starts206      // eslint-disable-next-line react-hooks/set-state-in-effect207      setOpen(false);208    }209  }, [streaming]);210  const ref = React.useRef<HTMLDivElement>(null);211  React.useEffect(() => {212    if (open && streaming && ref.current) ref.current.scrollTop = ref.current.scrollHeight;213  }, [text, open, streaming]);214  return (215    <div className="rounded-lg border border-border bg-bg-subtle/60">216      <button onClick={() => setOpen((o) => !o)} className="flex min-h-9 w-full items-center gap-2 px-3 py-1.5 text-left text-[12.5px] text-fg-muted hover:text-fg" aria-expanded={open}>217        <Brain className={cn("size-3.5", streaming && "animate-pulse-soft text-accent")} />218        <span className="font-medium">{streaming ? "Thinking…" : "Reasoning"}</span>219        {!streaming && text ? <span className="text-fg-subtle">· ≈ {formatTokens(Math.round(text.length / 4))} tok</span> : null}220        <span className="ml-auto">{open ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}</span>221      </button>222      {open ? (223        <div ref={ref} className="max-h-56 overflow-y-auto border-t border-border px-3 py-2 text-[13px] leading-relaxed text-fg-muted scrollbar-thin">224          {text ? <Markdown content={text} className="text-[13px] text-fg-muted [&_p]:text-fg-muted" /> : <span className="text-fg-subtle">Reasoning summary will appear here when the provider exposes it.</span>}225        </div>226      ) : null}227    </div>228  );229}230231function Citations({ items }: { items: { url?: string; title?: string; snippet?: string }[] }) {232  const unique = React.useMemo(() => {233    const seen = new Set<string>();234    return items.filter((c) => {235      const k = c.url ?? c.title ?? "";236      if (!k || seen.has(k)) return false;237      seen.add(k);238      return true;239    });240  }, [items]);241  if (!unique.length) return null;242  return (243    <div className="flex flex-wrap gap-1.5 pt-1">244      {unique.slice(0, 10).map((c, i) => (245        <a key={i} href={c.url} target="_blank" rel="noopener noreferrer nofollow" className="inline-flex max-w-[240px] items-center gap-1 rounded-md border border-border bg-bg-subtle px-2 py-1 text-[11.5px] text-fg-muted hover:border-border-strong hover:text-fg" title={c.snippet}>246          <ExternalLink className="size-3 shrink-0" />247          <span className="truncate">{c.title || safeHost(c.url)}</span>248        </a>249      ))}250    </div>251  );252}253254function safeHost(url?: string): string {255  try {256    return url ? new URL(url).hostname : "source";257  } catch {258    return "source";259  }260}261262function dedupe(list: { name: string; status: string }[]) {263  const map = new Map<string, { name: string; status: string }>();264  for (const t of list) map.set(t.name, t);265  return [...map.values()];266}267