Spaces:
Runtime error
Runtime error
| 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, | |
| }; | |
| } | |