mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-03-30 04:01:07 +00:00
feat: select model in visualized modl mapping
This commit is contained in:
@@ -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 />}
|
||||
|
||||
@@ -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 和 SecretAccessKey;API 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) {
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user