From f8c40ecca6fa704964b962528429879b93c4f6c3 Mon Sep 17 00:00:00 2001 From: Seefs Date: Sat, 22 Nov 2025 19:23:27 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E4=BA=8C=E6=AC=A1=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=E6=B7=BB=E5=8A=A0=E9=87=8D=E5=AE=9A=E5=90=91=E5=89=8D?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../channels/modals/EditChannelModal.jsx | 142 +++++++++++++++++- 1 file changed, 135 insertions(+), 7 deletions(-) diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index 6d9388bef..25e0f95b1 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -220,6 +220,8 @@ const EditChannelModal = (props) => { ]; const formContainerRef = useRef(null); const doubaoApiClickCountRef = useRef(0); + const initialModelsRef = useRef([]); + const initialModelMappingRef = useRef(''); // 2FA状态更新辅助函数 const updateTwoFAState = (updates) => { @@ -595,6 +597,10 @@ const EditChannelModal = (props) => { system_prompt: data.system_prompt, system_prompt_override: data.system_prompt_override || false, }); + initialModelsRef.current = (data.models || []) + .map((model) => (model || '').trim()) + .filter(Boolean); + initialModelMappingRef.current = data.model_mapping || ''; // console.log(data); } else { showError(message); @@ -830,6 +836,13 @@ const EditChannelModal = (props) => { } }, [props.visible, channelId]); + useEffect(() => { + if (!isEdit) { + initialModelsRef.current = []; + initialModelMappingRef.current = ''; + } + }, [isEdit, props.visible]); + // 统一的模态框重置函数 const resetModalState = () => { formApiRef.current?.reset(); @@ -903,6 +916,80 @@ const EditChannelModal = (props) => { })(); }; + const confirmMissingModelMappings = (missingModels) => + new Promise((resolve) => { + const modal = Modal.confirm({ + title: t('模型未加入列表,可能无法调用'), + content: ( +
+
+ {t( + '模型重定向里的下列模型尚未添加到“模型”列表,调用时会因为缺少可用模型而失败:', + )} +
+
+ {missingModels.join(', ')} +
+
+ {t( + '你可以在“自定义模型名称”处手动添加它们,然后点击填入后再提交,或者直接使用下方操作自动处理。', + )} +
+
+ ), + centered: true, + footer: ( + + + + + + ), + }); + }); + + const hasModelConfigChanged = (normalizedModels, modelMappingStr) => { + if (!isEdit) return true; + const initialModels = initialModelsRef.current; + if (normalizedModels.length !== initialModels.length) { + return true; + } + for (let i = 0; i < normalizedModels.length; i++) { + if (normalizedModels[i] !== initialModels[i]) { + return true; + } + } + const normalizedMapping = (modelMappingStr || '').trim(); + const initialMapping = (initialModelMappingRef.current || '').trim(); + return normalizedMapping !== initialMapping; + }; + const submit = async () => { const formValues = formApiRef.current ? formApiRef.current.getValues() : {}; let localInputs = { ...formValues }; @@ -986,14 +1073,55 @@ const EditChannelModal = (props) => { showInfo(t('请输入API地址!')); return; } - if ( - localInputs.model_mapping && - localInputs.model_mapping !== '' && - !verifyJSON(localInputs.model_mapping) - ) { - showInfo(t('模型映射必须是合法的 JSON 格式!')); - return; + const hasModelMapping = + typeof localInputs.model_mapping === 'string' && + localInputs.model_mapping.trim() !== ''; + let parsedModelMapping = null; + if (hasModelMapping) { + if (!verifyJSON(localInputs.model_mapping)) { + showInfo(t('模型映射必须是合法的 JSON 格式!')); + return; + } + try { + parsedModelMapping = JSON.parse(localInputs.model_mapping); + } catch (error) { + showInfo(t('模型映射必须是合法的 JSON 格式!')); + return; + } } + + const normalizedModels = (localInputs.models || []) + .map((model) => (model || '').trim()) + .filter(Boolean); + localInputs.models = normalizedModels; + + if ( + parsedModelMapping && + typeof parsedModelMapping === 'object' && + !Array.isArray(parsedModelMapping) + ) { + const modelSet = new Set(normalizedModels); + const missingModels = Object.keys(parsedModelMapping) + .map((key) => (key || '').trim()) + .filter((key) => key && !modelSet.has(key)); + const shouldPromptMissing = + missingModels.length > 0 && + hasModelConfigChanged(normalizedModels, localInputs.model_mapping); + if (shouldPromptMissing) { + const confirmAction = await confirmMissingModelMappings(missingModels); + if (confirmAction === 'cancel') { + return; + } + if (confirmAction === 'add') { + const updatedModels = Array.from( + new Set([...normalizedModels, ...missingModels]), + ); + localInputs.models = updatedModels; + handleInputChange('models', updatedModels); + } + } + } + if (localInputs.base_url && localInputs.base_url.endsWith('/')) { localInputs.base_url = localInputs.base_url.slice( 0, From 7a2bd38700baa8ddd758083f3bcc1c08fbdd61e5 Mon Sep 17 00:00:00 2001 From: Seefs Date: Sat, 22 Nov 2025 19:34:36 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=E9=87=8D=E5=AE=9A=E5=90=91?= =?UTF-8?q?=E5=90=8E=E7=9A=84=E6=A8=A1=E5=9E=8B=E8=A7=86=E4=B8=BA=E5=B7=B2?= =?UTF-8?q?=E6=9C=89=E7=9A=84=E6=A8=A1=E5=9E=8B=EF=BC=8C=E9=99=84=E5=B8=A6?= =?UTF-8?q?=E7=89=B9=E6=AE=8A=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../channels/modals/EditChannelModal.jsx | 25 +++++++ .../channels/modals/ModelSelectModal.jsx | 66 +++++++++++++++++-- 2 files changed, 85 insertions(+), 6 deletions(-) diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index 25e0f95b1..4b46444dd 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -190,6 +190,30 @@ const EditChannelModal = (props) => { const [keyMode, setKeyMode] = useState('append'); // 密钥模式:replace(覆盖)或 append(追加) const [isEnterpriseAccount, setIsEnterpriseAccount] = useState(false); // 是否为企业账户 const [doubaoApiEditUnlocked, setDoubaoApiEditUnlocked] = useState(false); // 豆包渠道自定义 API 地址隐藏入口 + const redirectModelList = useMemo(() => { + const mapping = inputs.model_mapping; + if (typeof mapping !== 'string') return []; + const trimmed = mapping.trim(); + if (!trimmed) return []; + try { + const parsed = JSON.parse(trimmed); + if ( + !parsed || + typeof parsed !== 'object' || + Array.isArray(parsed) + ) { + return []; + } + const values = Object.values(parsed) + .map((value) => + typeof value === 'string' ? value.trim() : undefined, + ) + .filter((value) => value); + return Array.from(new Set(values)); + } catch (error) { + return []; + } + }, [inputs.model_mapping]); // 密钥显示状态 const [keyDisplayState, setKeyDisplayState] = useState({ @@ -3044,6 +3068,7 @@ const EditChannelModal = (props) => { visible={modelModalVisible} models={fetchedModels} selected={inputs.models} + redirectModels={redirectModelList} onConfirm={(selectedModels) => { handleInputChange('models', selectedModels); showSuccess(t('模型列表已更新')); diff --git a/web/src/components/table/channels/modals/ModelSelectModal.jsx b/web/src/components/table/channels/modals/ModelSelectModal.jsx index 0fe98167f..21ac768c6 100644 --- a/web/src/components/table/channels/modals/ModelSelectModal.jsx +++ b/web/src/components/table/channels/modals/ModelSelectModal.jsx @@ -17,7 +17,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; import { useIsMobile } from '../../../../hooks/common/useIsMobile'; import { Modal, @@ -28,12 +28,13 @@ import { Empty, Tabs, Collapse, + Tooltip, } from '@douyinfe/semi-ui'; import { IllustrationNoResult, IllustrationNoResultDark, } from '@douyinfe/semi-illustrations'; -import { IconSearch } from '@douyinfe/semi-icons'; +import { IconSearch, IconInfoCircle } from '@douyinfe/semi-icons'; import { useTranslation } from 'react-i18next'; import { getModelCategories } from '../../../../helpers/render'; @@ -41,6 +42,7 @@ const ModelSelectModal = ({ visible, models = [], selected = [], + redirectModels = [], onConfirm, onCancel, }) => { @@ -50,15 +52,54 @@ const ModelSelectModal = ({ const [activeTab, setActiveTab] = useState('new'); const isMobile = useIsMobile(); + const normalizeModelName = (model) => + typeof model === 'string' ? model.trim() : ''; + const normalizedRedirectModels = useMemo( + () => + Array.from( + new Set( + (redirectModels || []) + .map((model) => normalizeModelName(model)) + .filter(Boolean), + ), + ), + [redirectModels], + ); + const normalizedSelectedSet = useMemo(() => { + const set = new Set(); + (selected || []).forEach((model) => { + const normalized = normalizeModelName(model); + if (normalized) { + set.add(normalized); + } + }); + return set; + }, [selected]); + const classificationSet = useMemo(() => { + const set = new Set(normalizedSelectedSet); + normalizedRedirectModels.forEach((model) => set.add(model)); + return set; + }, [normalizedSelectedSet, normalizedRedirectModels]); + const redirectOnlySet = useMemo(() => { + const set = new Set(); + normalizedRedirectModels.forEach((model) => { + if (!normalizedSelectedSet.has(model)) { + set.add(model); + } + }); + return set; + }, [normalizedRedirectModels, normalizedSelectedSet]); const filteredModels = models.filter((m) => - m.toLowerCase().includes(keyword.toLowerCase()), + String(m || '').toLowerCase().includes(keyword.toLowerCase()), ); // 分类模型:新获取的模型和已有模型 - const newModels = filteredModels.filter((model) => !selected.includes(model)); + const isExistingModel = (model) => + classificationSet.has(normalizeModelName(model)); + const newModels = filteredModels.filter((model) => !isExistingModel(model)); const existingModels = filteredModels.filter((model) => - selected.includes(model), + isExistingModel(model), ); // 同步外部选中值 @@ -228,7 +269,20 @@ const ModelSelectModal = ({
{categoryData.models.map((model) => ( - {model} + + {model} + {redirectOnlySet.has(normalizeModelName(model)) && ( + + + + )} + ))}