diff --git a/controller/audit.go b/controller/audit.go index 36080724f8..fc0206088c 100644 --- a/controller/audit.go +++ b/controller/audit.go @@ -30,20 +30,22 @@ var auditContentTemplates = map[string]string{ "user.reset_passkey": "Reset the user passkey", "option.update": "Updated system setting ${key}", - "channel.create": "Created channel ${name} (type ${type}, count ${count})", - "channel.update": "Updated channel ${name} (ID: ${id})", - "channel.delete": "Deleted channel ${name} (ID: ${id})", - "channel.delete_batch": "Batch deleted ${count} channels", - "channel.delete_disabled": "Deleted all disabled channels (${count})", - "channel.key_view": "Viewed channel key ${name} (ID: ${id})", - "channel.tag_disable": "Disabled channels with tag ${tag}", - "channel.tag_enable": "Enabled channels with tag ${tag}", - "channel.tag_edit": "Edited channels with tag ${tag}", - "channel.tag_batch_set": "Batch set tag for ${count} channels", - "channel.copy": "Copied channel (source ID: ${sourceId}) to ${name} (new ID: ${id})", - "channel.multi_key_manage": "Multi-key management ${action} on channel (ID: ${id})", - "channel.upstream_apply": "Applied upstream model changes to channel (ID: ${id})", - "channel.upstream_apply_all": "Applied upstream model changes to ${count} channels", + "channel.create": "Created channel ${name} (type ${type}, count ${count})", + "channel.update": "Updated channel ${name} (ID: ${id})", + "channel.status_update": "Updated channel status (ID: ${id})", + "channel.status_update_batch": "Batch updated ${count} of ${total} channel statuses", + "channel.delete": "Deleted channel ${name} (ID: ${id})", + "channel.delete_batch": "Batch deleted ${count} channels", + "channel.delete_disabled": "Deleted all disabled channels (${count})", + "channel.key_view": "Viewed channel key ${name} (ID: ${id})", + "channel.tag_disable": "Disabled channels with tag ${tag}", + "channel.tag_enable": "Enabled channels with tag ${tag}", + "channel.tag_edit": "Edited channels with tag ${tag}", + "channel.tag_batch_set": "Batch set tag for ${count} channels", + "channel.copy": "Copied channel (source ID: ${sourceId}) to ${name} (new ID: ${id})", + "channel.multi_key_manage": "Multi-key management ${action} on channel (ID: ${id})", + "channel.upstream_apply": "Applied upstream model changes to channel (ID: ${id})", + "channel.upstream_apply_all": "Applied upstream model changes to ${count} channels", "redemption.create": "Created ${count} redemption codes named ${name} (${quota} each)", diff --git a/web/bun.lock b/web/bun.lock index 1cd7f42e0d..d4b18b4f4b 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -73,6 +73,7 @@ "@codemirror/language": "^6.12.4", "@codemirror/state": "^6.7.0", "@codemirror/view": "^6.43.4", + "@fontsource-variable/jetbrains-mono": "5.2.8", "@fontsource-variable/lora": "^5.2.8", "@fontsource-variable/public-sans": "^5.2.7", "@hookform/resolvers": "^5.4.0", @@ -366,6 +367,8 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], + "@fontsource-variable/jetbrains-mono": ["@fontsource-variable/jetbrains-mono@5.2.8", "", {}, "sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q=="], + "@fontsource-variable/lora": ["@fontsource-variable/lora@5.2.8", "", {}, "sha512-cxjTJ9BbOWIzusewR4UMBLVePvTSWV6dtNaNsCkF/oKoyA68fJGWfaYCILOOP1BObE4dmjfZ3xo6m9hdHhtYhg=="], "@fontsource-variable/public-sans": ["@fontsource-variable/public-sans@5.2.7", "", {}, "sha512-4mvade2J3slKkvwRkS+p8T3szet/0vhWoSnuUJTVU81Uo2pRpSZY/Y8bSLRqpSwzIPxjVmRJ53oq6JKP/l/PSg=="], diff --git a/web/default/AGENTS.md b/web/default/AGENTS.md index 9605a6d352..36ba7f9b66 100644 --- a/web/default/AGENTS.md +++ b/web/default/AGENTS.md @@ -132,9 +132,10 @@ **颜色** - 后台界面(除 `features/home/` 营销页外)**禁止硬编码 Tailwind 调色板类**(如 `text-emerald-600`、`bg-amber-50`、`border-rose-200`),一律使用语义令牌:`success` / `warning` / `destructive` / `info` / `neutral`、`primary` / `muted` / `accent` / `border` / `ring`。暗色适配由令牌完成,禁止 `dark:text-emerald-400` 之类逐处覆写。 -- 状态色仅表达状态(成功/警告/错误/信息),不得用于装饰或分类;分类信息(模型名、分组名、渠道名等)一律中性呈现,身份靠文本与图标传达。`StatusBadge` 的 `autoColor` 已废弃为 neutral,禁止恢复字符串哈希取色。 -- 需要浅底状态胶囊时使用 `status-badge.tsx` 的 `tintedBadgeClassMap`,不要再手写 `bg-xxx-50 text-xxx-700 dark:...` 组合。 -- 图表用 `--chart-1..5`;用户头像身份色走 `getAvatarColorClass`。这两处是仅有的多色场景。 +- 状态色仅表达状态(成功/警告/错误/信息),不得用于装饰;实体分类信息(模型名、分组名、渠道名、标签、端点、供应商等)使用**身份色**呈现:经 `lib/colors.ts` 的 `getIdentityTextColorClass` / `getIdentityColorClass`(字符串哈希稳定取色,同名同色)映射到 `text-identity-*` 令牌,不得为实体信息挪用状态色,也不得绕过身份色系统硬编码调色板类。 +- 身份色令牌(`--color-identity-*`)与状态文字色(`--color-status-*`)集中定义在 `styles/theme.css`,亮/暗模式的混合比例由 `--identity-color-mix` / `--status-color-mix` 控制;调整观感改这些变量,不要在组件里逐处覆写。 +- 需要浅底胶囊时使用 `StatusBadge` 的 `appearance='soft'`(或 `getIdentityColorClass` 的 currentColor 底色),不要手写 `bg-xxx-50 text-xxx-700 dark:...` 组合。 +- 图表用 `--chart-1..5`;用户头像身份色走 `lib/avatar.ts` 的 `getUserAvatarStyle`。 - 禁止用 `!important`(`!text-*` 等)压制文字颜色/字号;若需要覆盖,说明层级设计有误,先修组件。 **排版** @@ -170,7 +171,7 @@ **徽章与图标** -- 文本徽章统一 `StatusBadge`(五种语义 voice);模型/分组/渠道等实体徽章统一中性底(`border-border/60 bg-muted/30`)。不要新造第三种徽章样式。 +- 文本徽章统一 `StatusBadge`(五种语义 voice);模型/分组/渠道/标签/端点等实体徽章保持中性 `variant`,文字色通过 `getIdentityTextColorClass(name)` 叠加身份色(需要浅底时用 `appearance='soft'` 或 `getIdentityColorClass`)。不要新造第三种徽章样式。 - 业务代码图标一律 `lucide-react`;`components/ui/` 由 shadcn 生成器维护(Hugeicons),不要手改基础组件图标库;AI 品牌图标用 `@lobehub/icons`(经 `getLobeIcon`)。 ### 3.11 文件组织 diff --git a/web/default/package.json b/web/default/package.json index 630f01100f..af32ec7b8a 100644 --- a/web/default/package.json +++ b/web/default/package.json @@ -24,6 +24,7 @@ "@codemirror/language": "^6.12.4", "@codemirror/state": "^6.7.0", "@codemirror/view": "^6.43.4", + "@fontsource-variable/jetbrains-mono": "^5.2.8", "@fontsource-variable/lora": "^5.2.8", "@fontsource-variable/public-sans": "^5.2.7", "@hookform/resolvers": "^5.4.0", diff --git a/web/default/src/components/config-drawer.tsx b/web/default/src/components/config-drawer.tsx index 3c06868697..1a3d6f1516 100644 --- a/web/default/src/components/config-drawer.tsx +++ b/web/default/src/components/config-drawer.tsx @@ -65,19 +65,31 @@ import { cn } from '@/lib/utils' const Item = RadioPrimitive.Root -export function ConfigDrawer() { +type ConfigDrawerProps = { + /** + * Whether to show console-only sections (sidebar, layout, content width). + * Requires SidebarProvider/LayoutProvider; disable on public pages. + * @default true + */ + showLayoutControls?: boolean + /** + * Extra classes for the trigger button (e.g. to override `max-md:hidden`). + */ + triggerClassName?: string +} + +export function ConfigDrawer({ + showLayoutControls = true, + triggerClassName, +}: ConfigDrawerProps) { const { t } = useTranslation() - const { setOpen } = useSidebar() const { resetDir } = useDirection() const { resetTheme } = useTheme() - const { resetLayout } = useLayout() const { resetCustomization } = useThemeCustomization() - const handleReset = () => { - setOpen(true) + const resetAppearance = () => { resetDir() resetTheme() - resetLayout() resetCustomization() } @@ -90,7 +102,7 @@ export function ConfigDrawer() { variant='ghost' aria-label={t('Open theme settings')} aria-describedby='config-drawer-description' - className='max-md:hidden' + className={cn('max-md:hidden', triggerClassName)} /> } > @@ -109,25 +121,60 @@ export function ConfigDrawer() { - - - + {showLayoutControls && ( + <> + + + + + )} - + {showLayoutControls ? ( + + ) : ( + + )} ) } +/** + * Reset button variant that also restores sidebar/layout state. Kept as a + * separate component because it consumes SidebarProvider/LayoutProvider hooks + * that only exist inside the console layout. + */ +function ConsoleResetButton(props: { onReset: () => void }) { + const { t } = useTranslation() + const { setOpen } = useSidebar() + const { resetLayout } = useLayout() + + const handleReset = () => { + setOpen(true) + resetLayout() + props.onReset() + } + + return ( + + ) +} + function SectionTitle(props: { title: string showReset?: boolean @@ -322,6 +369,7 @@ const FONT_OPTIONS: { preview?: string }[] = [ { value: 'default', label: 'Auto', preview: undefined }, + { value: 'mono', label: 'Mono', preview: 'var(--font-mono)' }, { value: 'sans', label: 'Sans', preview: 'var(--font-sans)' }, { value: 'serif', label: 'Serif', preview: 'var(--font-serif)' }, ] @@ -339,7 +387,7 @@ function FontConfig() { setFont(v as ThemeFont)} - className='grid w-full grid-cols-3 gap-4' + className='grid w-full grid-cols-4 gap-4' aria-label={t('Select body font')} > {FONT_OPTIONS.map((option) => ( diff --git a/web/default/src/components/layout/components/public-header.tsx b/web/default/src/components/layout/components/public-header.tsx index 9e05d9a7b6..077c91cf64 100644 --- a/web/default/src/components/layout/components/public-header.tsx +++ b/web/default/src/components/layout/components/public-header.tsx @@ -20,12 +20,12 @@ import { Link, useNavigate, useRouterState } from '@tanstack/react-router' import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' +import { ConfigDrawer } from '@/components/config-drawer' import { Button } from '@/components/design-system/button' import { Dialog } from '@/components/dialog' import { LanguageSwitcher } from '@/components/language-switcher' import { NotificationPopover } from '@/components/notification-popover' import { ProfileDropdown } from '@/components/profile-dropdown' -import { ThemeSwitch } from '@/components/theme-switch' import { Skeleton } from '@/components/ui/skeleton' import { useNotifications } from '@/hooks/use-notifications' import { useSystemConfig } from '@/hooks/use-system-config' @@ -263,7 +263,12 @@ export function PublicHeader(props: PublicHeaderProps) { )} {showLanguageSwitcher && } - {showThemeSwitch && } + {showThemeSwitch && ( + + )} {showNotifications && ( - {showThemeSwitch && } + {showThemeSwitch && ( + + )} {showAuthButtons && !loading && isAuthenticated && ( )} diff --git a/web/default/src/components/provider-badge.tsx b/web/default/src/components/provider-badge.tsx index 9a5af178bb..ba53b28514 100644 --- a/web/default/src/components/provider-badge.tsx +++ b/web/default/src/components/provider-badge.tsx @@ -16,6 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ +import { getIdentityTextColorClass } from '@/lib/colors' import { getLobeIcon } from '@/lib/lobe-icon' import { cn } from '@/lib/utils' @@ -24,12 +25,15 @@ import { StatusBadge, type StatusBadgeProps } from './status-badge' type ProviderBadgeProps = Omit & { iconKey?: string | null label: string + /** Color the label text by provider name. Set false for a neutral label. */ + colorText?: boolean } export function ProviderBadge({ className, iconKey, label, + colorText = true, ...badgeProps }: ProviderBadgeProps) { const icon = iconKey ? getLobeIcon(iconKey, 12) : null @@ -38,7 +42,11 @@ export function ProviderBadge({ {icon && ( . For commercial licensing, please contact support@quantumnous.com */ import { Check, Moon, Sun } from 'lucide-react' -import { useEffect } from 'react' import { useTranslation } from 'react-i18next' import { Button } from '@/components/design-system/button' @@ -32,15 +31,7 @@ import { cn } from '@/lib/utils' export function ThemeSwitch() { const { t } = useTranslation() - const { resolvedTheme, theme, setTheme } = useTheme() - - /* Update theme-color meta tag - * when theme is updated */ - useEffect(() => { - const themeColor = resolvedTheme === 'dark' ? '#2c2c2c' : '#fff' - const metaThemeColor = document.querySelector("meta[name='theme-color']") - if (metaThemeColor) metaThemeColor.setAttribute('content', themeColor) - }, [resolvedTheme]) + const { theme, setTheme } = useTheme() return ( diff --git a/web/default/src/context/theme-customization-provider.tsx b/web/default/src/context/theme-customization-provider.tsx index c6c3563d7d..8889f07d0c 100644 --- a/web/default/src/context/theme-customization-provider.tsx +++ b/web/default/src/context/theme-customization-provider.tsx @@ -146,9 +146,9 @@ export function ThemeCustomizationProvider(props: { // Font is the one axis where we resolve before writing the attribute: // the persisted preference may be `default`, but CSS works in terms of - // the concrete `sans`/`serif` choice that should drive the cascade. + // the concrete `mono`/`sans`/`serif` choice that should drive the cascade. // Resolving here (instead of in CSS via `:not()` selectors) keeps the - // stylesheet to one simple `[data-theme-font='serif']` selector and lets + // stylesheet to simple `[data-theme-font='...']` selectors and lets // future presets opt into typography via `PRESET_DEFAULT_FONT` alone. useLayoutEffect(() => { applyAttribute('data-theme-font', resolveThemeFont(font, preset)) diff --git a/web/default/src/context/theme-provider.tsx b/web/default/src/context/theme-provider.tsx index dd53e4ea63..e3ac7c0eab 100644 --- a/web/default/src/context/theme-provider.tsx +++ b/web/default/src/context/theme-provider.tsx @@ -98,6 +98,10 @@ export function ThemeProvider({ root.classList.remove('light', 'dark') root.classList.add(nextResolvedTheme) setResolvedTheme(nextResolvedTheme) + + const themeColor = nextResolvedTheme === 'dark' ? '#2c2c2c' : '#fff' + const metaThemeColor = document.querySelector("meta[name='theme-color']") + if (metaThemeColor) metaThemeColor.setAttribute('content', themeColor) } applyTheme() diff --git a/web/default/src/features/channels/components/channels-columns.tsx b/web/default/src/features/channels/components/channels-columns.tsx index 3a7ce3926e..2f77c0aee7 100644 --- a/web/default/src/features/channels/components/channels-columns.tsx +++ b/web/default/src/features/channels/components/channels-columns.tsx @@ -47,13 +47,14 @@ import { TooltipTrigger, } from '@/components/ui/tooltip' import { toIntlLocale } from '@/i18n/languages' +import { getIdentityTextColorClass } from '@/lib/colors' import { formatCurrencyFromUSD, formatQuotaWithCurrency, getCurrencyLabel, } from '@/lib/currency' import { formatTimestampToDate } from '@/lib/format' -import { truncateText } from '@/lib/utils' +import { cn, truncateText } from '@/lib/utils' import { getCodexUsage } from '../api' import { CHANNEL_STATUS_CONFIG, MODEL_FETCHABLE_TYPES } from '../constants' @@ -755,6 +756,7 @@ export function useChannelsColumns( {typeName} @@ -952,6 +954,7 @@ export function useChannelsColumns( variant='neutral' size='sm' title={model} + className={cn('font-mono', getIdentityTextColorClass(model))} > {model} @@ -1019,7 +1022,12 @@ export function useChannelsColumns( } return ( - + {tag} ) diff --git a/web/default/src/features/dashboard/components/models/performance-overview.tsx b/web/default/src/features/dashboard/components/models/performance-overview.tsx index 1ba19345fa..31f00f9a58 100644 --- a/web/default/src/features/dashboard/components/models/performance-overview.tsx +++ b/web/default/src/features/dashboard/components/models/performance-overview.tsx @@ -21,14 +21,13 @@ import { Gauge, HeartPulse, Timer } from 'lucide-react' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { StatusBadge, type StatusVariant } from '@/components/status-badge' import { Skeleton } from '@/components/ui/skeleton' import { getPerfMetricsSummary } from '@/features/performance-metrics/api' import { formatLatency, formatThroughput, formatUptimePct, - getSuccessRateLevel, + getSuccessRateDotClass, getSuccessRateTextClass, } from '@/features/performance-metrics/lib/format' import type { PerfModelSummary } from '@/features/performance-metrics/types' @@ -207,22 +206,25 @@ function InlineMetric(props: { function ModelBadge(props: { model: PerfModelSummary }) { const model = props.model - const level = getSuccessRateLevel(model.success_rate) - let variant: StatusVariant = 'neutral' - if (level === 'excellent' || level === 'good') { - variant = 'success' - } else if (level === 'warning') { - variant = 'warning' - } else if (level === 'critical') { - variant = 'destructive' - } return ( - - {model.model_name} - + + {model.model_name} + + ) } diff --git a/web/default/src/features/models/components/deployments-columns.tsx b/web/default/src/features/models/components/deployments-columns.tsx index 0b9e121653..b10eac3817 100644 --- a/web/default/src/features/models/components/deployments-columns.tsx +++ b/web/default/src/features/models/components/deployments-columns.tsx @@ -29,6 +29,7 @@ import { DropdownMenuSeparator, DropdownMenuShortcut, } from '@/components/ui/dropdown-menu' +import { getIdentityTextColorClass } from '@/lib/colors' import { formatTimestampToDate } from '@/lib/format' import { getDeploymentStatusConfig } from '../constants' @@ -126,7 +127,11 @@ export function useDeploymentsColumns(opts: { return - } return ( - + {String(provider)} ) diff --git a/web/default/src/features/models/components/dialogs/prefill-group-management-dialog.tsx b/web/default/src/features/models/components/dialogs/prefill-group-management-dialog.tsx index 09cdcc9639..e06931e53f 100644 --- a/web/default/src/features/models/components/dialogs/prefill-group-management-dialog.tsx +++ b/web/default/src/features/models/components/dialogs/prefill-group-management-dialog.tsx @@ -53,6 +53,7 @@ import { EmptyTitle, } from '@/components/ui/empty' import { useIsMobile } from '@/hooks/use-mobile' +import { getIdentityTextColorClass } from '@/lib/colors' import { cn } from '@/lib/utils' import { deletePrefillGroup, getPrefillGroups } from '../../api' @@ -246,7 +247,12 @@ export function PrefillGroupManagementDialog({ {parsedItems.length > 0 ? (
{parsedItems.slice(0, 6).map((item) => ( - + {item} ))} @@ -318,7 +324,12 @@ export function PrefillGroupManagementDialog({ {parsedItems.length > 0 ? ( <> {parsedItems.slice(0, 6).map((item) => ( - + {item} ))} diff --git a/web/default/src/features/models/components/models-columns.tsx b/web/default/src/features/models/components/models-columns.tsx index 2fdaf02199..f9dc6e1648 100644 --- a/web/default/src/features/models/components/models-columns.tsx +++ b/web/default/src/features/models/components/models-columns.tsx @@ -31,6 +31,7 @@ import { TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip' +import { getIdentityTextColorClass } from '@/lib/colors' import { formatTimestampToDate } from '@/lib/format' import { getLobeIcon } from '@/lib/lobe-icon' @@ -166,7 +167,11 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef[] { } const badge = ( - + {label} ) @@ -178,7 +183,12 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef[] { model.matched_models.length > 0 ) { const matchedBadges = model.matched_models.map((m) => ( - + {m} )) @@ -306,7 +316,12 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef[] { return ( ( - + {tag} ))} @@ -333,7 +348,12 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef[] { return ( ( - + {ep} ))} @@ -368,6 +388,7 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef[] { key={`${c.id}-${c.name}-${c.type ?? ''}`} variant='neutral' size='sm' + className={getIdentityTextColorClass(c.name)} > {`${c.name} (${c.type})`} @@ -424,6 +445,7 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef[] { key={qt} variant={config?.variant || 'neutral'} size='sm' + className={config?.className} > {config?.label || String(qt)} diff --git a/web/default/src/features/models/constants.ts b/web/default/src/features/models/constants.ts index 013789e5f0..bb1a730d6a 100644 --- a/web/default/src/features/models/constants.ts +++ b/web/default/src/features/models/constants.ts @@ -46,28 +46,32 @@ export function getNameRuleConfig(t: TFunction): Record< { label: string variant: StatusBadgeProps['variant'] + className?: string description: string } > { return { 0: { label: t('Exact'), - variant: 'neutral', + variant: 'success', description: t('Match model name exactly'), }, 1: { label: t('Prefix'), variant: 'neutral', + className: 'text-identity-blue', description: t('Match models starting with this name'), }, 2: { label: t('Contains'), variant: 'neutral', + className: 'text-identity-orange', description: t('Match models containing this name'), }, 3: { label: t('Suffix'), variant: 'neutral', + className: 'text-identity-purple', description: t('Match models ending with this name'), }, } @@ -153,12 +157,25 @@ export function getDeploymentStatusConfig(t: TFunction): Record< // Quota Type // ============================================================================ -export function getQuotaTypeConfig( - t: TFunction -): Record { +export function getQuotaTypeConfig(t: TFunction): Record< + number, + { + label: string + variant: StatusBadgeProps['variant'] + className?: string + } +> { return { - 0: { label: t('Usage-based'), variant: 'neutral' }, - 1: { label: t('Per-call'), variant: 'neutral' }, + 0: { + label: t('Usage-based'), + variant: 'neutral', + className: 'text-identity-violet', + }, + 1: { + label: t('Per-call'), + variant: 'neutral', + className: 'text-identity-teal', + }, } } diff --git a/web/default/src/features/pricing/components/dynamic-pricing-breakdown.tsx b/web/default/src/features/pricing/components/dynamic-pricing-breakdown.tsx index a80ca93261..2e1ddf6df1 100644 --- a/web/default/src/features/pricing/components/dynamic-pricing-breakdown.tsx +++ b/web/default/src/features/pricing/components/dynamic-pricing-breakdown.tsx @@ -199,7 +199,7 @@ export function DynamicPricingBreakdown({
{!compact && (
- +
@@ -212,7 +212,7 @@ export function DynamicPricingBreakdown({
)} -
+
{t('Raw expression')}
@@ -234,7 +234,7 @@ export function DynamicPricingBreakdown({
{!compact && (
- +
@@ -268,20 +268,23 @@ export function DynamicPricingBreakdown({ tier.label === matchedTierLabel return (
- + {tier.label || t('Default')} {isMatched && ( {t('Matched')} @@ -299,12 +302,12 @@ export function DynamicPricingBreakdown({ ) return (
-
+
{t(v.shortLabel)}
@@ -334,7 +337,10 @@ export function DynamicPricingBreakdown({ const isMatched = normalizedMatchedTierLabel !== '' && normalizeTierLabel(tier.label) === normalizedMatchedTierLabel - return cn(isMatched && 'bg-success/10 hover:bg-success/10') + return cn( + isMatched && + 'bg-emerald-50/70 hover:bg-emerald-50/70 dark:bg-emerald-500/10 dark:hover:bg-emerald-500/10' + ) }} columns={[ { @@ -354,13 +360,16 @@ export function DynamicPricingBreakdown({ return ( <>
- + {tier.label || t('Default')} {isMatched && ( {t('Matched')} @@ -383,7 +392,7 @@ export function DynamicPricingBreakdown({ compact && 'h-8' ), cellClassName: cn( - 'text-right align-top tabular-nums', + 'text-right align-top font-mono', compact ? 'py-2' : 'py-2.5' ), cell: (tier: ParsedTier) => { @@ -416,30 +425,27 @@ export function DynamicPricingBreakdown({ {t('Conditional multipliers')}
    - {ruleGroups.map((group) => { - const description = describeGroup(group, t) - return ( -
  • ( +
  • + - - {description} - - - {group.multiplier}x - -
  • - ) - })} + {describeGroup(group, t)} + + + {group.multiplier}x + + + ))}
)} diff --git a/web/default/src/features/pricing/components/empty-state.tsx b/web/default/src/features/pricing/components/empty-state.tsx index 419ffd352f..bfd8476f87 100644 --- a/web/default/src/features/pricing/components/empty-state.tsx +++ b/web/default/src/features/pricing/components/empty-state.tsx @@ -49,7 +49,7 @@ export function EmptyState(props: EmptyStateProps) {

{(props.hasActiveFilters || hasSearch) && ( - )} diff --git a/web/default/src/features/pricing/components/index.ts b/web/default/src/features/pricing/components/index.ts index 3d3338b2bf..2acb49cb8c 100644 --- a/web/default/src/features/pricing/components/index.ts +++ b/web/default/src/features/pricing/components/index.ts @@ -23,5 +23,9 @@ export { ModelCardGrid } from './model-card-grid' export { LoadingSkeleton } from './loading-skeleton' export { EmptyState } from './empty-state' export { SearchBar } from './search-bar' -export { ModelDetails, ModelDetailsContent } from './model-details' +export { + ModelDetails, + ModelDetailsContent, + ModelDetailsDrawer, +} from './model-details' export { PricingTable } from './pricing-table' diff --git a/web/default/src/features/pricing/components/loading-skeleton.tsx b/web/default/src/features/pricing/components/loading-skeleton.tsx index b51b78fa10..1702ee2d60 100644 --- a/web/default/src/features/pricing/components/loading-skeleton.tsx +++ b/web/default/src/features/pricing/components/loading-skeleton.tsx @@ -18,55 +18,28 @@ For commercial licensing, please contact support@quantumnous.com */ import { Skeleton } from '@/components/ui/skeleton' -import { DEFAULT_VIEW_MODE, VIEW_MODES, type ViewMode } from '../constants' - -const CARD_SKELETONS = [ - 'card-1', - 'card-2', - 'card-3', - 'card-4', - 'card-5', - 'card-6', - 'card-7', - 'card-8', - 'card-9', -] -const PRICE_COLUMNS = ['input', 'cached', 'output', 'groups'] -const TABLE_ROWS = [ - 'row-1', - 'row-2', - 'row-3', - 'row-4', - 'row-5', - 'row-6', - 'row-7', - 'row-8', - 'row-9', - 'row-10', -] -const PAGINATION_ITEMS = ['previous', 'page-1', 'page-2', 'next'] +import { VIEW_MODES, type ViewMode } from '../constants' export interface LoadingSkeletonProps { viewMode?: ViewMode } export function LoadingSkeleton(props: LoadingSkeletonProps) { - const viewMode = props.viewMode ?? DEFAULT_VIEW_MODE + const viewMode = props.viewMode ?? VIEW_MODES.CARD return ( -
-
- - -
-
- - {viewMode === VIEW_MODES.TABLE ? ( - - ) : ( - - )} +
+
+ +
+ + + {viewMode === VIEW_MODES.TABLE ? ( + + ) : ( + + )}
) } @@ -74,11 +47,11 @@ export function LoadingSkeleton(props: LoadingSkeletonProps) { function CardContentSkeleton() { return (
- {CARD_SKELETONS.map((key) => ( -
+ {Array.from({ length: 9 }, (_, i) => `card-${i}`).map((key) => ( +
- +
@@ -107,41 +80,64 @@ function CardContentSkeleton() { function FilterBarSkeleton() { return ( -
-
- -
- - - - +
+
+
+ {[80, 90, 75, 85, 70].map((width) => ( + + ))} +
+
+ + + +
- +
) } function TableContentSkeleton() { + const columns = [ + { id: 'model', width: 200 }, + { id: 'type', width: 100 }, + { id: 'price', width: 100 }, + { id: 'cached', width: 100 }, + { id: 'vendor', width: 80 }, + { id: 'groups', width: 100 }, + ] + return (
-
- - {PRICE_COLUMNS.map((column) => ( - +
+ {columns.map((col) => ( + ))}
- {TABLE_ROWS.map((row) => ( + {Array.from({ length: 10 }, (_, i) => `row-${i}`).map((rowKey) => (
- - {PRICE_COLUMNS.map((column) => ( - + {columns.map((col) => ( + ))}
))} @@ -149,8 +145,8 @@ function TableContentSkeleton() {
- {PAGINATION_ITEMS.map((item) => ( - + {Array.from({ length: 4 }, (_, i) => `pager-${i}`).map((key) => ( + ))}
diff --git a/web/default/src/features/pricing/components/model-card-grid.tsx b/web/default/src/features/pricing/components/model-card-grid.tsx index 3cc08377cf..2713ef2d63 100644 --- a/web/default/src/features/pricing/components/model-card-grid.tsx +++ b/web/default/src/features/pricing/components/model-card-grid.tsx @@ -18,16 +18,13 @@ For commercial licensing, please contact support@quantumnous.com */ import { useQuery } from '@tanstack/react-query' import { ChevronLeft, ChevronRight } from 'lucide-react' -import { useEffect, useMemo, useState } from 'react' +import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { Button } from '@/components/design-system/button' import { getPerfMetricsSummary } from '@/features/performance-metrics/api' -import { - DEFAULT_PRICING_CARD_PAGE_SIZE, - DEFAULT_TOKEN_UNIT, -} from '../constants' +import { DEFAULT_PRICING_PAGE_SIZE, DEFAULT_TOKEN_UNIT } from '../constants' import type { PricingModel, TokenUnit } from '../types' import { ModelCard } from './model-card' import type { ModelPerfBadgeData } from './model-perf-badge' @@ -45,15 +42,11 @@ export interface ModelCardGridProps { export function ModelCardGrid(props: ModelCardGridProps) { const { t } = useTranslation() const [page, setPage] = useState(1) - const pageSize = DEFAULT_PRICING_CARD_PAGE_SIZE + const pageSize = DEFAULT_PRICING_PAGE_SIZE const tokenUnit = props.tokenUnit ?? DEFAULT_TOKEN_UNIT const totalPages = Math.max(1, Math.ceil(props.models.length / pageSize)) const currentPage = Math.min(page, totalPages) - useEffect(() => { - setPage(1) - }, [props.models]) - const perfQuery = useQuery({ queryKey: ['perf-metrics-summary', 24], queryFn: () => getPerfMetricsSummary(24), @@ -80,7 +73,7 @@ export function ModelCardGrid(props: ModelCardGridProps) { return (
-
+
{pagedModels.map((model) => ( {totalPages > 1 && ( -
+

{t('Page {{current}} of {{total}}', { current: currentPage, @@ -108,16 +101,18 @@ export function ModelCardGrid(props: ModelCardGridProps) {

diff --git a/web/default/src/features/pricing/components/model-card.tsx b/web/default/src/features/pricing/components/model-card.tsx index f70ad5ef84..1735c40721 100644 --- a/web/default/src/features/pricing/components/model-card.tsx +++ b/web/default/src/features/pricing/components/model-card.tsx @@ -16,14 +16,14 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { ArrowRight, Copy } from 'lucide-react' +import { ChevronRight, Copy } from 'lucide-react' import { memo, type ReactNode } from 'react' import { useTranslation } from 'react-i18next' -import { Button } from '@/components/design-system/button' import { StatusBadge } from '@/components/status-badge' import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' import { getLobeIcon } from '@/lib/lobe-icon' +import { cn } from '@/lib/utils' import { DEFAULT_TOKEN_UNIT } from '../constants' import { @@ -47,31 +47,6 @@ export interface ModelCardProps { perf?: ModelPerfBadgeData } -function PriceMetric(props: { - label: string - value: string - unit: string - muted?: boolean -}) { - return ( -
-

{props.label}

-

- {props.value} - - / {props.unit} - -

-
- ) -} - export const ModelCard = memo(function ModelCard(props: ModelCardProps) { const { t } = useTranslation() const { copyToClipboard } = useCopyToClipboard() @@ -79,196 +54,235 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) { const priceRate = props.priceRate ?? 1 const usdExchangeRate = props.usdExchangeRate ?? 1 const showRechargePrice = props.showRechargePrice ?? false - const tokenUnitLabel = `${tokenUnit === 'K' ? '1K' : '1M'} ${t('tokens')}` const isTokenBased = isTokenBasedModel(props.model) - const modelIconKey = props.model.icon || props.model.vendor_icon - const modelIcon = modelIconKey ? getLobeIcon(modelIconKey, 24) : null + const tokenUnitLabel = tokenUnit === 'K' ? '1K' : '1M' const tags = parseTags(props.model.tags) - const endpoints = props.model.supported_endpoint_types || [] const groups = props.model.enable_groups || [] - const visibleTags = [...endpoints.slice(0, 2), ...tags.slice(0, 2)] - const hiddenTagCount = - Math.max(endpoints.length - 2, 0) + Math.max(tags.length - 2, 0) - const dynamicSummary = getDynamicPricingSummary(props.model, { - tokenUnit, - showRechargePrice, - priceRate, - usdExchangeRate, - groupRatioMultiplier: getDynamicDisplayGroupRatio( - props.model, - props.selectedGroup - ), - }) - - const inputPrice = isTokenBased - ? formatPrice( - props.model, - 'input', + const endpoints = props.model.supported_endpoint_types || [] + const modelIconKey = props.model.icon || props.model.vendor_icon + const modelIcon = modelIconKey ? getLobeIcon(modelIconKey, 28) : null + const initial = props.model.model_name?.charAt(0).toUpperCase() || '?' + const isDynamicPricing = + props.model.billing_mode === 'tiered_expr' && + Boolean(props.model.billing_expr) + const hasCachedPrice = isTokenBased && props.model.cache_ratio != null + const dynamicSummary = isDynamicPricing + ? getDynamicPricingSummary(props.model, { tokenUnit, showRechargePrice, priceRate, usdExchangeRate, - props.selectedGroup - ) - : '' - const outputPrice = isTokenBased - ? formatPrice( - props.model, - 'output', - tokenUnit, - showRechargePrice, - priceRate, - usdExchangeRate, - props.selectedGroup - ) - : '' - const cachedPrice = - isTokenBased && props.model.cache_ratio != null - ? formatPrice( + groupRatioMultiplier: getDynamicDisplayGroupRatio( props.model, - 'cache', - tokenUnit, - showRechargePrice, - priceRate, - usdExchangeRate, props.selectedGroup - ) - : null + ), + }) + : null - let priceContent: ReactNode - if (dynamicSummary?.isSpecialExpression) { - priceContent = ( -
-

- {t('Special billing expression')} -

- - {dynamicSummary.rawExpression} - -
- ) - } else if (dynamicSummary && dynamicSummary.primaryEntries.length > 0) { - priceContent = ( -
- {dynamicSummary.primaryEntries.slice(0, 2).map((entry) => ( - - ))} -
- ) + const primaryGroup = groups[0] + const bottomTags = [...endpoints.slice(0, 2), ...tags.slice(0, 2)] + const hiddenCount = + Math.max(groups.length - 1, 0) + + Math.max(endpoints.length - 2, 0) + + Math.max(tags.length - 2, 0) + + const handleCopy = (e: React.MouseEvent) => { + e.stopPropagation() + copyToClipboard(props.model.model_name || '') + } + + let priceSummary: ReactNode + if (dynamicSummary) { + if (dynamicSummary.isSpecialExpression) { + priceSummary = ( + + + {t('Special billing expression')} + + + {dynamicSummary.rawExpression} + + + ) + } else if (dynamicSummary.primaryEntries.length > 0) { + priceSummary = ( + <> + {dynamicSummary.primaryEntries.map((entry) => ( + + {t(entry.shortLabel)}{' '} + + {entry.formatted} + + /{tokenUnitLabel} + + ))} + + ) + } else { + priceSummary = ( + + {t('Dynamic Pricing')} + + ) + } } else if (isTokenBased) { - priceContent = ( -
- - - {cachedPrice && ( - + priceSummary = ( + <> + + {t('Input')}{' '} + + {formatPrice( + props.model, + 'input', + tokenUnit, + showRechargePrice, + priceRate, + usdExchangeRate, + props.selectedGroup + )} + + /{tokenUnitLabel} + + + {t('Output')}{' '} + + {formatPrice( + props.model, + 'output', + tokenUnit, + showRechargePrice, + priceRate, + usdExchangeRate, + props.selectedGroup + )} + + /{tokenUnitLabel} + + {hasCachedPrice && ( + + {t('Cached')}{' '} + + {formatPrice( + props.model, + 'cache', + tokenUnit, + showRechargePrice, + priceRate, + usdExchangeRate, + props.selectedGroup + )} + + )} -
+ ) } else { - priceContent = ( - + priceSummary = ( + + + {formatRequestPrice( + props.model, + showRechargePrice, + priceRate, + usdExchangeRate, + props.selectedGroup + )} + {' '} + / {t('request')} + ) } return ( -
-
-
- {modelIcon || ( - - {props.model.model_name?.charAt(0).toUpperCase() || '?'} - - )} -
- -
-
-

+
+ {/* Header: icon + name + price + actions */} +
+
+
+ {modelIcon || ( + + {initial} + + )} +
+
+

{props.model.model_name} -

- +

+
+ {priceSummary} +
-

- {props.model.vendor_name || - (isTokenBased ? t('Token-based') : t('Per Request'))} -

- +
+ + +
-

+ {/* Description */} +

{props.model.description || t('No description available.')}

-
{priceContent}
+ {/* Footer: left metadata and right performance summary share row alignment */} +
+
+ {primaryGroup && ( + + {primaryGroup} {t('Groups')} + + )} + + {isTokenBased ? t('Token-based') : t('Per Request')} + + {isDynamicPricing && ( + + {t('Dynamic Pricing')} + + )} +
+ -
-
- {groups.slice(0, 1).map((group) => ( - - {group} - +
+ {bottomTags.map((item) => ( + + {item} + ))} - {visibleTags.map((tag) => ( - - {tag} - - ))} - {hiddenTagCount > 0 && ( - - +{hiddenTagCount} + + {tokenUnitLabel} + + {hiddenCount > 0 && ( + + +{hiddenCount} )}
- -
-
+
) }) diff --git a/web/default/src/features/pricing/components/model-details-api.tsx b/web/default/src/features/pricing/components/model-details-api.tsx index 62c6674aa1..77ab2b2bdc 100644 --- a/web/default/src/features/pricing/components/model-details-api.tsx +++ b/web/default/src/features/pricing/components/model-details-api.tsx @@ -109,7 +109,7 @@ function buildChatSample(lang: Lang, ctx: SampleContext): string { `curl ${url} \\`, ` -H "Authorization: Bearer $${ctx.apiKeyEnv}" \\`, ` -H "Content-Type: application/json" \\`, - ` -d '${bodyJson.replace(/\n/g, '\n ')}'`, + ` -d '${bodyJson.replaceAll('\n', '\n ')}'`, ].join('\n') } @@ -177,7 +177,7 @@ function buildAnthropicSample(lang: Lang, ctx: SampleContext): string { ` -H "x-api-key: $${ctx.apiKeyEnv}" \\`, ` -H "anthropic-version: 2023-06-01" \\`, ` -H "Content-Type: application/json" \\`, - ` -d '${body.replace(/\n/g, '\n ')}'`, + ` -d '${body.replaceAll('\n', '\n ')}'`, ].join('\n') } if (lang === 'python') { @@ -249,7 +249,7 @@ function buildGeminiSample(lang: Lang, ctx: SampleContext): string { return [ `curl '${url}' \\`, ` -H 'Content-Type: application/json' \\`, - ` -d '${body.replace(/\n/g, '\n ')}'`, + ` -d '${body.replaceAll('\n', '\n ')}'`, ].join('\n') } if (lang === 'python') { @@ -291,7 +291,7 @@ function buildGeminiSample(lang: Lang, ctx: SampleContext): string { function buildEmbeddingSample(lang: Lang, ctx: SampleContext): string { const url = `${ctx.baseUrl}${ctx.endpointPath}` - const text = 'The food was delicious and the waiter…' + const text = 'The food was delicious and the waiter?' if (lang === 'curl') { const body = JSON.stringify({ model: ctx.modelName, input: text }, null, 2) @@ -299,7 +299,7 @@ function buildEmbeddingSample(lang: Lang, ctx: SampleContext): string { `curl ${url} \\`, ` -H "Authorization: Bearer $${ctx.apiKeyEnv}" \\`, ` -H "Content-Type: application/json" \\`, - ` -d '${body.replace(/\n/g, '\n ')}'`, + ` -d '${body.replaceAll('\n', '\n ')}'`, ].join('\n') } if (lang === 'python') { @@ -365,7 +365,7 @@ function buildImageSample(lang: Lang, ctx: SampleContext): string { `curl ${url} \\`, ` -H "Authorization: Bearer $${ctx.apiKeyEnv}" \\`, ` -H "Content-Type: application/json" \\`, - ` -d '${body.replace(/\n/g, '\n ')}'`, + ` -d '${body.replaceAll('\n', '\n ')}'`, ].join('\n') } if (lang === 'python') { @@ -430,8 +430,9 @@ function buildSample( ): string { if (endpointType === 'anthropic') return buildAnthropicSample(lang, ctx) if (endpointType === 'gemini') return buildGeminiSample(lang, ctx) - if (endpointType === 'embeddings' || endpointType === 'jina-rerank') + if (endpointType === 'embeddings' || endpointType === 'jina-rerank') { return buildEmbeddingSample(lang, ctx) + } if (endpointType === 'image-generation') return buildImageSample(lang, ctx) return buildChatSample(lang, ctx) } @@ -502,12 +503,12 @@ function CodeSamplesSection(props: {
{endpoints.length > 1 && ( - + {endpoints.map((ep) => ( {ep.type} @@ -521,9 +522,9 @@ function CodeSamplesSection(props: { onValueChange={(v) => setLang(v as Lang)} className='ml-auto' > - + {(Object.keys(LANG_LABELS) as Lang[]).map((l) => ( - + {LANG_LABELS[l]} ))} @@ -539,7 +540,7 @@ function CodeSamplesSection(props: {

{t('Replace')}{' '} - + {''} {' '} {t('with the API key from your token settings.')} @@ -582,7 +583,7 @@ function SupportedParametersSection(props: { model: PricingModel }) { {p.required && ( {t('required')} @@ -598,7 +599,7 @@ function SupportedParametersSection(props: { model: PricingModel }) { cell: (p) => ( {p.type} @@ -658,7 +659,7 @@ function ParamRangeCell(props: { param: SupportedParameter }) {

) } - return + return ? } // --------------------------------------------------------------------------- @@ -711,7 +712,7 @@ function RateLimitsSection(props: { model: PricingModel }) { }, ]} /> -

+

{t( 'RPM = requests per minute, TPM = tokens per minute, RPD = requests per day. Limits apply per token group.' )} @@ -734,11 +735,11 @@ function AuthSection() {

{t('All requests must include')}{' '} - + Authorization: Bearer <TOKEN> {' '} {t('header. Anthropic-formatted endpoints accept the')}{' '} - + x-api-key {' '} {t('header instead.')} diff --git a/web/default/src/features/pricing/components/model-details-apps.tsx b/web/default/src/features/pricing/components/model-details-apps.tsx index 41dd0d8bdf..3704564c24 100644 --- a/web/default/src/features/pricing/components/model-details-apps.tsx +++ b/web/default/src/features/pricing/components/model-details-apps.tsx @@ -46,16 +46,21 @@ const COMPACT_NUMBER = new Intl.NumberFormat(undefined, { function RankBadge(props: { rank: number }) { const rank = props.rank const isPodium = rank <= 3 - const palette = - rank === 1 - ? 'bg-warning/15 text-warning' - : rank <= 3 - ? 'bg-muted text-foreground' - : 'bg-muted text-muted-foreground' + let palette = 'bg-muted text-muted-foreground' + if (rank === 1) { + palette = + 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300' + } else if (rank === 2) { + palette = + 'bg-slate-100 text-slate-700 dark:bg-slate-500/20 dark:text-slate-300' + } else if (rank === 3) { + palette = + 'bg-orange-100 text-orange-700 dark:bg-orange-500/20 dark:text-orange-300' + } return ( @@ -68,17 +73,21 @@ function GrowthChip(props: { value: number }) { const value = props.value const isUp = value > 0 const isDown = value < 0 - const palette = isUp - ? 'bg-success/10 text-success' - : isDown - ? 'bg-destructive/10 text-destructive' - : 'bg-muted text-muted-foreground' - const Icon = isUp ? ArrowUpRight : isDown ? ArrowDownRight : null + let palette = 'bg-muted text-muted-foreground' + let Icon = null + if (isUp) { + palette = + 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300' + Icon = ArrowUpRight + } else if (isDown) { + palette = 'bg-rose-100 text-rose-700 dark:bg-rose-500/20 dark:text-rose-300' + Icon = ArrowDownRight + } const formatted = `${value > 0 ? '+' : ''}${value.toFixed(1)}%` return ( @@ -123,35 +132,35 @@ export function ModelDetailsApps(props: { model: PricingModel }) {

-
+
{t('Tracked apps')}
-
+
{apps.length}
-

+

{t('Top integrations using this model')}

-
+
{t('Monthly tokens')}
-
+
{COMPACT_NUMBER.format(totalMonthlyTokens)}
-

+

{t('Aggregated across the apps below')}

-
+
{t('#1 by usage')}
{top.name}
-

+

{top.category} · {formatTokenVolume(top.monthly_tokens)}{' '} {t('tokens / mo')}

@@ -223,7 +232,7 @@ export function ModelDetailsApps(props: { model: PricingModel }) { ]} /> -

+

{t( 'App rankings shown here are simulated for preview purposes and will be replaced with live usage data once the backend integration is complete.' )} diff --git a/web/default/src/features/pricing/components/model-details-performance.tsx b/web/default/src/features/pricing/components/model-details-performance.tsx index 5baa40db61..68ffd1fccf 100644 --- a/web/default/src/features/pricing/components/model-details-performance.tsx +++ b/web/default/src/features/pricing/components/model-details-performance.tsx @@ -36,7 +36,7 @@ import { import type { PerformanceGroup } from '@/features/performance-metrics/types' import { cn } from '@/lib/utils' -import { type UptimeDayPoint } from '../lib/mock-stats' +import type { UptimeDayPoint } from '../lib/mock-stats' import type { PricingModel } from '../types' import { LatencyTrendChart, UptimeTrendChart } from './model-details-charts' import { UptimeSparkline } from './model-details-uptime-sparkline' @@ -51,20 +51,22 @@ function StatCard(props: { const Icon = props.icon return (

- + {props.label} {props.value} {props.hint && ( - {props.hint} + + {props.hint} + )}
) @@ -95,7 +97,7 @@ function toLatencySeries(groups: PerformanceGroup[]) { } } - return Array.from(byTs.entries()) + return [...byTs.entries()] .sort(([a], [b]) => a - b) .map(([ts, values]) => ({ timestamp: new Date(ts * 1000).toISOString(), @@ -119,7 +121,7 @@ function toUptimeSeries(groups: PerformanceGroup[]): UptimeDayPoint[] { byTs.set(point.ts, current) } } - return Array.from(byTs.entries()) + return [...byTs.entries()] .sort(([a], [b]) => a - b) .map(([ts, value]) => { const uptime = @@ -328,7 +330,7 @@ export function ModelDetailsPerformance(props: { model: PricingModel }) { } accent={ incidentCount > 0 ? ( - + {t('{{count}} incidents', { count: incidentCount, diff --git a/web/default/src/features/pricing/components/model-details-uptime-sparkline.tsx b/web/default/src/features/pricing/components/model-details-uptime-sparkline.tsx index 46fd8c3aca..520570362a 100644 --- a/web/default/src/features/pricing/components/model-details-uptime-sparkline.tsx +++ b/web/default/src/features/pricing/components/model-details-uptime-sparkline.tsx @@ -128,7 +128,7 @@ export function UptimeSparkline(props: UptimeSparklineProps) { {showOverall && ( @@ -156,28 +156,21 @@ export function UptimeStatusRow(props: { return 'major' }, [summary.uptime_pct]) - const StatusIcon = - status === 'operational' - ? CheckCircle2 - : status === 'minor' - ? Activity - : AlertCircle - - const statusColour = - status === 'operational' || status === 'minor' - ? 'text-success' - : status === 'degraded' - ? 'text-warning' - : 'text-destructive' - - const statusLabel = - status === 'operational' - ? t('All systems operational') - : status === 'minor' - ? t('Minor blips in the last 30 days') - : status === 'degraded' - ? t('Degraded performance recently') - : t('Significant outages detected') + let StatusIcon = AlertCircle + let statusColour = 'text-rose-600 dark:text-rose-400' + let statusLabel = t('Significant outages detected') + if (status === 'operational') { + StatusIcon = CheckCircle2 + statusColour = 'text-emerald-600 dark:text-emerald-400' + statusLabel = t('All systems operational') + } else if (status === 'minor') { + StatusIcon = Activity + statusColour = 'text-emerald-600 dark:text-emerald-400' + statusLabel = t('Minor blips in the last 30 days') + } else if (status === 'degraded') { + statusColour = 'text-amber-600 dark:text-amber-400' + statusLabel = t('Degraded performance recently') + } return (
-

{props.children}

- {props.description && ( -

- {props.description} -

- )} -
+

+ {props.children} +

) } @@ -151,22 +150,28 @@ function normalizeCatalogItems(items?: readonly string[]): string[] { } function OverviewMetric(props: { + icon: React.ComponentType<{ className?: string }> label: string value: React.ReactNode valueClassName?: string }) { + const Icon = props.icon + return ( -
-
- {props.label} -
-
- {props.value} +
+ +
+
+ {props.label} +
+
+ {props.value} +
) @@ -207,13 +212,19 @@ function OverviewSummaryGrid(props: { model: PricingModel }) { : 0 return ( -
- +
+ {props.items.map((item) => ( - + {item} - + ))}
) @@ -236,7 +250,7 @@ function CatalogPillList(props: { items: string[] }) { function CatalogTextValue(props: { children: React.ReactNode }) { return ( - + {props.children} ) @@ -244,8 +258,8 @@ function CatalogTextValue(props: { children: React.ReactNode }) { function CatalogInfoCell(props: { label: string; children: React.ReactNode }) { return ( -
- +
+ {props.label} {props.children} @@ -344,20 +358,23 @@ function ModelBackendQuickStats(props: { model: PricingModel }) { if (stats.length === 0) return null return ( -
+
{stats.map((stat) => { const Icon = stat.icon return ( -
- - +
+ + {stat.label} {stat.value} {stat.hint && ( - + {stat.hint} )} @@ -384,9 +401,11 @@ function ModelBackendSignalsSection(props: { model: PricingModel }) { return (
- {t('Capabilities')} -
- {capabilities.length > 0 && ( + + {t('Capabilities')} / {t('Supported modalities')} + +
+ {capabilities.length > 0 ? ( t( @@ -395,11 +414,13 @@ function ModelBackendSignalsSection(props: { model: PricingModel }) { ) )} /> + ) : ( +
)} {(inputModalities.length > 0 || outputModalities.length > 0) && (
{inputModalities.length > 0 && ( -
+
{t('Input')} @@ -409,7 +430,7 @@ function ModelBackendSignalsSection(props: { model: PricingModel }) {
)} {outputModalities.length > 0 && ( -
+
{t('Output')} @@ -454,11 +475,7 @@ function ModelBackendProviderSection(props: { model: PricingModel }) { if (groups.length > 0) { cells.push( -
- {groups.map((group) => ( - - ))} -
+
) } @@ -492,7 +509,7 @@ function ModelBackendProviderSection(props: { model: PricingModel }) { return (
{t('Model')} -
+
{cells}
@@ -502,6 +519,7 @@ function ModelBackendProviderSection(props: { model: PricingModel }) { function ModelBackendDetailsSection(props: { model: PricingModel }) { return ( <> + @@ -516,84 +534,55 @@ function ModelHeader(props: { model: PricingModel }) { const { t } = useTranslation() const model = props.model const modelIconKey = model.icon || model.vendor_icon - const modelIcon = modelIconKey ? getLobeIcon(modelIconKey, 28) : null + const modelIcon = modelIconKey ? getLobeIcon(modelIconKey, 20) : null const description = model.description || model.vendor_description || null - const tags = parseTags(model.tags) - const endpoints = normalizeCatalogItems(model.supported_endpoint_types) const isSpecialExpression = model.billing_mode === 'tiered_expr' && Boolean(model.billing_expr) && getDynamicPricingTiers(model).length === 0 return ( -
-
-
- {modelIcon || ( - - {model.model_name?.charAt(0).toUpperCase() || '?'} +
+
+ {modelIcon} +

+ {model.model_name} +

+ +
+
+ {model.vendor_name && ( + {model.vendor_name} + )} + · + + {model.quota_type === QUOTA_TYPE_VALUES.TOKEN + ? t('Token-based') + : t('Per Request')} + + {model.billing_mode === 'tiered_expr' && model.billing_expr && ( + <> + · + + {isSpecialExpression + ? t('Special billing expression') + : t('Dynamic Pricing')} - )} -
-
-
-

- {model.model_name} -

- - {model.billing_mode === 'tiered_expr' && model.billing_expr && ( - - {isSpecialExpression - ? t('Special billing expression') - : t('Dynamic Pricing')} - - )} -
-
- {model.vendor_name && {model.vendor_name}} - {model.vendor_name && ( - · - )} - - {model.quota_type === QUOTA_TYPE_VALUES.TOKEN - ? t('Token-based') - : t('Per Request')} - -
-
+ + )}
- {description && ( -

+

{description}

)} - - {(tags.length > 0 || endpoints.length > 0) && ( -
- {tags.map((tag) => ( - - {tag} - - ))} - {endpoints.map((endpoint) => ( - - {endpoint} - - ))} -
- )}
) } @@ -664,16 +653,16 @@ function PriceSection(props: { if (dynamicSummary.isSpecialExpression) { return (
- {t('Pricing')} -
-
+ {t('Base Price')} +
+
{t('Special billing expression')}
-

+

{t('Unable to parse structured pricing')}

-
+
{t('Raw expression')}
@@ -685,37 +674,55 @@ function PriceSection(props: { ) } - const priceRows = [ - ...dynamicSummary.primaryEntries, - ...dynamicSummary.secondaryEntries, - ] - return (
- {t('Pricing')} -
-
- {t('Text tokens')} - - {t('Prices shown per')} {tokenUnitLabel} {t('tokens')} - -
-
- {priceRows.map((entry) => ( + {t('Base Price')} + {dynamicSummary.primaryEntries.length > 0 ? ( +
+ {dynamicSummary.primaryEntries.map((entry) => (
- +
{t(entry.shortLabel)} - - +
+
{entry.formatted} - + + / {tokenUnitLabel} + +
))}
-
+ ) : ( +

+ {t('Dynamic Pricing')} +

+ )} + {dynamicSummary.secondaryEntries.length > 0 && ( +
+
+ {dynamicSummary.secondaryEntries.map((entry) => ( +
+ + {t(entry.shortLabel)} + + + {entry.formatted} + + / {tokenUnitLabel} + + +
+ ))} +
+
+ )}
) } @@ -723,72 +730,77 @@ function PriceSection(props: { if (!isTokenBased) { return (
- {t('Pricing')} -
-
- - {t('Per request')} - - - {formatFixedPrice( - props.model, - baseGroupKey, - props.showRechargePrice, - props.priceRate, - props.usdExchangeRate, - baseGroupRatioMap - )} - -
+ {t('Base Price')} +
+ + {t('Per request')} + + + {formatFixedPrice( + props.model, + baseGroupKey, + props.showRechargePrice, + props.priceRate, + props.usdExchangeRate, + baseGroupRatioMap + )} +
) } const secondaryItems = secondaryPriceTypes.filter((p) => p.available) - const priceRows = [ - ...primaryPriceTypes, - ...secondaryItems.map((item) => ({ - label: item.label, - type: item.type, - })), - ] + const renderPrice = (type: PriceType) => ( + <> + {formatGroupPrice( + props.model, + baseGroupKey, + type, + props.tokenUnit, + props.showRechargePrice, + props.priceRate, + props.usdExchangeRate, + baseGroupRatioMap + )} + + / {tokenUnitLabel} + + + ) return (
- {t('Pricing')} -
-
- {t('Text tokens')} - - {t('Prices shown per')} {tokenUnitLabel} {t('tokens')} - -
-
- {priceRows.map((item) => ( -
- - {item.label} - - - {formatGroupPrice( - props.model, - baseGroupKey, - item.type, - props.tokenUnit, - props.showRechargePrice, - props.priceRate, - props.usdExchangeRate, - baseGroupRatioMap - )} - + {t('Base Price')} +
+ {primaryPriceTypes.map((item) => ( +
+
{item.label}
+
+ {renderPrice(item.type)}
- ))} -
+
+ ))}
+ {secondaryItems.length > 0 && ( +
+
+ {secondaryItems.map((item) => ( +
+ + {item.label} + + + {renderPrice(item.type)} + +
+ ))} +
+
+ )}
) } @@ -920,7 +932,8 @@ function GroupPricingSection(props: { ) } - const thClass = 'text-muted-foreground py-2 text-xs font-medium' + const thClass = + 'text-muted-foreground py-2 text-[10px] font-medium tracking-wider uppercase' if (isDynamicPricingModel(props.model)) { const dynamicTiers = getDynamicPricingTiers(props.model) @@ -930,8 +943,8 @@ function GroupPricingSection(props: {
{t('Pricing by Group')} -
-
+
+
{t('Special billing expression')}

@@ -940,7 +953,7 @@ function GroupPricingSection(props: { )}

-
+
{t('Raw expression')}
@@ -1025,7 +1038,7 @@ function GroupPricingSection(props: {
) })} -

+

{t('Prices shown per')} {tokenUnitLabel} tokens

@@ -1116,7 +1129,7 @@ function GroupPricingSection(props: { />
{isTokenBased && ( -

+

{t('Prices shown per')} {tokenUnitLabel} tokens

)} @@ -1158,52 +1171,53 @@ export function ModelDetailsContent(props: ModelDetailsContentProps) { Boolean(props.model.billing_expr) return ( -
+
- - - - + + {TAB_VALUES.map((value) => { const Icon = TAB_META[value].icon return ( - ) })} - - - {isDynamic && ( - - )} - + + + +
+ {t('Pricing')} + + {isDynamic && ( + + )} + +
+
@@ -1222,6 +1236,39 @@ export function ModelDetailsContent(props: ModelDetailsContentProps) { ) } +// ---------------------------------------------------------------------------- +// Drawer & page wrappers +// ---------------------------------------------------------------------------- + +export interface ModelDetailsDrawerProps extends ModelDetailsContentProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +export function ModelDetailsDrawer(props: ModelDetailsDrawerProps) { + const { t } = useTranslation() + const { open, onOpenChange, ...contentProps } = props + + return ( + + + + {props.model.model_name} + {t('Model details')} + +
+ +
+
+
+ ) +} + export function ModelDetails() { const { t } = useTranslation() const { modelId } = useParams({ from: '/pricing/$modelId/' }) @@ -1253,8 +1300,8 @@ export function ModelDetails() { if (isLoading) { return ( - -
+ +
@@ -1278,15 +1325,15 @@ export function ModelDetails() { if (!model) { return ( - -
+ +

{t('Model not found')}

{t("The model you're looking for doesn't exist.")}

-
@@ -1295,14 +1342,15 @@ export function ModelDetails() { } return ( - - + +
@@ -1322,7 +1370,7 @@ export function ModelDetails() { >) || {} } /> - +
) } diff --git a/web/default/src/features/pricing/components/model-perf-badge.tsx b/web/default/src/features/pricing/components/model-perf-badge.tsx index 6be6dd327a..4393eb44fc 100644 --- a/web/default/src/features/pricing/components/model-perf-badge.tsx +++ b/web/default/src/features/pricing/components/model-perf-badge.tsx @@ -67,9 +67,25 @@ export const ModelPerfBadge = memo(function ModelPerfBadge( const statusRates = recentRates.length > 0 ? recentRates.slice(-3) : [success_rate] const statusBars = [ - ...Array(Math.max(0, 3 - statusRates.length)).fill(null), - ...statusRates, - ].slice(-3) + { + id: 'oldest', + rate: statusRates.at(-3) ?? null, + heightClassName: 'h-2', + emptyClassName: 'bg-muted-foreground/10', + }, + { + id: 'middle', + rate: statusRates.at(-2) ?? null, + heightClassName: 'h-2.5', + emptyClassName: 'bg-muted-foreground/15', + }, + { + id: 'latest', + rate: statusRates.at(-1) ?? null, + heightClassName: 'h-3', + emptyClassName: 'bg-muted-foreground/15', + }, + ] return (
-
+
{t('Latency short')}
@@ -87,7 +103,7 @@ export const ModelPerfBadge = memo(function ModelPerfBadge(
-
+
{t('Throughput short')}
@@ -98,23 +114,19 @@ export const ModelPerfBadge = memo(function ModelPerfBadge( title={`${t('Success rate')}: ${success_rate.toFixed(1)}%`} className='min-w-0' > -
+
{t('Status short')}
- {statusBars.map((rate, index) => ( + {statusBars.map((bar) => ( ))} diff --git a/web/default/src/features/pricing/components/pricing-columns.tsx b/web/default/src/features/pricing/components/pricing-columns.tsx index 07bbe38a1c..0ef7c82216 100644 --- a/web/default/src/features/pricing/components/pricing-columns.tsx +++ b/web/default/src/features/pricing/components/pricing-columns.tsx @@ -17,16 +17,19 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import type { ColumnDef } from '@tanstack/react-table' -import type { TFunction } from 'i18next' -import type { ReactNode } from 'react' import { useTranslation } from 'react-i18next' -import { BadgeListCell } from '@/components/data-table' +import { + BadgeCell, + BadgeListCell, + DataTableColumnHeader, +} from '@/components/data-table' import { GroupBadge } from '@/components/group-badge' import { StatusBadge } from '@/components/status-badge' +import { getIdentityTextColorClass } from '@/lib/colors' import { getLobeIcon } from '@/lib/lobe-icon' -import { DEFAULT_TOKEN_UNIT } from '../constants' +import { DEFAULT_TOKEN_UNIT, QUOTA_TYPE_VALUES } from '../constants' import { getDynamicDisplayGroupRatio, getDynamicPricingSummary, @@ -38,8 +41,11 @@ import { formatRequestPrice, stripTrailingZeros, } from '../lib/price' -import type { PriceType, PricingModel, TokenUnit } from '../types' -import { ModelPerfBadge, type ModelPerfBadgeData } from './model-perf-badge' +import type { PricingModel, TokenUnit } from '../types' + +// ---------------------------------------------------------------------------- +// Pricing Table Columns +// ---------------------------------------------------------------------------- export interface PricingColumnsOptions { tokenUnit?: TokenUnit @@ -47,303 +53,368 @@ export interface PricingColumnsOptions { usdExchangeRate?: number showRechargePrice?: boolean selectedGroup?: string - perfMap?: Map -} - -type PriceColumnType = Extract - -const DYNAMIC_FIELD_BY_PRICE_TYPE: Record = { - input: 'inputPrice', - cache: 'cacheReadPrice', - output: 'outputPrice', -} - -function renderEmptyCell(align: 'left' | 'right' = 'left'): ReactNode { - const dash = ( - - ) - if (align === 'right') { - return
{dash}
- } - return dash -} - -function renderEmptyPrice(): ReactNode { - return renderEmptyCell('right') -} - -function renderPriceCell( - props: { - model: PricingModel - priceType: PriceColumnType - options: Required< - Omit - > & { - selectedGroup?: string - } - }, - t: TFunction -): ReactNode { - const tokenUnitLabel = props.options.tokenUnit === 'K' ? '1K' : '1M' - const dynamicSummary = getDynamicPricingSummary(props.model, { - tokenUnit: props.options.tokenUnit, - showRechargePrice: props.options.showRechargePrice, - priceRate: props.options.priceRate, - usdExchangeRate: props.options.usdExchangeRate, - groupRatioMultiplier: getDynamicDisplayGroupRatio( - props.model, - props.options.selectedGroup - ), - }) - - if (dynamicSummary?.isSpecialExpression) { - if (props.priceType !== 'input') return renderEmptyPrice() - return ( -
-

- {t('Special billing expression')} -

-

- {t('View details')} -

-
- ) - } - - if (dynamicSummary) { - const entry = dynamicSummary.entries.find( - (item) => item.field === DYNAMIC_FIELD_BY_PRICE_TYPE[props.priceType] - ) - if (!entry) return renderEmptyPrice() - - return ( -
-

- {stripTrailingZeros(entry.formatted)} -

-

- / {tokenUnitLabel} {t('tokens')} - {dynamicSummary.tierCount > 1 && - ` · ${t('{{count}} tiers', { - count: dynamicSummary.tierCount, - })}`} -

-
- ) - } - - if (!isTokenBasedModel(props.model)) { - if (props.priceType !== 'input') return renderEmptyPrice() - return ( -
-

- {stripTrailingZeros( - formatRequestPrice( - props.model, - props.options.showRechargePrice, - props.options.priceRate, - props.options.usdExchangeRate, - props.options.selectedGroup - ) - )} -

-

/ {t('request')}

-
- ) - } - - if (props.priceType === 'cache' && props.model.cache_ratio == null) { - return renderEmptyPrice() - } - - return ( -
-

- {stripTrailingZeros( - formatPrice( - props.model, - props.priceType, - props.options.tokenUnit, - props.options.showRechargePrice, - props.options.priceRate, - props.options.usdExchangeRate, - props.options.selectedGroup - ) - )} -

-

- / {tokenUnitLabel} {t('tokens')} -

-
- ) } export function usePricingColumns( options: PricingColumnsOptions = {} ): ColumnDef[] { const { t } = useTranslation() - const priceOptions = { - tokenUnit: options.tokenUnit ?? DEFAULT_TOKEN_UNIT, - priceRate: options.priceRate ?? 1, - usdExchangeRate: options.usdExchangeRate ?? 1, - showRechargePrice: options.showRechargePrice ?? false, - selectedGroup: options.selectedGroup, - } + const { + tokenUnit = DEFAULT_TOKEN_UNIT, + priceRate = 1, + usdExchangeRate = 1, + showRechargePrice = false, + selectedGroup, + } = options + + const tokenUnitLabel = tokenUnit === 'K' ? '1K' : '1M' return [ + // Model column { accessorKey: 'model_name', - header: t('Model'), + meta: { label: t('Model') }, + header: ({ column }) => ( + + ), cell: ({ row }) => { const model = row.original const modelIconKey = model.icon || model.vendor_icon - const modelIcon = modelIconKey ? getLobeIcon(modelIconKey, 20) : null + const modelIcon = modelIconKey ? getLobeIcon(modelIconKey, 14) : null return ( -
-
- {modelIcon || ( - - {model.model_name?.charAt(0).toUpperCase() || '?'} - - )} +
+ {modelIcon} + + {model.model_name} + +
+ ) + }, + minSize: 200, + }, + + // Type column + { + accessorKey: 'quota_type', + header: t('Type'), + cell: ({ row }) => { + const isTokenBased = row.original.quota_type === QUOTA_TYPE_VALUES.TOKEN + return ( + + {isTokenBased ? t('Token') : t('Request')} + + ) + }, + size: 80, + enableSorting: false, + }, + + // Price column + { + accessorKey: 'price', + meta: { label: t('Price') }, + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const model = row.original + const dynamicSummary = getDynamicPricingSummary(model, { + tokenUnit, + showRechargePrice, + priceRate, + usdExchangeRate, + groupRatioMultiplier: getDynamicDisplayGroupRatio( + model, + selectedGroup + ), + }) + + if (dynamicSummary) { + if (dynamicSummary.isSpecialExpression) { + return ( +
+
+ {t('Special billing expression')} +
+
+ {t('Unable to parse structured pricing')} +
+ + {dynamicSummary.rawExpression} + +
+ ) + } + + const primaryEntries = dynamicSummary.primaryEntries.slice(0, 2) + if (primaryEntries.length === 0) { + return ( + + {t('Dynamic Pricing')} + + ) + } + + return ( +
+ + {primaryEntries.map((entry, index) => ( + + {index > 0 && ( + / + )} + {stripTrailingZeros(entry.formatted)} + + ))} + +
+ / {tokenUnitLabel} tokens + {dynamicSummary.tierCount > 1 && + ` · ${t('{{count}} tiers', { + count: dynamicSummary.tierCount, + })}`} +
-
-

- {model.model_name} -

-

- {model.vendor_name || - model.description || - (isTokenBasedModel(model) - ? t('Token-based') - : t('Per Request'))} -

+ ) + } + + const isTokenBased = isTokenBasedModel(model) + + if (isTokenBased) { + const inputPrice = stripTrailingZeros( + formatPrice( + model, + 'input', + tokenUnit, + showRechargePrice, + priceRate, + usdExchangeRate, + selectedGroup + ) + ) + const outputPrice = stripTrailingZeros( + formatPrice( + model, + 'output', + tokenUnit, + showRechargePrice, + priceRate, + usdExchangeRate, + selectedGroup + ) + ) + + return ( +
+ + {inputPrice} + / + {outputPrice} + +
+ / {tokenUnitLabel} tokens +
+
+ ) + } + + const price = stripTrailingZeros( + formatRequestPrice( + model, + showRechargePrice, + priceRate, + usdExchangeRate, + selectedGroup + ) + ) + + return ( +
+ {price} +
+ / {t('request')}
) }, - minSize: 260, - enableSorting: false, - }, - { - id: 'input_price', - header: () =>
{t('Input')}
, - cell: ({ row }) => - renderPriceCell( - { - model: row.original, - priceType: 'input', - options: priceOptions, - }, - t - ), - size: 130, + size: 180, enableSorting: false, }, + + // Cached price column (Vercel AI Gateway style) { id: 'cached_price', - header: () =>
{t('Cached input')}
, - cell: ({ row }) => - renderPriceCell( - { - model: row.original, - priceType: 'cache', - options: priceOptions, - }, - t - ), - size: 130, - enableSorting: false, - }, - { - id: 'output_price', - header: () =>
{t('Output')}
, - cell: ({ row }) => - renderPriceCell( - { - model: row.original, - priceType: 'output', - options: priceOptions, - }, - t - ), - size: 130, - enableSorting: false, - }, - { - id: 'health', - header: t('Health'), + header: t('Cached'), cell: ({ row }) => { - const perf = options.perfMap?.get(row.original.model_name || '') - if (!perf) { - return renderEmptyCell() + const model = row.original + const dynamicSummary = getDynamicPricingSummary(model, { + tokenUnit, + showRechargePrice, + priceRate, + usdExchangeRate, + groupRatioMultiplier: getDynamicDisplayGroupRatio( + model, + selectedGroup + ), + }) + + if (dynamicSummary) { + if (dynamicSummary.isSpecialExpression) { + return ( + + {t('Special billing expression')} + + ) + } + + const cacheEntry = dynamicSummary.entries.find( + (entry) => entry.field === 'cacheReadPrice' + ) + if (!cacheEntry) { + return + } + + return ( +
+ + {stripTrailingZeros(cacheEntry.formatted)} + +
+ / {tokenUnitLabel} +
+
+ ) } - return + + const isTokenBased = isTokenBasedModel(model) + + if (!isTokenBased || model.cache_ratio == null) { + return + } + + const cachedPrice = stripTrailingZeros( + formatPrice( + model, + 'cache', + tokenUnit, + showRechargePrice, + priceRate, + usdExchangeRate, + selectedGroup + ) + ) + + return ( +
+ + {cachedPrice} + +
+ / {tokenUnitLabel} +
+
+ ) }, - size: 160, + size: 110, enableSorting: false, }, + + // Vendor column + { + accessorKey: 'vendor_name', + header: t('Vendor'), + cell: ({ row }) => { + const model = row.original + if (!model.vendor_name) { + return + } + const vendorIcon = model.vendor_icon + ? getLobeIcon(model.vendor_icon, 12) + : null + return ( + + {vendorIcon} + + {model.vendor_name} + + + ) + }, + size: 130, + enableSorting: false, + }, + + // Tags column { accessorKey: 'tags', header: t('Tags'), cell: ({ row }) => { const tags = parseTags(row.original.tags) - if (tags.length === 0) { - return renderEmptyCell() - } return ( ( - + {tag} ))} /> ) }, - size: 160, + size: 140, enableSorting: false, }, + + // Endpoints column { accessorKey: 'supported_endpoint_types', header: t('Endpoints'), cell: ({ row }) => { const endpoints = row.original.supported_endpoint_types || [] - if (endpoints.length === 0) { - return renderEmptyCell() - } return ( ( - - {endpoint} + items={endpoints.map((ep) => ( + + {ep} ))} /> ) }, - size: 150, + size: 130, enableSorting: false, }, + + // Enable Groups column { accessorKey: 'enable_groups', header: t('Groups'), cell: ({ row }) => { const groups = row.original.enable_groups || [] - if (groups.length === 0) { - return renderEmptyCell() - } return ( ( - + ))} - tooltipClassName='max-w-72 p-2' + tooltipClassName='max-w-[280px] p-2' /> ) }, - size: 140, + size: 130, enableSorting: false, }, ] diff --git a/web/default/src/features/pricing/components/pricing-sidebar.tsx b/web/default/src/features/pricing/components/pricing-sidebar.tsx index 17b7db104d..2eafe81b42 100644 --- a/web/default/src/features/pricing/components/pricing-sidebar.tsx +++ b/web/default/src/features/pricing/components/pricing-sidebar.tsx @@ -21,6 +21,7 @@ import type { ReactNode } from 'react' import { useTranslation } from 'react-i18next' import { Button } from '@/components/design-system/button' +import { Badge } from '@/components/ui/badge' import { Collapsible, CollapsibleContent, @@ -100,10 +101,10 @@ function FilterChip(props: { type='button' onClick={props.onClick} className={cn( - 'inline-flex min-h-6 max-w-full items-center gap-1.5 rounded-md border px-2 py-1 text-xs font-medium transition-colors', + 'group inline-flex max-w-full items-center gap-1.5 rounded-md border px-2 py-1 text-xs font-medium transition-all', props.active - ? 'border-foreground/30 bg-muted text-foreground' - : 'border-border bg-background text-muted-foreground hover:bg-muted/50 hover:text-foreground' + ? 'border-foreground/30 bg-foreground/5 text-foreground shadow-sm' + : 'border-border/70 bg-background text-muted-foreground hover:border-border hover:bg-muted/50 hover:text-foreground' )} title={props.option.label} > @@ -114,7 +115,7 @@ function FilterChip(props: { {(props.option.suffix || props.option.count != null) && ( + - + {props.title} -
@@ -245,25 +246,40 @@ export function PricingSidebar(props: PricingSidebarProps) { ] return ( -