mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-26 12:17:14 +00:00
feat(agent): add visual event range picker
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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: ',
|
||||
|
||||
@@ -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: ',
|
||||
|
||||
@@ -713,7 +713,9 @@ const jaJP = {
|
||||
'この Agent を選択できるボットイベントルートの範囲を制限します。通常は既定値のままで問題ありません。',
|
||||
supportedEvents: 'イベント範囲',
|
||||
supportedEventsDescription:
|
||||
'1 行に 1 つのイベントパターンを指定します。例: *、message.received、group.*。Pipeline は message.* 固定です。',
|
||||
'すべてのイベント、イベントグループ、または個別のイベントを選択します。ボットルートでは一致するイベントにのみこの Agent が表示されます。',
|
||||
searchEvents: 'イベントを検索…',
|
||||
noEventsFound: '一致するイベントがありません',
|
||||
nameRequired: '名前は必須です',
|
||||
createSuccess: '作成に成功しました',
|
||||
createError: '作成に失敗しました:',
|
||||
|
||||
@@ -530,7 +530,9 @@ const ruRU = {
|
||||
'Ограничьте, какие маршруты событий бота могут выбирать этот Agent. Обычно достаточно значения по умолчанию.',
|
||||
supportedEvents: 'Диапазон событий',
|
||||
supportedEventsDescription:
|
||||
'Один шаблон события в строке, например *, message.received, group.*. Pipeline фиксирован на message.*.',
|
||||
'Выберите все события, группу или отдельные события. В маршрутах бота этот Agent будет доступен только для подходящих событий.',
|
||||
searchEvents: 'Поиск событий…',
|
||||
noEventsFound: 'Подходящие события не найдены',
|
||||
nameRequired: 'Имя не может быть пустым',
|
||||
createSuccess: 'Успешно создано',
|
||||
createError: 'Ошибка создания: ',
|
||||
|
||||
@@ -516,7 +516,9 @@ const thTH = {
|
||||
'จำกัดว่าเส้นทางเหตุการณ์ของบอทใดสามารถเลือก Agent นี้ได้ ค่าเริ่มต้นเหมาะกับกรณีส่วนใหญ่',
|
||||
supportedEvents: 'ช่วงเหตุการณ์',
|
||||
supportedEventsDescription:
|
||||
'หนึ่งรูปแบบเหตุการณ์ต่อบรรทัด เช่น *, message.received, group.* Pipeline ถูกกำหนดไว้ที่ message.*',
|
||||
'เลือกเหตุการณ์ทั้งหมด กลุ่มเหตุการณ์ หรือเหตุการณ์ที่ต้องการ เส้นทางบอทจะแสดง Agent นี้เฉพาะเหตุการณ์ที่ตรงกัน',
|
||||
searchEvents: 'ค้นหาเหตุการณ์…',
|
||||
noEventsFound: 'ไม่พบเหตุการณ์ที่ตรงกัน',
|
||||
nameRequired: 'ชื่อต้องไม่ว่างเปล่า',
|
||||
createSuccess: 'สร้างสำเร็จ',
|
||||
createError: 'สร้างล้มเหลว: ',
|
||||
|
||||
@@ -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: ',
|
||||
|
||||
@@ -668,7 +668,9 @@ const zhHans = {
|
||||
'限制此 Agent 可被机器人事件路由选择的事件范围。通常保持默认即可。',
|
||||
supportedEvents: '事件范围',
|
||||
supportedEventsDescription:
|
||||
'每行一个事件模式,例如 *、message.received、group.*。Pipeline 固定仅支持 message.*。',
|
||||
'选择全部事件、事件组或具体事件。机器人路由只会在匹配的事件中显示此 Agent。',
|
||||
searchEvents: '搜索事件…',
|
||||
noEventsFound: '没有匹配的事件',
|
||||
nameRequired: '名称不能为空',
|
||||
createSuccess: '创建成功',
|
||||
createError: '创建失败:',
|
||||
|
||||
@@ -499,7 +499,9 @@ const zhHant = {
|
||||
'限制此 Agent 可被機器人事件路由選擇的事件範圍。通常保持預設即可。',
|
||||
supportedEvents: '事件範圍',
|
||||
supportedEventsDescription:
|
||||
'每行一個事件模式,例如 *、message.received、group.*。Pipeline 固定僅支援 message.*。',
|
||||
'選擇全部事件、事件群組或具體事件。機器人路由只會在符合的事件中顯示此 Agent。',
|
||||
searchEvents: '搜尋事件…',
|
||||
noEventsFound: '沒有符合的事件',
|
||||
nameRequired: '名稱不能為空',
|
||||
createSuccess: '建立成功',
|
||||
createError: '建立失敗:',
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user