mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-21 16:05:19 +08:00
feat(web): install event processor plugins from selector
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user