From 4d45fac41e663e920ce1caf8b61b7ea2e50a5b51 Mon Sep 17 00:00:00 2001 From: Micah-Zheng <102610064+Micah-Zheng@users.noreply.github.com> Date: Thu, 30 Apr 2026 01:51:37 +0800 Subject: [PATCH 01/20] customize frontend UI overrides --- .gitignore | 3 + .../layout/components/nav-group.tsx | 27 ++- web/default/src/components/layout/types.ts | 1 + web/default/src/custom/site.ts | 53 ++++++ .../pricing/components/model-details.tsx | 92 +++++----- .../pricing/components/pricing-table.tsx | 12 +- .../src/features/pricing/hooks/use-filters.ts | 82 +++++++-- web/default/src/features/pricing/index.tsx | 71 +++++--- .../components/affiliate-rewards-card.tsx | 171 ++++++++---------- .../dialogs/payment-confirm-dialog.tsx | 8 +- .../wallet/components/recharge-form-card.tsx | 42 +++-- web/default/src/features/wallet/index.tsx | 33 +--- web/default/src/features/wallet/lib/format.ts | 8 + web/default/src/hooks/use-sidebar-data.ts | 7 + web/default/src/hooks/use-top-nav-links.ts | 14 ++ web/default/src/i18n/locales/en.json | 8 + web/default/src/i18n/locales/fr.json | 8 + web/default/src/i18n/locales/ja.json | 8 + web/default/src/i18n/locales/ru.json | 8 + web/default/src/i18n/locales/vi.json | 8 + web/default/src/i18n/locales/zh.json | 8 + 21 files changed, 420 insertions(+), 252 deletions(-) create mode 100644 web/default/src/custom/site.ts diff --git a/.gitignore b/.gitignore index bbc5717e4727..e4d4f645841c 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ data/ .test token_estimator_test.go skills-lock.json +.omx/ +.playwright-mcp/ +web/default/.omc/ diff --git a/web/default/src/components/layout/components/nav-group.tsx b/web/default/src/components/layout/components/nav-group.tsx index e2841bd55baa..e3b4b2d390d4 100644 --- a/web/default/src/components/layout/components/nav-group.tsx +++ b/web/default/src/components/layout/components/nav-group.tsx @@ -99,6 +99,14 @@ function NavBadge({ children }: { children: ReactNode }) { */ function SidebarMenuLink({ item, href }: { item: NavLink; href: string }) { const { setOpenMobile } = useSidebar() + const content = ( + <> + {item.icon && } + {item.title} + {item.badge && {item.badge}} + + ) + return ( - setOpenMobile(false)}> - {item.icon && } - {item.title} - {item.badge && {item.badge}} - + {item.newTab ? ( + setOpenMobile(false)} + > + {content} + + ) : ( + setOpenMobile(false)}> + {content} + + )} ) diff --git a/web/default/src/components/layout/types.ts b/web/default/src/components/layout/types.ts index f5b3d6764fd4..f664700e2b1b 100644 --- a/web/default/src/components/layout/types.ts +++ b/web/default/src/components/layout/types.ts @@ -18,6 +18,7 @@ type BaseNavItem = { title: string badge?: string icon?: React.ElementType + newTab?: boolean } /** diff --git a/web/default/src/custom/site.ts b/web/default/src/custom/site.ts new file mode 100644 index 000000000000..606550c14810 --- /dev/null +++ b/web/default/src/custom/site.ts @@ -0,0 +1,53 @@ +import { Activity, Store, type LucideIcon } from 'lucide-react' + +type CustomSidebarLink = { + titleKey: string + url: string + icon: LucideIcon + newTab?: boolean +} + +type CustomTopNavLink = { + titleKey: string + href: string + external?: boolean + moduleKey?: keyof typeof customHeaderNavModuleDefaults +} + +export const customSidebarLinks: CustomSidebarLink[] = [ + { + titleKey: 'Model Square', + url: '/pricing', + icon: Store, + newTab: true, + }, + { + titleKey: 'Status Monitor', + url: 'https://status.tcp.red', + icon: Activity, + newTab: true, + }, +] + +export const customTopNavLinks: CustomTopNavLink[] = [ + { + titleKey: 'Status Monitor', + href: 'https://status.tcp.red', + external: true, + moduleKey: 'statusMonitor', + }, +] + +export const customHeaderNavModuleDefaults = { + statusMonitor: true, +} + +export function formatCustomPaymentAmount(amount: number | string): string { + const numeric = + typeof amount === 'number' ? amount : Number.parseFloat(String(amount)) + const safeAmount = Number.isFinite(numeric) ? numeric : 0 + + return `¥${safeAmount.toLocaleString(undefined, { + maximumFractionDigits: 2, + })}` +} diff --git a/web/default/src/features/pricing/components/model-details.tsx b/web/default/src/features/pricing/components/model-details.tsx index bebc686b171f..66ff199883cc 100644 --- a/web/default/src/features/pricing/components/model-details.tsx +++ b/web/default/src/features/pricing/components/model-details.tsx @@ -463,10 +463,18 @@ function GroupPricingSection(props: { ) } -export function ModelDetails() { +type ModelDetailsProps = { + embedded?: boolean + routeFrom?: '/pricing/$modelId/' + backPath?: '/pricing' +} + +export function ModelDetails(props: ModelDetailsProps) { const { t } = useTranslation() - const { modelId } = useParams({ from: '/pricing/$modelId/' }) - const search = useSearch({ from: '/pricing/$modelId/' }) + const routeFrom = props.routeFrom ?? '/pricing/$modelId/' + const backPath = props.backPath ?? '/pricing' + const { modelId } = useParams({ from: routeFrom }) + const search = useSearch({ from: routeFrom }) const navigate = useNavigate() const { @@ -489,53 +497,56 @@ export function ModelDetails() { }, [models, modelId]) const handleBack = () => { - navigate({ to: '/pricing', search }) + navigate({ to: backPath, search }) + } + + const wrapContent = (children: React.ReactNode) => { + if (props.embedded) { + return children + } + + return {children} } if (isLoading) { - return ( - -
- -
- - - -
-
- {Array.from({ length: 3 }).map((_, i) => ( -
- - -
- ))} -
+ return wrapContent( +
+ +
+ + + +
+
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ + +
+ ))}
- +
) } if (!model) { - return ( - -
-

- {t('Model not found')} -

-

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

- -
-
+ return wrapContent( +
+

+ {t('Model not found')} +

+

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

+ +
) } - return ( - -
+ return wrapContent( +
- +
) } diff --git a/web/default/src/features/pricing/components/pricing-table.tsx b/web/default/src/features/pricing/components/pricing-table.tsx index f4836818e91d..30794c3943b7 100644 --- a/web/default/src/features/pricing/components/pricing-table.tsx +++ b/web/default/src/features/pricing/components/pricing-table.tsx @@ -1,5 +1,4 @@ import { useState, useCallback } from 'react' -import { useNavigate } from '@tanstack/react-router' import { flexRender, getCoreRowModel, @@ -29,11 +28,11 @@ export interface PricingTableProps { usdExchangeRate?: number tokenUnit?: TokenUnit showRechargePrice?: boolean + onModelClick: (modelName: string) => void } export function PricingTable(props: PricingTableProps) { const { t } = useTranslation() - const navigate = useNavigate({ from: '/pricing/' }) const { models, isLoading = false, @@ -41,6 +40,7 @@ export function PricingTable(props: PricingTableProps) { usdExchangeRate = 1, tokenUnit = DEFAULT_TOKEN_UNIT, showRechargePrice = false, + onModelClick, } = props const [pagination, setPagination] = useState({ @@ -68,13 +68,9 @@ export function PricingTable(props: PricingTableProps) { const handleRowClick = useCallback( (model: PricingModel) => { - navigate({ - to: '/pricing/$modelId', - params: { modelId: model.model_name }, - search: (prev) => prev, - }) + onModelClick(model.model_name || '') }, - [navigate] + [onModelClick] ) return ( diff --git a/web/default/src/features/pricing/hooks/use-filters.ts b/web/default/src/features/pricing/hooks/use-filters.ts index 981fb8da15df..cce7982a8ade 100644 --- a/web/default/src/features/pricing/hooks/use-filters.ts +++ b/web/default/src/features/pricing/hooks/use-filters.ts @@ -12,40 +12,82 @@ import { import { filterAndSortModels, extractAllTags } from '../lib/filters' import type { PricingModel, TokenUnit } from '../types' -export function useFilters(models: PricingModel[]) { - const search = useSearch({ from: '/pricing/' }) - const navigate = useNavigate({ from: '/pricing/' }) +type PricingNavigatePath = '/pricing' - const searchInput = search.search || '' - const sortBy = search.sort || SORT_OPTIONS.NAME - const vendorFilter = search.vendor || FILTER_ALL - const groupFilter = search.group || FILTER_ALL - const quotaTypeFilter = search.quotaType || QUOTA_TYPES.ALL - const endpointTypeFilter = search.endpointType || ENDPOINT_TYPES.ALL - const tagFilter = search.tag || FILTER_ALL +function firstString(value: unknown): string | undefined { + if (typeof value === 'string') return value + if (Array.isArray(value) && typeof value[0] === 'string') return value[0] + return undefined +} + +export function useFilters( + models: PricingModel[], + routeTo: PricingNavigatePath = '/pricing' +) { + const search = useSearch({ strict: false }) + const navigate = useNavigate() + + const searchInput = firstString(search.search) || '' + const sortBy = firstString(search.sort) || SORT_OPTIONS.NAME + const vendorFilter = firstString(search.vendor) || FILTER_ALL + const groupFilter = firstString(search.group) || FILTER_ALL + const quotaTypeFilter = firstString(search.quotaType) || QUOTA_TYPES.ALL + const endpointTypeFilter = + firstString(search.endpointType) || ENDPOINT_TYPES.ALL + const tagFilter = firstString(search.tag) || FILTER_ALL const tokenUnit: TokenUnit = - search.tokenUnit === 'K' ? 'K' : DEFAULT_TOKEN_UNIT + firstString(search.tokenUnit) === 'K' ? 'K' : DEFAULT_TOKEN_UNIT const viewMode: ViewMode = - search.view === 'table' ? VIEW_MODES.TABLE : VIEW_MODES.LIST + firstString(search.view) === 'table' ? VIEW_MODES.TABLE : VIEW_MODES.LIST const showRechargePrice = search.rechargePrice === true const updateSearch = useCallback( (updates: Record) => { navigate({ - to: '/pricing' as const, - search: (prev) => { - const next: Record = { ...prev, ...updates } - for (const key of Object.keys(next)) { - if (next[key] === undefined || next[key] === null) { - delete next[key] - } + to: routeTo, + search: () => { + const next = { + search: searchInput || undefined, + sort: sortBy === SORT_OPTIONS.NAME ? undefined : sortBy, + vendor: vendorFilter === FILTER_ALL ? undefined : vendorFilter, + group: groupFilter === FILTER_ALL ? undefined : groupFilter, + quotaType: + quotaTypeFilter === QUOTA_TYPES.ALL ? undefined : quotaTypeFilter, + endpointType: + endpointTypeFilter === ENDPOINT_TYPES.ALL + ? undefined + : endpointTypeFilter, + tag: tagFilter === FILTER_ALL ? undefined : tagFilter, + tokenUnit: tokenUnit === DEFAULT_TOKEN_UNIT ? undefined : tokenUnit, + view: viewMode === VIEW_MODES.LIST ? undefined : viewMode, + rechargePrice: showRechargePrice || undefined, + ...updates, } + Object.keys(next).forEach((key) => { + const typedKey = key as keyof typeof next + if (next[typedKey] === undefined || next[typedKey] === null) { + delete next[typedKey] + } + }) return next }, replace: true, }) }, - [navigate] + [ + endpointTypeFilter, + groupFilter, + navigate, + quotaTypeFilter, + routeTo, + searchInput, + showRechargePrice, + sortBy, + tagFilter, + tokenUnit, + vendorFilter, + viewMode, + ] ) const setSearchInput = useCallback( diff --git a/web/default/src/features/pricing/index.tsx b/web/default/src/features/pricing/index.tsx index 284ef2ccd7a7..36bc5ccd2a66 100644 --- a/web/default/src/features/pricing/index.tsx +++ b/web/default/src/features/pricing/index.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from 'react' +import { useCallback, useMemo, type ReactNode } from 'react' import { useNavigate } from '@tanstack/react-router' import { useMediaQuery } from '@/hooks' import { useTranslation } from 'react-i18next' @@ -16,9 +16,17 @@ import { EXCLUDED_GROUPS, VIEW_MODES } from './constants' import { useFilters } from './hooks/use-filters' import { usePricingData } from './hooks/use-pricing-data' -export function Pricing() { +type PricingProps = { + embedded?: boolean + routeTo?: '/pricing' + detailPath?: '/pricing/$modelId' +} + +export function Pricing(props: PricingProps) { const { t } = useTranslation() - const navigate = useNavigate({ from: '/pricing/' }) + const routeTo = props.routeTo ?? '/pricing' + const detailPath = props.detailPath ?? '/pricing/$modelId' + const navigate = useNavigate() const isMobile = useMediaQuery('(max-width: 640px)') const { @@ -57,17 +65,27 @@ export function Pricing() { availableTags, clearFilters, clearSearch, - } = useFilters(models || []) + } = useFilters(models || [], routeTo) const handleModelClick = useCallback( (modelName: string) => { navigate({ - to: '/pricing/$modelId', + to: detailPath, params: { modelId: modelName }, - search: (prev) => prev, }) }, - [navigate] + [detailPath, navigate] + ) + + const wrapContent = useCallback( + (children: ReactNode) => { + if (props.embedded) { + return children + } + + return {children} + }, + [props.embedded] ) const availableGroups = useMemo( @@ -84,28 +102,25 @@ export function Pricing() { }, [clearFilters, clearSearch]) if (isLoading) { - return ( - -
- -
-
+ return wrapContent( +
+ +
) } - return ( - - -
-

- {t('Model Pricing')} -

-

- {t('Browse and compare')} {models?.length || 0} {t('models')} -

-
+ return wrapContent( + +
+

+ {t('Model Pricing')} +

+

+ {t('Browse and compare')} {models?.length || 0} {t('models')} +

+
-
+
) ) : ( @@ -167,8 +183,7 @@ export function Pricing() { onClearFilters={handleClearAll} /> )} -
- - +
+
) } diff --git a/web/default/src/features/wallet/components/affiliate-rewards-card.tsx b/web/default/src/features/wallet/components/affiliate-rewards-card.tsx index 74b081ce7af3..2adc3ea07dee 100644 --- a/web/default/src/features/wallet/components/affiliate-rewards-card.tsx +++ b/web/default/src/features/wallet/components/affiliate-rewards-card.tsx @@ -1,138 +1,113 @@ import { useTranslation } from 'react-i18next' -import { formatQuota } from '@/lib/format' -import { Button } from '@/components/ui/button' +import { BadgePercent, ArrowRightLeft } from 'lucide-react' import { Card, CardContent, CardHeader } from '@/components/ui/card' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' import { Skeleton } from '@/components/ui/skeleton' -import { CopyButton } from '@/components/copy-button' -import type { UserWalletData } from '../types' +import { formatCnyAmount } from '../lib' +import type { TopupInfo } from '../types' interface AffiliateRewardsCardProps { - user: UserWalletData | null - affiliateLink: string - onTransfer: () => void loading?: boolean + topupInfo?: TopupInfo | null + priceRatio?: number } -export function AffiliateRewardsCard({ - user, - affiliateLink, - onTransfer, - loading, -}: AffiliateRewardsCardProps) { +function getDiscountTiers(topupInfo?: TopupInfo | null, priceRatio = 1) { + return Object.entries(topupInfo?.discount ?? {}) + .map(([amount, discount]) => { + const numericAmount = Number(amount) + const numericDiscount = Number(discount) + const originalPrice = numericAmount * priceRatio + const savedAmount = originalPrice * (1 - numericDiscount) + + return { + amount: numericAmount, + discount: numericDiscount, + savedAmount, + } + }) + .filter( + (tier) => + Number.isFinite(tier.amount) && + tier.amount > 0 && + Number.isFinite(tier.discount) && + tier.discount > 0 && + tier.discount < 1 && + Number.isFinite(tier.savedAmount) && + tier.savedAmount > 0 + ) + .sort((first, second) => first.amount - second.amount) +} + +export function AffiliateRewardsCard(props: AffiliateRewardsCardProps) { const { t } = useTranslation() - if (loading) { + const priceRatio = props.priceRatio ?? 1 + + if (props.loading) { return ( - - {/* Statistics Skeleton */} -
- {Array.from({ length: 3 }).map((_, i) => ( -
- - -
- ))} -
- - {/* Affiliate Link Skeleton */} -
- -
- - -
-
- - {/* Info Section Skeleton */} + +
) } - const hasRewards = (user?.aff_quota ?? 0) > 0 + const discountTiers = getDiscountTiers(props.topupInfo, priceRatio) return (

- {t('Referral Program')} + {t('Pricing Information')}

- {t('Share your link and earn rewards')} + {t('Recharge rate and discount tiers')}

- - {/* Statistics */} -
-
-
- {t('Pending')} -
-
- {formatQuota(user?.aff_quota ?? 0)} -
+ +
+
+ + + {t('Recharge Rate')} +
- -
-
- {t('Total Earned')} -
-
- {formatQuota(user?.aff_history_quota ?? 0)} -
-
- -
-
- {t('Invites')} -
-
{user?.aff_count ?? 0}
+
+ {formatCnyAmount(priceRatio)} = $1
- {/* Transfer Button */} - {hasRewards && ( - - )} - - {/* Affiliate Link */}
- -
- - +
+ + + {t('Recharge Discounts')} +
-
- - {/* Info */} -
-

- {t( - 'Earn rewards when your referrals add funds. Transfer accumulated rewards to your balance anytime.' +

+ {discountTiers.length > 0 ? ( + discountTiers.map((tier) => ( +
+ ${tier.amount} + + {t('Discount')} {formatCnyAmount(tier.savedAmount)} + +
+ )) + ) : ( +
+ {t('No recharge discounts configured')} +
)} -

+
diff --git a/web/default/src/features/wallet/components/dialogs/payment-confirm-dialog.tsx b/web/default/src/features/wallet/components/dialogs/payment-confirm-dialog.tsx index 0cd778b441d3..eb9ed819795e 100644 --- a/web/default/src/features/wallet/components/dialogs/payment-confirm-dialog.tsx +++ b/web/default/src/features/wallet/components/dialogs/payment-confirm-dialog.tsx @@ -13,7 +13,7 @@ import { } from '@/components/ui/alert-dialog' import { Skeleton } from '@/components/ui/skeleton' import { DEFAULT_DISCOUNT_RATE } from '../../constants' -import { formatCurrency, getPaymentIcon } from '../../lib' +import { formatCnyAmount, getPaymentIcon } from '../../lib' import type { PaymentMethod } from '../../types' interface PaymentConfirmDialogProps { @@ -81,11 +81,11 @@ export function PaymentConfirmDialog({ ) : (
- {formatCurrency(paymentAmount)} + {formatCnyAmount(paymentAmount)} {hasDiscount && ( - {formatCurrency(originalAmount)} + {formatCnyAmount(originalAmount)} )}
@@ -97,7 +97,7 @@ export function PaymentConfirmDialog({
{t('You save')} - {formatCurrency(discountAmount)} + {formatCnyAmount(discountAmount)}
diff --git a/web/default/src/features/wallet/components/recharge-form-card.tsx b/web/default/src/features/wallet/components/recharge-form-card.tsx index 53b75f99515e..c70f99286ecd 100644 --- a/web/default/src/features/wallet/components/recharge-form-card.tsx +++ b/web/default/src/features/wallet/components/recharge-form-card.tsx @@ -16,8 +16,7 @@ import { TooltipTrigger, } from '@/components/ui/tooltip' import { - formatCurrency, - getDiscountLabel, + formatCnyAmount, getPaymentIcon, getMinTopupAmount, calculatePresetPricing, @@ -231,23 +230,30 @@ export function RechargeFormCard({ )} onClick={() => onSelectPreset(preset)} > -
-
- {formatNumber(displayValue)} -
- {hasDiscount && ( -
- {getDiscountLabel(discount)} +
+
+
+ {formatNumber(displayValue)}
- )} -
-
- Pay {formatCurrency(actualPrice)} - {hasDiscount && savedAmount > 0 && ( - - {' '} - • Save {formatCurrency(savedAmount)} + {hasDiscount && savedAmount > 0 && ( +
+ {t('Discount')} {formatCnyAmount(savedAmount)} +
+ )} +
+
+ {t('Pay')} + + {formatCnyAmount(actualPrice)} +
+ {hasDiscount && savedAmount > 0 && ( +
+ {t('Original price')} + + {formatCnyAmount(actualPrice + savedAmount)} + +
)}
@@ -282,7 +288,7 @@ export function RechargeFormCard({ ) : ( - {formatCurrency(paymentAmount)} + {formatCnyAmount(paymentAmount)} )}
diff --git a/web/default/src/features/wallet/index.tsx b/web/default/src/features/wallet/index.tsx index ad09d0129e9b..972bddd2f829 100644 --- a/web/default/src/features/wallet/index.tsx +++ b/web/default/src/features/wallet/index.tsx @@ -8,7 +8,6 @@ import { AffiliateRewardsCard } from './components/affiliate-rewards-card' import { BillingHistoryDialog } from './components/dialogs/billing-history-dialog' import { CreemConfirmDialog } from './components/dialogs/creem-confirm-dialog' import { PaymentConfirmDialog } from './components/dialogs/payment-confirm-dialog' -import { TransferDialog } from './components/dialogs/transfer-dialog' import { RechargeFormCard } from './components/recharge-form-card' import { SubscriptionPlansCard } from './components/subscription-plans-card' import { WalletStatsCard } from './components/wallet-stats-card' @@ -16,7 +15,6 @@ import { DEFAULT_DISCOUNT_RATE } from './constants' import { useTopupInfo, usePayment, - useAffiliate, useRedemption, useCreemPayment, useWaffoPayment, @@ -48,7 +46,6 @@ export function Wallet(props: WalletProps) { useState() const [paymentLoading, setPaymentLoading] = useState(null) const [confirmDialogOpen, setConfirmDialogOpen] = useState(false) - const [transferDialogOpen, setTransferDialogOpen] = useState(false) const [billingDialogOpen, setBillingDialogOpen] = useState(false) const [redemptionCode, setRedemptionCode] = useState('') const [creemDialogOpen, setCreemDialogOpen] = useState(false) @@ -72,12 +69,6 @@ export function Wallet(props: WalletProps) { calculatePaymentAmount, processPayment, } = usePayment() - const { - affiliateLink, - loading: affiliateLoading, - transferQuota, - transferring, - } = useAffiliate() const { redeeming, redeemCode } = useRedemption() const { processing: creemProcessing, processCreemPayment } = useCreemPayment() const { processWaffoPayment } = useWaffoPayment() @@ -188,15 +179,6 @@ export function Wallet(props: WalletProps) { } } - // Handle transfer - const handleTransfer = async (amount: number) => { - const success = await transferQuota(amount) - if (success) { - await fetchUser() - } - return success - } - // Handle Creem product selection const handleCreemProductSelect = (product: CreemProduct) => { setSelectedCreemProduct(product) @@ -277,10 +259,9 @@ export function Wallet(props: WalletProps) { {/* Right Column - Affiliate & Subscriptions */}
setTransferDialogOpen(true)} - loading={affiliateLoading} + loading={topupLoading} + topupInfo={topupInfo} + priceRatio={(status?.price as number) || 1} />
@@ -303,14 +284,6 @@ export function Wallet(props: WalletProps) { usdExchangeRate={effectiveUsdExchangeRate} /> - - ({ + title: t(link.titleKey), + url: link.url, + icon: link.icon, + newTab: link.newTab, + })), { title: t('Profile'), url: '/profile', diff --git a/web/default/src/hooks/use-top-nav-links.ts b/web/default/src/hooks/use-top-nav-links.ts index 335232c52c83..6e0b296c9d7d 100644 --- a/web/default/src/hooks/use-top-nav-links.ts +++ b/web/default/src/hooks/use-top-nav-links.ts @@ -2,6 +2,7 @@ import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { useAuthStore } from '@/stores/auth-store' import { useStatus } from '@/hooks/use-status' +import { customHeaderNavModuleDefaults, customTopNavLinks } from '@/custom/site' export type TopNavLink = { title: string @@ -17,6 +18,7 @@ const DEFAULT_HEADER_NAV_MODULES = { pricing: { enabled: true, requireAuth: false }, docs: true, about: true, + ...customHeaderNavModuleDefaults, } /** @@ -74,6 +76,18 @@ export function useTopNavLinks(): TopNavLink[] { links.push({ title: t('Pricing'), href: '/pricing', disabled }) } + customTopNavLinks.forEach((link) => { + if (link.moduleKey && modules?.[link.moduleKey] === false) { + return + } + + links.push({ + title: t(link.titleKey), + href: link.href, + external: link.external, + }) + }) + // Docs (supports external links) if (modules?.docs !== false) { if (docsLink) { diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index 0c5fc96be8fa..5b9d63730b6d 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -1980,6 +1980,8 @@ "Model not found": "Model not found", "Model Price": "Model Price", "Model Price Not Configured": "Model Price Not Configured", + "Model Square": "Model Square", + "Status Monitor": "Status Monitor", "Model Pricing": "Model Pricing", "Model pull failed: {{msg}}": "Model pull failed: {{msg}}", "Model ratio": "Model ratio", @@ -3673,6 +3675,12 @@ "Your Telegram Bot Token": "Your Telegram Bot Token", "Your Turnstile secret key": "Your Turnstile secret key", "Your Turnstile site key": "Your Turnstile site key", + "Original price": "Original price", + "Pricing Information": "Pricing Information", + "Recharge rate and discount tiers": "Recharge rate and discount tiers", + "Recharge Rate": "Recharge Rate", + "Recharge Discounts": "Recharge Discounts", + "No recharge discounts configured": "No recharge discounts configured", "Zhipu": "Zhipu", "Zhipu V4": "Zhipu V4", "Zoom": "Zoom" diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json index d0f78d4e392d..f715a9ce4eb7 100644 --- a/web/default/src/i18n/locales/fr.json +++ b/web/default/src/i18n/locales/fr.json @@ -1980,6 +1980,8 @@ "Model not found": "Modèle introuvable", "Model Price": "Prix du modèle", "Model Price Not Configured": "Prix du modèle non configuré", + "Model Square": "Place des modèles", + "Status Monitor": "Surveillance du statut", "Model Pricing": "Tarification des modèles", "Model pull failed: {{msg}}": "Échec du téléchargement du modèle : {{msg}}", "Model ratio": "Ratio modèle", @@ -3673,6 +3675,12 @@ "Your Telegram Bot Token": "Votre Jeton de Bot Telegram", "Your Turnstile secret key": "Votre clé secrète Turnstile", "Your Turnstile site key": "Votre clé de site Turnstile", + "Original price": "Prix initial", + "Pricing Information": "Informations tarifaires", + "Recharge rate and discount tiers": "Taux de recharge et paliers de remise", + "Recharge Rate": "Taux de recharge", + "Recharge Discounts": "Remises de recharge", + "No recharge discounts configured": "Aucune remise de recharge configurée", "Zhipu": "Zhipu", "Zhipu V4": "Zhipu V4", "Zoom": "Zoom" diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index 0027340c056f..943f9d7af2ff 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -1980,6 +1980,8 @@ "Model not found": "モデルが見つかりません", "Model Price": "モデル価格", "Model Price Not Configured": "モデル価格が未設定", + "Model Square": "モデル広場", + "Status Monitor": "ステータス監視", "Model Pricing": "モデル料金", "Model pull failed: {{msg}}": "モデルのプルに失敗しました: __ PH_0 __", "Model ratio": "モデル倍率", @@ -3673,6 +3675,12 @@ "Your Telegram Bot Token": "あなたのTelegramボットトークン", "Your Turnstile secret key": "あなたのTurnstileシークレットキー", "Your Turnstile site key": "あなたのTurnstileサイトキー", + "Original price": "元の価格", + "Pricing Information": "料金情報", + "Recharge rate and discount tiers": "チャージ率と割引段階", + "Recharge Rate": "チャージ率", + "Recharge Discounts": "チャージ割引", + "No recharge discounts configured": "チャージ割引は設定されていません", "Zhipu": "Zhipu", "Zhipu V4": "Zhipu V 4", "Zoom": "ズーム" diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index 6affe7e7b24a..0c9d77b7fe39 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -1980,6 +1980,8 @@ "Model not found": "Модель не найдена", "Model Price": "Цена модели", "Model Price Not Configured": "Цена модели не настроена", + "Model Square": "Площадка моделей", + "Status Monitor": "Мониторинг статуса", "Model Pricing": "Цены на модели", "Model pull failed: {{msg}}": "Ошибка тяги модели: {{msg}}", "Model ratio": "Коэффициент модели", @@ -3673,6 +3675,12 @@ "Your Telegram Bot Token": "Ваш токен Telegram-бота", "Your Turnstile secret key": "Секретный ключ Turnstile", "Your Turnstile site key": "Ключ сайта Turnstile", + "Original price": "Исходная цена", + "Pricing Information": "Информация о ценах", + "Recharge rate and discount tiers": "Курс пополнения и уровни скидок", + "Recharge Rate": "Курс пополнения", + "Recharge Discounts": "Скидки на пополнение", + "No recharge discounts configured": "Скидки на пополнение не настроены", "Zhipu": "Zhipu", "Zhipu V4": "Zhipu V4", "Zoom": "Zoom" diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index ce27d36bc453..8c806110dff9 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -1980,6 +1980,8 @@ "Model not found": "Không tìm thấy mô hình", "Model Price": "Giá mô hình", "Model Price Not Configured": "Giá mô hình chưa được cấu hình", + "Model Square": "Quảng trường mô hình", + "Status Monitor": "Giám sát trạng thái", "Model Pricing": "Bảng giá mô hình", "Model pull failed: {{msg}}": "Tải mô hình thất bại: {{msg}}", "Model ratio": "Tỷ lệ mô hình", @@ -3673,6 +3675,12 @@ "Your Telegram Bot Token": "Mã thông báo bot Telegram của bạn", "Your Turnstile secret key": "Khóa bí mật Turnstile của bạn", "Your Turnstile site key": "Khóa site Turnstile của bạn", + "Original price": "Giá gốc", + "Pricing Information": "Thông tin giá", + "Recharge rate and discount tiers": "Tỷ lệ nạp và các mức giảm giá", + "Recharge Rate": "Tỷ lệ nạp", + "Recharge Discounts": "Ưu đãi nạp tiền", + "No recharge discounts configured": "Chưa cấu hình ưu đãi nạp tiền", "Zhipu": "Zhipu", "Zhipu V4": "Zhipu V4", "Zoom": "Zoom" diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index 5b87cc57dd19..1f47c6c69971 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -1980,6 +1980,8 @@ "Model not found": "模型未找到", "Model Price": "模型价格", "Model Price Not Configured": "模型价格未配置", + "Model Square": "模型广场", + "Status Monitor": "状态监控", "Model Pricing": "模型定价", "Model pull failed: {{msg}}": "模型拉取失败:{{msg}}", "Model ratio": "模型倍率", @@ -3673,6 +3675,12 @@ "Your Telegram Bot Token": "您的 Telegram 机器人令牌", "Your Turnstile secret key": "您的 Turnstile 密钥", "Your Turnstile site key": "您的 Turnstile 站点密钥", + "Original price": "原价", + "Pricing Information": "定价信息", + "Recharge rate and discount tiers": "充值比例与优惠档位", + "Recharge Rate": "充值比例", + "Recharge Discounts": "充值优惠", + "No recharge discounts configured": "暂无充值优惠配置", "Zhipu": "智谱", "Zhipu V4": "智谱 V4", "Zoom": "缩放" From 830fddf3d50232bace754f41a55adcf19f5dd253 Mon Sep 17 00:00:00 2001 From: Micah-Zheng <102610064+Micah-Zheng@users.noreply.github.com> Date: Thu, 30 Apr 2026 02:53:29 +0800 Subject: [PATCH 02/20] fix: respect auto group default in API key form --- .../keys/components/api-key-group-combobox.tsx | 7 ++++++- .../keys/components/api-keys-mutate-drawer.tsx | 11 +++++++---- web/default/src/features/keys/constants.ts | 2 +- web/default/src/features/keys/lib/api-key-form.ts | 10 ++++++++++ web/default/src/features/keys/lib/index.ts | 1 + 5 files changed, 25 insertions(+), 6 deletions(-) diff --git a/web/default/src/features/keys/components/api-key-group-combobox.tsx b/web/default/src/features/keys/components/api-key-group-combobox.tsx index 0b0e38fea221..968f7a6f3dbb 100644 --- a/web/default/src/features/keys/components/api-key-group-combobox.tsx +++ b/web/default/src/features/keys/components/api-key-group-combobox.tsx @@ -128,7 +128,12 @@ export function ApiKeyGroupCombobox({ - + event.stopPropagation()} + onTouchMove={(event) => event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + > ({ resolver: zodResolver(apiKeyFormSchema), - defaultValues: API_KEY_FORM_DEFAULT_VALUES, + defaultValues: getApiKeyFormDefaultValues(defaultUseAutoGroup), }) // Load existing data when updating @@ -156,9 +159,9 @@ export function ApiKeysMutateDrawer({ }) } else if (open && !isUpdate) { // For create, reset to defaults - form.reset(API_KEY_FORM_DEFAULT_VALUES) + form.reset(getApiKeyFormDefaultValues(defaultUseAutoGroup)) } - }, [open, isUpdate, currentRow, form]) + }, [open, isUpdate, currentRow, form, defaultUseAutoGroup]) const onSubmit = async (data: ApiKeyFormValues) => { setIsSubmitting(true) diff --git a/web/default/src/features/keys/constants.ts b/web/default/src/features/keys/constants.ts index 435d808df9b6..632b3aa79b65 100644 --- a/web/default/src/features/keys/constants.ts +++ b/web/default/src/features/keys/constants.ts @@ -56,7 +56,7 @@ export const API_KEY_STATUS_OPTIONS = Object.values(API_KEY_STATUSES).map( // Default Values // ============================================================================ -export const DEFAULT_GROUP = 'auto' as const +export const DEFAULT_GROUP = '' as const // ============================================================================ // Error Messages (i18n keys: use t(ERROR_MESSAGES.xxx) when displaying) diff --git a/web/default/src/features/keys/lib/api-key-form.ts b/web/default/src/features/keys/lib/api-key-form.ts index 1c4ef0060766..60f4c6787cd4 100644 --- a/web/default/src/features/keys/lib/api-key-form.ts +++ b/web/default/src/features/keys/lib/api-key-form.ts @@ -37,6 +37,16 @@ export const API_KEY_FORM_DEFAULT_VALUES: ApiKeyFormValues = { tokenCount: 1, } +export function getApiKeyFormDefaultValues( + defaultUseAutoGroup: boolean +): ApiKeyFormValues { + return { + ...API_KEY_FORM_DEFAULT_VALUES, + group: defaultUseAutoGroup ? 'auto' : DEFAULT_GROUP, + cross_group_retry: defaultUseAutoGroup, + } +} + // ============================================================================ // Form Data Transformation // ============================================================================ diff --git a/web/default/src/features/keys/lib/index.ts b/web/default/src/features/keys/lib/index.ts index 1f9301b2096d..e0fb9a9c7f4a 100644 --- a/web/default/src/features/keys/lib/index.ts +++ b/web/default/src/features/keys/lib/index.ts @@ -5,6 +5,7 @@ export { apiKeyFormSchema, type ApiKeyFormValues, API_KEY_FORM_DEFAULT_VALUES, + getApiKeyFormDefaultValues, transformFormDataToPayload, transformApiKeyToFormDefaults, } from './api-key-form' From 6d35ff655bd073031c1726272453b4862a5494d8 Mon Sep 17 00:00:00 2001 From: Micah-Zheng <102610064+Micah-Zheng@users.noreply.github.com> Date: Thu, 30 Apr 2026 03:44:28 +0800 Subject: [PATCH 03/20] customize API key group selection rules --- .../keys/components/api-keys-mutate-drawer.tsx | 16 ++++++++++------ .../src/features/keys/lib/api-key-form.ts | 2 +- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/web/default/src/features/keys/components/api-keys-mutate-drawer.tsx b/web/default/src/features/keys/components/api-keys-mutate-drawer.tsx index b8138aa5b7dd..b6fdc0f87d11 100644 --- a/web/default/src/features/keys/components/api-keys-mutate-drawer.tsx +++ b/web/default/src/features/keys/components/api-keys-mutate-drawer.tsx @@ -125,17 +125,21 @@ export function ApiKeysMutateDrawer({ const models = modelsData?.data || [] const groupsRaw = groupsData?.data || {} - const groups: ApiKeyGroupOption[] = Object.entries(groupsRaw).map( - ([key, info]) => ({ + const groups: ApiKeyGroupOption[] = Object.entries(groupsRaw) + .filter(([key, info]) => { + if (key === 'auto') { + return defaultUseAutoGroup + } + return info.desc !== '用户分组' + }) + .map(([key, info]) => ({ value: key, label: key, desc: info.desc || key, ratio: info.ratio, - }) - ) + })) - // Add auto group if configured - if (!groups.some((g) => g.value === 'auto')) { + if (defaultUseAutoGroup && !groups.some((g) => g.value === 'auto')) { groups.unshift({ value: 'auto', label: 'auto', diff --git a/web/default/src/features/keys/lib/api-key-form.ts b/web/default/src/features/keys/lib/api-key-form.ts index 60f4c6787cd4..05b30d020e61 100644 --- a/web/default/src/features/keys/lib/api-key-form.ts +++ b/web/default/src/features/keys/lib/api-key-form.ts @@ -14,7 +14,7 @@ export const apiKeyFormSchema = z.object({ unlimited_quota: z.boolean(), model_limits: z.array(z.string()), allow_ips: z.string().optional(), - group: z.string().optional(), + group: z.string().min(1, 'Group is required'), cross_group_retry: z.boolean().optional(), tokenCount: z.number().min(1).optional(), }) From 584a1a8baf9a5088f34e60df2e2d0e1c8838ba25 Mon Sep 17 00:00:00 2001 From: Micah-Zheng <102610064+Micah-Zheng@users.noreply.github.com> Date: Thu, 30 Apr 2026 03:53:32 +0800 Subject: [PATCH 04/20] customize API key group validation highlight --- .../keys/components/api-key-group-combobox.tsx | 16 ++++++++++++++-- .../keys/components/api-keys-mutate-drawer.tsx | 5 +++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/web/default/src/features/keys/components/api-key-group-combobox.tsx b/web/default/src/features/keys/components/api-key-group-combobox.tsx index 968f7a6f3dbb..42b3aa79eab7 100644 --- a/web/default/src/features/keys/components/api-key-group-combobox.tsx +++ b/web/default/src/features/keys/components/api-key-group-combobox.tsx @@ -31,6 +31,7 @@ type ApiKeyGroupComboboxProps = { onValueChange: (value: string) => void placeholder?: string disabled?: boolean + error?: boolean } function formatGroupRatio(ratio: ApiKeyGroupOption['ratio'], ratioLabel: string) { @@ -74,6 +75,7 @@ export function ApiKeyGroupCombobox({ onValueChange, placeholder, disabled, + error, }: ApiKeyGroupComboboxProps) { const { t } = useTranslation() const [open, setOpen] = useState(false) @@ -109,12 +111,22 @@ export function ApiKeyGroupCombobox({ variant='outline' role='combobox' aria-expanded={open} + aria-invalid={error} disabled={disabled} - className='border-input bg-muted/40 h-auto min-h-20 w-full justify-between gap-3 rounded-lg px-4 py-3 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]' + className={cn( + 'border-input bg-muted/40 h-auto min-h-20 w-full justify-between gap-3 rounded-lg px-4 py-3 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]', + error && + 'border-destructive bg-destructive/5 ring-destructive/20 hover:bg-destructive/10 data-[state=open]:border-destructive data-[state=open]:ring-destructive/25' + )} > - + {selectedOption?.value || placeholder || t('Select a group')} {selectedOption?.desc && ( diff --git a/web/default/src/features/keys/components/api-keys-mutate-drawer.tsx b/web/default/src/features/keys/components/api-keys-mutate-drawer.tsx index b6fdc0f87d11..2934b64554b4 100644 --- a/web/default/src/features/keys/components/api-keys-mutate-drawer.tsx +++ b/web/default/src/features/keys/components/api-keys-mutate-drawer.tsx @@ -302,7 +302,7 @@ export function ApiKeysMutateDrawer({ ( + render={({ field, fieldState }) => ( {t('Group')} @@ -311,9 +311,10 @@ export function ApiKeysMutateDrawer({ value={field.value} onValueChange={field.onChange} placeholder={t('Select a group')} + error={!!fieldState.error} /> - + )} /> From d89f78ef399be0a48f7d56ad4c874176e5f221be Mon Sep 17 00:00:00 2001 From: Micah-Zheng <102610064+Micah-Zheng@users.noreply.github.com> Date: Thu, 30 Apr 2026 07:19:42 +0800 Subject: [PATCH 05/20] customize navigation query parameters --- .../layout/components/nav-group.tsx | 53 +++++++++++++--- .../components/layout/components/top-nav.tsx | 61 ++++++++++++++----- web/default/src/custom/site.ts | 6 +- web/default/src/hooks/use-top-nav-links.ts | 4 +- 4 files changed, 96 insertions(+), 28 deletions(-) diff --git a/web/default/src/components/layout/components/nav-group.tsx b/web/default/src/components/layout/components/nav-group.tsx index e3b4b2d390d4..c256a77a57f4 100644 --- a/web/default/src/components/layout/components/nav-group.tsx +++ b/web/default/src/components/layout/components/nav-group.tsx @@ -35,6 +35,15 @@ import { } from '../types' import { ChatPresetsItem } from './chat-presets-item' +function splitUrl(url: string) { + const [pathname, search = ''] = url.split('?') + + return { + pathname, + search, + } +} + /** * Sidebar navigation group component * Renders a group of navigation items, supporting regular links and collapsible submenus @@ -124,9 +133,23 @@ function SidebarMenuLink({ item, href }: { item: NavLink; href: string }) { {content} ) : ( - setOpenMobile(false)}> - {content} - + (() => { + const { pathname, search } = splitUrl(item.url) + + return ( + setOpenMobile(false)} + > + {content} + + ) + })() )} @@ -181,11 +204,25 @@ function SidebarMenuCollapsible({ asChild isActive={checkIsActive(href, subItem)} > - setOpenMobile(false)}> - {subItem.icon && } - {subItem.title} - {subItem.badge && {subItem.badge}} - + {(() => { + const { pathname, search } = splitUrl(subItem.url) + + return ( + setOpenMobile(false)} + > + {subItem.icon && } + {subItem.title} + {subItem.badge && {subItem.badge}} + + ) + })()} ))} diff --git a/web/default/src/components/layout/components/top-nav.tsx b/web/default/src/components/layout/components/top-nav.tsx index 953cac233300..061bd47b50a9 100644 --- a/web/default/src/components/layout/components/top-nav.tsx +++ b/web/default/src/components/layout/components/top-nav.tsx @@ -15,6 +15,15 @@ type TopNavProps = React.HTMLAttributes & { links: TopNavLink[] } +function splitHref(href: string) { + const [pathname, search = ''] = href.split('?') + + return { + pathname, + search, + } +} + /** * 顶部导航栏组件 * 在大屏幕显示水平导航,在小屏幕显示下拉菜单 @@ -56,13 +65,24 @@ export function TopNav({ className, links, ...props }: TopNavProps) { {title} ) : ( - - {title} - + (() => { + const { pathname, search } = splitHref(href) + + return ( + + {title} + + ) + })() )} ) @@ -91,14 +111,25 @@ export function TopNav({ className, links, ...props }: TopNavProps) { {title} ) : ( - - {title} - + (() => { + const { pathname, search } = splitHref(href) + + return ( + + {title} + + ) + })() ) )} diff --git a/web/default/src/custom/site.ts b/web/default/src/custom/site.ts index 606550c14810..45d917f41646 100644 --- a/web/default/src/custom/site.ts +++ b/web/default/src/custom/site.ts @@ -17,13 +17,13 @@ type CustomTopNavLink = { export const customSidebarLinks: CustomSidebarLink[] = [ { titleKey: 'Model Square', - url: '/pricing', + url: '/pricing?view=table', icon: Store, newTab: true, }, { titleKey: 'Status Monitor', - url: 'https://status.tcp.red', + url: 'https://status.tcp.red?sort=serviceType_desc', icon: Activity, newTab: true, }, @@ -32,7 +32,7 @@ export const customSidebarLinks: CustomSidebarLink[] = [ export const customTopNavLinks: CustomTopNavLink[] = [ { titleKey: 'Status Monitor', - href: 'https://status.tcp.red', + href: 'https://status.tcp.red?sort=serviceType_desc', external: true, moduleKey: 'statusMonitor', }, diff --git a/web/default/src/hooks/use-top-nav-links.ts b/web/default/src/hooks/use-top-nav-links.ts index 6e0b296c9d7d..8c2c41a9d24c 100644 --- a/web/default/src/hooks/use-top-nav-links.ts +++ b/web/default/src/hooks/use-top-nav-links.ts @@ -1,8 +1,8 @@ import { useMemo } from 'react' +import { customHeaderNavModuleDefaults, customTopNavLinks } from '@/custom/site' import { useTranslation } from 'react-i18next' import { useAuthStore } from '@/stores/auth-store' import { useStatus } from '@/hooks/use-status' -import { customHeaderNavModuleDefaults, customTopNavLinks } from '@/custom/site' export type TopNavLink = { title: string @@ -73,7 +73,7 @@ export function useTopNavLinks(): TopNavLink[] { const pricing = modules?.pricing if (pricing && typeof pricing === 'object' && pricing.enabled) { const disabled = pricing.requireAuth && !isAuthed - links.push({ title: t('Pricing'), href: '/pricing', disabled }) + links.push({ title: t('Pricing'), href: '/pricing?view=table', disabled }) } customTopNavLinks.forEach((link) => { From 63a1db70b883a7bfc17608cfe5c2ec046f45e4ab Mon Sep 17 00:00:00 2001 From: Micah-Zheng <102610064+Micah-Zheng@users.noreply.github.com> Date: Thu, 30 Apr 2026 09:42:57 +0800 Subject: [PATCH 06/20] embed status monitor and model square in console --- web/default/src/custom/site.ts | 9 +- .../pricing/components/model-details.tsx | 98 +++++++------- web/default/src/features/pricing/index.tsx | 120 +++++++++--------- web/default/src/hooks/use-top-nav-links.ts | 6 +- web/default/src/routeTree.gen.ts | 67 ++++++++++ .../model-square/$modelId/index.tsx | 36 ++++++ .../_authenticated/model-square/index.tsx | 37 ++++++ .../routes/_authenticated/status-monitor.tsx | 25 ++++ 8 files changed, 280 insertions(+), 118 deletions(-) create mode 100644 web/default/src/routes/_authenticated/model-square/$modelId/index.tsx create mode 100644 web/default/src/routes/_authenticated/model-square/index.tsx create mode 100644 web/default/src/routes/_authenticated/status-monitor.tsx diff --git a/web/default/src/custom/site.ts b/web/default/src/custom/site.ts index 45d917f41646..c9c387630d38 100644 --- a/web/default/src/custom/site.ts +++ b/web/default/src/custom/site.ts @@ -17,23 +17,20 @@ type CustomTopNavLink = { export const customSidebarLinks: CustomSidebarLink[] = [ { titleKey: 'Model Square', - url: '/pricing?view=table', + url: '/model-square?view=table', icon: Store, - newTab: true, }, { titleKey: 'Status Monitor', - url: 'https://status.tcp.red?sort=serviceType_desc', + url: '/status-monitor', icon: Activity, - newTab: true, }, ] export const customTopNavLinks: CustomTopNavLink[] = [ { titleKey: 'Status Monitor', - href: 'https://status.tcp.red?sort=serviceType_desc', - external: true, + href: '/status-monitor', moduleKey: 'statusMonitor', }, ] diff --git a/web/default/src/features/pricing/components/model-details.tsx b/web/default/src/features/pricing/components/model-details.tsx index 66ff199883cc..54d64686a256 100644 --- a/web/default/src/features/pricing/components/model-details.tsx +++ b/web/default/src/features/pricing/components/model-details.tsx @@ -465,8 +465,8 @@ function GroupPricingSection(props: { type ModelDetailsProps = { embedded?: boolean - routeFrom?: '/pricing/$modelId/' - backPath?: '/pricing' + routeFrom?: '/pricing/$modelId/' | '/_authenticated/model-square/$modelId/' + backPath?: '/pricing' | '/model-square' } export function ModelDetails(props: ModelDetailsProps) { @@ -532,9 +532,7 @@ export function ModelDetails(props: ModelDetailsProps) { if (!model) { return wrapContent(
-

- {t('Model not found')} -

+

{t('Model not found')}

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

@@ -547,53 +545,51 @@ export function ModelDetails(props: ModelDetailsProps) { return wrapContent(
- - - - - - - ) || {} - } - /> - - {model.billing_mode === 'tiered_expr' && model.billing_expr && ( -
- -
- )} + + + + + + + ) || + {} + } + /> + + {model.billing_mode === 'tiered_expr' && model.billing_expr && ( +
+ +
+ )} - +
) } diff --git a/web/default/src/features/pricing/index.tsx b/web/default/src/features/pricing/index.tsx index 36bc5ccd2a66..9bbbf60d7789 100644 --- a/web/default/src/features/pricing/index.tsx +++ b/web/default/src/features/pricing/index.tsx @@ -18,8 +18,8 @@ import { usePricingData } from './hooks/use-pricing-data' type PricingProps = { embedded?: boolean - routeTo?: '/pricing' - detailPath?: '/pricing/$modelId' + routeTo?: '/pricing' | '/model-square' + detailPath?: '/pricing/$modelId' | '/model-square/$modelId' } export function Pricing(props: PricingProps) { @@ -121,68 +121,68 @@ export function Pricing(props: PricingProps) {
- + - + - {filteredModels.length > 0 ? ( - isMobile || viewMode === VIEW_MODES.LIST ? ( - - ) : ( - - ) + {filteredModels.length > 0 ? ( + isMobile || viewMode === VIEW_MODES.LIST ? ( + ) : ( - - )} + ) + ) : ( + + )}
) diff --git a/web/default/src/hooks/use-top-nav-links.ts b/web/default/src/hooks/use-top-nav-links.ts index 8c2c41a9d24c..98f5180b5981 100644 --- a/web/default/src/hooks/use-top-nav-links.ts +++ b/web/default/src/hooks/use-top-nav-links.ts @@ -73,7 +73,11 @@ export function useTopNavLinks(): TopNavLink[] { const pricing = modules?.pricing if (pricing && typeof pricing === 'object' && pricing.enabled) { const disabled = pricing.requireAuth && !isAuthed - links.push({ title: t('Pricing'), href: '/pricing?view=table', disabled }) + links.push({ + title: t('Pricing'), + href: '/model-square?view=table', + disabled, + }) } customTopNavLinks.forEach((link) => { diff --git a/web/default/src/routeTree.gen.ts b/web/default/src/routeTree.gen.ts index fd78de1527c6..0a178e6d1df6 100644 --- a/web/default/src/routeTree.gen.ts +++ b/web/default/src/routeTree.gen.ts @@ -18,6 +18,7 @@ import { Route as SetupIndexRouteImport } from './routes/setup/index' import { Route as PricingIndexRouteImport } from './routes/pricing/index' import { Route as AboutIndexRouteImport } from './routes/about/index' import { Route as OauthProviderRouteImport } from './routes/oauth/$provider' +import { Route as AuthenticatedStatusMonitorRouteImport } from './routes/_authenticated/status-monitor' import { Route as AuthenticatedChat2linkRouteImport } from './routes/_authenticated/chat2link' import { Route as errors503RouteImport } from './routes/(errors)/503' import { Route as errors500RouteImport } from './routes/(errors)/500' @@ -41,6 +42,7 @@ import { Route as AuthenticatedRedemptionCodesIndexRouteImport } from './routes/ import { Route as AuthenticatedProfileIndexRouteImport } from './routes/_authenticated/profile/index' import { Route as AuthenticatedPlaygroundIndexRouteImport } from './routes/_authenticated/playground/index' import { Route as AuthenticatedModelsIndexRouteImport } from './routes/_authenticated/models/index' +import { Route as AuthenticatedModelSquareIndexRouteImport } from './routes/_authenticated/model-square/index' import { Route as AuthenticatedKeysIndexRouteImport } from './routes/_authenticated/keys/index' import { Route as AuthenticatedDashboardIndexRouteImport } from './routes/_authenticated/dashboard/index' import { Route as AuthenticatedChannelsIndexRouteImport } from './routes/_authenticated/channels/index' @@ -57,6 +59,7 @@ import { Route as AuthenticatedSystemSettingsIntegrationsIndexRouteImport } from import { Route as AuthenticatedSystemSettingsGeneralIndexRouteImport } from './routes/_authenticated/system-settings/general/index' import { Route as AuthenticatedSystemSettingsContentIndexRouteImport } from './routes/_authenticated/system-settings/content/index' import { Route as AuthenticatedSystemSettingsAuthIndexRouteImport } from './routes/_authenticated/system-settings/auth/index' +import { Route as AuthenticatedModelSquareModelIdIndexRouteImport } from './routes/_authenticated/model-square/$modelId/index' import { Route as AuthenticatedSystemSettingsRequestLimitsSectionRouteImport } from './routes/_authenticated/system-settings/request-limits/$section' import { Route as AuthenticatedSystemSettingsModelsSectionRouteImport } from './routes/_authenticated/system-settings/models/$section' import { Route as AuthenticatedSystemSettingsMaintenanceSectionRouteImport } from './routes/_authenticated/system-settings/maintenance/$section' @@ -108,6 +111,12 @@ const OauthProviderRoute = OauthProviderRouteImport.update({ path: '/oauth/$provider', getParentRoute: () => rootRouteImport, } as any) +const AuthenticatedStatusMonitorRoute = + AuthenticatedStatusMonitorRouteImport.update({ + id: '/status-monitor', + path: '/status-monitor', + getParentRoute: () => AuthenticatedRouteRoute, + } as any) const AuthenticatedChat2linkRoute = AuthenticatedChat2linkRouteImport.update({ id: '/chat2link', path: '/chat2link', @@ -232,6 +241,12 @@ const AuthenticatedModelsIndexRoute = path: '/models/', getParentRoute: () => AuthenticatedRouteRoute, } as any) +const AuthenticatedModelSquareIndexRoute = + AuthenticatedModelSquareIndexRouteImport.update({ + id: '/model-square/', + path: '/model-square/', + getParentRoute: () => AuthenticatedRouteRoute, + } as any) const AuthenticatedKeysIndexRoute = AuthenticatedKeysIndexRouteImport.update({ id: '/keys/', path: '/keys/', @@ -325,6 +340,12 @@ const AuthenticatedSystemSettingsAuthIndexRoute = path: '/auth/', getParentRoute: () => AuthenticatedSystemSettingsRouteRoute, } as any) +const AuthenticatedModelSquareModelIdIndexRoute = + AuthenticatedModelSquareModelIdIndexRouteImport.update({ + id: '/model-square/$modelId/', + path: '/model-square/$modelId/', + getParentRoute: () => AuthenticatedRouteRoute, + } as any) const AuthenticatedSystemSettingsRequestLimitsSectionRoute = AuthenticatedSystemSettingsRequestLimitsSectionRouteImport.update({ id: '/request-limits/$section', @@ -385,6 +406,7 @@ export interface FileRoutesByFullPath { '/500': typeof errors500Route '/503': typeof errors503Route '/chat2link': typeof AuthenticatedChat2linkRoute + '/status-monitor': typeof AuthenticatedStatusMonitorRoute '/oauth/$provider': typeof OauthProviderRoute '/about/': typeof AboutIndexRoute '/pricing/': typeof PricingIndexRoute @@ -398,6 +420,7 @@ export interface FileRoutesByFullPath { '/channels/': typeof AuthenticatedChannelsIndexRoute '/dashboard/': typeof AuthenticatedDashboardIndexRoute '/keys/': typeof AuthenticatedKeysIndexRoute + '/model-square/': typeof AuthenticatedModelSquareIndexRoute '/models/': typeof AuthenticatedModelsIndexRoute '/playground/': typeof AuthenticatedPlaygroundIndexRoute '/profile/': typeof AuthenticatedProfileIndexRoute @@ -415,6 +438,7 @@ export interface FileRoutesByFullPath { '/system-settings/maintenance/$section': typeof AuthenticatedSystemSettingsMaintenanceSectionRoute '/system-settings/models/$section': typeof AuthenticatedSystemSettingsModelsSectionRoute '/system-settings/request-limits/$section': typeof AuthenticatedSystemSettingsRequestLimitsSectionRoute + '/model-square/$modelId/': typeof AuthenticatedModelSquareModelIdIndexRoute '/system-settings/auth/': typeof AuthenticatedSystemSettingsAuthIndexRoute '/system-settings/content/': typeof AuthenticatedSystemSettingsContentIndexRoute '/system-settings/general/': typeof AuthenticatedSystemSettingsGeneralIndexRoute @@ -439,6 +463,7 @@ export interface FileRoutesByTo { '/500': typeof errors500Route '/503': typeof errors503Route '/chat2link': typeof AuthenticatedChat2linkRoute + '/status-monitor': typeof AuthenticatedStatusMonitorRoute '/oauth/$provider': typeof OauthProviderRoute '/about': typeof AboutIndexRoute '/pricing': typeof PricingIndexRoute @@ -452,6 +477,7 @@ export interface FileRoutesByTo { '/channels': typeof AuthenticatedChannelsIndexRoute '/dashboard': typeof AuthenticatedDashboardIndexRoute '/keys': typeof AuthenticatedKeysIndexRoute + '/model-square': typeof AuthenticatedModelSquareIndexRoute '/models': typeof AuthenticatedModelsIndexRoute '/playground': typeof AuthenticatedPlaygroundIndexRoute '/profile': typeof AuthenticatedProfileIndexRoute @@ -469,6 +495,7 @@ export interface FileRoutesByTo { '/system-settings/maintenance/$section': typeof AuthenticatedSystemSettingsMaintenanceSectionRoute '/system-settings/models/$section': typeof AuthenticatedSystemSettingsModelsSectionRoute '/system-settings/request-limits/$section': typeof AuthenticatedSystemSettingsRequestLimitsSectionRoute + '/model-square/$modelId': typeof AuthenticatedModelSquareModelIdIndexRoute '/system-settings/auth': typeof AuthenticatedSystemSettingsAuthIndexRoute '/system-settings/content': typeof AuthenticatedSystemSettingsContentIndexRoute '/system-settings/general': typeof AuthenticatedSystemSettingsGeneralIndexRoute @@ -497,6 +524,7 @@ export interface FileRoutesById { '/(errors)/500': typeof errors500Route '/(errors)/503': typeof errors503Route '/_authenticated/chat2link': typeof AuthenticatedChat2linkRoute + '/_authenticated/status-monitor': typeof AuthenticatedStatusMonitorRoute '/oauth/$provider': typeof OauthProviderRoute '/about/': typeof AboutIndexRoute '/pricing/': typeof PricingIndexRoute @@ -510,6 +538,7 @@ export interface FileRoutesById { '/_authenticated/channels/': typeof AuthenticatedChannelsIndexRoute '/_authenticated/dashboard/': typeof AuthenticatedDashboardIndexRoute '/_authenticated/keys/': typeof AuthenticatedKeysIndexRoute + '/_authenticated/model-square/': typeof AuthenticatedModelSquareIndexRoute '/_authenticated/models/': typeof AuthenticatedModelsIndexRoute '/_authenticated/playground/': typeof AuthenticatedPlaygroundIndexRoute '/_authenticated/profile/': typeof AuthenticatedProfileIndexRoute @@ -527,6 +556,7 @@ export interface FileRoutesById { '/_authenticated/system-settings/maintenance/$section': typeof AuthenticatedSystemSettingsMaintenanceSectionRoute '/_authenticated/system-settings/models/$section': typeof AuthenticatedSystemSettingsModelsSectionRoute '/_authenticated/system-settings/request-limits/$section': typeof AuthenticatedSystemSettingsRequestLimitsSectionRoute + '/_authenticated/model-square/$modelId/': typeof AuthenticatedModelSquareModelIdIndexRoute '/_authenticated/system-settings/auth/': typeof AuthenticatedSystemSettingsAuthIndexRoute '/_authenticated/system-settings/content/': typeof AuthenticatedSystemSettingsContentIndexRoute '/_authenticated/system-settings/general/': typeof AuthenticatedSystemSettingsGeneralIndexRoute @@ -554,6 +584,7 @@ export interface FileRouteTypes { | '/500' | '/503' | '/chat2link' + | '/status-monitor' | '/oauth/$provider' | '/about/' | '/pricing/' @@ -567,6 +598,7 @@ export interface FileRouteTypes { | '/channels/' | '/dashboard/' | '/keys/' + | '/model-square/' | '/models/' | '/playground/' | '/profile/' @@ -584,6 +616,7 @@ export interface FileRouteTypes { | '/system-settings/maintenance/$section' | '/system-settings/models/$section' | '/system-settings/request-limits/$section' + | '/model-square/$modelId/' | '/system-settings/auth/' | '/system-settings/content/' | '/system-settings/general/' @@ -608,6 +641,7 @@ export interface FileRouteTypes { | '/500' | '/503' | '/chat2link' + | '/status-monitor' | '/oauth/$provider' | '/about' | '/pricing' @@ -621,6 +655,7 @@ export interface FileRouteTypes { | '/channels' | '/dashboard' | '/keys' + | '/model-square' | '/models' | '/playground' | '/profile' @@ -638,6 +673,7 @@ export interface FileRouteTypes { | '/system-settings/maintenance/$section' | '/system-settings/models/$section' | '/system-settings/request-limits/$section' + | '/model-square/$modelId' | '/system-settings/auth' | '/system-settings/content' | '/system-settings/general' @@ -665,6 +701,7 @@ export interface FileRouteTypes { | '/(errors)/500' | '/(errors)/503' | '/_authenticated/chat2link' + | '/_authenticated/status-monitor' | '/oauth/$provider' | '/about/' | '/pricing/' @@ -678,6 +715,7 @@ export interface FileRouteTypes { | '/_authenticated/channels/' | '/_authenticated/dashboard/' | '/_authenticated/keys/' + | '/_authenticated/model-square/' | '/_authenticated/models/' | '/_authenticated/playground/' | '/_authenticated/profile/' @@ -695,6 +733,7 @@ export interface FileRouteTypes { | '/_authenticated/system-settings/maintenance/$section' | '/_authenticated/system-settings/models/$section' | '/_authenticated/system-settings/request-limits/$section' + | '/_authenticated/model-square/$modelId/' | '/_authenticated/system-settings/auth/' | '/_authenticated/system-settings/content/' | '/_authenticated/system-settings/general/' @@ -787,6 +826,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof OauthProviderRouteImport parentRoute: typeof rootRouteImport } + '/_authenticated/status-monitor': { + id: '/_authenticated/status-monitor' + path: '/status-monitor' + fullPath: '/status-monitor' + preLoaderRoute: typeof AuthenticatedStatusMonitorRouteImport + parentRoute: typeof AuthenticatedRouteRoute + } '/_authenticated/chat2link': { id: '/_authenticated/chat2link' path: '/chat2link' @@ -948,6 +994,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedModelsIndexRouteImport parentRoute: typeof AuthenticatedRouteRoute } + '/_authenticated/model-square/': { + id: '/_authenticated/model-square/' + path: '/model-square' + fullPath: '/model-square/' + preLoaderRoute: typeof AuthenticatedModelSquareIndexRouteImport + parentRoute: typeof AuthenticatedRouteRoute + } '/_authenticated/keys/': { id: '/_authenticated/keys/' path: '/keys' @@ -1060,6 +1113,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedSystemSettingsAuthIndexRouteImport parentRoute: typeof AuthenticatedSystemSettingsRouteRoute } + '/_authenticated/model-square/$modelId/': { + id: '/_authenticated/model-square/$modelId/' + path: '/model-square/$modelId' + fullPath: '/model-square/$modelId/' + preLoaderRoute: typeof AuthenticatedModelSquareModelIdIndexRouteImport + parentRoute: typeof AuthenticatedRouteRoute + } '/_authenticated/system-settings/request-limits/$section': { id: '/_authenticated/system-settings/request-limits/$section' path: '/request-limits/$section' @@ -1196,6 +1256,7 @@ const AuthenticatedSystemSettingsRouteRouteWithChildren = interface AuthenticatedRouteRouteChildren { AuthenticatedSystemSettingsRouteRoute: typeof AuthenticatedSystemSettingsRouteRouteWithChildren AuthenticatedChat2linkRoute: typeof AuthenticatedChat2linkRoute + AuthenticatedStatusMonitorRoute: typeof AuthenticatedStatusMonitorRoute AuthenticatedChatChatIdRoute: typeof AuthenticatedChatChatIdRoute AuthenticatedDashboardSectionRoute: typeof AuthenticatedDashboardSectionRoute AuthenticatedErrorsErrorRoute: typeof AuthenticatedErrorsErrorRoute @@ -1204,6 +1265,7 @@ interface AuthenticatedRouteRouteChildren { AuthenticatedChannelsIndexRoute: typeof AuthenticatedChannelsIndexRoute AuthenticatedDashboardIndexRoute: typeof AuthenticatedDashboardIndexRoute AuthenticatedKeysIndexRoute: typeof AuthenticatedKeysIndexRoute + AuthenticatedModelSquareIndexRoute: typeof AuthenticatedModelSquareIndexRoute AuthenticatedModelsIndexRoute: typeof AuthenticatedModelsIndexRoute AuthenticatedPlaygroundIndexRoute: typeof AuthenticatedPlaygroundIndexRoute AuthenticatedProfileIndexRoute: typeof AuthenticatedProfileIndexRoute @@ -1212,12 +1274,14 @@ interface AuthenticatedRouteRouteChildren { AuthenticatedUsageLogsIndexRoute: typeof AuthenticatedUsageLogsIndexRoute AuthenticatedUsersIndexRoute: typeof AuthenticatedUsersIndexRoute AuthenticatedWalletIndexRoute: typeof AuthenticatedWalletIndexRoute + AuthenticatedModelSquareModelIdIndexRoute: typeof AuthenticatedModelSquareModelIdIndexRoute } const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = { AuthenticatedSystemSettingsRouteRoute: AuthenticatedSystemSettingsRouteRouteWithChildren, AuthenticatedChat2linkRoute: AuthenticatedChat2linkRoute, + AuthenticatedStatusMonitorRoute: AuthenticatedStatusMonitorRoute, AuthenticatedChatChatIdRoute: AuthenticatedChatChatIdRoute, AuthenticatedDashboardSectionRoute: AuthenticatedDashboardSectionRoute, AuthenticatedErrorsErrorRoute: AuthenticatedErrorsErrorRoute, @@ -1226,6 +1290,7 @@ const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = { AuthenticatedChannelsIndexRoute: AuthenticatedChannelsIndexRoute, AuthenticatedDashboardIndexRoute: AuthenticatedDashboardIndexRoute, AuthenticatedKeysIndexRoute: AuthenticatedKeysIndexRoute, + AuthenticatedModelSquareIndexRoute: AuthenticatedModelSquareIndexRoute, AuthenticatedModelsIndexRoute: AuthenticatedModelsIndexRoute, AuthenticatedPlaygroundIndexRoute: AuthenticatedPlaygroundIndexRoute, AuthenticatedProfileIndexRoute: AuthenticatedProfileIndexRoute, @@ -1235,6 +1300,8 @@ const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = { AuthenticatedUsageLogsIndexRoute: AuthenticatedUsageLogsIndexRoute, AuthenticatedUsersIndexRoute: AuthenticatedUsersIndexRoute, AuthenticatedWalletIndexRoute: AuthenticatedWalletIndexRoute, + AuthenticatedModelSquareModelIdIndexRoute: + AuthenticatedModelSquareModelIdIndexRoute, } const AuthenticatedRouteRouteWithChildren = diff --git a/web/default/src/routes/_authenticated/model-square/$modelId/index.tsx b/web/default/src/routes/_authenticated/model-square/$modelId/index.tsx new file mode 100644 index 000000000000..41f25cd95c11 --- /dev/null +++ b/web/default/src/routes/_authenticated/model-square/$modelId/index.tsx @@ -0,0 +1,36 @@ +import z from 'zod' +import { createFileRoute } from '@tanstack/react-router' +import { AppHeader, Main } from '@/components/layout' +import { ModelDetails } from '@/features/pricing/components/model-details' + +const modelSquareDetailsSearchSchema = z.object({ + search: z.string().optional(), + sort: z.string().optional(), + vendor: z.string().optional(), + group: z.string().optional(), + quotaType: z.string().optional(), + endpointType: z.string().optional(), + tag: z.string().optional(), + tokenUnit: z.enum(['M', 'K']).optional(), + rechargePrice: z.boolean().optional(), +}) + +export const Route = createFileRoute('/_authenticated/model-square/$modelId/')({ + validateSearch: modelSquareDetailsSearchSchema, + component: ModelSquareDetails, +}) + +function ModelSquareDetails() { + return ( + <> + +
+ +
+ + ) +} diff --git a/web/default/src/routes/_authenticated/model-square/index.tsx b/web/default/src/routes/_authenticated/model-square/index.tsx new file mode 100644 index 000000000000..6d5ea4c1d44f --- /dev/null +++ b/web/default/src/routes/_authenticated/model-square/index.tsx @@ -0,0 +1,37 @@ +import z from 'zod' +import { createFileRoute } from '@tanstack/react-router' +import { AppHeader, Main } from '@/components/layout' +import { Pricing } from '@/features/pricing' + +const modelSquareSearchSchema = z.object({ + search: z.string().optional(), + sort: z.string().optional(), + vendor: z.string().optional(), + group: z.string().optional(), + quotaType: z.string().optional(), + endpointType: z.string().optional(), + tag: z.string().optional(), + tokenUnit: z.enum(['M', 'K']).optional(), + view: z.enum(['list', 'table']).optional(), + rechargePrice: z.boolean().optional(), +}) + +export const Route = createFileRoute('/_authenticated/model-square/')({ + validateSearch: modelSquareSearchSchema, + component: ModelSquare, +}) + +function ModelSquare() { + return ( + <> + +
+ +
+ + ) +} diff --git a/web/default/src/routes/_authenticated/status-monitor.tsx b/web/default/src/routes/_authenticated/status-monitor.tsx new file mode 100644 index 000000000000..f33a5fafda2e --- /dev/null +++ b/web/default/src/routes/_authenticated/status-monitor.tsx @@ -0,0 +1,25 @@ +import { createFileRoute } from '@tanstack/react-router' +import { AppHeader, Main } from '@/components/layout' + +const STATUS_MONITOR_URL = 'https://status.tcp.red?sort=serviceType_desc' + +export const Route = createFileRoute('/_authenticated/status-monitor')({ + component: StatusMonitor, +}) + +function StatusMonitor() { + return ( + <> + +
+
+