diff --git a/web/src/app/home/agents/components/AgentEventPatternPicker.tsx b/web/src/app/home/agents/components/AgentEventPatternPicker.tsx new file mode 100644 index 000000000..82efdb9d4 --- /dev/null +++ b/web/src/app/home/agents/components/AgentEventPatternPicker.tsx @@ -0,0 +1,195 @@ +import { useMemo, useState } from 'react'; +import { Check, ChevronsUpDown } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { cn } from '@/lib/utils'; + +const FALLBACK_EVENTS = ['message.received']; + +interface AgentEventPatternPickerProps { + events: string[]; + value: string[]; + onChange: (patterns: string[]) => void; +} + +function eventNamespaces(events: string[]) { + const counts = new Map(); + events.forEach((event) => { + if (event === '*' || event.endsWith('.*')) return; + const namespace = event.split('.')[0]; + if (namespace) counts.set(namespace, (counts.get(namespace) ?? 0) + 1); + }); + return Array.from(counts.entries()) + .filter(([, count]) => count >= 2) + .map(([namespace]) => `${namespace}.*`) + .sort(); +} + +export default function AgentEventPatternPicker({ + events, + value, + onChange, +}: AgentEventPatternPickerProps) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const selectedPatterns = useMemo( + () => (value.length > 0 ? value : ['*']), + [value], + ); + const options = useMemo(() => { + const concreteEvents = Array.from( + new Set([ + ...(events.length > 0 ? events : FALLBACK_EVENTS), + ...selectedPatterns.filter( + (pattern) => pattern !== '*' && !pattern.endsWith('.*'), + ), + ]), + ).sort(); + const namespaces = Array.from( + new Set([ + ...eventNamespaces(concreteEvents), + ...selectedPatterns.filter((pattern) => pattern.endsWith('.*')), + ]), + ).sort(); + return ['*', ...namespaces, ...concreteEvents]; + }, [events, selectedPatterns]); + + function eventLabel(pattern: string) { + if (pattern === '*') return t('bots.eventWildcard'); + if (pattern.endsWith('.*')) { + return t('bots.eventNamespaceWildcard', { + namespace: pattern.replace('.*', ''), + }); + } + const key = `bots.eventNames.${pattern.replace(/\./g, '_')}`; + const label = t(key); + return label === key ? pattern : label; + } + + function eventDescription(pattern: string) { + if (pattern === '*') return t('bots.eventDescriptions.all'); + if (pattern.endsWith('.*')) { + return t('bots.eventDescriptions.namespace'); + } + const key = `bots.eventDescriptions.${pattern.replace(/\./g, '_')}`; + const description = t(key); + return description === key + ? t('bots.eventDescriptions.custom') + : description; + } + + function togglePattern(pattern: string) { + if (pattern === '*') { + onChange(['*']); + return; + } + + if (selectedPatterns.includes(pattern)) { + const next = selectedPatterns.filter((item) => item !== pattern); + onChange(next.length > 0 ? next : ['*']); + return; + } + + let next = selectedPatterns.filter((item) => item !== '*'); + const namespace = pattern.split('.')[0]; + if (pattern.endsWith('.*')) { + next = next.filter( + (item) => item.split('.')[0] !== namespace || item.endsWith('.*'), + ); + } else { + next = next.filter((item) => item !== `${namespace}.*`); + } + onChange(Array.from(new Set([...next, pattern]))); + } + + return ( + + + + + + + + + {t('agents.noEventsFound')} + + {options.map((pattern) => { + const selected = selectedPatterns.includes(pattern); + return ( + togglePattern(pattern)} + className="items-start gap-2 py-2" + > + + + + + {eventLabel(pattern)} + + + {pattern} + + + + {eventDescription(pattern)} + + + + ); + })} + + + + + + ); +} diff --git a/web/src/app/home/agents/components/AgentFormComponent.tsx b/web/src/app/home/agents/components/AgentFormComponent.tsx index 37b845b6e..c3b6a5b25 100644 --- a/web/src/app/home/agents/components/AgentFormComponent.tsx +++ b/web/src/app/home/agents/components/AgentFormComponent.tsx @@ -23,7 +23,6 @@ import { import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent'; import { extractI18nObject } from '@/i18n/I18nProvider'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Textarea } from '@/components/ui/textarea'; import { Card, CardContent, @@ -33,13 +32,12 @@ import { } from '@/components/ui/card'; import { Form, - FormControl, FormDescription, FormField, FormItem, - FormLabel, FormMessage, } from '@/components/ui/form'; +import AgentEventPatternPicker from './AgentEventPatternPicker'; export interface AgentRunnerStatus { label: string; @@ -114,8 +112,11 @@ function AgentFormComponent( useState(null); const [pluginStatusLoading, setPluginStatusLoading] = useState(true); const [pluginStatusError, setPluginStatusError] = useState(false); + const [availableEventTypes, setAvailableEventTypes] = useState([ + 'message.received', + ]); const [activeSection, setActiveSection] = - useState('events'); + useState('runner'); const isSavingRef = useRef(false); const hasUnsavedChangesRef = useRef(false); @@ -127,7 +128,7 @@ function AgentFormComponent( }), runner: z.record(z.string(), z.any()), runner_config: z.record(z.string(), z.any()), - supported_event_patterns_text: z.string(), + supported_event_patterns: z.array(z.string()).min(1), }); type FormValues = z.infer; @@ -141,7 +142,7 @@ function AgentFormComponent( }, runner: {}, runner_config: {}, - supported_event_patterns_text: '*', + supported_event_patterns: ['*'], }, }); @@ -160,10 +161,22 @@ function AgentFormComponent( useEffect(() => { let cancelled = false; - Promise.all([httpClient.getAgentMetadata(), httpClient.getAgent(agentId)]) - .then(([metadata, resp]) => { + Promise.all([ + httpClient.getAgentMetadata(), + httpClient.getAgent(agentId), + httpClient.getAdapters().catch(() => ({ adapters: [] })), + ]) + .then(([metadata, resp, adaptersResp]) => { if (cancelled) return; setRunnerConfigSchema(metadata.runner_config ?? null); + const adapterEvents = adaptersResp.adapters.flatMap( + (adapter) => adapter.spec.supported_events ?? [], + ); + setAvailableEventTypes( + adapterEvents.length > 0 + ? Array.from(new Set(adapterEvents)).sort() + : ['message.received'], + ); const agent = resp.agent; const config = (agent.config ?? {}) as Record; const loadedValues: FormValues = { @@ -175,10 +188,8 @@ function AgentFormComponent( runner: (config.runner as Record) ?? {}, runner_config: (config.runner_config as Record) ?? {}, - supported_event_patterns_text: ( - agent.supported_event_patterns ?? - agent.capability?.supported_event_patterns ?? ['*'] - ).join('\n'), + supported_event_patterns: agent.supported_event_patterns ?? + agent.capability?.supported_event_patterns ?? ['*'], }; form.reset(loadedValues); savedSnapshotRef.current = JSON.stringify(loadedValues); @@ -250,11 +261,6 @@ function AgentFormComponent( label: string; icon: React.ElementType; }> = [ - { - name: 'events', - label: t('agents.bindableEvents'), - icon: Zap, - }, { name: 'runner', label: t('agents.runnerSettings'), @@ -267,6 +273,11 @@ function AgentFormComponent( : t('pipelines.configuration'), icon: SlidersHorizontal, }, + { + name: 'events', + label: t('agents.bindableEvents'), + icon: Zap, + }, ]; const runnerStatus = useMemo(() => { @@ -423,14 +434,6 @@ function AgentFormComponent( ); } - function normalizeEventPatterns(value: string): string[] { - const patterns = value - .split(/[\n,]/) - .map((item) => item.trim()) - .filter(Boolean); - return patterns.length > 0 ? patterns : ['*']; - } - const saveValues = useCallback( async (values: FormValues) => { if (isSavingRef.current) return false; @@ -441,9 +444,7 @@ function AgentFormComponent( description: values.basic.description ?? '', emoji: values.basic.emoji, component_ref: (runner.id as string) || null, - supported_event_patterns: normalizeEventPatterns( - values.supported_event_patterns_text, - ), + supported_event_patterns: values.supported_event_patterns, config: { runner, runner_config: values.runner_config ?? {}, @@ -525,14 +526,18 @@ function AgentFormComponent( setActiveSection(value as AgentConfigSection) } > -
- +
+ {primarySections.map((section) => { const Icon = section.icon; return ( - - - {section.label} + + + {section.label} ); })} @@ -588,17 +593,14 @@ function AgentFormComponent( ( - {t('agents.supportedEvents')} - -