2025-08-18 04:14:35 +08:00
|
|
|
|
/*
|
|
|
|
|
|
Copyright (C) 2025 QuantumNous
|
|
|
|
|
|
|
|
|
|
|
|
This program is free software: you can redistribute it and/or modify
|
|
|
|
|
|
it under the terms of the GNU Affero General Public License as
|
|
|
|
|
|
published by the Free Software Foundation, either version 3 of the
|
|
|
|
|
|
License, or (at your option) any later version.
|
|
|
|
|
|
|
|
|
|
|
|
This program is distributed in the hope that it will be useful,
|
|
|
|
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
|
|
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
|
|
|
|
GNU Affero General Public License for more details.
|
|
|
|
|
|
|
|
|
|
|
|
You should have received a copy of the GNU Affero General Public License
|
|
|
|
|
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
|
|
|
|
|
|
|
|
For commercial licensing, please contact support@quantumnous.com
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
2025-07-28 01:33:23 +08:00
|
|
|
|
import React, { useState, useEffect } from 'react';
|
2025-08-18 04:14:35 +08:00
|
|
|
|
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
2025-08-30 21:15:10 +08:00
|
|
|
|
import {
|
|
|
|
|
|
Modal,
|
|
|
|
|
|
Checkbox,
|
|
|
|
|
|
Spin,
|
|
|
|
|
|
Input,
|
|
|
|
|
|
Typography,
|
|
|
|
|
|
Empty,
|
|
|
|
|
|
Tabs,
|
|
|
|
|
|
Collapse,
|
|
|
|
|
|
} from '@douyinfe/semi-ui';
|
2025-07-28 01:33:23 +08:00
|
|
|
|
import {
|
|
|
|
|
|
IllustrationNoResult,
|
2025-08-30 21:15:10 +08:00
|
|
|
|
IllustrationNoResultDark,
|
2025-07-28 01:33:23 +08:00
|
|
|
|
} from '@douyinfe/semi-illustrations';
|
|
|
|
|
|
import { IconSearch } from '@douyinfe/semi-icons';
|
|
|
|
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
|
|
import { getModelCategories } from '../../../../helpers/render';
|
|
|
|
|
|
|
2025-08-30 21:15:10 +08:00
|
|
|
|
const ModelSelectModal = ({
|
|
|
|
|
|
visible,
|
|
|
|
|
|
models = [],
|
|
|
|
|
|
selected = [],
|
|
|
|
|
|
onConfirm,
|
|
|
|
|
|
onCancel,
|
|
|
|
|
|
}) => {
|
2025-07-28 01:33:23 +08:00
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
|
const [checkedList, setCheckedList] = useState(selected);
|
|
|
|
|
|
const [keyword, setKeyword] = useState('');
|
|
|
|
|
|
const [activeTab, setActiveTab] = useState('new');
|
|
|
|
|
|
|
|
|
|
|
|
const isMobile = useIsMobile();
|
|
|
|
|
|
|
2025-08-30 21:15:10 +08:00
|
|
|
|
const filteredModels = models.filter((m) =>
|
|
|
|
|
|
m.toLowerCase().includes(keyword.toLowerCase()),
|
|
|
|
|
|
);
|
2025-07-28 01:33:23 +08:00
|
|
|
|
|
|
|
|
|
|
// 分类模型:新获取的模型和已有模型
|
2025-08-30 21:15:10 +08:00
|
|
|
|
const newModels = filteredModels.filter((model) => !selected.includes(model));
|
|
|
|
|
|
const existingModels = filteredModels.filter((model) =>
|
|
|
|
|
|
selected.includes(model),
|
|
|
|
|
|
);
|
2025-07-28 01:33:23 +08:00
|
|
|
|
|
|
|
|
|
|
// 同步外部选中值
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (visible) {
|
|
|
|
|
|
setCheckedList(selected);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [visible, selected]);
|
|
|
|
|
|
|
|
|
|
|
|
// 当模型列表变化时,设置默认tab
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (visible) {
|
|
|
|
|
|
// 默认显示新获取模型tab,如果没有新模型则显示已有模型
|
|
|
|
|
|
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 = [];
|
|
|
|
|
|
|
2025-08-30 21:15:10 +08:00
|
|
|
|
models.forEach((model) => {
|
2025-07-28 01:33:23 +08:00
|
|
|
|
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,
|
2025-08-30 21:15:10 +08:00
|
|
|
|
models: [],
|
2025-07-28 01:33:23 +08:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
categorizedModels[key].models.push(model);
|
|
|
|
|
|
foundCategory = true;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!foundCategory) {
|
|
|
|
|
|
uncategorizedModels.push(model);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 如果有未分类模型,添加到"其他"分类
|
|
|
|
|
|
if (uncategorizedModels.length > 0) {
|
|
|
|
|
|
categorizedModels['other'] = {
|
|
|
|
|
|
label: t('其他'),
|
|
|
|
|
|
icon: null,
|
2025-08-30 21:15:10 +08:00
|
|
|
|
models: uncategorizedModels,
|
2025-07-28 01:33:23 +08:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return categorizedModels;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const newModelsByCategory = categorizeModels(newModels);
|
|
|
|
|
|
const existingModelsByCategory = categorizeModels(existingModels);
|
|
|
|
|
|
|
|
|
|
|
|
// Tab列表配置
|
|
|
|
|
|
const tabList = [
|
2025-08-30 21:15:10 +08:00
|
|
|
|
...(newModels.length > 0
|
|
|
|
|
|
? [
|
|
|
|
|
|
{
|
|
|
|
|
|
tab: `${t('新获取的模型')} (${newModels.length})`,
|
|
|
|
|
|
itemKey: 'new',
|
|
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
: []),
|
|
|
|
|
|
...(existingModels.length > 0
|
|
|
|
|
|
? [
|
|
|
|
|
|
{
|
|
|
|
|
|
tab: `${t('已有的模型')} (${existingModels.length})`,
|
|
|
|
|
|
itemKey: 'existing',
|
|
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
: []),
|
2025-07-28 01:33:23 +08:00
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
// 处理分类全选/取消全选
|
|
|
|
|
|
const handleCategorySelectAll = (categoryModels, isChecked) => {
|
|
|
|
|
|
let newCheckedList = [...checkedList];
|
|
|
|
|
|
|
|
|
|
|
|
if (isChecked) {
|
|
|
|
|
|
// 全选:添加该分类下所有未选中的模型
|
2025-08-30 21:15:10 +08:00
|
|
|
|
categoryModels.forEach((model) => {
|
2025-07-28 01:33:23 +08:00
|
|
|
|
if (!newCheckedList.includes(model)) {
|
|
|
|
|
|
newCheckedList.push(model);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 取消全选:移除该分类下所有已选中的模型
|
2025-08-30 21:15:10 +08:00
|
|
|
|
newCheckedList = newCheckedList.filter(
|
|
|
|
|
|
(model) => !categoryModels.includes(model),
|
|
|
|
|
|
);
|
2025-07-28 01:33:23 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setCheckedList(newCheckedList);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 检查分类是否全选
|
|
|
|
|
|
const isCategoryAllSelected = (categoryModels) => {
|
2025-08-30 21:15:10 +08:00
|
|
|
|
return (
|
|
|
|
|
|
categoryModels.length > 0 &&
|
|
|
|
|
|
categoryModels.every((model) => checkedList.includes(model))
|
|
|
|
|
|
);
|
2025-07-28 01:33:23 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 检查分类是否部分选中
|
|
|
|
|
|
const isCategoryIndeterminate = (categoryModels) => {
|
2025-08-30 21:15:10 +08:00
|
|
|
|
const selectedCount = categoryModels.filter((model) =>
|
|
|
|
|
|
checkedList.includes(model),
|
|
|
|
|
|
).length;
|
2025-07-28 01:33:23 +08:00
|
|
|
|
return selectedCount > 0 && selectedCount < categoryModels.length;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const renderModelsByCategory = (modelsByCategory, categoryKeyPrefix) => {
|
|
|
|
|
|
const categoryEntries = Object.entries(modelsByCategory);
|
|
|
|
|
|
if (categoryEntries.length === 0) return null;
|
|
|
|
|
|
|
|
|
|
|
|
// 生成所有面板的key,确保都展开
|
2025-08-30 21:15:10 +08:00
|
|
|
|
const allActiveKeys = categoryEntries.map(
|
|
|
|
|
|
(_, index) => `${categoryKeyPrefix}_${index}`,
|
|
|
|
|
|
);
|
2025-07-28 01:33:23 +08:00
|
|
|
|
|
|
|
|
|
|
return (
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<Collapse
|
|
|
|
|
|
key={`${categoryKeyPrefix}_${categoryEntries.length}`}
|
|
|
|
|
|
defaultActiveKey={[]}
|
|
|
|
|
|
>
|
2025-07-28 01:33:23 +08:00
|
|
|
|
{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(); // 防止触发面板折叠
|
2025-08-30 21:15:10 +08:00
|
|
|
|
handleCategorySelectAll(
|
|
|
|
|
|
categoryData.models,
|
|
|
|
|
|
e.target.checked,
|
|
|
|
|
|
);
|
2025-07-28 01:33:23 +08:00
|
|
|
|
}}
|
|
|
|
|
|
onClick={(e) => e.stopPropagation()} // 防止点击checkbox时折叠面板
|
|
|
|
|
|
/>
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<div className='flex items-center gap-2 mb-3'>
|
2025-07-28 01:33:23 +08:00
|
|
|
|
{categoryData.icon}
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<Typography.Text type='secondary' size='small'>
|
2025-07-28 01:33:23 +08:00
|
|
|
|
{t('已选择 {{selected}} / {{total}}', {
|
2025-08-30 21:15:10 +08:00
|
|
|
|
selected: categoryData.models.filter((model) =>
|
|
|
|
|
|
checkedList.includes(model),
|
|
|
|
|
|
).length,
|
|
|
|
|
|
total: categoryData.models.length,
|
2025-07-28 01:33:23 +08:00
|
|
|
|
})}
|
|
|
|
|
|
</Typography.Text>
|
|
|
|
|
|
</div>
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<div className='grid grid-cols-2 gap-x-4'>
|
2025-07-28 01:33:23 +08:00
|
|
|
|
{categoryData.models.map((model) => (
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<Checkbox key={model} value={model} className='my-1'>
|
2025-07-28 01:33:23 +08:00
|
|
|
|
{model}
|
|
|
|
|
|
</Checkbox>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Collapse.Panel>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</Collapse>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Modal
|
|
|
|
|
|
header={
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<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'>
|
2025-07-28 01:33:23 +08:00
|
|
|
|
{t('选择模型')}
|
|
|
|
|
|
</Typography.Title>
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<div className='flex-shrink-0'>
|
2025-07-28 01:33:23 +08:00
|
|
|
|
<Tabs
|
2025-08-30 21:15:10 +08:00
|
|
|
|
type='slash'
|
|
|
|
|
|
size='small'
|
2025-07-28 01:33:23 +08:00
|
|
|
|
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
|
2025-08-30 21:15:10 +08:00
|
|
|
|
image={
|
|
|
|
|
|
<IllustrationNoResult style={{ width: 150, height: 150 }} />
|
|
|
|
|
|
}
|
|
|
|
|
|
darkModeImage={
|
|
|
|
|
|
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
|
|
|
|
|
|
}
|
2025-07-28 01:33:23 +08:00
|
|
|
|
description={t('暂无匹配模型')}
|
|
|
|
|
|
style={{ padding: 30 }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<Checkbox.Group
|
|
|
|
|
|
value={checkedList}
|
|
|
|
|
|
onChange={(vals) => setCheckedList(vals)}
|
|
|
|
|
|
>
|
2025-07-28 01:33:23 +08:00
|
|
|
|
{activeTab === 'new' && newModels.length > 0 && (
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<div>{renderModelsByCategory(newModelsByCategory, 'new')}</div>
|
2025-07-28 01:33:23 +08:00
|
|
|
|
)}
|
|
|
|
|
|
{activeTab === 'existing' && existingModels.length > 0 && (
|
|
|
|
|
|
<div>
|
|
|
|
|
|
{renderModelsByCategory(existingModelsByCategory, 'existing')}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</Checkbox.Group>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Spin>
|
|
|
|
|
|
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<Typography.Text
|
|
|
|
|
|
type='secondary'
|
|
|
|
|
|
size='small'
|
|
|
|
|
|
className='block text-right mt-4'
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className='flex items-center justify-end gap-2'>
|
2025-07-28 01:33:23 +08:00
|
|
|
|
{(() => {
|
2025-08-30 21:15:10 +08:00
|
|
|
|
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;
|
2025-07-28 01:33:23 +08:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<span>
|
|
|
|
|
|
{t('已选择 {{selected}} / {{total}}', {
|
|
|
|
|
|
selected: currentSelected,
|
2025-08-30 21:15:10 +08:00
|
|
|
|
total: currentModels.length,
|
2025-07-28 01:33:23 +08:00
|
|
|
|
})}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<Checkbox
|
|
|
|
|
|
checked={isAllSelected}
|
|
|
|
|
|
indeterminate={isIndeterminate}
|
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
|
handleCategorySelectAll(currentModels, e.target.checked);
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
})()}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Typography.Text>
|
|
|
|
|
|
</Modal>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-08-30 21:15:10 +08:00
|
|
|
|
export default ModelSelectModal;
|