mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-08 18:47: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.
|
1. Install a plugin containing an EventProcessor component.
|
||||||
2. Create an Event processor in the Processors area.
|
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.
|
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.
|
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
|
plugins must explicitly adopt the new component and be bound by the user; do not
|
||||||
create subscriptions during migration.
|
create subscriptions during migration.
|
||||||
|
|
||||||
Validate component availability, event compatibility, Workspace ownership, and
|
An unconfigured instance has no supported events and cannot execute. Validate
|
||||||
instance identity at creation/update and again at invocation. A disabled or
|
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
|
unavailable plugin leaves the instance visible with an actionable unavailable
|
||||||
status. It must not silently fall back to Agent or Pipeline.
|
status. It must not silently fall back to Agent or Pipeline.
|
||||||
|
|
||||||
## Compact UI
|
## Compact UI
|
||||||
|
|
||||||
Creation adds a third type next to Agent and Pipeline, followed by a component
|
Creation adds a third type next to Agent and Pipeline and asks only for basic
|
||||||
selector and basic instance information. Show configuration fields only when the
|
instance information. Select the plugin component in the detail-page header.
|
||||||
component declares them. If no component is installed, show a relevant plugin
|
Keep component-defined configuration in the adjacent Plugin settings popover.
|
||||||
installation entry point; installing still does not create a binding.
|
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
|
The detail page shows event debugging on the left and logs on the right without
|
||||||
trace of the incoming event, handler logs, outgoing actions/messages, and outcome.
|
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
|
Keep payloads and error details collapsed until expanded. Distinguish attempted
|
||||||
delivery from confirmed delivery and display the actual destination.
|
delivery from confirmed delivery and display the actual destination.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -232,4 +232,4 @@ line-ending = "auto"
|
|||||||
|
|
||||||
[tool.uv.sources]
|
[tool.uv.sources]
|
||||||
# Development contract: update to the matching SDK release before publishing.
|
# 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
|
## Event processors
|
||||||
|
|
||||||
Install the plugin, discover its component with `get_processor_metadata`, then
|
Create a processor with `kind: "event_processor"` and basic information. Without
|
||||||
create a processor with `kind: "event_processor"`, `component_ref` and optional
|
a component it supports no events. Discover installed components with
|
||||||
`parameters`. Bind bot events to this instance with `target_type: "event_processor"`
|
`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.
|
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.
|
`debug_agent` accepts the complete typed EBA event in `payload.data` for this kind.
|
||||||
Legacy EventListener plugins remain in the Pipeline lifecycle.
|
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,
|
'dispatch_attempts': row.dispatch_attempts,
|
||||||
'last_claimed_at': _datetime_to_epoch(row.last_claimed_at),
|
'last_claimed_at': _datetime_to_epoch(row.last_claimed_at),
|
||||||
'created_at': _datetime_to_epoch(row.created_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),
|
'started_at': _datetime_to_epoch(row.started_at),
|
||||||
'finished_at': _datetime_to_epoch(row.finished_at),
|
'finished_at': _datetime_to_epoch(row.finished_at),
|
||||||
'updated_at': _datetime_to_epoch(row.updated_at),
|
'updated_at': _datetime_to_epoch(row.updated_at),
|
||||||
|
|||||||
@@ -488,6 +488,9 @@ class AgentService:
|
|||||||
if not isinstance(config, dict):
|
if not isinstance(config, dict):
|
||||||
raise ValueError('Processor configuration must be an object')
|
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)
|
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:'):
|
if not isinstance(component_ref, str) or not component_ref.startswith('event_processor:'):
|
||||||
raise ValueError('Select an installed EventProcessor component')
|
raise ValueError('Select an installed EventProcessor component')
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -188,8 +188,9 @@ class LangBotMCPServer:
|
|||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
description=(
|
description=(
|
||||||
'Create an Agent, Pipeline or Event processor. Set `processor_data.kind` to '
|
'Create an Agent, Pipeline or Event processor. Set `processor_data.kind` to '
|
||||||
'`agent`, `pipeline` or `event_processor`. Event processors require an installed component_ref '
|
'`agent`, `pipeline` or `event_processor`. Event processors may be created without a component; '
|
||||||
'from get_processor_metadata; optional parameters configure the instance. Returns UUID and kind.'
|
'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:
|
async def create_processor(processor_data: dict) -> str:
|
||||||
@@ -213,7 +214,10 @@ class LangBotMCPServer:
|
|||||||
context = _authorized(Permission.RESOURCE_VIEW)
|
context = _authorized(Permission.RESOURCE_VIEW)
|
||||||
return _dump(await ap.agent_service.get_agent_metadata(context))
|
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:
|
async def list_processor_runs(processor_uuid: str, before_id: int | None = None) -> str:
|
||||||
context = _authorized(Permission.RESOURCE_VIEW)
|
context = _authorized(Permission.RESOURCE_VIEW)
|
||||||
return _dump(await ap.agent_service.get_processor_runs(context, processor_uuid, before_id=before_id))
|
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 (total, more) == (2, False)
|
||||||
assert second[0]['run_id'] == 'run-0'
|
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():
|
async def test_event_processor_creation_uses_installed_component_scope():
|
||||||
app = _make_app()
|
app = _make_app()
|
||||||
ref = 'event_processor:test/welcome/default'
|
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'}
|
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():
|
async def test_event_processor_rejects_invalid_component_and_missing_parameters():
|
||||||
app = _make_app()
|
app = _make_app()
|
||||||
service = AgentService(app)
|
service = AgentService(app)
|
||||||
|
|||||||
@@ -2119,7 +2119,7 @@ requires-dist = [
|
|||||||
{ name = "ebooklib", specifier = ">=0.18" },
|
{ name = "ebooklib", specifier = ">=0.18" },
|
||||||
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
||||||
{ name = "html2text", specifier = ">=2024.2.26" },
|
{ 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", specifier = ">=1.3.9" },
|
||||||
{ name = "langchain-core", specifier = ">=1.3.3" },
|
{ name = "langchain-core", specifier = ">=1.3.3" },
|
||||||
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
|
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
|
||||||
@@ -2186,7 +2186,7 @@ dev = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "langbot-plugin"
|
name = "langbot-plugin"
|
||||||
version = "0.5.5"
|
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 = [
|
dependencies = [
|
||||||
{ name = "aiofiles" },
|
{ name = "aiofiles" },
|
||||||
{ name = "aiohttp" },
|
{ name = "aiohttp" },
|
||||||
|
|||||||
@@ -174,6 +174,8 @@ export default function AgentDetailContent({ id }: { id: string }) {
|
|||||||
id={id}
|
id={id}
|
||||||
agent={agent}
|
agent={agent}
|
||||||
canManage={canManage}
|
canManage={canManage}
|
||||||
|
canOperate={canOperate}
|
||||||
|
availableEventTypes={availableEventTypes}
|
||||||
onDelete={() => setDeleteConfirmOpen(true)}
|
onDelete={() => setDeleteConfirmOpen(true)}
|
||||||
onEdit={() => setBasicInfoOpen(true)}
|
onEdit={() => setBasicInfoOpen(true)}
|
||||||
onSaved={() => {
|
onSaved={() => {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link } from 'react-router-dom';
|
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 { toast } from 'sonner';
|
||||||
import type {
|
import type {
|
||||||
Agent,
|
Agent,
|
||||||
@@ -14,12 +16,23 @@ import { httpClient } from '@/app/infra/http/HttpClient';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||||
import { Badge } from '@/components/ui/badge';
|
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';
|
import EventProcessorSettings from './components/EventProcessorSettings';
|
||||||
|
|
||||||
export default function EventProcessorDetailContent({
|
export default function EventProcessorDetailContent({
|
||||||
agent,
|
agent,
|
||||||
id,
|
id,
|
||||||
canManage,
|
canManage,
|
||||||
|
canOperate,
|
||||||
|
availableEventTypes,
|
||||||
onDelete,
|
onDelete,
|
||||||
onEdit,
|
onEdit,
|
||||||
onSaved,
|
onSaved,
|
||||||
@@ -27,6 +40,8 @@ export default function EventProcessorDetailContent({
|
|||||||
agent: Agent;
|
agent: Agent;
|
||||||
id: string;
|
id: string;
|
||||||
canManage: boolean;
|
canManage: boolean;
|
||||||
|
canOperate: boolean;
|
||||||
|
availableEventTypes: string[];
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onSaved: () => void;
|
onSaved: () => void;
|
||||||
@@ -46,6 +61,13 @@ export default function EventProcessorDetailContent({
|
|||||||
>
|
>
|
||||||
)[agent.component_ref ?? ''] ?? {};
|
)[agent.component_ref ?? ''] ?? {};
|
||||||
const [parameters, setParameters] = useState(initialParameters);
|
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 [runs, setRuns] = useState<ProcessorRun[]>([]);
|
||||||
const [cursor, setCursor] = useState<number | null>(null);
|
const [cursor, setCursor] = useState<number | null>(null);
|
||||||
const [selected, setSelected] = useState<ProcessorRun | null>(null);
|
const [selected, setSelected] = useState<ProcessorRun | null>(null);
|
||||||
@@ -55,11 +77,11 @@ export default function EventProcessorDetailContent({
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [pagingRuns, setPagingRuns] = useState(false);
|
const [pagingRuns, setPagingRuns] = useState(false);
|
||||||
const [pagingEvents, setPagingEvents] = useState(false);
|
const [pagingEvents, setPagingEvents] = useState(false);
|
||||||
const [configOpen, setConfigOpen] = useState(false);
|
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
const validate = useRef<(() => Promise<boolean>) | null>(null);
|
const validate = useRef<(() => Promise<boolean>) | null>(null);
|
||||||
const requestVersion = useRef(0);
|
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 () => {
|
const load = useCallback(async () => {
|
||||||
setFailed(false);
|
setFailed(false);
|
||||||
@@ -88,20 +110,38 @@ export default function EventProcessorDetailContent({
|
|||||||
[id],
|
[id],
|
||||||
);
|
);
|
||||||
|
|
||||||
async function openRun(run: ProcessorRun) {
|
const openRun = useCallback(
|
||||||
const version = ++requestVersion.current;
|
async (run: ProcessorRun) => {
|
||||||
setSelected(run);
|
const version = ++requestVersion.current;
|
||||||
setEvents([]);
|
setSelected(run);
|
||||||
setEventCursor(null);
|
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 {
|
try {
|
||||||
const page = await httpClient.getProcessorRunEvents(id, run.run_id);
|
const page = await httpClient.getProcessorRuns(id);
|
||||||
if (version !== requestVersion.current) return;
|
setRuns(page.items);
|
||||||
setSelected(page.run);
|
setCursor(page.has_more ? page.next_cursor : null);
|
||||||
setEvents(page.items);
|
if (page.items[0]) await openRun(page.items[0]);
|
||||||
setEventCursor(page.has_more ? page.next_cursor : null);
|
|
||||||
} catch {
|
} 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]);
|
}, [id, selected, eventCursor, events]);
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
if (!componentRef || !((await validate.current?.()) ?? true)) return;
|
if (
|
||||||
|
!canManage ||
|
||||||
|
saving ||
|
||||||
|
!component ||
|
||||||
|
!((await validate.current?.()) ?? true)
|
||||||
|
)
|
||||||
|
return false;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await httpClient.updateAgent(id, {
|
await httpClient.updateAgent(id, {
|
||||||
@@ -208,230 +254,213 @@ export default function EventProcessorDetailContent({
|
|||||||
});
|
});
|
||||||
toast.success(t('agents.saveSuccess'));
|
toast.success(t('agents.saveSuccess'));
|
||||||
onSaved();
|
onSaved();
|
||||||
setConfigOpen(false);
|
setSavedConfig({ componentRef, parameters });
|
||||||
await load();
|
await load();
|
||||||
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('agents.saveError'));
|
toast.error(t('agents.saveError'));
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
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 (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col gap-4">
|
<ProcessorDetailWorkbench
|
||||||
<header className="flex flex-wrap items-center gap-3">
|
title={`${agent.emoji || '⚡'} ${agent.name}`}
|
||||||
<FileCode2 className="size-6" />
|
titleAction={
|
||||||
<h1 className="text-2xl font-semibold">{agent.name}</h1>
|
canManage ? <EntityTitleEditButton onClick={onEdit} /> : undefined
|
||||||
{canManage && (
|
}
|
||||||
<Button
|
titleControls={
|
||||||
variant="ghost"
|
<EventProcessorSettings
|
||||||
size="icon"
|
components={components}
|
||||||
onClick={onEdit}
|
value={componentRef}
|
||||||
aria-label={t('common.edit')}
|
parameters={parameters}
|
||||||
>
|
disabled={!canManage || saving || loading}
|
||||||
<Pencil className="size-4" />
|
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>
|
</Button>
|
||||||
)}
|
) : undefined
|
||||||
<Badge variant="outline">{t('agents.eventProcessor.type')}</Badge>
|
}
|
||||||
{!loading && !failed && !available && (
|
configTitle={t('agents.eventProcessor.trace')}
|
||||||
<Badge variant="destructive">
|
configIcon={<ScrollText className="size-4" />}
|
||||||
{t('agents.eventProcessor.unavailable')}
|
configContent={
|
||||||
</Badge>
|
<div className="flex h-full min-h-0 flex-col gap-3">
|
||||||
)}
|
<form
|
||||||
<div className="ml-auto flex gap-2">
|
id="event-processor-form"
|
||||||
<Button
|
onSubmit={(event) => {
|
||||||
variant="outline"
|
event.preventDefault();
|
||||||
onClick={() => {
|
void save();
|
||||||
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;
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button
|
{failed && (
|
||||||
className="mt-4"
|
<Alert variant="destructive">
|
||||||
disabled={
|
<AlertDescription>
|
||||||
saving || !components.some((item) => item.id === componentRef)
|
{t('agents.eventProcessor.loadError')}
|
||||||
}
|
</AlertDescription>
|
||||||
onClick={() => void save()}
|
</Alert>
|
||||||
>
|
|
||||||
{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>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
{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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
disabled={pagingRuns}
|
size="icon"
|
||||||
onClick={() => void loadMoreRuns()}
|
aria-label={t('agents.eventProcessor.refresh')}
|
||||||
|
onClick={() => void refreshLatestRun()}
|
||||||
>
|
>
|
||||||
{t('agents.eventProcessor.loadMore')}
|
<RefreshCw className="size-4" />
|
||||||
</Button>
|
</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>
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
<section className="min-h-0 overflow-y-auto rounded-xl border p-4">
|
<div className="space-y-2 pr-3">
|
||||||
<h2 className="mb-3 font-semibold">
|
{!selected ? (
|
||||||
{t('agents.eventProcessor.trace')}
|
<Alert>
|
||||||
</h2>
|
<AlertDescription>
|
||||||
{!selected ? (
|
{loading
|
||||||
<p className="text-sm text-muted-foreground">
|
? t('common.loading')
|
||||||
{t('agents.eventProcessor.selectRun')}
|
: t('agents.eventProcessor.noRuns')}
|
||||||
</p>
|
<Button asChild variant="link" className="h-auto px-0">
|
||||||
) : (
|
<Link to="/home/bots">
|
||||||
<div className="space-y-3">
|
{t('agents.eventProcessor.bindBot')}
|
||||||
<details className="rounded-lg border p-3">
|
</Link>
|
||||||
<summary className="cursor-pointer text-sm font-medium">
|
</Button>
|
||||||
{t('agents.eventProcessor.input')}
|
</AlertDescription>
|
||||||
</summary>
|
</Alert>
|
||||||
{payload(selected.metadata.input_event)}
|
) : (
|
||||||
</details>
|
<>
|
||||||
{selected.metadata.delivery != null && (
|
<div className="border-b pb-2">
|
||||||
<details className="rounded-lg border p-3">
|
<p className="text-sm font-medium">
|
||||||
<summary className="cursor-pointer text-sm font-medium">
|
{eventPatternLabel(selected.metadata.event_type ?? '', t)}
|
||||||
{t('agents.eventProcessor.destination')}
|
</p>
|
||||||
</summary>
|
<p className="text-xs text-muted-foreground">
|
||||||
{payload(selected.metadata.delivery)}
|
{new Date(selected.created_at * 1000).toLocaleString()}
|
||||||
</details>
|
</p>
|
||||||
)}
|
|
||||||
{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>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<Badge
|
||||||
<details
|
variant={
|
||||||
key={event.sequence}
|
selected.status === 'failed' ? 'destructive' : 'outline'
|
||||||
className="rounded-lg border p-3"
|
}
|
||||||
>
|
>
|
||||||
<summary className="cursor-pointer break-all text-sm font-medium">
|
{t(`agents.eventProcessor.status_${selected.status}`, {
|
||||||
{t(
|
defaultValue: selected.status,
|
||||||
`agents.eventProcessor.trace_${event.type.replaceAll('.', '_')}`,
|
})}
|
||||||
{ defaultValue: event.type },
|
</Badge>
|
||||||
)}
|
<ProcessorPayload
|
||||||
{typeof event.data.tool_name === 'string' && (
|
title={t('agents.eventProcessor.input')}
|
||||||
<span className="ml-2 text-muted-foreground">
|
value={selected.metadata.input_event}
|
||||||
{toolLabels[event.data.tool_name] ||
|
/>
|
||||||
event.data.tool_name}
|
{selected.metadata.delivery != null && (
|
||||||
</span>
|
<ProcessorPayload
|
||||||
)}
|
title={t('agents.eventProcessor.destination')}
|
||||||
</summary>
|
value={selected.metadata.delivery}
|
||||||
{payload(event.data)}
|
/>
|
||||||
</details>
|
)}
|
||||||
),
|
<EventProcessorTrace
|
||||||
)}
|
events={events}
|
||||||
{selected.status === 'failed' && selected.status_reason && (
|
toolLabels={toolLabels}
|
||||||
<p className="break-words text-sm text-destructive">
|
/>
|
||||||
{selected.status_reason}
|
{selected.status === 'failed' && selected.status_reason && (
|
||||||
</p>
|
<Alert variant="destructive">
|
||||||
)}
|
<AlertDescription className="break-words">
|
||||||
{eventCursor !== null && (
|
{selected.status_reason}
|
||||||
<Button
|
</AlertDescription>
|
||||||
variant="ghost"
|
</Alert>
|
||||||
disabled={pagingEvents}
|
)}
|
||||||
onClick={() => void loadMoreEvents()}
|
{eventCursor !== null && (
|
||||||
>
|
<Button
|
||||||
{t('agents.eventProcessor.loadMore')}
|
variant="ghost"
|
||||||
</Button>
|
disabled={pagingEvents}
|
||||||
|
onClick={() => void loadMoreEvents()}
|
||||||
|
>
|
||||||
|
{t('agents.eventProcessor.loadMore')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</ScrollArea>
|
||||||
</section>
|
</div>
|
||||||
</div>
|
}
|
||||||
</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 { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -6,8 +6,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Bot, Workflow, FileCode2 } from 'lucide-react';
|
import { Bot, Workflow, FileCode2 } from 'lucide-react';
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import { AgentKind, EventProcessorDescriptor } from '@/app/infra/entities/api';
|
import { AgentKind } from '@/app/infra/entities/api';
|
||||||
import EventProcessorSettings from './EventProcessorSettings';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||||
import {
|
import {
|
||||||
@@ -36,23 +35,6 @@ export default function AgentCreateContent({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [kind, setKind] = useState<AgentKind>('agent');
|
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({
|
const formSchema = z.object({
|
||||||
name: z.string().min(1, { message: t('agents.nameRequired') }),
|
name: z.string().min(1, { message: t('agents.nameRequired') }),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
@@ -85,23 +67,9 @@ export default function AgentCreateContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit(values: FormValues) {
|
async function handleSubmit(values: FormValues) {
|
||||||
if (
|
return httpClient
|
||||||
kind === 'event_processor' &&
|
|
||||||
(!componentRef || !((await validateParameters.current?.()) ?? true))
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
httpClient
|
|
||||||
.createAgent({
|
.createAgent({
|
||||||
kind,
|
kind,
|
||||||
...(kind === 'event_processor'
|
|
||||||
? {
|
|
||||||
component_ref: componentRef,
|
|
||||||
config: {
|
|
||||||
runner: { id: componentRef },
|
|
||||||
runner_config: { [componentRef]: parameters },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
name: values.name,
|
name: values.name,
|
||||||
description: values.description ?? '',
|
description: values.description ?? '',
|
||||||
emoji: values.emoji || (kind === 'pipeline' ? '⚙️' : '🤖'),
|
emoji: values.emoji || (kind === 'pipeline' ? '⚙️' : '🤖'),
|
||||||
@@ -143,10 +111,7 @@ export default function AgentCreateContent({
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
form="agent-create-form"
|
form="agent-create-form"
|
||||||
disabled={
|
disabled={form.formState.isSubmitting}
|
||||||
form.formState.isSubmitting ||
|
|
||||||
(kind === 'event_processor' && !componentRef)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{t('common.submit')}
|
{t('common.submit')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -209,22 +174,6 @@ export default function AgentCreateContent({
|
|||||||
</ToggleGroup>
|
</ToggleGroup>
|
||||||
</section>
|
</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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('agents.basicInfo')}</CardTitle>
|
<CardTitle>{t('agents.basicInfo')}</CardTitle>
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import {
|
|||||||
groupEventPatterns,
|
groupEventPatterns,
|
||||||
} from '@/app/home/components/event-patterns/event-pattern-groups';
|
} from '@/app/home/components/event-patterns/event-pattern-groups';
|
||||||
import EventSelectOptionContent from '@/app/home/components/event-patterns/EventSelectOptionContent';
|
import EventSelectOptionContent from '@/app/home/components/event-patterns/EventSelectOptionContent';
|
||||||
|
import EventProcessorTrace from './EventProcessorTrace';
|
||||||
import AgentExecutionTrace from './AgentExecutionTrace';
|
import AgentExecutionTrace from './AgentExecutionTrace';
|
||||||
import AgentEventDataEditor from './AgentEventDataEditor';
|
import AgentEventDataEditor from './AgentEventDataEditor';
|
||||||
import {
|
import {
|
||||||
@@ -53,15 +54,18 @@ import {
|
|||||||
debugEventInputText,
|
debugEventInputText,
|
||||||
invalidDebugEventField,
|
invalidDebugEventField,
|
||||||
parseDebugEventData,
|
parseDebugEventData,
|
||||||
|
processorDebugEventTypes,
|
||||||
} from './debug-event-data';
|
} from './debug-event-data';
|
||||||
import { executionSteps, type DebugExecutionEvent } from './debug-execution';
|
import { executionSteps, type DebugExecutionEvent } from './debug-execution';
|
||||||
|
|
||||||
interface AgentDebugPanelProps {
|
interface AgentDebugPanelProps {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
|
processor?: boolean;
|
||||||
availableEventTypes: string[];
|
availableEventTypes: string[];
|
||||||
platformTools?: AgentPlatformTool[];
|
platformTools?: AgentPlatformTool[];
|
||||||
supportedEventPatterns?: string[];
|
supportedEventPatterns?: string[];
|
||||||
beforeRun?: () => Promise<boolean>;
|
beforeRun?: () => Promise<boolean>;
|
||||||
|
onRunFinished?: () => void;
|
||||||
hasUnsavedChanges?: boolean;
|
hasUnsavedChanges?: boolean;
|
||||||
onOpenRunnerConfig?: () => void;
|
onOpenRunnerConfig?: () => void;
|
||||||
}
|
}
|
||||||
@@ -89,10 +93,12 @@ function matchesEventPattern(pattern: string, eventType: string) {
|
|||||||
|
|
||||||
export default function AgentDebugPanel({
|
export default function AgentDebugPanel({
|
||||||
agentId,
|
agentId,
|
||||||
|
processor = false,
|
||||||
availableEventTypes,
|
availableEventTypes,
|
||||||
platformTools = [],
|
platformTools = [],
|
||||||
supportedEventPatterns = ['*'],
|
supportedEventPatterns = ['*'],
|
||||||
beforeRun,
|
beforeRun,
|
||||||
|
onRunFinished,
|
||||||
hasUnsavedChanges = false,
|
hasUnsavedChanges = false,
|
||||||
onOpenRunnerConfig,
|
onOpenRunnerConfig,
|
||||||
}: AgentDebugPanelProps) {
|
}: AgentDebugPanelProps) {
|
||||||
@@ -105,15 +111,19 @@ export default function AgentDebugPanel({
|
|||||||
const newEventData = useCallback(
|
const newEventData = useCallback(
|
||||||
(type: string) =>
|
(type: string) =>
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
createDebugEventData(type, {
|
createDebugEventData(
|
||||||
user: t('agents.debugData.sampleUser'),
|
type,
|
||||||
message: t('agents.debugData.sampleMessage'),
|
{
|
||||||
feedback: t('agents.debugData.sampleFeedback'),
|
user: t('agents.debugData.sampleUser'),
|
||||||
}),
|
message: t('agents.debugData.sampleMessage'),
|
||||||
|
feedback: t('agents.debugData.sampleFeedback'),
|
||||||
|
},
|
||||||
|
processor,
|
||||||
|
),
|
||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
),
|
),
|
||||||
[t],
|
[t, processor],
|
||||||
);
|
);
|
||||||
const [eventDataText, setEventDataText] = useState(() =>
|
const [eventDataText, setEventDataText] = useState(() =>
|
||||||
newEventData('message.received'),
|
newEventData('message.received'),
|
||||||
@@ -140,21 +150,28 @@ export default function AgentDebugPanel({
|
|||||||
const concretePatterns = supportedEventPatterns.filter(
|
const concretePatterns = supportedEventPatterns.filter(
|
||||||
(pattern) => pattern !== '*' && !pattern.endsWith('.*'),
|
(pattern) => pattern !== '*' && !pattern.endsWith('.*'),
|
||||||
);
|
);
|
||||||
return Array.from(new Set([...availableEventTypes, ...concretePatterns]))
|
return Array.from(
|
||||||
|
new Set([
|
||||||
|
...(processor ? processorDebugEventTypes : availableEventTypes),
|
||||||
|
...concretePatterns,
|
||||||
|
]),
|
||||||
|
)
|
||||||
.filter((candidate) =>
|
.filter((candidate) =>
|
||||||
supportedEventPatterns.some((pattern) =>
|
supportedEventPatterns.some((pattern) =>
|
||||||
matchesEventPattern(pattern, candidate),
|
matchesEventPattern(pattern, candidate),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.sort();
|
.sort();
|
||||||
}, [availableEventTypes, supportedEventPatterns]);
|
}, [availableEventTypes, supportedEventPatterns, processor]);
|
||||||
const eventGroups = useMemo(
|
const eventGroups = useMemo(
|
||||||
() => groupEventPatterns(availableEvents),
|
() => groupEventPatterns(availableEvents),
|
||||||
[availableEvents],
|
[availableEvents],
|
||||||
);
|
);
|
||||||
const supportsCustomEvent = supportedEventPatterns.some(
|
const supportsCustomEvent =
|
||||||
(pattern) => pattern === '*' || pattern.endsWith('.*'),
|
!processor &&
|
||||||
);
|
supportedEventPatterns.some(
|
||||||
|
(pattern) => pattern === '*' || pattern.endsWith('.*'),
|
||||||
|
);
|
||||||
|
|
||||||
const selectPreset = useCallback(
|
const selectPreset = useCallback(
|
||||||
(value: string) => {
|
(value: string) => {
|
||||||
@@ -193,7 +210,11 @@ export default function AgentDebugPanel({
|
|||||||
toast.error(t('agents.debugInvalidPayload'));
|
toast.error(t('agents.debugInvalidPayload'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const invalidField = invalidDebugEventField(eventType, eventData);
|
const invalidField = invalidDebugEventField(
|
||||||
|
eventType,
|
||||||
|
eventData,
|
||||||
|
processor,
|
||||||
|
);
|
||||||
if (invalidField) {
|
if (invalidField) {
|
||||||
toast.error(
|
toast.error(
|
||||||
t('agents.debugData.invalidField', {
|
t('agents.debugData.invalidField', {
|
||||||
@@ -202,7 +223,7 @@ export default function AgentDebugPanel({
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const inputText = debugEventInputText(eventType, eventData);
|
const inputText = debugEventInputText(eventType, eventData, processor);
|
||||||
let mockOptions: Record<string, unknown>;
|
let mockOptions: Record<string, unknown>;
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(mockOptionsText || '{}');
|
const parsed = JSON.parse(mockOptionsText || '{}');
|
||||||
@@ -275,12 +296,14 @@ export default function AgentDebugPanel({
|
|||||||
? {
|
? {
|
||||||
...entry,
|
...entry,
|
||||||
finished: true,
|
finished: true,
|
||||||
text: executionSteps(entry.events ?? []).some(
|
text:
|
||||||
(step) =>
|
processor ||
|
||||||
step.kind === 'tool' || step.text || step.reasoning,
|
executionSteps(entry.events ?? []).some(
|
||||||
)
|
(step) =>
|
||||||
? ''
|
step.kind === 'tool' || step.text || step.reasoning,
|
||||||
: result.final_text || t('agents.debugNoTextOutput'),
|
)
|
||||||
|
? ''
|
||||||
|
: result.final_text || t('agents.debugNoTextOutput'),
|
||||||
}
|
}
|
||||||
: entry,
|
: entry,
|
||||||
)
|
)
|
||||||
@@ -354,6 +377,7 @@ export default function AgentDebugPanel({
|
|||||||
if (requestRef.current === controller) {
|
if (requestRef.current === controller) {
|
||||||
requestRef.current = null;
|
requestRef.current = null;
|
||||||
setRunning(false);
|
setRunning(false);
|
||||||
|
onRunFinished?.();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -364,15 +388,25 @@ export default function AgentDebugPanel({
|
|||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<p className="text-sm font-medium">{t('agents.debugTranscript')}</p>
|
<p className="text-sm font-medium">{t('agents.debugTranscript')}</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t('agents.debugTranscriptDescription')}
|
{t(
|
||||||
|
processor
|
||||||
|
? 'agents.eventProcessor.debugDescription'
|
||||||
|
: 'agents.debugTranscriptDescription',
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{entries.length === 0 ? (
|
{entries.length === 0 ? (
|
||||||
<Alert className="my-4 bg-muted/20">
|
<Alert className="my-4 bg-muted/20">
|
||||||
<CircleHelp className="size-4" />
|
<CircleHelp className="size-4" />
|
||||||
<AlertTitle>{t('agents.debugEmptyTitle')}</AlertTitle>
|
<AlertTitle>
|
||||||
|
{t(processor ? 'agents.debugTab' : 'agents.debugEmptyTitle')}
|
||||||
|
</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
{t('agents.debugEmptyTranscript')}
|
{t(
|
||||||
|
processor
|
||||||
|
? 'agents.eventProcessor.debugDescription'
|
||||||
|
: 'agents.debugEmptyTranscript',
|
||||||
|
)}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : (
|
) : (
|
||||||
@@ -382,6 +416,7 @@ export default function AgentDebugPanel({
|
|||||||
(entry) =>
|
(entry) =>
|
||||||
entry.direction !== 'output' ||
|
entry.direction !== 'output' ||
|
||||||
entry.text ||
|
entry.text ||
|
||||||
|
processor ||
|
||||||
executionSteps(entry.events ?? []).some(
|
executionSteps(entry.events ?? []).some(
|
||||||
(step) =>
|
(step) =>
|
||||||
step.kind === 'tool' || step.text || step.reasoning,
|
step.kind === 'tool' || step.text || step.reasoning,
|
||||||
@@ -412,19 +447,29 @@ export default function AgentDebugPanel({
|
|||||||
</Badge>
|
</Badge>
|
||||||
<span className="shrink-0 text-xs text-muted-foreground">
|
<span className="shrink-0 text-xs text-muted-foreground">
|
||||||
{entry.direction === 'output'
|
{entry.direction === 'output'
|
||||||
? t('agents.debugAgentOutput')
|
? t(
|
||||||
|
processor
|
||||||
|
? 'agents.eventProcessor.debugOutput'
|
||||||
|
: 'agents.debugAgentOutput',
|
||||||
|
)
|
||||||
: entry.direction === 'error'
|
: entry.direction === 'error'
|
||||||
? t('common.error')
|
? t('common.error')
|
||||||
: t('agents.debugTestInput')}
|
: t('agents.debugTestInput')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{entry.events && (
|
{entry.events &&
|
||||||
<AgentExecutionTrace
|
(processor ? (
|
||||||
events={entry.events}
|
<EventProcessorTrace
|
||||||
finished={entry.finished}
|
events={entry.events}
|
||||||
toolLabels={toolLabels}
|
toolLabels={toolLabels}
|
||||||
/>
|
/>
|
||||||
)}
|
) : (
|
||||||
|
<AgentExecutionTrace
|
||||||
|
events={entry.events}
|
||||||
|
finished={entry.finished}
|
||||||
|
toolLabels={toolLabels}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
{entry.text && (
|
{entry.text && (
|
||||||
<pre className="min-w-0 whitespace-pre-wrap break-words [overflow-wrap:anywhere] font-sans text-sm leading-relaxed">
|
<pre className="min-w-0 whitespace-pre-wrap break-words [overflow-wrap:anywhere] font-sans text-sm leading-relaxed">
|
||||||
{entry.text}
|
{entry.text}
|
||||||
@@ -546,26 +591,37 @@ export default function AgentDebugPanel({
|
|||||||
<AgentEventDataEditor
|
<AgentEventDataEditor
|
||||||
key={eventType}
|
key={eventType}
|
||||||
eventType={eventType}
|
eventType={eventType}
|
||||||
|
processor={processor}
|
||||||
custom={preset === 'custom'}
|
custom={preset === 'custom'}
|
||||||
value={eventDataText}
|
value={eventDataText}
|
||||||
onChange={setEventDataText}
|
onChange={setEventDataText}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<details className="text-muted-foreground">
|
<Collapsible>
|
||||||
<summary className="cursor-pointer text-xs font-medium">
|
<CollapsibleTrigger asChild>
|
||||||
{t('agents.debugMockOptions')}
|
<Button
|
||||||
</summary>
|
type="button"
|
||||||
<p className="my-2 text-xs text-muted-foreground">
|
variant="ghost"
|
||||||
{t('agents.debugMockOptionsHelp')}
|
size="sm"
|
||||||
</p>
|
className="group"
|
||||||
<Textarea
|
>
|
||||||
aria-label={t('agents.debugMockOptions')}
|
<ChevronDown className="size-3.5 transition-transform group-data-[state=open]:rotate-180" />
|
||||||
value={mockOptionsText}
|
{t('agents.debugMockOptions')}
|
||||||
onChange={(event) => setMockOptionsText(event.target.value)}
|
</Button>
|
||||||
className="min-h-24 font-mono text-xs"
|
</CollapsibleTrigger>
|
||||||
spellCheck={false}
|
<CollapsibleContent>
|
||||||
/>
|
<p className="my-2 text-xs text-muted-foreground">
|
||||||
</details>
|
{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>
|
</div>
|
||||||
|
|||||||
@@ -5,27 +5,48 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
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({
|
export default function AgentEventDataEditor({
|
||||||
eventType,
|
eventType,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
custom = false,
|
custom = false,
|
||||||
|
processor = false,
|
||||||
}: {
|
}: {
|
||||||
eventType: string;
|
eventType: string;
|
||||||
custom?: boolean;
|
custom?: boolean;
|
||||||
|
processor?: boolean;
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [showJson, setShowJson] = useState(false);
|
const [showJson, setShowJson] = useState(false);
|
||||||
const fields = custom ? [] : (debugEventDefinition(eventType)?.fields ?? []);
|
const fields = custom
|
||||||
|
? []
|
||||||
|
: (debugEventDefinition(eventType, processor)?.fields ?? []);
|
||||||
const data = parseDebugEventData(value);
|
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) {
|
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 (
|
return (
|
||||||
@@ -34,7 +55,7 @@ export default function AgentEventDataEditor({
|
|||||||
<span className="text-xs font-medium">
|
<span className="text-xs font-medium">
|
||||||
{t('agents.debugData.title')}
|
{t('agents.debugData.title')}
|
||||||
</span>
|
</span>
|
||||||
{fields.length > 0 && (
|
{fields.length > 0 && !richMessage && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -69,7 +90,7 @@ export default function AgentEventDataEditor({
|
|||||||
<div className="grid grid-cols-2 gap-x-3 gap-y-2">
|
<div className="grid grid-cols-2 gap-x-3 gap-y-2">
|
||||||
{fields.map((field) => {
|
{fields.map((field) => {
|
||||||
const id = `agent-debug-data-${field.key}`;
|
const id = `agent-debug-data-${field.key}`;
|
||||||
const current = data?.[field.key];
|
const current = getDebugEventField(data, field.key);
|
||||||
const text =
|
const text =
|
||||||
typeof current === 'string' || typeof current === 'number'
|
typeof current === 'string' || typeof current === 'number'
|
||||||
? current
|
? 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 { useTranslation } from 'react-i18next';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import type { EventProcessorDescriptor } from '@/app/infra/entities/api';
|
import type { EventProcessorDescriptor } from '@/app/infra/entities/api';
|
||||||
|
import { httpClient } from '@/app/infra/http';
|
||||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -9,8 +17,58 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} 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';
|
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({
|
export default function EventProcessorSettings({
|
||||||
components,
|
components,
|
||||||
value,
|
value,
|
||||||
@@ -18,6 +76,7 @@ export default function EventProcessorSettings({
|
|||||||
onChange,
|
onChange,
|
||||||
onParametersChange,
|
onParametersChange,
|
||||||
onValidate,
|
onValidate,
|
||||||
|
disabled = false,
|
||||||
}: {
|
}: {
|
||||||
components: EventProcessorDescriptor[];
|
components: EventProcessorDescriptor[];
|
||||||
value: string;
|
value: string;
|
||||||
@@ -25,66 +84,112 @@ export default function EventProcessorSettings({
|
|||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
onParametersChange: (value: Record<string, unknown>) => void;
|
onParametersChange: (value: Record<string, unknown>) => void;
|
||||||
onValidate?: (validate: () => Promise<boolean>) => void;
|
onValidate?: (validate: () => Promise<boolean>) => void;
|
||||||
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const selected = components.find((item) => item.id === value);
|
const selected = components.find((item) => item.id === value);
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<div className="space-y-2">
|
<Label className="sr-only" htmlFor="event-processor-component">
|
||||||
<label
|
{t('agents.eventProcessor.component')}
|
||||||
className="text-sm font-medium"
|
</Label>
|
||||||
htmlFor="event-processor-component"
|
<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')}
|
{selected ? (
|
||||||
</label>
|
<ProcessorComponentContent component={selected} />
|
||||||
<Select value={value} onValueChange={onChange}>
|
) : (
|
||||||
<SelectTrigger id="event-processor-component">
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
<SelectValue
|
<Puzzle className="size-4 shrink-0 text-muted-foreground" />
|
||||||
placeholder={t('agents.eventProcessor.selectComponent')}
|
<SelectValue
|
||||||
/>
|
placeholder={t('agents.eventProcessor.selectComponent')}
|
||||||
</SelectTrigger>
|
/>
|
||||||
<SelectContent>
|
</span>
|
||||||
{value && !selected && (
|
)}
|
||||||
<SelectItem value={value}>
|
</SelectTrigger>
|
||||||
{t('agents.eventProcessor.unavailable')}
|
<SelectContent className="max-h-72 w-[var(--radix-select-trigger-width)] max-w-[calc(100vw-2rem)]">
|
||||||
</SelectItem>
|
{value && !selected && (
|
||||||
)}
|
<SelectItem value={value}>
|
||||||
{components.map((component) => (
|
{t('agents.eventProcessor.unavailable')}
|
||||||
<SelectItem key={component.id} value={component.id}>
|
</SelectItem>
|
||||||
{extractI18nObject({
|
)}
|
||||||
en_US: component.id,
|
{components.map((component) => (
|
||||||
zh_Hans: component.id,
|
<SelectItem
|
||||||
...component.label,
|
key={component.id}
|
||||||
})}{' '}
|
value={component.id}
|
||||||
· {component.plugin_author}/{component.plugin_name}
|
className="py-1.5 [&>span:last-child]:min-w-0 [&>span:last-child]:flex-1"
|
||||||
</SelectItem>
|
>
|
||||||
))}
|
<ProcessorComponentContent component={component} option />
|
||||||
</SelectContent>
|
</SelectItem>
|
||||||
</Select>
|
))}
|
||||||
{components.length === 0 && (
|
{components.length === 0 && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<div className="p-2 text-sm text-muted-foreground">
|
||||||
{t('agents.eventProcessor.noComponents')}{' '}
|
{t('agents.eventProcessor.noComponents')}
|
||||||
<Link className="text-primary underline" to="/home/plugins">
|
<Button asChild variant="link" className="h-auto px-0">
|
||||||
{t('agents.eventProcessor.installPlugin')}
|
<Link to="/home/plugins">
|
||||||
</Link>
|
{t('agents.eventProcessor.installPlugin')}
|
||||||
</p>
|
</Link>
|
||||||
)}
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{selected && (
|
)}
|
||||||
<p className="break-words text-xs text-muted-foreground">
|
</SelectContent>
|
||||||
{selected.supported_event_patterns.join(' · ')}
|
</Select>
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{selected && selected.config_schema.length > 0 && (
|
{selected && selected.config_schema.length > 0 && (
|
||||||
<DynamicFormComponent
|
<Popover open={settingsOpen} onOpenChange={setSettingsOpen}>
|
||||||
key={value}
|
<PopoverTrigger asChild>
|
||||||
itemConfigList={selected.config_schema}
|
<Button
|
||||||
initialValues={parameters}
|
type="button"
|
||||||
onSubmit={(values) =>
|
variant="ghost"
|
||||||
onParametersChange(values as Record<string, unknown>)
|
size="icon"
|
||||||
}
|
disabled={disabled}
|
||||||
onValidate={onValidate}
|
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>
|
</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(
|
export function debugEventDefinition(
|
||||||
eventType: string,
|
eventType: string,
|
||||||
|
processor = false,
|
||||||
): DebugEventDefinition | undefined {
|
): DebugEventDefinition | undefined {
|
||||||
return Object.hasOwn(DEBUG_EVENT_DEFINITIONS, eventType)
|
const definitions = processor
|
||||||
? DEBUG_EVENT_DEFINITIONS[eventType]
|
? PROCESSOR_EVENT_DEFINITIONS
|
||||||
|
: DEBUG_EVENT_DEFINITIONS;
|
||||||
|
return Object.hasOwn(definitions, eventType)
|
||||||
|
? definitions[eventType]
|
||||||
: undefined;
|
: undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createDebugEventData(
|
export function createDebugEventData(
|
||||||
eventType: string,
|
eventType: string,
|
||||||
samples: Record<'user' | 'message' | 'feedback', string>,
|
samples: Record<'user' | 'message' | 'feedback', string>,
|
||||||
|
processor = false,
|
||||||
) {
|
) {
|
||||||
const definition = debugEventDefinition(eventType);
|
const definition = debugEventDefinition(eventType, processor);
|
||||||
return {
|
return (definition?.fields ?? []).reduce<Record<string, unknown>>(
|
||||||
...definition?.defaults,
|
(data, field) =>
|
||||||
...Object.fromEntries(
|
setDebugEventField(
|
||||||
(definition?.fields ?? []).map((field) => [
|
data,
|
||||||
field.key,
|
field.key,
|
||||||
field.sample ? samples[field.sample] : field.value,
|
field.sample ? samples[field.sample] : field.value,
|
||||||
]),
|
),
|
||||||
),
|
{ ...definition?.defaults },
|
||||||
};
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseDebugEventData(
|
export function parseDebugEventData(
|
||||||
@@ -195,9 +322,20 @@ export function parseDebugEventData(
|
|||||||
export function invalidDebugEventField(
|
export function invalidDebugEventField(
|
||||||
eventType: string,
|
eventType: string,
|
||||||
data: Record<string, unknown>,
|
data: Record<string, unknown>,
|
||||||
|
processor = false,
|
||||||
) {
|
) {
|
||||||
return debugEventDefinition(eventType)?.fields.find((field) => {
|
return debugEventDefinition(eventType, processor)?.fields.find((field) => {
|
||||||
const value = data[field.key];
|
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 (
|
if (
|
||||||
value === undefined ||
|
value === undefined ||
|
||||||
value === null ||
|
value === null ||
|
||||||
@@ -215,7 +353,10 @@ export function invalidDebugEventField(
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
typeof value !== 'string' &&
|
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(
|
export function debugEventInputText(
|
||||||
eventType: string,
|
eventType: string,
|
||||||
data: Record<string, unknown>,
|
data: Record<string, unknown>,
|
||||||
|
processor = false,
|
||||||
) {
|
) {
|
||||||
const key = debugEventDefinition(eventType)?.messageField;
|
const key = debugEventDefinition(eventType, processor)?.messageField;
|
||||||
return key && typeof data[key] === 'string' ? data[key].trim() : '';
|
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' ? (
|
{item.kind === 'event_processor' ? (
|
||||||
<span className="text-xs">⚡</span>
|
<Puzzle className="size-3.5" />
|
||||||
) : item.kind === 'pipeline' ? (
|
) : item.kind === 'pipeline' ? (
|
||||||
<Workflow className="size-3.5" />
|
<Workflow className="size-3.5" />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@/components/ui/tooltip';
|
} from '@/components/ui/tooltip';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { Card, CardHeader, CardContent } from '@/components/ui/card';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface ProcessorMonitoringView {
|
interface ProcessorMonitoringView {
|
||||||
@@ -26,6 +27,7 @@ interface ProcessorDetailWorkbenchProps {
|
|||||||
title: string;
|
title: string;
|
||||||
titleBadge?: ReactNode;
|
titleBadge?: ReactNode;
|
||||||
titleAction?: ReactNode;
|
titleAction?: ReactNode;
|
||||||
|
titleControls?: ReactNode;
|
||||||
headerActions?: ReactNode;
|
headerActions?: ReactNode;
|
||||||
status?: ProcessorDetailStatus | null;
|
status?: ProcessorDetailStatus | null;
|
||||||
saveLabel: string;
|
saveLabel: string;
|
||||||
@@ -34,6 +36,7 @@ interface ProcessorDetailWorkbenchProps {
|
|||||||
isDirty: boolean;
|
isDirty: boolean;
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
configTitle: string;
|
configTitle: string;
|
||||||
|
configIcon?: ReactNode;
|
||||||
configContent: ReactNode;
|
configContent: ReactNode;
|
||||||
debugTitle?: string;
|
debugTitle?: string;
|
||||||
debugDescription?: string;
|
debugDescription?: string;
|
||||||
@@ -49,6 +52,7 @@ export default function ProcessorDetailWorkbench({
|
|||||||
title,
|
title,
|
||||||
titleBadge,
|
titleBadge,
|
||||||
titleAction,
|
titleAction,
|
||||||
|
titleControls,
|
||||||
headerActions,
|
headerActions,
|
||||||
status,
|
status,
|
||||||
saveLabel,
|
saveLabel,
|
||||||
@@ -57,6 +61,7 @@ export default function ProcessorDetailWorkbench({
|
|||||||
isDirty,
|
isDirty,
|
||||||
isSaving,
|
isSaving,
|
||||||
configTitle,
|
configTitle,
|
||||||
|
configIcon,
|
||||||
configContent,
|
configContent,
|
||||||
debugTitle,
|
debugTitle,
|
||||||
debugDescription,
|
debugDescription,
|
||||||
@@ -81,10 +86,11 @@ export default function ProcessorDetailWorkbench({
|
|||||||
className="flex h-full min-h-0 min-w-0 flex-col gap-0"
|
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 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>
|
<h1 className="truncate text-xl font-semibold">{title}</h1>
|
||||||
{titleBadge}
|
{titleBadge}
|
||||||
{titleAction}
|
{titleAction}
|
||||||
|
{titleControls}
|
||||||
{monitoring && (
|
{monitoring && (
|
||||||
<TabsList
|
<TabsList
|
||||||
aria-label={`${monitoring.workbenchLabel} / ${monitoring.label}`}
|
aria-label={`${monitoring.workbenchLabel} / ${monitoring.label}`}
|
||||||
@@ -168,18 +174,20 @@ export default function ProcessorDetailWorkbench({
|
|||||||
value="monitoring"
|
value="monitoring"
|
||||||
className="mt-0 min-h-0 flex-1 overflow-hidden"
|
className="mt-0 min-h-0 flex-1 overflow-hidden"
|
||||||
>
|
>
|
||||||
<section
|
<Card
|
||||||
|
role="region"
|
||||||
aria-label={monitoring.label}
|
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}
|
{monitoring.content}
|
||||||
</section>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<TabsContent
|
<TabsContent
|
||||||
value="workbench"
|
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
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -190,24 +198,27 @@ export default function ProcessorDetailWorkbench({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{hasDebug && (
|
{hasDebug && (
|
||||||
<section
|
<Card
|
||||||
|
role="region"
|
||||||
aria-label={debugTitle}
|
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">
|
<div className="flex min-w-0 items-center gap-2 font-medium">
|
||||||
<Bug className="size-4 shrink-0" />
|
<Bug className="size-4 shrink-0" />
|
||||||
<span className="truncate">{debugTitle}</span>
|
<span className="truncate">{debugTitle}</span>
|
||||||
{debugDescription && (
|
{debugDescription && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
aria-label={debugDescription}
|
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" />
|
<Info className="size-4" />
|
||||||
</button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent className="max-w-xs whitespace-normal leading-relaxed">
|
<TooltipContent className="max-w-xs whitespace-normal leading-relaxed">
|
||||||
{debugDescription}
|
{debugDescription}
|
||||||
@@ -228,19 +239,20 @@ export default function ProcessorDetailWorkbench({
|
|||||||
: debugDisconnectedLabel}
|
: debugDisconnectedLabel}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</CardHeader>
|
||||||
<div className="min-h-0 min-w-0 flex-1 overflow-hidden">
|
<CardContent className="min-h-0 min-w-0 flex-1 overflow-hidden px-0">
|
||||||
{debugContent}
|
{debugContent}
|
||||||
</div>
|
</CardContent>
|
||||||
</section>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<section
|
<Card
|
||||||
|
role="region"
|
||||||
aria-label={configTitle}
|
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">
|
<CardHeader className="flex h-12 shrink-0 flex-row items-center gap-2 border-b px-4 font-medium [.border-b]:pb-0">
|
||||||
<Settings className="size-4" />
|
{configIcon ?? <Settings className="size-4" />}
|
||||||
<span className="truncate">{configTitle}</span>
|
<span className="truncate">{configTitle}</span>
|
||||||
{isDirty && (
|
{isDirty && (
|
||||||
<span className="ml-auto flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-400">
|
<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}
|
{unsavedLabel}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</CardHeader>
|
||||||
<div className="min-h-0 min-w-0 flex-1 overflow-hidden p-4">
|
<CardContent className="min-h-0 min-w-0 flex-1 overflow-hidden p-4">
|
||||||
{configContent}
|
{configContent}
|
||||||
</div>
|
</CardContent>
|
||||||
</section>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
@@ -178,6 +178,11 @@ export interface ProcessorRun {
|
|||||||
status: string;
|
status: string;
|
||||||
status_reason?: string;
|
status_reason?: string;
|
||||||
created_at: number;
|
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 };
|
metadata: { event_type?: string; input_event?: unknown; delivery?: unknown };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -704,6 +704,16 @@ const enUS = {
|
|||||||
},
|
},
|
||||||
agents: {
|
agents: {
|
||||||
eventProcessor: {
|
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',
|
type: 'Event processor',
|
||||||
description: 'Process platform events using plugin code.',
|
description: 'Process platform events using plugin code.',
|
||||||
component: 'Plugin component',
|
component: 'Plugin component',
|
||||||
@@ -734,6 +744,9 @@ const enUS = {
|
|||||||
trace_tool_call_completed: 'Action result',
|
trace_tool_call_completed: 'Action result',
|
||||||
},
|
},
|
||||||
debugData: {
|
debugData: {
|
||||||
|
chatId: 'Chat ID',
|
||||||
|
feedbackType: 'Feedback type (1: like, 2: dislike, 3: cancel)',
|
||||||
|
|
||||||
title: 'Event data',
|
title: 'Event data',
|
||||||
form: 'Common fields',
|
form: 'Common fields',
|
||||||
json: 'Full JSON',
|
json: 'Full JSON',
|
||||||
|
|||||||
@@ -507,6 +507,16 @@ const esES = {
|
|||||||
},
|
},
|
||||||
agents: {
|
agents: {
|
||||||
eventProcessor: {
|
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',
|
type: 'Procesador de eventos',
|
||||||
description: 'Procesa eventos con código del plugin.',
|
description: 'Procesa eventos con código del plugin.',
|
||||||
component: 'Componente del plugin',
|
component: 'Componente del plugin',
|
||||||
@@ -538,6 +548,10 @@ const esES = {
|
|||||||
trace_tool_call_completed: 'Resultado de la acción',
|
trace_tool_call_completed: 'Resultado de la acción',
|
||||||
},
|
},
|
||||||
debugData: {
|
debugData: {
|
||||||
|
chatId: 'ID del chat',
|
||||||
|
feedbackType:
|
||||||
|
'Tipo de valoración (1: positiva, 2: negativa, 3: cancelar)',
|
||||||
|
|
||||||
title: 'Datos del evento',
|
title: 'Datos del evento',
|
||||||
form: 'Campos comunes',
|
form: 'Campos comunes',
|
||||||
json: 'JSON completo',
|
json: 'JSON completo',
|
||||||
|
|||||||
@@ -717,6 +717,16 @@ const jaJP = {
|
|||||||
},
|
},
|
||||||
agents: {
|
agents: {
|
||||||
eventProcessor: {
|
eventProcessor: {
|
||||||
|
pluginSettings: 'プラグイン設定',
|
||||||
|
pluginSettingsDescription: 'このプラグインが定義するパラメーターです。',
|
||||||
|
selectToDebug: '上でプラグインを選択してデバッグを開始してください。',
|
||||||
|
|
||||||
|
debugOutput: 'プロセッサー出力',
|
||||||
|
debugDescription:
|
||||||
|
'このテストの入力イベント、プラグインログ、アクション結果。',
|
||||||
|
debugNotice:
|
||||||
|
'プラグインはテストイベントを実際に処理します。返信や送信などは Mock を使用し、実際のメッセージは送信しません。他のツールは設定どおりに実行されます。',
|
||||||
|
|
||||||
type: 'イベントプロセッサー',
|
type: 'イベントプロセッサー',
|
||||||
description: 'プラグインのコードでイベントを処理します。',
|
description: 'プラグインのコードでイベントを処理します。',
|
||||||
component: 'プラグインコンポーネント',
|
component: 'プラグインコンポーネント',
|
||||||
@@ -748,6 +758,9 @@ const jaJP = {
|
|||||||
trace_tool_call_completed: 'アクション結果',
|
trace_tool_call_completed: 'アクション結果',
|
||||||
},
|
},
|
||||||
debugData: {
|
debugData: {
|
||||||
|
chatId: 'チャット ID',
|
||||||
|
feedbackType: 'フィードバック種別(1: 高評価、2: 低評価、3: 取消)',
|
||||||
|
|
||||||
title: 'イベントデータ',
|
title: 'イベントデータ',
|
||||||
form: '基本項目',
|
form: '基本項目',
|
||||||
json: '完全な JSON',
|
json: '完全な JSON',
|
||||||
|
|||||||
@@ -504,6 +504,16 @@ const ruRU = {
|
|||||||
},
|
},
|
||||||
agents: {
|
agents: {
|
||||||
eventProcessor: {
|
eventProcessor: {
|
||||||
|
pluginSettings: 'Настройки плагина',
|
||||||
|
pluginSettingsDescription: 'Параметры, объявленные этим плагином.',
|
||||||
|
selectToDebug: 'Выберите плагин выше, чтобы начать отладку.',
|
||||||
|
|
||||||
|
debugOutput: 'Вывод обработчика',
|
||||||
|
debugDescription:
|
||||||
|
'Входные события, журналы плагина и результаты действий этой проверки.',
|
||||||
|
debugNotice:
|
||||||
|
'Плагин обрабатывает тестовое событие. Действия платформы используют Mock и не отправляют реальные сообщения; остальные инструменты работают согласно настройкам.',
|
||||||
|
|
||||||
type: 'Обработчик событий',
|
type: 'Обработчик событий',
|
||||||
description: 'Обрабатывает события кодом плагина.',
|
description: 'Обрабатывает события кодом плагина.',
|
||||||
component: 'Компонент плагина',
|
component: 'Компонент плагина',
|
||||||
@@ -535,6 +545,9 @@ const ruRU = {
|
|||||||
trace_tool_call_completed: 'Результат действия',
|
trace_tool_call_completed: 'Результат действия',
|
||||||
},
|
},
|
||||||
debugData: {
|
debugData: {
|
||||||
|
chatId: 'ID чата',
|
||||||
|
feedbackType: 'Тип отзыва (1: нравится, 2: не нравится, 3: отмена)',
|
||||||
|
|
||||||
title: 'Данные события',
|
title: 'Данные события',
|
||||||
form: 'Основные поля',
|
form: 'Основные поля',
|
||||||
json: 'Полный JSON',
|
json: 'Полный JSON',
|
||||||
|
|||||||
@@ -491,6 +491,16 @@ const thTH = {
|
|||||||
},
|
},
|
||||||
agents: {
|
agents: {
|
||||||
eventProcessor: {
|
eventProcessor: {
|
||||||
|
pluginSettings: 'การตั้งค่าปลั๊กอิน',
|
||||||
|
pluginSettingsDescription: 'พารามิเตอร์ที่ประกาศโดยปลั๊กอินนี้',
|
||||||
|
selectToDebug: 'เลือกปลั๊กอินด้านบนเพื่อเริ่มแก้จุดบกพร่อง',
|
||||||
|
|
||||||
|
debugOutput: 'ผลลัพธ์ตัวประมวลผล',
|
||||||
|
debugDescription:
|
||||||
|
'เหตุการณ์ขาเข้า บันทึกปลั๊กอิน และผลการดำเนินการของการทดสอบนี้',
|
||||||
|
debugNotice:
|
||||||
|
'ปลั๊กอินประมวลผลเหตุการณ์ทดสอบจริง การตอบกลับและส่งข้อความใช้ Mock โดยไม่ส่งข้อความจริง เครื่องมืออื่นทำงานตามการตั้งค่า',
|
||||||
|
|
||||||
type: 'ตัวประมวลผลเหตุการณ์',
|
type: 'ตัวประมวลผลเหตุการณ์',
|
||||||
description: 'ประมวลผลเหตุการณ์ด้วยโค้ดปลั๊กอิน',
|
description: 'ประมวลผลเหตุการณ์ด้วยโค้ดปลั๊กอิน',
|
||||||
component: 'ส่วนประกอบปลั๊กอิน',
|
component: 'ส่วนประกอบปลั๊กอิน',
|
||||||
@@ -521,6 +531,9 @@ const thTH = {
|
|||||||
trace_tool_call_completed: 'ผลการดำเนินการ',
|
trace_tool_call_completed: 'ผลการดำเนินการ',
|
||||||
},
|
},
|
||||||
debugData: {
|
debugData: {
|
||||||
|
chatId: 'ID แชท',
|
||||||
|
feedbackType: 'ประเภทข้อเสนอแนะ (1: ชอบ, 2: ไม่ชอบ, 3: ยกเลิก)',
|
||||||
|
|
||||||
title: 'ข้อมูลเหตุการณ์',
|
title: 'ข้อมูลเหตุการณ์',
|
||||||
form: 'ฟิลด์ทั่วไป',
|
form: 'ฟิลด์ทั่วไป',
|
||||||
json: 'JSON ทั้งหมด',
|
json: 'JSON ทั้งหมด',
|
||||||
|
|||||||
@@ -500,6 +500,16 @@ const viVN = {
|
|||||||
},
|
},
|
||||||
agents: {
|
agents: {
|
||||||
eventProcessor: {
|
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',
|
type: 'Bộ xử lý sự kiện',
|
||||||
description: 'Xử lý sự kiện bằng mã plugin.',
|
description: 'Xử lý sự kiện bằng mã plugin.',
|
||||||
component: 'Thành phần plugin',
|
component: 'Thành phần plugin',
|
||||||
@@ -530,6 +540,9 @@ const viVN = {
|
|||||||
trace_tool_call_completed: 'Kết quả hành động',
|
trace_tool_call_completed: 'Kết quả hành động',
|
||||||
},
|
},
|
||||||
debugData: {
|
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',
|
title: 'Dữ liệu sự kiện',
|
||||||
form: 'Trường thường dùng',
|
form: 'Trường thường dùng',
|
||||||
json: 'JSON đầy đủ',
|
json: 'JSON đầy đủ',
|
||||||
|
|||||||
@@ -669,6 +669,15 @@ const zhHans = {
|
|||||||
},
|
},
|
||||||
agents: {
|
agents: {
|
||||||
eventProcessor: {
|
eventProcessor: {
|
||||||
|
pluginSettings: '插件设置',
|
||||||
|
pluginSettingsDescription: '由当前插件声明的参数。',
|
||||||
|
selectToDebug: '请先在上方选择插件,再开始调试。',
|
||||||
|
|
||||||
|
debugOutput: '处理器输出',
|
||||||
|
debugDescription: '当前测试的输入事件、插件日志和动作结果。',
|
||||||
|
debugNotice:
|
||||||
|
'插件真实处理测试事件;回复、发送等平台动作使用 Mock,不发送真实消息。其他工具仍按实际配置执行。',
|
||||||
|
|
||||||
type: '事件处理器',
|
type: '事件处理器',
|
||||||
description: '通过插件代码处理平台事件。',
|
description: '通过插件代码处理平台事件。',
|
||||||
component: '插件组件',
|
component: '插件组件',
|
||||||
@@ -699,6 +708,9 @@ const zhHans = {
|
|||||||
trace_tool_call_completed: '动作结果',
|
trace_tool_call_completed: '动作结果',
|
||||||
},
|
},
|
||||||
debugData: {
|
debugData: {
|
||||||
|
chatId: '会话 ID',
|
||||||
|
feedbackType: '反馈类型(1:赞,2:踩,3:取消)',
|
||||||
|
|
||||||
title: '事件数据',
|
title: '事件数据',
|
||||||
form: '常用字段',
|
form: '常用字段',
|
||||||
json: '完整 JSON',
|
json: '完整 JSON',
|
||||||
|
|||||||
@@ -475,6 +475,15 @@ const zhHant = {
|
|||||||
},
|
},
|
||||||
agents: {
|
agents: {
|
||||||
eventProcessor: {
|
eventProcessor: {
|
||||||
|
pluginSettings: '外掛設定',
|
||||||
|
pluginSettingsDescription: '由目前外掛宣告的參數。',
|
||||||
|
selectToDebug: '請先在上方選擇外掛,再開始除錯。',
|
||||||
|
|
||||||
|
debugOutput: '處理器輸出',
|
||||||
|
debugDescription: '目前測試的輸入事件、外掛日誌和動作結果。',
|
||||||
|
debugNotice:
|
||||||
|
'外掛實際處理測試事件;回覆、傳送等平台動作使用 Mock,不傳送真實訊息。其他工具仍依實際設定執行。',
|
||||||
|
|
||||||
type: '事件處理器',
|
type: '事件處理器',
|
||||||
description: '透過外掛程式碼處理平台事件。',
|
description: '透過外掛程式碼處理平台事件。',
|
||||||
component: '外掛元件',
|
component: '外掛元件',
|
||||||
@@ -505,6 +514,9 @@ const zhHant = {
|
|||||||
trace_tool_call_completed: '動作結果',
|
trace_tool_call_completed: '動作結果',
|
||||||
},
|
},
|
||||||
debugData: {
|
debugData: {
|
||||||
|
chatId: '會話 ID',
|
||||||
|
feedbackType: '回饋類型(1:讚,2:踩,3:取消)',
|
||||||
|
|
||||||
title: '事件資料',
|
title: '事件資料',
|
||||||
form: '常用欄位',
|
form: '常用欄位',
|
||||||
json: '完整 JSON',
|
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 expect(panel.getByText('Done', { exact: true })).toBeVisible();
|
||||||
await page.setViewportSize({ width: 1280, height: 650 });
|
await page.setViewportSize({ width: 1280, height: 650 });
|
||||||
await panel.getByRole('button', { name: 'Full JSON' }).click();
|
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(
|
await expect(
|
||||||
panel.getByRole('button', { name: 'Run test' }),
|
panel.getByRole('button', { name: 'Run test' }),
|
||||||
).toBeInViewport();
|
).toBeInViewport();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
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,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await installLangBotApiMocks(page, { authenticated: true });
|
await installLangBotApiMocks(page, { authenticated: true });
|
||||||
@@ -12,13 +12,18 @@ test('event processor shows isolated logs, paginates and scrolls expanded payloa
|
|||||||
name: 'Welcome processor',
|
name: 'Welcome processor',
|
||||||
component_ref: ref,
|
component_ref: ref,
|
||||||
supported_event_patterns: ['group.member_joined'],
|
supported_event_patterns: ['group.member_joined'],
|
||||||
config: { runner: { id: ref }, runner_config: { [ref]: {} } },
|
config: {
|
||||||
|
runner: { id: ref },
|
||||||
|
runner_config: { [ref]: { greeting: 'Hello' } },
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const run = {
|
const run = {
|
||||||
run_id: 'run-one',
|
run_id: 'run-one',
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
status_reason: 'stop',
|
status_reason: 'stop',
|
||||||
created_at: 1788000000,
|
created_at: 1788000000,
|
||||||
|
started_at_ms: 1788000000000,
|
||||||
|
finished_at_ms: 1788000000500,
|
||||||
metadata: {
|
metadata: {
|
||||||
event_type: 'group.member_joined',
|
event_type: 'group.member_joined',
|
||||||
input_event: { member: { id: 'one' } },
|
input_event: { member: { id: 'one' } },
|
||||||
@@ -28,10 +33,63 @@ test('event processor shows isolated logs, paginates and scrolls expanded payloa
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
const cursors: string[] = [];
|
const cursors: string[] = [];
|
||||||
|
const debugRequests: unknown[] = [];
|
||||||
|
const operations: string[] = [];
|
||||||
|
const creations: unknown[] = [];
|
||||||
await page.route('**/api/v1/agents**', async (route) => {
|
await page.route('**/api/v1/agents**', async (route) => {
|
||||||
const url = new URL(route.request().url());
|
const url = new URL(route.request().url());
|
||||||
let data: unknown;
|
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 = {
|
data = {
|
||||||
kinds: [],
|
kinds: [],
|
||||||
event_processors: [
|
event_processors: [
|
||||||
@@ -39,12 +97,38 @@ test('event processor shows isolated logs, paginates and scrolls expanded payloa
|
|||||||
id: ref,
|
id: ref,
|
||||||
label: { en_US: 'Welcome' },
|
label: { en_US: 'Welcome' },
|
||||||
supported_event_patterns: ['group.member_joined'],
|
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_author: 'qa',
|
||||||
plugin_name: 'welcome',
|
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')) {
|
} else if (url.pathname.endsWith('/runs/run-one/events')) {
|
||||||
cursors.push(url.searchParams.get('after_sequence') ?? '');
|
cursors.push(url.searchParams.get('after_sequence') ?? '');
|
||||||
data = {
|
data = {
|
||||||
@@ -62,6 +146,27 @@ test('event processor shows isolated logs, paginates and scrolls expanded payloa
|
|||||||
sequence: 1,
|
sequence: 1,
|
||||||
type: 'processor.log',
|
type: 'processor.log',
|
||||||
data: { level: 'info', text: 'Member received' },
|
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,
|
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,
|
next_cursor: url.searchParams.has('after_sequence') ? null : 100,
|
||||||
};
|
};
|
||||||
} else if (url.pathname.endsWith('/runs')) {
|
} 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')) {
|
} 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 };
|
data = { agent: processor };
|
||||||
} else {
|
} else {
|
||||||
data = { agents: [processor] };
|
data = { agents: [processor] };
|
||||||
}
|
}
|
||||||
await route.fulfill({ json: { code: 0, data } });
|
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(
|
await expect(
|
||||||
page.getByRole('heading', { name: 'Welcome processor' }),
|
page.getByRole('combobox', { name: 'Plugin component' }),
|
||||||
).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 }),
|
|
||||||
).toHaveCount(0);
|
).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',
|
'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