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 配置:",