feat(processors): add explicitly bound plugin event processors

This commit is contained in:
RockChinQ
2026-09-08 00:43:35 +08:00
parent 812eb09ee4
commit 237fa6545d
50 changed files with 1968 additions and 289 deletions
+98 -80
View File
@@ -23,6 +23,7 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import PipelineDetailContent from '@/app/home/pipelines/PipelineDetailContent';
import EventProcessorDetailContent from './EventProcessorDetailContent';
import AgentCreateContent from './components/AgentCreateContent';
import AgentDebugPanel from './components/AgentDebugPanel';
import AgentFormComponent, {
@@ -167,87 +168,104 @@ export default function AgentDetailContent({ id }: { id: string }) {
return (
<>
<ProcessorDetailWorkbench
key={id}
title={`${agent.emoji || '🤖'} ${agent.name}`}
titleBadge={
supportedEventPatterns.length === 0 ? (
<Badge
variant="outline"
role="status"
className="shrink-0 gap-1 rounded-full border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300"
>
<AlertTriangle className="size-3" />
{t('agents.noEventsConfiguredBadge')}
</Badge>
) : undefined
}
titleAction={
canManage ? (
<EntityTitleEditButton onClick={() => setBasicInfoOpen(true)} />
) : undefined
}
status={runnerStatus}
saveLabel={t('common.save')}
saveFormId="agent-form"
canSave={canManage}
isDirty={formDirty}
isSaving={formSaving}
headerActions={
canManage ? (
<Button
type="button"
variant="destructive"
disabled={formSaving || deleting}
onClick={() => setDeleteConfirmOpen(true)}
>
<Trash2 className="size-4" />
{t('common.delete')}
</Button>
) : undefined
}
configTitle={t('pipelines.configuration')}
configContent={
<fieldset className="contents" disabled={!canManage}>
<AgentFormComponent
ref={agentFormRef}
agentId={id}
availableEventTypes={availableEventTypes}
onFinish={(updatedAgent) => {
if (updatedAgent) {
setAgent((current) =>
current ? { ...current, ...updatedAgent } : current,
);
{agent.kind === 'event_processor' ? (
<EventProcessorDetailContent
key={id}
id={id}
agent={agent}
canManage={canManage}
onDelete={() => setDeleteConfirmOpen(true)}
onEdit={() => setBasicInfoOpen(true)}
onSaved={() => {
void httpClient
.getAgent(id)
.then((response) => setAgent(response.agent));
void refreshPipelines();
}}
/>
) : (
<ProcessorDetailWorkbench
key={id}
title={`${agent.emoji || '🤖'} ${agent.name}`}
titleBadge={
supportedEventPatterns.length === 0 ? (
<Badge
variant="outline"
role="status"
className="shrink-0 gap-1 rounded-full border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300"
>
<AlertTriangle className="size-3" />
{t('agents.noEventsConfiguredBadge')}
</Badge>
) : undefined
}
titleAction={
canManage ? (
<EntityTitleEditButton onClick={() => setBasicInfoOpen(true)} />
) : undefined
}
status={runnerStatus}
saveLabel={t('common.save')}
saveFormId="agent-form"
canSave={canManage}
isDirty={formDirty}
isSaving={formSaving}
headerActions={
canManage ? (
<Button
type="button"
variant="destructive"
disabled={formSaving || deleting}
onClick={() => setDeleteConfirmOpen(true)}
>
<Trash2 className="size-4" />
{t('common.delete')}
</Button>
) : undefined
}
configTitle={t('pipelines.configuration')}
configContent={
<fieldset className="contents" disabled={!canManage}>
<AgentFormComponent
ref={agentFormRef}
agentId={id}
availableEventTypes={availableEventTypes}
onFinish={(updatedAgent) => {
if (updatedAgent) {
setAgent((current) =>
current ? { ...current, ...updatedAgent } : current,
);
}
refreshPipelines();
}}
onDirtyChange={setFormDirty}
onSavingChange={setFormSaving}
onRunnerStatusChange={setRunnerStatus}
onSupportedEventPatternsChange={setSupportedEventPatterns}
onPlatformToolsChange={setPlatformTools}
/>
</fieldset>
}
debugTitle={canOperate ? t('agents.debugTab') : undefined}
debugDescription={t('agents.debugPlatformNotice')}
debugContent={
canOperate ? (
<AgentDebugPanel
agentId={id}
platformTools={platformTools}
hasUnsavedChanges={formDirty}
beforeRun={async () => agentFormRef.current?.save() ?? false}
onOpenRunnerConfig={() =>
agentFormRef.current?.openSection('runner_config')
}
refreshPipelines();
}}
onDirtyChange={setFormDirty}
onSavingChange={setFormSaving}
onRunnerStatusChange={setRunnerStatus}
onSupportedEventPatternsChange={setSupportedEventPatterns}
onPlatformToolsChange={setPlatformTools}
/>
</fieldset>
}
debugTitle={canOperate ? t('agents.debugTab') : undefined}
debugDescription={t('agents.debugPlatformNotice')}
debugContent={
canOperate ? (
<AgentDebugPanel
agentId={id}
platformTools={platformTools}
hasUnsavedChanges={formDirty}
beforeRun={async () => agentFormRef.current?.save() ?? false}
onOpenRunnerConfig={() =>
agentFormRef.current?.openSection('runner_config')
}
supportedEventPatterns={supportedEventPatterns}
availableEventTypes={availableEventTypes}
/>
) : undefined
}
unsavedLabel={t('pipelines.unsavedChanges')}
/>
supportedEventPatterns={supportedEventPatterns}
availableEventTypes={availableEventTypes}
/>
) : undefined
}
unsavedLabel={t('pipelines.unsavedChanges')}
/>
)}
<EntityBasicInfoDialog
open={basicInfoOpen}
onOpenChange={setBasicInfoOpen}
@@ -0,0 +1,437 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { FileCode2, RefreshCw, Settings2, Trash2, Pencil } from 'lucide-react';
import { toast } from 'sonner';
import type {
Agent,
AgentPlatformTool,
EventProcessorDescriptor,
ProcessorRun,
ProcessorRunEvent,
} from '@/app/infra/entities/api';
import { httpClient } from '@/app/infra/http/HttpClient';
import { Button } from '@/components/ui/button';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { Badge } from '@/components/ui/badge';
import EventProcessorSettings from './components/EventProcessorSettings';
export default function EventProcessorDetailContent({
agent,
id,
canManage,
onDelete,
onEdit,
onSaved,
}: {
agent: Agent;
id: string;
canManage: boolean;
onDelete: () => void;
onEdit: () => void;
onSaved: () => void;
}) {
const { t } = useTranslation();
const [platformTools, setPlatformTools] = useState<AgentPlatformTool[]>([]);
const toolLabels = Object.fromEntries(
platformTools.map((tool) => [tool.name, extractI18nObject(tool.label)]),
);
const [components, setComponents] = useState<EventProcessorDescriptor[]>([]);
const [componentRef, setComponentRef] = useState(agent.component_ref ?? '');
const initialParameters =
(
(agent.config?.runner_config ?? {}) as Record<
string,
Record<string, unknown>
>
)[agent.component_ref ?? ''] ?? {};
const [parameters, setParameters] = useState(initialParameters);
const [runs, setRuns] = useState<ProcessorRun[]>([]);
const [cursor, setCursor] = useState<number | null>(null);
const [selected, setSelected] = useState<ProcessorRun | null>(null);
const [events, setEvents] = useState<ProcessorRunEvent[]>([]);
const [eventCursor, setEventCursor] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [pagingRuns, setPagingRuns] = useState(false);
const [pagingEvents, setPagingEvents] = useState(false);
const [configOpen, setConfigOpen] = useState(false);
const [failed, setFailed] = useState(false);
const validate = useRef<(() => Promise<boolean>) | null>(null);
const requestVersion = useRef(0);
const available = components.some((item) => item.id === agent.component_ref);
const load = useCallback(async () => {
setFailed(false);
try {
const [metadata, page] = await Promise.all([
httpClient.getAgentMetadata(),
httpClient.getProcessorRuns(id),
]);
setComponents(metadata.event_processors ?? []);
setPlatformTools(metadata.platform_tools ?? []);
setRuns(page.items);
setCursor(page.has_more ? page.next_cursor : null);
} catch {
setFailed(true);
} finally {
setLoading(false);
}
}, [id]);
useEffect(() => {
void load();
}, [load]);
useEffect(
() => () => {
requestVersion.current += 1;
},
[id],
);
async function openRun(run: ProcessorRun) {
const version = ++requestVersion.current;
setSelected(run);
setEvents([]);
setEventCursor(null);
try {
const page = await httpClient.getProcessorRunEvents(id, run.run_id);
if (version !== requestVersion.current) return;
setSelected(page.run);
setEvents(page.items);
setEventCursor(page.has_more ? page.next_cursor : null);
} catch {
if (version === requestVersion.current)
toast.error(t('agents.eventProcessor.loadError'));
}
}
async function loadMoreEvents() {
if (!selected || eventCursor === null || pagingEvents) return;
setPagingEvents(true);
const runId = selected.run_id;
const version = requestVersion.current;
try {
const page = await httpClient.getProcessorRunEvents(
id,
runId,
eventCursor,
);
if (version !== requestVersion.current) return;
setEvents((current) => [...current, ...page.items]);
setEventCursor(page.has_more ? page.next_cursor : null);
} catch {
toast.error(t('agents.eventProcessor.loadError'));
} finally {
setPagingEvents(false);
}
}
async function loadMoreRuns() {
if (cursor === null || pagingRuns) return;
setPagingRuns(true);
try {
const page = await httpClient.getProcessorRuns(id, cursor);
setRuns((current) => [
...new Map(
[...current, ...page.items].map((run) => [run.run_id, run]),
).values(),
]);
setCursor(page.has_more ? page.next_cursor : null);
} catch {
toast.error(t('agents.eventProcessor.loadError'));
} finally {
setPagingRuns(false);
}
}
useEffect(() => {
let cancelled = false;
let busy = false;
const timer = window.setInterval(async () => {
if (busy || document.hidden) return;
busy = true;
try {
const page = await httpClient.getProcessorRuns(id);
if (cancelled) return;
setRuns((current) =>
[
...new Map(
[...current, ...page.items].map((run) => [run.run_id, run]),
).values(),
].sort((a, b) => b.created_at - a.created_at),
);
if (
selected &&
!['completed', 'failed', 'cancelled'].includes(selected.status) &&
eventCursor === null
) {
const trace = await httpClient.getProcessorRunEvents(
id,
selected.run_id,
events.at(-1)?.sequence,
);
if (cancelled) return;
setSelected(trace.run);
setEvents((current) => [
...new Map(
[...current, ...trace.items].map((event) => [
event.sequence,
event,
]),
).values(),
]);
setEventCursor(trace.has_more ? trace.next_cursor : null);
}
} catch {
/* Keep existing records visible across transient refresh failures. */
} finally {
busy = false;
}
}, 3000);
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, [id, selected, eventCursor, events]);
async function save() {
if (!componentRef || !((await validate.current?.()) ?? true)) return;
setSaving(true);
try {
await httpClient.updateAgent(id, {
component_ref: componentRef,
config: {
...agent.config,
runner: { id: componentRef },
runner_config: { [componentRef]: parameters },
},
});
toast.success(t('agents.saveSuccess'));
onSaved();
setConfigOpen(false);
await load();
} catch {
toast.error(t('agents.saveError'));
} finally {
setSaving(false);
}
}
function payload(value: unknown) {
return (
<pre className="mt-2 whitespace-pre-wrap break-all rounded-md bg-muted/50 p-3 text-xs">
{JSON.stringify(value, null, 2)}
</pre>
);
}
return (
<div className="flex h-full min-h-0 flex-col gap-4">
<header className="flex flex-wrap items-center gap-3">
<FileCode2 className="size-6" />
<h1 className="text-2xl font-semibold">{agent.name}</h1>
{canManage && (
<Button
variant="ghost"
size="icon"
onClick={onEdit}
aria-label={t('common.edit')}
>
<Pencil className="size-4" />
</Button>
)}
<Badge variant="outline">{t('agents.eventProcessor.type')}</Badge>
{!loading && !failed && !available && (
<Badge variant="destructive">
{t('agents.eventProcessor.unavailable')}
</Badge>
)}
<div className="ml-auto flex gap-2">
<Button
variant="outline"
onClick={() => {
void load();
if (selected) void openRun(selected);
}}
aria-label={t('agents.eventProcessor.refresh')}
>
<RefreshCw className="size-4" />
</Button>
{canManage && (
<>
<Button
variant="outline"
onClick={() => setConfigOpen(!configOpen)}
>
<Settings2 className="size-4" />
{t('pipelines.configuration')}
</Button>
<Button variant="destructive" onClick={onDelete}>
<Trash2 className="size-4" />
{t('common.delete')}
</Button>
</>
)}
</div>
</header>
<p className="shrink-0 break-all text-xs text-muted-foreground">
{agent.component_ref}
</p>
{configOpen && (
<div className="max-h-[45vh] shrink-0 overflow-y-auto rounded-xl border p-4">
<EventProcessorSettings
components={components}
value={componentRef}
parameters={parameters}
onChange={(value) => {
setComponentRef(value);
setParameters({});
validate.current = null;
}}
onParametersChange={setParameters}
onValidate={(fn) => {
validate.current = fn;
}}
/>
<Button
className="mt-4"
disabled={
saving || !components.some((item) => item.id === componentRef)
}
onClick={() => void save()}
>
{t('common.save')}
</Button>
</div>
)}
{failed && (
<p role="alert" className="text-destructive">
{t('agents.eventProcessor.loadError')}
</p>
)}
<div className="grid min-h-0 flex-1 gap-4 md:grid-cols-[minmax(240px,0.7fr)_minmax(0,1.3fr)]">
<section className="min-h-0 overflow-y-auto rounded-xl border p-4">
<h2 className="mb-3 font-semibold">
{t('agents.eventProcessor.runs')}
</h2>
{loading ? (
<p>{t('common.loading')}</p>
) : runs.length === 0 && !failed ? (
<div className="space-y-3 text-sm text-muted-foreground">
<p>{t('agents.eventProcessor.noRuns')}</p>
<Link className="text-primary underline" to="/home/bots">
{t('agents.eventProcessor.bindBot')}
</Link>
</div>
) : (
runs.map((run) => (
<button
key={run.run_id}
onClick={() => void openRun(run)}
className={`mb-2 block w-full rounded-lg border p-3 text-left text-sm ${selected?.run_id === run.run_id ? 'border-primary bg-primary/5' : 'hover:bg-muted/50'}`}
>
<span className="block break-all font-medium">
{run.metadata.event_type}
</span>
<span className="mt-1 flex flex-wrap justify-between gap-1 text-xs text-muted-foreground">
<span>
{new Date(run.created_at * 1000).toLocaleString()}
</span>
<span>
{t(`agents.eventProcessor.status_${run.status}`, {
defaultValue: run.status,
})}
</span>
</span>
</button>
))
)}
{cursor !== null && (
<Button
variant="ghost"
disabled={pagingRuns}
onClick={() => void loadMoreRuns()}
>
{t('agents.eventProcessor.loadMore')}
</Button>
)}
</section>
<section className="min-h-0 overflow-y-auto rounded-xl border p-4">
<h2 className="mb-3 font-semibold">
{t('agents.eventProcessor.trace')}
</h2>
{!selected ? (
<p className="text-sm text-muted-foreground">
{t('agents.eventProcessor.selectRun')}
</p>
) : (
<div className="space-y-3">
<details className="rounded-lg border p-3">
<summary className="cursor-pointer text-sm font-medium">
{t('agents.eventProcessor.input')}
</summary>
{payload(selected.metadata.input_event)}
</details>
{selected.metadata.delivery != null && (
<details className="rounded-lg border p-3">
<summary className="cursor-pointer text-sm font-medium">
{t('agents.eventProcessor.destination')}
</summary>
{payload(selected.metadata.delivery)}
</details>
)}
{events.map((event) =>
event.type === 'processor.log' ? (
<div
key={event.sequence}
className="rounded-lg bg-muted/40 p-3 text-sm"
>
<span className="mr-2 text-xs text-muted-foreground">
{String(event.data.level)}
</span>
<span className="whitespace-pre-wrap break-words">
{String(event.data.text)}
</span>
</div>
) : (
<details
key={event.sequence}
className="rounded-lg border p-3"
>
<summary className="cursor-pointer break-all text-sm font-medium">
{t(
`agents.eventProcessor.trace_${event.type.replaceAll('.', '_')}`,
{ defaultValue: event.type },
)}
{typeof event.data.tool_name === 'string' && (
<span className="ml-2 text-muted-foreground">
{toolLabels[event.data.tool_name] ||
event.data.tool_name}
</span>
)}
</summary>
{payload(event.data)}
</details>
),
)}
{selected.status === 'failed' && selected.status_reason && (
<p className="break-words text-sm text-destructive">
{selected.status_reason}
</p>
)}
{eventCursor !== null && (
<Button
variant="ghost"
disabled={pagingEvents}
onClick={() => void loadMoreEvents()}
>
{t('agents.eventProcessor.loadMore')}
</Button>
)}
</div>
)}
</section>
</div>
</div>
);
}
@@ -1,12 +1,13 @@
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Bot, Workflow } from 'lucide-react';
import { Bot, Workflow, FileCode2 } from 'lucide-react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { AgentKind } from '@/app/infra/entities/api';
import { AgentKind, EventProcessorDescriptor } from '@/app/infra/entities/api';
import EventProcessorSettings from './EventProcessorSettings';
import { Button } from '@/components/ui/button';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import {
@@ -35,6 +36,23 @@ export default function AgentCreateContent({
}) {
const { t } = useTranslation();
const [kind, setKind] = useState<AgentKind>('agent');
const [components, setComponents] = useState<EventProcessorDescriptor[]>([]);
const [componentRef, setComponentRef] = useState('');
const [parameters, setParameters] = useState<Record<string, unknown>>({});
const validateParameters = useRef<(() => Promise<boolean>) | null>(null);
useEffect(() => {
if (kind !== 'event_processor') return;
let cancelled = false;
httpClient
.getAgentMetadata()
.then((metadata) => {
if (!cancelled) setComponents(metadata.event_processors ?? []);
})
.catch(() => toast.error(t('agents.eventProcessor.loadError')));
return () => {
cancelled = true;
};
}, [kind, t]);
const formSchema = z.object({
name: z.string().min(1, { message: t('agents.nameRequired') }),
description: z.string().optional(),
@@ -51,8 +69,14 @@ export default function AgentCreateContent({
});
function handleKindChange(nextKind: AgentKind) {
const previousDefaultEmoji = kind === 'pipeline' ? '⚙️' : '🤖';
const nextDefaultEmoji = nextKind === 'pipeline' ? '⚙️' : '🤖';
const previousDefaultEmoji =
kind === 'pipeline' ? '⚙️' : kind === 'event_processor' ? '⚡' : '🤖';
const nextDefaultEmoji =
nextKind === 'pipeline'
? '⚙️'
: nextKind === 'event_processor'
? '⚡'
: '🤖';
setKind(nextKind);
const currentEmoji = form.getValues('emoji');
if (!currentEmoji || currentEmoji === previousDefaultEmoji) {
@@ -60,10 +84,24 @@ export default function AgentCreateContent({
}
}
function handleSubmit(values: FormValues) {
async function handleSubmit(values: FormValues) {
if (
kind === 'event_processor' &&
(!componentRef || !((await validateParameters.current?.()) ?? true))
)
return;
httpClient
.createAgent({
kind,
...(kind === 'event_processor'
? {
component_ref: componentRef,
config: {
runner: { id: componentRef },
runner_config: { [componentRef]: parameters },
},
}
: {}),
name: values.name,
description: values.description ?? '',
emoji: values.emoji || (kind === 'pipeline' ? '⚙️' : '🤖'),
@@ -90,13 +128,26 @@ export default function AgentCreateContent({
title: t('agents.pipelineType'),
description: t('agents.pipelineTypeDescription'),
},
{
kind: 'event_processor' as const,
icon: FileCode2,
title: t('agents.eventProcessor.type'),
description: t('agents.eventProcessor.description'),
},
];
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between pb-4 shrink-0">
<h1 className="text-xl font-semibold">{t('agents.create')}</h1>
<Button type="submit" form="agent-create-form">
<Button
type="submit"
form="agent-create-form"
disabled={
form.formState.isSubmitting ||
(kind === 'event_processor' && !componentRef)
}
>
{t('common.submit')}
</Button>
</div>
@@ -158,6 +209,22 @@ export default function AgentCreateContent({
</ToggleGroup>
</section>
{kind === 'event_processor' && (
<EventProcessorSettings
components={components}
value={componentRef}
parameters={parameters}
onChange={(value) => {
setComponentRef(value);
setParameters({});
validateParameters.current = null;
}}
onParametersChange={setParameters}
onValidate={(validate) => {
validateParameters.current = validate;
}}
/>
)}
<Card>
<CardHeader>
<CardTitle>{t('agents.basicInfo')}</CardTitle>
@@ -0,0 +1,91 @@
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import type { EventProcessorDescriptor } from '@/app/infra/entities/api';
import { extractI18nObject } from '@/i18n/I18nProvider';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
export default function EventProcessorSettings({
components,
value,
parameters,
onChange,
onParametersChange,
onValidate,
}: {
components: EventProcessorDescriptor[];
value: string;
parameters: Record<string, unknown>;
onChange: (value: string) => void;
onParametersChange: (value: Record<string, unknown>) => void;
onValidate?: (validate: () => Promise<boolean>) => void;
}) {
const { t } = useTranslation();
const selected = components.find((item) => item.id === value);
return (
<div className="space-y-4">
<div className="space-y-2">
<label
className="text-sm font-medium"
htmlFor="event-processor-component"
>
{t('agents.eventProcessor.component')}
</label>
<Select value={value} onValueChange={onChange}>
<SelectTrigger id="event-processor-component">
<SelectValue
placeholder={t('agents.eventProcessor.selectComponent')}
/>
</SelectTrigger>
<SelectContent>
{value && !selected && (
<SelectItem value={value}>
{t('agents.eventProcessor.unavailable')}
</SelectItem>
)}
{components.map((component) => (
<SelectItem key={component.id} value={component.id}>
{extractI18nObject({
en_US: component.id,
zh_Hans: component.id,
...component.label,
})}{' '}
· {component.plugin_author}/{component.plugin_name}
</SelectItem>
))}
</SelectContent>
</Select>
{components.length === 0 && (
<p className="text-sm text-muted-foreground">
{t('agents.eventProcessor.noComponents')}{' '}
<Link className="text-primary underline" to="/home/plugins">
{t('agents.eventProcessor.installPlugin')}
</Link>
</p>
)}
</div>
{selected && (
<p className="break-words text-xs text-muted-foreground">
{selected.supported_event_patterns.join(' · ')}
</p>
)}
{selected && selected.config_schema.length > 0 && (
<DynamicFormComponent
key={value}
itemConfigList={selected.config_schema}
initialValues={parameters}
onSubmit={(values) =>
onParametersChange(values as Record<string, unknown>)
}
onValidate={onValidate}
/>
)}
</div>
);
}
@@ -374,5 +374,29 @@ function PipelineDiagram() {
}
export default function ProcessorTypeDiagram({ kind }: { kind: AgentKind }) {
const { t } = useTranslation();
if (kind === 'event_processor')
return (
<div className="flex h-full flex-col justify-center gap-6 rounded-xl border bg-muted/20 p-8">
<h3 className="text-lg font-semibold">
{t('agents.eventProcessor.type')}
</h3>
<p className="text-sm text-muted-foreground">
{t('agents.eventProcessor.description')}
</p>
{['input', 'component', 'trace'].map((step, index) => (
<div
key={step}
className="flex items-center gap-3 rounded-lg border bg-background p-4"
>
<span className="text-primary">{index + 1}</span>
<span>{t(`agents.eventProcessor.${step}`)}</span>
</div>
))}
<p className="text-sm text-muted-foreground">
{t('agents.eventProcessor.activation')}
</p>
</div>
);
return kind === 'agent' ? <AgentDiagram /> : <PipelineDiagram />;
}
@@ -75,7 +75,12 @@ const getFormSchema = (t: (key: string) => string) =>
z.object({
id: z.string().optional(),
event_pattern: z.string(),
target_type: z.enum(['agent', 'pipeline', 'discard']),
target_type: z.enum([
'agent',
'pipeline',
'event_processor',
'discard',
]),
target_uuid: z.string(),
filters: z.array(z.record(z.string(), z.any())).optional(),
priority: z.number(),
@@ -35,6 +35,7 @@ import {
UserMinus,
UserPlus,
Workflow,
FileCode2,
XCircle,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -518,6 +519,11 @@ 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')
@@ -531,7 +537,9 @@ function TargetCombobox({
if (agent)
return (
<span className="flex items-center gap-1.5">
{agent.kind === 'pipeline' ? (
{agent.kind === 'event_processor' ? (
<FileCode2 className="size-3.5" />
) : agent.kind === 'pipeline' ? (
<Workflow className="size-3.5" />
) : (
<Bot className="size-3.5" />
@@ -585,6 +593,26 @@ 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) => (
@@ -730,13 +730,16 @@ function NavItems({
const showAgentGroupHeaders =
isAgents && !inPopover && sidebarData.agentsGroupByKind;
const agentGroupOrder: Array<'agent' | 'pipeline'> = [
'agent',
'pipeline',
];
const agentGroupLabelKey: Record<'agent' | 'pipeline', string> = {
const agentGroupOrder: Array<
'agent' | 'pipeline' | 'event_processor'
> = ['agent', 'pipeline', 'event_processor'];
const agentGroupLabelKey: Record<
'agent' | 'pipeline' | 'event_processor',
string
> = {
agent: 'agents.kindBadgeAgent',
pipeline: 'agents.kindBadgePipeline',
event_processor: 'agents.eventProcessor.type',
};
const groupOrder: Array<'plugin' | 'mcp' | 'skill'> = [
@@ -889,12 +892,16 @@ function NavItems({
<span
className="ml-auto flex shrink-0 items-center text-muted-foreground"
title={
item.kind === 'pipeline'
? t('agents.kindBadgePipeline')
: t('agents.kindBadgeAgent')
item.kind === 'event_processor'
? t('agents.eventProcessor.type')
: item.kind === 'pipeline'
? t('agents.kindBadgePipeline')
: t('agents.kindBadgeAgent')
}
>
{item.kind === 'pipeline' ? (
{item.kind === 'event_processor' ? (
<span className="text-xs"></span>
) : item.kind === 'pipeline' ? (
<Workflow className="size-3.5" />
) : (
<Bot className="size-3.5" />
@@ -31,7 +31,7 @@ export interface SidebarEntityItem {
// Set when this item appears in the unified extensions list
extensionType?: 'plugin' | 'mcp' | 'skill';
// Agent-specific: distinguishes Agent processors from Pipelines
kind?: 'agent' | 'pipeline';
kind?: 'agent' | 'pipeline' | 'event_processor';
}
// Plugin page registered by a plugin