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%
11.5 KB · 222 lines tsx
Raw Blame History
1import * as React from "react";2import Link from "next/link";3import { Check, Minus } from "lucide-react";4import type { ModelCapabilities, PolyModel } from "@/lib/ai/core/types";5import { ProviderIcon } from "@/components/brand/provider-icon";6import { PROVIDERS } from "@/lib/client/providers";7import { deriveBadges, lifecycleStatus, speedTier, type BadgeContext } from "@/lib/models/badges";8import { formatContext, formatIsoDate, formatPrice } from "@/lib/models/format";9import { PARAM_DEFS } from "@/lib/models/params";10import { BadgeChips, LifecycleChip } from "./badge-chips";11import { Meter } from "./meter";12import { cn } from "@/lib/utils";1314/**15 * Side-by-side comparison of 2–4 models. Server-safe (no hooks): used by the public16 * `/compare/[slug]` page and by `/app/models/compare`. Desktop = grid table; phones = stacked17 * spec cards (one per row) so nothing overflows horizontally.18 */19export const CAPABILITY_LABELS: Record<keyof ModelCapabilities, string> = {20  text: "Text",21  vision: "Vision (images)",22  reasoning: "Reasoning",23  tools: "Tool calling",24  structuredOutput: "Structured output",25  webSearch: "Web search",26  files: "File / PDF input",27  streaming: "Streaming",28  audioInput: "Audio input",29  audioOutput: "Audio output",30  imageGeneration: "Image generation",31  video: "Video input",32};3334interface SpecRow {35  key: string;36  label: string;37  hint?: string;38  value: (m: PolyModel) => React.ReactNode;39  /** Numeric magnitude for the meter (null = no bar). */40  bar?: (m: PolyModel) => number | null;41}4243function longTier(m: PolyModel): string {44  const lc = m.pricing?.longContext;45  if (!lc) return "—";46  return `> ${formatContext(lc.thresholdTokens)}: ${formatPrice(lc.inputPerMillion)} / ${formatPrice(lc.outputPerMillion)}`;47}4849function metaStr(m: PolyModel, key: string): string {50  const v = m.metadata?.[key];51  return typeof v === "string" && v ? v : "";52}5354export function CompareTable({ models, ctx, hrefFor, connected, className }: { models: PolyModel[]; ctx: BadgeContext; hrefFor?: (m: PolyModel) => string | undefined; connected?: (m: PolyModel) => boolean; className?: string }) {55  const rows: SpecRow[] = [56    { key: "provider", label: "Provider", value: (m) => PROVIDERS[m.provider].name },57    { key: "family", label: "Family", value: (m) => m.family ?? "—" },58    { key: "status", label: "Status", value: (m) => <LifecycleChip lifecycle={lifecycleStatus(m, { ctx, connected: connected?.(m) })} /> },59    { key: "context", label: "Context window", value: (m) => `${formatContext(m.limits?.contextTokens)} tokens`, bar: (m) => m.limits?.contextTokens ?? null },60    { key: "maxOut", label: "Max output", value: (m) => `${formatContext(m.limits?.maxOutputTokens)} tokens`, bar: (m) => m.limits?.maxOutputTokens ?? null },61    { key: "in", label: "Input price", hint: "USD per 1M tokens — shorter is cheaper", value: (m) => formatPrice(m.pricing?.inputPerMillion), bar: (m) => m.pricing?.inputPerMillion ?? null },62    { key: "out", label: "Output price", hint: "USD per 1M tokens — shorter is cheaper", value: (m) => formatPrice(m.pricing?.outputPerMillion), bar: (m) => m.pricing?.outputPerMillion ?? null },63    { key: "cached", label: "Cached input", value: (m) => formatPrice(m.pricing?.cachedInputPerMillion) },64    { key: "longTier", label: "Long-context tier", value: longTier },65    { key: "speed", label: "Speed tier", value: (m) => ({ fast: "Fast", standard: "Standard", frontier: "Frontier" })[speedTier(m, ctx)] },66    { key: "effort", label: "Reasoning effort", value: (m) => (m.parameters.reasoningEffort && m.parameters.reasoningEffortLevels?.length ? m.parameters.reasoningEffortLevels.join(" · ") : m.capabilities.reasoning ? "always on" : "—") },67    { key: "budget", label: "Thinking budget", value: (m) => (m.parameters.thinkingBudgetRange ? `${formatContext(m.parameters.thinkingBudgetRange.min)}–${formatContext(m.parameters.thinkingBudgetRange.max)}` : "—") },68    { key: "cutoff", label: "Knowledge cutoff", hint: "Only when the provider publishes it", value: (m) => metaStr(m, "knowledgeCutoff") || "—" },69    { key: "release", label: "Release date", hint: "Only when the provider publishes it", value: (m) => (metaStr(m, "releaseDate") ? formatIsoDate(metaStr(m, "releaseDate")) : "—") },70    { key: "badges", label: "Badges", value: (m) => <BadgeChips badges={deriveBadges(m, ctx)} max={5} /> },71  ];72  const colStyle: React.CSSProperties = { gridTemplateColumns: `150px repeat(${models.length}, minmax(0, 1fr))` };73  const max = (row: SpecRow) => Math.max(0, ...models.map((m) => row.bar?.(m) ?? 0));7475  return (76    <div className={cn("space-y-8", className)}>77      {/* Header cards */}78      <div className={cn("grid gap-3", models.length >= 3 ? "grid-cols-2 md:grid-cols-4" : "grid-cols-2")}>79        {models.map((m) => {80          const href = hrefFor?.(m);81          const inner = (82            <>83              <span className="flex items-center gap-2">84                <ProviderIcon provider={m.provider} size={18} />85                <span className="text-[11px] font-medium uppercase tracking-wide text-fg-subtle">{PROVIDERS[m.provider].shortName}</span>86              </span>87              <span className="mt-2 block text-balance text-[15px] font-semibold leading-5 sm:text-base">{m.displayName}</span>88              <span className="mt-0.5 block truncate font-mono text-[11px] text-fg-subtle">{m.id}</span>89            </>90          );91          return href ? (92            <Link key={m.key} href={href} className="panel block min-w-0 p-3 transition-colors hover:bg-bg-muted sm:p-4">93              {inner}94            </Link>95          ) : (96            <div key={m.key} className="panel min-w-0 p-3 sm:p-4">97              {inner}98            </div>99          );100        })}101      </div>102103      {/* Desktop grid */}104      <section aria-label="Specifications" className="hidden md:block">105        <div className="grid items-center gap-x-4 border-b border-border pb-2 text-[11px] font-medium uppercase tracking-wide text-fg-subtle" style={colStyle}>106          <span>Spec</span>107          {models.map((m) => (108            <span key={m.key} className="flex items-center gap-1.5 truncate">109              <ProviderIcon provider={m.provider} size={12} /> {m.displayName}110            </span>111          ))}112        </div>113        {rows.map((row) => {114          const mx = row.bar ? max(row) : 0;115          return (116            <div key={row.key} className="grid items-start gap-x-4 border-b border-hairline py-2.5 text-[13px]" style={colStyle}>117              <div>118                <div className="font-medium text-fg-muted">{row.label}</div>119                {row.hint ? <div className="text-[11px] text-fg-subtle">{row.hint}</div> : null}120              </div>121              {models.map((m) => (122                <div key={m.key} className="min-w-0 tabular-nums">123                  <div className="truncate">{row.value(m)}</div>124                  {row.bar ? <Meter value={row.bar(m)} max={mx} className="mt-1.5 w-full max-w-[220px]" label={`${row.label}: ${typeof row.value(m) === "string" ? row.value(m) : ""}`} /> : null}125                </div>126              ))}127            </div>128          );129        })}130      </section>131132      {/* Mobile stacked */}133      <section aria-label="Specifications" className="space-y-3 md:hidden">134        {rows.map((row) => {135          const mx = row.bar ? max(row) : 0;136          return (137            <div key={row.key} className="panel p-3">138              <div className="text-[12px] font-medium text-fg-muted">{row.label}</div>139              {row.hint ? <div className="text-[11px] text-fg-subtle">{row.hint}</div> : null}140              <ul className="mt-2 space-y-2">141                {models.map((m) => (142                  <li key={m.key} className="min-w-0">143                    <div className="flex items-center justify-between gap-3 text-[13px]">144                      <span className="flex min-w-0 items-center gap-1.5 text-fg-muted">145                        <ProviderIcon provider={m.provider} size={13} />146                        <span className="truncate">{m.displayName}</span>147                      </span>148                      <span className="shrink-0 tabular-nums">{row.value(m)}</span>149                    </div>150                    {row.bar ? <Meter value={row.bar(m)} max={mx} className="mt-1" /> : null}151                  </li>152                ))}153              </ul>154            </div>155          );156        })}157      </section>158159      <Matrix title="Capabilities" models={models} rows={(Object.keys(CAPABILITY_LABELS) as (keyof ModelCapabilities)[]).map((k) => ({ key: k, label: CAPABILITY_LABELS[k], on: (m: PolyModel) => Boolean(m.capabilities[k]) }))} />160      <Matrix title="Supported parameters" models={models} rows={PARAM_DEFS.map((d) => ({ key: d.key, label: d.label, on: (m: PolyModel) => d.supports(m), detail: (m: PolyModel) => (d.supports(m) ? d.detail?.(m) ?? null : null) }))} />161    </div>162  );163}164165function Matrix({ title, models, rows }: { title: string; models: PolyModel[]; rows: { key: string; label: string; on: (m: PolyModel) => boolean; detail?: (m: PolyModel) => string | null }[] }) {166  const colStyle: React.CSSProperties = { gridTemplateColumns: `150px repeat(${models.length}, minmax(0, 1fr))` };167  const mobileStyle: React.CSSProperties = { gridTemplateColumns: `minmax(0, 1fr) repeat(${models.length}, 40px)` };168  return (169    <section aria-label={title}>170      <h3 className="mb-2 text-[12px] font-semibold uppercase tracking-wide text-fg-subtle">{title}</h3>171      {/* Desktop */}172      <div className="hidden md:block">173        <div className="grid gap-x-4 border-b border-border pb-2 text-[11px] font-medium uppercase tracking-wide text-fg-subtle" style={colStyle}>174          <span />175          {models.map((m) => (176            <span key={m.key} className="truncate">177              {m.displayName}178            </span>179          ))}180        </div>181        {rows.map((r) => (182          <div key={r.key} className="grid items-center gap-x-4 border-b border-hairline py-2 text-[13px]" style={colStyle}>183            <span className="text-fg-muted">{r.label}</span>184            {models.map((m) => (185              <span key={m.key} className="flex min-w-0 items-center gap-2">186                <Mark on={r.on(m)} />187                {r.detail?.(m) ? <span className="truncate font-mono text-[11px] text-fg-subtle">{r.detail(m)}</span> : null}188              </span>189            ))}190          </div>191        ))}192      </div>193      {/* Mobile: compact check grid with icon headers */}194      <div className="panel p-3 md:hidden">195        <div className="grid items-center gap-x-1 border-b border-hairline pb-2" style={mobileStyle}>196          <span />197          {models.map((m) => (198            <span key={m.key} className="flex justify-center" title={m.displayName}>199              <ProviderIcon provider={m.provider} size={14} />200            </span>201          ))}202        </div>203        {rows.map((r) => (204          <div key={r.key} className="grid items-center gap-x-1 border-b border-hairline py-2 text-[13px] last:border-b-0" style={mobileStyle}>205            <span className="min-w-0 truncate text-fg-muted">{r.label}</span>206            {models.map((m) => (207              <span key={m.key} className="flex justify-center">208                <Mark on={r.on(m)} />209              </span>210            ))}211          </div>212        ))}213        <p className="mt-2 text-[10.5px] text-fg-subtle">Columns: {models.map((m) => m.displayName).join(" · ")}</p>214      </div>215    </section>216  );217}218219function Mark({ on }: { on: boolean }) {220  return on ? <Check className="size-4 text-success" aria-label="yes" /> : <Minus className="size-4 text-border-strong" aria-label="no" />;221}222