No puede seleccionar más de 25 temas Los temas deben comenzar con una letra o número, pueden incluir guiones ('-') y pueden tener hasta 35 caracteres de largo.
 
 
 

766 líneas
21 KiB

  1. /*
  2. Copyright (C) 2025 QuantumNous
  3. This program is free software: you can redistribute it and/or modify
  4. it under the terms of the GNU Affero General Public License as
  5. published by the Free Software Foundation, either version 3 of the
  6. License, or (at your option) any later version.
  7. This program is distributed in the hope that it will be useful,
  8. but WITHOUT ANY WARRANTY; without even the implied warranty of
  9. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  10. GNU Affero General Public License for more details.
  11. You should have received a copy of the GNU Affero General Public License
  12. along with this program. If not, see <https://www.gnu.org/licenses/>.
  13. For commercial licensing, please contact support@quantumnous.com
  14. */
  15. import React from 'react';
  16. import {
  17. Button,
  18. Dropdown,
  19. InputNumber,
  20. Modal,
  21. Space,
  22. SplitButtonGroup,
  23. Tag,
  24. Tooltip,
  25. Typography,
  26. } from '@douyinfe/semi-ui';
  27. import {
  28. timestamp2string,
  29. renderGroup,
  30. renderQuota,
  31. getChannelIcon,
  32. renderQuotaWithAmount,
  33. showSuccess,
  34. showError,
  35. } from '../../../helpers';
  36. import { CHANNEL_OPTIONS } from '../../../constants';
  37. import {
  38. IconTreeTriangleDown,
  39. IconMore,
  40. IconAlertTriangle,
  41. } from '@douyinfe/semi-icons';
  42. import { FaRandom } from 'react-icons/fa';
  43. // Render functions
  44. const renderType = (type, record = {}, t) => {
  45. const channelInfo = record?.channel_info;
  46. let type2label = new Map();
  47. for (let i = 0; i < CHANNEL_OPTIONS.length; i++) {
  48. type2label[CHANNEL_OPTIONS[i].value] = CHANNEL_OPTIONS[i];
  49. }
  50. type2label[0] = { value: 0, label: t('未知类型'), color: 'grey' };
  51. let icon = getChannelIcon(type);
  52. if (channelInfo?.is_multi_key) {
  53. icon =
  54. channelInfo?.multi_key_mode === 'random' ? (
  55. <div className='flex items-center gap-1'>
  56. <FaRandom className='text-blue-500' />
  57. {icon}
  58. </div>
  59. ) : (
  60. <div className='flex items-center gap-1'>
  61. <IconTreeTriangleDown className='text-blue-500' />
  62. {icon}
  63. </div>
  64. );
  65. }
  66. const typeTag = (
  67. <Tag color={type2label[type]?.color} shape='circle' prefixIcon={icon}>
  68. {t(type2label[type]?.label)}
  69. </Tag>
  70. );
  71. let ionetMeta = null;
  72. if (record?.other_info) {
  73. try {
  74. const parsed = JSON.parse(record.other_info);
  75. if (parsed && typeof parsed === 'object' && parsed.source === 'ionet') {
  76. ionetMeta = parsed;
  77. }
  78. } catch (error) {
  79. // ignore invalid metadata
  80. }
  81. }
  82. if (!ionetMeta) {
  83. return typeTag;
  84. }
  85. const handleNavigate = (event) => {
  86. event?.stopPropagation?.();
  87. if (!ionetMeta?.deployment_id) {
  88. return;
  89. }
  90. const targetUrl = `/console/deployment?deployment_id=${ionetMeta.deployment_id}`;
  91. window.open(targetUrl, '_blank', 'noopener');
  92. };
  93. return (
  94. <Space spacing={6}>
  95. {typeTag}
  96. <Tooltip
  97. content={
  98. <div className='max-w-xs'>
  99. <div className='text-xs text-gray-600'>
  100. {t('来源于 IO.NET 部署')}
  101. </div>
  102. {ionetMeta?.deployment_id && (
  103. <div className='text-xs text-gray-500 mt-1'>
  104. {t('部署 ID')}: {ionetMeta.deployment_id}
  105. </div>
  106. )}
  107. </div>
  108. }
  109. >
  110. <span>
  111. <Tag
  112. color='purple'
  113. type='light'
  114. className='cursor-pointer'
  115. onClick={handleNavigate}
  116. >
  117. IO.NET
  118. </Tag>
  119. </span>
  120. </Tooltip>
  121. </Space>
  122. );
  123. };
  124. const renderTagType = (t) => {
  125. return (
  126. <Tag color='light-blue' shape='circle' type='light'>
  127. {t('标签聚合')}
  128. </Tag>
  129. );
  130. };
  131. const renderStatus = (status, channelInfo = undefined, t) => {
  132. if (channelInfo) {
  133. if (channelInfo.is_multi_key) {
  134. let keySize = channelInfo.multi_key_size;
  135. let enabledKeySize = keySize;
  136. if (channelInfo.multi_key_status_list) {
  137. enabledKeySize =
  138. keySize - Object.keys(channelInfo.multi_key_status_list).length;
  139. }
  140. return renderMultiKeyStatus(status, keySize, enabledKeySize, t);
  141. }
  142. }
  143. switch (status) {
  144. case 1:
  145. return (
  146. <Tag color='green' shape='circle'>
  147. {t('已启用')}
  148. </Tag>
  149. );
  150. case 2:
  151. return (
  152. <Tag color='red' shape='circle'>
  153. {t('已禁用')}
  154. </Tag>
  155. );
  156. case 3:
  157. return (
  158. <Tag color='yellow' shape='circle'>
  159. {t('自动禁用')}
  160. </Tag>
  161. );
  162. default:
  163. return (
  164. <Tag color='grey' shape='circle'>
  165. {t('未知状态')}
  166. </Tag>
  167. );
  168. }
  169. };
  170. const renderMultiKeyStatus = (status, keySize, enabledKeySize, t) => {
  171. switch (status) {
  172. case 1:
  173. return (
  174. <Tag color='green' shape='circle'>
  175. {t('已启用')} {enabledKeySize}/{keySize}
  176. </Tag>
  177. );
  178. case 2:
  179. return (
  180. <Tag color='red' shape='circle'>
  181. {t('已禁用')} {enabledKeySize}/{keySize}
  182. </Tag>
  183. );
  184. case 3:
  185. return (
  186. <Tag color='yellow' shape='circle'>
  187. {t('自动禁用')} {enabledKeySize}/{keySize}
  188. </Tag>
  189. );
  190. default:
  191. return (
  192. <Tag color='grey' shape='circle'>
  193. {t('未知状态')} {enabledKeySize}/{keySize}
  194. </Tag>
  195. );
  196. }
  197. };
  198. const renderResponseTime = (responseTime, t) => {
  199. let time = responseTime / 1000;
  200. time = time.toFixed(2) + t(' 秒');
  201. if (responseTime === 0) {
  202. return (
  203. <Tag color='grey' shape='circle'>
  204. {t('未测试')}
  205. </Tag>
  206. );
  207. } else if (responseTime <= 1000) {
  208. return (
  209. <Tag color='green' shape='circle'>
  210. {time}
  211. </Tag>
  212. );
  213. } else if (responseTime <= 3000) {
  214. return (
  215. <Tag color='lime' shape='circle'>
  216. {time}
  217. </Tag>
  218. );
  219. } else if (responseTime <= 5000) {
  220. return (
  221. <Tag color='yellow' shape='circle'>
  222. {time}
  223. </Tag>
  224. );
  225. } else {
  226. return (
  227. <Tag color='red' shape='circle'>
  228. {time}
  229. </Tag>
  230. );
  231. }
  232. };
  233. const isRequestPassThroughEnabled = (record) => {
  234. if (!record || record.children !== undefined) {
  235. return false;
  236. }
  237. const settingValue = record.setting;
  238. if (!settingValue) {
  239. return false;
  240. }
  241. if (typeof settingValue === 'object') {
  242. return settingValue.pass_through_body_enabled === true;
  243. }
  244. if (typeof settingValue !== 'string') {
  245. return false;
  246. }
  247. try {
  248. const parsed = JSON.parse(settingValue);
  249. return parsed?.pass_through_body_enabled === true;
  250. } catch (error) {
  251. return false;
  252. }
  253. };
  254. export const getChannelsColumns = ({
  255. t,
  256. COLUMN_KEYS,
  257. updateChannelBalance,
  258. manageChannel,
  259. manageTag,
  260. submitTagEdit,
  261. testChannel,
  262. setCurrentTestChannel,
  263. setShowModelTestModal,
  264. setEditingChannel,
  265. setShowEdit,
  266. setShowEditTag,
  267. setEditingTag,
  268. copySelectedChannel,
  269. refresh,
  270. activePage,
  271. channels,
  272. checkOllamaVersion,
  273. setShowMultiKeyManageModal,
  274. setCurrentMultiKeyChannel,
  275. }) => {
  276. return [
  277. {
  278. key: COLUMN_KEYS.ID,
  279. title: t('ID'),
  280. dataIndex: 'id',
  281. },
  282. {
  283. key: COLUMN_KEYS.NAME,
  284. title: t('名称'),
  285. dataIndex: 'name',
  286. render: (text, record, index) => {
  287. const passThroughEnabled = isRequestPassThroughEnabled(record);
  288. const nameWithId = `${record.id} - ${text}`;
  289. const nameNode =
  290. record.remark && record.remark.trim() !== '' ? (
  291. <Tooltip
  292. content={
  293. <div className='flex flex-col gap-2 max-w-xs'>
  294. <div className='text-sm'>{record.remark}</div>
  295. <Button
  296. size='small'
  297. type='primary'
  298. theme='outline'
  299. onClick={(e) => {
  300. e.stopPropagation();
  301. navigator.clipboard
  302. .writeText(record.remark)
  303. .then(() => {
  304. showSuccess(t('复制成功'));
  305. })
  306. .catch(() => {
  307. showError(t('复制失败'));
  308. });
  309. }}
  310. >
  311. {t('复制')}
  312. </Button>
  313. </div>
  314. }
  315. trigger='hover'
  316. position='topLeft'
  317. >
  318. <span>{nameWithId}</span>
  319. </Tooltip>
  320. ) : (
  321. <span>{nameWithId}</span>
  322. );
  323. if (!passThroughEnabled) {
  324. return nameNode;
  325. }
  326. return (
  327. <Space spacing={6} align='center'>
  328. {nameNode}
  329. <Tooltip
  330. content={t(
  331. '该渠道已开启请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。',
  332. )}
  333. trigger='hover'
  334. position='topLeft'
  335. >
  336. <span className='inline-flex items-center'>
  337. <IconAlertTriangle
  338. style={{ color: 'var(--semi-color-warning)' }}
  339. />
  340. </span>
  341. </Tooltip>
  342. </Space>
  343. );
  344. },
  345. },
  346. {
  347. key: COLUMN_KEYS.GROUP,
  348. title: t('分组'),
  349. dataIndex: 'group',
  350. render: (text, record, index) => (
  351. <div>
  352. <Space spacing={2}>
  353. {text
  354. ?.split(',')
  355. .sort((a, b) => {
  356. if (a === 'default') return -1;
  357. if (b === 'default') return 1;
  358. return a.localeCompare(b);
  359. })
  360. .map((item, index) => renderGroup(item))}
  361. </Space>
  362. </div>
  363. ),
  364. },
  365. {
  366. key: COLUMN_KEYS.TYPE,
  367. title: t('类型'),
  368. dataIndex: 'type',
  369. render: (text, record, index) => {
  370. if (record.children === undefined) {
  371. return <>{renderType(text, record, t)}</>;
  372. } else {
  373. return <>{renderTagType(t)}</>;
  374. }
  375. },
  376. },
  377. {
  378. key: COLUMN_KEYS.STATUS,
  379. title: t('状态'),
  380. dataIndex: 'status',
  381. render: (text, record, index) => {
  382. if (text === 3) {
  383. if (record.other_info === '') {
  384. record.other_info = '{}';
  385. }
  386. let otherInfo = JSON.parse(record.other_info);
  387. let reason = otherInfo['status_reason'];
  388. let time = otherInfo['status_time'];
  389. return (
  390. <div>
  391. <Tooltip
  392. content={
  393. t('原因:') + reason + t(',时间:') + timestamp2string(time)
  394. }
  395. >
  396. {renderStatus(text, record.channel_info, t)}
  397. </Tooltip>
  398. </div>
  399. );
  400. } else {
  401. return renderStatus(text, record.channel_info, t);
  402. }
  403. },
  404. },
  405. {
  406. key: COLUMN_KEYS.RESPONSE_TIME,
  407. title: t('响应时间'),
  408. dataIndex: 'response_time',
  409. render: (text, record, index) => <div>{renderResponseTime(text, t)}</div>,
  410. },
  411. {
  412. key: COLUMN_KEYS.BALANCE,
  413. title: t('已用/剩余'),
  414. dataIndex: 'expired_time',
  415. render: (text, record, index) => {
  416. if (record.children === undefined) {
  417. return (
  418. <div>
  419. <Space spacing={1}>
  420. <Tooltip content={t('已用额度')}>
  421. <Tag color='white' type='ghost' shape='circle'>
  422. {renderQuota(record.used_quota)}
  423. </Tag>
  424. </Tooltip>
  425. <Tooltip
  426. content={t('剩余额度$') + record.balance + t(',点击更新')}
  427. >
  428. <Tag
  429. color='white'
  430. type='ghost'
  431. shape='circle'
  432. onClick={() => updateChannelBalance(record)}
  433. >
  434. {renderQuotaWithAmount(record.balance)}
  435. </Tag>
  436. </Tooltip>
  437. </Space>
  438. </div>
  439. );
  440. } else {
  441. return (
  442. <Tooltip content={t('已用额度')}>
  443. <Tag color='white' type='ghost' shape='circle'>
  444. {renderQuota(record.used_quota)}
  445. </Tag>
  446. </Tooltip>
  447. );
  448. }
  449. },
  450. },
  451. {
  452. key: COLUMN_KEYS.PRIORITY,
  453. title: t('优先级'),
  454. dataIndex: 'priority',
  455. render: (text, record, index) => {
  456. if (record.children === undefined) {
  457. return (
  458. <div>
  459. <InputNumber
  460. style={{ width: 70 }}
  461. name='priority'
  462. onBlur={(e) => {
  463. manageChannel(record.id, 'priority', record, e.target.value);
  464. }}
  465. keepFocus={true}
  466. innerButtons
  467. defaultValue={record.priority}
  468. min={-999}
  469. size='small'
  470. />
  471. </div>
  472. );
  473. } else {
  474. return (
  475. <InputNumber
  476. style={{ width: 70 }}
  477. name='priority'
  478. keepFocus={true}
  479. onBlur={(e) => {
  480. Modal.warning({
  481. title: t('修改子渠道优先级'),
  482. content:
  483. t('确定要修改所有子渠道优先级为 ') +
  484. e.target.value +
  485. t(' 吗?'),
  486. onOk: () => {
  487. if (e.target.value === '') {
  488. return;
  489. }
  490. submitTagEdit('priority', {
  491. tag: record.key,
  492. priority: e.target.value,
  493. });
  494. },
  495. });
  496. }}
  497. innerButtons
  498. defaultValue={record.priority}
  499. min={-999}
  500. size='small'
  501. />
  502. );
  503. }
  504. },
  505. },
  506. {
  507. key: COLUMN_KEYS.WEIGHT,
  508. title: t('权重'),
  509. dataIndex: 'weight',
  510. render: (text, record, index) => {
  511. if (record.children === undefined) {
  512. return (
  513. <div>
  514. <InputNumber
  515. style={{ width: 70 }}
  516. name='weight'
  517. onBlur={(e) => {
  518. manageChannel(record.id, 'weight', record, e.target.value);
  519. }}
  520. keepFocus={true}
  521. innerButtons
  522. defaultValue={record.weight}
  523. min={0}
  524. size='small'
  525. />
  526. </div>
  527. );
  528. } else {
  529. return (
  530. <InputNumber
  531. style={{ width: 70 }}
  532. name='weight'
  533. keepFocus={true}
  534. onBlur={(e) => {
  535. Modal.warning({
  536. title: t('修改子渠道权重'),
  537. content:
  538. t('确定要修改所有子渠道权重为 ') +
  539. e.target.value +
  540. t(' 吗?'),
  541. onOk: () => {
  542. if (e.target.value === '') {
  543. return;
  544. }
  545. submitTagEdit('weight', {
  546. tag: record.key,
  547. weight: e.target.value,
  548. });
  549. },
  550. });
  551. }}
  552. innerButtons
  553. defaultValue={record.weight}
  554. min={-999}
  555. size='small'
  556. />
  557. );
  558. }
  559. },
  560. },
  561. {
  562. key: COLUMN_KEYS.OPERATE,
  563. title: '',
  564. dataIndex: 'operate',
  565. fixed: 'right',
  566. render: (text, record, index) => {
  567. if (record.children === undefined) {
  568. const moreMenuItems = [
  569. {
  570. node: 'item',
  571. name: t('删除'),
  572. type: 'danger',
  573. onClick: () => {
  574. Modal.confirm({
  575. title: t('确定是否要删除此渠道?'),
  576. content: t('此修改将不可逆'),
  577. onOk: () => {
  578. (async () => {
  579. await manageChannel(record.id, 'delete', record);
  580. await refresh();
  581. setTimeout(() => {
  582. if (channels.length === 0 && activePage > 1) {
  583. refresh(activePage - 1);
  584. }
  585. }, 100);
  586. })();
  587. },
  588. });
  589. },
  590. },
  591. {
  592. node: 'item',
  593. name: t('复制'),
  594. type: 'tertiary',
  595. onClick: () => {
  596. Modal.confirm({
  597. title: t('确定是否要复制此渠道?'),
  598. content: t('复制渠道的所有信息'),
  599. onOk: () => copySelectedChannel(record),
  600. });
  601. },
  602. },
  603. ];
  604. if (record.type === 4) {
  605. moreMenuItems.unshift({
  606. node: 'item',
  607. name: t('测活'),
  608. type: 'tertiary',
  609. onClick: () => checkOllamaVersion(record),
  610. });
  611. }
  612. return (
  613. <Space wrap>
  614. <SplitButtonGroup
  615. className='overflow-hidden'
  616. aria-label={t('测试单个渠道操作项目组')}
  617. >
  618. <Button
  619. size='small'
  620. type='tertiary'
  621. onClick={() => testChannel(record, '')}
  622. >
  623. {t('测试')}
  624. </Button>
  625. <Button
  626. size='small'
  627. type='tertiary'
  628. icon={<IconTreeTriangleDown />}
  629. onClick={() => {
  630. setCurrentTestChannel(record);
  631. setShowModelTestModal(true);
  632. }}
  633. />
  634. </SplitButtonGroup>
  635. {record.status === 1 ? (
  636. <Button
  637. type='danger'
  638. size='small'
  639. onClick={() => manageChannel(record.id, 'disable', record)}
  640. >
  641. {t('禁用')}
  642. </Button>
  643. ) : (
  644. <Button
  645. size='small'
  646. onClick={() => manageChannel(record.id, 'enable', record)}
  647. >
  648. {t('启用')}
  649. </Button>
  650. )}
  651. {record.channel_info?.is_multi_key ? (
  652. <SplitButtonGroup aria-label={t('多密钥渠道操作项目组')}>
  653. <Button
  654. type='tertiary'
  655. size='small'
  656. onClick={() => {
  657. setEditingChannel(record);
  658. setShowEdit(true);
  659. }}
  660. >
  661. {t('编辑')}
  662. </Button>
  663. <Dropdown
  664. trigger='click'
  665. position='bottomRight'
  666. menu={[
  667. {
  668. node: 'item',
  669. name: t('多密钥管理'),
  670. onClick: () => {
  671. setCurrentMultiKeyChannel(record);
  672. setShowMultiKeyManageModal(true);
  673. },
  674. },
  675. ]}
  676. >
  677. <Button
  678. type='tertiary'
  679. size='small'
  680. icon={<IconTreeTriangleDown />}
  681. />
  682. </Dropdown>
  683. </SplitButtonGroup>
  684. ) : (
  685. <Button
  686. type='tertiary'
  687. size='small'
  688. onClick={() => {
  689. setEditingChannel(record);
  690. setShowEdit(true);
  691. }}
  692. >
  693. {t('编辑')}
  694. </Button>
  695. )}
  696. <Dropdown
  697. trigger='click'
  698. position='bottomRight'
  699. menu={moreMenuItems}
  700. >
  701. <Button icon={<IconMore />} type='tertiary' size='small' />
  702. </Dropdown>
  703. </Space>
  704. );
  705. } else {
  706. // 标签操作按钮
  707. return (
  708. <Space wrap>
  709. <Button
  710. type='tertiary'
  711. size='small'
  712. onClick={() => manageTag(record.key, 'enable')}
  713. >
  714. {t('启用全部')}
  715. </Button>
  716. <Button
  717. type='tertiary'
  718. size='small'
  719. onClick={() => manageTag(record.key, 'disable')}
  720. >
  721. {t('禁用全部')}
  722. </Button>
  723. <Button
  724. type='tertiary'
  725. size='small'
  726. onClick={() => {
  727. setShowEditTag(true);
  728. setEditingTag(record.key);
  729. }}
  730. >
  731. {t('编辑')}
  732. </Button>
  733. </Space>
  734. );
  735. }
  736. },
  737. },
  738. ];
  739. };