ModelTestModal.jsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369
  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. Modal,
  18. Button,
  19. Input,
  20. Table,
  21. Tag,
  22. Typography,
  23. Select,
  24. Switch,
  25. Banner,
  26. } from '@douyinfe/semi-ui';
  27. import { IconSearch, IconInfoCircle } from '@douyinfe/semi-icons';
  28. import { copy, showError, showInfo, showSuccess } from '../../../../helpers';
  29. import { MODEL_TABLE_PAGE_SIZE } from '../../../../constants';
  30. const ModelTestModal = ({
  31. showModelTestModal,
  32. currentTestChannel,
  33. handleCloseModal,
  34. isBatchTesting,
  35. batchTestModels,
  36. modelSearchKeyword,
  37. setModelSearchKeyword,
  38. selectedModelKeys,
  39. setSelectedModelKeys,
  40. modelTestResults,
  41. testingModels,
  42. testChannel,
  43. modelTablePage,
  44. setModelTablePage,
  45. selectedEndpointType,
  46. setSelectedEndpointType,
  47. isStreamTest,
  48. setIsStreamTest,
  49. allSelectingRef,
  50. isMobile,
  51. t,
  52. }) => {
  53. const hasChannel = Boolean(currentTestChannel);
  54. const streamToggleDisabled = [
  55. 'embeddings',
  56. 'image-generation',
  57. 'jina-rerank',
  58. 'openai-response-compact',
  59. ].includes(selectedEndpointType);
  60. React.useEffect(() => {
  61. if (streamToggleDisabled && isStreamTest) {
  62. setIsStreamTest(false);
  63. }
  64. }, [streamToggleDisabled, isStreamTest, setIsStreamTest]);
  65. const filteredModels = hasChannel
  66. ? currentTestChannel.models
  67. .split(',')
  68. .filter((model) =>
  69. model.toLowerCase().includes(modelSearchKeyword.toLowerCase()),
  70. )
  71. : [];
  72. const endpointTypeOptions = [
  73. { value: '', label: t('自动检测') },
  74. { value: 'openai', label: 'OpenAI (/v1/chat/completions)' },
  75. { value: 'openai-response', label: 'OpenAI Response (/v1/responses)' },
  76. {
  77. value: 'openai-response-compact',
  78. label: 'OpenAI Response Compaction (/v1/responses/compact)',
  79. },
  80. { value: 'anthropic', label: 'Anthropic (/v1/messages)' },
  81. {
  82. value: 'gemini',
  83. label: 'Gemini (/v1beta/models/{model}:generateContent)',
  84. },
  85. { value: 'jina-rerank', label: 'Jina Rerank (/v1/rerank)' },
  86. {
  87. value: 'image-generation',
  88. label: t('图像生成') + ' (/v1/images/generations)',
  89. },
  90. { value: 'embeddings', label: 'Embeddings (/v1/embeddings)' },
  91. ];
  92. const handleCopySelected = () => {
  93. if (selectedModelKeys.length === 0) {
  94. showError(t('请先选择模型!'));
  95. return;
  96. }
  97. copy(selectedModelKeys.join(',')).then((ok) => {
  98. if (ok) {
  99. showSuccess(
  100. t('已复制 ${count} 个模型').replace(
  101. '${count}',
  102. selectedModelKeys.length,
  103. ),
  104. );
  105. } else {
  106. showError(t('复制失败,请手动复制'));
  107. }
  108. });
  109. };
  110. const handleSelectSuccess = () => {
  111. if (!currentTestChannel) return;
  112. const successKeys = currentTestChannel.models
  113. .split(',')
  114. .filter((m) => m.toLowerCase().includes(modelSearchKeyword.toLowerCase()))
  115. .filter((m) => {
  116. const result = modelTestResults[`${currentTestChannel.id}-${m}`];
  117. return result && result.success;
  118. });
  119. if (successKeys.length === 0) {
  120. showInfo(t('暂无成功模型'));
  121. }
  122. setSelectedModelKeys(successKeys);
  123. };
  124. const columns = [
  125. {
  126. title: t('模型名称'),
  127. dataIndex: 'model',
  128. render: (text) => (
  129. <div className='flex items-center'>
  130. <Typography.Text strong>{text}</Typography.Text>
  131. </div>
  132. ),
  133. },
  134. {
  135. title: t('状态'),
  136. dataIndex: 'status',
  137. render: (text, record) => {
  138. const testResult =
  139. modelTestResults[`${currentTestChannel.id}-${record.model}`];
  140. const isTesting = testingModels.has(record.model);
  141. if (isTesting) {
  142. return (
  143. <Tag color='blue' shape='circle'>
  144. {t('测试中')}
  145. </Tag>
  146. );
  147. }
  148. if (!testResult) {
  149. return (
  150. <Tag color='grey' shape='circle'>
  151. {t('未开始')}
  152. </Tag>
  153. );
  154. }
  155. return (
  156. <div className='flex items-center gap-2'>
  157. <Tag color={testResult.success ? 'green' : 'red'} shape='circle'>
  158. {testResult.success ? t('成功') : t('失败')}
  159. </Tag>
  160. {testResult.success && (
  161. <Typography.Text type='tertiary'>
  162. {t('请求时长: ${time}s').replace(
  163. '${time}',
  164. testResult.time.toFixed(2),
  165. )}
  166. </Typography.Text>
  167. )}
  168. </div>
  169. );
  170. },
  171. },
  172. {
  173. title: '',
  174. dataIndex: 'operate',
  175. render: (text, record) => {
  176. const isTesting = testingModels.has(record.model);
  177. return (
  178. <Button
  179. type='tertiary'
  180. onClick={() =>
  181. testChannel(
  182. currentTestChannel,
  183. record.model,
  184. selectedEndpointType,
  185. isStreamTest,
  186. )
  187. }
  188. loading={isTesting}
  189. size='small'
  190. >
  191. {t('测试')}
  192. </Button>
  193. );
  194. },
  195. },
  196. ];
  197. const dataSource = (() => {
  198. if (!hasChannel) return [];
  199. const start = (modelTablePage - 1) * MODEL_TABLE_PAGE_SIZE;
  200. const end = start + MODEL_TABLE_PAGE_SIZE;
  201. return filteredModels.slice(start, end).map((model) => ({
  202. model,
  203. key: model,
  204. }));
  205. })();
  206. return (
  207. <Modal
  208. title={
  209. hasChannel ? (
  210. <div className='flex flex-col gap-2 w-full'>
  211. <div className='flex items-center gap-2'>
  212. <Typography.Text
  213. strong
  214. className='!text-[var(--semi-color-text-0)] !text-base'
  215. >
  216. {currentTestChannel.name} {t('渠道的模型测试')}
  217. </Typography.Text>
  218. <Typography.Text type='tertiary' size='small'>
  219. {t('共')} {currentTestChannel.models.split(',').length}{' '}
  220. {t('个模型')}
  221. </Typography.Text>
  222. </div>
  223. </div>
  224. ) : null
  225. }
  226. visible={showModelTestModal}
  227. onCancel={handleCloseModal}
  228. footer={
  229. hasChannel ? (
  230. <div className='flex justify-end'>
  231. {isBatchTesting ? (
  232. <Button type='danger' onClick={handleCloseModal}>
  233. {t('停止测试')}
  234. </Button>
  235. ) : (
  236. <Button type='tertiary' onClick={handleCloseModal}>
  237. {t('取消')}
  238. </Button>
  239. )}
  240. <Button
  241. onClick={batchTestModels}
  242. loading={isBatchTesting}
  243. disabled={isBatchTesting}
  244. >
  245. {isBatchTesting
  246. ? t('测试中...')
  247. : t('批量测试${count}个模型').replace(
  248. '${count}',
  249. filteredModels.length,
  250. )}
  251. </Button>
  252. </div>
  253. ) : null
  254. }
  255. maskClosable={!isBatchTesting}
  256. className='!rounded-lg'
  257. size={isMobile ? 'full-width' : 'large'}
  258. >
  259. {hasChannel && (
  260. <div className='model-test-scroll'>
  261. {/* Endpoint toolbar */}
  262. <div className='flex flex-col sm:flex-row sm:items-center gap-2 w-full mb-2'>
  263. <div className='flex items-center gap-2 flex-1 min-w-0'>
  264. <Typography.Text strong className='shrink-0'>
  265. {t('端点类型')}:
  266. </Typography.Text>
  267. <Select
  268. value={selectedEndpointType}
  269. onChange={setSelectedEndpointType}
  270. optionList={endpointTypeOptions}
  271. className='!w-full min-w-0'
  272. placeholder={t('选择端点类型')}
  273. />
  274. </div>
  275. <div className='flex items-center justify-between sm:justify-end gap-2 shrink-0'>
  276. <Typography.Text strong className='shrink-0'>
  277. {t('流式')}:
  278. </Typography.Text>
  279. <Switch
  280. checked={isStreamTest}
  281. onChange={setIsStreamTest}
  282. size='small'
  283. disabled={streamToggleDisabled}
  284. aria-label={t('流式')}
  285. />
  286. </div>
  287. </div>
  288. <Banner
  289. type='info'
  290. closeIcon={null}
  291. icon={<IconInfoCircle />}
  292. className='!rounded-lg mb-2'
  293. description={t(
  294. '说明:本页测试为非流式请求;若渠道仅支持流式返回,可能出现测试失败,请以实际使用为准。',
  295. )}
  296. />
  297. {/* 搜索与操作按钮 */}
  298. <div className='flex flex-col sm:flex-row sm:items-center gap-2 w-full mb-2'>
  299. <Input
  300. placeholder={t('搜索模型...')}
  301. value={modelSearchKeyword}
  302. onChange={(v) => {
  303. setModelSearchKeyword(v);
  304. setModelTablePage(1);
  305. }}
  306. className='!w-full sm:!flex-1'
  307. prefix={<IconSearch />}
  308. showClear
  309. />
  310. <div className='flex items-center justify-end gap-2'>
  311. <Button onClick={handleCopySelected}>{t('复制已选')}</Button>
  312. <Button type='tertiary' onClick={handleSelectSuccess}>
  313. {t('选择成功')}
  314. </Button>
  315. </div>
  316. </div>
  317. <Table
  318. columns={columns}
  319. dataSource={dataSource}
  320. rowSelection={{
  321. selectedRowKeys: selectedModelKeys,
  322. onChange: (keys) => {
  323. if (allSelectingRef.current) {
  324. allSelectingRef.current = false;
  325. return;
  326. }
  327. setSelectedModelKeys(keys);
  328. },
  329. onSelectAll: (checked) => {
  330. allSelectingRef.current = true;
  331. setSelectedModelKeys(checked ? filteredModels : []);
  332. },
  333. }}
  334. pagination={{
  335. currentPage: modelTablePage,
  336. pageSize: MODEL_TABLE_PAGE_SIZE,
  337. total: filteredModels.length,
  338. showSizeChanger: false,
  339. onPageChange: (page) => setModelTablePage(page),
  340. }}
  341. />
  342. </div>
  343. )}
  344. </Modal>
  345. );
  346. };
  347. export default ModelTestModal;