mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-05-03 02:18:25 +00:00
fix: update language settings and improve model pricing editor for better clarity and functionality
This commit is contained in:
@@ -265,7 +265,7 @@ export default function GroupRatioSettings(props) {
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
<Button onClick={onSubmit}>{t('保存分组倍率设置')}</Button>
|
||||
<Button onClick={onSubmit}>{t('保存分组相关设置')}</Button>
|
||||
</Spin>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,47 +18,13 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Input,
|
||||
Modal,
|
||||
Form,
|
||||
Space,
|
||||
Typography,
|
||||
Radio,
|
||||
Notification,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconDelete,
|
||||
IconPlus,
|
||||
IconSearch,
|
||||
IconSave,
|
||||
IconBolt,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { API, showError, showSuccess } from '../../../helpers';
|
||||
import { API, showError } from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ModelPricingEditor from './components/ModelPricingEditor';
|
||||
|
||||
export default function ModelRatioNotSetEditor(props) {
|
||||
const { t } = useTranslation();
|
||||
const [models, setModels] = useState([]);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [batchVisible, setBatchVisible] = useState(false);
|
||||
const [currentModel, setCurrentModel] = useState(null);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [enabledModels, setEnabledModels] = useState([]);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState([]);
|
||||
const [batchFillType, setBatchFillType] = useState('ratio');
|
||||
const [batchFillValue, setBatchFillValue] = useState('');
|
||||
const [batchRatioValue, setBatchRatioValue] = useState('');
|
||||
const [batchCompletionRatioValue, setBatchCompletionRatioValue] =
|
||||
useState('');
|
||||
const { Text } = Typography;
|
||||
// 定义可选的每页显示条数
|
||||
const pageSizeOptions = [10, 20, 50, 100];
|
||||
|
||||
const getAllEnabledModels = async () => {
|
||||
try {
|
||||
@@ -79,540 +45,20 @@ export default function ModelRatioNotSetEditor(props) {
|
||||
// 获取所有启用的模型
|
||||
getAllEnabledModels();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const modelPrice = JSON.parse(props.options.ModelPrice || '{}');
|
||||
const modelRatio = JSON.parse(props.options.ModelRatio || '{}');
|
||||
const completionRatio = JSON.parse(props.options.CompletionRatio || '{}');
|
||||
|
||||
// 找出所有未设置价格和倍率的模型
|
||||
const unsetModels = enabledModels.filter((modelName) => {
|
||||
const hasPrice = modelPrice[modelName] !== undefined;
|
||||
const hasRatio = modelRatio[modelName] !== undefined;
|
||||
|
||||
// 如果模型没有价格或者没有倍率设置,则显示
|
||||
return !hasPrice && !hasRatio;
|
||||
});
|
||||
|
||||
// 创建模型数据
|
||||
const modelData = unsetModels.map((name) => ({
|
||||
name,
|
||||
price: modelPrice[name] || '',
|
||||
ratio: modelRatio[name] || '',
|
||||
completionRatio: completionRatio[name] || '',
|
||||
}));
|
||||
|
||||
setModels(modelData);
|
||||
// 清空选择
|
||||
setSelectedRowKeys([]);
|
||||
} catch (error) {
|
||||
console.error(t('JSON解析错误:'), error);
|
||||
}
|
||||
}, [props.options, enabledModels]);
|
||||
|
||||
// 首先声明分页相关的工具函数
|
||||
const getPagedData = (data, currentPage, pageSize) => {
|
||||
const start = (currentPage - 1) * pageSize;
|
||||
const end = start + pageSize;
|
||||
return data.slice(start, end);
|
||||
};
|
||||
|
||||
// 处理页面大小变化
|
||||
const handlePageSizeChange = (size) => {
|
||||
setPageSize(size);
|
||||
// 重新计算当前页,避免数据丢失
|
||||
const totalPages = Math.ceil(filteredModels.length / size);
|
||||
if (currentPage > totalPages) {
|
||||
setCurrentPage(totalPages || 1);
|
||||
}
|
||||
};
|
||||
|
||||
// 在 return 语句之前,先处理过滤和分页逻辑
|
||||
const filteredModels = models.filter((model) =>
|
||||
searchText ? model.name.includes(searchText) : true,
|
||||
);
|
||||
|
||||
// 然后基于过滤后的数据计算分页数据
|
||||
const pagedData = getPagedData(filteredModels, currentPage, pageSize);
|
||||
|
||||
const SubmitData = async () => {
|
||||
setLoading(true);
|
||||
const output = {
|
||||
ModelPrice: JSON.parse(props.options.ModelPrice || '{}'),
|
||||
ModelRatio: JSON.parse(props.options.ModelRatio || '{}'),
|
||||
CompletionRatio: JSON.parse(props.options.CompletionRatio || '{}'),
|
||||
};
|
||||
|
||||
try {
|
||||
// 数据转换 - 只处理已修改的模型
|
||||
models.forEach((model) => {
|
||||
// 只有当用户设置了值时才更新
|
||||
if (model.price !== '') {
|
||||
// 如果价格不为空,则转换为浮点数,忽略倍率参数
|
||||
output.ModelPrice[model.name] = parseFloat(model.price);
|
||||
} else {
|
||||
if (model.ratio !== '')
|
||||
output.ModelRatio[model.name] = parseFloat(model.ratio);
|
||||
if (model.completionRatio !== '')
|
||||
output.CompletionRatio[model.name] = parseFloat(
|
||||
model.completionRatio,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// 准备API请求数组
|
||||
const finalOutput = {
|
||||
ModelPrice: JSON.stringify(output.ModelPrice, null, 2),
|
||||
ModelRatio: JSON.stringify(output.ModelRatio, null, 2),
|
||||
CompletionRatio: JSON.stringify(output.CompletionRatio, null, 2),
|
||||
};
|
||||
|
||||
const requestQueue = Object.entries(finalOutput).map(([key, value]) => {
|
||||
return API.put('/api/option/', {
|
||||
key,
|
||||
value,
|
||||
});
|
||||
});
|
||||
|
||||
// 批量处理请求
|
||||
const results = await Promise.all(requestQueue);
|
||||
|
||||
// 验证结果
|
||||
if (requestQueue.length === 1) {
|
||||
if (results.includes(undefined)) return;
|
||||
} else if (requestQueue.length > 1) {
|
||||
if (results.includes(undefined)) {
|
||||
return showError(t('部分保存失败,请重试'));
|
||||
}
|
||||
}
|
||||
|
||||
// 检查每个请求的结果
|
||||
for (const res of results) {
|
||||
if (!res.data.success) {
|
||||
return showError(res.data.message);
|
||||
}
|
||||
}
|
||||
|
||||
showSuccess(t('保存成功'));
|
||||
props.refresh();
|
||||
// 重新获取未设置的模型
|
||||
getAllEnabledModels();
|
||||
} catch (error) {
|
||||
console.error(t('保存失败:'), error);
|
||||
showError(t('保存失败,请重试'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('模型名称'),
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: t('模型固定价格'),
|
||||
dataIndex: 'price',
|
||||
key: 'price',
|
||||
render: (text, record) => (
|
||||
<Input
|
||||
value={text}
|
||||
placeholder={t('按量计费')}
|
||||
onChange={(value) => updateModel(record.name, 'price', value)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('模型倍率'),
|
||||
dataIndex: 'ratio',
|
||||
key: 'ratio',
|
||||
render: (text, record) => (
|
||||
<Input
|
||||
value={text}
|
||||
placeholder={record.price !== '' ? t('模型倍率') : t('输入模型倍率')}
|
||||
disabled={record.price !== ''}
|
||||
onChange={(value) => updateModel(record.name, 'ratio', value)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('补全倍率'),
|
||||
dataIndex: 'completionRatio',
|
||||
key: 'completionRatio',
|
||||
render: (text, record) => (
|
||||
<Input
|
||||
value={text}
|
||||
placeholder={record.price !== '' ? t('补全倍率') : t('输入补全倍率')}
|
||||
disabled={record.price !== ''}
|
||||
onChange={(value) =>
|
||||
updateModel(record.name, 'completionRatio', value)
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const updateModel = (name, field, value) => {
|
||||
if (value !== '' && isNaN(value)) {
|
||||
showError(t('请输入数字'));
|
||||
return;
|
||||
}
|
||||
setModels((prev) =>
|
||||
prev.map((model) =>
|
||||
model.name === name ? { ...model, [field]: value } : model,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const addModel = (values) => {
|
||||
// 检查模型名称是否存在, 如果存在则拒绝添加
|
||||
if (models.some((model) => model.name === values.name)) {
|
||||
showError(t('模型名称已存在'));
|
||||
return;
|
||||
}
|
||||
setModels((prev) => [
|
||||
{
|
||||
name: values.name,
|
||||
price: values.price || '',
|
||||
ratio: values.ratio || '',
|
||||
completionRatio: values.completionRatio || '',
|
||||
},
|
||||
...prev,
|
||||
]);
|
||||
setVisible(false);
|
||||
showSuccess(t('添加成功'));
|
||||
};
|
||||
|
||||
// 批量填充功能
|
||||
const handleBatchFill = () => {
|
||||
if (selectedRowKeys.length === 0) {
|
||||
showError(t('请先选择需要批量设置的模型'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (batchFillType === 'bothRatio') {
|
||||
if (batchRatioValue === '' || batchCompletionRatioValue === '') {
|
||||
showError(t('请输入模型倍率和补全倍率'));
|
||||
return;
|
||||
}
|
||||
if (isNaN(batchRatioValue) || isNaN(batchCompletionRatioValue)) {
|
||||
showError(t('请输入有效的数字'));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (batchFillValue === '') {
|
||||
showError(t('请输入填充值'));
|
||||
return;
|
||||
}
|
||||
if (isNaN(batchFillValue)) {
|
||||
showError(t('请输入有效的数字'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 根据选择的类型批量更新模型
|
||||
setModels((prev) =>
|
||||
prev.map((model) => {
|
||||
if (selectedRowKeys.includes(model.name)) {
|
||||
if (batchFillType === 'price') {
|
||||
return {
|
||||
...model,
|
||||
price: batchFillValue,
|
||||
ratio: '',
|
||||
completionRatio: '',
|
||||
};
|
||||
} else if (batchFillType === 'ratio') {
|
||||
return {
|
||||
...model,
|
||||
price: '',
|
||||
ratio: batchFillValue,
|
||||
};
|
||||
} else if (batchFillType === 'completionRatio') {
|
||||
return {
|
||||
...model,
|
||||
price: '',
|
||||
completionRatio: batchFillValue,
|
||||
};
|
||||
} else if (batchFillType === 'bothRatio') {
|
||||
return {
|
||||
...model,
|
||||
price: '',
|
||||
ratio: batchRatioValue,
|
||||
completionRatio: batchCompletionRatioValue,
|
||||
};
|
||||
}
|
||||
}
|
||||
return model;
|
||||
}),
|
||||
);
|
||||
|
||||
setBatchVisible(false);
|
||||
Notification.success({
|
||||
title: t('批量设置成功'),
|
||||
content: t('已为 {{count}} 个模型设置{{type}}', {
|
||||
count: selectedRowKeys.length,
|
||||
type:
|
||||
batchFillType === 'price'
|
||||
? t('固定价格')
|
||||
: batchFillType === 'ratio'
|
||||
? t('模型倍率')
|
||||
: batchFillType === 'completionRatio'
|
||||
? t('补全倍率')
|
||||
: t('模型倍率和补全倍率'),
|
||||
}),
|
||||
duration: 3,
|
||||
});
|
||||
};
|
||||
|
||||
const handleBatchTypeChange = (value) => {
|
||||
console.log(t('Changing batch type to:'), value);
|
||||
setBatchFillType(value);
|
||||
|
||||
// 切换类型时清空对应的值
|
||||
if (value !== 'bothRatio') {
|
||||
setBatchFillValue('');
|
||||
} else {
|
||||
setBatchRatioValue('');
|
||||
setBatchCompletionRatioValue('');
|
||||
}
|
||||
};
|
||||
|
||||
const rowSelection = {
|
||||
selectedRowKeys,
|
||||
onChange: (selectedKeys) => {
|
||||
setSelectedRowKeys(selectedKeys);
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Space vertical align='start' style={{ width: '100%' }}>
|
||||
<Space className='mt-2'>
|
||||
<Button icon={<IconPlus />} onClick={() => setVisible(true)}>
|
||||
{t('添加模型')}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<IconBolt />}
|
||||
type='secondary'
|
||||
onClick={() => setBatchVisible(true)}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
>
|
||||
{t('批量设置')} ({selectedRowKeys.length})
|
||||
</Button>
|
||||
<Button
|
||||
type='primary'
|
||||
icon={<IconSave />}
|
||||
onClick={SubmitData}
|
||||
loading={loading}
|
||||
>
|
||||
{t('应用更改')}
|
||||
</Button>
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('搜索模型名称')}
|
||||
value={searchText}
|
||||
onChange={(value) => {
|
||||
setSearchText(value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
style={{ width: 200 }}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
<Text>
|
||||
{t('此页面仅显示未设置价格或倍率的模型,设置后将自动从列表中移除')}
|
||||
</Text>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={pagedData}
|
||||
rowSelection={rowSelection}
|
||||
rowKey='name'
|
||||
pagination={{
|
||||
currentPage: currentPage,
|
||||
pageSize: pageSize,
|
||||
total: filteredModels.length,
|
||||
onPageChange: (page) => setCurrentPage(page),
|
||||
onPageSizeChange: handlePageSizeChange,
|
||||
pageSizeOptions: pageSizeOptions,
|
||||
showTotal: true,
|
||||
showSizeChanger: true,
|
||||
}}
|
||||
empty={
|
||||
<div style={{ textAlign: 'center', padding: '20px' }}>
|
||||
{t('没有未设置的模型')}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
{/* 添加模型弹窗 */}
|
||||
<Modal
|
||||
title={t('添加模型')}
|
||||
visible={visible}
|
||||
onCancel={() => setVisible(false)}
|
||||
onOk={() => {
|
||||
currentModel && addModel(currentModel);
|
||||
}}
|
||||
>
|
||||
<Form>
|
||||
<Form.Input
|
||||
field='name'
|
||||
label={t('模型名称')}
|
||||
placeholder='strawberry'
|
||||
required
|
||||
onChange={(value) =>
|
||||
setCurrentModel((prev) => ({ ...prev, name: value }))
|
||||
}
|
||||
/>
|
||||
<Form.Switch
|
||||
field='priceMode'
|
||||
label={
|
||||
<>
|
||||
{t('定价模式')}:
|
||||
{currentModel?.priceMode ? t('固定价格') : t('倍率模式')}
|
||||
</>
|
||||
}
|
||||
onChange={(checked) => {
|
||||
setCurrentModel((prev) => ({
|
||||
...prev,
|
||||
price: '',
|
||||
ratio: '',
|
||||
completionRatio: '',
|
||||
priceMode: checked,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
{currentModel?.priceMode ? (
|
||||
<Form.Input
|
||||
field='price'
|
||||
label={t('固定价格(每次)')}
|
||||
placeholder={t('输入每次价格')}
|
||||
onChange={(value) =>
|
||||
setCurrentModel((prev) => ({ ...prev, price: value }))
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Form.Input
|
||||
field='ratio'
|
||||
label={t('模型倍率')}
|
||||
placeholder={t('输入模型倍率')}
|
||||
onChange={(value) =>
|
||||
setCurrentModel((prev) => ({ ...prev, ratio: value }))
|
||||
}
|
||||
/>
|
||||
<Form.Input
|
||||
field='completionRatio'
|
||||
label={t('补全倍率')}
|
||||
placeholder={t('输入补全价格')}
|
||||
onChange={(value) =>
|
||||
setCurrentModel((prev) => ({
|
||||
...prev,
|
||||
completionRatio: value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 批量设置弹窗 */}
|
||||
<Modal
|
||||
title={t('批量设置模型参数')}
|
||||
visible={batchVisible}
|
||||
onCancel={() => setBatchVisible(false)}
|
||||
onOk={handleBatchFill}
|
||||
width={500}
|
||||
>
|
||||
<Form>
|
||||
<Form.Section text={t('设置类型')}>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<Space>
|
||||
<Radio
|
||||
checked={batchFillType === 'price'}
|
||||
onChange={() => handleBatchTypeChange('price')}
|
||||
>
|
||||
{t('固定价格')}
|
||||
</Radio>
|
||||
<Radio
|
||||
checked={batchFillType === 'ratio'}
|
||||
onChange={() => handleBatchTypeChange('ratio')}
|
||||
>
|
||||
{t('模型倍率')}
|
||||
</Radio>
|
||||
<Radio
|
||||
checked={batchFillType === 'completionRatio'}
|
||||
onChange={() => handleBatchTypeChange('completionRatio')}
|
||||
>
|
||||
{t('补全倍率')}
|
||||
</Radio>
|
||||
<Radio
|
||||
checked={batchFillType === 'bothRatio'}
|
||||
onChange={() => handleBatchTypeChange('bothRatio')}
|
||||
>
|
||||
{t('模型倍率和补全倍率同时设置')}
|
||||
</Radio>
|
||||
</Space>
|
||||
</div>
|
||||
</Form.Section>
|
||||
|
||||
{batchFillType === 'bothRatio' ? (
|
||||
<>
|
||||
<Form.Input
|
||||
field='batchRatioValue'
|
||||
label={t('模型倍率值')}
|
||||
placeholder={t('请输入模型倍率')}
|
||||
value={batchRatioValue}
|
||||
onChange={(value) => setBatchRatioValue(value)}
|
||||
/>
|
||||
<Form.Input
|
||||
field='batchCompletionRatioValue'
|
||||
label={t('补全倍率值')}
|
||||
placeholder={t('请输入补全倍率')}
|
||||
value={batchCompletionRatioValue}
|
||||
onChange={(value) => setBatchCompletionRatioValue(value)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Form.Input
|
||||
field='batchFillValue'
|
||||
label={
|
||||
batchFillType === 'price'
|
||||
? t('固定价格值')
|
||||
: batchFillType === 'ratio'
|
||||
? t('模型倍率值')
|
||||
: t('补全倍率值')
|
||||
}
|
||||
placeholder={t('请输入数值')}
|
||||
value={batchFillValue}
|
||||
onChange={(value) => setBatchFillValue(value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Text type='tertiary'>
|
||||
{t('将为选中的 ')} <Text strong>{selectedRowKeys.length}</Text>{' '}
|
||||
{t(' 个模型设置相同的值')}
|
||||
</Text>
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<Text type='tertiary'>
|
||||
{t('当前设置类型: ')}{' '}
|
||||
<Text strong>
|
||||
{batchFillType === 'price'
|
||||
? t('固定价格')
|
||||
: batchFillType === 'ratio'
|
||||
? t('模型倍率')
|
||||
: batchFillType === 'completionRatio'
|
||||
? t('补全倍率')
|
||||
: t('模型倍率和补全倍率')}
|
||||
</Text>
|
||||
</Text>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
<ModelPricingEditor
|
||||
options={props.options}
|
||||
refresh={props.refresh}
|
||||
candidateModelNames={enabledModels}
|
||||
filterMode='unset'
|
||||
allowAddModel={false}
|
||||
allowDeleteModel={false}
|
||||
showConflictFilter={false}
|
||||
listDescription={t(
|
||||
'此页面仅显示未设置价格或基础倍率的模型,设置后会自动从列表中移出',
|
||||
)}
|
||||
emptyTitle={t('没有未设置定价的模型')}
|
||||
emptyDescription={t('当前没有未设置定价的模型')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,741 +17,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Input,
|
||||
Modal,
|
||||
Form,
|
||||
Space,
|
||||
RadioGroup,
|
||||
Radio,
|
||||
Checkbox,
|
||||
Tag,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconDelete,
|
||||
IconPlus,
|
||||
IconSearch,
|
||||
IconSave,
|
||||
IconEdit,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { API, showError, showSuccess, getQuotaPerUnit } from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import React from 'react';
|
||||
import ModelPricingEditor from './components/ModelPricingEditor';
|
||||
|
||||
export default function ModelSettingsVisualEditor(props) {
|
||||
const { t } = useTranslation();
|
||||
const [models, setModels] = useState([]);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
const [currentModel, setCurrentModel] = useState(null);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pricingMode, setPricingMode] = useState('per-token'); // 'per-token' or 'per-request'
|
||||
const [pricingSubMode, setPricingSubMode] = useState('ratio'); // 'ratio' or 'token-price'
|
||||
const [conflictOnly, setConflictOnly] = useState(false);
|
||||
const formRef = useRef(null);
|
||||
const pageSize = 10;
|
||||
const quotaPerUnit = getQuotaPerUnit();
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const modelPrice = JSON.parse(props.options.ModelPrice || '{}');
|
||||
const modelRatio = JSON.parse(props.options.ModelRatio || '{}');
|
||||
const completionRatio = JSON.parse(props.options.CompletionRatio || '{}');
|
||||
|
||||
// 合并所有模型名称
|
||||
const modelNames = new Set([
|
||||
...Object.keys(modelPrice),
|
||||
...Object.keys(modelRatio),
|
||||
...Object.keys(completionRatio),
|
||||
]);
|
||||
|
||||
const modelData = Array.from(modelNames).map((name) => {
|
||||
const price = modelPrice[name] === undefined ? '' : modelPrice[name];
|
||||
const ratio = modelRatio[name] === undefined ? '' : modelRatio[name];
|
||||
const comp =
|
||||
completionRatio[name] === undefined ? '' : completionRatio[name];
|
||||
|
||||
return {
|
||||
name,
|
||||
price,
|
||||
ratio,
|
||||
completionRatio: comp,
|
||||
hasConflict: price !== '' && (ratio !== '' || comp !== ''),
|
||||
};
|
||||
});
|
||||
|
||||
setModels(modelData);
|
||||
} catch (error) {
|
||||
console.error('JSON解析错误:', error);
|
||||
}
|
||||
}, [props.options]);
|
||||
|
||||
// 首先声明分页相关的工具函数
|
||||
const getPagedData = (data, currentPage, pageSize) => {
|
||||
const start = (currentPage - 1) * pageSize;
|
||||
const end = start + pageSize;
|
||||
return data.slice(start, end);
|
||||
};
|
||||
|
||||
// 在 return 语句之前,先处理过滤和分页逻辑
|
||||
const filteredModels = models.filter((model) => {
|
||||
const keywordMatch = searchText ? model.name.includes(searchText) : true;
|
||||
const conflictMatch = conflictOnly ? model.hasConflict : true;
|
||||
return keywordMatch && conflictMatch;
|
||||
});
|
||||
|
||||
// 然后基于过滤后的数据计算分页数据
|
||||
const pagedData = getPagedData(filteredModels, currentPage, pageSize);
|
||||
|
||||
const SubmitData = async () => {
|
||||
setLoading(true);
|
||||
const output = {
|
||||
ModelPrice: {},
|
||||
ModelRatio: {},
|
||||
CompletionRatio: {},
|
||||
};
|
||||
let currentConvertModelName = '';
|
||||
|
||||
try {
|
||||
// 数据转换
|
||||
models.forEach((model) => {
|
||||
currentConvertModelName = model.name;
|
||||
if (model.price !== '') {
|
||||
// 如果价格不为空,则转换为浮点数,忽略倍率参数
|
||||
output.ModelPrice[model.name] = parseFloat(model.price);
|
||||
} else {
|
||||
if (model.ratio !== '')
|
||||
output.ModelRatio[model.name] = parseFloat(model.ratio);
|
||||
if (model.completionRatio !== '')
|
||||
output.CompletionRatio[model.name] = parseFloat(
|
||||
model.completionRatio,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// 准备API请求数组
|
||||
const finalOutput = {
|
||||
ModelPrice: JSON.stringify(output.ModelPrice, null, 2),
|
||||
ModelRatio: JSON.stringify(output.ModelRatio, null, 2),
|
||||
CompletionRatio: JSON.stringify(output.CompletionRatio, null, 2),
|
||||
};
|
||||
|
||||
const requestQueue = Object.entries(finalOutput).map(([key, value]) => {
|
||||
return API.put('/api/option/', {
|
||||
key,
|
||||
value,
|
||||
});
|
||||
});
|
||||
|
||||
// 批量处理请求
|
||||
const results = await Promise.all(requestQueue);
|
||||
|
||||
// 验证结果
|
||||
if (requestQueue.length === 1) {
|
||||
if (results.includes(undefined)) return;
|
||||
} else if (requestQueue.length > 1) {
|
||||
if (results.includes(undefined)) {
|
||||
return showError('部分保存失败,请重试');
|
||||
}
|
||||
}
|
||||
|
||||
// 检查每个请求的结果
|
||||
for (const res of results) {
|
||||
if (!res.data.success) {
|
||||
return showError(res.data.message);
|
||||
}
|
||||
}
|
||||
|
||||
showSuccess('保存成功');
|
||||
props.refresh();
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error);
|
||||
showError('保存失败,请重试');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('模型名称'),
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (text, record) => (
|
||||
<span>
|
||||
{text}
|
||||
{record.hasConflict && (
|
||||
<Tag color='red' shape='circle' className='ml-2'>
|
||||
{t('矛盾')}
|
||||
</Tag>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('模型固定价格'),
|
||||
dataIndex: 'price',
|
||||
key: 'price',
|
||||
render: (text, record) => (
|
||||
<Input
|
||||
value={text}
|
||||
placeholder={t('按量计费')}
|
||||
onChange={(value) => updateModel(record.name, 'price', value)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('模型倍率'),
|
||||
dataIndex: 'ratio',
|
||||
key: 'ratio',
|
||||
render: (text, record) => (
|
||||
<Input
|
||||
value={text}
|
||||
placeholder={record.price !== '' ? t('模型倍率') : t('默认补全倍率')}
|
||||
disabled={record.price !== ''}
|
||||
onChange={(value) => updateModel(record.name, 'ratio', value)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('补全倍率'),
|
||||
dataIndex: 'completionRatio',
|
||||
key: 'completionRatio',
|
||||
render: (text, record) => (
|
||||
<Input
|
||||
value={text}
|
||||
placeholder={record.price !== '' ? t('补全倍率') : t('默认补全倍率')}
|
||||
disabled={record.price !== ''}
|
||||
onChange={(value) =>
|
||||
updateModel(record.name, 'completionRatio', value)
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('操作'),
|
||||
key: 'action',
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button
|
||||
type='primary'
|
||||
icon={<IconEdit />}
|
||||
onClick={() => editModel(record)}
|
||||
></Button>
|
||||
<Button
|
||||
icon={<IconDelete />}
|
||||
type='danger'
|
||||
onClick={() => deleteModel(record.name)}
|
||||
/>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const updateModel = (name, field, value) => {
|
||||
if (isNaN(value)) {
|
||||
showError('请输入数字');
|
||||
return;
|
||||
}
|
||||
setModels((prev) =>
|
||||
prev.map((model) => {
|
||||
if (model.name !== name) return model;
|
||||
const updated = { ...model, [field]: value };
|
||||
updated.hasConflict =
|
||||
updated.price !== '' &&
|
||||
(updated.ratio !== '' || updated.completionRatio !== '');
|
||||
return updated;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const deleteModel = (name) => {
|
||||
setModels((prev) => prev.filter((model) => model.name !== name));
|
||||
};
|
||||
|
||||
const calculateRatioFromTokenPrice = (tokenPrice) => {
|
||||
return tokenPrice / 2;
|
||||
};
|
||||
|
||||
const calculateCompletionRatioFromPrices = (
|
||||
modelTokenPrice,
|
||||
completionTokenPrice,
|
||||
) => {
|
||||
if (!modelTokenPrice || modelTokenPrice === '0') {
|
||||
showError('模型价格不能为0');
|
||||
return '';
|
||||
}
|
||||
return completionTokenPrice / modelTokenPrice;
|
||||
};
|
||||
|
||||
const handleTokenPriceChange = (value) => {
|
||||
// Use a temporary variable to hold the new state
|
||||
let newState = {
|
||||
...(currentModel || {}),
|
||||
tokenPrice: value,
|
||||
ratio: 0,
|
||||
};
|
||||
|
||||
if (!isNaN(value) && value !== '') {
|
||||
const tokenPrice = parseFloat(value);
|
||||
const ratio = calculateRatioFromTokenPrice(tokenPrice);
|
||||
newState.ratio = ratio;
|
||||
}
|
||||
|
||||
// Set the state with the complete updated object
|
||||
setCurrentModel(newState);
|
||||
};
|
||||
|
||||
const handleCompletionTokenPriceChange = (value) => {
|
||||
// Use a temporary variable to hold the new state
|
||||
let newState = {
|
||||
...(currentModel || {}),
|
||||
completionTokenPrice: value,
|
||||
completionRatio: 0,
|
||||
};
|
||||
|
||||
if (!isNaN(value) && value !== '' && currentModel?.tokenPrice) {
|
||||
const completionTokenPrice = parseFloat(value);
|
||||
const modelTokenPrice = parseFloat(currentModel.tokenPrice);
|
||||
|
||||
if (modelTokenPrice > 0) {
|
||||
const completionRatio = calculateCompletionRatioFromPrices(
|
||||
modelTokenPrice,
|
||||
completionTokenPrice,
|
||||
);
|
||||
newState.completionRatio = completionRatio;
|
||||
}
|
||||
}
|
||||
|
||||
// Set the state with the complete updated object
|
||||
setCurrentModel(newState);
|
||||
};
|
||||
|
||||
const addOrUpdateModel = (values) => {
|
||||
// Check if we're editing an existing model or adding a new one
|
||||
const existingModelIndex = models.findIndex(
|
||||
(model) => model.name === values.name,
|
||||
);
|
||||
|
||||
if (existingModelIndex >= 0) {
|
||||
// Update existing model
|
||||
setModels((prev) =>
|
||||
prev.map((model, index) => {
|
||||
if (index !== existingModelIndex) return model;
|
||||
const updated = {
|
||||
name: values.name,
|
||||
price: values.price || '',
|
||||
ratio: values.ratio || '',
|
||||
completionRatio: values.completionRatio || '',
|
||||
};
|
||||
updated.hasConflict =
|
||||
updated.price !== '' &&
|
||||
(updated.ratio !== '' || updated.completionRatio !== '');
|
||||
return updated;
|
||||
}),
|
||||
);
|
||||
setVisible(false);
|
||||
showSuccess(t('更新成功'));
|
||||
} else {
|
||||
// Add new model
|
||||
// Check if model name already exists
|
||||
if (models.some((model) => model.name === values.name)) {
|
||||
showError(t('模型名称已存在'));
|
||||
return;
|
||||
}
|
||||
|
||||
setModels((prev) => {
|
||||
const newModel = {
|
||||
name: values.name,
|
||||
price: values.price || '',
|
||||
ratio: values.ratio || '',
|
||||
completionRatio: values.completionRatio || '',
|
||||
};
|
||||
newModel.hasConflict =
|
||||
newModel.price !== '' &&
|
||||
(newModel.ratio !== '' || newModel.completionRatio !== '');
|
||||
return [newModel, ...prev];
|
||||
});
|
||||
setVisible(false);
|
||||
showSuccess(t('添加成功'));
|
||||
}
|
||||
};
|
||||
|
||||
const calculateTokenPriceFromRatio = (ratio) => {
|
||||
return ratio * 2;
|
||||
};
|
||||
|
||||
const resetModalState = () => {
|
||||
setCurrentModel(null);
|
||||
setPricingMode('per-token');
|
||||
setPricingSubMode('ratio');
|
||||
setIsEditMode(false);
|
||||
};
|
||||
|
||||
const editModel = (record) => {
|
||||
setIsEditMode(true);
|
||||
// Determine which pricing mode to use based on the model's current configuration
|
||||
let initialPricingMode = 'per-token';
|
||||
let initialPricingSubMode = 'ratio';
|
||||
|
||||
if (record.price !== '') {
|
||||
initialPricingMode = 'per-request';
|
||||
} else {
|
||||
initialPricingMode = 'per-token';
|
||||
// We default to ratio mode, but could set to token-price if needed
|
||||
}
|
||||
|
||||
// Set the pricing modes for the form
|
||||
setPricingMode(initialPricingMode);
|
||||
setPricingSubMode(initialPricingSubMode);
|
||||
|
||||
// Create a copy of the model data to avoid modifying the original
|
||||
const modelCopy = { ...record };
|
||||
|
||||
// If the model has ratio data and we want to populate token price fields
|
||||
if (record.ratio) {
|
||||
modelCopy.tokenPrice = calculateTokenPriceFromRatio(
|
||||
parseFloat(record.ratio),
|
||||
).toString();
|
||||
|
||||
if (record.completionRatio) {
|
||||
modelCopy.completionTokenPrice = (
|
||||
parseFloat(modelCopy.tokenPrice) * parseFloat(record.completionRatio)
|
||||
).toString();
|
||||
}
|
||||
}
|
||||
|
||||
// Set the current model
|
||||
setCurrentModel(modelCopy);
|
||||
|
||||
// Open the modal
|
||||
setVisible(true);
|
||||
|
||||
// Use setTimeout to ensure the form is rendered before setting values
|
||||
setTimeout(() => {
|
||||
if (formRef.current) {
|
||||
// Update the form fields based on pricing mode
|
||||
const formValues = {
|
||||
name: modelCopy.name,
|
||||
};
|
||||
|
||||
if (initialPricingMode === 'per-request') {
|
||||
formValues.priceInput = modelCopy.price;
|
||||
} else if (initialPricingMode === 'per-token') {
|
||||
formValues.ratioInput = modelCopy.ratio;
|
||||
formValues.completionRatioInput = modelCopy.completionRatio;
|
||||
formValues.modelTokenPrice = modelCopy.tokenPrice;
|
||||
formValues.completionTokenPrice = modelCopy.completionTokenPrice;
|
||||
}
|
||||
|
||||
formRef.current.setValues(formValues);
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Space vertical align='start' style={{ width: '100%' }}>
|
||||
<Space className='mt-2'>
|
||||
<Button
|
||||
icon={<IconPlus />}
|
||||
onClick={() => {
|
||||
resetModalState();
|
||||
setVisible(true);
|
||||
}}
|
||||
>
|
||||
{t('添加模型')}
|
||||
</Button>
|
||||
<Button type='primary' icon={<IconSave />} onClick={SubmitData}>
|
||||
{t('应用更改')}
|
||||
</Button>
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('搜索模型名称')}
|
||||
value={searchText}
|
||||
onChange={(value) => {
|
||||
setSearchText(value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
style={{ width: 200 }}
|
||||
showClear
|
||||
/>
|
||||
<Checkbox
|
||||
checked={conflictOnly}
|
||||
onChange={(e) => {
|
||||
setConflictOnly(e.target.checked);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
>
|
||||
{t('仅显示矛盾倍率')}
|
||||
</Checkbox>
|
||||
</Space>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={pagedData}
|
||||
pagination={{
|
||||
currentPage: currentPage,
|
||||
pageSize: pageSize,
|
||||
total: filteredModels.length,
|
||||
onPageChange: (page) => setCurrentPage(page),
|
||||
showTotal: true,
|
||||
showSizeChanger: false,
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
<Modal
|
||||
title={isEditMode ? t('编辑模型') : t('添加模型')}
|
||||
visible={visible}
|
||||
onCancel={() => {
|
||||
resetModalState();
|
||||
setVisible(false);
|
||||
}}
|
||||
onOk={() => {
|
||||
if (currentModel) {
|
||||
// If we're in token price mode, make sure ratio values are properly set
|
||||
const valuesToSave = { ...currentModel };
|
||||
|
||||
if (
|
||||
pricingMode === 'per-token' &&
|
||||
pricingSubMode === 'token-price' &&
|
||||
currentModel.tokenPrice
|
||||
) {
|
||||
// Calculate and set ratio from token price
|
||||
const tokenPrice = parseFloat(currentModel.tokenPrice);
|
||||
valuesToSave.ratio = (tokenPrice / 2).toString();
|
||||
|
||||
// Calculate and set completion ratio if both token prices are available
|
||||
if (
|
||||
currentModel.completionTokenPrice &&
|
||||
currentModel.tokenPrice
|
||||
) {
|
||||
const completionPrice = parseFloat(
|
||||
currentModel.completionTokenPrice,
|
||||
);
|
||||
const modelPrice = parseFloat(currentModel.tokenPrice);
|
||||
if (modelPrice > 0) {
|
||||
valuesToSave.completionRatio = (
|
||||
completionPrice / modelPrice
|
||||
).toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear price if we're in per-token mode
|
||||
if (pricingMode === 'per-token') {
|
||||
valuesToSave.price = '';
|
||||
} else {
|
||||
// Clear ratios if we're in per-request mode
|
||||
valuesToSave.ratio = '';
|
||||
valuesToSave.completionRatio = '';
|
||||
}
|
||||
|
||||
addOrUpdateModel(valuesToSave);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form getFormApi={(api) => (formRef.current = api)}>
|
||||
<Form.Input
|
||||
field='name'
|
||||
label={t('模型名称')}
|
||||
placeholder='strawberry'
|
||||
required
|
||||
disabled={isEditMode}
|
||||
onChange={(value) =>
|
||||
setCurrentModel((prev) => ({ ...prev, name: value }))
|
||||
}
|
||||
/>
|
||||
|
||||
<Form.Section text={t('定价模式')}>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<RadioGroup
|
||||
type='button'
|
||||
value={pricingMode}
|
||||
onChange={(e) => {
|
||||
const newMode = e.target.value;
|
||||
const oldMode = pricingMode;
|
||||
setPricingMode(newMode);
|
||||
|
||||
// Instead of resetting all values, convert between modes
|
||||
if (currentModel) {
|
||||
const updatedModel = { ...currentModel };
|
||||
|
||||
// Update formRef with converted values
|
||||
if (formRef.current) {
|
||||
const formValues = {
|
||||
name: updatedModel.name,
|
||||
};
|
||||
|
||||
if (newMode === 'per-request') {
|
||||
formValues.priceInput = updatedModel.price || '';
|
||||
} else if (newMode === 'per-token') {
|
||||
formValues.ratioInput = updatedModel.ratio || '';
|
||||
formValues.completionRatioInput =
|
||||
updatedModel.completionRatio || '';
|
||||
formValues.modelTokenPrice =
|
||||
updatedModel.tokenPrice || '';
|
||||
formValues.completionTokenPrice =
|
||||
updatedModel.completionTokenPrice || '';
|
||||
}
|
||||
|
||||
formRef.current.setValues(formValues);
|
||||
}
|
||||
|
||||
// Update the model state
|
||||
setCurrentModel(updatedModel);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Radio value='per-token'>{t('按量计费')}</Radio>
|
||||
<Radio value='per-request'>{t('按次计费')}</Radio>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</Form.Section>
|
||||
|
||||
{pricingMode === 'per-token' && (
|
||||
<>
|
||||
<Form.Section text={t('价格设置方式')}>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<RadioGroup
|
||||
type='button'
|
||||
value={pricingSubMode}
|
||||
onChange={(e) => {
|
||||
const newSubMode = e.target.value;
|
||||
const oldSubMode = pricingSubMode;
|
||||
setPricingSubMode(newSubMode);
|
||||
|
||||
// Handle conversion between submodes
|
||||
if (currentModel) {
|
||||
const updatedModel = { ...currentModel };
|
||||
|
||||
// Convert between ratio and token price
|
||||
if (
|
||||
oldSubMode === 'ratio' &&
|
||||
newSubMode === 'token-price'
|
||||
) {
|
||||
if (updatedModel.ratio) {
|
||||
updatedModel.tokenPrice =
|
||||
calculateTokenPriceFromRatio(
|
||||
parseFloat(updatedModel.ratio),
|
||||
).toString();
|
||||
|
||||
if (updatedModel.completionRatio) {
|
||||
updatedModel.completionTokenPrice = (
|
||||
parseFloat(updatedModel.tokenPrice) *
|
||||
parseFloat(updatedModel.completionRatio)
|
||||
).toString();
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
oldSubMode === 'token-price' &&
|
||||
newSubMode === 'ratio'
|
||||
) {
|
||||
// Ratio values should already be calculated by the handlers
|
||||
}
|
||||
|
||||
// Update the form values
|
||||
if (formRef.current) {
|
||||
const formValues = {};
|
||||
|
||||
if (newSubMode === 'ratio') {
|
||||
formValues.ratioInput = updatedModel.ratio || '';
|
||||
formValues.completionRatioInput =
|
||||
updatedModel.completionRatio || '';
|
||||
} else if (newSubMode === 'token-price') {
|
||||
formValues.modelTokenPrice =
|
||||
updatedModel.tokenPrice || '';
|
||||
formValues.completionTokenPrice =
|
||||
updatedModel.completionTokenPrice || '';
|
||||
}
|
||||
|
||||
formRef.current.setValues(formValues);
|
||||
}
|
||||
|
||||
setCurrentModel(updatedModel);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Radio value='ratio'>{t('按倍率设置')}</Radio>
|
||||
<Radio value='token-price'>{t('按价格设置')}</Radio>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</Form.Section>
|
||||
|
||||
{pricingSubMode === 'ratio' && (
|
||||
<>
|
||||
<Form.Input
|
||||
field='ratioInput'
|
||||
label={t('模型倍率')}
|
||||
placeholder={t('输入模型倍率')}
|
||||
onChange={(value) =>
|
||||
setCurrentModel((prev) => ({
|
||||
...(prev || {}),
|
||||
ratio: value,
|
||||
}))
|
||||
}
|
||||
initValue={currentModel?.ratio || ''}
|
||||
/>
|
||||
<Form.Input
|
||||
field='completionRatioInput'
|
||||
label={t('补全倍率')}
|
||||
placeholder={t('输入补全倍率')}
|
||||
onChange={(value) =>
|
||||
setCurrentModel((prev) => ({
|
||||
...(prev || {}),
|
||||
completionRatio: value,
|
||||
}))
|
||||
}
|
||||
initValue={currentModel?.completionRatio || ''}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{pricingSubMode === 'token-price' && (
|
||||
<>
|
||||
<Form.Input
|
||||
field='modelTokenPrice'
|
||||
label={t('输入价格')}
|
||||
onChange={(value) => {
|
||||
handleTokenPriceChange(value);
|
||||
}}
|
||||
initValue={currentModel?.tokenPrice || ''}
|
||||
suffix={t('$/1M tokens')}
|
||||
/>
|
||||
<Form.Input
|
||||
field='completionTokenPrice'
|
||||
label={t('输出价格')}
|
||||
onChange={(value) => {
|
||||
handleCompletionTokenPriceChange(value);
|
||||
}}
|
||||
initValue={currentModel?.completionTokenPrice || ''}
|
||||
suffix={t('$/1M tokens')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{pricingMode === 'per-request' && (
|
||||
<Form.Input
|
||||
field='priceInput'
|
||||
label={t('固定价格(每次)')}
|
||||
placeholder={t('输入每次价格')}
|
||||
onChange={(value) =>
|
||||
setCurrentModel((prev) => ({
|
||||
...(prev || {}),
|
||||
price: value,
|
||||
}))
|
||||
}
|
||||
initValue={currentModel?.price || ''}
|
||||
/>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
return <ModelPricingEditor options={props.options} refresh={props.refresh} />;
|
||||
}
|
||||
|
||||
739
web/src/pages/Setting/Ratio/components/ModelPricingEditor.jsx
Normal file
739
web/src/pages/Setting/Ratio/components/ModelPricingEditor.jsx
Normal file
@@ -0,0 +1,739 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Empty,
|
||||
Input,
|
||||
Modal,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconDelete,
|
||||
IconPlus,
|
||||
IconSave,
|
||||
IconSearch,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
PAGE_SIZE,
|
||||
PRICE_SUFFIX,
|
||||
buildSummaryText,
|
||||
hasValue,
|
||||
useModelPricingEditorState,
|
||||
} from '../hooks/useModelPricingEditorState';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
|
||||
const { Text } = Typography;
|
||||
const EMPTY_CANDIDATE_MODEL_NAMES = [];
|
||||
|
||||
const PriceInput = ({
|
||||
label,
|
||||
value,
|
||||
placeholder,
|
||||
onChange,
|
||||
suffix = PRICE_SUFFIX,
|
||||
disabled = false,
|
||||
extraText = '',
|
||||
headerAction = null,
|
||||
hidden = false,
|
||||
}) => (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div className='mb-1 font-medium text-gray-700 flex items-center justify-between gap-3'>
|
||||
<span>{label}</span>
|
||||
{headerAction}
|
||||
</div>
|
||||
{!hidden ? (
|
||||
<Input
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={onChange}
|
||||
suffix={suffix}
|
||||
disabled={disabled}
|
||||
/>
|
||||
) : null}
|
||||
{extraText ? (
|
||||
<div className='mt-1 text-xs text-gray-500'>{extraText}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default function ModelPricingEditor({
|
||||
options,
|
||||
refresh,
|
||||
candidateModelNames = EMPTY_CANDIDATE_MODEL_NAMES,
|
||||
filterMode = 'all',
|
||||
allowAddModel = true,
|
||||
allowDeleteModel = true,
|
||||
showConflictFilter = true,
|
||||
listDescription = '',
|
||||
emptyTitle = '',
|
||||
emptyDescription = '',
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsMobile();
|
||||
const [addVisible, setAddVisible] = useState(false);
|
||||
const [batchVisible, setBatchVisible] = useState(false);
|
||||
const [newModelName, setNewModelName] = useState('');
|
||||
|
||||
const {
|
||||
selectedModel,
|
||||
selectedModelName,
|
||||
selectedModelNames,
|
||||
setSelectedModelName,
|
||||
setSelectedModelNames,
|
||||
searchText,
|
||||
setSearchText,
|
||||
currentPage,
|
||||
setCurrentPage,
|
||||
loading,
|
||||
conflictOnly,
|
||||
setConflictOnly,
|
||||
filteredModels,
|
||||
pagedData,
|
||||
selectedWarnings,
|
||||
previewRows,
|
||||
isOptionalFieldEnabled,
|
||||
handleOptionalFieldToggle,
|
||||
handleNumericFieldChange,
|
||||
handleBillingModeChange,
|
||||
handleSubmit,
|
||||
addModel,
|
||||
deleteModel,
|
||||
applySelectedModelPricing,
|
||||
} = useModelPricingEditorState({
|
||||
options,
|
||||
refresh,
|
||||
t,
|
||||
candidateModelNames,
|
||||
filterMode,
|
||||
});
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: t('模型名称'),
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (text, record) => (
|
||||
<Space>
|
||||
<Button
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
onClick={() => setSelectedModelName(record.name)}
|
||||
style={{
|
||||
padding: 0,
|
||||
color:
|
||||
record.name === selectedModelName
|
||||
? 'var(--semi-color-primary)'
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</Button>
|
||||
{selectedModelNames.includes(record.name) ? (
|
||||
<Tag color='green' shape='circle'>
|
||||
{t('已勾选')}
|
||||
</Tag>
|
||||
) : null}
|
||||
{record.hasConflict ? (
|
||||
<Tag color='red' shape='circle'>
|
||||
{t('矛盾')}
|
||||
</Tag>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('计费方式'),
|
||||
dataIndex: 'billingMode',
|
||||
key: 'billingMode',
|
||||
render: (_, record) => (
|
||||
<Tag color={record.billingMode === 'per-request' ? 'teal' : 'violet'}>
|
||||
{record.billingMode === 'per-request'
|
||||
? t('按次计费')
|
||||
: t('按量计费')}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('价格摘要'),
|
||||
dataIndex: 'summary',
|
||||
key: 'summary',
|
||||
render: (_, record) => buildSummaryText(record, t),
|
||||
},
|
||||
{
|
||||
title: t('操作'),
|
||||
key: 'action',
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
{allowDeleteModel ? (
|
||||
<Button
|
||||
size='small'
|
||||
type='danger'
|
||||
icon={<IconDelete />}
|
||||
onClick={() => deleteModel(record.name)}
|
||||
/>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
],
|
||||
[
|
||||
allowDeleteModel,
|
||||
deleteModel,
|
||||
selectedModelName,
|
||||
selectedModelNames,
|
||||
setSelectedModelName,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const handleAddModel = () => {
|
||||
if (addModel(newModelName)) {
|
||||
setNewModelName('');
|
||||
setAddVisible(false);
|
||||
}
|
||||
};
|
||||
|
||||
const rowSelection = {
|
||||
selectedRowKeys: selectedModelNames,
|
||||
onChange: (selectedRowKeys) => setSelectedModelNames(selectedRowKeys),
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Space vertical align='start' style={{ width: '100%' }}>
|
||||
<Space wrap className='mt-2'>
|
||||
{allowAddModel ? (
|
||||
<Button
|
||||
icon={<IconPlus />}
|
||||
onClick={() => setAddVisible(true)}
|
||||
style={isMobile ? { width: '100%' } : undefined}
|
||||
>
|
||||
{t('添加模型')}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type='primary'
|
||||
icon={<IconSave />}
|
||||
loading={loading}
|
||||
onClick={handleSubmit}
|
||||
style={isMobile ? { width: '100%' } : undefined}
|
||||
>
|
||||
{t('应用更改')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!selectedModel || selectedModelNames.length === 0}
|
||||
onClick={() => setBatchVisible(true)}
|
||||
style={isMobile ? { width: '100%' } : undefined}
|
||||
>
|
||||
{t('批量应用当前模型价格')}
|
||||
{selectedModelNames.length > 0 ? ` (${selectedModelNames.length})` : ''}
|
||||
</Button>
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('搜索模型名称')}
|
||||
value={searchText}
|
||||
onChange={(value) => setSearchText(value)}
|
||||
style={{ width: isMobile ? '100%' : 220 }}
|
||||
showClear
|
||||
/>
|
||||
{showConflictFilter ? (
|
||||
<Checkbox
|
||||
checked={conflictOnly}
|
||||
onChange={(event) => setConflictOnly(event.target.checked)}
|
||||
>
|
||||
{t('仅显示矛盾倍率')}
|
||||
</Checkbox>
|
||||
) : null}
|
||||
</Space>
|
||||
|
||||
{listDescription ? (
|
||||
<div className='text-sm text-gray-500'>{listDescription}</div>
|
||||
) : null}
|
||||
{selectedModelNames.length > 0 ? (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
borderRadius: 8,
|
||||
background: 'var(--semi-color-primary-light-default)',
|
||||
border: '1px solid var(--semi-color-primary)',
|
||||
color: 'var(--semi-color-primary)',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{t('已勾选 {{count}} 个模型', { count: selectedModelNames.length })}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'grid',
|
||||
gap: 16,
|
||||
gridTemplateColumns: isMobile
|
||||
? 'minmax(0, 1fr)'
|
||||
: 'minmax(360px, 1.1fr) minmax(420px, 1fr)',
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
bodyStyle={{ padding: 0 }}
|
||||
style={isMobile ? { order: 2 } : undefined}
|
||||
>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={pagedData}
|
||||
rowKey='name'
|
||||
rowSelection={rowSelection}
|
||||
pagination={{
|
||||
currentPage,
|
||||
pageSize: PAGE_SIZE,
|
||||
total: filteredModels.length,
|
||||
onPageChange: (page) => setCurrentPage(page),
|
||||
showTotal: true,
|
||||
showSizeChanger: false,
|
||||
}}
|
||||
empty={
|
||||
<div style={{ textAlign: 'center', padding: '20px' }}>
|
||||
{emptyTitle || t('暂无模型')}
|
||||
</div>
|
||||
}
|
||||
onRow={(record) => ({
|
||||
style: {
|
||||
background: selectedModelNames.includes(record.name)
|
||||
? 'var(--semi-color-success-light-default)'
|
||||
: record.name === selectedModelName
|
||||
? 'var(--semi-color-primary-light-default)'
|
||||
: undefined,
|
||||
boxShadow: selectedModelNames.includes(record.name)
|
||||
? 'inset 4px 0 0 var(--semi-color-success)'
|
||||
: record.name === selectedModelName
|
||||
? 'inset 4px 0 0 var(--semi-color-primary)'
|
||||
: undefined,
|
||||
transition: 'background 0.2s ease, box-shadow 0.2s ease',
|
||||
},
|
||||
onClick: () => setSelectedModelName(record.name),
|
||||
})}
|
||||
scroll={isMobile ? { x: 720 } : undefined}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
style={isMobile ? { order: 1 } : undefined}
|
||||
title={selectedModel ? selectedModel.name : t('模型计费编辑器')}
|
||||
headerExtraContent={
|
||||
selectedModel ? (
|
||||
<Tag color='blue'>
|
||||
{selectedModel.billingMode === 'per-request'
|
||||
? t('按次计费')
|
||||
: t('按量计费')}
|
||||
</Tag>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{!selectedModel ? (
|
||||
<Empty
|
||||
title={emptyTitle || t('暂无模型')}
|
||||
description={
|
||||
emptyDescription || t('请先新增模型或从左侧列表选择一个模型')
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
<div className='mb-4'>
|
||||
<div className='mb-2 font-medium text-gray-700'>
|
||||
{t('计费方式')}
|
||||
</div>
|
||||
<RadioGroup
|
||||
type='button'
|
||||
value={selectedModel.billingMode}
|
||||
onChange={(event) => handleBillingModeChange(event.target.value)}
|
||||
>
|
||||
<Radio value='per-token'>{t('按量计费')}</Radio>
|
||||
<Radio value='per-request'>{t('按次计费')}</Radio>
|
||||
</RadioGroup>
|
||||
<div className='mt-2 text-xs text-gray-500'>
|
||||
{t(
|
||||
'这个界面默认按价格填写,保存时会自动换算回后端需要的倍率 JSON。',
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedWarnings.length > 0 ? (
|
||||
<Card
|
||||
bodyStyle={{ padding: 12 }}
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
background: 'var(--semi-color-warning-light-default)',
|
||||
}}
|
||||
>
|
||||
<div className='font-medium mb-2'>{t('当前提示')}</div>
|
||||
{selectedWarnings.map((warning) => (
|
||||
<div key={warning} className='text-sm text-gray-700 mb-1'>
|
||||
{warning}
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{selectedModel.billingMode === 'per-request' ? (
|
||||
<PriceInput
|
||||
label={t('固定价格')}
|
||||
value={selectedModel.fixedPrice}
|
||||
placeholder={t('输入每次调用价格')}
|
||||
suffix={t('$/次')}
|
||||
onChange={(value) => handleNumericFieldChange('fixedPrice', value)}
|
||||
extraText={t('适合 MJ / 任务类等按次收费模型。')}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Card
|
||||
bodyStyle={{ padding: 16 }}
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
background: 'var(--semi-color-fill-0)',
|
||||
}}
|
||||
>
|
||||
<div className='font-medium mb-3'>{t('基础价格')}</div>
|
||||
<PriceInput
|
||||
label={t('输入价格')}
|
||||
value={selectedModel.inputPrice}
|
||||
placeholder={t('输入 $/1M tokens')}
|
||||
onChange={(value) => handleNumericFieldChange('inputPrice', value)}
|
||||
/>
|
||||
{selectedModel.completionRatioLocked ? (
|
||||
<Banner
|
||||
type='warning'
|
||||
bordered
|
||||
fullMode={false}
|
||||
closeIcon={null}
|
||||
style={{ marginBottom: 12 }}
|
||||
title={t('补全价格已锁定')}
|
||||
description={t(
|
||||
'该模型补全倍率由后端固定为 {{ratio}}。补全价格不能在这里修改。',
|
||||
{
|
||||
ratio: selectedModel.lockedCompletionRatio || '-',
|
||||
},
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
<PriceInput
|
||||
label={t('补全价格')}
|
||||
value={selectedModel.completionPrice}
|
||||
placeholder={t('输入 $/1M tokens')}
|
||||
onChange={(value) =>
|
||||
handleNumericFieldChange('completionPrice', value)
|
||||
}
|
||||
headerAction={
|
||||
<Switch
|
||||
size='small'
|
||||
checked={isOptionalFieldEnabled(
|
||||
selectedModel,
|
||||
'completionPrice',
|
||||
)}
|
||||
disabled={selectedModel.completionRatioLocked}
|
||||
onChange={(checked) =>
|
||||
handleOptionalFieldToggle('completionPrice', checked)
|
||||
}
|
||||
/>
|
||||
}
|
||||
hidden={
|
||||
!isOptionalFieldEnabled(selectedModel, 'completionPrice')
|
||||
}
|
||||
disabled={
|
||||
!hasValue(selectedModel.inputPrice) ||
|
||||
selectedModel.completionRatioLocked
|
||||
}
|
||||
extraText={
|
||||
selectedModel.completionRatioLocked
|
||||
? t(
|
||||
'后端固定倍率:{{ratio}}。该字段仅展示换算后的价格。',
|
||||
{
|
||||
ratio: selectedModel.lockedCompletionRatio || '-',
|
||||
},
|
||||
)
|
||||
: !isOptionalFieldEnabled(
|
||||
selectedModel,
|
||||
'completionPrice',
|
||||
)
|
||||
? t('当前未启用,需要时再打开即可。')
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
<PriceInput
|
||||
label={t('提示缓存价格')}
|
||||
value={selectedModel.cachePrice}
|
||||
placeholder={t('输入 $/1M tokens')}
|
||||
onChange={(value) => handleNumericFieldChange('cachePrice', value)}
|
||||
headerAction={
|
||||
<Switch
|
||||
size='small'
|
||||
checked={isOptionalFieldEnabled(selectedModel, 'cachePrice')}
|
||||
onChange={(checked) =>
|
||||
handleOptionalFieldToggle('cachePrice', checked)
|
||||
}
|
||||
/>
|
||||
}
|
||||
hidden={!isOptionalFieldEnabled(selectedModel, 'cachePrice')}
|
||||
disabled={!hasValue(selectedModel.inputPrice)}
|
||||
extraText={
|
||||
!isOptionalFieldEnabled(selectedModel, 'cachePrice')
|
||||
? t('当前未启用,需要时再打开即可。')
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
<PriceInput
|
||||
label={t('缓存创建价格')}
|
||||
value={selectedModel.createCachePrice}
|
||||
placeholder={t('输入 $/1M tokens')}
|
||||
onChange={(value) =>
|
||||
handleNumericFieldChange('createCachePrice', value)
|
||||
}
|
||||
headerAction={
|
||||
<Switch
|
||||
size='small'
|
||||
checked={isOptionalFieldEnabled(
|
||||
selectedModel,
|
||||
'createCachePrice',
|
||||
)}
|
||||
onChange={(checked) =>
|
||||
handleOptionalFieldToggle('createCachePrice', checked)
|
||||
}
|
||||
/>
|
||||
}
|
||||
hidden={
|
||||
!isOptionalFieldEnabled(selectedModel, 'createCachePrice')
|
||||
}
|
||||
disabled={!hasValue(selectedModel.inputPrice)}
|
||||
extraText={
|
||||
!isOptionalFieldEnabled(
|
||||
selectedModel,
|
||||
'createCachePrice',
|
||||
)
|
||||
? t('当前未启用,需要时再打开即可。')
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
bodyStyle={{ padding: 16 }}
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
background: 'var(--semi-color-fill-0)',
|
||||
}}
|
||||
>
|
||||
<div className='mb-3'>
|
||||
<div className='font-medium'>{t('扩展价格')}</div>
|
||||
<div className='text-xs text-gray-500 mt-1'>
|
||||
{t('这些价格都是可选项,不填也可以。')}
|
||||
</div>
|
||||
</div>
|
||||
<PriceInput
|
||||
label={t('图片输入价格')}
|
||||
value={selectedModel.imagePrice}
|
||||
placeholder={t('输入 $/1M tokens')}
|
||||
onChange={(value) => handleNumericFieldChange('imagePrice', value)}
|
||||
headerAction={
|
||||
<Switch
|
||||
size='small'
|
||||
checked={isOptionalFieldEnabled(selectedModel, 'imagePrice')}
|
||||
onChange={(checked) =>
|
||||
handleOptionalFieldToggle('imagePrice', checked)
|
||||
}
|
||||
/>
|
||||
}
|
||||
hidden={!isOptionalFieldEnabled(selectedModel, 'imagePrice')}
|
||||
disabled={!hasValue(selectedModel.inputPrice)}
|
||||
extraText={
|
||||
!isOptionalFieldEnabled(selectedModel, 'imagePrice')
|
||||
? t('当前未启用,需要时再打开即可。')
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
<PriceInput
|
||||
label={t('音频输入价格')}
|
||||
value={selectedModel.audioInputPrice}
|
||||
placeholder={t('输入 $/1M tokens')}
|
||||
onChange={(value) =>
|
||||
handleNumericFieldChange('audioInputPrice', value)
|
||||
}
|
||||
headerAction={
|
||||
<Switch
|
||||
size='small'
|
||||
checked={isOptionalFieldEnabled(
|
||||
selectedModel,
|
||||
'audioInputPrice',
|
||||
)}
|
||||
onChange={(checked) =>
|
||||
handleOptionalFieldToggle('audioInputPrice', checked)
|
||||
}
|
||||
/>
|
||||
}
|
||||
hidden={!isOptionalFieldEnabled(selectedModel, 'audioInputPrice')}
|
||||
disabled={!hasValue(selectedModel.inputPrice)}
|
||||
extraText={
|
||||
!isOptionalFieldEnabled(
|
||||
selectedModel,
|
||||
'audioInputPrice',
|
||||
)
|
||||
? t('当前未启用,需要时再打开即可。')
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
<PriceInput
|
||||
label={t('音频补全价格')}
|
||||
value={selectedModel.audioOutputPrice}
|
||||
placeholder={t('输入 $/1M tokens')}
|
||||
onChange={(value) =>
|
||||
handleNumericFieldChange('audioOutputPrice', value)
|
||||
}
|
||||
headerAction={
|
||||
<Switch
|
||||
size='small'
|
||||
checked={isOptionalFieldEnabled(
|
||||
selectedModel,
|
||||
'audioOutputPrice',
|
||||
)}
|
||||
disabled={!isOptionalFieldEnabled(
|
||||
selectedModel,
|
||||
'audioInputPrice',
|
||||
)}
|
||||
onChange={(checked) =>
|
||||
handleOptionalFieldToggle('audioOutputPrice', checked)
|
||||
}
|
||||
/>
|
||||
}
|
||||
hidden={
|
||||
!isOptionalFieldEnabled(selectedModel, 'audioOutputPrice')
|
||||
}
|
||||
disabled={!hasValue(selectedModel.audioInputPrice)}
|
||||
extraText={
|
||||
!isOptionalFieldEnabled(
|
||||
selectedModel,
|
||||
'audioInputPrice',
|
||||
)
|
||||
? t('请先开启并填写音频输入价格。')
|
||||
: !isOptionalFieldEnabled(
|
||||
selectedModel,
|
||||
'audioOutputPrice',
|
||||
)
|
||||
? t('当前未启用,需要时再打开即可。')
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Card
|
||||
bodyStyle={{ padding: 16 }}
|
||||
style={{ background: 'var(--semi-color-fill-0)' }}
|
||||
>
|
||||
<div className='font-medium mb-3'>{t('保存预览')}</div>
|
||||
<div className='text-xs text-gray-500 mb-3'>
|
||||
{t(
|
||||
'下面展示这个模型保存后会写入哪些后端字段,便于和原始 JSON 编辑框保持一致。',
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(140px, 180px) 1fr',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{previewRows.map((row) => (
|
||||
<React.Fragment key={row.key}>
|
||||
<Text strong>{row.label}</Text>
|
||||
<Text>{row.value}</Text>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</Space>
|
||||
|
||||
{allowAddModel ? (
|
||||
<Modal
|
||||
title={t('添加模型')}
|
||||
visible={addVisible}
|
||||
onCancel={() => {
|
||||
setAddVisible(false);
|
||||
setNewModelName('');
|
||||
}}
|
||||
onOk={handleAddModel}
|
||||
>
|
||||
<Input
|
||||
value={newModelName}
|
||||
placeholder={t('输入模型名称,例如 gpt-4.1')}
|
||||
onChange={(value) => setNewModelName(value)}
|
||||
/>
|
||||
</Modal>
|
||||
) : null}
|
||||
|
||||
<Modal
|
||||
title={t('批量应用当前模型价格')}
|
||||
visible={batchVisible}
|
||||
onCancel={() => setBatchVisible(false)}
|
||||
onOk={() => {
|
||||
if (applySelectedModelPricing()) {
|
||||
setBatchVisible(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className='text-sm text-gray-600'>
|
||||
{selectedModel
|
||||
? t(
|
||||
'将把当前编辑中的模型 {{name}} 的价格配置,批量应用到已勾选的 {{count}} 个模型。',
|
||||
{
|
||||
name: selectedModel.name,
|
||||
count: selectedModelNames.length,
|
||||
},
|
||||
)
|
||||
: t('请先选择一个作为模板的模型')}
|
||||
</div>
|
||||
{selectedModel ? (
|
||||
<div className='text-xs text-gray-500 mt-3'>
|
||||
{t(
|
||||
'适合同系列模型一起定价,例如把 gpt-5.1 的价格批量同步到 gpt-5.1-high、gpt-5.1-low 等模型。',
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
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