feat(agent): add visual event range picker

This commit is contained in:
RockChinQ
2026-08-25 23:51:24 +08:00
parent 114612a984
commit 4d7a333802
13 changed files with 325 additions and 60 deletions
@@ -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<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,
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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
aria-label={t('agents.supportedEvents')}
className="h-auto min-h-10 w-full min-w-0 justify-between gap-2 px-3 py-2 font-normal"
>
<span className="flex min-w-0 flex-1 flex-wrap gap-1.5">
{selectedPatterns.slice(0, 3).map((pattern) => (
<Badge
key={pattern}
variant="secondary"
className="max-w-full rounded-md font-normal"
>
<span className="truncate">{eventLabel(pattern)}</span>
</Badge>
))}
{selectedPatterns.length > 3 && (
<Badge variant="outline" className="rounded-md font-normal">
+{selectedPatterns.length - 3}
</Badge>
)}
</span>
<ChevronsUpDown className="size-4 shrink-0 text-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] p-0"
>
<Command>
<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)}
</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>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
@@ -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<ApiRespPluginSystemStatus | null>(null);
const [pluginStatusLoading, setPluginStatusLoading] = useState(true);
const [pluginStatusError, setPluginStatusError] = useState(false);
const [availableEventTypes, setAvailableEventTypes] = useState<string[]>([
'message.received',
]);
const [activeSection, setActiveSection] =
useState<AgentConfigSection>('events');
useState<AgentConfigSection>('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<typeof formSchema>;
@@ -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<string, any>;
const loadedValues: FormValues = {
@@ -175,10 +188,8 @@ function AgentFormComponent(
runner: (config.runner as Record<string, unknown>) ?? {},
runner_config:
(config.runner_config as Record<string, unknown>) ?? {},
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<AgentRunnerStatus>(() => {
@@ -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)
}
>
<div className="overflow-x-auto">
<TabsList className="grid min-w-[34rem] w-full grid-cols-3">
<div className="min-w-0">
<TabsList className="grid h-auto w-full min-w-0 grid-cols-3">
{primarySections.map((section) => {
const Icon = section.icon;
return (
<TabsTrigger key={section.name} value={section.name}>
<Icon />
{section.label}
<TabsTrigger
key={section.name}
value={section.name}
className="min-w-0 gap-1.5 px-2"
>
<Icon className="size-4 shrink-0" />
<span className="truncate">{section.label}</span>
</TabsTrigger>
);
})}
@@ -588,17 +593,14 @@ function AgentFormComponent(
<CardContent>
<FormField
control={form.control}
name="supported_event_patterns_text"
name="supported_event_patterns"
render={({ field }) => (
<FormItem>
<FormLabel>{t('agents.supportedEvents')}</FormLabel>
<FormControl>
<Textarea
{...field}
className="min-h-32 font-mono text-sm"
placeholder={'*\nmessage.received\ngroup.*'}
/>
</FormControl>
<AgentEventPatternPicker
events={availableEventTypes}
value={field.value}
onChange={field.onChange}
/>
<FormDescription>
{t('agents.supportedEventsDescription')}
</FormDescription>
+3 -1
View File
@@ -698,7 +698,9 @@ const enUS = {
'Limit which bot event routes can select this Agent. The default is suitable for most cases.',
supportedEvents: 'Event Range',
supportedEventsDescription:
'Use one event pattern per line, for example *, message.received, group.*. Pipelines are fixed to message.*.',
'Choose all events, an event group, or individual events. Bot routes will only list this Agent for matching events.',
searchEvents: 'Search events…',
noEventsFound: 'No matching events found',
nameRequired: 'Name cannot be empty',
createSuccess: 'Created successfully',
createError: 'Creation failed: ',
+3 -1
View File
@@ -533,7 +533,9 @@ const esES = {
'Limita qué rutas de eventos del bot pueden seleccionar este Agent. El valor predeterminado sirve para la mayoría de los casos.',
supportedEvents: 'Rango de eventos',
supportedEventsDescription:
'Usa un patrón de evento por línea, por ejemplo *, message.received, group.*. Los Pipelines están fijos en message.*.',
'Elige todos los eventos, un grupo o eventos concretos. Las rutas del bot solo mostrarán este Agent para los eventos compatibles.',
searchEvents: 'Buscar eventos…',
noEventsFound: 'No se encontraron eventos',
nameRequired: 'El nombre no puede estar vacío',
createSuccess: 'Creado correctamente',
createError: 'Error al crear: ',
+3 -1
View File
@@ -713,7 +713,9 @@ const jaJP = {
'この Agent を選択できるボットイベントルートの範囲を制限します。通常は既定値のままで問題ありません。',
supportedEvents: 'イベント範囲',
supportedEventsDescription:
'1 行に 1 つのイベントパターンを指定します。例: *、message.received、group.*。Pipeline は message.* 固定です。',
'すべてのイベント、イベントグループ、または個別のイベントを選択します。ボットルートでは一致するイベントにのみこの Agent が表示されます。',
searchEvents: 'イベントを検索…',
noEventsFound: '一致するイベントがありません',
nameRequired: '名前は必須です',
createSuccess: '作成に成功しました',
createError: '作成に失敗しました:',
+3 -1
View File
@@ -530,7 +530,9 @@ const ruRU = {
'Ограничьте, какие маршруты событий бота могут выбирать этот Agent. Обычно достаточно значения по умолчанию.',
supportedEvents: 'Диапазон событий',
supportedEventsDescription:
'Один шаблон события в строке, например *, message.received, group.*. Pipeline фиксирован на message.*.',
'Выберите все события, группу или отдельные события. В маршрутах бота этот Agent будет доступен только для подходящих событий.',
searchEvents: 'Поиск событий…',
noEventsFound: 'Подходящие события не найдены',
nameRequired: 'Имя не может быть пустым',
createSuccess: 'Успешно создано',
createError: 'Ошибка создания: ',
+3 -1
View File
@@ -516,7 +516,9 @@ const thTH = {
'จำกัดว่าเส้นทางเหตุการณ์ของบอทใดสามารถเลือก Agent นี้ได้ ค่าเริ่มต้นเหมาะกับกรณีส่วนใหญ่',
supportedEvents: 'ช่วงเหตุการณ์',
supportedEventsDescription:
'หนึ่งรูปแบบเหตุการณ์ต่อบรรทัด เช่น *, message.received, group.* Pipeline ถูกกำหนดไว้ที่ message.*',
'เลือกเหตุการณ์ทั้งหมด กลุ่มเหตุการณ์ หรือเหตุการณ์ที่ต้องการ เส้นทางบอทจะแสดง Agent นี้เฉพาะเหตุการณ์ที่ตรงกัน',
searchEvents: 'ค้นหาเหตุการณ์…',
noEventsFound: 'ไม่พบเหตุการณ์ที่ตรงกัน',
nameRequired: 'ชื่อต้องไม่ว่างเปล่า',
createSuccess: 'สร้างสำเร็จ',
createError: 'สร้างล้มเหลว: ',
+3 -1
View File
@@ -526,7 +526,9 @@ const viVN = {
'Giới hạn những tuyến sự kiện bot có thể chọn Agent này. Mặc định phù hợp với hầu hết trường hợp.',
supportedEvents: 'Phạm vi sự kiện',
supportedEventsDescription:
'Mỗi dòng một mẫu sự kiện, ví dụ *, message.received, group.*. Pipeline cố định ở message.*.',
'Chọn tất cả sự kiện, một nhóm hoặc từng sự kiện. Tuyến bot chỉ hiển thị Agent này cho các sự kiện phù hợp.',
searchEvents: 'Tìm sự kiện…',
noEventsFound: 'Không tìm thấy sự kiện phù hợp',
nameRequired: 'Tên không được để trống',
createSuccess: 'Tạo thành công',
createError: 'Tạo thất bại: ',
+3 -1
View File
@@ -668,7 +668,9 @@ const zhHans = {
'限制此 Agent 可被机器人事件路由选择的事件范围。通常保持默认即可。',
supportedEvents: '事件范围',
supportedEventsDescription:
'每行一个事件模式,例如 *、message.received、group.*。Pipeline 固定仅支持 message.*。',
'选择全部事件、事件组或具体事件。机器人路由只会在匹配的事件中显示此 Agent。',
searchEvents: '搜索事件…',
noEventsFound: '没有匹配的事件',
nameRequired: '名称不能为空',
createSuccess: '创建成功',
createError: '创建失败:',
+3 -1
View File
@@ -499,7 +499,9 @@ const zhHant = {
'限制此 Agent 可被機器人事件路由選擇的事件範圍。通常保持預設即可。',
supportedEvents: '事件範圍',
supportedEventsDescription:
'每行一個事件模式,例如 *、message.received、group.*。Pipeline 固定僅支援 message.*。',
'選擇全部事件、事件群組或具體事件。機器人路由只會在符合的事件中顯示此 Agent。',
searchEvents: '搜尋事件…',
noEventsFound: '沒有符合的事件',
nameRequired: '名稱不能為空',
createSuccess: '建立成功',
createError: '建立失敗:',
+17 -3
View File
@@ -758,7 +758,10 @@ test.describe('agent and pipeline save concurrency', () => {
test('agent save freezes its payload and keeps later edits dirty', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
await installLangBotApiMocks(page, {
authenticated: true,
withAdapterEvents: true,
});
const delayedSave = await installDelayedFirstSave(
page,
'/api/v1/agents/agent-save-race',
@@ -770,12 +773,23 @@ test.describe('agent and pipeline save concurrency', () => {
const eventPatterns = page.getByLabel('Event Range');
await expect(eventPatterns).toBeVisible();
await eventPatterns.fill('message.received');
await eventPatterns.click();
await page
.getByRole('option')
.filter({ hasText: 'message.received' })
.click();
await page.keyboard.press('Escape');
await saveButton.click();
await expect.poll(() => delayedSave.payloads.length).toBe(1);
await expect(saveButton).toBeDisabled();
await eventPatterns.fill('group.*');
await eventPatterns.click();
await page.getByRole('option').filter({ hasText: 'group.*' }).click();
await page
.getByRole('option')
.filter({ hasText: 'message.received' })
.click();
await page.keyboard.press('Escape');
await forceFormSubmit(page, '#agent-form');
expect(delayedSave.payloads).toHaveLength(1);
await expect(saveButton).toBeDisabled();
+19 -2
View File
@@ -106,6 +106,7 @@ interface LangBotApiMockState {
sessionAnalyses: Record<string, unknown>;
sessionMessages: Record<string, unknown[]>;
skills: SkillMock[];
withAdapterEvents: boolean;
withRunnerToolSelector: boolean;
workspaces: WorkspaceEntryMock[];
}
@@ -514,7 +515,7 @@ function makeBot(
};
}
function mockAdapters() {
function mockAdapters(withAdapterEvents = false) {
return [
{
name: 'playwright-adapter',
@@ -528,6 +529,17 @@ function mockAdapters() {
},
spec: {
categories: ['testing'],
...(withAdapterEvents
? {
supported_events: [
'message.received',
'message.edited',
'group.member_joined',
'group.member_left',
'feedback.received',
],
}
: {}),
config: [],
},
},
@@ -621,7 +633,9 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
}
if (path === '/api/v1/platform/adapters') {
return fulfillJson(route, { adapters: mockAdapters() });
return fulfillJson(route, {
adapters: mockAdapters(state.withAdapterEvents),
});
}
if (path === '/api/v1/platform/bots') {
@@ -1230,6 +1244,7 @@ export async function installLangBotApiMocks(
sessionAnalyses?: Record<string, unknown>;
sessionMessages?: Record<string, unknown[]>;
storage?: JsonRecord;
withAdapterEvents?: boolean;
withRunnerToolSelector?: boolean;
workspaces?: WorkspaceEntryMock[];
} = {},
@@ -1241,6 +1256,7 @@ export async function installLangBotApiMocks(
sessionAnalyses,
sessionMessages,
storage = {},
withAdapterEvents = false,
withRunnerToolSelector = false,
workspaces = [defaultWorkspaceEntry()],
} = options;
@@ -1256,6 +1272,7 @@ export async function installLangBotApiMocks(
sessionAnalyses: sessionAnalyses || {},
sessionMessages: sessionMessages || {},
skills: [],
withAdapterEvents,
withRunnerToolSelector,
workspaces,
};
@@ -10,6 +10,7 @@ test.describe('processor detail workbench', () => {
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
withAdapterEvents: true,
withRunnerToolSelector: true,
});
@@ -45,14 +46,23 @@ test.describe('processor detail workbench', () => {
expect(debugBox!.y).toBeGreaterThanOrEqual(0);
const flow = configPanel.getByRole('tablist');
await expect(flow.getByRole('tab').nth(0)).toContainText(
await expect(flow.getByRole('tab').nth(0)).toContainText('Runner');
await expect(flow.getByRole('tab').nth(1)).toContainText('Local Agent');
await expect(flow.getByRole('tab').nth(2)).toContainText(
'Bindable Event Range',
);
await expect(flow.getByRole('tab').nth(1)).toContainText('Runner');
await expect(flow.getByRole('tab').nth(2)).toContainText('Local Agent');
await expect(flow.getByRole('tab')).toHaveCount(3);
await expect(flow.getByText('Management')).toHaveCount(0);
await page.setViewportSize({ width: 1024, height: 900 });
const tabListMetrics = await flow.evaluate((element) => ({
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
}));
expect(tabListMetrics.scrollWidth).toBeLessThanOrEqual(
tabListMetrics.clientWidth,
);
await expect(
page.getByRole('heading', { name: /agent-workbench/ }),
).toBeVisible();
@@ -83,11 +93,22 @@ test.describe('processor detail workbench', () => {
.last(),
).toBeVisible();
await flow.getByRole('tab').nth(0).click();
await flow.getByRole('tab').nth(2).click();
await expect(
configPanel.getByText('Bindable Event Range', { exact: true }).last(),
).toBeVisible();
await flow.getByRole('tab').nth(2).click();
const eventPicker = configPanel.getByRole('combobox', {
name: 'Event Range',
});
await expect(eventPicker).toBeVisible();
await expect(configPanel.getByRole('textbox')).toHaveCount(0);
await eventPicker.click();
await expect(
page.getByRole('option').filter({ hasText: 'message.received' }),
).toBeVisible();
await page.keyboard.press('Escape');
await flow.getByRole('tab').nth(1).click();
await expect(
configPanel.getByText('Local Agent', { exact: true }).last(),
).toBeVisible();