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';
@@ -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 () => {
@@ -1677,7 +1724,9 @@ const EditChannelModal = (props) => {
type='info'
closeIcon={null}
className='mb-4 rounded-xl'
description={t('此渠道由 IO.NET 自动同步,类型、密钥和 API 地址已锁定。')}
description={t(
'此渠道由 IO.NET 自动同步,类型、密钥和 API 地址已锁定。',
)}
>
<Space>
{ionetMetadata?.deployment_id && (
@@ -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',
)
@@ -1905,7 +1959,9 @@ const EditChannelModal = (props) => {
</Button>
<Button
size='small'
type={useManualInput ? 'primary' : 'tertiary'}
type={
useManualInput ? 'primary' : 'tertiary'
}
onClick={() => {
setUseManualInput(true);
// 切换到手动输入模式时清空文件上传相关状态
@@ -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={
@@ -2424,14 +2482,17 @@ const EditChannelModal = (props) => {
label: 'https://ark.cn-beijing.volces.com',
},
{
value: 'https://ark.ap-southeast.bytepluses.com',
label: 'https://ark.ap-southeast.bytepluses.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'
]}
defaultValue='https://ark.cn-beijing.volces.com'
disabled={isIonetLocked}
/>
</div>
@@ -2577,7 +2638,9 @@ const EditChannelModal = (props) => {
try {
if (Array.isArray(group.items)) {
items = group.items;
} else if (typeof group.items === 'string') {
} else if (
typeof group.items === 'string'
) {
const parsed = JSON.parse(
group.items || '[]',
);
@@ -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;