From abad0d3cc08908c3c84b035ba436aa37c10aec94 Mon Sep 17 00:00:00 2001 From: QuentinHsu Date: Wed, 3 Jun 2026 14:49:08 +0800 Subject: [PATCH 01/10] 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. --- .../models/model-pricing-sheet.tsx | 111 +- .../models/model-ratio-form.tsx | 20 +- .../models/model-ratio-visual-editor.tsx | 1552 +++++++++-------- 3 files changed, 881 insertions(+), 802 deletions(-) diff --git a/web/default/src/features/system-settings/models/model-pricing-sheet.tsx b/web/default/src/features/system-settings/models/model-pricing-sheet.tsx index b9e5c548..9b033d9c 100644 --- a/web/default/src/features/system-settings/models/model-pricing-sheet.tsx +++ b/web/default/src/features/system-settings/models/model-pricing-sheet.tsx @@ -16,7 +16,14 @@ along with this program. If not, see . 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 +} + 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({ {description} ) -} +}) -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('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]) - if (pricingMode === 'tiered_expr') { - data.billingExpr = billingExpr - data.requestRuleExpr = requestRuleExpr - } + 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 + } + + 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({ ) -} +}) function PriceInput(props: { value: string diff --git a/web/default/src/features/system-settings/models/model-ratio-form.tsx b/web/default/src/features/system-settings/models/model-ratio-form.tsx index 5b8d1f14..f098c025 100644 --- a/web/default/src/features/system-settings/models/model-ratio-form.tsx +++ b/web/default/src/features/system-settings/models/model-ratio-form.tsx @@ -16,7 +16,7 @@ along with this program. If not, see . 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(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 (
@@ -118,7 +131,7 @@ export const ModelRatioForm = memo(function ModelRatioForm({ + +
+ ), + enableHiding: false, + }, + ] + }, [handleEdit, handleDelete, t]) - const handleDelete = useCallback( - (name: string) => { - const priceMap = safeJsonParse>(modelPrice, { - fallback: {}, - silent: true, - }) - const ratioMap = safeJsonParse>(modelRatio, { - fallback: {}, - silent: true, - }) - const cacheMap = safeJsonParse>(cacheRatio, { - fallback: {}, - silent: true, - }) - const createCacheMap = safeJsonParse>( - createCacheRatio, - { fallback: {}, silent: true } - ) - const completionMap = safeJsonParse>( - completionRatio, - { fallback: {}, silent: true } - ) - const imageMap = safeJsonParse>(imageRatio, { - fallback: {}, - silent: true, - }) - const audioMap = safeJsonParse>(audioRatio, { - fallback: {}, - silent: true, - }) - const audioCompletionMap = safeJsonParse>( - audioCompletionRatio, - { fallback: {}, silent: true } - ) - const billingModeMap = safeJsonParse>( - billingMode, - { fallback: {}, silent: true } - ) - const billingExprMap = safeJsonParse>( - billingExpr, - { fallback: {}, silent: true } - ) + 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 persistPricingData = useCallback( + (data: ModelRatioData, targetNames: string[] = [data.name]) => { + const priceMap = safeJsonParse>(modelPrice, { + fallback: {}, + silent: true, + }) + const ratioMap = safeJsonParse>(modelRatio, { + fallback: {}, + silent: true, + }) + const cacheMap = safeJsonParse>(cacheRatio, { + fallback: {}, + silent: true, + }) + const createCacheMap = safeJsonParse>( + createCacheRatio, + { fallback: {}, silent: true } + ) + const completionMap = safeJsonParse>( + completionRatio, + { fallback: {}, silent: true } + ) + const imageMap = safeJsonParse>(imageRatio, { + fallback: {}, + silent: true, + }) + const audioMap = safeJsonParse>(audioRatio, { + fallback: {}, + silent: true, + }) + const audioCompletionMap = safeJsonParse>( + audioCompletionRatio, + { fallback: {}, silent: true } + ) + const billingModeMap = safeJsonParse>( + billingMode, + { fallback: {}, silent: true } + ) + const billingExprMap = safeJsonParse>( + billingExpr, + { fallback: {}, silent: true } + ) + + const setIfPresent = ( + target: Record, + 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[]>(() => { - return [ - { - id: 'select', - header: ({ table }) => ( - - table.toggleAllPageRowsSelected(!!value) - } - aria-label={t('Select all')} - className='translate-y-[2px]' - /> - ), - cell: ({ row }) => ( - row.toggleSelected(!!value)} - aria-label={t('Select row')} - className='translate-y-[2px]' - /> - ), - enableSorting: false, - enableHiding: false, - meta: { label: t('Select') }, - }, - { - accessorKey: 'name', - header: ({ column }) => ( - - ), - cell: ({ row }) => ( -
- {row.getValue('name')} - {row.original.billingMode === 'tiered_expr' && ( - - )} - {row.original.hasConflict && ( - - )} -
- ), - enableHiding: false, - }, - { - accessorKey: 'billingMode', - header: ({ column }) => ( - - ), - cell: ({ row }) => ( - - ), - filterFn: (row, id, value) => - filterBySelectedValues(row.getValue(id), value), - meta: { label: t('Mode') }, - }, - { - id: 'priceSummary', - header: ({ column }) => ( - - ), - cell: ({ row }) => ( -
- - {getPriceSummary(row.original, t)} - - - {getPriceDetail(row.original, t)} - -
- ), - sortingFn: (rowA, rowB) => - getPriceSummary(rowA.original, t).localeCompare( - getPriceSummary(rowB.original, t) - ), - meta: { label: t('Price summary') }, - }, - { - id: 'actions', - cell: ({ row }) => ( -
- - -
- ), - enableHiding: false, - }, - ] - }, [handleEdit, handleDelete, t]) - - 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 persistPricingData = useCallback( - (data: ModelRatioData, targetNames: string[] = [data.name]) => { - const priceMap = safeJsonParse>(modelPrice, { - fallback: {}, - silent: true, - }) - const ratioMap = safeJsonParse>(modelRatio, { - fallback: {}, - silent: true, - }) - const cacheMap = safeJsonParse>(cacheRatio, { - fallback: {}, - silent: true, - }) - const createCacheMap = safeJsonParse>( - createCacheRatio, - { fallback: {}, silent: true } - ) - const completionMap = safeJsonParse>( - completionRatio, - { fallback: {}, silent: true } - ) - const imageMap = safeJsonParse>(imageRatio, { - fallback: {}, - silent: true, - }) - const audioMap = safeJsonParse>(audioRatio, { - fallback: {}, - silent: true, - }) - const audioCompletionMap = safeJsonParse>( - audioCompletionRatio, - { fallback: {}, silent: true } - ) - const billingModeMap = safeJsonParse>( - billingMode, - { fallback: {}, silent: true } - ) - const billingExprMap = safeJsonParse>( - billingExpr, - { fallback: {}, silent: true } - ) - - const setIfPresent = ( - target: Record, - 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] - delete createCacheMap[name] - delete completionMap[name] - delete imageMap[name] - delete audioMap[name] - delete audioCompletionMap[name] - delete billingModeMap[name] - delete billingExprMap[name] - - if (data.billingMode === 'tiered_expr') { - const combined = combineBillingExpr( - data.billingExpr || '', - data.requestRuleExpr || '' - ) - if (combined) { - billingModeMap[name] = 'tiered_expr' - billingExprMap[name] = combined - } - // Always serialize ratio/price values for tiered_expr models so they - // serve as fallback during multi-instance sync delays. The backend's - // ModelPriceHelper checks billing_mode first, so these values are - // only consulted when billing_setting hasn't propagated yet. - setIfPresent(priceMap, name, data.price) - setIfPresent(ratioMap, name, data.ratio) - setIfPresent(cacheMap, name, data.cacheRatio) - setIfPresent(createCacheMap, name, data.createCacheRatio) - setIfPresent(completionMap, name, data.completionRatio) - setIfPresent(imageMap, name, data.imageRatio) - setIfPresent(audioMap, name, data.audioRatio) - setIfPresent(audioCompletionMap, name, data.audioCompletionRatio) - } else if (data.price && data.price !== '') { - setIfPresent(priceMap, name, data.price) - } else { - setIfPresent(ratioMap, name, data.ratio) - setIfPresent(cacheMap, name, data.cacheRatio) - setIfPresent(createCacheMap, name, data.createCacheRatio) - setIfPresent(completionMap, name, data.completionRatio) - setIfPresent(imageMap, name, data.imageRatio) - setIfPresent(audioMap, name, data.audioRatio) - setIfPresent(audioCompletionMap, name, data.audioCompletionRatio) + 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) + } + }) - 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, - ] + 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 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 handleBatchCopy = useCallback(() => { + if (!editData) { + toast.error(t('Open a source model first')) + return + } + + const targetNames = table + .getFilteredSelectedRowModel() + .rows.map((row) => row.original.name) + + if (targetNames.length === 0) { + toast.error(t('Select at least one target model')) + return + } + + persistPricingData(editData, targetNames) + table.resetRowSelection() + toast.success( + t('Applied {{name}} pricing to {{count}} models', { + name: editData.name, + count: targetNames.length, + }) ) + }, [editData, persistPricingData, t, table]) - const 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) + return ( +
+
+
+ + + {t('Add model')} + + } + /> - if (targetNames.length === 0) { - toast.error(t('Select at least one target model')) - return - } + {table.getRowModel().rows.length === 0 ? ( +
+ {table.getState().globalFilter + ? t('No models match your search') + : t('No models configured. Use Add model to get started.')} +
+ ) : ( +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + { + const target = event.target as HTMLElement + if (target.closest('button, [role="checkbox"]')) return + handleEdit(row.original) + }} + > + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + ))} + +
+
+ )} - persistPricingData(editData, targetNames) - table.resetRowSelection() - toast.success( - t('Applied {{name}} pricing to {{count}} models', { - name: editData.name, - count: targetNames.length, - }) - ) - }, [editData, persistPricingData, t, table]) - - const selectedTargetCount = table.getFilteredSelectedRowModel().rows.length - - return ( -
-
-
- - - {t('Add model')} - - } - /> - - {table.getRowModel().rows.length === 0 ? ( -
- {table.getState().globalFilter - ? t('No models match your search') - : t('No models configured. Use Add model to get started.')} -
- ) : ( -
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext() - )} - - ))} - - ))} - - - {table.getRowModel().rows.map((row) => ( - { - const target = event.target as HTMLElement - if (target.closest('button, [role="checkbox"]')) - return - handleEdit(row.original) - }} - > - {row.getVisibleCells().map((cell) => ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext() - )} - - ))} - - ))} - -
-
- )} - - {table.getRowModel().rows.length > 0 && ( - - )} -
- -
- {editorOpen ? ( - - ) : ( -
-
- {t('Select a model to edit pricing')} -
-

- {t( - 'Use the full-width table to scan prices, then select a row to edit it here.' - )} -

- -
- )} -
+ {table.getRowModel().rows.length > 0 && ( + + )}
- - - - - {isMobile && ( - - )} +
+ {editorOpen ? ( + + ) : ( +
+
+ {t('Select a model to edit pricing')} +
+

+ {t( + 'Use the full-width table to scan prices, then select a row to edit it here.' + )} +

+ +
+ )} +
- ) - }, + + + + + + {isMobile && ( + + )} +
+ ) +}) + +export const ModelRatioVisualEditor = memo( + ModelRatioVisualEditorComponent, // Custom equality check - only re-render if JSON props actually changed (prevProps, nextProps) => { return ( From 9e59ffc3d819faf1d6875de36f4ff1e934052ef4 Mon Sep 17 00:00:00 2001 From: QuentinHsu Date: Wed, 3 Jun 2026 18:27:07 +0800 Subject: [PATCH 02/10] fix(model-pricing): align pricing mode editor spacing - add consistent tab and field spacing so each pricing mode keeps the same visual rhythm. - wrap per-request and tiered sections in shared field groups to match the per-token form structure. - keep fixed-price descriptions and validation messages aligned with the updated field layout. --- .../models/model-pricing-sheet.tsx | 108 +++++++++--------- 1 file changed, 56 insertions(+), 52 deletions(-) diff --git a/web/default/src/features/system-settings/models/model-pricing-sheet.tsx b/web/default/src/features/system-settings/models/model-pricing-sheet.tsx index 9b033d9c..f35afb80 100644 --- a/web/default/src/features/system-settings/models/model-pricing-sheet.tsx +++ b/web/default/src/features/system-settings/models/model-pricing-sheet.tsx @@ -839,7 +839,11 @@ export const ModelPricingEditorPanel = forwardRef< )} /> - + {t('Per-token')} @@ -850,8 +854,8 @@ export const ModelPricingEditorPanel = forwardRef< - - + + {t('Input price')} - - ( - - {t('Fixed price')} - - - $ - { - const value = event.target.value - if (numericDraftRegex.test(value)) { - field.onChange(value) - } - }} - /> - - {t('per request')} - - - - - {t( - 'Cost in USD per request, regardless of tokens used.' - )} - - - - )} - /> + + + ( + + + {t('Fixed price')} + + + $ + { + const value = event.target.value + if (numericDraftRegex.test(value)) { + field.onChange(value) + } + }} + /> + + {t('per request')} + + + + + {t( + 'Cost in USD per request, regardless of tokens used.' + )} + + + + + )} + /> + - - + + + + From 39e05118ffae4e8ac184dc435cfaf8d244eebea7 Mon Sep 17 00:00:00 2001 From: QuentinHsu Date: Wed, 3 Jun 2026 18:27:40 +0800 Subject: [PATCH 03/10] fix(model-pricing): align pricing mode editor spacing - add consistent tab and field spacing so each pricing mode keeps the same visual rhythm. - wrap per-request and tiered sections in shared field groups to match the per-token form structure. - keep fixed-price descriptions and validation messages aligned with the updated field layout. --- .../models/tiered-pricing-editor.tsx | 61 ++++++++++--------- 1 file changed, 31 insertions(+), 30 deletions(-) diff --git a/web/default/src/features/system-settings/models/tiered-pricing-editor.tsx b/web/default/src/features/system-settings/models/tiered-pricing-editor.tsx index e1d54339..784e4d7b 100644 --- a/web/default/src/features/system-settings/models/tiered-pricing-editor.tsx +++ b/web/default/src/features/system-settings/models/tiered-pricing-editor.tsx @@ -40,6 +40,7 @@ import { CollapsibleContent, CollapsibleTrigger, } from '@/components/ui/collapsible' +import { Field, FieldLabel } from '@/components/ui/field' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { @@ -1309,9 +1310,7 @@ function PresetSection({ applyPreset }: PresetSectionProps) { return (
- - {t('Preset templates')} - + {t('Preset templates')} {hasMore && ( - -
- +
diff --git a/web/default/src/features/system-settings/models/model-ratio-form.tsx b/web/default/src/features/system-settings/models/model-ratio-form.tsx index f098c025..a1110903 100644 --- a/web/default/src/features/system-settings/models/model-ratio-form.tsx +++ b/web/default/src/features/system-settings/models/model-ratio-form.tsx @@ -37,7 +37,6 @@ import { SettingsSwitchContent, SettingsSwitchItem, } from '../components/settings-form-layout' -import { SettingsPageActionsPortal } from '../components/settings-page-context' import { ModelRatioVisualEditor, type ModelRatioVisualEditorHandle, @@ -59,6 +58,7 @@ type ModelFormValues = { type ModelRatioFormProps = { form: UseFormReturn + savedValues: ModelFormValues onSave: (values: ModelFormValues) => Promise onReset: () => void isSaving: boolean @@ -67,6 +67,7 @@ type ModelRatioFormProps = { export const ModelRatioForm = memo(function ModelRatioForm({ form, + savedValues, onSave, onReset, isSaving, @@ -101,7 +102,24 @@ export const ModelRatioForm = memo(function ModelRatioForm({ return (
-
+
+ +
- - - - {editMode === 'visual' ? (
void } -type ModelRow = { +type ModelPricingSnapshot = { name: string price?: string ratio?: string @@ -107,6 +109,14 @@ type ModelRow = { hasConflict: boolean } +type ModelRow = ModelPricingSnapshot & { + saved?: ModelPricingSnapshot + draft?: ModelPricingSnapshot + isDraftChanged: boolean + isDraftDeleted: boolean + isDraftNew: boolean +} + export type ModelRatioVisualEditorHandle = { commitOpenEditor: () => Promise } @@ -148,7 +158,10 @@ const getModeVariant = (mode?: string): 'warning' | 'info' | 'success' => { return 'success' } -const getExpressionSummary = (row: ModelRow, t: (key: string) => string) => { +const getExpressionSummary = ( + row: ModelPricingSnapshot, + t: (key: string) => string +) => { const tierCount = (row.billingExpr?.match(/tier\(/g) || []).length if (tierCount > 0) { return `${t('Tiered pricing')} · ${tierCount} ${t('tiers')}` @@ -156,7 +169,10 @@ const getExpressionSummary = (row: ModelRow, t: (key: string) => string) => { return t('Expression pricing') } -const getPriceSummary = (row: ModelRow, t: (key: string) => string) => { +const getPriceSummary = ( + row: ModelPricingSnapshot, + t: (key: string) => string +) => { if (row.billingMode === 'tiered_expr') { return getExpressionSummary(row, t) } @@ -181,7 +197,10 @@ const getPriceSummary = (row: ModelRow, t: (key: string) => string) => { : `${t('Input')} $${inputPrice}` } -const getPriceDetail = (row: ModelRow, t: (key: string) => string) => { +const getPriceDetail = ( + row: ModelPricingSnapshot, + t: (key: string) => string +) => { if (row.billingMode === 'tiered_expr') { return row.requestRuleExpr ? t('Includes request rules') @@ -206,11 +225,172 @@ const getPriceDetail = (row: ModelRow, t: (key: string) => string) => { return details.length > 0 ? details.join(' · ') : t('Base input price only') } +const buildModelSnapshots = ({ + modelPrice, + modelRatio, + cacheRatio, + createCacheRatio, + completionRatio, + imageRatio, + audioRatio, + audioCompletionRatio, + billingMode, + billingExpr, +}: Pick< + ModelRatioVisualEditorProps, + | 'modelPrice' + | 'modelRatio' + | 'cacheRatio' + | 'createCacheRatio' + | 'completionRatio' + | 'imageRatio' + | 'audioRatio' + | 'audioCompletionRatio' + | 'billingMode' + | 'billingExpr' +>): ModelPricingSnapshot[] => { + const priceMap = safeJsonParse>(modelPrice, { + fallback: {}, + context: 'model prices', + }) + const ratioMap = safeJsonParse>(modelRatio, { + fallback: {}, + context: 'model ratios', + }) + const cacheMap = safeJsonParse>(cacheRatio, { + fallback: {}, + context: 'cache ratios', + }) + const createCacheMap = safeJsonParse>( + createCacheRatio, + { fallback: {}, context: 'create cache ratios' } + ) + const completionMap = safeJsonParse>(completionRatio, { + fallback: {}, + context: 'completion ratios', + }) + const imageMap = safeJsonParse>(imageRatio, { + fallback: {}, + context: 'image ratios', + }) + const audioMap = safeJsonParse>(audioRatio, { + fallback: {}, + context: 'audio ratios', + }) + const audioCompletionMap = safeJsonParse>( + audioCompletionRatio, + { fallback: {}, context: 'audio completion ratios' } + ) + const billingModeMap = safeJsonParse>(billingMode, { + fallback: {}, + context: 'billing mode', + }) + const billingExprMap = safeJsonParse>(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), + ]) + + return 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') { + 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 !== ''), + } + }) +} + +const getSnapshotSignature = (snapshot?: ModelPricingSnapshot) => { + if (!snapshot) return '' + return JSON.stringify({ + price: snapshot.price || '', + ratio: snapshot.ratio || '', + cacheRatio: snapshot.cacheRatio || '', + createCacheRatio: snapshot.createCacheRatio || '', + completionRatio: snapshot.completionRatio || '', + imageRatio: snapshot.imageRatio || '', + audioRatio: snapshot.audioRatio || '', + audioCompletionRatio: snapshot.audioCompletionRatio || '', + billingMode: snapshot.billingMode || 'per-token', + billingExpr: snapshot.billingExpr || '', + requestRuleExpr: snapshot.requestRuleExpr || '', + }) +} + const ModelRatioVisualEditorComponent = forwardRef< ModelRatioVisualEditorHandle, ModelRatioVisualEditorProps >(function ModelRatioVisualEditor( { + savedModelPrice, + savedModelRatio, + savedCacheRatio, + savedCreateCacheRatio, + savedCompletionRatio, + savedImageRatio, + savedAudioRatio, + savedAudioCompletionRatio, + savedBillingMode, + savedBillingExpr, modelPrice, modelRatio, cacheRatio, @@ -279,120 +459,64 @@ const ModelRatioVisualEditorComponent = forwardRef< }, [columnVisibility]) const models = useMemo(() => { - const priceMap = safeJsonParse>(modelPrice, { - fallback: {}, - context: 'model prices', + const savedRows = buildModelSnapshots({ + modelPrice: savedModelPrice, + modelRatio: savedModelRatio, + cacheRatio: savedCacheRatio, + createCacheRatio: savedCreateCacheRatio, + completionRatio: savedCompletionRatio, + imageRatio: savedImageRatio, + audioRatio: savedAudioRatio, + audioCompletionRatio: savedAudioCompletionRatio, + billingMode: savedBillingMode, + billingExpr: savedBillingExpr, }) - const ratioMap = safeJsonParse>(modelRatio, { - fallback: {}, - context: 'model ratios', - }) - const cacheMap = safeJsonParse>(cacheRatio, { - fallback: {}, - context: 'cache ratios', - }) - const createCacheMap = safeJsonParse>( + const draftRows = buildModelSnapshots({ + modelPrice, + modelRatio, + cacheRatio, createCacheRatio, - { fallback: {}, context: 'create cache ratios' } - ) - const completionMap = safeJsonParse>( completionRatio, - { fallback: {}, context: 'completion ratios' } - ) - const imageMap = safeJsonParse>(imageRatio, { - fallback: {}, - context: 'image ratios', - }) - const audioMap = safeJsonParse>(audioRatio, { - fallback: {}, - context: 'audio ratios', - }) - const audioCompletionMap = safeJsonParse>( + imageRatio, + audioRatio, audioCompletionRatio, - { fallback: {}, context: 'audio completion ratios' } - ) - const billingModeMap = safeJsonParse>(billingMode, { - fallback: {}, - context: 'billing mode', - }) - const billingExprMap = safeJsonParse>(billingExpr, { - fallback: {}, - context: 'billing expression', + billingMode, + billingExpr, }) - 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 savedByName = new Map(savedRows.map((row) => [row.name, row])) + const draftByName = new Map(draftRows.map((row) => [row.name, row])) + const modelNames = new Set([...savedByName.keys(), ...draftByName.keys()]) - 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() || '' + return Array.from(modelNames) + .map((name) => { + const saved = savedByName.get(name) + const draft = draftByName.get(name) + const displayed = saved ?? draft + const savedSignature = getSnapshotSignature(saved) + const draftSignature = getSnapshotSignature(draft) - 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, + ...displayed!, + saved, + draft, + isDraftChanged: savedSignature !== draftSignature, + isDraftDeleted: Boolean(saved && !draft), + isDraftNew: Boolean(!saved && draft), } - } - - 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)) + }) + .sort((a, b) => a.name.localeCompare(b.name)) }, [ + savedModelPrice, + savedModelRatio, + savedCacheRatio, + savedCreateCacheRatio, + savedCompletionRatio, + savedImageRatio, + savedAudioRatio, + savedAudioCompletionRatio, + savedBillingMode, + savedBillingExpr, modelPrice, modelRatio, cacheRatio, @@ -428,24 +552,25 @@ const ModelRatioVisualEditorComponent = forwardRef< const handleEdit = useCallback( (model: ModelRow) => { + const editableModel = model.draft ?? model.saved ?? model 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, + name: editableModel.name, + price: editableModel.price, + ratio: editableModel.ratio, + cacheRatio: editableModel.cacheRatio, + createCacheRatio: editableModel.createCacheRatio, + completionRatio: editableModel.completionRatio, + imageRatio: editableModel.imageRatio, + audioRatio: editableModel.audioRatio, + audioCompletionRatio: editableModel.audioCompletionRatio, billingMode: - model.billingMode === 'tiered_expr' + editableModel.billingMode === 'tiered_expr' ? 'tiered_expr' - : model.price && model.price !== '' + : editableModel.price && editableModel.price !== '' ? 'per-request' : 'per-token', - billingExpr: model.billingExpr, - requestRuleExpr: model.requestRuleExpr, + billingExpr: editableModel.billingExpr, + requestRuleExpr: editableModel.requestRuleExpr, }) setEditorOpen(true) if (isMobile) setSheetOpen(true) @@ -459,12 +584,6 @@ const ModelRatioVisualEditorComponent = forwardRef< if (isMobile) setSheetOpen(true) }, [isMobile]) - const handleCancel = useCallback(() => { - setEditData(null) - setEditorOpen(false) - setSheetOpen(false) - }, []) - const handleGlobalFilterChange = useCallback>( (updater) => { setGlobalFilter((previous) => { @@ -604,6 +723,13 @@ const ModelRatioVisualEditorComponent = forwardRef< cell: ({ row }) => (
{row.getValue('name')} + {row.original.isDraftChanged && ( + + )} {row.original.billingMode === 'tiered_expr' && ( ), cell: ({ row }) => ( -
- - {getPriceSummary(row.original, t)} - - - {getPriceDetail(row.original, t)} - +
+
+ + {getPriceSummary(row.original, t)} + + + {getPriceDetail(row.original, t)} + +
+ {row.original.isDraftChanged && ( +
+
+ + {!row.original.isDraftDeleted && row.original.draft && ( + + )} + + {row.original.isDraftDeleted + ? t('Will be removed') + : getPriceSummary(row.original.draft ?? row.original, t)} + +
+ {!row.original.isDraftDeleted && row.original.draft && ( + + {getPriceDetail(row.original.draft, t)} + + )} +
+ )}
), sortingFn: (rowA, rowB) => @@ -849,18 +1007,6 @@ const ModelRatioVisualEditorComponent = forwardRef< ] ) - 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 handleBatchCopy = useCallback(() => { if (!editData) { toast.error(t('Open a source model first')) @@ -901,12 +1047,10 @@ const ModelRatioVisualEditorComponent = forwardRef< [editorOpen, persistPricingData] ) - const selectedTargetCount = table.getFilteredSelectedRowModel().rows.length - return (
-
-
+
+
) : ( -
- - +
+
+ {table.getHeaderGroups().map((headerGroup) => ( - + {headerGroup.headers.map((header) => ( - + ))} - + ))} - - + + {table.getRowModel().rows.map((row) => ( - { const target = event.target as HTMLElement @@ -983,17 +1131,20 @@ const ModelRatioVisualEditorComponent = forwardRef< }} > {row.getVisibleCells().map((cell) => ( - + ))} - + ))} - -
{header.isPlaceholder ? null : flexRender( header.column.columnDef.header, header.getContext() )} - +
{flexRender( cell.column.columnDef.cell, cell.getContext() )} - +
+ +
)} @@ -1002,18 +1153,15 @@ const ModelRatioVisualEditorComponent = forwardRef< )}
-
+
{editorOpen ? ( ) : ( -
+
{t('Select a model to edit pricing')}
@@ -1045,10 +1193,7 @@ const ModelRatioVisualEditorComponent = forwardRef< ref={editorPanelRef} open={sheetOpen} onOpenChange={setSheetOpen} - onSave={handleSave} - onCancel={handleCancel} editData={editData} - selectedTargetCount={selectedTargetCount} /> )}
diff --git a/web/default/src/features/system-settings/models/ratio-settings-card.tsx b/web/default/src/features/system-settings/models/ratio-settings-card.tsx index 7d72e404..6f9d9f58 100644 --- a/web/default/src/features/system-settings/models/ratio-settings-card.tsx +++ b/web/default/src/features/system-settings/models/ratio-settings-card.tsx @@ -250,6 +250,9 @@ export function RatioSettingsCard({ BillingMode: normalizeJsonString(modelDefaults.BillingMode), BillingExpr: normalizeJsonString(modelDefaults.BillingExpr), }) + const [savedModelValues, setSavedModelValues] = useState( + modelNormalizedDefaults.current + ) const groupNormalizedDefaults = useRef({ GroupRatio: normalizeJsonString(groupDefaults.GroupRatio), @@ -315,6 +318,7 @@ export function RatioSettingsCard({ BillingMode: normalizeJsonString(modelDefaults.BillingMode), BillingExpr: normalizeJsonString(modelDefaults.BillingExpr), } + setSavedModelValues(modelNormalizedDefaults.current) modelForm.reset({ ...modelDefaults, @@ -395,6 +399,9 @@ export function RatioSettingsCard({ const apiKey = apiKeyMap[key as string] || (key as string) await updateOption.mutateAsync({ key: apiKey, value: normalized[key] }) } + + modelNormalizedDefaults.current = normalized + setSavedModelValues(normalized) }, [t, updateOption] ) @@ -462,6 +469,7 @@ export function RatioSettingsCard({ return ( Date: Fri, 5 Jun 2026 00:06:41 +0800 Subject: [PATCH 05/10] refactor(model-pricing): split visual pricing editor modules - extract pricing form primitives, snapshot helpers, and table column setup to keep the editor components smaller. - remove draft comparison UI now that switching models discards unsaved edits. - refine the model list with a fixed actions column and tighter mode and price summary display. --- .../models/model-pricing-core.ts | 296 +++++++++++ .../models/model-pricing-inputs.tsx | 91 ++++ .../models/model-pricing-sheet.tsx | 357 +------------ .../models/model-pricing-snapshots.ts | 296 +++++++++++ .../models/model-ratio-table-columns.tsx | 160 ++++++ .../models/model-ratio-visual-editor.tsx | 476 ++---------------- web/default/src/i18n/locales/en.json | 2 - web/default/src/i18n/locales/fr.json | 2 - web/default/src/i18n/locales/ja.json | 2 - web/default/src/i18n/locales/ru.json | 2 - web/default/src/i18n/locales/vi.json | 2 - web/default/src/i18n/locales/zh.json | 2 - 12 files changed, 892 insertions(+), 796 deletions(-) create mode 100644 web/default/src/features/system-settings/models/model-pricing-core.ts create mode 100644 web/default/src/features/system-settings/models/model-pricing-inputs.tsx create mode 100644 web/default/src/features/system-settings/models/model-pricing-snapshots.ts create mode 100644 web/default/src/features/system-settings/models/model-ratio-table-columns.tsx diff --git a/web/default/src/features/system-settings/models/model-pricing-core.ts b/web/default/src/features/system-settings/models/model-pricing-core.ts new file mode 100644 index 00000000..4f5be4e7 --- /dev/null +++ b/web/default/src/features/system-settings/models/model-pricing-core.ts @@ -0,0 +1,296 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import * as z from 'zod' +import { combineBillingExpr } from '@/features/pricing/lib/billing-expr' +import { formatPricingNumber } from './pricing-format' + +export const createModelPricingSchema = (t: (key: string) => string) => + z.object({ + name: z.string().min(1, t('Model name is required')), + price: z.string().optional(), + ratio: z.string().optional(), + cacheRatio: z.string().optional(), + createCacheRatio: z.string().optional(), + completionRatio: z.string().optional(), + imageRatio: z.string().optional(), + audioRatio: z.string().optional(), + audioCompletionRatio: z.string().optional(), + }) + +export type ModelPricingFormValues = z.infer< + ReturnType +> + +export type PricingMode = 'per-token' | 'per-request' | 'tiered_expr' + +export type LaneKey = + | 'completion' + | 'cache' + | 'createCache' + | 'image' + | 'audioInput' + | 'audioOutput' + +export type ModelRatioData = { + name: string + price?: string + ratio?: string + cacheRatio?: string + createCacheRatio?: string + completionRatio?: string + imageRatio?: string + audioRatio?: string + audioCompletionRatio?: string + billingMode?: PricingMode + billingExpr?: string + requestRuleExpr?: string +} + +export type PreviewRow = { + key: string + label: string + value: string + multiline?: boolean +} + +export const numericDraftRegex = /^(\d+(\.\d*)?|\.\d*)?$/ + +export const EMPTY_LANE_PRICES: Record = { + completion: '', + cache: '', + createCache: '', + image: '', + audioInput: '', + audioOutput: '', +} + +export const EMPTY_LANE_ENABLED: Record = { + completion: false, + cache: false, + createCache: false, + image: false, + audioInput: false, + audioOutput: false, +} + +export const ratioFieldByLane: Record = { + completion: 'completionRatio', + cache: 'cacheRatio', + createCache: 'createCacheRatio', + image: 'imageRatio', + audioInput: 'audioRatio', + audioOutput: 'audioCompletionRatio', +} + +export const laneConfigs: Array<{ + key: LaneKey + titleKey: string + descriptionKey: string + placeholder: string +}> = [ + { + key: 'completion', + titleKey: 'Completion price', + descriptionKey: 'Output token price for generated tokens.', + placeholder: '15', + }, + { + key: 'cache', + titleKey: 'Cache read price', + descriptionKey: 'Token price for cache reads.', + placeholder: '0.3', + }, + { + key: 'createCache', + titleKey: 'Cache write price', + descriptionKey: 'Token price for creating cache entries.', + placeholder: '3.75', + }, + { + key: 'image', + titleKey: 'Image input price', + descriptionKey: 'Token price for image input.', + placeholder: '2.5', + }, + { + key: 'audioInput', + titleKey: 'Audio input price', + descriptionKey: 'Token price for audio input.', + placeholder: '3.81', + }, + { + key: 'audioOutput', + titleKey: 'Audio output price', + descriptionKey: 'Token price for audio output.', + placeholder: '15.11', + }, +] + +export function hasValue(value: unknown): boolean { + return ( + value !== '' && value !== null && value !== undefined && value !== false + ) +} + +export function toNumberOrNull(value: unknown): number | null { + if (!hasValue(value) && value !== 0) return null + const num = Number(value) + return Number.isFinite(num) ? num : null +} + +function ratioToBasePrice(ratio: unknown): string { + const num = toNumberOrNull(ratio) + if (num === null) return '' + return formatPricingNumber(num * 2) +} + +function deriveLanePrice( + ratio: unknown, + denominator: unknown, + fallback = '' +): string { + const ratioNumber = toNumberOrNull(ratio) + const denominatorNumber = toNumberOrNull(denominator) + if (ratioNumber === null || denominatorNumber === null) return fallback + return formatPricingNumber(ratioNumber * denominatorNumber) +} + +export function createInitialLaneState(data?: ModelRatioData | null) { + if (!data) { + return { + promptPrice: '', + prices: { ...EMPTY_LANE_PRICES }, + enabled: { ...EMPTY_LANE_ENABLED }, + } + } + + const promptPrice = ratioToBasePrice(data.ratio) + const audioInputPrice = deriveLanePrice(data.audioRatio, promptPrice) + const prices: Record = { + completion: deriveLanePrice(data.completionRatio, promptPrice), + cache: deriveLanePrice(data.cacheRatio, promptPrice), + createCache: deriveLanePrice(data.createCacheRatio, promptPrice), + image: deriveLanePrice(data.imageRatio, promptPrice), + audioInput: audioInputPrice, + audioOutput: deriveLanePrice(data.audioCompletionRatio, audioInputPrice), + } + + return { + promptPrice, + prices, + enabled: { + completion: hasValue(data.completionRatio), + cache: hasValue(data.cacheRatio), + createCache: hasValue(data.createCacheRatio), + image: hasValue(data.imageRatio), + audioInput: hasValue(data.audioRatio), + audioOutput: hasValue(data.audioCompletionRatio), + }, + } +} + +export function buildPreviewRows( + values: ModelPricingFormValues, + mode: PricingMode, + billingExpr: string, + requestRuleExpr: string, + promptPrice: string, + lanePrices: Record, + laneEnabled: Record, + t: (key: string) => string +): PreviewRow[] { + if (mode === 'tiered_expr') { + const effectiveExpr = combineBillingExpr(billingExpr, requestRuleExpr) + return [ + { key: 'mode', label: 'BillingMode', value: 'tiered_expr' }, + { + key: 'expr', + label: t('Expression'), + value: effectiveExpr || t('Empty'), + multiline: true, + }, + ] + } + + if (mode === 'per-request') { + return [ + { + key: 'price', + label: 'ModelPrice', + value: values.price || t('Empty'), + }, + ] + } + + return [ + { + key: 'inputPrice', + label: t('Input price'), + value: promptPrice ? `$${promptPrice}` : t('Empty'), + }, + { + key: 'completion', + label: t('Completion price'), + value: + laneEnabled.completion && lanePrices.completion + ? `$${lanePrices.completion}` + : t('Empty'), + }, + { + key: 'cache', + label: t('Cache read price'), + value: + laneEnabled.cache && lanePrices.cache + ? `$${lanePrices.cache}` + : t('Empty'), + }, + { + key: 'createCache', + label: t('Cache write price'), + value: + laneEnabled.createCache && lanePrices.createCache + ? `$${lanePrices.createCache}` + : t('Empty'), + }, + { + key: 'image', + label: t('Image input price'), + value: + laneEnabled.image && lanePrices.image + ? `$${lanePrices.image}` + : t('Empty'), + }, + { + key: 'audio', + label: t('Audio input price'), + value: + laneEnabled.audioInput && lanePrices.audioInput + ? `$${lanePrices.audioInput}` + : t('Empty'), + }, + { + key: 'audioCompletion', + label: t('Audio output price'), + value: + laneEnabled.audioOutput && lanePrices.audioOutput + ? `$${lanePrices.audioOutput}` + : t('Empty'), + }, + ] +} diff --git a/web/default/src/features/system-settings/models/model-pricing-inputs.tsx b/web/default/src/features/system-settings/models/model-pricing-inputs.tsx new file mode 100644 index 00000000..c79ef90f --- /dev/null +++ b/web/default/src/features/system-settings/models/model-pricing-inputs.tsx @@ -0,0 +1,91 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { useTranslation } from 'react-i18next' +import { cn } from '@/lib/utils' +import { + InputGroup, + InputGroupAddon, + InputGroupInput, +} from '@/components/ui/input-group' +import { + SettingsControlGroup, + SettingsSwitchField, +} from '../components/settings-form-layout' + +export function PriceInput(props: { + value: string + placeholder?: string + disabled?: boolean + onChange: (value: string) => void +}) { + return ( + + $ + props.onChange(event.target.value)} + /> + $/1M + + ) +} + +export function PriceLane(props: { + title: string + description: string + placeholder: string + value: string + enabled: boolean + disabled?: boolean + onEnabledChange: (checked: boolean) => void + onChange: (value: string) => void +}) { + const { t } = useTranslation() + const effectiveDisabled = props.disabled || !props.enabled + + return ( + + + +

+ {props.enabled + ? t('USD price per 1M tokens.') + : t('Disabled lanes are omitted on save.')} +

+
+ ) +} diff --git a/web/default/src/features/system-settings/models/model-pricing-sheet.tsx b/web/default/src/features/system-settings/models/model-pricing-sheet.tsx index ab0ac72e..0ed12a0a 100644 --- a/web/default/src/features/system-settings/models/model-pricing-sheet.tsx +++ b/web/default/src/features/system-settings/models/model-pricing-sheet.tsx @@ -24,7 +24,6 @@ import { useMemo, useState, } from 'react' -import * as z from 'zod' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { AlertTriangle } from 'lucide-react' @@ -61,54 +60,27 @@ import { } from '@/components/ui/sheet' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { sideDrawerContentClassName } from '@/components/drawer-layout' -import { combineBillingExpr } from '@/features/pricing/lib/billing-expr' import { - SettingsControlGroup, - SettingsSwitchField, -} from '../components/settings-form-layout' + EMPTY_LANE_ENABLED, + EMPTY_LANE_PRICES, + buildPreviewRows, + createInitialLaneState, + createModelPricingSchema, + hasValue, + laneConfigs, + numericDraftRegex, + ratioFieldByLane, + toNumberOrNull, + type LaneKey, + type ModelPricingFormValues, + type ModelRatioData, + type PricingMode, +} from './model-pricing-core' +import { PriceInput, PriceLane } from './model-pricing-inputs' import { formatPricingNumber } from './pricing-format' import { TieredPricingEditor } from './tiered-pricing-editor' -const createModelPricingSchema = (t: (key: string) => string) => - z.object({ - name: z.string().min(1, t('Model name is required')), - price: z.string().optional(), - ratio: z.string().optional(), - cacheRatio: z.string().optional(), - createCacheRatio: z.string().optional(), - completionRatio: z.string().optional(), - imageRatio: z.string().optional(), - audioRatio: z.string().optional(), - audioCompletionRatio: z.string().optional(), - }) - -type ModelPricingFormValues = z.infer< - ReturnType -> - -type PricingMode = 'per-token' | 'per-request' | 'tiered_expr' -type LaneKey = - | 'completion' - | 'cache' - | 'createCache' - | 'image' - | 'audioInput' - | 'audioOutput' - -export type ModelRatioData = { - name: string - price?: string - ratio?: string - cacheRatio?: string - createCacheRatio?: string - completionRatio?: string - imageRatio?: string - audioRatio?: string - audioCompletionRatio?: string - billingMode?: PricingMode - billingExpr?: string - requestRuleExpr?: string -} +export type { ModelRatioData } from './model-pricing-core' type ModelPricingSheetProps = { open: boolean @@ -127,239 +99,6 @@ export type ModelPricingEditorPanelHandle = { commitDraft: () => Promise } -type PreviewRow = { - key: string - label: string - value: string - multiline?: boolean -} - -const numericDraftRegex = /^(\d+(\.\d*)?|\.\d*)?$/ - -const EMPTY_LANE_PRICES: Record = { - completion: '', - cache: '', - createCache: '', - image: '', - audioInput: '', - audioOutput: '', -} - -const EMPTY_LANE_ENABLED: Record = { - completion: false, - cache: false, - createCache: false, - image: false, - audioInput: false, - audioOutput: false, -} - -const ratioFieldByLane: Record = { - completion: 'completionRatio', - cache: 'cacheRatio', - createCache: 'createCacheRatio', - image: 'imageRatio', - audioInput: 'audioRatio', - audioOutput: 'audioCompletionRatio', -} - -const laneConfigs: Array<{ - key: LaneKey - titleKey: string - descriptionKey: string - placeholder: string -}> = [ - { - key: 'completion', - titleKey: 'Completion price', - descriptionKey: 'Output token price for generated tokens.', - placeholder: '15', - }, - { - key: 'cache', - titleKey: 'Cache read price', - descriptionKey: 'Token price for cache reads.', - placeholder: '0.3', - }, - { - key: 'createCache', - titleKey: 'Cache write price', - descriptionKey: 'Token price for creating cache entries.', - placeholder: '3.75', - }, - { - key: 'image', - titleKey: 'Image input price', - descriptionKey: 'Token price for image input.', - placeholder: '2.5', - }, - { - key: 'audioInput', - titleKey: 'Audio input price', - descriptionKey: 'Token price for audio input.', - placeholder: '3.81', - }, - { - key: 'audioOutput', - titleKey: 'Audio output price', - descriptionKey: 'Token price for audio output.', - placeholder: '15.11', - }, -] - -function hasValue(value: unknown): boolean { - return ( - value !== '' && value !== null && value !== undefined && value !== false - ) -} - -function toNumberOrNull(value: unknown): number | null { - if (!hasValue(value) && value !== 0) return null - const num = Number(value) - return Number.isFinite(num) ? num : null -} - -function ratioToBasePrice(ratio: unknown): string { - const num = toNumberOrNull(ratio) - if (num === null) return '' - return formatPricingNumber(num * 2) -} - -function deriveLanePrice( - ratio: unknown, - denominator: unknown, - fallback = '' -): string { - const ratioNumber = toNumberOrNull(ratio) - const denominatorNumber = toNumberOrNull(denominator) - if (ratioNumber === null || denominatorNumber === null) return fallback - return formatPricingNumber(ratioNumber * denominatorNumber) -} - -function createInitialLaneState(data?: ModelRatioData | null) { - if (!data) { - return { - promptPrice: '', - prices: { ...EMPTY_LANE_PRICES }, - enabled: { ...EMPTY_LANE_ENABLED }, - } - } - - const promptPrice = ratioToBasePrice(data.ratio) - const audioInputPrice = deriveLanePrice(data.audioRatio, promptPrice) - const prices: Record = { - completion: deriveLanePrice(data.completionRatio, promptPrice), - cache: deriveLanePrice(data.cacheRatio, promptPrice), - createCache: deriveLanePrice(data.createCacheRatio, promptPrice), - image: deriveLanePrice(data.imageRatio, promptPrice), - audioInput: audioInputPrice, - audioOutput: deriveLanePrice(data.audioCompletionRatio, audioInputPrice), - } - - return { - promptPrice, - prices, - enabled: { - completion: hasValue(data.completionRatio), - cache: hasValue(data.cacheRatio), - createCache: hasValue(data.createCacheRatio), - image: hasValue(data.imageRatio), - audioInput: hasValue(data.audioRatio), - audioOutput: hasValue(data.audioCompletionRatio), - }, - } -} - -function buildPreviewRows( - values: ModelPricingFormValues, - mode: PricingMode, - billingExpr: string, - requestRuleExpr: string, - promptPrice: string, - lanePrices: Record, - laneEnabled: Record, - t: (key: string) => string -): PreviewRow[] { - if (mode === 'tiered_expr') { - const effectiveExpr = combineBillingExpr(billingExpr, requestRuleExpr) - return [ - { key: 'mode', label: 'BillingMode', value: 'tiered_expr' }, - { - key: 'expr', - label: t('Expression'), - value: effectiveExpr || t('Empty'), - multiline: true, - }, - ] - } - - if (mode === 'per-request') { - return [ - { - key: 'price', - label: 'ModelPrice', - value: values.price || t('Empty'), - }, - ] - } - - return [ - { - key: 'inputPrice', - label: t('Input price'), - value: promptPrice ? `$${promptPrice}` : t('Empty'), - }, - { - key: 'completion', - label: t('Completion price'), - value: - laneEnabled.completion && lanePrices.completion - ? `$${lanePrices.completion}` - : t('Empty'), - }, - { - key: 'cache', - label: t('Cache read price'), - value: - laneEnabled.cache && lanePrices.cache - ? `$${lanePrices.cache}` - : t('Empty'), - }, - { - key: 'createCache', - label: t('Cache write price'), - value: - laneEnabled.createCache && lanePrices.createCache - ? `$${lanePrices.createCache}` - : t('Empty'), - }, - { - key: 'image', - label: t('Image input price'), - value: - laneEnabled.image && lanePrices.image - ? `$${lanePrices.image}` - : t('Empty'), - }, - { - key: 'audio', - label: t('Audio input price'), - value: - laneEnabled.audioInput && lanePrices.audioInput - ? `$${lanePrices.audioInput}` - : t('Empty'), - }, - { - key: 'audioCompletion', - label: t('Audio output price'), - value: - laneEnabled.audioOutput && lanePrices.audioOutput - ? `$${lanePrices.audioOutput}` - : t('Empty'), - }, - ] -} - export const ModelPricingSheet = forwardRef< ModelPricingEditorPanelHandle, ModelPricingSheetProps @@ -936,65 +675,3 @@ export const ModelPricingEditorPanel = forwardRef<
) }) - -function PriceInput(props: { - value: string - placeholder?: string - disabled?: boolean - onChange: (value: string) => void -}) { - return ( - - $ - props.onChange(event.target.value)} - /> - $/1M - - ) -} - -function PriceLane(props: { - title: string - description: string - placeholder: string - value: string - enabled: boolean - disabled?: boolean - onEnabledChange: (checked: boolean) => void - onChange: (value: string) => void -}) { - const { t } = useTranslation() - const effectiveDisabled = props.disabled || !props.enabled - - return ( - - - -

- {props.enabled - ? t('USD price per 1M tokens.') - : t('Disabled lanes are omitted on save.')} -

-
- ) -} diff --git a/web/default/src/features/system-settings/models/model-pricing-snapshots.ts b/web/default/src/features/system-settings/models/model-pricing-snapshots.ts new file mode 100644 index 00000000..4668f278 --- /dev/null +++ b/web/default/src/features/system-settings/models/model-pricing-snapshots.ts @@ -0,0 +1,296 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { splitBillingExprAndRequestRules } from '@/features/pricing/lib/billing-expr' +import { safeJsonParse } from '../utils/json-parser' +import { formatPricingNumber } from './pricing-format' + +export type ModelPricingSnapshotInput = { + modelPrice: string + modelRatio: string + cacheRatio: string + createCacheRatio: string + completionRatio: string + imageRatio: string + audioRatio: string + audioCompletionRatio: string + billingMode: string + billingExpr: string +} + +export type ModelPricingSnapshot = { + name: string + price?: string + ratio?: string + cacheRatio?: string + createCacheRatio?: string + completionRatio?: string + imageRatio?: string + audioRatio?: string + audioCompletionRatio?: string + billingMode?: string + billingExpr?: string + requestRuleExpr?: string + hasConflict: boolean +} + +export type ModelRow = ModelPricingSnapshot & { + saved?: ModelPricingSnapshot + draft?: ModelPricingSnapshot + isDraftChanged: boolean + isDraftDeleted: boolean + isDraftNew: boolean +} + +export const hasPricingValue = (value?: string) => + value !== undefined && value !== '' + +const toNumberOrNull = (value?: string) => { + if (!hasPricingValue(value)) return null + const num = Number(value) + return Number.isFinite(num) ? num : null +} + +const ratioToPrice = (ratio?: string, denominator?: string) => { + const ratioNumber = toNumberOrNull(ratio) + const denominatorNumber = denominator ? toNumberOrNull(denominator) : 2 + if (ratioNumber === null || denominatorNumber === null) return '' + return formatPricingNumber(ratioNumber * denominatorNumber) +} + +export const getModeLabel = (mode?: string) => { + if (mode === 'per-request') return 'Per-request' + if (mode === 'tiered_expr') return 'Expression' + return 'Per-token' +} + +export const getModeVariant = ( + mode?: string +): 'warning' | 'info' | 'success' => { + if (mode === 'per-request') return 'warning' + if (mode === 'tiered_expr') return 'info' + return 'success' +} + +const getExpressionSummary = ( + row: ModelPricingSnapshot, + t: (key: string) => string +) => { + const tierCount = (row.billingExpr?.match(/tier\(/g) || []).length + if (tierCount > 0) { + return `${t('Tiered pricing')} · ${tierCount} ${t('tiers')}` + } + return t('Expression pricing') +} + +export const getPriceSummary = ( + row: ModelPricingSnapshot, + t: (key: string) => string +) => { + if (row.billingMode === 'tiered_expr') { + return getExpressionSummary(row, t) + } + if (row.billingMode === 'per-request') { + return row.price ? `$${row.price} / ${t('request')}` : t('Unset price') + } + + const inputPrice = ratioToPrice(row.ratio) + if (!inputPrice) return t('Unset price') + + const extraCount = [ + row.completionRatio, + row.cacheRatio, + row.createCacheRatio, + row.imageRatio, + row.audioRatio, + row.audioCompletionRatio, + ].filter(hasPricingValue).length + + return extraCount > 0 + ? `${t('Input')} $${inputPrice} · ${extraCount} ${t('extras')}` + : `${t('Input')} $${inputPrice}` +} + +export const getPriceDetail = ( + row: ModelPricingSnapshot, + t: (key: string) => string +) => { + if (row.billingMode === 'tiered_expr') { + return row.requestRuleExpr + ? t('Includes request rules') + : t('Expression based') + } + if (row.billingMode === 'per-request') { + return t('Fixed request price') + } + + const inputPrice = ratioToPrice(row.ratio) + if (!inputPrice) return t('No base input price') + + const details = [ + row.completionRatio && + `${t('Output')} $${ratioToPrice(row.completionRatio, inputPrice)}`, + row.cacheRatio && + `${t('Cache')} $${ratioToPrice(row.cacheRatio, inputPrice)}`, + row.createCacheRatio && + `${t('Cache write')} $${ratioToPrice(row.createCacheRatio, inputPrice)}`, + ] + .filter(Boolean) + .slice(0, 2) + + return details.length > 0 ? details.join(' · ') : t('Base input price only') +} + +export const buildModelSnapshots = ({ + modelPrice, + modelRatio, + cacheRatio, + createCacheRatio, + completionRatio, + imageRatio, + audioRatio, + audioCompletionRatio, + billingMode, + billingExpr, +}: ModelPricingSnapshotInput): ModelPricingSnapshot[] => { + const priceMap = safeJsonParse>(modelPrice, { + fallback: {}, + context: 'model prices', + }) + const ratioMap = safeJsonParse>(modelRatio, { + fallback: {}, + context: 'model ratios', + }) + const cacheMap = safeJsonParse>(cacheRatio, { + fallback: {}, + context: 'cache ratios', + }) + const createCacheMap = safeJsonParse>( + createCacheRatio, + { fallback: {}, context: 'create cache ratios' } + ) + const completionMap = safeJsonParse>(completionRatio, { + fallback: {}, + context: 'completion ratios', + }) + const imageMap = safeJsonParse>(imageRatio, { + fallback: {}, + context: 'image ratios', + }) + const audioMap = safeJsonParse>(audioRatio, { + fallback: {}, + context: 'audio ratios', + }) + const audioCompletionMap = safeJsonParse>( + audioCompletionRatio, + { fallback: {}, context: 'audio completion ratios' } + ) + const billingModeMap = safeJsonParse>(billingMode, { + fallback: {}, + context: 'billing mode', + }) + const billingExprMap = safeJsonParse>(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), + ]) + + return 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') { + 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 !== ''), + } + }) +} + +export const getSnapshotSignature = (snapshot?: ModelPricingSnapshot) => { + if (!snapshot) return '' + return JSON.stringify({ + price: snapshot.price || '', + ratio: snapshot.ratio || '', + cacheRatio: snapshot.cacheRatio || '', + createCacheRatio: snapshot.createCacheRatio || '', + completionRatio: snapshot.completionRatio || '', + imageRatio: snapshot.imageRatio || '', + audioRatio: snapshot.audioRatio || '', + audioCompletionRatio: snapshot.audioCompletionRatio || '', + billingMode: snapshot.billingMode || 'per-token', + billingExpr: snapshot.billingExpr || '', + requestRuleExpr: snapshot.requestRuleExpr || '', + }) +} diff --git a/web/default/src/features/system-settings/models/model-ratio-table-columns.tsx b/web/default/src/features/system-settings/models/model-ratio-table-columns.tsx new file mode 100644 index 00000000..25581019 --- /dev/null +++ b/web/default/src/features/system-settings/models/model-ratio-table-columns.tsx @@ -0,0 +1,160 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { type ColumnDef } from '@tanstack/react-table' +import { Pencil, Trash2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { DataTableColumnHeader } from '@/components/data-table' +import { StatusBadge } from '@/components/status-badge' +import { + getModeLabel, + getModeVariant, + getPriceDetail, + getPriceSummary, + type ModelRow, +} from './model-pricing-snapshots' + +const filterBySelectedValues = ( + rowValue: unknown, + filterValue: unknown +): boolean => { + if (!Array.isArray(filterValue) || filterValue.length === 0) return true + return filterValue.includes(String(rowValue)) +} + +type BuildModelRatioColumnsOptions = { + onDelete: (name: string) => void + onEdit: (model: ModelRow) => void + t: (key: string) => string +} + +export function buildModelRatioColumns({ + onDelete, + onEdit, + t, +}: BuildModelRatioColumnsOptions): ColumnDef[] { + return [ + { + id: 'select', + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label={t('Select all')} + className='translate-y-[2px]' + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label={t('Select row')} + className='translate-y-[2px]' + /> + ), + enableSorting: false, + enableHiding: false, + meta: { label: t('Select') }, + }, + { + accessorKey: 'name', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ {row.getValue('name')} + {row.original.billingMode === 'tiered_expr' && ( + + )} + {row.original.hasConflict && ( + + )} +
+ ), + enableHiding: false, + }, + { + accessorKey: 'billingMode', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + ), + filterFn: (row, id, value) => + filterBySelectedValues(row.getValue(id), value), + meta: { label: t('Mode') }, + }, + { + id: 'priceSummary', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ + {getPriceSummary(row.original, t)} + + + {getPriceDetail(row.original, t)} + +
+ ), + sortingFn: (rowA, rowB) => + getPriceSummary(rowA.original, t).localeCompare( + getPriceSummary(rowB.original, t) + ), + meta: { label: t('Price summary') }, + }, + { + id: 'actions', + cell: ({ row }) => ( +
+ + +
+ ), + enableHiding: false, + }, + ] +} diff --git a/web/default/src/features/system-settings/models/model-ratio-visual-editor.tsx b/web/default/src/features/system-settings/models/model-ratio-visual-editor.tsx index e9714937..0b7d8c71 100644 --- a/web/default/src/features/system-settings/models/model-ratio-visual-editor.tsx +++ b/web/default/src/features/system-settings/models/model-ratio-visual-editor.tsx @@ -27,7 +27,6 @@ import { useRef, } from 'react' import { - type ColumnDef, type ColumnFiltersState, type OnChangeFn, type PaginationState, @@ -44,22 +43,17 @@ import { useReactTable, } from '@tanstack/react-table' import { useMediaQuery } from '@/hooks' -import { Copy, Pencil, Plus, Trash2 } from 'lucide-react' +import { Copy, Plus } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' +import { cn } from '@/lib/utils' import { Button } from '@/components/ui/button' -import { Checkbox } from '@/components/ui/checkbox' import { DataTableBulkActions, - DataTableColumnHeader, DataTableToolbar, DataTablePagination, } from '@/components/data-table' -import { StatusBadge } from '@/components/status-badge' -import { - combineBillingExpr, - splitBillingExprAndRequestRules, -} from '@/features/pricing/lib/billing-expr' +import { combineBillingExpr } from '@/features/pricing/lib/billing-expr' import { safeJsonParse } from '../utils/json-parser' import { ModelPricingEditorPanel, @@ -67,7 +61,12 @@ import { ModelPricingSheet, type ModelRatioData, } from './model-pricing-sheet' -import { formatPricingNumber } from './pricing-format' +import { + buildModelSnapshots, + getSnapshotSignature, + type ModelRow, +} from './model-pricing-snapshots' +import { buildModelRatioColumns } from './model-ratio-table-columns' type ModelRatioVisualEditorProps = { savedModelPrice: string @@ -93,289 +92,12 @@ type ModelRatioVisualEditorProps = { onChange: (field: string, value: string) => void } -type ModelPricingSnapshot = { - name: string - price?: string - ratio?: string - cacheRatio?: string - createCacheRatio?: string - completionRatio?: string - imageRatio?: string - audioRatio?: string - audioCompletionRatio?: string - billingMode?: string - billingExpr?: string - requestRuleExpr?: string - hasConflict: boolean -} - -type ModelRow = ModelPricingSnapshot & { - saved?: ModelPricingSnapshot - draft?: ModelPricingSnapshot - isDraftChanged: boolean - isDraftDeleted: boolean - isDraftNew: boolean -} - export type ModelRatioVisualEditorHandle = { commitOpenEditor: () => Promise } const STORAGE_KEY = 'model-ratio-column-visibility' -const hasValue = (value?: string) => value !== undefined && value !== '' - -const toNumberOrNull = (value?: string) => { - if (!hasValue(value)) return null - const num = Number(value) - return Number.isFinite(num) ? num : null -} - -const ratioToPrice = (ratio?: string, denominator?: string) => { - const ratioNumber = toNumberOrNull(ratio) - const denominatorNumber = denominator ? toNumberOrNull(denominator) : 2 - if (ratioNumber === null || denominatorNumber === null) return '' - return formatPricingNumber(ratioNumber * denominatorNumber) -} - -const filterBySelectedValues = ( - rowValue: unknown, - filterValue: unknown -): boolean => { - if (!Array.isArray(filterValue) || filterValue.length === 0) return true - return filterValue.includes(String(rowValue)) -} - -const getModeLabel = (mode?: string) => { - if (mode === 'per-request') return 'Per-request' - if (mode === 'tiered_expr') return 'Expression' - return 'Per-token' -} - -const getModeVariant = (mode?: string): 'warning' | 'info' | 'success' => { - if (mode === 'per-request') return 'warning' - if (mode === 'tiered_expr') return 'info' - return 'success' -} - -const getExpressionSummary = ( - row: ModelPricingSnapshot, - t: (key: string) => string -) => { - const tierCount = (row.billingExpr?.match(/tier\(/g) || []).length - if (tierCount > 0) { - return `${t('Tiered pricing')} · ${tierCount} ${t('tiers')}` - } - return t('Expression pricing') -} - -const getPriceSummary = ( - row: ModelPricingSnapshot, - t: (key: string) => string -) => { - if (row.billingMode === 'tiered_expr') { - return getExpressionSummary(row, t) - } - if (row.billingMode === 'per-request') { - return row.price ? `$${row.price} / ${t('request')}` : t('Unset price') - } - - const inputPrice = ratioToPrice(row.ratio) - if (!inputPrice) return t('Unset price') - - const extraCount = [ - row.completionRatio, - row.cacheRatio, - row.createCacheRatio, - row.imageRatio, - row.audioRatio, - row.audioCompletionRatio, - ].filter(hasValue).length - - return extraCount > 0 - ? `${t('Input')} $${inputPrice} · ${extraCount} ${t('extras')}` - : `${t('Input')} $${inputPrice}` -} - -const getPriceDetail = ( - row: ModelPricingSnapshot, - t: (key: string) => string -) => { - if (row.billingMode === 'tiered_expr') { - return row.requestRuleExpr - ? t('Includes request rules') - : t('Expression based') - } - if (row.billingMode === 'per-request') { - return t('Fixed request price') - } - - const inputPrice = ratioToPrice(row.ratio) - if (!inputPrice) return t('No base input price') - - const details = [ - row.completionRatio && - `${t('Output')} $${ratioToPrice(row.completionRatio, inputPrice)}`, - row.cacheRatio && - `${t('Cache')} $${ratioToPrice(row.cacheRatio, inputPrice)}`, - row.createCacheRatio && - `${t('Cache write')} $${ratioToPrice(row.createCacheRatio, inputPrice)}`, - ].filter(Boolean) - - return details.length > 0 ? details.join(' · ') : t('Base input price only') -} - -const buildModelSnapshots = ({ - modelPrice, - modelRatio, - cacheRatio, - createCacheRatio, - completionRatio, - imageRatio, - audioRatio, - audioCompletionRatio, - billingMode, - billingExpr, -}: Pick< - ModelRatioVisualEditorProps, - | 'modelPrice' - | 'modelRatio' - | 'cacheRatio' - | 'createCacheRatio' - | 'completionRatio' - | 'imageRatio' - | 'audioRatio' - | 'audioCompletionRatio' - | 'billingMode' - | 'billingExpr' ->): ModelPricingSnapshot[] => { - const priceMap = safeJsonParse>(modelPrice, { - fallback: {}, - context: 'model prices', - }) - const ratioMap = safeJsonParse>(modelRatio, { - fallback: {}, - context: 'model ratios', - }) - const cacheMap = safeJsonParse>(cacheRatio, { - fallback: {}, - context: 'cache ratios', - }) - const createCacheMap = safeJsonParse>( - createCacheRatio, - { fallback: {}, context: 'create cache ratios' } - ) - const completionMap = safeJsonParse>(completionRatio, { - fallback: {}, - context: 'completion ratios', - }) - const imageMap = safeJsonParse>(imageRatio, { - fallback: {}, - context: 'image ratios', - }) - const audioMap = safeJsonParse>(audioRatio, { - fallback: {}, - context: 'audio ratios', - }) - const audioCompletionMap = safeJsonParse>( - audioCompletionRatio, - { fallback: {}, context: 'audio completion ratios' } - ) - const billingModeMap = safeJsonParse>(billingMode, { - fallback: {}, - context: 'billing mode', - }) - const billingExprMap = safeJsonParse>(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), - ]) - - return 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') { - 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 !== ''), - } - }) -} - -const getSnapshotSignature = (snapshot?: ModelPricingSnapshot) => { - if (!snapshot) return '' - return JSON.stringify({ - price: snapshot.price || '', - ratio: snapshot.ratio || '', - cacheRatio: snapshot.cacheRatio || '', - createCacheRatio: snapshot.createCacheRatio || '', - completionRatio: snapshot.completionRatio || '', - imageRatio: snapshot.imageRatio || '', - audioRatio: snapshot.audioRatio || '', - audioCompletionRatio: snapshot.audioCompletionRatio || '', - billingMode: snapshot.billingMode || 'per-token', - billingExpr: snapshot.billingExpr || '', - requestRuleExpr: snapshot.requestRuleExpr || '', - }) -} - const ModelRatioVisualEditorComponent = forwardRef< ModelRatioVisualEditorHandle, ModelRatioVisualEditorProps @@ -688,159 +410,15 @@ const ModelRatioVisualEditorComponent = forwardRef< ] ) - const columns = useMemo[]>(() => { - return [ - { - id: 'select', - header: ({ table }) => ( - - table.toggleAllPageRowsSelected(!!value) - } - aria-label={t('Select all')} - className='translate-y-[2px]' - /> - ), - cell: ({ row }) => ( - row.toggleSelected(!!value)} - aria-label={t('Select row')} - className='translate-y-[2px]' - /> - ), - enableSorting: false, - enableHiding: false, - meta: { label: t('Select') }, - }, - { - accessorKey: 'name', - header: ({ column }) => ( - - ), - cell: ({ row }) => ( -
- {row.getValue('name')} - {row.original.isDraftChanged && ( - - )} - {row.original.billingMode === 'tiered_expr' && ( - - )} - {row.original.hasConflict && ( - - )} -
- ), - enableHiding: false, - }, - { - accessorKey: 'billingMode', - header: ({ column }) => ( - - ), - cell: ({ row }) => ( - - ), - filterFn: (row, id, value) => - filterBySelectedValues(row.getValue(id), value), - meta: { label: t('Mode') }, - }, - { - id: 'priceSummary', - header: ({ column }) => ( - - ), - cell: ({ row }) => ( -
-
- - {getPriceSummary(row.original, t)} - - - {getPriceDetail(row.original, t)} - -
- {row.original.isDraftChanged && ( -
-
- - {!row.original.isDraftDeleted && row.original.draft && ( - - )} - - {row.original.isDraftDeleted - ? t('Will be removed') - : getPriceSummary(row.original.draft ?? row.original, t)} - -
- {!row.original.isDraftDeleted && row.original.draft && ( - - {getPriceDetail(row.original.draft, t)} - - )} -
- )} -
- ), - sortingFn: (rowA, rowB) => - getPriceSummary(rowA.original, t).localeCompare( - getPriceSummary(rowB.original, t) - ), - meta: { label: t('Price summary') }, - }, - { - id: 'actions', - cell: ({ row }) => ( -
- - -
- ), - enableHiding: false, - }, - ] - }, [handleEdit, handleDelete, t]) + const columns = useMemo( + () => + buildModelRatioColumns({ + onDelete: handleDelete, + onEdit: handleEdit, + t, + }), + [handleEdit, handleDelete, t] + ) const table = useReactTable({ data: models, @@ -1101,7 +679,11 @@ const ModelRatioVisualEditorComponent = forwardRef< {header.isPlaceholder ? null @@ -1121,8 +703,8 @@ const ModelRatioVisualEditorComponent = forwardRef< data-state={row.getIsSelected() ? 'selected' : undefined} className={ editData?.name === row.original.name - ? 'bg-muted/45 hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors' - : 'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors' + ? 'bg-muted/45 hover:bg-muted/50 data-[state=selected]:bg-muted group border-b transition-colors' + : 'hover:bg-muted/50 data-[state=selected]:bg-muted group border-b transition-colors' } onClick={(event) => { const target = event.target as HTMLElement @@ -1133,7 +715,13 @@ const ModelRatioVisualEditorComponent = forwardRef< {row.getVisibleCells().map((cell) => ( {flexRender( cell.column.columnDef.cell, diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index e49c1f70..7abaed07 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -1253,7 +1253,6 @@ "DoubaoVideo": "DoubaoVideo", "Double check the configuration below. Your system will be locked until initialization is complete.": "Double check the configuration below. Your system will be locked until initialization is complete.", "Download": "Download", - "Draft": "Draft", "Draw": "Draw", "Drawing": "Drawing", "Drawing logs": "Drawing logs", @@ -4415,7 +4414,6 @@ "We could not load the setup status.": "We could not load the setup status.", "We will prompt your device to confirm using biometrics or your hardware key.": "We will prompt your device to confirm using biometrics or your hardware key.", "We'll be back online shortly.": "We'll be back online shortly.", - "Will be removed": "Will be removed", "Web search": "Web search", "Web Search": "Web Search", "Webhook Configuration:": "Webhook Configuration:", diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json index 2250d659..27e3ea49 100644 --- a/web/default/src/i18n/locales/fr.json +++ b/web/default/src/i18n/locales/fr.json @@ -1253,7 +1253,6 @@ "DoubaoVideo": "DoubaoVideo", "Double check the configuration below. Your system will be locked until initialization is complete.": "Vérifiez la configuration ci-dessous. Votre système sera verrouillé jusqu'à ce que l'initialisation soit terminée.", "Download": "Télécharger", - "Draft": "Brouillon", "Draw": "Dessin", "Drawing": "Dessin", "Drawing logs": "Journaux de dessin", @@ -4415,7 +4414,6 @@ "We could not load the setup status.": "Nous n'avons pas pu charger l'état de la configuration.", "We will prompt your device to confirm using biometrics or your hardware key.": "Nous allons demander à votre appareil de confirmer en utilisant la biométrie ou votre clé matérielle.", "We'll be back online shortly.": "Nous serons de retour en ligne sous peu.", - "Will be removed": "Sera supprimé", "Web search": "Recherche web", "Web Search": "Recherche web", "Webhook Configuration:": "Configuration du Webhook :", diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index c11afb53..26ed4174 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -1253,7 +1253,6 @@ "DoubaoVideo": "DoubaoVideo", "Double check the configuration below. Your system will be locked until initialization is complete.": "下記の設定を再確認してください。初期化が完了するまでシステムはロックされます。", "Download": "ダウンロード", - "Draft": "下書き", "Draw": "描画", "Drawing": "画像生成", "Drawing logs": "描画ログ", @@ -4415,7 +4414,6 @@ "We could not load the setup status.": "セットアップステータスを読み込めませんでした。", "We will prompt your device to confirm using biometrics or your hardware key.": "生体認証またはハードウェアキーを使用して確認するよう、デバイスにプロンプトが表示されます。", "We'll be back online shortly.": "まもなくオンラインに戻ります。", - "Will be removed": "削除予定", "Web search": "ウェブ検索", "Web Search": "Web 検索", "Webhook Configuration:": "Webhook設定:", diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index 370f38b9..50382fd0 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -1253,7 +1253,6 @@ "DoubaoVideo": "DoubaoVideo", "Double check the configuration below. Your system will be locked until initialization is complete.": "Дважды проверьте конфигурацию ниже. Ваша система будет заблокирована до завершения инициализации.", "Download": "Скачать", - "Draft": "Черновик", "Draw": "Рисование", "Drawing": "Рисование", "Drawing logs": "Журналы рисования", @@ -4415,7 +4414,6 @@ "We could not load the setup status.": "Не удалось загрузить статус настройки.", "We will prompt your device to confirm using biometrics or your hardware key.": "Мы предложим вашему устройству подтвердить действие с помощью биометрии или аппаратного ключа.", "We'll be back online shortly.": "Мы скоро вернемся в сеть.", - "Will be removed": "Будет удалено", "Web search": "Веб-поиск", "Web Search": "Веб-поиск", "Webhook Configuration:": "Конфигурация веб-хука:", diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index e9c0e2d8..a28b90fe 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -1253,7 +1253,6 @@ "DoubaoVideo": "DoubaoVideo", "Double check the configuration below. Your system will be locked until initialization is complete.": "Kiểm tra kỹ lại cấu hình bên dưới. Hệ thống của bạn sẽ bị khóa cho đến khi quá trình khởi tạo hoàn tất.", "Download": "Tải xuống", - "Draft": "Bản nháp", "Draw": "Vẽ", "Drawing": "Vẽ", "Drawing logs": "Nhật ký vẽ", @@ -4415,7 +4414,6 @@ "We could not load the setup status.": "Chúng tôi không thể tải trạng thái thiết lập.", "We will prompt your device to confirm using biometrics or your hardware key.": "Chúng tôi sẽ yêu cầu thiết bị của bạn xác nhận bằng cách sử dụng sinh trắc học hoặc khóa bảo mật phần cứng của bạn.", "We'll be back online shortly.": "Chúng tôi sẽ sớm trực tuyến trở lại.", - "Will be removed": "Sẽ bị xóa", "Web search": "Tìm kiếm web", "Web Search": "Tìm kiếm web", "Webhook Configuration:": "Cấu hình Webhook:", diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index e213ae14..1ce821f5 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -1253,7 +1253,6 @@ "DoubaoVideo": "DoubaoVideo", "Double check the configuration below. Your system will be locked until initialization is complete.": "仔细检查以下配置。您的系统将在初始化完成前保持锁定状态。", "Download": "下载", - "Draft": "草稿", "Draw": "绘图", "Drawing": "绘图", "Drawing logs": "绘制日志", @@ -4415,7 +4414,6 @@ "We could not load the setup status.": "我们无法加载设置状态。", "We will prompt your device to confirm using biometrics or your hardware key.": "我们将提示您的设备使用生物识别或硬件密钥进行确认。", "We'll be back online shortly.": "我们将很快恢复在线。", - "Will be removed": "将被移除", "Web search": "网络搜索", "Web Search": "网页搜索", "Webhook Configuration:": "Webhook 配置:", From 5681c92b3fae0f27900f29cd58fa230c4f51ca29 Mon Sep 17 00:00:00 2001 From: QuentinHsu Date: Fri, 5 Jun 2026 01:04:47 +0800 Subject: [PATCH 06/10] perf(model-pricing): refine visual editor actions - keep the global reset action in the top toolbar while moving visual-mode saves into the model editor footer. - pin the actions header with the rest of the model table headers so horizontal scrolling keeps context visible. - add action icons to make save and reset controls easier to scan. --- .../models/model-pricing-sheet.tsx | 37 +++++++++++++++++-- .../models/model-ratio-form.tsx | 24 +++++++----- .../models/model-ratio-table-columns.tsx | 1 + .../models/model-ratio-visual-editor.tsx | 18 +++++++-- 4 files changed, 63 insertions(+), 17 deletions(-) diff --git a/web/default/src/features/system-settings/models/model-pricing-sheet.tsx b/web/default/src/features/system-settings/models/model-pricing-sheet.tsx index 0ed12a0a..0ff897c4 100644 --- a/web/default/src/features/system-settings/models/model-pricing-sheet.tsx +++ b/web/default/src/features/system-settings/models/model-pricing-sheet.tsx @@ -26,10 +26,11 @@ import { } from 'react' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' -import { AlertTriangle } from 'lucide-react' +import { AlertTriangle, Save } from 'lucide-react' import { useTranslation } from 'react-i18next' import { cn } from '@/lib/utils' import { Alert, AlertDescription } from '@/components/ui/alert' +import { Button } from '@/components/ui/button' import { Field, FieldDescription, @@ -86,6 +87,8 @@ type ModelPricingSheetProps = { open: boolean onOpenChange: (open: boolean) => void editData?: ModelRatioData | null + onSave?: () => void | Promise + isSaving?: boolean } type ModelPricingEditorPanelProps = Omit< @@ -102,7 +105,10 @@ export type ModelPricingEditorPanelHandle = { export const ModelPricingSheet = forwardRef< ModelPricingEditorPanelHandle, ModelPricingSheetProps ->(function ModelPricingSheet({ open, onOpenChange, editData }, ref) { +>(function ModelPricingSheet( + { open, onOpenChange, editData, onSave, isSaving }, + ref +) { const { t } = useTranslation() const title = editData ? t('Edit model pricing') : t('Add model pricing') const description = editData?.name || t('New model') @@ -120,6 +126,8 @@ export const ModelPricingSheet = forwardRef< @@ -130,7 +138,10 @@ export const ModelPricingSheet = forwardRef< export const ModelPricingEditorPanel = forwardRef< ModelPricingEditorPanelHandle, ModelPricingEditorPanelProps ->(function ModelPricingEditorPanel({ editData, className }, ref) { +>(function ModelPricingEditorPanel( + { editData, className, onSave, isSaving }, + ref +) { const { t } = useTranslation() const [pricingMode, setPricingMode] = useState('per-token') const [promptPrice, setPromptPrice] = useState('') @@ -461,6 +472,7 @@ export const ModelPricingEditorPanel = forwardRef< ) const activeName = watchedValues.name || editData?.name || t('New model') + const showActions = Boolean(onSave) return (
-
+
{warnings.length > 0 && ( @@ -670,6 +682,23 @@ export const ModelPricingEditorPanel = forwardRef<
+ {showActions && ( +
+
+ {onSave && ( + + )} +
+
+ )}
diff --git a/web/default/src/features/system-settings/models/model-ratio-form.tsx b/web/default/src/features/system-settings/models/model-ratio-form.tsx index a1110903..38c3a530 100644 --- a/web/default/src/features/system-settings/models/model-ratio-form.tsx +++ b/web/default/src/features/system-settings/models/model-ratio-form.tsx @@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com */ import { memo, useCallback, useRef, useState } from 'react' import { type UseFormReturn } from 'react-hook-form' -import { Code2, Eye } from 'lucide-react' +import { Code2, Eye, RotateCcw, Save } from 'lucide-react' import { useTranslation } from 'react-i18next' import { Button } from '@/components/ui/button' import { @@ -110,16 +110,20 @@ export const ModelRatioForm = memo(function ModelRatioForm({ onClick={onReset} disabled={isResetting} > + {t('Reset prices')} - + {editMode === 'json' && ( + + )}