mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-17 07:17:18 +00:00
feat(processors): add explicitly bound plugin event processors
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user