chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
import { MODEL_CATALOG_PROVIDERS } from '@/app/(landing)/models/utils'
|
||||
|
||||
/**
|
||||
* Luminance ceiling (Rec. 601, 0-255) above which a near-gray provider color is
|
||||
* too light to read as a dot or bar on the landing's light background. OpenAI's
|
||||
* near-white brand gray sits well above this, so it gets darkened to stay visible
|
||||
* on the timeline and comparison charts.
|
||||
*/
|
||||
const MAX_LUMINANCE = 140
|
||||
|
||||
/**
|
||||
* Only desaturated (near-gray) colors are darkened. Saturated brand colors stay
|
||||
* readable on white thanks to their hue, so a bright yellow or orange is left
|
||||
* exactly as the brand declares it.
|
||||
*/
|
||||
const MAX_GRAY_SATURATION = 0.15
|
||||
|
||||
/** Fallback when a provider declares no brand color: a readable mid-dark gray. */
|
||||
const FALLBACK_COLOR = '#666666'
|
||||
|
||||
/** Darkens a too-light near-gray color toward the readable ceiling; passes others through. */
|
||||
function clampToReadable(hex: string): string {
|
||||
const match = /^#?([0-9a-f]{6})$/i.exec(hex)
|
||||
if (!match) return hex
|
||||
|
||||
const value = Number.parseInt(match[1], 16)
|
||||
const r = (value >> 16) & 0xff
|
||||
const g = (value >> 8) & 0xff
|
||||
const b = value & 0xff
|
||||
|
||||
const max = Math.max(r, g, b)
|
||||
const saturation = max === 0 ? 0 : (max - Math.min(r, g, b)) / max
|
||||
const luminance = 0.299 * r + 0.587 * g + 0.114 * b
|
||||
if (luminance <= MAX_LUMINANCE || saturation > MAX_GRAY_SATURATION) return hex
|
||||
|
||||
const factor = MAX_LUMINANCE / luminance
|
||||
const toHex = (channel: number) =>
|
||||
Math.round(channel * factor)
|
||||
.toString(16)
|
||||
.padStart(2, '0')
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
|
||||
}
|
||||
|
||||
const colorMap = new Map(
|
||||
MODEL_CATALOG_PROVIDERS.flatMap(
|
||||
(p): Array<[string, string]> => (p.color ? [[p.id, clampToReadable(p.color)]] : [])
|
||||
)
|
||||
)
|
||||
|
||||
/** Provider brand color, darkened when too light to read on the light background. */
|
||||
export function getProviderColor(providerId: string): string {
|
||||
return colorMap.get(providerId) ?? FALLBACK_COLOR
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import type { ComponentType } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { getProviderColor } from '@/app/(landing)/models/components/constants'
|
||||
import type { CatalogModel } from '@/app/(landing)/models/utils'
|
||||
import {
|
||||
formatPrice,
|
||||
formatTokenCount,
|
||||
MODEL_CATALOG_PROVIDERS,
|
||||
} from '@/app/(landing)/models/utils'
|
||||
|
||||
/** Flagship providers featured in the landing-page comparison, in display order. */
|
||||
const FEATURED_COMPARISON_PROVIDER_IDS = ['anthropic', 'openai', 'google']
|
||||
|
||||
/** Max latest models pulled from each featured provider. */
|
||||
const MAX_MODELS_PER_PROVIDER = 4
|
||||
|
||||
const PROVIDER_ICON_MAP: Record<string, ComponentType<{ className?: string }>> = (() => {
|
||||
const map: Record<string, ComponentType<{ className?: string }>> = {}
|
||||
for (const provider of MODEL_CATALOG_PROVIDERS) {
|
||||
if (provider.icon) {
|
||||
map[provider.id] = provider.icon
|
||||
}
|
||||
}
|
||||
return map
|
||||
})()
|
||||
|
||||
function selectComparisonModels(models: CatalogModel[]): CatalogModel[] {
|
||||
const seen = new Set<string>()
|
||||
const result: CatalogModel[] = []
|
||||
|
||||
for (const providerId of FEATURED_COMPARISON_PROVIDER_IDS) {
|
||||
const providerModels = models
|
||||
.filter((model) => model.providerId === providerId && !model.deprecated)
|
||||
.sort((a, b) => (b.releaseDate ?? '').localeCompare(a.releaseDate ?? ''))
|
||||
|
||||
let takenForProvider = 0
|
||||
for (const model of providerModels) {
|
||||
if (takenForProvider >= MAX_MODELS_PER_PROVIDER) break
|
||||
|
||||
const nameKey = model.displayName.toLowerCase()
|
||||
if (seen.has(nameKey)) continue
|
||||
|
||||
seen.add(nameKey)
|
||||
result.push(model)
|
||||
takenForProvider += 1
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
interface ModelLabelProps {
|
||||
model: CatalogModel
|
||||
}
|
||||
|
||||
function ModelLabel({ model }: ModelLabelProps) {
|
||||
const Icon = PROVIDER_ICON_MAP[model.providerId]
|
||||
|
||||
return (
|
||||
<div className='flex w-[90px] shrink-0 items-center justify-end gap-1.5 sm:w-[140px] lg:w-[180px]'>
|
||||
{Icon && <Icon className='size-3.5 shrink-0' />}
|
||||
<span className='truncate text-[13px] text-[var(--text-primary)] leading-none tracking-[-0.01em]'>
|
||||
{model.displayName}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ChartProps {
|
||||
models: CatalogModel[]
|
||||
}
|
||||
|
||||
function StackedCostChart({ models }: ChartProps) {
|
||||
const entries = models
|
||||
.reduce<Array<{ model: CatalogModel; input: number; output: number; total: number }>>(
|
||||
(acc, model) => {
|
||||
const total = model.pricing.input + model.pricing.output
|
||||
if (total > 0) {
|
||||
acc.push({ model, input: model.pricing.input, output: model.pricing.output, total })
|
||||
}
|
||||
return acc
|
||||
},
|
||||
[]
|
||||
)
|
||||
.sort((a, b) => a.total - b.total)
|
||||
|
||||
const maxTotal = entries.length > 0 ? Math.max(...entries.map((e) => e.total)) : 0
|
||||
const data = { entries, maxTotal }
|
||||
|
||||
if (data.entries.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-3'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<h3 className='text-[20px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em] lg:text-[24px]'>
|
||||
Cost
|
||||
</h3>
|
||||
<span className='text-[var(--text-muted)] text-sm leading-[150%] tracking-[0.02em]'>
|
||||
Per 1M tokens
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
{data.entries.map(({ model, input, output, total }) => {
|
||||
const totalPct = data.maxTotal > 0 ? (total / data.maxTotal) * 100 : 0
|
||||
const inputPct = total > 0 ? (input / total) * 100 : 0
|
||||
const color = getProviderColor(model.providerId)
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={model.id}
|
||||
href={model.href}
|
||||
className='-mx-2 flex items-center gap-3 rounded-md px-2 transition-colors hover:bg-[var(--surface-hover)]'
|
||||
>
|
||||
<ModelLabel model={model} />
|
||||
<div className='relative flex h-7 min-w-0 flex-1 items-center'>
|
||||
<div
|
||||
className='hidden h-full overflow-hidden rounded-r-[3px] sm:flex'
|
||||
style={{ width: `${Math.max(totalPct, 3)}%` }}
|
||||
>
|
||||
<div
|
||||
className='h-full'
|
||||
style={{
|
||||
width: `${inputPct}%`,
|
||||
backgroundColor: color,
|
||||
opacity: 0.8,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className='h-full'
|
||||
style={{
|
||||
width: `${100 - inputPct}%`,
|
||||
backgroundColor: color,
|
||||
opacity: 0.35,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className='shrink-0 text-[11px] text-[var(--text-muted)] sm:ml-2.5 sm:text-xs'>
|
||||
{formatPrice(input)} input / {formatPrice(output)} output
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextWindowChart({ models }: ChartProps) {
|
||||
const entries = models
|
||||
.map((model) => ({
|
||||
model,
|
||||
value: model.contextWindow,
|
||||
}))
|
||||
.filter((e): e is { model: CatalogModel; value: number } => e.value !== null && e.value > 0)
|
||||
.sort((a, b) => a.value - b.value)
|
||||
|
||||
const maxValue = entries.length > 0 ? Math.max(...entries.map((e) => e.value)) : 0
|
||||
const data = { entries, maxValue }
|
||||
|
||||
if (data.entries.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-3'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<h3 className='text-[20px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em] lg:text-[24px]'>
|
||||
Context window
|
||||
</h3>
|
||||
<span className='text-[var(--text-muted)] text-sm leading-[150%] tracking-[0.02em]'>
|
||||
Max tokens
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
{data.entries.map(({ model, value }) => {
|
||||
const pct = data.maxValue > 0 ? (value / data.maxValue) * 100 : 0
|
||||
const color = getProviderColor(model.providerId)
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={model.id}
|
||||
href={model.href}
|
||||
className='-mx-2 flex items-center gap-3 rounded-md px-2 transition-colors hover:bg-[var(--surface-hover)]'
|
||||
>
|
||||
<ModelLabel model={model} />
|
||||
<div className='relative flex h-7 min-w-0 flex-1 items-center'>
|
||||
<div
|
||||
className='h-full rounded-r-[3px]'
|
||||
style={{
|
||||
width: `${Math.max(pct, 3)}%`,
|
||||
backgroundColor: color,
|
||||
opacity: 0.8,
|
||||
}}
|
||||
/>
|
||||
<span className='ml-2.5 shrink-0 text-[11px] text-[var(--text-muted)] sm:text-xs'>
|
||||
{formatTokenCount(value)}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ModelComparisonChartsProps {
|
||||
models: CatalogModel[]
|
||||
}
|
||||
|
||||
export function ModelComparisonCharts({ models }: ModelComparisonChartsProps) {
|
||||
const comparisonModels = selectComparisonModels(models)
|
||||
|
||||
return (
|
||||
<section aria-labelledby='comparison-heading'>
|
||||
<div className='px-6 pt-10 pb-4'>
|
||||
<h2
|
||||
id='comparison-heading'
|
||||
className='mb-2 text-[20px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em] lg:text-[24px]'
|
||||
>
|
||||
Compare models
|
||||
</h2>
|
||||
<p className='text-[var(--text-muted)] text-sm leading-[150%] tracking-[0.02em]'>
|
||||
Side-by-side comparison of top models across key metrics.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='h-px w-full bg-[var(--border)]' />
|
||||
|
||||
<div className='flex flex-col sm:flex-row'>
|
||||
<div className='flex-1 p-6'>
|
||||
<StackedCostChart models={comparisonModels} />
|
||||
</div>
|
||||
<div className='h-px w-full bg-[var(--border)] sm:h-auto sm:w-px' />
|
||||
<div className='flex-1 p-6'>
|
||||
<ContextWindowChart models={comparisonModels} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { ChipInput, Search } from '@sim/emcn'
|
||||
import Link from 'next/link'
|
||||
import { debounce, useQueryStates } from 'nuqs'
|
||||
import { ChevronArrow } from '@/app/(landing)/components/chevron-arrow'
|
||||
import { ProviderIcon } from '@/app/(landing)/models/components/model-primitives'
|
||||
import { modelsParsers, modelsUrlKeys } from '@/app/(landing)/models/search-params'
|
||||
import {
|
||||
type CatalogModel,
|
||||
type CatalogProvider,
|
||||
formatPrice,
|
||||
formatTokenCount,
|
||||
MODEL_PROVIDERS_WITH_CATALOGS,
|
||||
MODEL_PROVIDERS_WITH_DYNAMIC_CATALOGS,
|
||||
} from '@/app/(landing)/models/utils'
|
||||
|
||||
/** Debounce window for writing the search term to the URL (filtering is instant). */
|
||||
const SEARCH_DEBOUNCE_MS = 300
|
||||
|
||||
const PROVIDER_OPTIONS = MODEL_PROVIDERS_WITH_CATALOGS.map((provider) => ({
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
count: provider.modelCount,
|
||||
}))
|
||||
|
||||
export function ModelDirectory() {
|
||||
const [{ q: query, provider }, setParams] = useQueryStates(modelsParsers, modelsUrlKeys)
|
||||
const activeProviderId = provider || null
|
||||
|
||||
const normalizedQuery = query.trim().toLowerCase()
|
||||
|
||||
const { filteredProviders, filteredDynamicProviders } = useMemo(() => {
|
||||
const filteredProviders = MODEL_PROVIDERS_WITH_CATALOGS.map((provider) => {
|
||||
const providerMatchesSearch =
|
||||
normalizedQuery.length > 0 && provider.searchText.includes(normalizedQuery)
|
||||
const providerMatchesFilter = !activeProviderId || provider.id === activeProviderId
|
||||
|
||||
if (!providerMatchesFilter) {
|
||||
return null
|
||||
}
|
||||
|
||||
const models =
|
||||
normalizedQuery.length === 0
|
||||
? provider.models
|
||||
: provider.models.filter(
|
||||
(model) =>
|
||||
model.searchText.includes(normalizedQuery) ||
|
||||
(providerMatchesSearch && normalizedQuery.length > 0)
|
||||
)
|
||||
|
||||
if (!providerMatchesSearch && models.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
...provider,
|
||||
models: providerMatchesSearch && normalizedQuery.length > 0 ? provider.models : models,
|
||||
}
|
||||
}).filter((provider): provider is CatalogProvider => provider !== null)
|
||||
|
||||
const filteredDynamicProviders = MODEL_PROVIDERS_WITH_DYNAMIC_CATALOGS.filter((provider) => {
|
||||
const providerMatchesFilter = !activeProviderId || provider.id === activeProviderId
|
||||
if (!providerMatchesFilter) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!normalizedQuery) {
|
||||
return true
|
||||
}
|
||||
|
||||
return provider.searchText.includes(normalizedQuery)
|
||||
})
|
||||
|
||||
return {
|
||||
filteredProviders,
|
||||
filteredDynamicProviders,
|
||||
}
|
||||
}, [activeProviderId, normalizedQuery])
|
||||
|
||||
const hasResults = filteredProviders.length > 0 || filteredDynamicProviders.length > 0
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='mb-6 flex flex-col gap-4 px-6 sm:flex-row sm:items-center'>
|
||||
<div className='max-w-[480px] flex-1'>
|
||||
<ChipInput
|
||||
icon={Search}
|
||||
type='search'
|
||||
placeholder='Search models, providers, or capabilities…'
|
||||
value={query}
|
||||
onChange={(event) =>
|
||||
setParams(
|
||||
{ q: event.target.value },
|
||||
{ limitUrlUpdates: debounce(SEARCH_DEBOUNCE_MS) }
|
||||
)
|
||||
}
|
||||
aria-label='Search AI models'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mb-6 flex flex-wrap gap-2 px-6'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setParams({ provider: '' })}
|
||||
className={`rounded-[5px] border px-[9px] py-0.5 text-small transition-colors ${
|
||||
activeProviderId === null
|
||||
? 'border-[var(--border-1)] bg-[var(--surface-active)] text-[var(--text-primary)]'
|
||||
: 'border-[var(--border-1)] text-[var(--text-primary)] hover:bg-[var(--surface-hover)]'
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{PROVIDER_OPTIONS.map((provider) => (
|
||||
<button
|
||||
key={provider.id}
|
||||
type='button'
|
||||
onClick={() =>
|
||||
setParams({ provider: activeProviderId === provider.id ? '' : provider.id })
|
||||
}
|
||||
className={`rounded-[5px] border px-[9px] py-0.5 text-small transition-colors ${
|
||||
activeProviderId === provider.id
|
||||
? 'border-[var(--border-1)] bg-[var(--surface-active)] text-[var(--text-primary)]'
|
||||
: 'border-[var(--border-1)] text-[var(--text-primary)] hover:bg-[var(--surface-hover)]'
|
||||
}`}
|
||||
>
|
||||
{provider.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className='h-px w-full bg-[var(--border)]' />
|
||||
|
||||
{!hasResults ? (
|
||||
<div className='px-6 py-12 text-center'>
|
||||
<h3 className='text-[18px] text-[var(--text-primary)]'>No matches found</h3>
|
||||
<p className='mt-2 text-[var(--text-muted)] text-sm leading-[150%]'>
|
||||
Try a provider name like OpenAI or Anthropic, or search for capabilities like
|
||||
structured outputs, reasoning, or deep research.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{filteredProviders.map((provider, index) => (
|
||||
<section key={provider.id} aria-labelledby={`${provider.id}-heading`}>
|
||||
{index > 0 && <div className='h-px w-full bg-[var(--border)]' />}
|
||||
|
||||
<Link
|
||||
href={provider.href}
|
||||
className='group/link flex items-center gap-3 px-6 py-4 transition-colors hover:bg-[var(--surface-hover)]'
|
||||
>
|
||||
<ProviderIcon
|
||||
provider={provider}
|
||||
className='size-8 rounded-xl'
|
||||
iconClassName='size-4'
|
||||
/>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<h3
|
||||
id={`${provider.id}-heading`}
|
||||
className='text-[14px] text-[var(--text-primary)] leading-snug tracking-[-0.02em]'
|
||||
>
|
||||
{provider.name}
|
||||
</h3>
|
||||
<p className='line-clamp-1 hidden text-[12px] text-[var(--text-muted)] leading-[150%] sm:block'>
|
||||
{provider.modelCount} models · {provider.description}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronArrow />
|
||||
</Link>
|
||||
|
||||
{provider.models.map((model) => (
|
||||
<ModelRow key={model.id} provider={provider} model={model} />
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
|
||||
{filteredDynamicProviders.length > 0 && (
|
||||
<section aria-labelledby='dynamic-catalogs-heading'>
|
||||
<div className='h-px w-full bg-[var(--border)]' />
|
||||
|
||||
<div className='px-6 pt-8 pb-6'>
|
||||
<h3
|
||||
id='dynamic-catalogs-heading'
|
||||
className='text-[18px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em] lg:text-[20px]'
|
||||
>
|
||||
Dynamic model catalogs
|
||||
</h3>
|
||||
<p className='mt-2 text-[var(--text-muted)] text-sm leading-[150%]'>
|
||||
These providers load their model lists dynamically at runtime.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='h-px w-full bg-[var(--border)]' />
|
||||
|
||||
<nav aria-label='Dynamic catalog providers' className='flex flex-col lg:flex-row'>
|
||||
{filteredDynamicProviders.map((provider) => (
|
||||
<div
|
||||
key={provider.id}
|
||||
className='flex flex-1 items-center gap-3 border-[var(--border)] border-t px-6 py-4 first:border-t-0 lg:border-t-0 lg:border-l lg:first:border-l-0'
|
||||
>
|
||||
<ProviderIcon
|
||||
provider={provider}
|
||||
className='size-8 rounded-xl'
|
||||
iconClassName='size-4'
|
||||
/>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<h4 className='text-[14px] text-[var(--text-primary)] leading-snug'>
|
||||
{provider.name}
|
||||
</h4>
|
||||
<p className='line-clamp-1 text-[12px] text-[var(--text-muted)] leading-[150%]'>
|
||||
{provider.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ModelRow({ provider, model }: { provider: CatalogProvider; model: CatalogModel }) {
|
||||
return (
|
||||
<>
|
||||
<div className='h-px w-full bg-[var(--border)]' />
|
||||
<Link
|
||||
href={model.href}
|
||||
className='group/link flex items-center gap-4 px-6 py-4 transition-colors hover:bg-[var(--surface-hover)]'
|
||||
>
|
||||
<ProviderIcon
|
||||
provider={provider}
|
||||
className='size-8 shrink-0 rounded-xl'
|
||||
iconClassName='size-4'
|
||||
/>
|
||||
|
||||
<div className='flex min-w-0 flex-1 flex-col gap-0.5'>
|
||||
<h4 className='text-[14px] text-[var(--text-primary)] leading-snug tracking-[-0.02em]'>
|
||||
{model.displayName}
|
||||
</h4>
|
||||
<p className='line-clamp-1 hidden text-[12px] text-[var(--text-muted)] leading-[150%] sm:block'>
|
||||
{model.id} · Input {formatPrice(model.pricing.input)}/1M · Output{' '}
|
||||
{formatPrice(model.pricing.output)}/1M
|
||||
{model.contextWindow ? ` · ${formatTokenCount(model.contextWindow)} context` : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ChevronArrow />
|
||||
</Link>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import Link from 'next/link'
|
||||
import { ProviderIcon } from '@/app/(landing)/models/components/model-primitives/provider-icon'
|
||||
import type { CatalogModel, CatalogProvider } from '@/app/(landing)/models/utils'
|
||||
|
||||
export function FeaturedModelCard({
|
||||
provider,
|
||||
model,
|
||||
}: {
|
||||
provider: CatalogProvider
|
||||
model: CatalogModel
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={model.href}
|
||||
className='group flex flex-1 flex-col gap-4 border-[var(--border)] border-t p-6 transition-colors first:border-t-0 hover:bg-[var(--surface-hover)] sm:border-t-0 sm:border-l sm:first:border-l-0'
|
||||
>
|
||||
<ProviderIcon provider={provider} className='size-10 rounded-xl' iconClassName='size-5' />
|
||||
<div className='flex flex-col gap-2'>
|
||||
<span className='text-[var(--text-muted)] text-xs uppercase tracking-[0.1em]'>
|
||||
{provider.name}
|
||||
</span>
|
||||
<h3 className='text-[var(--text-primary)] text-lg leading-tight tracking-[-0.01em]'>
|
||||
{model.displayName}
|
||||
</h3>
|
||||
<p className='line-clamp-2 text-[var(--text-muted)] text-sm leading-[150%]'>
|
||||
{model.summary}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import Link from 'next/link'
|
||||
import { ProviderIcon } from '@/app/(landing)/models/components/model-primitives/provider-icon'
|
||||
import type { CatalogProvider } from '@/app/(landing)/models/utils'
|
||||
|
||||
export function FeaturedProviderCard({ provider }: { provider: CatalogProvider }) {
|
||||
return (
|
||||
<Link
|
||||
href={provider.href}
|
||||
className='group flex flex-1 flex-col gap-4 border-[var(--border)] border-t p-6 transition-colors first:border-t-0 hover:bg-[var(--surface-hover)] sm:border-t-0 sm:border-l sm:first:border-l-0'
|
||||
>
|
||||
<ProviderIcon provider={provider} className='size-10 rounded-xl' iconClassName='size-5' />
|
||||
<div className='flex flex-col gap-2'>
|
||||
<h3 className='text-[var(--text-primary)] text-lg leading-tight tracking-[-0.01em]'>
|
||||
{provider.name}
|
||||
</h3>
|
||||
<p className='line-clamp-2 text-[var(--text-muted)] text-sm leading-[150%]'>
|
||||
{provider.description}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { FeaturedModelCard } from './featured-model-card'
|
||||
export { FeaturedProviderCard } from './featured-provider-card'
|
||||
export { ProviderIcon } from './provider-icon'
|
||||
@@ -0,0 +1,31 @@
|
||||
import { cn } from '@sim/emcn'
|
||||
import type { CatalogProvider } from '@/app/(landing)/models/utils'
|
||||
|
||||
export function ProviderIcon({
|
||||
provider,
|
||||
className = 'size-12 rounded-xl',
|
||||
iconClassName = 'size-6',
|
||||
}: {
|
||||
provider: Pick<CatalogProvider, 'icon' | 'name'>
|
||||
className?: string
|
||||
iconClassName?: string
|
||||
}) {
|
||||
const Icon = provider.icon
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'flex items-center justify-center border border-[var(--border-1)] bg-[var(--bg)]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{Icon ? (
|
||||
<Icon className={iconClassName} />
|
||||
) : (
|
||||
<span className='text-[14px] text-[var(--text-primary)]'>
|
||||
{provider.name.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import Link from 'next/link'
|
||||
import { getProviderColor } from '@/app/(landing)/models/components/constants'
|
||||
import type { CatalogModel } from '@/app/(landing)/models/utils'
|
||||
|
||||
const SHORT_DATE_FORMAT = new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
})
|
||||
|
||||
function formatShortDate(date: string): string {
|
||||
try {
|
||||
return SHORT_DATE_FORMAT.format(new Date(date))
|
||||
} catch {
|
||||
return date
|
||||
}
|
||||
}
|
||||
|
||||
interface ModelTimelineChartProps {
|
||||
models: CatalogModel[]
|
||||
providerId: string
|
||||
}
|
||||
|
||||
const ITEM_WIDTH = 150
|
||||
|
||||
export function ModelTimelineChart({ models, providerId }: ModelTimelineChartProps) {
|
||||
const entries = models
|
||||
.filter((m) => m.releaseDate !== null)
|
||||
.map((m) => ({
|
||||
model: m,
|
||||
date: new Date(m.releaseDate as string),
|
||||
dateStr: m.releaseDate as string,
|
||||
}))
|
||||
.sort((a, b) => a.date.getTime() - b.date.getTime())
|
||||
|
||||
if (entries.length === 0) return null
|
||||
|
||||
const color = getProviderColor(providerId)
|
||||
|
||||
return (
|
||||
<section aria-labelledby='timeline-heading'>
|
||||
<div className='px-6 pt-10 pb-4'>
|
||||
<h2
|
||||
id='timeline-heading'
|
||||
className='mb-2 text-[20px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em] lg:text-[24px]'
|
||||
>
|
||||
Release timeline
|
||||
</h2>
|
||||
<p className='text-[var(--text-muted)] text-sm leading-[150%] tracking-[0.02em]'>
|
||||
When each model was first publicly available.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='overflow-x-auto px-6 pb-8'>
|
||||
{/* Fixed height: top labels + line + bottom labels */}
|
||||
<div
|
||||
className='relative h-[140px]'
|
||||
style={{ minWidth: `${entries.length * ITEM_WIDTH}px` }}
|
||||
>
|
||||
{/* Horizontal line - vertically centered */}
|
||||
<div className='absolute top-[70px] right-0 left-0 h-px bg-[var(--border-1)]' />
|
||||
|
||||
{entries.map(({ model, dateStr }, i) => {
|
||||
const left = i * ITEM_WIDTH + ITEM_WIDTH / 2
|
||||
const isAbove = i % 2 === 0
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={model.id}
|
||||
href={model.href}
|
||||
className='group absolute flex flex-col items-center'
|
||||
style={{
|
||||
left: `${left}px`,
|
||||
width: `${ITEM_WIDTH}px`,
|
||||
marginLeft: `${-ITEM_WIDTH / 2}px`,
|
||||
top: 0,
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='-translate-x-1/2 absolute top-[64px] left-1/2 size-[12px] rounded-full opacity-[0.85] transition-[opacity,transform] duration-150 group-hover:scale-110 group-hover:opacity-100'
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
|
||||
{/* Stem + label above */}
|
||||
{isAbove && (
|
||||
<div className='-translate-x-1/2 absolute bottom-[74px] left-1/2 flex flex-col items-center'>
|
||||
<div className='flex flex-col items-center gap-0.5 pb-1.5'>
|
||||
<span className='whitespace-nowrap text-[12px] text-[var(--text-primary)] leading-none tracking-[-0.01em] transition-colors group-hover:text-[var(--text-primary)]'>
|
||||
{model.displayName}
|
||||
</span>
|
||||
<span className='whitespace-nowrap text-[10px] text-[var(--text-muted)] leading-none'>
|
||||
{formatShortDate(dateStr)}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className='w-px'
|
||||
style={{ height: '10px', backgroundColor: color, opacity: 0.2 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stem + label below */}
|
||||
{!isAbove && (
|
||||
<div className='-translate-x-1/2 absolute top-[75px] left-1/2 flex flex-col items-center'>
|
||||
<div
|
||||
className='w-px'
|
||||
style={{ height: '10px', backgroundColor: color, opacity: 0.2 }}
|
||||
/>
|
||||
<div className='flex flex-col items-center gap-0.5 pt-1.5'>
|
||||
<span className='whitespace-nowrap text-[12px] text-[var(--text-primary)] leading-none tracking-[-0.01em] transition-colors group-hover:text-[var(--text-primary)]'>
|
||||
{model.displayName}
|
||||
</span>
|
||||
<span className='whitespace-nowrap text-[10px] text-[var(--text-muted)] leading-none'>
|
||||
{formatShortDate(dateStr)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user