SingleModelSelectModal.jsx 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  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, { useEffect, useMemo, useState } from 'react';
  16. import { useTranslation } from 'react-i18next';
  17. import { useIsMobile } from '../../../../hooks/common/useIsMobile';
  18. import {
  19. Collapse,
  20. Empty,
  21. Input,
  22. Modal,
  23. Radio,
  24. Typography,
  25. } from '@douyinfe/semi-ui';
  26. import {
  27. IllustrationNoResult,
  28. IllustrationNoResultDark,
  29. } from '@douyinfe/semi-illustrations';
  30. import { IconSearch } from '@douyinfe/semi-icons';
  31. import { getModelCategories } from '../../../../helpers/render';
  32. const SingleModelSelectModal = ({
  33. visible,
  34. models = [],
  35. selected = '',
  36. onConfirm,
  37. onCancel,
  38. }) => {
  39. const { t } = useTranslation();
  40. const isMobile = useIsMobile();
  41. const normalizeModelName = (model) => String(model ?? '').trim();
  42. const normalizedModels = useMemo(() => {
  43. const list = Array.isArray(models) ? models : [];
  44. return Array.from(new Set(list.map(normalizeModelName).filter(Boolean)));
  45. }, [models]);
  46. const [keyword, setKeyword] = useState('');
  47. const [selectedModel, setSelectedModel] = useState('');
  48. useEffect(() => {
  49. if (visible) {
  50. setKeyword('');
  51. setSelectedModel(normalizeModelName(selected));
  52. }
  53. }, [visible, selected]);
  54. const filteredModels = useMemo(() => {
  55. const lower = keyword.trim().toLowerCase();
  56. if (!lower) return normalizedModels;
  57. return normalizedModels.filter((m) => m.toLowerCase().includes(lower));
  58. }, [normalizedModels, keyword]);
  59. const modelsByCategory = useMemo(() => {
  60. const categories = getModelCategories(t);
  61. const categorized = {};
  62. const uncategorized = [];
  63. filteredModels.forEach((model) => {
  64. let foundCategory = false;
  65. for (const [key, category] of Object.entries(categories)) {
  66. if (key !== 'all' && category.filter({ model_name: model })) {
  67. if (!categorized[key]) {
  68. categorized[key] = {
  69. label: category.label,
  70. icon: category.icon,
  71. models: [],
  72. };
  73. }
  74. categorized[key].models.push(model);
  75. foundCategory = true;
  76. break;
  77. }
  78. }
  79. if (!foundCategory) {
  80. uncategorized.push(model);
  81. }
  82. });
  83. if (uncategorized.length > 0) {
  84. categorized.other = {
  85. label: t('其他'),
  86. icon: null,
  87. models: uncategorized,
  88. };
  89. }
  90. return categorized;
  91. }, [filteredModels, t]);
  92. const categoryEntries = useMemo(
  93. () => Object.entries(modelsByCategory),
  94. [modelsByCategory],
  95. );
  96. return (
  97. <Modal
  98. header={
  99. <div className='flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-4 py-4'>
  100. <Typography.Title heading={5} className='m-0'>
  101. {t('选择模型')}
  102. </Typography.Title>
  103. </div>
  104. }
  105. visible={visible}
  106. onOk={() => onConfirm?.(selectedModel)}
  107. onCancel={onCancel}
  108. okText={t('确定')}
  109. cancelText={t('取消')}
  110. okButtonProps={{ disabled: !selectedModel }}
  111. size={isMobile ? 'full-width' : 'large'}
  112. closeOnEsc
  113. maskClosable
  114. centered
  115. >
  116. <Input
  117. prefix={<IconSearch size={14} />}
  118. placeholder={t('搜索模型')}
  119. value={keyword}
  120. onChange={(v) => setKeyword(v)}
  121. showClear
  122. />
  123. <div style={{ maxHeight: 400, overflowY: 'auto', paddingRight: 8 }}>
  124. {filteredModels.length === 0 ? (
  125. <Empty
  126. image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
  127. darkModeImage={
  128. <IllustrationNoResultDark style={{ width: 150, height: 150 }} />
  129. }
  130. description={t('暂无匹配模型')}
  131. style={{ padding: 30 }}
  132. />
  133. ) : (
  134. <Radio.Group
  135. className='w-full'
  136. style={{ width: '100%' }}
  137. value={selectedModel}
  138. onChange={(val) => {
  139. const next = val && val.target ? val.target.value : val;
  140. setSelectedModel(next);
  141. }}
  142. >
  143. <Collapse
  144. className='w-full'
  145. style={{ width: '100%' }}
  146. defaultActiveKey={[]}
  147. >
  148. {categoryEntries.map(([key, categoryData], index) => (
  149. <Collapse.Panel
  150. key={`${key}_${index}`}
  151. itemKey={`${key}_${index}`}
  152. header={
  153. <span className='flex items-center gap-2'>
  154. {categoryData.icon}
  155. <span>
  156. {categoryData.label} ({categoryData.models.length})
  157. </span>
  158. </span>
  159. }
  160. >
  161. <div className='grid grid-cols-2 gap-x-4'>
  162. {categoryData.models.map((model) => (
  163. <Radio key={model} value={model} className='my-1'>
  164. {model}
  165. </Radio>
  166. ))}
  167. </div>
  168. </Collapse.Panel>
  169. ))}
  170. </Collapse>
  171. </Radio.Group>
  172. )}
  173. </div>
  174. </Modal>
  175. );
  176. };
  177. export default SingleModelSelectModal;