feat: select model in visualized modl mapping

This commit is contained in:
Bliod
2026-01-08 15:26:59 +00:00
parent ad61c0f89e
commit d4edec6ac2
3 changed files with 667 additions and 335 deletions

View File

@@ -60,6 +60,7 @@ const JSONEditor = ({
editorType = 'keyValue',
rules = [],
formApi = null,
renderStringValueSuffix,
...props
}) => {
const { t } = useTranslation();
@@ -335,7 +336,7 @@ const JSONEditor = ({
]);
// 渲染值输入控件(支持嵌套)
const renderValueInput = (pairId, value) => {
const renderValueInput = (pairId, pairKey, value) => {
const valueType = typeof value;
if (valueType === 'boolean') {
@@ -387,6 +388,7 @@ const JSONEditor = ({
<Input
placeholder={t('参数值')}
value={String(value)}
suffix={renderStringValueSuffix?.({ pairId, pairKey, value })}
onChange={(newValue) => {
let convertedValue = newValue;
if (newValue === 'true') convertedValue = true;
@@ -470,7 +472,9 @@ const JSONEditor = ({
)}
</div>
</Col>
<Col span={12}>{renderValueInput(pair.id, pair.value)}</Col>
<Col span={12}>
{renderValueInput(pair.id, pair.key, pair.value)}
</Col>
<Col span={2}>
<Button
icon={<IconDelete />}

View File

@@ -46,6 +46,7 @@ import {
Col,
Highlight,
Input,
Tooltip,
} from '@douyinfe/semi-ui';
import {
getChannelModels,
@@ -55,6 +56,7 @@ import {
selectFilter,
} from '../../../../helpers';
import ModelSelectModal from './ModelSelectModal';
import SingleModelSelectModal from './SingleModelSelectModal';
import OllamaModelModal from './OllamaModelModal';
import JSONEditor from '../../../common/ui/JSONEditor';
import SecureVerificationModal from '../../../common/modals/SecureVerificationModal';
@@ -69,6 +71,7 @@ import {
IconCode,
IconGlobe,
IconBolt,
IconSearch,
IconChevronUp,
IconChevronDown,
} from '@douyinfe/semi-icons';
@@ -109,7 +112,7 @@ function type2secretPrompt(type) {
case 33:
return '按照如下格式输入Ak|Sk|Region';
case 45:
return '请输入渠道对应的鉴权密钥, 豆包语音输入AppId|AccessToken';
return '请输入渠道对应的鉴权密钥, 豆包语音输入AppId|AccessToken';
case 50:
return '按照如下格式输入: AccessKey|SecretKey, 如果上游是New API则直接输ApiKey';
case 51:
@@ -181,6 +184,13 @@ const EditChannelModal = (props) => {
const [isModalOpenurl, setIsModalOpenurl] = useState(false);
const [modelModalVisible, setModelModalVisible] = useState(false);
const [fetchedModels, setFetchedModels] = useState([]);
const [modelMappingValueModalVisible, setModelMappingValueModalVisible] =
useState(false);
const [modelMappingValueModalModels, setModelMappingValueModalModels] =
useState([]);
const [modelMappingValueKey, setModelMappingValueKey] = useState('');
const [modelMappingValueSelected, setModelMappingValueSelected] =
useState('');
const [ollamaModalVisible, setOllamaModalVisible] = useState(false);
const formApiRef = useRef(null);
const [vertexKeys, setVertexKeys] = useState([]);
@@ -199,17 +209,11 @@ const EditChannelModal = (props) => {
if (!trimmed) return [];
try {
const parsed = JSON.parse(trimmed);
if (
!parsed ||
typeof parsed !== 'object' ||
Array.isArray(parsed)
) {
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return [];
}
const values = Object.values(parsed)
.map((value) =>
typeof value === 'string' ? value.trim() : undefined,
)
.map((value) => (typeof value === 'string' ? value.trim() : undefined))
.filter((value) => value);
return Array.from(new Set(values));
} catch (error) {
@@ -426,7 +430,11 @@ const EditChannelModal = (props) => {
const isIonetLocked = isIonetChannel && isEdit;
const handleInputChange = (name, value) => {
if (isIonetChannel && isEdit && ['type', 'key', 'base_url'].includes(name)) {
if (
isIonetChannel &&
isEdit &&
['type', 'key', 'base_url'].includes(name)
) {
return;
}
if (formApiRef.current) {
@@ -730,10 +738,49 @@ const EditChannelModal = (props) => {
if (!silent) {
setModelModalVisible(true);
}
setLoading(false);
return uniqueModels;
} else {
showError(t('获取模型列表失败'));
}
setLoading(false);
return null;
};
const openModelMappingValueModal = async ({ pairKey, value }) => {
const mappingKey = String(pairKey ?? '').trim();
if (!mappingKey) return;
if (!MODEL_FETCHABLE_TYPES.has(inputs.type)) {
return;
}
let modelsToUse = fetchedModels;
if (!Array.isArray(modelsToUse) || modelsToUse.length === 0) {
const fetched = await fetchUpstreamModelList('models', { silent: true });
if (Array.isArray(fetched)) {
modelsToUse = fetched;
}
}
if (!Array.isArray(modelsToUse) || modelsToUse.length === 0) {
showInfo(t('暂无模型'));
return;
}
const normalizedModelsToUse = Array.from(
new Set(
modelsToUse.map((model) => String(model ?? '').trim()).filter(Boolean),
),
);
const currentValue = String(value ?? '').trim();
setModelMappingValueModalModels(normalizedModelsToUse);
setModelMappingValueKey(mappingKey);
setModelMappingValueSelected(
normalizedModelsToUse.includes(currentValue) ? currentValue : '',
);
setModelMappingValueModalVisible(true);
};
const fetchModels = async () => {
@@ -1673,43 +1720,45 @@ const EditChannelModal = (props) => {
</div>
{isIonetChannel && (
<Banner
type='info'
closeIcon={null}
className='mb-4 rounded-xl'
description={t('此渠道由 IO.NET 自动同步,类型、密钥和 API 地址已锁定。')}
>
<Space>
{ionetMetadata?.deployment_id && (
<Button
size='small'
theme='light'
type='primary'
icon={<IconGlobe />}
onClick={handleOpenIonetDeployment}
>
{t('查看关联部署')}
</Button>
<Banner
type='info'
closeIcon={null}
className='mb-4 rounded-xl'
description={t(
'此渠道由 IO.NET 自动同步,类型、密钥和 API 地址已锁定。',
)}
</Space>
</Banner>
)}
>
<Space>
{ionetMetadata?.deployment_id && (
<Button
size='small'
theme='light'
type='primary'
icon={<IconGlobe />}
onClick={handleOpenIonetDeployment}
>
{t('查看关联部署')}
</Button>
)}
</Space>
</Banner>
)}
<Form.Select
field='type'
label={t('类型')}
placeholder={t('请选择渠道类型')}
rules={[{ required: true, message: t('请选择渠道类型') }]}
optionList={channelOptionList}
style={{ width: '100%' }}
filter={selectFilter}
autoClearSearchValue={false}
searchPosition='dropdown'
onSearch={(value) => setChannelSearchValue(value)}
renderOptionItem={renderChannelOption}
onChange={(value) => handleInputChange('type', value)}
disabled={isIonetLocked}
/>
<Form.Select
field='type'
label={t('类型')}
placeholder={t('请选择渠道类型')}
rules={[{ required: true, message: t('请选择渠道类型') }]}
optionList={channelOptionList}
style={{ width: '100%' }}
filter={selectFilter}
autoClearSearchValue={false}
searchPosition='dropdown'
onSearch={(value) => setChannelSearchValue(value)}
renderOptionItem={renderChannelOption}
onChange={(value) => handleInputChange('type', value)}
disabled={isIonetLocked}
/>
{inputs.type === 20 && (
<Form.Switch
@@ -1754,7 +1803,10 @@ const EditChannelModal = (props) => {
style={{ width: '100%' }}
value={inputs.aws_key_type || 'ak_sk'}
onChange={(value) => {
handleChannelOtherSettingsChange('aws_key_type', value);
handleChannelOtherSettingsChange(
'aws_key_type',
value,
);
}}
extraText={t(
'AK/SK 模式:使用 AccessKey 和 SecretAccessKeyAPI Key 模式:使用 API Key',
@@ -1834,7 +1886,9 @@ const EditChannelModal = (props) => {
placeholder={
inputs.type === 33
? inputs.aws_key_type === 'api_key'
? t('请输入 API Key一行一个格式APIKey|Region')
? t(
'请输入 API Key一行一个格式APIKey|Region',
)
: t(
'请输入密钥一行一个格式AccessKey|SecretAccessKey|Region',
)
@@ -1849,85 +1903,87 @@ const EditChannelModal = (props) => {
autoComplete='new-password'
onChange={(value) => handleInputChange('key', value)}
disabled={isIonetLocked}
extraText={
<div className='flex items-center gap-2 flex-wrap'>
{isEdit &&
isMultiKeyChannel &&
keyMode === 'append' && (
<Text type='warning' size='small'>
{t(
'追加模式:新密钥将添加到现有密钥列表的末尾',
)}
</Text>
extraText={
<div className='flex items-center gap-2 flex-wrap'>
{isEdit &&
isMultiKeyChannel &&
keyMode === 'append' && (
<Text type='warning' size='small'>
{t(
'追加模式:新密钥将添加到现有密钥列表的末尾',
)}
</Text>
)}
{isEdit && (
<Button
size='small'
type='primary'
theme='outline'
onClick={handleShow2FAModal}
>
{t('查看密钥')}
</Button>
)}
{isEdit && (
<Button
size='small'
type='primary'
theme='outline'
onClick={handleShow2FAModal}
>
{t('查看密钥')}
</Button>
)}
{batchExtra}
</div>
}
showClear
/>
)
) : (
<>
{inputs.type === 41 &&
(inputs.vertex_key_type || 'json') === 'json' ? (
<>
{!batch && (
<div className='flex items-center justify-between mb-3'>
<Text className='text-sm font-medium'>
{t('密钥输入方式')}
</Text>
<Space>
<Button
size='small'
type={
!useManualInput ? 'primary' : 'tertiary'
}
onClick={() => {
setUseManualInput(false);
// 切换到文件上传模式时清空手动输入的密钥
if (formApiRef.current) {
formApiRef.current.setValue('key', '');
}
handleInputChange('key', '');
}}
>
{t('文件上传')}
</Button>
<Button
size='small'
type={useManualInput ? 'primary' : 'tertiary'}
onClick={() => {
setUseManualInput(true);
// 切换到手动输入模式时清空文件上传相关状态
setVertexKeys([]);
setVertexFileList([]);
if (formApiRef.current) {
formApiRef.current.setValue(
'vertex_files',
[],
);
}
setInputs((prev) => ({
...prev,
vertex_files: [],
}));
}}
>
{t('手动输入')}
</Button>
</Space>
{batchExtra}
</div>
)}
}
showClear
/>
)
) : (
<>
{inputs.type === 41 &&
(inputs.vertex_key_type || 'json') === 'json' ? (
<>
{!batch && (
<div className='flex items-center justify-between mb-3'>
<Text className='text-sm font-medium'>
{t('密钥输入方式')}
</Text>
<Space>
<Button
size='small'
type={
!useManualInput ? 'primary' : 'tertiary'
}
onClick={() => {
setUseManualInput(false);
// 切换到文件上传模式时清空手动输入的密钥
if (formApiRef.current) {
formApiRef.current.setValue('key', '');
}
handleInputChange('key', '');
}}
>
{t('文件上传')}
</Button>
<Button
size='small'
type={
useManualInput ? 'primary' : 'tertiary'
}
onClick={() => {
setUseManualInput(true);
// 切换到手动输入模式时清空文件上传相关状态
setVertexKeys([]);
setVertexFileList([]);
if (formApiRef.current) {
formApiRef.current.setValue(
'vertex_files',
[],
);
}
setInputs((prev) => ({
...prev,
vertex_files: [],
}));
}}
>
{t('手动输入')}
</Button>
</Space>
</div>
)}
{batch && (
<Banner
@@ -2036,7 +2092,9 @@ const EditChannelModal = (props) => {
inputs.type === 33
? inputs.aws_key_type === 'api_key'
? t('请输入 API Key格式APIKey|Region')
: t('按照如下格式输入AccessKey|SecretAccessKey|Region')
: t(
'按照如下格式输入AccessKey|SecretAccessKey|Region',
)
: t(type2secretPrompt(inputs.type))
}
rules={
@@ -2258,86 +2316,86 @@ const EditChannelModal = (props) => {
/>
)}
{inputs.type === 3 && (
<>
<Banner
type='warning'
description={t(
'2025年5月10日后添加的渠道不需要再在部署的时候移除模型名称中的"."',
)}
className='!rounded-lg'
/>
<div>
<Form.Input
field='base_url'
label='AZURE_OPENAI_ENDPOINT'
placeholder={t(
'请输入 AZURE_OPENAI_ENDPOINT例如https://docs-test-001.openai.azure.com',
{inputs.type === 3 && (
<>
<Banner
type='warning'
description={t(
'2025年5月10日后添加的渠道不需要再在部署的时候移除模型名称中的"."',
)}
onChange={(value) =>
handleInputChange('base_url', value)
}
showClear
disabled={isIonetLocked}
className='!rounded-lg'
/>
</div>
<div>
<Form.Input
field='other'
label={t('默认 API 版本')}
placeholder={t(
'请输入默认 API 版本例如2025-04-01-preview',
)}
onChange={(value) =>
handleInputChange('other', value)
}
showClear
/>
</div>
<div>
<Form.Input
field='azure_responses_version'
label={t(
'默认 Responses API 版本,为空则使用上方版本',
)}
placeholder={t('例如preview')}
onChange={(value) =>
handleChannelOtherSettingsChange(
'azure_responses_version',
value,
)
}
showClear
/>
</div>
</>
)}
<div>
<Form.Input
field='base_url'
label='AZURE_OPENAI_ENDPOINT'
placeholder={t(
'请输入 AZURE_OPENAI_ENDPOINT例如https://docs-test-001.openai.azure.com',
)}
onChange={(value) =>
handleInputChange('base_url', value)
}
showClear
disabled={isIonetLocked}
/>
</div>
<div>
<Form.Input
field='other'
label={t('默认 API 版本')}
placeholder={t(
'请输入默认 API 版本例如2025-04-01-preview',
)}
onChange={(value) =>
handleInputChange('other', value)
}
showClear
/>
</div>
<div>
<Form.Input
field='azure_responses_version'
label={t(
'默认 Responses API 版本,为空则使用上方版本',
)}
placeholder={t('例如preview')}
onChange={(value) =>
handleChannelOtherSettingsChange(
'azure_responses_version',
value,
)
}
showClear
/>
</div>
</>
)}
{inputs.type === 8 && (
<>
<Banner
type='warning'
description={t(
'如果你对接的是上游One API或者New API等转发项目请使用OpenAI类型不要使用此类型除非你知道你在做什么。',
)}
className='!rounded-lg'
/>
<div>
<Form.Input
field='base_url'
label={t('完整的 Base URL支持变量{model}')}
placeholder={t(
'请输入完整的URL例如https://api.openai.com/v1/chat/completions',
{inputs.type === 8 && (
<>
<Banner
type='warning'
description={t(
'如果你对接的是上游One API或者New API等转发项目请使用OpenAI类型不要使用此类型除非你知道你在做什么。',
)}
onChange={(value) =>
handleInputChange('base_url', value)
}
showClear
disabled={isIonetLocked}
className='!rounded-lg'
/>
</div>
</>
)}
<div>
<Form.Input
field='base_url'
label={t('完整的 Base URL支持变量{model}')}
placeholder={t(
'请输入完整的URL例如https://api.openai.com/v1/chat/completions',
)}
onChange={(value) =>
handleInputChange('base_url', value)
}
showClear
disabled={isIonetLocked}
/>
</div>
</>
)}
{inputs.type === 37 && (
<Banner
@@ -2366,76 +2424,79 @@ const EditChannelModal = (props) => {
}
showClear
disabled={isIonetLocked}
extraText={t(
'对于官方渠道new-api已经内置地址除非是第三方代理站点或者Azure的特殊接入地址否则不需要填写',
extraText={t(
'对于官方渠道new-api已经内置地址除非是第三方代理站点或者Azure的特殊接入地址否则不需要填写',
)}
/>
</div>
)}
{inputs.type === 22 && (
<div>
<Form.Input
field='base_url'
label={t('私有部署地址')}
placeholder={t(
'请输入私有部署地址格式为https://fastgpt.run/api/openapi',
)}
onChange={(value) =>
handleInputChange('base_url', value)
}
showClear
disabled={isIonetLocked}
/>
</div>
)}
{inputs.type === 22 && (
<div>
<Form.Input
field='base_url'
label={t('私有部署地址')}
placeholder={t(
'请输入私有部署地址格式为https://fastgpt.run/api/openapi',
)}
onChange={(value) =>
handleInputChange('base_url', value)
}
showClear
disabled={isIonetLocked}
/>
</div>
)}
{inputs.type === 36 && (
<div>
<Form.Input
field='base_url'
label={t(
'注意非Chat API请务必填写正确的API地址否则可能导致无法使用',
)}
placeholder={t(
'请输入到 /suno 前的路径通常就是域名例如https://api.example.com',
)}
onChange={(value) =>
handleInputChange('base_url', value)
}
showClear
disabled={isIonetLocked}
/>
</div>
)}
{inputs.type === 45 && !doubaoApiEditUnlocked && (
<div>
<Form.Select
field='base_url'
label={t('API地址')}
placeholder={t('请选择API地址')}
onChange={(value) =>
{inputs.type === 36 && (
<div>
<Form.Input
field='base_url'
label={t(
'注意非Chat API请务必填写正确的API地址否则可能导致无法使用',
)}
placeholder={t(
'请输入到 /suno 前的路径通常就是域名例如https://api.example.com',
)}
onChange={(value) =>
handleInputChange('base_url', value)
}
optionList={[
{
value: 'https://ark.cn-beijing.volces.com',
label: 'https://ark.cn-beijing.volces.com',
},
{
value: 'https://ark.ap-southeast.bytepluses.com',
label: 'https://ark.ap-southeast.bytepluses.com',
},
{
value: 'doubao-coding-plan',
label: 'Doubao Coding Plan',
},
]}defaultValue='https://ark.cn-beijing.volces.com'
disabled={isIonetLocked}
/>
</div>
)}
}
showClear
disabled={isIonetLocked}
/>
</div>
)}
{inputs.type === 45 && !doubaoApiEditUnlocked && (
<div>
<Form.Select
field='base_url'
label={t('API地址')}
placeholder={t('请选择API地址')}
onChange={(value) =>
handleInputChange('base_url', value)
}
optionList={[
{
value: 'https://ark.cn-beijing.volces.com',
label: 'https://ark.cn-beijing.volces.com',
},
{
value:
'https://ark.ap-southeast.bytepluses.com',
label:
'https://ark.ap-southeast.bytepluses.com',
},
{
value: 'doubao-coding-plan',
label: 'Doubao Coding Plan',
},
]}
defaultValue='https://ark.cn-beijing.volces.com'
disabled={isIonetLocked}
/>
</div>
)}
</Card>
</div>
)}
@@ -2531,79 +2592,81 @@ const EditChannelModal = (props) => {
</Button>
)}
{inputs.type === 4 && isEdit && (
<Button
size='small'
type='primary'
theme='light'
onClick={() => setOllamaModalVisible(true)}
>
{t('Ollama 模型管理')}
</Button>
)}
<Button
size='small'
type='warning'
onClick={() => handleInputChange('models', [])}
>
{t('清除所有模型')}
</Button>
<Button
size='small'
type='tertiary'
onClick={() => {
if (inputs.models.length === 0) {
showInfo(t('没有模型可以复制'));
return;
}
try {
copy(inputs.models.join(','));
showSuccess(t('模型列表已复制到剪贴板'));
} catch (error) {
showError(t('复制失败'));
}
}}
>
{t('复制所有模型')}
</Button>
{modelGroups &&
modelGroups.length > 0 &&
modelGroups.map((group) => (
<Button
key={group.id}
size='small'
type='primary'
onClick={() => {
let items = [];
try {
if (Array.isArray(group.items)) {
items = group.items;
} else if (typeof group.items === 'string') {
const parsed = JSON.parse(
group.items || '[]',
);
if (Array.isArray(parsed)) items = parsed;
}
} catch {}
const current =
formApiRef.current?.getValue('models') ||
inputs.models ||
[];
const merged = Array.from(
new Set(
[...current, ...items]
.map((m) => (m || '').trim())
.filter(Boolean),
),
);
handleInputChange('models', merged);
}}
theme='light'
onClick={() => setOllamaModalVisible(true)}
>
{group.name}
{t('Ollama 模型管理')}
</Button>
))}
</Space>
}
/>
)}
<Button
size='small'
type='warning'
onClick={() => handleInputChange('models', [])}
>
{t('清除所有模型')}
</Button>
<Button
size='small'
type='tertiary'
onClick={() => {
if (inputs.models.length === 0) {
showInfo(t('没有模型可以复制'));
return;
}
try {
copy(inputs.models.join(','));
showSuccess(t('模型列表已复制到剪贴板'));
} catch (error) {
showError(t('复制失败'));
}
}}
>
{t('复制所有模型')}
</Button>
{modelGroups &&
modelGroups.length > 0 &&
modelGroups.map((group) => (
<Button
key={group.id}
size='small'
type='primary'
onClick={() => {
let items = [];
try {
if (Array.isArray(group.items)) {
items = group.items;
} else if (
typeof group.items === 'string'
) {
const parsed = JSON.parse(
group.items || '[]',
);
if (Array.isArray(parsed)) items = parsed;
}
} catch {}
const current =
formApiRef.current?.getValue('models') ||
inputs.models ||
[];
const merged = Array.from(
new Set(
[...current, ...items]
.map((m) => (m || '').trim())
.filter(Boolean),
),
);
handleInputChange('models', merged);
}}
>
{group.name}
</Button>
))}
</Space>
}
/>
<Form.Input
field='custom_model'
@@ -2650,6 +2713,27 @@ const EditChannelModal = (props) => {
templateLabel={t('填入模板')}
editorType='keyValue'
formApi={formApiRef.current}
renderStringValueSuffix={({ pairKey, value }) => {
if (!MODEL_FETCHABLE_TYPES.has(inputs.type)) {
return null;
}
const disabled = !String(pairKey ?? '').trim();
return (
<Tooltip content={t('选择模型')}>
<Button
type='tertiary'
theme='borderless'
size='small'
icon={<IconSearch size={14} />}
disabled={disabled}
onClick={(e) => {
e.stopPropagation();
openModelMappingValueModal({ pairKey, value });
}}
/>
</Tooltip>
);
}}
extraText={t(
'键为请求中的模型名称,值为要替换的模型名称',
)}
@@ -3164,6 +3248,53 @@ const EditChannelModal = (props) => {
onCancel={() => setModelModalVisible(false)}
/>
<SingleModelSelectModal
visible={modelMappingValueModalVisible}
models={modelMappingValueModalModels}
selected={modelMappingValueSelected}
onConfirm={(selectedModel) => {
const modelName = String(selectedModel ?? '').trim();
if (!modelName) {
showError(t('请先选择模型!'));
return;
}
const mappingKey = String(modelMappingValueKey ?? '').trim();
if (!mappingKey) {
setModelMappingValueModalVisible(false);
return;
}
let parsed = {};
const currentMapping = inputs.model_mapping;
if (typeof currentMapping === 'string' && currentMapping.trim()) {
try {
parsed = JSON.parse(currentMapping);
} catch (error) {
parsed = {};
}
} else if (
currentMapping &&
typeof currentMapping === 'object' &&
!Array.isArray(currentMapping)
) {
parsed = currentMapping;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
parsed = {};
}
parsed[mappingKey] = modelName;
const nextMapping = JSON.stringify(parsed, null, 2);
handleInputChange('model_mapping', nextMapping);
if (formApiRef.current) {
formApiRef.current.setValue('model_mapping', nextMapping);
}
setModelMappingValueModalVisible(false);
}}
onCancel={() => setModelMappingValueModalVisible(false)}
/>
<OllamaModelModal
visible={ollamaModalVisible}
onCancel={() => setOllamaModalVisible(false)}
@@ -3181,7 +3312,9 @@ const EditChannelModal = (props) => {
? inputs.models.map(String)
: [];
const incoming = modelIds.map(String);
const nextModels = Array.from(new Set([...existingModels, ...incoming]));
const nextModels = Array.from(
new Set([...existingModels, ...incoming]),
);
handleInputChange('models', nextModels);
if (formApiRef.current) {

View File

@@ -0,0 +1,195 @@
/*
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;