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:
t0ng7u
2025-07-07 01:12:01 +08:00
parent f0f277dc2a
commit 870cdd5a56
2 changed files with 241 additions and 87 deletions

View File

@@ -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}"
}

View File

@@ -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('渠道标签')}