mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-08 02:27:14 +00:00
feat(processors): streamline event debugging and run inspection
This commit is contained in:
@@ -99,7 +99,7 @@ The activation sequence is explicit:
|
||||
|
||||
1. Install a plugin containing an EventProcessor component.
|
||||
2. Create an Event processor in the Processors area.
|
||||
3. Select its plugin component and enter any component-defined configuration.
|
||||
3. Open its detail page, select a plugin component, and save its configuration.
|
||||
4. Bind a Bot event to that processor instance in the existing event routing UI.
|
||||
|
||||
Installation and processor creation alone do not subscribe to Bot events.
|
||||
@@ -112,20 +112,26 @@ There is no automatic EBA broadcast to installed EventListeners. Keep Pipeline h
|
||||
plugins must explicitly adopt the new component and be bound by the user; do not
|
||||
create subscriptions during migration.
|
||||
|
||||
Validate component availability, event compatibility, Workspace ownership, and
|
||||
instance identity at creation/update and again at invocation. A disabled or
|
||||
An unconfigured instance has no supported events and cannot execute. Validate
|
||||
component availability, event compatibility, Workspace ownership, and instance
|
||||
identity when configuring the instance and again at invocation. A disabled or
|
||||
unavailable plugin leaves the instance visible with an actionable unavailable
|
||||
status. It must not silently fall back to Agent or Pipeline.
|
||||
|
||||
## Compact UI
|
||||
|
||||
Creation adds a third type next to Agent and Pipeline, followed by a component
|
||||
selector and basic instance information. Show configuration fields only when the
|
||||
component declares them. If no component is installed, show a relevant plugin
|
||||
installation entry point; installing still does not create a binding.
|
||||
Creation adds a third type next to Agent and Pipeline and asks only for basic
|
||||
instance information. Select the plugin component in the detail-page header.
|
||||
Keep component-defined configuration in the adjacent Plugin settings popover.
|
||||
If no component is installed, show a relevant plugin installation entry point;
|
||||
installing still does not create a binding.
|
||||
|
||||
The detail page prioritizes a single run list. Selecting a run shows a chronological
|
||||
trace of the incoming event, handler logs, outgoing actions/messages, and outcome.
|
||||
The detail page shows event debugging on the left and logs on the right without
|
||||
view-switching tabs. A compact run list shows event type, time, status and known
|
||||
processing duration. Selecting a row shows that run's identity, input, logs,
|
||||
actions and outcome below. There is no shared timeline between unrelated runs.
|
||||
The additive `created_at_ms`, `started_at_ms`, and `finished_at_ms` fields retain
|
||||
Host lifecycle precision for elapsed-time display.
|
||||
Keep payloads and error details collapsed until expanded. Distinguish attempted
|
||||
delivery from confirmed delivery and display the actual destination.
|
||||
|
||||
|
||||
+1
-1
@@ -232,4 +232,4 @@ line-ending = "auto"
|
||||
|
||||
[tool.uv.sources]
|
||||
# Development contract: update to the matching SDK release before publishing.
|
||||
langbot-plugin = { git = "https://github.com/langbot-app/langbot-plugin-sdk", rev = "23011398160cedcd4ef090054692bc2a4b34ee9f" }
|
||||
langbot-plugin = { git = "https://github.com/langbot-app/langbot-plugin-sdk", rev = "f82b3ce935f9a33afee389fb39fe8dc29a45b615" }
|
||||
|
||||
@@ -116,9 +116,14 @@ already have a default pipeline.
|
||||
|
||||
## Event processors
|
||||
|
||||
Install the plugin, discover its component with `get_processor_metadata`, then
|
||||
create a processor with `kind: "event_processor"`, `component_ref` and optional
|
||||
`parameters`. Bind bot events to this instance with `target_type: "event_processor"`
|
||||
Create a processor with `kind: "event_processor"` and basic information. Without
|
||||
a component it supports no events. Discover installed components with
|
||||
`get_processor_metadata`, then use `update_processor` with `component_ref` and
|
||||
optional `parameters`. API callers may also supply these when creating an instance. Bind bot events to this instance with `target_type: "event_processor"`
|
||||
and `target_id` equal to its UUID. Installation alone never activates a handler.
|
||||
`debug_agent` accepts the complete typed EBA event in `payload.data` for this kind.
|
||||
Legacy EventListener plugins remain in the Pipeline lifecycle.
|
||||
|
||||
`list_processor_runs` includes `created_at_ms`, `started_at_ms`, and
|
||||
`finished_at_ms`: Host lifecycle times in epoch milliseconds. Use the start and finish times for elapsed processing time; select a run and call `get_processor_run_events` for its
|
||||
logs and action results. These times are not internal plugin profiling data.
|
||||
|
||||
@@ -776,6 +776,9 @@ class RunLedgerStore:
|
||||
'dispatch_attempts': row.dispatch_attempts,
|
||||
'last_claimed_at': _datetime_to_epoch(row.last_claimed_at),
|
||||
'created_at': _datetime_to_epoch(row.created_at),
|
||||
'created_at_ms': round(_as_utc(row.created_at).timestamp() * 1000) if row.created_at else None,
|
||||
'started_at_ms': round(_as_utc(row.started_at).timestamp() * 1000) if row.started_at else None,
|
||||
'finished_at_ms': round(_as_utc(row.finished_at).timestamp() * 1000) if row.finished_at else None,
|
||||
'started_at': _datetime_to_epoch(row.started_at),
|
||||
'finished_at': _datetime_to_epoch(row.finished_at),
|
||||
'updated_at': _datetime_to_epoch(row.updated_at),
|
||||
|
||||
@@ -488,6 +488,9 @@ class AgentService:
|
||||
if not isinstance(config, dict):
|
||||
raise ValueError('Processor configuration must be an object')
|
||||
component_ref = data.get('component_ref') or (existing.component_ref if existing is not None else None)
|
||||
if component_ref is None and not config and not data.get('parameters'):
|
||||
# An unconfigured instance cannot subscribe to or execute any events.
|
||||
return {}, None, []
|
||||
if not isinstance(component_ref, str) or not component_ref.startswith('event_processor:'):
|
||||
raise ValueError('Select an installed EventProcessor component')
|
||||
try:
|
||||
|
||||
@@ -188,8 +188,9 @@ class LangBotMCPServer:
|
||||
@mcp.tool(
|
||||
description=(
|
||||
'Create an Agent, Pipeline or Event processor. Set `processor_data.kind` to '
|
||||
'`agent`, `pipeline` or `event_processor`. Event processors require an installed component_ref '
|
||||
'from get_processor_metadata; optional parameters configure the instance. Returns UUID and kind.'
|
||||
'`agent`, `pipeline` or `event_processor`. Event processors may be created without a component; '
|
||||
'then use update_processor with an installed component_ref from get_processor_metadata and optional '
|
||||
'parameters. Unconfigured instances support no events. Returns UUID and kind.'
|
||||
)
|
||||
)
|
||||
async def create_processor(processor_data: dict) -> str:
|
||||
@@ -213,7 +214,10 @@ class LangBotMCPServer:
|
||||
context = _authorized(Permission.RESOURCE_VIEW)
|
||||
return _dump(await ap.agent_service.get_agent_metadata(context))
|
||||
|
||||
@mcp.tool(description='List one Event processor instance run history; use before_id to page older runs.')
|
||||
@mcp.tool(
|
||||
description='List one Event processor instance run history; use before_id to page older runs. '
|
||||
'created_at_ms, started_at_ms and finished_at_ms are Host lifecycle times in epoch milliseconds.'
|
||||
)
|
||||
async def list_processor_runs(processor_uuid: str, before_id: int | None = None) -> str:
|
||||
context = _authorized(Permission.RESOURCE_VIEW)
|
||||
return _dump(await ap.agent_service.get_processor_runs(context, processor_uuid, before_id=before_id))
|
||||
|
||||
@@ -455,3 +455,20 @@ async def test_processor_instance_history_filters_count_and_pages(store):
|
||||
)
|
||||
assert (total, more) == (2, False)
|
||||
assert second[0]['run_id'] == 'run-0'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_lifecycle_retains_milliseconds(store, monkeypatch):
|
||||
started = datetime.datetime(2026, 9, 8, 0, 0, 0, 123000, tzinfo=UTC)
|
||||
monkeypatch.setattr('langbot.pkg.agent.runner.run_ledger_store._utc_now', lambda: started)
|
||||
run = await store.create_run(
|
||||
run_id='run-ms', event_id='evt-ms', binding_id='binding-ms', runner_id='runner-ms', status='running'
|
||||
)
|
||||
assert run['created_at_ms'] == round(started.timestamp() * 1000)
|
||||
assert run['started_at_ms'] == run['created_at_ms']
|
||||
assert run['finished_at_ms'] is None
|
||||
finished = started + datetime.timedelta(milliseconds=275)
|
||||
monkeypatch.setattr('langbot.pkg.agent.runner.run_ledger_store._utc_now', lambda: finished)
|
||||
await store.finalize_run(run_id='run-ms', status='completed')
|
||||
saved = await store.get_run('run-ms')
|
||||
assert saved['finished_at_ms'] - saved['started_at_ms'] == 275
|
||||
|
||||
@@ -770,6 +770,20 @@ class TestAgentServiceCreateUpdateDelete:
|
||||
)
|
||||
|
||||
|
||||
async def test_event_processor_can_be_created_before_selecting_a_plugin():
|
||||
app = _make_app()
|
||||
service = AgentService(app)
|
||||
result = await service.create_agent(
|
||||
WORKSPACE_UUID,
|
||||
{'kind': 'event_processor', 'name': 'Unconfigured', 'supported_event_patterns': ['*']},
|
||||
)
|
||||
values = _compiled_params(app.persistence_mgr.execute_async.call_args.args[0])
|
||||
assert result['kind'] == 'event_processor'
|
||||
assert values['component_ref'] is None
|
||||
assert values['supported_event_patterns'] == []
|
||||
assert values['config'] == {}
|
||||
|
||||
|
||||
async def test_event_processor_creation_uses_installed_component_scope():
|
||||
app = _make_app()
|
||||
ref = 'event_processor:test/welcome/default'
|
||||
@@ -797,6 +811,34 @@ async def test_event_processor_creation_uses_installed_component_scope():
|
||||
assert values['config']['runner_config'][ref] == {'greeting': 'Hi'}
|
||||
|
||||
|
||||
async def test_unconfigured_event_processor_can_select_a_plugin_after_creation():
|
||||
app = _make_app()
|
||||
row = _agent_row(config={})
|
||||
row.kind = 'event_processor'
|
||||
row.component_ref = None
|
||||
ref = 'event_processor:test/welcome/default'
|
||||
app.agent_runner_registry = SimpleNamespace(
|
||||
get=AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
component_kind='EventProcessor',
|
||||
supported_event_patterns=['group.member_joined'],
|
||||
config_schema=[{'name': 'greeting', 'required': True}],
|
||||
)
|
||||
)
|
||||
)
|
||||
service = AgentService(app)
|
||||
service._get_agent_row = AsyncMock(return_value=row)
|
||||
await service.update_agent(
|
||||
WORKSPACE_UUID,
|
||||
row.uuid,
|
||||
{'component_ref': ref, 'parameters': {'greeting': 'Hi'}, 'supported_event_patterns': ['*']},
|
||||
)
|
||||
values = _compiled_update_values(app.persistence_mgr.execute_async.call_args.args[0])
|
||||
assert values['component_ref'] == ref
|
||||
assert values['supported_event_patterns'] == ['group.member_joined']
|
||||
assert values['config']['runner_config'][ref] == {'greeting': 'Hi'}
|
||||
|
||||
|
||||
async def test_event_processor_rejects_invalid_component_and_missing_parameters():
|
||||
app = _make_app()
|
||||
service = AgentService(app)
|
||||
|
||||
@@ -2119,7 +2119,7 @@ requires-dist = [
|
||||
{ name = "ebooklib", specifier = ">=0.18" },
|
||||
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
||||
{ name = "html2text", specifier = ">=2024.2.26" },
|
||||
{ name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=23011398160cedcd4ef090054692bc2a4b34ee9f" },
|
||||
{ name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=f82b3ce935f9a33afee389fb39fe8dc29a45b615" },
|
||||
{ name = "langchain", specifier = ">=1.3.9" },
|
||||
{ name = "langchain-core", specifier = ">=1.3.3" },
|
||||
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
|
||||
@@ -2186,7 +2186,7 @@ dev = [
|
||||
[[package]]
|
||||
name = "langbot-plugin"
|
||||
version = "0.5.5"
|
||||
source = { git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=23011398160cedcd4ef090054692bc2a4b34ee9f#23011398160cedcd4ef090054692bc2a4b34ee9f" }
|
||||
source = { git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=f82b3ce935f9a33afee389fb39fe8dc29a45b615#f82b3ce935f9a33afee389fb39fe8dc29a45b615" }
|
||||
dependencies = [
|
||||
{ name = "aiofiles" },
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -174,6 +174,8 @@ export default function AgentDetailContent({ id }: { id: string }) {
|
||||
id={id}
|
||||
agent={agent}
|
||||
canManage={canManage}
|
||||
canOperate={canOperate}
|
||||
availableEventTypes={availableEventTypes}
|
||||
onDelete={() => setDeleteConfirmOpen(true)}
|
||||
onEdit={() => setBasicInfoOpen(true)}
|
||||
onSaved={() => {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { FileCode2, RefreshCw, Settings2, Trash2, Pencil } from 'lucide-react';
|
||||
import { eventPatternLabel } from '@/app/home/components/event-patterns/event-pattern-groups';
|
||||
import { RefreshCw, Trash2, ScrollText } from 'lucide-react';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import { toast } from 'sonner';
|
||||
import type {
|
||||
Agent,
|
||||
@@ -14,12 +16,23 @@ import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import ProcessorDetailWorkbench from '@/app/home/components/processor-detail/ProcessorDetailWorkbench';
|
||||
import EntityTitleEditButton from '@/app/home/components/entity-basic-info/EntityTitleEditButton';
|
||||
import AgentDebugPanel from './components/AgentDebugPanel';
|
||||
import EventProcessorTrace, {
|
||||
ProcessorPayload,
|
||||
} from './components/EventProcessorTrace';
|
||||
import ProcessorRunList from './components/ProcessorRunList';
|
||||
import EventProcessorSettings from './components/EventProcessorSettings';
|
||||
|
||||
export default function EventProcessorDetailContent({
|
||||
agent,
|
||||
id,
|
||||
canManage,
|
||||
canOperate,
|
||||
availableEventTypes,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onSaved,
|
||||
@@ -27,6 +40,8 @@ export default function EventProcessorDetailContent({
|
||||
agent: Agent;
|
||||
id: string;
|
||||
canManage: boolean;
|
||||
canOperate: boolean;
|
||||
availableEventTypes: string[];
|
||||
onDelete: () => void;
|
||||
onEdit: () => void;
|
||||
onSaved: () => void;
|
||||
@@ -46,6 +61,13 @@ export default function EventProcessorDetailContent({
|
||||
>
|
||||
)[agent.component_ref ?? ''] ?? {};
|
||||
const [parameters, setParameters] = useState(initialParameters);
|
||||
const [savedConfig, setSavedConfig] = useState({
|
||||
componentRef,
|
||||
parameters: initialParameters,
|
||||
});
|
||||
const dirty =
|
||||
componentRef !== savedConfig.componentRef ||
|
||||
!isEqual(parameters, savedConfig.parameters);
|
||||
const [runs, setRuns] = useState<ProcessorRun[]>([]);
|
||||
const [cursor, setCursor] = useState<number | null>(null);
|
||||
const [selected, setSelected] = useState<ProcessorRun | null>(null);
|
||||
@@ -55,11 +77,11 @@ export default function EventProcessorDetailContent({
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [pagingRuns, setPagingRuns] = useState(false);
|
||||
const [pagingEvents, setPagingEvents] = useState(false);
|
||||
const [configOpen, setConfigOpen] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const validate = useRef<(() => Promise<boolean>) | null>(null);
|
||||
const requestVersion = useRef(0);
|
||||
const available = components.some((item) => item.id === agent.component_ref);
|
||||
const component = components.find((item) => item.id === componentRef);
|
||||
const available = Boolean(component);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setFailed(false);
|
||||
@@ -88,20 +110,38 @@ export default function EventProcessorDetailContent({
|
||||
[id],
|
||||
);
|
||||
|
||||
async function openRun(run: ProcessorRun) {
|
||||
const version = ++requestVersion.current;
|
||||
setSelected(run);
|
||||
setEvents([]);
|
||||
setEventCursor(null);
|
||||
const openRun = useCallback(
|
||||
async (run: ProcessorRun) => {
|
||||
const version = ++requestVersion.current;
|
||||
setSelected(run);
|
||||
setEvents([]);
|
||||
setEventCursor(null);
|
||||
try {
|
||||
const page = await httpClient.getProcessorRunEvents(id, run.run_id);
|
||||
if (version !== requestVersion.current) return;
|
||||
setSelected(page.run);
|
||||
setEvents(page.items);
|
||||
setEventCursor(page.has_more ? page.next_cursor : null);
|
||||
} catch {
|
||||
if (version === requestVersion.current)
|
||||
toast.error(t('agents.eventProcessor.loadError'));
|
||||
}
|
||||
},
|
||||
[id, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected && runs.length > 0) void openRun(runs[0]);
|
||||
}, [selected, runs, openRun]);
|
||||
|
||||
async function refreshLatestRun() {
|
||||
try {
|
||||
const page = await httpClient.getProcessorRunEvents(id, run.run_id);
|
||||
if (version !== requestVersion.current) return;
|
||||
setSelected(page.run);
|
||||
setEvents(page.items);
|
||||
setEventCursor(page.has_more ? page.next_cursor : null);
|
||||
const page = await httpClient.getProcessorRuns(id);
|
||||
setRuns(page.items);
|
||||
setCursor(page.has_more ? page.next_cursor : null);
|
||||
if (page.items[0]) await openRun(page.items[0]);
|
||||
} catch {
|
||||
if (version === requestVersion.current)
|
||||
toast.error(t('agents.eventProcessor.loadError'));
|
||||
toast.error(t('agents.eventProcessor.loadError'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,7 +235,13 @@ export default function EventProcessorDetailContent({
|
||||
}, [id, selected, eventCursor, events]);
|
||||
|
||||
async function save() {
|
||||
if (!componentRef || !((await validate.current?.()) ?? true)) return;
|
||||
if (
|
||||
!canManage ||
|
||||
saving ||
|
||||
!component ||
|
||||
!((await validate.current?.()) ?? true)
|
||||
)
|
||||
return false;
|
||||
setSaving(true);
|
||||
try {
|
||||
await httpClient.updateAgent(id, {
|
||||
@@ -208,230 +254,213 @@ export default function EventProcessorDetailContent({
|
||||
});
|
||||
toast.success(t('agents.saveSuccess'));
|
||||
onSaved();
|
||||
setConfigOpen(false);
|
||||
setSavedConfig({ componentRef, parameters });
|
||||
await load();
|
||||
return true;
|
||||
} catch {
|
||||
toast.error(t('agents.saveError'));
|
||||
return false;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function payload(value: unknown) {
|
||||
return (
|
||||
<pre className="mt-2 whitespace-pre-wrap break-all rounded-md bg-muted/50 p-3 text-xs">
|
||||
{JSON.stringify(value, null, 2)}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col gap-4">
|
||||
<header className="flex flex-wrap items-center gap-3">
|
||||
<FileCode2 className="size-6" />
|
||||
<h1 className="text-2xl font-semibold">{agent.name}</h1>
|
||||
{canManage && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onEdit}
|
||||
aria-label={t('common.edit')}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
<ProcessorDetailWorkbench
|
||||
title={`${agent.emoji || '⚡'} ${agent.name}`}
|
||||
titleAction={
|
||||
canManage ? <EntityTitleEditButton onClick={onEdit} /> : undefined
|
||||
}
|
||||
titleControls={
|
||||
<EventProcessorSettings
|
||||
components={components}
|
||||
value={componentRef}
|
||||
parameters={parameters}
|
||||
disabled={!canManage || saving || loading}
|
||||
onChange={(value) => {
|
||||
setComponentRef(value);
|
||||
const descriptor = components.find((item) => item.id === value);
|
||||
setParameters(
|
||||
Object.fromEntries(
|
||||
(descriptor?.config_schema ?? [])
|
||||
.filter((field) => field.default !== undefined)
|
||||
.map((field) => [field.name, field.default]),
|
||||
),
|
||||
);
|
||||
validate.current = null;
|
||||
}}
|
||||
onParametersChange={setParameters}
|
||||
onValidate={(fn) => {
|
||||
validate.current = fn;
|
||||
}}
|
||||
/>
|
||||
}
|
||||
status={
|
||||
!loading && componentRef && !available
|
||||
? { label: t('agents.eventProcessor.unavailable'), tone: 'error' }
|
||||
: undefined
|
||||
}
|
||||
saveLabel={t('common.save')}
|
||||
saveFormId="event-processor-form"
|
||||
canSave={canManage && available}
|
||||
isDirty={dirty}
|
||||
isSaving={saving}
|
||||
headerActions={
|
||||
canManage ? (
|
||||
<Button variant="destructive" disabled={saving} onClick={onDelete}>
|
||||
<Trash2 className="size-4" />
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
)}
|
||||
<Badge variant="outline">{t('agents.eventProcessor.type')}</Badge>
|
||||
{!loading && !failed && !available && (
|
||||
<Badge variant="destructive">
|
||||
{t('agents.eventProcessor.unavailable')}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="ml-auto flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
void load();
|
||||
if (selected) void openRun(selected);
|
||||
}}
|
||||
aria-label={t('agents.eventProcessor.refresh')}
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
</Button>
|
||||
{canManage && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setConfigOpen(!configOpen)}
|
||||
>
|
||||
<Settings2 className="size-4" />
|
||||
{t('pipelines.configuration')}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={onDelete}>
|
||||
<Trash2 className="size-4" />
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<p className="shrink-0 break-all text-xs text-muted-foreground">
|
||||
{agent.component_ref}
|
||||
</p>
|
||||
{configOpen && (
|
||||
<div className="max-h-[45vh] shrink-0 overflow-y-auto rounded-xl border p-4">
|
||||
<EventProcessorSettings
|
||||
components={components}
|
||||
value={componentRef}
|
||||
parameters={parameters}
|
||||
onChange={(value) => {
|
||||
setComponentRef(value);
|
||||
setParameters({});
|
||||
validate.current = null;
|
||||
}}
|
||||
onParametersChange={setParameters}
|
||||
onValidate={(fn) => {
|
||||
validate.current = fn;
|
||||
) : undefined
|
||||
}
|
||||
configTitle={t('agents.eventProcessor.trace')}
|
||||
configIcon={<ScrollText className="size-4" />}
|
||||
configContent={
|
||||
<div className="flex h-full min-h-0 flex-col gap-3">
|
||||
<form
|
||||
id="event-processor-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void save();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
className="mt-4"
|
||||
disabled={
|
||||
saving || !components.some((item) => item.id === componentRef)
|
||||
}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{failed && (
|
||||
<p role="alert" className="text-destructive">
|
||||
{t('agents.eventProcessor.loadError')}
|
||||
</p>
|
||||
)}
|
||||
<div className="grid min-h-0 flex-1 gap-4 md:grid-cols-[minmax(240px,0.7fr)_minmax(0,1.3fr)]">
|
||||
<section className="min-h-0 overflow-y-auto rounded-xl border p-4">
|
||||
<h2 className="mb-3 font-semibold">
|
||||
{t('agents.eventProcessor.runs')}
|
||||
</h2>
|
||||
{loading ? (
|
||||
<p>{t('common.loading')}</p>
|
||||
) : runs.length === 0 && !failed ? (
|
||||
<div className="space-y-3 text-sm text-muted-foreground">
|
||||
<p>{t('agents.eventProcessor.noRuns')}</p>
|
||||
<Link className="text-primary underline" to="/home/bots">
|
||||
{t('agents.eventProcessor.bindBot')}
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
runs.map((run) => (
|
||||
<button
|
||||
key={run.run_id}
|
||||
onClick={() => void openRun(run)}
|
||||
className={`mb-2 block w-full rounded-lg border p-3 text-left text-sm ${selected?.run_id === run.run_id ? 'border-primary bg-primary/5' : 'hover:bg-muted/50'}`}
|
||||
>
|
||||
<span className="block break-all font-medium">
|
||||
{run.metadata.event_type}
|
||||
</span>
|
||||
<span className="mt-1 flex flex-wrap justify-between gap-1 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{new Date(run.created_at * 1000).toLocaleString()}
|
||||
</span>
|
||||
<span>
|
||||
{t(`agents.eventProcessor.status_${run.status}`, {
|
||||
defaultValue: run.status,
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
{failed && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
{t('agents.eventProcessor.loadError')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{cursor !== null && (
|
||||
<div className="flex shrink-0 items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
{t('agents.eventProcessor.runs')}{' '}
|
||||
<span className="text-muted-foreground">({runs.length})</span>
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={pagingRuns}
|
||||
onClick={() => void loadMoreRuns()}
|
||||
size="icon"
|
||||
aria-label={t('agents.eventProcessor.refresh')}
|
||||
onClick={() => void refreshLatestRun()}
|
||||
>
|
||||
{t('agents.eventProcessor.loadMore')}
|
||||
<RefreshCw className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{runs.length > 0 && (
|
||||
<ProcessorRunList
|
||||
runs={runs}
|
||||
selectedId={selected?.run_id}
|
||||
onSelect={(run) => void openRun(run)}
|
||||
footer={
|
||||
cursor !== null ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="ghost"
|
||||
disabled={pagingRuns}
|
||||
onClick={() => void loadMoreRuns()}
|
||||
>
|
||||
{t('agents.eventProcessor.loadMore')}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
<section className="min-h-0 overflow-y-auto rounded-xl border p-4">
|
||||
<h2 className="mb-3 font-semibold">
|
||||
{t('agents.eventProcessor.trace')}
|
||||
</h2>
|
||||
{!selected ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('agents.eventProcessor.selectRun')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<details className="rounded-lg border p-3">
|
||||
<summary className="cursor-pointer text-sm font-medium">
|
||||
{t('agents.eventProcessor.input')}
|
||||
</summary>
|
||||
{payload(selected.metadata.input_event)}
|
||||
</details>
|
||||
{selected.metadata.delivery != null && (
|
||||
<details className="rounded-lg border p-3">
|
||||
<summary className="cursor-pointer text-sm font-medium">
|
||||
{t('agents.eventProcessor.destination')}
|
||||
</summary>
|
||||
{payload(selected.metadata.delivery)}
|
||||
</details>
|
||||
)}
|
||||
{events.map((event) =>
|
||||
event.type === 'processor.log' ? (
|
||||
<div
|
||||
key={event.sequence}
|
||||
className="rounded-lg bg-muted/40 p-3 text-sm"
|
||||
>
|
||||
<span className="mr-2 text-xs text-muted-foreground">
|
||||
{String(event.data.level)}
|
||||
</span>
|
||||
<span className="whitespace-pre-wrap break-words">
|
||||
{String(event.data.text)}
|
||||
</span>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="space-y-2 pr-3">
|
||||
{!selected ? (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
{loading
|
||||
? t('common.loading')
|
||||
: t('agents.eventProcessor.noRuns')}
|
||||
<Button asChild variant="link" className="h-auto px-0">
|
||||
<Link to="/home/bots">
|
||||
{t('agents.eventProcessor.bindBot')}
|
||||
</Link>
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<div className="border-b pb-2">
|
||||
<p className="text-sm font-medium">
|
||||
{eventPatternLabel(selected.metadata.event_type ?? '', t)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(selected.created_at * 1000).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<details
|
||||
key={event.sequence}
|
||||
className="rounded-lg border p-3"
|
||||
<Badge
|
||||
variant={
|
||||
selected.status === 'failed' ? 'destructive' : 'outline'
|
||||
}
|
||||
>
|
||||
<summary className="cursor-pointer break-all text-sm font-medium">
|
||||
{t(
|
||||
`agents.eventProcessor.trace_${event.type.replaceAll('.', '_')}`,
|
||||
{ defaultValue: event.type },
|
||||
)}
|
||||
{typeof event.data.tool_name === 'string' && (
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
{toolLabels[event.data.tool_name] ||
|
||||
event.data.tool_name}
|
||||
</span>
|
||||
)}
|
||||
</summary>
|
||||
{payload(event.data)}
|
||||
</details>
|
||||
),
|
||||
)}
|
||||
{selected.status === 'failed' && selected.status_reason && (
|
||||
<p className="break-words text-sm text-destructive">
|
||||
{selected.status_reason}
|
||||
</p>
|
||||
)}
|
||||
{eventCursor !== null && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={pagingEvents}
|
||||
onClick={() => void loadMoreEvents()}
|
||||
>
|
||||
{t('agents.eventProcessor.loadMore')}
|
||||
</Button>
|
||||
{t(`agents.eventProcessor.status_${selected.status}`, {
|
||||
defaultValue: selected.status,
|
||||
})}
|
||||
</Badge>
|
||||
<ProcessorPayload
|
||||
title={t('agents.eventProcessor.input')}
|
||||
value={selected.metadata.input_event}
|
||||
/>
|
||||
{selected.metadata.delivery != null && (
|
||||
<ProcessorPayload
|
||||
title={t('agents.eventProcessor.destination')}
|
||||
value={selected.metadata.delivery}
|
||||
/>
|
||||
)}
|
||||
<EventProcessorTrace
|
||||
events={events}
|
||||
toolLabels={toolLabels}
|
||||
/>
|
||||
{selected.status === 'failed' && selected.status_reason && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription className="break-words">
|
||||
{selected.status_reason}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{eventCursor !== null && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={pagingEvents}
|
||||
onClick={() => void loadMoreEvents()}
|
||||
>
|
||||
{t('agents.eventProcessor.loadMore')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
}
|
||||
debugTitle={canOperate ? t('agents.debugTab') : undefined}
|
||||
debugDescription={t('agents.eventProcessor.debugNotice')}
|
||||
debugContent={
|
||||
canOperate ? (
|
||||
!component ? (
|
||||
<Alert className="m-3 w-auto">
|
||||
<AlertDescription>
|
||||
{t('agents.eventProcessor.selectToDebug')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<AgentDebugPanel
|
||||
agentId={id}
|
||||
processor
|
||||
platformTools={platformTools}
|
||||
hasUnsavedChanges={dirty}
|
||||
beforeRun={save}
|
||||
onRunFinished={() => {
|
||||
void refreshLatestRun();
|
||||
}}
|
||||
supportedEventPatterns={component.supported_event_patterns}
|
||||
availableEventTypes={availableEventTypes}
|
||||
/>
|
||||
)
|
||||
) : undefined
|
||||
}
|
||||
unsavedLabel={t('pipelines.unsavedChanges')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
@@ -6,8 +6,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import { Bot, Workflow, FileCode2 } from 'lucide-react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { AgentKind, EventProcessorDescriptor } from '@/app/infra/entities/api';
|
||||
import EventProcessorSettings from './EventProcessorSettings';
|
||||
import { AgentKind } from '@/app/infra/entities/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import {
|
||||
@@ -36,23 +35,6 @@ export default function AgentCreateContent({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [kind, setKind] = useState<AgentKind>('agent');
|
||||
const [components, setComponents] = useState<EventProcessorDescriptor[]>([]);
|
||||
const [componentRef, setComponentRef] = useState('');
|
||||
const [parameters, setParameters] = useState<Record<string, unknown>>({});
|
||||
const validateParameters = useRef<(() => Promise<boolean>) | null>(null);
|
||||
useEffect(() => {
|
||||
if (kind !== 'event_processor') return;
|
||||
let cancelled = false;
|
||||
httpClient
|
||||
.getAgentMetadata()
|
||||
.then((metadata) => {
|
||||
if (!cancelled) setComponents(metadata.event_processors ?? []);
|
||||
})
|
||||
.catch(() => toast.error(t('agents.eventProcessor.loadError')));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [kind, t]);
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(1, { message: t('agents.nameRequired') }),
|
||||
description: z.string().optional(),
|
||||
@@ -85,23 +67,9 @@ export default function AgentCreateContent({
|
||||
}
|
||||
|
||||
async function handleSubmit(values: FormValues) {
|
||||
if (
|
||||
kind === 'event_processor' &&
|
||||
(!componentRef || !((await validateParameters.current?.()) ?? true))
|
||||
)
|
||||
return;
|
||||
httpClient
|
||||
return httpClient
|
||||
.createAgent({
|
||||
kind,
|
||||
...(kind === 'event_processor'
|
||||
? {
|
||||
component_ref: componentRef,
|
||||
config: {
|
||||
runner: { id: componentRef },
|
||||
runner_config: { [componentRef]: parameters },
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
name: values.name,
|
||||
description: values.description ?? '',
|
||||
emoji: values.emoji || (kind === 'pipeline' ? '⚙️' : '🤖'),
|
||||
@@ -143,10 +111,7 @@ export default function AgentCreateContent({
|
||||
<Button
|
||||
type="submit"
|
||||
form="agent-create-form"
|
||||
disabled={
|
||||
form.formState.isSubmitting ||
|
||||
(kind === 'event_processor' && !componentRef)
|
||||
}
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{t('common.submit')}
|
||||
</Button>
|
||||
@@ -209,22 +174,6 @@ export default function AgentCreateContent({
|
||||
</ToggleGroup>
|
||||
</section>
|
||||
|
||||
{kind === 'event_processor' && (
|
||||
<EventProcessorSettings
|
||||
components={components}
|
||||
value={componentRef}
|
||||
parameters={parameters}
|
||||
onChange={(value) => {
|
||||
setComponentRef(value);
|
||||
setParameters({});
|
||||
validateParameters.current = null;
|
||||
}}
|
||||
onParametersChange={setParameters}
|
||||
onValidate={(validate) => {
|
||||
validateParameters.current = validate;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('agents.basicInfo')}</CardTitle>
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
groupEventPatterns,
|
||||
} from '@/app/home/components/event-patterns/event-pattern-groups';
|
||||
import EventSelectOptionContent from '@/app/home/components/event-patterns/EventSelectOptionContent';
|
||||
import EventProcessorTrace from './EventProcessorTrace';
|
||||
import AgentExecutionTrace from './AgentExecutionTrace';
|
||||
import AgentEventDataEditor from './AgentEventDataEditor';
|
||||
import {
|
||||
@@ -53,15 +54,18 @@ import {
|
||||
debugEventInputText,
|
||||
invalidDebugEventField,
|
||||
parseDebugEventData,
|
||||
processorDebugEventTypes,
|
||||
} from './debug-event-data';
|
||||
import { executionSteps, type DebugExecutionEvent } from './debug-execution';
|
||||
|
||||
interface AgentDebugPanelProps {
|
||||
agentId: string;
|
||||
processor?: boolean;
|
||||
availableEventTypes: string[];
|
||||
platformTools?: AgentPlatformTool[];
|
||||
supportedEventPatterns?: string[];
|
||||
beforeRun?: () => Promise<boolean>;
|
||||
onRunFinished?: () => void;
|
||||
hasUnsavedChanges?: boolean;
|
||||
onOpenRunnerConfig?: () => void;
|
||||
}
|
||||
@@ -89,10 +93,12 @@ function matchesEventPattern(pattern: string, eventType: string) {
|
||||
|
||||
export default function AgentDebugPanel({
|
||||
agentId,
|
||||
processor = false,
|
||||
availableEventTypes,
|
||||
platformTools = [],
|
||||
supportedEventPatterns = ['*'],
|
||||
beforeRun,
|
||||
onRunFinished,
|
||||
hasUnsavedChanges = false,
|
||||
onOpenRunnerConfig,
|
||||
}: AgentDebugPanelProps) {
|
||||
@@ -105,15 +111,19 @@ export default function AgentDebugPanel({
|
||||
const newEventData = useCallback(
|
||||
(type: string) =>
|
||||
JSON.stringify(
|
||||
createDebugEventData(type, {
|
||||
user: t('agents.debugData.sampleUser'),
|
||||
message: t('agents.debugData.sampleMessage'),
|
||||
feedback: t('agents.debugData.sampleFeedback'),
|
||||
}),
|
||||
createDebugEventData(
|
||||
type,
|
||||
{
|
||||
user: t('agents.debugData.sampleUser'),
|
||||
message: t('agents.debugData.sampleMessage'),
|
||||
feedback: t('agents.debugData.sampleFeedback'),
|
||||
},
|
||||
processor,
|
||||
),
|
||||
null,
|
||||
2,
|
||||
),
|
||||
[t],
|
||||
[t, processor],
|
||||
);
|
||||
const [eventDataText, setEventDataText] = useState(() =>
|
||||
newEventData('message.received'),
|
||||
@@ -140,21 +150,28 @@ export default function AgentDebugPanel({
|
||||
const concretePatterns = supportedEventPatterns.filter(
|
||||
(pattern) => pattern !== '*' && !pattern.endsWith('.*'),
|
||||
);
|
||||
return Array.from(new Set([...availableEventTypes, ...concretePatterns]))
|
||||
return Array.from(
|
||||
new Set([
|
||||
...(processor ? processorDebugEventTypes : availableEventTypes),
|
||||
...concretePatterns,
|
||||
]),
|
||||
)
|
||||
.filter((candidate) =>
|
||||
supportedEventPatterns.some((pattern) =>
|
||||
matchesEventPattern(pattern, candidate),
|
||||
),
|
||||
)
|
||||
.sort();
|
||||
}, [availableEventTypes, supportedEventPatterns]);
|
||||
}, [availableEventTypes, supportedEventPatterns, processor]);
|
||||
const eventGroups = useMemo(
|
||||
() => groupEventPatterns(availableEvents),
|
||||
[availableEvents],
|
||||
);
|
||||
const supportsCustomEvent = supportedEventPatterns.some(
|
||||
(pattern) => pattern === '*' || pattern.endsWith('.*'),
|
||||
);
|
||||
const supportsCustomEvent =
|
||||
!processor &&
|
||||
supportedEventPatterns.some(
|
||||
(pattern) => pattern === '*' || pattern.endsWith('.*'),
|
||||
);
|
||||
|
||||
const selectPreset = useCallback(
|
||||
(value: string) => {
|
||||
@@ -193,7 +210,11 @@ export default function AgentDebugPanel({
|
||||
toast.error(t('agents.debugInvalidPayload'));
|
||||
return;
|
||||
}
|
||||
const invalidField = invalidDebugEventField(eventType, eventData);
|
||||
const invalidField = invalidDebugEventField(
|
||||
eventType,
|
||||
eventData,
|
||||
processor,
|
||||
);
|
||||
if (invalidField) {
|
||||
toast.error(
|
||||
t('agents.debugData.invalidField', {
|
||||
@@ -202,7 +223,7 @@ export default function AgentDebugPanel({
|
||||
);
|
||||
return;
|
||||
}
|
||||
const inputText = debugEventInputText(eventType, eventData);
|
||||
const inputText = debugEventInputText(eventType, eventData, processor);
|
||||
let mockOptions: Record<string, unknown>;
|
||||
try {
|
||||
const parsed = JSON.parse(mockOptionsText || '{}');
|
||||
@@ -275,12 +296,14 @@ export default function AgentDebugPanel({
|
||||
? {
|
||||
...entry,
|
||||
finished: true,
|
||||
text: executionSteps(entry.events ?? []).some(
|
||||
(step) =>
|
||||
step.kind === 'tool' || step.text || step.reasoning,
|
||||
)
|
||||
? ''
|
||||
: result.final_text || t('agents.debugNoTextOutput'),
|
||||
text:
|
||||
processor ||
|
||||
executionSteps(entry.events ?? []).some(
|
||||
(step) =>
|
||||
step.kind === 'tool' || step.text || step.reasoning,
|
||||
)
|
||||
? ''
|
||||
: result.final_text || t('agents.debugNoTextOutput'),
|
||||
}
|
||||
: entry,
|
||||
)
|
||||
@@ -354,6 +377,7 @@ export default function AgentDebugPanel({
|
||||
if (requestRef.current === controller) {
|
||||
requestRef.current = null;
|
||||
setRunning(false);
|
||||
onRunFinished?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -364,15 +388,25 @@ export default function AgentDebugPanel({
|
||||
<div className="mb-3">
|
||||
<p className="text-sm font-medium">{t('agents.debugTranscript')}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('agents.debugTranscriptDescription')}
|
||||
{t(
|
||||
processor
|
||||
? 'agents.eventProcessor.debugDescription'
|
||||
: 'agents.debugTranscriptDescription',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{entries.length === 0 ? (
|
||||
<Alert className="my-4 bg-muted/20">
|
||||
<CircleHelp className="size-4" />
|
||||
<AlertTitle>{t('agents.debugEmptyTitle')}</AlertTitle>
|
||||
<AlertTitle>
|
||||
{t(processor ? 'agents.debugTab' : 'agents.debugEmptyTitle')}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t('agents.debugEmptyTranscript')}
|
||||
{t(
|
||||
processor
|
||||
? 'agents.eventProcessor.debugDescription'
|
||||
: 'agents.debugEmptyTranscript',
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
@@ -382,6 +416,7 @@ export default function AgentDebugPanel({
|
||||
(entry) =>
|
||||
entry.direction !== 'output' ||
|
||||
entry.text ||
|
||||
processor ||
|
||||
executionSteps(entry.events ?? []).some(
|
||||
(step) =>
|
||||
step.kind === 'tool' || step.text || step.reasoning,
|
||||
@@ -412,19 +447,29 @@ export default function AgentDebugPanel({
|
||||
</Badge>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{entry.direction === 'output'
|
||||
? t('agents.debugAgentOutput')
|
||||
? t(
|
||||
processor
|
||||
? 'agents.eventProcessor.debugOutput'
|
||||
: 'agents.debugAgentOutput',
|
||||
)
|
||||
: entry.direction === 'error'
|
||||
? t('common.error')
|
||||
: t('agents.debugTestInput')}
|
||||
</span>
|
||||
</div>
|
||||
{entry.events && (
|
||||
<AgentExecutionTrace
|
||||
events={entry.events}
|
||||
finished={entry.finished}
|
||||
toolLabels={toolLabels}
|
||||
/>
|
||||
)}
|
||||
{entry.events &&
|
||||
(processor ? (
|
||||
<EventProcessorTrace
|
||||
events={entry.events}
|
||||
toolLabels={toolLabels}
|
||||
/>
|
||||
) : (
|
||||
<AgentExecutionTrace
|
||||
events={entry.events}
|
||||
finished={entry.finished}
|
||||
toolLabels={toolLabels}
|
||||
/>
|
||||
))}
|
||||
{entry.text && (
|
||||
<pre className="min-w-0 whitespace-pre-wrap break-words [overflow-wrap:anywhere] font-sans text-sm leading-relaxed">
|
||||
{entry.text}
|
||||
@@ -546,26 +591,37 @@ export default function AgentDebugPanel({
|
||||
<AgentEventDataEditor
|
||||
key={eventType}
|
||||
eventType={eventType}
|
||||
processor={processor}
|
||||
custom={preset === 'custom'}
|
||||
value={eventDataText}
|
||||
onChange={setEventDataText}
|
||||
/>
|
||||
|
||||
<details className="text-muted-foreground">
|
||||
<summary className="cursor-pointer text-xs font-medium">
|
||||
{t('agents.debugMockOptions')}
|
||||
</summary>
|
||||
<p className="my-2 text-xs text-muted-foreground">
|
||||
{t('agents.debugMockOptionsHelp')}
|
||||
</p>
|
||||
<Textarea
|
||||
aria-label={t('agents.debugMockOptions')}
|
||||
value={mockOptionsText}
|
||||
onChange={(event) => setMockOptionsText(event.target.value)}
|
||||
className="min-h-24 font-mono text-xs"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</details>
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="group"
|
||||
>
|
||||
<ChevronDown className="size-3.5 transition-transform group-data-[state=open]:rotate-180" />
|
||||
{t('agents.debugMockOptions')}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<p className="my-2 text-xs text-muted-foreground">
|
||||
{t('agents.debugMockOptionsHelp')}
|
||||
</p>
|
||||
<Textarea
|
||||
aria-label={t('agents.debugMockOptions')}
|
||||
value={mockOptionsText}
|
||||
onChange={(event) => setMockOptionsText(event.target.value)}
|
||||
className="min-h-24 font-mono text-xs"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,27 +5,48 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { debugEventDefinition, parseDebugEventData } from './debug-event-data';
|
||||
import {
|
||||
debugEventDefinition,
|
||||
parseDebugEventData,
|
||||
getDebugEventField,
|
||||
setDebugEventField,
|
||||
} from './debug-event-data';
|
||||
|
||||
export default function AgentEventDataEditor({
|
||||
eventType,
|
||||
value,
|
||||
onChange,
|
||||
custom = false,
|
||||
processor = false,
|
||||
}: {
|
||||
eventType: string;
|
||||
custom?: boolean;
|
||||
processor?: boolean;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [showJson, setShowJson] = useState(false);
|
||||
const fields = custom ? [] : (debugEventDefinition(eventType)?.fields ?? []);
|
||||
const fields = custom
|
||||
? []
|
||||
: (debugEventDefinition(eventType, processor)?.fields ?? []);
|
||||
const data = parseDebugEventData(value);
|
||||
const jsonMode = showJson || !fields.length;
|
||||
const chainKey =
|
||||
processor &&
|
||||
(eventType === 'message.received'
|
||||
? 'message_chain'
|
||||
: eventType === 'message.edited'
|
||||
? 'new_content'
|
||||
: undefined);
|
||||
const chain = chainKey && data?.[chainKey];
|
||||
const richMessage =
|
||||
chainKey &&
|
||||
(!Array.isArray(chain) || chain.length !== 1 || chain[0]?.type !== 'Plain');
|
||||
const jsonMode = showJson || !fields.length || !!richMessage;
|
||||
|
||||
function updateField(key: string, next: string | number | undefined) {
|
||||
if (data) onChange(JSON.stringify({ ...data, [key]: next }, null, 2));
|
||||
if (data)
|
||||
onChange(JSON.stringify(setDebugEventField(data, key, next), null, 2));
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -34,7 +55,7 @@ export default function AgentEventDataEditor({
|
||||
<span className="text-xs font-medium">
|
||||
{t('agents.debugData.title')}
|
||||
</span>
|
||||
{fields.length > 0 && (
|
||||
{fields.length > 0 && !richMessage && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -69,7 +90,7 @@ export default function AgentEventDataEditor({
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-2">
|
||||
{fields.map((field) => {
|
||||
const id = `agent-debug-data-${field.key}`;
|
||||
const current = data?.[field.key];
|
||||
const current = getDebugEventField(data, field.key);
|
||||
const text =
|
||||
typeof current === 'string' || typeof current === 'number'
|
||||
? current
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { useState } from 'react';
|
||||
import { Settings2, Puzzle } from 'lucide-react';
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
} from '@/components/ui/popover';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { EventProcessorDescriptor } from '@/app/infra/entities/api';
|
||||
import { httpClient } from '@/app/infra/http';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import {
|
||||
Select,
|
||||
@@ -9,8 +17,58 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
|
||||
|
||||
function ProcessorComponentContent({
|
||||
component,
|
||||
option = false,
|
||||
}: {
|
||||
component: EventProcessorDescriptor;
|
||||
option?: boolean;
|
||||
}) {
|
||||
const label = extractI18nObject({
|
||||
en_US: component.id,
|
||||
zh_Hans: component.id,
|
||||
...component.label,
|
||||
});
|
||||
const pluginId = `${component.plugin_author}/${component.plugin_name}`;
|
||||
return (
|
||||
<span
|
||||
className={
|
||||
option
|
||||
? 'grid w-full min-w-0 grid-cols-[1.75rem_minmax(0,1fr)] items-center gap-x-2 text-left'
|
||||
: 'flex min-w-0 items-center gap-2'
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={httpClient.getPluginIconURL(
|
||||
component.plugin_author,
|
||||
component.plugin_name,
|
||||
)}
|
||||
alt=""
|
||||
className={
|
||||
option
|
||||
? 'row-span-2 size-7 shrink-0 rounded-md object-cover'
|
||||
: 'size-5 shrink-0 rounded object-cover'
|
||||
}
|
||||
/>
|
||||
<span className={option ? 'truncate font-medium leading-5' : 'truncate'}>
|
||||
{label}
|
||||
</span>
|
||||
{option && (
|
||||
<span
|
||||
className="truncate text-xs leading-4 text-muted-foreground"
|
||||
title={pluginId}
|
||||
>
|
||||
{pluginId}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EventProcessorSettings({
|
||||
components,
|
||||
value,
|
||||
@@ -18,6 +76,7 @@ export default function EventProcessorSettings({
|
||||
onChange,
|
||||
onParametersChange,
|
||||
onValidate,
|
||||
disabled = false,
|
||||
}: {
|
||||
components: EventProcessorDescriptor[];
|
||||
value: string;
|
||||
@@ -25,66 +84,112 @@ export default function EventProcessorSettings({
|
||||
onChange: (value: string) => void;
|
||||
onParametersChange: (value: Record<string, unknown>) => void;
|
||||
onValidate?: (validate: () => Promise<boolean>) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const selected = components.find((item) => item.id === value);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
className="text-sm font-medium"
|
||||
htmlFor="event-processor-component"
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Label className="sr-only" htmlFor="event-processor-component">
|
||||
{t('agents.eventProcessor.component')}
|
||||
</Label>
|
||||
<Select
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onValueChange={(next) => {
|
||||
onChange(next);
|
||||
const component = components.find((item) => item.id === next);
|
||||
setSettingsOpen(
|
||||
Boolean(
|
||||
component?.config_schema.some(
|
||||
(field) =>
|
||||
field.required &&
|
||||
(field.default == null || field.default === ''),
|
||||
),
|
||||
),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="event-processor-component"
|
||||
className="w-[22rem] max-w-[calc(100vw-8rem)] bg-[#ffffff] dark:bg-[#2a2a2e]"
|
||||
>
|
||||
{t('agents.eventProcessor.component')}
|
||||
</label>
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger id="event-processor-component">
|
||||
<SelectValue
|
||||
placeholder={t('agents.eventProcessor.selectComponent')}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{value && !selected && (
|
||||
<SelectItem value={value}>
|
||||
{t('agents.eventProcessor.unavailable')}
|
||||
</SelectItem>
|
||||
)}
|
||||
{components.map((component) => (
|
||||
<SelectItem key={component.id} value={component.id}>
|
||||
{extractI18nObject({
|
||||
en_US: component.id,
|
||||
zh_Hans: component.id,
|
||||
...component.label,
|
||||
})}{' '}
|
||||
· {component.plugin_author}/{component.plugin_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{components.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('agents.eventProcessor.noComponents')}{' '}
|
||||
<Link className="text-primary underline" to="/home/plugins">
|
||||
{t('agents.eventProcessor.installPlugin')}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{selected && (
|
||||
<p className="break-words text-xs text-muted-foreground">
|
||||
{selected.supported_event_patterns.join(' · ')}
|
||||
</p>
|
||||
)}
|
||||
{selected ? (
|
||||
<ProcessorComponentContent component={selected} />
|
||||
) : (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<Puzzle className="size-4 shrink-0 text-muted-foreground" />
|
||||
<SelectValue
|
||||
placeholder={t('agents.eventProcessor.selectComponent')}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-72 w-[var(--radix-select-trigger-width)] max-w-[calc(100vw-2rem)]">
|
||||
{value && !selected && (
|
||||
<SelectItem value={value}>
|
||||
{t('agents.eventProcessor.unavailable')}
|
||||
</SelectItem>
|
||||
)}
|
||||
{components.map((component) => (
|
||||
<SelectItem
|
||||
key={component.id}
|
||||
value={component.id}
|
||||
className="py-1.5 [&>span:last-child]:min-w-0 [&>span:last-child]:flex-1"
|
||||
>
|
||||
<ProcessorComponentContent component={component} option />
|
||||
</SelectItem>
|
||||
))}
|
||||
{components.length === 0 && (
|
||||
<div className="p-2 text-sm text-muted-foreground">
|
||||
{t('agents.eventProcessor.noComponents')}
|
||||
<Button asChild variant="link" className="h-auto px-0">
|
||||
<Link to="/home/plugins">
|
||||
{t('agents.eventProcessor.installPlugin')}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selected && selected.config_schema.length > 0 && (
|
||||
<DynamicFormComponent
|
||||
key={value}
|
||||
itemConfigList={selected.config_schema}
|
||||
initialValues={parameters}
|
||||
onSubmit={(values) =>
|
||||
onParametersChange(values as Record<string, unknown>)
|
||||
}
|
||||
onValidate={onValidate}
|
||||
/>
|
||||
<Popover open={settingsOpen} onOpenChange={setSettingsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={disabled}
|
||||
aria-label={t('agents.eventProcessor.pluginSettings')}
|
||||
title={t('agents.eventProcessor.pluginSettings')}
|
||||
>
|
||||
<Settings2 className="size-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="max-h-[70vh] overflow-y-auto space-y-3"
|
||||
>
|
||||
<p className="text-sm font-medium">
|
||||
{t('agents.eventProcessor.pluginSettings')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('agents.eventProcessor.pluginSettingsDescription')}
|
||||
</p>
|
||||
<fieldset disabled={disabled}>
|
||||
<DynamicFormComponent
|
||||
key={value}
|
||||
itemConfigList={selected.config_schema}
|
||||
initialValues={parameters}
|
||||
onSubmit={(values) =>
|
||||
onParametersChange(values as Record<string, unknown>)
|
||||
}
|
||||
onValidate={onValidate}
|
||||
/>
|
||||
</fieldset>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import type { DebugExecutionEvent } from './debug-execution';
|
||||
|
||||
export function ProcessorPayload({
|
||||
title,
|
||||
value,
|
||||
}: {
|
||||
title: ReactNode;
|
||||
value: unknown;
|
||||
}) {
|
||||
return (
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-auto w-full justify-start whitespace-normal text-left group"
|
||||
>
|
||||
<ChevronRight className="size-4 shrink-0 transition-transform group-data-[state=open]:rotate-90" />
|
||||
<span className="min-w-0 break-words">{title}</span>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<pre className="p-3 text-xs whitespace-pre-wrap break-words [overflow-wrap:anywhere]">
|
||||
{JSON.stringify(value, null, 2)}
|
||||
</pre>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EventProcessorTrace({
|
||||
events,
|
||||
toolLabels = {},
|
||||
}: {
|
||||
events: DebugExecutionEvent[];
|
||||
toolLabels?: Record<string, string>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{events.map((event, index) =>
|
||||
event.type === 'processor.log' ? (
|
||||
<Alert
|
||||
key={event.sequence ?? index}
|
||||
variant={event.data.level === 'error' ? 'destructive' : 'default'}
|
||||
>
|
||||
<AlertDescription className="flex min-w-0 items-start gap-2">
|
||||
<Badge variant="outline">{String(event.data.level)}</Badge>
|
||||
<span className="min-w-0 whitespace-pre-wrap break-words [overflow-wrap:anywhere]">
|
||||
{String(event.data.text)}
|
||||
</span>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<ProcessorPayload
|
||||
key={event.sequence ?? index}
|
||||
title={
|
||||
<>
|
||||
{t(
|
||||
`agents.eventProcessor.trace_${event.type.replaceAll('.', '_')}`,
|
||||
{ defaultValue: event.type },
|
||||
)}
|
||||
{typeof event.data.tool_name === 'string' && (
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
{toolLabels[event.data.tool_name] || event.data.tool_name}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
value={event.data}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import type { ProcessorRun } from '@/app/infra/entities/api';
|
||||
import { eventPatternLabel } from '@/app/home/components/event-patterns/event-pattern-groups';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
processorRunDuration,
|
||||
formatRunDuration,
|
||||
} from './processor-run-timing';
|
||||
|
||||
export default function ProcessorRunList({
|
||||
runs,
|
||||
selectedId,
|
||||
onSelect,
|
||||
footer,
|
||||
}: {
|
||||
runs: ProcessorRun[];
|
||||
selectedId?: string;
|
||||
onSelect: (run: ProcessorRun) => void;
|
||||
footer?: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t('agents.eventProcessor.runs')}
|
||||
className="max-h-56 shrink-0 overflow-y-auto rounded-md border"
|
||||
>
|
||||
{runs.map((run) => {
|
||||
const duration = processorRunDuration(run);
|
||||
const selected = selectedId === run.run_id;
|
||||
const event = run.metadata.event_type ?? '';
|
||||
const label = eventPatternLabel(event, t);
|
||||
return (
|
||||
<Button
|
||||
key={run.run_id}
|
||||
variant="ghost"
|
||||
aria-pressed={selected}
|
||||
onClick={() => onSelect(run)}
|
||||
aria-label={`${label} ${event} ${new Date(run.created_at * 1000).toLocaleString()}`}
|
||||
title={event}
|
||||
className="flex h-auto min-h-12 w-full justify-start gap-3 rounded-none border-b px-3 py-2 text-left text-sm font-normal last:border-b-0 aria-pressed:bg-accent"
|
||||
>
|
||||
<span className="min-w-0 flex-1 space-y-1">
|
||||
<span className="block truncate font-medium">{label}</span>
|
||||
<time
|
||||
className="block text-xs text-muted-foreground"
|
||||
dateTime={new Date(run.created_at * 1000).toISOString()}
|
||||
>
|
||||
{new Date(run.created_at * 1000).toLocaleString()}
|
||||
</time>
|
||||
</span>
|
||||
<span className="flex shrink-0 flex-col items-end gap-1">
|
||||
<Badge
|
||||
variant={
|
||||
run.status === 'failed' || run.status === 'timeout'
|
||||
? 'destructive'
|
||||
: 'outline'
|
||||
}
|
||||
className="px-1.5 py-0 text-xs"
|
||||
>
|
||||
{t(`agents.eventProcessor.status_${run.status}`, {
|
||||
defaultValue: run.status,
|
||||
})}
|
||||
</Badge>
|
||||
{duration !== null && (
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{formatRunDuration(duration)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronRight
|
||||
className={`size-4 shrink-0 ${selected ? 'text-foreground' : 'text-muted-foreground'}`}
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -155,28 +155,155 @@ const DEBUG_EVENT_DEFINITIONS: Record<string, DebugEventDefinition> = {
|
||||
},
|
||||
};
|
||||
|
||||
// Event processors receive the SDK's typed EBA payload, rather than Agent debug aliases.
|
||||
const PROCESSOR_EVENT_DEFINITIONS: Record<string, DebugEventDefinition> =
|
||||
Object.fromEntries(
|
||||
Object.entries(DEBUG_EVENT_DEFINITIONS)
|
||||
.filter(([type]) => type !== 'message.recalled')
|
||||
.map(([type, definition]) => {
|
||||
const paths: Record<string, string> = {
|
||||
group_id: 'group.id',
|
||||
group_name: 'group.name',
|
||||
member_id: 'member.id',
|
||||
member_name: 'member.nickname',
|
||||
user_id: 'user.id',
|
||||
user_name: 'user.nickname',
|
||||
requester_id:
|
||||
type === 'bot.invited_to_group' ? 'inviter.id' : 'user.id',
|
||||
requester_name: 'user.nickname',
|
||||
event_name: 'action',
|
||||
};
|
||||
return [
|
||||
type,
|
||||
{
|
||||
...definition,
|
||||
fields: definition.fields.map((field) => ({
|
||||
...field,
|
||||
key: paths[field.key] ?? field.key,
|
||||
})),
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
for (const [type, chain, sender] of [
|
||||
['message.received', 'message_chain', 'sender'],
|
||||
['message.edited', 'new_content', 'editor'],
|
||||
]) {
|
||||
PROCESSOR_EVENT_DEFINITIONS[type] = {
|
||||
fields: [
|
||||
{ ...message, key: `${chain}.0.text` },
|
||||
{ ...userName, key: `${sender}.nickname` },
|
||||
{ ...user, key: `${sender}.id` },
|
||||
{ key: 'chat_id', label: 'chatId', value: 'debug-user' },
|
||||
],
|
||||
defaults: {
|
||||
[chain]: [{ type: 'Plain', text: '' }],
|
||||
chat_type: 'private',
|
||||
message_id: 'debug-message',
|
||||
},
|
||||
messageField: `${chain}.0.text`,
|
||||
};
|
||||
}
|
||||
for (const type of ['message.deleted', 'message.reaction']) {
|
||||
const definition = PROCESSOR_EVENT_DEFINITIONS[type];
|
||||
definition.fields = definition.fields.filter(
|
||||
(field) => field.key !== 'group.id',
|
||||
);
|
||||
definition.fields.push({
|
||||
key: 'chat_id',
|
||||
label: 'chatId',
|
||||
value: 'debug-user',
|
||||
});
|
||||
definition.defaults = { ...definition.defaults, chat_type: 'private' };
|
||||
}
|
||||
PROCESSOR_EVENT_DEFINITIONS['feedback.received'] = {
|
||||
fields: [
|
||||
{
|
||||
key: 'feedback_content',
|
||||
label: 'feedback',
|
||||
value: '',
|
||||
sample: 'feedback',
|
||||
multiline: true,
|
||||
},
|
||||
{
|
||||
key: 'feedback_type',
|
||||
label: 'feedbackType',
|
||||
value: 1,
|
||||
min: 1,
|
||||
max: 3,
|
||||
required: true,
|
||||
},
|
||||
user,
|
||||
messageId,
|
||||
],
|
||||
defaults: { feedback_id: 'debug-feedback' },
|
||||
};
|
||||
|
||||
export const processorDebugEventTypes = Object.keys(
|
||||
PROCESSOR_EVENT_DEFINITIONS,
|
||||
);
|
||||
|
||||
export function getDebugEventField(data: unknown, path: string): unknown {
|
||||
return path
|
||||
.split('.')
|
||||
.reduce<unknown>(
|
||||
(value, key) =>
|
||||
value && typeof value === 'object' && Object.hasOwn(value, key)
|
||||
? (value as Record<string, unknown>)[key]
|
||||
: undefined,
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
export function setDebugEventField(
|
||||
data: Record<string, unknown>,
|
||||
path: string,
|
||||
value: unknown,
|
||||
): Record<string, unknown> {
|
||||
const [key, ...rest] = path.split('.');
|
||||
if (['__proto__', 'constructor', 'prototype'].includes(key)) return data;
|
||||
const current = data[key];
|
||||
const next = rest.length
|
||||
? setDebugEventField(
|
||||
current && typeof current === 'object'
|
||||
? (current as Record<string, unknown>)
|
||||
: {},
|
||||
rest.join('.'),
|
||||
value,
|
||||
)
|
||||
: value;
|
||||
return Array.isArray(data)
|
||||
? Object.assign([...data], { [key]: next })
|
||||
: { ...data, [key]: next };
|
||||
}
|
||||
|
||||
export function debugEventDefinition(
|
||||
eventType: string,
|
||||
processor = false,
|
||||
): DebugEventDefinition | undefined {
|
||||
return Object.hasOwn(DEBUG_EVENT_DEFINITIONS, eventType)
|
||||
? DEBUG_EVENT_DEFINITIONS[eventType]
|
||||
const definitions = processor
|
||||
? PROCESSOR_EVENT_DEFINITIONS
|
||||
: DEBUG_EVENT_DEFINITIONS;
|
||||
return Object.hasOwn(definitions, eventType)
|
||||
? definitions[eventType]
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function createDebugEventData(
|
||||
eventType: string,
|
||||
samples: Record<'user' | 'message' | 'feedback', string>,
|
||||
processor = false,
|
||||
) {
|
||||
const definition = debugEventDefinition(eventType);
|
||||
return {
|
||||
...definition?.defaults,
|
||||
...Object.fromEntries(
|
||||
(definition?.fields ?? []).map((field) => [
|
||||
const definition = debugEventDefinition(eventType, processor);
|
||||
return (definition?.fields ?? []).reduce<Record<string, unknown>>(
|
||||
(data, field) =>
|
||||
setDebugEventField(
|
||||
data,
|
||||
field.key,
|
||||
field.sample ? samples[field.sample] : field.value,
|
||||
]),
|
||||
),
|
||||
};
|
||||
),
|
||||
{ ...definition?.defaults },
|
||||
);
|
||||
}
|
||||
|
||||
export function parseDebugEventData(
|
||||
@@ -195,9 +322,20 @@ export function parseDebugEventData(
|
||||
export function invalidDebugEventField(
|
||||
eventType: string,
|
||||
data: Record<string, unknown>,
|
||||
processor = false,
|
||||
) {
|
||||
return debugEventDefinition(eventType)?.fields.find((field) => {
|
||||
const value = data[field.key];
|
||||
return debugEventDefinition(eventType, processor)?.fields.find((field) => {
|
||||
const value = getDebugEventField(data, field.key);
|
||||
// Rich messages are edited as full JSON; a first Plain component is not required.
|
||||
if (processor && field.key.endsWith('.0.text')) {
|
||||
const chain = data[field.key.split('.')[0]];
|
||||
if (
|
||||
Array.isArray(chain) &&
|
||||
chain.length > 0 &&
|
||||
(chain.length > 1 || chain[0]?.type !== 'Plain')
|
||||
)
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
@@ -215,7 +353,10 @@ export function invalidDebugEventField(
|
||||
}
|
||||
return (
|
||||
typeof value !== 'string' &&
|
||||
!(field.key.endsWith('_id') && typeof value === 'number')
|
||||
!(
|
||||
(field.key.endsWith('_id') || field.key.endsWith('.id')) &&
|
||||
typeof value === 'number'
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -223,7 +364,9 @@ export function invalidDebugEventField(
|
||||
export function debugEventInputText(
|
||||
eventType: string,
|
||||
data: Record<string, unknown>,
|
||||
processor = false,
|
||||
) {
|
||||
const key = debugEventDefinition(eventType)?.messageField;
|
||||
return key && typeof data[key] === 'string' ? data[key].trim() : '';
|
||||
const key = debugEventDefinition(eventType, processor)?.messageField;
|
||||
const value = key ? getDebugEventField(data, key) : undefined;
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ProcessorRun } from '@/app/infra/entities/api';
|
||||
|
||||
function milliseconds(
|
||||
precise: number | null | undefined,
|
||||
seconds: number | null | undefined,
|
||||
) {
|
||||
if (typeof precise === 'number' && Number.isFinite(precise)) return precise;
|
||||
if (typeof seconds === 'number' && Number.isFinite(seconds))
|
||||
return seconds * 1000;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function processorRunDuration(run: ProcessorRun): number | null {
|
||||
const start = milliseconds(run.started_at_ms, run.started_at);
|
||||
const end = milliseconds(run.finished_at_ms, run.finished_at);
|
||||
return start !== null && end !== null && end >= start ? end - start : null;
|
||||
}
|
||||
|
||||
export function formatRunDuration(ms: number): string {
|
||||
if (ms < 1000) return `${Math.round(ms)} ms`;
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(2)} s`;
|
||||
return `${(ms / 60000).toFixed(1)} min`;
|
||||
}
|
||||
@@ -900,7 +900,7 @@ function NavItems({
|
||||
}
|
||||
>
|
||||
{item.kind === 'event_processor' ? (
|
||||
<span className="text-xs">⚡</span>
|
||||
<Puzzle className="size-3.5" />
|
||||
) : item.kind === 'pipeline' ? (
|
||||
<Workflow className="size-3.5" />
|
||||
) : (
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/card';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ProcessorMonitoringView {
|
||||
@@ -26,6 +27,7 @@ interface ProcessorDetailWorkbenchProps {
|
||||
title: string;
|
||||
titleBadge?: ReactNode;
|
||||
titleAction?: ReactNode;
|
||||
titleControls?: ReactNode;
|
||||
headerActions?: ReactNode;
|
||||
status?: ProcessorDetailStatus | null;
|
||||
saveLabel: string;
|
||||
@@ -34,6 +36,7 @@ interface ProcessorDetailWorkbenchProps {
|
||||
isDirty: boolean;
|
||||
isSaving: boolean;
|
||||
configTitle: string;
|
||||
configIcon?: ReactNode;
|
||||
configContent: ReactNode;
|
||||
debugTitle?: string;
|
||||
debugDescription?: string;
|
||||
@@ -49,6 +52,7 @@ export default function ProcessorDetailWorkbench({
|
||||
title,
|
||||
titleBadge,
|
||||
titleAction,
|
||||
titleControls,
|
||||
headerActions,
|
||||
status,
|
||||
saveLabel,
|
||||
@@ -57,6 +61,7 @@ export default function ProcessorDetailWorkbench({
|
||||
isDirty,
|
||||
isSaving,
|
||||
configTitle,
|
||||
configIcon,
|
||||
configContent,
|
||||
debugTitle,
|
||||
debugDescription,
|
||||
@@ -81,10 +86,11 @@ export default function ProcessorDetailWorkbench({
|
||||
className="flex h-full min-h-0 min-w-0 flex-col gap-0"
|
||||
>
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 pb-4">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h1 className="truncate text-xl font-semibold">{title}</h1>
|
||||
{titleBadge}
|
||||
{titleAction}
|
||||
{titleControls}
|
||||
{monitoring && (
|
||||
<TabsList
|
||||
aria-label={`${monitoring.workbenchLabel} / ${monitoring.label}`}
|
||||
@@ -168,18 +174,20 @@ export default function ProcessorDetailWorkbench({
|
||||
value="monitoring"
|
||||
className="mt-0 min-h-0 flex-1 overflow-hidden"
|
||||
>
|
||||
<section
|
||||
<Card
|
||||
role="region"
|
||||
aria-label={monitoring.label}
|
||||
className="h-full min-h-0 overflow-y-auto rounded-xl border bg-card p-4"
|
||||
className="h-full min-h-0 overflow-y-auto gap-0 p-4"
|
||||
>
|
||||
{monitoring.content}
|
||||
</section>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
<TabsContent
|
||||
value="workbench"
|
||||
className="mt-0 min-h-0 flex-1 overflow-y-auto lg:overflow-hidden"
|
||||
forceMount
|
||||
className="mt-0 min-h-0 flex-1 overflow-y-auto lg:overflow-hidden data-[state=inactive]:hidden"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -190,24 +198,27 @@ export default function ProcessorDetailWorkbench({
|
||||
)}
|
||||
>
|
||||
{hasDebug && (
|
||||
<section
|
||||
<Card
|
||||
role="region"
|
||||
aria-label={debugTitle}
|
||||
className="flex min-h-[32rem] min-w-0 flex-col overflow-hidden rounded-xl border bg-card lg:min-h-0"
|
||||
className="min-h-[32rem] min-w-0 gap-0 overflow-hidden py-0 lg:min-h-0"
|
||||
>
|
||||
<div className="flex h-12 shrink-0 items-center justify-between gap-3 border-b px-4">
|
||||
<CardHeader className="flex h-12 shrink-0 items-center justify-between gap-3 border-b px-4 [.border-b]:pb-0">
|
||||
<div className="flex min-w-0 items-center gap-2 font-medium">
|
||||
<Bug className="size-4 shrink-0" />
|
||||
<span className="truncate">{debugTitle}</span>
|
||||
{debugDescription && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={debugDescription}
|
||||
className="inline-flex shrink-0 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm"
|
||||
className="size-6 shrink-0 text-muted-foreground"
|
||||
>
|
||||
<Info className="size-4" />
|
||||
</button>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs whitespace-normal leading-relaxed">
|
||||
{debugDescription}
|
||||
@@ -228,19 +239,20 @@ export default function ProcessorDetailWorkbench({
|
||||
: debugDisconnectedLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-h-0 min-w-0 flex-1 overflow-hidden">
|
||||
</CardHeader>
|
||||
<CardContent className="min-h-0 min-w-0 flex-1 overflow-hidden px-0">
|
||||
{debugContent}
|
||||
</div>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<section
|
||||
<Card
|
||||
role="region"
|
||||
aria-label={configTitle}
|
||||
className="flex min-h-[36rem] min-w-0 flex-col overflow-hidden rounded-xl border bg-card lg:min-h-0"
|
||||
className="min-h-[36rem] min-w-0 gap-0 overflow-hidden py-0 lg:min-h-0"
|
||||
>
|
||||
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-4 font-medium">
|
||||
<Settings className="size-4" />
|
||||
<CardHeader className="flex h-12 shrink-0 flex-row items-center gap-2 border-b px-4 font-medium [.border-b]:pb-0">
|
||||
{configIcon ?? <Settings className="size-4" />}
|
||||
<span className="truncate">{configTitle}</span>
|
||||
{isDirty && (
|
||||
<span className="ml-auto flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-400">
|
||||
@@ -248,11 +260,11 @@ export default function ProcessorDetailWorkbench({
|
||||
{unsavedLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-h-0 min-w-0 flex-1 overflow-hidden p-4">
|
||||
</CardHeader>
|
||||
<CardContent className="min-h-0 min-w-0 flex-1 overflow-hidden p-4">
|
||||
{configContent}
|
||||
</div>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
@@ -178,6 +178,11 @@ export interface ProcessorRun {
|
||||
status: string;
|
||||
status_reason?: string;
|
||||
created_at: number;
|
||||
started_at?: number | null;
|
||||
finished_at?: number | null;
|
||||
created_at_ms?: number | null;
|
||||
started_at_ms?: number | null;
|
||||
finished_at_ms?: number | null;
|
||||
metadata: { event_type?: string; input_event?: unknown; delivery?: unknown };
|
||||
}
|
||||
|
||||
|
||||
@@ -704,6 +704,16 @@ const enUS = {
|
||||
},
|
||||
agents: {
|
||||
eventProcessor: {
|
||||
pluginSettings: 'Plugin settings',
|
||||
pluginSettingsDescription: 'Parameters declared by this plugin.',
|
||||
selectToDebug: 'Select a plugin above to start debugging.',
|
||||
|
||||
debugOutput: 'Processor output',
|
||||
debugDescription:
|
||||
'Input events, plugin logs and action results for this test session.',
|
||||
debugNotice:
|
||||
'The plugin processes a test event. Platform actions use Mock and do not send real messages; other tools run as configured.',
|
||||
|
||||
type: 'Event processor',
|
||||
description: 'Process platform events using plugin code.',
|
||||
component: 'Plugin component',
|
||||
@@ -734,6 +744,9 @@ const enUS = {
|
||||
trace_tool_call_completed: 'Action result',
|
||||
},
|
||||
debugData: {
|
||||
chatId: 'Chat ID',
|
||||
feedbackType: 'Feedback type (1: like, 2: dislike, 3: cancel)',
|
||||
|
||||
title: 'Event data',
|
||||
form: 'Common fields',
|
||||
json: 'Full JSON',
|
||||
|
||||
@@ -507,6 +507,16 @@ const esES = {
|
||||
},
|
||||
agents: {
|
||||
eventProcessor: {
|
||||
pluginSettings: 'Ajustes del plugin',
|
||||
pluginSettingsDescription: 'Parámetros definidos por este plugin.',
|
||||
selectToDebug: 'Selecciona un plugin arriba para iniciar la depuración.',
|
||||
|
||||
debugOutput: 'Salida del procesador',
|
||||
debugDescription:
|
||||
'Eventos de entrada, registros del plugin y resultados de acciones de esta prueba.',
|
||||
debugNotice:
|
||||
'El plugin procesa un evento de prueba. Las acciones de plataforma usan Mock y no envían mensajes reales; las demás herramientas se ejecutan según su configuración.',
|
||||
|
||||
type: 'Procesador de eventos',
|
||||
description: 'Procesa eventos con código del plugin.',
|
||||
component: 'Componente del plugin',
|
||||
@@ -538,6 +548,10 @@ const esES = {
|
||||
trace_tool_call_completed: 'Resultado de la acción',
|
||||
},
|
||||
debugData: {
|
||||
chatId: 'ID del chat',
|
||||
feedbackType:
|
||||
'Tipo de valoración (1: positiva, 2: negativa, 3: cancelar)',
|
||||
|
||||
title: 'Datos del evento',
|
||||
form: 'Campos comunes',
|
||||
json: 'JSON completo',
|
||||
|
||||
@@ -717,6 +717,16 @@ const jaJP = {
|
||||
},
|
||||
agents: {
|
||||
eventProcessor: {
|
||||
pluginSettings: 'プラグイン設定',
|
||||
pluginSettingsDescription: 'このプラグインが定義するパラメーターです。',
|
||||
selectToDebug: '上でプラグインを選択してデバッグを開始してください。',
|
||||
|
||||
debugOutput: 'プロセッサー出力',
|
||||
debugDescription:
|
||||
'このテストの入力イベント、プラグインログ、アクション結果。',
|
||||
debugNotice:
|
||||
'プラグインはテストイベントを実際に処理します。返信や送信などは Mock を使用し、実際のメッセージは送信しません。他のツールは設定どおりに実行されます。',
|
||||
|
||||
type: 'イベントプロセッサー',
|
||||
description: 'プラグインのコードでイベントを処理します。',
|
||||
component: 'プラグインコンポーネント',
|
||||
@@ -748,6 +758,9 @@ const jaJP = {
|
||||
trace_tool_call_completed: 'アクション結果',
|
||||
},
|
||||
debugData: {
|
||||
chatId: 'チャット ID',
|
||||
feedbackType: 'フィードバック種別(1: 高評価、2: 低評価、3: 取消)',
|
||||
|
||||
title: 'イベントデータ',
|
||||
form: '基本項目',
|
||||
json: '完全な JSON',
|
||||
|
||||
@@ -504,6 +504,16 @@ const ruRU = {
|
||||
},
|
||||
agents: {
|
||||
eventProcessor: {
|
||||
pluginSettings: 'Настройки плагина',
|
||||
pluginSettingsDescription: 'Параметры, объявленные этим плагином.',
|
||||
selectToDebug: 'Выберите плагин выше, чтобы начать отладку.',
|
||||
|
||||
debugOutput: 'Вывод обработчика',
|
||||
debugDescription:
|
||||
'Входные события, журналы плагина и результаты действий этой проверки.',
|
||||
debugNotice:
|
||||
'Плагин обрабатывает тестовое событие. Действия платформы используют Mock и не отправляют реальные сообщения; остальные инструменты работают согласно настройкам.',
|
||||
|
||||
type: 'Обработчик событий',
|
||||
description: 'Обрабатывает события кодом плагина.',
|
||||
component: 'Компонент плагина',
|
||||
@@ -535,6 +545,9 @@ const ruRU = {
|
||||
trace_tool_call_completed: 'Результат действия',
|
||||
},
|
||||
debugData: {
|
||||
chatId: 'ID чата',
|
||||
feedbackType: 'Тип отзыва (1: нравится, 2: не нравится, 3: отмена)',
|
||||
|
||||
title: 'Данные события',
|
||||
form: 'Основные поля',
|
||||
json: 'Полный JSON',
|
||||
|
||||
@@ -491,6 +491,16 @@ const thTH = {
|
||||
},
|
||||
agents: {
|
||||
eventProcessor: {
|
||||
pluginSettings: 'การตั้งค่าปลั๊กอิน',
|
||||
pluginSettingsDescription: 'พารามิเตอร์ที่ประกาศโดยปลั๊กอินนี้',
|
||||
selectToDebug: 'เลือกปลั๊กอินด้านบนเพื่อเริ่มแก้จุดบกพร่อง',
|
||||
|
||||
debugOutput: 'ผลลัพธ์ตัวประมวลผล',
|
||||
debugDescription:
|
||||
'เหตุการณ์ขาเข้า บันทึกปลั๊กอิน และผลการดำเนินการของการทดสอบนี้',
|
||||
debugNotice:
|
||||
'ปลั๊กอินประมวลผลเหตุการณ์ทดสอบจริง การตอบกลับและส่งข้อความใช้ Mock โดยไม่ส่งข้อความจริง เครื่องมืออื่นทำงานตามการตั้งค่า',
|
||||
|
||||
type: 'ตัวประมวลผลเหตุการณ์',
|
||||
description: 'ประมวลผลเหตุการณ์ด้วยโค้ดปลั๊กอิน',
|
||||
component: 'ส่วนประกอบปลั๊กอิน',
|
||||
@@ -521,6 +531,9 @@ const thTH = {
|
||||
trace_tool_call_completed: 'ผลการดำเนินการ',
|
||||
},
|
||||
debugData: {
|
||||
chatId: 'ID แชท',
|
||||
feedbackType: 'ประเภทข้อเสนอแนะ (1: ชอบ, 2: ไม่ชอบ, 3: ยกเลิก)',
|
||||
|
||||
title: 'ข้อมูลเหตุการณ์',
|
||||
form: 'ฟิลด์ทั่วไป',
|
||||
json: 'JSON ทั้งหมด',
|
||||
|
||||
@@ -500,6 +500,16 @@ const viVN = {
|
||||
},
|
||||
agents: {
|
||||
eventProcessor: {
|
||||
pluginSettings: 'Cài đặt plugin',
|
||||
pluginSettingsDescription: 'Tham số do plugin này khai báo.',
|
||||
selectToDebug: 'Chọn plugin ở trên để bắt đầu gỡ lỗi.',
|
||||
|
||||
debugOutput: 'Đầu ra bộ xử lý',
|
||||
debugDescription:
|
||||
'Sự kiện đầu vào, nhật ký plugin và kết quả hành động của lần kiểm thử này.',
|
||||
debugNotice:
|
||||
'Plugin xử lý sự kiện kiểm thử. Hành động nền tảng dùng Mock và không gửi tin nhắn thật; các công cụ khác chạy theo cấu hình.',
|
||||
|
||||
type: 'Bộ xử lý sự kiện',
|
||||
description: 'Xử lý sự kiện bằng mã plugin.',
|
||||
component: 'Thành phần plugin',
|
||||
@@ -530,6 +540,9 @@ const viVN = {
|
||||
trace_tool_call_completed: 'Kết quả hành động',
|
||||
},
|
||||
debugData: {
|
||||
chatId: 'ID cuộc trò chuyện',
|
||||
feedbackType: 'Loại phản hồi (1: thích, 2: không thích, 3: hủy)',
|
||||
|
||||
title: 'Dữ liệu sự kiện',
|
||||
form: 'Trường thường dùng',
|
||||
json: 'JSON đầy đủ',
|
||||
|
||||
@@ -669,6 +669,15 @@ const zhHans = {
|
||||
},
|
||||
agents: {
|
||||
eventProcessor: {
|
||||
pluginSettings: '插件设置',
|
||||
pluginSettingsDescription: '由当前插件声明的参数。',
|
||||
selectToDebug: '请先在上方选择插件,再开始调试。',
|
||||
|
||||
debugOutput: '处理器输出',
|
||||
debugDescription: '当前测试的输入事件、插件日志和动作结果。',
|
||||
debugNotice:
|
||||
'插件真实处理测试事件;回复、发送等平台动作使用 Mock,不发送真实消息。其他工具仍按实际配置执行。',
|
||||
|
||||
type: '事件处理器',
|
||||
description: '通过插件代码处理平台事件。',
|
||||
component: '插件组件',
|
||||
@@ -699,6 +708,9 @@ const zhHans = {
|
||||
trace_tool_call_completed: '动作结果',
|
||||
},
|
||||
debugData: {
|
||||
chatId: '会话 ID',
|
||||
feedbackType: '反馈类型(1:赞,2:踩,3:取消)',
|
||||
|
||||
title: '事件数据',
|
||||
form: '常用字段',
|
||||
json: '完整 JSON',
|
||||
|
||||
@@ -475,6 +475,15 @@ const zhHant = {
|
||||
},
|
||||
agents: {
|
||||
eventProcessor: {
|
||||
pluginSettings: '外掛設定',
|
||||
pluginSettingsDescription: '由目前外掛宣告的參數。',
|
||||
selectToDebug: '請先在上方選擇外掛,再開始除錯。',
|
||||
|
||||
debugOutput: '處理器輸出',
|
||||
debugDescription: '目前測試的輸入事件、外掛日誌和動作結果。',
|
||||
debugNotice:
|
||||
'外掛實際處理測試事件;回覆、傳送等平台動作使用 Mock,不傳送真實訊息。其他工具仍依實際設定執行。',
|
||||
|
||||
type: '事件處理器',
|
||||
description: '透過外掛程式碼處理平台事件。',
|
||||
component: '外掛元件',
|
||||
@@ -505,6 +514,9 @@ const zhHant = {
|
||||
trace_tool_call_completed: '動作結果',
|
||||
},
|
||||
debugData: {
|
||||
chatId: '會話 ID',
|
||||
feedbackType: '回饋類型(1:讚,2:踩,3:取消)',
|
||||
|
||||
title: '事件資料',
|
||||
form: '常用欄位',
|
||||
json: '完整 JSON',
|
||||
|
||||
@@ -78,7 +78,7 @@ test('event data stays in sync across the compact form, JSON and the request', a
|
||||
await expect(panel.getByText('Done', { exact: true })).toBeVisible();
|
||||
await page.setViewportSize({ width: 1280, height: 650 });
|
||||
await panel.getByRole('button', { name: 'Full JSON' }).click();
|
||||
await panel.locator('summary').filter({ hasText: 'Mock scenario' }).click();
|
||||
await panel.getByRole('button', { name: 'Mock scenario' }).click();
|
||||
await expect(
|
||||
panel.getByRole('button', { name: 'Run test' }),
|
||||
).toBeInViewport();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||
|
||||
test('event processor shows isolated logs, paginates and scrolls expanded payloads', async ({
|
||||
test('create first, select a plugin in the header, debug beside scrollable logs', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
@@ -12,13 +12,18 @@ test('event processor shows isolated logs, paginates and scrolls expanded payloa
|
||||
name: 'Welcome processor',
|
||||
component_ref: ref,
|
||||
supported_event_patterns: ['group.member_joined'],
|
||||
config: { runner: { id: ref }, runner_config: { [ref]: {} } },
|
||||
config: {
|
||||
runner: { id: ref },
|
||||
runner_config: { [ref]: { greeting: 'Hello' } },
|
||||
},
|
||||
};
|
||||
const run = {
|
||||
run_id: 'run-one',
|
||||
status: 'completed',
|
||||
status_reason: 'stop',
|
||||
created_at: 1788000000,
|
||||
started_at_ms: 1788000000000,
|
||||
finished_at_ms: 1788000000500,
|
||||
metadata: {
|
||||
event_type: 'group.member_joined',
|
||||
input_event: { member: { id: 'one' } },
|
||||
@@ -28,10 +33,63 @@ test('event processor shows isolated logs, paginates and scrolls expanded payloa
|
||||
},
|
||||
};
|
||||
const cursors: string[] = [];
|
||||
const debugRequests: unknown[] = [];
|
||||
const operations: string[] = [];
|
||||
const creations: unknown[] = [];
|
||||
await page.route('**/api/v1/agents**', async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
let data: unknown;
|
||||
if (url.pathname.endsWith('/_/metadata')) {
|
||||
if (
|
||||
url.pathname === '/api/v1/agents' &&
|
||||
route.request().method() === 'POST'
|
||||
) {
|
||||
const payload = route.request().postDataJSON();
|
||||
creations.push(payload);
|
||||
Object.assign(processor, payload, {
|
||||
component_ref: null,
|
||||
config: {},
|
||||
supported_event_patterns: [],
|
||||
});
|
||||
await route.fulfill({
|
||||
json: { code: 0, data: { uuid: processor.uuid, kind: processor.kind } },
|
||||
});
|
||||
return;
|
||||
} else if (url.pathname.endsWith('/debug/stream')) {
|
||||
operations.push('debug');
|
||||
debugRequests.push(route.request().postDataJSON());
|
||||
const events = [
|
||||
{
|
||||
type: 'processor.log',
|
||||
sequence: 1,
|
||||
data: { level: 'info', text: 'Debug handler invoked once' },
|
||||
},
|
||||
{
|
||||
type: 'tool.call.started',
|
||||
sequence: 2,
|
||||
data: { tool_name: 'event_reply', parameters: { text: 'Welcome' } },
|
||||
},
|
||||
{
|
||||
type: 'tool.call.completed',
|
||||
sequence: 3,
|
||||
data: {
|
||||
tool_name: 'event_reply',
|
||||
result: { mock: true, ok: true, delivery: 'simulated' },
|
||||
},
|
||||
},
|
||||
{ type: 'run.completed', sequence: 4, data: {} },
|
||||
];
|
||||
await route.fulfill({
|
||||
contentType: 'application/x-ndjson',
|
||||
body:
|
||||
[
|
||||
...events.map((data) => ({ kind: 'result', data })),
|
||||
{ kind: 'completed', data: { final_text: '' } },
|
||||
]
|
||||
.map((frame) => JSON.stringify(frame))
|
||||
.join('\n') + '\n',
|
||||
});
|
||||
return;
|
||||
} else if (url.pathname.endsWith('/_/metadata')) {
|
||||
data = {
|
||||
kinds: [],
|
||||
event_processors: [
|
||||
@@ -39,12 +97,38 @@ test('event processor shows isolated logs, paginates and scrolls expanded payloa
|
||||
id: ref,
|
||||
label: { en_US: 'Welcome' },
|
||||
supported_event_patterns: ['group.member_joined'],
|
||||
config_schema: [],
|
||||
config_schema: [
|
||||
{
|
||||
name: 'greeting',
|
||||
type: 'string',
|
||||
label: { en_US: 'Greeting' },
|
||||
default: 'Hello',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
plugin_author: 'qa',
|
||||
plugin_name: 'welcome',
|
||||
},
|
||||
],
|
||||
};
|
||||
} else if (url.pathname.endsWith('/runs/run-two/events')) {
|
||||
data = {
|
||||
run: {
|
||||
...run,
|
||||
run_id: 'run-two',
|
||||
metadata: { event_type: 'group.member_left' },
|
||||
},
|
||||
items: [
|
||||
{
|
||||
sequence: 1,
|
||||
type: 'processor.log',
|
||||
created_at_ms: 1788000000000,
|
||||
data: { text: 'Older run selected', level: 'info' },
|
||||
},
|
||||
],
|
||||
has_more: false,
|
||||
next_cursor: null,
|
||||
};
|
||||
} else if (url.pathname.endsWith('/runs/run-one/events')) {
|
||||
cursors.push(url.searchParams.get('after_sequence') ?? '');
|
||||
data = {
|
||||
@@ -62,6 +146,27 @@ test('event processor shows isolated logs, paginates and scrolls expanded payloa
|
||||
sequence: 1,
|
||||
type: 'processor.log',
|
||||
data: { level: 'info', text: 'Member received' },
|
||||
created_at_ms: 1788000000000,
|
||||
},
|
||||
{
|
||||
sequence: 2,
|
||||
type: 'tool.call.started',
|
||||
created_at_ms: 1788000000050,
|
||||
data: {
|
||||
tool_call_id: 'reply-one',
|
||||
tool_name: 'event_reply',
|
||||
parameters: { text: 'Welcome' },
|
||||
},
|
||||
},
|
||||
{
|
||||
sequence: 3,
|
||||
type: 'tool.call.completed',
|
||||
created_at_ms: 1788000000200,
|
||||
data: {
|
||||
tool_call_id: 'reply-one',
|
||||
tool_name: 'event_reply',
|
||||
result: { ok: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
sequence: 100,
|
||||
@@ -78,38 +183,120 @@ test('event processor shows isolated logs, paginates and scrolls expanded payloa
|
||||
next_cursor: url.searchParams.has('after_sequence') ? null : 100,
|
||||
};
|
||||
} else if (url.pathname.endsWith('/runs')) {
|
||||
data = { items: [run], has_more: false, next_cursor: null, total: 1 };
|
||||
data = {
|
||||
items: debugRequests.length
|
||||
? [
|
||||
run,
|
||||
{
|
||||
...run,
|
||||
run_id: 'run-two',
|
||||
created_at: run.created_at - 60,
|
||||
started_at_ms: 1787999940000,
|
||||
finished_at_ms: 1787999940200,
|
||||
metadata: { event_type: 'group.member_left' },
|
||||
},
|
||||
]
|
||||
: [],
|
||||
has_more: false,
|
||||
next_cursor: null,
|
||||
total: debugRequests.length,
|
||||
};
|
||||
} else if (url.pathname.endsWith('/processor-qa')) {
|
||||
if (route.request().method() === 'PUT') {
|
||||
operations.push('save');
|
||||
Object.assign(processor, route.request().postDataJSON(), {
|
||||
supported_event_patterns: ['group.member_joined'],
|
||||
});
|
||||
}
|
||||
data = { agent: processor };
|
||||
} else {
|
||||
data = { agents: [processor] };
|
||||
}
|
||||
await route.fulfill({ json: { code: 0, data } });
|
||||
});
|
||||
await page.goto('/home/agents?id=processor-qa');
|
||||
await page.goto('/home/agents?id=new');
|
||||
await page.locator('[data-processor-kind="event_processor"]').click();
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Welcome processor' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Logs and message flow' }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('stop', { exact: true })).toHaveCount(0);
|
||||
await page
|
||||
.getByRole('button')
|
||||
.filter({ hasText: 'group.member_joined' })
|
||||
.click();
|
||||
await expect(
|
||||
page.getByText('Member received', { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Payload line 99', { exact: false }),
|
||||
).toBeHidden();
|
||||
await page.locator('summary').filter({ hasText: 'Action result' }).click();
|
||||
await page.getByRole('button', { name: 'Load more', exact: true }).click();
|
||||
await page.getByText('Final log after pagination').scrollIntoViewIfNeeded();
|
||||
await expect(page.getByText('Final log after pagination')).toBeInViewport();
|
||||
expect(cursors).toEqual(['', '100']);
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Load more', exact: true }),
|
||||
page.getByRole('combobox', { name: 'Plugin component' }),
|
||||
).toHaveCount(0);
|
||||
await page
|
||||
.getByRole('textbox', { name: 'Name', exact: false })
|
||||
.fill('Welcome processor');
|
||||
await page.getByRole('button', { name: 'Submit', exact: true }).click();
|
||||
await expect(page).toHaveURL(/id=processor-qa/);
|
||||
expect(creations).toHaveLength(1);
|
||||
expect(creations[0]).toMatchObject({
|
||||
kind: 'event_processor',
|
||||
name: 'Welcome processor',
|
||||
});
|
||||
expect(creations[0]).not.toHaveProperty('component_ref');
|
||||
expect(creations[0]).not.toHaveProperty('config');
|
||||
const panel = page.getByRole('region', { name: 'Event Debug' });
|
||||
const logs = page.getByRole('region', { name: 'Logs and message flow' });
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(logs).toBeVisible();
|
||||
await expect(page.getByRole('tab')).toHaveCount(0);
|
||||
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||||
await expect(panel.getByRole('button', { name: 'Run test' })).toHaveCount(0);
|
||||
await page.getByRole('combobox', { name: 'Plugin component' }).click();
|
||||
await page.getByRole('option').filter({ hasText: 'Welcome' }).click();
|
||||
await expect(
|
||||
panel.getByRole('combobox', { name: 'Event type' }),
|
||||
).toContainText('group.member_joined');
|
||||
await expect(page.getByText('Greeting', { exact: true })).toHaveCount(0);
|
||||
await page
|
||||
.getByRole('button', { name: 'Plugin settings', exact: true })
|
||||
.click();
|
||||
const settings = page.locator('[data-slot="popover-content"]');
|
||||
await settings.getByRole('textbox').fill('Welcome');
|
||||
await page.keyboard.press('Escape');
|
||||
await panel
|
||||
.getByRole('textbox', { name: 'Member ID' })
|
||||
.fill('debug-member-42');
|
||||
await panel.getByRole('button', { name: 'Save and run' }).click();
|
||||
await expect(panel.getByText('Debug handler invoked once')).toBeVisible();
|
||||
expect(operations).toEqual(['save', 'debug']);
|
||||
expect(debugRequests).toHaveLength(1);
|
||||
expect(debugRequests[0]).toMatchObject({
|
||||
event_type: 'group.member_joined',
|
||||
data: { member: { id: 'debug-member-42' } },
|
||||
});
|
||||
await expect(
|
||||
logs.getByText('Member received', { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
logs.getByText('Payload line 99', { exact: false }),
|
||||
).toBeHidden();
|
||||
const runList = logs.getByRole('group', { name: 'Runs', exact: true });
|
||||
await expect(logs.getByRole('combobox')).toHaveCount(0);
|
||||
await expect(runList.getByRole('button')).toHaveCount(2);
|
||||
await expect(runList.getByText('500 ms', { exact: true })).toBeVisible();
|
||||
await expect(runList.getByText('200 ms', { exact: true })).toBeVisible();
|
||||
await expect(runList.getByText('Member received')).toHaveCount(0);
|
||||
await runList.getByRole('button', { name: /group.member_left/ }).click();
|
||||
await expect(logs.getByText('Older run selected')).toBeVisible();
|
||||
await runList.getByRole('button', { name: /group.member_joined/ }).click();
|
||||
await expect(
|
||||
logs.getByText('Member received', { exact: true }),
|
||||
).toBeVisible();
|
||||
await logs
|
||||
.getByRole('button', { name: 'Action result', exact: true })
|
||||
.click();
|
||||
await logs.getByRole('button', { name: 'Load more' }).click();
|
||||
await logs.getByText('Final log after pagination').scrollIntoViewIfNeeded();
|
||||
await expect(logs.getByText('Final log after pagination')).toBeInViewport();
|
||||
expect(cursors).toContain('100');
|
||||
await expect(panel.getByText('Debug handler invoked once')).toBeVisible();
|
||||
const debugBox = await panel.boundingBox();
|
||||
const logBox = await logs.boundingBox();
|
||||
expect(debugBox!.x).toBeLessThan(logBox!.x);
|
||||
await page.setViewportSize({ width: 390, height: 700 });
|
||||
await panel
|
||||
.getByRole('button', { name: 'Run test' })
|
||||
.scrollIntoViewIfNeeded();
|
||||
await expect(
|
||||
panel.getByRole('button', { name: 'Run test' }),
|
||||
).toBeInViewport();
|
||||
await logs.getByText('Final log after pagination').scrollIntoViewIfNeeded();
|
||||
await expect(logs.getByText('Final log after pagination')).toBeInViewport();
|
||||
});
|
||||
|
||||
@@ -98,3 +98,64 @@ test('duration and rating remain numbers and reject invalid values', () => {
|
||||
'rating',
|
||||
);
|
||||
});
|
||||
|
||||
test('processor common fields edit nested SDK data without losing JSON-only fields', () => {
|
||||
const { setDebugEventField, getDebugEventField } = module.exports;
|
||||
const data = createDebugEventData('group.member_joined', samples, true);
|
||||
assert.deepEqual(data, {
|
||||
member: { nickname: '测试用户', id: 'debug-user' },
|
||||
group: { id: 'debug-group' },
|
||||
});
|
||||
data.member.username = 'alice';
|
||||
data.inviter = { id: 'inviter-1' };
|
||||
const edited = setDebugEventField(data, 'member.id', 'member-42');
|
||||
assert.equal(edited.member.id, 'member-42');
|
||||
assert.equal(edited.member.username, 'alice');
|
||||
assert.deepEqual(edited.inviter, data.inviter);
|
||||
assert.equal(data.member.id, 'debug-user');
|
||||
assert.equal(getDebugEventField(edited, 'member.id'), 'member-42');
|
||||
assert.equal(
|
||||
invalidDebugEventField('group.member_joined', edited, true),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test('processor message content uses SDK message chains and feedback uses SDK fields', () => {
|
||||
const { setDebugEventField, processorDebugEventTypes } = module.exports;
|
||||
for (const [type, field] of [
|
||||
['message.received', 'message_chain'],
|
||||
['message.edited', 'new_content'],
|
||||
]) {
|
||||
const data = createDebugEventData(type, samples, true);
|
||||
const edited = setDebugEventField(data, `${field}.0.text`, 'Changed');
|
||||
assert.deepEqual(edited[field], [{ type: 'Plain', text: 'Changed' }]);
|
||||
assert.equal(debugEventInputText(type, edited, true), 'Changed');
|
||||
assert.equal(data[field][0].text, '你好');
|
||||
}
|
||||
const feedback = createDebugEventData('feedback.received', samples, true);
|
||||
assert.equal(feedback.feedback_type, 1);
|
||||
assert.equal(feedback.feedback_content, '很有帮助');
|
||||
assert.ok(feedback.feedback_id);
|
||||
for (const type of processorDebugEventTypes) {
|
||||
assert.equal(
|
||||
invalidDebugEventField(
|
||||
type,
|
||||
createDebugEventData(type, samples, true),
|
||||
true,
|
||||
),
|
||||
undefined,
|
||||
type,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('processor full JSON accepts rich messages without requiring a first text component', () => {
|
||||
const data = createDebugEventData('message.received', samples, true);
|
||||
data.message_chain = [
|
||||
{ type: 'Image', url: 'https://example.com/image.png' },
|
||||
];
|
||||
assert.equal(
|
||||
invalidDebugEventField('message.received', data, true),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import ts from 'typescript';
|
||||
const source = fs.readFileSync(
|
||||
new URL(
|
||||
'../../src/app/home/agents/components/processor-run-timing.ts',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
const exports = {};
|
||||
new Function(
|
||||
'exports',
|
||||
ts.transpileModule(source, {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
},
|
||||
}).outputText,
|
||||
)(exports);
|
||||
const { processorRunDuration } = exports;
|
||||
test('duration uses precise lifecycle times, excluding queue wait', () => {
|
||||
assert.equal(
|
||||
processorRunDuration({
|
||||
created_at: 1,
|
||||
started_at_ms: 2100,
|
||||
finished_at_ms: 2375,
|
||||
}),
|
||||
275,
|
||||
);
|
||||
});
|
||||
test('missing or inverted times remain unknown; zero is a valid duration', () => {
|
||||
assert.equal(
|
||||
processorRunDuration({ created_at: 1, started_at_ms: 1000 }),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
processorRunDuration({ created_at: 1, finished_at_ms: 2000 }),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
processorRunDuration({ started_at_ms: 1000, finished_at_ms: 900 }),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
processorRunDuration({ started_at_ms: 1000, finished_at_ms: 1000 }),
|
||||
0,
|
||||
);
|
||||
});
|
||||
test('old server responses fall back to second precision', () => {
|
||||
assert.equal(processorRunDuration({ started_at: 2, finished_at: 4 }), 2000);
|
||||
});
|
||||
Reference in New Issue
Block a user