refactor(events): group event selection options

This commit is contained in:
RockChinQ
2026-08-25 23:59:52 +08:00
parent 4d7a333802
commit d28b385a9f
8 changed files with 223 additions and 101 deletions
@@ -17,6 +17,11 @@ import {
PopoverTrigger, PopoverTrigger,
} from '@/components/ui/popover'; } from '@/components/ui/popover';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import {
eventGroupLabel,
eventNamespaces,
groupEventPatterns,
} from '@/app/home/components/event-patterns/event-pattern-groups';
const FALLBACK_EVENTS = ['message.received']; const FALLBACK_EVENTS = ['message.received'];
@@ -26,19 +31,6 @@ interface AgentEventPatternPickerProps {
onChange: (patterns: string[]) => void; onChange: (patterns: string[]) => void;
} }
function eventNamespaces(events: string[]) {
const counts = new Map<string, number>();
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({ export default function AgentEventPatternPicker({
events, events,
value, value,
@@ -67,6 +59,7 @@ export default function AgentEventPatternPicker({
).sort(); ).sort();
return ['*', ...namespaces, ...concreteEvents]; return ['*', ...namespaces, ...concreteEvents];
}, [events, selectedPatterns]); }, [events, selectedPatterns]);
const optionGroups = useMemo(() => groupEventPatterns(options), [options]);
function eventLabel(pattern: string) { function eventLabel(pattern: string) {
if (pattern === '*') return t('bots.eventWildcard'); if (pattern === '*') return t('bots.eventWildcard');
@@ -154,39 +147,44 @@ export default function AgentEventPatternPicker({
<CommandInput placeholder={t('agents.searchEvents')} /> <CommandInput placeholder={t('agents.searchEvents')} />
<CommandList> <CommandList>
<CommandEmpty>{t('agents.noEventsFound')}</CommandEmpty> <CommandEmpty>{t('agents.noEventsFound')}</CommandEmpty>
<CommandGroup heading={t('agents.supportedEvents')}> {optionGroups.map((group) => (
{options.map((pattern) => { <CommandGroup
const selected = selectedPatterns.includes(pattern); key={group.namespace}
return ( heading={eventGroupLabel(group.namespace, t)}
<CommandItem >
key={pattern} {group.patterns.map((pattern) => {
value={`${eventLabel(pattern)} ${pattern}`} const selected = selectedPatterns.includes(pattern);
onSelect={() => togglePattern(pattern)} return (
className="items-start gap-2 py-2" <CommandItem
> key={pattern}
<Check value={`${eventLabel(pattern)} ${pattern}`}
className={cn( onSelect={() => togglePattern(pattern)}
'mt-0.5 size-4 shrink-0', className="items-start gap-2 py-2"
selected ? 'opacity-100' : 'opacity-0', >
)} <Check
/> className={cn(
<span className="min-w-0 flex-1"> 'mt-0.5 size-4 shrink-0',
<span className="flex min-w-0 items-center gap-2"> selected ? 'opacity-100' : 'opacity-0',
<span className="truncate font-medium"> )}
{eventLabel(pattern)} />
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-center gap-2">
<span className="truncate font-medium">
{eventLabel(pattern)}
</span>
<code className="shrink-0 text-[10px] text-muted-foreground">
{pattern}
</code>
</span>
<span className="mt-0.5 block text-xs text-muted-foreground">
{eventDescription(pattern)}
</span> </span>
<code className="shrink-0 text-[10px] text-muted-foreground">
{pattern}
</code>
</span> </span>
<span className="mt-0.5 block text-xs text-muted-foreground"> </CommandItem>
{eventDescription(pattern)} );
</span> })}
</span> </CommandGroup>
</CommandItem> ))}
);
})}
</CommandGroup>
</CommandList> </CommandList>
</Command> </Command>
</PopoverContent> </PopoverContent>
@@ -48,7 +48,9 @@ import {
import { import {
Select, Select,
SelectContent, SelectContent,
SelectGroup,
SelectItem, SelectItem,
SelectLabel,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
@@ -102,6 +104,11 @@ import {
BotRouteTestResult, BotRouteTestResult,
} from '@/app/infra/entities/api'; } from '@/app/infra/entities/api';
import { backendClient } from '@/app/infra/http'; import { backendClient } from '@/app/infra/http';
import {
eventGroupLabel,
eventNamespaces,
groupEventPatterns,
} from '@/app/home/components/event-patterns/event-pattern-groups';
export const PIPELINE_DISCARD = '__discard__'; export const PIPELINE_DISCARD = '__discard__';
@@ -302,20 +309,6 @@ function agentSupportsEventPattern(agent: Agent, pattern: string) {
return patterns.some((p) => eventPatternCovers(p, pattern)); 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<string, number>();
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 // Localized label for an event pattern. Concrete events look up
// `bots.eventNames.<event_with_underscores>`, falling back to the raw // `bots.eventNames.<event_with_underscores>`, falling back to the raw
// string when no translation exists (e.g. custom/unknown events). // string when no translation exists (e.g. custom/unknown events).
@@ -771,7 +764,8 @@ function AdapterCapabilitySummary({
const [advancedOpen, setAdvancedOpen] = useState(false); const [advancedOpen, setAdvancedOpen] = useState(false);
const concreteEvents = const concreteEvents =
supportedEvents.length > 0 ? supportedEvents : DEFAULT_EVENTS; supportedEvents.length > 0 ? supportedEvents : DEFAULT_EVENTS;
const previewEvents = concreteEvents.slice(0, 4); const concreteEventGroups = groupEventPatterns(concreteEvents);
const optionGroups = groupEventPatterns(eventOptions);
return ( return (
<div className="rounded-lg border bg-muted/20 p-3"> <div className="rounded-lg border bg-muted/20 p-3">
@@ -793,20 +787,22 @@ function AdapterCapabilitySummary({
</div> </div>
</div> </div>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{previewEvents.map((event) => ( {concreteEventGroups.slice(0, 4).map((group) => (
<Badge <Badge
key={event} key={group.namespace}
variant="secondary" variant="secondary"
className="max-w-full rounded-md px-2 py-0.5 font-normal" className="max-w-full rounded-md px-2 py-0.5 font-normal"
title={event}
> >
<span className="truncate">{eventLabel(event, t)}</span> <span className="truncate">
{eventGroupLabel(group.namespace, t)} ·{' '}
{group.patterns.length}
</span>
</Badge> </Badge>
))} ))}
{concreteEvents.length > previewEvents.length && ( {concreteEventGroups.length > 4 && (
<Badge variant="outline" className="rounded-md px-2 py-0.5"> <Badge variant="outline" className="rounded-md px-2 py-0.5">
{t('bots.adapterEventsMore', { {t('bots.adapterEventsMore', {
count: concreteEvents.length - previewEvents.length, count: concreteEventGroups.length - 4,
})} })}
</Badge> </Badge>
)} )}
@@ -828,25 +824,40 @@ function AdapterCapabilitySummary({
</Button> </Button>
</div> </div>
{advancedOpen && ( {advancedOpen && (
<div className="mt-3 grid gap-2 border-t pt-3 sm:grid-cols-2"> <div className="mt-3 space-y-4 border-t pt-3">
{eventOptions.map((event) => ( {optionGroups.map((group) => (
<div key={event} className="min-w-0 rounded-md bg-background p-2"> <div key={group.namespace} className="space-y-2">
<div className="flex min-w-0 items-center justify-between gap-2"> <p className="text-xs font-medium text-muted-foreground">
<span className="truncate text-xs font-medium"> {eventGroupLabel(group.namespace, t)}
{eventLabel(event, t)}
</span>
{event.endsWith('.*') && (
<Badge variant="outline" className="shrink-0 text-[10px]">
{t('bots.eventGroup')}
</Badge>
)}
</div>
<p className="mt-1 line-clamp-2 text-[11px] leading-snug text-muted-foreground">
{eventDescription(event, t)}
</p> </p>
<code className="mt-1 block truncate text-[11px] text-muted-foreground"> <div className="grid gap-2 sm:grid-cols-2">
{event} {group.patterns.map((event) => (
</code> <div
key={event}
className="min-w-0 rounded-md bg-background p-2"
>
<div className="flex min-w-0 items-center justify-between gap-2">
<span className="truncate text-xs font-medium">
{eventLabel(event, t)}
</span>
{event.endsWith('.*') && (
<Badge
variant="outline"
className="shrink-0 text-[10px]"
>
{t('bots.eventGroup')}
</Badge>
)}
</div>
<p className="mt-1 line-clamp-2 text-[11px] leading-snug text-muted-foreground">
{eventDescription(event, t)}
</p>
<code className="mt-1 block truncate text-[11px] text-muted-foreground">
{event}
</code>
</div>
))}
</div>
</div> </div>
))} ))}
</div> </div>
@@ -1032,10 +1043,17 @@ function RouteDryRunDialog({
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{eventOptions.map((event) => ( {groupEventPatterns(eventOptions).map((group) => (
<SelectItem key={event} value={event}> <SelectGroup key={group.namespace}>
{eventLabel(event, t)} <SelectLabel>
</SelectItem> {eventGroupLabel(group.namespace, t)}
</SelectLabel>
{group.patterns.map((event) => (
<SelectItem key={event} value={event}>
{eventLabel(event, t)}
</SelectItem>
))}
</SelectGroup>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
@@ -1304,19 +1322,21 @@ function BindingCardContent({
)} )}
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{eventOptions.map((event) => { {groupEventPatterns(eventOptions).map((group) => (
const label = eventLabel(event, t); <SelectGroup key={group.namespace}>
return ( <SelectLabel>{eventGroupLabel(group.namespace, t)}</SelectLabel>
<SelectItem key={event} value={event}> {group.patterns.map((event) => (
<span className="flex flex-col"> <SelectItem key={event} value={event}>
<span>{label}</span> <span className="flex flex-col">
<span className="text-[11px] text-muted-foreground"> <span>{eventLabel(event, t)}</span>
{eventDescription(event, t)} <span className="text-[11px] text-muted-foreground">
{eventDescription(event, t)}
</span>
</span> </span>
</span> </SelectItem>
</SelectItem> ))}
); </SelectGroup>
})} ))}
</SelectContent> </SelectContent>
</Select> </Select>
@@ -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<string, number>();
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<string, string[]>();
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;
}
+8
View File
@@ -433,6 +433,14 @@ const enUS = {
adapterEventsMore: '{{count}} more', adapterEventsMore: '{{count}} more',
advancedEventValues: 'View all', advancedEventValues: 'View all',
eventGroup: 'Group', eventGroup: 'Group',
eventGroupNames: {
bot: 'Bot status',
feedback: 'Feedback',
friend: 'Friends',
group: 'Groups',
message: 'Messages',
platform: 'Platform',
},
routeConflictTitle: 'Some routes overlap', routeConflictTitle: 'Some routes overlap',
routeConflictShadowed: routeConflictShadowed:
'{{shadowed}} may never run because {{winner}} handles the same events first.', '{{shadowed}} may never run because {{winner}} handles the same events first.',
+8
View File
@@ -440,6 +440,14 @@ const jaJP = {
adapterEventsMore: 'ほか {{count}} 件', adapterEventsMore: 'ほか {{count}} 件',
advancedEventValues: 'すべて表示', advancedEventValues: 'すべて表示',
eventGroup: 'グループ', eventGroup: 'グループ',
eventGroupNames: {
bot: 'ボットの状態',
feedback: 'フィードバック',
friend: '友だち',
group: 'グループ',
message: 'メッセージ',
platform: 'プラットフォーム',
},
routeConflictTitle: '一部のルートが重複しています', routeConflictTitle: '一部のルートが重複しています',
routeConflictShadowed: routeConflictShadowed:
'{{winner}} が同じイベントを先に処理するため、{{shadowed}} は実行されない可能性があります。', '{{winner}} が同じイベントを先に処理するため、{{shadowed}} は実行されない可能性があります。',
+8
View File
@@ -412,6 +412,14 @@ const zhHans = {
adapterEventsMore: '另有 {{count}} 类', adapterEventsMore: '另有 {{count}} 类',
advancedEventValues: '查看全部', advancedEventValues: '查看全部',
eventGroup: '事件组', eventGroup: '事件组',
eventGroupNames: {
bot: '机器人状态',
feedback: '反馈',
friend: '好友',
group: '群组',
message: '消息',
platform: '平台',
},
routeConflictTitle: '部分路由存在覆盖冲突', routeConflictTitle: '部分路由存在覆盖冲突',
routeConflictShadowed: routeConflictShadowed:
'{{shadowed}} 可能永远不会运行,因为 {{winner}} 会先处理相同事件。', '{{shadowed}} 可能永远不会运行,因为 {{winner}} 会先处理相同事件。',
+23 -2
View File
@@ -353,7 +353,10 @@ test.describe('bot advanced flows', () => {
test('keeps event routing compact and hides raw status errors', async ({ test('keeps event routing compact and hides raw status errors', async ({
page, page,
}) => { }) => {
await installLangBotApiMocks(page, { authenticated: true }); await installLangBotApiMocks(page, {
authenticated: true,
withAdapterEvents: true,
});
await page.route('**/api/v1/platform/bots/*/event-routes/status', (route) => await page.route('**/api/v1/platform/bots/*/event-routes/status', (route) =>
route.fulfill({ route.fulfill({
status: 500, status: 500,
@@ -416,7 +419,9 @@ test.describe('bot advanced flows', () => {
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/); await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
await expect(page.getByText('Supported events')).toBeVisible(); 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('Internal server error')).toHaveCount(0);
await expect( await expect(
page.getByText('Events that match no route are ignored.'), page.getByText('Events that match no route are ignored.'),
@@ -436,6 +441,14 @@ test.describe('bot advanced flows', () => {
dangerBox!.y - (routingBox!.y + routingBox!.height), dangerBox!.y - (routingBox!.y + routingBox!.height),
).toBeGreaterThanOrEqual(20); ).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 page.getByRole('button', { name: 'Refresh status' }).hover();
await expect( await expect(
page.getByText('Failed to refresh route status.'), page.getByText('Failed to refresh route status.'),
@@ -461,6 +474,14 @@ test.describe('bot advanced flows', () => {
await expect( await expect(
routeDialog.getByRole('button', { name: 'Run full test' }), routeDialog.getByRole('button', { name: 'Run full test' }),
).toBeVisible(); ).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 routeDialog.getByRole('button', { name: 'Preview match' }).click();
await expect(routeDialog.getByText('Matched route')).toBeVisible(); await expect(routeDialog.getByText('Matched route')).toBeVisible();
@@ -106,6 +106,8 @@ test.describe('processor detail workbench', () => {
await expect( await expect(
page.getByRole('option').filter({ hasText: 'message.received' }), page.getByRole('option').filter({ hasText: 'message.received' }),
).toBeVisible(); ).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 page.keyboard.press('Escape');
await flow.getByRole('tab').nth(1).click(); await flow.getByRole('tab').nth(1).click();