diff --git a/packages/stats/app/src/routes/compare/index.tsx b/packages/stats/app/src/routes/compare/index.tsx index 23e134225f..220048ddc1 100644 --- a/packages/stats/app/src/routes/compare/index.tsx +++ b/packages/stats/app/src/routes/compare/index.tsx @@ -1,11 +1,16 @@ import "../index.css" -import { Link, Meta, Title } from "@solidjs/meta" +import { Meta, Title } from "@solidjs/meta" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { createAsync } from "@solidjs/router" -import { createMemo, createSignal, onMount, Show } from "solid-js" +import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js" import { getRequestEvent } from "solid-js/web" -import { ComparisonCardsSection, modelRefFromCatalog, uniqueComparisonPairs } from "../compare-cards" -import { ComparisonSelector } from "../compare-selector" -import { getModelCatalog, type ModelCatalogEntry } from "../model-catalog" +import { LocaleLinks } from "../../component/locale-links" +import { useI18n } from "../../context/i18n" +import { useLanguage } from "../../context/language" +import { localizedUrl } from "../../lib/language" +import { comparisonHref, modelRefFromCatalog, type ComparisonModelRef } from "../compare-cards" +import { formatCatalogLabName, getModelCatalog, type ModelCatalogEntry } from "../model-catalog" +import { setStatsPageCacheHeaders } from "../stats-cache" import { applyThemePreference, Footer, @@ -17,35 +22,75 @@ import { type ThemePreference, } from "../stats-shell" -const compareFallbackUrl = "https://stats.opencode.ai" +const comparePath = "/data/compare" const compareTitle = "AI Model Comparison" const compareDescription = - "Compare AI models used in OpenCode by context, output, release date, usage, rank, token share, and cost." -const compareHeaderLinks: readonly HeaderLink[] = [ - { href: "#compare-tool", label: "Compare" }, - { href: "#model-comparison", label: "Popular" }, -] -const compareFooterLinks: readonly HeaderLink[] = [ - { href: import.meta.env.BASE_URL, label: "Data Home" }, - { href: `${import.meta.env.BASE_URL}#top-models`, label: "Top Models" }, - { href: `${import.meta.env.BASE_URL}#token-cost`, label: "Token Cost" }, - { href: `${import.meta.env.BASE_URL}compare`, label: "Model Compare" }, -] + "Compare AI models on key metrics including benchmarks, price, context length, and other model features." +const statsUnfurlPath = "banner.png" +const uptimeBars = Array.from({ length: 10 }, (_, index) => index) +const heroLabs = [ + { lab: "deepseek", label: "DeepSeek" }, + { lab: "openai", label: "OpenAI" }, + { lab: "anthropic", label: "Anthropic" }, +] as const +const categoryTemplates = [ + { + title: "Flagship models", + description: "The latest top-tier model from major labs.", + kind: "flagship", + }, + { + title: "Most affordable", + description: "Compact, lower-cost picks for high-throughput use.", + kind: "affordable", + }, + { + title: "Best for code", + description: "Frequently chosen for programming tasks.", + kind: "code", + }, + { + title: "Image generation", + description: "Compare top image models on cost and quality.", + kind: "image", + }, +] as const + +type CompareCategory = { + title: string + description: string + first: ComparisonModelRef + second: ComparisonModelRef + avatars: ComparisonModelRef[] +} +type CompareSlot = "first" | "second" export default function ModelCompareIndex() { + const i18n = useI18n() + const language = useLanguage() const event = getRequestEvent() - event?.response.headers.set("Cache-Control", "public, max-age=60, s-maxage=300, stale-while-revalidate=86400") + setStatsPageCacheHeaders(event?.response.headers) const catalog = createAsync(() => getModelCatalog()) const githubStars = createAsync(() => getGitHubStars()) const [themePreference, setThemePreference] = createSignal("system") - const compareUrl = createMemo(() => - new URL( - `${import.meta.env.BASE_URL}compare`, - event?.request.url ?? (typeof window === "undefined" ? compareFallbackUrl : window.location.href), - ).toString(), - ) + const compareUrl = createMemo(() => localizedUrl(language.locale(), comparePath)) + const statsUnfurlUrl = new URL(statsUnfurlPath, localizedUrl("en", "/data/")).toString() const featuredModels = createMemo(() => (catalog()?.models ?? []).slice(0, 120)) - const pairs = createMemo(() => buildPopularPairs(featuredModels())) + const categories = createMemo(() => buildComparisonCategories(featuredModels())) + const compareHeaderLinks = createMemo(() => [ + { href: `${import.meta.env.BASE_URL}#top-models`, label: i18n.t("nav.topModels") }, + { href: `${import.meta.env.BASE_URL}#leaderboard`, label: i18n.t("nav.leaderboard") }, + { href: `${import.meta.env.BASE_URL}#market-share`, label: i18n.t("nav.marketShare") }, + { href: `${import.meta.env.BASE_URL}#token-cost`, label: i18n.t("nav.tokenCost") }, + { href: `${import.meta.env.BASE_URL}#session-cost`, label: i18n.t("nav.sessionCost") }, + ]) + const compareFooterLinks = createMemo(() => [ + { href: `${import.meta.env.BASE_URL}#top-models`, label: i18n.t("nav.topModels") }, + { href: `${import.meta.env.BASE_URL}#leaderboard`, label: i18n.t("nav.leaderboard") }, + { href: `${import.meta.env.BASE_URL}#market-share`, label: i18n.t("nav.marketShare") }, + { href: `${import.meta.env.BASE_URL}#token-cost`, label: i18n.t("nav.tokenCost") }, + { href: `${import.meta.env.BASE_URL}#session-cost`, label: i18n.t("nav.sessionCost") }, + ]) const updateThemePreference = (preference: ThemePreference) => { applyThemePreference(preference) setThemePreference(preference) @@ -65,84 +110,527 @@ export default function ModelCompareIndex() {
{compareTitle} - + - + + + + + + -
+ + +
-
- - Data - -
-
- /compare -

Model Comparison

-

Choose two models and compare usage, cost, limits, and features.

-
-
- Model Pairs - {formatPairCount(featuredModels().length)} -

Available pairings from the model list.

-
+
+ +
+

+ Compare + + AI models +

+

{compareDescription}

-
+
1} - fallback={ -
- No models found -

The model list could not be loaded.

-
- } + fallback={ +
+ No models found +

The model list could not be loaded.

+
+ } > - +
- + 0}> +
+

+ Related comparisons. Other model pairs to check. +

+
+ {(category) => } +
+
+
) } -function buildPopularPairs(models: ModelCatalogEntry[]) { - return uniqueComparisonPairs( - ( - [ - [0, 1, "Popular pair"], - [0, 2, "Top alternative"], - [1, 2, "Nearby pair"], - [2, 3, "Recent model pair"], - [3, 4, "Model pair"], - [4, 5, "Model pair"], - ] as const - ).flatMap(([firstIndex, secondIndex, detail]) => { - const first = models[firstIndex] - const second = models[secondIndex] - return first && second ? [{ first: modelRefFromCatalog(first), second: modelRefFromCatalog(second), detail }] : [] - }), +function CompareHomeSelector(props: { models: ModelCatalogEntry[] }) { + const [firstId, setFirstId] = createSignal("") + const [secondId, setSecondId] = createSignal("") + const [activeSlot, setActiveSlot] = createSignal() + const modelById = createMemo(() => new Map(props.models.map((model) => [model.id, model]))) + const first = createMemo(() => modelById().get(firstId())) + const second = createMemo(() => modelById().get(secondId())) + const activeSelected = createMemo(() => (activeSlot() === "first" ? first() : second())) + const activeBlockedId = createMemo(() => (activeSlot() === "first" ? secondId() : firstId())) + const href = createMemo(() => { + const firstModel = first() + const secondModel = second() + if (!firstModel || !secondModel || firstModel.id === secondModel.id) return undefined + return comparisonHref(modelRefFromCatalog(firstModel), modelRefFromCatalog(secondModel)) + }) + + createEffect(() => { + if (!secondId() || secondId() !== firstId()) return + setSecondId("") + }) + + createEffect(() => { + const url = href() + if (!url || typeof window === "undefined") return + window.location.href = url + }) + + return ( +
event.preventDefault()} + > + setActiveSlot("first")} + /> + setActiveSlot("second")} + /> + + {(slot) => ( + setActiveSlot(undefined)} + onSelect={(model) => { + if (slot() === "first") setFirstId(model.id) + if (slot() === "second") setSecondId(model.id) + setActiveSlot(undefined) + }} + /> + )} + + ) } -function formatPairCount(count: number) { - if (count < 2) return "0" - return new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }).format((count * (count - 1)) / 2) +function CompareHomeSelect(props: { + selected: ModelCatalogEntry | undefined + label: string + expanded: boolean + onOpen: () => void +}) { + return ( + + ) +} + +function CompareModelSelectModal(props: { + models: ModelCatalogEntry[] + selected: ModelCatalogEntry | undefined + blockedId: string + label: string + onClose: () => void + onSelect: (model: ModelCatalogEntry) => void +}) { + let searchInput: HTMLInputElement | undefined + const [search, setSearch] = createSignal("") + const [previewId, setPreviewId] = createSignal(props.selected?.id ?? "") + const availableModels = createMemo(() => props.models.filter((model) => model.id !== props.blockedId)) + const filteredModels = createMemo(() => { + const terms = search().trim().toLowerCase().split(/\s+/).filter(Boolean) + if (terms.length === 0) return availableModels() + return availableModels().filter((model) => terms.every((term) => modelSearchText(model).includes(term))) + }) + const preview = createMemo( + () => + filteredModels().find((model) => model.id === previewId()) ?? + filteredModels()[0] ?? + availableModels()[0], + ) + + createEffect(() => { + const selected = props.selected + const fallback = selected && selected.id !== props.blockedId ? selected.id : filteredModels()[0]?.id + if (!fallback) return + if (availableModels().some((model) => model.id === previewId())) return + setPreviewId(fallback) + }) + + createEffect(() => { + const models = filteredModels() + if (models.some((model) => model.id === previewId())) return + setPreviewId(models[0]?.id ?? "") + }) + + createEffect(() => { + if (typeof window === "undefined") return + window.requestAnimationFrame(() => searchInput?.focus()) + }) + + return ( +
{ + if (event.key !== "Escape") return + props.onClose() + }} + > +
event.stopPropagation()} + > +
+ +
+ 0} + fallback={ +
+ No models found + Try another search. +
+ } + > + + {(model) => ( + + )} + +
+
+
+ +
+ ) +} + +function CompareModelDetail(props: { model: ModelCatalogEntry }) { + return ( + + ) +} + +function CompareModelFact(props: { label: string; value: string }) { + return ( +
+
{props.label}
+
{props.value}
+
+ ) +} + +function HeroModelStack() { + return ( + + ) +} + +function CompareHomeCard(props: { category: CompareCategory }) { + return ( + + + + {props.category.title} + {props.category.description} + +