diff --git a/packages/console/app/public/robots.txt b/packages/console/app/public/robots.txt index bddac69deae..12850523e5e 100644 --- a/packages/console/app/public/robots.txt +++ b/packages/console/app/public/robots.txt @@ -3,4 +3,7 @@ Allow: / # Disallow shared content pages Disallow: /s/ -Disallow: /share/ \ No newline at end of file +Disallow: /share/ + +Sitemap: https://opencode.ai/sitemap.xml +Sitemap: https://opencode.ai/data/sitemap.xml diff --git a/packages/stats/app/src/component/model-compare-detail.tsx b/packages/stats/app/src/component/model-compare-detail.tsx new file mode 100644 index 00000000000..41261893ce9 --- /dev/null +++ b/packages/stats/app/src/component/model-compare-detail.tsx @@ -0,0 +1,1174 @@ +import "../routes/index.css" +import { Link, Meta, Title } from "@solidjs/meta" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { + getStatsModelsComparisonData, + type ModelUsagePoint, + type StatsModelComparisonInput, + type StatsModelComparisonEntry, +} from "@opencode-ai/stats-core/domain/home" +import { runtime } from "@opencode-ai/stats-core/runtime" +import { createAsync, query, useParams, useSearchParams } from "@solidjs/router" +import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js" +import { getRequestEvent } from "solid-js/web" +import { + ComparisonCardsSection, + comparisonHref, + modelRefFromCatalog, + uniqueComparisonPairs, + type ComparisonModelRef, + type ComparisonPair, +} from "../routes/compare-cards" +import { ComparisonRadar } from "../routes/compare-radar" +import { + catalogSlug, + findModelCatalogEntry, + formatCatalogLabName, + getModelCatalog, + type ModelCatalog, + type ModelCatalogEntry, +} from "../routes/model-catalog" +import { + applyThemePreference, + Footer, + getGitHubStars, + Header, + isThemePreference, + themeStorageKey, + type HeaderLink, + type ThemePreference, +} from "../routes/stats-shell" +import { + canonicalFamilyComparisonPath, + canonicalModelComparisonPath, + latestFamilyComparisonPath, + type ResolvedComparisonFamily, +} from "../lib/comparison-pages" +import { baseUrl } from "../lib/language" + +const compareHeaderLinks: readonly HeaderLink[] = [ + { href: `${import.meta.env.BASE_URL}#top-models`, label: "Top Models" }, + { href: `${import.meta.env.BASE_URL}#leaderboard`, label: "Leaderboard" }, + { href: `${import.meta.env.BASE_URL}#market-share`, label: "Market Share" }, + { href: `${import.meta.env.BASE_URL}#token-cost`, label: "Token Cost" }, + { href: `${import.meta.env.BASE_URL}#session-cost`, label: "Session Cost" }, +] +const compareFooterLinks: readonly HeaderLink[] = [ + { href: import.meta.env.BASE_URL, label: "Data Home" }, + { href: `${import.meta.env.BASE_URL}compare`, label: "Model Compare" }, + { href: `${import.meta.env.BASE_URL}#top-models`, label: "Top Models" }, + { href: `${import.meta.env.BASE_URL}#token-cost`, label: "Token Cost" }, +] +const heroLabs = [ + { lab: "deepseek", label: "DeepSeek" }, + { lab: "openai", label: "OpenAI" }, + { lab: "anthropic", label: "Anthropic" }, +] as const +const usageBarLimit = 60 +const comparisonModelLimit = 6 + +type ComparisonModel = { + name: string + lab: string + labName: string + slug: string + catalog: ModelCatalogEntry | null + stats: StatsModelComparisonEntry | null +} +type ComparisonDirection = "higher" | "lower" +type ComparisonDetailCell = { + value: string + unit?: string + href?: string + kind?: "boolean" + score?: number + trend?: number +} +type ComparisonDetailRow = { + label: string + direction?: ComparisonDirection + cells: ComparisonDetailCell[] +} +type ComparisonDetailSection = { + title: string + badge?: string + rows: ComparisonDetailRow[] + usage?: ModelUsagePoint[][] +} +type ComparisonModelSelection = { + lab: string + slug: string + catalog: ModelCatalogEntry | null | undefined +} +type ComparisonModels = [ComparisonModel, ComparisonModel, ...ComparisonModel[]] + +export type ModelCompareDetailPageProps = { + first?: { lab: string; slug: string } + second?: { lab: string; slug: string } + family?: { first: ResolvedComparisonFamily; second: ResolvedComparisonFamily } + catalog?: ModelCatalog +} + +const getComparisonData = query(async (models: StatsModelComparisonInput[]) => { + "use server" + return runtime.runPromise(getStatsModelsComparisonData(models)) +}, "getStatsModelComparisonDetailData") + +export default function ModelCompareDetailPage(props: ModelCompareDetailPageProps = {}) { + const event = getRequestEvent() + event?.response.headers.set("Cache-Control", "public, max-age=60, s-maxage=300, stale-while-revalidate=86400") + const params = useParams() + const [searchParams] = useSearchParams<{ add?: string }>() + const firstLabParam = createMemo(() => props.first?.lab ?? params.firstLab ?? "") + const firstModelParam = createMemo(() => props.first?.slug ?? params.firstModel ?? "") + const secondLabParam = createMemo(() => props.second?.lab ?? params.secondLab ?? "") + const secondModelParam = createMemo(() => props.second?.slug ?? params.secondModel ?? "") + const catalogData = createAsync(() => getModelCatalog()) + const catalog = createMemo(() => props.catalog ?? catalogData()) + const firstCatalog = createMemo(() => resolvedCatalogEntry(catalog(), firstLabParam(), firstModelParam())) + const secondCatalog = createMemo(() => resolvedCatalogEntry(catalog(), secondLabParam(), secondModelParam())) + const modelSelections = createMemo(() => { + const selected: ComparisonModelSelection[] = [ + { lab: firstLabParam(), slug: firstModelParam(), catalog: firstCatalog() }, + { lab: secondLabParam(), slug: secondModelParam(), catalog: secondCatalog() }, + ...parseAdditionalModels(searchParams.add).map((model) => ({ + ...model, + catalog: resolvedCatalogEntry(catalog(), model.lab, model.slug), + })), + ] + return selected + .filter( + (model, index) => + index < 2 || + selected.findIndex( + (candidate) => comparisonModelSelectionKey(candidate) === comparisonModelSelectionKey(model), + ) === index, + ) + .slice(0, comparisonModelLimit) as [ + ComparisonModelSelection, + ComparisonModelSelection, + ...ComparisonModelSelection[], + ] + }) + const stats = createAsync(() => { + const selected = modelSelections() + if (catalog() === undefined || selected.some((model) => model.catalog === undefined)) + return Promise.resolve(undefined) + return getComparisonData( + selected.map((model) => ({ + provider: model.catalog?.lab ?? model.lab, + model: model.catalog?.slug ?? model.slug, + })), + ) + }) + const githubStars = createAsync(() => getGitHubStars()) + const [themePreference, setThemePreference] = createSignal("system") + const [highlightBest, setHighlightBest] = createSignal(true) + const [addingModel, setAddingModel] = createSignal(false) + let comparisonHeadingScroll: HTMLDivElement | undefined + let comparisonBodyScroll: HTMLDivElement | undefined + const models = createMemo( + () => + modelSelections().map((model, index) => + buildComparisonModel(model.lab, model.slug, model.catalog ?? null, stats()?.models[index] ?? null), + ) as ComparisonModels, + ) + const title = createMemo(() => { + if (props.family) + return `${props.family.first.name} vs ${props.family.second.name}: ${models()[0].name} vs ${models()[1].name}` + return `${models()[0].name} vs ${models()[1].name} - Model Comparison` + }) + const description = createMemo(() => { + if (props.family) + return `Compare the latest ${props.family.first.name} and ${props.family.second.name} models: ${models()[0].name} vs ${models()[1].name}. See benchmarks, usage, price, context length, and features.` + return `Compare ${models()[0].name} and ${models()[1].name} by usage, rank, context window, output limit, cache ratio, and cost across OpenCode data.` + }) + const canonicalPath = createMemo(() => { + if (props.family) return canonicalFamilyComparisonPath(props.family.first, props.family.second) + const first = firstCatalog() + const second = secondCatalog() + const source = catalog() + if (source && first && second) + return latestFamilyComparisonPath(source, first, second) ?? canonicalModelComparisonPath(first, second) + return canonicalModelComparisonPath(comparisonCatalogEntry(models()[0]), comparisonCatalogEntry(models()[1])) + }) + const canonicalUrl = createMemo(() => new URL(canonicalPath(), baseUrl).toString()) + const detailSections = createMemo(() => buildComparisonDetailSections(models())) + const relatedPairs = createMemo(() => buildRelatedPairs(catalog(), models()[0], models()[1])) + const selectorModels = createMemo(() => + uniqueCatalogModels([...models().map(comparisonCatalogEntry), ...(catalog()?.models ?? [])]), + ) + const selectedCatalogModels = createMemo(() => models().map(comparisonCatalogEntry)) + const canAddModel = createMemo( + () => + models().length < comparisonModelLimit && + selectorModels().some((model) => !selectedCatalogModels().some((selected) => selected.id === model.id)), + ) + const navigateToModels = (next: ModelCatalogEntry[]) => { + if (typeof window === "undefined" || next.length < 2) return + window.location.href = comparisonModelsHref(next) + } + const syncComparisonScroll = (source: HTMLDivElement, target: HTMLDivElement | undefined) => { + if (target && target.scrollLeft !== source.scrollLeft) target.scrollLeft = source.scrollLeft + } + const structuredData = createMemo(() => + JSON.stringify({ + "@context": "https://schema.org", + "@type": "WebPage", + name: title(), + description: description(), + url: canonicalUrl(), + about: models().map((model) => ({ + "@type": "SoftwareApplication", + name: model.name, + applicationCategory: "AI model", + provider: model.labName, + })), + }), + ) + const updateThemePreference = (preference: ThemePreference) => { + applyThemePreference(preference) + setThemePreference(preference) + if (typeof window === "undefined") return + window.localStorage.setItem(themeStorageKey, preference) + } + + onMount(() => { + if (typeof window === "undefined") return + const preference = window.localStorage.getItem(themeStorageKey) + const nextPreference = isThemePreference(preference) ? preference : "system" + applyThemePreference(nextPreference) + setThemePreference(nextPreference) + }) + + return ( +
+ + {title()} + + 2 ? "noindex,follow" : "index,follow"} /> + + + + + + + + + + + +
+
+
+ setAddingModel(true)} + onHighlightBestChange={() => setHighlightBest(!highlightBest())} + /> + + model.id)} + label="Add model" + models={selectorModels()} + onClose={() => setAddingModel(false)} + onSelect={(model) => { + setAddingModel(false) + navigateToModels([...selectedCatalogModels(), model]) + }} + /> + + +
+
(comparisonHeadingScroll = element)} + onScroll={(event) => syncComparisonScroll(event.currentTarget, comparisonBodyScroll)} + > + +
+
(comparisonBodyScroll = element)} + onScroll={(event) => syncComparisonScroll(event.currentTarget, comparisonHeadingScroll)} + > + +
+ Loading comparison +

Loading model stats.

+
+ + } + > + +
+
+
+ +
+
+
+
+ ) +} + +function ComparisonHero(props: { + models: readonly ComparisonModel[] + canAddModel: boolean + highlightBest: boolean + onAddModel: () => void + onHighlightBestChange: () => void +}) { + return ( +
+ +
+

model.name).join(", ")}`}> + Compare + + AI models +

+
+ + +
+
+
+ ) +} + +function HeroModelStack() { + return ( + + ) +} + +function ComparisonPairSelector(props: { catalogModels: ModelCatalogEntry[]; models: readonly ComparisonModel[] }) { + const [activeIndex, setActiveIndex] = createSignal() + const selectedModels = createMemo(() => props.models.map(comparisonCatalogEntry)) + const activeSelected = createMemo(() => { + const index = activeIndex() + if (index === undefined) return undefined + return selectedModels()[index] + }) + const blockedIds = createMemo(() => + selectedModels() + .filter((_, index) => index !== activeIndex()) + .map((model) => model.id), + ) + const navigateToSelection = (index: number, model: ModelCatalogEntry) => { + if (typeof window === "undefined") return + window.location.href = comparisonModelsHref( + selectedModels().map((selected, selectedIndex) => (selectedIndex === index ? model : selected)), + ) + } + + return ( +
+
+ + + {(selected) => ( + setActiveIndex(undefined)} + onSelect={(model) => { + const index = activeIndex() + if (index === undefined) return + setActiveIndex(undefined) + navigateToSelection(index, model) + }} + /> + )} + +
+ ) +} + +function CompareDetailSelectButton(props: { + model: ComparisonModel + label: string + column: number + last: boolean + expanded: boolean + onOpen: () => void +}) { + return ( + + ) +} + +function CompareModelSelectModal(props: { + models: ModelCatalogEntry[] + selected?: ModelCatalogEntry + blockedIds: 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(() => + uniqueCatalogModels([...(props.selected ? [props.selected] : []), ...props.models]).filter( + (model) => !props.blockedIds.includes(model.id), + ), + ) + 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 models = filteredModels() + if (models.some((model) => model.id === previewId())) return + const selected = + props.selected && models.some((model) => model.id === props.selected?.id) ? props.selected.id : undefined + setPreviewId(selected ?? 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; href?: string }) { + return ( +
+
{props.label}
+
+ + {(href) => {props.value}} + +
+
+ ) +} + +function ComparisonDetailMatrix(props: { + sections: ComparisonDetailSection[] + highlightBest: boolean + modelCount: number +}) { + return ( +
+
+ + {(section) => ( +
+ +
+ {section.title} + {(badge) => {badge()}} +
+ + {(_, index) => ( +
+ )} +
+
+
+ ) +} + +function ComparisonDetailSpacer(props: { modelCount: number }) { + return ( + <> +