Commit 98433092 by CaIon

feat(web): improve plugin management and marketplace

parent 99974a81
/*
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 { render, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, test, vi } from 'vitest'
import { MarketplacePluginCard } from '../components/marketplace-plugin-card'
import type { MarketplacePlugin } from '../types'
vi.mock('@/lib/lobe-icon', () => ({
getLobeIcon: () => null,
}))
const INDEX_URL =
'https://raw.githubusercontent.com/QuantumNous/new-api-plugins/main/index.json'
function plugin(overrides?: Partial<MarketplacePlugin>): MarketplacePlugin {
return {
key: 'incho',
name: 'Incho',
icon: 'text',
latest: '1.0.1',
versions: [
{ version: '1.0.1', path: 'plugins/tasks/incho/1.0.1/plugin.js' },
],
iconFile: { path: 'plugins/tasks/incho/icon.svg' },
...overrides,
}
}
function renderCard(target: MarketplacePlugin) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
return render(
<QueryClientProvider client={client}>
<MarketplacePluginCard
plugin={target}
indexUrl={INDEX_URL}
installState={{ status: 'not_installed' }}
onInstall={() => undefined}
/>
</QueryClientProvider>
)
}
afterEach(() => {
vi.unstubAllGlobals()
})
describe('marketplace card logo', () => {
test('fetches the sidecar icon from the index origin and renders it as a data URI', async () => {
const fetchMock = vi.fn(
async () =>
new Response('<svg/>', {
status: 200,
headers: { 'content-type': 'text/plain; charset=utf-8' },
})
)
vi.stubGlobal('fetch', fetchMock)
const { container } = renderCard(plugin())
await waitFor(() => {
const image = container.querySelector('img')
expect(image?.getAttribute('src')).toBe(
'data:image/svg+xml;base64,PHN2Zy8+'
)
})
expect(fetchMock).toHaveBeenCalledWith(
'https://raw.githubusercontent.com/QuantumNous/new-api-plugins/main/plugins/tasks/incho/icon.svg'
)
})
test('shows the text avatar when the icon file cannot be fetched', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response('', { status: 404 }))
)
const { container } = renderCard(plugin())
await waitFor(() => expect(container.textContent).toContain('IN'))
expect(container.querySelector('img')).toBeNull()
})
test('does not fetch anything for a plugin without a sidecar icon', () => {
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
const { container } = renderCard(plugin({ iconFile: undefined }))
expect(fetchMock).not.toHaveBeenCalled()
expect(container.querySelector('img')).toBeNull()
})
})
test.each([{ channelTypes: undefined }, { channelTypes: [] }])(
'shows the generic task channel type when channelTypes is $channelTypes',
({ channelTypes }) => {
const { getByText } = renderCard(
plugin({ iconFile: undefined, channelTypes })
)
expect(getByText('Channel type').nextElementSibling).toHaveTextContent(
'Task Plugin'
)
}
)
......@@ -29,7 +29,6 @@ import {
isStaleFactoryOverride,
marketplaceBuiltInVersion,
parseMarketplaceIndex,
resolveMarketplaceActionPolicy,
resolvePluginSourceUrl,
} from '../lib/marketplace'
import type {
......@@ -365,6 +364,83 @@ describe('marketplace index parsing', () => {
})
assert.equal(index.plugins[0].icon, undefined)
})
test('drops an inline data URI icon so logos only come from sidecar files', () => {
const index = parseMarketplaceIndex({
indexVersion: 1,
plugins: [
{
key: 'sunoapi',
latest: '1.0.0',
icon: 'data:image/png;base64,iVBORw0KGgo=',
versions: [{ version: '1.0.0', path: 'a/plugin.js' }],
},
],
})
assert.equal(index.plugins[0].icon, undefined)
})
test('drops a remote http icon so the marketplace page cannot beacon the author', () => {
const index = parseMarketplaceIndex({
indexVersion: 1,
plugins: [
{
key: 'sunoapi',
latest: '1.0.0',
icon: 'https://evil.example/icon.png',
versions: [{ version: '1.0.0', path: 'a/plugin.js' }],
},
],
})
assert.equal(index.plugins[0].icon, undefined)
})
test('keeps an iconFile entry pointing at an svg or png and drops other extensions', () => {
const index = parseMarketplaceIndex({
indexVersion: 1,
plugins: [
{
key: 'incho',
latest: '1.0.1',
iconFile: { path: ' plugins/tasks/incho/icon.svg ', sha256: 'abc' },
versions: [{ version: '1.0.1', path: 'a/plugin.js' }],
},
{
key: 'other',
latest: '1.0.0',
iconFile: { path: 'plugins/tasks/other/icon.js' },
versions: [{ version: '1.0.0', path: 'b/plugin.js' }],
},
],
})
assert.deepEqual(index.plugins[0].iconFile, {
path: 'plugins/tasks/incho/icon.svg',
sha256: 'abc',
})
assert.equal(index.plugins[1].iconFile, undefined)
})
test('keeps a version baseUrl after trim and omits a blank one', () => {
const index = parseMarketplaceIndex({
indexVersion: 1,
plugins: [
{
key: 'sunoapi',
latest: '1.1.0',
versions: [
{
version: '1.1.0',
path: 'a/plugin.js',
baseUrl: ' http://127.0.0.1:8000 ',
},
{ version: '1.0.0', path: 'b/plugin.js', baseUrl: ' ' },
],
},
],
})
assert.equal(index.plugins[0].versions[0].baseUrl, 'http://127.0.0.1:8000')
assert.equal(index.plugins[0].versions[1].baseUrl, undefined)
})
})
describe('install state derivation', () => {
......@@ -419,41 +495,7 @@ describe('install state derivation', () => {
})
})
describe('marketplace action policy', () => {
test('factory-served plugin returns the informational system-update state', () => {
assert.deepEqual(
resolveMarketplaceActionPolicy(
installedPlugin('doubao', '1.0.0', { source: 'factory' })
),
{ kind: 'system_update' }
)
})
test('overridden factory plugin still allows marketplace install', () => {
assert.deepEqual(
resolveMarketplaceActionPolicy(
installedPlugin('doubao', '1.2.0', {
source: 'override_over_factory',
factory_meta: factoryMeta('doubao', '1.0.0'),
})
),
{ kind: 'install' }
)
})
test('third-party plugin still allows marketplace install', () => {
assert.deepEqual(
resolveMarketplaceActionPolicy(installedPlugin('doubao', '1.0.0')),
{ kind: 'install' }
)
})
test('uninstalled plugin still allows marketplace install', () => {
assert.deepEqual(resolveMarketplaceActionPolicy(undefined), {
kind: 'install',
})
})
describe('marketplace built-in version', () => {
test('factory-served built-in version is the installed meta version', () => {
assert.equal(
marketplaceBuiltInVersion(
......@@ -585,3 +627,72 @@ describe('source integrity and trust labels', () => {
)
})
})
describe('marketplace display metadata', () => {
test('sorts by descending priority and ascending key with zero defaults', () => {
const index = parseMarketplaceIndex({
indexVersion: 1,
plugins: [
marketplacePlugin({ key: 'low', sortPriority: -10 }),
marketplacePlugin({ key: 'zero' }),
marketplacePlugin({ key: 'beta', sortPriority: 20 }),
marketplacePlugin({ key: 'alpha', sortPriority: 20 }),
],
})
assert.deepEqual(
index.plugins.map((plugin) => plugin.key),
['alpha', 'beta', 'zero', 'low']
)
})
test.each([1.5, '2', null, Number.NaN, Infinity, -2147483649, 2147483648])(
'defaults invalid priority %s to zero',
(sortPriority) => {
const index = parseMarketplaceIndex({
indexVersion: 1,
plugins: [{ ...marketplacePlugin(), sortPriority }],
})
assert.equal(index.plugins[0].sortPriority, 0)
}
)
test.each([-2147483648, 0, 2147483647])(
'preserves priority %s',
(sortPriority) => {
const index = parseMarketplaceIndex({
indexVersion: 1,
plugins: [marketplacePlugin({ sortPriority })],
})
assert.equal(index.plugins[0].sortPriority, sortPriority)
}
)
test('preserves HTTPS website paths, queries and fragments', () => {
const website = 'https://example.com/docs?q=1#intro'
const index = parseMarketplaceIndex({
indexVersion: 1,
plugins: [marketplacePlugin({ website })],
})
assert.equal(index.plugins[0].website, website)
})
test.each([
'',
'http://example.com',
'/docs',
'https:///docs',
'https://user:pass@example.com',
'https://@example.com',
'javascript:alert(1)',
'https://-example.com',
'https://example.com:99999',
'https://example.com/a b',
])('hides invalid website %s without dropping the plugin', (website) => {
const index = parseMarketplaceIndex({
indexVersion: 1,
plugins: [marketplacePlugin({ website })],
})
assert.equal(index.plugins.length, 1)
assert.equal(index.plugins[0].website, undefined)
})
})
......@@ -22,8 +22,10 @@ import {
type ColumnDef,
} from '@tanstack/react-table'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, test, vi } from 'vitest'
import { MarketplacePluginCard } from '../components/marketplace-plugin-card'
import { PluginCard } from '../components/plugin-card'
import type { TaskPluginListItem } from '../types'
......@@ -78,23 +80,21 @@ function PluginCardHarness({ item }: { item: TaskPluginListItem }) {
}
describe('PluginCard layout', () => {
test('given a plugin, the versions read as pills beside the source and runtime badges', () => {
test('given a plugin, the plugin version reads as a pill beside the source and runtime badges', () => {
render(<PluginCardHarness item={makeItem()} />)
const version = screen.getByText('v1.2.3')
const apiVersion = screen.getByText('API v1')
const badgeRow = version.parentElement
expect(badgeRow).toBe(apiVersion.parentElement)
expect(screen.queryByText('API v1')).not.toBeInTheDocument()
expect(badgeRow).toHaveClass('flex-wrap')
expect(badgeRow?.textContent).toContain('source-badge')
expect(badgeRow?.textContent).toContain('runtime-badge')
})
test('given a plugin, the version pills carry labelled accessible names', () => {
test('given a plugin, the plugin version carries a labelled accessible name', () => {
render(<PluginCardHarness item={makeItem()} />)
expect(screen.getByLabelText('Active version 1.2.3')).toBeInTheDocument()
expect(screen.getByLabelText('API version v1')).toBeInTheDocument()
})
test('given a description, it is clamped to two lines', () => {
......@@ -160,3 +160,55 @@ describe('PluginCard layout', () => {
expect(screen.getByText('enabled-switch')).toBeInTheDocument()
})
})
describe('PluginCard website', () => {
test('shows an HTTPS website with safe new-tab attributes', () => {
const item = makeItem()
item.meta.website = 'https://example.com/docs'
render(<PluginCardHarness item={item} />)
const link = screen.getByRole('link', { name: 'Plugin website' })
expect(link).toHaveAttribute('href', item.meta.website)
expect(link).toHaveAttribute('target', '_blank')
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
})
test.each([undefined, 'http://example.com', 'javascript:alert(1)'])(
'hides website %s',
(website) => {
const item = makeItem()
item.meta.website = website
render(<PluginCardHarness item={item} />)
expect(
screen.queryByRole('link', { name: 'Plugin website' })
).not.toBeInTheDocument()
}
)
})
test.each(['override', 'factory', 'override_over_factory'] as const)(
'marketplace entry for %s offers installation',
async (source) => {
const user = userEvent.setup()
const onInstall = vi.fn()
render(
<MarketplacePluginCard
plugin={{
key: 'kling',
name: 'Kling',
latest: '1.2.3',
versions: [{ version: '1.2.3', path: 'plugin.js' }],
}}
installed={makeItem({ source })}
installState={{ status: 'up_to_date', installedVersion: '1.2.3' }}
onInstall={onInstall}
/>
)
expect(
screen.queryByRole('button', { name: 'Select version' })
).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Install' }))
expect(onInstall).toHaveBeenCalledOnce()
expect(
screen.queryByText('Updates with the system')
).not.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 assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import {
encodePluginIconFile,
fetchPluginIconDataUri,
MAX_PLUGIN_ICON_BYTES,
PluginIconFileError,
pluginIconMediaType,
} from '../lib/plugin-icon-file'
describe('pluginIconMediaType', () => {
test('maps svg and png extensions case-insensitively and rejects everything else', () => {
assert.equal(pluginIconMediaType('icon.svg'), 'image/svg+xml')
assert.equal(pluginIconMediaType('ICON.PNG'), 'image/png')
assert.equal(pluginIconMediaType('icon.jpg'), null)
assert.equal(pluginIconMediaType('plugin.js'), null)
})
})
describe('encodePluginIconFile', () => {
test('encodes an svg file as the data URI the gateway stores', async () => {
const file = new File(['<svg/>'], 'icon.svg', { type: '' })
assert.equal(
await encodePluginIconFile(file),
'data:image/svg+xml;base64,PHN2Zy8+'
)
})
test('rejects a file that is not svg or png', async () => {
const file = new File(['x'], 'icon.gif', { type: 'image/gif' })
await assert.rejects(encodePluginIconFile(file), (error: unknown) => {
assert.ok(error instanceof PluginIconFileError)
assert.equal(error.reason, 'unsupported_type')
return true
})
})
test('rejects a file over the size cap before reading it', async () => {
const file = new File(
[new Uint8Array(MAX_PLUGIN_ICON_BYTES + 1)],
'icon.png'
)
await assert.rejects(encodePluginIconFile(file), (error: unknown) => {
assert.ok(error instanceof PluginIconFileError)
assert.equal(error.reason, 'too_large')
return true
})
})
})
describe('fetchPluginIconDataUri', () => {
const okResponse = (body: string) =>
new Response(body, {
status: 200,
headers: { 'content-type': 'image/svg+xml' },
})
test('returns a data URI for a reachable svg even when the host labels it text/plain', async () => {
const result = await fetchPluginIconDataUri(
'https://raw.example/plugins/tasks/incho/icon.svg',
{
fetchImpl: async () =>
new Response('<svg/>', {
status: 200,
headers: {
'content-type': 'text/plain; charset=utf-8',
'x-content-type-options': 'nosniff',
},
}),
}
)
assert.equal(result, 'data:image/svg+xml;base64,PHN2Zy8+')
})
test('keeps the logo when the index digest matches and drops it on a mismatch', async () => {
const url = 'https://raw.example/plugins/tasks/incho/icon.svg'
assert.equal(
await fetchPluginIconDataUri(url, {
sha256:
'd4dc56669143034f31aa309635d4113d9ad76a02b1739da22c965ed2049be9e6',
fetchImpl: async () => okResponse('<svg/>'),
}),
'data:image/svg+xml;base64,PHN2Zy8+'
)
assert.equal(
await fetchPluginIconDataUri(url, {
sha256: 'deadbeef',
fetchImpl: async () => okResponse('<svg/>'),
}),
null
)
})
test('returns null for a non-image path, a failed response, or a network error', async () => {
assert.equal(
await fetchPluginIconDataUri('https://raw.example/icon.js', {
fetchImpl: async () => okResponse('x'),
}),
null
)
assert.equal(
await fetchPluginIconDataUri('https://raw.example/icon.svg', {
fetchImpl: async () => new Response('', { status: 404 }),
}),
null
)
assert.equal(
await fetchPluginIconDataUri('https://raw.example/icon.svg', {
fetchImpl: async () => {
throw new TypeError('offline')
},
}),
null
)
})
})
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { AxiosError, type AxiosAdapter } from 'axios'
/*
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 { StrictMode } from 'react'
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import { TaskPluginChannelBadge } from '@/features/channels/components/channel-type-badge'
import { api } from '@/lib/api'
import { ROLE } from '@/lib/roles'
import { useAuthStore } from '@/stores/auth-store'
import { PluginIcon } from '../components/plugin-icon'
vi.mock('@/lib/lobe-icon', () => ({
getLobeIcon: () => <svg data-testid='lobe-icon' />,
}))
const originalAdapter = api.defaults.adapter
const originalAuth = useAuthStore.getState().auth
const logo = new Blob(['<svg xmlns="http://www.w3.org/2000/svg"/>'], {
type: 'image/svg+xml',
})
const createObjectURL = vi.fn(() => 'blob:plugin-logo')
const revokeObjectURL = vi.fn()
let client: QueryClient
let adapter: ReturnType<typeof vi.fn<AxiosAdapter>>
beforeEach(() => {
client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
createObjectURL.mockClear()
revokeObjectURL.mockClear()
vi.stubGlobal(
'URL',
Object.assign(class extends URL {}, { createObjectURL, revokeObjectURL })
)
useAuthStore.setState({
auth: { ...originalAuth, accessToken: 'test-icon-access-token' },
})
adapter = vi.fn(async (config) => ({
data: logo,
status: 200,
statusText: 'OK',
headers: {},
config,
}))
api.defaults.adapter = adapter
})
afterEach(() => {
cleanup()
client.clear()
api.defaults.adapter = originalAdapter
useAuthStore.setState({ auth: originalAuth })
vi.unstubAllGlobals()
})
function GatewayLogo(props: { pluginKey?: string }) {
return (
<QueryClientProvider client={client}>
<PluginIcon
plugin={{
key: props.pluginKey ?? 'incho',
name: props.pluginKey ?? 'Incho',
hasIcon: true,
}}
size={24}
/>
</QueryClientProvider>
)
}
describe('PluginIcon image rendering', () => {
test('loads the plugin logo on first channel render with an empty cache in StrictMode', async () => {
useAuthStore.setState({
auth: {
...useAuthStore.getState().auth,
user: { id: 1, username: 'root', role: ROLE.SUPER_ADMIN },
},
})
adapter.mockImplementation(async (config) => ({
data:
config.url === '/api/task_plugin_options'
? {
success: true,
data: [
{ key: 'incho', name: 'Incho', hasIcon: true, models: [] },
],
}
: logo,
status: 200,
statusText: 'OK',
headers: {},
config,
}))
const { container } = render(
<StrictMode>
<QueryClientProvider client={client}>
<TaskPluginChannelBadge pluginKey='incho' />
</QueryClientProvider>
</StrictMode>
)
await waitFor(() =>
expect(container.querySelector('img')?.getAttribute('src')).toBe(
'blob:plugin-logo'
)
)
expect(container.textContent).toContain('Incho')
expect(createObjectURL).toHaveBeenCalledWith(logo)
})
test('fetches protected logos with the dashboard bearer header and renders a blob image', async () => {
const { container, unmount } = render(<GatewayLogo />)
await waitFor(() =>
expect(container.querySelector('img')?.getAttribute('src')).toBe(
'blob:plugin-logo'
)
)
expect(adapter).toHaveBeenCalledTimes(1)
const config = adapter.mock.calls[0][0]
expect(config.url).toBe('/api/plugin/task/incho/icon')
expect(config.headers.get('Authorization')).toBe(
'Bearer test-icon-access-token'
)
expect(config.responseType).toBe('blob')
expect(createObjectURL).toHaveBeenCalledWith(logo)
expect(container.querySelector('img')?.getAttribute('width')).toBe('24')
expect(container.querySelector('svg')).toBeNull()
unmount()
expect(revokeObjectURL).toHaveBeenCalledWith('blob:plugin-logo')
})
test.each(['failure', 'non-image'])(
'keeps the avatar for %s responses',
async (outcome) => {
adapter.mockImplementationOnce(async (config) => {
if (outcome === 'failure') {
throw new AxiosError('missing logo', undefined, config)
}
return {
data: new Blob(['{}'], { type: 'application/json' }),
status: 200,
statusText: 'OK',
headers: {},
config,
}
})
const { container } = render(<GatewayLogo />)
await waitFor(() => expect(client.isFetching()).toBe(0))
expect(container.querySelector('img')).toBeNull()
expect(container.textContent).toBe('IN')
expect(createObjectURL).not.toHaveBeenCalled()
}
)
test('removes the previous plugin logo while a different plugin loads', async () => {
const { container, rerender } = render(<GatewayLogo />)
await waitFor(() => expect(container.querySelector('img')).not.toBeNull())
adapter.mockImplementationOnce(() => new Promise(() => {}))
rerender(<GatewayLogo pluginKey='second' />)
expect(container.querySelector('img')).toBeNull()
expect(container.textContent).toBe('SE')
await waitFor(() =>
expect(revokeObjectURL).toHaveBeenCalledWith('blob:plugin-logo')
)
})
test('falls back to text instead of a channel brand when an image cannot be decoded', async () => {
const { container } = render(
<PluginIcon
plugin={{
key: 'sunoapi',
name: 'SunoAPI',
channelTypes: [36],
iconSrc: 'https://example.com/broken.svg',
}}
/>
)
await waitFor(() => expect(container.querySelector('img')).not.toBeNull())
fireEvent.error(container.querySelector('img') as HTMLImageElement)
expect(container.querySelector('img')).toBeNull()
expect(container.textContent).toBe('SU')
})
test('uses public marketplace images directly without authenticated requests', () => {
const { container } = render(
<PluginIcon
plugin={{
key: 'incho',
hasIcon: true,
iconSrc: 'https://example.com/icon.svg',
}}
/>
)
expect(container.querySelector('img')?.getAttribute('src')).toBe(
'https://example.com/icon.svg'
)
expect(adapter).not.toHaveBeenCalled()
})
test('ignores inline manifest images and still supports LobeHub icons', () => {
const { container, rerender, getByTestId } = render(
<PluginIcon
plugin={{ key: 'sunoapi', icon: 'data:image/png;base64,iVBORw0KGgo=' }}
/>
)
expect(container.querySelector('img')).toBeNull()
expect(container.textContent).toBe('SU')
rerender(<PluginIcon plugin={{ key: 'sora', icon: 'Sora.Color' }} />)
expect(getByTestId('lobe-icon')).toBeTruthy()
})
})
......@@ -17,9 +17,11 @@ 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 {
pluginIconUrl,
resolvePluginIcon,
TEXT_AVATAR_PALETTE,
textAvatarClass,
......@@ -37,13 +39,14 @@ describe('resolvePluginIcon', () => {
)
})
test('uses the first channel type when icon is absent', () => {
test('uses a text avatar when icon is absent even with a declared channel type', () => {
assert.deepEqual(
resolvePluginIcon({
channelTypes: [55, 1],
key: 'sora',
channelTypes: [36],
key: 'sunoapi',
name: 'SunoAPI',
}),
{ kind: 'lobe', name: 'OpenAI.Color' }
{ kind: 'text', label: 'SU', colorSeed: 'sunoapi' }
)
})
......@@ -89,6 +92,62 @@ describe('resolvePluginIcon', () => {
})
})
describe('resolvePluginIcon image logos', () => {
test('serves a gateway-held sidecar logo through the icon endpoint, ahead of meta.icon and channelTypes', () => {
assert.deepEqual(
resolvePluginIcon({
icon: 'Sora.Color',
channelTypes: [1],
key: 'in cho',
hasIcon: true,
}),
{ kind: 'image', src: '/api/plugin/task/in%20cho/icon' }
)
})
test('prefers an explicit image source over the gateway endpoint', () => {
assert.deepEqual(
resolvePluginIcon({
key: 'incho',
hasIcon: true,
iconSrc: 'https://raw.example/plugins/tasks/incho/icon.svg',
}),
{ kind: 'image', src: 'https://raw.example/plugins/tasks/incho/icon.svg' }
)
})
test('ignores an inline data URI in meta.icon and falls back to the text avatar', () => {
assert.deepEqual(
resolvePluginIcon({
icon: 'data:image/png;base64,iVBORw0KGgo=',
channelTypes: [1],
key: 'x',
}),
{ kind: 'text', label: 'X', colorSeed: 'x' }
)
})
test('ignores a remote URL in meta.icon and falls back to the text avatar', () => {
assert.deepEqual(
resolvePluginIcon({
icon: 'https://evil.example/icon.png',
key: 'x',
name: 'Suno',
}),
{ kind: 'text', label: 'SU', colorSeed: 'x' }
)
})
})
describe('pluginIconUrl', () => {
test('pins a version through the query string', () => {
assert.equal(
pluginIconUrl('incho', '1.0.1'),
'/api/plugin/task/incho/icon?version=1.0.1'
)
})
})
describe('textAvatarClass', () => {
test('is deterministic for the same seed', () => {
assert.equal(textAvatarClass('sunoapi'), textAvatarClass('sunoapi'))
......
/*
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, vi } from 'vitest'
import { parseMarketplaceIndex } from '../lib/marketplace'
import {
parsePluginMetaPreview,
resolvePluginMetaPreview,
} from '../lib/plugin-meta-preview'
import type { MarketplacePlugin } from '../types'
const inchoMeta = `export const meta = {
apiVersion: 1, key: "incho", name: "Incho", version: "1.0.1",
models: ["incho_music"],
baseUrl: "https://open.yinchaoyongxian.com",
protocols: [{name: "openai_responses", supports: ["stream", "sync", "background"]}],
routes: [
{method: "POST", path: "/incho/submit/:action", type: "submit", decode: "decodeSubmit"},
{method: "GET", path: "/incho/fetch/:task_id", type: "query"},
],
};`
const plugin: MarketplacePlugin = {
key: 'incho',
name: 'Incho',
latest: '1.0.1',
versions: [
{ version: '1.0.1', path: 'new.js' },
{ version: '1.0.0', path: 'old.js', baseUrl: 'https://old.example.com' },
],
models: ['latest-model'],
protocols: ['openai_video'],
channelTypes: [61],
}
test('reads the selected Incho models, protocols and both native routes without requiring runtime hooks', () => {
const preview = parsePluginMetaPreview(inchoMeta)
expect(preview.status).toBe('parsed')
expect(preview.fields.models).toEqual({
state: 'value',
origin: 'source',
value: ['incho_music'],
})
expect(preview.fields.protocols).toEqual({
state: 'value',
origin: 'source',
value: [
{ name: 'openai_responses', supports: ['stream', 'sync', 'background'] },
],
})
expect(preview.fields.routes).toEqual({
state: 'value',
origin: 'source',
value: [
{ method: 'POST', path: '/incho/submit/:action', type: 'submit' },
{ method: 'GET', path: '/incho/fetch/:task_id', type: 'query' },
],
})
expect(preview.fields.allowedHosts).toEqual({
state: 'missing',
origin: 'source',
})
expect(preview.fields.auth).toEqual({ state: 'missing', origin: 'source' })
})
test('supports comments, trailing commas, quoted keys and JavaScript string escapes', () => {
const preview =
parsePluginMetaPreview(String.raw`export const /* declaration */ meta /* name */ = /* value */ {
"models": [/* model */ '\x69ncho_\u006dusic', 'line\nquote\'slash\\',],
baseUrl: "https://example.com/\u{1F3B5}",
auth: {type: 'api_key'}, channelTypes: [+61, 0x18],
};`)
expect(preview.status).toBe('parsed')
expect(preview.fields.models).toEqual({
state: 'value',
origin: 'source',
value: ['incho_music', "line\nquote'slash\\"],
})
expect(preview.fields.baseUrl).toEqual({
state: 'value',
origin: 'source',
value: 'https://example.com/🎵',
})
expect(preview.fields.auth).toEqual({
state: 'value',
origin: 'source',
value: 'api_key',
})
expect(preview.fields.channelTypes).toEqual({
state: 'value',
origin: 'source',
value: [61, 24],
})
})
describe('unreadable declarations never produce partial lists or execute code', () => {
test.each([
'export const meta = { ...external, models: ["m"] };',
'export const meta = { models: ["m"], [key]: [] };',
'export const meta = makeMeta();',
'export const meta = { models: ["m"] }; meta.models.push("changed");',
'export const meta = { models: ["m"] }; mutate(meta);',
'export const meta = { models: ["m"] ',
'const meta = { models: ["m"] }; export {meta};',
])('keeps every field unknown for %s', (source) => {
const preview = parsePluginMetaPreview(source)
expect(preview.status).toBe('unavailable')
expect(
Object.values(preview.fields).every((field) => field.state === 'unknown')
).toBe(true)
})
test.each([
'routes: [{method: "GET", path: "/a", type: "query"}, other]',
'routes: [{method: "GET", path: "/a", type: "query", ...overrides}]',
'routes: [{method: "GET", path: "/a", type: "unsupported"}]',
'routes: [], routes: []',
'get routes() { throw new Error("must not execute") }',
'routes: [,]',
])('marks only the affected field unknown for %s', (declaration) => {
const preview = parsePluginMetaPreview(
`export const meta = {models: ['m'], ${declaration}};`
)
expect(preview.status).toBe('partial')
expect(preview.fields.routes).toEqual({ state: 'unknown' })
expect(preview.fields.models).toEqual({
state: 'value',
origin: 'source',
value: ['m'],
})
})
test('does not invoke a metadata expression or top-level plugin statement', () => {
const probe = vi.fn()
vi.stubGlobal('previewProbe', probe)
try {
const preview = parsePluginMetaPreview(
'previewProbe(); export const meta = {models: ["m"], routes: previewProbe()};'
)
expect(preview.status).toBe('partial')
expect(probe).not.toHaveBeenCalled()
} finally {
vi.unstubAllGlobals()
}
})
})
test('keeps source omissions and empty arrays distinct and does not overwrite them with index values', () => {
const source = parsePluginMetaPreview(
'export const meta = {models: [], allowedHosts: []};'
)
const resolved = resolvePluginMetaPreview(plugin, plugin.versions[0], source)
expect(resolved.models).toEqual({
state: 'value',
origin: 'source',
value: [],
})
expect(resolved.allowedHosts).toEqual({
state: 'value',
origin: 'source',
value: [],
})
expect(resolved.protocols).toEqual({ state: 'missing', origin: 'source' })
})
test('only uses plugin-level index hints for the latest version and marks fallback provenance', () => {
const source = parsePluginMetaPreview('export const meta = buildMeta();')
const latest = resolvePluginMetaPreview(plugin, plugin.versions[0], source)
expect(latest.protocols).toEqual({
state: 'value',
origin: 'index',
value: ['openai_video'],
})
const older = resolvePluginMetaPreview(plugin, plugin.versions[1], source)
expect(older.models.state).toBe('unknown')
expect(older.protocols.state).toBe('unknown')
expect(older.channelTypes.state).toBe('unknown')
expect(older.baseUrl).toEqual({
state: 'value',
origin: 'index',
value: 'https://old.example.com',
})
})
test('preserves valid protocol declarations and empty model lists from the marketplace index', () => {
const index = parseMarketplaceIndex({
indexVersion: 1,
name: 'test',
plugins: [plugin, { ...plugin, key: 'empty', models: [], protocols: [] }],
})
expect(
index.plugins.find((entry) => entry.key === 'incho')?.protocols
).toEqual(['openai_video'])
expect(index.plugins.find((entry) => entry.key === 'empty')?.models).toEqual(
[]
)
expect(
index.plugins.find((entry) => entry.key === 'empty')?.protocols
).toEqual([])
})
/*
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 { act, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, expect, test, vi } from 'vitest'
import { api } from '@/lib/api'
import { PluginsTable } from '../components/plugins-table'
import type { TaskPluginListItem } from '../types'
vi.mock('@/lib/lobe-icon', () => ({ getLobeIcon: () => null }))
const clients: QueryClient[] = []
afterEach(() => {
for (const client of clients) client.clear()
clients.length = 0
localStorage.removeItem('task-plugins-view-mode')
})
function renderPlugins(
enabled: boolean,
view: 'card' | 'table' = 'card',
source: TaskPluginListItem['source'] = 'override'
) {
localStorage.setItem('task-plugins-view-mode', view)
const item: TaskPluginListItem = {
meta: {
key: 'example',
name: 'Example',
version: '1.0.0',
apiVersion: 1,
author: { name: 'Example' },
models: [],
fetchMode: 'per_task',
},
source,
enabled,
active: true,
source_hash: '',
remark: '',
runtime_status: enabled ? 'registered' : 'disabled',
channel_count: 0,
in_flight_count: 0,
}
if (source === 'override_over_factory') {
item.factory_meta = { ...item.meta, version: '0.9.0' }
}
const client = new QueryClient({
defaultOptions: {
queries: { retry: false, staleTime: Infinity },
mutations: { retry: false },
},
})
clients.push(client)
client.setQueryData(['task-plugins'], [item])
vi.spyOn(api, 'get').mockResolvedValue({
data: { success: true, data: [{ ...item, enabled: !enabled }] },
})
render(
<QueryClientProvider client={client}>
<PluginsTable onDetails={() => undefined} onUpload={() => undefined} />
</QueryClientProvider>
)
return screen.getAllByRole('switch', { name: 'Enable plugin example' })[0]
}
test.each([
['card', true],
['card', false],
['table', true],
['table', false],
] as const)(
'%s view: changing enabled=%s requires confirmation and cancellation preserves status',
async (view, enabled) => {
const user = userEvent.setup()
const post = vi
.spyOn(api, 'post')
.mockResolvedValue({ data: { success: true, data: null } })
const toggle = renderPlugins(enabled, view)
await user.click(toggle)
expect(post).not.toHaveBeenCalled()
expect(toggle).toHaveAttribute('aria-checked', String(enabled))
const title = enabled ? 'Disable plugin?' : 'Enable plugin?'
const dialog = await screen.findByRole('alertdialog', { name: title })
expect(dialog).toHaveTextContent('Example')
await user.click(within(dialog).getByRole('button', { name: 'Cancel' }))
await waitFor(() =>
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
)
expect(post).not.toHaveBeenCalled()
expect(toggle).toHaveAttribute('aria-checked', String(enabled))
await user.click(
screen.getAllByRole('switch', { name: 'Enable plugin example' })[0]
)
await user.click(
within(await screen.findByRole('alertdialog', { name: title })).getByRole(
'button',
{ name: enabled ? 'Disable' : 'Enable' }
)
)
await waitFor(() =>
expect(post).toHaveBeenCalledExactlyOnceWith(
'/api/plugin/task/example/status',
{ enabled: !enabled },
expect.any(Object)
)
)
await waitFor(() =>
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
)
expect(
screen.getAllByRole('switch', { name: 'Enable plugin example' })[0]
).toHaveAttribute('aria-checked', String(!enabled))
}
)
test('keyboard toggle opens confirmation and Escape cancels without a request', async () => {
const user = userEvent.setup()
const post = vi.spyOn(api, 'post')
const toggle = renderPlugins(true)
act(() => toggle.focus())
await user.keyboard(' ')
await screen.findByRole('alertdialog', { name: 'Disable plugin?' })
await user.keyboard('{Escape}')
await waitFor(() =>
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
)
expect(post).not.toHaveBeenCalled()
expect(toggle).toHaveAttribute('aria-checked', 'true')
})
test('failed status request leaves confirmation available to retry and preserves the switch', async () => {
const user = userEvent.setup()
let finish!: (value: { data: { success: boolean; message: string } }) => void
const post = vi.spyOn(api, 'post').mockImplementationOnce(
() =>
new Promise((resolve) => {
finish = resolve
})
)
const toggle = renderPlugins(true)
await user.click(toggle)
const dialog = await screen.findByRole('alertdialog', {
name: 'Disable plugin?',
})
const confirm = within(dialog).getByRole('button', { name: 'Disable' })
await user.click(confirm)
expect(confirm).toBeDisabled()
expect(within(dialog).getByRole('button', { name: 'Cancel' })).toBeDisabled()
expect(screen.getAllByRole('switch', { hidden: true })[0]).toHaveAttribute(
'aria-checked',
'true'
)
await user.click(confirm)
expect(post).toHaveBeenCalledTimes(1)
await act(async () =>
finish({ data: { success: false, message: 'Status update failed' } })
)
await waitFor(() => expect(confirm).toBeEnabled())
expect(toggle).toHaveAttribute('aria-checked', 'true')
expect(dialog).toBeInTheDocument()
})
test('confirmed disable blocked by usage opens the existing cascade confirmation', async () => {
const user = userEvent.setup()
const post = vi
.spyOn(api, 'post')
.mockResolvedValueOnce({
data: {
success: false,
message: 'Plugin in use',
data: {
channels: [{ id: 7, name: 'Video channel' }],
in_flight_count: 1,
},
},
})
.mockResolvedValueOnce({ data: { success: true, data: null } })
await user.click(renderPlugins(true))
await user.click(
within(
await screen.findByRole('alertdialog', { name: 'Disable plugin?' })
).getByRole('button', { name: 'Disable' })
)
const usage = await screen.findByRole('alertdialog', {
name: 'Plugin is still in use',
})
expect(
screen.queryByRole('alertdialog', { name: 'Disable plugin?' })
).not.toBeInTheDocument()
expect(usage).toHaveTextContent('Video channel')
await user.click(
within(usage).getByRole('button', { name: 'Cascade disable channels' })
)
await waitFor(() =>
expect(post).toHaveBeenLastCalledWith(
'/api/plugin/task/example/status',
{ enabled: false },
expect.objectContaining({ params: { cascade: true } })
)
)
await waitFor(() =>
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
)
})
test.each(['factory', 'override_over_factory'] as const)(
'%s deletion protects the factory plugin while allowing custom version removal',
async (source) => {
const user = userEvent.setup()
renderPlugins(true, 'table', source)
await user.click(screen.getAllByRole('button', { name: 'Open menu' })[0])
const remove = screen.getByRole('menuitem', {
name: 'Delete active custom version',
})
if (source === 'factory') {
expect(remove).toHaveAttribute('aria-disabled', 'true')
} else {
expect(remove).not.toHaveAttribute('aria-disabled', 'true')
await user.click(remove)
expect(
await screen.findByRole('alertdialog', {
name: 'Delete plugin version?',
})
).toBeVisible()
}
}
)
/*
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 { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, expect, test, vi } from 'vitest'
import { MarketplacePluginCard } from '../components/marketplace-plugin-card'
import { PluginWebsiteLink } from '../components/plugin-website-link'
import { PluginsTable } from '../components/plugins-table'
import type { TaskPluginListItem } from '../types'
vi.mock('@/lib/lobe-icon', () => ({ getLobeIcon: () => null }))
const clients: QueryClient[] = []
afterEach(() => {
for (const client of clients) client.clear()
clients.length = 0
localStorage.removeItem('task-plugins-view-mode')
})
const website = 'https://example.com/plugin'
test('marketplace card shows the plugin website separately from installation', () => {
render(
<MarketplacePluginCard
indexUrl='https://example.com/index.json'
plugin={{
key: 'example',
name: 'Example',
website,
latest: '1.0.0',
versions: [{ version: '1.0.0', path: 'plugin.js' }],
}}
installState={{ status: 'not_installed' }}
onInstall={() => undefined}
/>
)
expect(screen.getByRole('link', { name: 'Plugin website' })).toHaveAttribute(
'href',
website
)
expect(screen.getByRole('button', { name: 'Install' })).toBeEnabled()
})
test('installed table shows the same safe website as the plugin card', () => {
localStorage.setItem('task-plugins-view-mode', 'table')
const client = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
})
clients.push(client)
const item: TaskPluginListItem = {
meta: {
key: 'example',
name: 'Example',
website,
sortPriority: 10,
version: '1.0.0',
apiVersion: 1,
author: { name: 'Example' },
models: [],
fetchMode: 'per_task',
},
source: 'factory',
enabled: true,
active: true,
source_hash: '',
remark: '',
runtime_status: 'registered',
channel_count: 0,
in_flight_count: 0,
}
client.setQueryData(['task-plugins'], [item])
render(
<QueryClientProvider client={client}>
<PluginsTable onDetails={() => undefined} onUpload={() => undefined} />
</QueryClientProvider>
)
const links = screen.getAllByRole('link', { name: 'Plugin website' })
for (const link of links) {
expect(link).toHaveAttribute('href', website)
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
}
})
test('website link is reachable and activated using the keyboard', async () => {
const user = userEvent.setup()
render(<PluginWebsiteLink website={website} />)
const link = screen.getByRole('link', { name: 'Plugin website' })
const click = vi.fn((event: Event) => event.preventDefault())
link.addEventListener('click', click)
await user.tab()
expect(link).toHaveFocus()
await user.keyboard('{Enter}')
expect(click).toHaveBeenCalledOnce()
link.removeEventListener('click', click)
})
......@@ -86,10 +86,18 @@ export async function getTaskPluginVersions(key: string) {
return requireSuccess(response.data)
}
export async function uploadTaskPlugin(source: string, remark: string) {
/**
* `icon` is the sidecar icon.svg / icon.png as a data URI. It is stored apart
* from the source, so the JavaScript stays readable in diffs and reviews.
*/
export async function uploadTaskPlugin(
source: string,
remark: string,
icon?: string
) {
const response = await api.post<ApiResponse<TaskPluginDetail>>(
'/api/plugin/task',
{ source, remark },
{ source, remark, icon: icon || undefined },
mutationConfig
)
return requireSuccess(response.data)
......@@ -105,6 +113,7 @@ export async function installMarketplacePlugin(request: {
source: string
sourceSha256?: string
remark: string
icon?: string
}) {
const response = await api.post<ApiResponse<TaskPluginDetail>>(
'/api/plugin/task',
......@@ -113,6 +122,7 @@ export async function installMarketplacePlugin(request: {
sourceSha256: request.sourceSha256,
enabled: true,
remark: request.remark,
icon: request.icon || undefined,
},
mutationConfig
)
......@@ -176,8 +186,8 @@ export async function getTaskPluginEnabledOption() {
)
const options = requireSuccess(response.data)
return (
options.find((option) => option.key === 'TaskPluginEnabled')
?.value === 'true'
options.find((option) => option.key === 'TaskPluginEnabled')?.value ===
'true'
)
}
......
import { javascript } from '@codemirror/lang-javascript'
/*
Copyright (C) 2023-2026 QuantumNous
......@@ -16,11 +17,14 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { javascript } from '@codemirror/lang-javascript'
import { syntaxHighlighting } from '@codemirror/language'
import { EditorState } from '@codemirror/state'
import { EditorView, lineNumbers } from '@codemirror/view'
import { classHighlighter } from '@lezer/highlight'
import { useEffect, useRef } from 'react'
import { cn } from '@/lib/utils'
type JavaScriptViewerProps = {
value: string
className?: string
......@@ -39,12 +43,24 @@ export function JavaScriptViewer(props: JavaScriptViewerProps) {
extensions: [
lineNumbers(),
javascript(),
syntaxHighlighting(classHighlighter),
EditorState.readOnly.of(true),
EditorView.editable.of(false),
EditorView.lineWrapping,
EditorView.theme({
'&': { height: '100%', backgroundColor: 'transparent' },
'.cm-scroller': { overflow: 'auto', fontFamily: 'monospace' },
'.cm-scroller': {
overflow: 'auto',
fontFamily: 'var(--font-mono)',
lineHeight: '1.65',
},
'.cm-content': { padding: '12px 0' },
'.cm-gutters': {
backgroundColor: 'transparent',
color: 'var(--muted-foreground)',
borderColor: 'var(--border)',
},
'.cm-line': { padding: '0 12px' },
}),
],
}),
......@@ -56,5 +72,19 @@ export function JavaScriptViewer(props: JavaScriptViewerProps) {
}
}, [props.value])
return <div ref={containerRef} className={props.className} />
return (
<div
ref={containerRef}
className={cn(
'[&_.tok-keyword]:text-purple-700 dark:[&_.tok-keyword]:text-purple-300',
'[&_.tok-string]:text-green-800 dark:[&_.tok-string]:text-green-300',
'[&_.tok-number]:text-orange-800 dark:[&_.tok-number]:text-orange-300 [&_.tok-bool]:text-orange-800 dark:[&_.tok-bool]:text-orange-300',
'[&_.tok-comment]:text-muted-foreground [&_.tok-comment]:italic',
'[&_.tok-variableName.tok-function]:text-blue-700 dark:[&_.tok-variableName.tok-function]:text-blue-300',
'[&_.tok-definition]:text-blue-700 dark:[&_.tok-definition]:text-blue-300',
'[&_.tok-propertyName]:text-teal-800 dark:[&_.tok-propertyName]:text-teal-300',
props.className
)}
/>
)
}
......@@ -259,6 +259,7 @@ function MarketplaceSourceSection(props: MarketplaceSourceSectionProps) {
<MarketplacePluginCard
key={plugin.key}
plugin={plugin}
indexUrl={props.source.index_url}
installState={installState}
installed={props.installed.find(
(item) => item.meta.key === plugin.key
......
......@@ -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 { useQuery } from '@tanstack/react-query'
import {
ArrowUpCircle,
CheckCircle2,
......@@ -32,14 +33,18 @@ import { resolveLocalizedText } from '@/lib/localized-text'
import {
findMarketplaceVersion,
marketplaceBuiltInVersion,
resolveMarketplaceActionPolicy,
resolvePluginSourceUrl,
type InstallState,
} from '../lib/marketplace'
import { fetchPluginIconDataUri } from '../lib/plugin-icon-file'
import type { MarketplacePlugin, TaskPluginListItem } from '../types'
import { PluginIcon } from './plugin-icon'
import { PluginWebsiteLink } from './plugin-website-link'
type MarketplacePluginCardProps = {
plugin: MarketplacePlugin
/** Index URL the plugin came from; sidecar logos resolve against it. */
indexUrl?: string
installState: InstallState
installed?: TaskPluginListItem
onInstall: () => void
......@@ -52,15 +57,28 @@ export function MarketplacePluginCard(props: MarketplacePluginCardProps) {
const channelTypes = plugin.channelTypes ?? []
const latestEntry = findMarketplaceVersion(plugin, plugin.latest)
const labelClass = 'text-muted-foreground text-[11px] font-medium select-none'
const actionPolicy = resolveMarketplaceActionPolicy(props.installed)
const builtInVersion = marketplaceBuiltInVersion(props.installed)
// Same-origin-as-index rule as the source itself: a logo is only ever
// fetched from the repository the administrator chose to trust.
const iconUrl =
plugin.iconFile && props.indexUrl
? resolvePluginSourceUrl(props.indexUrl, plugin.iconFile.path)
: null
return (
<div className='flex h-full flex-col gap-2.5 rounded-xl border p-3'>
<div className='flex items-start justify-between gap-2'>
<div className='flex min-w-0 flex-1 items-center gap-2.5'>
<span className='mt-0.5 shrink-0'>
<PluginIcon plugin={plugin} size={20} />
{iconUrl ? (
<MarketplacePluginLogo
plugin={plugin}
iconUrl={iconUrl}
sha256={plugin.iconFile?.sha256}
/>
) : (
<PluginIcon plugin={plugin} size={20} />
)}
</span>
<div className='min-w-0'>
<div className='truncate text-sm font-medium'>{plugin.name}</div>
......@@ -72,6 +90,8 @@ export function MarketplacePluginCard(props: MarketplacePluginCardProps) {
<InstallStateBadge state={props.installState} />
</div>
<PluginWebsiteLink website={plugin.website} />
{description ? (
<p className='text-muted-foreground line-clamp-2 text-xs'>
{description}
......@@ -87,8 +107,8 @@ export function MarketplacePluginCard(props: MarketplacePluginCardProps) {
<div className={labelClass}>{t('Channel type')}</div>
<div className='truncate text-xs'>
{channelTypes.length > 0
? getChannelTypeLabel(channelTypes[0])
: '—'}
? t(getChannelTypeLabel(channelTypes[0]))
: t('Task Plugin')}
</div>
</div>
<div className='min-w-0'>
......@@ -117,21 +137,17 @@ export function MarketplacePluginCard(props: MarketplacePluginCardProps) {
)}
<div className='mt-auto border-t pt-2'>
{actionPolicy.kind === 'system_update' ? (
<Badge variant='secondary'>{t('Updates with the system')}</Badge>
) : (
<Button
size='sm'
variant={
props.installState.status === 'up_to_date' ? 'outline' : 'default'
}
className='w-full'
onClick={props.onInstall}
>
<Download />
{getActionLabel(props.installState, t)}
</Button>
)}
<Button
size='sm'
variant={
props.installState.status === 'up_to_date' ? 'outline' : 'default'
}
className='w-full'
onClick={props.onInstall}
>
<Download />
{t('Install')}
</Button>
</div>
</div>
)
......@@ -174,11 +190,35 @@ function InstallStateBadge({ state }: { state: InstallState }) {
)
}
function getActionLabel(
state: InstallState,
t: (key: string, options?: Record<string, unknown>) => string
): string {
if (state.status === 'not_installed') return t('Install')
if (state.status === 'up_to_date') return t('Reinstall latest')
return t('Review and upgrade')
type MarketplacePluginLogoProps = {
plugin: MarketplacePlugin
iconUrl: string
sha256?: string
}
/**
* Loads the sidecar logo through fetch and shows it as a data URI. Linking the
* raw file with `<img src>` fails on hosts that serve SVG as text/plain with
* nosniff (raw.githubusercontent.com does), and fetching also lets the index
* digest be checked before anything is displayed. Until the bytes arrive, or if
* they never do, the manifest icon or text avatar renders instead.
*/
function MarketplacePluginLogo(props: MarketplacePluginLogoProps) {
const iconQuery = useQuery({
queryKey: [
'task-plugin-marketplace-icon',
props.iconUrl,
props.sha256 ?? '',
],
queryFn: () =>
fetchPluginIconDataUri(props.iconUrl, { sha256: props.sha256 }),
staleTime: Number.POSITIVE_INFINITY,
retry: false,
})
return (
<PluginIcon
plugin={{ ...props.plugin, iconSrc: iconQuery.data ?? undefined }}
size={20}
/>
)
}
......@@ -25,6 +25,7 @@ import { resolveLocalizedText } from '@/lib/localized-text'
import type { TaskPluginListItem } from '../types'
import { PluginIcon } from './plugin-icon'
import { PluginWebsiteLink } from './plugin-website-link'
/**
* A card is one grid cell, so the model list has to stay a fixed number of
......@@ -40,7 +41,7 @@ const MAX_VISIBLE_MODELS = 4
* and the actions menu.
*
* The card answers "which plugin is this and is it live" — identity, source,
* runtime state, the versions, the models it binds, and the enable toggle.
* runtime state, the plugin version, the models it binds, and the enable toggle.
* Manifest detail (billing parameters, endpoints, source) belongs to the detail
* sheet: rendering it here made every card a different height and buried the
* plugin's own description under its parameter descriptions.
......@@ -71,7 +72,10 @@ function PluginCardComponent({ row }: { row: Row<TaskPluginListItem> }) {
<div className='flex items-start justify-between gap-2'>
<div className='flex min-w-0 flex-1 items-center gap-2.5'>
<span className='mt-0.5 shrink-0'>
<PluginIcon plugin={row.original.meta} size={20} />
<PluginIcon
plugin={{ ...row.original.meta, hasIcon: row.original.has_icon }}
size={20}
/>
</span>
<div className='min-w-0'>
<div className='truncate text-sm font-medium'>
......@@ -87,9 +91,9 @@ function PluginCardComponent({ row }: { row: Row<TaskPluginListItem> }) {
</div>
</div>
{/* Row 2: source + runtime badges next to the version pills, all wrapping
freely. The versions read as pills rather than labelled stats because
`v1.2.3` and `API v1` already name themselves. */}
<PluginWebsiteLink website={row.original.meta.website} />
{/* Row 2: source, runtime, and plugin version badges wrap freely. */}
<div className='flex flex-wrap items-center gap-1.5'>
{renderCell('source')}
{renderCell('runtime')}
......@@ -100,13 +104,6 @@ function PluginCardComponent({ row }: { row: Row<TaskPluginListItem> }) {
>
{row.original.meta.version ? `v${row.original.meta.version}` : '—'}
</Badge>
<Badge
variant='secondary'
className='font-mono font-normal'
aria-label={`${t('API version')} v${row.original.meta.apiVersion}`}
>
API v{row.original.meta.apiVersion}
</Badge>
</div>
{description ? (
......
/*
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 { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { CopyButton } from '@/components/copy-button'
import { Badge } from '@/components/ui/badge'
import { HOST_PROTOCOL_ENDPOINTS } from '../lib/host-protocols'
import type { TaskPluginMeta, TaskPluginRoute } from '../types'
import { PluginModelList } from './plugin-model-list'
/**
* One HTTP endpoint the gateway serves for this plugin. Methods and paths are
* wire vocabulary and stay raw; `children` carries the trailing annotations
* (supported request forms, native route type) that belong to this endpoint.
*/
function EndpointRow(props: {
method: string
path: string
children?: ReactNode
}) {
const { t } = useTranslation()
return (
<li className='grid grid-cols-[3.5rem_minmax(0,1fr)_auto] items-start gap-2 py-2'>
<Badge
variant='outline'
className='mt-1 justify-center font-mono font-normal'
>
{props.method}
</Badge>
<div className='min-w-0 space-y-1.5 pt-1'>
<span className='block font-mono text-xs break-all'>{props.path}</span>
<div className='flex flex-wrap items-center gap-1.5'>
{props.children}
</div>
</div>
<CopyButton
value={props.path}
className='size-7'
iconClassName='size-3.5'
aria-label={t('Copy endpoint path')}
/>
</li>
)
}
/**
* The endpoints a plugin exposes: first the host protocol endpoints derived
* from each `meta.protocols` claim, then the native routes it declares itself.
*
* The supported request forms of a mode-bearing protocol are rendered on the
* create endpoint rather than next to the protocol name, because `supports`
* gates exactly that call — retrieval of a created resource is always
* available. Mode names are wire vocabulary and are never translated.
*/
export function PluginEndpoints(props: {
title?: ReactNode
protocolsNote?: ReactNode
routesNote?: ReactNode
protocols?: TaskPluginMeta['protocols']
routes?: TaskPluginRoute[]
}) {
const { t } = useTranslation()
const claims = props.protocols ?? []
const routes = props.routes ?? []
return (
<div className='space-y-3'>
<h3 className='text-sm font-semibold'>{props.title ?? t('Endpoints')}</h3>
{claims.length === 0 &&
routes.length === 0 &&
!props.protocolsNote &&
!props.routesNote ? (
<p className='text-muted-foreground text-sm'>{t('Not declared')}</p>
) : null}
{props.protocolsNote}
{claims.length > 0 && (
<p className='text-xs font-medium'>
{t('Standard protocol interfaces')}
</p>
)}
{claims.map((claim) => {
const name = typeof claim === 'string' ? claim : claim.name
const supports = typeof claim === 'string' ? undefined : claim.supports
const models = typeof claim === 'string' ? undefined : claim.models
const endpoints = HOST_PROTOCOL_ENDPOINTS[name] ?? []
const modeLabels = {
stream: t('Streaming'),
sync: t('Synchronous'),
background: t('Background'),
}
const chips = supports?.map((mode) => (
<Badge
key={mode}
variant='secondary'
className='font-mono font-normal'
>
<span>{mode}</span>
<span className='font-sans'>{modeLabels[mode]}</span>
</Badge>
))
// Chips belong on the create row, but a claim naming a protocol absent
// from the frozen table has no rows at all; keep the declared forms
// visible on the group header rather than dropping them silently.
const hasCreateRow = endpoints.some((endpoint) => endpoint.modeBearing)
return (
<div key={name} className='bg-muted/30 space-y-1.5 rounded-md p-3'>
<div className='flex flex-wrap items-center gap-x-2 gap-y-1'>
<span className='text-muted-foreground min-w-0 font-mono text-xs break-all'>
{name}
</span>
{models?.length ? (
<PluginModelList
models={models}
collapsedLabel={t('Model scope')}
/>
) : null}
{hasCreateRow ? null : chips}
</div>
<ul className='divide-y'>
{endpoints.map((endpoint) => (
<EndpointRow
key={`${endpoint.method} ${endpoint.path}`}
method={endpoint.method}
path={endpoint.path}
>
<span className='text-muted-foreground text-xs'>
{endpoint.method === 'POST'
? t('Submit request')
: t('Retrieve result')}
</span>
{endpoint.modeBearing ? chips : null}
</EndpointRow>
))}
</ul>
</div>
)
})}
{props.routesNote}
{routes.length > 0 ? (
<div className='bg-muted/30 space-y-1.5 rounded-md p-3'>
<p className='text-muted-foreground text-xs'>
{t('Plugin-defined interfaces')}
</p>
<ul className='divide-y'>
{routes.map((route) => (
<EndpointRow
key={`${route.method} ${route.path}`}
method={route.method}
path={route.path}
>
<span className='text-muted-foreground font-mono text-[11px]'>
{route.type}
</span>
<span className='text-muted-foreground text-xs'>
{route.type === 'submit' && t('Submit request')}
{route.type === 'query' && t('Retrieve result')}
{route.type === 'dynamic' && t('Dynamic operation')}
</span>
{route.models?.length ? (
<PluginModelList
models={route.models}
collapsedLabel={t('Model scope')}
/>
) : null}
</EndpointRow>
))}
</ul>
</div>
) : null}
</div>
)
}
......@@ -16,10 +16,15 @@ 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 { useEffect, useState } from 'react'
import { api } from '@/lib/api'
import { getLobeIcon } from '@/lib/lobe-icon'
import { cn } from '@/lib/utils'
import {
pluginIconUrl,
resolvePluginIcon,
textAvatarClass,
type PluginIconInput,
......@@ -30,12 +35,88 @@ type PluginIconProps = {
size?: number
}
function GatewayPluginIcon(props: PluginIconProps) {
const query = useQuery({
queryKey: ['task-plugin', props.plugin.key, 'icon'],
queryFn: async ({ signal }) => {
// An img request cannot carry the dashboard Bearer token. Fetch through
// the shared client, then keep SVG in the browser's inert image mode.
const response = await api.get<Blob>(pluginIconUrl(props.plugin.key), {
responseType: 'blob',
signal,
// React Query owns deduplication and cancellation for this key. The
// HTTP client's shared promise can still belong to an aborted mount.
disableDuplicate: true,
skipErrorHandler: true,
skipBusinessError: true,
})
return response.data.type.startsWith('image/') ? response.data : null
},
staleTime: 5 * 60 * 1000,
retry: false,
})
const [image, setImage] = useState<{ blob: Blob; src: string } | null>(null)
useEffect(() => {
if (!query.data) return
const src = URL.createObjectURL(query.data)
setImage({ blob: query.data, src })
return () => URL.revokeObjectURL(src)
}, [query.data])
return (
<PluginIcon
plugin={{
...props.plugin,
hasIcon: false,
iconSrc: image?.blob === query.data ? image?.src : undefined,
}}
size={props.size}
/>
)
}
export function PluginIcon(props: PluginIconProps) {
const size = props.size ?? 20
const descriptor = resolvePluginIcon(props.plugin)
const [failedSrc, setFailedSrc] = useState<string | null>(null)
if (props.plugin.hasIcon && !props.plugin.iconSrc) {
return <GatewayPluginIcon key={props.plugin.key} {...props} />
}
let descriptor = resolvePluginIcon(props.plugin)
if (descriptor.kind === 'image' && descriptor.src === failedSrc) {
// The image did not load (missing file, blocked content type): show what
// the manifest declares instead of a broken-image glyph.
descriptor = resolvePluginIcon({
...props.plugin,
iconSrc: undefined,
hasIcon: false,
})
}
if (descriptor.kind === 'lobe') {
return <>{getLobeIcon(descriptor.name, size)}</>
}
if (descriptor.kind === 'image') {
const src = descriptor.src
// Logos are drawn only through <img>: the browser's image mode runs SVG
// without scripts, external loads, or DOM access, so no sanitizer is
// needed and the bytes must never reach dangerouslySetInnerHTML.
return (
<span
aria-hidden='true'
className='bg-muted/40 inline-flex shrink-0 items-center justify-center overflow-hidden rounded-md'
style={{ width: size, height: size }}
>
<img
src={src}
alt=''
width={size}
height={size}
draggable={false}
className='h-full w-full object-contain'
onError={() => setFailedSrc(src)}
/>
</span>
)
}
return (
<div
aria-hidden='true'
......
/*
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 { AlertTriangle, CheckCircle2, ChevronDown, Info } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { CopyButton } from '@/components/copy-button'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { Spinner } from '@/components/ui/spinner'
type PluginIntegrityCheckProps = {
compact?: boolean
expected?: string
digest?: string | null
isLoading: boolean
sourceAvailable: boolean
}
export function PluginIntegrityCheck(props: PluginIntegrityCheckProps) {
const { t } = useTranslation()
let status: 'pending' | 'verified' | 'failed' | 'unavailable' | 'missing' =
'missing'
let label = t('No integrity hash')
if (props.expected) {
status = 'unavailable'
label = props.sourceAvailable
? t('Integrity verification unavailable in this environment')
: t('File could not be verified')
if (props.isLoading) {
status = 'pending'
label = t('Checking file integrity...')
} else if (props.digest) {
status =
props.digest.toLowerCase() === props.expected.toLowerCase()
? 'verified'
: 'failed'
label =
status === 'verified'
? t('File integrity verified')
: t('Integrity check failed')
}
}
return (
<section
className={props.compact ? 'space-y-1' : 'space-y-3'}
aria-label={t('File verification')}
>
{!props.compact && (
<h3 className='text-sm font-medium'>{t('File verification')}</h3>
)}
<Alert
variant={status === 'failed' ? 'destructive' : 'default'}
className={props.compact ? 'py-2' : undefined}
>
{status === 'pending' && <Spinner />}
{status === 'verified' && (
<CheckCircle2
className='text-green-700 dark:text-green-400'
aria-hidden='true'
/>
)}
{status === 'failed' && <AlertTriangle aria-hidden='true' />}
{(status === 'unavailable' || status === 'missing') && (
<Info aria-hidden='true' />
)}
<AlertTitle>{label}</AlertTitle>
<AlertDescription className='text-xs leading-relaxed'>
{status === 'failed' &&
t(
'The downloaded source does not match the sha256 declared in the index. Do not install it.'
)}
{status === 'unavailable' &&
t(
'The source is unavailable or this browser cannot calculate SHA-256. The gateway checks the published hash during installation.'
)}
{status === 'missing' &&
t(
'This source does not publish a sha256 for this version, so the downloaded source cannot be pinned to what the source intended.'
)}
{(status === 'verified' || status === 'pending') &&
t(
'A matching hash confirms the published file, not the safety of its code.'
)}
</AlertDescription>
</Alert>
{props.expected && (
<Collapsible>
<CollapsibleTrigger
render={
<Button
variant='ghost'
size='sm'
className='group h-auto gap-1 px-1 py-1 text-xs'
/>
}
>
{t('Integrity hash')}
<ChevronDown
className='size-3 transition-transform group-aria-expanded:rotate-180'
aria-hidden='true'
/>
</CollapsibleTrigger>
<CollapsibleContent>
<div className='bg-muted/20 mt-2 flex min-w-0 items-start gap-2 rounded-lg border p-3'>
<code className='min-w-0 flex-1 text-xs leading-relaxed break-all'>
{props.expected}
</code>
<CopyButton
value={props.expected}
className='size-6'
iconClassName='size-3.5'
/>
</div>
</CollapsibleContent>
</Collapsible>
)}
</section>
)
}
/*
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 { ChevronDown } from 'lucide-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 {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
type PluginModelListProps = {
models: string[]
collapsedLabel?: string
maxVisible?: number
}
/** Model lists remain available to touch and keyboard users, even on narrow screens. */
export function PluginModelList(props: PluginModelListProps) {
const { t } = useTranslation()
if (props.collapsedLabel) {
return (
<Popover>
<PopoverTrigger render={<Button variant='outline' size='xs' />}>
{props.collapsedLabel}
</PopoverTrigger>
<PopoverContent
aria-label={props.collapsedLabel}
className='max-h-64 max-w-[calc(100vw-2rem)] overflow-y-auto'
>
<p className='mb-2 text-sm font-medium'>{props.collapsedLabel}</p>
<ul className='space-y-1 font-mono text-xs'>
{props.models.map((model) => (
<li key={model} className='break-all'>
{model}
</li>
))}
</ul>
</PopoverContent>
</Popover>
)
}
const visible = props.models.slice(0, props.maxVisible ?? 6)
const hidden = props.models.slice(props.maxVisible ?? 6)
return (
<div className='min-w-0 space-y-2'>
{visible.length > 0 && (
<div className='flex flex-wrap gap-1.5'>
{visible.map((model) => (
<Badge
key={model}
variant='secondary'
className='h-auto max-w-full rounded-md font-mono font-normal break-all whitespace-normal'
>
{model}
</Badge>
))}
</div>
)}
{hidden.length > 0 && (
<Collapsible>
<CollapsibleTrigger
render={
<Button
variant='ghost'
size='sm'
className='h-auto max-w-full gap-1 px-1 py-1 text-xs whitespace-normal'
/>
}
>
{props.collapsedLabel ??
t('More models ({{count}})', { count: hidden.length })}
<ChevronDown className='size-3 shrink-0' aria-hidden='true' />
</CollapsibleTrigger>
<CollapsibleContent>
<div className='flex flex-wrap gap-1.5 pt-2'>
{hidden.map((model) => (
<Badge
key={model}
variant='secondary'
className='h-auto max-w-full rounded-md font-mono font-normal break-all whitespace-normal'
>
{model}
</Badge>
))}
</div>
</CollapsibleContent>
</Collapsible>
)}
</div>
)
}
/*
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 { LinkSquare01Icon } from '@hugeicons/core-free-icons'
import { HugeiconsIcon } from '@hugeicons/react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { getPluginWebsite } from '../lib/plugin-website'
export function PluginWebsiteLink(props: { website?: string }) {
const { t } = useTranslation()
const website = getPluginWebsite(props.website)
if (!website) return null
return (
<Button
role='link'
variant='link'
size='xs'
className='h-auto max-w-full justify-start px-0 text-xs whitespace-normal'
render={<a href={website} target='_blank' rel='noopener noreferrer' />}
>
<HugeiconsIcon icon={LinkSquare01Icon} aria-hidden='true' />
{t('Plugin website')}
</Button>
)
}
......@@ -47,6 +47,7 @@ import { isStaleFactoryOverride } from '../lib/marketplace'
import type { TaskPluginListItem, TaskPluginUsage } from '../types'
import { PluginCard } from './plugin-card'
import { PluginIcon } from './plugin-icon'
import { PluginWebsiteLink } from './plugin-website-link'
const VIEW_MODE_STORAGE_KEY = 'task-plugins-view-mode'
......@@ -68,6 +69,10 @@ export function PluginsTable(props: PluginsTableProps) {
const [statusTarget, setStatusTarget] = useState<TaskPluginListItem | null>(
null
)
const [statusConfirmation, setStatusConfirmation] = useState<{
plugin: TaskPluginListItem
enabled: boolean
} | null>(null)
const pluginsQuery = useQuery({
queryKey: ['task-plugins'],
queryFn: listTaskPlugins,
......@@ -84,11 +89,14 @@ export function PluginsTable(props: PluginsTableProps) {
}) => setTaskPluginStatus(key, enabled, options),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['task-plugins'] })
setStatusConfirmation(null)
setStatusTarget(null)
setBlockedAction(null)
setBlockedUsage(null)
},
onError: (error) => {
if (error instanceof TaskPluginUsageError) {
setStatusConfirmation(null)
setBlockedUsage(error.usage)
setBlockedAction('disable')
return
......@@ -130,7 +138,13 @@ export function PluginsTable(props: PluginsTableProps) {
title={description || undefined}
>
<span className='shrink-0'>
<PluginIcon plugin={row.original.meta} size={18} />
<PluginIcon
plugin={{
...row.original.meta,
hasIcon: row.original.has_icon,
}}
size={18}
/>
</span>
<div className='min-w-0'>
<div className='truncate text-sm font-medium'>
......@@ -139,6 +153,7 @@ export function PluginsTable(props: PluginsTableProps) {
<div className='text-muted-foreground truncate font-mono text-xs'>
{row.original.meta.key}
</div>
<PluginWebsiteLink website={row.original.meta.website} />
</div>
</div>
)
......@@ -190,11 +205,11 @@ export function PluginsTable(props: PluginsTableProps) {
cell: ({ row }) => {
const channelTypes = row.original.meta.channelTypes ?? []
if (channelTypes.length === 0) {
return <span className='text-muted-foreground text-xs'></span>
return <span className='text-xs'>{t('Task Plugin')}</span>
}
return (
<span className='text-xs'>
{getChannelTypeLabel(channelTypes[0])}
{t(getChannelTypeLabel(channelTypes[0]))}
<span className='text-muted-foreground ml-1'>
{channelTypes.map((type) => `#${type}`).join(' ')}
</span>
......@@ -203,15 +218,6 @@ export function PluginsTable(props: PluginsTableProps) {
},
},
{
accessorKey: 'meta.apiVersion',
header: t('API version'),
cell: ({ row }) => (
<span className='font-mono text-xs'>
v{row.original.meta.apiVersion}
</span>
),
},
{
id: 'models',
header: t('Models'),
cell: ({ row }) => row.original.meta.models?.length ?? 0,
......@@ -227,9 +233,8 @@ export function PluginsTable(props: PluginsTableProps) {
checked={row.original.enabled}
disabled={statusMutation.isPending}
onCheckedChange={(checked) => {
setStatusTarget(row.original)
statusMutation.mutate({
key: row.original.meta.key,
setStatusConfirmation({
plugin: row.original,
enabled: checked,
})
}}
......@@ -364,6 +369,42 @@ export function PluginsTable(props: PluginsTableProps) {
toolbarProps={{ searchPlaceholder: t('Filter plugins...') }}
/>
<ConfirmDialog
open={Boolean(statusConfirmation)}
onOpenChange={(open) => {
if (!open && !statusMutation.isPending) setStatusConfirmation(null)
}}
title={
statusConfirmation?.enabled
? t('Enable plugin?')
: t('Disable plugin?')
}
desc={
statusConfirmation?.enabled
? t('Are you sure you want to enable plugin {{name}} ({{key}})?', {
name: statusConfirmation.plugin.meta.name,
key: statusConfirmation.plugin.meta.key,
})
: t(
'Disable plugin {{name}} ({{key}})? Requests using this plugin may be affected.',
{
name: statusConfirmation?.plugin.meta.name,
key: statusConfirmation?.plugin.meta.key,
}
)
}
destructive={!statusConfirmation?.enabled}
isLoading={statusMutation.isPending}
confirmText={statusConfirmation?.enabled ? t('Enable') : t('Disable')}
handleConfirm={() => {
if (!statusConfirmation || statusMutation.isPending) return
setStatusTarget(statusConfirmation.plugin)
statusMutation.mutate({
key: statusConfirmation.plugin.meta.key,
enabled: statusConfirmation.enabled,
})
}}
/>
<ConfirmDialog
open={Boolean(deleteTarget)}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null)
......
......@@ -18,7 +18,9 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { useTranslation } from 'react-i18next'
type SourceDiffProps = { before: string; after: string }
import { cn } from '@/lib/utils'
type SourceDiffProps = { before: string; after: string; className?: string }
type DiffLine = { id: string; kind: 'same' | 'added' | 'removed'; text: string }
......@@ -60,10 +62,23 @@ function diffLines(before: string, after: string): DiffLine[] {
export function SourceDiff(props: SourceDiffProps) {
const { t } = useTranslation()
if (props.before === props.after) {
return (
<p
role='status'
className='text-muted-foreground rounded-md border px-3 py-6 text-center text-sm'
>
{t('No source changes')}
</p>
)
}
const lines = diffLines(props.before, props.after)
return (
<div
className='max-h-96 overflow-auto rounded-md border font-mono text-xs'
className={cn(
'max-h-96 overflow-auto rounded-md border font-mono text-xs',
props.className
)}
aria-label={t('Source diff')}
>
{lines.map((line) => {
......
......@@ -26,16 +26,27 @@ import { CodeBlockEditor } from '@/components/ai-elements/code-block'
import { Dialog } from '@/components/dialog'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Field, FieldGroup, FieldLabel } from '@/components/ui/field'
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
} from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Spinner } from '@/components/ui/spinner'
import { uploadTaskPlugin } from '../api'
import {
encodePluginIconFile,
PluginIconFileError,
} from '../lib/plugin-icon-file'
import {
MAX_PLUGIN_SOURCE_BYTES,
pluginSourceByteLength,
} from '../lib/plugin-url'
import type { TaskPluginDetail } from '../types'
import { PluginIcon } from './plugin-icon'
import { PluginSourcePicker } from './plugin-source-picker'
import { PluginUrlImportField } from './plugin-url-import-field'
......@@ -51,11 +62,14 @@ export function UploadDialog(props: UploadDialogProps) {
const [source, setSource] = useState('')
const [fileName, setFileName] = useState('')
const [remark, setRemark] = useState('')
const [icon, setIcon] = useState('')
const [iconFileName, setIconFileName] = useState('')
const [iconError, setIconError] = useState('')
const [result, setResult] = useState<TaskPluginDetail | null>(null)
const [importUrl, setImportUrl] = useState('')
const [importError, setImportError] = useState('')
const mutation = useMutation({
mutationFn: () => uploadTaskPlugin(source, remark),
mutationFn: () => uploadTaskPlugin(source, remark, icon),
onSuccess: (data) => {
setResult(data)
queryClient.invalidateQueries({ queryKey: ['task-plugins'] })
......@@ -82,12 +96,36 @@ export function UploadDialog(props: UploadDialogProps) {
setResult(null)
}
const handleIconFile = async (file: File | undefined) => {
if (!file) return
try {
setIcon(await encodePluginIconFile(file))
setIconFileName(file.name)
setIconError('')
} catch (error) {
setIcon('')
setIconFileName('')
if (
error instanceof PluginIconFileError &&
error.reason === 'too_large'
) {
setIconError(t('Plugin icon exceeds the 512 KiB limit.'))
} else {
setIconError(t('Plugin icon must be an .svg or .png file.'))
}
}
setResult(null)
}
const close = (open: boolean) => {
props.onOpenChange(open)
if (!open) {
setSource('')
setFileName('')
setRemark('')
setIcon('')
setIconFileName('')
setIconError('')
setResult(null)
setImportUrl('')
setImportError('')
......@@ -178,6 +216,49 @@ export function UploadDialog(props: UploadDialogProps) {
/>
<Field>
<FieldLabel htmlFor='task-plugin-icon'>{t('Plugin icon')}</FieldLabel>
<div className='flex items-center gap-3'>
{icon ? (
<PluginIcon
plugin={{ key: result?.meta.key ?? 'upload', iconSrc: icon }}
size={32}
/>
) : null}
<Input
id='task-plugin-icon'
type='file'
accept='.svg,.png,image/svg+xml,image/png'
aria-invalid={iconError ? true : undefined}
onChange={(event) => {
void handleIconFile(event.target.files?.[0])
event.target.value = ''
}}
/>
{icon ? (
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => {
setIcon('')
setIconFileName('')
setIconError('')
}}
>
{t('Remove')}
</Button>
) : null}
</div>
<FieldDescription>
{iconFileName ||
t(
'Optional icon.svg or icon.png shipped next to plugin.js, up to 512 KiB. Stored separately from the source.'
)}
</FieldDescription>
{iconError ? <FieldError>{iconError}</FieldError> : null}
</Field>
<Field>
<FieldLabel htmlFor='task-plugin-remark'>{t('Remark')}</FieldLabel>
<Input
id='task-plugin-remark'
......@@ -204,8 +285,7 @@ export function UploadDialog(props: UploadDialogProps) {
<CircleCheck className='text-primary' />
<AlertTitle>{t('Parsed plugin metadata')}</AlertTitle>
<AlertDescription className='font-mono'>
{result.meta.key} · {result.meta.name} · v{result.meta.version} ·
API v{result.meta.apiVersion}
{result.meta.key} · {result.meta.name} · v{result.meta.version}
</AlertDescription>
</Alert>
) : null}
......
......@@ -26,6 +26,7 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table'
import { taskEnumLabel } from '@/features/pricing/lib/task-price-display'
import type { BillingUsageSchema } from '@/features/pricing/types'
import { resolveLocalizedText } from '@/lib/localized-text'
......@@ -73,13 +74,24 @@ export function UsageSchemaTable(props: UsageSchemaTableProps) {
<TableBody>
{entries.map(([name, definition]) => (
<TableRow key={name}>
<TableCell className='font-mono'>{name}</TableCell>
<TableCell className='max-w-64 font-mono text-xs break-words whitespace-normal'>
{name}
</TableCell>
<TableCell>{t(getUsageTypeLabelKey(definition.type))}</TableCell>
<TableCell>{formatUsageUnit(definition.unit, t)}</TableCell>
<TableCell className='font-mono'>
{definition.enum?.join(', ') || '—'}
<TableCell className='max-w-64 font-mono text-xs break-words whitespace-normal'>
{definition.enum
?.map((value) => {
const label = taskEnumLabel(
definition,
value,
i18n.language
)
return label === value ? value : `${value} → ${label}`
})
.join(', ') || '—'}
</TableCell>
<TableCell className='min-w-48 whitespace-normal'>
<TableCell className='min-w-48 break-words whitespace-normal'>
{resolveLocalizedText(definition.description, i18n.language) ||
'—'}
</TableCell>
......
......@@ -20,8 +20,11 @@ import type {
MarketplaceIndex,
MarketplaceIndexVersion,
MarketplacePlugin,
MarketplacePluginIcon,
TaskPluginListItem,
} from '../types'
import { pluginProtocolClaimsSchema } from './plugin-meta-preview'
import { getPluginWebsite } from './plugin-website'
export const SUPPORTED_INDEX_VERSION = 1
......@@ -89,7 +92,12 @@ export function parseMarketplaceIndex(payload: unknown): MarketplaceIndex {
return {
indexVersion,
name: typeof raw.name === 'string' ? raw.name : '',
plugins,
plugins: plugins.sort((left, right) => {
const difference = (right.sortPriority ?? 0) - (left.sortPriority ?? 0)
if (difference !== 0) return difference
if (left.key === right.key) return 0
return left.key < right.key ? -1 : 1
}),
}
}
......@@ -124,6 +132,10 @@ function parseMarketplacePlugin(entry: unknown): MarketplacePlugin | null {
: undefined,
kind: kind || undefined,
allowedHosts: stringArray(rawVersion.allowedHosts),
baseUrl:
typeof rawVersion.baseUrl === 'string' && rawVersion.baseUrl.trim()
? rawVersion.baseUrl.trim()
: undefined,
auth: typeof rawVersion.auth === 'string' ? rawVersion.auth : undefined,
})
}
......@@ -135,21 +147,55 @@ function parseMarketplacePlugin(entry: unknown): MarketplacePlugin | null {
? declaredLatest
: versions[0].version
// `icon` is a LobeHub name or the text scheme, exactly what meta.icon admits.
// Inline data URIs and remote URLs are dropped: image logos ship as sidecar
// files declared in `iconFile`, and are only ever loaded from the index's own
// origin, so an index cannot turn the marketplace page into a beacon.
let icon: string | undefined
if (typeof raw.icon === 'string') {
const trimmed = raw.icon.trim()
if (trimmed && trimmed.length <= 128) {
if (
trimmed &&
trimmed.length <= 128 &&
!trimmed.startsWith('data:') &&
!trimmed.includes('://')
) {
icon = trimmed
}
}
let iconFile: MarketplacePluginIcon | undefined
if (raw.iconFile && typeof raw.iconFile === 'object') {
const rawIconFile = raw.iconFile as Record<string, unknown>
const iconPath =
typeof rawIconFile.path === 'string' ? rawIconFile.path.trim() : ''
if (/\.(svg|png)$/i.test(iconPath)) {
iconFile = {
path: iconPath,
sha256:
typeof rawIconFile.sha256 === 'string'
? rawIconFile.sha256.trim()
: undefined,
}
}
}
return {
key,
sortPriority:
typeof raw.sortPriority === 'number' &&
Number.isInteger(raw.sortPriority) &&
raw.sortPriority >= -2147483648 &&
raw.sortPriority <= 2147483647
? raw.sortPriority
: 0,
website: getPluginWebsite(raw.website),
name: typeof raw.name === 'string' && raw.name ? raw.name : key,
icon,
iconFile,
description: parseMarketplaceDescription(raw.description),
channelTypes: numberArray(raw.channelTypes),
models: stringArray(raw.models),
protocols: pluginProtocolClaimsSchema.safeParse(raw.protocols).data,
latest,
versions,
}
......@@ -173,8 +219,10 @@ function parseMarketplaceDescription(
function stringArray(value: unknown): string[] | undefined {
if (!Array.isArray(value)) return undefined
const items = value.filter((item): item is string => typeof item === 'string')
return items.length > 0 ? items : undefined
if (!value.every((item) => typeof item === 'string' && item.length > 0)) {
return undefined
}
return value
}
function numberArray(value: unknown): number[] | undefined {
......@@ -234,25 +282,6 @@ export function deriveInstallState(
}
}
export type MarketplaceActionPolicy =
| { kind: 'install' }
| { kind: 'system_update' }
/**
* Factory-served plugins are compiled into the binary and must only update
* with a system release. Marketplace install would create a permanent override
* that shadows every future built-in update — that action is suppressed.
* Overrides and third-party plugins still install/upgrade normally.
*/
export function resolveMarketplaceActionPolicy(
installed?: TaskPluginListItem
): MarketplaceActionPolicy {
if (installed?.source === 'factory') {
return { kind: 'system_update' }
}
return { kind: 'install' }
}
/**
* Built-in version shown next to the marketplace latest. Factory-served items
* do not carry `factory_meta` (their `meta` *is* the factory meta); overridden
......
/*
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
*/
/** Mirrors the gateway's cap on an uploaded plugin logo. */
export const MAX_PLUGIN_ICON_BYTES = 512 * 1024
export type PluginIconMediaType = 'image/png' | 'image/svg+xml'
/**
* Media type of a sidecar logo file, decided by extension because browsers
* report an empty or vendor-specific type for SVG files on some platforms.
* Returns null for anything that is not icon.svg / icon.png material.
*/
export function pluginIconMediaType(
fileName: string
): PluginIconMediaType | null {
const lower = fileName.toLowerCase()
if (lower.endsWith('.svg')) return 'image/svg+xml'
if (lower.endsWith('.png')) return 'image/png'
return null
}
export type PluginIconFileFailure = 'unsupported_type' | 'too_large'
export class PluginIconFileError extends Error {
constructor(public reason: PluginIconFileFailure) {
super(reason)
}
}
function bytesToBase64(bytes: Uint8Array): string {
let binary = ''
const chunk = 0x8000
for (let offset = 0; offset < bytes.length; offset += chunk) {
binary += String.fromCharCode(...bytes.subarray(offset, offset + chunk))
}
return btoa(binary)
}
/**
* Encodes a sidecar logo as the data URI the upload endpoint stores. The bytes
* never enter the plugin source; they travel in their own request field.
*/
export async function encodePluginIconFile(file: File): Promise<string> {
const mediaType = pluginIconMediaType(file.name)
if (!mediaType) throw new PluginIconFileError('unsupported_type')
if (file.size > MAX_PLUGIN_ICON_BYTES) {
throw new PluginIconFileError('too_large')
}
const bytes = new Uint8Array(await file.arrayBuffer())
return `data:${mediaType};base64,${bytesToBase64(bytes)}`
}
async function sha256Hex(
bytes: Uint8Array<ArrayBuffer>
): Promise<string | null> {
if (!globalThis.crypto?.subtle) return null
const digest = await globalThis.crypto.subtle.digest('SHA-256', bytes)
return [...new Uint8Array(digest)]
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('')
}
export type FetchPluginIconOptions = {
/** Index-declared digest; a mismatch yields null so a swapped file is never shown or stored. */
sha256?: string
fetchImpl?: typeof fetch
}
/**
* Fetches a marketplace plugin's sidecar logo from the index repository and
* returns it as a data URI, both for display and for the upload request. The
* bytes are fetched rather than linked with `<img src>` because raw hosting
* such as raw.githubusercontent.com serves SVG as text/plain with nosniff,
* which browsers refuse to render as an image. Returns null when the fetch
* fails, the file is not an SVG/PNG within the cap, or the digest does not
* match: a logo must never block installation, so errors are swallowed.
*/
export async function fetchPluginIconDataUri(
url: string,
options: FetchPluginIconOptions = {}
): Promise<string | null> {
const mediaType = pluginIconMediaType(new URL(url).pathname)
if (!mediaType) return null
const fetchImpl = options.fetchImpl ?? globalThis.fetch
let response: Response
try {
response = await fetchImpl(url)
} catch {
return null
}
if (!response.ok) return null
const bytes = new Uint8Array(await response.arrayBuffer())
if (bytes.length === 0 || bytes.length > MAX_PLUGIN_ICON_BYTES) return null
const expected = options.sha256?.trim().toLowerCase()
if (expected) {
const actual = await sha256Hex(bytes)
if (actual !== null && actual !== expected) return null
}
return `data:${mediaType};base64,${bytesToBase64(bytes)}`
}
......@@ -16,29 +16,51 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { getChannelTypeIcon } from '@/features/channels/lib/channel-utils'
export type PluginIconDescriptor =
| { kind: 'lobe'; name: string }
| { kind: 'image'; src: string }
| { kind: 'text'; label: string; colorSeed: string }
/**
* Where the gateway serves a plugin's sidecar logo (icon.svg / icon.png shipped
* next to plugin.js). The bytes never travel inside list JSON; the browser
* loads them like any other image.
*/
export function pluginIconUrl(key: string, version?: string): string {
const base = `/api/plugin/task/${encodeURIComponent(key)}/icon`
return version ? `${base}?version=${encodeURIComponent(version)}` : base
}
export type PluginIconInput = {
icon?: string
channelTypes?: number[] | null
key: string
name?: string
/** True when the gateway holds a sidecar logo for this plugin. */
hasIcon?: boolean
/** Direct image source, used by marketplace cards whose logo lives in the index repository. */
iconSrc?: string
}
/**
* Resolves how a plugin logo should render.
*
* Priority: explicit `icon` (a LobeHub icon name, or the `text` /
* `text:<label>` scheme for a generated text avatar), then the first declared
* channel type's icon, then a text avatar derived from the plugin name — so a
* plugin without any logo still gets a stable, branded-looking mark instead of
* a generic placeholder.
* Priority: a shipped image logo (`iconSrc`, or `hasIcon` served by the
* gateway), then explicit `meta.icon` (a LobeHub icon name, or the `text` /
* `text:<label>` scheme for a generated text avatar), then a text avatar derived
* from the plugin name. Channel compatibility does not imply brand identity.
* Inline data URIs and remote URLs in `meta.icon` are
* not honoured: logos ship as sidecar files, never inside the manifest.
*/
export function resolvePluginIcon(input: PluginIconInput): PluginIconDescriptor {
export function resolvePluginIcon(
input: PluginIconInput
): PluginIconDescriptor {
if (input.iconSrc) {
return { kind: 'image', src: input.iconSrc }
}
if (input.hasIcon) {
return { kind: 'image', src: pluginIconUrl(input.key) }
}
const icon = input.icon?.trim()
if (icon) {
if (icon === 'text' || icon.startsWith('text:')) {
......@@ -49,11 +71,9 @@ export function resolvePluginIcon(input: PluginIconInput): PluginIconDescriptor
colorSeed: input.key,
}
}
return { kind: 'lobe', name: icon }
}
const channelTypes = input.channelTypes
if (channelTypes != null && channelTypes.length > 0) {
return { kind: 'lobe', name: `${getChannelTypeIcon(channelTypes[0])}.Color` }
if (!icon.startsWith('data:') && !icon.includes('://')) {
return { kind: 'lobe', name: icon }
}
}
return { kind: 'text', label: deriveTextLabel(input), colorSeed: input.key }
}
......
/*
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
*/
/** Accept only explicit HTTPS links; never let URL repair malformed input. */
export function getPluginWebsite(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined
const website = value.trim()
if (!/^https:\/\/[^/?#]+/i.test(website) || /[\s\p{Cc}\\]/u.test(website)) {
return undefined
}
const authority = website.slice(8).split(/[/?#]/, 1)[0]
if (
/[@%]/.test(authority) ||
[...authority].some((character) => character.charCodeAt(0) > 127)
) {
return undefined
}
try {
const url = new URL(website)
if (
url.protocol !== 'https:' ||
!url.hostname ||
url.username ||
url.password
) {
return undefined
}
const host = url.hostname.replace(/\.$/, '')
if (
!host.startsWith('[') &&
(host.length > 253 ||
!host
.split('.')
.every((label) =>
/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(label)
))
) {
return undefined
}
return website
} catch {
return undefined
}
}
......@@ -39,6 +39,8 @@ export type TaskPluginRoute = {
}
export type TaskPluginMeta = {
sortPriority?: number
website?: string
apiVersion: number
key: string
name: string
......@@ -49,6 +51,7 @@ export type TaskPluginMeta = {
name: string
url?: string
}
baseUrl?: string
channelTypes?: number[] | null
models: string[] | null
fetchMode: string
......@@ -76,6 +79,7 @@ export type TaskPluginListItem = {
enabled: boolean
active: boolean
source_hash: string
has_icon?: boolean
remark: string
runtime_status:
| 'registered'
......@@ -99,6 +103,7 @@ export type TaskPluginDetail = {
meta: TaskPluginMeta
source: string
layer: 'factory' | 'override'
has_icon?: boolean
}
export type ApiResponse<T> = {
......@@ -119,9 +124,10 @@ export type MarketplaceSource = {
}
/**
* A single installable version from a marketplace index. `allowedHosts`, `auth`
* and `sha256` are optional: older or hand-rolled indexes may omit them, and
* the confirmation dialog degrades to a warning rather than refusing to render.
* A single installable version from a marketplace index. `allowedHosts`,
* `baseUrl`, `auth` and `sha256` are optional: older or hand-rolled indexes may
* omit them, and the confirmation dialog degrades to a warning rather than
* refusing to render.
*/
export type MarketplaceIndexVersion = {
version: string
......@@ -130,13 +136,25 @@ export type MarketplaceIndexVersion = {
minApiVersion?: number
kind?: string
allowedHosts?: string[]
baseUrl?: string
auth?: string
}
export type MarketplacePluginIcon = {
/** Index-relative path of the sidecar icon.svg / icon.png, resolved like `path`. */
path: string
sha256?: string
}
export type MarketplacePlugin = {
protocols?: TaskPluginProtocolClaim[]
sortPriority?: number
website?: string
key: string
name: string
icon?: string
/** Sidecar logo published by the index; rendered from the source repository. */
iconFile?: MarketplacePluginIcon
description?: string | Record<string, string>
channelTypes?: number[]
models?: string[]
......@@ -149,3 +167,28 @@ export type MarketplaceIndex = {
name: string
plugins: MarketplacePlugin[]
}
/** A missing source property is distinct from an empty value or an unreadable expression. */
export type PluginPreviewField<T> =
| { state: 'value'; value: T; origin: 'source' | 'index' }
| { state: 'missing'; origin: 'source' }
| { state: 'unknown' }
export type PluginPreviewValues = {
models: string[]
protocols: TaskPluginProtocolClaim[]
routes: TaskPluginRoute[]
channelTypes: number[]
baseUrl: string
allowedHosts: string[]
auth: string
}
export type PluginMetaPreview = {
status: 'parsed' | 'partial' | 'unavailable'
fields: {
[Key in keyof PluginPreviewValues]: PluginPreviewField<
PluginPreviewValues[Key]
>
}
}
......@@ -52,6 +52,23 @@ Object.defineProperty(window, 'matchMedia', {
}),
})
// jsdom does not implement Range geometry. CodeMirror measures text through
// these browser APIs; actual wrapping and scrolling are checked in browser QA.
if (!Range.prototype.getClientRects) {
Object.defineProperty(Range.prototype, 'getClientRects', {
configurable: true,
writable: true,
value: () => [],
})
}
if (!Range.prototype.getBoundingClientRect) {
Object.defineProperty(Range.prototype, 'getBoundingClientRect', {
configurable: true,
writable: true,
value: () => new DOMRect(),
})
}
window.requestAnimationFrame = (callback: FrameRequestCallback) =>
window.setTimeout(() => callback(performance.now()), 0)
window.cancelAnimationFrame = (handle: number) => window.clearTimeout(handle)
......
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