Files
new-api/web/default/src/features/system-settings/models/ratio-settings-card.tsx
T
CaIon 0f9f094a48 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.
2026-05-06 16:29:45 +08:00

514 lines
18 KiB
TypeScript
Vendored

import { useCallback, useEffect, useRef, useState } from 'react'
import * as z from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { resetModelRatios } from '../api'
import { SettingsSection } from '../components/settings-section'
import { useUpdateOption } from '../hooks/use-update-option'
import { GroupRatioForm } from './group-ratio-form'
import { ModelRatioForm } from './model-ratio-form'
import { ToolPriceSettings } from './tool-price-settings'
import { UpstreamRatioSync } from './upstream-ratio-sync'
import {
formatJsonForTextarea,
normalizeJsonString,
validateJsonString,
} from './utils'
const modelSchema = z.object({
ModelPrice: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
ModelRatio: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
CacheRatio: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
CreateCacheRatio: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
CompletionRatio: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
ImageRatio: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
AudioRatio: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
AudioCompletionRatio: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
ExposeRatioEnabled: z.boolean(),
BillingMode: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
BillingExpr: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
})
const groupSchema = z.object({
GroupRatio: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
TopupGroupRatio: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
UserUsableGroups: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
GroupGroupRatio: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
AutoGroups: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value, {
predicate: (parsed) =>
Array.isArray(parsed) &&
parsed.every((item) => typeof item === 'string'),
predicateMessage: 'Expected a JSON array of group identifiers',
})
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON array',
})
}
}),
DefaultUseAutoGroup: z.boolean(),
GroupSpecialUsableGroup: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
})
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()
const queryClient = useQueryClient()
const [confirmOpen, setConfirmOpen] = useState(false)
const resetMutation = useMutation({
mutationFn: resetModelRatios,
onSuccess: (data) => {
if (data.success) {
toast.success(t('Model ratios reset successfully'))
queryClient.invalidateQueries({ queryKey: ['system-options'] })
setConfirmOpen(false)
} else {
toast.error(data.message || t('Failed to reset model ratios'))
}
},
onError: (error: Error) => {
toast.error(error.message || t('Failed to reset model ratios'))
},
})
const modelNormalizedDefaults = useRef({
ModelPrice: normalizeJsonString(modelDefaults.ModelPrice),
ModelRatio: normalizeJsonString(modelDefaults.ModelRatio),
CacheRatio: normalizeJsonString(modelDefaults.CacheRatio),
CreateCacheRatio: normalizeJsonString(modelDefaults.CreateCacheRatio),
CompletionRatio: normalizeJsonString(modelDefaults.CompletionRatio),
ImageRatio: normalizeJsonString(modelDefaults.ImageRatio),
AudioRatio: normalizeJsonString(modelDefaults.AudioRatio),
AudioCompletionRatio: normalizeJsonString(
modelDefaults.AudioCompletionRatio
),
ExposeRatioEnabled: modelDefaults.ExposeRatioEnabled,
BillingMode: normalizeJsonString(modelDefaults.BillingMode),
BillingExpr: normalizeJsonString(modelDefaults.BillingExpr),
})
const groupNormalizedDefaults = useRef({
GroupRatio: normalizeJsonString(groupDefaults.GroupRatio),
TopupGroupRatio: normalizeJsonString(groupDefaults.TopupGroupRatio),
UserUsableGroups: normalizeJsonString(groupDefaults.UserUsableGroups),
GroupGroupRatio: normalizeJsonString(groupDefaults.GroupGroupRatio),
AutoGroups: normalizeJsonString(groupDefaults.AutoGroups),
DefaultUseAutoGroup: groupDefaults.DefaultUseAutoGroup,
GroupSpecialUsableGroup: normalizeJsonString(
groupDefaults.GroupSpecialUsableGroup
),
})
const modelForm = useForm<ModelFormValues>({
resolver: zodResolver(modelSchema),
mode: 'onChange',
defaultValues: {
...modelDefaults,
ModelPrice: formatJsonForTextarea(modelDefaults.ModelPrice),
ModelRatio: formatJsonForTextarea(modelDefaults.ModelRatio),
CacheRatio: formatJsonForTextarea(modelDefaults.CacheRatio),
CreateCacheRatio: formatJsonForTextarea(modelDefaults.CreateCacheRatio),
CompletionRatio: formatJsonForTextarea(modelDefaults.CompletionRatio),
ImageRatio: formatJsonForTextarea(modelDefaults.ImageRatio),
AudioRatio: formatJsonForTextarea(modelDefaults.AudioRatio),
AudioCompletionRatio: formatJsonForTextarea(
modelDefaults.AudioCompletionRatio
),
BillingMode: formatJsonForTextarea(modelDefaults.BillingMode),
BillingExpr: formatJsonForTextarea(modelDefaults.BillingExpr),
},
})
const groupForm = useForm<GroupFormValues>({
resolver: zodResolver(groupSchema),
mode: 'onChange',
defaultValues: {
...groupDefaults,
GroupRatio: formatJsonForTextarea(groupDefaults.GroupRatio),
TopupGroupRatio: formatJsonForTextarea(groupDefaults.TopupGroupRatio),
UserUsableGroups: formatJsonForTextarea(groupDefaults.UserUsableGroups),
GroupGroupRatio: formatJsonForTextarea(groupDefaults.GroupGroupRatio),
AutoGroups: formatJsonForTextarea(groupDefaults.AutoGroups),
GroupSpecialUsableGroup: formatJsonForTextarea(
groupDefaults.GroupSpecialUsableGroup
),
},
})
useEffect(() => {
modelNormalizedDefaults.current = {
ModelPrice: normalizeJsonString(modelDefaults.ModelPrice),
ModelRatio: normalizeJsonString(modelDefaults.ModelRatio),
CacheRatio: normalizeJsonString(modelDefaults.CacheRatio),
CreateCacheRatio: normalizeJsonString(modelDefaults.CreateCacheRatio),
CompletionRatio: normalizeJsonString(modelDefaults.CompletionRatio),
ImageRatio: normalizeJsonString(modelDefaults.ImageRatio),
AudioRatio: normalizeJsonString(modelDefaults.AudioRatio),
AudioCompletionRatio: normalizeJsonString(
modelDefaults.AudioCompletionRatio
),
ExposeRatioEnabled: modelDefaults.ExposeRatioEnabled,
BillingMode: normalizeJsonString(modelDefaults.BillingMode),
BillingExpr: normalizeJsonString(modelDefaults.BillingExpr),
}
modelForm.reset({
...modelDefaults,
ModelPrice: formatJsonForTextarea(modelDefaults.ModelPrice),
ModelRatio: formatJsonForTextarea(modelDefaults.ModelRatio),
CacheRatio: formatJsonForTextarea(modelDefaults.CacheRatio),
CreateCacheRatio: formatJsonForTextarea(modelDefaults.CreateCacheRatio),
CompletionRatio: formatJsonForTextarea(modelDefaults.CompletionRatio),
ImageRatio: formatJsonForTextarea(modelDefaults.ImageRatio),
AudioRatio: formatJsonForTextarea(modelDefaults.AudioRatio),
AudioCompletionRatio: formatJsonForTextarea(
modelDefaults.AudioCompletionRatio
),
BillingMode: formatJsonForTextarea(modelDefaults.BillingMode),
BillingExpr: formatJsonForTextarea(modelDefaults.BillingExpr),
})
}, [modelDefaults, modelForm])
useEffect(() => {
groupNormalizedDefaults.current = {
GroupRatio: normalizeJsonString(groupDefaults.GroupRatio),
TopupGroupRatio: normalizeJsonString(groupDefaults.TopupGroupRatio),
UserUsableGroups: normalizeJsonString(groupDefaults.UserUsableGroups),
GroupGroupRatio: normalizeJsonString(groupDefaults.GroupGroupRatio),
AutoGroups: normalizeJsonString(groupDefaults.AutoGroups),
DefaultUseAutoGroup: groupDefaults.DefaultUseAutoGroup,
GroupSpecialUsableGroup: normalizeJsonString(
groupDefaults.GroupSpecialUsableGroup
),
}
groupForm.reset({
...groupDefaults,
GroupRatio: formatJsonForTextarea(groupDefaults.GroupRatio),
TopupGroupRatio: formatJsonForTextarea(groupDefaults.TopupGroupRatio),
UserUsableGroups: formatJsonForTextarea(groupDefaults.UserUsableGroups),
GroupGroupRatio: formatJsonForTextarea(groupDefaults.GroupGroupRatio),
AutoGroups: formatJsonForTextarea(groupDefaults.AutoGroups),
GroupSpecialUsableGroup: formatJsonForTextarea(
groupDefaults.GroupSpecialUsableGroup
),
})
}, [groupDefaults, groupForm])
const saveModelRatios = useCallback(
async (values: ModelFormValues) => {
const normalized = {
ModelPrice: normalizeJsonString(values.ModelPrice),
ModelRatio: normalizeJsonString(values.ModelRatio),
CacheRatio: normalizeJsonString(values.CacheRatio),
CreateCacheRatio: normalizeJsonString(values.CreateCacheRatio),
CompletionRatio: normalizeJsonString(values.CompletionRatio),
ImageRatio: normalizeJsonString(values.ImageRatio),
AudioRatio: normalizeJsonString(values.AudioRatio),
AudioCompletionRatio: normalizeJsonString(values.AudioCompletionRatio),
ExposeRatioEnabled: values.ExposeRatioEnabled,
BillingMode: normalizeJsonString(values.BillingMode),
BillingExpr: normalizeJsonString(values.BillingExpr),
}
const apiKeyMap: Record<string, string> = {
BillingMode: 'billing_setting.billing_mode',
BillingExpr: 'billing_setting.billing_expr',
}
const updates = (
Object.keys(normalized) as Array<keyof ModelFormValues>
).filter(
(key) => normalized[key] !== modelNormalizedDefaults.current[key]
)
for (const key of updates) {
const apiKey = apiKeyMap[key as string] || (key as string)
await updateOption.mutateAsync({ key: apiKey, value: normalized[key] })
}
},
[updateOption]
)
const saveGroupRatios = useCallback(
async (values: GroupFormValues) => {
const normalized = {
GroupRatio: normalizeJsonString(values.GroupRatio),
TopupGroupRatio: normalizeJsonString(values.TopupGroupRatio),
UserUsableGroups: normalizeJsonString(values.UserUsableGroups),
GroupGroupRatio: normalizeJsonString(values.GroupGroupRatio),
AutoGroups: normalizeJsonString(values.AutoGroups),
DefaultUseAutoGroup: values.DefaultUseAutoGroup,
GroupSpecialUsableGroup: normalizeJsonString(
values.GroupSpecialUsableGroup
),
}
// Map form field names to API keys (most are 1:1, except GroupSpecialUsableGroup)
const apiKeyMap: Record<string, string> = {
GroupSpecialUsableGroup:
'group_ratio_setting.group_special_usable_group',
}
const updates = (
Object.keys(normalized) as Array<keyof typeof normalized>
).filter(
(key) => normalized[key] !== groupNormalizedDefaults.current[key]
)
for (const key of updates) {
const apiKey = apiKeyMap[key] || key
await updateOption.mutateAsync({ key: apiKey, value: normalized[key] })
}
},
[updateOption]
)
const handleResetRatios = useCallback(() => {
setConfirmOpen(true)
}, [])
const { mutate: resetMutate } = resetMutation
const handleConfirmReset = useCallback(() => {
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(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>
{visibleTabs.map((tab) => (
<TabsContent key={tab} value={tab}>
{renderTabContent(tab)}
</TabsContent>
))}
</Tabs>
<ConfirmDialog
open={confirmOpen}
onOpenChange={setConfirmOpen}
title={t('Reset all model ratios?')}
desc={t(
'This will clear custom pricing ratios and revert to upstream defaults.'
)}
destructive
isLoading={resetMutation.isPending}
handleConfirm={handleConfirmReset}
confirmText={t('Reset')}
/>
</SettingsSection>
)
}