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
|
||||
*/
|
||||
import { type ReactNode, useMemo, useState } from 'react'
|
||||
import { Check, Plus, Trash2 } from 'lucide-react'
|
||||
import { type ReactNode, useMemo, useRef, useState } from 'react'
|
||||
import { ArrowRight, Check, Plus, Shuffle, 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 {
|
||||
@@ -34,18 +33,26 @@ import {
|
||||
} from '@/components/ui/select'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
ADVANCED_CUSTOM_AUTH_MODE_OPTIONS,
|
||||
ADVANCED_CUSTOM_CONVERTER_OPTIONS,
|
||||
ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS,
|
||||
ADVANCED_CUSTOM_TEMPLATE_OPTIONS,
|
||||
type AdvancedCustomAuthMode,
|
||||
buildAdvancedCustomAuth,
|
||||
createAdvancedCustomConfig,
|
||||
createAdvancedCustomRoute,
|
||||
getAdvancedCustomAuthMode,
|
||||
getAdvancedCustomConverterOptions,
|
||||
getAdvancedCustomIncomingPathLabel,
|
||||
getAdvancedCustomIncomingPathOptions,
|
||||
getAdvancedCustomTemplateConfig,
|
||||
getAdvancedCustomUpstreamPathPlaceholder,
|
||||
getDefaultAdvancedCustomIncomingPath,
|
||||
@@ -74,6 +81,10 @@ 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'
|
||||
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(
|
||||
options: ReadonlyArray<{ value: string; label: string }>,
|
||||
@@ -89,9 +100,18 @@ export function AdvancedCustomEditorDialog({
|
||||
onSave,
|
||||
}: AdvancedCustomEditorDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const routeKeyCounterRef = useRef(0)
|
||||
const [config, setConfig] = useState<AdvancedCustomConfig>(
|
||||
() => 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 [jsonText, setJsonText] = useState(() =>
|
||||
stringifyAdvancedCustomConfig(
|
||||
@@ -112,11 +132,28 @@ export function AdvancedCustomEditorDialog({
|
||||
[config]
|
||||
)
|
||||
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(
|
||||
() => validateAdvancedCustomConfig(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>) => {
|
||||
setConfig((current) => {
|
||||
const next = normalizeAdvancedCustomConfig(current)
|
||||
@@ -137,6 +174,7 @@ export function AdvancedCustomEditorDialog({
|
||||
],
|
||||
}
|
||||
})
|
||||
setRouteKeys((current) => [...current, createRouteKey()])
|
||||
}
|
||||
|
||||
const removeRoute = (index: number) => {
|
||||
@@ -149,6 +187,9 @@ export function AdvancedCustomEditorDialog({
|
||||
),
|
||||
}
|
||||
})
|
||||
setRouteKeys((current) =>
|
||||
current.filter((_, routeIndex) => routeIndex !== index)
|
||||
)
|
||||
}
|
||||
|
||||
const parseJsonEditorConfig = (): AdvancedCustomConfig | null => {
|
||||
@@ -171,7 +212,9 @@ export function AdvancedCustomEditorDialog({
|
||||
const switchToVisualMode = () => {
|
||||
const parsed = parseJsonEditorConfig()
|
||||
if (!parsed) return
|
||||
setConfig(parsed)
|
||||
const normalized = normalizeAdvancedCustomConfig(parsed)
|
||||
setConfig(normalized)
|
||||
setRouteKeys(createRouteKeys(normalized.advanced_routes?.length || 0))
|
||||
setEditMode('visual')
|
||||
}
|
||||
|
||||
@@ -212,6 +255,7 @@ export function AdvancedCustomEditorDialog({
|
||||
|
||||
const normalized = normalizeAdvancedCustomConfig(nextConfig)
|
||||
setConfig(normalized)
|
||||
setRouteKeys(createRouteKeys(normalized.advanced_routes?.length || 0))
|
||||
setJsonText(stringifyAdvancedCustomConfig(normalized))
|
||||
setJsonError('')
|
||||
}
|
||||
@@ -341,8 +385,8 @@ export function AdvancedCustomEditorDialog({
|
||||
</div>
|
||||
|
||||
{editMode === 'visual' ? (
|
||||
<div className='flex flex-col gap-5 p-4'>
|
||||
<div className='flex justify-end border-y py-4'>
|
||||
<div className='flex flex-col gap-4 p-4 lg:gap-3'>
|
||||
<div className='flex justify-end border-y py-4 lg:py-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
@@ -365,11 +409,28 @@ export function AdvancedCustomEditorDialog({
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<div className='flex flex-col gap-4'>
|
||||
{routes.map((route, index) => (
|
||||
<p className='text-muted-foreground bg-muted/30 hidden rounded-md border px-3 py-2 text-xs leading-relaxed lg:block'>
|
||||
{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
|
||||
key={index}
|
||||
route={route}
|
||||
key={routeRow.routeKey}
|
||||
route={routeRow.route}
|
||||
index={index}
|
||||
onChange={(patch) => updateRoute(index, patch)}
|
||||
onRemove={() => removeRoute(index)}
|
||||
@@ -429,14 +490,16 @@ function RouteEditor({
|
||||
const authMode = getAdvancedCustomAuthMode(route)
|
||||
const incomingPath =
|
||||
route.incoming_path || getDefaultAdvancedCustomIncomingPath(converter)
|
||||
const incomingPathOptions = useMemo(
|
||||
() => getAdvancedCustomIncomingPathOptions(converter),
|
||||
[converter]
|
||||
const converterOptions = useMemo(
|
||||
() => getAdvancedCustomConverterOptions(incomingPath),
|
||||
[incomingPath]
|
||||
)
|
||||
const incomingPathLabel = getAdvancedCustomIncomingPathLabel(incomingPath)
|
||||
const converterLabel =
|
||||
getOptionLabel(ADVANCED_CUSTOM_CONVERTER_OPTIONS, converter)
|
||||
const authLabel = getOptionLabel(ADVANCED_CUSTOM_AUTH_MODE_OPTIONS, authMode)
|
||||
const isNativeConverter = converter === 'none'
|
||||
const ConverterVisualIcon = isNativeConverter ? ArrowRight : Shuffle
|
||||
|
||||
const setConverter = (nextConverter: AdvancedCustomConverter) => {
|
||||
const patch: Partial<AdvancedCustomRoute> = { converter: nextConverter }
|
||||
@@ -446,6 +509,18 @@ function RouteEditor({
|
||||
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) => {
|
||||
onChange({ auth: buildAdvancedCustomAuth(mode, route.auth) })
|
||||
}
|
||||
@@ -467,36 +542,68 @@ function RouteEditor({
|
||||
}
|
||||
|
||||
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 className='border-border flex flex-col gap-4 rounded-md border p-4 lg:gap-2 lg:p-3'>
|
||||
<div
|
||||
className={cn(
|
||||
'grid gap-4 md:grid-cols-2 lg:items-center lg:gap-2',
|
||||
routeEditorGridClassName
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
<Badge variant='secondary'>{t(converterLabel)}</Badge>
|
||||
</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>
|
||||
<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')}>
|
||||
<FieldBlock
|
||||
label={t('Incoming path')}
|
||||
className='lg:gap-1'
|
||||
labelClassName='lg:sr-only'
|
||||
>
|
||||
<Select
|
||||
value={incomingPath}
|
||||
onValueChange={(value) =>
|
||||
onChange({
|
||||
incoming_path:
|
||||
value || getDefaultAdvancedCustomIncomingPath(converter),
|
||||
})
|
||||
}
|
||||
onValueChange={setIncomingPath}
|
||||
>
|
||||
<SelectTrigger className='w-full max-w-full'>
|
||||
<SelectTrigger className='w-full max-w-full lg:h-8'>
|
||||
<SelectValue className='min-w-0 truncate'>
|
||||
{`${t(incomingPathLabel)} · ${incomingPath}`}
|
||||
{`${incomingPathLabel}`}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
@@ -504,14 +611,14 @@ function RouteEditor({
|
||||
className={longSelectContentClass}
|
||||
>
|
||||
<SelectGroup>
|
||||
{incomingPathOptions.map((option) => (
|
||||
{ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS.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>{option.label}</span>
|
||||
<span className='text-muted-foreground break-all font-mono text-xs'>
|
||||
{option.value}
|
||||
</span>
|
||||
@@ -523,7 +630,11 @@ function RouteEditor({
|
||||
</Select>
|
||||
</FieldBlock>
|
||||
|
||||
<FieldBlock label={t('Upstream path')}>
|
||||
<FieldBlock
|
||||
label={t('Upstream path')}
|
||||
className='lg:gap-1'
|
||||
labelClassName='lg:sr-only'
|
||||
>
|
||||
<Input
|
||||
value={route.upstream_path || ''}
|
||||
onChange={(event) =>
|
||||
@@ -533,23 +644,23 @@ function RouteEditor({
|
||||
}
|
||||
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 className='text-muted-foreground text-xs leading-relaxed lg:hidden'>
|
||||
{t(upstreamPathDescriptionKey)}
|
||||
</p>
|
||||
</FieldBlock>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<FieldBlock label={t('Converter')}>
|
||||
<FieldBlock
|
||||
label={t('Converter')}
|
||||
className='lg:gap-1'
|
||||
labelClassName='lg:sr-only'
|
||||
>
|
||||
<Select
|
||||
value={converter}
|
||||
onValueChange={(value) =>
|
||||
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'>
|
||||
{t(converterLabel)}
|
||||
</SelectValue>
|
||||
@@ -559,7 +670,7 @@ function RouteEditor({
|
||||
className={longSelectContentClass}
|
||||
>
|
||||
<SelectGroup>
|
||||
{ADVANCED_CUSTOM_CONVERTER_OPTIONS.map((option) => (
|
||||
{converterOptions.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
@@ -575,14 +686,18 @@ function RouteEditor({
|
||||
</Select>
|
||||
</FieldBlock>
|
||||
|
||||
<FieldBlock label={t('Auth')}>
|
||||
<FieldBlock
|
||||
label={t('Auth')}
|
||||
className='lg:gap-1'
|
||||
labelClassName='lg:sr-only'
|
||||
>
|
||||
<Select
|
||||
value={authMode}
|
||||
onValueChange={(value) =>
|
||||
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'>
|
||||
{t(authLabel)}
|
||||
</SelectValue>
|
||||
@@ -598,13 +713,34 @@ function RouteEditor({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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>
|
||||
|
||||
{authMode === 'header' || authMode === 'query' ? (
|
||||
<>
|
||||
<Separator />
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<FieldBlock label={t('Auth name')}>
|
||||
<Separator className='lg:hidden' />
|
||||
<div
|
||||
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
|
||||
value={route.auth?.name || ''}
|
||||
onChange={(event) => updateAuth('name', event.target.value)}
|
||||
@@ -613,7 +749,11 @@ function RouteEditor({
|
||||
}
|
||||
/>
|
||||
</FieldBlock>
|
||||
<FieldBlock label={t('Auth value')}>
|
||||
<FieldBlock
|
||||
label={t('Auth value')}
|
||||
className='lg:gap-1'
|
||||
labelClassName='lg:text-xs'
|
||||
>
|
||||
<Input
|
||||
value={route.auth?.value || ''}
|
||||
onChange={(event) => updateAuth('value', event.target.value)}
|
||||
@@ -622,6 +762,9 @@ function RouteEditor({
|
||||
}
|
||||
/>
|
||||
</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>
|
||||
</>
|
||||
) : null}
|
||||
@@ -631,14 +774,20 @@ function RouteEditor({
|
||||
|
||||
function FieldBlock({
|
||||
label,
|
||||
className,
|
||||
labelClassName,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
className?: string
|
||||
labelClassName?: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className='flex min-w-0 flex-col gap-2'>
|
||||
<span className='text-sm font-medium'>{label}</span>
|
||||
<div className={cn('flex min-w-0 flex-col gap-2', className)}>
|
||||
<span className={cn('text-sm font-medium', labelClassName)}>
|
||||
{label}
|
||||
</span>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
+37
@@ -256,6 +256,7 @@ const ADVANCED_SETTINGS_SECTION_IDS = {
|
||||
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',
|
||||
@@ -776,6 +777,18 @@ export function ChannelMutateDrawer({
|
||||
() => getAdvancedCustomStats(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
|
||||
const allModelsList = useMemo(
|
||||
@@ -2647,6 +2660,30 @@ export function ChannelMutateDrawer({
|
||||
{t('Routes')}:{' '}
|
||||
{advancedCustomStats.routeCount}
|
||||
</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 && (
|
||||
<Badge variant='destructive'>
|
||||
{t('Incomplete')}
|
||||
|
||||
+46
-9
@@ -78,11 +78,7 @@ export const ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS: AdvancedCustomIncomingPathOp
|
||||
[
|
||||
{
|
||||
value: '/v1/chat/completions',
|
||||
label: 'OpenAI Chat Completions',
|
||||
},
|
||||
{
|
||||
value: '/v1/completions',
|
||||
label: 'OpenAI Completions',
|
||||
label: 'OpenAI Chat',
|
||||
},
|
||||
{
|
||||
value: '/v1/responses',
|
||||
@@ -104,6 +100,10 @@ export const ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS: AdvancedCustomIncomingPathOp
|
||||
value: '/v1/images/edits',
|
||||
label: 'OpenAI Image Edits',
|
||||
},
|
||||
{
|
||||
value: '/v1/completions',
|
||||
label: 'OpenAI Completions',
|
||||
},
|
||||
{
|
||||
value: '/v1/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 = {
|
||||
message: string
|
||||
routeIndex?: number
|
||||
@@ -357,8 +361,17 @@ export function isAdvancedCustomIncomingPathAllowed(
|
||||
incomingPath: string,
|
||||
converter: AdvancedCustomConverter
|
||||
): boolean {
|
||||
return getAdvancedCustomIncomingPathOptions(converter).some(
|
||||
(option) => option.value === incomingPath
|
||||
return isConverterPathAllowed(incomingPath, converter)
|
||||
}
|
||||
|
||||
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): {
|
||||
routeCount: number
|
||||
valid: boolean
|
||||
routeTypeLabels: string[]
|
||||
} {
|
||||
const config = parseAdvancedCustomConfig(value)
|
||||
if (!config) {
|
||||
return { routeCount: 0, valid: false }
|
||||
return { routeCount: 0, valid: false, routeTypeLabels: [] }
|
||||
}
|
||||
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 {
|
||||
routeCount: normalized.advanced_routes?.length || 0,
|
||||
routeCount: routes.length,
|
||||
valid: validateAdvancedCustomConfig(normalized) === null,
|
||||
routeTypeLabels,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -543,6 +569,17 @@ function getAdvancedCustomRouteUpstreamPath(route: AdvancedCustomRoute): string
|
||||
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 {
|
||||
if (value.startsWith('/')) return !value.startsWith('//')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user