Files
new-api/web/src/pages/Setting/Ratio/hooks/useModelPricingEditorState.js

938 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
};
}