TokensColumnDefs.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472
  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. Space,
  20. SplitButtonGroup,
  21. Tag,
  22. AvatarGroup,
  23. Avatar,
  24. Tooltip,
  25. Progress,
  26. Switch,
  27. Input,
  28. Modal
  29. } from '@douyinfe/semi-ui';
  30. import {
  31. timestamp2string,
  32. renderGroup,
  33. renderQuota,
  34. getModelCategories,
  35. showError
  36. } from '../../../helpers';
  37. import {
  38. IconTreeTriangleDown,
  39. IconCopy,
  40. IconEyeOpened,
  41. IconEyeClosed,
  42. } from '@douyinfe/semi-icons';
  43. // Render functions
  44. function renderTimestamp(timestamp) {
  45. return <>{timestamp2string(timestamp)}</>;
  46. }
  47. // Render status column with switch and progress bar
  48. const renderStatus = (text, record, manageToken, t) => {
  49. const enabled = text === 1;
  50. const handleToggle = (checked) => {
  51. if (checked) {
  52. manageToken(record.id, 'enable', record);
  53. } else {
  54. manageToken(record.id, 'disable', record);
  55. }
  56. };
  57. let tagColor = 'black';
  58. let tagText = t('未知状态');
  59. if (enabled) {
  60. tagColor = 'green';
  61. tagText = t('已启用');
  62. } else if (text === 2) {
  63. tagColor = 'red';
  64. tagText = t('已禁用');
  65. } else if (text === 3) {
  66. tagColor = 'yellow';
  67. tagText = t('已过期');
  68. } else if (text === 4) {
  69. tagColor = 'grey';
  70. tagText = t('已耗尽');
  71. }
  72. const used = parseInt(record.used_quota) || 0;
  73. const remain = parseInt(record.remain_quota) || 0;
  74. const total = used + remain;
  75. const percent = total > 0 ? (remain / total) * 100 : 0;
  76. const getProgressColor = (pct) => {
  77. if (pct === 100) return 'var(--semi-color-success)';
  78. if (pct <= 10) return 'var(--semi-color-danger)';
  79. if (pct <= 30) return 'var(--semi-color-warning)';
  80. return undefined;
  81. };
  82. const quotaSuffix = record.unlimited_quota ? (
  83. <div className='text-xs'>{t('无限额度')}</div>
  84. ) : (
  85. <div className='flex flex-col items-end'>
  86. <span className='text-xs leading-none'>{`${renderQuota(remain)} / ${renderQuota(total)}`}</span>
  87. <Progress
  88. percent={percent}
  89. stroke={getProgressColor(percent)}
  90. aria-label='quota usage'
  91. format={() => `${percent.toFixed(0)}%`}
  92. style={{ width: '100%', marginTop: '1px', marginBottom: 0 }}
  93. />
  94. </div>
  95. );
  96. const content = (
  97. <Tag
  98. color={tagColor}
  99. shape='circle'
  100. size='large'
  101. prefixIcon={
  102. <Switch
  103. size='small'
  104. checked={enabled}
  105. onChange={handleToggle}
  106. aria-label='token status switch'
  107. />
  108. }
  109. suffixIcon={quotaSuffix}
  110. >
  111. {tagText}
  112. </Tag>
  113. );
  114. const tooltipContent = record.unlimited_quota ? (
  115. <div className='text-xs'>
  116. <div>{t('已用额度')}: {renderQuota(used)}</div>
  117. </div>
  118. ) : (
  119. <div className='text-xs'>
  120. <div>{t('已用额度')}: {renderQuota(used)}</div>
  121. <div>{t('剩余额度')}: {renderQuota(remain)} ({percent.toFixed(0)}%)</div>
  122. <div>{t('总额度')}: {renderQuota(total)}</div>
  123. </div>
  124. );
  125. return (
  126. <Tooltip content={tooltipContent}>
  127. {content}
  128. </Tooltip>
  129. );
  130. };
  131. // Render group column
  132. const renderGroupColumn = (text, t) => {
  133. if (text === 'auto') {
  134. return (
  135. <Tooltip
  136. content={t('当前分组为 auto,会自动选择最优分组,当一个组不可用时自动降级到下一个组(熔断机制)')}
  137. position='top'
  138. >
  139. <Tag color='white' shape='circle'> {t('智能熔断')} </Tag>
  140. </Tooltip>
  141. );
  142. }
  143. return renderGroup(text);
  144. };
  145. // Render token key column with show/hide and copy functionality
  146. const renderTokenKey = (text, record, showKeys, setShowKeys, copyText) => {
  147. const fullKey = 'sk-' + record.key;
  148. const maskedKey = 'sk-' + record.key.slice(0, 4) + '**********' + record.key.slice(-4);
  149. const revealed = !!showKeys[record.id];
  150. return (
  151. <div className='w-[200px]'>
  152. <Input
  153. readOnly
  154. value={revealed ? fullKey : maskedKey}
  155. size='small'
  156. suffix={
  157. <div className='flex items-center'>
  158. <Button
  159. theme='borderless'
  160. size='small'
  161. type='tertiary'
  162. icon={revealed ? <IconEyeClosed /> : <IconEyeOpened />}
  163. aria-label='toggle token visibility'
  164. onClick={(e) => {
  165. e.stopPropagation();
  166. setShowKeys(prev => ({ ...prev, [record.id]: !revealed }));
  167. }}
  168. />
  169. <Button
  170. theme='borderless'
  171. size='small'
  172. type='tertiary'
  173. icon={<IconCopy />}
  174. aria-label='copy token key'
  175. onClick={async (e) => {
  176. e.stopPropagation();
  177. await copyText(fullKey);
  178. }}
  179. />
  180. </div>
  181. }
  182. />
  183. </div>
  184. );
  185. };
  186. // Render model limits column
  187. const renderModelLimits = (text, record, t) => {
  188. if (record.model_limits_enabled && text) {
  189. const models = text.split(',').filter(Boolean);
  190. const categories = getModelCategories(t);
  191. const vendorAvatars = [];
  192. const matchedModels = new Set();
  193. Object.entries(categories).forEach(([key, category]) => {
  194. if (key === 'all') return;
  195. if (!category.icon || !category.filter) return;
  196. const vendorModels = models.filter((m) => category.filter({ model_name: m }));
  197. if (vendorModels.length > 0) {
  198. vendorAvatars.push(
  199. <Tooltip key={key} content={vendorModels.join(', ')} position='top' showArrow>
  200. <Avatar size='extra-extra-small' alt={category.label} color='transparent'>
  201. {category.icon}
  202. </Avatar>
  203. </Tooltip>
  204. );
  205. vendorModels.forEach((m) => matchedModels.add(m));
  206. }
  207. });
  208. const unmatchedModels = models.filter((m) => !matchedModels.has(m));
  209. if (unmatchedModels.length > 0) {
  210. vendorAvatars.push(
  211. <Tooltip key='unknown' content={unmatchedModels.join(', ')} position='top' showArrow>
  212. <Avatar size='extra-extra-small' alt='unknown'>
  213. {t('其他')}
  214. </Avatar>
  215. </Tooltip>
  216. );
  217. }
  218. return (
  219. <AvatarGroup size='extra-extra-small'>
  220. {vendorAvatars}
  221. </AvatarGroup>
  222. );
  223. } else {
  224. return (
  225. <Tag color='white' shape='circle'>
  226. {t('无限制')}
  227. </Tag>
  228. );
  229. }
  230. };
  231. // Render IP restrictions column
  232. const renderAllowIps = (text, t) => {
  233. if (!text || text.trim() === '') {
  234. return (
  235. <Tag color='white' shape='circle'>
  236. {t('无限制')}
  237. </Tag>
  238. );
  239. }
  240. const ips = text
  241. .split('\n')
  242. .map((ip) => ip.trim())
  243. .filter(Boolean);
  244. const displayIps = ips.slice(0, 1);
  245. const extraCount = ips.length - displayIps.length;
  246. const ipTags = displayIps.map((ip, idx) => (
  247. <Tag key={idx} shape='circle'>
  248. {ip}
  249. </Tag>
  250. ));
  251. if (extraCount > 0) {
  252. ipTags.push(
  253. <Tooltip
  254. key='extra'
  255. content={ips.slice(1).join(', ')}
  256. position='top'
  257. showArrow
  258. >
  259. <Tag shape='circle'>
  260. {'+' + extraCount}
  261. </Tag>
  262. </Tooltip>
  263. );
  264. }
  265. return <Space wrap>{ipTags}</Space>;
  266. };
  267. // Render operations column
  268. const renderOperations = (text, record, onOpenLink, setEditingToken, setShowEdit, manageToken, refresh, t) => {
  269. let chats = localStorage.getItem('chats');
  270. let chatsArray = [];
  271. let shouldUseCustom = true;
  272. if (shouldUseCustom) {
  273. try {
  274. chats = JSON.parse(chats);
  275. if (Array.isArray(chats)) {
  276. for (let i = 0; i < chats.length; i++) {
  277. let chat = {};
  278. chat.node = 'item';
  279. for (let key in chats[i]) {
  280. if (chats[i].hasOwnProperty(key)) {
  281. chat.key = i;
  282. chat.name = key;
  283. chat.onClick = () => {
  284. onOpenLink(key, chats[i][key], record);
  285. };
  286. }
  287. }
  288. chatsArray.push(chat);
  289. }
  290. }
  291. } catch (e) {
  292. console.log(e);
  293. showError(t('聊天链接配置错误,请联系管理员'));
  294. }
  295. }
  296. return (
  297. <Space wrap>
  298. <SplitButtonGroup
  299. className="overflow-hidden"
  300. aria-label={t('项目操作按钮组')}
  301. >
  302. <Button
  303. size="small"
  304. type='tertiary'
  305. onClick={() => {
  306. if (chatsArray.length === 0) {
  307. showError(t('请联系管理员配置聊天链接'));
  308. } else {
  309. onOpenLink(
  310. 'default',
  311. chats[0][Object.keys(chats[0])[0]],
  312. record,
  313. );
  314. }
  315. }}
  316. >
  317. {t('聊天')}
  318. </Button>
  319. <Dropdown
  320. trigger='click'
  321. position='bottomRight'
  322. menu={chatsArray}
  323. >
  324. <Button
  325. type='tertiary'
  326. icon={<IconTreeTriangleDown />}
  327. size="small"
  328. ></Button>
  329. </Dropdown>
  330. </SplitButtonGroup>
  331. <Button
  332. type='tertiary'
  333. size="small"
  334. onClick={() => {
  335. setEditingToken(record);
  336. setShowEdit(true);
  337. }}
  338. >
  339. {t('编辑')}
  340. </Button>
  341. <Button
  342. type='danger'
  343. size="small"
  344. onClick={() => {
  345. Modal.confirm({
  346. title: t('确定是否要删除此令牌?'),
  347. content: t('此修改将不可逆'),
  348. onOk: () => {
  349. (async () => {
  350. await manageToken(record.id, 'delete', record);
  351. await refresh();
  352. })();
  353. },
  354. });
  355. }}
  356. >
  357. {t('删除')}
  358. </Button>
  359. </Space>
  360. );
  361. };
  362. export const getTokensColumns = ({
  363. t,
  364. showKeys,
  365. setShowKeys,
  366. copyText,
  367. manageToken,
  368. onOpenLink,
  369. setEditingToken,
  370. setShowEdit,
  371. refresh,
  372. }) => {
  373. return [
  374. {
  375. title: t('名称'),
  376. dataIndex: 'name',
  377. },
  378. {
  379. title: t('状态'),
  380. dataIndex: 'status',
  381. key: 'status',
  382. render: (text, record) => renderStatus(text, record, manageToken, t),
  383. },
  384. {
  385. title: t('分组'),
  386. dataIndex: 'group',
  387. key: 'group',
  388. render: (text) => renderGroupColumn(text, t),
  389. },
  390. {
  391. title: t('密钥'),
  392. key: 'token_key',
  393. render: (text, record) => renderTokenKey(text, record, showKeys, setShowKeys, copyText),
  394. },
  395. {
  396. title: t('可用模型'),
  397. dataIndex: 'model_limits',
  398. render: (text, record) => renderModelLimits(text, record, t),
  399. },
  400. {
  401. title: t('IP限制'),
  402. dataIndex: 'allow_ips',
  403. render: (text) => renderAllowIps(text, t),
  404. },
  405. {
  406. title: t('创建时间'),
  407. dataIndex: 'created_time',
  408. render: (text, record, index) => {
  409. return <div>{renderTimestamp(text)}</div>;
  410. },
  411. },
  412. {
  413. title: t('过期时间'),
  414. dataIndex: 'expired_time',
  415. render: (text, record, index) => {
  416. return (
  417. <div>
  418. {record.expired_time === -1 ? t('永不过期') : renderTimestamp(text)}
  419. </div>
  420. );
  421. },
  422. },
  423. {
  424. title: '',
  425. dataIndex: 'operate',
  426. fixed: 'right',
  427. render: (text, record, index) => renderOperations(
  428. text,
  429. record,
  430. onOpenLink,
  431. setEditingToken,
  432. setShowEdit,
  433. manageToken,
  434. refresh,
  435. t
  436. ),
  437. },
  438. ];
  439. };