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/>.
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>
)
......
......@@ -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')}
......
......@@ -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',
......@@ -105,6 +101,10 @@ export const ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS: AdvancedCustomIncomingPathOp
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('//')
......
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