mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-26 14:26:06 +00:00
feat(agent): add event orchestration surface
This commit is contained in:
@@ -14,10 +14,11 @@ import { UUID } from 'uuidjs';
|
||||
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { systemInfo } from '@/app/infra/http';
|
||||
import { Bot } from '@/app/infra/entities/api';
|
||||
import { Agent, Bot } from '@/app/infra/entities/api';
|
||||
import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import RoutingRulesEditor from './RoutingRulesEditor';
|
||||
import EventBindingsEditor from './EventBindingsEditor';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
@@ -88,6 +89,21 @@ const getFormSchema = (t: (key: string) => string) =>
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
event_bindings: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().optional(),
|
||||
event_pattern: z.string(),
|
||||
target_type: z.enum(['agent', 'pipeline', 'discard']),
|
||||
target_uuid: z.string(),
|
||||
filters: z.array(z.record(z.string(), z.any())).optional(),
|
||||
priority: z.number(),
|
||||
enabled: z.boolean(),
|
||||
description: z.string().optional(),
|
||||
order: z.number().optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export default function BotForm({
|
||||
@@ -114,6 +130,7 @@ export default function BotForm({
|
||||
enable: true,
|
||||
use_pipeline_uuid: '',
|
||||
pipeline_routing_rules: [],
|
||||
event_bindings: [],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -133,10 +150,14 @@ export default function BotForm({
|
||||
const [adapterHelpLinks, setAdapterHelpLinks] = useState<
|
||||
Record<string, Record<string, string>>
|
||||
>({});
|
||||
const [adapterSupportedEvents, setAdapterSupportedEvents] = useState<
|
||||
Record<string, string[]>
|
||||
>({});
|
||||
|
||||
const [pipelineNameList, setPipelineNameList] = useState<IPipelineEntity[]>(
|
||||
[],
|
||||
);
|
||||
const [agentNameList, setAgentNameList] = useState<Agent[]>([]);
|
||||
|
||||
const [dynamicFormConfigList, setDynamicFormConfigList] = useState<
|
||||
IDynamicFormItemSchema[]
|
||||
@@ -181,6 +202,7 @@ export default function BotForm({
|
||||
enable: val.enable,
|
||||
use_pipeline_uuid: val.use_pipeline_uuid || '',
|
||||
pipeline_routing_rules: val.pipeline_routing_rules || [],
|
||||
event_bindings: val.event_bindings || [],
|
||||
});
|
||||
handleAdapterSelect(val.adapter);
|
||||
|
||||
@@ -220,6 +242,9 @@ export default function BotForm({
|
||||
}),
|
||||
);
|
||||
|
||||
const agentsRes = await httpClient.getAgents();
|
||||
setAgentNameList(agentsRes.agents);
|
||||
|
||||
const adaptersRes = await httpClient.getAdapters();
|
||||
setAdapterNameList(
|
||||
adaptersRes.adapters.map((item) => {
|
||||
@@ -253,6 +278,16 @@ export default function BotForm({
|
||||
),
|
||||
);
|
||||
|
||||
setAdapterSupportedEvents(
|
||||
adaptersRes.adapters.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.name] = item.spec.supported_events || [];
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string[]>,
|
||||
),
|
||||
);
|
||||
|
||||
adaptersRes.adapters.forEach((rawAdapter) => {
|
||||
adapterNameToDynamicConfigMap.set(
|
||||
rawAdapter.name,
|
||||
@@ -298,6 +333,7 @@ export default function BotForm({
|
||||
enable: bot.enable ?? true,
|
||||
use_pipeline_uuid: bot.use_pipeline_uuid ?? '',
|
||||
pipeline_routing_rules: bot.pipeline_routing_rules ?? [],
|
||||
event_bindings: bot.event_bindings ?? [],
|
||||
webhook_full_url: runtimeValues?.webhook_full_url as
|
||||
| string
|
||||
| undefined,
|
||||
@@ -343,6 +379,7 @@ export default function BotForm({
|
||||
enable: form.getValues().enable,
|
||||
use_pipeline_uuid: form.getValues().use_pipeline_uuid,
|
||||
pipeline_routing_rules: form.getValues().pipeline_routing_rules ?? [],
|
||||
event_bindings: form.getValues().event_bindings ?? [],
|
||||
};
|
||||
httpClient
|
||||
.updateBot(initBotId, updateBot)
|
||||
@@ -503,7 +540,26 @@ export default function BotForm({
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Card 3: Adapter Configuration */}
|
||||
{/* Card 3: Event Orchestration (edit mode only) */}
|
||||
{initBotId && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('bots.eventOrchestration')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('bots.eventOrchestrationDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<EventBindingsEditor
|
||||
form={form}
|
||||
supportedEvents={adapterSupportedEvents[currentAdapter] || []}
|
||||
agentOptions={agentNameList}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Card 4: Adapter Configuration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('bots.adapterConfig')}</CardTitle>
|
||||
@@ -561,7 +617,10 @@ export default function BotForm({
|
||||
</SelectLabel>
|
||||
)}
|
||||
{group.items.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
<SelectItem
|
||||
key={`${group.categoryId ?? 'uncategorized'}:${item.value}`}
|
||||
value={item.value}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={httpClient.getAdapterIconURL(
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { UseFormReturn } from 'react-hook-form';
|
||||
import { Ban, Bot, Plus, Trash2, Workflow } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { FormLabel } from '@/components/ui/form';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { EventBinding, Agent, AgentKind } from '@/app/infra/entities/api';
|
||||
|
||||
interface EventBindingsEditorProps {
|
||||
form: UseFormReturn<any>;
|
||||
supportedEvents: string[];
|
||||
agentOptions: Agent[];
|
||||
}
|
||||
|
||||
const DEFAULT_EVENTS = [
|
||||
'message.received',
|
||||
'feedback.received',
|
||||
'group.member_joined',
|
||||
'group.member_left',
|
||||
'friend.request_received',
|
||||
'bot.invited_to_group',
|
||||
'platform.specific',
|
||||
];
|
||||
|
||||
function isMessageEventPattern(pattern: string): boolean {
|
||||
return pattern === 'message.*' || pattern.startsWith('message.');
|
||||
}
|
||||
|
||||
function eventPatternCovers(
|
||||
supportedPattern: string,
|
||||
bindingPattern: string,
|
||||
): boolean {
|
||||
if (supportedPattern === '*') return true;
|
||||
if (supportedPattern === bindingPattern) return true;
|
||||
if (bindingPattern === '*') return false;
|
||||
if (supportedPattern.endsWith('.*')) {
|
||||
const namespace = supportedPattern.replace('.*', '');
|
||||
return (
|
||||
bindingPattern === `${namespace}.*` ||
|
||||
bindingPattern.startsWith(`${namespace}.`)
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function agentSupportsEventPattern(
|
||||
agent: Agent,
|
||||
bindingPattern: string,
|
||||
): boolean {
|
||||
const patterns = agent.supported_event_patterns ??
|
||||
agent.capability?.supported_event_patterns ?? ['*'];
|
||||
return patterns.some((pattern) =>
|
||||
eventPatternCovers(pattern, bindingPattern),
|
||||
);
|
||||
}
|
||||
|
||||
function eventNamespaces(events: string[]): string[] {
|
||||
const namespaces = new Set<string>();
|
||||
for (const event of events) {
|
||||
const namespace = event.split('.')[0];
|
||||
if (namespace) namespaces.add(`${namespace}.*`);
|
||||
}
|
||||
return Array.from(namespaces).sort();
|
||||
}
|
||||
|
||||
function targetLabel(agent: Agent): string {
|
||||
return `${agent.emoji ? `${agent.emoji} ` : ''}${agent.name}`;
|
||||
}
|
||||
|
||||
export default function EventBindingsEditor({
|
||||
form,
|
||||
supportedEvents,
|
||||
agentOptions,
|
||||
}: EventBindingsEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const bindings: EventBinding[] = form.watch('event_bindings') || [];
|
||||
|
||||
const eventOptions = useMemo(() => {
|
||||
const concreteEvents =
|
||||
supportedEvents.length > 0 ? supportedEvents : DEFAULT_EVENTS;
|
||||
return ['*', ...eventNamespaces(concreteEvents), ...concreteEvents].filter(
|
||||
(event, index, list) => list.indexOf(event) === index,
|
||||
);
|
||||
}, [supportedEvents]);
|
||||
|
||||
function updateBindings(nextBindings: EventBinding[]) {
|
||||
form.setValue('event_bindings', nextBindings, { shouldDirty: true });
|
||||
}
|
||||
|
||||
function addBinding() {
|
||||
updateBindings([
|
||||
...bindings,
|
||||
{
|
||||
event_pattern: 'message.received',
|
||||
target_type: 'agent',
|
||||
target_uuid: '',
|
||||
priority: bindings.length,
|
||||
enabled: true,
|
||||
description: '',
|
||||
filters: [],
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function updateBinding(index: number, patch: Partial<EventBinding>) {
|
||||
const updated = [...bindings];
|
||||
updated[index] = { ...updated[index], ...patch };
|
||||
updateBindings(updated);
|
||||
}
|
||||
|
||||
function removeBinding(index: number) {
|
||||
const updated = [...bindings];
|
||||
updated.splice(index, 1);
|
||||
updateBindings(updated);
|
||||
}
|
||||
|
||||
function getTargetOptions(binding: EventBinding, kind: AgentKind): Agent[] {
|
||||
return agentOptions.filter((agent) => {
|
||||
if (agent.kind !== kind) return false;
|
||||
if (kind === 'pipeline') {
|
||||
return isMessageEventPattern(binding.event_pattern);
|
||||
}
|
||||
if (kind === 'agent') {
|
||||
return agentSupportsEventPattern(agent, binding.event_pattern);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<FormLabel>{t('bots.eventBindings')}</FormLabel>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t('bots.eventOrchestrationDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addBinding}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{t('bots.addEventBinding')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{bindings.length === 0 && (
|
||||
<div className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
|
||||
{t('bots.noEventBindings')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{bindings.map((binding, index) => {
|
||||
const pipelineAllowed = isMessageEventPattern(binding.event_pattern);
|
||||
const targetType = binding.target_type || 'agent';
|
||||
const targetOptions =
|
||||
targetType === 'discard'
|
||||
? []
|
||||
: getTargetOptions(binding, targetType as AgentKind);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={binding.id ?? index}
|
||||
className="grid gap-2 rounded-md border bg-muted/30 p-3 lg:grid-cols-[1.2fr_0.9fr_1.4fr_80px_72px_36px]"
|
||||
>
|
||||
<Select
|
||||
value={binding.event_pattern}
|
||||
onValueChange={(eventPattern) => {
|
||||
const patch: Partial<EventBinding> = {
|
||||
event_pattern: eventPattern,
|
||||
};
|
||||
if (
|
||||
binding.target_type === 'pipeline' &&
|
||||
!isMessageEventPattern(eventPattern)
|
||||
) {
|
||||
patch.target_type = 'agent';
|
||||
patch.target_uuid = '';
|
||||
}
|
||||
updateBinding(index, patch);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t('bots.eventPatternPlaceholder')}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{eventOptions.map((event) => (
|
||||
<SelectItem key={event} value={event}>
|
||||
{event === '*'
|
||||
? t('bots.eventWildcard')
|
||||
: event.endsWith('.*')
|
||||
? t('bots.eventNamespaceWildcard', {
|
||||
namespace: event.replace('.*', ''),
|
||||
})
|
||||
: event}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={targetType}
|
||||
onValueChange={(nextType) => {
|
||||
updateBinding(index, {
|
||||
target_type: nextType as EventBinding['target_type'],
|
||||
target_uuid: '',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="agent">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="size-3.5" />
|
||||
{t('bots.targetAgent')}
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="pipeline" disabled={!pipelineAllowed}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Workflow className="size-3.5" />
|
||||
{t('bots.targetPipeline')}
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectSeparator />
|
||||
<SelectItem value="discard">
|
||||
<div className="flex items-center gap-2 text-destructive">
|
||||
<Ban className="size-3.5" />
|
||||
{t('bots.targetDiscard')}
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{targetType === 'discard' ? (
|
||||
<div className="flex items-center rounded-md border bg-background px-3 text-sm text-destructive">
|
||||
<Ban className="mr-2 size-3.5" />
|
||||
{t('bots.targetDiscard')}
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={binding.target_uuid}
|
||||
onValueChange={(targetUuid) =>
|
||||
updateBinding(index, { target_uuid: targetUuid })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('bots.selectTarget')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{targetOptions.map((agent) => (
|
||||
<SelectItem key={agent.uuid} value={agent.uuid || ''}>
|
||||
{targetLabel(agent)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
<Input
|
||||
type="number"
|
||||
value={binding.priority ?? 0}
|
||||
onChange={(event) =>
|
||||
updateBinding(index, {
|
||||
priority: Number(event.target.value || 0),
|
||||
})
|
||||
}
|
||||
aria-label={t('bots.priority')}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-center rounded-md border bg-background">
|
||||
<Switch
|
||||
checked={binding.enabled ?? true}
|
||||
onCheckedChange={(enabled) =>
|
||||
updateBinding(index, { enabled })
|
||||
}
|
||||
aria-label={t('bots.enabled')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeBinding(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
|
||||
{!pipelineAllowed && binding.target_type === 'pipeline' && (
|
||||
<div className="lg:col-span-6 text-xs text-destructive">
|
||||
{t('bots.unsupportedPipelineEvent')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user