🐛 fix: multi-key channel sync and Vertex-AI key-upload edge cases
Backend
1. controller/channel.go
• Always hydrate `ChannelInfo` from DB in `UpdateChannel`, keeping `IsMultiKey` true so `MultiKeySize` is recalculated.
2. model/channel.go
• getKeys(): accept both newline-separated keys and JSON array (`[ {...}, {...} ]`).
• Update(): reuse new parser-logic to recalc `MultiKeySize`; prune stale indices in `MultiKeyStatusList`.
Frontend
1. pages/Channel/EditChannel.js
• `handleVertexUploadChange`
– Reset `vertexErroredNames` on every change so the “ignored files” prompt always re-appears.
– In single-key mode keep only the last file; in batch mode keep all valid files.
– Parse files, display “以下文件解析失败,已忽略:…”.
• Batch-toggle checkbox
– When switching from batch→single while multiple files are present, show a confirm dialog and retain only the first file (synchronises state, form and local caches).
• On opening the “new channel” side-sheet, clear `vertexErroredNames` to restore error prompts.
Result
• “已启用 x/x” count updates immediately after editing multi-key channels.
• Vertex-AI key upload works intuitively: proper error feedback, no duplicated files, and safe down-switch from batch to single mode.
This commit is contained in:
@@ -709,18 +709,22 @@ func UpdateChannel(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Preserve existing ChannelInfo to ensure multi-key channels keep correct state even if the client does not send ChannelInfo in the request.
|
||||||
|
originChannel, err := model.GetChannelById(channel.Id, false)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always copy the original ChannelInfo so that fields like IsMultiKey and MultiKeySize are retained.
|
||||||
|
channel.ChannelInfo = originChannel.ChannelInfo
|
||||||
|
|
||||||
|
// If the request explicitly specifies a new MultiKeyMode, apply it on top of the original info.
|
||||||
if channel.MultiKeyMode != nil && *channel.MultiKeyMode != "" {
|
if channel.MultiKeyMode != nil && *channel.MultiKeyMode != "" {
|
||||||
originChannel, err := model.GetChannelById(channel.Id, false)
|
channel.ChannelInfo.MultiKeyMode = constant.MultiKeyMode(*channel.MultiKeyMode)
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if originChannel.ChannelInfo.IsMultiKey {
|
|
||||||
channel.ChannelInfo = originChannel.ChannelInfo
|
|
||||||
channel.ChannelInfo.MultiKeyMode = constant.MultiKeyMode(*channel.MultiKeyMode)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
err = channel.Update()
|
err = channel.Update()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -71,7 +71,19 @@ func (channel *Channel) getKeys() []string {
|
|||||||
if channel.Key == "" {
|
if channel.Key == "" {
|
||||||
return []string{}
|
return []string{}
|
||||||
}
|
}
|
||||||
// use \n to split keys
|
trimmed := strings.TrimSpace(channel.Key)
|
||||||
|
// If the key starts with '[', try to parse it as a JSON array (e.g., for Vertex AI scenarios)
|
||||||
|
if strings.HasPrefix(trimmed, "[") {
|
||||||
|
var arr []json.RawMessage
|
||||||
|
if err := json.Unmarshal([]byte(trimmed), &arr); err == nil {
|
||||||
|
res := make([]string, len(arr))
|
||||||
|
for i, v := range arr {
|
||||||
|
res[i] = string(v)
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Otherwise, fall back to splitting by newline
|
||||||
keys := strings.Split(strings.Trim(channel.Key, "\n"), "\n")
|
keys := strings.Split(strings.Trim(channel.Key, "\n"), "\n")
|
||||||
return keys
|
return keys
|
||||||
}
|
}
|
||||||
@@ -396,23 +408,36 @@ func (channel *Channel) Insert() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (channel *Channel) Update() error {
|
func (channel *Channel) Update() error {
|
||||||
// 如果是多密钥渠道,则根据当前 key 列表重新计算 MultiKeySize,避免编辑密钥后数量未同步
|
// If this is a multi-key channel, recalculate MultiKeySize based on the current key list to avoid inconsistency after editing keys
|
||||||
if channel.ChannelInfo.IsMultiKey {
|
if channel.ChannelInfo.IsMultiKey {
|
||||||
var keyStr string
|
var keyStr string
|
||||||
if channel.Key != "" {
|
if channel.Key != "" {
|
||||||
keyStr = channel.Key
|
keyStr = channel.Key
|
||||||
} else {
|
} else {
|
||||||
// 如果当前未提供 key,读取数据库中的现有 key
|
// If key is not provided, read the existing key from the database
|
||||||
if existing, err := GetChannelById(channel.Id, true); err == nil {
|
if existing, err := GetChannelById(channel.Id, true); err == nil {
|
||||||
keyStr = existing.Key
|
keyStr = existing.Key
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Parse the key list (supports newline separation or JSON array)
|
||||||
keys := []string{}
|
keys := []string{}
|
||||||
if keyStr != "" {
|
if keyStr != "" {
|
||||||
keys = strings.Split(strings.Trim(keyStr, "\n"), "\n")
|
trimmed := strings.TrimSpace(keyStr)
|
||||||
|
if strings.HasPrefix(trimmed, "[") {
|
||||||
|
var arr []json.RawMessage
|
||||||
|
if err := json.Unmarshal([]byte(trimmed), &arr); err == nil {
|
||||||
|
keys = make([]string, len(arr))
|
||||||
|
for i, v := range arr {
|
||||||
|
keys[i] = string(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(keys) == 0 { // fallback to newline split
|
||||||
|
keys = strings.Split(strings.Trim(keyStr, "\n"), "\n")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
channel.ChannelInfo.MultiKeySize = len(keys)
|
channel.ChannelInfo.MultiKeySize = len(keys)
|
||||||
// 清理超过新 key 数量范围的状态数据,防止索引越界
|
// Clean up status data that exceeds the new key count to prevent index out of range
|
||||||
if channel.ChannelInfo.MultiKeyStatusList != nil {
|
if channel.ChannelInfo.MultiKeyStatusList != nil {
|
||||||
for idx := range channel.ChannelInfo.MultiKeyStatusList {
|
for idx := range channel.ChannelInfo.MultiKeyStatusList {
|
||||||
if idx >= channel.ChannelInfo.MultiKeySize {
|
if idx >= channel.ChannelInfo.MultiKeySize {
|
||||||
|
|||||||
@@ -987,7 +987,6 @@ const ChannelsTable = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// console.log('default effect')
|
|
||||||
const localIdSort = localStorage.getItem('id-sort') === 'true';
|
const localIdSort = localStorage.getItem('id-sort') === 'true';
|
||||||
const localPageSize =
|
const localPageSize =
|
||||||
parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE;
|
parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE;
|
||||||
|
|||||||
@@ -1770,10 +1770,15 @@
|
|||||||
"轮询": "Polling",
|
"轮询": "Polling",
|
||||||
"密钥文件 (.json)": "Key file (.json)",
|
"密钥文件 (.json)": "Key file (.json)",
|
||||||
"点击上传文件或拖拽文件到这里": "Click to upload file or drag and drop file here",
|
"点击上传文件或拖拽文件到这里": "Click to upload file or drag and drop file here",
|
||||||
|
"仅支持 JSON 文件": "Only JSON files are supported",
|
||||||
"仅支持 JSON 文件,支持多文件": "Only JSON files are supported, multiple files are supported",
|
"仅支持 JSON 文件,支持多文件": "Only JSON files are supported, multiple files are supported",
|
||||||
"请上传密钥文件": "Please upload the key file",
|
"请上传密钥文件": "Please upload the key file",
|
||||||
"请填写部署地区": "Please fill in the deployment region",
|
"请填写部署地区": "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}",
|
"请输入部署地区,例如: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}",
|
||||||
"其他": "Other",
|
"其他": "Other",
|
||||||
"未知渠道": "Unknown channel"
|
"未知渠道": "Unknown channel",
|
||||||
|
"切换为单密钥模式": "Switch to single key mode",
|
||||||
|
"将仅保留第一个密钥文件,其余文件将被移除,是否继续?": "Only the first key file will be retained, and the remaining files will be removed. Continue?",
|
||||||
|
"自定义模型名称": "Custom model name",
|
||||||
|
"启用全部密钥": "Enable all keys"
|
||||||
}
|
}
|
||||||
@@ -26,7 +26,6 @@ import {
|
|||||||
Form,
|
Form,
|
||||||
Row,
|
Row,
|
||||||
Col,
|
Col,
|
||||||
Upload,
|
|
||||||
} from '@douyinfe/semi-ui';
|
} from '@douyinfe/semi-ui';
|
||||||
import { getChannelModels, copy, getChannelIcon, getModelCategories } from '../../helpers';
|
import { getChannelModels, copy, getChannelIcon, getModelCategories } from '../../helpers';
|
||||||
import {
|
import {
|
||||||
@@ -424,9 +423,10 @@ const EditChannel = (props) => {
|
|||||||
}, [props.visible, channelId]);
|
}, [props.visible, channelId]);
|
||||||
|
|
||||||
const handleVertexUploadChange = ({ fileList }) => {
|
const handleVertexUploadChange = ({ fileList }) => {
|
||||||
|
vertexErroredNames.current.clear();
|
||||||
(async () => {
|
(async () => {
|
||||||
const validFiles = [];
|
let validFiles = [];
|
||||||
const keys = [];
|
let keys = [];
|
||||||
const errorNames = [];
|
const errorNames = [];
|
||||||
for (const item of fileList) {
|
for (const item of fileList) {
|
||||||
const fileObj = item.fileInstance;
|
const fileObj = item.fileInstance;
|
||||||
@@ -434,7 +434,7 @@ const EditChannel = (props) => {
|
|||||||
try {
|
try {
|
||||||
const txt = await fileObj.text();
|
const txt = await fileObj.text();
|
||||||
keys.push(JSON.parse(txt));
|
keys.push(JSON.parse(txt));
|
||||||
validFiles.push(item); // 仅合法文件加入列表
|
validFiles.push(item);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!vertexErroredNames.current.has(item.name)) {
|
if (!vertexErroredNames.current.has(item.name)) {
|
||||||
errorNames.push(item.name);
|
errorNames.push(item.name);
|
||||||
@@ -443,6 +443,12 @@ const EditChannel = (props) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 非批量模式下只保留一个文件(最新选择的),避免重复叠加
|
||||||
|
if (!batch && validFiles.length > 1) {
|
||||||
|
validFiles = [validFiles[validFiles.length - 1]];
|
||||||
|
keys = [keys[keys.length - 1]];
|
||||||
|
}
|
||||||
|
|
||||||
setVertexKeys(keys);
|
setVertexKeys(keys);
|
||||||
setVertexFileList(validFiles);
|
setVertexFileList(validFiles);
|
||||||
if (formApiRef.current) {
|
if (formApiRef.current) {
|
||||||
@@ -603,13 +609,45 @@ const EditChannel = (props) => {
|
|||||||
const batchAllowed = !isEdit || isMultiKeyChannel;
|
const batchAllowed = !isEdit || isMultiKeyChannel;
|
||||||
const batchExtra = batchAllowed ? (
|
const batchExtra = batchAllowed ? (
|
||||||
<Space>
|
<Space>
|
||||||
<Checkbox disabled={isEdit} checked={batch} onChange={() => {
|
<Checkbox
|
||||||
setBatch(!batch);
|
disabled={isEdit}
|
||||||
if (batch) {
|
checked={batch}
|
||||||
setMultiToSingle(false);
|
onChange={(e) => {
|
||||||
setMultiKeyMode('random');
|
const checked = e.target.checked;
|
||||||
}
|
|
||||||
}}>{t('批量创建')}</Checkbox>
|
if (!checked && vertexFileList.length > 1) {
|
||||||
|
Modal.confirm({
|
||||||
|
title: t('切换为单密钥模式'),
|
||||||
|
content: t('将仅保留第一个密钥文件,其余文件将被移除,是否继续?'),
|
||||||
|
onOk: () => {
|
||||||
|
const firstFile = vertexFileList[0];
|
||||||
|
const firstKey = vertexKeys[0] ? [vertexKeys[0]] : [];
|
||||||
|
|
||||||
|
setVertexFileList([firstFile]);
|
||||||
|
setVertexKeys(firstKey);
|
||||||
|
|
||||||
|
formApiRef.current?.setValue('vertex_files', [firstFile]);
|
||||||
|
setInputs((prev) => ({ ...prev, vertex_files: [firstFile] }));
|
||||||
|
|
||||||
|
setBatch(false);
|
||||||
|
setMultiToSingle(false);
|
||||||
|
setMultiKeyMode('random');
|
||||||
|
},
|
||||||
|
onCancel: () => {
|
||||||
|
setBatch(true);
|
||||||
|
},
|
||||||
|
centered: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBatch(checked);
|
||||||
|
if (!checked) {
|
||||||
|
setMultiToSingle(false);
|
||||||
|
setMultiKeyMode('random');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>{t('批量创建')}</Checkbox>
|
||||||
{batch && (
|
{batch && (
|
||||||
<Checkbox disabled={isEdit} checked={multiToSingle} onChange={() => {
|
<Checkbox disabled={isEdit} checked={multiToSingle} onChange={() => {
|
||||||
setMultiToSingle(prev => !prev);
|
setMultiToSingle(prev => !prev);
|
||||||
|
|||||||
Reference in New Issue
Block a user