import { type ReactNode, useEffect, useState, useMemo, useCallback, useRef, } from 'react' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { useQuery, useQueryClient } from '@tanstack/react-query' import { ArrowRight, HelpCircle, Loader2, Sparkles, Trash2, Copy, FileText, Eraser, Plus, Eye, Link2, RefreshCw, ChevronDown, Code, Boxes, KeyRound, Route, Server, Settings, SlidersHorizontal, Wand2, } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { getLobeIcon } from '@/lib/lobe-icon' import { cn } from '@/lib/utils' import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' import { useHiddenClickUnlock } from '@/hooks/use-hidden-click-unlock' import { Alert, AlertDescription } from '@/components/ui/alert' import { Button } from '@/components/ui/button' import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from '@/components/ui/collapsible' import { Combobox } from '@/components/ui/combobox' import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from '@/components/ui/form' import { Input } from '@/components/ui/input' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, } from '@/components/ui/sheet' import { Skeleton } from '@/components/ui/skeleton' import { Switch } from '@/components/ui/switch' import { Textarea } from '@/components/ui/textarea' import { Tooltip, TooltipContent, TooltipTrigger, } from '@/components/ui/tooltip' import { JsonEditor } from '@/components/json-editor' import { MultiSelect } from '@/components/multi-select' import { SecureVerificationDialog, useSecureVerification, } from '@/features/auth/secure-verification' import { createChannel, fetchModels, getAllModels, getChannel, getChannelKey, getGroups, getPrefillGroups, refreshCodexCredential, updateChannel, } from '../../api' import { ADD_MODE_OPTIONS, CHANNEL_TYPE_OPTIONS, CHANNEL_TYPE_WARNINGS, ERROR_MESSAGES, FIELD_DESCRIPTIONS, FIELD_PLACEHOLDERS, MODEL_FETCHABLE_TYPES, SUCCESS_MESSAGES, } from '../../constants' import { CHANNEL_FORM_DEFAULT_VALUES, channelFormSchema, channelsQueryKeys, transformChannelToFormDefaults, transformFormDataToCreatePayload, transformFormDataToUpdatePayload, type ChannelFormValues, deduplicateKeys, getChannelTypeIcon, getKeyPromptForType, parseModelsString, formatModelsArray, extractRedirectModels, extractMappingSourceModels, hasModelConfigChanged, findMissingModelsInMapping, validateModelMappingJson, } from '../../lib' import { collectInvalidStatusCodeEntries, collectNewDisallowedStatusCodeRedirects, } from '../../lib/status-code-risk-guard' import type { Channel } from '../../types' import { useChannels } from '../channels-provider' import { CodexOAuthDialog } from '../dialogs/codex-oauth-dialog' import { FetchModelsDialog } from '../dialogs/fetch-models-dialog' import { MissingModelsConfirmationDialog, type MissingModelsAction, } from '../dialogs/missing-models-confirmation-dialog' import { ParamOverrideEditorDialog } from '../dialogs/param-override-editor-dialog' import { StatusCodeRiskDialog } from '../dialogs/status-code-risk-dialog' import { ModelMappingEditor } from '../model-mapping-editor' type ChannelMutateDrawerProps = { open: boolean onOpenChange: (open: boolean) => void currentRow?: Channel | null } type ModelMappingGuardrail = { invalidJson: boolean entries: Array<{ source: string; target: string }> missingSourceModels: string[] exposedTargetModels: string[] } function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null } function getErrorMessage(error: unknown): string | undefined { if (error instanceof Error && typeof error.message === 'string') { return error.message } if (!isRecord(error)) return undefined const response = error.response if (isRecord(response)) { const data = response.data if (isRecord(data)) { const message = data.message if (typeof message === 'string') return message } } const message = error.message if (typeof message === 'string') return message return undefined } // Helper functions const createEmptyModelMappingGuardrail = (): ModelMappingGuardrail => ({ invalidJson: false, entries: [], missingSourceModels: [], exposedTargetModels: [], }) const formatModelNames = (models: string[]): string => models.map((model) => `"${model}"`).join(', ') const MODEL_MAPPING_PREVIEW_FALLBACK: Array<{ source: string target: string }> = [{ source: 'client-model', target: 'upstream-model' }] const ADVANCED_SETTINGS_EXPANDED_KEY = 'channel-advanced-settings-expanded' const UPSTREAM_DETECTED_MODEL_PREVIEW_LIMIT = 8 function readAdvancedSettingsPreference(): boolean { if (typeof window === 'undefined') return false return window.localStorage.getItem(ADVANCED_SETTINGS_EXPANDED_KEY) === 'true' } function hasAdvancedSettingsValues(values: ChannelFormValues): boolean { return Boolean( values.model_mapping?.trim() || values.param_override?.trim() || values.header_override?.trim() || values.status_code_mapping?.trim() || values.tag?.trim() || values.remark?.trim() || values.priority || values.weight || values.proxy?.trim() || values.system_prompt?.trim() || values.force_format || values.thinking_to_content || values.pass_through_body_enabled || values.system_prompt_override || values.claude_beta_query || values.upstream_model_update_check_enabled || values.upstream_model_update_auto_sync_enabled || values.upstream_model_update_ignored_models?.trim() ) } function parseSettingsRecord( settings: string | undefined ): Record { if (!settings?.trim()) return {} try { const parsed = JSON.parse(settings) if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { return parsed as Record } } catch { return {} } return {} } function formatUnixTime(timestamp: unknown): string { const seconds = Number(timestamp) if (!Number.isFinite(seconds) || seconds <= 0) return '-' return new Date(seconds * 1000).toLocaleString() } function CardHeading({ title, icon }: { title: string; icon?: ReactNode }) { return (
{icon && ( {icon} )}

{title}

) } function SubHeading({ title, icon }: { title: string; icon?: ReactNode }) { return (
{icon && {icon}}

{title}

) } export function ChannelMutateDrawer({ open, onOpenChange, currentRow, }: ChannelMutateDrawerProps) { const { t } = useTranslation() const queryClient = useQueryClient() const { setOpen } = useChannels() const [isSubmitting, setIsSubmitting] = useState(false) const [customModel, setCustomModel] = useState('') const [isFetchingModels, setIsFetchingModels] = useState(false) const [fetchModelsDialogOpen, setFetchModelsDialogOpen] = useState(false) const [channelKey, setChannelKey] = useState(null) const [isChannelKeyLoading, setIsChannelKeyLoading] = useState(false) const [codexOAuthDialogOpen, setCodexOAuthDialogOpen] = useState(false) const [isCodexCredentialRefreshing, setIsCodexCredentialRefreshing] = useState(false) const initialModelsRef = useRef([]) const initialModelMappingRef = useRef('') const initialStatusCodeMappingRef = useRef('') const [statusCodeRiskOpen, setStatusCodeRiskOpen] = useState(false) const [statusCodeRiskDetailItems, setStatusCodeRiskDetailItems] = useState< string[] >([]) const statusCodeRiskResolveRef = useRef< ((confirmed: boolean) => void) | null >(null) const [missingModelsDialogOpen, setMissingModelsDialogOpen] = useState(false) const [missingModelsList, setMissingModelsList] = useState([]) const missingModelsResolveRef = useRef< ((action: MissingModelsAction) => void) | null >(null) const [advancedSettingsOpen, setAdvancedSettingsOpen] = useState(false) const [paramOverrideEditorOpen, setParamOverrideEditorOpen] = useState(false) const isEditing = Boolean(currentRow) const channelId = currentRow?.id ?? null // Fetch channel details if editing const { data: channelData } = useQuery({ queryKey: channelsQueryKeys.detail(currentRow?.id || 0), queryFn: () => getChannel(currentRow!.id), enabled: isEditing && Boolean(currentRow?.id), }) // Fetch available groups const { data: groupsData, isLoading: isLoadingGroups } = useQuery({ queryKey: ['groups'], queryFn: getGroups, }) // Fetch all available models const { data: allModelsData } = useQuery({ queryKey: ['channel_models'], queryFn: getAllModels, }) // Fetch prefill model groups const { data: prefillGroupsData } = useQuery({ queryKey: ['prefill_groups', 'model'], queryFn: () => getPrefillGroups('model'), }) const { copyToClipboard } = useCopyToClipboard() const { open: verificationOpen, methods: verificationMethods, state: verificationState, executeVerification, withVerification, cancel: cancelVerification, setCode: setVerificationCode, switchMethod: switchVerificationMethod, } = useSecureVerification() useEffect(() => { if (!open) { setChannelKey(null) setIsChannelKeyLoading(false) } else if (channelId) { setChannelKey(null) } }, [open, channelId]) // Check if this is a multi-key channel const isMultiKeyChannel = isEditing && channelData?.data?.channel_info?.is_multi_key === true // Form setup const form = useForm({ resolver: zodResolver(channelFormSchema), defaultValues: CHANNEL_FORM_DEFAULT_VALUES, }) // Watch form values for conditional rendering const multiKeyMode = form.watch('multi_key_mode') const multiKeyType = form.watch('multi_key_type') const keyMode = form.watch('key_mode') const currentGroups = form.watch('group') const currentType = form.watch('type') const currentBaseUrl = form.watch('base_url') const currentModels = form.watch('models') const currentModelMapping = form.watch('model_mapping') const awsKeyType = form.watch('aws_key_type') const upstreamModelUpdateCheckEnabled = form.watch( 'upstream_model_update_check_enabled' ) const currentSettings = form.watch('settings') const { unlocked: doubaoApiEditUnlocked, handleClick: handleApiConfigSecretClick, reset: resetDoubaoApiUnlock, } = useHiddenClickUnlock({ requiredClicks: 10, disabled: currentType !== 45, onUnlock: () => { toast.info(t('Doubao custom API address editing unlocked')) }, }) useEffect(() => { if (!open) { resetDoubaoApiUnlock() } }, [open, resetDoubaoApiUnlock]) // Helper computed values const isBatchMode = multiKeyMode === 'batch' || multiKeyMode === 'multi_to_single' // Get all models list const allModelsList = useMemo( () => allModelsData?.data?.map((model) => model.id).filter(Boolean) || [], [allModelsData] ) // Get basic models for the current channel type const basicModels = useMemo(() => { if (!allModelsList.length) return [] // Filter models based on common patterns for specific types if (currentType === 1) { return allModelsList.filter( (model) => model.startsWith('gpt-') || model.startsWith('text-') ) } return allModelsList }, [allModelsList, currentType]) // Get prefill groups const prefillGroups = useMemo( () => prefillGroupsData?.data || [], [prefillGroupsData] ) // Transform groups to multi-select options const groupOptions = useMemo(() => { if (!groupsData?.data) return [] const allGroups = new Set([...groupsData.data, ...(currentGroups || [])]) return Array.from(allGroups).map((group) => ({ value: group, label: group, })) }, [groupsData, currentGroups]) // Parse current models as array const currentModelsArray = useMemo( () => parseModelsString(currentModels), [currentModels] ) const currentTypeLabel = useMemo( () => CHANNEL_TYPE_OPTIONS.find((option) => option.value === currentType) ?.label || `#${currentType}`, [currentType] ) const channelTypeOptions = useMemo(() => { const options = CHANNEL_TYPE_OPTIONS.map((option) => ({ value: String(option.value), label: t(option.label), icon: getLobeIcon(`${getChannelTypeIcon(option.value)}.Color`, 16), })) if (!options.some((option) => Number(option.value) === currentType)) { options.push({ value: String(currentType), label: `#${currentType}`, icon: getLobeIcon(`${getChannelTypeIcon(currentType)}.Color`, 16), }) } return options }, [currentType, t]) // Extract redirect models from model_mapping (target values) const redirectModelList = useMemo( () => extractRedirectModels(currentModelMapping || ''), [currentModelMapping] ) // Extract source keys from model_mapping (models being remapped FROM) const redirectModelKeyList = useMemo( () => extractMappingSourceModels(currentModelMapping || ''), [currentModelMapping] ) // Transform models to multi-select options const modelOptions = useMemo(() => { const allModels = new Set([...allModelsList, ...currentModelsArray]) return Array.from(allModels).map((model) => ({ value: model, label: model, })) }, [allModelsList, currentModelsArray]) const modelMappingGuardrail = useMemo(() => { if (!currentModelMapping?.trim()) { return createEmptyModelMappingGuardrail() } try { const parsed = JSON.parse(currentModelMapping) if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { return { ...createEmptyModelMappingGuardrail(), invalidJson: true } } const entries = Object.entries(parsed).reduce< Array<{ source: string; target: string }> >((acc, [rawSource, rawTarget]) => { const source = String(rawSource).trim() const target = String(rawTarget ?? '').trim() if (!source || !target) { return acc } acc.push({ source, target }) return acc }, []) const missingSourceModels = Array.from( new Set( entries .filter( (entry) => Boolean(entry.source) && !currentModelsArray.includes(entry.source) ) .map((entry) => entry.source) ) ) const exposedTargetModels = Array.from( new Set( entries .filter( (entry) => Boolean(entry.target) && currentModelsArray.includes(entry.target) ) .map((entry) => entry.target) ) ) return { invalidJson: false, entries, missingSourceModels, exposedTargetModels, } } catch { return { ...createEmptyModelMappingGuardrail(), invalidJson: true } } }, [currentModelMapping, currentModelsArray]) const mappingPreviewPairs = modelMappingGuardrail.entries.length > 0 ? modelMappingGuardrail.entries.slice(0, 3) : MODEL_MAPPING_PREVIEW_FALLBACK const remainingMappingCount = modelMappingGuardrail.entries.length > 3 ? modelMappingGuardrail.entries.length - 3 : 0 const upstreamUpdateMeta = useMemo(() => { const settings = parseSettingsRecord(currentSettings) const detectedModels = Array.isArray( settings.upstream_model_update_last_detected_models ) ? settings.upstream_model_update_last_detected_models .map((model) => String(model || '').trim()) .filter(Boolean) : [] return { lastCheckTime: settings.upstream_model_update_last_check_time, detectedModels: Array.from(new Set(detectedModels)), } }, [currentSettings]) const upstreamDetectedModelsPreview = upstreamUpdateMeta.detectedModels.slice( 0, UPSTREAM_DETECTED_MODEL_PREVIEW_LIMIT ) const upstreamDetectedModelsOmittedCount = upstreamUpdateMeta.detectedModels.length - upstreamDetectedModelsPreview.length // Load channel data into form when editing useEffect(() => { if (isEditing && channelData?.data) { const defaults = transformChannelToFormDefaults(channelData.data) form.reset(defaults) setAdvancedSettingsOpen( readAdvancedSettingsPreference() || hasAdvancedSettingsValues(defaults) ) // Store initial values for comparison initialModelsRef.current = parseModelsString( channelData.data.models || '' ) initialModelMappingRef.current = channelData.data.model_mapping || '' initialStatusCodeMappingRef.current = channelData.data.status_code_mapping || '' } else if (!isEditing) { form.reset(CHANNEL_FORM_DEFAULT_VALUES) setAdvancedSettingsOpen(false) initialModelsRef.current = [] initialModelMappingRef.current = '' initialStatusCodeMappingRef.current = '' } }, [isEditing, channelData, form]) // Handle type change - set default values for specific types useEffect(() => { if (isEditing) return // Don't auto-set defaults when editing // Type 45 (VolcEngine) - set default base_url if (currentType === 45) { const currentBaseUrlValue = form.getValues('base_url') if (!currentBaseUrlValue || currentBaseUrlValue === '') { form.setValue('base_url', 'https://ark.cn-beijing.volces.com') } } // Type 18 (Xunfei) - set default other (version) if (currentType === 18) { const currentOther = form.getValues('other') if (!currentOther || currentOther === '') { form.setValue('other', 'v2.1') } } }, [currentType, isEditing, form]) // Validate base_url - warn if it ends with /v1 useEffect(() => { if (!currentBaseUrl || !currentBaseUrl.endsWith('/v1')) return // Show warning toast const timer = setTimeout(() => { toast.warning( t( 'Warning: Base URL should not end with /v1. New API will handle it automatically. This may cause request failures.' ), { duration: 5000 } ) }, 500) return () => clearTimeout(timer) // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentBaseUrl]) // Handle key deduplication const handleDeduplicateKeys = () => { const currentKey = form.getValues('key') if (!currentKey || currentKey.trim() === '') { toast.info(t('Please enter keys first')) return } const result = deduplicateKeys(currentKey) if (result.removedCount === 0) { toast.info(t('No duplicate keys found')) } else { form.setValue('key', result.deduplicatedText) toast.success( t( 'Removed {{removed}} duplicate key(s). Before: {{before}}, After: {{after}}', { removed: result.removedCount, before: result.beforeCount, after: result.afterCount, } ) ) } } const fetchChannelKey = useCallback(async () => { if (!channelId) { throw new Error('Channel is not selected') } setIsChannelKeyLoading(true) try { const res = await getChannelKey(channelId) if (!res.success) { throw new Error(res.message || 'Failed to fetch channel key') } const keyValue = res.data?.key ?? '' setChannelKey(keyValue) toast.success(t('Channel key unlocked')) return res } finally { setIsChannelKeyLoading(false) } }, [channelId, t]) const handleRevealKey = useCallback(async () => { if (!channelId) return try { await withVerification(fetchChannelKey, { preferredMethod: 'passkey', title: 'Verify to view channel key', description: 'Use Passkey or 2FA to confirm your identity before revealing this channel key.', }) } catch (error) { if (error instanceof Error) { toast.error(error.message) } } }, [channelId, withVerification, fetchChannelKey]) const handleRefreshCodexCredential = useCallback(async () => { if (!channelId) return setIsCodexCredentialRefreshing(true) try { const res = await refreshCodexCredential(channelId) if (!res.success) { throw new Error(res.message || 'Failed to refresh credential') } toast.success(t('Credential refreshed')) queryClient.invalidateQueries({ queryKey: channelsQueryKeys.detail(channelId), }) } catch (error) { toast.error(error instanceof Error ? error.message : t('Refresh failed')) } finally { setIsCodexCredentialRefreshing(false) } }, [channelId, queryClient, t]) // Unified function to update models const updateModels = useCallback( (newModels: string[], merge: boolean = false) => { const finalModels = merge ? formatModelsArray([...currentModelsArray, ...newModels]) : formatModelsArray(newModels) form.setValue('models', finalModels) return newModels.length }, [currentModelsArray, form] ) // Handle fetching models from upstream const handleFetchModels = useCallback(async () => { const type = form.getValues('type') if (!MODEL_FETCHABLE_TYPES.has(type)) { toast.error(t('This channel type does not support fetching models')) return } // For editing mode, open FetchModelsDialog to let user select if (isEditing && currentRow) { setFetchModelsDialogOpen(true) return } // For creation mode, fetch and fill all models const key = form.getValues('key') if (!key?.trim()) { toast.error(t('Please enter API key first')) return } setIsFetchingModels(true) try { const response = await fetchModels({ type, key, base_url: form.getValues('base_url') || '', }) if (response.success && response.data) { updateModels(response.data, true) toast.success( t('Fetched {{count}} model(s) from upstream', { count: response.data.length, }) ) } else { toast.error(t('No models fetched from upstream')) } } catch (error: unknown) { toast.error(getErrorMessage(error) || t('Failed to fetch models')) } finally { setIsFetchingModels(false) } }, [isEditing, currentRow, form, t, updateModels]) // Handle adding custom models const handleAddCustomModels = useCallback(() => { if (!customModel?.trim()) return const modelArray = parseModelsString(customModel) const count = updateModels(modelArray, true) setCustomModel('') toast.success(t('Added {{count}} custom model(s)', { count })) }, [customModel, t, updateModels]) // Handle model operations const handleFillRelatedModels = useCallback(() => { if (!basicModels.length) { toast.info(t('No related models available for this channel type')) return } updateModels(basicModels) toast.success( t('Filled {{count}} related model(s)', { count: basicModels.length }) ) }, [basicModels, updateModels, t]) const handleFillAllModels = useCallback(() => { if (!allModelsList.length) { toast.info(t('No models available')) return } updateModels(allModelsList) toast.success( t('Filled {{count}} model(s)', { count: allModelsList.length }) ) }, [allModelsList, updateModels, t]) const handleClearModels = useCallback(() => { form.setValue('models', '') toast.success(t('Cleared all models')) }, [form, t]) const handleCopyModels = useCallback(async () => { const models = form.getValues('models') if (!models?.trim()) { toast.info(t('No models to copy')) return } await copyToClipboard(models) }, [form, copyToClipboard, t]) // Handle adding prefill group models const handleAddPrefillGroup = useCallback( (group: { id: number; name: string; items: string | string[] }) => { try { const items = Array.isArray(group.items) ? group.items : JSON.parse(group.items) if (!Array.isArray(items)) { throw new Error('Invalid items format') } const count = updateModels(items, true) toast.success( t('Added {{count}} models from "{{name}}"', { count, name: group.name, }) ) } catch { toast.error(t('Failed to parse group items')) } }, [updateModels, t] ) // Handle model selection change from MultiSelect const handleModelsChange = useCallback( (selected: string[]) => { form.setValue('models', selected.join(',')) }, [form] ) // Handle successful submission const handleSuccess = useCallback(() => { queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() }) onOpenChange(false) setOpen(null) }, [queryClient, onOpenChange, setOpen]) // Show missing models confirmation dialog const confirmMissingModelMappings = useCallback( (missingModels: string[]): Promise => { return new Promise((resolve) => { setMissingModelsList(missingModels) setMissingModelsDialogOpen(true) missingModelsResolveRef.current = resolve }) }, [] ) // Handle missing models dialog action const handleMissingModelsAction = useCallback( (action: MissingModelsAction) => { setMissingModelsDialogOpen(false) if (missingModelsResolveRef.current) { missingModelsResolveRef.current(action) missingModelsResolveRef.current = null } }, [] ) const confirmStatusCodeRisk = useCallback( (detailItems: string[]): Promise => new Promise((resolve) => { statusCodeRiskResolveRef.current = resolve setStatusCodeRiskDetailItems(detailItems) setStatusCodeRiskOpen(true) }), [] ) const handleStatusCodeRiskAction = useCallback((confirmed: boolean) => { setStatusCodeRiskOpen(false) setStatusCodeRiskDetailItems([]) if (statusCodeRiskResolveRef.current) { statusCodeRiskResolveRef.current(confirmed) statusCodeRiskResolveRef.current = null } }, []) useEffect(() => { return () => { if (statusCodeRiskResolveRef.current) { statusCodeRiskResolveRef.current(false) statusCodeRiskResolveRef.current = null } } }, []) // Submit handler const onSubmit = useCallback( async (data: ChannelFormValues) => { // Validate key is required when creating if (!isEditing && !data.key?.trim()) { form.setError('key', { type: 'manual', message: 'API key is required', }) return } // Validate status_code_mapping entries if (data.status_code_mapping?.trim()) { const invalidEntries = collectInvalidStatusCodeEntries( data.status_code_mapping ) if (invalidEntries.length > 0) { toast.error( t('Invalid status code mapping entries: {{entries}}', { entries: invalidEntries.join(', '), }) ) return } const riskyRedirects = collectNewDisallowedStatusCodeRedirects( initialStatusCodeMappingRef.current, data.status_code_mapping ) if (riskyRedirects.length > 0) { const confirmed = await confirmStatusCodeRisk(riskyRedirects) if (!confirmed) return } } // Validate model_mapping JSON format const hasModelMapping = typeof data.model_mapping === 'string' && data.model_mapping.trim() !== '' if (hasModelMapping) { const validation = validateModelMappingJson(data.model_mapping!) if (!validation.valid) { toast.error(t(validation.error || 'Invalid model mapping')) return } } // Normalize models array const normalizedModels = parseModelsString(data.models || '') // Check for missing models in model_mapping if (hasModelMapping) { const missingModels = findMissingModelsInMapping( data.model_mapping!, normalizedModels ) const shouldPromptMissing = missingModels.length > 0 && hasModelConfigChanged( normalizedModels, data.model_mapping || '', initialModelsRef.current, initialModelMappingRef.current ) if (shouldPromptMissing) { const confirmAction = await confirmMissingModelMappings(missingModels) if (confirmAction === 'cancel') { return } if (confirmAction === 'add') { const updatedModels = Array.from( new Set([...normalizedModels, ...missingModels]) ) data.models = formatModelsArray(updatedModels) form.setValue('models', data.models) } } } setIsSubmitting(true) try { if (isEditing && currentRow) { // Update existing channel const payload = transformFormDataToUpdatePayload(data, currentRow.id) const payloadWithKeyMode = isMultiKeyChannel && data.key_mode ? { ...payload, key_mode: data.key_mode, } : payload const response = await updateChannel( currentRow.id, payloadWithKeyMode ) if (response.success) { toast.success(t(SUCCESS_MESSAGES.UPDATED)) handleSuccess() } } else { // Create new channel(s) const payload = transformFormDataToCreatePayload(data) const response = await createChannel(payload) if (response.success) { toast.success(t(SUCCESS_MESSAGES.CREATED)) handleSuccess() } } } catch (error: unknown) { toast.error(getErrorMessage(error) || t(ERROR_MESSAGES.CREATE_FAILED)) } finally { setIsSubmitting(false) } }, [ isEditing, currentRow, isMultiKeyChannel, form, handleSuccess, confirmMissingModelMappings, confirmStatusCodeRisk, t, ] ) // Handle drawer close const handleOpenChange = useCallback( (v: boolean) => { onOpenChange(v) if (!v) { form.reset(CHANNEL_FORM_DEFAULT_VALUES) setAdvancedSettingsOpen(false) } }, [onOpenChange, form] ) const handleAdvancedSettingsOpenChange = useCallback((nextOpen: boolean) => { setAdvancedSettingsOpen(nextOpen) if (typeof window !== 'undefined') { window.localStorage.setItem( ADVANCED_SETTINGS_EXPANDED_KEY, String(nextOpen) ) } }, []) return ( <> {getLobeIcon(`${getChannelTypeIcon(currentType)}.Color`, 22)} {isEditing ? t('Edit Channel') : t('Create Channel')} {t(currentTypeLabel)} {isEditing ? t( "Update channel configuration and click save when you're done." ) : t( 'Add a new channel by providing the necessary information.' )}
{/* ── Basic Information ── */}
} />
( {t('Name *')} )} /> ( {t('Type *')} { const nextType = Number(value) if (Number.isInteger(nextType) && nextType > 0) { field.onChange(nextType) } }} placeholder={t('Select channel type')} searchPlaceholder={t('Search channel type...')} emptyText={t('No channel type found.')} allowCustomValue /> )} />
(
{t('Enabled')} {t('Enable or disable this channel')}
field.onChange(checked ? 1 : 2) } />
)} /> {currentType === 1 && ( ( {t('OpenAI Organization')} {t(FIELD_DESCRIPTIONS.OPENAI_ORG)} )} /> )}
{/* ── API Access ── */}
} /> {CHANNEL_TYPE_WARNINGS[currentType] && ( {t(CHANNEL_TYPE_WARNINGS[currentType])} )} {/* Azure (type 3) */} {currentType === 3 && ( <> ( {t('AZURE_OPENAI_ENDPOINT *')} {t('Your Azure OpenAI endpoint URL')} )} /> ( {t('Default API Version *')} {t('Default API version for this channel')} )} /> ( {t('Responses API Version')} {t( 'Default Responses API version, if empty, will use the API version above' )} )} /> )} {/* Custom (type 8) */} {currentType === 8 && ( ( {t('Full Base URL (supports')} {'{'} {t('model')} {'}'} {t('variable) *')} {t('Enter the complete URL, supports')} {'{'} {t('model')} {'}'} {t('variable')} )} /> )} {/* Xunfei/Spark (type 18) */} {currentType === 18 && ( ( {t('Model Version *')} {t( 'Spark model version, e.g., v2.1 (version number in API URL)' )} )} /> )} {/* OpenRouter (type 20) */} {currentType === 20 && ( (
{t('Enterprise Account')} {t( 'Enable if this is an OpenRouter enterprise account with special response format' )}
)} /> )} {/* AWS (type 33) */} {currentType === 33 && ( ( {t('AWS Key Format')} {field.value === 'api_key' ? t('API Key mode: use APIKey|Region') : t( 'AK/SK mode: use AccessKey|SecretAccessKey|Region' )} )} /> )} {/* AI Proxy Library (type 21) */} {currentType === 21 && ( ( {t('Knowledge Base ID *')} {t('Enter the knowledge base ID')} )} /> )} {/* FastGPT (type 22) */} {currentType === 22 && ( ( {t('Private Deployment URL')} {t( 'For private deployments, format: https://fastgpt.run/api/openapi' )} )} /> )} {/* SunoAPI (type 36) */} {currentType === 36 && ( ( {t('API Base URL (Important: Not Chat API) *')} {t( 'Enter the path before /suno, usually just the domain' )} )} /> )} {/* Cloudflare Workers AI (type 39) */} {currentType === 39 && ( ( {t('Account ID *')} {t('Your Cloudflare Account ID')} )} /> )} {/* SiliconFlow (type 40) */} {currentType === 40 && ( {t('Referral link:')}{' '} {t('https://cloud.siliconflow.cn/i/hij0YNTZ')} )} {/* Vertex AI (type 41) */} {currentType === 41 && ( <> ( {t('Vertex AI Key Format')} {field.value === 'json' ? t( 'JSON format supports service account JSON files' ) : t( 'API Key mode (does not support batch creation)' )} )} /> {form.watch('vertex_key_type') === 'json' && ( {t('Service account JSON file(s)')} { const fileList = e.target.files const files = fileList ? Array.from(fileList) : [] // allow re-selecting the same file e.target.value = '' if (files.length === 0) { toast.info(t('Please upload key file(s)')) return } const keys: unknown[] = [] for (const file of files) { try { const txt = await file.text() keys.push(JSON.parse(txt)) } catch { toast.error( t('Failed to parse JSON file: {{name}}', { name: file.name, }) ) return } } if (keys.length === 0) { toast.info(t('Please upload key file(s)')) return } const keyValue = isBatchMode ? JSON.stringify(keys) : JSON.stringify(keys[0]) form.setValue('key', keyValue, { shouldDirty: true, shouldValidate: true, }) toast.success( t('Parsed {{count}} service account file(s)', { count: keys.length, }) ) }} /> {isBatchMode ? t('Upload multiple JSON files in batch modes') : t('Upload a single service account JSON file')} )} ( {t('Deployment Region *')}