fix(model-pricing): detect visual pricing draft changes on save

- expose a draft commit handle from the model pricing editor panel before saving.
- commit the open visual editor into the parent form before page-level save runs.
- support both desktop side editor and mobile sheet save paths.
This commit is contained in:
QuentinHsu
2026-06-03 14:49:08 +08:00
parent 7aaa533265
commit abad0d3cc0
3 changed files with 881 additions and 802 deletions
@@ -16,7 +16,14 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { useEffect, useMemo, useState } from 'react' import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useState,
} from 'react'
import * as z from 'zod' import * as z from 'zod'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
@@ -130,6 +137,10 @@ type ModelPricingEditorPanelProps = Omit<
className?: string className?: string
} }
export type ModelPricingEditorPanelHandle = {
commitDraft: () => Promise<ModelRatioData | null>
}
type PreviewRow = { type PreviewRow = {
key: string key: string
label: string label: string
@@ -377,14 +388,13 @@ function buildPreviewRows(
] ]
} }
export function ModelPricingSheet({ export const ModelPricingSheet = forwardRef<
open, ModelPricingEditorPanelHandle,
onOpenChange, ModelPricingSheetProps
onSave, >(function ModelPricingSheet(
onCancel, { open, onOpenChange, onSave, onCancel, editData, selectedTargetCount = 0 },
editData, ref
selectedTargetCount = 0, ) {
}: ModelPricingSheetProps) {
const { t } = useTranslation() const { t } = useTranslation()
const title = editData ? t('Edit model pricing') : t('Add model pricing') const title = editData ? t('Edit model pricing') : t('Add model pricing')
const description = editData?.name || t('New model') const description = editData?.name || t('New model')
@@ -400,6 +410,7 @@ export function ModelPricingSheet({
<SheetDescription>{description}</SheetDescription> <SheetDescription>{description}</SheetDescription>
</SheetHeader> </SheetHeader>
<ModelPricingEditorPanel <ModelPricingEditorPanel
ref={ref}
onSave={onSave} onSave={onSave}
editData={editData} editData={editData}
selectedTargetCount={selectedTargetCount} selectedTargetCount={selectedTargetCount}
@@ -412,15 +423,15 @@ export function ModelPricingSheet({
</SheetContent> </SheetContent>
</Sheet> </Sheet>
) )
} })
export function ModelPricingEditorPanel({ export const ModelPricingEditorPanel = forwardRef<
onSave, ModelPricingEditorPanelHandle,
editData, ModelPricingEditorPanelProps
selectedTargetCount = 0, >(function ModelPricingEditorPanel(
onCancel, { onSave, editData, selectedTargetCount = 0, onCancel, className },
className, ref
}: ModelPricingEditorPanelProps) { ) {
const { t } = useTranslation() const { t } = useTranslation()
const [pricingMode, setPricingMode] = useState<PricingMode>('per-token') const [pricingMode, setPricingMode] = useState<PricingMode>('per-token')
const [promptPrice, setPromptPrice] = useState('') const [promptPrice, setPromptPrice] = useState('')
@@ -687,7 +698,7 @@ export function ModelPricingEditorPanel({
return nextWarnings return nextWarnings
}, [editData, laneEnabled, lanePrices, pricingMode, promptPrice, t]) }, [editData, laneEnabled, lanePrices, pricingMode, promptPrice, t])
const handleSubmit = (values: ModelPricingFormValues) => { const validatePricingValues = useCallback(() => {
if ( if (
pricingMode === 'per-token' && pricingMode === 'per-token' &&
toNumberOrNull(promptPrice) === null && toNumberOrNull(promptPrice) === null &&
@@ -698,7 +709,7 @@ export function ModelPricingEditorPanel({
form.setError('ratio', { form.setError('ratio', {
message: t('Input price is required before saving dependent prices.'), message: t('Input price is required before saving dependent prices.'),
}) })
return return false
} }
if ( if (
@@ -709,27 +720,53 @@ export function ModelPricingEditorPanel({
form.setError('audioRatio', { form.setError('audioRatio', {
message: t('Audio output price requires an audio input price.'), message: t('Audio output price requires an audio input price.'),
}) })
return return false
} }
const data: ModelRatioData = { return true
name: values.name.trim(), }, [form, laneEnabled, lanePrices, pricingMode, promptPrice, t])
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') { const buildSubmitData = useCallback(
data.billingExpr = billingExpr (values: ModelPricingFormValues) => {
data.requestRuleExpr = requestRuleExpr const data: ModelRatioData = {
} name: values.name.trim(),
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
}
return data
},
[billingExpr, pricingMode, requestRuleExpr]
)
useImperativeHandle(
ref,
() => ({
commitDraft: async () => {
const isValid = await form.trigger()
if (!isValid || !validatePricingValues()) return null
return buildSubmitData(form.getValues())
},
}),
[form, validatePricingValues, buildSubmitData]
)
const handleSubmit = (values: ModelPricingFormValues) => {
if (!validatePricingValues()) return
const data = buildSubmitData(values)
onSave(data) onSave(data)
form.reset() form.reset()
onCancel?.() onCancel?.()
@@ -980,7 +1017,7 @@ export function ModelPricingEditorPanel({
</Form> </Form>
</div> </div>
) )
} })
function PriceInput(props: { function PriceInput(props: {
value: string value: string
@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { memo, useCallback, useState } from 'react' import { memo, useCallback, useRef, useState } from 'react'
import { type UseFormReturn } from 'react-hook-form' import { type UseFormReturn } from 'react-hook-form'
import { Code2, Eye } from 'lucide-react' import { Code2, Eye } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -38,7 +38,10 @@ import {
SettingsSwitchItem, SettingsSwitchItem,
} from '../components/settings-form-layout' } from '../components/settings-form-layout'
import { SettingsPageActionsPortal } from '../components/settings-page-context' import { SettingsPageActionsPortal } from '../components/settings-page-context'
import { ModelRatioVisualEditor } from './model-ratio-visual-editor' import {
ModelRatioVisualEditor,
type ModelRatioVisualEditorHandle,
} from './model-ratio-visual-editor'
type ModelFormValues = { type ModelFormValues = {
ModelPrice: string ModelPrice: string
@@ -71,6 +74,7 @@ export const ModelRatioForm = memo(function ModelRatioForm({
}: ModelRatioFormProps) { }: ModelRatioFormProps) {
const { t } = useTranslation() const { t } = useTranslation()
const [editMode, setEditMode] = useState<'visual' | 'json'>('visual') const [editMode, setEditMode] = useState<'visual' | 'json'>('visual')
const visualEditorRef = useRef<ModelRatioVisualEditorHandle>(null)
const handleFieldChange = useCallback( const handleFieldChange = useCallback(
(field: keyof ModelFormValues, value: string) => { (field: keyof ModelFormValues, value: string) => {
@@ -86,6 +90,15 @@ export const ModelRatioForm = memo(function ModelRatioForm({
setEditMode((prev) => (prev === 'visual' ? 'json' : 'visual')) setEditMode((prev) => (prev === 'visual' ? 'json' : 'visual'))
}, []) }, [])
const handleSave = useCallback(async () => {
if (editMode === 'visual') {
const committed = await visualEditorRef.current?.commitOpenEditor()
if (committed === false) return
}
await form.handleSubmit(onSave)()
}, [editMode, form, onSave])
return ( return (
<div className='space-y-6'> <div className='space-y-6'>
<div className='flex justify-end'> <div className='flex justify-end'>
@@ -118,7 +131,7 @@ export const ModelRatioForm = memo(function ModelRatioForm({
<Button <Button
type='button' type='button'
size='sm' size='sm'
onClick={form.handleSubmit(onSave)} onClick={handleSave}
disabled={isSaving} disabled={isSaving}
> >
{isSaving ? t('Saving...') : t('Save model prices')} {isSaving ? t('Saving...') : t('Save model prices')}
@@ -127,6 +140,7 @@ export const ModelRatioForm = memo(function ModelRatioForm({
{editMode === 'visual' ? ( {editMode === 'visual' ? (
<div className='space-y-6'> <div className='space-y-6'>
<ModelRatioVisualEditor <ModelRatioVisualEditor
ref={visualEditorRef}
modelPrice={form.watch('ModelPrice')} modelPrice={form.watch('ModelPrice')}
modelRatio={form.watch('ModelRatio')} modelRatio={form.watch('ModelRatio')}
cacheRatio={form.watch('CacheRatio')} cacheRatio={form.watch('CacheRatio')}
File diff suppressed because it is too large Load Diff