Commit abad0d3c by QuentinHsu

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.
parent 7aaa5332
......@@ -16,7 +16,14 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
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 { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
......@@ -130,6 +137,10 @@ type ModelPricingEditorPanelProps = Omit<
className?: string
}
export type ModelPricingEditorPanelHandle = {
commitDraft: () => Promise<ModelRatioData | null>
}
type PreviewRow = {
key: string
label: string
......@@ -377,14 +388,13 @@ function buildPreviewRows(
]
}
export function ModelPricingSheet({
open,
onOpenChange,
onSave,
onCancel,
editData,
selectedTargetCount = 0,
}: ModelPricingSheetProps) {
export const ModelPricingSheet = forwardRef<
ModelPricingEditorPanelHandle,
ModelPricingSheetProps
>(function ModelPricingSheet(
{ open, onOpenChange, onSave, onCancel, editData, selectedTargetCount = 0 },
ref
) {
const { t } = useTranslation()
const title = editData ? t('Edit model pricing') : t('Add model pricing')
const description = editData?.name || t('New model')
......@@ -400,6 +410,7 @@ export function ModelPricingSheet({
<SheetDescription>{description}</SheetDescription>
</SheetHeader>
<ModelPricingEditorPanel
ref={ref}
onSave={onSave}
editData={editData}
selectedTargetCount={selectedTargetCount}
......@@ -412,15 +423,15 @@ export function ModelPricingSheet({
</SheetContent>
</Sheet>
)
}
export function ModelPricingEditorPanel({
onSave,
editData,
selectedTargetCount = 0,
onCancel,
className,
}: ModelPricingEditorPanelProps) {
})
export const ModelPricingEditorPanel = forwardRef<
ModelPricingEditorPanelHandle,
ModelPricingEditorPanelProps
>(function ModelPricingEditorPanel(
{ onSave, editData, selectedTargetCount = 0, onCancel, className },
ref
) {
const { t } = useTranslation()
const [pricingMode, setPricingMode] = useState<PricingMode>('per-token')
const [promptPrice, setPromptPrice] = useState('')
......@@ -687,7 +698,7 @@ export function ModelPricingEditorPanel({
return nextWarnings
}, [editData, laneEnabled, lanePrices, pricingMode, promptPrice, t])
const handleSubmit = (values: ModelPricingFormValues) => {
const validatePricingValues = useCallback(() => {
if (
pricingMode === 'per-token' &&
toNumberOrNull(promptPrice) === null &&
......@@ -698,7 +709,7 @@ export function ModelPricingEditorPanel({
form.setError('ratio', {
message: t('Input price is required before saving dependent prices.'),
})
return
return false
}
if (
......@@ -709,27 +720,53 @@ export function ModelPricingEditorPanel({
form.setError('audioRatio', {
message: t('Audio output price requires an audio input price.'),
})
return
return false
}
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 || '',
}
return true
}, [form, laneEnabled, lanePrices, pricingMode, promptPrice, t])
const buildSubmitData = useCallback(
(values: ModelPricingFormValues) => {
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
}
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)
form.reset()
onCancel?.()
......@@ -980,7 +1017,7 @@ export function ModelPricingEditorPanel({
</Form>
</div>
)
}
})
function PriceInput(props: {
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
*/
import { memo, useCallback, useState } from 'react'
import { memo, useCallback, useRef, useState } from 'react'
import { type UseFormReturn } from 'react-hook-form'
import { Code2, Eye } from 'lucide-react'
import { useTranslation } from 'react-i18next'
......@@ -38,7 +38,10 @@ import {
SettingsSwitchItem,
} from '../components/settings-form-layout'
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 = {
ModelPrice: string
......@@ -71,6 +74,7 @@ export const ModelRatioForm = memo(function ModelRatioForm({
}: ModelRatioFormProps) {
const { t } = useTranslation()
const [editMode, setEditMode] = useState<'visual' | 'json'>('visual')
const visualEditorRef = useRef<ModelRatioVisualEditorHandle>(null)
const handleFieldChange = useCallback(
(field: keyof ModelFormValues, value: string) => {
......@@ -86,6 +90,15 @@ export const ModelRatioForm = memo(function ModelRatioForm({
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 (
<div className='space-y-6'>
<div className='flex justify-end'>
......@@ -118,7 +131,7 @@ export const ModelRatioForm = memo(function ModelRatioForm({
<Button
type='button'
size='sm'
onClick={form.handleSubmit(onSave)}
onClick={handleSave}
disabled={isSaving}
>
{isSaving ? t('Saving...') : t('Save model prices')}
......@@ -127,6 +140,7 @@ export const ModelRatioForm = memo(function ModelRatioForm({
{editMode === 'visual' ? (
<div className='space-y-6'>
<ModelRatioVisualEditor
ref={visualEditorRef}
modelPrice={form.watch('ModelPrice')}
modelRatio={form.watch('ModelRatio')}
cacheRatio={form.watch('CacheRatio')}
......
......@@ -16,7 +16,16 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
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 {
type ColumnDef,
type ColumnFiltersState,
......@@ -62,6 +71,7 @@ import {
import { safeJsonParse } from '../utils/json-parser'
import {
ModelPricingEditorPanel,
type ModelPricingEditorPanelHandle,
ModelPricingSheet,
type ModelRatioData,
} from './model-pricing-sheet'
......@@ -97,6 +107,10 @@ type ModelRow = {
hasConflict: boolean
}
export type ModelRatioVisualEditorHandle = {
commitOpenEditor: () => Promise<boolean>
}
const STORAGE_KEY = 'model-ratio-column-visibility'
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')
}
export const ModelRatioVisualEditor = memo(
function ModelRatioVisualEditor({
const ModelRatioVisualEditorComponent = forwardRef<
ModelRatioVisualEditorHandle,
ModelRatioVisualEditorProps
>(function ModelRatioVisualEditor(
{
modelPrice,
modelRatio,
cacheRatio,
......@@ -205,180 +222,339 @@ export const ModelRatioVisualEditor = memo(
billingMode,
billingExpr,
onChange,
}: ModelRatioVisualEditorProps) {
const { t } = useTranslation()
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: 20,
})
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
() => {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved) {
try {
return safeJsonParse<VisibilityState>(saved, {
fallback: {
cacheRatio: false,
createCacheRatio: false,
imageRatio: false,
audioRatio: false,
audioCompletionRatio: false,
},
silent: true,
})
} catch {
return {
},
ref
) {
const { t } = useTranslation()
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 editorPanelRef = useRef<ModelPricingEditorPanelHandle>(null)
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 20,
})
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
() => {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved) {
try {
return safeJsonParse<VisibilityState>(saved, {
fallback: {
cacheRatio: false,
createCacheRatio: false,
imageRatio: false,
audioRatio: false,
audioCompletionRatio: false,
}
},
silent: true,
})
} catch {
return {
cacheRatio: false,
createCacheRatio: false,
imageRatio: false,
audioRatio: false,
audioCompletionRatio: false,
}
}
}
return {
cacheRatio: false,
createCacheRatio: false,
imageRatio: false,
audioRatio: false,
audioCompletionRatio: false,
}
}
)
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(columnVisibility))
}, [columnVisibility])
const models = useMemo(() => {
const priceMap = safeJsonParse<Record<string, number>>(modelPrice, {
fallback: {},
context: 'model prices',
})
const ratioMap = safeJsonParse<Record<string, number>>(modelRatio, {
fallback: {},
context: 'model ratios',
})
const cacheMap = safeJsonParse<Record<string, number>>(cacheRatio, {
fallback: {},
context: 'cache ratios',
})
const createCacheMap = safeJsonParse<Record<string, number>>(
createCacheRatio,
{ fallback: {}, context: 'create cache ratios' }
)
const completionMap = safeJsonParse<Record<string, number>>(
completionRatio,
{ fallback: {}, context: 'completion ratios' }
)
const imageMap = safeJsonParse<Record<string, number>>(imageRatio, {
fallback: {},
context: 'image ratios',
})
const audioMap = safeJsonParse<Record<string, number>>(audioRatio, {
fallback: {},
context: 'audio ratios',
})
const audioCompletionMap = safeJsonParse<Record<string, number>>(
audioCompletionRatio,
{ fallback: {}, context: 'audio completion ratios' }
)
const billingModeMap = safeJsonParse<Record<string, string>>(billingMode, {
fallback: {},
context: 'billing mode',
})
const billingExprMap = safeJsonParse<Record<string, string>>(billingExpr, {
fallback: {},
context: 'billing expression',
})
const modelNames = new Set([
...Object.keys(priceMap),
...Object.keys(ratioMap),
...Object.keys(cacheMap),
...Object.keys(createCacheMap),
...Object.keys(completionMap),
...Object.keys(imageMap),
...Object.keys(audioMap),
...Object.keys(audioCompletionMap),
...Object.keys(billingModeMap),
...Object.keys(billingExprMap),
])
const modelData: ModelRow[] = Array.from(modelNames).map((name) => {
const price = priceMap[name]?.toString() || ''
const ratio = ratioMap[name]?.toString() || ''
const cache = cacheMap[name]?.toString() || ''
const createCache = createCacheMap[name]?.toString() || ''
const completion = completionMap[name]?.toString() || ''
const image = imageMap[name]?.toString() || ''
const audio = audioMap[name]?.toString() || ''
const audioCompletion = audioCompletionMap[name]?.toString() || ''
const modeForModel = billingModeMap[name]
if (modeForModel === 'tiered_expr') {
// Tiered_expr models may also retain ratio/price values as fallback
// during multi-instance sync delays. We preserve them in the row so
// the edit dialog round-trip and the next save don't drop them.
const fullExpr = billingExprMap[name] || ''
const { billingExpr: pureExpr, requestRuleExpr } =
splitBillingExprAndRequestRules(fullExpr)
return {
cacheRatio: false,
createCacheRatio: false,
imageRatio: false,
audioRatio: false,
audioCompletionRatio: false,
name,
billingMode: 'tiered_expr',
billingExpr: pureExpr,
requestRuleExpr,
price,
ratio,
cacheRatio: cache,
createCacheRatio: createCache,
completionRatio: completion,
imageRatio: image,
audioRatio: audio,
audioCompletionRatio: audioCompletion,
hasConflict: false,
}
}
)
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(columnVisibility))
}, [columnVisibility])
return {
name,
price,
ratio,
cacheRatio: cache,
createCacheRatio: createCache,
completionRatio: completion,
imageRatio: image,
audioRatio: audio,
audioCompletionRatio: audioCompletion,
billingMode: price !== '' ? 'per-request' : 'per-token',
hasConflict:
price !== '' &&
(ratio !== '' ||
completion !== '' ||
cache !== '' ||
createCache !== '' ||
image !== '' ||
audio !== '' ||
audioCompletion !== ''),
}
})
return modelData.sort((a, b) => a.name.localeCompare(b.name))
}, [
modelPrice,
modelRatio,
cacheRatio,
createCacheRatio,
completionRatio,
imageRatio,
audioRatio,
audioCompletionRatio,
billingMode,
billingExpr,
])
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)
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 models = useMemo(() => {
const handleDelete = useCallback(
(name: string) => {
const priceMap = safeJsonParse<Record<string, number>>(modelPrice, {
fallback: {},
context: 'model prices',
silent: true,
})
const ratioMap = safeJsonParse<Record<string, number>>(modelRatio, {
fallback: {},
context: 'model ratios',
silent: true,
})
const cacheMap = safeJsonParse<Record<string, number>>(cacheRatio, {
fallback: {},
context: 'cache ratios',
silent: true,
})
const createCacheMap = safeJsonParse<Record<string, number>>(
createCacheRatio,
{ fallback: {}, context: 'create cache ratios' }
{ fallback: {}, silent: true }
)
const completionMap = safeJsonParse<Record<string, number>>(
completionRatio,
{ fallback: {}, context: 'completion ratios' }
{ fallback: {}, silent: true }
)
const imageMap = safeJsonParse<Record<string, number>>(imageRatio, {
fallback: {},
context: 'image ratios',
silent: true,
})
const audioMap = safeJsonParse<Record<string, number>>(audioRatio, {
fallback: {},
context: 'audio ratios',
silent: true,
})
const audioCompletionMap = safeJsonParse<Record<string, number>>(
audioCompletionRatio,
{ fallback: {}, context: 'audio completion ratios' }
{ fallback: {}, silent: true }
)
const billingModeMap = safeJsonParse<Record<string, string>>(
billingMode,
{
fallback: {},
context: 'billing mode',
}
{ fallback: {}, silent: true }
)
const billingExprMap = safeJsonParse<Record<string, string>>(
billingExpr,
{
fallback: {},
context: 'billing expression',
}
{ fallback: {}, silent: true }
)
const modelNames = new Set([
...Object.keys(priceMap),
...Object.keys(ratioMap),
...Object.keys(cacheMap),
...Object.keys(createCacheMap),
...Object.keys(completionMap),
...Object.keys(imageMap),
...Object.keys(audioMap),
...Object.keys(audioCompletionMap),
...Object.keys(billingModeMap),
...Object.keys(billingExprMap),
])
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]
const modelData: ModelRow[] = Array.from(modelNames).map((name) => {
const price = priceMap[name]?.toString() || ''
const ratio = ratioMap[name]?.toString() || ''
const cache = cacheMap[name]?.toString() || ''
const createCache = createCacheMap[name]?.toString() || ''
const completion = completionMap[name]?.toString() || ''
const image = imageMap[name]?.toString() || ''
const audio = audioMap[name]?.toString() || ''
const audioCompletion = audioCompletionMap[name]?.toString() || ''
const modeForModel = billingModeMap[name]
if (modeForModel === 'tiered_expr') {
// Tiered_expr models may also retain ratio/price values as fallback
// during multi-instance sync delays. We preserve them in the row so
// the edit dialog round-trip and the next save don't drop them.
const fullExpr = billingExprMap[name] || ''
const { billingExpr: pureExpr, requestRuleExpr } =
splitBillingExprAndRequestRules(fullExpr)
return {
name,
billingMode: 'tiered_expr',
billingExpr: pureExpr,
requestRuleExpr,
price,
ratio,
cacheRatio: cache,
createCacheRatio: createCache,
completionRatio: completion,
imageRatio: image,
audioRatio: audio,
audioCompletionRatio: audioCompletion,
hasConflict: false,
}
}
return {
name,
price,
ratio,
cacheRatio: cache,
createCacheRatio: createCache,
completionRatio: completion,
imageRatio: image,
audioRatio: audio,
audioCompletionRatio: audioCompletion,
billingMode: price !== '' ? 'per-request' : 'per-token',
hasConflict:
price !== '' &&
(ratio !== '' ||
completion !== '' ||
cache !== '' ||
createCache !== '' ||
image !== '' ||
audio !== '' ||
audioCompletion !== ''),
}
})
return modelData.sort((a, b) => a.name.localeCompare(b.name))
}, [
onChange('ModelPrice', JSON.stringify(priceMap, null, 2))
onChange('ModelRatio', JSON.stringify(ratioMap, null, 2))
onChange('CacheRatio', JSON.stringify(cacheMap, null, 2))
onChange('CreateCacheRatio', JSON.stringify(createCacheMap, null, 2))
onChange('CompletionRatio', JSON.stringify(completionMap, null, 2))
onChange('ImageRatio', JSON.stringify(imageMap, null, 2))
onChange('AudioRatio', JSON.stringify(audioMap, null, 2))
onChange(
'AudioCompletionRatio',
JSON.stringify(audioCompletionMap, null, 2)
)
onChange(
'billing_setting.billing_mode',
JSON.stringify(billingModeMap, null, 2)
)
onChange(
'billing_setting.billing_expr',
JSON.stringify(billingExprMap, null, 2)
)
},
[
modelPrice,
modelRatio,
cacheRatio,
......@@ -389,127 +565,210 @@ export const ModelRatioVisualEditor = memo(
audioCompletionRatio,
billingMode,
billingExpr,
])
onChange,
]
)
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>
const columns = useMemo<ColumnDef<ModelRow>[]>(() => {
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]'
/>
),
[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)
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') },
},
[isMobile]
)
const handleAdd = useCallback(() => {
setEditData(null)
setEditorOpen(true)
if (isMobile) setSheetOpen(true)
}, [isMobile])
{
accessorKey: 'name',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Model name')} />
),
cell: ({ row }) => (
<div className='flex items-center gap-2 font-medium'>
{row.getValue('name')}
{row.original.billingMode === 'tiered_expr' && (
<StatusBadge
label={t('Tiered')}
variant='info'
copyable={false}
/>
)}
{row.original.hasConflict && (
<StatusBadge
label={t('Conflict')}
variant='danger'
copyable={false}
/>
)}
</div>
),
enableHiding: false,
},
{
accessorKey: 'billingMode',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Mode')} />
),
cell: ({ row }) => (
<StatusBadge
label={t(getModeLabel(row.original.billingMode))}
variant={getModeVariant(row.original.billingMode)}
copyable={false}
/>
),
filterFn: (row, id, value) =>
filterBySelectedValues(row.getValue(id), value),
meta: { label: t('Mode') },
},
{
id: 'priceSummary',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Price summary')} />
),
cell: ({ row }) => (
<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>
),
sortingFn: (rowA, rowB) =>
getPriceSummary(rowA.original, t).localeCompare(
getPriceSummary(rowB.original, t)
),
meta: { label: t('Price summary') },
},
{
id: 'actions',
cell: ({ row }) => (
<div className='flex justify-end gap-2'>
<Button
variant='ghost'
size='sm'
onClick={() => handleEdit(row.original)}
>
<Pencil />
</Button>
<Button
variant='ghost'
size='sm'
onClick={() => handleDelete(row.original.name)}
>
<Trash2 />
</Button>
</div>
),
enableHiding: false,
},
]
}, [handleEdit, handleDelete, t])
const handleCancel = useCallback(() => {
setEditData(null)
setEditorOpen(false)
setSheetOpen(false)
}, [])
const table = useReactTable({
data: models,
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 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 persistPricingData = useCallback(
(data: ModelRatioData, targetNames: string[] = [data.name]) => {
const priceMap = safeJsonParse<Record<string, number>>(modelPrice, {
fallback: {},
silent: true,
})
const ratioMap = safeJsonParse<Record<string, number>>(modelRatio, {
fallback: {},
silent: true,
})
const cacheMap = safeJsonParse<Record<string, number>>(cacheRatio, {
fallback: {},
silent: true,
})
const createCacheMap = safeJsonParse<Record<string, number>>(
createCacheRatio,
{ fallback: {}, silent: true }
)
const completionMap = safeJsonParse<Record<string, number>>(
completionRatio,
{ fallback: {}, silent: true }
)
const imageMap = safeJsonParse<Record<string, number>>(imageRatio, {
fallback: {},
silent: true,
})
const audioMap = safeJsonParse<Record<string, number>>(audioRatio, {
fallback: {},
silent: true,
})
const audioCompletionMap = safeJsonParse<Record<string, number>>(
audioCompletionRatio,
{ fallback: {}, silent: true }
)
const billingModeMap = safeJsonParse<Record<string, string>>(
billingMode,
{ fallback: {}, silent: true }
)
const billingExprMap = safeJsonParse<Record<string, string>>(
billingExpr,
{ fallback: {}, silent: true }
)
const handleDelete = useCallback(
(name: string) => {
const priceMap = safeJsonParse<Record<string, number>>(modelPrice, {
fallback: {},
silent: true,
})
const ratioMap = safeJsonParse<Record<string, number>>(modelRatio, {
fallback: {},
silent: true,
})
const cacheMap = safeJsonParse<Record<string, number>>(cacheRatio, {
fallback: {},
silent: true,
})
const createCacheMap = safeJsonParse<Record<string, number>>(
createCacheRatio,
{ fallback: {}, silent: true }
)
const completionMap = safeJsonParse<Record<string, number>>(
completionRatio,
{ fallback: {}, silent: true }
)
const imageMap = safeJsonParse<Record<string, number>>(imageRatio, {
fallback: {},
silent: true,
})
const audioMap = safeJsonParse<Record<string, number>>(audioRatio, {
fallback: {},
silent: true,
})
const audioCompletionMap = safeJsonParse<Record<string, number>>(
audioCompletionRatio,
{ fallback: {}, silent: true }
)
const billingModeMap = safeJsonParse<Record<string, string>>(
billingMode,
{ fallback: {}, silent: true }
)
const billingExprMap = safeJsonParse<Record<string, string>>(
billingExpr,
{ fallback: {}, silent: true }
)
const setIfPresent = (
target: Record<string, number>,
name: string,
value: string | undefined
) => {
if (!value || value === '') return
const parsed = parseFloat(value)
if (Number.isFinite(parsed)) target[name] = parsed
}
targetNames.forEach((name) => {
delete priceMap[name]
delete ratioMap[name]
delete cacheMap[name]
......@@ -521,514 +780,283 @@ export const ModelRatioVisualEditor = memo(
delete billingModeMap[name]
delete billingExprMap[name]
onChange('ModelPrice', JSON.stringify(priceMap, null, 2))
onChange('ModelRatio', JSON.stringify(ratioMap, null, 2))
onChange('CacheRatio', JSON.stringify(cacheMap, null, 2))
onChange('CreateCacheRatio', JSON.stringify(createCacheMap, null, 2))
onChange('CompletionRatio', JSON.stringify(completionMap, null, 2))
onChange('ImageRatio', JSON.stringify(imageMap, null, 2))
onChange('AudioRatio', JSON.stringify(audioMap, null, 2))
onChange(
'AudioCompletionRatio',
JSON.stringify(audioCompletionMap, null, 2)
)
onChange(
'billing_setting.billing_mode',
JSON.stringify(billingModeMap, null, 2)
)
onChange(
'billing_setting.billing_expr',
JSON.stringify(billingExprMap, null, 2)
)
},
[
modelPrice,
modelRatio,
cacheRatio,
createCacheRatio,
completionRatio,
imageRatio,
audioRatio,
audioCompletionRatio,
billingMode,
billingExpr,
onChange,
]
)
const columns = useMemo<ColumnDef<ModelRow>[]>(() => {
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 }) => (
<DataTableColumnHeader column={column} title={t('Model name')} />
),
cell: ({ row }) => (
<div className='flex items-center gap-2 font-medium'>
{row.getValue('name')}
{row.original.billingMode === 'tiered_expr' && (
<StatusBadge
label={t('Tiered')}
variant='info'
copyable={false}
/>
)}
{row.original.hasConflict && (
<StatusBadge
label={t('Conflict')}
variant='danger'
copyable={false}
/>
)}
</div>
),
enableHiding: false,
},
{
accessorKey: 'billingMode',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Mode')} />
),
cell: ({ row }) => (
<StatusBadge
label={t(getModeLabel(row.original.billingMode))}
variant={getModeVariant(row.original.billingMode)}
copyable={false}
/>
),
filterFn: (row, id, value) =>
filterBySelectedValues(row.getValue(id), value),
meta: { label: t('Mode') },
},
{
id: 'priceSummary',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Price summary')} />
),
cell: ({ row }) => (
<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>
),
sortingFn: (rowA, rowB) =>
getPriceSummary(rowA.original, t).localeCompare(
getPriceSummary(rowB.original, t)
),
meta: { label: t('Price summary') },
},
{
id: 'actions',
cell: ({ row }) => (
<div className='flex justify-end gap-2'>
<Button
variant='ghost'
size='sm'
onClick={() => handleEdit(row.original)}
>
<Pencil />
</Button>
<Button
variant='ghost'
size='sm'
onClick={() => handleDelete(row.original.name)}
>
<Trash2 />
</Button>
</div>
),
enableHiding: false,
},
]
}, [handleEdit, handleDelete, t])
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)
}
})
const table = useReactTable({
data: models,
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)
},
})
onChange('ModelPrice', JSON.stringify(priceMap, null, 2))
onChange('ModelRatio', JSON.stringify(ratioMap, null, 2))
onChange('CacheRatio', JSON.stringify(cacheMap, null, 2))
onChange('CreateCacheRatio', JSON.stringify(createCacheMap, null, 2))
onChange('CompletionRatio', JSON.stringify(completionMap, null, 2))
onChange('ImageRatio', JSON.stringify(imageMap, null, 2))
onChange('AudioRatio', JSON.stringify(audioMap, null, 2))
onChange(
'AudioCompletionRatio',
JSON.stringify(audioCompletionMap, null, 2)
)
onChange(
'billing_setting.billing_mode',
JSON.stringify(billingModeMap, null, 2)
)
onChange(
'billing_setting.billing_expr',
JSON.stringify(billingExprMap, null, 2)
)
},
[
modelPrice,
modelRatio,
cacheRatio,
createCacheRatio,
completionRatio,
imageRatio,
audioRatio,
audioCompletionRatio,
billingMode,
billingExpr,
onChange,
]
)
const persistPricingData = useCallback(
(data: ModelRatioData, targetNames: string[] = [data.name]) => {
const priceMap = safeJsonParse<Record<string, number>>(modelPrice, {
fallback: {},
silent: true,
})
const ratioMap = safeJsonParse<Record<string, number>>(modelRatio, {
fallback: {},
silent: true,
})
const cacheMap = safeJsonParse<Record<string, number>>(cacheRatio, {
fallback: {},
silent: true,
})
const createCacheMap = safeJsonParse<Record<string, number>>(
createCacheRatio,
{ fallback: {}, silent: true }
)
const completionMap = safeJsonParse<Record<string, number>>(
completionRatio,
{ fallback: {}, silent: true }
)
const imageMap = safeJsonParse<Record<string, number>>(imageRatio, {
fallback: {},
silent: true,
})
const audioMap = safeJsonParse<Record<string, number>>(audioRatio, {
fallback: {},
silent: true,
})
const audioCompletionMap = safeJsonParse<Record<string, number>>(
audioCompletionRatio,
{ fallback: {}, silent: true }
)
const billingModeMap = safeJsonParse<Record<string, string>>(
billingMode,
{ fallback: {}, silent: true }
)
const billingExprMap = safeJsonParse<Record<string, string>>(
billingExpr,
{ fallback: {}, silent: true }
)
const handleSave = useCallback(
(data: ModelRatioData) => {
persistPricingData(data)
setEditData(data)
setEditorOpen(true)
toast.success(
t('Pricing changes saved to draft. Click "Save model prices" to apply.')
)
},
[persistPricingData, t]
)
const setIfPresent = (
target: Record<string, number>,
name: string,
value: string | undefined
) => {
if (!value || value === '') return
const parsed = parseFloat(value)
if (Number.isFinite(parsed)) target[name] = parsed
}
const handleBatchCopy = useCallback(() => {
if (!editData) {
toast.error(t('Open a source model first'))
return
}
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]
const targetNames = table
.getFilteredSelectedRowModel()
.rows.map((row) => row.original.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)
}
})
if (targetNames.length === 0) {
toast.error(t('Select at least one target model'))
return
}
onChange('ModelPrice', JSON.stringify(priceMap, null, 2))
onChange('ModelRatio', JSON.stringify(ratioMap, null, 2))
onChange('CacheRatio', JSON.stringify(cacheMap, null, 2))
onChange('CreateCacheRatio', JSON.stringify(createCacheMap, null, 2))
onChange('CompletionRatio', JSON.stringify(completionMap, null, 2))
onChange('ImageRatio', JSON.stringify(imageMap, null, 2))
onChange('AudioRatio', JSON.stringify(audioMap, null, 2))
onChange(
'AudioCompletionRatio',
JSON.stringify(audioCompletionMap, null, 2)
)
onChange(
'billing_setting.billing_mode',
JSON.stringify(billingModeMap, null, 2)
)
onChange(
'billing_setting.billing_expr',
JSON.stringify(billingExprMap, null, 2)
)
},
[
modelPrice,
modelRatio,
cacheRatio,
createCacheRatio,
completionRatio,
imageRatio,
audioRatio,
audioCompletionRatio,
billingMode,
billingExpr,
onChange,
]
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 handleSave = useCallback(
(data: ModelRatioData) => {
useImperativeHandle(
ref,
() => ({
commitOpenEditor: async () => {
if (!editorOpen || !editorPanelRef.current) return true
const data = await editorPanelRef.current.commitDraft()
if (!data) return false
persistPricingData(data)
setEditData(data)
setEditorOpen(true)
toast.success(
t(
'Pricing changes saved to draft. Click "Save model prices" to apply.'
)
)
return true
},
[persistPricingData, t]
)
}),
[editorOpen, persistPricingData]
)
const handleBatchCopy = useCallback(() => {
if (!editData) {
toast.error(t('Open a source model first'))
return
}
const selectedTargetCount = table.getFilteredSelectedRowModel().rows.length
const targetNames = table
.getFilteredSelectedRowModel()
.rows.map((row) => row.original.name)
if (targetNames.length === 0) {
toast.error(t('Select at least one target model'))
return
}
return (
<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>
}
/>
persistPricingData(editData, targetNames)
table.resetRowSelection()
toast.success(
t('Applied {{name}} pricing to {{count}} models', {
name: editData.name,
count: targetNames.length,
})
)
}, [editData, persistPricingData, t, table])
{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>
)}
const selectedTargetCount = table.getFilteredSelectedRowModel().rows.length
{table.getRowModel().rows.length > 0 && (
<DataTablePagination table={table} />
)}
</div>
return (
<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>
}
<div className='hidden min-w-0 md:block'>
{editorOpen ? (
<ModelPricingEditorPanel
ref={editorPanelRef}
onSave={handleSave}
onCancel={handleCancel}
editData={editData}
selectedTargetCount={selectedTargetCount}
className='sticky top-4 h-[calc(100vh-8rem)] min-h-[620px]'
/>
{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 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>
)}
</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>
<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>
<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>
{isMobile && (
<ModelPricingSheet
open={sheetOpen}
onOpenChange={setSheetOpen}
onSave={handleSave}
onCancel={handleCancel}
editData={editData}
selectedTargetCount={selectedTargetCount}
/>
)}
</div>
)
},
{isMobile && (
<ModelPricingSheet
ref={editorPanelRef}
open={sheetOpen}
onOpenChange={setSheetOpen}
onSave={handleSave}
onCancel={handleCancel}
editData={editData}
selectedTargetCount={selectedTargetCount}
/>
)}
</div>
)
})
export const ModelRatioVisualEditor = memo(
ModelRatioVisualEditorComponent,
// Custom equality check - only re-render if JSON props actually changed
(prevProps, nextProps) => {
return (
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment