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