Commit 1d166532 by CaIon

fix: update section titles and improve layout in channel components

parent 2d5a0416
......@@ -29,8 +29,14 @@ import { zodResolver } from '@hookform/resolvers/zod'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
ArrowRight,
AlertCircle,
Boxes,
CheckCircle2,
Circle,
HelpCircle,
KeyRound,
Loader2,
Server,
Sparkles,
Trash2,
Copy,
......@@ -47,15 +53,17 @@ import {
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import {
ADMIN_PERMISSION_ACTIONS,
ADMIN_PERMISSION_RESOURCES,
hasPermission,
} from '@/lib/admin-permissions'
import { getLobeIcon } from '@/lib/lobe-icon'
import { ROLE } from '@/lib/roles'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { useHiddenClickUnlock } from '@/hooks/use-hidden-click-unlock'
sideDrawerContentClassName,
sideDrawerFooterClassName,
sideDrawerFormClassName,
sideDrawerHeaderClassName,
sideDrawerSectionClassName,
sideDrawerSwitchItemClassName,
} from '@/components/drawer-layout'
import { JsonEditor } from '@/components/json-editor'
import { MultiSelect } from '@/components/multi-select'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
......@@ -97,20 +105,21 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
sideDrawerContentClassName,
sideDrawerFooterClassName,
sideDrawerFormClassName,
sideDrawerHeaderClassName,
sideDrawerSectionClassName,
sideDrawerSwitchItemClassName,
} from '@/components/drawer-layout'
import { JsonEditor } from '@/components/json-editor'
import { MultiSelect } from '@/components/multi-select'
import {
SecureVerificationDialog,
useSecureVerification,
} from '@/features/auth/secure-verification'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { useHiddenClickUnlock } from '@/hooks/use-hidden-click-unlock'
import {
ADMIN_PERMISSION_ACTIONS,
ADMIN_PERMISSION_RESOURCES,
hasPermission,
} from '@/lib/admin-permissions'
import { getLobeIcon } from '@/lib/lobe-icon'
import { ROLE } from '@/lib/roles'
import { cn } from '@/lib/utils'
import { useAuthStore } from '@/stores/auth-store'
import {
fetchModels,
getAllModels,
......@@ -122,6 +131,7 @@ import {
} from '../../api'
import {
ADD_MODE_OPTIONS,
CHANNEL_STATUS_LABELS,
CHANNEL_TYPE_OPTIONS,
CHANNEL_TYPE_WARNINGS,
ERROR_MESSAGES,
......@@ -187,6 +197,17 @@ type ModelMappingGuardrail = {
exposedTargetModels: string[]
}
type ChannelEditorSectionStatus = 'complete' | 'configured' | 'error' | 'idle'
type ChannelEditorNavItem = {
id: string
title: string
description?: string
statusLabel: string
status: ChannelEditorSectionStatus
icon: ReactNode
}
// Helper functions
const createEmptyModelMappingGuardrail = (): ModelMappingGuardrail => ({
invalidJson: false,
......@@ -313,6 +334,150 @@ function SubHeading({ title, icon }: { title: string; icon?: ReactNode }) {
)
}
function ChannelTypeLogo(props: {
type: number
size?: number
className?: string
}) {
const isKnownType = CHANNEL_TYPE_OPTIONS.some(
(option) => option.value === props.type
)
if (!isKnownType) {
return (
<Server
className={cn('text-muted-foreground shrink-0', props.className)}
style={{
width: props.size ?? 16,
height: props.size ?? 16,
}}
aria-hidden='true'
/>
)
}
return (
<span className={cn('inline-flex shrink-0', props.className)}>
{getLobeIcon(`${getChannelTypeIcon(props.type)}.Color`, props.size ?? 16)}
</span>
)
}
function getSectionStatusIcon(status: ChannelEditorSectionStatus): ReactNode {
if (status === 'error') {
return <AlertCircle className='h-3.5 w-3.5' aria-hidden='true' />
}
if (status === 'complete' || status === 'configured') {
return <CheckCircle2 className='h-3.5 w-3.5' aria-hidden='true' />
}
return <Circle className='h-3.5 w-3.5' aria-hidden='true' />
}
function getCompletionStatus(
hasErrors: boolean,
isComplete: boolean
): ChannelEditorSectionStatus {
if (hasErrors) return 'error'
if (isComplete) return 'complete'
return 'idle'
}
function getSectionStatusLabel(
status: ChannelEditorSectionStatus,
t: (key: string) => string
): string {
if (status === 'error') return t('Error')
if (status === 'complete' || status === 'configured') return t('Ready')
return t('Incomplete')
}
function ChannelEditorNav(props: {
providerLogo: ReactNode
providerLabel: string
statusLabel: string
progressLabel: string
navigationLabel: string
items: ChannelEditorNavItem[]
}) {
return (
<aside className='hidden self-start lg:sticky lg:top-4 lg:z-20 lg:block'>
<div className='flex max-h-[calc(100dvh-12rem)] flex-col gap-3 overflow-y-auto overscroll-contain pr-1'>
<div className='border-border/60 bg-muted/20 rounded-lg border p-3'>
<div className='flex min-w-0 items-center gap-2'>
<span className='bg-background flex size-8 shrink-0 items-center justify-center rounded-md border'>
{props.providerLogo}
</span>
<div className='min-w-0'>
<p className='truncate text-sm font-medium'>
{props.providerLabel}
</p>
<p className='text-muted-foreground truncate text-xs'>
{props.statusLabel} · {props.progressLabel}
</p>
</div>
</div>
</div>
<nav
className='border-border/60 bg-background rounded-lg border p-1'
aria-label={props.navigationLabel}
>
{props.items.map((item) => {
const isError = item.status === 'error'
const isDone =
item.status === 'complete' || item.status === 'configured'
return (
<button
key={item.id}
type='button'
className={cn(
'hover:bg-muted/60 flex w-full items-start gap-2 rounded-md px-2 py-2 text-left transition-colors',
isError && 'text-destructive hover:bg-destructive/10'
)}
onClick={() => {
document
.querySelector<HTMLElement>(`#${item.id}`)
?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}}
>
<span
className={cn(
'bg-muted text-muted-foreground mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-md',
isError && 'bg-destructive/10 text-destructive',
isDone && !isError && 'text-primary'
)}
>
{item.icon}
</span>
<span className='min-w-0 flex-1'>
<span className='block truncate text-sm font-medium'>
{item.title}
</span>
{item.description && (
<span className='text-muted-foreground block truncate text-xs'>
{item.description}
</span>
)}
</span>
<span
className={cn(
'text-muted-foreground mt-1 shrink-0',
isError && 'text-destructive',
isDone && !isError && 'text-primary'
)}
aria-label={item.statusLabel}
>
{getSectionStatusIcon(item.status)}
</span>
</button>
)
})}
</nav>
</div>
</aside>
)
}
export function ChannelMutateDrawer({
open,
onOpenChange,
......@@ -359,9 +524,9 @@ export function ChannelMutateDrawer({
// Fetch channel details if editing
const { data: channelData, isLoading: isChannelLoading } = useQuery({
queryKey: channelsQueryKeys.detail(currentRow?.id || 0),
queryFn: () => getChannel(currentRow!.id),
enabled: isEditing && Boolean(currentRow?.id),
queryKey: channelsQueryKeys.detail(channelId || 0),
queryFn: () => getChannel(channelId || 0),
enabled: isEditing && Boolean(channelId),
})
// Fetch available groups
......@@ -420,7 +585,10 @@ export function ChannelMutateDrawer({
const keyMode = form.watch('key_mode')
const currentGroups = form.watch('group')
const currentType = form.watch('type')
const currentStatus = form.watch('status')
const currentBaseUrl = form.watch('base_url')
const currentKey = form.watch('key')
const currentOther = form.watch('other')
const currentModels = form.watch('models')
const currentName = form.watch('name')
const currentModelMapping = form.watch('model_mapping')
......@@ -496,7 +664,7 @@ export function ChannelMutateDrawer({
const groupOptions = useMemo(() => {
if (!groupsData?.data) return []
const allGroups = new Set([...groupsData.data, ...(currentGroups || [])])
return Array.from(allGroups).map((group) => ({
return [...allGroups].map((group) => ({
value: group,
label: group,
}))
......@@ -519,18 +687,110 @@ export function ChannelMutateDrawer({
const options = CHANNEL_TYPE_OPTIONS.map((option) => ({
value: String(option.value),
label: t(option.label),
icon: getLobeIcon(`${getChannelTypeIcon(option.value)}.Color`, 16),
icon: <ChannelTypeLogo type={option.value} size={16} />,
}))
if (!options.some((option) => Number(option.value) === currentType)) {
options.push({
value: String(currentType),
label: `#${currentType}`,
icon: getLobeIcon(`${getChannelTypeIcon(currentType)}.Color`, 16),
icon: <ChannelTypeLogo type={currentType} size={16} />,
})
}
return options
}, [currentType, t])
const formErrors = form.formState.errors
const identityHasErrors = Boolean(
formErrors.name ||
formErrors.type ||
formErrors.status ||
formErrors.openai_organization
)
const credentialsHaveErrors = Boolean(
formErrors.key ||
formErrors.base_url ||
formErrors.other ||
formErrors.multi_key_mode ||
formErrors.multi_key_type ||
formErrors.key_mode ||
formErrors.vertex_key_type ||
formErrors.aws_key_type ||
formErrors.azure_responses_version
)
const modelsHaveErrors = Boolean(
formErrors.models || formErrors.group || formErrors.model_mapping
)
const advancedHaveErrors =
hasAdvancedSettingsErrors(formErrors) || Boolean(formErrors.advanced_custom)
const providerRequiresBaseUrl = [3, 8, 36, 45].includes(currentType)
const providerRequiresOther = [3, 18, 21, 39, 41, 49].includes(currentType)
const identityComplete = Boolean(currentName?.trim() && currentType > 0)
const credentialsComplete = Boolean(
(isEditing || currentKey?.trim()) &&
(!providerRequiresBaseUrl || currentBaseUrl?.trim()) &&
(!providerRequiresOther || currentOther?.trim())
)
const modelsComplete = Boolean(
currentModelsArray.length > 0 && currentGroups?.length
)
const requiredCompletedCount = [
identityComplete,
credentialsComplete,
modelsComplete,
].filter(Boolean).length
const currentStatusLabel =
CHANNEL_STATUS_LABELS[
currentStatus as keyof typeof CHANNEL_STATUS_LABELS
] || 'Unknown'
const progressLabel = `${requiredCompletedCount}/3`
const identityStatus = getCompletionStatus(
identityHasErrors,
identityComplete
)
const credentialsStatus = getCompletionStatus(
credentialsHaveErrors,
credentialsComplete
)
const modelsStatus = getCompletionStatus(modelsHaveErrors, modelsComplete)
const advancedStatus: ChannelEditorSectionStatus = advancedHaveErrors
? 'error'
: 'idle'
const advancedSummary = advancedHaveErrors ? t('Error') : undefined
const editorNavItems: ChannelEditorNavItem[] = [
{
id: 'channel-section-identity',
title: t('Basic Information'),
description: getSectionStatusLabel(identityStatus, t),
statusLabel: getSectionStatusLabel(identityStatus, t),
status: identityStatus,
icon: <Server className='h-4 w-4' aria-hidden='true' />,
},
{
id: 'channel-section-credentials',
title: t('Credentials'),
description: getSectionStatusLabel(credentialsStatus, t),
statusLabel: getSectionStatusLabel(credentialsStatus, t),
status: credentialsStatus,
icon: <KeyRound className='h-4 w-4' aria-hidden='true' />,
},
{
id: 'channel-section-models',
title: t('Models & Groups'),
description: getSectionStatusLabel(modelsStatus, t),
statusLabel: getSectionStatusLabel(modelsStatus, t),
status: modelsStatus,
icon: <Boxes className='h-4 w-4' aria-hidden='true' />,
},
{
id: 'channel-section-advanced',
title: t('Advanced Settings'),
description: advancedSummary,
statusLabel: advancedSummary ?? t('Advanced Settings'),
status: advancedStatus,
icon: <Settings className='h-4 w-4' aria-hidden='true' />,
},
]
// Extract redirect models from model_mapping (target values)
const redirectModelList = useMemo(
() => extractRedirectModels(currentModelMapping || ''),
......@@ -546,7 +806,7 @@ export function ChannelMutateDrawer({
// Transform models to multi-select options
const modelOptions = useMemo(() => {
const allModels = new Set([...allModelsList, ...currentModelsArray])
return Array.from(allModels).map((model) => ({
return [...allModels].map((model) => ({
value: model,
label: model,
}))
......@@ -577,8 +837,8 @@ export function ChannelMutateDrawer({
return acc
}, [])
const missingSourceModels = Array.from(
new Set(
const missingSourceModels = [
...new Set(
entries
.filter(
(entry) =>
......@@ -586,11 +846,11 @@ export function ChannelMutateDrawer({
!currentModelsArray.includes(entry.source)
)
.map((entry) => entry.source)
)
)
),
]
const exposedTargetModels = Array.from(
new Set(
const exposedTargetModels = [
...new Set(
entries
.filter(
(entry) =>
......@@ -598,8 +858,8 @@ export function ChannelMutateDrawer({
currentModelsArray.includes(entry.target)
)
.map((entry) => entry.target)
)
)
),
]
return {
invalidJson: false,
......@@ -633,7 +893,7 @@ export function ChannelMutateDrawer({
return {
lastCheckTime: settings.upstream_model_update_last_check_time,
detectedModels: Array.from(new Set(detectedModels)),
detectedModels: [...new Set(detectedModels)],
}
}, [currentSettings])
......@@ -1063,9 +1323,10 @@ export function ChannelMutateDrawer({
const hasModelMapping =
typeof data.model_mapping === 'string' &&
data.model_mapping.trim() !== ''
const modelMappingValue = data.model_mapping || ''
if (hasModelMapping) {
const validation = validateModelMappingJson(data.model_mapping!)
const validation = validateModelMappingJson(modelMappingValue)
if (!validation.valid) {
toast.error(t(validation.error || 'Invalid model mapping'))
return
......@@ -1078,7 +1339,7 @@ export function ChannelMutateDrawer({
// Check for missing models in model_mapping
if (hasModelMapping) {
const missingModels = findMissingModelsInMapping(
data.model_mapping!,
modelMappingValue,
normalizedModels
)
......@@ -1097,9 +1358,9 @@ export function ChannelMutateDrawer({
return
}
if (confirmAction === 'add') {
const updatedModels = Array.from(
new Set([...normalizedModels, ...missingModels])
)
const updatedModels = [
...new Set([...normalizedModels, ...missingModels]),
]
data.models = formatModelsArray(updatedModels)
form.setValue('models', data.models)
}
......@@ -1154,11 +1415,11 @@ export function ChannelMutateDrawer({
return (
<>
<Sheet open={open} onOpenChange={handleOpenChange}>
<SheetContent className={sideDrawerContentClassName('sm:max-w-3xl')}>
<SheetContent className={sideDrawerContentClassName('sm:max-w-5xl')}>
<SheetHeader className={sideDrawerHeaderClassName()}>
<SheetTitle className='flex items-center gap-3'>
<span className='bg-muted flex size-9 shrink-0 items-center justify-center rounded-md'>
{getLobeIcon(`${getChannelTypeIcon(currentType)}.Color`, 22)}
<ChannelTypeLogo type={currentType} size={22} />
</span>
<span>
{isEditing ? t('Edit Channel') : t('Create Channel')}
......@@ -1181,7 +1442,9 @@ export function ChannelMutateDrawer({
{sensitiveLocked && (
<Alert className='border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-50'>
<AlertDescription>
{t('Sensitive channel settings are read-only for your account.')}{' '}
{t(
'Sensitive channel settings are read-only for your account.'
)}{' '}
{t(
'You can still edit non-sensitive operations fields such as models, groups, priority, and weight.'
)}
......@@ -1198,27 +1461,22 @@ export function ChannelMutateDrawer({
{isChannelDetailLoading ? (
<ChannelEditorLoadingState />
) : (
<>
<div className='grid gap-5 lg:grid-cols-[13rem_minmax(0,1fr)] lg:items-start'>
<ChannelEditorNav
providerLogo={
<ChannelTypeLogo type={currentType} size={18} />
}
providerLabel={t(currentTypeLabel)}
statusLabel={t(currentStatusLabel)}
progressLabel={progressLabel}
navigationLabel={t('Channels')}
items={editorNavItems}
/>
<div className='flex min-w-0 flex-col gap-5'>
{/* ── Basic Information ── */}
<div id='channel-section-identity' className='scroll-mt-4'>
<ChannelBasicSection>
<div className='grid gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Name *')}</FormLabel>
<FormControl>
<Input
placeholder={t(FIELD_PLACEHOLDERS.NAME)}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<fieldset
disabled={sensitiveLocked}
className='min-w-0 disabled:opacity-60'
......@@ -1230,6 +1488,13 @@ export function ChannelMutateDrawer({
<FormItem>
<FormLabel>{t('Type *')}</FormLabel>
<FormControl>
<div className='relative'>
<span className='pointer-events-none absolute top-1/2 left-3 z-10 flex -translate-y-1/2'>
<ChannelTypeLogo
type={Number(field.value)}
size={18}
/>
</span>
<Combobox
options={channelTypeOptions}
value={String(field.value)}
......@@ -1247,8 +1512,10 @@ export function ChannelMutateDrawer({
'Search channel type...'
)}
emptyText={t('No channel type found.')}
className='pl-10'
allowCustomValue
/>
</div>
</FormControl>
{sensitiveLocked && (
<FormDescription>
......@@ -1262,6 +1529,23 @@ export function ChannelMutateDrawer({
)}
/>
</fieldset>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Name *')}</FormLabel>
<FormControl>
<Input
placeholder={t(FIELD_PLACEHOLDERS.NAME)}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{!isEditing && (
......@@ -1269,7 +1553,9 @@ export function ChannelMutateDrawer({
control={form.control}
name='status'
render={({ field }) => (
<FormItem className={sideDrawerSwitchItemClassName()}>
<FormItem
className={sideDrawerSwitchItemClassName()}
>
<div className='flex flex-col gap-0.5'>
<FormLabel>{t('Enabled')}</FormLabel>
<FormDescription className='text-xs'>
......@@ -1299,9 +1585,14 @@ export function ChannelMutateDrawer({
name='openai_organization'
render={({ field }) => (
<FormItem>
<FormLabel>{t('OpenAI Organization')}</FormLabel>
<FormLabel>
{t('OpenAI Organization')}
</FormLabel>
<FormControl>
<Input placeholder={t('org-...')} {...field} />
<Input
placeholder={t('org-...')}
{...field}
/>
</FormControl>
<FormDescription>
{sensitiveLocked
......@@ -1317,8 +1608,13 @@ export function ChannelMutateDrawer({
</fieldset>
)}
</ChannelBasicSection>
</div>
{/* ── API Access ── */}
<div
id='channel-section-credentials'
className='scroll-mt-4'
>
<ChannelApiAccessSection>
{CHANNEL_TYPE_WARNINGS[currentType] && (
<Alert>
......@@ -1331,13 +1627,12 @@ export function ChannelMutateDrawer({
{sensitiveLocked && (
<Alert className='border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-50'>
<AlertDescription>
{t(
'No permission to perform this action'
)}
{t('No permission to perform this action')}
</AlertDescription>
</Alert>
)}
<div className='border-border/60 bg-muted/10 rounded-lg border p-4'>
<fieldset
disabled={sensitiveLocked}
className='space-y-4 disabled:opacity-60'
......@@ -1378,12 +1673,16 @@ export function ChannelMutateDrawer({
</FormLabel>
<FormControl>
<Input
placeholder={t('e.g., 2025-04-01-preview')}
placeholder={t(
'e.g., 2025-04-01-preview'
)}
{...field}
/>
</FormControl>
<FormDescription>
{t('Default API version for this channel')}
{t(
'Default API version for this channel'
)}
</FormDescription>
<FormMessage />
</FormItem>
......@@ -1436,7 +1735,8 @@ export function ChannelMutateDrawer({
/>
</FormControl>
<FormDescription>
{t('Enter the complete URL, supports')} {'{'}
{t('Enter the complete URL, supports')}{' '}
{'{'}
{t('model')}
{'}'} {t('variable')}
</FormDescription>
......@@ -1453,9 +1753,14 @@ export function ChannelMutateDrawer({
name='other'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Model Version *')}</FormLabel>
<FormLabel>
{t('Model Version *')}
</FormLabel>
<FormControl>
<Input placeholder={t('e.g., v2.1')} {...field} />
<Input
placeholder={t('e.g., v2.1')}
{...field}
/>
</FormControl>
<FormDescription>
{t(
......@@ -1476,7 +1781,9 @@ export function ChannelMutateDrawer({
render={({ field }) => (
<FormItem className='flex items-center justify-between'>
<div className='space-y-0.5'>
<FormLabel>{t('Enterprise Account')}</FormLabel>
<FormLabel>
{t('Enterprise Account')}
</FormLabel>
<FormDescription>
{t(
'Enable if this is an OpenRouter enterprise account with special response format'
......@@ -1506,9 +1813,14 @@ export function ChannelMutateDrawer({
items={[
{
value: 'ak_sk',
label: t('AccessKey / SecretAccessKey'),
label: t(
'AccessKey / SecretAccessKey'
),
},
{
value: 'api_key',
label: t('API Key'),
},
{ value: 'api_key', label: t('API Key') },
]}
onValueChange={field.onChange}
value={field.value}
......@@ -1520,7 +1832,9 @@ export function ChannelMutateDrawer({
/>
</SelectTrigger>
</FormControl>
<SelectContent alignItemWithTrigger={false}>
<SelectContent
alignItemWithTrigger={false}
>
<SelectGroup>
<SelectItem value='ak_sk'>
{t('AccessKey / SecretAccessKey')}
......@@ -1551,7 +1865,9 @@ export function ChannelMutateDrawer({
name='other'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Knowledge Base ID *')}</FormLabel>
<FormLabel>
{t('Knowledge Base ID *')}
</FormLabel>
<FormControl>
<Input
placeholder={t('e.g., 123456')}
......@@ -1574,7 +1890,9 @@ export function ChannelMutateDrawer({
name='base_url'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Private Deployment URL')}</FormLabel>
<FormLabel>
{t('Private Deployment URL')}
</FormLabel>
<FormControl>
<Input
placeholder={t(
......@@ -1602,7 +1920,9 @@ export function ChannelMutateDrawer({
render={({ field }) => (
<FormItem>
<FormLabel>
{t('API Base URL (Important: Not Chat API) *')}
{t(
'API Base URL (Important: Not Chat API) *'
)}
</FormLabel>
<FormControl>
<Input
......@@ -1659,7 +1979,9 @@ export function ChannelMutateDrawer({
rel='noopener noreferrer'
className='text-primary underline'
>
{t('https://cloud.siliconflow.cn/i/hij0YNTZ')}
{t(
'https://cloud.siliconflow.cn/i/hij0YNTZ'
)}
</a>
</AlertDescription>
</Alert>
......@@ -1673,11 +1995,16 @@ export function ChannelMutateDrawer({
name='vertex_key_type'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Vertex AI Key Format')}</FormLabel>
<FormLabel>
{t('Vertex AI Key Format')}
</FormLabel>
<Select
items={[
{ value: 'json', label: t('JSON') },
{ value: 'api_key', label: t('API Key') },
{
value: 'api_key',
label: t('API Key'),
},
]}
onValueChange={field.onChange}
value={field.value}
......@@ -1687,7 +2014,9 @@ export function ChannelMutateDrawer({
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent alignItemWithTrigger={false}>
<SelectContent
alignItemWithTrigger={false}
>
<SelectGroup>
<SelectItem value='json'>
{t('JSON')}
......@@ -1724,13 +2053,15 @@ export function ChannelMutateDrawer({
onChange={async (e) => {
const fileList = e.target.files
const files = fileList
? Array.from(fileList)
? [...fileList]
: []
// allow re-selecting the same file
e.target.value = ''
if (files.length === 0) {
toast.info(t('Please upload key file(s)'))
toast.info(
t('Please upload key file(s)')
)
return
}
......@@ -1753,7 +2084,9 @@ export function ChannelMutateDrawer({
}
if (keys.length === 0) {
toast.info(t('Please upload key file(s)'))
toast.info(
t('Please upload key file(s)')
)
return
}
......@@ -1779,7 +2112,9 @@ export function ChannelMutateDrawer({
</FormControl>
<FormDescription>
{isBatchMode
? t('Upload multiple JSON files in batch modes')
? t(
'Upload multiple JSON files in batch modes'
)
: t(
'Upload a single service account JSON file'
)}
......@@ -1792,7 +2127,9 @@ export function ChannelMutateDrawer({
name='other'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Deployment Region *')}</FormLabel>
<FormLabel>
{t('Deployment Region *')}
</FormLabel>
<FormControl>
<Textarea
placeholder={t(
......@@ -1803,7 +2140,9 @@ export function ChannelMutateDrawer({
/>
</FormControl>
<FormDescription>
{t('Enter deployment region or JSON mapping:')}{' '}
{t(
'Enter deployment region or JSON mapping:'
)}{' '}
{'{'}
{t(
'"default": "us-central1", "claude-3-5-sonnet-20240620": "europe-west1"'
......@@ -1833,8 +2172,11 @@ export function ChannelMutateDrawer({
<Select
items={[
{
value: 'https://ark.cn-beijing.volces.com',
label: t('https://ark.cn-beijing.volces.com'),
value:
'https://ark.cn-beijing.volces.com',
label: t(
'https://ark.cn-beijing.volces.com'
),
},
{
value:
......@@ -1857,10 +2199,14 @@ export function ChannelMutateDrawer({
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent alignItemWithTrigger={false}>
<SelectContent
alignItemWithTrigger={false}
>
<SelectGroup>
<SelectItem value='https://ark.cn-beijing.volces.com'>
{t('https://ark.cn-beijing.volces.com')}
{t(
'https://ark.cn-beijing.volces.com'
)}
</SelectItem>
<SelectItem value='https://ark.ap-southeast.bytepluses.com'>
{t(
......@@ -1937,7 +2283,9 @@ export function ChannelMutateDrawer({
<FormLabel>{t('Base URL')}</FormLabel>
<FormControl>
<Input
placeholder={t(FIELD_PLACEHOLDERS.BASE_URL)}
placeholder={t(
FIELD_PLACEHOLDERS.BASE_URL
)}
{...field}
/>
</FormControl>
......@@ -2002,24 +2350,29 @@ export function ChannelMutateDrawer({
control={form.control}
name='multi_key_mode'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Add Mode')}</FormLabel>
<FormItem className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
<FormLabel className='text-muted-foreground text-xs font-medium'>
{t('Add Mode')}
</FormLabel>
<Select
items={[
...addModeOptions.map((option) => ({
items={addModeOptions.map((option) => ({
value: option.value,
label: t(option.label),
})),
]}
}))}
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectTrigger
size='sm'
className='w-full sm:w-56'
>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent alignItemWithTrigger={false}>
<SelectContent
alignItemWithTrigger={false}
>
<SelectGroup>
{addModeOptions.map((option) => (
<SelectItem
......@@ -2032,13 +2385,6 @@ export function ChannelMutateDrawer({
</SelectGroup>
</SelectContent>
</Select>
<FormDescription>
{t(
supportsMultiKeyAddMode
? FIELD_DESCRIPTIONS.BATCH_ADD
: FIELD_DESCRIPTIONS.KEY
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
......@@ -2049,33 +2395,74 @@ export function ChannelMutateDrawer({
control={form.control}
name='key'
render={({ field }) => {
const keyPlaceholder = (() => {
let keyPlaceholder = t(
getKeyPromptForType(currentType)
)
if (isEditing) {
return t('Leave empty to keep existing key')
}
if (currentType === 33) {
if (awsKeyType === 'api_key') {
return isBatchMode
? t(
keyPlaceholder = t(
'Leave empty to keep existing key'
)
} else if (
currentType === 33 &&
awsKeyType === 'api_key' &&
isBatchMode
) {
keyPlaceholder = t(
'Enter API Key, one per line, format: APIKey|Region'
)
: t('Enter API Key, format: APIKey|Region')
}
return isBatchMode
? t(
} else if (
currentType === 33 &&
awsKeyType === 'api_key'
) {
keyPlaceholder = t(
'Enter API Key, format: APIKey|Region'
)
} else if (
currentType === 33 &&
isBatchMode
) {
keyPlaceholder = t(
'Enter key, one per line, format: AccessKey|SecretAccessKey|Region'
)
: t(
} else if (currentType === 33) {
keyPlaceholder = t(
'Enter key, format: AccessKey|SecretAccessKey|Region'
)
}
if (isBatchMode) {
return t(
} else if (isBatchMode) {
keyPlaceholder = t(
'Enter one key per line for batch creation'
)
}
return t(getKeyPromptForType(currentType))
})()
let keyDescription: ReactNode = t(
FIELD_DESCRIPTIONS.KEY
)
if (isEditing) {
let keyModeDescription = t(
'Append mode: New keys will be added to the end of the existing key list'
)
if (keyMode === 'replace') {
keyModeDescription = t(
'Replace mode: Will completely replace all existing keys'
)
}
keyDescription = (
<>
{t(
'Enter new key to update, or leave empty to keep current key'
)}
{isMultiKeyChannel && (
<span className='text-warning mt-1 block'>
{keyModeDescription}
</span>
)}
</>
)
} else if (isBatchMode) {
keyDescription = t(
'Enter one API key per line for batch creation'
)
}
return (
<FormItem>
<FormLabel>{t('API Key *')}</FormLabel>
......@@ -2088,31 +2475,7 @@ export function ChannelMutateDrawer({
</FormControl>
<FormDescription>
<div className='flex flex-col gap-2'>
<span>
{isEditing ? (
<>
{t(
'Enter new key to update, or leave empty to keep current key'
)}
{isMultiKeyChannel && (
<span className='text-warning mt-1 block'>
{t(
'Multi-key channel: Keys will be'
)}{' '}
{keyMode === 'replace'
? t('replaced')
: t('appended')}
</span>
)}
</>
) : isBatchMode ? (
t(
'Enter one API key per line for batch creation'
)
) : (
t(FIELD_DESCRIPTIONS.KEY)
)}
</span>
<span>{keyDescription}</span>
{isBatchMode && (
<Button
type='button'
......@@ -2165,7 +2528,9 @@ export function ChannelMutateDrawer({
size='sm'
onClick={async () => {
if (channelKey) {
await copyToClipboard(channelKey)
await copyToClipboard(
channelKey
)
}
}}
disabled={!channelKey}
......@@ -2178,7 +2543,9 @@ export function ChannelMutateDrawer({
<Input
readOnly
value={channelKey ?? ''}
placeholder={t('Hidden — verify to reveal')}
placeholder={t(
'Hidden — verify to reveal'
)}
className='font-mono'
/>
</div>
......@@ -2237,7 +2604,9 @@ export function ChannelMutateDrawer({
name='key_mode'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Key Update Mode')}</FormLabel>
<FormLabel>
{t('Key Update Mode')}
</FormLabel>
<Select
items={[
{
......@@ -2246,7 +2615,9 @@ export function ChannelMutateDrawer({
},
{
value: 'replace',
label: t('Replace all existing keys'),
label: t(
'Replace all existing keys'
),
},
]}
onValueChange={field.onChange}
......@@ -2257,7 +2628,9 @@ export function ChannelMutateDrawer({
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent alignItemWithTrigger={false}>
<SelectContent
alignItemWithTrigger={false}
>
<SelectGroup>
<SelectItem value='append'>
{t('Append to existing keys')}
......@@ -2283,17 +2656,26 @@ export function ChannelMutateDrawer({
/>
)}
{!isEditing && multiKeyMode === 'multi_to_single' && (
{!isEditing &&
multiKeyMode === 'multi_to_single' && (
<FormField
control={form.control}
name='multi_key_type'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Multi-Key Strategy')}</FormLabel>
<FormLabel>
{t('Multi-Key Strategy')}
</FormLabel>
<Select
items={[
{ value: 'random', label: t('Random') },
{ value: 'polling', label: t('Polling') },
{
value: 'random',
label: t('Random'),
},
{
value: 'polling',
label: t('Polling'),
},
]}
onValueChange={field.onChange}
value={field.value}
......@@ -2303,7 +2685,9 @@ export function ChannelMutateDrawer({
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent alignItemWithTrigger={false}>
<SelectContent
alignItemWithTrigger={false}
>
<SelectGroup>
<SelectItem value='random'>
{t('Random')}
......@@ -2334,9 +2718,12 @@ export function ChannelMutateDrawer({
)}
</ChannelAuthSection>
</fieldset>
</div>
</ChannelApiAccessSection>
</div>
{/* ── Models & Groups ── */}
<div id='channel-section-models' className='scroll-mt-4'>
<ChannelModelsSection>
<div className='space-y-5'>
<div className='border-border/60 bg-muted/10 rounded-lg border p-4'>
......@@ -2395,7 +2782,8 @@ export function ChannelMutateDrawer({
)
updateModels(
currentModelsArray.filter(
(model) => !hiddenTargets.has(model)
(model) =>
!hiddenTargets.has(model)
)
)
}}
......@@ -2512,7 +2900,9 @@ export function ChannelMutateDrawer({
type='button'
variant='secondary'
size='sm'
onClick={() => handleAddPrefillGroup(group)}
onClick={() =>
handleAddPrefillGroup(group)
}
>
{group.name}
</Button>
......@@ -2562,7 +2952,8 @@ export function ChannelMutateDrawer({
{t('Request flow')}
</p>
<div className='space-y-1 font-mono text-xs'>
{mappingPreviewPairs.map((pair) => (
{mappingPreviewPairs.map(
(pair) => (
<div
key={`${pair.source}-${pair.target}`}
className='flex items-center gap-1'
......@@ -2574,7 +2965,8 @@ export function ChannelMutateDrawer({
/>
<span>{pair.target}</span>
</div>
))}
)
)}
{remainingMappingCount > 0 && (
<div className='text-[11px] opacity-70'>
+{remainingMappingCount}{' '}
......@@ -2618,7 +3010,9 @@ export function ChannelMutateDrawer({
<code className='font-mono'>
{'{"gpt-4":"Azure-GPT4"}'}
</code>
{t('. Please fix the JSON before saving.')}
{t(
'. Please fix the JSON before saving.'
)}
</AlertDescription>
</Alert>
)}
......@@ -2677,7 +3071,9 @@ export function ChannelMutateDrawer({
options={groupOptions}
selected={field.value}
onChange={field.onChange}
placeholder={t(FIELD_PLACEHOLDERS.GROUP)}
placeholder={t(
FIELD_PLACEHOLDERS.GROUP
)}
/>
)}
</FormControl>
......@@ -2688,10 +3084,13 @@ export function ChannelMutateDrawer({
</div>
</div>
</ChannelModelsSection>
</div>
<div id='channel-section-advanced' className='scroll-mt-4'>
<ChannelAdvancedSection
open={advancedSettingsOpen}
onOpenChange={handleAdvancedSettingsOpenChange}
summary={advancedSummary}
>
{/* ── Routing & Overrides ── */}
<div className={sideDrawerSectionClassName()}>
......@@ -2762,7 +3161,9 @@ export function ChannelMutateDrawer({
<FormLabel>{t('Test Model')}</FormLabel>
<FormControl>
<Input
placeholder={t(FIELD_PLACEHOLDERS.TEST_MODEL)}
placeholder={t(
FIELD_PLACEHOLDERS.TEST_MODEL
)}
{...field}
/>
</FormControl>
......@@ -2832,7 +3233,9 @@ export function ChannelMutateDrawer({
<FormLabel>{t('Remark')}</FormLabel>
<FormControl>
<Textarea
placeholder={t(FIELD_PLACEHOLDERS.REMARK)}
placeholder={t(
FIELD_PLACEHOLDERS.REMARK
)}
rows={2}
{...field}
/>
......@@ -2973,7 +3376,9 @@ export function ChannelMutateDrawer({
<Textarea
value={field.value || ''}
onChange={field.onChange}
disabled={sensitiveLocked || isSubmitting}
disabled={
sensitiveLocked || isSubmitting
}
rows={8}
placeholder={t(
'Override request parameters. Cannot override stream parameter.'
......@@ -3011,8 +3416,10 @@ export function ChannelMutateDrawer({
{
'*': true,
're:^X-Trace-.*$': true,
'X-Foo': '{client_header:X-Foo}',
Authorization: 'Bearer {api_key}',
'X-Foo':
'{client_header:X-Foo}',
Authorization:
'Bearer {api_key}',
},
null,
2
......@@ -3028,7 +3435,11 @@ export function ChannelMutateDrawer({
size='sm'
onClick={() =>
field.onChange(
JSON.stringify({ '*': true }, null, 2)
JSON.stringify(
{ '*': true },
null,
2
)
)
}
>
......@@ -3046,7 +3457,7 @@ export function ChannelMutateDrawer({
field.onChange(
JSON.stringify(parsed, null, 2)
)
} catch (_e) {
} catch {
/* ignore invalid JSON */
}
}}
......@@ -3069,7 +3480,9 @@ export function ChannelMutateDrawer({
rows={6}
value={field.value || ''}
onChange={field.onChange}
disabled={sensitiveLocked || isSubmitting}
disabled={
sensitiveLocked || isSubmitting
}
placeholder={t(
'Enter JSON to override request headers'
)}
......@@ -3103,9 +3516,7 @@ export function ChannelMutateDrawer({
{sensitiveLocked && (
<Alert className='border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-50'>
<AlertDescription>
{t(
'No permission to perform this action'
)}
{t('No permission to perform this action')}
</AlertDescription>
</Alert>
)}
......@@ -3117,7 +3528,9 @@ export function ChannelMutateDrawer({
<div className='border-border/60 flex flex-col gap-3 border-y py-4'>
<SubHeading
title={t('Field passthrough controls')}
icon={<SlidersHorizontal className='h-3.5 w-3.5' />}
icon={
<SlidersHorizontal className='h-3.5 w-3.5' />
}
/>
<div className='divide-border space-y-0 divide-y border-y'>
......@@ -3128,10 +3541,14 @@ export function ChannelMutateDrawer({
<FormItem className='flex items-center justify-between gap-3 px-4 py-3'>
<div className='space-y-0.5'>
<FormLabel className='text-sm'>
{t('Allow service_tier passthrough')}
{t(
'Allow service_tier passthrough'
)}
</FormLabel>
<FormDescription>
{t('Pass through the service_tier field')}
{t(
'Pass through the service_tier field'
)}
</FormDescription>
</div>
<FormControl>
......@@ -3263,7 +3680,9 @@ export function ChannelMutateDrawer({
<FormItem className='flex items-center justify-between gap-3 px-4 py-3'>
<div className='space-y-0.5'>
<FormLabel className='text-sm'>
{t('Allow inference_geo passthrough')}
{t(
'Allow inference_geo passthrough'
)}
</FormLabel>
<FormDescription>
{t(
......@@ -3346,7 +3765,9 @@ export function ChannelMutateDrawer({
render={({ field }) => (
<FormItem className='flex items-center justify-between px-4 py-3'>
<div className='space-y-0.5'>
<FormLabel>{t('Force Format')}</FormLabel>
<FormLabel>
{t('Force Format')}
</FormLabel>
<FormDescription>
{t(
'Force format response to OpenAI standard (OpenAI channel only)'
......@@ -3395,9 +3816,13 @@ export function ChannelMutateDrawer({
render={({ field }) => (
<FormItem className='flex items-center justify-between px-4 py-3'>
<div className='space-y-0.5'>
<FormLabel>{t('Pass Through Body')}</FormLabel>
<FormLabel>
{t('Pass Through Body')}
</FormLabel>
<FormDescription>
{t('Pass request body directly to upstream')}
{t(
'Pass request body directly to upstream'
)}
</FormDescription>
</div>
<FormControl>
......@@ -3444,7 +3869,9 @@ export function ChannelMutateDrawer({
<FormLabel>{t('Proxy Address')}</FormLabel>
<FormControl>
<Input
placeholder={t('socks5://user:pass@host:port')}
placeholder={t(
'socks5://user:pass@host:port'
)}
{...field}
/>
</FormControl>
......@@ -3474,7 +3901,9 @@ export function ChannelMutateDrawer({
/>
</FormControl>
<FormDescription>
{t('Default system prompt for this channel')}
{t(
'Default system prompt for this channel'
)}
</FormDescription>
<FormMessage />
</FormItem>
......@@ -3595,28 +4024,33 @@ export function ChannelMutateDrawer({
<span className='text-foreground font-medium'>
{t('Last check time')}:
</span>{' '}
{formatUnixTime(upstreamUpdateMeta.lastCheckTime)}
{formatUnixTime(
upstreamUpdateMeta.lastCheckTime
)}
</div>
<div>
<span className='text-foreground font-medium'>
{t('Last detected addable models')}:
</span>{' '}
{upstreamUpdateMeta.detectedModels.length ===
0 ? (
{upstreamUpdateMeta.detectedModels
.length === 0 ? (
t('None')
) : (
<>
<span className='break-all'>
{upstreamDetectedModelsPreview.join(', ')}
{upstreamDetectedModelsPreview.join(
', '
)}
</span>
{upstreamDetectedModelsOmittedCount > 0 && (
{upstreamDetectedModelsOmittedCount >
0 && (
<span className='ml-1'>
{t(
'({{total}} total, {{omit}} omitted)',
{
total:
upstreamUpdateMeta.detectedModels
.length,
upstreamUpdateMeta
.detectedModels.length,
omit: upstreamDetectedModelsOmittedCount,
}
)}
......@@ -3631,7 +4065,9 @@ export function ChannelMutateDrawer({
</fieldset>
</div>
</ChannelAdvancedSection>
</>
</div>
</div>
</div>
)}
</form>
</Form>
......
import { ChevronDown, Settings } from 'lucide-react'
/*
Copyright (C) 2023-2026 QuantumNous
......@@ -17,19 +18,20 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type { ReactNode } from 'react'
import { ChevronDown, Settings } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { cn } from '@/lib/utils'
type ChannelAdvancedSectionProps = {
children: ReactNode
open: boolean
onOpenChange: (open: boolean) => void
summary?: ReactNode
}
export function ChannelAdvancedSection(props: ChannelAdvancedSectionProps) {
......@@ -55,7 +57,8 @@ export function ChannelAdvancedSection(props: ChannelAdvancedSectionProps) {
{t('Advanced Settings')}
</div>
<div className='text-muted-foreground text-xs'>
{t(
{props.summary ??
t(
'Request overrides, routing behavior, and upstream model automation'
)}
</div>
......
......@@ -17,8 +17,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type { ReactNode } from 'react'
import { Link2 } from 'lucide-react'
import { KeyRound } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import {
SideDrawerSection,
SideDrawerSectionHeader,
......@@ -34,11 +35,9 @@ export function ChannelApiAccessSection(props: ChannelApiAccessSectionProps) {
return (
<SideDrawerSection>
<SideDrawerSectionHeader
title={t('API Access')}
description={t(
'Endpoint, provider-specific settings, and credentials.'
)}
icon={<Link2 className='h-4 w-4' aria-hidden='true' />}
title={t('Credentials')}
description={t('Authentication')}
icon={<KeyRound className='h-4 w-4' aria-hidden='true' />}
/>
{props.children}
</SideDrawerSection>
......
......@@ -28,7 +28,7 @@ export function ChannelAuthSection(props: ChannelAuthSectionProps) {
const { t } = useTranslation()
return (
<div className='border-border/60 flex flex-col gap-4 border-t pt-4'>
<div className='border-border/60 flex flex-col gap-3 border-t pt-4'>
<div className='flex items-center gap-2'>
<KeyRound
className='text-muted-foreground h-3.5 w-3.5'
......
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