diff --git a/web/default/src/features/channels/components/dialogs/advanced-custom-editor-dialog.tsx b/web/default/src/features/channels/components/dialogs/advanced-custom-editor-dialog.tsx index 90ea076e..8f9372f6 100644 --- a/web/default/src/features/channels/components/dialogs/advanced-custom-editor-dialog.tsx +++ b/web/default/src/features/channels/components/dialogs/advanced-custom-editor-dialog.tsx @@ -16,12 +16,11 @@ along with this program. If not, see . 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( () => parseAdvancedCustomConfig(value) || createAdvancedCustomConfig() ) + const [routeKeys, setRouteKeys] = useState(() => { + const initialConfig = + parseAdvancedCustomConfig(value) || createAdvancedCustomConfig() + const normalized = normalizeAdvancedCustomConfig(initialConfig) + return (normalized.advanced_routes || []).map( + (_, routeIndex) => `advanced-custom-route-initial-${routeIndex}` + ) + }) const [editMode, setEditMode] = useState('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) => { 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({ {editMode === 'visual' ? ( -
-
+
+
- -
-
- + - + @@ -533,23 +644,23 @@ function RouteEditor({ } placeholder={getAdvancedCustomUpstreamPathPlaceholder(converter)} /> -

- {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.' - )} +

+ {t(upstreamPathDescriptionKey)}

-
-
- + setAuthMode(value as AdvancedCustomAuthMode) } > - + {t(authLabel)} @@ -598,13 +713,34 @@ function RouteEditor({ + +
{authMode === 'header' || authMode === 'query' ? ( <> - -
- + +
+
) : null} @@ -631,14 +774,20 @@ function RouteEditor({ function FieldBlock({ label, + className, + labelClassName, children, }: { label: string + className?: string + labelClassName?: string children: ReactNode }) { return ( -
- {label} +
+ + {label} + {children}
) diff --git a/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx b/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx index 0824b4eb..4ee913d1 100644 --- a/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx +++ b/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx @@ -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} + {advancedCustomRouteTypeLabels.map( + (label) => ( + + + {label} + + + ) + )} + {hiddenAdvancedCustomRouteTypeCount > 0 && ( + + +{hiddenAdvancedCustomRouteTypeCount} + + )} {!advancedCustomStats.valid && ( {t('Incomplete')} diff --git a/web/default/src/features/channels/lib/advanced-custom.ts b/web/default/src/features/channels/lib/advanced-custom.ts index 823e55f7..150ce11b 100644 --- a/web/default/src/features/channels/lib/advanced-custom.ts +++ b/web/default/src/features/channels/lib/advanced-custom.ts @@ -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 = { + '/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() + + 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('//')