Commit 43591fba by CaIon

feat: improve advanced custom route editor

parent 25f99859
...@@ -16,12 +16,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -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 { ...@@ -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' ...@@ -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({ ...@@ -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({ ...@@ -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({ ...@@ -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({ ...@@ -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({ ...@@ -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({ ...@@ -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({ ...@@ -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({ ...@@ -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({ ...@@ -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({ ...@@ -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({ ...@@ -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({ ...@@ -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({ ...@@ -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({ ...@@ -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({ ...@@ -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({ ...@@ -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({ ...@@ -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({ ...@@ -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({ ...@@ -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({ ...@@ -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>
) )
......
...@@ -256,6 +256,7 @@ const ADVANCED_SETTINGS_SECTION_IDS = { ...@@ -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({ ...@@ -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({ ...@@ -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')}
......
...@@ -78,11 +78,7 @@ export const ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS: AdvancedCustomIncomingPathOp ...@@ -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',
...@@ -105,6 +101,10 @@ export const ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS: AdvancedCustomIncomingPathOp ...@@ -105,6 +101,10 @@ export const ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS: AdvancedCustomIncomingPathOp
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 ...@@ -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( ...@@ -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( ...@@ -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 ...@@ -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('//')
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment