diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json
index 80f7f3cd..a2657b31 100644
--- a/web/src/i18n/locales/en.json
+++ b/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}"
}
\ No newline at end of file
diff --git a/web/src/pages/Channel/EditChannel.js b/web/src/pages/Channel/EditChannel.js
index cfed54e4..a12777fc 100644
--- a/web/src/pages/Channel/EditChannel.js
+++ b/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 ? (
-