mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-06 17:46:23 +00:00
* 🎨 feat(web/default): add shadcn-style theme presets, radius prefs, and fix selection badges Integrate the qn-platform–style OKLCH color system into the default frontend while keeping the existing blue-tinted dark tokens for the default theme. Add [data-theme-preset] palettes for seven named presets plus the default zinc-like scale, define [data-theme-radius] overrides so user radius beats preset --radius, and align the Tailwind @custom-variant dark helper with .dark usage. Introduce ThemeCustomizationProvider to own preset and radius state, persist choices in cookies (theme-preset, theme-radius), and sync data-theme-preset / data-theme-radius on <html>. Wrap the tree in main.tsx. Extend ConfigDrawer with theme preset swatches (scoped data-theme-preset) and radius previews wired to context; refactor swatch/card markup so selected CircleCheck badges sit outside clipped rows (remove outer overflow-hidden that hid the centered checkmark). Add i18n keys for preset names, radius, and accessibility labels across en, zh, fr, ja, ru, vi. * 🎨 fix(web): align segmented controls with theme radius tokens - Replace hard-coded inner pill radii (rounded-[5px]) on dashboard chart toolbars with radius-md so the active state follows --radius when users change Radius in Theme Settings. - Use nested radii consistent with TabsList/TabsTrigger: outer rounded-lg (var(--radius)) and inner rounded-md (calc(var(--radius) - 2px)) so the track and active thumb stay concentric at small scales (e.g. 0.3rem) instead of a squared “focus” block inside a rounded shell. - Apply the same pattern to pricing SegmentedControl and the segmented groups in consumption-distribution-chart, model-charts, and user-charts. Verified: bun run typecheck (web/default) * ✨ feat(pricing): enrich model details with uptime sparkline and API documentation Add a compact 30-day uptime sparkline (OpenRouter-style bars + aggregate %) with per-day tooltips, surface it in a status row under quick stats and in the per-group performance table, and extend mock data so uptime series are stable and optionally scoped by group. Introduce an API tab with Shiki-highlighted code samples (cURL, Python, TypeScript, JavaScript), endpoint-type switching, authentication guidance, a supported-parameters table, and mock per-group RPM/TPM/RPD limits. Infer vendor, tokenizer, license, and data-retention hints for a provider & data privacy card on the Overview tab (capabilities/modalities stay with model identity; rate limits stay with the API tab). Update i18n for all new user-facing strings across en, zh, fr, ja, ru, and vi. * 🏆 feat(rankings): add comprehensive rankings dashboard Add a mock-data powered rankings experience with period tabs, model, app, and vendor leaderboards, market share and history charts, movers, new releases, and per-category sections while backend analytics are pending. Link ranked models to pricing details and ranked vendors to filtered pricing results, and include localized copy for all supported frontend locales. * fix(theme): correct theme preset selection state - update Base UI Radio selectors to use data-checked/data-unchecked states. - fix unchecked theme options still showing selected indicators. - isolate the default theme preview tokens to prevent preset changes from leaking into it. * fix(setup): correct usage mode radio state - use Base UI data-checked/data-unchecked states for RadioGroup styling. - hide radio indicators when options are unchecked to avoid setup page display issues. - drive usage mode card and icon selection styles from Base UI state. * fix(auth): submit sign-in and sign-up forms * 🎨 refactor: Align default theme with shadcn Base Nova and prune legacy customization Migrate shadcn UI to Base UI primitives via CLI (`base-nova` / `components.json`) and reinstall full component registry with `--overwrite`, including Hugeicons-backed widgets and newly added registry components. - Remove custom multi-preset/theme-radius system (`ThemeCustomizationProvider`, cookies, preset UI from config drawer); rely on official semantic CSS tokens + light/dark only. - Replace `theme.css` with shadcn’s documented neutral `:root`/`.dark` palette and `@theme inline` mappings (plus skeleton token vars for existing shimmer usage). - Update global styles for Base UI: collapsible animation uses `--collapsible-panel-height`; clarify scroll-lock override comment. Application compatibility: - Keep minimal shims where app code diverged from official APIs (popover collision props, combobox legacy `options` callers, Spinner prop typing). - Switch interactive styling from Radix-era `data-state` / `--radix-*` selectors to Base UI semantics (`data-open`, `data-popup-open`, `data-panel-open`, `--anchor-width`, etc.) Tooling / docs / build: - Rename Rsbuild vendor chunk grouping to `@base-ui` + transitive `@radix-ui`. - Refresh AGENTS.md / CLAUDE.md / classic→default sync skill for Base UI stack. - Bump `package.json` / lockfile for shadcn-postinstall deps (Hugeicons, chart stack, themes, etc.) Verified: `bun run typecheck` passes. Note: `bun run lint` still reports pre-existing hooks rule violations elsewhere; not addressed in this change. * 🎨 chore(web/default): unify table toolbar, relocate usage stats, refine filters - Refactor DataTableToolbar to a single wrapping flex row with a right-aligned action cluster (Reset / Search / View / Expand) for a cleaner Ant Design Pro–style filter bar; remove the dedicated stats row and the toolbar `stats` prop. - Move Common Logs summary badges (Usage / RPM / TPM) and the sensitive- data visibility toggle into the page header via CommonLogsHeaderActions and SectionPageLayout.Actions so the toolbar stays focused on filters. - Slim CommonLogsFilterBar props (no stats / preActions eye control). - Improve CompactDateTimeRangePicker: show minute-precision labels on the trigger (seconds omitted; aligns with datetime-local inputs); widen the trigger on sm+ breakpoints so the full range is visible without truncation; apply the same width in task logs filters. - Simplify DataTableViewOptions: text-only “View” trigger, no sliders icon. - Earlier layout tweak: extra top padding on SectionPageLayout scroll content so control focus rings are not clipped by overflow. * feat(web/default): Base UI migration and component foundation Migrate from Radix UI to Base UI, rewrite core UI primitives, update dependencies (recharts, date-fns, next-themes), add shadcn agent skill documentation, and refresh AI element components. This is the foundational work from the v2/localmain lineage that was not covered by the individual feature commits above. --------- Co-authored-by: t0ng7u <dev@aiass.cc> Co-authored-by: QuentinHsu <xuquentinyang@gmail.com>
1049 lines
29 KiB
TypeScript
Vendored
1049 lines
29 KiB
TypeScript
Vendored
import {
|
|
hashStringToSeed,
|
|
randomInRange,
|
|
randomIntInRange,
|
|
seededRandom,
|
|
} from '@/features/pricing/lib/seed'
|
|
import type {
|
|
AppCategory,
|
|
AppListing,
|
|
CategorySection,
|
|
ModelHistoryPoint,
|
|
ModelHistorySeries,
|
|
ModelRanking,
|
|
NewModelEntry,
|
|
RankingCategory,
|
|
RankingCategoryId,
|
|
RankingMover,
|
|
RankingPeriod,
|
|
RankingsSnapshot,
|
|
VendorRanking,
|
|
VendorSharePoint,
|
|
VendorShareSeries,
|
|
} from '../types'
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Catalogue: categories + canonical model & app fixtures
|
|
// ----------------------------------------------------------------------------
|
|
//
|
|
// All ranking data is derived from these fixtures plus a deterministic PRNG
|
|
// seeded by `${period}:${category}`. Every call with the same arguments
|
|
// returns the same numbers, while different (period, category) pairs render
|
|
// visibly distinct data. When the backend ships real analytics, these
|
|
// fixtures stay only as fallbacks.
|
|
|
|
export const RANKING_CATEGORIES: RankingCategory[] = [
|
|
{
|
|
id: 'all',
|
|
label: 'All categories',
|
|
description: 'Aggregate traffic across every category',
|
|
},
|
|
{
|
|
id: 'programming',
|
|
label: 'Programming',
|
|
description: 'Code generation, refactoring, autocomplete',
|
|
},
|
|
{
|
|
id: 'roleplay',
|
|
label: 'Roleplay',
|
|
description: 'Character chat, storytelling, persona',
|
|
},
|
|
{
|
|
id: 'marketing',
|
|
label: 'Marketing',
|
|
description: 'Copywriting, ad creative, SEO',
|
|
},
|
|
{
|
|
id: 'translation',
|
|
label: 'Translation',
|
|
description: 'Multilingual translation and localisation',
|
|
},
|
|
{
|
|
id: 'science',
|
|
label: 'Science',
|
|
description: 'Research, analysis, scientific reasoning',
|
|
},
|
|
{
|
|
id: 'finance',
|
|
label: 'Finance',
|
|
description: 'Trading insights, accounting, advisory',
|
|
},
|
|
{
|
|
id: 'health',
|
|
label: 'Health',
|
|
description: 'Medical Q&A, mental health support',
|
|
},
|
|
{
|
|
id: 'legal',
|
|
label: 'Legal',
|
|
description: 'Contract review, compliance, summarisation',
|
|
},
|
|
{
|
|
id: 'education',
|
|
label: 'Education',
|
|
description: 'Tutoring, learning aids, assessment',
|
|
},
|
|
{
|
|
id: 'productivity',
|
|
label: 'Productivity',
|
|
description: 'Email, summarisation, knowledge work',
|
|
},
|
|
{
|
|
id: 'multimodal',
|
|
label: 'Multimodal',
|
|
description: 'Vision, image / video, document chat',
|
|
},
|
|
]
|
|
|
|
type ModelFixture = {
|
|
name: string
|
|
vendor: string
|
|
vendor_icon: string
|
|
release_date: string
|
|
/** Categories this model commonly serves. First entry is the primary. */
|
|
categories: RankingCategoryId[]
|
|
/** Relative popularity weight (0..1). */
|
|
weight: number
|
|
}
|
|
|
|
const MODEL_FIXTURES: ModelFixture[] = [
|
|
{
|
|
name: 'gpt-5',
|
|
vendor: 'OpenAI',
|
|
vendor_icon: 'OpenAI.Color',
|
|
release_date: '2025-10-12',
|
|
categories: ['programming', 'productivity', 'science'],
|
|
weight: 1.0,
|
|
},
|
|
{
|
|
name: 'claude-sonnet-4-5',
|
|
vendor: 'Anthropic',
|
|
vendor_icon: 'Claude.Color',
|
|
release_date: '2025-09-08',
|
|
categories: ['programming', 'productivity', 'legal'],
|
|
weight: 0.96,
|
|
},
|
|
{
|
|
name: 'gemini-2.5-pro',
|
|
vendor: 'Google',
|
|
vendor_icon: 'Gemini.Color',
|
|
release_date: '2025-06-15',
|
|
categories: ['multimodal', 'science', 'education'],
|
|
weight: 0.88,
|
|
},
|
|
{
|
|
name: 'deepseek-v3.2',
|
|
vendor: 'DeepSeek',
|
|
vendor_icon: 'DeepSeek.Color',
|
|
release_date: '2025-08-22',
|
|
categories: ['programming', 'science'],
|
|
weight: 0.84,
|
|
},
|
|
{
|
|
name: 'gpt-5-mini',
|
|
vendor: 'OpenAI',
|
|
vendor_icon: 'OpenAI.Color',
|
|
release_date: '2025-10-12',
|
|
categories: ['productivity', 'roleplay', 'translation'],
|
|
weight: 0.78,
|
|
},
|
|
{
|
|
name: 'claude-opus-4-5',
|
|
vendor: 'Anthropic',
|
|
vendor_icon: 'Claude.Color',
|
|
release_date: '2025-08-04',
|
|
categories: ['legal', 'science', 'finance'],
|
|
weight: 0.7,
|
|
},
|
|
{
|
|
name: 'qwen3-235b-a22b',
|
|
vendor: 'Alibaba',
|
|
vendor_icon: 'Qwen.Color',
|
|
release_date: '2025-05-30',
|
|
categories: ['programming', 'translation', 'science'],
|
|
weight: 0.66,
|
|
},
|
|
{
|
|
name: 'grok-4',
|
|
vendor: 'xAI',
|
|
vendor_icon: 'XAI',
|
|
release_date: '2025-04-18',
|
|
categories: ['roleplay', 'science', 'marketing'],
|
|
weight: 0.62,
|
|
},
|
|
{
|
|
name: 'llama-4-maverick',
|
|
vendor: 'Meta',
|
|
vendor_icon: 'Meta.Color',
|
|
release_date: '2025-04-05',
|
|
categories: ['programming', 'productivity'],
|
|
weight: 0.58,
|
|
},
|
|
{
|
|
name: 'kimi-k2',
|
|
vendor: 'Moonshot',
|
|
vendor_icon: 'Moonshot',
|
|
release_date: '2025-07-19',
|
|
categories: ['productivity', 'translation'],
|
|
weight: 0.55,
|
|
},
|
|
{
|
|
name: 'glm-4.6',
|
|
vendor: 'Zhipu',
|
|
vendor_icon: 'Zhipu.Color',
|
|
release_date: '2025-09-26',
|
|
categories: ['programming', 'productivity'],
|
|
weight: 0.52,
|
|
},
|
|
{
|
|
name: 'gemini-2.5-flash',
|
|
vendor: 'Google',
|
|
vendor_icon: 'Gemini.Color',
|
|
release_date: '2025-06-15',
|
|
categories: ['productivity', 'translation', 'multimodal'],
|
|
weight: 0.49,
|
|
},
|
|
{
|
|
name: 'mistral-large-3',
|
|
vendor: 'Mistral',
|
|
vendor_icon: 'Mistral.Color',
|
|
release_date: '2025-03-12',
|
|
categories: ['programming', 'finance'],
|
|
weight: 0.46,
|
|
},
|
|
{
|
|
name: 'doubao-1.6-pro',
|
|
vendor: 'ByteDance',
|
|
vendor_icon: 'Doubao.Color',
|
|
release_date: '2025-07-02',
|
|
categories: ['marketing', 'roleplay'],
|
|
weight: 0.44,
|
|
},
|
|
{
|
|
name: 'hunyuan-turbos',
|
|
vendor: 'Tencent',
|
|
vendor_icon: 'Hunyuan.Color',
|
|
release_date: '2025-05-08',
|
|
categories: ['productivity', 'translation'],
|
|
weight: 0.4,
|
|
},
|
|
{
|
|
name: 'gpt-image-2',
|
|
vendor: 'OpenAI',
|
|
vendor_icon: 'OpenAI.Color',
|
|
release_date: '2025-06-04',
|
|
categories: ['multimodal', 'marketing'],
|
|
weight: 0.38,
|
|
},
|
|
{
|
|
name: 'sora-2',
|
|
vendor: 'OpenAI',
|
|
vendor_icon: 'OpenAI.Color',
|
|
release_date: '2025-09-30',
|
|
categories: ['multimodal', 'marketing'],
|
|
weight: 0.34,
|
|
},
|
|
{
|
|
name: 'veo-3',
|
|
vendor: 'Google',
|
|
vendor_icon: 'Gemini.Color',
|
|
release_date: '2025-08-15',
|
|
categories: ['multimodal', 'marketing'],
|
|
weight: 0.31,
|
|
},
|
|
{
|
|
name: 'qwen3-vl-plus',
|
|
vendor: 'Alibaba',
|
|
vendor_icon: 'Qwen.Color',
|
|
release_date: '2025-06-20',
|
|
categories: ['multimodal', 'education'],
|
|
weight: 0.3,
|
|
},
|
|
{
|
|
name: 'minimax-m2',
|
|
vendor: 'MiniMax',
|
|
vendor_icon: 'Minimax.Color',
|
|
release_date: '2025-07-25',
|
|
categories: ['roleplay', 'translation'],
|
|
weight: 0.28,
|
|
},
|
|
{
|
|
name: 'cohere-command-r-plus',
|
|
vendor: 'Cohere',
|
|
vendor_icon: 'Cohere.Color',
|
|
release_date: '2024-11-10',
|
|
categories: ['marketing', 'productivity'],
|
|
weight: 0.26,
|
|
},
|
|
{
|
|
name: 'ernie-x1-turbo',
|
|
vendor: 'Baidu',
|
|
vendor_icon: 'Baidu.Color',
|
|
release_date: '2025-04-30',
|
|
categories: ['translation', 'productivity'],
|
|
weight: 0.22,
|
|
},
|
|
]
|
|
|
|
type AppFixture = {
|
|
name: string
|
|
description: string
|
|
category: AppCategory
|
|
url?: string
|
|
weight: number
|
|
/** Bias toward these models (model_name). */
|
|
prefers: string[]
|
|
}
|
|
|
|
const APP_FIXTURES: AppFixture[] = [
|
|
{
|
|
name: 'Cline',
|
|
description: 'Autonomous coding agent inside the IDE',
|
|
category: 'Coding',
|
|
url: 'https://cline.bot',
|
|
weight: 1.0,
|
|
prefers: ['claude-sonnet-4-5', 'gpt-5'],
|
|
},
|
|
{
|
|
name: 'Roo Code',
|
|
description: 'AI agent for VS Code with multi-step planning',
|
|
category: 'Coding',
|
|
url: 'https://roocode.com',
|
|
weight: 0.9,
|
|
prefers: ['claude-sonnet-4-5', 'deepseek-v3.2'],
|
|
},
|
|
{
|
|
name: 'Cursor',
|
|
description: 'Editor with built-in AI for code generation',
|
|
category: 'Coding',
|
|
url: 'https://cursor.com',
|
|
weight: 0.85,
|
|
prefers: ['gpt-5', 'claude-sonnet-4-5'],
|
|
},
|
|
{
|
|
name: 'Continue',
|
|
description: 'Open-source AI code assistant for editors',
|
|
category: 'Coding',
|
|
url: 'https://continue.dev',
|
|
weight: 0.62,
|
|
prefers: ['deepseek-v3.2', 'qwen3-235b-a22b'],
|
|
},
|
|
{
|
|
name: 'Aider',
|
|
description: 'Pair-programming in your terminal',
|
|
category: 'Coding',
|
|
url: 'https://aider.chat',
|
|
weight: 0.46,
|
|
prefers: ['claude-sonnet-4-5', 'gpt-5'],
|
|
},
|
|
{
|
|
name: 'Open WebUI',
|
|
description: 'Self-hosted ChatGPT-like web interface',
|
|
category: 'Chat',
|
|
url: 'https://openwebui.com',
|
|
weight: 0.74,
|
|
prefers: ['gpt-5-mini', 'qwen3-235b-a22b'],
|
|
},
|
|
{
|
|
name: 'LibreChat',
|
|
description: 'Open-source multi-model chat platform',
|
|
category: 'Chat',
|
|
url: 'https://librechat.ai',
|
|
weight: 0.6,
|
|
prefers: ['gpt-5-mini', 'gemini-2.5-flash'],
|
|
},
|
|
{
|
|
name: 'Lobe Chat',
|
|
description: 'Modern open-source chat UI with plugins',
|
|
category: 'Chat',
|
|
url: 'https://lobehub.com',
|
|
weight: 0.58,
|
|
prefers: ['gpt-5-mini', 'claude-sonnet-4-5'],
|
|
},
|
|
{
|
|
name: 'NextChat',
|
|
description: 'Cross-platform private ChatGPT client',
|
|
category: 'Chat',
|
|
url: 'https://nextchat.dev',
|
|
weight: 0.4,
|
|
prefers: ['gpt-5-mini', 'gemini-2.5-flash'],
|
|
},
|
|
{
|
|
name: 'TypingMind',
|
|
description: 'Better UI for ChatGPT and Claude',
|
|
category: 'Chat',
|
|
url: 'https://typingmind.com',
|
|
weight: 0.34,
|
|
prefers: ['gpt-5', 'claude-sonnet-4-5'],
|
|
},
|
|
{
|
|
name: 'SillyTavern',
|
|
description: 'Roleplay frontend for chat models',
|
|
category: 'Roleplay',
|
|
url: 'https://sillytavernai.com',
|
|
weight: 0.7,
|
|
prefers: ['claude-opus-4-5', 'minimax-m2'],
|
|
},
|
|
{
|
|
name: 'Janitor AI',
|
|
description: 'Roleplay chat with custom characters',
|
|
category: 'Roleplay',
|
|
url: 'https://janitorai.com',
|
|
weight: 0.55,
|
|
prefers: ['minimax-m2', 'doubao-1.6-pro'],
|
|
},
|
|
{
|
|
name: 'Notion AI',
|
|
description: 'AI features inside Notion docs',
|
|
category: 'Productivity',
|
|
url: 'https://notion.so',
|
|
weight: 0.62,
|
|
prefers: ['gpt-5', 'claude-sonnet-4-5'],
|
|
},
|
|
{
|
|
name: 'Reflect',
|
|
description: 'Personal AI knowledge assistant',
|
|
category: 'Productivity',
|
|
url: 'https://reflect.app',
|
|
weight: 0.36,
|
|
prefers: ['gpt-5-mini', 'claude-sonnet-4-5'],
|
|
},
|
|
{
|
|
name: 'Mem',
|
|
description: 'AI-first note-taking app',
|
|
category: 'Productivity',
|
|
url: 'https://mem.ai',
|
|
weight: 0.32,
|
|
prefers: ['gpt-5-mini'],
|
|
},
|
|
{
|
|
name: 'Khanmigo',
|
|
description: 'Tutor for Khan Academy learners',
|
|
category: 'Education',
|
|
url: 'https://khanmigo.ai',
|
|
weight: 0.48,
|
|
prefers: ['gpt-5', 'claude-sonnet-4-5'],
|
|
},
|
|
{
|
|
name: 'Quizlet AI',
|
|
description: 'Personalised study & flashcards',
|
|
category: 'Education',
|
|
url: 'https://quizlet.com',
|
|
weight: 0.36,
|
|
prefers: ['gemini-2.5-flash'],
|
|
},
|
|
{
|
|
name: 'Perplexity',
|
|
description: 'Conversational answer engine',
|
|
category: 'Research',
|
|
url: 'https://perplexity.ai',
|
|
weight: 0.78,
|
|
prefers: ['gpt-5', 'claude-sonnet-4-5'],
|
|
},
|
|
{
|
|
name: 'Elicit',
|
|
description: 'AI research assistant for papers',
|
|
category: 'Research',
|
|
url: 'https://elicit.com',
|
|
weight: 0.42,
|
|
prefers: ['claude-opus-4-5', 'gemini-2.5-pro'],
|
|
},
|
|
{
|
|
name: 'Jasper',
|
|
description: 'Marketing copywriting platform',
|
|
category: 'Marketing',
|
|
url: 'https://jasper.ai',
|
|
weight: 0.5,
|
|
prefers: ['gpt-5', 'claude-sonnet-4-5'],
|
|
},
|
|
{
|
|
name: 'Copy.ai',
|
|
description: 'AI sales & marketing automation',
|
|
category: 'Marketing',
|
|
url: 'https://copy.ai',
|
|
weight: 0.4,
|
|
prefers: ['gpt-5-mini'],
|
|
},
|
|
{
|
|
name: 'DeepL Write',
|
|
description: 'AI rewriting & translation',
|
|
category: 'Translation',
|
|
url: 'https://deepl.com',
|
|
weight: 0.36,
|
|
prefers: ['gpt-5-mini', 'qwen3-235b-a22b'],
|
|
},
|
|
{
|
|
name: 'Wordtune',
|
|
description: 'AI rewriting & paraphrasing',
|
|
category: 'Translation',
|
|
url: 'https://wordtune.com',
|
|
weight: 0.3,
|
|
prefers: ['gpt-5-mini'],
|
|
},
|
|
{
|
|
name: 'Harvey',
|
|
description: 'AI assistant for law firms',
|
|
category: 'Other',
|
|
url: 'https://harvey.ai',
|
|
weight: 0.42,
|
|
prefers: ['claude-opus-4-5', 'gpt-5'],
|
|
},
|
|
{
|
|
name: 'Hippocratic',
|
|
description: 'Healthcare-focused AI agents',
|
|
category: 'Health',
|
|
url: 'https://hippocratic.ai',
|
|
weight: 0.32,
|
|
prefers: ['claude-opus-4-5'],
|
|
},
|
|
{
|
|
name: 'Cleo',
|
|
description: 'AI personal finance assistant',
|
|
category: 'Finance',
|
|
url: 'https://meetcleo.com',
|
|
weight: 0.28,
|
|
prefers: ['gpt-5-mini'],
|
|
},
|
|
]
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// PRNG seeding helpers
|
|
// ----------------------------------------------------------------------------
|
|
|
|
const PERIOD_FACTOR: Record<RankingPeriod, number> = {
|
|
today: 0.04,
|
|
week: 0.25,
|
|
month: 1.0,
|
|
year: 11.5,
|
|
all: 38.0,
|
|
}
|
|
|
|
function periodSeed(
|
|
period: RankingPeriod,
|
|
category: RankingCategoryId
|
|
): number {
|
|
return hashStringToSeed(`rankings:${period}:${category}`)
|
|
}
|
|
|
|
/** Pick a previous_rank for a model that's currently at `rank`. */
|
|
function makePreviousRank(rand: () => number, rank: number, total: number) {
|
|
if (rand() < 0.08) return undefined
|
|
const delta = randomIntInRange(rand, -3, 3)
|
|
const prev = Math.max(1, Math.min(total + 4, rank + delta))
|
|
if (prev === rank) return undefined
|
|
return prev
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Leaderboard builders
|
|
// ----------------------------------------------------------------------------
|
|
|
|
function buildModelRankings(
|
|
period: RankingPeriod,
|
|
category: RankingCategoryId
|
|
): ModelRanking[] {
|
|
const seed = periodSeed(period, category)
|
|
const periodFactor = PERIOD_FACTOR[period]
|
|
|
|
const filtered =
|
|
category === 'all'
|
|
? MODEL_FIXTURES
|
|
: MODEL_FIXTURES.filter((m) => m.categories.includes(category))
|
|
|
|
// Slight per-call jitter on weights so the leaderboard re-orders a bit
|
|
// between periods/categories.
|
|
const ranked = filtered
|
|
.map((m) => ({
|
|
fixture: m,
|
|
score:
|
|
m.weight *
|
|
(0.85 + seededRandom(seed ^ hashStringToSeed(m.name))() * 0.4),
|
|
}))
|
|
.sort((a, b) => b.score - a.score)
|
|
.slice(0, 20)
|
|
|
|
if (ranked.length === 0) return []
|
|
|
|
const totalScore = ranked.reduce((s, r) => s + r.score, 0)
|
|
const baseTokens = 240_000_000 * periodFactor
|
|
|
|
return ranked.map(({ fixture, score }, idx) => {
|
|
const rowSeed = seed ^ hashStringToSeed(fixture.name)
|
|
const rowRand = seededRandom(rowSeed)
|
|
const share = score / totalScore
|
|
const totalTokens = Math.round(baseTokens * share * (0.9 + rowRand() * 0.2))
|
|
const growth = randomInRange(seededRandom(rowSeed ^ 0x11), -22, 96)
|
|
return {
|
|
rank: idx + 1,
|
|
previous_rank: makePreviousRank(
|
|
seededRandom(rowSeed ^ 0x22),
|
|
idx + 1,
|
|
ranked.length
|
|
),
|
|
model_name: fixture.name,
|
|
vendor: fixture.vendor,
|
|
vendor_icon: fixture.vendor_icon,
|
|
category: fixture.categories[0],
|
|
total_tokens: totalTokens,
|
|
share,
|
|
growth_pct: Math.round(growth * 10) / 10,
|
|
}
|
|
})
|
|
}
|
|
|
|
function buildAppListings(
|
|
period: RankingPeriod,
|
|
category: RankingCategoryId,
|
|
models: ModelRanking[]
|
|
): AppListing[] {
|
|
const seed = periodSeed(period, category) ^ 0xa11
|
|
const periodFactor = PERIOD_FACTOR[period]
|
|
|
|
// Map "all" category to all apps; otherwise filter by a soft mapping. We
|
|
// keep the list large and let weights distribute naturally.
|
|
const filtered = APP_FIXTURES.filter((app) => {
|
|
if (category === 'all') return true
|
|
if (category === 'programming') return app.category === 'Coding'
|
|
if (category === 'roleplay') return app.category === 'Roleplay'
|
|
if (category === 'marketing') return app.category === 'Marketing'
|
|
if (category === 'translation') return app.category === 'Translation'
|
|
if (category === 'education') return app.category === 'Education'
|
|
if (category === 'productivity')
|
|
return app.category === 'Productivity' || app.category === 'Chat'
|
|
if (category === 'science') return app.category === 'Research'
|
|
if (category === 'health') return app.category === 'Health'
|
|
if (category === 'finance') return app.category === 'Finance'
|
|
if (category === 'multimodal')
|
|
return ['Creative', 'Marketing'].includes(app.category)
|
|
return true
|
|
})
|
|
|
|
const ranked = filtered
|
|
.map((app) => ({
|
|
app,
|
|
score:
|
|
app.weight *
|
|
(0.85 + seededRandom(seed ^ hashStringToSeed(app.name))() * 0.4),
|
|
}))
|
|
.sort((a, b) => b.score - a.score)
|
|
|
|
if (ranked.length === 0) return []
|
|
|
|
const totalScore = ranked.reduce((s, r) => s + r.score, 0)
|
|
const baseTokens = 84_000_000 * periodFactor
|
|
const modelNames = new Set(models.map((m) => m.model_name))
|
|
|
|
return ranked.slice(0, 14).map(({ app, score }, idx) => {
|
|
const rowSeed = seed ^ hashStringToSeed(app.name)
|
|
const share = score / totalScore
|
|
const totalTokens = Math.round(
|
|
baseTokens * share * (0.9 + seededRandom(rowSeed)() * 0.25)
|
|
)
|
|
const growth = randomInRange(seededRandom(rowSeed ^ 0xab), -28, 130)
|
|
const topModel =
|
|
app.prefers.find((m) => modelNames.has(m)) ??
|
|
app.prefers[0] ??
|
|
models[0]?.model_name ??
|
|
'gpt-5'
|
|
return {
|
|
rank: idx + 1,
|
|
previous_rank: makePreviousRank(
|
|
seededRandom(rowSeed ^ 0xcd),
|
|
idx + 1,
|
|
ranked.length
|
|
),
|
|
name: app.name,
|
|
description: app.description,
|
|
category: app.category,
|
|
url: app.url,
|
|
total_tokens: totalTokens,
|
|
growth_pct: Math.round(growth * 10) / 10,
|
|
top_model: topModel,
|
|
initial: app.name.charAt(0).toUpperCase(),
|
|
}
|
|
})
|
|
}
|
|
|
|
function buildVendorRankings(models: ModelRanking[]): VendorRanking[] {
|
|
if (models.length === 0) return []
|
|
const totals = new Map<
|
|
string,
|
|
{
|
|
tokens: number
|
|
icon?: string
|
|
count: number
|
|
growthSum: number
|
|
topModel: { name: string; tokens: number }
|
|
}
|
|
>()
|
|
for (const m of models) {
|
|
const cur = totals.get(m.vendor)
|
|
if (!cur) {
|
|
totals.set(m.vendor, {
|
|
tokens: m.total_tokens,
|
|
icon: m.vendor_icon,
|
|
count: 1,
|
|
growthSum: m.growth_pct,
|
|
topModel: { name: m.model_name, tokens: m.total_tokens },
|
|
})
|
|
} else {
|
|
cur.tokens += m.total_tokens
|
|
cur.count += 1
|
|
cur.growthSum += m.growth_pct
|
|
if (m.total_tokens > cur.topModel.tokens) {
|
|
cur.topModel = { name: m.model_name, tokens: m.total_tokens }
|
|
}
|
|
}
|
|
}
|
|
|
|
const grand = [...totals.values()].reduce((s, v) => s + v.tokens, 0)
|
|
const sorted = [...totals.entries()]
|
|
.map(([vendor, v]) => ({
|
|
vendor,
|
|
total_tokens: v.tokens,
|
|
vendor_icon: v.icon,
|
|
models_count: v.count,
|
|
top_model: v.topModel.name,
|
|
share: v.tokens / Math.max(grand, 1),
|
|
growth_pct: Math.round((v.growthSum / v.count) * 10) / 10,
|
|
}))
|
|
.sort((a, b) => b.total_tokens - a.total_tokens)
|
|
|
|
return sorted.map((row, idx) => ({ rank: idx + 1, ...row }))
|
|
}
|
|
|
|
function buildMovers(models: ModelRanking[]): {
|
|
movers: RankingMover[]
|
|
droppers: RankingMover[]
|
|
} {
|
|
const withDelta = models
|
|
.filter((m) => m.previous_rank !== undefined)
|
|
.map<RankingMover>((m) => ({
|
|
model_name: m.model_name,
|
|
vendor: m.vendor,
|
|
vendor_icon: m.vendor_icon,
|
|
current_rank: m.rank,
|
|
rank_delta: (m.previous_rank ?? m.rank) - m.rank,
|
|
growth_pct: m.growth_pct,
|
|
}))
|
|
|
|
const movers = [...withDelta]
|
|
.filter((x) => x.rank_delta > 0)
|
|
.sort((a, b) => b.rank_delta - a.rank_delta || b.growth_pct - a.growth_pct)
|
|
.slice(0, 5)
|
|
|
|
const droppers = [...withDelta]
|
|
.filter((x) => x.rank_delta < 0)
|
|
.sort((a, b) => a.rank_delta - b.rank_delta || a.growth_pct - b.growth_pct)
|
|
.slice(0, 5)
|
|
|
|
return { movers, droppers }
|
|
}
|
|
|
|
function buildNewModels(period: RankingPeriod): NewModelEntry[] {
|
|
const seed = periodSeed(period, 'all') ^ 0xfa11
|
|
const rand = seededRandom(seed)
|
|
// "New" = released within the last 90 days for shorter periods, last 12
|
|
// months for "year/all"
|
|
const cutoffDays = period === 'today' || period === 'week' ? 90 : 365
|
|
const cutoffMs = Date.now() - cutoffDays * 86_400_000
|
|
return MODEL_FIXTURES.filter((m) => Date.parse(m.release_date) >= cutoffMs)
|
|
.slice()
|
|
.sort(
|
|
(a, b) =>
|
|
Date.parse(b.release_date) - Date.parse(a.release_date) ||
|
|
b.weight - a.weight
|
|
)
|
|
.slice(0, 6)
|
|
.map((m) => ({
|
|
model_name: m.name,
|
|
vendor: m.vendor,
|
|
vendor_icon: m.vendor_icon,
|
|
category: m.categories[0],
|
|
release_date: m.release_date,
|
|
total_tokens: Math.round(
|
|
220_000_000 * m.weight * PERIOD_FACTOR[period] * (0.85 + rand() * 0.3)
|
|
),
|
|
growth_pct: Math.round(randomInRange(rand, 35, 220) * 10) / 10,
|
|
}))
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// History (stacked bar / 100% stacked area) builders
|
|
// ----------------------------------------------------------------------------
|
|
//
|
|
// These produce a longer time-series than the leaderboard sparklines so the
|
|
// charts can render a recognisable growth story across 30+ buckets. Bucket
|
|
// granularity scales with the active period.
|
|
|
|
const HISTORY_BUCKETS: Record<RankingPeriod, number> = {
|
|
today: 24, // hourly
|
|
week: 21, // 3 weeks of daily
|
|
month: 30, // ~30 days
|
|
year: 52, // ~1 year of weekly
|
|
all: 78, // ~18 months of weekly
|
|
}
|
|
|
|
/** Cap stacked series so the chart legend / colour palette stays legible. */
|
|
const HISTORY_TOP_MODELS = 18
|
|
const HISTORY_TOP_VENDORS = 12
|
|
const OTHERS_LABEL = 'Others'
|
|
|
|
function bucketStepMs(period: RankingPeriod): number {
|
|
if (period === 'today') return 60 * 60 * 1000
|
|
if (period === 'week' || period === 'month') return 24 * 60 * 60 * 1000
|
|
return 7 * 24 * 60 * 60 * 1000
|
|
}
|
|
|
|
function formatBucketLabel(date: Date, period: RankingPeriod): string {
|
|
if (period === 'today') {
|
|
return `${String(date.getHours()).padStart(2, '0')}:00`
|
|
}
|
|
return date.toLocaleDateString(undefined, {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Smooth ramp-up profile in [0..1] for a model that launched at
|
|
* `releaseTs` and is observed at `bucketTs`. Models launched after the
|
|
* bucket return 0; long-established models return 1. The S-curve gives a
|
|
* natural ramp during the first ~6 weeks after launch.
|
|
*/
|
|
function rampWeight(bucketTs: number, releaseTs: number): number {
|
|
if (!Number.isFinite(releaseTs)) return 1
|
|
const ageMs = bucketTs - releaseTs
|
|
if (ageMs <= 0) return 0
|
|
const sixWeeks = 6 * 7 * 24 * 60 * 60 * 1000
|
|
if (ageMs >= sixWeeks) return 1
|
|
const t = ageMs / sixWeeks
|
|
return 1 - Math.pow(1 - t, 3)
|
|
}
|
|
|
|
function buildModelsHistory(
|
|
period: RankingPeriod,
|
|
models: ModelRanking[]
|
|
): ModelHistorySeries {
|
|
const buckets = HISTORY_BUCKETS[period]
|
|
if (buckets === 0 || models.length === 0) {
|
|
return { points: [], models: [], buckets: 0 }
|
|
}
|
|
|
|
const stepMs = bucketStepMs(period)
|
|
const now = Date.now()
|
|
const seed = periodSeed(period, 'all') ^ 0x71_57_07_4d
|
|
const top = models.slice(0, Math.min(models.length, HISTORY_TOP_MODELS))
|
|
|
|
const points: ModelHistoryPoint[] = []
|
|
const totals = new Map<string, number>()
|
|
|
|
for (const model of top) {
|
|
const releaseFixture = MODEL_FIXTURES.find(
|
|
(m) => m.name === model.model_name
|
|
)
|
|
const releaseTs = releaseFixture
|
|
? Date.parse(releaseFixture.release_date)
|
|
: Number.NaN
|
|
|
|
const modelSeed = seed ^ hashStringToSeed(`${model.model_name}:hist`)
|
|
const rand = seededRandom(modelSeed)
|
|
|
|
// Per-model average tokens per bucket so the area under the curve roughly
|
|
// matches `total_tokens` (the leaderboard summary).
|
|
const avgPerBucket = model.total_tokens / buckets
|
|
|
|
// Drift = how much the model has been growing across the visible window.
|
|
// Newer / faster-growing models (high growth_pct) show a steeper slope.
|
|
const drift = 0.4 + Math.min(2.4, model.growth_pct / 50)
|
|
// Shape factor — most weight near the end for growing models, more even
|
|
// for established ones.
|
|
const skew = 0.8 + rand() * 0.6
|
|
|
|
let modelTotal = 0
|
|
for (let i = buckets - 1; i >= 0; i--) {
|
|
const bucketTs = now - i * stepMs
|
|
const date = new Date(bucketTs)
|
|
const t = (buckets - 1 - i) / Math.max(1, buckets - 1)
|
|
const trendShape = Math.pow(t, 1.4 * skew) * drift + 0.4
|
|
const ramp = rampWeight(bucketTs, releaseTs)
|
|
const jitter = 0.78 + rand() * 0.45
|
|
const tokens = Math.max(
|
|
0,
|
|
Math.round(avgPerBucket * trendShape * ramp * jitter)
|
|
)
|
|
modelTotal += tokens
|
|
points.push({
|
|
ts: date.toISOString(),
|
|
label: formatBucketLabel(date, period),
|
|
model: model.model_name,
|
|
vendor: model.vendor,
|
|
tokens,
|
|
})
|
|
}
|
|
totals.set(model.model_name, modelTotal)
|
|
}
|
|
|
|
// Stable oldest → newest ordering.
|
|
points.sort((a, b) => a.ts.localeCompare(b.ts))
|
|
|
|
const ranked = top
|
|
.map((m) => ({
|
|
name: m.model_name,
|
|
vendor: m.vendor,
|
|
total: totals.get(m.model_name) ?? 0,
|
|
}))
|
|
.sort((a, b) => b.total - a.total)
|
|
|
|
return { points, models: ranked, buckets }
|
|
}
|
|
|
|
function buildVendorShareHistory(
|
|
history: ModelHistorySeries
|
|
): VendorShareSeries {
|
|
if (history.points.length === 0) {
|
|
return { points: [], vendors: [], buckets: 0 }
|
|
}
|
|
|
|
const byBucket = new Map<string, Map<string, number>>()
|
|
const labelByTs = new Map<string, string>()
|
|
for (const point of history.points) {
|
|
if (!byBucket.has(point.ts)) byBucket.set(point.ts, new Map())
|
|
if (!labelByTs.has(point.ts)) labelByTs.set(point.ts, point.label)
|
|
const map = byBucket.get(point.ts)!
|
|
map.set(point.vendor, (map.get(point.vendor) ?? 0) + point.tokens)
|
|
}
|
|
|
|
// Use the union of vendors observed across the window so the area chart
|
|
// has stable series even on buckets where a vendor has 0 tokens.
|
|
const vendorTotals = new Map<string, number>()
|
|
for (const [, vendorMap] of byBucket) {
|
|
for (const [vendor, tokens] of vendorMap) {
|
|
vendorTotals.set(vendor, (vendorTotals.get(vendor) ?? 0) + tokens)
|
|
}
|
|
}
|
|
const grand = [...vendorTotals.values()].reduce((s, v) => s + v, 0) || 1
|
|
|
|
const sortedVendors = [...vendorTotals.entries()].sort((a, b) => b[1] - a[1])
|
|
const topVendors = sortedVendors
|
|
.slice(0, HISTORY_TOP_VENDORS)
|
|
.map(([name]) => name)
|
|
const otherVendors = new Set(
|
|
sortedVendors.slice(HISTORY_TOP_VENDORS).map(([name]) => name)
|
|
)
|
|
const hasOthers = otherVendors.size > 0
|
|
|
|
const points: VendorSharePoint[] = []
|
|
const sortedTimestamps = [...byBucket.keys()].sort()
|
|
for (const ts of sortedTimestamps) {
|
|
const vendorMap = byBucket.get(ts)!
|
|
const label = labelByTs.get(ts) ?? ts
|
|
const totalAtBucket =
|
|
[...vendorMap.values()].reduce((s, v) => s + v, 0) || 1
|
|
|
|
for (const vendor of topVendors) {
|
|
const tokens = vendorMap.get(vendor) ?? 0
|
|
points.push({
|
|
ts,
|
|
label,
|
|
vendor,
|
|
share: tokens / totalAtBucket,
|
|
tokens,
|
|
})
|
|
}
|
|
if (hasOthers) {
|
|
let othersTokens = 0
|
|
for (const vendor of otherVendors) {
|
|
othersTokens += vendorMap.get(vendor) ?? 0
|
|
}
|
|
points.push({
|
|
ts,
|
|
label,
|
|
vendor: OTHERS_LABEL,
|
|
share: othersTokens / totalAtBucket,
|
|
tokens: othersTokens,
|
|
})
|
|
}
|
|
}
|
|
|
|
const vendors = topVendors
|
|
.map((name) => {
|
|
const total = vendorTotals.get(name) ?? 0
|
|
return { name, total, share: total / grand }
|
|
})
|
|
.sort((a, b) => b.total - a.total)
|
|
if (hasOthers) {
|
|
let othersTotal = 0
|
|
for (const vendor of otherVendors) {
|
|
othersTotal += vendorTotals.get(vendor) ?? 0
|
|
}
|
|
vendors.push({
|
|
name: OTHERS_LABEL,
|
|
total: othersTotal,
|
|
share: othersTotal / grand,
|
|
})
|
|
}
|
|
|
|
return { points, vendors, buckets: history.buckets }
|
|
}
|
|
|
|
/**
|
|
* Build a single per-category section. Used by `buildRankingsSnapshot` to
|
|
* eagerly compute every category section the page renders inline (rather
|
|
* than gating them behind a top-level filter).
|
|
*/
|
|
function buildCategorySection(
|
|
period: RankingPeriod,
|
|
category: RankingCategory
|
|
): CategorySection {
|
|
const models = buildModelRankings(period, category.id).slice(0, 12)
|
|
const models_history = buildModelsHistory(period, models)
|
|
const total_tokens = models.reduce((s, m) => s + m.total_tokens, 0)
|
|
return {
|
|
category: category.id,
|
|
label: category.label,
|
|
description: category.description,
|
|
models,
|
|
models_history,
|
|
total_tokens,
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Public entry point
|
|
// ----------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Build a full leaderboard snapshot for the given period.
|
|
*
|
|
* The snapshot bundles the overall (all-categories) view used by the page
|
|
* header sections **and** an independent ranking unit for each non-`all`
|
|
* category — so the page can render every category inline instead of
|
|
* gating the data behind a category filter.
|
|
*/
|
|
export function buildRankingsSnapshot(period: RankingPeriod): RankingsSnapshot {
|
|
const models = buildModelRankings(period, 'all')
|
|
const apps = buildAppListings(period, 'all', models)
|
|
const vendors = buildVendorRankings(models)
|
|
const { movers, droppers } = buildMovers(models)
|
|
const new_models = buildNewModels(period)
|
|
const models_history = buildModelsHistory(period, models)
|
|
const vendor_share_history = buildVendorShareHistory(models_history)
|
|
|
|
const category_sections = RANKING_CATEGORIES.filter(
|
|
(c) => c.id !== 'all'
|
|
).map((c) => buildCategorySection(period, c))
|
|
|
|
return {
|
|
models,
|
|
apps,
|
|
vendors,
|
|
top_movers: movers,
|
|
top_droppers: droppers,
|
|
new_models,
|
|
models_history,
|
|
vendor_share_history,
|
|
category_sections,
|
|
}
|
|
}
|