feat(web/default): unified UI overhaul — Base UI migration, theme presets, rankings dashboard, and table toolbar refactor (#4633)
* 🎨 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>
This commit is contained in:
co-authored by
t0ng7u
QuentinHsu
parent
dac55f0fde
commit
8b2b03d276
+296
-170
@@ -1,9 +1,24 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useParams, useNavigate, useSearch } from '@tanstack/react-router'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { useNavigate, useParams, useSearch } from '@tanstack/react-router'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Boxes,
|
||||
Code2,
|
||||
HeartPulse,
|
||||
Info,
|
||||
ReceiptText,
|
||||
Rocket,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getLobeIcon } from '@/lib/lobe-icon'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Table,
|
||||
@@ -13,33 +28,40 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { CopyButton } from '@/components/copy-button'
|
||||
import { GroupBadge } from '@/components/group-badge'
|
||||
import { PublicLayout } from '@/components/layout'
|
||||
import { DEFAULT_TOKEN_UNIT, QUOTA_TYPE_VALUES } from '../constants'
|
||||
import { usePricingData } from '../hooks/use-pricing-data'
|
||||
import { parseTags } from '../lib/filters'
|
||||
import {
|
||||
getAvailableGroups,
|
||||
replaceModelInPath,
|
||||
isTokenBasedModel,
|
||||
} from '../lib/model-helpers'
|
||||
import {
|
||||
getDynamicPriceEntries,
|
||||
getDynamicPricingSummary,
|
||||
getDynamicPricingTiers,
|
||||
isDynamicPricingModel,
|
||||
} from '../lib/dynamic-price'
|
||||
import { formatGroupPrice, formatFixedPrice } from '../lib/price'
|
||||
import type { PricingModel, TokenUnit, PriceType } from '../types'
|
||||
import { parseTags } from '../lib/filters'
|
||||
import { buildUptimeSeries } from '../lib/mock-stats'
|
||||
import {
|
||||
getAvailableGroups,
|
||||
isTokenBasedModel,
|
||||
replaceModelInPath,
|
||||
} from '../lib/model-helpers'
|
||||
import { inferModelMetadata } from '../lib/model-metadata'
|
||||
import { formatFixedPrice, formatGroupPrice } from '../lib/price'
|
||||
import type { PriceType, PricingModel, TokenUnit } from '../types'
|
||||
import { DynamicPricingBreakdown } from './dynamic-pricing-breakdown'
|
||||
import { ModelDetailsApi, ModelDetailsProviderInfo } from './model-details-api'
|
||||
import { ModelDetailsApps } from './model-details-apps'
|
||||
import { ModelDetailsCapabilities } from './model-details-capabilities'
|
||||
import { ModalitiesMatrix } from './model-details-modalities'
|
||||
import { ModelDetailsPerformance } from './model-details-performance'
|
||||
import { ModelDetailsQuickStats } from './model-details-quick-stats'
|
||||
import { UptimeStatusRow } from './model-details-uptime-sparkline'
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Local UI helpers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
function SectionTitle(props: { children: React.ReactNode }) {
|
||||
return (
|
||||
@@ -49,6 +71,10 @@ function SectionTitle(props: { children: React.ReactNode }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Model header (always visible above the tabs)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
function ModelHeader(props: { model: PricingModel }) {
|
||||
const { t } = useTranslation()
|
||||
const model = props.model
|
||||
@@ -63,7 +89,7 @@ function ModelHeader(props: { model: PricingModel }) {
|
||||
getDynamicPricingTiers(model).length === 0
|
||||
|
||||
return (
|
||||
<header className='pb-5'>
|
||||
<header className='pb-4'>
|
||||
<div className='flex items-center gap-2.5'>
|
||||
{vendorIcon}
|
||||
<h1 className='font-mono text-xl font-bold tracking-tight sm:text-2xl'>
|
||||
@@ -78,12 +104,12 @@ function ModelHeader(props: { model: PricingModel }) {
|
||||
aria-label={t('Copy model name')}
|
||||
/>
|
||||
</div>
|
||||
<div className='mt-1 flex items-center gap-1.5 text-xs'>
|
||||
<div className='mt-1 flex flex-wrap items-center gap-1.5 text-xs'>
|
||||
{model.vendor_name && (
|
||||
<span className='text-muted-foreground'>{model.vendor_name}</span>
|
||||
)}
|
||||
<span className='text-muted-foreground/30'>·</span>
|
||||
<span className='text-muted-foreground/50'>
|
||||
<span className='text-muted-foreground/70'>
|
||||
{model.quota_type === QUOTA_TYPE_VALUES.TOKEN
|
||||
? t('Token-based')
|
||||
: t('Per Request')}
|
||||
@@ -120,6 +146,10 @@ function ModelHeader(props: { model: PricingModel }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Base price card (used in the Overview tab)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
function PriceSection(props: {
|
||||
model: PricingModel
|
||||
priceRate: number
|
||||
@@ -128,22 +158,15 @@ function PriceSection(props: {
|
||||
showRechargePrice: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
model,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
tokenUnit,
|
||||
showRechargePrice,
|
||||
} = props
|
||||
const isTokenBased = isTokenBasedModel(model)
|
||||
const tokenUnitLabel = tokenUnit === 'K' ? '1K' : '1M'
|
||||
const isTokenBased = isTokenBasedModel(props.model)
|
||||
const tokenUnitLabel = props.tokenUnit === 'K' ? '1K' : '1M'
|
||||
const baseGroupKey = '_base'
|
||||
const baseGroupRatioMap = { [baseGroupKey]: 1 }
|
||||
const dynamicSummary = getDynamicPricingSummary(model, {
|
||||
tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
const dynamicSummary = getDynamicPricingSummary(props.model, {
|
||||
tokenUnit: props.tokenUnit,
|
||||
showRechargePrice: props.showRechargePrice,
|
||||
priceRate: props.priceRate,
|
||||
usdExchangeRate: props.usdExchangeRate,
|
||||
groupRatioMultiplier: 1,
|
||||
})
|
||||
|
||||
@@ -159,38 +182,39 @@ function PriceSection(props: {
|
||||
{
|
||||
label: t('Cached input'),
|
||||
type: 'cache',
|
||||
available: model.cache_ratio != null,
|
||||
available: props.model.cache_ratio != null,
|
||||
},
|
||||
{
|
||||
label: t('Cache write'),
|
||||
type: 'create_cache',
|
||||
available: model.create_cache_ratio != null,
|
||||
available: props.model.create_cache_ratio != null,
|
||||
},
|
||||
{
|
||||
label: t('Image input'),
|
||||
type: 'image',
|
||||
available: model.image_ratio != null,
|
||||
available: props.model.image_ratio != null,
|
||||
},
|
||||
{
|
||||
label: t('Audio input'),
|
||||
type: 'audio_input',
|
||||
available: model.audio_ratio != null,
|
||||
available: props.model.audio_ratio != null,
|
||||
},
|
||||
{
|
||||
label: t('Audio output'),
|
||||
type: 'audio_output',
|
||||
available:
|
||||
model.audio_ratio != null && model.audio_completion_ratio != null,
|
||||
props.model.audio_ratio != null &&
|
||||
props.model.audio_completion_ratio != null,
|
||||
},
|
||||
]
|
||||
|
||||
if (dynamicSummary) {
|
||||
if (dynamicSummary.isSpecialExpression) {
|
||||
return (
|
||||
<section className='border-b py-4'>
|
||||
<section>
|
||||
<SectionTitle>{t('Base Price')}</SectionTitle>
|
||||
<div className='rounded-lg border border-amber-200/70 bg-amber-50/70 p-3 dark:border-amber-500/20 dark:bg-amber-500/10'>
|
||||
<div className='text-amber-800 text-sm font-medium dark:text-amber-200'>
|
||||
<div className='text-sm font-medium text-amber-800 dark:text-amber-200'>
|
||||
{t('Special billing expression')}
|
||||
</div>
|
||||
<p className='text-muted-foreground mt-1 text-xs'>
|
||||
@@ -200,7 +224,7 @@ function PriceSection(props: {
|
||||
<div className='text-muted-foreground mb-1 text-[10px] font-medium tracking-wider uppercase'>
|
||||
{t('Raw expression')}
|
||||
</div>
|
||||
<code className='text-muted-foreground block max-h-28 overflow-auto rounded-md border bg-background/80 px-2 py-1.5 font-mono text-xs break-all'>
|
||||
<code className='text-muted-foreground bg-background/80 block max-h-28 overflow-auto rounded-md border px-2 py-1.5 font-mono text-xs break-all'>
|
||||
{dynamicSummary.rawExpression}
|
||||
</code>
|
||||
</div>
|
||||
@@ -210,12 +234,15 @@ function PriceSection(props: {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className='border-b py-4'>
|
||||
<section>
|
||||
<SectionTitle>{t('Base Price')}</SectionTitle>
|
||||
{dynamicSummary.primaryEntries.length > 0 ? (
|
||||
<div className='grid grid-cols-2 gap-2'>
|
||||
{dynamicSummary.primaryEntries.map((entry) => (
|
||||
<div key={entry.key} className='rounded-lg border bg-muted/20 p-3'>
|
||||
<div
|
||||
key={entry.key}
|
||||
className='bg-muted/20 rounded-lg border p-3'
|
||||
>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{t(entry.shortLabel)}
|
||||
</div>
|
||||
@@ -261,7 +288,7 @@ function PriceSection(props: {
|
||||
|
||||
if (!isTokenBased) {
|
||||
return (
|
||||
<section className='border-b py-4'>
|
||||
<section>
|
||||
<SectionTitle>{t('Base Price')}</SectionTitle>
|
||||
<div className='flex items-baseline justify-between'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
@@ -269,11 +296,11 @@ function PriceSection(props: {
|
||||
</span>
|
||||
<span className='text-foreground font-mono text-sm font-semibold tabular-nums'>
|
||||
{formatFixedPrice(
|
||||
model,
|
||||
props.model,
|
||||
baseGroupKey,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
props.showRechargePrice,
|
||||
props.priceRate,
|
||||
props.usdExchangeRate,
|
||||
baseGroupRatioMap
|
||||
)}
|
||||
</span>
|
||||
@@ -286,13 +313,13 @@ function PriceSection(props: {
|
||||
const renderPrice = (type: PriceType) => (
|
||||
<>
|
||||
{formatGroupPrice(
|
||||
model,
|
||||
props.model,
|
||||
baseGroupKey,
|
||||
type,
|
||||
tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
props.tokenUnit,
|
||||
props.showRechargePrice,
|
||||
props.priceRate,
|
||||
props.usdExchangeRate,
|
||||
baseGroupRatioMap
|
||||
)}
|
||||
<span className='text-muted-foreground/40 ml-1 text-xs font-normal'>
|
||||
@@ -302,11 +329,11 @@ function PriceSection(props: {
|
||||
)
|
||||
|
||||
return (
|
||||
<section className='border-b py-4'>
|
||||
<section>
|
||||
<SectionTitle>{t('Base Price')}</SectionTitle>
|
||||
<div className='grid grid-cols-2 gap-2'>
|
||||
{primaryPriceTypes.map((item) => (
|
||||
<div key={item.type} className='rounded-lg border bg-muted/20 p-3'>
|
||||
<div key={item.type} className='bg-muted/20 rounded-lg border p-3'>
|
||||
<div className='text-muted-foreground text-xs'>{item.label}</div>
|
||||
<div className='text-foreground mt-1 font-mono text-base font-semibold tabular-nums'>
|
||||
{renderPrice(item.type)}
|
||||
@@ -337,29 +364,31 @@ function PriceSection(props: {
|
||||
)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// API endpoints list
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
function EndpointsSection(props: {
|
||||
model: PricingModel
|
||||
endpointMap: Record<string, { path?: string; method?: string }>
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { model, endpointMap } = props
|
||||
|
||||
const endpoints = useMemo(() => {
|
||||
const types = model.supported_endpoint_types || []
|
||||
const types = props.model.supported_endpoint_types || []
|
||||
return types.map((type) => {
|
||||
const info = endpointMap[type] || {}
|
||||
const info = props.endpointMap[type] || {}
|
||||
let path = info.path || ''
|
||||
if (path.includes('{model}')) {
|
||||
path = replaceModelInPath(path, model.model_name || '')
|
||||
path = replaceModelInPath(path, props.model.model_name || '')
|
||||
}
|
||||
return { type, path, method: info.method || 'POST' }
|
||||
})
|
||||
}, [model, endpointMap])
|
||||
}, [props.model, props.endpointMap])
|
||||
|
||||
if (endpoints.length === 0) return null
|
||||
|
||||
return (
|
||||
<section className='border-b py-4'>
|
||||
<section>
|
||||
<SectionTitle>{t('API Endpoints')}</SectionTitle>
|
||||
<div className='space-y-1'>
|
||||
{endpoints.map(({ type, path, method }) => (
|
||||
@@ -384,6 +413,10 @@ function EndpointsSection(props: {
|
||||
)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Auto group chain (used inside group pricing section)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
function AutoGroupChain(props: { model: PricingModel; autoGroups: string[] }) {
|
||||
const { t } = useTranslation()
|
||||
const modelEnableGroups = Array.isArray(props.model.enable_groups)
|
||||
@@ -411,6 +444,10 @@ function AutoGroupChain(props: { model: PricingModel; autoGroups: string[] }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Group pricing table
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
function GroupPricingSection(props: {
|
||||
model: PricingModel
|
||||
groupRatio: Record<string, number>
|
||||
@@ -422,45 +459,39 @@ function GroupPricingSection(props: {
|
||||
showRechargePrice?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
model,
|
||||
groupRatio,
|
||||
usableGroup,
|
||||
autoGroups,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
tokenUnit,
|
||||
showRechargePrice = false,
|
||||
} = props
|
||||
const showRechargePrice = props.showRechargePrice ?? false
|
||||
|
||||
const availableGroups = useMemo(
|
||||
() => getAvailableGroups(model, usableGroup || {}),
|
||||
[model, usableGroup]
|
||||
() => getAvailableGroups(props.model, props.usableGroup || {}),
|
||||
[props.model, props.usableGroup]
|
||||
)
|
||||
|
||||
const isTokenBased = isTokenBasedModel(model)
|
||||
const tokenUnitLabel = tokenUnit === 'K' ? '1K' : '1M'
|
||||
const isTokenBased = isTokenBasedModel(props.model)
|
||||
const tokenUnitLabel = props.tokenUnit === 'K' ? '1K' : '1M'
|
||||
|
||||
const extraPriceTypes = useMemo(() => {
|
||||
const types: { label: string; type: PriceType }[] = []
|
||||
if (model.cache_ratio != null)
|
||||
if (props.model.cache_ratio != null)
|
||||
types.push({ label: t('Cache'), type: 'cache' })
|
||||
if (model.create_cache_ratio != null)
|
||||
if (props.model.create_cache_ratio != null)
|
||||
types.push({ label: t('Cache Write'), type: 'create_cache' })
|
||||
if (model.image_ratio != null)
|
||||
if (props.model.image_ratio != null)
|
||||
types.push({ label: t('Image'), type: 'image' })
|
||||
if (model.audio_ratio != null)
|
||||
if (props.model.audio_ratio != null)
|
||||
types.push({ label: t('Audio In'), type: 'audio_input' })
|
||||
if (model.audio_ratio != null && model.audio_completion_ratio != null)
|
||||
if (
|
||||
props.model.audio_ratio != null &&
|
||||
props.model.audio_completion_ratio != null
|
||||
)
|
||||
types.push({ label: t('Audio Out'), type: 'audio_output' })
|
||||
return types
|
||||
}, [model, t])
|
||||
}, [props.model, t])
|
||||
|
||||
if (availableGroups.length === 0) {
|
||||
return (
|
||||
<section className='py-4'>
|
||||
<section>
|
||||
<SectionTitle>{t('Pricing by Group')}</SectionTitle>
|
||||
<AutoGroupChain model={model} autoGroups={autoGroups} />
|
||||
<AutoGroupChain model={props.model} autoGroups={props.autoGroups} />
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{t(
|
||||
'This model is not available in any group, or no group pricing information is configured.'
|
||||
@@ -473,16 +504,16 @@ function GroupPricingSection(props: {
|
||||
const thClass =
|
||||
'text-muted-foreground py-2 text-[10px] font-medium tracking-wider uppercase'
|
||||
|
||||
if (isDynamicPricingModel(model)) {
|
||||
const dynamicTiers = getDynamicPricingTiers(model)
|
||||
if (isDynamicPricingModel(props.model)) {
|
||||
const dynamicTiers = getDynamicPricingTiers(props.model)
|
||||
|
||||
if (dynamicTiers.length === 0) {
|
||||
return (
|
||||
<section className='py-4'>
|
||||
<section>
|
||||
<SectionTitle>{t('Pricing by Group')}</SectionTitle>
|
||||
<AutoGroupChain model={model} autoGroups={autoGroups} />
|
||||
<AutoGroupChain model={props.model} autoGroups={props.autoGroups} />
|
||||
<div className='rounded-lg border border-amber-200/70 bg-amber-50/70 p-3 dark:border-amber-500/20 dark:bg-amber-500/10'>
|
||||
<div className='text-amber-800 text-sm font-medium dark:text-amber-200'>
|
||||
<div className='text-sm font-medium text-amber-800 dark:text-amber-200'>
|
||||
{t('Special billing expression')}
|
||||
</div>
|
||||
<p className='text-muted-foreground mt-1 text-xs'>
|
||||
@@ -494,8 +525,8 @@ function GroupPricingSection(props: {
|
||||
<div className='text-muted-foreground mb-1 text-[10px] font-medium tracking-wider uppercase'>
|
||||
{t('Raw expression')}
|
||||
</div>
|
||||
<code className='text-muted-foreground block max-h-28 overflow-auto rounded-md border bg-background/80 px-2 py-1.5 font-mono text-xs break-all'>
|
||||
{model.billing_expr}
|
||||
<code className='text-muted-foreground bg-background/80 block max-h-28 overflow-auto rounded-md border px-2 py-1.5 font-mono text-xs break-all'>
|
||||
{props.model.billing_expr}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
@@ -508,10 +539,10 @@ function GroupPricingSection(props: {
|
||||
dynamicTiers
|
||||
.flatMap((tier) =>
|
||||
getDynamicPriceEntries(tier, {
|
||||
tokenUnit,
|
||||
tokenUnit: props.tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
priceRate: props.priceRate,
|
||||
usdExchangeRate: props.usdExchangeRate,
|
||||
groupRatioMultiplier: 1,
|
||||
})
|
||||
)
|
||||
@@ -520,12 +551,12 @@ function GroupPricingSection(props: {
|
||||
)
|
||||
|
||||
return (
|
||||
<section className='py-4'>
|
||||
<section>
|
||||
<SectionTitle>{t('Pricing by Group')}</SectionTitle>
|
||||
<AutoGroupChain model={model} autoGroups={autoGroups} />
|
||||
<AutoGroupChain model={props.model} autoGroups={props.autoGroups} />
|
||||
<div className='space-y-3'>
|
||||
{availableGroups.map((group) => {
|
||||
const ratio = groupRatio[group] || 1
|
||||
const ratio = props.groupRatio[group] || 1
|
||||
return (
|
||||
<div key={group} className='overflow-hidden rounded-lg border'>
|
||||
<div className='bg-muted/20 flex items-center justify-between gap-3 border-b px-3 py-2'>
|
||||
@@ -552,10 +583,10 @@ function GroupPricingSection(props: {
|
||||
<TableBody>
|
||||
{dynamicTiers.map((tier, tierIndex) => {
|
||||
const entries = getDynamicPriceEntries(tier, {
|
||||
tokenUnit,
|
||||
tokenUnit: props.tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
priceRate: props.priceRate,
|
||||
usdExchangeRate: props.usdExchangeRate,
|
||||
groupRatioMultiplier: ratio,
|
||||
})
|
||||
const entryMap = new Map(
|
||||
@@ -587,7 +618,7 @@ function GroupPricingSection(props: {
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<p className='text-muted-foreground/40 mt-1.5 px-4 text-[10px] sm:px-0'>
|
||||
<p className='text-muted-foreground/40 mt-1.5 text-[10px]'>
|
||||
{t('Prices shown per')} {tokenUnitLabel} tokens
|
||||
</p>
|
||||
</div>
|
||||
@@ -596,10 +627,10 @@ function GroupPricingSection(props: {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className='py-4'>
|
||||
<section>
|
||||
<SectionTitle>{t('Pricing by Group')}</SectionTitle>
|
||||
<AutoGroupChain model={model} autoGroups={autoGroups} />
|
||||
<div className='-mx-4 sm:mx-0'>
|
||||
<AutoGroupChain model={props.model} autoGroups={props.autoGroups} />
|
||||
<div className='-mx-4 overflow-x-auto sm:mx-0'>
|
||||
<Table className='text-sm'>
|
||||
<TableHeader>
|
||||
<TableRow className='hover:bg-transparent'>
|
||||
@@ -631,7 +662,7 @@ function GroupPricingSection(props: {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{availableGroups.map((group) => {
|
||||
const ratio = groupRatio[group] || 1
|
||||
const ratio = props.groupRatio[group] || 1
|
||||
return (
|
||||
<TableRow key={group}>
|
||||
<TableCell className='py-2.5'>
|
||||
@@ -644,26 +675,26 @@ function GroupPricingSection(props: {
|
||||
<>
|
||||
<TableCell className='py-2.5 text-right font-mono'>
|
||||
{formatGroupPrice(
|
||||
model,
|
||||
props.model,
|
||||
group,
|
||||
'input',
|
||||
tokenUnit,
|
||||
props.tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
groupRatio
|
||||
props.priceRate,
|
||||
props.usdExchangeRate,
|
||||
props.groupRatio
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className='py-2.5 text-right font-mono'>
|
||||
{formatGroupPrice(
|
||||
model,
|
||||
props.model,
|
||||
group,
|
||||
'output',
|
||||
tokenUnit,
|
||||
props.tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
groupRatio
|
||||
props.priceRate,
|
||||
props.usdExchangeRate,
|
||||
props.groupRatio
|
||||
)}
|
||||
</TableCell>
|
||||
{extraPriceTypes.map((ep) => (
|
||||
@@ -672,14 +703,14 @@ function GroupPricingSection(props: {
|
||||
className='py-2.5 text-right font-mono'
|
||||
>
|
||||
{formatGroupPrice(
|
||||
model,
|
||||
props.model,
|
||||
group,
|
||||
ep.type,
|
||||
tokenUnit,
|
||||
props.tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
groupRatio
|
||||
props.priceRate,
|
||||
props.usdExchangeRate,
|
||||
props.groupRatio
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
@@ -687,12 +718,12 @@ function GroupPricingSection(props: {
|
||||
) : (
|
||||
<TableCell className='py-2.5 text-right font-mono'>
|
||||
{formatFixedPrice(
|
||||
model,
|
||||
props.model,
|
||||
group,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
groupRatio
|
||||
props.priceRate,
|
||||
props.usdExchangeRate,
|
||||
props.groupRatio
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
@@ -711,6 +742,30 @@ function GroupPricingSection(props: {
|
||||
)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Tabbed details content
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
const TAB_VALUES = [
|
||||
'overview',
|
||||
'pricing',
|
||||
'performance',
|
||||
'api',
|
||||
'apps',
|
||||
] as const
|
||||
type TabValue = (typeof TAB_VALUES)[number]
|
||||
|
||||
const TAB_META: Record<
|
||||
TabValue,
|
||||
{ icon: React.ComponentType<{ className?: string }>; labelKey: string }
|
||||
> = {
|
||||
overview: { icon: Info, labelKey: 'Overview' },
|
||||
pricing: { icon: ReceiptText, labelKey: 'Pricing' },
|
||||
performance: { icon: HeartPulse, labelKey: 'Performance' },
|
||||
api: { icon: Code2, labelKey: 'API' },
|
||||
apps: { icon: Rocket, labelKey: 'Apps' },
|
||||
}
|
||||
|
||||
export interface ModelDetailsContentProps {
|
||||
model: PricingModel
|
||||
groupRatio: Record<string, number>
|
||||
@@ -724,52 +779,121 @@ export interface ModelDetailsContentProps {
|
||||
}
|
||||
|
||||
export function ModelDetailsContent(props: ModelDetailsContentProps) {
|
||||
const {
|
||||
model,
|
||||
groupRatio,
|
||||
usableGroup,
|
||||
endpointMap,
|
||||
autoGroups,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
tokenUnit,
|
||||
showRechargePrice = false,
|
||||
} = props
|
||||
const { t } = useTranslation()
|
||||
const showRechargePrice = props.showRechargePrice ?? false
|
||||
const metadata = useMemo(() => inferModelMetadata(props.model), [props.model])
|
||||
const uptimeSeries = useMemo(
|
||||
() => buildUptimeSeries(props.model),
|
||||
[props.model]
|
||||
)
|
||||
|
||||
const isDynamic =
|
||||
props.model.billing_mode === 'tiered_expr' &&
|
||||
Boolean(props.model.billing_expr)
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModelHeader model={model} />
|
||||
<div className='@container/details space-y-4'>
|
||||
<ModelHeader model={props.model} />
|
||||
|
||||
<PriceSection
|
||||
model={model}
|
||||
priceRate={priceRate}
|
||||
usdExchangeRate={usdExchangeRate}
|
||||
tokenUnit={tokenUnit}
|
||||
showRechargePrice={showRechargePrice}
|
||||
/>
|
||||
<ModelDetailsQuickStats metadata={metadata} />
|
||||
|
||||
<EndpointsSection model={model} endpointMap={endpointMap} />
|
||||
<UptimeStatusRow series={uptimeSeries} />
|
||||
|
||||
{model.billing_mode === 'tiered_expr' && model.billing_expr && (
|
||||
<div className='border-b'>
|
||||
<DynamicPricingBreakdown billingExpr={model.billing_expr} />
|
||||
</div>
|
||||
)}
|
||||
<Tabs defaultValue='overview' className='gap-4'>
|
||||
<TabsList className='bg-muted/60 h-auto w-full justify-start gap-1 overflow-x-auto rounded-lg p-1'>
|
||||
{TAB_VALUES.map((value) => {
|
||||
const Icon = TAB_META[value].icon
|
||||
return (
|
||||
<TabsTrigger
|
||||
key={value}
|
||||
value={value}
|
||||
className='h-8 gap-1.5 rounded-md px-3 text-xs sm:text-sm'
|
||||
>
|
||||
<Icon className='size-3.5' />
|
||||
<span>{t(TAB_META[value].labelKey)}</span>
|
||||
</TabsTrigger>
|
||||
)
|
||||
})}
|
||||
</TabsList>
|
||||
|
||||
<GroupPricingSection
|
||||
model={model}
|
||||
groupRatio={groupRatio}
|
||||
usableGroup={usableGroup}
|
||||
autoGroups={autoGroups}
|
||||
priceRate={priceRate}
|
||||
usdExchangeRate={usdExchangeRate}
|
||||
tokenUnit={tokenUnit}
|
||||
showRechargePrice={showRechargePrice}
|
||||
/>
|
||||
</>
|
||||
<TabsContent value='overview' className='space-y-5 outline-none'>
|
||||
<section>
|
||||
<div className='mb-3 flex items-center gap-2'>
|
||||
<Boxes className='text-muted-foreground/70 size-3.5' />
|
||||
<h3 className='text-foreground text-sm font-semibold'>
|
||||
{t('Capabilities')}
|
||||
</h3>
|
||||
</div>
|
||||
<ModelDetailsCapabilities capabilities={metadata.capabilities} />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className='mb-3 flex items-center gap-2'>
|
||||
<h3 className='text-foreground text-sm font-semibold'>
|
||||
{t('Supported modalities')}
|
||||
</h3>
|
||||
</div>
|
||||
<ModalitiesMatrix
|
||||
input={metadata.input_modalities}
|
||||
output={metadata.output_modalities}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<ModelDetailsProviderInfo model={props.model} />
|
||||
|
||||
<PriceSection
|
||||
model={props.model}
|
||||
priceRate={props.priceRate}
|
||||
usdExchangeRate={props.usdExchangeRate}
|
||||
tokenUnit={props.tokenUnit}
|
||||
showRechargePrice={showRechargePrice}
|
||||
/>
|
||||
|
||||
<EndpointsSection
|
||||
model={props.model}
|
||||
endpointMap={props.endpointMap}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='pricing' className='space-y-5 outline-none'>
|
||||
{isDynamic && (
|
||||
<DynamicPricingBreakdown billingExpr={props.model.billing_expr} />
|
||||
)}
|
||||
<GroupPricingSection
|
||||
model={props.model}
|
||||
groupRatio={props.groupRatio}
|
||||
usableGroup={props.usableGroup}
|
||||
autoGroups={props.autoGroups}
|
||||
priceRate={props.priceRate}
|
||||
usdExchangeRate={props.usdExchangeRate}
|
||||
tokenUnit={props.tokenUnit}
|
||||
showRechargePrice={showRechargePrice}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='performance' className='outline-none'>
|
||||
<ModelDetailsPerformance model={props.model} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='api' className='outline-none'>
|
||||
<ModelDetailsApi
|
||||
model={props.model}
|
||||
endpointMap={props.endpointMap}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='apps' className='outline-none'>
|
||||
<ModelDetailsApps model={props.model} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Drawer & page wrappers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
export interface ModelDetailsDrawerProps extends ModelDetailsContentProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
@@ -783,7 +907,7 @@ export function ModelDetailsDrawer(props: ModelDetailsDrawerProps) {
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side='right'
|
||||
className='flex h-dvh w-full overflow-hidden p-0 sm:max-w-2xl xl:max-w-3xl'
|
||||
className='flex h-dvh w-full overflow-hidden p-0 sm:max-w-2xl lg:max-w-3xl xl:max-w-4xl 2xl:max-w-5xl'
|
||||
>
|
||||
<SheetHeader className='sr-only'>
|
||||
<SheetTitle>{props.model.model_name}</SheetTitle>
|
||||
@@ -829,19 +953,21 @@ export function ModelDetails() {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PublicLayout>
|
||||
<div className='mx-auto max-w-2xl px-4 sm:px-6'>
|
||||
<div className='mx-auto max-w-5xl px-4 sm:px-6'>
|
||||
<Skeleton className='mb-4 h-5 w-16' />
|
||||
<div className='space-y-2'>
|
||||
<Skeleton className='h-7 w-64' />
|
||||
<Skeleton className='h-4 w-40' />
|
||||
<Skeleton className='h-4 w-full max-w-md' />
|
||||
</div>
|
||||
<div className='mt-6 grid grid-cols-2 gap-2 sm:grid-cols-4'>
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className='h-16 w-full' />
|
||||
))}
|
||||
</div>
|
||||
<div className='mt-6 space-y-3'>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className='flex justify-between'>
|
||||
<Skeleton className='h-5 w-24' />
|
||||
<Skeleton className='h-5 w-20' />
|
||||
</div>
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className='h-24 w-full' />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -869,7 +995,7 @@ export function ModelDetails() {
|
||||
|
||||
return (
|
||||
<PublicLayout>
|
||||
<div className='mx-auto max-w-2xl px-4 sm:px-6'>
|
||||
<div className='mx-auto max-w-5xl px-4 sm:px-6'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
|
||||
Reference in New Issue
Block a user