* perf(json-editor): improve JSON editing experience - integrate Yace for syntax highlighting, history, indentation, auto-closing, and smart line breaks. - add copy support, cursor location feedback, and synchronized content and line-number scrolling. - extract JSON editor utilities and cover key interactions with unit tests. * perf(system-settings): improve JSON configuration editing - replace raw JSON textareas with the shared editor for highlighting, validation, copy, and formatting. - preserve field-specific examples and make placeholders visible through the transparent editor layer. - remove duplicate formatting controls while keeping existing form validation and save behavior. * perf(json-editor): standardize JSON inputs across admin settings - replace pure JSON textareas with the shared editor across system settings and channel workflows. - preserve form focus, validation, placeholders, and visual or JSON editing modes. - add happy-dom component coverage for form bindings, controlled updates, and formatting. * fix(json-code-editor): address accessibility review findings - Drop the unconditional aria-label that overrode every field's label-derived accessible name; add an optional ariaLabel prop and set it at call sites without an associated label - Associate standalone Labels via htmlFor/id in channel-affinity views - Hide the highlight mirror and line-number layers from the accessibility tree (aria-hidden) - Give the line-number gutter an opaque background so horizontally scrolled code no longer slides under it - Degrade to no scroll sync instead of destroying the editor when the line-number layer is not found
1437 lines
46 KiB
TypeScript
1437 lines
46 KiB
TypeScript
/*
|
|
Copyright (C) 2023-2026 QuantumNous
|
|
|
|
This program is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU Affero General Public License as
|
|
published by the Free Software Foundation, either version 3 of the
|
|
License, or (at your option) any later version.
|
|
|
|
This program is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU Affero General Public License for more details.
|
|
|
|
You should have received a copy of the GNU Affero General Public License
|
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
For commercial licensing, please contact support@quantumnous.com
|
|
*/
|
|
import {
|
|
ArrowDown,
|
|
ArrowDownToLine,
|
|
ArrowRight,
|
|
ArrowUp,
|
|
Check,
|
|
Info,
|
|
Plus,
|
|
Shuffle,
|
|
Trash2,
|
|
type LucideIcon,
|
|
} from 'lucide-react'
|
|
import { type ReactNode, useMemo, useRef, useState } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { toast } from 'sonner'
|
|
|
|
import { Dialog } from '@/components/dialog'
|
|
import { JsonCodeEditor } from '@/components/json-code-editor'
|
|
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 {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverDescription,
|
|
PopoverHeader,
|
|
PopoverTitle,
|
|
PopoverTrigger,
|
|
} from '@/components/ui/popover'
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectGroup,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select'
|
|
import { Separator } from '@/components/ui/separator'
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipProvider,
|
|
TooltipTrigger,
|
|
} from '@/components/ui/tooltip'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
import {
|
|
ADVANCED_CUSTOM_AUTH_MODE_OPTIONS,
|
|
ADVANCED_CUSTOM_CONVERTER_OPTIONS,
|
|
ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS,
|
|
ADVANCED_CUSTOM_MODEL_LIST_LABEL,
|
|
ADVANCED_CUSTOM_MODEL_LIST_PATH,
|
|
ADVANCED_CUSTOM_TEMPLATE_OPTIONS,
|
|
type AdvancedCustomAuthMode,
|
|
buildAdvancedCustomAuth,
|
|
createAdvancedCustomConfig,
|
|
createAdvancedCustomRoute,
|
|
getAdvancedCustomAuthMode,
|
|
getAdvancedCustomConverterDefaults,
|
|
getAdvancedCustomConverterOptions,
|
|
getAdvancedCustomIncomingPathLabel,
|
|
getAdvancedCustomModelRuleKind,
|
|
getAdvancedCustomRegexModelPattern,
|
|
getAdvancedCustomTemplateConfig,
|
|
getAdvancedCustomUpstreamPathPlaceholder,
|
|
getDefaultAdvancedCustomIncomingPath,
|
|
isAdvancedCustomIncomingPathAllowed,
|
|
normalizeAdvancedCustomConfig,
|
|
parseAdvancedCustomRouteModels,
|
|
parseAdvancedCustomConfig,
|
|
stringifyAdvancedCustomConfig,
|
|
validateAdvancedCustomConfig,
|
|
} from '../../lib/advanced-custom'
|
|
import type {
|
|
AdvancedCustomAuthType,
|
|
AdvancedCustomConfig,
|
|
AdvancedCustomConverter,
|
|
AdvancedCustomRoute,
|
|
} from '../../types'
|
|
|
|
type AdvancedCustomEditorDialogProps = {
|
|
open: boolean
|
|
value: string
|
|
onOpenChange: (open: boolean) => void
|
|
onSave: (value: string) => void
|
|
}
|
|
|
|
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-[6rem_minmax(0,1fr)_minmax(0,1.25fr)_minmax(0,1fr)_minmax(0,0.85fr)_7rem]'
|
|
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.'
|
|
const catchAllOrderErrorMessage =
|
|
'Catch-all route must be last for the same incoming path'
|
|
const emptyAdvancedRoutes: AdvancedCustomRoute[] = []
|
|
|
|
type AdvancedCustomRouteRow = {
|
|
route: AdvancedCustomRoute
|
|
routeKey: string
|
|
index: number
|
|
}
|
|
|
|
type AdvancedCustomRouteGroup = {
|
|
incomingPath: string
|
|
routeRows: AdvancedCustomRouteRow[]
|
|
}
|
|
|
|
function getOptionLabel(
|
|
options: ReadonlyArray<{ value: string; label: string }>,
|
|
value: string
|
|
) {
|
|
return options.find((option) => option.value === value)?.label || value
|
|
}
|
|
|
|
function getRouteIncomingPath(route: AdvancedCustomRoute): string {
|
|
return (route.incoming_path || '').trim()
|
|
}
|
|
|
|
function isCatchAllRoute(route: AdvancedCustomRoute): boolean {
|
|
return !route.models || route.models.length === 0
|
|
}
|
|
|
|
function buildRouteGroups(
|
|
routeRows: AdvancedCustomRouteRow[]
|
|
): AdvancedCustomRouteGroup[] {
|
|
const groups: AdvancedCustomRouteGroup[] = []
|
|
const groupByPath = new Map<string, AdvancedCustomRouteGroup>()
|
|
|
|
for (const routeRow of routeRows) {
|
|
const incomingPath = getRouteIncomingPath(routeRow.route)
|
|
let group = groupByPath.get(incomingPath)
|
|
if (!group) {
|
|
group = { incomingPath, routeRows: [] }
|
|
groupByPath.set(incomingPath, group)
|
|
groups.push(group)
|
|
}
|
|
group.routeRows.push(routeRow)
|
|
}
|
|
|
|
return groups
|
|
}
|
|
|
|
export function AdvancedCustomEditorDialog({
|
|
open,
|
|
value,
|
|
onOpenChange,
|
|
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(
|
|
parseAdvancedCustomConfig(value) || createAdvancedCustomConfig()
|
|
)
|
|
)
|
|
const [jsonError, setJsonError] = useState('')
|
|
const [templateKey, setTemplateKey] = useState(
|
|
ADVANCED_CUSTOM_TEMPLATE_OPTIONS[0]?.value || ''
|
|
)
|
|
const templateLabel = useMemo(
|
|
() => getOptionLabel(ADVANCED_CUSTOM_TEMPLATE_OPTIONS, templateKey),
|
|
[templateKey]
|
|
)
|
|
|
|
const normalizedConfig = useMemo(
|
|
() => normalizeAdvancedCustomConfig(config),
|
|
[config]
|
|
)
|
|
const routes = normalizedConfig.advanced_routes || emptyAdvancedRoutes
|
|
const routeRows = useMemo(
|
|
() =>
|
|
routes.map((route, index) => ({
|
|
route,
|
|
index,
|
|
routeKey:
|
|
routeKeys.at(index) ||
|
|
route.incoming_path ||
|
|
route.upstream_path ||
|
|
route.converter ||
|
|
'advanced-custom-route',
|
|
})),
|
|
[routeKeys, routes]
|
|
)
|
|
const routeGroups = useMemo(() => buildRouteGroups(routeRows), [routeRows])
|
|
const usedIncomingPaths = useMemo(
|
|
() => new Set(routeGroups.map((routeGroup) => routeGroup.incomingPath)),
|
|
[routeGroups]
|
|
)
|
|
const availableIncomingPathOptions = useMemo(
|
|
() =>
|
|
ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS.filter(
|
|
(option) => !usedIncomingPaths.has(option.value)
|
|
),
|
|
[usedIncomingPaths]
|
|
)
|
|
const validationError = useMemo(
|
|
() => validateAdvancedCustomConfig(normalizedConfig),
|
|
[normalizedConfig]
|
|
)
|
|
const canFixCatchAllOrder =
|
|
validationError?.message === catchAllOrderErrorMessage
|
|
|
|
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)
|
|
const nextRoutes = [...(next.advanced_routes || [])]
|
|
nextRoutes[index] = { ...nextRoutes[index], ...patch }
|
|
return { ...next, advanced_routes: nextRoutes }
|
|
})
|
|
}
|
|
|
|
const replaceRoutes = (
|
|
nextRoutes: AdvancedCustomRoute[],
|
|
nextRouteKeys = routeRows.map((routeRow) => routeRow.routeKey)
|
|
) => {
|
|
setConfig((current) => {
|
|
const next = normalizeAdvancedCustomConfig(current)
|
|
return { ...next, advanced_routes: nextRoutes }
|
|
})
|
|
setRouteKeys(nextRouteKeys)
|
|
}
|
|
|
|
const addRoute = (incomingPath: string | null) => {
|
|
if (!incomingPath || usedIncomingPaths.has(incomingPath)) return
|
|
setConfig((current) => {
|
|
const next = normalizeAdvancedCustomConfig(current)
|
|
return {
|
|
...next,
|
|
advanced_routes: [
|
|
...(next.advanced_routes || []),
|
|
{
|
|
...createAdvancedCustomRoute(),
|
|
incoming_path: incomingPath,
|
|
upstream_path: incomingPath,
|
|
},
|
|
],
|
|
}
|
|
})
|
|
setRouteKeys((current) => [...current, createRouteKey()])
|
|
}
|
|
|
|
const addRouteForIncomingPath = (incomingPath: string) => {
|
|
const resolvedIncomingPath = incomingPath || '/v1/chat/completions'
|
|
setConfig((current) => {
|
|
const next = normalizeAdvancedCustomConfig(current)
|
|
return {
|
|
...next,
|
|
advanced_routes: [
|
|
...(next.advanced_routes || []),
|
|
{
|
|
...createAdvancedCustomRoute(),
|
|
incoming_path: resolvedIncomingPath,
|
|
upstream_path: resolvedIncomingPath,
|
|
},
|
|
],
|
|
}
|
|
})
|
|
setRouteKeys((current) => [...current, createRouteKey()])
|
|
}
|
|
|
|
const removeRoute = (index: number) => {
|
|
setConfig((current) => {
|
|
const next = normalizeAdvancedCustomConfig(current)
|
|
return {
|
|
...next,
|
|
advanced_routes: (next.advanced_routes || []).filter(
|
|
(_, routeIndex) => routeIndex !== index
|
|
),
|
|
}
|
|
})
|
|
setRouteKeys((current) =>
|
|
current.filter((_, routeIndex) => routeIndex !== index)
|
|
)
|
|
}
|
|
|
|
const updateGroupIncomingPath = (
|
|
group: AdvancedCustomRouteGroup,
|
|
nextIncomingPath: string | null
|
|
) => {
|
|
const resolvedIncomingPath = nextIncomingPath || '/v1/chat/completions'
|
|
const groupRouteIndexes = new Set(
|
|
group.routeRows.map((routeRow) => routeRow.index)
|
|
)
|
|
const nextRoutes = routes.map((route, routeIndex) => {
|
|
if (!groupRouteIndexes.has(routeIndex)) return route
|
|
if (resolvedIncomingPath === ADVANCED_CUSTOM_MODEL_LIST_PATH) {
|
|
return {
|
|
...route,
|
|
incoming_path: resolvedIncomingPath,
|
|
upstream_path: ADVANCED_CUSTOM_MODEL_LIST_PATH,
|
|
converter: 'none' as const,
|
|
models: [],
|
|
}
|
|
}
|
|
const converter = route.converter || 'none'
|
|
return {
|
|
...route,
|
|
incoming_path: resolvedIncomingPath,
|
|
converter: isAdvancedCustomIncomingPathAllowed(
|
|
resolvedIncomingPath,
|
|
converter
|
|
)
|
|
? converter
|
|
: 'none',
|
|
}
|
|
})
|
|
replaceRoutes(nextRoutes)
|
|
}
|
|
|
|
const swapRoutes = (fromIndex: number, toIndex: number) => {
|
|
if (fromIndex === toIndex) return
|
|
const nextRoutes = [...routes]
|
|
const nextRouteKeys = routeRows.map((routeRow) => routeRow.routeKey)
|
|
const fromRoute = nextRoutes[fromIndex]
|
|
nextRoutes[fromIndex] = nextRoutes[toIndex]
|
|
nextRoutes[toIndex] = fromRoute
|
|
const fromRouteKey = nextRouteKeys[fromIndex]
|
|
nextRouteKeys[fromIndex] = nextRouteKeys[toIndex]
|
|
nextRouteKeys[toIndex] = fromRouteKey
|
|
replaceRoutes(nextRoutes, nextRouteKeys)
|
|
}
|
|
|
|
const moveRouteWithinGroup = (index: number, direction: -1 | 1) => {
|
|
const incomingPath = getRouteIncomingPath(routes[index])
|
|
const samePathIndexes = routes
|
|
.map((route, routeIndex) => ({ route, routeIndex }))
|
|
.filter(({ route }) => getRouteIncomingPath(route) === incomingPath)
|
|
.map(({ routeIndex }) => routeIndex)
|
|
const position = samePathIndexes.indexOf(index)
|
|
const nextIndex = samePathIndexes.at(position + direction)
|
|
if (nextIndex === undefined) return
|
|
swapRoutes(index, nextIndex)
|
|
}
|
|
|
|
const moveRouteToGroupEnd = (index: number) => {
|
|
const incomingPath = getRouteIncomingPath(routes[index])
|
|
let lastSamePathIndex = -1
|
|
for (let routeIndex = routes.length - 1; routeIndex >= 0; routeIndex -= 1) {
|
|
if (getRouteIncomingPath(routes[routeIndex]) === incomingPath) {
|
|
lastSamePathIndex = routeIndex
|
|
break
|
|
}
|
|
}
|
|
if (lastSamePathIndex < 0 || index === lastSamePathIndex) return
|
|
|
|
const nextRoutes = [...routes]
|
|
const nextRouteKeys = routeRows.map((routeRow) => routeRow.routeKey)
|
|
const [route] = nextRoutes.splice(index, 1)
|
|
const [routeKey] = nextRouteKeys.splice(index, 1)
|
|
nextRoutes.splice(lastSamePathIndex, 0, route)
|
|
nextRouteKeys.splice(lastSamePathIndex, 0, routeKey)
|
|
replaceRoutes(nextRoutes, nextRouteKeys)
|
|
}
|
|
|
|
const fixCatchAllOrder = () => {
|
|
const routeRowsByPath = new Map<string, AdvancedCustomRouteRow[]>()
|
|
for (const routeRow of routeRows) {
|
|
const incomingPath = getRouteIncomingPath(routeRow.route)
|
|
routeRowsByPath.set(incomingPath, [
|
|
...(routeRowsByPath.get(incomingPath) || []),
|
|
routeRow,
|
|
])
|
|
}
|
|
|
|
const orderedRowsByPath = new Map<string, AdvancedCustomRouteRow[]>()
|
|
for (const [incomingPath, rows] of routeRowsByPath) {
|
|
orderedRowsByPath.set(incomingPath, [
|
|
...rows.filter((routeRow) => !isCatchAllRoute(routeRow.route)),
|
|
...rows.filter((routeRow) => isCatchAllRoute(routeRow.route)),
|
|
])
|
|
}
|
|
|
|
const nextRows = routeRows.map((routeRow) => {
|
|
const incomingPath = getRouteIncomingPath(routeRow.route)
|
|
const orderedRows = orderedRowsByPath.get(incomingPath)
|
|
return orderedRows?.shift() || routeRow
|
|
})
|
|
replaceRoutes(
|
|
nextRows.map((routeRow) => routeRow.route),
|
|
nextRows.map((routeRow) => routeRow.routeKey)
|
|
)
|
|
}
|
|
|
|
const parseJsonEditorConfig = (): AdvancedCustomConfig | null => {
|
|
const parsed = parseAdvancedCustomConfig(jsonText)
|
|
if (!parsed) {
|
|
setJsonError(t('Invalid JSON'))
|
|
return null
|
|
}
|
|
|
|
const error = validateAdvancedCustomConfig(parsed)
|
|
if (error) {
|
|
setJsonError(t(error.message))
|
|
return null
|
|
}
|
|
|
|
setJsonError('')
|
|
return parsed
|
|
}
|
|
|
|
const switchToVisualMode = () => {
|
|
const parsed = parseJsonEditorConfig()
|
|
if (!parsed) return
|
|
const normalized = normalizeAdvancedCustomConfig(parsed)
|
|
setConfig(normalized)
|
|
setRouteKeys(createRouteKeys(normalized.advanced_routes?.length || 0))
|
|
setEditMode('visual')
|
|
}
|
|
|
|
const switchToJsonMode = () => {
|
|
setJsonText(stringifyAdvancedCustomConfig(normalizedConfig))
|
|
setJsonError('')
|
|
setEditMode('json')
|
|
}
|
|
|
|
const handleJsonChange = (nextValue: string) => {
|
|
setJsonText(nextValue)
|
|
if (jsonError) setJsonError('')
|
|
}
|
|
|
|
const applyTemplate = (mode: 'fill' | 'append') => {
|
|
const templateConfig = getAdvancedCustomTemplateConfig(templateKey)
|
|
let nextConfig = templateConfig
|
|
|
|
if (mode === 'append') {
|
|
const baseConfig =
|
|
editMode === 'json' ? parseJsonEditorConfig() : normalizedConfig
|
|
if (!baseConfig) return
|
|
const base = normalizeAdvancedCustomConfig(baseConfig)
|
|
const template = normalizeAdvancedCustomConfig(templateConfig)
|
|
nextConfig = {
|
|
advanced_routes: [
|
|
...(base.advanced_routes || []),
|
|
...(template.advanced_routes || []),
|
|
],
|
|
}
|
|
}
|
|
|
|
const normalized = normalizeAdvancedCustomConfig(nextConfig)
|
|
setConfig(normalized)
|
|
setRouteKeys(createRouteKeys(normalized.advanced_routes?.length || 0))
|
|
setJsonText(stringifyAdvancedCustomConfig(normalized))
|
|
setJsonError('')
|
|
}
|
|
|
|
const saveConfig = () => {
|
|
if (editMode === 'json') {
|
|
const parsed = parseJsonEditorConfig()
|
|
if (!parsed) {
|
|
toast.error(t('Please fix JSON errors before saving'))
|
|
return
|
|
}
|
|
onSave(stringifyAdvancedCustomConfig(parsed))
|
|
onOpenChange(false)
|
|
return
|
|
}
|
|
|
|
if (validationError) {
|
|
toast.error(t(validationError.message))
|
|
return
|
|
}
|
|
onSave(stringifyAdvancedCustomConfig(normalizedConfig))
|
|
onOpenChange(false)
|
|
}
|
|
|
|
return (
|
|
<Dialog
|
|
open={open}
|
|
onOpenChange={onOpenChange}
|
|
title={t('Advanced Custom Routes')}
|
|
description={t('Advanced Custom')}
|
|
contentClassName='flex max-h-[90vh] flex-col gap-0 p-0 sm:max-w-5xl'
|
|
headerClassName='border-b px-6 py-4'
|
|
footerClassName='border-t px-6 py-4'
|
|
contentHeight='70vh'
|
|
footer={
|
|
<>
|
|
<Button
|
|
type='button'
|
|
variant='outline'
|
|
onClick={() => onOpenChange(false)}
|
|
>
|
|
{t('Cancel')}
|
|
</Button>
|
|
<Button type='button' onClick={saveConfig}>
|
|
<Check data-icon='inline-start' />
|
|
{t('Save changes')}
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
<div className='bg-muted/30 border-b px-4 py-3'>
|
|
<div className='flex flex-wrap items-center gap-2'>
|
|
<span className='text-muted-foreground text-xs font-medium'>
|
|
{t('Mode')}
|
|
</span>
|
|
<Button
|
|
type='button'
|
|
variant={editMode === 'visual' ? 'default' : 'outline'}
|
|
size='sm'
|
|
onClick={switchToVisualMode}
|
|
>
|
|
{t('Visual')}
|
|
</Button>
|
|
<Button
|
|
type='button'
|
|
variant={editMode === 'json' ? 'default' : 'outline'}
|
|
size='sm'
|
|
onClick={switchToJsonMode}
|
|
>
|
|
{t('JSON Text')}
|
|
</Button>
|
|
|
|
<div className='bg-border mx-1 h-5 w-px' />
|
|
|
|
<span className='text-muted-foreground text-xs font-medium'>
|
|
{t('Template')}
|
|
</span>
|
|
<Select
|
|
value={templateKey}
|
|
onValueChange={(nextValue) =>
|
|
setTemplateKey(
|
|
nextValue || ADVANCED_CUSTOM_TEMPLATE_OPTIONS[0]?.value || ''
|
|
)
|
|
}
|
|
>
|
|
<SelectTrigger className='h-8 max-w-full min-w-[260px] flex-1 sm:w-[320px]'>
|
|
<SelectValue className='min-w-0 truncate'>
|
|
{t(templateLabel)}
|
|
</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent
|
|
alignItemWithTrigger={false}
|
|
className={longSelectContentClass}
|
|
>
|
|
<SelectGroup>
|
|
{ADVANCED_CUSTOM_TEMPLATE_OPTIONS.map((option) => (
|
|
<SelectItem
|
|
key={option.value}
|
|
value={option.value}
|
|
className={longSelectItemClass}
|
|
>
|
|
<span className='min-w-0 leading-snug break-words whitespace-normal'>
|
|
{t(option.label)}
|
|
</span>
|
|
</SelectItem>
|
|
))}
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
<Button
|
|
type='button'
|
|
variant='outline'
|
|
size='sm'
|
|
onClick={() => applyTemplate('fill')}
|
|
>
|
|
{t('Fill Template')}
|
|
</Button>
|
|
<Button
|
|
type='button'
|
|
variant='ghost'
|
|
size='sm'
|
|
onClick={() => applyTemplate('append')}
|
|
>
|
|
{t('Append Template')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{editMode === 'visual' ? (
|
|
<div className='flex flex-col gap-4 p-4 lg:gap-3'>
|
|
<div className='flex justify-end border-y py-4 lg:py-2'>
|
|
<Select
|
|
items={availableIncomingPathOptions}
|
|
value={null}
|
|
onValueChange={(incomingPath) => {
|
|
if (typeof incomingPath === 'string') {
|
|
addRoute(incomingPath)
|
|
}
|
|
}}
|
|
>
|
|
<SelectTrigger
|
|
size='sm'
|
|
disabled={availableIncomingPathOptions.length === 0}
|
|
>
|
|
<Plus data-icon='inline-start' />
|
|
<SelectValue placeholder={t('Add route')} />
|
|
</SelectTrigger>
|
|
<SelectContent
|
|
align='end'
|
|
alignItemWithTrigger={false}
|
|
className={longSelectContentClass}
|
|
>
|
|
<SelectGroup>
|
|
{availableIncomingPathOptions.map((option) => (
|
|
<SelectItem
|
|
key={option.value}
|
|
value={option.value}
|
|
className={longSelectItemClass}
|
|
>
|
|
<div className='flex min-w-0 flex-col gap-1 leading-snug whitespace-normal'>
|
|
<span>{option.label}</span>
|
|
<span className='text-muted-foreground font-mono text-xs break-all'>
|
|
{option.value}
|
|
</span>
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{validationError ? (
|
|
<Alert variant='destructive'>
|
|
<AlertDescription className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
|
|
<span>
|
|
{validationError.routeIndex !== undefined
|
|
? `${t('Route')} ${validationError.routeIndex + 1}: `
|
|
: ''}
|
|
{t(validationError.message)}
|
|
</span>
|
|
{canFixCatchAllOrder ? (
|
|
<Button
|
|
type='button'
|
|
variant='outline'
|
|
size='sm'
|
|
onClick={fixCatchAllOrder}
|
|
>
|
|
{t('Fix order')}
|
|
</Button>
|
|
) : null}
|
|
</AlertDescription>
|
|
</Alert>
|
|
) : null}
|
|
|
|
<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'>
|
|
{routeGroups.map((routeGroup) => (
|
|
<RouteGroupEditor
|
|
key={routeGroup.incomingPath || 'advanced-custom-empty-path'}
|
|
group={routeGroup}
|
|
usedIncomingPaths={usedIncomingPaths}
|
|
validationError={validationError}
|
|
onAddRoute={() =>
|
|
addRouteForIncomingPath(routeGroup.incomingPath)
|
|
}
|
|
onIncomingPathChange={(nextIncomingPath) =>
|
|
updateGroupIncomingPath(routeGroup, nextIncomingPath)
|
|
}
|
|
onMoveRoute={(index, direction) =>
|
|
moveRouteWithinGroup(index, direction)
|
|
}
|
|
onMoveRouteToEnd={moveRouteToGroupEnd}
|
|
onRemoveRoute={removeRoute}
|
|
onRouteChange={updateRoute}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className='p-4'>
|
|
<div className='mb-2 flex items-center gap-2'>
|
|
<span className='text-muted-foreground text-xs'>
|
|
{t('Advanced text editing')}
|
|
</span>
|
|
</div>
|
|
<JsonCodeEditor
|
|
value={jsonText}
|
|
onChange={handleJsonChange}
|
|
placeholder={stringifyAdvancedCustomConfig(
|
|
getAdvancedCustomTemplateConfig(templateKey)
|
|
)}
|
|
heightClassName='h-[420px] min-h-[420px] max-h-[420px]'
|
|
aria-invalid={Boolean(jsonError)}
|
|
ariaLabel={t('Advanced text editing')}
|
|
/>
|
|
<p className='text-muted-foreground mt-2 text-xs'>
|
|
{t('Edit JSON text directly. Format will be validated on save.')}
|
|
</p>
|
|
{jsonError ? (
|
|
<p className='text-destructive mt-1 text-xs'>{jsonError}</p>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
</Dialog>
|
|
)
|
|
}
|
|
|
|
function RouteGroupEditor({
|
|
group,
|
|
usedIncomingPaths,
|
|
validationError,
|
|
onAddRoute,
|
|
onIncomingPathChange,
|
|
onMoveRoute,
|
|
onMoveRouteToEnd,
|
|
onRemoveRoute,
|
|
onRouteChange,
|
|
}: {
|
|
group: AdvancedCustomRouteGroup
|
|
usedIncomingPaths: ReadonlySet<string>
|
|
validationError: ReturnType<typeof validateAdvancedCustomConfig>
|
|
onAddRoute: () => void
|
|
onIncomingPathChange: (incomingPath: string | null) => void
|
|
onMoveRoute: (index: number, direction: -1 | 1) => void
|
|
onMoveRouteToEnd: (index: number) => void
|
|
onRemoveRoute: (index: number) => void
|
|
onRouteChange: (index: number, patch: Partial<AdvancedCustomRoute>) => void
|
|
}) {
|
|
const { t } = useTranslation()
|
|
const incomingPath = group.incomingPath || '/v1/chat/completions'
|
|
const isModelListGroup = incomingPath === ADVANCED_CUSTOM_MODEL_LIST_PATH
|
|
const incomingPathLabel = getAdvancedCustomIncomingPathLabel(incomingPath)
|
|
const catchAllRoute = group.routeRows.find((routeRow) =>
|
|
isCatchAllRoute(routeRow.route)
|
|
)
|
|
const catchAllRoutePosition = catchAllRoute
|
|
? group.routeRows.findIndex(
|
|
(routeRow) => routeRow.index === catchAllRoute.index
|
|
)
|
|
: -1
|
|
const hasCatchAll = catchAllRoute !== undefined
|
|
const catchAllIsLast =
|
|
!hasCatchAll || catchAllRoutePosition === group.routeRows.length - 1
|
|
const groupHasError =
|
|
validationError?.routeIndex !== undefined &&
|
|
group.routeRows.some(
|
|
(routeRow) => routeRow.index === validationError.routeIndex
|
|
)
|
|
|
|
return (
|
|
<section
|
|
className={cn(
|
|
'border-border overflow-hidden rounded-md border',
|
|
groupHasError && 'border-destructive/60'
|
|
)}
|
|
>
|
|
<div className='bg-muted/20 flex flex-col gap-3 p-3 lg:flex-row lg:items-center lg:justify-between'>
|
|
<div className='flex min-w-0 flex-1 flex-col gap-2'>
|
|
<div className='flex flex-wrap items-center gap-2'>
|
|
<span className='text-sm font-medium'>{t('Route group')}</span>
|
|
<Badge variant='secondary'>
|
|
{group.routeRows.length} {t('Routes')}
|
|
</Badge>
|
|
{isModelListGroup ? (
|
|
<Badge variant='outline'>
|
|
{ADVANCED_CUSTOM_MODEL_LIST_LABEL}
|
|
</Badge>
|
|
) : (
|
|
<Badge variant={hasCatchAll ? 'outline' : 'secondary'}>
|
|
{hasCatchAll ? t('Fallback route') : t('Model-scoped only')}
|
|
</Badge>
|
|
)}
|
|
{!isModelListGroup && !catchAllIsLast ? (
|
|
<Badge variant='destructive'>{t('Fallback must be last')}</Badge>
|
|
) : null}
|
|
</div>
|
|
<Select
|
|
items={ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS}
|
|
value={incomingPath}
|
|
onValueChange={onIncomingPathChange}
|
|
>
|
|
<SelectTrigger className='h-9 max-w-full lg:max-w-[420px]'>
|
|
<SelectValue className='min-w-0 truncate'>
|
|
{incomingPathLabel}
|
|
</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent
|
|
alignItemWithTrigger={false}
|
|
className={longSelectContentClass}
|
|
>
|
|
<SelectGroup>
|
|
{ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS.map((option) => (
|
|
<SelectItem
|
|
key={option.value}
|
|
value={option.value}
|
|
disabled={
|
|
(option.value !== incomingPath &&
|
|
usedIncomingPaths.has(option.value)) ||
|
|
(option.value === ADVANCED_CUSTOM_MODEL_LIST_PATH &&
|
|
group.routeRows.length > 1)
|
|
}
|
|
className={longSelectItemClass}
|
|
>
|
|
<div className='flex min-w-0 flex-col gap-1 leading-snug whitespace-normal'>
|
|
<span>{option.label}</span>
|
|
<span className='text-muted-foreground font-mono text-xs break-all'>
|
|
{option.value}
|
|
</span>
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{!isModelListGroup ? (
|
|
<Button
|
|
type='button'
|
|
variant='outline'
|
|
size='sm'
|
|
onClick={onAddRoute}
|
|
>
|
|
<Plus data-icon='inline-start' />
|
|
{t('Add split')}
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className='border-t px-3 py-2'>
|
|
<p className='text-muted-foreground text-xs leading-relaxed'>
|
|
{isModelListGroup
|
|
? t(
|
|
'This route discovers upstream OpenAI models and cannot be split or matched by client model rules.'
|
|
)
|
|
: t(
|
|
'Routes with the same incoming path are split by client model rules. Unmatched requests use the final fallback.'
|
|
)}
|
|
</p>
|
|
{groupHasError && validationError ? (
|
|
<p className='text-destructive mt-1 text-xs'>
|
|
{validationError.routeIndex !== undefined
|
|
? `${t('Route')} ${validationError.routeIndex + 1}: `
|
|
: ''}
|
|
{t(validationError.message)}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
|
|
<div
|
|
className={cn(
|
|
'text-muted-foreground hidden items-center gap-2 border-t bg-muted/10 px-3 py-2 text-xs font-medium lg:grid',
|
|
routeEditorGridClassName
|
|
)}
|
|
>
|
|
<span>{t('Route')}</span>
|
|
<span className='inline-flex items-center gap-1'>
|
|
{t('Client model')}
|
|
<ModelRuleHelpPopover />
|
|
</span>
|
|
<span>{t('Upstream path')}</span>
|
|
<span>{t('Converter')}</span>
|
|
<span>{t('Auth')}</span>
|
|
<span className='text-right'>{t('Actions')}</span>
|
|
</div>
|
|
|
|
<div className='divide-y'>
|
|
{group.routeRows.map((routeRow, position) => {
|
|
const canMoveUp = position > 0
|
|
const canMoveDown = position < group.routeRows.length - 1
|
|
const catchAllOutOfOrder =
|
|
isCatchAllRoute(routeRow.route) && canMoveDown
|
|
const routeErrorMessage =
|
|
validationError?.routeIndex === routeRow.index
|
|
? validationError.message
|
|
: undefined
|
|
|
|
return (
|
|
<RouteEditor
|
|
key={routeRow.routeKey}
|
|
route={routeRow.route}
|
|
index={routeRow.index}
|
|
errorMessage={routeErrorMessage}
|
|
canMoveUp={canMoveUp}
|
|
canMoveDown={canMoveDown}
|
|
catchAllOutOfOrder={catchAllOutOfOrder}
|
|
onChange={(patch) => onRouteChange(routeRow.index, patch)}
|
|
onMoveDown={() => onMoveRoute(routeRow.index, 1)}
|
|
onMoveUp={() => onMoveRoute(routeRow.index, -1)}
|
|
onMoveCatchAllToEnd={() => onMoveRouteToEnd(routeRow.index)}
|
|
onRemove={() => onRemoveRoute(routeRow.index)}
|
|
/>
|
|
)
|
|
})}
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function RouteEditor({
|
|
route,
|
|
index,
|
|
errorMessage,
|
|
canMoveUp,
|
|
canMoveDown,
|
|
catchAllOutOfOrder,
|
|
onChange,
|
|
onMoveUp,
|
|
onMoveDown,
|
|
onMoveCatchAllToEnd,
|
|
onRemove,
|
|
}: {
|
|
route: AdvancedCustomRoute
|
|
index: number
|
|
errorMessage?: string
|
|
canMoveUp: boolean
|
|
canMoveDown: boolean
|
|
catchAllOutOfOrder: boolean
|
|
onChange: (patch: Partial<AdvancedCustomRoute>) => void
|
|
onMoveUp: () => void
|
|
onMoveDown: () => void
|
|
onMoveCatchAllToEnd: () => void
|
|
onRemove: () => void
|
|
}) {
|
|
const { t } = useTranslation()
|
|
const converter = route.converter || 'none'
|
|
const authMode = getAdvancedCustomAuthMode(route)
|
|
const incomingPath =
|
|
route.incoming_path || getDefaultAdvancedCustomIncomingPath(converter)
|
|
const isModelListRoute = incomingPath === ADVANCED_CUSTOM_MODEL_LIST_PATH
|
|
const converterOptions = useMemo(
|
|
() => getAdvancedCustomConverterOptions(incomingPath),
|
|
[incomingPath]
|
|
)
|
|
const converterLabel = getOptionLabel(
|
|
ADVANCED_CUSTOM_CONVERTER_OPTIONS,
|
|
converter
|
|
)
|
|
const converterTriggerLabel =
|
|
ADVANCED_CUSTOM_CONVERTER_OPTIONS.find(
|
|
(option) => option.value === converter
|
|
)?.triggerLabel || converterLabel
|
|
const authLabel = getOptionLabel(ADVANCED_CUSTOM_AUTH_MODE_OPTIONS, authMode)
|
|
const isNativeConverter = converter === 'none'
|
|
const ConverterVisualIcon = isNativeConverter ? ArrowRight : Shuffle
|
|
const modelsInputValue = route.models?.join(', ') || ''
|
|
const parsedRouteModels = parseAdvancedCustomRouteModels(modelsInputValue)
|
|
const isFallback = !isModelListRoute && parsedRouteModels.length === 0
|
|
|
|
const setConverter = (nextConverter: AdvancedCustomConverter) => {
|
|
let nextIncomingPath = incomingPath
|
|
if (!isAdvancedCustomIncomingPathAllowed(nextIncomingPath, nextConverter)) {
|
|
nextIncomingPath = getDefaultAdvancedCustomIncomingPath(nextConverter)
|
|
}
|
|
const defaults = getAdvancedCustomConverterDefaults(
|
|
nextConverter,
|
|
nextIncomingPath
|
|
)
|
|
onChange({
|
|
converter: nextConverter,
|
|
incoming_path: nextIncomingPath,
|
|
upstream_path: defaults.upstream_path,
|
|
auth: defaults.auth,
|
|
})
|
|
}
|
|
|
|
const setAuthMode = (mode: AdvancedCustomAuthMode) => {
|
|
onChange({ auth: buildAdvancedCustomAuth(mode, route.auth) })
|
|
}
|
|
|
|
const setModelsInput = (value: string) => {
|
|
onChange({
|
|
models: value === '' ? [] : value.split(','),
|
|
})
|
|
}
|
|
|
|
const normalizeModelsInput = (value: string) => {
|
|
onChange({ models: parseAdvancedCustomRouteModels(value) })
|
|
}
|
|
|
|
const updateAuth = (
|
|
field: Exclude<keyof NonNullable<AdvancedCustomRoute['auth']>, 'type'>,
|
|
value: string
|
|
) => {
|
|
const currentAuth = route.auth
|
|
if (!currentAuth || currentAuth.type === 'none') return
|
|
onChange({
|
|
auth: {
|
|
type: currentAuth.type as AdvancedCustomAuthType,
|
|
name: currentAuth.name || '',
|
|
value: currentAuth.value || '',
|
|
[field]: value,
|
|
},
|
|
})
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'flex flex-col gap-4 px-4 py-4 lg:gap-2 lg:px-3 lg:py-3',
|
|
errorMessage && 'bg-destructive/5'
|
|
)}
|
|
>
|
|
<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>
|
|
{isModelListRoute ? (
|
|
<Badge variant='outline'>
|
|
{ADVANCED_CUSTOM_MODEL_LIST_LABEL}
|
|
</Badge>
|
|
) : null}
|
|
{!isModelListRoute && isFallback ? (
|
|
<Badge variant='outline'>{t('Fallback')}</Badge>
|
|
) : null}
|
|
<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>
|
|
<div className='flex shrink-0 items-center gap-1 lg:hidden'>
|
|
<TooltipIconButton
|
|
label={t('Move route up')}
|
|
icon={ArrowUp}
|
|
disabled={!canMoveUp}
|
|
onClick={onMoveUp}
|
|
/>
|
|
<TooltipIconButton
|
|
label={t('Move route down')}
|
|
icon={ArrowDown}
|
|
disabled={!canMoveDown}
|
|
onClick={onMoveDown}
|
|
/>
|
|
{catchAllOutOfOrder ? (
|
|
<TooltipIconButton
|
|
label={t('Move fallback to end')}
|
|
icon={ArrowDownToLine}
|
|
onClick={onMoveCatchAllToEnd}
|
|
/>
|
|
) : null}
|
|
<TooltipIconButton
|
|
label={t('Delete')}
|
|
icon={Trash2}
|
|
onClick={onRemove}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<FieldBlock
|
|
label={
|
|
<span className='inline-flex items-center gap-1'>
|
|
{t('Client model')}
|
|
<ModelRuleHelpPopover />
|
|
</span>
|
|
}
|
|
className='lg:gap-1'
|
|
labelClassName='lg:sr-only'
|
|
>
|
|
{isModelListRoute && parsedRouteModels.length === 0 ? (
|
|
<div className='flex h-9 items-center'>
|
|
<Badge variant='outline'>
|
|
{ADVANCED_CUSTOM_MODEL_LIST_LABEL}
|
|
</Badge>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<Input
|
|
value={modelsInputValue}
|
|
onChange={(event) => setModelsInput(event.target.value)}
|
|
onBlur={(event) => normalizeModelsInput(event.target.value)}
|
|
placeholder={
|
|
isFallback
|
|
? t('Leave empty for fallback')
|
|
: t('e.g. gpt-4o, gemini-2.5-flash')
|
|
}
|
|
aria-invalid={Boolean(errorMessage)}
|
|
/>
|
|
<div className='flex flex-wrap gap-1'>
|
|
{isFallback ? (
|
|
<Badge variant='outline'>{t('Fallback')}</Badge>
|
|
) : (
|
|
parsedRouteModels.map((model) => {
|
|
const ruleKind = getAdvancedCustomModelRuleKind(model)
|
|
const displayModel =
|
|
ruleKind === 'regex'
|
|
? getAdvancedCustomRegexModelPattern(model) || model
|
|
: model
|
|
return (
|
|
<Badge
|
|
key={model}
|
|
variant={ruleKind === 'regex' ? 'outline' : 'secondary'}
|
|
className='max-w-full gap-1.5 font-mono'
|
|
>
|
|
<span className='font-sans text-[10px] font-semibold tracking-normal uppercase'>
|
|
{t(ruleKind === 'regex' ? 'Regex' : 'Exact')}
|
|
</span>
|
|
<span className='truncate'>{displayModel}</span>
|
|
</Badge>
|
|
)
|
|
})
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</FieldBlock>
|
|
|
|
<FieldBlock
|
|
label={t('Upstream path')}
|
|
className='lg:gap-1'
|
|
labelClassName='lg:sr-only'
|
|
>
|
|
<Input
|
|
value={route.upstream_path || ''}
|
|
onChange={(event) =>
|
|
onChange({
|
|
upstream_path: event.target.value,
|
|
})
|
|
}
|
|
placeholder={getAdvancedCustomUpstreamPathPlaceholder(
|
|
converter,
|
|
incomingPath
|
|
)}
|
|
/>
|
|
<p className='text-muted-foreground text-xs leading-relaxed lg:hidden'>
|
|
{t(upstreamPathDescriptionKey)}
|
|
</p>
|
|
</FieldBlock>
|
|
|
|
<FieldBlock
|
|
label={t('Converter')}
|
|
className='lg:gap-1'
|
|
labelClassName='lg:sr-only'
|
|
>
|
|
<Select
|
|
value={converter}
|
|
disabled={isModelListRoute && converter === 'none'}
|
|
onValueChange={(value) =>
|
|
setConverter(value as AdvancedCustomConverter)
|
|
}
|
|
>
|
|
<SelectTrigger className='w-full max-w-full lg:h-8'>
|
|
<SelectValue className='min-w-0 truncate'>
|
|
{t(converterTriggerLabel)}
|
|
</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent
|
|
alignItemWithTrigger={false}
|
|
className={longSelectContentClass}
|
|
>
|
|
<SelectGroup>
|
|
{converterOptions.map((option) => (
|
|
<SelectItem
|
|
key={option.value}
|
|
value={option.value}
|
|
className={longSelectItemClass}
|
|
>
|
|
<span className='min-w-0 leading-snug break-words whitespace-normal'>
|
|
{t(option.label)}
|
|
</span>
|
|
</SelectItem>
|
|
))}
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
</FieldBlock>
|
|
|
|
<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 lg:h-8'>
|
|
<SelectValue className='min-w-0 truncate'>
|
|
{t(authLabel)}
|
|
</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent alignItemWithTrigger={false}>
|
|
<SelectGroup>
|
|
{ADVANCED_CUSTOM_AUTH_MODE_OPTIONS.map((option) => (
|
|
<SelectItem key={option.value} value={option.value}>
|
|
{t(option.label)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
</FieldBlock>
|
|
|
|
<div className='hidden items-center justify-end gap-1 lg:flex'>
|
|
<TooltipIconButton
|
|
label={t('Move route up')}
|
|
icon={ArrowUp}
|
|
disabled={!canMoveUp}
|
|
onClick={onMoveUp}
|
|
/>
|
|
<TooltipIconButton
|
|
label={t('Move route down')}
|
|
icon={ArrowDown}
|
|
disabled={!canMoveDown}
|
|
onClick={onMoveDown}
|
|
/>
|
|
{catchAllOutOfOrder ? (
|
|
<TooltipIconButton
|
|
label={t('Move fallback to end')}
|
|
icon={ArrowDownToLine}
|
|
onClick={onMoveCatchAllToEnd}
|
|
/>
|
|
) : null}
|
|
<TooltipIconButton
|
|
label={t('Delete')}
|
|
icon={Trash2}
|
|
onClick={onRemove}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{errorMessage ? (
|
|
<p className='text-destructive text-xs'>{t(errorMessage)}</p>
|
|
) : null}
|
|
|
|
{authMode === 'header' || authMode === 'query' ? (
|
|
<>
|
|
<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)}
|
|
placeholder={
|
|
authMode === 'header' ? 'Authorization' : 'api_key'
|
|
}
|
|
/>
|
|
</FieldBlock>
|
|
<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)}
|
|
placeholder={
|
|
authMode === 'header' ? 'Bearer {api_key}' : '{api_key}'
|
|
}
|
|
/>
|
|
</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}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ModelRuleHelpPopover() {
|
|
const { t } = useTranslation()
|
|
|
|
return (
|
|
<Popover>
|
|
<PopoverTrigger
|
|
render={
|
|
<Button
|
|
type='button'
|
|
variant='ghost'
|
|
size='icon'
|
|
className='text-muted-foreground hover:text-foreground size-6'
|
|
aria-label={t('Client model matching help')}
|
|
/>
|
|
}
|
|
>
|
|
<Info className='size-3.5' aria-hidden='true' />
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
align='start'
|
|
side='bottom'
|
|
sideOffset={8}
|
|
className='w-[min(22rem,calc(100vw-2rem))] gap-3 p-3'
|
|
>
|
|
<PopoverHeader className='gap-1'>
|
|
<PopoverTitle>{t('Client model matching')}</PopoverTitle>
|
|
<PopoverDescription className='text-xs leading-relaxed'>
|
|
{t(
|
|
'Rules match the original model value from the client request body.'
|
|
)}
|
|
</PopoverDescription>
|
|
</PopoverHeader>
|
|
<div className='text-muted-foreground space-y-2 text-xs leading-relaxed'>
|
|
<p>
|
|
{t(
|
|
'Use exact model names such as gpt-4o, or regex rules prefixed with re: such as re:^gemini-.'
|
|
)}
|
|
</p>
|
|
<p>
|
|
{t(
|
|
'Separate multiple rules with English commas. For regex patterns that need commas, switch to JSON Text.'
|
|
)}
|
|
</p>
|
|
<p>
|
|
{t(
|
|
'Leave the final split empty as the fallback for models not matched above.'
|
|
)}
|
|
</p>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)
|
|
}
|
|
|
|
function TooltipIconButton({
|
|
label,
|
|
icon: Icon,
|
|
disabled,
|
|
onClick,
|
|
}: {
|
|
label: string
|
|
icon: LucideIcon
|
|
disabled?: boolean
|
|
onClick: () => void
|
|
}) {
|
|
return (
|
|
<TooltipProvider delay={100}>
|
|
<Tooltip>
|
|
<TooltipTrigger
|
|
render={
|
|
<Button
|
|
type='button'
|
|
variant='ghost'
|
|
size='icon'
|
|
disabled={disabled}
|
|
onClick={onClick}
|
|
/>
|
|
}
|
|
>
|
|
<Icon data-icon='inline-start' aria-hidden='true' />
|
|
<span className='sr-only'>{label}</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent side='top'>{label}</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
)
|
|
}
|
|
|
|
function FieldBlock({
|
|
label,
|
|
className,
|
|
labelClassName,
|
|
children,
|
|
}: {
|
|
label: ReactNode
|
|
className?: string
|
|
labelClassName?: string
|
|
children: ReactNode
|
|
}) {
|
|
return (
|
|
<div className={cn('flex min-w-0 flex-col gap-2', className)}>
|
|
<span className={cn('text-sm font-medium', labelClassName)}>{label}</span>
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|