初始化导入 new-api 源码
This commit is contained in:
329
web/src/components/table/channels/ChannelsActions.jsx
Normal file
329
web/src/components/table/channels/ChannelsActions.jsx
Normal file
@@ -0,0 +1,329 @@
|
||||
/*
|
||||
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 from 'react';
|
||||
import {
|
||||
Button,
|
||||
Dropdown,
|
||||
Modal,
|
||||
Switch,
|
||||
Typography,
|
||||
Select,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import CompactModeToggle from '../../common/ui/CompactModeToggle';
|
||||
|
||||
const ChannelsActions = ({
|
||||
enableBatchDelete,
|
||||
batchDeleteChannels,
|
||||
setShowBatchSetTag,
|
||||
testAllChannels,
|
||||
fixChannelsAbilities,
|
||||
updateAllChannelsBalance,
|
||||
deleteAllDisabledChannels,
|
||||
applyAllUpstreamUpdates,
|
||||
detectAllUpstreamUpdates,
|
||||
detectAllUpstreamUpdatesLoading,
|
||||
applyAllUpstreamUpdatesLoading,
|
||||
compactMode,
|
||||
setCompactMode,
|
||||
idSort,
|
||||
setIdSort,
|
||||
setEnableBatchDelete,
|
||||
enableTagMode,
|
||||
setEnableTagMode,
|
||||
statusFilter,
|
||||
setStatusFilter,
|
||||
getFormValues,
|
||||
loadChannels,
|
||||
searchChannels,
|
||||
activeTypeKey,
|
||||
activePage,
|
||||
pageSize,
|
||||
setActivePage,
|
||||
t,
|
||||
}) => {
|
||||
return (
|
||||
<div className='flex flex-col gap-2'>
|
||||
{/* 第一行:批量操作按钮 + 设置开关 */}
|
||||
<div className='flex flex-col md:flex-row justify-between gap-2'>
|
||||
{/* 左侧:批量操作按钮 */}
|
||||
<div className='flex flex-wrap md:flex-nowrap items-center gap-2 w-full md:w-auto order-2 md:order-1'>
|
||||
<Button
|
||||
size='small'
|
||||
disabled={!enableBatchDelete}
|
||||
type='danger'
|
||||
className='w-full md:w-auto'
|
||||
onClick={() => {
|
||||
Modal.confirm({
|
||||
title: t('确定是否要删除所选通道?'),
|
||||
content: t('此修改将不可逆'),
|
||||
onOk: () => batchDeleteChannels(),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('删除所选通道')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size='small'
|
||||
disabled={!enableBatchDelete}
|
||||
type='tertiary'
|
||||
onClick={() => setShowBatchSetTag(true)}
|
||||
className='w-full md:w-auto'
|
||||
>
|
||||
{t('批量设置标签')}
|
||||
</Button>
|
||||
|
||||
<Dropdown
|
||||
size='small'
|
||||
trigger='click'
|
||||
render={
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Item>
|
||||
<Button
|
||||
size='small'
|
||||
type='tertiary'
|
||||
className='w-full'
|
||||
loading={detectAllUpstreamUpdatesLoading}
|
||||
disabled={detectAllUpstreamUpdatesLoading}
|
||||
onClick={() => {
|
||||
Modal.confirm({
|
||||
title: t('确定?'),
|
||||
content: t('确定要测试所有未手动禁用渠道吗?'),
|
||||
onOk: () => testAllChannels(),
|
||||
size: 'small',
|
||||
centered: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('测试所有未手动禁用渠道')}
|
||||
</Button>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item>
|
||||
<Button
|
||||
size='small'
|
||||
className='w-full'
|
||||
onClick={() => {
|
||||
Modal.confirm({
|
||||
title: t('确定是否要修复数据库一致性?'),
|
||||
content: t(
|
||||
'进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用',
|
||||
),
|
||||
onOk: () => fixChannelsAbilities(),
|
||||
size: 'sm',
|
||||
centered: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('修复数据库一致性')}
|
||||
</Button>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item>
|
||||
<Button
|
||||
size='small'
|
||||
type='secondary'
|
||||
className='w-full'
|
||||
onClick={() => {
|
||||
Modal.confirm({
|
||||
title: t('确定?'),
|
||||
content: t('确定要更新所有已启用通道余额吗?'),
|
||||
onOk: () => updateAllChannelsBalance(),
|
||||
size: 'sm',
|
||||
centered: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('更新所有已启用通道余额')}
|
||||
</Button>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item>
|
||||
<Button
|
||||
size='small'
|
||||
type='tertiary'
|
||||
className='w-full'
|
||||
onClick={() => {
|
||||
Modal.confirm({
|
||||
title: t('确定?'),
|
||||
content: t(
|
||||
'确定要仅检测全部渠道上游模型更新吗?(不执行新增/删除)',
|
||||
),
|
||||
onOk: () => detectAllUpstreamUpdates(),
|
||||
size: 'sm',
|
||||
centered: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('检测全部渠道上游更新')}
|
||||
</Button>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item>
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
className='w-full'
|
||||
loading={applyAllUpstreamUpdatesLoading}
|
||||
disabled={applyAllUpstreamUpdatesLoading}
|
||||
onClick={() => {
|
||||
Modal.confirm({
|
||||
title: t('确定?'),
|
||||
content: t('确定要对全部渠道执行上游模型更新吗?'),
|
||||
onOk: () => applyAllUpstreamUpdates(),
|
||||
size: 'sm',
|
||||
centered: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('处理全部渠道上游更新')}
|
||||
</Button>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item>
|
||||
<Button
|
||||
size='small'
|
||||
type='danger'
|
||||
className='w-full'
|
||||
onClick={() => {
|
||||
Modal.confirm({
|
||||
title: t('确定是否要删除禁用通道?'),
|
||||
content: t('此修改将不可逆'),
|
||||
onOk: () => deleteAllDisabledChannels(),
|
||||
size: 'sm',
|
||||
centered: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('删除禁用通道')}
|
||||
</Button>
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
size='small'
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
className='w-full md:w-auto'
|
||||
>
|
||||
{t('批量操作')}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
|
||||
<CompactModeToggle
|
||||
compactMode={compactMode}
|
||||
setCompactMode={setCompactMode}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 右侧:设置开关区域 */}
|
||||
<div className='flex flex-col md:flex-row items-start md:items-center gap-2 w-full md:w-auto order-1 md:order-2'>
|
||||
<div className='flex items-center justify-between w-full md:w-auto'>
|
||||
<Typography.Text strong className='mr-2'>
|
||||
{t('使用ID排序')}
|
||||
</Typography.Text>
|
||||
<Switch
|
||||
size='small'
|
||||
checked={idSort}
|
||||
onChange={(v) => {
|
||||
localStorage.setItem('id-sort', v + '');
|
||||
setIdSort(v);
|
||||
const { searchKeyword, searchGroup, searchModel } =
|
||||
getFormValues();
|
||||
if (
|
||||
searchKeyword === '' &&
|
||||
searchGroup === '' &&
|
||||
searchModel === ''
|
||||
) {
|
||||
loadChannels(activePage, pageSize, v, enableTagMode);
|
||||
} else {
|
||||
searchChannels(
|
||||
enableTagMode,
|
||||
activeTypeKey,
|
||||
statusFilter,
|
||||
activePage,
|
||||
pageSize,
|
||||
v,
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between w-full md:w-auto'>
|
||||
<Typography.Text strong className='mr-2'>
|
||||
{t('开启批量操作')}
|
||||
</Typography.Text>
|
||||
<Switch
|
||||
size='small'
|
||||
checked={enableBatchDelete}
|
||||
onChange={(v) => {
|
||||
localStorage.setItem('enable-batch-delete', v + '');
|
||||
setEnableBatchDelete(v);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between w-full md:w-auto'>
|
||||
<Typography.Text strong className='mr-2'>
|
||||
{t('标签聚合模式')}
|
||||
</Typography.Text>
|
||||
<Switch
|
||||
size='small'
|
||||
checked={enableTagMode}
|
||||
onChange={(v) => {
|
||||
localStorage.setItem('enable-tag-mode', v + '');
|
||||
setEnableTagMode(v);
|
||||
setActivePage(1);
|
||||
loadChannels(1, pageSize, idSort, v);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between w-full md:w-auto'>
|
||||
<Typography.Text strong className='mr-2'>
|
||||
{t('状态筛选')}
|
||||
</Typography.Text>
|
||||
<Select
|
||||
size='small'
|
||||
value={statusFilter}
|
||||
onChange={(v) => {
|
||||
localStorage.setItem('channel-status-filter', v);
|
||||
setStatusFilter(v);
|
||||
setActivePage(1);
|
||||
loadChannels(
|
||||
1,
|
||||
pageSize,
|
||||
idSort,
|
||||
enableTagMode,
|
||||
activeTypeKey,
|
||||
v,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Select.Option value='all'>{t('全部')}</Select.Option>
|
||||
<Select.Option value='enabled'>{t('已启用')}</Select.Option>
|
||||
<Select.Option value='disabled'>{t('已禁用')}</Select.Option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelsActions;
|
||||
906
web/src/components/table/channels/ChannelsColumnDefs.jsx
Normal file
906
web/src/components/table/channels/ChannelsColumnDefs.jsx
Normal file
@@ -0,0 +1,906 @@
|
||||
/*
|
||||
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 from 'react';
|
||||
import {
|
||||
Button,
|
||||
Dropdown,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Space,
|
||||
SplitButtonGroup,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
timestamp2string,
|
||||
renderGroup,
|
||||
renderQuota,
|
||||
getChannelIcon,
|
||||
renderQuotaWithAmount,
|
||||
showSuccess,
|
||||
showError,
|
||||
showInfo,
|
||||
} from '../../../helpers';
|
||||
import {
|
||||
CHANNEL_OPTIONS,
|
||||
MODEL_FETCHABLE_CHANNEL_TYPES,
|
||||
} from '../../../constants';
|
||||
import { parseUpstreamUpdateMeta } from '../../../hooks/channels/upstreamUpdateUtils';
|
||||
import {
|
||||
IconTreeTriangleDown,
|
||||
IconMore,
|
||||
IconAlertTriangle,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { FaRandom } from 'react-icons/fa';
|
||||
|
||||
// Render functions
|
||||
const renderType = (type, record = {}, t) => {
|
||||
const channelInfo = record?.channel_info;
|
||||
let type2label = new Map();
|
||||
for (let i = 0; i < CHANNEL_OPTIONS.length; i++) {
|
||||
type2label[CHANNEL_OPTIONS[i].value] = CHANNEL_OPTIONS[i];
|
||||
}
|
||||
type2label[0] = { value: 0, label: t('未知类型'), color: 'grey' };
|
||||
|
||||
let icon = getChannelIcon(type);
|
||||
|
||||
if (channelInfo?.is_multi_key) {
|
||||
icon =
|
||||
channelInfo?.multi_key_mode === 'random' ? (
|
||||
<div className='flex items-center gap-1'>
|
||||
<FaRandom className='text-blue-500' />
|
||||
{icon}
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex items-center gap-1'>
|
||||
<IconTreeTriangleDown className='text-blue-500' />
|
||||
{icon}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const typeTag = (
|
||||
<Tag color={type2label[type]?.color} shape='circle' prefixIcon={icon}>
|
||||
{type2label[type]?.label}
|
||||
</Tag>
|
||||
);
|
||||
|
||||
let ionetMeta = null;
|
||||
if (record?.other_info) {
|
||||
try {
|
||||
const parsed = JSON.parse(record.other_info);
|
||||
if (parsed && typeof parsed === 'object' && parsed.source === 'ionet') {
|
||||
ionetMeta = parsed;
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore invalid metadata
|
||||
}
|
||||
}
|
||||
|
||||
if (!ionetMeta) {
|
||||
return typeTag;
|
||||
}
|
||||
|
||||
const handleNavigate = (event) => {
|
||||
event?.stopPropagation?.();
|
||||
if (!ionetMeta?.deployment_id) {
|
||||
return;
|
||||
}
|
||||
const targetUrl = `/console/deployment?deployment_id=${ionetMeta.deployment_id}`;
|
||||
window.open(targetUrl, '_blank', 'noopener');
|
||||
};
|
||||
|
||||
return (
|
||||
<Space spacing={6}>
|
||||
{typeTag}
|
||||
<Tooltip
|
||||
content={
|
||||
<div className='max-w-xs'>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('来源于 IO.NET 部署')}
|
||||
</div>
|
||||
{ionetMeta?.deployment_id && (
|
||||
<div className='text-xs text-gray-500 mt-1'>
|
||||
{t('部署 ID')}: {ionetMeta.deployment_id}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<Tag
|
||||
color='purple'
|
||||
type='light'
|
||||
className='cursor-pointer'
|
||||
onClick={handleNavigate}
|
||||
>
|
||||
IO.NET
|
||||
</Tag>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
const renderTagType = (t) => {
|
||||
return (
|
||||
<Tag color='light-blue' shape='circle' type='light'>
|
||||
{t('标签聚合')}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
const renderStatus = (status, channelInfo = undefined, t) => {
|
||||
if (channelInfo) {
|
||||
if (channelInfo.is_multi_key) {
|
||||
let keySize = channelInfo.multi_key_size;
|
||||
let enabledKeySize = keySize;
|
||||
if (channelInfo.multi_key_status_list) {
|
||||
enabledKeySize =
|
||||
keySize - Object.keys(channelInfo.multi_key_status_list).length;
|
||||
}
|
||||
return renderMultiKeyStatus(status, keySize, enabledKeySize, t);
|
||||
}
|
||||
}
|
||||
switch (status) {
|
||||
case 1:
|
||||
return (
|
||||
<Tag color='green' shape='circle'>
|
||||
{t('已启用')}
|
||||
</Tag>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<Tag color='red' shape='circle'>
|
||||
{t('已禁用')}
|
||||
</Tag>
|
||||
);
|
||||
case 3:
|
||||
return (
|
||||
<Tag color='yellow' shape='circle'>
|
||||
{t('自动禁用')}
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Tag color='grey' shape='circle'>
|
||||
{t('未知状态')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const renderMultiKeyStatus = (status, keySize, enabledKeySize, t) => {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return (
|
||||
<Tag color='green' shape='circle'>
|
||||
{t('已启用')} {enabledKeySize}/{keySize}
|
||||
</Tag>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<Tag color='red' shape='circle'>
|
||||
{t('已禁用')} {enabledKeySize}/{keySize}
|
||||
</Tag>
|
||||
);
|
||||
case 3:
|
||||
return (
|
||||
<Tag color='yellow' shape='circle'>
|
||||
{t('自动禁用')} {enabledKeySize}/{keySize}
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Tag color='grey' shape='circle'>
|
||||
{t('未知状态')} {enabledKeySize}/{keySize}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const renderResponseTime = (responseTime, t) => {
|
||||
let time = responseTime / 1000;
|
||||
time = time.toFixed(2) + t(' 秒');
|
||||
if (responseTime === 0) {
|
||||
return (
|
||||
<Tag color='grey' shape='circle'>
|
||||
{t('未测试')}
|
||||
</Tag>
|
||||
);
|
||||
} else if (responseTime <= 1000) {
|
||||
return (
|
||||
<Tag color='green' shape='circle'>
|
||||
{time}
|
||||
</Tag>
|
||||
);
|
||||
} else if (responseTime <= 3000) {
|
||||
return (
|
||||
<Tag color='lime' shape='circle'>
|
||||
{time}
|
||||
</Tag>
|
||||
);
|
||||
} else if (responseTime <= 5000) {
|
||||
return (
|
||||
<Tag color='yellow' shape='circle'>
|
||||
{time}
|
||||
</Tag>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Tag color='red' shape='circle'>
|
||||
{time}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const isRequestPassThroughEnabled = (record) => {
|
||||
if (!record || record.children !== undefined) {
|
||||
return false;
|
||||
}
|
||||
const settingValue = record.setting;
|
||||
if (!settingValue) {
|
||||
return false;
|
||||
}
|
||||
if (typeof settingValue === 'object') {
|
||||
return settingValue.pass_through_body_enabled === true;
|
||||
}
|
||||
if (typeof settingValue !== 'string') {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(settingValue);
|
||||
return parsed?.pass_through_body_enabled === true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const getUpstreamUpdateMeta = (record) => {
|
||||
const supported =
|
||||
!!record &&
|
||||
record.children === undefined &&
|
||||
MODEL_FETCHABLE_CHANNEL_TYPES.has(record.type);
|
||||
if (!record || record.children !== undefined) {
|
||||
return {
|
||||
supported: false,
|
||||
enabled: false,
|
||||
pendingAddModels: [],
|
||||
pendingRemoveModels: [],
|
||||
};
|
||||
}
|
||||
const parsed =
|
||||
record?.upstreamUpdateMeta && typeof record.upstreamUpdateMeta === 'object'
|
||||
? record.upstreamUpdateMeta
|
||||
: parseUpstreamUpdateMeta(record?.settings);
|
||||
return {
|
||||
supported,
|
||||
enabled: parsed?.enabled === true,
|
||||
pendingAddModels: Array.isArray(parsed?.pendingAddModels)
|
||||
? parsed.pendingAddModels
|
||||
: [],
|
||||
pendingRemoveModels: Array.isArray(parsed?.pendingRemoveModels)
|
||||
? parsed.pendingRemoveModels
|
||||
: [],
|
||||
};
|
||||
};
|
||||
|
||||
export const getChannelsColumns = ({
|
||||
t,
|
||||
COLUMN_KEYS,
|
||||
updateChannelBalance,
|
||||
manageChannel,
|
||||
manageTag,
|
||||
submitTagEdit,
|
||||
testChannel,
|
||||
setCurrentTestChannel,
|
||||
setShowModelTestModal,
|
||||
setEditingChannel,
|
||||
setShowEdit,
|
||||
setShowEditTag,
|
||||
setEditingTag,
|
||||
copySelectedChannel,
|
||||
refresh,
|
||||
activePage,
|
||||
channels,
|
||||
checkOllamaVersion,
|
||||
setShowMultiKeyManageModal,
|
||||
setCurrentMultiKeyChannel,
|
||||
openUpstreamUpdateModal,
|
||||
detectChannelUpstreamUpdates,
|
||||
}) => {
|
||||
return [
|
||||
{
|
||||
key: COLUMN_KEYS.ID,
|
||||
title: t('ID'),
|
||||
dataIndex: 'id',
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.NAME,
|
||||
title: t('名称'),
|
||||
dataIndex: 'name',
|
||||
render: (text, record, index) => {
|
||||
const passThroughEnabled = isRequestPassThroughEnabled(record);
|
||||
const upstreamUpdateMeta = getUpstreamUpdateMeta(record);
|
||||
const pendingAddCount = upstreamUpdateMeta.pendingAddModels.length;
|
||||
const pendingRemoveCount =
|
||||
upstreamUpdateMeta.pendingRemoveModels.length;
|
||||
const showUpstreamUpdateTag =
|
||||
upstreamUpdateMeta.supported &&
|
||||
upstreamUpdateMeta.enabled &&
|
||||
(pendingAddCount > 0 || pendingRemoveCount > 0);
|
||||
const nameNode =
|
||||
record.remark && record.remark.trim() !== '' ? (
|
||||
<Tooltip
|
||||
content={
|
||||
<div className='flex flex-col gap-2 max-w-xs'>
|
||||
<div className='text-sm'>{record.remark}</div>
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard
|
||||
.writeText(record.remark)
|
||||
.then(() => {
|
||||
showSuccess(t('复制成功'));
|
||||
})
|
||||
.catch(() => {
|
||||
showError(t('复制失败'));
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('复制')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
trigger='hover'
|
||||
position='topLeft'
|
||||
>
|
||||
<span>{text}</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span>{text}</span>
|
||||
);
|
||||
|
||||
if (!passThroughEnabled && !showUpstreamUpdateTag) {
|
||||
return nameNode;
|
||||
}
|
||||
|
||||
return (
|
||||
<Space spacing={6} align='center'>
|
||||
{nameNode}
|
||||
{passThroughEnabled && (
|
||||
<Tooltip
|
||||
content={t(
|
||||
'该渠道已开启请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。',
|
||||
)}
|
||||
trigger='hover'
|
||||
position='topLeft'
|
||||
>
|
||||
<span className='inline-flex items-center'>
|
||||
<IconAlertTriangle
|
||||
style={{ color: 'var(--semi-color-warning)' }}
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{showUpstreamUpdateTag && (
|
||||
<Space spacing={4} align='center'>
|
||||
{pendingAddCount > 0 ? (
|
||||
<Tooltip content={t('点击处理新增模型')} position='top'>
|
||||
<Tag
|
||||
color='green'
|
||||
type='light'
|
||||
size='small'
|
||||
shape='circle'
|
||||
className='cursor-pointer transition-all duration-150 hover:opacity-85 hover:-translate-y-px active:scale-95'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openUpstreamUpdateModal(
|
||||
record,
|
||||
upstreamUpdateMeta.pendingAddModels,
|
||||
upstreamUpdateMeta.pendingRemoveModels,
|
||||
'add',
|
||||
);
|
||||
}}
|
||||
>
|
||||
+{pendingAddCount}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{pendingRemoveCount > 0 ? (
|
||||
<Tooltip content={t('点击处理删除模型')} position='top'>
|
||||
<Tag
|
||||
color='red'
|
||||
type='light'
|
||||
size='small'
|
||||
shape='circle'
|
||||
className='cursor-pointer transition-all duration-150 hover:opacity-85 hover:-translate-y-px active:scale-95'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openUpstreamUpdateModal(
|
||||
record,
|
||||
upstreamUpdateMeta.pendingAddModels,
|
||||
upstreamUpdateMeta.pendingRemoveModels,
|
||||
'remove',
|
||||
);
|
||||
}}
|
||||
>
|
||||
-{pendingRemoveCount}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Space>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.GROUP,
|
||||
title: t('分组'),
|
||||
dataIndex: 'group',
|
||||
render: (text, record, index) => (
|
||||
<div>
|
||||
<Space spacing={2}>
|
||||
{text
|
||||
?.split(',')
|
||||
.sort((a, b) => {
|
||||
if (a === 'default') return -1;
|
||||
if (b === 'default') return 1;
|
||||
return a.localeCompare(b);
|
||||
})
|
||||
.map((item, index) => renderGroup(item))}
|
||||
</Space>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.TYPE,
|
||||
title: t('类型'),
|
||||
dataIndex: 'type',
|
||||
render: (text, record, index) => {
|
||||
if (record.children === undefined) {
|
||||
return <>{renderType(text, record, t)}</>;
|
||||
} else {
|
||||
return <>{renderTagType(t)}</>;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.STATUS,
|
||||
title: t('状态'),
|
||||
dataIndex: 'status',
|
||||
render: (text, record, index) => {
|
||||
if (text === 3) {
|
||||
if (record.other_info === '') {
|
||||
record.other_info = '{}';
|
||||
}
|
||||
let otherInfo = JSON.parse(record.other_info);
|
||||
let reason = otherInfo['status_reason'];
|
||||
let time = otherInfo['status_time'];
|
||||
return (
|
||||
<div>
|
||||
<Tooltip
|
||||
content={
|
||||
t('原因:') + reason + t(',时间:') + timestamp2string(time)
|
||||
}
|
||||
>
|
||||
{renderStatus(text, record.channel_info, t)}
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return renderStatus(text, record.channel_info, t);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.RESPONSE_TIME,
|
||||
title: t('响应时间'),
|
||||
dataIndex: 'response_time',
|
||||
render: (text, record, index) => <div>{renderResponseTime(text, t)}</div>,
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.BALANCE,
|
||||
title: t('已用/剩余'),
|
||||
dataIndex: 'expired_time',
|
||||
render: (text, record, index) => {
|
||||
if (record.children === undefined) {
|
||||
return (
|
||||
<div>
|
||||
<Space spacing={1}>
|
||||
<Tooltip content={t('已用额度')}>
|
||||
<Tag color='white' type='ghost' shape='circle'>
|
||||
{renderQuota(record.used_quota)}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={
|
||||
record.type === 57
|
||||
? t('查看 Codex 帐号信息与用量')
|
||||
: t('剩余额度') +
|
||||
': ' +
|
||||
renderQuotaWithAmount(record.balance) +
|
||||
t(',点击更新')
|
||||
}
|
||||
>
|
||||
<Tag
|
||||
color={record.type === 57 ? 'light-blue' : 'white'}
|
||||
type={record.type === 57 ? 'light' : 'ghost'}
|
||||
shape='circle'
|
||||
className={record.type === 57 ? 'cursor-pointer' : ''}
|
||||
onClick={() => updateChannelBalance(record)}
|
||||
>
|
||||
{record.type === 57
|
||||
? t('帐号信息')
|
||||
: renderQuotaWithAmount(record.balance)}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Tooltip content={t('已用额度')}>
|
||||
<Tag color='white' type='ghost' shape='circle'>
|
||||
{renderQuota(record.used_quota)}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.PRIORITY,
|
||||
title: t('优先级'),
|
||||
dataIndex: 'priority',
|
||||
render: (text, record, index) => {
|
||||
if (record.children === undefined) {
|
||||
return (
|
||||
<div>
|
||||
<InputNumber
|
||||
style={{ width: 70 }}
|
||||
name='priority'
|
||||
onBlur={(e) => {
|
||||
manageChannel(record.id, 'priority', record, e.target.value);
|
||||
}}
|
||||
keepFocus={true}
|
||||
innerButtons
|
||||
defaultValue={record.priority}
|
||||
min={-999}
|
||||
size='small'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<InputNumber
|
||||
style={{ width: 70 }}
|
||||
name='priority'
|
||||
keepFocus={true}
|
||||
onBlur={(e) => {
|
||||
Modal.warning({
|
||||
title: t('修改子渠道优先级'),
|
||||
content:
|
||||
t('确定要修改所有子渠道优先级为 ') +
|
||||
e.target.value +
|
||||
t(' 吗?'),
|
||||
onOk: () => {
|
||||
if (e.target.value === '') {
|
||||
return;
|
||||
}
|
||||
submitTagEdit('priority', {
|
||||
tag: record.key,
|
||||
priority: e.target.value,
|
||||
});
|
||||
},
|
||||
});
|
||||
}}
|
||||
innerButtons
|
||||
defaultValue={record.priority}
|
||||
min={-999}
|
||||
size='small'
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.WEIGHT,
|
||||
title: t('权重'),
|
||||
dataIndex: 'weight',
|
||||
render: (text, record, index) => {
|
||||
if (record.children === undefined) {
|
||||
return (
|
||||
<div>
|
||||
<InputNumber
|
||||
style={{ width: 70 }}
|
||||
name='weight'
|
||||
onBlur={(e) => {
|
||||
manageChannel(record.id, 'weight', record, e.target.value);
|
||||
}}
|
||||
keepFocus={true}
|
||||
innerButtons
|
||||
defaultValue={record.weight}
|
||||
min={0}
|
||||
size='small'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<InputNumber
|
||||
style={{ width: 70 }}
|
||||
name='weight'
|
||||
keepFocus={true}
|
||||
onBlur={(e) => {
|
||||
Modal.warning({
|
||||
title: t('修改子渠道权重'),
|
||||
content:
|
||||
t('确定要修改所有子渠道权重为 ') +
|
||||
e.target.value +
|
||||
t(' 吗?'),
|
||||
onOk: () => {
|
||||
if (e.target.value === '') {
|
||||
return;
|
||||
}
|
||||
submitTagEdit('weight', {
|
||||
tag: record.key,
|
||||
weight: e.target.value,
|
||||
});
|
||||
},
|
||||
});
|
||||
}}
|
||||
innerButtons
|
||||
defaultValue={record.weight}
|
||||
min={-999}
|
||||
size='small'
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.OPERATE,
|
||||
title: '',
|
||||
dataIndex: 'operate',
|
||||
fixed: 'right',
|
||||
render: (text, record, index) => {
|
||||
if (record.children === undefined) {
|
||||
const upstreamUpdateMeta = getUpstreamUpdateMeta(record);
|
||||
const moreMenuItems = [
|
||||
{
|
||||
node: 'item',
|
||||
name: t('删除'),
|
||||
type: 'danger',
|
||||
onClick: () => {
|
||||
Modal.confirm({
|
||||
title: t('确定是否要删除此渠道?'),
|
||||
content: t('此修改将不可逆'),
|
||||
onOk: () => {
|
||||
(async () => {
|
||||
await manageChannel(record.id, 'delete', record);
|
||||
await refresh();
|
||||
setTimeout(() => {
|
||||
if (channels.length === 0 && activePage > 1) {
|
||||
refresh(activePage - 1);
|
||||
}
|
||||
}, 100);
|
||||
})();
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
node: 'item',
|
||||
name: t('复制'),
|
||||
type: 'tertiary',
|
||||
onClick: () => {
|
||||
Modal.confirm({
|
||||
title: t('确定是否要复制此渠道?'),
|
||||
content: t('复制渠道的所有信息'),
|
||||
onOk: () => copySelectedChannel(record),
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (upstreamUpdateMeta.supported) {
|
||||
moreMenuItems.push({
|
||||
node: 'item',
|
||||
name: t('仅检测上游模型更新'),
|
||||
type: 'tertiary',
|
||||
onClick: () => {
|
||||
detectChannelUpstreamUpdates(record);
|
||||
},
|
||||
});
|
||||
moreMenuItems.push({
|
||||
node: 'item',
|
||||
name: t('处理上游模型更新'),
|
||||
type: 'tertiary',
|
||||
onClick: () => {
|
||||
if (!upstreamUpdateMeta.enabled) {
|
||||
showInfo(t('该渠道未开启上游模型更新检测'));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
upstreamUpdateMeta.pendingAddModels.length === 0 &&
|
||||
upstreamUpdateMeta.pendingRemoveModels.length === 0
|
||||
) {
|
||||
showInfo(t('该渠道暂无可处理的上游模型更新'));
|
||||
return;
|
||||
}
|
||||
openUpstreamUpdateModal(
|
||||
record,
|
||||
upstreamUpdateMeta.pendingAddModels,
|
||||
upstreamUpdateMeta.pendingRemoveModels,
|
||||
upstreamUpdateMeta.pendingAddModels.length > 0
|
||||
? 'add'
|
||||
: 'remove',
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (record.type === 4) {
|
||||
moreMenuItems.unshift({
|
||||
node: 'item',
|
||||
name: t('测活'),
|
||||
type: 'tertiary',
|
||||
onClick: () => checkOllamaVersion(record),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Space wrap>
|
||||
<SplitButtonGroup
|
||||
className='overflow-hidden'
|
||||
aria-label={t('测试单个渠道操作项目组')}
|
||||
>
|
||||
<Button
|
||||
size='small'
|
||||
type='tertiary'
|
||||
onClick={() => testChannel(record, '')}
|
||||
>
|
||||
{t('测试')}
|
||||
</Button>
|
||||
<Button
|
||||
size='small'
|
||||
type='tertiary'
|
||||
icon={<IconTreeTriangleDown />}
|
||||
onClick={() => {
|
||||
setCurrentTestChannel(record);
|
||||
setShowModelTestModal(true);
|
||||
}}
|
||||
/>
|
||||
</SplitButtonGroup>
|
||||
|
||||
{record.status === 1 ? (
|
||||
<Button
|
||||
type='danger'
|
||||
size='small'
|
||||
onClick={() => manageChannel(record.id, 'disable', record)}
|
||||
>
|
||||
{t('禁用')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size='small'
|
||||
onClick={() => manageChannel(record.id, 'enable', record)}
|
||||
>
|
||||
{t('启用')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{record.channel_info?.is_multi_key ? (
|
||||
<SplitButtonGroup aria-label={t('多密钥渠道操作项目组')}>
|
||||
<Button
|
||||
type='tertiary'
|
||||
size='small'
|
||||
onClick={() => {
|
||||
setEditingChannel(record);
|
||||
setShowEdit(true);
|
||||
}}
|
||||
>
|
||||
{t('编辑')}
|
||||
</Button>
|
||||
<Dropdown
|
||||
trigger='click'
|
||||
position='bottomRight'
|
||||
menu={[
|
||||
{
|
||||
node: 'item',
|
||||
name: t('多密钥管理'),
|
||||
onClick: () => {
|
||||
setCurrentMultiKeyChannel(record);
|
||||
setShowMultiKeyManageModal(true);
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Button
|
||||
type='tertiary'
|
||||
size='small'
|
||||
icon={<IconTreeTriangleDown />}
|
||||
/>
|
||||
</Dropdown>
|
||||
</SplitButtonGroup>
|
||||
) : (
|
||||
<Button
|
||||
type='tertiary'
|
||||
size='small'
|
||||
onClick={() => {
|
||||
setEditingChannel(record);
|
||||
setShowEdit(true);
|
||||
}}
|
||||
>
|
||||
{t('编辑')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Dropdown
|
||||
trigger='click'
|
||||
position='bottomRight'
|
||||
menu={moreMenuItems}
|
||||
>
|
||||
<Button icon={<IconMore />} type='tertiary' size='small' />
|
||||
</Dropdown>
|
||||
</Space>
|
||||
);
|
||||
} else {
|
||||
// 标签操作按钮
|
||||
return (
|
||||
<Space wrap>
|
||||
<Button
|
||||
type='tertiary'
|
||||
size='small'
|
||||
onClick={() => manageTag(record.key, 'enable')}
|
||||
>
|
||||
{t('启用全部')}
|
||||
</Button>
|
||||
<Button
|
||||
type='tertiary'
|
||||
size='small'
|
||||
onClick={() => manageTag(record.key, 'disable')}
|
||||
>
|
||||
{t('禁用全部')}
|
||||
</Button>
|
||||
<Button
|
||||
type='tertiary'
|
||||
size='small'
|
||||
onClick={() => {
|
||||
setShowEditTag(true);
|
||||
setEditingTag(record.key);
|
||||
}}
|
||||
>
|
||||
{t('编辑')}
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
159
web/src/components/table/channels/ChannelsFilters.jsx
Normal file
159
web/src/components/table/channels/ChannelsFilters.jsx
Normal file
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
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 from 'react';
|
||||
import { Button, Form } from '@douyinfe/semi-ui';
|
||||
import { IconSearch } from '@douyinfe/semi-icons';
|
||||
|
||||
const ChannelsFilters = ({
|
||||
setEditingChannel,
|
||||
setShowEdit,
|
||||
refresh,
|
||||
setShowColumnSelector,
|
||||
formInitValues,
|
||||
setFormApi,
|
||||
searchChannels,
|
||||
enableTagMode,
|
||||
formApi,
|
||||
groupOptions,
|
||||
loading,
|
||||
searching,
|
||||
t,
|
||||
}) => {
|
||||
return (
|
||||
<div className='flex flex-col md:flex-row justify-between items-center gap-2 w-full'>
|
||||
<div className='flex gap-2 w-full md:w-auto order-2 md:order-1'>
|
||||
<Button
|
||||
size='small'
|
||||
theme='light'
|
||||
type='primary'
|
||||
className='w-full md:w-auto'
|
||||
onClick={() => {
|
||||
setEditingChannel({
|
||||
id: undefined,
|
||||
});
|
||||
setShowEdit(true);
|
||||
}}
|
||||
>
|
||||
{t('添加渠道')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size='small'
|
||||
type='tertiary'
|
||||
className='w-full md:w-auto'
|
||||
onClick={refresh}
|
||||
>
|
||||
{t('刷新')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size='small'
|
||||
type='tertiary'
|
||||
onClick={() => setShowColumnSelector(true)}
|
||||
className='w-full md:w-auto'
|
||||
>
|
||||
{t('列设置')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col md:flex-row items-center gap-2 w-full md:w-auto order-1 md:order-2'>
|
||||
<Form
|
||||
initValues={formInitValues}
|
||||
getFormApi={(api) => setFormApi(api)}
|
||||
onSubmit={() => searchChannels(enableTagMode)}
|
||||
allowEmpty={true}
|
||||
autoComplete='off'
|
||||
layout='horizontal'
|
||||
trigger='change'
|
||||
stopValidateWithError={false}
|
||||
className='flex flex-col md:flex-row items-center gap-2 w-full'
|
||||
>
|
||||
<div className='relative w-full md:w-64'>
|
||||
<Form.Input
|
||||
size='small'
|
||||
field='searchKeyword'
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('渠道ID,名称,密钥,API地址')}
|
||||
showClear
|
||||
pure
|
||||
/>
|
||||
</div>
|
||||
<div className='w-full md:w-48'>
|
||||
<Form.Input
|
||||
size='small'
|
||||
field='searchModel'
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('模型关键字')}
|
||||
showClear
|
||||
pure
|
||||
/>
|
||||
</div>
|
||||
<div className='w-full md:w-32'>
|
||||
<Form.Select
|
||||
size='small'
|
||||
field='searchGroup'
|
||||
placeholder={t('选择分组')}
|
||||
optionList={[
|
||||
{ label: t('选择分组'), value: null },
|
||||
...groupOptions,
|
||||
]}
|
||||
className='w-full'
|
||||
showClear
|
||||
pure
|
||||
onChange={() => {
|
||||
// 延迟执行搜索,让表单值先更新
|
||||
setTimeout(() => {
|
||||
searchChannels(enableTagMode);
|
||||
}, 0);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size='small'
|
||||
type='tertiary'
|
||||
htmlType='submit'
|
||||
loading={loading || searching}
|
||||
className='w-full md:w-auto'
|
||||
>
|
||||
{t('查询')}
|
||||
</Button>
|
||||
<Button
|
||||
size='small'
|
||||
type='tertiary'
|
||||
onClick={() => {
|
||||
if (formApi) {
|
||||
formApi.reset();
|
||||
// 重置后立即查询,使用setTimeout确保表单重置完成
|
||||
setTimeout(() => {
|
||||
refresh();
|
||||
}, 100);
|
||||
}
|
||||
}}
|
||||
className='w-full md:w-auto'
|
||||
>
|
||||
{t('重置')}
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelsFilters;
|
||||
177
web/src/components/table/channels/ChannelsTable.jsx
Normal file
177
web/src/components/table/channels/ChannelsTable.jsx
Normal file
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
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 } from 'react';
|
||||
import { Empty } from '@douyinfe/semi-ui';
|
||||
import CardTable from '../../common/ui/CardTable';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { getChannelsColumns } from './ChannelsColumnDefs';
|
||||
|
||||
const ChannelsTable = (channelsData) => {
|
||||
const {
|
||||
channels,
|
||||
loading,
|
||||
searching,
|
||||
activePage,
|
||||
pageSize,
|
||||
channelCount,
|
||||
enableBatchDelete,
|
||||
compactMode,
|
||||
visibleColumns,
|
||||
setSelectedChannels,
|
||||
handlePageChange,
|
||||
handlePageSizeChange,
|
||||
handleRow,
|
||||
t,
|
||||
COLUMN_KEYS,
|
||||
// Column functions and data
|
||||
updateChannelBalance,
|
||||
manageChannel,
|
||||
manageTag,
|
||||
submitTagEdit,
|
||||
testChannel,
|
||||
setCurrentTestChannel,
|
||||
setShowModelTestModal,
|
||||
setEditingChannel,
|
||||
setShowEdit,
|
||||
setShowEditTag,
|
||||
setEditingTag,
|
||||
copySelectedChannel,
|
||||
refresh,
|
||||
checkOllamaVersion,
|
||||
// Multi-key management
|
||||
setShowMultiKeyManageModal,
|
||||
setCurrentMultiKeyChannel,
|
||||
openUpstreamUpdateModal,
|
||||
detectChannelUpstreamUpdates,
|
||||
} = channelsData;
|
||||
|
||||
// Get all columns
|
||||
const allColumns = useMemo(() => {
|
||||
return getChannelsColumns({
|
||||
t,
|
||||
COLUMN_KEYS,
|
||||
updateChannelBalance,
|
||||
manageChannel,
|
||||
manageTag,
|
||||
submitTagEdit,
|
||||
testChannel,
|
||||
setCurrentTestChannel,
|
||||
setShowModelTestModal,
|
||||
setEditingChannel,
|
||||
setShowEdit,
|
||||
setShowEditTag,
|
||||
setEditingTag,
|
||||
copySelectedChannel,
|
||||
refresh,
|
||||
activePage,
|
||||
channels,
|
||||
checkOllamaVersion,
|
||||
setShowMultiKeyManageModal,
|
||||
setCurrentMultiKeyChannel,
|
||||
openUpstreamUpdateModal,
|
||||
detectChannelUpstreamUpdates,
|
||||
});
|
||||
}, [
|
||||
t,
|
||||
COLUMN_KEYS,
|
||||
updateChannelBalance,
|
||||
manageChannel,
|
||||
manageTag,
|
||||
submitTagEdit,
|
||||
testChannel,
|
||||
setCurrentTestChannel,
|
||||
setShowModelTestModal,
|
||||
setEditingChannel,
|
||||
setShowEdit,
|
||||
setShowEditTag,
|
||||
setEditingTag,
|
||||
copySelectedChannel,
|
||||
refresh,
|
||||
activePage,
|
||||
channels,
|
||||
checkOllamaVersion,
|
||||
setShowMultiKeyManageModal,
|
||||
setCurrentMultiKeyChannel,
|
||||
openUpstreamUpdateModal,
|
||||
detectChannelUpstreamUpdates,
|
||||
]);
|
||||
|
||||
// Filter columns based on visibility settings
|
||||
const getVisibleColumns = () => {
|
||||
return allColumns.filter((column) => visibleColumns[column.key]);
|
||||
};
|
||||
|
||||
const visibleColumnsList = useMemo(() => {
|
||||
return getVisibleColumns();
|
||||
}, [visibleColumns, allColumns]);
|
||||
|
||||
const tableColumns = useMemo(() => {
|
||||
return compactMode
|
||||
? visibleColumnsList.map(({ fixed, ...rest }) => rest)
|
||||
: visibleColumnsList;
|
||||
}, [compactMode, visibleColumnsList]);
|
||||
|
||||
return (
|
||||
<CardTable
|
||||
columns={tableColumns}
|
||||
dataSource={channels}
|
||||
scroll={compactMode ? undefined : { x: 'max-content' }}
|
||||
pagination={{
|
||||
currentPage: activePage,
|
||||
pageSize: pageSize,
|
||||
total: channelCount,
|
||||
pageSizeOpts: [10, 20, 50, 100],
|
||||
showSizeChanger: true,
|
||||
onPageSizeChange: handlePageSizeChange,
|
||||
onPageChange: handlePageChange,
|
||||
}}
|
||||
hidePagination={true}
|
||||
expandAllRows={false}
|
||||
onRow={handleRow}
|
||||
rowSelection={
|
||||
enableBatchDelete
|
||||
? {
|
||||
onChange: (selectedRowKeys, selectedRows) => {
|
||||
setSelectedChannels(selectedRows);
|
||||
},
|
||||
}
|
||||
: null
|
||||
}
|
||||
empty={
|
||||
<Empty
|
||||
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
|
||||
darkModeImage={
|
||||
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
description={t('搜索无结果')}
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
}
|
||||
className='rounded-xl overflow-hidden'
|
||||
size='middle'
|
||||
loading={loading || searching}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelsTable;
|
||||
97
web/src/components/table/channels/ChannelsTabs.jsx
Normal file
97
web/src/components/table/channels/ChannelsTabs.jsx
Normal file
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
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 from 'react';
|
||||
import { Tabs, TabPane, Tag } from '@douyinfe/semi-ui';
|
||||
import { CHANNEL_OPTIONS } from '../../../constants';
|
||||
import { getChannelIcon } from '../../../helpers';
|
||||
|
||||
const ChannelsTabs = ({
|
||||
enableTagMode,
|
||||
activeTypeKey,
|
||||
setActiveTypeKey,
|
||||
channelTypeCounts,
|
||||
availableTypeKeys,
|
||||
loadChannels,
|
||||
activePage,
|
||||
pageSize,
|
||||
idSort,
|
||||
setActivePage,
|
||||
t,
|
||||
}) => {
|
||||
if (enableTagMode) return null;
|
||||
|
||||
const handleTabChange = (key) => {
|
||||
setActiveTypeKey(key);
|
||||
setActivePage(1);
|
||||
loadChannels(1, pageSize, idSort, enableTagMode, key);
|
||||
};
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
activeKey={activeTypeKey}
|
||||
type='card'
|
||||
collapsible
|
||||
onChange={handleTabChange}
|
||||
className='mb-2'
|
||||
>
|
||||
<TabPane
|
||||
itemKey='all'
|
||||
tab={
|
||||
<span className='flex items-center gap-2'>
|
||||
{t('全部')}
|
||||
<Tag
|
||||
color={activeTypeKey === 'all' ? 'red' : 'grey'}
|
||||
shape='circle'
|
||||
>
|
||||
{channelTypeCounts['all'] || 0}
|
||||
</Tag>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
{CHANNEL_OPTIONS.filter((opt) =>
|
||||
availableTypeKeys.includes(String(opt.value)),
|
||||
).map((option) => {
|
||||
const key = String(option.value);
|
||||
const count = channelTypeCounts[option.value] || 0;
|
||||
return (
|
||||
<TabPane
|
||||
key={key}
|
||||
itemKey={key}
|
||||
tab={
|
||||
<span className='flex items-center gap-2'>
|
||||
{getChannelIcon(option.value)}
|
||||
{option.label}
|
||||
<Tag
|
||||
color={activeTypeKey === key ? 'red' : 'grey'}
|
||||
shape='circle'
|
||||
>
|
||||
{count}
|
||||
</Tag>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelsTabs;
|
||||
116
web/src/components/table/channels/index.jsx
Normal file
116
web/src/components/table/channels/index.jsx
Normal file
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
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 from 'react';
|
||||
import { Banner } from '@douyinfe/semi-ui';
|
||||
import { IconAlertTriangle } from '@douyinfe/semi-icons';
|
||||
import CardPro from '../../common/ui/CardPro';
|
||||
import ChannelsTable from './ChannelsTable';
|
||||
import ChannelsActions from './ChannelsActions';
|
||||
import ChannelsFilters from './ChannelsFilters';
|
||||
import ChannelsTabs from './ChannelsTabs';
|
||||
import { useChannelsData } from '../../../hooks/channels/useChannelsData';
|
||||
import { useIsMobile } from '../../../hooks/common/useIsMobile';
|
||||
import BatchTagModal from './modals/BatchTagModal';
|
||||
import ModelTestModal from './modals/ModelTestModal';
|
||||
import ColumnSelectorModal from './modals/ColumnSelectorModal';
|
||||
import EditChannelModal from './modals/EditChannelModal';
|
||||
import EditTagModal from './modals/EditTagModal';
|
||||
import MultiKeyManageModal from './modals/MultiKeyManageModal';
|
||||
import ChannelUpstreamUpdateModal from './modals/ChannelUpstreamUpdateModal';
|
||||
import { createCardProPagination } from '../../../helpers/utils';
|
||||
|
||||
const ChannelsPage = () => {
|
||||
const channelsData = useChannelsData();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Modals */}
|
||||
<ColumnSelectorModal {...channelsData} />
|
||||
<EditTagModal
|
||||
visible={channelsData.showEditTag}
|
||||
tag={channelsData.editingTag}
|
||||
handleClose={() => channelsData.setShowEditTag(false)}
|
||||
refresh={channelsData.refresh}
|
||||
/>
|
||||
<EditChannelModal
|
||||
refresh={channelsData.refresh}
|
||||
visible={channelsData.showEdit}
|
||||
handleClose={channelsData.closeEdit}
|
||||
editingChannel={channelsData.editingChannel}
|
||||
/>
|
||||
<BatchTagModal {...channelsData} />
|
||||
<ModelTestModal {...channelsData} />
|
||||
<MultiKeyManageModal
|
||||
visible={channelsData.showMultiKeyManageModal}
|
||||
onCancel={() => channelsData.setShowMultiKeyManageModal(false)}
|
||||
channel={channelsData.currentMultiKeyChannel}
|
||||
onRefresh={channelsData.refresh}
|
||||
/>
|
||||
<ChannelUpstreamUpdateModal
|
||||
visible={channelsData.showUpstreamUpdateModal}
|
||||
addModels={channelsData.upstreamUpdateAddModels}
|
||||
removeModels={channelsData.upstreamUpdateRemoveModels}
|
||||
preferredTab={channelsData.upstreamUpdatePreferredTab}
|
||||
confirmLoading={channelsData.upstreamApplyLoading}
|
||||
onConfirm={channelsData.applyUpstreamUpdates}
|
||||
onCancel={channelsData.closeUpstreamUpdateModal}
|
||||
/>
|
||||
|
||||
{/* Main Content */}
|
||||
{channelsData.globalPassThroughEnabled ? (
|
||||
<Banner
|
||||
type='warning'
|
||||
closeIcon={null}
|
||||
icon={
|
||||
<IconAlertTriangle
|
||||
size='large'
|
||||
style={{ color: 'var(--semi-color-warning)' }}
|
||||
/>
|
||||
}
|
||||
description={channelsData.t(
|
||||
'已开启全局请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。',
|
||||
)}
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
) : null}
|
||||
<CardPro
|
||||
type='type3'
|
||||
tabsArea={<ChannelsTabs {...channelsData} />}
|
||||
actionsArea={<ChannelsActions {...channelsData} />}
|
||||
searchArea={<ChannelsFilters {...channelsData} />}
|
||||
paginationArea={createCardProPagination({
|
||||
currentPage: channelsData.activePage,
|
||||
pageSize: channelsData.pageSize,
|
||||
total: channelsData.channelCount,
|
||||
onPageChange: channelsData.handlePageChange,
|
||||
onPageSizeChange: channelsData.handlePageSizeChange,
|
||||
isMobile: isMobile,
|
||||
t: channelsData.t,
|
||||
})}
|
||||
t={channelsData.t}
|
||||
>
|
||||
<ChannelsTable {...channelsData} />
|
||||
</CardPro>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelsPage;
|
||||
63
web/src/components/table/channels/modals/BatchTagModal.jsx
Normal file
63
web/src/components/table/channels/modals/BatchTagModal.jsx
Normal file
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
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 from 'react';
|
||||
import { Modal, Input, Typography } from '@douyinfe/semi-ui';
|
||||
|
||||
const BatchTagModal = ({
|
||||
showBatchSetTag,
|
||||
setShowBatchSetTag,
|
||||
batchSetChannelTag,
|
||||
batchSetTagValue,
|
||||
setBatchSetTagValue,
|
||||
selectedChannels,
|
||||
t,
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
title={t('批量设置标签')}
|
||||
visible={showBatchSetTag}
|
||||
onOk={batchSetChannelTag}
|
||||
onCancel={() => setShowBatchSetTag(false)}
|
||||
maskClosable={false}
|
||||
centered={true}
|
||||
size='small'
|
||||
className='!rounded-lg'
|
||||
>
|
||||
<div className='mb-5'>
|
||||
<Typography.Text>{t('请输入要设置的标签名称')}</Typography.Text>
|
||||
</div>
|
||||
<Input
|
||||
placeholder={t('请输入标签名称')}
|
||||
value={batchSetTagValue}
|
||||
onChange={(v) => setBatchSetTagValue(v)}
|
||||
/>
|
||||
<div className='mt-4'>
|
||||
<Typography.Text type='secondary'>
|
||||
{t('已选择 ${count} 个渠道').replace(
|
||||
'${count}',
|
||||
selectedChannels.length,
|
||||
)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default BatchTagModal;
|
||||
@@ -0,0 +1,313 @@
|
||||
/*
|
||||
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, { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Modal,
|
||||
Checkbox,
|
||||
Empty,
|
||||
Input,
|
||||
Tabs,
|
||||
Typography,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { IconSearch } from '@douyinfe/semi-icons';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
|
||||
const normalizeModels = (models = []) =>
|
||||
Array.from(
|
||||
new Set(
|
||||
(models || []).map((model) => String(model || '').trim()).filter(Boolean),
|
||||
),
|
||||
);
|
||||
|
||||
const filterByKeyword = (models = [], keyword = '') => {
|
||||
const normalizedKeyword = String(keyword || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!normalizedKeyword) {
|
||||
return models;
|
||||
}
|
||||
return models.filter((model) =>
|
||||
String(model).toLowerCase().includes(normalizedKeyword),
|
||||
);
|
||||
};
|
||||
|
||||
const ChannelUpstreamUpdateModal = ({
|
||||
visible,
|
||||
addModels = [],
|
||||
removeModels = [],
|
||||
preferredTab = 'add',
|
||||
confirmLoading = false,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const normalizedAddModels = useMemo(
|
||||
() => normalizeModels(addModels),
|
||||
[addModels],
|
||||
);
|
||||
const normalizedRemoveModels = useMemo(
|
||||
() => normalizeModels(removeModels),
|
||||
[removeModels],
|
||||
);
|
||||
|
||||
const [selectedAddModels, setSelectedAddModels] = useState([]);
|
||||
const [selectedRemoveModels, setSelectedRemoveModels] = useState([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [activeTab, setActiveTab] = useState('add');
|
||||
const [partialSubmitConfirmed, setPartialSubmitConfirmed] = useState(false);
|
||||
|
||||
const addTabEnabled = normalizedAddModels.length > 0;
|
||||
const removeTabEnabled = normalizedRemoveModels.length > 0;
|
||||
const filteredAddModels = useMemo(
|
||||
() => filterByKeyword(normalizedAddModels, keyword),
|
||||
[normalizedAddModels, keyword],
|
||||
);
|
||||
const filteredRemoveModels = useMemo(
|
||||
() => filterByKeyword(normalizedRemoveModels, keyword),
|
||||
[normalizedRemoveModels, keyword],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
return;
|
||||
}
|
||||
setSelectedAddModels([]);
|
||||
setSelectedRemoveModels([]);
|
||||
setKeyword('');
|
||||
setPartialSubmitConfirmed(false);
|
||||
const normalizedPreferredTab = preferredTab === 'remove' ? 'remove' : 'add';
|
||||
if (normalizedPreferredTab === 'remove' && removeTabEnabled) {
|
||||
setActiveTab('remove');
|
||||
return;
|
||||
}
|
||||
if (normalizedPreferredTab === 'add' && addTabEnabled) {
|
||||
setActiveTab('add');
|
||||
return;
|
||||
}
|
||||
setActiveTab(addTabEnabled ? 'add' : 'remove');
|
||||
}, [visible, addTabEnabled, removeTabEnabled, preferredTab]);
|
||||
|
||||
const currentModels =
|
||||
activeTab === 'add' ? filteredAddModels : filteredRemoveModels;
|
||||
const currentSelectedModels =
|
||||
activeTab === 'add' ? selectedAddModels : selectedRemoveModels;
|
||||
const currentSetSelectedModels =
|
||||
activeTab === 'add' ? setSelectedAddModels : setSelectedRemoveModels;
|
||||
const selectedAddCount = selectedAddModels.length;
|
||||
const selectedRemoveCount = selectedRemoveModels.length;
|
||||
const checkedCount = currentModels.filter((model) =>
|
||||
currentSelectedModels.includes(model),
|
||||
).length;
|
||||
const isAllChecked =
|
||||
currentModels.length > 0 && checkedCount === currentModels.length;
|
||||
const isIndeterminate =
|
||||
checkedCount > 0 && checkedCount < currentModels.length;
|
||||
|
||||
const handleToggleAllCurrent = (checked) => {
|
||||
if (checked) {
|
||||
const merged = normalizeModels([
|
||||
...currentSelectedModels,
|
||||
...currentModels,
|
||||
]);
|
||||
currentSetSelectedModels(merged);
|
||||
return;
|
||||
}
|
||||
const currentSet = new Set(currentModels);
|
||||
currentSetSelectedModels(
|
||||
currentSelectedModels.filter((model) => !currentSet.has(model)),
|
||||
);
|
||||
};
|
||||
|
||||
const tabList = [
|
||||
{
|
||||
itemKey: 'add',
|
||||
tab: `${t('新增模型')} (${selectedAddCount}/${normalizedAddModels.length})`,
|
||||
disabled: !addTabEnabled,
|
||||
},
|
||||
{
|
||||
itemKey: 'remove',
|
||||
tab: `${t('删除模型')} (${selectedRemoveCount}/${normalizedRemoveModels.length})`,
|
||||
disabled: !removeTabEnabled,
|
||||
},
|
||||
];
|
||||
|
||||
const submitSelectedChanges = () => {
|
||||
onConfirm?.({
|
||||
addModels: selectedAddModels,
|
||||
removeModels: selectedRemoveModels,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
const hasAnySelected = selectedAddCount > 0 || selectedRemoveCount > 0;
|
||||
if (!hasAnySelected) {
|
||||
submitSelectedChanges();
|
||||
return;
|
||||
}
|
||||
|
||||
const hasBothPending = addTabEnabled && removeTabEnabled;
|
||||
const hasUnselectedAdd = addTabEnabled && selectedAddCount === 0;
|
||||
const hasUnselectedRemove = removeTabEnabled && selectedRemoveCount === 0;
|
||||
if (hasBothPending && (hasUnselectedAdd || hasUnselectedRemove)) {
|
||||
if (partialSubmitConfirmed) {
|
||||
submitSelectedChanges();
|
||||
return;
|
||||
}
|
||||
const missingTab = hasUnselectedAdd ? 'add' : 'remove';
|
||||
const missingType = hasUnselectedAdd ? t('新增') : t('删除');
|
||||
const missingCount = hasUnselectedAdd
|
||||
? normalizedAddModels.length
|
||||
: normalizedRemoveModels.length;
|
||||
setActiveTab(missingTab);
|
||||
Modal.confirm({
|
||||
title: t('仍有未处理项'),
|
||||
content: t(
|
||||
'你还没有处理{{type}}模型({{count}}个)。是否仅提交当前已勾选内容?',
|
||||
{
|
||||
type: missingType,
|
||||
count: missingCount,
|
||||
},
|
||||
),
|
||||
okText: t('仅提交已勾选'),
|
||||
cancelText: t('去处理{{type}}', { type: missingType }),
|
||||
centered: true,
|
||||
onOk: () => {
|
||||
setPartialSubmitConfirmed(true);
|
||||
submitSelectedChanges();
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
submitSelectedChanges();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title={t('处理上游模型更新')}
|
||||
okText={t('确定')}
|
||||
cancelText={t('取消')}
|
||||
size={isMobile ? 'full-width' : 'medium'}
|
||||
centered
|
||||
closeOnEsc
|
||||
maskClosable
|
||||
confirmLoading={confirmLoading}
|
||||
onCancel={onCancel}
|
||||
onOk={handleSubmit}
|
||||
>
|
||||
<div className='flex flex-col gap-3'>
|
||||
<Typography.Text type='secondary' size='small'>
|
||||
{t(
|
||||
'可勾选需要执行的变更:新增会加入渠道模型列表,删除会从渠道模型列表移除。',
|
||||
)}
|
||||
</Typography.Text>
|
||||
|
||||
<Tabs
|
||||
type='slash'
|
||||
size='small'
|
||||
tabList={tabList}
|
||||
activeKey={activeTab}
|
||||
onChange={(key) => setActiveTab(key)}
|
||||
/>
|
||||
<div className='flex items-center gap-3 text-xs text-gray-500'>
|
||||
<span>
|
||||
{t('新增已选 {{selected}} / {{total}}', {
|
||||
selected: selectedAddCount,
|
||||
total: normalizedAddModels.length,
|
||||
})}
|
||||
</span>
|
||||
<span>
|
||||
{t('删除已选 {{selected}} / {{total}}', {
|
||||
selected: selectedRemoveCount,
|
||||
total: normalizedRemoveModels.length,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
prefix={<IconSearch size={14} />}
|
||||
placeholder={t('搜索模型')}
|
||||
value={keyword}
|
||||
onChange={(value) => setKeyword(value)}
|
||||
showClear
|
||||
/>
|
||||
|
||||
<div style={{ maxHeight: 320, overflowY: 'auto', paddingRight: 8 }}>
|
||||
{currentModels.length === 0 ? (
|
||||
<Empty
|
||||
image={
|
||||
<IllustrationNoResult style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
darkModeImage={
|
||||
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
description={t('暂无匹配模型')}
|
||||
style={{ padding: 24 }}
|
||||
/>
|
||||
) : (
|
||||
<Checkbox.Group
|
||||
value={currentSelectedModels}
|
||||
onChange={(values) =>
|
||||
currentSetSelectedModels(normalizeModels(values))
|
||||
}
|
||||
>
|
||||
<div className='grid grid-cols-1 md:grid-cols-2 gap-x-4'>
|
||||
{currentModels.map((model) => (
|
||||
<Checkbox
|
||||
key={`${activeTab}:${model}`}
|
||||
value={model}
|
||||
className='my-1'
|
||||
>
|
||||
{model}
|
||||
</Checkbox>
|
||||
))}
|
||||
</div>
|
||||
</Checkbox.Group>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-end gap-2'>
|
||||
<Typography.Text type='secondary' size='small'>
|
||||
{t('已选择 {{selected}} / {{total}}', {
|
||||
selected: checkedCount,
|
||||
total: currentModels.length,
|
||||
})}
|
||||
</Typography.Text>
|
||||
<Checkbox
|
||||
checked={isAllChecked}
|
||||
indeterminate={isIndeterminate}
|
||||
aria-label={t('全选当前列表模型')}
|
||||
onChange={(e) => handleToggleAllCurrent(e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelUpstreamUpdateModal;
|
||||
172
web/src/components/table/channels/modals/CodexOAuthModal.jsx
Normal file
172
web/src/components/table/channels/modals/CodexOAuthModal.jsx
Normal file
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
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, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Modal,
|
||||
Button,
|
||||
Space,
|
||||
Typography,
|
||||
Input,
|
||||
Banner,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { API, copy, showError, showSuccess } from '../../../../helpers';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const CodexOAuthModal = ({ visible, onCancel, onSuccess }) => {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [authorizeUrl, setAuthorizeUrl] = useState('');
|
||||
const [input, setInput] = useState('');
|
||||
|
||||
const startOAuth = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.post(
|
||||
'/api/channel/codex/oauth/start',
|
||||
{},
|
||||
{ skipErrorHandler: true },
|
||||
);
|
||||
if (!res?.data?.success) {
|
||||
console.error('Codex OAuth start failed:', res?.data?.message);
|
||||
throw new Error(t('启动授权失败'));
|
||||
}
|
||||
const url = res?.data?.data?.authorize_url || '';
|
||||
if (!url) {
|
||||
console.error(
|
||||
'Codex OAuth start response missing authorize_url:',
|
||||
res?.data,
|
||||
);
|
||||
throw new Error(t('响应缺少授权链接'));
|
||||
}
|
||||
setAuthorizeUrl(url);
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
showSuccess(t('已打开授权页面'));
|
||||
} catch (error) {
|
||||
showError(error?.message || t('启动授权失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const completeOAuth = async () => {
|
||||
if (!input || !input.trim()) {
|
||||
showError(t('请先粘贴回调 URL'));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.post(
|
||||
'/api/channel/codex/oauth/complete',
|
||||
{ input },
|
||||
{ skipErrorHandler: true },
|
||||
);
|
||||
if (!res?.data?.success) {
|
||||
console.error('Codex OAuth complete failed:', res?.data?.message);
|
||||
throw new Error(t('授权失败'));
|
||||
}
|
||||
|
||||
const key = res?.data?.data?.key || '';
|
||||
if (!key) {
|
||||
console.error('Codex OAuth complete response missing key:', res?.data);
|
||||
throw new Error(t('响应缺少凭据'));
|
||||
}
|
||||
|
||||
onSuccess && onSuccess(key);
|
||||
showSuccess(t('已生成授权凭据'));
|
||||
onCancel && onCancel();
|
||||
} catch (error) {
|
||||
showError(error?.message || t('授权失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
setAuthorizeUrl('');
|
||||
setInput('');
|
||||
}, [visible]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('Codex 授权')}
|
||||
visible={visible}
|
||||
onCancel={onCancel}
|
||||
maskClosable={false}
|
||||
closeOnEsc
|
||||
width={720}
|
||||
footer={
|
||||
<Space>
|
||||
<Button theme='borderless' onClick={onCancel} disabled={loading}>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='solid'
|
||||
type='primary'
|
||||
onClick={completeOAuth}
|
||||
loading={loading}
|
||||
>
|
||||
{t('生成并填入')}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Space vertical spacing='tight' style={{ width: '100%' }}>
|
||||
<Banner
|
||||
type='info'
|
||||
description={t(
|
||||
'1) 点击「打开授权页面」完成登录;2) 浏览器会跳转到 localhost(页面打不开也没关系);3) 复制地址栏完整 URL 粘贴到下方;4) 点击「生成并填入」。',
|
||||
)}
|
||||
/>
|
||||
|
||||
<Space wrap>
|
||||
<Button type='primary' onClick={startOAuth} loading={loading}>
|
||||
{t('打开授权页面')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='outline'
|
||||
disabled={!authorizeUrl || loading}
|
||||
onClick={() => copy(authorizeUrl)}
|
||||
>
|
||||
{t('复制授权链接')}
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Input
|
||||
value={input}
|
||||
onChange={(value) => setInput(value)}
|
||||
placeholder={t('请粘贴完整回调 URL(包含 code 与 state)')}
|
||||
showClear
|
||||
/>
|
||||
|
||||
<Text type='tertiary' size='small'>
|
||||
{t(
|
||||
'说明:生成结果是可直接粘贴到渠道密钥里的 JSON(包含 access_token / refresh_token / account_id)。',
|
||||
)}
|
||||
</Text>
|
||||
</Space>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CodexOAuthModal;
|
||||
514
web/src/components/table/channels/modals/CodexUsageModal.jsx
Normal file
514
web/src/components/table/channels/modals/CodexUsageModal.jsx
Normal file
@@ -0,0 +1,514 @@
|
||||
/*
|
||||
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, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Button,
|
||||
Progress,
|
||||
Typography,
|
||||
Spin,
|
||||
Tag,
|
||||
Descriptions,
|
||||
Collapse,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { API, showError } from '../../../../helpers';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const clampPercent = (value) => {
|
||||
const v = Number(value);
|
||||
if (!Number.isFinite(v)) return 0;
|
||||
return Math.max(0, Math.min(100, v));
|
||||
};
|
||||
|
||||
const pickStrokeColor = (percent) => {
|
||||
const p = clampPercent(percent);
|
||||
if (p >= 95) return '#ef4444';
|
||||
if (p >= 80) return '#f59e0b';
|
||||
return '#3b82f6';
|
||||
};
|
||||
|
||||
const normalizePlanType = (value) => {
|
||||
if (value == null) return '';
|
||||
return String(value).trim().toLowerCase();
|
||||
};
|
||||
|
||||
const getWindowDurationSeconds = (windowData) => {
|
||||
const value = Number(windowData?.limit_window_seconds);
|
||||
if (!Number.isFinite(value) || value <= 0) return null;
|
||||
return value;
|
||||
};
|
||||
|
||||
const classifyWindowByDuration = (windowData) => {
|
||||
const seconds = getWindowDurationSeconds(windowData);
|
||||
if (seconds == null) return null;
|
||||
return seconds >= 24 * 60 * 60 ? 'weekly' : 'fiveHour';
|
||||
};
|
||||
|
||||
const resolveRateLimitWindows = (data) => {
|
||||
const rateLimit = data?.rate_limit ?? {};
|
||||
const primary = rateLimit?.primary_window ?? null;
|
||||
const secondary = rateLimit?.secondary_window ?? null;
|
||||
const windows = [primary, secondary].filter(Boolean);
|
||||
const planType = normalizePlanType(data?.plan_type ?? rateLimit?.plan_type);
|
||||
|
||||
let fiveHourWindow = null;
|
||||
let weeklyWindow = null;
|
||||
|
||||
for (const windowData of windows) {
|
||||
const bucket = classifyWindowByDuration(windowData);
|
||||
if (bucket === 'fiveHour' && !fiveHourWindow) {
|
||||
fiveHourWindow = windowData;
|
||||
continue;
|
||||
}
|
||||
if (bucket === 'weekly' && !weeklyWindow) {
|
||||
weeklyWindow = windowData;
|
||||
}
|
||||
}
|
||||
|
||||
if (planType === 'free') {
|
||||
if (!weeklyWindow) {
|
||||
weeklyWindow = primary ?? secondary ?? null;
|
||||
}
|
||||
return { fiveHourWindow: null, weeklyWindow };
|
||||
}
|
||||
|
||||
if (!fiveHourWindow && !weeklyWindow) {
|
||||
return {
|
||||
fiveHourWindow: primary ?? null,
|
||||
weeklyWindow: secondary ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
if (!fiveHourWindow) {
|
||||
fiveHourWindow = windows.find((windowData) => windowData !== weeklyWindow) ?? null;
|
||||
}
|
||||
if (!weeklyWindow) {
|
||||
weeklyWindow = windows.find((windowData) => windowData !== fiveHourWindow) ?? null;
|
||||
}
|
||||
|
||||
return { fiveHourWindow, weeklyWindow };
|
||||
};
|
||||
|
||||
const formatDurationSeconds = (seconds, t) => {
|
||||
const tt = typeof t === 'function' ? t : (v) => v;
|
||||
const s = Number(seconds);
|
||||
if (!Number.isFinite(s) || s <= 0) return '-';
|
||||
const total = Math.floor(s);
|
||||
const hours = Math.floor(total / 3600);
|
||||
const minutes = Math.floor((total % 3600) / 60);
|
||||
const secs = total % 60;
|
||||
if (hours > 0) return `${hours}${tt('小时')} ${minutes}${tt('分钟')}`;
|
||||
if (minutes > 0) return `${minutes}${tt('分钟')} ${secs}${tt('秒')}`;
|
||||
return `${secs}${tt('秒')}`;
|
||||
};
|
||||
|
||||
const formatUnixSeconds = (unixSeconds) => {
|
||||
const v = Number(unixSeconds);
|
||||
if (!Number.isFinite(v) || v <= 0) return '-';
|
||||
try {
|
||||
return new Date(v * 1000).toLocaleString();
|
||||
} catch (error) {
|
||||
return String(unixSeconds);
|
||||
}
|
||||
};
|
||||
|
||||
const getDisplayText = (value) => {
|
||||
if (value == null) return '';
|
||||
return String(value).trim();
|
||||
};
|
||||
|
||||
const formatAccountTypeLabel = (value, t) => {
|
||||
const tt = typeof t === 'function' ? t : (v) => v;
|
||||
const normalized = normalizePlanType(value);
|
||||
switch (normalized) {
|
||||
case 'free':
|
||||
return 'Free';
|
||||
case 'plus':
|
||||
return 'Plus';
|
||||
case 'pro':
|
||||
return 'Pro';
|
||||
case 'team':
|
||||
return 'Team';
|
||||
case 'enterprise':
|
||||
return 'Enterprise';
|
||||
default:
|
||||
return getDisplayText(value) || tt('未识别');
|
||||
}
|
||||
};
|
||||
|
||||
const getAccountTypeTagColor = (value) => {
|
||||
const normalized = normalizePlanType(value);
|
||||
switch (normalized) {
|
||||
case 'enterprise':
|
||||
return 'green';
|
||||
case 'team':
|
||||
return 'cyan';
|
||||
case 'pro':
|
||||
return 'blue';
|
||||
case 'plus':
|
||||
return 'violet';
|
||||
case 'free':
|
||||
return 'amber';
|
||||
default:
|
||||
return 'grey';
|
||||
}
|
||||
};
|
||||
|
||||
const resolveUsageStatusTag = (t, rateLimit) => {
|
||||
const tt = typeof t === 'function' ? t : (v) => v;
|
||||
if (!rateLimit || Object.keys(rateLimit).length === 0) {
|
||||
return <Tag color='grey'>{tt('待确认')}</Tag>;
|
||||
}
|
||||
if (rateLimit?.allowed && !rateLimit?.limit_reached) {
|
||||
return <Tag color='green'>{tt('可用')}</Tag>;
|
||||
}
|
||||
return <Tag color='red'>{tt('受限')}</Tag>;
|
||||
};
|
||||
|
||||
const AccountInfoValue = ({ t, value, onCopy, monospace = false }) => {
|
||||
const tt = typeof t === 'function' ? t : (v) => v;
|
||||
const text = getDisplayText(value);
|
||||
const hasValue = text !== '';
|
||||
|
||||
return (
|
||||
<div className='flex min-w-0 items-start justify-between gap-2'>
|
||||
<div
|
||||
className={`min-w-0 flex-1 break-all text-xs leading-5 text-semi-color-text-1 ${
|
||||
monospace ? 'font-mono' : ''
|
||||
}`}
|
||||
>
|
||||
{hasValue ? text : '-'}
|
||||
</div>
|
||||
<Button
|
||||
size='small'
|
||||
type='tertiary'
|
||||
theme='borderless'
|
||||
className='shrink-0 px-1 text-xs'
|
||||
disabled={!hasValue}
|
||||
onClick={() => onCopy?.(text)}
|
||||
>
|
||||
{tt('复制')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const RateLimitWindowCard = ({ t, title, windowData }) => {
|
||||
const tt = typeof t === 'function' ? t : (v) => v;
|
||||
const hasWindowData =
|
||||
!!windowData &&
|
||||
typeof windowData === 'object' &&
|
||||
Object.keys(windowData).length > 0;
|
||||
const percent = clampPercent(windowData?.used_percent ?? 0);
|
||||
const resetAt = windowData?.reset_at;
|
||||
const resetAfterSeconds = windowData?.reset_after_seconds;
|
||||
const limitWindowSeconds = windowData?.limit_window_seconds;
|
||||
|
||||
return (
|
||||
<div className='rounded-lg border border-semi-color-border bg-semi-color-bg-0 p-3'>
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<div className='font-medium'>{title}</div>
|
||||
<Text type='tertiary' size='small'>
|
||||
{tt('重置时间:')}
|
||||
{formatUnixSeconds(resetAt)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{hasWindowData ? (
|
||||
<div className='mt-2'>
|
||||
<Progress
|
||||
percent={percent}
|
||||
stroke={pickStrokeColor(percent)}
|
||||
showInfo={true}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className='mt-3 text-sm text-semi-color-text-2'>-</div>
|
||||
)}
|
||||
|
||||
<div className='mt-1 flex flex-wrap items-center gap-2 text-xs text-semi-color-text-2'>
|
||||
<div>
|
||||
{tt('已使用:')}
|
||||
{hasWindowData ? `${percent}%` : '-'}
|
||||
</div>
|
||||
<div>
|
||||
{tt('距离重置:')}
|
||||
{hasWindowData ? formatDurationSeconds(resetAfterSeconds, tt) : '-'}
|
||||
</div>
|
||||
<div>
|
||||
{tt('窗口:')}
|
||||
{hasWindowData ? formatDurationSeconds(limitWindowSeconds, tt) : '-'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CodexUsageView = ({ t, record, payload, onCopy, onRefresh }) => {
|
||||
const tt = typeof t === 'function' ? t : (v) => v;
|
||||
const [showRawJson, setShowRawJson] = useState(false);
|
||||
const data = payload?.data ?? null;
|
||||
const rateLimit = data?.rate_limit ?? {};
|
||||
const { fiveHourWindow, weeklyWindow } = resolveRateLimitWindows(data);
|
||||
const upstreamStatus = payload?.upstream_status;
|
||||
const accountType = data?.plan_type ?? rateLimit?.plan_type;
|
||||
const accountTypeLabel = formatAccountTypeLabel(accountType, tt);
|
||||
const accountTypeTagColor = getAccountTypeTagColor(accountType);
|
||||
const statusTag = resolveUsageStatusTag(tt, rateLimit);
|
||||
const userId = data?.user_id;
|
||||
const email = data?.email;
|
||||
const accountId = data?.account_id;
|
||||
const errorMessage =
|
||||
payload?.success === false ? getDisplayText(payload?.message) || tt('获取用量失败') : '';
|
||||
|
||||
const rawText =
|
||||
typeof data === 'string' ? data : JSON.stringify(data ?? payload, null, 2);
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-4'>
|
||||
{errorMessage && (
|
||||
<div className='rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700'>
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='rounded-xl border border-semi-color-border bg-semi-color-bg-0 p-3'>
|
||||
<div className='flex flex-wrap items-start justify-between gap-2'>
|
||||
<div className='min-w-0'>
|
||||
<div className='text-xs font-medium text-semi-color-text-2'>
|
||||
{tt('Codex 帐号')}
|
||||
</div>
|
||||
<div className='mt-2 flex flex-wrap items-center gap-2'>
|
||||
<Tag
|
||||
color={accountTypeTagColor}
|
||||
type='light'
|
||||
shape='circle'
|
||||
size='large'
|
||||
className='font-semibold'
|
||||
>
|
||||
{accountTypeLabel}
|
||||
</Tag>
|
||||
{statusTag}
|
||||
<Tag color='grey' type='light' shape='circle'>
|
||||
{tt('上游状态码:')}
|
||||
{upstreamStatus ?? '-'}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
<Button size='small' type='tertiary' theme='outline' onClick={onRefresh}>
|
||||
{tt('刷新')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='mt-2 rounded-lg bg-semi-color-fill-0 px-3 py-2'>
|
||||
<Descriptions>
|
||||
<Descriptions.Item itemKey='User ID'>
|
||||
<AccountInfoValue
|
||||
t={tt}
|
||||
value={userId}
|
||||
onCopy={onCopy}
|
||||
monospace={true}
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item itemKey={tt('邮箱')}>
|
||||
<AccountInfoValue t={tt} value={email} onCopy={onCopy} />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item itemKey='Account ID'>
|
||||
<AccountInfoValue
|
||||
t={tt}
|
||||
value={accountId}
|
||||
onCopy={onCopy}
|
||||
monospace={true}
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</div>
|
||||
|
||||
<div className='mt-2 text-xs text-semi-color-text-2'>
|
||||
{tt('渠道:')}
|
||||
{record?.name || '-'} ({tt('编号:')}
|
||||
{record?.id || '-'})
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className='mb-2'>
|
||||
<div className='text-sm font-semibold text-semi-color-text-0'>
|
||||
{tt('额度窗口')}
|
||||
</div>
|
||||
<Text type='tertiary' size='small'>
|
||||
{tt('用于观察当前帐号在 Codex 上游的限额使用情况')}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 gap-3 md:grid-cols-2'>
|
||||
<RateLimitWindowCard
|
||||
t={tt}
|
||||
title={tt('5小时窗口')}
|
||||
windowData={fiveHourWindow}
|
||||
/>
|
||||
<RateLimitWindowCard
|
||||
t={tt}
|
||||
title={tt('每周窗口')}
|
||||
windowData={weeklyWindow}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Collapse
|
||||
activeKey={showRawJson ? ['raw-json'] : []}
|
||||
onChange={(activeKey) => {
|
||||
const keys = Array.isArray(activeKey) ? activeKey : [activeKey];
|
||||
setShowRawJson(keys.includes('raw-json'));
|
||||
}}
|
||||
>
|
||||
<Collapse.Panel header={tt('原始 JSON')} itemKey='raw-json'>
|
||||
<div className='mb-2 flex justify-end'>
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
onClick={() => onCopy?.(rawText)}
|
||||
disabled={!rawText}
|
||||
>
|
||||
{tt('复制')}
|
||||
</Button>
|
||||
</div>
|
||||
<pre className='max-h-[50vh] overflow-y-auto rounded-lg bg-semi-color-fill-0 p-3 text-xs text-semi-color-text-0'>
|
||||
{rawText}
|
||||
</pre>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CodexUsageLoader = ({ t, record, initialPayload, onCopy }) => {
|
||||
const tt = typeof t === 'function' ? t : (v) => v;
|
||||
const [loading, setLoading] = useState(!initialPayload);
|
||||
const [payload, setPayload] = useState(initialPayload ?? null);
|
||||
const hasShownErrorRef = useRef(false);
|
||||
const mountedRef = useRef(true);
|
||||
const recordId = record?.id;
|
||||
|
||||
const fetchUsage = useCallback(async () => {
|
||||
if (!recordId) {
|
||||
if (mountedRef.current) setPayload(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mountedRef.current) setLoading(true);
|
||||
try {
|
||||
const res = await API.get(`/api/channel/${recordId}/codex/usage`, {
|
||||
skipErrorHandler: true,
|
||||
});
|
||||
if (!mountedRef.current) return;
|
||||
setPayload(res?.data ?? null);
|
||||
if (!res?.data?.success && !hasShownErrorRef.current) {
|
||||
hasShownErrorRef.current = true;
|
||||
showError(tt('获取用量失败'));
|
||||
}
|
||||
} catch (error) {
|
||||
if (!mountedRef.current) return;
|
||||
if (!hasShownErrorRef.current) {
|
||||
hasShownErrorRef.current = true;
|
||||
showError(tt('获取用量失败'));
|
||||
}
|
||||
setPayload({ success: false, message: String(error) });
|
||||
} finally {
|
||||
if (mountedRef.current) setLoading(false);
|
||||
}
|
||||
}, [recordId, tt]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialPayload) return;
|
||||
fetchUsage().catch(() => {});
|
||||
}, [fetchUsage, initialPayload]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className='flex items-center justify-center py-10'>
|
||||
<Spin spinning={true} size='large' tip={tt('加载中...')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!payload) {
|
||||
return (
|
||||
<div className='flex flex-col gap-3'>
|
||||
<Text type='danger'>{tt('获取用量失败')}</Text>
|
||||
<div className='flex justify-end'>
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
onClick={fetchUsage}
|
||||
>
|
||||
{tt('刷新')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CodexUsageView
|
||||
t={tt}
|
||||
record={record}
|
||||
payload={payload}
|
||||
onCopy={onCopy}
|
||||
onRefresh={fetchUsage}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const openCodexUsageModal = ({ t, record, payload, onCopy }) => {
|
||||
const tt = typeof t === 'function' ? t : (v) => v;
|
||||
|
||||
Modal.info({
|
||||
title: tt('Codex 帐号与用量'),
|
||||
centered: true,
|
||||
width: 900,
|
||||
style: { maxWidth: '95vw' },
|
||||
content: (
|
||||
<CodexUsageLoader
|
||||
t={tt}
|
||||
record={record}
|
||||
initialPayload={payload}
|
||||
onCopy={onCopy}
|
||||
/>
|
||||
),
|
||||
footer: (
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button type='primary' theme='solid' onClick={() => Modal.destroyAll()}>
|
||||
{tt('关闭')}
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
};
|
||||
128
web/src/components/table/channels/modals/ColumnSelectorModal.jsx
Normal file
128
web/src/components/table/channels/modals/ColumnSelectorModal.jsx
Normal file
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
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 from 'react';
|
||||
import { Modal, Button, Checkbox } from '@douyinfe/semi-ui';
|
||||
import { getChannelsColumns } from '../ChannelsColumnDefs';
|
||||
|
||||
const ColumnSelectorModal = ({
|
||||
showColumnSelector,
|
||||
setShowColumnSelector,
|
||||
visibleColumns,
|
||||
handleColumnVisibilityChange,
|
||||
handleSelectAll,
|
||||
initDefaultColumns,
|
||||
COLUMN_KEYS,
|
||||
t,
|
||||
// Props needed for getChannelsColumns
|
||||
updateChannelBalance,
|
||||
manageChannel,
|
||||
manageTag,
|
||||
submitTagEdit,
|
||||
testChannel,
|
||||
setCurrentTestChannel,
|
||||
setShowModelTestModal,
|
||||
setEditingChannel,
|
||||
setShowEdit,
|
||||
setShowEditTag,
|
||||
setEditingTag,
|
||||
copySelectedChannel,
|
||||
refresh,
|
||||
activePage,
|
||||
channels,
|
||||
}) => {
|
||||
// Get all columns for display in selector
|
||||
const allColumns = getChannelsColumns({
|
||||
t,
|
||||
COLUMN_KEYS,
|
||||
updateChannelBalance,
|
||||
manageChannel,
|
||||
manageTag,
|
||||
submitTagEdit,
|
||||
testChannel,
|
||||
setCurrentTestChannel,
|
||||
setShowModelTestModal,
|
||||
setEditingChannel,
|
||||
setShowEdit,
|
||||
setShowEditTag,
|
||||
setEditingTag,
|
||||
copySelectedChannel,
|
||||
refresh,
|
||||
activePage,
|
||||
channels,
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('列设置')}
|
||||
visible={showColumnSelector}
|
||||
onCancel={() => setShowColumnSelector(false)}
|
||||
footer={
|
||||
<div className='flex justify-end'>
|
||||
<Button onClick={() => initDefaultColumns()}>{t('重置')}</Button>
|
||||
<Button onClick={() => setShowColumnSelector(false)}>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
<Button onClick={() => setShowColumnSelector(false)}>
|
||||
{t('确定')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<Checkbox
|
||||
checked={Object.values(visibleColumns).every((v) => v === true)}
|
||||
indeterminate={
|
||||
Object.values(visibleColumns).some((v) => v === true) &&
|
||||
!Object.values(visibleColumns).every((v) => v === true)
|
||||
}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
>
|
||||
{t('全选')}
|
||||
</Checkbox>
|
||||
</div>
|
||||
<div
|
||||
className='flex flex-wrap max-h-96 overflow-y-auto rounded-lg p-4'
|
||||
style={{ border: '1px solid var(--semi-color-border)' }}
|
||||
>
|
||||
{allColumns.map((column) => {
|
||||
// Skip columns without title
|
||||
if (!column.title) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={column.key} className='w-1/2 mb-4 pr-2'>
|
||||
<Checkbox
|
||||
checked={!!visibleColumns[column.key]}
|
||||
onChange={(e) =>
|
||||
handleColumnVisibilityChange(column.key, e.target.checked)
|
||||
}
|
||||
>
|
||||
{column.title}
|
||||
</Checkbox>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ColumnSelectorModal;
|
||||
4123
web/src/components/table/channels/modals/EditChannelModal.jsx
Normal file
4123
web/src/components/table/channels/modals/EditChannelModal.jsx
Normal file
File diff suppressed because it is too large
Load Diff
754
web/src/components/table/channels/modals/EditTagModal.jsx
Normal file
754
web/src/components/table/channels/modals/EditTagModal.jsx
Normal file
@@ -0,0 +1,754 @@
|
||||
/*
|
||||
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, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import {
|
||||
API,
|
||||
showError,
|
||||
showInfo,
|
||||
showSuccess,
|
||||
showWarning,
|
||||
verifyJSON,
|
||||
selectFilter,
|
||||
} from '../../../../helpers';
|
||||
import {
|
||||
SideSheet,
|
||||
Space,
|
||||
Button,
|
||||
Typography,
|
||||
Spin,
|
||||
Banner,
|
||||
Card,
|
||||
Tag,
|
||||
Avatar,
|
||||
Form,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconSave,
|
||||
IconClose,
|
||||
IconBookmark,
|
||||
IconUser,
|
||||
IconCode,
|
||||
IconSetting,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { getChannelModels } from '../../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const MODEL_MAPPING_EXAMPLE = {
|
||||
'gpt-3.5-turbo': 'gpt-3.5-turbo-0125',
|
||||
};
|
||||
|
||||
const EditTagModal = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const { visible, tag, handleClose, refresh } = props;
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [originModelOptions, setOriginModelOptions] = useState([]);
|
||||
const [modelOptions, setModelOptions] = useState([]);
|
||||
const [groupOptions, setGroupOptions] = useState([]);
|
||||
const [customModel, setCustomModel] = useState('');
|
||||
const [modelSearchValue, setModelSearchValue] = useState('');
|
||||
const originInputs = {
|
||||
tag: '',
|
||||
new_tag: null,
|
||||
model_mapping: null,
|
||||
groups: [],
|
||||
models: [],
|
||||
param_override: null,
|
||||
header_override: null,
|
||||
};
|
||||
const [inputs, setInputs] = useState(originInputs);
|
||||
const modelSearchMatchedCount = useMemo(() => {
|
||||
const keyword = modelSearchValue.trim();
|
||||
if (!keyword) {
|
||||
return modelOptions.length;
|
||||
}
|
||||
return modelOptions.reduce(
|
||||
(count, option) => count + (selectFilter(keyword, option) ? 1 : 0),
|
||||
0,
|
||||
);
|
||||
}, [modelOptions, modelSearchValue]);
|
||||
const modelSearchHintText = useMemo(() => {
|
||||
const keyword = modelSearchValue.trim();
|
||||
if (!keyword || modelSearchMatchedCount !== 0) {
|
||||
return '';
|
||||
}
|
||||
return t('未匹配到模型,按回车键可将「{{name}}」作为自定义模型名添加', {
|
||||
name: keyword,
|
||||
});
|
||||
}, [modelSearchMatchedCount, modelSearchValue, t]);
|
||||
const formApiRef = useRef(null);
|
||||
const getInitValues = () => ({ ...originInputs });
|
||||
|
||||
const handleInputChange = (name, value) => {
|
||||
setInputs((inputs) => ({ ...inputs, [name]: value }));
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValue(name, value);
|
||||
}
|
||||
if (name === 'type') {
|
||||
let localModels = [];
|
||||
switch (value) {
|
||||
case 2:
|
||||
localModels = [
|
||||
'mj_imagine',
|
||||
'mj_variation',
|
||||
'mj_reroll',
|
||||
'mj_blend',
|
||||
'mj_upscale',
|
||||
'mj_describe',
|
||||
'mj_uploads',
|
||||
];
|
||||
break;
|
||||
case 5:
|
||||
localModels = [
|
||||
'swap_face',
|
||||
'mj_imagine',
|
||||
'mj_video',
|
||||
'mj_edits',
|
||||
'mj_variation',
|
||||
'mj_reroll',
|
||||
'mj_blend',
|
||||
'mj_upscale',
|
||||
'mj_describe',
|
||||
'mj_zoom',
|
||||
'mj_shorten',
|
||||
'mj_modal',
|
||||
'mj_inpaint',
|
||||
'mj_custom_zoom',
|
||||
'mj_high_variation',
|
||||
'mj_low_variation',
|
||||
'mj_pan',
|
||||
'mj_uploads',
|
||||
];
|
||||
break;
|
||||
case 36:
|
||||
localModels = ['suno_music', 'suno_lyrics'];
|
||||
break;
|
||||
case 53:
|
||||
localModels = [
|
||||
'NousResearch/Hermes-4-405B-FP8',
|
||||
'Qwen/Qwen3-235B-A22B-Thinking-2507',
|
||||
'Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8',
|
||||
'Qwen/Qwen3-235B-A22B-Instruct-2507',
|
||||
'zai-org/GLM-4.5-FP8',
|
||||
'openai/gpt-oss-120b',
|
||||
'deepseek-ai/DeepSeek-R1-0528',
|
||||
'deepseek-ai/DeepSeek-R1',
|
||||
'deepseek-ai/DeepSeek-V3-0324',
|
||||
'deepseek-ai/DeepSeek-V3.1',
|
||||
];
|
||||
break;
|
||||
default:
|
||||
localModels = getChannelModels(value);
|
||||
break;
|
||||
}
|
||||
if (inputs.models.length === 0) {
|
||||
setInputs((inputs) => ({ ...inputs, models: localModels }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const fetchModels = async () => {
|
||||
try {
|
||||
let res = await API.get(`/api/channel/models`);
|
||||
let localModelOptions = res.data.data.map((model) => ({
|
||||
label: model.id,
|
||||
value: model.id,
|
||||
}));
|
||||
setOriginModelOptions(localModelOptions);
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchGroups = async () => {
|
||||
try {
|
||||
let res = await API.get(`/api/group/`);
|
||||
if (res === undefined) {
|
||||
return;
|
||||
}
|
||||
setGroupOptions(
|
||||
res.data.data.map((group) => ({
|
||||
label: group,
|
||||
value: group,
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async (values) => {
|
||||
setLoading(true);
|
||||
const formVals = values || formApiRef.current?.getValues() || {};
|
||||
let data = { tag };
|
||||
if (formVals.model_mapping) {
|
||||
if (!verifyJSON(formVals.model_mapping)) {
|
||||
showInfo('模型映射必须是合法的 JSON 格式!');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
data.model_mapping = formVals.model_mapping;
|
||||
}
|
||||
if (formVals.groups && formVals.groups.length > 0) {
|
||||
data.groups = formVals.groups.join(',');
|
||||
}
|
||||
if (formVals.models && formVals.models.length > 0) {
|
||||
data.models = formVals.models.join(',');
|
||||
}
|
||||
if (
|
||||
formVals.param_override !== undefined &&
|
||||
formVals.param_override !== null
|
||||
) {
|
||||
if (typeof formVals.param_override !== 'string') {
|
||||
showInfo('参数覆盖必须是合法的 JSON 格式!');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const trimmedParamOverride = formVals.param_override.trim();
|
||||
if (trimmedParamOverride !== '' && !verifyJSON(trimmedParamOverride)) {
|
||||
showInfo('参数覆盖必须是合法的 JSON 格式!');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
data.param_override = trimmedParamOverride;
|
||||
}
|
||||
if (
|
||||
formVals.header_override !== undefined &&
|
||||
formVals.header_override !== null
|
||||
) {
|
||||
if (typeof formVals.header_override !== 'string') {
|
||||
showInfo('请求头覆盖必须是合法的 JSON 格式!');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const trimmedHeaderOverride = formVals.header_override.trim();
|
||||
if (trimmedHeaderOverride !== '' && !verifyJSON(trimmedHeaderOverride)) {
|
||||
showInfo('请求头覆盖必须是合法的 JSON 格式!');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
data.header_override = trimmedHeaderOverride;
|
||||
}
|
||||
data.new_tag = formVals.new_tag;
|
||||
if (
|
||||
data.model_mapping === undefined &&
|
||||
data.groups === undefined &&
|
||||
data.models === undefined &&
|
||||
data.new_tag === undefined &&
|
||||
data.param_override === undefined &&
|
||||
data.header_override === undefined
|
||||
) {
|
||||
showWarning('没有任何修改!');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
await submit(data);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const submit = async (data) => {
|
||||
try {
|
||||
const res = await API.put('/api/channel/tag', data);
|
||||
if (res?.data?.success) {
|
||||
showSuccess('标签更新成功!');
|
||||
refresh();
|
||||
handleClose();
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let localModelOptions = [...originModelOptions];
|
||||
inputs.models.forEach((model) => {
|
||||
if (!localModelOptions.find((option) => option.label === model)) {
|
||||
localModelOptions.push({
|
||||
label: model,
|
||||
value: model,
|
||||
});
|
||||
}
|
||||
});
|
||||
setModelOptions(localModelOptions);
|
||||
}, [originModelOptions, inputs.models]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTagModels = async () => {
|
||||
if (!tag) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.get(`/api/channel/tag/models?tag=${tag}`);
|
||||
if (res?.data?.success) {
|
||||
const models = res.data.data ? res.data.data.split(',') : [];
|
||||
handleInputChange('models', models);
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchModels().then();
|
||||
fetchGroups().then();
|
||||
fetchTagModels().then();
|
||||
setModelSearchValue('');
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValues({
|
||||
...getInitValues(),
|
||||
tag: tag,
|
||||
new_tag: tag,
|
||||
});
|
||||
}
|
||||
|
||||
setInputs({
|
||||
...originInputs,
|
||||
tag: tag,
|
||||
new_tag: tag,
|
||||
});
|
||||
}, [visible, tag]);
|
||||
|
||||
useEffect(() => {
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValues(inputs);
|
||||
}
|
||||
}, [inputs]);
|
||||
|
||||
const addCustomModels = () => {
|
||||
if (customModel.trim() === '') return;
|
||||
const modelArray = customModel.split(',').map((model) => model.trim());
|
||||
|
||||
let localModels = [...inputs.models];
|
||||
let localModelOptions = [...modelOptions];
|
||||
const addedModels = [];
|
||||
|
||||
modelArray.forEach((model) => {
|
||||
if (model && !localModels.includes(model)) {
|
||||
localModels.push(model);
|
||||
localModelOptions.push({
|
||||
key: model,
|
||||
text: model,
|
||||
value: model,
|
||||
});
|
||||
addedModels.push(model);
|
||||
}
|
||||
});
|
||||
|
||||
setModelOptions(localModelOptions);
|
||||
setCustomModel('');
|
||||
handleInputChange('models', localModels);
|
||||
|
||||
if (addedModels.length > 0) {
|
||||
showSuccess(
|
||||
t('已新增 {{count}} 个模型:{{list}}', {
|
||||
count: addedModels.length,
|
||||
list: addedModels.join(', '),
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
showInfo(t('未发现新增模型'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SideSheet
|
||||
placement='right'
|
||||
title={
|
||||
<Space>
|
||||
<Tag color='blue' shape='circle'>
|
||||
{t('编辑')}
|
||||
</Tag>
|
||||
<Title heading={4} className='m-0'>
|
||||
{t('编辑标签')}
|
||||
</Title>
|
||||
</Space>
|
||||
}
|
||||
bodyStyle={{ padding: '0' }}
|
||||
visible={visible}
|
||||
width={600}
|
||||
onCancel={handleClose}
|
||||
footer={
|
||||
<div className='flex justify-end bg-white'>
|
||||
<Space>
|
||||
<Button
|
||||
theme='solid'
|
||||
onClick={() => formApiRef.current?.submitForm()}
|
||||
loading={loading}
|
||||
icon={<IconSave />}
|
||||
>
|
||||
{t('保存')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='light'
|
||||
type='primary'
|
||||
onClick={handleClose}
|
||||
icon={<IconClose />}
|
||||
>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
closeIcon={null}
|
||||
>
|
||||
<Form
|
||||
key={tag || 'edit'}
|
||||
initValues={getInitValues()}
|
||||
getFormApi={(api) => (formApiRef.current = api)}
|
||||
onSubmit={handleSave}
|
||||
>
|
||||
{() => (
|
||||
<Spin spinning={loading}>
|
||||
<div className='p-2'>
|
||||
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
|
||||
{/* Header: Tag Info */}
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar size='small' color='blue' className='mr-2 shadow-md'>
|
||||
<IconBookmark size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>{t('标签信息')}</Text>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('标签的基本配置')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Banner
|
||||
type='warning'
|
||||
description={t('所有编辑均为覆盖操作,留空则不更改')}
|
||||
className='!rounded-lg mb-4'
|
||||
/>
|
||||
|
||||
<div className='space-y-4'>
|
||||
<Form.Input
|
||||
field='new_tag'
|
||||
label={t('标签名称')}
|
||||
placeholder={t('请输入新标签,留空则解散标签')}
|
||||
onChange={(value) => handleInputChange('new_tag', value)}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
|
||||
{/* Header: Model Config */}
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar
|
||||
size='small'
|
||||
color='purple'
|
||||
className='mr-2 shadow-md'
|
||||
>
|
||||
<IconCode size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>{t('模型配置')}</Text>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('模型选择和映射设置')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-4'>
|
||||
<Banner
|
||||
type='info'
|
||||
description={t(
|
||||
'当前模型列表为该标签下所有渠道模型列表最长的一个,并非所有渠道的并集,请注意可能导致某些渠道模型丢失。',
|
||||
)}
|
||||
className='!rounded-lg mb-4'
|
||||
/>
|
||||
<Form.Select
|
||||
field='models'
|
||||
label={t('模型')}
|
||||
placeholder={t('请选择该渠道所支持的模型,留空则不更改')}
|
||||
multiple
|
||||
filter={selectFilter}
|
||||
allowCreate
|
||||
autoClearSearchValue={false}
|
||||
searchPosition='dropdown'
|
||||
optionList={modelOptions}
|
||||
onSearch={(value) => setModelSearchValue(value)}
|
||||
innerBottomSlot={
|
||||
modelSearchHintText ? (
|
||||
<Text className='px-3 py-2 block text-xs !text-semi-color-text-2'>
|
||||
{modelSearchHintText}
|
||||
</Text>
|
||||
) : null
|
||||
}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(value) => handleInputChange('models', value)}
|
||||
/>
|
||||
|
||||
<Form.Input
|
||||
field='custom_model'
|
||||
label={t('自定义模型名称')}
|
||||
placeholder={t('输入自定义模型名称')}
|
||||
onChange={(value) => setCustomModel(value.trim())}
|
||||
suffix={
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
onClick={addCustomModels}
|
||||
>
|
||||
{t('填入')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Form.TextArea
|
||||
field='model_mapping'
|
||||
label={t('模型重定向')}
|
||||
placeholder={t(
|
||||
'此项可选,用于修改请求体中的模型名称,为一个 JSON 字符串,键为请求中模型名称,值为要替换的模型名称,留空则不更改',
|
||||
)}
|
||||
autosize
|
||||
onChange={(value) =>
|
||||
handleInputChange('model_mapping', value)
|
||||
}
|
||||
extraText={
|
||||
<Space>
|
||||
<Text
|
||||
className='!text-semi-color-primary cursor-pointer'
|
||||
onClick={() =>
|
||||
handleInputChange(
|
||||
'model_mapping',
|
||||
JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('填入模板')}
|
||||
</Text>
|
||||
<Text
|
||||
className='!text-semi-color-primary cursor-pointer'
|
||||
onClick={() =>
|
||||
handleInputChange(
|
||||
'model_mapping',
|
||||
JSON.stringify({}, null, 2),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('清空重定向')}
|
||||
</Text>
|
||||
<Text
|
||||
className='!text-semi-color-primary cursor-pointer'
|
||||
onClick={() => handleInputChange('model_mapping', '')}
|
||||
>
|
||||
{t('不更改')}
|
||||
</Text>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
|
||||
{/* Header: Advanced Settings */}
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar
|
||||
size='small'
|
||||
color='orange'
|
||||
className='mr-2 shadow-md'
|
||||
>
|
||||
<IconSetting size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>{t('高级设置')}</Text>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('渠道的高级配置选项')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-4'>
|
||||
<Form.TextArea
|
||||
field='param_override'
|
||||
label={t('参数覆盖')}
|
||||
placeholder={
|
||||
t('此项可选,用于覆盖请求参数。不支持覆盖 stream 参数') +
|
||||
'\n' +
|
||||
t('旧格式(直接覆盖):') +
|
||||
'\n{\n "temperature": 0,\n "max_tokens": 1000\n}' +
|
||||
'\n\n' +
|
||||
t('新格式(支持条件判断与json自定义):') +
|
||||
'\n{\n "operations": [\n {\n "path": "temperature",\n "mode": "set",\n "value": 0.7,\n "conditions": [\n {\n "path": "model",\n "mode": "prefix",\n "value": "gpt"\n }\n ]\n }\n ]\n}'
|
||||
}
|
||||
autosize
|
||||
showClear
|
||||
onChange={(value) =>
|
||||
handleInputChange('param_override', value)
|
||||
}
|
||||
extraText={
|
||||
<div className='flex gap-2 flex-wrap'>
|
||||
<Text
|
||||
className='!text-semi-color-primary cursor-pointer'
|
||||
onClick={() =>
|
||||
handleInputChange(
|
||||
'param_override',
|
||||
JSON.stringify({ temperature: 0 }, null, 2),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('旧格式模板')}
|
||||
</Text>
|
||||
<Text
|
||||
className='!text-semi-color-primary cursor-pointer'
|
||||
onClick={() =>
|
||||
handleInputChange(
|
||||
'param_override',
|
||||
JSON.stringify(
|
||||
{
|
||||
operations: [
|
||||
{
|
||||
path: 'temperature',
|
||||
mode: 'set',
|
||||
value: 0.7,
|
||||
conditions: [
|
||||
{
|
||||
path: 'model',
|
||||
mode: 'prefix',
|
||||
value: 'gpt',
|
||||
},
|
||||
],
|
||||
logic: 'AND',
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('新格式模板')}
|
||||
</Text>
|
||||
<Text
|
||||
className='!text-semi-color-primary cursor-pointer'
|
||||
onClick={() =>
|
||||
handleInputChange('param_override', null)
|
||||
}
|
||||
>
|
||||
{t('不更改')}
|
||||
</Text>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<Form.TextArea
|
||||
field='header_override'
|
||||
label={t('请求头覆盖')}
|
||||
placeholder={
|
||||
t('此项可选,用于覆盖请求头参数') +
|
||||
'\n' +
|
||||
t('格式示例:') +
|
||||
'\n{\n "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0",\n "Authorization": "Bearer {api_key}"\n}'
|
||||
}
|
||||
autosize
|
||||
showClear
|
||||
onChange={(value) =>
|
||||
handleInputChange('header_override', value)
|
||||
}
|
||||
extraText={
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className='flex gap-2 flex-wrap items-center'>
|
||||
<Text
|
||||
className='!text-semi-color-primary cursor-pointer'
|
||||
onClick={() =>
|
||||
handleInputChange(
|
||||
'header_override',
|
||||
JSON.stringify(
|
||||
{
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0',
|
||||
Authorization: 'Bearer {api_key}',
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('填入模板')}
|
||||
</Text>
|
||||
<Text
|
||||
className='!text-semi-color-primary cursor-pointer'
|
||||
onClick={() =>
|
||||
handleInputChange('header_override', null)
|
||||
}
|
||||
>
|
||||
{t('不更改')}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text type='tertiary' size='small'>
|
||||
{t('支持变量:')}
|
||||
</Text>
|
||||
<div className='text-xs text-tertiary ml-2'>
|
||||
<div>
|
||||
{t('渠道密钥')}: {'{api_key}'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
{/* Header: Group Settings */}
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar size='small' color='green' className='mr-2 shadow-md'>
|
||||
<IconUser size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>{t('分组设置')}</Text>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('用户分组配置')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-4'>
|
||||
<Form.Select
|
||||
field='groups'
|
||||
label={t('分组')}
|
||||
placeholder={t('请选择可以使用该渠道的分组,留空则不更改')}
|
||||
multiple
|
||||
allowAdditions
|
||||
additionLabel={t(
|
||||
'请在系统设置页面编辑分组倍率以添加新的分组:',
|
||||
)}
|
||||
optionList={groupOptions}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(value) => handleInputChange('groups', value)}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Spin>
|
||||
)}
|
||||
</Form>
|
||||
</SideSheet>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditTagModal;
|
||||
419
web/src/components/table/channels/modals/ModelSelectModal.jsx
Normal file
419
web/src/components/table/channels/modals/ModelSelectModal.jsx
Normal file
@@ -0,0 +1,419 @@
|
||||
/*
|
||||
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, { useState, useEffect, useMemo } from 'react';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
import {
|
||||
Modal,
|
||||
Checkbox,
|
||||
Spin,
|
||||
Input,
|
||||
Typography,
|
||||
Empty,
|
||||
Tabs,
|
||||
Collapse,
|
||||
Tooltip,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { IconSearch, IconInfoCircle } from '@douyinfe/semi-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getModelCategories } from '../../../../helpers/render';
|
||||
|
||||
const ModelSelectModal = ({
|
||||
visible,
|
||||
models = [],
|
||||
selected = [],
|
||||
redirectModels = [],
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getModelName = (model) => {
|
||||
if (!model) return '';
|
||||
if (typeof model === 'string') return model;
|
||||
if (typeof model === 'object' && model.model_name) return model.model_name;
|
||||
return String(model ?? '');
|
||||
};
|
||||
|
||||
const normalizedSelected = useMemo(
|
||||
() => (selected || []).map(getModelName),
|
||||
[selected],
|
||||
);
|
||||
|
||||
const [checkedList, setCheckedList] = useState(normalizedSelected);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [activeTab, setActiveTab] = useState('new');
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
const normalizeModelName = (model) =>
|
||||
typeof model === 'string' ? model.trim() : '';
|
||||
const normalizedRedirectModels = useMemo(
|
||||
() =>
|
||||
Array.from(
|
||||
new Set(
|
||||
(redirectModels || [])
|
||||
.map((model) => normalizeModelName(model))
|
||||
.filter(Boolean),
|
||||
),
|
||||
),
|
||||
[redirectModels],
|
||||
);
|
||||
const normalizedSelectedSet = useMemo(() => {
|
||||
const set = new Set();
|
||||
(selected || []).forEach((model) => {
|
||||
const normalized = normalizeModelName(model);
|
||||
if (normalized) {
|
||||
set.add(normalized);
|
||||
}
|
||||
});
|
||||
return set;
|
||||
}, [selected]);
|
||||
const classificationSet = useMemo(() => {
|
||||
const set = new Set(normalizedSelectedSet);
|
||||
normalizedRedirectModels.forEach((model) => set.add(model));
|
||||
return set;
|
||||
}, [normalizedSelectedSet, normalizedRedirectModels]);
|
||||
const redirectOnlySet = useMemo(() => {
|
||||
const set = new Set();
|
||||
normalizedRedirectModels.forEach((model) => {
|
||||
if (!normalizedSelectedSet.has(model)) {
|
||||
set.add(model);
|
||||
}
|
||||
});
|
||||
return set;
|
||||
}, [normalizedRedirectModels, normalizedSelectedSet]);
|
||||
|
||||
const filteredModels = models.filter((m) =>
|
||||
String(m || '')
|
||||
.toLowerCase()
|
||||
.includes(keyword.toLowerCase()),
|
||||
);
|
||||
|
||||
// 分类模型:新获取的模型和已有模型
|
||||
const isExistingModel = (model) =>
|
||||
classificationSet.has(normalizeModelName(model));
|
||||
const newModels = filteredModels.filter((model) => !isExistingModel(model));
|
||||
const existingModels = filteredModels.filter((model) =>
|
||||
isExistingModel(model),
|
||||
);
|
||||
|
||||
// 同步外部选中值
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setCheckedList(normalizedSelected);
|
||||
}
|
||||
}, [visible, normalizedSelected]);
|
||||
|
||||
// 当模型列表变化时,设置默认tab
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
// 默认显示新获取模型tab,如果没有新模型则显示已有模型
|
||||
const hasNewModels = newModels.length > 0;
|
||||
setActiveTab(hasNewModels ? 'new' : 'existing');
|
||||
}
|
||||
}, [visible, newModels.length, selected]);
|
||||
|
||||
const handleOk = () => {
|
||||
onConfirm && onConfirm(checkedList);
|
||||
};
|
||||
|
||||
// 按厂商分类模型
|
||||
const categorizeModels = (models) => {
|
||||
const categories = getModelCategories(t);
|
||||
const categorizedModels = {};
|
||||
const uncategorizedModels = [];
|
||||
|
||||
models.forEach((model) => {
|
||||
let foundCategory = false;
|
||||
for (const [key, category] of Object.entries(categories)) {
|
||||
if (key !== 'all' && category.filter({ model_name: model })) {
|
||||
if (!categorizedModels[key]) {
|
||||
categorizedModels[key] = {
|
||||
label: category.label,
|
||||
icon: category.icon,
|
||||
models: [],
|
||||
};
|
||||
}
|
||||
categorizedModels[key].models.push(model);
|
||||
foundCategory = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!foundCategory) {
|
||||
uncategorizedModels.push(model);
|
||||
}
|
||||
});
|
||||
|
||||
// 如果有未分类模型,添加到"其他"分类
|
||||
if (uncategorizedModels.length > 0) {
|
||||
categorizedModels['other'] = {
|
||||
label: t('其他'),
|
||||
icon: null,
|
||||
models: uncategorizedModels,
|
||||
};
|
||||
}
|
||||
|
||||
return categorizedModels;
|
||||
};
|
||||
|
||||
const newModelsByCategory = categorizeModels(newModels);
|
||||
const existingModelsByCategory = categorizeModels(existingModels);
|
||||
|
||||
// Tab列表配置
|
||||
const tabList = [
|
||||
...(newModels.length > 0
|
||||
? [
|
||||
{
|
||||
tab: `${t('新获取的模型')} (${newModels.length})`,
|
||||
itemKey: 'new',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(existingModels.length > 0
|
||||
? [
|
||||
{
|
||||
tab: `${t('已有的模型')} (${existingModels.length})`,
|
||||
itemKey: 'existing',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
// 处理分类全选/取消全选
|
||||
const handleCategorySelectAll = (categoryModels, isChecked) => {
|
||||
let newCheckedList = [...checkedList];
|
||||
|
||||
if (isChecked) {
|
||||
// 全选:添加该分类下所有未选中的模型
|
||||
categoryModels.forEach((model) => {
|
||||
if (!newCheckedList.includes(model)) {
|
||||
newCheckedList.push(model);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 取消全选:移除该分类下所有已选中的模型
|
||||
newCheckedList = newCheckedList.filter(
|
||||
(model) => !categoryModels.includes(model),
|
||||
);
|
||||
}
|
||||
|
||||
setCheckedList(newCheckedList);
|
||||
};
|
||||
|
||||
// 检查分类是否全选
|
||||
const isCategoryAllSelected = (categoryModels) => {
|
||||
return (
|
||||
categoryModels.length > 0 &&
|
||||
categoryModels.every((model) => checkedList.includes(model))
|
||||
);
|
||||
};
|
||||
|
||||
// 检查分类是否部分选中
|
||||
const isCategoryIndeterminate = (categoryModels) => {
|
||||
const selectedCount = categoryModels.filter((model) =>
|
||||
checkedList.includes(model),
|
||||
).length;
|
||||
return selectedCount > 0 && selectedCount < categoryModels.length;
|
||||
};
|
||||
|
||||
const renderModelsByCategory = (modelsByCategory, categoryKeyPrefix) => {
|
||||
const categoryEntries = Object.entries(modelsByCategory);
|
||||
if (categoryEntries.length === 0) return null;
|
||||
|
||||
// 生成所有面板的key,确保都展开
|
||||
const allActiveKeys = categoryEntries.map(
|
||||
(_, index) => `${categoryKeyPrefix}_${index}`,
|
||||
);
|
||||
|
||||
return (
|
||||
<Collapse
|
||||
key={`${categoryKeyPrefix}_${categoryEntries.length}`}
|
||||
defaultActiveKey={[]}
|
||||
>
|
||||
{categoryEntries.map(([key, categoryData], index) => (
|
||||
<Collapse.Panel
|
||||
key={`${categoryKeyPrefix}_${index}`}
|
||||
itemKey={`${categoryKeyPrefix}_${index}`}
|
||||
header={`${categoryData.label} (${categoryData.models.length})`}
|
||||
extra={
|
||||
<Checkbox
|
||||
checked={isCategoryAllSelected(categoryData.models)}
|
||||
indeterminate={isCategoryIndeterminate(categoryData.models)}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation(); // 防止触发面板折叠
|
||||
handleCategorySelectAll(
|
||||
categoryData.models,
|
||||
e.target.checked,
|
||||
);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()} // 防止点击checkbox时折叠面板
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className='flex items-center gap-2 mb-3'>
|
||||
{categoryData.icon}
|
||||
<Typography.Text type='secondary' size='small'>
|
||||
{t('已选择 {{selected}} / {{total}}', {
|
||||
selected: categoryData.models.filter((model) =>
|
||||
checkedList.includes(model),
|
||||
).length,
|
||||
total: categoryData.models.length,
|
||||
})}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className='grid grid-cols-2 gap-x-4'>
|
||||
{categoryData.models.map((model) => (
|
||||
<Checkbox key={model} value={model} className='my-1'>
|
||||
<span className='flex items-center gap-2'>
|
||||
<span>{model}</span>
|
||||
{redirectOnlySet.has(normalizeModelName(model)) && (
|
||||
<Tooltip
|
||||
position='top'
|
||||
content={t('来自模型重定向,尚未加入模型列表')}
|
||||
>
|
||||
<IconInfoCircle
|
||||
size='small'
|
||||
className='text-amber-500 cursor-help'
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
</Checkbox>
|
||||
))}
|
||||
</div>
|
||||
</Collapse.Panel>
|
||||
))}
|
||||
</Collapse>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
header={
|
||||
<div className='flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-4 py-4'>
|
||||
<Typography.Title heading={5} className='m-0'>
|
||||
{t('选择模型')}
|
||||
</Typography.Title>
|
||||
<div className='flex-shrink-0'>
|
||||
<Tabs
|
||||
type='slash'
|
||||
size='small'
|
||||
tabList={tabList}
|
||||
activeKey={activeTab}
|
||||
onChange={(key) => setActiveTab(key)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
visible={visible}
|
||||
onOk={handleOk}
|
||||
onCancel={onCancel}
|
||||
okText={t('确定')}
|
||||
cancelText={t('取消')}
|
||||
size={isMobile ? 'full-width' : 'large'}
|
||||
closeOnEsc
|
||||
maskClosable
|
||||
centered
|
||||
>
|
||||
<Input
|
||||
prefix={<IconSearch size={14} />}
|
||||
placeholder={t('搜索模型')}
|
||||
value={keyword}
|
||||
onChange={(v) => setKeyword(v)}
|
||||
showClear
|
||||
/>
|
||||
|
||||
<Spin spinning={!models || models.length === 0}>
|
||||
<div style={{ maxHeight: 400, overflowY: 'auto', paddingRight: 8 }}>
|
||||
{filteredModels.length === 0 ? (
|
||||
<Empty
|
||||
image={
|
||||
<IllustrationNoResult style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
darkModeImage={
|
||||
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
description={t('暂无匹配模型')}
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
) : (
|
||||
<Checkbox.Group
|
||||
value={checkedList}
|
||||
onChange={(vals) => setCheckedList(vals)}
|
||||
>
|
||||
{activeTab === 'new' && newModels.length > 0 && (
|
||||
<div>{renderModelsByCategory(newModelsByCategory, 'new')}</div>
|
||||
)}
|
||||
{activeTab === 'existing' && existingModels.length > 0 && (
|
||||
<div>
|
||||
{renderModelsByCategory(existingModelsByCategory, 'existing')}
|
||||
</div>
|
||||
)}
|
||||
</Checkbox.Group>
|
||||
)}
|
||||
</div>
|
||||
</Spin>
|
||||
|
||||
<Typography.Text
|
||||
type='secondary'
|
||||
size='small'
|
||||
className='block text-right mt-4'
|
||||
>
|
||||
<div className='flex items-center justify-end gap-2'>
|
||||
{(() => {
|
||||
const currentModels =
|
||||
activeTab === 'new' ? newModels : existingModels;
|
||||
const currentSelected = currentModels.filter((model) =>
|
||||
checkedList.includes(model),
|
||||
).length;
|
||||
const isAllSelected =
|
||||
currentModels.length > 0 &&
|
||||
currentSelected === currentModels.length;
|
||||
const isIndeterminate =
|
||||
currentSelected > 0 && currentSelected < currentModels.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<span>
|
||||
{t('已选择 {{selected}} / {{total}}', {
|
||||
selected: currentSelected,
|
||||
total: currentModels.length,
|
||||
})}
|
||||
</span>
|
||||
<Checkbox
|
||||
checked={isAllSelected}
|
||||
indeterminate={isIndeterminate}
|
||||
onChange={(e) => {
|
||||
handleCategorySelectAll(currentModels, e.target.checked);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</Typography.Text>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelSelectModal;
|
||||
369
web/src/components/table/channels/modals/ModelTestModal.jsx
Normal file
369
web/src/components/table/channels/modals/ModelTestModal.jsx
Normal file
@@ -0,0 +1,369 @@
|
||||
/*
|
||||
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 from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Button,
|
||||
Input,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
Select,
|
||||
Switch,
|
||||
Banner,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconSearch, IconInfoCircle } from '@douyinfe/semi-icons';
|
||||
import { copy, showError, showInfo, showSuccess } from '../../../../helpers';
|
||||
import { MODEL_TABLE_PAGE_SIZE } from '../../../../constants';
|
||||
|
||||
const ModelTestModal = ({
|
||||
showModelTestModal,
|
||||
currentTestChannel,
|
||||
handleCloseModal,
|
||||
isBatchTesting,
|
||||
batchTestModels,
|
||||
modelSearchKeyword,
|
||||
setModelSearchKeyword,
|
||||
selectedModelKeys,
|
||||
setSelectedModelKeys,
|
||||
modelTestResults,
|
||||
testingModels,
|
||||
testChannel,
|
||||
modelTablePage,
|
||||
setModelTablePage,
|
||||
selectedEndpointType,
|
||||
setSelectedEndpointType,
|
||||
isStreamTest,
|
||||
setIsStreamTest,
|
||||
allSelectingRef,
|
||||
isMobile,
|
||||
t,
|
||||
}) => {
|
||||
const hasChannel = Boolean(currentTestChannel);
|
||||
const streamToggleDisabled = [
|
||||
'embeddings',
|
||||
'image-generation',
|
||||
'jina-rerank',
|
||||
'openai-response-compact',
|
||||
].includes(selectedEndpointType);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (streamToggleDisabled && isStreamTest) {
|
||||
setIsStreamTest(false);
|
||||
}
|
||||
}, [streamToggleDisabled, isStreamTest, setIsStreamTest]);
|
||||
|
||||
const filteredModels = hasChannel
|
||||
? currentTestChannel.models
|
||||
.split(',')
|
||||
.filter((model) =>
|
||||
model.toLowerCase().includes(modelSearchKeyword.toLowerCase()),
|
||||
)
|
||||
: [];
|
||||
|
||||
const endpointTypeOptions = [
|
||||
{ value: '', label: t('自动检测') },
|
||||
{ value: 'openai', label: 'OpenAI (/v1/chat/completions)' },
|
||||
{ value: 'openai-response', label: 'OpenAI Response (/v1/responses)' },
|
||||
{
|
||||
value: 'openai-response-compact',
|
||||
label: 'OpenAI Response Compaction (/v1/responses/compact)',
|
||||
},
|
||||
{ value: 'anthropic', label: 'Anthropic (/v1/messages)' },
|
||||
{
|
||||
value: 'gemini',
|
||||
label: 'Gemini (/v1beta/models/{model}:generateContent)',
|
||||
},
|
||||
{ value: 'jina-rerank', label: 'Jina Rerank (/v1/rerank)' },
|
||||
{
|
||||
value: 'image-generation',
|
||||
label: t('图像生成') + ' (/v1/images/generations)',
|
||||
},
|
||||
{ value: 'embeddings', label: 'Embeddings (/v1/embeddings)' },
|
||||
];
|
||||
|
||||
const handleCopySelected = () => {
|
||||
if (selectedModelKeys.length === 0) {
|
||||
showError(t('请先选择模型!'));
|
||||
return;
|
||||
}
|
||||
copy(selectedModelKeys.join(',')).then((ok) => {
|
||||
if (ok) {
|
||||
showSuccess(
|
||||
t('已复制 ${count} 个模型').replace(
|
||||
'${count}',
|
||||
selectedModelKeys.length,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
showError(t('复制失败,请手动复制'));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectSuccess = () => {
|
||||
if (!currentTestChannel) return;
|
||||
const successKeys = currentTestChannel.models
|
||||
.split(',')
|
||||
.filter((m) => m.toLowerCase().includes(modelSearchKeyword.toLowerCase()))
|
||||
.filter((m) => {
|
||||
const result = modelTestResults[`${currentTestChannel.id}-${m}`];
|
||||
return result && result.success;
|
||||
});
|
||||
if (successKeys.length === 0) {
|
||||
showInfo(t('暂无成功模型'));
|
||||
}
|
||||
setSelectedModelKeys(successKeys);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('模型名称'),
|
||||
dataIndex: 'model',
|
||||
render: (text) => (
|
||||
<div className='flex items-center'>
|
||||
<Typography.Text strong>{text}</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('状态'),
|
||||
dataIndex: 'status',
|
||||
render: (text, record) => {
|
||||
const testResult =
|
||||
modelTestResults[`${currentTestChannel.id}-${record.model}`];
|
||||
const isTesting = testingModels.has(record.model);
|
||||
|
||||
if (isTesting) {
|
||||
return (
|
||||
<Tag color='blue' shape='circle'>
|
||||
{t('测试中')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
if (!testResult) {
|
||||
return (
|
||||
<Tag color='grey' shape='circle'>
|
||||
{t('未开始')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-2'>
|
||||
<Tag color={testResult.success ? 'green' : 'red'} shape='circle'>
|
||||
{testResult.success ? t('成功') : t('失败')}
|
||||
</Tag>
|
||||
{testResult.success && (
|
||||
<Typography.Text type='tertiary'>
|
||||
{t('请求时长: ${time}s').replace(
|
||||
'${time}',
|
||||
testResult.time.toFixed(2),
|
||||
)}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
dataIndex: 'operate',
|
||||
render: (text, record) => {
|
||||
const isTesting = testingModels.has(record.model);
|
||||
return (
|
||||
<Button
|
||||
type='tertiary'
|
||||
onClick={() =>
|
||||
testChannel(
|
||||
currentTestChannel,
|
||||
record.model,
|
||||
selectedEndpointType,
|
||||
isStreamTest,
|
||||
)
|
||||
}
|
||||
loading={isTesting}
|
||||
size='small'
|
||||
>
|
||||
{t('测试')}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const dataSource = (() => {
|
||||
if (!hasChannel) return [];
|
||||
const start = (modelTablePage - 1) * MODEL_TABLE_PAGE_SIZE;
|
||||
const end = start + MODEL_TABLE_PAGE_SIZE;
|
||||
return filteredModels.slice(start, end).map((model) => ({
|
||||
model,
|
||||
key: model,
|
||||
}));
|
||||
})();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
hasChannel ? (
|
||||
<div className='flex flex-col gap-2 w-full'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Typography.Text
|
||||
strong
|
||||
className='!text-[var(--semi-color-text-0)] !text-base'
|
||||
>
|
||||
{currentTestChannel.name} {t('渠道的模型测试')}
|
||||
</Typography.Text>
|
||||
<Typography.Text type='tertiary' size='small'>
|
||||
{t('共')} {currentTestChannel.models.split(',').length}{' '}
|
||||
{t('个模型')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
visible={showModelTestModal}
|
||||
onCancel={handleCloseModal}
|
||||
footer={
|
||||
hasChannel ? (
|
||||
<div className='flex justify-end'>
|
||||
{isBatchTesting ? (
|
||||
<Button type='danger' onClick={handleCloseModal}>
|
||||
{t('停止测试')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button type='tertiary' onClick={handleCloseModal}>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={batchTestModels}
|
||||
loading={isBatchTesting}
|
||||
disabled={isBatchTesting}
|
||||
>
|
||||
{isBatchTesting
|
||||
? t('测试中...')
|
||||
: t('批量测试${count}个模型').replace(
|
||||
'${count}',
|
||||
filteredModels.length,
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
maskClosable={!isBatchTesting}
|
||||
className='!rounded-lg'
|
||||
size={isMobile ? 'full-width' : 'large'}
|
||||
>
|
||||
{hasChannel && (
|
||||
<div className='model-test-scroll'>
|
||||
{/* Endpoint toolbar */}
|
||||
<div className='flex flex-col sm:flex-row sm:items-center gap-2 w-full mb-2'>
|
||||
<div className='flex items-center gap-2 flex-1 min-w-0'>
|
||||
<Typography.Text strong className='shrink-0'>
|
||||
{t('端点类型')}:
|
||||
</Typography.Text>
|
||||
<Select
|
||||
value={selectedEndpointType}
|
||||
onChange={setSelectedEndpointType}
|
||||
optionList={endpointTypeOptions}
|
||||
className='!w-full min-w-0'
|
||||
placeholder={t('选择端点类型')}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex items-center justify-between sm:justify-end gap-2 shrink-0'>
|
||||
<Typography.Text strong className='shrink-0'>
|
||||
{t('流式')}:
|
||||
</Typography.Text>
|
||||
<Switch
|
||||
checked={isStreamTest}
|
||||
onChange={setIsStreamTest}
|
||||
size='small'
|
||||
disabled={streamToggleDisabled}
|
||||
aria-label={t('流式')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Banner
|
||||
type='info'
|
||||
closeIcon={null}
|
||||
icon={<IconInfoCircle />}
|
||||
className='!rounded-lg mb-2'
|
||||
description={t(
|
||||
'说明:本页测试为非流式请求;若渠道仅支持流式返回,可能出现测试失败,请以实际使用为准。',
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* 搜索与操作按钮 */}
|
||||
<div className='flex flex-col sm:flex-row sm:items-center gap-2 w-full mb-2'>
|
||||
<Input
|
||||
placeholder={t('搜索模型...')}
|
||||
value={modelSearchKeyword}
|
||||
onChange={(v) => {
|
||||
setModelSearchKeyword(v);
|
||||
setModelTablePage(1);
|
||||
}}
|
||||
className='!w-full sm:!flex-1'
|
||||
prefix={<IconSearch />}
|
||||
showClear
|
||||
/>
|
||||
|
||||
<div className='flex items-center justify-end gap-2'>
|
||||
<Button onClick={handleCopySelected}>{t('复制已选')}</Button>
|
||||
<Button type='tertiary' onClick={handleSelectSuccess}>
|
||||
{t('选择成功')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={dataSource}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedModelKeys,
|
||||
onChange: (keys) => {
|
||||
if (allSelectingRef.current) {
|
||||
allSelectingRef.current = false;
|
||||
return;
|
||||
}
|
||||
setSelectedModelKeys(keys);
|
||||
},
|
||||
onSelectAll: (checked) => {
|
||||
allSelectingRef.current = true;
|
||||
setSelectedModelKeys(checked ? filteredModels : []);
|
||||
},
|
||||
}}
|
||||
pagination={{
|
||||
currentPage: modelTablePage,
|
||||
pageSize: MODEL_TABLE_PAGE_SIZE,
|
||||
total: filteredModels.length,
|
||||
showSizeChanger: false,
|
||||
onPageChange: (page) => setModelTablePage(page),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelTestModal;
|
||||
742
web/src/components/table/channels/modals/MultiKeyManageModal.jsx
Normal file
742
web/src/components/table/channels/modals/MultiKeyManageModal.jsx
Normal file
@@ -0,0 +1,742 @@
|
||||
/*
|
||||
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, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Modal,
|
||||
Button,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
Space,
|
||||
Tooltip,
|
||||
Popconfirm,
|
||||
Empty,
|
||||
Spin,
|
||||
Select,
|
||||
Row,
|
||||
Col,
|
||||
Badge,
|
||||
Progress,
|
||||
Card,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import {
|
||||
API,
|
||||
showError,
|
||||
showSuccess,
|
||||
timestamp2string,
|
||||
} from '../../../../helpers';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const MultiKeyManageModal = ({ visible, onCancel, channel, onRefresh }) => {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [keyStatusList, setKeyStatusList] = useState([]);
|
||||
const [operationLoading, setOperationLoading] = useState({});
|
||||
|
||||
// Pagination states
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
|
||||
// Statistics states
|
||||
const [enabledCount, setEnabledCount] = useState(0);
|
||||
const [manualDisabledCount, setManualDisabledCount] = useState(0);
|
||||
const [autoDisabledCount, setAutoDisabledCount] = useState(0);
|
||||
|
||||
// Filter states
|
||||
const [statusFilter, setStatusFilter] = useState(null); // null=all, 1=enabled, 2=manual_disabled, 3=auto_disabled
|
||||
|
||||
// Load key status data
|
||||
const loadKeyStatus = async (
|
||||
page = currentPage,
|
||||
size = pageSize,
|
||||
status = statusFilter,
|
||||
) => {
|
||||
if (!channel?.id) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const requestData = {
|
||||
channel_id: channel.id,
|
||||
action: 'get_key_status',
|
||||
page: page,
|
||||
page_size: size,
|
||||
};
|
||||
|
||||
// Add status filter if specified
|
||||
if (status !== null) {
|
||||
requestData.status = status;
|
||||
}
|
||||
|
||||
const res = await API.post('/api/channel/multi_key/manage', requestData);
|
||||
|
||||
if (res.data.success) {
|
||||
const data = res.data.data;
|
||||
setKeyStatusList(data.keys || []);
|
||||
setTotal(data.total || 0);
|
||||
setCurrentPage(data.page || 1);
|
||||
setPageSize(data.page_size || 10);
|
||||
setTotalPages(data.total_pages || 0);
|
||||
|
||||
// Update statistics (these are always the overall statistics)
|
||||
setEnabledCount(data.enabled_count || 0);
|
||||
setManualDisabledCount(data.manual_disabled_count || 0);
|
||||
setAutoDisabledCount(data.auto_disabled_count || 0);
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showError(t('获取密钥状态失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Disable a specific key
|
||||
const handleDisableKey = async (keyIndex) => {
|
||||
const operationId = `disable_${keyIndex}`;
|
||||
setOperationLoading((prev) => ({ ...prev, [operationId]: true }));
|
||||
|
||||
try {
|
||||
const res = await API.post('/api/channel/multi_key/manage', {
|
||||
channel_id: channel.id,
|
||||
action: 'disable_key',
|
||||
key_index: keyIndex,
|
||||
});
|
||||
|
||||
if (res.data.success) {
|
||||
showSuccess(t('密钥已禁用'));
|
||||
await loadKeyStatus(currentPage, pageSize); // Reload current page
|
||||
onRefresh && onRefresh(); // Refresh parent component
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('禁用密钥失败'));
|
||||
} finally {
|
||||
setOperationLoading((prev) => ({ ...prev, [operationId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// Enable a specific key
|
||||
const handleEnableKey = async (keyIndex) => {
|
||||
const operationId = `enable_${keyIndex}`;
|
||||
setOperationLoading((prev) => ({ ...prev, [operationId]: true }));
|
||||
|
||||
try {
|
||||
const res = await API.post('/api/channel/multi_key/manage', {
|
||||
channel_id: channel.id,
|
||||
action: 'enable_key',
|
||||
key_index: keyIndex,
|
||||
});
|
||||
|
||||
if (res.data.success) {
|
||||
showSuccess(t('密钥已启用'));
|
||||
await loadKeyStatus(currentPage, pageSize); // Reload current page
|
||||
onRefresh && onRefresh(); // Refresh parent component
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('启用密钥失败'));
|
||||
} finally {
|
||||
setOperationLoading((prev) => ({ ...prev, [operationId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// Enable all disabled keys
|
||||
const handleEnableAll = async () => {
|
||||
setOperationLoading((prev) => ({ ...prev, enable_all: true }));
|
||||
|
||||
try {
|
||||
const res = await API.post('/api/channel/multi_key/manage', {
|
||||
channel_id: channel.id,
|
||||
action: 'enable_all_keys',
|
||||
});
|
||||
|
||||
if (res.data.success) {
|
||||
showSuccess(res.data.message || t('已启用所有密钥'));
|
||||
// Reset to first page after bulk operation
|
||||
setCurrentPage(1);
|
||||
await loadKeyStatus(1, pageSize);
|
||||
onRefresh && onRefresh(); // Refresh parent component
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('启用所有密钥失败'));
|
||||
} finally {
|
||||
setOperationLoading((prev) => ({ ...prev, enable_all: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// Disable all enabled keys
|
||||
const handleDisableAll = async () => {
|
||||
setOperationLoading((prev) => ({ ...prev, disable_all: true }));
|
||||
|
||||
try {
|
||||
const res = await API.post('/api/channel/multi_key/manage', {
|
||||
channel_id: channel.id,
|
||||
action: 'disable_all_keys',
|
||||
});
|
||||
|
||||
if (res.data.success) {
|
||||
showSuccess(res.data.message || t('已禁用所有密钥'));
|
||||
// Reset to first page after bulk operation
|
||||
setCurrentPage(1);
|
||||
await loadKeyStatus(1, pageSize);
|
||||
onRefresh && onRefresh(); // Refresh parent component
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('禁用所有密钥失败'));
|
||||
} finally {
|
||||
setOperationLoading((prev) => ({ ...prev, disable_all: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// Delete all disabled keys
|
||||
const handleDeleteDisabledKeys = async () => {
|
||||
setOperationLoading((prev) => ({ ...prev, delete_disabled: true }));
|
||||
|
||||
try {
|
||||
const res = await API.post('/api/channel/multi_key/manage', {
|
||||
channel_id: channel.id,
|
||||
action: 'delete_disabled_keys',
|
||||
});
|
||||
|
||||
if (res.data.success) {
|
||||
showSuccess(res.data.message);
|
||||
// Reset to first page after deletion as data structure might change
|
||||
setCurrentPage(1);
|
||||
await loadKeyStatus(1, pageSize);
|
||||
onRefresh && onRefresh(); // Refresh parent component
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('删除禁用密钥失败'));
|
||||
} finally {
|
||||
setOperationLoading((prev) => ({ ...prev, delete_disabled: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// Delete a specific key
|
||||
const handleDeleteKey = async (keyIndex) => {
|
||||
const operationId = `delete_${keyIndex}`;
|
||||
setOperationLoading((prev) => ({ ...prev, [operationId]: true }));
|
||||
|
||||
try {
|
||||
const res = await API.post('/api/channel/multi_key/manage', {
|
||||
channel_id: channel.id,
|
||||
action: 'delete_key',
|
||||
key_index: keyIndex,
|
||||
});
|
||||
|
||||
if (res.data.success) {
|
||||
showSuccess(t('密钥已删除'));
|
||||
await loadKeyStatus(currentPage, pageSize); // Reload current page
|
||||
onRefresh && onRefresh(); // Refresh parent component
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('删除密钥失败'));
|
||||
} finally {
|
||||
setOperationLoading((prev) => ({ ...prev, [operationId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// Handle page change
|
||||
const handlePageChange = (page) => {
|
||||
setCurrentPage(page);
|
||||
loadKeyStatus(page, pageSize);
|
||||
};
|
||||
|
||||
// Handle page size change
|
||||
const handlePageSizeChange = (size) => {
|
||||
setPageSize(size);
|
||||
setCurrentPage(1); // Reset to first page
|
||||
loadKeyStatus(1, size);
|
||||
};
|
||||
|
||||
// Handle status filter change
|
||||
const handleStatusFilterChange = (status) => {
|
||||
setStatusFilter(status);
|
||||
setCurrentPage(1); // Reset to first page when filter changes
|
||||
loadKeyStatus(1, pageSize, status);
|
||||
};
|
||||
|
||||
// Effect to load data when modal opens
|
||||
useEffect(() => {
|
||||
if (visible && channel?.id) {
|
||||
setCurrentPage(1); // Reset to first page when opening
|
||||
loadKeyStatus(1, pageSize);
|
||||
}
|
||||
}, [visible, channel?.id]);
|
||||
|
||||
// Reset pagination when modal closes
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setCurrentPage(1);
|
||||
setKeyStatusList([]);
|
||||
setTotal(0);
|
||||
setTotalPages(0);
|
||||
setEnabledCount(0);
|
||||
setManualDisabledCount(0);
|
||||
setAutoDisabledCount(0);
|
||||
setStatusFilter(null); // Reset filter
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
// Percentages for progress display
|
||||
const enabledPercent =
|
||||
total > 0 ? Math.round((enabledCount / total) * 100) : 0;
|
||||
const manualDisabledPercent =
|
||||
total > 0 ? Math.round((manualDisabledCount / total) * 100) : 0;
|
||||
const autoDisabledPercent =
|
||||
total > 0 ? Math.round((autoDisabledCount / total) * 100) : 0;
|
||||
|
||||
// 取消饼图:不再需要图表数据与配置
|
||||
|
||||
// Get status tag component
|
||||
const renderStatusTag = (status) => {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return (
|
||||
<Tag color='green' shape='circle' size='small'>
|
||||
{t('已启用')}
|
||||
</Tag>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<Tag color='red' shape='circle' size='small'>
|
||||
{t('已禁用')}
|
||||
</Tag>
|
||||
);
|
||||
case 3:
|
||||
return (
|
||||
<Tag color='orange' shape='circle' size='small'>
|
||||
{t('自动禁用')}
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Tag color='grey' shape='circle' size='small'>
|
||||
{t('未知状态')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Table columns definition
|
||||
const columns = [
|
||||
{
|
||||
title: t('索引'),
|
||||
dataIndex: 'index',
|
||||
render: (text) => `#${text}`,
|
||||
},
|
||||
// {
|
||||
// title: t('密钥预览'),
|
||||
// dataIndex: 'key_preview',
|
||||
// render: (text) => (
|
||||
// <Text code style={{ fontSize: '12px' }}>
|
||||
// {text}
|
||||
// </Text>
|
||||
// ),
|
||||
// },
|
||||
{
|
||||
title: t('状态'),
|
||||
dataIndex: 'status',
|
||||
render: (status) => renderStatusTag(status),
|
||||
},
|
||||
{
|
||||
title: t('禁用原因'),
|
||||
dataIndex: 'reason',
|
||||
render: (reason, record) => {
|
||||
if (record.status === 1 || !reason) {
|
||||
return <Text type='quaternary'>-</Text>;
|
||||
}
|
||||
return (
|
||||
<Tooltip content={reason}>
|
||||
<Text style={{ maxWidth: '200px', display: 'block' }} ellipsis>
|
||||
{reason}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('禁用时间'),
|
||||
dataIndex: 'disabled_time',
|
||||
render: (time, record) => {
|
||||
if (record.status === 1 || !time) {
|
||||
return <Text type='quaternary'>-</Text>;
|
||||
}
|
||||
return (
|
||||
<Tooltip content={timestamp2string(time)}>
|
||||
<Text style={{ fontSize: '12px' }}>{timestamp2string(time)}</Text>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('操作'),
|
||||
key: 'action',
|
||||
fixed: 'right',
|
||||
width: 150,
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
{record.status === 1 ? (
|
||||
<Button
|
||||
type='danger'
|
||||
size='small'
|
||||
loading={operationLoading[`disable_${record.index}`]}
|
||||
onClick={() => handleDisableKey(record.index)}
|
||||
>
|
||||
{t('禁用')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type='primary'
|
||||
size='small'
|
||||
loading={operationLoading[`enable_${record.index}`]}
|
||||
onClick={() => handleEnableKey(record.index)}
|
||||
>
|
||||
{t('启用')}
|
||||
</Button>
|
||||
)}
|
||||
<Popconfirm
|
||||
title={t('确定要删除此密钥吗?')}
|
||||
content={t('此操作不可撤销,将永久删除该密钥')}
|
||||
onConfirm={() => handleDeleteKey(record.index)}
|
||||
okType={'danger'}
|
||||
position={'topRight'}
|
||||
>
|
||||
<Button
|
||||
type='danger'
|
||||
size='small'
|
||||
loading={operationLoading[`delete_${record.index}`]}
|
||||
>
|
||||
{t('删除')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<Space>
|
||||
<Text>{t('多密钥管理')}</Text>
|
||||
{channel?.name && (
|
||||
<Tag size='small' shape='circle' color='white'>
|
||||
{channel.name}
|
||||
</Tag>
|
||||
)}
|
||||
<Tag size='small' shape='circle' color='white'>
|
||||
{t('总密钥数')}: {total}
|
||||
</Tag>
|
||||
{channel?.channel_info?.multi_key_mode && (
|
||||
<Tag size='small' shape='circle' color='white'>
|
||||
{channel.channel_info.multi_key_mode === 'random'
|
||||
? t('随机模式')
|
||||
: t('轮询模式')}
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
visible={visible}
|
||||
onCancel={onCancel}
|
||||
width={900}
|
||||
footer={null}
|
||||
>
|
||||
<div className='flex flex-col mb-5'>
|
||||
{/* Stats & Mode */}
|
||||
<div
|
||||
className='rounded-xl p-4 mb-3'
|
||||
style={{
|
||||
background: 'var(--semi-color-bg-1)',
|
||||
border: '1px solid var(--semi-color-border)',
|
||||
}}
|
||||
>
|
||||
<Row gutter={16} align='middle'>
|
||||
<Col span={8}>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--semi-color-bg-0)',
|
||||
border: '1px solid var(--semi-color-border)',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
}}
|
||||
>
|
||||
<div className='flex items-center gap-2 mb-2'>
|
||||
<Badge dot type='success' />
|
||||
<Text type='tertiary'>{t('已启用')}</Text>
|
||||
</div>
|
||||
<div className='flex items-end gap-2 mb-2'>
|
||||
<Text
|
||||
style={{ fontSize: 18, fontWeight: 700, color: '#22c55e' }}
|
||||
>
|
||||
{enabledCount}
|
||||
</Text>
|
||||
<Text
|
||||
style={{ fontSize: 18, color: 'var(--semi-color-text-2)' }}
|
||||
>
|
||||
/ {total}
|
||||
</Text>
|
||||
</div>
|
||||
<Progress
|
||||
percent={enabledPercent}
|
||||
showInfo={false}
|
||||
size='small'
|
||||
stroke='#22c55e'
|
||||
style={{ height: 6, borderRadius: 999 }}
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--semi-color-bg-0)',
|
||||
border: '1px solid var(--semi-color-border)',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
}}
|
||||
>
|
||||
<div className='flex items-center gap-2 mb-2'>
|
||||
<Badge dot type='danger' />
|
||||
<Text type='tertiary'>{t('手动禁用')}</Text>
|
||||
</div>
|
||||
<div className='flex items-end gap-2 mb-2'>
|
||||
<Text
|
||||
style={{ fontSize: 18, fontWeight: 700, color: '#ef4444' }}
|
||||
>
|
||||
{manualDisabledCount}
|
||||
</Text>
|
||||
<Text
|
||||
style={{ fontSize: 18, color: 'var(--semi-color-text-2)' }}
|
||||
>
|
||||
/ {total}
|
||||
</Text>
|
||||
</div>
|
||||
<Progress
|
||||
percent={manualDisabledPercent}
|
||||
showInfo={false}
|
||||
size='small'
|
||||
stroke='#ef4444'
|
||||
style={{ height: 6, borderRadius: 999 }}
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--semi-color-bg-0)',
|
||||
border: '1px solid var(--semi-color-border)',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
}}
|
||||
>
|
||||
<div className='flex items-center gap-2 mb-2'>
|
||||
<Badge dot type='warning' />
|
||||
<Text type='tertiary'>{t('自动禁用')}</Text>
|
||||
</div>
|
||||
<div className='flex items-end gap-2 mb-2'>
|
||||
<Text
|
||||
style={{ fontSize: 18, fontWeight: 700, color: '#f59e0b' }}
|
||||
>
|
||||
{autoDisabledCount}
|
||||
</Text>
|
||||
<Text
|
||||
style={{ fontSize: 18, color: 'var(--semi-color-text-2)' }}
|
||||
>
|
||||
/ {total}
|
||||
</Text>
|
||||
</div>
|
||||
<Progress
|
||||
percent={autoDisabledPercent}
|
||||
showInfo={false}
|
||||
size='small'
|
||||
stroke='#f59e0b'
|
||||
style={{ height: 6, borderRadius: 999 }}
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className='flex-1 flex flex-col min-h-0'>
|
||||
<Spin spinning={loading}>
|
||||
<Card className='!rounded-xl'>
|
||||
<Table
|
||||
title={() => (
|
||||
<Row gutter={12} style={{ width: '100%' }}>
|
||||
<Col span={14}>
|
||||
<Row gutter={12} style={{ alignItems: 'center' }}>
|
||||
<Col>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={handleStatusFilterChange}
|
||||
size='small'
|
||||
placeholder={t('全部状态')}
|
||||
>
|
||||
<Select.Option value={null}>
|
||||
{t('全部状态')}
|
||||
</Select.Option>
|
||||
<Select.Option value={1}>
|
||||
{t('已启用')}
|
||||
</Select.Option>
|
||||
<Select.Option value={2}>
|
||||
{t('手动禁用')}
|
||||
</Select.Option>
|
||||
<Select.Option value={3}>
|
||||
{t('自动禁用')}
|
||||
</Select.Option>
|
||||
</Select>
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
<Col
|
||||
span={10}
|
||||
style={{ display: 'flex', justifyContent: 'flex-end' }}
|
||||
>
|
||||
<Space>
|
||||
<Button
|
||||
size='small'
|
||||
type='tertiary'
|
||||
onClick={() => loadKeyStatus(currentPage, pageSize)}
|
||||
loading={loading}
|
||||
>
|
||||
{t('刷新')}
|
||||
</Button>
|
||||
{manualDisabledCount + autoDisabledCount > 0 && (
|
||||
<Popconfirm
|
||||
title={t('确定要启用所有密钥吗?')}
|
||||
onConfirm={handleEnableAll}
|
||||
position={'topRight'}
|
||||
>
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
loading={operationLoading.enable_all}
|
||||
>
|
||||
{t('启用全部')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
{enabledCount > 0 && (
|
||||
<Popconfirm
|
||||
title={t('确定要禁用所有的密钥吗?')}
|
||||
onConfirm={handleDisableAll}
|
||||
okType={'danger'}
|
||||
position={'topRight'}
|
||||
>
|
||||
<Button
|
||||
size='small'
|
||||
type='danger'
|
||||
loading={operationLoading.disable_all}
|
||||
>
|
||||
{t('禁用全部')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
<Popconfirm
|
||||
title={t('确定要删除所有已自动禁用的密钥吗?')}
|
||||
content={t(
|
||||
'此操作不可撤销,将永久删除已自动禁用的密钥',
|
||||
)}
|
||||
onConfirm={handleDeleteDisabledKeys}
|
||||
okType={'danger'}
|
||||
position={'topRight'}
|
||||
>
|
||||
<Button
|
||||
size='small'
|
||||
type='warning'
|
||||
loading={operationLoading.delete_disabled}
|
||||
>
|
||||
{t('删除自动禁用密钥')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
columns={columns}
|
||||
dataSource={keyStatusList}
|
||||
pagination={{
|
||||
currentPage: currentPage,
|
||||
pageSize: pageSize,
|
||||
total: total,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
pageSizeOpts: [10, 20, 50, 100],
|
||||
onChange: (page, size) => {
|
||||
setCurrentPage(page);
|
||||
loadKeyStatus(page, size);
|
||||
},
|
||||
onShowSizeChange: (current, size) => {
|
||||
setCurrentPage(1);
|
||||
handlePageSizeChange(size);
|
||||
},
|
||||
}}
|
||||
size='small'
|
||||
bordered={false}
|
||||
rowKey='index'
|
||||
scroll={{ x: 'max-content' }}
|
||||
empty={
|
||||
<Empty
|
||||
image={
|
||||
<IllustrationNoResult
|
||||
style={{ width: 140, height: 140 }}
|
||||
/>
|
||||
}
|
||||
darkModeImage={
|
||||
<IllustrationNoResultDark
|
||||
style={{ width: 140, height: 140 }}
|
||||
/>
|
||||
}
|
||||
title={t('暂无密钥数据')}
|
||||
description={t('请检查渠道配置或刷新重试')}
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
</Spin>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default MultiKeyManageModal;
|
||||
778
web/src/components/table/channels/modals/OllamaModelModal.jsx
Normal file
778
web/src/components/table/channels/modals/OllamaModelModal.jsx
Normal file
@@ -0,0 +1,778 @@
|
||||
/*
|
||||
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, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Modal,
|
||||
Button,
|
||||
Typography,
|
||||
Card,
|
||||
List,
|
||||
Space,
|
||||
Input,
|
||||
Spin,
|
||||
Popconfirm,
|
||||
Tag,
|
||||
Empty,
|
||||
Row,
|
||||
Col,
|
||||
Progress,
|
||||
Checkbox,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconDownload,
|
||||
IconDelete,
|
||||
IconRefresh,
|
||||
IconSearch,
|
||||
IconPlus,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import {
|
||||
API,
|
||||
authHeader,
|
||||
getUserIdFromLocalStorage,
|
||||
showError,
|
||||
showSuccess,
|
||||
} from '../../../../helpers';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const CHANNEL_TYPE_OLLAMA = 4;
|
||||
|
||||
const parseMaybeJSON = (value) => {
|
||||
if (!value) return null;
|
||||
if (typeof value === 'object') return value;
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const resolveOllamaBaseUrl = (info) => {
|
||||
if (!info) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const direct = typeof info.base_url === 'string' ? info.base_url.trim() : '';
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
const alt =
|
||||
typeof info.ollama_base_url === 'string' ? info.ollama_base_url.trim() : '';
|
||||
if (alt) {
|
||||
return alt;
|
||||
}
|
||||
|
||||
const parsed = parseMaybeJSON(info.other_info);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const candidate =
|
||||
(typeof parsed.base_url === 'string' && parsed.base_url.trim()) ||
|
||||
(typeof parsed.public_url === 'string' && parsed.public_url.trim()) ||
|
||||
(typeof parsed.api_url === 'string' && parsed.api_url.trim());
|
||||
if (candidate) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
const normalizeModels = (items) => {
|
||||
if (!Array.isArray(items)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return items
|
||||
.map((item) => {
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof item === 'string') {
|
||||
return {
|
||||
id: item,
|
||||
owned_by: 'ollama',
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof item === 'object') {
|
||||
const candidateId =
|
||||
item.id || item.ID || item.name || item.model || item.Model;
|
||||
if (!candidateId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const metadata = item.metadata || item.Metadata;
|
||||
const normalized = {
|
||||
...item,
|
||||
id: candidateId,
|
||||
owned_by: item.owned_by || item.ownedBy || 'ollama',
|
||||
};
|
||||
|
||||
if (typeof item.size === 'number' && !normalized.size) {
|
||||
normalized.size = item.size;
|
||||
}
|
||||
if (metadata && typeof metadata === 'object') {
|
||||
if (typeof metadata.size === 'number' && !normalized.size) {
|
||||
normalized.size = metadata.size;
|
||||
}
|
||||
if (!normalized.digest && typeof metadata.digest === 'string') {
|
||||
normalized.digest = metadata.digest;
|
||||
}
|
||||
if (
|
||||
!normalized.modified_at &&
|
||||
typeof metadata.modified_at === 'string'
|
||||
) {
|
||||
normalized.modified_at = metadata.modified_at;
|
||||
}
|
||||
if (metadata.details && !normalized.details) {
|
||||
normalized.details = metadata.details;
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
const OllamaModelModal = ({
|
||||
visible,
|
||||
onCancel,
|
||||
channelId,
|
||||
channelInfo,
|
||||
onModelsUpdate,
|
||||
onApplyModels,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [models, setModels] = useState([]);
|
||||
const [filteredModels, setFilteredModels] = useState([]);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [pullModelName, setPullModelName] = useState('');
|
||||
const [pullLoading, setPullLoading] = useState(false);
|
||||
const [pullProgress, setPullProgress] = useState(null);
|
||||
const [eventSource, setEventSource] = useState(null);
|
||||
const [selectedModelIds, setSelectedModelIds] = useState([]);
|
||||
|
||||
const handleApplyAllModels = () => {
|
||||
if (!onApplyModels || selectedModelIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
onApplyModels({ mode: 'append', modelIds: selectedModelIds });
|
||||
};
|
||||
|
||||
const handleToggleModel = (modelId, checked) => {
|
||||
if (!modelId) {
|
||||
return;
|
||||
}
|
||||
setSelectedModelIds((prev) => {
|
||||
if (checked) {
|
||||
if (prev.includes(modelId)) {
|
||||
return prev;
|
||||
}
|
||||
return [...prev, modelId];
|
||||
}
|
||||
return prev.filter((id) => id !== modelId);
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
setSelectedModelIds(models.map((item) => item?.id).filter(Boolean));
|
||||
};
|
||||
|
||||
const handleClearSelection = () => {
|
||||
setSelectedModelIds([]);
|
||||
};
|
||||
|
||||
// 获取模型列表
|
||||
const fetchModels = async () => {
|
||||
const channelType = Number(channelInfo?.type ?? CHANNEL_TYPE_OLLAMA);
|
||||
const shouldTryLiveFetch = channelType === CHANNEL_TYPE_OLLAMA;
|
||||
const resolvedBaseUrl = resolveOllamaBaseUrl(channelInfo);
|
||||
|
||||
setLoading(true);
|
||||
let liveFetchSucceeded = false;
|
||||
let fallbackSucceeded = false;
|
||||
let lastError = '';
|
||||
let nextModels = [];
|
||||
|
||||
try {
|
||||
if (shouldTryLiveFetch && resolvedBaseUrl) {
|
||||
try {
|
||||
const payload = {
|
||||
base_url: resolvedBaseUrl,
|
||||
type: CHANNEL_TYPE_OLLAMA,
|
||||
key: channelInfo?.key || '',
|
||||
};
|
||||
|
||||
const res = await API.post('/api/channel/fetch_models', payload, {
|
||||
skipErrorHandler: true,
|
||||
});
|
||||
|
||||
if (res?.data?.success) {
|
||||
nextModels = normalizeModels(res.data.data);
|
||||
liveFetchSucceeded = true;
|
||||
} else if (res?.data?.message) {
|
||||
lastError = res.data.message;
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error?.response?.data?.message || error.message;
|
||||
if (message) {
|
||||
lastError = message;
|
||||
}
|
||||
}
|
||||
} else if (shouldTryLiveFetch && !resolvedBaseUrl && !channelId) {
|
||||
lastError = t('请先填写 Ollama API 地址');
|
||||
}
|
||||
|
||||
if ((!liveFetchSucceeded || nextModels.length === 0) && channelId) {
|
||||
try {
|
||||
const res = await API.get(`/api/channel/fetch_models/${channelId}`, {
|
||||
skipErrorHandler: true,
|
||||
});
|
||||
|
||||
if (res?.data?.success) {
|
||||
nextModels = normalizeModels(res.data.data);
|
||||
fallbackSucceeded = true;
|
||||
lastError = '';
|
||||
} else if (res?.data?.message) {
|
||||
lastError = res.data.message;
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error?.response?.data?.message || error.message;
|
||||
if (message) {
|
||||
lastError = message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!liveFetchSucceeded && !fallbackSucceeded && lastError) {
|
||||
showError(`${t('获取模型列表失败')}: ${lastError}`);
|
||||
}
|
||||
|
||||
const normalized = nextModels;
|
||||
setModels(normalized);
|
||||
setFilteredModels(normalized);
|
||||
setSelectedModelIds((prev) => {
|
||||
if (!normalized || normalized.length === 0) {
|
||||
return [];
|
||||
}
|
||||
if (!prev || prev.length === 0) {
|
||||
return normalized.map((item) => item.id).filter(Boolean);
|
||||
}
|
||||
const available = prev.filter((id) =>
|
||||
normalized.some((item) => item.id === id),
|
||||
);
|
||||
return available.length > 0
|
||||
? available
|
||||
: normalized.map((item) => item.id).filter(Boolean);
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 拉取模型 (流式,支持进度)
|
||||
const pullModel = async () => {
|
||||
if (!pullModelName.trim()) {
|
||||
showError(t('请输入模型名称'));
|
||||
return;
|
||||
}
|
||||
|
||||
setPullLoading(true);
|
||||
setPullProgress({ status: 'starting', completed: 0, total: 0 });
|
||||
|
||||
let hasRefreshed = false;
|
||||
const refreshModels = async () => {
|
||||
if (hasRefreshed) return;
|
||||
hasRefreshed = true;
|
||||
await fetchModels();
|
||||
if (onModelsUpdate) {
|
||||
onModelsUpdate({ silent: true });
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// 关闭之前的连接
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
setEventSource(null);
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const closable = {
|
||||
close: () => controller.abort(),
|
||||
};
|
||||
setEventSource(closable);
|
||||
|
||||
// 使用 fetch 请求 SSE 流
|
||||
const authHeaders = authHeader();
|
||||
const userId = getUserIdFromLocalStorage();
|
||||
const fetchHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'text/event-stream',
|
||||
'New-API-User': String(userId),
|
||||
...authHeaders,
|
||||
};
|
||||
|
||||
const response = await fetch('/api/channel/ollama/pull/stream', {
|
||||
method: 'POST',
|
||||
headers: fetchHeaders,
|
||||
body: JSON.stringify({
|
||||
channel_id: channelId,
|
||||
model_name: pullModelName.trim(),
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
// 读取 SSE 流
|
||||
const processStream = async () => {
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const eventData = line.substring(6);
|
||||
if (eventData === '[DONE]') {
|
||||
setPullLoading(false);
|
||||
setPullProgress(null);
|
||||
setEventSource(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = JSON.parse(eventData);
|
||||
|
||||
if (data.status) {
|
||||
// 处理进度数据
|
||||
setPullProgress(data);
|
||||
} else if (data.error) {
|
||||
// 处理错误
|
||||
showError(data.error);
|
||||
setPullProgress(null);
|
||||
setPullLoading(false);
|
||||
setEventSource(null);
|
||||
return;
|
||||
} else if (data.message) {
|
||||
// 处理成功消息
|
||||
showSuccess(data.message);
|
||||
setPullModelName('');
|
||||
setPullProgress(null);
|
||||
setPullLoading(false);
|
||||
setEventSource(null);
|
||||
await fetchModels();
|
||||
if (onModelsUpdate) {
|
||||
onModelsUpdate({ silent: true });
|
||||
}
|
||||
await refreshModels();
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse SSE data:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 正常结束流
|
||||
setPullLoading(false);
|
||||
setPullProgress(null);
|
||||
setEventSource(null);
|
||||
await refreshModels();
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
setPullProgress(null);
|
||||
setPullLoading(false);
|
||||
setEventSource(null);
|
||||
return;
|
||||
}
|
||||
console.error('Stream processing error:', error);
|
||||
showError(t('数据传输中断'));
|
||||
setPullProgress(null);
|
||||
setPullLoading(false);
|
||||
setEventSource(null);
|
||||
await refreshModels();
|
||||
}
|
||||
};
|
||||
|
||||
await processStream();
|
||||
} catch (error) {
|
||||
if (error?.name !== 'AbortError') {
|
||||
showError(t('模型拉取失败: {{error}}', { error: error.message }));
|
||||
}
|
||||
setPullLoading(false);
|
||||
setPullProgress(null);
|
||||
setEventSource(null);
|
||||
await refreshModels();
|
||||
}
|
||||
};
|
||||
|
||||
// 删除模型
|
||||
const deleteModel = async (modelName) => {
|
||||
try {
|
||||
const res = await API.delete('/api/channel/ollama/delete', {
|
||||
data: {
|
||||
channel_id: channelId,
|
||||
model_name: modelName,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.data.success) {
|
||||
showSuccess(t('模型删除成功'));
|
||||
await fetchModels(); // 重新获取模型列表
|
||||
if (onModelsUpdate) {
|
||||
onModelsUpdate({ silent: true }); // 通知父组件更新
|
||||
}
|
||||
} else {
|
||||
showError(res.data.message || t('模型删除失败'));
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('模型删除失败: {{error}}', { error: error.message }));
|
||||
}
|
||||
};
|
||||
|
||||
// 搜索过滤
|
||||
useEffect(() => {
|
||||
if (!searchValue) {
|
||||
setFilteredModels(models);
|
||||
} else {
|
||||
const filtered = models.filter((model) =>
|
||||
model.id.toLowerCase().includes(searchValue.toLowerCase()),
|
||||
);
|
||||
setFilteredModels(filtered);
|
||||
}
|
||||
}, [models, searchValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setSelectedModelIds([]);
|
||||
setPullModelName('');
|
||||
setPullProgress(null);
|
||||
setPullLoading(false);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
// 组件加载时获取模型列表
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (channelId || Number(channelInfo?.type) === CHANNEL_TYPE_OLLAMA) {
|
||||
fetchModels();
|
||||
}
|
||||
}, [
|
||||
visible,
|
||||
channelId,
|
||||
channelInfo?.type,
|
||||
channelInfo?.base_url,
|
||||
channelInfo?.other_info,
|
||||
channelInfo?.ollama_base_url,
|
||||
]);
|
||||
|
||||
// 组件卸载时清理 EventSource
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
}
|
||||
};
|
||||
}, [eventSource]);
|
||||
|
||||
const formatModelSize = (size) => {
|
||||
if (!size) return '-';
|
||||
const gb = size / (1024 * 1024 * 1024);
|
||||
return gb >= 1
|
||||
? `${gb.toFixed(1)} GB`
|
||||
: `${(size / (1024 * 1024)).toFixed(0)} MB`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('Ollama 模型管理')}
|
||||
visible={visible}
|
||||
onCancel={onCancel}
|
||||
width={720}
|
||||
style={{ maxWidth: '95vw' }}
|
||||
footer={
|
||||
<Button theme='solid' type='primary' onClick={onCancel}>
|
||||
{t('关闭')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Space vertical spacing='medium' style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Text type='tertiary' size='small'>
|
||||
{channelInfo?.name ? `${channelInfo.name} - ` : ''}
|
||||
{t('管理 Ollama 模型的拉取和删除')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* 拉取新模型 */}
|
||||
<Card>
|
||||
<Title heading={6} className='m-0 mb-3'>
|
||||
{t('拉取新模型')}
|
||||
</Title>
|
||||
|
||||
<Row gutter={12} align='middle'>
|
||||
<Col span={16}>
|
||||
<Input
|
||||
placeholder={t('请输入模型名称,例如: llama3.2, qwen2.5:7b')}
|
||||
value={pullModelName}
|
||||
onChange={(value) => setPullModelName(value)}
|
||||
onEnterPress={pullModel}
|
||||
disabled={pullLoading}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Button
|
||||
theme='solid'
|
||||
type='primary'
|
||||
onClick={pullModel}
|
||||
loading={pullLoading}
|
||||
disabled={!pullModelName.trim()}
|
||||
icon={<IconDownload />}
|
||||
block
|
||||
>
|
||||
{pullLoading ? t('拉取中...') : t('拉取模型')}
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 进度条显示 */}
|
||||
{pullProgress &&
|
||||
(() => {
|
||||
const completedBytes = Number(pullProgress.completed) || 0;
|
||||
const totalBytes = Number(pullProgress.total) || 0;
|
||||
const hasTotal = Number.isFinite(totalBytes) && totalBytes > 0;
|
||||
const safePercent = hasTotal
|
||||
? Math.min(
|
||||
100,
|
||||
Math.max(
|
||||
0,
|
||||
Math.round((completedBytes / totalBytes) * 100),
|
||||
),
|
||||
)
|
||||
: null;
|
||||
const percentText =
|
||||
hasTotal && safePercent !== null
|
||||
? `${safePercent.toFixed(0)}%`
|
||||
: pullProgress.status || t('处理中');
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<div className='flex items-center justify-between mb-2'>
|
||||
<Text strong>{t('拉取进度')}</Text>
|
||||
<Text type='tertiary' size='small'>
|
||||
{percentText}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{hasTotal && safePercent !== null ? (
|
||||
<div>
|
||||
<Progress
|
||||
percent={safePercent}
|
||||
showInfo={false}
|
||||
stroke='#1890ff'
|
||||
size='small'
|
||||
/>
|
||||
<div className='flex justify-between mt-1'>
|
||||
<Text type='tertiary' size='small'>
|
||||
{(completedBytes / (1024 * 1024 * 1024)).toFixed(2)}{' '}
|
||||
GB
|
||||
</Text>
|
||||
<Text type='tertiary' size='small'>
|
||||
{(totalBytes / (1024 * 1024 * 1024)).toFixed(2)} GB
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex items-center gap-2 text-xs text-[var(--semi-color-text-2)]'>
|
||||
<Spin size='small' />
|
||||
<span>{t('准备中...')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
<Text type='tertiary' size='small' className='mt-2 block'>
|
||||
{t(
|
||||
'支持拉取 Ollama 官方模型库中的所有模型,拉取过程可能需要几分钟时间',
|
||||
)}
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
{/* 已有模型列表 */}
|
||||
<Card>
|
||||
<div className='flex items-center justify-between mb-3'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Title heading={6} className='m-0'>
|
||||
{t('已有模型')}
|
||||
</Title>
|
||||
{models.length > 0 ? (
|
||||
<Tag color='blue'>{models.length}</Tag>
|
||||
) : null}
|
||||
</div>
|
||||
<Space wrap>
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('搜索模型...')}
|
||||
value={searchValue}
|
||||
onChange={(value) => setSearchValue(value)}
|
||||
style={{ width: 200 }}
|
||||
showClear
|
||||
/>
|
||||
<Button
|
||||
size='small'
|
||||
theme='light'
|
||||
onClick={handleSelectAll}
|
||||
disabled={models.length === 0}
|
||||
>
|
||||
{t('全选')}
|
||||
</Button>
|
||||
<Button
|
||||
size='small'
|
||||
theme='light'
|
||||
onClick={handleClearSelection}
|
||||
disabled={selectedModelIds.length === 0}
|
||||
>
|
||||
{t('清空')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='solid'
|
||||
type='primary'
|
||||
icon={<IconPlus />}
|
||||
onClick={handleApplyAllModels}
|
||||
disabled={selectedModelIds.length === 0}
|
||||
size='small'
|
||||
>
|
||||
{t('加入渠道')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='light'
|
||||
type='primary'
|
||||
onClick={fetchModels}
|
||||
loading={loading}
|
||||
icon={<IconRefresh />}
|
||||
size='small'
|
||||
>
|
||||
{t('刷新')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Spin spinning={loading}>
|
||||
{filteredModels.length === 0 ? (
|
||||
<Empty
|
||||
title={searchValue ? t('未找到匹配的模型') : t('暂无模型')}
|
||||
description={
|
||||
searchValue
|
||||
? t('请尝试其他搜索关键词')
|
||||
: t('您可以在上方拉取需要的模型')
|
||||
}
|
||||
style={{ padding: '40px 0' }}
|
||||
/>
|
||||
) : (
|
||||
<List
|
||||
dataSource={filteredModels}
|
||||
split
|
||||
renderItem={(model) => (
|
||||
<List.Item key={model.id}>
|
||||
<div className='flex items-center justify-between w-full'>
|
||||
<div className='flex items-center flex-1 min-w-0 gap-3'>
|
||||
<Checkbox
|
||||
checked={selectedModelIds.includes(model.id)}
|
||||
onChange={(checked) =>
|
||||
handleToggleModel(model.id, checked)
|
||||
}
|
||||
/>
|
||||
<div className='flex-1 min-w-0'>
|
||||
<Text strong className='block truncate'>
|
||||
{model.id}
|
||||
</Text>
|
||||
<div className='flex items-center space-x-2 mt-1'>
|
||||
<Tag color='cyan' size='small'>
|
||||
{model.owned_by || 'ollama'}
|
||||
</Tag>
|
||||
{model.size && (
|
||||
<Text type='tertiary' size='small'>
|
||||
{formatModelSize(model.size)}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-center space-x-2 ml-4'>
|
||||
<Popconfirm
|
||||
title={t('确认删除模型')}
|
||||
content={t(
|
||||
'删除后无法恢复,确定要删除模型 "{{name}}" 吗?',
|
||||
{ name: model.id },
|
||||
)}
|
||||
onConfirm={() => deleteModel(model.id)}
|
||||
okText={t('确认')}
|
||||
cancelText={t('取消')}
|
||||
>
|
||||
<Button
|
||||
theme='borderless'
|
||||
type='danger'
|
||||
size='small'
|
||||
icon={<IconDelete />}
|
||||
/>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Spin>
|
||||
</Card>
|
||||
</Space>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default OllamaModelModal;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,195 @@
|
||||
/*
|
||||
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, { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
import {
|
||||
Collapse,
|
||||
Empty,
|
||||
Input,
|
||||
Modal,
|
||||
Radio,
|
||||
Typography,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { IconSearch } from '@douyinfe/semi-icons';
|
||||
import { getModelCategories } from '../../../../helpers/render';
|
||||
|
||||
const SingleModelSelectModal = ({
|
||||
visible,
|
||||
models = [],
|
||||
selected = '',
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const normalizeModelName = (model) => String(model ?? '').trim();
|
||||
const normalizedModels = useMemo(() => {
|
||||
const list = Array.isArray(models) ? models : [];
|
||||
return Array.from(new Set(list.map(normalizeModelName).filter(Boolean)));
|
||||
}, [models]);
|
||||
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [selectedModel, setSelectedModel] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setKeyword('');
|
||||
setSelectedModel(normalizeModelName(selected));
|
||||
}
|
||||
}, [visible, selected]);
|
||||
|
||||
const filteredModels = useMemo(() => {
|
||||
const lower = keyword.trim().toLowerCase();
|
||||
if (!lower) return normalizedModels;
|
||||
return normalizedModels.filter((m) => m.toLowerCase().includes(lower));
|
||||
}, [normalizedModels, keyword]);
|
||||
|
||||
const modelsByCategory = useMemo(() => {
|
||||
const categories = getModelCategories(t);
|
||||
const categorized = {};
|
||||
const uncategorized = [];
|
||||
|
||||
filteredModels.forEach((model) => {
|
||||
let foundCategory = false;
|
||||
for (const [key, category] of Object.entries(categories)) {
|
||||
if (key !== 'all' && category.filter({ model_name: model })) {
|
||||
if (!categorized[key]) {
|
||||
categorized[key] = {
|
||||
label: category.label,
|
||||
icon: category.icon,
|
||||
models: [],
|
||||
};
|
||||
}
|
||||
categorized[key].models.push(model);
|
||||
foundCategory = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!foundCategory) {
|
||||
uncategorized.push(model);
|
||||
}
|
||||
});
|
||||
|
||||
if (uncategorized.length > 0) {
|
||||
categorized.other = {
|
||||
label: t('其他'),
|
||||
icon: null,
|
||||
models: uncategorized,
|
||||
};
|
||||
}
|
||||
|
||||
return categorized;
|
||||
}, [filteredModels, t]);
|
||||
|
||||
const categoryEntries = useMemo(
|
||||
() => Object.entries(modelsByCategory),
|
||||
[modelsByCategory],
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
header={
|
||||
<div className='flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-4 py-4'>
|
||||
<Typography.Title heading={5} className='m-0'>
|
||||
{t('选择模型')}
|
||||
</Typography.Title>
|
||||
</div>
|
||||
}
|
||||
visible={visible}
|
||||
onOk={() => onConfirm?.(selectedModel)}
|
||||
onCancel={onCancel}
|
||||
okText={t('确定')}
|
||||
cancelText={t('取消')}
|
||||
okButtonProps={{ disabled: !selectedModel }}
|
||||
size={isMobile ? 'full-width' : 'large'}
|
||||
closeOnEsc
|
||||
maskClosable
|
||||
centered
|
||||
>
|
||||
<Input
|
||||
prefix={<IconSearch size={14} />}
|
||||
placeholder={t('搜索模型')}
|
||||
value={keyword}
|
||||
onChange={(v) => setKeyword(v)}
|
||||
showClear
|
||||
/>
|
||||
|
||||
<div style={{ maxHeight: 400, overflowY: 'auto', paddingRight: 8 }}>
|
||||
{filteredModels.length === 0 ? (
|
||||
<Empty
|
||||
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
|
||||
darkModeImage={
|
||||
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
description={t('暂无匹配模型')}
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
) : (
|
||||
<Radio.Group
|
||||
className='w-full'
|
||||
style={{ width: '100%' }}
|
||||
value={selectedModel}
|
||||
onChange={(val) => {
|
||||
const next = val && val.target ? val.target.value : val;
|
||||
setSelectedModel(next);
|
||||
}}
|
||||
>
|
||||
<Collapse
|
||||
className='w-full'
|
||||
style={{ width: '100%' }}
|
||||
defaultActiveKey={[]}
|
||||
>
|
||||
{categoryEntries.map(([key, categoryData], index) => (
|
||||
<Collapse.Panel
|
||||
key={`${key}_${index}`}
|
||||
itemKey={`${key}_${index}`}
|
||||
header={
|
||||
<span className='flex items-center gap-2'>
|
||||
{categoryData.icon}
|
||||
<span>
|
||||
{categoryData.label} ({categoryData.models.length})
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className='grid grid-cols-2 gap-x-4'>
|
||||
{categoryData.models.map((model) => (
|
||||
<Radio key={model} value={model} className='my-1'>
|
||||
{model}
|
||||
</Radio>
|
||||
))}
|
||||
</div>
|
||||
</Collapse.Panel>
|
||||
))}
|
||||
</Collapse>
|
||||
</Radio.Group>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default SingleModelSelectModal;
|
||||
@@ -0,0 +1,41 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import RiskAcknowledgementModal from '../../../common/modals/RiskAcknowledgementModal';
|
||||
import {
|
||||
STATUS_CODE_RISK_I18N_KEYS,
|
||||
STATUS_CODE_RISK_CHECKLIST_KEYS,
|
||||
} from './statusCodeRiskGuard';
|
||||
|
||||
const StatusCodeRiskGuardModal = React.memo(function StatusCodeRiskGuardModal({
|
||||
visible,
|
||||
detailItems,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const checklist = useMemo(
|
||||
() => STATUS_CODE_RISK_CHECKLIST_KEYS.map((item) => t(item)),
|
||||
[t, i18n.language],
|
||||
);
|
||||
|
||||
return (
|
||||
<RiskAcknowledgementModal
|
||||
visible={visible}
|
||||
title={t(STATUS_CODE_RISK_I18N_KEYS.title)}
|
||||
markdownContent={t(STATUS_CODE_RISK_I18N_KEYS.markdown)}
|
||||
detailTitle={t(STATUS_CODE_RISK_I18N_KEYS.detailTitle)}
|
||||
detailItems={detailItems}
|
||||
checklist={checklist}
|
||||
inputPrompt={t(STATUS_CODE_RISK_I18N_KEYS.inputPrompt)}
|
||||
requiredText={t(STATUS_CODE_RISK_I18N_KEYS.confirmText)}
|
||||
inputPlaceholder={t(STATUS_CODE_RISK_I18N_KEYS.inputPlaceholder)}
|
||||
mismatchText={t(STATUS_CODE_RISK_I18N_KEYS.mismatchText)}
|
||||
cancelText={t('取消')}
|
||||
confirmText={t(STATUS_CODE_RISK_I18N_KEYS.confirmButton)}
|
||||
onCancel={onCancel}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default StatusCodeRiskGuardModal;
|
||||
132
web/src/components/table/channels/modals/statusCodeRiskGuard.js
Normal file
132
web/src/components/table/channels/modals/statusCodeRiskGuard.js
Normal file
@@ -0,0 +1,132 @@
|
||||
const NON_REDIRECTABLE_STATUS_CODES = new Set([504, 524]);
|
||||
|
||||
export const STATUS_CODE_RISK_I18N_KEYS = {
|
||||
title: '高危操作确认',
|
||||
detailTitle: '检测到以下高危状态码重定向规则',
|
||||
inputPrompt: '操作确认',
|
||||
confirmButton: '我确认开启高危重试',
|
||||
markdown: '高危状态码重试风险告知与免责声明Markdown',
|
||||
confirmText: '高危状态码重试风险确认输入文本',
|
||||
inputPlaceholder: '高危状态码重试风险输入框占位文案',
|
||||
mismatchText: '高危状态码重试风险输入不匹配提示',
|
||||
};
|
||||
|
||||
export const STATUS_CODE_RISK_CHECKLIST_KEYS = [
|
||||
'高危状态码重试风险确认项1',
|
||||
'高危状态码重试风险确认项2',
|
||||
'高危状态码重试风险确认项3',
|
||||
'高危状态码重试风险确认项4',
|
||||
];
|
||||
|
||||
function parseStatusCodeKey(rawKey) {
|
||||
if (typeof rawKey !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const normalized = rawKey.trim();
|
||||
if (!/^[1-5]\d{2}$/.test(normalized)) {
|
||||
return null;
|
||||
}
|
||||
return Number.parseInt(normalized, 10);
|
||||
}
|
||||
|
||||
function parseStatusCodeMappingTarget(rawValue) {
|
||||
if (typeof rawValue === 'number' && Number.isInteger(rawValue)) {
|
||||
return rawValue >= 100 && rawValue <= 599 ? rawValue : null;
|
||||
}
|
||||
if (typeof rawValue === 'string') {
|
||||
const normalized = rawValue.trim();
|
||||
if (!/^[1-5]\d{2}$/.test(normalized)) {
|
||||
return null;
|
||||
}
|
||||
const code = Number.parseInt(normalized, 10);
|
||||
return code >= 100 && code <= 599 ? code : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function collectInvalidStatusCodeEntries(statusCodeMappingStr) {
|
||||
if (
|
||||
typeof statusCodeMappingStr !== 'string' ||
|
||||
statusCodeMappingStr.trim() === ''
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(statusCodeMappingStr);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const invalid = [];
|
||||
for (const [rawKey, rawValue] of Object.entries(parsed)) {
|
||||
const fromCode = parseStatusCodeKey(rawKey);
|
||||
const toCode = parseStatusCodeMappingTarget(rawValue);
|
||||
if (fromCode === null || toCode === null) {
|
||||
invalid.push(`${rawKey} → ${rawValue}`);
|
||||
}
|
||||
}
|
||||
|
||||
return invalid;
|
||||
}
|
||||
|
||||
export function collectDisallowedStatusCodeRedirects(statusCodeMappingStr) {
|
||||
if (
|
||||
typeof statusCodeMappingStr !== 'string' ||
|
||||
statusCodeMappingStr.trim() === ''
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(statusCodeMappingStr);
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const riskyMappings = [];
|
||||
Object.entries(parsed).forEach(([rawFrom, rawTo]) => {
|
||||
const fromCode = parseStatusCodeKey(rawFrom);
|
||||
const toCode = parseStatusCodeMappingTarget(rawTo);
|
||||
if (fromCode === null || toCode === null) {
|
||||
return;
|
||||
}
|
||||
if (!NON_REDIRECTABLE_STATUS_CODES.has(fromCode)) {
|
||||
return;
|
||||
}
|
||||
if (fromCode === toCode) {
|
||||
return;
|
||||
}
|
||||
riskyMappings.push(`${fromCode} -> ${toCode}`);
|
||||
});
|
||||
|
||||
return Array.from(new Set(riskyMappings)).sort();
|
||||
}
|
||||
|
||||
export function collectNewDisallowedStatusCodeRedirects(
|
||||
originalStatusCodeMappingStr,
|
||||
currentStatusCodeMappingStr,
|
||||
) {
|
||||
const currentRisky = collectDisallowedStatusCodeRedirects(
|
||||
currentStatusCodeMappingStr,
|
||||
);
|
||||
if (currentRisky.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const originalRiskySet = new Set(
|
||||
collectDisallowedStatusCodeRedirects(originalStatusCodeMappingStr),
|
||||
);
|
||||
|
||||
return currentRisky.filter((mapping) => !originalRiskySet.has(mapping));
|
||||
}
|
||||
Reference in New Issue
Block a user