Просмотр исходного кода

✨ feat(channel): Robust Vertex AI batch-key upload & stable multi-key settings

Summary
-------
1. Vertex AI JSON key upload
   • Accept multiple `.json` files (drag & drop / click).
   • Parse each `fileInstance`; build valid key array.
   • Malformed files are skipped and collected into **one** toast message.
   • Upload list now仅displays valid files; form state (`vertex_files`) 同步保持.
   • On *Submit* keys are re-parsed to prevent async timing loss.

2. Multi-key mode stability
   • Added `multi_key_mode: "random"` to initial form values.
   • `Form.Select` becomes fully controlled (`value`/`onChange`).
   • Toggling the “multi-key mode” checkbox writes default / removes field, so
     `setValues(inputs)` no longer clears the user’s choice.

3. UX / compatibility tweaks
   • Preserve uploaded files when editing any other field.
   • Use `fileInstance` only – compatible with legacy Semi Upload API.
   • Removed redundant `limit` prop on `Form.Upload`.
   • Aggregated error handling avoids toast spam.

Result
------
Channel creation/update now supports:
• Reliable batch import of Vertex AI service-account keys.
• Consistent retention of multi-key strategy (`random` / `polling`).
• Cleaner, user-friendly error feedback.
t0ng7u 8 месяцев назад
Родитель
Сommit
870cdd5a56
2 измененных файлов с 241 добавлено и 87 удалено
  1. 11 2
      web/src/i18n/locales/en.json
  2. 230 85
      web/src/pages/Channel/EditChannel.js

+ 11 - 2
web/src/i18n/locales/en.json

@@ -1142,7 +1142,7 @@
   "鉴权json": "Authentication JSON",
   "请输入鉴权json": "Please enter authentication JSON",
   "组织": "Organization",
-  "组织,可选,不填则为默认组织": "Organization (optional), default if empty",
+  "组织,不填则为默认组织": "Organization, default if empty",
   "请输入组织org-xxx": "Please enter organization org-xxx",
   "默认测试模型": "Default Test Model",
   "不填则为模型列表第一个": "First model in list if empty",
@@ -1756,5 +1756,14 @@
   "生成数量必须大于0": "Generation quantity must be greater than 0",
   "创建后可在编辑渠道时获取上游模型列表": "After creation, you can get the upstream model list when editing the channel",
   "可用端点类型": "Supported endpoint types",
-  "未登录,使用默认分组倍率:": "Not logged in, using default group ratio: "
+  "未登录,使用默认分组倍率:": "Not logged in, using default group ratio: ",
+  "密钥聚合模式": "Key aggregation mode",
+  "随机": "Random",
+  "轮询": "Polling",
+  "密钥文件 (.json)": "Key file (.json)",
+  "点击上传文件或拖拽文件到这里": "Click to upload file or drag and drop file here",
+  "仅支持 JSON 文件,支持多文件": "Only JSON files are supported, multiple files are supported",
+  "请上传密钥文件": "Please upload the key file",
+  "请填写部署地区": "Please fill in the deployment region",
+  "请输入部署地区,例如:us-central1\n支持使用模型映射格式\n{\n    \"default\": \"us-central1\",\n    \"claude-3-5-sonnet-20240620\": \"europe-west1\"\n}": "Please enter the deployment region, for example: us-central1\nSupports using model mapping format\n{\n    \"default\": \"us-central1\",\n    \"claude-3-5-sonnet-20240620\": \"europe-west1\"\n}"
 }

+ 230 - 85
web/src/pages/Channel/EditChannel.js

@@ -26,6 +26,7 @@ import {
   Form,
   Row,
   Col,
+  Upload,
 } from '@douyinfe/semi-ui';
 import { getChannelModels, copy } from '../../helpers';
 import {
@@ -35,6 +36,7 @@ import {
   IconSetting,
   IconCode,
   IconGlobe,
+  IconBolt,
 } from '@douyinfe/semi-icons';
 
 const { Text, Title } = Typography;
@@ -100,8 +102,11 @@ const EditChannel = (props) => {
     priority: 0,
     weight: 0,
     tag: '',
+    multi_key_mode: 'random',
   };
   const [batch, setBatch] = useState(false);
+  const [multiToSingle, setMultiToSingle] = useState(false);
+  const [multiKeyMode, setMultiKeyMode] = useState('random');
   const [autoBan, setAutoBan] = useState(true);
   // const [autoBan, setAutoBan] = useState(true);
   const [inputs, setInputs] = useState(originInputs);
@@ -114,6 +119,9 @@ const EditChannel = (props) => {
   const [modalImageUrl, setModalImageUrl] = useState('');
   const [isModalOpenurl, setIsModalOpenurl] = useState(false);
   const formApiRef = useRef(null);
+  const [vertexKeys, setVertexKeys] = useState([]);
+  const [vertexFileList, setVertexFileList] = useState([]);
+  const vertexErroredNames = useRef(new Set()); // 避免重复报错
   const getInitValues = () => ({ ...originInputs });
   const handleInputChange = (name, value) => {
     if (formApiRef.current) {
@@ -377,10 +385,72 @@ const EditChannel = (props) => {
     }
   }, [props.visible, channelId]);
 
+  const handleVertexUploadChange = ({ fileList }) => {
+    (async () => {
+      const validFiles = [];
+      const keys = [];
+      const errorNames = [];
+      for (const item of fileList) {
+        const fileObj = item.fileInstance;
+        if (!fileObj) continue;
+        try {
+          const txt = await fileObj.text();
+          keys.push(JSON.parse(txt));
+          validFiles.push(item); // 仅合法文件加入列表
+        } catch (err) {
+          if (!vertexErroredNames.current.has(item.name)) {
+            errorNames.push(item.name);
+            vertexErroredNames.current.add(item.name);
+          }
+        }
+      }
+
+      setVertexKeys(keys);
+      setVertexFileList(validFiles);
+      if (formApiRef.current) {
+        formApiRef.current.setValue('vertex_files', validFiles);
+      }
+      setInputs((prev) => ({ ...prev, vertex_files: validFiles }));
+
+      if (errorNames.length > 0) {
+        showError(t('以下文件解析失败,已忽略:{{list}}', { list: errorNames.join(', ') }));
+      }
+    })();
+  };
+
   const submit = async () => {
     const formValues = formApiRef.current ? formApiRef.current.getValues() : {};
     let localInputs = { ...formValues };
 
+    if (localInputs.type === 41 && batch) {
+      let keys = vertexKeys;
+      if (keys.length === 0) {
+        // 确保提交时也能解析,避免因异步延迟导致 keys 为空
+        try {
+          const parsed = await Promise.all(
+            vertexFileList.map(async (item) => {
+              const fileObj = item.fileInstance;
+              if (!fileObj) return null;
+              const txt = await fileObj.text();
+              return JSON.parse(txt);
+            })
+          );
+          keys = parsed.filter(Boolean);
+        } catch (err) {
+          showError(t('解析密钥文件失败: {{msg}}', { msg: err.message }));
+          return;
+        }
+      }
+
+      if (keys.length === 0) {
+        showInfo(t('请上传密钥文件!'));
+        return;
+      }
+
+      localInputs.key = JSON.stringify(keys);
+    }
+    delete localInputs.vertex_files;
+
     if (!isEdit && (!localInputs.name || !localInputs.key)) {
       showInfo(t('请填写渠道名称和渠道密钥!'));
       return;
@@ -406,13 +476,23 @@ const EditChannel = (props) => {
     localInputs.auto_ban = localInputs.auto_ban ? 1 : 0;
     localInputs.models = localInputs.models.join(',');
     localInputs.group = (localInputs.groups || []).join(',');
+
+    let mode = 'single';
+    if (batch) {
+      mode = multiToSingle ? 'multi_to_single' : 'batch';
+    }
+
     if (isEdit) {
       res = await API.put(`/api/channel/`, {
         ...localInputs,
         id: parseInt(channelId),
       });
     } else {
-      res = await API.post(`/api/channel/`, localInputs);
+      res = await API.post(`/api/channel/`, {
+        mode: mode,
+        multi_key_mode: mode === 'multi_to_single' ? multiKeyMode : undefined,
+        channel: localInputs,
+      });
     }
     const { success, message } = res.data;
     if (success) {
@@ -465,9 +545,31 @@ const EditChannel = (props) => {
     }
   };
 
-  const batchAllowed = !isEdit && inputs.type !== 41;
+  const batchAllowed = !isEdit;
   const batchExtra = batchAllowed ? (
-    <Checkbox checked={batch} onChange={() => setBatch(!batch)}>{t('批量创建')}</Checkbox>
+    <Space>
+      <Checkbox checked={batch} onChange={() => {
+        setBatch(!batch);
+        if (batch) {
+          setMultiToSingle(false);
+          setMultiKeyMode('random');
+        }
+      }}>{t('批量创建')}</Checkbox>
+      {batch && (
+        <Checkbox checked={multiToSingle} onChange={() => {
+          setMultiToSingle(prev => !prev);
+          setInputs(prev => {
+            const newInputs = { ...prev };
+            if (!multiToSingle) {
+              newInputs.multi_key_mode = multiKeyMode;
+            } else {
+              delete newInputs.multi_key_mode;
+            }
+            return newInputs;
+          });
+        }}>{t('密钥聚合模式')}</Checkbox>
+      )}
+    </Space>
   ) : null;
 
   return (
@@ -553,16 +655,37 @@ const EditChannel = (props) => {
                   />
 
                   {batch ? (
-                    <Form.TextArea
-                      field='key'
-                      label={t('密钥')}
-                      placeholder={t('请输入密钥,一行一个')}
-                      rules={isEdit ? [] : [{ required: true, message: t('请输入密钥') }]}
-                      autosize={{ minRows: 6, maxRows: 6 }}
-                      autoComplete='new-password'
-                      onChange={(value) => handleInputChange('key', value)}
-                      extraText={batchExtra}
-                    />
+                    inputs.type === 41 ? (
+                      <Form.Upload
+                        field='vertex_files'
+                        label={t('密钥文件 (.json)')}
+                        accept='.json'
+                        multiple
+                        draggable
+                        dragIcon={<IconBolt />}
+                        dragMainText={t('点击上传文件或拖拽文件到这里')}
+                        dragSubText={t('仅支持 JSON 文件,支持多文件')}
+                        style={{ marginTop: 10 }}
+                        uploadTrigger='custom'
+                        beforeUpload={() => false}
+                        onChange={handleVertexUploadChange}
+                        fileList={vertexFileList}
+                        rules={isEdit ? [] : [{ required: true, message: t('请上传密钥文件') }]}
+                        extraText={batchExtra}
+                      />
+                    ) : (
+                      <Form.TextArea
+                        field='key'
+                        label={t('密钥')}
+                        placeholder={t('请输入密钥,一行一个')}
+                        rules={isEdit ? [] : [{ required: true, message: t('请输入密钥') }]}
+                        autosize
+                        autoComplete='new-password'
+                        onChange={(value) => handleInputChange('key', value)}
+                        extraText={batchExtra}
+                        showClear
+                      />
+                    )
                   ) : (
                     <>
                       {inputs.type === 41 ? (
@@ -585,10 +708,11 @@ const EditChannel = (props) => {
                             '}'
                           }
                           rules={isEdit ? [] : [{ required: true, message: t('请输入密钥') }]}
-                          autosize={{ minRows: 10 }}
+                          autosize
                           autoComplete='new-password'
                           onChange={(value) => handleInputChange('key', value)}
                           extraText={batchExtra}
+                          showClear
                         />
                       ) : (
                         <Form.Input
@@ -599,10 +723,102 @@ const EditChannel = (props) => {
                           autoComplete='new-password'
                           onChange={(value) => handleInputChange('key', value)}
                           extraText={batchExtra}
+                          showClear
                         />
                       )}
                     </>
                   )}
+
+                  {batch && multiToSingle && (
+                    <Form.Select
+                      field='multi_key_mode'
+                      label={t('密钥聚合模式')}
+                      placeholder={t('请选择多密钥使用策略')}
+                      optionList={[
+                        { label: t('随机'), value: 'random' },
+                        { label: t('轮询'), value: 'polling' },
+                      ]}
+                      style={{ width: '100%' }}
+                      value={inputs.multi_key_mode || 'random'}
+                      onChange={(value) => {
+                        setMultiKeyMode(value);
+                        handleInputChange('multi_key_mode', value);
+                      }}
+                    />
+                  )}
+
+                  {inputs.type === 18 && (
+                    <Form.Input
+                      field='other'
+                      label={t('模型版本')}
+                      placeholder={'请输入星火大模型版本,注意是接口地址中的版本号,例如:v2.1'}
+                      onChange={(value) => handleInputChange('other', value)}
+                      showClear
+                    />
+                  )}
+
+                  {inputs.type === 41 && (
+                    <Form.TextArea
+                      field='other'
+                      label={t('部署地区')}
+                      placeholder={t(
+                        '请输入部署地区,例如:us-central1\n支持使用模型映射格式\n{\n    "default": "us-central1",\n    "claude-3-5-sonnet-20240620": "europe-west1"\n}'
+                      )}
+                      autosize
+                      onChange={(value) => handleInputChange('other', value)}
+                      rules={[{ required: true, message: t('请填写部署地区') }]}
+                      extraText={
+                        <Text
+                          className="!text-semi-color-primary cursor-pointer"
+                          onClick={() => handleInputChange('other', JSON.stringify(REGION_EXAMPLE, null, 2))}
+                        >
+                          {t('填入模板')}
+                        </Text>
+                      }
+                      showClear
+                    />
+                  )}
+
+                  {inputs.type === 21 && (
+                    <Form.Input
+                      field='other'
+                      label={t('知识库 ID')}
+                      placeholder={'请输入知识库 ID,例如:123456'}
+                      onChange={(value) => handleInputChange('other', value)}
+                      showClear
+                    />
+                  )}
+
+                  {inputs.type === 39 && (
+                    <Form.Input
+                      field='other'
+                      label='Account ID'
+                      placeholder={'请输入Account ID,例如:d6b5da8hk1awo8nap34ube6gh'}
+                      onChange={(value) => handleInputChange('other', value)}
+                      showClear
+                    />
+                  )}
+
+                  {inputs.type === 49 && (
+                    <Form.Input
+                      field='other'
+                      label={t('智能体ID')}
+                      placeholder={'请输入智能体ID,例如:7342866812345'}
+                      onChange={(value) => handleInputChange('other', value)}
+                      showClear
+                    />
+                  )}
+
+                  {inputs.type === 1 && (
+                    <Form.Input
+                      field='openai_organization'
+                      label={t('组织')}
+                      placeholder={t('请输入组织org-xxx')}
+                      showClear
+                      helpText={t('组织,不填则为默认组织')}
+                      onChange={(value) => handleInputChange('openai_organization', value)}
+                    />
+                  )}
                 </Card>
 
                 {/* API Configuration Card */}
@@ -860,77 +1076,6 @@ const EditChannel = (props) => {
                     onChange={(value) => handleInputChange('groups', value)}
                   />
 
-                  {inputs.type === 18 && (
-                    <Form.Input
-                      field='other'
-                      label={t('模型版本')}
-                      placeholder={'请输入星火大模型版本,注意是接口地址中的版本号,例如:v2.1'}
-                      onChange={(value) => handleInputChange('other', value)}
-                      showClear
-                    />
-                  )}
-
-                  {inputs.type === 41 && (
-                    <Form.TextArea
-                      field='other'
-                      label={t('部署地区')}
-                      placeholder={t(
-                        '请输入部署地区,例如:us-central1\n支持使用模型映射格式\n{\n    "default": "us-central1",\n    "claude-3-5-sonnet-20240620": "europe-west1"\n}'
-                      )}
-                      autosize={{ minRows: 2 }}
-                      onChange={(value) => handleInputChange('other', value)}
-                      extraText={
-                        <Text
-                          className="!text-semi-color-primary cursor-pointer"
-                          onClick={() => handleInputChange('other', JSON.stringify(REGION_EXAMPLE, null, 2))}
-                        >
-                          {t('填入模板')}
-                        </Text>
-                      }
-                    />
-                  )}
-
-                  {inputs.type === 21 && (
-                    <Form.Input
-                      field='other'
-                      label={t('知识库 ID')}
-                      placeholder={'请输入知识库 ID,例如:123456'}
-                      onChange={(value) => handleInputChange('other', value)}
-                      showClear
-                    />
-                  )}
-
-                  {inputs.type === 39 && (
-                    <Form.Input
-                      field='other'
-                      label='Account ID'
-                      placeholder={'请输入Account ID,例如:d6b5da8hk1awo8nap34ube6gh'}
-                      onChange={(value) => handleInputChange('other', value)}
-                      showClear
-                    />
-                  )}
-
-                  {inputs.type === 49 && (
-                    <Form.Input
-                      field='other'
-                      label={t('智能体ID')}
-                      placeholder={'请输入智能体ID,例如:7342866812345'}
-                      onChange={(value) => handleInputChange('other', value)}
-                      showClear
-                    />
-                  )}
-
-                  {inputs.type === 1 && (
-                    <Form.Input
-                      field='openai_organization'
-                      label={t('组织')}
-                      placeholder={t('请输入组织org-xxx')}
-                      showClear
-                      helpText={t('组织,可选,不填则为默认组织')}
-                      onChange={(value) => handleInputChange('openai_organization', value)}
-                    />
-                  )}
-
                   <Form.Input
                     field='tag'
                     label={t('渠道标签')}