mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-26 12:17:14 +00:00
refactor(events): group event selection options
This commit is contained in:
@@ -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<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({
|
||||
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({
|
||||
<CommandInput placeholder={t('agents.searchEvents')} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t('agents.noEventsFound')}</CommandEmpty>
|
||||
<CommandGroup heading={t('agents.supportedEvents')}>
|
||||
{options.map((pattern) => {
|
||||
const selected = selectedPatterns.includes(pattern);
|
||||
return (
|
||||
<CommandItem
|
||||
key={pattern}
|
||||
value={`${eventLabel(pattern)} ${pattern}`}
|
||||
onSelect={() => togglePattern(pattern)}
|
||||
className="items-start gap-2 py-2"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'mt-0.5 size-4 shrink-0',
|
||||
selected ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-medium">
|
||||
{eventLabel(pattern)}
|
||||
{optionGroups.map((group) => (
|
||||
<CommandGroup
|
||||
key={group.namespace}
|
||||
heading={eventGroupLabel(group.namespace, t)}
|
||||
>
|
||||
{group.patterns.map((pattern) => {
|
||||
const selected = selectedPatterns.includes(pattern);
|
||||
return (
|
||||
<CommandItem
|
||||
key={pattern}
|
||||
value={`${eventLabel(pattern)} ${pattern}`}
|
||||
onSelect={() => togglePattern(pattern)}
|
||||
className="items-start gap-2 py-2"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'mt-0.5 size-4 shrink-0',
|
||||
selected ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
<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>
|
||||
<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>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
|
||||
@@ -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<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
|
||||
// `bots.eventNames.<event_with_underscores>`, 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 (
|
||||
<div className="rounded-lg border bg-muted/20 p-3">
|
||||
@@ -793,20 +787,22 @@ function AdapterCapabilitySummary({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{previewEvents.map((event) => (
|
||||
{concreteEventGroups.slice(0, 4).map((group) => (
|
||||
<Badge
|
||||
key={event}
|
||||
key={group.namespace}
|
||||
variant="secondary"
|
||||
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>
|
||||
))}
|
||||
{concreteEvents.length > previewEvents.length && (
|
||||
{concreteEventGroups.length > 4 && (
|
||||
<Badge variant="outline" className="rounded-md px-2 py-0.5">
|
||||
{t('bots.adapterEventsMore', {
|
||||
count: concreteEvents.length - previewEvents.length,
|
||||
count: concreteEventGroups.length - 4,
|
||||
})}
|
||||
</Badge>
|
||||
)}
|
||||
@@ -828,25 +824,40 @@ function AdapterCapabilitySummary({
|
||||
</Button>
|
||||
</div>
|
||||
{advancedOpen && (
|
||||
<div className="mt-3 grid gap-2 border-t pt-3 sm:grid-cols-2">
|
||||
{eventOptions.map((event) => (
|
||||
<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)}
|
||||
<div className="mt-3 space-y-4 border-t pt-3">
|
||||
{optionGroups.map((group) => (
|
||||
<div key={group.namespace} className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
{eventGroupLabel(group.namespace, t)}
|
||||
</p>
|
||||
<code className="mt-1 block truncate text-[11px] text-muted-foreground">
|
||||
{event}
|
||||
</code>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{group.patterns.map((event) => (
|
||||
<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>
|
||||
@@ -1032,10 +1043,17 @@ function RouteDryRunDialog({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{eventOptions.map((event) => (
|
||||
<SelectItem key={event} value={event}>
|
||||
{eventLabel(event, t)}
|
||||
</SelectItem>
|
||||
{groupEventPatterns(eventOptions).map((group) => (
|
||||
<SelectGroup key={group.namespace}>
|
||||
<SelectLabel>
|
||||
{eventGroupLabel(group.namespace, t)}
|
||||
</SelectLabel>
|
||||
{group.patterns.map((event) => (
|
||||
<SelectItem key={event} value={event}>
|
||||
{eventLabel(event, t)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -1304,19 +1322,21 @@ function BindingCardContent({
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{eventOptions.map((event) => {
|
||||
const label = eventLabel(event, t);
|
||||
return (
|
||||
<SelectItem key={event} value={event}>
|
||||
<span className="flex flex-col">
|
||||
<span>{label}</span>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{eventDescription(event, t)}
|
||||
{groupEventPatterns(eventOptions).map((group) => (
|
||||
<SelectGroup key={group.namespace}>
|
||||
<SelectLabel>{eventGroupLabel(group.namespace, t)}</SelectLabel>
|
||||
{group.patterns.map((event) => (
|
||||
<SelectItem key={event} value={event}>
|
||||
<span className="flex flex-col">
|
||||
<span>{eventLabel(event, t)}</span>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{eventDescription(event, t)}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
</SelectContent>
|
||||
</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;
|
||||
}
|
||||
@@ -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.',
|
||||
|
||||
@@ -440,6 +440,14 @@ const jaJP = {
|
||||
adapterEventsMore: 'ほか {{count}} 件',
|
||||
advancedEventValues: 'すべて表示',
|
||||
eventGroup: 'グループ',
|
||||
eventGroupNames: {
|
||||
bot: 'ボットの状態',
|
||||
feedback: 'フィードバック',
|
||||
friend: '友だち',
|
||||
group: 'グループ',
|
||||
message: 'メッセージ',
|
||||
platform: 'プラットフォーム',
|
||||
},
|
||||
routeConflictTitle: '一部のルートが重複しています',
|
||||
routeConflictShadowed:
|
||||
'{{winner}} が同じイベントを先に処理するため、{{shadowed}} は実行されない可能性があります。',
|
||||
|
||||
@@ -412,6 +412,14 @@ const zhHans = {
|
||||
adapterEventsMore: '另有 {{count}} 类',
|
||||
advancedEventValues: '查看全部',
|
||||
eventGroup: '事件组',
|
||||
eventGroupNames: {
|
||||
bot: '机器人状态',
|
||||
feedback: '反馈',
|
||||
friend: '好友',
|
||||
group: '群组',
|
||||
message: '消息',
|
||||
platform: '平台',
|
||||
},
|
||||
routeConflictTitle: '部分路由存在覆盖冲突',
|
||||
routeConflictShadowed:
|
||||
'{{shadowed}} 可能永远不会运行,因为 {{winner}} 会先处理相同事件。',
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user