feat(bots): bind plugin processor configurations independently

This commit is contained in:
RockChinQ
2026-09-15 00:28:13 +08:00
parent e913658e03
commit 184037a427
27 changed files with 1253 additions and 82 deletions
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { Link, useSearchParams } from 'react-router-dom';
import { eventPatternLabel } from '@/app/home/components/event-patterns/event-pattern-groups';
import { RefreshCw, Trash2, ScrollText, Settings2 } from 'lucide-react';
import isEqual from 'lodash/isEqual';
@@ -48,7 +48,10 @@ export default function PluginProcessorDetailContent({
onSaved: () => void;
}) {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState('config');
const [searchParams] = useSearchParams();
const [activeTab, setActiveTab] = useState(
searchParams.get('tab') === 'logs' ? 'logs' : 'config',
);
const [platformTools, setPlatformTools] = useState<AgentPlatformTool[]>([]);
const toolLabels = Object.fromEntries(
platformTools.map((tool) => [tool.name, extractI18nObject(tool.label)]),
@@ -24,6 +24,7 @@ import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs';
import { ExternalLink, ChevronDown, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import EventBindingsEditor from './EventBindingsEditor';
import PluginProcessorBindings from './PluginProcessorBindings';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
@@ -71,6 +72,9 @@ const getFormSchema = (t: (key: string) => string) =>
adapter: z.string().min(1, { message: t('bots.adapterRequired') }),
adapter_config: z.record(z.string(), z.any()),
enable: z.boolean().optional(),
plugin_processors: z
.array(z.object({ processor_uuid: z.string(), enabled: z.boolean() }))
.optional(),
event_bindings: z
.array(
z.object({
@@ -127,6 +131,7 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
adapter_config: {},
enable: true,
event_bindings: [],
plugin_processors: [],
},
});
@@ -237,6 +242,7 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
adapter_config: val.adapter_config,
enable: val.enable,
event_bindings: val.event_bindings || [],
plugin_processors: val.plugin_processors || [],
});
handleAdapterSelect(val.adapter);
@@ -360,6 +366,7 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
adapter_config: bot.adapter_config,
enable: bot.enable ?? true,
event_bindings: bot.event_bindings ?? [],
plugin_processors: bot.plugin_processors ?? [],
webhook_full_url: runtimeValues?.webhook_full_url as
| string
| undefined,
@@ -404,6 +411,7 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
adapter_config: form.getValues().adapter_config,
enable: form.getValues().enable,
event_bindings: form.getValues().event_bindings ?? [],
plugin_processors: form.getValues().plugin_processors ?? [],
};
httpClient
.updateBot(initBotId, updateBot)
@@ -427,6 +435,7 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
adapter_config: form.getValues().adapter_config,
enable: form.getValues().enable,
event_bindings: form.getValues().event_bindings ?? [],
plugin_processors: form.getValues().plugin_processors ?? [],
};
httpClient
.createBot(newBot)
@@ -752,7 +761,21 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
form={form}
botId={initBotId}
supportedEvents={adapterSupportedEvents[currentAdapter] || []}
agentOptions={agentNameList}
agentOptions={agentNameList.filter(
(agent) => agent.kind !== 'event_processor',
)}
/>
<PluginProcessorBindings
value={form.watch('plugin_processors') ?? []}
onChange={(value) =>
form.setValue('plugin_processors', value, {
shouldDirty: true,
})
}
agents={agentNameList}
onCreated={(agent) =>
setAgentNameList((items) => [...items, agent])
}
/>
</CardContent>
</Card>
@@ -519,11 +519,6 @@ function TargetCombobox({
const pipelines = pipelineAllowed
? agentOptions.filter((a) => a.kind === 'pipeline')
: [];
const eventProcessors = agentOptions.filter(
(item) =>
item.kind === 'event_processor' &&
agentSupportsEventPattern(item, binding.event_pattern),
);
function currentLabel() {
if (targetType === 'discard')
@@ -593,26 +588,6 @@ function TargetCombobox({
))}
</CommandGroup>
)}
{eventProcessors.length > 0 && (
<CommandGroup heading={t('agents.eventProcessor.type')}>
{eventProcessors.map((item) => (
<CommandItem
key={item.uuid}
value={`event_processor:${item.uuid}:${item.name}`}
onSelect={() =>
select(encodeTarget('event_processor', item.uuid || ''))
}
>
<FileCode2 className="mr-2 size-3.5 shrink-0" />
<span className="truncate">{targetLabel(item)}</span>
{current ===
encodeTarget('event_processor', item.uuid || '') && (
<Check className="ml-auto size-3.5 shrink-0" />
)}
</CommandItem>
))}
</CommandGroup>
)}
{pipelines.length > 0 && (
<CommandGroup heading={t('bots.targetPipeline')}>
{pipelines.map((a) => (
@@ -0,0 +1,466 @@
import { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { Check, Plus, Settings2, ScrollText, X } from 'lucide-react';
import { toast } from 'sonner';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import type {
Agent,
PluginProcessorBinding,
RunnerDescriptor,
} from '@/app/infra/entities/api';
import { httpClient } from '@/app/infra/http';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { eventPatternLabel } from '@/app/home/components/event-patterns/event-pattern-groups';
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
import { AuthenticatedPluginIcon } from '@/components/AuthenticatedPluginIcon';
import { Checkbox } from '@/components/ui/checkbox';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
export default function PluginProcessorBindings({
value,
onChange,
agents,
onCreated,
}: {
value: PluginProcessorBinding[];
onChange: (value: PluginProcessorBinding[]) => void;
agents: Agent[];
onCreated: (agent: Agent) => void;
}) {
const { t } = useTranslation();
const { refreshPipelines } = useSidebarData();
const [open, setOpen] = useState(false);
const [mode, setMode] = useState('existing');
const [selected, setSelected] = useState<string[]>([]);
const [components, setComponents] = useState<RunnerDescriptor[]>([]);
const [componentRef, setComponentRef] = useState('');
const [name, setName] = useState('');
const [parameters, setParameters] = useState<Record<string, unknown>>({});
const [busy, setBusy] = useState(false);
const submitting = useRef(false);
const validate = useRef<(() => Promise<boolean>) | null>(null);
const component = components.find((item) => item.id === componentRef);
const available = agents.filter(
(agent) =>
agent.kind === 'event_processor' &&
agent.component_ref &&
!value.some((item) => item.processor_uuid === agent.uuid),
);
async function showDialog() {
setSelected([]);
setMode(available.length ? 'existing' : 'new');
setOpen(true);
try {
const metadata = await httpClient.getAgentMetadata();
setComponents(metadata.event_processors ?? []);
} catch {
toast.error(t('agents.eventProcessor.loadError'));
}
}
async function add() {
if (submitting.current) return;
if (mode === 'existing') {
if (!selected.length) return;
onChange([
...value,
...selected.map((processor_uuid) => ({
processor_uuid,
enabled: true,
})),
]);
setOpen(false);
return;
}
if (!component || !name.trim()) return;
submitting.current = true;
setBusy(true);
try {
if (!((await validate.current?.()) ?? true)) return;
const agent: Agent = {
name: name.trim(),
description: '',
emoji: '🧩',
kind: 'event_processor',
component_ref: componentRef,
config: {
runner: { id: componentRef },
runner_config: { [componentRef]: parameters },
},
};
const result = await httpClient.createAgent(agent);
onCreated({
...agent,
uuid: result.uuid,
supported_event_patterns: component.supported_event_patterns,
});
onChange([...value, { processor_uuid: result.uuid, enabled: true }]);
void refreshPipelines();
setOpen(false);
setName('');
setComponentRef('');
setParameters({});
toast.success(t('bots.pluginSubscriptions.created'));
} catch (error) {
toast.error(
t('agents.createError') +
((error as { msg?: string }).msg ??
t('agents.eventProcessor.loadError')),
);
} finally {
submitting.current = false;
setBusy(false);
}
}
return (
<section
className="mt-6 space-y-3 border-t pt-5"
aria-labelledby="plugin-subscriptions-title"
>
<h3
id="plugin-subscriptions-title"
className="text-sm font-semibold text-foreground"
>
{t('agents.eventProcessor.type')}
</h3>
<p className="text-sm text-muted-foreground">
{t('bots.pluginSubscriptions.description')}
</p>
{value.length === 0 && (
<div className="flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-border">
<p className="text-sm text-muted-foreground">
{t('bots.pluginSubscriptions.empty')}
</p>
</div>
)}
{value.map((binding) => {
const agent = agents.find(
(item) => item.uuid === binding.processor_uuid,
);
const title = agent?.name ?? t('agents.eventProcessor.unavailable');
return (
<Card
key={binding.processor_uuid}
className="gap-0 rounded-lg py-0 shadow-none hover:bg-accent"
>
<CardContent className="flex items-center gap-3 p-3">
<span
className="flex size-10 shrink-0 items-center justify-center rounded-lg border bg-muted text-2xl"
aria-hidden="true"
>
{agent?.emoji || '🧩'}
</span>
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{title}</div>
<p
className="truncate text-sm text-muted-foreground"
title={agent?.component_ref?.replace('plugin:', '')}
>
{agent?.component_ref?.replace('plugin:', '')}
</p>
<p
className="truncate text-xs text-muted-foreground"
title={(agent?.supported_event_patterns ?? [])
.map((pattern) => eventPatternLabel(pattern, t))
.join(' · ')}
>
{(agent?.supported_event_patterns ?? [])
.map((pattern) => eventPatternLabel(pattern, t))
.join(' · ') || t('agents.eventProcessor.unavailable')}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
{agent &&
(
[
['config', Settings2, 'configure'],
['logs', ScrollText, 'logs'],
] as const
).map(([tab, Icon, key]) => (
<Tooltip key={tab}>
<TooltipTrigger asChild>
<Button
asChild
variant="ghost"
size="icon"
className="size-8"
>
<Link
target="_blank"
rel="noopener noreferrer"
to={`/home/agents?id=${agent.uuid}&tab=${tab}`}
aria-label={t(`bots.pluginSubscriptions.${key}`)}
>
<Icon className="size-4" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>
{t(`bots.pluginSubscriptions.${key}`)}
</TooltipContent>
</Tooltip>
))}
<Switch
aria-label={t('bots.pluginSubscriptions.enable', {
name: title,
})}
checked={binding.enabled}
onCheckedChange={(enabled) =>
onChange(
value.map((item) =>
item.processor_uuid === binding.processor_uuid
? { ...item, enabled }
: item,
),
)
}
/>
<Button
type="button"
size="icon"
variant="ghost"
aria-label={t('bots.pluginSubscriptions.remove', {
name: title,
})}
onClick={() =>
onChange(
value.filter(
(item) =>
item.processor_uuid !== binding.processor_uuid,
),
)
}
>
<X className="size-4" />
</Button>
</div>
</CardContent>
</Card>
);
})}
<Button
type="button"
variant="outline"
className="w-full"
onClick={showDialog}
>
<Plus className="size-4" />
{t('bots.pluginSubscriptions.add')}
</Button>
<Dialog
open={open}
onOpenChange={(next) => {
if (!busy) setOpen(next);
}}
>
<DialogContent className="flex max-h-[80vh] flex-col overflow-hidden sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{t('bots.pluginSubscriptions.add')}</DialogTitle>
<DialogDescription>
{t('bots.pluginSubscriptions.saveHint')}
</DialogDescription>
</DialogHeader>
<Tabs
value={mode}
onValueChange={setMode}
className="min-h-0 overflow-y-auto"
>
<TabsList className="w-full">
<TabsTrigger value="existing" disabled={busy}>
{t('bots.pluginSubscriptions.existing')}
</TabsTrigger>
<TabsTrigger value="new" disabled={busy}>
{t('bots.pluginSubscriptions.new')}
</TabsTrigger>
</TabsList>
<TabsContent value="existing" className="space-y-3">
<div className="max-h-80 space-y-2 overflow-y-auto">
{available.map((agent) => (
<label
key={agent.uuid}
className="flex cursor-pointer items-center gap-3 rounded-lg border p-3 hover:bg-accent"
>
<Checkbox
checked={selected.includes(agent.uuid!)}
onCheckedChange={(checked) =>
setSelected((current) =>
checked
? [...current, agent.uuid!]
: current.filter((id) => id !== agent.uuid),
)
}
/>
<span
className="flex size-10 shrink-0 items-center justify-center rounded-lg border bg-muted text-2xl"
aria-hidden="true"
>
{agent.emoji || '🧩'}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">
{agent.name}
</span>
<span className="block truncate text-sm text-muted-foreground">
{agent.component_ref?.replace('plugin:', '')}
</span>
<span className="block truncate text-xs text-muted-foreground">
{(agent.supported_event_patterns ?? [])
.map((pattern) => eventPatternLabel(pattern, t))
.join(' · ')}
</span>
</span>
</label>
))}
</div>
{available.length === 0 && (
<p className="text-sm text-muted-foreground">
{t('bots.pluginSubscriptions.noExisting')}
</p>
)}
<p className="text-sm text-muted-foreground">
{t('bots.pluginSubscriptions.shared')}
</p>
</TabsContent>
<TabsContent value="new">
<fieldset disabled={busy} className="space-y-4">
<div
className="max-h-60 space-y-2 overflow-y-auto"
role="group"
aria-label={t('agents.eventProcessor.component')}
>
{components.map((descriptor) => {
const label = extractI18nObject({
en_US: descriptor.id,
zh_Hans: descriptor.id,
...descriptor.label,
});
return (
<Button
key={descriptor.id}
type="button"
variant="outline"
aria-pressed={componentRef === descriptor.id}
className="h-auto w-full justify-start gap-3 whitespace-normal p-3 text-left font-normal shadow-none aria-pressed:bg-accent"
onClick={() => {
setComponentRef(descriptor.id);
validate.current = null;
setParameters(
Object.fromEntries(
(descriptor.config_schema ?? [])
.filter((field) => field.default !== undefined)
.map((field) => [field.name, field.default]),
),
);
if (!name.trim()) setName(label);
}}
>
<AuthenticatedPluginIcon
author={descriptor.plugin_author}
name={descriptor.plugin_name}
className="size-10 shrink-0 rounded-lg border bg-muted object-cover"
/>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">
{label}
</span>
<span className="block truncate text-sm text-muted-foreground">
{descriptor.plugin_author}/{descriptor.plugin_name}
</span>
<span className="block truncate text-xs text-muted-foreground">
{descriptor.supported_event_patterns
.map((pattern) => eventPatternLabel(pattern, t))
.join(' · ')}
</span>
</span>
{componentRef === descriptor.id && (
<Check className="size-4 shrink-0" />
)}
</Button>
);
})}
{components.length === 0 && (
<p className="py-6 text-center text-sm text-muted-foreground">
{t('agents.eventProcessor.noComponents')}
</p>
)}
</div>
{component && (
<div className="space-y-2">
<Label htmlFor="new-plugin-processor-name">
{t('common.name')}
</Label>
<Input
id="new-plugin-processor-name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
)}
{component && (
<DynamicFormComponent
key={componentRef}
itemConfigList={component.config_schema}
initialValues={parameters}
onSubmit={(values) =>
setParameters(values as Record<string, unknown>)
}
onValidate={(fn) => {
validate.current = fn;
}}
/>
)}
</fieldset>
</TabsContent>
</Tabs>
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={busy}
onClick={() => setOpen(false)}
>
{t('common.cancel')}
</Button>
<Button
type="button"
disabled={
busy ||
(mode === 'existing'
? !selected.length
: !component || !name.trim())
}
onClick={add}
>
{t(
mode === 'existing'
? 'bots.pluginSubscriptions.add'
: 'bots.pluginSubscriptions.createAndBind',
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</section>
);
}
@@ -739,7 +739,7 @@ function NavItems({
> = {
agent: 'agents.kindBadgeAgent',
pipeline: 'agents.kindBadgePipeline',
event_processor: 'agents.eventProcessor.type',
event_processor: 'agents.eventProcessor.configurations',
};
const groupOrder: Array<'plugin' | 'mcp' | 'skill'> = [
@@ -893,7 +893,7 @@ function NavItems({
className="ml-auto flex shrink-0 items-center text-muted-foreground"
title={
item.kind === 'event_processor'
? t('agents.eventProcessor.type')
? t('agents.eventProcessor.configurations')
: item.kind === 'pipeline'
? t('agents.kindBadgePipeline')
: t('agents.kindBadgeAgent')
+6
View File
@@ -329,11 +329,17 @@ export interface Bot {
adapter: string;
adapter_config: object;
event_bindings?: EventBinding[];
plugin_processors?: PluginProcessorBinding[];
created_at?: string;
updated_at?: string;
adapter_runtime_values?: object;
}
export interface PluginProcessorBinding {
processor_uuid: string;
enabled: boolean;
}
export interface EventBinding {
id?: string;
event_pattern: string;
+24 -3
View File
@@ -354,6 +354,25 @@ const enUS = {
},
},
bots: {
pluginSubscriptions: {
description:
'Automatically receive events declared by the plugin, independently of the routes above.',
empty: 'No plugin processors are bound.',
add: 'Add plugin processor',
existing: 'Choose configuration',
new: 'New configuration',
noExisting: 'No configurations available. Create one.',
shared:
'Bots using the same configuration share settings and runtime state.',
saveHint:
'Save the bot after adding a processor to activate the binding.',
createAndBind: 'Create and bind',
created: 'Configuration created. Save the bot to activate the binding.',
enable: 'Enable {{name}}',
remove: 'Unbind {{name}}',
configure: 'Configure',
logs: 'View logs',
},
applyFailed: 'Configuration saved, but could not be applied',
internalErrorHint:
'An unexpected error occurred. Check the backend logs using the reference below.',
@@ -723,6 +742,7 @@ const enUS = {
},
agents: {
eventProcessor: {
configurations: 'Plugin processor configurations',
configTab: 'Configuration',
logsTab: 'Logs',
noSettings: 'This plugin processor requires no configuration.',
@@ -750,14 +770,15 @@ const enUS = {
loadError: 'Unable to load processor details.',
refresh: 'Refresh',
runs: 'Runs',
noRuns: 'No runs yet. Bind a Bot event to start.',
bindBot: 'Bind Bot events',
noRuns: 'No runs yet. Bind this processor to a bot to start.',
bindBot: 'Bind to a bot',
trace: 'Logs and message flow',
selectRun: 'Select a run to view details.',
input: 'Incoming event',
destination: 'Delivery destination',
loadMore: 'Load more',
activation: 'Install a plugin, create an instance, then bind Bot events.',
activation:
'Install a plugin, create a processor configuration, then bind a bot.',
status_pending: 'Pending',
status_running: 'Running',
status_completed: 'Completed',
+22 -3
View File
@@ -363,6 +363,24 @@ const esES = {
"Uses the Workspace owner's LangBot Account billing and credits.",
},
bots: {
pluginSubscriptions: {
description:
'Recibe automáticamente los eventos declarados por el plugin, de forma independiente de las rutas anteriores.',
empty: 'No hay procesadores vinculados.',
add: 'Añadir procesador de plugin',
existing: 'Elegir configuración',
new: 'Nueva configuración',
noExisting: 'No hay configuraciones disponibles. Crea una.',
shared:
'Los bots que usan la misma configuración comparten ajustes y estado de ejecución.',
saveHint: 'Guarda el bot para activar el vínculo.',
createAndBind: 'Crear y vincular',
created: 'Configuración creada. Guarda el bot para activar el vínculo.',
enable: 'Activar {{name}}',
remove: 'Desvincular {{name}}',
configure: 'Configurar',
logs: 'Ver registros',
},
applyFailed: 'Configuración guardada, pero no se pudo aplicar',
internalErrorHint:
'Se produjo un error interno. Consulta los registros del servidor con esta referencia.',
@@ -528,6 +546,7 @@ const esES = {
},
agents: {
eventProcessor: {
configurations: 'Configuraciones de procesadores de plugins',
configTab: 'Configuración',
logsTab: 'Registros',
noSettings: 'Este procesador de plugin no requiere configuración.',
@@ -555,15 +574,15 @@ const esES = {
loadError: 'No se pudieron cargar los detalles.',
refresh: 'Actualizar',
runs: 'Ejecuciones',
noRuns: 'Sin ejecuciones. Vincula eventos de un Bot para empezar.',
bindBot: 'Vincular eventos del Bot',
noRuns: 'Sin ejecuciones. Vincula este procesador a un bot para empezar.',
bindBot: 'Vincular a un bot',
trace: 'Registros y flujo de mensajes',
selectRun: 'Selecciona una ejecución para ver los detalles.',
input: 'Evento recibido',
destination: 'Destino de entrega',
loadMore: 'Cargar más',
activation:
'Instala un plugin, crea una instancia y vincula eventos del Bot.',
'Instala un plugin, crea una configuración de procesador y vincula un bot.',
status_pending: 'Pendiente',
status_running: 'En ejecución',
status_completed: 'Completado',
+21 -3
View File
@@ -360,6 +360,23 @@ const jaJP = {
},
},
bots: {
pluginSubscriptions: {
description:
'プラグインが宣言したイベントを自動で受信し、上のルートとは独立して実行します。',
empty: 'プラグインプロセッサーは未登録です。',
add: 'プラグインプロセッサーを追加',
existing: '既存の設定を選択',
new: '設定を新規作成',
noExisting: '追加できる設定がありません。新しく作成してください。',
shared: '同じ設定を使用するボットは設定内容と実行状態を共有します。',
saveHint: '追加後にボットを保存すると有効になります。',
createAndBind: '作成して紐付け',
created: '設定を作成しました。ボットを保存すると紐付けが有効になります。',
enable: '{{name}} を有効化',
remove: '{{name}} の紐付けを解除',
configure: '設定',
logs: 'ログを表示',
},
applyFailed: '設定を保存しましたが、適用に失敗しました',
internalErrorHint:
'内部エラーが発生しました。エラー番号でバックエンドのログを確認してください。',
@@ -736,6 +753,7 @@ const jaJP = {
},
agents: {
eventProcessor: {
configurations: 'プラグインプロセッサー設定',
configTab: '設定',
logsTab: 'ログ',
noSettings: 'このプラグインプロセッサーに設定項目はありません。',
@@ -764,15 +782,15 @@ const jaJP = {
loadError: '詳細を読み込めません。',
refresh: '更新',
runs: '実行履歴',
noRuns: '実行履歴はありません。Bot イベントを紐付けて開始します。',
bindBot: 'Bot イベントを紐付ける',
noRuns: '実行履歴はありません。ボットに紐付けて開始します。',
bindBot: 'ボットに紐付ける',
trace: 'ログとメッセージの流れ',
selectRun: '実行履歴を選択して詳細を表示します。',
input: '受信イベント',
destination: '送信先',
loadMore: 'さらに読み込む',
activation:
'プラグインをインストールし、インスタンスを作成して Bot イベントを紐付けます。',
'プラグインをインストールし、プロセッサー設定を作成してボットに紐付けます。',
status_pending: '待機中',
status_running: '実行中',
status_completed: '完了',
+22 -3
View File
@@ -360,6 +360,24 @@ const ruRU = {
"Uses the Workspace owner's LangBot Account billing and credits.",
},
bots: {
pluginSubscriptions: {
description:
'Автоматически получает события, объявленные плагином, независимо от маршрутов выше.',
empty: 'Обработчики плагинов не привязаны.',
add: 'Добавить обработчик плагина',
existing: 'Выбрать конфигурацию',
new: 'Новая конфигурация',
noExisting: 'Нет доступных конфигураций. Создайте новую.',
shared:
'Боты с общей конфигурацией используют общие настройки и состояние выполнения.',
saveHint: 'Сохраните бота, чтобы активировать привязку.',
createAndBind: 'Создать и привязать',
created: 'Конфигурация создана. Сохраните бота для активации привязки.',
enable: 'Включить {{name}}',
remove: 'Отвязать {{name}}',
configure: 'Настроить',
logs: 'Журнал',
},
applyFailed: 'Настройки сохранены, но не применены',
internalErrorHint:
'Внутренняя ошибка. Проверьте журналы сервера по указанному идентификатору.',
@@ -524,6 +542,7 @@ const ruRU = {
},
agents: {
eventProcessor: {
configurations: 'Конфигурации обработчиков плагинов',
configTab: 'Настройки',
logsTab: 'Журнал',
noSettings: 'Этот обработчик плагина не требует настройки.',
@@ -550,15 +569,15 @@ const ruRU = {
loadError: 'Не удалось загрузить данные.',
refresh: 'Обновить',
runs: 'Запуски',
noRuns: 'Запусков пока нет. Привяжите события бота.',
bindBot: 'Привязать события бота',
noRuns: 'Запусков пока нет. Привяжите обработчик к боту.',
bindBot: 'Привязать к боту',
trace: 'Журнал и поток сообщений',
selectRun: 'Выберите запуск для просмотра.',
input: 'Входящее событие',
destination: 'Получатель',
loadMore: 'Загрузить ещё',
activation:
'Установите плагин, создайте экземпляр и привяжите события бота.',
'Установите плагин, создайте конфигурацию обработчика и привяжите бота.',
status_pending: 'Ожидание',
status_running: 'Выполняется',
status_completed: 'Завершено',
+21 -3
View File
@@ -347,6 +347,23 @@ const thTH = {
"Uses the Workspace owner's LangBot Account billing and credits.",
},
bots: {
pluginSubscriptions: {
description:
'รับเหตุการณ์ที่ปลั๊กอินประกาศไว้โดยอัตโนมัติ และทำงานแยกจากเส้นทางด้านบน',
empty: 'ยังไม่ได้เชื่อมโยงตัวประมวลผลปลั๊กอิน',
add: 'เพิ่มตัวประมวลผลปลั๊กอิน',
existing: 'เลือกการตั้งค่า',
new: 'สร้างการตั้งค่า',
noExisting: 'ไม่มีการตั้งค่าที่ใช้ได้ โปรดสร้างใหม่',
shared: 'บอทที่ใช้การตั้งค่าเดียวกันจะแชร์การตั้งค่าและสถานะการทำงาน',
saveHint: 'บันทึกบอตเพื่อเปิดใช้งานการเชื่อมโยง',
createAndBind: 'สร้างและเชื่อมโยง',
created: 'สร้างการตั้งค่าแล้ว บันทึกบอทเพื่อเปิดใช้งานการเชื่อมโยง',
enable: 'เปิดใช้งาน {{name}}',
remove: 'ยกเลิกการเชื่อมโยง {{name}}',
configure: 'ตั้งค่า',
logs: 'ดูบันทึก',
},
applyFailed: 'บันทึกการตั้งค่าแล้ว แต่ไม่สามารถนำไปใช้ได้',
internalErrorHint:
'เกิดข้อผิดพลาดภายใน โปรดตรวจสอบบันทึกของเซิร์ฟเวอร์ด้วยหมายเลขอ้างอิง',
@@ -511,6 +528,7 @@ const thTH = {
},
agents: {
eventProcessor: {
configurations: 'การตั้งค่าตัวประมวลผลปลั๊กอิน',
configTab: 'การตั้งค่า',
logsTab: 'บันทึก',
noSettings: 'ตัวประมวลผลปลั๊กอินนี้ไม่ต้องตั้งค่า',
@@ -537,14 +555,14 @@ const thTH = {
loadError: 'ไม่สามารถโหลดรายละเอียดได้',
refresh: 'รีเฟรช',
runs: 'ประวัติการทำงาน',
noRuns: 'ยังไม่มีการทำงาน เชื่อมโยงเหตุการณ์บอทเพื่อเริ่มต้น',
bindBot: 'เชื่อมโยงเหตุการณ์บอท',
noRuns: 'ยังไม่มีการทำงาน เชื่อมโยงตัวประมวลผลกับบอทเพื่อเริ่มต้น',
bindBot: 'เชื่อมโยงกับบอท',
trace: 'บันทึกและเส้นทางข้อความ',
selectRun: 'เลือกการทำงานเพื่อดูรายละเอียด',
input: 'เหตุการณ์ขาเข้า',
destination: 'ปลายทางการส่ง',
loadMore: 'โหลดเพิ่มเติม',
activation: 'ติดตั้งปลั๊กอิน สร้างอินสแตนซ์ แล้วเชื่อมโยงเหตุการณ์บอท',
activation: 'ติดตั้งปลั๊กอิน สร้างการตั้งค่าตัวประมวลผล แล้วเชื่อมโยงบอท',
status_pending: 'รอดำเนินการ',
status_running: 'กำลังทำงาน',
status_completed: 'เสร็จสิ้น',
+22 -3
View File
@@ -356,6 +356,24 @@ const viVN = {
"Uses the Workspace owner's LangBot Account billing and credits.",
},
bots: {
pluginSubscriptions: {
description:
'Tự động nhận sự kiện do plugin khai báo, hoạt động độc lập với các tuyến ở trên.',
empty: 'Chưa liên kết bộ xử lý plugin.',
add: 'Thêm bộ xử lý plugin',
existing: 'Chọn cấu hình',
new: 'Cấu hình mới',
noExisting: 'Chưa có cấu hình khả dụng. Hãy tạo mới.',
shared:
'Các bot dùng chung cấu hình sẽ chia sẻ thiết lập và trạng thái chạy.',
saveHint: 'Lưu bot để kích hoạt liên kết.',
createAndBind: 'Tạo và liên kết',
created: 'Đã tạo cấu hình. Lưu bot để kích hoạt liên kết.',
enable: 'Bật {{name}}',
remove: 'Hủy liên kết {{name}}',
configure: 'Cấu hình',
logs: 'Xem nhật ký',
},
applyFailed: 'Đã lưu cấu hình nhưng không thể áp dụng',
internalErrorHint:
'Đã xảy ra lỗi nội bộ. Hãy kiểm tra nhật ký máy chủ bằng mã lỗi.',
@@ -520,6 +538,7 @@ const viVN = {
},
agents: {
eventProcessor: {
configurations: 'Cấu hình bộ xử lý plugin',
configTab: 'Cấu hình',
logsTab: 'Nhật ký',
noSettings: 'Bộ xử lý plugin này không cần cấu hình.',
@@ -546,14 +565,14 @@ const viVN = {
loadError: 'Không thể tải chi tiết.',
refresh: 'Làm mới',
runs: 'Lịch sử chạy',
noRuns: 'Chưa có lần chạy nào. Liên kết sự kiện Bot để bắt đầu.',
bindBot: 'Liên kết sự kiện Bot',
noRuns: 'Chưa có lần chạy nào. Liên kết bộ xử lý với bot để bắt đầu.',
bindBot: 'Liên kết với bot',
trace: 'Nhật ký và luồng tin nhắn',
selectRun: 'Chọn một lần chạy để xem chi tiết.',
input: 'Sự kiện đầu vào',
destination: 'Đích gửi',
loadMore: 'Tải thêm',
activation: 'Cài plugin, tạo phiên bản rồi liên kết sự kiện Bot.',
activation: 'Cài plugin, tạo cấu hình bộ xử lý rồi liên kết bot.',
status_pending: 'Đang chờ',
status_running: 'Đang chạy',
status_completed: 'Hoàn tất',
+20 -3
View File
@@ -339,6 +339,22 @@ const zhHans = {
},
},
bots: {
pluginSubscriptions: {
description: '自动接收插件声明的事件,与上方事件路由独立执行。',
empty: '尚未绑定插件处理器。',
add: '添加插件处理器',
existing: '选择已有配置',
new: '新建配置',
noExisting: '没有可添加的配置,可新建一份。',
shared: '使用同一配置的机器人会共享设置和运行状态。',
saveHint: '添加后保存机器人配置,绑定才会生效。',
createAndBind: '创建并绑定',
created: '配置已创建,保存机器人配置后生效。',
enable: '启用 {{name}}',
remove: '解除绑定 {{name}}',
configure: '配置',
logs: '查看日志',
},
applyFailed: '配置已保存,但应用失败',
internalErrorHint: '发生内部错误,请通过错误编号查看后端日志。',
errorReference: '错误编号:{{id}}',
@@ -687,6 +703,7 @@ const zhHans = {
},
agents: {
eventProcessor: {
configurations: '插件处理器配置',
configTab: '配置',
logsTab: '日志',
noSettings: '此插件处理器无需配置。',
@@ -712,14 +729,14 @@ const zhHans = {
loadError: '无法加载处理器详情。',
refresh: '刷新',
runs: '运行记录',
noRuns: '暂无运行记录,绑定机器人事件后开始处理。',
bindBot: '绑定机器人事件',
noRuns: '暂无运行记录,绑定机器人后开始处理。',
bindBot: '绑定机器人',
trace: '日志与消息流向',
selectRun: '选择一条运行记录查看详情。',
input: '传入事件',
destination: '投递目标',
loadMore: '加载更多',
activation: '安装插件,创建实例,再绑定机器人事件。',
activation: '安装插件,创建处理器配置,再绑定机器人。',
status_pending: '待执行',
status_running: '运行中',
status_completed: '已完成',
+20 -3
View File
@@ -336,6 +336,22 @@ const zhHant = {
"Uses the Workspace owner's LangBot Account billing and credits.",
},
bots: {
pluginSubscriptions: {
description: '自動接收外掛宣告的事件,與上方事件路由獨立執行。',
empty: '尚未綁定外掛處理器。',
add: '新增外掛處理器',
existing: '選擇現有設定',
new: '新增設定',
noExisting: '沒有可新增的設定,請建立一份。',
shared: '使用同一份設定的機器人會共用設定和執行狀態。',
saveHint: '新增後儲存機器人設定,綁定才會生效。',
createAndBind: '建立並綁定',
created: '設定已建立,儲存機器人設定後生效。',
enable: '啟用 {{name}}',
remove: '解除綁定 {{name}}',
configure: '設定',
logs: '查看日誌',
},
applyFailed: '設定已儲存,但套用失敗',
internalErrorHint: '發生內部錯誤,請透過錯誤編號查看後端日誌。',
errorReference: '錯誤編號:{{id}}',
@@ -494,6 +510,7 @@ const zhHant = {
},
agents: {
eventProcessor: {
configurations: '外掛處理器設定',
configTab: '設定',
logsTab: '日誌',
noSettings: '此外掛處理器無需設定。',
@@ -519,14 +536,14 @@ const zhHant = {
loadError: '無法載入處理器詳情。',
refresh: '重新整理',
runs: '執行記錄',
noRuns: '尚無執行記錄,綁定機器人事件後開始處理。',
bindBot: '綁定機器人事件',
noRuns: '尚無執行記錄,綁定機器人後開始處理。',
bindBot: '綁定機器人',
trace: '日誌與訊息流向',
selectRun: '選擇一筆執行記錄查看詳情。',
input: '傳入事件',
destination: '傳送目標',
loadMore: '載入更多',
activation: '安裝外掛、建立實例,再綁定機器人事件。',
activation: '安裝外掛、建立處理器設定,再綁定機器人。',
status_pending: '待執行',
status_running: '執行中',
status_completed: '已完成',
+2
View File
@@ -64,6 +64,7 @@ interface BotMock {
adapter_config: JsonRecord;
use_pipeline_uuid?: string;
event_bindings: unknown[];
plugin_processors: unknown[];
pipeline_routing_rules: unknown[];
adapter_runtime_values: JsonRecord;
updated_at: string;
@@ -505,6 +506,7 @@ function makeBot(
? String(data.use_pipeline_uuid)
: undefined,
event_bindings: (data.event_bindings as unknown[] | undefined) || [],
plugin_processors: (data.plugin_processors as unknown[] | undefined) || [],
pipeline_routing_rules:
(data.pipeline_routing_rules as unknown[] | undefined) || [],
adapter_runtime_values: {
+135
View File
@@ -0,0 +1,135 @@
import { expect, test } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
test('creates a configured processor, persists subscriptions separately and reuses an instance', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
withAdapterEvents: true,
});
const ref = 'plugin:test/welcome/default';
const processors: Record<string, unknown>[] = [];
await page.route('**/api/v1/agents/_/metadata', async (route) =>
route.fulfill({
json: {
code: 0,
data: {
event_processors: [
{
id: ref,
plugin_author: 'test',
plugin_name: 'welcome',
usages: ['event'],
label: { en_US: 'Welcome members', zh_Hans: '欢迎新成员' },
supported_event_patterns: ['group.member_joined'],
config_schema: [
{
name: 'greeting',
type: 'string',
label: { en_US: 'Greeting', zh_Hans: '欢迎语' },
required: true,
default: 'Hello',
},
],
},
],
},
},
}),
);
await page.route('**/api/v1/agents', async (route) => {
if (route.request().method() === 'POST') {
processors.push({
...route.request().postDataJSON(),
uuid: 'processor-new',
supported_event_patterns: ['group.member_joined'],
});
return route.fulfill({
json: {
code: 0,
data: { uuid: 'processor-new', kind: 'event_processor' },
},
});
}
return route.fulfill({ json: { code: 0, data: { agents: processors } } });
});
await page.goto('/home/bots?id=new');
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Playwright Adapter' }).click();
await page.locator('input[name="name"]').fill('Subscription Bot');
await page.getByRole('button', { name: /^Submit$/ }).click();
await expect(page).toHaveURL(/id=bot-1$/);
const section = page.getByRole('region', {
name: 'Plugin processor',
exact: true,
});
await section.getByRole('button', { name: 'Add plugin processor' }).click();
const dialog = page.getByRole('dialog');
await dialog.getByRole('button', { name: /Welcome members/ }).click();
await dialog.getByLabel('Name', { exact: true }).fill('Customer welcome');
await dialog.locator('input[name="greeting"]').fill('Welcome aboard');
await dialog.getByRole('button', { name: 'Create and bind' }).click();
await expect(dialog).toBeHidden();
expect(processors[0]).toMatchObject({
kind: 'event_processor',
component_ref: ref,
config: { runner_config: { [ref]: { greeting: 'Welcome aboard' } } },
});
await expect(section).toContainText('Customer welcome');
const save = async () => {
const request = page.waitForRequest(
(r) => r.method() === 'PUT' && r.url().endsWith('/platform/bots/bot-1'),
);
await page.getByRole('button', { name: /^Save$/ }).click();
const body = (await request).postDataJSON();
await expect(page.getByRole('button', { name: /^Save$/ })).toBeDisabled();
return body;
};
const body = await save();
expect(body.plugin_processors).toEqual([
{ processor_uuid: 'processor-new', enabled: true },
]);
expect(body.event_bindings).toEqual([]);
await page.reload();
await expect(section).toContainText('Customer welcome');
await expect(
section.getByRole('link', { name: 'View logs' }),
).toHaveAttribute('href', '/home/agents?id=processor-new&tab=logs');
await section
.getByRole('switch', { name: 'Enable Customer welcome' })
.click();
expect((await save()).plugin_processors).toEqual([
{ processor_uuid: 'processor-new', enabled: false },
]);
await section
.getByRole('button', { name: 'Unbind Customer welcome' })
.click();
await section.getByRole('button', { name: 'Add plugin processor' }).click();
await dialog.getByRole('checkbox', { name: /Customer welcome/ }).check();
await dialog
.getByRole('button', { name: 'Add plugin processor', exact: true })
.click();
await expect(section).toContainText('Customer welcome');
expect(processors).toHaveLength(1);
processors.push({
...processors[0],
uuid: 'processor-observer',
name: 'Event observer',
});
await page.reload();
await section
.getByRole('button', { name: 'Unbind Customer welcome' })
.click();
await section.getByRole('button', { name: 'Add plugin processor' }).click();
await dialog.getByRole('checkbox', { name: /Customer welcome/ }).check();
await dialog.getByRole('checkbox', { name: /Event observer/ }).check();
await dialog
.getByRole('button', { name: 'Add plugin processor', exact: true })
.click();
expect((await save()).plugin_processors).toEqual([
{ processor_uuid: 'processor-new', enabled: true },
{ processor_uuid: 'processor-observer', enabled: true },
]);
});