feat(web): install event processor plugins from selector

This commit is contained in:
Hyu
2026-10-08 21:21:15 +08:00
parent d4458d8883
commit ec3a396f1d
5 changed files with 389 additions and 140 deletions
@@ -152,6 +152,23 @@ export default function PluginProcessorDetailContent({
[t, available, hasParameters, canOperate],
);
const applyInstalledProcessor = useCallback((component: RunnerDescriptor) => {
setComponents((current) => [
...current.filter((item) => item.id !== component.id),
component,
]);
setComponentRef(component.id);
setParameters(
Object.fromEntries(
(component.config_schema ?? [])
.filter((field) => field.default !== undefined)
.map((field) => [field.name, field.default]),
),
);
setActiveTab('config');
validate.current = null;
}, []);
const load = useCallback(async () => {
setLoading(true);
setFailed(false);
@@ -469,6 +486,8 @@ export default function PluginProcessorDetailContent({
}
titleControls={
<PluginProcessorSettings
installScope={`event-processor:${id}`}
onInstalled={applyInstalledProcessor}
components={components}
value={componentRef}
disabled={!canManage || saving || loading}
@@ -1,139 +1,47 @@
import { Puzzle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import type { RunnerDescriptor } from '@/app/infra/entities/api';
import { useInstalledPluginIcon } from '@/app/infra/hooks/useInstalledPluginIcon';
import { extractI18nObject } from '@/i18n/I18nProvider';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
function ProcessorComponentContent({
component,
option = false,
}: {
component: RunnerDescriptor;
option?: boolean;
}) {
const label = extractI18nObject({
en_US: component.id,
zh_Hans: component.id,
...component.label,
});
const pluginId = `${component.plugin_author}/${component.plugin_name}`;
const iconURL = useInstalledPluginIcon(
component.plugin_author,
component.plugin_name,
);
return (
<span
className={
option
? 'grid w-full min-w-0 grid-cols-[1.75rem_minmax(0,1fr)] items-center gap-x-2 text-left'
: 'flex min-w-0 items-center gap-2'
}
>
{iconURL ? (
<img
src={iconURL}
alt=""
className={
option
? 'row-span-2 size-7 shrink-0 rounded-md object-cover'
: 'size-5 shrink-0 rounded object-cover'
}
/>
) : (
<Puzzle
className={
option
? 'row-span-2 size-5 shrink-0 justify-self-center text-muted-foreground'
: 'size-4 shrink-0 text-muted-foreground'
}
/>
)}
<span className={option ? 'truncate font-medium leading-5' : 'truncate'}>
{label}
</span>
{option && (
<span
className="truncate text-xs leading-4 text-muted-foreground"
title={pluginId}
>
{pluginId}
</span>
)}
</span>
);
}
import RunnerSelect from './RunnerSelect';
export default function PluginProcessorSettings({
components,
value,
onChange,
disabled = false,
installScope,
onInstalled,
}: {
components: RunnerDescriptor[];
value: string;
onChange: (value: string) => void;
disabled?: boolean;
installScope: string;
onInstalled: (component: RunnerDescriptor) => void;
}) {
const { t } = useTranslation();
const selected = components.find((item) => item.id === value);
const options = components.map((component) => ({
name: component.id,
label: { en_US: component.id, zh_Hans: component.id, ...component.label },
}));
if (value && !components.some((component) => component.id === value))
options.push({
name: value,
label: {
en_US: t('agents.eventProcessor.unavailable'),
zh_Hans: t('agents.eventProcessor.unavailable'),
},
});
return (
<div className="flex min-w-0 items-center gap-2">
<Label className="sr-only" htmlFor="event-processor-component">
{t('agents.eventProcessor.component')}
</Label>
<Select value={value} disabled={disabled} onValueChange={onChange}>
<SelectTrigger
id="event-processor-component"
className="w-[13.2rem] max-w-[calc(100vw-8rem)] bg-[#ffffff] dark:bg-[#2a2a2e]"
>
{selected ? (
<ProcessorComponentContent component={selected} />
) : (
<span className="flex min-w-0 items-center gap-2">
<Puzzle className="size-4 shrink-0 text-muted-foreground" />
<SelectValue
placeholder={t('agents.eventProcessor.selectComponent')}
/>
</span>
)}
</SelectTrigger>
<SelectContent className="max-h-72 w-[var(--radix-select-trigger-width)] max-w-[calc(100vw-2rem)]">
{value && !selected && (
<SelectItem value={value}>
{t('agents.eventProcessor.unavailable')}
</SelectItem>
)}
{components.map((component) => (
<SelectItem
key={component.id}
value={component.id}
className="py-1.5 [&>span:last-child]:min-w-0 [&>span:last-child]:flex-1"
>
<ProcessorComponentContent component={component} option />
</SelectItem>
))}
{components.length === 0 && (
<div className="p-2 text-sm text-muted-foreground">
{t('agents.eventProcessor.noComponents')}
<Button asChild variant="link" className="h-auto px-0">
<Link to="/home/add-extension?type=plugin&component=Runner&runner_usage=event">
{t('agents.eventProcessor.installPlugin')}
</Link>
</Button>
</div>
)}
</SelectContent>
</Select>
<div className="w-[20rem] max-w-[calc(100vw-8rem)]">
<RunnerSelect
usage="event"
options={options}
value={value}
label={t('agents.eventProcessor.component')}
disabled={disabled}
installScope={installScope}
onValueChange={onChange}
onEventProcessorInstalled={onInstalled}
/>
</div>
);
}
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Bot, ExternalLink, Loader2, Store } from 'lucide-react';
import { Bot, Puzzle, ExternalLink, Loader2, Store } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
@@ -7,10 +7,13 @@ import { getCloudServiceClientSync } from '@/app/infra/http';
import { useInstalledPluginIcon } from '@/app/infra/hooks/useInstalledPluginIcon';
import type { IDynamicFormItemOption } from '@/app/infra/entities/form/dynamic';
import type { PluginV4 } from '@/app/infra/entities/plugin';
import type { RunnerDescriptor } from '@/app/infra/entities/api';
import {
RunnerMarketplaceError,
getErrorMessage,
installMarketplaceRunner,
installMarketplaceEventProcessor,
resumePendingEventProcessorInstall,
loadRunnerCatalog,
marketplacePluginId,
runnerPluginPrefix,
@@ -63,9 +66,12 @@ function pluginOptionParts(option: IDynamicFormItemOption): {
function InstalledRunnerContent({
option,
eventProcessor = false,
}: {
option: IDynamicFormItemOption;
eventProcessor?: boolean;
}) {
const Icon = eventProcessor ? Puzzle : Bot;
const parts = pluginOptionParts(option);
const iconURL = useInstalledPluginIcon(parts?.author, parts?.name);
@@ -78,7 +84,7 @@ function InstalledRunnerContent({
className="size-5 shrink-0 rounded object-cover"
/>
) : (
<Bot className="size-4 shrink-0 text-muted-foreground" />
<Icon className="size-4 shrink-0 text-muted-foreground" />
)}
<span className="truncate">{extractI18nObject(option.label)}</span>
</span>
@@ -88,10 +94,13 @@ function InstalledRunnerContent({
function InstalledRunnerOptionContent({
option,
description,
eventProcessor = false,
}: {
option: IDynamicFormItemOption;
description: string;
eventProcessor?: boolean;
}) {
const Icon = eventProcessor ? Puzzle : Bot;
const parts = pluginOptionParts(option);
const iconURL = useInstalledPluginIcon(parts?.author, parts?.name);
@@ -104,7 +113,7 @@ function InstalledRunnerOptionContent({
className="row-span-2 size-7 shrink-0 rounded-md object-cover"
/>
) : (
<Bot className="row-span-2 size-5 justify-self-center text-muted-foreground" />
<Icon className="row-span-2 size-5 justify-self-center text-muted-foreground" />
)}
<span className="truncate font-medium leading-5">
{extractI18nObject(option.label)}
@@ -202,13 +211,19 @@ export default function RunnerSelect({
onValueChange,
installScope,
onInstalled,
usage = 'agent',
disabled = false,
onEventProcessorInstalled,
}: {
options: IDynamicFormItemOption[];
label: string;
value: string;
onValueChange: (value: string) => void;
installScope: string;
onInstalled: (installed: InstalledRunner) => void;
onInstalled?: (installed: InstalledRunner) => void;
usage?: 'agent' | 'event';
disabled?: boolean;
onEventProcessorInstalled?: (component: RunnerDescriptor) => void;
}) {
const { t } = useTranslation();
const { addTask, tasks } = usePluginInstallTasks();
@@ -230,7 +245,7 @@ export default function RunnerSelect({
setCatalogLoading(true);
setCatalogError(false);
try {
const catalog = await loadRunnerCatalog('agent');
const catalog = await loadRunnerCatalog(usage);
setMarketplaceRunners(catalog.marketplaceRunners);
setInstalledPluginIds(catalog.installedPluginIds);
setInstalledPluginDescriptions(catalog.installedPluginDescriptions);
@@ -240,7 +255,37 @@ export default function RunnerSelect({
} finally {
setCatalogLoading(false);
}
}, []);
}, [usage]);
useEffect(() => {
if (
usage !== 'event' ||
disabled ||
!readPendingRunnerInstall(installScope)
)
return;
let cancelled = false;
void resumePendingEventProcessorInstall(installScope)
.then((component) => {
if (!cancelled && component) {
onEventProcessorInstalled?.(component);
void loadCatalog();
}
})
.catch((error) => {
if (!cancelled) setInstallError(installErrorMessage(error, t));
});
return () => {
cancelled = true;
};
}, [
installScope,
usage,
disabled,
onEventProcessorInstalled,
loadCatalog,
t,
]);
useEffect(() => {
void loadCatalog();
@@ -289,22 +334,28 @@ export default function RunnerSelect({
const handleInstall = useCallback(
async (plugin: PluginV4) => {
const pluginId = marketplacePluginId(plugin);
if (pendingInstall || installingPluginId) return;
if (disabled || pendingInstall || installingPluginId) return;
setInstallingPluginId(pluginId);
setInstallError(null);
try {
const installed = await installMarketplaceRunner(plugin, {
const installOptions = {
scope: installScope,
onTaskCreated: (taskId) =>
onTaskCreated: (taskId: number) =>
addTask({
taskId,
pluginName: marketplacePluginId(plugin),
source: 'marketplace',
extensionType: 'plugin',
}),
});
onInstalled(installed);
};
if (usage === 'event') {
onEventProcessorInstalled?.(
await installMarketplaceEventProcessor(plugin, installOptions),
);
} else {
onInstalled?.(await installMarketplaceRunner(plugin, installOptions));
}
await loadCatalog();
toast.success(
t('agents.runnerInstallSuccess', {
@@ -329,12 +380,16 @@ export default function RunnerSelect({
onInstalled,
pendingInstall,
t,
usage,
disabled,
onEventProcessorInstalled,
],
);
return (
<div className="w-full max-w-[22rem] space-y-2">
<Select
disabled={disabled}
value={value}
onValueChange={handleValueChange}
onOpenChange={(open) => {
@@ -346,17 +401,34 @@ export default function RunnerSelect({
className="w-full bg-[#ffffff] dark:bg-[#2a2a2e]"
>
{selectedOption ? (
<InstalledRunnerContent option={selectedOption} />
<InstalledRunnerContent
option={selectedOption}
eventProcessor={usage === 'event'}
/>
) : (
<SelectValue placeholder={t('common.select')} />
<SelectValue
placeholder={t(
usage === 'event'
? 'agents.eventProcessor.selectComponent'
: 'common.select',
)}
/>
)}
</SelectTrigger>
<SelectContent className="z-[70] max-h-72 w-[var(--radix-select-trigger-width)] max-w-[calc(100vw-2rem)]">
<SelectGroup>
<SelectLabel className="px-2 py-1 text-[11px] font-medium">
<span className="inline-flex items-center gap-1.5">
<Bot className="size-3.5" />
{t('agents.installedRunners')}
{usage === 'event' ? (
<Puzzle className="size-3.5" />
) : (
<Bot className="size-3.5" />
)}
{t(
usage === 'event'
? 'plugins.installed'
: 'agents.installedRunners',
)}
</span>
</SelectLabel>
{options.length > 0 ? (
@@ -374,6 +446,7 @@ export default function RunnerSelect({
>
<InstalledRunnerOptionContent
option={option}
eventProcessor={usage === 'event'}
description={description}
/>
</SelectItem>
@@ -381,7 +454,11 @@ export default function RunnerSelect({
})
) : (
<div className="px-2 py-1.5 text-xs text-muted-foreground">
{t('agents.noInstalledRunners')}
{t(
usage === 'event'
? 'agents.eventProcessor.noComponents'
: 'agents.noInstalledRunners',
)}
</div>
)}
</SelectGroup>
@@ -392,10 +469,14 @@ export default function RunnerSelect({
<SelectLabel className="flex items-center justify-between gap-2 px-2 py-1 text-[11px] font-medium">
<span className="inline-flex min-w-0 items-center gap-1.5">
<Store className="size-3.5" />
{t('agents.marketplaceRunners')}
{t(
usage === 'event'
? 'plugins.marketplace'
: 'agents.marketplaceRunners',
)}
</span>
<a
href="https://space.langbot.app/market?type=plugin&component=Runner&runner_usage=agent"
href={`https://space.langbot.app/market?type=plugin&component=Runner&runner_usage=${usage}`}
target="_blank"
rel="noreferrer"
className="inline-flex shrink-0 items-center gap-1 rounded border px-1.5 py-0.5 text-[10px] font-medium text-foreground hover:bg-accent"
+64 -2
View File
@@ -8,7 +8,7 @@ import {
type PluginV4,
type RunnerUsage,
} from '@/app/infra/entities/plugin';
import type { AsyncTask } from '@/app/infra/entities/api';
import type { AsyncTask, RunnerDescriptor } from '@/app/infra/entities/api';
import type { I18nObject } from '@/app/infra/entities/common';
export const RUNNER_COMPONENT_FILTER = 'Runner';
@@ -232,6 +232,14 @@ export async function installMarketplaceRunner(
plugin: PluginV4,
options: InstallRunnerOptions,
): Promise<InstalledRunner> {
const pending = await startRunnerInstall(plugin, options);
return finishRunnerInstall(pending, options.onProgress);
}
async function startRunnerInstall(
plugin: PluginV4,
options: InstallRunnerOptions,
): Promise<PendingRunnerInstall> {
if (!plugin.latest_version) {
throw new RunnerMarketplaceError('version-unavailable');
}
@@ -252,7 +260,7 @@ export async function installMarketplaceRunner(
};
writePendingRunnerInstall(pending);
options.onTaskCreated?.(taskId);
return finishRunnerInstall(pending, options.onProgress);
return pending;
}
function extractPluginLabel(plugin: PluginV4) {
@@ -271,6 +279,14 @@ async function finishRunnerInstall(
pending: PendingRunnerInstall,
onProgress?: (task: AsyncTask) => void,
): Promise<InstalledRunner> {
await waitForRunnerInstall(pending, onProgress);
return waitForRunnerRegistration(pending);
}
async function waitForRunnerInstall(
pending: PendingRunnerInstall,
onProgress?: (task: AsyncTask) => void,
) {
// A refreshed page receives a fresh observation window. The backend task is
// authoritative; `startedAt` is display metadata, not a reason to abandon a
// still-running installation immediately after recovery.
@@ -293,7 +309,11 @@ async function finishRunnerInstall(
if (!installCompleted) {
throw new RunnerMarketplaceError('install-timeout');
}
}
async function waitForRunnerRegistration(
pending: PendingRunnerInstall,
): Promise<InstalledRunner> {
const registrationDeadline = Date.now() + RUNNER_REGISTRATION_TIMEOUT_MS;
const prefix = runnerPluginPrefix({
author: pending.pluginAuthor,
@@ -333,3 +353,45 @@ export async function resumePendingRunnerInstall(
if (!pending) return null;
return finishRunnerInstall(pending, onProgress);
}
export async function installMarketplaceEventProcessor(
plugin: PluginV4,
options: InstallRunnerOptions,
): Promise<RunnerDescriptor> {
const pending = await startRunnerInstall(plugin, options);
return finishEventProcessorInstall(pending, options.onProgress);
}
export async function resumePendingEventProcessorInstall(
scope: string,
): Promise<RunnerDescriptor | null> {
const pending = readPendingRunnerInstall(scope);
return pending ? finishEventProcessorInstall(pending) : null;
}
async function finishEventProcessorInstall(
pending: PendingRunnerInstall,
onProgress?: (task: AsyncTask) => void,
): Promise<RunnerDescriptor> {
await waitForRunnerInstall(pending, onProgress);
const deadline = Date.now() + RUNNER_REGISTRATION_TIMEOUT_MS;
while (Date.now() < deadline) {
const metadata = await httpClient.getAgentMetadata();
// Only the event registry is authoritative; Agent-only Runners are invalid.
const candidates = (metadata.event_processors ?? []).filter(
(component) =>
component.plugin_author === pending.pluginAuthor &&
component.plugin_name === pending.pluginName,
);
const component =
candidates.find((component) => component.id.endsWith('/default')) ??
candidates[0];
if (component) {
clearPendingRunnerInstall(pending.scope, pending.taskId);
return component;
}
await wait(1000);
}
clearPendingRunnerInstall(pending.scope, pending.taskId);
throw new RunnerMarketplaceError('registration-timeout');
}
@@ -0,0 +1,179 @@
import { test, expect } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
for (const scenario of ['install', 'retry', 'reload'] as const) {
test(`event processor marketplace ${scenario}: filters usage and selects registered component`, async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
const ref = 'plugin:qa/events/default';
let installed = false;
let finish = scenario !== 'reload';
let installs = 0;
let registrations = 0;
const requests: Record<string, unknown>[] = [];
const descriptor = {
id: ref,
plugin_author: 'qa',
plugin_name: 'events',
label: { en_US: 'Event handler' },
supported_event_patterns: ['group.member_joined'],
config_schema: [
{
name: 'greeting',
type: 'string',
label: { en_US: 'Greeting' },
default: 'Welcome',
required: true,
},
],
};
let processor = {
uuid: 'event-market',
kind: 'event_processor',
name: 'Event marketplace',
component_ref: '',
config: {},
supported_event_patterns: [],
};
await page.route('**/api/v1/agents**', async (route) => {
const path = new URL(route.request().url()).pathname;
let data: unknown;
if (path.endsWith('/_/metadata')) {
if (installed) registrations++;
data = {
event_processors: installed && registrations > 1 ? [descriptor] : [],
kinds: [],
platform_tools: [],
};
} else if (path.endsWith('/runs')) data = { items: [], has_more: false };
else if (path.endsWith('/event-market')) {
if (route.request().method() === 'PUT')
processor = { ...processor, ...route.request().postDataJSON() };
data = { agent: processor };
} else return route.fallback();
await route.fulfill({ json: { code: 0, data } });
});
await page.route('**/api/v1/marketplace/**', async (route) => {
const path = new URL(route.request().url()).pathname;
if (!path.endsWith('/search')) return route.fallback();
requests.push(route.request().postDataJSON());
const plugin = (
name: string,
usages?: string[],
components = { Runner: 1 },
) => ({
author: 'qa',
name,
label: { en_US: name },
description: { en_US: name + ' description' },
latest_version: '1.0.0',
type: 'plugin',
components,
runner_usages: usages,
install_count: 0,
});
await route.fulfill({
json: {
code: 0,
data: {
total: 5,
[path.includes('/extensions/') ? 'extensions' : 'plugins']: [
plugin('events', ['event']),
plugin('agent-only', ['agent']),
plugin('mixed', ['agent', 'event']),
plugin('unknown'),
plugin('knowledge-only', ['event'], {
KnowledgeEngine: 1,
} as any),
],
},
},
});
});
await page.route('**/api/v1/plugins/install/marketplace', async (route) => {
installs++;
expect(route.request().postDataJSON()).toEqual({
plugin_author: 'qa',
plugin_name: 'events',
plugin_version: '1.0.0',
});
await route.fulfill({ json: { code: 0, data: { task_id: installs } } });
});
await page.route('**/api/v1/system/tasks/*', async (route) => {
const failed = scenario === 'retry' && installs === 1;
installed = finish && !failed;
await route.fulfill({
json: {
code: 0,
data: {
id: installs,
name: 'plugin-install-marketplace',
runtime: {
done: finish,
exception: failed ? 'Download failed' : null,
},
task_context: {
current_action: 'Downloading',
metadata: { progress_percent: 25 },
},
},
},
});
});
await page.goto('/home/agents?id=event-market');
const select = page.getByRole('combobox', {
name: 'Plugin processor',
includeHidden: true,
});
await select.click();
const install = page.getByRole('button', {
name: 'Install events',
exact: true,
});
await expect(install).toBeVisible();
await expect(
page.getByRole('button', { name: 'Install mixed', exact: true }),
).toBeVisible();
for (const excluded of ['agent-only', 'unknown', 'knowledge-only'])
await expect(
page.getByRole('button', { name: `Install ${excluded}`, exact: true }),
).toHaveCount(0);
expect(requests.length).toBeGreaterThan(0);
for (const request of requests)
expect(request).toMatchObject({
component_filter: 'Runner',
runner_usage: 'event',
type_filter: 'plugin',
});
await install.click();
if (scenario === 'retry') {
await expect(
page
.getByRole('alert', { includeHidden: true })
.filter({ hasText: 'Download failed' }),
).toBeVisible();
await install.click();
}
if (scenario === 'reload') {
await expect.poll(() => installs).toBe(1);
await expect
.poll(() =>
page.evaluate(() =>
Object.keys(sessionStorage).some((key) =>
key.includes('event-processor:event-market'),
),
),
)
.toBe(true);
finish = true;
await page.reload();
}
await expect(select).toContainText('Event handler', { timeout: 20000 });
await page.keyboard.press('Escape');
await expect(page.locator('input[name="greeting"]')).toHaveValue('Welcome');
expect(installs).toBe(scenario === 'retry' ? 2 : 1);
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect.poll(() => processor.component_ref).toBe(ref);
});
}