From d28b385a9f5a18a5eb3bee724f5427abb1d535fc Mon Sep 17 00:00:00 2001 From: RockChinQ Date: Tue, 25 Aug 2026 23:59:52 +0800 Subject: [PATCH] refactor(events): group event selection options --- .../components/AgentEventPatternPicker.tsx | 86 ++++++------ .../bot-form/EventBindingsEditor.tsx | 130 ++++++++++-------- .../event-patterns/event-pattern-groups.ts | 57 ++++++++ web/src/i18n/locales/en-US.ts | 8 ++ web/src/i18n/locales/ja-JP.ts | 8 ++ web/src/i18n/locales/zh-Hans.ts | 8 ++ web/tests/e2e/crud-smoke.spec.ts | 25 +++- .../e2e/processor-detail-workbench.spec.ts | 2 + 8 files changed, 223 insertions(+), 101 deletions(-) create mode 100644 web/src/app/home/components/event-patterns/event-pattern-groups.ts diff --git a/web/src/app/home/agents/components/AgentEventPatternPicker.tsx b/web/src/app/home/agents/components/AgentEventPatternPicker.tsx index 82efdb9d4..55867b7f8 100644 --- a/web/src/app/home/agents/components/AgentEventPatternPicker.tsx +++ b/web/src/app/home/agents/components/AgentEventPatternPicker.tsx @@ -17,6 +17,11 @@ import { PopoverTrigger, } from '@/components/ui/popover'; import { cn } from '@/lib/utils'; +import { + eventGroupLabel, + eventNamespaces, + groupEventPatterns, +} from '@/app/home/components/event-patterns/event-pattern-groups'; const FALLBACK_EVENTS = ['message.received']; @@ -26,19 +31,6 @@ interface AgentEventPatternPickerProps { 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, @@ -67,6 +59,7 @@ export default function AgentEventPatternPicker({ ).sort(); return ['*', ...namespaces, ...concreteEvents]; }, [events, selectedPatterns]); + const optionGroups = useMemo(() => groupEventPatterns(options), [options]); function eventLabel(pattern: string) { if (pattern === '*') return t('bots.eventWildcard'); @@ -154,39 +147,44 @@ export default function AgentEventPatternPicker({ {t('agents.noEventsFound')} - - {options.map((pattern) => { - const selected = selectedPatterns.includes(pattern); - return ( - togglePattern(pattern)} - className="items-start gap-2 py-2" - > - - - - - {eventLabel(pattern)} + {optionGroups.map((group) => ( + + {group.patterns.map((pattern) => { + const selected = selectedPatterns.includes(pattern); + return ( + togglePattern(pattern)} + className="items-start gap-2 py-2" + > + + + + + {eventLabel(pattern)} + + + {pattern} + + + + {eventDescription(pattern)} - - {pattern} - - - {eventDescription(pattern)} - - - - ); - })} - + + ); + })} + + ))} diff --git a/web/src/app/home/bots/components/bot-form/EventBindingsEditor.tsx b/web/src/app/home/bots/components/bot-form/EventBindingsEditor.tsx index 54c079b1e..f3f2a6dea 100644 --- a/web/src/app/home/bots/components/bot-form/EventBindingsEditor.tsx +++ b/web/src/app/home/bots/components/bot-form/EventBindingsEditor.tsx @@ -48,7 +48,9 @@ import { import { Select, SelectContent, + SelectGroup, SelectItem, + SelectLabel, SelectTrigger, SelectValue, } from '@/components/ui/select'; @@ -102,6 +104,11 @@ import { BotRouteTestResult, } from '@/app/infra/entities/api'; import { backendClient } from '@/app/infra/http'; +import { + eventGroupLabel, + eventNamespaces, + groupEventPatterns, +} from '@/app/home/components/event-patterns/event-pattern-groups'; export const PIPELINE_DISCARD = '__discard__'; @@ -302,20 +309,6 @@ function agentSupportsEventPattern(agent: Agent, pattern: string) { 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). @@ -771,7 +764,8 @@ function AdapterCapabilitySummary({ const [advancedOpen, setAdvancedOpen] = useState(false); const concreteEvents = supportedEvents.length > 0 ? supportedEvents : DEFAULT_EVENTS; - const previewEvents = concreteEvents.slice(0, 4); + const concreteEventGroups = groupEventPatterns(concreteEvents); + const optionGroups = groupEventPatterns(eventOptions); return (
@@ -793,20 +787,22 @@ function AdapterCapabilitySummary({
- {previewEvents.map((event) => ( + {concreteEventGroups.slice(0, 4).map((group) => ( - {eventLabel(event, t)} + + {eventGroupLabel(group.namespace, t)} ·{' '} + {group.patterns.length} + ))} - {concreteEvents.length > previewEvents.length && ( + {concreteEventGroups.length > 4 && ( {t('bots.adapterEventsMore', { - count: concreteEvents.length - previewEvents.length, + count: concreteEventGroups.length - 4, })} )} @@ -828,25 +824,40 @@ function AdapterCapabilitySummary({
{advancedOpen && ( -
- {eventOptions.map((event) => ( -
-
- - {eventLabel(event, t)} - - {event.endsWith('.*') && ( - - {t('bots.eventGroup')} - - )} -
-

- {eventDescription(event, t)} +

+ {optionGroups.map((group) => ( +
+

+ {eventGroupLabel(group.namespace, t)}

- - {event} - +
+ {group.patterns.map((event) => ( +
+
+ + {eventLabel(event, t)} + + {event.endsWith('.*') && ( + + {t('bots.eventGroup')} + + )} +
+

+ {eventDescription(event, t)} +

+ + {event} + +
+ ))} +
))}
@@ -1032,10 +1043,17 @@ function RouteDryRunDialog({ - {eventOptions.map((event) => ( - - {eventLabel(event, t)} - + {groupEventPatterns(eventOptions).map((group) => ( + + + {eventGroupLabel(group.namespace, t)} + + {group.patterns.map((event) => ( + + {eventLabel(event, t)} + + ))} + ))} @@ -1304,19 +1322,21 @@ function BindingCardContent({ )} - {eventOptions.map((event) => { - const label = eventLabel(event, t); - return ( - - - {label} - - {eventDescription(event, t)} + {groupEventPatterns(eventOptions).map((group) => ( + + {eventGroupLabel(group.namespace, t)} + {group.patterns.map((event) => ( + + + {eventLabel(event, t)} + + {eventDescription(event, t)} + - - - ); - })} + + ))} + + ))} diff --git a/web/src/app/home/components/event-patterns/event-pattern-groups.ts b/web/src/app/home/components/event-patterns/event-pattern-groups.ts new file mode 100644 index 000000000..b1f1f8525 --- /dev/null +++ b/web/src/app/home/components/event-patterns/event-pattern-groups.ts @@ -0,0 +1,57 @@ +import type { TFunction } from 'i18next'; + +export interface EventPatternGroup { + namespace: string; + patterns: string[]; +} + +function eventPatternNamespace(pattern: string) { + if (pattern === '*') return '*'; + return pattern.split('.')[0] || pattern; +} + +export function eventNamespaces(events: string[]) { + const counts = new Map(); + events.forEach((event) => { + if (event === '*' || event.endsWith('.*')) return; + const namespace = eventPatternNamespace(event); + counts.set(namespace, (counts.get(namespace) ?? 0) + 1); + }); + return Array.from(counts.entries()) + .filter(([, count]) => count >= 2) + .map(([namespace]) => `${namespace}.*`) + .sort(); +} + +export function groupEventPatterns(patterns: string[]): EventPatternGroup[] { + const groups = new Map(); + patterns.forEach((pattern) => { + const namespace = eventPatternNamespace(pattern); + const group = groups.get(namespace) ?? []; + if (!group.includes(pattern)) group.push(pattern); + groups.set(namespace, group); + }); + + return Array.from(groups.entries()) + .sort(([left], [right]) => { + if (left === '*') return -1; + if (right === '*') return 1; + return left.localeCompare(right); + }) + .map(([namespace, groupPatterns]) => ({ + namespace, + patterns: groupPatterns.sort((left, right) => { + const leftWildcard = left.endsWith('.*'); + const rightWildcard = right.endsWith('.*'); + if (leftWildcard !== rightWildcard) return leftWildcard ? -1 : 1; + return left.localeCompare(right); + }), + })); +} + +export function eventGroupLabel(namespace: string, t: TFunction) { + if (namespace === '*') return t('bots.eventWildcard'); + const key = `bots.eventGroupNames.${namespace}`; + const label = t(key); + return label === key ? namespace : label; +} diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 33ff7a7a7..731bd05d2 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -433,6 +433,14 @@ const enUS = { adapterEventsMore: '{{count}} more', advancedEventValues: 'View all', eventGroup: 'Group', + eventGroupNames: { + bot: 'Bot status', + feedback: 'Feedback', + friend: 'Friends', + group: 'Groups', + message: 'Messages', + platform: 'Platform', + }, routeConflictTitle: 'Some routes overlap', routeConflictShadowed: '{{shadowed}} may never run because {{winner}} handles the same events first.', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index b8058cac3..1b2929036 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -440,6 +440,14 @@ const jaJP = { adapterEventsMore: 'ほか {{count}} 件', advancedEventValues: 'すべて表示', eventGroup: 'グループ', + eventGroupNames: { + bot: 'ボットの状態', + feedback: 'フィードバック', + friend: '友だち', + group: 'グループ', + message: 'メッセージ', + platform: 'プラットフォーム', + }, routeConflictTitle: '一部のルートが重複しています', routeConflictShadowed: '{{winner}} が同じイベントを先に処理するため、{{shadowed}} は実行されない可能性があります。', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index b42f2ec0d..1c2710506 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -412,6 +412,14 @@ const zhHans = { adapterEventsMore: '另有 {{count}} 类', advancedEventValues: '查看全部', eventGroup: '事件组', + eventGroupNames: { + bot: '机器人状态', + feedback: '反馈', + friend: '好友', + group: '群组', + message: '消息', + platform: '平台', + }, routeConflictTitle: '部分路由存在覆盖冲突', routeConflictShadowed: '{{shadowed}} 可能永远不会运行,因为 {{winner}} 会先处理相同事件。', diff --git a/web/tests/e2e/crud-smoke.spec.ts b/web/tests/e2e/crud-smoke.spec.ts index b92eb2fea..1457743dd 100644 --- a/web/tests/e2e/crud-smoke.spec.ts +++ b/web/tests/e2e/crud-smoke.spec.ts @@ -353,7 +353,10 @@ test.describe('bot advanced flows', () => { test('keeps event routing compact and hides raw status errors', async ({ page, }) => { - await installLangBotApiMocks(page, { authenticated: true }); + await installLangBotApiMocks(page, { + authenticated: true, + withAdapterEvents: true, + }); await page.route('**/api/v1/platform/bots/*/event-routes/status', (route) => route.fulfill({ status: 500, @@ -416,7 +419,9 @@ test.describe('bot advanced flows', () => { await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/); await expect(page.getByText('Supported events')).toBeVisible(); - await expect(page.getByText('1 event types')).toBeVisible(); + await expect(page.getByText('5 event types')).toBeVisible(); + await expect(page.getByText('Messages · 2')).toBeVisible(); + await expect(page.getByText('Groups · 2')).toBeVisible(); await expect(page.getByText('Internal server error')).toHaveCount(0); await expect( page.getByText('Events that match no route are ignored.'), @@ -436,6 +441,14 @@ test.describe('bot advanced flows', () => { dangerBox!.y - (routingBox!.y + routingBox!.height), ).toBeGreaterThanOrEqual(20); + await routingCard.getByRole('button', { name: 'View all' }).click(); + await expect( + routingCard.getByText('Messages', { exact: true }), + ).toBeVisible(); + await expect( + routingCard.getByText('Groups', { exact: true }), + ).toBeVisible(); + await page.getByRole('button', { name: 'Refresh status' }).hover(); await expect( page.getByText('Failed to refresh route status.'), @@ -461,6 +474,14 @@ test.describe('bot advanced flows', () => { await expect( routeDialog.getByRole('button', { name: 'Run full test' }), ).toBeVisible(); + await routeDialog.getByRole('combobox').first().click(); + await expect( + page.getByText('Messages', { exact: true }).last(), + ).toBeVisible(); + await expect( + page.getByText('Groups', { exact: true }).last(), + ).toBeVisible(); + await page.keyboard.press('Escape'); await routeDialog.getByRole('button', { name: 'Preview match' }).click(); await expect(routeDialog.getByText('Matched route')).toBeVisible(); diff --git a/web/tests/e2e/processor-detail-workbench.spec.ts b/web/tests/e2e/processor-detail-workbench.spec.ts index ba145dbcd..692769078 100644 --- a/web/tests/e2e/processor-detail-workbench.spec.ts +++ b/web/tests/e2e/processor-detail-workbench.spec.ts @@ -106,6 +106,8 @@ test.describe('processor detail workbench', () => { await expect( page.getByRole('option').filter({ hasText: 'message.received' }), ).toBeVisible(); + await expect(page.getByRole('group', { name: 'Messages' })).toHaveCount(1); + await expect(page.getByRole('group', { name: 'Groups' })).toHaveCount(1); await page.keyboard.press('Escape'); await flow.getByRole('tab').nth(1).click();