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%
9.9 KB · 183 lines tsx
Raw Blame History
1"use client";2import * as React from "react";3import Link from "next/link";4import { ArrowLeft, Coins, Info, Menu, Swords, Trophy } from "lucide-react";5import { useApp } from "@/components/app/store";6import { ProviderIcon } from "@/components/brand/provider-icon";7import { Button } from "@/components/ui/button";8import { ChipRow } from "@/components/ui/segmented";9import { Skeleton } from "@/components/ui/misc";10import { Tooltip } from "@/components/ui/tooltip";11import { useApi } from "@/lib/client/api";12import { PROVIDERS } from "@/lib/client/providers";13import { CATEGORIES, criterionById, type ScoreboardRow, type TaskCategory } from "@/lib/arena/scoring";14import { cn, formatMs, formatUsd } from "@/lib/utils";1516type Filter = "all" | TaskCategory | "value";1718const FILTERS: { value: Filter; label: string; icon?: React.ReactNode }[] = [{ value: "all", label: "All" }, ...CATEGORIES.map((c) => ({ value: c.value as Filter, label: c.label })), { value: "value", label: "Cost efficiency", icon: <Coins /> }];1920interface ScoreboardResponse {21  rows: ScoreboardRow[];22  sessions: number;23  votes: number;24}2526/** Personal Arena scoreboard: win rate per model from your own votes. Mobile-first ranking list. */27export function ScoreboardView() {28  const { modelsByKey, setSidebarOpen, preferences } = useApp();29  const [filter, setFilter] = React.useState<Filter>("all");30  const query = filter === "all" ? "" : filter === "value" ? "?criterion=value" : `?category=${filter}`;31  const { data, isLoading, error } = useApi<ScoreboardResponse>(`/api/arena/scoreboard${query}`);32  const rows = React.useMemo(() => (data?.rows ?? []).filter((r) => r.sessions > 0), [data]);33  const ranked = React.useMemo(() => rows.filter((r) => r.decided > 0), [rows]);34  const unranked = React.useMemo(() => rows.filter((r) => r.decided === 0), [rows]);35  const nameOf = (k: string) => modelsByKey.get(k)?.displayName ?? k.split("/").slice(1).join("/");3637  return (38    <div className="flex h-full min-h-0 flex-col">39      <header className="flex h-12 shrink-0 items-center gap-2 border-b border-border px-2 sm:px-4">40        <Button variant="ghost" size="icon-sm" className="md:hidden" onClick={() => setSidebarOpen(true)} aria-label="Open sidebar">41          <Menu />42        </Button>43        <Button asChild variant="ghost" size="icon-sm" aria-label="Back to Arena">44          <Link href="/app/arena">45            <ArrowLeft />46          </Link>47        </Button>48        <span className="flex size-7 items-center justify-center rounded-md bg-accent-soft text-accent">49          <Trophy className="size-4" />50        </span>51        <div className="min-w-0 leading-tight">52          <h1 className="text-[14px] font-semibold tracking-tight">Scoreboard</h1>53          <p className="hidden truncate text-[11.5px] text-fg-muted sm:block">Which models win your comparisons.</p>54        </div>55        <Button asChild variant="outline" size="sm" className="ml-auto">56          <Link href="/app/arena">57            <Swords /> <span className="hidden sm:inline">New comparison</span>58            <span className="sm:hidden">Arena</span>59          </Link>60        </Button>61      </header>6263      <main className="min-h-0 flex-1 overflow-y-auto scrollbar-thin">64        <div className="mx-auto w-full max-w-3xl px-3 py-4 sm:px-5">65          <ChipRow value={filter} onChange={setFilter} options={FILTERS} className="-mx-3 px-3 pb-1 sm:mx-0 sm:px-0" />66          <p className="mt-2 flex items-start gap-1.5 text-[12px] text-fg-muted">67            <Info className="mt-0.5 size-3.5 shrink-0" />68            <span>69              {filter === "value" ? "Ranked by “Best value” votes only." : filter === "all" ? "Ranked by the share of decided comparisons each model won (most criteria; ties → fastest)." : `Only comparisons classified as ${FILTERS.find((f) => f.value === filter)?.label.toLowerCase()} prompts.`}70              {data ? ` ${data.sessions} session${data.sessions === 1 ? "" : "s"} · ${data.votes} vote${data.votes === 1 ? "" : "s"}.` : ""}71            </span>72          </p>7374          {error ? (75            <p className="mt-6 rounded-lg border border-danger/30 bg-danger-soft px-3 py-2 text-[13px] text-danger">Could not load the scoreboard: {(error as Error).message}</p>76          ) : isLoading && !data ? (77            <ul className="mt-4 space-y-2">78              {[0, 1, 2, 3].map((i) => (79                <Skeleton key={i} className="h-[76px]" />80              ))}81            </ul>82          ) : !ranked.length ? (83            <EmptyScoreboard hasSessions={rows.length > 0} filtered={filter !== "all"} />84          ) : (85            <ol className="mt-4 space-y-2" aria-label="Ranking">86              {ranked.map((r, i) => (87                <RankRow key={r.modelKey} rank={i + 1} row={r} name={nameOf(r.modelKey)} showCosts={preferences.showCosts} filter={filter} />88              ))}89            </ol>90          )}9192          {unranked.length && ranked.length ? (93            <details className="mt-5 text-[12.5px] text-fg-muted">94              <summary className="cursor-pointer select-none">95                {unranked.length} model{unranked.length === 1 ? "" : "s"} without a decided comparison96              </summary>97              <ul className="mt-2 flex flex-wrap gap-1.5">98                {unranked.map((r) => (99                  <li key={r.modelKey} className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1">100                    <ProviderIcon provider={r.provider} size={12} /> {nameOf(r.modelKey)} <span className="text-fg-subtle">· {r.sessions}</span>101                  </li>102                ))}103              </ul>104            </details>105          ) : null}106        </div>107      </main>108    </div>109  );110}111112function RankRow({ rank, row: r, name, showCosts, filter }: { rank: number; row: ScoreboardRow; name: string; showCosts: boolean; filter: Filter }) {113  const pct = Math.round(r.winRate * 100);114  const top = rank === 1;115  const crit = Object.entries(r.criteria)116    .sort((a, b) => b[1] - a[1])117    .slice(0, 3);118  return (119    <li className={cn("rounded-xl border bg-bg-elevated p-3 sm:p-3.5", top ? "border-accent/50 shadow-glow" : "border-border")}>120      <div className="flex items-center gap-3">121        <span className={cn("flex size-8 shrink-0 items-center justify-center rounded-lg text-[13px] font-semibold tabular-nums", top ? "bg-accent text-accent-fg" : "bg-bg-muted text-fg-muted")} aria-label={`Rank ${rank}`}>122          {rank}123        </span>124        <span className="flex size-8 shrink-0 items-center justify-center rounded-lg border border-border bg-bg-subtle">125          <ProviderIcon provider={r.provider} size={16} />126        </span>127        <div className="min-w-0 flex-1">128          <div className="flex items-baseline gap-2">129            <h3 className="truncate text-[14px] font-semibold tracking-tight">{name}</h3>130            <span className="truncate text-[11.5px] text-fg-subtle">{PROVIDERS[r.provider as keyof typeof PROVIDERS]?.shortName ?? r.provider}</span>131          </div>132          <div className="mt-1.5 flex items-center gap-2">133            <div className="h-2 min-w-0 flex-1 overflow-hidden rounded-full bg-bg-muted" role="progressbar" aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100} aria-label="Win rate">134              <div className={cn("h-full rounded-full transition-[width] duration-500", top ? "bg-accent" : "bg-fg/70")} style={{ width: `${Math.max(2, pct)}%` }} />135            </div>136            <span className="w-10 shrink-0 text-right text-[13px] font-semibold tabular-nums">{pct}%</span>137          </div>138        </div>139      </div>140      <dl className="mt-2.5 grid grid-cols-3 gap-2 text-[11.5px] tabular-nums sm:grid-cols-4">141        <Stat label={filter === "value" ? "Value wins" : "Wins"} value={`${r.wins} / ${r.decided}`} hint={`${r.sessions} session${r.sessions === 1 ? "" : "s"}`} />142        <Stat label="Votes" value={String(r.votes)} hint={crit.length ? crit.map(([id, n]) => `${criterionById(id).short} ${n}`).join(" · ") : undefined} />143        <Stat label="Avg TTFT" value={formatMs(r.avgTtftMs)} />144        {showCosts ? <Stat label="Avg cost" value={r.avgCostUsd === null ? "—" : formatUsd(r.avgCostUsd, { precise: r.avgCostUsd < 0.01 })} hint="per response" className="hidden sm:block" /> : null}145      </dl>146    </li>147  );148}149150function Stat({ label, value, hint, className }: { label: string; value: string; hint?: string; className?: string }) {151  return (152    <div className={cn("min-w-0", className)}>153      <dt className="text-[10.5px] uppercase tracking-wide text-fg-subtle">{label}</dt>154      <dd className="truncate font-medium text-fg">{value}</dd>155      {hint ? (156        <Tooltip content={hint}>157          <dd className="truncate text-[10.5px] text-fg-subtle">{hint}</dd>158        </Tooltip>159      ) : null}160    </div>161  );162}163164function EmptyScoreboard({ hasSessions, filtered }: { hasSessions: boolean; filtered: boolean }) {165  return (166    <section className="mt-6 rounded-xl border border-dashed border-border px-5 py-8 text-center sm:px-8">167      <span className="mx-auto flex size-10 items-center justify-center rounded-lg bg-bg-muted text-fg-muted">168        <Trophy className="size-5" />169      </span>170      <h2 className="mt-3 text-balance text-[16px] font-semibold tracking-tight">{filtered ? "No decided comparison in this filter yet" : hasSessions ? "Vote to build your ranking" : "Your ranking starts with a comparison"}</h2>171      <p className="mx-auto mt-1.5 max-w-md text-[13px] leading-5 text-fg-muted">172        Run the same prompt through several models in the Arena, then vote on criteria such as Best answer, Most accurate or Best value. Each decided comparison counts as a win for the model that took the most criteria (ties go to the fastest). The win rate is wins over173        the comparisons a model took part in that received a vote.174      </p>175      <Button asChild variant="accent" size="sm" className="mt-4">176        <Link href="/app/arena">177          <Swords /> Open the Arena178        </Link>179      </Button>180    </section>181  );182}183