Просмотр исходного кода

feat(stats): use catalog descriptions

Adam 1 месяц назад
Родитель
Сommit
3706b33ee0

+ 116 - 1
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<StatsHomeData | undefined> => getHomeData())
   const githubStars = createAsync(() => getGitHubStars())
   const [themePreference, setThemePreference] = createSignal<ThemePreference>("system")
   const labName = createMemo(() => lab()?.name ?? formatCatalogLabName(labParam()))
@@ -134,6 +144,7 @@ export default function StatsLab() {
                   <LabOverview lab={data()} data={stats() ?? null} />
                   <LabUsageSection lab={data()} data={stats() ?? null} />
                   <LabModelsSection lab={data()} usage={stats()?.models ?? []} />
+                  <LabRelatedSection lab={data()} labs={catalog()?.labs ?? []} market={homeStats()?.market["2M"] ?? []} />
                 </>
               )}
             </Show>
@@ -625,7 +636,7 @@ function LabModelTooltip(props: { state: LabModelTooltipState }) {
           <strong>{props.state.model.name}</strong>
         </div>
         <p>
-          Recent OpenCode Go usage, share, context, and output limits.
+          {props.state.model.description ?? "Recent OpenCode Go usage, share, context, and output limits."}
         </p>
       </div>
       <div data-slot="tooltip-divider" />
@@ -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 (
+    <section id="related-labs" data-section="model-panel" data-variant="lab-related">
+      <SectionHeading href="#related-labs" title="Related labs" description="Explore more." />
+      <div data-component="lab-related-list">
+        <For each={related()}>
+          {(entry) => <LabRelatedCard entry={entry} />}
+        </For>
+      </div>
+    </section>
+  )
+}
+
+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 (
+    <a
+      data-component="lab-related-card"
+      data-tone={relatedTone(props.entry.share)}
+      href={language.route(`${import.meta.env.BASE_URL}${props.entry.lab.id}`)}
+    >
+      <ProviderIcon data-slot="lab-related-watermark" aria-hidden="true" id={getProviderIconId(props.entry.lab.id)} />
+      <div data-slot="lab-related-heading">
+        <span data-slot="lab-related-avatar" aria-hidden="true">
+          <ProviderIcon id={getProviderIconId(props.entry.lab.id)} />
+        </span>
+        <strong>{props.entry.lab.name}</strong>
+      </div>
+      <p data-slot="lab-related-copy">{labRelatedDescription(props.entry.lab)}</p>
+      <p data-slot="lab-related-models">{modelSummary()}</p>
+      <div data-slot="lab-related-divider" />
+      <div data-slot="lab-related-usage">
+        <span>Used by {formatWholePercent(props.entry.share)}</span>
+        <i aria-hidden="true">
+          <For each={Array.from({ length: 20 })}>
+            {(_, index) => <b data-active={index() < activeBars() ? "true" : undefined} />}
+          </For>
+        </i>
+      </div>
+    </a>
+  )
+}
+
 function LabEmptyState(props: { title: string; description: string }) {
   return (
     <div data-component="empty-state" data-compact="true">
@@ -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<string, ModelCatalogLab>()
+  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<string, number>()
+  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)
 }

+ 213 - 0
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;
   }

+ 52 - 2
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<string, string>()
+  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<unknown> => (response.ok ? (response.json() as Promise<unknown>) : Promise.resolve()))