feat(default): reorganize system settings pricing UI
Refine the default system settings structure and model pricing editor so pricing configuration is easier to scan and edit.
This commit is contained in:
+20
-1
@@ -3,6 +3,8 @@ import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
const FEEDBACK_URL = 'https://github.com/QuantumNous/new-api/issues'
|
||||
|
||||
type GeneralErrorProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||
minimal?: boolean
|
||||
}
|
||||
@@ -28,10 +30,27 @@ export function GeneralError({
|
||||
{t('Please try again later.')}
|
||||
</p>
|
||||
{!minimal && (
|
||||
<div className='mt-6 flex gap-4'>
|
||||
<p className='text-muted-foreground text-center text-sm'>
|
||||
{t('If this keeps happening, please report it on GitHub Issues.')}
|
||||
</p>
|
||||
)}
|
||||
{!minimal && (
|
||||
<div className='mt-6 flex flex-wrap justify-center gap-4'>
|
||||
<Button variant='outline' onClick={() => history.go(-1)}>
|
||||
{t('Go Back')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
render={
|
||||
<a
|
||||
href={FEEDBACK_URL}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
/>
|
||||
}
|
||||
>
|
||||
{t('Report an issue')}
|
||||
</Button>
|
||||
<Button onClick={() => navigate({ to: '/' })}>
|
||||
{t('Back to Home')}
|
||||
</Button>
|
||||
|
||||
@@ -87,7 +87,10 @@ export function DeploymentAccessGuard({
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleGoToSettings = () => {
|
||||
navigate({ to: '/system-settings/integrations' })
|
||||
navigate({
|
||||
to: '/system-settings/models/$section',
|
||||
params: { section: 'model-deployment' },
|
||||
})
|
||||
}
|
||||
|
||||
// Combined loading state with step indicator
|
||||
|
||||
@@ -168,6 +168,13 @@ export function ModelMutateDrawer({
|
||||
'group_ratio_setting.group_special_usable_group': '{}',
|
||||
'grok.violation_deduction_enabled': false,
|
||||
'grok.violation_deduction_amount': 0,
|
||||
'channel_affinity_setting.enabled': false,
|
||||
'channel_affinity_setting.switch_on_success': true,
|
||||
'channel_affinity_setting.max_entries': 100000,
|
||||
'channel_affinity_setting.default_ttl_seconds': 3600,
|
||||
'channel_affinity_setting.rules': '[]',
|
||||
'model_deployment.ionet.api_key': '',
|
||||
'model_deployment.ionet.enabled': false,
|
||||
}
|
||||
return getOptionValue(systemOptionsData.data, defaultModelSettings)
|
||||
}, [systemOptionsData])
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { SettingsPage } from '../components/settings-page'
|
||||
import type { BillingSettings } from '../types'
|
||||
import {
|
||||
BILLING_DEFAULT_SECTION,
|
||||
getBillingSectionContent,
|
||||
} from './section-registry.tsx'
|
||||
|
||||
const defaultBillingSettings: BillingSettings = {
|
||||
QuotaForNewUser: 0,
|
||||
PreConsumedQuota: 0,
|
||||
QuotaForInviter: 0,
|
||||
QuotaForInvitee: 0,
|
||||
TopUpLink: '',
|
||||
'general_setting.docs_link': '',
|
||||
'quota_setting.enable_free_model_pre_consume': true,
|
||||
QuotaPerUnit: 500000,
|
||||
USDExchangeRate: 7,
|
||||
'general_setting.quota_display_type': 'USD',
|
||||
'general_setting.custom_currency_symbol': '¤',
|
||||
'general_setting.custom_currency_exchange_rate': 1,
|
||||
DisplayInCurrencyEnabled: true,
|
||||
DisplayTokenStatEnabled: true,
|
||||
ModelPrice: '',
|
||||
ModelRatio: '',
|
||||
CacheRatio: '',
|
||||
CreateCacheRatio: '',
|
||||
CompletionRatio: '',
|
||||
ImageRatio: '',
|
||||
AudioRatio: '',
|
||||
AudioCompletionRatio: '',
|
||||
ExposeRatioEnabled: false,
|
||||
'billing_setting.billing_mode': '{}',
|
||||
'billing_setting.billing_expr': '{}',
|
||||
'tool_price_setting.prices': '{}',
|
||||
TopupGroupRatio: '',
|
||||
GroupRatio: '',
|
||||
UserUsableGroups: '',
|
||||
GroupGroupRatio: '',
|
||||
AutoGroups: '',
|
||||
DefaultUseAutoGroup: false,
|
||||
'group_ratio_setting.group_special_usable_group': '{}',
|
||||
PayAddress: '',
|
||||
EpayId: '',
|
||||
EpayKey: '',
|
||||
Price: 7.3,
|
||||
MinTopUp: 1,
|
||||
CustomCallbackAddress: '',
|
||||
PayMethods: '',
|
||||
'payment_setting.amount_options': '',
|
||||
'payment_setting.amount_discount': '',
|
||||
StripeApiSecret: '',
|
||||
StripeWebhookSecret: '',
|
||||
StripePriceId: '',
|
||||
StripeUnitPrice: 8.0,
|
||||
StripeMinTopUp: 1,
|
||||
StripePromotionCodesEnabled: false,
|
||||
CreemApiKey: '',
|
||||
CreemWebhookSecret: '',
|
||||
CreemTestMode: false,
|
||||
CreemProducts: '[]',
|
||||
WaffoEnabled: false,
|
||||
WaffoApiKey: '',
|
||||
WaffoPrivateKey: '',
|
||||
WaffoPublicCert: '',
|
||||
WaffoSandboxPublicCert: '',
|
||||
WaffoSandboxApiKey: '',
|
||||
WaffoSandboxPrivateKey: '',
|
||||
WaffoSandbox: false,
|
||||
WaffoMerchantId: '',
|
||||
WaffoCurrency: 'USD',
|
||||
WaffoUnitPrice: 1,
|
||||
WaffoMinTopUp: 1,
|
||||
WaffoNotifyUrl: '',
|
||||
WaffoReturnUrl: '',
|
||||
WaffoPayMethods: '[]',
|
||||
WaffoPancakeEnabled: false,
|
||||
WaffoPancakeSandbox: false,
|
||||
WaffoPancakeMerchantID: '',
|
||||
WaffoPancakePrivateKey: '',
|
||||
WaffoPancakeWebhookPublicKey: '',
|
||||
WaffoPancakeWebhookTestKey: '',
|
||||
WaffoPancakeStoreID: '',
|
||||
WaffoPancakeProductID: '',
|
||||
WaffoPancakeReturnURL: '',
|
||||
WaffoPancakeCurrency: 'USD',
|
||||
WaffoPancakeUnitPrice: 1,
|
||||
WaffoPancakeMinTopUp: 1,
|
||||
'checkin_setting.enabled': false,
|
||||
'checkin_setting.min_quota': 1000,
|
||||
'checkin_setting.max_quota': 10000,
|
||||
}
|
||||
|
||||
export function BillingSettings() {
|
||||
return (
|
||||
<SettingsPage
|
||||
routePath='/_authenticated/system-settings/billing/$section'
|
||||
defaultSettings={defaultBillingSettings}
|
||||
defaultSection={BILLING_DEFAULT_SECTION}
|
||||
getSectionContent={getBillingSectionContent}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { parseCurrencyDisplayType } from '@/lib/currency'
|
||||
import type { BillingSettings } from '../types'
|
||||
import { createSectionRegistry } from '../utils/section-registry'
|
||||
import { CheckinSettingsSection } from '../general/checkin-settings-section'
|
||||
import { PricingSection } from '../general/pricing-section'
|
||||
import { QuotaSettingsSection } from '../general/quota-settings-section'
|
||||
import { PaymentSettingsSection } from '../integrations/payment-settings-section'
|
||||
import { RatioSettingsCard } from '../models/ratio-settings-card'
|
||||
|
||||
const getModelDefaults = (settings: BillingSettings) => ({
|
||||
ModelPrice: settings.ModelPrice,
|
||||
ModelRatio: settings.ModelRatio,
|
||||
CacheRatio: settings.CacheRatio,
|
||||
CreateCacheRatio: settings.CreateCacheRatio,
|
||||
CompletionRatio: settings.CompletionRatio,
|
||||
ImageRatio: settings.ImageRatio,
|
||||
AudioRatio: settings.AudioRatio,
|
||||
AudioCompletionRatio: settings.AudioCompletionRatio,
|
||||
ExposeRatioEnabled: settings.ExposeRatioEnabled,
|
||||
BillingMode: settings['billing_setting.billing_mode'],
|
||||
BillingExpr: settings['billing_setting.billing_expr'],
|
||||
})
|
||||
|
||||
const getGroupDefaults = (settings: BillingSettings) => ({
|
||||
TopupGroupRatio: settings.TopupGroupRatio,
|
||||
GroupRatio: settings.GroupRatio,
|
||||
UserUsableGroups: settings.UserUsableGroups,
|
||||
GroupGroupRatio: settings.GroupGroupRatio,
|
||||
AutoGroups: settings.AutoGroups,
|
||||
DefaultUseAutoGroup: settings.DefaultUseAutoGroup,
|
||||
GroupSpecialUsableGroup:
|
||||
settings['group_ratio_setting.group_special_usable_group'],
|
||||
})
|
||||
|
||||
const BILLING_SECTIONS = [
|
||||
{
|
||||
id: 'quota',
|
||||
titleKey: 'Quota Settings',
|
||||
descriptionKey: 'Configure user quota allocation and rewards',
|
||||
build: (settings: BillingSettings) => (
|
||||
<QuotaSettingsSection
|
||||
defaultValues={{
|
||||
QuotaForNewUser: settings.QuotaForNewUser,
|
||||
PreConsumedQuota: settings.PreConsumedQuota,
|
||||
QuotaForInviter: settings.QuotaForInviter,
|
||||
QuotaForInvitee: settings.QuotaForInvitee,
|
||||
TopUpLink: settings.TopUpLink,
|
||||
'general_setting.docs_link': settings['general_setting.docs_link'],
|
||||
'quota_setting.enable_free_model_pre_consume':
|
||||
settings['quota_setting.enable_free_model_pre_consume'],
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'currency',
|
||||
titleKey: 'Currency & Display',
|
||||
descriptionKey: 'Configure currency conversion and quota display options',
|
||||
build: (settings: BillingSettings) => (
|
||||
<PricingSection
|
||||
defaultValues={{
|
||||
QuotaPerUnit: settings.QuotaPerUnit,
|
||||
USDExchangeRate: settings.USDExchangeRate,
|
||||
DisplayInCurrencyEnabled: settings.DisplayInCurrencyEnabled,
|
||||
DisplayTokenStatEnabled: settings.DisplayTokenStatEnabled,
|
||||
general_setting: {
|
||||
quota_display_type: parseCurrencyDisplayType(
|
||||
settings['general_setting.quota_display_type']
|
||||
),
|
||||
custom_currency_symbol:
|
||||
settings['general_setting.custom_currency_symbol'] ?? '¤',
|
||||
custom_currency_exchange_rate:
|
||||
settings['general_setting.custom_currency_exchange_rate'] ?? 1,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'model-pricing',
|
||||
titleKey: 'Model Pricing',
|
||||
descriptionKey: 'Configure model pricing ratios and tool prices',
|
||||
build: (settings: BillingSettings) => (
|
||||
<RatioSettingsCard
|
||||
titleKey='Model Pricing'
|
||||
descriptionKey='Configure model pricing ratios and tool prices'
|
||||
modelDefaults={getModelDefaults(settings)}
|
||||
groupDefaults={getGroupDefaults(settings)}
|
||||
toolPricesDefault={settings['tool_price_setting.prices']}
|
||||
visibleTabs={['models', 'tool-prices', 'upstream-sync']}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'group-pricing',
|
||||
titleKey: 'Group Pricing',
|
||||
descriptionKey: 'Configure group ratios and group-specific pricing rules',
|
||||
build: (settings: BillingSettings) => (
|
||||
<RatioSettingsCard
|
||||
titleKey='Group Pricing'
|
||||
descriptionKey='Configure group ratios and group-specific pricing rules'
|
||||
modelDefaults={getModelDefaults(settings)}
|
||||
groupDefaults={getGroupDefaults(settings)}
|
||||
toolPricesDefault={settings['tool_price_setting.prices']}
|
||||
visibleTabs={['groups']}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'payment',
|
||||
titleKey: 'Payment Gateway',
|
||||
descriptionKey: 'Configure payment gateway integrations',
|
||||
build: (settings: BillingSettings) => (
|
||||
<PaymentSettingsSection
|
||||
defaultValues={{
|
||||
PayAddress: settings.PayAddress,
|
||||
EpayId: settings.EpayId,
|
||||
EpayKey: settings.EpayKey,
|
||||
Price: settings.Price,
|
||||
MinTopUp: settings.MinTopUp,
|
||||
CustomCallbackAddress: settings.CustomCallbackAddress,
|
||||
PayMethods: settings.PayMethods,
|
||||
AmountOptions: settings['payment_setting.amount_options'],
|
||||
AmountDiscount: settings['payment_setting.amount_discount'],
|
||||
StripeApiSecret: settings.StripeApiSecret,
|
||||
StripeWebhookSecret: settings.StripeWebhookSecret,
|
||||
StripePriceId: settings.StripePriceId,
|
||||
StripeUnitPrice: settings.StripeUnitPrice,
|
||||
StripeMinTopUp: settings.StripeMinTopUp,
|
||||
StripePromotionCodesEnabled: settings.StripePromotionCodesEnabled,
|
||||
CreemApiKey: settings.CreemApiKey,
|
||||
CreemWebhookSecret: settings.CreemWebhookSecret,
|
||||
CreemTestMode: settings.CreemTestMode,
|
||||
CreemProducts: settings.CreemProducts,
|
||||
}}
|
||||
waffoDefaultValues={{
|
||||
WaffoEnabled: settings.WaffoEnabled ?? false,
|
||||
WaffoApiKey: settings.WaffoApiKey ?? '',
|
||||
WaffoPrivateKey: settings.WaffoPrivateKey ?? '',
|
||||
WaffoPublicCert: settings.WaffoPublicCert ?? '',
|
||||
WaffoSandboxPublicCert: settings.WaffoSandboxPublicCert ?? '',
|
||||
WaffoSandboxApiKey: settings.WaffoSandboxApiKey ?? '',
|
||||
WaffoSandboxPrivateKey: settings.WaffoSandboxPrivateKey ?? '',
|
||||
WaffoSandbox: settings.WaffoSandbox ?? false,
|
||||
WaffoMerchantId: settings.WaffoMerchantId ?? '',
|
||||
WaffoCurrency: settings.WaffoCurrency ?? 'USD',
|
||||
WaffoUnitPrice: settings.WaffoUnitPrice ?? 1,
|
||||
WaffoMinTopUp: settings.WaffoMinTopUp ?? 1,
|
||||
WaffoNotifyUrl: settings.WaffoNotifyUrl ?? '',
|
||||
WaffoReturnUrl: settings.WaffoReturnUrl ?? '',
|
||||
WaffoPayMethods: settings.WaffoPayMethods ?? '[]',
|
||||
}}
|
||||
waffoPancakeDefaultValues={{
|
||||
WaffoPancakeEnabled: settings.WaffoPancakeEnabled ?? false,
|
||||
WaffoPancakeSandbox: settings.WaffoPancakeSandbox ?? false,
|
||||
WaffoPancakeMerchantID: settings.WaffoPancakeMerchantID ?? '',
|
||||
WaffoPancakePrivateKey: settings.WaffoPancakePrivateKey ?? '',
|
||||
WaffoPancakeWebhookPublicKey:
|
||||
settings.WaffoPancakeWebhookPublicKey ?? '',
|
||||
WaffoPancakeWebhookTestKey:
|
||||
settings.WaffoPancakeWebhookTestKey ?? '',
|
||||
WaffoPancakeStoreID: settings.WaffoPancakeStoreID ?? '',
|
||||
WaffoPancakeProductID: settings.WaffoPancakeProductID ?? '',
|
||||
WaffoPancakeReturnURL: settings.WaffoPancakeReturnURL ?? '',
|
||||
WaffoPancakeCurrency: settings.WaffoPancakeCurrency ?? 'USD',
|
||||
WaffoPancakeUnitPrice: settings.WaffoPancakeUnitPrice ?? 1,
|
||||
WaffoPancakeMinTopUp: settings.WaffoPancakeMinTopUp ?? 1,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'checkin',
|
||||
titleKey: 'Check-in Rewards',
|
||||
descriptionKey: 'Configure daily check-in rewards for users',
|
||||
build: (settings: BillingSettings) => (
|
||||
<CheckinSettingsSection
|
||||
defaultValues={{
|
||||
enabled: settings['checkin_setting.enabled'],
|
||||
minQuota: settings['checkin_setting.min_quota'],
|
||||
maxQuota: settings['checkin_setting.max_quota'],
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
] as const
|
||||
|
||||
export type BillingSectionId = (typeof BILLING_SECTIONS)[number]['id']
|
||||
|
||||
const billingRegistry = createSectionRegistry<BillingSectionId, BillingSettings>(
|
||||
{
|
||||
sections: BILLING_SECTIONS,
|
||||
defaultSection: 'quota',
|
||||
basePath: '/system-settings/billing',
|
||||
urlStyle: 'path',
|
||||
}
|
||||
)
|
||||
|
||||
export const BILLING_SECTION_IDS = billingRegistry.sectionIds
|
||||
export const BILLING_DEFAULT_SECTION = billingRegistry.defaultSection
|
||||
export const getBillingSectionNavItems = billingRegistry.getSectionNavItems
|
||||
export const getBillingSectionContent = billingRegistry.getSectionContent
|
||||
@@ -1,89 +0,0 @@
|
||||
import { useParams } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { parseCurrencyDisplayType } from '@/lib/currency'
|
||||
import { useSystemOptions, getOptionValue } from '../hooks/use-system-options'
|
||||
import type { GeneralSettings } from '../types'
|
||||
import {
|
||||
GENERAL_DEFAULT_SECTION,
|
||||
getGeneralSectionContent,
|
||||
} from './section-registry.tsx'
|
||||
|
||||
const defaultGeneralSettings: GeneralSettings = {
|
||||
'theme.frontend': 'default',
|
||||
Notice: '',
|
||||
SystemName: 'New API',
|
||||
Logo: '',
|
||||
Footer: '',
|
||||
About: '',
|
||||
HomePageContent: '',
|
||||
ServerAddress: '',
|
||||
'legal.user_agreement': '',
|
||||
'legal.privacy_policy': '',
|
||||
QuotaForNewUser: 0,
|
||||
PreConsumedQuota: 0,
|
||||
QuotaForInviter: 0,
|
||||
QuotaForInvitee: 0,
|
||||
TopUpLink: '',
|
||||
'general_setting.docs_link': '',
|
||||
'quota_setting.enable_free_model_pre_consume': true,
|
||||
QuotaPerUnit: 500000,
|
||||
USDExchangeRate: 7,
|
||||
'general_setting.quota_display_type': 'USD',
|
||||
'general_setting.custom_currency_symbol': '¤',
|
||||
'general_setting.custom_currency_exchange_rate': 1,
|
||||
RetryTimes: 0,
|
||||
DisplayInCurrencyEnabled: true,
|
||||
DisplayTokenStatEnabled: true,
|
||||
DefaultCollapseSidebar: false,
|
||||
DemoSiteEnabled: false,
|
||||
SelfUseModeEnabled: false,
|
||||
'checkin_setting.enabled': false,
|
||||
'checkin_setting.min_quota': 1000,
|
||||
'checkin_setting.max_quota': 10000,
|
||||
'channel_affinity_setting.enabled': false,
|
||||
'channel_affinity_setting.switch_on_success': true,
|
||||
'channel_affinity_setting.max_entries': 100000,
|
||||
'channel_affinity_setting.default_ttl_seconds': 3600,
|
||||
'channel_affinity_setting.rules': '[]',
|
||||
}
|
||||
|
||||
export function GeneralSettings() {
|
||||
const { t } = useTranslation()
|
||||
const { data, isLoading } = useSystemOptions()
|
||||
const params = useParams({
|
||||
from: '/_authenticated/system-settings/general/$section',
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className='flex items-center justify-center py-12'>
|
||||
<div className='text-muted-foreground'>{t('Loading settings...')}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const settings = getOptionValue(data?.data, defaultGeneralSettings)
|
||||
const quotaDisplayType = parseCurrencyDisplayType(
|
||||
settings['general_setting.quota_display_type']
|
||||
)
|
||||
const activeSection = (params?.section ?? GENERAL_DEFAULT_SECTION) as
|
||||
| 'system-info'
|
||||
| 'quota'
|
||||
| 'pricing'
|
||||
| 'checkin'
|
||||
| 'behavior'
|
||||
| 'channel-affinity'
|
||||
const sectionContent = getGeneralSectionContent(
|
||||
activeSection,
|
||||
settings,
|
||||
quotaDisplayType
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='flex h-full w-full flex-1 flex-col'>
|
||||
<div className='faded-bottom h-full w-full overflow-y-auto scroll-smooth pe-4 pb-12'>
|
||||
<div className='space-y-4'>{sectionContent}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
import type { GeneralSettings } from '../types'
|
||||
import { createSectionRegistry } from '../utils/section-registry'
|
||||
import { ChannelAffinitySection } from './channel-affinity'
|
||||
import { CheckinSettingsSection } from './checkin-settings-section'
|
||||
import { PricingSection } from './pricing-section'
|
||||
import { QuotaSettingsSection } from './quota-settings-section'
|
||||
import { SystemBehaviorSection } from './system-behavior-section'
|
||||
import { SystemInfoSection } from './system-info-section'
|
||||
|
||||
const GENERAL_SECTIONS = [
|
||||
{
|
||||
id: 'system-info',
|
||||
titleKey: 'System Information',
|
||||
descriptionKey: 'Configure basic system information and branding',
|
||||
build: (settings: GeneralSettings) => (
|
||||
<SystemInfoSection
|
||||
defaultValues={{
|
||||
theme: {
|
||||
frontend: settings['theme.frontend'] as 'default' | 'classic',
|
||||
},
|
||||
Notice: settings.Notice,
|
||||
SystemName: settings.SystemName,
|
||||
Logo: settings.Logo,
|
||||
Footer: settings.Footer,
|
||||
About: settings.About,
|
||||
HomePageContent: settings.HomePageContent,
|
||||
ServerAddress: settings.ServerAddress,
|
||||
legal: {
|
||||
user_agreement: settings['legal.user_agreement'],
|
||||
privacy_policy: settings['legal.privacy_policy'],
|
||||
},
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'quota',
|
||||
titleKey: 'Quota Settings',
|
||||
descriptionKey: 'Configure user quota allocation and rewards',
|
||||
build: (settings: GeneralSettings) => (
|
||||
<QuotaSettingsSection
|
||||
defaultValues={{
|
||||
QuotaForNewUser: settings.QuotaForNewUser,
|
||||
PreConsumedQuota: settings.PreConsumedQuota,
|
||||
QuotaForInviter: settings.QuotaForInviter,
|
||||
QuotaForInvitee: settings.QuotaForInvitee,
|
||||
TopUpLink: settings.TopUpLink,
|
||||
'general_setting.docs_link': settings['general_setting.docs_link'],
|
||||
'quota_setting.enable_free_model_pre_consume':
|
||||
settings['quota_setting.enable_free_model_pre_consume'],
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'pricing',
|
||||
titleKey: 'Pricing & Display',
|
||||
descriptionKey: 'Configure pricing model and display options',
|
||||
build: (
|
||||
settings: GeneralSettings,
|
||||
quotaDisplayType: 'USD' | 'CNY' | 'TOKENS' | 'CUSTOM'
|
||||
) => (
|
||||
<PricingSection
|
||||
defaultValues={{
|
||||
QuotaPerUnit: settings.QuotaPerUnit,
|
||||
USDExchangeRate: settings.USDExchangeRate,
|
||||
DisplayInCurrencyEnabled: settings.DisplayInCurrencyEnabled,
|
||||
DisplayTokenStatEnabled: settings.DisplayTokenStatEnabled,
|
||||
general_setting: {
|
||||
quota_display_type: quotaDisplayType,
|
||||
custom_currency_symbol:
|
||||
settings['general_setting.custom_currency_symbol'] ?? '¤',
|
||||
custom_currency_exchange_rate:
|
||||
settings['general_setting.custom_currency_exchange_rate'] ?? 1,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'checkin',
|
||||
titleKey: 'Check-in Settings',
|
||||
descriptionKey: 'Configure daily check-in rewards for users',
|
||||
build: (settings: GeneralSettings) => (
|
||||
<CheckinSettingsSection
|
||||
defaultValues={{
|
||||
enabled: settings['checkin_setting.enabled'],
|
||||
minQuota: settings['checkin_setting.min_quota'],
|
||||
maxQuota: settings['checkin_setting.max_quota'],
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'behavior',
|
||||
titleKey: 'System Behavior',
|
||||
descriptionKey: 'Configure system-wide behavior and defaults',
|
||||
build: (settings: GeneralSettings) => (
|
||||
<SystemBehaviorSection
|
||||
defaultValues={{
|
||||
RetryTimes: settings.RetryTimes,
|
||||
DefaultCollapseSidebar: settings.DefaultCollapseSidebar,
|
||||
DemoSiteEnabled: settings.DemoSiteEnabled,
|
||||
SelfUseModeEnabled: settings.SelfUseModeEnabled,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'channel-affinity',
|
||||
titleKey: 'Channel Affinity',
|
||||
descriptionKey: 'Configure channel affinity (sticky routing) rules',
|
||||
build: (settings: GeneralSettings) => (
|
||||
<ChannelAffinitySection
|
||||
defaultValues={{
|
||||
'channel_affinity_setting.enabled':
|
||||
settings['channel_affinity_setting.enabled'],
|
||||
'channel_affinity_setting.switch_on_success':
|
||||
settings['channel_affinity_setting.switch_on_success'],
|
||||
'channel_affinity_setting.max_entries':
|
||||
settings['channel_affinity_setting.max_entries'],
|
||||
'channel_affinity_setting.default_ttl_seconds':
|
||||
settings['channel_affinity_setting.default_ttl_seconds'],
|
||||
'channel_affinity_setting.rules':
|
||||
settings['channel_affinity_setting.rules'],
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
] as const
|
||||
|
||||
export type GeneralSectionId = (typeof GENERAL_SECTIONS)[number]['id']
|
||||
|
||||
const generalRegistry = createSectionRegistry<
|
||||
GeneralSectionId,
|
||||
GeneralSettings,
|
||||
['USD' | 'CNY' | 'TOKENS' | 'CUSTOM']
|
||||
>({
|
||||
sections: GENERAL_SECTIONS,
|
||||
defaultSection: 'system-info',
|
||||
basePath: '/system-settings/general',
|
||||
urlStyle: 'path',
|
||||
})
|
||||
|
||||
export const GENERAL_SECTION_IDS = generalRegistry.sectionIds
|
||||
export const GENERAL_DEFAULT_SECTION = generalRegistry.defaultSection
|
||||
export const getGeneralSectionNavItems = generalRegistry.getSectionNavItems
|
||||
export const getGeneralSectionContent = generalRegistry.getSectionContent
|
||||
@@ -32,7 +32,6 @@ const _systemInfoSchema = z.object({
|
||||
theme: z.object({
|
||||
frontend: z.enum(['default', 'classic']),
|
||||
}),
|
||||
Notice: z.string().optional(),
|
||||
SystemName: z.string().min(1),
|
||||
ServerAddress: z.string().optional(),
|
||||
Logo: z.string().url().optional().or(z.literal('')),
|
||||
@@ -65,7 +64,6 @@ export function SystemInfoSection({ defaultValues }: SystemInfoSectionProps) {
|
||||
frontend:
|
||||
defaultValues.theme?.frontend === 'classic' ? 'classic' : 'default',
|
||||
},
|
||||
Notice: normalizeValue(defaultValues.Notice),
|
||||
SystemName: normalizeValue(defaultValues.SystemName),
|
||||
ServerAddress: normalizeValue(defaultValues.ServerAddress),
|
||||
Logo: normalizeValue(defaultValues.Logo),
|
||||
@@ -82,7 +80,6 @@ export function SystemInfoSection({ defaultValues }: SystemInfoSectionProps) {
|
||||
theme: z.object({
|
||||
frontend: z.enum(['default', 'classic']),
|
||||
}),
|
||||
Notice: z.string().optional(),
|
||||
SystemName: z.string().min(1, {
|
||||
error: () => t('System name is required'),
|
||||
}),
|
||||
@@ -161,31 +158,6 @@ export function SystemInfoSection({ defaultValues }: SystemInfoSectionProps) {
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='Notice'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Notice')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={t(
|
||||
'Enter announcement content (supports Markdown & HTML)'
|
||||
)}
|
||||
rows={6}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Announcement displayed to users (supports Markdown & HTML)'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='SystemName'
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import { SettingsPage } from '../components/settings-page'
|
||||
import type { IntegrationSettings as IntegrationSettingsType } from '../types'
|
||||
import {
|
||||
INTEGRATIONS_DEFAULT_SECTION,
|
||||
getIntegrationsSectionContent,
|
||||
} from './section-registry.tsx'
|
||||
|
||||
const defaultIntegrationSettings: IntegrationSettingsType = {
|
||||
SMTPServer: '',
|
||||
SMTPPort: '',
|
||||
SMTPAccount: '',
|
||||
SMTPFrom: '',
|
||||
SMTPToken: '',
|
||||
SMTPSSLEnabled: false,
|
||||
SMTPForceAuthLogin: false,
|
||||
WorkerUrl: '',
|
||||
WorkerValidKey: '',
|
||||
WorkerAllowHttpImageRequestEnabled: false,
|
||||
ChannelDisableThreshold: '',
|
||||
QuotaRemindThreshold: '',
|
||||
AutomaticDisableChannelEnabled: false,
|
||||
AutomaticEnableChannelEnabled: false,
|
||||
AutomaticDisableKeywords: '',
|
||||
AutomaticDisableStatusCodes: '401',
|
||||
AutomaticRetryStatusCodes:
|
||||
'100-199,300-399,401-407,409-499,500-503,505-523,525-599',
|
||||
'monitor_setting.auto_test_channel_enabled': false,
|
||||
'monitor_setting.auto_test_channel_minutes': 10,
|
||||
'model_deployment.ionet.api_key': '',
|
||||
'model_deployment.ionet.enabled': false,
|
||||
PayAddress: '',
|
||||
EpayId: '',
|
||||
EpayKey: '',
|
||||
Price: 7.3,
|
||||
MinTopUp: 1,
|
||||
CustomCallbackAddress: '',
|
||||
PayMethods: '',
|
||||
'payment_setting.amount_options': '',
|
||||
'payment_setting.amount_discount': '',
|
||||
StripeApiSecret: '',
|
||||
StripeWebhookSecret: '',
|
||||
StripePriceId: '',
|
||||
StripeUnitPrice: 8.0,
|
||||
StripeMinTopUp: 1,
|
||||
StripePromotionCodesEnabled: false,
|
||||
CreemApiKey: '',
|
||||
CreemWebhookSecret: '',
|
||||
CreemTestMode: false,
|
||||
CreemProducts: '[]',
|
||||
WaffoEnabled: false,
|
||||
WaffoApiKey: '',
|
||||
WaffoPrivateKey: '',
|
||||
WaffoPublicCert: '',
|
||||
WaffoSandboxPublicCert: '',
|
||||
WaffoSandboxApiKey: '',
|
||||
WaffoSandboxPrivateKey: '',
|
||||
WaffoSandbox: false,
|
||||
WaffoMerchantId: '',
|
||||
WaffoCurrency: 'USD',
|
||||
WaffoUnitPrice: 1,
|
||||
WaffoMinTopUp: 1,
|
||||
WaffoNotifyUrl: '',
|
||||
WaffoReturnUrl: '',
|
||||
WaffoPayMethods: '[]',
|
||||
WaffoPancakeEnabled: false,
|
||||
WaffoPancakeSandbox: false,
|
||||
WaffoPancakeMerchantID: '',
|
||||
WaffoPancakePrivateKey: '',
|
||||
WaffoPancakeWebhookPublicKey: '',
|
||||
WaffoPancakeWebhookTestKey: '',
|
||||
WaffoPancakeStoreID: '',
|
||||
WaffoPancakeProductID: '',
|
||||
WaffoPancakeReturnURL: '',
|
||||
WaffoPancakeCurrency: 'USD',
|
||||
WaffoPancakeUnitPrice: 1,
|
||||
WaffoPancakeMinTopUp: 1,
|
||||
}
|
||||
|
||||
export function IntegrationSettings() {
|
||||
return (
|
||||
<SettingsPage
|
||||
routePath='/_authenticated/system-settings/integrations/$section'
|
||||
defaultSettings={defaultIntegrationSettings}
|
||||
defaultSection={INTEGRATIONS_DEFAULT_SECTION}
|
||||
getSectionContent={getIntegrationsSectionContent}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
import type { IntegrationSettings } from '../types'
|
||||
import { createSectionRegistry } from '../utils/section-registry'
|
||||
import { EmailSettingsSection } from './email-settings-section'
|
||||
import { IoNetDeploymentSettingsSection } from './ionet-deployment-settings-section'
|
||||
import { MonitoringSettingsSection } from './monitoring-settings-section'
|
||||
import { PaymentSettingsSection } from './payment-settings-section'
|
||||
import { WorkerSettingsSection } from './worker-settings-section'
|
||||
|
||||
const INTEGRATIONS_SECTIONS = [
|
||||
{
|
||||
id: 'payment',
|
||||
titleKey: 'Payment Gateway',
|
||||
descriptionKey: 'Configure payment gateway integrations',
|
||||
build: (settings: IntegrationSettings) => (
|
||||
<PaymentSettingsSection
|
||||
defaultValues={{
|
||||
PayAddress: settings.PayAddress,
|
||||
EpayId: settings.EpayId,
|
||||
EpayKey: settings.EpayKey,
|
||||
Price: settings.Price,
|
||||
MinTopUp: settings.MinTopUp,
|
||||
CustomCallbackAddress: settings.CustomCallbackAddress,
|
||||
PayMethods: settings.PayMethods,
|
||||
AmountOptions: settings['payment_setting.amount_options'],
|
||||
AmountDiscount: settings['payment_setting.amount_discount'],
|
||||
StripeApiSecret: settings.StripeApiSecret,
|
||||
StripeWebhookSecret: settings.StripeWebhookSecret,
|
||||
StripePriceId: settings.StripePriceId,
|
||||
StripeUnitPrice: settings.StripeUnitPrice,
|
||||
StripeMinTopUp: settings.StripeMinTopUp,
|
||||
StripePromotionCodesEnabled: settings.StripePromotionCodesEnabled,
|
||||
CreemApiKey: settings.CreemApiKey,
|
||||
CreemWebhookSecret: settings.CreemWebhookSecret,
|
||||
CreemTestMode: settings.CreemTestMode,
|
||||
CreemProducts: settings.CreemProducts,
|
||||
}}
|
||||
waffoDefaultValues={{
|
||||
WaffoEnabled: settings.WaffoEnabled ?? false,
|
||||
WaffoApiKey: settings.WaffoApiKey ?? '',
|
||||
WaffoPrivateKey: settings.WaffoPrivateKey ?? '',
|
||||
WaffoPublicCert: settings.WaffoPublicCert ?? '',
|
||||
WaffoSandboxPublicCert: settings.WaffoSandboxPublicCert ?? '',
|
||||
WaffoSandboxApiKey: settings.WaffoSandboxApiKey ?? '',
|
||||
WaffoSandboxPrivateKey: settings.WaffoSandboxPrivateKey ?? '',
|
||||
WaffoSandbox: settings.WaffoSandbox ?? false,
|
||||
WaffoMerchantId: settings.WaffoMerchantId ?? '',
|
||||
WaffoCurrency: settings.WaffoCurrency ?? 'USD',
|
||||
WaffoUnitPrice: settings.WaffoUnitPrice ?? 1,
|
||||
WaffoMinTopUp: settings.WaffoMinTopUp ?? 1,
|
||||
WaffoNotifyUrl: settings.WaffoNotifyUrl ?? '',
|
||||
WaffoReturnUrl: settings.WaffoReturnUrl ?? '',
|
||||
WaffoPayMethods: settings.WaffoPayMethods ?? '[]',
|
||||
}}
|
||||
waffoPancakeDefaultValues={{
|
||||
WaffoPancakeEnabled: settings.WaffoPancakeEnabled ?? false,
|
||||
WaffoPancakeSandbox: settings.WaffoPancakeSandbox ?? false,
|
||||
WaffoPancakeMerchantID: settings.WaffoPancakeMerchantID ?? '',
|
||||
WaffoPancakePrivateKey: settings.WaffoPancakePrivateKey ?? '',
|
||||
WaffoPancakeWebhookPublicKey:
|
||||
settings.WaffoPancakeWebhookPublicKey ?? '',
|
||||
WaffoPancakeWebhookTestKey: settings.WaffoPancakeWebhookTestKey ?? '',
|
||||
WaffoPancakeStoreID: settings.WaffoPancakeStoreID ?? '',
|
||||
WaffoPancakeProductID: settings.WaffoPancakeProductID ?? '',
|
||||
WaffoPancakeReturnURL: settings.WaffoPancakeReturnURL ?? '',
|
||||
WaffoPancakeCurrency: settings.WaffoPancakeCurrency ?? 'USD',
|
||||
WaffoPancakeUnitPrice: settings.WaffoPancakeUnitPrice ?? 1,
|
||||
WaffoPancakeMinTopUp: settings.WaffoPancakeMinTopUp ?? 1,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
titleKey: 'SMTP Email',
|
||||
descriptionKey: 'Configure SMTP email settings',
|
||||
build: (settings: IntegrationSettings) => (
|
||||
<EmailSettingsSection
|
||||
defaultValues={{
|
||||
SMTPServer: settings.SMTPServer,
|
||||
SMTPPort: settings.SMTPPort,
|
||||
SMTPAccount: settings.SMTPAccount,
|
||||
SMTPFrom: settings.SMTPFrom,
|
||||
SMTPToken: settings.SMTPToken,
|
||||
SMTPSSLEnabled: settings.SMTPSSLEnabled,
|
||||
SMTPForceAuthLogin: settings.SMTPForceAuthLogin,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'worker',
|
||||
titleKey: 'Worker Proxy',
|
||||
descriptionKey: 'Configure worker service settings',
|
||||
build: (settings: IntegrationSettings) => (
|
||||
<WorkerSettingsSection
|
||||
defaultValues={{
|
||||
WorkerUrl: settings.WorkerUrl,
|
||||
WorkerValidKey: settings.WorkerValidKey,
|
||||
WorkerAllowHttpImageRequestEnabled:
|
||||
settings.WorkerAllowHttpImageRequestEnabled,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'ionet',
|
||||
titleKey: 'io.net Deployments',
|
||||
descriptionKey: 'Configure IoNet model deployment settings',
|
||||
build: (settings: IntegrationSettings) => (
|
||||
<IoNetDeploymentSettingsSection
|
||||
defaultValues={{
|
||||
enabled: settings['model_deployment.ionet.enabled'],
|
||||
apiKey: settings['model_deployment.ionet.api_key'],
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'monitoring',
|
||||
titleKey: 'Monitoring & Alerts',
|
||||
descriptionKey: 'Configure channel monitoring and automation',
|
||||
build: (settings: IntegrationSettings) => (
|
||||
<MonitoringSettingsSection
|
||||
defaultValues={{
|
||||
ChannelDisableThreshold: settings.ChannelDisableThreshold,
|
||||
QuotaRemindThreshold: settings.QuotaRemindThreshold,
|
||||
AutomaticDisableChannelEnabled:
|
||||
settings.AutomaticDisableChannelEnabled,
|
||||
AutomaticEnableChannelEnabled: settings.AutomaticEnableChannelEnabled,
|
||||
AutomaticDisableKeywords: settings.AutomaticDisableKeywords,
|
||||
AutomaticDisableStatusCodes: settings.AutomaticDisableStatusCodes,
|
||||
AutomaticRetryStatusCodes: settings.AutomaticRetryStatusCodes,
|
||||
'monitor_setting.auto_test_channel_enabled':
|
||||
settings['monitor_setting.auto_test_channel_enabled'],
|
||||
'monitor_setting.auto_test_channel_minutes':
|
||||
settings['monitor_setting.auto_test_channel_minutes'],
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
] as const
|
||||
|
||||
export type IntegrationSectionId = (typeof INTEGRATIONS_SECTIONS)[number]['id']
|
||||
|
||||
const integrationsRegistry = createSectionRegistry<
|
||||
IntegrationSectionId,
|
||||
IntegrationSettings
|
||||
>({
|
||||
sections: INTEGRATIONS_SECTIONS,
|
||||
defaultSection: 'payment',
|
||||
basePath: '/system-settings/integrations',
|
||||
urlStyle: 'path',
|
||||
})
|
||||
|
||||
export const INTEGRATIONS_SECTION_IDS = integrationsRegistry.sectionIds
|
||||
export const INTEGRATIONS_DEFAULT_SECTION = integrationsRegistry.defaultSection
|
||||
export const getIntegrationsSectionNavItems =
|
||||
integrationsRegistry.getSectionNavItems
|
||||
export const getIntegrationsSectionContent =
|
||||
integrationsRegistry.getSectionContent
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { MaintenanceSettings } from '../types'
|
||||
|
||||
export type HeaderNavPricingConfig = {
|
||||
enabled: boolean
|
||||
requireAuth: boolean
|
||||
@@ -62,25 +60,6 @@ export const SIDEBAR_MODULES_DEFAULT: SidebarModulesAdminConfig = {
|
||||
},
|
||||
}
|
||||
|
||||
export const DEFAULT_MAINTENANCE_SETTINGS: MaintenanceSettings = {
|
||||
Notice: '',
|
||||
LogConsumeEnabled: false,
|
||||
HeaderNavModules: JSON.stringify(HEADER_NAV_DEFAULT),
|
||||
SidebarModulesAdmin: JSON.stringify(SIDEBAR_MODULES_DEFAULT),
|
||||
'performance_setting.disk_cache_enabled': false,
|
||||
'performance_setting.disk_cache_threshold_mb': 10,
|
||||
'performance_setting.disk_cache_max_size_mb': 1024,
|
||||
'performance_setting.disk_cache_path': '',
|
||||
'performance_setting.monitor_enabled': false,
|
||||
'performance_setting.monitor_cpu_threshold': 90,
|
||||
'performance_setting.monitor_memory_threshold': 90,
|
||||
'performance_setting.monitor_disk_threshold': 95,
|
||||
'perf_metrics_setting.enabled': true,
|
||||
'perf_metrics_setting.flush_interval': 5,
|
||||
'perf_metrics_setting.bucket_time': 'hour',
|
||||
'perf_metrics_setting.retention_days': 0,
|
||||
}
|
||||
|
||||
const toBoolean = (value: unknown, fallback: boolean): boolean => {
|
||||
if (typeof value === 'boolean') return value
|
||||
if (typeof value === 'number') return value === 1
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useParams } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStatus } from '@/hooks/use-status'
|
||||
import { getOptionValue, useSystemOptions } from '../hooks/use-system-options'
|
||||
import { DEFAULT_MAINTENANCE_SETTINGS } from './config'
|
||||
import {
|
||||
MAINTENANCE_DEFAULT_SECTION,
|
||||
getMaintenanceSectionContent,
|
||||
} from './section-registry.tsx'
|
||||
|
||||
export function MaintenanceSettings() {
|
||||
const { t } = useTranslation()
|
||||
const { data, isLoading } = useSystemOptions()
|
||||
const { status } = useStatus()
|
||||
const params = useParams({
|
||||
from: '/_authenticated/system-settings/maintenance/$section',
|
||||
})
|
||||
|
||||
const settings = useMemo(
|
||||
() => getOptionValue(data?.data, DEFAULT_MAINTENANCE_SETTINGS),
|
||||
[data?.data]
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className='text-muted-foreground flex h-full w-full flex-1 items-center justify-center'>
|
||||
{t('Loading maintenance settings...')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const activeSection = (params?.section ?? MAINTENANCE_DEFAULT_SECTION) as
|
||||
| 'update-checker'
|
||||
| 'notice'
|
||||
| 'logs'
|
||||
| 'header-navigation'
|
||||
| 'sidebar-modules'
|
||||
| 'performance'
|
||||
const sectionContent = getMaintenanceSectionContent(
|
||||
activeSection,
|
||||
settings,
|
||||
status?.version as string | undefined,
|
||||
status?.start_time as number | null | undefined
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='flex h-full w-full flex-1 flex-col'>
|
||||
<div className='faded-bottom h-full w-full overflow-y-auto scroll-smooth pe-4 pb-12'>
|
||||
<div className='space-y-4'>{sectionContent}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
import type { MaintenanceSettings } from '../types'
|
||||
import { createSectionRegistry } from '../utils/section-registry'
|
||||
import {
|
||||
parseHeaderNavModules,
|
||||
parseSidebarModulesAdmin,
|
||||
serializeHeaderNavModules,
|
||||
serializeSidebarModulesAdmin,
|
||||
} from './config'
|
||||
import { HeaderNavigationSection } from './header-navigation-section'
|
||||
import { LogSettingsSection } from './log-settings-section'
|
||||
import { NoticeSection } from './notice-section'
|
||||
import { PerformanceSection } from './performance-section'
|
||||
import { SidebarModulesSection } from './sidebar-modules-section'
|
||||
import { UpdateCheckerSection } from './update-checker-section'
|
||||
|
||||
const MAINTENANCE_SECTIONS = [
|
||||
{
|
||||
id: 'update-checker',
|
||||
titleKey: 'System maintenance',
|
||||
descriptionKey: 'Check for system updates',
|
||||
build: (
|
||||
_settings: MaintenanceSettings,
|
||||
currentVersion?: string | null,
|
||||
startTime?: number | null
|
||||
) => (
|
||||
<UpdateCheckerSection
|
||||
currentVersion={currentVersion}
|
||||
startTime={startTime}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'notice',
|
||||
titleKey: 'System Notice',
|
||||
descriptionKey: 'Configure system maintenance notice',
|
||||
build: (settings: MaintenanceSettings) => (
|
||||
<NoticeSection defaultValue={settings.Notice ?? ''} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'logs',
|
||||
titleKey: 'Log Maintenance',
|
||||
descriptionKey: 'Configure log consumption settings',
|
||||
build: (settings: MaintenanceSettings) => (
|
||||
<LogSettingsSection
|
||||
defaultEnabled={Boolean(settings.LogConsumeEnabled)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'header-navigation',
|
||||
titleKey: 'Header navigation',
|
||||
descriptionKey: 'Configure header navigation modules',
|
||||
build: (settings: MaintenanceSettings) => {
|
||||
const headerNavConfig = parseHeaderNavModules(settings.HeaderNavModules)
|
||||
const headerNavSerialized = serializeHeaderNavModules(headerNavConfig)
|
||||
return (
|
||||
<HeaderNavigationSection
|
||||
config={headerNavConfig}
|
||||
initialSerialized={headerNavSerialized}
|
||||
/>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'sidebar-modules',
|
||||
titleKey: 'Sidebar modules',
|
||||
descriptionKey: 'Configure sidebar modules for admin',
|
||||
build: (settings: MaintenanceSettings) => {
|
||||
const sidebarConfig = parseSidebarModulesAdmin(
|
||||
settings.SidebarModulesAdmin
|
||||
)
|
||||
const sidebarSerialized = serializeSidebarModulesAdmin(sidebarConfig)
|
||||
return (
|
||||
<SidebarModulesSection
|
||||
config={sidebarConfig}
|
||||
initialSerialized={sidebarSerialized}
|
||||
/>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'performance',
|
||||
titleKey: 'Performance',
|
||||
descriptionKey: 'Disk cache, system monitoring and performance stats',
|
||||
build: (settings: MaintenanceSettings) => (
|
||||
<PerformanceSection
|
||||
defaultValues={{
|
||||
'performance_setting.disk_cache_enabled':
|
||||
settings['performance_setting.disk_cache_enabled'] ?? false,
|
||||
'performance_setting.disk_cache_threshold_mb':
|
||||
settings['performance_setting.disk_cache_threshold_mb'] ?? 10,
|
||||
'performance_setting.disk_cache_max_size_mb':
|
||||
settings['performance_setting.disk_cache_max_size_mb'] ?? 1024,
|
||||
'performance_setting.disk_cache_path':
|
||||
settings['performance_setting.disk_cache_path'] ?? '',
|
||||
'performance_setting.monitor_enabled':
|
||||
settings['performance_setting.monitor_enabled'] ?? false,
|
||||
'performance_setting.monitor_cpu_threshold':
|
||||
settings['performance_setting.monitor_cpu_threshold'] ?? 90,
|
||||
'performance_setting.monitor_memory_threshold':
|
||||
settings['performance_setting.monitor_memory_threshold'] ?? 90,
|
||||
'performance_setting.monitor_disk_threshold':
|
||||
settings['performance_setting.monitor_disk_threshold'] ?? 95,
|
||||
'perf_metrics_setting.enabled':
|
||||
settings['perf_metrics_setting.enabled'] ?? true,
|
||||
'perf_metrics_setting.flush_interval':
|
||||
settings['perf_metrics_setting.flush_interval'] ?? 5,
|
||||
'perf_metrics_setting.bucket_time':
|
||||
settings['perf_metrics_setting.bucket_time'] ?? 'hour',
|
||||
'perf_metrics_setting.retention_days':
|
||||
settings['perf_metrics_setting.retention_days'] ?? 0,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
] as const
|
||||
|
||||
export type MaintenanceSectionId = (typeof MAINTENANCE_SECTIONS)[number]['id']
|
||||
|
||||
const maintenanceRegistry = createSectionRegistry<
|
||||
MaintenanceSectionId,
|
||||
MaintenanceSettings,
|
||||
[string | null | undefined, number | null | undefined]
|
||||
>({
|
||||
sections: MAINTENANCE_SECTIONS,
|
||||
defaultSection: 'update-checker',
|
||||
basePath: '/system-settings/maintenance',
|
||||
urlStyle: 'path',
|
||||
})
|
||||
|
||||
export const MAINTENANCE_SECTION_IDS = maintenanceRegistry.sectionIds
|
||||
export const MAINTENANCE_DEFAULT_SECTION = maintenanceRegistry.defaultSection
|
||||
export const getMaintenanceSectionNavItems =
|
||||
maintenanceRegistry.getSectionNavItems
|
||||
export const getMaintenanceSectionContent =
|
||||
maintenanceRegistry.getSectionContent
|
||||
@@ -43,6 +43,13 @@ const defaultModelSettings: ModelSettings = {
|
||||
AutoGroups: '',
|
||||
DefaultUseAutoGroup: false,
|
||||
'group_ratio_setting.group_special_usable_group': '{}',
|
||||
'channel_affinity_setting.enabled': false,
|
||||
'channel_affinity_setting.switch_on_success': true,
|
||||
'channel_affinity_setting.max_entries': 100000,
|
||||
'channel_affinity_setting.default_ttl_seconds': 3600,
|
||||
'channel_affinity_setting.rules': '[]',
|
||||
'model_deployment.ionet.api_key': '',
|
||||
'model_deployment.ionet.enabled': false,
|
||||
}
|
||||
|
||||
export function ModelSettings() {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,656 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import * as z from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||
import { TieredPricingEditor } from './tiered-pricing-editor'
|
||||
|
||||
const createModelDialogSchema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
name: z.string().min(1, t('Model name is required')),
|
||||
price: z.string().optional(),
|
||||
ratio: z.string().optional(),
|
||||
cacheRatio: z.string().optional(),
|
||||
createCacheRatio: z.string().optional(),
|
||||
completionRatio: z.string().optional(),
|
||||
imageRatio: z.string().optional(),
|
||||
audioRatio: z.string().optional(),
|
||||
audioCompletionRatio: z.string().optional(),
|
||||
})
|
||||
|
||||
type ModelDialogFormValues = z.infer<ReturnType<typeof createModelDialogSchema>>
|
||||
|
||||
type PricingMode = 'per-token' | 'per-request' | 'tiered_expr'
|
||||
type PricingSubMode = 'ratio' | 'price'
|
||||
|
||||
export type ModelRatioData = {
|
||||
name: string
|
||||
price?: string
|
||||
ratio?: string
|
||||
cacheRatio?: string
|
||||
createCacheRatio?: string
|
||||
completionRatio?: string
|
||||
imageRatio?: string
|
||||
audioRatio?: string
|
||||
audioCompletionRatio?: string
|
||||
billingMode?: 'per-token' | 'per-request' | 'tiered_expr'
|
||||
billingExpr?: string
|
||||
requestRuleExpr?: string
|
||||
}
|
||||
|
||||
type ModelRatioDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSave: (data: ModelRatioData) => void
|
||||
editData?: ModelRatioData | null
|
||||
}
|
||||
|
||||
export function ModelRatioDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
editData,
|
||||
}: ModelRatioDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const [pricingMode, setPricingMode] = useState<PricingMode>('per-token')
|
||||
const [pricingSubMode, setPricingSubMode] = useState<PricingSubMode>('ratio')
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||
const [promptPrice, setPromptPrice] = useState('')
|
||||
const [completionPrice, setCompletionPrice] = useState('')
|
||||
const [billingExpr, setBillingExpr] = useState('')
|
||||
const [requestRuleExpr, setRequestRuleExpr] = useState('')
|
||||
const isEditMode = !!editData
|
||||
|
||||
const form = useForm<ModelDialogFormValues>({
|
||||
resolver: zodResolver(createModelDialogSchema(t)),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
price: '',
|
||||
ratio: '',
|
||||
cacheRatio: '',
|
||||
createCacheRatio: '',
|
||||
completionRatio: '',
|
||||
imageRatio: '',
|
||||
audioRatio: '',
|
||||
audioCompletionRatio: '',
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (editData) {
|
||||
form.reset(editData)
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setBillingExpr(editData.billingExpr || '')
|
||||
setRequestRuleExpr(editData.requestRuleExpr || '')
|
||||
|
||||
if (editData.billingMode === 'tiered_expr') {
|
||||
setPricingMode('tiered_expr')
|
||||
} else if (editData.price && editData.price !== '') {
|
||||
setPricingMode('per-request')
|
||||
} else {
|
||||
setPricingMode('per-token')
|
||||
if (editData.ratio) {
|
||||
const tokenPrice = parseFloat(editData.ratio) * 2
|
||||
setPromptPrice(tokenPrice.toString())
|
||||
if (editData.completionRatio) {
|
||||
const compPrice = tokenPrice * parseFloat(editData.completionRatio)
|
||||
setCompletionPrice(compPrice.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
form.reset({
|
||||
name: '',
|
||||
price: '',
|
||||
ratio: '',
|
||||
cacheRatio: '',
|
||||
createCacheRatio: '',
|
||||
completionRatio: '',
|
||||
imageRatio: '',
|
||||
audioRatio: '',
|
||||
audioCompletionRatio: '',
|
||||
})
|
||||
setPricingMode('per-token')
|
||||
setPricingSubMode('ratio')
|
||||
setPromptPrice('')
|
||||
setCompletionPrice('')
|
||||
setBillingExpr('')
|
||||
setRequestRuleExpr('')
|
||||
setAdvancedOpen(false)
|
||||
}
|
||||
}, [editData, form, open])
|
||||
|
||||
const handleSubmit = (values: ModelDialogFormValues) => {
|
||||
// Always pass through every field. The visual editor decides what to
|
||||
// persist based on `billingMode`, and tiered_expr models also keep the
|
||||
// ratio/price values as fallback during multi-instance sync delays
|
||||
// (the backend's ModelPriceHelper checks billing_mode first, so these
|
||||
// fallbacks only kick in when billing_setting hasn't propagated yet).
|
||||
const data: ModelRatioData = {
|
||||
name: values.name,
|
||||
billingMode: pricingMode,
|
||||
price: values.price || '',
|
||||
ratio: values.ratio || '',
|
||||
cacheRatio: values.cacheRatio || '',
|
||||
createCacheRatio: values.createCacheRatio || '',
|
||||
completionRatio: values.completionRatio || '',
|
||||
imageRatio: values.imageRatio || '',
|
||||
audioRatio: values.audioRatio || '',
|
||||
audioCompletionRatio: values.audioCompletionRatio || '',
|
||||
}
|
||||
|
||||
if (pricingMode === 'tiered_expr') {
|
||||
data.billingExpr = billingExpr
|
||||
data.requestRuleExpr = requestRuleExpr
|
||||
}
|
||||
|
||||
onSave(data)
|
||||
form.reset()
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const validateNumber = (value: string) => {
|
||||
if (value === '') return true
|
||||
return !isNaN(parseFloat(value))
|
||||
}
|
||||
|
||||
const handlePromptPriceChange = (value: string) => {
|
||||
setPromptPrice(value)
|
||||
if (value && !isNaN(parseFloat(value))) {
|
||||
const ratio = parseFloat(value) / 2
|
||||
form.setValue('ratio', ratio.toString())
|
||||
} else {
|
||||
form.setValue('ratio', '')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCompletionPriceChange = (value: string) => {
|
||||
setCompletionPrice(value)
|
||||
if (
|
||||
value &&
|
||||
!isNaN(parseFloat(value)) &&
|
||||
promptPrice &&
|
||||
!isNaN(parseFloat(promptPrice)) &&
|
||||
parseFloat(promptPrice) > 0
|
||||
) {
|
||||
const completionRatio = parseFloat(value) / parseFloat(promptPrice)
|
||||
form.setValue('completionRatio', completionRatio.toString())
|
||||
} else {
|
||||
form.setValue('completionRatio', '')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='max-h-[90vh] overflow-y-auto sm:max-w-[680px]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEditMode ? t('Edit model') : t('Add model')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('Configure pricing ratios for a specific model.')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className='space-y-6'
|
||||
autoComplete='off'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Model name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('gpt-4')}
|
||||
{...field}
|
||||
disabled={isEditMode}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('The exact model identifier as used in API requests.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className='space-y-4'>
|
||||
<Label>{t('Pricing mode')}</Label>
|
||||
<RadioGroup
|
||||
value={pricingMode}
|
||||
onValueChange={(value) => setPricingMode(value as PricingMode)}
|
||||
>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<RadioGroupItem value='per-token' id='per-token' />
|
||||
<Label htmlFor='per-token' className='font-normal'>
|
||||
{t('Per-token (ratio based)')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<RadioGroupItem value='per-request' id='per-request' />
|
||||
<Label htmlFor='per-request' className='font-normal'>
|
||||
{t('Per-request (fixed price)')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<RadioGroupItem value='tiered_expr' id='tiered_expr' />
|
||||
<Label htmlFor='tiered_expr' className='font-normal'>
|
||||
{t('Tiered (billing expression)')}
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{pricingMode === 'tiered_expr' ? (
|
||||
<TieredPricingEditor
|
||||
modelName={form.getValues('name')}
|
||||
billingExpr={billingExpr}
|
||||
requestRuleExpr={requestRuleExpr}
|
||||
onBillingExprChange={setBillingExpr}
|
||||
onRequestRuleExprChange={setRequestRuleExpr}
|
||||
/>
|
||||
) : pricingMode === 'per-request' ? (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='price'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Fixed price (USD)')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder='0.01'
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
if (validateNumber(value)) {
|
||||
field.onChange(value)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Cost in USD per request, regardless of tokens used.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className='space-y-4'>
|
||||
<Label>{t('Input mode')}</Label>
|
||||
<RadioGroup
|
||||
value={pricingSubMode}
|
||||
onValueChange={(value) =>
|
||||
setPricingSubMode(value as PricingSubMode)
|
||||
}
|
||||
>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<RadioGroupItem value='ratio' id='ratio' />
|
||||
<Label htmlFor='ratio' className='font-normal'>
|
||||
{t('Ratio mode')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<RadioGroupItem value='price' id='price' />
|
||||
<Label htmlFor='price' className='font-normal'>
|
||||
{t('Price mode (USD per 1M tokens)')}
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{pricingSubMode === 'ratio' ? (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='ratio'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Model ratio')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder='1.0'
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
if (validateNumber(value)) {
|
||||
field.onChange(value)
|
||||
if (value) {
|
||||
setPromptPrice(
|
||||
(parseFloat(value) * 2).toString()
|
||||
)
|
||||
} else {
|
||||
setPromptPrice('')
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{field.value && !isNaN(parseFloat(field.value))
|
||||
? t(
|
||||
'Calculated price: ${{price}} per 1M tokens',
|
||||
{
|
||||
price: (
|
||||
parseFloat(field.value) * 2
|
||||
).toFixed(4),
|
||||
}
|
||||
)
|
||||
: t('Multiplier for prompt tokens.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='completionRatio'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Completion ratio')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder='1.0'
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
if (validateNumber(value)) {
|
||||
field.onChange(value)
|
||||
const ratio = form.getValues('ratio')
|
||||
if (value && ratio) {
|
||||
const compPrice =
|
||||
parseFloat(ratio) * 2 * parseFloat(value)
|
||||
setCompletionPrice(compPrice.toString())
|
||||
} else {
|
||||
setCompletionPrice('')
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{field.value &&
|
||||
!isNaN(parseFloat(field.value)) &&
|
||||
promptPrice &&
|
||||
!isNaN(parseFloat(promptPrice))
|
||||
? t(
|
||||
'Calculated price: ${{price}} per 1M tokens',
|
||||
{
|
||||
price: (
|
||||
parseFloat(promptPrice) *
|
||||
parseFloat(field.value)
|
||||
).toFixed(4),
|
||||
}
|
||||
)
|
||||
: t('Multiplier for completion tokens.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label>{t('Prompt price ($/1M tokens)')}</Label>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder='2.0'
|
||||
value={promptPrice}
|
||||
onChange={(e) =>
|
||||
handlePromptPriceChange(e.target.value)
|
||||
}
|
||||
/>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{promptPrice && !isNaN(parseFloat(promptPrice))
|
||||
? t('Calculated ratio: {{ratio}}', {
|
||||
ratio: (parseFloat(promptPrice) / 2).toFixed(4),
|
||||
})
|
||||
: t('Enter Input price to calculate ratio')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label>{t('Completion price ($/1M tokens)')}</Label>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder='4.0'
|
||||
value={completionPrice}
|
||||
onChange={(e) =>
|
||||
handleCompletionPriceChange(e.target.value)
|
||||
}
|
||||
/>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{completionPrice &&
|
||||
!isNaN(parseFloat(completionPrice)) &&
|
||||
promptPrice &&
|
||||
!isNaN(parseFloat(promptPrice)) &&
|
||||
parseFloat(promptPrice) > 0
|
||||
? t('Calculated ratio: {{ratio}}', {
|
||||
ratio: (
|
||||
parseFloat(completionPrice) /
|
||||
parseFloat(promptPrice)
|
||||
).toFixed(4),
|
||||
})
|
||||
: t('Enter Completion price to calculate ratio')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||||
<CollapsibleTrigger
|
||||
render={
|
||||
<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>
|
||||
<CollapsibleContent className='space-y-6 pt-6'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='cacheRatio'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Cache ratio')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder='0.1'
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
if (validateNumber(value)) {
|
||||
field.onChange(value)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Discount ratio for cache hits.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='createCacheRatio'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Create cache ratio')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder='1.25'
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
if (validateNumber(value)) {
|
||||
field.onChange(value)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Ratio applied when creating cache entries for supported models.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='imageRatio'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Image ratio')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder='1.0'
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
if (validateNumber(value)) {
|
||||
field.onChange(value)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Multiplier for image processing.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='audioRatio'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Audio ratio')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder='1.0'
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
if (validateNumber(value)) {
|
||||
field.onChange(value)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Multiplier for audio inputs.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='audioCompletionRatio'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Audio completion ratio')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder='1.0'
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
if (validateNumber(value)) {
|
||||
field.onChange(value)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Multiplier for audio outputs.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button type='submit'>
|
||||
{isEditMode ? t('Update') : t('Add')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
+478
-236
@@ -1,19 +1,27 @@
|
||||
import { useState, useMemo, memo, useCallback, useEffect } from 'react'
|
||||
import {
|
||||
type ColumnDef,
|
||||
type ColumnFiltersState,
|
||||
type OnChangeFn,
|
||||
type PaginationState,
|
||||
type RowSelectionState,
|
||||
type VisibilityState,
|
||||
type SortingState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFacetedRowModel,
|
||||
getFacetedUniqueValues,
|
||||
getFilteredRowModel,
|
||||
getSortedRowModel,
|
||||
getPaginationRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table'
|
||||
import { Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
import { useMediaQuery } from '@/hooks'
|
||||
import { Copy, Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -23,6 +31,7 @@ import {
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
DataTableBulkActions,
|
||||
DataTableColumnHeader,
|
||||
DataTableToolbar,
|
||||
DataTablePagination,
|
||||
@@ -33,7 +42,11 @@ import {
|
||||
splitBillingExprAndRequestRules,
|
||||
} from '@/features/pricing/lib/billing-expr'
|
||||
import { safeJsonParse } from '../utils/json-parser'
|
||||
import { ModelRatioDialog, type ModelRatioData } from './model-ratio-dialog'
|
||||
import {
|
||||
ModelPricingEditorPanel,
|
||||
ModelPricingSheet,
|
||||
type ModelRatioData,
|
||||
} from './model-pricing-sheet'
|
||||
|
||||
type ModelRatioVisualEditorProps = {
|
||||
modelPrice: string
|
||||
@@ -67,9 +80,101 @@ type ModelRow = {
|
||||
|
||||
const STORAGE_KEY = 'model-ratio-column-visibility'
|
||||
|
||||
const formatValue = (value?: string) => {
|
||||
if (!value || value === '') return '—'
|
||||
return value
|
||||
const hasValue = (value?: string) => value !== undefined && value !== ''
|
||||
|
||||
const toNumberOrNull = (value?: string) => {
|
||||
if (!hasValue(value)) return null
|
||||
const num = Number(value)
|
||||
return Number.isFinite(num) ? num : null
|
||||
}
|
||||
|
||||
const formatPrice = (value: number) => {
|
||||
return Number.parseFloat(value.toFixed(12)).toString()
|
||||
}
|
||||
|
||||
const ratioToPrice = (ratio?: string, denominator?: string) => {
|
||||
const ratioNumber = toNumberOrNull(ratio)
|
||||
const denominatorNumber = denominator ? toNumberOrNull(denominator) : 2
|
||||
if (ratioNumber === null || denominatorNumber === null) return ''
|
||||
return formatPrice(ratioNumber * denominatorNumber)
|
||||
}
|
||||
|
||||
const filterBySelectedValues = (
|
||||
rowValue: unknown,
|
||||
filterValue: unknown
|
||||
): boolean => {
|
||||
if (!Array.isArray(filterValue) || filterValue.length === 0) return true
|
||||
return filterValue.includes(String(rowValue))
|
||||
}
|
||||
|
||||
const getModeLabel = (mode?: string) => {
|
||||
if (mode === 'per-request') return 'Per-request'
|
||||
if (mode === 'tiered_expr') return 'Expression'
|
||||
return 'Per-token'
|
||||
}
|
||||
|
||||
const getModeVariant = (mode?: string): 'warning' | 'info' | 'success' => {
|
||||
if (mode === 'per-request') return 'warning'
|
||||
if (mode === 'tiered_expr') return 'info'
|
||||
return 'success'
|
||||
}
|
||||
|
||||
const getExpressionSummary = (row: ModelRow, t: (key: string) => string) => {
|
||||
const tierCount = (row.billingExpr?.match(/tier\(/g) || []).length
|
||||
if (tierCount > 0) {
|
||||
return `${t('Tiered pricing')} · ${tierCount} ${t('tiers')}`
|
||||
}
|
||||
return t('Expression pricing')
|
||||
}
|
||||
|
||||
const getPriceSummary = (row: ModelRow, t: (key: string) => string) => {
|
||||
if (row.billingMode === 'tiered_expr') {
|
||||
return getExpressionSummary(row, t)
|
||||
}
|
||||
if (row.billingMode === 'per-request') {
|
||||
return row.price ? `$${row.price} / ${t('request')}` : t('Unset price')
|
||||
}
|
||||
|
||||
const inputPrice = ratioToPrice(row.ratio)
|
||||
if (!inputPrice) return t('Unset price')
|
||||
|
||||
const extraCount = [
|
||||
row.completionRatio,
|
||||
row.cacheRatio,
|
||||
row.createCacheRatio,
|
||||
row.imageRatio,
|
||||
row.audioRatio,
|
||||
row.audioCompletionRatio,
|
||||
].filter(hasValue).length
|
||||
|
||||
return extraCount > 0
|
||||
? `${t('Input')} $${inputPrice} · ${extraCount} ${t('extras')}`
|
||||
: `${t('Input')} $${inputPrice}`
|
||||
}
|
||||
|
||||
const getPriceDetail = (row: ModelRow, t: (key: string) => string) => {
|
||||
if (row.billingMode === 'tiered_expr') {
|
||||
return row.requestRuleExpr
|
||||
? t('Includes request rules')
|
||||
: t('Expression based')
|
||||
}
|
||||
if (row.billingMode === 'per-request') {
|
||||
return t('Fixed request price')
|
||||
}
|
||||
|
||||
const inputPrice = ratioToPrice(row.ratio)
|
||||
if (!inputPrice) return t('No base input price')
|
||||
|
||||
const details = [
|
||||
row.completionRatio &&
|
||||
`${t('Output')} $${ratioToPrice(row.completionRatio, inputPrice)}`,
|
||||
row.cacheRatio &&
|
||||
`${t('Cache')} $${ratioToPrice(row.cacheRatio, inputPrice)}`,
|
||||
row.createCacheRatio &&
|
||||
`${t('Cache write')} $${ratioToPrice(row.createCacheRatio, inputPrice)}`,
|
||||
].filter(Boolean)
|
||||
|
||||
return details.length > 0 ? details.join(' · ') : t('Base input price only')
|
||||
}
|
||||
|
||||
export const ModelRatioVisualEditor = memo(
|
||||
@@ -87,12 +192,17 @@ export const ModelRatioVisualEditor = memo(
|
||||
onChange,
|
||||
}: ModelRatioVisualEditorProps) {
|
||||
const { t } = useTranslation()
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const isMobile = useMediaQuery('(max-width: 767px)')
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const [editorOpen, setEditorOpen] = useState(false)
|
||||
const [editData, setEditData] = useState<ModelRatioData | null>(null)
|
||||
const [sorting, setSorting] = useState<SortingState>([])
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||
const [globalFilter, setGlobalFilter] = useState('')
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
pageSize: 20,
|
||||
})
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
|
||||
() => {
|
||||
@@ -102,6 +212,7 @@ export const ModelRatioVisualEditor = memo(
|
||||
return safeJsonParse<VisibilityState>(saved, {
|
||||
fallback: {
|
||||
cacheRatio: false,
|
||||
createCacheRatio: false,
|
||||
imageRatio: false,
|
||||
audioRatio: false,
|
||||
audioCompletionRatio: false,
|
||||
@@ -265,34 +376,82 @@ export const ModelRatioVisualEditor = memo(
|
||||
billingExpr,
|
||||
])
|
||||
|
||||
const handleEdit = useCallback((model: ModelRow) => {
|
||||
setEditData({
|
||||
name: model.name,
|
||||
price: model.price,
|
||||
ratio: model.ratio,
|
||||
cacheRatio: model.cacheRatio,
|
||||
createCacheRatio: model.createCacheRatio,
|
||||
completionRatio: model.completionRatio,
|
||||
imageRatio: model.imageRatio,
|
||||
audioRatio: model.audioRatio,
|
||||
audioCompletionRatio: model.audioCompletionRatio,
|
||||
billingMode:
|
||||
model.billingMode === 'tiered_expr'
|
||||
? 'tiered_expr'
|
||||
: model.price && model.price !== ''
|
||||
? 'per-request'
|
||||
: 'per-token',
|
||||
billingExpr: model.billingExpr,
|
||||
requestRuleExpr: model.requestRuleExpr,
|
||||
})
|
||||
setDialogOpen(true)
|
||||
}, [])
|
||||
const modeCounts = useMemo(
|
||||
() =>
|
||||
models.reduce(
|
||||
(acc, model) => {
|
||||
const mode =
|
||||
model.billingMode === 'per-request' ||
|
||||
model.billingMode === 'tiered_expr'
|
||||
? model.billingMode
|
||||
: 'per-token'
|
||||
acc[mode] += 1
|
||||
return acc
|
||||
},
|
||||
{
|
||||
'per-token': 0,
|
||||
'per-request': 0,
|
||||
tiered_expr: 0,
|
||||
} as Record<'per-token' | 'per-request' | 'tiered_expr', number>
|
||||
),
|
||||
[models]
|
||||
)
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(model: ModelRow) => {
|
||||
setEditData({
|
||||
name: model.name,
|
||||
price: model.price,
|
||||
ratio: model.ratio,
|
||||
cacheRatio: model.cacheRatio,
|
||||
createCacheRatio: model.createCacheRatio,
|
||||
completionRatio: model.completionRatio,
|
||||
imageRatio: model.imageRatio,
|
||||
audioRatio: model.audioRatio,
|
||||
audioCompletionRatio: model.audioCompletionRatio,
|
||||
billingMode:
|
||||
model.billingMode === 'tiered_expr'
|
||||
? 'tiered_expr'
|
||||
: model.price && model.price !== ''
|
||||
? 'per-request'
|
||||
: 'per-token',
|
||||
billingExpr: model.billingExpr,
|
||||
requestRuleExpr: model.requestRuleExpr,
|
||||
})
|
||||
setEditorOpen(true)
|
||||
if (isMobile) setSheetOpen(true)
|
||||
},
|
||||
[isMobile]
|
||||
)
|
||||
|
||||
const handleAdd = useCallback(() => {
|
||||
setEditData(null)
|
||||
setDialogOpen(true)
|
||||
setEditorOpen(true)
|
||||
if (isMobile) setSheetOpen(true)
|
||||
}, [isMobile])
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setEditData(null)
|
||||
setEditorOpen(false)
|
||||
setSheetOpen(false)
|
||||
}, [])
|
||||
|
||||
const handleGlobalFilterChange = useCallback<OnChangeFn<string>>(
|
||||
(updater) => {
|
||||
setGlobalFilter((previous) => {
|
||||
const next =
|
||||
typeof updater === 'function' ? updater(previous) : updater
|
||||
if (next !== previous) {
|
||||
setEditData(null)
|
||||
setEditorOpen(false)
|
||||
setSheetOpen(false)
|
||||
}
|
||||
return next
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(name: string) => {
|
||||
const priceMap = safeJsonParse<Record<string, number>>(modelPrice, {
|
||||
@@ -383,15 +542,32 @@ export const ModelRatioVisualEditor = memo(
|
||||
)
|
||||
|
||||
const columns = useMemo<ColumnDef<ModelRow>[]>(() => {
|
||||
// Ratio fields are not the primary pricing when a per-request fixed
|
||||
// price is set, or when the model is in tiered_expr mode (the
|
||||
// expression is primary; ratios are fallback during sync delays).
|
||||
const isFallbackRow = (row: ModelRow) =>
|
||||
row.billingMode === 'tiered_expr' || !!row.price
|
||||
const fallbackClass = (row: ModelRow) =>
|
||||
isFallbackRow(row) ? 'text-muted-foreground' : ''
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
indeterminate={table.getIsSomePageRowsSelected()}
|
||||
onCheckedChange={(value) =>
|
||||
table.toggleAllPageRowsSelected(!!value)
|
||||
}
|
||||
aria-label={t('Select all')}
|
||||
className='translate-y-[2px]'
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label={t('Select row')}
|
||||
className='translate-y-[2px]'
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
meta: { label: t('Select') },
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: ({ column }) => (
|
||||
@@ -419,106 +595,41 @@ export const ModelRatioVisualEditor = memo(
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: 'price',
|
||||
accessorKey: 'billingMode',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Fixed price')} />
|
||||
<DataTableColumnHeader column={column} title={t('Mode')} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className={
|
||||
row.original.billingMode === 'tiered_expr'
|
||||
? 'text-muted-foreground'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{formatValue(row.getValue('price'))}
|
||||
</span>
|
||||
<StatusBadge
|
||||
label={t(getModeLabel(row.original.billingMode))}
|
||||
variant={getModeVariant(row.original.billingMode)}
|
||||
copyable={false}
|
||||
/>
|
||||
),
|
||||
meta: { label: 'Fixed price' },
|
||||
filterFn: (row, id, value) =>
|
||||
filterBySelectedValues(row.getValue(id), value),
|
||||
meta: { label: t('Mode') },
|
||||
},
|
||||
{
|
||||
accessorKey: 'ratio',
|
||||
id: 'priceSummary',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Ratio')} />
|
||||
<DataTableColumnHeader column={column} title={t('Price summary')} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className={fallbackClass(row.original)}>
|
||||
{formatValue(row.getValue('ratio'))}
|
||||
</span>
|
||||
<div className='flex min-w-[180px] flex-col gap-1'>
|
||||
<span className='font-medium'>
|
||||
{getPriceSummary(row.original, t)}
|
||||
</span>
|
||||
<span className='text-muted-foreground max-w-[320px] truncate text-xs'>
|
||||
{getPriceDetail(row.original, t)}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
meta: { label: 'Ratio' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'completionRatio',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Completion')} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className={fallbackClass(row.original)}>
|
||||
{formatValue(row.getValue('completionRatio'))}
|
||||
</span>
|
||||
),
|
||||
meta: { label: 'Completion' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'cacheRatio',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Cache')} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className={fallbackClass(row.original)}>
|
||||
{formatValue(row.getValue('cacheRatio'))}
|
||||
</span>
|
||||
),
|
||||
meta: { label: 'Cache' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'createCacheRatio',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Create cache')} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className={fallbackClass(row.original)}>
|
||||
{formatValue(row.getValue('createCacheRatio'))}
|
||||
</span>
|
||||
),
|
||||
meta: { label: 'Create cache' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'imageRatio',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Image')} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className={fallbackClass(row.original)}>
|
||||
{formatValue(row.getValue('imageRatio'))}
|
||||
</span>
|
||||
),
|
||||
meta: { label: 'Image' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'audioRatio',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Audio')} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className={fallbackClass(row.original)}>
|
||||
{formatValue(row.getValue('audioRatio'))}
|
||||
</span>
|
||||
),
|
||||
meta: { label: 'Audio' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'audioCompletionRatio',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Audio comp.')} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className={fallbackClass(row.original)}>
|
||||
{formatValue(row.getValue('audioCompletionRatio'))}
|
||||
</span>
|
||||
),
|
||||
meta: { label: 'Audio comp.' },
|
||||
sortingFn: (rowA, rowB) =>
|
||||
getPriceSummary(rowA.original, t).localeCompare(
|
||||
getPriceSummary(rowB.original, t)
|
||||
),
|
||||
meta: { label: t('Price summary') },
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
@@ -529,14 +640,14 @@ export const ModelRatioVisualEditor = memo(
|
||||
size='sm'
|
||||
onClick={() => handleEdit(row.original)}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
<Pencil />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => handleDelete(row.original.name)}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
@@ -550,25 +661,34 @@ export const ModelRatioVisualEditor = memo(
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
globalFilter,
|
||||
columnVisibility,
|
||||
pagination,
|
||||
rowSelection,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onGlobalFilterChange: handleGlobalFilterChange,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onPaginationChange: setPagination,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
autoResetPageIndex: false,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getFacetedRowModel: getFacetedRowModel(),
|
||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||
globalFilterFn: (row, _columnId, filterValue) => {
|
||||
const searchValue = String(filterValue).toLowerCase()
|
||||
return row.original.name.toLowerCase().includes(searchValue)
|
||||
},
|
||||
})
|
||||
|
||||
const handleSave = useCallback(
|
||||
(data: ModelRatioData) => {
|
||||
const persistPricingData = useCallback(
|
||||
(data: ModelRatioData, targetNames: string[] = [data.name]) => {
|
||||
const priceMap = safeJsonParse<Record<string, number>>(modelPrice, {
|
||||
fallback: {},
|
||||
silent: true,
|
||||
@@ -610,58 +730,61 @@ export const ModelRatioVisualEditor = memo(
|
||||
{ fallback: {}, silent: true }
|
||||
)
|
||||
|
||||
delete priceMap[data.name]
|
||||
delete ratioMap[data.name]
|
||||
delete cacheMap[data.name]
|
||||
delete createCacheMap[data.name]
|
||||
delete completionMap[data.name]
|
||||
delete imageMap[data.name]
|
||||
delete audioMap[data.name]
|
||||
delete audioCompletionMap[data.name]
|
||||
delete billingModeMap[data.name]
|
||||
delete billingExprMap[data.name]
|
||||
|
||||
const setIfPresent = (
|
||||
target: Record<string, number>,
|
||||
name: string,
|
||||
value: string | undefined
|
||||
) => {
|
||||
if (!value || value === '') return
|
||||
const parsed = parseFloat(value)
|
||||
if (Number.isFinite(parsed)) target[data.name] = parsed
|
||||
if (Number.isFinite(parsed)) target[name] = parsed
|
||||
}
|
||||
|
||||
if (data.billingMode === 'tiered_expr') {
|
||||
const combined = combineBillingExpr(
|
||||
data.billingExpr || '',
|
||||
data.requestRuleExpr || ''
|
||||
)
|
||||
if (combined) {
|
||||
billingModeMap[data.name] = 'tiered_expr'
|
||||
billingExprMap[data.name] = combined
|
||||
targetNames.forEach((name) => {
|
||||
delete priceMap[name]
|
||||
delete ratioMap[name]
|
||||
delete cacheMap[name]
|
||||
delete createCacheMap[name]
|
||||
delete completionMap[name]
|
||||
delete imageMap[name]
|
||||
delete audioMap[name]
|
||||
delete audioCompletionMap[name]
|
||||
delete billingModeMap[name]
|
||||
delete billingExprMap[name]
|
||||
|
||||
if (data.billingMode === 'tiered_expr') {
|
||||
const combined = combineBillingExpr(
|
||||
data.billingExpr || '',
|
||||
data.requestRuleExpr || ''
|
||||
)
|
||||
if (combined) {
|
||||
billingModeMap[name] = 'tiered_expr'
|
||||
billingExprMap[name] = combined
|
||||
}
|
||||
// Always serialize ratio/price values for tiered_expr models so they
|
||||
// serve as fallback during multi-instance sync delays. The backend's
|
||||
// ModelPriceHelper checks billing_mode first, so these values are
|
||||
// only consulted when billing_setting hasn't propagated yet.
|
||||
setIfPresent(priceMap, name, data.price)
|
||||
setIfPresent(ratioMap, name, data.ratio)
|
||||
setIfPresent(cacheMap, name, data.cacheRatio)
|
||||
setIfPresent(createCacheMap, name, data.createCacheRatio)
|
||||
setIfPresent(completionMap, name, data.completionRatio)
|
||||
setIfPresent(imageMap, name, data.imageRatio)
|
||||
setIfPresent(audioMap, name, data.audioRatio)
|
||||
setIfPresent(audioCompletionMap, name, data.audioCompletionRatio)
|
||||
} else if (data.price && data.price !== '') {
|
||||
setIfPresent(priceMap, name, data.price)
|
||||
} else {
|
||||
setIfPresent(ratioMap, name, data.ratio)
|
||||
setIfPresent(cacheMap, name, data.cacheRatio)
|
||||
setIfPresent(createCacheMap, name, data.createCacheRatio)
|
||||
setIfPresent(completionMap, name, data.completionRatio)
|
||||
setIfPresent(imageMap, name, data.imageRatio)
|
||||
setIfPresent(audioMap, name, data.audioRatio)
|
||||
setIfPresent(audioCompletionMap, name, data.audioCompletionRatio)
|
||||
}
|
||||
// Always serialize ratio/price values for tiered_expr models so they
|
||||
// serve as fallback during multi-instance sync delays. The backend's
|
||||
// ModelPriceHelper checks billing_mode first, so these values are
|
||||
// only consulted when billing_setting hasn't propagated yet.
|
||||
setIfPresent(priceMap, data.price)
|
||||
setIfPresent(ratioMap, data.ratio)
|
||||
setIfPresent(cacheMap, data.cacheRatio)
|
||||
setIfPresent(createCacheMap, data.createCacheRatio)
|
||||
setIfPresent(completionMap, data.completionRatio)
|
||||
setIfPresent(imageMap, data.imageRatio)
|
||||
setIfPresent(audioMap, data.audioRatio)
|
||||
setIfPresent(audioCompletionMap, data.audioCompletionRatio)
|
||||
} else if (data.price && data.price !== '') {
|
||||
setIfPresent(priceMap, data.price)
|
||||
} else {
|
||||
setIfPresent(ratioMap, data.ratio)
|
||||
setIfPresent(cacheMap, data.cacheRatio)
|
||||
setIfPresent(createCacheMap, data.createCacheRatio)
|
||||
setIfPresent(completionMap, data.completionRatio)
|
||||
setIfPresent(imageMap, data.imageRatio)
|
||||
setIfPresent(audioMap, data.audioRatio)
|
||||
setIfPresent(audioCompletionMap, data.audioCompletionRatio)
|
||||
}
|
||||
})
|
||||
|
||||
onChange('ModelPrice', JSON.stringify(priceMap, null, 2))
|
||||
onChange('ModelRatio', JSON.stringify(ratioMap, null, 2))
|
||||
@@ -698,72 +821,191 @@ export const ModelRatioVisualEditor = memo(
|
||||
]
|
||||
)
|
||||
|
||||
const handleSave = useCallback(
|
||||
(data: ModelRatioData) => {
|
||||
persistPricingData(data)
|
||||
setEditData(data)
|
||||
setEditorOpen(true)
|
||||
},
|
||||
[persistPricingData]
|
||||
)
|
||||
|
||||
const handleBatchCopy = useCallback(() => {
|
||||
if (!editData) {
|
||||
toast.error(t('Open a source model first'))
|
||||
return
|
||||
}
|
||||
|
||||
const targetNames = table
|
||||
.getFilteredSelectedRowModel()
|
||||
.rows.map((row) => row.original.name)
|
||||
|
||||
if (targetNames.length === 0) {
|
||||
toast.error(t('Select at least one target model'))
|
||||
return
|
||||
}
|
||||
|
||||
persistPricingData(editData, targetNames)
|
||||
table.resetRowSelection()
|
||||
toast.success(
|
||||
t('Applied {{name}} pricing to {{count}} models', {
|
||||
name: editData.name,
|
||||
count: targetNames.length,
|
||||
})
|
||||
)
|
||||
}, [editData, persistPricingData, t, table])
|
||||
|
||||
const selectedTargetCount = table.getFilteredSelectedRowModel().rows.length
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='flex items-center justify-between gap-4'>
|
||||
<DataTableToolbar
|
||||
table={table}
|
||||
searchPlaceholder={t('Search models...')}
|
||||
/>
|
||||
<Button onClick={handleAdd}>
|
||||
<Plus className='mr-2 h-4 w-4' />
|
||||
{t('Add model')}
|
||||
</Button>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='grid min-h-0 gap-4 md:grid-cols-[minmax(0,1fr)_minmax(420px,0.82fr)] xl:grid-cols-[minmax(0,1.1fr)_minmax(520px,0.9fr)]'>
|
||||
<div className='flex min-w-0 flex-col gap-4'>
|
||||
<DataTableToolbar
|
||||
table={table}
|
||||
searchPlaceholder={t('Search models...')}
|
||||
filters={[
|
||||
{
|
||||
columnId: 'billingMode',
|
||||
title: t('Mode'),
|
||||
options: [
|
||||
{
|
||||
label: 'Per-token',
|
||||
value: 'per-token',
|
||||
count: modeCounts['per-token'],
|
||||
},
|
||||
{
|
||||
label: 'Per-request',
|
||||
value: 'per-request',
|
||||
count: modeCounts['per-request'],
|
||||
},
|
||||
{
|
||||
label: 'Expression',
|
||||
value: 'tiered_expr',
|
||||
count: modeCounts.tiered_expr,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
preActions={
|
||||
<Button onClick={handleAdd}>
|
||||
<Plus data-icon='inline-start' />
|
||||
{t('Add model')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{table.getRowModel().rows.length === 0 ? (
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
|
||||
{table.getState().globalFilter
|
||||
? t('No models match your search')
|
||||
: t('No models configured. Use Add model to get started.')}
|
||||
</div>
|
||||
) : (
|
||||
<div className='overflow-hidden rounded-md border'>
|
||||
<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>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={
|
||||
row.getIsSelected() ? 'selected' : undefined
|
||||
}
|
||||
className={
|
||||
editData?.name === row.original.name
|
||||
? 'bg-muted/45'
|
||||
: undefined
|
||||
}
|
||||
onClick={(event) => {
|
||||
const target = event.target as HTMLElement
|
||||
if (target.closest('button, [role="checkbox"]'))
|
||||
return
|
||||
handleEdit(row.original)
|
||||
}}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{table.getRowModel().rows.length > 0 && (
|
||||
<DataTablePagination table={table} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='hidden min-w-0 md:block'>
|
||||
{editorOpen ? (
|
||||
<ModelPricingEditorPanel
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
editData={editData}
|
||||
selectedTargetCount={selectedTargetCount}
|
||||
className='sticky top-4 h-[calc(100vh-8rem)] min-h-[620px]'
|
||||
/>
|
||||
) : (
|
||||
<div className='bg-card text-muted-foreground sticky top-4 flex h-[calc(100vh-8rem)] min-h-[420px] flex-col items-center justify-center gap-3 rounded-xl border border-dashed p-6 text-center'>
|
||||
<div className='text-foreground text-base font-medium'>
|
||||
{t('Select a model to edit pricing')}
|
||||
</div>
|
||||
<p className='max-w-sm text-sm'>
|
||||
{t(
|
||||
'Use the full-width table to scan prices, then select a row to edit it here.'
|
||||
)}
|
||||
</p>
|
||||
<Button variant='outline' onClick={handleAdd}>
|
||||
<Plus data-icon='inline-start' />
|
||||
{t('Add model')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{table.getRowModel().rows.length === 0 ? (
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
|
||||
{table.getState().globalFilter
|
||||
? t('No models match your search')
|
||||
: t('No models configured. Click "Add model" to get started.')}
|
||||
</div>
|
||||
) : (
|
||||
<div className='overflow-hidden rounded-md border'>
|
||||
<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>
|
||||
{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>
|
||||
)}
|
||||
<DataTableBulkActions table={table} entityName={t('model')}>
|
||||
<Button size='sm' disabled={!editData} onClick={handleBatchCopy}>
|
||||
<Copy data-icon='inline-start' />
|
||||
{editData
|
||||
? t('Copy {{name}} pricing', { name: editData.name })
|
||||
: t('Open a source model first')}
|
||||
</Button>
|
||||
</DataTableBulkActions>
|
||||
|
||||
{table.getRowModel().rows.length > 0 && (
|
||||
<DataTablePagination table={table} />
|
||||
{isMobile && (
|
||||
<ModelPricingSheet
|
||||
open={sheetOpen}
|
||||
onOpenChange={setSheetOpen}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
editData={editData}
|
||||
selectedTargetCount={selectedTargetCount}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ModelRatioDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
onSave={handleSave}
|
||||
editData={editData}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -179,17 +179,24 @@ const groupSchema = z.object({
|
||||
|
||||
type ModelFormValues = z.infer<typeof modelSchema>
|
||||
type GroupFormValues = z.infer<typeof groupSchema>
|
||||
type RatioTabId = 'models' | 'groups' | 'tool-prices' | 'upstream-sync'
|
||||
|
||||
type RatioSettingsCardProps = {
|
||||
modelDefaults: ModelFormValues
|
||||
groupDefaults: GroupFormValues
|
||||
toolPricesDefault: string
|
||||
titleKey?: string
|
||||
descriptionKey?: string
|
||||
visibleTabs?: RatioTabId[]
|
||||
}
|
||||
|
||||
export function RatioSettingsCard({
|
||||
modelDefaults,
|
||||
groupDefaults,
|
||||
toolPricesDefault,
|
||||
titleKey = 'Pricing Ratios',
|
||||
descriptionKey = 'Configure model, caching, and group ratios used for billing',
|
||||
visibleTabs = ['models', 'groups', 'tool-prices', 'upstream-sync'],
|
||||
}: RatioSettingsCardProps) {
|
||||
const { t } = useTranslation()
|
||||
const updateOption = useUpdateOption()
|
||||
@@ -414,61 +421,79 @@ export function RatioSettingsCard({
|
||||
resetMutate()
|
||||
}, [resetMutate])
|
||||
|
||||
const tabLabels: Record<RatioTabId, string> = {
|
||||
models: 'Model ratios',
|
||||
groups: 'Group ratios',
|
||||
'tool-prices': 'Tool prices',
|
||||
'upstream-sync': 'Upstream price sync',
|
||||
}
|
||||
const tabsGridClass =
|
||||
{
|
||||
1: 'grid-cols-1',
|
||||
2: 'grid-cols-2',
|
||||
3: 'grid-cols-3',
|
||||
4: 'grid-cols-4',
|
||||
}[visibleTabs.length] ?? 'grid-cols-4'
|
||||
const defaultTab = visibleTabs[0] ?? 'models'
|
||||
|
||||
const renderTabContent = (tab: RatioTabId) => {
|
||||
if (tab === 'models') {
|
||||
return (
|
||||
<ModelRatioForm
|
||||
form={modelForm}
|
||||
onSave={saveModelRatios}
|
||||
onReset={handleResetRatios}
|
||||
isSaving={updateOption.isPending}
|
||||
isResetting={resetMutation.isPending}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (tab === 'groups') {
|
||||
return (
|
||||
<GroupRatioForm
|
||||
form={groupForm}
|
||||
onSave={saveGroupRatios}
|
||||
isSaving={updateOption.isPending}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (tab === 'tool-prices') {
|
||||
return <ToolPriceSettings defaultValue={toolPricesDefault} />
|
||||
}
|
||||
return (
|
||||
<UpstreamRatioSync
|
||||
modelRatios={{
|
||||
ModelPrice: modelDefaults.ModelPrice,
|
||||
ModelRatio: modelDefaults.ModelRatio,
|
||||
CompletionRatio: modelDefaults.CompletionRatio,
|
||||
CacheRatio: modelDefaults.CacheRatio,
|
||||
CreateCacheRatio: modelDefaults.CreateCacheRatio,
|
||||
ImageRatio: modelDefaults.ImageRatio,
|
||||
AudioRatio: modelDefaults.AudioRatio,
|
||||
AudioCompletionRatio: modelDefaults.AudioCompletionRatio,
|
||||
'billing_setting.billing_mode': modelDefaults.BillingMode,
|
||||
'billing_setting.billing_expr': modelDefaults.BillingExpr,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t('Pricing Ratios')}
|
||||
description={t(
|
||||
'Configure model, caching, and group ratios used for billing'
|
||||
)}
|
||||
>
|
||||
<Tabs defaultValue='models' className='space-y-6'>
|
||||
<TabsList className='grid w-full grid-cols-4'>
|
||||
<TabsTrigger value='models'>{t('Model ratios')}</TabsTrigger>
|
||||
<TabsTrigger value='groups'>{t('Group ratios')}</TabsTrigger>
|
||||
<TabsTrigger value='tool-prices'>{t('Tool prices')}</TabsTrigger>
|
||||
<TabsTrigger value='upstream-sync'>
|
||||
{t('Upstream price sync')}
|
||||
</TabsTrigger>
|
||||
<SettingsSection title={t(titleKey)} description={t(descriptionKey)}>
|
||||
<Tabs defaultValue={defaultTab} className='space-y-6'>
|
||||
<TabsList className={`grid w-full ${tabsGridClass}`}>
|
||||
{visibleTabs.map((tab) => (
|
||||
<TabsTrigger key={tab} value={tab}>
|
||||
{t(tabLabels[tab])}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='models'>
|
||||
<ModelRatioForm
|
||||
form={modelForm}
|
||||
onSave={saveModelRatios}
|
||||
onReset={handleResetRatios}
|
||||
isSaving={updateOption.isPending}
|
||||
isResetting={resetMutation.isPending}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='groups'>
|
||||
<GroupRatioForm
|
||||
form={groupForm}
|
||||
onSave={saveGroupRatios}
|
||||
isSaving={updateOption.isPending}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='tool-prices'>
|
||||
<ToolPriceSettings defaultValue={toolPricesDefault} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='upstream-sync'>
|
||||
<UpstreamRatioSync
|
||||
modelRatios={{
|
||||
ModelPrice: modelDefaults.ModelPrice,
|
||||
ModelRatio: modelDefaults.ModelRatio,
|
||||
CompletionRatio: modelDefaults.CompletionRatio,
|
||||
CacheRatio: modelDefaults.CacheRatio,
|
||||
CreateCacheRatio: modelDefaults.CreateCacheRatio,
|
||||
ImageRatio: modelDefaults.ImageRatio,
|
||||
AudioRatio: modelDefaults.AudioRatio,
|
||||
AudioCompletionRatio: modelDefaults.AudioCompletionRatio,
|
||||
'billing_setting.billing_mode': modelDefaults.BillingMode,
|
||||
'billing_setting.billing_expr': modelDefaults.BillingExpr,
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
{visibleTabs.map((tab) => (
|
||||
<TabsContent key={tab} value={tab}>
|
||||
{renderTabContent(tab)}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
<ConfirmDialog
|
||||
|
||||
@@ -4,7 +4,8 @@ import { ClaudeSettingsCard } from './claude-settings-card'
|
||||
import { GeminiSettingsCard } from './gemini-settings-card'
|
||||
import { GlobalSettingsCard } from './global-settings-card'
|
||||
import { GrokSettingsCard } from './grok-settings-card'
|
||||
import { RatioSettingsCard } from './ratio-settings-card'
|
||||
import { ChannelAffinitySection } from '../general/channel-affinity'
|
||||
import { IoNetDeploymentSettingsSection } from '../integrations/ionet-deployment-settings-section'
|
||||
|
||||
function formatJsonForEditor(value: string, fallback: string) {
|
||||
const raw = (value ?? '').toString().trim()
|
||||
@@ -106,34 +107,35 @@ const MODELS_SECTIONS = [
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'ratio',
|
||||
titleKey: 'Pricing Ratios',
|
||||
descriptionKey: 'Configure model pricing and ratio settings',
|
||||
id: 'channel-affinity',
|
||||
titleKey: 'Channel Affinity',
|
||||
descriptionKey: 'Configure channel affinity (sticky routing) rules',
|
||||
build: (settings: ModelSettings) => (
|
||||
<RatioSettingsCard
|
||||
modelDefaults={{
|
||||
ModelPrice: settings.ModelPrice,
|
||||
ModelRatio: settings.ModelRatio,
|
||||
CacheRatio: settings.CacheRatio,
|
||||
CreateCacheRatio: settings.CreateCacheRatio,
|
||||
CompletionRatio: settings.CompletionRatio,
|
||||
ImageRatio: settings.ImageRatio,
|
||||
AudioRatio: settings.AudioRatio,
|
||||
AudioCompletionRatio: settings.AudioCompletionRatio,
|
||||
ExposeRatioEnabled: settings.ExposeRatioEnabled,
|
||||
BillingMode: settings['billing_setting.billing_mode'],
|
||||
BillingExpr: settings['billing_setting.billing_expr'],
|
||||
<ChannelAffinitySection
|
||||
defaultValues={{
|
||||
'channel_affinity_setting.enabled':
|
||||
settings['channel_affinity_setting.enabled'],
|
||||
'channel_affinity_setting.switch_on_success':
|
||||
settings['channel_affinity_setting.switch_on_success'],
|
||||
'channel_affinity_setting.max_entries':
|
||||
settings['channel_affinity_setting.max_entries'],
|
||||
'channel_affinity_setting.default_ttl_seconds':
|
||||
settings['channel_affinity_setting.default_ttl_seconds'],
|
||||
'channel_affinity_setting.rules':
|
||||
settings['channel_affinity_setting.rules'],
|
||||
}}
|
||||
toolPricesDefault={settings['tool_price_setting.prices']}
|
||||
groupDefaults={{
|
||||
TopupGroupRatio: settings.TopupGroupRatio,
|
||||
GroupRatio: settings.GroupRatio,
|
||||
UserUsableGroups: settings.UserUsableGroups,
|
||||
GroupGroupRatio: settings.GroupGroupRatio,
|
||||
AutoGroups: settings.AutoGroups,
|
||||
DefaultUseAutoGroup: settings.DefaultUseAutoGroup,
|
||||
GroupSpecialUsableGroup:
|
||||
settings['group_ratio_setting.group_special_usable_group'],
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'model-deployment',
|
||||
titleKey: 'Model Deployment',
|
||||
descriptionKey: 'Configure model deployment provider settings',
|
||||
build: (settings: ModelSettings) => (
|
||||
<IoNetDeploymentSettingsSection
|
||||
defaultValues={{
|
||||
enabled: settings['model_deployment.ionet.enabled'],
|
||||
apiKey: settings['model_deployment.ionet.api_key'],
|
||||
}}
|
||||
/>
|
||||
),
|
||||
|
||||
+246
-150
@@ -10,7 +10,7 @@ import {
|
||||
type InputHTMLAttributes,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
} from 'react'
|
||||
import { Copy, Plus, Trash2 } from 'lucide-react'
|
||||
import { ChevronDown, Copy, Plus, Trash2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
BILLING_EXTRA_VARS,
|
||||
@@ -38,6 +39,10 @@ import {
|
||||
MATCH_CONTAINS,
|
||||
MATCH_EQ,
|
||||
MATCH_EXISTS,
|
||||
MATCH_GT,
|
||||
MATCH_GTE,
|
||||
MATCH_LT,
|
||||
MATCH_LTE,
|
||||
MATCH_RANGE,
|
||||
SOURCE_HEADER,
|
||||
SOURCE_PARAM,
|
||||
@@ -48,7 +53,6 @@ import {
|
||||
createEmptyCondition,
|
||||
createEmptyRuleGroup,
|
||||
createEmptyTimeCondition,
|
||||
createEmptyTimeRuleGroup,
|
||||
getRequestRuleMatchOptions,
|
||||
splitBillingExprAndRequestRules,
|
||||
tryParseRequestRuleExpr,
|
||||
@@ -77,11 +81,20 @@ import {
|
||||
} from '@/features/pricing/lib/tier-expr'
|
||||
|
||||
const PRICE_SUFFIX = '$/1M tokens'
|
||||
const CACHE_PRICE_VARS = BILLING_EXTRA_VARS.filter(
|
||||
(variable) => variable.group === 'cache'
|
||||
)
|
||||
const MEDIA_PRICE_VARS = BILLING_EXTRA_VARS.filter(
|
||||
(variable) => variable.group === 'media'
|
||||
)
|
||||
|
||||
const VAR_OPTIONS: { value: TierConditionInput['var']; label: string }[] = [
|
||||
{ value: 'len', label: 'len (input length)' },
|
||||
{ value: 'p', label: 'p (input)' },
|
||||
{ value: 'c', label: 'c (output)' },
|
||||
const CONDITION_INPUT_OPTIONS: {
|
||||
value: TierConditionInput['var']
|
||||
labelKey: string
|
||||
}[] = [
|
||||
{ value: 'len', labelKey: 'Full input length' },
|
||||
{ value: 'p', labelKey: 'Billable input tokens' },
|
||||
{ value: 'c', labelKey: 'Billable output tokens' },
|
||||
]
|
||||
const OPS: TierConditionInput['op'][] = ['<', '<=', '>', '>=']
|
||||
|
||||
@@ -394,6 +407,11 @@ type ConditionRowProps = {
|
||||
}
|
||||
|
||||
function ConditionRow({ condition, onChange, onRemove }: ConditionRowProps) {
|
||||
const { t } = useTranslation()
|
||||
const currentInputOption = CONDITION_INPUT_OPTIONS.find(
|
||||
(option) => option.value === condition.var
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-2'>
|
||||
<Select
|
||||
@@ -403,12 +421,16 @@ function ConditionRow({ condition, onChange, onRemove }: ConditionRowProps) {
|
||||
}
|
||||
>
|
||||
<SelectTrigger className='w-32' size='sm'>
|
||||
<SelectValue />
|
||||
<SelectValue>
|
||||
{currentInputOption
|
||||
? t(currentInputOption.labelKey)
|
||||
: condition.var}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{VAR_OPTIONS.map((option) => (
|
||||
{CONDITION_INPUT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
{t(option.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -462,31 +484,19 @@ type PriceFieldProps = {
|
||||
hint?: string
|
||||
value: number
|
||||
onChange: (next: number) => void
|
||||
showSuffix?: boolean
|
||||
}
|
||||
|
||||
function PriceField({
|
||||
label,
|
||||
hint,
|
||||
value,
|
||||
onChange,
|
||||
showSuffix = true,
|
||||
}: PriceFieldProps) {
|
||||
function PriceField({ label, hint, value, onChange }: PriceFieldProps) {
|
||||
return (
|
||||
<div className='space-y-1'>
|
||||
<Label className='text-xs'>{label}</Label>
|
||||
<div className='flex items-center gap-2'>
|
||||
<DraftNumberInput
|
||||
min={0}
|
||||
step={0.01}
|
||||
value={Number.isFinite(value) ? value : 0}
|
||||
onValueChange={onChange}
|
||||
className='w-32'
|
||||
/>
|
||||
{showSuffix && (
|
||||
<span className='text-muted-foreground text-xs'>{PRICE_SUFFIX}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className='w-36 space-y-0.5'>
|
||||
<Label className='text-muted-foreground text-xs'>{label}</Label>
|
||||
<DraftNumberInput
|
||||
min={0}
|
||||
step={0.01}
|
||||
value={Number.isFinite(value) ? value : 0}
|
||||
onValueChange={onChange}
|
||||
className='h-8 w-full'
|
||||
/>
|
||||
{hint && <p className='text-muted-foreground text-xs'>{hint}</p>}
|
||||
</div>
|
||||
)
|
||||
@@ -547,21 +557,49 @@ function VisualTierCard({
|
||||
|
||||
const inputUnitPrice = unitCostToPrice(tier.input_unit_cost)
|
||||
const outputUnitPrice = unitCostToPrice(tier.output_unit_cost)
|
||||
const hasMediaPricing = MEDIA_PRICE_VARS.some((variable) => {
|
||||
const fieldKey = variable.tierField as keyof VisualTier
|
||||
return unitCostToPrice((tier[fieldKey] as number | undefined) ?? 0) > 0
|
||||
})
|
||||
const [mediaOpen, setMediaOpen] = useState(hasMediaPricing)
|
||||
|
||||
useEffect(() => {
|
||||
if (hasMediaPricing) setMediaOpen(true)
|
||||
}, [hasMediaPricing])
|
||||
|
||||
const renderPriceVariable = (
|
||||
variable: (typeof BILLING_EXTRA_VARS)[number]
|
||||
) => {
|
||||
const fieldKey = variable.tierField as keyof VisualTier
|
||||
const value = unitCostToPrice((tier[fieldKey] as number | undefined) ?? 0)
|
||||
|
||||
return (
|
||||
<PriceField
|
||||
key={variable.key}
|
||||
label={t(variable.label)}
|
||||
value={value}
|
||||
onChange={(next) => handlePriceChange(fieldKey, priceToUnitCost(next))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='bg-muted/30 space-y-3 rounded-md border p-3'>
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<div className='space-y-3 rounded-lg border p-3'>
|
||||
<div className='flex flex-wrap items-center justify-between gap-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Badge variant='outline'>
|
||||
{t('Tier')} {index + 1} / {total}
|
||||
</Badge>
|
||||
{tier.conditions.length === 0 && (
|
||||
<Badge variant='secondary'>{t('Fallback tier')}</Badge>
|
||||
)}
|
||||
<Input
|
||||
value={tier.label}
|
||||
onChange={(event) =>
|
||||
onChange({ ...tier, label: event.target.value })
|
||||
}
|
||||
placeholder={t('Tier name')}
|
||||
className='h-8 w-40'
|
||||
className='h-7 w-36'
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
@@ -575,9 +613,10 @@ function VisualTierCard({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label className='text-xs'>{t('Conditions (AND)')}</Label>
|
||||
{/* Conditions */}
|
||||
<div className='space-y-1.5'>
|
||||
<div className='flex h-7 items-center justify-between'>
|
||||
<Label className='text-xs font-medium'>{t('Tier conditions')}</Label>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
@@ -605,67 +644,90 @@ function VisualTierCard({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 gap-3 md:grid-cols-2'>
|
||||
<PriceField
|
||||
label={t('Input price')}
|
||||
hint={`${inputUnitPrice} × p`}
|
||||
value={inputUnitPrice}
|
||||
onChange={(value) =>
|
||||
handlePriceChange('input_unit_cost', priceToUnitCost(value))
|
||||
}
|
||||
/>
|
||||
<PriceField
|
||||
label={t('Output price')}
|
||||
hint={`${outputUnitPrice} × c`}
|
||||
value={outputUnitPrice}
|
||||
onChange={(value) =>
|
||||
handlePriceChange('output_unit_cost', priceToUnitCost(value))
|
||||
}
|
||||
/>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<Label className='text-sm font-semibold'>{t('Token prices')}</Label>
|
||||
<span className='bg-muted text-muted-foreground rounded-md px-2 py-1 text-xs'>
|
||||
{PRICE_SUFFIX}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='space-y-3'>
|
||||
<div className='flex flex-wrap gap-x-4 gap-y-2'>
|
||||
<PriceField
|
||||
label={t('Input price')}
|
||||
value={inputUnitPrice}
|
||||
onChange={(value) =>
|
||||
handlePriceChange('input_unit_cost', priceToUnitCost(value))
|
||||
}
|
||||
/>
|
||||
<PriceField
|
||||
label={t('Output price')}
|
||||
value={outputUnitPrice}
|
||||
onChange={(value) =>
|
||||
handlePriceChange('output_unit_cost', priceToUnitCost(value))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<div className='flex h-7 items-center'>
|
||||
<Tabs
|
||||
value={cacheMode}
|
||||
onValueChange={(value) =>
|
||||
value !== null && handleCacheModeChange(value as CacheMode)
|
||||
}
|
||||
>
|
||||
<TabsList className='h-8'>
|
||||
<TabsTrigger
|
||||
value={CACHE_MODE_GENERIC}
|
||||
className='px-2 text-xs'
|
||||
>
|
||||
{t('Generic cache')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value={CACHE_MODE_TIMED}
|
||||
className='px-2 text-xs'
|
||||
>
|
||||
{t('Time-sliced cache (Claude)')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-x-4 gap-y-2'>
|
||||
{CACHE_PRICE_VARS.map((variable) => {
|
||||
if (variable.key === 'cc1h' && cacheMode !== CACHE_MODE_TIMED) {
|
||||
return null
|
||||
}
|
||||
return renderPriceVariable(variable)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label className='text-xs'>{t('Cache mode')}</Label>
|
||||
<Select
|
||||
value={cacheMode}
|
||||
onValueChange={(value) => handleCacheModeChange(value as CacheMode)}
|
||||
>
|
||||
<SelectTrigger className='w-44' size='sm'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={CACHE_MODE_GENERIC}>
|
||||
{t('Generic cache')}
|
||||
</SelectItem>
|
||||
<SelectItem value={CACHE_MODE_TIMED}>
|
||||
{t('Timed cache (1h)')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className='grid grid-cols-1 gap-3 md:grid-cols-2'>
|
||||
{BILLING_EXTRA_VARS.map((variable) => {
|
||||
if (variable.key === 'cc1h' && cacheMode !== CACHE_MODE_TIMED) {
|
||||
return null
|
||||
}
|
||||
const fieldKey = variable.tierField as keyof VisualTier
|
||||
const value = unitCostToPrice(
|
||||
(tier[fieldKey] as number | undefined) ?? 0
|
||||
)
|
||||
return (
|
||||
<PriceField
|
||||
key={variable.key}
|
||||
label={variable.label}
|
||||
hint={`${value} × ${variable.key}`}
|
||||
value={value}
|
||||
onChange={(next) =>
|
||||
handlePriceChange(fieldKey, priceToUnitCost(next))
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{/* Media prices */}
|
||||
<div className='space-y-1.5'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-7 px-2 text-xs'
|
||||
onClick={() => setMediaOpen((prev) => !prev)}
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'mr-1 h-3 w-3 transition-transform',
|
||||
mediaOpen && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
{t('Media pricing')}
|
||||
</Button>
|
||||
{mediaOpen && (
|
||||
<div className='flex flex-wrap gap-x-4 gap-y-2'>
|
||||
{MEDIA_PRICE_VARS.map(renderPriceVariable)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -747,14 +809,12 @@ function VisualEditor({ visualConfig, onChange }: VisualEditorProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-3'>
|
||||
<Alert>
|
||||
<AlertDescription className='text-xs'>
|
||||
{t(
|
||||
'Each tier supports 0~2 conditions (over len, p, c); the last tier is the catch-all without conditions. Use len (full input length, including cache hits) for tier conditions to avoid mis-routing when cache hits reduce p.'
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className='space-y-2'>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t(
|
||||
'Each tier supports up to 2 conditions. The last tier without conditions is the fallback.'
|
||||
)}
|
||||
</p>
|
||||
{config.tiers.map((tier, index) => (
|
||||
<VisualTierCard
|
||||
key={index}
|
||||
@@ -766,7 +826,12 @@ function VisualEditor({ visualConfig, onChange }: VisualEditorProps) {
|
||||
onAddCondition={() => handleAddCondition(index)}
|
||||
/>
|
||||
))}
|
||||
<Button variant='outline' size='sm' onClick={handleAddTier}>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-9 w-36 justify-center'
|
||||
onClick={handleAddTier}
|
||||
>
|
||||
<Plus className='mr-2 h-4 w-4' />
|
||||
{t('Add tier')}
|
||||
</Button>
|
||||
@@ -832,6 +897,50 @@ function RuleConditionRow({
|
||||
}: RuleConditionRowProps) {
|
||||
const { t } = useTranslation()
|
||||
const matchOptions = getRequestRuleMatchOptions(condition.source)
|
||||
const getMatchLabel = (mode: string) => {
|
||||
switch (mode) {
|
||||
case MATCH_EQ:
|
||||
return t('Equals')
|
||||
case MATCH_CONTAINS:
|
||||
return t('Contains')
|
||||
case MATCH_EXISTS:
|
||||
return t('Exists')
|
||||
case MATCH_GT:
|
||||
return t('Greater than')
|
||||
case MATCH_GTE:
|
||||
return t('Greater than or equal')
|
||||
case MATCH_LT:
|
||||
return t('Less than')
|
||||
case MATCH_LTE:
|
||||
return t('Less than or equal')
|
||||
case MATCH_RANGE:
|
||||
return t('Overnight range')
|
||||
default:
|
||||
return mode
|
||||
}
|
||||
}
|
||||
const getTimeFuncLabel = (timeFunc: TimeFunc) => {
|
||||
switch (timeFunc) {
|
||||
case 'hour':
|
||||
return t('Hour of day')
|
||||
case 'minute':
|
||||
return t('Minute')
|
||||
case 'weekday':
|
||||
return t('Weekday')
|
||||
case 'month':
|
||||
return t('Month number')
|
||||
case 'day':
|
||||
return t('Day of month')
|
||||
default:
|
||||
return timeFunc
|
||||
}
|
||||
}
|
||||
const sourceLabel =
|
||||
condition.source === SOURCE_PARAM
|
||||
? t('Body param')
|
||||
: condition.source === SOURCE_HEADER
|
||||
? t('Header')
|
||||
: t('Time')
|
||||
|
||||
const handleSourceChange = (source: string) => {
|
||||
if (source === SOURCE_TIME) {
|
||||
@@ -857,12 +966,12 @@ function RuleConditionRow({
|
||||
}
|
||||
>
|
||||
<SelectTrigger className='w-32' size='sm'>
|
||||
<SelectValue />
|
||||
<SelectValue>{getTimeFuncLabel(timeCond.timeFunc)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIME_FUNCS.map((fn) => (
|
||||
<SelectItem key={fn} value={fn}>
|
||||
{fn}
|
||||
{getTimeFuncLabel(fn)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -874,7 +983,10 @@ function RuleConditionRow({
|
||||
}
|
||||
>
|
||||
<SelectTrigger className='w-56' size='sm'>
|
||||
<SelectValue />
|
||||
<SelectValue>
|
||||
{COMMON_TIMEZONES.find((tz) => tz.value === timeCond.timezone)
|
||||
?.label ?? timeCond.timezone}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{COMMON_TIMEZONES.map((tz) => (
|
||||
@@ -889,12 +1001,12 @@ function RuleConditionRow({
|
||||
onValueChange={(v) => v !== null && handleModeChange(v)}
|
||||
>
|
||||
<SelectTrigger className='w-32' size='sm'>
|
||||
<SelectValue />
|
||||
<SelectValue>{getMatchLabel(timeCond.mode)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{matchOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{t(option.labelKey)}
|
||||
{getMatchLabel(option.value)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -906,7 +1018,7 @@ function RuleConditionRow({
|
||||
onValueChange={(value) =>
|
||||
onChange({ ...timeCond, rangeStart: String(value) })
|
||||
}
|
||||
placeholder='start'
|
||||
placeholder={t('Start')}
|
||||
className='w-20'
|
||||
/>
|
||||
<span className='text-muted-foreground text-xs'>~</span>
|
||||
@@ -915,7 +1027,7 @@ function RuleConditionRow({
|
||||
onValueChange={(value) =>
|
||||
onChange({ ...timeCond, rangeEnd: String(value) })
|
||||
}
|
||||
placeholder='end'
|
||||
placeholder={t('End')}
|
||||
className='w-20'
|
||||
/>
|
||||
</>
|
||||
@@ -925,7 +1037,7 @@ function RuleConditionRow({
|
||||
onValueChange={(value) =>
|
||||
onChange({ ...timeCond, value: String(value) })
|
||||
}
|
||||
placeholder='value'
|
||||
placeholder={t('Value')}
|
||||
className='w-24'
|
||||
/>
|
||||
)}
|
||||
@@ -947,12 +1059,12 @@ function RuleConditionRow({
|
||||
onValueChange={(v) => v !== null && handleModeChange(v)}
|
||||
>
|
||||
<SelectTrigger className='w-32' size='sm'>
|
||||
<SelectValue />
|
||||
<SelectValue>{getMatchLabel(phCond.mode)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{matchOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{t(option.labelKey)}
|
||||
{getMatchLabel(option.value)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -977,7 +1089,7 @@ function RuleConditionRow({
|
||||
onValueChange={(v) => v !== null && handleSourceChange(v)}
|
||||
>
|
||||
<SelectTrigger className='w-28' size='sm'>
|
||||
<SelectValue />
|
||||
<SelectValue>{sourceLabel}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={SOURCE_PARAM}>{t('Body param')}</SelectItem>
|
||||
@@ -1214,7 +1326,7 @@ function CostEstimator({ effectiveExpr }: EstimatorProps) {
|
||||
</div>
|
||||
<div className='grid grid-cols-2 gap-3'>
|
||||
<div className='space-y-1'>
|
||||
<Label className='text-xs'>{t('Input tokens')} (p)</Label>
|
||||
<Label className='text-xs'>{t('Input tokens')}</Label>
|
||||
<DraftNumberInput
|
||||
min={0}
|
||||
value={promptTokens}
|
||||
@@ -1222,7 +1334,7 @@ function CostEstimator({ effectiveExpr }: EstimatorProps) {
|
||||
/>
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
<Label className='text-xs'>{t('Output tokens')} (c)</Label>
|
||||
<Label className='text-xs'>{t('Output tokens')}</Label>
|
||||
<DraftNumberInput
|
||||
min={0}
|
||||
value={completionTokens}
|
||||
@@ -1243,9 +1355,7 @@ function CostEstimator({ effectiveExpr }: EstimatorProps) {
|
||||
) as keyof ExtraTokenValues
|
||||
return (
|
||||
<div key={variable.key} className='space-y-1'>
|
||||
<Label className='text-xs'>
|
||||
{variable.shortLabel} ({variable.key})
|
||||
</Label>
|
||||
<Label className='text-xs'>{t(variable.shortLabel)}</Label>
|
||||
<DraftNumberInput
|
||||
min={0}
|
||||
value={extras[stateKey]}
|
||||
@@ -1612,7 +1722,7 @@ export const TieredPricingEditor = memo(function TieredPricingEditor({
|
||||
<div className='flex-1'>
|
||||
<PresetSection applyPreset={applyPreset} />
|
||||
</div>
|
||||
<LlmPromptHelper modelName={modelName} />
|
||||
{editorMode === 'raw' && <LlmPromptHelper modelName={modelName} />}
|
||||
</div>
|
||||
|
||||
<div className='bg-muted/30 space-y-3 rounded-md border p-3'>
|
||||
@@ -1665,34 +1775,20 @@ export const TieredPricingEditor = memo(function TieredPricingEditor({
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
handleRuleGroupsChange([
|
||||
...requestRuleGroups,
|
||||
createEmptyRuleGroup(),
|
||||
])
|
||||
}
|
||||
>
|
||||
<Plus className='mr-2 h-4 w-4' />
|
||||
{t('Add rule group')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
handleRuleGroupsChange([
|
||||
...requestRuleGroups,
|
||||
createEmptyTimeRuleGroup(),
|
||||
])
|
||||
}
|
||||
>
|
||||
<Plus className='mr-2 h-4 w-4' />
|
||||
{t('Add time rule group')}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-9 w-36 justify-center'
|
||||
onClick={() =>
|
||||
handleRuleGroupsChange([
|
||||
...requestRuleGroups,
|
||||
createEmptyRuleGroup(),
|
||||
])
|
||||
}
|
||||
>
|
||||
<Plus className='mr-2 h-4 w-4' />
|
||||
{t('Add rule group')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useParams } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStatus } from '@/hooks/use-status'
|
||||
import { getOptionValue, useSystemOptions } from '../hooks/use-system-options'
|
||||
import type { OperationsSettings } from '../types'
|
||||
import {
|
||||
OPERATIONS_DEFAULT_SECTION,
|
||||
getOperationsSectionContent,
|
||||
} from './section-registry.tsx'
|
||||
|
||||
const defaultOperationsSettings: OperationsSettings = {
|
||||
RetryTimes: 0,
|
||||
DefaultCollapseSidebar: false,
|
||||
DemoSiteEnabled: false,
|
||||
SelfUseModeEnabled: false,
|
||||
ChannelDisableThreshold: '',
|
||||
QuotaRemindThreshold: '',
|
||||
AutomaticDisableChannelEnabled: false,
|
||||
AutomaticEnableChannelEnabled: false,
|
||||
AutomaticDisableKeywords: '',
|
||||
AutomaticDisableStatusCodes: '401',
|
||||
AutomaticRetryStatusCodes:
|
||||
'100-199,300-399,401-407,409-499,500-503,505-523,525-599',
|
||||
'monitor_setting.auto_test_channel_enabled': false,
|
||||
'monitor_setting.auto_test_channel_minutes': 10,
|
||||
SMTPServer: '',
|
||||
SMTPPort: '',
|
||||
SMTPAccount: '',
|
||||
SMTPFrom: '',
|
||||
SMTPToken: '',
|
||||
SMTPSSLEnabled: false,
|
||||
SMTPForceAuthLogin: false,
|
||||
WorkerUrl: '',
|
||||
WorkerValidKey: '',
|
||||
WorkerAllowHttpImageRequestEnabled: false,
|
||||
LogConsumeEnabled: false,
|
||||
'performance_setting.disk_cache_enabled': false,
|
||||
'performance_setting.disk_cache_threshold_mb': 10,
|
||||
'performance_setting.disk_cache_max_size_mb': 1024,
|
||||
'performance_setting.disk_cache_path': '',
|
||||
'performance_setting.monitor_enabled': false,
|
||||
'performance_setting.monitor_cpu_threshold': 90,
|
||||
'performance_setting.monitor_memory_threshold': 90,
|
||||
'performance_setting.monitor_disk_threshold': 95,
|
||||
'perf_metrics_setting.enabled': true,
|
||||
'perf_metrics_setting.flush_interval': 5,
|
||||
'perf_metrics_setting.bucket_time': 'hour',
|
||||
'perf_metrics_setting.retention_days': 0,
|
||||
}
|
||||
|
||||
export function OperationsSettings() {
|
||||
const { t } = useTranslation()
|
||||
const { data, isLoading } = useSystemOptions()
|
||||
const { status } = useStatus()
|
||||
const params = useParams({
|
||||
from: '/_authenticated/system-settings/operations/$section',
|
||||
})
|
||||
|
||||
const settings = useMemo(
|
||||
() => getOptionValue(data?.data, defaultOperationsSettings),
|
||||
[data?.data]
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className='text-muted-foreground flex h-full w-full flex-1 items-center justify-center'>
|
||||
{t('Loading maintenance settings...')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const activeSection = (params?.section ?? OPERATIONS_DEFAULT_SECTION) as
|
||||
| 'behavior'
|
||||
| 'monitoring'
|
||||
| 'email'
|
||||
| 'worker'
|
||||
| 'logs'
|
||||
| 'performance'
|
||||
| 'update-checker'
|
||||
const sectionContent = getOperationsSectionContent(
|
||||
activeSection,
|
||||
settings,
|
||||
status?.version as string | undefined,
|
||||
status?.start_time as number | null | undefined
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='flex h-full w-full flex-1 flex-col'>
|
||||
<div className='faded-bottom h-full w-full overflow-y-auto scroll-smooth pe-4 pb-12'>
|
||||
<div className='space-y-4'>{sectionContent}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { OperationsSettings } from '../types'
|
||||
import { createSectionRegistry } from '../utils/section-registry'
|
||||
import { SystemBehaviorSection } from '../general/system-behavior-section'
|
||||
import { EmailSettingsSection } from '../integrations/email-settings-section'
|
||||
import { MonitoringSettingsSection } from '../integrations/monitoring-settings-section'
|
||||
import { WorkerSettingsSection } from '../integrations/worker-settings-section'
|
||||
import { LogSettingsSection } from '../maintenance/log-settings-section'
|
||||
import { PerformanceSection } from '../maintenance/performance-section'
|
||||
import { UpdateCheckerSection } from '../maintenance/update-checker-section'
|
||||
|
||||
const OPERATIONS_SECTIONS = [
|
||||
{
|
||||
id: 'behavior',
|
||||
titleKey: 'System Behavior',
|
||||
descriptionKey: 'Configure system-wide behavior and defaults',
|
||||
build: (settings: OperationsSettings) => (
|
||||
<SystemBehaviorSection
|
||||
defaultValues={{
|
||||
RetryTimes: settings.RetryTimes,
|
||||
DefaultCollapseSidebar: settings.DefaultCollapseSidebar,
|
||||
DemoSiteEnabled: settings.DemoSiteEnabled,
|
||||
SelfUseModeEnabled: settings.SelfUseModeEnabled,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'monitoring',
|
||||
titleKey: 'Monitoring & Alerts',
|
||||
descriptionKey: 'Configure channel monitoring and automation',
|
||||
build: (settings: OperationsSettings) => (
|
||||
<MonitoringSettingsSection
|
||||
defaultValues={{
|
||||
ChannelDisableThreshold: settings.ChannelDisableThreshold,
|
||||
QuotaRemindThreshold: settings.QuotaRemindThreshold,
|
||||
AutomaticDisableChannelEnabled:
|
||||
settings.AutomaticDisableChannelEnabled,
|
||||
AutomaticEnableChannelEnabled: settings.AutomaticEnableChannelEnabled,
|
||||
AutomaticDisableKeywords: settings.AutomaticDisableKeywords,
|
||||
AutomaticDisableStatusCodes: settings.AutomaticDisableStatusCodes,
|
||||
AutomaticRetryStatusCodes: settings.AutomaticRetryStatusCodes,
|
||||
'monitor_setting.auto_test_channel_enabled':
|
||||
settings['monitor_setting.auto_test_channel_enabled'],
|
||||
'monitor_setting.auto_test_channel_minutes':
|
||||
settings['monitor_setting.auto_test_channel_minutes'],
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
titleKey: 'SMTP Email',
|
||||
descriptionKey: 'Configure SMTP email settings',
|
||||
build: (settings: OperationsSettings) => (
|
||||
<EmailSettingsSection
|
||||
defaultValues={{
|
||||
SMTPServer: settings.SMTPServer,
|
||||
SMTPPort: settings.SMTPPort,
|
||||
SMTPAccount: settings.SMTPAccount,
|
||||
SMTPFrom: settings.SMTPFrom,
|
||||
SMTPToken: settings.SMTPToken,
|
||||
SMTPSSLEnabled: settings.SMTPSSLEnabled,
|
||||
SMTPForceAuthLogin: settings.SMTPForceAuthLogin,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'worker',
|
||||
titleKey: 'Worker Proxy',
|
||||
descriptionKey: 'Configure worker service settings',
|
||||
build: (settings: OperationsSettings) => (
|
||||
<WorkerSettingsSection
|
||||
defaultValues={{
|
||||
WorkerUrl: settings.WorkerUrl,
|
||||
WorkerValidKey: settings.WorkerValidKey,
|
||||
WorkerAllowHttpImageRequestEnabled:
|
||||
settings.WorkerAllowHttpImageRequestEnabled,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'logs',
|
||||
titleKey: 'Log Maintenance',
|
||||
descriptionKey: 'Configure log consumption settings',
|
||||
build: (settings: OperationsSettings) => (
|
||||
<LogSettingsSection
|
||||
defaultEnabled={Boolean(settings.LogConsumeEnabled)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'performance',
|
||||
titleKey: 'Performance',
|
||||
descriptionKey: 'Disk cache, system monitoring and performance stats',
|
||||
build: (settings: OperationsSettings) => (
|
||||
<PerformanceSection
|
||||
defaultValues={{
|
||||
'performance_setting.disk_cache_enabled':
|
||||
settings['performance_setting.disk_cache_enabled'] ?? false,
|
||||
'performance_setting.disk_cache_threshold_mb':
|
||||
settings['performance_setting.disk_cache_threshold_mb'] ?? 10,
|
||||
'performance_setting.disk_cache_max_size_mb':
|
||||
settings['performance_setting.disk_cache_max_size_mb'] ?? 1024,
|
||||
'performance_setting.disk_cache_path':
|
||||
settings['performance_setting.disk_cache_path'] ?? '',
|
||||
'performance_setting.monitor_enabled':
|
||||
settings['performance_setting.monitor_enabled'] ?? false,
|
||||
'performance_setting.monitor_cpu_threshold':
|
||||
settings['performance_setting.monitor_cpu_threshold'] ?? 90,
|
||||
'performance_setting.monitor_memory_threshold':
|
||||
settings['performance_setting.monitor_memory_threshold'] ?? 90,
|
||||
'performance_setting.monitor_disk_threshold':
|
||||
settings['performance_setting.monitor_disk_threshold'] ?? 95,
|
||||
'perf_metrics_setting.enabled':
|
||||
settings['perf_metrics_setting.enabled'] ?? true,
|
||||
'perf_metrics_setting.flush_interval':
|
||||
settings['perf_metrics_setting.flush_interval'] ?? 5,
|
||||
'perf_metrics_setting.bucket_time':
|
||||
settings['perf_metrics_setting.bucket_time'] ?? 'hour',
|
||||
'perf_metrics_setting.retention_days':
|
||||
settings['perf_metrics_setting.retention_days'] ?? 0,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'update-checker',
|
||||
titleKey: 'System maintenance',
|
||||
descriptionKey: 'Check for system updates',
|
||||
build: (
|
||||
_settings: OperationsSettings,
|
||||
currentVersion?: string | null,
|
||||
startTime?: number | null
|
||||
) => (
|
||||
<UpdateCheckerSection
|
||||
currentVersion={currentVersion}
|
||||
startTime={startTime}
|
||||
/>
|
||||
),
|
||||
},
|
||||
] as const
|
||||
|
||||
export type OperationsSectionId = (typeof OPERATIONS_SECTIONS)[number]['id']
|
||||
|
||||
const operationsRegistry = createSectionRegistry<
|
||||
OperationsSectionId,
|
||||
OperationsSettings,
|
||||
[string | null | undefined, number | null | undefined]
|
||||
>({
|
||||
sections: OPERATIONS_SECTIONS,
|
||||
defaultSection: 'behavior',
|
||||
basePath: '/system-settings/operations',
|
||||
urlStyle: 'path',
|
||||
})
|
||||
|
||||
export const OPERATIONS_SECTION_IDS = operationsRegistry.sectionIds
|
||||
export const OPERATIONS_DEFAULT_SECTION = operationsRegistry.defaultSection
|
||||
export const getOperationsSectionNavItems =
|
||||
operationsRegistry.getSectionNavItems
|
||||
export const getOperationsSectionContent =
|
||||
operationsRegistry.getSectionContent
|
||||
+9
-9
@@ -1,11 +1,11 @@
|
||||
import { SettingsPage } from '../components/settings-page'
|
||||
import type { RequestLimitsSettings } from '../types'
|
||||
import type { SecuritySettings } from '../types'
|
||||
import {
|
||||
REQUEST_LIMITS_DEFAULT_SECTION,
|
||||
getRequestLimitsSectionContent,
|
||||
SECURITY_DEFAULT_SECTION,
|
||||
getSecuritySectionContent,
|
||||
} from './section-registry.tsx'
|
||||
|
||||
const defaultRequestLimitsSettings: RequestLimitsSettings = {
|
||||
const defaultSecuritySettings: SecuritySettings = {
|
||||
ModelRequestRateLimitEnabled: false,
|
||||
ModelRequestRateLimitCount: 0,
|
||||
ModelRequestRateLimitSuccessCount: 1000,
|
||||
@@ -24,13 +24,13 @@ const defaultRequestLimitsSettings: RequestLimitsSettings = {
|
||||
'fetch_setting.apply_ip_filter_for_domain': false,
|
||||
}
|
||||
|
||||
export function RequestLimitsSettings() {
|
||||
export function SecuritySettings() {
|
||||
return (
|
||||
<SettingsPage
|
||||
routePath='/_authenticated/system-settings/request-limits/$section'
|
||||
defaultSettings={defaultRequestLimitsSettings}
|
||||
defaultSection={REQUEST_LIMITS_DEFAULT_SECTION}
|
||||
getSectionContent={getRequestLimitsSectionContent}
|
||||
routePath='/_authenticated/system-settings/security/$section'
|
||||
defaultSettings={defaultSecuritySettings}
|
||||
defaultSection={SECURITY_DEFAULT_SECTION}
|
||||
getSectionContent={getSecuritySectionContent}
|
||||
/>
|
||||
)
|
||||
}
|
||||
+18
-22
@@ -1,15 +1,15 @@
|
||||
import type { RequestLimitsSettings } from '../types'
|
||||
import type { SecuritySettings } from '../types'
|
||||
import { createSectionRegistry } from '../utils/section-registry'
|
||||
import { RateLimitSection } from './rate-limit-section'
|
||||
import { SensitiveWordsSection } from './sensitive-words-section'
|
||||
import { SSRFSection } from './ssrf-section'
|
||||
import { RateLimitSection } from '../request-limits/rate-limit-section'
|
||||
import { SensitiveWordsSection } from '../request-limits/sensitive-words-section'
|
||||
import { SSRFSection } from '../request-limits/ssrf-section'
|
||||
|
||||
const REQUEST_LIMITS_SECTIONS = [
|
||||
const SECURITY_SECTIONS = [
|
||||
{
|
||||
id: 'rate-limit',
|
||||
titleKey: 'Rate Limiting',
|
||||
descriptionKey: 'Configure model request rate limiting',
|
||||
build: (settings: RequestLimitsSettings) => (
|
||||
build: (settings: SecuritySettings) => (
|
||||
<RateLimitSection
|
||||
defaultValues={{
|
||||
ModelRequestRateLimitEnabled: settings.ModelRequestRateLimitEnabled,
|
||||
@@ -27,7 +27,7 @@ const REQUEST_LIMITS_SECTIONS = [
|
||||
id: 'sensitive-words',
|
||||
titleKey: 'Sensitive Words',
|
||||
descriptionKey: 'Configure sensitive word filtering',
|
||||
build: (settings: RequestLimitsSettings) => (
|
||||
build: (settings: SecuritySettings) => (
|
||||
<SensitiveWordsSection
|
||||
defaultValues={{
|
||||
CheckSensitiveEnabled: settings.CheckSensitiveEnabled,
|
||||
@@ -41,7 +41,7 @@ const REQUEST_LIMITS_SECTIONS = [
|
||||
id: 'ssrf',
|
||||
titleKey: 'SSRF Protection',
|
||||
descriptionKey: 'Configure SSRF (Server-Side Request Forgery) protection',
|
||||
build: (settings: RequestLimitsSettings) => (
|
||||
build: (settings: SecuritySettings) => (
|
||||
<SSRFSection
|
||||
defaultValues={{
|
||||
'fetch_setting.enable_ssrf_protection':
|
||||
@@ -64,23 +64,19 @@ const REQUEST_LIMITS_SECTIONS = [
|
||||
},
|
||||
] as const
|
||||
|
||||
export type RequestLimitsSectionId =
|
||||
(typeof REQUEST_LIMITS_SECTIONS)[number]['id']
|
||||
export type SecuritySectionId = (typeof SECURITY_SECTIONS)[number]['id']
|
||||
|
||||
const requestLimitsRegistry = createSectionRegistry<
|
||||
RequestLimitsSectionId,
|
||||
RequestLimitsSettings
|
||||
const securityRegistry = createSectionRegistry<
|
||||
SecuritySectionId,
|
||||
SecuritySettings
|
||||
>({
|
||||
sections: REQUEST_LIMITS_SECTIONS,
|
||||
sections: SECURITY_SECTIONS,
|
||||
defaultSection: 'rate-limit',
|
||||
basePath: '/system-settings/request-limits',
|
||||
basePath: '/system-settings/security',
|
||||
urlStyle: 'path',
|
||||
})
|
||||
|
||||
export const REQUEST_LIMITS_SECTION_IDS = requestLimitsRegistry.sectionIds
|
||||
export const REQUEST_LIMITS_DEFAULT_SECTION =
|
||||
requestLimitsRegistry.defaultSection
|
||||
export const getRequestLimitsSectionNavItems =
|
||||
requestLimitsRegistry.getSectionNavItems
|
||||
export const getRequestLimitsSectionContent =
|
||||
requestLimitsRegistry.getSectionContent
|
||||
export const SECURITY_SECTION_IDS = securityRegistry.sectionIds
|
||||
export const SECURITY_DEFAULT_SECTION = securityRegistry.defaultSection
|
||||
export const getSecuritySectionNavItems = securityRegistry.getSectionNavItems
|
||||
export const getSecuritySectionContent = securityRegistry.getSectionContent
|
||||
@@ -0,0 +1,32 @@
|
||||
import { SettingsPage } from '../components/settings-page'
|
||||
import type { SiteSettings } from '../types'
|
||||
import {
|
||||
SITE_DEFAULT_SECTION,
|
||||
getSiteSectionContent,
|
||||
} from './section-registry.tsx'
|
||||
|
||||
const defaultSiteSettings: SiteSettings = {
|
||||
'theme.frontend': 'default',
|
||||
Notice: '',
|
||||
SystemName: 'New API',
|
||||
Logo: '',
|
||||
Footer: '',
|
||||
About: '',
|
||||
HomePageContent: '',
|
||||
ServerAddress: '',
|
||||
'legal.user_agreement': '',
|
||||
'legal.privacy_policy': '',
|
||||
HeaderNavModules: '',
|
||||
SidebarModulesAdmin: '',
|
||||
}
|
||||
|
||||
export function SiteSettings() {
|
||||
return (
|
||||
<SettingsPage
|
||||
routePath='/_authenticated/system-settings/site/$section'
|
||||
defaultSettings={defaultSiteSettings}
|
||||
defaultSection={SITE_DEFAULT_SECTION}
|
||||
getSectionContent={getSiteSectionContent}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { SiteSettings } from '../types'
|
||||
import { createSectionRegistry } from '../utils/section-registry'
|
||||
import {
|
||||
parseHeaderNavModules,
|
||||
parseSidebarModulesAdmin,
|
||||
serializeHeaderNavModules,
|
||||
serializeSidebarModulesAdmin,
|
||||
} from '../maintenance/config'
|
||||
import { HeaderNavigationSection } from '../maintenance/header-navigation-section'
|
||||
import { NoticeSection } from '../maintenance/notice-section'
|
||||
import { SidebarModulesSection } from '../maintenance/sidebar-modules-section'
|
||||
import { SystemInfoSection } from '../general/system-info-section'
|
||||
|
||||
const SITE_SECTIONS = [
|
||||
{
|
||||
id: 'system-info',
|
||||
titleKey: 'System Information',
|
||||
descriptionKey: 'Configure basic system information and branding',
|
||||
build: (settings: SiteSettings) => (
|
||||
<SystemInfoSection
|
||||
defaultValues={{
|
||||
theme: {
|
||||
frontend: settings['theme.frontend'] as 'default' | 'classic',
|
||||
},
|
||||
SystemName: settings.SystemName,
|
||||
Logo: settings.Logo,
|
||||
Footer: settings.Footer,
|
||||
About: settings.About,
|
||||
HomePageContent: settings.HomePageContent,
|
||||
ServerAddress: settings.ServerAddress,
|
||||
legal: {
|
||||
user_agreement: settings['legal.user_agreement'],
|
||||
privacy_policy: settings['legal.privacy_policy'],
|
||||
},
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'notice',
|
||||
titleKey: 'System Notice',
|
||||
descriptionKey: 'Configure system maintenance notice',
|
||||
build: (settings: SiteSettings) => (
|
||||
<NoticeSection defaultValue={settings.Notice ?? ''} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'header-navigation',
|
||||
titleKey: 'Header navigation',
|
||||
descriptionKey: 'Configure header navigation modules',
|
||||
build: (settings: SiteSettings) => {
|
||||
const headerNavConfig = parseHeaderNavModules(settings.HeaderNavModules)
|
||||
const headerNavSerialized = serializeHeaderNavModules(headerNavConfig)
|
||||
return (
|
||||
<HeaderNavigationSection
|
||||
config={headerNavConfig}
|
||||
initialSerialized={headerNavSerialized}
|
||||
/>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'sidebar-modules',
|
||||
titleKey: 'Sidebar modules',
|
||||
descriptionKey: 'Configure sidebar modules for admin',
|
||||
build: (settings: SiteSettings) => {
|
||||
const sidebarConfig = parseSidebarModulesAdmin(
|
||||
settings.SidebarModulesAdmin
|
||||
)
|
||||
const sidebarSerialized = serializeSidebarModulesAdmin(sidebarConfig)
|
||||
return (
|
||||
<SidebarModulesSection
|
||||
config={sidebarConfig}
|
||||
initialSerialized={sidebarSerialized}
|
||||
/>
|
||||
)
|
||||
},
|
||||
},
|
||||
] as const
|
||||
|
||||
export type SiteSectionId = (typeof SITE_SECTIONS)[number]['id']
|
||||
|
||||
const siteRegistry = createSectionRegistry<SiteSectionId, SiteSettings>({
|
||||
sections: SITE_SECTIONS,
|
||||
defaultSection: 'system-info',
|
||||
basePath: '/system-settings/site',
|
||||
urlStyle: 'path',
|
||||
})
|
||||
|
||||
export const SITE_SECTION_IDS = siteRegistry.sectionIds
|
||||
export const SITE_DEFAULT_SECTION = siteRegistry.defaultSection
|
||||
export const getSiteSectionNavItems = siteRegistry.getSectionNavItems
|
||||
export const getSiteSectionContent = siteRegistry.getSectionContent
|
||||
+110
-92
@@ -27,7 +27,7 @@ export type DeleteLogsResponse = {
|
||||
data?: number
|
||||
}
|
||||
|
||||
export type GeneralSettings = {
|
||||
export type SiteSettings = {
|
||||
'theme.frontend': string
|
||||
Notice: string
|
||||
SystemName: string
|
||||
@@ -38,32 +38,8 @@ export type GeneralSettings = {
|
||||
ServerAddress: string
|
||||
'legal.user_agreement': string
|
||||
'legal.privacy_policy': string
|
||||
QuotaForNewUser: number
|
||||
PreConsumedQuota: number
|
||||
QuotaForInviter: number
|
||||
QuotaForInvitee: number
|
||||
TopUpLink: string
|
||||
'general_setting.docs_link': string
|
||||
'quota_setting.enable_free_model_pre_consume': boolean
|
||||
QuotaPerUnit: number
|
||||
USDExchangeRate: number
|
||||
'general_setting.quota_display_type': string
|
||||
'general_setting.custom_currency_symbol': string
|
||||
'general_setting.custom_currency_exchange_rate': number
|
||||
RetryTimes: number
|
||||
DisplayInCurrencyEnabled: boolean
|
||||
DisplayTokenStatEnabled: boolean
|
||||
DefaultCollapseSidebar: boolean
|
||||
DemoSiteEnabled: boolean
|
||||
SelfUseModeEnabled: boolean
|
||||
'checkin_setting.enabled': boolean
|
||||
'checkin_setting.min_quota': number
|
||||
'checkin_setting.max_quota': number
|
||||
'channel_affinity_setting.enabled': boolean
|
||||
'channel_affinity_setting.switch_on_success': boolean
|
||||
'channel_affinity_setting.max_entries': number
|
||||
'channel_affinity_setting.default_ttl_seconds': number
|
||||
'channel_affinity_setting.rules': string
|
||||
HeaderNavModules: string
|
||||
SidebarModulesAdmin: string
|
||||
}
|
||||
|
||||
export type AuthSettings = {
|
||||
@@ -131,28 +107,87 @@ export type ContentSettings = {
|
||||
MjActionCheckSuccessEnabled: boolean
|
||||
}
|
||||
|
||||
export type IntegrationSettings = {
|
||||
SMTPServer: string
|
||||
SMTPPort: string
|
||||
SMTPAccount: string
|
||||
SMTPFrom: string
|
||||
SMTPToken: string
|
||||
SMTPSSLEnabled: boolean
|
||||
SMTPForceAuthLogin: boolean
|
||||
WorkerUrl: string
|
||||
WorkerValidKey: string
|
||||
WorkerAllowHttpImageRequestEnabled: boolean
|
||||
ChannelDisableThreshold: string
|
||||
QuotaRemindThreshold: string
|
||||
AutomaticDisableChannelEnabled: boolean
|
||||
AutomaticEnableChannelEnabled: boolean
|
||||
AutomaticDisableKeywords: string
|
||||
AutomaticDisableStatusCodes: string
|
||||
AutomaticRetryStatusCodes: string
|
||||
'monitor_setting.auto_test_channel_enabled': boolean
|
||||
'monitor_setting.auto_test_channel_minutes': number
|
||||
export type ModelSettings = {
|
||||
'global.pass_through_request_enabled': boolean
|
||||
'global.thinking_model_blacklist': string
|
||||
'global.chat_completions_to_responses_policy': string
|
||||
'general_setting.ping_interval_enabled': boolean
|
||||
'general_setting.ping_interval_seconds': number
|
||||
'gemini.safety_settings': string
|
||||
'gemini.version_settings': string
|
||||
'gemini.supported_imagine_models': string
|
||||
'gemini.thinking_adapter_enabled': boolean
|
||||
'gemini.thinking_adapter_budget_tokens_percentage': number
|
||||
'gemini.function_call_thought_signature_enabled': boolean
|
||||
'gemini.remove_function_response_id_enabled': boolean
|
||||
'claude.model_headers_settings': string
|
||||
'claude.default_max_tokens': string
|
||||
'claude.thinking_adapter_enabled': boolean
|
||||
'claude.thinking_adapter_budget_tokens_percentage': number
|
||||
'grok.violation_deduction_enabled': boolean
|
||||
'grok.violation_deduction_amount': number
|
||||
ModelPrice: string
|
||||
ModelRatio: string
|
||||
CacheRatio: string
|
||||
CreateCacheRatio: string
|
||||
CompletionRatio: string
|
||||
ImageRatio: string
|
||||
AudioRatio: string
|
||||
AudioCompletionRatio: string
|
||||
ExposeRatioEnabled: boolean
|
||||
'billing_setting.billing_mode': string
|
||||
'billing_setting.billing_expr': string
|
||||
'tool_price_setting.prices': string
|
||||
TopupGroupRatio: string
|
||||
GroupRatio: string
|
||||
UserUsableGroups: string
|
||||
GroupGroupRatio: string
|
||||
AutoGroups: string
|
||||
DefaultUseAutoGroup: boolean
|
||||
'group_ratio_setting.group_special_usable_group': string
|
||||
'channel_affinity_setting.enabled': boolean
|
||||
'channel_affinity_setting.switch_on_success': boolean
|
||||
'channel_affinity_setting.max_entries': number
|
||||
'channel_affinity_setting.default_ttl_seconds': number
|
||||
'channel_affinity_setting.rules': string
|
||||
'model_deployment.ionet.api_key': string
|
||||
'model_deployment.ionet.enabled': boolean
|
||||
}
|
||||
|
||||
export type BillingSettings = {
|
||||
QuotaForNewUser: number
|
||||
PreConsumedQuota: number
|
||||
QuotaForInviter: number
|
||||
QuotaForInvitee: number
|
||||
TopUpLink: string
|
||||
'general_setting.docs_link': string
|
||||
'quota_setting.enable_free_model_pre_consume': boolean
|
||||
QuotaPerUnit: number
|
||||
USDExchangeRate: number
|
||||
'general_setting.quota_display_type': string
|
||||
'general_setting.custom_currency_symbol': string
|
||||
'general_setting.custom_currency_exchange_rate': number
|
||||
DisplayInCurrencyEnabled: boolean
|
||||
DisplayTokenStatEnabled: boolean
|
||||
ModelPrice: string
|
||||
ModelRatio: string
|
||||
CacheRatio: string
|
||||
CreateCacheRatio: string
|
||||
CompletionRatio: string
|
||||
ImageRatio: string
|
||||
AudioRatio: string
|
||||
AudioCompletionRatio: string
|
||||
ExposeRatioEnabled: boolean
|
||||
'billing_setting.billing_mode': string
|
||||
'billing_setting.billing_expr': string
|
||||
'tool_price_setting.prices': string
|
||||
TopupGroupRatio: string
|
||||
GroupRatio: string
|
||||
UserUsableGroups: string
|
||||
GroupGroupRatio: string
|
||||
AutoGroups: string
|
||||
DefaultUseAutoGroup: boolean
|
||||
'group_ratio_setting.group_special_usable_group': string
|
||||
PayAddress: string
|
||||
EpayId: string
|
||||
EpayKey: string
|
||||
@@ -199,53 +234,36 @@ export type IntegrationSettings = {
|
||||
WaffoPancakeCurrency: string
|
||||
WaffoPancakeUnitPrice: number
|
||||
WaffoPancakeMinTopUp: number
|
||||
'checkin_setting.enabled': boolean
|
||||
'checkin_setting.min_quota': number
|
||||
'checkin_setting.max_quota': number
|
||||
}
|
||||
|
||||
export type ModelSettings = {
|
||||
'global.pass_through_request_enabled': boolean
|
||||
'global.thinking_model_blacklist': string
|
||||
'global.chat_completions_to_responses_policy': string
|
||||
'general_setting.ping_interval_enabled': boolean
|
||||
'general_setting.ping_interval_seconds': number
|
||||
'gemini.safety_settings': string
|
||||
'gemini.version_settings': string
|
||||
'gemini.supported_imagine_models': string
|
||||
'gemini.thinking_adapter_enabled': boolean
|
||||
'gemini.thinking_adapter_budget_tokens_percentage': number
|
||||
'gemini.function_call_thought_signature_enabled': boolean
|
||||
'gemini.remove_function_response_id_enabled': boolean
|
||||
'claude.model_headers_settings': string
|
||||
'claude.default_max_tokens': string
|
||||
'claude.thinking_adapter_enabled': boolean
|
||||
'claude.thinking_adapter_budget_tokens_percentage': number
|
||||
'grok.violation_deduction_enabled': boolean
|
||||
'grok.violation_deduction_amount': number
|
||||
ModelPrice: string
|
||||
ModelRatio: string
|
||||
CacheRatio: string
|
||||
CreateCacheRatio: string
|
||||
CompletionRatio: string
|
||||
ImageRatio: string
|
||||
AudioRatio: string
|
||||
AudioCompletionRatio: string
|
||||
ExposeRatioEnabled: boolean
|
||||
'billing_setting.billing_mode': string
|
||||
'billing_setting.billing_expr': string
|
||||
'tool_price_setting.prices': string
|
||||
TopupGroupRatio: string
|
||||
GroupRatio: string
|
||||
UserUsableGroups: string
|
||||
GroupGroupRatio: string
|
||||
AutoGroups: string
|
||||
DefaultUseAutoGroup: boolean
|
||||
'group_ratio_setting.group_special_usable_group': string
|
||||
}
|
||||
|
||||
export type MaintenanceSettings = {
|
||||
Notice: string
|
||||
export type OperationsSettings = {
|
||||
RetryTimes: number
|
||||
DefaultCollapseSidebar: boolean
|
||||
DemoSiteEnabled: boolean
|
||||
SelfUseModeEnabled: boolean
|
||||
ChannelDisableThreshold: string
|
||||
QuotaRemindThreshold: string
|
||||
AutomaticDisableChannelEnabled: boolean
|
||||
AutomaticEnableChannelEnabled: boolean
|
||||
AutomaticDisableKeywords: string
|
||||
AutomaticDisableStatusCodes: string
|
||||
AutomaticRetryStatusCodes: string
|
||||
'monitor_setting.auto_test_channel_enabled': boolean
|
||||
'monitor_setting.auto_test_channel_minutes': number
|
||||
SMTPServer: string
|
||||
SMTPPort: string
|
||||
SMTPAccount: string
|
||||
SMTPFrom: string
|
||||
SMTPToken: string
|
||||
SMTPSSLEnabled: boolean
|
||||
SMTPForceAuthLogin: boolean
|
||||
WorkerUrl: string
|
||||
WorkerValidKey: string
|
||||
WorkerAllowHttpImageRequestEnabled: boolean
|
||||
LogConsumeEnabled: boolean
|
||||
HeaderNavModules: string
|
||||
SidebarModulesAdmin: string
|
||||
'performance_setting.disk_cache_enabled': boolean
|
||||
'performance_setting.disk_cache_threshold_mb': number
|
||||
'performance_setting.disk_cache_max_size_mb': number
|
||||
@@ -260,7 +278,7 @@ export type MaintenanceSettings = {
|
||||
'perf_metrics_setting.retention_days': number
|
||||
}
|
||||
|
||||
export type RequestLimitsSettings = {
|
||||
export type SecuritySettings = {
|
||||
ModelRequestRateLimitEnabled: boolean
|
||||
ModelRequestRateLimitCount: number
|
||||
ModelRequestRateLimitSuccessCount: number
|
||||
|
||||
@@ -29,7 +29,7 @@ export type SettingsRouteConfigOptions<
|
||||
defaultSection: TSectionId
|
||||
/** Settings component to render */
|
||||
component: TComponent
|
||||
/** Route path for redirect (e.g., '/system-settings/general') */
|
||||
/** Route path for redirect (e.g., '/system-settings/site') */
|
||||
routePath: string
|
||||
/** Whether to redirect to default section if no section is provided (default: false) */
|
||||
redirectToDefault?: boolean
|
||||
@@ -44,12 +44,12 @@ export type SettingsRouteConfigOptions<
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* export const Route = createFileRoute('/_authenticated/system-settings/general')(
|
||||
* export const Route = createFileRoute('/_authenticated/system-settings/site')(
|
||||
* createSettingsRouteConfig({
|
||||
* sectionIds: GENERAL_SECTION_IDS,
|
||||
* defaultSection: GENERAL_DEFAULT_SECTION,
|
||||
* component: GeneralSettings,
|
||||
* routePath: '/system-settings/general',
|
||||
* sectionIds: SITE_SECTION_IDS,
|
||||
* defaultSection: SITE_DEFAULT_SECTION,
|
||||
* component: SiteSettings,
|
||||
* routePath: '/system-settings/site',
|
||||
* redirectToDefault: true,
|
||||
* })
|
||||
* )
|
||||
|
||||
Reference in New Issue
Block a user