ModelSelectModal.jsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419
  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, { useState, useEffect, useMemo } from 'react';
  16. import { useIsMobile } from '../../../../hooks/common/useIsMobile';
  17. import {
  18. Modal,
  19. Checkbox,
  20. Spin,
  21. Input,
  22. Typography,
  23. Empty,
  24. Tabs,
  25. Collapse,
  26. Tooltip,
  27. } from '@douyinfe/semi-ui';
  28. import {
  29. IllustrationNoResult,
  30. IllustrationNoResultDark,
  31. } from '@douyinfe/semi-illustrations';
  32. import { IconSearch, IconInfoCircle } from '@douyinfe/semi-icons';
  33. import { useTranslation } from 'react-i18next';
  34. import { getModelCategories } from '../../../../helpers/render';
  35. const ModelSelectModal = ({
  36. visible,
  37. models = [],
  38. selected = [],
  39. redirectModels = [],
  40. onConfirm,
  41. onCancel,
  42. }) => {
  43. const { t } = useTranslation();
  44. const getModelName = (model) => {
  45. if (!model) return '';
  46. if (typeof model === 'string') return model;
  47. if (typeof model === 'object' && model.model_name) return model.model_name;
  48. return String(model ?? '');
  49. };
  50. const normalizedSelected = useMemo(
  51. () => (selected || []).map(getModelName),
  52. [selected],
  53. );
  54. const [checkedList, setCheckedList] = useState(normalizedSelected);
  55. const [keyword, setKeyword] = useState('');
  56. const [activeTab, setActiveTab] = useState('new');
  57. const isMobile = useIsMobile();
  58. const normalizeModelName = (model) =>
  59. typeof model === 'string' ? model.trim() : '';
  60. const normalizedRedirectModels = useMemo(
  61. () =>
  62. Array.from(
  63. new Set(
  64. (redirectModels || [])
  65. .map((model) => normalizeModelName(model))
  66. .filter(Boolean),
  67. ),
  68. ),
  69. [redirectModels],
  70. );
  71. const normalizedSelectedSet = useMemo(() => {
  72. const set = new Set();
  73. (selected || []).forEach((model) => {
  74. const normalized = normalizeModelName(model);
  75. if (normalized) {
  76. set.add(normalized);
  77. }
  78. });
  79. return set;
  80. }, [selected]);
  81. const classificationSet = useMemo(() => {
  82. const set = new Set(normalizedSelectedSet);
  83. normalizedRedirectModels.forEach((model) => set.add(model));
  84. return set;
  85. }, [normalizedSelectedSet, normalizedRedirectModels]);
  86. const redirectOnlySet = useMemo(() => {
  87. const set = new Set();
  88. normalizedRedirectModels.forEach((model) => {
  89. if (!normalizedSelectedSet.has(model)) {
  90. set.add(model);
  91. }
  92. });
  93. return set;
  94. }, [normalizedRedirectModels, normalizedSelectedSet]);
  95. const filteredModels = models.filter((m) =>
  96. String(m || '')
  97. .toLowerCase()
  98. .includes(keyword.toLowerCase()),
  99. );
  100. // 分类模型:新获取的模型和已有模型
  101. const isExistingModel = (model) =>
  102. classificationSet.has(normalizeModelName(model));
  103. const newModels = filteredModels.filter((model) => !isExistingModel(model));
  104. const existingModels = filteredModels.filter((model) =>
  105. isExistingModel(model),
  106. );
  107. // 同步外部选中值
  108. useEffect(() => {
  109. if (visible) {
  110. setCheckedList(normalizedSelected);
  111. }
  112. }, [visible, normalizedSelected]);
  113. // 当模型列表变化时,设置默认tab
  114. useEffect(() => {
  115. if (visible) {
  116. // 默认显示新获取模型tab,如果没有新模型则显示已有模型
  117. const hasNewModels = newModels.length > 0;
  118. setActiveTab(hasNewModels ? 'new' : 'existing');
  119. }
  120. }, [visible, newModels.length, selected]);
  121. const handleOk = () => {
  122. onConfirm && onConfirm(checkedList);
  123. };
  124. // 按厂商分类模型
  125. const categorizeModels = (models) => {
  126. const categories = getModelCategories(t);
  127. const categorizedModels = {};
  128. const uncategorizedModels = [];
  129. models.forEach((model) => {
  130. let foundCategory = false;
  131. for (const [key, category] of Object.entries(categories)) {
  132. if (key !== 'all' && category.filter({ model_name: model })) {
  133. if (!categorizedModels[key]) {
  134. categorizedModels[key] = {
  135. label: category.label,
  136. icon: category.icon,
  137. models: [],
  138. };
  139. }
  140. categorizedModels[key].models.push(model);
  141. foundCategory = true;
  142. break;
  143. }
  144. }
  145. if (!foundCategory) {
  146. uncategorizedModels.push(model);
  147. }
  148. });
  149. // 如果有未分类模型,添加到"其他"分类
  150. if (uncategorizedModels.length > 0) {
  151. categorizedModels['other'] = {
  152. label: t('其他'),
  153. icon: null,
  154. models: uncategorizedModels,
  155. };
  156. }
  157. return categorizedModels;
  158. };
  159. const newModelsByCategory = categorizeModels(newModels);
  160. const existingModelsByCategory = categorizeModels(existingModels);
  161. // Tab列表配置
  162. const tabList = [
  163. ...(newModels.length > 0
  164. ? [
  165. {
  166. tab: `${t('新获取的模型')} (${newModels.length})`,
  167. itemKey: 'new',
  168. },
  169. ]
  170. : []),
  171. ...(existingModels.length > 0
  172. ? [
  173. {
  174. tab: `${t('已有的模型')} (${existingModels.length})`,
  175. itemKey: 'existing',
  176. },
  177. ]
  178. : []),
  179. ];
  180. // 处理分类全选/取消全选
  181. const handleCategorySelectAll = (categoryModels, isChecked) => {
  182. let newCheckedList = [...checkedList];
  183. if (isChecked) {
  184. // 全选:添加该分类下所有未选中的模型
  185. categoryModels.forEach((model) => {
  186. if (!newCheckedList.includes(model)) {
  187. newCheckedList.push(model);
  188. }
  189. });
  190. } else {
  191. // 取消全选:移除该分类下所有已选中的模型
  192. newCheckedList = newCheckedList.filter(
  193. (model) => !categoryModels.includes(model),
  194. );
  195. }
  196. setCheckedList(newCheckedList);
  197. };
  198. // 检查分类是否全选
  199. const isCategoryAllSelected = (categoryModels) => {
  200. return (
  201. categoryModels.length > 0 &&
  202. categoryModels.every((model) => checkedList.includes(model))
  203. );
  204. };
  205. // 检查分类是否部分选中
  206. const isCategoryIndeterminate = (categoryModels) => {
  207. const selectedCount = categoryModels.filter((model) =>
  208. checkedList.includes(model),
  209. ).length;
  210. return selectedCount > 0 && selectedCount < categoryModels.length;
  211. };
  212. const renderModelsByCategory = (modelsByCategory, categoryKeyPrefix) => {
  213. const categoryEntries = Object.entries(modelsByCategory);
  214. if (categoryEntries.length === 0) return null;
  215. // 生成所有面板的key,确保都展开
  216. const allActiveKeys = categoryEntries.map(
  217. (_, index) => `${categoryKeyPrefix}_${index}`,
  218. );
  219. return (
  220. <Collapse
  221. key={`${categoryKeyPrefix}_${categoryEntries.length}`}
  222. defaultActiveKey={[]}
  223. >
  224. {categoryEntries.map(([key, categoryData], index) => (
  225. <Collapse.Panel
  226. key={`${categoryKeyPrefix}_${index}`}
  227. itemKey={`${categoryKeyPrefix}_${index}`}
  228. header={`${categoryData.label} (${categoryData.models.length})`}
  229. extra={
  230. <Checkbox
  231. checked={isCategoryAllSelected(categoryData.models)}
  232. indeterminate={isCategoryIndeterminate(categoryData.models)}
  233. onChange={(e) => {
  234. e.stopPropagation(); // 防止触发面板折叠
  235. handleCategorySelectAll(
  236. categoryData.models,
  237. e.target.checked,
  238. );
  239. }}
  240. onClick={(e) => e.stopPropagation()} // 防止点击checkbox时折叠面板
  241. />
  242. }
  243. >
  244. <div className='flex items-center gap-2 mb-3'>
  245. {categoryData.icon}
  246. <Typography.Text type='secondary' size='small'>
  247. {t('已选择 {{selected}} / {{total}}', {
  248. selected: categoryData.models.filter((model) =>
  249. checkedList.includes(model),
  250. ).length,
  251. total: categoryData.models.length,
  252. })}
  253. </Typography.Text>
  254. </div>
  255. <div className='grid grid-cols-2 gap-x-4'>
  256. {categoryData.models.map((model) => (
  257. <Checkbox key={model} value={model} className='my-1'>
  258. <span className='flex items-center gap-2'>
  259. <span>{model}</span>
  260. {redirectOnlySet.has(normalizeModelName(model)) && (
  261. <Tooltip
  262. position='top'
  263. content={t('来自模型重定向,尚未加入模型列表')}
  264. >
  265. <IconInfoCircle
  266. size='small'
  267. className='text-amber-500 cursor-help'
  268. />
  269. </Tooltip>
  270. )}
  271. </span>
  272. </Checkbox>
  273. ))}
  274. </div>
  275. </Collapse.Panel>
  276. ))}
  277. </Collapse>
  278. );
  279. };
  280. return (
  281. <Modal
  282. header={
  283. <div className='flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-4 py-4'>
  284. <Typography.Title heading={5} className='m-0'>
  285. {t('选择模型')}
  286. </Typography.Title>
  287. <div className='flex-shrink-0'>
  288. <Tabs
  289. type='slash'
  290. size='small'
  291. tabList={tabList}
  292. activeKey={activeTab}
  293. onChange={(key) => setActiveTab(key)}
  294. />
  295. </div>
  296. </div>
  297. }
  298. visible={visible}
  299. onOk={handleOk}
  300. onCancel={onCancel}
  301. okText={t('确定')}
  302. cancelText={t('取消')}
  303. size={isMobile ? 'full-width' : 'large'}
  304. closeOnEsc
  305. maskClosable
  306. centered
  307. >
  308. <Input
  309. prefix={<IconSearch size={14} />}
  310. placeholder={t('搜索模型')}
  311. value={keyword}
  312. onChange={(v) => setKeyword(v)}
  313. showClear
  314. />
  315. <Spin spinning={!models || models.length === 0}>
  316. <div style={{ maxHeight: 400, overflowY: 'auto', paddingRight: 8 }}>
  317. {filteredModels.length === 0 ? (
  318. <Empty
  319. image={
  320. <IllustrationNoResult style={{ width: 150, height: 150 }} />
  321. }
  322. darkModeImage={
  323. <IllustrationNoResultDark style={{ width: 150, height: 150 }} />
  324. }
  325. description={t('暂无匹配模型')}
  326. style={{ padding: 30 }}
  327. />
  328. ) : (
  329. <Checkbox.Group
  330. value={checkedList}
  331. onChange={(vals) => setCheckedList(vals)}
  332. >
  333. {activeTab === 'new' && newModels.length > 0 && (
  334. <div>{renderModelsByCategory(newModelsByCategory, 'new')}</div>
  335. )}
  336. {activeTab === 'existing' && existingModels.length > 0 && (
  337. <div>
  338. {renderModelsByCategory(existingModelsByCategory, 'existing')}
  339. </div>
  340. )}
  341. </Checkbox.Group>
  342. )}
  343. </div>
  344. </Spin>
  345. <Typography.Text
  346. type='secondary'
  347. size='small'
  348. className='block text-right mt-4'
  349. >
  350. <div className='flex items-center justify-end gap-2'>
  351. {(() => {
  352. const currentModels =
  353. activeTab === 'new' ? newModels : existingModels;
  354. const currentSelected = currentModels.filter((model) =>
  355. checkedList.includes(model),
  356. ).length;
  357. const isAllSelected =
  358. currentModels.length > 0 &&
  359. currentSelected === currentModels.length;
  360. const isIndeterminate =
  361. currentSelected > 0 && currentSelected < currentModels.length;
  362. return (
  363. <>
  364. <span>
  365. {t('已选择 {{selected}} / {{total}}', {
  366. selected: currentSelected,
  367. total: currentModels.length,
  368. })}
  369. </span>
  370. <Checkbox
  371. checked={isAllSelected}
  372. indeterminate={isIndeterminate}
  373. onChange={(e) => {
  374. handleCategorySelectAll(currentModels, e.target.checked);
  375. }}
  376. />
  377. </>
  378. );
  379. })()}
  380. </div>
  381. </Typography.Text>
  382. </Modal>
  383. );
  384. };
  385. export default ModelSelectModal;