Skip to content
Toggle navigation
P
Projects
G
Groups
S
Snippets
Help
phsl
/
new-api
This project
Loading...
Sign in
Toggle navigation
Go to a project
Project
Repository
Issues
0
Merge Requests
0
Pipelines
Wiki
Snippets
Members
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Commit
eb76b136
authored
Sep 08, 2026
by
CaIon
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat(channels): improve plugin channel setup and icons
parent
98433092
Expand all
Hide whitespace changes
Inline
Side-by-side
Showing
12 changed files
with
624 additions
and
33 deletions
+624
-33
web/src/components/provider-badge.tsx
+5
-1
web/src/components/ui/__tests__/combobox.test.tsx
+129
-5
web/src/components/ui/combobox.tsx
+8
-1
web/src/features/channels/api.ts
+10
-1
web/src/features/channels/components/channel-type-badge.test.tsx
+101
-0
web/src/features/channels/components/channel-type-badge.tsx
+124
-0
web/src/features/channels/components/channels-columns.tsx
+34
-21
web/src/features/channels/components/channels-table.tsx
+2
-4
web/src/features/channels/components/drawers/channel-mutate-drawer.tsx
+0
-0
web/src/features/channels/lib/__tests__/task-plugin-base-url.test.ts
+113
-0
web/src/features/channels/lib/task-plugin-base-url.ts
+97
-0
web/src/features/channels/types.ts
+1
-0
No files found.
web/src/components/provider-badge.tsx
View file @
eb76b136
...
...
@@ -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
=
icon
Key
?
getLobeIcon
(
iconKey
,
iconSize
)
:
null
const
icon
=
icon
Node
??
(
iconKey
?
getLobeIcon
(
iconKey
,
iconSize
)
:
null
)
return
(
<
div
...
...
web/src/components/ui/__tests__/combobox.test.tsx
View file @
eb76b136
...
...
@@ -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
()
})
})
web/src/components/ui/combobox.tsx
View file @
eb76b136
...
...
@@ -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
>
...
...
web/src/features/channels/api.ts
View file @
eb76b136
...
...
@@ -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
<
{
...
...
web/src/features/channels/components/channel-type-badge.test.tsx
0 → 100644
View file @
eb76b136
/*
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
()
})
web/src/features/channels/components/channel-type-badge.tsx
0 → 100644
View file @
eb76b136
/*
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
>
)
}
web/src/features/channels/components/channels-columns.tsx
View file @
eb76b136
...
...
@@ -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
>
...
...
web/src/features/channels/components/channels-table.tsx
View file @
eb76b136
...
...
@@ -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
}
/>
,
}
}),
]
...
...
web/src/features/channels/components/drawers/channel-mutate-drawer.tsx
View file @
eb76b136
This diff is collapsed.
Click to expand it.
web/src/features/channels/lib/__tests__/task-plugin-base-url.test.ts
0 → 100644
View file @
eb76b136
/*
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
)
})
})
web/src/features/channels/lib/task-plugin-base-url.ts
0 → 100644
View file @
eb76b136
/*
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
}
web/src/features/channels/types.ts
View file @
eb76b136
...
...
@@ -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
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment