✨ 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.
This commit is contained in:
@@ -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}"
|
||||
}
|
||||
@@ -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('渠道标签')}
|
||||
|
||||
Reference in New Issue
Block a user