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/>. ...@@ -16,12 +16,15 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import type { ReactNode } from 'react'
import { getLobeIcon } from '@/lib/lobe-icon' import { getLobeIcon } from '@/lib/lobe-icon'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { StatusBadge, type StatusBadgeProps } from './status-badge' import { StatusBadge, type StatusBadgeProps } from './status-badge'
type ProviderBadgeProps = Omit<StatusBadgeProps, 'children' | 'label'> & { type ProviderBadgeProps = Omit<StatusBadgeProps, 'children' | 'label'> & {
iconNode?: ReactNode
iconKey?: string | null iconKey?: string | null
iconSize?: number iconSize?: number
label: string label: string
...@@ -32,12 +35,13 @@ type ProviderBadgeProps = Omit<StatusBadgeProps, 'children' | 'label'> & { ...@@ -32,12 +35,13 @@ type ProviderBadgeProps = Omit<StatusBadgeProps, 'children' | 'label'> & {
export function ProviderBadge({ export function ProviderBadge({
className, className,
iconKey, iconKey,
iconNode,
iconSize = 14, iconSize = 14,
label, label,
colorText = true, colorText = true,
...badgeProps ...badgeProps
}: ProviderBadgeProps) { }: ProviderBadgeProps) {
const icon = iconKey ? getLobeIcon(iconKey, iconSize) : null const icon = iconNode ?? (iconKey ? getLobeIcon(iconKey, iconSize) : null)
return ( return (
<div <div
......
...@@ -31,7 +31,18 @@ const options = [ ...@@ -31,7 +31,18 @@ const options = [
function Fixture() { function Fixture() {
const [value, setValue] = useState('openai') 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', () => { describe('searchable single selection', () => {
...@@ -56,13 +67,126 @@ describe('searchable single selection', () => { ...@@ -56,13 +67,126 @@ describe('searchable single selection', () => {
it('respects disabled controls and options', async () => { it('respects disabled controls and options', async () => {
const change = vi.fn() 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() const user = userEvent.setup()
expect(screen.getByRole('combobox', { name: 'Provider' })).toBeDisabled() 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' })) await user.click(screen.getByRole('combobox', { name: 'Provider' }))
expect(screen.getByRole('option', { name: 'Unavailable provider' })).toHaveAttribute('aria-disabled', 'true') expect(
await user.click(screen.getByRole('option', { name: 'Unavailable provider' })) screen.getByRole('option', { name: 'Unavailable provider' })
).toHaveAttribute('aria-disabled', 'true')
await user.click(
screen.getByRole('option', { name: 'Unavailable provider' })
)
expect(change).not.toHaveBeenCalled() 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 = { ...@@ -48,6 +48,7 @@ type LegacyComboboxProps = {
searchPlaceholder?: string searchPlaceholder?: string
emptyText?: string emptyText?: string
allowCustomValue?: boolean allowCustomValue?: boolean
showSelectedIcon?: boolean
className?: string className?: string
id?: string id?: string
openOnFocus?: boolean openOnFocus?: boolean
...@@ -142,7 +143,13 @@ function OptionCombobox(props: LegacyComboboxProps) { ...@@ -142,7 +143,13 @@ function OptionCombobox(props: LegacyComboboxProps) {
} }
triggerAriaLabel={props['aria-label'] ?? t('Open')} triggerAriaLabel={props['aria-label'] ?? t('Open')}
className='h-full min-h-8 w-full' 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> </div>
<ComboboxContent anchor={anchor}> <ComboboxContent anchor={anchor}>
<ComboboxEmpty> <ComboboxEmpty>
......
...@@ -48,7 +48,16 @@ const channelActionConfig = ( ...@@ -48,7 +48,16 @@ const channelActionConfig = (
skipErrorHandler: true, 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[]> { export async function getTaskPluginOptions(): Promise<TaskPluginOption[]> {
const response = await api.get<{ 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' ...@@ -56,7 +56,11 @@ import { formatTimestampToDate } from '@/lib/format'
import { truncateText } from '@/lib/utils' import { truncateText } from '@/lib/utils'
import { getCodexUsage, updateChannelBalance } from '../api' 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 { import {
formatRelativeTime, formatRelativeTime,
formatResponseTime, formatResponseTime,
...@@ -78,6 +82,7 @@ import { ...@@ -78,6 +82,7 @@ import {
import { parseUpstreamUpdateMeta } from '../lib/upstream-update-utils' import { parseUpstreamUpdateMeta } from '../lib/upstream-update-utils'
import type { Channel } from '../types' import type { Channel } from '../types'
import { ChannelRowActionsLayoutContext } from './channel-row-actions-context' import { ChannelRowActionsLayoutContext } from './channel-row-actions-context'
import { TaskPluginChannelBadge } from './channel-type-badge'
import { useChannels } from './channels-provider' import { useChannels } from './channels-provider'
import { DataTableRowActions } from './data-table-row-actions' import { DataTableRowActions } from './data-table-row-actions'
import { DataTableTagRowActions } from './data-table-tag-row-actions' import { DataTableTagRowActions } from './data-table-tag-row-actions'
...@@ -819,26 +824,34 @@ export function useChannelsColumns( ...@@ -819,26 +824,34 @@ export function useChannelsColumns(
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
)} )}
<TooltipProvider delay={300}> {type === CHANNEL_TYPE_TASK_PLUGIN ? (
<Tooltip> <TaskPluginChannelBadge
<TooltipTrigger pluginKey={
render={ parseChannelSettings(channel.setting)?.task_plugin_key
<div className='max-w-full min-w-0 overflow-hidden' /> }
} />
> ) : (
<ProviderBadge <TooltipProvider delay={300}>
iconKey={`${iconName}.Color`} <Tooltip>
iconSize={18} <TooltipTrigger
label={typeName} render={
colorText={false} <div className='max-w-full min-w-0 overflow-hidden' />
copyable={false} }
showDot={false} >
className='max-w-full min-w-0 overflow-hidden' <ProviderBadge
/> iconKey={`${iconName}.Color`}
</TooltipTrigger> iconSize={18}
<TooltipContent side='top'>{typeName}</TooltipContent> label={typeName}
</Tooltip> colorText={false}
</TooltipProvider> copyable={false}
showDot={false}
className='max-w-full min-w-0 overflow-hidden'
/>
</TooltipTrigger>
<TooltipContent side='top'>{typeName}</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{isIonet && ( {isIonet && (
<TooltipProvider delay={100}> <TooltipProvider delay={100}>
<Tooltip> <Tooltip>
......
...@@ -44,7 +44,6 @@ import { ...@@ -44,7 +44,6 @@ import {
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
import { useMediaQuery } from '@/hooks' import { useMediaQuery } from '@/hooks'
import { useTableUrlState } from '@/hooks/use-table-url-state' import { useTableUrlState } from '@/hooks/use-table-url-state'
import { getLobeIcon } from '@/lib/lobe-icon'
import { getChannels, searchChannels, getGroups } from '../api' import { getChannels, searchChannels, getGroups } from '../api'
import { import {
...@@ -57,11 +56,11 @@ import { ...@@ -57,11 +56,11 @@ import {
aggregateChannelsByTag, aggregateChannelsByTag,
getChannelTableRowId, getChannelTableRowId,
isTagAggregateRow, isTagAggregateRow,
getChannelTypeIcon,
getChannelTypeLabel, getChannelTypeLabel,
} from '../lib' } from '../lib'
import type { Channel, ChannelSortBy } from '../types' import type { Channel, ChannelSortBy } from '../types'
import { ChannelCard } from './channel-card' import { ChannelCard } from './channel-card'
import { ChannelTypeLogo } from './channel-type-badge'
import { useChannelsColumns } from './channels-columns' import { useChannelsColumns } from './channels-columns'
import { useChannels } from './channels-provider' import { useChannels } from './channels-provider'
import { DataTableBulkActions } from './data-table-bulk-actions' import { DataTableBulkActions } from './data-table-bulk-actions'
...@@ -388,12 +387,11 @@ export function ChannelsTable() { ...@@ -388,12 +387,11 @@ export function ChannelsTable() {
count: totalTypes, count: totalTypes,
}, },
...typeIds.map((item) => { ...typeIds.map((item) => {
const iconName = getChannelTypeIcon(item.type)
return { return {
label: getChannelTypeLabel(item.type), label: getChannelTypeLabel(item.type),
value: String(item.type), value: String(item.type),
count: item.count, count: item.count,
iconNode: getLobeIcon(`${iconName}.Color`, 16), iconNode: <ChannelTypeLogo type={item.type} size={16} />,
} }
}), }),
] ]
......
/*
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> ...@@ -80,6 +80,7 @@ export type Channel = z.infer<typeof channelSchema>
// ============================================================================ // ============================================================================
export interface ChannelSettings { export interface ChannelSettings {
task_plugin_key?: string
force_format?: boolean force_format?: boolean
thinking_to_content?: boolean thinking_to_content?: boolean
proxy?: string 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