| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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; |
|
|