feat: advanced custom channel (#5590)

This commit is contained in:
Seefs
2026-06-18 18:00:27 +08:00
committed by GitHub
parent 21d4d18dfc
commit 3f2c0aeda7
26 changed files with 3012 additions and 62 deletions
@@ -0,0 +1,674 @@
/*
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 <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { type ReactNode, useMemo, useState } from 'react'
import { Check, Plus, Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { Dialog } from '@/components/dialog'
import {
ADVANCED_CUSTOM_AUTH_MODE_OPTIONS,
ADVANCED_CUSTOM_CONVERTER_OPTIONS,
ADVANCED_CUSTOM_TEMPLATE_OPTIONS,
type AdvancedCustomAuthMode,
buildAdvancedCustomAuth,
createAdvancedCustomConfig,
createAdvancedCustomRoute,
getAdvancedCustomAuthMode,
getAdvancedCustomIncomingPathLabel,
getAdvancedCustomIncomingPathOptions,
getAdvancedCustomTemplateConfig,
getAdvancedCustomUpstreamPathPlaceholder,
getDefaultAdvancedCustomIncomingPath,
isAdvancedCustomIncomingPathAllowed,
normalizeAdvancedCustomConfig,
parseAdvancedCustomConfig,
stringifyAdvancedCustomConfig,
validateAdvancedCustomConfig,
} from '../../lib/advanced-custom'
import type {
AdvancedCustomAuthType,
AdvancedCustomConfig,
AdvancedCustomConverter,
AdvancedCustomRoute,
} from '../../types'
type AdvancedCustomEditorDialogProps = {
open: boolean
value: string
onOpenChange: (open: boolean) => void
onSave: (value: string) => void
}
type AdvancedCustomEditMode = 'visual' | 'json'
const longSelectContentClass = 'w-[360px] max-w-[calc(100vw-2rem)]'
const longSelectItemClass =
'items-start py-2 [&_[data-slot=select-item-text]]:min-w-0 [&_[data-slot=select-item-text]]:shrink [&_[data-slot=select-item-text]]:whitespace-normal'
function getOptionLabel(
options: ReadonlyArray<{ value: string; label: string }>,
value: string
) {
return options.find((option) => option.value === value)?.label || value
}
export function AdvancedCustomEditorDialog({
open,
value,
onOpenChange,
onSave,
}: AdvancedCustomEditorDialogProps) {
const { t } = useTranslation()
const [config, setConfig] = useState<AdvancedCustomConfig>(
() => parseAdvancedCustomConfig(value) || createAdvancedCustomConfig()
)
const [editMode, setEditMode] = useState<AdvancedCustomEditMode>('visual')
const [jsonText, setJsonText] = useState(() =>
stringifyAdvancedCustomConfig(
parseAdvancedCustomConfig(value) || createAdvancedCustomConfig()
)
)
const [jsonError, setJsonError] = useState('')
const [templateKey, setTemplateKey] = useState(
ADVANCED_CUSTOM_TEMPLATE_OPTIONS[0]?.value || ''
)
const templateLabel = useMemo(
() => getOptionLabel(ADVANCED_CUSTOM_TEMPLATE_OPTIONS, templateKey),
[templateKey]
)
const normalizedConfig = useMemo(
() => normalizeAdvancedCustomConfig(config),
[config]
)
const routes = normalizedConfig.advanced_routes || []
const validationError = useMemo(
() => validateAdvancedCustomConfig(normalizedConfig),
[normalizedConfig]
)
const updateRoute = (index: number, patch: Partial<AdvancedCustomRoute>) => {
setConfig((current) => {
const next = normalizeAdvancedCustomConfig(current)
const nextRoutes = [...(next.advanced_routes || [])]
nextRoutes[index] = { ...nextRoutes[index], ...patch }
return { ...next, advanced_routes: nextRoutes }
})
}
const addRoute = () => {
setConfig((current) => {
const next = normalizeAdvancedCustomConfig(current)
return {
...next,
advanced_routes: [
...(next.advanced_routes || []),
createAdvancedCustomRoute(),
],
}
})
}
const removeRoute = (index: number) => {
setConfig((current) => {
const next = normalizeAdvancedCustomConfig(current)
return {
...next,
advanced_routes: (next.advanced_routes || []).filter(
(_, routeIndex) => routeIndex !== index
),
}
})
}
const setFallbackEnabled = (enabled: boolean) => {
setConfig((current) => ({
...normalizeAdvancedCustomConfig(current),
advanced_fallback: { enabled },
}))
}
const parseJsonEditorConfig = (): AdvancedCustomConfig | null => {
const parsed = parseAdvancedCustomConfig(jsonText)
if (!parsed) {
setJsonError(t('Invalid JSON'))
return null
}
const error = validateAdvancedCustomConfig(parsed)
if (error) {
setJsonError(t(error.message))
return null
}
setJsonError('')
return parsed
}
const switchToVisualMode = () => {
const parsed = parseJsonEditorConfig()
if (!parsed) return
setConfig(parsed)
setEditMode('visual')
}
const switchToJsonMode = () => {
setJsonText(stringifyAdvancedCustomConfig(normalizedConfig))
setJsonError('')
setEditMode('json')
}
const handleJsonChange = (nextValue: string) => {
setJsonText(nextValue)
if (jsonError) setJsonError('')
}
const formatJson = () => {
const parsed = parseJsonEditorConfig()
if (!parsed) return
setJsonText(stringifyAdvancedCustomConfig(parsed))
}
const applyTemplate = (mode: 'fill' | 'append') => {
const templateConfig = getAdvancedCustomTemplateConfig(templateKey)
let nextConfig = templateConfig
if (mode === 'append') {
const baseConfig =
editMode === 'json' ? parseJsonEditorConfig() : normalizedConfig
if (!baseConfig) return
const base = normalizeAdvancedCustomConfig(baseConfig)
const template = normalizeAdvancedCustomConfig(templateConfig)
nextConfig = {
advanced_routes: [
...(base.advanced_routes || []),
...(template.advanced_routes || []),
],
advanced_fallback: {
enabled:
base.advanced_fallback?.enabled === true ||
template.advanced_fallback?.enabled === true,
},
}
}
const normalized = normalizeAdvancedCustomConfig(nextConfig)
setConfig(normalized)
setJsonText(stringifyAdvancedCustomConfig(normalized))
setJsonError('')
}
const saveConfig = () => {
if (editMode === 'json') {
const parsed = parseJsonEditorConfig()
if (!parsed) {
toast.error(t('Please fix JSON errors before saving'))
return
}
onSave(stringifyAdvancedCustomConfig(parsed))
onOpenChange(false)
return
}
if (validationError) {
toast.error(t(validationError.message))
return
}
onSave(stringifyAdvancedCustomConfig(normalizedConfig))
onOpenChange(false)
}
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Advanced Custom Routes')}
description={t('Advanced Custom')}
contentClassName='flex max-h-[90vh] flex-col gap-0 p-0 sm:max-w-5xl'
headerClassName='border-b px-6 py-4'
footerClassName='border-t px-6 py-4'
contentHeight='70vh'
footer={
<>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
>
{t('Cancel')}
</Button>
<Button type='button' onClick={saveConfig}>
<Check className='mr-2 h-4 w-4' />
{t('Save changes')}
</Button>
</>
}
>
<div className='bg-muted/30 border-b px-4 py-3'>
<div className='flex flex-wrap items-center gap-2'>
<span className='text-muted-foreground text-xs font-medium'>
{t('Mode')}
</span>
<Button
type='button'
variant={editMode === 'visual' ? 'default' : 'outline'}
size='sm'
onClick={switchToVisualMode}
>
{t('Visual')}
</Button>
<Button
type='button'
variant={editMode === 'json' ? 'default' : 'outline'}
size='sm'
onClick={switchToJsonMode}
>
{t('JSON Text')}
</Button>
<div className='bg-border mx-1 h-5 w-px' />
<span className='text-muted-foreground text-xs font-medium'>
{t('Template')}
</span>
<Select
value={templateKey}
onValueChange={(nextValue) =>
setTemplateKey(
nextValue || ADVANCED_CUSTOM_TEMPLATE_OPTIONS[0]?.value || ''
)
}
>
<SelectTrigger className='h-8 min-w-[260px] max-w-full flex-1 sm:w-[320px]'>
<SelectValue className='min-w-0 truncate'>
{t(templateLabel)}
</SelectValue>
</SelectTrigger>
<SelectContent
alignItemWithTrigger={false}
className={longSelectContentClass}
>
<SelectGroup>
{ADVANCED_CUSTOM_TEMPLATE_OPTIONS.map((option) => (
<SelectItem
key={option.value}
value={option.value}
className={longSelectItemClass}
>
<span className='min-w-0 whitespace-normal break-words leading-snug'>
{t(option.label)}
</span>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => applyTemplate('fill')}
>
{t('Fill Template')}
</Button>
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => applyTemplate('append')}
>
{t('Append Template')}
</Button>
</div>
</div>
{editMode === 'visual' ? (
<div className='flex flex-col gap-5 p-4'>
<div className='flex flex-col gap-3 border-y py-4 sm:flex-row sm:items-center sm:justify-between'>
<div className='flex items-center gap-3'>
<Switch
checked={normalizedConfig.advanced_fallback?.enabled === true}
onCheckedChange={setFallbackEnabled}
/>
<div className='space-y-1'>
<div className='text-sm font-medium'>
{t('Fallback routing')}
</div>
<div className='text-muted-foreground max-w-2xl text-xs leading-relaxed'>
{t(
'When enabled, requests that do not match any advanced route are forwarded to the channel base URL. When disabled, unmatched requests return an error.'
)}
</div>
</div>
</div>
<Button
type='button'
variant='outline'
size='sm'
onClick={addRoute}
>
<Plus className='mr-2 h-4 w-4' />
{t('Add route')}
</Button>
</div>
{validationError ? (
<Alert variant='destructive'>
<AlertDescription>
{validationError.routeIndex !== undefined
? `${t('Route')} ${validationError.routeIndex + 1}: `
: ''}
{t(validationError.message)}
</AlertDescription>
</Alert>
) : null}
<div className='flex flex-col gap-4'>
{routes.map((route, index) => (
<RouteEditor
key={index}
route={route}
index={index}
onChange={(patch) => updateRoute(index, patch)}
onRemove={() => removeRoute(index)}
/>
))}
</div>
</div>
) : (
<div className='p-4'>
<div className='mb-2 flex items-center gap-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={formatJson}
>
{t('Format')}
</Button>
<span className='text-muted-foreground text-xs'>
{t('Advanced text editing')}
</span>
</div>
<Textarea
value={jsonText}
onChange={(event) => handleJsonChange(event.target.value)}
placeholder={stringifyAdvancedCustomConfig(
getAdvancedCustomTemplateConfig(templateKey)
)}
rows={22}
className='min-h-[420px] font-mono text-xs'
/>
<p className='text-muted-foreground mt-2 text-xs'>
{t('Edit JSON text directly. Format will be validated on save.')}
</p>
{jsonError ? (
<p className='text-destructive mt-1 text-xs'>{jsonError}</p>
) : null}
</div>
)}
</Dialog>
)
}
function RouteEditor({
route,
index,
onChange,
onRemove,
}: {
route: AdvancedCustomRoute
index: number
onChange: (patch: Partial<AdvancedCustomRoute>) => void
onRemove: () => void
}) {
const { t } = useTranslation()
const converter = route.converter || 'none'
const authMode = getAdvancedCustomAuthMode(route)
const incomingPath =
route.incoming_path || getDefaultAdvancedCustomIncomingPath(converter)
const incomingPathOptions = useMemo(
() => getAdvancedCustomIncomingPathOptions(converter),
[converter]
)
const incomingPathLabel = getAdvancedCustomIncomingPathLabel(incomingPath)
const converterLabel =
getOptionLabel(ADVANCED_CUSTOM_CONVERTER_OPTIONS, converter)
const authLabel = getOptionLabel(ADVANCED_CUSTOM_AUTH_MODE_OPTIONS, authMode)
const setConverter = (nextConverter: AdvancedCustomConverter) => {
const patch: Partial<AdvancedCustomRoute> = { converter: nextConverter }
if (!isAdvancedCustomIncomingPathAllowed(incomingPath, nextConverter)) {
patch.incoming_path = getDefaultAdvancedCustomIncomingPath(nextConverter)
}
onChange(patch)
}
const setAuthMode = (mode: AdvancedCustomAuthMode) => {
onChange({ auth: buildAdvancedCustomAuth(mode, route.auth) })
}
const updateAuth = (
field: Exclude<keyof NonNullable<AdvancedCustomRoute['auth']>, 'type'>,
value: string
) => {
const currentAuth = route.auth
if (!currentAuth || currentAuth.type === 'none') return
onChange({
auth: {
type: currentAuth.type as AdvancedCustomAuthType,
name: currentAuth.name || '',
value: currentAuth.value || '',
[field]: value,
},
})
}
return (
<div className='border-border flex flex-col gap-4 rounded-md border p-4'>
<div className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
<div className='min-w-0 space-y-2'>
<div className='flex flex-wrap items-center gap-2'>
<div className='text-sm font-medium'>
{t('Route')} {index + 1}
</div>
<Badge variant='secondary'>{t(converterLabel)}</Badge>
</div>
</div>
<Button type='button' variant='ghost' size='icon' onClick={onRemove}>
<Trash2 className='h-4 w-4' />
<span className='sr-only'>{t('Delete')}</span>
</Button>
</div>
<div className='grid gap-4 md:grid-cols-2'>
<FieldBlock label={t('Incoming path')}>
<Select
value={incomingPath}
onValueChange={(value) =>
onChange({
incoming_path:
value || getDefaultAdvancedCustomIncomingPath(converter),
})
}
>
<SelectTrigger className='w-full max-w-full'>
<SelectValue className='min-w-0 truncate'>
{`${t(incomingPathLabel)} · ${incomingPath}`}
</SelectValue>
</SelectTrigger>
<SelectContent
alignItemWithTrigger={false}
className={longSelectContentClass}
>
<SelectGroup>
{incomingPathOptions.map((option) => (
<SelectItem
key={option.value}
value={option.value}
className={longSelectItemClass}
>
<div className='flex min-w-0 flex-col gap-1 whitespace-normal leading-snug'>
<span>{t(option.label)}</span>
<span className='text-muted-foreground break-all font-mono text-xs'>
{option.value}
</span>
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</FieldBlock>
<FieldBlock label={t('Upstream path')}>
<Input
value={route.upstream_path || ''}
onChange={(event) =>
onChange({
upstream_path: event.target.value,
})
}
placeholder={getAdvancedCustomUpstreamPathPlaceholder(converter)}
/>
<p className='text-muted-foreground text-xs leading-relaxed'>
{t(
'Use a path to append it to the channel Base URL, or enter a full URL to override the Base URL for this route.'
)}
</p>
</FieldBlock>
</div>
<div className='grid gap-4 md:grid-cols-2'>
<FieldBlock label={t('Converter')}>
<Select
value={converter}
onValueChange={(value) =>
setConverter(value as AdvancedCustomConverter)
}
>
<SelectTrigger className='w-full max-w-full'>
<SelectValue className='min-w-0 truncate'>
{t(converterLabel)}
</SelectValue>
</SelectTrigger>
<SelectContent
alignItemWithTrigger={false}
className={longSelectContentClass}
>
<SelectGroup>
{ADVANCED_CUSTOM_CONVERTER_OPTIONS.map((option) => (
<SelectItem
key={option.value}
value={option.value}
className={longSelectItemClass}
>
<span className='min-w-0 whitespace-normal break-words leading-snug'>
{t(option.label)}
</span>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</FieldBlock>
<FieldBlock label={t('Auth')}>
<Select
value={authMode}
onValueChange={(value) =>
setAuthMode(value as AdvancedCustomAuthMode)
}
>
<SelectTrigger className='w-full max-w-full'>
<SelectValue className='min-w-0 truncate'>
{t(authLabel)}
</SelectValue>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{ADVANCED_CUSTOM_AUTH_MODE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</FieldBlock>
</div>
{authMode === 'header' || authMode === 'query' ? (
<>
<Separator />
<div className='grid gap-4 md:grid-cols-2'>
<FieldBlock label={t('Auth name')}>
<Input
value={route.auth?.name || ''}
onChange={(event) => updateAuth('name', event.target.value)}
placeholder={
authMode === 'header' ? 'Authorization' : 'api_key'
}
/>
</FieldBlock>
<FieldBlock label={t('Auth value')}>
<Input
value={route.auth?.value || ''}
onChange={(event) => updateAuth('value', event.target.value)}
placeholder={
authMode === 'header' ? 'Bearer {api_key}' : '{api_key}'
}
/>
</FieldBlock>
</div>
</>
) : null}
</div>
)
}
function FieldBlock({
label,
children,
}: {
label: string
children: ReactNode
}) {
return (
<div className='flex min-w-0 flex-col gap-2'>
<span className='text-sm font-medium'>{label}</span>
{children}
</div>
)
}
@@ -125,8 +125,10 @@ import {
import { useChannelMutateForm } from '../../hooks/use-channel-mutate-form'
import {
CHANNEL_FORM_DEFAULT_VALUES,
CHANNEL_TYPE_ADVANCED_CUSTOM,
channelFormSchema,
channelsQueryKeys,
getAdvancedCustomStats,
transformChannelToFormDefaults,
type ChannelFormValues,
deduplicateKeys,
@@ -147,6 +149,7 @@ import {
} 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,
@@ -205,6 +208,7 @@ function hasAdvancedSettingsValues(values: ChannelFormValues): boolean {
return Boolean(
values.param_override?.trim() ||
values.header_override?.trim() ||
values.advanced_custom?.trim() ||
values.status_code_mapping?.trim() ||
values.tag?.trim() ||
values.remark?.trim() ||
@@ -298,6 +302,8 @@ export function ChannelMutateDrawer({
>(null)
const [advancedSettingsOpen, setAdvancedSettingsOpen] = useState(false)
const [paramOverrideEditorOpen, setParamOverrideEditorOpen] = useState(false)
const [advancedCustomEditorOpen, setAdvancedCustomEditorOpen] =
useState(false)
const isEditing = Boolean(currentRow)
const channelId = currentRow?.id ?? null
@@ -375,6 +381,7 @@ export function ChannelMutateDrawer({
'upstream_model_update_check_enabled'
)
const currentSettings = form.watch('settings')
const currentAdvancedCustom = form.watch('advanced_custom')
const {
unlocked: doubaoApiEditUnlocked,
handleClick: handleApiConfigSecretClick,
@@ -407,6 +414,11 @@ export function ChannelMutateDrawer({
[supportsMultiKeyAddMode]
)
const advancedCustomStats = useMemo(
() => getAdvancedCustomStats(currentAdvancedCustom),
[currentAdvancedCustom]
)
// Get all models list
const allModelsList = useMemo(
() => allModelsData?.data?.map((model) => model.id).filter(Boolean) || [],
@@ -1817,6 +1829,62 @@ export function ChannelMutateDrawer({
/>
)}
{currentType === CHANNEL_TYPE_ADVANCED_CUSTOM && (
<FormField
control={form.control}
name='advanced_custom'
render={({ field }) => (
<FormItem className='space-y-3 border-y py-4'>
<div className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
<div className='space-y-2'>
<FormLabel>
{t('Advanced Custom Routes')}
</FormLabel>
<div className='flex flex-wrap gap-2'>
<Badge variant='secondary'>
{t('Routes')}:{' '}
{advancedCustomStats.routeCount}
</Badge>
<Badge
variant={
advancedCustomStats.fallbackEnabled
? 'default'
: 'outline'
}
>
{t('Fallback')}:{' '}
{advancedCustomStats.fallbackEnabled
? t('Enabled')
: t('Disabled')}
</Badge>
{!advancedCustomStats.valid && (
<Badge variant='destructive'>
{t('Incomplete')}
</Badge>
)}
</div>
</div>
<Button
type='button'
variant='outline'
size='sm'
onClick={() =>
setAdvancedCustomEditorOpen(true)
}
>
<Route className='mr-2 h-4 w-4' />
{t('Configure routes')}
</Button>
</div>
<FormControl>
<input type='hidden' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<ChannelAuthSection>
{!isEditing && (
<FormField
@@ -3423,6 +3491,20 @@ export function ChannelMutateDrawer({
/>
)}
{advancedCustomEditorOpen && (
<AdvancedCustomEditorDialog
open={advancedCustomEditorOpen}
value={form.watch('advanced_custom') || ''}
onOpenChange={setAdvancedCustomEditorOpen}
onSave={(nextValue) => {
form.setValue('advanced_custom', nextValue, {
shouldDirty: true,
shouldValidate: true,
})
}}
/>
)}
{/* Fetch Models Dialog */}
<FetchModelsDialog
open={fetchModelsDialogOpen}