Commit eb76b136 by CaIon

feat(channels): improve plugin channel setup and icons

parent 98433092
......@@ -16,12 +16,15 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type { ReactNode } from 'react'
import { getLobeIcon } from '@/lib/lobe-icon'
import { cn } from '@/lib/utils'
import { StatusBadge, type StatusBadgeProps } from './status-badge'
type ProviderBadgeProps = Omit<StatusBadgeProps, 'children' | 'label'> & {
iconNode?: ReactNode
iconKey?: string | null
iconSize?: number
label: string
......@@ -32,12 +35,13 @@ type ProviderBadgeProps = Omit<StatusBadgeProps, 'children' | 'label'> & {
export function ProviderBadge({
className,
iconKey,
iconNode,
iconSize = 14,
label,
colorText = true,
...badgeProps
}: ProviderBadgeProps) {
const icon = iconKey ? getLobeIcon(iconKey, iconSize) : null
const icon = iconNode ?? (iconKey ? getLobeIcon(iconKey, iconSize) : null)
return (
<div
......
......@@ -31,7 +31,18 @@ const options = [
function Fixture() {
const [value, setValue] = useState('openai')
return <><Combobox options={options} value={value} onValueChange={(next) => setValue(next ?? '')} aria-label='Provider' emptyText='No matching provider' /><output>{value}</output></>
return (
<>
<Combobox
options={options}
value={value}
onValueChange={(next) => setValue(next ?? '')}
aria-label='Provider'
emptyText='No matching provider'
/>
<output>{value}</output>
</>
)
}
describe('searchable single selection', () => {
......@@ -56,13 +67,126 @@ describe('searchable single selection', () => {
it('respects disabled controls and options', async () => {
const change = vi.fn()
const view = render(<Combobox options={options} value='openai' onValueChange={change} aria-label='Provider' disabled />)
const view = render(
<Combobox
options={options}
value='openai'
onValueChange={change}
aria-label='Provider'
disabled
/>
)
const user = userEvent.setup()
expect(screen.getByRole('combobox', { name: 'Provider' })).toBeDisabled()
view.rerender(<Combobox options={options} value='openai' onValueChange={change} aria-label='Provider' />)
view.rerender(
<Combobox
options={options}
value='openai'
onValueChange={change}
aria-label='Provider'
/>
)
await user.click(screen.getByRole('combobox', { name: 'Provider' }))
expect(screen.getByRole('option', { name: 'Unavailable provider' })).toHaveAttribute('aria-disabled', 'true')
await user.click(screen.getByRole('option', { name: 'Unavailable provider' }))
expect(
screen.getByRole('option', { name: 'Unavailable provider' })
).toHaveAttribute('aria-disabled', 'true')
await user.click(
screen.getByRole('option', { name: 'Unavailable provider' })
)
expect(change).not.toHaveBeenCalled()
})
})
const pluginOptions = [
{
value: 'alpha',
label: 'Alpha plugin',
icon: <img src='/api/plugin/task/alpha/icon' alt='' />,
},
{
value: 'beta',
label: 'Beta plugin',
icon: <img src='/api/plugin/task/beta/icon' alt='' />,
},
]
function PluginSelectionFixture() {
const [value, setValue] = useState<string | null>('alpha')
return (
<Combobox
options={pluginOptions}
value={value}
onValueChange={setValue}
showSelectedIcon
aria-label='Task plugin'
/>
)
}
describe('selected option icons', () => {
it('shows the selected plugin logo and updates it when choosing another plugin', async () => {
render(<PluginSelectionFixture />)
const user = userEvent.setup()
const input = screen.getByRole('combobox', { name: 'Task plugin' })
expect(screen.getByAltText('')).toHaveAttribute(
'src',
'/api/plugin/task/alpha/icon'
)
await user.click(input)
const nextOption = screen.getByRole('option', { name: 'Beta plugin' })
expect(nextOption.querySelector('img')).toHaveAttribute(
'src',
'/api/plugin/task/beta/icon'
)
await user.click(nextOption)
await waitFor(() => expect(input).toHaveValue('Beta plugin'))
expect(screen.getByAltText('')).toHaveAttribute(
'src',
'/api/plugin/task/beta/icon'
)
})
it('removes the logo when the selection is cleared or no longer has an icon', () => {
const view = render(
<Combobox
options={pluginOptions}
value='alpha'
showSelectedIcon
aria-label='Task plugin'
/>
)
expect(screen.getByAltText('')).toBeInTheDocument()
view.rerender(
<Combobox
options={pluginOptions}
value={null}
showSelectedIcon
aria-label='Task plugin'
/>
)
expect(screen.queryByAltText('')).not.toBeInTheDocument()
view.rerender(
<Combobox
options={[{ value: 'alpha', label: 'Alpha plugin' }]}
value='alpha'
showSelectedIcon
aria-label='Task plugin'
/>
)
expect(screen.queryByAltText('')).not.toBeInTheDocument()
expect(screen.getByRole('combobox', { name: 'Task plugin' })).toHaveValue(
'Alpha plugin'
)
})
it('preserves the existing text-only selected state unless icon display is requested', () => {
render(
<Combobox
options={pluginOptions}
value='alpha'
aria-label='Task plugin'
/>
)
expect(screen.queryByAltText('')).not.toBeInTheDocument()
})
})
......@@ -48,6 +48,7 @@ type LegacyComboboxProps = {
searchPlaceholder?: string
emptyText?: string
allowCustomValue?: boolean
showSelectedIcon?: boolean
className?: string
id?: string
openOnFocus?: boolean
......@@ -142,7 +143,13 @@ function OptionCombobox(props: LegacyComboboxProps) {
}
triggerAriaLabel={props['aria-label'] ?? t('Open')}
className='h-full min-h-8 w-full'
/>
>
{props.showSelectedIcon && !open && selected?.icon && (
<InputGroupAddon align='inline-start' aria-hidden='true'>
{selected.icon}
</InputGroupAddon>
)}
</ComboboxInput>
</div>
<ComboboxContent anchor={anchor}>
<ComboboxEmpty>
......
......@@ -48,7 +48,16 @@ const channelActionConfig = (
skipErrorHandler: true,
})
export type TaskPluginOption = { key: string; name: string; models: string[] }
export type TaskPluginOption = {
sortPriority?: number
website?: string
key: string
name: string
icon?: string
hasIcon?: boolean
baseUrl?: string
models: string[]
}
export async function getTaskPluginOptions(): Promise<TaskPluginOption[]> {
const response = await api.get<{
......
/*
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 { cleanup, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, expect, test, vi } from 'vitest'
import { ROLE } from '@/lib/roles'
import { useAuthStore } from '@/stores/auth-store'
import { getTaskPluginOptions } from '../api'
import { CHANNEL_TYPE_TASK_PLUGIN } from '../constants'
import { ChannelTypeLogo, TaskPluginChannelBadge } from './channel-type-badge'
vi.mock('../api', () => ({ getTaskPluginOptions: vi.fn() }))
vi.mock('@/lib/lobe-icon', () => ({
getLobeIcon: (name: string) => <svg data-testid={name} />,
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}))
const originalAuth = useAuthStore.getState().auth
let client: QueryClient
beforeEach(() => {
client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
useAuthStore.setState({
auth: {
...originalAuth,
user: { id: 1, username: 'root', role: ROLE.SUPER_ADMIN },
},
})
vi.mocked(getTaskPluginOptions).mockReset()
})
afterEach(() => {
cleanup()
client.clear()
useAuthStore.setState({ auth: originalAuth })
})
function ChannelTypeHarness(props: { pluginKey?: string }) {
return (
<QueryClientProvider client={client}>
<TaskPluginChannelBadge pluginKey={props.pluginKey} />
</QueryClientProvider>
)
}
test('identifies a bound plugin by its metadata and retains the task-plugin type', async () => {
vi.mocked(getTaskPluginOptions).mockResolvedValue([
{ key: 'incho', name: 'Incho AI', icon: 'text:IA', models: [] },
])
render(<ChannelTypeHarness pluginKey='incho' />)
expect(await screen.findByText('Incho AI')).toBeInTheDocument()
expect(screen.getByText('IA')).toBeInTheDocument()
expect(screen.getByText('Task Plugin')).toBeInTheDocument()
expect(screen.queryByTestId('OpenAI.Color')).not.toBeInTheDocument()
})
test('keeps the binding key when the plugin is unavailable', async () => {
vi.mocked(getTaskPluginOptions).mockResolvedValue([])
render(<ChannelTypeHarness pluginKey='removed-plugin' />)
expect(await screen.findByText('removed-plugin')).toBeInTheDocument()
expect(screen.queryByTestId('OpenAI.Color')).not.toBeInTheDocument()
})
test('does not request plugin metadata without bind permission', () => {
useAuthStore.setState({
auth: {
...originalAuth,
user: { id: 2, username: 'admin', role: ROLE.ADMIN },
},
})
render(<ChannelTypeHarness pluginKey='incho' />)
expect(screen.getByText('incho')).toBeInTheDocument()
expect(getTaskPluginOptions).not.toHaveBeenCalled()
})
test('unbound task channels use a neutral icon while regular providers keep their logo', () => {
const { rerender } = render(
<ChannelTypeLogo type={CHANNEL_TYPE_TASK_PLUGIN} />
)
expect(screen.queryByTestId('OpenAI.Color')).not.toBeInTheDocument()
rerender(<ChannelTypeLogo type={1} />)
expect(screen.getByTestId('OpenAI.Color')).toBeInTheDocument()
})
/*
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 { useQuery } from '@tanstack/react-query'
import { Puzzle, Server } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { ProviderBadge } from '@/components/provider-badge'
import { StatusBadge } from '@/components/status-badge'
import { PluginIcon } from '@/features/task-plugins/components/plugin-icon'
import type { PluginIconInput } from '@/features/task-plugins/lib/plugin-icon'
import {
ADMIN_PERMISSION_ACTIONS,
ADMIN_PERMISSION_RESOURCES,
hasPermission,
} from '@/lib/admin-permissions'
import { getLobeIcon } from '@/lib/lobe-icon'
import { cn } from '@/lib/utils'
import { useAuthStore } from '@/stores/auth-store'
import { getTaskPluginOptions } from '../api'
import { CHANNEL_TYPE_OPTIONS, CHANNEL_TYPE_TASK_PLUGIN } from '../constants'
import { getChannelTypeIcon } from '../lib/channel-utils'
export function ChannelTypeLogo(props: {
type: number
plugin?: PluginIconInput
size?: number
className?: string
}) {
const size = props.size ?? 16
if (props.type === CHANNEL_TYPE_TASK_PLUGIN && props.plugin) {
return <PluginIcon plugin={props.plugin} size={size} />
}
const isKnownType = CHANNEL_TYPE_OPTIONS.some(
(option) => option.value === props.type
)
if (props.type === CHANNEL_TYPE_TASK_PLUGIN || !isKnownType) {
const Icon = props.type === CHANNEL_TYPE_TASK_PLUGIN ? Puzzle : Server
return (
<Icon
className={cn('text-muted-foreground shrink-0', props.className)}
size={size}
aria-hidden='true'
/>
)
}
return (
<span className={cn('inline-flex shrink-0', props.className)}>
{getLobeIcon(`${getChannelTypeIcon(props.type)}.Color`, size)}
</span>
)
}
export function TaskPluginChannelBadge(props: { pluginKey?: string }) {
const { t } = useTranslation()
const user = useAuthStore((s) => s.auth.user)
const canBind = hasPermission(
user,
ADMIN_PERMISSION_RESOURCES.TASK_PLUGIN,
ADMIN_PERMISSION_ACTIONS.BIND
)
const query = useQuery({
queryKey: ['task-plugin-options'],
queryFn: getTaskPluginOptions,
enabled: Boolean(props.pluginKey) && canBind,
staleTime: 60 * 1000,
})
const plugin = query.data?.find((item) => item.key === props.pluginKey)
const label = plugin?.name || props.pluginKey || t('Task Plugin')
const iconInput =
plugin ?? (props.pluginKey ? { key: props.pluginKey } : undefined)
return (
<div
className='flex max-w-full min-w-0 items-center gap-1.5'
title={
props.pluginKey
? `${t('Task Plugin')} · ${label} (${props.pluginKey})`
: label
}
>
<ProviderBadge
iconNode={
<ChannelTypeLogo
type={CHANNEL_TYPE_TASK_PLUGIN}
plugin={iconInput}
size={18}
/>
}
label={label}
colorText={false}
copyable={false}
showDot={false}
className='min-w-0 overflow-hidden'
/>
{props.pluginKey && (
<StatusBadge
label={t('Task Plugin')}
variant='neutral'
size='sm'
copyable={false}
showDot={false}
className='shrink-0 text-[10px]'
/>
)}
</div>
)
}
......@@ -56,7 +56,11 @@ import { formatTimestampToDate } from '@/lib/format'
import { truncateText } from '@/lib/utils'
import { getCodexUsage, updateChannelBalance } from '../api'
import { CHANNEL_STATUS_CONFIG, MODEL_FETCHABLE_TYPES } from '../constants'
import {
CHANNEL_STATUS_CONFIG,
CHANNEL_TYPE_TASK_PLUGIN,
MODEL_FETCHABLE_TYPES,
} from '../constants'
import {
formatRelativeTime,
formatResponseTime,
......@@ -78,6 +82,7 @@ import {
import { parseUpstreamUpdateMeta } from '../lib/upstream-update-utils'
import type { Channel } from '../types'
import { ChannelRowActionsLayoutContext } from './channel-row-actions-context'
import { TaskPluginChannelBadge } from './channel-type-badge'
import { useChannels } from './channels-provider'
import { DataTableRowActions } from './data-table-row-actions'
import { DataTableTagRowActions } from './data-table-tag-row-actions'
......@@ -819,26 +824,34 @@ export function useChannelsColumns(
</Tooltip>
</TooltipProvider>
)}
<TooltipProvider delay={300}>
<Tooltip>
<TooltipTrigger
render={
<div className='max-w-full min-w-0 overflow-hidden' />
}
>
<ProviderBadge
iconKey={`${iconName}.Color`}
iconSize={18}
label={typeName}
colorText={false}
copyable={false}
showDot={false}
className='max-w-full min-w-0 overflow-hidden'
/>
</TooltipTrigger>
<TooltipContent side='top'>{typeName}</TooltipContent>
</Tooltip>
</TooltipProvider>
{type === CHANNEL_TYPE_TASK_PLUGIN ? (
<TaskPluginChannelBadge
pluginKey={
parseChannelSettings(channel.setting)?.task_plugin_key
}
/>
) : (
<TooltipProvider delay={300}>
<Tooltip>
<TooltipTrigger
render={
<div className='max-w-full min-w-0 overflow-hidden' />
}
>
<ProviderBadge
iconKey={`${iconName}.Color`}
iconSize={18}
label={typeName}
colorText={false}
copyable={false}
showDot={false}
className='max-w-full min-w-0 overflow-hidden'
/>
</TooltipTrigger>
<TooltipContent side='top'>{typeName}</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{isIonet && (
<TooltipProvider delay={100}>
<Tooltip>
......
......@@ -44,7 +44,6 @@ import {
} from '@/components/ui/tooltip'
import { useMediaQuery } from '@/hooks'
import { useTableUrlState } from '@/hooks/use-table-url-state'
import { getLobeIcon } from '@/lib/lobe-icon'
import { getChannels, searchChannels, getGroups } from '../api'
import {
......@@ -57,11 +56,11 @@ import {
aggregateChannelsByTag,
getChannelTableRowId,
isTagAggregateRow,
getChannelTypeIcon,
getChannelTypeLabel,
} from '../lib'
import type { Channel, ChannelSortBy } from '../types'
import { ChannelCard } from './channel-card'
import { ChannelTypeLogo } from './channel-type-badge'
import { useChannelsColumns } from './channels-columns'
import { useChannels } from './channels-provider'
import { DataTableBulkActions } from './data-table-bulk-actions'
......@@ -388,12 +387,11 @@ export function ChannelsTable() {
count: totalTypes,
},
...typeIds.map((item) => {
const iconName = getChannelTypeIcon(item.type)
return {
label: getChannelTypeLabel(item.type),
value: String(item.type),
count: item.count,
iconNode: getLobeIcon(`${iconName}.Color`, 16),
iconNode: <ChannelTypeLogo type={item.type} size={16} />,
}
}),
]
......
......@@ -81,7 +81,14 @@ import {
} from '@/components/ui/form'
import { IconBadge, type IconBadgeTone } from '@/components/ui/icon-badge'
import { Input } from '@/components/ui/input'
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 {
Sheet,
......@@ -101,6 +108,7 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip'
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 {
......@@ -112,7 +120,6 @@ import {
parseChannelConnectionInfo,
type ChannelConnectionInfo,
} from '@/lib/channel-connection-info'
import { getLobeIcon } from '@/lib/lobe-icon'
import { ROLE } from '@/lib/roles'
import { cn } from '@/lib/utils'
import { useAuthStore } from '@/stores/auth-store'
......@@ -152,7 +159,6 @@ import {
transformChannelToFormDefaults,
type ChannelFormValues,
deduplicateKeys,
getChannelTypeIcon,
getKeyPromptForType,
parseModelsString,
formatModelsArray,
......@@ -167,7 +173,12 @@ import {
collectInvalidStatusCodeEntries,
collectNewDisallowedStatusCodeRedirects,
} from '../../lib/status-code-risk-guard'
import {
assessBaseUrlTrust,
nextTaskPluginBaseUrl,
} from '../../lib/task-plugin-base-url'
import type { Channel } from '../../types'
import { ChannelTypeLogo } from '../channel-type-badge'
import { useChannels } from '../channels-provider'
import { AdvancedCustomEditorDialog } from '../dialogs/advanced-custom-editor-dialog'
import { FetchModelsDialog } from '../dialogs/fetch-models-dialog'
......@@ -415,35 +426,6 @@ function configuredAdvancedSectionClassName(
)
}
function ChannelTypeLogo(props: {
type: number
size?: number
className?: string
}) {
const isKnownType = CHANNEL_TYPE_OPTIONS.some(
(option) => option.value === props.type
)
if (!isKnownType) {
return (
<Server
className={cn('text-muted-foreground shrink-0', props.className)}
style={{
width: props.size ?? 16,
height: props.size ?? 16,
}}
aria-hidden='true'
/>
)
}
return (
<span className={cn('inline-flex shrink-0', props.className)}>
{getLobeIcon(`${getChannelTypeIcon(props.type)}.Color`, props.size ?? 16)}
</span>
)
}
function getSectionStatusIcon(status: ChannelEditorSectionStatus): ReactNode {
if (status === 'error') {
return <AlertCircle className='h-3.5 w-3.5' aria-hidden='true' />
......@@ -704,6 +686,7 @@ export function ChannelMutateDrawer({
const currentType = form.watch('type')
const currentStatus = form.watch('status')
const currentBaseUrl = form.watch('base_url')
const currentTaskPluginKey = form.watch('task_plugin_key')
const currentKey = form.watch('key')
const currentOther = form.watch('other')
const currentModels = form.watch('models')
......@@ -913,6 +896,19 @@ export function ChannelMutateDrawer({
queryFn: getTaskPluginOptions,
enabled: currentType === CHANNEL_TYPE_TASK_PLUGIN && canBindTaskPlugin,
})
const boundTaskPlugin =
currentType === CHANNEL_TYPE_TASK_PLUGIN
? taskPluginOptionsQuery.data?.find(
(item) => item.key === currentTaskPluginKey
)
: undefined
// The plugin author proposes the destination host once a default is
// prefilled, so the admin is told when the key would travel over plain HTTP
// or to a private network before the channel is saved.
const taskPluginBaseUrlTrust =
currentType === CHANNEL_TYPE_TASK_PLUGIN
? assessBaseUrlTrust(currentBaseUrl)
: null
const channelTypeOptions = useMemo(() => {
const options = channelTypeOptionsForTaskPluginBind(canBindTaskPlugin).map(
......@@ -1814,7 +1810,11 @@ export function ChannelMutateDrawer({
<div className='min-w-0'>
<SheetTitle className='flex items-center gap-3'>
<IconBadge tone='info' size='title'>
<ChannelTypeLogo type={currentType} size={22} />
<ChannelTypeLogo
type={currentType}
plugin={boundTaskPlugin}
size={22}
/>
</IconBadge>
<span>
{isEditing ? t('Edit Channel') : t('Create Channel')}
......@@ -1899,9 +1899,13 @@ export function ChannelMutateDrawer({
<div className='grid gap-5 lg:grid-cols-[13rem_minmax(0,1fr)] lg:items-start'>
<ChannelEditorNav
providerLogo={
<ChannelTypeLogo type={currentType} size={18} />
<ChannelTypeLogo
type={currentType}
plugin={boundTaskPlugin}
size={18}
/>
}
providerLabel={t(currentTypeLabel)}
providerLabel={boundTaskPlugin?.name || t(currentTypeLabel)}
statusLabel={t(currentStatusLabel)}
progressLabel={progressLabel}
navigationLabel={t('Channels')}
......@@ -1980,35 +1984,65 @@ export function ChannelMutateDrawer({
<FormItem>
<FormLabel>{t('Task plugin *')}</FormLabel>
{canBindTaskPlugin ? (
<FormControl><Combobox
value={field.value}
onValueChange={(value) => {
field.onChange(value)
const plugin =
taskPluginOptionsQuery.data?.find(
(item) => item.key === value
<FormControl>
<Combobox
value={field.value}
onValueChange={(value) => {
const options =
taskPluginOptionsQuery.data ?? []
const previousPlugin = options.find(
(item) => item.key === field.value
)
if (plugin?.models?.length) {
form.setValue(
'models',
formatModelsArray(plugin.models),
{
shouldDirty: true,
}
field.onChange(value)
const plugin = options.find(
(item) => item.key === value
)
}
}}
options={(
taskPluginOptionsQuery.data ?? []
).map((plugin) => ({
value: plugin.key,
label: `${plugin.name} (${plugin.key})`,
}))}
className='w-full'
placeholder={t(
'Select task plugin'
)}
/></FormControl>
if (plugin?.models?.length) {
form.setValue(
'models',
formatModelsArray(plugin.models),
{
shouldDirty: true,
}
)
}
const prefilledBaseUrl =
nextTaskPluginBaseUrl(
form.getValues('base_url'),
previousPlugin?.baseUrl,
plugin?.baseUrl
)
if (prefilledBaseUrl !== null) {
form.setValue(
'base_url',
prefilledBaseUrl,
{
shouldDirty: true,
shouldValidate: true,
}
)
}
}}
options={(
taskPluginOptionsQuery.data ?? []
).map((plugin) => ({
value: plugin.key,
label: `${plugin.name} (${plugin.key})`,
icon: (
<PluginIcon
plugin={{
...plugin,
hasIcon: plugin.hasIcon,
}}
size={16}
/>
),
}))}
className='w-full'
placeholder={t('Select task plugin')}
showSelectedIcon
/>
</FormControl>
) : (
<FormControl>
<Input
......@@ -2020,7 +2054,7 @@ placeholder={t(
)}
<FormDescription>
{t(
'Selecting a plugin fills its declared models.'
'Selecting a plugin fills its declared models and default base URL.'
)}
</FormDescription>
<FormMessage />
......@@ -2792,12 +2826,74 @@ placeholder={t(
{...field}
/>
</FormControl>
<FormDescription>
{t(
'Custom API base URL. For official channels, New API has built-in addresses. Only fill this for third-party proxy sites or special endpoints. Do not add /v1 or trailing slash.'
{currentType !==
CHANNEL_TYPE_TASK_PLUGIN && (
<FormDescription>
{t(
'Custom API base URL. For official channels, New API has built-in addresses. Only fill this for third-party proxy sites or special endpoints. Do not add /v1 or trailing slash.'
)}
</FormDescription>
)}
{currentType === CHANNEL_TYPE_TASK_PLUGIN &&
!boundTaskPlugin?.baseUrl && (
<FormDescription>
{t(
'The upstream address this plugin sends requests to. The plugin declares no default, so it must be filled in.'
)}
</FormDescription>
)}
{currentType === CHANNEL_TYPE_TASK_PLUGIN &&
boundTaskPlugin?.baseUrl && (
<FormDescription className='flex flex-wrap items-center gap-x-1'>
<span>{t('Plugin default')}:</span>
<span className='font-mono break-all'>
{boundTaskPlugin.baseUrl}
</span>
{(field.value ?? '')
.trim()
.replace(/\/+$/, '') !==
boundTaskPlugin.baseUrl && (
<Button
type='button'
variant='link'
size='xs'
className='h-auto p-0'
onClick={() =>
form.setValue(
'base_url',
boundTaskPlugin.baseUrl ?? '',
{
shouldDirty: true,
shouldValidate: true,
}
)
}
>
{t('Use default')}
</Button>
)}
</FormDescription>
)}
</FormDescription>
<FormMessage />
{(taskPluginBaseUrlTrust?.plainHttp ||
taskPluginBaseUrlTrust?.privateHost) && (
<Alert>
<AlertCircle />
<AlertDescription>
{taskPluginBaseUrlTrust?.plainHttp &&
t(
'This base URL uses plain HTTP, so the channel key is sent unencrypted.'
)}
{taskPluginBaseUrlTrust?.plainHttp &&
taskPluginBaseUrlTrust?.privateHost &&
' '}
{taskPluginBaseUrlTrust?.privateHost &&
t(
'This base URL points at a private or local network host. Make sure it is an upstream you control.'
)}
</AlertDescription>
</Alert>
)}
</FormItem>
)}
/>
......
/*
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 assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import {
assessBaseUrlTrust,
nextTaskPluginBaseUrl,
} from '../task-plugin-base-url'
describe('assessBaseUrlTrust', () => {
test('returns null for empty or unparsable input', () => {
assert.equal(assessBaseUrlTrust(''), null)
assert.equal(assessBaseUrlTrust(' '), null)
assert.equal(assessBaseUrlTrust('not a url'), null)
assert.equal(assessBaseUrlTrust('ftp://files.example.com'), null)
})
test('flags plain http on a public host without flagging the host', () => {
assert.deepEqual(assessBaseUrlTrust('http://api.example.com/v1'), {
plainHttp: true,
privateHost: false,
})
})
test('flags private, loopback, link-local and single-label hosts', () => {
for (const url of [
'http://127.0.0.1:8000',
'http://localhost:3000',
'http://10.0.0.5',
'http://192.168.1.10:8080',
'http://172.16.0.1',
'http://169.254.169.254/latest',
'http://100.64.0.1',
'http://[::1]:8000',
'http://[fe80::1]',
'http://[fd00::1]',
'http://suno-api:8000',
'https://nas.local',
'https://gateway.internal',
]) {
assert.equal(assessBaseUrlTrust(url)?.privateHost, true, url)
}
})
test('does not flag a public https host', () => {
assert.deepEqual(assessBaseUrlTrust('https://api.klingai.com'), {
plainHttp: false,
privateHost: false,
})
assert.equal(
assessBaseUrlTrust('https://172.32.0.1')?.privateHost,
false,
'172.32.x.x is outside the RFC 1918 172.16/12 block'
)
})
})
describe('nextTaskPluginBaseUrl', () => {
const pluginA = 'http://127.0.0.1:8000'
const pluginB = 'https://api.vendor-b.example'
test('fills an empty field with the selected plugin default', () => {
assert.equal(nextTaskPluginBaseUrl('', undefined, pluginA), pluginA)
assert.equal(nextTaskPluginBaseUrl(undefined, undefined, pluginA), pluginA)
})
test('replaces the previous plugin default when switching plugins', () => {
assert.equal(nextTaskPluginBaseUrl(pluginA, pluginA, pluginB), pluginB)
assert.equal(
nextTaskPluginBaseUrl(`${pluginA}/`, pluginA, pluginB),
pluginB,
'a trailing slash typed by the browser autocomplete still counts as the default'
)
})
test('keeps a value the administrator typed by hand', () => {
assert.equal(
nextTaskPluginBaseUrl('https://my-proxy.example', pluginA, pluginB),
null
)
assert.equal(
nextTaskPluginBaseUrl('https://my-proxy.example', undefined, pluginB),
null
)
})
test('changes nothing when the selected plugin declares no default', () => {
assert.equal(nextTaskPluginBaseUrl('', pluginA, undefined), null)
assert.equal(nextTaskPluginBaseUrl(pluginA, pluginA, ''), null)
})
test('changes nothing when the field already holds the new default', () => {
assert.equal(nextTaskPluginBaseUrl(`${pluginB}/`, pluginA, pluginB), null)
})
})
/*
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
*/
/**
* Hosts an administrator should look at twice before sending a channel key to
* them: loopback, RFC 1918 and CGNAT ranges, link-local (including the cloud
* metadata address), IPv6 loopback/link-local/ULA, and single-label or
* *.local / *.internal / *.lan names. These are legitimate for self-hosted
* upstreams, so they are flagged, never blocked.
*/
const PRIVATE_HOST_PATTERNS: readonly RegExp[] = [
/^localhost$/i,
/\.localhost$/i,
/^127\./,
/^0\.0\.0\.0$/,
/^10\./,
/^192\.168\./,
/^172\.(1[6-9]|2\d|3[01])\./,
/^169\.254\./,
/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./,
/^\[?::1\]?$/,
/^\[?fe80:/i,
/^\[?f[cd][0-9a-f]{2}:/i,
/\.(local|internal|lan)$/i,
]
export type BaseUrlTrust = {
plainHttp: boolean
privateHost: boolean
}
/**
* Describes why a base URL deserves a warning before a channel key is bound to
* it. Returns null for empty or unparsable input so callers render nothing.
*/
export function assessBaseUrlTrust(
value: string | undefined
): BaseUrlTrust | null {
const trimmed = value?.trim()
if (!trimmed) return null
let parsed: URL
try {
parsed = new URL(trimmed)
} catch {
return null
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null
const host = parsed.hostname.toLowerCase()
const singleLabel = !host.includes('.') && !host.includes(':')
return {
plainHttp: parsed.protocol === 'http:',
privateHost:
singleLabel ||
PRIVATE_HOST_PATTERNS.some((pattern) => pattern.test(host)),
}
}
function normalizeBaseUrlValue(value: string | undefined): string {
return String(value ?? '')
.trim()
.replace(/\/+$/, '')
}
/**
* Decides what the Base URL field should become when the bound task plugin
* changes. The plugin default is written only when the field is empty or still
* holds the previous plugin's default, so a value the administrator typed by
* hand survives switching plugins. Returns null when nothing should change.
*/
export function nextTaskPluginBaseUrl(
currentValue: string | undefined,
previousDefault: string | undefined,
nextDefault: string | undefined
): string | null {
if (!nextDefault) return null
const current = normalizeBaseUrlValue(currentValue)
if (current && current !== normalizeBaseUrlValue(previousDefault)) {
return null
}
if (current === normalizeBaseUrlValue(nextDefault)) return null
return nextDefault
}
......@@ -80,6 +80,7 @@ export type Channel = z.infer<typeof channelSchema>
// ============================================================================
export interface ChannelSettings {
task_plugin_key?: string
force_format?: boolean
thinking_to_content?: boolean
proxy?: string
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment