ModelPricing.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606
  1. import React, { useContext, useEffect, useRef, useMemo, useState } from 'react';
  2. import { API, copy, showError, showInfo, showSuccess, getModelCategories, renderModelTag, stringToColor } from '../../helpers';
  3. import { useTranslation } from 'react-i18next';
  4. import {
  5. Input,
  6. Layout,
  7. Modal,
  8. Space,
  9. Table,
  10. Tag,
  11. Tooltip,
  12. Popover,
  13. ImagePreview,
  14. Button,
  15. Card,
  16. Tabs,
  17. TabPane,
  18. Empty
  19. } from '@douyinfe/semi-ui';
  20. import {
  21. IllustrationNoResult,
  22. IllustrationNoResultDark
  23. } from '@douyinfe/semi-illustrations';
  24. import {
  25. IconVerify,
  26. IconHelpCircle,
  27. IconSearch,
  28. IconCopy,
  29. IconInfoCircle,
  30. IconLayers
  31. } from '@douyinfe/semi-icons';
  32. import { UserContext } from '../../context/User/index.js';
  33. import { AlertCircle } from 'lucide-react';
  34. const ModelPricing = () => {
  35. const { t } = useTranslation();
  36. const [filteredValue, setFilteredValue] = useState([]);
  37. const compositionRef = useRef({ isComposition: false });
  38. const [selectedRowKeys, setSelectedRowKeys] = useState([]);
  39. const [modalImageUrl, setModalImageUrl] = useState('');
  40. const [isModalOpenurl, setIsModalOpenurl] = useState(false);
  41. const [selectedGroup, setSelectedGroup] = useState('default');
  42. const [activeKey, setActiveKey] = useState('all');
  43. const [pageSize, setPageSize] = useState(10);
  44. const rowSelection = useMemo(
  45. () => ({
  46. onChange: (selectedRowKeys, selectedRows) => {
  47. setSelectedRowKeys(selectedRowKeys);
  48. },
  49. }),
  50. [],
  51. );
  52. const handleChange = (value) => {
  53. if (compositionRef.current.isComposition) {
  54. return;
  55. }
  56. const newFilteredValue = value ? [value] : [];
  57. setFilteredValue(newFilteredValue);
  58. };
  59. const handleCompositionStart = () => {
  60. compositionRef.current.isComposition = true;
  61. };
  62. const handleCompositionEnd = (event) => {
  63. compositionRef.current.isComposition = false;
  64. const value = event.target.value;
  65. const newFilteredValue = value ? [value] : [];
  66. setFilteredValue(newFilteredValue);
  67. };
  68. function renderQuotaType(type) {
  69. switch (type) {
  70. case 1:
  71. return (
  72. <Tag color='teal' shape='circle'>
  73. {t('按次计费')}
  74. </Tag>
  75. );
  76. case 0:
  77. return (
  78. <Tag color='violet' shape='circle'>
  79. {t('按量计费')}
  80. </Tag>
  81. );
  82. default:
  83. return t('未知');
  84. }
  85. }
  86. function renderAvailable(available) {
  87. return available ? (
  88. <Popover
  89. content={
  90. <div style={{ padding: 8 }}>{t('您的分组可以使用该模型')}</div>
  91. }
  92. position='top'
  93. key={available}
  94. className="bg-green-50"
  95. >
  96. <IconVerify style={{ color: 'rgb(22 163 74)' }} size='large' />
  97. </Popover>
  98. ) : null;
  99. }
  100. function renderSupportedEndpoints(endpoints) {
  101. if (!endpoints || endpoints.length === 0) {
  102. return null;
  103. }
  104. return (
  105. <Space wrap>
  106. {endpoints.map((endpoint, idx) => (
  107. <Tag
  108. key={endpoint}
  109. color={stringToColor(endpoint)}
  110. shape='circle'
  111. >
  112. {endpoint}
  113. </Tag>
  114. ))}
  115. </Space>
  116. );
  117. }
  118. const columns = [
  119. {
  120. title: t('可用性'),
  121. dataIndex: 'available',
  122. render: (text, record, index) => {
  123. return renderAvailable(record.enable_groups.includes(selectedGroup));
  124. },
  125. sorter: (a, b) => {
  126. const aAvailable = a.enable_groups.includes(selectedGroup);
  127. const bAvailable = b.enable_groups.includes(selectedGroup);
  128. return Number(aAvailable) - Number(bAvailable);
  129. },
  130. defaultSortOrder: 'descend',
  131. },
  132. {
  133. title: t('可用端点类型'),
  134. dataIndex: 'supported_endpoint_types',
  135. render: (text, record, index) => {
  136. return renderSupportedEndpoints(text);
  137. },
  138. },
  139. {
  140. title: t('模型名称'),
  141. dataIndex: 'model_name',
  142. render: (text, record, index) => {
  143. return renderModelTag(text, {
  144. onClick: () => {
  145. copyText(text);
  146. }
  147. });
  148. },
  149. onFilter: (value, record) =>
  150. record.model_name.toLowerCase().includes(value.toLowerCase()),
  151. filteredValue,
  152. },
  153. {
  154. title: t('计费类型'),
  155. dataIndex: 'quota_type',
  156. render: (text, record, index) => {
  157. return renderQuotaType(parseInt(text));
  158. },
  159. sorter: (a, b) => a.quota_type - b.quota_type,
  160. },
  161. {
  162. title: t('可用分组'),
  163. dataIndex: 'enable_groups',
  164. render: (text, record, index) => {
  165. return (
  166. <Space wrap>
  167. {text.map((group) => {
  168. if (usableGroup[group]) {
  169. if (group === selectedGroup) {
  170. return (
  171. <Tag color='blue' shape='circle' prefixIcon={<IconVerify />}>
  172. {group}
  173. </Tag>
  174. );
  175. } else {
  176. return (
  177. <Tag
  178. color='blue'
  179. shape='circle'
  180. onClick={() => {
  181. setSelectedGroup(group);
  182. showInfo(
  183. t('当前查看的分组为:{{group}},倍率为:{{ratio}}', {
  184. group: group,
  185. ratio: groupRatio[group],
  186. }),
  187. );
  188. }}
  189. className="cursor-pointer hover:opacity-80 transition-opacity"
  190. >
  191. {group}
  192. </Tag>
  193. );
  194. }
  195. }
  196. })}
  197. </Space>
  198. );
  199. },
  200. },
  201. {
  202. title: () => (
  203. <div className="flex items-center space-x-1">
  204. <span>{t('倍率')}</span>
  205. <Tooltip content={t('倍率是为了方便换算不同价格的模型')}>
  206. <IconHelpCircle
  207. className="text-blue-500 cursor-pointer"
  208. onClick={() => {
  209. setModalImageUrl('/ratio.png');
  210. setIsModalOpenurl(true);
  211. }}
  212. />
  213. </Tooltip>
  214. </div>
  215. ),
  216. dataIndex: 'model_ratio',
  217. render: (text, record, index) => {
  218. let content = text;
  219. let completionRatio = parseFloat(record.completion_ratio.toFixed(3));
  220. content = (
  221. <div className="space-y-1">
  222. <div className="text-gray-700">
  223. {t('模型倍率')}:{record.quota_type === 0 ? text : t('无')}
  224. </div>
  225. <div className="text-gray-700">
  226. {t('补全倍率')}:
  227. {record.quota_type === 0 ? completionRatio : t('无')}
  228. </div>
  229. <div className="text-gray-700">
  230. {t('分组倍率')}:{groupRatio[selectedGroup]}
  231. </div>
  232. </div>
  233. );
  234. return content;
  235. },
  236. },
  237. {
  238. title: t('模型价格'),
  239. dataIndex: 'model_price',
  240. render: (text, record, index) => {
  241. let content = text;
  242. if (record.quota_type === 0) {
  243. let inputRatioPrice =
  244. record.model_ratio * 2 * groupRatio[selectedGroup];
  245. let completionRatioPrice =
  246. record.model_ratio *
  247. record.completion_ratio *
  248. 2 *
  249. groupRatio[selectedGroup];
  250. content = (
  251. <div className="space-y-1">
  252. <div className="text-gray-700">
  253. {t('提示')} ${inputRatioPrice.toFixed(3)} / 1M tokens
  254. </div>
  255. <div className="text-gray-700">
  256. {t('补全')} ${completionRatioPrice.toFixed(3)} / 1M tokens
  257. </div>
  258. </div>
  259. );
  260. } else {
  261. let price = parseFloat(text) * groupRatio[selectedGroup];
  262. content = (
  263. <div className="text-gray-700">
  264. {t('模型价格')}:${price.toFixed(3)}
  265. </div>
  266. );
  267. }
  268. return content;
  269. },
  270. },
  271. ];
  272. const [models, setModels] = useState([]);
  273. const [loading, setLoading] = useState(true);
  274. const [userState] = useContext(UserContext);
  275. const [groupRatio, setGroupRatio] = useState({});
  276. const [usableGroup, setUsableGroup] = useState({});
  277. const setModelsFormat = (models, groupRatio) => {
  278. for (let i = 0; i < models.length; i++) {
  279. models[i].key = models[i].model_name;
  280. models[i].group_ratio = groupRatio[models[i].model_name];
  281. }
  282. models.sort((a, b) => {
  283. return a.quota_type - b.quota_type;
  284. });
  285. models.sort((a, b) => {
  286. if (a.model_name.startsWith('gpt') && !b.model_name.startsWith('gpt')) {
  287. return -1;
  288. } else if (
  289. !a.model_name.startsWith('gpt') &&
  290. b.model_name.startsWith('gpt')
  291. ) {
  292. return 1;
  293. } else {
  294. return a.model_name.localeCompare(b.model_name);
  295. }
  296. });
  297. setModels(models);
  298. };
  299. const loadPricing = async () => {
  300. setLoading(true);
  301. let url = '/api/pricing';
  302. const res = await API.get(url);
  303. const { success, message, data, group_ratio, usable_group } = res.data;
  304. if (success) {
  305. setGroupRatio(group_ratio);
  306. setUsableGroup(usable_group);
  307. setSelectedGroup(userState.user ? userState.user.group : 'default');
  308. setModelsFormat(data, group_ratio);
  309. } else {
  310. showError(message);
  311. }
  312. setLoading(false);
  313. };
  314. const refresh = async () => {
  315. await loadPricing();
  316. };
  317. const copyText = async (text) => {
  318. if (await copy(text)) {
  319. showSuccess(t('已复制:') + text);
  320. } else {
  321. Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text });
  322. }
  323. };
  324. useEffect(() => {
  325. refresh().then();
  326. }, []);
  327. const modelCategories = getModelCategories(t);
  328. const categoryCounts = useMemo(() => {
  329. const counts = {};
  330. if (models.length > 0) {
  331. counts['all'] = models.length;
  332. Object.entries(modelCategories).forEach(([key, category]) => {
  333. if (key !== 'all') {
  334. counts[key] = models.filter(model => category.filter(model)).length;
  335. }
  336. });
  337. }
  338. return counts;
  339. }, [models, modelCategories]);
  340. const availableCategories = useMemo(() => {
  341. if (!models.length) return ['all'];
  342. return Object.entries(modelCategories).filter(([key, category]) => {
  343. if (key === 'all') return true;
  344. return models.some(model => category.filter(model));
  345. }).map(([key]) => key);
  346. }, [models]);
  347. const renderTabs = () => {
  348. return (
  349. <Tabs
  350. activeKey={activeKey}
  351. type="card"
  352. collapsible
  353. onChange={key => setActiveKey(key)}
  354. className="mt-2"
  355. >
  356. {Object.entries(modelCategories)
  357. .filter(([key]) => availableCategories.includes(key))
  358. .map(([key, category]) => {
  359. const modelCount = categoryCounts[key] || 0;
  360. return (
  361. <TabPane
  362. tab={
  363. <span className="flex items-center gap-2">
  364. {category.icon && <span className="w-4 h-4">{category.icon}</span>}
  365. {category.label}
  366. <Tag
  367. color={activeKey === key ? 'red' : 'grey'}
  368. shape='circle'
  369. >
  370. {modelCount}
  371. </Tag>
  372. </span>
  373. }
  374. itemKey={key}
  375. key={key}
  376. />
  377. );
  378. })}
  379. </Tabs>
  380. );
  381. };
  382. const filteredModels = useMemo(() => {
  383. let result = models;
  384. if (activeKey !== 'all') {
  385. result = result.filter(model => modelCategories[activeKey].filter(model));
  386. }
  387. if (filteredValue.length > 0) {
  388. const searchTerm = filteredValue[0].toLowerCase();
  389. result = result.filter(model =>
  390. model.model_name.toLowerCase().includes(searchTerm)
  391. );
  392. }
  393. return result;
  394. }, [activeKey, models, filteredValue]);
  395. const SearchAndActions = useMemo(() => (
  396. <Card className="!rounded-xl mb-6" bordered={false}>
  397. <div className="flex flex-wrap items-center gap-4">
  398. <div className="flex-1 min-w-[200px]">
  399. <Input
  400. prefix={<IconSearch />}
  401. placeholder={t('模糊搜索模型名称')}
  402. onCompositionStart={handleCompositionStart}
  403. onCompositionEnd={handleCompositionEnd}
  404. onChange={handleChange}
  405. showClear
  406. />
  407. </div>
  408. <Button
  409. theme='light'
  410. type='primary'
  411. icon={<IconCopy />}
  412. onClick={() => copyText(selectedRowKeys)}
  413. disabled={selectedRowKeys.length === 0}
  414. className="!bg-blue-500 hover:!bg-blue-600 text-white"
  415. >
  416. {t('复制选中模型')}
  417. </Button>
  418. </div>
  419. </Card>
  420. ), [selectedRowKeys, t]);
  421. const ModelTable = useMemo(() => (
  422. <Card className="!rounded-xl overflow-hidden" bordered={false}>
  423. <Table
  424. columns={columns}
  425. dataSource={filteredModels}
  426. loading={loading}
  427. rowSelection={rowSelection}
  428. className="custom-table"
  429. empty={
  430. <Empty
  431. image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
  432. darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
  433. description={t('搜索无结果')}
  434. style={{ padding: 30 }}
  435. />
  436. }
  437. pagination={{
  438. defaultPageSize: 10,
  439. pageSize: pageSize,
  440. showSizeChanger: true,
  441. pageSizeOptions: [10, 20, 50, 100],
  442. formatPageText: (page) =>
  443. t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
  444. start: page.currentStart,
  445. end: page.currentEnd,
  446. total: filteredModels.length,
  447. }),
  448. onPageSizeChange: (size) => setPageSize(size),
  449. }}
  450. />
  451. </Card>
  452. ), [filteredModels, loading, columns, rowSelection, pageSize, t]);
  453. return (
  454. <div className="bg-gray-50">
  455. <Layout>
  456. <Layout.Content>
  457. <div className="flex justify-center">
  458. <div className="w-full">
  459. {/* 主卡片容器 */}
  460. <Card bordered={false} className="!rounded-2xl shadow-lg border-0">
  461. {/* 顶部状态卡片 */}
  462. <Card
  463. className="!rounded-2xl !border-0 !shadow-md overflow-hidden mb-6"
  464. style={{
  465. background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 25%, #a855f7 50%, #c084fc 75%, #d8b4fe 100%)',
  466. position: 'relative'
  467. }}
  468. bodyStyle={{ padding: 0 }}
  469. >
  470. <div className="relative p-6 sm:p-8" style={{ color: 'white' }}>
  471. <div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4 lg:gap-6">
  472. <div className="flex items-start">
  473. <div className="w-10 h-10 sm:w-12 sm:h-12 rounded-xl bg-white/10 flex items-center justify-center mr-3 sm:mr-4">
  474. <IconLayers size="extra-large" className="text-white" />
  475. </div>
  476. <div className="flex-1 min-w-0">
  477. <div className="text-base sm:text-lg font-semibold mb-1 sm:mb-2">
  478. {t('模型定价')}
  479. </div>
  480. <div className="text-sm text-white/80">
  481. {userState.user ? (
  482. <div className="flex items-center">
  483. <IconVerify className="mr-1.5 flex-shrink-0" size="small" />
  484. <span className="truncate">
  485. {t('当前分组')}: {userState.user.group},{t('倍率')}: {groupRatio[userState.user.group]}
  486. </span>
  487. </div>
  488. ) : (
  489. <div className="flex items-center">
  490. <AlertCircle size={14} className="mr-1.5 flex-shrink-0" />
  491. <span className="truncate">
  492. {t('未登录,使用默认分组倍率:')}{groupRatio['default']}
  493. </span>
  494. </div>
  495. )}
  496. </div>
  497. </div>
  498. </div>
  499. <div className="grid grid-cols-3 gap-2 sm:gap-3 mt-2 lg:mt-0">
  500. <div
  501. className="text-center px-2 py-2 sm:px-3 sm:py-2.5 bg-white/10 rounded-lg backdrop-blur-sm hover:bg-white/20 transition-colors duration-200"
  502. style={{ backdropFilter: 'blur(10px)' }}
  503. >
  504. <div className="text-xs text-white/70 mb-0.5">{t('分组倍率')}</div>
  505. <div className="text-sm sm:text-base font-semibold">{groupRatio[selectedGroup] || '1.0'}x</div>
  506. </div>
  507. <div
  508. className="text-center px-2 py-2 sm:px-3 sm:py-2.5 bg-white/10 rounded-lg backdrop-blur-sm hover:bg-white/20 transition-colors duration-200"
  509. style={{ backdropFilter: 'blur(10px)' }}
  510. >
  511. <div className="text-xs text-white/70 mb-0.5">{t('可用模型')}</div>
  512. <div className="text-sm sm:text-base font-semibold">
  513. {models.filter(m => m.enable_groups.includes(selectedGroup)).length}
  514. </div>
  515. </div>
  516. <div
  517. className="text-center px-2 py-2 sm:px-3 sm:py-2.5 bg-white/10 rounded-lg backdrop-blur-sm hover:bg-white/20 transition-colors duration-200"
  518. style={{ backdropFilter: 'blur(10px)' }}
  519. >
  520. <div className="text-xs text-white/70 mb-0.5">{t('计费类型')}</div>
  521. <div className="text-sm sm:text-base font-semibold">2</div>
  522. </div>
  523. </div>
  524. </div>
  525. {/* 计费说明 */}
  526. <div className="mt-4 sm:mt-5">
  527. <div className="flex items-start">
  528. <div
  529. className="w-full flex items-start space-x-2 px-3 py-2 sm:px-4 sm:py-2.5 rounded-lg text-xs sm:text-sm"
  530. style={{
  531. backgroundColor: 'rgba(255, 255, 255, 0.2)',
  532. color: 'white',
  533. backdropFilter: 'blur(10px)'
  534. }}
  535. >
  536. <IconInfoCircle className="flex-shrink-0 mt-0.5" size="small" />
  537. <span>
  538. {t('按量计费费用 = 分组倍率 × 模型倍率 × (提示token数 + 补全token数 × 补全倍率)/ 500000 (单位:美元)')}
  539. </span>
  540. </div>
  541. </div>
  542. </div>
  543. <div className="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-yellow-400 via-orange-400 to-red-400" style={{ opacity: 0.6 }}></div>
  544. </div>
  545. </Card>
  546. {/* 模型分类 Tabs */}
  547. <div className="mb-6">
  548. {renderTabs()}
  549. {/* 搜索和表格区域 */}
  550. {SearchAndActions}
  551. {ModelTable}
  552. </div>
  553. {/* 倍率说明图预览 */}
  554. <ImagePreview
  555. src={modalImageUrl}
  556. visible={isModalOpenurl}
  557. onVisibleChange={(visible) => setIsModalOpenurl(visible)}
  558. />
  559. </Card>
  560. </div>
  561. </div>
  562. </Layout.Content>
  563. </Layout>
  564. </div>
  565. );
  566. };
  567. export default ModelPricing;