/* Copyright (C) 2023-2026 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 { zodResolver } from '@hookform/resolvers/zod' import { useQuery, useQueryClient } from '@tanstack/react-query' import { ArrowRight, AlertCircle, Boxes, CheckCircle2, Circle, HelpCircle, KeyRound, Loader2, Server, Sparkles, Trash2, Copy, FileText, Eraser, Plus, Eye, RefreshCw, Code, Route, Settings, SlidersHorizontal, Wand2, } from 'lucide-react' import { type ReactNode, useEffect, useState, useMemo, useCallback, useRef, } from 'react' import { type SubmitErrorHandler, useForm } from 'react-hook-form' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { sideDrawerContentClassName, sideDrawerFooterClassName, sideDrawerFormClassName, sideDrawerHeaderClassName, sideDrawerSectionClassName, sideDrawerSwitchItemClassName, } from '@/components/drawer-layout' import { JsonEditor } from '@/components/json-editor' import { MultiSelect } from '@/components/multi-select' import { Alert, AlertDescription } from '@/components/ui/alert' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' 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, SelectGroup, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { Separator } from '@/components/ui/separator' 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 { SecureVerificationDialog, useSecureVerification, } from '@/features/auth/secure-verification' import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' import { useHiddenClickUnlock } from '@/hooks/use-hidden-click-unlock' import { ADMIN_PERMISSION_ACTIONS, ADMIN_PERMISSION_RESOURCES, hasPermission, } from '@/lib/admin-permissions' import { getLobeIcon } from '@/lib/lobe-icon' import { ROLE } from '@/lib/roles' import { cn } from '@/lib/utils' import { useAuthStore } from '@/stores/auth-store' import { fetchModels, getAllModels, getChannel, getChannelKey, getGroups, getPrefillGroups, refreshCodexCredential, } from '../../api' import { ADD_MODE_OPTIONS, CHANNEL_STATUS_LABELS, CHANNEL_TYPE_OPTIONS, CHANNEL_TYPE_WARNINGS, ERROR_MESSAGES, FIELD_DESCRIPTIONS, FIELD_PLACEHOLDERS, MODEL_FETCHABLE_TYPES, } from '../../constants' import { useChannelMutateForm } from '../../hooks/use-channel-mutate-form' import { CHANNEL_FORM_DEFAULT_VALUES, CHANNEL_TYPE_ADVANCED_CUSTOM, channelFormSchema, channelsQueryKeys, getAdvancedCustomStats, transformChannelToFormDefaults, type ChannelFormValues, deduplicateKeys, getChannelTypeIcon, getKeyPromptForType, parseModelsString, formatModelsArray, extractRedirectModels, extractMappingSourceModels, hasModelConfigChanged, findMissingModelsInMapping, validateModelMappingJson, hasAdvancedSettingsErrors, } from '../../lib' import { collectInvalidStatusCodeEntries, collectNewDisallowedStatusCodeRedirects, } from '../../lib/status-code-risk-guard' import type { Channel } from '../../types' import { useChannels } from '../channels-provider' import { AdvancedCustomEditorDialog } from '../dialogs/advanced-custom-editor-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' import { ChannelAdvancedSection, ChannelApiAccessSection, ChannelAuthSection, ChannelBasicSection, ChannelEditorLoadingState, ChannelModelsSection, } from './sections' 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[] } type ChannelEditorSectionStatus = 'complete' | 'configured' | 'error' | 'idle' type ChannelEditorNavChildItem = { id: string title: string configured?: boolean } type ChannelEditorNavItem = { id: string title: string description?: string statusLabel: string status: ChannelEditorSectionStatus icon: ReactNode configured?: boolean children?: ChannelEditorNavChildItem[] } // 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 CHANNEL_EDITOR_SECTION_IDS = { identity: 'channel-section-identity', credentials: 'channel-section-credentials', models: 'channel-section-models', advanced: 'channel-section-advanced', } as const const CHANNEL_EDITOR_MAIN_SECTION_IDS = [ CHANNEL_EDITOR_SECTION_IDS.identity, CHANNEL_EDITOR_SECTION_IDS.credentials, CHANNEL_EDITOR_SECTION_IDS.models, CHANNEL_EDITOR_SECTION_IDS.advanced, ] const ADVANCED_SETTINGS_SECTION_IDS = { routingStrategy: 'channel-section-advanced-routing-strategy', internalNotes: 'channel-section-advanced-internal-notes', overrideRules: 'channel-section-advanced-override-rules', extraSettings: 'channel-section-advanced-extra-settings', fieldPassthrough: 'channel-section-advanced-field-passthrough', upstreamModelDetection: 'channel-section-advanced-upstream-model-detection', } as const const ADVANCED_SETTINGS_CHILD_SECTION_IDS: string[] = Object.values( ADVANCED_SETTINGS_SECTION_IDS ) const ADVANCED_CUSTOM_ROUTE_TYPE_PREVIEW_LIMIT = 3 const UPSTREAM_DETECTED_MODEL_PREVIEW_LIMIT = 8 const SENSITIVE_FORM_FIELDS = [ 'type', 'base_url', 'key', 'openai_organization', 'other', 'key_mode', 'param_override', 'header_override', 'settings', 'setting', 'advanced_custom', 'is_enterprise_account', 'vertex_key_type', 'aws_key_type', 'azure_responses_version', 'force_format', 'thinking_to_content', 'proxy', 'pass_through_body_enabled', 'system_prompt', 'system_prompt_override', 'allow_service_tier', 'disable_store', 'allow_safety_identifier', 'allow_include_obfuscation', 'allow_inference_geo', 'allow_speed', 'claude_beta_query', 'disable_task_polling_sleep', 'upstream_model_update_check_enabled', 'upstream_model_update_auto_sync_enabled', 'upstream_model_update_ignored_models', ] satisfies (keyof ChannelFormValues)[] function readAdvancedSettingsPreference(): boolean { if (typeof window === 'undefined') return false return window.localStorage.getItem(ADVANCED_SETTINGS_EXPANDED_KEY) === 'true' } function hasConfiguredOverrideValue(value: unknown): boolean { if (typeof value !== 'string') return false const trimmed = value.trim() if (!trimmed || trimmed === 'null') return false try { const parsed = JSON.parse(trimmed) if (parsed === null) return false if (Array.isArray(parsed)) return parsed.length > 0 if (typeof parsed === 'object') return Object.keys(parsed).length > 0 } catch { return true } return true } function hasAdvancedSettingsValues(values: ChannelFormValues): boolean { return Boolean( hasConfiguredOverrideValue(values.param_override) || hasConfiguredOverrideValue(values.header_override) || values.advanced_custom?.trim() || hasConfiguredOverrideValue(values.status_code_mapping) || 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}

) } function configuredAdvancedSectionClassName( className: string, configured: boolean ) { return cn( className, 'border-border/60 rounded-lg border p-3 transition-colors', configured && 'border-primary/35 ring-primary/20 ring-1' ) } function ChannelTypeLogo(props: { type: number size?: number className?: string }) { const isKnownType = CHANNEL_TYPE_OPTIONS.some( (option) => option.value === props.type ) if (!isKnownType) { return (