From bde9b2f44887d34ec54799ae191d50f97914359e Mon Sep 17 00:00:00 2001 From: CaIon Date: Sat, 11 Jul 2026 22:57:22 +0800 Subject: [PATCH] fix: harden unset price models tab batch copy, feedback, and memo equality Persist committed editor drafts to the source model during batch copy so targets never carry pricing the source would lose, surface loading/error states for the enabled-models query, and compare saved* props in the visual editor memo so rows leave the unset list reliably after save. --- .../models/model-ratio-form.tsx | 20 ++++++- .../models/model-ratio-visual-editor.tsx | 54 +++++++++++++------ web/default/src/i18n/locales/en.json | 1 + web/default/src/i18n/locales/fr.json | 1 + web/default/src/i18n/locales/ja.json | 1 + web/default/src/i18n/locales/ru.json | 1 + web/default/src/i18n/locales/vi.json | 1 + web/default/src/i18n/locales/zh-TW.json | 1 + web/default/src/i18n/locales/zh.json | 1 + 9 files changed, 63 insertions(+), 18 deletions(-) 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 640d6090..7dbd7697 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,9 +18,10 @@ For commercial licensing, please contact support@quantumnous.com */ import { useQuery } from '@tanstack/react-query' import { Code2, Eye, RotateCcw, Save } from 'lucide-react' -import { memo, useCallback, useRef, useState } from 'react' -import { type UseFormReturn } from 'react-hook-form' +import { memo, useCallback, useEffect, useRef, useState } from 'react' +import type { UseFormReturn } from 'react-hook-form' import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' import { JsonCodeEditor } from '@/components/json-code-editor' import { Button } from '@/components/ui/button' @@ -180,6 +181,18 @@ export const ModelRatioForm = memo(function ModelRatioForm({ enabled: isUnsetVariant, }) + const enabledModelsError = isUnsetVariant + ? enabledModelsQuery.isError || + (enabledModelsQuery.data !== undefined && + !enabledModelsQuery.data.success) + : false + const enabledModelsErrorMessage = enabledModelsQuery.data?.message + + useEffect(() => { + if (!enabledModelsError) return + toast.error(enabledModelsErrorMessage || t('Failed to load enabled models')) + }, [enabledModelsError, enabledModelsErrorMessage, t]) + const handleFieldChange = useCallback( (field: keyof ModelFormValues, value: string) => { form.setValue(field, value, { @@ -272,6 +285,9 @@ export const ModelRatioForm = memo(function ModelRatioForm({ candidateModelNames={ isUnsetVariant ? enabledModelsQuery.data?.data : undefined } + candidateModelsLoading={ + isUnsetVariant && enabledModelsQuery.isLoading + } filterMode={isUnsetVariant ? 'unset' : 'all'} onSave={handleSave} isSaving={isSaving} 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 3606d2aa..827ef2eb 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 @@ -16,13 +16,13 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { - type ColumnFiltersState, - type OnChangeFn, - type PaginationState, - type RowSelectionState, - type VisibilityState, - type SortingState, +import type { + ColumnFiltersState, + OnChangeFn, + PaginationState, + RowSelectionState, + VisibilityState, + SortingState, } from '@tanstack/react-table' import { Copy, Plus } from 'lucide-react' import { @@ -51,6 +51,7 @@ import { combineBillingExpr } from '@/features/pricing/lib/billing-expr' import { useMediaQuery } from '@/hooks' import { safeJsonParse } from '../utils/json-parser' +import type { PricingMode } from './model-pricing-core' import { ModelPricingEditorPanel, type ModelPricingEditorPanelHandle, @@ -87,6 +88,7 @@ type ModelRatioVisualEditorProps = { billingMode: string billingExpr: string candidateModelNames?: string[] + candidateModelsLoading?: boolean filterMode?: 'all' | 'unset' onChange: (field: string, value: string) => void onSave: () => void | Promise @@ -125,6 +127,7 @@ const ModelRatioVisualEditorComponent = forwardRef< billingMode, billingExpr, candidateModelNames, + candidateModelsLoading, filterMode = 'all', onChange, onSave, @@ -219,7 +222,7 @@ const ModelRatioVisualEditorComponent = forwardRef< ...draftByName.keys(), ]) - return Array.from(modelNames) + return [...modelNames] .map((name) => { const saved = savedByName.get(name) const draft = draftByName.get(name) @@ -289,6 +292,12 @@ const ModelRatioVisualEditorComponent = forwardRef< const handleEdit = useCallback( (model: ModelRow) => { const editableModel = model.draft ?? model.saved ?? model + let editBillingMode: PricingMode = 'per-token' + if (editableModel.billingMode === 'tiered_expr') { + editBillingMode = 'tiered_expr' + } else if (editableModel.price && editableModel.price !== '') { + editBillingMode = 'per-request' + } setEditData({ name: editableModel.name, price: editableModel.price, @@ -299,12 +308,7 @@ const ModelRatioVisualEditorComponent = forwardRef< imageRatio: editableModel.imageRatio, audioRatio: editableModel.audioRatio, audioCompletionRatio: editableModel.audioCompletionRatio, - billingMode: - editableModel.billingMode === 'tiered_expr' - ? 'tiered_expr' - : editableModel.price && editableModel.price !== '' - ? 'per-request' - : 'per-token', + billingMode: editBillingMode, billingExpr: editableModel.billingExpr, requestRuleExpr: editableModel.requestRuleExpr, }) @@ -632,7 +636,11 @@ const ModelRatioVisualEditorComponent = forwardRef< return } - persistPricingData(sourceData, targetNames) + // Persist to the source model too, so targets never carry pricing the + // source itself would lose if the editor draft were abandoned. + persistPricingData(sourceData, [ + ...new Set([sourceData.name, ...targetNames]), + ]) table.resetRowSelection() toast.success( t('Applied {{name}} pricing to {{count}} models', { @@ -663,7 +671,9 @@ const ModelRatioVisualEditorComponent = forwardRef< if (table.getState().globalFilter) { emptyStateText = t('No models match your search') } else if (filterMode === 'unset') { - emptyStateText = t('No models with unset prices') + emptyStateText = candidateModelsLoading + ? t('Loading...') + : t('No models with unset prices') } return ( @@ -821,6 +831,17 @@ export const ModelRatioVisualEditor = memo( // Custom equality check - only re-render if JSON props actually changed (prevProps, nextProps) => { return ( + prevProps.savedModelPrice === nextProps.savedModelPrice && + prevProps.savedModelRatio === nextProps.savedModelRatio && + prevProps.savedCacheRatio === nextProps.savedCacheRatio && + prevProps.savedCreateCacheRatio === nextProps.savedCreateCacheRatio && + prevProps.savedCompletionRatio === nextProps.savedCompletionRatio && + prevProps.savedImageRatio === nextProps.savedImageRatio && + prevProps.savedAudioRatio === nextProps.savedAudioRatio && + prevProps.savedAudioCompletionRatio === + nextProps.savedAudioCompletionRatio && + prevProps.savedBillingMode === nextProps.savedBillingMode && + prevProps.savedBillingExpr === nextProps.savedBillingExpr && prevProps.modelPrice === nextProps.modelPrice && prevProps.modelRatio === nextProps.modelRatio && prevProps.cacheRatio === nextProps.cacheRatio && @@ -832,6 +853,7 @@ export const ModelRatioVisualEditor = memo( prevProps.billingMode === nextProps.billingMode && prevProps.billingExpr === nextProps.billingExpr && prevProps.candidateModelNames === nextProps.candidateModelNames && + prevProps.candidateModelsLoading === nextProps.candidateModelsLoading && prevProps.filterMode === nextProps.filterMode && prevProps.onChange === nextProps.onChange && prevProps.onSave === nextProps.onSave && diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index a9f5cec0..9eba821e 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -1845,6 +1845,7 @@ "Failed to load": "Failed to load", "Failed to load API keys": "Failed to load API keys", "Failed to load billing history": "Failed to load billing history", + "Failed to load enabled models": "Failed to load enabled models", "Failed to load home page content": "Failed to load home page content", "Failed to load image": "Failed to load image", "Failed to load key status": "Failed to load key status", diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json index 72157673..f4014a48 100644 --- a/web/default/src/i18n/locales/fr.json +++ b/web/default/src/i18n/locales/fr.json @@ -1845,6 +1845,7 @@ "Failed to load": "Échec du chargement", "Failed to load API keys": "Échec du chargement des Clés API", "Failed to load billing history": "Échec du chargement de l'historique de facturation", + "Failed to load enabled models": "Échec du chargement des modèles activés", "Failed to load home page content": "Échec du chargement du contenu de la page d'accueil", "Failed to load image": "Échec du chargement de l'image", "Failed to load key status": "Échec du chargement du statut des clés", diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index 9845303e..527f49b9 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -1845,6 +1845,7 @@ "Failed to load": "読み込みに失敗しました", "Failed to load API keys": "APIキーの読み込みに失敗しました", "Failed to load billing history": "請求履歴の読み込みに失敗しました", + "Failed to load enabled models": "有効なモデルの取得に失敗しました", "Failed to load home page content": "ホームページの内容の読み込みに失敗しました", "Failed to load image": "画像の読み込みに失敗しました", "Failed to load key status": "キー状態の読み込みに失敗しました", diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index 9765f086..0c424ad5 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -1845,6 +1845,7 @@ "Failed to load": "Не удалось загрузить", "Failed to load API keys": "Не удалось загрузить API ключи", "Failed to load billing history": "Не удалось загрузить историю платежей", + "Failed to load enabled models": "Не удалось загрузить включённые модели", "Failed to load home page content": "Не удалось загрузить содержимое главной страницы", "Failed to load image": "Не удалось загрузить изображение", "Failed to load key status": "Не удалось загрузить статус ключей", diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index bcef090c..17fc695a 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -1845,6 +1845,7 @@ "Failed to load": "Tải thất bại", "Failed to load API keys": "Không thể tải khóa API", "Failed to load billing history": "Không thể tải lịch sử thanh toán", + "Failed to load enabled models": "Không thể tải các mô hình đã bật", "Failed to load home page content": "Không thể tải nội dung trang chủ", "Failed to load image": "Không thể tải ảnh", "Failed to load key status": "Không thể tải trạng thái khóa", diff --git a/web/default/src/i18n/locales/zh-TW.json b/web/default/src/i18n/locales/zh-TW.json index 26addcd7..279e4e6e 100644 --- a/web/default/src/i18n/locales/zh-TW.json +++ b/web/default/src/i18n/locales/zh-TW.json @@ -1845,6 +1845,7 @@ "Failed to load": "載入失敗", "Failed to load API keys": "載入 API 金鑰失敗", "Failed to load billing history": "載入收費歷史失敗", + "Failed to load enabled models": "獲取啟用模型失敗", "Failed to load home page content": "載入首頁內容失敗", "Failed to load image": "無法載入圖像", "Failed to load key status": "載入金鑰狀態失敗", diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index 3da71a04..45368da4 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -1845,6 +1845,7 @@ "Failed to load": "加载失败", "Failed to load API keys": "加载 API 密钥失败", "Failed to load billing history": "加载计费历史失败", + "Failed to load enabled models": "获取启用模型失败", "Failed to load home page content": "加载首页内容失败", "Failed to load image": "无法加载图像", "Failed to load key status": "加载密钥状态失败",