revert: undo t0ng7u UI design-system refactor
Revert the following commits: -308e3e347feat(web): polish themed data views and add task log details -b2a890e75fix: Fontsource asset resolution across workspace layouts -9d1ca545erefactor(web): refine data-table cards and pricing page layout -0918bdb49refactor(web): consolidate design-system primitives and responsive data views -262ab9312style(web): unify design system across default frontend
This commit is contained in:
+43
-37
@@ -199,7 +199,7 @@ export function DynamicPricingBreakdown({
|
||||
<section className={cn('min-w-0', !compact && 'py-4')}>
|
||||
{!compact && (
|
||||
<div className='mb-3 flex items-center gap-2'>
|
||||
<span className='bg-warning/15 text-warning inline-flex size-6 items-center justify-center rounded-lg'>
|
||||
<span className='inline-flex size-6 items-center justify-center rounded-lg bg-amber-100 text-amber-700 shadow-sm dark:bg-amber-500/20 dark:text-amber-300'>
|
||||
<TagIcon className='size-3.5' />
|
||||
</span>
|
||||
<div>
|
||||
@@ -212,7 +212,7 @@ export function DynamicPricingBreakdown({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className='text-muted-foreground mb-1 text-xs font-medium'>
|
||||
<div className='text-muted-foreground mb-1 text-[10px] font-medium tracking-wider uppercase'>
|
||||
{t('Raw expression')}
|
||||
</div>
|
||||
<code className='text-muted-foreground block text-xs break-all'>
|
||||
@@ -234,7 +234,7 @@ export function DynamicPricingBreakdown({
|
||||
<section className={cn('min-w-0', !compact && 'py-3 sm:py-4')}>
|
||||
{!compact && (
|
||||
<div className='mb-3 flex items-start gap-2 sm:mb-4'>
|
||||
<span className='bg-warning/15 text-warning mt-0.5 inline-flex size-6 items-center justify-center rounded-lg'>
|
||||
<span className='mt-0.5 inline-flex size-6 items-center justify-center rounded-lg bg-amber-100 text-amber-700 shadow-sm dark:bg-amber-500/20 dark:text-amber-300'>
|
||||
<TagIcon className='size-3.5' />
|
||||
</span>
|
||||
<div>
|
||||
@@ -260,7 +260,7 @@ export function DynamicPricingBreakdown({
|
||||
{t('Tiered price table')}
|
||||
</div>
|
||||
<div className='space-y-1.5 sm:hidden'>
|
||||
{tiers.map((tier) => {
|
||||
{tiers.map((tier, i) => {
|
||||
const condSummary = formatConditionSummary(tier.conditions, t)
|
||||
const isMatched =
|
||||
matchedTierLabel != null &&
|
||||
@@ -268,20 +268,23 @@ export function DynamicPricingBreakdown({
|
||||
tier.label === matchedTierLabel
|
||||
return (
|
||||
<div
|
||||
key={`tier-mobile-${tier.label || condSummary || 'default'}`}
|
||||
key={`tier-mobile-${i}`}
|
||||
className={cn(
|
||||
'rounded-md border p-2',
|
||||
isMatched && 'border-success/40 bg-success/10'
|
||||
isMatched && 'border-emerald-500/40 bg-emerald-500/10'
|
||||
)}
|
||||
>
|
||||
<div className='mb-1.5 flex flex-wrap items-center gap-1.5'>
|
||||
<Badge variant='outline'>
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300'
|
||||
>
|
||||
{tier.label || t('Default')}
|
||||
</Badge>
|
||||
{isMatched && (
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='bg-success/10 text-success'
|
||||
className='bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300'
|
||||
>
|
||||
{t('Matched')}
|
||||
</Badge>
|
||||
@@ -299,12 +302,12 @@ export function DynamicPricingBreakdown({
|
||||
)
|
||||
return (
|
||||
<div key={v.field} className='min-w-0'>
|
||||
<div className='text-muted-foreground truncate text-xs font-medium'>
|
||||
<div className='text-muted-foreground truncate text-[10px] font-medium tracking-wider uppercase'>
|
||||
{t(v.shortLabel)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'truncate tabular-nums',
|
||||
'truncate font-mono',
|
||||
compact ? 'text-xs' : 'text-sm font-semibold'
|
||||
)}
|
||||
>
|
||||
@@ -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 (
|
||||
<>
|
||||
<div className='flex flex-wrap items-center gap-1.5'>
|
||||
<Badge variant='outline'>
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300'
|
||||
>
|
||||
{tier.label || t('Default')}
|
||||
</Badge>
|
||||
{isMatched && (
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='bg-success/10 text-success'
|
||||
className='bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300'
|
||||
>
|
||||
{t('Matched')}
|
||||
</Badge>
|
||||
@@ -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')}
|
||||
</div>
|
||||
<ul className='space-y-1.5'>
|
||||
{ruleGroups.map((group) => {
|
||||
const description = describeGroup(group, t)
|
||||
return (
|
||||
<li
|
||||
key={`${description}-${group.multiplier}`}
|
||||
className='bg-muted/50 flex items-center justify-between gap-3 rounded-md px-3 py-2'
|
||||
{ruleGroups.map((group, gi) => (
|
||||
<li
|
||||
key={`group-${gi}`}
|
||||
className='bg-muted/50 flex items-center justify-between gap-3 rounded-md px-3 py-2'
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'text-foreground break-all',
|
||||
compact ? 'text-xs' : 'text-sm'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'text-foreground break-all',
|
||||
compact ? 'text-xs' : 'text-sm'
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</span>
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='border-warning/30 bg-warning/10 text-status-warning shrink-0'
|
||||
>
|
||||
{group.multiplier}x
|
||||
</Badge>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
{describeGroup(group, t)}
|
||||
</span>
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='shrink-0 bg-orange-100 text-orange-700 dark:bg-orange-500/20 dark:text-orange-300'
|
||||
>
|
||||
{group.multiplier}x
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -19,7 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import { Search } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export interface EmptyStateProps {
|
||||
searchQuery?: string
|
||||
@@ -49,7 +49,7 @@ export function EmptyState(props: EmptyStateProps) {
|
||||
</p>
|
||||
|
||||
{(props.hasActiveFilters || hasSearch) && (
|
||||
<Button variant='outline' onClick={props.onClearFilters}>
|
||||
<Button variant='outline' size='sm' onClick={props.onClearFilters}>
|
||||
{t('Clear all filters')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
+5
-1
@@ -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'
|
||||
|
||||
@@ -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 (
|
||||
<div>
|
||||
<div className='mb-8 max-w-3xl space-y-2'>
|
||||
<Skeleton className='h-6 w-48' />
|
||||
<Skeleton className='h-4 w-full max-w-xl' />
|
||||
</div>
|
||||
<div className='space-y-4'>
|
||||
<FilterBarSkeleton />
|
||||
{viewMode === VIEW_MODES.TABLE ? (
|
||||
<TableContentSkeleton />
|
||||
) : (
|
||||
<CardContentSkeleton />
|
||||
)}
|
||||
<div className='space-y-5'>
|
||||
<div className='space-y-1.5'>
|
||||
<Skeleton className='h-8 w-40' />
|
||||
<Skeleton className='h-4 w-52' />
|
||||
</div>
|
||||
<Skeleton className='h-10 w-full rounded-lg' />
|
||||
<FilterBarSkeleton />
|
||||
{viewMode === VIEW_MODES.TABLE ? (
|
||||
<TableContentSkeleton />
|
||||
) : (
|
||||
<CardContentSkeleton />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -74,11 +47,11 @@ export function LoadingSkeleton(props: LoadingSkeletonProps) {
|
||||
function CardContentSkeleton() {
|
||||
return (
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3'>
|
||||
{CARD_SKELETONS.map((key) => (
|
||||
<div key={key} className='rounded-lg border p-4'>
|
||||
{Array.from({ length: 9 }).map((_, i) => (
|
||||
<div key={i} className='rounded-xl border p-5'>
|
||||
<div className='flex items-start justify-between gap-3'>
|
||||
<div className='flex min-w-0 items-start gap-3'>
|
||||
<Skeleton className='size-9 shrink-0 rounded-lg' />
|
||||
<Skeleton className='size-10 shrink-0 rounded-xl' />
|
||||
<div className='min-w-0 flex-1 space-y-2'>
|
||||
<Skeleton className='h-5 w-36' />
|
||||
<Skeleton className='h-3.5 w-48' />
|
||||
@@ -107,41 +80,64 @@ function CardContentSkeleton() {
|
||||
|
||||
function FilterBarSkeleton() {
|
||||
return (
|
||||
<div>
|
||||
<div className='flex flex-col gap-3 sm:flex-row sm:items-center'>
|
||||
<Skeleton className='h-7 w-full sm:h-8 sm:max-w-sm' />
|
||||
<div className='flex flex-wrap items-center gap-2 sm:ml-auto'>
|
||||
<Skeleton className='h-7 w-20 sm:h-8' />
|
||||
<Skeleton className='h-7 w-24 sm:h-8' />
|
||||
<Skeleton className='h-7 w-28 sm:h-8' />
|
||||
<Skeleton className='h-7 w-16 sm:h-8' />
|
||||
<div className='space-y-3'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='flex flex-1 flex-wrap items-center gap-2'>
|
||||
{[80, 90, 75, 85, 70].map((width, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
className='h-8 rounded-lg'
|
||||
style={{ width: `${width}px` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Skeleton className='h-8 w-24 rounded-lg' />
|
||||
<Skeleton className='h-8 w-20 rounded-lg' />
|
||||
<Skeleton className='h-8 w-24' />
|
||||
<Skeleton className='h-8 w-20 rounded-lg' />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className='mt-3 h-4 w-24' />
|
||||
<Skeleton className='h-5 w-24' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableContentSkeleton() {
|
||||
const columns = [
|
||||
{ width: 200 },
|
||||
{ width: 100 },
|
||||
{ width: 100 },
|
||||
{ width: 100 },
|
||||
{ width: 80 },
|
||||
{ width: 100 },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='overflow-hidden rounded-lg border'>
|
||||
<div className='bg-muted/30 border-b px-4 py-3'>
|
||||
<div className='grid grid-cols-[minmax(200px,2fr)_repeat(3,minmax(100px,1fr))_minmax(120px,1fr)] gap-4'>
|
||||
<Skeleton className='h-4 w-32' />
|
||||
{PRICE_COLUMNS.map((column) => (
|
||||
<Skeleton key={column} className='h-4 w-20' />
|
||||
<div className='flex items-center gap-4'>
|
||||
{columns.map((col, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
className='h-4'
|
||||
style={{ width: `${col.width}px` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{TABLE_ROWS.map((row) => (
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<div
|
||||
key={row}
|
||||
className='grid grid-cols-[minmax(200px,2fr)_repeat(3,minmax(100px,1fr))_minmax(120px,1fr)] gap-4 border-b px-4 py-3 last:border-b-0'
|
||||
key={i}
|
||||
className='flex items-center gap-4 border-b px-4 py-3 last:border-b-0'
|
||||
>
|
||||
<Skeleton className='h-5 w-40' />
|
||||
{PRICE_COLUMNS.map((column) => (
|
||||
<Skeleton key={`${row}-${column}`} className='h-5 w-20' />
|
||||
{columns.map((col, j) => (
|
||||
<Skeleton
|
||||
key={j}
|
||||
className='h-5'
|
||||
style={{ width: `${col.width}px` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
@@ -149,8 +145,8 @@ function TableContentSkeleton() {
|
||||
<div className='flex items-center justify-between'>
|
||||
<Skeleton className='h-5 w-32' />
|
||||
<div className='flex items-center gap-2'>
|
||||
{PAGINATION_ITEMS.map((item) => (
|
||||
<Skeleton key={item} className='size-8' />
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className='size-8' />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+10
-15
@@ -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 { Button } from '@/components/ui/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 (
|
||||
<div className='space-y-4 sm:space-y-5'>
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3'>
|
||||
<div className='grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-2 lg:grid-cols-3'>
|
||||
{pagedModels.map((model) => (
|
||||
<ModelCard
|
||||
key={model.id ?? model.model_name}
|
||||
@@ -97,7 +90,7 @@ export function ModelCardGrid(props: ModelCardGridProps) {
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className='text-muted-foreground flex flex-col items-center justify-between gap-3 border-t py-4 text-sm sm:flex-row'>
|
||||
<div className='text-muted-foreground flex flex-col items-center justify-between gap-3 border-t px-4 py-3 text-sm sm:flex-row'>
|
||||
<p className='text-muted-foreground'>
|
||||
{t('Page {{current}} of {{total}}', {
|
||||
current: currentPage,
|
||||
@@ -108,16 +101,18 @@ export function ModelCardGrid(props: ModelCardGridProps) {
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => setPage((current) => Math.max(1, current - 1))}
|
||||
disabled={currentPage <= 1}
|
||||
className='gap-1.5'
|
||||
>
|
||||
<ChevronLeft aria-hidden='true' />
|
||||
<ChevronLeft className='size-4' />
|
||||
{t('Previous page')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
setPage((current) => Math.min(totalPages, current + 1))
|
||||
}
|
||||
@@ -125,7 +120,7 @@ export function ModelCardGrid(props: ModelCardGridProps) {
|
||||
className='gap-1.5'
|
||||
>
|
||||
{t('Next page')}
|
||||
<ChevronRight aria-hidden='true' />
|
||||
<ChevronRight className='size-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+202
-185
@@ -16,14 +16,14 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
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 (
|
||||
<div className='min-w-0'>
|
||||
<p className='text-muted-foreground text-xs'>{props.label}</p>
|
||||
<p
|
||||
className={
|
||||
props.muted
|
||||
? 'text-muted-foreground mt-1 truncate text-sm tabular-nums'
|
||||
: 'text-foreground mt-1 truncate text-sm font-semibold tabular-nums'
|
||||
}
|
||||
>
|
||||
{props.value}
|
||||
<span className='text-muted-foreground ml-1 text-xs font-normal'>
|
||||
/ {props.unit}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
|
||||
const { t } = useTranslation()
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
@@ -79,196 +54,238 @@ 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 = (
|
||||
<div>
|
||||
<p className='text-warning text-sm font-medium'>
|
||||
{t('Special billing expression')}
|
||||
</p>
|
||||
<code className='text-muted-foreground mt-1 line-clamp-2 block font-mono text-xs break-all'>
|
||||
{dynamicSummary.rawExpression}
|
||||
</code>
|
||||
</div>
|
||||
)
|
||||
} else if (dynamicSummary && dynamicSummary.primaryEntries.length > 0) {
|
||||
priceContent = (
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
{dynamicSummary.primaryEntries.slice(0, 2).map((entry) => (
|
||||
<PriceMetric
|
||||
key={entry.key}
|
||||
label={t(entry.shortLabel)}
|
||||
value={entry.formatted}
|
||||
unit={tokenUnitLabel}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
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 = (
|
||||
<span className='min-w-0'>
|
||||
<span className='text-amber-700 dark:text-amber-300'>
|
||||
{t('Special billing expression')}
|
||||
</span>
|
||||
<code className='text-muted-foreground/70 mt-0.5 line-clamp-1 block font-mono text-[11px] break-all'>
|
||||
{dynamicSummary.rawExpression}
|
||||
</code>
|
||||
</span>
|
||||
)
|
||||
} else if (dynamicSummary.primaryEntries.length > 0) {
|
||||
priceSummary = (
|
||||
<>
|
||||
{dynamicSummary.primaryEntries.map((entry) => (
|
||||
<span
|
||||
key={entry.key}
|
||||
className='text-muted-foreground whitespace-nowrap'
|
||||
>
|
||||
{t(entry.shortLabel)}{' '}
|
||||
<span className='text-foreground font-mono font-semibold'>
|
||||
{entry.formatted}
|
||||
</span>
|
||||
/{tokenUnitLabel}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
} else {
|
||||
priceSummary = (
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{t('Dynamic Pricing')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
} else if (isTokenBased) {
|
||||
priceContent = (
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<PriceMetric
|
||||
label={t('Input')}
|
||||
value={inputPrice}
|
||||
unit={tokenUnitLabel}
|
||||
/>
|
||||
<PriceMetric
|
||||
label={t('Output')}
|
||||
value={outputPrice}
|
||||
unit={tokenUnitLabel}
|
||||
/>
|
||||
{cachedPrice && (
|
||||
<PriceMetric
|
||||
label={t('Cached input')}
|
||||
value={cachedPrice}
|
||||
unit={tokenUnitLabel}
|
||||
muted
|
||||
/>
|
||||
priceSummary = (
|
||||
<>
|
||||
<span className='text-muted-foreground whitespace-nowrap'>
|
||||
{t('Input')}{' '}
|
||||
<span className='text-foreground font-mono font-semibold'>
|
||||
{formatPrice(
|
||||
props.model,
|
||||
'input',
|
||||
tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
props.selectedGroup
|
||||
)}
|
||||
</span>
|
||||
/{tokenUnitLabel}
|
||||
</span>
|
||||
<span className='text-muted-foreground whitespace-nowrap'>
|
||||
{t('Output')}{' '}
|
||||
<span className='text-foreground font-mono font-semibold'>
|
||||
{formatPrice(
|
||||
props.model,
|
||||
'output',
|
||||
tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
props.selectedGroup
|
||||
)}
|
||||
</span>
|
||||
/{tokenUnitLabel}
|
||||
</span>
|
||||
{hasCachedPrice && (
|
||||
<span className='text-muted-foreground/60 whitespace-nowrap'>
|
||||
{t('Cached')}{' '}
|
||||
<span className='font-mono'>
|
||||
{formatPrice(
|
||||
props.model,
|
||||
'cache',
|
||||
tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
props.selectedGroup
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
} else {
|
||||
priceContent = (
|
||||
<PriceMetric
|
||||
label={t('Price')}
|
||||
value={formatRequestPrice(
|
||||
props.model,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
props.selectedGroup
|
||||
)}
|
||||
unit={t('request')}
|
||||
/>
|
||||
priceSummary = (
|
||||
<span className='text-muted-foreground whitespace-nowrap'>
|
||||
<span className='text-foreground font-mono font-semibold'>
|
||||
{formatRequestPrice(
|
||||
props.model,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
props.selectedGroup
|
||||
)}
|
||||
</span>{' '}
|
||||
/ {t('request')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<article className='hover:bg-muted/20 flex min-h-full flex-col rounded-lg border p-4 transition-colors'>
|
||||
<div className='flex items-start gap-3'>
|
||||
<div className='bg-muted flex size-9 shrink-0 items-center justify-center rounded-lg'>
|
||||
{modelIcon || (
|
||||
<span className='text-muted-foreground text-sm font-medium'>
|
||||
{props.model.model_name?.charAt(0).toUpperCase() || '?'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='flex min-w-0 items-center gap-2'>
|
||||
<h2 className='truncate font-mono text-sm font-medium'>
|
||||
<div
|
||||
className={cn(
|
||||
'group relative flex flex-col rounded-xl border p-3 transition-colors sm:p-5',
|
||||
'hover:bg-muted/20'
|
||||
)}
|
||||
>
|
||||
{/* Header: icon + name + price + actions */}
|
||||
<div className='flex items-start justify-between gap-2.5 sm:gap-3'>
|
||||
<div className='flex min-w-0 items-start gap-2.5 sm:gap-3'>
|
||||
<div className='bg-muted/40 flex size-9 shrink-0 items-center justify-center rounded-lg sm:size-10 sm:rounded-xl'>
|
||||
{modelIcon || (
|
||||
<span className='text-muted-foreground text-sm font-bold'>
|
||||
{initial}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className='min-w-0'>
|
||||
<h3 className='text-foreground truncate font-mono text-[15px] leading-tight font-bold'>
|
||||
{props.model.model_name}
|
||||
</h2>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon-xs'
|
||||
onClick={() => copyToClipboard(props.model.model_name || '')}
|
||||
aria-label={t('Copy model name')}
|
||||
>
|
||||
<Copy aria-hidden='true' className='size-3' />
|
||||
</Button>
|
||||
</h3>
|
||||
<div className='mt-0.5 flex flex-wrap items-baseline gap-x-2 gap-y-0.5 text-xs sm:mt-1 sm:gap-x-3'>
|
||||
{priceSummary}
|
||||
</div>
|
||||
</div>
|
||||
<p className='text-muted-foreground mt-0.5 text-xs'>
|
||||
{props.model.vendor_name ||
|
||||
(isTokenBased ? t('Token-based') : t('Per Request'))}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ModelPerfBadge perf={props.perf} />
|
||||
<div className='flex shrink-0 items-center gap-1.5'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={props.onClick}
|
||||
className='text-muted-foreground hover:text-foreground hover:bg-muted inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs transition-colors sm:px-2.5 sm:py-1.5'
|
||||
>
|
||||
{t('Details')}
|
||||
<ChevronRight className='size-3.5' />
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleCopy}
|
||||
className='text-muted-foreground hover:text-foreground hover:bg-muted rounded-md border p-1.5 transition-colors'
|
||||
title={t('Copy')}
|
||||
>
|
||||
<Copy className='size-3.5' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className='text-muted-foreground mt-4 line-clamp-2 min-h-10 text-sm leading-relaxed'>
|
||||
{/* Description */}
|
||||
<p className='text-muted-foreground mt-2 line-clamp-1 flex-1 text-[13px] leading-relaxed sm:mt-4 sm:line-clamp-2 sm:min-h-[2.5rem]'>
|
||||
{props.model.description || t('No description available.')}
|
||||
</p>
|
||||
|
||||
<div className='mt-5 border-y py-4'>{priceContent}</div>
|
||||
{/* Footer: left metadata and right performance summary share row alignment */}
|
||||
<div className='mt-2 grid grid-cols-[minmax(0,1fr)_auto] items-start gap-x-2 gap-y-1 sm:mt-4'>
|
||||
<div className='flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1'>
|
||||
{primaryGroup && (
|
||||
<span className='text-muted-foreground text-xs font-medium'>
|
||||
{primaryGroup} {t('Groups')}
|
||||
</span>
|
||||
)}
|
||||
<span className='text-muted-foreground text-xs font-medium'>
|
||||
{isTokenBased ? t('Token-based') : t('Per Request')}
|
||||
</span>
|
||||
{isDynamicPricing && (
|
||||
<StatusBadge
|
||||
label={t('Dynamic Pricing')}
|
||||
variant='warning'
|
||||
copyable={false}
|
||||
size='sm'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ModelPerfBadge perf={props.perf} className='row-span-2 self-start' />
|
||||
|
||||
<div className='mt-4 flex flex-1 flex-col justify-end gap-4'>
|
||||
<div className='flex min-h-6 flex-wrap items-center gap-1.5'>
|
||||
{groups.slice(0, 1).map((group) => (
|
||||
<StatusBadge key={group} variant='neutral' size='md'>
|
||||
{group}
|
||||
</StatusBadge>
|
||||
<div className='flex min-w-0 flex-wrap items-center gap-x-2.5 gap-y-0.5 sm:gap-x-3 sm:gap-y-1'>
|
||||
{bottomTags.map((item) => (
|
||||
<span key={item} className='text-muted-foreground/70 text-xs'>
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
{visibleTags.map((tag) => (
|
||||
<StatusBadge key={tag} variant='neutral' size='md'>
|
||||
{tag}
|
||||
</StatusBadge>
|
||||
))}
|
||||
{hiddenTagCount > 0 && (
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
+{hiddenTagCount}
|
||||
<span className='text-muted-foreground/50 text-xs'>
|
||||
{tokenUnitLabel}
|
||||
</span>
|
||||
{hiddenCount > 0 && (
|
||||
<span className='text-muted-foreground/40 text-xs'>
|
||||
+{hiddenCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
onClick={props.onClick}
|
||||
className='self-start'
|
||||
>
|
||||
{t('Details')}
|
||||
<ArrowRight aria-hidden='true' />
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -36,8 +36,8 @@ import {
|
||||
StaticDataTable,
|
||||
staticDataTableClassNames as tableStyles,
|
||||
} from '@/components/data-table'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/design-system/tabs'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { useStatus } from '@/hooks/use-status'
|
||||
|
||||
import {
|
||||
@@ -502,12 +502,12 @@ function CodeSamplesSection(props: {
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
{endpoints.length > 1 && (
|
||||
<Tabs value={endpointType} onValueChange={setEndpointType}>
|
||||
<TabsList className='bg-muted/40 p-0.5'>
|
||||
<TabsList className='bg-muted/40 h-8 p-0.5'>
|
||||
{endpoints.map((ep) => (
|
||||
<TabsTrigger
|
||||
key={ep.type}
|
||||
value={ep.type}
|
||||
className='px-2.5 text-xs'
|
||||
className='h-7 px-2.5 text-xs'
|
||||
>
|
||||
{ep.type}
|
||||
</TabsTrigger>
|
||||
@@ -521,9 +521,9 @@ function CodeSamplesSection(props: {
|
||||
onValueChange={(v) => setLang(v as Lang)}
|
||||
className='ml-auto'
|
||||
>
|
||||
<TabsList className='bg-muted/40 p-0.5'>
|
||||
<TabsList className='bg-muted/40 h-8 p-0.5'>
|
||||
{(Object.keys(LANG_LABELS) as Lang[]).map((l) => (
|
||||
<TabsTrigger key={l} value={l} className='px-2.5 text-xs'>
|
||||
<TabsTrigger key={l} value={l} className='h-7 px-2.5 text-xs'>
|
||||
{LANG_LABELS[l]}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
@@ -539,7 +539,7 @@ function CodeSamplesSection(props: {
|
||||
|
||||
<p className='text-muted-foreground mt-2 text-xs'>
|
||||
{t('Replace')}{' '}
|
||||
<code className='bg-muted rounded px-1 py-0.5 font-mono text-xs'>
|
||||
<code className='bg-muted rounded px-1 py-0.5 font-mono text-[11px]'>
|
||||
{'<YOUR_API_KEY>'}
|
||||
</code>{' '}
|
||||
{t('with the API key from your token settings.')}
|
||||
@@ -582,7 +582,7 @@ function SupportedParametersSection(props: { model: PricingModel }) {
|
||||
{p.required && (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='border-destructive/40 text-destructive h-6 px-2 text-sm'
|
||||
className='h-6 border-rose-500/40 px-2 text-sm text-rose-600 dark:text-rose-400'
|
||||
>
|
||||
{t('required')}
|
||||
</Badge>
|
||||
@@ -598,7 +598,7 @@ function SupportedParametersSection(props: { model: PricingModel }) {
|
||||
cell: (p) => (
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='rounded-full px-2.5 font-mono text-sm font-normal'
|
||||
className='h-7 rounded-full px-2.5 font-mono text-sm font-normal'
|
||||
>
|
||||
{p.type}
|
||||
</Badge>
|
||||
@@ -711,7 +711,7 @@ function RateLimitsSection(props: { model: PricingModel }) {
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<p className='text-muted-foreground mt-2 text-xs leading-relaxed'>
|
||||
<p className='text-muted-foreground mt-2 text-[11px] leading-relaxed'>
|
||||
{t(
|
||||
'RPM = requests per minute, TPM = tokens per minute, RPD = requests per day. Limits apply per token group.'
|
||||
)}
|
||||
@@ -734,11 +734,11 @@ function AuthSection() {
|
||||
<div className='space-y-1.5 text-xs leading-relaxed'>
|
||||
<p>
|
||||
{t('All requests must include')}{' '}
|
||||
<code className='bg-muted rounded px-1 py-0.5 font-mono text-xs'>
|
||||
<code className='bg-muted rounded px-1 py-0.5 font-mono text-[11px]'>
|
||||
Authorization: Bearer <TOKEN>
|
||||
</code>{' '}
|
||||
{t('header. Anthropic-formatted endpoints accept the')}{' '}
|
||||
<code className='bg-muted rounded px-1 py-0.5 font-mono text-xs'>
|
||||
<code className='bg-muted rounded px-1 py-0.5 font-mono text-[11px]'>
|
||||
x-api-key
|
||||
</code>{' '}
|
||||
{t('header instead.')}
|
||||
|
||||
@@ -48,14 +48,16 @@ function RankBadge(props: { rank: number }) {
|
||||
const isPodium = rank <= 3
|
||||
const palette =
|
||||
rank === 1
|
||||
? 'bg-warning/15 text-warning'
|
||||
: rank <= 3
|
||||
? 'bg-muted text-foreground'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
? 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300'
|
||||
: rank === 2
|
||||
? 'bg-slate-100 text-slate-700 dark:bg-slate-500/20 dark:text-slate-300'
|
||||
: rank === 3
|
||||
? 'bg-orange-100 text-orange-700 dark:bg-orange-500/20 dark:text-orange-300'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex size-7 shrink-0 items-center justify-center rounded-md text-xs font-bold tabular-nums',
|
||||
'inline-flex size-7 shrink-0 items-center justify-center rounded-md font-mono text-xs font-bold tabular-nums',
|
||||
palette
|
||||
)}
|
||||
>
|
||||
@@ -69,16 +71,16 @@ function GrowthChip(props: { value: number }) {
|
||||
const isUp = value > 0
|
||||
const isDown = value < 0
|
||||
const palette = isUp
|
||||
? 'bg-success/10 text-success'
|
||||
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300'
|
||||
: isDown
|
||||
? 'bg-destructive/10 text-destructive'
|
||||
? 'bg-rose-100 text-rose-700 dark:bg-rose-500/20 dark:text-rose-300'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
const Icon = isUp ? ArrowUpRight : isDown ? ArrowDownRight : null
|
||||
const formatted = `${value > 0 ? '+' : ''}${value.toFixed(1)}%`
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-0.5 rounded-md px-1.5 py-0.5 text-xs font-semibold tabular-nums',
|
||||
'inline-flex items-center gap-0.5 rounded-md px-1.5 py-0.5 font-mono text-[11px] font-semibold tabular-nums',
|
||||
palette
|
||||
)}
|
||||
>
|
||||
@@ -123,35 +125,35 @@ export function ModelDetailsApps(props: { model: PricingModel }) {
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='grid grid-cols-1 gap-2 sm:grid-cols-3'>
|
||||
<div className='bg-muted/20 rounded-lg border p-3'>
|
||||
<div className='text-muted-foreground text-xs font-medium tracking-wider uppercase'>
|
||||
<div className='text-muted-foreground text-[10px] font-medium tracking-wider uppercase'>
|
||||
{t('Tracked apps')}
|
||||
</div>
|
||||
<div className='text-foreground mt-1 text-lg font-semibold tabular-nums'>
|
||||
<div className='text-foreground mt-1 font-mono text-lg font-semibold tabular-nums'>
|
||||
{apps.length}
|
||||
</div>
|
||||
<p className='text-muted-foreground/70 text-xs'>
|
||||
<p className='text-muted-foreground/70 text-[11px]'>
|
||||
{t('Top integrations using this model')}
|
||||
</p>
|
||||
</div>
|
||||
<div className='bg-muted/20 rounded-lg border p-3'>
|
||||
<div className='text-muted-foreground text-xs font-medium tracking-wider uppercase'>
|
||||
<div className='text-muted-foreground text-[10px] font-medium tracking-wider uppercase'>
|
||||
{t('Monthly tokens')}
|
||||
</div>
|
||||
<div className='text-foreground mt-1 text-lg font-semibold tabular-nums'>
|
||||
<div className='text-foreground mt-1 font-mono text-lg font-semibold tabular-nums'>
|
||||
{COMPACT_NUMBER.format(totalMonthlyTokens)}
|
||||
</div>
|
||||
<p className='text-muted-foreground/70 text-xs'>
|
||||
<p className='text-muted-foreground/70 text-[11px]'>
|
||||
{t('Aggregated across the apps below')}
|
||||
</p>
|
||||
</div>
|
||||
<div className='bg-muted/20 rounded-lg border p-3'>
|
||||
<div className='text-muted-foreground text-xs font-medium tracking-wider uppercase'>
|
||||
<div className='text-muted-foreground text-[10px] font-medium tracking-wider uppercase'>
|
||||
{t('#1 by usage')}
|
||||
</div>
|
||||
<div className='text-foreground mt-1 truncate text-base font-semibold'>
|
||||
{top.name}
|
||||
</div>
|
||||
<p className='text-muted-foreground/70 truncate text-xs'>
|
||||
<p className='text-muted-foreground/70 truncate text-[11px]'>
|
||||
{top.category} · {formatTokenVolume(top.monthly_tokens)}{' '}
|
||||
{t('tokens / mo')}
|
||||
</p>
|
||||
@@ -223,7 +225,7 @@ export function ModelDetailsApps(props: { model: PricingModel }) {
|
||||
]}
|
||||
/>
|
||||
|
||||
<p className='text-muted-foreground/60 text-xs leading-relaxed'>
|
||||
<p className='text-muted-foreground/60 text-[11px] leading-relaxed'>
|
||||
{t(
|
||||
'App rankings shown here are simulated for preview purposes and will be replaced with live usage data once the backend integration is complete.'
|
||||
)}
|
||||
|
||||
@@ -51,20 +51,22 @@ function StatCard(props: {
|
||||
const Icon = props.icon
|
||||
return (
|
||||
<div className='bg-background flex flex-col gap-1 rounded-lg border p-3'>
|
||||
<span className='text-muted-foreground inline-flex items-center gap-1.5 text-xs font-medium'>
|
||||
<span className='text-muted-foreground inline-flex items-center gap-1.5 text-[10px] font-medium tracking-wider uppercase'>
|
||||
<Icon className='size-3' />
|
||||
{props.label}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-foreground text-lg font-semibold tabular-nums',
|
||||
'text-foreground font-mono text-lg font-semibold tabular-nums',
|
||||
props.valueClassName
|
||||
)}
|
||||
>
|
||||
{props.value}
|
||||
</span>
|
||||
{props.hint && (
|
||||
<span className='text-muted-foreground/70 text-xs'>{props.hint}</span>
|
||||
<span className='text-muted-foreground/70 text-[11px]'>
|
||||
{props.hint}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -328,7 +330,7 @@ export function ModelDetailsPerformance(props: { model: PricingModel }) {
|
||||
}
|
||||
accent={
|
||||
incidentCount > 0 ? (
|
||||
<span className='text-warning inline-flex items-center gap-1'>
|
||||
<span className='inline-flex items-center gap-1 text-amber-600 dark:text-amber-400'>
|
||||
<AlertTriangle className='size-3.5' />
|
||||
{t('{{count}} incidents', {
|
||||
count: incidentCount,
|
||||
|
||||
+8
-6
@@ -128,7 +128,7 @@ export function UptimeSparkline(props: UptimeSparklineProps) {
|
||||
{showOverall && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm font-semibold tabular-nums',
|
||||
'font-mono text-sm font-semibold tabular-nums',
|
||||
getSuccessRateTextClass(overall)
|
||||
)}
|
||||
>
|
||||
@@ -164,11 +164,13 @@ export function UptimeStatusRow(props: {
|
||||
: AlertCircle
|
||||
|
||||
const statusColour =
|
||||
status === 'operational' || status === 'minor'
|
||||
? 'text-success'
|
||||
: status === 'degraded'
|
||||
? 'text-warning'
|
||||
: 'text-destructive'
|
||||
status === 'operational'
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: status === 'minor'
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: status === 'degraded'
|
||||
? 'text-amber-600 dark:text-amber-400'
|
||||
: 'text-rose-600 dark:text-rose-400'
|
||||
|
||||
const statusLabel =
|
||||
status === 'operational'
|
||||
|
||||
+304
-266
@@ -28,24 +28,26 @@ import {
|
||||
Layers,
|
||||
Maximize2,
|
||||
Sparkles,
|
||||
Timer,
|
||||
} from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { CopyButton } from '@/components/copy-button'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from '@/components/design-system/tabs'
|
||||
import { sideDrawerContentClassName } from '@/components/drawer-layout'
|
||||
import { GroupBadge } from '@/components/group-badge'
|
||||
import { PublicLayout } from '@/components/layout'
|
||||
import { PageTransition } from '@/components/page-transition'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { getPerfMetrics } from '@/features/performance-metrics/api'
|
||||
import {
|
||||
formatLatency,
|
||||
@@ -81,19 +83,11 @@ import { ModelDetailsPerformance } from './model-details-performance'
|
||||
// Local UI helpers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
function SectionTitle(props: {
|
||||
children: React.ReactNode
|
||||
description?: string
|
||||
}) {
|
||||
function SectionTitle(props: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className='mb-4'>
|
||||
<h2 className='text-base font-semibold'>{props.children}</h2>
|
||||
{props.description && (
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
{props.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<h2 className='text-muted-foreground mb-3 text-xs font-semibold tracking-wider uppercase'>
|
||||
{props.children}
|
||||
</h2>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -151,22 +145,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 (
|
||||
<div className='min-w-0 px-4 py-3'>
|
||||
<div className='text-muted-foreground text-xs font-medium'>
|
||||
{props.label}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'text-foreground mt-1 truncate text-sm font-semibold tabular-nums',
|
||||
props.valueClassName
|
||||
)}
|
||||
>
|
||||
{props.value}
|
||||
<div className='flex min-w-0 items-center gap-2 px-3 py-2'>
|
||||
<Icon className='text-muted-foreground/70 size-3.5 shrink-0' />
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='text-muted-foreground truncate text-[10px] font-medium tracking-wider uppercase'>
|
||||
{props.label}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'text-foreground truncate font-mono text-sm font-semibold tabular-nums',
|
||||
props.valueClassName
|
||||
)}
|
||||
>
|
||||
{props.value}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -207,13 +207,19 @@ function OverviewSummaryGrid(props: { model: PricingModel }) {
|
||||
: 0
|
||||
|
||||
return (
|
||||
<div className='divide-border grid divide-y overflow-hidden rounded-xl border sm:grid-cols-3 sm:divide-x sm:divide-y-0'>
|
||||
<OverviewMetric label='TPS' value={formatThroughput(avgTps)} />
|
||||
<div className='bg-muted/20 grid overflow-hidden rounded-lg border sm:grid-cols-3 sm:divide-x'>
|
||||
<OverviewMetric
|
||||
icon={Timer}
|
||||
label='TPS'
|
||||
value={formatThroughput(avgTps)}
|
||||
/>
|
||||
<OverviewMetric
|
||||
icon={Timer}
|
||||
label={t('Average latency')}
|
||||
value={formatLatency(avgLatency)}
|
||||
/>
|
||||
<OverviewMetric
|
||||
icon={HeartPulse}
|
||||
label={t('Success rate')}
|
||||
value={formatUptimePct(successRate)}
|
||||
valueClassName={getSuccessRateTextClass(successRate)}
|
||||
@@ -226,9 +232,12 @@ function CatalogPillList(props: { items: string[] }) {
|
||||
return (
|
||||
<div className='flex min-w-0 flex-wrap gap-1.5'>
|
||||
{props.items.map((item) => (
|
||||
<StatusBadge key={item} variant='neutral' size='md'>
|
||||
<span
|
||||
key={item}
|
||||
className='bg-muted text-muted-foreground rounded-md px-2 py-1 text-xs font-medium'
|
||||
>
|
||||
{item}
|
||||
</StatusBadge>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
@@ -236,7 +245,7 @@ function CatalogPillList(props: { items: string[] }) {
|
||||
|
||||
function CatalogTextValue(props: { children: React.ReactNode }) {
|
||||
return (
|
||||
<span className='text-foreground min-w-0 truncate text-sm font-medium'>
|
||||
<span className='text-foreground min-w-0 truncate text-sm font-semibold'>
|
||||
{props.children}
|
||||
</span>
|
||||
)
|
||||
@@ -244,8 +253,8 @@ function CatalogTextValue(props: { children: React.ReactNode }) {
|
||||
|
||||
function CatalogInfoCell(props: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className='flex min-w-0 flex-col gap-1.5 px-4 py-3'>
|
||||
<span className='text-muted-foreground text-xs font-medium'>
|
||||
<div className='bg-card flex min-w-0 flex-col gap-1 px-3 py-2.5'>
|
||||
<span className='text-muted-foreground text-[10px] font-medium tracking-wider uppercase'>
|
||||
{props.label}
|
||||
</span>
|
||||
{props.children}
|
||||
@@ -344,20 +353,23 @@ function ModelBackendQuickStats(props: { model: PricingModel }) {
|
||||
if (stats.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className='divide-border grid grid-cols-2 divide-x divide-y overflow-hidden rounded-xl border @md/details:grid-cols-3 @2xl/details:grid-cols-5'>
|
||||
<div className='bg-muted/20 grid grid-cols-2 gap-px overflow-hidden rounded-lg border @md/details:grid-cols-3 @2xl/details:grid-cols-5'>
|
||||
{stats.map((stat) => {
|
||||
const Icon = stat.icon
|
||||
return (
|
||||
<div key={stat.key} className='flex min-w-0 flex-col gap-1 px-4 py-3'>
|
||||
<span className='text-muted-foreground inline-flex min-w-0 items-center gap-1.5 text-xs font-medium'>
|
||||
<Icon className='size-3.5 shrink-0' />
|
||||
<div
|
||||
key={stat.key}
|
||||
className='bg-background flex min-w-0 flex-col gap-0.5 px-3 py-2.5'
|
||||
>
|
||||
<span className='text-muted-foreground inline-flex min-w-0 items-center gap-1 text-[10px] font-medium tracking-wider uppercase'>
|
||||
<Icon className='size-3 shrink-0' />
|
||||
<span className='truncate'>{stat.label}</span>
|
||||
</span>
|
||||
<span className='text-foreground truncate text-sm font-semibold tabular-nums'>
|
||||
{stat.value}
|
||||
</span>
|
||||
{stat.hint && (
|
||||
<span className='text-muted-foreground truncate text-xs'>
|
||||
<span className='text-muted-foreground/60 truncate text-[10px]'>
|
||||
{stat.hint}
|
||||
</span>
|
||||
)}
|
||||
@@ -384,9 +396,11 @@ function ModelBackendSignalsSection(props: { model: PricingModel }) {
|
||||
|
||||
return (
|
||||
<section>
|
||||
<SectionTitle>{t('Capabilities')}</SectionTitle>
|
||||
<div className='space-y-4 rounded-xl border p-4'>
|
||||
{capabilities.length > 0 && (
|
||||
<SectionTitle>
|
||||
{t('Capabilities')} / {t('Supported modalities')}
|
||||
</SectionTitle>
|
||||
<div className='grid gap-3 rounded-xl border p-3 @2xl/details:grid-cols-[minmax(0,1.5fr)_minmax(260px,1fr)]'>
|
||||
{capabilities.length > 0 ? (
|
||||
<CatalogPillList
|
||||
items={capabilities.map((capability) =>
|
||||
t(
|
||||
@@ -395,11 +409,13 @@ function ModelBackendSignalsSection(props: { model: PricingModel }) {
|
||||
)
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
{(inputModalities.length > 0 || outputModalities.length > 0) && (
|
||||
<div className='grid gap-2 sm:grid-cols-2'>
|
||||
{inputModalities.length > 0 && (
|
||||
<div className='bg-muted/20 flex items-center justify-between gap-3 rounded-lg px-3 py-2.5'>
|
||||
<div className='flex items-center justify-between gap-3 rounded-lg border px-3 py-2'>
|
||||
<span className='text-muted-foreground text-xs font-medium'>
|
||||
{t('Input')}
|
||||
</span>
|
||||
@@ -409,7 +425,7 @@ function ModelBackendSignalsSection(props: { model: PricingModel }) {
|
||||
</div>
|
||||
)}
|
||||
{outputModalities.length > 0 && (
|
||||
<div className='bg-muted/20 flex items-center justify-between gap-3 rounded-lg px-3 py-2.5'>
|
||||
<div className='flex items-center justify-between gap-3 rounded-lg border px-3 py-2'>
|
||||
<span className='text-muted-foreground text-xs font-medium'>
|
||||
{t('Output')}
|
||||
</span>
|
||||
@@ -454,11 +470,7 @@ function ModelBackendProviderSection(props: { model: PricingModel }) {
|
||||
if (groups.length > 0) {
|
||||
cells.push(
|
||||
<CatalogInfoCell key='groups' label={t('Groups')}>
|
||||
<div className='flex min-w-0 flex-wrap gap-1.5'>
|
||||
{groups.map((group) => (
|
||||
<GroupBadge key={group} group={group} size='md' />
|
||||
))}
|
||||
</div>
|
||||
<CatalogPillList items={groups} />
|
||||
</CatalogInfoCell>
|
||||
)
|
||||
}
|
||||
@@ -492,7 +504,7 @@ function ModelBackendProviderSection(props: { model: PricingModel }) {
|
||||
return (
|
||||
<section>
|
||||
<SectionTitle>{t('Model')}</SectionTitle>
|
||||
<div className='divide-border grid grid-cols-1 overflow-hidden rounded-xl border sm:grid-cols-2 [&>*]:border-b [&>*:nth-child(odd)]:sm:border-r [&>*:nth-last-child(-n+2)]:sm:border-b-0'>
|
||||
<div className='border-border/60 bg-border/60 grid grid-cols-1 gap-px overflow-hidden rounded-lg border sm:grid-cols-2'>
|
||||
{cells}
|
||||
</div>
|
||||
</section>
|
||||
@@ -502,6 +514,7 @@ function ModelBackendProviderSection(props: { model: PricingModel }) {
|
||||
function ModelBackendDetailsSection(props: { model: PricingModel }) {
|
||||
return (
|
||||
<>
|
||||
<ModelBackendQuickStats model={props.model} />
|
||||
<ModelBackendSignalsSection model={props.model} />
|
||||
<ModelBackendProviderSection model={props.model} />
|
||||
</>
|
||||
@@ -516,84 +529,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 (
|
||||
<header className='space-y-5'>
|
||||
<div className='flex items-start gap-4'>
|
||||
<div className='bg-muted flex size-12 shrink-0 items-center justify-center rounded-xl'>
|
||||
{modelIcon || (
|
||||
<span className='text-muted-foreground text-base font-medium'>
|
||||
{model.model_name?.charAt(0).toUpperCase() || '?'}
|
||||
<header className='pb-4'>
|
||||
<div className='flex items-center gap-2.5'>
|
||||
{modelIcon}
|
||||
<h1 className='font-mono text-xl font-bold tracking-tight sm:text-2xl'>
|
||||
{model.model_name}
|
||||
</h1>
|
||||
<CopyButton
|
||||
value={model.model_name || ''}
|
||||
className='size-6'
|
||||
iconClassName='size-3'
|
||||
tooltip={t('Copy model name')}
|
||||
successTooltip={t('Copied!')}
|
||||
aria-label={t('Copy model name')}
|
||||
/>
|
||||
</div>
|
||||
<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/70'>
|
||||
{model.quota_type === QUOTA_TYPE_VALUES.TOKEN
|
||||
? t('Token-based')
|
||||
: t('Per Request')}
|
||||
</span>
|
||||
{model.billing_mode === 'tiered_expr' && model.billing_expr && (
|
||||
<>
|
||||
<span className='text-muted-foreground/30'>·</span>
|
||||
<span className='rounded bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:bg-amber-500/20 dark:text-amber-300'>
|
||||
{isSpecialExpression
|
||||
? t('Special billing expression')
|
||||
: t('Dynamic Pricing')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='flex min-w-0 flex-wrap items-center gap-2'>
|
||||
<h1 className='truncate font-mono text-lg font-semibold tracking-tight'>
|
||||
{model.model_name}
|
||||
</h1>
|
||||
<CopyButton
|
||||
value={model.model_name || ''}
|
||||
size='icon-xs'
|
||||
iconClassName='size-3'
|
||||
tooltip={t('Copy model name')}
|
||||
successTooltip={t('Copied!')}
|
||||
aria-label={t('Copy model name')}
|
||||
/>
|
||||
{model.billing_mode === 'tiered_expr' && model.billing_expr && (
|
||||
<StatusBadge variant='warning' size='md'>
|
||||
{isSpecialExpression
|
||||
? t('Special billing expression')
|
||||
: t('Dynamic Pricing')}
|
||||
</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
<div className='text-muted-foreground mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm'>
|
||||
{model.vendor_name && <span>{model.vendor_name}</span>}
|
||||
{model.vendor_name && (
|
||||
<span className='text-muted-foreground/40'>·</span>
|
||||
)}
|
||||
<span>
|
||||
{model.quota_type === QUOTA_TYPE_VALUES.TOKEN
|
||||
? t('Token-based')
|
||||
: t('Per Request')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{description && (
|
||||
<p className='text-muted-foreground max-w-3xl text-sm leading-relaxed'>
|
||||
<p className='text-muted-foreground mt-2 text-sm leading-relaxed'>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(tags.length > 0 || endpoints.length > 0) && (
|
||||
<div className='flex flex-wrap gap-1.5'>
|
||||
{tags.map((tag) => (
|
||||
<StatusBadge key={`tag-${tag}`} variant='neutral' size='md'>
|
||||
{tag}
|
||||
</StatusBadge>
|
||||
))}
|
||||
{endpoints.map((endpoint) => (
|
||||
<StatusBadge
|
||||
key={`endpoint-${endpoint}`}
|
||||
variant='neutral'
|
||||
size='md'
|
||||
>
|
||||
{endpoint}
|
||||
</StatusBadge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -664,16 +648,16 @@ function PriceSection(props: {
|
||||
if (dynamicSummary.isSpecialExpression) {
|
||||
return (
|
||||
<section>
|
||||
<SectionTitle>{t('Pricing')}</SectionTitle>
|
||||
<div className='border-warning/30 bg-warning/10 rounded-xl border p-4'>
|
||||
<div className='text-warning text-sm font-medium'>
|
||||
<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-sm font-medium text-amber-800 dark:text-amber-200'>
|
||||
{t('Special billing expression')}
|
||||
</div>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
<p className='text-muted-foreground mt-1 text-xs'>
|
||||
{t('Unable to parse structured pricing')}
|
||||
</p>
|
||||
<div className='mt-3'>
|
||||
<div className='text-muted-foreground mb-1 text-xs font-medium'>
|
||||
<div className='text-muted-foreground mb-1 text-[10px] font-medium tracking-wider uppercase'>
|
||||
{t('Raw expression')}
|
||||
</div>
|
||||
<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'>
|
||||
@@ -685,37 +669,55 @@ function PriceSection(props: {
|
||||
)
|
||||
}
|
||||
|
||||
const priceRows = [
|
||||
...dynamicSummary.primaryEntries,
|
||||
...dynamicSummary.secondaryEntries,
|
||||
]
|
||||
|
||||
return (
|
||||
<section>
|
||||
<SectionTitle>{t('Pricing')}</SectionTitle>
|
||||
<div className='overflow-hidden rounded-xl border'>
|
||||
<div className='flex items-baseline justify-between gap-3 border-b px-4 py-3'>
|
||||
<span className='text-sm font-medium'>{t('Text tokens')}</span>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{t('Prices shown per')} {tokenUnitLabel} {t('tokens')}
|
||||
</span>
|
||||
</div>
|
||||
<div className='divide-y'>
|
||||
{priceRows.map((entry) => (
|
||||
<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='flex items-baseline justify-between gap-4 px-4 py-3'
|
||||
className='bg-muted/20 rounded-lg border p-3'
|
||||
>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{t(entry.shortLabel)}
|
||||
</span>
|
||||
<span className='text-sm font-medium tabular-nums'>
|
||||
</div>
|
||||
<div className='text-foreground mt-1 font-mono text-base font-semibold tabular-nums'>
|
||||
{entry.formatted}
|
||||
</span>
|
||||
<span className='text-muted-foreground/40 ml-1 text-xs font-normal'>
|
||||
/ {tokenUnitLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{t('Dynamic Pricing')}
|
||||
</p>
|
||||
)}
|
||||
{dynamicSummary.secondaryEntries.length > 0 && (
|
||||
<div className='bg-muted/20 mt-3 rounded-lg border px-3 py-2.5'>
|
||||
<div className='space-y-1.5'>
|
||||
{dynamicSummary.secondaryEntries.map((entry) => (
|
||||
<div
|
||||
key={entry.key}
|
||||
className='flex items-baseline justify-between gap-4'
|
||||
>
|
||||
<span className='text-muted-foreground/70 text-sm'>
|
||||
{t(entry.shortLabel)}
|
||||
</span>
|
||||
<span className='text-muted-foreground font-mono text-sm tabular-nums'>
|
||||
{entry.formatted}
|
||||
<span className='text-muted-foreground/40 ml-1 text-xs font-normal'>
|
||||
/ {tokenUnitLabel}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -723,72 +725,77 @@ function PriceSection(props: {
|
||||
if (!isTokenBased) {
|
||||
return (
|
||||
<section>
|
||||
<SectionTitle>{t('Pricing')}</SectionTitle>
|
||||
<div className='overflow-hidden rounded-xl border'>
|
||||
<div className='flex items-baseline justify-between gap-4 px-4 py-3'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
{t('Per request')}
|
||||
</span>
|
||||
<span className='text-sm font-medium tabular-nums'>
|
||||
{formatFixedPrice(
|
||||
props.model,
|
||||
baseGroupKey,
|
||||
props.showRechargePrice,
|
||||
props.priceRate,
|
||||
props.usdExchangeRate,
|
||||
baseGroupRatioMap
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<SectionTitle>{t('Base Price')}</SectionTitle>
|
||||
<div className='flex items-baseline justify-between'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
{t('Per request')}
|
||||
</span>
|
||||
<span className='text-foreground font-mono text-sm font-semibold tabular-nums'>
|
||||
{formatFixedPrice(
|
||||
props.model,
|
||||
baseGroupKey,
|
||||
props.showRechargePrice,
|
||||
props.priceRate,
|
||||
props.usdExchangeRate,
|
||||
baseGroupRatioMap
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
)}
|
||||
<span className='text-muted-foreground/40 ml-1 text-xs font-normal'>
|
||||
/ {tokenUnitLabel}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<section>
|
||||
<SectionTitle>{t('Pricing')}</SectionTitle>
|
||||
<div className='overflow-hidden rounded-xl border'>
|
||||
<div className='flex items-baseline justify-between gap-3 border-b px-4 py-3'>
|
||||
<span className='text-sm font-medium'>{t('Text tokens')}</span>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{t('Prices shown per')} {tokenUnitLabel} {t('tokens')}
|
||||
</span>
|
||||
</div>
|
||||
<div className='divide-y'>
|
||||
{priceRows.map((item) => (
|
||||
<div
|
||||
key={item.type}
|
||||
className='flex items-baseline justify-between gap-4 px-4 py-3'
|
||||
>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
{item.label}
|
||||
</span>
|
||||
<span className='text-sm font-medium tabular-nums'>
|
||||
{formatGroupPrice(
|
||||
props.model,
|
||||
baseGroupKey,
|
||||
item.type,
|
||||
props.tokenUnit,
|
||||
props.showRechargePrice,
|
||||
props.priceRate,
|
||||
props.usdExchangeRate,
|
||||
baseGroupRatioMap
|
||||
)}
|
||||
</span>
|
||||
<SectionTitle>{t('Base Price')}</SectionTitle>
|
||||
<div className='grid grid-cols-2 gap-2'>
|
||||
{primaryPriceTypes.map((item) => (
|
||||
<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)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{secondaryItems.length > 0 && (
|
||||
<div className='bg-muted/20 mt-3 rounded-lg border px-3 py-2.5'>
|
||||
<div className='space-y-1.5'>
|
||||
{secondaryItems.map((item) => (
|
||||
<div
|
||||
key={item.type}
|
||||
className='flex items-baseline justify-between gap-4'
|
||||
>
|
||||
<span className='text-muted-foreground/70 text-sm'>
|
||||
{item.label}
|
||||
</span>
|
||||
<span className='text-muted-foreground font-mono text-sm tabular-nums'>
|
||||
{renderPrice(item.type)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -832,13 +839,13 @@ function getDynamicPriceFields(
|
||||
tiers: DynamicPricingTier[],
|
||||
options: DynamicPriceOptions
|
||||
) {
|
||||
return [
|
||||
...new Map(
|
||||
return Array.from(
|
||||
new Map(
|
||||
tiers
|
||||
.flatMap((tier) => getDynamicPriceEntries(tier, options))
|
||||
.map((entry) => [entry.field, entry])
|
||||
).values(),
|
||||
]
|
||||
).values()
|
||||
)
|
||||
}
|
||||
|
||||
function getDynamicFormattedPricesByTier(
|
||||
@@ -885,24 +892,19 @@ function GroupPricingSection(props: {
|
||||
|
||||
const extraPriceTypes = useMemo(() => {
|
||||
const types: { label: string; type: PriceType }[] = []
|
||||
if (props.model.cache_ratio != null) {
|
||||
if (props.model.cache_ratio != null)
|
||||
types.push({ label: t('Cache'), type: 'cache' })
|
||||
}
|
||||
if (props.model.create_cache_ratio != null) {
|
||||
if (props.model.create_cache_ratio != null)
|
||||
types.push({ label: t('Cache Write'), type: 'create_cache' })
|
||||
}
|
||||
if (props.model.image_ratio != null) {
|
||||
if (props.model.image_ratio != null)
|
||||
types.push({ label: t('Image'), type: 'image' })
|
||||
}
|
||||
if (props.model.audio_ratio != null) {
|
||||
if (props.model.audio_ratio != null)
|
||||
types.push({ label: t('Audio In'), type: 'audio_input' })
|
||||
}
|
||||
if (
|
||||
props.model.audio_ratio != null &&
|
||||
props.model.audio_completion_ratio != null
|
||||
) {
|
||||
)
|
||||
types.push({ label: t('Audio Out'), type: 'audio_output' })
|
||||
}
|
||||
return types
|
||||
}, [props.model, t])
|
||||
|
||||
@@ -920,7 +922,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 +933,8 @@ function GroupPricingSection(props: {
|
||||
<section>
|
||||
<SectionTitle>{t('Pricing by Group')}</SectionTitle>
|
||||
<AutoGroupChain model={props.model} autoGroups={props.autoGroups} />
|
||||
<div className='border-warning/30 bg-warning/10 rounded-lg border p-3'>
|
||||
<div className='text-warning text-sm font-medium'>
|
||||
<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-sm font-medium text-amber-800 dark:text-amber-200'>
|
||||
{t('Special billing expression')}
|
||||
</div>
|
||||
<p className='text-muted-foreground mt-1 text-xs'>
|
||||
@@ -940,7 +943,7 @@ function GroupPricingSection(props: {
|
||||
)}
|
||||
</p>
|
||||
<div className='mt-3'>
|
||||
<div className='text-muted-foreground mb-1 text-xs font-medium'>
|
||||
<div className='text-muted-foreground mb-1 text-[10px] font-medium tracking-wider uppercase'>
|
||||
{t('Raw expression')}
|
||||
</div>
|
||||
<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'>
|
||||
@@ -1025,7 +1028,7 @@ function GroupPricingSection(props: {
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<p className='text-muted-foreground/40 mt-1.5 text-xs'>
|
||||
<p className='text-muted-foreground/40 mt-1.5 text-[10px]'>
|
||||
{t('Prices shown per')} {tokenUnitLabel} tokens
|
||||
</p>
|
||||
</div>
|
||||
@@ -1116,7 +1119,7 @@ function GroupPricingSection(props: {
|
||||
/>
|
||||
<div className='-mx-4 sm:mx-0'>
|
||||
{isTokenBased && (
|
||||
<p className='text-muted-foreground/40 mt-1.5 px-4 text-xs sm:px-0'>
|
||||
<p className='text-muted-foreground/40 mt-1.5 px-4 text-[10px] sm:px-0'>
|
||||
{t('Prices shown per')} {tokenUnitLabel} tokens
|
||||
</p>
|
||||
)}
|
||||
@@ -1158,52 +1161,53 @@ export function ModelDetailsContent(props: ModelDetailsContentProps) {
|
||||
Boolean(props.model.billing_expr)
|
||||
|
||||
return (
|
||||
<div className='@container/details space-y-8'>
|
||||
<div className='@container/details space-y-4'>
|
||||
<ModelHeader model={props.model} />
|
||||
<ModelBackendQuickStats model={props.model} />
|
||||
<OverviewSummaryGrid model={props.model} />
|
||||
|
||||
<Tabs defaultValue='overview' className='gap-6'>
|
||||
<TabsList
|
||||
variant='line'
|
||||
className='w-full justify-start gap-6 overflow-x-auto overflow-y-hidden border-b p-0'
|
||||
>
|
||||
<Tabs defaultValue='overview' className='gap-4'>
|
||||
<TabsList className='bg-muted/60 grid w-full grid-cols-3 gap-1 rounded-lg p-1 group-data-horizontal/tabs:h-auto'>
|
||||
{TAB_VALUES.map((value) => {
|
||||
const Icon = TAB_META[value].icon
|
||||
return (
|
||||
<TabsTrigger
|
||||
key={value}
|
||||
value={value}
|
||||
className='flex-none gap-1.5 px-0.5 pb-3'
|
||||
className='h-8 min-w-0 gap-1.5 rounded-md px-3 text-xs sm:text-sm'
|
||||
>
|
||||
<Icon aria-hidden='true' className='size-3.5' />
|
||||
<Icon className='size-3.5' />
|
||||
<span className='truncate'>{t(TAB_META[value].labelKey)}</span>
|
||||
</TabsTrigger>
|
||||
)
|
||||
})}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='overview' className='space-y-8 outline-none'>
|
||||
<PriceSection
|
||||
model={props.model}
|
||||
priceRate={props.priceRate}
|
||||
usdExchangeRate={props.usdExchangeRate}
|
||||
tokenUnit={props.tokenUnit}
|
||||
showRechargePrice={showRechargePrice}
|
||||
/>
|
||||
{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 value='overview' className='space-y-6 outline-none'>
|
||||
<OverviewSummaryGrid model={props.model} />
|
||||
|
||||
<section className='bg-card/60 space-y-5 rounded-xl border p-4 shadow-sm'>
|
||||
<SectionTitle>{t('Pricing')}</SectionTitle>
|
||||
<PriceSection
|
||||
model={props.model}
|
||||
priceRate={props.priceRate}
|
||||
usdExchangeRate={props.usdExchangeRate}
|
||||
tokenUnit={props.tokenUnit}
|
||||
showRechargePrice={showRechargePrice}
|
||||
/>
|
||||
{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}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<ModelBackendDetailsSection model={props.model} />
|
||||
</TabsContent>
|
||||
|
||||
@@ -1222,6 +1226,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 (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side='right'
|
||||
className={sideDrawerContentClassName(
|
||||
'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>
|
||||
<SheetDescription>{t('Model details')}</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className='flex-1 overflow-y-auto px-4 pt-11 pb-5 sm:px-6 sm:pt-12 sm:pb-6'>
|
||||
<ModelDetailsContent {...contentProps} />
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
export function ModelDetails() {
|
||||
const { t } = useTranslation()
|
||||
const { modelId } = useParams({ from: '/pricing/$modelId/' })
|
||||
@@ -1253,8 +1290,8 @@ export function ModelDetails() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PublicLayout showMainContainer={false}>
|
||||
<div className='mx-auto max-w-6xl px-4 pt-20 pb-10 sm:px-6 lg:px-8'>
|
||||
<PublicLayout>
|
||||
<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' />
|
||||
@@ -1262,13 +1299,13 @@ export function ModelDetails() {
|
||||
<Skeleton className='h-4 w-full max-w-md' />
|
||||
</div>
|
||||
<div className='mt-6 grid grid-cols-2 gap-2 sm:grid-cols-4'>
|
||||
{['stats-a', 'stats-b', 'stats-c', 'stats-d'].map((key) => (
|
||||
<Skeleton key={key} className='h-16 w-full' />
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className='h-16 w-full' />
|
||||
))}
|
||||
</div>
|
||||
<div className='mt-6 space-y-3'>
|
||||
{['block-a', 'block-b', 'block-c', 'block-d'].map((key) => (
|
||||
<Skeleton key={key} className='h-24 w-full' />
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className='h-24 w-full' />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1278,15 +1315,15 @@ export function ModelDetails() {
|
||||
|
||||
if (!model) {
|
||||
return (
|
||||
<PublicLayout showMainContainer={false}>
|
||||
<div className='mx-auto max-w-2xl px-4 pt-24 pb-10 text-center sm:px-6'>
|
||||
<PublicLayout>
|
||||
<div className='mx-auto max-w-2xl px-4 text-center sm:px-6'>
|
||||
<h2 className='mb-1 text-base font-semibold'>
|
||||
{t('Model not found')}
|
||||
</h2>
|
||||
<p className='text-muted-foreground mb-4 text-sm'>
|
||||
{t("The model you're looking for doesn't exist.")}
|
||||
</p>
|
||||
<Button onClick={handleBack} variant='outline'>
|
||||
<Button onClick={handleBack} variant='outline' size='sm'>
|
||||
{t('Back to Models')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1295,14 +1332,15 @@ export function ModelDetails() {
|
||||
}
|
||||
|
||||
return (
|
||||
<PublicLayout showMainContainer={false}>
|
||||
<PageTransition className='mx-auto max-w-5xl px-4 pt-20 pb-16 sm:px-6 lg:px-8'>
|
||||
<PublicLayout>
|
||||
<div className='mx-auto max-w-5xl px-4 sm:px-6'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={handleBack}
|
||||
className='text-muted-foreground hover:text-foreground mb-8 -ml-2'
|
||||
className='text-muted-foreground hover:text-foreground mb-4 h-auto gap-1 px-0 py-1 text-xs'
|
||||
>
|
||||
<ArrowLeft aria-hidden='true' />
|
||||
<ArrowLeft className='size-3.5' />
|
||||
{t('Back')}
|
||||
</Button>
|
||||
|
||||
@@ -1322,7 +1360,7 @@ export function ModelDetails() {
|
||||
>) || {}
|
||||
}
|
||||
/>
|
||||
</PageTransition>
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ export const ModelPerfBadge = memo(function ModelPerfBadge(
|
||||
)}
|
||||
>
|
||||
<div title={t('Average latency')} className='min-w-0'>
|
||||
<div className='text-muted-foreground/55 text-xs leading-4'>
|
||||
<div className='text-muted-foreground/55 text-[10px] leading-4'>
|
||||
{t('Latency short')}
|
||||
</div>
|
||||
<div className='text-muted-foreground/80 font-mono text-xs leading-4 whitespace-nowrap'>
|
||||
@@ -87,7 +87,7 @@ export const ModelPerfBadge = memo(function ModelPerfBadge(
|
||||
</div>
|
||||
</div>
|
||||
<div title={t('Throughput')} className='min-w-0'>
|
||||
<div className='text-muted-foreground/55 truncate text-xs leading-4'>
|
||||
<div className='text-muted-foreground/55 truncate text-[10px] leading-4'>
|
||||
{t('Throughput short')}
|
||||
</div>
|
||||
<div className='text-muted-foreground/80 font-mono text-xs leading-4 whitespace-nowrap'>
|
||||
@@ -98,7 +98,7 @@ export const ModelPerfBadge = memo(function ModelPerfBadge(
|
||||
title={`${t('Success rate')}: ${success_rate.toFixed(1)}%`}
|
||||
className='min-w-0'
|
||||
>
|
||||
<div className='text-muted-foreground/55 truncate text-xs leading-4'>
|
||||
<div className='text-muted-foreground/55 truncate text-[10px] leading-4'>
|
||||
{t('Status short')}
|
||||
</div>
|
||||
<div className='flex h-4 items-center justify-end gap-0.5'>
|
||||
|
||||
+330
-261
@@ -17,16 +17,18 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
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 { 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 +40,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,304 +52,368 @@ export interface PricingColumnsOptions {
|
||||
usdExchangeRate?: number
|
||||
showRechargePrice?: boolean
|
||||
selectedGroup?: string
|
||||
perfMap?: Map<string, ModelPerfBadgeData>
|
||||
}
|
||||
|
||||
type PriceColumnType = Extract<PriceType, 'input' | 'cache' | 'output'>
|
||||
|
||||
const DYNAMIC_FIELD_BY_PRICE_TYPE: Record<PriceColumnType, string> = {
|
||||
input: 'inputPrice',
|
||||
cache: 'cacheReadPrice',
|
||||
output: 'outputPrice',
|
||||
}
|
||||
|
||||
function renderEmptyCell(align: 'left' | 'right' = 'left'): ReactNode {
|
||||
const dash = (
|
||||
<span className='text-muted-foreground/50 text-sm tabular-nums'>—</span>
|
||||
)
|
||||
if (align === 'right') {
|
||||
return <div className='text-right'>{dash}</div>
|
||||
}
|
||||
return dash
|
||||
}
|
||||
|
||||
function renderEmptyPrice(): ReactNode {
|
||||
return renderEmptyCell('right')
|
||||
}
|
||||
|
||||
function renderPriceCell(
|
||||
props: {
|
||||
model: PricingModel
|
||||
priceType: PriceColumnType
|
||||
options: Required<
|
||||
Omit<PricingColumnsOptions, 'selectedGroup' | 'perfMap'>
|
||||
> & {
|
||||
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 (
|
||||
<div className='max-w-36'>
|
||||
<p className='text-warning text-xs font-medium'>
|
||||
{t('Special billing expression')}
|
||||
</p>
|
||||
<p className='text-muted-foreground mt-0.5 text-xs'>
|
||||
{t('View details')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (dynamicSummary) {
|
||||
const entry = dynamicSummary.entries.find(
|
||||
(item) => item.field === DYNAMIC_FIELD_BY_PRICE_TYPE[props.priceType]
|
||||
)
|
||||
if (!entry) return renderEmptyPrice()
|
||||
|
||||
return (
|
||||
<div className='text-right'>
|
||||
<p className='text-sm font-medium tabular-nums'>
|
||||
{stripTrailingZeros(entry.formatted)}
|
||||
</p>
|
||||
<p className='text-muted-foreground mt-0.5 text-xs'>
|
||||
/ {tokenUnitLabel} {t('tokens')}
|
||||
{dynamicSummary.tierCount > 1 &&
|
||||
` · ${t('{{count}} tiers', {
|
||||
count: dynamicSummary.tierCount,
|
||||
})}`}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isTokenBasedModel(props.model)) {
|
||||
if (props.priceType !== 'input') return renderEmptyPrice()
|
||||
return (
|
||||
<div className='text-right'>
|
||||
<p className='text-sm font-medium tabular-nums'>
|
||||
{stripTrailingZeros(
|
||||
formatRequestPrice(
|
||||
props.model,
|
||||
props.options.showRechargePrice,
|
||||
props.options.priceRate,
|
||||
props.options.usdExchangeRate,
|
||||
props.options.selectedGroup
|
||||
)
|
||||
)}
|
||||
</p>
|
||||
<p className='text-muted-foreground mt-0.5 text-xs'>/ {t('request')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (props.priceType === 'cache' && props.model.cache_ratio == null) {
|
||||
return renderEmptyPrice()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='text-right'>
|
||||
<p className='text-sm font-medium tabular-nums'>
|
||||
{stripTrailingZeros(
|
||||
formatPrice(
|
||||
props.model,
|
||||
props.priceType,
|
||||
props.options.tokenUnit,
|
||||
props.options.showRechargePrice,
|
||||
props.options.priceRate,
|
||||
props.options.usdExchangeRate,
|
||||
props.options.selectedGroup
|
||||
)
|
||||
)}
|
||||
</p>
|
||||
<p className='text-muted-foreground mt-0.5 text-xs'>
|
||||
/ {tokenUnitLabel} {t('tokens')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function usePricingColumns(
|
||||
options: PricingColumnsOptions = {}
|
||||
): ColumnDef<PricingModel>[] {
|
||||
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 }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Model')} />
|
||||
),
|
||||
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 (
|
||||
<div className='flex min-w-0 items-start gap-3 py-1'>
|
||||
<div className='bg-muted flex size-8 shrink-0 items-center justify-center rounded-md'>
|
||||
{modelIcon || (
|
||||
<span className='text-muted-foreground text-xs font-medium'>
|
||||
{model.model_name?.charAt(0).toUpperCase() || '?'}
|
||||
</span>
|
||||
)}
|
||||
<div className='flex max-w-full min-w-0 items-center gap-2'>
|
||||
{modelIcon}
|
||||
<span className='truncate font-mono text-sm font-medium'>
|
||||
{model.model_name}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
minSize: 200,
|
||||
},
|
||||
|
||||
// Type column
|
||||
{
|
||||
accessorKey: 'quota_type',
|
||||
header: t('Type'),
|
||||
cell: ({ row }) => {
|
||||
const isTokenBased = row.original.quota_type === QUOTA_TYPE_VALUES.TOKEN
|
||||
return (
|
||||
<StatusBadge
|
||||
label={isTokenBased ? t('Token') : t('Request')}
|
||||
variant={isTokenBased ? 'info' : 'neutral'}
|
||||
copyable={false}
|
||||
/>
|
||||
)
|
||||
},
|
||||
size: 80,
|
||||
enableSorting: false,
|
||||
},
|
||||
|
||||
// Price column
|
||||
{
|
||||
accessorKey: 'price',
|
||||
meta: { label: t('Price') },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Price')} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const model = row.original
|
||||
const dynamicSummary = getDynamicPricingSummary(model, {
|
||||
tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
groupRatioMultiplier: getDynamicDisplayGroupRatio(
|
||||
model,
|
||||
selectedGroup
|
||||
),
|
||||
})
|
||||
|
||||
if (dynamicSummary) {
|
||||
if (dynamicSummary.isSpecialExpression) {
|
||||
return (
|
||||
<div className='max-w-full min-w-0'>
|
||||
<div className='text-xs font-medium text-amber-700 dark:text-amber-300'>
|
||||
{t('Special billing expression')}
|
||||
</div>
|
||||
<div className='text-muted-foreground text-[11px]'>
|
||||
{t('Unable to parse structured pricing')}
|
||||
</div>
|
||||
<code className='text-muted-foreground/70 mt-1 line-clamp-2 block font-mono text-[10px] leading-relaxed break-all'>
|
||||
{dynamicSummary.rawExpression}
|
||||
</code>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const primaryEntries = dynamicSummary.primaryEntries.slice(0, 2)
|
||||
if (primaryEntries.length === 0) {
|
||||
return (
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{t('Dynamic Pricing')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='max-w-full min-w-0'>
|
||||
<span className='font-mono text-sm tabular-nums'>
|
||||
{primaryEntries.map((entry, index) => (
|
||||
<span key={entry.key}>
|
||||
{index > 0 && (
|
||||
<span className='text-muted-foreground/40 mx-1'>/</span>
|
||||
)}
|
||||
{stripTrailingZeros(entry.formatted)}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
<div className='text-muted-foreground/50 text-[10px]'>
|
||||
/ {tokenUnitLabel} tokens
|
||||
{dynamicSummary.tierCount > 1 &&
|
||||
` · ${t('{{count}} tiers', {
|
||||
count: dynamicSummary.tierCount,
|
||||
})}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className='min-w-0'>
|
||||
<p className='truncate font-mono text-sm font-medium'>
|
||||
{model.model_name}
|
||||
</p>
|
||||
<p className='text-muted-foreground mt-0.5 line-clamp-1 text-xs'>
|
||||
{model.vendor_name ||
|
||||
model.description ||
|
||||
(isTokenBasedModel(model)
|
||||
? t('Token-based')
|
||||
: t('Per Request'))}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className='max-w-full min-w-0'>
|
||||
<span className='font-mono text-sm tabular-nums'>
|
||||
{inputPrice}
|
||||
<span className='text-muted-foreground/40 mx-1'>/</span>
|
||||
{outputPrice}
|
||||
</span>
|
||||
<div className='text-muted-foreground/50 text-[10px]'>
|
||||
/ {tokenUnitLabel} tokens
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const price = stripTrailingZeros(
|
||||
formatRequestPrice(
|
||||
model,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
selectedGroup
|
||||
)
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='max-w-full min-w-0'>
|
||||
<span className='font-mono text-sm tabular-nums'>{price}</span>
|
||||
<div className='text-muted-foreground/50 text-[10px]'>
|
||||
/ {t('request')}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
minSize: 260,
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
id: 'input_price',
|
||||
header: () => <div className='text-right'>{t('Input')}</div>,
|
||||
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: () => <div className='text-right'>{t('Cached input')}</div>,
|
||||
cell: ({ row }) =>
|
||||
renderPriceCell(
|
||||
{
|
||||
model: row.original,
|
||||
priceType: 'cache',
|
||||
options: priceOptions,
|
||||
},
|
||||
t
|
||||
),
|
||||
size: 130,
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
id: 'output_price',
|
||||
header: () => <div className='text-right'>{t('Output')}</div>,
|
||||
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 (
|
||||
<span className='text-muted-foreground/50 text-xs'>
|
||||
{t('Special billing expression')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const cacheEntry = dynamicSummary.entries.find(
|
||||
(entry) => entry.field === 'cacheReadPrice'
|
||||
)
|
||||
if (!cacheEntry) {
|
||||
return <span className='text-muted-foreground/30 text-xs'>—</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='max-w-full min-w-0'>
|
||||
<span className='font-mono text-sm tabular-nums'>
|
||||
{stripTrailingZeros(cacheEntry.formatted)}
|
||||
</span>
|
||||
<div className='text-muted-foreground/50 text-[10px]'>
|
||||
/ {tokenUnitLabel}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <ModelPerfBadge perf={perf} className='grid' />
|
||||
|
||||
const isTokenBased = isTokenBasedModel(model)
|
||||
|
||||
if (!isTokenBased || model.cache_ratio == null) {
|
||||
return <span className='text-muted-foreground/30 text-xs'>—</span>
|
||||
}
|
||||
|
||||
const cachedPrice = stripTrailingZeros(
|
||||
formatPrice(
|
||||
model,
|
||||
'cache',
|
||||
tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
selectedGroup
|
||||
)
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='max-w-full min-w-0'>
|
||||
<span className='font-mono text-sm tabular-nums'>
|
||||
{cachedPrice}
|
||||
</span>
|
||||
<div className='text-muted-foreground/50 text-[10px]'>
|
||||
/ {tokenUnitLabel}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
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 <span className='text-muted-foreground/50 text-xs'>—</span>
|
||||
}
|
||||
const vendorIcon = model.vendor_icon
|
||||
? getLobeIcon(model.vendor_icon, 12)
|
||||
: null
|
||||
return (
|
||||
<BadgeCell className='gap-1.5'>
|
||||
{vendorIcon}
|
||||
<StatusBadge
|
||||
label={model.vendor_name}
|
||||
autoColor={model.vendor_name}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
</BadgeCell>
|
||||
)
|
||||
},
|
||||
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 (
|
||||
<BadgeListCell
|
||||
items={tags.map((tag) => (
|
||||
<StatusBadge key={tag} variant='neutral' size='md'>
|
||||
{tag}
|
||||
</StatusBadge>
|
||||
<StatusBadge
|
||||
key={tag}
|
||||
label={tag}
|
||||
autoColor={tag}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
))}
|
||||
/>
|
||||
)
|
||||
},
|
||||
size: 160,
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: 'supported_endpoint_types',
|
||||
header: t('Endpoints'),
|
||||
cell: ({ row }) => {
|
||||
const endpoints = row.original.supported_endpoint_types || []
|
||||
if (endpoints.length === 0) {
|
||||
return renderEmptyCell()
|
||||
}
|
||||
return (
|
||||
<BadgeListCell
|
||||
items={endpoints.map((endpoint) => (
|
||||
<StatusBadge key={endpoint} variant='neutral' size='md'>
|
||||
{endpoint}
|
||||
</StatusBadge>
|
||||
))}
|
||||
/>
|
||||
)
|
||||
},
|
||||
size: 150,
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: 'enable_groups',
|
||||
header: t('Groups'),
|
||||
cell: ({ row }) => {
|
||||
const groups = row.original.enable_groups || []
|
||||
if (groups.length === 0) {
|
||||
return renderEmptyCell()
|
||||
}
|
||||
return (
|
||||
<BadgeListCell
|
||||
items={groups.map((group) => (
|
||||
<GroupBadge key={group} group={group} size='md' />
|
||||
))}
|
||||
tooltipClassName='max-w-72 p-2'
|
||||
/>
|
||||
)
|
||||
},
|
||||
size: 140,
|
||||
enableSorting: false,
|
||||
},
|
||||
|
||||
// Endpoints column
|
||||
{
|
||||
accessorKey: 'supported_endpoint_types',
|
||||
header: t('Endpoints'),
|
||||
cell: ({ row }) => {
|
||||
const endpoints = row.original.supported_endpoint_types || []
|
||||
return (
|
||||
<BadgeListCell
|
||||
items={endpoints.map((ep) => (
|
||||
<StatusBadge
|
||||
key={ep}
|
||||
label={ep}
|
||||
autoColor={ep}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
))}
|
||||
/>
|
||||
)
|
||||
},
|
||||
size: 130,
|
||||
enableSorting: false,
|
||||
},
|
||||
|
||||
// Enable Groups column
|
||||
{
|
||||
accessorKey: 'enable_groups',
|
||||
header: t('Groups'),
|
||||
cell: ({ row }) => {
|
||||
const groups = row.original.enable_groups || []
|
||||
return (
|
||||
<BadgeListCell
|
||||
items={groups.map((group) => (
|
||||
<GroupBadge key={group} group={group} size='sm' />
|
||||
))}
|
||||
tooltipClassName='max-w-[280px] p-2'
|
||||
/>
|
||||
)
|
||||
},
|
||||
size: 130,
|
||||
enableSorting: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
+43
-33
@@ -20,7 +20,8 @@ import { ChevronDown, RotateCcw } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
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) && (
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-md px-1.5 py-0.5 text-xs',
|
||||
'rounded-md px-1.5 py-0.5 text-[10px]',
|
||||
props.active
|
||||
? 'bg-background text-foreground'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
@@ -129,15 +130,15 @@ function FilterChip(props: {
|
||||
|
||||
function FilterSection(props: FilterSectionProps) {
|
||||
return (
|
||||
<Collapsible defaultOpen className='border-b pb-3 last:border-b-0'>
|
||||
<Collapsible
|
||||
defaultOpen
|
||||
className='border-border/70 border-b pb-3 last:border-b-0'
|
||||
>
|
||||
<CollapsibleTrigger className='group flex w-full items-center justify-between py-2.5 text-left'>
|
||||
<span className='text-foreground text-sm font-medium'>
|
||||
<span className='text-foreground text-sm font-semibold'>
|
||||
{props.title}
|
||||
</span>
|
||||
<ChevronDown
|
||||
aria-hidden='true'
|
||||
className='text-muted-foreground size-4 transition-transform group-data-[panel-open]:rotate-180'
|
||||
/>
|
||||
<ChevronDown className='text-muted-foreground size-4 transition-transform group-data-[panel-open]:rotate-180' />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className='flex flex-wrap gap-1.5'>
|
||||
@@ -245,25 +246,40 @@ export function PricingSidebar(props: PricingSidebarProps) {
|
||||
]
|
||||
|
||||
return (
|
||||
<aside className={cn('rounded-lg border p-3', props.className)}>
|
||||
<div className='mb-2 flex items-center justify-between gap-2'>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{props.hasActiveFilters
|
||||
? t('Filters active')
|
||||
: t('Refine models by provider, group, type, and tags.')}
|
||||
</p>
|
||||
<aside className={cn('rounded-xl border p-3', props.className)}>
|
||||
<div className='mb-2.5 flex items-center justify-between gap-2'>
|
||||
<div>
|
||||
<h2 className='text-foreground text-sm font-bold'>{t('Filter')}</h2>
|
||||
<p className='text-muted-foreground mt-1 text-xs'>
|
||||
{t('Refine models by provider, group, type, and tags.')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={props.onClearFilters}
|
||||
disabled={!props.hasActiveFilters}
|
||||
className='h-7 gap-1.5 px-2 text-xs'
|
||||
>
|
||||
<RotateCcw aria-hidden='true' />
|
||||
<RotateCcw className='size-3.5' />
|
||||
{t('Reset')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{props.hasActiveFilters && (
|
||||
<Badge variant='secondary' className='mb-3'>
|
||||
{t('Filters active')}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<div className='space-y-1'>
|
||||
<FilterSection
|
||||
title={t('Groups')}
|
||||
value={props.groupFilter}
|
||||
options={groupOptions}
|
||||
onChange={props.onGroupChange}
|
||||
/>
|
||||
<FilterSection
|
||||
title={t('All Vendors')}
|
||||
value={props.vendorFilter}
|
||||
@@ -271,10 +287,10 @@ export function PricingSidebar(props: PricingSidebarProps) {
|
||||
onChange={props.onVendorChange}
|
||||
/>
|
||||
<FilterSection
|
||||
title={t('Endpoint Type')}
|
||||
value={props.endpointTypeFilter}
|
||||
options={endpointOptions}
|
||||
onChange={props.onEndpointTypeChange}
|
||||
title={t('Model Tags')}
|
||||
value={props.tagFilter}
|
||||
options={tagOptions}
|
||||
onChange={props.onTagChange}
|
||||
/>
|
||||
<FilterSection
|
||||
title={t('Pricing Type')}
|
||||
@@ -283,16 +299,10 @@ export function PricingSidebar(props: PricingSidebarProps) {
|
||||
onChange={props.onQuotaTypeChange}
|
||||
/>
|
||||
<FilterSection
|
||||
title={t('Groups')}
|
||||
value={props.groupFilter}
|
||||
options={groupOptions}
|
||||
onChange={props.onGroupChange}
|
||||
/>
|
||||
<FilterSection
|
||||
title={t('Model Tags')}
|
||||
value={props.tagFilter}
|
||||
options={tagOptions}
|
||||
onChange={props.onTagChange}
|
||||
title={t('Endpoint Type')}
|
||||
value={props.endpointTypeFilter}
|
||||
options={endpointOptions}
|
||||
onChange={props.onEndpointTypeChange}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -16,9 +16,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { Row, PaginationState } from '@tanstack/react-table'
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react'
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
@@ -27,11 +26,9 @@ import {
|
||||
DataTableView,
|
||||
useDataTable,
|
||||
} from '@/components/data-table'
|
||||
import { getPerfMetricsSummary } from '@/features/performance-metrics/api'
|
||||
|
||||
import { DEFAULT_PRICING_PAGE_SIZE, DEFAULT_TOKEN_UNIT } from '../constants'
|
||||
import type { PricingModel, TokenUnit } from '../types'
|
||||
import type { ModelPerfBadgeData } from './model-perf-badge'
|
||||
import { usePricingColumns } from './pricing-columns'
|
||||
|
||||
export interface PricingTableProps {
|
||||
@@ -63,34 +60,12 @@ export function PricingTable(props: PricingTableProps) {
|
||||
pageSize: DEFAULT_PRICING_PAGE_SIZE,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setPagination((current) =>
|
||||
current.pageIndex === 0 ? current : { ...current, pageIndex: 0 }
|
||||
)
|
||||
}, [models])
|
||||
|
||||
const perfQuery = useQuery({
|
||||
queryKey: ['perf-metrics-summary', 24],
|
||||
queryFn: () => getPerfMetricsSummary(24),
|
||||
staleTime: 60 * 1000,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
const perfMap = useMemo(() => {
|
||||
const map = new Map<string, ModelPerfBadgeData>()
|
||||
for (const model of perfQuery.data?.data?.models ?? []) {
|
||||
map.set(model.model_name, model)
|
||||
}
|
||||
return map
|
||||
}, [perfQuery.data])
|
||||
|
||||
const columns = usePricingColumns({
|
||||
tokenUnit,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
showRechargePrice,
|
||||
selectedGroup,
|
||||
perfMap,
|
||||
})
|
||||
|
||||
const { table } = useDataTable({
|
||||
@@ -112,15 +87,6 @@ export function PricingTable(props: PricingTableProps) {
|
||||
[onModelClick]
|
||||
)
|
||||
|
||||
const handleRowKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLTableRowElement>, model: PricingModel) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return
|
||||
event.preventDefault()
|
||||
handleRowClick(model)
|
||||
},
|
||||
[handleRowClick]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<DataTableView
|
||||
@@ -137,11 +103,8 @@ export function PricingTable(props: PricingTableProps) {
|
||||
<DataTableRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
tabIndex={0}
|
||||
aria-label={`${t('View details')}: ${row.original.model_name}`}
|
||||
className='hover:bg-muted/30 focus-visible:ring-ring cursor-pointer transition-colors focus-visible:ring-2 focus-visible:outline-none'
|
||||
className='hover:bg-muted/30 cursor-pointer transition-colors'
|
||||
onClick={() => handleRowClick(row.original)}
|
||||
onKeyDown={(event) => handleRowKeyDown(event, row.original)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
+142
-123
@@ -17,17 +17,16 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { ArrowUpDown, Check, Filter, Grid2X2, Table2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/design-system/tabs'
|
||||
import {
|
||||
sideDrawerContentClassName,
|
||||
sideDrawerFormClassName,
|
||||
sideDrawerHeaderClassName,
|
||||
} from '@/components/drawer-layout'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -56,12 +55,15 @@ import {
|
||||
} from '../constants'
|
||||
import type { PricingModel, PricingVendor, TokenUnit } from '../types'
|
||||
import { PricingSidebar } from './pricing-sidebar'
|
||||
import { SearchBar } from './search-bar'
|
||||
|
||||
type SegmentOption = {
|
||||
value: string
|
||||
label?: string
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
export interface PricingToolbarProps {
|
||||
searchInput: string
|
||||
onSearchChange: (value: string) => void
|
||||
onClearSearch: () => void
|
||||
filteredCount: number
|
||||
totalCount?: number
|
||||
sortBy: string
|
||||
@@ -92,117 +94,144 @@ export interface PricingToolbarProps {
|
||||
onClearFilters: () => void
|
||||
}
|
||||
|
||||
function PriceModeTabs(props: {
|
||||
value: 'standard' | 'recharge'
|
||||
onChange: (value: 'standard' | 'recharge') => void
|
||||
function SegmentedControl(props: {
|
||||
options: SegmentOption[]
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
ariaLabel: string
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
value={props.value}
|
||||
onValueChange={(value) =>
|
||||
props.onChange(value as 'standard' | 'recharge')
|
||||
}
|
||||
<div
|
||||
role='group'
|
||||
aria-label={props.ariaLabel}
|
||||
className='bg-muted/60 inline-flex h-8 items-center rounded-lg border p-0.5'
|
||||
>
|
||||
<TabsList aria-label={t('Price display mode')}>
|
||||
<TabsTrigger value='standard'>{t('Standard')}</TabsTrigger>
|
||||
<TabsTrigger value='recharge'>{t('Recharge')}</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
|
||||
function TokenUnitTabs(props: {
|
||||
value: TokenUnit
|
||||
onChange: (value: TokenUnit) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
value={props.value}
|
||||
onValueChange={(value) => props.onChange(value as TokenUnit)}
|
||||
>
|
||||
<TabsList aria-label={t('Token unit')}>
|
||||
<TabsTrigger value='M'>1M</TabsTrigger>
|
||||
<TabsTrigger value='K'>1K</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
|
||||
function ViewModeTabs(props: {
|
||||
value: ViewMode
|
||||
onChange: (value: ViewMode) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
value={props.value}
|
||||
onValueChange={(value) => props.onChange(value as ViewMode)}
|
||||
>
|
||||
<TabsList aria-label={t('View mode')}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={<TabsTrigger value={VIEW_MODES.TABLE} className='px-2' />}
|
||||
{props.options.map((option) => {
|
||||
const Icon = option.icon
|
||||
const isActive = option.value === props.value
|
||||
const button = (
|
||||
<button
|
||||
key={option.value}
|
||||
type='button'
|
||||
onClick={() => props.onChange(option.value)}
|
||||
aria-pressed={isActive}
|
||||
className={cn(
|
||||
'inline-flex h-full items-center justify-center rounded-md text-xs font-medium transition-all',
|
||||
Icon && !option.label ? 'w-7' : 'gap-1.5 px-3',
|
||||
isActive
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<Table2 aria-hidden='true' className='size-3.5' />
|
||||
<span className='sr-only'>{t('Table view')}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='bottom'>{t('Table view')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={<TabsTrigger value={VIEW_MODES.CARD} className='px-2' />}
|
||||
>
|
||||
<Grid2X2 aria-hidden='true' className='size-3.5' />
|
||||
<span className='sr-only'>{t('Card view')}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='bottom'>{t('Card view')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
{Icon && <Icon className='size-3.5' />}
|
||||
{option.label}
|
||||
</button>
|
||||
)
|
||||
|
||||
if (!option.tooltip) {
|
||||
return button
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip key={option.value}>
|
||||
<TooltipTrigger render={button}></TooltipTrigger>
|
||||
<TooltipContent side='bottom' className='text-xs'>
|
||||
{option.tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PricingToolbar(props: PricingToolbarProps) {
|
||||
const { t } = useTranslation()
|
||||
const [filtersOpen, setFiltersOpen] = useState(false)
|
||||
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false)
|
||||
const sortLabels = getSortLabels(t)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='flex flex-col gap-3 sm:flex-row sm:items-center'>
|
||||
<SearchBar
|
||||
value={props.searchInput}
|
||||
onChange={props.onSearchChange}
|
||||
onClear={props.onClearSearch}
|
||||
placeholder={t('Search model name, provider, endpoint, or tag...')}
|
||||
className='w-full sm:max-w-sm'
|
||||
/>
|
||||
const handleTokenUnitChange = useCallback(
|
||||
(value: string) => props.onTokenUnitChange(value as TokenUnit),
|
||||
[props]
|
||||
)
|
||||
|
||||
<div className='flex flex-wrap items-center gap-2 sm:ml-auto'>
|
||||
const handleViewModeChange = useCallback(
|
||||
(value: string) => props.onViewModeChange(value as ViewMode),
|
||||
[props]
|
||||
)
|
||||
|
||||
const handleRechargePriceChange = useCallback(
|
||||
(value: string) => props.onRechargePriceChange(value === 'recharge'),
|
||||
[props]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='rounded-xl border p-3'>
|
||||
<div className='flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => setFiltersOpen(true)}
|
||||
size='sm'
|
||||
onClick={() => setMobileFiltersOpen(true)}
|
||||
className='gap-1.5 xl:hidden'
|
||||
>
|
||||
<Filter aria-hidden='true' />
|
||||
<Filter className='size-4' />
|
||||
{t('Filter')}
|
||||
{props.activeFilterCount > 0 && (
|
||||
<StatusBadge variant='neutral' size='sm'>
|
||||
<Badge className='ml-0.5 size-5 justify-center p-0 text-[10px]'>
|
||||
{props.activeFilterCount}
|
||||
</StatusBadge>
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<div className='text-muted-foreground flex items-baseline gap-1 text-sm'>
|
||||
<span className='text-foreground font-semibold tabular-nums'>
|
||||
{props.filteredCount.toLocaleString()}
|
||||
</span>
|
||||
<span>{props.filteredCount === 1 ? t('model') : t('models')}</span>
|
||||
{props.hasActiveFilters && props.totalCount && (
|
||||
<span className='text-muted-foreground/60 text-xs'>
|
||||
/ {props.totalCount.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<div className='hidden items-center gap-2 sm:flex'>
|
||||
<SegmentedControl
|
||||
options={[
|
||||
{ value: 'standard', label: t('Standard') },
|
||||
{ value: 'recharge', label: t('Recharge') },
|
||||
]}
|
||||
value={props.showRechargePrice ? 'recharge' : 'standard'}
|
||||
onChange={handleRechargePriceChange}
|
||||
ariaLabel={t('Price display mode')}
|
||||
/>
|
||||
<SegmentedControl
|
||||
options={[
|
||||
{ value: 'M', label: '/1M' },
|
||||
{ value: 'K', label: '/1K' },
|
||||
]}
|
||||
value={props.tokenUnit}
|
||||
onChange={handleTokenUnitChange}
|
||||
ariaLabel={t('Token unit')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={<Button type='button' variant='outline' />}
|
||||
render={
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-8 gap-1.5 px-3 text-xs'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ArrowUpDown aria-hidden='true' />
|
||||
<ArrowUpDown className='size-3.5' />
|
||||
<span>{sortLabels[props.sortBy as SortOption] || t('Sort')}</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-44'>
|
||||
@@ -210,11 +239,11 @@ export function PricingToolbar(props: PricingToolbarProps) {
|
||||
<DropdownMenuItem
|
||||
key={value}
|
||||
onClick={() => props.onSortChange(value)}
|
||||
className='gap-2'
|
||||
>
|
||||
<Check
|
||||
aria-hidden='true'
|
||||
className={cn(
|
||||
'size-4',
|
||||
'size-4 shrink-0',
|
||||
props.sortBy === value ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
@@ -224,37 +253,27 @@ export function PricingToolbar(props: PricingToolbarProps) {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<PriceModeTabs
|
||||
value={props.showRechargePrice ? 'recharge' : 'standard'}
|
||||
onChange={(value) =>
|
||||
props.onRechargePriceChange(value === 'recharge')
|
||||
}
|
||||
/>
|
||||
<TokenUnitTabs
|
||||
value={props.tokenUnit}
|
||||
onChange={props.onTokenUnitChange}
|
||||
/>
|
||||
<ViewModeTabs
|
||||
<SegmentedControl
|
||||
options={[
|
||||
{
|
||||
value: VIEW_MODES.CARD,
|
||||
icon: Grid2X2,
|
||||
tooltip: t('Card view'),
|
||||
},
|
||||
{
|
||||
value: VIEW_MODES.TABLE,
|
||||
icon: Table2,
|
||||
tooltip: t('Table view'),
|
||||
},
|
||||
]}
|
||||
value={props.viewMode}
|
||||
onChange={props.onViewModeChange}
|
||||
onChange={handleViewModeChange}
|
||||
ariaLabel={t('View mode')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className='text-muted-foreground mt-3 text-sm'>
|
||||
<span className='text-foreground font-medium tabular-nums'>
|
||||
{props.filteredCount.toLocaleString()}
|
||||
</span>{' '}
|
||||
{props.filteredCount === 1 ? t('model') : t('models')}
|
||||
{props.hasActiveFilters && props.totalCount != null && (
|
||||
<span>
|
||||
{' '}
|
||||
{t('of')} {props.totalCount.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<Sheet open={filtersOpen} onOpenChange={setFiltersOpen}>
|
||||
<Sheet open={mobileFiltersOpen} onOpenChange={setMobileFiltersOpen}>
|
||||
<SheetContent
|
||||
side='right'
|
||||
className={sideDrawerContentClassName('sm:max-w-md')}
|
||||
@@ -284,7 +303,7 @@ export function PricingToolbar(props: PricingToolbarProps) {
|
||||
models={props.models}
|
||||
hasActiveFilters={props.hasActiveFilters}
|
||||
onClearFilters={props.onClearFilters}
|
||||
className='border-0 bg-transparent p-0'
|
||||
className='border-0 bg-transparent p-0 shadow-none'
|
||||
/>
|
||||
</div>
|
||||
</SheetContent>
|
||||
|
||||
+16
-15
@@ -20,8 +20,7 @@ import { Search, X } from 'lucide-react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import { Input } from '@/components/design-system/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface SearchBarProps {
|
||||
@@ -52,33 +51,35 @@ export function SearchBar(props: SearchBarProps) {
|
||||
|
||||
return (
|
||||
<div className={cn('relative', props.className)}>
|
||||
<Search
|
||||
aria-hidden='true'
|
||||
className='text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2'
|
||||
/>
|
||||
<Input
|
||||
<Search className='text-muted-foreground/60 pointer-events-none absolute top-1/2 left-3.5 size-4 -translate-y-1/2' />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type='search'
|
||||
type='text'
|
||||
placeholder={props.placeholder || t('Search models...')}
|
||||
value={props.value}
|
||||
onChange={(e) => props.onChange(e.target.value)}
|
||||
className='bg-background w-full pr-14 pl-8 [&::-webkit-search-cancel-button]:hidden'
|
||||
className={cn(
|
||||
'border-border/60 bg-background placeholder:text-muted-foreground/50',
|
||||
'hover:border-border',
|
||||
'focus:border-primary/50 focus:ring-primary/20 focus:ring-2',
|
||||
'h-10 w-full rounded-lg border pr-16 pl-10 text-sm transition-all outline-none'
|
||||
)}
|
||||
aria-label={t('Search models')}
|
||||
/>
|
||||
<div className='absolute top-1/2 right-1 flex -translate-y-1/2 items-center'>
|
||||
<div className='absolute top-1/2 right-2.5 flex -translate-y-1/2 items-center gap-1'>
|
||||
{props.value ? (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon-xs'
|
||||
size='icon'
|
||||
onClick={props.onClear}
|
||||
className='text-muted-foreground hover:text-foreground'
|
||||
className='text-muted-foreground/60 hover:text-foreground size-7'
|
||||
aria-label={t('Clear search')}
|
||||
>
|
||||
<X aria-hidden='true' className='size-3.5' />
|
||||
<X className='size-4' />
|
||||
</Button>
|
||||
) : (
|
||||
<kbd className='bg-muted text-muted-foreground pointer-events-none hidden rounded-md border px-1.5 py-0.5 font-mono text-xs sm:inline-block'>
|
||||
Ctrl K
|
||||
<kbd className='bg-muted text-muted-foreground pointer-events-none hidden rounded border px-1.5 py-0.5 font-mono text-[10px] sm:inline-block'>
|
||||
⌘K
|
||||
</kbd>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user