290 lines
8.0 KiB
TypeScript
Vendored
290 lines
8.0 KiB
TypeScript
Vendored
import type { ReactNode } from 'react'
|
|
import { ChevronDown, RotateCcw } from 'lucide-react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { getLobeIcon } from '@/lib/lobe-icon'
|
|
import { cn } from '@/lib/utils'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Button } from '@/components/ui/button'
|
|
import {
|
|
Collapsible,
|
|
CollapsibleContent,
|
|
CollapsibleTrigger,
|
|
} from '@/components/ui/collapsible'
|
|
import {
|
|
ENDPOINT_TYPES,
|
|
FILTER_ALL,
|
|
QUOTA_TYPES,
|
|
getEndpointTypeLabels,
|
|
getQuotaTypeLabels,
|
|
} from '../constants'
|
|
import { parseTags } from '../lib/filters'
|
|
import type { PricingModel, PricingVendor } from '../types'
|
|
|
|
type FilterOption = {
|
|
value: string
|
|
label: string
|
|
count?: number
|
|
suffix?: string
|
|
icon?: ReactNode
|
|
}
|
|
|
|
type FilterSectionProps = {
|
|
title: string
|
|
value: string
|
|
options: FilterOption[]
|
|
onChange: (value: string) => void
|
|
}
|
|
|
|
export interface PricingSidebarProps {
|
|
quotaTypeFilter: string
|
|
endpointTypeFilter: string
|
|
vendorFilter: string
|
|
groupFilter: string
|
|
tagFilter: string
|
|
onQuotaTypeChange: (value: string) => void
|
|
onEndpointTypeChange: (value: string) => void
|
|
onVendorChange: (value: string) => void
|
|
onGroupChange: (value: string) => void
|
|
onTagChange: (value: string) => void
|
|
vendors: PricingVendor[]
|
|
groups: string[]
|
|
groupRatios?: Record<string, number>
|
|
tags: string[]
|
|
models: PricingModel[]
|
|
hasActiveFilters: boolean
|
|
onClearFilters: () => void
|
|
className?: string
|
|
}
|
|
|
|
function countBy(
|
|
models: PricingModel[],
|
|
predicate: (model: PricingModel) => boolean
|
|
): number {
|
|
return models.reduce((count, model) => count + (predicate(model) ? 1 : 0), 0)
|
|
}
|
|
|
|
function formatGroupRatio(ratio: number | undefined): string | undefined {
|
|
if (ratio == null) return undefined
|
|
const formatted = Number.isInteger(ratio)
|
|
? ratio.toString()
|
|
: ratio.toFixed(3).replace(/0+$/, '').replace(/\.$/, '')
|
|
return `x${formatted}`
|
|
}
|
|
|
|
function FilterChip(props: {
|
|
option: FilterOption
|
|
active: boolean
|
|
onClick: () => void
|
|
}) {
|
|
return (
|
|
<button
|
|
type='button'
|
|
onClick={props.onClick}
|
|
className={cn(
|
|
'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-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}
|
|
>
|
|
{props.option.icon && <span className='shrink-0'>{props.option.icon}</span>}
|
|
<span className='truncate'>{props.option.label}</span>
|
|
{(props.option.suffix || props.option.count != null) && (
|
|
<span
|
|
className={cn(
|
|
'rounded-full px-1.5 py-0.5 text-[10px]',
|
|
props.active
|
|
? 'bg-background text-foreground'
|
|
: 'bg-muted text-muted-foreground'
|
|
)}
|
|
>
|
|
{props.option.suffix ?? props.option.count}
|
|
</span>
|
|
)}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
function FilterSection(props: FilterSectionProps) {
|
|
return (
|
|
<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-semibold'>
|
|
{props.title}
|
|
</span>
|
|
<ChevronDown className='text-muted-foreground size-4 transition-transform group-data-[state=open]:rotate-180' />
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent>
|
|
<div className='flex flex-wrap gap-1.5'>
|
|
{props.options.map((option) => (
|
|
<FilterChip
|
|
key={option.value}
|
|
option={option}
|
|
active={props.value === option.value}
|
|
onClick={() => props.onChange(option.value)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
)
|
|
}
|
|
|
|
export function PricingSidebar(props: PricingSidebarProps) {
|
|
const { t } = useTranslation()
|
|
const quotaTypeLabels = getQuotaTypeLabels(t)
|
|
const endpointTypeLabels = getEndpointTypeLabels(t)
|
|
|
|
const vendorOptions: FilterOption[] = [
|
|
{
|
|
value: FILTER_ALL,
|
|
label: t('All Vendors'),
|
|
count: props.models.length,
|
|
},
|
|
...props.vendors
|
|
.map((vendor) => ({
|
|
value: vendor.name,
|
|
label: vendor.name,
|
|
count: countBy(
|
|
props.models,
|
|
(model) => model.vendor_name === vendor.name
|
|
),
|
|
icon: vendor.icon ? getLobeIcon(vendor.icon, 14) : undefined,
|
|
}))
|
|
.filter((vendor) => vendor.count > 0),
|
|
]
|
|
|
|
const groupOptions: FilterOption[] = [
|
|
{
|
|
value: FILTER_ALL,
|
|
label: t('All Groups'),
|
|
},
|
|
...props.groups.map((group) => ({
|
|
value: group,
|
|
label: group,
|
|
suffix: formatGroupRatio(props.groupRatios?.[group]),
|
|
})),
|
|
]
|
|
|
|
const quotaOptions: FilterOption[] = [
|
|
{
|
|
value: QUOTA_TYPES.ALL,
|
|
label: quotaTypeLabels[QUOTA_TYPES.ALL],
|
|
count: props.models.length,
|
|
},
|
|
{
|
|
value: QUOTA_TYPES.TOKEN,
|
|
label: quotaTypeLabels[QUOTA_TYPES.TOKEN],
|
|
count: countBy(props.models, (model) => model.quota_type === 0),
|
|
},
|
|
{
|
|
value: QUOTA_TYPES.REQUEST,
|
|
label: quotaTypeLabels[QUOTA_TYPES.REQUEST],
|
|
count: countBy(props.models, (model) => model.quota_type === 1),
|
|
},
|
|
]
|
|
|
|
const tagOptions: FilterOption[] = [
|
|
{
|
|
value: FILTER_ALL,
|
|
label: t('All Tags'),
|
|
count: props.models.length,
|
|
},
|
|
...props.tags.map((tag) => ({
|
|
value: tag,
|
|
label: tag,
|
|
count: countBy(props.models, (model) =>
|
|
parseTags(model.tags)
|
|
.map((item) => item.toLowerCase())
|
|
.includes(tag.toLowerCase())
|
|
),
|
|
})),
|
|
]
|
|
|
|
const endpointOptions: FilterOption[] = [
|
|
{
|
|
value: ENDPOINT_TYPES.ALL,
|
|
label: endpointTypeLabels[ENDPOINT_TYPES.ALL],
|
|
count: props.models.length,
|
|
},
|
|
...Object.entries(endpointTypeLabels)
|
|
.filter(([value]) => value !== ENDPOINT_TYPES.ALL)
|
|
.map(([value, label]) => ({
|
|
value,
|
|
label,
|
|
count: countBy(props.models, (model) =>
|
|
model.supported_endpoint_types?.includes(value) ?? false
|
|
),
|
|
})),
|
|
]
|
|
|
|
return (
|
|
<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 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}
|
|
options={vendorOptions}
|
|
onChange={props.onVendorChange}
|
|
/>
|
|
<FilterSection
|
|
title={t('Model Tags')}
|
|
value={props.tagFilter}
|
|
options={tagOptions}
|
|
onChange={props.onTagChange}
|
|
/>
|
|
<FilterSection
|
|
title={t('Pricing Type')}
|
|
value={props.quotaTypeFilter}
|
|
options={quotaOptions}
|
|
onChange={props.onQuotaTypeChange}
|
|
/>
|
|
<FilterSection
|
|
title={t('Endpoint Type')}
|
|
value={props.endpointTypeFilter}
|
|
options={endpointOptions}
|
|
onChange={props.onEndpointTypeChange}
|
|
/>
|
|
</div>
|
|
</aside>
|
|
)
|
|
}
|