/* Copyright (C) 2025 QuantumNous This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import React, { useEffect, useState, useRef, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { API, showError, showInfo, showSuccess, verifyJSON, } from '../../../../helpers'; import { useIsMobile } from '../../../../hooks/common/useIsMobile'; import { CHANNEL_OPTIONS } from '../../../../constants'; import { SideSheet, Space, Spin, Button, Typography, Checkbox, Banner, Modal, ImagePreview, Card, Tag, Avatar, Form, Row, Col, Highlight, Input, } from '@douyinfe/semi-ui'; import { getChannelModels, copy, getChannelIcon, getModelCategories, selectFilter, } from '../../../../helpers'; import ModelSelectModal from './ModelSelectModal'; import JSONEditor from '../../../common/ui/JSONEditor'; import SecureVerificationModal from '../../../common/modals/SecureVerificationModal'; import ChannelKeyDisplay from '../../../common/ui/ChannelKeyDisplay'; import { useSecureVerification } from '../../../../hooks/common/useSecureVerification'; import { createApiCalls } from '../../../../services/secureVerification'; import { IconSave, IconClose, IconServer, IconSetting, IconCode, IconGlobe, IconBolt, IconChevronUp, IconChevronDown, } from '@douyinfe/semi-icons'; const { Text, Title } = Typography; const MODEL_MAPPING_EXAMPLE = { 'gpt-3.5-turbo': 'gpt-3.5-turbo-0125', }; const STATUS_CODE_MAPPING_EXAMPLE = { 400: '500', }; const REGION_EXAMPLE = { default: 'global', 'gemini-1.5-pro-002': 'europe-west2', 'gemini-1.5-flash-002': 'europe-west2', 'claude-3-5-sonnet-20240620': 'europe-west1', }; // 支持并且已适配通过接口获取模型列表的渠道类型 const MODEL_FETCHABLE_TYPES = new Set([ 1, 4, 14, 34, 17, 26, 27, 24, 47, 25, 20, 23, 31, 35, 40, 42, 48, 43, ]); function type2secretPrompt(type) { // inputs.type === 15 ? '按照如下格式输入:APIKey|SecretKey' : (inputs.type === 18 ? '按照如下格式输入:APPID|APISecret|APIKey' : '请输入渠道对应的鉴权密钥') switch (type) { case 15: return '按照如下格式输入:APIKey|SecretKey'; case 18: return '按照如下格式输入:APPID|APISecret|APIKey'; case 22: return '按照如下格式输入:APIKey-AppId,例如:fastgpt-0sp2gtvfdgyi4k30jwlgwf1i-64f335d84283f05518e9e041'; case 23: return '按照如下格式输入:AppId|SecretId|SecretKey'; case 33: return '按照如下格式输入:Ak|Sk|Region'; case 45: return '请输入渠道对应的鉴权密钥, 豆包语音输入:AppId|AccessToken'; case 50: return '按照如下格式输入: AccessKey|SecretKey, 如果上游是New API,则直接输ApiKey'; case 51: return '按照如下格式输入: AccessKey|SecretAccessKey'; default: return '请输入渠道对应的鉴权密钥'; } } const EditChannelModal = (props) => { const { t } = useTranslation(); const channelId = props.editingChannel.id; const isEdit = channelId !== undefined; const [loading, setLoading] = useState(isEdit); const isMobile = useIsMobile(); const handleCancel = () => { props.handleClose(); }; const originInputs = { name: '', type: 1, key: '', openai_organization: '', max_input_tokens: 0, base_url: '', other: '', model_mapping: '', status_code_mapping: '', models: [], auto_ban: 1, test_model: '', groups: ['default'], priority: 0, weight: 0, tag: '', multi_key_mode: 'random', // 渠道额外设置的默认值 force_format: false, thinking_to_content: false, proxy: '', pass_through_body_enabled: false, system_prompt: '', system_prompt_override: false, settings: '', // 仅 Vertex: 密钥格式(存入 settings.vertex_key_type) vertex_key_type: 'json', // 仅 AWS: 密钥格式和区域(存入 settings.aws_key_type 和 settings.aws_region) aws_key_type: 'ak_sk', // 企业账户设置 is_enterprise_account: false, // 字段透传控制默认值 allow_service_tier: false, disable_store: false, // false = 允许透传(默认开启) allow_safety_identifier: false, }; const [batch, setBatch] = useState(false); const [multiToSingle, setMultiToSingle] = useState(false); const [multiKeyMode, setMultiKeyMode] = useState('random'); const [autoBan, setAutoBan] = useState(true); const [inputs, setInputs] = useState(originInputs); const [originModelOptions, setOriginModelOptions] = useState([]); const [modelOptions, setModelOptions] = useState([]); const [groupOptions, setGroupOptions] = useState([]); const [basicModels, setBasicModels] = useState([]); const [fullModels, setFullModels] = useState([]); const [modelGroups, setModelGroups] = useState([]); const [customModel, setCustomModel] = useState(''); const [modalImageUrl, setModalImageUrl] = useState(''); const [isModalOpenurl, setIsModalOpenurl] = useState(false); const [modelModalVisible, setModelModalVisible] = useState(false); const [fetchedModels, setFetchedModels] = useState([]); const formApiRef = useRef(null); const [vertexKeys, setVertexKeys] = useState([]); const [vertexFileList, setVertexFileList] = useState([]); const vertexErroredNames = useRef(new Set()); // 避免重复报错 const [isMultiKeyChannel, setIsMultiKeyChannel] = useState(false); const [channelSearchValue, setChannelSearchValue] = useState(''); const [useManualInput, setUseManualInput] = useState(false); // 是否使用手动输入模式 const [keyMode, setKeyMode] = useState('append'); // 密钥模式:replace(覆盖)或 append(追加) const [isEnterpriseAccount, setIsEnterpriseAccount] = useState(false); // 是否为企业账户 const [doubaoApiEditUnlocked, setDoubaoApiEditUnlocked] = useState(false); // 豆包渠道自定义 API 地址隐藏入口 const redirectModelList = useMemo(() => { const mapping = inputs.model_mapping; if (typeof mapping !== 'string') return []; const trimmed = mapping.trim(); if (!trimmed) return []; try { const parsed = JSON.parse(trimmed); if ( !parsed || typeof parsed !== 'object' || Array.isArray(parsed) ) { return []; } const values = Object.values(parsed) .map((value) => typeof value === 'string' ? value.trim() : undefined, ) .filter((value) => value); return Array.from(new Set(values)); } catch (error) { return []; } }, [inputs.model_mapping]); // 密钥显示状态 const [keyDisplayState, setKeyDisplayState] = useState({ showModal: false, keyData: '', }); // 专门的2FA验证状态(用于TwoFactorAuthModal) const [show2FAVerifyModal, setShow2FAVerifyModal] = useState(false); const [verifyCode, setVerifyCode] = useState(''); const [verifyLoading, setVerifyLoading] = useState(false); // 表单块导航相关状态 const formSectionRefs = useRef({ basicInfo: null, apiConfig: null, modelConfig: null, advancedSettings: null, channelExtraSettings: null, }); const [currentSectionIndex, setCurrentSectionIndex] = useState(0); const formSections = [ 'basicInfo', 'apiConfig', 'modelConfig', 'advancedSettings', 'channelExtraSettings', ]; const formContainerRef = useRef(null); const doubaoApiClickCountRef = useRef(0); const initialModelsRef = useRef([]); const initialModelMappingRef = useRef(''); // 2FA状态更新辅助函数 const updateTwoFAState = (updates) => { setTwoFAState((prev) => ({ ...prev, ...updates })); }; // 使用通用安全验证 Hook const { isModalVisible, verificationMethods, verificationState, withVerification, executeVerification, cancelVerification, setVerificationCode, switchVerificationMethod, } = useSecureVerification({ onSuccess: (result) => { // 验证成功后显示密钥 console.log('Verification success, result:', result); if (result && result.success && result.data?.key) { showSuccess(t('密钥获取成功')); setKeyDisplayState({ showModal: true, keyData: result.data.key, }); } else if (result && result.key) { // 直接返回了 key(没有包装在 data 中) showSuccess(t('密钥获取成功')); setKeyDisplayState({ showModal: true, keyData: result.key, }); } }, }); // 重置密钥显示状态 const resetKeyDisplayState = () => { setKeyDisplayState({ showModal: false, keyData: '', }); }; // 重置2FA验证状态 const reset2FAVerifyState = () => { setShow2FAVerifyModal(false); setVerifyCode(''); setVerifyLoading(false); }; // 表单导航功能 const scrollToSection = (sectionKey) => { const sectionElement = formSectionRefs.current[sectionKey]; if (sectionElement) { sectionElement.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest', }); } }; const navigateToSection = (direction) => { const availableSections = formSections.filter((section) => { if (section === 'apiConfig') { return showApiConfigCard; } return true; }); let newIndex; if (direction === 'up') { newIndex = currentSectionIndex > 0 ? currentSectionIndex - 1 : availableSections.length - 1; } else { newIndex = currentSectionIndex < availableSections.length - 1 ? currentSectionIndex + 1 : 0; } setCurrentSectionIndex(newIndex); scrollToSection(availableSections[newIndex]); }; const handleApiConfigSecretClick = () => { if (inputs.type !== 45) return; const next = doubaoApiClickCountRef.current + 1; doubaoApiClickCountRef.current = next; if (next >= 10) { setDoubaoApiEditUnlocked((unlocked) => { if (!unlocked) { showInfo(t('已解锁豆包自定义 API 地址编辑')); } return true; }); } }; // 渠道额外设置状态 const [channelSettings, setChannelSettings] = useState({ force_format: false, thinking_to_content: false, proxy: '', pass_through_body_enabled: false, system_prompt: '', }); const showApiConfigCard = true; // 控制是否显示 API 配置卡片 const getInitValues = () => ({ ...originInputs }); // 处理渠道额外设置的更新 const handleChannelSettingsChange = (key, value) => { // 更新内部状态 setChannelSettings((prev) => ({ ...prev, [key]: value })); // 同步更新到表单字段 if (formApiRef.current) { formApiRef.current.setValue(key, value); } // 同步更新inputs状态 setInputs((prev) => ({ ...prev, [key]: value })); // 生成setting JSON并更新 const newSettings = { ...channelSettings, [key]: value }; const settingsJson = JSON.stringify(newSettings); handleInputChange('setting', settingsJson); }; const handleChannelOtherSettingsChange = (key, value) => { // 更新内部状态 setChannelSettings((prev) => ({ ...prev, [key]: value })); // 同步更新到表单字段 if (formApiRef.current) { formApiRef.current.setValue(key, value); } // 同步更新inputs状态 setInputs((prev) => ({ ...prev, [key]: value })); // 需要更新settings,是一个json,例如{"azure_responses_version": "preview"} let settings = {}; if (inputs.settings) { try { settings = JSON.parse(inputs.settings); } catch (error) { console.error('解析设置失败:', error); } } settings[key] = value; const settingsJson = JSON.stringify(settings); handleInputChange('settings', settingsJson); }; const handleInputChange = (name, value) => { if (formApiRef.current) { formApiRef.current.setValue(name, value); } if (name === 'models' && Array.isArray(value)) { value = Array.from(new Set(value.map((m) => (m || '').trim()))); } if (name === 'base_url' && value.endsWith('/v1')) { Modal.confirm({ title: '警告', content: '不需要在末尾加/v1,New API会自动处理,添加后可能导致请求失败,是否继续?', onOk: () => { setInputs((inputs) => ({ ...inputs, [name]: value })); }, }); return; } setInputs((inputs) => ({ ...inputs, [name]: value })); if (name === 'type') { let localModels = []; switch (value) { case 2: localModels = [ 'mj_imagine', 'mj_variation', 'mj_reroll', 'mj_blend', 'mj_upscale', 'mj_describe', 'mj_uploads', ]; break; case 5: localModels = [ 'swap_face', 'mj_imagine', 'mj_video', 'mj_edits', 'mj_variation', 'mj_reroll', 'mj_blend', 'mj_upscale', 'mj_describe', 'mj_zoom', 'mj_shorten', 'mj_modal', 'mj_inpaint', 'mj_custom_zoom', 'mj_high_variation', 'mj_low_variation', 'mj_pan', 'mj_uploads', ]; break; case 36: localModels = ['suno_music', 'suno_lyrics']; break; case 45: localModels = getChannelModels(value); setInputs((prevInputs) => ({ ...prevInputs, base_url: 'https://ark.cn-beijing.volces.com', })); break; default: localModels = getChannelModels(value); break; } if (inputs.models.length === 0) { setInputs((inputs) => ({ ...inputs, models: localModels })); } setBasicModels(localModels); // 重置手动输入模式状态 setUseManualInput(false); } //setAutoBan }; const loadChannel = async () => { setLoading(true); let res = await API.get(`/api/channel/${channelId}`); if (res === undefined) { return; } const { success, message, data } = res.data; if (success) { if (data.models === '') { data.models = []; } else { data.models = data.models.split(','); } if (data.group === '') { data.groups = []; } else { data.groups = data.group.split(','); } if (data.model_mapping !== '') { data.model_mapping = JSON.stringify( JSON.parse(data.model_mapping), null, 2, ); } const chInfo = data.channel_info || {}; const isMulti = chInfo.is_multi_key === true; setIsMultiKeyChannel(isMulti); if (isMulti) { setBatch(true); setMultiToSingle(true); const modeVal = chInfo.multi_key_mode || 'random'; setMultiKeyMode(modeVal); data.multi_key_mode = modeVal; } else { setBatch(false); setMultiToSingle(false); } // 解析渠道额外设置并合并到data中 if (data.setting) { try { const parsedSettings = JSON.parse(data.setting); data.force_format = parsedSettings.force_format || false; data.thinking_to_content = parsedSettings.thinking_to_content || false; data.proxy = parsedSettings.proxy || ''; data.pass_through_body_enabled = parsedSettings.pass_through_body_enabled || false; data.system_prompt = parsedSettings.system_prompt || ''; data.system_prompt_override = parsedSettings.system_prompt_override || false; } catch (error) { console.error('解析渠道设置失败:', error); data.force_format = false; data.thinking_to_content = false; data.proxy = ''; data.pass_through_body_enabled = false; data.system_prompt = ''; data.system_prompt_override = false; } } else { data.force_format = false; data.thinking_to_content = false; data.proxy = ''; data.pass_through_body_enabled = false; data.system_prompt = ''; data.system_prompt_override = false; } if (data.settings) { try { const parsedSettings = JSON.parse(data.settings); data.azure_responses_version = parsedSettings.azure_responses_version || ''; // 读取 Vertex 密钥格式 data.vertex_key_type = parsedSettings.vertex_key_type || 'json'; // 读取 AWS 密钥格式和区域 data.aws_key_type = parsedSettings.aws_key_type || 'ak_sk'; // 读取企业账户设置 data.is_enterprise_account = parsedSettings.openrouter_enterprise === true; // 读取字段透传控制设置 data.allow_service_tier = parsedSettings.allow_service_tier || false; data.disable_store = parsedSettings.disable_store || false; data.allow_safety_identifier = parsedSettings.allow_safety_identifier || false; } catch (error) { console.error('解析其他设置失败:', error); data.azure_responses_version = ''; data.region = ''; data.vertex_key_type = 'json'; data.aws_key_type = 'ak_sk'; data.is_enterprise_account = false; data.allow_service_tier = false; data.disable_store = false; data.allow_safety_identifier = false; } } else { // 兼容历史数据:老渠道没有 settings 时,默认按 json 展示 data.vertex_key_type = 'json'; data.aws_key_type = 'ak_sk'; data.is_enterprise_account = false; data.allow_service_tier = false; data.disable_store = false; data.allow_safety_identifier = false; } if ( data.type === 45 && (!data.base_url || (typeof data.base_url === 'string' && data.base_url.trim() === '')) ) { data.base_url = 'https://ark.cn-beijing.volces.com'; } setInputs(data); if (formApiRef.current) { formApiRef.current.setValues(data); } if (data.auto_ban === 0) { setAutoBan(false); } else { setAutoBan(true); } // 同步企业账户状态 setIsEnterpriseAccount(data.is_enterprise_account || false); setBasicModels(getChannelModels(data.type)); // 同步更新channelSettings状态显示 setChannelSettings({ force_format: data.force_format, thinking_to_content: data.thinking_to_content, proxy: data.proxy, pass_through_body_enabled: data.pass_through_body_enabled, system_prompt: data.system_prompt, system_prompt_override: data.system_prompt_override || false, }); initialModelsRef.current = (data.models || []) .map((model) => (model || '').trim()) .filter(Boolean); initialModelMappingRef.current = data.model_mapping || ''; // console.log(data); } else { showError(message); } setLoading(false); }; const fetchUpstreamModelList = async (name) => { // if (inputs['type'] !== 1) { // showError(t('仅支持 OpenAI 接口格式')); // return; // } setLoading(true); const models = []; let err = false; if (isEdit) { // 如果是编辑模式,使用已有的 channelId 获取模型列表 const res = await API.get('/api/channel/fetch_models/' + channelId, { skipErrorHandler: true, }); if (res && res.data && res.data.success) { models.push(...res.data.data); } else { err = true; } } else { // 如果是新建模式,通过后端代理获取模型列表 if (!inputs?.['key']) { showError(t('请填写密钥')); err = true; } else { try { const res = await API.post( '/api/channel/fetch_models', { base_url: inputs['base_url'], type: inputs['type'], key: inputs['key'], }, { skipErrorHandler: true }, ); if (res && res.data && res.data.success) { models.push(...res.data.data); } else { err = true; } } catch (error) { console.error('Error fetching models:', error); err = true; } } } if (!err) { const uniqueModels = Array.from(new Set(models)); setFetchedModels(uniqueModels); setModelModalVisible(true); } else { showError(t('获取模型列表失败')); } setLoading(false); }; const fetchModels = async () => { try { let res = await API.get(`/api/channel/models`); const localModelOptions = res.data.data.map((model) => { const id = (model.id || '').trim(); return { key: id, label: id, value: id, }; }); setOriginModelOptions(localModelOptions); setFullModels(res.data.data.map((model) => model.id)); setBasicModels( res.data.data .filter((model) => { return model.id.startsWith('gpt-') || model.id.startsWith('text-'); }) .map((model) => model.id), ); } catch (error) { showError(error.message); } }; const fetchGroups = async () => { try { let res = await API.get(`/api/group/`); if (res === undefined) { return; } setGroupOptions( res.data.data.map((group) => ({ label: group, value: group, })), ); } catch (error) { showError(error.message); } }; const fetchModelGroups = async () => { try { const res = await API.get('/api/prefill_group?type=model'); if (res?.data?.success) { setModelGroups(res.data.data || []); } } catch (error) { // ignore } }; // 查看渠道密钥(透明验证) const handleShow2FAModal = async () => { try { // 使用 withVerification 包装,会自动处理需要验证的情况 const result = await withVerification( createApiCalls.viewChannelKey(channelId), { title: t('查看渠道密钥'), description: t('为了保护账户安全,请验证您的身份。'), preferredMethod: 'passkey', // 优先使用 Passkey }, ); // 如果直接返回了结果(已验证),显示密钥 if (result && result.success && result.data?.key) { showSuccess(t('密钥获取成功')); setKeyDisplayState({ showModal: true, keyData: result.data.key, }); } } catch (error) { console.error('Failed to view channel key:', error); showError(error.message || t('获取密钥失败')); } }; useEffect(() => { if (inputs.type !== 45) { doubaoApiClickCountRef.current = 0; setDoubaoApiEditUnlocked(false); } }, [inputs.type]); useEffect(() => { const modelMap = new Map(); originModelOptions.forEach((option) => { const v = (option.value || '').trim(); if (!modelMap.has(v)) { modelMap.set(v, option); } }); inputs.models.forEach((model) => { const v = (model || '').trim(); if (!modelMap.has(v)) { modelMap.set(v, { key: v, label: v, value: v, }); } }); const categories = getModelCategories(t); const optionsWithIcon = Array.from(modelMap.values()).map((opt) => { const modelName = opt.value; let icon = null; for (const [key, category] of Object.entries(categories)) { if (key !== 'all' && category.filter({ model_name: modelName })) { icon = category.icon; break; } } return { ...opt, label: ( {icon} {modelName} ), }; }); setModelOptions(optionsWithIcon); }, [originModelOptions, inputs.models, t]); useEffect(() => { fetchModels().then(); fetchGroups().then(); if (!isEdit) { setInputs(originInputs); if (formApiRef.current) { formApiRef.current.setValues(originInputs); } let localModels = getChannelModels(inputs.type); setBasicModels(localModels); setInputs((inputs) => ({ ...inputs, models: localModels })); } }, [props.editingChannel.id]); useEffect(() => { if (formApiRef.current) { formApiRef.current.setValues(inputs); } }, [inputs]); useEffect(() => { if (props.visible) { if (isEdit) { loadChannel(); } else { formApiRef.current?.setValues(getInitValues()); } fetchModelGroups(); // 重置手动输入模式状态 setUseManualInput(false); // 重置导航状态 setCurrentSectionIndex(0); } else { // 统一的模态框关闭重置逻辑 resetModalState(); } }, [props.visible, channelId]); useEffect(() => { if (!isEdit) { initialModelsRef.current = []; initialModelMappingRef.current = ''; } }, [isEdit, props.visible]); // 统一的模态框重置函数 const resetModalState = () => { formApiRef.current?.reset(); // 重置渠道设置状态 setChannelSettings({ force_format: false, thinking_to_content: false, proxy: '', pass_through_body_enabled: false, system_prompt: '', system_prompt_override: false, }); // 重置密钥模式状态 setKeyMode('append'); // 重置企业账户状态 setIsEnterpriseAccount(false); // 重置豆包隐藏入口状态 setDoubaoApiEditUnlocked(false); doubaoApiClickCountRef.current = 0; // 清空表单中的key_mode字段 if (formApiRef.current) { formApiRef.current.setValue('key_mode', undefined); } // 重置本地输入,避免下次打开残留上一次的 JSON 字段值 setInputs(getInitValues()); // 重置密钥显示状态 resetKeyDisplayState(); }; const handleVertexUploadChange = ({ fileList }) => { vertexErroredNames.current.clear(); (async () => { let validFiles = []; let 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); } } } // 非批量模式下只保留一个文件(最新选择的),避免重复叠加 if (!batch && validFiles.length > 1) { validFiles = [validFiles[validFiles.length - 1]]; keys = [keys[keys.length - 1]]; } 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 confirmMissingModelMappings = (missingModels) => new Promise((resolve) => { const modal = Modal.confirm({ title: t('模型未加入列表,可能无法调用'), content: (
{t( '模型重定向里的下列模型尚未添加到“模型”列表,调用时会因为缺少可用模型而失败:', )}
{missingModels.join(', ')}
{t( '你可以在“自定义模型名称”处手动添加它们,然后点击填入后再提交,或者直接使用下方操作自动处理。', )}
), centered: true, footer: ( ), }); }); const hasModelConfigChanged = (normalizedModels, modelMappingStr) => { if (!isEdit) return true; const initialModels = initialModelsRef.current; if (normalizedModels.length !== initialModels.length) { return true; } for (let i = 0; i < normalizedModels.length; i++) { if (normalizedModels[i] !== initialModels[i]) { return true; } } const normalizedMapping = (modelMappingStr || '').trim(); const initialMapping = (initialModelMappingRef.current || '').trim(); return normalizedMapping !== initialMapping; }; const submit = async () => { const formValues = formApiRef.current ? formApiRef.current.getValues() : {}; let localInputs = { ...formValues }; if (localInputs.type === 41) { const keyType = localInputs.vertex_key_type || 'json'; if (keyType === 'api_key') { // 直接作为普通字符串密钥处理 if (!isEdit && (!localInputs.key || localInputs.key.trim() === '')) { showInfo(t('请输入密钥!')); return; } } else { // JSON 服务账号密钥 if (useManualInput) { if (localInputs.key && localInputs.key.trim() !== '') { try { const parsedKey = JSON.parse(localInputs.key); localInputs.key = JSON.stringify(parsedKey); } catch (err) { showError(t('密钥格式无效,请输入有效的 JSON 格式密钥')); return; } } else if (!isEdit) { showInfo(t('请输入密钥!')); return; } } else { // 文件上传模式 let keys = vertexKeys; if (keys.length === 0 && vertexFileList.length > 0) { 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) { if (!isEdit) { showInfo(t('请上传密钥文件!')); return; } else { delete localInputs.key; } } else { localInputs.key = batch ? JSON.stringify(keys) : JSON.stringify(keys[0]); } } } } // 如果是编辑模式且 key 为空字符串,避免提交空值覆盖旧密钥 if (isEdit && (!localInputs.key || localInputs.key.trim() === '')) { delete localInputs.key; } delete localInputs.vertex_files; if (!isEdit && (!localInputs.name || !localInputs.key)) { showInfo(t('请填写渠道名称和渠道密钥!')); return; } if (!Array.isArray(localInputs.models) || localInputs.models.length === 0) { showInfo(t('请至少选择一个模型!')); return; } if ( localInputs.type === 45 && (!localInputs.base_url || localInputs.base_url.trim() === '') ) { showInfo(t('请输入API地址!')); return; } const hasModelMapping = typeof localInputs.model_mapping === 'string' && localInputs.model_mapping.trim() !== ''; let parsedModelMapping = null; if (hasModelMapping) { if (!verifyJSON(localInputs.model_mapping)) { showInfo(t('模型映射必须是合法的 JSON 格式!')); return; } try { parsedModelMapping = JSON.parse(localInputs.model_mapping); } catch (error) { showInfo(t('模型映射必须是合法的 JSON 格式!')); return; } } const normalizedModels = (localInputs.models || []) .map((model) => (model || '').trim()) .filter(Boolean); localInputs.models = normalizedModels; if ( parsedModelMapping && typeof parsedModelMapping === 'object' && !Array.isArray(parsedModelMapping) ) { const modelSet = new Set(normalizedModels); const missingModels = Object.keys(parsedModelMapping) .map((key) => (key || '').trim()) .filter((key) => key && !modelSet.has(key)); const shouldPromptMissing = missingModels.length > 0 && hasModelConfigChanged(normalizedModels, localInputs.model_mapping); if (shouldPromptMissing) { const confirmAction = await confirmMissingModelMappings(missingModels); if (confirmAction === 'cancel') { return; } if (confirmAction === 'add') { const updatedModels = Array.from( new Set([...normalizedModels, ...missingModels]), ); localInputs.models = updatedModels; handleInputChange('models', updatedModels); } } } if (localInputs.base_url && localInputs.base_url.endsWith('/')) { localInputs.base_url = localInputs.base_url.slice( 0, localInputs.base_url.length - 1, ); } if (localInputs.type === 18 && localInputs.other === '') { localInputs.other = 'v2.1'; } // 生成渠道额外设置JSON const channelExtraSettings = { force_format: localInputs.force_format || false, thinking_to_content: localInputs.thinking_to_content || false, proxy: localInputs.proxy || '', pass_through_body_enabled: localInputs.pass_through_body_enabled || false, system_prompt: localInputs.system_prompt || '', system_prompt_override: localInputs.system_prompt_override || false, }; localInputs.setting = JSON.stringify(channelExtraSettings); // 处理 settings 字段(包括企业账户设置和字段透传控制) let settings = {}; if (localInputs.settings) { try { settings = JSON.parse(localInputs.settings); } catch (error) { console.error('解析settings失败:', error); } } // type === 20: 设置企业账户标识,无论是true还是false都要传到后端 if (localInputs.type === 20) { settings.openrouter_enterprise = localInputs.is_enterprise_account === true; } // type === 33 (AWS): 保存 aws_key_type 到 settings if (localInputs.type === 33) { settings.aws_key_type = localInputs.aws_key_type || 'ak_sk'; } // type === 1 (OpenAI) 或 type === 14 (Claude): 设置字段透传控制(显式保存布尔值) if (localInputs.type === 1 || localInputs.type === 14) { settings.allow_service_tier = localInputs.allow_service_tier === true; // 仅 OpenAI 渠道需要 store 和 safety_identifier if (localInputs.type === 1) { settings.disable_store = localInputs.disable_store === true; settings.allow_safety_identifier = localInputs.allow_safety_identifier === true; } } localInputs.settings = JSON.stringify(settings); // 清理不需要发送到后端的字段 delete localInputs.force_format; delete localInputs.thinking_to_content; delete localInputs.proxy; delete localInputs.pass_through_body_enabled; delete localInputs.system_prompt; delete localInputs.system_prompt_override; delete localInputs.is_enterprise_account; // 顶层的 vertex_key_type 不应发送给后端 delete localInputs.vertex_key_type; // 顶层的 aws_key_type 不应发送给后端 delete localInputs.aws_key_type; // 清理字段透传控制的临时字段 delete localInputs.allow_service_tier; delete localInputs.disable_store; delete localInputs.allow_safety_identifier; let res; 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), key_mode: isMultiKeyChannel ? keyMode : undefined, // 只在多key模式下传递 }); } else { 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) { if (isEdit) { showSuccess(t('渠道更新成功!')); } else { showSuccess(t('渠道创建成功!')); setInputs(originInputs); } props.refresh(); props.handleClose(); } else { showError(message); } }; // 密钥去重函数 const deduplicateKeys = () => { const currentKey = formApiRef.current?.getValue('key') || inputs.key || ''; if (!currentKey.trim()) { showInfo(t('请先输入密钥')); return; } // 按行分割密钥 const keyLines = currentKey.split('\n'); const beforeCount = keyLines.length; // 使用哈希表去重,保持原有顺序 const keySet = new Set(); const deduplicatedKeys = []; keyLines.forEach((line) => { const trimmedLine = line.trim(); if (trimmedLine && !keySet.has(trimmedLine)) { keySet.add(trimmedLine); deduplicatedKeys.push(trimmedLine); } }); const afterCount = deduplicatedKeys.length; const deduplicatedKeyText = deduplicatedKeys.join('\n'); // 更新表单和状态 if (formApiRef.current) { formApiRef.current.setValue('key', deduplicatedKeyText); } handleInputChange('key', deduplicatedKeyText); // 显示去重结果 const message = t( '去重完成:去重前 {{before}} 个密钥,去重后 {{after}} 个密钥', { before: beforeCount, after: afterCount, }, ); if (beforeCount === afterCount) { showInfo(t('未发现重复密钥')); } else { showSuccess(message); } }; const addCustomModels = () => { if (customModel.trim() === '') return; const modelArray = customModel.split(',').map((model) => model.trim()); let localModels = [...inputs.models]; let localModelOptions = [...modelOptions]; const addedModels = []; modelArray.forEach((model) => { if (model && !localModels.includes(model)) { localModels.push(model); localModelOptions.push({ key: model, label: model, value: model, }); addedModels.push(model); } }); setModelOptions(localModelOptions); setCustomModel(''); handleInputChange('models', localModels); if (addedModels.length > 0) { showSuccess( t('已新增 {{count}} 个模型:{{list}}', { count: addedModels.length, list: addedModels.join(', '), }), ); } else { showInfo(t('未发现新增模型')); } }; const batchAllowed = !isEdit || isMultiKeyChannel; const batchExtra = batchAllowed ? ( {!isEdit && ( { const checked = e.target.checked; 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'); } else { // 批量模式下禁用手动输入,并清空手动输入的内容 setUseManualInput(false); if (inputs.type === 41) { // 清空手动输入的密钥内容 if (formApiRef.current) { formApiRef.current.setValue('key', ''); } handleInputChange('key', ''); } } }} > {t('批量创建')} )} {batch && ( <> { setMultiToSingle((prev) => { const nextValue = !prev; setInputs((prevInputs) => { const newInputs = { ...prevInputs }; if (nextValue) { newInputs.multi_key_mode = multiKeyMode; } else { delete newInputs.multi_key_mode; } return newInputs; }); return nextValue; }); }} > {t('密钥聚合模式')} {inputs.type !== 41 && ( )} )} ) : null; const channelOptionList = useMemo( () => CHANNEL_OPTIONS.map((opt) => ({ ...opt, // 保持 label 为纯文本以支持搜索 label: opt.label, })), [], ); const renderChannelOption = (renderProps) => { const { disabled, selected, label, value, focused, className, style, onMouseEnter, onClick, ...rest } = renderProps; const searchWords = channelSearchValue ? [channelSearchValue] : []; // 构建样式类名 const optionClassName = [ 'flex items-center gap-3 px-3 py-2 transition-all duration-200 rounded-lg mx-2 my-1', focused && 'bg-blue-50 shadow-sm', selected && 'bg-blue-100 text-blue-700 shadow-lg ring-2 ring-blue-200 ring-opacity-50', disabled && 'opacity-50 cursor-not-allowed', !disabled && 'hover:bg-gray-50 hover:shadow-md cursor-pointer', className, ] .filter(Boolean) .join(' '); return (
!disabled && onClick()} onMouseEnter={(e) => onMouseEnter()} >
{getChannelIcon(value)}
{selected && (
)}
); }; return ( <> {isEdit ? t('编辑') : t('新建')} {isEdit ? t('更新渠道信息') : t('创建新的渠道')} } bodyStyle={{ padding: '0' }} visible={props.visible} width={isMobile ? '100%' : 600} footer={
} closeIcon={null} onCancel={() => handleCancel()} >
(formApiRef.current = api)} onSubmit={submit} > {() => (
(formSectionRefs.current.basicInfo = el)}> {/* Header: Basic Info */}
{t('基本信息')}
{t('渠道的基本配置信息')}
setChannelSearchValue(value)} renderOptionItem={renderChannelOption} onChange={(value) => handleInputChange('type', value)} /> {inputs.type === 20 && ( { setIsEnterpriseAccount(value); handleInputChange('is_enterprise_account', value); }} extraText={t( '企业账户为特殊返回格式,需要特殊处理,如果非企业账户,请勿勾选', )} initValue={inputs.is_enterprise_account} /> )} handleInputChange('name', value)} autoComplete='new-password' /> {inputs.type === 33 && ( <> { handleChannelOtherSettingsChange('aws_key_type', value); }} extraText={t( 'AK/SK 模式:使用 AccessKey 和 SecretAccessKey;API Key 模式:使用 API Key', )} /> )} {inputs.type === 41 && ( { // 更新设置中的 vertex_key_type handleChannelOtherSettingsChange( 'vertex_key_type', value, ); // 切换为 api_key 时,关闭批量与手动/文件切换,并清理已选文件 if (value === 'api_key') { setBatch(false); setUseManualInput(false); setVertexKeys([]); setVertexFileList([]); if (formApiRef.current) { formApiRef.current.setValue('vertex_files', []); } } }} extraText={ inputs.vertex_key_type === 'api_key' ? t('API Key 模式下不支持批量创建') : t('JSON 模式支持手动输入或上传服务账号 JSON') } /> )} {batch ? ( inputs.type === 41 && (inputs.vertex_key_type || 'json') === 'json' ? ( } dragMainText={t('点击上传文件或拖拽文件到这里')} dragSubText={t('仅支持 JSON 文件,支持多文件')} style={{ marginTop: 10 }} uploadTrigger='custom' beforeUpload={() => false} onChange={handleVertexUploadChange} fileList={vertexFileList} rules={ isEdit ? [] : [ { required: true, message: t('请上传密钥文件'), }, ] } extraText={batchExtra} /> ) : ( handleInputChange('key', value)} extraText={
{isEdit && isMultiKeyChannel && keyMode === 'append' && ( {t( '追加模式:新密钥将添加到现有密钥列表的末尾', )} )} {isEdit && ( )} {batchExtra}
} showClear /> ) ) : ( <> {inputs.type === 41 && (inputs.vertex_key_type || 'json') === 'json' ? ( <> {!batch && (
{t('密钥输入方式')}
)} {batch && ( )} {useManualInput && !batch ? ( handleInputChange('key', value) } extraText={
{t('请输入完整的 JSON 格式密钥内容')} {isEdit && isMultiKeyChannel && keyMode === 'append' && ( {t( '追加模式:新密钥将添加到现有密钥列表的末尾', )} )} {isEdit && ( )} {batchExtra}
} autosize showClear /> ) : ( } dragMainText={t('点击上传文件或拖拽文件到这里')} dragSubText={t('仅支持 JSON 文件')} style={{ marginTop: 10 }} uploadTrigger='custom' beforeUpload={() => false} onChange={handleVertexUploadChange} fileList={vertexFileList} rules={ isEdit ? [] : [ { required: true, message: t('请上传密钥文件'), }, ] } extraText={batchExtra} /> )} ) : ( handleInputChange('key', value) } extraText={
{isEdit && isMultiKeyChannel && keyMode === 'append' && ( {t( '追加模式:新密钥将添加到现有密钥列表的末尾', )} )} {isEdit && ( )} {batchExtra}
} showClear /> )} )} {isEdit && isMultiKeyChannel && ( setKeyMode(value)} extraText={ {keyMode === 'replace' ? t('覆盖模式:将完全替换现有的所有密钥') : t('追加模式:将新密钥添加到现有密钥列表末尾')} } /> )} {batch && multiToSingle && ( <> { setMultiKeyMode(value); handleInputChange('multi_key_mode', value); }} /> {inputs.multi_key_mode === 'polling' && ( )} )} {inputs.type === 18 && ( handleInputChange('other', value)} showClear /> )} {inputs.type === 41 && ( handleInputChange('other', value)} rules={[ { required: true, message: t('请填写部署地区') }, ]} template={REGION_EXAMPLE} templateLabel={t('填入模板')} editorType='region' formApi={formApiRef.current} extraText={t('设置默认地区和特定模型的专用地区')} /> )} {inputs.type === 21 && ( handleInputChange('other', value)} showClear /> )} {inputs.type === 39 && ( handleInputChange('other', value)} showClear /> )} {inputs.type === 49 && ( handleInputChange('other', value)} showClear /> )} {inputs.type === 1 && ( handleInputChange('openai_organization', value) } /> )}
{/* API Configuration Card */} {showApiConfigCard && (
(formSectionRefs.current.apiConfig = el)}> {/* Header: API Config */}
{t('API 配置')}
{t('API 地址和相关配置')}
{inputs.type === 40 && ( {t('邀请链接')}: window.open( 'https://cloud.siliconflow.cn/i/hij0YNTZ', ) } > https://cloud.siliconflow.cn/i/hij0YNTZ
} className='!rounded-lg' /> )} {inputs.type === 3 && ( <>
handleInputChange('base_url', value) } showClear />
handleInputChange('other', value) } showClear />
handleChannelOtherSettingsChange( 'azure_responses_version', value, ) } showClear />
)} {inputs.type === 8 && ( <>
handleInputChange('base_url', value) } showClear />
)} {inputs.type === 37 && ( )} {inputs.type !== 3 && inputs.type !== 8 && inputs.type !== 22 && inputs.type !== 36 && (inputs.type !== 45 || doubaoApiEditUnlocked) && (
handleInputChange('base_url', value) } showClear extraText={t( '对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写', )} />
)} {inputs.type === 22 && (
handleInputChange('base_url', value) } showClear />
)} {inputs.type === 36 && (
handleInputChange('base_url', value) } showClear />
)} {inputs.type === 45 && !doubaoApiEditUnlocked && (
handleInputChange('base_url', value) } optionList={[ { value: 'https://ark.cn-beijing.volces.com', label: 'https://ark.cn-beijing.volces.com', }, { value: 'https://ark.ap-southeast.bytepluses.com', label: 'https://ark.ap-southeast.bytepluses.com', }, { value: 'doubao-coding-plan', label: 'Doubao Coding Plan', }, ]} defaultValue='https://ark.cn-beijing.volces.com' />
)}
)} {/* Model Configuration Card */}
(formSectionRefs.current.modelConfig = el)}> {/* Header: Model Config */}
{t('模型配置')}
{t('模型选择和映射设置')}
handleInputChange('models', value)} renderSelectedItem={(optionNode) => { const modelName = String(optionNode?.value ?? ''); return { isRenderInTag: true, content: ( { e.stopPropagation(); const ok = await copy(modelName); if (ok) { showSuccess( t('已复制:{{name}}', { name: modelName }), ); } else { showError(t('复制失败')); } }} > {optionNode.label || modelName} ), }; }} extraText={ {MODEL_FETCHABLE_TYPES.has(inputs.type) && ( )} {modelGroups && modelGroups.length > 0 && modelGroups.map((group) => ( ))} } /> setCustomModel(value.trim())} value={customModel} suffix={ } /> handleInputChange('test_model', value) } showClear /> handleInputChange('model_mapping', value) } template={MODEL_MAPPING_EXAMPLE} templateLabel={t('填入模板')} editorType='keyValue' formApi={formApiRef.current} extraText={t( '键为请求中的模型名称,值为要替换的模型名称', )} />
{/* Advanced Settings Card */}
(formSectionRefs.current.advancedSettings = el)} > {/* Header: Advanced Settings */}
{t('高级设置')}
{t('渠道的高级配置选项')}
handleInputChange('groups', value)} /> handleInputChange('tag', value)} /> handleInputChange('remark', value)} /> handleInputChange('priority', value) } style={{ width: '100%' }} /> handleInputChange('weight', value) } style={{ width: '100%' }} /> setAutoBan(value)} extraText={t( '仅当自动禁用开启时有效,关闭后不会自动禁用该渠道', )} initValue={autoBan} /> handleInputChange('param_override', value) } extraText={
handleInputChange( 'param_override', JSON.stringify({ temperature: 0 }, null, 2), ) } > {t('旧格式模板')} handleInputChange( 'param_override', JSON.stringify( { operations: [ { path: 'temperature', mode: 'set', value: 0.7, conditions: [ { path: 'model', mode: 'prefix', value: 'gpt', }, ], logic: 'AND', }, ], }, null, 2, ), ) } > {t('新格式模板')}
} showClear /> handleInputChange('header_override', value) } extraText={
handleInputChange( 'header_override', JSON.stringify( { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0', Authorization: 'Bearer{api_key}', }, null, 2, ), ) } > {t('填入模板')}
{t('支持变量:')}
{t('渠道密钥')}: {'{api_key}'}
} showClear /> handleInputChange('status_code_mapping', value) } template={STATUS_CODE_MAPPING_EXAMPLE} templateLabel={t('填入模板')} editorType='keyValue' formApi={formApiRef.current} extraText={t( '键为原状态码,值为要复写的状态码,仅影响本地判断', )} /> {/* 字段透传控制 - OpenAI 渠道 */} {inputs.type === 1 && ( <>
{t('字段透传控制')}
handleChannelOtherSettingsChange( 'allow_service_tier', value, ) } extraText={t( 'service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用', )} /> handleChannelOtherSettingsChange( 'disable_store', value, ) } extraText={t( 'store 字段用于授权 OpenAI 存储请求数据以评估和优化产品。默认关闭,开启后可能导致 Codex 无法正常使用', )} /> handleChannelOtherSettingsChange( 'allow_safety_identifier', value, ) } extraText={t( 'safety_identifier 字段用于帮助 OpenAI 识别可能违反使用政策的应用程序用户。默认关闭以保护用户隐私', )} /> )} {/* 字段透传控制 - Claude 渠道 */} {inputs.type === 14 && ( <>
{t('字段透传控制')}
handleChannelOtherSettingsChange( 'allow_service_tier', value, ) } extraText={t( 'service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用', )} /> )}
{/* Channel Extra Settings Card */}
(formSectionRefs.current.channelExtraSettings = el) } > {/* Header: Channel Extra Settings */}
{t('渠道额外设置')}
{inputs.type === 1 && ( handleChannelSettingsChange('force_format', value) } extraText={t( '强制将响应格式化为 OpenAI 标准格式(只适用于OpenAI渠道类型)', )} /> )} handleChannelSettingsChange( 'thinking_to_content', value, ) } extraText={t( '将 reasoning_content 转换为 标签拼接到内容中', )} /> handleChannelSettingsChange( 'pass_through_body_enabled', value, ) } extraText={t('启用请求体透传功能')} /> handleChannelSettingsChange('proxy', value) } showClear extraText={t('用于配置网络代理,支持 socks5 协议')} /> handleChannelSettingsChange('system_prompt', value) } autosize showClear extraText={t( '用户优先:如果用户在请求中指定了系统提示词,将优先使用用户的设置', )} /> handleChannelSettingsChange( 'system_prompt_override', value, ) } extraText={t( '如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面', )} />
)}
setIsModalOpenurl(visible)} />
{/* 使用通用安全验证模态框 */} {/* 使用ChannelKeyDisplay组件显示密钥 */}
{t('渠道密钥信息')} } visible={keyDisplayState.showModal} onCancel={resetKeyDisplayState} footer={ } width={700} style={{ maxWidth: '90vw' }} >
{ handleInputChange('models', selectedModels); showSuccess(t('模型列表已更新')); setModelModalVisible(false); }} onCancel={() => setModelModalVisible(false)} /> ); }; export default EditChannelModal;