Commit 25f99859 by CaIon

feat: refine channel management UI

parent 1d166532
......@@ -43,6 +43,23 @@ type EditorRow = {
value: string
}
function parseJsonRows(json: string): EditorRow[] {
try {
if (!json.trim()) return []
const parsed = JSON.parse(json)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return []
}
return Object.entries(parsed).map(([key, val], index) => ({
id: `${Date.now()}-${index}`,
key,
value: typeof val === 'object' ? JSON.stringify(val) : String(val),
}))
} catch {
return []
}
}
export function JsonEditor({
value,
onChange,
......@@ -63,27 +80,11 @@ export function JsonEditor({
const resolvedKeyLabel = keyLabel ?? t('Key')
const resolvedValueLabel = valueLabel ?? t('Value')
const [mode, setMode] = useState<'visual' | 'json'>('visual')
const [rows, setRows] = useState<EditorRow[]>([])
const [rows, setRows] = useState<EditorRow[]>(() => parseJsonRows(value))
const [jsonValue, setJsonValue] = useState(value)
const parseJsonToRows = (json: string) => {
try {
if (!json.trim()) {
setRows([])
return
}
const parsed = JSON.parse(json)
const newRows: EditorRow[] = Object.entries(parsed).map(
([key, val], index) => ({
id: `${Date.now()}-${index}`,
key,
value: typeof val === 'object' ? JSON.stringify(val) : String(val),
})
)
setRows(newRows)
} catch (_error) {
// Invalid JSON, keep current rows
}
setRows(parseJsonRows(json))
}
// Parse JSON to rows when value changes externally
......@@ -228,7 +229,7 @@ export function JsonEditor({
<div className='grid grid-cols-[1fr_1fr_auto] gap-2 text-sm font-medium'>
<div>{resolvedKeyLabel}</div>
<div>{resolvedValueLabel}</div>
<div className='w-10'></div>
<div className='w-10' />
</div>
{rows.map((row) => (
<div
......
......@@ -37,6 +37,7 @@ interface ComboboxInputProps {
className?: string
id?: string
allowCustomValue?: boolean
openOnFocus?: boolean
}
export function ComboboxInput({
......@@ -48,6 +49,7 @@ export function ComboboxInput({
className,
id,
allowCustomValue = false,
openOnFocus = true,
}: ComboboxInputProps) {
const { t } = useTranslation()
const [open, setOpen] = React.useState(false)
......@@ -56,6 +58,7 @@ export function ComboboxInput({
const containerRef = React.useRef<HTMLDivElement>(null)
const inputRef = React.useRef<HTMLInputElement>(null)
const listRef = React.useRef<HTMLUListElement>(null)
const pointerFocusRef = React.useRef(false)
const selectedOption = React.useMemo(
() => options.find((option) => option.value === value),
[options, value]
......@@ -175,9 +178,18 @@ export function ComboboxInput({
}
if (!open) setOpen(true)
}}
onPointerDown={() => {
pointerFocusRef.current = true
if (document.activeElement === inputRef.current && !open) {
setOpen(true)
}
}}
onFocus={() => {
setSearchValue(allowCustomValue && !selectedOption ? value : '')
setOpen(true)
if (openOnFocus || pointerFocusRef.current) {
setOpen(true)
}
pointerFocusRef.current = false
}}
onKeyDown={handleKeyDown}
className={cn('pr-9', className)}
......
......@@ -47,6 +47,7 @@ type LegacyComboboxProps = {
allowCustomValue?: boolean
className?: string
id?: string
openOnFocus?: boolean
}
function Combobox(props: LegacyComboboxProps): React.ReactElement
......@@ -69,6 +70,7 @@ function Combobox(
emptyText={props.emptyText}
className={props.className}
allowCustomValue={props.allowCustomValue}
openOnFocus={props.openOnFocus}
/>
)
}
......
......@@ -16,11 +16,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { memo } from 'react'
import { flexRender, type Row } from '@tanstack/react-table'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { GroupBadge } from '@/components/group-badge'
import { cn } from '@/lib/utils'
import { CHANNEL_STATUS } from '../constants'
import { isTagAggregateRow, parseGroupsList } from '../lib'
import type { Channel } from '../types'
......@@ -87,86 +89,92 @@ function ChannelCardComponent({
row.original.status !== CHANNEL_STATUS.MANUAL_DISABLED)
return (
<div
data-state={isSelected ? 'selected' : undefined}
className='flex flex-col gap-3'
>
{/* Row 1: selection + type, with status badge + actions menu */}
<div className='flex items-center justify-between gap-2'>
<div className='flex min-w-0 flex-1 items-center gap-2'>
{!isTagRow && selectCell && (
<span className='shrink-0'>{selectCell}</span>
)}
<div className='min-w-0 overflow-hidden'>{typeCell}</div>
</div>
<div className='flex shrink-0 items-center gap-1.5'>
{showStatusBadge && statusCell}
<ChannelRowActionsLayoutContext.Provider value='card'>
<ChannelRowActionsLayoutContext.Provider value='card'>
<div
data-state={isSelected ? 'selected' : undefined}
className='flex flex-col gap-3'
>
{/* Row 1: selection + type, with status badge + actions menu */}
<div className='flex items-center justify-between gap-2'>
<div className='flex min-w-0 flex-1 items-center gap-2'>
{!isTagRow && selectCell && (
<span className='shrink-0'>{selectCell}</span>
)}
<div className='min-w-0 overflow-hidden'>{typeCell}</div>
</div>
<div className='flex shrink-0 items-center gap-1.5'>
{showStatusBadge && statusCell}
{actionsCell}
</ChannelRowActionsLayoutContext.Provider>
</div>
</div>
</div>
{/* Body: left column (id/name + balance) paired with a right-aligned
{/* Body: left column (id/name + balance) paired with a right-aligned
column (priority/weight + response/test time). */}
<div className='flex items-start justify-between gap-3'>
{/* Left column */}
<div className='flex min-w-0 flex-1 flex-col gap-3 overflow-hidden'>
<div className='min-w-0 text-sm'>
{!isTagRow && (
<div className={labelClass}>
#{sensitiveVisible ? row.original.id : SENSITIVE_MASK}
<div className='flex items-start justify-between gap-3'>
{/* Left column */}
<div className='flex min-w-0 flex-1 flex-col gap-3 overflow-hidden'>
<div className='min-w-0 text-sm'>
{!isTagRow && (
<div className={labelClass}>
#{sensitiveVisible ? row.original.id : SENSITIVE_MASK}
</div>
)}
{nameCell}
</div>
<div className='min-w-0'>
<div className={cn('mb-1', labelClass)}>
{fieldLabels.balance}
</div>
<div className='min-w-0 overflow-hidden text-sm'>
{balanceCell ?? (
<span className='text-muted-foreground'>-</span>
)}
</div>
)}
{nameCell}
</div>
<div className='min-w-0'>
<div className={cn('mb-1', labelClass)}>{fieldLabels.balance}</div>
<div className='min-w-0 overflow-hidden text-sm'>
{balanceCell ?? <span className='text-muted-foreground'>-</span>}
</div>
</div>
</div>
{/* Right column (sits on the right, content left-aligned). A single
{/* Right column (sits on the right, content left-aligned). A single
grid with content-sized columns keeps Priority/Weight and
Response/Last Tested aligned without wasting horizontal space. */}
<div className='grid shrink-0 grid-cols-[auto_auto] items-center gap-x-3 gap-y-1'>
<span className={labelClass}>{t('Priority')}</span>
<span className={labelClass}>{t('Weight')}</span>
<div className='flex justify-start'>{priorityCell}</div>
<div className='flex justify-start'>{weightCell}</div>
<span className={cn('mt-2', labelClass)}>
{fieldLabels.response_time}
</span>
<span className={cn('mt-2', labelClass)}>{fieldLabels.test_time}</span>
<div className='overflow-hidden text-sm'>
{responseCell ?? <span className='text-muted-foreground'>-</span>}
</div>
<div className='overflow-hidden text-sm'>
{testCell ?? <span className='text-muted-foreground'>-</span>}
<div className='grid shrink-0 grid-cols-[auto_auto] items-center gap-x-3 gap-y-1'>
<span className={labelClass}>{t('Priority')}</span>
<span className={labelClass}>{t('Weight')}</span>
<div className='flex justify-start'>{priorityCell}</div>
<div className='flex justify-start'>{weightCell}</div>
<span className={cn('mt-2', labelClass)}>
{fieldLabels.response_time}
</span>
<span className={cn('mt-2', labelClass)}>
{fieldLabels.test_time}
</span>
<div className='overflow-hidden text-sm'>
{responseCell ?? <span className='text-muted-foreground'>-</span>}
</div>
<div className='overflow-hidden text-sm'>
{testCell ?? <span className='text-muted-foreground'>-</span>}
</div>
</div>
</div>
</div>
{/* Last row: groups span the full width, showing every group (no label) */}
<div className='min-w-0'>
{groups.length > 0 ? (
<div className='-ml-1.5 flex flex-wrap gap-1'>
{groups.map((g) => (
<GroupBadge
key={g}
group={g}
label={sensitiveVisible ? undefined : SENSITIVE_MASK}
size='sm'
/>
))}
</div>
) : (
<span className='text-muted-foreground text-sm'>-</span>
)}
{/* Last row: groups span the full width, showing every group (no label) */}
<div className='min-w-0'>
{groups.length > 0 ? (
<div className='-ml-1.5 flex flex-wrap gap-1'>
{groups.map((g) => (
<GroupBadge
key={g}
group={g}
label={sensitiveVisible ? undefined : SENSITIVE_MASK}
size='sm'
/>
))}
</div>
) : (
<span className='text-muted-foreground text-sm'>-</span>
)}
</div>
</div>
</div>
</ChannelRowActionsLayoutContext.Provider>
)
}
......
......@@ -19,9 +19,8 @@ For commercial licensing, please contact support@quantumnous.com
import { createContext } from 'react'
/**
* Where the channel row actions are being rendered. In the card view we
* surface the "Test Connection" action as an inline button next to the quick
* test; in the table it stays inside the overflow menu.
* Where channel row-derived controls are being rendered. Card view can tune
* compact display details while table view keeps the full desktop treatment.
*/
export type ChannelRowActionsLayout = 'table' | 'card'
......
import { useQueryClient } from '@tanstack/react-query'
import type { ColumnDef } from '@tanstack/react-table'
import {
AlertTriangle,
ChevronDown,
ChevronRight,
ListOrdered,
Shuffle,
SlidersHorizontal,
} from 'lucide-react'
/*
Copyright (C) 2023-2026 QuantumNous
......@@ -27,7 +17,17 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
/* eslint-disable react-refresh/only-export-components */
import { useState, useMemo } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import type { ColumnDef } from '@tanstack/react-table'
import {
AlertTriangle,
ChevronDown,
ChevronRight,
ListOrdered,
Shuffle,
SlidersHorizontal,
} from 'lucide-react'
import { useState, useMemo, useContext } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
......@@ -51,16 +51,12 @@ import {
formatQuotaWithCurrency,
getCurrencyLabel,
} from '@/lib/currency'
import {
formatTimestampToDate,
formatQuota as formatQuotaValue,
} from '@/lib/format'
import { formatTimestampToDate } from '@/lib/format'
import { truncateText } from '@/lib/utils'
import { getCodexUsage } from '../api'
import { CHANNEL_STATUS_CONFIG, MODEL_FETCHABLE_TYPES } from '../constants'
import {
formatBalance,
formatRelativeTime,
formatResponseTime,
getBalanceVariant,
......@@ -79,6 +75,7 @@ import {
} from '../lib'
import { parseUpstreamUpdateMeta } from '../lib/upstream-update-utils'
import type { Channel } from '../types'
import { ChannelRowActionsLayoutContext } from './channel-row-actions-context'
import { useChannels } from './channels-provider'
import { DataTableRowActions } from './data-table-row-actions'
import { DataTableTagRowActions } from './data-table-tag-row-actions'
......@@ -299,6 +296,7 @@ const SENSITIVE_MASK = '••••'
function BalanceCell({ channel }: { channel: Channel }) {
const { t, i18n } = useTranslation()
const queryClient = useQueryClient()
const layout = useContext(ChannelRowActionsLayoutContext)
const { sensitiveVisible } = useChannels()
const isTagRow = isTagAggregateRow(channel)
const balance = channel.balance || 0
......@@ -313,18 +311,43 @@ function BalanceCell({ channel }: { channel: Channel }) {
tokenSuffix && value !== '-' ? `${value}${tokenSuffix}` : value
const locale = i18n.resolvedLanguage || i18n.language
const balanceFormatOptions = {
digitsLarge: 2,
digitsSmall: 4,
abbreviate: false,
showSymbol: layout !== 'card',
} as const
// Precise values are kept for the tooltip; long values are shown compactly inline.
const usedFull = withSuffix(formatQuotaValue(usedQuota))
const remainingFull = withSuffix(formatBalance(balance))
const usedFull = withSuffix(
formatQuotaWithCurrency(usedQuota, {
digitsLarge: 2,
digitsSmall: 4,
abbreviate: true,
showSymbol: layout !== 'card',
})
)
const remainingFull = withSuffix(
formatCurrencyFromUSD(balance, balanceFormatOptions)
)
const usedDisplay =
usedFull.length > MAX_INLINE_BALANCE_CHARS
? withSuffix(
formatQuotaWithCurrency(usedQuota, { compact: true, locale })
formatQuotaWithCurrency(usedQuota, {
compact: true,
locale,
showSymbol: layout !== 'card',
})
)
: usedFull
const remainingDisplay =
remainingFull.length > MAX_INLINE_BALANCE_CHARS
? withSuffix(formatCurrencyFromUSD(balance, { compact: true, locale }))
? withSuffix(
formatCurrencyFromUSD(balance, {
compact: true,
locale,
showSymbol: layout !== 'card',
})
)
: remainingFull
const usedLabel = `${t('Used:')} ${usedFull}`
const remainingLabel = `${t('Remaining:')} ${remainingFull}`
......@@ -488,9 +511,14 @@ function BalanceCell({ channel }: { channel: Channel }) {
/**
* Generate channels columns configuration
*/
export function useChannelsColumns(): ColumnDef<Channel>[] {
export function useChannelsColumns(
options: {
enableSelection?: boolean
} = {}
): ColumnDef<Channel>[] {
const { t, i18n } = useTranslation()
const { sensitiveVisible } = useChannels()
const enableSelection = options.enableSelection ?? true
const locale = i18n.resolvedLanguage || i18n.language
// The column definitions only depend on the translation function, the active
// locale, and sensitive-data visibility. Memoizing keeps the array (and every
......@@ -499,38 +527,42 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
return useMemo<ColumnDef<Channel>[]>(
() => [
// Checkbox column
{
id: 'select',
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
indeterminate={table.getIsSomePageRowsSelected()}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
aria-label='Select all'
/>
),
cell: ({ row }) => {
const isTagRow = isTagAggregateRow(row.original)
...(enableSelection
? [
{
id: 'select',
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
indeterminate={table.getIsSomePageRowsSelected()}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
aria-label={t('Select all')}
/>
),
cell: ({ row }) => {
const isTagRow = isTagAggregateRow(row.original)
// Don't show checkbox for tag rows
if (isTagRow) {
return null
}
// Don't show checkbox for tag rows
if (isTagRow) {
return null
}
return (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label='Select row'
/>
)
},
enableSorting: false,
enableHiding: false,
size: 40,
},
return (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label={t('Select row')}
/>
)
},
enableSorting: false,
enableHiding: false,
size: 40,
} satisfies ColumnDef<Channel>,
]
: []),
// ID column
{
......@@ -543,7 +575,6 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
},
size: 80,
},
// Name column
{
accessorKey: 'name',
......@@ -1119,6 +1150,6 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
meta: { pinned: 'right' as const },
},
],
[t, locale, sensitiveVisible]
[enableSelection, t, locale, sensitiveVisible]
)
}
......@@ -16,7 +16,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import {
Plus,
......@@ -26,19 +25,17 @@ import {
Tags,
TestTube,
DollarSign,
ListChecks,
SortAsc,
RefreshCw,
ArrowUpFromLine,
} from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Button } from '@/components/ui/button'
import {
ADMIN_PERMISSION_ACTIONS,
ADMIN_PERMISSION_RESOURCES,
hasPermission,
} from '@/lib/admin-permissions'
import { useAuthStore } from '@/stores/auth-store'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuCheckboxItem,
......@@ -54,7 +51,13 @@ import {
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { ConfirmDialog } from '@/components/confirm-dialog'
import {
ADMIN_PERMISSION_ACTIONS,
ADMIN_PERMISSION_RESOURCES,
hasPermission,
} from '@/lib/admin-permissions'
import { useAuthStore } from '@/stores/auth-store'
import {
handleDeleteAllDisabled,
handleFixAbilities,
......@@ -72,10 +75,14 @@ export function ChannelsPrimaryButtons() {
setEnableTagMode,
idSort,
setIdSort,
batchMode,
setBatchMode,
upstream,
} = useChannels()
const queryClient = useQueryClient()
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [showConsistencyDialog, setShowConsistencyDialog] = useState(false)
const [isRepairingConsistency, setIsRepairingConsistency] = useState(false)
const currentUser = useAuthStore((s) => s.auth.user)
const canEditSensitive = hasPermission(
currentUser,
......@@ -93,11 +100,30 @@ export function ChannelsPrimaryButtons() {
setIdSort(checked)
}
const handleBatchModeToggle = (checked: boolean) => {
setBatchMode(checked)
}
return (
<>
<div className='flex items-center gap-2'>
{/* Desktop: Toggle switches visible */}
<div className='hidden items-center gap-2 rounded-md border px-3 py-1.5 sm:flex'>
<ListChecks className='text-muted-foreground h-4 w-4' />
<Label
htmlFor='channel-batch-mode'
className='cursor-pointer text-sm'
>
{t('Batch Operations')}
</Label>
<Switch
id='channel-batch-mode'
checked={batchMode}
onCheckedChange={handleBatchModeToggle}
/>
</div>
<div className='hidden items-center gap-2 rounded-md border px-3 py-1.5 sm:flex'>
<Tags className='text-muted-foreground h-4 w-4' />
<Label htmlFor='tag-mode' className='cursor-pointer text-sm'>
{t('Tag Mode')}
......@@ -154,6 +180,15 @@ export function ChannelsPrimaryButtons() {
{/* Mobile-only: toggle switches */}
<DropdownMenuCheckboxItem
className='sm:hidden'
checked={batchMode}
onCheckedChange={handleBatchModeToggle}
>
<ListChecks className='mr-2 h-4 w-4' />
{t('Batch Operations')}
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
className='sm:hidden'
checked={enableTagMode}
onCheckedChange={handleTagModeToggle}
>
......@@ -219,14 +254,12 @@ export function ChannelsPrimaryButtons() {
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
handleFixAbilities(queryClient, (_result) => {
// eslint-disable-next-line no-console
console.log('Fix abilities result:', _result)
})
onSelect={(e) => {
e.preventDefault()
setShowConsistencyDialog(true)
}}
>
{t('Fix Abilities')}
{t('Repair Channel Consistency')}
<DropdownMenuShortcut>
<Settings2 className='h-4 w-4' />
</DropdownMenuShortcut>
......@@ -269,6 +302,29 @@ export function ChannelsPrimaryButtons() {
setShowDeleteDialog(false)
}}
/>
<ConfirmDialog
open={showConsistencyDialog}
onOpenChange={setShowConsistencyDialog}
title={t('Repair channel consistency?')}
desc={t(
'This will rebuild the channel routing index from every channel configuration, including supported models, groups, priorities, and weights. Routing may be briefly incomplete while the rebuild is running. Continue?'
)}
confirmText={t('Repair')}
isLoading={isRepairingConsistency}
handleConfirm={async () => {
setIsRepairingConsistency(true)
try {
await handleFixAbilities(queryClient, (_result) => {
// eslint-disable-next-line no-console
console.log('Repair channel consistency result:', _result)
})
setShowConsistencyDialog(false)
} finally {
setIsRepairingConsistency(false)
}
}}
/>
</>
)
}
......@@ -17,6 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
/* eslint-disable react-refresh/only-export-components */
import { useQueryClient } from '@tanstack/react-query'
import React, {
createContext,
useContext,
......@@ -24,7 +25,7 @@ import React, {
useCallback,
useMemo,
} from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { useChannelUpstreamUpdates } from '../hooks/use-channel-upstream-updates'
import { channelsQueryKeys } from '../lib'
import type { Channel } from '../types'
......@@ -59,6 +60,8 @@ type ChannelsContextType = {
setEnableTagMode: (enabled: boolean) => void
idSort: boolean
setIdSort: (enabled: boolean) => void
batchMode: boolean
setBatchMode: (enabled: boolean) => void
sensitiveVisible: boolean
setSensitiveVisible: (visible: boolean) => void
upstream: UpstreamUpdateState
......@@ -86,6 +89,7 @@ export function ChannelsProvider({ children }: { children: React.ReactNode }) {
const [idSort, setIdSort] = useState(() => {
return localStorage.getItem('channels-id-sort') === 'true'
})
const [batchMode, setBatchMode] = useState(false)
const [sensitiveVisible, setSensitiveVisible] = useState(true)
const queryClient = useQueryClient()
......@@ -109,6 +113,8 @@ export function ChannelsProvider({ children }: { children: React.ReactNode }) {
setEnableTagMode,
idSort,
setIdSort,
batchMode,
setBatchMode,
sensitiveVisible,
setSensitiveVisible,
upstream,
......@@ -119,6 +125,7 @@ export function ChannelsProvider({ children }: { children: React.ReactNode }) {
currentTag,
enableTagMode,
idSort,
batchMode,
sensitiveVisible,
upstream,
]
......
......@@ -16,22 +16,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useState, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { getRouteApi } from '@tanstack/react-router'
import type { OnChangeFn, SortingState, Row } from '@tanstack/react-table'
import { Eye, EyeOff } from 'lucide-react'
import { useMediaQuery } from '@/hooks'
import { useState, useMemo, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { getLobeIcon } from '@/lib/lobe-icon'
import { useTableUrlState } from '@/hooks/use-table-url-state'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
DISABLED_ROW_DESKTOP,
DISABLED_ROW_MOBILE,
......@@ -39,6 +30,17 @@ import {
useDebouncedColumnFilter,
useDataTable,
} from '@/components/data-table'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { useMediaQuery } from '@/hooks'
import { useTableUrlState } from '@/hooks/use-table-url-state'
import { getLobeIcon } from '@/lib/lobe-icon'
import { getChannels, searchChannels, getGroups } from '../api'
import {
DEFAULT_PAGE_SIZE,
......@@ -53,14 +55,13 @@ import {
getChannelTypeLabel,
} from '../lib'
import type { Channel, ChannelSortBy } from '../types'
import { useChannelsColumns } from './channels-columns'
import { ChannelCard } from './channel-card'
import { useChannelsColumns } from './channels-columns'
import { useChannels } from './channels-provider'
import { DataTableBulkActions } from './data-table-bulk-actions'
const route = getRouteApi('/_authenticated/channels/')
const CHANNELS_COLUMN_VISIBILITY_STORAGE_KEY =
'channels:column-visibility'
const CHANNELS_COLUMN_VISIBILITY_STORAGE_KEY = 'channels:column-visibility'
const CHANNELS_VIEW_MODE_STORAGE_KEY = 'channels:view-mode'
const CHANNEL_SORTABLE_COLUMNS = new Set<ChannelSortBy>([
......@@ -83,6 +84,7 @@ export function ChannelsTable() {
const {
enableTagMode,
idSort,
batchMode,
sensitiveVisible,
setSensitiveVisible,
} = useChannels()
......@@ -268,7 +270,7 @@ export function ChannelsTable() {
const typeCounts = data?.data?.type_counts
// Columns configuration
const columns = useChannelsColumns()
const columns = useChannelsColumns({ enableSelection: batchMode })
// React Table instance
const { table } = useDataTable({
......@@ -284,7 +286,9 @@ export function ChannelsTable() {
columnFilters,
pagination,
globalFilter,
enableRowSelection: (row: Row<Channel>) => !isTagAggregateRow(row.original),
enableRowSelection: batchMode
? (row: Row<Channel>) => !isTagAggregateRow(row.original)
: false,
onSortingChange: handleSortingChange,
onColumnFiltersChange,
onPaginationChange,
......@@ -297,6 +301,12 @@ export function ChannelsTable() {
ensurePageInRange,
})
useEffect(() => {
if (!batchMode) {
table.resetRowSelection()
}
}, [batchMode, table])
// Prepare filter options from existing channel types only.
const typeFilterOptions = useMemo(() => {
const counts = typeCounts || {}
......@@ -441,7 +451,7 @@ export function ChannelsTable() {
}
return DISABLED_ROW_DESKTOP
}}
bulkActions={<DataTableBulkActions table={table} />}
bulkActions={batchMode ? <DataTableBulkActions table={table} /> : null}
/>
)
}
......@@ -164,24 +164,26 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
return (
<div className='-ml-1.5 flex items-center gap-1'>
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='icon-sm'
onClick={(e) => {
e.stopPropagation()
handleEdit()
}}
aria-label={t('Edit')}
/>
}
>
<Pencil className='size-4' />
</TooltipTrigger>
<TooltipContent>{t('Edit')}</TooltipContent>
</Tooltip>
{layout !== 'card' && (
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='icon-sm'
onClick={(e) => {
e.stopPropagation()
handleEdit()
}}
aria-label={t('Edit')}
/>
}
>
<Pencil className='size-4' />
</TooltipTrigger>
<TooltipContent>{t('Edit')}</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger
......@@ -262,6 +264,15 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
<span className='sr-only'>{t('Open menu')}</span>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-48'>
{layout === 'card' && (
<DropdownMenuItem onClick={handleEdit}>
{t('Edit')}
<DropdownMenuShortcut>
<Pencil size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
)}
{/* Test Connection */}
<DropdownMenuItem onClick={handleTest}>
{t('Test Connection')}
......
......@@ -199,6 +199,12 @@ type ModelMappingGuardrail = {
type ChannelEditorSectionStatus = 'complete' | 'configured' | 'error' | 'idle'
type ChannelEditorNavChildItem = {
id: string
title: string
configured?: boolean
}
type ChannelEditorNavItem = {
id: string
title: string
......@@ -206,6 +212,8 @@ type ChannelEditorNavItem = {
statusLabel: string
status: ChannelEditorSectionStatus
icon: ReactNode
configured?: boolean
children?: ChannelEditorNavChildItem[]
}
// Helper functions
......@@ -225,6 +233,29 @@ const MODEL_MAPPING_PREVIEW_FALLBACK: Array<{
}> = [{ source: 'client-model', target: 'upstream-model' }]
const ADVANCED_SETTINGS_EXPANDED_KEY = 'channel-advanced-settings-expanded'
const CHANNEL_EDITOR_SECTION_IDS = {
identity: 'channel-section-identity',
credentials: 'channel-section-credentials',
models: 'channel-section-models',
advanced: 'channel-section-advanced',
} as const
const CHANNEL_EDITOR_MAIN_SECTION_IDS = [
CHANNEL_EDITOR_SECTION_IDS.identity,
CHANNEL_EDITOR_SECTION_IDS.credentials,
CHANNEL_EDITOR_SECTION_IDS.models,
CHANNEL_EDITOR_SECTION_IDS.advanced,
]
const ADVANCED_SETTINGS_SECTION_IDS = {
routingStrategy: 'channel-section-advanced-routing-strategy',
internalNotes: 'channel-section-advanced-internal-notes',
overrideRules: 'channel-section-advanced-override-rules',
extraSettings: 'channel-section-advanced-extra-settings',
fieldPassthrough: 'channel-section-advanced-field-passthrough',
upstreamModelDetection: 'channel-section-advanced-upstream-model-detection',
} as const
const ADVANCED_SETTINGS_CHILD_SECTION_IDS: string[] = Object.values(
ADVANCED_SETTINGS_SECTION_IDS
)
const UPSTREAM_DETECTED_MODEL_PREVIEW_LIMIT = 8
const SENSITIVE_FORM_FIELDS = [
'type',
......@@ -266,12 +297,30 @@ function readAdvancedSettingsPreference(): boolean {
return window.localStorage.getItem(ADVANCED_SETTINGS_EXPANDED_KEY) === 'true'
}
function hasConfiguredOverrideValue(value: unknown): boolean {
if (typeof value !== 'string') return false
const trimmed = value.trim()
if (!trimmed || trimmed === 'null') return false
try {
const parsed = JSON.parse(trimmed)
if (parsed === null) return false
if (Array.isArray(parsed)) return parsed.length > 0
if (typeof parsed === 'object') return Object.keys(parsed).length > 0
} catch {
return true
}
return true
}
function hasAdvancedSettingsValues(values: ChannelFormValues): boolean {
return Boolean(
values.param_override?.trim() ||
values.header_override?.trim() ||
hasConfiguredOverrideValue(values.param_override) ||
hasConfiguredOverrideValue(values.header_override) ||
values.advanced_custom?.trim() ||
values.status_code_mapping?.trim() ||
hasConfiguredOverrideValue(values.status_code_mapping) ||
values.tag?.trim() ||
values.remark?.trim() ||
values.priority ||
......@@ -334,6 +383,17 @@ function SubHeading({ title, icon }: { title: string; icon?: ReactNode }) {
)
}
function configuredAdvancedSectionClassName(
className: string,
configured: boolean
) {
return cn(
className,
'border-border/60 rounded-lg border p-3 transition-colors',
configured && 'border-primary/35 ring-primary/20 ring-1'
)
}
function ChannelTypeLogo(props: {
type: number
size?: number
......@@ -398,6 +458,9 @@ function ChannelEditorNav(props: {
progressLabel: string
navigationLabel: string
items: ChannelEditorNavItem[]
activeItemId?: string
expandedItemId?: string
onNavigate: (targetId: string) => void
}) {
return (
<aside className='hidden self-start lg:sticky lg:top-4 lg:z-20 lg:block'>
......@@ -426,50 +489,87 @@ function ChannelEditorNav(props: {
const isError = item.status === 'error'
const isDone =
item.status === 'complete' || item.status === 'configured'
const isConfigured = Boolean(item.configured)
const isActive = props.activeItemId === item.id
const isExpanded = props.expandedItemId === item.id
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
<div key={item.id}>
<button
type='button'
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'
'hover:bg-muted/60 flex w-full items-start gap-2 rounded-md px-2 py-2 text-left transition-colors',
isActive && 'bg-muted/70',
isConfigured && !isError && 'text-primary',
isError && 'text-destructive hover:bg-destructive/10'
)}
onClick={() => props.onNavigate(item.id)}
aria-current={isActive ? 'true' : undefined}
>
{item.icon}
</span>
<span className='min-w-0 flex-1'>
<span className='block truncate text-sm font-medium'>
{item.title}
<span
className={cn(
'bg-muted text-muted-foreground mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-md',
isConfigured && !isError && 'bg-primary/10 text-primary',
isError && 'bg-destructive/10 text-destructive',
isDone && !isError && 'text-primary'
)}
>
{item.icon}
</span>
{item.description && (
<span className='text-muted-foreground block truncate text-xs'>
{item.description}
<span className='min-w-0 flex-1'>
<span className='block truncate text-sm font-medium'>
{item.title}
</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>
{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',
isConfigured && !isError && 'pt-1.5'
)}
aria-label={item.statusLabel}
>
{isConfigured && !isError && !isDone ? (
<span
className='bg-success block size-2 rounded-full'
aria-hidden='true'
/>
) : (
getSectionStatusIcon(item.status)
)}
</span>
</button>
{item.children && isExpanded && (
<div className='border-border/60 ml-5 flex flex-col gap-0.5 border-l py-1 pl-3'>
{item.children.map((child) => (
<button
key={child.id}
type='button'
className={cn(
'text-muted-foreground hover:bg-muted/50 hover:text-foreground flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-xs transition-colors',
child.configured && 'text-primary'
)}
onClick={() => props.onNavigate(child.id)}
>
<span className='min-w-0 flex-1 truncate'>
{child.title}
</span>
{child.configured && (
<span
className='bg-success size-1.5 shrink-0 rounded-full'
aria-hidden='true'
/>
)}
</button>
))}
</div>
)}
</div>
)
})}
</nav>
......@@ -513,6 +613,14 @@ export function ChannelMutateDrawer({
const missingModelsResolveRef = useRef<
((action: MissingModelsAction) => void) | null
>(null)
const channelFormRef = useRef<HTMLFormElement>(null)
const advancedNavScrollPendingRef = useRef(false)
const [activeEditorSectionId, setActiveEditorSectionId] = useState<string>(
CHANNEL_EDITOR_SECTION_IDS.identity
)
const [expandedEditorNavItemId, setExpandedEditorNavItemId] = useState<
string | undefined
>()
const [advancedSettingsOpen, setAdvancedSettingsOpen] = useState(false)
const [paramOverrideEditorOpen, setParamOverrideEditorOpen] = useState(false)
const [advancedCustomEditorOpen, setAdvancedCustomEditorOpen] =
......@@ -599,6 +707,39 @@ export function ChannelMutateDrawer({
)
const currentSettings = form.watch('settings')
const currentAdvancedCustom = form.watch('advanced_custom')
const currentPriority = form.watch('priority')
const currentWeight = form.watch('weight')
const currentTestModel = form.watch('test_model')
const currentAutoBan = form.watch('auto_ban')
const currentTag = form.watch('tag')
const currentRemark = form.watch('remark')
const currentStatusCodeMapping = form.watch('status_code_mapping')
const currentParamOverride = form.watch('param_override')
const currentHeaderOverride = form.watch('header_override')
const currentForceFormat = form.watch('force_format')
const currentThinkingToContent = form.watch('thinking_to_content')
const currentPassThroughBodyEnabled = form.watch('pass_through_body_enabled')
const currentDisableTaskPollingSleep = form.watch(
'disable_task_polling_sleep'
)
const currentProxy = form.watch('proxy')
const currentSystemPrompt = form.watch('system_prompt')
const currentSystemPromptOverride = form.watch('system_prompt_override')
const currentAllowServiceTier = form.watch('allow_service_tier')
const currentDisableStore = form.watch('disable_store')
const currentAllowSafetyIdentifier = form.watch('allow_safety_identifier')
const currentAllowIncludeObfuscation = form.watch(
'allow_include_obfuscation'
)
const currentAllowInferenceGeo = form.watch('allow_inference_geo')
const currentAllowSpeed = form.watch('allow_speed')
const currentClaudeBetaQuery = form.watch('claude_beta_query')
const currentUpstreamModelUpdateAutoSyncEnabled = form.watch(
'upstream_model_update_auto_sync_enabled'
)
const currentUpstreamModelUpdateIgnoredModels = form.watch(
'upstream_model_update_ignored_models'
)
const {
unlocked: doubaoApiEditUnlocked,
handleClick: handleApiConfigSecretClick,
......@@ -756,9 +897,98 @@ export function ChannelMutateDrawer({
? 'error'
: 'idle'
const advancedSummary = advancedHaveErrors ? t('Error') : undefined
const routingStrategyConfigured = Boolean(
currentPriority ||
currentWeight ||
currentTestModel?.trim() ||
(currentAutoBan ?? 1) !== 1
)
const internalNotesConfigured = Boolean(
currentTag?.trim() || currentRemark?.trim()
)
const overrideRulesConfigured = Boolean(
hasConfiguredOverrideValue(currentStatusCodeMapping) ||
hasConfiguredOverrideValue(currentParamOverride) ||
hasConfiguredOverrideValue(currentHeaderOverride)
)
const extraSettingsConfigured = Boolean(
currentForceFormat ||
currentThinkingToContent ||
currentPassThroughBodyEnabled ||
currentDisableTaskPollingSleep ||
currentProxy?.trim() ||
currentSystemPrompt?.trim() ||
currentSystemPromptOverride
)
let fieldPassthroughConfigured = false
if (currentType === 1) {
fieldPassthroughConfigured = Boolean(
currentAllowServiceTier ||
currentDisableStore ||
currentAllowSafetyIdentifier ||
currentAllowIncludeObfuscation ||
currentAllowInferenceGeo
)
} else if (currentType === 14) {
fieldPassthroughConfigured = Boolean(
currentAllowServiceTier ||
currentAllowInferenceGeo ||
currentAllowSpeed ||
currentClaudeBetaQuery
)
}
const upstreamModelDetectionConfigured = Boolean(
upstreamModelUpdateCheckEnabled ||
currentUpstreamModelUpdateAutoSyncEnabled ||
currentUpstreamModelUpdateIgnoredModels?.trim()
)
const advancedConfigured = Boolean(
routingStrategyConfigured ||
internalNotesConfigured ||
overrideRulesConfigured ||
extraSettingsConfigured ||
fieldPassthroughConfigured ||
upstreamModelDetectionConfigured
)
const advancedNavChildren: ChannelEditorNavChildItem[] = [
{
id: ADVANCED_SETTINGS_SECTION_IDS.routingStrategy,
title: t('Routing Strategy'),
configured: routingStrategyConfigured,
},
{
id: ADVANCED_SETTINGS_SECTION_IDS.internalNotes,
title: t('Internal Notes'),
configured: internalNotesConfigured,
},
{
id: ADVANCED_SETTINGS_SECTION_IDS.overrideRules,
title: t('Override Rules'),
configured: overrideRulesConfigured,
},
{
id: ADVANCED_SETTINGS_SECTION_IDS.extraSettings,
title: t('Channel Extra Settings'),
configured: extraSettingsConfigured,
},
]
if (currentType === 1 || currentType === 14) {
advancedNavChildren.push({
id: ADVANCED_SETTINGS_SECTION_IDS.fieldPassthrough,
title: t('Field passthrough controls'),
configured: fieldPassthroughConfigured,
})
}
if (MODEL_FETCHABLE_TYPES.has(currentType)) {
advancedNavChildren.push({
id: ADVANCED_SETTINGS_SECTION_IDS.upstreamModelDetection,
title: t('Upstream Model Detection Settings'),
configured: upstreamModelDetectionConfigured,
})
}
const editorNavItems: ChannelEditorNavItem[] = [
{
id: 'channel-section-identity',
id: CHANNEL_EDITOR_SECTION_IDS.identity,
title: t('Basic Information'),
description: getSectionStatusLabel(identityStatus, t),
statusLabel: getSectionStatusLabel(identityStatus, t),
......@@ -766,7 +996,7 @@ export function ChannelMutateDrawer({
icon: <Server className='h-4 w-4' aria-hidden='true' />,
},
{
id: 'channel-section-credentials',
id: CHANNEL_EDITOR_SECTION_IDS.credentials,
title: t('Credentials'),
description: getSectionStatusLabel(credentialsStatus, t),
statusLabel: getSectionStatusLabel(credentialsStatus, t),
......@@ -774,7 +1004,7 @@ export function ChannelMutateDrawer({
icon: <KeyRound className='h-4 w-4' aria-hidden='true' />,
},
{
id: 'channel-section-models',
id: CHANNEL_EDITOR_SECTION_IDS.models,
title: t('Models & Groups'),
description: getSectionStatusLabel(modelsStatus, t),
statusLabel: getSectionStatusLabel(modelsStatus, t),
......@@ -782,12 +1012,14 @@ export function ChannelMutateDrawer({
icon: <Boxes className='h-4 w-4' aria-hidden='true' />,
},
{
id: 'channel-section-advanced',
id: CHANNEL_EDITOR_SECTION_IDS.advanced,
title: t('Advanced Settings'),
description: advancedSummary,
statusLabel: advancedSummary ?? t('Advanced Settings'),
status: advancedStatus,
icon: <Settings className='h-4 w-4' aria-hidden='true' />,
configured: advancedConfigured,
children: advancedNavChildren,
},
]
......@@ -1381,6 +1613,10 @@ export function ChannelMutateDrawer({
)
const handleAdvancedSettingsOpenChange = useCallback((nextOpen: boolean) => {
if (!nextOpen) {
advancedNavScrollPendingRef.current = false
setExpandedEditorNavItemId(undefined)
}
setAdvancedSettingsOpen(nextOpen)
if (typeof window !== 'undefined') {
window.localStorage.setItem(
......@@ -1390,6 +1626,90 @@ export function ChannelMutateDrawer({
}
}, [])
const handleEditorNavNavigate = useCallback(
(targetId: string) => {
const isAdvancedTarget =
targetId === CHANNEL_EDITOR_SECTION_IDS.advanced ||
ADVANCED_SETTINGS_CHILD_SECTION_IDS.includes(targetId)
if (isAdvancedTarget) {
advancedNavScrollPendingRef.current = true
handleAdvancedSettingsOpenChange(true)
setActiveEditorSectionId(CHANNEL_EDITOR_SECTION_IDS.advanced)
setExpandedEditorNavItemId(CHANNEL_EDITOR_SECTION_IDS.advanced)
} else {
advancedNavScrollPendingRef.current = false
setActiveEditorSectionId(targetId)
setExpandedEditorNavItemId(undefined)
}
const scrollTargetIntoView = () => {
document
.querySelector<HTMLElement>(`#${targetId}`)
?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
if (isAdvancedTarget && !advancedSettingsOpen) {
window.requestAnimationFrame(scrollTargetIntoView)
return
}
scrollTargetIntoView()
},
[advancedSettingsOpen, handleAdvancedSettingsOpenChange]
)
const updateActiveEditorSection = useCallback(() => {
const formElement = channelFormRef.current
if (!formElement) return
const activationY = formElement.getBoundingClientRect().top + 80
let nextActiveSectionId: string = CHANNEL_EDITOR_SECTION_IDS.identity
for (const sectionId of CHANNEL_EDITOR_MAIN_SECTION_IDS) {
const sectionElement = document.querySelector<HTMLElement>(
`#${sectionId}`
)
if (!sectionElement) continue
if (sectionElement.getBoundingClientRect().top <= activationY) {
nextActiveSectionId = sectionId
} else {
break
}
}
setActiveEditorSectionId((current) =>
current === nextActiveSectionId ? current : nextActiveSectionId
)
if (nextActiveSectionId === CHANNEL_EDITOR_SECTION_IDS.advanced) {
advancedNavScrollPendingRef.current = false
setExpandedEditorNavItemId(CHANNEL_EDITOR_SECTION_IDS.advanced)
if (!advancedSettingsOpen) {
handleAdvancedSettingsOpenChange(true)
}
} else if (!advancedNavScrollPendingRef.current) {
setExpandedEditorNavItemId(undefined)
}
}, [advancedSettingsOpen, handleAdvancedSettingsOpenChange])
useEffect(() => {
if (!open || isChannelDetailLoading) return
const formElement = channelFormRef.current
if (!formElement) return
updateActiveEditorSection()
formElement.addEventListener('scroll', updateActiveEditorSection, {
passive: true,
})
window.addEventListener('resize', updateActiveEditorSection)
return () => {
formElement.removeEventListener('scroll', updateActiveEditorSection)
window.removeEventListener('resize', updateActiveEditorSection)
}
}, [isChannelDetailLoading, open, updateActiveEditorSection])
const onInvalid: SubmitErrorHandler<ChannelFormValues> = useCallback(
(errors) => {
if (hasAdvancedSettingsErrors(errors)) {
......@@ -1406,6 +1726,9 @@ export function ChannelMutateDrawer({
onOpenChange(v)
if (!v) {
form.reset(CHANNEL_FORM_DEFAULT_VALUES)
advancedNavScrollPendingRef.current = false
setActiveEditorSectionId(CHANNEL_EDITOR_SECTION_IDS.identity)
setExpandedEditorNavItemId(undefined)
setAdvancedSettingsOpen(false)
}
},
......@@ -1455,6 +1778,7 @@ export function ChannelMutateDrawer({
<Form {...form}>
<form
id='channel-form'
ref={channelFormRef}
onSubmit={form.handleSubmit(onSubmit, onInvalid)}
className={sideDrawerFormClassName('gap-5')}
>
......@@ -1471,10 +1795,16 @@ export function ChannelMutateDrawer({
progressLabel={progressLabel}
navigationLabel={t('Channels')}
items={editorNavItems}
activeItemId={activeEditorSectionId}
expandedItemId={expandedEditorNavItemId}
onNavigate={handleEditorNavNavigate}
/>
<div className='flex min-w-0 flex-col gap-5'>
{/* ── Basic Information ── */}
<div id='channel-section-identity' className='scroll-mt-4'>
<div
id={CHANNEL_EDITOR_SECTION_IDS.identity}
className='scroll-mt-4'
>
<ChannelBasicSection>
<div className='grid gap-4 sm:grid-cols-2'>
<fieldset
......@@ -1514,6 +1844,7 @@ export function ChannelMutateDrawer({
emptyText={t('No channel type found.')}
className='pl-10'
allowCustomValue
openOnFocus={false}
/>
</div>
</FormControl>
......@@ -1612,7 +1943,7 @@ export function ChannelMutateDrawer({
{/* ── API Access ── */}
<div
id='channel-section-credentials'
id={CHANNEL_EDITOR_SECTION_IDS.credentials}
className='scroll-mt-4'
>
<ChannelApiAccessSection>
......@@ -2723,7 +3054,10 @@ export function ChannelMutateDrawer({
</div>
{/* ── Models & Groups ── */}
<div id='channel-section-models' className='scroll-mt-4'>
<div
id={CHANNEL_EDITOR_SECTION_IDS.models}
className='scroll-mt-4'
>
<ChannelModelsSection>
<div className='space-y-5'>
<div className='border-border/60 bg-muted/10 rounded-lg border p-4'>
......@@ -3086,7 +3420,10 @@ export function ChannelMutateDrawer({
</ChannelModelsSection>
</div>
<div id='channel-section-advanced' className='scroll-mt-4'>
<div
id={CHANNEL_EDITOR_SECTION_IDS.advanced}
className='scroll-mt-4'
>
<ChannelAdvancedSection
open={advancedSettingsOpen}
onOpenChange={handleAdvancedSettingsOpenChange}
......@@ -3098,7 +3435,13 @@ export function ChannelMutateDrawer({
title={t('Routing & Overrides')}
icon={<Route className='h-4 w-4' />}
/>
<div className='flex flex-col gap-4'>
<div
id={ADVANCED_SETTINGS_SECTION_IDS.routingStrategy}
className={configuredAdvancedSectionClassName(
'flex scroll-mt-4 flex-col gap-4',
routingStrategyConfigured
)}
>
<SubHeading
title={t('Routing Strategy')}
icon={<Route className='h-3.5 w-3.5' />}
......@@ -3199,7 +3542,13 @@ export function ChannelMutateDrawer({
/>
</div>
<div className='flex flex-col gap-4 border-t pt-4'>
<div
id={ADVANCED_SETTINGS_SECTION_IDS.internalNotes}
className={configuredAdvancedSectionClassName(
'flex scroll-mt-4 flex-col gap-4 border-t pt-4',
internalNotesConfigured
)}
>
<SubHeading
title={t('Internal Notes')}
icon={<FileText className='h-3.5 w-3.5' />}
......@@ -3250,7 +3599,13 @@ export function ChannelMutateDrawer({
</div>
</div>
<div className='flex flex-col gap-4 border-t pt-4'>
<div
id={ADVANCED_SETTINGS_SECTION_IDS.overrideRules}
className={configuredAdvancedSectionClassName(
'flex scroll-mt-4 flex-col gap-4 border-t pt-4',
overrideRulesConfigured
)}
>
<SubHeading
title={t('Override Rules')}
icon={<Code className='h-3.5 w-3.5' />}
......@@ -3508,7 +3863,15 @@ export function ChannelMutateDrawer({
</div>
{/* ── Extra Settings ── */}
<div className={sideDrawerSectionClassName()}>
<div
id={ADVANCED_SETTINGS_SECTION_IDS.extraSettings}
className={sideDrawerSectionClassName(
configuredAdvancedSectionClassName(
'scroll-mt-4',
extraSettingsConfigured
)
)}
>
<CardHeading
title={t('Channel Extra Settings')}
icon={<Settings className='h-4 w-4' />}
......@@ -3524,239 +3887,6 @@ export function ChannelMutateDrawer({
disabled={sensitiveLocked}
className='space-y-4 disabled:opacity-60'
>
{(currentType === 1 || currentType === 14) && (
<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' />
}
/>
<div className='divide-border space-y-0 divide-y border-y'>
<FormField
control={form.control}
name='allow_service_tier'
render={({ field }) => (
<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'
)}
</FormLabel>
<FormDescription>
{t(
'Pass through the service_tier field'
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{currentType === 1 && (
<>
<FormField
control={form.control}
name='disable_store'
render={({ field }) => (
<FormItem className='flex items-center justify-between gap-3 px-4 py-3'>
<div className='space-y-0.5'>
<FormLabel className='text-sm'>
{t('Disable store passthrough')}
</FormLabel>
<FormDescription>
{t(
'When enabled, the store field will be blocked'
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name='allow_safety_identifier'
render={({ field }) => (
<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 safety_identifier passthrough'
)}
</FormLabel>
<FormDescription>
{t(
'Pass through the safety_identifier field'
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name='allow_include_obfuscation'
render={({ field }) => (
<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 include usage obfuscation passthrough'
)}
</FormLabel>
<FormDescription>
{t(
'Pass through the include field for usage obfuscation'
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name='allow_inference_geo'
render={({ field }) => (
<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 geography passthrough'
)}
</FormLabel>
<FormDescription>
{t(
'Pass through the inference_geo field for geographic routing'
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</>
)}
{currentType === 14 && (
<>
<FormField
control={form.control}
name='allow_inference_geo'
render={({ field }) => (
<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'
)}
</FormLabel>
<FormDescription>
{t(
'Pass through the inference_geo field for Claude data residency region control'
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name='allow_speed'
render={({ field }) => (
<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 speed passthrough')}
</FormLabel>
<FormDescription>
{t(
'Pass through the speed field for Claude inference speed mode control'
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name='claude_beta_query'
render={({ field }) => (
<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 Claude beta query passthrough'
)}
</FormLabel>
<FormDescription>
{t(
'Pass through the anthropic-beta header for beta features'
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</>
)}
</div>
</div>
)}
<div className='divide-border space-y-0 divide-y border-y'>
{currentType === 1 && (
<FormField
......@@ -3934,136 +4064,394 @@ export function ChannelMutateDrawer({
</FormItem>
)}
/>
</fieldset>
</div>
{MODEL_FETCHABLE_TYPES.has(currentType) && (
<div className='border-border/60 flex flex-col gap-3 border-y py-4'>
<SubHeading
title={t('Upstream Model Detection Settings')}
icon={<RefreshCw className='h-3.5 w-3.5' />}
{(currentType === 1 || currentType === 14) && (
<div
id={ADVANCED_SETTINGS_SECTION_IDS.fieldPassthrough}
className={sideDrawerSectionClassName(
configuredAdvancedSectionClassName(
'scroll-mt-4',
fieldPassthroughConfigured
)
)}
>
<CardHeading
title={t('Field passthrough controls')}
icon={
<SlidersHorizontal className='h-4 w-4' />
}
/>
<fieldset
disabled={sensitiveLocked}
className='disabled:opacity-60'
>
<div className='divide-border space-y-0 divide-y border-y'>
<FormField
control={form.control}
name='allow_service_tier'
render={({ field }) => (
<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')}
</FormLabel>
<FormDescription>
{t(
'Pass through the service_tier field'
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<div className='divide-border space-y-0 divide-y border-y'>
<FormField
control={form.control}
name='upstream_model_update_check_enabled'
render={({ field }) => (
<FormItem className='flex items-center justify-between px-4 py-3'>
<div className='space-y-0.5'>
<FormLabel>
{t('Upstream Model Update Check')}
</FormLabel>
<FormDescription>
{t(
'Periodically check for upstream model changes'
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name='upstream_model_update_auto_sync_enabled'
render={({ field }) => (
<FormItem className='flex items-center justify-between px-4 py-3'>
<div className='space-y-0.5'>
<FormLabel>
{t('Auto Sync Upstream Models')}
</FormLabel>
<FormDescription>
{t(
'Automatically sync model list when upstream changes are detected'
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
disabled={
!upstreamModelUpdateCheckEnabled
}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
{currentType === 1 && (
<>
<FormField
control={form.control}
name='disable_store'
render={({ field }) => (
<FormItem className='flex items-center justify-between gap-3 px-4 py-3'>
<div className='space-y-0.5'>
<FormLabel className='text-sm'>
{t('Disable store passthrough')}
</FormLabel>
<FormDescription>
{t(
'When enabled, the store field will be blocked'
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name='allow_safety_identifier'
render={({ field }) => (
<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 safety_identifier passthrough'
)}
</FormLabel>
<FormDescription>
{t(
'Pass through the safety_identifier field'
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name='allow_include_obfuscation'
render={({ field }) => (
<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 include usage obfuscation passthrough'
)}
</FormLabel>
<FormDescription>
{t(
'Pass through the include field for usage obfuscation'
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name='allow_inference_geo'
render={({ field }) => (
<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 geography passthrough'
)}
</FormLabel>
<FormDescription>
{t(
'Pass through the inference_geo field for geographic routing'
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</>
)}
{currentType === 14 && (
<>
<FormField
control={form.control}
name='allow_inference_geo'
render={({ field }) => (
<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'
)}
</FormLabel>
<FormDescription>
{t(
'Pass through the inference_geo field for Claude data residency region control'
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name='allow_speed'
render={({ field }) => (
<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 speed passthrough')}
</FormLabel>
<FormDescription>
{t(
'Pass through the speed field for Claude inference speed mode control'
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name='claude_beta_query'
render={({ field }) => (
<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 Claude beta query passthrough'
)}
</FormLabel>
<FormDescription>
{t(
'Pass through the anthropic-beta header for beta features'
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</>
)}
</div>
</fieldset>
</div>
)}
{MODEL_FETCHABLE_TYPES.has(currentType) && (
<div
id={
ADVANCED_SETTINGS_SECTION_IDS.upstreamModelDetection
}
className={sideDrawerSectionClassName(
configuredAdvancedSectionClassName(
'scroll-mt-4',
upstreamModelDetectionConfigured
)
)}
>
<CardHeading
title={t('Upstream Model Detection Settings')}
icon={<RefreshCw className='h-4 w-4' />}
/>
<fieldset
disabled={sensitiveLocked}
className='space-y-4 disabled:opacity-60'
>
<div className='divide-border space-y-0 divide-y border-y'>
<FormField
control={form.control}
name='upstream_model_update_ignored_models'
name='upstream_model_update_check_enabled'
render={({ field }) => (
<FormItem>
<FormLabel>
{t('Ignored upstream models')}
</FormLabel>
<FormItem className='flex items-center justify-between px-4 py-3'>
<div className='space-y-0.5'>
<FormLabel>
{t('Upstream Model Update Check')}
</FormLabel>
<FormDescription>
{t(
'Periodically check for upstream model changes'
)}
</FormDescription>
</div>
<FormControl>
<Input
placeholder={t(
'e.g., gpt-4.1-nano,regex:^claude-.*$,regex:^sora-.*$'
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name='upstream_model_update_auto_sync_enabled'
render={({ field }) => (
<FormItem className='flex items-center justify-between px-4 py-3'>
<div className='space-y-0.5'>
<FormLabel>
{t('Auto Sync Upstream Models')}
</FormLabel>
<FormDescription>
{t(
'Automatically sync model list when upstream changes are detected'
)}
{...field}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
disabled={
!upstreamModelUpdateCheckEnabled
}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t(
'Comma-separated exact model names. Prefix with regex: to ignore by regular expression.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className='text-muted-foreground space-y-2 border-t pt-3 text-xs'>
<div>
<span className='text-foreground font-medium'>
{t('Last check time')}:
</span>{' '}
{formatUnixTime(
upstreamUpdateMeta.lastCheckTime
)}
</div>
<div>
<span className='text-foreground font-medium'>
{t('Last detected addable models')}:
</span>{' '}
{upstreamUpdateMeta.detectedModels
.length === 0 ? (
t('None')
) : (
<>
<span className='break-all'>
{upstreamDetectedModelsPreview.join(
', '
</div>
<FormField
control={form.control}
name='upstream_model_update_ignored_models'
render={({ field }) => (
<FormItem>
<FormLabel>
{t('Ignored upstream models')}
</FormLabel>
<FormControl>
<Input
placeholder={t(
'e.g., gpt-4.1-nano,regex:^claude-.*$,regex:^sora-.*$'
)}
{...field}
/>
</FormControl>
<FormDescription>
{t(
'Comma-separated exact model names. Prefix with regex: to ignore by regular expression.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className='text-muted-foreground space-y-2 border-t pt-3 text-xs'>
<div>
<span className='text-foreground font-medium'>
{t('Last check time')}:
</span>{' '}
{formatUnixTime(
upstreamUpdateMeta.lastCheckTime
)}
</div>
<div>
<span className='text-foreground font-medium'>
{t('Last detected addable models')}:
</span>{' '}
{upstreamUpdateMeta.detectedModels.length ===
0 ? (
t('None')
) : (
<>
<span className='break-all'>
{upstreamDetectedModelsPreview.join(
', '
)}
</span>
{upstreamDetectedModelsOmittedCount >
0 && (
<span className='ml-1'>
{t(
'({{total}} total, {{omit}} omitted)',
{
total:
upstreamUpdateMeta
.detectedModels.length,
omit: upstreamDetectedModelsOmittedCount,
}
)}
</span>
{upstreamDetectedModelsOmittedCount >
0 && (
<span className='ml-1'>
{t(
'({{total}} total, {{omit}} omitted)',
{
total:
upstreamUpdateMeta
.detectedModels.length,
omit: upstreamDetectedModelsOmittedCount,
}
)}
</span>
)}
</>
)}
</div>
)}
</>
)}
</div>
</div>
)}
</fieldset>
</div>
</fieldset>
</div>
)}
</ChannelAdvancedSection>
</div>
</div>
......
......@@ -19,7 +19,9 @@ For commercial licensing, please contact support@quantumnous.com
import type { QueryClient } from '@tanstack/react-query'
import i18next from 'i18next'
import { toast } from 'sonner'
import { formatCurrencyFromUSD } from '@/lib/currency'
import {
copyChannel,
deleteChannel,
......@@ -129,7 +131,7 @@ export async function handleEnableChannel(
} else {
toast.error(response.message || i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
}
} catch (_error) {
} catch {
toast.error(i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
}
}
......@@ -154,7 +156,7 @@ export async function handleDisableChannel(
} else {
toast.error(response.message || i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
}
} catch (_error) {
} catch {
toast.error(i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
}
}
......@@ -192,7 +194,7 @@ export async function handleDeleteChannel(
} else {
toast.error(response.message || i18next.t(ERROR_MESSAGES.DELETE_FAILED))
}
} catch (_error) {
} catch {
toast.error(i18next.t(ERROR_MESSAGES.DELETE_FAILED))
}
}
......@@ -224,7 +226,7 @@ export async function handleUpdateChannelField(
} else {
toast.error(response.message || i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
}
} catch (_error) {
} catch {
toast.error(i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
}
}
......@@ -258,7 +260,7 @@ export async function handleUpdateTagField(
} else {
toast.error(response.message || i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
}
} catch (_error) {
} catch {
toast.error(i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
}
}
......@@ -355,7 +357,7 @@ export async function handleCopyChannel(
} else {
toast.error(response.message || i18next.t('Failed to copy channel'))
}
} catch (_error) {
} catch {
toast.error(i18next.t('Failed to copy channel'))
}
}
......@@ -425,7 +427,7 @@ export async function handleBatchDelete(
} else {
toast.error(response.message || i18next.t(ERROR_MESSAGES.DELETE_FAILED))
}
} catch (_error) {
} catch {
toast.error(i18next.t(ERROR_MESSAGES.DELETE_FAILED))
}
}
......@@ -444,10 +446,7 @@ export async function handleBatchEnable(
}
try {
const response = await batchUpdateChannelStatus(
ids,
CHANNEL_STATUS.ENABLED
)
const response = await batchUpdateChannelStatus(ids, CHANNEL_STATUS.ENABLED)
const successCount = response.success ? response.data || 0 : 0
const failCount = ids.length - successCount
......@@ -466,7 +465,7 @@ export async function handleBatchEnable(
i18next.t('{{count}} channel(s) failed to enable', { count: failCount })
)
}
} catch (_error) {
} catch {
toast.error(i18next.t('Failed to enable channels'))
}
}
......@@ -509,7 +508,7 @@ export async function handleBatchDisable(
})
)
}
} catch (_error) {
} catch {
toast.error(i18next.t('Failed to disable channels'))
}
}
......@@ -537,7 +536,7 @@ export async function handleBatchSetTag(
} else {
toast.error(response.message || i18next.t('Failed to set tag'))
}
} catch (_error) {
} catch {
toast.error(i18next.t('Failed to set tag'))
}
}
......@@ -567,7 +566,7 @@ export async function handleEnableTagChannels(
response.message || i18next.t('Failed to enable tag channels')
)
}
} catch (_error) {
} catch {
toast.error(i18next.t('Failed to enable tag channels'))
}
}
......@@ -593,7 +592,7 @@ export async function handleDisableTagChannels(
response.message || i18next.t('Failed to disable tag channels')
)
}
} catch (_error) {
} catch {
toast.error(i18next.t('Failed to disable tag channels'))
}
}
......@@ -624,13 +623,13 @@ export async function handleDeleteAllDisabled(
response.message || i18next.t('Failed to delete disabled channels')
)
}
} catch (_error) {
} catch {
toast.error(i18next.t('Failed to delete disabled channels'))
}
}
/**
* Fix channel abilities
* Repair channel consistency
*/
export async function handleFixAbilities(
queryClient?: QueryClient,
......@@ -640,18 +639,23 @@ export async function handleFixAbilities(
const response = await fixChannelAbilities()
if (response.success && response.data) {
toast.success(
i18next.t('Fixed abilities: {{success}} succeeded, {{fails}} failed', {
success: response.data.success,
fails: response.data.fails,
})
i18next.t(
'Channel consistency repaired: {{success}} succeeded, {{fails}} failed',
{
success: response.data.success,
fails: response.data.fails,
}
)
)
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.(response.data)
} else {
toast.error(response.message || i18next.t('Failed to fix abilities'))
toast.error(
response.message || i18next.t('Failed to repair channel consistency')
)
}
} catch (_error) {
toast.error(i18next.t('Failed to fix abilities'))
} catch {
toast.error(i18next.t('Failed to repair channel consistency'))
}
}
......@@ -677,7 +681,7 @@ export async function handleTestAllChannels(
response.message || i18next.t('Failed to start testing all channels')
)
}
} catch (_error) {
} catch {
toast.error(i18next.t('Failed to test all channels'))
}
}
......@@ -704,7 +708,7 @@ export async function handleUpdateAllBalances(
response.message || i18next.t('Failed to update all balances')
)
}
} catch (_error) {
} catch {
toast.error(i18next.t('Failed to update all balances'))
}
}
......@@ -580,6 +580,7 @@
"Batch edit all channels with this tag. Leave fields empty to keep current values.": "Batch edit all channels with this tag. Leave fields empty to keep current values.",
"Batch Edit by Tag": "Batch Edit by Tag",
"Batch enable failed": "Batch enable failed",
"Batch Operations": "Batch Operations",
"Batch processing failed": "Batch processing failed",
"Batch set tag for {{count}} channels": "Batch set tag for {{count}} channels",
"Batch test completed: {{count}} succeeded": "Batch test completed: {{count}} succeeded",
......@@ -711,6 +712,7 @@
"Channel Affinity": "Channel Affinity",
"Channel affinity reuses the last successful channel based on keys extracted from the request context or JSON body.": "Channel affinity reuses the last successful channel based on keys extracted from the request context or JSON body.",
"Channel Affinity: Upstream Cache Hit": "Channel Affinity: Upstream Cache Hit",
"Channel consistency repaired: {{success}} succeeded, {{fails}} failed": "Channel consistency repaired: {{success}} succeeded, {{fails}} failed",
"Channel copied successfully": "Channel copied successfully",
"Channel created successfully": "Channel created successfully",
"Channel deleted successfully": "Channel deleted successfully",
......@@ -1772,7 +1774,7 @@
"Failed to fetch upstream ratios": "Failed to fetch upstream ratios",
"Failed to fetch usage": "Failed to fetch usage",
"Failed to fetch user information": "Failed to fetch user information",
"Failed to fix abilities": "Failed to fix abilities",
"Failed to fix abilities": "Failed to repair channel consistency",
"Failed to generate token": "Failed to generate token",
"Failed to initialize OAuth": "Failed to initialize OAuth",
"Failed to initialize system": "Failed to initialize system",
......@@ -1800,6 +1802,7 @@
"Failed to regenerate backup codes": "Failed to regenerate backup codes",
"Failed to register Passkey": "Failed to register Passkey",
"Failed to remove Passkey": "Failed to remove Passkey",
"Failed to repair channel consistency": "Failed to repair channel consistency",
"Failed to reset 2FA": "Failed to reset 2FA",
"Failed to reset model ratios": "Failed to reset model ratios",
"Failed to reset Passkey": "Failed to reset Passkey",
......@@ -1919,8 +1922,8 @@
"Finish Time": "Finish Time",
"First API request": "First API request",
"First/Last Frame to Video": "First/Last Frame to Video",
"Fix Abilities": "Fix Abilities",
"Fixed abilities: {{success}} succeeded, {{fails}} failed": "Fixed abilities: {{success}} succeeded, {{fails}} failed",
"Fix Abilities": "Repair Channel Consistency",
"Fixed abilities: {{success}} succeeded, {{fails}} failed": "Channel consistency repaired: {{success}} succeeded, {{fails}} failed",
"Fixed price": "Fixed price",
"Fixed price (USD)": "Fixed price (USD)",
"Fixed request price": "Fixed request price",
......@@ -3584,6 +3587,9 @@
"Rename deployment": "Rename deployment",
"Rename failed": "Rename failed",
"Renamed successfully": "Renamed successfully",
"Repair": "Repair",
"Repair Channel Consistency": "Repair Channel Consistency",
"Repair channel consistency?": "Repair channel consistency?",
"Repeat the administrator password": "Repeat the administrator password",
"Replace": "Replace",
"Replace all existing keys": "Replace all existing keys",
......@@ -4369,6 +4375,7 @@
"This will permanently delete user": "This will permanently delete user",
"This will permanently remove all log entries created before {{date}}.": "This will permanently remove all log entries created before {{date}}.",
"This will permanently remove log entries before the selected timestamp.": "This will permanently remove log entries before the selected timestamp.",
"This will rebuild the channel routing index from every channel configuration, including supported models, groups, priorities, and weights. Routing may be briefly incomplete while the rebuild is running. Continue?": "This will rebuild the channel routing index from every channel configuration, including supported models, groups, priorities, and weights. Routing may be briefly incomplete while the rebuild is running. Continue?",
"This will update the priority to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?": "This will update the priority to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?",
"This will update the weight to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?": "This will update the weight to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?",
"This year": "This year",
......
......@@ -580,6 +580,7 @@
"Batch edit all channels with this tag. Leave fields empty to keep current values.": "Modifiez par lot tous les canaux avec ce tag. Laissez les champs vides pour conserver les valeurs actuelles.",
"Batch Edit by Tag": "Modification par lot par tag",
"Batch enable failed": "Échec de l'activation par lots",
"Batch Operations": "Opérations par lots",
"Batch processing failed": "Échec du traitement par lot",
"Batch set tag for {{count}} channels": "Étiquette définie par lot pour {{count}} canaux",
"Batch test completed: {{count}} succeeded": "Test par lots terminé : {{count}} réussi(s)",
......@@ -711,6 +712,7 @@
"Channel Affinity": "Affinité de canal",
"Channel affinity reuses the last successful channel based on keys extracted from the request context or JSON body.": "L'affinité de canal réutilise le dernier canal ayant réussi, en se basant sur les clés extraites du contexte de la requête ou du corps JSON.",
"Channel Affinity: Upstream Cache Hit": "Affinité de canal : hit de cache en amont",
"Channel consistency repaired: {{success}} succeeded, {{fails}} failed": "Cohérence des canaux réparée : {{success}} réussie(s), {{fails}} échouée(s)",
"Channel copied successfully": "Canal copié avec succès",
"Channel created successfully": "Canal créé avec succès",
"Channel deleted successfully": "Canal supprimé avec succès",
......@@ -1772,7 +1774,7 @@
"Failed to fetch upstream ratios": "Échec de la récupération des ratios en amont",
"Failed to fetch usage": "Échec de la récupération de l'utilisation",
"Failed to fetch user information": "Échec de la récupération des informations utilisateur",
"Failed to fix abilities": "Échec de la correction des capacités",
"Failed to fix abilities": "Échec de la réparation de la cohérence des canaux",
"Failed to generate token": "Échec de la génération du jeton",
"Failed to initialize OAuth": "Échec de l'initialisation d'OAuth",
"Failed to initialize system": "Échec de l'initialisation du système",
......@@ -1800,6 +1802,7 @@
"Failed to regenerate backup codes": "Échec de la régénération des codes de sauvegarde",
"Failed to register Passkey": "Échec de l'enregistrement du Passkey",
"Failed to remove Passkey": "Échec de la suppression de la Passkey",
"Failed to repair channel consistency": "Échec de la réparation de la cohérence des canaux",
"Failed to reset 2FA": "Échec de la réinitialisation de la 2FA",
"Failed to reset model ratios": "Échec de la réinitialisation des ratios du modèle",
"Failed to reset Passkey": "Échec de la réinitialisation de la Passkey",
......@@ -1919,8 +1922,8 @@
"Finish Time": "Heure de fin",
"First API request": "Première requête API",
"First/Last Frame to Video": "Première/Dernière image vers vidéo",
"Fix Abilities": "Corriger les capacités",
"Fixed abilities: {{success}} succeeded, {{fails}} failed": "Correction des capacités : {{success}} réussie(s), {{fails}} échouée(s)",
"Fix Abilities": "Réparer la cohérence des canaux",
"Fixed abilities: {{success}} succeeded, {{fails}} failed": "Cohérence des canaux réparée : {{success}} réussie(s), {{fails}} échouée(s)",
"Fixed price": "Prix fixe",
"Fixed price (USD)": "Prix fixe (USD)",
"Fixed request price": "Prix fixe par requête",
......@@ -3584,6 +3587,9 @@
"Rename deployment": "Renommer le déploiement",
"Rename failed": "Échec du renommage",
"Renamed successfully": "Renommé avec succès",
"Repair": "Réparer",
"Repair Channel Consistency": "Réparer la cohérence des canaux",
"Repair channel consistency?": "Réparer la cohérence des canaux ?",
"Repeat the administrator password": "Répéter le mot de passe administrateur",
"Replace": "Remplacer",
"Replace all existing keys": "Remplacer toutes les clés existantes",
......@@ -4369,6 +4375,7 @@
"This will permanently delete user": "Cela supprimera définitivement l'utilisateur",
"This will permanently remove all log entries created before {{date}}.": "Cela supprimera définitivement toutes les entrées de journal créées avant le {{date}}.",
"This will permanently remove log entries before the selected timestamp.": "Cela supprimera définitivement les entrées de journal antérieures à l'horodatage sélectionné.",
"This will rebuild the channel routing index from every channel configuration, including supported models, groups, priorities, and weights. Routing may be briefly incomplete while the rebuild is running. Continue?": "Cette action reconstruit l’index de routage des canaux à partir de toutes les configurations, y compris les modèles pris en charge, les groupes, les priorités et les poids. Le routage peut être brièvement incomplet pendant la reconstruction. Continuer ?",
"This will update the priority to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?": "La priorité de tous les {{count}} canaux avec le tag \"{{tag}}\" sera mise à jour à {{value}}. Continuer ?",
"This will update the weight to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?": "Le poids de tous les {{count}} canaux avec le tag \"{{tag}}\" sera mis à jour à {{value}}. Continuer ?",
"This year": "Cette année",
......
......@@ -580,6 +580,7 @@
"Batch edit all channels with this tag. Leave fields empty to keep current values.": "このタグを持つすべてのチャネルを一括編集します。現在の値を維持するには、フィールドを空のままにしてください。",
"Batch Edit by Tag": "タグによる一括編集",
"Batch enable failed": "一括有効化に失敗しました",
"Batch Operations": "一括操作",
"Batch processing failed": "一括処理に失敗しました",
"Batch set tag for {{count}} channels": "{{count}} 件のチャネルにタグを一括設定しました",
"Batch test completed: {{count}} succeeded": "バッチテストが完了しました: {{count}} 件成功",
......@@ -711,6 +712,7 @@
"Channel Affinity": "チャネルアフィニティ",
"Channel affinity reuses the last successful channel based on keys extracted from the request context or JSON body.": "チャネルアフィニティは、リクエストコンテキストまたは JSON Body から抽出したキーに基づいて、前回成功したチャネルを優先的に再利用します。",
"Channel Affinity: Upstream Cache Hit": "チャネルアフィニティ:上流キャッシュヒット",
"Channel consistency repaired: {{success}} succeeded, {{fails}} failed": "チャネル整合性を修復しました:成功 {{success}} 件、失敗 {{fails}} 件",
"Channel copied successfully": "チャネルが正常にコピーされました",
"Channel created successfully": "チャネルが正常に作成されました",
"Channel deleted successfully": "チャネルが正常に削除されました",
......@@ -1772,7 +1774,7 @@
"Failed to fetch upstream ratios": "アップストリーム比率の取得に失敗しました",
"Failed to fetch usage": "利用状況の取得に失敗しました",
"Failed to fetch user information": "ユーザー情報の取得に失敗しました",
"Failed to fix abilities": "アビリティの修正に失敗しました",
"Failed to fix abilities": "チャネル整合性の修復に失敗しました",
"Failed to generate token": "トークンの生成に失敗しました",
"Failed to initialize OAuth": "OAuthの初期化に失敗しました",
"Failed to initialize system": "システムの初期化に失敗しました",
......@@ -1800,6 +1802,7 @@
"Failed to regenerate backup codes": "バックアップコードの再生成に失敗しました",
"Failed to register Passkey": "Passkeyの登録に失敗しました",
"Failed to remove Passkey": "パスキーの削除に失敗しました",
"Failed to repair channel consistency": "チャネル整合性の修復に失敗しました",
"Failed to reset 2FA": "2FAのリセットに失敗しました",
"Failed to reset model ratios": "モデル比率のリセットに失敗しました",
"Failed to reset Passkey": "パスキーのリセットに失敗しました",
......@@ -1919,8 +1922,8 @@
"Finish Time": "完了時刻",
"First API request": "最初の API リクエスト",
"First/Last Frame to Video": "先頭/末尾フレームから動画",
"Fix Abilities": "アビリティを修正",
"Fixed abilities: {{success}} succeeded, {{fails}} failed": "能力修正:{{success}} 件成功、{{fails}} 件失敗",
"Fix Abilities": "チャネル整合性を修復",
"Fixed abilities: {{success}} succeeded, {{fails}} failed": "チャネル整合性を修復しました:成功 {{success}} 件、失敗 {{fails}} 件",
"Fixed price": "固定価格",
"Fixed price (USD)": "固定価格 (USD)",
"Fixed request price": "固定リクエスト価格",
......@@ -3584,6 +3587,9 @@
"Rename deployment": "デプロイメントの名前を変更",
"Rename failed": "名前変更に失敗しました",
"Renamed successfully": "名前が正常に変更されました",
"Repair": "修復",
"Repair Channel Consistency": "チャネル整合性を修復",
"Repair channel consistency?": "チャネル整合性を修復しますか?",
"Repeat the administrator password": "管理者パスワードの再入力",
"Replace": "置換",
"Replace all existing keys": "既存のすべてのキーを置き換える",
......@@ -4369,6 +4375,7 @@
"This will permanently delete user": "これによりユーザーが完全に削除されます",
"This will permanently remove all log entries created before {{date}}.": "{{date}} より前に作成されたすべてのログエントリが完全に削除されます。",
"This will permanently remove log entries before the selected timestamp.": "選択したタイムスタンプより前のログエントリが完全に削除されます。",
"This will rebuild the channel routing index from every channel configuration, including supported models, groups, priorities, and weights. Routing may be briefly incomplete while the rebuild is running. Continue?": "すべてのチャネル設定からルーティングインデックスを再構築します。対応モデル、グループ、優先度、重みが含まれます。再構築中はルーティングが一時的に不完全になる可能性があります。続行しますか?",
"This will update the priority to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?": "タグ \"{{tag}}\" の {{count}} 件すべてのチャネルの優先度を {{value}} に更新します。続行しますか?",
"This will update the weight to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?": "タグ \"{{tag}}\" の {{count}} 件すべてのチャネルの重みを {{value}} に更新します。続行しますか?",
"This year": "今年",
......
......@@ -580,6 +580,7 @@
"Batch edit all channels with this tag. Leave fields empty to keep current values.": "Пакетно редактировать все каналы с этим тегом. Оставьте поля пустыми, чтобы сохранить текущие значения.",
"Batch Edit by Tag": "Пакетное редактирование по тегу",
"Batch enable failed": "Пакетное включение не удалось",
"Batch Operations": "Пакетные операции",
"Batch processing failed": "Пакетная обработка не удалась",
"Batch set tag for {{count}} channels": "Тег пакетно задан для {{count}} каналов",
"Batch test completed: {{count}} succeeded": "Пакетный тест завершен: {{count}} успешно",
......@@ -711,6 +712,7 @@
"Channel Affinity": "Привязка к каналу",
"Channel affinity reuses the last successful channel based on keys extracted from the request context or JSON body.": "Привязка к каналу повторно использует последний успешный канал на основе ключей, извлечённых из контекста запроса или тела JSON.",
"Channel Affinity: Upstream Cache Hit": "Привязка к каналу: попадание в кэш upstream",
"Channel consistency repaired: {{success}} succeeded, {{fails}} failed": "Согласованность каналов восстановлена: успешно {{success}}, ошибок {{fails}}",
"Channel copied successfully": "Канал успешно скопирован",
"Channel created successfully": "Канал успешно создан",
"Channel deleted successfully": "Канал успешно удалён",
......@@ -1772,7 +1774,7 @@
"Failed to fetch upstream ratios": "Не удалось получить коэффициенты upstream",
"Failed to fetch usage": "Не удалось получить данные об использовании",
"Failed to fetch user information": "Не удалось получить данные пользователя",
"Failed to fix abilities": "Не удалось исправить способности",
"Failed to fix abilities": "Не удалось восстановить согласованность каналов",
"Failed to generate token": "Не удалось сгенерировать токен",
"Failed to initialize OAuth": "Не удалось инициализировать OAuth",
"Failed to initialize system": "Не удалось инициализировать систему",
......@@ -1800,6 +1802,7 @@
"Failed to regenerate backup codes": "Не удалось перегенерировать резервные коды",
"Failed to register Passkey": "Не удалось зарегистрировать Passkey",
"Failed to remove Passkey": "Не удалось удалить Passkey",
"Failed to repair channel consistency": "Не удалось восстановить согласованность каналов",
"Failed to reset 2FA": "Не удалось сбросить 2FA",
"Failed to reset model ratios": "Не удалось сбросить коэффициенты модели",
"Failed to reset Passkey": "Не удалось сбросить Passkey",
......@@ -1919,8 +1922,8 @@
"Finish Time": "Время завершения",
"First API request": "Первый API-запрос",
"First/Last Frame to Video": "Первый/последний кадр в видео",
"Fix Abilities": "Исправить способности",
"Fixed abilities: {{success}} succeeded, {{fails}} failed": "Исправление способностей: {{success}} успешно, {{fails}} неудачно",
"Fix Abilities": "Восстановить согласованность каналов",
"Fixed abilities: {{success}} succeeded, {{fails}} failed": "Согласованность каналов восстановлена: успешно {{success}}, ошибок {{fails}}",
"Fixed price": "Фиксированная цена",
"Fixed price (USD)": "Фиксированная цена (USD)",
"Fixed request price": "Фиксированная цена запроса",
......@@ -3584,6 +3587,9 @@
"Rename deployment": "Переименовать развертывание",
"Rename failed": "Ошибка переименования",
"Renamed successfully": "Успешно переименовано",
"Repair": "Восстановить",
"Repair Channel Consistency": "Восстановить согласованность каналов",
"Repair channel consistency?": "Восстановить согласованность каналов?",
"Repeat the administrator password": "Повторите пароль администратора",
"Replace": "Заменить",
"Replace all existing keys": "Заменить все существующие ключи",
......@@ -4369,6 +4375,7 @@
"This will permanently delete user": "Это безвозвратно удалит пользователя",
"This will permanently remove all log entries created before {{date}}.": "Это безвозвратно удалит все записи журнала, созданные до {{date}}.",
"This will permanently remove log entries before the selected timestamp.": "Это безвозвратно удалит записи журнала до выбранной временной метки.",
"This will rebuild the channel routing index from every channel configuration, including supported models, groups, priorities, and weights. Routing may be briefly incomplete while the rebuild is running. Continue?": "Будет заново построен индекс маршрутизации каналов на основе всех конфигураций каналов, включая поддерживаемые модели, группы, приоритеты и веса. Во время перестроения маршрутизация может кратковременно быть неполной. Продолжить?",
"This will update the priority to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?": "Приоритет всех каналов ({{count}}) с тегом \"{{tag}}\" будет изменен на {{value}}. Продолжить?",
"This will update the weight to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?": "Вес всех каналов ({{count}}) с тегом \"{{tag}}\" будет изменен на {{value}}. Продолжить?",
"This year": "Этот год",
......
......@@ -580,6 +580,7 @@
"Batch edit all channels with this tag. Leave fields empty to keep current values.": "Chỉnh sửa hàng loạt tất cả các kênh có gắn thẻ này. Để trống các trường để giữ nguyên giá trị hiện tại.",
"Batch Edit by Tag": "Chỉnh sửa hàng loạt theo Thẻ",
"Batch enable failed": "Kích hoạt hàng loạt thất bại",
"Batch Operations": "Thao tác hàng loạt",
"Batch processing failed": "Xử lý hàng loạt thất bại",
"Batch set tag for {{count}} channels": "Đã đặt thẻ hàng loạt cho {{count}} kênh",
"Batch test completed: {{count}} succeeded": "Kiểm thử hàng loạt hoàn tất: {{count}} thành công",
......@@ -711,6 +712,7 @@
"Channel Affinity": "Ưu tiên kênh",
"Channel affinity reuses the last successful channel based on keys extracted from the request context or JSON body.": "Ưu tiên kênh sẽ sử dụng lại kênh thành công gần nhất dựa trên các khóa được trích xuất từ ngữ cảnh yêu cầu hoặc JSON body.",
"Channel Affinity: Upstream Cache Hit": "Ưu tiên kênh: Cache hit từ upstream",
"Channel consistency repaired: {{success}} succeeded, {{fails}} failed": "Đã sửa tính nhất quán kênh: {{success}} thành công, {{fails}} thất bại",
"Channel copied successfully": "Sao chép kênh thành công",
"Channel created successfully": "Tạo kênh thành công",
"Channel deleted successfully": "Xóa kênh thành công",
......@@ -1772,7 +1774,7 @@
"Failed to fetch upstream ratios": "Không thể lấy tỷ lệ upstream",
"Failed to fetch usage": "Không lấy được mức sử dụng",
"Failed to fetch user information": "Không lấy được thông tin người dùng",
"Failed to fix abilities": "Không thể khắc phục các khả năng",
"Failed to fix abilities": "Không thể sửa tính nhất quán kênh",
"Failed to generate token": "Không thể tạo token",
"Failed to initialize OAuth": "Không thể khởi tạo OAuth",
"Failed to initialize system": "Không thể khởi tạo hệ thống",
......@@ -1800,6 +1802,7 @@
"Failed to regenerate backup codes": "Không thể tạo lại mã sao lưu",
"Failed to register Passkey": "Không thể đăng ký Passkey",
"Failed to remove Passkey": "Không thể xóa Passkey",
"Failed to repair channel consistency": "Không thể sửa tính nhất quán kênh",
"Failed to reset 2FA": "Không thể đặt lại 2FA",
"Failed to reset model ratios": "Không thể đặt lại tỷ lệ mô hình",
"Failed to reset Passkey": "Không thể đặt lại Passkey",
......@@ -1919,8 +1922,8 @@
"Finish Time": "Thời gian hoàn thành",
"First API request": "Yêu cầu API đầu tiên",
"First/Last Frame to Video": "Khung đầu/cuối sang video",
"Fix Abilities": "Sửa Kỹ năng",
"Fixed abilities: {{success}} succeeded, {{fails}} failed": "Sửa khả năng: {{success}} thành công, {{fails}} thất bại",
"Fix Abilities": "Sửa tính nhất quán kênh",
"Fixed abilities: {{success}} succeeded, {{fails}} failed": "Đã sửa tính nhất quán kênh: {{success}} thành công, {{fails}} thất bại",
"Fixed price": "Giá cố định",
"Fixed price (USD)": "Giá cố định (USD)",
"Fixed request price": "Giá cố định theo yêu cầu",
......@@ -3584,6 +3587,9 @@
"Rename deployment": "Đổi tên deployment",
"Rename failed": "Đổi tên thất bại",
"Renamed successfully": "Đổi tên thành công",
"Repair": "Sửa",
"Repair Channel Consistency": "Sửa tính nhất quán kênh",
"Repair channel consistency?": "Sửa tính nhất quán kênh?",
"Repeat the administrator password": "Nhập lại mật khẩu quản trị viên",
"Replace": "Thay thế",
"Replace all existing keys": "Thay thế tất cả các khóa hiện có",
......@@ -4369,6 +4375,7 @@
"This will permanently delete user": "Thao tác này sẽ xóa vĩnh viễn người dùng",
"This will permanently remove all log entries created before {{date}}.": "Thao tác này sẽ xóa vĩnh viễn tất cả các mục nhật ký được tạo trước {{date}}.",
"This will permanently remove log entries before the selected timestamp.": "Thao tác này sẽ xóa vĩnh viễn các mục nhật ký trước mốc thời gian đã chọn.",
"This will rebuild the channel routing index from every channel configuration, including supported models, groups, priorities, and weights. Routing may be briefly incomplete while the rebuild is running. Continue?": "Thao tác này sẽ xây dựng lại chỉ mục định tuyến kênh từ toàn bộ cấu hình kênh, bao gồm mô hình được hỗ trợ, nhóm, độ ưu tiên và trọng số. Định tuyến có thể tạm thời chưa đầy đủ trong khi xây dựng lại. Tiếp tục?",
"This will update the priority to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?": "Thao tác này sẽ cập nhật mức ưu tiên thành {{value}} cho tất cả {{count}} kênh có thẻ \"{{tag}}\". Tiếp tục?",
"This will update the weight to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?": "Thao tác này sẽ cập nhật trọng số thành {{value}} cho tất cả {{count}} kênh có thẻ \"{{tag}}\". Tiếp tục?",
"This year": "Năm nay",
......
......@@ -580,6 +580,7 @@
"Batch edit all channels with this tag. Leave fields empty to keep current values.": "批量编辑带有此标签的所有渠道。留空字段以保留当前值。",
"Batch Edit by Tag": "按标签批量编辑",
"Batch enable failed": "批量启用失败",
"Batch Operations": "批量操作",
"Batch processing failed": "批量处理失败",
"Batch set tag for {{count}} channels": "批量为 {{count}} 个渠道设置标签",
"Batch test completed: {{count}} succeeded": "批量测试完成:{{count}} 个成功",
......@@ -711,6 +712,7 @@
"Channel Affinity": "渠道亲和性",
"Channel affinity reuses the last successful channel based on keys extracted from the request context or JSON body.": "渠道亲和性会基于从请求上下文或 JSON Body 提取的 Key,优先复用上一次成功的渠道。",
"Channel Affinity: Upstream Cache Hit": "渠道亲和性:上游缓存命中",
"Channel consistency repaired: {{success}} succeeded, {{fails}} failed": "渠道一致性修复完成:{{success}} 个成功,{{fails}} 个失败",
"Channel copied successfully": "渠道复制成功",
"Channel created successfully": "渠道创建成功",
"Channel deleted successfully": "渠道删除成功",
......@@ -1772,7 +1774,7 @@
"Failed to fetch upstream ratios": "获取上游比率失败",
"Failed to fetch usage": "获取用量失败",
"Failed to fetch user information": "获取用户信息失败",
"Failed to fix abilities": "修复能力失败",
"Failed to fix abilities": "修复渠道一致性失败",
"Failed to generate token": "生成令牌失败",
"Failed to initialize OAuth": "初始化 OAuth 失败",
"Failed to initialize system": "系统初始化失败",
......@@ -1800,6 +1802,7 @@
"Failed to regenerate backup codes": "重新生成备份代码失败",
"Failed to register Passkey": "注册 Passkey 失败",
"Failed to remove Passkey": "移除 Passkey 失败",
"Failed to repair channel consistency": "修复渠道一致性失败",
"Failed to reset 2FA": "重置 2FA 失败",
"Failed to reset model ratios": "重置模型比率失败",
"Failed to reset Passkey": "重置 Passkey 失败",
......@@ -1919,8 +1922,8 @@
"Finish Time": "完成时间",
"First API request": "首个 API 请求",
"First/Last Frame to Video": "首尾生视频",
"Fix Abilities": "修复能力",
"Fixed abilities: {{success}} succeeded, {{fails}} failed": "修复能力:{{success}} 个成功,{{fails}} 个失败",
"Fix Abilities": "修复渠道一致性",
"Fixed abilities: {{success}} succeeded, {{fails}} failed": "渠道一致性修复完成:{{success}} 个成功,{{fails}} 个失败",
"Fixed price": "固定价格",
"Fixed price (USD)": "固定价格 (USD)",
"Fixed request price": "固定按次价格",
......@@ -3584,6 +3587,9 @@
"Rename deployment": "重命名部署",
"Rename failed": "重命名失败",
"Renamed successfully": "重命名成功",
"Repair": "修复",
"Repair Channel Consistency": "修复渠道一致性",
"Repair channel consistency?": "修复渠道一致性?",
"Repeat the administrator password": "重复输入管理员密码",
"Replace": "替换",
"Replace all existing keys": "替换所有现有密钥",
......@@ -4369,6 +4375,7 @@
"This will permanently delete user": "这将永久删除用户",
"This will permanently remove all log entries created before {{date}}.": "这将永久删除 {{date}} 之前创建的所有日志条目。",
"This will permanently remove log entries before the selected timestamp.": "这将永久删除所选时间戳之前的日志条目。",
"This will rebuild the channel routing index from every channel configuration, including supported models, groups, priorities, and weights. Routing may be briefly incomplete while the rebuild is running. Continue?": "这会根据所有渠道配置重建渠道路由索引,包括支持模型、分组、优先级和权重。重建期间路由可能短暂不完整。是否继续?",
"This will update the priority to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?": "这会将标签 \"{{tag}}\" 下所有 {{count}} 个渠道的优先级更新为 {{value}}。继续吗?",
"This will update the weight to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?": "这会将标签 \"{{tag}}\" 下所有 {{count}} 个渠道的权重更新为 {{value}}。继续吗?",
"This year": "本年",
......
......@@ -99,6 +99,8 @@ export interface CurrencyFormatOptions {
* "$280K" in en). The currency symbol is preserved.
*/
compact?: boolean
/** Whether to include the currency/custom symbol. Token displays are unchanged. */
showSymbol?: boolean
/** Locale used for number formatting (defaults to the runtime locale) */
locale?: Intl.LocalesArgument | undefined
}
......@@ -134,6 +136,7 @@ const DEFAULT_FORMAT_OPTIONS: ResolvedCurrencyFormatOptions = {
abbreviate: true,
minimumNonZero: 0,
compact: false,
showSymbol: true,
locale: undefined,
}
......@@ -236,6 +239,7 @@ function mergeOptions(
minimumNonZero:
options.minimumNonZero ?? DEFAULT_FORMAT_OPTIONS.minimumNonZero,
compact: options.compact ?? DEFAULT_FORMAT_OPTIONS.compact,
showSymbol: options.showSymbol ?? DEFAULT_FORMAT_OPTIONS.showSymbol,
locale: options.locale ?? DEFAULT_FORMAT_OPTIONS.locale,
}
}
......@@ -254,7 +258,7 @@ function formatNumberWithSuffix(
const abs = Math.abs(value)
if (abbreviate && abs >= 1000) {
const result = value / 1000
return removeTrailingZeros(result.toFixed(1)) + 'k'
return `${removeTrailingZeros(result.toFixed(1))}k`
}
const digits = abs >= 1 ? digitsLarge : digitsSmall
......@@ -301,6 +305,14 @@ function formatCurrencyValue(
const adjustedValue = adjustForMinimum(value, digits, options.minimumNonZero)
if (meta.kind === 'currency') {
if (!options.showSymbol) {
return new Intl.NumberFormat(options.locale, {
notation: options.compact ? 'compact' : 'standard',
minimumFractionDigits: 0,
maximumFractionDigits: options.compact ? 1 : digits,
}).format(adjustedValue)
}
const formatted = new Intl.NumberFormat(options.locale, {
style: 'currency',
currency: meta.currencyCode,
......@@ -318,7 +330,7 @@ function formatCurrencyValue(
maximumFractionDigits: options.compact ? 1 : digits,
}).format(adjustedValue)
return `${meta.symbol} ${decimal}`
return options.showSymbol ? `${meta.symbol} ${decimal}` : decimal
}
/**
......
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