feat(processors): refine plugin processor configuration and presentation

This commit is contained in:
RockChinQ
2026-09-09 15:37:12 +08:00
parent ea3e32c904
commit 5ba25c39af
18 changed files with 477 additions and 319 deletions
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { eventPatternLabel } from '@/app/home/components/event-patterns/event-pattern-groups';
import { RefreshCw, Trash2, ScrollText } from 'lucide-react';
import { RefreshCw, Trash2, ScrollText, Settings2 } from 'lucide-react';
import isEqual from 'lodash/isEqual';
import { toast } from 'sonner';
import type {
@@ -26,6 +26,7 @@ import EventProcessorTrace, {
} from './components/EventProcessorTrace';
import ProcessorRunList from './components/ProcessorRunList';
import EventProcessorSettings from './components/EventProcessorSettings';
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
export default function EventProcessorDetailContent({
agent,
@@ -47,6 +48,7 @@ export default function EventProcessorDetailContent({
onSaved: () => void;
}) {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState('config');
const [platformTools, setPlatformTools] = useState<AgentPlatformTool[]>([]);
const toolLabels = Object.fromEntries(
platformTools.map((tool) => [tool.name, extractI18nObject(tool.label)]),
@@ -235,13 +237,11 @@ export default function EventProcessorDetailContent({
}, [id, selected, eventCursor, events]);
async function save() {
if (
!canManage ||
saving ||
!component ||
!((await validate.current?.()) ?? true)
)
if (!canManage || saving || !component) return false;
if (!((await validate.current?.()) ?? true)) {
setActiveTab('config');
return false;
}
setSaving(true);
try {
await httpClient.updateAgent(id, {
@@ -265,9 +265,126 @@ export default function EventProcessorDetailContent({
}
}
const logsContent = (
<div className="flex h-full min-h-0 flex-col gap-3">
<form
id="event-processor-form"
onSubmit={(event) => {
event.preventDefault();
void save();
}}
/>
{failed && (
<Alert variant="destructive">
<AlertDescription>
{t('agents.eventProcessor.loadError')}
</AlertDescription>
</Alert>
)}
<div className="flex shrink-0 items-center justify-between gap-2">
<span className="text-sm font-medium">
{t('agents.eventProcessor.runs')}{' '}
<span className="text-muted-foreground">({runs.length})</span>
</span>
<Button
variant="ghost"
size="icon"
aria-label={t('agents.eventProcessor.refresh')}
onClick={() => void refreshLatestRun()}
>
<RefreshCw className="size-4" />
</Button>
</div>
{runs.length > 0 && (
<ProcessorRunList
runs={runs}
selectedId={selected?.run_id}
onSelect={(run) => void openRun(run)}
footer={
cursor !== null ? (
<Button
className="w-full"
variant="ghost"
disabled={pagingRuns}
onClick={() => void loadMoreRuns()}
>
{t('agents.eventProcessor.loadMore')}
</Button>
) : undefined
}
/>
)}
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-2 pr-3">
{!selected ? (
<Alert>
<AlertDescription>
{loading
? t('common.loading')
: t('agents.eventProcessor.noRuns')}
<Button asChild variant="link" className="h-auto px-0">
<Link to="/home/bots">
{t('agents.eventProcessor.bindBot')}
</Link>
</Button>
</AlertDescription>
</Alert>
) : (
<>
<div className="border-b pb-2">
<p className="text-sm font-medium">
{eventPatternLabel(selected.metadata.event_type ?? '', t)}
</p>
<p className="text-xs text-muted-foreground">
{new Date(selected.created_at * 1000).toLocaleString()}
</p>
</div>
<Badge
variant={
selected.status === 'failed' ? 'destructive' : 'outline'
}
>
{t(`agents.eventProcessor.status_${selected.status}`, {
defaultValue: selected.status,
})}
</Badge>
<ProcessorPayload
title={t('agents.eventProcessor.input')}
value={selected.metadata.input_event}
/>
{selected.metadata.delivery != null && (
<ProcessorPayload
title={t('agents.eventProcessor.destination')}
value={selected.metadata.delivery}
/>
)}
<EventProcessorTrace events={events} toolLabels={toolLabels} />
{selected.status === 'failed' && selected.status_reason && (
<Alert variant="destructive">
<AlertDescription className="break-words">
{selected.status_reason}
</AlertDescription>
</Alert>
)}
{eventCursor !== null && (
<Button
variant="ghost"
disabled={pagingEvents}
onClick={() => void loadMoreEvents()}
>
{t('agents.eventProcessor.loadMore')}
</Button>
)}
</>
)}
</div>
</ScrollArea>
</div>
);
return (
<ProcessorDetailWorkbench
title={`${agent.emoji || ''} ${agent.name}`}
title={`${agent.emoji || '🧩'} ${agent.name}`}
titleAction={
canManage ? <EntityTitleEditButton onClick={onEdit} /> : undefined
}
@@ -275,10 +392,10 @@ export default function EventProcessorDetailContent({
<EventProcessorSettings
components={components}
value={componentRef}
parameters={parameters}
disabled={!canManage || saving || loading}
onChange={(value) => {
setComponentRef(value);
setActiveTab('config');
const descriptor = components.find((item) => item.id === value);
setParameters(
Object.fromEntries(
@@ -289,10 +406,6 @@ export default function EventProcessorDetailContent({
);
validate.current = null;
}}
onParametersChange={setParameters}
onValidate={(fn) => {
validate.current = fn;
}}
/>
}
status={
@@ -313,127 +426,51 @@ export default function EventProcessorDetailContent({
</Button>
) : undefined
}
configTitle={t('agents.eventProcessor.trace')}
configIcon={<ScrollText className="size-4" />}
configContent={
<div className="flex h-full min-h-0 flex-col gap-3">
<form
id="event-processor-form"
onSubmit={(event) => {
event.preventDefault();
void save();
}}
/>
{failed && (
<Alert variant="destructive">
<AlertDescription>
{t('agents.eventProcessor.loadError')}
</AlertDescription>
</Alert>
)}
<div className="flex shrink-0 items-center justify-between gap-2">
<span className="text-sm font-medium">
{t('agents.eventProcessor.runs')}{' '}
<span className="text-muted-foreground">({runs.length})</span>
</span>
<Button
variant="ghost"
size="icon"
aria-label={t('agents.eventProcessor.refresh')}
onClick={() => void refreshLatestRun()}
>
<RefreshCw className="size-4" />
</Button>
</div>
{runs.length > 0 && (
<ProcessorRunList
runs={runs}
selectedId={selected?.run_id}
onSelect={(run) => void openRun(run)}
footer={
cursor !== null ? (
<Button
className="w-full"
variant="ghost"
disabled={pagingRuns}
onClick={() => void loadMoreRuns()}
>
{t('agents.eventProcessor.loadMore')}
</Button>
) : undefined
}
/>
)}
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-2 pr-3">
{!selected ? (
<Alert>
<AlertDescription>
{loading
? t('common.loading')
: t('agents.eventProcessor.noRuns')}
<Button asChild variant="link" className="h-auto px-0">
<Link to="/home/bots">
{t('agents.eventProcessor.bindBot')}
</Link>
</Button>
</AlertDescription>
</Alert>
) : (
<>
<div className="border-b pb-2">
<p className="text-sm font-medium">
{eventPatternLabel(selected.metadata.event_type ?? '', t)}
</p>
<p className="text-xs text-muted-foreground">
{new Date(selected.created_at * 1000).toLocaleString()}
</p>
</div>
<Badge
variant={
selected.status === 'failed' ? 'destructive' : 'outline'
}
>
{t(`agents.eventProcessor.status_${selected.status}`, {
defaultValue: selected.status,
})}
</Badge>
<ProcessorPayload
title={t('agents.eventProcessor.input')}
value={selected.metadata.input_event}
/>
{selected.metadata.delivery != null && (
<ProcessorPayload
title={t('agents.eventProcessor.destination')}
value={selected.metadata.delivery}
configTitle={t('agents.eventProcessor.type')}
configTabs={{
value: activeTab,
onValueChange: setActiveTab,
items: [
{
value: 'config',
label: t('agents.eventProcessor.configTab'),
icon: <Settings2 className="size-4" />,
content: (
<div className="h-full overflow-y-auto">
{!component ? (
<p className="text-sm text-muted-foreground">
{t('agents.eventProcessor.selectComponent')}
</p>
) : component.config_schema.length === 0 ? (
<p className="text-sm text-muted-foreground">
{t('agents.eventProcessor.noSettings')}
</p>
) : (
<fieldset disabled={!canManage || saving}>
<DynamicFormComponent
key={componentRef}
itemConfigList={component.config_schema}
initialValues={parameters}
onSubmit={(values) =>
setParameters(values as Record<string, unknown>)
}
onValidate={(fn) => {
validate.current = fn;
}}
/>
)}
<EventProcessorTrace
events={events}
toolLabels={toolLabels}
/>
{selected.status === 'failed' && selected.status_reason && (
<Alert variant="destructive">
<AlertDescription className="break-words">
{selected.status_reason}
</AlertDescription>
</Alert>
)}
{eventCursor !== null && (
<Button
variant="ghost"
disabled={pagingEvents}
onClick={() => void loadMoreEvents()}
>
{t('agents.eventProcessor.loadMore')}
</Button>
)}
</>
)}
</div>
</ScrollArea>
</div>
}
</fieldset>
)}
</div>
),
},
{
value: 'logs',
label: t('agents.eventProcessor.logsTab'),
icon: <ScrollText className="size-4" />,
content: logsContent,
},
],
}}
debugTitle={canOperate ? t('agents.debugTab') : undefined}
debugDescription={t('agents.eventProcessor.debugNotice')}
debugContent={
@@ -452,6 +489,7 @@ export default function EventProcessorDetailContent({
hasUnsavedChanges={dirty}
beforeRun={save}
onRunFinished={() => {
setActiveTab('logs');
void refreshLatestRun();
}}
supportedEventPatterns={component.supported_event_patterns}
@@ -4,7 +4,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Bot, Workflow, FileCode2 } from 'lucide-react';
import { Bot, Workflow, Puzzle } from 'lucide-react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { AgentKind } from '@/app/infra/entities/api';
import { Button } from '@/components/ui/button';
@@ -52,12 +52,12 @@ export default function AgentCreateContent({
function handleKindChange(nextKind: AgentKind) {
const previousDefaultEmoji =
kind === 'pipeline' ? '⚙️' : kind === 'event_processor' ? '' : '🤖';
kind === 'pipeline' ? '⚙️' : kind === 'event_processor' ? '🧩' : '🤖';
const nextDefaultEmoji =
nextKind === 'pipeline'
? '⚙️'
: nextKind === 'event_processor'
? ''
? '🧩'
: '🤖';
setKind(nextKind);
const currentEmoji = form.getValues('emoji');
@@ -72,7 +72,13 @@ export default function AgentCreateContent({
kind,
name: values.name,
description: values.description ?? '',
emoji: values.emoji || (kind === 'pipeline' ? '⚙️' : '🤖'),
emoji:
values.emoji ||
(kind === 'pipeline'
? '⚙️'
: kind === 'event_processor'
? '🧩'
: '🤖'),
})
.then((resp) => {
toast.success(t('agents.createSuccess'));
@@ -98,7 +104,7 @@ export default function AgentCreateContent({
},
{
kind: 'event_processor' as const,
icon: FileCode2,
icon: Puzzle,
title: t('agents.eventProcessor.type'),
description: t('agents.eventProcessor.description'),
},
@@ -107,13 +113,19 @@ export default function AgentCreateContent({
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>
<h1 className="text-xl font-semibold">
{t('agents.eventProcessor.createPageTitle')}
</h1>
<Button
type="submit"
form="agent-create-form"
disabled={form.formState.isSubmitting}
>
{t('common.submit')}
{t(
kind === 'event_processor'
? 'agents.eventProcessor.create'
: 'common.submit',
)}
</Button>
</div>
@@ -396,19 +396,15 @@ export default function AgentDebugPanel({
</p>
</div>
{entries.length === 0 ? (
<Alert className="my-4 bg-muted/20">
<CircleHelp className="size-4" />
<AlertTitle>
{t(processor ? 'agents.debugTab' : 'agents.debugEmptyTitle')}
</AlertTitle>
<AlertDescription>
{t(
processor
? 'agents.eventProcessor.debugDescription'
: 'agents.debugEmptyTranscript',
)}
</AlertDescription>
</Alert>
!processor && (
<Alert className="my-4 bg-muted/20">
<CircleHelp className="size-4" />
<AlertTitle>{t('agents.debugEmptyTitle')}</AlertTitle>
<AlertDescription>
{t('agents.debugEmptyTranscript')}
</AlertDescription>
</Alert>
)
) : (
<div className="space-y-3">
{entries
@@ -1,10 +1,4 @@
import { useState } from 'react';
import { Settings2, Puzzle } from 'lucide-react';
import {
Popover,
PopoverTrigger,
PopoverContent,
} from '@/components/ui/popover';
import { Puzzle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import type { EventProcessorDescriptor } from '@/app/infra/entities/api';
@@ -19,7 +13,6 @@ import {
} from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
function ProcessorComponentContent({
component,
@@ -72,48 +65,25 @@ function ProcessorComponentContent({
export default function EventProcessorSettings({
components,
value,
parameters,
onChange,
onParametersChange,
onValidate,
disabled = false,
}: {
components: EventProcessorDescriptor[];
value: string;
parameters: Record<string, unknown>;
onChange: (value: string) => void;
onParametersChange: (value: Record<string, unknown>) => void;
onValidate?: (validate: () => Promise<boolean>) => void;
disabled?: boolean;
}) {
const { t } = useTranslation();
const selected = components.find((item) => item.id === value);
const [settingsOpen, setSettingsOpen] = useState(false);
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={(next) => {
onChange(next);
const component = components.find((item) => item.id === next);
setSettingsOpen(
Boolean(
component?.config_schema.some(
(field) =>
field.required &&
(field.default == null || field.default === ''),
),
),
);
}}
>
<Select value={value} disabled={disabled} onValueChange={onChange}>
<SelectTrigger
id="event-processor-component"
className="w-[22rem] max-w-[calc(100vw-8rem)] bg-[#ffffff] dark:bg-[#2a2a2e]"
className="w-[13.2rem] max-w-[calc(100vw-8rem)] bg-[#ffffff] dark:bg-[#2a2a2e]"
>
{selected ? (
<ProcessorComponentContent component={selected} />
@@ -153,44 +123,6 @@ export default function EventProcessorSettings({
)}
</SelectContent>
</Select>
{selected && selected.config_schema.length > 0 && (
<Popover open={settingsOpen} onOpenChange={setSettingsOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
disabled={disabled}
aria-label={t('agents.eventProcessor.pluginSettings')}
title={t('agents.eventProcessor.pluginSettings')}
>
<Settings2 className="size-4" />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="max-h-[70vh] overflow-y-auto space-y-3"
>
<p className="text-sm font-medium">
{t('agents.eventProcessor.pluginSettings')}
</p>
<p className="text-xs text-muted-foreground">
{t('agents.eventProcessor.pluginSettingsDescription')}
</p>
<fieldset disabled={disabled}>
<DynamicFormComponent
key={value}
itemConfigList={selected.config_schema}
initialValues={parameters}
onSubmit={(values) =>
onParametersChange(values as Record<string, unknown>)
}
onValidate={onValidate}
/>
</fieldset>
</PopoverContent>
</Popover>
)}
</div>
);
}
@@ -373,18 +373,38 @@ function PipelineDiagram() {
);
}
export default function ProcessorTypeDiagram({ kind }: { kind: AgentKind }) {
function EventProcessorDiagram() {
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">
const codeLines = [
<>
<span className="text-[#8b5cf6]">@handler</span>
</>,
<>
<span className="text-[#2288ee]">async def</span>{' '}
<span className="text-[#19b8c9]">on_event</span>(event):
</>,
<>
<span className="text-[#2288ee]">if</span> event.type =={' '}
<span className="text-amber-500">&quot;message&quot;</span>:
</>,
<>
<span className="text-[#2288ee]">await</span> event.process()
</>,
<>
<span className="text-muted-foreground"># output trace</span>
</>,
];
return (
<div
className="processor-diagram grid h-full w-full overflow-hidden bg-muted/20 lg:grid-cols-[minmax(0,1.05fr)_minmax(280px,0.95fr)]"
data-testid="event-processor-diagram"
>
<div className="flex min-w-0 flex-col justify-center gap-5 p-8 lg:p-10">
<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) => (
{['input', 'processWithPlugin', 'trace'].map((step, index) => (
<div
key={step}
className="flex items-center gap-3 rounded-lg border bg-background p-4"
@@ -393,10 +413,52 @@ export default function ProcessorTypeDiagram({ kind }: { kind: AgentKind }) {
<span>{t(`agents.eventProcessor.${step}`)}</span>
</div>
))}
<p className="text-sm text-muted-foreground">
{t('agents.eventProcessor.activation')}
</p>
</div>
);
<div
className="relative hidden min-w-0 items-center border-l bg-background/45 p-8 lg:flex"
aria-hidden="true"
>
<div className="absolute left-0 top-1/2 w-8 -translate-y-1/2 border-t border-dashed border-[#2288ee]/25" />
<div className="relative w-full rounded-xl border bg-card">
<div className="flex h-11 items-center gap-2 border-b px-4">
<span className="font-mono text-[11px] text-muted-foreground">
processor.py
</span>
</div>
<div className="space-y-3 px-4 py-5 font-mono text-[12px] leading-5">
{codeLines.map((line, index) => (
<div key={index} className="flex min-w-0 gap-3">
<span className="w-4 shrink-0 select-none text-right text-muted-foreground/45">
{index + 1}
</span>
<code
className={
index === 2 || index === 3
? 'min-w-0 pl-3 text-foreground/80'
: 'min-w-0 text-foreground/80'
}
>
{line}
</code>
</div>
))}
</div>
<div className="mx-4 mb-4 border-t pt-3">
<span className="font-mono text-[11px] text-muted-foreground">
event plugin log
</span>
</div>
</div>
</div>
</div>
);
}
export default function ProcessorTypeDiagram({ kind }: { kind: AgentKind }) {
if (kind === 'event_processor') return <EventProcessorDiagram />;
return kind === 'agent' ? <AgentDiagram /> : <PipelineDiagram />;
}
@@ -37,7 +37,17 @@ interface ProcessorDetailWorkbenchProps {
isSaving: boolean;
configTitle: string;
configIcon?: ReactNode;
configContent: ReactNode;
configContent?: ReactNode;
configTabs?: {
value: string;
onValueChange: (value: string) => void;
items: {
value: string;
label: string;
icon?: ReactNode;
content: ReactNode;
}[];
};
debugTitle?: string;
debugDescription?: string;
debugContent?: ReactNode;
@@ -63,6 +73,7 @@ export default function ProcessorDetailWorkbench({
configTitle,
configIcon,
configContent,
configTabs,
debugTitle,
debugDescription,
debugContent,
@@ -77,6 +88,52 @@ export default function ProcessorDetailWorkbench({
);
const hasDebug = Boolean(debugTitle && debugContent);
const configPanel = (
<Card
role="region"
aria-label={configTitle}
className="h-full min-h-[36rem] min-w-0 gap-0 overflow-hidden py-0 lg:min-h-0"
>
<CardHeader className="flex h-12 shrink-0 flex-row items-center gap-2 border-b px-4 font-medium [.border-b]:pb-0">
{configTabs ? (
<TabsList aria-label={configTitle}>
{configTabs.items.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
{tab.icon}
{tab.label}
</TabsTrigger>
))}
</TabsList>
) : (
<>
{configIcon ?? <Settings className="size-4" />}
<span className="truncate">{configTitle}</span>
</>
)}
{isDirty && (
<span className="ml-auto flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-400">
<span className="size-1.5 rounded-full bg-amber-500" />
{unsavedLabel}
</span>
)}
</CardHeader>
<CardContent className="min-h-0 min-w-0 flex-1 overflow-hidden p-4">
{configTabs
? configTabs.items.map((tab) => (
<TabsContent
key={tab.value}
value={tab.value}
forceMount
className="m-0 h-full min-h-0 data-[state=inactive]:hidden"
>
{tab.content}
</TabsContent>
))
: configContent}
</CardContent>
</Card>
);
return (
<Tabs
value={activeView}
@@ -246,25 +303,17 @@ export default function ProcessorDetailWorkbench({
</Card>
)}
<Card
role="region"
aria-label={configTitle}
className="min-h-[36rem] min-w-0 gap-0 overflow-hidden py-0 lg:min-h-0"
>
<CardHeader className="flex h-12 shrink-0 flex-row items-center gap-2 border-b px-4 font-medium [.border-b]:pb-0">
{configIcon ?? <Settings className="size-4" />}
<span className="truncate">{configTitle}</span>
{isDirty && (
<span className="ml-auto flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-400">
<span className="size-1.5 rounded-full bg-amber-500" />
{unsavedLabel}
</span>
)}
</CardHeader>
<CardContent className="min-h-0 min-w-0 flex-1 overflow-hidden p-4">
{configContent}
</CardContent>
</Card>
{configTabs ? (
<Tabs
value={configTabs.value}
onValueChange={configTabs.onValueChange}
className="min-h-0 min-w-0"
>
{configPanel}
</Tabs>
) : (
configPanel
)}
</div>
</TabsContent>
</Tabs>