Files
chaos-api/web/default/src/features/pricing/index.tsx
T
2026-06-06 15:49:38 +08:00

286 lines
9.1 KiB
TypeScript
Vendored

/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { PublicLayout } from '@/components/layout'
import { PageTransition } from '@/components/page-transition'
import {
LoadingSkeleton,
EmptyState,
SearchBar,
PricingTable,
PricingSidebar,
PricingToolbar,
ModelCardGrid,
ModelDetailsDrawer,
} from './components'
import { EXCLUDED_GROUPS, VIEW_MODES } from './constants'
import { useFilters } from './hooks/use-filters'
import { usePricingData } from './hooks/use-pricing-data'
export function Pricing() {
const { t } = useTranslation()
const [selectedModelName, setSelectedModelName] = useState<string | null>(
null
)
const {
models,
vendors,
groupRatio,
usableGroup,
endpointMap,
autoGroups,
isLoading,
priceRate,
usdExchangeRate,
} = usePricingData()
const {
searchInput,
sortBy,
vendorFilter,
groupFilter,
quotaTypeFilter,
endpointTypeFilter,
tagFilter,
tokenUnit,
viewMode,
showRechargePrice,
setSearchInput,
setSortBy,
setVendorFilter,
setGroupFilter,
setQuotaTypeFilter,
setEndpointTypeFilter,
setTagFilter,
setTokenUnit,
setViewMode,
setShowRechargePrice,
filteredModels,
hasActiveFilters,
activeFilterCount,
availableTags,
clearFilters,
clearSearch,
} = useFilters(models || [])
const handleModelClick = useCallback((modelName: string) => {
setSelectedModelName(modelName)
}, [])
const selectedModel = useMemo(
() =>
selectedModelName
? (models || []).find(
(model) => model.model_name === selectedModelName
) || null
: null,
[models, selectedModelName]
)
const availableGroups = useMemo(
() =>
Object.keys(usableGroup || {}).filter(
(g) => !EXCLUDED_GROUPS.includes(g)
),
[usableGroup]
)
const handleClearAll = useCallback(() => {
clearFilters()
clearSearch()
}, [clearFilters, clearSearch])
const renderPricingContent = () => {
if (filteredModels.length === 0) {
return (
<EmptyState
searchQuery={searchInput}
hasActiveFilters={hasActiveFilters}
onClearFilters={handleClearAll}
/>
)
}
if (viewMode === VIEW_MODES.CARD) {
return (
<ModelCardGrid
models={filteredModels}
onModelClick={handleModelClick}
priceRate={priceRate}
usdExchangeRate={usdExchangeRate}
tokenUnit={tokenUnit}
showRechargePrice={showRechargePrice}
/>
)
}
return (
<PricingTable
models={filteredModels}
priceRate={priceRate}
usdExchangeRate={usdExchangeRate}
tokenUnit={tokenUnit}
showRechargePrice={showRechargePrice}
onModelClick={handleModelClick}
/>
)
}
if (isLoading) {
return (
<PublicLayout showMainContainer={false}>
<div className='mx-auto w-full max-w-[1800px] px-3 pt-16 pb-8 sm:px-6 sm:pt-20 sm:pb-10 xl:px-8'>
<LoadingSkeleton viewMode={viewMode} />
</div>
</PublicLayout>
)
}
return (
<PublicLayout showMainContainer={false}>
<div className='relative'>
<div
aria-hidden
className='pointer-events-none absolute inset-x-0 top-0 h-[600px] opacity-20 dark:opacity-[0.10]'
style={{
background: [
'radial-gradient(ellipse 60% 50% at 20% 20%, oklch(0.72 0.18 250 / 80%) 0%, transparent 70%)',
'radial-gradient(ellipse 50% 40% at 80% 15%, oklch(0.65 0.15 200 / 60%) 0%, transparent 70%)',
'radial-gradient(ellipse 40% 35% at 50% 70%, oklch(0.70 0.12 280 / 40%) 0%, transparent 70%)',
].join(', '),
maskImage:
'linear-gradient(to bottom, black 40%, transparent 100%)',
WebkitMaskImage:
'linear-gradient(to bottom, black 40%, transparent 100%)',
}}
/>
<PageTransition className='relative mx-auto w-full max-w-[1800px] px-3 pt-16 pb-8 sm:px-6 sm:pt-20 sm:pb-10 xl:px-8'>
<header className='mx-auto mb-5 max-w-3xl pt-5 text-center sm:mb-10 sm:pt-10'>
<h1 className='text-[clamp(2rem,5.5vw,3.5rem)] leading-[1.15] font-bold tracking-tight'>
{t('Model Square')}
</h1>
<p className='text-muted-foreground/80 mt-3 text-sm sm:mt-4 sm:text-base'>
{t('This site currently has {{count}} models enabled', {
count: models?.length || 0,
})}
</p>
<p className='text-muted-foreground/60 mx-auto mt-2 max-w-2xl text-xs leading-relaxed sm:text-sm'>
{t(
'Discover curated AI models, compare pricing and capabilities, and choose the right model for every scenario.'
)}
</p>
<SearchBar
value={searchInput}
onChange={setSearchInput}
onClear={clearSearch}
placeholder={t(
'Search model name, provider, endpoint, or tag...'
)}
className='mx-auto mt-4 max-w-2xl sm:mt-6'
/>
</header>
<div className='grid gap-4 xl:grid-cols-[330px_minmax(0,1fr)]'>
<PricingSidebar
quotaTypeFilter={quotaTypeFilter}
endpointTypeFilter={endpointTypeFilter}
vendorFilter={vendorFilter}
groupFilter={groupFilter}
tagFilter={tagFilter}
onQuotaTypeChange={setQuotaTypeFilter}
onEndpointTypeChange={setEndpointTypeFilter}
onVendorChange={setVendorFilter}
onGroupChange={setGroupFilter}
onTagChange={setTagFilter}
vendors={vendors || []}
groups={availableGroups}
groupRatios={groupRatio}
tags={availableTags}
models={models || []}
hasActiveFilters={hasActiveFilters}
onClearFilters={clearFilters}
className='hover-scrollbar sticky top-4 hidden max-h-[calc(100dvh-2rem)] self-start overflow-y-auto xl:block'
/>
<main className='min-w-0 space-y-4'>
<PricingToolbar
filteredCount={filteredModels.length}
totalCount={models?.length}
sortBy={sortBy}
onSortChange={setSortBy}
tokenUnit={tokenUnit}
onTokenUnitChange={setTokenUnit}
showRechargePrice={showRechargePrice}
onRechargePriceChange={setShowRechargePrice}
viewMode={viewMode}
onViewModeChange={setViewMode}
quotaTypeFilter={quotaTypeFilter}
endpointTypeFilter={endpointTypeFilter}
vendorFilter={vendorFilter}
groupFilter={groupFilter}
tagFilter={tagFilter}
onQuotaTypeChange={setQuotaTypeFilter}
onEndpointTypeChange={setEndpointTypeFilter}
onVendorChange={setVendorFilter}
onGroupChange={setGroupFilter}
onTagChange={setTagFilter}
vendors={vendors || []}
groups={availableGroups}
groupRatios={groupRatio}
tags={availableTags}
models={models || []}
hasActiveFilters={hasActiveFilters}
activeFilterCount={activeFilterCount}
onClearFilters={clearFilters}
/>
{renderPricingContent()}
</main>
</div>
{selectedModel && (
<ModelDetailsDrawer
open={Boolean(selectedModel)}
onOpenChange={(open) => {
if (!open) setSelectedModelName(null)
}}
model={selectedModel}
groupRatio={groupRatio || {}}
usableGroup={usableGroup || {}}
endpointMap={
(endpointMap as Record<
string,
{ path?: string; method?: string }
>) || {}
}
autoGroups={autoGroups || []}
priceRate={priceRate ?? 1}
usdExchangeRate={usdExchangeRate ?? 1}
tokenUnit={tokenUnit}
showRechargePrice={showRechargePrice}
/>
)}
</PageTransition>
</div>
</PublicLayout>
)
}