feat(web/default): unified UI overhaul — Base UI migration, theme presets, rankings dashboard, and table toolbar refactor (#4633)
* 🎨 feat(web/default): add shadcn-style theme presets, radius prefs, and fix selection badges Integrate the qn-platform–style OKLCH color system into the default frontend while keeping the existing blue-tinted dark tokens for the default theme. Add [data-theme-preset] palettes for seven named presets plus the default zinc-like scale, define [data-theme-radius] overrides so user radius beats preset --radius, and align the Tailwind @custom-variant dark helper with .dark usage. Introduce ThemeCustomizationProvider to own preset and radius state, persist choices in cookies (theme-preset, theme-radius), and sync data-theme-preset / data-theme-radius on <html>. Wrap the tree in main.tsx. Extend ConfigDrawer with theme preset swatches (scoped data-theme-preset) and radius previews wired to context; refactor swatch/card markup so selected CircleCheck badges sit outside clipped rows (remove outer overflow-hidden that hid the centered checkmark). Add i18n keys for preset names, radius, and accessibility labels across en, zh, fr, ja, ru, vi. * 🎨 fix(web): align segmented controls with theme radius tokens - Replace hard-coded inner pill radii (rounded-[5px]) on dashboard chart toolbars with radius-md so the active state follows --radius when users change Radius in Theme Settings. - Use nested radii consistent with TabsList/TabsTrigger: outer rounded-lg (var(--radius)) and inner rounded-md (calc(var(--radius) - 2px)) so the track and active thumb stay concentric at small scales (e.g. 0.3rem) instead of a squared “focus” block inside a rounded shell. - Apply the same pattern to pricing SegmentedControl and the segmented groups in consumption-distribution-chart, model-charts, and user-charts. Verified: bun run typecheck (web/default) * ✨ feat(pricing): enrich model details with uptime sparkline and API documentation Add a compact 30-day uptime sparkline (OpenRouter-style bars + aggregate %) with per-day tooltips, surface it in a status row under quick stats and in the per-group performance table, and extend mock data so uptime series are stable and optionally scoped by group. Introduce an API tab with Shiki-highlighted code samples (cURL, Python, TypeScript, JavaScript), endpoint-type switching, authentication guidance, a supported-parameters table, and mock per-group RPM/TPM/RPD limits. Infer vendor, tokenizer, license, and data-retention hints for a provider & data privacy card on the Overview tab (capabilities/modalities stay with model identity; rate limits stay with the API tab). Update i18n for all new user-facing strings across en, zh, fr, ja, ru, and vi. * 🏆 feat(rankings): add comprehensive rankings dashboard Add a mock-data powered rankings experience with period tabs, model, app, and vendor leaderboards, market share and history charts, movers, new releases, and per-category sections while backend analytics are pending. Link ranked models to pricing details and ranked vendors to filtered pricing results, and include localized copy for all supported frontend locales. * fix(theme): correct theme preset selection state - update Base UI Radio selectors to use data-checked/data-unchecked states. - fix unchecked theme options still showing selected indicators. - isolate the default theme preview tokens to prevent preset changes from leaking into it. * fix(setup): correct usage mode radio state - use Base UI data-checked/data-unchecked states for RadioGroup styling. - hide radio indicators when options are unchecked to avoid setup page display issues. - drive usage mode card and icon selection styles from Base UI state. * fix(auth): submit sign-in and sign-up forms * 🎨 refactor: Align default theme with shadcn Base Nova and prune legacy customization Migrate shadcn UI to Base UI primitives via CLI (`base-nova` / `components.json`) and reinstall full component registry with `--overwrite`, including Hugeicons-backed widgets and newly added registry components. - Remove custom multi-preset/theme-radius system (`ThemeCustomizationProvider`, cookies, preset UI from config drawer); rely on official semantic CSS tokens + light/dark only. - Replace `theme.css` with shadcn’s documented neutral `:root`/`.dark` palette and `@theme inline` mappings (plus skeleton token vars for existing shimmer usage). - Update global styles for Base UI: collapsible animation uses `--collapsible-panel-height`; clarify scroll-lock override comment. Application compatibility: - Keep minimal shims where app code diverged from official APIs (popover collision props, combobox legacy `options` callers, Spinner prop typing). - Switch interactive styling from Radix-era `data-state` / `--radix-*` selectors to Base UI semantics (`data-open`, `data-popup-open`, `data-panel-open`, `--anchor-width`, etc.) Tooling / docs / build: - Rename Rsbuild vendor chunk grouping to `@base-ui` + transitive `@radix-ui`. - Refresh AGENTS.md / CLAUDE.md / classic→default sync skill for Base UI stack. - Bump `package.json` / lockfile for shadcn-postinstall deps (Hugeicons, chart stack, themes, etc.) Verified: `bun run typecheck` passes. Note: `bun run lint` still reports pre-existing hooks rule violations elsewhere; not addressed in this change. * 🎨 chore(web/default): unify table toolbar, relocate usage stats, refine filters - Refactor DataTableToolbar to a single wrapping flex row with a right-aligned action cluster (Reset / Search / View / Expand) for a cleaner Ant Design Pro–style filter bar; remove the dedicated stats row and the toolbar `stats` prop. - Move Common Logs summary badges (Usage / RPM / TPM) and the sensitive- data visibility toggle into the page header via CommonLogsHeaderActions and SectionPageLayout.Actions so the toolbar stays focused on filters. - Slim CommonLogsFilterBar props (no stats / preActions eye control). - Improve CompactDateTimeRangePicker: show minute-precision labels on the trigger (seconds omitted; aligns with datetime-local inputs); widen the trigger on sm+ breakpoints so the full range is visible without truncation; apply the same width in task logs filters. - Simplify DataTableViewOptions: text-only “View” trigger, no sliders icon. - Earlier layout tweak: extra top padding on SectionPageLayout scroll content so control focus rings are not clipped by overflow. * feat(web/default): Base UI migration and component foundation Migrate from Radix UI to Base UI, rewrite core UI primitives, update dependencies (recharts, date-fns, next-themes), add shadcn agent skill documentation, and refresh AI element components. This is the foundational work from the v2/localmain lineage that was not covered by the individual feature commits above. --------- Co-authored-by: t0ng7u <dev@aiass.cc> Co-authored-by: QuentinHsu <xuquentinyang@gmail.com>
This commit is contained in:
@@ -25,7 +25,7 @@ export function LegalConsent({
|
||||
return null
|
||||
}
|
||||
|
||||
const handleChange = (value: boolean | 'indeterminate') => {
|
||||
const handleChange = (value: boolean) => {
|
||||
onCheckedChange(value === true)
|
||||
}
|
||||
|
||||
|
||||
@@ -305,6 +305,7 @@ export function UserAuthForm({
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type='submit'
|
||||
className='mt-2 w-full justify-center gap-2'
|
||||
disabled={isLoading || (requiresLegalConsent && !agreedToLegal)}
|
||||
>
|
||||
|
||||
@@ -321,6 +321,7 @@ export function SignUpForm({
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type='submit'
|
||||
className='mt-2 w-full justify-center gap-2'
|
||||
disabled={isLoading || (requiresLegalConsent && !agreedToLegal)}
|
||||
>
|
||||
|
||||
@@ -351,10 +351,10 @@ function BalanceCell({ channel }: { channel: Channel }) {
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className='text-muted-foreground cursor-help'>
|
||||
{usedDisplay}
|
||||
</span>
|
||||
<TooltipTrigger
|
||||
render={<span className='text-muted-foreground cursor-help' />}
|
||||
>
|
||||
{usedDisplay}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
@@ -364,22 +364,24 @@ function BalanceCell({ channel }: { channel: Channel }) {
|
||||
</Tooltip>
|
||||
<span className='text-muted-foreground/30'>·</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className={cn(
|
||||
'cursor-pointer transition-opacity hover:opacity-70',
|
||||
channel.type === 57
|
||||
? 'text-primary'
|
||||
: textColorMap[isUpdating ? 'neutral' : variant]
|
||||
)}
|
||||
onClick={handleClickUpdate}
|
||||
>
|
||||
{isUpdating
|
||||
? 'Updating...'
|
||||
: channel.type === 57
|
||||
? t('Account Info')
|
||||
: remainingDisplay}
|
||||
</span>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span
|
||||
className={cn(
|
||||
'cursor-pointer transition-opacity hover:opacity-70',
|
||||
channel.type === 57
|
||||
? 'text-primary'
|
||||
: textColorMap[isUpdating ? 'neutral' : variant]
|
||||
)}
|
||||
onClick={handleClickUpdate}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{isUpdating
|
||||
? 'Updating...'
|
||||
: channel.type === 57
|
||||
? t('Account Info')
|
||||
: remainingDisplay}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
@@ -434,10 +436,8 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && 'indeterminate')
|
||||
}
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
indeterminate={table.getIsSomePageRowsSelected()}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label='Select all'
|
||||
/>
|
||||
@@ -540,11 +540,13 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<span className='font-medium'>{truncateText(name, 30)}</span>
|
||||
{isPassThrough && (
|
||||
<TooltipProvider delayDuration={100}>
|
||||
<TooltipProvider delay={100}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<AlertTriangle className='h-3.5 w-3.5 flex-shrink-0 text-amber-500' />
|
||||
</TooltipTrigger>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<AlertTriangle className='h-3.5 w-3.5 flex-shrink-0 text-amber-500' />
|
||||
}
|
||||
></TooltipTrigger>
|
||||
<TooltipContent side='top'>
|
||||
{t(
|
||||
'Request body pass-through is enabled. The request body will be sent directly to the upstream without any conversion.'
|
||||
@@ -564,12 +566,14 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
<UpstreamUpdateTags channel={channel} />
|
||||
</div>
|
||||
{channel.remark && (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<TooltipProvider delay={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{truncateText(channel.remark, 40)}
|
||||
</span>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span className='text-muted-foreground text-xs' />
|
||||
}
|
||||
>
|
||||
{truncateText(channel.remark, 40)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='bottom' className='max-w-xs'>
|
||||
{channel.remark}
|
||||
@@ -629,12 +633,14 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
{isMultiKey && (
|
||||
<TooltipProvider delayDuration={100}>
|
||||
<TooltipProvider delay={100}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className='border-border bg-muted text-primary inline-flex h-6 w-6 items-center justify-center rounded-full border'>
|
||||
<MultiKeyModeIcon className='h-3.5 w-3.5' />
|
||||
</span>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span className='border-border bg-muted text-primary inline-flex h-6 w-6 items-center justify-center rounded-full border' />
|
||||
}
|
||||
>
|
||||
<MultiKeyModeIcon className='h-3.5 w-3.5' />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top'>
|
||||
{multiKeyTooltip}
|
||||
@@ -651,21 +657,23 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
copyable={false}
|
||||
/>
|
||||
{isIonet && (
|
||||
<TooltipProvider delayDuration={100}>
|
||||
<TooltipProvider delay={100}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className='flex cursor-pointer items-center gap-1.5 text-xs font-medium'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (!deploymentId) return
|
||||
const targetUrl = `/console/deployment?deployment_id=${deploymentId}`
|
||||
window.open(targetUrl, '_blank', 'noopener')
|
||||
}}
|
||||
>
|
||||
<span className='text-muted-foreground/30'>·</span>
|
||||
<span className={cn(textColorMap.purple)}>IO.NET</span>
|
||||
</span>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span
|
||||
className='flex cursor-pointer items-center gap-1.5 text-xs font-medium'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (!deploymentId) return
|
||||
const targetUrl = `/console/deployment?deployment_id=${deploymentId}`
|
||||
window.open(targetUrl, '_blank', 'noopener')
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span className='text-muted-foreground/30'>·</span>
|
||||
<span className={cn(textColorMap.purple)}>IO.NET</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top'>
|
||||
<div className='max-w-xs space-y-1'>
|
||||
@@ -769,18 +777,16 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
|
||||
if (statusReason || statusTime) {
|
||||
return (
|
||||
<TooltipProvider delayDuration={100}>
|
||||
<TooltipProvider delay={100}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<StatusBadge
|
||||
label={label}
|
||||
variant={config.variant}
|
||||
showDot={config.showDot}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
</span>
|
||||
<TooltipTrigger render={<span />}>
|
||||
<StatusBadge
|
||||
label={label}
|
||||
variant={config.variant}
|
||||
showDot={config.showDot}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top' className='max-w-xs'>
|
||||
<div className='space-y-1 text-xs'>
|
||||
@@ -849,8 +855,8 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>{renderLimitedItems(modelBadges, 2)}</div>
|
||||
<TooltipTrigger render={<div />}>
|
||||
{renderLimitedItems(modelBadges, 2)}
|
||||
</TooltipTrigger>
|
||||
{modelArray.length > 2 && (
|
||||
<TooltipContent
|
||||
@@ -884,8 +890,8 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>{renderLimitedItems(groupBadges, 2)}</div>
|
||||
<TooltipTrigger render={<div />}>
|
||||
{renderLimitedItems(groupBadges, 2)}
|
||||
</TooltipTrigger>
|
||||
{groupArray.length > 2 && (
|
||||
<TooltipContent
|
||||
@@ -1002,10 +1008,12 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className='text-muted-foreground cursor-pointer font-mono text-sm'>
|
||||
{timeText}
|
||||
</span>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span className='text-muted-foreground cursor-pointer font-mono text-sm' />
|
||||
}
|
||||
>
|
||||
{timeText}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top'>
|
||||
<p className='font-mono text-sm'>{fullDate}</p>
|
||||
|
||||
@@ -94,10 +94,8 @@ export function ChannelsPrimaryButtons() {
|
||||
|
||||
{/* More Actions */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='outline' size='sm'>
|
||||
<MoreHorizontal className='h-4 w-4' />
|
||||
</Button>
|
||||
<DropdownMenuTrigger render={<Button variant='outline' size='sm' />}>
|
||||
<MoreHorizontal className='h-4 w-4' />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-56'>
|
||||
{/* Mobile-only: toggle switches */}
|
||||
|
||||
+51
-132
@@ -2,7 +2,6 @@ import { useState, useMemo, useEffect } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getRouteApi } from '@tanstack/react-router'
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
getExpandedRowModel,
|
||||
@@ -14,27 +13,13 @@ import {
|
||||
import { useDebounce, useMediaQuery } from '@/hooks'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getLobeIcon } from '@/lib/lobe-icon'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useTableUrlState } from '@/hooks/use-table-url-state'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
DISABLED_ROW_DESKTOP,
|
||||
DISABLED_ROW_MOBILE,
|
||||
DataTableToolbar,
|
||||
TableSkeleton,
|
||||
TableEmpty,
|
||||
MobileCardList,
|
||||
DataTablePage,
|
||||
} from '@/components/data-table'
|
||||
import { DataTablePagination } from '@/components/data-table/pagination'
|
||||
import { PageFooterPortal } from '@/components/layout'
|
||||
import { getChannels, searchChannels, getGroups } from '../api'
|
||||
import {
|
||||
DEFAULT_PAGE_SIZE,
|
||||
@@ -331,122 +316,56 @@ export function ChannelsTable() {
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-3 sm:space-y-4'>
|
||||
<DataTableToolbar
|
||||
table={table}
|
||||
searchPlaceholder={t('Filter by name, ID, or key...')}
|
||||
additionalSearch={
|
||||
<Input
|
||||
placeholder={t('Filter by model...')}
|
||||
value={modelFilterInput}
|
||||
onChange={(e) => setModelFilterInput(e.target.value)}
|
||||
className='h-8 w-full sm:w-[150px] lg:w-[200px]'
|
||||
/>
|
||||
}
|
||||
filters={[
|
||||
{
|
||||
columnId: 'status',
|
||||
title: t('Status'),
|
||||
options: [...CHANNEL_STATUS_OPTIONS],
|
||||
singleSelect: true,
|
||||
},
|
||||
{
|
||||
columnId: 'type',
|
||||
title: t('Type'),
|
||||
options: typeFilterOptions,
|
||||
singleSelect: true,
|
||||
},
|
||||
{
|
||||
columnId: 'group',
|
||||
title: t('Group'),
|
||||
options: groupFilterOptions,
|
||||
singleSelect: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{isMobile ? (
|
||||
<MobileCardList
|
||||
table={table}
|
||||
isLoading={isLoading}
|
||||
emptyTitle='No Channels Found'
|
||||
emptyDescription='No channels available. Create your first channel to get started.'
|
||||
getRowClassName={(row) =>
|
||||
isDisabledChannelRow(row.original) ? DISABLED_ROW_MOBILE : undefined
|
||||
}
|
||||
<DataTablePage
|
||||
table={table}
|
||||
columns={columns}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
emptyTitle={t('No Channels Found')}
|
||||
emptyDescription={t(
|
||||
'No channels available. Create your first channel to get started.'
|
||||
)}
|
||||
skeletonKeyPrefix='channel-skeleton'
|
||||
applyHeaderSize
|
||||
toolbarProps={{
|
||||
searchPlaceholder: t('Filter by name, ID, or key...'),
|
||||
additionalSearch: (
|
||||
<Input
|
||||
placeholder={t('Filter by model...')}
|
||||
value={modelFilterInput}
|
||||
onChange={(e) => setModelFilterInput(e.target.value)}
|
||||
className='w-full sm:w-[150px] lg:w-[180px]'
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden rounded-md border transition-opacity duration-150',
|
||||
isFetching && !isLoading && 'pointer-events-none opacity-50'
|
||||
)}
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
style={{ width: header.getSize() }}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableSkeleton table={table} keyPrefix='channel-skeleton' />
|
||||
) : table.getRowModel().rows.length === 0 ? (
|
||||
<TableEmpty
|
||||
colSpan={columns.length}
|
||||
title={t('No Channels Found')}
|
||||
description={t(
|
||||
'No channels available. Create your first channel to get started.'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && 'selected'}
|
||||
className={cn(
|
||||
isDisabledChannelRow(row.original) &&
|
||||
DISABLED_ROW_DESKTOP
|
||||
)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<DataTableBulkActions table={table} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<PageFooterPortal>
|
||||
<DataTablePagination table={table} />
|
||||
</PageFooterPortal>
|
||||
</>
|
||||
),
|
||||
filters: [
|
||||
{
|
||||
columnId: 'status',
|
||||
title: t('Status'),
|
||||
options: [...CHANNEL_STATUS_OPTIONS],
|
||||
singleSelect: true,
|
||||
},
|
||||
{
|
||||
columnId: 'type',
|
||||
title: t('Type'),
|
||||
options: typeFilterOptions,
|
||||
singleSelect: true,
|
||||
},
|
||||
{
|
||||
columnId: 'group',
|
||||
title: t('Group'),
|
||||
options: groupFilterOptions,
|
||||
singleSelect: true,
|
||||
},
|
||||
],
|
||||
}}
|
||||
getRowClassName={(row, { isMobile }) =>
|
||||
isDisabledChannelRow(row.original)
|
||||
? isMobile
|
||||
? DISABLED_ROW_MOBILE
|
||||
: DISABLED_ROW_DESKTOP
|
||||
: undefined
|
||||
}
|
||||
bulkActions={<DataTableBulkActions table={table} />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -83,18 +83,20 @@ export function DataTableBulkActions<TData>({
|
||||
<>
|
||||
<BulkActionsToolbar table={table} entityName='channel'>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
onClick={handleEnableAll}
|
||||
className='size-8'
|
||||
aria-label={t('Enable selected channels')}
|
||||
title={t('Enable selected channels')}
|
||||
>
|
||||
<Power />
|
||||
<span className='sr-only'>{t('Enable selected channels')}</span>
|
||||
</Button>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
onClick={handleEnableAll}
|
||||
className='size-8'
|
||||
aria-label={t('Enable selected channels')}
|
||||
title={t('Enable selected channels')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Power />
|
||||
<span className='sr-only'>{t('Enable selected channels')}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('Enable selected channels')}</p>
|
||||
@@ -102,18 +104,20 @@ export function DataTableBulkActions<TData>({
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
onClick={handleDisableAll}
|
||||
className='size-8'
|
||||
aria-label={t('Disable selected channels')}
|
||||
title={t('Disable selected channels')}
|
||||
>
|
||||
<PowerOff />
|
||||
<span className='sr-only'>{t('Disable selected channels')}</span>
|
||||
</Button>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
onClick={handleDisableAll}
|
||||
className='size-8'
|
||||
aria-label={t('Disable selected channels')}
|
||||
title={t('Disable selected channels')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<PowerOff />
|
||||
<span className='sr-only'>{t('Disable selected channels')}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('Disable selected channels')}</p>
|
||||
@@ -121,20 +125,22 @@ export function DataTableBulkActions<TData>({
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
onClick={() => setShowTagDialog(true)}
|
||||
className='size-8'
|
||||
aria-label={t('Set tag for selected channels')}
|
||||
title={t('Set tag for selected channels')}
|
||||
>
|
||||
<Tag />
|
||||
<span className='sr-only'>
|
||||
{t('Set tag for selected channels')}
|
||||
</span>
|
||||
</Button>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
onClick={() => setShowTagDialog(true)}
|
||||
className='size-8'
|
||||
aria-label={t('Set tag for selected channels')}
|
||||
title={t('Set tag for selected channels')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Tag />
|
||||
<span className='sr-only'>
|
||||
{t('Set tag for selected channels')}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('Set tag for selected channels')}</p>
|
||||
@@ -142,18 +148,20 @@ export function DataTableBulkActions<TData>({
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='destructive'
|
||||
size='icon'
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className='size-8'
|
||||
aria-label={t('Delete selected channels')}
|
||||
title={t('Delete selected channels')}
|
||||
>
|
||||
<Trash2 />
|
||||
<span className='sr-only'>{t('Delete selected channels')}</span>
|
||||
</Button>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='destructive'
|
||||
size='icon'
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className='size-8'
|
||||
aria-label={t('Delete selected channels')}
|
||||
title={t('Delete selected channels')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Trash2 />
|
||||
<span className='sr-only'>{t('Delete selected channels')}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('Delete selected channels')}</p>
|
||||
|
||||
@@ -124,46 +124,50 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
return (
|
||||
<div className='flex items-center justify-end gap-1'>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
onClick={handleDirectTest}
|
||||
disabled={isTesting}
|
||||
aria-label={t('Test Connection')}
|
||||
>
|
||||
{isTesting ? (
|
||||
<Loader2 className='size-4 animate-spin' />
|
||||
) : (
|
||||
<Gauge className='size-4' />
|
||||
)}
|
||||
</Button>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
onClick={handleDirectTest}
|
||||
disabled={isTesting}
|
||||
aria-label={t('Test Connection')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{isTesting ? (
|
||||
<Loader2 className='size-4 animate-spin' />
|
||||
) : (
|
||||
<Gauge className='size-4' />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('Test Connection')}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
onClick={handleToggleStatus}
|
||||
disabled={isTogglingStatus}
|
||||
aria-label={isEnabled ? t('Disable') : t('Enable')}
|
||||
className={
|
||||
isEnabled
|
||||
? 'text-destructive hover:text-destructive'
|
||||
: 'text-emerald-600 hover:text-emerald-600 dark:text-emerald-400 dark:hover:text-emerald-400'
|
||||
}
|
||||
>
|
||||
{isTogglingStatus ? (
|
||||
<Loader2 className='size-4 animate-spin' />
|
||||
) : isEnabled ? (
|
||||
<PowerOff className='size-4' />
|
||||
) : (
|
||||
<Power className='size-4' />
|
||||
)}
|
||||
</Button>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
onClick={handleToggleStatus}
|
||||
disabled={isTogglingStatus}
|
||||
aria-label={isEnabled ? t('Disable') : t('Enable')}
|
||||
className={
|
||||
isEnabled
|
||||
? 'text-destructive hover:text-destructive'
|
||||
: 'text-emerald-600 hover:text-emerald-600 dark:text-emerald-400 dark:hover:text-emerald-400'
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{isTogglingStatus ? (
|
||||
<Loader2 className='size-4 animate-spin' />
|
||||
) : isEnabled ? (
|
||||
<PowerOff className='size-4' />
|
||||
) : (
|
||||
<Power className='size-4' />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isEnabled ? t('Disable') : t('Enable')}
|
||||
@@ -171,14 +175,16 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
</Tooltip>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='data-[state=open]:bg-muted flex h-8 w-8 p-0'
|
||||
>
|
||||
<MoreHorizontal className='h-4 w-4' />
|
||||
<span className='sr-only'>{t('Open menu')}</span>
|
||||
</Button>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='data-popup-open:bg-muted flex h-8 w-8 p-0'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontal className='h-4 w-4' />
|
||||
<span className='sr-only'>{t('Open menu')}</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-48'>
|
||||
{/* Edit */}
|
||||
|
||||
+10
-8
@@ -47,14 +47,16 @@ export function DataTableTagRowActions({ row }: DataTableTagRowActionsProps) {
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='data-[state=open]:bg-muted flex h-8 w-8 p-0'
|
||||
>
|
||||
<MoreHorizontal className='h-4 w-4' />
|
||||
<span className='sr-only'>{t('Open menu')}</span>
|
||||
</Button>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='data-popup-open:bg-muted flex h-8 w-8 p-0'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontal className='h-4 w-4' />
|
||||
<span className='sr-only'>{t('Open menu')}</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-48'>
|
||||
{/* Edit Tag */}
|
||||
|
||||
+23
-20
@@ -252,10 +252,8 @@ export function ChannelTestDialog({
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && 'indeterminate')
|
||||
}
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
indeterminate={table.getIsSomePageRowsSelected()}
|
||||
onCheckedChange={(value) =>
|
||||
table.toggleAllPageRowsSelected(!!value)
|
||||
}
|
||||
@@ -432,7 +430,10 @@ export function ChannelTestDialog({
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='endpoint-type'>{t('Endpoint Type')}</Label>
|
||||
<Select value={endpointType} onValueChange={setEndpointType}>
|
||||
<Select
|
||||
value={endpointType}
|
||||
onValueChange={(v) => v !== null && setEndpointType(v)}
|
||||
>
|
||||
<SelectTrigger id='endpoint-type'>
|
||||
<SelectValue placeholder={t('Auto detect (default)')} />
|
||||
</SelectTrigger>
|
||||
@@ -586,21 +587,23 @@ function TestModelsBulkActions({
|
||||
return (
|
||||
<BulkActionsToolbar table={table} entityName='model'>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size='sm'
|
||||
onClick={() => onTestSelected(selectedModels)}
|
||||
disabled={disabled || selectedModels.length === 0}
|
||||
>
|
||||
{disabled ? (
|
||||
<>
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
{t('Testing...')}
|
||||
</>
|
||||
) : (
|
||||
buttonLabel
|
||||
)}
|
||||
</Button>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
size='sm'
|
||||
onClick={() => onTestSelected(selectedModels)}
|
||||
disabled={disabled || selectedModels.length === 0}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{disabled ? (
|
||||
<>
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
{t('Testing...')}
|
||||
</>
|
||||
) : (
|
||||
buttonLabel
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('Run tests for the selected models')}</p>
|
||||
|
||||
@@ -275,8 +275,9 @@ export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) {
|
||||
</div>
|
||||
|
||||
<div className='flex gap-2'>
|
||||
<Select
|
||||
<Select<string>
|
||||
onValueChange={(value) => {
|
||||
if (value === null) return
|
||||
if (!selectedModels.includes(value)) {
|
||||
setSelectedModels([...selectedModels, value])
|
||||
}
|
||||
|
||||
+3
-3
@@ -316,9 +316,9 @@ export function FetchModelsDialog({
|
||||
<span>{model}</span>
|
||||
{redirectOnlySet.has(normalizeModelName(model)) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className='h-3.5 w-3.5 text-amber-500' />
|
||||
</TooltipTrigger>
|
||||
<TooltipTrigger
|
||||
render={<Info className='h-3.5 w-3.5 text-amber-500' />}
|
||||
></TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t('From model redirect, not yet added to models list')}
|
||||
</TooltipContent>
|
||||
|
||||
Vendored
+15
-15
@@ -49,21 +49,21 @@ export function MissingModelsConfirmationDialog({
|
||||
<AlertDialogTitle>
|
||||
{t('Models not in list, may fail to invoke')}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div className='space-y-3 text-sm'>
|
||||
<div>
|
||||
{t(
|
||||
'The following models in the model redirect have not been added to the "Models" list and may fail during invocation due to missing available models:'
|
||||
)}
|
||||
</div>
|
||||
<div className='rounded-md bg-red-50 p-2 font-mono text-xs break-all text-red-600 dark:bg-red-950/50 dark:text-red-400'>
|
||||
{missingModels.join(', ')}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
'You can manually add them in "Custom Model Names", click "Fill" and then submit, or use the operations below to handle automatically.'
|
||||
)}
|
||||
</div>
|
||||
<AlertDialogDescription
|
||||
render={<div className='space-y-3 text-sm' />}
|
||||
>
|
||||
<div>
|
||||
{t(
|
||||
'The following models in the model redirect have not been added to the "Models" list and may fail during invocation due to missing available models:'
|
||||
)}
|
||||
</div>
|
||||
<div className='rounded-md bg-red-50 p-2 font-mono text-xs break-all text-red-600 dark:bg-red-950/50 dark:text-red-400'>
|
||||
{missingModels.join(', ')}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
'You can manually add them in "Custom Model Names", click "Fill" and then submit, or use the operations below to handle automatically.'
|
||||
)}
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
+1
-1
@@ -258,7 +258,7 @@ export function MultiKeyManageDialog({
|
||||
<div className='flex shrink-0 items-center justify-between'>
|
||||
<Select
|
||||
value={statusFilter === null ? 'all' : statusFilter.toString()}
|
||||
onValueChange={handleStatusFilterChange}
|
||||
onValueChange={(v) => v !== null && handleStatusFilterChange(v)}
|
||||
>
|
||||
<SelectTrigger className='w-40'>
|
||||
<SelectValue placeholder={t('All Status')} />
|
||||
|
||||
+6
@@ -2142,6 +2142,7 @@ function RuleEditor(ruleEditorProps: RuleEditorProps) {
|
||||
<Select
|
||||
value={mode}
|
||||
onValueChange={(nextMode) =>
|
||||
nextMode !== null &&
|
||||
ruleEditorProps.updateOperation(operation.id, {
|
||||
mode: nextMode,
|
||||
})
|
||||
@@ -2340,6 +2341,7 @@ function RuleEditor(ruleEditorProps: RuleEditorProps) {
|
||||
<Select
|
||||
value={operation.logic || 'OR'}
|
||||
onValueChange={(v) =>
|
||||
v !== null &&
|
||||
ruleEditorProps.updateOperation(operation.id, {
|
||||
logic: v,
|
||||
})
|
||||
@@ -2515,6 +2517,7 @@ function ConditionEditor(conditionEditorProps: ConditionEditorProps) {
|
||||
<Select
|
||||
value={condition.mode}
|
||||
onValueChange={(v) =>
|
||||
v !== null &&
|
||||
conditionEditorProps.updateCondition(
|
||||
conditionEditorProps.operationId,
|
||||
condition.id,
|
||||
@@ -3020,6 +3023,7 @@ function PruneObjectsEditor(pruneObjectsEditorProps: PruneObjectsEditorProps) {
|
||||
<Select
|
||||
value={rule.mode}
|
||||
onValueChange={(v) =>
|
||||
v !== null &&
|
||||
pruneObjectsEditorProps.updateRule(
|
||||
pruneObjectsEditorProps.operationId,
|
||||
rule.id,
|
||||
@@ -3124,6 +3128,7 @@ function SyncFieldsEditor(syncFieldsEditorProps: SyncFieldsEditorProps) {
|
||||
<Select
|
||||
value={syncFieldsEditorProps.syncFromTarget.type || 'json'}
|
||||
onValueChange={(v) =>
|
||||
v !== null &&
|
||||
syncFieldsEditorProps.updateOperation(
|
||||
syncFieldsEditorProps.operationId,
|
||||
{
|
||||
@@ -3172,6 +3177,7 @@ function SyncFieldsEditor(syncFieldsEditorProps: SyncFieldsEditorProps) {
|
||||
<Select
|
||||
value={syncFieldsEditorProps.syncToTarget.type || 'json'}
|
||||
onValueChange={(v) =>
|
||||
v !== null &&
|
||||
syncFieldsEditorProps.updateOperation(
|
||||
syncFieldsEditorProps.operationId,
|
||||
{
|
||||
|
||||
+39
-35
@@ -2247,16 +2247,18 @@ export function ChannelMutateDrawer({
|
||||
{t('Model Mapping')}
|
||||
</FormLabel>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
className='text-muted-foreground hover:text-foreground size-auto p-0'
|
||||
aria-label='How model mapping works'
|
||||
>
|
||||
<HelpCircle className='h-4 w-4' />
|
||||
</Button>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
className='text-muted-foreground hover:text-foreground size-auto p-0'
|
||||
aria-label='How model mapping works'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<HelpCircle className='h-4 w-4' />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side='top'
|
||||
@@ -2362,28 +2364,30 @@ export function ChannelMutateDrawer({
|
||||
open={advancedSettingsOpen}
|
||||
onOpenChange={handleAdvancedSettingsOpenChange}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
className='bg-card hover:bg-accent/50 flex w-full items-center justify-between rounded-xl border px-5 py-4 text-left transition-colors'
|
||||
>
|
||||
<div className='space-y-0.5'>
|
||||
<div className='text-[13px] font-semibold'>
|
||||
{t('Advanced Settings')}
|
||||
</div>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{t(
|
||||
'Request overrides, routing behavior, and upstream model automation'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'text-muted-foreground h-4 w-4 shrink-0 transition-transform',
|
||||
advancedSettingsOpen && 'rotate-180'
|
||||
)}
|
||||
<CollapsibleTrigger
|
||||
render={
|
||||
<button
|
||||
type='button'
|
||||
className='bg-card hover:bg-accent/50 flex w-full items-center justify-between rounded-xl border px-5 py-4 text-left transition-colors'
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className='space-y-0.5'>
|
||||
<div className='text-[13px] font-semibold'>
|
||||
{t('Advanced Settings')}
|
||||
</div>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{t(
|
||||
'Request overrides, routing behavior, and upstream model automation'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'text-muted-foreground h-4 w-4 shrink-0 transition-transform',
|
||||
advancedSettingsOpen && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent className='mt-5 space-y-5'>
|
||||
@@ -3277,10 +3281,10 @@ export function ChannelMutateDrawer({
|
||||
</Form>
|
||||
|
||||
<SheetFooter className='grid grid-cols-2 gap-2 border-t px-4 py-3 sm:flex sm:px-6 sm:py-4'>
|
||||
<SheetClose asChild>
|
||||
<Button variant='outline' disabled={isSubmitting}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<SheetClose
|
||||
render={<Button variant='outline' disabled={isSubmitting} />}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</SheetClose>
|
||||
<Button form='channel-form' type='submit' disabled={isSubmitting}>
|
||||
{isSubmitting && (
|
||||
|
||||
+10
-10
@@ -26,11 +26,13 @@ interface ConsumptionDistributionChartProps {
|
||||
defaultChartType?: ConsumptionDistributionChartType
|
||||
}
|
||||
|
||||
const CHART_TYPE_ICONS: Record<ConsumptionDistributionChartType, typeof BarChart3> =
|
||||
{
|
||||
bar: BarChart3,
|
||||
area: AreaChart,
|
||||
}
|
||||
const CHART_TYPE_ICONS: Record<
|
||||
ConsumptionDistributionChartType,
|
||||
typeof BarChart3
|
||||
> = {
|
||||
bar: BarChart3,
|
||||
area: AreaChart,
|
||||
}
|
||||
|
||||
export function ConsumptionDistributionChart(
|
||||
props: ConsumptionDistributionChartProps
|
||||
@@ -80,15 +82,13 @@ export function ConsumptionDistributionChart(
|
||||
<div className='flex w-full flex-col gap-1.5 border-b px-3 py-2 sm:gap-3 sm:px-5 sm:py-3 lg:flex-row lg:items-center lg:justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<WalletCards className='text-muted-foreground/60 size-4' />
|
||||
<div className='text-sm font-semibold'>
|
||||
{t('Quota Distribution')}
|
||||
</div>
|
||||
<div className='text-sm font-semibold'>{t('Quota Distribution')}</div>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{t('Total:')} {chartData.totalQuotaDisplay}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='bg-muted/60 inline-flex h-7 w-full overflow-x-auto rounded-md border p-0.5 sm:h-8 sm:w-auto'>
|
||||
<div className='bg-muted/60 inline-flex h-7 w-full overflow-x-auto rounded-lg border p-0.5 sm:h-8 sm:w-auto'>
|
||||
{CONSUMPTION_DISTRIBUTION_CHART_OPTIONS.map((item) => {
|
||||
const Icon = CHART_TYPE_ICONS[item.value]
|
||||
return (
|
||||
@@ -96,7 +96,7 @@ export function ConsumptionDistributionChart(
|
||||
key={item.value}
|
||||
type='button'
|
||||
onClick={() => setChartType(item.value)}
|
||||
className={`inline-flex shrink-0 items-center gap-1.5 rounded-[5px] px-3 text-xs font-medium transition-colors ${
|
||||
className={`inline-flex shrink-0 items-center gap-1.5 rounded-md px-3 text-xs font-medium transition-colors ${
|
||||
chartType === item.value
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import { formatNumber, formatQuota } from '@/lib/format'
|
||||
import { computeTimeRange } from '@/lib/time'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { getUserQuotaDates } from '@/features/dashboard/api'
|
||||
import { useModelStatCardsConfig } from '@/features/dashboard/hooks/use-dashboard-config'
|
||||
|
||||
@@ -89,13 +89,13 @@ export function ModelCharts(props: ModelChartsProps) {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='bg-muted/60 inline-flex h-7 w-full overflow-x-auto rounded-md border p-0.5 sm:h-8 sm:w-auto'>
|
||||
<div className='bg-muted/60 inline-flex h-7 w-full overflow-x-auto rounded-lg border p-0.5 sm:h-8 sm:w-auto'>
|
||||
{MODEL_ANALYTICS_CHART_OPTIONS.map((tab) => (
|
||||
<button
|
||||
key={tab.value}
|
||||
type='button'
|
||||
onClick={() => setActiveTab(tab.value)}
|
||||
className={`shrink-0 rounded-[5px] px-3 text-xs font-medium transition-colors ${
|
||||
className={`shrink-0 rounded-md px-3 text-xs font-medium transition-colors ${
|
||||
activeTab === tab.value
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
|
||||
+4
-6
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Save, Settings2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { TimeGranularity } from '@/lib/time'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
@@ -30,7 +31,6 @@ import type {
|
||||
DashboardChartPreferences,
|
||||
ModelAnalyticsChartTab,
|
||||
} from '@/features/dashboard/types'
|
||||
import type { TimeGranularity } from '@/lib/time'
|
||||
|
||||
interface ModelsChartPreferencesProps {
|
||||
preferences: DashboardChartPreferences
|
||||
@@ -55,11 +55,9 @@ export function ModelsChartPreferences(props: ModelsChartPreferencesProps) {
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant='outline' size='sm'>
|
||||
<Settings2 className='mr-2 h-4 w-4' />
|
||||
{t('Preferences')}
|
||||
</Button>
|
||||
<DialogTrigger render={<Button variant='outline' size='sm' />}>
|
||||
<Settings2 className='mr-2 h-4 w-4' />
|
||||
{t('Preferences')}
|
||||
</DialogTrigger>
|
||||
<DialogContent className='sm:max-w-md'>
|
||||
<DialogHeader>
|
||||
|
||||
+5
-7
@@ -68,8 +68,8 @@ export function ModelsFilter(props: ModelsFilterProps) {
|
||||
const [filters, setFilters] = useState<DashboardFilters>(() =>
|
||||
buildDefaultDashboardFilters(props.preferences)
|
||||
)
|
||||
const [selectedRange, setSelectedRange] = useState<number | null>(() =>
|
||||
props.preferences.defaultTimeRangeDays
|
||||
const [selectedRange, setSelectedRange] = useState<number | null>(
|
||||
() => props.preferences.defaultTimeRangeDays
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -121,11 +121,9 @@ export function ModelsFilter(props: ModelsFilterProps) {
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant='outline' size='sm'>
|
||||
<Filter className='mr-2 h-4 w-4' />
|
||||
{t('Filter')}
|
||||
</Button>
|
||||
<DialogTrigger render={<Button variant='outline' size='sm' />}>
|
||||
<Filter className='mr-2 h-4 w-4' />
|
||||
{t('Filter')}
|
||||
</DialogTrigger>
|
||||
<DialogContent className='flex max-h-[calc(100dvh-2rem)] flex-col max-sm:h-dvh max-sm:w-screen max-sm:max-w-none max-sm:rounded-none max-sm:p-4 sm:max-w-lg'>
|
||||
<DialogHeader>
|
||||
|
||||
@@ -110,13 +110,11 @@ export function ApiInfoItemComponent(props: ApiInfoItemProps) {
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
asChild
|
||||
className='hidden size-7 p-0 sm:inline-flex'
|
||||
title={t('Open in New Tab')}
|
||||
render={<a href={item.url} target='_blank' rel='noreferrer' />}
|
||||
>
|
||||
<a href={item.url} target='_blank' rel='noreferrer'>
|
||||
<ExternalLink className='size-3.5' />
|
||||
</a>
|
||||
<ExternalLink className='size-3.5' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -30,7 +30,7 @@ export function FAQPanel() {
|
||||
height='h-64 sm:h-80'
|
||||
>
|
||||
<ScrollArea className='h-64 sm:h-80'>
|
||||
<Accordion type='single' collapsible className='w-full'>
|
||||
<Accordion className='w-full'>
|
||||
{list.map((item: FAQItem, idx: number) => {
|
||||
const key = item.id ?? `faq-${idx}`
|
||||
const value = `item-${key}`
|
||||
|
||||
@@ -68,12 +68,10 @@ export function SummaryCards() {
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='hidden h-6 gap-1 px-2 text-xs sm:inline-flex'
|
||||
asChild
|
||||
render={<Link to='/wallet' />}
|
||||
>
|
||||
<Link to='/wallet'>
|
||||
<CreditCard className='size-3' />
|
||||
{t('Recharge')}
|
||||
</Link>
|
||||
<CreditCard className='size-3' />
|
||||
{t('Recharge')}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
|
||||
@@ -21,9 +21,7 @@ export function StatCard(props: StatCardProps) {
|
||||
<Icon className='text-muted-foreground/60 size-3.5 shrink-0' />
|
||||
<span className='line-clamp-2 leading-snug'>{props.title}</span>
|
||||
</div>
|
||||
{props.action && (
|
||||
<div className='shrink-0'>{props.action}</div>
|
||||
)}
|
||||
{props.action && <div className='shrink-0'>{props.action}</div>}
|
||||
</div>
|
||||
|
||||
{props.loading ? (
|
||||
|
||||
@@ -125,13 +125,13 @@ export function UserCharts() {
|
||||
return (
|
||||
<div className='space-y-3'>
|
||||
<div className='flex items-center gap-1.5 overflow-x-auto pb-1 sm:gap-2'>
|
||||
<div className='flex shrink-0 items-center gap-1.5 rounded-md border p-0.5'>
|
||||
<div className='flex shrink-0 items-center gap-1.5 rounded-lg border p-0.5'>
|
||||
{TIME_RANGE_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.days}
|
||||
type='button'
|
||||
onClick={() => handleRangeChange(preset.days)}
|
||||
className={`rounded-[5px] px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||
selectedRange === preset.days
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
@@ -142,7 +142,7 @@ export function UserCharts() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className='flex shrink-0 items-center gap-1.5 rounded-md border p-0.5'>
|
||||
<div className='flex shrink-0 items-center gap-1.5 rounded-lg border p-0.5'>
|
||||
{TIME_GRANULARITY_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
@@ -150,7 +150,7 @@ export function UserCharts() {
|
||||
onClick={() =>
|
||||
handleGranularityChange(opt.value as TimeGranularity)
|
||||
}
|
||||
className={`rounded-[5px] px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||
timeGranularity === opt.value
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
@@ -161,7 +161,7 @@ export function UserCharts() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className='flex shrink-0 items-center gap-1.5 rounded-md border p-0.5'>
|
||||
<div className='flex shrink-0 items-center gap-1.5 rounded-lg border p-0.5'>
|
||||
<span className='text-muted-foreground px-2 text-xs font-medium'>
|
||||
{t('Top Users')}
|
||||
</span>
|
||||
@@ -170,7 +170,7 @@ export function UserCharts() {
|
||||
key={limit}
|
||||
type='button'
|
||||
onClick={() => setTopUserLimit(limit)}
|
||||
className={`rounded-[5px] px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||
topUserLimit === limit
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
|
||||
+7
-6
@@ -11,11 +11,6 @@ import {
|
||||
CardStaggerItem,
|
||||
FadeIn,
|
||||
} from '@/components/page-transition'
|
||||
import {
|
||||
buildDefaultDashboardFilters,
|
||||
getSavedChartPreferences,
|
||||
saveChartPreferences,
|
||||
} from './lib'
|
||||
import { ModelsChartPreferences } from './components/models/models-chart-preferences'
|
||||
import { ModelsFilter } from './components/models/models-filter-dialog'
|
||||
import { AnnouncementsPanel } from './components/overview/announcements-panel'
|
||||
@@ -24,6 +19,11 @@ import { FAQPanel } from './components/overview/faq-panel'
|
||||
import { SummaryCards } from './components/overview/summary-cards'
|
||||
import { UptimePanel } from './components/overview/uptime-panel'
|
||||
import { DEFAULT_TIME_GRANULARITY } from './constants'
|
||||
import {
|
||||
buildDefaultDashboardFilters,
|
||||
getSavedChartPreferences,
|
||||
saveChartPreferences,
|
||||
} from './lib'
|
||||
import {
|
||||
type DashboardSectionId,
|
||||
DASHBOARD_DEFAULT_SECTION,
|
||||
@@ -168,7 +168,8 @@ export function Dashboard() {
|
||||
},
|
||||
[navigate]
|
||||
)
|
||||
const showSectionTabs = activeSection !== 'overview' && visibleSections.length > 1
|
||||
const showSectionTabs =
|
||||
activeSection !== 'overview' && visibleSections.length > 1
|
||||
const modelActions =
|
||||
activeSection === 'models' ? (
|
||||
<>
|
||||
|
||||
+10
-10
@@ -71,9 +71,7 @@ export function processChartData(
|
||||
return (array: TooltipLineItem[]) => {
|
||||
const modelItems = array.filter((item) => !isOtherTooltipKey(item.key))
|
||||
const otherItems = array.filter((item) => isOtherTooltipKey(item.key))
|
||||
modelItems.sort(
|
||||
(a, b) => (Number(b.value) || 0) - (Number(a.value) || 0)
|
||||
)
|
||||
modelItems.sort((a, b) => (Number(b.value) || 0) - (Number(a.value) || 0))
|
||||
array = [...modelItems, ...otherItems]
|
||||
|
||||
let sum = 0
|
||||
@@ -91,13 +89,15 @@ export function processChartData(
|
||||
|
||||
if (collapseOverflow && array.length > MAX_TOOLTIP_MODELS) {
|
||||
const visible = modelItems.slice(0, MAX_TOOLTIP_MODELS)
|
||||
const otherSum = [...modelItems.slice(MAX_TOOLTIP_MODELS), ...otherItems]
|
||||
.reduce((sum, item) => {
|
||||
const raw = item.datum
|
||||
? Number((item.datum as Record<string, unknown>)?.rawQuota) || 0
|
||||
: 0
|
||||
return sum + raw
|
||||
}, 0)
|
||||
const otherSum = [
|
||||
...modelItems.slice(MAX_TOOLTIP_MODELS),
|
||||
...otherItems,
|
||||
].reduce((sum, item) => {
|
||||
const raw = item.datum
|
||||
? Number((item.datum as Record<string, unknown>)?.rawQuota) || 0
|
||||
: 0
|
||||
return sum + raw
|
||||
}, 0)
|
||||
array = [
|
||||
...visible,
|
||||
{
|
||||
|
||||
+1
-3
@@ -99,9 +99,7 @@ export function getSavedChartPreferences(): DashboardChartPreferences {
|
||||
defaultTimeRangeDays: isTimeRangePresetDays(parsed.defaultTimeRangeDays)
|
||||
? parsed.defaultTimeRangeDays
|
||||
: fallbackPreferences.defaultTimeRangeDays,
|
||||
defaultTimeGranularity: isTimeGranularity(
|
||||
parsed.defaultTimeGranularity
|
||||
)
|
||||
defaultTimeGranularity: isTimeGranularity(parsed.defaultTimeGranularity)
|
||||
? parsed.defaultTimeGranularity
|
||||
: fallbackPreferences.defaultTimeGranularity,
|
||||
}
|
||||
|
||||
+7
-11
@@ -14,24 +14,20 @@ export function HeroButtons({ isAuthenticated }: HeroButtonsProps) {
|
||||
const { t } = useTranslation()
|
||||
if (isAuthenticated) {
|
||||
return (
|
||||
<Button size='lg' asChild>
|
||||
<Link to='/dashboard'>
|
||||
{t('Go to Dashboard')} <ArrowRight className='ml-2 h-5 w-5' />
|
||||
</Link>
|
||||
<Button size='lg' render={<Link to='/dashboard' />}>
|
||||
{t('Go to Dashboard')} <ArrowRight className='ml-2 h-5 w-5' />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button size='lg' asChild>
|
||||
<Link to='/sign-up'>
|
||||
{t('Get Started')}
|
||||
<ArrowRight className='ml-2 h-5 w-5' />
|
||||
</Link>
|
||||
<Button size='lg' render={<Link to='/sign-up' />}>
|
||||
{t('Get Started')}
|
||||
<ArrowRight className='ml-2 h-5 w-5' />
|
||||
</Button>
|
||||
<Button size='lg' variant='outline' asChild>
|
||||
<Link to='/sign-in'>{t('Sign In')}</Link>
|
||||
<Button size='lg' variant='outline' render={<Link to='/sign-in' />}>
|
||||
{t('Sign In')}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
|
||||
+18
-22
@@ -40,7 +40,8 @@ const ACCENT_CLASSES: Record<
|
||||
blue: {
|
||||
activeText: 'text-blue-600 dark:text-blue-400',
|
||||
activeBorder: 'border-blue-500 dark:border-blue-400',
|
||||
badge: 'bg-blue-500/10 text-blue-600 dark:bg-blue-400/10 dark:text-blue-400',
|
||||
badge:
|
||||
'bg-blue-500/10 text-blue-600 dark:bg-blue-400/10 dark:text-blue-400',
|
||||
},
|
||||
violet: {
|
||||
activeText: 'text-violet-600 dark:text-violet-400',
|
||||
@@ -80,10 +81,7 @@ const API_DEMOS: ApiDemoConfig[] = [
|
||||
method: 'POST',
|
||||
endpoint: '/v1/responses',
|
||||
headers: ['"Authorization: Bearer sk-••••"'],
|
||||
request: [
|
||||
'"model": "your-model",',
|
||||
'"input": "..."',
|
||||
],
|
||||
request: ['"model": "your-model",', '"input": "..."'],
|
||||
response: [
|
||||
'{',
|
||||
' "output": [{ "type": "output_text", "text": <text> }],',
|
||||
@@ -209,10 +207,10 @@ export function HeroTerminalDemo() {
|
||||
key={item.id}
|
||||
onClick={() => handleSelect(index)}
|
||||
className={cn(
|
||||
'-mb-px relative flex items-center gap-1.5 border-b-2 px-2.5 py-2.5 text-[11px] font-medium tracking-wide transition-colors sm:px-3 sm:text-xs',
|
||||
'relative -mb-px flex items-center gap-1.5 border-b-2 px-2.5 py-2.5 text-[11px] font-medium tracking-wide transition-colors sm:px-3 sm:text-xs',
|
||||
isActive
|
||||
? `${tone.activeBorder} ${tone.activeText}`
|
||||
: 'border-transparent text-foreground/40 hover:text-foreground/70'
|
||||
: 'text-foreground/40 hover:text-foreground/70 border-transparent'
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
@@ -221,7 +219,7 @@ export function HeroTerminalDemo() {
|
||||
})}
|
||||
<div className='ml-auto flex items-center gap-2 pr-2 sm:pr-3'>
|
||||
<span className='inline-block size-1.5 rounded-full bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.45)]' />
|
||||
<span className='font-mono text-[10px] tracking-wider text-foreground/40 uppercase'>
|
||||
<span className='text-foreground/40 font-mono text-[10px] tracking-wider uppercase'>
|
||||
200 ok
|
||||
</span>
|
||||
</div>
|
||||
@@ -244,7 +242,7 @@ export function HeroTerminalDemo() {
|
||||
</span>
|
||||
<code
|
||||
className={cn(
|
||||
'truncate font-mono text-[12.5px] text-foreground/75 transition-opacity duration-200',
|
||||
'text-foreground/75 truncate font-mono text-[12.5px] transition-opacity duration-200',
|
||||
transitioning ? 'opacity-0' : 'opacity-100'
|
||||
)}
|
||||
>
|
||||
@@ -268,17 +266,17 @@ export function HeroTerminalDemo() {
|
||||
'border-border/40 bg-muted/30 dark:border-white/[0.05] dark:bg-white/[0.02]'
|
||||
)}
|
||||
>
|
||||
<div className='flex items-center gap-3 text-[10px] tabular-nums text-foreground/40'>
|
||||
<div className='text-foreground/40 flex items-center gap-3 text-[10px] tabular-nums'>
|
||||
<span className='flex items-center gap-1'>
|
||||
<span className='font-mono'>{demo.latency}</span>
|
||||
<span className='tracking-wider uppercase'>ms</span>
|
||||
</span>
|
||||
<span className='size-1 rounded-full bg-foreground/15' />
|
||||
<span className='bg-foreground/15 size-1 rounded-full' />
|
||||
<span className='flex items-center gap-1'>
|
||||
<span className='font-mono'>{demo.tokens}</span>
|
||||
<span className='tracking-wider uppercase'>tokens</span>
|
||||
</span>
|
||||
<span className='size-1 rounded-full bg-foreground/15' />
|
||||
<span className='bg-foreground/15 size-1 rounded-full' />
|
||||
<span className='flex items-center gap-1'>
|
||||
<span className='tracking-wider uppercase'>cost</span>
|
||||
<span className='font-mono'>
|
||||
@@ -286,7 +284,7 @@ export function HeroTerminalDemo() {
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<span className='font-mono text-[10px] tracking-wider text-foreground/30 uppercase'>
|
||||
<span className='text-foreground/30 font-mono text-[10px] tracking-wider uppercase'>
|
||||
stream · sse
|
||||
</span>
|
||||
</div>
|
||||
@@ -352,9 +350,7 @@ function ResponseBlock(props: { demo: ApiDemoConfig; transitioning: boolean }) {
|
||||
)}
|
||||
>
|
||||
{demo.response.map((line, i) => (
|
||||
<CodeLine key={i}>
|
||||
{renderResponseLine(line, demo)}
|
||||
</CodeLine>
|
||||
<CodeLine key={i}>{renderResponseLine(line, demo)}</CodeLine>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -363,7 +359,7 @@ function ResponseBlock(props: { demo: ApiDemoConfig; transitioning: boolean }) {
|
||||
|
||||
function SectionLabel(props: { children: ReactNode }) {
|
||||
return (
|
||||
<span className='font-sans text-[10px] font-semibold tracking-[0.18em] text-foreground/30 uppercase'>
|
||||
<span className='text-foreground/30 font-sans text-[10px] font-semibold tracking-[0.18em] uppercase'>
|
||||
{props.children}
|
||||
</span>
|
||||
)
|
||||
@@ -401,9 +397,7 @@ function renderResponseLine(line: string, demo: ApiDemoConfig): ReactNode {
|
||||
</Accent>
|
||||
)
|
||||
} else if (placeholder === '<tokens>') {
|
||||
segments.push(
|
||||
<NumberText key={`ph-${idx}`}>{demo.tokens}</NumberText>
|
||||
)
|
||||
segments.push(<NumberText key={`ph-${idx}`}>{demo.tokens}</NumberText>)
|
||||
} else if (placeholder === '<in>') {
|
||||
segments.push(
|
||||
<NumberText key={`ph-${idx}`}>
|
||||
@@ -472,7 +466,7 @@ function tokenize(input: string): ReactNode {
|
||||
|
||||
function CodeLine(props: { children: ReactNode; indent?: number }) {
|
||||
return (
|
||||
<div className='whitespace-pre-wrap break-words'>
|
||||
<div className='break-words whitespace-pre-wrap'>
|
||||
{props.indent ? (
|
||||
<span
|
||||
aria-hidden
|
||||
@@ -525,5 +519,7 @@ function Muted(props: { children: ReactNode }) {
|
||||
|
||||
function Accent(props: { children: ReactNode; accent: AccentTone }) {
|
||||
const tone = ACCENT_CLASSES[props.accent]
|
||||
return <span className={cn('font-medium', tone.activeText)}>{props.children}</span>
|
||||
return (
|
||||
<span className={cn('font-medium', tone.activeText)}>{props.children}</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -47,18 +47,16 @@ export function CTA(props: CTAProps) {
|
||||
)}
|
||||
</p>
|
||||
<div className='mt-8 flex items-center justify-center gap-3'>
|
||||
<Button className='group rounded-lg' asChild>
|
||||
<Link to='/sign-up'>
|
||||
{t('Get Started')}
|
||||
<ArrowRight className='ml-1 size-3.5 transition-transform duration-200 group-hover:translate-x-0.5' />
|
||||
</Link>
|
||||
<Button className='group rounded-lg' render={<Link to='/sign-up' />}>
|
||||
{t('Get Started')}
|
||||
<ArrowRight className='ml-1 size-3.5 transition-transform duration-200 group-hover:translate-x-0.5' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='border-border/50 hover:border-border hover:bg-muted/50 rounded-lg'
|
||||
asChild
|
||||
render={<Link to='/pricing' />}
|
||||
>
|
||||
<Link to='/pricing'>{t('View Pricing')}</Link>
|
||||
{t('View Pricing')}
|
||||
</Button>
|
||||
</div>
|
||||
</AnimateInView>
|
||||
|
||||
+14
-12
@@ -59,26 +59,28 @@ export function Hero(props: HeroProps) {
|
||||
style={{ animationDelay: '160ms' }}
|
||||
>
|
||||
{props.isAuthenticated ? (
|
||||
<Button className='group rounded-lg' asChild>
|
||||
<Link to='/dashboard'>
|
||||
{t('Go to Dashboard')}
|
||||
<ArrowRight className='ml-1 size-3.5 transition-transform duration-200 group-hover:translate-x-0.5' />
|
||||
</Link>
|
||||
<Button
|
||||
className='group rounded-lg'
|
||||
render={<Link to='/dashboard' />}
|
||||
>
|
||||
{t('Go to Dashboard')}
|
||||
<ArrowRight className='ml-1 size-3.5 transition-transform duration-200 group-hover:translate-x-0.5' />
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button className='group rounded-lg' asChild>
|
||||
<Link to='/sign-up'>
|
||||
{t('Get Started')}
|
||||
<ArrowRight className='ml-1 size-3.5 transition-transform duration-200 group-hover:translate-x-0.5' />
|
||||
</Link>
|
||||
<Button
|
||||
className='group rounded-lg'
|
||||
render={<Link to='/sign-up' />}
|
||||
>
|
||||
{t('Get Started')}
|
||||
<ArrowRight className='ml-1 size-3.5 transition-transform duration-200 group-hover:translate-x-0.5' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='border-border/50 hover:border-border hover:bg-muted/50 rounded-lg'
|
||||
asChild
|
||||
render={<Link to='/pricing' />}
|
||||
>
|
||||
<Link to='/pricing'>{t('View Pricing')}</Link>
|
||||
{t('View Pricing')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -33,7 +33,10 @@ type ApiKeyGroupComboboxProps = {
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
function formatGroupRatio(ratio: ApiKeyGroupOption['ratio'], ratioLabel: string) {
|
||||
function formatGroupRatio(
|
||||
ratio: ApiKeyGroupOption['ratio'],
|
||||
ratioLabel: string
|
||||
) {
|
||||
if (ratio === undefined || ratio === null || ratio === '') return null
|
||||
return `${ratio}x ${ratioLabel}`
|
||||
}
|
||||
@@ -109,35 +112,37 @@ export function ApiKeyGroupCombobox({
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
className='border-input bg-muted/40 h-auto min-h-14 w-full justify-between gap-2 rounded-lg px-3 py-2 text-start shadow-none transition-[background-color,border-color,box-shadow] duration-150 hover:bg-muted/55 hover:text-foreground active:bg-background data-[state=open]:border-ring data-[state=open]:bg-background data-[state=open]:ring-ring/20 data-[state=open]:ring-[3px] sm:min-h-20 sm:gap-3 sm:px-4 sm:py-3'
|
||||
>
|
||||
<span className='flex min-w-0 flex-1 items-center justify-between gap-2 sm:gap-3'>
|
||||
<span className='min-w-0'>
|
||||
<span className='block truncate font-medium'>
|
||||
{selectedOption?.value || placeholder || t('Select a group')}
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
className='border-input bg-muted/40 hover:bg-muted/55 hover:text-foreground active:bg-background data-popup-open:border-ring data-popup-open:bg-background data-popup-open:ring-ring/20 h-auto min-h-14 w-full justify-between gap-2 rounded-lg px-3 py-2 text-start shadow-none transition-[background-color,border-color,box-shadow] duration-150 data-popup-open:ring-[3px] sm:min-h-20 sm:gap-3 sm:px-4 sm:py-3'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span className='flex min-w-0 flex-1 items-center justify-between gap-2 sm:gap-3'>
|
||||
<span className='min-w-0'>
|
||||
<span className='block truncate font-medium'>
|
||||
{selectedOption?.value || placeholder || t('Select a group')}
|
||||
</span>
|
||||
{selectedOption?.desc && (
|
||||
<span className='text-muted-foreground block truncate text-[11px] sm:text-xs'>
|
||||
{selectedOption.desc}
|
||||
</span>
|
||||
{selectedOption?.desc && (
|
||||
<span className='text-muted-foreground block truncate text-[11px] sm:text-xs'>
|
||||
{selectedOption.desc}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className='hidden sm:block'>
|
||||
<GroupRatioBadge ratio={selectedOption?.ratio} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronsUpDown className='h-4 w-4 shrink-0 opacity-50' />
|
||||
</Button>
|
||||
<span className='hidden sm:block'>
|
||||
<GroupRatioBadge ratio={selectedOption?.ratio} />
|
||||
</span>
|
||||
</span>
|
||||
<ChevronsUpDown className='h-4 w-4 shrink-0 opacity-50' />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className='data-[state=closed]:zoom-out-100 data-[state=open]:zoom-in-100 data-[side=bottom]:slide-in-from-top-0 data-[side=left]:slide-in-from-right-0 data-[side=right]:slide-in-from-left-0 data-[side=top]:slide-in-from-bottom-0 w-[var(--radix-popover-trigger-width)] overflow-hidden rounded-xl p-0 shadow-lg data-[state=closed]:duration-75 data-[state=open]:duration-100'
|
||||
className='data-closed:zoom-out-100 data-open:zoom-in-100 data-[side=bottom]:slide-in-from-top-0 data-[side=left]:slide-in-from-right-0 data-[side=right]:slide-in-from-left-0 data-[side=top]:slide-in-from-bottom-0 w-[var(--anchor-width)] overflow-hidden rounded-xl p-0 shadow-lg data-closed:duration-75 data-open:duration-100'
|
||||
onWheel={(event) => event.stopPropagation()}
|
||||
onTouchMove={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
@@ -156,7 +161,7 @@ export function ApiKeyGroupCombobox({
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
onSelect={handleSelect}
|
||||
className='items-start gap-3 rounded-lg px-3 py-3 transition-colors data-[selected=true]:bg-muted'
|
||||
className='data-[selected=true]:bg-muted items-start gap-3 rounded-lg px-3 py-3 transition-colors'
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
|
||||
+40
-40
@@ -54,14 +54,16 @@ export function ApiKeyCell({ apiKey }: { apiKey: ApiKey }) {
|
||||
return (
|
||||
<div className='flex items-center'>
|
||||
<Popover open={popoverOpen} onOpenChange={handlePopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='text-muted-foreground h-7 font-mono text-xs'
|
||||
>
|
||||
{maskedKey}
|
||||
</Button>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='text-muted-foreground h-7 font-mono text-xs'
|
||||
/>
|
||||
}
|
||||
>
|
||||
{maskedKey}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className='w-auto max-w-[min(90vw,28rem)]'
|
||||
@@ -89,22 +91,24 @@ export function ApiKeyCell({ apiKey }: { apiKey: ApiKey }) {
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='size-7 shrink-0'
|
||||
onClick={handleCopy}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className='size-3.5 animate-spin' />
|
||||
) : isCopied ? (
|
||||
<Check className='size-3.5 text-green-600' />
|
||||
) : (
|
||||
<Copy className='size-3.5' />
|
||||
)}
|
||||
</Button>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='size-7 shrink-0'
|
||||
onClick={handleCopy}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className='size-3.5 animate-spin' />
|
||||
) : isCopied ? (
|
||||
<Check className='size-3.5 text-green-600' />
|
||||
) : (
|
||||
<Copy className='size-3.5' />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isLoading
|
||||
@@ -131,14 +135,12 @@ export function ModelLimitsCell({ apiKey }: { apiKey: ApiKey }) {
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<StatusBadge
|
||||
label={t('{{count}} model(s)', { count: models.length })}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
/>
|
||||
</span>
|
||||
<TooltipTrigger render={<span />}>
|
||||
<StatusBadge
|
||||
label={t('{{count}} model(s)', { count: models.length })}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top' className='max-w-xs'>
|
||||
<div className='max-h-[200px] space-y-0.5 overflow-y-auto text-xs'>
|
||||
@@ -174,14 +176,12 @@ export function IpRestrictionsCell({ apiKey }: { apiKey: ApiKey }) {
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<StatusBadge
|
||||
label={t('{{count}} IP(s)', { count: ips.length })}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
/>
|
||||
</span>
|
||||
<TooltipTrigger render={<span />}>
|
||||
<StatusBadge
|
||||
label={t('{{count}} IP(s)', { count: ips.length })}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top' className='max-w-xs'>
|
||||
<div className='max-h-[200px] space-y-0.5 overflow-y-auto text-xs'>
|
||||
|
||||
+28
-30
@@ -85,10 +85,8 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && 'indeterminate')
|
||||
}
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
indeterminate={table.getIsSomePageRowsSelected()}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label='Select all'
|
||||
className='translate-y-[2px]'
|
||||
@@ -171,21 +169,19 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className='w-[150px] space-y-1'>
|
||||
<div className='flex justify-between text-xs'>
|
||||
<span className='font-medium tabular-nums'>
|
||||
{formatQuota(remaining)}
|
||||
</span>
|
||||
<span className='text-muted-foreground tabular-nums'>
|
||||
{formatQuota(total)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={percentage}
|
||||
className={cn('h-1.5', getQuotaProgressColor(percentage))}
|
||||
/>
|
||||
<TooltipTrigger render={<div className='w-[150px] space-y-1' />}>
|
||||
<div className='flex justify-between text-xs'>
|
||||
<span className='font-medium tabular-nums'>
|
||||
{formatQuota(remaining)}
|
||||
</span>
|
||||
<span className='text-muted-foreground tabular-nums'>
|
||||
{formatQuota(total)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={percentage}
|
||||
className={cn('h-1.5', getQuotaProgressColor(percentage))}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className='space-y-1 text-xs'>
|
||||
@@ -219,18 +215,20 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
if (group === 'auto') {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className='inline-flex items-center gap-1.5 text-xs'>
|
||||
<GroupBadge group='auto' />
|
||||
{apiKey.cross_group_retry && (
|
||||
<>
|
||||
<span className='text-muted-foreground/30'>·</span>
|
||||
<span className='text-muted-foreground/60'>
|
||||
{t('Cross-group')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span className='inline-flex items-center gap-1.5 text-xs' />
|
||||
}
|
||||
>
|
||||
<GroupBadge group='auto' />
|
||||
{apiKey.cross_group_retry && (
|
||||
<>
|
||||
<span className='text-muted-foreground/30'>·</span>
|
||||
<span className='text-muted-foreground/60'>
|
||||
{t('Cross-group')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<span className='text-xs'>
|
||||
|
||||
@@ -84,7 +84,7 @@ function ApiKeyFormSection(props: ApiKeyFormSectionProps) {
|
||||
<Icon className='size-4 sm:size-5' />
|
||||
</div>
|
||||
<div className='min-w-0'>
|
||||
<h3 className='text-sm font-medium leading-none'>{props.title}</h3>
|
||||
<h3 className='text-sm leading-none font-medium'>{props.title}</h3>
|
||||
<p className='text-muted-foreground mt-0.5 text-xs sm:mt-1'>
|
||||
{props.description}
|
||||
</p>
|
||||
@@ -285,10 +285,7 @@ export function ApiKeysMutateDrawer({
|
||||
<FormItem>
|
||||
<FormLabel>{t('Name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder={t('Enter a name')}
|
||||
/>
|
||||
<Input {...field} placeholder={t('Enter a name')} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -492,29 +489,31 @@ export function ApiKeysMutateDrawer({
|
||||
|
||||
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||||
<section className='bg-card rounded-lg border'>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
className='hover:bg-muted/50 flex w-full items-center gap-2.5 px-3 py-2.5 text-left transition-colors sm:gap-3 sm:px-4 sm:py-3'
|
||||
>
|
||||
<div className='bg-muted text-muted-foreground flex size-8 shrink-0 items-center justify-center rounded-lg border sm:size-10'>
|
||||
<Settings2 className='size-4 sm:size-5' />
|
||||
</div>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<h3 className='text-sm font-medium leading-none'>
|
||||
{t('Advanced Settings')}
|
||||
</h3>
|
||||
<p className='text-muted-foreground mt-1 text-xs'>
|
||||
{t('Set API key access restrictions')}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'text-muted-foreground size-4 shrink-0 transition-transform',
|
||||
advancedOpen && 'rotate-180'
|
||||
)}
|
||||
<CollapsibleTrigger
|
||||
render={
|
||||
<button
|
||||
type='button'
|
||||
className='hover:bg-muted/50 flex w-full items-center gap-2.5 px-3 py-2.5 text-left transition-colors sm:gap-3 sm:px-4 sm:py-3'
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className='bg-muted text-muted-foreground flex size-8 shrink-0 items-center justify-center rounded-lg border sm:size-10'>
|
||||
<Settings2 className='size-4 sm:size-5' />
|
||||
</div>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<h3 className='text-sm leading-none font-medium'>
|
||||
{t('Advanced Settings')}
|
||||
</h3>
|
||||
<p className='text-muted-foreground mt-1 text-xs'>
|
||||
{t('Set API key access restrictions')}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'text-muted-foreground size-4 shrink-0 transition-transform',
|
||||
advancedOpen && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className='space-y-3 border-t p-3 sm:space-y-4 sm:p-4'>
|
||||
@@ -579,10 +578,10 @@ export function ApiKeysMutateDrawer({
|
||||
</form>
|
||||
</Form>
|
||||
<SheetFooter className='bg-background grid grid-cols-2 gap-2 border-t px-3 py-3 sm:flex sm:flex-row sm:justify-end sm:px-5 sm:py-4'>
|
||||
<SheetClose asChild>
|
||||
<Button variant='outline' className='w-full sm:w-auto'>
|
||||
{t('Close')}
|
||||
</Button>
|
||||
<SheetClose
|
||||
render={<Button variant='outline' className='w-full sm:w-auto' />}
|
||||
>
|
||||
{t('Close')}
|
||||
</SheetClose>
|
||||
<Button
|
||||
form='api-key-form'
|
||||
|
||||
+32
-111
@@ -4,7 +4,6 @@ import { getRouteApi } from '@tanstack/react-router'
|
||||
import {
|
||||
type SortingState,
|
||||
type VisibilityState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFacetedRowModel,
|
||||
getFacetedUniqueValues,
|
||||
@@ -13,29 +12,12 @@ import {
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table'
|
||||
import { useMediaQuery } from '@/hooks'
|
||||
import { Database } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { formatQuota } from '@/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Database } from 'lucide-react'
|
||||
import { useTableUrlState } from '@/hooks/use-table-url-state'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
DISABLED_ROW_DESKTOP,
|
||||
DISABLED_ROW_MOBILE,
|
||||
DataTablePagination,
|
||||
DataTableToolbar,
|
||||
TableSkeleton,
|
||||
TableEmpty,
|
||||
} from '@/components/data-table'
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
@@ -43,8 +25,12 @@ import {
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/empty'
|
||||
import { PageFooterPortal } from '@/components/layout'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
DISABLED_ROW_DESKTOP,
|
||||
DISABLED_ROW_MOBILE,
|
||||
DataTablePage,
|
||||
} from '@/components/data-table'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { getApiKeys, searchApiKeys } from '../api'
|
||||
import {
|
||||
@@ -56,7 +42,6 @@ import {
|
||||
import { type ApiKey } from '../types'
|
||||
import { ApiKeyCell } from './api-keys-cells'
|
||||
import { useApiKeysColumns } from './api-keys-columns'
|
||||
import { ApiKeysPrimaryButtons } from './api-keys-primary-buttons'
|
||||
import { useApiKeys } from './api-keys-provider'
|
||||
import { DataTableBulkActions } from './data-table-bulk-actions'
|
||||
import { DataTableRowActions } from './data-table-row-actions'
|
||||
@@ -188,7 +173,6 @@ export function ApiKeysTable() {
|
||||
const { t } = useTranslation()
|
||||
const { refreshTrigger } = useApiKeys()
|
||||
const columns = useApiKeysColumns()
|
||||
const isMobile = useMediaQuery('(max-width: 640px)')
|
||||
const [rowSelection, setRowSelection] = useState({})
|
||||
const [sorting, setSorting] = useState<SortingState>([])
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
||||
@@ -299,94 +283,31 @@ export function ApiKeysTable() {
|
||||
}, [pageCount, ensurePageInRange])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-3 sm:space-y-4'>
|
||||
<div className='flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between'>
|
||||
<ApiKeysPrimaryButtons />
|
||||
<div className='min-w-0 sm:flex sm:justify-end'>
|
||||
<DataTableToolbar
|
||||
table={table}
|
||||
searchPlaceholder={t('Filter by name or key...')}
|
||||
filters={[
|
||||
{
|
||||
columnId: 'status',
|
||||
title: t('Status'),
|
||||
options: API_KEY_STATUS_OPTIONS,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{isMobile ? (
|
||||
<ApiKeysMobileList
|
||||
table={table}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden rounded-md border transition-opacity duration-150',
|
||||
isFetching && !isLoading && 'pointer-events-none opacity-50'
|
||||
)}
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id} colSpan={header.colSpan}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableSkeleton table={table} keyPrefix='api-keys-skeleton' />
|
||||
) : table.getRowModel().rows.length === 0 ? (
|
||||
<TableEmpty
|
||||
colSpan={columns.length}
|
||||
title={t('No API Keys Found')}
|
||||
description={t(
|
||||
'No API keys available. Create your first API key to get started.'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && 'selected'}
|
||||
className={cn(
|
||||
isDisabledApiKeyRow(row.original) &&
|
||||
DISABLED_ROW_DESKTOP
|
||||
)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
{!isMobile && <DataTableBulkActions table={table} />}
|
||||
</div>
|
||||
<PageFooterPortal>
|
||||
<DataTablePagination table={table} />
|
||||
</PageFooterPortal>
|
||||
</>
|
||||
<DataTablePage
|
||||
table={table}
|
||||
columns={columns}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
emptyTitle={t('No API Keys Found')}
|
||||
emptyDescription={t(
|
||||
'No API keys available. Create your first API key to get started.'
|
||||
)}
|
||||
skeletonKeyPrefix='api-keys-skeleton'
|
||||
toolbarProps={{
|
||||
searchPlaceholder: t('Filter by name or key...'),
|
||||
filters: [
|
||||
{
|
||||
columnId: 'status',
|
||||
title: t('Status'),
|
||||
options: API_KEY_STATUS_OPTIONS,
|
||||
},
|
||||
],
|
||||
}}
|
||||
mobile={<ApiKeysMobileList table={table} isLoading={isLoading} />}
|
||||
getRowClassName={(row) =>
|
||||
isDisabledApiKeyRow(row.original) ? DISABLED_ROW_DESKTOP : undefined
|
||||
}
|
||||
bulkActions={<DataTableBulkActions table={table} />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -64,21 +64,23 @@ export function DataTableBulkActions<TData>({
|
||||
<>
|
||||
<BulkActionsToolbar table={table} entityName='API key'>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
className='size-8'
|
||||
onClick={handleBatchCopy}
|
||||
disabled={isCopying}
|
||||
aria-label={t('Copy selected keys')}
|
||||
>
|
||||
{isCopying ? (
|
||||
<Loader2 className='size-4 animate-spin' />
|
||||
) : (
|
||||
<Copy className='size-4' />
|
||||
)}
|
||||
</Button>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
className='size-8'
|
||||
onClick={handleBatchCopy}
|
||||
disabled={isCopying}
|
||||
aria-label={t('Copy selected keys')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{isCopying ? (
|
||||
<Loader2 className='size-4 animate-spin' />
|
||||
) : (
|
||||
<Copy className='size-4' />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('Copy selected keys')}</p>
|
||||
@@ -86,17 +88,19 @@ export function DataTableBulkActions<TData>({
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='destructive'
|
||||
size='icon'
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className='size-8'
|
||||
aria-label={t('Delete selected API keys')}
|
||||
>
|
||||
<Trash2 />
|
||||
<span className='sr-only'>{t('Delete selected API keys')}</span>
|
||||
</Button>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='destructive'
|
||||
size='icon'
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className='size-8'
|
||||
aria-label={t('Delete selected API keys')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Trash2 />
|
||||
<span className='sr-only'>{t('Delete selected API keys')}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('Delete selected API keys')}</p>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { DotsHorizontalIcon } from '@radix-ui/react-icons'
|
||||
import { type Row } from '@tanstack/react-table'
|
||||
import {
|
||||
Trash2,
|
||||
@@ -11,6 +10,7 @@ import {
|
||||
Copy,
|
||||
Link,
|
||||
Loader2,
|
||||
MoreHorizontal as DotsHorizontalIcon,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
@@ -154,27 +154,29 @@ export function DataTableRowActions<TData>({
|
||||
return (
|
||||
<div className='flex items-center justify-end gap-1'>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
onClick={handleToggleStatus}
|
||||
disabled={isTogglingStatus}
|
||||
aria-label={isEnabled ? t('Disable') : t('Enable')}
|
||||
className={
|
||||
isEnabled
|
||||
? 'text-destructive hover:text-destructive'
|
||||
: 'text-emerald-600 hover:text-emerald-600 dark:text-emerald-400 dark:hover:text-emerald-400'
|
||||
}
|
||||
>
|
||||
{isTogglingStatus ? (
|
||||
<Loader2 className='size-4 animate-spin' />
|
||||
) : isEnabled ? (
|
||||
<PowerOff className='size-4' />
|
||||
) : (
|
||||
<Power className='size-4' />
|
||||
)}
|
||||
</Button>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
onClick={handleToggleStatus}
|
||||
disabled={isTogglingStatus}
|
||||
aria-label={isEnabled ? t('Disable') : t('Enable')}
|
||||
className={
|
||||
isEnabled
|
||||
? 'text-destructive hover:text-destructive'
|
||||
: 'text-emerald-600 hover:text-emerald-600 dark:text-emerald-400 dark:hover:text-emerald-400'
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{isTogglingStatus ? (
|
||||
<Loader2 className='size-4 animate-spin' />
|
||||
) : isEnabled ? (
|
||||
<PowerOff className='size-4' />
|
||||
) : (
|
||||
<Power className='size-4' />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isEnabled ? t('Disable') : t('Enable')}
|
||||
@@ -182,14 +184,16 @@ export function DataTableRowActions<TData>({
|
||||
</Tooltip>
|
||||
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='data-[state=open]:bg-muted flex h-8 w-8 p-0'
|
||||
>
|
||||
<DotsHorizontalIcon className='h-4 w-4' />
|
||||
<span className='sr-only'>{t('Open menu')}</span>
|
||||
</Button>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='data-popup-open:bg-muted flex h-8 w-8 p-0'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<DotsHorizontalIcon className='h-4 w-4' />
|
||||
<span className='sr-only'>{t('Open menu')}</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-[200px]'>
|
||||
<DropdownMenuItem
|
||||
|
||||
+4
@@ -1,6 +1,7 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SectionPageLayout } from '@/components/layout'
|
||||
import { ApiKeysDialogs } from './components/api-keys-dialogs'
|
||||
import { ApiKeysPrimaryButtons } from './components/api-keys-primary-buttons'
|
||||
import { ApiKeysProvider } from './components/api-keys-provider'
|
||||
import { ApiKeysTable } from './components/api-keys-table'
|
||||
|
||||
@@ -13,6 +14,9 @@ export function ApiKeys() {
|
||||
<SectionPageLayout.Description>
|
||||
{t('Manage your API keys for accessing the service')}
|
||||
</SectionPageLayout.Description>
|
||||
<SectionPageLayout.Actions>
|
||||
<ApiKeysPrimaryButtons />
|
||||
</SectionPageLayout.Actions>
|
||||
<SectionPageLayout.Content>
|
||||
<ApiKeysTable />
|
||||
</SectionPageLayout.Content>
|
||||
|
||||
+10
-4
@@ -96,10 +96,16 @@ export function LegalDocument({
|
||||
'The administrator configured an external link for this document.'
|
||||
)}
|
||||
</p>
|
||||
<Button asChild>
|
||||
<a href={rawContent} target='_blank' rel='noopener noreferrer'>
|
||||
{t('View document')}
|
||||
</a>
|
||||
<Button
|
||||
render={
|
||||
<a
|
||||
href={rawContent}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
/>
|
||||
}
|
||||
>
|
||||
{t('View document')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -84,18 +84,20 @@ export function DataTableBulkActions<TData>({
|
||||
<>
|
||||
<BulkActionsToolbar table={table} entityName='model'>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
onClick={handleEnableAll}
|
||||
className='size-8'
|
||||
aria-label={t('Enable selected models')}
|
||||
title={t('Enable selected models')}
|
||||
>
|
||||
<Power />
|
||||
<span className='sr-only'>{t('Enable selected models')}</span>
|
||||
</Button>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
onClick={handleEnableAll}
|
||||
className='size-8'
|
||||
aria-label={t('Enable selected models')}
|
||||
title={t('Enable selected models')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Power />
|
||||
<span className='sr-only'>{t('Enable selected models')}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('Enable selected models')}</p>
|
||||
@@ -103,18 +105,20 @@ export function DataTableBulkActions<TData>({
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
onClick={handleDisableAll}
|
||||
className='size-8'
|
||||
aria-label={t('Disable selected models')}
|
||||
title={t('Disable selected models')}
|
||||
>
|
||||
<PowerOff />
|
||||
<span className='sr-only'>{t('Disable selected models')}</span>
|
||||
</Button>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
onClick={handleDisableAll}
|
||||
className='size-8'
|
||||
aria-label={t('Disable selected models')}
|
||||
title={t('Disable selected models')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<PowerOff />
|
||||
<span className='sr-only'>{t('Disable selected models')}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('Disable selected models')}</p>
|
||||
@@ -122,18 +126,20 @@ export function DataTableBulkActions<TData>({
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
onClick={handleCopyNames}
|
||||
className='size-8'
|
||||
aria-label={t('Copy model names')}
|
||||
title={t('Copy model names')}
|
||||
>
|
||||
<Copy />
|
||||
<span className='sr-only'>{t('Copy model names')}</span>
|
||||
</Button>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
onClick={handleCopyNames}
|
||||
className='size-8'
|
||||
aria-label={t('Copy model names')}
|
||||
title={t('Copy model names')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Copy />
|
||||
<span className='sr-only'>{t('Copy model names')}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('Copy model names')}</p>
|
||||
@@ -141,18 +147,20 @@ export function DataTableBulkActions<TData>({
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='destructive'
|
||||
size='icon'
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className='size-8'
|
||||
aria-label={t('Delete selected models')}
|
||||
title={t('Delete selected models')}
|
||||
>
|
||||
<Trash2 />
|
||||
<span className='sr-only'>{t('Delete selected models')}</span>
|
||||
</Button>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='destructive'
|
||||
size='icon'
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className='size-8'
|
||||
aria-label={t('Delete selected models')}
|
||||
title={t('Delete selected models')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Trash2 />
|
||||
<span className='sr-only'>{t('Delete selected models')}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('Delete selected models')}</p>
|
||||
|
||||
@@ -45,14 +45,16 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='data-[state=open]:bg-muted flex h-8 w-8 p-0'
|
||||
>
|
||||
<MoreHorizontal className='h-4 w-4' />
|
||||
<span className='sr-only'>{t('Open menu')}</span>
|
||||
</Button>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='data-popup-open:bg-muted flex h-8 w-8 p-0'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontal className='h-4 w-4' />
|
||||
<span className='sr-only'>{t('Open menu')}</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-48'>
|
||||
{/* Edit */}
|
||||
|
||||
+18
-101
@@ -2,7 +2,6 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { getRouteApi } from '@tanstack/react-router'
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
type VisibilityState,
|
||||
@@ -10,7 +9,6 @@ import {
|
||||
import { useMediaQuery } from '@/hooks'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useTableUrlState } from '@/hooks/use-table-url-state'
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -22,22 +20,7 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
DataTableToolbar,
|
||||
MobileCardList,
|
||||
TableEmpty,
|
||||
TableSkeleton,
|
||||
} from '@/components/data-table'
|
||||
import { DataTablePagination } from '@/components/data-table/pagination'
|
||||
import { PageFooterPortal } from '@/components/layout'
|
||||
import { DataTablePage } from '@/components/data-table'
|
||||
import { deleteDeployment, listDeployments, searchDeployments } from '../api'
|
||||
import { getDeploymentStatusOptions } from '../constants'
|
||||
import { deploymentsQueryKeys } from '../lib'
|
||||
@@ -229,95 +212,29 @@ export function DeploymentsTable() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-3 sm:space-y-4'>
|
||||
<DataTableToolbar
|
||||
table={table}
|
||||
searchPlaceholder={t('Search deployments...')}
|
||||
filters={[
|
||||
<DataTablePage
|
||||
table={table}
|
||||
columns={columns}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
emptyTitle={t('No Deployments Found')}
|
||||
emptyDescription={t(
|
||||
'No deployments available. Create one to get started.'
|
||||
)}
|
||||
skeletonKeyPrefix='deployment-skeleton'
|
||||
applyHeaderSize
|
||||
toolbarProps={{
|
||||
searchPlaceholder: t('Search deployments...'),
|
||||
filters: [
|
||||
{
|
||||
columnId: 'status',
|
||||
title: t('Status'),
|
||||
options: statusFilterOptions,
|
||||
singleSelect: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{isMobile ? (
|
||||
<MobileCardList
|
||||
table={table}
|
||||
isLoading={isLoading}
|
||||
emptyTitle={t('No Deployments Found')}
|
||||
emptyDescription={t(
|
||||
'No deployments available. Create one to get started.'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden rounded-md border transition-opacity duration-150',
|
||||
isFetching && !isLoading && 'pointer-events-none opacity-50'
|
||||
)}
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
style={{ width: header.getSize() }}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableSkeleton
|
||||
table={table}
|
||||
keyPrefix='deployment-skeleton'
|
||||
/>
|
||||
) : table.getRowModel().rows.length === 0 ? (
|
||||
<TableEmpty
|
||||
colSpan={table.getVisibleLeafColumns().length}
|
||||
title={t('No Deployments Found')}
|
||||
description={t(
|
||||
'No deployments available. Create one to get started.'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PageFooterPortal>
|
||||
<DataTablePagination
|
||||
table={table as ReturnType<typeof useReactTable>}
|
||||
/>
|
||||
</PageFooterPortal>
|
||||
],
|
||||
}}
|
||||
/>
|
||||
|
||||
<ViewLogsDialog
|
||||
open={logsOpen}
|
||||
|
||||
+2
-2
@@ -730,8 +730,8 @@ export function CreateDeploymentDrawer({
|
||||
</Form>
|
||||
|
||||
<SheetFooter className='gap-2'>
|
||||
<SheetClose asChild>
|
||||
<Button variant='outline'>{t('Cancel')}</Button>
|
||||
<SheetClose render={<Button variant='outline' />}>
|
||||
{t('Cancel')}
|
||||
</SheetClose>
|
||||
<Button
|
||||
form='deployment-form'
|
||||
|
||||
+1
-5
@@ -100,11 +100,7 @@ export function MissingModelsDialog({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className='flex max-h-[85vh] max-w-2xl flex-col gap-3 p-4'
|
||||
onOpenAutoFocus={(event) => {
|
||||
if (isMobile) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}}
|
||||
initialFocus={!isMobile}
|
||||
>
|
||||
<DialogHeader className='flex-shrink-0 text-start'>
|
||||
<DialogTitle>{t('Missing Models')}</DialogTitle>
|
||||
|
||||
+11
-9
@@ -183,15 +183,17 @@ export function PrefillGroupManagementDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='text-muted-foreground hover:text-foreground absolute top-4 right-4 rounded-full border border-transparent sm:top-5 sm:right-6'
|
||||
>
|
||||
<span className='sr-only'>{t('Close dialog')}</span>
|
||||
<X className='h-4 w-4' />
|
||||
</Button>
|
||||
<DialogClose
|
||||
render={
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='text-muted-foreground hover:text-foreground absolute top-4 right-4 rounded-full border border-transparent sm:top-5 sm:right-6'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span className='sr-only'>{t('Close dialog')}</span>
|
||||
<X className='h-4 w-4' />
|
||||
</DialogClose>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -110,11 +110,7 @@ export function SyncWizardDialog({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className='flex max-h-[90vh] w-full flex-col gap-4 p-4 sm:max-w-2xl sm:p-6'
|
||||
onOpenAutoFocus={(event) => {
|
||||
if (isMobile) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}}
|
||||
initialFocus={!isMobile}
|
||||
>
|
||||
<DialogHeader className='flex-shrink-0 text-start'>
|
||||
<DialogTitle>{t('Sync Upstream Models')}</DialogTitle>
|
||||
|
||||
+14
-18
@@ -230,15 +230,17 @@ export function UpstreamConflictDialog({
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className={isMobile ? 'h-7 w-7 p-0' : 'h-7 gap-2 px-2 text-xs'}
|
||||
>
|
||||
<MousePointerClick className='h-3.5 w-3.5' />
|
||||
{!isMobile && 'View diff'}
|
||||
</Button>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className={isMobile ? 'h-7 w-7 p-0' : 'h-7 gap-2 px-2 text-xs'}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MousePointerClick className='h-3.5 w-3.5' />
|
||||
{!isMobile && 'View diff'}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-[min(90vw,24rem)] space-y-4 text-sm'>
|
||||
<div>
|
||||
@@ -279,10 +281,8 @@ export function UpstreamConflictDialog({
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && 'indeterminate')
|
||||
}
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
indeterminate={table.getIsSomePageRowsSelected()}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label='Select all'
|
||||
/>
|
||||
@@ -437,11 +437,7 @@ export function UpstreamConflictDialog({
|
||||
>
|
||||
<DialogContent
|
||||
className='flex max-h-[90vh] w-full flex-col gap-4 p-4 sm:max-w-5xl sm:p-6'
|
||||
onOpenAutoFocus={(event) => {
|
||||
if (isMobile) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}}
|
||||
initialFocus={!isMobile}
|
||||
>
|
||||
<div className='flex min-h-0 flex-1 flex-col gap-4 overflow-hidden'>
|
||||
<DialogHeader className='flex-shrink-0 text-start'>
|
||||
|
||||
@@ -105,7 +105,7 @@ export function ViewDetailsDialog({
|
||||
</DialogHeader>
|
||||
|
||||
<div className='max-h-[calc(100dvh-8.5rem)] space-y-3 overflow-y-auto py-2 pr-1 sm:max-h-[72vh] sm:space-y-4'>
|
||||
<div className='flex flex-wrap items-center justify-between gap-2'>
|
||||
<div className='flex flex-wrap items-center justify-between gap-2'>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
{t('Deployment ID')}:{' '}
|
||||
<span className='font-mono'>{deploymentId}</span>
|
||||
@@ -252,7 +252,11 @@ export function ViewDetailsDialog({
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant='outline' onClick={() => onOpenChange(false)} className='w-full sm:w-auto'>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => onOpenChange(false)}
|
||||
className='w-full sm:w-auto'
|
||||
>
|
||||
{t('Close')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -176,7 +176,7 @@ export function ViewLogsDialog({
|
||||
</div>
|
||||
<Select
|
||||
value={containerId}
|
||||
onValueChange={(v) => setContainerId(v)}
|
||||
onValueChange={(v) => v !== null && setContainerId(v)}
|
||||
disabled={isLoadingContainers || containers.length === 0}
|
||||
>
|
||||
<SelectTrigger>
|
||||
|
||||
+23
-17
@@ -793,7 +793,11 @@ export function ModelMutateDrawer({
|
||||
<div className='space-y-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<h3 className='text-sm font-semibold'>{t('Endpoints')}</h3>
|
||||
<Select onValueChange={handleFillEndpointTemplate}>
|
||||
<Select<string>
|
||||
onValueChange={(v) =>
|
||||
v !== null && handleFillEndpointTemplate(v)
|
||||
}
|
||||
>
|
||||
<SelectTrigger size='sm' className='w-[200px]'>
|
||||
<SelectValue placeholder={t('Load template...')} />
|
||||
</SelectTrigger>
|
||||
@@ -1049,19 +1053,21 @@ export function ModelMutateDrawer({
|
||||
open={advancedOpen}
|
||||
onOpenChange={setAdvancedOpen}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
className='flex w-full items-center justify-between'
|
||||
>
|
||||
{t('Advanced options')}
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform duration-200 ${
|
||||
advancedOpen ? 'rotate-180' : ''
|
||||
}`}
|
||||
<CollapsibleTrigger
|
||||
render={
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
className='flex w-full items-center justify-between'
|
||||
/>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{t('Advanced options')}
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform duration-200 ${
|
||||
advancedOpen ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className='space-y-6 pt-6'>
|
||||
<FormField
|
||||
@@ -1233,10 +1239,10 @@ export function ModelMutateDrawer({
|
||||
</Form>
|
||||
|
||||
<SheetFooter className='grid grid-cols-2 gap-2 border-t px-4 py-3 sm:flex sm:px-6 sm:py-4'>
|
||||
<SheetClose asChild>
|
||||
<Button variant='outline' disabled={isSubmitting}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<SheetClose
|
||||
render={<Button variant='outline' disabled={isSubmitting} />}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</SheetClose>
|
||||
<Button form='model-form' type='submit' disabled={isSubmitting}>
|
||||
{isSubmitting && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
|
||||
|
||||
+9
-6
@@ -253,8 +253,9 @@ export function PrefillGroupFormDrawer({
|
||||
<FormLabel>Group Type</FormLabel>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={(value: PrefillGroupType) =>
|
||||
field.onChange(value)
|
||||
onValueChange={(value) =>
|
||||
value !== null &&
|
||||
field.onChange(value as PrefillGroupType)
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
@@ -344,10 +345,12 @@ export function PrefillGroupFormDrawer({
|
||||
</Form>
|
||||
|
||||
<SheetFooter className='grid grid-cols-2 gap-2 border-t px-4 py-3 sm:flex sm:px-6 sm:py-4'>
|
||||
<SheetClose asChild>
|
||||
<Button type='button' variant='outline' disabled={isSaving}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<SheetClose
|
||||
render={
|
||||
<Button type='button' variant='outline' disabled={isSaving} />
|
||||
}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</SheetClose>
|
||||
<Button type='submit' form='prefill-group-form' disabled={isSaving}>
|
||||
{isSaving && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
|
||||
|
||||
+13
-17
@@ -73,10 +73,8 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && 'indeterminate')
|
||||
}
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
indeterminate={table.getIsSomePageRowsSelected()}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label='Select all'
|
||||
/>
|
||||
@@ -202,9 +200,7 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>{badge}</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipTrigger render={<div />}>{badge}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side='top'
|
||||
className='border-border bg-popover max-h-48 max-w-[320px] overflow-y-auto p-2'
|
||||
@@ -324,8 +320,8 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>{renderLimitedItems(tagBadges, 2)}</div>
|
||||
<TooltipTrigger render={<div />}>
|
||||
{renderLimitedItems(tagBadges, 2)}
|
||||
</TooltipTrigger>
|
||||
{tagArray.length > 2 && (
|
||||
<TooltipContent
|
||||
@@ -363,8 +359,8 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>{renderLimitedItems(endpointBadges, 2)}</div>
|
||||
<TooltipTrigger render={<div />}>
|
||||
{renderLimitedItems(endpointBadges, 2)}
|
||||
</TooltipTrigger>
|
||||
{endpointArray.length > 2 && (
|
||||
<TooltipContent
|
||||
@@ -411,8 +407,8 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>{renderLimitedItems(channelBadges, 2)}</div>
|
||||
<TooltipTrigger render={<div />}>
|
||||
{renderLimitedItems(channelBadges, 2)}
|
||||
</TooltipTrigger>
|
||||
{channels.length > 2 && (
|
||||
<TooltipContent
|
||||
@@ -451,8 +447,8 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>{renderLimitedItems(groupBadges, 2)}</div>
|
||||
<TooltipTrigger render={<div />}>
|
||||
{renderLimitedItems(groupBadges, 2)}
|
||||
</TooltipTrigger>
|
||||
{groups.length > 2 && (
|
||||
<TooltipContent
|
||||
@@ -504,8 +500,8 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>{renderLimitedItems(quotaBadges, 2)}</div>
|
||||
<TooltipTrigger render={<div />}>
|
||||
{renderLimitedItems(quotaBadges, 2)}
|
||||
</TooltipTrigger>
|
||||
{quotaTypes.length > 2 && (
|
||||
<TooltipContent
|
||||
|
||||
@@ -53,10 +53,8 @@ export function ModelsPrimaryButtons() {
|
||||
|
||||
{/* More Actions */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='outline' size='sm'>
|
||||
<MoreHorizontal className='h-4 w-4' />
|
||||
</Button>
|
||||
<DropdownMenuTrigger render={<Button variant='outline' size='sm' />}>
|
||||
<MoreHorizontal className='h-4 w-4' />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-56'>
|
||||
<DropdownMenuItem onClick={handleMissingModels}>
|
||||
|
||||
+37
-124
@@ -2,7 +2,6 @@ import { useState, useMemo, useEffect } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getRouteApi } from '@tanstack/react-router'
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
type SortingState,
|
||||
@@ -10,24 +9,8 @@ import {
|
||||
} from '@tanstack/react-table'
|
||||
import { useMediaQuery } from '@/hooks'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useTableUrlState } from '@/hooks/use-table-url-state'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
DataTableToolbar,
|
||||
TableSkeleton,
|
||||
TableEmpty,
|
||||
MobileCardList,
|
||||
} from '@/components/data-table'
|
||||
import { DataTablePagination } from '@/components/data-table/pagination'
|
||||
import { PageFooterPortal } from '@/components/layout'
|
||||
import { DataTablePage } from '@/components/data-table'
|
||||
import { getModels, searchModels, getVendors } from '../api'
|
||||
import {
|
||||
DEFAULT_PAGE_SIZE,
|
||||
@@ -219,111 +202,41 @@ export function ModelsTable() {
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-3 sm:space-y-4'>
|
||||
<DataTableToolbar
|
||||
table={table}
|
||||
searchPlaceholder={t('Filter by model name...')}
|
||||
filters={[
|
||||
{
|
||||
columnId: 'status',
|
||||
title: t('Status'),
|
||||
options: [...getModelStatusOptions(t)],
|
||||
singleSelect: true,
|
||||
},
|
||||
{
|
||||
columnId: 'vendor_id',
|
||||
title: t('Vendor'),
|
||||
options: vendorFilterOptions,
|
||||
singleSelect: true,
|
||||
},
|
||||
{
|
||||
columnId: 'sync_official',
|
||||
title: t('Official Sync'),
|
||||
options: [...getSyncStatusOptions(t)],
|
||||
singleSelect: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{isMobile ? (
|
||||
<MobileCardList
|
||||
table={table}
|
||||
isLoading={isLoading}
|
||||
emptyTitle={t('No Models Found')}
|
||||
emptyDescription={t(
|
||||
'No models available. Create your first model to get started.'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden rounded-md border transition-opacity duration-150',
|
||||
isFetching && !isLoading && 'pointer-events-none opacity-50'
|
||||
)}
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
style={{ width: header.getSize() }}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableSkeleton table={table} keyPrefix='model-skeleton' />
|
||||
) : table.getRowModel().rows.length === 0 ? (
|
||||
<TableEmpty
|
||||
colSpan={columns.length}
|
||||
title={t('No Models Found')}
|
||||
description={t(
|
||||
'No models available. Create your first model to get started.'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && 'selected'}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<DataTableBulkActions table={table} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<PageFooterPortal>
|
||||
<DataTablePagination
|
||||
table={table as ReturnType<typeof useReactTable>}
|
||||
/>
|
||||
</PageFooterPortal>
|
||||
</>
|
||||
<DataTablePage
|
||||
table={table}
|
||||
columns={columns}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
emptyTitle={t('No Models Found')}
|
||||
emptyDescription={t(
|
||||
'No models available. Create your first model to get started.'
|
||||
)}
|
||||
skeletonKeyPrefix='model-skeleton'
|
||||
applyHeaderSize
|
||||
toolbarProps={{
|
||||
searchPlaceholder: t('Filter by model name...'),
|
||||
filters: [
|
||||
{
|
||||
columnId: 'status',
|
||||
title: t('Status'),
|
||||
options: [...getModelStatusOptions(t)],
|
||||
singleSelect: true,
|
||||
},
|
||||
{
|
||||
columnId: 'vendor_id',
|
||||
title: t('Vendor'),
|
||||
options: vendorFilterOptions,
|
||||
singleSelect: true,
|
||||
},
|
||||
{
|
||||
columnId: 'sync_official',
|
||||
title: t('Official Sync'),
|
||||
options: [...getSyncStatusOptions(t)],
|
||||
singleSelect: true,
|
||||
},
|
||||
],
|
||||
}}
|
||||
bulkActions={<DataTableBulkActions table={table} />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
+1
-3
@@ -107,9 +107,7 @@ function ModelsContent() {
|
||||
return (
|
||||
<>
|
||||
<SectionPageLayout>
|
||||
<SectionPageLayout.Title>
|
||||
{t(meta.titleKey)}
|
||||
</SectionPageLayout.Title>
|
||||
<SectionPageLayout.Title>{t(meta.titleKey)}</SectionPageLayout.Title>
|
||||
<SectionPageLayout.Description>
|
||||
{t(meta.descriptionKey)}
|
||||
</SectionPageLayout.Description>
|
||||
|
||||
@@ -31,17 +31,19 @@ export function MessageActionButton({
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className={`${baseStyle} ${className}`}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
>
|
||||
<Icon className={MESSAGE_ACTION_BUTTON_STYLES.ICON} />
|
||||
</Button>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className={`${baseStyle} ${className}`}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Icon className={MESSAGE_ACTION_BUTTON_STYLES.ICON} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{label}</p>
|
||||
|
||||
@@ -56,7 +56,7 @@ export function MessageActions({
|
||||
: 'opacity-0 group-hover:opacity-100 max-md:opacity-100'
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<TooltipProvider delay={300}>
|
||||
<div
|
||||
className={`flex items-center gap-0.5 transition-opacity ${visibilityClass} ${className}`}
|
||||
>
|
||||
|
||||
@@ -114,16 +114,18 @@ export function PlaygroundInput({
|
||||
<PromptInputFooter className='p-2.5'>
|
||||
<PromptInputTools>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<PromptInputButton
|
||||
className='!rounded-full border font-medium'
|
||||
disabled={disabled}
|
||||
variant='outline'
|
||||
>
|
||||
<PaperclipIcon size={16} />
|
||||
<span className='hidden sm:inline'>{t('Attach')}</span>
|
||||
<span className='sr-only sm:hidden'>{t('Attach')}</span>
|
||||
</PromptInputButton>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<PromptInputButton
|
||||
className='!rounded-full border font-medium'
|
||||
disabled={disabled}
|
||||
variant='outline'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<PaperclipIcon size={16} />
|
||||
<span className='hidden sm:inline'>{t('Attach')}</span>
|
||||
<span className='sr-only sm:hidden'>{t('Attach')}</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='start'>
|
||||
<DropdownMenuItem
|
||||
|
||||
@@ -242,8 +242,7 @@ export function DynamicPricingBreakdown({
|
||||
key={`tier-mobile-${i}`}
|
||||
className={cn(
|
||||
'rounded-md border p-2',
|
||||
isMatched &&
|
||||
'border-emerald-500/40 bg-emerald-500/10'
|
||||
isMatched && 'border-emerald-500/40 bg-emerald-500/10'
|
||||
)}
|
||||
>
|
||||
<div className='mb-1.5 flex flex-wrap items-center gap-1.5'>
|
||||
|
||||
@@ -1,727 +0,0 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import {
|
||||
ArrowUpDown,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Filter,
|
||||
RotateCcw,
|
||||
Table2,
|
||||
X,
|
||||
} 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 {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import {
|
||||
FILTER_ALL,
|
||||
QUOTA_TYPES,
|
||||
ENDPOINT_TYPES,
|
||||
VIEW_MODES,
|
||||
getSortLabels,
|
||||
getQuotaTypeLabels,
|
||||
getEndpointTypeLabels,
|
||||
type SortOption,
|
||||
type ViewMode,
|
||||
} from '../constants'
|
||||
import type { PricingVendor, TokenUnit } from '../types'
|
||||
|
||||
interface FilterOption {
|
||||
value: string
|
||||
label: string
|
||||
icon?: React.ReactNode
|
||||
}
|
||||
|
||||
interface FilterChipProps {
|
||||
label: string
|
||||
value: string
|
||||
options: FilterOption[]
|
||||
onChange: (value: string) => void
|
||||
defaultValue?: string
|
||||
searchPlaceholder?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
function FilterChip(props: FilterChipProps) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const defaultVal = props.defaultValue ?? FILTER_ALL
|
||||
const isActive = props.value !== defaultVal
|
||||
const selectedOption = props.options.find((o) => o.value === props.value)
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
role='combobox'
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm font-medium transition-all',
|
||||
isActive
|
||||
? 'border-foreground/30 bg-foreground/5 text-foreground'
|
||||
: 'border-border bg-muted/50 text-muted-foreground hover:border-border/80 hover:text-foreground hover:bg-muted',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{selectedOption?.icon}
|
||||
<span className='max-w-[100px] truncate'>
|
||||
{isActive && selectedOption ? selectedOption.label : props.label}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'size-3.5 transition-transform',
|
||||
open && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-52 p-0' align='start'>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={props.searchPlaceholder || t('Search...')}
|
||||
className='h-9'
|
||||
/>
|
||||
<CommandList className='max-h-64'>
|
||||
<CommandEmpty>{t('No results found')}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{props.options.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.label}
|
||||
onSelect={() => {
|
||||
props.onChange(option.value)
|
||||
setOpen(false)
|
||||
}}
|
||||
className='gap-2'
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'size-4 shrink-0',
|
||||
props.value === option.value ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
{option.icon && (
|
||||
<span className='shrink-0'>{option.icon}</span>
|
||||
)}
|
||||
<span className='truncate'>{option.label}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
interface SegmentedControlProps {
|
||||
options: Array<{
|
||||
value: string
|
||||
label?: string
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
tooltip?: string
|
||||
}>
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
ariaLabel?: string
|
||||
}
|
||||
|
||||
function SegmentedControl(props: SegmentedControlProps) {
|
||||
return (
|
||||
<div
|
||||
role='group'
|
||||
aria-label={props.ariaLabel}
|
||||
className='bg-muted/60 inline-flex h-8 items-center rounded-md border p-0.5'
|
||||
>
|
||||
{props.options.map((option) => {
|
||||
const isActive = option.value === props.value
|
||||
const Icon = option.icon
|
||||
const isIconOnly = Icon && !option.label
|
||||
const button = (
|
||||
<button
|
||||
key={option.value}
|
||||
type='button'
|
||||
onClick={() => props.onChange(option.value)}
|
||||
className={cn(
|
||||
'inline-flex h-[calc(100%)] items-center justify-center rounded-[5px] text-xs font-medium transition-all',
|
||||
isIconOnly ? 'w-7' : 'gap-1.5 px-2.5',
|
||||
isActive
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
aria-pressed={isActive}
|
||||
>
|
||||
{Icon && <Icon className='size-3.5' />}
|
||||
{option.label}
|
||||
</button>
|
||||
)
|
||||
|
||||
if (option.tooltip) {
|
||||
return (
|
||||
<Tooltip key={option.value}>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent side='bottom' className='text-xs'>
|
||||
{option.tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
return button
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ActiveFilterBadgeProps {
|
||||
label: string
|
||||
onRemove: () => void
|
||||
}
|
||||
|
||||
function ActiveFilterBadge(props: ActiveFilterBadgeProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='gap-1 py-1 pr-1 pl-2.5 text-xs font-medium'
|
||||
>
|
||||
{props.label}
|
||||
<button
|
||||
type='button'
|
||||
onClick={props.onRemove}
|
||||
className='hover:bg-secondary-foreground/10 rounded-full p-0.5 transition-colors'
|
||||
aria-label={`${t('Remove filter')}: ${props.label}`}
|
||||
>
|
||||
<X className='size-3' />
|
||||
</button>
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
interface MobileFilterGroupProps {
|
||||
title: string
|
||||
value: string
|
||||
options: FilterOption[]
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
function MobileFilterGroup(props: MobileFilterGroupProps) {
|
||||
return (
|
||||
<div className='space-y-2.5'>
|
||||
<h3 className='text-foreground text-sm font-semibold'>{props.title}</h3>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{props.options.map((option) => {
|
||||
const isActive = props.value === option.value
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type='button'
|
||||
onClick={() => props.onChange(option.value)}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm font-medium transition-all',
|
||||
isActive
|
||||
? 'border-foreground/30 bg-foreground/5 text-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:text-foreground border-transparent'
|
||||
)}
|
||||
>
|
||||
{option.icon}
|
||||
{option.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface FilterBarProps {
|
||||
quotaTypeFilter: string
|
||||
endpointTypeFilter: string
|
||||
vendorFilter: string
|
||||
groupFilter: string
|
||||
tagFilter: string
|
||||
onQuotaTypeChange: (v: string) => void
|
||||
onEndpointTypeChange: (v: string) => void
|
||||
onVendorChange: (v: string) => void
|
||||
onGroupChange: (v: string) => void
|
||||
onTagChange: (v: string) => void
|
||||
vendors: PricingVendor[]
|
||||
groups: string[]
|
||||
tags: string[]
|
||||
sortBy: string
|
||||
onSortChange: (v: string) => void
|
||||
tokenUnit: TokenUnit
|
||||
onTokenUnitChange: (v: TokenUnit) => void
|
||||
showRechargePrice: boolean
|
||||
onRechargePriceChange: (v: boolean) => void
|
||||
viewMode: ViewMode
|
||||
onViewModeChange: (v: ViewMode) => void
|
||||
hasActiveFilters: boolean
|
||||
activeFilterCount: number
|
||||
onClearFilters: () => void
|
||||
filteredCount: number
|
||||
totalCount?: number
|
||||
}
|
||||
|
||||
export function FilterBar(props: FilterBarProps) {
|
||||
const { t } = useTranslation()
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
|
||||
const {
|
||||
quotaTypeFilter,
|
||||
endpointTypeFilter,
|
||||
vendorFilter,
|
||||
groupFilter,
|
||||
tagFilter,
|
||||
onQuotaTypeChange,
|
||||
onEndpointTypeChange,
|
||||
onVendorChange,
|
||||
onGroupChange,
|
||||
onTagChange,
|
||||
vendors,
|
||||
groups,
|
||||
tags,
|
||||
onTokenUnitChange,
|
||||
onViewModeChange,
|
||||
onRechargePriceChange,
|
||||
} = props
|
||||
|
||||
const quotaTypeLabels = getQuotaTypeLabels(t)
|
||||
const endpointTypeLabels = getEndpointTypeLabels(t)
|
||||
const sortLabels = getSortLabels(t)
|
||||
|
||||
const typeOptions = useMemo<FilterOption[]>(
|
||||
() =>
|
||||
Object.entries(quotaTypeLabels).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
})),
|
||||
[quotaTypeLabels]
|
||||
)
|
||||
|
||||
const endpointOptions = useMemo<FilterOption[]>(
|
||||
() =>
|
||||
Object.entries(endpointTypeLabels).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
})),
|
||||
[endpointTypeLabels]
|
||||
)
|
||||
|
||||
const vendorOptions = useMemo<FilterOption[]>(
|
||||
() => [
|
||||
{ value: FILTER_ALL, label: t('All Vendors') },
|
||||
...vendors.map((v) => ({
|
||||
value: v.name,
|
||||
label: v.name,
|
||||
icon: v.icon ? getLobeIcon(v.icon, 14) : undefined,
|
||||
})),
|
||||
],
|
||||
[vendors, t]
|
||||
)
|
||||
|
||||
const groupOptions = useMemo<FilterOption[]>(
|
||||
() => [
|
||||
{ value: FILTER_ALL, label: t('All Groups') },
|
||||
...groups.map((g) => ({ value: g, label: g })),
|
||||
],
|
||||
[groups, t]
|
||||
)
|
||||
|
||||
const tagOptions = useMemo<FilterOption[]>(
|
||||
() => [
|
||||
{ value: FILTER_ALL, label: t('All Tags') },
|
||||
...tags.map((tag) => ({ value: tag, label: tag })),
|
||||
],
|
||||
[tags, t]
|
||||
)
|
||||
|
||||
const activeFilters = useMemo(() => {
|
||||
const filters: Array<{ key: string; label: string; onRemove: () => void }> =
|
||||
[]
|
||||
|
||||
if (quotaTypeFilter !== QUOTA_TYPES.ALL) {
|
||||
filters.push({
|
||||
key: 'quotaType',
|
||||
label:
|
||||
quotaTypeLabels[quotaTypeFilter as keyof typeof quotaTypeLabels] ||
|
||||
quotaTypeFilter,
|
||||
onRemove: () => onQuotaTypeChange(QUOTA_TYPES.ALL),
|
||||
})
|
||||
}
|
||||
|
||||
if (endpointTypeFilter !== ENDPOINT_TYPES.ALL) {
|
||||
filters.push({
|
||||
key: 'endpointType',
|
||||
label:
|
||||
endpointTypeLabels[
|
||||
endpointTypeFilter as keyof typeof endpointTypeLabels
|
||||
] || endpointTypeFilter,
|
||||
onRemove: () => onEndpointTypeChange(ENDPOINT_TYPES.ALL),
|
||||
})
|
||||
}
|
||||
|
||||
if (vendorFilter !== FILTER_ALL) {
|
||||
filters.push({
|
||||
key: 'vendor',
|
||||
label: vendorFilter,
|
||||
onRemove: () => onVendorChange(FILTER_ALL),
|
||||
})
|
||||
}
|
||||
|
||||
if (groupFilter !== FILTER_ALL) {
|
||||
filters.push({
|
||||
key: 'group',
|
||||
label: groupFilter,
|
||||
onRemove: () => onGroupChange(FILTER_ALL),
|
||||
})
|
||||
}
|
||||
|
||||
if (tagFilter !== FILTER_ALL) {
|
||||
filters.push({
|
||||
key: 'tag',
|
||||
label: tagFilter,
|
||||
onRemove: () => onTagChange(FILTER_ALL),
|
||||
})
|
||||
}
|
||||
|
||||
return filters
|
||||
}, [
|
||||
quotaTypeFilter,
|
||||
endpointTypeFilter,
|
||||
vendorFilter,
|
||||
groupFilter,
|
||||
tagFilter,
|
||||
onQuotaTypeChange,
|
||||
onEndpointTypeChange,
|
||||
onVendorChange,
|
||||
onGroupChange,
|
||||
onTagChange,
|
||||
quotaTypeLabels,
|
||||
endpointTypeLabels,
|
||||
])
|
||||
|
||||
const handleTokenUnitChange = useCallback(
|
||||
(v: string) => onTokenUnitChange(v as TokenUnit),
|
||||
[onTokenUnitChange]
|
||||
)
|
||||
|
||||
const handleViewModeChange = useCallback(
|
||||
(v: string) => onViewModeChange(v as ViewMode),
|
||||
[onViewModeChange]
|
||||
)
|
||||
|
||||
const handleRechargePriceChange = useCallback(
|
||||
(v: string) => onRechargePriceChange(v === 'recharge'),
|
||||
[onRechargePriceChange]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='space-y-3'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='hidden flex-1 flex-wrap items-center gap-2 lg:flex'>
|
||||
<FilterChip
|
||||
label={t('Pricing')}
|
||||
value={props.quotaTypeFilter}
|
||||
options={typeOptions}
|
||||
onChange={props.onQuotaTypeChange}
|
||||
defaultValue={QUOTA_TYPES.ALL}
|
||||
/>
|
||||
<FilterChip
|
||||
label={t('Endpoint')}
|
||||
value={props.endpointTypeFilter}
|
||||
options={endpointOptions}
|
||||
onChange={props.onEndpointTypeChange}
|
||||
defaultValue={ENDPOINT_TYPES.ALL}
|
||||
/>
|
||||
{props.vendors.length > 0 && (
|
||||
<FilterChip
|
||||
label={t('Vendor')}
|
||||
value={props.vendorFilter}
|
||||
options={vendorOptions}
|
||||
onChange={props.onVendorChange}
|
||||
searchPlaceholder={t('Search vendors...')}
|
||||
/>
|
||||
)}
|
||||
{props.groups.length > 0 && (
|
||||
<FilterChip
|
||||
label={t('Group')}
|
||||
value={props.groupFilter}
|
||||
options={groupOptions}
|
||||
onChange={props.onGroupChange}
|
||||
searchPlaceholder={t('Search groups...')}
|
||||
/>
|
||||
)}
|
||||
{props.tags.length > 0 && (
|
||||
<FilterChip
|
||||
label={t('Tag')}
|
||||
value={props.tagFilter}
|
||||
options={tagOptions}
|
||||
onChange={props.onTagChange}
|
||||
searchPlaceholder={t('Search tags...')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => setMobileOpen(true)}
|
||||
className='gap-1.5 lg:hidden'
|
||||
>
|
||||
<Filter className='size-4' />
|
||||
{t('Filters')}
|
||||
{props.activeFilterCount > 0 && (
|
||||
<Badge className='ml-0.5 size-5 justify-center rounded-full p-0 text-[10px]'>
|
||||
{props.activeFilterCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<div className='flex-1 lg:hidden' />
|
||||
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<div className='hidden items-center gap-1.5 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 className='bg-border mx-0.5 h-4 w-px' />
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-8 gap-1.5 px-2.5 text-xs font-medium'
|
||||
>
|
||||
<ArrowUpDown className='size-3.5' />
|
||||
<span className='hidden sm:inline'>
|
||||
{sortLabels[props.sortBy as SortOption] || t('Sort')}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-44'>
|
||||
{Object.entries(sortLabels).map(([value, label]) => (
|
||||
<DropdownMenuItem
|
||||
key={value}
|
||||
onClick={() => props.onSortChange(value)}
|
||||
className='gap-2'
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'size-4 shrink-0',
|
||||
props.sortBy === value ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
{label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<SegmentedControl
|
||||
options={[
|
||||
{
|
||||
value: VIEW_MODES.TABLE,
|
||||
icon: Table2,
|
||||
tooltip: t('Table view'),
|
||||
},
|
||||
]}
|
||||
value={props.viewMode}
|
||||
onChange={handleViewModeChange}
|
||||
ariaLabel={t('View mode')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeFilters.length > 0 && (
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
{activeFilters.map((filter) => (
|
||||
<ActiveFilterBadge
|
||||
key={filter.key}
|
||||
label={filter.label}
|
||||
onRemove={filter.onRemove}
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={props.onClearFilters}
|
||||
className='text-muted-foreground hover:text-foreground h-6 gap-1 px-2 text-xs'
|
||||
>
|
||||
<RotateCcw className='size-3' />
|
||||
{t('Clear all')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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'>
|
||||
{t('of')} {props.totalCount.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
||||
<SheetContent
|
||||
side='right'
|
||||
className='flex h-dvh w-full flex-col overflow-hidden p-0 sm:max-w-md'
|
||||
>
|
||||
<SheetHeader className='border-b px-4 py-3 sm:px-6 sm:py-4'>
|
||||
<SheetTitle>{t('Filters')}</SheetTitle>
|
||||
<SheetDescription className='sr-only'>
|
||||
{t('Filter models by type, endpoint, vendor, group and tags')}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className='flex-1 space-y-4 overflow-y-auto px-4 py-4 sm:space-y-6 sm:px-6 sm:py-5'>
|
||||
<MobileFilterGroup
|
||||
title={t('Pricing Type')}
|
||||
value={props.quotaTypeFilter}
|
||||
options={typeOptions}
|
||||
onChange={props.onQuotaTypeChange}
|
||||
/>
|
||||
<MobileFilterGroup
|
||||
title={t('Endpoint Type')}
|
||||
value={props.endpointTypeFilter}
|
||||
options={endpointOptions}
|
||||
onChange={props.onEndpointTypeChange}
|
||||
/>
|
||||
{props.vendors.length > 0 && (
|
||||
<MobileFilterGroup
|
||||
title={t('Vendor')}
|
||||
value={props.vendorFilter}
|
||||
options={vendorOptions}
|
||||
onChange={props.onVendorChange}
|
||||
/>
|
||||
)}
|
||||
{props.groups.length > 0 && (
|
||||
<MobileFilterGroup
|
||||
title={t('Group')}
|
||||
value={props.groupFilter}
|
||||
options={groupOptions}
|
||||
onChange={props.onGroupChange}
|
||||
/>
|
||||
)}
|
||||
{props.tags.length > 0 && (
|
||||
<MobileFilterGroup
|
||||
title={t('Tag')}
|
||||
value={props.tagFilter}
|
||||
options={tagOptions}
|
||||
onChange={props.onTagChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className='border-t pt-5'>
|
||||
<h3 className='text-foreground mb-3 text-sm font-semibold'>
|
||||
{t('Display Options')}
|
||||
</h3>
|
||||
<div className='space-y-3 sm:space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('Price display')}
|
||||
</p>
|
||||
<SegmentedControl
|
||||
options={[
|
||||
{ value: 'standard', label: t('Standard') },
|
||||
{ value: 'recharge', label: t('Recharge') },
|
||||
]}
|
||||
value={props.showRechargePrice ? 'recharge' : 'standard'}
|
||||
onChange={handleRechargePriceChange}
|
||||
ariaLabel={t('Price display mode')}
|
||||
/>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('Token unit')}
|
||||
</p>
|
||||
<SegmentedControl
|
||||
options={[
|
||||
{ value: 'M', label: t('Per 1M tokens') },
|
||||
{ value: 'K', label: t('Per 1K tokens') },
|
||||
]}
|
||||
value={props.tokenUnit}
|
||||
onChange={handleTokenUnitChange}
|
||||
ariaLabel={t('Token unit')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SheetFooter className='border-t px-4 py-3 sm:px-6 sm:py-4'>
|
||||
<div className='grid w-full grid-cols-2 gap-2 sm:flex sm:gap-3'>
|
||||
{props.hasActiveFilters && (
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={props.onClearFilters}
|
||||
className='flex-1'
|
||||
>
|
||||
{t('Reset')}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => setMobileOpen(false)} className='flex-1'>
|
||||
{t('Show')} {props.filteredCount} {t('models')}
|
||||
</Button>
|
||||
</div>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+5
-2
@@ -1,4 +1,3 @@
|
||||
export { FilterBar } from './filter-bar'
|
||||
export { PricingSidebar } from './pricing-sidebar'
|
||||
export { PricingToolbar } from './pricing-toolbar'
|
||||
export { ModelCard } from './model-card'
|
||||
@@ -6,5 +5,9 @@ export { ModelCardGrid } from './model-card-grid'
|
||||
export { LoadingSkeleton } from './loading-skeleton'
|
||||
export { EmptyState } from './empty-state'
|
||||
export { SearchBar } from './search-bar'
|
||||
export { ModelDetails, ModelDetailsDrawer } from './model-details'
|
||||
export {
|
||||
ModelDetails,
|
||||
ModelDetailsContent,
|
||||
ModelDetailsDrawer,
|
||||
} from './model-details'
|
||||
export { PricingTable } from './pricing-table'
|
||||
|
||||
+44
-18
@@ -3,16 +3,16 @@ import { ChevronRight, Copy } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getLobeIcon } from '@/lib/lobe-icon'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { DEFAULT_TOKEN_UNIT } from '../constants'
|
||||
import { parseTags } from '../lib/filters'
|
||||
import { isTokenBasedModel } from '../lib/model-helpers'
|
||||
import { formatPrice, formatRequestPrice } from '../lib/price'
|
||||
import {
|
||||
getDynamicDisplayGroupRatio,
|
||||
getDynamicPricingSummary,
|
||||
} from '../lib/dynamic-price'
|
||||
import { parseTags } from '../lib/filters'
|
||||
import { isTokenBasedModel } from '../lib/model-helpers'
|
||||
import { formatPrice, formatRequestPrice } from '../lib/price'
|
||||
import type { PricingModel, TokenUnit } from '../types'
|
||||
|
||||
export interface ModelCardProps {
|
||||
@@ -41,7 +41,8 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
|
||||
: null
|
||||
const initial = props.model.model_name?.charAt(0).toUpperCase() || '?'
|
||||
const isDynamicPricing =
|
||||
props.model.billing_mode === 'tiered_expr' && Boolean(props.model.billing_expr)
|
||||
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, {
|
||||
@@ -83,7 +84,7 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
|
||||
)}
|
||||
</div>
|
||||
<div className='min-w-0'>
|
||||
<h3 className='text-foreground truncate font-mono text-[15px] font-bold leading-tight'>
|
||||
<h3 className='text-foreground truncate font-mono text-[15px] leading-tight font-bold'>
|
||||
{props.model.model_name}
|
||||
</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'>
|
||||
@@ -93,7 +94,7 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
|
||||
<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 break-all font-mono text-[11px]'>
|
||||
<code className='text-muted-foreground/70 mt-0.5 line-clamp-1 block font-mono text-[11px] break-all'>
|
||||
{dynamicSummary.rawExpression}
|
||||
</code>
|
||||
</span>
|
||||
@@ -122,14 +123,28 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
|
||||
<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)}
|
||||
{formatPrice(
|
||||
props.model,
|
||||
'input',
|
||||
tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate
|
||||
)}
|
||||
</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)}
|
||||
{formatPrice(
|
||||
props.model,
|
||||
'output',
|
||||
tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate
|
||||
)}
|
||||
</span>
|
||||
/{tokenUnitLabel}
|
||||
</span>
|
||||
@@ -137,7 +152,14 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
|
||||
<span className='text-muted-foreground/60 whitespace-nowrap'>
|
||||
{t('Cached')}{' '}
|
||||
<span className='font-mono'>
|
||||
{formatPrice(props.model, 'cache', tokenUnit, showRechargePrice, priceRate, usdExchangeRate)}
|
||||
{formatPrice(
|
||||
props.model,
|
||||
'cache',
|
||||
tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
@@ -145,9 +167,14 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
|
||||
) : (
|
||||
<span className='text-muted-foreground whitespace-nowrap'>
|
||||
<span className='text-foreground font-mono font-semibold'>
|
||||
{formatRequestPrice(props.model, showRechargePrice, priceRate, usdExchangeRate)}
|
||||
</span>
|
||||
{' '}/ {t('request')}
|
||||
{formatRequestPrice(
|
||||
props.model,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate
|
||||
)}
|
||||
</span>{' '}
|
||||
/ {t('request')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -202,14 +229,13 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
|
||||
{/* Footer row 2: endpoint + tag chips */}
|
||||
<div className='mt-1.5 flex flex-wrap items-center gap-x-2.5 gap-y-0.5 sm:mt-2 sm:gap-x-3 sm:gap-y-1'>
|
||||
{bottomTags.map((item) => (
|
||||
<span
|
||||
key={item}
|
||||
className='text-muted-foreground/70 text-xs'
|
||||
>
|
||||
<span key={item} className='text-muted-foreground/70 text-xs'>
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
<span className='text-muted-foreground/50 text-xs'>{tokenUnitLabel}</span>
|
||||
<span className='text-muted-foreground/50 text-xs'>
|
||||
{tokenUnitLabel}
|
||||
</span>
|
||||
{hiddenCount > 0 && (
|
||||
<span className='text-muted-foreground/40 text-xs'>
|
||||
+{hiddenCount}
|
||||
|
||||
@@ -0,0 +1,871 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import {
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
Gauge,
|
||||
KeyRound,
|
||||
ScrollText,
|
||||
ShieldCheck,
|
||||
Sigma,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { BundledLanguage } from 'shiki/bundle/web'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useStatus } from '@/hooks/use-status'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
CodeBlock,
|
||||
CodeBlockCopyButton,
|
||||
} from '@/components/ai-elements/code-block'
|
||||
import {
|
||||
buildRateLimits,
|
||||
buildSupportedParameters,
|
||||
formatRateLimit,
|
||||
type SupportedParameter,
|
||||
} from '../lib/mock-stats'
|
||||
import { replaceModelInPath } from '../lib/model-helpers'
|
||||
import { inferApiInfo } from '../lib/model-metadata'
|
||||
import type { PricingModel } from '../types'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Code-sample registry
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Each sample is keyed by language and endpoint type. The endpoint type comes
|
||||
// from the model's `supported_endpoint_types`; we render samples only for the
|
||||
// types the model actually supports. This keeps copy-pasted code accurate and
|
||||
// provider-shaped (OpenAI vs Anthropic vs Gemini, etc.).
|
||||
|
||||
type Lang = 'curl' | 'python' | 'typescript' | 'javascript'
|
||||
|
||||
const LANG_LABELS: Record<Lang, string> = {
|
||||
curl: 'cURL',
|
||||
python: 'Python',
|
||||
typescript: 'TypeScript',
|
||||
javascript: 'JavaScript',
|
||||
}
|
||||
|
||||
const LANG_HIGHLIGHT: Record<Lang, BundledLanguage> = {
|
||||
curl: 'bash',
|
||||
python: 'python',
|
||||
typescript: 'typescript',
|
||||
javascript: 'javascript',
|
||||
}
|
||||
|
||||
type SampleContext = {
|
||||
baseUrl: string
|
||||
apiKeyEnv: string
|
||||
modelName: string
|
||||
endpointType: string
|
||||
endpointPath: string
|
||||
}
|
||||
|
||||
function buildChatSample(lang: Lang, ctx: SampleContext): string {
|
||||
const url = `${ctx.baseUrl}${ctx.endpointPath}`
|
||||
const isResponses = ctx.endpointType === 'openai-response'
|
||||
const isReasoning = /^o[1-4]|reasoning|thinking|deepseek-r/i.test(
|
||||
ctx.modelName
|
||||
)
|
||||
const userMessage = 'Explain quantum entanglement in one paragraph.'
|
||||
|
||||
const bodyJson = isResponses
|
||||
? JSON.stringify({ model: ctx.modelName, input: userMessage }, null, 2)
|
||||
: JSON.stringify(
|
||||
{
|
||||
model: ctx.modelName,
|
||||
messages: [{ role: 'user', content: userMessage }],
|
||||
...(isReasoning ? {} : { temperature: 0.7 }),
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
|
||||
const fnCall = isResponses ? 'responses.create' : 'chat.completions.create'
|
||||
|
||||
if (lang === 'curl') {
|
||||
return [
|
||||
`curl ${url} \\`,
|
||||
` -H "Authorization: Bearer $${ctx.apiKeyEnv}" \\`,
|
||||
` -H "Content-Type: application/json" \\`,
|
||||
` -d '${bodyJson.replace(/\n/g, '\n ')}'`,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
if (lang === 'python') {
|
||||
return [
|
||||
'from openai import OpenAI',
|
||||
'',
|
||||
'client = OpenAI(',
|
||||
` base_url="${ctx.baseUrl}/v1",`,
|
||||
` api_key="<YOUR_API_KEY>",`,
|
||||
')',
|
||||
'',
|
||||
isResponses
|
||||
? `response = client.${fnCall}(\n model="${ctx.modelName}",\n input="${userMessage}",\n)\n\nprint(response.output_text)`
|
||||
: `completion = client.${fnCall}(\n model="${ctx.modelName}",\n messages=[\n {"role": "user", "content": "${userMessage}"}\n ],\n)\n\nprint(completion.choices[0].message.content)`,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
if (lang === 'typescript') {
|
||||
return [
|
||||
`import OpenAI from 'openai'`,
|
||||
'',
|
||||
`const client = new OpenAI({`,
|
||||
` baseURL: '${ctx.baseUrl}/v1',`,
|
||||
` apiKey: process.env.${ctx.apiKeyEnv},`,
|
||||
`})`,
|
||||
'',
|
||||
isResponses
|
||||
? `const response = await client.${fnCall}({\n model: '${ctx.modelName}',\n input: '${userMessage}',\n})\n\nconsole.log(response.output_text)`
|
||||
: `const completion = await client.${fnCall}({\n model: '${ctx.modelName}',\n messages: [{ role: 'user', content: '${userMessage}' }],\n})\n\nconsole.log(completion.choices[0].message.content)`,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
return [
|
||||
`const response = await fetch('${url}', {`,
|
||||
` method: 'POST',`,
|
||||
` headers: {`,
|
||||
` Authorization: \`Bearer \${process.env.${ctx.apiKeyEnv}}\`,`,
|
||||
` 'Content-Type': 'application/json',`,
|
||||
` },`,
|
||||
` body: JSON.stringify(${bodyJson}),`,
|
||||
`})`,
|
||||
'',
|
||||
`const data = await response.json()`,
|
||||
`console.log(data)`,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function buildAnthropicSample(lang: Lang, ctx: SampleContext): string {
|
||||
const url = `${ctx.baseUrl}${ctx.endpointPath}`
|
||||
const userMessage = 'Explain quantum entanglement in one paragraph.'
|
||||
|
||||
if (lang === 'curl') {
|
||||
const body = JSON.stringify(
|
||||
{
|
||||
model: ctx.modelName,
|
||||
max_tokens: 1024,
|
||||
messages: [{ role: 'user', content: userMessage }],
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
return [
|
||||
`curl ${url} \\`,
|
||||
` -H "x-api-key: $${ctx.apiKeyEnv}" \\`,
|
||||
` -H "anthropic-version: 2023-06-01" \\`,
|
||||
` -H "Content-Type: application/json" \\`,
|
||||
` -d '${body.replace(/\n/g, '\n ')}'`,
|
||||
].join('\n')
|
||||
}
|
||||
if (lang === 'python') {
|
||||
return [
|
||||
'import anthropic',
|
||||
'',
|
||||
'client = anthropic.Anthropic(',
|
||||
` base_url="${ctx.baseUrl}",`,
|
||||
` api_key="<YOUR_API_KEY>",`,
|
||||
')',
|
||||
'',
|
||||
`message = client.messages.create(`,
|
||||
` model="${ctx.modelName}",`,
|
||||
` max_tokens=1024,`,
|
||||
` messages=[{"role": "user", "content": "${userMessage}"}],`,
|
||||
')',
|
||||
'',
|
||||
'print(message.content[0].text)',
|
||||
].join('\n')
|
||||
}
|
||||
if (lang === 'typescript') {
|
||||
return [
|
||||
`import Anthropic from '@anthropic-ai/sdk'`,
|
||||
'',
|
||||
`const client = new Anthropic({`,
|
||||
` baseURL: '${ctx.baseUrl}',`,
|
||||
` apiKey: process.env.${ctx.apiKeyEnv},`,
|
||||
`})`,
|
||||
'',
|
||||
`const message = await client.messages.create({`,
|
||||
` model: '${ctx.modelName}',`,
|
||||
` max_tokens: 1024,`,
|
||||
` messages: [{ role: 'user', content: '${userMessage}' }],`,
|
||||
`})`,
|
||||
'',
|
||||
`console.log(message.content[0].text)`,
|
||||
].join('\n')
|
||||
}
|
||||
return [
|
||||
`const response = await fetch('${url}', {`,
|
||||
` method: 'POST',`,
|
||||
` headers: {`,
|
||||
` 'x-api-key': process.env.${ctx.apiKeyEnv},`,
|
||||
` 'anthropic-version': '2023-06-01',`,
|
||||
` 'Content-Type': 'application/json',`,
|
||||
` },`,
|
||||
` body: JSON.stringify({`,
|
||||
` model: '${ctx.modelName}',`,
|
||||
` max_tokens: 1024,`,
|
||||
` messages: [{ role: 'user', content: '${userMessage}' }],`,
|
||||
` }),`,
|
||||
`})`,
|
||||
'',
|
||||
`const data = await response.json()`,
|
||||
`console.log(data.content[0].text)`,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function buildGeminiSample(lang: Lang, ctx: SampleContext): string {
|
||||
const url = `${ctx.baseUrl}${ctx.endpointPath}?key=$${ctx.apiKeyEnv}`
|
||||
const userMessage = 'Explain quantum entanglement in one paragraph.'
|
||||
|
||||
if (lang === 'curl') {
|
||||
const body = JSON.stringify(
|
||||
{ contents: [{ parts: [{ text: userMessage }] }] },
|
||||
null,
|
||||
2
|
||||
)
|
||||
return [
|
||||
`curl '${url}' \\`,
|
||||
` -H 'Content-Type: application/json' \\`,
|
||||
` -d '${body.replace(/\n/g, '\n ')}'`,
|
||||
].join('\n')
|
||||
}
|
||||
if (lang === 'python') {
|
||||
return [
|
||||
'import google.generativeai as genai',
|
||||
'',
|
||||
`genai.configure(api_key="<YOUR_API_KEY>")`,
|
||||
'',
|
||||
`model = genai.GenerativeModel("${ctx.modelName}")`,
|
||||
`response = model.generate_content("${userMessage}")`,
|
||||
'',
|
||||
`print(response.text)`,
|
||||
].join('\n')
|
||||
}
|
||||
if (lang === 'typescript') {
|
||||
return [
|
||||
`import { GoogleGenerativeAI } from '@google/generative-ai'`,
|
||||
'',
|
||||
`const genAI = new GoogleGenerativeAI(process.env.${ctx.apiKeyEnv}!)`,
|
||||
`const model = genAI.getGenerativeModel({ model: '${ctx.modelName}' })`,
|
||||
'',
|
||||
`const result = await model.generateContent('${userMessage}')`,
|
||||
`console.log(result.response.text())`,
|
||||
].join('\n')
|
||||
}
|
||||
return [
|
||||
`const response = await fetch('${url}', {`,
|
||||
` method: 'POST',`,
|
||||
` headers: { 'Content-Type': 'application/json' },`,
|
||||
` body: JSON.stringify({`,
|
||||
` contents: [{ parts: [{ text: '${userMessage}' }] }],`,
|
||||
` }),`,
|
||||
`})`,
|
||||
'',
|
||||
`const data = await response.json()`,
|
||||
`console.log(data.candidates[0].content.parts[0].text)`,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function buildEmbeddingSample(lang: Lang, ctx: SampleContext): string {
|
||||
const url = `${ctx.baseUrl}${ctx.endpointPath}`
|
||||
const text = 'The food was delicious and the waiter…'
|
||||
|
||||
if (lang === 'curl') {
|
||||
const body = JSON.stringify({ model: ctx.modelName, input: text }, null, 2)
|
||||
return [
|
||||
`curl ${url} \\`,
|
||||
` -H "Authorization: Bearer $${ctx.apiKeyEnv}" \\`,
|
||||
` -H "Content-Type: application/json" \\`,
|
||||
` -d '${body.replace(/\n/g, '\n ')}'`,
|
||||
].join('\n')
|
||||
}
|
||||
if (lang === 'python') {
|
||||
return [
|
||||
'from openai import OpenAI',
|
||||
'',
|
||||
`client = OpenAI(base_url="${ctx.baseUrl}/v1", api_key="<YOUR_API_KEY>")`,
|
||||
'',
|
||||
'response = client.embeddings.create(',
|
||||
` model="${ctx.modelName}",`,
|
||||
` input="${text}",`,
|
||||
')',
|
||||
'',
|
||||
'print(response.data[0].embedding[:8])',
|
||||
].join('\n')
|
||||
}
|
||||
if (lang === 'typescript') {
|
||||
return [
|
||||
`import OpenAI from 'openai'`,
|
||||
'',
|
||||
`const client = new OpenAI({`,
|
||||
` baseURL: '${ctx.baseUrl}/v1',`,
|
||||
` apiKey: process.env.${ctx.apiKeyEnv},`,
|
||||
`})`,
|
||||
'',
|
||||
`const response = await client.embeddings.create({`,
|
||||
` model: '${ctx.modelName}',`,
|
||||
` input: '${text}',`,
|
||||
`})`,
|
||||
'',
|
||||
`console.log(response.data[0].embedding.slice(0, 8))`,
|
||||
].join('\n')
|
||||
}
|
||||
return [
|
||||
`const response = await fetch('${url}', {`,
|
||||
` method: 'POST',`,
|
||||
` headers: {`,
|
||||
` Authorization: \`Bearer \${process.env.${ctx.apiKeyEnv}}\`,`,
|
||||
` 'Content-Type': 'application/json',`,
|
||||
` },`,
|
||||
` body: JSON.stringify({`,
|
||||
` model: '${ctx.modelName}',`,
|
||||
` input: '${text}',`,
|
||||
` }),`,
|
||||
`})`,
|
||||
'',
|
||||
`const data = await response.json()`,
|
||||
`console.log(data.data[0].embedding.slice(0, 8))`,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function buildImageSample(lang: Lang, ctx: SampleContext): string {
|
||||
const url = `${ctx.baseUrl}${ctx.endpointPath}`
|
||||
const prompt = 'A serene koi pond at sunset, ukiyo-e style.'
|
||||
|
||||
if (lang === 'curl') {
|
||||
const body = JSON.stringify(
|
||||
{ model: ctx.modelName, prompt, size: '1024x1024', n: 1 },
|
||||
null,
|
||||
2
|
||||
)
|
||||
return [
|
||||
`curl ${url} \\`,
|
||||
` -H "Authorization: Bearer $${ctx.apiKeyEnv}" \\`,
|
||||
` -H "Content-Type: application/json" \\`,
|
||||
` -d '${body.replace(/\n/g, '\n ')}'`,
|
||||
].join('\n')
|
||||
}
|
||||
if (lang === 'python') {
|
||||
return [
|
||||
'from openai import OpenAI',
|
||||
'',
|
||||
`client = OpenAI(base_url="${ctx.baseUrl}/v1", api_key="<YOUR_API_KEY>")`,
|
||||
'',
|
||||
'response = client.images.generate(',
|
||||
` model="${ctx.modelName}",`,
|
||||
` prompt="${prompt}",`,
|
||||
` size="1024x1024",`,
|
||||
` n=1,`,
|
||||
')',
|
||||
'',
|
||||
'print(response.data[0].url)',
|
||||
].join('\n')
|
||||
}
|
||||
if (lang === 'typescript') {
|
||||
return [
|
||||
`import OpenAI from 'openai'`,
|
||||
'',
|
||||
`const client = new OpenAI({`,
|
||||
` baseURL: '${ctx.baseUrl}/v1',`,
|
||||
` apiKey: process.env.${ctx.apiKeyEnv},`,
|
||||
`})`,
|
||||
'',
|
||||
`const response = await client.images.generate({`,
|
||||
` model: '${ctx.modelName}',`,
|
||||
` prompt: '${prompt}',`,
|
||||
` size: '1024x1024',`,
|
||||
` n: 1,`,
|
||||
`})`,
|
||||
'',
|
||||
`console.log(response.data[0].url)`,
|
||||
].join('\n')
|
||||
}
|
||||
return [
|
||||
`const response = await fetch('${url}', {`,
|
||||
` method: 'POST',`,
|
||||
` headers: {`,
|
||||
` Authorization: \`Bearer \${process.env.${ctx.apiKeyEnv}}\`,`,
|
||||
` 'Content-Type': 'application/json',`,
|
||||
` },`,
|
||||
` body: JSON.stringify({`,
|
||||
` model: '${ctx.modelName}',`,
|
||||
` prompt: '${prompt}',`,
|
||||
` size: '1024x1024',`,
|
||||
` n: 1,`,
|
||||
` }),`,
|
||||
`})`,
|
||||
'',
|
||||
`const data = await response.json()`,
|
||||
`console.log(data.data[0].url)`,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function buildSample(
|
||||
lang: Lang,
|
||||
endpointType: string,
|
||||
ctx: SampleContext
|
||||
): string {
|
||||
if (endpointType === 'anthropic') return buildAnthropicSample(lang, ctx)
|
||||
if (endpointType === 'gemini') return buildGeminiSample(lang, ctx)
|
||||
if (endpointType === 'embeddings' || endpointType === 'jina-rerank')
|
||||
return buildEmbeddingSample(lang, ctx)
|
||||
if (endpointType === 'image-generation') return buildImageSample(lang, ctx)
|
||||
return buildChatSample(lang, ctx)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Code samples section
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function CodeSamplesSection(props: {
|
||||
model: PricingModel
|
||||
endpointMap: Record<string, { path?: string; method?: string }>
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { status } = useStatus()
|
||||
|
||||
const baseUrl = useMemo(() => {
|
||||
const candidate =
|
||||
(status as Record<string, unknown> | null)?.server_address ??
|
||||
(status as Record<string, unknown> | null)?.serverAddress ??
|
||||
(status?.data as Record<string, unknown> | undefined)?.server_address ??
|
||||
(status?.data as Record<string, unknown> | undefined)?.serverAddress
|
||||
if (candidate && typeof candidate === 'string') {
|
||||
return candidate.replace(/\/$/, '')
|
||||
}
|
||||
if (typeof window !== 'undefined') return window.location.origin
|
||||
return 'https://api.example.com'
|
||||
}, [status])
|
||||
|
||||
const endpoints = useMemo(() => {
|
||||
const types = props.model.supported_endpoint_types || []
|
||||
return types
|
||||
.map((type) => {
|
||||
const info = props.endpointMap[type] || {}
|
||||
let path = info.path || ''
|
||||
if (path && path.includes('{model}')) {
|
||||
path = replaceModelInPath(path, props.model.model_name || '')
|
||||
}
|
||||
return { type, path, method: info.method || 'POST' }
|
||||
})
|
||||
.filter((e) => Boolean(e.path))
|
||||
}, [props.model, props.endpointMap])
|
||||
|
||||
const [endpointType, setEndpointType] = useState<string>(
|
||||
endpoints[0]?.type ?? ''
|
||||
)
|
||||
const [lang, setLang] = useState<Lang>('curl')
|
||||
|
||||
const activeEndpoint = useMemo(() => {
|
||||
return endpoints.find((e) => e.type === endpointType) ?? endpoints[0]
|
||||
}, [endpointType, endpoints])
|
||||
|
||||
if (endpoints.length === 0 || !activeEndpoint) {
|
||||
return null
|
||||
}
|
||||
|
||||
const code = buildSample(lang, activeEndpoint.type, {
|
||||
baseUrl,
|
||||
apiKeyEnv: 'NEW_API_KEY',
|
||||
modelName: props.model.model_name || '',
|
||||
endpointType: activeEndpoint.type,
|
||||
endpointPath: activeEndpoint.path,
|
||||
})
|
||||
|
||||
return (
|
||||
<section>
|
||||
<SectionTitle icon={ScrollText}>{t('Code samples')}</SectionTitle>
|
||||
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
{endpoints.length > 1 && (
|
||||
<Tabs value={endpointType} onValueChange={setEndpointType}>
|
||||
<TabsList className='bg-muted/40 h-8 p-0.5'>
|
||||
{endpoints.map((ep) => (
|
||||
<TabsTrigger
|
||||
key={ep.type}
|
||||
value={ep.type}
|
||||
className='h-7 px-2.5 text-xs'
|
||||
>
|
||||
{ep.type}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
<Tabs
|
||||
value={lang}
|
||||
onValueChange={(v) => setLang(v as Lang)}
|
||||
className='ml-auto'
|
||||
>
|
||||
<TabsList className='bg-muted/40 h-8 p-0.5'>
|
||||
{(Object.keys(LANG_LABELS) as Lang[]).map((l) => (
|
||||
<TabsTrigger key={l} value={l} className='h-7 px-2.5 text-xs'>
|
||||
{LANG_LABELS[l]}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div className='mt-3'>
|
||||
<CodeBlock code={code} language={LANG_HIGHLIGHT[lang]}>
|
||||
<CodeBlockCopyButton />
|
||||
</CodeBlock>
|
||||
</div>
|
||||
|
||||
<p className='text-muted-foreground mt-2 text-xs'>
|
||||
{t('Replace')}{' '}
|
||||
<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.')}
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Supported parameters table
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SupportedParametersSection(props: { model: PricingModel }) {
|
||||
const { t } = useTranslation()
|
||||
const params = useMemo(
|
||||
() => buildSupportedParameters(props.model),
|
||||
[props.model]
|
||||
)
|
||||
|
||||
if (params.length === 0) return null
|
||||
|
||||
return (
|
||||
<section>
|
||||
<SectionTitle icon={Sigma}>{t('Supported parameters')}</SectionTitle>
|
||||
<div className='border-border/60 overflow-hidden rounded-lg border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className='bg-muted/30 hover:bg-muted/30'>
|
||||
<TableHead className='h-9 w-44 text-xs'>
|
||||
{t('Parameter')}
|
||||
</TableHead>
|
||||
<TableHead className='h-9 w-24 text-xs'>{t('Type')}</TableHead>
|
||||
<TableHead className='h-9 w-32 text-xs'>
|
||||
{t('Default / range')}
|
||||
</TableHead>
|
||||
<TableHead className='h-9 text-xs'>{t('Description')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{params.map((p) => (
|
||||
<TableRow key={p.name} className='hover:bg-muted/20'>
|
||||
<TableCell className='py-2 align-top'>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<code className='font-mono text-xs font-medium'>
|
||||
{p.name}
|
||||
</code>
|
||||
{p.required && (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='h-4 border-rose-500/40 px-1 text-[9px] text-rose-600 dark:text-rose-400'
|
||||
>
|
||||
{t('required')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='py-2 align-top'>
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='h-5 rounded-sm px-1.5 font-mono text-[10px] font-normal'
|
||||
>
|
||||
{p.type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className='py-2 align-top'>
|
||||
<ParamRangeCell param={p} />
|
||||
</TableCell>
|
||||
<TableCell className='text-muted-foreground py-2 align-top text-xs'>
|
||||
{t(p.descriptionKey)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ParamRangeCell(props: { param: SupportedParameter }) {
|
||||
const { defaultValue, range, enumValues } = props.param
|
||||
if (defaultValue !== undefined) {
|
||||
return (
|
||||
<div className='flex flex-wrap items-center gap-1'>
|
||||
<span className='text-muted-foreground text-[11px]'>=</span>
|
||||
<code className='bg-muted rounded px-1 py-0.5 font-mono text-[11px]'>
|
||||
{String(defaultValue)}
|
||||
</code>
|
||||
{range && (
|
||||
<span className='text-muted-foreground text-[11px]'>{range}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (range) {
|
||||
return (
|
||||
<span className='text-muted-foreground font-mono text-[11px]'>
|
||||
{range}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (enumValues && enumValues.length > 0) {
|
||||
return (
|
||||
<div className='flex flex-wrap gap-0.5'>
|
||||
{enumValues.map((v) => (
|
||||
<code
|
||||
key={v}
|
||||
className='bg-muted text-muted-foreground rounded px-1 py-0.5 font-mono text-[10px]'
|
||||
>
|
||||
{v}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <span className='text-muted-foreground/60 text-[11px]'>—</span>
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rate-limits table
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function RateLimitsSection(props: { model: PricingModel }) {
|
||||
const { t } = useTranslation()
|
||||
const limits = useMemo(() => buildRateLimits(props.model), [props.model])
|
||||
|
||||
if (limits.length === 0) return null
|
||||
|
||||
return (
|
||||
<section>
|
||||
<SectionTitle icon={Gauge}>{t('Rate limits')}</SectionTitle>
|
||||
<div className='border-border/60 overflow-hidden rounded-lg border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className='bg-muted/30 hover:bg-muted/30'>
|
||||
<TableHead className='h-9 text-xs'>{t('Group')}</TableHead>
|
||||
<TableHead className='h-9 text-right text-xs'>RPM</TableHead>
|
||||
<TableHead className='h-9 text-right text-xs'>TPM</TableHead>
|
||||
<TableHead className='h-9 text-right text-xs'>RPD</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{limits.map((l) => (
|
||||
<TableRow key={l.group} className='hover:bg-muted/20'>
|
||||
<TableCell className='py-2 font-mono text-xs'>
|
||||
{l.group}
|
||||
</TableCell>
|
||||
<TableCell className='py-2 text-right font-mono text-xs'>
|
||||
{formatRateLimit(l.rpm)}
|
||||
</TableCell>
|
||||
<TableCell className='py-2 text-right font-mono text-xs'>
|
||||
{formatRateLimit(l.tpm)}
|
||||
</TableCell>
|
||||
<TableCell className='py-2 text-right font-mono text-xs'>
|
||||
{formatRateLimit(l.rpd)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<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.'
|
||||
)}
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider info card (vendor / tokenizer / license / privacy)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Exported separately so the Overview tab can render it alongside capabilities
|
||||
// and modalities (i.e. "what is this model?" rather than "how do I call it?").
|
||||
|
||||
export function ModelDetailsProviderInfo(props: { model: PricingModel }) {
|
||||
const { t } = useTranslation()
|
||||
const info = useMemo(() => inferApiInfo(props.model), [props.model])
|
||||
|
||||
return (
|
||||
<section>
|
||||
<SectionTitle icon={ShieldCheck}>
|
||||
{t('Provider & data privacy')}
|
||||
</SectionTitle>
|
||||
|
||||
<div className='border-border/60 bg-border/60 grid grid-cols-1 gap-px overflow-hidden rounded-lg border sm:grid-cols-2'>
|
||||
<InfoCell label={t('Provider')}>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<span className='text-sm font-medium'>{info.vendor_label}</span>
|
||||
{info.homepage && (
|
||||
<a
|
||||
href={info.homepage}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-muted-foreground hover:text-foreground inline-flex items-center gap-0.5 text-[11px]'
|
||||
>
|
||||
{t('Docs')}
|
||||
<ExternalLink className='size-3' />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</InfoCell>
|
||||
|
||||
<InfoCell label={t('Tokenizer')}>
|
||||
<div className='flex flex-col gap-0.5'>
|
||||
<code className='font-mono text-xs'>{info.tokenizer}</code>
|
||||
{info.tokenizer_note && (
|
||||
<span className='text-muted-foreground text-[10px]'>
|
||||
{info.tokenizer_note}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</InfoCell>
|
||||
|
||||
<InfoCell label={t('License')}>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<span className='text-sm'>{info.license}</span>
|
||||
<Badge
|
||||
variant='outline'
|
||||
className={cn(
|
||||
'h-4 w-fit px-1.5 text-[9px] font-medium',
|
||||
info.license_kind === 'open' &&
|
||||
'border-emerald-500/40 text-emerald-600 dark:text-emerald-400',
|
||||
info.license_kind === 'open-weight' &&
|
||||
'border-sky-500/40 text-sky-600 dark:text-sky-400',
|
||||
info.license_kind === 'proprietary' &&
|
||||
'border-amber-500/40 text-amber-600 dark:text-amber-400'
|
||||
)}
|
||||
>
|
||||
{info.license_kind === 'open'
|
||||
? t('Open source')
|
||||
: info.license_kind === 'open-weight'
|
||||
? t('Open weights')
|
||||
: info.license_kind === 'proprietary'
|
||||
? t('Proprietary')
|
||||
: t('Unknown')}
|
||||
</Badge>
|
||||
</div>
|
||||
</InfoCell>
|
||||
|
||||
<InfoCell label={t('Data retention')}>
|
||||
<span className='text-sm'>
|
||||
{info.data_retention_days === 0
|
||||
? t('Zero retention')
|
||||
: `${info.data_retention_days} ${t('days')}`}
|
||||
</span>
|
||||
<span className='text-muted-foreground text-[10px]'>
|
||||
{info.training_opt_out
|
||||
? t('Not used for upstream training by default')
|
||||
: t('May be used for training by upstream provider')}
|
||||
</span>
|
||||
</InfoCell>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function InfoCell(props: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className='bg-card flex 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}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Authentication preview
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function AuthSection() {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<section>
|
||||
<SectionTitle icon={KeyRound}>{t('Authentication')}</SectionTitle>
|
||||
<div className='border-border/60 bg-muted/20 flex items-start gap-2 rounded-lg border p-3'>
|
||||
<ChevronRight className='text-muted-foreground mt-0.5 size-3.5 shrink-0' />
|
||||
<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-[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-[11px]'>
|
||||
x-api-key
|
||||
</code>{' '}
|
||||
{t('header instead.')}
|
||||
</p>
|
||||
<p className='text-muted-foreground'>
|
||||
{t(
|
||||
'Generate tokens from the Tokens page; you can scope them to specific models, groups, IPs, and rate-limits.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Composite API tab
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ModelDetailsApi(props: {
|
||||
model: PricingModel
|
||||
endpointMap: Record<string, { path?: string; method?: string }>
|
||||
}) {
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<CodeSamplesSection model={props.model} endpointMap={props.endpointMap} />
|
||||
<AuthSection />
|
||||
<SupportedParametersSection model={props.model} />
|
||||
<RateLimitsSection model={props.model} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local UI helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SectionTitle(props: {
|
||||
children: React.ReactNode
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
}) {
|
||||
const Icon = props.icon
|
||||
return (
|
||||
<h3 className='text-foreground mb-3 flex items-center gap-1.5 text-sm font-semibold'>
|
||||
<Icon className='text-muted-foreground/70 size-3.5' />
|
||||
{props.children}
|
||||
</h3>
|
||||
)
|
||||
}
|
||||
|
||||
// Re-export so the parent can keep its own SectionTitle if it wants:
|
||||
export { Zap as ApiTabIcon }
|
||||
@@ -0,0 +1,212 @@
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
ArrowDownRight,
|
||||
ArrowUpRight,
|
||||
ExternalLink,
|
||||
Trophy,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
buildAppRankings,
|
||||
formatTokenVolume,
|
||||
type AppRanking,
|
||||
} from '../lib/mock-stats'
|
||||
import type { PricingModel } from '../types'
|
||||
|
||||
const COMPACT_NUMBER = new Intl.NumberFormat(undefined, {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
})
|
||||
|
||||
function RankBadge(props: { rank: number }) {
|
||||
const rank = props.rank
|
||||
const isPodium = rank <= 3
|
||||
const palette =
|
||||
rank === 1
|
||||
? '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-full font-mono text-xs font-bold tabular-nums',
|
||||
palette
|
||||
)}
|
||||
>
|
||||
{isPodium ? <Trophy className='size-3.5' /> : rank}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function GrowthChip(props: { value: number }) {
|
||||
const value = props.value
|
||||
const isUp = value > 0
|
||||
const isDown = value < 0
|
||||
const palette = isUp
|
||||
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300'
|
||||
: isDown
|
||||
? '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 font-mono text-[11px] font-semibold tabular-nums',
|
||||
palette
|
||||
)}
|
||||
>
|
||||
{Icon && <Icon className='size-3' />}
|
||||
{formatted}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function AppLink(props: { app: AppRanking }) {
|
||||
if (!props.app.url) {
|
||||
return <span className='text-foreground'>{props.app.name}</span>
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={props.app.url}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
className='text-foreground hover:text-primary inline-flex items-center gap-1 transition-colors'
|
||||
>
|
||||
{props.app.name}
|
||||
<ExternalLink className='text-muted-foreground/40 size-3' />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export function ModelDetailsApps(props: { model: PricingModel }) {
|
||||
const { t } = useTranslation()
|
||||
const apps = useMemo(() => buildAppRankings(props.model, 12), [props.model])
|
||||
|
||||
if (apps.length === 0) {
|
||||
return (
|
||||
<div className='text-muted-foreground rounded-lg border p-6 text-center text-sm'>
|
||||
{t('No app usage data available for this model.')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const totalMonthlyTokens = apps.reduce((s, a) => s + a.monthly_tokens, 0)
|
||||
const top = apps[0]
|
||||
const headerCellClass =
|
||||
'text-muted-foreground py-2 text-[10px] font-medium tracking-wider uppercase'
|
||||
|
||||
return (
|
||||
<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-[10px] font-medium tracking-wider uppercase'>
|
||||
{t('Tracked apps')}
|
||||
</div>
|
||||
<div className='text-foreground mt-1 font-mono text-lg font-semibold tabular-nums'>
|
||||
{apps.length}
|
||||
</div>
|
||||
<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-[10px] font-medium tracking-wider uppercase'>
|
||||
{t('Monthly tokens')}
|
||||
</div>
|
||||
<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-[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-[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-[11px]'>
|
||||
{top.category} · {formatTokenVolume(top.monthly_tokens)}{' '}
|
||||
{t('tokens / mo')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='overflow-x-auto rounded-lg border'>
|
||||
<Table className='text-sm'>
|
||||
<TableHeader>
|
||||
<TableRow className='hover:bg-transparent'>
|
||||
<TableHead className={cn(headerCellClass, 'w-12')}>#</TableHead>
|
||||
<TableHead className={headerCellClass}>{t('App')}</TableHead>
|
||||
<TableHead
|
||||
className={cn(headerCellClass, 'hidden md:table-cell')}
|
||||
>
|
||||
{t('Category')}
|
||||
</TableHead>
|
||||
<TableHead className={`${headerCellClass} text-right`}>
|
||||
{t('Monthly tokens')}
|
||||
</TableHead>
|
||||
<TableHead className={`${headerCellClass} text-right`}>
|
||||
{t('30d change')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{apps.map((app) => (
|
||||
<TableRow key={`${app.rank}-${app.name}`}>
|
||||
<TableCell className='py-2.5'>
|
||||
<RankBadge rank={app.rank} />
|
||||
</TableCell>
|
||||
<TableCell className='py-2.5'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<span className='bg-muted text-muted-foreground inline-flex size-7 shrink-0 items-center justify-center rounded-md font-bold'>
|
||||
{app.initial}
|
||||
</span>
|
||||
<div className='min-w-0'>
|
||||
<div className='text-sm font-medium'>
|
||||
<AppLink app={app} />
|
||||
</div>
|
||||
<p className='text-muted-foreground line-clamp-1 text-xs'>
|
||||
{app.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='text-muted-foreground hidden py-2.5 text-xs md:table-cell'>
|
||||
{app.category}
|
||||
</TableCell>
|
||||
<TableCell className='py-2.5 text-right font-mono tabular-nums'>
|
||||
{formatTokenVolume(app.monthly_tokens)}
|
||||
</TableCell>
|
||||
<TableCell className='py-2.5 text-right'>
|
||||
<GrowthChip value={app.growth_pct} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<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.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import {
|
||||
BookOpenCheck,
|
||||
Braces,
|
||||
Code2,
|
||||
Database,
|
||||
FileCode,
|
||||
Globe,
|
||||
type LucideIcon,
|
||||
PanelTopOpen,
|
||||
ScanEye,
|
||||
Settings2,
|
||||
Sparkles,
|
||||
Workflow,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ModelCapability } from '../types'
|
||||
|
||||
type CapabilityMeta = {
|
||||
icon: LucideIcon
|
||||
labelKey: string
|
||||
descriptionKey: string
|
||||
}
|
||||
|
||||
const CAPABILITY_META: Record<ModelCapability, CapabilityMeta> = {
|
||||
function_calling: {
|
||||
icon: Workflow,
|
||||
labelKey: 'Function calling',
|
||||
descriptionKey:
|
||||
'Invoke developer-defined functions with structured arguments',
|
||||
},
|
||||
streaming: {
|
||||
icon: Zap,
|
||||
labelKey: 'Streaming',
|
||||
descriptionKey: 'Stream tokens incrementally as they are generated',
|
||||
},
|
||||
vision: {
|
||||
icon: ScanEye,
|
||||
labelKey: 'Vision',
|
||||
descriptionKey: 'Understand image inputs alongside text',
|
||||
},
|
||||
json_mode: {
|
||||
icon: Braces,
|
||||
labelKey: 'JSON mode',
|
||||
descriptionKey: 'Force a syntactically valid JSON response',
|
||||
},
|
||||
structured_output: {
|
||||
icon: FileCode,
|
||||
labelKey: 'Structured output',
|
||||
descriptionKey: 'Return data conforming to a JSON schema',
|
||||
},
|
||||
reasoning: {
|
||||
icon: Sparkles,
|
||||
labelKey: 'Reasoning',
|
||||
descriptionKey: 'Multi-step thinking before final answer',
|
||||
},
|
||||
tools: {
|
||||
icon: Settings2,
|
||||
labelKey: 'Tools',
|
||||
descriptionKey: 'Use external tools to extend capabilities',
|
||||
},
|
||||
system_prompt: {
|
||||
icon: PanelTopOpen,
|
||||
labelKey: 'System prompt',
|
||||
descriptionKey: 'Steer behaviour with a system instruction',
|
||||
},
|
||||
web_search: {
|
||||
icon: Globe,
|
||||
labelKey: 'Web search',
|
||||
descriptionKey: 'Search the public web at inference time',
|
||||
},
|
||||
code_interpreter: {
|
||||
icon: Code2,
|
||||
labelKey: 'Code interpreter',
|
||||
descriptionKey: 'Execute code in a sandbox during the response',
|
||||
},
|
||||
caching: {
|
||||
icon: Database,
|
||||
labelKey: 'Prompt caching',
|
||||
descriptionKey: 'Cache repeated prompt prefixes for cheaper, faster reuse',
|
||||
},
|
||||
embeddings: {
|
||||
icon: BookOpenCheck,
|
||||
labelKey: 'Embeddings',
|
||||
descriptionKey: 'Return vector embeddings for inputs',
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Order capabilities for display. We put the most user-facing capabilities
|
||||
* first, then the rest. Anything not listed sinks to the bottom in a stable
|
||||
* order so the layout looks tidy across models.
|
||||
*/
|
||||
const CAPABILITY_ORDER: ModelCapability[] = [
|
||||
'streaming',
|
||||
'function_calling',
|
||||
'tools',
|
||||
'json_mode',
|
||||
'structured_output',
|
||||
'vision',
|
||||
'reasoning',
|
||||
'caching',
|
||||
'system_prompt',
|
||||
'web_search',
|
||||
'code_interpreter',
|
||||
'embeddings',
|
||||
]
|
||||
|
||||
function orderCapabilities(capabilities: ModelCapability[]): ModelCapability[] {
|
||||
const set = new Set(capabilities)
|
||||
const ordered = CAPABILITY_ORDER.filter((c) => set.has(c))
|
||||
for (const c of capabilities) {
|
||||
if (!ordered.includes(c)) ordered.push(c)
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
export function ModelDetailsCapabilities(props: {
|
||||
capabilities: ModelCapability[]
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const ordered = orderCapabilities(props.capabilities)
|
||||
|
||||
if (ordered.length === 0) {
|
||||
return (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{t('No capabilities reported for this model.')}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='grid grid-cols-2 gap-2 @md/details:grid-cols-3 @2xl/details:grid-cols-4'>
|
||||
{ordered.map((capability) => {
|
||||
const meta = CAPABILITY_META[capability]
|
||||
if (!meta) return null
|
||||
const Icon = meta.icon
|
||||
return (
|
||||
<div
|
||||
key={capability}
|
||||
className={cn(
|
||||
'group flex items-start gap-2 rounded-lg border p-3 transition-colors',
|
||||
'hover:bg-muted/30'
|
||||
)}
|
||||
>
|
||||
<span className='bg-muted text-foreground inline-flex size-7 shrink-0 items-center justify-center rounded-md transition-colors group-hover:bg-emerald-100 group-hover:text-emerald-700 dark:group-hover:bg-emerald-500/20 dark:group-hover:text-emerald-300'>
|
||||
<Icon className='size-3.5' />
|
||||
</span>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='text-foreground truncate text-xs font-semibold'>
|
||||
{t(meta.labelKey)}
|
||||
</div>
|
||||
<p className='text-muted-foreground mt-0.5 line-clamp-2 text-[11px] leading-snug'>
|
||||
{t(meta.descriptionKey)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import { useMemo } from 'react'
|
||||
import { VChart } from '@visactor/react-vchart'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useChartTheme } from '@/lib/use-chart-theme'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { VCHART_OPTION } from '@/lib/vchart'
|
||||
import type { LatencyTimePoint, UptimeDayPoint } from '../lib/mock-stats'
|
||||
|
||||
function formatHourLabel(iso: string): string {
|
||||
const date = new Date(iso)
|
||||
const hours = date.getHours()
|
||||
return `${String(hours).padStart(2, '0')}:00`
|
||||
}
|
||||
|
||||
function formatDayLabel(date: string): string {
|
||||
const parsed = new Date(date)
|
||||
return parsed.toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Latency trend chart (24h, multi-group line chart)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function LatencyTrendChart(props: {
|
||||
series: LatencyTimePoint[]
|
||||
className?: string
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { resolvedTheme, themeReady } = useChartTheme()
|
||||
|
||||
const spec = useMemo(() => {
|
||||
if (props.series.length === 0) return null
|
||||
const data = props.series.map((point) => ({
|
||||
time: formatHourLabel(point.timestamp),
|
||||
group: point.group,
|
||||
ttft: point.ttft_ms,
|
||||
}))
|
||||
return {
|
||||
type: 'line' as const,
|
||||
data: [{ id: 'latency', values: data }],
|
||||
xField: 'time',
|
||||
yField: 'ttft',
|
||||
seriesField: 'group',
|
||||
smooth: true,
|
||||
point: { visible: false },
|
||||
legends: { visible: true, orient: 'top', position: 'start' },
|
||||
tooltip: {
|
||||
mark: {
|
||||
title: { value: (d: { time: string }) => d.time },
|
||||
content: [
|
||||
{
|
||||
key: (d: { group: string }) => d.group,
|
||||
value: (d: { ttft: number }) => `${Math.round(d.ttft)} ms`,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
axes: [
|
||||
{
|
||||
orient: 'bottom',
|
||||
label: {
|
||||
style: { fill: 'currentColor', fontSize: 10 },
|
||||
},
|
||||
tick: { visible: false },
|
||||
},
|
||||
{
|
||||
orient: 'left',
|
||||
label: {
|
||||
formatMethod: (val: number | string) => `${val} ms`,
|
||||
style: { fill: 'currentColor', fontSize: 10 },
|
||||
},
|
||||
grid: { visible: true, style: { lineDash: [3, 3] } },
|
||||
},
|
||||
],
|
||||
}
|
||||
}, [props.series])
|
||||
|
||||
if (props.series.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'text-muted-foreground flex h-48 items-center justify-center rounded-lg border text-xs',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{t('No latency data available')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('h-64 sm:h-72', props.className)}>
|
||||
{themeReady && spec && (
|
||||
<VChart
|
||||
key={`latency-${resolvedTheme}`}
|
||||
spec={{
|
||||
...spec,
|
||||
theme: resolvedTheme === 'dark' ? 'dark' : 'light',
|
||||
background: 'transparent',
|
||||
}}
|
||||
option={VCHART_OPTION}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Uptime bar chart (30 days)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function UptimeBarChart(props: {
|
||||
series: UptimeDayPoint[]
|
||||
className?: string
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { resolvedTheme, themeReady } = useChartTheme()
|
||||
|
||||
const spec = useMemo(() => {
|
||||
if (props.series.length === 0) return null
|
||||
|
||||
const data = props.series.map((point) => ({
|
||||
date: formatDayLabel(point.date),
|
||||
uptime: point.uptime_pct,
|
||||
incidents: point.incidents,
|
||||
outage: point.outage_minutes,
|
||||
}))
|
||||
|
||||
return {
|
||||
type: 'bar' as const,
|
||||
data: [{ id: 'uptime', values: data }],
|
||||
xField: 'date',
|
||||
yField: 'uptime',
|
||||
bar: {
|
||||
style: {
|
||||
fill: (datum: { uptime: number }) => {
|
||||
if (datum.uptime >= 99.9) return '#10b981'
|
||||
if (datum.uptime >= 99.0) return '#f59e0b'
|
||||
return '#ef4444'
|
||||
},
|
||||
cornerRadius: 2,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
mark: {
|
||||
title: { value: (d: { date: string }) => d.date },
|
||||
content: [
|
||||
{
|
||||
key: t('Uptime'),
|
||||
value: (d: { uptime: number }) => `${d.uptime.toFixed(2)}%`,
|
||||
},
|
||||
{
|
||||
key: t('Incidents'),
|
||||
value: (d: { incidents: number }) => `${d.incidents}`,
|
||||
},
|
||||
{
|
||||
key: t('Outage'),
|
||||
value: (d: { outage: number }) => `${d.outage} ${t('minutes')}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
axes: [
|
||||
{
|
||||
orient: 'bottom',
|
||||
label: {
|
||||
style: { fill: 'currentColor', fontSize: 10 },
|
||||
autoLimit: true,
|
||||
},
|
||||
tick: { visible: false },
|
||||
},
|
||||
{
|
||||
orient: 'left',
|
||||
min: 95,
|
||||
max: 100,
|
||||
label: {
|
||||
formatMethod: (val: number | string) => `${val}%`,
|
||||
style: { fill: 'currentColor', fontSize: 10 },
|
||||
},
|
||||
grid: { visible: true, style: { lineDash: [3, 3] } },
|
||||
},
|
||||
],
|
||||
}
|
||||
}, [props.series, t])
|
||||
|
||||
if (props.series.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'text-muted-foreground flex h-48 items-center justify-center rounded-lg border text-xs',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{t('No uptime data available')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('h-56 sm:h-64', props.className)}>
|
||||
{themeReady && spec && (
|
||||
<VChart
|
||||
key={`uptime-${resolvedTheme}`}
|
||||
spec={{
|
||||
...spec,
|
||||
theme: resolvedTheme === 'dark' ? 'dark' : 'light',
|
||||
background: 'transparent',
|
||||
}}
|
||||
option={VCHART_OPTION}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Throughput by group (horizontal bar)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ThroughputBarChart(props: {
|
||||
rows: { group: string; throughput_tps: number }[]
|
||||
className?: string
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { resolvedTheme, themeReady } = useChartTheme()
|
||||
|
||||
const filtered = useMemo(
|
||||
() => props.rows.filter((r) => r.throughput_tps > 0),
|
||||
[props.rows]
|
||||
)
|
||||
|
||||
const spec = useMemo(() => {
|
||||
if (filtered.length === 0) return null
|
||||
return {
|
||||
type: 'bar' as const,
|
||||
direction: 'horizontal' as const,
|
||||
data: [{ id: 'tput', values: filtered.map((r) => ({ ...r })) }],
|
||||
xField: 'throughput_tps',
|
||||
yField: 'group',
|
||||
bar: {
|
||||
style: { fill: '#6366f1', cornerRadius: 2 },
|
||||
},
|
||||
label: {
|
||||
visible: true,
|
||||
position: 'right',
|
||||
style: { fontSize: 11, fill: 'currentColor' },
|
||||
formatMethod: (text: string) => `${text} t/s`,
|
||||
},
|
||||
axes: [
|
||||
{
|
||||
orient: 'left',
|
||||
label: { style: { fill: 'currentColor', fontSize: 10 } },
|
||||
tick: { visible: false },
|
||||
},
|
||||
{
|
||||
orient: 'bottom',
|
||||
label: { style: { fill: 'currentColor', fontSize: 10 } },
|
||||
grid: { visible: true, style: { lineDash: [3, 3] } },
|
||||
},
|
||||
],
|
||||
tooltip: {
|
||||
mark: {
|
||||
title: { value: (d: { group: string }) => d.group },
|
||||
content: [
|
||||
{
|
||||
key: t('Throughput'),
|
||||
value: (d: { throughput_tps: number }) =>
|
||||
`${d.throughput_tps.toFixed(1)} t/s`,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
}, [filtered, t])
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('h-48 sm:h-56', props.className)}>
|
||||
{themeReady && spec && (
|
||||
<VChart
|
||||
key={`tput-${resolvedTheme}`}
|
||||
spec={{
|
||||
...spec,
|
||||
theme: resolvedTheme === 'dark' ? 'dark' : 'light',
|
||||
background: 'transparent',
|
||||
}}
|
||||
option={VCHART_OPTION}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import {
|
||||
FileText,
|
||||
Image as ImageIcon,
|
||||
Mic2,
|
||||
Type as TypeIcon,
|
||||
Video,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import type { Modality } from '../types'
|
||||
|
||||
type IconComponent = React.ComponentType<{ className?: string }>
|
||||
|
||||
const MODALITY_META: Record<
|
||||
Modality,
|
||||
{ icon: IconComponent; labelKey: string }
|
||||
> = {
|
||||
text: { icon: TypeIcon, labelKey: 'Text' },
|
||||
image: { icon: ImageIcon, labelKey: 'Image' },
|
||||
audio: { icon: Mic2, labelKey: 'Audio' },
|
||||
video: { icon: Video, labelKey: 'Video' },
|
||||
file: { icon: FileText, labelKey: 'File' },
|
||||
}
|
||||
|
||||
const ALL_MODALITIES: Modality[] = ['text', 'image', 'audio', 'video', 'file']
|
||||
|
||||
/** Inline modality icons (used by the quick-stats flow). */
|
||||
export function ModalityIcons(props: {
|
||||
modalities: Modality[]
|
||||
className?: string
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
if (props.modalities.length === 0) {
|
||||
return <span className='text-muted-foreground text-xs'>—</span>
|
||||
}
|
||||
return (
|
||||
<span className='inline-flex items-center gap-1'>
|
||||
{props.modalities.map((modality) => {
|
||||
const meta = MODALITY_META[modality]
|
||||
const Icon = meta.icon
|
||||
return (
|
||||
<Tooltip key={modality}>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span
|
||||
aria-label={t(meta.labelKey)}
|
||||
className='text-foreground/80 inline-flex'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Icon className={cn('size-3.5', props.className)} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top' className='text-xs'>
|
||||
{t(meta.labelKey)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 2 × N matrix showing which modalities are supported as input vs output.
|
||||
* Cells with a checkmark indicate support; empty cells show a dash.
|
||||
*/
|
||||
export function ModalitiesMatrix(props: {
|
||||
input: Modality[]
|
||||
output: Modality[]
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const inputSet = new Set(props.input)
|
||||
const outputSet = new Set(props.output)
|
||||
|
||||
const renderRow = (label: string, set: Set<Modality>) => (
|
||||
<tr>
|
||||
<th
|
||||
scope='row'
|
||||
className='text-muted-foreground bg-muted/30 px-3 py-2 text-left text-[11px] font-medium tracking-wider uppercase'
|
||||
>
|
||||
{label}
|
||||
</th>
|
||||
{ALL_MODALITIES.map((modality) => {
|
||||
const enabled = set.has(modality)
|
||||
const Icon = MODALITY_META[modality].icon
|
||||
return (
|
||||
<td
|
||||
key={modality}
|
||||
className={cn(
|
||||
'border-l px-3 py-2 text-center',
|
||||
enabled
|
||||
? 'bg-emerald-50/40 dark:bg-emerald-500/10'
|
||||
: 'bg-background'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center',
|
||||
enabled
|
||||
? 'text-emerald-700 dark:text-emerald-300'
|
||||
: 'text-muted-foreground/40'
|
||||
)}
|
||||
aria-label={
|
||||
enabled
|
||||
? t('{{modality}} supported', {
|
||||
modality: t(MODALITY_META[modality].labelKey),
|
||||
})
|
||||
: t('{{modality}} not supported', {
|
||||
modality: t(MODALITY_META[modality].labelKey),
|
||||
})
|
||||
}
|
||||
>
|
||||
<Icon className='size-4' />
|
||||
</span>
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='overflow-x-auto rounded-lg border'>
|
||||
<table className='w-full text-sm'>
|
||||
<thead>
|
||||
<tr className='bg-muted/40'>
|
||||
<th
|
||||
scope='col'
|
||||
className='text-muted-foreground px-3 py-2 text-left text-[11px] font-medium tracking-wider uppercase'
|
||||
>
|
||||
{t('Modality')}
|
||||
</th>
|
||||
{ALL_MODALITIES.map((modality) => (
|
||||
<th
|
||||
key={modality}
|
||||
scope='col'
|
||||
className='text-muted-foreground border-l px-3 py-2 text-center text-[11px] font-medium tracking-wider uppercase'
|
||||
>
|
||||
{t(MODALITY_META[modality].labelKey)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{renderRow(t('Input'), inputSet)}
|
||||
{renderRow(t('Output'), outputSet)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
Gauge,
|
||||
HeartPulse,
|
||||
Timer,
|
||||
TrendingUp,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { GroupBadge } from '@/components/group-badge'
|
||||
import {
|
||||
aggregateUptime,
|
||||
buildGroupPerformance,
|
||||
buildLatencyTimeSeries,
|
||||
buildUptimeSeries,
|
||||
formatLatency,
|
||||
formatThroughput,
|
||||
formatUptimePct,
|
||||
type UptimeDayPoint,
|
||||
} from '../lib/mock-stats'
|
||||
import type { PricingModel } from '../types'
|
||||
import {
|
||||
LatencyTrendChart,
|
||||
ThroughputBarChart,
|
||||
UptimeBarChart,
|
||||
} from './model-details-charts'
|
||||
import { UptimeSparkline } from './model-details-uptime-sparkline'
|
||||
|
||||
const COMPACT_NUMBER = new Intl.NumberFormat(undefined, {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
})
|
||||
|
||||
function StatCard(props: {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
label: string
|
||||
value: React.ReactNode
|
||||
hint?: string
|
||||
intent?: 'default' | 'warning' | 'success'
|
||||
}) {
|
||||
const Icon = props.icon
|
||||
const intent = props.intent ?? 'default'
|
||||
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-[10px] font-medium tracking-wider uppercase'>
|
||||
<Icon className='size-3' />
|
||||
{props.label}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-foreground font-mono text-lg font-semibold tabular-nums',
|
||||
intent === 'warning' && 'text-amber-600 dark:text-amber-400',
|
||||
intent === 'success' && 'text-emerald-600 dark:text-emerald-400'
|
||||
)}
|
||||
>
|
||||
{props.value}
|
||||
</span>
|
||||
{props.hint && (
|
||||
<span className='text-muted-foreground/70 text-[11px]'>
|
||||
{props.hint}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ModelDetailsPerformance(props: { model: PricingModel }) {
|
||||
const { t } = useTranslation()
|
||||
const performances = useMemo(
|
||||
() => buildGroupPerformance(props.model),
|
||||
[props.model]
|
||||
)
|
||||
const latencySeries = useMemo(
|
||||
() => buildLatencyTimeSeries(props.model),
|
||||
[props.model]
|
||||
)
|
||||
const uptimeSeries = useMemo(
|
||||
() => buildUptimeSeries(props.model),
|
||||
[props.model]
|
||||
)
|
||||
const aggregated = useMemo(
|
||||
() => aggregateUptime(uptimeSeries),
|
||||
[uptimeSeries]
|
||||
)
|
||||
const uptimeByGroup = useMemo<Record<string, UptimeDayPoint[]>>(() => {
|
||||
const map: Record<string, UptimeDayPoint[]> = {}
|
||||
for (const perf of performances) {
|
||||
map[perf.group] = buildUptimeSeries(props.model, perf.group)
|
||||
}
|
||||
return map
|
||||
}, [performances, props.model])
|
||||
|
||||
if (performances.length === 0) {
|
||||
return (
|
||||
<div className='text-muted-foreground rounded-lg border p-6 text-center text-sm'>
|
||||
{t('Performance data is not yet available for this model.')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const bestTtft = Math.min(...performances.map((p) => p.ttft_p50_ms))
|
||||
const bestThroughput = Math.max(...performances.map((p) => p.throughput_tps))
|
||||
const totalRequests = performances.reduce(
|
||||
(s, p) => s + p.request_volume_24h,
|
||||
0
|
||||
)
|
||||
const intent =
|
||||
aggregated.uptime_pct >= 99.9
|
||||
? 'success'
|
||||
: aggregated.uptime_pct >= 99
|
||||
? 'default'
|
||||
: 'warning'
|
||||
|
||||
const headerCellClass =
|
||||
'text-muted-foreground py-2 text-[10px] font-medium tracking-wider uppercase'
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='grid grid-cols-2 gap-2 lg:grid-cols-4'>
|
||||
<StatCard
|
||||
icon={Timer}
|
||||
label={t('Best TTFT')}
|
||||
value={formatLatency(bestTtft)}
|
||||
hint={t('Lowest median first-token latency')}
|
||||
/>
|
||||
<StatCard
|
||||
icon={Gauge}
|
||||
label={t('Peak throughput')}
|
||||
value={formatThroughput(bestThroughput)}
|
||||
hint={t('Across all groups')}
|
||||
/>
|
||||
<StatCard
|
||||
icon={HeartPulse}
|
||||
label={t('Uptime (30d)')}
|
||||
value={formatUptimePct(aggregated.uptime_pct)}
|
||||
hint={
|
||||
aggregated.incidents > 0
|
||||
? t('{{count}} incidents in the last 30 days', {
|
||||
count: aggregated.incidents,
|
||||
})
|
||||
: t('No incidents in the last 30 days')
|
||||
}
|
||||
intent={intent}
|
||||
/>
|
||||
<StatCard
|
||||
icon={TrendingUp}
|
||||
label={t('Requests (24h)')}
|
||||
value={COMPACT_NUMBER.format(totalRequests)}
|
||||
hint={t('Aggregated across enabled groups')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<SectionHeader
|
||||
icon={Activity}
|
||||
title={t('Per-group performance')}
|
||||
description={t(
|
||||
'TTFT percentiles, throughput, and 30-day uptime by group'
|
||||
)}
|
||||
/>
|
||||
<div className='overflow-x-auto rounded-lg border'>
|
||||
<Table className='text-sm'>
|
||||
<TableHeader>
|
||||
<TableRow className='hover:bg-transparent'>
|
||||
<TableHead className={headerCellClass}>{t('Group')}</TableHead>
|
||||
<TableHead className={`${headerCellClass} text-right`}>
|
||||
{t('TTFT P50')}
|
||||
</TableHead>
|
||||
<TableHead className={`${headerCellClass} text-right`}>
|
||||
{t('TTFT P95')}
|
||||
</TableHead>
|
||||
<TableHead className={`${headerCellClass} text-right`}>
|
||||
{t('TTFT P99')}
|
||||
</TableHead>
|
||||
<TableHead className={`${headerCellClass} text-right`}>
|
||||
{t('Throughput')}
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className={`${headerCellClass} min-w-[160px] text-left`}
|
||||
>
|
||||
{t('Uptime (30d)')}
|
||||
</TableHead>
|
||||
<TableHead className={`${headerCellClass} text-right`}>
|
||||
{t('Requests / 24h')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{performances.map((perf) => {
|
||||
const isBestTtft = perf.ttft_p50_ms === bestTtft
|
||||
const isBestTput = perf.throughput_tps === bestThroughput
|
||||
return (
|
||||
<TableRow key={perf.group}>
|
||||
<TableCell className='py-2.5'>
|
||||
<GroupBadge group={perf.group} size='sm' />
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={cn(
|
||||
'py-2.5 text-right font-mono',
|
||||
isBestTtft && 'text-emerald-600 dark:text-emerald-400'
|
||||
)}
|
||||
>
|
||||
{formatLatency(perf.ttft_p50_ms)}
|
||||
</TableCell>
|
||||
<TableCell className='text-muted-foreground py-2.5 text-right font-mono'>
|
||||
{formatLatency(perf.ttft_p95_ms)}
|
||||
</TableCell>
|
||||
<TableCell className='text-muted-foreground py-2.5 text-right font-mono'>
|
||||
{formatLatency(perf.ttft_p99_ms)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={cn(
|
||||
'py-2.5 text-right font-mono',
|
||||
isBestTput &&
|
||||
perf.throughput_tps > 0 &&
|
||||
'text-emerald-600 dark:text-emerald-400'
|
||||
)}
|
||||
>
|
||||
{formatThroughput(perf.throughput_tps)}
|
||||
</TableCell>
|
||||
<TableCell className='py-2.5'>
|
||||
<UptimeSparkline
|
||||
size='sm'
|
||||
series={uptimeByGroup[perf.group] ?? []}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className='text-muted-foreground py-2.5 text-right font-mono'>
|
||||
{COMPACT_NUMBER.format(perf.request_volume_24h)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionHeader
|
||||
icon={Timer}
|
||||
title={t('Latency trend (last 24h)')}
|
||||
description={t(
|
||||
'Median time-to-first-token (TTFT) sampled hourly per group'
|
||||
)}
|
||||
/>
|
||||
<LatencyTrendChart series={latencySeries} />
|
||||
</section>
|
||||
|
||||
{bestThroughput > 0 && (
|
||||
<section>
|
||||
<SectionHeader
|
||||
icon={Gauge}
|
||||
title={t('Throughput by group')}
|
||||
description={t('Average tokens per second sustained per group')}
|
||||
/>
|
||||
<ThroughputBarChart rows={performances} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<SectionHeader
|
||||
icon={HeartPulse}
|
||||
title={t('Uptime (last 30 days)')}
|
||||
description={
|
||||
aggregated.incidents > 0
|
||||
? t(
|
||||
'Daily uptime; {{incidents}} incidents totalling {{minutes}} minutes',
|
||||
{
|
||||
incidents: aggregated.incidents,
|
||||
minutes: aggregated.outage_minutes,
|
||||
}
|
||||
)
|
||||
: t('Daily uptime over the last 30 days')
|
||||
}
|
||||
accent={
|
||||
aggregated.incidents > 0 ? (
|
||||
<span className='inline-flex items-center gap-1 text-amber-600 dark:text-amber-400'>
|
||||
<AlertTriangle className='size-3.5' />
|
||||
{t('{{count}} incidents', {
|
||||
count: aggregated.incidents,
|
||||
})}
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<UptimeBarChart series={uptimeSeries} />
|
||||
</section>
|
||||
|
||||
<p className='text-muted-foreground/60 text-[11px] leading-relaxed'>
|
||||
{t(
|
||||
'Performance metrics shown here are simulated for preview purposes and will be replaced with live observability data once the backend integration is complete.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionHeader(props: {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
title: string
|
||||
description?: string
|
||||
accent?: React.ReactNode
|
||||
}) {
|
||||
const Icon = props.icon
|
||||
return (
|
||||
<div className='mb-2 flex flex-wrap items-center justify-between gap-2'>
|
||||
<div className='flex min-w-0 items-center gap-2'>
|
||||
<Icon className='text-muted-foreground/70 size-3.5 shrink-0' />
|
||||
<div className='min-w-0'>
|
||||
<div className='text-foreground text-sm font-semibold'>
|
||||
{props.title}
|
||||
</div>
|
||||
{props.description && (
|
||||
<p className='text-muted-foreground/80 text-xs'>
|
||||
{props.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{props.accent && (
|
||||
<div className='shrink-0 text-xs font-medium'>{props.accent}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
CalendarClock,
|
||||
FileText,
|
||||
Layers,
|
||||
Maximize2,
|
||||
Sparkles,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
formatTokenCount,
|
||||
formatYearMonth,
|
||||
type ModelMetadata,
|
||||
} from '../lib/model-metadata'
|
||||
import type { Modality } from '../types'
|
||||
import { ModalityIcons } from './model-details-modalities'
|
||||
|
||||
type QuickStatsProps = {
|
||||
metadata: ModelMetadata
|
||||
}
|
||||
|
||||
type Stat = {
|
||||
key: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
label: string
|
||||
value: React.ReactNode
|
||||
hint?: string
|
||||
}
|
||||
|
||||
function buildStats(
|
||||
metadata: ModelMetadata,
|
||||
t: (key: string) => string
|
||||
): Stat[] {
|
||||
const stats: Stat[] = [
|
||||
{
|
||||
key: 'context',
|
||||
icon: Layers,
|
||||
label: t('Context'),
|
||||
value: formatTokenCount(metadata.context_length),
|
||||
hint: t('Maximum input window'),
|
||||
},
|
||||
]
|
||||
|
||||
if (metadata.max_output_tokens > 0) {
|
||||
stats.push({
|
||||
key: 'max-output',
|
||||
icon: Maximize2,
|
||||
label: t('Max output'),
|
||||
value: formatTokenCount(metadata.max_output_tokens),
|
||||
hint: t('Maximum tokens per response'),
|
||||
})
|
||||
}
|
||||
|
||||
stats.push({
|
||||
key: 'modalities',
|
||||
icon: FileText,
|
||||
label: t('Modalities'),
|
||||
value: (
|
||||
<ModalityFlow
|
||||
input={metadata.input_modalities}
|
||||
output={metadata.output_modalities}
|
||||
/>
|
||||
),
|
||||
})
|
||||
|
||||
if (metadata.knowledge_cutoff) {
|
||||
stats.push({
|
||||
key: 'knowledge',
|
||||
icon: Sparkles,
|
||||
label: t('Knowledge cutoff'),
|
||||
value: formatYearMonth(metadata.knowledge_cutoff),
|
||||
})
|
||||
}
|
||||
|
||||
if (metadata.release_date) {
|
||||
stats.push({
|
||||
key: 'release',
|
||||
icon: CalendarClock,
|
||||
label: t('Released'),
|
||||
value: formatYearMonth(metadata.release_date),
|
||||
})
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
function ModalityFlow(props: { input: Modality[]; output: Modality[] }) {
|
||||
return (
|
||||
<span className='inline-flex items-center gap-1 align-middle'>
|
||||
<ModalityIcons modalities={props.input} className='size-3.5' />
|
||||
<span className='text-muted-foreground/40'>→</span>
|
||||
<ModalityIcons modalities={props.output} className='size-3.5' />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function ModelDetailsQuickStats(props: QuickStatsProps) {
|
||||
const { t } = useTranslation()
|
||||
const stats = buildStats(props.metadata, t)
|
||||
|
||||
return (
|
||||
<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={cn(
|
||||
'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/60 truncate text-[10px]'>
|
||||
{stat.hint}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Activity, AlertCircle, CheckCircle2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import {
|
||||
aggregateUptime,
|
||||
formatUptimePct,
|
||||
type UptimeDayPoint,
|
||||
} from '../lib/mock-stats'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Uptime sparkline
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Compact 30-day uptime visualisation: a row of small coloured bars where:
|
||||
// - Bar colour reflects per-day uptime (green / amber / red)
|
||||
// - Bar height reflects severity (the worse the day, the shorter the bar)
|
||||
// - Hovering a bar reveals the exact date and uptime
|
||||
//
|
||||
// Useful as a header strip ("at-a-glance" status) and as a per-row visual
|
||||
// inside the per-group performance table.
|
||||
|
||||
type SparklineSize = 'sm' | 'md'
|
||||
|
||||
type UptimeSparklineProps = {
|
||||
series: UptimeDayPoint[]
|
||||
size?: SparklineSize
|
||||
showOverall?: boolean
|
||||
emptyLabel?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
function colourFor(uptime: number): string {
|
||||
if (uptime >= 99.9) return 'bg-emerald-500'
|
||||
if (uptime >= 99.0) return 'bg-emerald-400'
|
||||
if (uptime >= 95.0) return 'bg-amber-500'
|
||||
if (uptime >= 90.0) return 'bg-amber-600'
|
||||
return 'bg-rose-500'
|
||||
}
|
||||
|
||||
function heightFor(uptime: number): string {
|
||||
if (uptime >= 99.9) return 'h-full'
|
||||
if (uptime >= 99.0) return 'h-[88%]'
|
||||
if (uptime >= 95.0) return 'h-[72%]'
|
||||
if (uptime >= 90.0) return 'h-[55%]'
|
||||
return 'h-[40%]'
|
||||
}
|
||||
|
||||
function overallTextColour(pct: number): string {
|
||||
if (pct >= 99.9) return 'text-emerald-600 dark:text-emerald-400'
|
||||
if (pct >= 99.0) return 'text-emerald-600 dark:text-emerald-400'
|
||||
if (pct >= 95.0) return 'text-amber-600 dark:text-amber-400'
|
||||
return 'text-rose-600 dark:text-rose-400'
|
||||
}
|
||||
|
||||
export function UptimeSparkline(props: UptimeSparklineProps) {
|
||||
const size = props.size ?? 'md'
|
||||
const showOverall = props.showOverall ?? true
|
||||
|
||||
if (props.series.length === 0) {
|
||||
return (
|
||||
<span className={cn('text-muted-foreground text-xs', props.className)}>
|
||||
{props.emptyLabel ?? '—'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const overall =
|
||||
props.series.reduce((s, p) => s + p.uptime_pct, 0) / props.series.length
|
||||
|
||||
const containerHeight = size === 'sm' ? 'h-3.5' : 'h-5'
|
||||
const barWidth = size === 'sm' ? 'w-[3px]' : 'w-1'
|
||||
const gap = size === 'sm' ? 'gap-px' : 'gap-[2px]'
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-2', props.className)}>
|
||||
<div
|
||||
className={cn('flex items-end', containerHeight, gap)}
|
||||
role='img'
|
||||
aria-label={`30 day uptime ${overall.toFixed(2)}%`}
|
||||
>
|
||||
{props.series.map((day) => (
|
||||
<Tooltip key={day.date}>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-[1px] transition-opacity hover:opacity-80',
|
||||
barWidth,
|
||||
containerHeight,
|
||||
'flex items-end'
|
||||
)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'w-full rounded-[1px]',
|
||||
colourFor(day.uptime_pct),
|
||||
heightFor(day.uptime_pct)
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top' className='font-mono text-xs'>
|
||||
<div className='font-medium'>{day.date}</div>
|
||||
<div>{day.uptime_pct.toFixed(2)}%</div>
|
||||
{day.outage_minutes > 0 && (
|
||||
<div className='text-muted-foreground'>
|
||||
{day.outage_minutes} min outage
|
||||
</div>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
{showOverall && (
|
||||
<span
|
||||
className={cn(
|
||||
'font-mono text-sm font-semibold tabular-nums',
|
||||
overallTextColour(overall)
|
||||
)}
|
||||
>
|
||||
{overall.toFixed(1)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Uptime status row — sparkline + summary text + status icon
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function UptimeStatusRow(props: {
|
||||
series: UptimeDayPoint[]
|
||||
className?: string
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const summary = useMemo(() => aggregateUptime(props.series), [props.series])
|
||||
const status = useMemo(() => {
|
||||
if (summary.uptime_pct >= 99.9) return 'operational'
|
||||
if (summary.uptime_pct >= 99.0) return 'minor'
|
||||
if (summary.uptime_pct >= 95.0) return 'degraded'
|
||||
return 'major'
|
||||
}, [summary.uptime_pct])
|
||||
|
||||
const StatusIcon =
|
||||
status === 'operational'
|
||||
? CheckCircle2
|
||||
: status === 'minor'
|
||||
? Activity
|
||||
: AlertCircle
|
||||
|
||||
const statusColour =
|
||||
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'
|
||||
? t('All systems operational')
|
||||
: status === 'minor'
|
||||
? t('Minor blips in the last 30 days')
|
||||
: status === 'degraded'
|
||||
? t('Degraded performance recently')
|
||||
: t('Significant outages detected')
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'border-border/60 bg-muted/30 flex flex-wrap items-center gap-3 rounded-lg border px-3 py-2 sm:gap-4 sm:px-4',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<StatusIcon className={cn('size-4 shrink-0', statusColour)} />
|
||||
<span className='text-sm font-medium'>{t('Last 30 days uptime')}</span>
|
||||
</div>
|
||||
|
||||
<UptimeSparkline series={props.series} className='ml-auto' />
|
||||
|
||||
<div className='flex items-center gap-3 text-xs'>
|
||||
<span className={cn('font-medium', statusColour)}>{statusLabel}</span>
|
||||
{summary.incidents > 0 && (
|
||||
<span className='text-muted-foreground'>
|
||||
{summary.incidents}{' '}
|
||||
{summary.incidents === 1 ? t('incident') : t('incidents')}
|
||||
</span>
|
||||
)}
|
||||
{summary.outage_minutes > 0 && (
|
||||
<span className='text-muted-foreground'>
|
||||
{summary.outage_minutes} {t('min downtime')}
|
||||
</span>
|
||||
)}
|
||||
<span className='text-muted-foreground hidden sm:inline'>
|
||||
{formatUptimePct(summary.uptime_pct)} {t('overall')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+296
-170
@@ -1,9 +1,24 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useParams, useNavigate, useSearch } from '@tanstack/react-router'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { useNavigate, useParams, useSearch } from '@tanstack/react-router'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Boxes,
|
||||
Code2,
|
||||
HeartPulse,
|
||||
Info,
|
||||
ReceiptText,
|
||||
Rocket,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getLobeIcon } from '@/lib/lobe-icon'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Table,
|
||||
@@ -13,33 +28,40 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { CopyButton } from '@/components/copy-button'
|
||||
import { GroupBadge } from '@/components/group-badge'
|
||||
import { PublicLayout } from '@/components/layout'
|
||||
import { DEFAULT_TOKEN_UNIT, QUOTA_TYPE_VALUES } from '../constants'
|
||||
import { usePricingData } from '../hooks/use-pricing-data'
|
||||
import { parseTags } from '../lib/filters'
|
||||
import {
|
||||
getAvailableGroups,
|
||||
replaceModelInPath,
|
||||
isTokenBasedModel,
|
||||
} from '../lib/model-helpers'
|
||||
import {
|
||||
getDynamicPriceEntries,
|
||||
getDynamicPricingSummary,
|
||||
getDynamicPricingTiers,
|
||||
isDynamicPricingModel,
|
||||
} from '../lib/dynamic-price'
|
||||
import { formatGroupPrice, formatFixedPrice } from '../lib/price'
|
||||
import type { PricingModel, TokenUnit, PriceType } from '../types'
|
||||
import { parseTags } from '../lib/filters'
|
||||
import { buildUptimeSeries } from '../lib/mock-stats'
|
||||
import {
|
||||
getAvailableGroups,
|
||||
isTokenBasedModel,
|
||||
replaceModelInPath,
|
||||
} from '../lib/model-helpers'
|
||||
import { inferModelMetadata } from '../lib/model-metadata'
|
||||
import { formatFixedPrice, formatGroupPrice } from '../lib/price'
|
||||
import type { PriceType, PricingModel, TokenUnit } from '../types'
|
||||
import { DynamicPricingBreakdown } from './dynamic-pricing-breakdown'
|
||||
import { ModelDetailsApi, ModelDetailsProviderInfo } from './model-details-api'
|
||||
import { ModelDetailsApps } from './model-details-apps'
|
||||
import { ModelDetailsCapabilities } from './model-details-capabilities'
|
||||
import { ModalitiesMatrix } from './model-details-modalities'
|
||||
import { ModelDetailsPerformance } from './model-details-performance'
|
||||
import { ModelDetailsQuickStats } from './model-details-quick-stats'
|
||||
import { UptimeStatusRow } from './model-details-uptime-sparkline'
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Local UI helpers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
function SectionTitle(props: { children: React.ReactNode }) {
|
||||
return (
|
||||
@@ -49,6 +71,10 @@ function SectionTitle(props: { children: React.ReactNode }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Model header (always visible above the tabs)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
function ModelHeader(props: { model: PricingModel }) {
|
||||
const { t } = useTranslation()
|
||||
const model = props.model
|
||||
@@ -63,7 +89,7 @@ function ModelHeader(props: { model: PricingModel }) {
|
||||
getDynamicPricingTiers(model).length === 0
|
||||
|
||||
return (
|
||||
<header className='pb-5'>
|
||||
<header className='pb-4'>
|
||||
<div className='flex items-center gap-2.5'>
|
||||
{vendorIcon}
|
||||
<h1 className='font-mono text-xl font-bold tracking-tight sm:text-2xl'>
|
||||
@@ -78,12 +104,12 @@ function ModelHeader(props: { model: PricingModel }) {
|
||||
aria-label={t('Copy model name')}
|
||||
/>
|
||||
</div>
|
||||
<div className='mt-1 flex items-center gap-1.5 text-xs'>
|
||||
<div className='mt-1 flex flex-wrap items-center gap-1.5 text-xs'>
|
||||
{model.vendor_name && (
|
||||
<span className='text-muted-foreground'>{model.vendor_name}</span>
|
||||
)}
|
||||
<span className='text-muted-foreground/30'>·</span>
|
||||
<span className='text-muted-foreground/50'>
|
||||
<span className='text-muted-foreground/70'>
|
||||
{model.quota_type === QUOTA_TYPE_VALUES.TOKEN
|
||||
? t('Token-based')
|
||||
: t('Per Request')}
|
||||
@@ -120,6 +146,10 @@ function ModelHeader(props: { model: PricingModel }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Base price card (used in the Overview tab)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
function PriceSection(props: {
|
||||
model: PricingModel
|
||||
priceRate: number
|
||||
@@ -128,22 +158,15 @@ function PriceSection(props: {
|
||||
showRechargePrice: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
model,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
tokenUnit,
|
||||
showRechargePrice,
|
||||
} = props
|
||||
const isTokenBased = isTokenBasedModel(model)
|
||||
const tokenUnitLabel = tokenUnit === 'K' ? '1K' : '1M'
|
||||
const isTokenBased = isTokenBasedModel(props.model)
|
||||
const tokenUnitLabel = props.tokenUnit === 'K' ? '1K' : '1M'
|
||||
const baseGroupKey = '_base'
|
||||
const baseGroupRatioMap = { [baseGroupKey]: 1 }
|
||||
const dynamicSummary = getDynamicPricingSummary(model, {
|
||||
tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
const dynamicSummary = getDynamicPricingSummary(props.model, {
|
||||
tokenUnit: props.tokenUnit,
|
||||
showRechargePrice: props.showRechargePrice,
|
||||
priceRate: props.priceRate,
|
||||
usdExchangeRate: props.usdExchangeRate,
|
||||
groupRatioMultiplier: 1,
|
||||
})
|
||||
|
||||
@@ -159,38 +182,39 @@ function PriceSection(props: {
|
||||
{
|
||||
label: t('Cached input'),
|
||||
type: 'cache',
|
||||
available: model.cache_ratio != null,
|
||||
available: props.model.cache_ratio != null,
|
||||
},
|
||||
{
|
||||
label: t('Cache write'),
|
||||
type: 'create_cache',
|
||||
available: model.create_cache_ratio != null,
|
||||
available: props.model.create_cache_ratio != null,
|
||||
},
|
||||
{
|
||||
label: t('Image input'),
|
||||
type: 'image',
|
||||
available: model.image_ratio != null,
|
||||
available: props.model.image_ratio != null,
|
||||
},
|
||||
{
|
||||
label: t('Audio input'),
|
||||
type: 'audio_input',
|
||||
available: model.audio_ratio != null,
|
||||
available: props.model.audio_ratio != null,
|
||||
},
|
||||
{
|
||||
label: t('Audio output'),
|
||||
type: 'audio_output',
|
||||
available:
|
||||
model.audio_ratio != null && model.audio_completion_ratio != null,
|
||||
props.model.audio_ratio != null &&
|
||||
props.model.audio_completion_ratio != null,
|
||||
},
|
||||
]
|
||||
|
||||
if (dynamicSummary) {
|
||||
if (dynamicSummary.isSpecialExpression) {
|
||||
return (
|
||||
<section className='border-b py-4'>
|
||||
<section>
|
||||
<SectionTitle>{t('Base Price')}</SectionTitle>
|
||||
<div className='rounded-lg border border-amber-200/70 bg-amber-50/70 p-3 dark:border-amber-500/20 dark:bg-amber-500/10'>
|
||||
<div className='text-amber-800 text-sm font-medium dark:text-amber-200'>
|
||||
<div className='text-sm font-medium text-amber-800 dark:text-amber-200'>
|
||||
{t('Special billing expression')}
|
||||
</div>
|
||||
<p className='text-muted-foreground mt-1 text-xs'>
|
||||
@@ -200,7 +224,7 @@ function PriceSection(props: {
|
||||
<div className='text-muted-foreground mb-1 text-[10px] font-medium tracking-wider uppercase'>
|
||||
{t('Raw expression')}
|
||||
</div>
|
||||
<code className='text-muted-foreground block max-h-28 overflow-auto rounded-md border bg-background/80 px-2 py-1.5 font-mono text-xs break-all'>
|
||||
<code className='text-muted-foreground bg-background/80 block max-h-28 overflow-auto rounded-md border px-2 py-1.5 font-mono text-xs break-all'>
|
||||
{dynamicSummary.rawExpression}
|
||||
</code>
|
||||
</div>
|
||||
@@ -210,12 +234,15 @@ function PriceSection(props: {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className='border-b py-4'>
|
||||
<section>
|
||||
<SectionTitle>{t('Base Price')}</SectionTitle>
|
||||
{dynamicSummary.primaryEntries.length > 0 ? (
|
||||
<div className='grid grid-cols-2 gap-2'>
|
||||
{dynamicSummary.primaryEntries.map((entry) => (
|
||||
<div key={entry.key} className='rounded-lg border bg-muted/20 p-3'>
|
||||
<div
|
||||
key={entry.key}
|
||||
className='bg-muted/20 rounded-lg border p-3'
|
||||
>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{t(entry.shortLabel)}
|
||||
</div>
|
||||
@@ -261,7 +288,7 @@ function PriceSection(props: {
|
||||
|
||||
if (!isTokenBased) {
|
||||
return (
|
||||
<section className='border-b py-4'>
|
||||
<section>
|
||||
<SectionTitle>{t('Base Price')}</SectionTitle>
|
||||
<div className='flex items-baseline justify-between'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
@@ -269,11 +296,11 @@ function PriceSection(props: {
|
||||
</span>
|
||||
<span className='text-foreground font-mono text-sm font-semibold tabular-nums'>
|
||||
{formatFixedPrice(
|
||||
model,
|
||||
props.model,
|
||||
baseGroupKey,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
props.showRechargePrice,
|
||||
props.priceRate,
|
||||
props.usdExchangeRate,
|
||||
baseGroupRatioMap
|
||||
)}
|
||||
</span>
|
||||
@@ -286,13 +313,13 @@ function PriceSection(props: {
|
||||
const renderPrice = (type: PriceType) => (
|
||||
<>
|
||||
{formatGroupPrice(
|
||||
model,
|
||||
props.model,
|
||||
baseGroupKey,
|
||||
type,
|
||||
tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
props.tokenUnit,
|
||||
props.showRechargePrice,
|
||||
props.priceRate,
|
||||
props.usdExchangeRate,
|
||||
baseGroupRatioMap
|
||||
)}
|
||||
<span className='text-muted-foreground/40 ml-1 text-xs font-normal'>
|
||||
@@ -302,11 +329,11 @@ function PriceSection(props: {
|
||||
)
|
||||
|
||||
return (
|
||||
<section className='border-b py-4'>
|
||||
<section>
|
||||
<SectionTitle>{t('Base Price')}</SectionTitle>
|
||||
<div className='grid grid-cols-2 gap-2'>
|
||||
{primaryPriceTypes.map((item) => (
|
||||
<div key={item.type} className='rounded-lg border bg-muted/20 p-3'>
|
||||
<div key={item.type} className='bg-muted/20 rounded-lg border p-3'>
|
||||
<div className='text-muted-foreground text-xs'>{item.label}</div>
|
||||
<div className='text-foreground mt-1 font-mono text-base font-semibold tabular-nums'>
|
||||
{renderPrice(item.type)}
|
||||
@@ -337,29 +364,31 @@ function PriceSection(props: {
|
||||
)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// API endpoints list
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
function EndpointsSection(props: {
|
||||
model: PricingModel
|
||||
endpointMap: Record<string, { path?: string; method?: string }>
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { model, endpointMap } = props
|
||||
|
||||
const endpoints = useMemo(() => {
|
||||
const types = model.supported_endpoint_types || []
|
||||
const types = props.model.supported_endpoint_types || []
|
||||
return types.map((type) => {
|
||||
const info = endpointMap[type] || {}
|
||||
const info = props.endpointMap[type] || {}
|
||||
let path = info.path || ''
|
||||
if (path.includes('{model}')) {
|
||||
path = replaceModelInPath(path, model.model_name || '')
|
||||
path = replaceModelInPath(path, props.model.model_name || '')
|
||||
}
|
||||
return { type, path, method: info.method || 'POST' }
|
||||
})
|
||||
}, [model, endpointMap])
|
||||
}, [props.model, props.endpointMap])
|
||||
|
||||
if (endpoints.length === 0) return null
|
||||
|
||||
return (
|
||||
<section className='border-b py-4'>
|
||||
<section>
|
||||
<SectionTitle>{t('API Endpoints')}</SectionTitle>
|
||||
<div className='space-y-1'>
|
||||
{endpoints.map(({ type, path, method }) => (
|
||||
@@ -384,6 +413,10 @@ function EndpointsSection(props: {
|
||||
)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Auto group chain (used inside group pricing section)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
function AutoGroupChain(props: { model: PricingModel; autoGroups: string[] }) {
|
||||
const { t } = useTranslation()
|
||||
const modelEnableGroups = Array.isArray(props.model.enable_groups)
|
||||
@@ -411,6 +444,10 @@ function AutoGroupChain(props: { model: PricingModel; autoGroups: string[] }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Group pricing table
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
function GroupPricingSection(props: {
|
||||
model: PricingModel
|
||||
groupRatio: Record<string, number>
|
||||
@@ -422,45 +459,39 @@ function GroupPricingSection(props: {
|
||||
showRechargePrice?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
model,
|
||||
groupRatio,
|
||||
usableGroup,
|
||||
autoGroups,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
tokenUnit,
|
||||
showRechargePrice = false,
|
||||
} = props
|
||||
const showRechargePrice = props.showRechargePrice ?? false
|
||||
|
||||
const availableGroups = useMemo(
|
||||
() => getAvailableGroups(model, usableGroup || {}),
|
||||
[model, usableGroup]
|
||||
() => getAvailableGroups(props.model, props.usableGroup || {}),
|
||||
[props.model, props.usableGroup]
|
||||
)
|
||||
|
||||
const isTokenBased = isTokenBasedModel(model)
|
||||
const tokenUnitLabel = tokenUnit === 'K' ? '1K' : '1M'
|
||||
const isTokenBased = isTokenBasedModel(props.model)
|
||||
const tokenUnitLabel = props.tokenUnit === 'K' ? '1K' : '1M'
|
||||
|
||||
const extraPriceTypes = useMemo(() => {
|
||||
const types: { label: string; type: PriceType }[] = []
|
||||
if (model.cache_ratio != null)
|
||||
if (props.model.cache_ratio != null)
|
||||
types.push({ label: t('Cache'), type: 'cache' })
|
||||
if (model.create_cache_ratio != null)
|
||||
if (props.model.create_cache_ratio != null)
|
||||
types.push({ label: t('Cache Write'), type: 'create_cache' })
|
||||
if (model.image_ratio != null)
|
||||
if (props.model.image_ratio != null)
|
||||
types.push({ label: t('Image'), type: 'image' })
|
||||
if (model.audio_ratio != null)
|
||||
if (props.model.audio_ratio != null)
|
||||
types.push({ label: t('Audio In'), type: 'audio_input' })
|
||||
if (model.audio_ratio != null && model.audio_completion_ratio != null)
|
||||
if (
|
||||
props.model.audio_ratio != null &&
|
||||
props.model.audio_completion_ratio != null
|
||||
)
|
||||
types.push({ label: t('Audio Out'), type: 'audio_output' })
|
||||
return types
|
||||
}, [model, t])
|
||||
}, [props.model, t])
|
||||
|
||||
if (availableGroups.length === 0) {
|
||||
return (
|
||||
<section className='py-4'>
|
||||
<section>
|
||||
<SectionTitle>{t('Pricing by Group')}</SectionTitle>
|
||||
<AutoGroupChain model={model} autoGroups={autoGroups} />
|
||||
<AutoGroupChain model={props.model} autoGroups={props.autoGroups} />
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{t(
|
||||
'This model is not available in any group, or no group pricing information is configured.'
|
||||
@@ -473,16 +504,16 @@ function GroupPricingSection(props: {
|
||||
const thClass =
|
||||
'text-muted-foreground py-2 text-[10px] font-medium tracking-wider uppercase'
|
||||
|
||||
if (isDynamicPricingModel(model)) {
|
||||
const dynamicTiers = getDynamicPricingTiers(model)
|
||||
if (isDynamicPricingModel(props.model)) {
|
||||
const dynamicTiers = getDynamicPricingTiers(props.model)
|
||||
|
||||
if (dynamicTiers.length === 0) {
|
||||
return (
|
||||
<section className='py-4'>
|
||||
<section>
|
||||
<SectionTitle>{t('Pricing by Group')}</SectionTitle>
|
||||
<AutoGroupChain model={model} autoGroups={autoGroups} />
|
||||
<AutoGroupChain model={props.model} autoGroups={props.autoGroups} />
|
||||
<div className='rounded-lg border border-amber-200/70 bg-amber-50/70 p-3 dark:border-amber-500/20 dark:bg-amber-500/10'>
|
||||
<div className='text-amber-800 text-sm font-medium dark:text-amber-200'>
|
||||
<div className='text-sm font-medium text-amber-800 dark:text-amber-200'>
|
||||
{t('Special billing expression')}
|
||||
</div>
|
||||
<p className='text-muted-foreground mt-1 text-xs'>
|
||||
@@ -494,8 +525,8 @@ function GroupPricingSection(props: {
|
||||
<div className='text-muted-foreground mb-1 text-[10px] font-medium tracking-wider uppercase'>
|
||||
{t('Raw expression')}
|
||||
</div>
|
||||
<code className='text-muted-foreground block max-h-28 overflow-auto rounded-md border bg-background/80 px-2 py-1.5 font-mono text-xs break-all'>
|
||||
{model.billing_expr}
|
||||
<code className='text-muted-foreground bg-background/80 block max-h-28 overflow-auto rounded-md border px-2 py-1.5 font-mono text-xs break-all'>
|
||||
{props.model.billing_expr}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
@@ -508,10 +539,10 @@ function GroupPricingSection(props: {
|
||||
dynamicTiers
|
||||
.flatMap((tier) =>
|
||||
getDynamicPriceEntries(tier, {
|
||||
tokenUnit,
|
||||
tokenUnit: props.tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
priceRate: props.priceRate,
|
||||
usdExchangeRate: props.usdExchangeRate,
|
||||
groupRatioMultiplier: 1,
|
||||
})
|
||||
)
|
||||
@@ -520,12 +551,12 @@ function GroupPricingSection(props: {
|
||||
)
|
||||
|
||||
return (
|
||||
<section className='py-4'>
|
||||
<section>
|
||||
<SectionTitle>{t('Pricing by Group')}</SectionTitle>
|
||||
<AutoGroupChain model={model} autoGroups={autoGroups} />
|
||||
<AutoGroupChain model={props.model} autoGroups={props.autoGroups} />
|
||||
<div className='space-y-3'>
|
||||
{availableGroups.map((group) => {
|
||||
const ratio = groupRatio[group] || 1
|
||||
const ratio = props.groupRatio[group] || 1
|
||||
return (
|
||||
<div key={group} className='overflow-hidden rounded-lg border'>
|
||||
<div className='bg-muted/20 flex items-center justify-between gap-3 border-b px-3 py-2'>
|
||||
@@ -552,10 +583,10 @@ function GroupPricingSection(props: {
|
||||
<TableBody>
|
||||
{dynamicTiers.map((tier, tierIndex) => {
|
||||
const entries = getDynamicPriceEntries(tier, {
|
||||
tokenUnit,
|
||||
tokenUnit: props.tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
priceRate: props.priceRate,
|
||||
usdExchangeRate: props.usdExchangeRate,
|
||||
groupRatioMultiplier: ratio,
|
||||
})
|
||||
const entryMap = new Map(
|
||||
@@ -587,7 +618,7 @@ function GroupPricingSection(props: {
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<p className='text-muted-foreground/40 mt-1.5 px-4 text-[10px] sm:px-0'>
|
||||
<p className='text-muted-foreground/40 mt-1.5 text-[10px]'>
|
||||
{t('Prices shown per')} {tokenUnitLabel} tokens
|
||||
</p>
|
||||
</div>
|
||||
@@ -596,10 +627,10 @@ function GroupPricingSection(props: {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className='py-4'>
|
||||
<section>
|
||||
<SectionTitle>{t('Pricing by Group')}</SectionTitle>
|
||||
<AutoGroupChain model={model} autoGroups={autoGroups} />
|
||||
<div className='-mx-4 sm:mx-0'>
|
||||
<AutoGroupChain model={props.model} autoGroups={props.autoGroups} />
|
||||
<div className='-mx-4 overflow-x-auto sm:mx-0'>
|
||||
<Table className='text-sm'>
|
||||
<TableHeader>
|
||||
<TableRow className='hover:bg-transparent'>
|
||||
@@ -631,7 +662,7 @@ function GroupPricingSection(props: {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{availableGroups.map((group) => {
|
||||
const ratio = groupRatio[group] || 1
|
||||
const ratio = props.groupRatio[group] || 1
|
||||
return (
|
||||
<TableRow key={group}>
|
||||
<TableCell className='py-2.5'>
|
||||
@@ -644,26 +675,26 @@ function GroupPricingSection(props: {
|
||||
<>
|
||||
<TableCell className='py-2.5 text-right font-mono'>
|
||||
{formatGroupPrice(
|
||||
model,
|
||||
props.model,
|
||||
group,
|
||||
'input',
|
||||
tokenUnit,
|
||||
props.tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
groupRatio
|
||||
props.priceRate,
|
||||
props.usdExchangeRate,
|
||||
props.groupRatio
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className='py-2.5 text-right font-mono'>
|
||||
{formatGroupPrice(
|
||||
model,
|
||||
props.model,
|
||||
group,
|
||||
'output',
|
||||
tokenUnit,
|
||||
props.tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
groupRatio
|
||||
props.priceRate,
|
||||
props.usdExchangeRate,
|
||||
props.groupRatio
|
||||
)}
|
||||
</TableCell>
|
||||
{extraPriceTypes.map((ep) => (
|
||||
@@ -672,14 +703,14 @@ function GroupPricingSection(props: {
|
||||
className='py-2.5 text-right font-mono'
|
||||
>
|
||||
{formatGroupPrice(
|
||||
model,
|
||||
props.model,
|
||||
group,
|
||||
ep.type,
|
||||
tokenUnit,
|
||||
props.tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
groupRatio
|
||||
props.priceRate,
|
||||
props.usdExchangeRate,
|
||||
props.groupRatio
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
@@ -687,12 +718,12 @@ function GroupPricingSection(props: {
|
||||
) : (
|
||||
<TableCell className='py-2.5 text-right font-mono'>
|
||||
{formatFixedPrice(
|
||||
model,
|
||||
props.model,
|
||||
group,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
groupRatio
|
||||
props.priceRate,
|
||||
props.usdExchangeRate,
|
||||
props.groupRatio
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
@@ -711,6 +742,30 @@ function GroupPricingSection(props: {
|
||||
)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Tabbed details content
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
const TAB_VALUES = [
|
||||
'overview',
|
||||
'pricing',
|
||||
'performance',
|
||||
'api',
|
||||
'apps',
|
||||
] as const
|
||||
type TabValue = (typeof TAB_VALUES)[number]
|
||||
|
||||
const TAB_META: Record<
|
||||
TabValue,
|
||||
{ icon: React.ComponentType<{ className?: string }>; labelKey: string }
|
||||
> = {
|
||||
overview: { icon: Info, labelKey: 'Overview' },
|
||||
pricing: { icon: ReceiptText, labelKey: 'Pricing' },
|
||||
performance: { icon: HeartPulse, labelKey: 'Performance' },
|
||||
api: { icon: Code2, labelKey: 'API' },
|
||||
apps: { icon: Rocket, labelKey: 'Apps' },
|
||||
}
|
||||
|
||||
export interface ModelDetailsContentProps {
|
||||
model: PricingModel
|
||||
groupRatio: Record<string, number>
|
||||
@@ -724,52 +779,121 @@ export interface ModelDetailsContentProps {
|
||||
}
|
||||
|
||||
export function ModelDetailsContent(props: ModelDetailsContentProps) {
|
||||
const {
|
||||
model,
|
||||
groupRatio,
|
||||
usableGroup,
|
||||
endpointMap,
|
||||
autoGroups,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
tokenUnit,
|
||||
showRechargePrice = false,
|
||||
} = props
|
||||
const { t } = useTranslation()
|
||||
const showRechargePrice = props.showRechargePrice ?? false
|
||||
const metadata = useMemo(() => inferModelMetadata(props.model), [props.model])
|
||||
const uptimeSeries = useMemo(
|
||||
() => buildUptimeSeries(props.model),
|
||||
[props.model]
|
||||
)
|
||||
|
||||
const isDynamic =
|
||||
props.model.billing_mode === 'tiered_expr' &&
|
||||
Boolean(props.model.billing_expr)
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModelHeader model={model} />
|
||||
<div className='@container/details space-y-4'>
|
||||
<ModelHeader model={props.model} />
|
||||
|
||||
<PriceSection
|
||||
model={model}
|
||||
priceRate={priceRate}
|
||||
usdExchangeRate={usdExchangeRate}
|
||||
tokenUnit={tokenUnit}
|
||||
showRechargePrice={showRechargePrice}
|
||||
/>
|
||||
<ModelDetailsQuickStats metadata={metadata} />
|
||||
|
||||
<EndpointsSection model={model} endpointMap={endpointMap} />
|
||||
<UptimeStatusRow series={uptimeSeries} />
|
||||
|
||||
{model.billing_mode === 'tiered_expr' && model.billing_expr && (
|
||||
<div className='border-b'>
|
||||
<DynamicPricingBreakdown billingExpr={model.billing_expr} />
|
||||
</div>
|
||||
)}
|
||||
<Tabs defaultValue='overview' className='gap-4'>
|
||||
<TabsList className='bg-muted/60 h-auto w-full justify-start gap-1 overflow-x-auto rounded-lg p-1'>
|
||||
{TAB_VALUES.map((value) => {
|
||||
const Icon = TAB_META[value].icon
|
||||
return (
|
||||
<TabsTrigger
|
||||
key={value}
|
||||
value={value}
|
||||
className='h-8 gap-1.5 rounded-md px-3 text-xs sm:text-sm'
|
||||
>
|
||||
<Icon className='size-3.5' />
|
||||
<span>{t(TAB_META[value].labelKey)}</span>
|
||||
</TabsTrigger>
|
||||
)
|
||||
})}
|
||||
</TabsList>
|
||||
|
||||
<GroupPricingSection
|
||||
model={model}
|
||||
groupRatio={groupRatio}
|
||||
usableGroup={usableGroup}
|
||||
autoGroups={autoGroups}
|
||||
priceRate={priceRate}
|
||||
usdExchangeRate={usdExchangeRate}
|
||||
tokenUnit={tokenUnit}
|
||||
showRechargePrice={showRechargePrice}
|
||||
/>
|
||||
</>
|
||||
<TabsContent value='overview' className='space-y-5 outline-none'>
|
||||
<section>
|
||||
<div className='mb-3 flex items-center gap-2'>
|
||||
<Boxes className='text-muted-foreground/70 size-3.5' />
|
||||
<h3 className='text-foreground text-sm font-semibold'>
|
||||
{t('Capabilities')}
|
||||
</h3>
|
||||
</div>
|
||||
<ModelDetailsCapabilities capabilities={metadata.capabilities} />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className='mb-3 flex items-center gap-2'>
|
||||
<h3 className='text-foreground text-sm font-semibold'>
|
||||
{t('Supported modalities')}
|
||||
</h3>
|
||||
</div>
|
||||
<ModalitiesMatrix
|
||||
input={metadata.input_modalities}
|
||||
output={metadata.output_modalities}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<ModelDetailsProviderInfo model={props.model} />
|
||||
|
||||
<PriceSection
|
||||
model={props.model}
|
||||
priceRate={props.priceRate}
|
||||
usdExchangeRate={props.usdExchangeRate}
|
||||
tokenUnit={props.tokenUnit}
|
||||
showRechargePrice={showRechargePrice}
|
||||
/>
|
||||
|
||||
<EndpointsSection
|
||||
model={props.model}
|
||||
endpointMap={props.endpointMap}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='pricing' className='space-y-5 outline-none'>
|
||||
{isDynamic && (
|
||||
<DynamicPricingBreakdown billingExpr={props.model.billing_expr} />
|
||||
)}
|
||||
<GroupPricingSection
|
||||
model={props.model}
|
||||
groupRatio={props.groupRatio}
|
||||
usableGroup={props.usableGroup}
|
||||
autoGroups={props.autoGroups}
|
||||
priceRate={props.priceRate}
|
||||
usdExchangeRate={props.usdExchangeRate}
|
||||
tokenUnit={props.tokenUnit}
|
||||
showRechargePrice={showRechargePrice}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='performance' className='outline-none'>
|
||||
<ModelDetailsPerformance model={props.model} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='api' className='outline-none'>
|
||||
<ModelDetailsApi
|
||||
model={props.model}
|
||||
endpointMap={props.endpointMap}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='apps' className='outline-none'>
|
||||
<ModelDetailsApps model={props.model} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Drawer & page wrappers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
export interface ModelDetailsDrawerProps extends ModelDetailsContentProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
@@ -783,7 +907,7 @@ export function ModelDetailsDrawer(props: ModelDetailsDrawerProps) {
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side='right'
|
||||
className='flex h-dvh w-full overflow-hidden p-0 sm:max-w-2xl xl:max-w-3xl'
|
||||
className='flex h-dvh w-full overflow-hidden p-0 sm:max-w-2xl lg:max-w-3xl xl:max-w-4xl 2xl:max-w-5xl'
|
||||
>
|
||||
<SheetHeader className='sr-only'>
|
||||
<SheetTitle>{props.model.model_name}</SheetTitle>
|
||||
@@ -829,19 +953,21 @@ export function ModelDetails() {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PublicLayout>
|
||||
<div className='mx-auto max-w-2xl px-4 sm:px-6'>
|
||||
<div className='mx-auto max-w-5xl px-4 sm:px-6'>
|
||||
<Skeleton className='mb-4 h-5 w-16' />
|
||||
<div className='space-y-2'>
|
||||
<Skeleton className='h-7 w-64' />
|
||||
<Skeleton className='h-4 w-40' />
|
||||
<Skeleton className='h-4 w-full max-w-md' />
|
||||
</div>
|
||||
<div className='mt-6 grid grid-cols-2 gap-2 sm:grid-cols-4'>
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className='h-16 w-full' />
|
||||
))}
|
||||
</div>
|
||||
<div className='mt-6 space-y-3'>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className='flex justify-between'>
|
||||
<Skeleton className='h-5 w-24' />
|
||||
<Skeleton className='h-5 w-20' />
|
||||
</div>
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className='h-24 w-full' />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -869,7 +995,7 @@ export function ModelDetails() {
|
||||
|
||||
return (
|
||||
<PublicLayout>
|
||||
<div className='mx-auto max-w-2xl px-4 sm:px-6'>
|
||||
<div className='mx-auto max-w-5xl px-4 sm:px-6'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
|
||||
+11
-11
@@ -10,12 +10,12 @@ import {
|
||||
import { DataTableColumnHeader } from '@/components/data-table/column-header'
|
||||
import { GroupBadge } from '@/components/group-badge'
|
||||
import { DEFAULT_TOKEN_UNIT, QUOTA_TYPE_VALUES } from '../constants'
|
||||
import { parseTags } from '../lib/filters'
|
||||
import { isTokenBasedModel } from '../lib/model-helpers'
|
||||
import {
|
||||
getDynamicDisplayGroupRatio,
|
||||
getDynamicPricingSummary,
|
||||
} from '../lib/dynamic-price'
|
||||
import { parseTags } from '../lib/filters'
|
||||
import { isTokenBasedModel } from '../lib/model-helpers'
|
||||
import {
|
||||
formatPrice,
|
||||
formatRequestPrice,
|
||||
@@ -152,14 +152,14 @@ export function usePricingColumns(
|
||||
if (dynamicSummary) {
|
||||
if (dynamicSummary.isSpecialExpression) {
|
||||
return (
|
||||
<div className='min-w-[200px] max-w-[320px]'>
|
||||
<div className='text-amber-700 text-xs font-medium dark:text-amber-300'>
|
||||
<div className='max-w-[320px] min-w-[200px]'>
|
||||
<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 break-all font-mono text-[10px] leading-relaxed'>
|
||||
<code className='text-muted-foreground/70 mt-1 line-clamp-2 block font-mono text-[10px] leading-relaxed break-all'>
|
||||
{dynamicSummary.rawExpression}
|
||||
</code>
|
||||
</div>
|
||||
@@ -371,8 +371,8 @@ export function usePricingColumns(
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>{renderLimitedTags(tags, 2)}</div>
|
||||
<TooltipTrigger render={<div />}>
|
||||
{renderLimitedTags(tags, 2)}
|
||||
</TooltipTrigger>
|
||||
{tags.length > 2 && (
|
||||
<TooltipContent side='top' className='max-w-[280px] p-2'>
|
||||
@@ -401,8 +401,8 @@ export function usePricingColumns(
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>{renderLimitedTags(endpoints, 2)}</div>
|
||||
<TooltipTrigger render={<div />}>
|
||||
{renderLimitedTags(endpoints, 2)}
|
||||
</TooltipTrigger>
|
||||
{endpoints.length > 2 && (
|
||||
<TooltipContent side='top' className='max-w-[280px] p-2'>
|
||||
@@ -431,8 +431,8 @@ export function usePricingColumns(
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>{renderLimitedGroupBadges(groups, 2)}</div>
|
||||
<TooltipTrigger render={<div />}>
|
||||
{renderLimitedGroupBadges(groups, 2)}
|
||||
</TooltipTrigger>
|
||||
{groups.length > 2 && (
|
||||
<TooltipContent side='top' className='max-w-[280px] p-2'>
|
||||
|
||||
+12
-11
@@ -88,7 +88,9 @@ function FilterChip(props: {
|
||||
)}
|
||||
title={props.option.label}
|
||||
>
|
||||
{props.option.icon && <span className='shrink-0'>{props.option.icon}</span>}
|
||||
{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
|
||||
@@ -108,12 +110,15 @@ function FilterChip(props: {
|
||||
|
||||
function FilterSection(props: FilterSectionProps) {
|
||||
return (
|
||||
<Collapsible defaultOpen className='border-border/70 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-semibold'>
|
||||
{props.title}
|
||||
</span>
|
||||
<ChevronDown className='text-muted-foreground size-4 transition-transform group-data-[state=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'>
|
||||
@@ -213,19 +218,15 @@ export function PricingSidebar(props: PricingSidebarProps) {
|
||||
.map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
count: countBy(props.models, (model) =>
|
||||
model.supported_endpoint_types?.includes(value) ?? false
|
||||
count: countBy(
|
||||
props.models,
|
||||
(model) => model.supported_endpoint_types?.includes(value) ?? false
|
||||
),
|
||||
})),
|
||||
]
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
'rounded-xl border p-3',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
|
||||
+18
-26
@@ -1,11 +1,5 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import {
|
||||
ArrowUpDown,
|
||||
Check,
|
||||
Filter,
|
||||
Grid2X2,
|
||||
Table2,
|
||||
} from 'lucide-react'
|
||||
import { ArrowUpDown, Check, Filter, Grid2X2, Table2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -85,7 +79,7 @@ function SegmentedControl(props: {
|
||||
<div
|
||||
role='group'
|
||||
aria-label={props.ariaLabel}
|
||||
className='bg-muted/60 inline-flex h-8 items-center rounded-md border p-0.5'
|
||||
className='bg-muted/60 inline-flex h-8 items-center rounded-lg border p-0.5'
|
||||
>
|
||||
{props.options.map((option) => {
|
||||
const Icon = option.icon
|
||||
@@ -97,10 +91,10 @@ function SegmentedControl(props: {
|
||||
onClick={() => props.onChange(option.value)}
|
||||
aria-pressed={isActive}
|
||||
className={cn(
|
||||
'inline-flex h-full items-center justify-center rounded-[5px] text-xs font-medium transition-all',
|
||||
'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-foreground text-background shadow-sm'
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
@@ -115,7 +109,7 @@ function SegmentedControl(props: {
|
||||
|
||||
return (
|
||||
<Tooltip key={option.value}>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipTrigger render={button}></TooltipTrigger>
|
||||
<TooltipContent side='bottom' className='text-xs'>
|
||||
{option.tooltip}
|
||||
</TooltipContent>
|
||||
@@ -170,9 +164,7 @@ export function PricingToolbar(props: PricingToolbarProps) {
|
||||
<span className='text-foreground font-semibold tabular-nums'>
|
||||
{props.filteredCount.toLocaleString()}
|
||||
</span>
|
||||
<span>
|
||||
{props.filteredCount === 1 ? t('model') : t('models')}
|
||||
</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()}
|
||||
@@ -204,18 +196,18 @@ export function PricingToolbar(props: PricingToolbarProps) {
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-8 gap-1.5 px-3 text-xs'
|
||||
>
|
||||
<ArrowUpDown className='size-3.5' />
|
||||
<span>
|
||||
{sortLabels[props.sortBy as SortOption] || t('Sort')}
|
||||
</span>
|
||||
</Button>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-8 gap-1.5 px-3 text-xs'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ArrowUpDown className='size-3.5' />
|
||||
<span>{sortLabels[props.sortBy as SortOption] || t('Sort')}</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-44'>
|
||||
{Object.entries(sortLabels).map(([value, label]) => (
|
||||
|
||||
+10
-13
@@ -59,20 +59,17 @@ export function useFilters(models: PricingModel[]) {
|
||||
const viewMode = normalizeViewMode(filterState.view)
|
||||
const showRechargePrice = filterState.rechargePrice === true
|
||||
|
||||
const updateFilters = useCallback(
|
||||
(updates: Record<string, unknown>) => {
|
||||
setFilterState((prev) => {
|
||||
const next: Record<string, unknown> = { ...prev, ...updates }
|
||||
for (const key of Object.keys(next)) {
|
||||
if (next[key] === undefined || next[key] === null) {
|
||||
delete next[key]
|
||||
}
|
||||
const updateFilters = useCallback((updates: Record<string, unknown>) => {
|
||||
setFilterState((prev) => {
|
||||
const next: Record<string, unknown> = { ...prev, ...updates }
|
||||
for (const key of Object.keys(next)) {
|
||||
if (next[key] === undefined || next[key] === null) {
|
||||
delete next[key]
|
||||
}
|
||||
return next as FilterState
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
}
|
||||
return next as FilterState
|
||||
})
|
||||
}, [])
|
||||
|
||||
const setSearchInput = useCallback(
|
||||
(v: string) => updateFilters({ search: v || undefined }),
|
||||
|
||||
+18
-14
@@ -18,7 +18,9 @@ import { usePricingData } from './hooks/use-pricing-data'
|
||||
|
||||
export function Pricing() {
|
||||
const { t } = useTranslation()
|
||||
const [selectedModelName, setSelectedModelName] = useState<string | null>(null)
|
||||
const [selectedModelName, setSelectedModelName] = useState<string | null>(
|
||||
null
|
||||
)
|
||||
|
||||
const {
|
||||
models,
|
||||
@@ -61,18 +63,16 @@ export function Pricing() {
|
||||
clearSearch,
|
||||
} = useFilters(models || [])
|
||||
|
||||
const handleModelClick = useCallback(
|
||||
(modelName: string) => {
|
||||
setSelectedModelName(modelName)
|
||||
},
|
||||
[]
|
||||
)
|
||||
const handleModelClick = useCallback((modelName: string) => {
|
||||
setSelectedModelName(modelName)
|
||||
}, [])
|
||||
|
||||
const selectedModel = useMemo(
|
||||
() =>
|
||||
selectedModelName
|
||||
? (models || []).find((model) => model.model_name === selectedModelName) ||
|
||||
null
|
||||
? (models || []).find(
|
||||
(model) => model.model_name === selectedModelName
|
||||
) || null
|
||||
: null,
|
||||
[models, selectedModelName]
|
||||
)
|
||||
@@ -148,8 +148,10 @@ export function Pricing() {
|
||||
'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%)',
|
||||
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'>
|
||||
@@ -174,12 +176,14 @@ export function Pricing() {
|
||||
value={searchInput}
|
||||
onChange={setSearchInput}
|
||||
onClear={clearSearch}
|
||||
placeholder={t('Search model name, provider, endpoint, or tag...')}
|
||||
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)] 2xl:grid-cols-[330px_minmax(0,1fr)]'>
|
||||
<div className='grid gap-4 xl:grid-cols-[330px_minmax(0,1fr)]'>
|
||||
<PricingSidebar
|
||||
quotaTypeFilter={quotaTypeFilter}
|
||||
endpointTypeFilter={endpointTypeFilter}
|
||||
@@ -198,7 +202,7 @@ export function Pricing() {
|
||||
models={models || []}
|
||||
hasActiveFilters={hasActiveFilters}
|
||||
onClearFilters={clearFilters}
|
||||
className='sticky top-20 hidden max-h-[calc(100vh-6rem)] overflow-y-auto xl:block'
|
||||
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'>
|
||||
|
||||
+4
-2
@@ -1,5 +1,6 @@
|
||||
import { formatBillingCurrencyFromUSD } from '@/lib/currency'
|
||||
import { TOKEN_UNIT_DIVISORS } from '../constants'
|
||||
import type { PricingModel, TokenUnit } from '../types'
|
||||
import {
|
||||
BILLING_PRICING_VARS,
|
||||
parseTiersFromExpr,
|
||||
@@ -8,7 +9,6 @@ import {
|
||||
type BillingVar,
|
||||
type ParsedTier,
|
||||
} from './billing-expr'
|
||||
import type { PricingModel, TokenUnit } from '../types'
|
||||
|
||||
type DynamicPriceOptions = {
|
||||
tokenUnit: TokenUnit
|
||||
@@ -98,7 +98,9 @@ export function formatDynamicUnitPrice(
|
||||
|
||||
export function getDynamicPricingTiers(model: PricingModel): ParsedTier[] {
|
||||
if (!isDynamicPricingModel(model)) return []
|
||||
const { billingExpr } = splitBillingExprAndRequestRules(model.billing_expr || '')
|
||||
const { billingExpr } = splitBillingExprAndRequestRules(
|
||||
model.billing_expr || ''
|
||||
)
|
||||
return parseTiersFromExpr(billingExpr)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,3 +7,6 @@ export * from './price'
|
||||
export * from './model-helpers'
|
||||
export * from './billing-expr'
|
||||
export * from './tier-expr'
|
||||
export * from './model-metadata'
|
||||
export * from './mock-stats'
|
||||
export * from './seed'
|
||||
|
||||
+844
@@ -0,0 +1,844 @@
|
||||
import type { PricingModel } from '../types'
|
||||
import {
|
||||
hashStringToSeed,
|
||||
randomInRange,
|
||||
randomIntInRange,
|
||||
seededRandom,
|
||||
} from './seed'
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Mock model statistics
|
||||
// ----------------------------------------------------------------------------
|
||||
//
|
||||
// The backend has not yet implemented latency / uptime / app-ranking data.
|
||||
// These helpers generate plausible, deterministic mock values seeded from
|
||||
// the model name (and optionally the group name) so that:
|
||||
// - Every render of the same model shows the same numbers
|
||||
// - Different models / different groups render visibly distinct values
|
||||
//
|
||||
// When the backend ships real metrics, callers should switch to the
|
||||
// real API and these helpers can be deleted. The shape of the returned
|
||||
// data is designed to mirror what we expect the real endpoints to return.
|
||||
|
||||
export type GroupPerformance = {
|
||||
group: string
|
||||
ttft_p50_ms: number
|
||||
ttft_p95_ms: number
|
||||
ttft_p99_ms: number
|
||||
throughput_tps: number
|
||||
uptime_30d_pct: number
|
||||
/** Number of monitored requests in the last 24h (display only). */
|
||||
request_volume_24h: number
|
||||
}
|
||||
|
||||
export type LatencyTimePoint = {
|
||||
timestamp: string
|
||||
group: string
|
||||
ttft_ms: number
|
||||
}
|
||||
|
||||
export type UptimeDayPoint = {
|
||||
date: string
|
||||
uptime_pct: number
|
||||
incidents: number
|
||||
outage_minutes: number
|
||||
}
|
||||
|
||||
export type AppRanking = {
|
||||
rank: number
|
||||
name: string
|
||||
description: string
|
||||
category: string
|
||||
growth_pct: number
|
||||
monthly_tokens: number
|
||||
url?: string
|
||||
initial: string
|
||||
}
|
||||
|
||||
const APP_TEMPLATES: Array<
|
||||
Omit<AppRanking, 'rank' | 'monthly_tokens' | 'growth_pct' | 'initial'>
|
||||
> = [
|
||||
{
|
||||
name: 'Cline',
|
||||
description: 'Autonomous coding agent inside the IDE',
|
||||
category: 'Coding',
|
||||
url: 'https://cline.bot',
|
||||
},
|
||||
{
|
||||
name: 'Roo Code',
|
||||
description: 'AI agent for VS Code with multi-step planning',
|
||||
category: 'Coding',
|
||||
url: 'https://roocode.com',
|
||||
},
|
||||
{
|
||||
name: 'Open WebUI',
|
||||
description: 'Self-hosted ChatGPT-like web interface',
|
||||
category: 'Chat',
|
||||
url: 'https://openwebui.com',
|
||||
},
|
||||
{
|
||||
name: 'LibreChat',
|
||||
description: 'Open-source chat platform with multi-model support',
|
||||
category: 'Chat',
|
||||
url: 'https://librechat.ai',
|
||||
},
|
||||
{
|
||||
name: 'Lobe Chat',
|
||||
description: 'Modern open-source chat UI with plugins',
|
||||
category: 'Chat',
|
||||
url: 'https://lobehub.com',
|
||||
},
|
||||
{
|
||||
name: 'NextChat',
|
||||
description: 'Cross-platform private ChatGPT client',
|
||||
category: 'Chat',
|
||||
url: 'https://nextchat.dev',
|
||||
},
|
||||
{
|
||||
name: 'Continue',
|
||||
description: 'Open-source AI code assistant for editors',
|
||||
category: 'Coding',
|
||||
url: 'https://continue.dev',
|
||||
},
|
||||
{
|
||||
name: 'Aider',
|
||||
description: 'Pair-programming agent in your terminal',
|
||||
category: 'Coding',
|
||||
url: 'https://aider.chat',
|
||||
},
|
||||
{
|
||||
name: 'Dify',
|
||||
description: 'LLM application development platform',
|
||||
category: 'Platform',
|
||||
url: 'https://dify.ai',
|
||||
},
|
||||
{
|
||||
name: 'FastGPT',
|
||||
description: 'Knowledge base orchestration and chat platform',
|
||||
category: 'Platform',
|
||||
url: 'https://fastgpt.in',
|
||||
},
|
||||
{
|
||||
name: 'Flowise',
|
||||
description: 'Low-code LLM workflow builder',
|
||||
category: 'Platform',
|
||||
url: 'https://flowiseai.com',
|
||||
},
|
||||
{
|
||||
name: 'OpenInterpreter',
|
||||
description: 'Natural-language code execution agent',
|
||||
category: 'Coding',
|
||||
url: 'https://openinterpreter.com',
|
||||
},
|
||||
{
|
||||
name: 'Devika',
|
||||
description: 'Open-source AI software engineer',
|
||||
category: 'Coding',
|
||||
url: 'https://github.com/stitionai/devika',
|
||||
},
|
||||
{
|
||||
name: 'Cherry Studio',
|
||||
description: 'Multi-model desktop chat client',
|
||||
category: 'Chat',
|
||||
url: 'https://cherry-ai.com',
|
||||
},
|
||||
{
|
||||
name: 'AnythingLLM',
|
||||
description: 'Workspaces around your private documents',
|
||||
category: 'Platform',
|
||||
url: 'https://anythingllm.com',
|
||||
},
|
||||
{
|
||||
name: 'OpenHands',
|
||||
description: 'Coding agent with browser-and-code tools',
|
||||
category: 'Coding',
|
||||
url: 'https://docs.all-hands.dev',
|
||||
},
|
||||
{
|
||||
name: 'Cursor',
|
||||
description: 'AI-native code editor',
|
||||
category: 'Coding',
|
||||
url: 'https://cursor.com',
|
||||
},
|
||||
{
|
||||
name: 'Zed',
|
||||
description: 'Multiplayer code editor with AI',
|
||||
category: 'Coding',
|
||||
url: 'https://zed.dev',
|
||||
},
|
||||
{
|
||||
name: 'Notion AI',
|
||||
description: 'Documents and writing assistant',
|
||||
category: 'Productivity',
|
||||
url: 'https://notion.so',
|
||||
},
|
||||
{
|
||||
name: 'Raycast AI',
|
||||
description: 'AI on your macOS launcher',
|
||||
category: 'Productivity',
|
||||
url: 'https://raycast.com',
|
||||
},
|
||||
{
|
||||
name: 'Obsidian Smart Connections',
|
||||
description: 'Connect notes with semantic search',
|
||||
category: 'Productivity',
|
||||
},
|
||||
{
|
||||
name: 'Bolt.new',
|
||||
description: 'Prompt-to-app full-stack builder',
|
||||
category: 'Coding',
|
||||
url: 'https://bolt.new',
|
||||
},
|
||||
{
|
||||
name: 'Pieces',
|
||||
description: 'AI workflow companion for developers',
|
||||
category: 'Productivity',
|
||||
url: 'https://pieces.app',
|
||||
},
|
||||
{
|
||||
name: 'AmazingAI',
|
||||
description: 'Personal AI knowledge assistant',
|
||||
category: 'Productivity',
|
||||
},
|
||||
{
|
||||
name: 'TypingMind',
|
||||
description: 'Better UI for ChatGPT and Claude',
|
||||
category: 'Chat',
|
||||
url: 'https://typingmind.com',
|
||||
},
|
||||
]
|
||||
|
||||
const PROFILE_BY_NAME = (name: string) => {
|
||||
const n = name.toLowerCase()
|
||||
if (/embed|rerank/.test(n)) return 'embedding'
|
||||
if (/image|sora|veo|kling|pika|jimeng|dalle|imagen/.test(n)) return 'image'
|
||||
if (/whisper|tts|voice|audio/.test(n)) return 'audio'
|
||||
if (/o1|o3|o4|reasoning|thinking|deepseek-r/.test(n)) return 'reasoning'
|
||||
if (/flash|haiku|mini|small|nano|fast/.test(n)) return 'fast'
|
||||
if (/gpt-5|opus|ultra|405|70b/.test(n)) return 'large'
|
||||
return 'standard'
|
||||
}
|
||||
|
||||
type ProfileSpec = {
|
||||
ttftRange: [number, number]
|
||||
throughputRange: [number, number]
|
||||
uptimeRange: [number, number]
|
||||
}
|
||||
|
||||
const PROFILE_SPECS: Record<string, ProfileSpec> = {
|
||||
embedding: {
|
||||
ttftRange: [40, 120],
|
||||
throughputRange: [0, 0],
|
||||
uptimeRange: [99.9, 99.99],
|
||||
},
|
||||
image: {
|
||||
ttftRange: [2_500, 12_000],
|
||||
throughputRange: [0, 0],
|
||||
uptimeRange: [98.5, 99.8],
|
||||
},
|
||||
audio: {
|
||||
ttftRange: [180, 600],
|
||||
throughputRange: [0, 0],
|
||||
uptimeRange: [99.5, 99.95],
|
||||
},
|
||||
reasoning: {
|
||||
ttftRange: [1_800, 5_500],
|
||||
throughputRange: [25, 70],
|
||||
uptimeRange: [99.4, 99.95],
|
||||
},
|
||||
fast: {
|
||||
ttftRange: [180, 480],
|
||||
throughputRange: [110, 240],
|
||||
uptimeRange: [99.7, 99.99],
|
||||
},
|
||||
large: {
|
||||
ttftRange: [600, 1_400],
|
||||
throughputRange: [55, 95],
|
||||
uptimeRange: [99.5, 99.95],
|
||||
},
|
||||
standard: {
|
||||
ttftRange: [400, 900],
|
||||
throughputRange: [70, 140],
|
||||
uptimeRange: [99.6, 99.97],
|
||||
},
|
||||
}
|
||||
|
||||
function rangeFromSeed(
|
||||
rand: () => number,
|
||||
[min, max]: [number, number]
|
||||
): number {
|
||||
return randomInRange(rand, min, max)
|
||||
}
|
||||
|
||||
function applyGroupFactor(value: number, factor: number): number {
|
||||
return value * factor
|
||||
}
|
||||
|
||||
function groupFactor(
|
||||
group: string,
|
||||
baseSeed: number
|
||||
): { ttft: number; throughput: number; uptime: number } {
|
||||
const rand = seededRandom(baseSeed ^ hashStringToSeed(group || 'default'))
|
||||
return {
|
||||
ttft: 0.85 + rand() * 0.55,
|
||||
throughput: 0.85 + rand() * 0.4,
|
||||
uptime: 0.997 + rand() * 0.003,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build per-group performance stats for a model. Always returns at least one
|
||||
* row for each enabled group, sorted alphabetically.
|
||||
*/
|
||||
export function buildGroupPerformance(model: PricingModel): GroupPerformance[] {
|
||||
const groups = (model.enable_groups ?? []).filter((g) => g && g !== 'auto')
|
||||
const targets = groups.length > 0 ? groups : ['default']
|
||||
const profile = PROFILE_BY_NAME(model.model_name)
|
||||
const spec = PROFILE_SPECS[profile]
|
||||
const baseSeed = hashStringToSeed(model.model_name)
|
||||
|
||||
return targets
|
||||
.slice()
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.map<GroupPerformance>((group) => {
|
||||
const rand = seededRandom(baseSeed ^ hashStringToSeed(group))
|
||||
const factor = groupFactor(group, baseSeed)
|
||||
const ttftP50 = applyGroupFactor(
|
||||
rangeFromSeed(rand, spec.ttftRange),
|
||||
factor.ttft
|
||||
)
|
||||
const throughput = applyGroupFactor(
|
||||
rangeFromSeed(rand, spec.throughputRange),
|
||||
factor.throughput
|
||||
)
|
||||
const uptimePct = Math.min(
|
||||
99.99,
|
||||
rangeFromSeed(rand, spec.uptimeRange) * factor.uptime
|
||||
)
|
||||
const requestVolume = randomIntInRange(rand, 18_000, 480_000)
|
||||
return {
|
||||
group,
|
||||
ttft_p50_ms: Math.round(ttftP50),
|
||||
ttft_p95_ms: Math.round(ttftP50 * (1.6 + rand() * 0.4)),
|
||||
ttft_p99_ms: Math.round(ttftP50 * (2.4 + rand() * 0.6)),
|
||||
throughput_tps: throughput === 0 ? 0 : Math.round(throughput * 10) / 10,
|
||||
uptime_30d_pct: Math.round(uptimePct * 100) / 100,
|
||||
request_volume_24h: requestVolume,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a 24-hour latency series for each group. Returns one point per hour
|
||||
* (24 buckets), oldest first.
|
||||
*/
|
||||
export function buildLatencyTimeSeries(
|
||||
model: PricingModel
|
||||
): LatencyTimePoint[] {
|
||||
const performances = buildGroupPerformance(model)
|
||||
if (performances.length === 0) return []
|
||||
|
||||
const now = new Date()
|
||||
now.setMinutes(0, 0, 0)
|
||||
const baseSeed = hashStringToSeed(`${model.model_name}:lat`)
|
||||
const points: LatencyTimePoint[] = []
|
||||
|
||||
for (const perf of performances) {
|
||||
const rand = seededRandom(baseSeed ^ hashStringToSeed(perf.group))
|
||||
for (let i = 23; i >= 0; i--) {
|
||||
const ts = new Date(now.getTime() - i * 3_600_000)
|
||||
const noise = 0.7 + rand() * 0.7
|
||||
const trend = 0.85 + Math.sin(i / 3) * 0.1
|
||||
const value = Math.max(50, Math.round(perf.ttft_p50_ms * noise * trend))
|
||||
points.push({
|
||||
timestamp: ts.toISOString(),
|
||||
group: perf.group,
|
||||
ttft_ms: value,
|
||||
})
|
||||
}
|
||||
}
|
||||
return points
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a 30-day uptime series. Returns one point per day, oldest first.
|
||||
*
|
||||
* If `group` is provided the series is anchored on that group's mean uptime,
|
||||
* otherwise it uses the per-model average. Either way the seed is derived
|
||||
* deterministically so re-renders are stable.
|
||||
*/
|
||||
export function buildUptimeSeries(
|
||||
model: PricingModel,
|
||||
group?: string
|
||||
): UptimeDayPoint[] {
|
||||
const performances = buildGroupPerformance(model)
|
||||
if (performances.length === 0) return []
|
||||
|
||||
const target = group ? performances.find((p) => p.group === group) : null
|
||||
const baseUptime = target
|
||||
? target.uptime_30d_pct
|
||||
: performances.reduce((s, p) => s + p.uptime_30d_pct, 0) /
|
||||
performances.length
|
||||
const baseSeed = hashStringToSeed(`${model.model_name}:up:${group ?? '_all'}`)
|
||||
const rand = seededRandom(baseSeed)
|
||||
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const points: UptimeDayPoint[] = []
|
||||
|
||||
for (let i = 29; i >= 0; i--) {
|
||||
const date = new Date(today.getTime() - i * 86_400_000)
|
||||
const isoDate = date.toISOString().slice(0, 10)
|
||||
const incidentChance = rand()
|
||||
const incidents = incidentChance > 0.92 ? 1 : 0
|
||||
const outageMinutes = incidents > 0 ? Math.round(rand() * 30 + 5) : 0
|
||||
const downtimePct = (outageMinutes / 1_440) * 100
|
||||
const dayUptime = Math.max(85, Math.min(100, baseUptime - downtimePct))
|
||||
points.push({
|
||||
date: isoDate,
|
||||
uptime_pct: Math.round(dayUptime * 100) / 100,
|
||||
incidents,
|
||||
outage_minutes: outageMinutes,
|
||||
})
|
||||
}
|
||||
|
||||
return points
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a deterministic top-apps ranking for the model. The first three apps
|
||||
* always come from the same template list; the rest is shuffled by the seed
|
||||
* so different models surface different long tails.
|
||||
*/
|
||||
export function buildAppRankings(
|
||||
model: PricingModel,
|
||||
count = 12
|
||||
): AppRanking[] {
|
||||
const baseSeed = hashStringToSeed(`${model.model_name}:apps`)
|
||||
const rand = seededRandom(baseSeed)
|
||||
const candidates = [...APP_TEMPLATES]
|
||||
// Fisher–Yates shuffle.
|
||||
for (let i = candidates.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rand() * (i + 1))
|
||||
;[candidates[i], candidates[j]] = [candidates[j], candidates[i]]
|
||||
}
|
||||
|
||||
const top = candidates.slice(0, count)
|
||||
const baseTokens = randomInRange(rand, 90_000_000, 320_000_000)
|
||||
|
||||
return top.map((app, idx) => {
|
||||
const decay = Math.pow(0.78, idx)
|
||||
const monthlyTokens = Math.round(baseTokens * decay * (0.85 + rand() * 0.3))
|
||||
const growthPctRaw = randomInRange(rand, -28, 84)
|
||||
const growthPct = Math.round(growthPctRaw * 10) / 10
|
||||
return {
|
||||
rank: idx + 1,
|
||||
name: app.name,
|
||||
description: app.description,
|
||||
category: app.category,
|
||||
url: app.url,
|
||||
growth_pct: growthPct,
|
||||
monthly_tokens: monthlyTokens,
|
||||
initial: app.name.charAt(0).toUpperCase(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Aggregate uptime over the most recent 30 days. */
|
||||
export function aggregateUptime(points: UptimeDayPoint[]): {
|
||||
uptime_pct: number
|
||||
incidents: number
|
||||
outage_minutes: number
|
||||
} {
|
||||
if (points.length === 0) {
|
||||
return { uptime_pct: 0, incidents: 0, outage_minutes: 0 }
|
||||
}
|
||||
const incidents = points.reduce((s, p) => s + p.incidents, 0)
|
||||
const outageMinutes = points.reduce((s, p) => s + p.outage_minutes, 0)
|
||||
const totalMinutes = points.length * 1_440
|
||||
const uptimePct = ((totalMinutes - outageMinutes) / totalMinutes) * 100
|
||||
return {
|
||||
incidents,
|
||||
outage_minutes: outageMinutes,
|
||||
uptime_pct: Math.round(uptimePct * 1000) / 1000,
|
||||
}
|
||||
}
|
||||
|
||||
/** Format throughput for display: "0" → "—". */
|
||||
export function formatThroughput(tps: number): string {
|
||||
if (tps <= 0) return '—'
|
||||
if (tps >= 1_000) return `${(tps / 1_000).toFixed(1)}K t/s`
|
||||
return `${tps.toFixed(tps < 10 ? 2 : 1)} t/s`
|
||||
}
|
||||
|
||||
/** Format latency in ms with proper unit selection. */
|
||||
export function formatLatency(ms: number): string {
|
||||
if (!Number.isFinite(ms) || ms <= 0) return '—'
|
||||
if (ms >= 1_000) return `${(ms / 1_000).toFixed(2)}s`
|
||||
return `${Math.round(ms)}ms`
|
||||
}
|
||||
|
||||
/** Format uptime percentage with 2 decimal places. */
|
||||
export function formatUptimePct(pct: number): string {
|
||||
if (!Number.isFinite(pct)) return '—'
|
||||
return `${pct.toFixed(2)}%`
|
||||
}
|
||||
|
||||
/** Compact integer formatter for token counts in apps tab. */
|
||||
export function formatTokenVolume(n: number): string {
|
||||
if (!Number.isFinite(n) || n <= 0) return '0'
|
||||
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
|
||||
return n.toString()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock supported-parameters & rate-limits & misc API metadata
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SupportedParameter = {
|
||||
name: string
|
||||
type:
|
||||
| 'number'
|
||||
| 'integer'
|
||||
| 'boolean'
|
||||
| 'string'
|
||||
| 'object'
|
||||
| 'array'
|
||||
| 'enum'
|
||||
defaultValue?: string | number | boolean
|
||||
range?: string
|
||||
enumValues?: string[]
|
||||
descriptionKey: string
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
const COMMON_CHAT_PARAMS: SupportedParameter[] = [
|
||||
{
|
||||
name: 'temperature',
|
||||
type: 'number',
|
||||
defaultValue: 1,
|
||||
range: '0 ~ 2',
|
||||
descriptionKey: 'Sampling temperature; lower is more deterministic',
|
||||
},
|
||||
{
|
||||
name: 'top_p',
|
||||
type: 'number',
|
||||
defaultValue: 1,
|
||||
range: '0 ~ 1',
|
||||
descriptionKey: 'Nucleus sampling probability mass',
|
||||
},
|
||||
{
|
||||
name: 'max_tokens',
|
||||
type: 'integer',
|
||||
range: '>= 1',
|
||||
descriptionKey: 'Maximum number of tokens in the response',
|
||||
},
|
||||
{
|
||||
name: 'frequency_penalty',
|
||||
type: 'number',
|
||||
defaultValue: 0,
|
||||
range: '-2 ~ 2',
|
||||
descriptionKey: 'Penalises repetition of frequent tokens',
|
||||
},
|
||||
{
|
||||
name: 'presence_penalty',
|
||||
type: 'number',
|
||||
defaultValue: 0,
|
||||
range: '-2 ~ 2',
|
||||
descriptionKey: 'Encourages introducing new topics',
|
||||
},
|
||||
{
|
||||
name: 'stop',
|
||||
type: 'array',
|
||||
descriptionKey: 'Up to 4 strings that stop generation',
|
||||
},
|
||||
{
|
||||
name: 'seed',
|
||||
type: 'integer',
|
||||
descriptionKey: 'Deterministic sampling seed (best-effort)',
|
||||
},
|
||||
{
|
||||
name: 'n',
|
||||
type: 'integer',
|
||||
defaultValue: 1,
|
||||
range: '>= 1',
|
||||
descriptionKey: 'Number of completions to generate',
|
||||
},
|
||||
{
|
||||
name: 'stream',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
descriptionKey: 'Stream tokens via Server-Sent Events',
|
||||
},
|
||||
{
|
||||
name: 'response_format',
|
||||
type: 'object',
|
||||
descriptionKey: 'Force JSON object or schema-conforming output',
|
||||
},
|
||||
{
|
||||
name: 'tools',
|
||||
type: 'array',
|
||||
descriptionKey: 'Tool / function declarations the model may call',
|
||||
},
|
||||
{
|
||||
name: 'tool_choice',
|
||||
type: 'string',
|
||||
enumValues: ['auto', 'none', 'required'],
|
||||
descriptionKey: 'Tool-choice policy or specific tool name',
|
||||
},
|
||||
{
|
||||
name: 'logprobs',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
descriptionKey: 'Return per-token log probabilities',
|
||||
},
|
||||
{
|
||||
name: 'top_logprobs',
|
||||
type: 'integer',
|
||||
range: '0 ~ 20',
|
||||
descriptionKey: 'Number of top log probabilities returned per token',
|
||||
},
|
||||
{
|
||||
name: 'logit_bias',
|
||||
type: 'object',
|
||||
descriptionKey: 'Per-token logit bias map',
|
||||
},
|
||||
{
|
||||
name: 'user',
|
||||
type: 'string',
|
||||
descriptionKey: 'End-user identifier for abuse monitoring',
|
||||
},
|
||||
]
|
||||
|
||||
const REASONING_PARAMS: SupportedParameter[] = [
|
||||
{
|
||||
name: 'reasoning_effort',
|
||||
type: 'enum',
|
||||
enumValues: ['low', 'medium', 'high'],
|
||||
defaultValue: 'medium',
|
||||
descriptionKey: 'Controls how much the model thinks before answering',
|
||||
},
|
||||
{
|
||||
name: 'max_completion_tokens',
|
||||
type: 'integer',
|
||||
range: '>= 1',
|
||||
descriptionKey: 'Maximum tokens including hidden reasoning tokens',
|
||||
},
|
||||
{
|
||||
name: 'stop',
|
||||
type: 'array',
|
||||
descriptionKey: 'Up to 4 strings that stop generation',
|
||||
},
|
||||
{
|
||||
name: 'seed',
|
||||
type: 'integer',
|
||||
descriptionKey: 'Deterministic sampling seed (best-effort)',
|
||||
},
|
||||
{
|
||||
name: 'stream',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
descriptionKey: 'Stream tokens via Server-Sent Events',
|
||||
},
|
||||
{
|
||||
name: 'response_format',
|
||||
type: 'object',
|
||||
descriptionKey: 'Force JSON object or schema-conforming output',
|
||||
},
|
||||
{
|
||||
name: 'tools',
|
||||
type: 'array',
|
||||
descriptionKey: 'Tool / function declarations the model may call',
|
||||
},
|
||||
{
|
||||
name: 'tool_choice',
|
||||
type: 'string',
|
||||
enumValues: ['auto', 'none', 'required'],
|
||||
descriptionKey: 'Tool-choice policy or specific tool name',
|
||||
},
|
||||
{
|
||||
name: 'user',
|
||||
type: 'string',
|
||||
descriptionKey: 'End-user identifier for abuse monitoring',
|
||||
},
|
||||
]
|
||||
|
||||
const EMBEDDING_PARAMS: SupportedParameter[] = [
|
||||
{
|
||||
name: 'input',
|
||||
type: 'string',
|
||||
required: true,
|
||||
descriptionKey: 'Text or array of texts to embed',
|
||||
},
|
||||
{
|
||||
name: 'dimensions',
|
||||
type: 'integer',
|
||||
range: '>= 1',
|
||||
descriptionKey: 'Truncate embeddings to this many dimensions',
|
||||
},
|
||||
{
|
||||
name: 'encoding_format',
|
||||
type: 'enum',
|
||||
enumValues: ['float', 'base64'],
|
||||
defaultValue: 'float',
|
||||
descriptionKey: 'Wire encoding for the embedding vectors',
|
||||
},
|
||||
{
|
||||
name: 'user',
|
||||
type: 'string',
|
||||
descriptionKey: 'End-user identifier for abuse monitoring',
|
||||
},
|
||||
]
|
||||
|
||||
const IMAGE_PARAMS: SupportedParameter[] = [
|
||||
{
|
||||
name: 'prompt',
|
||||
type: 'string',
|
||||
required: true,
|
||||
descriptionKey: 'Text description of the desired image',
|
||||
},
|
||||
{
|
||||
name: 'size',
|
||||
type: 'enum',
|
||||
enumValues: ['256x256', '512x512', '1024x1024', '1024x1792', '1792x1024'],
|
||||
defaultValue: '1024x1024',
|
||||
descriptionKey: 'Output image size',
|
||||
},
|
||||
{
|
||||
name: 'quality',
|
||||
type: 'enum',
|
||||
enumValues: ['standard', 'hd'],
|
||||
defaultValue: 'standard',
|
||||
descriptionKey: 'Generation quality preset',
|
||||
},
|
||||
{
|
||||
name: 'style',
|
||||
type: 'enum',
|
||||
enumValues: ['vivid', 'natural'],
|
||||
defaultValue: 'vivid',
|
||||
descriptionKey: 'Aesthetic style',
|
||||
},
|
||||
{
|
||||
name: 'n',
|
||||
type: 'integer',
|
||||
defaultValue: 1,
|
||||
range: '1 ~ 10',
|
||||
descriptionKey: 'Number of images to generate',
|
||||
},
|
||||
{
|
||||
name: 'response_format',
|
||||
type: 'enum',
|
||||
enumValues: ['url', 'b64_json'],
|
||||
defaultValue: 'url',
|
||||
descriptionKey: 'How to deliver the resulting image',
|
||||
},
|
||||
]
|
||||
|
||||
const VIDEO_PARAMS: SupportedParameter[] = [
|
||||
{
|
||||
name: 'prompt',
|
||||
type: 'string',
|
||||
required: true,
|
||||
descriptionKey: 'Text description of the desired video',
|
||||
},
|
||||
{
|
||||
name: 'duration',
|
||||
type: 'integer',
|
||||
range: '1 ~ 60',
|
||||
descriptionKey: 'Video length in seconds',
|
||||
},
|
||||
{
|
||||
name: 'aspect_ratio',
|
||||
type: 'enum',
|
||||
enumValues: ['16:9', '9:16', '1:1'],
|
||||
defaultValue: '16:9',
|
||||
descriptionKey: 'Output aspect ratio',
|
||||
},
|
||||
{
|
||||
name: 'fps',
|
||||
type: 'integer',
|
||||
range: '8 ~ 60',
|
||||
defaultValue: 24,
|
||||
descriptionKey: 'Frames per second',
|
||||
},
|
||||
]
|
||||
|
||||
type ApiCategory = 'reasoning' | 'embedding' | 'image' | 'video' | 'chat'
|
||||
|
||||
/**
|
||||
* Refine the broad PROFILE_BY_NAME bucket into an API-shape category. The
|
||||
* `image` bucket from `PROFILE_BY_NAME` lumps still-image and video models
|
||||
* together (because their performance profiles overlap); for the API tab we
|
||||
* need to distinguish them so the request-parameter table is accurate.
|
||||
*/
|
||||
function apiCategoryOf(model: PricingModel): ApiCategory {
|
||||
const profile = PROFILE_BY_NAME(model.model_name)
|
||||
if (profile === 'embedding' || profile === 'reasoning') return profile
|
||||
if (profile === 'image') {
|
||||
return /sora|veo|kling|pika|video|wan-|hunyuanvideo/i.test(model.model_name)
|
||||
? 'video'
|
||||
: 'image'
|
||||
}
|
||||
return 'chat'
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the list of request parameters that the model accepts. The list is
|
||||
* shaped per-modality so reasoning, embedding, image, video and chat models
|
||||
* each show their relevant parameter set.
|
||||
*/
|
||||
export function buildSupportedParameters(
|
||||
model: PricingModel
|
||||
): SupportedParameter[] {
|
||||
const cat = apiCategoryOf(model)
|
||||
if (cat === 'reasoning') return REASONING_PARAMS
|
||||
if (cat === 'embedding') return EMBEDDING_PARAMS
|
||||
if (cat === 'image') return IMAGE_PARAMS
|
||||
if (cat === 'video') return VIDEO_PARAMS
|
||||
return COMMON_CHAT_PARAMS
|
||||
}
|
||||
|
||||
export type RateLimit = {
|
||||
group: string
|
||||
rpm: number
|
||||
tpm: number
|
||||
rpd: number
|
||||
}
|
||||
|
||||
/** Build per-group RPM / TPM / RPD limits for the model. */
|
||||
export function buildRateLimits(model: PricingModel): RateLimit[] {
|
||||
const groups = (model.enable_groups ?? []).filter((g) => g && g !== 'auto')
|
||||
const targets = groups.length > 0 ? groups : ['default']
|
||||
const cat = apiCategoryOf(model)
|
||||
const baseSeed = hashStringToSeed(`${model.model_name}:rl`)
|
||||
const isHeavy = cat === 'image' || cat === 'video'
|
||||
const isLight = cat === 'embedding'
|
||||
const baseRpm = isHeavy ? 60 : isLight ? 5_000 : 500
|
||||
const baseTpm = isHeavy ? 0 : isLight ? 1_000_000 : 200_000
|
||||
const baseRpd = isHeavy ? 1_000 : isLight ? 100_000 : 10_000
|
||||
|
||||
return targets
|
||||
.slice()
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.map((group) => {
|
||||
const rand = seededRandom(baseSeed ^ hashStringToSeed(group))
|
||||
const tier = 0.6 + rand() * 1.4
|
||||
return {
|
||||
group,
|
||||
rpm: Math.round((baseRpm * tier) / 10) * 10,
|
||||
tpm: baseTpm === 0 ? 0 : Math.round((baseTpm * tier) / 1_000) * 1_000,
|
||||
rpd: Math.round((baseRpd * tier) / 100) * 100,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Format an integer rate-limit value compactly. */
|
||||
export function formatRateLimit(value: number): string {
|
||||
if (value <= 0) return '—'
|
||||
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
|
||||
if (value >= 1_000)
|
||||
return `${(value / 1_000).toFixed(value >= 10_000 ? 0 : 1)}K`
|
||||
return value.toLocaleString()
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
import type { Modality, ModelCapability, PricingModel } from '../types'
|
||||
import { hashStringToSeed, seededRandom } from './seed'
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Model metadata inference
|
||||
// ----------------------------------------------------------------------------
|
||||
//
|
||||
// The backend does not currently return `context_length`, `max_output_tokens`,
|
||||
// `knowledge_cutoff`, `release_date`, `parameter_count`, or modality/capability
|
||||
// flags for a model. Until it does, we infer reasonable values client-side
|
||||
// from the data we already have (endpoint types, ratios, tags, model name)
|
||||
// and fall back to a deterministic mock seeded from the model name so that
|
||||
// every render of the same model shows the same numbers.
|
||||
//
|
||||
// When the backend starts returning these fields, callers should prefer the
|
||||
// explicit values on `model.*` and only fall back to the inferred ones.
|
||||
|
||||
const TEXT_INPUT_ENDPOINTS = new Set([
|
||||
'openai',
|
||||
'openai-response',
|
||||
'anthropic',
|
||||
'gemini',
|
||||
'embeddings',
|
||||
'jina-rerank',
|
||||
])
|
||||
|
||||
const IMAGE_OUTPUT_ENDPOINTS = new Set(['image-generation'])
|
||||
const VIDEO_OUTPUT_ENDPOINTS = new Set(['openai-video'])
|
||||
const EMBEDDING_ENDPOINTS = new Set(['embeddings', 'jina-rerank'])
|
||||
|
||||
const REASONING_NAME_PATTERNS = [
|
||||
/^o[1-4](?:[-:_].+)?$/i,
|
||||
/reasoning/i,
|
||||
/thinking/i,
|
||||
/qwq/i,
|
||||
/deepseek-r\d/i,
|
||||
/grok.*-(?:thinking|reasoning)/i,
|
||||
]
|
||||
|
||||
const VISION_NAME_PATTERNS = [
|
||||
/vision/i,
|
||||
/vl(?:[-_]|$)/i,
|
||||
/multimodal/i,
|
||||
/-omni/i,
|
||||
]
|
||||
|
||||
const AUDIO_NAME_PATTERNS = [
|
||||
/audio/i,
|
||||
/whisper/i,
|
||||
/tts/i,
|
||||
/voice/i,
|
||||
/-realtime/i,
|
||||
]
|
||||
|
||||
const VIDEO_NAME_PATTERNS = [/video/i, /sora/i, /veo/i, /kling/i, /pika/i]
|
||||
|
||||
const CODE_NAME_PATTERNS = [/code/i, /-coder/i]
|
||||
|
||||
const WEB_SEARCH_PATTERNS = [/web[-_ ]?search/i, /-online/i, /perplexity/i]
|
||||
|
||||
const KNOWLEDGE_CUTOFFS = [
|
||||
'2023-04',
|
||||
'2023-10',
|
||||
'2023-12',
|
||||
'2024-04',
|
||||
'2024-06',
|
||||
'2024-08',
|
||||
'2024-10',
|
||||
'2024-12',
|
||||
'2025-02',
|
||||
'2025-04',
|
||||
'2025-08',
|
||||
]
|
||||
|
||||
const PARAM_BUCKETS = [
|
||||
'1.5B',
|
||||
'3B',
|
||||
'7B',
|
||||
'8B',
|
||||
'14B',
|
||||
'32B',
|
||||
'70B',
|
||||
'120B',
|
||||
'405B',
|
||||
]
|
||||
|
||||
const CONTEXT_BUCKETS = [
|
||||
8_192, 16_384, 32_768, 65_536, 128_000, 200_000, 1_000_000,
|
||||
]
|
||||
const MAX_OUTPUT_BUCKETS = [2_048, 4_096, 8_192, 16_384, 32_768, 65_536]
|
||||
|
||||
const TAG_TO_CAPABILITY: Record<string, ModelCapability> = {
|
||||
vision: 'vision',
|
||||
multimodal: 'vision',
|
||||
reasoning: 'reasoning',
|
||||
thinking: 'reasoning',
|
||||
tools: 'tools',
|
||||
function: 'function_calling',
|
||||
'function-calling': 'function_calling',
|
||||
streaming: 'streaming',
|
||||
json: 'json_mode',
|
||||
structured: 'structured_output',
|
||||
search: 'web_search',
|
||||
code: 'code_interpreter',
|
||||
embedding: 'embeddings',
|
||||
}
|
||||
|
||||
const TAG_TO_MODALITY: Record<string, Modality> = {
|
||||
text: 'text',
|
||||
image: 'image',
|
||||
audio: 'audio',
|
||||
video: 'video',
|
||||
file: 'file',
|
||||
document: 'file',
|
||||
pdf: 'file',
|
||||
}
|
||||
|
||||
function pickFromBuckets<T>(buckets: T[], rand: () => number): T {
|
||||
return buckets[Math.floor(rand() * buckets.length)]
|
||||
}
|
||||
|
||||
function parseModelTags(tagsString?: string): string[] {
|
||||
if (!tagsString) return []
|
||||
return tagsString
|
||||
.split(/[,;|\s]+/)
|
||||
.map((t) => t.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function nameMatches(name: string, patterns: RegExp[]): boolean {
|
||||
return patterns.some((re) => re.test(name))
|
||||
}
|
||||
|
||||
function inferInputModalities(
|
||||
model: PricingModel,
|
||||
tags: string[],
|
||||
endpoints: string[],
|
||||
name: string
|
||||
): Modality[] {
|
||||
const set = new Set<Modality>()
|
||||
|
||||
if (
|
||||
endpoints.length === 0 ||
|
||||
endpoints.some((e) => TEXT_INPUT_ENDPOINTS.has(e))
|
||||
) {
|
||||
set.add('text')
|
||||
}
|
||||
|
||||
if (model.image_ratio != null || nameMatches(name, VISION_NAME_PATTERNS)) {
|
||||
set.add('image')
|
||||
}
|
||||
if (model.audio_ratio != null || nameMatches(name, AUDIO_NAME_PATTERNS)) {
|
||||
set.add('audio')
|
||||
}
|
||||
if (nameMatches(name, VIDEO_NAME_PATTERNS)) {
|
||||
set.add('video')
|
||||
}
|
||||
|
||||
for (const tag of tags) {
|
||||
const m = TAG_TO_MODALITY[tag]
|
||||
if (m) set.add(m)
|
||||
}
|
||||
|
||||
if (set.size === 0) set.add('text')
|
||||
return ordered(set)
|
||||
}
|
||||
|
||||
function inferOutputModalities(
|
||||
model: PricingModel,
|
||||
endpoints: string[],
|
||||
name: string
|
||||
): Modality[] {
|
||||
const set = new Set<Modality>()
|
||||
|
||||
if (endpoints.some((e) => IMAGE_OUTPUT_ENDPOINTS.has(e))) set.add('image')
|
||||
if (endpoints.some((e) => VIDEO_OUTPUT_ENDPOINTS.has(e))) set.add('video')
|
||||
if (endpoints.some((e) => EMBEDDING_ENDPOINTS.has(e))) set.add('text')
|
||||
|
||||
if (
|
||||
model.audio_completion_ratio != null ||
|
||||
/tts|voice|audio-out/i.test(name)
|
||||
) {
|
||||
set.add('audio')
|
||||
}
|
||||
|
||||
if (set.size === 0) set.add('text')
|
||||
return ordered(set)
|
||||
}
|
||||
|
||||
function inferCapabilities(
|
||||
model: PricingModel,
|
||||
tags: string[],
|
||||
endpoints: string[],
|
||||
name: string,
|
||||
outputs: Modality[],
|
||||
inputs: Modality[]
|
||||
): ModelCapability[] {
|
||||
const set = new Set<ModelCapability>()
|
||||
|
||||
if (outputs.includes('text') && !endpoints.includes('image-generation')) {
|
||||
set.add('streaming')
|
||||
set.add('system_prompt')
|
||||
}
|
||||
if (
|
||||
!endpoints.includes('image-generation') &&
|
||||
!endpoints.includes('embeddings') &&
|
||||
!endpoints.includes('jina-rerank')
|
||||
) {
|
||||
set.add('function_calling')
|
||||
set.add('tools')
|
||||
set.add('json_mode')
|
||||
set.add('structured_output')
|
||||
}
|
||||
if (inputs.includes('image')) set.add('vision')
|
||||
if (model.cache_ratio != null) set.add('caching')
|
||||
if (endpoints.some((e) => EMBEDDING_ENDPOINTS.has(e))) set.add('embeddings')
|
||||
if (nameMatches(name, REASONING_NAME_PATTERNS)) set.add('reasoning')
|
||||
if (nameMatches(name, CODE_NAME_PATTERNS)) set.add('code_interpreter')
|
||||
if (nameMatches(name, WEB_SEARCH_PATTERNS)) set.add('web_search')
|
||||
|
||||
for (const tag of tags) {
|
||||
const cap = TAG_TO_CAPABILITY[tag]
|
||||
if (cap) set.add(cap)
|
||||
}
|
||||
|
||||
return Array.from(set)
|
||||
}
|
||||
|
||||
function ordered(modalities: Set<Modality>): Modality[] {
|
||||
const order: Modality[] = ['text', 'image', 'audio', 'video', 'file']
|
||||
return order.filter((m) => modalities.has(m))
|
||||
}
|
||||
|
||||
function inferContextAndOutputs(
|
||||
name: string,
|
||||
rand: () => number,
|
||||
endpoints: string[]
|
||||
): { context: number; maxOutput: number } {
|
||||
if (endpoints.includes('embeddings') || endpoints.includes('jina-rerank')) {
|
||||
return { context: 8_192, maxOutput: 0 }
|
||||
}
|
||||
if (
|
||||
endpoints.includes('image-generation') ||
|
||||
endpoints.includes('openai-video')
|
||||
) {
|
||||
return { context: 4_096, maxOutput: 0 }
|
||||
}
|
||||
|
||||
const lower = name.toLowerCase()
|
||||
if (lower.includes('1m') || lower.includes('-long')) {
|
||||
return { context: 1_000_000, maxOutput: 65_536 }
|
||||
}
|
||||
if (
|
||||
lower.includes('200k') ||
|
||||
lower.includes('claude-3') ||
|
||||
lower.includes('claude-4')
|
||||
) {
|
||||
return { context: 200_000, maxOutput: 16_384 }
|
||||
}
|
||||
if (lower.includes('128k') || /gpt-4o|gpt-4\.1|gpt-5|o1|o3|o4/.test(lower)) {
|
||||
return { context: 128_000, maxOutput: 16_384 }
|
||||
}
|
||||
if (/gemini.*-2|gemini.*pro|gemini.*flash/.test(lower)) {
|
||||
return { context: 1_000_000, maxOutput: 8_192 }
|
||||
}
|
||||
if (/gpt-3\.5|claude-2/.test(lower)) {
|
||||
return { context: 16_384, maxOutput: 4_096 }
|
||||
}
|
||||
|
||||
const context = pickFromBuckets(CONTEXT_BUCKETS, rand)
|
||||
const maxOutput = Math.min(context, pickFromBuckets(MAX_OUTPUT_BUCKETS, rand))
|
||||
return { context, maxOutput }
|
||||
}
|
||||
|
||||
function inferReleaseAndCutoff(rand: () => number): {
|
||||
release: string
|
||||
cutoff: string
|
||||
} {
|
||||
const cutoff = pickFromBuckets(KNOWLEDGE_CUTOFFS, rand)
|
||||
const [year, month] = cutoff.split('-').map(Number)
|
||||
const offsetMonths = 4 + Math.floor(rand() * 6)
|
||||
const releaseMonth = month + offsetMonths
|
||||
const releaseYear = year + Math.floor((releaseMonth - 1) / 12)
|
||||
const finalMonth = ((releaseMonth - 1) % 12) + 1
|
||||
const release = `${releaseYear}-${String(finalMonth).padStart(2, '0')}-15`
|
||||
return { release, cutoff }
|
||||
}
|
||||
|
||||
export type ModelMetadata = {
|
||||
context_length: number
|
||||
max_output_tokens: number
|
||||
knowledge_cutoff: string
|
||||
release_date: string
|
||||
parameter_count: string
|
||||
input_modalities: Modality[]
|
||||
output_modalities: Modality[]
|
||||
capabilities: ModelCapability[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer / mock model metadata. Prefers explicit fields on `model.*` and
|
||||
* falls back to inference + a deterministic seed otherwise.
|
||||
*/
|
||||
export function inferModelMetadata(model: PricingModel): ModelMetadata {
|
||||
const name = model.model_name || ''
|
||||
const rand = seededRandom(hashStringToSeed(name))
|
||||
const tags = parseModelTags(model.tags)
|
||||
const endpoints = model.supported_endpoint_types || []
|
||||
|
||||
const inputs =
|
||||
model.input_modalities ?? inferInputModalities(model, tags, endpoints, name)
|
||||
const outputs =
|
||||
model.output_modalities ?? inferOutputModalities(model, endpoints, name)
|
||||
const capabilities =
|
||||
model.capabilities ??
|
||||
inferCapabilities(model, tags, endpoints, name, outputs, inputs)
|
||||
|
||||
const fallback = inferContextAndOutputs(name, rand, endpoints)
|
||||
const cutoffAndRelease = inferReleaseAndCutoff(rand)
|
||||
|
||||
return {
|
||||
context_length: model.context_length ?? fallback.context,
|
||||
max_output_tokens: model.max_output_tokens ?? fallback.maxOutput,
|
||||
knowledge_cutoff: model.knowledge_cutoff ?? cutoffAndRelease.cutoff,
|
||||
release_date: model.release_date ?? cutoffAndRelease.release,
|
||||
parameter_count:
|
||||
model.parameter_count ?? pickFromBuckets(PARAM_BUCKETS, rand),
|
||||
input_modalities: inputs,
|
||||
output_modalities: outputs,
|
||||
capabilities,
|
||||
}
|
||||
}
|
||||
|
||||
const TOKEN_FORMAT = new Intl.NumberFormat(undefined, {
|
||||
maximumFractionDigits: 1,
|
||||
})
|
||||
|
||||
/** Format a token count compactly: 128_000 → "128K", 1_000_000 → "1M". */
|
||||
export function formatTokenCount(tokens: number): string {
|
||||
if (!Number.isFinite(tokens) || tokens <= 0) return '—'
|
||||
if (tokens >= 1_000_000) {
|
||||
const value = tokens / 1_000_000
|
||||
return `${TOKEN_FORMAT.format(value)}M`
|
||||
}
|
||||
if (tokens >= 1_000) {
|
||||
const value = tokens / 1_000
|
||||
return `${TOKEN_FORMAT.format(value)}K`
|
||||
}
|
||||
return TOKEN_FORMAT.format(tokens)
|
||||
}
|
||||
|
||||
/** Format a YYYY-MM (or YYYY-MM-DD) date as `Mon YYYY` for display. */
|
||||
export function formatYearMonth(value: string): string {
|
||||
if (!value) return '—'
|
||||
const [yearStr, monthStr] = value.split('-')
|
||||
const year = Number(yearStr)
|
||||
const month = Number(monthStr)
|
||||
if (!Number.isFinite(year) || !Number.isFinite(month)) return value
|
||||
const date = new Date(Date.UTC(year, month - 1, 1))
|
||||
return date.toLocaleString(undefined, { year: 'numeric', month: 'short' })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider / vendor / tokenizer / license inference
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// These helpers derive vendor-style metadata from the model name. They are
|
||||
// purely heuristic and serve only the API-info display until the backend
|
||||
// returns explicit fields.
|
||||
|
||||
export type ModelVendor =
|
||||
| 'openai'
|
||||
| 'anthropic'
|
||||
| 'google'
|
||||
| 'meta'
|
||||
| 'mistral'
|
||||
| 'qwen'
|
||||
| 'deepseek'
|
||||
| 'xai'
|
||||
| 'cohere'
|
||||
| 'baidu'
|
||||
| 'zhipu'
|
||||
| 'moonshot'
|
||||
| 'minimax'
|
||||
| 'tencent'
|
||||
| 'bytedance'
|
||||
| 'midjourney'
|
||||
| 'stability'
|
||||
| 'unknown'
|
||||
|
||||
export type ApiInfo = {
|
||||
vendor: ModelVendor
|
||||
vendor_label: string
|
||||
tokenizer: string
|
||||
tokenizer_note?: string
|
||||
license: string
|
||||
license_kind: 'proprietary' | 'open' | 'open-weight' | 'unknown'
|
||||
data_retention_days: number
|
||||
training_opt_out: boolean
|
||||
homepage?: string
|
||||
}
|
||||
|
||||
const VENDOR_LABELS: Record<ModelVendor, string> = {
|
||||
openai: 'OpenAI',
|
||||
anthropic: 'Anthropic',
|
||||
google: 'Google',
|
||||
meta: 'Meta',
|
||||
mistral: 'Mistral AI',
|
||||
qwen: 'Alibaba (Qwen)',
|
||||
deepseek: 'DeepSeek',
|
||||
xai: 'xAI',
|
||||
cohere: 'Cohere',
|
||||
baidu: 'Baidu',
|
||||
zhipu: 'Zhipu AI',
|
||||
moonshot: 'Moonshot AI',
|
||||
minimax: 'MiniMax',
|
||||
tencent: 'Tencent',
|
||||
bytedance: 'ByteDance',
|
||||
midjourney: 'Midjourney',
|
||||
stability: 'Stability AI',
|
||||
unknown: 'Unknown',
|
||||
}
|
||||
|
||||
function detectVendor(name: string): ModelVendor {
|
||||
const n = name.toLowerCase()
|
||||
if (/^gpt|^o[1-4]|davinci|babbage|whisper|tts|dall.?e|sora|^omni/.test(n))
|
||||
return 'openai'
|
||||
if (/claude/.test(n)) return 'anthropic'
|
||||
if (/gemini|gemma|imagen|veo|palm/.test(n)) return 'google'
|
||||
if (/llama|^codellama/.test(n)) return 'meta'
|
||||
if (/mistral|mixtral|codestral|magistral|pixtral/.test(n)) return 'mistral'
|
||||
if (/qwen|qwq|qvq/.test(n)) return 'qwen'
|
||||
if (/deepseek/.test(n)) return 'deepseek'
|
||||
if (/grok/.test(n)) return 'xai'
|
||||
if (/command|cohere|aya/.test(n)) return 'cohere'
|
||||
if (/ernie|wenxin/.test(n)) return 'baidu'
|
||||
if (/glm|chatglm|cogview|cogvideo/.test(n)) return 'zhipu'
|
||||
if (/kimi|moonshot/.test(n)) return 'moonshot'
|
||||
if (/abab|minimax|hailuo/.test(n)) return 'minimax'
|
||||
if (/hunyuan/.test(n)) return 'tencent'
|
||||
if (/doubao|seed|jimeng/.test(n)) return 'bytedance'
|
||||
if (/midjourney|niji/.test(n)) return 'midjourney'
|
||||
if (/^sd-|stable[-_]?diffusion|sdxl/.test(n)) return 'stability'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
const TOKENIZER_BY_VENDOR: Partial<Record<ModelVendor, string>> = {
|
||||
openai: 'o200k_base',
|
||||
anthropic: 'Anthropic Claude tokenizer',
|
||||
google: 'SentencePiece (Gemini)',
|
||||
meta: 'Llama 3 tokenizer',
|
||||
mistral: 'Mistral tokenizer (BPE)',
|
||||
qwen: 'Qwen tokenizer (tiktoken-compat)',
|
||||
deepseek: 'DeepSeek tokenizer (BPE)',
|
||||
xai: 'Grok tokenizer (BPE)',
|
||||
cohere: 'Cohere tokenizer',
|
||||
baidu: 'Ernie tokenizer',
|
||||
zhipu: 'GLM tokenizer',
|
||||
moonshot: 'Kimi tokenizer',
|
||||
minimax: 'ABAB tokenizer',
|
||||
tencent: 'Hunyuan tokenizer',
|
||||
bytedance: 'Doubao tokenizer',
|
||||
}
|
||||
|
||||
function inferTokenizer(
|
||||
model: PricingModel,
|
||||
vendor: ModelVendor
|
||||
): {
|
||||
tokenizer: string
|
||||
note?: string
|
||||
} {
|
||||
const name = model.model_name.toLowerCase()
|
||||
if (vendor === 'openai') {
|
||||
if (/gpt-3|davinci|babbage|whisper|tts/.test(name)) {
|
||||
return { tokenizer: 'cl100k_base', note: 'Older GPT-3.5 family' }
|
||||
}
|
||||
return { tokenizer: 'o200k_base' }
|
||||
}
|
||||
return { tokenizer: TOKENIZER_BY_VENDOR[vendor] ?? 'BPE (vendor-specific)' }
|
||||
}
|
||||
|
||||
const LICENSE_BY_VENDOR: Record<
|
||||
ModelVendor,
|
||||
{ license: string; kind: ApiInfo['license_kind'] }
|
||||
> = {
|
||||
openai: { license: 'Proprietary (commercial)', kind: 'proprietary' },
|
||||
anthropic: { license: 'Proprietary (commercial)', kind: 'proprietary' },
|
||||
google: { license: 'Proprietary (commercial)', kind: 'proprietary' },
|
||||
meta: { license: 'Llama Community License', kind: 'open-weight' },
|
||||
mistral: { license: 'Apache 2.0 / Commercial', kind: 'open-weight' },
|
||||
qwen: { license: 'Tongyi Qianwen License', kind: 'open-weight' },
|
||||
deepseek: { license: 'DeepSeek License', kind: 'open-weight' },
|
||||
xai: { license: 'Proprietary (commercial)', kind: 'proprietary' },
|
||||
cohere: { license: 'Proprietary (commercial)', kind: 'proprietary' },
|
||||
baidu: { license: 'Proprietary (commercial)', kind: 'proprietary' },
|
||||
zhipu: { license: 'GLM-4 License', kind: 'open-weight' },
|
||||
moonshot: { license: 'Proprietary (commercial)', kind: 'proprietary' },
|
||||
minimax: { license: 'Proprietary (commercial)', kind: 'proprietary' },
|
||||
tencent: { license: 'Hunyuan License', kind: 'open-weight' },
|
||||
bytedance: { license: 'Proprietary (commercial)', kind: 'proprietary' },
|
||||
midjourney: { license: 'Proprietary (commercial)', kind: 'proprietary' },
|
||||
stability: { license: 'Stability AI Community License', kind: 'open-weight' },
|
||||
unknown: { license: 'Provider-specific', kind: 'unknown' },
|
||||
}
|
||||
|
||||
const HOMEPAGE_BY_VENDOR: Partial<Record<ModelVendor, string>> = {
|
||||
openai: 'https://platform.openai.com/docs/models',
|
||||
anthropic: 'https://docs.anthropic.com/claude/docs/models-overview',
|
||||
google: 'https://ai.google.dev/models',
|
||||
meta: 'https://llama.meta.com/',
|
||||
mistral: 'https://docs.mistral.ai/getting-started/models/',
|
||||
qwen: 'https://qwenlm.github.io/',
|
||||
deepseek: 'https://api-docs.deepseek.com/',
|
||||
xai: 'https://x.ai/api',
|
||||
cohere: 'https://docs.cohere.com/docs/models',
|
||||
baidu: 'https://cloud.baidu.com/product/wenxinworkshop',
|
||||
zhipu: 'https://open.bigmodel.cn/dev/api',
|
||||
moonshot: 'https://platform.moonshot.cn/docs',
|
||||
minimax: 'https://platform.minimaxi.com/document/notice',
|
||||
tencent: 'https://cloud.tencent.com/document/product/1729',
|
||||
bytedance: 'https://www.volcengine.com/docs/82379',
|
||||
midjourney: 'https://www.midjourney.com/',
|
||||
stability: 'https://platform.stability.ai/',
|
||||
}
|
||||
|
||||
/**
|
||||
* Build vendor / tokenizer / license / privacy metadata for the model.
|
||||
* Returns deterministic values keyed off the model name so each render is
|
||||
* stable.
|
||||
*/
|
||||
export function inferApiInfo(model: PricingModel): ApiInfo {
|
||||
const vendor = detectVendor(model.model_name || '')
|
||||
const tk = inferTokenizer(model, vendor)
|
||||
const license = LICENSE_BY_VENDOR[vendor]
|
||||
const rand = seededRandom(hashStringToSeed(`${model.model_name}:api`))
|
||||
const retention = vendor === 'openai' ? 30 : Math.round(rand() * 90)
|
||||
return {
|
||||
vendor,
|
||||
vendor_label: VENDOR_LABELS[vendor],
|
||||
tokenizer: tk.tokenizer,
|
||||
tokenizer_note: tk.note,
|
||||
license: license.license,
|
||||
license_kind: license.kind,
|
||||
data_retention_days: retention,
|
||||
training_opt_out: true,
|
||||
homepage: HOMEPAGE_BY_VENDOR[vendor],
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
// ----------------------------------------------------------------------------
|
||||
// Deterministic seeding helpers
|
||||
// ----------------------------------------------------------------------------
|
||||
//
|
||||
// These utilities are used to generate stable, repeatable mock metrics for
|
||||
// model details (latency, throughput, uptime, app rankings) until the
|
||||
// backend ships real values. Seeding the PRNG from the model name (and
|
||||
// optionally the group name) ensures the same model always renders the same
|
||||
// numbers, instead of jittering on every render.
|
||||
|
||||
/** djb2-inspired string hash → non-negative 31-bit integer. */
|
||||
export function hashStringToSeed(input: string): number {
|
||||
let hash = 5381
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
hash = (hash * 33) ^ input.charCodeAt(i)
|
||||
}
|
||||
return Math.abs(hash | 0)
|
||||
}
|
||||
|
||||
/** Linear-congruential generator producing pseudo-random numbers in [0, 1). */
|
||||
export function seededRandom(seed: number): () => number {
|
||||
let state = (seed || 1) >>> 0
|
||||
return () => {
|
||||
state = (state * 1664525 + 1013904223) >>> 0
|
||||
return state / 0x1_0000_0000
|
||||
}
|
||||
}
|
||||
|
||||
/** Pick a number in [min, max] from a seeded PRNG. */
|
||||
export function randomInRange(
|
||||
rand: () => number,
|
||||
min: number,
|
||||
max: number
|
||||
): number {
|
||||
return min + rand() * (max - min)
|
||||
}
|
||||
|
||||
/** Pick an integer in [min, max] (inclusive) from a seeded PRNG. */
|
||||
export function randomIntInRange(
|
||||
rand: () => number,
|
||||
min: number,
|
||||
max: number
|
||||
): number {
|
||||
return Math.floor(randomInRange(rand, min, max + 1))
|
||||
}
|
||||
+32
@@ -37,8 +37,40 @@ export type PricingModel = {
|
||||
billing_expr?: string
|
||||
/** Pricing version returned by backend, useful for cache busting */
|
||||
pricing_version?: string
|
||||
/**
|
||||
* Optional model metadata fields. These are not yet returned by the backend
|
||||
* and are populated client-side from {@link inferModelMetadata}.
|
||||
* When the backend ships these fields, the inference layer becomes a
|
||||
* fallback rather than the source of truth.
|
||||
*/
|
||||
context_length?: number
|
||||
max_output_tokens?: number
|
||||
knowledge_cutoff?: string
|
||||
release_date?: string
|
||||
parameter_count?: string
|
||||
input_modalities?: Modality[]
|
||||
output_modalities?: Modality[]
|
||||
capabilities?: ModelCapability[]
|
||||
}
|
||||
|
||||
/** Input/output modalities supported by a model. */
|
||||
export type Modality = 'text' | 'image' | 'audio' | 'video' | 'file'
|
||||
|
||||
/** Functional capabilities a model exposes. */
|
||||
export type ModelCapability =
|
||||
| 'function_calling'
|
||||
| 'streaming'
|
||||
| 'vision'
|
||||
| 'json_mode'
|
||||
| 'structured_output'
|
||||
| 'reasoning'
|
||||
| 'tools'
|
||||
| 'system_prompt'
|
||||
| 'web_search'
|
||||
| 'code_interpreter'
|
||||
| 'caching'
|
||||
| 'embeddings'
|
||||
|
||||
export type PricingData = {
|
||||
success: boolean
|
||||
message?: string
|
||||
|
||||
+3
-1
@@ -44,7 +44,9 @@ export async function updateUserSettings(
|
||||
/**
|
||||
* Update interface language preference
|
||||
*/
|
||||
export async function updateUserLanguage(language: string): Promise<ApiResponse> {
|
||||
export async function updateUserLanguage(
|
||||
language: string
|
||||
): Promise<ApiResponse> {
|
||||
const res = await api.put('/api/user/self', { language })
|
||||
return res.data
|
||||
}
|
||||
|
||||
@@ -226,7 +226,7 @@ export function CheckinCalendarCard({
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={100}>
|
||||
<TooltipProvider delay={100}>
|
||||
<Dialog
|
||||
open={turnstileModalVisible}
|
||||
onOpenChange={(open) => {
|
||||
@@ -425,7 +425,7 @@ export function CheckinCalendarCard({
|
||||
if (isCheckedIn && dayObj.isCurrentMonth) {
|
||||
return (
|
||||
<Tooltip key={idx}>
|
||||
<TooltipTrigger asChild>{dayButton}</TooltipTrigger>
|
||||
<TooltipTrigger render={dayButton}></TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className='text-xs'>
|
||||
<div className='font-medium'>
|
||||
|
||||
+31
-30
@@ -54,7 +54,8 @@ export function LanguagePreferencesCard(props: LanguagePreferencesCardProps) {
|
||||
setCurrentLanguage(savedLanguage)
|
||||
}, [savedLanguage])
|
||||
|
||||
const handleLanguageChange = async (language: string) => {
|
||||
const handleLanguageChange = async (language: string | null) => {
|
||||
if (!language) return
|
||||
const nextLanguage = normalizeLanguage(language)
|
||||
if (nextLanguage === currentLanguage) return
|
||||
|
||||
@@ -100,37 +101,37 @@ export function LanguagePreferencesCard(props: LanguagePreferencesCardProps) {
|
||||
description={t('Set the language used across the interface')}
|
||||
icon={<Languages className='h-4 w-4' />}
|
||||
>
|
||||
<div className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4'>
|
||||
<div className='space-y-1'>
|
||||
<div className='text-sm font-medium'>{t('Interface Language')}</div>
|
||||
<p className='text-muted-foreground line-clamp-2 text-xs sm:text-sm'>
|
||||
{t(
|
||||
'Language preferences sync across your signed-in devices and affect API error messages.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex items-center gap-2 sm:min-w-48'>
|
||||
<Select
|
||||
value={currentLanguage}
|
||||
onValueChange={handleLanguageChange}
|
||||
disabled={saving}
|
||||
>
|
||||
<SelectTrigger className='w-full sm:w-48'>
|
||||
<SelectValue placeholder={t('Select language')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LANGUAGE_OPTIONS.map((language) => (
|
||||
<SelectItem key={language.value} value={language.value}>
|
||||
{language.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{saving && (
|
||||
<Loader2 className='text-muted-foreground size-4 animate-spin' />
|
||||
<div className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4'>
|
||||
<div className='space-y-1'>
|
||||
<div className='text-sm font-medium'>{t('Interface Language')}</div>
|
||||
<p className='text-muted-foreground line-clamp-2 text-xs sm:text-sm'>
|
||||
{t(
|
||||
'Language preferences sync across your signed-in devices and affect API error messages.'
|
||||
)}
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex items-center gap-2 sm:min-w-48'>
|
||||
<Select
|
||||
value={currentLanguage}
|
||||
onValueChange={handleLanguageChange}
|
||||
disabled={saving}
|
||||
>
|
||||
<SelectTrigger className='w-full sm:w-48'>
|
||||
<SelectValue placeholder={t('Select language')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LANGUAGE_OPTIONS.map((language) => (
|
||||
<SelectItem key={language.value} value={language.value}>
|
||||
{language.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{saving && (
|
||||
<Loader2 className='text-muted-foreground size-4 animate-spin' />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TitledCard>
|
||||
)
|
||||
}
|
||||
|
||||
+47
-45
@@ -204,43 +204,43 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) {
|
||||
<div className='space-y-6'>
|
||||
<div className='flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between xl:flex-col 2xl:flex-row'>
|
||||
<div className='flex items-start gap-4'>
|
||||
<div className='bg-muted rounded-md p-2'>
|
||||
<KeyRound className='h-5 w-5' />
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<p className='font-medium'>{t('Passkey Authentication')}</p>
|
||||
<StatusBadge
|
||||
label={enabled ? t('Enabled') : t('Disabled')}
|
||||
variant={enabled ? 'success' : 'neutral'}
|
||||
showDot
|
||||
copyable={false}
|
||||
/>
|
||||
{status?.backup_eligible !== undefined && (
|
||||
<div className='bg-muted rounded-md p-2'>
|
||||
<KeyRound className='h-5 w-5' />
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<p className='font-medium'>{t('Passkey Authentication')}</p>
|
||||
<StatusBadge
|
||||
label={
|
||||
status.backup_eligible
|
||||
? status.backup_state
|
||||
? t('Backed up')
|
||||
: t('Not backed up')
|
||||
: t('No backup')
|
||||
}
|
||||
variant={
|
||||
status.backup_eligible
|
||||
? status.backup_state
|
||||
? 'success'
|
||||
: 'warning'
|
||||
: 'neutral'
|
||||
}
|
||||
label={enabled ? t('Enabled') : t('Disabled')}
|
||||
variant={enabled ? 'success' : 'neutral'}
|
||||
showDot
|
||||
copyable={false}
|
||||
/>
|
||||
)}
|
||||
{status?.backup_eligible !== undefined && (
|
||||
<StatusBadge
|
||||
label={
|
||||
status.backup_eligible
|
||||
? status.backup_state
|
||||
? t('Backed up')
|
||||
: t('Not backed up')
|
||||
: t('No backup')
|
||||
}
|
||||
variant={
|
||||
status.backup_eligible
|
||||
? status.backup_state
|
||||
? 'success'
|
||||
: 'warning'
|
||||
: 'neutral'
|
||||
}
|
||||
showDot
|
||||
copyable={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{t('Last used:')} {formattedLastUsed}
|
||||
</p>
|
||||
</div>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{t('Last used:')} {formattedLastUsed}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!enabled && (
|
||||
@@ -260,19 +260,21 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) {
|
||||
{enabled && (
|
||||
<div className='flex flex-col gap-3 border-t pt-6 sm:flex-row xl:flex-col 2xl:flex-row'>
|
||||
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant='destructive'
|
||||
className='flex-1'
|
||||
disabled={removing}
|
||||
>
|
||||
{removing ? (
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
) : (
|
||||
<AlertTriangle className='mr-2 h-4 w-4' />
|
||||
)}
|
||||
{t('Remove Passkey')}
|
||||
</Button>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='destructive'
|
||||
className='flex-1'
|
||||
disabled={removing}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{removing ? (
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
) : (
|
||||
<AlertTriangle className='mr-2 h-4 w-4' />
|
||||
)}
|
||||
{t('Remove Passkey')}
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { Shield, Key, Trash2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDialogs } from '@/hooks/use-dialog'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
} from '@/components/ui/card'
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { TitledCard } from '@/components/ui/titled-card'
|
||||
import type { UserProfile } from '../types'
|
||||
@@ -80,36 +76,36 @@ export function ProfileSecurityCard({
|
||||
description={t('Manage your security settings and account access')}
|
||||
icon={<Shield className='h-4 w-4' />}
|
||||
>
|
||||
<div className='grid grid-cols-1 gap-2.5 sm:gap-3 md:grid-cols-3'>
|
||||
{securityActions.map((item) => (
|
||||
<button
|
||||
key={item.title}
|
||||
type='button'
|
||||
onClick={item.action}
|
||||
className={`hover:bg-muted/50 flex items-center gap-3 rounded-lg border p-3 text-left transition-colors md:flex-col md:gap-2 md:p-4 md:text-center ${
|
||||
<div className='grid grid-cols-1 gap-2.5 sm:gap-3 md:grid-cols-3'>
|
||||
{securityActions.map((item) => (
|
||||
<button
|
||||
key={item.title}
|
||||
type='button'
|
||||
onClick={item.action}
|
||||
className={`hover:bg-muted/50 flex items-center gap-3 rounded-lg border p-3 text-left transition-colors md:flex-col md:gap-2 md:p-4 md:text-center ${
|
||||
item.variant === 'destructive'
|
||||
? 'border-destructive/30 hover:border-destructive/50 hover:bg-destructive/5'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`rounded-md p-2 ${
|
||||
item.variant === 'destructive'
|
||||
? 'border-destructive/30 hover:border-destructive/50 hover:bg-destructive/5'
|
||||
: ''
|
||||
? 'bg-destructive/10 text-destructive'
|
||||
: 'bg-muted'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`rounded-md p-2 ${
|
||||
item.variant === 'destructive'
|
||||
? 'bg-destructive/10 text-destructive'
|
||||
: 'bg-muted'
|
||||
}`}
|
||||
>
|
||||
<item.icon className='h-5 w-5' />
|
||||
</div>
|
||||
<div className='min-w-0 md:contents'>
|
||||
<p className='text-sm font-medium'>{item.title}</p>
|
||||
<p className='text-muted-foreground line-clamp-1 text-xs md:line-clamp-none'>
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<item.icon className='h-5 w-5' />
|
||||
</div>
|
||||
<div className='min-w-0 md:contents'>
|
||||
<p className='text-sm font-medium'>{item.title}</p>
|
||||
<p className='text-muted-foreground line-clamp-1 text-xs md:line-clamp-none'>
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</TitledCard>
|
||||
|
||||
{/* Dialogs */}
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { Link2, Settings } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
} from '@/components/ui/card'
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { TitledCard } from '@/components/ui/titled-card'
|
||||
@@ -54,36 +50,36 @@ export function ProfileSettingsCard({
|
||||
description={t('Configure your account preferences and integrations')}
|
||||
icon={<Settings className='h-4 w-4' />}
|
||||
>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className='grid h-auto w-full grid-cols-2 gap-1 rounded-xl p-1'>
|
||||
<TabsTrigger
|
||||
value='bindings'
|
||||
className='h-auto gap-2 rounded-lg px-3 py-2'
|
||||
>
|
||||
<Link2 className='h-4 w-4' />
|
||||
<span className='hidden sm:inline'>{t('Account Bindings')}</span>
|
||||
<span className='sm:hidden'>{t('Bindings')}</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value='settings'
|
||||
className='h-auto gap-2 rounded-lg px-3 py-2'
|
||||
>
|
||||
<Settings className='h-4 w-4' />
|
||||
<span className='hidden sm:inline'>
|
||||
{t('Settings & Preferences')}
|
||||
</span>
|
||||
<span className='sm:hidden'>{t('Settings')}</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className='grid h-auto w-full grid-cols-2 gap-1 rounded-xl p-1'>
|
||||
<TabsTrigger
|
||||
value='bindings'
|
||||
className='h-auto gap-2 rounded-lg px-3 py-2'
|
||||
>
|
||||
<Link2 className='h-4 w-4' />
|
||||
<span className='hidden sm:inline'>{t('Account Bindings')}</span>
|
||||
<span className='sm:hidden'>{t('Bindings')}</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value='settings'
|
||||
className='h-auto gap-2 rounded-lg px-3 py-2'
|
||||
>
|
||||
<Settings className='h-4 w-4' />
|
||||
<span className='hidden sm:inline'>
|
||||
{t('Settings & Preferences')}
|
||||
</span>
|
||||
<span className='sm:hidden'>{t('Settings')}</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='bindings' className='mt-4 sm:mt-6'>
|
||||
<AccountBindingsTab profile={profile} onUpdate={onProfileUpdate} />
|
||||
</TabsContent>
|
||||
<TabsContent value='bindings' className='mt-4 sm:mt-6'>
|
||||
<AccountBindingsTab profile={profile} onUpdate={onProfileUpdate} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='settings' className='mt-4 sm:mt-6'>
|
||||
<NotificationTab profile={profile} onUpdate={onProfileUpdate} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<TabsContent value='settings' className='mt-4 sm:mt-6'>
|
||||
<NotificationTab profile={profile} onUpdate={onProfileUpdate} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</TitledCard>
|
||||
)
|
||||
}
|
||||
|
||||
+38
-41
@@ -1,6 +1,6 @@
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import { useStatus } from '@/hooks/use-status'
|
||||
import { AppHeader, Main } from '@/components/layout'
|
||||
import { Main } from '@/components/layout'
|
||||
import {
|
||||
CardStaggerContainer,
|
||||
CardStaggerItem,
|
||||
@@ -28,47 +28,44 @@ export function Profile() {
|
||||
const canConfigureSidebar = permissions?.sidebar_settings !== false
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppHeader />
|
||||
<Main>
|
||||
<div className='min-h-0 flex-1 overflow-auto px-3 py-3 sm:px-4 sm:py-6'>
|
||||
<CardStaggerContainer className='mx-auto flex w-full max-w-7xl flex-col gap-4 sm:gap-6'>
|
||||
<CardStaggerItem>
|
||||
<ProfileHeader profile={profile} loading={loading} />
|
||||
</CardStaggerItem>
|
||||
<Main>
|
||||
<div className='min-h-0 flex-1 overflow-auto px-3 py-3 sm:px-4 sm:py-6'>
|
||||
<CardStaggerContainer className='mx-auto flex w-full max-w-7xl flex-col gap-4 sm:gap-6'>
|
||||
<CardStaggerItem>
|
||||
<ProfileHeader profile={profile} loading={loading} />
|
||||
</CardStaggerItem>
|
||||
|
||||
<CardStaggerItem>
|
||||
<div className='grid gap-4 sm:gap-5 xl:grid-cols-[minmax(0,1fr)_minmax(360px,0.46fr)] xl:items-start'>
|
||||
<div className='space-y-4 sm:space-y-6'>
|
||||
<ProfileSettingsCard
|
||||
profile={profile}
|
||||
loading={loading}
|
||||
onProfileUpdate={refreshProfile}
|
||||
/>
|
||||
<LanguagePreferencesCard
|
||||
profile={profile}
|
||||
onProfileUpdate={refreshProfile}
|
||||
/>
|
||||
<ProfileSecurityCard profile={profile} loading={loading} />
|
||||
</div>
|
||||
|
||||
<div className='space-y-4 sm:space-y-6 xl:sticky xl:top-6'>
|
||||
{checkinEnabled && (
|
||||
<CheckinCalendarCard
|
||||
checkinEnabled={checkinEnabled}
|
||||
turnstileEnabled={turnstileEnabled}
|
||||
turnstileSiteKey={turnstileSiteKey}
|
||||
/>
|
||||
)}
|
||||
{canConfigureSidebar && <SidebarModulesCard />}
|
||||
<PasskeyCard loading={loading} />
|
||||
<TwoFACard loading={loading} />
|
||||
</div>
|
||||
<CardStaggerItem>
|
||||
<div className='grid gap-4 sm:gap-5 xl:grid-cols-[minmax(0,1fr)_minmax(360px,0.46fr)] xl:items-start'>
|
||||
<div className='space-y-4 sm:space-y-6'>
|
||||
<ProfileSettingsCard
|
||||
profile={profile}
|
||||
loading={loading}
|
||||
onProfileUpdate={refreshProfile}
|
||||
/>
|
||||
<LanguagePreferencesCard
|
||||
profile={profile}
|
||||
onProfileUpdate={refreshProfile}
|
||||
/>
|
||||
<ProfileSecurityCard profile={profile} loading={loading} />
|
||||
</div>
|
||||
</CardStaggerItem>
|
||||
</CardStaggerContainer>
|
||||
</div>
|
||||
</Main>
|
||||
</>
|
||||
|
||||
<div className='space-y-4 sm:space-y-6 xl:sticky xl:top-6'>
|
||||
{checkinEnabled && (
|
||||
<CheckinCalendarCard
|
||||
checkinEnabled={checkinEnabled}
|
||||
turnstileEnabled={turnstileEnabled}
|
||||
turnstileSiteKey={turnstileSiteKey}
|
||||
/>
|
||||
)}
|
||||
{canConfigureSidebar && <SidebarModulesCard />}
|
||||
<PasskeyCard loading={loading} />
|
||||
<TwoFACard loading={loading} />
|
||||
</div>
|
||||
</div>
|
||||
</CardStaggerItem>
|
||||
</CardStaggerContainer>
|
||||
</div>
|
||||
</Main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { ExternalLink, Rocket } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { formatTokens } from '../lib/format'
|
||||
import type { AppListing } from '../types'
|
||||
import { GrowthText } from './growth-text'
|
||||
|
||||
type AppsSectionProps = {
|
||||
rows: AppListing[]
|
||||
}
|
||||
|
||||
/**
|
||||
* "Top Apps" card — clean two-column listing of the apps consuming the
|
||||
* most tokens through new-api in the active period. Apps don't get a
|
||||
* dedicated chart (each app has too much variance to plot meaningfully);
|
||||
* instead we keep the focus on the leaderboard itself.
|
||||
*/
|
||||
export function AppsSection(props: AppsSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const half = Math.ceil(props.rows.length / 2)
|
||||
const left = props.rows.slice(0, half)
|
||||
const right = props.rows.slice(half)
|
||||
|
||||
return (
|
||||
<section className='bg-card overflow-hidden rounded-lg border'>
|
||||
<header className='px-5 py-4'>
|
||||
<h2 className='text-foreground inline-flex items-center gap-2 text-base font-semibold'>
|
||||
<Rocket className='text-primary size-4' />
|
||||
{t('Top Apps')}
|
||||
</h2>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
{t('Apps using the most tokens through new-api')}
|
||||
</p>
|
||||
</header>
|
||||
{props.rows.length === 0 ? (
|
||||
<div className='text-muted-foreground/80 border-t px-5 py-8 text-center text-sm'>
|
||||
{t('No apps match the selected filters')}
|
||||
</div>
|
||||
) : (
|
||||
<div className='grid grid-cols-1 gap-x-8 border-t px-5 pt-3 pb-4 md:grid-cols-2'>
|
||||
<AppList rows={left} />
|
||||
{right.length > 0 && <AppList rows={right} />}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function AppList(props: { rows: AppListing[] }) {
|
||||
return (
|
||||
<ul>
|
||||
{props.rows.map((row) => (
|
||||
<li key={row.name} className='flex items-center gap-3 py-2.5'>
|
||||
<span className='text-muted-foreground/80 w-6 shrink-0 text-right font-mono text-xs tabular-nums'>
|
||||
{row.rank}.
|
||||
</span>
|
||||
<span className='bg-muted text-muted-foreground inline-flex size-9 shrink-0 items-center justify-center rounded-md text-sm font-bold uppercase'>
|
||||
{row.initial}
|
||||
</span>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='flex items-center gap-2 text-sm font-semibold'>
|
||||
{row.url ? (
|
||||
<a
|
||||
href={row.url}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-foreground hover:text-primary inline-flex items-center gap-1 truncate transition-colors'
|
||||
>
|
||||
<span className='truncate'>{row.name}</span>
|
||||
<ExternalLink className='text-muted-foreground/60 size-3 shrink-0' />
|
||||
</a>
|
||||
) : (
|
||||
<span className='text-foreground truncate'>{row.name}</span>
|
||||
)}
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='h-4 shrink-0 rounded-sm px-1 text-[10px] font-normal'
|
||||
>
|
||||
{row.category}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className='text-muted-foreground/80 truncate text-xs'>
|
||||
{row.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className='shrink-0 text-right'>
|
||||
<div className='text-foreground font-mono text-sm font-semibold tabular-nums'>
|
||||
{formatTokens(row.total_tokens)}
|
||||
</div>
|
||||
<GrowthText value={row.growth_pct} className='text-[11px]' />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { useMemo } from 'react'
|
||||
import { VChart } from '@visactor/react-vchart'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useChartTheme } from '@/lib/use-chart-theme'
|
||||
import { VCHART_OPTION } from '@/lib/vchart'
|
||||
import { formatTokens } from '../lib/format'
|
||||
import type { CategorySection as CategorySectionData } from '../types'
|
||||
import { ModelLeaderboard } from './model-leaderboard'
|
||||
|
||||
const TOOLTIP_MAX_ROWS = 8
|
||||
const MAX_LEADERBOARD_ROWS = 8
|
||||
|
||||
type CategorySectionProps = {
|
||||
section: CategorySectionData
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-category ranking unit: a compact stacked-bar chart of token usage
|
||||
* over time paired with a 2-column leaderboard of the top models in that
|
||||
* category. Renders as a self-contained card; the rankings page stacks
|
||||
* one of these per category for quick browsing.
|
||||
*/
|
||||
export function CategorySection(props: CategorySectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const { resolvedTheme, themeReady } = useChartTheme()
|
||||
|
||||
const orderedPoints = useMemo(() => {
|
||||
const order = new Map(
|
||||
props.section.models_history.models.map(
|
||||
(m, idx) => [m.name, idx] as const
|
||||
)
|
||||
)
|
||||
return [...props.section.models_history.points].sort((a, b) => {
|
||||
const tsCmp = a.ts.localeCompare(b.ts)
|
||||
if (tsCmp !== 0) return tsCmp
|
||||
return (order.get(a.model) ?? 999) - (order.get(b.model) ?? 999)
|
||||
})
|
||||
}, [props.section.models_history])
|
||||
|
||||
const spec = useMemo(() => {
|
||||
if (orderedPoints.length === 0) return null
|
||||
return {
|
||||
type: 'bar' as const,
|
||||
data: [{ id: 'category-history', values: orderedPoints }],
|
||||
xField: 'label',
|
||||
yField: 'tokens',
|
||||
seriesField: 'model',
|
||||
stack: true,
|
||||
bar: { style: { cornerRadius: 1 } },
|
||||
legends: { visible: false },
|
||||
axes: [
|
||||
{
|
||||
orient: 'bottom',
|
||||
label: {
|
||||
style: { fill: 'currentColor', fontSize: 9 },
|
||||
autoHide: true,
|
||||
autoLimit: true,
|
||||
},
|
||||
tick: { visible: false },
|
||||
},
|
||||
{
|
||||
orient: 'left',
|
||||
label: {
|
||||
formatMethod: (val: number | string) => formatTokens(Number(val)),
|
||||
style: { fill: 'currentColor', fontSize: 9 },
|
||||
},
|
||||
grid: { visible: true, style: { lineDash: [3, 3] } },
|
||||
},
|
||||
],
|
||||
tooltip: {
|
||||
mark: {
|
||||
content: [
|
||||
{
|
||||
key: (datum: Record<string, unknown>) =>
|
||||
String(datum?.model ?? ''),
|
||||
value: (datum: Record<string, unknown>) =>
|
||||
formatTokens(Number(datum?.tokens) || 0),
|
||||
},
|
||||
],
|
||||
},
|
||||
dimension: {
|
||||
title: {
|
||||
value: (datum: Record<string, unknown>) =>
|
||||
String(datum?.label ?? ''),
|
||||
},
|
||||
content: [
|
||||
{
|
||||
key: (datum: Record<string, unknown>) =>
|
||||
String(datum?.model ?? ''),
|
||||
value: (datum: Record<string, unknown>) =>
|
||||
Number(datum?.tokens) || 0,
|
||||
},
|
||||
],
|
||||
updateContent: (
|
||||
array: Array<{ key: string; value: string | number }>
|
||||
) => {
|
||||
array.sort((a, b) => Number(b.value) - Number(a.value))
|
||||
const visible = array.slice(0, TOOLTIP_MAX_ROWS)
|
||||
return visible.map((item) => ({
|
||||
key: item.key,
|
||||
value: formatTokens(Number(item.value) || 0),
|
||||
}))
|
||||
},
|
||||
},
|
||||
},
|
||||
animationAppear: { duration: 400 },
|
||||
}
|
||||
}, [orderedPoints])
|
||||
|
||||
return (
|
||||
<article
|
||||
id={`category-${props.section.category}`}
|
||||
className='bg-card scroll-mt-20 overflow-hidden rounded-lg border'
|
||||
>
|
||||
<header className='flex items-start justify-between gap-4 px-5 py-3.5'>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<h3 className='text-foreground text-base font-semibold'>
|
||||
{t(props.section.label)}
|
||||
</h3>
|
||||
<p className='text-muted-foreground/80 mt-0.5 truncate text-xs'>
|
||||
{t(props.section.description)}
|
||||
</p>
|
||||
</div>
|
||||
<div className='shrink-0 text-right'>
|
||||
<div className='text-foreground font-mono text-base font-semibold tabular-nums'>
|
||||
{formatTokens(props.section.total_tokens)}
|
||||
</div>
|
||||
<div className='text-muted-foreground/80 text-[10px] tracking-widest uppercase'>
|
||||
{t('tokens')}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className='px-5 pb-4'>
|
||||
<div className='h-44 sm:h-48'>
|
||||
{themeReady && spec ? (
|
||||
<VChart
|
||||
key={`category-history-${props.section.category}-${resolvedTheme}`}
|
||||
spec={{
|
||||
...spec,
|
||||
theme: resolvedTheme === 'dark' ? 'dark' : 'light',
|
||||
background: 'transparent',
|
||||
}}
|
||||
option={VCHART_OPTION}
|
||||
/>
|
||||
) : (
|
||||
<div className='text-muted-foreground/80 flex h-full items-center justify-center text-xs'>
|
||||
{t('No history data available')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{props.section.models.length === 0 ? (
|
||||
<div className='text-muted-foreground/80 border-t px-5 py-6 text-center text-sm'>
|
||||
{t('No models match the selected filters')}
|
||||
</div>
|
||||
) : (
|
||||
<div className='border-t px-5 pt-2 pb-4'>
|
||||
<ModelLeaderboard
|
||||
rows={props.section.models}
|
||||
limit={MAX_LEADERBOARD_ROWS}
|
||||
variant='compact'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
type CategorySectionsProps = {
|
||||
sections: CategorySectionData[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the per-category rankings strip (one card per category).
|
||||
* Includes a strip header so users understand the page structure shifts
|
||||
* from the global view to category drill-downs.
|
||||
*/
|
||||
export function CategorySections(props: CategorySectionsProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (props.sections.length === 0) return null
|
||||
|
||||
return (
|
||||
<section className='space-y-5'>
|
||||
<header className='space-y-1'>
|
||||
<p className='text-muted-foreground text-[11px] font-medium tracking-widest uppercase'>
|
||||
{t('By category')}
|
||||
</p>
|
||||
<h2 className='text-foreground text-xl font-semibold tracking-tight'>
|
||||
{t('Browse rankings by category')}
|
||||
</h2>
|
||||
<p className='text-muted-foreground/80 max-w-2xl text-sm'>
|
||||
{t('Discover the leading models in each domain')}
|
||||
</p>
|
||||
</header>
|
||||
<div className='grid grid-cols-1 gap-5 lg:grid-cols-2'>
|
||||
{props.sections.map((section) => (
|
||||
<CategorySection key={section.category} section={section} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type EntityLinkBaseProps = {
|
||||
className?: string
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
type ModelLinkProps = EntityLinkBaseProps & {
|
||||
/** model_name as it appears in the pricing API. Used as the route param. */
|
||||
modelName: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Link wrapping a model name. Navigates to the existing model details
|
||||
* page (`/pricing/{modelName}`). Renders the model name itself by
|
||||
* default; pass `children` to wrap arbitrary content.
|
||||
*
|
||||
* A subtle persistent underline acts as the link affordance (so
|
||||
* clickability is obvious in lists with dozens of entries) and brightens
|
||||
* on hover.
|
||||
*/
|
||||
export function ModelLink(props: ModelLinkProps) {
|
||||
return (
|
||||
<Link
|
||||
to='/pricing/$modelId'
|
||||
params={{ modelId: props.modelName }}
|
||||
className={cn(
|
||||
'decoration-foreground/30 hover:decoration-foreground underline decoration-1 underline-offset-4 transition-colors',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{props.children ?? props.modelName}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
type VendorLinkProps = EntityLinkBaseProps & {
|
||||
/** Display name of the vendor (e.g. "Google", "OpenAI"). */
|
||||
vendor: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Link wrapping a vendor name. Navigates to the pricing page filtered by
|
||||
* that vendor (`/pricing?vendor={vendor}`). Renders the vendor name
|
||||
* itself by default. Uses the same subtle persistent underline as
|
||||
* {@link ModelLink}, scaled for the smaller secondary text it usually
|
||||
* wraps.
|
||||
*/
|
||||
export function VendorLink(props: VendorLinkProps) {
|
||||
return (
|
||||
<Link
|
||||
to='/pricing'
|
||||
search={{ vendor: props.vendor }}
|
||||
className={cn(
|
||||
'hover:text-foreground underline decoration-current/40 decoration-1 underline-offset-2 transition-colors hover:decoration-current',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{props.children ?? props.vendor}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type GrowthTextProps = {
|
||||
value: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a period-over-period growth percent as `↑303%`, `↓12.4%`, or
|
||||
* `0%` (when no change). The arrow is encoded in the text so the value
|
||||
* still aligns inside a tabular column.
|
||||
*/
|
||||
export function GrowthText(props: GrowthTextProps) {
|
||||
const v = props.value
|
||||
if (!Number.isFinite(v) || v === 0) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'text-muted-foreground/80 font-mono tabular-nums',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
0%
|
||||
</span>
|
||||
)
|
||||
}
|
||||
const isUp = v > 0
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'font-mono tabular-nums',
|
||||
isUp
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: 'text-rose-600 dark:text-rose-400',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{isUp ? '↑' : '↓'}
|
||||
{Math.abs(v).toFixed(Math.abs(v) >= 100 ? 0 : 1)}%
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export * from './apps-section'
|
||||
export * from './category-section'
|
||||
export * from './entity-links'
|
||||
export * from './growth-text'
|
||||
export * from './market-share-section'
|
||||
export * from './model-leaderboard'
|
||||
export * from './models-section'
|
||||
export * from './pulse-section'
|
||||
export * from './rankings-hero'
|
||||
@@ -0,0 +1,284 @@
|
||||
import { useMemo } from 'react'
|
||||
import { VChart } from '@visactor/react-vchart'
|
||||
import { PieChart } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useChartTheme } from '@/lib/use-chart-theme'
|
||||
import { VCHART_OPTION } from '@/lib/vchart'
|
||||
import { formatShare, formatTokens } from '../lib/format'
|
||||
import type { RankingPeriod, VendorRanking, VendorShareSeries } from '../types'
|
||||
import { VendorLink } from './entity-links'
|
||||
|
||||
const PERIOD_DESCRIPTIONS: Record<RankingPeriod, string> = {
|
||||
today: 'Token share by model author across the last 24 hours',
|
||||
week: 'Token share by model author across the past few weeks',
|
||||
month: 'Token share by model author across the past month',
|
||||
year: 'Token share by model author across the past year',
|
||||
all: 'Token share by model author since launch',
|
||||
}
|
||||
|
||||
/** Stable colour palette for vendors, used in both the area chart and the
|
||||
* legend dots. Falls back to a neutral palette for unknown vendors so that
|
||||
* future additions still render. */
|
||||
const VENDOR_COLOURS: Record<string, string> = {
|
||||
OpenAI: '#10a37f',
|
||||
Anthropic: '#d97757',
|
||||
Google: '#4285f4',
|
||||
DeepSeek: '#7c5cff',
|
||||
Alibaba: '#ff9900',
|
||||
xAI: '#1f2937',
|
||||
Meta: '#1877f2',
|
||||
Moonshot: '#ec4899',
|
||||
Zhipu: '#06b6d4',
|
||||
Mistral: '#ff7000',
|
||||
ByteDance: '#3b82f6',
|
||||
Tencent: '#22c55e',
|
||||
MiniMax: '#a855f7',
|
||||
Cohere: '#fb923c',
|
||||
Baidu: '#ef4444',
|
||||
Others: '#94a3b8',
|
||||
}
|
||||
|
||||
const FALLBACK_PALETTE = [
|
||||
'#0ea5e9',
|
||||
'#22c55e',
|
||||
'#a855f7',
|
||||
'#f97316',
|
||||
'#14b8a6',
|
||||
'#eab308',
|
||||
'#ec4899',
|
||||
'#84cc16',
|
||||
'#6366f1',
|
||||
'#10b981',
|
||||
'#f43f5e',
|
||||
'#0891b2',
|
||||
'#94a3b8',
|
||||
]
|
||||
|
||||
function buildVendorColourMap(names: string[]): Record<string, string> {
|
||||
const result: Record<string, string> = {}
|
||||
let fallbackIdx = 0
|
||||
for (const name of names) {
|
||||
if (VENDOR_COLOURS[name]) {
|
||||
result[name] = VENDOR_COLOURS[name]
|
||||
} else {
|
||||
result[name] = FALLBACK_PALETTE[fallbackIdx % FALLBACK_PALETTE.length]
|
||||
fallbackIdx += 1
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const MAX_VENDORS_IN_LIST = 12
|
||||
|
||||
type MarketShareSectionProps = {
|
||||
history: VendorShareSeries
|
||||
rows: VendorRanking[]
|
||||
period: RankingPeriod
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined "Market Share" card: a 100%-stacked area chart showing each
|
||||
* vendor's slice of total token volume, paired below with a two-column
|
||||
* vendor list.
|
||||
*/
|
||||
export function MarketShareSection(props: MarketShareSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const { resolvedTheme, themeReady } = useChartTheme()
|
||||
|
||||
const colourMap = useMemo(
|
||||
() => buildVendorColourMap(props.history.vendors.map((v) => v.name)),
|
||||
[props.history]
|
||||
)
|
||||
|
||||
const orderedPoints = useMemo(() => {
|
||||
const order = new Map(
|
||||
props.history.vendors.map((v, idx) => [v.name, idx] as const)
|
||||
)
|
||||
return [...props.history.points].sort((a, b) => {
|
||||
const tsCmp = a.ts.localeCompare(b.ts)
|
||||
if (tsCmp !== 0) return tsCmp
|
||||
return (order.get(a.vendor) ?? 999) - (order.get(b.vendor) ?? 999)
|
||||
})
|
||||
}, [props.history])
|
||||
|
||||
const spec = useMemo(() => {
|
||||
if (orderedPoints.length === 0) return null
|
||||
return {
|
||||
type: 'area' as const,
|
||||
data: [{ id: 'vendor-share', values: orderedPoints }],
|
||||
xField: 'label',
|
||||
yField: 'share',
|
||||
seriesField: 'vendor',
|
||||
stack: true,
|
||||
legends: { visible: false },
|
||||
area: {
|
||||
style: { fillOpacity: 0.85, curveType: 'monotone' },
|
||||
},
|
||||
line: { style: { lineWidth: 0, curveType: 'monotone' } },
|
||||
point: { visible: false },
|
||||
color: { specified: colourMap },
|
||||
axes: [
|
||||
{
|
||||
orient: 'bottom',
|
||||
label: {
|
||||
style: { fill: 'currentColor', fontSize: 10 },
|
||||
autoHide: true,
|
||||
autoLimit: true,
|
||||
},
|
||||
tick: { visible: false },
|
||||
},
|
||||
{
|
||||
orient: 'left',
|
||||
min: 0,
|
||||
max: 1,
|
||||
label: {
|
||||
formatMethod: (val: number | string) =>
|
||||
`${Math.round(Number(val) * 100)}%`,
|
||||
style: { fill: 'currentColor', fontSize: 10 },
|
||||
},
|
||||
grid: { visible: true, style: { lineDash: [3, 3] } },
|
||||
},
|
||||
],
|
||||
tooltip: {
|
||||
mark: {
|
||||
content: [
|
||||
{
|
||||
key: (datum: Record<string, unknown>) =>
|
||||
String(datum?.vendor ?? ''),
|
||||
value: (datum: Record<string, unknown>) =>
|
||||
`${(Number(datum?.share) * 100).toFixed(1)}% · ${formatTokens(Number(datum?.tokens) || 0)}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
dimension: {
|
||||
title: {
|
||||
value: (datum: Record<string, unknown>) =>
|
||||
String(datum?.label ?? ''),
|
||||
},
|
||||
content: [
|
||||
{
|
||||
key: (datum: Record<string, unknown>) =>
|
||||
String(datum?.vendor ?? ''),
|
||||
value: (datum: Record<string, unknown>) =>
|
||||
Number(datum?.share) || 0,
|
||||
},
|
||||
],
|
||||
updateContent: (
|
||||
array: Array<{ key: string; value: string | number }>
|
||||
) => {
|
||||
return array
|
||||
.filter((item) => Number(item.value) > 0.001)
|
||||
.sort((a, b) => Number(b.value) - Number(a.value))
|
||||
.map((item) => ({
|
||||
key: item.key,
|
||||
value: `${(Number(item.value) * 100).toFixed(1)}%`,
|
||||
}))
|
||||
},
|
||||
},
|
||||
},
|
||||
animationAppear: { duration: 500 },
|
||||
}
|
||||
}, [colourMap, orderedPoints])
|
||||
|
||||
const visible = props.rows.slice(0, MAX_VENDORS_IN_LIST)
|
||||
const half = Math.ceil(visible.length / 2)
|
||||
const left = visible.slice(0, half)
|
||||
const right = visible.slice(half)
|
||||
|
||||
return (
|
||||
<section className='bg-card overflow-hidden rounded-lg border'>
|
||||
{/* Chart block ----------------------------------------------------- */}
|
||||
<header className='px-5 py-4'>
|
||||
<h2 className='text-foreground inline-flex items-center gap-2 text-base font-semibold'>
|
||||
<PieChart className='text-primary size-4' />
|
||||
{t('Market Share')}
|
||||
</h2>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
{t(PERIOD_DESCRIPTIONS[props.period])}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className='px-5 pb-5'>
|
||||
<div className='h-60 sm:h-72'>
|
||||
{themeReady && spec ? (
|
||||
<VChart
|
||||
key={`vendor-share-${resolvedTheme}-${props.period}`}
|
||||
spec={{
|
||||
...spec,
|
||||
theme: resolvedTheme === 'dark' ? 'dark' : 'light',
|
||||
background: 'transparent',
|
||||
}}
|
||||
option={VCHART_OPTION}
|
||||
/>
|
||||
) : (
|
||||
<div className='text-muted-foreground/80 flex h-full items-center justify-center text-xs'>
|
||||
{t('No history data available')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vendor list block ----------------------------------------------- */}
|
||||
<div className='border-t'>
|
||||
<header className='px-5 pt-4 pb-2'>
|
||||
<h3 className='text-foreground text-sm font-semibold'>
|
||||
{t('By model author')}
|
||||
</h3>
|
||||
<p className='text-muted-foreground/80 mt-0.5 text-xs'>
|
||||
{t('Vendors ranked by aggregated token volume')}
|
||||
</p>
|
||||
</header>
|
||||
{visible.length === 0 ? (
|
||||
<div className='text-muted-foreground/80 px-5 py-8 text-center text-sm'>
|
||||
{t('No vendor data available')}
|
||||
</div>
|
||||
) : (
|
||||
<div className='grid grid-cols-1 gap-x-8 px-5 pt-1 pb-4 md:grid-cols-2'>
|
||||
<VendorList rows={left} colourMap={colourMap} />
|
||||
{right.length > 0 && (
|
||||
<VendorList rows={right} colourMap={colourMap} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function VendorList(props: {
|
||||
rows: VendorRanking[]
|
||||
colourMap: Record<string, string>
|
||||
}) {
|
||||
return (
|
||||
<ul>
|
||||
{props.rows.map((vendor) => (
|
||||
<li key={vendor.vendor} className='flex items-center gap-3 py-2.5'>
|
||||
<span className='text-muted-foreground/80 w-6 shrink-0 text-right font-mono text-xs tabular-nums'>
|
||||
{vendor.rank}.
|
||||
</span>
|
||||
<span
|
||||
aria-hidden
|
||||
className='size-2.5 shrink-0 rounded-full'
|
||||
style={{
|
||||
backgroundColor: props.colourMap[vendor.vendor] ?? '#94a3b8',
|
||||
}}
|
||||
/>
|
||||
<VendorLink
|
||||
vendor={vendor.vendor}
|
||||
className='text-foreground min-w-0 flex-1 truncate text-sm font-medium'
|
||||
>
|
||||
{vendor.vendor}
|
||||
</VendorLink>
|
||||
<div className='shrink-0 text-right'>
|
||||
<div className='text-foreground font-mono text-sm font-semibold tabular-nums'>
|
||||
{formatTokens(vendor.total_tokens)}
|
||||
</div>
|
||||
<div className='text-muted-foreground/80 font-mono text-[11px] tabular-nums'>
|
||||
{formatShare(vendor.share)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getLobeIcon } from '@/lib/lobe-icon'
|
||||
import { formatTokens } from '../lib/format'
|
||||
import type { ModelRanking } from '../types'
|
||||
import { ModelLink, VendorLink } from './entity-links'
|
||||
import { GrowthText } from './growth-text'
|
||||
|
||||
type ModelLeaderboardProps = {
|
||||
rows: ModelRanking[]
|
||||
/** Density variant. `compact` is used inside per-category sections; the
|
||||
* default fits the larger overall "Top Models" section. */
|
||||
variant?: 'default' | 'compact'
|
||||
/** Optional cap (rows beyond this are dropped). */
|
||||
limit?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-column model leaderboard list: "rank · model
|
||||
* (with vendor below) · tokens (with growth below)" rendering. Splits
|
||||
* `rows` evenly between the two columns so the visual rhythm matches a
|
||||
* single ranked list rather than two independent lists.
|
||||
*
|
||||
* Both the model name and vendor name are clickable: model jumps to
|
||||
* `/pricing/{modelName}` and vendor jumps to `/pricing?vendor={vendor}`.
|
||||
*/
|
||||
export function ModelLeaderboard(props: ModelLeaderboardProps) {
|
||||
const limited = props.limit ? props.rows.slice(0, props.limit) : props.rows
|
||||
const half = Math.ceil(limited.length / 2)
|
||||
const left = limited.slice(0, half)
|
||||
const right = limited.slice(half)
|
||||
const variant = props.variant ?? 'default'
|
||||
|
||||
if (limited.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='grid grid-cols-1 gap-x-8 md:grid-cols-2'>
|
||||
<ModelList rows={left} variant={variant} />
|
||||
{right.length > 0 && <ModelList rows={right} variant={variant} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ModelList(props: {
|
||||
rows: ModelRanking[]
|
||||
variant: 'default' | 'compact'
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const compact = props.variant === 'compact'
|
||||
return (
|
||||
<ul>
|
||||
{props.rows.map((row) => (
|
||||
<li
|
||||
key={row.model_name}
|
||||
className={
|
||||
compact
|
||||
? 'flex items-center gap-3 py-2'
|
||||
: 'flex items-center gap-3 py-2.5'
|
||||
}
|
||||
>
|
||||
<span className='text-muted-foreground/80 w-6 shrink-0 text-right font-mono text-xs tabular-nums'>
|
||||
{row.rank}.
|
||||
</span>
|
||||
<span className='shrink-0'>
|
||||
{getLobeIcon(row.vendor_icon, compact ? 20 : 22)}
|
||||
</span>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<ModelLink
|
||||
modelName={row.model_name}
|
||||
className={
|
||||
compact
|
||||
? 'text-foreground block truncate font-mono text-xs font-medium'
|
||||
: 'text-foreground block truncate font-mono text-sm font-medium'
|
||||
}
|
||||
>
|
||||
{row.model_name}
|
||||
</ModelLink>
|
||||
<p
|
||||
className={
|
||||
compact
|
||||
? 'text-muted-foreground/80 truncate text-[11px] italic'
|
||||
: 'text-muted-foreground/80 truncate text-xs italic'
|
||||
}
|
||||
>
|
||||
by{' '}
|
||||
<VendorLink vendor={row.vendor}>
|
||||
{row.vendor.toLowerCase()}
|
||||
</VendorLink>
|
||||
</p>
|
||||
</div>
|
||||
<div className='shrink-0 text-right'>
|
||||
<div
|
||||
className={
|
||||
compact
|
||||
? 'text-foreground font-mono text-xs font-semibold tabular-nums'
|
||||
: 'text-foreground font-mono text-sm font-semibold tabular-nums'
|
||||
}
|
||||
>
|
||||
{formatTokens(row.total_tokens)}
|
||||
{!compact && (
|
||||
<>
|
||||
{' '}
|
||||
<span className='text-muted-foreground/80 font-normal'>
|
||||
{t('tokens')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<GrowthText
|
||||
value={row.growth_pct}
|
||||
className={compact ? 'text-[10px]' : 'text-[11px]'}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useMemo } from 'react'
|
||||
import { VChart } from '@visactor/react-vchart'
|
||||
import { BarChart3, Trophy } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useChartTheme } from '@/lib/use-chart-theme'
|
||||
import { VCHART_OPTION } from '@/lib/vchart'
|
||||
import { formatTokens } from '../lib/format'
|
||||
import type { ModelHistorySeries, ModelRanking, RankingPeriod } from '../types'
|
||||
import { ModelLeaderboard } from './model-leaderboard'
|
||||
|
||||
const PERIOD_DESCRIPTIONS: Record<RankingPeriod, string> = {
|
||||
today: 'Hourly token usage by model across the last 24 hours',
|
||||
week: 'Weekly token usage by model across the past few weeks',
|
||||
month: 'Daily token usage by model across the past month',
|
||||
year: 'Weekly token usage by model across the past year',
|
||||
all: 'Token usage by model since launch',
|
||||
}
|
||||
|
||||
const TOOLTIP_MAX_ROWS = 10
|
||||
|
||||
type ModelsSectionProps = {
|
||||
history: ModelHistorySeries
|
||||
rows: ModelRanking[]
|
||||
period: RankingPeriod
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined "Top Models" card: a stacked bar chart showing token usage by
|
||||
* model over time, paired below with a two-column LLM Leaderboard. The
|
||||
* chart anchors the eye while the leaderboard provides the detailed key.
|
||||
*/
|
||||
export function ModelsSection(props: ModelsSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const { resolvedTheme, themeReady } = useChartTheme()
|
||||
|
||||
// Order points so the largest model appears at the bottom of every stack.
|
||||
const orderedPoints = useMemo(() => {
|
||||
const order = new Map(
|
||||
props.history.models.map((m, idx) => [m.name, idx] as const)
|
||||
)
|
||||
return [...props.history.points].sort((a, b) => {
|
||||
const tsCmp = a.ts.localeCompare(b.ts)
|
||||
if (tsCmp !== 0) return tsCmp
|
||||
return (order.get(a.model) ?? 999) - (order.get(b.model) ?? 999)
|
||||
})
|
||||
}, [props.history])
|
||||
|
||||
const totalTokens = useMemo(
|
||||
() => props.rows.reduce((s, r) => s + r.total_tokens, 0),
|
||||
[props.rows]
|
||||
)
|
||||
|
||||
const spec = useMemo(() => {
|
||||
if (orderedPoints.length === 0) return null
|
||||
return {
|
||||
type: 'bar' as const,
|
||||
data: [{ id: 'models-history', values: orderedPoints }],
|
||||
xField: 'label',
|
||||
yField: 'tokens',
|
||||
seriesField: 'model',
|
||||
stack: true,
|
||||
bar: { style: { cornerRadius: 1 } },
|
||||
legends: { visible: false },
|
||||
axes: [
|
||||
{
|
||||
orient: 'bottom',
|
||||
label: {
|
||||
style: { fill: 'currentColor', fontSize: 10 },
|
||||
autoHide: true,
|
||||
autoLimit: true,
|
||||
},
|
||||
tick: { visible: false },
|
||||
},
|
||||
{
|
||||
orient: 'left',
|
||||
label: {
|
||||
formatMethod: (val: number | string) => formatTokens(Number(val)),
|
||||
style: { fill: 'currentColor', fontSize: 10 },
|
||||
},
|
||||
grid: { visible: true, style: { lineDash: [3, 3] } },
|
||||
},
|
||||
],
|
||||
tooltip: {
|
||||
mark: {
|
||||
content: [
|
||||
{
|
||||
key: (datum: Record<string, unknown>) =>
|
||||
String(datum?.model ?? ''),
|
||||
value: (datum: Record<string, unknown>) =>
|
||||
formatTokens(Number(datum?.tokens) || 0),
|
||||
},
|
||||
],
|
||||
},
|
||||
dimension: {
|
||||
title: {
|
||||
value: (datum: Record<string, unknown>) =>
|
||||
String(datum?.label ?? ''),
|
||||
},
|
||||
content: [
|
||||
{
|
||||
key: (datum: Record<string, unknown>) =>
|
||||
String(datum?.model ?? ''),
|
||||
value: (datum: Record<string, unknown>) =>
|
||||
Number(datum?.tokens) || 0,
|
||||
},
|
||||
],
|
||||
updateContent: (
|
||||
array: Array<{ key: string; value: string | number }>
|
||||
) => {
|
||||
array.sort((a, b) => Number(b.value) - Number(a.value))
|
||||
const sum = array.reduce((s, x) => s + (Number(x.value) || 0), 0)
|
||||
const visible = array.slice(0, TOOLTIP_MAX_ROWS)
|
||||
const overflow = array.slice(TOOLTIP_MAX_ROWS)
|
||||
const result = visible.map((item) => ({
|
||||
key: item.key,
|
||||
value: formatTokens(Number(item.value) || 0),
|
||||
}))
|
||||
if (overflow.length > 0) {
|
||||
const otherSum = overflow.reduce(
|
||||
(s, item) => s + (Number(item.value) || 0),
|
||||
0
|
||||
)
|
||||
result.push({
|
||||
key: t('+{{count}} more', { count: overflow.length }),
|
||||
value: formatTokens(otherSum),
|
||||
})
|
||||
}
|
||||
result.unshift({ key: t('Total:'), value: formatTokens(sum) })
|
||||
return result
|
||||
},
|
||||
},
|
||||
},
|
||||
animationAppear: { duration: 500 },
|
||||
}
|
||||
}, [orderedPoints, t])
|
||||
|
||||
return (
|
||||
<section className='bg-card overflow-hidden rounded-lg border'>
|
||||
{/* Chart block ----------------------------------------------------- */}
|
||||
<header className='flex items-start justify-between gap-4 px-5 py-4'>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<h2 className='text-foreground inline-flex items-center gap-2 text-base font-semibold'>
|
||||
<BarChart3 className='text-primary size-4' />
|
||||
{t('Top Models')}
|
||||
</h2>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
{t(PERIOD_DESCRIPTIONS[props.period])}
|
||||
</p>
|
||||
</div>
|
||||
<div className='shrink-0 text-right'>
|
||||
<div className='text-foreground font-mono text-2xl font-semibold tabular-nums'>
|
||||
{formatTokens(totalTokens)}
|
||||
</div>
|
||||
<div className='text-muted-foreground/80 text-[10px] font-medium tracking-widest uppercase'>
|
||||
{t('tokens')}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className='px-5 pb-5'>
|
||||
<div className='h-60 sm:h-72'>
|
||||
{themeReady && spec ? (
|
||||
<VChart
|
||||
key={`models-history-${resolvedTheme}-${props.period}`}
|
||||
spec={{
|
||||
...spec,
|
||||
theme: resolvedTheme === 'dark' ? 'dark' : 'light',
|
||||
background: 'transparent',
|
||||
}}
|
||||
option={VCHART_OPTION}
|
||||
/>
|
||||
) : (
|
||||
<div className='text-muted-foreground/80 flex h-full items-center justify-center text-xs'>
|
||||
{t('No history data available')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Leaderboard block ----------------------------------------------- */}
|
||||
<div className='border-t'>
|
||||
<header className='px-5 pt-4 pb-2'>
|
||||
<h3 className='text-foreground inline-flex items-center gap-2 text-sm font-semibold'>
|
||||
<Trophy className='size-3.5 text-amber-500' />
|
||||
{t('LLM Leaderboard')}
|
||||
</h3>
|
||||
<p className='text-muted-foreground/80 mt-0.5 text-xs'>
|
||||
{t('Compare the most popular models on the platform')}
|
||||
</p>
|
||||
</header>
|
||||
{props.rows.length === 0 ? (
|
||||
<div className='text-muted-foreground/80 px-5 py-8 text-center text-sm'>
|
||||
{t('No models match the selected filters')}
|
||||
</div>
|
||||
) : (
|
||||
<div className='px-5 pt-1 pb-4'>
|
||||
<ModelLeaderboard rows={props.rows} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user