feat: improve advanced custom route editor
This commit is contained in:
+205
-56
@@ -16,12 +16,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|||||||
|
|
||||||
For commercial licensing, please contact support@quantumnous.com
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
*/
|
*/
|
||||||
import { type ReactNode, useMemo, useState } from 'react'
|
import { type ReactNode, useMemo, useRef, useState } from 'react'
|
||||||
import { Check, Plus, Trash2 } from 'lucide-react'
|
import { ArrowRight, Check, Plus, Shuffle, Trash2 } from 'lucide-react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import {
|
import {
|
||||||
@@ -34,18 +33,26 @@ import {
|
|||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip'
|
||||||
import { Dialog } from '@/components/dialog'
|
import { Dialog } from '@/components/dialog'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
import {
|
import {
|
||||||
ADVANCED_CUSTOM_AUTH_MODE_OPTIONS,
|
ADVANCED_CUSTOM_AUTH_MODE_OPTIONS,
|
||||||
ADVANCED_CUSTOM_CONVERTER_OPTIONS,
|
ADVANCED_CUSTOM_CONVERTER_OPTIONS,
|
||||||
|
ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS,
|
||||||
ADVANCED_CUSTOM_TEMPLATE_OPTIONS,
|
ADVANCED_CUSTOM_TEMPLATE_OPTIONS,
|
||||||
type AdvancedCustomAuthMode,
|
type AdvancedCustomAuthMode,
|
||||||
buildAdvancedCustomAuth,
|
buildAdvancedCustomAuth,
|
||||||
createAdvancedCustomConfig,
|
createAdvancedCustomConfig,
|
||||||
createAdvancedCustomRoute,
|
createAdvancedCustomRoute,
|
||||||
getAdvancedCustomAuthMode,
|
getAdvancedCustomAuthMode,
|
||||||
|
getAdvancedCustomConverterOptions,
|
||||||
getAdvancedCustomIncomingPathLabel,
|
getAdvancedCustomIncomingPathLabel,
|
||||||
getAdvancedCustomIncomingPathOptions,
|
|
||||||
getAdvancedCustomTemplateConfig,
|
getAdvancedCustomTemplateConfig,
|
||||||
getAdvancedCustomUpstreamPathPlaceholder,
|
getAdvancedCustomUpstreamPathPlaceholder,
|
||||||
getDefaultAdvancedCustomIncomingPath,
|
getDefaultAdvancedCustomIncomingPath,
|
||||||
@@ -74,6 +81,10 @@ type AdvancedCustomEditMode = 'visual' | 'json'
|
|||||||
const longSelectContentClass = 'w-[360px] max-w-[calc(100vw-2rem)]'
|
const longSelectContentClass = 'w-[360px] max-w-[calc(100vw-2rem)]'
|
||||||
const longSelectItemClass =
|
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'
|
'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'
|
||||||
|
const routeEditorGridClassName =
|
||||||
|
'lg:grid-cols-[7rem_minmax(0,1.45fr)_minmax(0,1.35fr)_minmax(0,1fr)_minmax(0,0.85fr)_2rem]'
|
||||||
|
const upstreamPathDescriptionKey =
|
||||||
|
'Use a path to append it to the channel Base URL, or enter a full URL to override the Base URL for this route.'
|
||||||
|
|
||||||
function getOptionLabel(
|
function getOptionLabel(
|
||||||
options: ReadonlyArray<{ value: string; label: string }>,
|
options: ReadonlyArray<{ value: string; label: string }>,
|
||||||
@@ -89,9 +100,18 @@ export function AdvancedCustomEditorDialog({
|
|||||||
onSave,
|
onSave,
|
||||||
}: AdvancedCustomEditorDialogProps) {
|
}: AdvancedCustomEditorDialogProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const routeKeyCounterRef = useRef(0)
|
||||||
const [config, setConfig] = useState<AdvancedCustomConfig>(
|
const [config, setConfig] = useState<AdvancedCustomConfig>(
|
||||||
() => parseAdvancedCustomConfig(value) || createAdvancedCustomConfig()
|
() => parseAdvancedCustomConfig(value) || createAdvancedCustomConfig()
|
||||||
)
|
)
|
||||||
|
const [routeKeys, setRouteKeys] = useState<string[]>(() => {
|
||||||
|
const initialConfig =
|
||||||
|
parseAdvancedCustomConfig(value) || createAdvancedCustomConfig()
|
||||||
|
const normalized = normalizeAdvancedCustomConfig(initialConfig)
|
||||||
|
return (normalized.advanced_routes || []).map(
|
||||||
|
(_, routeIndex) => `advanced-custom-route-initial-${routeIndex}`
|
||||||
|
)
|
||||||
|
})
|
||||||
const [editMode, setEditMode] = useState<AdvancedCustomEditMode>('visual')
|
const [editMode, setEditMode] = useState<AdvancedCustomEditMode>('visual')
|
||||||
const [jsonText, setJsonText] = useState(() =>
|
const [jsonText, setJsonText] = useState(() =>
|
||||||
stringifyAdvancedCustomConfig(
|
stringifyAdvancedCustomConfig(
|
||||||
@@ -112,11 +132,28 @@ export function AdvancedCustomEditorDialog({
|
|||||||
[config]
|
[config]
|
||||||
)
|
)
|
||||||
const routes = normalizedConfig.advanced_routes || []
|
const routes = normalizedConfig.advanced_routes || []
|
||||||
|
const routeRows = routes.map((route, index) => ({
|
||||||
|
route,
|
||||||
|
routeKey:
|
||||||
|
routeKeys.at(index) ||
|
||||||
|
route.incoming_path ||
|
||||||
|
route.upstream_path ||
|
||||||
|
route.converter ||
|
||||||
|
'advanced-custom-route',
|
||||||
|
}))
|
||||||
const validationError = useMemo(
|
const validationError = useMemo(
|
||||||
() => validateAdvancedCustomConfig(normalizedConfig),
|
() => validateAdvancedCustomConfig(normalizedConfig),
|
||||||
[normalizedConfig]
|
[normalizedConfig]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const createRouteKey = () => {
|
||||||
|
routeKeyCounterRef.current += 1
|
||||||
|
return `advanced-custom-route-${routeKeyCounterRef.current}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const createRouteKeys = (count: number) =>
|
||||||
|
Array.from({ length: count }, () => createRouteKey())
|
||||||
|
|
||||||
const updateRoute = (index: number, patch: Partial<AdvancedCustomRoute>) => {
|
const updateRoute = (index: number, patch: Partial<AdvancedCustomRoute>) => {
|
||||||
setConfig((current) => {
|
setConfig((current) => {
|
||||||
const next = normalizeAdvancedCustomConfig(current)
|
const next = normalizeAdvancedCustomConfig(current)
|
||||||
@@ -137,6 +174,7 @@ export function AdvancedCustomEditorDialog({
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
setRouteKeys((current) => [...current, createRouteKey()])
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeRoute = (index: number) => {
|
const removeRoute = (index: number) => {
|
||||||
@@ -149,6 +187,9 @@ export function AdvancedCustomEditorDialog({
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
setRouteKeys((current) =>
|
||||||
|
current.filter((_, routeIndex) => routeIndex !== index)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const parseJsonEditorConfig = (): AdvancedCustomConfig | null => {
|
const parseJsonEditorConfig = (): AdvancedCustomConfig | null => {
|
||||||
@@ -171,7 +212,9 @@ export function AdvancedCustomEditorDialog({
|
|||||||
const switchToVisualMode = () => {
|
const switchToVisualMode = () => {
|
||||||
const parsed = parseJsonEditorConfig()
|
const parsed = parseJsonEditorConfig()
|
||||||
if (!parsed) return
|
if (!parsed) return
|
||||||
setConfig(parsed)
|
const normalized = normalizeAdvancedCustomConfig(parsed)
|
||||||
|
setConfig(normalized)
|
||||||
|
setRouteKeys(createRouteKeys(normalized.advanced_routes?.length || 0))
|
||||||
setEditMode('visual')
|
setEditMode('visual')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,6 +255,7 @@ export function AdvancedCustomEditorDialog({
|
|||||||
|
|
||||||
const normalized = normalizeAdvancedCustomConfig(nextConfig)
|
const normalized = normalizeAdvancedCustomConfig(nextConfig)
|
||||||
setConfig(normalized)
|
setConfig(normalized)
|
||||||
|
setRouteKeys(createRouteKeys(normalized.advanced_routes?.length || 0))
|
||||||
setJsonText(stringifyAdvancedCustomConfig(normalized))
|
setJsonText(stringifyAdvancedCustomConfig(normalized))
|
||||||
setJsonError('')
|
setJsonError('')
|
||||||
}
|
}
|
||||||
@@ -341,8 +385,8 @@ export function AdvancedCustomEditorDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{editMode === 'visual' ? (
|
{editMode === 'visual' ? (
|
||||||
<div className='flex flex-col gap-5 p-4'>
|
<div className='flex flex-col gap-4 p-4 lg:gap-3'>
|
||||||
<div className='flex justify-end border-y py-4'>
|
<div className='flex justify-end border-y py-4 lg:py-2'>
|
||||||
<Button
|
<Button
|
||||||
type='button'
|
type='button'
|
||||||
variant='outline'
|
variant='outline'
|
||||||
@@ -365,11 +409,28 @@ export function AdvancedCustomEditorDialog({
|
|||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className='flex flex-col gap-4'>
|
<p className='text-muted-foreground bg-muted/30 hidden rounded-md border px-3 py-2 text-xs leading-relaxed lg:block'>
|
||||||
{routes.map((route, index) => (
|
{t(upstreamPathDescriptionKey)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className='flex flex-col gap-4 lg:gap-2'>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground hidden items-center gap-2 px-3 text-xs font-medium lg:grid',
|
||||||
|
routeEditorGridClassName
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span>{t('Route')}</span>
|
||||||
|
<span>{t('Incoming path')}</span>
|
||||||
|
<span>{t('Upstream path')}</span>
|
||||||
|
<span>{t('Converter')}</span>
|
||||||
|
<span>{t('Auth')}</span>
|
||||||
|
<span aria-hidden='true' />
|
||||||
|
</div>
|
||||||
|
{routeRows.map((routeRow, index) => (
|
||||||
<RouteEditor
|
<RouteEditor
|
||||||
key={index}
|
key={routeRow.routeKey}
|
||||||
route={route}
|
route={routeRow.route}
|
||||||
index={index}
|
index={index}
|
||||||
onChange={(patch) => updateRoute(index, patch)}
|
onChange={(patch) => updateRoute(index, patch)}
|
||||||
onRemove={() => removeRoute(index)}
|
onRemove={() => removeRoute(index)}
|
||||||
@@ -429,14 +490,16 @@ function RouteEditor({
|
|||||||
const authMode = getAdvancedCustomAuthMode(route)
|
const authMode = getAdvancedCustomAuthMode(route)
|
||||||
const incomingPath =
|
const incomingPath =
|
||||||
route.incoming_path || getDefaultAdvancedCustomIncomingPath(converter)
|
route.incoming_path || getDefaultAdvancedCustomIncomingPath(converter)
|
||||||
const incomingPathOptions = useMemo(
|
const converterOptions = useMemo(
|
||||||
() => getAdvancedCustomIncomingPathOptions(converter),
|
() => getAdvancedCustomConverterOptions(incomingPath),
|
||||||
[converter]
|
[incomingPath]
|
||||||
)
|
)
|
||||||
const incomingPathLabel = getAdvancedCustomIncomingPathLabel(incomingPath)
|
const incomingPathLabel = getAdvancedCustomIncomingPathLabel(incomingPath)
|
||||||
const converterLabel =
|
const converterLabel =
|
||||||
getOptionLabel(ADVANCED_CUSTOM_CONVERTER_OPTIONS, converter)
|
getOptionLabel(ADVANCED_CUSTOM_CONVERTER_OPTIONS, converter)
|
||||||
const authLabel = getOptionLabel(ADVANCED_CUSTOM_AUTH_MODE_OPTIONS, authMode)
|
const authLabel = getOptionLabel(ADVANCED_CUSTOM_AUTH_MODE_OPTIONS, authMode)
|
||||||
|
const isNativeConverter = converter === 'none'
|
||||||
|
const ConverterVisualIcon = isNativeConverter ? ArrowRight : Shuffle
|
||||||
|
|
||||||
const setConverter = (nextConverter: AdvancedCustomConverter) => {
|
const setConverter = (nextConverter: AdvancedCustomConverter) => {
|
||||||
const patch: Partial<AdvancedCustomRoute> = { converter: nextConverter }
|
const patch: Partial<AdvancedCustomRoute> = { converter: nextConverter }
|
||||||
@@ -446,6 +509,18 @@ function RouteEditor({
|
|||||||
onChange(patch)
|
onChange(patch)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setIncomingPath = (nextIncomingPath: string | null) => {
|
||||||
|
const resolvedIncomingPath =
|
||||||
|
nextIncomingPath || getDefaultAdvancedCustomIncomingPath(converter)
|
||||||
|
const patch: Partial<AdvancedCustomRoute> = {
|
||||||
|
incoming_path: resolvedIncomingPath,
|
||||||
|
}
|
||||||
|
if (!isAdvancedCustomIncomingPathAllowed(resolvedIncomingPath, converter)) {
|
||||||
|
patch.converter = 'none'
|
||||||
|
}
|
||||||
|
onChange(patch)
|
||||||
|
}
|
||||||
|
|
||||||
const setAuthMode = (mode: AdvancedCustomAuthMode) => {
|
const setAuthMode = (mode: AdvancedCustomAuthMode) => {
|
||||||
onChange({ auth: buildAdvancedCustomAuth(mode, route.auth) })
|
onChange({ auth: buildAdvancedCustomAuth(mode, route.auth) })
|
||||||
}
|
}
|
||||||
@@ -467,36 +542,68 @@ function RouteEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='border-border flex flex-col gap-4 rounded-md border p-4'>
|
<div className='border-border flex flex-col gap-4 rounded-md border p-4 lg:gap-2 lg:p-3'>
|
||||||
<div className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
|
<div
|
||||||
<div className='min-w-0 space-y-2'>
|
className={cn(
|
||||||
<div className='flex flex-wrap items-center gap-2'>
|
'grid gap-4 md:grid-cols-2 lg:items-center lg:gap-2',
|
||||||
<div className='text-sm font-medium'>
|
routeEditorGridClassName
|
||||||
{t('Route')} {index + 1}
|
)}
|
||||||
|
>
|
||||||
|
<div className='flex min-w-0 items-start justify-between gap-3 md:col-span-2 lg:col-span-1'>
|
||||||
|
<div className='min-w-0 space-y-2 lg:space-y-1'>
|
||||||
|
<div className='flex flex-wrap items-center gap-2'>
|
||||||
|
<div className='text-sm font-medium'>
|
||||||
|
{t('Route')} {index + 1}
|
||||||
|
</div>
|
||||||
|
<TooltipProvider delay={100}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'border-border inline-flex size-7 shrink-0 items-center justify-center rounded-md border',
|
||||||
|
isNativeConverter
|
||||||
|
? 'bg-secondary text-secondary-foreground'
|
||||||
|
: 'bg-muted text-foreground'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ConverterVisualIcon
|
||||||
|
className='size-3.5'
|
||||||
|
aria-hidden='true'
|
||||||
|
/>
|
||||||
|
<span className='sr-only'>{t(converterLabel)}</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side='top'>{t(converterLabel)}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant='secondary'>{t(converterLabel)}</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='ghost'
|
||||||
|
size='icon'
|
||||||
|
className='lg:hidden'
|
||||||
|
onClick={onRemove}
|
||||||
|
>
|
||||||
|
<Trash2 className='h-4 w-4' />
|
||||||
|
<span className='sr-only'>{t('Delete')}</span>
|
||||||
|
</Button>
|
||||||
</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
|
||||||
<FieldBlock label={t('Incoming path')}>
|
label={t('Incoming path')}
|
||||||
|
className='lg:gap-1'
|
||||||
|
labelClassName='lg:sr-only'
|
||||||
|
>
|
||||||
<Select
|
<Select
|
||||||
value={incomingPath}
|
value={incomingPath}
|
||||||
onValueChange={(value) =>
|
onValueChange={setIncomingPath}
|
||||||
onChange({
|
|
||||||
incoming_path:
|
|
||||||
value || getDefaultAdvancedCustomIncomingPath(converter),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger className='w-full max-w-full'>
|
<SelectTrigger className='w-full max-w-full lg:h-8'>
|
||||||
<SelectValue className='min-w-0 truncate'>
|
<SelectValue className='min-w-0 truncate'>
|
||||||
{`${t(incomingPathLabel)} · ${incomingPath}`}
|
{`${incomingPathLabel}`}
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent
|
<SelectContent
|
||||||
@@ -504,14 +611,14 @@ function RouteEditor({
|
|||||||
className={longSelectContentClass}
|
className={longSelectContentClass}
|
||||||
>
|
>
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
{incomingPathOptions.map((option) => (
|
{ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS.map((option) => (
|
||||||
<SelectItem
|
<SelectItem
|
||||||
key={option.value}
|
key={option.value}
|
||||||
value={option.value}
|
value={option.value}
|
||||||
className={longSelectItemClass}
|
className={longSelectItemClass}
|
||||||
>
|
>
|
||||||
<div className='flex min-w-0 flex-col gap-1 whitespace-normal leading-snug'>
|
<div className='flex min-w-0 flex-col gap-1 whitespace-normal leading-snug'>
|
||||||
<span>{t(option.label)}</span>
|
<span>{option.label}</span>
|
||||||
<span className='text-muted-foreground break-all font-mono text-xs'>
|
<span className='text-muted-foreground break-all font-mono text-xs'>
|
||||||
{option.value}
|
{option.value}
|
||||||
</span>
|
</span>
|
||||||
@@ -523,7 +630,11 @@ function RouteEditor({
|
|||||||
</Select>
|
</Select>
|
||||||
</FieldBlock>
|
</FieldBlock>
|
||||||
|
|
||||||
<FieldBlock label={t('Upstream path')}>
|
<FieldBlock
|
||||||
|
label={t('Upstream path')}
|
||||||
|
className='lg:gap-1'
|
||||||
|
labelClassName='lg:sr-only'
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
value={route.upstream_path || ''}
|
value={route.upstream_path || ''}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@@ -533,23 +644,23 @@ function RouteEditor({
|
|||||||
}
|
}
|
||||||
placeholder={getAdvancedCustomUpstreamPathPlaceholder(converter)}
|
placeholder={getAdvancedCustomUpstreamPathPlaceholder(converter)}
|
||||||
/>
|
/>
|
||||||
<p className='text-muted-foreground text-xs leading-relaxed'>
|
<p className='text-muted-foreground text-xs leading-relaxed lg:hidden'>
|
||||||
{t(
|
{t(upstreamPathDescriptionKey)}
|
||||||
'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>
|
</p>
|
||||||
</FieldBlock>
|
</FieldBlock>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='grid gap-4 md:grid-cols-2'>
|
<FieldBlock
|
||||||
<FieldBlock label={t('Converter')}>
|
label={t('Converter')}
|
||||||
|
className='lg:gap-1'
|
||||||
|
labelClassName='lg:sr-only'
|
||||||
|
>
|
||||||
<Select
|
<Select
|
||||||
value={converter}
|
value={converter}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
setConverter(value as AdvancedCustomConverter)
|
setConverter(value as AdvancedCustomConverter)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className='w-full max-w-full'>
|
<SelectTrigger className='w-full max-w-full lg:h-8'>
|
||||||
<SelectValue className='min-w-0 truncate'>
|
<SelectValue className='min-w-0 truncate'>
|
||||||
{t(converterLabel)}
|
{t(converterLabel)}
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
@@ -559,7 +670,7 @@ function RouteEditor({
|
|||||||
className={longSelectContentClass}
|
className={longSelectContentClass}
|
||||||
>
|
>
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
{ADVANCED_CUSTOM_CONVERTER_OPTIONS.map((option) => (
|
{converterOptions.map((option) => (
|
||||||
<SelectItem
|
<SelectItem
|
||||||
key={option.value}
|
key={option.value}
|
||||||
value={option.value}
|
value={option.value}
|
||||||
@@ -575,14 +686,18 @@ function RouteEditor({
|
|||||||
</Select>
|
</Select>
|
||||||
</FieldBlock>
|
</FieldBlock>
|
||||||
|
|
||||||
<FieldBlock label={t('Auth')}>
|
<FieldBlock
|
||||||
|
label={t('Auth')}
|
||||||
|
className='lg:gap-1'
|
||||||
|
labelClassName='lg:sr-only'
|
||||||
|
>
|
||||||
<Select
|
<Select
|
||||||
value={authMode}
|
value={authMode}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
setAuthMode(value as AdvancedCustomAuthMode)
|
setAuthMode(value as AdvancedCustomAuthMode)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className='w-full max-w-full'>
|
<SelectTrigger className='w-full max-w-full lg:h-8'>
|
||||||
<SelectValue className='min-w-0 truncate'>
|
<SelectValue className='min-w-0 truncate'>
|
||||||
{t(authLabel)}
|
{t(authLabel)}
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
@@ -598,13 +713,34 @@ function RouteEditor({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</FieldBlock>
|
</FieldBlock>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='ghost'
|
||||||
|
size='icon'
|
||||||
|
className='hidden lg:inline-flex'
|
||||||
|
onClick={onRemove}
|
||||||
|
>
|
||||||
|
<Trash2 className='h-4 w-4' />
|
||||||
|
<span className='sr-only'>{t('Delete')}</span>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{authMode === 'header' || authMode === 'query' ? (
|
{authMode === 'header' || authMode === 'query' ? (
|
||||||
<>
|
<>
|
||||||
<Separator />
|
<Separator className='lg:hidden' />
|
||||||
<div className='grid gap-4 md:grid-cols-2'>
|
<div
|
||||||
<FieldBlock label={t('Auth name')}>
|
className={cn(
|
||||||
|
'grid gap-4 md:grid-cols-2 lg:items-end lg:gap-2 lg:border-t lg:pt-2',
|
||||||
|
routeEditorGridClassName
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className='hidden lg:block' aria-hidden='true' />
|
||||||
|
<FieldBlock
|
||||||
|
label={t('Auth name')}
|
||||||
|
className='lg:gap-1'
|
||||||
|
labelClassName='lg:text-xs'
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
value={route.auth?.name || ''}
|
value={route.auth?.name || ''}
|
||||||
onChange={(event) => updateAuth('name', event.target.value)}
|
onChange={(event) => updateAuth('name', event.target.value)}
|
||||||
@@ -613,7 +749,11 @@ function RouteEditor({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</FieldBlock>
|
</FieldBlock>
|
||||||
<FieldBlock label={t('Auth value')}>
|
<FieldBlock
|
||||||
|
label={t('Auth value')}
|
||||||
|
className='lg:gap-1'
|
||||||
|
labelClassName='lg:text-xs'
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
value={route.auth?.value || ''}
|
value={route.auth?.value || ''}
|
||||||
onChange={(event) => updateAuth('value', event.target.value)}
|
onChange={(event) => updateAuth('value', event.target.value)}
|
||||||
@@ -622,6 +762,9 @@ function RouteEditor({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</FieldBlock>
|
</FieldBlock>
|
||||||
|
<span className='hidden lg:block' aria-hidden='true' />
|
||||||
|
<span className='hidden lg:block' aria-hidden='true' />
|
||||||
|
<span className='hidden lg:block' aria-hidden='true' />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -631,14 +774,20 @@ function RouteEditor({
|
|||||||
|
|
||||||
function FieldBlock({
|
function FieldBlock({
|
||||||
label,
|
label,
|
||||||
|
className,
|
||||||
|
labelClassName,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
label: string
|
label: string
|
||||||
|
className?: string
|
||||||
|
labelClassName?: string
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className='flex min-w-0 flex-col gap-2'>
|
<div className={cn('flex min-w-0 flex-col gap-2', className)}>
|
||||||
<span className='text-sm font-medium'>{label}</span>
|
<span className={cn('text-sm font-medium', labelClassName)}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
+37
@@ -256,6 +256,7 @@ const ADVANCED_SETTINGS_SECTION_IDS = {
|
|||||||
const ADVANCED_SETTINGS_CHILD_SECTION_IDS: string[] = Object.values(
|
const ADVANCED_SETTINGS_CHILD_SECTION_IDS: string[] = Object.values(
|
||||||
ADVANCED_SETTINGS_SECTION_IDS
|
ADVANCED_SETTINGS_SECTION_IDS
|
||||||
)
|
)
|
||||||
|
const ADVANCED_CUSTOM_ROUTE_TYPE_PREVIEW_LIMIT = 3
|
||||||
const UPSTREAM_DETECTED_MODEL_PREVIEW_LIMIT = 8
|
const UPSTREAM_DETECTED_MODEL_PREVIEW_LIMIT = 8
|
||||||
const SENSITIVE_FORM_FIELDS = [
|
const SENSITIVE_FORM_FIELDS = [
|
||||||
'type',
|
'type',
|
||||||
@@ -776,6 +777,18 @@ export function ChannelMutateDrawer({
|
|||||||
() => getAdvancedCustomStats(currentAdvancedCustom),
|
() => getAdvancedCustomStats(currentAdvancedCustom),
|
||||||
[currentAdvancedCustom]
|
[currentAdvancedCustom]
|
||||||
)
|
)
|
||||||
|
const advancedCustomRouteTypeLabels =
|
||||||
|
advancedCustomStats.routeTypeLabels.slice(
|
||||||
|
0,
|
||||||
|
ADVANCED_CUSTOM_ROUTE_TYPE_PREVIEW_LIMIT
|
||||||
|
)
|
||||||
|
const hiddenAdvancedCustomRouteTypeCount =
|
||||||
|
advancedCustomStats.routeTypeLabels.length -
|
||||||
|
advancedCustomRouteTypeLabels.length
|
||||||
|
const advancedCustomRouteTypeTitle =
|
||||||
|
hiddenAdvancedCustomRouteTypeCount > 0
|
||||||
|
? advancedCustomStats.routeTypeLabels.join(', ')
|
||||||
|
: undefined
|
||||||
|
|
||||||
// Get all models list
|
// Get all models list
|
||||||
const allModelsList = useMemo(
|
const allModelsList = useMemo(
|
||||||
@@ -2647,6 +2660,30 @@ export function ChannelMutateDrawer({
|
|||||||
{t('Routes')}:{' '}
|
{t('Routes')}:{' '}
|
||||||
{advancedCustomStats.routeCount}
|
{advancedCustomStats.routeCount}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{advancedCustomRouteTypeLabels.map(
|
||||||
|
(label) => (
|
||||||
|
<Badge
|
||||||
|
key={label}
|
||||||
|
variant='outline'
|
||||||
|
className='max-w-[12rem]'
|
||||||
|
title={label}
|
||||||
|
>
|
||||||
|
<span className='truncate'>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{hiddenAdvancedCustomRouteTypeCount > 0 && (
|
||||||
|
<Badge
|
||||||
|
variant='outline'
|
||||||
|
title={
|
||||||
|
advancedCustomRouteTypeTitle
|
||||||
|
}
|
||||||
|
>
|
||||||
|
+{hiddenAdvancedCustomRouteTypeCount}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
{!advancedCustomStats.valid && (
|
{!advancedCustomStats.valid && (
|
||||||
<Badge variant='destructive'>
|
<Badge variant='destructive'>
|
||||||
{t('Incomplete')}
|
{t('Incomplete')}
|
||||||
|
|||||||
+46
-9
@@ -78,11 +78,7 @@ export const ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS: AdvancedCustomIncomingPathOp
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
value: '/v1/chat/completions',
|
value: '/v1/chat/completions',
|
||||||
label: 'OpenAI Chat Completions',
|
label: 'OpenAI Chat',
|
||||||
},
|
|
||||||
{
|
|
||||||
value: '/v1/completions',
|
|
||||||
label: 'OpenAI Completions',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: '/v1/responses',
|
value: '/v1/responses',
|
||||||
@@ -104,6 +100,10 @@ export const ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS: AdvancedCustomIncomingPathOp
|
|||||||
value: '/v1/images/edits',
|
value: '/v1/images/edits',
|
||||||
label: 'OpenAI Image Edits',
|
label: 'OpenAI Image Edits',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: '/v1/completions',
|
||||||
|
label: 'OpenAI Completions',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: '/v1/audio/speech',
|
value: '/v1/audio/speech',
|
||||||
label: 'OpenAI Audio Speech',
|
label: 'OpenAI Audio Speech',
|
||||||
@@ -142,6 +142,10 @@ export const ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS: AdvancedCustomIncomingPathOp
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const ADVANCED_CUSTOM_ROUTE_SUMMARY_LABELS: Record<string, string> = {
|
||||||
|
'/v1/chat/completions': 'OpenAI Chat',
|
||||||
|
}
|
||||||
|
|
||||||
export type AdvancedCustomValidationError = {
|
export type AdvancedCustomValidationError = {
|
||||||
message: string
|
message: string
|
||||||
routeIndex?: number
|
routeIndex?: number
|
||||||
@@ -357,8 +361,17 @@ export function isAdvancedCustomIncomingPathAllowed(
|
|||||||
incomingPath: string,
|
incomingPath: string,
|
||||||
converter: AdvancedCustomConverter
|
converter: AdvancedCustomConverter
|
||||||
): boolean {
|
): boolean {
|
||||||
return getAdvancedCustomIncomingPathOptions(converter).some(
|
return isConverterPathAllowed(incomingPath, converter)
|
||||||
(option) => option.value === incomingPath
|
}
|
||||||
|
|
||||||
|
export function getAdvancedCustomConverterOptions(
|
||||||
|
incomingPath: string
|
||||||
|
): typeof ADVANCED_CUSTOM_CONVERTER_OPTIONS {
|
||||||
|
const normalizedIncomingPath = incomingPath.trim()
|
||||||
|
return ADVANCED_CUSTOM_CONVERTER_OPTIONS.filter(
|
||||||
|
(option) =>
|
||||||
|
option.value === 'none' ||
|
||||||
|
isConverterPathAllowed(normalizedIncomingPath, option.value)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -483,15 +496,28 @@ export function advancedCustomConfigUsesRelativeUpstreamPath(
|
|||||||
export function getAdvancedCustomStats(value: string | undefined): {
|
export function getAdvancedCustomStats(value: string | undefined): {
|
||||||
routeCount: number
|
routeCount: number
|
||||||
valid: boolean
|
valid: boolean
|
||||||
|
routeTypeLabels: string[]
|
||||||
} {
|
} {
|
||||||
const config = parseAdvancedCustomConfig(value)
|
const config = parseAdvancedCustomConfig(value)
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return { routeCount: 0, valid: false }
|
return { routeCount: 0, valid: false, routeTypeLabels: [] }
|
||||||
}
|
}
|
||||||
const normalized = normalizeAdvancedCustomConfig(config)
|
const normalized = normalizeAdvancedCustomConfig(config)
|
||||||
|
const routes = normalized.advanced_routes || []
|
||||||
|
const routeTypeLabels: string[] = []
|
||||||
|
const seenRouteTypeLabels = new Set<string>()
|
||||||
|
|
||||||
|
for (const route of routes) {
|
||||||
|
const label = getAdvancedCustomRouteSummaryLabel(route)
|
||||||
|
if (!label || seenRouteTypeLabels.has(label)) continue
|
||||||
|
routeTypeLabels.push(label)
|
||||||
|
seenRouteTypeLabels.add(label)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
routeCount: normalized.advanced_routes?.length || 0,
|
routeCount: routes.length,
|
||||||
valid: validateAdvancedCustomConfig(normalized) === null,
|
valid: validateAdvancedCustomConfig(normalized) === null,
|
||||||
|
routeTypeLabels,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -543,6 +569,17 @@ function getAdvancedCustomRouteUpstreamPath(route: AdvancedCustomRoute): string
|
|||||||
return (route.upstream_path || '').trim()
|
return (route.upstream_path || '').trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAdvancedCustomRouteSummaryLabel(
|
||||||
|
route: AdvancedCustomRoute
|
||||||
|
): string | null {
|
||||||
|
const incomingPath = route.incoming_path?.trim() || ''
|
||||||
|
if (!incomingPath) return null
|
||||||
|
return (
|
||||||
|
ADVANCED_CUSTOM_ROUTE_SUMMARY_LABELS[incomingPath] ||
|
||||||
|
getAdvancedCustomIncomingPathLabel(incomingPath)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function isFullHttpURLOrAbsolutePath(value: string): boolean {
|
function isFullHttpURLOrAbsolutePath(value: string): boolean {
|
||||||
if (value.startsWith('/')) return !value.startsWith('//')
|
if (value.startsWith('/')) return !value.startsWith('//')
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user