mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-04-20 01:18:37 +00:00
fix: update language settings and improve model pricing editor for better clarity and functionality
This commit is contained in:
937
web/src/pages/Setting/Ratio/hooks/useModelPricingEditorState.js
Normal file
937
web/src/pages/Setting/Ratio/hooks/useModelPricingEditorState.js
Normal file
@@ -0,0 +1,937 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user