Spaces:
Runtime error
Runtime error
| /* | |
| 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> | |
| </> | |
| ); | |
| } | |