newtest / web /src /components /table /channels /modals /SingleModelSelectModal.jsx
xwwww's picture
Upload 888 files
305487b verified
/*
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
*/
import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
import {
Collapse,
Empty,
Input,
Modal,
Radio,
Typography,
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
IllustrationNoResultDark,
} from '@douyinfe/semi-illustrations';
import { IconSearch } from '@douyinfe/semi-icons';
import { getModelCategories } from '../../../../helpers/render';
const SingleModelSelectModal = ({
visible,
models = [],
selected = '',
onConfirm,
onCancel,
}) => {
const { t } = useTranslation();
const isMobile = useIsMobile();
const normalizeModelName = (model) => String(model ?? '').trim();
const normalizedModels = useMemo(() => {
const list = Array.isArray(models) ? models : [];
return Array.from(new Set(list.map(normalizeModelName).filter(Boolean)));
}, [models]);
const [keyword, setKeyword] = useState('');
const [selectedModel, setSelectedModel] = useState('');
useEffect(() => {
if (visible) {
setKeyword('');
setSelectedModel(normalizeModelName(selected));
}
}, [visible, selected]);
const filteredModels = useMemo(() => {
const lower = keyword.trim().toLowerCase();
if (!lower) return normalizedModels;
return normalizedModels.filter((m) => m.toLowerCase().includes(lower));
}, [normalizedModels, keyword]);
const modelsByCategory = useMemo(() => {
const categories = getModelCategories(t);
const categorized = {};
const uncategorized = [];
filteredModels.forEach((model) => {
let foundCategory = false;
for (const [key, category] of Object.entries(categories)) {
if (key !== 'all' && category.filter({ model_name: model })) {
if (!categorized[key]) {
categorized[key] = {
label: category.label,
icon: category.icon,
models: [],
};
}
categorized[key].models.push(model);
foundCategory = true;
break;
}
}
if (!foundCategory) {
uncategorized.push(model);
}
});
if (uncategorized.length > 0) {
categorized.other = {
label: t('其他'),
icon: null,
models: uncategorized,
};
}
return categorized;
}, [filteredModels, t]);
const categoryEntries = useMemo(
() => Object.entries(modelsByCategory),
[modelsByCategory],
);
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>
}
visible={visible}
onOk={() => onConfirm?.(selectedModel)}
onCancel={onCancel}
okText={t('确定')}
cancelText={t('取消')}
okButtonProps={{ disabled: !selectedModel }}
size={isMobile ? 'full-width' : 'large'}
closeOnEsc
maskClosable
centered
>
<Input
prefix={<IconSearch size={14} />}
placeholder={t('搜索模型')}
value={keyword}
onChange={(v) => setKeyword(v)}
showClear
/>
<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 }}
/>
) : (
<Radio.Group
className='w-full'
style={{ width: '100%' }}
value={selectedModel}
onChange={(val) => {
const next = val && val.target ? val.target.value : val;
setSelectedModel(next);
}}
>
<Collapse
className='w-full'
style={{ width: '100%' }}
defaultActiveKey={[]}
>
{categoryEntries.map(([key, categoryData], index) => (
<Collapse.Panel
key={`${key}_${index}`}
itemKey={`${key}_${index}`}
header={
<span className='flex items-center gap-2'>
{categoryData.icon}
<span>
{categoryData.label} ({categoryData.models.length})
</span>
</span>
}
>
<div className='grid grid-cols-2 gap-x-4'>
{categoryData.models.map((model) => (
<Radio key={model} value={model} className='my-1'>
{model}
</Radio>
))}
</div>
</Collapse.Panel>
))}
</Collapse>
</Radio.Group>
)}
</div>
</Modal>
);
};
export default SingleModelSelectModal;