mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-28 13:17:14 +00:00
feat(bots): add platform event debugger
This commit is contained in:
@@ -188,11 +188,7 @@ try {
|
|||||||
.getByText(/Event Routing|事件路由|イベントルーティング/)
|
.getByText(/Event Routing|事件路由|イベントルーティング/)
|
||||||
.first()
|
.first()
|
||||||
.waitFor({ timeout: 15_000 });
|
.waitFor({ timeout: 15_000 });
|
||||||
await page
|
await page.getByText(/Supported events|支持的事件|対応イベント/).waitFor();
|
||||||
.getByText(
|
|
||||||
/Events this adapter can receive|此适配器可接收的事件|このアダプターが受信できるイベント/,
|
|
||||||
)
|
|
||||||
.waitFor();
|
|
||||||
await page
|
await page
|
||||||
.getByText(/Message received|收到消息|メッセージを受信/)
|
.getByText(/Message received|收到消息|メッセージを受信/)
|
||||||
.first()
|
.first()
|
||||||
@@ -237,6 +233,57 @@ try {
|
|||||||
.click();
|
.click();
|
||||||
await page.getByRole("dialog").waitFor({ state: "hidden" });
|
await page.getByRole("dialog").waitFor({ state: "hidden" });
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole("button", {
|
||||||
|
name: /Listen for platform events|监听平台事件|プラットフォームイベントを監視/,
|
||||||
|
})
|
||||||
|
.click();
|
||||||
|
const adapterDialog = page.getByRole("dialog");
|
||||||
|
await adapterDialog.waitFor();
|
||||||
|
await adapterDialog
|
||||||
|
.getByText(/Listening|正在监听|監視中/, { exact: true })
|
||||||
|
.waitFor({ timeout: 15_000 });
|
||||||
|
|
||||||
|
const inboundText = `adapter event ${paths.runId}`;
|
||||||
|
const inbound = await apiJson(
|
||||||
|
backendUrl,
|
||||||
|
`/bots/${encodeURIComponent(botId)}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
token,
|
||||||
|
body: {
|
||||||
|
session_id: `adapter-debug-${paths.runId}`,
|
||||||
|
session_type: "person",
|
||||||
|
sender: { id: "adapter-debug-user", name: "Adapter QA" },
|
||||||
|
message: [{ type: "Plain", text: inboundText }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
result.api.adapter_event_webhook = {
|
||||||
|
http_status: inbound.status,
|
||||||
|
code: inbound.json.code ?? null,
|
||||||
|
};
|
||||||
|
if (inbound.status >= 400 || inbound.json.code !== 0) {
|
||||||
|
throw new Error(
|
||||||
|
inbound.json.msg || "The HTTP Bot adapter rejected the inbound event.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await adapterDialog
|
||||||
|
.getByText(/Message received|收到消息|メッセージ受信/, { exact: true })
|
||||||
|
.waitFor({ timeout: 15_000 });
|
||||||
|
await adapterDialog.getByText("message.received", { exact: true }).waitFor();
|
||||||
|
await adapterDialog.getByText(inboundText, { exact: true }).waitFor();
|
||||||
|
result.visible_signals.push(
|
||||||
|
"adapter-event-listening",
|
||||||
|
"adapter-event-received",
|
||||||
|
"adapter-event-raw-code",
|
||||||
|
);
|
||||||
|
await adapterDialog
|
||||||
|
.getByRole("button", { name: /Close|关闭|閉じる/ })
|
||||||
|
.click();
|
||||||
|
await adapterDialog.waitFor({ state: "hidden" });
|
||||||
|
|
||||||
const text = await bodyText(page);
|
const text = await bodyText(page);
|
||||||
if (/\bEBA event\b/.test(text)) {
|
if (/\bEBA event\b/.test(text)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -291,7 +338,7 @@ try {
|
|||||||
}
|
}
|
||||||
result.status = "pass";
|
result.status = "pass";
|
||||||
result.reason =
|
result.reason =
|
||||||
"Bot event routing, dry-run, synthetic dispatch, and visible route status passed in the WebUI.";
|
"Bot event routing, dry-run, real adapter input, and visible route status passed in the WebUI.";
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!["blocked", "env_issue"].includes(result.status)) result.status = "fail";
|
if (!["blocked", "env_issue"].includes(result.status)) result.status = "fail";
|
||||||
result.reason = result.reason || error.message;
|
result.reason = result.reason || error.message;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
id: bot-event-routing-product-flow
|
id: bot-event-routing-product-flow
|
||||||
title: "Bot event routing can be configured and tested from the WebUI"
|
title: "Bot event routing and adapter input can be inspected from the WebUI"
|
||||||
mode: agent-browser
|
mode: agent-browser
|
||||||
area: bot
|
area: bot
|
||||||
type: feature
|
type: feature
|
||||||
@@ -33,16 +33,18 @@ steps:
|
|||||||
- "Confirm the adapter capability summary, friendly event name, target, and route status are visible."
|
- "Confirm the adapter capability summary, friendly event name, target, and route status are visible."
|
||||||
- "Confirm overlapping routes and unmatched-event fallback behavior are explained before save."
|
- "Confirm overlapping routes and unmatched-event fallback behavior are explained before save."
|
||||||
- "Open Test event route and run a dry-run against the current form."
|
- "Open Test event route and run a dry-run against the current form."
|
||||||
- "Run the saved runtime route with a synthetic event."
|
- "Open Platform event debugging and send a real inbound event through the HTTP Bot adapter."
|
||||||
- "Close the dialog and confirm the route card shows the latest discarded status."
|
- "Confirm the normalized event, raw event code, payload, and latest discarded route status are visible."
|
||||||
checks:
|
checks:
|
||||||
- "UI: A user can choose a channel and add a scenario-labeled behavior during initial Bot creation."
|
- "UI: A user can choose a channel and add a scenario-labeled behavior during initial Bot creation."
|
||||||
- "UI: Event routing uses user-facing labels and does not require the raw event name in the primary route card."
|
- "UI: Event routing uses user-facing labels and does not require the raw event name in the primary route card."
|
||||||
- "UI: Definite route shadowing and unmatched-event fallback behavior are visible without opening raw logs."
|
- "UI: Definite route shadowing and unmatched-event fallback behavior are visible without opening raw logs."
|
||||||
- "UI: Dry-run visibly reports that the route matched the discard processor."
|
- "UI: Dry-run visibly reports that the route matched the discard processor."
|
||||||
- "UI: Saved-route execution visibly succeeds, explains its side-effect boundary, and updates route status to discarded."
|
- "UI: Adapter event debugging is clearly separate from route preview and starts listening only after the dialog opens."
|
||||||
|
- "UI: A real adapter event shows its friendly name, raw code, and normalized event data."
|
||||||
|
- "UI: The route card updates to discarded after the real inbound event is handled."
|
||||||
- "Console: No unexpected frontend errors appear during the flow."
|
- "Console: No unexpected frontend errors appear during the flow."
|
||||||
- "Network: Bot, dry-run, route-status, and test-event requests return without 5xx responses."
|
- "Network: Bot, dry-run, route-status, log, and HTTP Bot webhook requests return without 5xx responses."
|
||||||
- "Cleanup: The temporary Bot is deleted after evidence is collected."
|
- "Cleanup: The temporary Bot is deleted after evidence is collected."
|
||||||
evidence_required:
|
evidence_required:
|
||||||
- ui
|
- ui
|
||||||
@@ -51,7 +53,8 @@ evidence_required:
|
|||||||
- api_diagnostic
|
- api_diagnostic
|
||||||
diagnostics:
|
diagnostics:
|
||||||
- "The fixture deliberately uses the discard processor so the product-flow test cannot invoke a model, tool, or external callback."
|
- "The fixture deliberately uses the discard processor so the product-flow test cannot invoke a model, tool, or external callback."
|
||||||
- "A passing API call without the visible matched and discarded UI states is not a pass."
|
- "The adapter dialog observes normalized platform events; it does not simulate route matching."
|
||||||
|
- "A passing webhook call without the visible adapter event and discarded UI states is not a pass."
|
||||||
troubleshooting:
|
troubleshooting:
|
||||||
- backend-not-listening
|
- backend-not-listening
|
||||||
- proxy-env-mismatch
|
- proxy-env-mismatch
|
||||||
|
|||||||
@@ -444,6 +444,26 @@ class RuntimeBot:
|
|||||||
compact[key] = value
|
compact[key] = value
|
||||||
return compact
|
return compact
|
||||||
|
|
||||||
|
async def _record_adapter_event(
|
||||||
|
self,
|
||||||
|
event: platform_events.EBAEvent,
|
||||||
|
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||||||
|
) -> dict[str, typing.Any]:
|
||||||
|
"""Record a normalized adapter event for the platform debugging surface."""
|
||||||
|
event_type = getattr(event, 'type', None) or event.__class__.__name__
|
||||||
|
metadata = {
|
||||||
|
'kind': 'adapter_event_received',
|
||||||
|
'event_type': event_type,
|
||||||
|
'event_data': self._compact_event_data(event),
|
||||||
|
'adapter': getattr(self.bot_entity, 'adapter', None) or adapter.__class__.__name__,
|
||||||
|
'bot_uuid': self.bot_entity.uuid,
|
||||||
|
}
|
||||||
|
await self.logger.info(
|
||||||
|
f'Platform adapter received {event_type}',
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
return metadata
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_entity_id(entity: typing.Any) -> str | None:
|
def _get_entity_id(entity: typing.Any) -> str | None:
|
||||||
entity_id = getattr(entity, 'id', None)
|
entity_id = getattr(entity, 'id', None)
|
||||||
@@ -864,11 +884,13 @@ class RuntimeBot:
|
|||||||
event: platform_events.EBAEvent,
|
event: platform_events.EBAEvent,
|
||||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
event.bot_uuid = self.bot_entity.uuid
|
||||||
|
await self._record_adapter_event(event, adapter)
|
||||||
|
|
||||||
if isinstance(event, platform_events.PlatformSpecificEvent) and event.action == 'interaction.submitted':
|
if isinstance(event, platform_events.PlatformSpecificEvent) and event.action == 'interaction.submitted':
|
||||||
await self._handle_interaction_submission(event, adapter)
|
await self._handle_interaction_submission(event, adapter)
|
||||||
return
|
return
|
||||||
|
|
||||||
event.bot_uuid = self.bot_entity.uuid
|
|
||||||
plugin_event = self._eba_event_to_plugin_event(event)
|
plugin_event = self._eba_event_to_plugin_event(event)
|
||||||
|
|
||||||
if plugin_event is not None:
|
if plugin_event is not None:
|
||||||
|
|||||||
@@ -95,6 +95,36 @@ class TestEventRouteTrace:
|
|||||||
assert metadata['target_uuid'] == 'agent-1'
|
assert metadata['target_uuid'] == 'agent-1'
|
||||||
assert metadata['status'] == 'failed'
|
assert metadata['status'] == 'failed'
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_adapter_event_log_exposes_normalized_input_without_platform_object(self):
|
||||||
|
"""Adapter debugging records the shared event shape without opaque SDK data."""
|
||||||
|
from langbot_plugin.api.entities.builtin.platform import entities, events, message
|
||||||
|
|
||||||
|
bot = self._make_bot([])
|
||||||
|
bot.bot_entity.adapter = 'test-adapter'
|
||||||
|
event = events.MessageReceivedEvent(
|
||||||
|
message_id='message-1',
|
||||||
|
message_chain=message.MessageChain([message.Plain(text='hello')]),
|
||||||
|
sender=entities.User(id='user-1', nickname='QA User'),
|
||||||
|
chat_type=entities.ChatType.PRIVATE,
|
||||||
|
chat_id='user-1',
|
||||||
|
source_platform_object={'access_token': 'must-not-be-logged'},
|
||||||
|
)
|
||||||
|
|
||||||
|
metadata = await bot._record_adapter_event(event, SimpleNamespace())
|
||||||
|
|
||||||
|
assert metadata['kind'] == 'adapter_event_received'
|
||||||
|
assert metadata['event_type'] == 'message.received'
|
||||||
|
assert metadata['adapter'] == 'test-adapter'
|
||||||
|
assert metadata['bot_uuid'] == 'bot-1'
|
||||||
|
assert metadata['event_data']['message_chain'] == [{'type': 'Plain', 'text': 'hello'}]
|
||||||
|
assert metadata['event_data']['sender']['id'] == 'user-1'
|
||||||
|
assert 'source_platform_object' not in metadata['event_data']
|
||||||
|
bot.logger.info.assert_awaited_once_with(
|
||||||
|
'Platform adapter received message.received',
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_dispatch_malformed_agent_config_fails_one_event_and_processes_next(self):
|
async def test_dispatch_malformed_agent_config_fails_one_event_and_processes_next(self):
|
||||||
"""Persisted malformed Agent config cannot escape the per-event route boundary."""
|
"""Persisted malformed Agent config cannot escape the per-event route boundary."""
|
||||||
|
|||||||
@@ -0,0 +1,335 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
AlertCircle,
|
||||||
|
ChevronDown,
|
||||||
|
RadioTower,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from '@/components/ui/collapsible';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { backendClient } from '@/app/infra/http';
|
||||||
|
import type { BotLog } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
|
||||||
|
import {
|
||||||
|
eventPatternDescription,
|
||||||
|
eventPatternLabel,
|
||||||
|
} from '@/app/home/components/event-patterns/event-pattern-groups';
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 1200;
|
||||||
|
const MAX_VISIBLE_EVENTS = 50;
|
||||||
|
|
||||||
|
interface ObservedAdapterEvent {
|
||||||
|
seqId: number;
|
||||||
|
timestamp: number;
|
||||||
|
eventType: string;
|
||||||
|
eventData: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListenerState = 'preparing' | 'listening' | 'error';
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function observedEventFromLog(log: BotLog): ObservedAdapterEvent | null {
|
||||||
|
const metadata = log.metadata;
|
||||||
|
if (!isRecord(metadata) || metadata.kind !== 'adapter_event_received') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventType = metadata.event_type;
|
||||||
|
if (typeof eventType !== 'string' || !eventType) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
seqId: log.seq_id,
|
||||||
|
timestamp: log.timestamp,
|
||||||
|
eventType,
|
||||||
|
eventData: isRecord(metadata.event_data) ? metadata.event_data : {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function findEventPreview(value: unknown, depth = 0): string | null {
|
||||||
|
if (depth > 4 || value === null || value === undefined) return null;
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const item of value) {
|
||||||
|
const preview = findEventPreview(item, depth + 1);
|
||||||
|
if (preview) return preview;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!isRecord(value)) return null;
|
||||||
|
|
||||||
|
for (const key of ['message_text', 'text', 'action']) {
|
||||||
|
const candidate = value[key];
|
||||||
|
if (typeof candidate === 'string' && candidate.trim()) {
|
||||||
|
const trimmed = candidate.trim();
|
||||||
|
return trimmed.length > 160 ? `${trimmed.slice(0, 160)}…` : trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const child of Object.values(value)) {
|
||||||
|
const preview = findEventPreview(child, depth + 1);
|
||||||
|
if (preview) return preview;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdapterEventDebugDialog({
|
||||||
|
botId,
|
||||||
|
adapterLabel,
|
||||||
|
}: {
|
||||||
|
botId?: string;
|
||||||
|
adapterLabel: string;
|
||||||
|
}) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [listenerState, setListenerState] =
|
||||||
|
useState<ListenerState>('preparing');
|
||||||
|
const [events, setEvents] = useState<ObservedAdapterEvent[]>([]);
|
||||||
|
const baselineSeqRef = useRef<number | null>(null);
|
||||||
|
const pollInFlightRef = useRef(false);
|
||||||
|
|
||||||
|
const platformName = adapterLabel || t('bots.adapterEventCurrentPlatform');
|
||||||
|
|
||||||
|
const pollLogs = useCallback(async () => {
|
||||||
|
if (!botId || pollInFlightRef.current) return;
|
||||||
|
pollInFlightRef.current = true;
|
||||||
|
try {
|
||||||
|
const response = await backendClient.getBotLogs(botId, {
|
||||||
|
from_index: -1,
|
||||||
|
max_count: 100,
|
||||||
|
});
|
||||||
|
const latestSeq = response.logs.reduce(
|
||||||
|
(maximum, log) => Math.max(maximum, log.seq_id),
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (baselineSeqRef.current === null) {
|
||||||
|
baselineSeqRef.current = latestSeq;
|
||||||
|
setListenerState('listening');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseline = baselineSeqRef.current;
|
||||||
|
const newlyObserved = response.logs
|
||||||
|
.filter((log) => log.seq_id > baseline)
|
||||||
|
.map(observedEventFromLog)
|
||||||
|
.filter((event): event is ObservedAdapterEvent => event !== null);
|
||||||
|
|
||||||
|
if (newlyObserved.length > 0) {
|
||||||
|
setEvents((current) => {
|
||||||
|
const bySeqId = new Map(
|
||||||
|
[...newlyObserved, ...current].map((event) => [event.seqId, event]),
|
||||||
|
);
|
||||||
|
return Array.from(bySeqId.values())
|
||||||
|
.sort((left, right) => right.seqId - left.seqId)
|
||||||
|
.slice(0, MAX_VISIBLE_EVENTS);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
baselineSeqRef.current = Math.max(baseline, latestSeq);
|
||||||
|
setListenerState('listening');
|
||||||
|
} catch {
|
||||||
|
setListenerState('error');
|
||||||
|
} finally {
|
||||||
|
pollInFlightRef.current = false;
|
||||||
|
}
|
||||||
|
}, [botId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !botId) return;
|
||||||
|
|
||||||
|
baselineSeqRef.current = null;
|
||||||
|
pollInFlightRef.current = false;
|
||||||
|
setEvents([]);
|
||||||
|
setListenerState('preparing');
|
||||||
|
void pollLogs();
|
||||||
|
const interval = window.setInterval(
|
||||||
|
() => void pollLogs(),
|
||||||
|
POLL_INTERVAL_MS,
|
||||||
|
);
|
||||||
|
return () => window.clearInterval(interval);
|
||||||
|
}, [botId, open, pollLogs]);
|
||||||
|
|
||||||
|
const status = useMemo(() => {
|
||||||
|
if (listenerState === 'error') {
|
||||||
|
return {
|
||||||
|
text: t('bots.adapterEventListenerUnavailable'),
|
||||||
|
dot: 'bg-destructive',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (listenerState === 'listening') {
|
||||||
|
return {
|
||||||
|
text: t('bots.adapterEventListening'),
|
||||||
|
dot: 'bg-emerald-500',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
text: t('bots.adapterEventPreparing'),
|
||||||
|
dot: 'bg-amber-500',
|
||||||
|
};
|
||||||
|
}, [listenerState, t]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={!botId}
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
title={!botId ? t('bots.adapterEventNeedsSavedBot') : undefined}
|
||||||
|
>
|
||||||
|
<RadioTower className="mr-1 h-4 w-4" />
|
||||||
|
{t('bots.adapterEventDebugAction')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent className="sm:max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 pr-8">
|
||||||
|
<DialogTitle>{t('bots.adapterEventDebugTitle')}</DialogTitle>
|
||||||
|
<Badge variant="outline" className="gap-1.5 font-normal">
|
||||||
|
<span className={`size-2 rounded-full ${status.dot}`} />
|
||||||
|
{status.text}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<DialogDescription>
|
||||||
|
{t('bots.adapterEventDebugDescription', {
|
||||||
|
platform: platformName,
|
||||||
|
})}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Alert className="bg-muted/30">
|
||||||
|
<Activity className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
{t('bots.adapterEventObserveOnly')}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{listenerState === 'error' && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
{t('bots.adapterEventLoadFailed')}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{t('bots.adapterEventReceivedCount', { count: events.length })}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={events.length === 0}
|
||||||
|
onClick={() => setEvents([])}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-1 h-4 w-4" />
|
||||||
|
{t('bots.adapterEventClear')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="h-[min(52vh,420px)] rounded-lg border">
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<div className="flex h-full min-h-64 flex-col items-center justify-center px-6 text-center">
|
||||||
|
<RadioTower className="mb-3 h-8 w-8 text-muted-foreground" />
|
||||||
|
<p className="font-medium">
|
||||||
|
{t('bots.adapterEventEmptyTitle')}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 max-w-md text-sm text-muted-foreground">
|
||||||
|
{t('bots.adapterEventEmptyDescription', {
|
||||||
|
platform: platformName,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3 p-3">
|
||||||
|
{events.map((event) => {
|
||||||
|
const preview = findEventPreview(event.eventData);
|
||||||
|
return (
|
||||||
|
<Card key={event.seqId} className="gap-0 py-0">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex min-w-0 items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium">
|
||||||
|
{eventPatternLabel(event.eventType, t)}
|
||||||
|
</p>
|
||||||
|
<code className="mt-1 block truncate text-xs text-muted-foreground">
|
||||||
|
{event.eventType}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<time className="shrink-0 text-xs text-muted-foreground">
|
||||||
|
{new Date(
|
||||||
|
event.timestamp * 1000,
|
||||||
|
).toLocaleTimeString(i18n.language, {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
})}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
{preview ||
|
||||||
|
eventPatternDescription(event.eventType, t)}
|
||||||
|
</p>
|
||||||
|
<Collapsible className="mt-3">
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 px-2 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
{t('bots.adapterEventData')}
|
||||||
|
<ChevronDown className="ml-1 h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<pre className="mt-2 max-h-64 overflow-auto rounded-md bg-muted p-3 text-xs leading-relaxed">
|
||||||
|
{JSON.stringify(event.eventData, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
{t('common.close')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -703,6 +703,11 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
|
|||||||
<EventBindingsEditor
|
<EventBindingsEditor
|
||||||
form={form}
|
form={form}
|
||||||
botId={initBotId}
|
botId={initBotId}
|
||||||
|
adapterLabel={
|
||||||
|
adapterNameList.find(
|
||||||
|
(adapter) => adapter.value === currentAdapter,
|
||||||
|
)?.label ?? currentAdapter
|
||||||
|
}
|
||||||
supportedEvents={adapterSupportedEvents[currentAdapter] || []}
|
supportedEvents={adapterSupportedEvents[currentAdapter] || []}
|
||||||
agentOptions={agentNameList}
|
agentOptions={agentNameList}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ import {
|
|||||||
groupEventPatterns,
|
groupEventPatterns,
|
||||||
} from '@/app/home/components/event-patterns/event-pattern-groups';
|
} from '@/app/home/components/event-patterns/event-pattern-groups';
|
||||||
import EventSelectOptionContent from '@/app/home/components/event-patterns/EventSelectOptionContent';
|
import EventSelectOptionContent from '@/app/home/components/event-patterns/EventSelectOptionContent';
|
||||||
|
import AdapterEventDebugDialog from './AdapterEventDebugDialog';
|
||||||
|
|
||||||
export const PIPELINE_DISCARD = '__discard__';
|
export const PIPELINE_DISCARD = '__discard__';
|
||||||
|
|
||||||
@@ -128,6 +129,7 @@ export const PIPELINE_DISCARD = '__discard__';
|
|||||||
interface EventBindingsEditorProps {
|
interface EventBindingsEditorProps {
|
||||||
form: UseFormReturn<any>;
|
form: UseFormReturn<any>;
|
||||||
botId?: string;
|
botId?: string;
|
||||||
|
adapterLabel: string;
|
||||||
supportedEvents: string[];
|
supportedEvents: string[];
|
||||||
agentOptions: Agent[];
|
agentOptions: Agent[];
|
||||||
}
|
}
|
||||||
@@ -1426,6 +1428,7 @@ function SortableBindingCard({
|
|||||||
export default function EventBindingsEditor({
|
export default function EventBindingsEditor({
|
||||||
form,
|
form,
|
||||||
botId,
|
botId,
|
||||||
|
adapterLabel,
|
||||||
supportedEvents,
|
supportedEvents,
|
||||||
agentOptions,
|
agentOptions,
|
||||||
}: EventBindingsEditorProps) {
|
}: EventBindingsEditorProps) {
|
||||||
@@ -1797,6 +1800,7 @@ export default function EventBindingsEditor({
|
|||||||
eventOptions={dryRunEventOptions}
|
eventOptions={dryRunEventOptions}
|
||||||
agentOptions={agentOptions}
|
agentOptions={agentOptions}
|
||||||
/>
|
/>
|
||||||
|
<AdapterEventDebugDialog botId={botId} adapterLabel={adapterLabel} />
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ export interface GetBotLogsResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface BotLog {
|
export interface BotLog {
|
||||||
images: [];
|
images: string[];
|
||||||
level: string;
|
level: string;
|
||||||
message_session_id: string;
|
message_session_id: string;
|
||||||
|
metadata?: Record<string, unknown> | null;
|
||||||
seq_id: number;
|
seq_id: number;
|
||||||
text: string;
|
text: string;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
|
|||||||
@@ -451,6 +451,26 @@ const enUS = {
|
|||||||
routeFallbackIgnored:
|
routeFallbackIgnored:
|
||||||
'Events that match no route are ignored. Add a catch-all route only when every event needs an explicit outcome.',
|
'Events that match no route are ignored. Add a catch-all route only when every event needs an explicit outcome.',
|
||||||
testRoute: 'Check route',
|
testRoute: 'Check route',
|
||||||
|
adapterEventDebugAction: 'Listen for platform events',
|
||||||
|
adapterEventDebugTitle: 'Platform event debugging',
|
||||||
|
adapterEventDebugDescription:
|
||||||
|
'Trigger an event in {{platform}}. It will appear here when the adapter receives it.',
|
||||||
|
adapterEventObserveOnly:
|
||||||
|
'This window only observes events. Incoming events still follow the current routes.',
|
||||||
|
adapterEventPreparing: 'Preparing',
|
||||||
|
adapterEventListening: 'Listening',
|
||||||
|
adapterEventListenerUnavailable: 'Listening interrupted',
|
||||||
|
adapterEventLoadFailed:
|
||||||
|
'Platform events could not be read. Make sure the bot is running, then try again.',
|
||||||
|
adapterEventReceivedCount: '{{count}} events received',
|
||||||
|
adapterEventClear: 'Clear',
|
||||||
|
adapterEventEmptyTitle: 'Waiting for a platform event',
|
||||||
|
adapterEventEmptyDescription:
|
||||||
|
'Send a message or trigger an event in {{platform}}.',
|
||||||
|
adapterEventData: 'View event data',
|
||||||
|
adapterEventNeedsSavedBot:
|
||||||
|
'Save the bot before listening for platform events.',
|
||||||
|
adapterEventCurrentPlatform: 'the current platform',
|
||||||
refreshRouteStatus: 'Refresh status',
|
refreshRouteStatus: 'Refresh status',
|
||||||
routeStatusIdle: 'No run yet',
|
routeStatusIdle: 'No run yet',
|
||||||
routeStatusRefreshFailed: 'Failed to refresh route status.',
|
routeStatusRefreshFailed: 'Failed to refresh route status.',
|
||||||
|
|||||||
@@ -458,6 +458,26 @@ const jaJP = {
|
|||||||
routeFallbackIgnored:
|
routeFallbackIgnored:
|
||||||
'どのルートにも一致しないイベントは無視されます。すべてのイベントに明示的な結果が必要な場合のみ、フォールバックを追加してください。',
|
'どのルートにも一致しないイベントは無視されます。すべてのイベントに明示的な結果が必要な場合のみ、フォールバックを追加してください。',
|
||||||
testRoute: 'ルートを確認',
|
testRoute: 'ルートを確認',
|
||||||
|
adapterEventDebugAction: 'プラットフォームイベントを監視',
|
||||||
|
adapterEventDebugTitle: 'プラットフォームイベントのデバッグ',
|
||||||
|
adapterEventDebugDescription:
|
||||||
|
'{{platform}} でイベントを発生させると、アダプターの受信後にここへ表示されます。',
|
||||||
|
adapterEventObserveOnly:
|
||||||
|
'この画面はイベントを監視するだけです。受信イベントは現在のルートで通常どおり処理されます。',
|
||||||
|
adapterEventPreparing: '準備中',
|
||||||
|
adapterEventListening: '監視中',
|
||||||
|
adapterEventListenerUnavailable: '監視が中断されました',
|
||||||
|
adapterEventLoadFailed:
|
||||||
|
'プラットフォームイベントを取得できません。ボットが起動していることを確認して、もう一度お試しください。',
|
||||||
|
adapterEventReceivedCount: '{{count}} 件のイベントを受信',
|
||||||
|
adapterEventClear: 'クリア',
|
||||||
|
adapterEventEmptyTitle: 'プラットフォームイベントを待機中',
|
||||||
|
adapterEventEmptyDescription:
|
||||||
|
'{{platform}} でメッセージを送るか、イベントを発生させてください。',
|
||||||
|
adapterEventData: 'イベントデータを表示',
|
||||||
|
adapterEventNeedsSavedBot:
|
||||||
|
'プラットフォームイベントを監視する前にボットを保存してください。',
|
||||||
|
adapterEventCurrentPlatform: '現在のプラットフォーム',
|
||||||
refreshRouteStatus: '状態を更新',
|
refreshRouteStatus: '状態を更新',
|
||||||
routeStatusIdle: '実行記録なし',
|
routeStatusIdle: '実行記録なし',
|
||||||
routeStatusRefreshFailed: 'ルート状態の更新に失敗しました。',
|
routeStatusRefreshFailed: 'ルート状態の更新に失敗しました。',
|
||||||
|
|||||||
@@ -430,6 +430,24 @@ const zhHans = {
|
|||||||
routeFallbackIgnored:
|
routeFallbackIgnored:
|
||||||
'未命中任何路由的事件会被忽略。只有需要为每个事件指定结果时,才添加全局兜底路由。',
|
'未命中任何路由的事件会被忽略。只有需要为每个事件指定结果时,才添加全局兜底路由。',
|
||||||
testRoute: '检查路由',
|
testRoute: '检查路由',
|
||||||
|
adapterEventDebugAction: '监听平台事件',
|
||||||
|
adapterEventDebugTitle: '平台事件调试',
|
||||||
|
adapterEventDebugDescription:
|
||||||
|
'在 {{platform}} 中触发事件,适配器收到后会显示在这里。',
|
||||||
|
adapterEventObserveOnly:
|
||||||
|
'此窗口只负责观察;收到的事件仍会按当前路由正常处理。',
|
||||||
|
adapterEventPreparing: '准备中',
|
||||||
|
adapterEventListening: '正在监听',
|
||||||
|
adapterEventListenerUnavailable: '监听已中断',
|
||||||
|
adapterEventLoadFailed:
|
||||||
|
'无法读取平台事件。请确认机器人正在运行,然后稍后重试。',
|
||||||
|
adapterEventReceivedCount: '已收到 {{count}} 个事件',
|
||||||
|
adapterEventClear: '清空',
|
||||||
|
adapterEventEmptyTitle: '等待平台事件',
|
||||||
|
adapterEventEmptyDescription: '请在 {{platform}} 中发送消息或触发事件。',
|
||||||
|
adapterEventData: '查看事件数据',
|
||||||
|
adapterEventNeedsSavedBot: '请先保存机器人后再监听平台事件。',
|
||||||
|
adapterEventCurrentPlatform: '当前平台',
|
||||||
refreshRouteStatus: '刷新状态',
|
refreshRouteStatus: '刷新状态',
|
||||||
routeStatusIdle: '暂无运行记录',
|
routeStatusIdle: '暂无运行记录',
|
||||||
routeStatusRefreshFailed: '刷新路由状态失败。',
|
routeStatusRefreshFailed: '刷新路由状态失败。',
|
||||||
|
|||||||
@@ -389,6 +389,44 @@ test.describe('bot advanced flows', () => {
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
let botLogPollCount = 0;
|
||||||
|
await page.route('**/api/v1/platform/bots/*/logs', (route) => {
|
||||||
|
botLogPollCount += 1;
|
||||||
|
const logs =
|
||||||
|
botLogPollCount === 1
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
seq_id: 7,
|
||||||
|
timestamp: Math.floor(Date.now() / 1000),
|
||||||
|
level: 'info',
|
||||||
|
text: 'Platform adapter received message.received',
|
||||||
|
images: [],
|
||||||
|
message_session_id: '',
|
||||||
|
metadata: {
|
||||||
|
kind: 'adapter_event_received',
|
||||||
|
event_type: 'message.received',
|
||||||
|
adapter: 'playwright-adapter',
|
||||||
|
bot_uuid: 'bot-1',
|
||||||
|
event_data: {
|
||||||
|
type: 'message.received',
|
||||||
|
chat_type: 'private',
|
||||||
|
chat_id: 'test-user',
|
||||||
|
message_chain: [{ type: 'Plain', text: 'adapter hello' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
msg: 'ok',
|
||||||
|
data: { logs, total_count: logs.length },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
await page.goto('/home/bots?id=new');
|
await page.goto('/home/bots?id=new');
|
||||||
await selectPlaywrightAdapter(page);
|
await selectPlaywrightAdapter(page);
|
||||||
await page.locator('input[name="name"]').fill('Route Status Bot');
|
await page.locator('input[name="name"]').fill('Route Status Bot');
|
||||||
@@ -492,6 +530,35 @@ test.describe('bot advanced flows', () => {
|
|||||||
const dialogBox = await routeDialog.boundingBox();
|
const dialogBox = await routeDialog.boundingBox();
|
||||||
expect(dialogBox).not.toBeNull();
|
expect(dialogBox).not.toBeNull();
|
||||||
expect(dialogBox!.height).toBeLessThan(500);
|
expect(dialogBox!.height).toBeLessThan(500);
|
||||||
|
|
||||||
|
await routeDialog.getByRole('button', { name: 'Close' }).first().click();
|
||||||
|
await routingCard
|
||||||
|
.getByRole('button', { name: 'Listen for platform events' })
|
||||||
|
.click();
|
||||||
|
const adapterDialog = page.getByRole('dialog');
|
||||||
|
await expect(
|
||||||
|
adapterDialog.getByText('Platform event debugging', { exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(adapterDialog).toContainText('Playwright Adapter');
|
||||||
|
await expect(adapterDialog).toContainText(
|
||||||
|
'This window only observes events. Incoming events still follow the current routes.',
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
adapterDialog.getByText('Message received', { exact: true }),
|
||||||
|
).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(adapterDialog.getByText('message.received')).toBeVisible();
|
||||||
|
await expect(adapterDialog.getByText('adapter hello')).toBeVisible();
|
||||||
|
await adapterDialog
|
||||||
|
.getByRole('button', { name: 'View event data' })
|
||||||
|
.click();
|
||||||
|
await expect(
|
||||||
|
adapterDialog.getByText(/"chat_id": "test-user"/),
|
||||||
|
).toBeVisible();
|
||||||
|
await adapterDialog.getByRole('button', { name: 'Clear' }).click();
|
||||||
|
await expect(adapterDialog.getByText('0 events received')).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
adapterDialog.getByText('Waiting for a platform event'),
|
||||||
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('toggles bot enable/disable state', async ({ page }) => {
|
test('toggles bot enable/disable state', async ({ page }) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user