| import { useEffect, useMemo, useState } from 'react'; |
| import { API, showError, showSuccess } from '../../../../helpers'; |
|
|
| export const PAGE_SIZE = 10; |
| export const PRICE_SUFFIX = '$/1M tokens'; |
| const EMPTY_CANDIDATE_MODEL_NAMES = []; |
|
|
| const EMPTY_MODEL = { |
| name: '', |
| billingMode: 'per-token', |
| fixedPrice: '', |
| inputPrice: '', |
| completionPrice: '', |
| lockedCompletionRatio: '', |
| completionRatioLocked: false, |
| cachePrice: '', |
| createCachePrice: '', |
| imagePrice: '', |
| audioInputPrice: '', |
| audioOutputPrice: '', |
| rawRatios: { |
| modelRatio: '', |
| completionRatio: '', |
| cacheRatio: '', |
| createCacheRatio: '', |
| imageRatio: '', |
| audioRatio: '', |
| audioCompletionRatio: '', |
| }, |
| hasConflict: false, |
| }; |
|
|
| const NUMERIC_INPUT_REGEX = /^(\d+(\.\d*)?|\.\d*)?$/; |
|
|
| export const hasValue = (value) => |
| value !== '' && value !== null && value !== undefined && value !== false; |
|
|
| const toNumericString = (value) => { |
| if (!hasValue(value) && value !== 0) { |
| return ''; |
| } |
| const num = Number(value); |
| return Number.isFinite(num) ? String(num) : ''; |
| }; |
|
|
| const toNumberOrNull = (value) => { |
| if (!hasValue(value) && value !== 0) { |
| return null; |
| } |
| const num = Number(value); |
| return Number.isFinite(num) ? num : null; |
| }; |
|
|
| const formatNumber = (value) => { |
| const num = toNumberOrNull(value); |
| if (num === null) { |
| return ''; |
| } |
| return parseFloat(num.toFixed(12)).toString(); |
| }; |
|
|
| const parseOptionJSON = (rawValue) => { |
| if (!rawValue || rawValue.trim() === '') { |
| return {}; |
| } |
| try { |
| const parsed = JSON.parse(rawValue); |
| return parsed && typeof parsed === 'object' ? parsed : {}; |
| } catch (error) { |
| console.error('JSON解析错误:', error); |
| return {}; |
| } |
| }; |
|
|
| const ratioToBasePrice = (ratio) => { |
| const num = toNumberOrNull(ratio); |
| if (num === null) return ''; |
| return formatNumber(num * 2); |
| }; |
|
|
| const normalizeCompletionRatioMeta = (rawMeta) => { |
| if (!rawMeta || typeof rawMeta !== 'object' || Array.isArray(rawMeta)) { |
| return { |
| locked: false, |
| ratio: '', |
| }; |
| } |
|
|
| return { |
| locked: Boolean(rawMeta.locked), |
| ratio: toNumericString(rawMeta.ratio), |
| }; |
| }; |
|
|
| const buildModelState = (name, sourceMaps) => { |
| const modelRatio = toNumericString(sourceMaps.ModelRatio[name]); |
| const completionRatio = toNumericString(sourceMaps.CompletionRatio[name]); |
| const completionRatioMeta = normalizeCompletionRatioMeta( |
| sourceMaps.CompletionRatioMeta?.[name], |
| ); |
| const cacheRatio = toNumericString(sourceMaps.CacheRatio[name]); |
| const createCacheRatio = toNumericString(sourceMaps.CreateCacheRatio[name]); |
| const imageRatio = toNumericString(sourceMaps.ImageRatio[name]); |
| const audioRatio = toNumericString(sourceMaps.AudioRatio[name]); |
| const audioCompletionRatio = toNumericString( |
| sourceMaps.AudioCompletionRatio[name], |
| ); |
| const fixedPrice = toNumericString(sourceMaps.ModelPrice[name]); |
| const inputPrice = ratioToBasePrice(modelRatio); |
| const inputPriceNumber = toNumberOrNull(inputPrice); |
| const audioInputPrice = |
| inputPriceNumber !== null && hasValue(audioRatio) |
| ? formatNumber(inputPriceNumber * Number(audioRatio)) |
| : ''; |
|
|
| return { |
| ...EMPTY_MODEL, |
| name, |
| billingMode: hasValue(fixedPrice) ? 'per-request' : 'per-token', |
| fixedPrice, |
| inputPrice, |
| completionRatioLocked: completionRatioMeta.locked, |
| lockedCompletionRatio: completionRatioMeta.ratio, |
| completionPrice: |
| inputPriceNumber !== null && |
| hasValue(completionRatioMeta.locked ? completionRatioMeta.ratio : completionRatio) |
| ? formatNumber( |
| inputPriceNumber * |
| Number( |
| completionRatioMeta.locked |
| ? completionRatioMeta.ratio |
| : completionRatio, |
| ), |
| ) |
| : '', |
| cachePrice: |
| inputPriceNumber !== null && hasValue(cacheRatio) |
| ? formatNumber(inputPriceNumber * Number(cacheRatio)) |
| : '', |
| createCachePrice: |
| inputPriceNumber !== null && hasValue(createCacheRatio) |
| ? formatNumber(inputPriceNumber * Number(createCacheRatio)) |
| : '', |
| imagePrice: |
| inputPriceNumber !== null && hasValue(imageRatio) |
| ? formatNumber(inputPriceNumber * Number(imageRatio)) |
| : '', |
| audioInputPrice, |
| audioOutputPrice: |
| toNumberOrNull(audioInputPrice) !== null && hasValue(audioCompletionRatio) |
| ? formatNumber(Number(audioInputPrice) * Number(audioCompletionRatio)) |
| : '', |
| rawRatios: { |
| modelRatio, |
| completionRatio, |
| cacheRatio, |
| createCacheRatio, |
| imageRatio, |
| audioRatio, |
| audioCompletionRatio, |
| }, |
| hasConflict: |
| hasValue(fixedPrice) && |
| [ |
| modelRatio, |
| completionRatio, |
| cacheRatio, |
| createCacheRatio, |
| imageRatio, |
| audioRatio, |
| audioCompletionRatio, |
| ].some(hasValue), |
| }; |
| }; |
|
|
| export const isBasePricingUnset = (model) => |
| !hasValue(model.fixedPrice) && !hasValue(model.inputPrice); |
|
|
| export const getModelWarnings = (model, t) => { |
| if (!model) { |
| return []; |
| } |
| const warnings = []; |
| const hasDerivedPricing = [ |
| model.inputPrice, |
| model.completionPrice, |
| model.cachePrice, |
| model.createCachePrice, |
| model.imagePrice, |
| model.audioInputPrice, |
| model.audioOutputPrice, |
| ].some(hasValue); |
|
|
| if (model.hasConflict) { |
| warnings.push(t('当前模型同时存在按次价格和倍率配置,保存时会按当前计费方式覆盖。')); |
| } |
|
|
| if ( |
| !hasValue(model.inputPrice) && |
| [ |
| model.rawRatios.completionRatio, |
| model.rawRatios.cacheRatio, |
| model.rawRatios.createCacheRatio, |
| model.rawRatios.imageRatio, |
| model.rawRatios.audioRatio, |
| model.rawRatios.audioCompletionRatio, |
| ].some(hasValue) |
| ) { |
| warnings.push( |
| t('当前模型存在未显式设置输入倍率的扩展倍率;填写输入价格后会自动换算为价格字段。'), |
| ); |
| } |
|
|
| if (model.billingMode === 'per-token' && hasDerivedPricing && !hasValue(model.inputPrice)) { |
| warnings.push(t('按量计费下需要先填写输入价格,才能保存其它价格项。')); |
| } |
|
|
| if ( |
| model.billingMode === 'per-token' && |
| hasValue(model.audioOutputPrice) && |
| !hasValue(model.audioInputPrice) |
| ) { |
| warnings.push(t('填写音频补全价格前,需要先填写音频输入价格。')); |
| } |
|
|
| return warnings; |
| }; |
|
|
| export const buildSummaryText = (model, t) => { |
| if (model.billingMode === 'per-request' && hasValue(model.fixedPrice)) { |
| return `${t('按次')} $${model.fixedPrice} / ${t('次')}`; |
| } |
|
|
| if (hasValue(model.inputPrice)) { |
| const extraCount = [ |
| model.completionPrice, |
| model.cachePrice, |
| model.createCachePrice, |
| model.imagePrice, |
| model.audioInputPrice, |
| model.audioOutputPrice, |
| ].filter(hasValue).length; |
| const extraLabel = |
| extraCount > 0 ? `,${t('额外价格项')} ${extraCount}` : ''; |
| return `${t('输入')} $${model.inputPrice}${extraLabel}`; |
| } |
|
|
| return t('未设置价格'); |
| }; |
|
|
| export const buildOptionalFieldToggles = (model) => ({ |
| completionPrice: model.completionRatioLocked || hasValue(model.completionPrice), |
| cachePrice: hasValue(model.cachePrice), |
| createCachePrice: hasValue(model.createCachePrice), |
| imagePrice: hasValue(model.imagePrice), |
| audioInputPrice: hasValue(model.audioInputPrice), |
| audioOutputPrice: hasValue(model.audioOutputPrice), |
| }); |
|
|
| const serializeModel = (model, t) => { |
| const result = { |
| ModelPrice: null, |
| ModelRatio: null, |
| CompletionRatio: null, |
| CacheRatio: null, |
| CreateCacheRatio: null, |
| ImageRatio: null, |
| AudioRatio: null, |
| AudioCompletionRatio: null, |
| }; |
|
|
| if (model.billingMode === 'per-request') { |
| if (hasValue(model.fixedPrice)) { |
| result.ModelPrice = Number(model.fixedPrice); |
| } |
| return result; |
| } |
|
|
| const inputPrice = toNumberOrNull(model.inputPrice); |
| const completionPrice = toNumberOrNull(model.completionPrice); |
| const cachePrice = toNumberOrNull(model.cachePrice); |
| const createCachePrice = toNumberOrNull(model.createCachePrice); |
| const imagePrice = toNumberOrNull(model.imagePrice); |
| const audioInputPrice = toNumberOrNull(model.audioInputPrice); |
| const audioOutputPrice = toNumberOrNull(model.audioOutputPrice); |
|
|
| const hasDependentPrice = [ |
| completionPrice, |
| cachePrice, |
| createCachePrice, |
| imagePrice, |
| audioInputPrice, |
| audioOutputPrice, |
| ].some((value) => value !== null); |
|
|
| if (inputPrice === null) { |
| if (hasDependentPrice) { |
| throw new Error( |
| t('模型 {{name}} 缺少输入价格,无法计算补全/缓存/图片/音频价格对应的倍率', { |
| name: model.name, |
| }), |
| ); |
| } |
|
|
| if (hasValue(model.rawRatios.modelRatio)) { |
| result.ModelRatio = Number(model.rawRatios.modelRatio); |
| } |
| if (hasValue(model.rawRatios.completionRatio)) { |
| result.CompletionRatio = Number(model.rawRatios.completionRatio); |
| } |
| if (hasValue(model.rawRatios.cacheRatio)) { |
| result.CacheRatio = Number(model.rawRatios.cacheRatio); |
| } |
| if (hasValue(model.rawRatios.createCacheRatio)) { |
| result.CreateCacheRatio = Number(model.rawRatios.createCacheRatio); |
| } |
| if (hasValue(model.rawRatios.imageRatio)) { |
| result.ImageRatio = Number(model.rawRatios.imageRatio); |
| } |
| if (hasValue(model.rawRatios.audioRatio)) { |
| result.AudioRatio = Number(model.rawRatios.audioRatio); |
| } |
| if (hasValue(model.rawRatios.audioCompletionRatio)) { |
| result.AudioCompletionRatio = Number(model.rawRatios.audioCompletionRatio); |
| } |
| return result; |
| } |
|
|
| result.ModelRatio = inputPrice / 2; |
|
|
| if (!model.completionRatioLocked && completionPrice !== null) { |
| result.CompletionRatio = completionPrice / inputPrice; |
| } else if ( |
| model.completionRatioLocked && |
| hasValue(model.rawRatios.completionRatio) |
| ) { |
| result.CompletionRatio = Number(model.rawRatios.completionRatio); |
| } |
| if (cachePrice !== null) { |
| result.CacheRatio = cachePrice / inputPrice; |
| } |
| if (createCachePrice !== null) { |
| result.CreateCacheRatio = createCachePrice / inputPrice; |
| } |
| if (imagePrice !== null) { |
| result.ImageRatio = imagePrice / inputPrice; |
| } |
| if (audioInputPrice !== null) { |
| result.AudioRatio = audioInputPrice / inputPrice; |
| } |
| if (audioOutputPrice !== null) { |
| if (audioInputPrice === null || audioInputPrice === 0) { |
| throw new Error( |
| t('模型 {{name}} 缺少音频输入价格,无法计算音频补全倍率', { |
| name: model.name, |
| }), |
| ); |
| } |
| result.AudioCompletionRatio = audioOutputPrice / audioInputPrice; |
| } |
|
|
| return result; |
| }; |
|
|
| export const buildPreviewRows = (model, t) => { |
| if (!model) return []; |
|
|
| if (model.billingMode === 'per-request') { |
| return [ |
| { |
| key: 'ModelPrice', |
| label: 'ModelPrice', |
| value: hasValue(model.fixedPrice) ? model.fixedPrice : t('空'), |
| }, |
| ]; |
| } |
|
|
| const inputPrice = toNumberOrNull(model.inputPrice); |
| if (inputPrice === null) { |
| return [ |
| { |
| key: 'ModelRatio', |
| label: 'ModelRatio', |
| value: hasValue(model.rawRatios.modelRatio) |
| ? model.rawRatios.modelRatio |
| : t('空'), |
| }, |
| { |
| key: 'CompletionRatio', |
| label: 'CompletionRatio', |
| value: hasValue(model.rawRatios.completionRatio) |
| ? model.rawRatios.completionRatio |
| : t('空'), |
| }, |
| { |
| key: 'CacheRatio', |
| label: 'CacheRatio', |
| value: hasValue(model.rawRatios.cacheRatio) |
| ? model.rawRatios.cacheRatio |
| : t('空'), |
| }, |
| { |
| key: 'CreateCacheRatio', |
| label: 'CreateCacheRatio', |
| value: hasValue(model.rawRatios.createCacheRatio) |
| ? model.rawRatios.createCacheRatio |
| : t('空'), |
| }, |
| { |
| key: 'ImageRatio', |
| label: 'ImageRatio', |
| value: hasValue(model.rawRatios.imageRatio) |
| ? model.rawRatios.imageRatio |
| : t('空'), |
| }, |
| { |
| key: 'AudioRatio', |
| label: 'AudioRatio', |
| value: hasValue(model.rawRatios.audioRatio) |
| ? model.rawRatios.audioRatio |
| : t('空'), |
| }, |
| { |
| key: 'AudioCompletionRatio', |
| label: 'AudioCompletionRatio', |
| value: hasValue(model.rawRatios.audioCompletionRatio) |
| ? model.rawRatios.audioCompletionRatio |
| : t('空'), |
| }, |
| ]; |
| } |
|
|
| const completionPrice = toNumberOrNull(model.completionPrice); |
| const cachePrice = toNumberOrNull(model.cachePrice); |
| const createCachePrice = toNumberOrNull(model.createCachePrice); |
| const imagePrice = toNumberOrNull(model.imagePrice); |
| const audioInputPrice = toNumberOrNull(model.audioInputPrice); |
| const audioOutputPrice = toNumberOrNull(model.audioOutputPrice); |
|
|
| return [ |
| { |
| key: 'ModelRatio', |
| label: 'ModelRatio', |
| value: formatNumber(inputPrice / 2), |
| }, |
| { |
| key: 'CompletionRatio', |
| label: 'CompletionRatio', |
| value: model.completionRatioLocked |
| ? `${model.lockedCompletionRatio || t('空')} (${t('后端固定')})` |
| : completionPrice !== null |
| ? formatNumber(completionPrice / inputPrice) |
| : t('空'), |
| }, |
| { |
| key: 'CacheRatio', |
| label: 'CacheRatio', |
| value: cachePrice !== null ? formatNumber(cachePrice / inputPrice) : t('空'), |
| }, |
| { |
| key: 'CreateCacheRatio', |
| label: 'CreateCacheRatio', |
| value: |
| createCachePrice !== null |
| ? formatNumber(createCachePrice / inputPrice) |
| : t('空'), |
| }, |
| { |
| key: 'ImageRatio', |
| label: 'ImageRatio', |
| value: imagePrice !== null ? formatNumber(imagePrice / inputPrice) : t('空'), |
| }, |
| { |
| key: 'AudioRatio', |
| label: 'AudioRatio', |
| value: |
| audioInputPrice !== null |
| ? formatNumber(audioInputPrice / inputPrice) |
| : t('空'), |
| }, |
| { |
| key: 'AudioCompletionRatio', |
| label: 'AudioCompletionRatio', |
| value: |
| audioOutputPrice !== null && audioInputPrice !== null && audioInputPrice !== 0 |
| ? formatNumber(audioOutputPrice / audioInputPrice) |
| : t('空'), |
| }, |
| ]; |
| }; |
|
|
| export function useModelPricingEditorState({ |
| options, |
| refresh, |
| t, |
| candidateModelNames = EMPTY_CANDIDATE_MODEL_NAMES, |
| filterMode = 'all', |
| }) { |
| const [models, setModels] = useState([]); |
| const [initialVisibleModelNames, setInitialVisibleModelNames] = useState([]); |
| const [selectedModelName, setSelectedModelName] = useState(''); |
| const [selectedModelNames, setSelectedModelNames] = useState([]); |
| const [searchText, setSearchText] = useState(''); |
| const [currentPage, setCurrentPage] = useState(1); |
| const [loading, setLoading] = useState(false); |
| const [conflictOnly, setConflictOnly] = useState(false); |
| const [optionalFieldToggles, setOptionalFieldToggles] = useState({}); |
|
|
| useEffect(() => { |
| const sourceMaps = { |
| ModelPrice: parseOptionJSON(options.ModelPrice), |
| ModelRatio: parseOptionJSON(options.ModelRatio), |
| CompletionRatio: parseOptionJSON(options.CompletionRatio), |
| CompletionRatioMeta: parseOptionJSON(options.CompletionRatioMeta), |
| CacheRatio: parseOptionJSON(options.CacheRatio), |
| CreateCacheRatio: parseOptionJSON(options.CreateCacheRatio), |
| ImageRatio: parseOptionJSON(options.ImageRatio), |
| AudioRatio: parseOptionJSON(options.AudioRatio), |
| AudioCompletionRatio: parseOptionJSON(options.AudioCompletionRatio), |
| }; |
|
|
| const names = new Set([ |
| ...candidateModelNames, |
| ...Object.keys(sourceMaps.ModelPrice), |
| ...Object.keys(sourceMaps.ModelRatio), |
| ...Object.keys(sourceMaps.CompletionRatio), |
| ...Object.keys(sourceMaps.CompletionRatioMeta), |
| ...Object.keys(sourceMaps.CacheRatio), |
| ...Object.keys(sourceMaps.CreateCacheRatio), |
| ...Object.keys(sourceMaps.ImageRatio), |
| ...Object.keys(sourceMaps.AudioRatio), |
| ...Object.keys(sourceMaps.AudioCompletionRatio), |
| ]); |
|
|
| const nextModels = Array.from(names) |
| .map((name) => buildModelState(name, sourceMaps)) |
| .sort((a, b) => a.name.localeCompare(b.name)); |
|
|
| setModels(nextModels); |
| setInitialVisibleModelNames( |
| filterMode === 'unset' |
| ? nextModels |
| .filter((model) => isBasePricingUnset(model)) |
| .map((model) => model.name) |
| : nextModels.map((model) => model.name), |
| ); |
| setOptionalFieldToggles( |
| nextModels.reduce((acc, model) => { |
| acc[model.name] = buildOptionalFieldToggles(model); |
| return acc; |
| }, {}), |
| ); |
| setSelectedModelName((previous) => { |
| if (previous && nextModels.some((model) => model.name === previous)) { |
| return previous; |
| } |
| const nextVisibleModels = |
| filterMode === 'unset' |
| ? nextModels.filter((model) => isBasePricingUnset(model)) |
| : nextModels; |
| return nextVisibleModels[0]?.name || ''; |
| }); |
| }, [candidateModelNames, filterMode, options]); |
|
|
| const visibleModels = useMemo(() => { |
| return filterMode === 'unset' |
| ? models.filter((model) => initialVisibleModelNames.includes(model.name)) |
| : models; |
| }, [filterMode, initialVisibleModelNames, models]); |
|
|
| const filteredModels = useMemo(() => { |
| return visibleModels.filter((model) => { |
| const keyword = searchText.trim().toLowerCase(); |
| const keywordMatch = keyword |
| ? model.name.toLowerCase().includes(keyword) |
| : true; |
| const conflictMatch = conflictOnly ? model.hasConflict : true; |
| return keywordMatch && conflictMatch; |
| }); |
| }, [conflictOnly, searchText, visibleModels]); |
|
|
| const pagedData = useMemo(() => { |
| const start = (currentPage - 1) * PAGE_SIZE; |
| return filteredModels.slice(start, start + PAGE_SIZE); |
| }, [currentPage, filteredModels]); |
|
|
| const selectedModel = useMemo( |
| () => visibleModels.find((model) => model.name === selectedModelName) || null, |
| [selectedModelName, visibleModels], |
| ); |
|
|
| const selectedWarnings = useMemo( |
| () => getModelWarnings(selectedModel, t), |
| [selectedModel, t], |
| ); |
|
|
| const previewRows = useMemo( |
| () => buildPreviewRows(selectedModel, t), |
| [selectedModel, t], |
| ); |
|
|
| useEffect(() => { |
| setCurrentPage(1); |
| }, [searchText, conflictOnly, filterMode, candidateModelNames]); |
|
|
| useEffect(() => { |
| setSelectedModelNames((previous) => |
| previous.filter((name) => visibleModels.some((model) => model.name === name)), |
| ); |
| }, [visibleModels]); |
|
|
| useEffect(() => { |
| if (visibleModels.length === 0) { |
| setSelectedModelName(''); |
| return; |
| } |
| if (!visibleModels.some((model) => model.name === selectedModelName)) { |
| setSelectedModelName(visibleModels[0].name); |
| } |
| }, [selectedModelName, visibleModels]); |
|
|
| const upsertModel = (name, updater) => { |
| setModels((previous) => |
| previous.map((model) => { |
| if (model.name !== name) return model; |
| return typeof updater === 'function' ? updater(model) : updater; |
| }), |
| ); |
| }; |
|
|
| const isOptionalFieldEnabled = (model, field) => { |
| if (!model) return false; |
| const modelToggles = optionalFieldToggles[model.name]; |
| if (modelToggles && typeof modelToggles[field] === 'boolean') { |
| return modelToggles[field]; |
| } |
| return buildOptionalFieldToggles(model)[field]; |
| }; |
|
|
| const updateOptionalFieldToggle = (modelName, field, checked) => { |
| setOptionalFieldToggles((prev) => ({ |
| ...prev, |
| [modelName]: { |
| ...(prev[modelName] || {}), |
| [field]: checked, |
| }, |
| })); |
| }; |
|
|
| const handleOptionalFieldToggle = (field, checked) => { |
| if (!selectedModel) return; |
|
|
| updateOptionalFieldToggle(selectedModel.name, field, checked); |
|
|
| if (checked) { |
| return; |
| } |
|
|
| upsertModel(selectedModel.name, (model) => { |
| const nextModel = { ...model, [field]: '' }; |
|
|
| if (field === 'audioInputPrice') { |
| nextModel.audioOutputPrice = ''; |
| setOptionalFieldToggles((prev) => ({ |
| ...prev, |
| [selectedModel.name]: { |
| ...(prev[selectedModel.name] || {}), |
| audioInputPrice: false, |
| audioOutputPrice: false, |
| }, |
| })); |
| } |
|
|
| return nextModel; |
| }); |
| }; |
|
|
| const fillDerivedPricesFromBase = (model, nextInputPrice) => { |
| const baseNumber = toNumberOrNull(nextInputPrice); |
| if (baseNumber === null) { |
| return model; |
| } |
|
|
| return { |
| ...model, |
| completionPrice: |
| model.completionRatioLocked && hasValue(model.lockedCompletionRatio) |
| ? formatNumber(baseNumber * Number(model.lockedCompletionRatio)) |
| : !hasValue(model.completionPrice) && |
| hasValue(model.rawRatios.completionRatio) |
| ? formatNumber(baseNumber * Number(model.rawRatios.completionRatio)) |
| : model.completionPrice, |
| cachePrice: |
| !hasValue(model.cachePrice) && hasValue(model.rawRatios.cacheRatio) |
| ? formatNumber(baseNumber * Number(model.rawRatios.cacheRatio)) |
| : model.cachePrice, |
| createCachePrice: |
| !hasValue(model.createCachePrice) && |
| hasValue(model.rawRatios.createCacheRatio) |
| ? formatNumber(baseNumber * Number(model.rawRatios.createCacheRatio)) |
| : model.createCachePrice, |
| imagePrice: |
| !hasValue(model.imagePrice) && hasValue(model.rawRatios.imageRatio) |
| ? formatNumber(baseNumber * Number(model.rawRatios.imageRatio)) |
| : model.imagePrice, |
| audioInputPrice: |
| !hasValue(model.audioInputPrice) && hasValue(model.rawRatios.audioRatio) |
| ? formatNumber(baseNumber * Number(model.rawRatios.audioRatio)) |
| : model.audioInputPrice, |
| audioOutputPrice: |
| !hasValue(model.audioOutputPrice) && |
| hasValue(model.rawRatios.audioRatio) && |
| hasValue(model.rawRatios.audioCompletionRatio) |
| ? formatNumber( |
| baseNumber * |
| Number(model.rawRatios.audioRatio) * |
| Number(model.rawRatios.audioCompletionRatio), |
| ) |
| : model.audioOutputPrice, |
| }; |
| }; |
|
|
| const handleNumericFieldChange = (field, value) => { |
| if (!selectedModel || !NUMERIC_INPUT_REGEX.test(value)) { |
| return; |
| } |
|
|
| upsertModel(selectedModel.name, (model) => { |
| const updatedModel = { ...model, [field]: value }; |
|
|
| if (field === 'inputPrice') { |
| return fillDerivedPricesFromBase(updatedModel, value); |
| } |
|
|
| return updatedModel; |
| }); |
| }; |
|
|
| const handleBillingModeChange = (value) => { |
| if (!selectedModel) return; |
| upsertModel(selectedModel.name, (model) => ({ |
| ...model, |
| billingMode: value, |
| })); |
| }; |
|
|
| const addModel = (modelName) => { |
| const trimmedName = modelName.trim(); |
| if (!trimmedName) { |
| showError(t('请输入模型名称')); |
| return false; |
| } |
| if (models.some((model) => model.name === trimmedName)) { |
| showError(t('模型名称已存在')); |
| return false; |
| } |
|
|
| const nextModel = { |
| ...EMPTY_MODEL, |
| name: trimmedName, |
| rawRatios: { ...EMPTY_MODEL.rawRatios }, |
| }; |
|
|
| setModels((previous) => [nextModel, ...previous]); |
| setOptionalFieldToggles((prev) => ({ |
| ...prev, |
| [trimmedName]: buildOptionalFieldToggles(nextModel), |
| })); |
| setSelectedModelName(trimmedName); |
| setCurrentPage(1); |
| return true; |
| }; |
|
|
| const deleteModel = (name) => { |
| const nextModels = models.filter((model) => model.name !== name); |
| setModels(nextModels); |
| setOptionalFieldToggles((prev) => { |
| const next = { ...prev }; |
| delete next[name]; |
| return next; |
| }); |
| setSelectedModelNames((previous) => previous.filter((item) => item !== name)); |
| if (selectedModelName === name) { |
| setSelectedModelName(nextModels[0]?.name || ''); |
| } |
| }; |
|
|
| const applySelectedModelPricing = () => { |
| if (!selectedModel) { |
| showError(t('请先选择一个作为模板的模型')); |
| return false; |
| } |
| if (selectedModelNames.length === 0) { |
| showError(t('请先勾选需要批量设置的模型')); |
| return false; |
| } |
|
|
| const sourceToggles = optionalFieldToggles[selectedModel.name] || {}; |
|
|
| setModels((previous) => |
| previous.map((model) => { |
| if (!selectedModelNames.includes(model.name)) { |
| return model; |
| } |
|
|
| const nextModel = { |
| ...model, |
| billingMode: selectedModel.billingMode, |
| fixedPrice: selectedModel.fixedPrice, |
| inputPrice: selectedModel.inputPrice, |
| completionPrice: selectedModel.completionPrice, |
| cachePrice: selectedModel.cachePrice, |
| createCachePrice: selectedModel.createCachePrice, |
| imagePrice: selectedModel.imagePrice, |
| audioInputPrice: selectedModel.audioInputPrice, |
| audioOutputPrice: selectedModel.audioOutputPrice, |
| }; |
|
|
| if ( |
| nextModel.billingMode === 'per-token' && |
| nextModel.completionRatioLocked && |
| hasValue(nextModel.inputPrice) && |
| hasValue(nextModel.lockedCompletionRatio) |
| ) { |
| nextModel.completionPrice = formatNumber( |
| Number(nextModel.inputPrice) * Number(nextModel.lockedCompletionRatio), |
| ); |
| } |
|
|
| return nextModel; |
| }), |
| ); |
|
|
| setOptionalFieldToggles((previous) => { |
| const next = { ...previous }; |
| selectedModelNames.forEach((modelName) => { |
| const targetModel = models.find((item) => item.name === modelName); |
| next[modelName] = { |
| completionPrice: targetModel?.completionRatioLocked |
| ? true |
| : Boolean(sourceToggles.completionPrice), |
| cachePrice: Boolean(sourceToggles.cachePrice), |
| createCachePrice: Boolean(sourceToggles.createCachePrice), |
| imagePrice: Boolean(sourceToggles.imagePrice), |
| audioInputPrice: Boolean(sourceToggles.audioInputPrice), |
| audioOutputPrice: |
| Boolean(sourceToggles.audioInputPrice) && |
| Boolean(sourceToggles.audioOutputPrice), |
| }; |
| }); |
| return next; |
| }); |
|
|
| showSuccess( |
| t('已将模型 {{name}} 的价格配置批量应用到 {{count}} 个模型', { |
| name: selectedModel.name, |
| count: selectedModelNames.length, |
| }), |
| ); |
| return true; |
| }; |
|
|
| const handleSubmit = async () => { |
| setLoading(true); |
| try { |
| const output = { |
| ModelPrice: {}, |
| ModelRatio: {}, |
| CompletionRatio: {}, |
| CacheRatio: {}, |
| CreateCacheRatio: {}, |
| ImageRatio: {}, |
| AudioRatio: {}, |
| AudioCompletionRatio: {}, |
| }; |
|
|
| for (const model of models) { |
| const serialized = serializeModel(model, t); |
| Object.entries(serialized).forEach(([key, value]) => { |
| if (value !== null) { |
| output[key][model.name] = value; |
| } |
| }); |
| } |
|
|
| const requestQueue = Object.entries(output).map(([key, value]) => |
| API.put('/api/option/', { |
| key, |
| value: JSON.stringify(value, null, 2), |
| }), |
| ); |
|
|
| const results = await Promise.all(requestQueue); |
| for (const res of results) { |
| if (!res?.data?.success) { |
| throw new Error(res?.data?.message || t('保存失败,请重试')); |
| } |
| } |
|
|
| showSuccess(t('保存成功')); |
| await refresh(); |
| } catch (error) { |
| console.error('保存失败:', error); |
| showError(error.message || t('保存失败,请重试')); |
| } finally { |
| setLoading(false); |
| } |
| }; |
|
|
| return { |
| models, |
| selectedModel, |
| selectedModelName, |
| selectedModelNames, |
| setSelectedModelName, |
| setSelectedModelNames, |
| searchText, |
| setSearchText, |
| currentPage, |
| setCurrentPage, |
| loading, |
| conflictOnly, |
| setConflictOnly, |
| filteredModels, |
| pagedData, |
| selectedWarnings, |
| previewRows, |
| isOptionalFieldEnabled, |
| handleOptionalFieldToggle, |
| handleNumericFieldChange, |
| handleBillingModeChange, |
| handleSubmit, |
| addModel, |
| deleteModel, |
| applySelectedModelPricing, |
| }; |
| } |
|
|