diff --git a/packages/stats/app/src/routes/[lab]/index.tsx b/packages/stats/app/src/routes/[lab]/index.tsx index 8b24834119..e66777decf 100644 --- a/packages/stats/app/src/routes/[lab]/index.tsx +++ b/packages/stats/app/src/routes/[lab]/index.tsx @@ -3,8 +3,11 @@ import { Meta, Title } from "@solidjs/meta" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { getStatsLabData, + getStatsHomeData, type LabUsageModelEntry, + type MarketDay, type ModelUsagePoint, + type StatsHomeData, type StatsLabData, } from "@opencode-ai/stats-core/domain/home" import { createAsync, query, useParams } from "@solidjs/router" @@ -15,6 +18,7 @@ import { useI18n } from "../../context/i18n" import { useLanguage } from "../../context/language" import { localizedUrl } from "../../lib/language" import { + catalogSlug, findModelCatalogLab, formatCatalogLabName, getModelCatalog, @@ -42,6 +46,11 @@ const getLabData = query(async (lab: string) => { return runStatsEffect(getStatsLabData(lab)) }, "getStatsLabData") +const getHomeData = query(async () => { + "use server" + return runStatsEffect(getStatsHomeData()) +}, "getStatsHomeData") + type LabModelTooltipState = { model: ModelCatalogEntry placement: "left" | "right" @@ -69,6 +78,7 @@ export default function StatsLab() { if (!entry) return Promise.resolve(null) return getLabData(entry.id) }) + const homeStats = createAsync((): Promise => getHomeData()) const githubStars = createAsync(() => getGitHubStars()) const [themePreference, setThemePreference] = createSignal("system") const labName = createMemo(() => lab()?.name ?? formatCatalogLabName(labParam())) @@ -134,6 +144,7 @@ export default function StatsLab() { + )} @@ -625,7 +636,7 @@ function LabModelTooltip(props: { state: LabModelTooltipState }) { {props.state.model.name}

- Recent OpenCode Go usage, share, context, and output limits. + {props.state.model.description ?? "Recent OpenCode Go usage, share, context, and output limits."}

@@ -651,6 +662,63 @@ function LabModelTooltip(props: { state: LabModelTooltipState }) { ) } +function LabRelatedSection(props: { lab: ModelCatalogLab; labs: ModelCatalogLab[]; market: MarketDay[] }) { + const related = createMemo(() => relatedLabs(props.lab, props.labs, props.market)) + return ( + + ) +} + +function LabRelatedCard(props: { entry: RelatedLabEntry }) { + const language = useLanguage() + const featured = () => props.entry.lab.models[0] + const otherCount = () => Math.max(props.entry.lab.models.length - 1, 0) + const modelSummary = () => { + const model = featured() + if (!model) return props.entry.lab.name + const count = otherCount() + if (count === 0) return model.name + return `${model.name} + ${count} other ${count === 1 ? "model" : "models"}` + } + const activeBars = () => { + if (props.entry.share <= 0) return 0 + return Math.max(1, Math.min(20, Math.round(props.entry.share / 5))) + } + return ( + +
+
+ Used by {formatWholePercent(props.entry.share)} + +
+
+ ) +} + function LabEmptyState(props: { title: string; description: string }) { return (
@@ -660,6 +728,53 @@ function LabEmptyState(props: { title: string; description: string }) { ) } +type RelatedLabEntry = { lab: ModelCatalogLab; share: number; tokens: number } + +function relatedLabs(current: ModelCatalogLab, labs: ModelCatalogLab[], market: MarketDay[]): RelatedLabEntry[] { + const stats = relatedLabStats(labs, market) + return labs + .filter((lab) => lab.id !== current.id) + .map((lab) => stats.get(lab.id) ?? { lab, share: 0, tokens: 0 }) + .toSorted((a, b) => b.tokens - a.tokens || a.lab.name.localeCompare(b.lab.name)) + .slice(0, 3) +} + +function relatedLabStats(labs: ModelCatalogLab[], market: MarketDay[]) { + const labByKey = new Map() + labs.forEach((lab) => { + labByKey.set(lab.id, lab) + labByKey.set(catalogSlug(lab.name), lab) + labByKey.set(catalogSlug(formatCatalogLabName(lab.id)), lab) + }) + + const tokensByLab = new Map() + const total = market.reduce((sum, day) => { + day.authors.forEach((author) => { + const lab = labByKey.get(catalogSlug(author.author)) + if (!lab) return + tokensByLab.set(lab.id, (tokensByLab.get(lab.id) ?? 0) + author.tokens) + }) + return sum + day.total + }, 0) + + return new Map( + labs.map((lab) => { + const tokens = tokensByLab.get(lab.id) ?? 0 + return [lab.id, { lab, tokens, share: total > 0 ? (tokens / total) * 100 : 0 }] + }), + ) +} + +function labRelatedDescription(lab: ModelCatalogLab) { + return lab.description ?? `OpenCode Go usage across ${lab.name} models, recent token volume, and model limits.` +} + +function relatedTone(share: number) { + if (share >= 50) return "high" + if (share > 0 && share < 10) return "low" + return "mid" +} + function formatCatalogLimit(value: number | undefined, unknown: string) { return value === undefined ? unknown : formatTokens(value) } diff --git a/packages/stats/app/src/routes/index.css b/packages/stats/app/src/routes/index.css index 82fa6a8b36..99c77a3851 100644 --- a/packages/stats/app/src/routes/index.css +++ b/packages/stats/app/src/routes/index.css @@ -3608,6 +3608,10 @@ } [data-page="stats"] [data-slot="lab-model-tooltip-summary"] > p { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; margin: 0; color: var(--stats-muted); font-size: 12px; @@ -3656,6 +3660,185 @@ text-align: right; } +[data-page="stats"] [data-section="model-panel"][data-variant="lab-related"] { + min-height: 465px; +} + +[data-page="stats"] [data-component="lab-related-list"] { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + min-width: 0; +} + +[data-page="stats"] [data-component="lab-related-card"] { + position: relative; + display: grid; + grid-template-rows: 42px 40px 20px 1px 24px; + gap: 16px; + min-width: 0; + height: 223px; + overflow: hidden; + box-sizing: border-box; + padding: 16px; + color: var(--stats-text); + background: var(--stats-layer); + border: 1px solid var(--stats-line); + box-shadow: 0 1px 2px #00000008; + text-decoration: none; + transition: + background 140ms ease, + border-color 140ms ease, + box-shadow 140ms ease, + transform 140ms ease; +} + +[data-page="stats"] [data-component="lab-related-card"]:hover, +[data-page="stats"] [data-component="lab-related-card"]:focus-visible { + color: var(--stats-text); + background: var(--stats-bg); + border-color: var(--stats-line-strong); + outline: none; + box-shadow: + 0 0 0 0.5px #00000014, + 0 6px 16px #0000000d, + 0 2px 6px #0000000f; + transform: translateY(-1px); +} + +[data-page="stats"] [data-slot="lab-related-watermark"] { + position: absolute; + top: -128px; + right: -132px; + width: 480px; + height: 480px; + color: var(--stats-bg); + opacity: 0.82; + pointer-events: none; +} + +[data-page="stats"] [data-slot="lab-related-heading"] { + position: relative; + z-index: 1; + display: flex; + align-items: center; + gap: 16px; + min-width: 0; +} + +[data-page="stats"] [data-slot="lab-related-avatar"] { + display: grid; + place-items: center; + flex: 0 0 auto; + width: 28px; + height: 28px; + color: #ffffff; + background: var(--stats-accent-text); + box-shadow: inset 0 0 0 1px #0000001a; +} + +[data-page="stats"] [data-slot="lab-related-avatar"] svg { + width: 21px; + height: 21px; +} + +[data-page="stats"] [data-slot="lab-related-heading"] strong { + min-width: 0; + overflow: hidden; + color: var(--stats-text); + font-size: 28px; + font-weight: 500; + line-height: 42px; + text-overflow: ellipsis; + white-space: nowrap; +} + +[data-page="stats"] [data-slot="lab-related-copy"], +[data-page="stats"] [data-slot="lab-related-models"] { + position: relative; + z-index: 1; + min-width: 0; + overflow: hidden; + margin: 0; + color: var(--stats-muted); + font-size: 13px; + font-weight: 400; + line-height: 20px; +} + +[data-page="stats"] [data-slot="lab-related-copy"] { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +[data-page="stats"] [data-slot="lab-related-models"] { + color: var(--stats-text); + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; +} + +[data-page="stats"] [data-slot="lab-related-divider"] { + position: relative; + z-index: 1; + align-self: center; + height: 1px; + background: var(--stats-line); +} + +[data-page="stats"] [data-slot="lab-related-usage"] { + position: relative; + z-index: 1; + display: flex; + align-items: center; + gap: 2px; + min-width: 0; +} + +[data-page="stats"] [data-slot="lab-related-usage"] > span { + display: inline-flex; + align-items: center; + flex: 0 0 auto; + height: 24px; + padding: 0 8px; + color: var(--stats-text); + background: var(--stats-layer-2); + font-size: 13px; + font-weight: 500; + line-height: 14px; +} + +[data-page="stats"] [data-slot="lab-related-usage"] i { + display: flex; + align-items: center; + gap: 2px; + flex: 0 0 auto; + height: 24px; + padding: 0 6px; + background: var(--stats-layer-2); + font-style: normal; +} + +[data-page="stats"] [data-slot="lab-related-usage"] b { + display: block; + width: 3px; + height: 12px; + background: var(--stats-bar-idle); +} + +[data-page="stats"] [data-component="lab-related-card"][data-tone="high"] [data-slot="lab-related-usage"] b[data-active="true"] { + background: #198b43; +} + +[data-page="stats"] [data-component="lab-related-card"][data-tone="mid"] [data-slot="lab-related-usage"] b[data-active="true"] { + background: #d69a00; +} + +[data-page="stats"] [data-component="lab-related-card"][data-tone="low"] [data-slot="lab-related-usage"] b[data-active="true"] { + background: #b82d35; +} + [data-page="stats"] [data-component="model-usage-chart"] { --model-usage-bar-gap: 6px; --model-usage-dot-size: 6px; @@ -4232,6 +4415,12 @@ opacity: 0.78; } +[data-page="stats"][data-theme="dark"] [data-slot="lab-related-watermark"], +:root[data-stats-theme="dark"] [data-page="stats"]:not([data-theme="light"]) [data-slot="lab-related-watermark"] { + color: var(--stats-line-strong); + opacity: 0.28; +} + [data-page="stats"][data-theme="dark"] button[data-component="cache-ratio-row"][data-active="true"], :root[data-stats-theme="dark"] [data-page="stats"]:not([data-theme="light"]) @@ -4790,6 +4979,22 @@ max-width: calc(100vw - 48px); } + [data-page="stats"] [data-component="lab-related-list"] { + grid-auto-columns: minmax(320px, 392px); + grid-auto-flow: column; + grid-template-columns: none; + width: calc(100% + 48px); + margin-inline: -24px; + padding-inline: 24px; + overflow-x: auto; + overscroll-behavior-x: contain; + scrollbar-width: none; + } + + [data-page="stats"] [data-component="lab-related-list"]::-webkit-scrollbar { + display: none; + } + [data-page="stats"] [data-section="model-hero"] h1 { font-size: 38px; line-height: 1; @@ -5147,6 +5352,14 @@ } @media (max-width: 30rem) { + [data-page="stats"] [data-component="lab-related-list"] { + grid-auto-columns: calc(100vw - 48px); + } + + [data-page="stats"] [data-slot="lab-related-heading"] strong { + font-size: 24px; + } + [data-page="stats"] [data-slot="lab-hero-pattern"] { display: none; } diff --git a/packages/stats/app/src/routes/model-catalog.ts b/packages/stats/app/src/routes/model-catalog.ts index 5733f4a77a..68e0c012da 100644 --- a/packages/stats/app/src/routes/model-catalog.ts +++ b/packages/stats/app/src/routes/model-catalog.ts @@ -1,6 +1,6 @@ import { query } from "@solidjs/router" -export const modelCatalogSourceUrl = "https://models.dev/models.json" +export const modelCatalogSourceUrl = "https://models.dev/catalog.json" export const modelCatalogPricingUrl = "https://models.dev/api.json" export type ModelCatalogCost = { @@ -15,6 +15,7 @@ export type ModelCatalogEntry = { lab: string slug: string name: string + description?: string family?: string knowledge?: string releaseDate?: string @@ -45,6 +46,7 @@ export type ModelCatalogBenchmark = { export type ModelCatalogLab = { id: string name: string + description?: string models: ModelCatalogEntry[] } @@ -113,7 +115,8 @@ export function catalogSlug(value: string) { function buildModelCatalog(payload: unknown, pricingPayload?: unknown): ModelCatalog { const costs = readCatalogCosts(pricingPayload) - const models = (Array.isArray(payload) ? payload : isRecord(payload) ? Object.values(payload) : []) + const labDescriptions = readCatalogLabDescriptions(payload, pricingPayload) + const models = readCatalogModels(payload) .flatMap(readModelCatalogEntry) .map((model) => ({ ...model, @@ -131,6 +134,7 @@ function buildModelCatalog(payload: unknown, pricingPayload?: unknown): ModelCat result[model.lab] = { id: model.lab, name: formatCatalogLabName(model.lab), + description: result[model.lab]?.description ?? labDescriptions.get(model.lab), models: [...(result[model.lab]?.models ?? []), model], } return result @@ -152,6 +156,7 @@ function readModelCatalogEntry(value: unknown): ModelCatalogEntry[] { lab: catalogSlug(lab), slug: catalogSlug(slug), name, + description: stringValue(value.description), family: stringValue(value.family), knowledge: stringValue(value.knowledge), releaseDate: stringValue(value.release_date), @@ -170,6 +175,51 @@ function readModelCatalogEntry(value: unknown): ModelCatalogEntry[] { ] } +function readCatalogModels(payload: unknown): unknown[] { + if (Array.isArray(payload)) return payload + if (!isRecord(payload)) return [] + if (Array.isArray(payload.models)) return payload.models + if (isRecord(payload.models)) return Object.values(payload.models) + return Object.values(payload) +} + +function readCatalogLabDescriptions(...payloads: unknown[]) { + const descriptions = new Map() + const add = (value: unknown, fallbackId?: string) => { + if (!isRecord(value)) return + const description = stringValue(value.description) + if (!description) return + const id = stringValue(value.id) ?? fallbackId + const name = stringValue(value.name) + const keys = [id, name] + keys.forEach((key) => { + if (!key) return + descriptions.set(catalogLabSlug(key), description) + }) + } + const addCollection = (value: unknown) => { + if (Array.isArray(value)) { + value.forEach((item) => add(item)) + return + } + if (!isRecord(value)) return + Object.entries(value).forEach(([key, item]) => add(item, key)) + } + + payloads.forEach((payload) => { + if (Array.isArray(payload)) { + payload.forEach((item) => add(item)) + return + } + if (!isRecord(payload)) return + addCollection(payload.labs) + addCollection(payload.providers) + Object.entries(payload).forEach(([key, value]) => add(value, key)) + }) + + return descriptions +} + async function fetchCatalogPayload(url: string) { return fetch(url) .then((response): Promise => (response.ok ? (response.json() as Promise) : Promise.resolve()))