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:
@@ -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,9 +720,14 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}, [form, laneEnabled, lanePrices, pricingMode, promptPrice, t])
|
||||||
|
|
||||||
|
const buildSubmitData = useCallback(
|
||||||
|
(values: ModelPricingFormValues) => {
|
||||||
const data: ModelRatioData = {
|
const data: ModelRatioData = {
|
||||||
name: values.name.trim(),
|
name: values.name.trim(),
|
||||||
billingMode: pricingMode,
|
billingMode: pricingMode,
|
||||||
@@ -730,6 +746,27 @@ export function ModelPricingEditorPanel({
|
|||||||
data.requestRuleExpr = requestRuleExpr
|
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')}
|
||||||
|
|||||||
+53
-25
@@ -16,7 +16,16 @@ 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 { useState, useMemo, memo, useCallback, useEffect } from 'react'
|
import {
|
||||||
|
useState,
|
||||||
|
useMemo,
|
||||||
|
memo,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
forwardRef,
|
||||||
|
useImperativeHandle,
|
||||||
|
useRef,
|
||||||
|
} from 'react'
|
||||||
import {
|
import {
|
||||||
type ColumnDef,
|
type ColumnDef,
|
||||||
type ColumnFiltersState,
|
type ColumnFiltersState,
|
||||||
@@ -62,6 +71,7 @@ import {
|
|||||||
import { safeJsonParse } from '../utils/json-parser'
|
import { safeJsonParse } from '../utils/json-parser'
|
||||||
import {
|
import {
|
||||||
ModelPricingEditorPanel,
|
ModelPricingEditorPanel,
|
||||||
|
type ModelPricingEditorPanelHandle,
|
||||||
ModelPricingSheet,
|
ModelPricingSheet,
|
||||||
type ModelRatioData,
|
type ModelRatioData,
|
||||||
} from './model-pricing-sheet'
|
} from './model-pricing-sheet'
|
||||||
@@ -97,6 +107,10 @@ type ModelRow = {
|
|||||||
hasConflict: boolean
|
hasConflict: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ModelRatioVisualEditorHandle = {
|
||||||
|
commitOpenEditor: () => Promise<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = 'model-ratio-column-visibility'
|
const STORAGE_KEY = 'model-ratio-column-visibility'
|
||||||
|
|
||||||
const hasValue = (value?: string) => value !== undefined && value !== ''
|
const hasValue = (value?: string) => value !== undefined && value !== ''
|
||||||
@@ -192,8 +206,11 @@ const getPriceDetail = (row: ModelRow, t: (key: string) => string) => {
|
|||||||
return details.length > 0 ? details.join(' · ') : t('Base input price only')
|
return details.length > 0 ? details.join(' · ') : t('Base input price only')
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ModelRatioVisualEditor = memo(
|
const ModelRatioVisualEditorComponent = forwardRef<
|
||||||
function ModelRatioVisualEditor({
|
ModelRatioVisualEditorHandle,
|
||||||
|
ModelRatioVisualEditorProps
|
||||||
|
>(function ModelRatioVisualEditor(
|
||||||
|
{
|
||||||
modelPrice,
|
modelPrice,
|
||||||
modelRatio,
|
modelRatio,
|
||||||
cacheRatio,
|
cacheRatio,
|
||||||
@@ -205,7 +222,9 @@ export const ModelRatioVisualEditor = memo(
|
|||||||
billingMode,
|
billingMode,
|
||||||
billingExpr,
|
billingExpr,
|
||||||
onChange,
|
onChange,
|
||||||
}: ModelRatioVisualEditorProps) {
|
},
|
||||||
|
ref
|
||||||
|
) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const isMobile = useMediaQuery('(max-width: 767px)')
|
const isMobile = useMediaQuery('(max-width: 767px)')
|
||||||
const [sheetOpen, setSheetOpen] = useState(false)
|
const [sheetOpen, setSheetOpen] = useState(false)
|
||||||
@@ -215,6 +234,7 @@ export const ModelRatioVisualEditor = memo(
|
|||||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||||
const [globalFilter, setGlobalFilter] = useState('')
|
const [globalFilter, setGlobalFilter] = useState('')
|
||||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||||
|
const editorPanelRef = useRef<ModelPricingEditorPanelHandle>(null)
|
||||||
const [pagination, setPagination] = useState<PaginationState>({
|
const [pagination, setPagination] = useState<PaginationState>({
|
||||||
pageIndex: 0,
|
pageIndex: 0,
|
||||||
pageSize: 20,
|
pageSize: 20,
|
||||||
@@ -291,20 +311,14 @@ export const ModelRatioVisualEditor = memo(
|
|||||||
audioCompletionRatio,
|
audioCompletionRatio,
|
||||||
{ fallback: {}, context: 'audio completion ratios' }
|
{ fallback: {}, context: 'audio completion ratios' }
|
||||||
)
|
)
|
||||||
const billingModeMap = safeJsonParse<Record<string, string>>(
|
const billingModeMap = safeJsonParse<Record<string, string>>(billingMode, {
|
||||||
billingMode,
|
|
||||||
{
|
|
||||||
fallback: {},
|
fallback: {},
|
||||||
context: 'billing mode',
|
context: 'billing mode',
|
||||||
}
|
})
|
||||||
)
|
const billingExprMap = safeJsonParse<Record<string, string>>(billingExpr, {
|
||||||
const billingExprMap = safeJsonParse<Record<string, string>>(
|
|
||||||
billingExpr,
|
|
||||||
{
|
|
||||||
fallback: {},
|
fallback: {},
|
||||||
context: 'billing expression',
|
context: 'billing expression',
|
||||||
}
|
})
|
||||||
)
|
|
||||||
|
|
||||||
const modelNames = new Set([
|
const modelNames = new Set([
|
||||||
...Object.keys(priceMap),
|
...Object.keys(priceMap),
|
||||||
@@ -454,8 +468,7 @@ export const ModelRatioVisualEditor = memo(
|
|||||||
const handleGlobalFilterChange = useCallback<OnChangeFn<string>>(
|
const handleGlobalFilterChange = useCallback<OnChangeFn<string>>(
|
||||||
(updater) => {
|
(updater) => {
|
||||||
setGlobalFilter((previous) => {
|
setGlobalFilter((previous) => {
|
||||||
const next =
|
const next = typeof updater === 'function' ? updater(previous) : updater
|
||||||
typeof updater === 'function' ? updater(previous) : updater
|
|
||||||
if (next !== previous) {
|
if (next !== previous) {
|
||||||
setEditData(null)
|
setEditData(null)
|
||||||
setEditorOpen(false)
|
setEditorOpen(false)
|
||||||
@@ -842,9 +855,7 @@ export const ModelRatioVisualEditor = memo(
|
|||||||
setEditData(data)
|
setEditData(data)
|
||||||
setEditorOpen(true)
|
setEditorOpen(true)
|
||||||
toast.success(
|
toast.success(
|
||||||
t(
|
t('Pricing changes saved to draft. Click "Save model prices" to apply.')
|
||||||
'Pricing changes saved to draft. Click "Save model prices" to apply.'
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
[persistPricingData, t]
|
[persistPricingData, t]
|
||||||
@@ -875,6 +886,21 @@ export const ModelRatioVisualEditor = memo(
|
|||||||
)
|
)
|
||||||
}, [editData, persistPricingData, t, table])
|
}, [editData, persistPricingData, t, table])
|
||||||
|
|
||||||
|
useImperativeHandle(
|
||||||
|
ref,
|
||||||
|
() => ({
|
||||||
|
commitOpenEditor: async () => {
|
||||||
|
if (!editorOpen || !editorPanelRef.current) return true
|
||||||
|
const data = await editorPanelRef.current.commitDraft()
|
||||||
|
if (!data) return false
|
||||||
|
persistPricingData(data)
|
||||||
|
setEditData(data)
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[editorOpen, persistPricingData]
|
||||||
|
)
|
||||||
|
|
||||||
const selectedTargetCount = table.getFilteredSelectedRowModel().rows.length
|
const selectedTargetCount = table.getFilteredSelectedRowModel().rows.length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -944,9 +970,7 @@ export const ModelRatioVisualEditor = memo(
|
|||||||
{table.getRowModel().rows.map((row) => (
|
{table.getRowModel().rows.map((row) => (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={row.id}
|
key={row.id}
|
||||||
data-state={
|
data-state={row.getIsSelected() ? 'selected' : undefined}
|
||||||
row.getIsSelected() ? 'selected' : undefined
|
|
||||||
}
|
|
||||||
className={
|
className={
|
||||||
editData?.name === row.original.name
|
editData?.name === row.original.name
|
||||||
? 'bg-muted/45'
|
? 'bg-muted/45'
|
||||||
@@ -954,8 +978,7 @@ export const ModelRatioVisualEditor = memo(
|
|||||||
}
|
}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
const target = event.target as HTMLElement
|
const target = event.target as HTMLElement
|
||||||
if (target.closest('button, [role="checkbox"]'))
|
if (target.closest('button, [role="checkbox"]')) return
|
||||||
return
|
|
||||||
handleEdit(row.original)
|
handleEdit(row.original)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -982,6 +1005,7 @@ export const ModelRatioVisualEditor = memo(
|
|||||||
<div className='hidden min-w-0 md:block'>
|
<div className='hidden min-w-0 md:block'>
|
||||||
{editorOpen ? (
|
{editorOpen ? (
|
||||||
<ModelPricingEditorPanel
|
<ModelPricingEditorPanel
|
||||||
|
ref={editorPanelRef}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
editData={editData}
|
editData={editData}
|
||||||
@@ -1018,6 +1042,7 @@ export const ModelRatioVisualEditor = memo(
|
|||||||
|
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<ModelPricingSheet
|
<ModelPricingSheet
|
||||||
|
ref={editorPanelRef}
|
||||||
open={sheetOpen}
|
open={sheetOpen}
|
||||||
onOpenChange={setSheetOpen}
|
onOpenChange={setSheetOpen}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
@@ -1028,7 +1053,10 @@ export const ModelRatioVisualEditor = memo(
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
},
|
})
|
||||||
|
|
||||||
|
export const ModelRatioVisualEditor = memo(
|
||||||
|
ModelRatioVisualEditorComponent,
|
||||||
// Custom equality check - only re-render if JSON props actually changed
|
// Custom equality check - only re-render if JSON props actually changed
|
||||||
(prevProps, nextProps) => {
|
(prevProps, nextProps) => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user