| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| | import React, { useState, useEffect, useMemo } from 'react'; |
| | import { useIsMobile } from '../../../../hooks/common/useIsMobile'; |
| | import { |
| | Modal, |
| | Checkbox, |
| | Spin, |
| | Input, |
| | Typography, |
| | Empty, |
| | Tabs, |
| | Collapse, |
| | Tooltip, |
| | } from '@douyinfe/semi-ui'; |
| | import { |
| | IllustrationNoResult, |
| | IllustrationNoResultDark, |
| | } from '@douyinfe/semi-illustrations'; |
| | import { IconSearch, IconInfoCircle } from '@douyinfe/semi-icons'; |
| | import { useTranslation } from 'react-i18next'; |
| | import { getModelCategories } from '../../../../helpers/render'; |
| |
|
| | const ModelSelectModal = ({ |
| | visible, |
| | models = [], |
| | selected = [], |
| | redirectModels = [], |
| | onConfirm, |
| | onCancel, |
| | }) => { |
| | const { t } = useTranslation(); |
| | const [checkedList, setCheckedList] = useState(selected); |
| | const [keyword, setKeyword] = useState(''); |
| | const [activeTab, setActiveTab] = useState('new'); |
| |
|
| | const isMobile = useIsMobile(); |
| | const normalizeModelName = (model) => |
| | typeof model === 'string' ? model.trim() : ''; |
| | const normalizedRedirectModels = useMemo( |
| | () => |
| | Array.from( |
| | new Set( |
| | (redirectModels || []) |
| | .map((model) => normalizeModelName(model)) |
| | .filter(Boolean), |
| | ), |
| | ), |
| | [redirectModels], |
| | ); |
| | const normalizedSelectedSet = useMemo(() => { |
| | const set = new Set(); |
| | (selected || []).forEach((model) => { |
| | const normalized = normalizeModelName(model); |
| | if (normalized) { |
| | set.add(normalized); |
| | } |
| | }); |
| | return set; |
| | }, [selected]); |
| | const classificationSet = useMemo(() => { |
| | const set = new Set(normalizedSelectedSet); |
| | normalizedRedirectModels.forEach((model) => set.add(model)); |
| | return set; |
| | }, [normalizedSelectedSet, normalizedRedirectModels]); |
| | const redirectOnlySet = useMemo(() => { |
| | const set = new Set(); |
| | normalizedRedirectModels.forEach((model) => { |
| | if (!normalizedSelectedSet.has(model)) { |
| | set.add(model); |
| | } |
| | }); |
| | return set; |
| | }, [normalizedRedirectModels, normalizedSelectedSet]); |
| |
|
| | const filteredModels = models.filter((m) => |
| | String(m || '').toLowerCase().includes(keyword.toLowerCase()), |
| | ); |
| |
|
| | |
| | const isExistingModel = (model) => |
| | classificationSet.has(normalizeModelName(model)); |
| | const newModels = filteredModels.filter((model) => !isExistingModel(model)); |
| | const existingModels = filteredModels.filter((model) => |
| | isExistingModel(model), |
| | ); |
| |
|
| | |
| | useEffect(() => { |
| | if (visible) { |
| | setCheckedList(selected); |
| | } |
| | }, [visible, selected]); |
| |
|
| | |
| | useEffect(() => { |
| | if (visible) { |
| | |
| | const hasNewModels = newModels.length > 0; |
| | setActiveTab(hasNewModels ? 'new' : 'existing'); |
| | } |
| | }, [visible, newModels.length, selected]); |
| |
|
| | const handleOk = () => { |
| | onConfirm && onConfirm(checkedList); |
| | }; |
| |
|
| | |
| | const categorizeModels = (models) => { |
| | const categories = getModelCategories(t); |
| | const categorizedModels = {}; |
| | const uncategorizedModels = []; |
| |
|
| | models.forEach((model) => { |
| | let foundCategory = false; |
| | for (const [key, category] of Object.entries(categories)) { |
| | if (key !== 'all' && category.filter({ model_name: model })) { |
| | if (!categorizedModels[key]) { |
| | categorizedModels[key] = { |
| | label: category.label, |
| | icon: category.icon, |
| | models: [], |
| | }; |
| | } |
| | categorizedModels[key].models.push(model); |
| | foundCategory = true; |
| | break; |
| | } |
| | } |
| | if (!foundCategory) { |
| | uncategorizedModels.push(model); |
| | } |
| | }); |
| |
|
| | |
| | if (uncategorizedModels.length > 0) { |
| | categorizedModels['other'] = { |
| | label: t('其他'), |
| | icon: null, |
| | models: uncategorizedModels, |
| | }; |
| | } |
| |
|
| | return categorizedModels; |
| | }; |
| |
|
| | const newModelsByCategory = categorizeModels(newModels); |
| | const existingModelsByCategory = categorizeModels(existingModels); |
| |
|
| | |
| | const tabList = [ |
| | ...(newModels.length > 0 |
| | ? [ |
| | { |
| | tab: `${t('新获取的模型')} (${newModels.length})`, |
| | itemKey: 'new', |
| | }, |
| | ] |
| | : []), |
| | ...(existingModels.length > 0 |
| | ? [ |
| | { |
| | tab: `${t('已有的模型')} (${existingModels.length})`, |
| | itemKey: 'existing', |
| | }, |
| | ] |
| | : []), |
| | ]; |
| |
|
| | |
| | const handleCategorySelectAll = (categoryModels, isChecked) => { |
| | let newCheckedList = [...checkedList]; |
| |
|
| | if (isChecked) { |
| | |
| | categoryModels.forEach((model) => { |
| | if (!newCheckedList.includes(model)) { |
| | newCheckedList.push(model); |
| | } |
| | }); |
| | } else { |
| | |
| | newCheckedList = newCheckedList.filter( |
| | (model) => !categoryModels.includes(model), |
| | ); |
| | } |
| |
|
| | setCheckedList(newCheckedList); |
| | }; |
| |
|
| | |
| | const isCategoryAllSelected = (categoryModels) => { |
| | return ( |
| | categoryModels.length > 0 && |
| | categoryModels.every((model) => checkedList.includes(model)) |
| | ); |
| | }; |
| |
|
| | |
| | const isCategoryIndeterminate = (categoryModels) => { |
| | const selectedCount = categoryModels.filter((model) => |
| | checkedList.includes(model), |
| | ).length; |
| | return selectedCount > 0 && selectedCount < categoryModels.length; |
| | }; |
| |
|
| | const renderModelsByCategory = (modelsByCategory, categoryKeyPrefix) => { |
| | const categoryEntries = Object.entries(modelsByCategory); |
| | if (categoryEntries.length === 0) return null; |
| |
|
| | |
| | const allActiveKeys = categoryEntries.map( |
| | (_, index) => `${categoryKeyPrefix}_${index}`, |
| | ); |
| |
|
| | return ( |
| | <Collapse |
| | key={`${categoryKeyPrefix}_${categoryEntries.length}`} |
| | defaultActiveKey={[]} |
| | > |
| | {categoryEntries.map(([key, categoryData], index) => ( |
| | <Collapse.Panel |
| | key={`${categoryKeyPrefix}_${index}`} |
| | itemKey={`${categoryKeyPrefix}_${index}`} |
| | header={`${categoryData.label} (${categoryData.models.length})`} |
| | extra={ |
| | <Checkbox |
| | checked={isCategoryAllSelected(categoryData.models)} |
| | indeterminate={isCategoryIndeterminate(categoryData.models)} |
| | onChange={(e) => { |
| | e.stopPropagation(); // 防止触发面板折叠 |
| | handleCategorySelectAll( |
| | categoryData.models, |
| | e.target.checked, |
| | ); |
| | }} |
| | onClick={(e) => e.stopPropagation()} // 防止点击checkbox时折叠面板 |
| | /> |
| | } |
| | > |
| | <div className='flex items-center gap-2 mb-3'> |
| | {categoryData.icon} |
| | <Typography.Text type='secondary' size='small'> |
| | {t('已选择 {{selected}} / {{total}}', { |
| | selected: categoryData.models.filter((model) => |
| | checkedList.includes(model), |
| | ).length, |
| | total: categoryData.models.length, |
| | })} |
| | </Typography.Text> |
| | </div> |
| | <div className='grid grid-cols-2 gap-x-4'> |
| | {categoryData.models.map((model) => ( |
| | <Checkbox key={model} value={model} className='my-1'> |
| | <span className='flex items-center gap-2'> |
| | <span>{model}</span> |
| | {redirectOnlySet.has(normalizeModelName(model)) && ( |
| | <Tooltip |
| | position='top' |
| | content={t('来自模型重定向,尚未加入模型列表')} |
| | > |
| | <IconInfoCircle |
| | size='small' |
| | className='text-amber-500 cursor-help' |
| | /> |
| | </Tooltip> |
| | )} |
| | </span> |
| | </Checkbox> |
| | ))} |
| | </div> |
| | </Collapse.Panel> |
| | ))} |
| | </Collapse> |
| | ); |
| | }; |
| |
|
| | return ( |
| | <Modal |
| | header={ |
| | <div className='flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-4 py-4'> |
| | <Typography.Title heading={5} className='m-0'> |
| | {t('选择模型')} |
| | </Typography.Title> |
| | <div className='flex-shrink-0'> |
| | <Tabs |
| | type='slash' |
| | size='small' |
| | tabList={tabList} |
| | activeKey={activeTab} |
| | onChange={(key) => setActiveTab(key)} |
| | /> |
| | </div> |
| | </div> |
| | } |
| | visible={visible} |
| | onOk={handleOk} |
| | onCancel={onCancel} |
| | okText={t('确定')} |
| | cancelText={t('取消')} |
| | size={isMobile ? 'full-width' : 'large'} |
| | closeOnEsc |
| | maskClosable |
| | centered |
| | > |
| | <Input |
| | prefix={<IconSearch size={14} />} |
| | placeholder={t('搜索模型')} |
| | value={keyword} |
| | onChange={(v) => setKeyword(v)} |
| | showClear |
| | /> |
| | |
| | <Spin spinning={!models || models.length === 0}> |
| | <div style={{ maxHeight: 400, overflowY: 'auto', paddingRight: 8 }}> |
| | {filteredModels.length === 0 ? ( |
| | <Empty |
| | image={ |
| | <IllustrationNoResult style={{ width: 150, height: 150 }} /> |
| | } |
| | darkModeImage={ |
| | <IllustrationNoResultDark style={{ width: 150, height: 150 }} /> |
| | } |
| | description={t('暂无匹配模型')} |
| | style={{ padding: 30 }} |
| | /> |
| | ) : ( |
| | <Checkbox.Group |
| | value={checkedList} |
| | onChange={(vals) => setCheckedList(vals)} |
| | > |
| | {activeTab === 'new' && newModels.length > 0 && ( |
| | <div>{renderModelsByCategory(newModelsByCategory, 'new')}</div> |
| | )} |
| | {activeTab === 'existing' && existingModels.length > 0 && ( |
| | <div> |
| | {renderModelsByCategory(existingModelsByCategory, 'existing')} |
| | </div> |
| | )} |
| | </Checkbox.Group> |
| | )} |
| | </div> |
| | </Spin> |
| | |
| | <Typography.Text |
| | type='secondary' |
| | size='small' |
| | className='block text-right mt-4' |
| | > |
| | <div className='flex items-center justify-end gap-2'> |
| | {(() => { |
| | const currentModels = |
| | activeTab === 'new' ? newModels : existingModels; |
| | const currentSelected = currentModels.filter((model) => |
| | checkedList.includes(model), |
| | ).length; |
| | const isAllSelected = |
| | currentModels.length > 0 && |
| | currentSelected === currentModels.length; |
| | const isIndeterminate = |
| | currentSelected > 0 && currentSelected < currentModels.length; |
| | |
| | return ( |
| | <> |
| | <span> |
| | {t('已选择 {{selected}} / {{total}}', { |
| | selected: currentSelected, |
| | total: currentModels.length, |
| | })} |
| | </span> |
| | <Checkbox |
| | checked={isAllSelected} |
| | indeterminate={isIndeterminate} |
| | onChange={(e) => { |
| | handleCategorySelectAll(currentModels, e.target.checked); |
| | }} |
| | /> |
| | </> |
| | ); |
| | })()} |
| | </div> |
| | </Typography.Text> |
| | </Modal> |
| | ); |
| | }; |
| |
|
| | export default ModelSelectModal; |
| |
|