ModelSelectModal.jsx 11 KB

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