+
+

-
{item.label}
+
+ {item.label}
+
))}
))}
+ {legacyAdapters.length > 0 && (
+ <>
+
{
+ e.preventDefault();
+ e.stopPropagation();
+ setShowLegacyAdapters((v) => !v);
+ }}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ setShowLegacyAdapters((v) => !v);
+ }
+ }}
+ className="flex cursor-pointer items-center gap-1 px-2 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground border-t mt-1 pt-2"
+ >
+ {showLegacyAdapters ? (
+
+ ) : (
+
+ )}
+ {t('bots.legacyAdapters')}
+
+ {legacyAdapters.length}
+
+
+ {showLegacyAdapters && (
+ <>
+
+ {t('bots.legacyAdaptersHint')}
+
+
+ {legacyAdapters.map((item) => (
+
+
+

+
+ {item.label}
+
+
+ {t('bots.legacyAdapterBadge')}
+
+
+
+ ))}
+
+ >
+ )}
+ >
+ )}
{currentAdapter &&
@@ -628,6 +650,26 @@ export default function BotForm({
)}
+
+ {/* Card 3: Event Routing */}
+ {currentAdapter && (
+
+
+ {t('bots.eventRouting')}
+
+ {t('bots.eventRoutingDescription')}
+
+
+
+
+
+
+ )}
);
diff --git a/web/src/app/home/bots/components/bot-form/ChooseEntity.ts b/web/src/app/home/bots/components/bot-form/ChooseEntity.ts
index b7ba86968..c460f06b4 100644
--- a/web/src/app/home/bots/components/bot-form/ChooseEntity.ts
+++ b/web/src/app/home/bots/components/bot-form/ChooseEntity.ts
@@ -2,6 +2,7 @@ export interface IChooseAdapterEntity {
label: string;
value: string;
categories?: string[];
+ legacy?: boolean;
}
export interface IPipelineEntity {
diff --git a/web/src/app/home/bots/components/bot-form/EventBindingsEditor.tsx b/web/src/app/home/bots/components/bot-form/EventBindingsEditor.tsx
new file mode 100644
index 000000000..bf0980508
--- /dev/null
+++ b/web/src/app/home/bots/components/bot-form/EventBindingsEditor.tsx
@@ -0,0 +1,1840 @@
+'use client';
+
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import type { TFunction } from 'i18next';
+import { UseFormReturn } from 'react-hook-form';
+import {
+ Activity,
+ AlertCircle,
+ ArrowRight,
+ Ban,
+ Bot,
+ Check,
+ CheckCircle2,
+ ChevronDown,
+ ChevronRight,
+ ChevronsUpDown,
+ GripVertical,
+ Info,
+ ListChecks,
+ MessageSquare,
+ Plus,
+ Play,
+ RefreshCw,
+ Shield,
+ Trash2,
+ UserCheck,
+ UserMinus,
+ UserPlus,
+ Workflow,
+ XCircle,
+} from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@/components/ui/popover';
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from '@/components/ui/command';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Alert, AlertDescription } from '@/components/ui/alert';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import {
+ DndContext,
+ DragOverlay,
+ closestCenter,
+ PointerSensor,
+ KeyboardSensor,
+ useSensor,
+ useSensors,
+ DragEndEvent,
+ DragStartEvent,
+} from '@dnd-kit/core';
+import {
+ arrayMove,
+ SortableContext,
+ sortableKeyboardCoordinates,
+ useSortable,
+ verticalListSortingStrategy,
+} from '@dnd-kit/sortable';
+import { CSS } from '@dnd-kit/utilities';
+import { Input } from '@/components/ui/input';
+import { Textarea } from '@/components/ui/textarea';
+import {
+ EventBinding,
+ Agent,
+ BotRouteDryRunResult,
+ BotEventRouteStatus,
+ BotRouteTestResult,
+} from '@/app/infra/entities/api';
+import { backendClient } from '@/app/infra/http';
+
+export const PIPELINE_DISCARD = '__discard__';
+
+// ── types ─────────────────────────────────────────────────────────────────────
+
+interface EventBindingsEditorProps {
+ form: UseFormReturn
;
+ botId?: string;
+ supportedEvents: string[];
+ agentOptions: Agent[];
+}
+
+type FilterField =
+ | 'chat_type'
+ | 'chat_id'
+ | 'message_text'
+ | 'message_element_types';
+type FilterOperator =
+ | 'eq'
+ | 'neq'
+ | 'contains'
+ | 'not_contains'
+ | 'starts_with'
+ | 'regex';
+
+interface FilterRow {
+ field: FilterField;
+ operator: FilterOperator;
+ value: string;
+}
+
+const OPERATORS_BY_FIELD: Record = {
+ chat_type: ['eq', 'neq'],
+ chat_id: ['eq', 'neq', 'contains', 'not_contains', 'regex'],
+ message_text: [
+ 'contains',
+ 'not_contains',
+ 'eq',
+ 'neq',
+ 'starts_with',
+ 'regex',
+ ],
+ message_element_types: ['contains', 'not_contains'],
+};
+
+const ELEMENTS = [
+ 'Image',
+ 'Voice',
+ 'File',
+ 'Forward',
+ 'Face',
+ 'At',
+ 'AtAll',
+ 'Quote',
+];
+
+// Adapters that don't declare `supported_events` (e.g. legacy adapters)
+// only emit message.received, so that's the sole fallback option.
+const DEFAULT_EVENTS = ['message.received'];
+
+const BEHAVIOR_PRESETS = [
+ {
+ eventType: 'message.received',
+ labelKey: 'bots.behaviorReplyMessages',
+ descriptionKey: 'bots.behaviorReplyMessagesDescription',
+ icon: MessageSquare,
+ },
+ {
+ eventType: 'group.member_joined',
+ labelKey: 'bots.behaviorWelcomeMembers',
+ descriptionKey: 'bots.behaviorWelcomeMembersDescription',
+ icon: UserPlus,
+ },
+ {
+ eventType: 'group.member_left',
+ labelKey: 'bots.behaviorHandleDepartures',
+ descriptionKey: 'bots.behaviorHandleDeparturesDescription',
+ icon: UserMinus,
+ },
+ {
+ eventType: 'friend.request_received',
+ labelKey: 'bots.behaviorReviewFriendRequests',
+ descriptionKey: 'bots.behaviorReviewFriendRequestsDescription',
+ icon: UserCheck,
+ },
+ {
+ eventType: 'group.member_banned',
+ labelKey: 'bots.behaviorHandleModeration',
+ descriptionKey: 'bots.behaviorHandleModerationDescription',
+ icon: Shield,
+ },
+];
+
+// ── helpers ───────────────────────────────────────────────────────────────────
+
+function isMessageEventPattern(p: string) {
+ return p === 'message.*' || p.startsWith('message.');
+}
+
+function eventPatternCovers(sup: string, bind: string) {
+ if (sup === '*') return true;
+ if (sup === bind) return true;
+ if (bind === '*') return false;
+ if (sup.endsWith('.*')) {
+ const ns = sup.replace('.*', '');
+ return bind === `${ns}.*` || bind.startsWith(`${ns}.`);
+ }
+ return false;
+}
+
+interface RouteConflict {
+ winnerIndex: number;
+ shadowedIndex: number;
+}
+
+function bindingFilters(binding: EventBinding) {
+ return binding.filters ?? [];
+}
+
+function filtersEqual(a: EventBinding, b: EventBinding) {
+ return (
+ JSON.stringify(bindingFilters(a)) === JSON.stringify(bindingFilters(b))
+ );
+}
+
+function routePrecedes(
+ a: EventBinding,
+ aIndex: number,
+ b: EventBinding,
+ bIndex: number,
+) {
+ const aPriority = Number.isFinite(a.priority) ? a.priority : 0;
+ const bPriority = Number.isFinite(b.priority) ? b.priority : 0;
+ return aPriority === bPriority ? aIndex < bIndex : aPriority > bPriority;
+}
+
+function findRouteConflicts(bindings: EventBinding[]): RouteConflict[] {
+ const enabled = bindings
+ .map((binding, index) => ({ binding, index }))
+ .filter(({ binding }) => binding.enabled ?? true);
+ const conflicts: RouteConflict[] = [];
+
+ for (let left = 0; left < enabled.length; left += 1) {
+ for (let right = left + 1; right < enabled.length; right += 1) {
+ const a = enabled[left];
+ const b = enabled[right];
+ const [winner, shadowed] = routePrecedes(
+ a.binding,
+ a.index,
+ b.binding,
+ b.index,
+ )
+ ? [a, b]
+ : [b, a];
+
+ if (
+ !eventPatternCovers(
+ winner.binding.event_pattern,
+ shadowed.binding.event_pattern,
+ )
+ ) {
+ continue;
+ }
+
+ if (
+ bindingFilters(winner.binding).length === 0 ||
+ filtersEqual(winner.binding, shadowed.binding)
+ ) {
+ conflicts.push({
+ winnerIndex: winner.index,
+ shadowedIndex: shadowed.index,
+ });
+ }
+ }
+ }
+
+ return conflicts;
+}
+
+function findCatchAllRouteIndex(bindings: EventBinding[]) {
+ const candidates = bindings
+ .map((binding, index) => ({ binding, index }))
+ .filter(
+ ({ binding }) =>
+ (binding.enabled ?? true) &&
+ binding.event_pattern === '*' &&
+ bindingFilters(binding).length === 0,
+ );
+ candidates.sort((a, b) =>
+ routePrecedes(a.binding, a.index, b.binding, b.index) ? -1 : 1,
+ );
+ return candidates[0]?.index ?? -1;
+}
+
+function agentSupportsEventPattern(agent: Agent, pattern: string) {
+ const patterns = agent.supported_event_patterns ??
+ agent.capability?.supported_event_patterns ?? ['*'];
+ return patterns.some((p) => eventPatternCovers(p, pattern));
+}
+
+function eventNamespaces(events: string[]) {
+ // Only surface a `ns.*` wildcard when the namespace actually has 2+
+ // concrete events — otherwise the wildcard is redundant with the single event.
+ const counts = new Map();
+ events.forEach((e) => {
+ const n = e.split('.')[0];
+ if (n) counts.set(n, (counts.get(n) ?? 0) + 1);
+ });
+ return Array.from(counts.entries())
+ .filter(([, c]) => c >= 2)
+ .map(([n]) => `${n}.*`)
+ .sort();
+}
+
+// Localized label for an event pattern. Concrete events look up
+// `bots.eventNames.`, falling back to the raw
+// string when no translation exists (e.g. custom/unknown events).
+function eventLabel(event: string, t: TFunction) {
+ if (event === '*') return t('bots.eventWildcard');
+ if (event.endsWith('.*'))
+ return t('bots.eventNamespaceWildcard', {
+ namespace: event.replace('.*', ''),
+ });
+ const key = `bots.eventNames.${event.replace(/\./g, '_')}`;
+ const label = t(key);
+ return label === key ? event : label;
+}
+
+function eventDescription(event: string, t: TFunction) {
+ if (event === '*') return t('bots.eventDescriptions.all');
+ if (event.endsWith('.*')) return t('bots.eventDescriptions.namespace');
+ const key = `bots.eventDescriptions.${event.replace(/\./g, '_')}`;
+ const description = t(key);
+ return description === key ? t('bots.eventDescriptions.custom') : description;
+}
+
+function targetLabel(agent: Agent) {
+ return `${agent.emoji ? `${agent.emoji} ` : ''}${agent.name}`;
+}
+
+function targetTypeLabel(
+ type: EventBinding['target_type'] | undefined,
+ t: TFunction,
+) {
+ if (type === 'pipeline') return t('bots.targetPipeline');
+ if (type === 'discard') return t('bots.targetDiscard');
+ return t('bots.targetAgent');
+}
+
+function routeStatusLabel(
+ status: BotEventRouteStatus['last_status'] | undefined,
+ t: TFunction,
+) {
+ if (!status) return t('bots.routeStatusIdle');
+ const key = `bots.routeStatus.${status}`;
+ const label = t(key);
+ return label === key ? String(status) : label;
+}
+
+function routeStatusBadgeClass(
+ status: BotEventRouteStatus['last_status'] | undefined,
+) {
+ if (status === 'delivered') {
+ return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/40 dark:bg-emerald-950/30 dark:text-emerald-300';
+ }
+ if (status === 'matched' || status === 'discarded') {
+ return 'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-900/40 dark:bg-sky-950/30 dark:text-sky-300';
+ }
+ if (status === 'failed' || status === 'not_matched') {
+ return 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/40 dark:bg-amber-950/30 dark:text-amber-300';
+ }
+ return 'border-border bg-muted/40 text-muted-foreground';
+}
+
+function localizedFailureReason(
+ failureCode: string | null | undefined,
+ fallback: string | null | undefined,
+ t: TFunction,
+) {
+ if (!failureCode) return fallback || '';
+ const key = `bots.routeFailure.${failureCode}`;
+ const label = t(key);
+ return label === key ? fallback || failureCode : label;
+}
+
+function routeStatusDetail(
+ status: BotEventRouteStatus | undefined,
+ t: TFunction,
+) {
+ if (!status) return '';
+ if (status.failure_code) {
+ return localizedFailureReason(status.failure_code, status.reason, t);
+ }
+ if (status.last_status) {
+ const key = `bots.routeStatusDetail.${status.last_status}`;
+ const label = t(key);
+ if (label !== key) return label;
+ }
+ return status.reason || formatRouteStatusTime(status.timestamp);
+}
+
+function diagnosticDetailLabel(
+ detail: Record,
+ fallbackIndex: number,
+ t: TFunction,
+) {
+ const order =
+ typeof detail.binding_index === 'number'
+ ? detail.binding_index
+ : typeof detail.order === 'number'
+ ? detail.order
+ : fallbackIndex;
+ const route = t('bots.dryRunRuleIndex', { index: order + 1 });
+ if (detail.selected) {
+ return t('bots.dryRunDiagnosticSelected', { route });
+ }
+ const failureCode =
+ typeof detail.failure_code === 'string' ? detail.failure_code : null;
+ const reason = localizedFailureReason(
+ failureCode,
+ typeof detail.reason === 'string' ? detail.reason : null,
+ t,
+ );
+ if (detail.matched) {
+ return t('bots.dryRunDiagnosticMatched', { route, reason });
+ }
+ return t('bots.dryRunDiagnosticSkipped', { route, reason });
+}
+
+function formatRouteStatusTime(timestamp: number | null | undefined) {
+ if (!timestamp) return '';
+ return new Date(timestamp * 1000).toLocaleString();
+}
+
+function samplePayloadForEvent(eventType: string): Record {
+ if (eventType === 'message.received') {
+ return {
+ message_text: 'Hello',
+ chat_type: 'private',
+ chat_id: 'test-user',
+ user_id: 'test-user',
+ };
+ }
+ if (eventType === 'group.member_joined') {
+ return {
+ group_id: 'test-group',
+ group_name: 'Test Group',
+ user_id: 'test-user',
+ user_name: 'Test User',
+ };
+ }
+ if (eventType === 'group.member_left') {
+ return {
+ group_id: 'test-group',
+ group_name: 'Test Group',
+ user_id: 'test-user',
+ user_name: 'Test User',
+ is_kicked: false,
+ };
+ }
+ if (eventType === 'platform.specific') {
+ return { action: 'test', data: {} };
+ }
+ return {};
+}
+
+// ── target combobox (type + target merged, with search + groups) ───────────────
+
+// Encoded value: "discard", "agent:", "pipeline:"
+function encodeTarget(type: EventBinding['target_type'], uuid: string): string {
+ return type === 'discard' ? 'discard' : `${type}:${uuid}`;
+}
+function decodeTarget(val: string): {
+ type: EventBinding['target_type'];
+ uuid: string;
+} {
+ if (val === 'discard') return { type: 'discard', uuid: '' };
+ const sep = val.indexOf(':');
+ return {
+ type: val.slice(0, sep) as EventBinding['target_type'],
+ uuid: val.slice(sep + 1),
+ };
+}
+
+function TargetCombobox({
+ binding,
+ agentOptions,
+ onUpdate,
+}: {
+ binding: EventBinding;
+ agentOptions: Agent[];
+ onUpdate: (patch: Partial) => void;
+}) {
+ const { t } = useTranslation();
+ const [open, setOpen] = useState(false);
+ const pipelineAllowed = isMessageEventPattern(binding.event_pattern);
+ const targetType = binding.target_type || 'agent';
+
+ const current =
+ targetType === 'discard'
+ ? encodeTarget('discard', '')
+ : binding.target_uuid
+ ? encodeTarget(targetType, binding.target_uuid)
+ : '';
+
+ const agents = agentOptions.filter(
+ (a) =>
+ a.kind === 'agent' && agentSupportsEventPattern(a, binding.event_pattern),
+ );
+ const pipelines = pipelineAllowed
+ ? agentOptions.filter((a) => a.kind === 'pipeline')
+ : [];
+
+ function currentLabel() {
+ if (targetType === 'discard')
+ return (
+
+
+ {t('bots.targetDiscard')}
+
+ );
+ const agent = agentOptions.find((a) => a.uuid === binding.target_uuid);
+ if (agent)
+ return (
+
+ {agent.kind === 'pipeline' ? (
+
+ ) : (
+
+ )}
+ {targetLabel(agent)}
+
+ );
+ return (
+ {t('bots.selectTarget')}
+ );
+ }
+
+ function select(val: string) {
+ const { type, uuid } = decodeTarget(val);
+ onUpdate({ target_type: type, target_uuid: uuid });
+ setOpen(false);
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ {t('bots.noTargetFound')}
+ {agents.length > 0 && (
+
+ {agents.map((a) => (
+ select(encodeTarget('agent', a.uuid || ''))}
+ >
+
+ {targetLabel(a)}
+ {current === encodeTarget('agent', a.uuid || '') && (
+
+ )}
+
+ ))}
+
+ )}
+ {pipelines.length > 0 && (
+
+ {pipelines.map((a) => (
+
+ select(encodeTarget('pipeline', a.uuid || ''))
+ }
+ >
+
+ {targetLabel(a)}
+ {current === encodeTarget('pipeline', a.uuid || '') && (
+
+ )}
+
+ ))}
+
+ )}
+
+
+
+
+ );
+}
+
+// ── filter conditions panel ───────────────────────────────────────────────────
+
+function FilterConditionsPanel({
+ filters,
+ onChange,
+}: {
+ filters: FilterRow[];
+ onChange: (f: FilterRow[]) => void;
+}) {
+ const { t } = useTranslation();
+
+ function add() {
+ onChange([
+ ...filters,
+ { field: 'chat_type', operator: 'eq', value: 'person' },
+ ]);
+ }
+
+ function update(i: number, patch: Partial) {
+ const next = [...filters];
+ next[i] = { ...next[i], ...patch };
+ if ('field' in patch) {
+ next[i].operator = OPERATORS_BY_FIELD[patch.field as FilterField][0];
+ next[i].value = '';
+ }
+ onChange(next);
+ }
+
+ function remove(i: number) {
+ const next = [...filters];
+ next.splice(i, 1);
+ onChange(next);
+ }
+
+ return (
+
+ {filters.length === 0 && (
+
+ {t('bots.conditionsEmpty')}
+
+ )}
+ {filters.map((f, i) => (
+
+
+
+
+
+ {f.field === 'chat_type' ? (
+
+ ) : f.field === 'message_element_types' ? (
+
+ ) : (
+ update(i, { value: e.target.value })}
+ />
+ )}
+
+
+
+ ))}
+
+
+ );
+}
+
+// ── adapter capability summary ────────────────────────────────────────────────
+
+function AdapterCapabilitySummary({
+ supportedEvents,
+ eventOptions,
+}: {
+ supportedEvents: string[];
+ eventOptions: string[];
+}) {
+ const { t } = useTranslation();
+ const [advancedOpen, setAdvancedOpen] = useState(false);
+ const concreteEvents =
+ supportedEvents.length > 0 ? supportedEvents : DEFAULT_EVENTS;
+ const previewEvents = concreteEvents.slice(0, 4);
+
+ return (
+
+
+
+
+
+
+
+
+
+ {t('bots.adapterEventsTitle')}
+
+
+ {t('bots.adapterEventsDescription', {
+ count: concreteEvents.length,
+ })}
+
+
+
+
+ {previewEvents.map((event) => (
+
+ {eventLabel(event, t)}
+
+ ))}
+ {concreteEvents.length > previewEvents.length && (
+
+ {t('bots.adapterEventsMore', {
+ count: concreteEvents.length - previewEvents.length,
+ })}
+
+ )}
+
+
+
+
+ {advancedOpen && (
+
+ {eventOptions.map((event) => (
+
+
+
+ {eventLabel(event, t)}
+
+ {event.endsWith('.*') && (
+
+ {t('bots.eventGroup')}
+
+ )}
+
+
+ {eventDescription(event, t)}
+
+
+ {event}
+
+
+ ))}
+
+ )}
+
+ );
+}
+
+// ── route dry-run dialog ─────────────────────────────────────────────────────
+
+function RouteDryRunDialog({
+ botId,
+ bindings,
+ eventOptions,
+ agentOptions,
+ onRouteStatusUpdate,
+}: {
+ botId?: string;
+ bindings: EventBinding[];
+ eventOptions: string[];
+ agentOptions: Agent[];
+ onRouteStatusUpdate?: (statuses: BotEventRouteStatus[]) => void;
+}) {
+ const { t } = useTranslation();
+ const firstEvent = eventOptions[0] ?? DEFAULT_EVENTS[0];
+ const [open, setOpen] = useState(false);
+ const [eventType, setEventType] = useState(firstEvent);
+ const [payloadText, setPayloadText] = useState(() =>
+ JSON.stringify(samplePayloadForEvent(firstEvent), null, 2),
+ );
+ const [advancedPayloadOpen, setAdvancedPayloadOpen] = useState(false);
+ const [isRunning, setIsRunning] = useState(false);
+ const [isDispatching, setIsDispatching] = useState(false);
+ const [payloadError, setPayloadError] = useState(null);
+ const [runError, setRunError] = useState(null);
+ const [result, setResult] = useState(null);
+ const [dispatchResult, setDispatchResult] =
+ useState(null);
+
+ useEffect(() => {
+ if (!eventOptions.includes(eventType)) {
+ setEventType(firstEvent);
+ }
+ }, [eventOptions, eventType, firstEvent]);
+
+ useEffect(() => {
+ setPayloadText(JSON.stringify(samplePayloadForEvent(eventType), null, 2));
+ setPayloadError(null);
+ setResult(null);
+ setDispatchResult(null);
+ }, [eventType]);
+
+ function resolveTargetName(resultTarget?: BotRouteDryRunResult['target']) {
+ if (!resultTarget) return '';
+ if (resultTarget.target_name) return resultTarget.target_name;
+ if (resultTarget.target_type === 'discard') return t('bots.targetDiscard');
+ const agent = agentOptions.find((a) => a.uuid === resultTarget.target_uuid);
+ return agent ? targetLabel(agent) : (resultTarget.target_uuid ?? '');
+ }
+
+ function parsePayload(): Record | null {
+ setPayloadError(null);
+ let payload: Record = {};
+ if (payloadText.trim()) {
+ try {
+ const parsed = JSON.parse(payloadText);
+ if (
+ parsed === null ||
+ typeof parsed !== 'object' ||
+ Array.isArray(parsed)
+ ) {
+ setPayloadError(t('bots.dryRunPayloadObjectError'));
+ return null;
+ }
+ payload = parsed as Record;
+ } catch {
+ setPayloadError(t('bots.dryRunPayloadJsonError'));
+ return null;
+ }
+ }
+ return payload;
+ }
+
+ async function runDryRun() {
+ setRunError(null);
+ setResult(null);
+ setDispatchResult(null);
+
+ const payload = parsePayload();
+ if (payload === null) return;
+
+ if (!botId) {
+ setRunError(t('bots.dryRunNeedsSavedBot'));
+ return;
+ }
+
+ setIsRunning(true);
+ try {
+ const dryRunResult = await backendClient.dryRunBotEventRoute(botId, {
+ event_type: eventType,
+ payload,
+ event_bindings: bindings,
+ });
+ setResult({
+ ...dryRunResult,
+ diagnostic_steps: dryRunResult.diagnostic_steps ?? [],
+ });
+ } catch (error) {
+ const err = error as { msg?: string };
+ setRunError(err.msg || t('bots.dryRunFailed'));
+ } finally {
+ setIsRunning(false);
+ }
+ }
+
+ async function dispatchTestEvent() {
+ setRunError(null);
+ setDispatchResult(null);
+
+ const payload = parsePayload();
+ if (payload === null) return;
+
+ if (!botId) {
+ setRunError(t('bots.dryRunNeedsSavedBot'));
+ return;
+ }
+
+ setIsDispatching(true);
+ try {
+ const testResult = await backendClient.testBotEventRoute(botId, {
+ event_type: eventType,
+ payload,
+ });
+ setDispatchResult(testResult);
+ onRouteStatusUpdate?.(testResult.route_status?.routes || []);
+ if (!testResult.dispatched) {
+ setRunError(
+ localizedFailureReason(
+ testResult.failure_code,
+ testResult.reason,
+ t,
+ ) || t('bots.routeTestFailed'),
+ );
+ }
+ } catch (error) {
+ const err = error as { msg?: string };
+ setRunError(err.msg || t('bots.routeTestFailed'));
+ } finally {
+ setIsDispatching(false);
+ }
+ }
+
+ const targetName = result ? resolveTargetName(result.target) : '';
+
+ return (
+ <>
+
+
+ >
+ );
+}
+
+// ── single binding card ───────────────────────────────────────────────────────
+
+interface BindingCardProps {
+ binding: EventBinding;
+ globalIndex: number;
+ routeStatus?: BotEventRouteStatus;
+ eventOptions: string[];
+ agentOptions: Agent[];
+ expandedIds: Set;
+ onToggleExpand: (id: string) => void;
+ onUpdate: (globalIndex: number, patch: Partial) => void;
+ onRemove: (globalIndex: number) => void;
+ dragHandleProps?: Record;
+ isOverlay?: boolean;
+}
+
+function BindingCardContent({
+ binding,
+ globalIndex,
+ routeStatus,
+ eventOptions,
+ agentOptions,
+ expandedIds,
+ onToggleExpand,
+ onUpdate,
+ onRemove,
+ dragHandleProps,
+}: BindingCardProps) {
+ const { t } = useTranslation();
+ const isEnabled = binding.enabled ?? true;
+ const id = String(binding.id ?? globalIndex);
+ const isExpanded = expandedIds.has(id);
+ const filterCount = (binding.filters as FilterRow[] | undefined)?.length ?? 0;
+ const pipelineAllowed = isMessageEventPattern(binding.event_pattern);
+ const statusTime = formatRouteStatusTime(routeStatus?.timestamp);
+ const statusDetail = routeStatusDetail(routeStatus, t);
+
+ return (
+
+ {/* main row */}
+
+ {isEnabled && (
+
+ )}
+
+
+ {globalIndex + 1}
+
+
+
+
+ {/* conditions toggle */}
+
+
+
+
+
onUpdate(globalIndex, patch)}
+ />
+
+ {!pipelineAllowed && binding.target_type === 'pipeline' && (
+
+ {t('bots.unsupportedPipelineEvent')}
+
+ )}
+
+
+ {/* disable/enable toggle */}
+
+
+
+
+
+
+
+
+ {routeStatusLabel(routeStatus?.last_status, t)}
+
+
+ {statusDetail || statusTime}
+
+
+
+ {/* conditions panel */}
+ {isExpanded && (
+
+
+ onUpdate(globalIndex, {
+ filters: filters as unknown as Record[],
+ })
+ }
+ />
+
+ )}
+
+ );
+}
+
+// ── sortable wrapper ──────────────────────────────────────────────────────────
+
+function SortableBindingCard(props: BindingCardProps) {
+ const { attributes, listeners, setNodeRef, transform, isDragging } =
+ useSortable({ id: props.binding.id ?? props.globalIndex });
+ return (
+
+
+
+ );
+}
+
+// ── main editor ───────────────────────────────────────────────────────────────
+
+export default function EventBindingsEditor({
+ form,
+ botId,
+ supportedEvents,
+ agentOptions,
+}: EventBindingsEditorProps) {
+ const { t } = useTranslation();
+ const watchedBindings: EventBinding[] | undefined =
+ form.watch('event_bindings');
+ const bindings = useMemo(() => watchedBindings ?? [], [watchedBindings]);
+ const [expandedIds, setExpandedIds] = useState>(new Set());
+ const [disabledSectionOpen, setDisabledSectionOpen] = useState(false);
+ const [activeId, setActiveId] = useState(null);
+ const [routeStatuses, setRouteStatuses] = useState([]);
+ const [routeStatusLoading, setRouteStatusLoading] = useState(false);
+ const [routeStatusError, setRouteStatusError] = useState(null);
+
+ // stable ids for dnd
+ const nextId = useRef(0);
+ const idsRef = useRef([]);
+ const enabledBindings = bindings.filter((b) => b.enabled ?? true);
+ useMemo(() => {
+ while (idsRef.current.length < enabledBindings.length)
+ idsRef.current.push(`b-${nextId.current++}`);
+ if (idsRef.current.length > enabledBindings.length)
+ idsRef.current = idsRef.current.slice(0, enabledBindings.length);
+ }, [enabledBindings.length]);
+
+ const eventOptions = useMemo(() => {
+ const concrete =
+ supportedEvents.length > 0 ? supportedEvents : DEFAULT_EVENTS;
+ return [...eventNamespaces(concrete), ...concrete].filter(
+ (e, i, a) => a.indexOf(e) === i,
+ );
+ }, [supportedEvents]);
+ const dryRunEventOptions = useMemo(
+ () => (supportedEvents.length > 0 ? supportedEvents : DEFAULT_EVENTS),
+ [supportedEvents],
+ );
+ const routeStatusByBinding = useMemo(() => {
+ const map = new Map();
+ routeStatuses.forEach((status) => {
+ if (status.binding_id) map.set(String(status.binding_id), status);
+ });
+ return map;
+ }, [routeStatuses]);
+ const routeConflicts = useMemo(
+ () => findRouteConflicts(bindings),
+ [bindings],
+ );
+ const catchAllRouteIndex = useMemo(
+ () => findCatchAllRouteIndex(bindings),
+ [bindings],
+ );
+ const behaviorPresets = useMemo(
+ () =>
+ BEHAVIOR_PRESETS.filter((preset) =>
+ dryRunEventOptions.includes(preset.eventType),
+ ),
+ [dryRunEventOptions],
+ );
+
+ const refreshRouteStatuses = useCallback(async () => {
+ if (!botId) {
+ setRouteStatuses([]);
+ setRouteStatusError(null);
+ return;
+ }
+ setRouteStatusLoading(true);
+ setRouteStatusError(null);
+ try {
+ const response = await backendClient.getBotEventRouteStatuses(botId);
+ setRouteStatuses(response.routes || []);
+ } catch (error) {
+ const err = error as { msg?: string };
+ setRouteStatusError(err.msg || t('bots.routeStatusRefreshFailed'));
+ } finally {
+ setRouteStatusLoading(false);
+ }
+ }, [botId, t]);
+
+ useEffect(() => {
+ refreshRouteStatuses();
+ }, [refreshRouteStatuses]);
+
+ function updateBindings(next: EventBinding[]) {
+ form.setValue('event_bindings', next, { shouldDirty: true });
+ }
+
+ function addBinding(eventPattern = 'message.received') {
+ updateBindings([
+ ...bindings,
+ {
+ event_pattern: eventPattern,
+ target_type: 'agent',
+ target_uuid: '',
+ priority: 0,
+ enabled: true,
+ description: '',
+ filters: [],
+ },
+ ]);
+ }
+
+ function updateBinding(index: number, patch: Partial) {
+ const next = [...bindings];
+ next[index] = { ...next[index], ...patch };
+ updateBindings(next);
+ }
+
+ function removeBinding(index: number) {
+ const next = [...bindings];
+ next.splice(index, 1);
+ updateBindings(next);
+ }
+
+ function toggleExpand(id: string) {
+ setExpandedIds((prev) => {
+ const s = new Set(prev);
+ if (s.has(id)) {
+ s.delete(id);
+ } else {
+ s.add(id);
+ }
+ return s;
+ });
+ }
+
+ const sensors = useSensors(
+ useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
+ useSensor(KeyboardSensor, {
+ coordinateGetter: sortableKeyboardCoordinates,
+ }),
+ );
+
+ function handleDragStart(e: DragStartEvent) {
+ setActiveId(String(e.active.id));
+ }
+
+ function handleDragEnd(e: DragEndEvent) {
+ setActiveId(null);
+ const { active, over } = e;
+ if (!over || active.id === over.id) return;
+ // reorder only within enabled bindings; disabled ones stay at end
+ const enabledIdxs = bindings.reduce((acc, b, i) => {
+ if (b.enabled ?? true) acc.push(i);
+ return acc;
+ }, []);
+ const activeEnabledIdx = idsRef.current.indexOf(String(active.id));
+ const overEnabledIdx = idsRef.current.indexOf(String(over.id));
+ if (activeEnabledIdx === -1 || overEnabledIdx === -1) return;
+ const newOrder = arrayMove(enabledIdxs, activeEnabledIdx, overEnabledIdx);
+ idsRef.current = arrayMove(
+ idsRef.current,
+ activeEnabledIdx,
+ overEnabledIdx,
+ );
+ const disabledBindings = bindings.filter((b) => !(b.enabled ?? true));
+ updateBindings([...newOrder.map((i) => bindings[i]), ...disabledBindings]);
+ }
+
+ const disabledBindings = bindings.reduce<{ b: EventBinding; i: number }[]>(
+ (acc, b, i) => {
+ if (!(b.enabled ?? true)) acc.push({ b, i });
+ return acc;
+ },
+ [],
+ );
+
+ const activeEnabledIdx = activeId ? idsRef.current.indexOf(activeId) : -1;
+ const activeBinding =
+ activeEnabledIdx >= 0 ? enabledBindings[activeEnabledIdx] : null;
+ const activeGlobalIdx = activeBinding ? bindings.indexOf(activeBinding) : -1;
+
+ return (
+
+
+
+ {routeConflicts.length > 0 && (
+
+
+
+ {t('bots.routeConflictTitle')}
+
+ {routeConflicts.slice(0, 3).map((conflict) => (
+ -
+ {t('bots.routeConflictShadowed', {
+ winner: t('bots.dryRunRuleIndex', {
+ index: conflict.winnerIndex + 1,
+ }),
+ shadowed: t('bots.dryRunRuleIndex', {
+ index: conflict.shadowedIndex + 1,
+ }),
+ })}
+
+ ))}
+
+ {routeConflicts.length > 3 && (
+
+ {t('bots.routeConflictMore', {
+ count: routeConflicts.length - 3,
+ })}
+
+ )}
+
+
+ )}
+
+
+
+
+ {catchAllRouteIndex >= 0
+ ? t('bots.routeFallbackCatchAll', {
+ route: t('bots.dryRunRuleIndex', {
+ index: catchAllRouteIndex + 1,
+ }),
+ })
+ : t('bots.routeFallbackIgnored')}
+
+
+
+ {/* enabled section */}
+
+
+
+ {enabledBindings.length === 0 && (
+
+ {t('bots.noEventBindings')}
+
+ )}
+ {enabledBindings.map((binding, sortIdx) => {
+ const globalIdx = bindings.indexOf(binding);
+ return (
+
+ );
+ })}
+
+
+
+ {activeBinding && activeGlobalIdx >= 0 ? (
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+ {behaviorPresets.map((preset) => {
+ const Icon = preset.icon;
+ return (
+ addBinding(preset.eventType)}
+ >
+
+
+ {t(preset.labelKey)}
+
+ {t(preset.descriptionKey)}
+
+
+
+ );
+ })}
+
+ addBinding(dryRunEventOptions[0])}
+ >
+
+
+ {t('bots.behaviorCustom')}
+
+ {t('bots.behaviorCustomDescription')}
+
+
+
+
+
+
+
+
+ {routeStatusError && (
+
{routeStatusError}
+ )}
+
+ {/* disabled section */}
+ {disabledBindings.length > 0 && (
+
+
+ {disabledSectionOpen && (
+
+ {disabledBindings.map(({ b, i }) => (
+
+ ))}
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/web/src/app/home/bots/components/bot-form/RoutingRulesEditor.tsx b/web/src/app/home/bots/components/bot-form/RoutingRulesEditor.tsx
deleted file mode 100644
index f8c7efbdf..000000000
--- a/web/src/app/home/bots/components/bot-form/RoutingRulesEditor.tsx
+++ /dev/null
@@ -1,479 +0,0 @@
-'use client';
-
-import { useTranslation } from 'react-i18next';
-import { UseFormReturn } from 'react-hook-form';
-import {
- PipelineRoutingRule,
- RoutingRuleOperator,
-} from '@/app/infra/entities/api';
-import { Ban, GripVertical, Plus, Trash2 } from 'lucide-react';
-import { Button } from '@/components/ui/button';
-import { Input } from '@/components/ui/input';
-import { FormLabel } from '@/components/ui/form';
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectSeparator,
- SelectTrigger,
- SelectValue,
-} from '@/components/ui/select';
-import {
- DndContext,
- DragOverlay,
- closestCenter,
- PointerSensor,
- KeyboardSensor,
- useSensor,
- useSensors,
- DragEndEvent,
- DragStartEvent,
-} from '@dnd-kit/core';
-import {
- arrayMove,
- SortableContext,
- sortableKeyboardCoordinates,
- useSortable,
- verticalListSortingStrategy,
-} from '@dnd-kit/sortable';
-import { CSS } from '@dnd-kit/utilities';
-import { useRef, useMemo, useState } from 'react';
-
-export const PIPELINE_DISCARD = '__discard__';
-
-interface PipelineOption {
- value: string;
- label: string;
- emoji?: string;
-}
-
-interface RoutingRulesEditorProps {
- form: UseFormReturn;
- pipelineNameList: PipelineOption[];
-}
-
-const OPERATORS_BY_TYPE: Record<
- PipelineRoutingRule['type'],
- { value: RoutingRuleOperator; labelKey: string }[]
-> = {
- launcher_type: [
- { value: 'eq', labelKey: 'bots.operatorEq' },
- { value: 'neq', labelKey: 'bots.operatorNeq' },
- ],
- launcher_id: [
- { value: 'eq', labelKey: 'bots.operatorEq' },
- { value: 'neq', labelKey: 'bots.operatorNeq' },
- { value: 'contains', labelKey: 'bots.operatorContains' },
- { value: 'not_contains', labelKey: 'bots.operatorNotContains' },
- { value: 'regex', labelKey: 'bots.operatorRegex' },
- ],
- message_content: [
- { value: 'eq', labelKey: 'bots.operatorEq' },
- { value: 'neq', labelKey: 'bots.operatorNeq' },
- { value: 'contains', labelKey: 'bots.operatorContains' },
- { value: 'not_contains', labelKey: 'bots.operatorNotContains' },
- { value: 'starts_with', labelKey: 'bots.operatorStartsWith' },
- { value: 'regex', labelKey: 'bots.operatorRegex' },
- ],
- message_has_element: [
- { value: 'eq', labelKey: 'bots.operatorHas' },
- { value: 'neq', labelKey: 'bots.operatorNotHas' },
- ],
-};
-
-function getValuePlaceholder(
- t: (key: string) => string,
- rule: PipelineRoutingRule,
-): string {
- if (rule.type === 'launcher_id')
- return t('bots.ruleValueLauncherIdPlaceholder');
- if (rule.type === 'message_has_element')
- return t('bots.ruleValueElementPlaceholder');
- if (rule.operator === 'regex') return t('bots.ruleValueRegexpPlaceholder');
- return t('bots.ruleValueMessagePlaceholder');
-}
-
-/* ── Static rule row (used in DragOverlay) ─────────────────────────── */
-
-interface RuleRowContentProps {
- rule: PipelineRoutingRule;
- index: number;
- pipelineNameList: PipelineOption[];
- updateRule: (index: number, patch: Partial) => void;
- removeRule: (index: number) => void;
- dragHandleProps?: Record;
- isOverlay?: boolean;
-}
-
-function RuleRowContent({
- rule,
- index,
- pipelineNameList,
- updateRule,
- removeRule,
- dragHandleProps,
- isOverlay,
-}: RuleRowContentProps) {
- const { t } = useTranslation();
- const operatorsForType =
- OPERATORS_BY_TYPE[rule.type] || OPERATORS_BY_TYPE.message_content;
- const isDiscard = rule.pipeline_uuid === PIPELINE_DISCARD;
-
- return (
-
- {/* Drag handle */}
-
-
- {/* Field selector */}
-
-
- {/* Operator selector */}
-
-
- {/* Value input */}
- {rule.type === 'launcher_type' ? (
-
- ) : rule.type === 'message_has_element' ? (
-
- ) : (
-
updateRule(index, { value: e.target.value })}
- />
- )}
-
-
→
-
- {/* Pipeline selector */}
-
-
-
-
- );
-}
-
-/* ── Sortable rule row ─────────────────────────────────────────────── */
-
-interface SortableRuleRowProps {
- id: string;
- rule: PipelineRoutingRule;
- index: number;
- pipelineNameList: PipelineOption[];
- updateRule: (index: number, patch: Partial) => void;
- removeRule: (index: number) => void;
-}
-
-function SortableRuleRow({
- id,
- rule,
- index,
- pipelineNameList,
- updateRule,
- removeRule,
-}: SortableRuleRowProps) {
- const { attributes, listeners, setNodeRef, transform, isDragging } =
- useSortable({ id });
-
- const style = {
- transform: CSS.Transform.toString(transform),
- // No transition — items reorder visually during drag via transform;
- // on drop the data updates and transform resets, so animating would
- // cause a redundant "swap" flicker.
- opacity: isDragging ? 0.3 : undefined,
- };
-
- return (
-
-
-
- );
-}
-
-/* ── Main editor ───────────────────────────────────────────────────── */
-
-export default function RoutingRulesEditor({
- form,
- pipelineNameList,
-}: RoutingRulesEditorProps) {
- const { t } = useTranslation();
- const [activeId, setActiveId] = useState(null);
-
- const rules: PipelineRoutingRule[] =
- form.watch('pipeline_routing_rules') || [];
-
- // Stable unique ids for sortable items.
- // We keep a running counter so newly added rules always get fresh ids.
- const nextId = useRef(0);
- const idsRef = useRef([]);
-
- const sortableIds = useMemo(() => {
- // Grow the id list to match rules length (newly added items get new ids).
- while (idsRef.current.length < rules.length) {
- idsRef.current.push(`rule-${nextId.current++}`);
- }
- // Shrink if rules were removed from the end.
- if (idsRef.current.length > rules.length) {
- idsRef.current = idsRef.current.slice(0, rules.length);
- }
- return idsRef.current;
- }, [rules.length]);
-
- const updateRules = (newRules: PipelineRoutingRule[]) => {
- form.setValue('pipeline_routing_rules', newRules, { shouldDirty: true });
- };
-
- const addRule = () => {
- updateRules([
- ...rules,
- {
- type: 'launcher_type',
- operator: 'eq',
- value: '',
- pipeline_uuid: '',
- },
- ]);
- };
-
- const updateRule = (index: number, patch: Partial) => {
- const updated = [...rules];
- updated[index] = { ...updated[index], ...patch };
- updateRules(updated);
- };
-
- const removeRule = (index: number) => {
- const updated = [...rules];
- updated.splice(index, 1);
- // Also remove the corresponding sortable id so indices stay in sync.
- idsRef.current.splice(index, 1);
- updateRules(updated);
- };
-
- const sensors = useSensors(
- useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
- useSensor(KeyboardSensor, {
- coordinateGetter: sortableKeyboardCoordinates,
- }),
- );
-
- const handleDragStart = (event: DragStartEvent) => {
- setActiveId(event.active.id as string);
- };
-
- const handleDragEnd = (event: DragEndEvent) => {
- setActiveId(null);
- const { active, over } = event;
- if (!over || active.id === over.id) return;
-
- const oldIndex = sortableIds.indexOf(active.id as string);
- const newIndex = sortableIds.indexOf(over.id as string);
- if (oldIndex === -1 || newIndex === -1) return;
-
- idsRef.current = arrayMove(idsRef.current, oldIndex, newIndex);
- updateRules(arrayMove(rules, oldIndex, newIndex));
- };
-
- const activeIndex = activeId ? sortableIds.indexOf(activeId) : -1;
- const activeRule = activeIndex >= 0 ? rules[activeIndex] : null;
-
- return (
-
-
-
-
{t('bots.routingRules')}
-
- {t('bots.routingRulesDescription')}
-
-
-
-
-
-
-
- {rules.map((rule, index) => (
-
- ))}
-
-
- {activeRule ? (
-
- ) : null}
-
-
-
- );
-}
diff --git a/web/src/app/home/bots/components/bot-session/BotSessionMonitor.tsx b/web/src/app/home/bots/components/bot-session/BotSessionMonitor.tsx
index 3a181134a..f31ad844f 100644
--- a/web/src/app/home/bots/components/bot-session/BotSessionMonitor.tsx
+++ b/web/src/app/home/bots/components/bot-session/BotSessionMonitor.tsx
@@ -39,7 +39,7 @@ import {
Quote,
Voice,
} from '@/app/infra/entities/message';
-import { PIPELINE_DISCARD } from '@/app/home/bots/components/bot-form/RoutingRulesEditor';
+import { PIPELINE_DISCARD } from '@/app/home/bots/components/bot-form/EventBindingsEditor';
interface SessionInfo {
session_id: string;
diff --git a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx
index da1860213..c9f24fb0c 100644
--- a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx
+++ b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx
@@ -1,7 +1,7 @@
import {
- DynamicFormItemType,
IDynamicFormItemSchema,
SYSTEM_FIELD_PREFIX,
+ DynamicFormItemType,
} from '@/app/infra/entities/form/dynamic';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -33,6 +33,7 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip';
import { systemInfo } from '@/app/infra/http';
+import { parseDynamicFormItemType } from './DynamicFormItemConfig';
/**
* Resolve the value referenced by a `show_if.field` string.
@@ -102,7 +103,7 @@ function getValueSchema(spec: DynamicFormValueSpec) {
return z.array(z.any());
}
- switch (spec.type) {
+ switch (normalizeItemType(spec.type)) {
case DynamicFormItemType.INT:
return z.number();
case DynamicFormItemType.FLOAT:
@@ -110,6 +111,7 @@ function getValueSchema(spec: DynamicFormValueSpec) {
case DynamicFormItemType.BOOLEAN:
return z.boolean();
case DynamicFormItemType.STRING:
+ case DynamicFormItemType.SECRET:
return z.string();
case DynamicFormItemType.STRING_ARRAY:
return z.array(z.string());
@@ -380,6 +382,13 @@ function DisabledTooltipIcon({ text }: { text: string }) {
);
}
+/**
+ * Normalize plugin manifest type names to frontend-compatible types
+ */
+function normalizeItemType(type: string): DynamicFormItemType {
+ return parseDynamicFormItemType(type);
+}
+
export default function DynamicFormComponent({
itemConfigList,
onSubmit,
@@ -392,7 +401,7 @@ export default function DynamicFormComponent({
}: {
itemConfigList: IDynamicFormItemSchema[];
onSubmit?: (val: object) => unknown;
- initialValues?: Record;
+ initialValues?: Record;
onFileUploaded?: (fileKey: string) => void;
isEditing?: boolean;
externalDependentValues?: Record;
@@ -630,7 +639,14 @@ export default function DynamicFormComponent({
}}
/>
- {itemConfigList.map((config) => {
+ {itemConfigList.map((config, index) => {
+ // Create a normalized config with type converted to frontend format
+ const normalizedConfig = {
+ ...config,
+ type: normalizeItemType(config.type),
+ };
+ const fieldKey = config.id || config.name || `field-${index}`;
+
if (config.show_if) {
const dependValue = resolveShowIfValue(
config.show_if.field,
@@ -725,7 +741,7 @@ export default function DynamicFormComponent({
}
// Webhook URL fields are display-only; render outside of form binding
- if (config.type === 'webhook-url') {
+ if (normalizedConfig.type === 'webhook-url') {
const webhookUrl = (systemContext?.webhook_url as string) || '';
const extraWebhookUrl =
(systemContext?.extra_webhook_url as string) || '';
@@ -734,7 +750,7 @@ export default function DynamicFormComponent({
return (
+
(
@@ -900,7 +916,7 @@ export default function DynamicFormComponent({
}
onFileUploaded={onFileUploaded}
@@ -918,7 +934,7 @@ export default function DynamicFormComponent({
return (
(
@@ -940,7 +956,7 @@ export default function DynamicFormComponent({
)}
>
}
onFileUploaded={onFileUploaded}
diff --git a/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx
index 40c5619a0..ccf118fa9 100644
--- a/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx
+++ b/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx
@@ -43,6 +43,7 @@ import {
Plus,
X,
Eye,
+ EyeOff,
Wrench,
Trash2,
Sparkles,
@@ -77,6 +78,44 @@ function hasUsableOptionName(option: { name?: string | null }): boolean {
return typeof option.name === 'string' && option.name.trim().length > 0;
}
+function getPluginComponentIconURL(value?: string): string | null {
+ if (!value?.startsWith('plugin:')) {
+ return null;
+ }
+
+ const match = value.match(/^plugin:([^/]+)\/([^/]+)(?:\/|$)/);
+ if (!match) {
+ return null;
+ }
+
+ return httpClient.getPluginIconURL(match[1], match[2]);
+}
+
+function SelectOptionContent({
+ label,
+ value,
+}: {
+ label: string;
+ value: string;
+}) {
+ const iconURL = getPluginComponentIconURL(value);
+
+ return (
+
+ {iconURL && (
+

+ )}
+
+ {label}
+
+
+ );
+}
+
export default function DynamicFormItemComponent({
config,
field,
@@ -99,6 +138,7 @@ export default function DynamicFormItemComponent({
const [bots, setBots] = useState([]);
const [tools, setTools] = useState([]);
const [uploading, setUploading] = useState(false);
+ const [secretVisible, setSecretVisible] = useState(false);
const [kbDialogOpen, setKbDialogOpen] = useState(false);
const [tempSelectedKBIds, setTempSelectedKBIds] = useState([]);
const [toolsDialogOpen, setToolsDialogOpen] = useState(false);
@@ -289,11 +329,13 @@ export default function DynamicFormItemComponent({
switch (config.type) {
case DynamicFormItemType.INT:
case DynamicFormItemType.FLOAT:
+ case DynamicFormItemType.NUMBER:
return (
field.onChange(Number(e.target.value))}
/>
);
@@ -302,7 +344,11 @@ export default function DynamicFormItemComponent({
if (config.options && config.options.length > 0) {
return (
@@ -1202,6 +1914,7 @@ function StepDone() {
// Always clear persisted progress so re-entering starts fresh
await httpClient.saveWizardProgress({
step: 0,
+ selected_scenario: null,
selected_adapter: null,
created_bot_uuid: null,
bot_saved: false,
diff --git a/web/src/components/ui/command.tsx b/web/src/components/ui/command.tsx
new file mode 100644
index 000000000..ca4b8dcad
--- /dev/null
+++ b/web/src/components/ui/command.tsx
@@ -0,0 +1,113 @@
+import * as React from 'react';
+import { Command as CommandPrimitive } from 'cmdk';
+import { Search } from 'lucide-react';
+import { cn } from '@/lib/utils';
+
+const Command = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+Command.displayName = CommandPrimitive.displayName;
+
+const CommandInput = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+));
+CommandInput.displayName = CommandPrimitive.Input.displayName;
+
+const CommandList = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+CommandList.displayName = CommandPrimitive.List.displayName;
+
+const CommandEmpty = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>((props, ref) => (
+
+));
+CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
+
+const CommandGroup = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+CommandGroup.displayName = CommandPrimitive.Group.displayName;
+
+const CommandItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+CommandItem.displayName = CommandPrimitive.Item.displayName;
+
+const CommandSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
+
+export {
+ Command,
+ CommandInput,
+ CommandList,
+ CommandEmpty,
+ CommandGroup,
+ CommandItem,
+ CommandSeparator,
+};
diff --git a/web/src/components/ui/sidebar.tsx b/web/src/components/ui/sidebar.tsx
index 8fad1287a..95ae80ee2 100644
--- a/web/src/components/ui/sidebar.tsx
+++ b/web/src/components/ui/sidebar.tsx
@@ -24,15 +24,23 @@ import {
} from '@/components/ui/tooltip';
const SIDEBAR_STORAGE_KEY = 'sidebar_state';
-const SIDEBAR_WIDTH = '16rem';
+const SIDEBAR_WIDTH_STORAGE_KEY = 'sidebar_width';
+const SIDEBAR_WIDTH_DEFAULT = 256;
+const SIDEBAR_WIDTH_MIN = 220;
+const SIDEBAR_WIDTH_MAX = 420;
const SIDEBAR_WIDTH_MOBILE = '18rem';
const SIDEBAR_WIDTH_ICON = '3rem';
const SIDEBAR_KEYBOARD_SHORTCUT = 'b';
+const SIDEBAR_RESIZE_DRAG_THRESHOLD = 4;
type SidebarContextProps = {
state: 'expanded' | 'collapsed';
open: boolean;
- setOpen: (open: boolean) => void;
+ setOpen: (open: boolean | ((open: boolean) => boolean)) => void;
+ sidebarWidth: number;
+ setSidebarWidth: (width: number | ((width: number) => number)) => void;
+ isSidebarResizing: boolean;
+ setSidebarResizing: (isResizing: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
@@ -50,6 +58,24 @@ function useSidebar() {
return context;
}
+function clampSidebarWidth(width: number) {
+ if (!Number.isFinite(width)) return SIDEBAR_WIDTH_DEFAULT;
+
+ return Math.min(
+ SIDEBAR_WIDTH_MAX,
+ Math.max(SIDEBAR_WIDTH_MIN, Math.round(width)),
+ );
+}
+
+function getStoredSidebarWidth() {
+ if (typeof window === 'undefined') return SIDEBAR_WIDTH_DEFAULT;
+
+ const stored = localStorage.getItem(SIDEBAR_WIDTH_STORAGE_KEY);
+ if (stored === null) return SIDEBAR_WIDTH_DEFAULT;
+
+ return clampSidebarWidth(Number.parseInt(stored, 10));
+}
+
function SidebarProvider({
defaultOpen = true,
open: openProp,
@@ -65,6 +91,10 @@ function SidebarProvider({
}) {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
+ const [sidebarWidth, _setSidebarWidth] = React.useState(
+ getStoredSidebarWidth,
+ );
+ const [isSidebarResizing, setSidebarResizing] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
@@ -93,6 +123,23 @@ function SidebarProvider({
[setOpenProp, open],
);
+ const setSidebarWidth = React.useCallback(
+ (value: number | ((width: number) => number)) => {
+ _setSidebarWidth((currentWidth) => {
+ const nextWidth =
+ typeof value === 'function' ? value(currentWidth) : value;
+ const clampedWidth = clampSidebarWidth(nextWidth);
+
+ if (typeof window !== 'undefined') {
+ localStorage.setItem(SIDEBAR_WIDTH_STORAGE_KEY, String(clampedWidth));
+ }
+
+ return clampedWidth;
+ });
+ },
+ [],
+ );
+
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
@@ -123,12 +170,28 @@ function SidebarProvider({
state,
open,
setOpen,
+ sidebarWidth,
+ setSidebarWidth,
+ isSidebarResizing,
+ setSidebarResizing,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
- [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
+ [
+ state,
+ open,
+ setOpen,
+ sidebarWidth,
+ setSidebarWidth,
+ isSidebarResizing,
+ setSidebarResizing,
+ isMobile,
+ openMobile,
+ setOpenMobile,
+ toggleSidebar,
+ ],
);
return (
@@ -138,7 +201,7 @@ function SidebarProvider({
data-slot="sidebar-wrapper"
style={
{
- '--sidebar-width': SIDEBAR_WIDTH,
+ '--sidebar-width': `${sidebarWidth}px`,
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
@@ -168,7 +231,8 @@ function Sidebar({
variant?: 'sidebar' | 'floating' | 'inset';
collapsible?: 'offcanvas' | 'icon' | 'none';
}) {
- const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
+ const { isMobile, state, openMobile, setOpenMobile, isSidebarResizing } =
+ useSidebar();
if (collapsible === 'none') {
return (
@@ -224,6 +288,7 @@ function Sidebar({
data-slot="sidebar-gap"
className={cn(
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',
+ isSidebarResizing && 'transition-none',
'group-data-[collapsible=offcanvas]:w-0',
'group-data-[side=right]:rotate-180',
variant === 'floating' || variant === 'inset'
@@ -235,6 +300,7 @@ function Sidebar({
data-slot="sidebar-container"
className={cn(
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
+ isSidebarResizing && 'transition-none',
side === 'left'
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
@@ -284,8 +350,132 @@ function SidebarTrigger({
);
}
-function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {
- const { toggleSidebar } = useSidebar();
+type SidebarResizeDragState = {
+ pointerId: number;
+ startX: number;
+ startWidth: number;
+ side: 'left' | 'right';
+ hasDragged: boolean;
+ captureTarget: HTMLElement;
+ previousCursor: string;
+ previousUserSelect: string;
+};
+
+function getSidebarSide(element: HTMLElement): 'left' | 'right' {
+ const sidebarRoot = element.closest('[data-slot="sidebar"]');
+ return sidebarRoot?.getAttribute('data-side') === 'right' ? 'right' : 'left';
+}
+
+function restoreSidebarResizeBodyState(dragState: SidebarResizeDragState) {
+ document.body.style.cursor = dragState.previousCursor;
+ document.body.style.userSelect = dragState.previousUserSelect;
+}
+
+function SidebarRail({
+ className,
+ onClick,
+ onPointerCancel,
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+ ...props
+}: React.ComponentProps<'button'>) {
+ const {
+ setOpen,
+ sidebarWidth,
+ setSidebarResizing,
+ setSidebarWidth,
+ toggleSidebar,
+ } = useSidebar();
+ const dragStateRef = React.useRef(null);
+ const removeDragListenersRef = React.useRef<(() => void) | null>(null);
+ const ignoreNextClickRef = React.useRef(false);
+
+ React.useEffect(() => {
+ return () => {
+ removeDragListenersRef.current?.();
+ removeDragListenersRef.current = null;
+
+ if (dragStateRef.current) {
+ restoreSidebarResizeBodyState(dragStateRef.current);
+ dragStateRef.current = null;
+ }
+ setSidebarResizing(false);
+ };
+ }, [setSidebarResizing]);
+
+ function clearDragState() {
+ const dragState = dragStateRef.current;
+ if (!dragState) return null;
+
+ removeDragListenersRef.current?.();
+ removeDragListenersRef.current = null;
+
+ if (dragState.captureTarget.hasPointerCapture(dragState.pointerId)) {
+ dragState.captureTarget.releasePointerCapture(dragState.pointerId);
+ }
+
+ restoreSidebarResizeBodyState(dragState);
+ dragStateRef.current = null;
+ setSidebarResizing(false);
+
+ return dragState;
+ }
+
+ function handleResizePointerMove(event: PointerEvent | React.PointerEvent) {
+ const dragState = dragStateRef.current;
+ if (!dragState || dragState.pointerId !== event.pointerId) return;
+
+ const delta =
+ dragState.side === 'left'
+ ? event.clientX - dragState.startX
+ : dragState.startX - event.clientX;
+
+ if (
+ !dragState.hasDragged &&
+ Math.abs(delta) >= SIDEBAR_RESIZE_DRAG_THRESHOLD
+ ) {
+ dragState.hasDragged = true;
+ setSidebarResizing(true);
+ setOpen(true);
+ }
+
+ if (!dragState.hasDragged) return;
+
+ event.preventDefault();
+ setSidebarWidth(dragState.startWidth + delta);
+ }
+
+ function handleResizePointerEnd(event: PointerEvent | React.PointerEvent) {
+ const currentDragState = dragStateRef.current;
+ if (!currentDragState || currentDragState.pointerId !== event.pointerId)
+ return;
+
+ const dragState = clearDragState();
+ if (!dragState) return;
+
+ ignoreNextClickRef.current = dragState.hasDragged;
+ if (dragState.hasDragged) {
+ event.preventDefault();
+ window.setTimeout(() => {
+ ignoreNextClickRef.current = false;
+ }, 0);
+ }
+ }
+
+ function handleResizePointerCancel(event: PointerEvent | React.PointerEvent) {
+ const currentDragState = dragStateRef.current;
+ if (!currentDragState || currentDragState.pointerId !== event.pointerId)
+ return;
+
+ const dragState = clearDragState();
+ if (dragState?.hasDragged) {
+ ignoreNextClickRef.current = true;
+ window.setTimeout(() => {
+ ignoreNextClickRef.current = false;
+ }, 0);
+ }
+ }
return (