2
0
Эх сурвалжийг харах

Merge pull request #2277 from seefs001/feature/model_list_fetch

feat: 二次确认添加重定向前模型 && 重定向后模式视为已有模型
Calcium-Ion 1 сар өмнө
parent
commit
a465597e78

+ 158 - 5
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({
@@ -220,6 +244,8 @@ const EditChannelModal = (props) => {
   ];
   const formContainerRef = useRef(null);
   const doubaoApiClickCountRef = useRef(0);
+  const initialModelsRef = useRef([]);
+  const initialModelMappingRef = useRef('');
 
   // 2FA状态更新辅助函数
   const updateTwoFAState = (updates) => {
@@ -595,6 +621,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 +860,13 @@ const EditChannelModal = (props) => {
     }
   }, [props.visible, channelId]);
 
+  useEffect(() => {
+    if (!isEdit) {
+      initialModelsRef.current = [];
+      initialModelMappingRef.current = '';
+    }
+  }, [isEdit, props.visible]);
+
   // 统一的模态框重置函数
   const resetModalState = () => {
     formApiRef.current?.reset();
@@ -903,6 +940,80 @@ const EditChannelModal = (props) => {
     })();
   };
 
+  const confirmMissingModelMappings = (missingModels) =>
+    new Promise((resolve) => {
+      const modal = Modal.confirm({
+        title: t('模型未加入列表,可能无法调用'),
+        content: (
+          <div className='text-sm leading-6'>
+            <div>
+              {t(
+                '模型重定向里的下列模型尚未添加到“模型”列表,调用时会因为缺少可用模型而失败:',
+              )}
+            </div>
+            <div className='font-mono text-xs break-all text-red-600 mt-1'>
+              {missingModels.join(', ')}
+            </div>
+            <div className='mt-2'>
+              {t(
+                '你可以在“自定义模型名称”处手动添加它们,然后点击填入后再提交,或者直接使用下方操作自动处理。',
+              )}
+            </div>
+          </div>
+        ),
+        centered: true,
+        footer: (
+          <Space align='center' className='w-full justify-end'>
+            <Button
+              type='tertiary'
+              onClick={() => {
+                modal.destroy();
+                resolve('cancel');
+              }}
+            >
+              {t('返回修改')}
+            </Button>
+            <Button
+              type='primary'
+              theme='light'
+              onClick={() => {
+                modal.destroy();
+                resolve('submit');
+              }}
+            >
+              {t('直接提交')}
+            </Button>
+            <Button
+              type='primary'
+              theme='solid'
+              onClick={() => {
+                modal.destroy();
+                resolve('add');
+              }}
+            >
+              {t('添加后提交')}
+            </Button>
+          </Space>
+        ),
+      });
+    });
+
+  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 +1097,55 @@ const EditChannelModal = (props) => {
       showInfo(t('请输入API地址!'));
       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 (
-      localInputs.model_mapping &&
-      localInputs.model_mapping !== '' &&
-      !verifyJSON(localInputs.model_mapping)
+      parsedModelMapping &&
+      typeof parsedModelMapping === 'object' &&
+      !Array.isArray(parsedModelMapping)
     ) {
-      showInfo(t('模型映射必须是合法的 JSON 格式!'));
-      return;
+      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,
@@ -2916,6 +3068,7 @@ const EditChannelModal = (props) => {
         visible={modelModalVisible}
         models={fetchedModels}
         selected={inputs.models}
+        redirectModels={redirectModelList}
         onConfirm={(selectedModels) => {
           handleInputChange('models', selectedModels);
           showSuccess(t('模型列表已更新'));

+ 60 - 6
web/src/components/table/channels/modals/ModelSelectModal.jsx

@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
 For commercial licensing, please contact [email protected]
 */
 
-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 = ({
             <div className='grid grid-cols-2 gap-x-4'>
               {categoryData.models.map((model) => (
                 <Checkbox key={model} value={model} className='my-1'>
-                  {model}
+                  <span className='flex items-center gap-2'>
+                    <span>{model}</span>
+                    {redirectOnlySet.has(normalizeModelName(model)) && (
+                      <Tooltip
+                        position='top'
+                        content={t('来自模型重定向,尚未加入模型列表')}
+                      >
+                        <IconInfoCircle
+                          size='small'
+                          className='text-amber-500 cursor-help'
+                        />
+                      </Tooltip>
+                    )}
+                  </span>
                 </Checkbox>
               ))}
             </div>