fix(desktop): support keyless plugin rows

This commit is contained in:
fangliquanflq 2026-08-10 07:56:19 +08:00 committed by Teknium
parent 56dc01d904
commit 6e19c20d0a
3 changed files with 111 additions and 13 deletions

View File

@ -0,0 +1,88 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { requestGateway } = vi.hoisted(() => ({ requestGateway: vi.fn() }))
vi.mock('@/app/gateway/hooks/use-gateway-request', () => ({
useGatewayRequest: () => ({ requestGateway })
}))
import { $pluginRecords } from '@/contrib/plugins-store'
import {
$agentPluginBusy,
$agentPlugins,
$agentPluginsError,
$agentPluginsStatus,
type AgentPluginRow
} from '@/store/agent-plugins'
import { $connection, $gatewayState } from '@/store/session'
import { PluginsSettings } from './plugins-settings'
const legacyRow = {
name: 'Legacy plugin',
version: '0.20.0',
description: 'Returned by a pre-key backend',
source: 'user',
status: 'disabled'
} satisfies AgentPluginRow
beforeEach(() => {
requestGateway.mockReset()
$pluginRecords.set({})
$agentPlugins.set([legacyRow])
$agentPluginsStatus.set('ready')
$agentPluginsError.set(null)
$agentPluginBusy.set(null)
$gatewayState.set('idle')
$connection.set(null)
})
afterEach(() => {
cleanup()
})
describe('PluginsSettings', () => {
it('renders and searches plugin rows returned without a canonical key', () => {
render(<PluginsSettings />)
expect(screen.getByText('Legacy plugin')).toBeTruthy()
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'pre-key' } })
expect(screen.getByText('Legacy plugin')).toBeTruthy()
})
it('uses the legacy name address when toggling a row without a key', async () => {
requestGateway.mockResolvedValue({ ok: true, plugin: { ...legacyRow, status: 'enabled' } })
render(<PluginsSettings />)
fireEvent.click(screen.getByRole('switch', { name: 'Enable Legacy plugin' }))
await waitFor(() =>
expect(requestGateway).toHaveBeenCalledWith('plugins.manage', {
action: 'toggle',
name: 'Legacy plugin',
enable: true
})
)
})
it('keeps using the canonical key when the backend provides one', async () => {
const keyedRow = { ...legacyRow, key: 'image_gen/legacy' }
$agentPlugins.set([keyedRow])
requestGateway.mockResolvedValue({ ok: true, plugin: { ...keyedRow, status: 'enabled' } })
render(<PluginsSettings />)
fireEvent.click(screen.getByRole('switch', { name: 'Enable Legacy plugin' }))
await waitFor(() =>
expect(requestGateway).toHaveBeenCalledWith('plugins.manage', {
action: 'toggle',
key: 'image_gen/legacy',
enable: true
})
)
})
})

View File

@ -39,7 +39,11 @@ const SOURCE_ORDER: Record<string, number> = { user: 0, git: 0, project: 1, entr
// the user-facing control for any of them, so listing them here is noise.
const HIDDEN_KEY_PREFIXES = ['dashboard_auth/', 'model-providers/', 'platforms/']
const isDesktopRelevant = (row: AgentPluginRow) => !HIDDEN_KEY_PREFIXES.some(prefix => row.key.startsWith(prefix))
const isDesktopRelevant = (row: AgentPluginRow) => {
const key = row.key
return !key || !HIDDEN_KEY_PREFIXES.some(prefix => key.startsWith(prefix))
}
function reveal(file: string) {
void window.hermesDesktop?.revealPath?.(file)?.catch(() => undefined)
@ -134,10 +138,10 @@ function AgentPluginRowView({ row }: { row: AgentPluginRow }) {
<Switch
aria-label={`${row.status === 'enabled' ? p.disable : p.enable} ${row.name}`}
checked={row.status === 'enabled'}
disabled={busy === row.key}
disabled={busy === (row.key ?? row.name)}
onCheckedChange={on => {
triggerHaptic('selection')
void toggleAgentPlugin(requestGateway, row.key, on, p.agent.toggleFailed(row.name))
void toggleAgentPlugin(requestGateway, row, on, p.agent.toggleFailed(row.name))
}}
/>
}
@ -180,7 +184,7 @@ function AgentPluginsSection() {
row =>
!needle ||
row.name.toLowerCase().includes(needle) ||
row.key.toLowerCase().includes(needle) ||
(row.key ?? '').toLowerCase().includes(needle) ||
row.description.toLowerCase().includes(needle)
)
.sort((a, b) => (SOURCE_ORDER[a.source] ?? 9) - (SOURCE_ORDER[b.source] ?? 9) || a.name.localeCompare(b.name))

View File

@ -17,8 +17,8 @@ import { notifyError } from '@/store/notifications'
export interface AgentPluginRow {
name: string
/** Canonical registry key (e.g. `image_gen/fal`) — names can collide. */
key: string
/** Canonical registry key (e.g. `image_gen/fal`) — absent on legacy backends. */
key?: string
version: string
description: string
/** 'bundled' | 'user' | 'git' | 'project' | 'entrypoint' */
@ -36,7 +36,7 @@ export type GatewayRequest = <T>(method: string, params?: Record<string, unknown
export const $agentPlugins = atom<AgentPluginRow[]>([])
export const $agentPluginsStatus = atom<AgentPluginsStatus>('idle')
export const $agentPluginsError = atom<string | null>(null)
/** Key of the row whose toggle RPC is in flight (disables its switch). */
/** Best available address of the row whose toggle RPC is in flight. */
export const $agentPluginBusy = atom<string | null>(null)
let inflight: Promise<void> | null = null
@ -70,20 +70,22 @@ export function loadAgentPlugins(request: GatewayRequest): Promise<void> {
}
/** Flip a backend plugin on/off and patch the row from the RPC's refreshed
* copy. Addressed by canonical key bare names collide (image_gen/fal vs
* video_gen/fal). Returns whether the toggle stuck. */
* copy. Current backends use canonical keys; legacy keyless rows fall back
* to the name-addressed protocol they were returned by. */
export async function toggleAgentPlugin(
request: GatewayRequest,
key: string,
row: Pick<AgentPluginRow, 'key' | 'name'>,
enable: boolean,
failMessage: string
): Promise<boolean> {
$agentPluginBusy.set(key)
const address = row.key ?? row.name
$agentPluginBusy.set(address)
try {
const result = await request<{ ok?: boolean; plugin?: AgentPluginRow | null }>('plugins.manage', {
action: 'toggle',
key,
...(row.key ? { key: row.key } : { name: row.name }),
enable
})
@ -94,7 +96,11 @@ export async function toggleAgentPlugin(
const refreshed = result.plugin
if (refreshed) {
$agentPlugins.set($agentPlugins.get().map(row => (row.key === key ? { ...row, ...refreshed } : row)))
$agentPlugins.set(
$agentPlugins
.get()
.map(current => ((current.key ?? current.name) === address ? { ...current, ...refreshed } : current))
)
} else {
await loadAgentPlugins(request)
}