feat: publish sanitized custom.97 source

This commit is contained in:
cneicy committed 2026-10-27 20:43:39 +08:00
1 parent 8e6ed0317b
commit 86c0276156
1022 files changed
+69903 -19207

No files matched your search

@@ -0,0 +1,38 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type { SVGProps } from 'react'
import { cn } from '@/lib/utils'
export function IconAlipay({ className, ...props }: SVGProps<SVGSVGElement>) {
return (
<svg
role='img'
viewBox='0 0 24 24'
xmlns='http://www.w3.org/2000/svg'
width='24'
height='24'
className={cn('fill-current', className)}
{...props}
>
<title>Alipay</title>
<path d='M19.695 15.07c3.426 1.158 4.203 1.22 4.203 1.22V3.846c0-2.124-1.705-3.845-3.81-3.845H3.914C1.808.001.102 1.722.102 3.846v16.31c0 2.123 1.706 3.845 3.813 3.845h16.173c2.105 0 3.81-1.722 3.81-3.845v-.157s-6.19-2.602-9.315-4.119c-2.096 2.602-4.8 4.181-7.607 4.181-4.75 0-6.361-4.19-4.112-6.949.49-.602 1.324-1.175 2.617-1.497 2.025-.502 5.247.313 8.266 1.317a16.796 16.796 0 0 0 1.341-3.302H5.781v-.952h4.799V6.975H4.77v-.953h5.81V3.591s0-.409.411-.409h2.347v2.84h5.744v.951h-5.744v1.704h4.69a19.453 19.453 0 0 1-1.986 5.06c1.424.52 2.702 1.011 3.654 1.333m-13.81-2.032c-.596.06-1.71.325-2.321.869-1.83 1.608-.735 4.55 2.968 4.55 2.151 0 4.301-1.388 5.99-3.61-2.403-1.182-4.438-2.028-6.637-1.809' />
</svg>
)
}
+1
View File
@@ -16,6 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
export { IconAlipay } from './icon-alipay'
export { IconDiscord } from './icon-discord'
export { IconDocker } from './icon-docker'
export { IconFacebook } from './icon-facebook'
@@ -0,0 +1,130 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import {
createMemoryHistory,
createRootRoute,
createRouter,
RouterProvider,
} from '@tanstack/react-router'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterAll, afterEach, beforeAll, expect, test } from 'vitest'
import { SearchProvider } from '@/context/search-provider'
import { ROLE } from '@/lib/roles'
import { STATUS_QUERY_KEY } from '@/lib/status-query'
import { useAuthStore } from '@/stores/auth-store'
import { CommandMenu } from '../command-menu'
import { Search } from '../search'
const adminEntries = ['Channels', 'Models', 'Users', 'Redemption Codes']
const superAdminEntries = ['System Info', 'Task Plugins', 'System Settings']
// jsdom lacks the Web Animations API the menu's scroll area waits on.
const getAnimationsDescriptor = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
'getAnimations'
)
beforeAll(() => {
Object.defineProperty(HTMLElement.prototype, 'getAnimations', {
configurable: true,
value: () => [],
})
})
afterAll(() => {
if (getAnimationsDescriptor) {
Object.defineProperty(
HTMLElement.prototype,
'getAnimations',
getAnimationsDescriptor
)
} else {
Reflect.deleteProperty(HTMLElement.prototype, 'getAnimations')
}
})
afterEach(() => {
useAuthStore.setState(useAuthStore.getInitialState(), true)
window.localStorage.clear()
})
async function searchEntriesFor(role: number, sidebarModulesAdmin = '') {
useAuthStore.getState().auth.setUser({ id: 1, username: 'alice', role })
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
client.setQueryData(STATUS_QUERY_KEY, {
SidebarModulesAdmin: sidebarModulesAdmin,
})
const router = createRouter({
routeTree: createRootRoute({
component: () => (
<SearchProvider>
<Search />
<CommandMenu />
</SearchProvider>
),
}),
history: createMemoryHistory({ initialEntries: ['/dashboard/overview'] }),
})
await router.load()
render(
<QueryClientProvider client={client}>
<RouterProvider router={router} />
</QueryClientProvider>
)
await userEvent.click(await screen.findByRole('button', { name: 'Search' }))
const options = await screen.findAllByRole('option')
return options.map((option) => option.textContent)
}
test('regular users only find their own console pages in search', async () => {
const entries = await searchEntriesFor(ROLE.USER)
expect(entries).toEqual(
expect.arrayContaining(['API Keys', 'Usage Logs', 'Wallet', 'Profile'])
)
for (const entry of [...adminEntries, ...superAdminEntries]) {
expect(entries).not.toContain(entry)
}
})
test('admins find admin pages in search but not super admin pages', async () => {
const entries = await searchEntriesFor(ROLE.ADMIN)
expect(entries).toEqual(expect.arrayContaining(adminEntries))
for (const entry of superAdminEntries) {
expect(entries).not.toContain(entry)
}
})
test('pages hidden in the sidebar settings stay out of search', async () => {
const entries = await searchEntriesFor(
ROLE.USER,
JSON.stringify({ personal: { enabled: true, topup: false } })
)
expect(entries).not.toContain('Wallet')
expect(entries).toContain('Profile')
})
@@ -0,0 +1,74 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import i18next from 'i18next'
import { afterEach, expect, test, vi } from 'vitest'
import { DatePicker } from '../date-picker'
import { DateTimePicker } from '../datetime-picker'
vi.mock('@/components/ui/calendar', () => ({
Calendar: ({ locale }: { locale?: { code?: string } }) => (
<div data-testid='calendar-locale'>{locale?.code ?? 'missing'}</div>
),
}))
const selected = new Date('2026-10-11T00:00:00')
async function setInterfaceLanguage(language: string) {
if (!i18next.hasResourceBundle(language, 'translation')) {
i18next.addResourceBundle(language, 'translation', {})
}
await act(() => i18next.changeLanguage(language))
}
async function openCalendar() {
await userEvent.click(screen.getByRole('button', { name: /2026-10-11/ }))
return screen.findByTestId('calendar-locale')
}
afterEach(async () => {
await act(() => i18next.changeLanguage('en'))
})
test('uses Chinese calendar locales for zhCN and zhTW in both date pickers', async () => {
await setInterfaceLanguage('zhCN')
const datePicker = render(
<DatePicker selected={selected} onSelect={() => undefined} />
)
expect(await openCalendar()).toHaveTextContent('zh-CN')
datePicker.unmount()
const dateTimePicker = render(
<DateTimePicker value={selected} onChange={() => undefined} />
)
expect(await openCalendar()).toHaveTextContent('zh-CN')
dateTimePicker.unmount()
await setInterfaceLanguage('zhTW')
render(<DatePicker selected={selected} onSelect={() => undefined} />)
expect(await openCalendar()).toHaveTextContent('zh-TW')
})
test('keeps French on the French calendar locale', async () => {
await setInterfaceLanguage('fr')
render(<DatePicker selected={selected} onSelect={() => undefined} />)
expect(await openCalendar()).toHaveTextContent('fr')
})
@@ -0,0 +1,74 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { fireEvent, render, screen } from '@testing-library/react'
import { expect, test, vi } from 'vitest'
import { DateTimePicker } from '../datetime-picker'
test('shows the seconds of the selected time', () => {
render(
<DateTimePicker
value={new Date(2026, 9, 11, 8, 30, 45)}
onChange={() => undefined}
/>
)
expect(screen.getByLabelText('Time')).toHaveValue('08:30:45')
})
test('keeps the seconds typed into the time field', () => {
const onChange = vi.fn()
render(
<DateTimePicker
value={new Date(2026, 9, 11, 8, 0, 0)}
onChange={onChange}
/>
)
fireEvent.change(screen.getByLabelText('Time'), {
target: { value: '08:30:45' },
})
expect(onChange).toHaveBeenLastCalledWith(new Date(2026, 9, 11, 8, 30, 45))
})
test('reads a time without seconds as zero seconds', () => {
const onChange = vi.fn()
render(
<DateTimePicker
value={new Date(2026, 9, 11, 8, 0, 30)}
onChange={onChange}
/>
)
fireEvent.change(screen.getByLabelText('Time'), {
target: { value: '09:15' },
})
expect(onChange).toHaveBeenLastCalledWith(new Date(2026, 9, 11, 9, 15, 0))
})
test('keeps the current time when the time field is emptied', () => {
const onChange = vi.fn()
render(
<DateTimePicker
value={new Date(2026, 9, 11, 20, 30, 45)}
onChange={onChange}
/>
)
const field = screen.getByLabelText('Time')
fireEvent.change(field, { target: { value: '' } })
expect(onChange).not.toHaveBeenCalled()
expect(field).toHaveValue('20:30:45')
})
@@ -40,6 +40,25 @@ function editorTree(value: string) {
}
describe('CodeBlockEditor', () => {
test('does not add a second vertical inset to the line-number gutter for multiline source', () => {
const { getByRole } = render(
<CodeBlockEditor
ariaLabel='Plugin source'
autoFocus={false}
language='javascript'
onChange={() => undefined}
value={'2\n1\n1'}
/>
)
const editor = getByRole('textbox', { name: 'Plugin source' })
const gutters = editor.querySelector('.cm-gutters')
// CodeMirror already offsets each gutter line by the content padding.
// Extra vertical padding here shifts every number below its source line.
expect(gutters).toHaveStyle({ paddingTop: '0px', paddingBottom: '0px' })
})
test('keeps the same editor instance when value and onKeyDown change on rerender', () => {
const { rerender } = render(editorTree('h'))
@@ -161,7 +161,8 @@ const codeMirrorTheme = EditorView.theme({
fontFamily: 'var(--font-mono)',
fontSize: '13px',
lineHeight: '1.5rem',
padding: '1rem 1rem 1rem 0',
// CodeMirror already accounts for the content's vertical padding.
padding: '0 1rem 0 0',
},
'.cm-gutters:empty': {
display: 'none',
@@ -889,7 +889,7 @@ export const PromptInputTextarea = ({
return (
<InputGroupTextarea
className={cn('field-sizing-content max-h-48 min-h-16', className)}
className={cn('field-sizing-content max-h-48 min-h-16 break-all', className)}
name='message'
onCompositionEnd={() => setIsComposing(false)}
onCompositionStart={() => setIsComposing(true)}
@@ -0,0 +1,136 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import {
ArrowDown01Icon,
ArrowUp01Icon,
Cancel01Icon,
Drag01Icon,
} from '@hugeicons/core-free-icons'
import { HugeiconsIcon } from '@hugeicons/react'
import { Reorder, useDragControls } from 'motion/react'
import type { KeyboardEvent, PointerEvent, ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
type AutoGroupOrderItemProps = {
group: string
index: number
count: number
leading?: ReactNode
children?: ReactNode
onMove: (index: number, direction: 'up' | 'down') => void
onRemove: (group: string) => void
}
export function AutoGroupOrderItem(props: AutoGroupOrderItemProps) {
const { t } = useTranslation()
const dragControls = useDragControls()
const handleDragStart = (event: PointerEvent<HTMLButtonElement>) => {
dragControls.start(event)
}
const handleDragKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key === 'ArrowUp') {
event.preventDefault()
props.onMove(props.index, 'up')
}
if (event.key === 'ArrowDown') {
event.preventDefault()
props.onMove(props.index, 'down')
}
}
return (
<Reorder.Item
value={props.group}
dragListener={false}
dragMomentum={false}
dragControls={dragControls}
className='bg-background relative flex min-w-0 items-center gap-2 rounded-lg border p-2'
>
<Button
type='button'
variant='ghost'
size='icon-sm'
className='text-muted-foreground cursor-grab touch-none font-mono active:cursor-grabbing'
aria-label={t('Drag {{group}} to reorder', { group: props.group })}
title={t('Drag {{group}} to reorder', { group: props.group })}
onPointerDown={handleDragStart}
onKeyDown={handleDragKeyDown}
>
<HugeiconsIcon icon={Drag01Icon} strokeWidth={2} aria-hidden='true' />
</Button>
{props.leading}
<div className='flex min-w-0 flex-1 flex-wrap items-center gap-2'>
<span
className='min-w-0 truncate text-sm font-medium'
title={props.group}
>
{props.group}
</span>
{props.children}
</div>
<div className='flex shrink-0 gap-1'>
<Button
type='button'
variant='ghost'
size='icon-sm'
disabled={props.index === 0}
aria-label={t('Move {{group}} up', { group: props.group })}
onClick={() => props.onMove(props.index, 'up')}
>
<HugeiconsIcon
icon={ArrowUp01Icon}
strokeWidth={2}
aria-hidden='true'
/>
</Button>
<Button
type='button'
variant='ghost'
size='icon-sm'
disabled={props.index === props.count - 1}
aria-label={t('Move {{group}} down', { group: props.group })}
onClick={() => props.onMove(props.index, 'down')}
>
<HugeiconsIcon
icon={ArrowDown01Icon}
strokeWidth={2}
aria-hidden='true'
/>
</Button>
<Button
type='button'
variant='ghost'
size='icon-sm'
aria-label={t('Remove {{group}}', { group: props.group })}
onClick={() => props.onRemove(props.group)}
>
<HugeiconsIcon
icon={Cancel01Icon}
strokeWidth={2}
aria-hidden='true'
/>
</Button>
</div>
</Reorder.Item>
)
}
+11 -14
View File
@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useLocation, useNavigate } from '@tanstack/react-router'
import { useNavigate } from '@tanstack/react-router'
import { ArrowRight, ChevronRight, Laptop, Moon, Sun } from 'lucide-react'
import React from 'react'
import { useTranslation } from 'react-i18next'
@@ -33,9 +33,8 @@ import {
} from '@/components/ui/command'
import { useSearch } from '@/context/search-provider'
import { useTheme } from '@/context/theme-provider'
import { useSidebarData } from '@/hooks/use-sidebar-data'
import { useSidebarView } from '@/hooks/use-sidebar-view'
import { getNavGroupsForPath } from './layout/lib/sidebar-view-registry'
import { ScrollArea } from './ui/scroll-area'
export function CommandMenu() {
@@ -43,12 +42,9 @@ export function CommandMenu() {
const navigate = useNavigate()
const { setTheme } = useTheme()
const { open, setOpen } = useSearch()
const { pathname } = useLocation()
const sidebarData = useSidebarData()
// Use the active nested sidebar view's nav groups when one matches
// the current URL; otherwise fall back to the root navigation.
const navGroups = getNavGroupsForPath(pathname, t) ?? sidebarData.navGroups
// Offer the same pages as the sidebar, so search never lists pages the
// user's role or the sidebar settings hide.
const { navGroups } = useSidebarView()
const runCommand = React.useCallback(
(command: () => unknown) => {
@@ -67,11 +63,11 @@ export function CommandMenu() {
<CommandEmpty>{t('No results found.')}</CommandEmpty>
{navGroups.map((group) => (
<CommandGroup key={group.id || group.title} heading={group.title}>
{group.items.map((navItem, i) => {
if (navItem.url)
{group.items.map((navItem) => {
if (navItem.url) {
return (
<CommandItem
key={`${navItem.url}-${i}`}
key={`${navItem.title}-${navItem.url}`}
value={navItem.title}
onSelect={() => {
runCommand(() => navigate({ to: navItem.url }))
@@ -83,10 +79,11 @@ export function CommandMenu() {
{navItem.title}
</CommandItem>
)
}
return navItem.items?.map((subItem, i) => (
return navItem.items?.map((subItem) => (
<CommandItem
key={`${navItem.title}-${subItem.url}-${i}`}
key={`${navItem.title}-${subItem.url}`}
value={`${navItem.title}-${subItem.url}`}
onSelect={() => {
runCommand(() => navigate({ to: subItem.url }))
+9 -16
View File
@@ -54,6 +54,7 @@ import { useThemeCustomization } from '@/context/theme-customization-provider'
import { useTheme } from '@/context/theme-provider'
import {
type ContentLayout,
resolveThemeFont,
THEME_PRESETS,
type ThemeFont,
type ThemePreset,
@@ -305,28 +306,26 @@ function PresetConfig() {
/**
* Font options shown in the theme drawer.
*
* Each option renders a live "Aa" preview in the font it represents.
* `Auto` deliberately leaves `fontFamily` undefined so the preview inherits
* the currently active body font — that way the user sees what `Auto` will
* actually look like for the active preset (Anthropic → serif glyphs,
* everything else → sans glyphs) without us having to duplicate the
* preset-default mapping in the UI.
* Each option previews the font it will select, including Auto when the
* user currently has an explicit font selected.
*/
const FONT_OPTIONS: {
value: ThemeFont
label: string
// CSS font-family applied to the "Aa" preview. `undefined` = inherit
// from the current theme (used by the `default` option).
// Auto resolves its preview from the active preset.
preview?: string
}[] = [
{ value: 'default', label: 'Auto', preview: undefined },
{ value: 'sans', label: 'Sans', preview: 'var(--font-sans)' },
{ value: 'sans', label: 'Sans', preview: 'var(--font-public-sans)' },
{ value: 'serif', label: 'Serif', preview: 'var(--font-serif)' },
]
function FontConfig() {
const { t } = useTranslation()
const { defaults, customization, setFont } = useThemeCustomization()
const autoFont = resolveThemeFont('default', customization.preset)
const autoFontFamily =
autoFont === 'serif' ? 'var(--font-serif)' : 'sans-serif'
return (
<div>
<SectionTitle
@@ -367,13 +366,7 @@ function FontConfig() {
<span
aria-hidden='true'
className='text-foreground absolute inset-0 flex items-center justify-center text-lg leading-none font-medium'
style={
option.preview
? { fontFamily: option.preview }
: // `font: inherit` defers to the active theme so the
// "Auto" tile previews what the resolved font will be.
{ font: 'inherit', fontSize: '1.125rem' }
}
style={{ fontFamily: option.preview ?? autoFontFamily }}
>
Aa
</span>
@@ -63,6 +63,7 @@ function DataTableRowInner<TData>({
return (
<TableRow
data-sortable-id={row.id}
data-state={isSelected ? 'selected' : undefined}
className={className}
{...rowProps}
@@ -183,6 +183,7 @@ export function DataTableCardGrid<TData>(props: DataTableCardGridProps<TData>) {
<div
key={key}
data-slot='data-table-card'
data-sortable-id={row.id}
data-state={isSelected ? 'selected' : undefined}
className={cn(
'rounded-lg border bg-(--data-table-card-bg,var(--table-row)) px-3 py-2.5 transition-[background-color,border-color] duration-150 data-[state=selected]:[--data-table-card-bg:color-mix(in_oklch,var(--primary)_7%,var(--table-row))] data-[state=selected]:border-primary/40',
+3 -2
View File
@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { Calendar as CalendarIcon } from 'lucide-react'
import { enUS, fr, ja, ru, vi, zhCN } from 'react-day-picker/locale'
import { enUS, fr, ja, ru, vi, zhCN, zhTW } from 'react-day-picker/locale'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
@@ -31,7 +31,8 @@ import dayjs from '@/lib/dayjs'
const calendarLocales = {
en: enUS,
zh: zhCN,
zhCN,
zhTW,
fr,
ru,
ja,
+16 -11
View File
@@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { ChevronDownIcon } from 'lucide-react'
import * as React from 'react'
import { enUS, fr, ja, ru, vi, zhCN } from 'react-day-picker/locale'
import { enUS, fr, ja, ru, vi, zhCN, zhTW } from 'react-day-picker/locale'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
@@ -34,7 +34,8 @@ import { cn } from '@/lib/utils'
const calendarLocales = {
en: enUS,
zh: zhCN,
zhCN,
zhTW,
fr,
ru,
ja,
@@ -62,23 +63,21 @@ export function DateTimePicker({
const [open, setOpen] = React.useState(false)
const [date, setDate] = React.useState<Date | undefined>(value)
const [month, setMonth] = React.useState<Date | undefined>(value)
const [time, setTime] = React.useState<string>('00:00')
const [time, setTime] = React.useState<string>('00:00:00')
React.useEffect(() => {
setDate(value)
setMonth(value)
if (value) {
const hours = value.getHours().toString().padStart(2, '0')
const minutes = value.getMinutes().toString().padStart(2, '0')
setTime(`${hours}:${minutes}`)
setTime(dayjs(value).format('HH:mm:ss'))
}
}, [value])
const handleDateSelect = (selectedDate: Date | undefined) => {
if (selectedDate) {
const [hours, minutes] = time.split(':').map(Number)
const [hours, minutes, seconds = 0] = time.split(':').map(Number)
const newDate = new Date(selectedDate)
newDate.setHours(hours, minutes, 0, 0)
newDate.setHours(hours, minutes, seconds, 0)
setDate(newDate)
setMonth(newDate)
onChange?.(newDate)
@@ -92,12 +91,16 @@ export function DateTimePicker({
const handleTimeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newTime = e.target.value
// The browser reports an emptied or half-cleared field as ''. Keep the
// current time: the field shows it again and no invalid date goes out.
if (!newTime) return
setTime(newTime)
if (date) {
const [hours, minutes] = newTime.split(':').map(Number)
// A browser may omit :00 seconds from the value.
const [hours, minutes, seconds = 0] = newTime.split(':').map(Number)
const newDate = new Date(date)
newDate.setHours(hours, minutes, 0, 0)
newDate.setHours(hours, minutes, seconds, 0)
setDate(newDate)
onChange?.(newDate)
}
@@ -106,7 +109,7 @@ export function DateTimePicker({
const handleClear = () => {
setDate(undefined)
setMonth(undefined)
setTime('00:00')
setTime('00:00:00')
onChange?.(undefined)
}
@@ -143,7 +146,9 @@ export function DateTimePicker({
</Popover>
<Input
type='time'
step={1}
value={time}
aria-label={t('Time')}
onChange={handleTimeChange}
className='w-32 appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none'
disabled={!date}
+2 -2
View File
@@ -71,7 +71,7 @@ export function Dialog({
{trigger ? <DialogTrigger render={trigger} /> : null}
<DialogContent
className={cn(
'flex max-h-[calc(100vh-2rem)] w-full flex-col gap-4 overflow-hidden p-4 sm:max-w-2xl sm:p-6',
'flex w-full flex-col gap-4 overflow-x-hidden overflow-y-auto p-4 sm:max-w-2xl sm:p-6',
contentClassName,
dialogContentMotionClassName
)}
@@ -97,7 +97,7 @@ export function Dialog({
<div
className={cn(
'-mx-1 min-h-0 overflow-x-hidden overflow-y-auto overscroll-contain',
'h-[var(--dialog-content-height)] max-h-[calc(100vh-14rem)]'
'h-[var(--dialog-content-height)]'
)}
>
<div
+29 -4
View File
@@ -16,6 +16,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { ArrowExpand02Icon } from '@hugeicons/core-free-icons'
import { HugeiconsIcon } from '@hugeicons/react'
import { ChevronsUpDown, GripHorizontal, X } from 'lucide-react'
import {
type PointerEvent as ReactPointerEvent,
@@ -60,6 +62,8 @@ type FloatingWindowProps = {
* key so the window reopens where and how the user left it.
*/
storageKey?: string
/** A new request expands the window without resetting its position or size. */
expandRequest?: number
className?: string
}
@@ -88,7 +92,7 @@ const RESIZE_HANDLE_CLASS: Record<ResizeDirection, string> = {
w: 'top-2 bottom-2 left-0 w-1.5 cursor-ew-resize',
ne: 'top-0 right-0 size-3 cursor-nesw-resize',
nw: 'top-0 left-0 size-3 cursor-nwse-resize',
se: 'bottom-0 right-0 size-3 cursor-nwse-resize',
se: 'bottom-0 right-0 size-8 cursor-nwse-resize',
sw: 'bottom-0 left-0 size-3 cursor-nesw-resize',
}
@@ -199,7 +203,8 @@ export function FloatingWindow(props: FloatingWindowProps) {
size.width
),
size,
collapsed: stored?.collapsed ?? false,
collapsed:
props.expandRequest === undefined && (stored?.collapsed ?? false),
}
})
const windowRef = useRef<HTMLElement>(null)
@@ -217,6 +222,13 @@ export function FloatingWindow(props: FloatingWindowProps) {
size: { width: number; height: number }
} | null>(null)
useEffect(() => {
if (props.expandRequest === undefined) return
setState((previous) =>
previous.collapsed ? { ...previous, collapsed: false } : previous
)
}, [props.expandRequest])
useEffect(() => {
if (!props.storageKey) return
try {
@@ -455,6 +467,9 @@ export function FloatingWindow(props: FloatingWindowProps) {
{props.footer}
</footer>
)}
<div className='border-border/50 bg-muted/30 text-muted-foreground flex h-8 shrink-0 items-center justify-end border-t pr-9 pl-4 text-xs select-none'>
{t('Drag to resize')}
</div>
{RESIZE_DIRECTIONS.map((direction) => (
<div
key={direction}
@@ -464,13 +479,23 @@ export function FloatingWindow(props: FloatingWindowProps) {
aria-hidden='true'
className={cn(
'absolute z-10 touch-none',
RESIZE_HANDLE_CLASS[direction]
RESIZE_HANDLE_CLASS[direction],
direction === 'se' &&
'text-muted-foreground hover:bg-accent hover:text-accent-foreground flex items-center justify-center rounded-tl-md'
)}
onPointerDown={(event) => handleResizeStart(event, direction)}
onPointerMove={handleResizeMove}
onPointerUp={handleResizeEnd}
onPointerCancel={handleResizeEnd}
/>
>
{direction === 'se' && (
<HugeiconsIcon
icon={ArrowExpand02Icon}
className='pointer-events-none size-4'
aria-hidden='true'
/>
)}
</div>
))}
</aside>
)
@@ -76,10 +76,13 @@ function resizeHandle(direction: string) {
return handle
}
test('dragging the bottom-right corner grows both dimensions from the rendered size and persists them', () => {
test('the visible resize hint leads to a bottom-right grip that grows both dimensions and persists them', () => {
renderWindow({ storageKey: 'scratch' })
const window_ = screen.getByRole('complementary', { name: 'Scratch pad' })
const handle = resizeHandle('se')
expect(screen.getByText('Drag to resize')).toBeVisible()
expect(handle.querySelector('svg')).toBeVisible()
expect(handle).toHaveClass('size-8', 'cursor-nwse-resize')
fireEvent.pointerDown(handle, {
button: 0,
pointerId: 3,
@@ -145,11 +148,13 @@ test('collapses to a pill that keeps the title and reopens, and the close contro
).not.toBeInTheDocument()
expect(screen.getByText('Scratch pad')).toBeVisible()
expect(screen.queryByText('body')).not.toBeInTheDocument()
expect(screen.queryByText('Drag to resize')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Expand panel' }))
expect(
screen.getByRole('complementary', { name: 'Scratch pad' })
).toBeVisible()
expect(screen.getByText('Drag to resize')).toBeVisible()
await user.click(screen.getByRole('button', { name: 'Close panel' }))
expect(onClose).toHaveBeenCalledTimes(1)
})
@@ -16,6 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { CommandMenu } from '@/components/command-menu'
import { AnimatedOutlet } from '@/components/page-transition'
import { SkipToMain } from '@/components/skip-to-main'
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'
@@ -54,6 +55,7 @@ export function AuthenticatedLayout(props: AuthenticatedLayoutProps) {
</SidebarInset>
</div>
</SidebarProvider>
<CommandMenu />
</SearchProvider>
</LayoutProvider>
)
@@ -153,14 +153,7 @@ export function ServiceSupportAttribution() {
const { t } = useTranslation()
return (
<span className='text-muted-foreground/60 text-xs'>
<a
href='https://www.crash.work/'
target='_blank'
rel='noopener noreferrer'
className='text-foreground/70 hover:text-foreground font-medium transition-colors'
>
{t('Service support provided by Crash Workshop Cloud Computing')}
</a>
CrashWork 破碎工坊
</span>
)
}
@@ -98,7 +98,7 @@ export function SectionPageLayout(props: SectionPageLayoutProps) {
</h2>
</div>
{actions != null && (
<div className='flex shrink-0 flex-wrap items-center justify-end gap-2 sm:gap-x-4'>
<div className='flex max-w-full shrink-0 flex-wrap items-center justify-end gap-2 sm:gap-x-4'>
{actions}
</div>
)}
+1 -4
View File
@@ -47,10 +47,7 @@ export { defaultTopNavLinks } from './config/top-nav.config'
export { MOBILE_DRAWER_ANIMATION, MOBILE_DRAWER_CONFIG } from './constants'
// Sidebar view registry
export {
getNavGroupsForPath,
resolveSidebarView,
} from './lib/sidebar-view-registry'
export { resolveSidebarView } from './lib/sidebar-view-registry'
// Type exports (type-only to avoid conflicts with components above)
export type {
@@ -16,10 +16,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { type TFunction } from 'i18next'
import { SYSTEM_SETTINGS_VIEW } from '../config/system-settings.config'
import type { NavGroup, SidebarView } from '../types'
import type { SidebarView } from '../types'
/**
* Registered nested sidebar views.
@@ -41,19 +39,3 @@ const SIDEBAR_VIEWS: readonly SidebarView[] = [SYSTEM_SETTINGS_VIEW]
export function resolveSidebarView(pathname: string): SidebarView | null {
return SIDEBAR_VIEWS.find((view) => view.pathPattern.test(pathname)) ?? null
}
/**
* Backwards-compatible helper for consumers (e.g. command palette) that
* just need the navigation groups for the current path, without caring
* about the view metadata.
*
* @returns Nav groups for the matched view, or `null` if no nested view
* matches (callers should then fall back to root nav groups).
*/
export function getNavGroupsForPath(
pathname: string,
t: TFunction
): NavGroup[] | null {
const view = resolveSidebarView(pathname)
return view ? view.getNavGroups(t) : null
}
+4 -17
View File
@@ -62,6 +62,7 @@ import {
import { useIsMobile } from '@/hooks/use-mobile'
import { cn } from '@/lib/utils'
import { formatGroupDetail } from './model-group-selector/group-detail'
import {
modelGroupSelectorLayoutClasses,
scrollSelectedOptionIntoView,
@@ -77,7 +78,7 @@ interface ModelOption {
interface GroupOption {
label: string
value: string
ratio?: number
ratio?: number | string
desc?: string
description?: string
}
@@ -440,13 +441,7 @@ export const GroupSelector: React.FC<GroupSelectorProps> = React.memo(
</span>
{(group.desc || group.description) && (
<div className='text-muted-foreground truncate text-[9px] leading-tight'>
{group.desc || group.description}
{group.ratio && (
<>
{' · '}
{t('Ratio: {{value}}', { value: group.ratio })}
</>
)}
{formatGroupDetail(group, t)}
</div>
)}
</div>
@@ -500,15 +495,7 @@ export const GroupSelector: React.FC<GroupSelectorProps> = React.memo(
</span>
{(group.desc || group.description) && (
<div className='text-muted-foreground mt-0.5 text-xs'>
{group.desc || group.description}
{group.ratio && (
<>
{' · '}
{t('Ratio: {{value}}', {
value: group.ratio,
})}
</>
)}
{formatGroupDetail(group, t)}
</div>
)}
</div>
@@ -0,0 +1,56 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { createInstance } from 'i18next'
import { describe, expect, test } from 'vitest'
import { formatGroupDetail } from '../group-detail'
describe('model group selector group detail', () => {
test('translates the backend description key and the auto ratio when the language is Chinese', async () => {
const i18n = createInstance()
await i18n.init({
lng: 'zh',
resources: {
zh: {
translation: {
'User group': '用户分组',
Auto: '自动',
'Ratio: {{value}}': '倍率:{{value}}',
},
},
},
})
expect(
formatGroupDetail({ desc: 'User group', ratio: 'auto' }, i18n.t)
).toBe('用户分组 · 倍率:自动')
})
test('keeps an administrator description and a numeric ratio as written', async () => {
const i18n = createInstance()
await i18n.init({ lng: 'en', resources: { en: { translation: {} } } })
expect(formatGroupDetail({ desc: 'VIP 专线', ratio: 1.5 }, i18n.t)).toBe(
'VIP 专线 · Ratio: 1.5'
)
expect(formatGroupDetail({ description: 'No ratio' }, i18n.t)).toBe(
'No ratio'
)
})
})
@@ -0,0 +1,42 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { translateServerText } from '@/lib/server-error-message'
export interface GroupDetailSource {
ratio?: number | string
desc?: string
description?: string
}
/**
* The line under a group name: its description and billing ratio. Built-in
* descriptions arrive as English source keys, and the auto group has a
* non-numeric ratio.
*/
export function formatGroupDetail(
group: GroupDetailSource,
t: (key: string, options?: Record<string, unknown>) => string
): string {
const description = group.desc
? translateServerText(t, group.desc)
: (group.description ?? '')
if (!group.ratio) return description
const value = typeof group.ratio === 'number' ? group.ratio : t('Auto')
return `${description} · ${t('Ratio: {{value}}', { value })}`
}
+31
View File
@@ -47,6 +47,11 @@ export type Option = {
* values also carry a marker icon whose tooltip repeats the hint.
*/
hint?: string
/**
* Leading icon rendered before the label in the dropdown and on the chip.
* Decorative only: it never changes the accessible name.
*/
icon?: React.ReactNode
}
interface MultiSelectProps {
@@ -153,6 +158,14 @@ export function MultiSelect(props: MultiSelectProps) {
return map
}, [props.options])
const iconMap = React.useMemo(() => {
const map = new Map<string, React.ReactNode>()
for (const option of props.options) {
if (option.icon) map.set(option.value, option.icon)
}
return map
}, [props.options])
const trimmedInput = inputValue.trim()
const inputMatchesExisting =
trimmedInput.length > 0 &&
@@ -316,8 +329,17 @@ export function MultiSelect(props: MultiSelectProps) {
{visibleValues.map((value) => {
const label = labelMap.get(value) ?? value
const hint = hintMap.get(value)
const icon = iconMap.get(value)
return (
<ComboboxChip key={value}>
{icon && (
<span
aria-hidden='true'
className='inline-flex shrink-0'
>
{icon}
</span>
)}
{props.copyChipOnClick ? (
<button
type='button'
@@ -401,6 +423,7 @@ export function MultiSelect(props: MultiSelectProps) {
const isCreate = canCreate && item === trimmedInput
const label = labelMap.get(item) ?? item
const hint = hintMap.get(item)
const icon = iconMap.get(item)
return (
<ComboboxItem
key={item}
@@ -424,6 +447,14 @@ export function MultiSelect(props: MultiSelectProps) {
</>
) : (
<>
{icon && (
<span
aria-hidden='true'
className='inline-flex shrink-0'
>
{icon}
</span>
)}
<span className='truncate'>{label}</span>
{hint && (
<span
@@ -0,0 +1,53 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test } from 'vitest'
import { MultiSelect } from '@/components/multi-select'
test('option icons render beside the label on chips and in the dropdown without changing accessible names', async () => {
const user = userEvent.setup()
render(
<MultiSelect
options={[
{
value: 'doubao',
label: 'Doubao',
icon: <span data-testid='icon-doubao' />,
},
{ value: 'plain', label: 'plain' },
]}
selected={['doubao']}
onChange={() => undefined}
/>
)
const chipIcon = screen.getByTestId('icon-doubao')
expect(chipIcon).toBeVisible()
expect(chipIcon.parentElement).toHaveAttribute('aria-hidden', 'true')
expect(screen.getByText('Doubao')).toBeVisible()
await user.click(screen.getByRole('combobox'))
const option = screen.getByRole('option', { name: 'Doubao' })
expect(within(option).getByTestId('icon-doubao')).toBeInTheDocument()
expect(
within(screen.getByRole('option', { name: 'plain' })).queryByTestId(/icon-/)
).not.toBeInTheDocument()
})
+238
View File
@@ -0,0 +1,238 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { Check, ChevronDown, Info } from 'lucide-react'
import { useId } from 'react'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { Toggle } from '@/components/ui/toggle'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import type {
AdminPermissionMatrix,
PermissionResourceDef,
} from '@/lib/admin-permissions'
import { cn } from '@/lib/utils'
export type PermissionMatrixGroup = {
key: string
labelKey: string
resources: PermissionResourceDef[]
}
type PermissionMatrixProps = {
value: AdminPermissionMatrix
onChange: (value: AdminPermissionMatrix) => void
disabled?: boolean
} & (
| { resources: PermissionResourceDef[] }
// Groups render as collapsible sections with a selection count and a
// select-all action.
| { groups: PermissionMatrixGroup[] }
)
type PermissionResourceRowProps = {
idPrefix: string
resource: PermissionResourceDef
value: AdminPermissionMatrix
onChange: (value: AdminPermissionMatrix) => void
disabled?: boolean
className: string
}
// PermissionMatrix renders one toggle per resource action of a permission
// catalog. Each resource lists its action descriptions in a tooltip, and the
// toggles announce them as their descriptions.
export function PermissionMatrix(props: PermissionMatrixProps) {
const { t } = useTranslation()
const idPrefix = useId()
if (!('groups' in props)) {
return (
<div className='divide-y rounded-md border'>
{props.resources.map((resource) => (
<PermissionResourceRow
key={resource.resource}
idPrefix={idPrefix}
resource={resource}
value={props.value}
onChange={props.onChange}
disabled={props.disabled}
className='px-3'
/>
))}
</div>
)
}
return (
<div className='divide-y rounded-md border'>
{props.groups.map((group) => {
const label = t(group.labelKey)
const cells = group.resources.flatMap((resource) =>
resource.actions.map(
(option) => props.value[resource.resource]?.[option.action] === true
)
)
const selected = cells.filter(Boolean).length
const allSelected = selected === cells.length
return (
<section key={group.key} aria-label={label}>
<Collapsible defaultOpen>
<div className='flex items-center gap-2 px-3 py-2'>
<CollapsibleTrigger className='group/trigger flex flex-1 items-center gap-2 text-left text-sm font-medium'>
<ChevronDown
className='text-muted-foreground size-4 -rotate-90 transition-transform group-data-[panel-open]/trigger:rotate-0'
aria-hidden='true'
/>
{label}
<Badge variant='secondary' className='tabular-nums'>
{t('Selected {{selected}} / {{total}}', {
selected,
total: cells.length,
})}
</Badge>
</CollapsibleTrigger>
<Button
type='button'
variant='ghost'
size='sm'
className='h-7 text-xs'
disabled={props.disabled}
onClick={() => {
const next = { ...props.value }
for (const resource of group.resources) {
const actions = { ...next[resource.resource] }
for (const option of resource.actions) {
actions[option.action] = !allSelected
}
next[resource.resource] = actions
}
props.onChange(next)
}}
>
{allSelected ? t('Clear') : t('Select all')}
</Button>
</div>
<CollapsibleContent>
<div className='divide-y border-t'>
{group.resources.map((resource) => (
<PermissionResourceRow
key={resource.resource}
idPrefix={idPrefix}
resource={resource}
value={props.value}
onChange={props.onChange}
disabled={props.disabled}
className='pr-3 pl-9'
/>
))}
</div>
</CollapsibleContent>
</Collapsible>
</section>
)
})}
</div>
)
}
function PermissionResourceRow(props: PermissionResourceRowProps) {
const { t } = useTranslation()
const resource = props.resource
const label = t(resource.label_key)
return (
<div
role='group'
aria-label={label}
className={cn('flex items-start gap-3 py-2', props.className)}
>
<div className='flex w-32 shrink-0 items-center gap-1.5 pt-1 text-sm'>
{label}
<Tooltip>
<TooltipTrigger
render={
<button
type='button'
className='text-muted-foreground hover:text-foreground inline-flex'
aria-label={t('Details')}
/>
}
>
<Info className='size-3.5' aria-hidden='true' />
</TooltipTrigger>
<TooltipContent className='block max-w-sm py-2 text-left'>
<dl className='space-y-1.5'>
{resource.actions.map((option) => (
<div key={option.action}>
<dt className='font-medium'>{t(option.label_key)}</dt>
<dd className='opacity-80'>{t(option.description_key)}</dd>
</div>
))}
</dl>
</TooltipContent>
</Tooltip>
</div>
<div className='flex min-w-0 flex-1 flex-wrap gap-1.5'>
{resource.actions.map((option) => {
const pressed =
props.value[resource.resource]?.[option.action] === true
const descriptionId = `${props.idPrefix}-${resource.resource}-${option.action}`
return (
<Toggle
key={option.action}
variant='outline'
size='sm'
pressed={pressed}
disabled={props.disabled}
aria-describedby={descriptionId}
onPressedChange={(next) => {
props.onChange({
...props.value,
[resource.resource]: {
...props.value[resource.resource],
[option.action]: next,
},
})
}}
className={cn(
'h-auto min-h-7 max-w-full font-normal whitespace-normal',
pressed &&
'border-primary bg-primary/10! text-primary hover:bg-primary/15'
)}
>
{pressed && <Check aria-hidden='true' />}
{t(option.label_key)}
<span id={descriptionId} hidden>
{t(option.description_key)}
</span>
</Toggle>
)
})}
</div>
</div>
)
}
@@ -0,0 +1,107 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, test, vi } from 'vitest'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Dialog } from '@/components/dialog'
import { Button } from '@/components/ui/button'
import { Dialog as DialogRoot, DialogContent, DialogTitle } from '../dialog'
describe('dialog viewport layout', () => {
test('long content shrinks independently of a stacked action footer', async () => {
const onConfirm = vi.fn()
render(
<Dialog
open
title='System updates'
contentHeight='720px'
footer={
<>
<Button onClick={onConfirm}>Check again</Button>
<Button>Ignore this version</Button>
<Button>Go to GitHub</Button>
</>
}
>
<p>Long release notes</p>
</Dialog>
)
const dialog = screen.getByRole('dialog', { name: 'System updates' })
// Keep a dynamic-viewport fallback when global CSS variables are missing.
expect(dialog).toHaveClass(
'max-h-[var(--dialog-available-height,calc(100dvh-2rem))]'
)
expect(dialog).toHaveClass('top-[var(--dialog-viewport-center,50dvh)]')
// If header and footer alone exceed a short viewport, the popup itself
// must remain scrollable instead of clipping its actions.
expect(dialog).toHaveClass('overflow-y-auto', 'overscroll-contain')
const body =
screen.getByText('Long release notes').parentElement?.parentElement
expect(body).toHaveClass('min-h-0', 'overflow-y-auto')
expect(body).not.toHaveClass('max-h-[calc(100vh-14rem)]')
const footer = dialog.querySelector('[data-slot=dialog-footer]')
expect(footer).toHaveClass('flex-shrink-0')
expect(body?.contains(footer)).toBe(false)
await userEvent.click(screen.getByRole('button', { name: 'Check again' }))
expect(onConfirm).toHaveBeenCalledOnce()
})
test('unwrapped dialogs bound and scroll long content within the visible viewport', () => {
render(
<DialogRoot open>
<DialogContent>
<DialogTitle>Long dialog</DialogTitle>
<p>Long content</p>
</DialogContent>
</DialogRoot>
)
expect(screen.getByRole('dialog', { name: 'Long dialog' })).toHaveClass(
'top-[var(--dialog-viewport-center,50dvh)]',
'max-h-[var(--dialog-available-height,calc(100dvh-2rem))]',
'overflow-y-auto',
'overscroll-contain'
)
})
test('confirmation dialogs scroll overflowing content and keep confirmation usable', async () => {
const onConfirm = vi.fn()
render(
<ConfirmDialog
open
onOpenChange={() => {}}
title='Confirm changes'
desc='A long confirmation description'
handleConfirm={onConfirm}
/>
)
expect(
screen.getByRole('alertdialog', { name: 'Confirm changes' })
).toHaveClass(
'top-[var(--dialog-viewport-center,50dvh)]',
'max-h-[var(--dialog-available-height,calc(100dvh-2rem))]',
'overflow-y-auto',
'overscroll-contain'
)
await userEvent.click(screen.getByRole('button', { name: 'Continue' }))
expect(onConfirm).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,68 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { render } from '@testing-library/react'
import { expect, test } from 'vitest'
import { Markdown } from '../markdown'
test.each([
['inline', 'Formula: $$\\frac{1}{2}$$.'],
['display', '$$\n\\frac{1}{2}\n$$'],
['fenced', '```math\n\\frac{1}{2}\n```'],
])('preserves MathML and visual output when rendering %s math', (_, source) => {
const view = render(<Markdown>{source}</Markdown>)
const math = view.getByRole('math', { hidden: true })
expect(math.querySelector('mfrac')).not.toBeNull()
expect(math.querySelector('annotation')).toHaveTextContent('\\frac{1}{2}')
expect(view.container.querySelector('.katex-html')).toHaveAttribute(
'aria-hidden',
'true'
)
})
test('does not load math resources when trust is inherited from a polluted prototype', () => {
const originalTrust = Object.getOwnPropertyDescriptor(
Object.prototype,
'trust'
)
try {
Object.defineProperty(Object.prototype, 'trust', {
configurable: true,
value: true,
writable: true,
})
const view = render(
<Markdown>
{'$$\\includegraphics{https://example.com/untrusted.png}$$'}
</Markdown>
)
expect(view.container.querySelector('img')).toBeNull()
expect(view.container).toHaveTextContent('\\includegraphics')
} finally {
if (originalTrust) {
Object.defineProperty(Object.prototype, 'trust', originalTrust)
} else {
Reflect.deleteProperty(Object.prototype, 'trust')
}
}
})
+1 -1
View File
@@ -70,7 +70,7 @@ function AlertDialogContent({
data-slot='alert-dialog-content'
data-size={size}
className={cn(
'group/alert-dialog-content bg-popover text-popover-foreground ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl p-4 ring-1 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm',
'group/alert-dialog-content bg-popover text-popover-foreground ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 fixed top-[var(--dialog-viewport-center,50dvh)] left-1/2 z-50 grid max-h-[var(--dialog-available-height,calc(100dvh-2rem))] w-full overflow-y-auto overscroll-contain -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl p-4 ring-1 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm',
className
)}
{...props}
+3 -3
View File
@@ -39,7 +39,7 @@ interface ComboboxInputProps {
placeholder?: string
emptyText?: string
className?: string
dropdownClassName?: string
popupClassName?: string
id?: string
allowCustomValue?: boolean
openOnFocus?: boolean
@@ -57,7 +57,7 @@ export function ComboboxInput({
placeholder = 'Select or type...',
emptyText = 'No option found.',
className,
dropdownClassName,
popupClassName,
id,
allowCustomValue = false,
openOnFocus = true,
@@ -297,7 +297,7 @@ export function ComboboxInput({
}}
className={cn(
'bg-popover text-popover-foreground z-100 rounded-md border shadow-md',
dropdownClassName
popupClassName
)}
>
{filteredOptions.length > 0 ? (
+3 -3
View File
@@ -50,7 +50,7 @@ type LegacyComboboxProps = {
allowCustomValue?: boolean
showSelectedIcon?: boolean
className?: string
dropdownClassName?: string
popupClassName?: string
id?: string
openOnFocus?: boolean
disabled?: boolean
@@ -88,7 +88,7 @@ function Combobox(
placeholder={props.searchPlaceholder ?? props.placeholder}
emptyText={props.emptyText}
className={props.className}
dropdownClassName={props.dropdownClassName}
popupClassName={props.popupClassName}
allowCustomValue={props.allowCustomValue}
openOnFocus={props.openOnFocus}
/>
@@ -160,7 +160,7 @@ function OptionCombobox(props: LegacyComboboxProps) {
)}
</ComboboxInput>
</div>
<ComboboxContent anchor={anchor}>
<ComboboxContent anchor={anchor} className={props.popupClassName}>
<ComboboxEmpty>
{props.emptyText ?? t('No results found')}
</ComboboxEmpty>
+1 -1
View File
@@ -70,7 +70,7 @@ function DialogContent({
<DialogPrimitive.Popup
data-slot='dialog-content'
className={cn(
'bg-popover text-popover-foreground ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl p-4 text-sm ring-1 duration-100 outline-none sm:max-w-sm',
'bg-popover text-popover-foreground ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 fixed top-[var(--dialog-viewport-center,50dvh)] left-1/2 z-50 grid max-h-[var(--dialog-available-height,calc(100dvh-2rem))] w-full overflow-y-auto overscroll-contain max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl p-4 text-sm ring-1 duration-100 outline-none sm:max-w-sm',
className
)}
{...props}
@@ -0,0 +1,95 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { THEME_STORAGE_KEYS } from '@/lib/theme-storage'
// The inline script in index.html must apply the theme class before React
// mounts; Dark Reader checks `html.dark` only once while the page loads.
const html = readFileSync(
resolve(import.meta.dirname, '../../../index.html'),
'utf8'
)
const bootScript = html.match(/<script>([\s\S]*?)<\/script>/)?.[1] ?? ''
function runBootScript() {
new Function(bootScript)()
}
function mockSystemDarkMode(dark: boolean) {
vi.spyOn(window, 'matchMedia').mockImplementation(
(query: string) =>
({
matches: dark && query === '(prefers-color-scheme: dark)',
media: query,
}) as MediaQueryList
)
}
beforeEach(() => {
localStorage.clear()
})
afterEach(() => {
vi.restoreAllMocks()
localStorage.clear()
document.documentElement.classList.remove('light', 'dark')
})
describe('index.html theme boot script', () => {
it('applies the saved dark preference to html before React mounts', () => {
mockSystemDarkMode(false)
localStorage.setItem(THEME_STORAGE_KEYS.mode, 'dark')
runBootScript()
expect(document.documentElement).toHaveClass('dark')
expect(document.documentElement).not.toHaveClass('light')
})
it('applies the saved light preference even when the system is dark', () => {
mockSystemDarkMode(true)
localStorage.setItem(THEME_STORAGE_KEYS.mode, 'light')
runBootScript()
expect(document.documentElement).toHaveClass('light')
expect(document.documentElement).not.toHaveClass('dark')
})
it('follows the system dark mode when no preference is saved', () => {
mockSystemDarkMode(true)
runBootScript()
expect(document.documentElement).toHaveClass('dark')
})
it('follows the system light mode when the saved value is invalid', () => {
mockSystemDarkMode(false)
localStorage.setItem(THEME_STORAGE_KEYS.mode, 'sepia')
runBootScript()
expect(document.documentElement).toHaveClass('light')
})
})
@@ -0,0 +1,301 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { cleanup, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
ThemeCustomizationProvider,
useThemeCustomization,
} from '@/context/theme-customization-provider'
import { ThemeProvider, useTheme } from '@/context/theme-provider'
import { initializeFrontendCache } from '@/lib/frontend-cache'
const savedPreferences = {
'newapi:theme:v1:mode': 'dark',
'newapi:theme:v1:preset': 'rose-garden',
'newapi:theme:v1:font': 'serif',
'newapi:theme:v1:radius': 'lg',
'newapi:theme:v1:scale': 'sm',
'newapi:theme:v1:content-layout': 'centered',
}
function ThemeControls() {
const theme = useTheme()
const customization = useThemeCustomization()
return (
<>
<output aria-label='Theme mode'>{theme.theme}</output>
<button type='button' onClick={() => customization.setFont('sans')}>
Sans font
</button>
<button type='button' onClick={() => customization.setFont('default')}>
Auto font
</button>
<button
type='button'
onClick={() => customization.setPreset('anthropic')}
>
Anthropic preset
</button>
<button type='button' onClick={() => customization.setPreset('default')}>
Default preset
</button>
<button
type='button'
onClick={() => {
theme.setTheme('dark')
customization.setPreset('rose-garden')
customization.setFont('serif')
customization.setRadius('lg')
customization.setScale('sm')
customization.setContentLayout('centered')
}}
>
Customize
</button>
<button
type='button'
onClick={() => {
theme.resetTheme()
customization.resetCustomization()
}}
>
Reset
</button>
</>
)
}
function ThemeFixture() {
return (
<ThemeProvider>
<ThemeCustomizationProvider>
<ThemeControls />
</ThemeCustomizationProvider>
</ThemeProvider>
)
}
beforeEach(() => {
localStorage.clear()
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
localStorage.clear()
for (const cookie of document.cookie.split(';')) {
const name = cookie.trim().split('=')[0]
document.cookie = `${name}=; path=/; max-age=0`
}
document.documentElement.classList.remove('light', 'dark')
for (const name of document.body.getAttributeNames()) {
if (name.startsWith('data-theme-')) document.body.removeAttribute(name)
}
})
describe('theme preference persistence', () => {
it('starts with defaults when only shared legacy theme cookies exist', () => {
document.cookie = 'theme_preset=ocean-breeze; path=/'
document.cookie = 'vite-ui-theme=dark; path=/'
document.cookie = 'theme_font=serif; path=/'
document.cookie = 'theme_radius=xl; path=/'
document.cookie = 'theme_scale=lg; path=/'
document.cookie = 'theme_content_layout=centered; path=/'
render(<ThemeFixture />)
expect(screen.getByLabelText('Theme mode')).toHaveTextContent('system')
expect(document.documentElement).toHaveClass('light')
expect(document.body).not.toHaveAttribute('data-theme-preset')
expect(document.body).toHaveAttribute('data-theme-font', 'system')
expect(document.body).not.toHaveAttribute('data-theme-radius')
expect(document.body).not.toHaveAttribute('data-theme-scale')
expect(document.body).toHaveAttribute('data-theme-content-layout', 'full')
})
it('restores all customized preferences after remounting without writing cookies', async () => {
const user = userEvent.setup()
const first = render(<ThemeFixture />)
await user.click(screen.getByRole('button', { name: 'Customize' }))
first.unmount()
render(<ThemeFixture />)
expect(screen.getByLabelText('Theme mode')).toHaveTextContent('dark')
expect(document.documentElement).toHaveClass('dark')
expect(document.body).toHaveAttribute('data-theme-preset', 'rose-garden')
expect(document.body).toHaveAttribute('data-theme-font', 'serif')
expect(document.body).toHaveAttribute('data-theme-radius', 'lg')
expect(document.body).toHaveAttribute('data-theme-scale', 'sm')
expect(document.body).toHaveAttribute(
'data-theme-content-layout',
'centered'
)
for (const [key, value] of Object.entries(savedPreferences)) {
expect(localStorage.getItem(key)).toBe(value)
}
expect(document.cookie).toBe('')
})
it('keeps a reset after remounting even when the legacy ocean cookie remains', async () => {
for (const [key, value] of Object.entries(savedPreferences)) {
localStorage.setItem(key, value)
}
localStorage.setItem('unrelated-preference', 'keep')
document.cookie = 'theme_preset=ocean-breeze; path=/'
const user = userEvent.setup()
const first = render(<ThemeFixture />)
await user.click(screen.getByRole('button', { name: 'Reset' }))
first.unmount()
render(<ThemeFixture />)
expect(screen.getByLabelText('Theme mode')).toHaveTextContent('system')
expect(document.documentElement).toHaveClass('light')
expect(document.body).not.toHaveAttribute('data-theme-preset')
expect(document.body).toHaveAttribute('data-theme-font', 'system')
expect(document.body).not.toHaveAttribute('data-theme-radius')
expect(document.body).not.toHaveAttribute('data-theme-scale')
expect(document.body).toHaveAttribute('data-theme-content-layout', 'full')
for (const key of Object.keys(savedPreferences)) {
expect(localStorage.getItem(key)).toBeNull()
}
expect(localStorage.getItem('unrelated-preference')).toBe('keep')
expect(document.cookie).toContain('theme_preset=ocean-breeze')
})
it.each(['', 'unknown'])(
'uses defaults when stored preferences are %j',
(value) => {
for (const key of Object.keys(savedPreferences)) {
localStorage.setItem(key, value)
}
render(<ThemeFixture />)
expect(screen.getByLabelText('Theme mode')).toHaveTextContent('system')
expect(document.body).not.toHaveAttribute('data-theme-preset')
expect(document.body).toHaveAttribute('data-theme-font', 'system')
expect(document.body).not.toHaveAttribute('data-theme-radius')
expect(document.body).not.toHaveAttribute('data-theme-scale')
expect(document.body).toHaveAttribute('data-theme-content-layout', 'full')
}
)
it('renders defaults when reading local storage fails', () => {
vi.spyOn(localStorage, 'getItem').mockImplementation(() => {
throw new DOMException('Storage unavailable', 'SecurityError')
})
render(<ThemeFixture />)
expect(screen.getByLabelText('Theme mode')).toHaveTextContent('system')
expect(document.body).not.toHaveAttribute('data-theme-preset')
})
it('still applies and resets preferences when storage writes fail', async () => {
vi.spyOn(localStorage, 'setItem').mockImplementation(() => {
throw new DOMException('Storage full', 'QuotaExceededError')
})
vi.spyOn(localStorage, 'removeItem').mockImplementation(() => {
throw new DOMException('Storage unavailable', 'SecurityError')
})
const user = userEvent.setup()
render(<ThemeFixture />)
await user.click(screen.getByRole('button', { name: 'Customize' }))
expect(screen.getByLabelText('Theme mode')).toHaveTextContent('dark')
expect(document.body).toHaveAttribute('data-theme-preset', 'rose-garden')
await user.click(screen.getByRole('button', { name: 'Reset' }))
expect(screen.getByLabelText('Theme mode')).toHaveTextContent('system')
expect(document.body).not.toHaveAttribute('data-theme-preset')
})
it('preserves saved theme preferences during frontend cache initialization', () => {
for (const [key, value] of Object.entries(savedPreferences)) {
localStorage.setItem(key, value)
}
localStorage.setItem('stale-ui-cache', 'old')
initializeFrontendCache()
initializeFrontendCache()
render(<ThemeFixture />)
expect(screen.getByLabelText('Theme mode')).toHaveTextContent('dark')
expect(document.body).toHaveAttribute('data-theme-preset', 'rose-garden')
for (const [key, value] of Object.entries(savedPreferences)) {
expect(localStorage.getItem(key)).toBe(value)
}
expect(localStorage.getItem('stale-ui-cache')).toBeNull()
})
})
describe('theme font selection', () => {
it('keeps manually selected Sans after remounting and switching presets', async () => {
const user = userEvent.setup()
const first = render(<ThemeFixture />)
await user.click(screen.getByRole('button', { name: 'Sans font' }))
first.unmount()
render(<ThemeFixture />)
expect(document.body).toHaveAttribute('data-theme-font', 'sans')
expect(localStorage.getItem('newapi:theme:v1:font')).toBe('sans')
await user.click(screen.getByRole('button', { name: 'Anthropic preset' }))
expect(document.body).toHaveAttribute('data-theme-font', 'sans')
})
it('restores the browser font after selecting Auto and remounting', async () => {
localStorage.setItem('newapi:theme:v1:font', 'sans')
const user = userEvent.setup()
const first = render(<ThemeFixture />)
await user.click(screen.getByRole('button', { name: 'Auto font' }))
expect(document.body).toHaveAttribute('data-theme-font', 'system')
expect(localStorage.getItem('newapi:theme:v1:font')).toBeNull()
first.unmount()
render(<ThemeFixture />)
expect(document.body).toHaveAttribute('data-theme-font', 'system')
})
it('uses the preset font in Auto and restores the browser font on the default preset', async () => {
const user = userEvent.setup()
render(<ThemeFixture />)
await user.click(screen.getByRole('button', { name: 'Anthropic preset' }))
expect(document.body).toHaveAttribute('data-theme-font', 'serif')
await user.click(screen.getByRole('button', { name: 'Default preset' }))
expect(document.body).toHaveAttribute('data-theme-font', 'system')
expect(localStorage.getItem('newapi:theme:v1:font')).toBeNull()
})
})
-3
View File
@@ -18,8 +18,6 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { createContext, useContext, useEffect, useState } from 'react'
import { CommandMenu } from '@/components/command-menu'
type SearchContextType = {
open: boolean
setOpen: React.Dispatch<React.SetStateAction<boolean>>
@@ -48,7 +46,6 @@ export function SearchProvider({ children }: SearchProviderProps) {
return (
<SearchContext.Provider value={{ open, setOpen }}>
{children}
<CommandMenu />
</SearchContext.Provider>
)
}
@@ -25,13 +25,11 @@ import {
useState,
} from 'react'
import { getCookie, removeCookie, setCookie } from '@/lib/cookies'
import {
CONTENT_LAYOUT_VALUES,
type ContentLayout,
DEFAULT_THEME_CUSTOMIZATION,
resolveThemeFont,
THEME_COOKIE_KEYS,
THEME_FONT_VALUES,
THEME_PRESET_VALUES,
THEME_RADIUS_VALUES,
@@ -42,17 +40,11 @@ import {
type ThemeRadius,
type ThemeScale,
} from '@/lib/theme-customization'
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365 // 1 year
function readCookie<T extends string>(
name: string,
allowed: ReadonlySet<T>,
fallback: T
): T {
const value = getCookie(name)
return value && allowed.has(value as T) ? (value as T) : fallback
}
import {
readThemePreference,
THEME_STORAGE_KEYS,
writeThemePreference,
} from '@/lib/theme-storage'
function applyAttribute(name: string, value: string | null) {
if (typeof document === 'undefined') return
@@ -98,36 +90,36 @@ export function ThemeCustomizationProvider(props: {
children: React.ReactNode
}) {
const [preset, _setPreset] = useState<ThemePreset>(() =>
readCookie<ThemePreset>(
THEME_COOKIE_KEYS.preset,
readThemePreference<ThemePreset>(
THEME_STORAGE_KEYS.preset,
THEME_PRESET_VALUES,
DEFAULT_THEME_CUSTOMIZATION.preset
)
)
const [font, _setFont] = useState<ThemeFont>(() =>
readCookie<ThemeFont>(
THEME_COOKIE_KEYS.font,
readThemePreference<ThemeFont>(
THEME_STORAGE_KEYS.font,
THEME_FONT_VALUES,
DEFAULT_THEME_CUSTOMIZATION.font
)
)
const [radius, _setRadius] = useState<ThemeRadius>(() =>
readCookie<ThemeRadius>(
THEME_COOKIE_KEYS.radius,
readThemePreference<ThemeRadius>(
THEME_STORAGE_KEYS.radius,
THEME_RADIUS_VALUES,
DEFAULT_THEME_CUSTOMIZATION.radius
)
)
const [scale, _setScale] = useState<ThemeScale>(() =>
readCookie<ThemeScale>(
THEME_COOKIE_KEYS.scale,
readThemePreference<ThemeScale>(
THEME_STORAGE_KEYS.scale,
THEME_SCALE_VALUES,
DEFAULT_THEME_CUSTOMIZATION.scale
)
)
const [contentLayout, _setContentLayout] = useState<ContentLayout>(() =>
readCookie<ContentLayout>(
THEME_COOKIE_KEYS.contentLayout,
readThemePreference<ContentLayout>(
THEME_STORAGE_KEYS.contentLayout,
CONTENT_LAYOUT_VALUES,
DEFAULT_THEME_CUSTOMIZATION.contentLayout
)
@@ -142,12 +134,8 @@ export function ThemeCustomizationProvider(props: {
)
}, [preset])
// Font is the one axis where we resolve before writing the attribute:
// the persisted preference may be `default`, but CSS works in terms of
// the concrete `sans`/`serif` choice that should drive the cascade.
// Resolving here (instead of in CSS via `:not()` selectors) keeps the
// stylesheet to one simple `[data-theme-font='serif']` selector and lets
// future presets opt into typography via `PRESET_DEFAULT_FONT` alone.
// Resolve Auto into the preset font or the browser's sans-serif while
// keeping an explicit Sans choice distinct from that browser fallback.
useEffect(() => {
applyAttribute('data-theme-font', resolveThemeFont(font, preset))
}, [font, preset])
@@ -172,47 +160,42 @@ export function ThemeCustomizationProvider(props: {
const setPreset = useCallback((value: ThemePreset) => {
_setPreset(value)
if (value === DEFAULT_THEME_CUSTOMIZATION.preset) {
removeCookie(THEME_COOKIE_KEYS.preset)
} else {
setCookie(THEME_COOKIE_KEYS.preset, value, COOKIE_MAX_AGE)
}
writeThemePreference(
THEME_STORAGE_KEYS.preset,
value === DEFAULT_THEME_CUSTOMIZATION.preset ? null : value
)
}, [])
const setFont = useCallback((value: ThemeFont) => {
_setFont(value)
if (value === DEFAULT_THEME_CUSTOMIZATION.font) {
removeCookie(THEME_COOKIE_KEYS.font)
} else {
setCookie(THEME_COOKIE_KEYS.font, value, COOKIE_MAX_AGE)
}
writeThemePreference(
THEME_STORAGE_KEYS.font,
value === DEFAULT_THEME_CUSTOMIZATION.font ? null : value
)
}, [])
const setRadius = useCallback((value: ThemeRadius) => {
_setRadius(value)
if (value === DEFAULT_THEME_CUSTOMIZATION.radius) {
removeCookie(THEME_COOKIE_KEYS.radius)
} else {
setCookie(THEME_COOKIE_KEYS.radius, value, COOKIE_MAX_AGE)
}
writeThemePreference(
THEME_STORAGE_KEYS.radius,
value === DEFAULT_THEME_CUSTOMIZATION.radius ? null : value
)
}, [])
const setScale = useCallback((value: ThemeScale) => {
_setScale(value)
if (value === DEFAULT_THEME_CUSTOMIZATION.scale) {
removeCookie(THEME_COOKIE_KEYS.scale)
} else {
setCookie(THEME_COOKIE_KEYS.scale, value, COOKIE_MAX_AGE)
}
writeThemePreference(
THEME_STORAGE_KEYS.scale,
value === DEFAULT_THEME_CUSTOMIZATION.scale ? null : value
)
}, [])
const setContentLayout = useCallback((value: ContentLayout) => {
_setContentLayout(value)
if (value === DEFAULT_THEME_CUSTOMIZATION.contentLayout) {
removeCookie(THEME_COOKIE_KEYS.contentLayout)
} else {
setCookie(THEME_COOKIE_KEYS.contentLayout, value, COOKIE_MAX_AGE)
}
writeThemePreference(
THEME_STORAGE_KEYS.contentLayout,
value === DEFAULT_THEME_CUSTOMIZATION.contentLayout ? null : value
)
}, [])
const resetCustomization = useCallback(() => {
+10 -13
View File
@@ -25,14 +25,16 @@ import {
useState,
} from 'react'
import { getCookie, setCookie, removeCookie } from '@/lib/cookies'
import {
readThemePreference,
THEME_STORAGE_KEYS,
writeThemePreference,
} from '@/lib/theme-storage'
type Theme = 'dark' | 'light' | 'system'
type ResolvedTheme = Exclude<Theme, 'system'>
const DEFAULT_THEME = 'system'
const THEME_COOKIE_NAME = 'vite-ui-theme'
const THEME_COOKIE_MAX_AGE = 60 * 60 * 24 * 365 // 1 year
const THEMES = new Set<Theme>(['dark', 'light', 'system'])
type ThemeProviderProps = {
@@ -70,22 +72,17 @@ function resolveTheme(theme: Theme): ResolvedTheme {
return theme === 'system' ? getSystemTheme() : theme
}
function getStoredTheme(storageKey: string, fallback: Theme): Theme {
const storedTheme = getCookie(storageKey) as Theme | undefined
return storedTheme && THEMES.has(storedTheme) ? storedTheme : fallback
}
export function ThemeProvider({
children,
defaultTheme = DEFAULT_THEME,
storageKey = THEME_COOKIE_NAME,
storageKey = THEME_STORAGE_KEYS.mode,
...props
}: ThemeProviderProps) {
const [theme, _setTheme] = useState<Theme>(() =>
getStoredTheme(storageKey, defaultTheme)
readThemePreference(storageKey, THEMES, defaultTheme)
)
const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>(() =>
resolveTheme(getStoredTheme(storageKey, defaultTheme))
resolveTheme(theme)
)
useEffect(() => {
@@ -108,14 +105,14 @@ export function ThemeProvider({
const setTheme = useCallback(
(theme: Theme) => {
setCookie(storageKey, theme, THEME_COOKIE_MAX_AGE)
writeThemePreference(storageKey, theme)
_setTheme(theme)
},
[storageKey]
)
const resetTheme = useCallback(() => {
removeCookie(storageKey)
writeThemePreference(storageKey, null)
_setTheme(defaultTheme)
}, [defaultTheme, storageKey])
+5 -2
View File
@@ -20,7 +20,10 @@ import axios from 'axios'
import { api, refreshAuthentication, type RefreshOutcome } from '@/lib/api'
import { AuthOperationError } from '@/lib/secure-verification'
import { getServerErrorMessageKey } from '@/lib/server-error-message'
import {
getServerErrorMessageKey,
getServerMessage,
} from '@/lib/server-error-message'
import { useAuthStore } from '@/stores/auth-store'
import {
@@ -204,7 +207,7 @@ export async function createOAuthAuthorization(
}
throw new AuthOperationError(
getServerErrorMessageKey(res.data) ||
res.data?.message ||
getServerMessage(res.data) ||
'Failed to initialize OAuth',
res.data?.code
)
@@ -19,7 +19,8 @@ For commercial licensing, please contact support@quantumnous.com
import { Loader2, Send, Shield, UserRound, type LucideIcon } from 'lucide-react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { SiGithub, SiLinux, SiWechat } from 'react-icons/si'
import { IconGithub, IconLinuxDo, IconWeChat } from '@/assets/brand-icons'
import { AuthLayout } from '../auth-layout'
@@ -37,21 +38,21 @@ const providerDictionary: Record<string, ProviderMeta> = {
github: {
label: 'GitHub',
Icon: (props: { className?: string }) => (
<SiGithub className={props.className} focusable='false' />
<IconGithub className={props.className} focusable='false' />
),
},
oidc: { label: 'OIDC', Icon: Shield },
linuxdo: {
label: 'LinuxDO',
Icon: (props: { className?: string }) => (
<SiLinux className={props.className} focusable='false' />
<IconLinuxDo className={props.className} focusable='false' />
),
},
telegram: { label: 'Telegram', Icon: Send },
wechat: {
label: 'WeChat',
Icon: (props: { className?: string }) => (
<SiWechat className={props.className} focusable='false' />
<IconWeChat className={props.className} focusable='false' />
),
},
}
@@ -25,6 +25,7 @@ import {
prepareCredentialCreationOptions,
} from '@/lib/passkey'
import { AuthOperationError } from '@/lib/secure-verification'
import { getServerMessage } from '@/lib/server-error-message'
import {
beginPasskeyRegistration,
@@ -50,7 +51,7 @@ export function usePasskeyManagement() {
const response = await getPasskeyStatus()
if (!response.success || !response.data) {
throw new AuthOperationError(
response.message || 'Failed to load Passkey status'
getServerMessage(response) || 'Failed to load Passkey status'
)
}
if (!mounted.current) return
@@ -25,6 +25,7 @@ import { afterEach, expect, it, vi } from 'vitest'
import { api } from '@/lib/api'
import type { AuthBundle } from '@/stores/auth-store'
import { OAUTH_POPUP_CALLBACK_MESSAGE } from '../../constants'
import { SecureVerificationDialog } from '../components/secure-verification-dialog'
import { useSecureVerification } from '../hooks/use-secure-verification'
import type {
@@ -40,6 +41,25 @@ const passwordRequirements: VerificationRequirements = {
password_encryption_enabled: false,
}
const linkedAccountRequirements: VerificationRequirements = {
scope: '2fa.setup',
methods: [{ method: 'oauth', available: true }],
oauth_providers: [{ slug: 'linuxdo', name: 'Linux DO' }],
password_encryption_enabled: false,
}
function stubOAuthPopup() {
const popup = {
closed: false,
location: { replace: vi.fn() },
sessionStorage: window.sessionStorage,
close: vi.fn(),
postMessage: vi.fn(),
}
vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window)
return popup
}
function Harness(props: {
onResult: (proof: SecurityProof | null) => void
operation?: RequestVerificationOptions
@@ -80,6 +100,7 @@ function pendingResponse<T>() {
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
window.sessionStorage.clear()
})
function LoginHarness(props: {
@@ -428,3 +449,123 @@ it('keeps an enrolled Passkey unavailable when this browser cannot use it', asyn
expect(screen.getByRole('button', { name: 'Verify' })).toBeDisabled()
expect(screen.queryByLabelText('Password')).not.toBeInTheDocument()
})
it('starts linked-account verification from the provider button without a separate Verify button', async () => {
const popup = stubOAuthPopup()
const proof: SecurityProof = {
proof_token: 'oauth-proof',
method: 'oauth',
scope: '2fa.setup',
expires_at: Math.floor(Date.now() / 1000) + 300,
}
vi.spyOn(api, 'get').mockImplementation((url) => {
if (url === '/api/verify/methods') {
return Promise.resolve({
data: { success: true, data: linkedAccountRequirements },
})
}
if (url === '/api/oauth/linuxdo') {
return Promise.resolve({ data: { success: true, data: proof } })
}
return Promise.resolve({ data: { success: true, data: {} } })
})
const post = vi.spyOn(api, 'post').mockResolvedValue({
data: {
success: true,
data: {
flow_token: 'verification-state',
authorization_url: 'https://connect.linux.do/oauth2/authorize?s=1',
},
},
})
const result = vi.fn()
const user = userEvent.setup()
render(<Harness operation={{ scope: '2fa.setup' }} onResult={result} />)
await user.click(screen.getByText('Protected action'))
const providerButton = await screen.findByRole('button', {
name: 'Continue with Linux DO',
})
expect(
screen.queryByRole('button', { name: 'Verify' })
).not.toBeInTheDocument()
expect(window.open).not.toHaveBeenCalled()
await user.click(providerButton)
expect(window.open).toHaveBeenCalledTimes(1)
expect(providerButton).toBeDisabled()
await waitFor(() =>
expect(popup.location.replace).toHaveBeenCalledWith(
'https://connect.linux.do/oauth2/authorize?s=1'
)
)
expect(post).toHaveBeenCalledExactlyOnceWith(
'/api/oauth/state',
expect.objectContaining({
provider: 'linuxdo',
intent: 'verify',
scope: '2fa.setup',
}),
expect.anything()
)
const event = new MessageEvent('message', {
origin: window.location.origin,
data: {
type: OAUTH_POPUP_CALLBACK_MESSAGE,
intent: 'verify',
provider: 'linuxdo',
state: 'verification-state',
code: 'code',
},
})
Object.defineProperty(event, 'source', { value: popup })
window.dispatchEvent(event)
await waitFor(() => expect(result).toHaveBeenCalledExactlyOnceWith(proof))
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('verifies with the clicked provider when several linked accounts are available', async () => {
const popup = stubOAuthPopup()
vi.spyOn(api, 'get').mockImplementation((url) =>
url === '/api/verify/methods'
? Promise.resolve({
data: {
success: true,
data: {
...linkedAccountRequirements,
oauth_providers: [
{ slug: 'linuxdo', name: 'Linux DO' },
{ slug: 'github', name: 'GitHub' },
],
},
},
})
: Promise.resolve({ data: { success: true, data: {} } })
)
const post = vi.spyOn(api, 'post').mockResolvedValue({
data: {
success: true,
data: {
flow_token: 'github-state',
authorization_url: 'https://github.com/login/oauth/authorize?s=1',
},
},
})
const user = userEvent.setup()
render(<Harness operation={{ scope: '2fa.setup' }} onResult={vi.fn()} />)
await user.click(screen.getByText('Protected action'))
await user.click(
await screen.findByRole('button', { name: 'Continue with GitHub' })
)
await waitFor(() =>
expect(popup.location.replace).toHaveBeenCalledWith(
'https://github.com/login/oauth/authorize?s=1'
)
)
expect(post).toHaveBeenCalledExactlyOnceWith(
'/api/oauth/state',
expect.objectContaining({ provider: 'github' }),
expect.anything()
)
expect(
screen.getByRole('button', { name: 'Continue with Linux DO' })
).toBeDisabled()
})
@@ -37,7 +37,7 @@ import type {
interface SecureVerificationDialogProps {
state: SecureVerificationState
passkeyDomains?: PasskeyDomains | null
onVerify: () => void | Promise<void>
onVerify: (input?: VerificationInput) => void | Promise<void>
onCancel: () => void
onRetry: () => void
onInputChange: (input: VerificationInput) => void
@@ -70,9 +70,8 @@ export function SecureVerificationDialog(props: SecureVerificationDialogProps) {
if (input?.method === '2fa') {
canVerify = canVerify && input.code.trim().length >= 6
}
if (input?.method === 'oauth') {
canVerify = canVerify && Boolean(input.provider)
}
// Linked-account verification starts from the provider button itself.
const showSubmit = state.phase !== 'error' && input?.method !== 'oauth'
const error = 'error' in state ? state.error : undefined
const formId = `${inputId}-form`
@@ -123,11 +122,12 @@ export function SecureVerificationDialog(props: SecureVerificationDialogProps) {
<Button type='button' variant='outline' onClick={props.onCancel}>
{t('Cancel')}
</Button>
{state.phase === 'error' ? (
{state.phase === 'error' && (
<Button type='button' onClick={props.onRetry}>
{t('Retry')}
</Button>
) : (
)}
{showSubmit && (
<Button type='submit' form={formId} disabled={!canVerify}>
{verifying && <Loader2 className='size-4 animate-spin' />}
{t('Verify')}
@@ -253,25 +253,20 @@ export function SecureVerificationDialog(props: SecureVerificationDialogProps) {
<Button
key={provider.slug}
type='button'
variant={
input.method === 'oauth' &&
input.provider === provider.slug
? 'default'
: 'outline'
}
aria-pressed={
input.method === 'oauth' &&
input.provider === provider.slug
}
disabled={verifying}
onClick={() =>
props.onInputChange({
void props.onVerify({
method: 'oauth',
provider: provider.slug,
})
}
>
{provider.name}
{verifying &&
input.method === 'oauth' &&
input.provider === provider.slug && (
<Loader2 className='size-4 animate-spin' />
)}
{t('Continue with {{name}}', { name: provider.name })}
</Button>
))}
</div>
@@ -277,58 +277,60 @@ export function useSecureVerification() {
[loadRequirements]
)
const executeVerification = useCallback(async () => {
const current = pending.current
if (
!current ||
current.submitting ||
state.phase !== 'ready' ||
!state.input
) {
return
}
current.submitting = true
const input = state.input
dispatch({ type: 'submit' })
try {
if (current.kind === 'login') {
const bundle = await verifyLogin(
input,
current.request.challenge,
current.controller.signal,
(domains) => {
if (pending.current === current) setPasskeyDomains(domains)
}
)
const executeVerification = useCallback(
async (override?: VerificationInput) => {
const current = pending.current
if (!current || current.submitting || state.phase !== 'ready') return
const input = override ?? state.input
if (!input) return
const available = state.requirements.methods.some(
(option) => option.method === input.method && option.available
)
if (!available) return
current.submitting = true
if (override) dispatch({ type: 'input', input })
dispatch({ type: 'submit' })
try {
if (current.kind === 'login') {
const bundle = await verifyLogin(
input,
current.request.challenge,
current.controller.signal,
(domains) => {
if (pending.current === current) setPasskeyDomains(domains)
}
)
if (pending.current !== current) return
current.resolve(bundle)
} else {
const proof = await verify(
input,
current.request,
state.requirements.password_encryption_enabled,
current.controller.signal,
(domains) => {
if (pending.current === current) setPasskeyDomains(domains)
}
)
if (pending.current !== current) return
current.resolve(proof)
}
pending.current = null
dispatch({ type: 'reset' })
} catch (error) {
if (pending.current !== current) return
current.resolve(bundle)
} else {
const proof = await verify(
input,
current.request,
state.requirements.password_encryption_enabled,
current.controller.signal,
(domains) => {
if (pending.current === current) setPasskeyDomains(domains)
}
)
if (pending.current !== current) return
current.resolve(proof)
const failure = AuthOperationError.from(error)
if (failure.code === 'AUTH_CANCELLED') {
cancel()
return
}
dispatch({ type: 'error', error: failure.message })
} finally {
current.submitting = false
}
pending.current = null
dispatch({ type: 'reset' })
} catch (error) {
if (pending.current !== current) return
const failure = AuthOperationError.from(error)
if (failure.code === 'AUTH_CANCELLED') {
cancel()
return
}
dispatch({ type: 'error', error: failure.message })
} finally {
current.submitting = false
}
}, [cancel, state])
},
[cancel, state]
)
const retry = useCallback(() => {
if (pending.current && state.phase === 'error') {
@@ -32,12 +32,23 @@ export type SecurityProofScope =
| '2fa.disable'
| '2fa.backup_codes.regenerate'
| 'access_token.generate'
| 'access_token.update'
| 'access_token.revoke'
| 'account.binding.bind'
| 'account.binding.unbind'
| 'account.password.set'
| 'account.password.change'
| 'account.delete'
| 'admin.user.create'
| 'admin.user.update'
| 'admin.user.delete'
| 'admin.user.manage'
| 'admin.user.passkey.reset'
| 'admin.user.2fa.disable'
| 'admin.user.binding.clear'
/** ManageUser actions that change a user's status or role and need step-up. */
export type AdminUserManageAction = 'disable' | 'enable' | 'promote' | 'demote'
export type VerificationOperation =
| { scope: 'channel.key.read'; context: { channel_id: number } }
@@ -46,10 +57,47 @@ export type VerificationOperation =
context: { provider: string; email?: string; code?: string }
}
| { scope: 'account.binding.unbind'; context: { provider_id: number } }
| { scope: 'admin.user.create'; context: { role: number } }
| {
scope:
| 'admin.user.update'
| 'admin.user.delete'
| 'admin.user.passkey.reset'
| 'admin.user.2fa.disable'
context: { user_id: number }
}
| {
scope: 'admin.user.manage'
context: { user_id: number; action: AdminUserManageAction }
}
| {
scope: 'admin.user.binding.clear'
context:
| { user_id: number; binding_type: string }
| { user_id: number; provider_id: number }
}
| {
scope: 'access_token.generate'
context: { scopes: string[]; expires_at: number }
}
| {
scope: 'access_token.update'
context: { token_id: number; scopes: string[] }
}
| {
scope: 'access_token.revoke'
context: { token_id: number } | { legacy: true }
}
| {
scope: Exclude<
SecurityProofScope,
'channel.key.read' | 'account.binding.bind' | 'account.binding.unbind'
| 'channel.key.read'
| 'account.binding.bind'
| 'account.binding.unbind'
| 'access_token.generate'
| 'access_token.update'
| 'access_token.revoke'
| `admin.user.${string}`
>
context?: Record<string, never>
}
@@ -399,7 +399,7 @@ export function UserAuthForm({
<Button
type='submit'
className='mt-2 w-full justify-center gap-2'
disabled={isLoading || (requiresLegalConsent && !agreedToLegal)}
disabled={isLoading}
>
{isLoading ? <Loader2 className='animate-spin' /> : <LogIn />}
{t('Sign in')}
+5
View File
@@ -182,6 +182,11 @@ export interface SystemStatus {
password_login_encryption_enabled?: boolean
password_register_enabled?: boolean
custom_oauth_providers?: CustomOAuthProviderInfo[]
log_export_enabled?: boolean
log_max_range_days?: number
quota_remind_enabled?: boolean
/** Site default low balance reminder threshold, in quota units */
quota_remind_threshold?: number
[key: string]: unknown
}
+20
View File
@@ -20,6 +20,7 @@ import { getGroups as getUserGroups } from '@/features/users/api'
import { api, type ApiRequestConfig } from '@/lib/api'
import { requireServerSuccess } from '@/lib/server-error-message'
import type { PriorityMove, PriorityOrder } from './lib/channel-priority-order'
import type { InferenceStatus } from './lib/inference-status'
import type {
AddChannelRequest,
@@ -50,6 +51,24 @@ const channelActionConfig = (
skipErrorHandler: true,
})
export async function getChannelPriorityOrder(): Promise<PriorityOrder> {
const response = await api.get<{ success: boolean; data: PriorityOrder }>(
'/api/channel/priority/order',
{ disableDuplicate: true }
)
return requireServerSuccess(response.data).data
}
export async function reorderChannelPriority(
move: PriorityMove
): Promise<PriorityOrder> {
const response = await api.post<{ success: boolean; data: PriorityOrder }>(
'/api/channel/priority/reorder',
move
)
return requireServerSuccess(response.data).data
}
export async function getInferenceStatus(
channelId: number,
provider: 'vllm' | 'sglang',
@@ -73,6 +92,7 @@ export type TaskPluginOption = {
baseUrl?: string
models: string[]
channelTypes?: number[] | null
upstreams?: string[] | null
}
export async function getTaskPluginOptions(): Promise<TaskPluginOption[]> {
@@ -63,6 +63,7 @@ const plugins: TaskPluginOption[] = [
icon: 'text:VA',
baseUrl: 'https://a.example',
models: ['video-a-1'],
upstreams: ['vendor', 'new_api'],
},
{
key: 'video-b',
@@ -70,8 +71,10 @@ const plugins: TaskPluginOption[] = [
icon: 'text:VB',
baseUrl: 'https://b.example',
models: ['video-b-1'],
upstreams: ['vendor', 'new_api'],
},
{
// Vendor-only: bindable to a Task Plugin channel, never to a New API one.
key: 'no-address',
name: 'No Address',
icon: 'text:NA',
@@ -1320,7 +1323,7 @@ test('editing legacy channels retains the full provider list and saves the origi
soraPlugin,
{
key: 'doubao',
name: 'Doubao Video',
name: 'Doubao',
icon: 'text',
models: ['doubao-video'],
},
@@ -1336,7 +1339,7 @@ test('editing legacy channels retains the full provider list and saves the origi
await screen.findByRole('option', { name: 'Sora Plugin sora' })
).toBeVisible()
expect(
screen.getByRole('option', { name: 'DoubaoVideo Built-in #54' })
screen.getByRole('option', { name: 'Doubao Built-in #54' })
).toBeVisible()
const legacy = screen.getByRole('option', { name: 'Sora Built-in #55' })
expect(legacy).toHaveAttribute('aria-current', 'true')
@@ -1608,6 +1611,56 @@ test('an Ollama channel marks a saved OpenAI-compatible chat setting in Request
})
})
test('a saved upstream TLS verification skip marks Other Settings and turning it off saves the setting without it', async () => {
editingChannel = {
...editingChannel,
setting: '{"tls_insecure_skip_verify":true}',
}
const put = vi
.spyOn(api, 'put')
.mockResolvedValue({ data: { success: true } })
const user = userEvent.setup()
render(<ConfigurationHarness currentRow={editingChannel} />)
await screen.findByDisplayValue('Existing channel')
const otherTab = screen.getByRole('tab', { name: /Other Settings/ })
expect(otherTab).toHaveAccessibleName(/Configured/)
await user.click(otherTab)
const toggle = screen.getByRole('switch', {
name: 'Skip Upstream TLS Verification',
})
expect(toggle).toBeChecked()
await user.click(toggle)
expect(toggle).not.toBeChecked()
expect(otherTab).not.toHaveAccessibleName(/Configured/)
await user.click(screen.getByRole('button', { name: 'Update Channel' }))
await waitFor(() => expect(put).toHaveBeenCalled())
const payload = put.mock.calls[0]?.[1] as { setting: string }
expect(JSON.parse(payload.setting)).not.toHaveProperty(
'tls_insecure_skip_verify'
)
})
test('turning on the upstream TLS verification skip saves it in the channel setting', async () => {
const put = vi
.spyOn(api, 'put')
.mockResolvedValue({ data: { success: true } })
const user = userEvent.setup()
render(<ConfigurationHarness currentRow={editingChannel} />)
await screen.findByDisplayValue('Existing channel')
await user.click(screen.getByRole('tab', { name: /Other Settings/ }))
const toggle = screen.getByRole('switch', {
name: 'Skip Upstream TLS Verification',
})
expect(toggle).not.toBeChecked()
await user.click(toggle)
await user.click(screen.getByRole('button', { name: 'Update Channel' }))
await waitFor(() => expect(put).toHaveBeenCalled())
const payload = put.mock.calls[0]?.[1] as { setting: string }
expect(JSON.parse(payload.setting)).toMatchObject({
tls_insecure_skip_verify: true,
})
})
test('an invalid edit switches categories and replaces configured styling with the field error', async () => {
const user = userEvent.setup()
render(<ConfigurationHarness currentRow={editingChannel} />)
@@ -2301,10 +2354,14 @@ test('redirecting a model from the model picker publishes it, adds a mapping row
})
test('fetched upstream models can be redirected in bulk with the model list synced', async () => {
editingChannel.models = 'custom-model,upstream-model'
const user = userEvent.setup()
render(<ConfigurationHarness currentRow={editingChannel} />)
await screen.findByDisplayValue('Existing channel')
await user.click(screen.getByRole('button', { name: 'Fetch from Upstream' }))
await user.click(
await screen.findByRole('tab', { name: 'Existing Models (1)' })
)
await screen.findByRole('checkbox', { name: 'upstream-model' })
await user.click(screen.getByRole('button', { name: 'Batch redirect' }))
@@ -2314,6 +2371,9 @@ test('fetched upstream models can be redirected in bulk with the model list sync
expect(
dialog.getByRole('tab', { name: /Upstream model list/ })
).toHaveAttribute('aria-selected', 'true')
expect(
dialog.getByRole('checkbox', { name: 'upstream-model' })
).not.toBeChecked()
await user.click(dialog.getByRole('checkbox', { name: 'upstream-model' }))
await user.type(dialog.getByRole('textbox', { name: 'Suffix' }), '-model')
await user.click(dialog.getByRole('button', { name: 'Add 1 mapping(s)' }))
@@ -2420,9 +2480,127 @@ test('on wide screens redirecting a fetched model opens the floating panel besid
).toHaveValue('upstream')
})
test('a suggested rule in the floating panel maps every matching fetched model and publishes the derived names', async () => {
test.each(['current session', 'saved preference'])(
'quick redirect expands a panel collapsed in the %s and focuses the new request name',
async (collapsedIn) => {
useWideScreen()
editingChannel.model_mapping = '{"existing-alias":"existing-upstream"}'
if (collapsedIn === 'saved preference') {
window.localStorage.setItem(
'floating-window:channel-model-redirects',
JSON.stringify({
x: 1000,
y: 80,
width: 380,
height: 360,
collapsed: true,
})
)
}
const user = userEvent.setup()
render(<ConfigurationHarness currentRow={editingChannel} />)
await screen.findByDisplayValue('Existing channel')
await user.click(
screen.getByRole('button', { name: 'Fetch from Upstream' })
)
await screen.findByRole('checkbox', { name: 'upstream-model' })
if (collapsedIn === 'current session') {
await user.click(screen.getByRole('button', { name: 'Redirect panel' }))
await user.click(screen.getByRole('button', { name: 'Collapse panel' }))
expect(screen.getByRole('button', { name: 'Expand panel' })).toBeVisible()
}
await user.click(
screen.getByRole('button', { name: 'Redirect upstream-model' })
)
let panelElement = await screen.findByRole('complementary', {
name: 'Model redirects',
})
let panel = within(panelElement)
await waitFor(() =>
expect(panel.getByDisplayValue('upstream-model')).toBeVisible()
)
const request = panel.getAllByRole('combobox', {
name: 'Request Model Name',
})[1]
await waitFor(() => expect(request).toHaveFocus())
expect(request).toHaveValue('')
expect(panel.getByDisplayValue('existing-alias')).toBeVisible()
expect(panel.getByDisplayValue('existing-upstream')).toBeVisible()
expect(screen.getByText('1 model(s) redirected')).toBeVisible()
if (collapsedIn === 'saved preference') {
expect(panelElement).toHaveStyle({
left: '1000px',
top: '80px',
width: '380px',
height: '360px',
})
}
// Handling the draft must not prevent collapse or swallow the next shortcut for the same model.
await user.click(panel.getByRole('button', { name: 'Collapse panel' }))
expect(
screen.queryByRole('complementary', { name: 'Model redirects' })
).not.toBeInTheDocument()
await user.click(
screen.getByRole('button', { name: 'Redirect upstream-model' })
)
panelElement = await screen.findByRole('complementary', {
name: 'Model redirects',
})
panel = within(panelElement)
await waitFor(() =>
expect(panel.getByDisplayValue('upstream-model')).toBeVisible()
)
await waitFor(() =>
expect(
panel.getAllByRole('combobox', { name: 'Request Model Name' })[1]
).toHaveFocus()
)
}
)
test('redirecting a recognized upstream name opens an empty alias draft without changing the model list', async () => {
useWideScreen()
const baseGet = vi.mocked(api.get).getMockImplementation()
vi.spyOn(api, 'get').mockImplementation(async (url, ...rest) => {
if (url === '/api/channel/fetch_models/42') {
return { data: { success: true, data: ['gpt-4o-all'] } }
}
if (url === '/api/channel/models') {
return { data: { success: true, data: [{ id: 'gpt-4o' }] } }
}
return baseGet?.(url, ...rest)
})
const user = userEvent.setup()
render(<ConfigurationHarness currentRow={editingChannel} />)
await screen.findByDisplayValue('Existing channel')
await user.click(screen.getByRole('button', { name: 'Fetch from Upstream' }))
await user.click(
await screen.findByRole('button', { name: 'Redirect gpt-4o-all' })
)
const panel = within(
await screen.findByRole('complementary', { name: 'Model redirects' })
)
const request = panel.getByRole('combobox', { name: 'Request Model Name' })
await waitFor(() => expect(request).toHaveFocus())
expect(request).toHaveValue('')
expect(
panel.getByRole('combobox', { name: 'Upstream Model Name' })
).toHaveValue('gpt-4o-all')
expect(screen.queryByLabelText('Published as gpt-4o')).not.toBeInTheDocument()
expect(
within(screen.getByRole('group', { name: 'Models' })).getByRole('button', {
name: 'custom-model',
})
).toBeVisible()
expect(screen.queryByText('1 model(s) redirected')).not.toBeInTheDocument()
})
test('the redirect panel batch action starts empty and only applies explicitly selected models after preview', async () => {
useWideScreen()
editingChannel.models = 'custom-model,gpt-4o-all,o3-all'
const baseGet = vi.mocked(api.get).getMockImplementation()
vi.spyOn(api, 'get').mockImplementation(async (url, ...rest) => {
if (url === '/api/channel/fetch_models/42') {
return { data: { success: true, data: ['gpt-4o-all', 'o3-all'] } }
@@ -2436,22 +2614,47 @@ test('a suggested rule in the floating panel maps every matching fetched model a
render(<ConfigurationHarness currentRow={editingChannel} />)
await screen.findByDisplayValue('Existing channel')
await user.click(screen.getByRole('button', { name: 'Fetch from Upstream' }))
await user.click(
await screen.findByRole('tab', { name: 'Existing Models (2)' })
)
await screen.findByRole('checkbox', { name: 'gpt-4o-all' })
await user.click(screen.getByRole('button', { name: 'Redirect panel' }))
const panel = within(
await screen.findByRole('complementary', { name: 'Model redirects' })
)
await user.click(panel.getByRole('button', { name: /Strip -all/ }))
expect(panel.queryByText('Suggested rules')).not.toBeInTheDocument()
await user.click(panel.getByRole('button', { name: 'Batch Add' }))
let dialog = within(
screen.getByRole('dialog', { name: 'Batch add mappings' })
)
expect(dialog.getByRole('checkbox', { name: 'gpt-4o-all' })).not.toBeChecked()
expect(dialog.getByRole('checkbox', { name: 'o3-all' })).not.toBeChecked()
await user.click(dialog.getByRole('checkbox', { name: 'gpt-4o-all' }))
await user.type(dialog.getByRole('textbox', { name: 'Suffix' }), '-all')
await user.click(dialog.getByRole('button', { name: 'Cancel' }))
const picker = within(screen.getByRole('group', { name: 'Models' }))
expect(picker.getByRole('button', { name: 'gpt-4o-all' })).toBeVisible()
expect(
picker.queryByRole('button', { name: 'gpt-4o' })
).not.toBeInTheDocument()
await user.click(panel.getByRole('button', { name: 'Batch Add' }))
dialog = within(screen.getByRole('dialog', { name: 'Batch add mappings' }))
expect(dialog.getByRole('checkbox', { name: 'gpt-4o-all' })).not.toBeChecked()
await user.click(dialog.getByRole('checkbox', { name: 'gpt-4o-all' }))
await user.type(dialog.getByRole('textbox', { name: 'Suffix' }), '-all')
expect(dialog.getByRole('list', { name: 'Preview' })).toHaveTextContent(
'gpt-4ogpt-4o-all'
)
await user.click(dialog.getByRole('button', { name: 'Add 1 mapping(s)' }))
expect(
panel.getAllByRole('combobox', { name: 'Request Model Name' })
).toHaveLength(2)
expect(screen.getByLabelText('Published as gpt-4o')).toBeVisible()
expect(screen.getByLabelText('Published as o3')).toBeVisible()
const picker = within(screen.getByRole('group', { name: 'Models' }))
).toHaveLength(1)
expect(picker.getByRole('button', { name: 'gpt-4o' })).toBeVisible()
expect(picker.getByRole('button', { name: 'o3' })).toBeVisible()
expect(screen.getByText('2 model(s) redirected')).toBeVisible()
expect(picker.getByRole('button', { name: 'o3-all' })).toBeVisible()
expect(picker.queryByRole('button', { name: 'o3' })).not.toBeInTheDocument()
expect(screen.getByText('1 model(s) redirected')).toBeVisible()
await user.click(panel.getByRole('button', { name: 'Collapse panel' }))
expect(
@@ -2595,3 +2798,138 @@ test('the header override passthrough template button confirms before filling th
)
).toBeChecked()
})
test('a New API channel binds upstream task plugins and publishes their models', async () => {
const channel = channelSchema.parse({
...editingChannel,
type: 60,
base_url: 'https://gateway.example',
models: 'gpt-5',
setting: JSON.stringify({ task_extend_plugin_keys: ['video-a'] }),
})
const originalGet = vi.mocked(api.get).getMockImplementation()
vi.mocked(api.get).mockImplementation(async (url, config) => {
if (url === '/api/channel/42') {
return { data: { success: true, data: channel } }
}
return originalGet?.(url, config)
})
const put = vi
.spyOn(api, 'put')
.mockResolvedValue({ data: { success: true } })
const user = userEvent.setup()
render(<ConfigurationHarness currentRow={channel} />)
const extensions = within(
await screen.findByRole('group', { name: 'Plugin extensions' })
)
expect(
extensions.getByRole('button', { name: 'Video A Selected 0 / 1' })
).toBeVisible()
expect(
extensions.queryByRole('button', { name: /^Video B/ })
).not.toBeInTheDocument()
const selector = screen.getByRole('combobox', {
name: 'Select the task plugins installed on the upstream gateway',
})
await user.click(selector)
expect(
screen.queryByRole('option', { name: /^No Address/ })
).not.toBeInTheDocument()
await user.click(screen.getByRole('option', { name: /^Video B/ }))
await user.keyboard('{Escape}')
expect(
extensions.getByRole('button', { name: 'Video B Selected 1 / 1' })
).toBeVisible()
expect(screen.getByRole('button', { name: 'video-b-1' })).toBeVisible()
await user.click(selector)
await user.click(screen.getByRole('option', { name: /^Video A/ }))
await user.keyboard('{Escape}')
expect(
extensions.queryByRole('button', { name: /^Video A/ })
).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'gpt-5' })).toBeVisible()
await user.click(screen.getByRole('button', { name: 'Update Channel' }))
await waitFor(() => expect(put).toHaveBeenCalled())
const payload = put.mock.calls[0]?.[1] as {
setting?: string
models?: string
}
const setting = JSON.parse(payload.setting ?? '{}')
expect(setting).toMatchObject({ task_extend_plugin_keys: ['video-b'] })
expect(setting).not.toHaveProperty('task_plugin_key')
expect(payload.models?.split(',').sort()).toEqual(['gpt-5', 'video-b-1'])
})
test.each([
{
saved: undefined,
shown: 'InvokeModel (legacy)',
next: 'Mantle (Messages API)',
value: 'mantle',
},
{
saved: 'mantle',
shown: 'Mantle (Messages API)',
next: 'InvokeModel (legacy)',
value: 'invoke_model',
},
])(
'AWS API defaults or restores $shown and saves the selected interface',
async ({ saved, shown, next, value }) => {
editingChannel = {
...editingChannel,
type: 33,
base_url: '',
models: 'claude-opus-4-7',
settings: JSON.stringify({
aws_key_type: 'api_key',
aws_api_type: saved,
}),
}
const put = vi
.spyOn(api, 'put')
.mockResolvedValue({ data: { success: true } })
const user = userEvent.setup()
render(<ConfigurationHarness currentRow={editingChannel} />)
const selector = await screen.findByRole('combobox', { name: 'AWS API' })
await waitFor(() => expect(selector).toHaveTextContent(shown))
await user.click(selector)
await user.click(screen.getByRole('option', { name: next }))
expect(selector).toHaveTextContent(next)
await user.click(screen.getByRole('button', { name: 'Update Channel' }))
await waitFor(() => expect(put).toHaveBeenCalled())
const payload = put.mock.calls[0]?.[1] as { settings: string }
expect(JSON.parse(payload.settings)).toMatchObject({
aws_key_type: 'api_key',
aws_api_type: value,
})
}
)
test('AWS interface selection is disabled without sensitive channel write permission', async () => {
editingChannel = {
...editingChannel,
type: 33,
settings: '{"aws_api_type":"mantle"}',
}
useAuthStore.setState({
auth: {
...originalAuth,
user: {
id: 10,
username: 'operator',
role: ROLE.ADMIN,
permissions: {
admin_permissions: { channel: { read: true, write: true } },
},
},
},
})
render(<ConfigurationHarness currentRow={editingChannel} />)
expect(
await screen.findByRole('combobox', { name: 'AWS API' })
).toBeDisabled()
})
@@ -0,0 +1,77 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import {
flexRender,
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test } from 'vitest'
import { channelSchema, type Channel } from '../../types'
import { useChannelsColumns } from '../channels-columns'
import { ChannelsProvider } from '../channels-provider'
function ExampleAutoDisabledStatusCell(props: { channel: Channel }) {
const table = useReactTable({
data: [props.channel],
columns: useChannelsColumns({ enableSelection: false }),
getCoreRowModel: getCoreRowModel(),
})
const cell = table
.getRowModel()
.rows[0]?.getAllCells()
.find((item) => item.column.id === 'status')
return cell ? flexRender(cell.column.columnDef.cell, cell.getContext()) : null
}
test('keeps the long string inside the status tooltip is wrapped when show', async () => {
const reason = '114514'.repeat(40)
const channelItem = channelSchema.parse({
id: 1,
type: 1,
key: 'test-key',
name: 'Test-Channel-Item',
status: 3,
created_time: 1,
test_time: 0,
response_time: 0,
balance_updated_time: 0,
other_info: JSON.stringify({ status_reason: reason }),
})
const queryClient = new QueryClient()
const user = userEvent.setup()
render(
<QueryClientProvider client={queryClient}>
<ChannelsProvider>
<ExampleAutoDisabledStatusCell channel={channelItem} />
</ChannelsProvider>
</QueryClientProvider>
)
await user.hover(screen.getByText('Auto Disabled'))
expect(await screen.findByText(reason, { exact: false })).toHaveClass(
'wrap-anywhere'
)
})
@@ -18,11 +18,15 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createInstance } from 'i18next'
import { I18nextProvider } from 'react-i18next'
import { expect, test, vi } from 'vitest'
import fr from '@/i18n/locales/fr.json'
import { ModelMappingBatchDialog } from '../model-mapping-batch-dialog'
test('upstream names are the default source: stripping a suffix derives request names, skips unchanged models, and syncs the model list', async () => {
test('creating user aliases starts unselected, previews request-to-upstream pairs, and skips unchanged names', async () => {
const user = userEvent.setup()
const onApply = vi.fn()
render(
@@ -30,113 +34,236 @@ test('upstream names are the default source: stripping a suffix derives request
open
onOpenChange={vi.fn()}
upstreamModels={['gpt-4o-all', 'o3']}
channelModels={['gpt-4o']}
channelModels={['gpt-4o-all']}
onApply={onApply}
/>
)
const dialog = within(
screen.getByRole('dialog', { name: 'Batch add mappings' })
)
expect(
dialog.getByRole('tab', { name: /Upstream model list/ })
).toHaveAttribute('aria-selected', 'true')
expect(
dialog.getByRole('radio', {
name: 'Upstream names returned by the provider',
})
screen.getByRole('radio', { name: 'Create aliases for users' })
).toBeChecked()
expect(dialog.getByRole('radio', { name: 'Strip suffix' })).toBeChecked()
expect(
dialog.getByRole('button', { name: 'Add 0 mapping(s)' })
).toBeDisabled()
await user.click(dialog.getByRole('checkbox', { name: 'gpt-4o-all' }))
await user.click(dialog.getByRole('checkbox', { name: 'o3' }))
await user.type(dialog.getByRole('textbox', { name: 'Suffix' }), '-all')
expect(dialog.getByRole('list', { name: 'Preview' })).toHaveTextContent(
'gpt-4ogpt-4o-all'
expect(screen.getByRole('combobox', { name: 'Rule' })).toHaveTextContent(
'Strip suffix'
)
expect(screen.getByRole('checkbox', { name: 'gpt-4o-all' })).not.toBeChecked()
expect(
dialog.getByText('1 model(s) unchanged by the rule were skipped')
screen.getByRole('button', { name: 'Add 0 mapping(s)' })
).toBeDisabled()
expect(
screen.getByText(
'Example: users call gpt-4o; upstream receives gpt-4o-all.'
)
).toBeVisible()
await user.click(dialog.getByRole('button', { name: 'Add 1 mapping(s)' }))
await user.click(screen.getByRole('checkbox', { name: 'gpt-4o-all' }))
await user.click(screen.getByRole('checkbox', { name: 'o3' }))
expect(screen.getByText('Enter a rule to preview mappings.')).toBeVisible()
expect(screen.queryByText(/unchanged by the rule/)).not.toBeInTheDocument()
await user.type(screen.getByRole('textbox', { name: 'Suffix' }), '-all')
const preview = within(screen.getByRole('list', { name: 'Preview' }))
expect(preview.getByRole('listitem')).toHaveTextContent('gpt-4ogpt-4o-all')
expect(
screen.getByText('1 model(s) unchanged by the rule were skipped')
).toBeVisible()
expect(onApply).not.toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: 'Add 1 mapping(s)' }))
expect(onApply).toHaveBeenCalledWith({
pairs: [{ from: 'gpt-4o', to: 'gpt-4o-all' }],
syncModels: true,
})
})
test('the channel-model source flips to request names, offers add rules, and never syncs the model list', async () => {
test('changing upstream names preserves the request name and leaves model-list syncing off', async () => {
const user = userEvent.setup()
const onApply = vi.fn()
render(
<ModelMappingBatchDialog
open
onOpenChange={vi.fn()}
upstreamModels={['x-all']}
channelModels={['gemini-2.5-flash']}
onApply={onApply}
/>
)
const dialog = within(
screen.getByRole('dialog', { name: 'Batch add mappings' })
)
await user.click(dialog.getByRole('tab', { name: /Channel models/ }))
expect(
dialog.getByRole('radio', { name: 'Request names your users call' })
screen.getByRole('radio', { name: 'Change upstream model names' })
).toBeChecked()
expect(dialog.getByRole('radio', { name: 'Add suffix' })).toBeChecked()
expect(
dialog.queryByRole('checkbox', { name: /Publish the request names/ })
screen.queryByRole('checkbox', { name: /Publish the request names/ })
).not.toBeInTheDocument()
await user.click(dialog.getByRole('checkbox', { name: 'gemini-2.5-flash' }))
await user.type(dialog.getByRole('textbox', { name: 'Suffix' }), '-all')
await user.click(dialog.getByRole('button', { name: 'Add 1 mapping(s)' }))
await user.click(screen.getByRole('checkbox', { name: 'gemini-2.5-flash' }))
await user.type(screen.getByRole('textbox', { name: 'Suffix' }), '-all')
await user.click(screen.getByRole('button', { name: 'Add 1 mapping(s)' }))
expect(onApply).toHaveBeenCalledWith({
pairs: [{ from: 'gemini-2.5-flash', to: 'gemini-2.5-flash-all' }],
syncModels: false,
})
})
test('without an upstream list the dialog offers only channel models, and conflicting request names are reported', async () => {
test('switching the model list clears selection without changing the chosen task or naming rule', async () => {
const user = userEvent.setup()
const onApply = vi.fn()
render(
<ModelMappingBatchDialog
open
onOpenChange={vi.fn()}
upstreamModels={['gpt-4o-all']}
channelModels={['claude-all']}
onApply={onApply}
/>
)
await user.click(screen.getByRole('checkbox', { name: 'gpt-4o-all' }))
await user.type(screen.getByRole('textbox', { name: 'Suffix' }), '-all')
await user.click(screen.getByRole('tab', { name: /Channel models/ }))
expect(
screen.getByRole('radio', { name: 'Create aliases for users' })
).toBeChecked()
expect(screen.getByRole('textbox', { name: 'Suffix' })).toHaveValue('-all')
expect(
screen.getByRole('button', { name: 'Add 0 mapping(s)' })
).toBeDisabled()
await user.click(screen.getByRole('checkbox', { name: 'claude-all' }))
await user.click(screen.getByRole('button', { name: 'Add 1 mapping(s)' }))
expect(onApply).toHaveBeenCalledWith({
pairs: [{ from: 'claude', to: 'claude-all' }],
syncModels: true,
})
})
test('creating aliases from channel models reports conflicts and allows opting out of model-list syncing', async () => {
const user = userEvent.setup()
const onApply = vi.fn()
render(
<ModelMappingBatchDialog
open
onOpenChange={vi.fn()}
channelModels={['claude-all', 'claude-latest']}
onApply={vi.fn()}
onApply={onApply}
/>
)
const dialog = within(
screen.getByRole('dialog', { name: 'Batch add mappings' })
)
expect(dialog.queryByRole('tab')).not.toBeInTheDocument()
expect(
dialog.getByRole('radio', { name: 'Request names your users call' })
).toBeChecked()
expect(screen.queryByRole('tab')).not.toBeInTheDocument()
await user.click(
dialog.getByRole('radio', {
name: 'Upstream names returned by the provider',
})
screen.getByRole('radio', { name: 'Create aliases for users' })
)
expect(dialog.getByRole('radio', { name: 'Strip suffix' })).toBeChecked()
await user.click(dialog.getByRole('checkbox', { name: 'claude-all' }))
await user.click(dialog.getByRole('checkbox', { name: 'claude-latest' }))
await user.click(screen.getByRole('checkbox', { name: 'claude-all' }))
await user.click(screen.getByRole('checkbox', { name: 'claude-latest' }))
await user.type(
dialog.getByRole('textbox', { name: 'Suffix' }),
screen.getByRole('textbox', { name: 'Suffix' }),
'-all, -latest'
)
expect(dialog.getByRole('list', { name: 'Preview' })).toHaveTextContent(
'claudeclaude-all'
)
expect(
dialog.getByText(
screen.getByText(
'1 model(s) skipped because another model derives the same request name'
)
).toBeVisible()
expect(dialog.getByRole('button', { name: 'Add 1 mapping(s)' })).toBeEnabled()
await user.click(
screen.getByRole('checkbox', { name: /Publish the request names/ })
)
await user.click(screen.getByRole('button', { name: 'Add 1 mapping(s)' }))
expect(onApply).toHaveBeenCalledWith({
pairs: [{ from: 'claude', to: 'claude-all' }],
syncModels: false,
})
})
test('the alias task can add a prefix and only shows fields for the selected rule', async () => {
const user = userEvent.setup()
const onApply = vi.fn()
render(
<ModelMappingBatchDialog
open
onOpenChange={vi.fn()}
upstreamModels={['gpt-4o']}
channelModels={[]}
onApply={onApply}
/>
)
await user.click(screen.getByRole('checkbox', { name: 'gpt-4o' }))
expect(
screen.queryByRole('textbox', { name: 'Prefix' })
).not.toBeInTheDocument()
await user.click(screen.getByRole('combobox', { name: 'Rule' }))
await user.click(screen.getByRole('option', { name: 'Add prefix' }))
expect(
screen.queryByRole('textbox', { name: 'Suffix' })
).not.toBeInTheDocument()
await user.type(screen.getByRole('textbox', { name: 'Prefix' }), 'team/')
await user.click(screen.getByRole('button', { name: 'Add 1 mapping(s)' }))
expect(onApply).toHaveBeenCalledWith({
pairs: [{ from: 'team/gpt-4o', to: 'gpt-4o' }],
syncModels: true,
})
})
test('replacement previews include every selected model even when several share an upstream target', async () => {
const user = userEvent.setup()
const onApply = vi.fn()
const models = [
'gpt-4o-a',
'gpt-4oa',
...Array.from({ length: 8 }, (_, n) => `gpt-${n}`),
]
render(
<ModelMappingBatchDialog
open
onOpenChange={vi.fn()}
channelModels={models}
onApply={onApply}
/>
)
await user.click(
screen.getByRole('checkbox', { name: 'Select all models in OpenAI' })
)
await user.click(screen.getByRole('combobox', { name: 'Rule' }))
await user.click(screen.getByRole('option', { name: 'Replace text' }))
await user.type(screen.getByRole('textbox', { name: 'Find' }), '-')
const preview = within(screen.getByRole('list', { name: 'Preview' }))
expect(preview.getAllByRole('listitem')).toHaveLength(10)
expect(preview.getAllByText('gpt4oa')).toHaveLength(2)
await user.click(screen.getByRole('button', { name: 'Add 10 mapping(s)' }))
expect(onApply).toHaveBeenCalledWith({
pairs: models.map((from) => ({ from, to: from.replaceAll('-', '') })),
syncModels: false,
})
})
test('cancelling after configuring a rule does not apply mappings', async () => {
const user = userEvent.setup()
const onApply = vi.fn()
const onOpenChange = vi.fn()
render(
<ModelMappingBatchDialog
open
onOpenChange={onOpenChange}
channelModels={['gpt-4o']}
onApply={onApply}
/>
)
await user.click(screen.getByRole('checkbox', { name: 'gpt-4o' }))
await user.type(screen.getByRole('textbox', { name: 'Suffix' }), '-all')
await user.click(screen.getByRole('button', { name: 'Cancel' }))
expect(onOpenChange).toHaveBeenCalledWith(false)
expect(onApply).not.toHaveBeenCalled()
})
test('long translated source labels wrap inside their row without covering the model search', async () => {
const i18n = createInstance()
await i18n.init({ lng: 'fr', resources: { fr }, keySeparator: false })
render(
<I18nextProvider i18n={i18n}>
<ModelMappingBatchDialog
open
onOpenChange={vi.fn()}
upstreamModels={['gpt-4o-all']}
channelModels={['gpt-4o']}
onApply={vi.fn()}
/>
</I18nextProvider>
)
const tabs = screen.getByRole('tablist', { name: 'Sélectionner les modèles' })
expect(tabs).toHaveClass('grid-cols-2', 'group-data-horizontal/tabs:h-auto')
for (const tab of within(tabs).getAllByRole('tab')) {
expect(tab).toHaveClass('whitespace-normal', 'h-auto')
}
expect(
screen.getByRole('textbox', { name: 'Rechercher des modèles...' })
).toBeVisible()
})
@@ -190,15 +190,15 @@ test('a draft request with only the upstream name focuses the request field, and
draftRequest={{ from: '', to: 'up-1', token: 1 }}
/>
)
await waitFor(() =>
expect(
screen.getAllByRole('combobox', { name: 'Request Model Name' })
).toHaveLength(2)
)
// The row is committed before its passive effect moves focus.
await waitFor(() => {
const requestFields = screen.getAllByRole('combobox', {
name: 'Request Model Name',
})
expect(requestFields).toHaveLength(2)
expect(requestFields[1]).toHaveFocus()
})
expect(screen.getByDisplayValue('up-1')).toBeVisible()
expect(
screen.getAllByRole('combobox', { name: 'Request Model Name' })[1]
).toHaveFocus()
view.rerender(
<ModelMappingEditor
@@ -0,0 +1,355 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterAll, beforeAll, expect, test, vi } from 'vitest'
import { ParamOverrideEditorDialog } from '../dialogs/param-override-editor-dialog'
const getAnimationsDescriptor = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
'getAnimations'
)
beforeAll(() => {
Object.defineProperty(HTMLElement.prototype, 'getAnimations', {
configurable: true,
value: () => [],
})
})
afterAll(() => {
if (getAnimationsDescriptor) {
Object.defineProperty(
HTMLElement.prototype,
'getAnimations',
getAnimationsDescriptor
)
} else {
Reflect.deleteProperty(HTMLElement.prototype, 'getAnimations')
}
})
async function renderPruneRules(
conditions: { path: string; mode: string; value: string }[]
) {
const onSave = vi.fn()
render(
<ParamOverrideEditorDialog
open
value={JSON.stringify({
operations: [
{ mode: 'prune_objects', path: 'messages', value: { conditions } },
],
})}
onOpenChange={vi.fn()}
onSave={onSave}
/>
)
// Wait for the dialog's opening autofocus before testing user-driven focus.
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Visual' })).toHaveFocus()
)
return onSave
}
test('clearing and retyping a prune path keeps focus on that row and leaves the next rule unchanged', async () => {
const user = userEvent.setup()
const onSave = await renderPruneRules([
{ path: 'a', mode: 'full', value: 'x' },
{ path: 'b', mode: 'full', value: 'y' },
])
const firstPath = screen.getByDisplayValue('a')
await user.clear(firstPath)
expect(firstPath).toHaveValue('')
expect(firstPath).toHaveFocus()
expect(screen.getByDisplayValue('b')).toBeVisible()
await user.type(firstPath, 'z')
expect(firstPath).toHaveFocus()
await user.click(screen.getByRole('button', { name: 'Save' }))
expect(JSON.parse(onSave.mock.calls[0][0])).toEqual({
operations: [
{
mode: 'prune_objects',
path: 'messages',
value: {
conditions: [
{ path: 'z', mode: 'full', value: 'x' },
{ path: 'b', mode: 'full', value: 'y' },
],
},
},
],
})
})
test('a newly added prune condition stays editable after deleting another row and survives duplication and JSON round-trip', async () => {
const user = userEvent.setup()
const onSave = await renderPruneRules([
{ path: 'type', mode: 'full', value: 'legacy' },
])
await user.click(screen.getAllByRole('button', { name: 'Add Condition' })[0])
const paths = screen.getAllByPlaceholderText('type')
expect(paths).toHaveLength(2)
await user.type(paths[1], 'role')
await user.click(screen.getAllByRole('combobox', { name: 'Match Mode' })[1])
await user.click(screen.getByRole('option', { name: 'Regex' }))
await user.type(
screen.getAllByRole('textbox', { name: 'Match Value (optional)' })[1],
'(?i)^tool$'
)
// The first Delete button belongs to the whole operation.
await user.click(screen.getAllByRole('button', { name: 'Delete' })[1])
expect(paths[1]).toBeInTheDocument()
expect(screen.getAllByPlaceholderText('type')).toHaveLength(1)
await user.click(screen.getByRole('button', { name: 'Duplicate' }))
await user.click(screen.getByRole('button', { name: 'JSON Text' }))
await user.click(screen.getByRole('button', { name: 'Visual' }))
await user.click(screen.getByRole('button', { name: 'Save' }))
const expected = {
mode: 'prune_objects',
path: 'messages',
value: {
conditions: [{ path: 'role', mode: 'regex', value: '(?i)^tool$' }],
},
}
expect(JSON.parse(onSave.mock.calls[0][0])).toEqual({
operations: [expected, expected],
})
})
test('switching prune simple and advanced modes retains unfinished rows but saving omits empty paths', async () => {
const user = userEvent.setup()
const onSave = await renderPruneRules([
{ path: 'a', mode: 'regex', value: '^x$' },
{ path: 'b', mode: 'full', value: 'y' },
])
await user.clear(screen.getByDisplayValue('a'))
await user.click(screen.getByRole('button', { name: 'Simple' }))
await user.click(screen.getByRole('button', { name: 'Advanced' }))
const paths = screen.getAllByPlaceholderText('type')
expect(paths).toHaveLength(2)
expect(paths[0]).toHaveValue('')
expect(paths[1]).toHaveValue('b')
expect(
screen.getAllByRole('combobox', { name: 'Match Mode' })[0]
).toHaveValue('Regex')
await user.click(screen.getByRole('button', { name: 'Save' }))
expect(JSON.parse(onSave.mock.calls[0][0])).toEqual({
operations: [
{
mode: 'prune_objects',
path: 'messages',
value: { conditions: [{ path: 'b', mode: 'full', value: 'y' }] },
},
],
})
expect(paths[0]).toBeInTheDocument()
})
test('saving an unfinished prune rule is blocked without discarding the draft', async () => {
const user = userEvent.setup()
const onSave = await renderPruneRules([])
await user.click(screen.getAllByRole('button', { name: 'Add Condition' })[0])
const path = screen.getByPlaceholderText('type')
await user.click(screen.getByRole('button', { name: 'Save' }))
expect(onSave).not.toHaveBeenCalled()
expect(path).toBeVisible()
await user.type(path, 'role')
await user.type(
screen.getByRole('textbox', { name: 'Match Value (optional)' }),
'tool'
)
await user.click(screen.getByRole('button', { name: 'Save' }))
expect(onSave).toHaveBeenCalledOnce()
expect(JSON.parse(onSave.mock.calls[0][0])).toEqual({
operations: [
{
mode: 'prune_objects',
path: 'messages',
value: { conditions: [{ path: 'role', mode: 'full', value: 'tool' }] },
},
],
})
})
test('an imported prune rule with an explicit empty type is preserved when saved unchanged', async () => {
const user = userEvent.setup()
const onSave = vi.fn()
const payload = {
operations: [
{ mode: 'prune_objects', path: 'messages', value: { type: '' } },
],
}
render(
<ParamOverrideEditorDialog
open
value={JSON.stringify(payload)}
onOpenChange={vi.fn()}
onSave={onSave}
/>
)
await user.click(screen.getByRole('button', { name: 'Save' }))
expect(onSave).toHaveBeenCalledOnce()
expect(JSON.parse(onSave.mock.calls[0][0])).toEqual(payload)
})
test('selecting Regex saves a literal Go pattern with the condition flags', async () => {
const user = userEvent.setup()
const onSave = vi.fn()
render(
<ParamOverrideEditorDialog
open
value={JSON.stringify({
operations: [
{
mode: 'set',
path: 'matched',
value: true,
conditions: [{ path: 'model', mode: 'full', value: 'gpt' }],
},
],
})}
onOpenChange={vi.fn()}
onSave={onSave}
/>
)
await user.click(screen.getByRole('button', { name: 'Expand All' }))
const mode = screen.getByRole('combobox', { name: 'Match Mode' })
await user.click(mode)
expect(mode).toHaveAttribute('aria-expanded', 'true')
await user.click(screen.getByRole('option', { name: 'Regex' }))
expect(mode).toHaveValue('Regex')
const pattern = String.raw`(?i)^gpt-\d`
fireEvent.change(screen.getByRole('textbox', { name: 'Match Value' }), {
target: { value: pattern },
})
await user.click(screen.getByRole('switch', { name: 'Invert match' }))
await user.click(
screen.getByRole('switch', { name: 'Pass when key is missing' })
)
await user.click(screen.getByRole('button', { name: 'Save' }))
expect(onSave).toHaveBeenCalledOnce()
expect(JSON.parse(onSave.mock.calls[0][0])).toEqual({
operations: [
{
mode: 'set',
path: 'matched',
value: true,
logic: 'OR',
conditions: [
{
path: 'model',
mode: 'regex',
value: pattern,
invert: true,
pass_missing_key: true,
},
],
},
],
})
})
test('importing and duplicating regex rules preserves patterns through JSON and visual modes', async () => {
const user = userEvent.setup()
const onSave = vi.fn()
const conditions = [
...['1e3', 'null', '[0, 1]', '"quoted"', ' padded ', ''].map((value) => ({
path: 'model',
mode: 'regex',
value,
})),
{ path: 'seed', mode: 'full', value: 1000 },
]
const operation = {
mode: 'set',
path: 'matched',
value: true,
logic: 'AND',
conditions,
}
render(
<ParamOverrideEditorDialog
open
value={JSON.stringify({ operations: [operation] })}
onOpenChange={vi.fn()}
onSave={onSave}
/>
)
await user.click(screen.getByRole('button', { name: 'Duplicate' }))
await user.click(screen.getByRole('button', { name: 'JSON Text' }))
await user.click(screen.getByRole('button', { name: 'Visual' }))
await user.click(screen.getByRole('button', { name: 'Save' }))
expect(onSave).toHaveBeenCalledOnce()
expect(JSON.parse(onSave.mock.calls[0][0])).toEqual({
operations: [operation, operation],
})
})
test('prune-object conditions offer Regex and preserve literal and empty patterns when edited', async () => {
const user = userEvent.setup()
const onSave = vi.fn()
const operation = {
mode: 'prune_objects',
path: 'messages',
value: {
conditions: [{ path: 'role', mode: 'full', value: 'system' }],
},
}
render(
<ParamOverrideEditorDialog
open
value={JSON.stringify({ operations: [operation] })}
onOpenChange={vi.fn()}
onSave={onSave}
/>
)
await user.click(screen.getByRole('combobox', { name: 'Match Mode' }))
await user.click(screen.getByRole('option', { name: 'Regex' }))
const matchValue = screen.getByRole('textbox', {
name: 'Match Value (optional)',
})
fireEvent.change(matchValue, { target: { value: ' 1e3 ' } })
await user.click(screen.getByRole('button', { name: 'Save' }))
expect(JSON.parse(onSave.mock.calls[0][0])).toEqual({
operations: [
{
...operation,
value: {
conditions: [{ path: 'role', mode: 'regex', value: ' 1e3 ' }],
},
},
],
})
await user.clear(matchValue)
await user.click(screen.getByRole('button', { name: 'Save' }))
expect(onSave).toHaveBeenCalledTimes(2)
expect(JSON.parse(onSave.mock.calls[1][0])).toEqual({
operations: [
{
...operation,
value: {
conditions: [{ path: 'role', mode: 'regex', value: '' }],
},
},
],
})
})
@@ -0,0 +1,352 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { StrictMode, useState } from 'react'
import { afterEach, beforeEach, expect, test, vi } from 'vitest'
import { CHANNEL_PRIORITY_GUIDE_KEYS } from '../../constants'
import { ChannelPriorityModeToggle } from '../channel-priority-controls'
function Fixture(props: { reason?: 'sort'; viewMode?: 'card' | 'table' }) {
const [active, setActive] = useState(false)
return (
<ChannelPriorityModeToggle
active={active}
viewMode={props.viewMode ?? 'table'}
reason={props.reason ?? null}
onChange={() => setActive((value) => !value)}
/>
)
}
beforeEach(() => localStorage.clear())
afterEach(() => {
cleanup()
vi.useRealTimers()
vi.restoreAllMocks()
localStorage.clear()
})
test('drag mode and its help are presented as one accessible control group', () => {
render(<Fixture />)
const group = within(screen.getByRole('group', { name: 'Drag mode' }))
expect(group.getByRole('button', { name: 'Drag mode' })).toBeVisible()
expect(group.getByRole('button', { name: 'Drag mode help' })).toBeVisible()
})
test('seeing the table guide does not hide the first card guide', async () => {
localStorage.setItem(CHANNEL_PRIORITY_GUIDE_KEYS.table, 'seen')
const user = userEvent.setup()
const page = render(<Fixture viewMode='card' />)
await user.click(screen.getByRole('button', { name: 'Drag mode' }))
const guide = within(await screen.findByRole('dialog'))
expect(guide.getByRole('list', { name: 'Card view' })).toBeVisible()
await user.click(guide.getByRole('button', { name: 'Got it' }))
await waitFor(() =>
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
)
page.unmount()
render(<Fixture viewMode='card' />)
await user.click(screen.getByRole('button', { name: 'Drag mode' }))
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
test('first activation shows the guide and remembers dismissal across visits', async () => {
const user = userEvent.setup()
const page = render(
<StrictMode>
<Fixture />
</StrictMode>
)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Drag mode' }))
const guide = await screen.findByRole('dialog', {
name: 'Drag to set channel priority',
})
expect(within(guide).getByText('Join the same priority tier')).toBeVisible()
expect(within(guide).getByText('Insert a new priority tier')).toBeVisible()
await user.click(within(guide).getByRole('button', { name: 'Got it' }))
await waitFor(() =>
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
)
expect(localStorage.getItem(CHANNEL_PRIORITY_GUIDE_KEYS.table)).toBe('seen')
expect(
screen.getByRole('button', { name: 'Finish reordering' })
).toHaveAttribute('aria-pressed', 'true')
page.unmount()
render(<Fixture />)
await user.click(screen.getByRole('button', { name: 'Drag mode' }))
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
test('returning users can reopen the demo and Escape returns focus to help', async () => {
localStorage.setItem(CHANNEL_PRIORITY_GUIDE_KEYS.table, 'seen')
const user = userEvent.setup()
render(<Fixture />)
const help = screen.getByRole('button', { name: 'Drag mode help' })
help.focus()
await user.keyboard('{Enter}')
expect(
await screen.findByRole('dialog', { name: 'Drag to set channel priority' })
).toBeVisible()
await user.keyboard('{Escape}')
await waitFor(() =>
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
)
await waitFor(() => expect(help).toHaveFocus())
expect(screen.getByRole('button', { name: 'Drag mode' })).toHaveAttribute(
'aria-pressed',
'false'
)
})
test('an unavailable drag mode keeps help accessible without enabling reordering', async () => {
const user = userEvent.setup()
render(<Fixture reason='sort' />)
expect(screen.getByRole('button', { name: 'Drag mode' })).toBeDisabled()
await user.click(screen.getByRole('button', { name: 'Drag mode help' }))
await user.click(screen.getByRole('button', { name: 'Got it' }))
expect(screen.getByRole('button', { name: 'Drag mode' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Drag mode' })).toHaveAttribute(
'aria-pressed',
'false'
)
})
test('unavailable browser storage does not block the guide or repeat it within the visit', async () => {
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('Unavailable')
})
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('Unavailable')
})
const user = userEvent.setup()
render(<Fixture />)
await user.click(screen.getByRole('button', { name: 'Drag mode' }))
await user.click(screen.getByRole('button', { name: 'Got it' }))
await waitFor(() =>
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
)
await user.click(screen.getByRole('button', { name: 'Finish reordering' }))
await user.click(screen.getByRole('button', { name: 'Drag mode' }))
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
test.each(['table', 'card'] as const)(
'%s guide automatically plays both distinct demos and loops after showing each result',
(viewMode) => {
const original = window.matchMedia
vi.spyOn(window, 'matchMedia').mockImplementation((query) => ({
...original(query),
matches: false,
}))
vi.useFakeTimers()
render(<Fixture viewMode={viewMode} />)
fireEvent.click(screen.getByRole('button', { name: 'Drag mode' }))
const guide = within(screen.getByRole('dialog'))
const joinTab = guide.getByRole('tab', {
name: 'Join the same priority tier',
})
const insertTab = guide.getByRole('tab', {
name: 'Insert a new priority tier',
})
expect(joinTab).toHaveAttribute('aria-selected', 'true')
expect(
guide.getByRole('list', {
name: viewMode === 'card' ? 'Card view' : 'Table view',
})
).toBeVisible()
act(() => vi.advanceTimersByTime(1600))
expect(guide.getByRole('status')).toHaveTextContent(
'Release to join priority 80'
)
act(() => vi.advanceTimersByTime(1400))
expect(guide.getByRole('status')).toHaveTextContent('New priority: 80')
act(() => vi.advanceTimersByTime(2199))
expect(joinTab).toHaveAttribute('aria-selected', 'true')
expect(guide.getByRole('status')).toHaveTextContent('New priority: 80')
act(() => vi.advanceTimersByTime(1))
expect(insertTab).toHaveAttribute('aria-selected', 'true')
expect(guide.getByRole('status')).toHaveTextContent(
'Drag the handle to move a channel.'
)
expect(guide.getByText('Channel C').closest('li')).toHaveTextContent(
'Priority 0'
)
act(() => vi.advanceTimersByTime(1600))
expect(guide.getByRole('status')).toHaveTextContent(
'Release to insert a new priority tier'
)
if (viewMode === 'table') {
expect(
guide.getAllByRole('listitem').map((row) => row.textContent)
).toEqual([
'Channel APriority 100',
'Channel BPriority 80',
'Channel CPriority 90',
])
}
act(() => vi.advanceTimersByTime(1400))
expect(guide.getByRole('status')).toHaveTextContent('New priority: 90')
expect(
guide.getAllByRole('listitem').map((row) => row.textContent)
).toEqual([
'Channel APriority 100',
'Channel CPriority 90',
'Channel BPriority 80',
])
act(() => vi.advanceTimersByTime(2200))
expect(joinTab).toHaveAttribute('aria-selected', 'true')
expect(guide.getByText('Channel C').closest('li')).toHaveTextContent(
'Priority 0'
)
fireEvent.click(guide.getByRole('button', { name: 'Got it' }))
act(() => vi.advanceTimersByTime(10000))
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Drag mode help' }))
const reopened = within(screen.getByRole('dialog'))
expect(
reopened.getByRole('tab', { name: 'Join the same priority tier' })
).toHaveAttribute('aria-selected', 'true')
expect(reopened.getByText('Channel C').closest('li')).toHaveTextContent(
'Priority 0'
)
}
)
test('autoplay can pause, resume from the same point, and replay the complete sequence', () => {
const original = window.matchMedia
vi.spyOn(window, 'matchMedia').mockImplementation((query) => ({
...original(query),
matches: false,
}))
vi.useFakeTimers()
render(<Fixture />)
fireEvent.click(screen.getByRole('button', { name: 'Drag mode help' }))
const guide = within(screen.getByRole('dialog'))
act(() => vi.advanceTimersByTime(1600))
fireEvent.click(guide.getByRole('button', { name: 'Pause drag demo' }))
act(() => vi.advanceTimersByTime(15000))
expect(guide.getByRole('status')).toHaveTextContent(
'Release to join priority 80'
)
fireEvent.click(guide.getByRole('button', { name: 'Resume drag demo' }))
act(() => vi.advanceTimersByTime(1399))
expect(guide.getByRole('status')).toHaveTextContent(
'Release to join priority 80'
)
act(() => vi.advanceTimersByTime(1))
expect(guide.getByRole('status')).toHaveTextContent('New priority: 80')
act(() => vi.advanceTimersByTime(2200))
expect(
guide.getByRole('tab', { name: 'Insert a new priority tier' })
).toHaveAttribute('aria-selected', 'true')
act(() => vi.advanceTimersByTime(1600))
fireEvent.click(guide.getByRole('button', { name: 'Replay drag demo' }))
expect(
guide.getByRole('tab', { name: 'Join the same priority tier' })
).toHaveAttribute('aria-selected', 'true')
expect(guide.getByText('Channel C').closest('li')).toHaveTextContent(
'Priority 0'
)
act(() => vi.advanceTimersByTime(5200))
expect(
guide.getByRole('tab', { name: 'Insert a new priority tier' })
).toHaveAttribute('aria-selected', 'true')
})
test('manually selecting a demo starts its own complete playback without an old automatic switch', () => {
const original = window.matchMedia
vi.spyOn(window, 'matchMedia').mockImplementation((query) => ({
...original(query),
matches: false,
}))
vi.useFakeTimers()
render(<Fixture />)
fireEvent.click(screen.getByRole('button', { name: 'Drag mode help' }))
const guide = within(screen.getByRole('dialog'))
const insertTab = guide.getByRole('tab', {
name: 'Insert a new priority tier',
})
act(() => vi.advanceTimersByTime(4800))
fireEvent.click(insertTab)
act(() => vi.advanceTimersByTime(400))
expect(insertTab).toHaveAttribute('aria-selected', 'true')
expect(guide.getByRole('status')).toHaveTextContent(
'Drag the handle to move a channel.'
)
act(() => vi.advanceTimersByTime(1200))
expect(guide.getByRole('status')).toHaveTextContent(
'Release to insert a new priority tier'
)
act(() => vi.advanceTimersByTime(3600))
expect(
guide.getByRole('tab', { name: 'Join the same priority tier' })
).toHaveAttribute('aria-selected', 'true')
})
test('reduced motion shows the result of the selected demo immediately', async () => {
const original = window.matchMedia
vi.spyOn(window, 'matchMedia').mockImplementation((query) => ({
...original(query),
matches: query === '(prefers-reduced-motion: reduce)',
}))
render(<Fixture />)
await userEvent
.setup()
.click(screen.getByRole('button', { name: 'Drag mode help' }))
const guide = within(await screen.findByRole('dialog'))
expect(guide.getByRole('status')).toHaveTextContent('New priority: 80')
expect(guide.getByText('Channel C').closest('li')).toHaveTextContent(
'Priority 80'
)
await userEvent
.setup()
.click(guide.getByRole('tab', { name: 'Insert a new priority tier' }))
expect(guide.getByRole('status')).toHaveTextContent('New priority: 90')
expect(guide.getByText('Channel C').closest('li')).toHaveTextContent(
'Priority 90'
)
})
test('keyboard selection switches to the other explicitly named demo', async () => {
const user = userEvent.setup()
render(<Fixture />)
await user.click(screen.getByRole('button', { name: 'Drag mode help' }))
const guide = within(await screen.findByRole('dialog'))
guide.getByRole('tab', { name: 'Join the same priority tier' }).focus()
await user.keyboard('{ArrowRight}')
expect(
guide.getByRole('tab', { name: 'Insert a new priority tier' })
).toHaveFocus()
await user.keyboard('{Enter}')
expect(
guide.getByRole('tab', { name: 'Insert a new priority tier' })
).toHaveAttribute('aria-selected', 'true')
expect(guide.getByRole('status')).toHaveTextContent('New priority: 90')
})
@@ -0,0 +1,889 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import {
createMemoryHistory,
createRootRoute,
createRoute,
createRouter,
RouterProvider,
} from '@tanstack/react-router'
import {
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, expect, test, vi } from 'vitest'
import { api } from '@/lib/api'
import { useAuthStore } from '@/stores/auth-store'
import { CHANNEL_PRIORITY_GUIDE_KEYS } from '../../constants'
import type {
PriorityMove,
PriorityOrder,
} from '../../lib/channel-priority-order'
import type { Channel } from '../../types'
import { ChannelsPrimaryButtons } from '../channels-primary-buttons'
import { ChannelsProvider } from '../channels-provider'
import { ChannelsTable } from '../channels-table'
const initial: PriorityOrder = {
revision: 'initial',
items: [
{ id: 1, priority: 100 },
{ id: 2, priority: 80 },
{ id: 3, priority: 0 },
],
changes: [],
}
const applied: PriorityOrder = {
revision: 'applied',
items: [
{ id: 1, priority: 100 },
{ id: 2, priority: 80 },
{ id: 3, priority: 90 },
],
changes: [{ id: 3, previous: 0, priority: 90 }],
}
let current: PriorityOrder
let requests: PriorityMove[]
let failSave: boolean
const pointerMethods = [
'setPointerCapture',
'releasePointerCapture',
'hasPointerCapture',
] as const
const originalPointerMethods = pointerMethods.map(
(name) =>
[
name,
Object.getOwnPropertyDescriptor(HTMLElement.prototype, name),
] as const
)
afterEach(() => {
vi.unstubAllGlobals()
for (const [name, descriptor] of originalPointerMethods) {
if (descriptor) {
Object.defineProperty(HTMLElement.prototype, name, descriptor)
} else Reflect.deleteProperty(HTMLElement.prototype, name)
}
localStorage.clear()
})
function channel(item: { id: number; priority: number | null }): Channel {
return {
id: item.id,
priority: item.priority,
name: `Channel ${item.id}`,
type: 1,
status: 1,
group: 'default',
models: 'test-model',
weight: 10,
balance: 0,
response_time: 0,
test_time: 0,
channel_info: {},
} as Channel
}
beforeEach(() => {
current = initial
requests = []
failSave = false
localStorage.clear()
for (const key of Object.values(CHANNEL_PRIORITY_GUIDE_KEYS)) {
localStorage.setItem(key, 'seen')
}
useAuthStore.getState().auth.setUser({
id: 1,
username: 'test',
display_name: 'Test',
role: 100,
group: 'default',
})
vi.spyOn(api, 'get').mockImplementation(async (url, config) => {
if (url === '/api/channel/priority/order') {
return { data: { success: true, data: current } }
}
if (url === '/api/channel' || url === '/api/channel/search') {
const field = config?.params?.sort_by ?? 'priority'
const desc = config?.params?.sort_order !== 'asc'
const items = current.items
.map(channel)
.sort(
(a, b) =>
((Number(a[field as keyof Channel]) || 0) -
(Number(b[field as keyof Channel]) || 0)) *
(desc ? -1 : 1) || b.id - a.id
)
return {
data: {
success: true,
data: { items, total: 3, type_counts: { 1: 3 } },
},
}
}
if (url === '/api/group/') {
return { data: { success: true, data: ['default'] } }
}
return { data: { success: true, data: [] } }
})
vi.spyOn(api, 'post').mockImplementation(async (url, body) => {
if (url !== '/api/channel/priority/reorder') {
return { data: { success: true } }
}
const move = body as PriorityMove
requests.push(move)
if (failSave && !move.preview) {
return {
data: {
success: false,
message: 'Channel priorities changed. Refresh and try again.',
},
}
}
const joined: PriorityOrder = {
revision: 'joined',
items: [
{ id: 1, priority: 100 },
{ id: 2, priority: 80 },
{ id: 3, priority: 80 },
],
changes: [{ id: 3, previous: 0, priority: 80 }],
}
let result = applied
if (move.restore) result = initial
else if (move.position === 'same') result = joined
if (!move.preview) current = result
return { data: { success: true, data: result } }
})
})
async function mount(view: 'table' | 'card' = 'card', enabled = true) {
localStorage.setItem('channels:view-mode', view)
const root = createRootRoute()
const authenticated = createRoute({
getParentRoute: () => root,
id: '_authenticated',
})
const channels = createRoute({
getParentRoute: () => authenticated,
path: '/channels/',
validateSearch: (search) => ({ page: 1, pageSize: 10, ...search }),
component: () => (
<ChannelsProvider>
<header>
<ChannelsPrimaryButtons />
</header>
<ChannelsTable />
</ChannelsProvider>
),
})
const router = createRouter({
routeTree: root.addChildren([authenticated.addChildren([channels])]),
history: createMemoryHistory({ initialEntries: ['/channels/'] }),
})
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})
render(
<QueryClientProvider client={client}>
<RouterProvider router={router} />
</QueryClientProvider>
)
await screen.findByRole('button', { name: 'Drag mode' })
if (enabled) {
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Drag mode' })).toBeEnabled()
)
await userEvent
.setup()
.click(screen.getByRole('button', { name: 'Drag mode' }))
await screen.findByRole('button', { name: 'Reorder channel 3' })
}
return { client, router }
}
function orderInView() {
return [...document.querySelectorAll<HTMLElement>('[data-sortable-id]')].map(
(element) => element.dataset.sortableId
)
}
test.each(['table', 'card'] as const)(
'%s view opens the matching tutorial without enabling drag mode',
async (view) => {
await mount(view, false)
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: 'Drag mode help' }))
const guide = within(await screen.findByRole('dialog'))
expect(
guide.getByRole('list', {
name: view === 'card' ? 'Card view' : 'Table view',
})
).toBeVisible()
await user.click(guide.getByRole('button', { name: 'Got it' }))
await waitFor(() =>
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
)
expect(screen.getByRole('button', { name: 'Drag mode' })).toHaveAttribute(
'aria-pressed',
'false'
)
expect(requests).toEqual([])
}
)
test.each(['table', 'card'] as const)(
'%s keyboard reorder saves priority and undo restores it',
async (view) => {
await mount(view)
const user = userEvent.setup()
fireEvent.keyDown(
screen.getByRole('button', { name: 'Reorder channel 3' }),
{ key: 'ArrowUp' }
)
await screen.findByRole('button', { name: 'Undo' })
await waitFor(() =>
expect(orderInView()).toEqual(['channel:1', 'channel:3', 'channel:2'])
)
expect(requests.filter((request) => !request.preview)).toEqual([
{ id: 3, target_id: 2, position: 'before', revision: 'initial' },
])
await user.click(screen.getByRole('button', { name: 'Undo' }))
await waitFor(() =>
expect(orderInView()).toEqual(['channel:1', 'channel:2', 'channel:3'])
)
expect(requests.at(-1)).toEqual({
revision: 'applied',
restore: [{ id: 3, previous: 90, priority: 0 }],
})
}
)
test('switching sort exits reorder mode without automatically reopening it', async () => {
await mount()
const user = userEvent.setup()
await user.click(screen.getByRole('combobox', { name: 'Sort channels by' }))
await user.click(await screen.findByRole('option', { name: /^ID$/ }))
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Drag mode' })).toBeDisabled()
)
expect(
screen.queryByRole('button', { name: 'Reorder channel 3' })
).not.toBeInTheDocument()
await user.click(screen.getByRole('combobox', { name: 'Sort channels by' }))
await user.click(await screen.findByRole('option', { name: /^Priority$/ }))
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Drag mode' })).toBeEnabled()
)
expect(
screen.queryByRole('button', { name: 'Reorder channel 3' })
).not.toBeInTheDocument()
expect(requests).toEqual([])
})
test('failed save restores the visible order and does not offer undo', async () => {
failSave = true
await mount()
fireEvent.keyDown(screen.getByRole('button', { name: 'Reorder channel 3' }), {
key: 'ArrowUp',
})
await waitFor(() => expect(requests).toHaveLength(1))
await waitFor(() =>
expect(
screen.getByRole('button', { name: 'Reorder channel 3' })
).toBeEnabled()
)
expect(orderInView()).toEqual(['channel:1', 'channel:2', 'channel:3'])
expect(screen.queryByRole('button', { name: 'Undo' })).not.toBeInTheDocument()
})
test('card and table views share the saved order', async () => {
await mount()
const user = userEvent.setup()
fireEvent.keyDown(screen.getByRole('button', { name: 'Reorder channel 3' }), {
key: 'ArrowUp',
})
await screen.findByRole('button', { name: 'Undo' })
const views = within(screen.getByRole('group', { name: 'View mode' }))
views.getByRole('button', { name: 'Card view' }).focus()
await user.tab()
expect(views.getByRole('button', { name: 'Table view' })).toHaveFocus()
await user.keyboard('{Enter}')
await screen.findByRole('table')
expect(views.getByRole('button', { name: 'Table view' })).toHaveAttribute(
'aria-pressed',
'true'
)
expect(views.getByRole('button', { name: 'Card view' })).toHaveAttribute(
'aria-pressed',
'false'
)
expect(orderInView()).toEqual(['channel:1', 'channel:3', 'channel:2'])
})
test('mobile view controls stay available with the filter panel collapsed', async () => {
const original = window.matchMedia
vi.spyOn(window, 'matchMedia').mockImplementation((query) => ({
...original(query),
matches: query === '(max-width: 640px)',
}))
await mount('card', false)
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: 'Collapse' }))
expect(
screen.queryByPlaceholderText('Filter by name, ID, or key...')
).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Refresh' })).toBeVisible()
expect(screen.getByRole('button', { name: 'Drag mode' })).toBeVisible()
await user.click(screen.getByRole('button', { name: 'Table view' }))
expect(await screen.findByRole('table')).toBeVisible()
await user.click(screen.getByRole('button', { name: 'View' }))
expect(
await screen.findByRole('menuitemcheckbox', { name: 'Weight' })
).toBeVisible()
})
function pointerGeometry(cardColumns = 3) {
class TestPointerEvent extends MouseEvent {
pointerId: number
constructor(type: string, init: PointerEventInit) {
super(type, init)
this.pointerId = init.pointerId ?? 1
}
}
vi.stubGlobal('PointerEvent', TestPointerEvent)
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(
function (this: HTMLElement) {
if (this.dataset.sortableId) {
const index = [
...document.querySelectorAll('[data-sortable-id]'),
].indexOf(this)
if (this.tagName === 'TR') {
return new DOMRect(0, 100 + index * 60, 1000, 60)
}
return new DOMRect(
(index % cardColumns) * 320,
100 + Math.floor(index / cardColumns) * 220,
300,
200
)
}
return new DOMRect(0, 0, 1200, 800)
}
)
for (const name of [
'setPointerCapture',
'releasePointerCapture',
'hasPointerCapture',
]) {
Object.defineProperty(HTMLElement.prototype, name, {
configurable: true,
value: () => false,
})
}
}
test.each([
{
startY: 174,
jitterY: 169,
position: 'same',
hint: 'Release to join priority 80',
},
{
startY: 165,
jitterY: 171,
position: 'before',
hint: 'Release to insert a new priority tier',
},
] as const)(
'table preserves $position mode through small pointer movements',
async ({ startY, jitterY, position, hint }) => {
await mount('table')
pointerGeometry()
const scope = screen.getByRole('region', { name: 'Channels' })
fireEvent.pointerDown(
screen.getByRole('button', { name: 'Reorder channel 3' }),
{ pointerId: 1, button: 0, clientX: 20, clientY: 250 }
)
fireEvent.pointerMove(scope, { pointerId: 1, clientX: 20, clientY: startY })
await screen.findByText(hint)
fireEvent.pointerMove(scope, {
pointerId: 1,
clientX: 20,
clientY: jitterY,
})
fireEvent.pointerUp(scope, { pointerId: 1, clientX: 20, clientY: jitterY })
await screen.findByRole('button', { name: 'Undo' })
expect(requests.filter((request) => !request.preview)).toEqual([
{ id: 3, target_id: 2, position, revision: 'initial' },
])
}
)
test('the compact table preview respects masked channel names', async () => {
await mount('table')
await userEvent.setup().click(screen.getByRole('button', { name: /^Hide$/ }))
pointerGeometry()
const scope = screen.getByRole('region', { name: 'Channels' })
fireEvent.pointerDown(
screen.getByRole('button', { name: 'Reorder channel 3' }),
{ pointerId: 1, button: 0, clientX: 20, clientY: 250 }
)
fireEvent.pointerMove(scope, { pointerId: 1, clientX: 20, clientY: 190 })
await screen.findByText('Release to join priority 80')
const ghost = document.querySelector('[data-origin-x]')
expect(ghost).not.toHaveTextContent('Channel 3')
expect(ghost).toHaveTextContent('••••')
fireEvent.keyDown(window, { key: 'Escape' })
expect(requests.filter((request) => !request.preview)).toEqual([])
})
test('table targets stay in place and the compact preview leaves the target rows visible', async () => {
await mount('table')
pointerGeometry()
const scope = screen.getByRole('region', { name: 'Channels' })
fireEvent.pointerDown(
screen.getByRole('button', { name: 'Reorder channel 3' }),
{ pointerId: 1, button: 0, clientX: 20, clientY: 250 }
)
fireEvent.pointerMove(scope, { pointerId: 1, clientX: 20, clientY: 105 })
await screen.findByText('Release to insert a new priority tier')
for (const row of document.querySelectorAll<HTMLElement>(
'[data-sortable-id]'
)) {
expect(row.style.transform).toBe('')
}
const ghost = document.querySelector<HTMLElement>('[data-origin-x]')
expect(ghost?.querySelector('tr')).toBeNull()
expect(
Number.parseFloat(ghost?.style.width ?? 'Infinity')
).toBeLessThanOrEqual(320)
expect(ghost).toHaveTextContent('Channel 3')
fireEvent.pointerMove(scope, { pointerId: 1, clientX: 20, clientY: 190 })
await screen.findByText('Release to join priority 80')
fireEvent.pointerUp(scope, { pointerId: 1, clientX: 20, clientY: 190 })
await screen.findByRole('button', { name: 'Undo' })
expect(requests.filter((request) => !request.preview)).toEqual([
{ id: 3, target_id: 2, position: 'same', revision: 'initial' },
])
for (const row of document.querySelectorAll<HTMLElement>(
'[data-sortable-id]'
)) {
expect(row.style.transform).toBe('')
}
})
test('dropping below the table rows cancels instead of selecting a distant row', async () => {
await mount('table')
pointerGeometry()
const scope = screen.getByRole('region', { name: 'Channels' })
fireEvent.pointerDown(
screen.getByRole('button', { name: 'Reorder channel 3' }),
{ pointerId: 1, button: 0, clientX: 20, clientY: 250 }
)
fireEvent.pointerMove(scope, { pointerId: 1, clientX: 20, clientY: 190 })
await screen.findByText('Release to join priority 80')
fireEvent.pointerMove(scope, { pointerId: 1, clientX: 20, clientY: 400 })
await waitFor(() =>
expect(
screen.queryByText('Release to join priority 80')
).not.toBeInTheDocument()
)
fireEvent.pointerUp(scope, { pointerId: 1, clientX: 20, clientY: 400 })
await waitFor(() =>
expect(document.querySelector('[data-origin-x]')).not.toBeInTheDocument()
)
expect(requests.filter((request) => !request.preview)).toEqual([])
})
test('a card alone on the last grid row still uses horizontal edge targets', async () => {
await mount()
pointerGeometry(2)
const scope = screen.getByRole('region', { name: 'Channels' })
fireEvent.pointerDown(
screen.getByRole('button', { name: 'Reorder channel 3' }),
{ pointerId: 1, button: 0, clientX: 20, clientY: 350 }
)
fireEvent.pointerMove(scope, { pointerId: 1, clientX: 340, clientY: 150 })
await screen.findByText('Release to insert a new priority tier')
fireEvent.pointerUp(scope, { pointerId: 1, clientX: 340, clientY: 150 })
await screen.findByRole('button', { name: 'Undo' })
expect(requests.filter((request) => !request.preview)).toEqual([
{ id: 3, target_id: 2, position: 'before', revision: 'initial' },
])
})
test('a quick card pointer drag saves even when released before an animation frame', async () => {
await mount()
pointerGeometry()
const handle = screen.getByRole('button', { name: 'Reorder channel 3' })
const scope = screen.getByRole('region', { name: 'Channels' })
fireEvent.pointerDown(handle, {
pointerId: 1,
button: 0,
clientX: 660,
clientY: 130,
})
fireEvent.pointerMove(scope, { pointerId: 1, clientX: 340, clientY: 150 })
fireEvent.pointerUp(scope, { pointerId: 1, clientX: 340, clientY: 150 })
fireEvent.lostPointerCapture(scope, { pointerId: 1 })
await screen.findByRole('button', { name: 'Undo' })
expect(requests.filter((request) => !request.preview)).toEqual([
{ id: 3, target_id: 2, position: 'before', revision: 'initial' },
])
await waitFor(() =>
expect(document.querySelector('[data-origin-x]')).not.toBeInTheDocument()
)
vi.unstubAllGlobals()
})
test('Escape cancels a card drag without saving and removes its ghost', async () => {
await mount()
pointerGeometry()
const scope = screen.getByRole('region', { name: 'Channels' })
fireEvent.pointerDown(
screen.getByRole('button', { name: 'Reorder channel 3' }),
{ pointerId: 1, button: 0, clientX: 660, clientY: 130 }
)
fireEvent.pointerMove(scope, { pointerId: 1, clientX: 340, clientY: 150 })
await waitFor(() =>
expect(document.querySelector('[data-origin-x]')).toBeInTheDocument()
)
fireEvent.keyDown(window, { key: 'Escape' })
await waitFor(() =>
expect(document.querySelector('[data-origin-x]')).not.toBeInTheDocument()
)
expect(requests.filter((request) => !request.preview)).toEqual([])
expect(orderInView()).toEqual(['channel:1', 'channel:2', 'channel:3'])
vi.unstubAllGlobals()
})
test('moving from a card edge to its center joins the tier despite the moving placeholder', async () => {
await mount()
pointerGeometry()
const scope = screen.getByRole('region', { name: 'Channels' })
fireEvent.pointerDown(
screen.getByRole('button', { name: 'Reorder channel 3' }),
{ pointerId: 1, button: 0, clientX: 660, clientY: 130 }
)
fireEvent.pointerMove(scope, { pointerId: 1, clientX: 340, clientY: 150 })
await waitFor(() =>
expect(document.querySelector('[data-origin-x]')).toBeInTheDocument()
)
expect(orderInView()).toEqual(['channel:1', 'channel:2', 'channel:3'])
expect(requests.every((request) => request.preview)).toBe(true)
fireEvent.pointerMove(scope, { pointerId: 1, clientX: 470, clientY: 150 })
fireEvent.pointerUp(scope, { pointerId: 1, clientX: 470, clientY: 150 })
await screen.findByRole('button', { name: 'Undo' })
expect(requests.filter((request) => !request.preview)).toEqual([
{ id: 3, target_id: 2, position: 'same', revision: 'initial' },
])
await waitFor(() =>
expect(document.querySelector('[data-origin-x]')).not.toBeInTheDocument()
)
vi.unstubAllGlobals()
})
test('tag grouping disables reorder mode', async () => {
localStorage.setItem('enable-tag-mode', 'true')
await mount('card', false)
expect(
screen.getByRole('button', {
name: 'Drag mode',
})
).toBeDisabled()
expect(requests).toEqual([])
})
test.each(['table', 'card'] as const)(
'%s drag handles are hidden by default',
async (view) => {
await mount(view, false)
expect(
screen.queryByRole('button', { name: 'Reorder channel 3' })
).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Drag mode' })).toHaveAttribute(
'aria-pressed',
'false'
)
}
)
test.each(['table', 'card'] as const)(
'%s reordering can be explicitly enabled and finished',
async (view) => {
await mount(view, false)
const user = userEvent.setup()
screen.getByRole('button', { name: 'Drag mode' }).focus()
await user.keyboard('{Enter}')
expect(
screen.getByRole('button', { name: 'Reorder channel 3' })
).toBeEnabled()
expect(
screen.getByRole('button', { name: 'Finish reordering' })
).toHaveAttribute('aria-pressed', 'true')
await user.click(screen.getByRole('button', { name: 'Finish reordering' }))
expect(
screen.queryByRole('button', { name: 'Reorder channel 3' })
).not.toBeInTheDocument()
expect(requests).toEqual([])
expect(api.get).not.toHaveBeenCalledWith('/api/channel/priority/order')
}
)
test('the page header provides the single sorting control without an ID switch', async () => {
await mount('card', false)
expect(
screen.queryByRole('switch', { name: 'Sort by ID' })
).not.toBeInTheDocument()
expect(
screen.getAllByRole('combobox', { name: 'Sort channels by' })
).toHaveLength(1)
expect(
within(screen.getByRole('banner')).getByRole('combobox', {
name: 'Sort channels by',
})
).toBeEnabled()
expect(
within(screen.getByRole('region', { name: 'Channels' })).queryByRole(
'combobox',
{ name: 'Sort channels by' }
)
).not.toBeInTheDocument()
})
test('the header places reorder before batch mode while shared display controls stay in the table toolbar', async () => {
await mount('card', false)
const user = userEvent.setup()
const header = within(screen.getByRole('banner'))
const toolbar = within(screen.getByRole('region', { name: 'Channels' }))
const reorder = header.getByRole('button', { name: 'Drag mode' })
reorder.focus()
await user.tab()
expect(header.getByRole('button', { name: 'Drag mode help' })).toHaveFocus()
await user.tab()
expect(header.getByRole('switch', { name: 'Batch Operations' })).toHaveFocus()
expect(
toolbar.queryByRole('button', { name: 'Drag mode' })
).not.toBeInTheDocument()
expect(toolbar.getByRole('button', { name: 'Hide' })).toBeVisible()
expect(toolbar.getByRole('group', { name: 'View mode' })).toBeVisible()
})
test('the header honors the saved ID preference and changes the shared sort direction', async () => {
localStorage.setItem('channels-id-sort', 'true')
await mount('card', false)
const header = within(screen.getByRole('banner'))
expect(
header.getByRole('combobox', { name: 'Sort channels by' })
).toHaveTextContent('ID')
await waitFor(() =>
expect(orderInView()).toEqual(['channel:3', 'channel:2', 'channel:1'])
)
const sort = within(header.getByRole('group', { name: 'Sort channels by' }))
sort.getByRole('combobox', { name: 'Sort channels by' }).focus()
const user = userEvent.setup()
await user.tab()
expect(sort.getByRole('button', { name: 'Descending' })).toHaveFocus()
await user.keyboard('{Enter}')
await waitFor(() =>
expect(orderInView()).toEqual(['channel:1', 'channel:2', 'channel:3'])
)
expect(header.getByRole('button', { name: 'Ascending' })).toBeEnabled()
expect(screen.getByRole('button', { name: 'Drag mode' })).toBeDisabled()
})
test('the compact more-actions button opens by keyboard and restores focus on Escape', async () => {
await mount('card', false)
const user = userEvent.setup()
const more = within(screen.getByRole('banner')).getByRole('button', {
name: 'More actions',
})
more.focus()
await user.keyboard('{Enter}')
expect(more).toHaveAttribute('aria-expanded', 'true')
expect(
await screen.findByRole('menuitem', { name: 'Test All Channels' })
).toBeVisible()
await user.keyboard('{Escape}')
await waitFor(() => expect(more).toHaveFocus())
expect(more).toHaveAttribute('aria-expanded', 'false')
})
test('sorting a table column updates the header selection and remains active in card view', async () => {
await mount('table', false)
const user = userEvent.setup()
await user.click(
within(screen.getByRole('table')).getByRole('button', {
name: 'Name',
})
)
await user.click(await screen.findByRole('menuitem', { name: 'Asc' }))
const header = within(screen.getByRole('banner'))
expect(
header.getByRole('combobox', { name: 'Sort channels by' })
).toHaveTextContent('Name')
expect(header.getByRole('button', { name: 'Ascending' })).toBeEnabled()
await user.click(screen.getByRole('button', { name: 'Card view' }))
expect(
header.getByRole('combobox', { name: 'Sort channels by' })
).toHaveTextContent('Name')
expect(screen.getByRole('button', { name: 'Drag mode' })).toBeDisabled()
})
test('dropping onto a card that moved into the original slot joins its priority tier', async () => {
await mount()
pointerGeometry()
const scope = screen.getByRole('region', { name: 'Channels' })
fireEvent.pointerDown(
screen.getByRole('button', { name: 'Reorder channel 3' }),
{ pointerId: 1, button: 0, clientX: 660, clientY: 130 }
)
fireEvent.pointerMove(scope, { pointerId: 1, clientX: 20, clientY: 150 })
await waitFor(() =>
expect(document.querySelector('[data-origin-x]')).toBeInTheDocument()
)
// Channel 2 now occupies the last slot that channel 3 originally occupied.
fireEvent.pointerMove(scope, { pointerId: 1, clientX: 790, clientY: 150 })
await waitFor(() =>
expect(screen.getByRole('status')).toHaveTextContent(
'Join the same priority tier'
)
)
expect(
document.querySelector(
'[data-origin-x] [data-column-id="priority"] [data-slot="numeric-spinner-value"]'
)
).toHaveTextContent('80')
expect(
document.querySelector('[data-sortable-id="channel:2"]')
).toHaveAttribute('data-priority-drop', 'same')
fireEvent.pointerUp(scope, { pointerId: 1, clientX: 790, clientY: 150 })
await screen.findByRole('button', { name: 'Undo' })
expect(requests.filter((request) => !request.preview)).toEqual([
{ id: 3, target_id: 2, position: 'same', revision: 'initial' },
])
})
test.each(['table', 'card'] as const)(
'%s keeps a held tier preview until Escape cancels it',
async (view) => {
await mount(view)
pointerGeometry()
const scope = screen.getByRole('region', { name: 'Channels' })
const source = document.querySelector(
'[data-sortable-id="channel:3"]'
) as HTMLElement
const start =
view === 'table'
? { clientX: 20, clientY: 250 }
: { clientX: 660, clientY: 130 }
const target =
view === 'table'
? { clientX: 20, clientY: 190 }
: { clientX: 470, clientY: 150 }
fireEvent.pointerDown(
screen.getByRole('button', { name: 'Reorder channel 3' }),
{ pointerId: 1, button: 0, ...start }
)
fireEvent.pointerMove(scope, { pointerId: 1, ...target })
await waitFor(() =>
expect(screen.getByRole('status')).toHaveTextContent(
'Join the same priority tier'
)
)
expect(source.isConnected).toBe(true)
expect(source).toHaveStyle({ opacity: '0.2' })
expect(
document.querySelector(
'[data-origin-x] [data-column-id="priority"] [data-slot="numeric-spinner-value"]'
)
).toHaveTextContent('80')
expect(
document.querySelector('[data-sortable-id="channel:2"]')
).toHaveAttribute('data-priority-drop', 'same')
fireEvent.keyDown(window, { key: 'Escape' })
await waitFor(() =>
expect(document.querySelector('[data-origin-x]')).not.toBeInTheDocument()
)
expect(
document.querySelector('[data-priority-drop]')
).not.toBeInTheDocument()
expect(source.style.opacity).toBe('')
expect(source.style.transform).toBe('')
expect(requests.filter((request) => !request.preview)).toEqual([])
}
)
test.each(['table', 'card'] as const)(
'%s distinguishes inserting a new tier from joining the target tier before release',
async (view) => {
await mount(view)
pointerGeometry()
const scope = screen.getByRole('region', { name: 'Channels' })
const start =
view === 'table'
? { clientX: 20, clientY: 250 }
: { clientX: 660, clientY: 130 }
const edge =
view === 'table'
? { clientX: 20, clientY: 165 }
: { clientX: 340, clientY: 150 }
const center =
view === 'table'
? { clientX: 20, clientY: 190 }
: { clientX: 470, clientY: 150 }
fireEvent.pointerDown(
screen.getByRole('button', { name: 'Reorder channel 3' }),
{ pointerId: 1, button: 0, ...start }
)
fireEvent.pointerMove(scope, { pointerId: 1, ...edge })
await screen.findByText('Release to insert a new priority tier')
expect(document.querySelector('[data-priority-insertion]')).toBeVisible()
expect(
document.querySelector('[data-priority-drop="same"]')
).not.toBeInTheDocument()
await screen.findByText('New priority: 90')
fireEvent.pointerMove(scope, { pointerId: 1, ...center })
await screen.findByText('Release to join priority 80')
expect(
screen.queryByText('Release to insert a new priority tier')
).not.toBeInTheDocument()
expect(
document.querySelector('[data-priority-insertion]')
).not.toBeVisible()
expect(
document.querySelector('[data-sortable-id="channel:2"]')
).toHaveAttribute('data-priority-drop', 'same')
fireEvent.keyDown(window, { key: 'Escape' })
await waitFor(() =>
expect(
screen.queryByText('Release to join priority 80')
).not.toBeInTheDocument()
)
}
)
@@ -51,7 +51,7 @@ const migratedProviders = [
{ type: 50, label: 'Kling', key: 'kling' },
{ type: 51, label: 'Jimeng', key: 'jimeng' },
{ type: 52, label: 'Vidu', key: 'vidu' },
{ type: 54, label: 'DoubaoVideo', key: 'doubao' },
{ type: 54, label: 'Doubao', key: 'doubao' },
{ type: 55, label: 'Sora', key: 'sora' },
]
const migratedPlugins = migratedProviders.map((provider) => ({
@@ -417,7 +417,7 @@ test('creation searches legacy names and translated names through the replacemen
fallbackLng: 'en',
resources: {
en: { translation: {} },
zh: { translation: { DoubaoVideo: '豆包视频' } },
zh: { translation: { Doubao: '豆包' } },
},
})
const select = vi.fn()
@@ -439,13 +439,13 @@ test('creation searches legacy names and translated names through the replacemen
</I18nextProvider>
)
const search = screen.getByRole('combobox')
await user.type(search, 'DoubaoVideo')
await user.type(search, 'Doubao')
expect(
screen.getByRole('option', { name: 'Ark Video Plugin doubao' })
).toBeVisible()
await act(() => language.changeLanguage('zh'))
await user.clear(search)
await user.type(search, '豆包视频')
await user.type(search, '豆包')
await user.click(screen.getByRole('tab', { name: 'Plugins' }))
expect(
screen.getByRole('option', { name: 'Ark Video Plugin doubao' })
@@ -19,20 +19,32 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { describe, expect, test } from 'vitest'
import { assert, describe, expect, test } from 'vitest'
import { Form } from '@/components/ui/form'
import { CHANNEL_TYPE_NEW_API, CHANNEL_TYPE_SUB2API } from '../../constants'
import { CHANNEL_TYPE_ADVANCED_CUSTOM } from '../../lib/advanced-custom'
import {
buildSettingJSON,
CHANNEL_FORM_DEFAULT_VALUES,
transformChannelToFormDefaults,
transformFormDataToCreatePayload,
transformFormDataToUpdatePayload,
type ChannelFormValues,
} from '../../lib/channel-form'
import { channelSchema } from '../../types'
import { AstraModelGuardSetting } from '../astra-model-guard-setting'
import { ResponsesWebSocketSetting } from '../responses-websocket-setting'
const supportedChannelTypes = [
1,
57,
CHANNEL_TYPE_ADVANCED_CUSTOM,
CHANNEL_TYPE_SUB2API,
CHANNEL_TYPE_NEW_API,
]
function SettingsForm(props: {
channelType: number
disabled?: boolean
@@ -130,6 +142,42 @@ describe('Responses WebSocket channel setting', () => {
expect(settings.responses_astra_model_guard).toBe(true)
}
)
describe.each(supportedChannelTypes)('channel type %s', (channelType) => {
test.each([undefined, false, true])(
'preserves setting %s through create, update and reload',
(enabled) => {
const channel = channelSchema.parse({
id: 1,
name: 'Test channel',
key: '',
type: channelType,
status: 1,
created_time: 0,
test_time: 0,
response_time: 0,
balance_updated_time: 0,
setting: JSON.stringify({ responses_websocket_enabled: enabled }),
})
const values = transformChannelToFormDefaults(channel)
const payloads = [
transformFormDataToCreatePayload(values).channel,
transformFormDataToUpdatePayload(values, channel.id),
]
for (const payload of payloads) {
assert(typeof payload.setting === 'string')
expect(JSON.parse(payload.setting).responses_websocket_enabled).toBe(
enabled === true
)
expect(
transformChannelToFormDefaults({
...channel,
setting: payload.setting,
}).responses_websocket_enabled
).toBe(enabled === true)
}
}
)
})
test('new channels default to WebSocket disabled', () => {
expect(
@@ -142,7 +190,7 @@ describe('Responses WebSocket channel setting', () => {
).toBe(true)
})
test.each([1, 57])(
test.each(supportedChannelTypes)(
'channel type %s exposes an accessible switch and saves on and off',
async (channelType) => {
const user = userEvent.setup()
@@ -0,0 +1,155 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import {
createMemoryHistory,
createRootRoute,
createRoute,
createRouter,
RouterProvider,
} from '@tanstack/react-router'
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createInstance } from 'i18next'
import { I18nextProvider } from 'react-i18next'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { api } from '@/lib/api'
import type { Channel } from '../../types'
import { ChannelsProvider } from '../channels-provider'
import { ChannelsTable } from '../channels-table'
const i18n = createInstance()
await i18n.init({
lng: 'en',
resources: { en: { translation: {} } },
initAsync: false,
})
const clients: QueryClient[] = []
function channel(name: string): Channel {
return {
id: 3,
type: 1,
key: '',
status: 1,
name,
created_time: 0,
test_time: 0,
response_time: 0,
other: '',
balance: 0,
balance_updated_time: 0,
models: 'gpt-4o',
group: 'vip',
used_quota: 0,
other_info: '',
remark: '',
max_input_tokens: 0,
channel_info: {
is_multi_key: false,
multi_key_size: 0,
multi_key_polling_index: 0,
multi_key_mode: 'random',
},
settings: '{}',
}
}
beforeEach(() => {
vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
localStorage.clear()
})
afterEach(() => {
cleanup()
localStorage.clear()
clients.splice(0).forEach((client) => client.clear())
vi.restoreAllMocks()
})
async function renderChannelsPage(searchGate: () => Promise<void>) {
const get = vi.spyOn(api, 'get').mockImplementation(async (url) => {
if (url === '/api/channel/search') {
await searchGate()
return {
data: {
success: true,
data: { items: [channel('prod')], total: 1, type_counts: {} },
},
}
}
return { data: { success: true, data: ['vip'] } }
})
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
clients.push(client)
const root = createRootRoute()
const auth = createRoute({ getParentRoute: () => root, id: '_authenticated' })
const channelsRoute = createRoute({
getParentRoute: () => auth,
path: 'channels/',
component: () => (
<ChannelsProvider>
<ChannelsTable />
</ChannelsProvider>
),
})
const initialEntry =
'/channels/?filter=prod&group=%5B%22vip%22%5D&status=%5B%22enabled%22%5D&model=gpt-4o'
const router = createRouter({
routeTree: root.addChildren([auth.addChildren([channelsRoute])]),
history: createMemoryHistory({ initialEntries: [initialEntry] }),
})
await router.load()
render(
<I18nextProvider i18n={i18n}>
<QueryClientProvider client={client}>
<RouterProvider router={router} />
</QueryClientProvider>
</I18nextProvider>
)
expect((await screen.findAllByText('prod')).length).toBeGreaterThan(0)
return get
}
it('refetches the channel list with the current filters and marks Refresh busy while it runs', async () => {
let gate = Promise.resolve()
const get = await renderChannelsPage(() => gate)
const searchCalls = () =>
get.mock.calls.filter(([url]) => url === '/api/channel/search')
const refresh = screen.getByRole('button', { name: 'Refresh' })
expect(searchCalls()).toHaveLength(1)
expect(refresh).toHaveAttribute('aria-busy', 'false')
let release!: () => void
gate = new Promise((resolve) => {
release = resolve
})
refresh.focus()
await userEvent.setup().keyboard('{Enter}')
await waitFor(() => expect(searchCalls()).toHaveLength(2))
expect(searchCalls()[1][1]?.params).toEqual(searchCalls()[0][1]?.params)
expect(refresh).toHaveAttribute('aria-busy', 'true')
release()
await waitFor(() => expect(refresh).toHaveAttribute('aria-busy', 'false'))
})
@@ -251,3 +251,27 @@ test('models published under another name show that name as a badge next to the
)
expect(screen.queryByLabelText(/Published as o3/)).not.toBeInTheDocument()
})
test('long model names remain fully labeled and selectable in a container-responsive list', async () => {
const user = userEvent.setup()
const model = 'gpt-4.1-mini-deployment-with-a-long-region-name-2025-04-14'
const onChange = vi.fn()
render(
<UpstreamModelSelection
models={[model]}
selected={[]}
existingModels={[]}
showChanges={false}
onChange={onChange}
/>
)
const checkbox = screen.getByRole('checkbox', { name: model })
const category = checkbox.closest('[data-slot="collapsible"]')
expect(category).toHaveClass('@container')
const grid = checkbox.parentElement?.parentElement
expect(grid).toHaveClass('@lg:grid-cols-2')
expect(grid).not.toHaveClass('sm:grid-cols-2')
expect(screen.getByText(model)).toBeVisible()
await user.click(checkbox)
expect(onChange).toHaveBeenCalledWith([model])
})
@@ -0,0 +1,363 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import {
act,
render,
renderHook,
screen,
waitFor,
within,
} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, test, vi } from 'vitest'
import { Button } from '@/components/ui/button'
import { api } from '@/lib/api'
import { useChannelUpstreamUpdates } from '../../hooks/use-channel-upstream-updates'
import { UpstreamUpdateDialog } from '../dialogs/upstream-update-dialog'
const detectionResponse = {
data: {
success: true,
data: { add_models: ['gpt-new', 'gpt-extra'], remove_models: ['gpt-old'] },
},
}
const applyResponse = {
data: {
success: true,
data: {
added_models: ['gpt-new'],
removed_models: ['gpt-old'],
remaining_models: ['gpt-extra'],
remaining_remove_models: [],
},
},
}
function UpstreamUpdateHarness(props: { refresh?: () => Promise<void> }) {
const upstream = useChannelUpstreamUpdates(
props.refresh ?? (() => Promise.resolve())
)
return (
<>
<Button
onClick={() => upstream.detectChannelUpdates({ id: 42, name: 'batch' })}
>
Detect
</Button>
<Button
onClick={() =>
upstream.openModal(
{ id: 43, name: 'other channel' },
[],
['other-old'],
'remove'
)
}
>
Other channel
</Button>
<UpstreamUpdateDialog upstream={upstream} />
</>
)
}
describe('upstream update preview', () => {
test('detecting changes opens a preview without applying them', async () => {
const post = vi.spyOn(api, 'post').mockResolvedValue(detectionResponse)
const { result } = renderHook(() =>
useChannelUpstreamUpdates(vi.fn().mockResolvedValue(undefined))
)
await act(() =>
result.current.detectChannelUpdates({ id: 42, name: 'batch' })
)
expect(result.current.showModal).toBe(true)
expect(result.current.addModels).toEqual(['gpt-new', 'gpt-extra'])
expect(result.current.removeModels).toEqual(['gpt-old'])
expect(post).toHaveBeenCalledExactlyOnceWith(
'/api/channel/upstream_updates/detect',
{ id: 42 },
expect.anything()
)
})
test('loading leads to selectable models and cancelling makes no apply request', async () => {
let resolve!: (value: typeof detectionResponse) => void
const promise = new Promise<typeof detectionResponse>((done) => {
resolve = done
})
const post = vi.spyOn(api, 'post').mockReturnValue(promise)
const user = userEvent.setup()
render(<UpstreamUpdateHarness />)
await user.click(screen.getByRole('button', { name: 'Detect' }))
expect(
screen.getByText(
'Checking upstream models. Channel models will only change after confirmation.'
)
).toBeVisible()
expect(
screen.queryByRole('button', { name: 'Review selected changes' })
).not.toBeInTheDocument()
await act(async () => resolve(detectionResponse))
expect(screen.getByRole('checkbox', { name: 'gpt-new' })).toBeChecked()
await user.click(screen.getByRole('button', { name: 'Cancel' }))
await waitFor(() =>
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
)
expect(post).toHaveBeenCalledTimes(1)
})
test('removals require explicit selection and a reopened preview resets selections', async () => {
const user = userEvent.setup()
render(<UpstreamUpdateHarness />)
await user.click(screen.getByRole('button', { name: 'Other channel' }))
const model = screen.getByRole('checkbox', { name: 'other-old' })
expect(model).not.toBeChecked()
expect(
screen.getByRole('button', { name: 'Review selected changes' })
).toBeDisabled()
await user.click(model)
expect(model).toBeChecked()
await user.click(screen.getByRole('button', { name: 'Cancel' }))
await user.click(screen.getByRole('button', { name: 'Other channel' }))
expect(
screen.getByRole('checkbox', { name: 'other-old' })
).not.toBeChecked()
})
test('review lists the selected changes and applies only after confirmation without ignoring unchecked models', async () => {
const post = vi
.spyOn(api, 'post')
.mockResolvedValueOnce(detectionResponse)
.mockResolvedValueOnce(applyResponse)
const user = userEvent.setup()
render(<UpstreamUpdateHarness />)
await user.click(screen.getByRole('button', { name: 'Detect' }))
await user.click(await screen.findByRole('checkbox', { name: 'gpt-extra' }))
await user.click(screen.getByRole('tab', { name: 'Remove Models (1)' }))
const removal = screen.getByRole('checkbox', { name: 'gpt-old' })
expect(removal).not.toBeChecked()
await user.click(removal)
await user.click(
screen.getByRole('button', { name: 'Review selected changes' })
)
const confirmation = screen.getByRole('alertdialog')
expect(within(confirmation).getByText('gpt-new')).toBeVisible()
expect(within(confirmation).getByText('gpt-old')).toBeVisible()
expect(
within(confirmation).queryByText('gpt-extra')
).not.toBeInTheDocument()
expect(post).toHaveBeenCalledTimes(1)
await user.click(
within(confirmation).getByRole('button', {
name: 'Apply selected changes',
})
)
expect(post).toHaveBeenLastCalledWith(
'/api/channel/upstream_updates/apply',
{
id: 42,
add_models: ['gpt-new'],
remove_models: ['gpt-old'],
ignore_models: [],
},
expect.anything()
)
const results = await screen.findByRole('dialog', {
name: 'Update results',
})
expect(within(results).getByText('#42 batch')).toBeVisible()
expect(
within(
within(results).getByRole('region', { name: 'Added models' })
).getByText('gpt-new')
).toBeVisible()
expect(
within(
within(results).getByRole('region', { name: 'Removed models' })
).getByText('gpt-old')
).toBeVisible()
expect(
within(
within(results).getByRole('region', { name: 'Still pending addition' })
).getByText('gpt-extra')
).toBeVisible()
expect(
screen.queryByRole('button', { name: 'Apply selected changes' })
).not.toBeInTheDocument()
await waitFor(() => {
expect(
within(results).getAllByRole('button', { name: 'Close' })
).toContain(document.activeElement)
})
})
test('cancelling the final confirmation returns to the same selection without saving', async () => {
const post = vi.spyOn(api, 'post').mockResolvedValue(detectionResponse)
const user = userEvent.setup()
render(<UpstreamUpdateHarness />)
await user.click(screen.getByRole('button', { name: 'Detect' }))
await user.click(await screen.findByRole('checkbox', { name: 'gpt-extra' }))
await user.click(
screen.getByRole('button', { name: 'Review selected changes' })
)
await user.click(
within(screen.getByRole('alertdialog')).getByRole('button', {
name: 'Cancel',
})
)
expect(screen.getByRole('checkbox', { name: 'gpt-new' })).toBeChecked()
expect(
screen.getByRole('checkbox', { name: 'gpt-extra' })
).not.toBeChecked()
expect(post).toHaveBeenCalledTimes(1)
})
test('detection failures remain visible and retry can show an empty preview', async () => {
const post = vi
.spyOn(api, 'post')
.mockResolvedValueOnce({
data: { success: false, message: 'Upstream unavailable' },
})
.mockResolvedValueOnce({
data: { success: true, data: { add_models: [], remove_models: [] } },
})
const user = userEvent.setup()
render(<UpstreamUpdateHarness />)
await user.click(screen.getByRole('button', { name: 'Detect' }))
expect(await screen.findByText('Upstream unavailable')).toBeVisible()
await user.click(screen.getByRole('button', { name: 'Retry' }))
expect(
await screen.findByText(
'No processable upstream model updates for this channel'
)
).toBeVisible()
expect(
screen.getByRole('button', { name: 'Review selected changes' })
).toBeDisabled()
expect(post).toHaveBeenCalledTimes(2)
})
test('long labels can wrap without a fixed tab-list height', async () => {
vi.spyOn(api, 'post').mockResolvedValue(detectionResponse)
const user = userEvent.setup()
render(<UpstreamUpdateHarness />)
await user.click(screen.getByRole('button', { name: 'Detect' }))
expect(await screen.findByRole('tablist')).toHaveClass(
'group-data-horizontal/tabs:h-auto'
)
for (const tab of screen.getAllByRole('tab')) {
expect(tab).toHaveClass('h-auto', 'whitespace-normal')
}
expect(
screen.getByRole('button', { name: 'Review selected changes' })
).toHaveClass('whitespace-normal')
})
test('late detection responses cannot replace another channel preview', async () => {
let resolve!: (value: typeof detectionResponse) => void
const promise = new Promise<typeof detectionResponse>((done) => {
resolve = done
})
vi.spyOn(api, 'post').mockReturnValue(promise)
const user = userEvent.setup()
render(<UpstreamUpdateHarness />)
await user.click(screen.getByRole('button', { name: 'Detect' }))
await user.keyboard('{Escape}')
await user.click(screen.getByRole('button', { name: 'Other channel' }))
await act(async () => resolve(detectionResponse))
expect(screen.getByText('#43 other channel')).toBeVisible()
expect(
screen.getByRole('checkbox', { name: 'other-old' })
).not.toBeChecked()
expect(screen.queryByText('gpt-new')).not.toBeInTheDocument()
})
test('pending apply cannot be dismissed or submitted twice and results use the server response even if refresh fails', async () => {
let resolve!: (value: typeof applyResponse) => void
const promise = new Promise<typeof applyResponse>((done) => {
resolve = done
})
const post = vi
.spyOn(api, 'post')
.mockResolvedValueOnce(detectionResponse)
.mockReturnValueOnce(promise)
const refresh = vi.fn().mockRejectedValue(new Error('List refresh failed'))
const user = userEvent.setup()
render(<UpstreamUpdateHarness refresh={refresh} />)
await user.click(screen.getByRole('button', { name: 'Detect' }))
await user.click(
await screen.findByRole('button', { name: 'Review selected changes' })
)
await user.click(
screen.getByRole('button', { name: 'Apply selected changes' })
)
const confirmation = screen.getByRole('alertdialog')
expect(
within(confirmation).getByRole('button', { name: 'Applying...' })
).toBeDisabled()
expect(
within(confirmation).getByRole('button', { name: 'Cancel' })
).toBeDisabled()
await user.keyboard('{Escape}')
expect(screen.getByRole('alertdialog')).toBeVisible()
await act(async () => resolve(applyResponse))
const results = await screen.findByRole('dialog', {
name: 'Update results',
})
const added = within(results).getByRole('region', { name: 'Added models' })
expect(within(added).getByText('gpt-new')).toBeVisible()
expect(within(added).queryByText('gpt-extra')).not.toBeInTheDocument()
expect(refresh).toHaveBeenCalledTimes(1)
expect(post).toHaveBeenCalledTimes(2)
})
test('failed apply keeps its error visible and requires a fresh preview before resubmission', async () => {
const post = vi
.spyOn(api, 'post')
.mockResolvedValueOnce(detectionResponse)
.mockRejectedValueOnce(new Error('Connection lost'))
.mockResolvedValueOnce(detectionResponse)
const user = userEvent.setup()
render(<UpstreamUpdateHarness />)
await user.click(screen.getByRole('button', { name: 'Detect' }))
await user.click(
await screen.findByRole('button', { name: 'Review selected changes' })
)
await user.click(
screen.getByRole('button', { name: 'Apply selected changes' })
)
expect(await screen.findByText('Connection lost')).toBeVisible()
expect(
screen.getByRole('button', { name: 'Review selected changes' })
).toBeDisabled()
expect(
screen.queryByRole('dialog', { name: 'Update results' })
).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Refresh preview' }))
expect(
await screen.findByRole('checkbox', { name: 'gpt-new' })
).toBeChecked()
expect(
screen.getByRole('button', { name: 'Review selected changes' })
).toBeEnabled()
expect(post).toHaveBeenCalledTimes(3)
})
})
@@ -21,7 +21,7 @@ import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { GroupBadge } from '@/components/group-badge'
import { cn } from '@/lib/utils'
import { StatusBadgeTypeContext } from '@/components/status-badge'
import { CHANNEL_STATUS } from '../constants'
import { isTagAggregateRow, parseGroupsList } from '../lib'
@@ -42,9 +42,11 @@ const SENSITIVE_MASK = '••••'
function ChannelCardComponent({
row,
isSelected,
reorderEnabled = false,
}: {
row: Row<Channel>
isSelected: boolean
reorderEnabled?: boolean
}) {
const { t } = useTranslation()
const { sensitiveVisible } = useChannels()
@@ -59,14 +61,9 @@ function ChannelCardComponent({
return flexRender(cell.column.columnDef.cell, cell.getContext())
}
const fieldLabels: Record<string, string> = {
balance: t('Used / Remaining'),
response_time: t('Response'),
test_time: t('Last Tested'),
}
const groups = parseGroupsList(row.original.group ?? '')
const reorderCell = reorderEnabled ? renderCell('reorder') : null
const selectCell = renderCell('select')
const typeCell = renderCell('type')
const nameCell = renderCell('name')
@@ -92,11 +89,12 @@ function ChannelCardComponent({
<ChannelRowActionsLayoutContext.Provider value='card'>
<div
data-state={isSelected ? 'selected' : undefined}
className='flex flex-col gap-3'
className='flex min-w-0 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'>
{reorderCell}
{!isTagRow && selectCell && (
<span className='shrink-0'>{selectCell}</span>
)}
@@ -108,12 +106,11 @@ function ChannelCardComponent({
</div>
</div>
{/* 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'>
{/* Both rows share their columns: identity/balance on the left,
priority/response and weight/last tested on the right. */}
<StatusBadgeTypeContext.Provider value='text'>
<div className='grid grid-cols-[minmax(0,1fr)_auto_auto] items-start gap-3'>
<div className='min-w-0 overflow-hidden text-sm'>
{!isTagRow && (
<div className={labelClass}>
#{sensitiveVisible ? row.original.id : SENSITIVE_MASK}
@@ -121,45 +118,48 @@ function ChannelCardComponent({
)}
{nameCell}
</div>
<div className='min-w-0'>
<div className={cn('mb-1', labelClass)}>
{fieldLabels.balance}
<div
data-column-id='priority'
className='flex min-w-0 flex-col gap-1'
>
<span className={labelClass}>{t('Priority')}</span>
{priorityCell}
</div>
<div className='flex min-w-0 flex-col gap-1'>
<span className={labelClass}>{t('Weight')}</span>
{weightCell}
</div>
<dl className='col-span-3 grid grid-cols-subgrid gap-y-1'>
<div className='row-span-2 grid min-w-0 grid-rows-subgrid'>
<dt className={labelClass}>{t('Used / Remaining')}</dt>
<dd className='min-w-0 text-sm tabular-nums [&_[data-slot=status-badge]]:!ml-0 [&>div]:ml-0 [&>div]:flex-wrap [&>div]:gap-x-3'>
{balanceCell ?? (
<span className='text-muted-foreground'>-</span>
)}
</dd>
</div>
<div className='min-w-0 overflow-hidden text-sm'>
{balanceCell ?? (
<span className='text-muted-foreground'>-</span>
)}
<div className='row-span-2 grid min-w-0 grid-rows-subgrid'>
<dt className={labelClass}>{t('Response')}</dt>
<dd className='min-w-0 text-sm tabular-nums [&_[data-slot=status-badge]]:!ml-0'>
{responseCell ?? (
<span className='text-muted-foreground'>-</span>
)}
</dd>
</div>
</div>
<div className='row-span-2 grid min-w-0 grid-rows-subgrid'>
<dt className={labelClass}>{t('Last Tested')}</dt>
<dd className='min-w-0 text-sm [&_[data-slot=status-badge]]:!ml-0'>
{testCell ?? <span className='text-muted-foreground'>-</span>}
</dd>
</div>
</dl>
</div>
</StatusBadgeTypeContext.Provider>
{/* 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>
</div>
</div>
{/* Last row: groups span the full width, showing every group (no label) */}
{/* Groups retain their compact, full-width footer. */}
<div className='min-w-0'>
{groups.length > 0 ? (
<div className='-ml-1.5 flex flex-wrap gap-1'>
<div className='-ml-1.5 flex min-w-0 flex-wrap gap-1'>
{groups.map((g) => (
<GroupBadge
key={g}
@@ -0,0 +1,29 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { createContext, type KeyboardEvent, type PointerEvent } from 'react'
type PriorityControls = {
reason: 'sort' | 'tag' | 'permission' | 'busy' | null
start: (event: PointerEvent<HTMLButtonElement>, id: number) => void
keyDown: (event: KeyboardEvent<HTMLButtonElement>, id: number) => void
}
export const ChannelPriorityContext = createContext<PriorityControls | null>(
null
)
@@ -0,0 +1,275 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { ArrowDown, ArrowUp, GripVertical, Undo2 } from 'lucide-react'
import { useContext, useState, useSyncExternalStore } from 'react'
import { useTranslation } from 'react-i18next'
import type { DataTableViewMode } from '@/components/data-table'
import { Button } from '@/components/ui/button'
import { ButtonGroup } from '@/components/ui/button-group'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { toIntlLocale } from '@/i18n/languages'
import { formatNumber } from '@/lib/format'
import { CHANNEL_PRIORITY_GUIDE_KEYS } from '../constants'
import type { useChannelPriorityOrder } from '../hooks/use-channel-priority-order'
import type { ChannelSortBy } from '../types'
import { ChannelPriorityContext } from './channel-priority-context'
import { ChannelPriorityGuide } from './channel-priority-guide'
export function ChannelPriorityHandle(props: { id: number }) {
const controls = useContext(ChannelPriorityContext)
const { t } = useTranslation()
if (!controls) return null
let hint = t(
'Drag to change priority. Use arrow keys to move, Escape to cancel.'
)
if (controls.reason === 'sort') hint = t('Sort by priority to drag channels')
if (controls.reason === 'tag') {
hint = t('Disable tag grouping to drag channels')
}
if (controls.reason === 'permission') {
hint = t('You do not have permission to edit channels')
}
if (controls.reason === 'busy') hint = t('Saving...')
return (
<Tooltip>
<TooltipTrigger render={<span className='inline-flex shrink-0' />}>
<Button
type='button'
variant='ghost'
size='icon-sm'
className='text-muted-foreground cursor-grab touch-none active:cursor-grabbing'
disabled={!!controls.reason}
aria-label={
controls.reason === 'tag'
? hint
: t('Reorder channel {{id}}', { id: props.id })
}
onPointerDown={(event) => controls.start(event, props.id)}
onKeyDown={(event) => controls.keyDown(event, props.id)}
>
<GripVertical className='size-4' aria-hidden />
</Button>
</TooltipTrigger>
<TooltipContent>{hint}</TooltipContent>
</Tooltip>
)
}
export function ChannelSortControl(props: {
field: ChannelSortBy
desc: boolean
disabled?: boolean
onChange: (field: ChannelSortBy, desc: boolean) => void
}) {
const { t } = useTranslation()
const options = [
{ value: 'priority', label: t('Priority') },
{ value: 'id', label: t('ID') },
{ value: 'name', label: t('Name') },
{ value: 'balance', label: t('Balance') },
{ value: 'response_time', label: t('Response') },
{ value: 'test_time', label: t('Last Tested') },
]
return (
<ButtonGroup aria-label={t('Sort channels by')} className='shrink-0'>
<Select
value={props.field}
disabled={props.disabled}
onValueChange={(value) => {
if (value) props.onChange(value as ChannelSortBy, props.desc)
}}
items={options}
>
<SelectTrigger
aria-label={t('Sort channels by')}
className='bg-background min-w-24'
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type='button'
size='icon'
variant='outline'
disabled={props.disabled}
aria-label={props.desc ? t('Descending') : t('Ascending')}
title={props.desc ? t('Descending') : t('Ascending')}
onClick={() => props.onChange(props.field, !props.desc)}
>
{props.desc ? (
<ArrowDown aria-hidden className='size-4' />
) : (
<ArrowUp aria-hidden className='size-4' />
)}
</Button>
</ButtonGroup>
)
}
export function ChannelPriorityModeToggle(props: {
active: boolean
viewMode: DataTableViewMode
reason: 'sort' | 'tag' | 'permission' | 'busy' | null
onChange: () => void
}) {
const { t } = useTranslation()
const [guideOpen, setGuideOpen] = useState(false)
const [guideSeen, setGuideSeen] = useState<
Partial<Record<DataTableViewMode, boolean>>
>(() => {
try {
return {
card: localStorage.getItem(CHANNEL_PRIORITY_GUIDE_KEYS.card) === 'seen',
table:
localStorage.getItem(CHANNEL_PRIORITY_GUIDE_KEYS.table) === 'seen',
}
} catch {
return {}
}
})
const handleGuideOpenChange = (open: boolean) => {
setGuideOpen(open)
if (open) return
setGuideSeen((seen) => ({ ...seen, [props.viewMode]: true }))
try {
localStorage.setItem(CHANNEL_PRIORITY_GUIDE_KEYS[props.viewMode], 'seen')
} catch {
// Remember dismissal for this visit when browser storage is unavailable.
}
}
let hint = t('Drag mode')
if (props.reason === 'sort') hint = t('Sort by priority to drag channels')
if (props.reason === 'tag') hint = t('Disable tag grouping to drag channels')
if (props.reason === 'permission') {
hint = t('You do not have permission to edit channels')
}
if (props.reason === 'busy') hint = t('Saving...')
return (
<ButtonGroup aria-label={t('Drag mode')} className='shrink-0'>
<Tooltip>
<TooltipTrigger
render={<span className='inline-flex [&>button]:rounded-r-none' />}
>
<Button
type='button'
variant='outline'
className='text-muted-foreground aria-pressed:border-primary/30 aria-pressed:bg-primary/10 aria-pressed:text-primary aria-pressed:hover:bg-primary/15'
aria-pressed={props.active}
disabled={!!props.reason}
onClick={() => {
props.onChange()
if (!props.active && !guideSeen[props.viewMode]) {
setGuideOpen(true)
}
}}
>
<GripVertical className='size-4' aria-hidden />
{props.active ? t('Finish reordering') : t('Drag mode')}
</Button>
</TooltipTrigger>
<TooltipContent>{hint}</TooltipContent>
</Tooltip>
<ChannelPriorityGuide
open={guideOpen}
viewMode={props.viewMode}
onOpenChange={handleGuideOpenChange}
/>
</ButtonGroup>
)
}
export function ChannelPriorityStatus(props: {
order: ReturnType<typeof useChannelPriorityOrder>
}) {
const { t, i18n } = useTranslation()
const order = props.order
const hover = useSyncExternalStore(
order.previewStore.subscribe,
order.previewStore.getSnapshot
)
const locale = toIntlLocale(i18n.resolvedLanguage || i18n.language)
let message = ''
if (order.saving) message = t('Saving...')
else if (hover.preparing) message = t('Calculating priority...')
else if (hover.preview && hover.drop) {
const value =
hover.preview.items.find((item) => item.id === hover.drop?.id)
?.priority ?? 0
const visible = new Set(order.items.map((item) => item.id))
const hidden = hover.preview.changes.filter(
(item) => !visible.has(item.id)
).length
message = t('New priority: {{priority}} · {{count}} channels affected', {
priority: formatNumber(value, locale),
count: hover.preview.changes.length,
})
if (hidden) {
message += ` · ${t('{{count}} outside this view', { count: hidden })}`
}
if (hover.drop.position === 'same') {
message += ` · ${t('Join the same priority tier')}`
} else {
message += ` · ${t('Release to insert a new priority tier')}`
}
} else if (order.active) {
message = t(
'Drop between tiers to change priority, or in the center to join a tier'
)
}
if (!message && !order.undo?.changes.length) return null
return (
<div className='text-muted-foreground flex shrink-0 flex-wrap items-center justify-between gap-2 px-1 text-xs'>
<span role='status' aria-live='polite'>
{message || t('Channel priorities saved')}
</span>
{!!order.undo?.changes.length && (
<Button
type='button'
variant='ghost'
size='sm'
disabled={order.active || order.saving}
onClick={() => void order.restore()}
>
<Undo2 className='size-3.5' aria-hidden />
{t('Undo')}
</Button>
)}
</div>
)
}
@@ -0,0 +1,261 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { GripVertical, MousePointer2 } from 'lucide-react'
import { motion } from 'motion/react'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { DataTableViewMode } from '@/components/data-table'
import { Badge } from '@/components/ui/badge'
import { useMediaQuery } from '@/hooks/use-media-query'
import { toIntlLocale } from '@/i18n/languages'
import { formatNumber } from '@/lib/format'
import { cn } from '@/lib/utils'
const frames = [
{ at: 0, phase: 'ready', lifted: false },
{ at: 700, phase: 'lift', lifted: true },
{ at: 1500, phase: 'target', lifted: true },
{ at: 3000, phase: 'done', lifted: false },
] as const
export function ChannelPriorityDemo(props: {
viewMode: DataTableViewMode
mode: 'same' | 'insert'
playing: boolean
onComplete: () => void
}) {
const { t, i18n } = useTranslation()
const reduced = useMediaQuery('(prefers-reduced-motion: reduce)')
const [frame, setFrame] = useState(0)
const elapsed = useRef(0)
const current = frames[reduced ? frames.length - 1 : frame]
const same = props.mode === 'same'
const reached = current.phase === 'target' || current.phase === 'done'
const between = !same && reached
const result = same ? 80 : 90
const card = props.viewMode === 'card'
const reorder = between && (card || current.phase === 'done')
const order = reorder ? ['A', 'C', 'B'] : ['A', 'B', 'C']
let pointerY = 150
if (reached) pointerY = same ? 92 : 66
const locale = toIntlLocale(i18n.resolvedLanguage || i18n.language)
let instruction = t('Drag the handle to move a channel.')
if (current.phase === 'target') {
instruction = same
? t('Release to join priority {{priority}}', {
priority: formatNumber(result, locale),
})
: t('Release to insert a new priority tier')
} else if (current.phase === 'done') {
instruction = t('New priority: {{priority}}', {
priority: formatNumber(result, locale),
})
}
useEffect(() => {
if (reduced || !props.playing) return
const started = performance.now()
const timers = frames
.map((item, index) => ({ ...item, index }))
.filter((item) => item.at > elapsed.current)
.map((item) =>
window.setTimeout(() => setFrame(item.index), item.at - elapsed.current)
)
// Keep the completed result visible before starting the next named demo.
timers.push(window.setTimeout(props.onComplete, 5200 - elapsed.current))
return () => {
elapsed.current += performance.now() - started
timers.forEach(window.clearTimeout)
}
}, [props.playing, props.onComplete, reduced])
return (
<div>
<div
className={cn(
'mb-3 grid gap-1.5 rounded-lg border p-3',
same
? 'border-primary/30 bg-primary/5'
: 'border-amber-500/40 bg-amber-500/5'
)}
>
<p className='text-sm font-medium'>
{same
? t('Drop in the center to use the same priority.')
: t('Drop at an edge to create a different priority.')}
</p>
<p className='text-muted-foreground text-xs tabular-nums'>
{t('Priority')} {formatNumber(0, locale)} →{' '}
{formatNumber(result, locale)}
</p>
</div>
<ol
aria-label={card ? t('Card view') : t('Table view')}
className={cn(
'bg-muted/30 relative m-0 list-none overflow-hidden rounded-xl border p-0',
card ? 'h-60' : 'h-52'
)}
>
{!card && current.lifted && (
<>
<li
aria-hidden
role='presentation'
className='absolute inset-x-3 top-35 h-13 rounded-lg border border-dashed opacity-30'
/>
<motion.li
aria-hidden
role='presentation'
animate={{ y: pointerY }}
transition={{ duration: reduced ? 0 : 0.6 }}
className='absolute top-3 left-6 z-30'
>
<MousePointer2 className='fill-primary text-primary-foreground size-5 drop-shadow' />
</motion.li>
</>
)}
{between && (
<li
aria-hidden
role='presentation'
className={cn(
'absolute z-20 rounded-full bg-amber-500',
card
? 'top-3 left-1/2 h-24 w-0.5'
: 'inset-x-3 top-[4.5rem] h-0.5'
)}
/>
)}
{order.map((name, index) => {
const lifted = name === 'C' && current.lifted
const peer = same && reached && name !== 'A'
const target = name === 'B' && current.phase !== 'done'
let priority = name === 'A' ? 100 : 80
if (name === 'C') priority = reached ? result : 0
let y = index * 64
if (lifted && !card) y = reached ? 20 : 96
let x: number | string = lifted ? 6 : 0
if (card) {
let column = index % 2
y = Math.floor(index / 2) * 108
if (lifted && same && reached) {
column = 1
y = 24
}
x = `calc(${column * 100}% + ${column * 12 + (lifted ? 4 : 0)}px)`
}
let layout = 'right-3 h-13 items-center gap-2 px-3'
if (card) {
layout =
'h-24 w-[calc(50%-1.125rem)] flex-col items-start justify-between p-3'
} else if (lifted) {
layout = 'right-6 left-auto h-9 w-[60%] items-center gap-2 px-2'
}
return (
<motion.li
key={name}
initial={false}
animate={{ y, x, scale: lifted ? 1.015 : 1 }}
transition={{
duration: reduced ? 0 : 0.6,
ease: [0.22, 1, 0.36, 1],
}}
className={cn(
'bg-background absolute top-3 left-3 flex min-w-0 rounded-lg border',
layout,
peer && 'border-primary bg-primary/5',
target && same && 'border-primary',
target && !same && 'border-amber-500',
lifted && 'bg-background z-10 shadow-lg',
lifted && same && 'border-primary',
name === 'C' &&
!same &&
(lifted || reached) &&
'border-amber-500'
)}
>
{target && (
<span
aria-hidden
className={cn(
'pointer-events-none absolute',
same
? 'border-primary/60 bg-primary/10'
: 'border-amber-500/60 bg-amber-500/10',
card &&
same &&
'inset-y-0 inset-x-6 border-x border-dashed',
card &&
!same &&
'inset-y-0 left-0 w-6 border-r border-dashed',
!card &&
same &&
'inset-x-0 inset-y-2 border-y border-dashed',
!card &&
!same &&
'inset-x-0 top-0 h-2 border-b border-dashed'
)}
/>
)}
<div className='relative flex min-w-0 items-center gap-2'>
<span
className='text-muted-foreground relative shrink-0'
aria-hidden
>
<GripVertical
className={cn('size-4', lifted && 'text-primary')}
/>
</span>
<span className='min-w-0 truncate text-sm font-medium'>
{t('Channel {{name}}', { name })}
</span>
</div>
<Badge
variant='outline'
className={cn(
'relative shrink-0 tabular-nums',
!card && 'ml-auto',
peer && 'border-primary/30 text-primary'
)}
>
{t('Priority')} {formatNumber(priority, locale)}
</Badge>
{lifted && card && (
<MousePointer2
aria-hidden
className={cn(
'fill-primary text-primary-foreground absolute top-1/2 size-5 drop-shadow',
same && reached ? 'left-1/2' : '-left-2'
)}
/>
)}
</motion.li>
)
})}
</ol>
<p
role='status'
className='text-muted-foreground mt-3 min-h-5 text-sm leading-5'
>
{instruction}
</p>
</div>
)
}
@@ -0,0 +1,91 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { ArrowDownToLine, Layers } from 'lucide-react'
import { useSyncExternalStore } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import { toIntlLocale } from '@/i18n/languages'
import { formatNumber } from '@/lib/format'
import { cn } from '@/lib/utils'
import type { useChannelPriorityOrder } from '../hooks/use-channel-priority-order'
import type { Channel } from '../types'
export function ChannelPriorityDropHint(props: {
container: HTMLElement | null
order: ReturnType<typeof useChannelPriorityOrder>
channels: Channel[]
}) {
const { t, i18n } = useTranslation()
const hover = useSyncExternalStore(
props.order.previewStore.subscribe,
props.order.previewStore.getSnapshot
)
if (!props.container || !hover.drop) return null
const joining = hover.drop.position === 'same'
const locale = toIntlLocale(i18n.resolvedLanguage || i18n.language)
const targetPriority =
props.channels.find((item) => item.id === hover.drop?.target_id)
?.priority ?? 0
const priority =
hover.preview?.items.find((item) => item.id === hover.drop?.id)?.priority ??
targetPriority
let detail = t('Use the same priority as the highlighted channels')
if (!joining) {
detail = hover.preview
? t('New priority: {{priority}}', {
priority: formatNumber(priority, locale),
})
: t('Calculating priority...')
}
return createPortal(
<div
data-priority-hint
className={cn(
'text-popover-foreground p-3',
props.container.dataset.priorityView !== 'table' &&
'bg-popover rounded-xl border shadow-xl'
)}
>
<Badge
variant={joining ? 'default' : 'warning'}
className='h-auto min-h-7 w-full justify-start gap-2 rounded-lg px-2.5 py-1.5 text-sm leading-snug whitespace-normal'
>
{joining ? (
<Layers aria-hidden className='shrink-0' />
) : (
<ArrowDownToLine aria-hidden className='shrink-0' />
)}
<span>
{joining
? t('Release to join priority {{priority}}', {
priority: formatNumber(priority, locale),
})
: t('Release to insert a new priority tier')}
</span>
</Badge>
<p className='text-muted-foreground mt-2 text-xs leading-relaxed'>
{detail}
</p>
</div>,
props.container
)
}
@@ -0,0 +1,166 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import {
ArrowDownToLine,
CircleHelp,
Layers,
Pause,
Play,
RotateCcw,
} from 'lucide-react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { DataTableViewMode } from '@/components/data-table'
import { Dialog } from '@/components/dialog'
import { Button } from '@/components/ui/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useMediaQuery } from '@/hooks/use-media-query'
import { ChannelPriorityDemo } from './channel-priority-demo'
export function ChannelPriorityGuide(props: {
open: boolean
viewMode: DataTableViewMode
onOpenChange: (open: boolean) => void
}) {
const { t } = useTranslation()
const [playback, setPlayback] = useState(0)
const [demo, setDemo] = useState<'same' | 'insert'>('same')
const [playing, setPlaying] = useState(true)
const reduced = useMediaQuery('(prefers-reduced-motion: reduce)')
const nextDemo = useCallback(() => {
setDemo((mode) => (mode === 'same' ? 'insert' : 'same'))
}, [])
const handleOpenChange = (open: boolean) => {
if (!open) {
setDemo('same')
setPlaying(true)
}
props.onOpenChange(open)
}
return (
<Dialog
open={props.open}
onOpenChange={handleOpenChange}
title={t('Drag to set channel priority')}
description={t(
'Move channels into a priority tier or between tiers. Priority values update automatically.'
)}
contentClassName='sm:max-w-lg'
headerClassName='pr-5'
trigger={
<Button
type='button'
variant='outline'
size='icon'
className='text-muted-foreground'
aria-label={t('Drag mode help')}
title={t('Drag mode help')}
>
<CircleHelp className='size-4' aria-hidden />
</Button>
}
footerClassName='flex-row items-center justify-between sm:justify-between'
footer={
<>
<div className='flex items-center gap-1'>
<Button
type='button'
variant='ghost'
onClick={() => {
setDemo('same')
setPlaying(true)
setPlayback((value) => value + 1)
}}
>
<RotateCcw className='size-4' aria-hidden />
{t('Replay drag demo')}
</Button>
{!reduced && (
<Button
type='button'
variant='ghost'
size='icon'
aria-label={
playing ? t('Pause drag demo') : t('Resume drag demo')
}
title={playing ? t('Pause drag demo') : t('Resume drag demo')}
onClick={() => setPlaying((value) => !value)}
>
{playing ? (
<Pause className='size-4' aria-hidden />
) : (
<Play className='size-4' aria-hidden />
)}
</Button>
)}
</div>
<Button type='button' onClick={() => handleOpenChange(false)}>
{t('Got it')}
</Button>
</>
}
>
<Tabs
value={demo}
onValueChange={(value) => {
if (value === 'same' || value === 'insert') setDemo(value)
}}
className='gap-3'
>
<TabsList
aria-label={t('Drag mode help')}
className='grid w-full grid-cols-2 group-data-horizontal/tabs:h-auto'
>
<TabsTrigger
value='same'
className='data-active:text-primary h-auto min-h-9 px-2 py-2 text-xs whitespace-normal'
>
<Layers className='size-4 shrink-0' aria-hidden />
<span>{t('Join the same priority tier')}</span>
</TabsTrigger>
<TabsTrigger
value='insert'
className='h-auto min-h-9 px-2 py-2 text-xs whitespace-normal data-active:text-amber-700 dark:data-active:text-amber-400'
>
<ArrowDownToLine className='size-4 shrink-0' aria-hidden />
<span>{t('Insert a new priority tier')}</span>
</TabsTrigger>
</TabsList>
{(['same', 'insert'] as const).map((mode) => (
<TabsContent key={mode} value={mode}>
{props.open && demo === mode && (
<ChannelPriorityDemo
key={`${props.viewMode}:${mode}:${playback}`}
viewMode={props.viewMode}
mode={mode}
playing={playing}
onComplete={nextDemo}
/>
)}
</TabsContent>
))}
</Tabs>
<p className='text-muted-foreground mt-3 border-t pt-3 text-xs leading-5'>
{t('Changes save on drop. You can undo the last move.')}
</p>
</Dialog>
)
}
@@ -54,7 +54,10 @@ import {
} from '@/lib/currency'
import { formatTimestampToDate } from '@/lib/format'
import { handleServerError } from '@/lib/handle-server-error'
import { createServerError } from '@/lib/server-error-message'
import {
createServerError,
translateServerText,
} from '@/lib/server-error-message'
import { truncateText } from '@/lib/utils'
import { getCodexUsage, updateChannelBalance } from '../api'
@@ -1023,8 +1026,9 @@ export function useChannelsColumns(
<TooltipContent side='top' className='max-w-xs'>
<div className='space-y-1 text-xs'>
{statusReason && (
<div>
{t('Reason:')} {statusReason}
<div className='wrap-anywhere'>
{t('Reason:')}{' '}
{translateServerText(t, statusReason)}
</div>
)}
{statusTime && (
@@ -106,15 +106,7 @@ export function ChannelsDialogs() {
/>
{/* Upstream Model Update Dialog */}
<UpstreamUpdateDialog
open={upstream.showModal}
addModels={upstream.addModels}
removeModels={upstream.removeModels}
preferredTab={upstream.preferredTab}
confirmLoading={upstream.applyLoading}
onConfirm={upstream.applyUpdates}
onCancel={upstream.closeModal}
/>
<UpstreamUpdateDialog upstream={upstream} />
</>
)
}
@@ -17,6 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useQueryClient } from '@tanstack/react-query'
import { getRouteApi } from '@tanstack/react-router'
import {
Plus,
MoreHorizontal,
@@ -26,7 +27,6 @@ import {
TestTube,
DollarSign,
ListChecks,
SortAsc,
RefreshCw,
ArrowUpFromLine,
} from 'lucide-react'
@@ -45,6 +45,7 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { Switch } from '@/components/ui/switch'
import {
Tooltip,
@@ -64,19 +65,25 @@ import {
handleTestAllChannels,
handleUpdateAllBalances,
} from '../lib'
import type { ChannelSortBy } from '../types'
import { ChannelSortControl } from './channel-priority-controls'
import { useChannels } from './channels-provider'
const route = getRouteApi('/_authenticated/channels/')
export function ChannelsPrimaryButtons() {
const { t } = useTranslation()
const navigate = route.useNavigate()
const {
setOpen,
setCurrentRow,
enableTagMode,
setEnableTagMode,
idSort,
setIdSort,
sorting,
setSorting,
batchMode,
setBatchMode,
setPriorityActionContainer,
upstream,
} = useChannels()
const queryClient = useQueryClient()
@@ -95,57 +102,60 @@ export function ChannelsPrimaryButtons() {
setEnableTagMode(checked)
}
const handleIdSortToggle = (checked: boolean) => {
localStorage.setItem('channels-id-sort', String(checked))
setIdSort(checked)
}
const handleBatchModeToggle = (checked: boolean) => {
setBatchMode(checked)
}
return (
<>
<div className='flex items-center gap-2'>
<div className='flex flex-wrap items-center gap-2'>
<div ref={setPriorityActionContainer} className='empty:hidden' />
{/* 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 className='bg-muted/40 hidden h-8 shrink-0 items-center gap-2.5 rounded-lg border px-2.5 sm:flex'>
<div className='text-muted-foreground has-data-checked:text-foreground flex items-center gap-2'>
<Label
htmlFor='channel-batch-mode'
className='cursor-pointer gap-1.5 text-xs whitespace-nowrap'
>
<ListChecks className='size-3.5' aria-hidden />
{t('Batch Operations')}
</Label>
<Switch
id='channel-batch-mode'
size='sm'
checked={batchMode}
onCheckedChange={handleBatchModeToggle}
/>
</div>
<Separator orientation='vertical' className='my-2' />
<div className='text-muted-foreground has-data-checked:text-foreground flex items-center gap-2'>
<Label
htmlFor='tag-mode'
className='cursor-pointer gap-1.5 text-xs whitespace-nowrap'
>
<Tags className='size-3.5' aria-hidden />
{t('Tag Mode')}
</Label>
<Switch
id='tag-mode'
size='sm'
checked={enableTagMode}
onCheckedChange={handleTagModeToggle}
/>
</div>
</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')}
</Label>
<Switch
id='tag-mode'
checked={enableTagMode}
onCheckedChange={handleTagModeToggle}
/>
</div>
<div className='hidden items-center gap-2 rounded-md border px-3 py-1.5 sm:flex'>
<SortAsc className='text-muted-foreground h-4 w-4' />
<Label htmlFor='id-sort' className='cursor-pointer text-sm'>
{t('Sort by ID')}
</Label>
<Switch
id='id-sort'
checked={idSort}
onCheckedChange={handleIdSortToggle}
/>
</div>
<ChannelSortControl
field={(sorting[0]?.id ?? 'priority') as ChannelSortBy}
desc={sorting[0]?.desc ?? true}
onChange={(field, desc) => {
setSorting([{ id: field, desc }])
void navigate({
search: (previous) => ({ ...previous, page: 1 }),
replace: true,
})
}}
/>
{/* Create Channel */}
<Tooltip>
@@ -156,7 +166,6 @@ export function ChannelsPrimaryButtons() {
setCurrentRow(null)
setOpen('create-channel')
}}
size='sm'
disabled={!canEditSensitive}
>
<Plus className='h-4 w-4' />
@@ -173,8 +182,11 @@ export function ChannelsPrimaryButtons() {
{/* More Actions */}
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant='outline' size='sm' />}>
<MoreHorizontal className='h-4 w-4' />
<DropdownMenuTrigger
render={<Button variant='outline' size='icon' />}
aria-label={t('More actions')}
>
<MoreHorizontal className='size-4' aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-56'>
{/* Mobile-only: toggle switches */}
@@ -196,15 +208,6 @@ export function ChannelsPrimaryButtons() {
{t('Tag Mode')}
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
className='sm:hidden'
checked={idSort}
onCheckedChange={handleIdSortToggle}
>
<SortAsc className='mr-2 h-4 w-4' />
{t('Sort by ID')}
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator className='sm:hidden' />
<DropdownMenuItem
@@ -18,12 +18,14 @@ For commercial licensing, please contact support@quantumnous.com
*/
/* eslint-disable react-refresh/only-export-components */
import { useQueryClient } from '@tanstack/react-query'
import type { OnChangeFn, SortingState } from '@tanstack/react-table'
import React, {
createContext,
useContext,
useState,
useCallback,
useMemo,
useEffect,
} from 'react'
import { useChannelUpstreamUpdates } from '../hooks/use-channel-upstream-updates'
@@ -59,12 +61,14 @@ type ChannelsContextType = {
setCurrentTag: (tag: string | null) => void
enableTagMode: boolean
setEnableTagMode: (enabled: boolean) => void
idSort: boolean
setIdSort: (enabled: boolean) => void
sorting: SortingState
setSorting: OnChangeFn<SortingState>
batchMode: boolean
setBatchMode: (enabled: boolean) => void
sensitiveVisible: boolean
setSensitiveVisible: (visible: boolean) => void
priorityActionContainer: HTMLDivElement | null
setPriorityActionContainer: (container: HTMLDivElement | null) => void
upstream: UpstreamUpdateState
}
@@ -87,11 +91,22 @@ export function ChannelsProvider({ children }: { children: React.ReactNode }) {
const [enableTagMode, setEnableTagMode] = useState(() => {
return localStorage.getItem('enable-tag-mode') === 'true'
})
const [idSort, setIdSort] = useState(() => {
return localStorage.getItem('channels-id-sort') === 'true'
})
const [sorting, setSorting] = useState<SortingState>(() => [
{
id:
localStorage.getItem('channels-id-sort') === 'true' ? 'id' : 'priority',
desc: true,
},
])
const idSort = sorting[0]?.id === 'id'
useEffect(() => {
// Retain the existing ID-sort preference for returning users.
localStorage.setItem('channels-id-sort', String(idSort))
}, [idSort])
const [batchMode, setBatchMode] = useState(false)
const [sensitiveVisible, setSensitiveVisible] = useState(true)
const [priorityActionContainer, setPriorityActionContainer] =
useState<HTMLDivElement | null>(null)
const queryClient = useQueryClient()
const refreshChannels = useCallback(async () => {
@@ -112,12 +127,14 @@ export function ChannelsProvider({ children }: { children: React.ReactNode }) {
setCurrentTag,
enableTagMode,
setEnableTagMode,
idSort,
setIdSort,
sorting,
setSorting,
batchMode,
setBatchMode,
sensitiveVisible,
setSensitiveVisible,
priorityActionContainer,
setPriorityActionContainer,
upstream,
}),
[
@@ -125,9 +142,10 @@ export function ChannelsProvider({ children }: { children: React.ReactNode }) {
currentRow,
currentTag,
enableTagMode,
idSort,
sorting,
batchMode,
sensitiveVisible,
priorityActionContainer,
upstream,
]
)
@@ -23,9 +23,11 @@ import type {
OnChangeFn,
SortingState,
Row,
ColumnDef,
} from '@tanstack/react-table'
import { Eye, EyeOff } from 'lucide-react'
import { Eye, EyeOff, RefreshCw } from 'lucide-react'
import { useState, useMemo, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import {
@@ -34,6 +36,7 @@ import {
DataTablePage,
useDebouncedColumnFilter,
useDataTable,
useDataTableViewMode,
} from '@/components/data-table'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -44,7 +47,10 @@ import {
} from '@/components/ui/tooltip'
import { useMediaQuery } from '@/hooks'
import { useTableUrlState } from '@/hooks/use-table-url-state'
import { hasPermission } from '@/lib/admin-permissions'
import { requireServerSuccess } from '@/lib/server-error-message'
import { cn } from '@/lib/utils'
import { useAuthStore } from '@/stores/auth-store'
import { getChannels, searchChannels, getGroups } from '../api'
import {
@@ -52,6 +58,8 @@ import {
CHANNEL_STATUS,
CHANNEL_STATUS_OPTIONS,
} from '../constants'
import { useChannelPriorityDrag } from '../hooks/use-channel-priority-drag'
import { useChannelPriorityOrder } from '../hooks/use-channel-priority-order'
import {
channelsQueryKeys,
aggregateChannelsByTag,
@@ -61,6 +69,13 @@ import {
} from '../lib'
import type { Channel, ChannelSortBy } from '../types'
import { ChannelCard } from './channel-card'
import { ChannelPriorityContext } from './channel-priority-context'
import {
ChannelPriorityHandle,
ChannelPriorityModeToggle,
ChannelPriorityStatus,
} from './channel-priority-controls'
import { ChannelPriorityDropHint } from './channel-priority-drop-hint'
import { ChannelTypeLogo } from './channel-type-badge'
import { useChannelsColumns } from './channels-columns'
import { useChannels } from './channels-provider'
@@ -91,15 +106,16 @@ export function ChannelsTable() {
const { t } = useTranslation()
const {
enableTagMode,
idSort,
sorting,
setSorting,
batchMode,
sensitiveVisible,
setSensitiveVisible,
priorityActionContainer,
} = useChannels()
const isMobile = useMediaQuery('(max-width: 640px)')
// Table state
const [sorting, setSorting] = useState<SortingState>([])
const idSort = sorting[0]?.id === 'id'
// URL state management
const {
@@ -194,13 +210,10 @@ export function ChannelsTable() {
}, [sorting])
const handleSortingChange: OnChangeFn<SortingState> = (updater) => {
setSorting((previous) => {
const next = typeof updater === 'function' ? updater(previous) : updater
if (pagination.pageIndex > 0) {
onPaginationChange({ ...pagination, pageIndex: 0 })
}
return next
})
setSorting(updater)
if (pagination.pageIndex > 0) {
onPaginationChange({ ...pagination, pageIndex: 0 })
}
}
// Fetch groups for filter
@@ -220,7 +233,7 @@ export function ChannelsTable() {
// Fetch channels data
// eslint-disable-next-line @tanstack/query/exhaustive-deps
const { data, isLoading, isFetching } = useQuery({
const { data, isLoading, isFetching, refetch } = useQuery({
queryKey: channelsQueryKeys.list({
keyword: globalFilter,
model: modelFilter,
@@ -308,12 +321,89 @@ export function ChannelsTable() {
const totalCount = data?.data?.total || 0
const typeCounts = data?.data?.type_counts
// Columns configuration
const columns = useChannelsColumns({ enableSelection: batchMode })
const [viewMode, setViewMode] = useDataTableViewMode({
storageKey: CHANNELS_VIEW_MODE_STORAGE_KEY,
defaultMode: 'card',
})
const user = useAuthStore((state) => state.auth.user)
const sortField = (sorting[0]?.id ??
(idSort ? 'id' : 'priority')) as ChannelSortBy
const sortDesc = sorting[0]?.desc ?? true
const [reorderEnabled, setReorderEnabled] = useState(false)
const canReorder =
sortField === 'priority' &&
!enableTagMode &&
hasPermission(user, 'channel', 'write')
const reorderActive = reorderEnabled && canReorder
useEffect(() => {
if (!canReorder) setReorderEnabled(false)
}, [canReorder])
const reorderScope = JSON.stringify([
viewMode,
reorderActive,
sorting,
idSort,
enableTagMode,
columnFilters,
globalFilter,
pagination,
channels.map((channel) => [channel.id, channel.priority]),
])
const priorityOrder = useChannelPriorityOrder({
channels,
desc: sortDesc,
scope: reorderScope,
enabled: reorderActive,
})
const drag = useChannelPriorityDrag({
items: channels,
desc: sortDesc,
enabled: reorderActive && !priorityOrder.saving && !isFetching,
scope: reorderScope,
begin: priorityOrder.begin,
select: priorityOrder.select,
commit: priorityOrder.commit,
cancel: priorityOrder.cancel,
previewStore: priorityOrder.previewStore,
})
let dragReason: 'sort' | 'tag' | 'permission' | 'busy' | null = null
if (sortField !== 'priority') dragReason = 'sort'
else if (enableTagMode) dragReason = 'tag'
else if (!hasPermission(user, 'channel', 'write')) dragReason = 'permission'
else if (priorityOrder.saving || isFetching) dragReason = 'busy'
const dragControls = useMemo(
() => ({ reason: dragReason, start: drag.start, keyDown: drag.keyDown }),
[dragReason, drag.start, drag.keyDown]
)
// Reuse the same handle cell in the table and channel cards.
const baseColumns = useChannelsColumns({ enableSelection: batchMode })
const columns = useMemo<ColumnDef<Channel>[]>(
() =>
reorderActive
? [
{
id: 'reorder',
header: '',
size: 40,
minSize: 40,
maxSize: 40,
enableSorting: false,
enableHiding: false,
enableResizing: false,
meta: { mobileHidden: true },
cell: ({ row }) => <ChannelPriorityHandle id={row.original.id} />,
},
...baseColumns,
]
: baseColumns,
[baseColumns, reorderActive]
)
// React Table instance
const { table } = useDataTable({
data: channels,
data: priorityOrder.items,
columns,
totalCount,
sorting,
@@ -411,91 +501,150 @@ export function ChannelsTable() {
]
return (
<DataTablePage
table={table}
columns={columns}
isLoading={isLoading}
isFetching={isFetching}
emptyTitle={t('No Channels Found')}
emptyDescription={t(
'No channels available. Create your first channel to get started.'
)}
skeletonKeyPrefix='channel-skeleton'
enableCardView
viewModeStorageKey={CHANNELS_VIEW_MODE_STORAGE_KEY}
renderCard={(row, { isSelected }) => (
<ChannelCard row={row} isSelected={isSelected} />
)}
cardGridClassName='grid grid-cols-1 gap-3 sm:gap-4 lg:grid-cols-3'
applyHeaderSize
toolbarProps={{
collapsibleOnMobile: true,
searchPlaceholder: t('Filter by name, ID, or key...'),
searchDebounceMs: 500,
onReset: () => {
resetModelFilterInput()
},
additionalSearch: (
<Input
placeholder={t('Filter by model...')}
value={modelFilterInput}
onChange={onModelFilterInputChange}
onCompositionStart={onModelFilterCompositionStart}
onCompositionEnd={onModelFilterCompositionEnd}
className='w-full sm:w-[150px] lg:w-[180px]'
/>
),
filters: [
{
columnId: 'status',
title: t('Status'),
options: [...CHANNEL_STATUS_OPTIONS],
singleSelect: true,
},
{
columnId: 'type',
title: t('Type'),
options: typeFilterOptions,
singleSelect: true,
},
{
columnId: 'group',
title: t('Group'),
options: groupFilterOptions,
singleSelect: true,
},
],
preActions: (
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='icon'
onClick={() => setSensitiveVisible(!sensitiveVisible)}
aria-label={sensitiveVisible ? t('Hide') : t('Show')}
className='text-muted-foreground hover:text-foreground size-8'
/>
}
>
{sensitiveVisible ? <Eye /> : <EyeOff />}
</TooltipTrigger>
<TooltipContent>
{sensitiveVisible ? t('Hide') : t('Show')}
</TooltipContent>
</Tooltip>
),
}}
getRowClassName={(row, { isMobile }) => {
if (!isDisabledChannelRow(row.original)) {
return undefined
}
if (isMobile) {
return DISABLED_ROW_MOBILE
}
return DISABLED_ROW_DESKTOP
}}
bulkActions={batchMode ? <DataTableBulkActions table={table} /> : null}
/>
<ChannelPriorityContext.Provider value={dragControls}>
{priorityActionContainer &&
createPortal(
<ChannelPriorityModeToggle
active={reorderActive}
viewMode={viewMode}
reason={dragReason}
onChange={() => setReorderEnabled((value) => !value)}
/>,
priorityActionContainer
)}
<ChannelPriorityDropHint
container={drag.feedbackContainer}
order={priorityOrder}
channels={channels}
/>
<div
ref={drag.root}
role='region'
aria-label={t('Channels')}
{...drag.rootEvents}
className='relative h-full min-h-0 data-[priority-dragging=true]:select-none data-[priority-dragging=true]:[&_[data-sortable-id]]:pointer-events-none'
>
<DataTablePage
table={table}
columns={columns}
isLoading={isLoading}
isFetching={isFetching}
emptyTitle={t('No Channels Found')}
emptyDescription={t(
'No channels available. Create your first channel to get started.'
)}
skeletonKeyPrefix='channel-skeleton'
enableCardView
viewMode={viewMode}
onViewModeChange={setViewMode}
renderCard={(row, { isSelected }) => (
<ChannelCard
row={row}
isSelected={isSelected}
reorderEnabled={reorderActive}
/>
)}
cardGridClassName='grid grid-cols-1 gap-3 sm:gap-4 lg:grid-cols-3'
applyHeaderSize
afterTable={<ChannelPriorityStatus order={priorityOrder} />}
toolbarProps={{
collapsibleOnMobile: true,
searchPlaceholder: t('Filter by name, ID, or key...'),
searchDebounceMs: 500,
onReset: () => {
resetModelFilterInput()
},
additionalSearch: (
<Input
placeholder={t('Filter by model...')}
value={modelFilterInput}
onChange={onModelFilterInputChange}
onCompositionStart={onModelFilterCompositionStart}
onCompositionEnd={onModelFilterCompositionEnd}
className='w-full sm:w-[150px] lg:w-[180px]'
/>
),
filters: [
{
columnId: 'status',
title: t('Status'),
options: [...CHANNEL_STATUS_OPTIONS],
singleSelect: true,
},
{
columnId: 'type',
title: t('Type'),
options: typeFilterOptions,
singleSelect: true,
},
{
columnId: 'group',
title: t('Group'),
options: groupFilterOptions,
singleSelect: true,
},
],
preActions: (
<>
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='icon'
onClick={() => setSensitiveVisible(!sensitiveVisible)}
aria-label={sensitiveVisible ? t('Hide') : t('Show')}
className='text-muted-foreground hover:text-foreground size-8'
/>
}
>
{sensitiveVisible ? (
<Eye aria-hidden />
) : (
<EyeOff aria-hidden />
)}
</TooltipTrigger>
<TooltipContent>
{sensitiveVisible ? t('Hide') : t('Show')}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='icon'
onClick={() => void refetch()}
aria-label={t('Refresh')}
aria-busy={isFetching}
className='text-muted-foreground hover:text-foreground size-8'
/>
}
>
<RefreshCw
aria-hidden
className={cn(isFetching && 'animate-spin')}
/>
</TooltipTrigger>
<TooltipContent>{t('Refresh')}</TooltipContent>
</Tooltip>
</>
),
}}
getRowClassName={(row, { isMobile }) => {
if (!isDisabledChannelRow(row.original)) {
return undefined
}
if (isMobile) {
return DISABLED_ROW_MOBILE
}
return DISABLED_ROW_DESKTOP
}}
bulkActions={
batchMode ? <DataTableBulkActions table={table} /> : null
}
/>
</div>
</ChannelPriorityContext.Provider>
)
}
@@ -68,7 +68,6 @@ import {
isChannelEnabled,
isMultiKeyChannel,
} 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'
@@ -300,22 +299,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
{/* Detect Upstream Updates (only for fetchable channel types) */}
{MODEL_FETCHABLE_TYPES.has(channel.type) && (
<DropdownMenuItem
onClick={() => {
const meta = parseUpstreamUpdateMeta(channel.settings)
if (
meta.pendingAddModels.length > 0 ||
meta.pendingRemoveModels.length > 0
) {
upstream.openModal(
channel,
meta.pendingAddModels,
meta.pendingRemoveModels,
meta.pendingAddModels.length > 0 ? 'add' : 'remove'
)
} else {
upstream.detectChannelUpdates(channel)
}
}}
onClick={() => upstream.detectChannelUpdates(channel)}
>
{t('Upstream Updates')}
<DropdownMenuShortcut>
@@ -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 { Combobox } from '@/components/ui/combobox'
import {
ArrowDown,
ArrowDownToLine,
@@ -50,6 +49,7 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { Combobox } from '@/components/ui/combobox'
import { Input } from '@/components/ui/input'
import {
Popover,
@@ -59,7 +59,14 @@ import {
PopoverTitle,
PopoverTrigger,
} from '@/components/ui/popover'
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import { Switch } from '@/components/ui/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
@@ -673,7 +680,7 @@ export function AdvancedCustomEditorDialog({
onOpenChange={onOpenChange}
title={t('Advanced Custom Routes')}
description={t('Advanced Custom')}
contentClassName='flex max-h-[90vh] flex-col gap-0 p-0 sm:max-w-5xl'
contentClassName='flex max-h-[min(90dvh,var(--dialog-available-height))] flex-col gap-0 p-0 sm:max-w-5xl'
headerClassName='border-b px-6 py-4'
footerClassName='border-t px-6 py-4'
contentHeight='70vh'
@@ -736,15 +743,15 @@ export function AdvancedCustomEditorDialog({
</div>
<div className='flex flex-wrap gap-2'>
<Combobox
options={availableIncomingPathOptions}
value=''
onValueChange={(incomingPath) => {
options={availableIncomingPathOptions}
value=''
onValueChange={(incomingPath) => {
if (typeof incomingPath === 'string') addRoute(incomingPath)
}}
disabled={availableIncomingPathOptions.length === 0}
className='w-full'
placeholder={t('Add route')}
/>
disabled={availableIncomingPathOptions.length === 0}
className='w-full'
placeholder={t('Add route')}
/>
<Select
value={null}
onValueChange={(value) => {
@@ -1187,16 +1194,19 @@ function RouteGroupEditor({
) : null}
</div>
<Combobox
options={ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS.map((option) => ({
...option,
description: option.value,
disabled: (option.value !== incomingPath && usedIncomingPaths.has(option.value)) ||
(option.value === ADVANCED_CUSTOM_MODEL_LIST_PATH && group.routeRows.length > 1),
}))}
value={incomingPath}
onValueChange={onIncomingPathChange}
className='h-9 max-w-full lg:max-w-[420px]'
/>
options={ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS.map((option) => ({
...option,
description: option.value,
disabled:
(option.value !== incomingPath &&
usedIncomingPaths.has(option.value)) ||
(option.value === ADVANCED_CUSTOM_MODEL_LIST_PATH &&
group.routeRows.length > 1),
}))}
value={incomingPath}
onValueChange={onIncomingPathChange}
className='h-9 max-w-full lg:max-w-[420px]'
/>
</div>
</div>
) : null}
@@ -969,7 +969,7 @@ function ChannelTestDialogContent({
<span className='min-w-0 truncate'>{currentRow.name}</span>
</span>
}
contentClassName='max-h-[90vh] overflow-hidden sm:max-w-4xl'
contentClassName='max-h-[min(90dvh,var(--dialog-available-height))] overflow-y-auto sm:max-w-4xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
@@ -76,7 +76,7 @@ import { Skeleton } from '@/components/ui/skeleton'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import dayjs from '@/lib/dayjs'
import { formatDateTimeStr, formatTimestampToDate } from '@/lib/format'
import { createServerError } from '@/lib/server-error-message'
import { createServerError, getServerMessage } from '@/lib/server-error-message'
import { cn } from '@/lib/utils'
import {
@@ -966,7 +966,7 @@ export function CodexUsageDialog({
const errorMessage =
response?.success === false
? response?.message?.trim() || t('Failed to fetch usage')
? getServerMessage(response)?.trim() || t('Failed to fetch usage')
: ''
const loadResetCredits = useCallback(
@@ -225,7 +225,7 @@ export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) {
description={t(
'Batch edit all channels with this tag. Leave fields empty to keep current values.'
)}
contentClassName='max-h-[90vh] max-w-2xl'
contentClassName='max-h-[min(90dvh,var(--dialog-available-height))] max-w-2xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
@@ -42,6 +42,7 @@ import {
hasPermission,
} from '@/lib/admin-permissions'
import { handleServerError } from '@/lib/handle-server-error'
import { getServerMessage } from '@/lib/server-error-message'
import { useAuthStore } from '@/stores/auth-store'
import {
@@ -191,7 +192,7 @@ export function MultiKeyManageDialog({
}
if (response?.success) {
toast.success(response.message || t('Operation successful'))
toast.success(getServerMessage(response) || t('Operation successful'))
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
// Reload data - reset to page 1 for bulk actions
@@ -261,7 +262,7 @@ export function MultiKeyManageDialog({
description={t(
'Manage multi-key status and configuration for this channel'
)}
contentClassName='flex max-h-[90vh] max-w-5xl flex-col'
contentClassName='flex max-h-[min(90dvh,var(--dialog-available-height))] max-w-5xl flex-col'
titleClassName='flex items-center gap-2'
contentHeight='min(72vh, 720px)'
bodyClassName='space-y-4'
@@ -41,6 +41,7 @@ import { Progress } from '@/components/ui/progress'
import { Separator } from '@/components/ui/separator'
import { getFreshAuthHeaders } from '@/lib/api'
import { handleServerError } from '@/lib/handle-server-error'
import { getServerMessage } from '@/lib/server-error-message'
import {
deleteOllamaModel,
@@ -136,8 +137,8 @@ export function OllamaModelsDialog({
})
if (payloadLive?.success) {
normalized = normalizeOllamaModels(payloadLive.data)
} else if (payloadLive?.message) {
lastErr = String(payloadLive.message)
} else {
lastErr = getServerMessage(payloadLive) || ''
}
} catch (err: unknown) {
lastErr = err instanceof Error ? err.message : ''
@@ -151,7 +152,7 @@ export function OllamaModelsDialog({
normalized = normalizeOllamaModels(payload.data)
lastErr = ''
} else {
lastErr = String(payload?.message || '')
lastErr = getServerMessage(payload) || ''
}
}
@@ -85,6 +85,7 @@ type ParamOverrideOperation = {
keep_origin: boolean
logic: string
conditions: ParamOverrideCondition[]
prune_draft: PruneObjectsDraft | null
}
export type ParamOverrideEditorDialogProps = {
@@ -140,6 +141,7 @@ const CONDITION_MODE_OPTIONS = [
{ label: 'Prefix', value: 'prefix' },
{ label: 'Suffix', value: 'suffix' },
{ label: 'Contains', value: 'contains' },
{ label: 'Regex', value: 'regex' },
{ label: 'Greater Than', value: 'gt' },
{ label: 'Greater Than or Equal', value: 'gte' },
{ label: 'Less Than', value: 'lt' },
@@ -473,6 +475,14 @@ const parseLooseValue = (valueText: string): unknown => {
}
}
const parseConditionValue = (
condition: Pick<ParamOverrideCondition, 'mode' | 'value_text'>
): unknown => {
// Regex patterns are literal strings, even when they look like JSON values.
if (condition.mode === 'regex') return condition.value_text
return parseLooseValue(condition.value_text)
}
const verifyJSON = (text: string): boolean => {
try {
JSON.parse(text)
@@ -509,6 +519,10 @@ const normalizeOperation = (
? (operation.mode as string)
: 'set',
value_text: toValueText(operation.value),
prune_draft:
operation.mode === 'prune_objects'
? parsePruneObjectsDraft(toValueText(operation.value))
: null,
keep_origin: operation.keep_origin === true,
from: typeof operation.from === 'string' ? operation.from : '',
to: typeof operation.to === 'string' ? operation.to : '',
@@ -762,14 +776,7 @@ const buildReturnErrorValueText = (
// prune_objects helpers
type PruneRule = {
id: string
path: string
mode: string
value_text: string
invert: boolean
pass_missing_key: boolean
}
type PruneRule = ParamOverrideCondition
type PruneObjectsDraft = {
simpleMode: boolean
@@ -779,17 +786,6 @@ type PruneObjectsDraft = {
rules: PruneRule[]
}
const normalizePruneRule = (rule: Record<string, unknown> = {}): PruneRule => ({
id: nextLocalId(),
path: typeof rule.path === 'string' ? rule.path : '',
mode: CONDITION_MODE_VALUES.has(rule.mode as string)
? (rule.mode as string)
: 'full',
value_text: toValueText(rule.value),
invert: rule.invert === true,
pass_missing_key: rule.pass_missing_key === true,
})
const parsePruneObjectsDraft = (valueText: string): PruneObjectsDraft => {
const defaults: PruneObjectsDraft = {
simpleMode: true,
@@ -815,13 +811,13 @@ const parsePruneObjectsDraft = (valueText: string): PruneObjectsDraft => {
for (const [path, value] of Object.entries(
parsed.where as Record<string, unknown>
)) {
rules.push(normalizePruneRule({ path, mode: 'full', value }))
rules.push(normalizeCondition({ path, mode: 'full', value }))
}
}
if (Array.isArray(parsed.conditions)) {
for (const item of parsed.conditions) {
if (item && typeof item === 'object') {
rules.push(normalizePruneRule(item))
rules.push(normalizeCondition(item))
}
}
} else if (
@@ -832,7 +828,7 @@ const parsePruneObjectsDraft = (valueText: string): PruneObjectsDraft => {
for (const [path, value] of Object.entries(
parsed.conditions as Record<string, unknown>
)) {
rules.push(normalizePruneRule({ path, mode: 'full', value }))
rules.push(normalizeCondition({ path, mode: 'full', value }))
}
}
const typeText =
@@ -874,8 +870,9 @@ const buildPruneObjectsValueText = (draft: PruneObjectsDraft): string => {
path: String(rule.path || '').trim(),
mode: CONDITION_MODE_VALUES.has(rule.mode) ? rule.mode : 'full',
}
const valueRaw = String(rule.value_text || '').trim()
if (valueRaw !== '') conditionPayload.value = parseLooseValue(valueRaw)
if (rule.mode === 'regex' || rule.value_text.trim() !== '') {
conditionPayload.value = parseConditionValue(rule)
}
if (rule.invert) conditionPayload.invert = true
if (rule.pass_missing_key) conditionPayload.pass_missing_key = true
return conditionPayload
@@ -922,7 +919,7 @@ const buildConditionPayload = (
const payload: Record<string, unknown> = {
path,
mode: condition.mode || 'full',
value: parseLooseValue(condition.value_text),
value: parseConditionValue(condition),
}
if (condition.invert) payload.invert = true
if (condition.pass_missing_key) payload.pass_missing_key = true
@@ -982,7 +979,18 @@ const validateOperations = (
if (mode === 'prune_objects') {
const raw = op.value_text.trim()
if (!raw) {
const draft = op.prune_draft
const hasDraftCondition =
draft &&
(draft.typeText.trim() ||
(!draft.simpleMode && draft.rules.some((rule) => rule.path.trim())))
const parsed = parseLooseValue(raw)
// Imported object rules can explicitly match an empty type string.
const hasExplicitType =
parsed !== null &&
typeof parsed === 'object' &&
Object.hasOwn(parsed, 'type')
if (!raw || (draft && !hasDraftCondition && !hasExplicitType)) {
return t('Rule {{line}} prune_objects is missing conditions', { line })
}
}
@@ -1248,12 +1256,7 @@ export function ParamOverrideEditorDialog(
return parseReturnErrorDraft(selectedOperation.value_text)
}, [selectedOperation])
const pruneObjectsDraft = useMemo(() => {
if (!selectedOperation || selectedOperation.mode !== 'prune_objects') {
return null
}
return parsePruneObjectsDraft(selectedOperation.value_text)
}, [selectedOperation])
const pruneObjectsDraft = selectedOperation?.prune_draft ?? null
const topOperationModes = useMemo(() => {
const counts: Record<string, number> = {}
@@ -1273,7 +1276,17 @@ export function ParamOverrideEditorDialog(
const updateOperation = useCallback(
(operationId: string, patch: Partial<ParamOverrideOperation>) => {
setOperations((prev) =>
prev.map((o) => (o.id === operationId ? { ...o, ...patch } : o))
prev.map((op) => {
if (op.id !== operationId) return op
const next = { ...op, ...patch }
if (next.mode !== op.mode || patch.value_text !== undefined) {
next.prune_draft =
next.mode === 'prune_objects'
? parsePruneObjectsDraft(next.value_text)
: null
}
return next
})
)
},
[]
@@ -1303,11 +1316,20 @@ export function ParamOverrideEditorDialog(
conditions: source.conditions.map((c) => ({
path: c.path,
mode: c.mode,
value: parseLooseValue(c.value_text),
value: parseConditionValue(c),
invert: c.invert,
pass_missing_key: c.pass_missing_key,
})),
})
if (source.prune_draft) {
cloned.prune_draft = {
...source.prune_draft,
rules: source.prune_draft.rules.map((rule) => ({
...rule,
id: nextLocalId(),
})),
}
}
insertedId = cloned.id
const next = [...prev]
next.splice(idx + 1, 0, cloned)
@@ -1403,13 +1425,17 @@ export function ParamOverrideEditorDialog(
setOperations((prev) =>
prev.map((op) => {
if (op.id !== operationId) return op
const draft = parsePruneObjectsDraft(op.value_text)
const draft = op.prune_draft
if (!draft) return op
const nextDraft =
typeof updater === 'function'
? updater(draft)
: { ...draft, ...updater }
return {
...op,
// Keep unfinished rows and their IDs in local state. The serialized
// value is only the valid payload, never the source of the next edit.
prune_draft: nextDraft,
value_text: buildPruneObjectsValueText(nextDraft),
}
})
@@ -1423,7 +1449,7 @@ export function ParamOverrideEditorDialog(
updatePruneObjectsDraft(operationId, (draft) => ({
...draft,
simpleMode: false,
rules: [...draft.rules, normalizePruneRule({})],
rules: [...draft.rules, createDefaultCondition()],
}))
},
[updatePruneObjectsDraft]
@@ -1747,7 +1773,7 @@ export function ParamOverrideEditorDialog(
description={t(
'Create request parameter override rules with a visual editor or raw JSON.'
)}
contentClassName='flex max-h-[90vh] flex-col gap-0 p-0 sm:max-w-5xl'
contentClassName='flex max-h-[min(90dvh,var(--dialog-available-height))] flex-col gap-0 p-0 sm:max-w-5xl'
headerClassName='border-b px-6 py-4'
footerClassName='border-t px-6 py-4'
contentHeight='min(72vh, 720px)'
@@ -2563,6 +2589,7 @@ function ConditionEditor(conditionEditorProps: ConditionEditorProps) {
{t('Match Mode')}
</label>
<Combobox
aria-label={t('Match Mode')}
options={CONDITION_MODE_OPTIONS.map((o) => ({
value: o.value,
label: t(o.label),
@@ -2584,6 +2611,7 @@ function ConditionEditor(conditionEditorProps: ConditionEditorProps) {
{t('Match Value')}
</label>
<Input
aria-label={t('Match Value')}
value={condition.value_text}
onChange={(e) =>
conditionEditorProps.updateCondition(
@@ -2592,7 +2620,7 @@ function ConditionEditor(conditionEditorProps: ConditionEditorProps) {
{ value_text: e.target.value }
)
}
placeholder='gpt'
placeholder={condition.mode === 'regex' ? '^gpt-' : 'gpt'}
className='h-8 text-xs'
/>
</div>
@@ -3069,6 +3097,7 @@ function PruneObjectsEditor(pruneObjectsEditorProps: PruneObjectsEditorProps) {
{t('Match Mode')}
</label>
<Combobox
aria-label={t('Match Mode')}
options={CONDITION_MODE_OPTIONS.map((o) => ({
value: o.value,
label: t(o.label),
@@ -3090,6 +3119,7 @@ function PruneObjectsEditor(pruneObjectsEditorProps: PruneObjectsEditorProps) {
{t('Match Value (optional)')}
</label>
<Input
aria-label={t('Match Value (optional)')}
value={rule.value_text}
onChange={(e) =>
pruneObjectsEditorProps.updateRule(
@@ -16,277 +16,295 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { Search } from 'lucide-react'
import { useState, useMemo } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import { EmptyState } from '@/components/empty-state'
import { ErrorState } from '@/components/error-state'
import { LoadingState } from '@/components/loading-state'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { toIntlLocale } from '@/i18n/languages'
import { formatNumber } from '@/lib/format'
interface UpstreamUpdateDialogProps {
open: boolean
addModels: string[]
removeModels: string[]
preferredTab: 'add' | 'remove'
confirmLoading: boolean
onConfirm: (data: { addModels: string[]; removeModels: string[] }) => void
onCancel: () => void
import type { ChannelUpstreamUpdateState } from '../../hooks/use-channel-upstream-updates'
import { UpstreamModelSelection } from '../upstream-model-selection'
type UpstreamUpdateDialogProps = {
upstream: ChannelUpstreamUpdateState
}
function ModelChangeList(props: { title: string; models: string[] }) {
const { t, i18n } = useTranslation()
const locale = toIntlLocale(i18n.resolvedLanguage || i18n.language)
return (
<section aria-label={props.title} className='min-w-0 space-y-2'>
<h3 className='text-sm font-medium'>
{props.title} ({formatNumber(props.models.length, locale)})
</h3>
{props.models.length ? (
<ul className='max-h-40 space-y-1 overflow-y-auto rounded-md border p-3 font-mono text-xs break-all'>
{props.models.map((model) => (
<li key={model}>{model}</li>
))}
</ul>
) : (
<p className='text-muted-foreground text-sm'>{t('None')}</p>
)}
</section>
)
}
export function UpstreamUpdateDialog(props: UpstreamUpdateDialogProps) {
if (!props.upstream.showModal || !props.upstream.channel) return null
// Each preview gets fresh selections, including when detection finishes or
// another channel is opened. Keeping a hidden dialog mounted retained stale state.
return (
<UpstreamUpdateSession
key={`${props.upstream.previewVersion}-${props.upstream.result ? 'result' : 'preview'}`}
upstream={props.upstream}
/>
)
}
function UpstreamUpdateSession(props: UpstreamUpdateDialogProps) {
const { t } = useTranslation()
const [activeTab, setActiveTab] = useState(props.preferredTab)
const [searchAdd, setSearchAdd] = useState('')
const [searchRemove, setSearchRemove] = useState('')
const [selectedAdd, setSelectedAdd] = useState<Set<string>>(
() => new Set(props.addModels)
)
const [selectedRemove, setSelectedRemove] = useState<Set<string>>(
() => new Set(props.removeModels)
)
const [partialConfirmOpen, setPartialConfirmOpen] = useState(false)
const filteredAdd = useMemo(
() =>
props.addModels.filter((m) =>
m.toLowerCase().includes(searchAdd.toLowerCase())
),
[props.addModels, searchAdd]
)
const filteredRemove = useMemo(
() =>
props.removeModels.filter((m) =>
m.toLowerCase().includes(searchRemove.toLowerCase())
),
[props.removeModels, searchRemove]
)
const toggleModel = (
model: string,
set: Set<string>,
setter: (s: Set<string>) => void
) => {
const next = new Set(set)
if (next.has(model)) next.delete(model)
else next.add(model)
setter(next)
}
const toggleAllVisible = (
models: string[],
set: Set<string>,
setter: (s: Set<string>) => void
) => {
const allSelected = models.every((m) => set.has(m))
const next = new Set(set)
if (allSelected) {
models.forEach((m) => next.delete(m))
} else {
models.forEach((m) => next.add(m))
}
setter(next)
}
const handleConfirm = () => {
const hasAdd = props.addModels.length > 0
const hasRemove = props.removeModels.length > 0
const selectedAddArr = Array.from(selectedAdd)
const selectedRemoveArr = Array.from(selectedRemove)
const anyAdd = selectedAddArr.length > 0
const anyRemove = selectedRemoveArr.length > 0
if (hasAdd && hasRemove && anyAdd !== anyRemove) {
setPartialConfirmOpen(true)
return
}
props.onConfirm({
addModels: selectedAddArr,
removeModels: selectedRemoveArr,
})
}
const [activeTab, setActiveTab] = useState(props.upstream.preferredTab)
const [selectedAdd, setSelectedAdd] = useState(props.upstream.addModels)
const [selectedRemove, setSelectedRemove] = useState<string[]>([])
const [confirmOpen, setConfirmOpen] = useState(false)
const result = props.upstream.result
const hasChanges =
props.upstream.addModels.length > 0 ||
props.upstream.removeModels.length > 0
const isPreview =
!props.upstream.detectLoading && !props.upstream.detectError && !result
const channelLabel = `#${props.upstream.channel?.id} ${props.upstream.channel?.name ?? ''}`
const summary = t('Add {{added}} models and remove {{removed}} models', {
added: selectedAdd.length,
removed: selectedRemove.length,
})
return (
<>
<Dialog
open={props.open}
onOpenChange={(v) => !v && props.onCancel()}
title={t('Upstream Model Updates')}
contentClassName='sm:max-w-lg'
contentHeight='auto'
open
onOpenChange={(open) => !open && props.upstream.closeModal()}
showCloseButton={!props.upstream.applyLoading}
title={
result ? t('Update results') : t('Preview upstream model changes')
}
description={<span className='break-all'>{channelLabel}</span>}
bodyClassName='space-y-4'
titleClassName='pr-6 leading-snug'
footerClassName='sm:flex-wrap'
footer={
<>
<Button variant='outline' onClick={props.onCancel}>
{t('Cancel')}
</Button>
<Button
onClick={handleConfirm}
disabled={
props.confirmLoading ||
(props.addModels.length === 0 &&
props.removeModels.length === 0)
}
variant='outline'
onClick={props.upstream.closeModal}
disabled={props.upstream.applyLoading}
>
{t('Confirm')}
{isPreview && hasChanges ? t('Cancel') : t('Close')}
</Button>
{isPreview && (
<>
<Button
className='h-auto min-h-8 max-w-full whitespace-normal'
variant='outline'
onClick={() =>
props.upstream.detectChannelUpdates(props.upstream.channel)
}
disabled={props.upstream.applyLoading}
>
{t('Refresh preview')}
</Button>
<Button
className='h-auto min-h-8 max-w-full whitespace-normal'
onClick={() => setConfirmOpen(true)}
disabled={
props.upstream.applyLoading ||
!!props.upstream.applyError ||
(!selectedAdd.length && !selectedRemove.length)
}
>
{t('Review selected changes')}
</Button>
</>
)}
</>
}
>
<p className='text-muted-foreground text-sm'>
{t(
'Select models to process. Unselected "add" models will be ignored.'
)}
</p>
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as 'add' | 'remove')}
>
<TabsList className='grid w-full grid-cols-2'>
<TabsTrigger value='add' className='gap-1'>
{t('Add Models')}
<StatusBadge variant='neutral' className='ml-1' copyable={false}>
{selectedAdd.size}/{props.addModels.length}
</StatusBadge>
</TabsTrigger>
<TabsTrigger value='remove' className='gap-1'>
{t('Remove Models')}
<StatusBadge variant='neutral' className='ml-1' copyable={false}>
{selectedRemove.size}/{props.removeModels.length}
</StatusBadge>
</TabsTrigger>
</TabsList>
<TabsContent value='add' className='space-y-3'>
<div className='relative'>
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-4 w-4' />
<Input
placeholder={t('Search models...')}
className='pl-8'
value={searchAdd}
onChange={(e) => setSearchAdd(e.target.value)}
/>
</div>
{filteredAdd.length > 0 && (
<div className='flex items-center gap-2'>
<Checkbox
checked={filteredAdd.every((m) => selectedAdd.has(m))}
onCheckedChange={() =>
toggleAllVisible(filteredAdd, selectedAdd, setSelectedAdd)
}
/>
<span className='text-muted-foreground text-xs'>
{t('Select All Visible')}
</span>
</div>
{props.upstream.detectLoading && (
<LoadingState
message={t(
'Checking upstream models. Channel models will only change after confirmation.'
)}
<ScrollArea className='h-[280px] rounded-md border p-2'>
{filteredAdd.length > 0 ? (
<div className='space-y-1'>
{filteredAdd.map((model) => (
<label
key={model}
className='hover:bg-accent flex cursor-pointer items-center gap-2 rounded px-2 py-1.5'
>
<Checkbox
checked={selectedAdd.has(model)}
onCheckedChange={() =>
toggleModel(model, selectedAdd, setSelectedAdd)
}
/>
<span className='truncate text-sm'>{model}</span>
</label>
))}
</div>
) : (
<p className='text-muted-foreground py-8 text-center text-sm'>
{props.addModels.length === 0
? t('No models to add')
: t('No matching results')}
</p>
)}
</ScrollArea>
</TabsContent>
<TabsContent value='remove' className='space-y-3'>
<div className='relative'>
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-4 w-4' />
<Input
placeholder={t('Search models...')}
className='pl-8'
value={searchRemove}
onChange={(e) => setSearchRemove(e.target.value)}
/>
</div>
{filteredRemove.length > 0 && (
<div className='flex items-center gap-2'>
<Checkbox
checked={filteredRemove.every((m) => selectedRemove.has(m))}
onCheckedChange={() =>
toggleAllVisible(
filteredRemove,
selectedRemove,
setSelectedRemove
)
}
/>
<span className='text-muted-foreground text-xs'>
{t('Select All Visible')}
</span>
</div>
)}
<ScrollArea className='h-[280px] rounded-md border p-2'>
{filteredRemove.length > 0 ? (
<div className='space-y-1'>
{filteredRemove.map((model) => (
<label
key={model}
className='hover:bg-accent flex cursor-pointer items-center gap-2 rounded px-2 py-1.5'
>
<Checkbox
checked={selectedRemove.has(model)}
onCheckedChange={() =>
toggleModel(model, selectedRemove, setSelectedRemove)
}
/>
<span className='truncate text-sm'>{model}</span>
</label>
))}
</div>
) : (
<p className='text-muted-foreground py-8 text-center text-sm'>
{props.removeModels.length === 0
? t('No models to remove')
: t('No matching results')}
</p>
)}
</ScrollArea>
</TabsContent>
</Tabs>
</Dialog>
<ConfirmDialog
open={partialConfirmOpen}
onOpenChange={setPartialConfirmOpen}
title={t('Partial Submission')}
desc={t(
'There are both add and remove models pending, but you only selected one type. Confirm submitting only the selected items?'
/>
)}
handleConfirm={() => {
setPartialConfirmOpen(false)
props.onConfirm({
addModels: Array.from(selectedAdd),
removeModels: Array.from(selectedRemove),
{props.upstream.detectError && (
<ErrorState
title={t('Detection failed')}
description={props.upstream.detectError}
onRetry={() =>
props.upstream.detectChannelUpdates(props.upstream.channel)
}
/>
)}
{result && (
<>
<Alert>
<AlertTitle>{t('Update completed')}</AlertTitle>
<AlertDescription>
{t(
'The server returned the following result. Unselected models remain pending.'
)}
</AlertDescription>
</Alert>
<ModelChangeList
title={t('Added models')}
models={result.addedModels}
/>
<ModelChangeList
title={t('Removed models')}
models={result.removedModels}
/>
<ModelChangeList
title={t('Still pending addition')}
models={result.remainingModels}
/>
<ModelChangeList
title={t('Still pending removal')}
models={result.remainingRemoveModels}
/>
</>
)}
{isPreview && (
<>
<Alert>
<AlertDescription>
{t(
'Review the model changes before applying. Unselected models stay unchanged and will not be ignored.'
)}
</AlertDescription>
</Alert>
{props.upstream.applyError && (
<Alert variant='destructive'>
<AlertTitle>
{t('Unable to confirm the update result')}
</AlertTitle>
<AlertDescription>
<p>{props.upstream.applyError}</p>
<p>
{t(
'Refresh the preview before trying again; some changes may already have been saved.'
)}
</p>
</AlertDescription>
</Alert>
)}
{hasChanges && (
<p className='text-sm font-medium' aria-live='polite'>
{summary}
</p>
)}
{hasChanges ? (
<Tabs
value={activeTab}
onValueChange={(value) =>
setActiveTab(value as 'add' | 'remove')
}
>
<TabsList className='grid w-full grid-cols-2 group-data-horizontal/tabs:h-auto'>
<TabsTrigger
value='add'
className='h-auto min-h-7 whitespace-normal'
>
{t('Add Models')} ({props.upstream.addModels.length})
</TabsTrigger>
<TabsTrigger
value='remove'
className='h-auto min-h-7 whitespace-normal'
>
{t('Remove Models')} ({props.upstream.removeModels.length})
</TabsTrigger>
</TabsList>
<TabsContent value='add'>
<UpstreamModelSelection
models={props.upstream.addModels}
selected={selectedAdd}
onChange={(models) =>
!props.upstream.applyLoading && setSelectedAdd(models)
}
existingModels={[]}
summaryText={t('Add Models')}
showChanges={false}
/>
</TabsContent>
<TabsContent value='remove' className='space-y-3'>
<Alert variant='destructive'>
<AlertDescription>
{t(
'Removed models will no longer be available through this channel. Select removals explicitly.'
)}
</AlertDescription>
</Alert>
<UpstreamModelSelection
models={props.upstream.removeModels}
selected={selectedRemove}
onChange={(models) =>
!props.upstream.applyLoading && setSelectedRemove(models)
}
existingModels={[]}
summaryText={t('Remove Models')}
showChanges={false}
/>
</TabsContent>
</Tabs>
) : (
<EmptyState
title={t(
'No processable upstream model updates for this channel'
)}
/>
)}
</>
)}
</Dialog>
<ConfirmDialog
open={confirmOpen && !result}
onOpenChange={(open) =>
!props.upstream.applyLoading && setConfirmOpen(open)
}
title={t('Confirm model changes')}
desc={
<>
<span className='break-all'>{channelLabel}</span>
<p>{summary}</p>
</>
}
confirmText={
props.upstream.applyLoading
? t('Applying...')
: t('Apply selected changes')
}
destructive={selectedRemove.length > 0}
isLoading={props.upstream.applyLoading}
className='max-h-(--dialog-available-height) overflow-y-auto [&_button]:h-auto [&_button]:min-h-8 [&_button]:whitespace-normal'
handleConfirm={async () => {
await props.upstream.applyUpdates({
addModels: selectedAdd,
removeModels: selectedRemove,
})
setConfirmOpen(false)
}}
/>
>
<ModelChangeList title={t('Add Models')} models={selectedAdd} />
<ModelChangeList title={t('Remove Models')} models={selectedRemove} />
</ConfirmDialog>
</>
)
}
@@ -105,6 +105,7 @@ import { Skeleton } from '@/components/ui/skeleton'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { SecureVerificationDialog } from '@/features/auth/secure-verification'
import { PluginIcon } from '@/features/task-plugins/components/plugin-icon'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { useHiddenClickUnlock } from '@/hooks/use-hidden-click-unlock'
import {
@@ -139,6 +140,7 @@ import {
ADD_MODE_OPTIONS,
CLAUDE_FIELD_PASSTHROUGH_TYPES,
CHANNEL_STATUS_LABELS,
CHANNEL_TYPE_NEW_API,
CHANNEL_TYPE_OLLAMA,
CHANNEL_TYPE_OPTIONS,
CHANNEL_TYPE_TASK_PLUGIN,
@@ -175,11 +177,7 @@ import {
parseModelsString,
formatModelsArray,
mergeModelMappingPairs,
deriveModelMappingPairs,
detectModelNamingPatterns,
findVerifiedAlias,
type ModelMappingPair,
type ModelNamingSuggestion,
extractRedirectModels,
extractMappingSourceModels,
hasModelConfigChanged,
@@ -196,6 +194,7 @@ import {
import {
getChannelPluginExtensions,
supportsChannelPluginExtensions,
supportsNewAPIUpstream,
} from '../../lib/channel-plugin-extensions'
import { getChannelTypeConfig } from '../../lib/channel-type-config'
import {
@@ -294,13 +293,16 @@ const SENSITIVE_FORM_FIELDS = [
'advanced_custom',
'is_enterprise_account',
'vertex_key_type',
'vertex_project_id',
'aws_key_type',
'aws_api_type',
'azure_responses_version',
'force_format',
'thinking_to_content',
'proxy',
'http_protocol',
'http2_connection_shards',
'tls_insecure_skip_verify',
'pass_through_body_enabled',
'responses_websocket_enabled',
'system_prompt',
@@ -481,7 +483,6 @@ export function ChannelMutateDrawer({
const mappingDraftTokenRef = useRef(0)
const [batchMapping, setBatchMapping] = useState<{
source: ModelMappingBatchSource
selected: string[]
} | null>(null)
const [redirectPanelOpen, setRedirectPanelOpen] = useState(false)
const [redirectSyncModels, setRedirectSyncModels] = useState(true)
@@ -594,6 +595,7 @@ export function ChannelMutateDrawer({
const currentStatus = formValues.status
const currentBaseUrl = formValues.base_url
const currentTaskPluginKey = formValues.task_plugin_key
const currentTaskExtendPluginKeys = formValues.task_extend_plugin_keys
const currentKey = formValues.key
const currentModels = formValues.models
const currentModelMapping = formValues.model_mapping
@@ -771,10 +773,15 @@ export function ChannelMutateDrawer({
const canHavePluginExtensions = supportsChannelPluginExtensions(currentType)
const pluginExtensions = useMemo(() => {
if (!canBindTaskPlugin || !taskPluginOptionsQuery.isSuccess) return []
return getChannelPluginExtensions(currentType, taskPluginOptionsQuery.data)
return getChannelPluginExtensions(
currentType,
taskPluginOptionsQuery.data,
currentTaskExtendPluginKeys
)
}, [
canBindTaskPlugin,
currentType,
currentTaskExtendPluginKeys,
taskPluginOptionsQuery.isSuccess,
taskPluginOptionsQuery.data,
])
@@ -1238,10 +1245,6 @@ export function ChannelMutateDrawer({
upstreamModelList.length > 0 ? 'upstream' : 'channel'
const canBatchMap =
currentModelsArray.length > 0 || upstreamModelList.length > 0
const namingSuggestions = useMemo(
() => detectModelNamingPatterns(upstreamModelList, allModelsList),
[upstreamModelList, allModelsList]
)
const fetchDiscoveredModels = discovery.fetch
const handleFetchModels = useCallback(async () => {
const type = form.getValues('type')
@@ -1329,6 +1332,46 @@ export function ChannelMutateDrawer({
[form]
)
const taskPluginExtensionOptions = useMemo(
() =>
(taskPluginOptionsQuery.data ?? [])
.filter(supportsNewAPIUpstream)
.map((plugin) => ({
value: plugin.key,
label: plugin.name,
hint: plugin.key,
icon: <PluginIcon plugin={plugin} size={16} />,
})),
[taskPluginOptionsQuery.data]
)
// Binding an upstream plugin publishes its models like the type-61 prefill;
// unbinding removes the models that no remaining bound plugin declares.
const handleTaskExtendPluginKeysChange = useCallback(
(keys: string[]) => {
const plugins = taskPluginOptionsQuery.data ?? []
const declaredBy = (bound: readonly string[]) =>
new Set(
plugins
.filter((plugin) => bound.includes(plugin.key))
.flatMap((plugin) => plugin.models)
)
const previous = form.getValues('task_extend_plugin_keys') ?? []
const added = declaredBy(keys.filter((key) => !previous.includes(key)))
const kept = declaredBy(keys)
const dropped = declaredBy(previous.filter((key) => !keys.includes(key)))
const models = parseModelsString(form.getValues('models') || '').filter(
(model) => kept.has(model) || !dropped.has(model)
)
for (const model of added) {
if (!models.includes(model)) models.push(model)
}
form.setValue('task_extend_plugin_keys', keys, { shouldDirty: true })
form.setValue('models', models.join(','), { shouldDirty: true })
},
[form, taskPluginOptionsQuery.data]
)
const raiseMappingDraft = useCallback(
(from: string, to: string, focus?: 'from' | 'to') => {
mappingDraftTokenRef.current += 1
@@ -1447,9 +1490,7 @@ export function ChannelMutateDrawer({
setRedirectPanelOpen(false)
}, [])
// Redirect one fetched upstream model. On wide screens the floating panel
// stays beside the list; a name the platform already knows is applied at
// once, anything else becomes a draft row awaiting the request name.
// Open a draft without guessing or publishing a request name.
const handleFetchedRedirect = useCallback(
(model: string) => {
if (window.innerWidth < REDIRECT_PANEL_MIN_VIEWPORT) {
@@ -1457,35 +1498,9 @@ export function ChannelMutateDrawer({
return
}
openRedirectPanel()
const alias = findVerifiedAlias(model, namingSuggestions, allModelsList)
if (alias) {
applyMappingPairs([alias], redirectSyncModels)
raiseMappingDraft(alias.from, alias.to, 'from')
return
}
raiseMappingDraft('', model, 'from')
},
[
requestMappingDraft,
openRedirectPanel,
namingSuggestions,
allModelsList,
applyMappingPairs,
redirectSyncModels,
raiseMappingDraft,
]
)
const handleApplySuggestion = useCallback(
(suggestion: ModelNamingSuggestion) => {
const derivation = deriveModelMappingPairs(
suggestion.models,
'upstream',
suggestion.rule
)
applyMappingPairs(derivation.pairs, redirectSyncModels)
},
[applyMappingPairs, redirectSyncModels]
[requestMappingDraft, openRedirectPanel, raiseMappingDraft]
)
// Handle successful submission
@@ -2148,6 +2163,32 @@ export function ChannelMutateDrawer({
/>
)
const tlsInsecureSkipVerifyFields = (
<FormField
control={form.control}
name='tls_insecure_skip_verify'
render={({ field }) => (
<FormItem className='flex items-center justify-between px-4 py-3'>
<div className='space-y-0.5'>
<FormLabel>{t('Skip Upstream TLS Verification')}</FormLabel>
<FormDescription>
{t(
'Accept upstream certificates that fail validation, such as self-signed ones. Only enable for trusted internal upstreams.'
)}
</FormDescription>
</div>
<FormControl>
<Switch
disabled={sensitiveLocked}
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
)
const routingFields = (
<div
role='group'
@@ -2461,7 +2502,6 @@ export function ChannelMutateDrawer({
? () =>
setBatchMapping({
source: batchMappingSource,
selected: [],
})
: undefined
}
@@ -3081,6 +3121,38 @@ export function ChannelMutateDrawer({
<ChannelModelsSection>
<div className='space-y-5'>
<div className='border-border/60 bg-muted/10 rounded-lg border p-4'>
{currentType === CHANNEL_TYPE_NEW_API &&
canBindTaskPlugin &&
taskPluginOptionsQuery.isSuccess &&
!showProviderPicker && (
<FormField
control={form.control}
name='task_extend_plugin_keys'
render={({ field }) => (
<FormItem className='mb-4'>
<FormLabel>{t('Upstream task plugins')}</FormLabel>
<FormControl>
<MultiSelect
options={taskPluginExtensionOptions}
selected={field.value ?? []}
onChange={handleTaskExtendPluginKeysChange}
placeholder={t(
'Select the task plugins installed on the upstream gateway'
)}
maxVisibleChips={8}
disabled={!canEditSensitive}
/>
</FormControl>
<FormDescription>
{t(
'This channel serves the models of every selected plugin. The upstream New API gateway must have the same plugins installed.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name='models'
@@ -3328,9 +3400,6 @@ export function ChannelMutateDrawer({
onClick={() =>
setBatchMapping({
source: 'upstream',
selected: discovery.models.filter((model) =>
currentModelsArray.includes(model)
),
})
}
>
@@ -3648,6 +3717,54 @@ export function ChannelMutateDrawer({
)}
{/* AWS (type 33) */}
{currentType === 33 && (
<FormField
control={form.control}
name='aws_api_type'
render={({ field }) => (
<FormItem>
<FormLabel>{t('AWS API')}</FormLabel>
<Select
disabled={sensitiveLocked}
items={[
{
value: 'invoke_model',
label: t('InvokeModel (legacy)'),
},
{
value: 'mantle',
label: t('Mantle (Messages API)'),
},
]}
onValueChange={field.onChange}
value={field.value ?? 'invoke_model'}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
<SelectItem value='invoke_model'>
{t('InvokeModel (legacy)')}
</SelectItem>
<SelectItem value='mantle'>
{t('Mantle (Messages API)')}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<FormDescription>
{t(
'Use Mantle for supported Claude models and automatic prompt caching. Keep InvokeModel for older models and Amazon Nova.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{currentType === 33 && (
<FormField
control={form.control}
@@ -3852,6 +3969,29 @@ export function ChannelMutateDrawer({
</FormItem>
)}
/>
{vertexKeyType === 'api_key' && (
<FormField
control={form.control}
name='vertex_project_id'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Vertex AI Project ID')}</FormLabel>
<FormControl>
<Input
placeholder={t('e.g., my-project-123')}
{...field}
/>
</FormControl>
<FormDescription>
{t(
'Optional. Without it, Google picks the region; with it, requests go to the deployment region below.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{vertexKeyType === 'json' && (
<FormItem>
<FormLabel>{t('Service account JSON file(s)')}</FormLabel>
@@ -4654,6 +4794,7 @@ export function ChannelMutateDrawer({
{proxyFields}
{httpProtocolFields}
{httpShardsFields}
{tlsInsecureSkipVerifyFields}
</fieldset>
</div>
{upstreamModelDetectionFields}
@@ -4920,14 +5061,9 @@ export function ChannelMutateDrawer({
mappingValue={formValues.model_mapping || ''}
onMappingChange={handlePanelMappingChange}
onMappingCommit={handlePanelMappingCommit}
suggestions={namingSuggestions}
onApplySuggestion={handleApplySuggestion}
onOpenRules={() =>
onBatchAdd={() =>
setBatchMapping({
source: 'upstream',
selected: upstreamModelList.filter((model) =>
currentModelsArray.includes(model)
),
source: batchMappingSource,
})
}
syncModels={redirectSyncModels}
@@ -4967,7 +5103,6 @@ export function ChannelMutateDrawer({
upstreamModels={upstreamModelList}
channelModels={currentModelsArray}
initialSource={batchMapping.source}
initialSelected={batchMapping.selected}
onOpenChange={(nextOpen) => {
if (!nextOpen) setBatchMapping(null)
}}
@@ -16,16 +16,31 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { ArrowRight } from 'lucide-react'
import { type ReactNode, useId, useMemo, useState } from 'react'
import { ArrowRight01Icon } from '@hugeicons/core-free-icons'
import { HugeiconsIcon } from '@hugeicons/react'
import { useId, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Dialog } from '@/components/dialog'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
@@ -36,8 +51,6 @@ import {
} from '../lib'
import { UpstreamModelSelection } from './upstream-model-selection'
const PREVIEW_LIMIT = 8
/** Where the selectable names come from. */
export type ModelMappingBatchSource = 'upstream' | 'channel'
@@ -47,16 +60,12 @@ export type ModelMappingBatchResult = {
syncModels: boolean
}
type RuleType = ModelMappingRule['type']
type ModelMappingBatchDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
/** Names returned by the provider's model list; enables the upstream source. */
upstreamModels?: string[]
channelModels: string[]
initialSource?: ModelMappingBatchSource
initialSelected?: string[]
onApply: (result: ModelMappingBatchResult) => void
}
@@ -65,153 +74,48 @@ export function ModelMappingBatchDialog(props: ModelMappingBatchDialogProps) {
const id = useId()
const upstreamModels = props.upstreamModels ?? []
const hasUpstream = upstreamModels.length > 0
const [source, setSource] = useState<ModelMappingBatchSource>(
() => props.initialSource ?? (hasUpstream ? 'upstream' : 'channel')
)
const [selected, setSelected] = useState<string[]>(
props.initialSelected ?? []
const [source, setSource] = useState<ModelMappingBatchSource>(() =>
hasUpstream ? (props.initialSource ?? 'upstream') : 'channel'
)
// Opening a batch action never opts models into a change on the user's behalf.
const [selected, setSelected] = useState<string[]>([])
const [direction, setDirection] = useState<ModelMappingDirection>(
source === 'upstream' ? 'upstream' : 'request'
)
const [ruleType, setRuleType] = useState<RuleType>(
const [ruleType, setRuleType] = useState<ModelMappingRule['type']>(
direction === 'upstream' ? 'strip-suffix' : 'add-suffix'
)
const [stripPrefixes, setStripPrefixes] = useState('')
const [stripSuffixes, setStripSuffixes] = useState('')
const [prefix, setPrefix] = useState('')
const [suffix, setSuffix] = useState('')
const [affix, setAffix] = useState('')
const [find, setFind] = useState('')
const [replaceWith, setReplaceWith] = useState('')
const [syncModels, setSyncModels] = useState(true)
const candidates =
source === 'upstream' ? upstreamModels : props.channelModels
const rule = useMemo<ModelMappingRule>(() => {
if (ruleType === 'strip-prefix') {
return { type: 'strip-prefix', values: stripPrefixes }
if (ruleType === 'strip-prefix' || ruleType === 'strip-suffix') {
return { type: ruleType, values: affix }
}
if (ruleType === 'strip-suffix') {
return { type: 'strip-suffix', values: stripSuffixes }
if (ruleType === 'add-prefix' || ruleType === 'add-suffix') {
return { type: ruleType, value: affix }
}
if (ruleType === 'add-prefix') return { type: 'add-prefix', value: prefix }
if (ruleType === 'add-suffix') return { type: 'add-suffix', value: suffix }
return { type: 'replace', find, replaceWith }
}, [
ruleType,
stripPrefixes,
stripSuffixes,
prefix,
suffix,
find,
replaceWith,
])
}, [ruleType, affix, find, replaceWith])
const derivation = useMemo(
() => deriveModelMappingPairs(selected, direction, rule),
[selected, direction, rule]
)
const preview = derivation.pairs.slice(0, PREVIEW_LIMIT)
const remaining = derivation.pairs.length - preview.length
const changeDirection = (next: ModelMappingDirection) => {
setDirection(next)
setRuleType(next === 'upstream' ? 'strip-suffix' : 'add-suffix')
}
const changeSource = (next: ModelMappingBatchSource) => {
setSource(next)
setSelected([])
changeDirection(next === 'upstream' ? 'upstream' : 'request')
}
const replaceControls = (
<div className='grid gap-2 sm:grid-cols-2'>
<Input
aria-label={t('Find')}
placeholder='.'
value={find}
onChange={(event) => setFind(event.target.value)}
disabled={ruleType !== 'replace'}
/>
<Input
aria-label={t('Replace with')}
placeholder='-'
value={replaceWith}
onChange={(event) => setReplaceWith(event.target.value)}
disabled={ruleType !== 'replace'}
/>
</div>
)
const affixHint = t('Separate several values with commas')
let ruleOptions: Array<{
type: RuleType
label: string
control: ReactNode
hint?: string
}>
if (direction === 'upstream') {
ruleOptions = [
{
type: 'strip-suffix',
label: t('Strip suffix'),
hint: affixHint,
control: (
<Input
aria-label={t('Suffix')}
placeholder='-all, -latest'
value={stripSuffixes}
onChange={(event) => setStripSuffixes(event.target.value)}
disabled={ruleType !== 'strip-suffix'}
/>
),
},
{
type: 'strip-prefix',
label: t('Strip prefix'),
hint: affixHint,
control: (
<Input
aria-label={t('Prefix')}
placeholder='openai/, anthropic/'
value={stripPrefixes}
onChange={(event) => setStripPrefixes(event.target.value)}
disabled={ruleType !== 'strip-prefix'}
/>
),
},
{ type: 'replace', label: t('Replace text'), control: replaceControls },
const hasRule =
ruleType === 'replace' ? find.length > 0 : affix.trim().length > 0
const isPrefix = ruleType === 'add-prefix' || ruleType === 'strip-prefix'
const isStrip = ruleType === 'strip-prefix' || ruleType === 'strip-suffix'
const ruleOptions: Array<{ value: ModelMappingRule['type']; label: string }> =
[
{ value: 'strip-suffix', label: t('Strip suffix') },
{ value: 'strip-prefix', label: t('Strip prefix') },
{ value: 'add-suffix', label: t('Add suffix') },
{ value: 'add-prefix', label: t('Add prefix') },
{ value: 'replace', label: t('Replace text') },
]
} else {
ruleOptions = [
{
type: 'add-suffix',
label: t('Add suffix'),
control: (
<Input
aria-label={t('Suffix')}
placeholder='-all'
value={suffix}
onChange={(event) => setSuffix(event.target.value)}
disabled={ruleType !== 'add-suffix'}
/>
),
},
{
type: 'add-prefix',
label: t('Add prefix'),
control: (
<Input
aria-label={t('Prefix')}
placeholder='openai/'
value={prefix}
onChange={(event) => setPrefix(event.target.value)}
disabled={ruleType !== 'add-prefix'}
/>
),
},
{ type: 'replace', label: t('Replace text'), control: replaceControls },
]
}
return (
<Dialog
@@ -219,9 +123,10 @@ export function ModelMappingBatchDialog(props: ModelMappingBatchDialogProps) {
onOpenChange={props.onOpenChange}
title={t('Batch add mappings')}
description={t(
'Pick models and derive the other side with a rule to create request-to-upstream mappings.'
'Users call the model on the left. The platform forwards the request to the upstream model on the right.'
)}
contentClassName='sm:max-w-4xl'
bodyClassName='flex flex-col gap-5'
footer={
<>
<Button
@@ -233,7 +138,7 @@ export function ModelMappingBatchDialog(props: ModelMappingBatchDialogProps) {
</Button>
<Button
type='button'
disabled={derivation.pairs.length === 0}
disabled={!hasRule || derivation.pairs.length === 0}
onClick={() => {
props.onApply({
pairs: derivation.pairs,
@@ -247,24 +152,102 @@ export function ModelMappingBatchDialog(props: ModelMappingBatchDialogProps) {
</>
}
>
<div className='grid gap-6 lg:grid-cols-2'>
<section aria-labelledby={`${id}-models`} className='min-w-0 space-y-3'>
<FieldSet>
<FieldLegend id={`${id}-task`} variant='label'>
{t('What do you want to change?')}
</FieldLegend>
<RadioGroup
aria-labelledby={`${id}-task`}
value={direction}
className='sm:grid-cols-2'
onValueChange={(value) => {
if (value !== 'upstream' && value !== 'request') return
setDirection(value)
setRuleType(value === 'upstream' ? 'strip-suffix' : 'add-suffix')
setAffix('')
setFind('')
setReplaceWith('')
}}
>
<FieldLabel htmlFor={`${id}-aliases`}>
<Field orientation='horizontal'>
<RadioGroupItem
id={`${id}-aliases`}
value='upstream'
aria-labelledby={`${id}-aliases-label`}
aria-describedby={`${id}-aliases-hint`}
/>
<div className='min-w-0'>
<span id={`${id}-aliases-label`}>
{t('Create aliases for users')}
</span>
<FieldDescription id={`${id}-aliases-hint`}>
{t('Keep upstream names; choose what users call.')}
</FieldDescription>
</div>
</Field>
</FieldLabel>
<FieldLabel htmlFor={`${id}-upstream`}>
<Field orientation='horizontal'>
<RadioGroupItem
id={`${id}-upstream`}
value='request'
aria-labelledby={`${id}-upstream-label`}
aria-describedby={`${id}-upstream-hint`}
/>
<div className='min-w-0'>
<span id={`${id}-upstream-label`}>
{t('Change upstream model names')}
</span>
<FieldDescription id={`${id}-upstream-hint`}>
{t(
'Keep the names users call; change what is sent upstream.'
)}
</FieldDescription>
</div>
</Field>
</FieldLabel>
</RadioGroup>
</FieldSet>
<div className='grid gap-6 md:grid-cols-2'>
<section
aria-labelledby={`${id}-models`}
className='flex min-w-0 flex-col gap-3'
>
<h3 id={`${id}-models`} className='text-sm font-semibold'>
{t('Select models')}
{direction === 'upstream'
? t('1. Select upstream models')
: t('1. Select models users call')}
</h3>
{hasUpstream && (
<Tabs
value={source}
onValueChange={(value) =>
changeSource(value === 'channel' ? 'channel' : 'upstream')
}
onValueChange={(value) => {
if (value !== 'channel' && value !== 'upstream') return
setSource(value)
setSelected([])
}}
>
<TabsList variant='line' aria-label={t('Select models')}>
<TabsTrigger value='upstream'>
{t('Upstream model list')} ({upstreamModels.length})
<TabsList
variant='line'
className='grid w-full grid-cols-2 group-data-horizontal/tabs:h-auto'
aria-label={t('Select models')}
>
<TabsTrigger
value='upstream'
className='h-auto min-h-8 whitespace-normal'
>
<span>
{t('Upstream model list')} ({upstreamModels.length})
</span>
</TabsTrigger>
<TabsTrigger value='channel'>
{t('Channel models')} ({props.channelModels.length})
<TabsTrigger
value='channel'
className='h-auto min-h-8 whitespace-normal'
>
<span>
{t('Channel models')} ({props.channelModels.length})
</span>
</TabsTrigger>
</TabsList>
</Tabs>
@@ -283,161 +266,138 @@ export function ModelMappingBatchDialog(props: ModelMappingBatchDialogProps) {
}
/>
</section>
<div className='min-w-0 space-y-5'>
<section aria-labelledby={`${id}-direction`} className='space-y-2'>
<h3 id={`${id}-direction`} className='text-sm font-semibold'>
{t('Selected names are')}
</h3>
<RadioGroup
value={direction}
aria-labelledby={`${id}-direction`}
onValueChange={(value) => {
if (value === 'upstream' || value === 'request') {
changeDirection(value)
}
}}
className='gap-2'
>
<div className='flex items-start gap-3'>
<RadioGroupItem
value='upstream'
id={`${id}-direction-upstream`}
className='mt-0.5'
/>
<div className='min-w-0 space-y-0.5'>
<Label
htmlFor={`${id}-direction-upstream`}
className='font-normal'
>
{t('Upstream names returned by the provider')}
</Label>
<p className='text-muted-foreground text-xs'>
{t('Derives the request names your users call')}
</p>
</div>
</div>
<div className='flex items-start gap-3'>
<RadioGroupItem
value='request'
id={`${id}-direction-request`}
className='mt-0.5'
/>
<div className='min-w-0 space-y-0.5'>
<Label
htmlFor={`${id}-direction-request`}
className='font-normal'
>
{t('Request names your users call')}
</Label>
<p className='text-muted-foreground text-xs'>
{t('Derives the upstream names sent to the provider')}
</p>
</div>
</div>
</RadioGroup>
</section>
<section aria-labelledby={`${id}-rule`} className='space-y-3'>
<h3 id={`${id}-rule`} className='text-sm font-semibold'>
{t('Rule')}
</h3>
<RadioGroup
value={ruleType}
aria-labelledby={`${id}-rule`}
onValueChange={(value) => {
const option = ruleOptions.find((item) => item.type === value)
if (option) setRuleType(option.type)
}}
className='gap-3'
>
{ruleOptions.map((option) => (
<div key={option.type} className='flex items-start gap-3'>
<RadioGroupItem
value={option.type}
id={`${id}-rule-${option.type}`}
className='mt-2'
/>
<div className='min-w-0 flex-1 space-y-2'>
<Label
htmlFor={`${id}-rule-${option.type}`}
className='font-normal'
>
<section
aria-labelledby={`${id}-naming`}
className='flex min-w-0 flex-col gap-3'
>
<h3 id={`${id}-naming`} className='text-sm font-semibold'>
{direction === 'upstream'
? t('2. Set names users call')
: t('2. Set upstream model names')}
</h3>
<FieldGroup>
<Field>
<FieldLabel htmlFor={`${id}-rule`}>{t('Rule')}</FieldLabel>
<Select
items={ruleOptions}
value={ruleType}
onValueChange={(value) => {
const option = ruleOptions.find(
(item) => item.value === value
)
if (option) {
setRuleType(option.value)
setAffix('')
}
}}
>
<SelectTrigger id={`${id}-rule`} className='w-full'>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ruleOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</Label>
{option.control}
{option.hint && ruleType === option.type && (
<p className='text-muted-foreground text-xs'>
{option.hint}
</p>
)}
</div>
</div>
))}
</RadioGroup>
</section>
{direction === 'upstream' && (
<div className='flex items-start gap-3 rounded-md border p-3'>
<Checkbox
id={`${id}-sync`}
className='mt-0.5'
checked={syncModels}
onCheckedChange={(checked) => setSyncModels(checked === true)}
/>
<Label htmlFor={`${id}-sync`} className='leading-5 font-normal'>
{t(
'Publish the request names and remove the raw upstream names from the model list'
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
{ruleType === 'replace' ? (
<div className='grid gap-3 sm:grid-cols-2'>
<Field>
<FieldLabel htmlFor={`${id}-find`}>{t('Find')}</FieldLabel>
<Input
id={`${id}-find`}
placeholder='.'
value={find}
onChange={(event) => setFind(event.target.value)}
/>
</Field>
<Field>
<FieldLabel htmlFor={`${id}-replacement`}>
{t('Replace with')}
</FieldLabel>
<Input
id={`${id}-replacement`}
placeholder='-'
value={replaceWith}
onChange={(event) => setReplaceWith(event.target.value)}
/>
</Field>
</div>
) : (
<Field>
<FieldLabel htmlFor={`${id}-affix`}>
{isPrefix ? t('Prefix') : t('Suffix')}
</FieldLabel>
<Input
id={`${id}-affix`}
placeholder={isPrefix ? 'openai/' : '-all'}
value={affix}
onChange={(event) => setAffix(event.target.value)}
/>
{isStrip && (
<FieldDescription>
{t('Separate several values with commas')}
</FieldDescription>
)}
</Label>
</div>
)}
<section aria-labelledby={`${id}-preview`} className='space-y-2'>
</Field>
)}
</FieldGroup>
<section
aria-labelledby={`${id}-preview`}
className='mt-2 flex min-w-0 flex-col gap-3 rounded-lg border p-3'
>
<h3 id={`${id}-preview`} className='text-sm font-semibold'>
{t('Preview')}
{t('3. Review mappings')}
</h3>
{selected.length === 0 && (
<div className='text-muted-foreground grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] gap-2 text-xs'>
<span>{t('Users call')}</span>
<span aria-hidden='true'>→</span>
<span>{t('Upstream receives')}</span>
</div>
{derivation.pairs.length > 0 && hasRule && (
<ul
aria-label={t('Preview')}
className='flex max-h-56 flex-col gap-2 overflow-y-auto font-mono text-xs'
>
{derivation.pairs.map((pair) => (
<li
key={pair.from}
className='grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-start gap-2'
>
<span className='min-w-0 wrap-anywhere'>{pair.from}</span>
<HugeiconsIcon
icon={ArrowRight01Icon}
className='size-3.5 shrink-0'
aria-hidden='true'
/>
<span className='min-w-0 wrap-anywhere'>{pair.to}</span>
</li>
))}
</ul>
)}
{(!hasRule || selected.length === 0) && (
<p className='text-muted-foreground text-sm'>
{t('Select at least one model to preview mappings.')}
{selected.length === 0
? t('Select at least one model to preview mappings.')
: t('Enter a rule to preview mappings.')}
</p>
)}
{derivation.pairs.length > 0 && (
<div className='rounded-md border p-3 font-mono text-xs'>
<div className='text-muted-foreground mb-2 grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] gap-2 font-sans'>
<span>{t('Request Model Name')}</span>
<span className='w-3.5' />
<span>{t('Upstream Model Name')}</span>
</div>
<ul
aria-label={t('Preview')}
className='max-h-56 space-y-1 overflow-y-auto'
>
{preview.map((pair) => (
<li
key={pair.to}
className='grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-start gap-2'
>
<span className='min-w-0 wrap-anywhere'>{pair.from}</span>
<ArrowRight
className='mt-0.5 size-3.5 shrink-0'
aria-hidden='true'
/>
<span className='min-w-0 wrap-anywhere'>{pair.to}</span>
</li>
))}
{remaining > 0 && (
<li className='text-muted-foreground'>
{t('+{{count}} more', { count: remaining })}
</li>
)}
</ul>
</div>
{selected.length === 0 && (
<p className='text-muted-foreground text-xs'>
{t('Example: users call gpt-4o; upstream receives gpt-4o-all.')}
</p>
)}
{derivation.unchanged.length > 0 && (
{hasRule && derivation.unchanged.length > 0 && (
<p className='text-muted-foreground text-xs'>
{t('{{count}} model(s) unchanged by the rule were skipped', {
count: derivation.unchanged.length,
})}
</p>
)}
{derivation.conflicts.length > 0 && (
{hasRule && derivation.conflicts.length > 0 && (
<p className='text-warning text-xs'>
{t(
'{{count}} model(s) skipped because another model derives the same request name',
@@ -446,7 +406,24 @@ export function ModelMappingBatchDialog(props: ModelMappingBatchDialogProps) {
</p>
)}
</section>
</div>
{direction === 'upstream' && (
<Field orientation='horizontal'>
<Checkbox
id={`${id}-sync`}
checked={syncModels}
onCheckedChange={(checked) => setSyncModels(checked === true)}
/>
<FieldLabel
htmlFor={`${id}-sync`}
className='leading-5 font-normal'
>
{t(
'Publish the request names and remove the raw upstream names from the model list'
)}
</FieldLabel>
</Field>
)}
</section>
</div>
</Dialog>
)
@@ -434,6 +434,11 @@ export function ModelMappingEditor(props: ModelMappingEditorProps) {
)}
<TabsContent value='visual' className='space-y-2'>
<p className='text-muted-foreground text-xs'>
{t(
'Users call the model on the left. The platform forwards the request to the upstream model on the right.'
)}
</p>
{rows.length > 0 ? (
<div className='space-y-2'>
{showFilter && (
@@ -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 { Wand2 } from 'lucide-react'
import { useId } from 'react'
import { useTranslation } from 'react-i18next'
@@ -25,11 +24,9 @@ import {
type FloatingWindowPosition,
} from '@/components/floating-window'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import type { ModelNamingSuggestion } from '../lib'
import {
ModelMappingEditor,
type ModelMappingDraftRequest,
@@ -44,9 +41,7 @@ type ModelRedirectPanelProps = {
onMappingChange: (value: string) => void
/** Called once an edit settles; the drawer syncs the model list here. */
onMappingCommit: (value: string) => void
suggestions: ModelNamingSuggestion[]
onApplySuggestion: (suggestion: ModelNamingSuggestion) => void
onOpenRules: () => void
onBatchAdd: () => void
syncModels: boolean
onSyncModelsChange: (value: boolean) => void
sourceModelOptions: string[]
@@ -77,6 +72,7 @@ export function ModelRedirectPanel(props: ModelRedirectPanelProps) {
defaultWidth={props.width}
minWidth={340}
storageKey='channel-model-redirects'
expandRequest={props.draftRequest?.token}
onClose={props.onClose}
footer={
<div className='flex items-start gap-3'>
@@ -99,65 +95,17 @@ export function ModelRedirectPanel(props: ModelRedirectPanelProps) {
</div>
}
>
<div className='space-y-4'>
<div className='space-y-2'>
{props.suggestions.length > 0 && (
<p className='text-muted-foreground text-xs'>
{t('Suggested rules')}
</p>
)}
<div className='flex flex-wrap items-center gap-2'>
{props.suggestions.map((suggestion) => {
let label = ''
if (suggestion.rule.type === 'replace') {
label = t('Replace {{find}} with {{replaceWith}}', {
find: suggestion.rule.find,
replaceWith: suggestion.rule.replaceWith,
})
} else if ('values' in suggestion.rule) {
label = t('Strip {{affix}}', {
affix: suggestion.rule.values,
})
}
return (
<Button
key={suggestion.id}
type='button'
variant='secondary'
size='xs'
disabled={props.disabled}
onClick={() => props.onApplySuggestion(suggestion)}
>
<Wand2 aria-hidden='true' />
{label}
<Badge variant='outline' className='h-4 px-1 text-[10px]'>
{suggestion.models.length}
</Badge>
</Button>
)
})}
<Button
type='button'
variant='ghost'
size='xs'
disabled={props.disabled}
onClick={props.onOpenRules}
>
{t('More rules')}
</Button>
</div>
</div>
<ModelMappingEditor
value={props.mappingValue}
onChange={props.onMappingChange}
onCommit={props.onMappingCommit}
disabled={props.disabled}
sourceModelOptions={props.sourceModelOptions}
targetModelOptions={props.targetModelOptions}
draftRequest={props.draftRequest}
onDraftRequestHandled={props.onDraftRequestHandled}
/>
</div>
<ModelMappingEditor
value={props.mappingValue}
onChange={props.onMappingChange}
onCommit={props.onMappingCommit}
onBatchAdd={props.onBatchAdd}
disabled={props.disabled}
sourceModelOptions={props.sourceModelOptions}
targetModelOptions={props.targetModelOptions}
draftRequest={props.draftRequest}
onDraftRequestHandled={props.onDraftRequestHandled}
/>
</FloatingWindow>
)
}
@@ -167,6 +167,7 @@ export function NumericSpinnerInput({
{editing ? (
<input
data-slot='numeric-spinner-value'
ref={inputRef}
type='text'
value={localValue}
@@ -178,6 +179,7 @@ export function NumericSpinnerInput({
/>
) : (
<button
data-slot='numeric-spinner-value'
type='button'
onClick={handleStartEdit}
disabled={disabled}
@@ -72,7 +72,7 @@ function ModelCategory(props: ModelCategoryProps) {
const allSelected = selectedCount === props.models.length
return (
<Collapsible defaultOpen className='rounded-lg border'>
<Collapsible defaultOpen className='@container rounded-lg border'>
<div className='flex items-center gap-3 px-3'>
<Checkbox
aria-label={t('Select all models in {{category}}', {
@@ -109,7 +109,7 @@ function ModelCategory(props: ModelCategoryProps) {
</CollapsibleTrigger>
</div>
<CollapsibleContent className='border-t px-3 py-2'>
<div className='grid gap-2 sm:grid-cols-2'>
<div className='grid gap-2 @lg:grid-cols-2'>
{props.models.map((model) => (
<div key={model} className='flex min-w-0 items-start gap-2'>
<Checkbox
+10 -3
View File
@@ -16,6 +16,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
export const CHANNEL_PRIORITY_GUIDE_KEYS = {
card: 'channels:priority-drag-guide:card:v1',
table: 'channels:priority-drag-guide:v1',
} as const
// ============================================================================
// Channel Types (from constant/channel.go)
// All label/name values are i18n keys; use t(value) when displaying.
@@ -84,7 +89,7 @@ export const CHANNEL_TYPES = {
51: 'Jimeng',
52: 'Vidu',
53: 'Submodel',
54: 'DoubaoVideo',
54: 'Doubao',
55: 'Sora',
56: 'Replicate',
57: 'ChatGPT Subscription (Codex)',
@@ -157,7 +162,8 @@ export const CHANNEL_PROVIDER_PRESENTATION: Partial<
52: { descriptionKey: 'Connect to Vidu video generation services' },
53: { descriptionKey: 'Connect to Submodel model services' },
54: {
descriptionKey: 'Generate Doubao Seedance videos through Volcengine Ark',
descriptionKey:
'Connect to Doubao Seedance video and Seedream image generation through Volcengine Ark',
},
55: { descriptionKey: 'Connect to OpenAI Sora video generation services' },
56: { descriptionKey: 'Access hosted model predictions through Replicate' },
@@ -171,7 +177,8 @@ export const CHANNEL_PROVIDER_PRESENTATION: Partial<
},
59: { descriptionKey: 'Connect to model services through a Sub2API gateway' },
60: {
descriptionKey: 'Connect to model services from another New API instance',
descriptionKey:
'Connect to New API model services with support for multiple task plugins',
},
62: { descriptionKey: 'Connect to self-hosted models served by vLLM' },
63: { descriptionKey: 'Connect to self-hosted models served by SGLang' },
@@ -0,0 +1,794 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import {
animate,
type AnimationPlaybackControls,
type ValueAnimationTransition,
} from 'motion'
import { useReducedMotion } from 'motion/react'
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type KeyboardEvent,
type PointerEvent,
} from 'react'
import {
previewPriorityDrop,
type PriorityDrop,
type PriorityPosition,
} from '../lib/channel-priority-order'
import type { Channel } from '../types'
import type { useChannelPriorityOrder } from './use-channel-priority-order'
function scrollParent(element: HTMLElement): HTMLElement {
let parent = element.parentElement
while (parent) {
if (/(auto|scroll)/.test(getComputedStyle(parent).overflowY)) return parent
parent = parent.parentElement
}
return document.documentElement
}
function animateDragTransform(
element: HTMLElement,
transform: string,
transition: ValueAnimationTransition<string>
): AnimationPlaybackControls {
// Animate the value directly so Motion cannot reapply a DOM transform after
// cleanup and React's final reorder have restored the row's natural position.
return animate(
element.style.transform || 'translate3d(0px, 0px, 0px)',
transform,
{
...transition,
onUpdate: (value) => {
element.style.transform = value
},
}
)
}
type DragOptions = {
items: Channel[]
desc: boolean
enabled: boolean
scope: string
begin: () => void
select: (drop: PriorityDrop | null) => void
commit: () => Promise<void>
cancel: () => void
previewStore: ReturnType<typeof useChannelPriorityOrder>['previewStore']
}
type DragSlot = {
id: number
element: HTMLElement
rect: DOMRect
transform: string
minHeight: string
willChange: string
boxShadow: string
backgroundColor: string
x: number
y: number
}
type DragPoint = {
pointerId: number
id: number
startX: number
startY: number
x: number
y: number
moved: boolean
clone: HTMLElement | null
overlay: HTMLElement | null
feedback: HTMLElement | null
insertion: HTMLElement | null
insertionY: number | null
drop: PriorityDrop | null
source: HTMLElement
opacity: string
outline: string
scroller: HTMLElement
scrollBounds: DOMRect
rootBounds: DOMRect
target: string
slots: DragSlot[]
slotsByID: Map<number, DragSlot>
items: Channel[]
ordered: Channel[]
hitIndex: number
hitTarget: number | null
horizontal: boolean
table: boolean
destination: DOMRect | null
scrollTop: number
scrollLeft: number
lastFrame: number
commit: boolean
}
/** The gesture keeps React rows and DOM order fixed. Only transforms change. */
export function useChannelPriorityDrag(options: DragOptions) {
const root = useRef<HTMLDivElement>(null)
const point = useRef<DragPoint | null>(null)
const [feedbackContainer, setFeedbackContainer] =
useState<HTMLElement | null>(null)
const frame = useRef<number | undefined>(undefined)
const released = useRef<DragPoint | null>(null)
const landing = useRef<AnimationPlaybackControls | null>(null)
const animations = useRef(new Map<HTMLElement, AnimationPlaybackControls>())
const latest = useRef(options)
latest.current = options
const reduced = useReducedMotion()
const transition = useMemo(
() =>
reduced
? { duration: 0 }
: { type: 'spring' as const, stiffness: 450, damping: 38 },
[reduced]
)
const clearVisuals = useCallback((current: DragPoint) => {
current.overlay?.remove()
setFeedbackContainer(null)
current.source.style.opacity = current.opacity
current.source.style.outline = current.outline
for (const slot of current.slots) {
animations.current.get(slot.element)?.stop()
Object.assign(slot.element.style, {
transform: slot.transform,
minHeight: slot.minHeight,
willChange: slot.willChange,
boxShadow: slot.boxShadow,
backgroundColor: slot.backgroundColor,
})
delete slot.element.dataset.priorityDrop
}
animations.current.clear()
if (root.current) delete root.current.dataset.priorityDragging
released.current = null
}, [])
const preview = useCallback(
(current: DragPoint, drop: PriorityDrop | null, settling = false) => {
const ordered = drop
? previewPriorityDrop(current.items, drop, latest.current.desc)
: current.items
current.ordered = ordered
current.drop = drop
if (current.clone) {
current.clone.style.outline =
drop && drop.position !== 'same'
? '1px solid var(--warning)'
: '1px solid var(--primary)'
}
const targetPriority =
current.items.find((item) => item.id === drop?.target_id)?.priority ?? 0
current.insertionY = null
if (current.table && drop && drop.position !== 'same') {
const tierIDs = new Set(
current.items
.filter(
(item) =>
item.id !== current.id &&
(item.priority ?? 0) === targetPriority
)
.map((item) => item.id)
)
const tier = current.slots.filter((slot) => tierIDs.has(slot.id))
const before = (drop.position === 'before') === latest.current.desc
current.insertionY =
(before ? tier[0]?.rect.top : tier.at(-1)?.rect.bottom) ?? null
}
for (const [index, item] of ordered.entries()) {
const slot = current.slotsByID.get(item.id)
const destination = current.slots[index]?.rect
if (!slot || !destination) continue
const joining =
drop?.position === 'same' &&
item.id !== current.id &&
(item.priority ?? 0) === targetPriority
if (joining) slot.element.dataset.priorityDrop = 'same'
else delete slot.element.dataset.priorityDrop
slot.element.style.boxShadow = joining
? 'inset 0 0 0 2px var(--primary)'
: slot.boxShadow
if (current.table) {
slot.element.style.backgroundColor = joining
? 'color-mix(in srgb, var(--primary) 8%, var(--card))'
: slot.backgroundColor
}
const x = destination.left - slot.rect.left
const y = destination.top - slot.rect.top
if (item.id === current.id) current.destination = destination
// Table rows remain stable hit targets; animate the result on release.
if (current.table && !settling) continue
if (slot.x === x && slot.y === y) continue
slot.x = x
slot.y = y
const transform = `translate3d(${x}px, ${y}px, 0)`
animations.current.get(slot.element)?.stop()
if (item.id === current.id) {
if (!current.table) slot.element.style.transform = transform
} else if (transition.duration === 0) {
slot.element.style.transform = transform
} else {
animations.current.set(
slot.element,
animateDragTransform(slot.element, transform, transition)
)
}
}
},
[transition]
)
const stop = useCallback(
(commit = false) => {
const current = point.current
if (!current) {
if (released.current && !commit) {
released.current.commit = false
landing.current?.complete()
}
return
}
point.current = null
if (frame.current !== undefined) cancelAnimationFrame(frame.current)
frame.current = undefined
if (root.current?.hasPointerCapture(current.pointerId)) {
root.current.releasePointerCapture(current.pointerId)
}
if (!current.moved || !current.clone) return
if (current.feedback) current.feedback.hidden = true
if (current.insertion) current.insertion.hidden = true
current.commit = commit && !!current.target
released.current = current
if (transition.duration === 0) {
clearVisuals(current)
if (current.commit) void latest.current.commit()
else latest.current.cancel()
return
}
if (current.table) {
if (current.overlay) current.overlay.hidden = true
if (!current.commit) {
clearVisuals(current)
latest.current.cancel()
return
}
current.source.style.opacity = current.opacity
preview(current, current.drop, true)
const source = current.slotsByID.get(current.id)
if (!source) {
clearVisuals(current)
latest.current.cancel()
return
}
landing.current = animateDragTransform(
current.source,
`translate3d(${source.x}px, ${source.y}px, 0)`,
{
...transition,
onComplete: () => {
clearVisuals(current)
if (current.commit) void latest.current.commit()
else latest.current.cancel()
},
}
)
return
}
if (!current.commit) preview(current, null)
const origin = current.slotsByID.get(current.id)?.rect
const target = current.destination ?? origin
if (!origin || !target) {
clearVisuals(current)
latest.current.cancel()
return
}
const x =
target.left -
origin.left -
current.scroller.scrollLeft +
current.scrollLeft
const y =
target.top - origin.top - current.scroller.scrollTop + current.scrollTop
landing.current = animateDragTransform(
current.clone,
`translate3d(${x}px, ${y}px, 0)`,
{
...transition,
onComplete: () => {
clearVisuals(current)
// Commit changes React's order exactly once, after the visual landing.
if (current.commit) void latest.current.commit()
else latest.current.cancel()
},
}
)
},
[clearVisuals, preview, transition]
)
const tick = useCallback(
(time: number) => {
frame.current = undefined
const current = point.current
if (!current || !root.current) return
if (!current.moved) {
if (
Math.hypot(current.x - current.startX, current.y - current.startY) <=
4
) {
return
}
// Batch every layout read before adding the ghost or writing styles.
const elements = [
...root.current.querySelectorAll<HTMLElement>(
'[data-sortable-id^="channel:"]'
),
]
current.slots = elements.map((element) => ({
id: Number(element.dataset.sortableId?.split(':')[1]),
element,
rect: element.getBoundingClientRect(),
transform: element.style.transform,
minHeight: element.style.minHeight,
willChange: element.style.willChange,
boxShadow: element.style.boxShadow,
backgroundColor: element.style.backgroundColor,
x: 0,
y: 0,
}))
current.slotsByID = new Map(
current.slots.map((slot) => [slot.id, slot])
)
const origin = current.slotsByID.get(current.id)?.rect
if (!origin) return
current.table = current.source.tagName === 'TR'
current.scrollBounds = current.scroller.getBoundingClientRect()
current.rootBounds = root.current.getBoundingClientRect()
current.scrollTop = current.scroller.scrollTop
current.scrollLeft = current.scroller.scrollLeft
current.horizontal =
!current.table &&
current.slots.some(
(slot, index) =>
index > 0 &&
Math.abs(
slot.rect.top - (current.slots[index - 1]?.rect.top ?? Infinity)
) < 4
)
current.destination = origin
current.moved = true
latest.current.begin()
let clone: HTMLElement
if (current.table) {
clone = document.createElement('div')
const identity = document.createElement('div')
identity.className =
'flex min-w-0 items-center gap-2 px-3 pt-3 text-sm'
const name = document.createElement('span')
name.className = 'min-w-0 flex-1 truncate font-medium'
// Read displayed text so the preview respects the visibility toggle.
name.textContent =
current.source.querySelector('[data-column-id="name"]')
?.textContent ?? ''
const priority = document.createElement('span')
priority.dataset.columnId = 'priority'
priority.className =
'text-muted-foreground shrink-0 font-mono text-xs'
const value = document.createElement('span')
value.dataset.slot = 'numeric-spinner-value'
value.textContent =
current.source.querySelector(
'[data-column-id="priority"] [data-slot="numeric-spinner-value"]'
)?.textContent ?? ''
priority.append(value)
identity.append(name, priority)
clone.append(identity)
} else clone = current.source.cloneNode(true) as HTMLElement
clone.style.animation = 'none'
clone.removeAttribute('id')
clone.removeAttribute('data-sortable-id')
clone
.querySelectorAll('[id]')
.forEach((element) => element.removeAttribute('id'))
const ghost = clone
ghost.dataset.originX = String(origin.left)
ghost.dataset.originY = String(origin.top)
Object.assign(ghost.style, {
position: 'absolute',
left: current.table ? '0' : `${origin.left}px`,
top: current.table ? '0' : `${origin.top}px`,
margin: '0',
width: current.table ? '300px' : `${origin.width}px`,
maxWidth: 'calc(100vw - 16px)',
height: current.table ? 'auto' : `${origin.height}px`,
pointerEvents: 'none',
background: 'var(--card)',
borderRadius: 'var(--radius)',
boxShadow: '0 12px 28px #0003',
outline: '1px solid var(--primary)',
overflow: 'hidden',
willChange: 'transform',
})
const overlay = document.createElement('div')
Object.assign(overlay.style, {
position: 'fixed',
inset: '0',
overflow: 'hidden',
pointerEvents: 'none',
zIndex: '1000',
})
overlay.setAttribute('aria-hidden', 'true')
overlay.inert = true
const insertion = document.createElement('div')
insertion.dataset.priorityInsertion = ''
insertion.hidden = true
Object.assign(insertion.style, {
position: 'absolute',
left: '0',
top: '0',
background: 'var(--warning)',
borderRadius: '999px',
boxShadow: '0 0 0 2px var(--background)',
pointerEvents: 'none',
})
const feedback = document.createElement('div')
Object.assign(feedback.style, {
position: current.table ? 'relative' : 'absolute',
left: '0',
top: '0',
width: '300px',
maxWidth: 'calc(100vw - 16px)',
pointerEvents: 'none',
willChange: 'transform',
})
if (current.table) {
feedback.dataset.priorityView = 'table'
ghost.append(feedback)
overlay.append(insertion, ghost)
} else overlay.append(ghost, insertion, feedback)
document.body.append(overlay)
current.feedback = feedback
current.insertion = insertion
setFeedbackContainer(feedback)
current.overlay = overlay
current.clone = ghost
for (const slot of current.slots) {
slot.element.style.willChange = 'transform'
if (slot.element.tagName !== 'TR') {
slot.element.style.minHeight = `${slot.rect.height}px`
}
}
current.source.style.opacity = '0.2'
current.source.style.outline = '2px dashed var(--primary)'
root.current.dataset.priorityDragging = 'true'
}
// No geometry reads, sorting or React table updates in the pointer frame.
const scrollBefore = current.scroller.scrollTop
const bounds = current.scrollBounds
const elapsed = current.lastFrame
? Math.min(32, time - current.lastFrame)
: 16
current.lastFrame = time
if (current.x >= bounds.left && current.x <= bounds.right) {
if (current.y < bounds.top + 32) {
current.scroller.scrollTop -= elapsed * 0.45
} else if (current.y > bounds.bottom - 32) {
current.scroller.scrollTop += elapsed * 0.45
}
}
const scrollX = current.scroller.scrollLeft - current.scrollLeft
const scrollY = current.scroller.scrollTop - current.scrollTop
const pointerX = current.x + scrollX
const pointerY = current.y + scrollY
let hitIndex = -1
let distance = Infinity
for (const [index, slot] of current.slots.entries()) {
const dx = pointerX - slot.rect.left - slot.rect.width / 2
const dy = pointerY - slot.rect.top - slot.rect.height / 2
const squared = dx * dx + dy * dy
if (squared < distance) {
hitIndex = index
distance = squared
}
}
let next: PriorityDrop | null = null
const slot = current.slots[hitIndex]
if (
slot &&
pointerX >= slot.rect.left - 8 &&
pointerX <= slot.rect.right + 8 &&
pointerY >= slot.rect.top - 8 &&
pointerY <= slot.rect.bottom + 8 &&
current.x >= current.rootBounds.left &&
current.x <= current.rootBounds.right &&
current.y >= current.rootBounds.top &&
current.y <= current.rootBounds.bottom
) {
if (current.table) {
current.hitIndex = hitIndex
current.hitTarget = slot.id === current.id ? null : slot.id
} else if (hitIndex !== current.hitIndex) {
current.hitIndex = hitIndex
const occupant = current.ordered[hitIndex]
// Resolve the card/row now occupying this slot, not its original ID.
// A moving placeholder retains the previous target instead of resetting.
if (occupant && occupant.id !== current.id) {
current.hitTarget = occupant.id
}
}
if (current.hitTarget !== null) {
const offset = current.horizontal
? pointerX - slot.rect.left
: pointerY - slot.rect.top
const size = current.horizontal ? slot.rect.width : slot.rect.height
const edge = Math.min(current.table ? 10 : 24, size * 0.18)
const leading: PriorityPosition = latest.current.desc
? 'before'
: 'after'
const trailing: PriorityPosition = latest.current.desc
? 'after'
: 'before'
let leadingEdge = edge
let trailingEdge = size - edge
if (current.drop?.target_id === current.hitTarget) {
leadingEdge += current.drop.position === leading ? 3 : -3
trailingEdge += current.drop.position === trailing ? -3 : 3
}
let position: PriorityPosition = 'same'
if (offset < leadingEdge) position = leading
else if (offset > trailingEdge) position = trailing
next = { id: current.id, target_id: current.hitTarget, position }
}
} else {
current.hitIndex = -1
current.hitTarget = null
}
if (current.clone && !current.table) {
current.clone.style.transform = `translate3d(${current.x - current.startX}px, ${current.y - current.startY}px, 0)`
}
const key = next ? `${next.target_id}:${next.position}` : ''
if (key !== current.target) {
current.target = key
preview(current, next)
latest.current.select(next)
}
if (current.feedback) {
const left = Math.max(
8,
Math.min(current.x + 16, window.innerWidth - 308)
)
const top = Math.max(
8,
Math.min(
current.table ? current.y - 132 : current.y + 20,
window.innerHeight - 148
)
)
const floating = current.table ? current.clone : current.feedback
if (floating) {
floating.style.transform = `translate3d(${left}px, ${top}px, 0)`
}
}
const destination = current.destination
if (current.insertion) {
current.insertion.hidden =
!current.drop || current.drop.position === 'same' || !destination
if (!current.insertion.hidden && destination) {
const left = destination.left - scrollX
const top = (current.insertionY ?? destination.top) - scrollY
const vertical = current.horizontal
Object.assign(current.insertion.style, {
transform: `translate3d(${vertical ? left - 6 : Math.max(left, current.scrollBounds.left)}px, ${vertical ? top : top - 2}px, 0)`,
width: vertical
? '4px'
: `${Math.min(destination.right - scrollX, current.scrollBounds.right) - Math.max(left, current.scrollBounds.left)}px`,
height: vertical ? `${destination.height}px` : '4px',
})
}
}
// Idle pointers do not run a perpetual frame loop. Continue only to scroll.
if (current.scroller.scrollTop !== scrollBefore) {
frame.current = requestAnimationFrame(tick)
}
},
[preview]
)
const start = useCallback(
(event: PointerEvent<HTMLButtonElement>, id: number) => {
if (
!latest.current.enabled ||
point.current ||
released.current ||
event.button !== 0
) {
return
}
const source = root.current?.querySelector<HTMLElement>(
`[data-sortable-id="channel:${id}"]`
)
if (!source || !root.current) return
event.preventDefault()
event.currentTarget.focus({ preventScroll: true })
point.current = {
pointerId: event.pointerId,
id,
startX: event.clientX,
startY: event.clientY,
x: event.clientX,
y: event.clientY,
moved: false,
clone: null,
overlay: null,
feedback: null,
insertion: null,
insertionY: null,
drop: null,
source,
opacity: source.style.opacity,
outline: source.style.outline,
scroller: scrollParent(source),
scrollBounds: new DOMRect(),
rootBounds: new DOMRect(),
target: '',
slots: [],
slotsByID: new Map(),
items: latest.current.items,
ordered: latest.current.items,
hitIndex: -1,
hitTarget: null,
horizontal: false,
table: false,
destination: null,
scrollTop: 0,
scrollLeft: 0,
lastFrame: 0,
commit: false,
}
root.current.setPointerCapture(event.pointerId)
},
[]
)
const keyDown = useCallback(
(event: KeyboardEvent<HTMLButtonElement>, id: number) => {
if (event.key === 'Escape') {
stop()
latest.current.cancel()
return
}
if (!latest.current.enabled || point.current || released.current) return
const index = latest.current.items.findIndex((item) => item.id === id)
let delta = 0
if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') delta = -1
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') delta = 1
if (!delta) return
event.preventDefault()
const target = latest.current.items[index + delta]
if (!target) return
latest.current.begin()
latest.current.select({
id,
target_id: target.id,
position: delta < 0 === latest.current.desc ? 'before' : 'after',
})
void latest.current.commit()
},
[stop]
)
useEffect(
() =>
options.previewStore.subscribe(() => {
const current = point.current ?? released.current
if (!current?.clone) return
const { drop, preview } = options.previewStore.getSnapshot()
let priority =
current.items.find((item) => item.id === current.id)?.priority ?? 0
if (drop?.id === current.id) {
if (drop.position === 'same') {
priority =
current.items.find((item) => item.id === drop.target_id)
?.priority ?? 0
}
if (preview) {
priority =
preview.items.find((item) => item.id === current.id)?.priority ??
priority
}
}
const value = current.clone.querySelector<HTMLElement>(
'[data-column-id="priority"] [data-slot="numeric-spinner-value"]'
)
if (value instanceof HTMLInputElement) value.value = String(priority)
else if (value) {
value.textContent = String(priority)
value.title = String(priority)
}
}),
[options.previewStore]
)
useEffect(() => {
stop()
}, [options.scope, options.enabled, stop])
useEffect(() => {
const cancel = () => stop()
const key = (event: globalThis.KeyboardEvent) => {
if (event.key === 'Escape') cancel()
}
window.addEventListener('blur', cancel)
window.addEventListener('resize', cancel)
window.addEventListener('keydown', key)
return () => {
window.removeEventListener('blur', cancel)
window.removeEventListener('resize', cancel)
window.removeEventListener('keydown', key)
if (frame.current !== undefined) cancelAnimationFrame(frame.current)
if (released.current) released.current.commit = false
landing.current?.stop()
if (point.current) clearVisuals(point.current)
if (released.current) clearVisuals(released.current)
point.current = null
}
}, [clearVisuals, stop])
return {
root,
feedbackContainer,
start,
keyDown,
rootEvents: {
onPointerMove: (event: PointerEvent<HTMLDivElement>) => {
if (point.current?.pointerId !== event.pointerId) return
point.current.x = event.clientX
point.current.y = event.clientY
if (frame.current === undefined) {
frame.current = requestAnimationFrame(tick)
}
},
onPointerUp: (event: PointerEvent<HTMLDivElement>) => {
if (point.current?.pointerId !== event.pointerId) return
point.current.x = event.clientX
point.current.y = event.clientY
if (frame.current !== undefined) cancelAnimationFrame(frame.current)
tick(performance.now())
stop(true)
},
onPointerCancel: () => {
if (point.current) stop()
},
onLostPointerCapture: () => {
if (point.current) stop()
},
},
}
}
@@ -0,0 +1,251 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { handleServerError } from '@/lib/handle-server-error'
import { getChannelPriorityOrder, reorderChannelPriority } from '../api'
import { channelsQueryKeys } from '../lib'
import {
applyPriorityPreview,
previewPriorityDrop,
type PriorityDrop,
type PriorityMove,
type PriorityOrder,
} from '../lib/channel-priority-order'
import type { Channel } from '../types'
export function useChannelPriorityOrder(props: {
channels: Channel[]
desc: boolean
scope: string
enabled: boolean
}) {
const { t } = useTranslation()
const client = useQueryClient()
const [optimisticDrop, setOptimisticDrop] = useState<PriorityDrop | null>(
null
)
const [savedPreview, setSavedPreview] = useState<PriorityOrder | null>(null)
// Only the status strip subscribes to hover/priority-preview updates. The table's
// data and row objects stay unchanged throughout the pointer gesture.
const [previewStore] = useState(() => {
const listeners = new Set<() => void>()
let value: {
drop: PriorityDrop | null
preview: PriorityOrder | null
preparing: boolean
} = { drop: null, preview: null, preparing: false }
return {
getSnapshot: () => value,
subscribe: (listener: () => void) => {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
},
publish: (next: typeof value) => {
value = next
for (const listener of listeners) listener()
},
}
})
const [undo, setUndo] = useState<PriorityOrder | null>(null)
const [active, setActive] = useState(false)
const [saving, setSaving] = useState(false)
const session = useRef<{
snapshot: Promise<PriorityOrder>
drop: PriorityDrop | null
serial: number
} | null>(null)
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const mutation = useMutation({
mutationFn: reorderChannelPriority,
meta: { errorToast: false },
})
const busy = useRef(false)
const cancel = useCallback(() => {
clearTimeout(timer.current)
session.current = null
setActive(false)
setOptimisticDrop(null)
setSavedPreview(null)
previewStore.publish({ drop: null, preview: null, preparing: false })
}, [previewStore])
function begin() {
if (!props.enabled || busy.current) return
cancel()
// Read a fresh snapshot on every gesture, including keyboard moves.
const snapshot = getChannelPriorityOrder()
// Mark rejection handled until the gesture actually needs the result.
void snapshot.catch(() => undefined)
session.current = { snapshot, drop: null, serial: 0 }
setActive(true)
}
function select(next: PriorityDrop | null) {
const current = session.current
if (!current || busy.current) return
clearTimeout(timer.current)
current.drop = next
current.serial++
previewStore.publish({ drop: next, preview: null, preparing: !!next })
if (!next) return
const serial = current.serial
timer.current = setTimeout(() => {
void current.snapshot
.then((snapshot) => {
if (
session.current !== current ||
current.serial !== serial ||
busy.current
) {
return null
}
return reorderChannelPriority({
...next,
revision: snapshot.revision,
preview: true,
})
})
.then((result) => {
if (
result &&
session.current === current &&
current.serial === serial &&
!busy.current
) {
previewStore.publish({
drop: next,
preview: result,
preparing: false,
})
}
})
.catch((error: unknown) => {
if (
session.current === current &&
current.serial === serial &&
!busy.current
) {
cancel()
handleServerError(error)
}
})
}, 200)
}
async function save(move: PriorityMove) {
const result = await mutation.mutateAsync(move)
setSavedPreview(result)
await client.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
setUndo(move.restore ? null : result)
if (result.changes.length > 0) toast.success(t('Channel priorities saved'))
}
async function commit() {
const current = session.current
if (busy.current) return
if (!current?.drop) {
cancel()
return
}
busy.current = true
setSaving(true)
clearTimeout(timer.current)
setOptimisticDrop(current.drop)
previewStore.publish({ drop: current.drop, preview: null, preparing: true })
try {
const snapshot = await current.snapshot
if (session.current !== current) return
await save({ ...current.drop, revision: snapshot.revision })
} catch (error) {
handleServerError(error)
await client.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
} finally {
busy.current = false
setSaving(false)
cancel()
}
}
async function restore() {
if (!undo || busy.current) return
busy.current = true
setSaving(true)
try {
await save({
revision: undo.revision,
restore: undo.changes.map((change) => ({
id: change.id,
previous: change.priority,
priority: change.previous,
})),
})
} catch (error) {
setUndo(null)
handleServerError(error)
await client.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
} finally {
busy.current = false
setSaving(false)
cancel()
}
}
useEffect(() => {
cancel()
// View/filter/page changes cancel a pending gesture, but not an in-flight save.
}, [props.scope, props.enabled, cancel])
useEffect(
() => () => {
clearTimeout(timer.current)
session.current = null
},
[]
)
const items = useMemo(() => {
if (savedPreview) {
return applyPriorityPreview(props.channels, savedPreview, props.desc)
}
if (optimisticDrop) {
return previewPriorityDrop(props.channels, optimisticDrop, props.desc)
}
return props.channels
}, [props.channels, props.desc, optimisticDrop, savedPreview])
return {
items,
previewStore,
active,
saving,
undo,
begin,
select,
commit,
cancel,
restore,
}
}
@@ -22,6 +22,10 @@ import { toast } from 'sonner'
import { api, type ApiRequestConfig } from '@/lib/api'
import { handleServerError } from '@/lib/handle-server-error'
import {
createServerError,
getServerErrorMessage,
} from '@/lib/server-error-message'
import { normalizeModelList } from '../lib/upstream-update-utils'
@@ -30,66 +34,76 @@ const upstreamUpdateRequestConfig = {
skipErrorHandler: true,
} satisfies ApiRequestConfig
function getManualIgnoredModelCount(settings: unknown): number {
let parsed: Record<string, unknown> | null = null
if (settings && typeof settings === 'object') {
parsed = settings as Record<string, unknown>
} else if (typeof settings === 'string') {
try {
parsed = JSON.parse(settings)
} catch {
parsed = null
}
}
if (!parsed) return 0
return normalizeModelList(
(parsed.upstream_model_update_ignored_models as unknown[]) || []
).length
type UpstreamUpdateChannel = { id: number; name?: string }
type UpstreamUpdateResult = {
addedModels: string[]
removedModels: string[]
remainingModels: string[]
remainingRemoveModels: string[]
}
type UpstreamUpdateResponse<T> = {
success: boolean
message?: string
data?: T
}
export function useChannelUpstreamUpdates(refresh: () => Promise<void>) {
const { t } = useTranslation()
const [showModal, setShowModal] = useState(false)
const [channel, setChannel] = useState<{
id: number
[key: string]: unknown
} | null>(null)
const [channel, setChannel] = useState<UpstreamUpdateChannel | null>(null)
const [addModels, setAddModels] = useState<string[]>([])
const [removeModels, setRemoveModels] = useState<string[]>([])
const [preferredTab, setPreferredTab] = useState<'add' | 'remove'>('add')
const [applyLoading, setApplyLoading] = useState(false)
const [detectLoading, setDetectLoading] = useState(false)
const [detectError, setDetectError] = useState<string | null>(null)
const [applyError, setApplyError] = useState<string | null>(null)
const [result, setResult] = useState<UpstreamUpdateResult | null>(null)
const [previewVersion, setPreviewVersion] = useState(0)
const [detectAllLoading, setDetectAllLoading] = useState(false)
const [applyAllLoading, setApplyAllLoading] = useState(false)
const applyRef = useRef(false)
const detectRef = useRef(false)
const previewRequestRef = useRef(0)
const detectAllRef = useRef(false)
const applyAllRef = useRef(false)
const openModal = useCallback(
(
record: { id: number; [key: string]: unknown } | null,
record: UpstreamUpdateChannel | null,
pendingAdd: string[] = [],
pendingRemove: string[] = [],
tab: 'add' | 'remove' = 'add'
) => {
if (applyRef.current) return
const normAdd = normalizeModelList(pendingAdd)
const normRemove = normalizeModelList(pendingRemove)
if (!record?.id || (normAdd.length === 0 && normRemove.length === 0)) {
toast.info(t('No processable upstream model updates for this channel'))
return
}
if (!record?.id) return
previewRequestRef.current += 1
setPreviewVersion((version) => version + 1)
setDetectLoading(false)
setDetectError(null)
setApplyError(null)
setResult(null)
setChannel(record)
setAddModels(normAdd)
setRemoveModels(normRemove)
setPreferredTab(tab)
setShowModal(true)
},
[t]
[]
)
const closeModal = useCallback(() => {
if (applyRef.current) return
previewRequestRef.current += 1
setDetectLoading(false)
setDetectError(null)
setApplyError(null)
setResult(null)
setShowModal(false)
setChannel(null)
setAddModels([])
@@ -105,55 +119,76 @@ export function useChannelUpstreamUpdates(refresh: () => Promise<void>) {
addModels?: string[]
removeModels?: string[]
} = {}) => {
if (applyRef.current) return
if (!channel?.id) {
closeModal()
if (
applyRef.current ||
detectLoading ||
detectError ||
applyError ||
result ||
!channel?.id
) {
return
}
const normSelectedAdd = normalizeModelList(selectedAdd).filter((model) =>
addModels.includes(model)
)
const normSelectedRemove = normalizeModelList(selectedRemove).filter(
(model) => removeModels.includes(model)
)
if (!normSelectedAdd.length && !normSelectedRemove.length) return
applyRef.current = true
setApplyLoading(true)
try {
const normSelectedAdd = normalizeModelList(selectedAdd)
const selectedAddSet = new Set(normSelectedAdd)
const ignoreModels = addModels.filter((m) => !selectedAddSet.has(m))
const res = await api.post(
const res = await api.post<
UpstreamUpdateResponse<{
added_models: string[] | null
removed_models: string[] | null
remaining_models: string[] | null
remaining_remove_models: string[] | null
}>
>(
'/api/channel/upstream_updates/apply',
{
id: channel.id,
add_models: normSelectedAdd,
ignore_models: ignoreModels,
remove_models: normalizeModelList(selectedRemove),
ignore_models: [],
remove_models: normSelectedRemove,
},
upstreamUpdateRequestConfig
)
const { success, data } = res.data || {}
if (!success) {
handleServerError(res.data, t('Operation failed'))
return
if (!res.data?.success || !res.data.data) {
throw createServerError(res.data, t('Operation failed'))
}
toast.success(
t(
'Upstream model updates applied: {{added}} added, {{removed}} removed, {{ignored}} ignored this time, {{totalIgnored}} total ignored models',
{
added: data?.added_models?.length || 0,
removed: data?.removed_models?.length || 0,
ignored: normalizeModelList(ignoreModels).length,
totalIgnored: getManualIgnoredModelCount(data?.settings),
}
)
)
closeModal()
await refresh()
} catch (e: unknown) {
handleServerError(e, t('Operation failed'))
const data = res.data.data
setResult({
addedModels: normalizeModelList(data.added_models ?? []),
removedModels: normalizeModelList(data.removed_models ?? []),
remainingModels: normalizeModelList(data.remaining_models ?? []),
remainingRemoveModels: normalizeModelList(
data.remaining_remove_models ?? []
),
})
} catch (error: unknown) {
setApplyError(getServerErrorMessage(error, t('Operation failed')))
handleServerError(error, t('Operation failed'))
} finally {
applyRef.current = false
setApplyLoading(false)
}
// A list refresh failure must not turn a successful update into a failed one.
void refresh().catch((error: unknown) => handleServerError(error))
},
[channel, addModels, closeModal, refresh, t]
[
channel,
addModels,
removeModels,
detectLoading,
detectError,
applyError,
result,
refresh,
t,
]
)
const applyAllUpdates = useCallback(async () => {
@@ -193,35 +228,46 @@ export function useChannelUpstreamUpdates(refresh: () => Promise<void>) {
}, [refresh, t])
const detectChannelUpdates = useCallback(
async (ch: { id: number; [key: string]: unknown } | null) => {
if (detectRef.current || !ch?.id) return
detectRef.current = true
async (ch: UpstreamUpdateChannel | null) => {
if (applyRef.current || !ch?.id) return
const requestId = ++previewRequestRef.current
setChannel(ch)
setShowModal(true)
setDetectLoading(true)
setDetectError(null)
setApplyError(null)
setResult(null)
setAddModels([])
setRemoveModels([])
try {
const res = await api.post(
const res = await api.post<
UpstreamUpdateResponse<{
add_models: string[] | null
remove_models: string[] | null
}>
>(
'/api/channel/upstream_updates/detect',
{ id: ch.id },
upstreamUpdateRequestConfig
)
const { success, data } = res.data || {}
if (!success) {
handleServerError(res.data, t('Detection failed'))
return
if (requestId !== previewRequestRef.current) return
if (!res.data?.success || !res.data.data) {
throw createServerError(res.data, t('Detection failed'))
}
toast.success(
t('Detection complete: {{add}} to add, {{remove}} to remove', {
add: data?.add_models?.length || 0,
remove: data?.remove_models?.length || 0,
})
)
await refresh()
} catch (e: unknown) {
handleServerError(e, t('Detection failed'))
const added = normalizeModelList(res.data.data.add_models ?? [])
setAddModels(added)
setRemoveModels(normalizeModelList(res.data.data.remove_models ?? []))
setPreferredTab(added.length ? 'add' : 'remove')
setPreviewVersion((version) => version + 1)
} catch (error: unknown) {
if (requestId !== previewRequestRef.current) return
setDetectError(getServerErrorMessage(error, t('Detection failed')))
handleServerError(error, t('Detection failed'))
} finally {
detectRef.current = false
if (requestId === previewRequestRef.current) setDetectLoading(false)
}
},
[refresh, t]
[t]
)
const detectAllUpdates = useCallback(async () => {
@@ -265,6 +311,11 @@ export function useChannelUpstreamUpdates(refresh: () => Promise<void>) {
removeModels,
preferredTab,
applyLoading,
detectLoading,
detectError,
applyError,
result,
previewVersion,
detectAllLoading,
applyAllLoading,
openModal,
@@ -281,6 +332,11 @@ export function useChannelUpstreamUpdates(refresh: () => Promise<void>) {
removeModels,
preferredTab,
applyLoading,
detectLoading,
detectError,
applyError,
result,
previewVersion,
detectAllLoading,
applyAllLoading,
openModal,
@@ -292,3 +348,7 @@ export function useChannelUpstreamUpdates(refresh: () => Promise<void>) {
]
)
}
export type ChannelUpstreamUpdateState = ReturnType<
typeof useChannelUpstreamUpdates
>
@@ -0,0 +1,78 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { expect, test } from 'vitest'
import { channelSchema } from '../../types'
import {
CHANNEL_FORM_DEFAULT_VALUES,
channelFormSchema,
transformChannelToFormDefaults,
transformFormDataToCreatePayload,
} from '../channel-form'
test.each(['invoke_model', 'mantle'] as const)(
'AWS creation round-trips the %s interface without losing other settings',
(apiType) => {
const form = channelFormSchema.parse({
...CHANNEL_FORM_DEFAULT_VALUES,
name: 'AWS',
type: 33,
key: 'key|us-east-1',
models: 'claude-opus-4-7',
aws_api_type: apiType,
aws_key_type: 'api_key',
settings: '{"custom_option":true}',
})
const payload = transformFormDataToCreatePayload(form).channel
expect(JSON.parse(payload.settings ?? '{}')).toMatchObject({
aws_api_type: apiType,
aws_key_type: 'api_key',
custom_option: true,
})
const channel = channelSchema.parse({
...payload,
other: '',
remark: '',
id: 7,
status: 1,
created_time: 1,
test_time: 0,
response_time: 0,
balance_updated_time: 0,
})
expect(transformChannelToFormDefaults(channel).aws_api_type).toBe(apiType)
}
)
test('changing an AWS channel to another provider removes the AWS interface setting', () => {
const form = channelFormSchema.parse({
...CHANNEL_FORM_DEFAULT_VALUES,
name: 'Claude',
type: 14,
key: 'test-key',
models: 'claude-opus-4-7',
aws_api_type: 'mantle',
settings: '{"aws_api_type":"mantle","aws_key_type":"api_key"}',
})
const settings = JSON.parse(
transformFormDataToCreatePayload(form).channel.settings ?? '{}'
)
expect(settings).not.toHaveProperty('aws_api_type')
expect(settings).not.toHaveProperty('aws_key_type')
})
@@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { describe, expect, test } from 'vitest'
import type { TaskPluginOption } from '../../api'
import {
CHANNEL_TYPE_NEW_API,
CHANNEL_TYPE_VLLM,
@@ -25,11 +26,19 @@ import {
CHANNEL_TYPE_OPTIONS,
MODEL_FETCHABLE_TYPES,
} from '../../constants'
import { channelSchema } from '../../types'
import {
CHANNEL_FORM_DEFAULT_VALUES,
channelFormSchema,
transformChannelToFormDefaults,
transformFormDataToCreatePayload,
transformFormDataToUpdatePayload,
} from '../channel-form'
import {
getChannelPluginExtensions,
readTaskExtendPluginKeys,
supportsNewAPIUpstream,
} from '../channel-plugin-extensions'
import { getChannelTypeConfig } from '../channel-type-config'
import { getChannelTypeIcon, getKeyPromptForType } from '../channel-utils'
@@ -45,6 +54,36 @@ function newAPIForm(baseUrl: string) {
}
describe('New API channel', () => {
test('preserves cache-read compatibility when editing ordinary channel fields', () => {
const channel = channelSchema.parse({
id: 22,
name: 'Gateway',
type: CHANNEL_TYPE_NEW_API,
key: '',
status: 1,
created_time: 1,
test_time: 0,
response_time: 0,
balance_updated_time: 0,
models: 'deepseek-v4.1-flash',
group: 'default',
settings: JSON.stringify({
claude_input_includes_cache_read_models: ['deepseek-v4.1-flash'],
allow_speed: true,
}),
})
const form = transformChannelToFormDefaults(channel)
const payload = transformFormDataToUpdatePayload(
{ ...form, name: 'Renamed gateway' },
channel.id
)
expect(payload.name).toBe('Renamed gateway')
expect(JSON.parse(payload.settings ?? '{}')).toMatchObject({
claude_input_includes_cache_read_models: ['deepseek-v4.1-flash'],
allow_speed: true,
})
})
test('registers selection, ordering, model discovery, and icon metadata', () => {
const option = CHANNEL_TYPE_OPTIONS.find(
(item) => item.value === CHANNEL_TYPE_NEW_API
@@ -145,3 +184,108 @@ describe.each([
})
})
})
describe('New API channel task plugin extensions', () => {
const plugins: TaskPluginOption[] = [
{
key: 'doubao',
name: 'Doubao',
models: ['seedance'],
channelTypes: [45],
upstreams: ['vendor', 'new_api'],
},
{
key: 'kling',
name: 'Kling',
models: ['kling-v2'],
upstreams: ['new_api'],
},
{
key: 'acme',
name: 'Acme',
models: ['acme-video'],
upstreams: ['vendor'],
},
]
test('only plugins declaring a New API upstream can be bound to a New API channel', () => {
expect(plugins.filter(supportsNewAPIUpstream).map((p) => p.key)).toEqual([
'doubao',
'kling',
])
expect(supportsNewAPIUpstream({ upstreams: null })).toBe(false)
expect(supportsNewAPIUpstream({})).toBe(false)
})
test('reads the extension list together with a single key on New API channels only', () => {
expect(
readTaskExtendPluginKeys(CHANNEL_TYPE_NEW_API, {
task_plugin_key: 'kling',
task_extend_plugin_keys: ['doubao', 'kling', '', 'doubao'],
})
).toEqual(['kling', 'doubao'])
expect(readTaskExtendPluginKeys(CHANNEL_TYPE_NEW_API, {})).toEqual([])
expect(
readTaskExtendPluginKeys(1, { task_extend_plugin_keys: ['doubao'] })
).toEqual([])
})
test('lists only bound plugins as extensions while other types keep declared channel types', () => {
expect(
getChannelPluginExtensions(CHANNEL_TYPE_NEW_API, plugins, ['kling'])
).toEqual([plugins[1]])
expect(getChannelPluginExtensions(CHANNEL_TYPE_NEW_API, plugins)).toEqual(
[]
)
expect(getChannelPluginExtensions(45, plugins, ['kling'])).toEqual([
plugins[0],
])
})
test('round-trips bound plugins through the channel setting for New API channels only', () => {
const channel = channelSchema.parse({
id: 7,
name: 'Gateway',
type: CHANNEL_TYPE_NEW_API,
key: '',
status: 1,
created_time: 1,
test_time: 0,
response_time: 0,
balance_updated_time: 0,
models: 'seedance',
group: 'default',
base_url: 'https://gateway.example',
setting: JSON.stringify({
task_plugin_key: 'kling',
task_extend_plugin_keys: ['doubao'],
}),
})
expect(
transformChannelToFormDefaults(channel).task_extend_plugin_keys
).toEqual(['kling', 'doubao'])
const gateway = transformFormDataToCreatePayload(
channelFormSchema.parse({
...newAPIForm('https://gateway.example'),
task_extend_plugin_keys: ['doubao', 'kling'],
})
)
const gatewaySetting = JSON.parse(gateway.channel.setting ?? '{}')
expect(gatewaySetting).toMatchObject({
task_extend_plugin_keys: ['doubao', 'kling'],
})
expect(gatewaySetting).not.toHaveProperty('task_plugin_key')
const openai = transformFormDataToCreatePayload(
channelFormSchema.parse({
...newAPIForm('https://gateway.example'),
type: 1,
task_extend_plugin_keys: ['doubao'],
})
)
expect(JSON.parse(openai.channel.setting ?? '{}')).not.toHaveProperty(
'task_extend_plugin_keys'
)
})
})
@@ -0,0 +1,94 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { expect, test } from 'vitest'
import type { Channel } from '../../types'
import {
applyPriorityPreview,
previewPriorityDrop,
} from '../channel-priority-order'
const channels = [
{ id: 4, priority: 100 },
{ id: 3, priority: 80 },
{ id: 2, priority: 80 },
{ id: 1, priority: 0 },
] as Channel[]
test('before and after target the entire equal-priority tier', () => {
expect(
previewPriorityDrop(
channels,
{ id: 1, target_id: 2, position: 'before' },
true
).map((c) => c.id)
).toEqual([4, 1, 3, 2])
expect(
previewPriorityDrop(
channels,
{ id: 4, target_id: 3, position: 'after' },
true
).map((c) => c.id)
).toEqual([3, 2, 4, 1])
expect(channels.map((c) => c.id)).toEqual([4, 3, 2, 1])
})
test('ascending view reverses tier boundaries but preserves the ID tie break', () => {
const ascending = [...channels].sort(
(a, b) => (a.priority ?? 0) - (b.priority ?? 0) || b.id - a.id
)
expect(
previewPriorityDrop(
ascending,
{ id: 4, target_id: 3, position: 'after' },
false
).map((c) => c.id)
).toEqual([1, 4, 3, 2])
expect(
previewPriorityDrop(
channels,
{ id: 4, target_id: 3, position: 'same' },
true
).map((c) => c.id)
).toEqual([4, 3, 2, 1])
})
test('server preview applies visible changes and keeps deterministic null-zero ordering', () => {
const preview = applyPriorityPreview(
channels,
{
revision: 'next',
changes: [],
items: [
{ id: 4, priority: null },
{ id: 3, priority: 80 },
{ id: 2, priority: 80 },
{ id: 1, priority: 90 },
],
},
true
)
expect(preview.map((c) => [c.id, c.priority])).toEqual([
[1, 90],
[3, 80],
[2, 80],
[4, 0],
])
expect(channels[0].priority).toBe(100)
})
@@ -0,0 +1,114 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { describe, expect, test } from 'vitest'
import { channelSchema } from '../../types'
import {
CHANNEL_FORM_DEFAULT_VALUES,
channelFormSchema,
transformChannelToFormDefaults,
transformFormDataToCreatePayload,
type ChannelFormValues,
} from '../channel-form'
const CHANNEL_TYPE_VERTEX_AI = 41
function vertexAPIKeyForm(
overrides: Partial<ChannelFormValues> = {}
): ChannelFormValues {
return {
...CHANNEL_FORM_DEFAULT_VALUES,
name: 'Vertex AI',
type: CHANNEL_TYPE_VERTEX_AI,
key: 'test-key',
models: 'gemini-4-argon',
other: '{"default":"global"}',
vertex_key_type: 'api_key',
...overrides,
}
}
function savedSettings(form: ChannelFormValues): Record<string, unknown> {
const payload = transformFormDataToCreatePayload(
channelFormSchema.parse(form)
)
return JSON.parse(payload.channel.settings ?? '{}')
}
describe('Vertex AI API Key project ID', () => {
test('saves the trimmed project ID in API Key mode', () => {
const settings = savedSettings(
vertexAPIKeyForm({ vertex_project_id: ' my-project ' })
)
expect(settings).toMatchObject({
vertex_key_type: 'api_key',
vertex_project_id: 'my-project',
})
})
test('accepts API Key mode without a project ID and saves none', () => {
const form = vertexAPIKeyForm({ vertex_project_id: ' ' })
expect(channelFormSchema.safeParse(form).success).toBe(true)
expect(savedSettings(form)).not.toHaveProperty('vertex_project_id')
})
test('drops a saved project ID after switching to JSON keys', () => {
const settings = savedSettings(
vertexAPIKeyForm({
vertex_key_type: 'json',
key: '{"project_id":"json-project"}',
vertex_project_id: 'my-project',
settings: JSON.stringify({
vertex_key_type: 'api_key',
vertex_project_id: 'my-project',
}),
})
)
expect(settings).toMatchObject({ vertex_key_type: 'json' })
expect(settings).not.toHaveProperty('vertex_project_id')
})
test('loads the saved project ID when editing a channel', () => {
const channel = channelSchema.parse({
id: 7,
name: 'Vertex AI',
type: CHANNEL_TYPE_VERTEX_AI,
key: '',
status: 1,
created_time: 1,
test_time: 0,
response_time: 0,
balance_updated_time: 0,
models: 'gemini-4-argon',
group: 'default',
other: '{"default":"global"}',
settings: JSON.stringify({
vertex_key_type: 'api_key',
vertex_project_id: 'my-project',
}),
})
expect(transformChannelToFormDefaults(channel).vertex_project_id).toBe(
'my-project'
)
})
})
@@ -46,6 +46,11 @@ export const ADVANCED_CUSTOM_CONVERTER_OPTIONS: Array<{
label: 'Anthropic Messages to OpenAI Chat',
triggerLabel: 'To OpenAI Chat',
},
{
value: 'claude_messages_to_openai_responses',
label: 'Anthropic Messages to OpenAI Responses',
triggerLabel: 'To OpenAI Responses',
},
{
value: 'openai_chat_completions_to_anthropic_messages',
label: 'OpenAI Chat to Anthropic Messages',
@@ -71,6 +76,11 @@ export const ADVANCED_CUSTOM_CONVERTER_OPTIONS: Array<{
label: 'Gemini Generate Content to OpenAI Chat',
triggerLabel: 'To OpenAI Chat',
},
{
value: 'gemini_generate_content_to_openai_responses',
label: 'Gemini Generate Content to OpenAI Responses',
triggerLabel: 'To OpenAI Responses',
},
{
value: 'openai_chat_completions_to_gemini_generate_content',
label: 'OpenAI Chat to Gemini Generate Content',
@@ -449,7 +459,11 @@ export function getAdvancedCustomConverterDefaults(
) {
return { upstream_path: openAIChatPath, auth: bearerHeaderAuth() }
}
if (converter === 'openai_chat_completions_to_openai_responses') {
if (
converter === 'openai_chat_completions_to_openai_responses' ||
converter === 'claude_messages_to_openai_responses' ||
converter === 'gemini_generate_content_to_openai_responses'
) {
return { upstream_path: openAIResponsesPath, auth: bearerHeaderAuth() }
}
if (converter === 'openai_chat_completions_to_anthropic_messages') {
@@ -924,7 +938,10 @@ function isConverterPathAllowed(
): boolean {
if (converter === 'none') return true
if (incomingPath === '/v1/alpha/search') return false
if (converter === 'anthropic_messages_to_openai_chat_completions') {
if (
converter === 'anthropic_messages_to_openai_chat_completions' ||
converter === 'claude_messages_to_openai_responses'
) {
return incomingPath === '/v1/messages'
}
if (
@@ -21,6 +21,7 @@ import i18next from 'i18next'
import { toast } from 'sonner'
import { handleServerError } from '@/lib/handle-server-error'
import { getServerMessage } from '@/lib/server-error-message'
import {
copyChannel,
@@ -316,7 +317,8 @@ export async function handleTestChannel(
}
onTestComplete?.(true, responseTime)
} else {
const errorMsg = response.message || i18next.t(ERROR_MESSAGES.TEST_FAILED)
const errorMsg =
getServerMessage(response) || i18next.t(ERROR_MESSAGES.TEST_FAILED)
if (!options?.silent) {
handleServerError(response, undefined, {
title: i18next.t('{{target}} test failed', { target }),
@@ -328,9 +330,10 @@ export async function handleTestChannel(
onTestComplete?.(false, responseTime, errorMsg, response.error_code)
}
} catch (_error: unknown) {
const err = _error as { response?: { data?: { message?: string } } }
const err = _error as { response?: { data?: unknown } }
const errorMsg =
err?.response?.data?.message || i18next.t(ERROR_MESSAGES.TEST_FAILED)
getServerMessage(err?.response?.data) ||
i18next.t(ERROR_MESSAGES.TEST_FAILED)
const target = getChannelTestLabel(options)
if (!options?.silent) {
handleServerError(_error, undefined, {
@@ -85,6 +85,7 @@ const CONFIGURATION_BLOCKS = {
'proxy',
'http_protocol',
'http2_connection_shards',
'tls_insecure_skip_verify',
'disable_task_polling_sleep',
],
},
@@ -176,6 +177,7 @@ export function getChannelConfigurationState(
values.proxy?.trim() ||
(values.http_protocol && values.http_protocol !== 'auto') ||
(values.http2_connection_shards ?? 1) > 1 ||
values.tls_insecure_skip_verify ||
values.disable_task_polling_sleep
),
upstreamModelDetection:
@@ -42,6 +42,7 @@ const ADVANCED_SETTINGS_FIELDS = new Set<FieldPath<ChannelFormValues>>([
'proxy',
'http_protocol',
'http2_connection_shards',
'tls_insecure_skip_verify',
'system_prompt',
'system_prompt_override',
'allow_service_tier',
Loaded 100 of 1022 files, more files were not shown because too many files have changed in this diff. Show more