mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-03-30 02:05:21 +00:00
938 lines
28 KiB
JavaScript
938 lines
28 KiB
JavaScript
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,
|
||
};
|
||
}
|