mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-07 01:56:53 +00:00
feat: advanced custom channel (#5590)
This commit is contained in:
+674
@@ -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>
|
||||
)
|
||||
}
|
||||
+82
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user