mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-25 03:27:15 +00:00
feat(agent): add event debugging and streamline configuration
This commit is contained in:
@@ -47,6 +47,24 @@ class AgentsRouterGroup(group.RouterGroup):
|
|||||||
async def _(request_context: RequestContext) -> str:
|
async def _(request_context: RequestContext) -> str:
|
||||||
return self.success(data=await self.ap.agent_service.get_agent_metadata(request_context))
|
return self.success(data=await self.ap.agent_service.get_agent_metadata(request_context))
|
||||||
|
|
||||||
|
@self.route(
|
||||||
|
'/<agent_uuid>/debug',
|
||||||
|
methods=['POST'],
|
||||||
|
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||||
|
permission=Permission.RUNTIME_OPERATE,
|
||||||
|
)
|
||||||
|
async def _(agent_uuid: str, request_context: RequestContext) -> str:
|
||||||
|
json_data = await quart.request.json
|
||||||
|
try:
|
||||||
|
result = await self.ap.agent_service.debug_agent(
|
||||||
|
request_context,
|
||||||
|
agent_uuid,
|
||||||
|
json_data or {},
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
return self.http_status(400, -1, str(exc))
|
||||||
|
return self.success(data=result)
|
||||||
|
|
||||||
@self.route(
|
@self.route(
|
||||||
'/<agent_uuid>',
|
'/<agent_uuid>',
|
||||||
methods=['GET', 'PUT', 'DELETE'],
|
methods=['GET', 'PUT', 'DELETE'],
|
||||||
|
|||||||
@@ -1,15 +1,33 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
|
import fnmatch
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
|
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
|
||||||
|
from langbot_plugin.api.entities.builtin.agent_runner.event import (
|
||||||
|
ActorContext,
|
||||||
|
RawEventRef,
|
||||||
|
SubjectContext,
|
||||||
|
)
|
||||||
|
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
|
||||||
|
|
||||||
from ....core import app
|
from ....core import app
|
||||||
from ....agent.runner.config_resolver import RunnerConfigResolver
|
from ....agent.runner.config_resolver import RunnerConfigResolver
|
||||||
|
from ....agent.runner.host_models import (
|
||||||
|
AgentBinding,
|
||||||
|
AgentEventEnvelope,
|
||||||
|
BindingScope,
|
||||||
|
DeliveryPolicy,
|
||||||
|
StatePolicy,
|
||||||
|
)
|
||||||
|
from ....agent.runner.resource_policy import ResourcePolicyProjector
|
||||||
from ....entity.persistence import agent as persistence_agent
|
from ....entity.persistence import agent as persistence_agent
|
||||||
from ....workspace.errors import WorkspaceNotFoundError
|
from ....workspace.errors import WorkspaceNotFoundError
|
||||||
|
from ..context import ExecutionContext, RequestContext
|
||||||
from .tenant import TenantContext, require_workspace_uuid, scope_statement
|
from .tenant import TenantContext, require_workspace_uuid, scope_statement
|
||||||
|
|
||||||
|
|
||||||
@@ -78,6 +96,173 @@ class AgentService:
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
async def debug_agent(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
agent_uuid: str,
|
||||||
|
payload: dict[str, typing.Any],
|
||||||
|
) -> dict[str, typing.Any]:
|
||||||
|
"""Execute one synthetic event against a configured Agent.
|
||||||
|
|
||||||
|
The debug surface uses a trusted Workspace execution context, never
|
||||||
|
delivers outputs to a real platform, and supports both message and
|
||||||
|
non-message event envelopes.
|
||||||
|
"""
|
||||||
|
agent = await self.get_agent(context, agent_uuid)
|
||||||
|
if agent is None or agent.get('kind') != AGENT_KIND_AGENT:
|
||||||
|
raise ValueError('Agent not found')
|
||||||
|
|
||||||
|
event_type = str(payload.get('event_type') or 'message.received').strip()
|
||||||
|
if not event_type or len(event_type) > 128:
|
||||||
|
raise ValueError('Invalid event_type')
|
||||||
|
if not self._supports_event_type(
|
||||||
|
agent.get('supported_event_patterns'),
|
||||||
|
event_type,
|
||||||
|
):
|
||||||
|
raise ValueError('Agent does not support this event type')
|
||||||
|
|
||||||
|
text = str(payload.get('text') or '').strip()
|
||||||
|
if len(text) > 20_000:
|
||||||
|
raise ValueError('Debug input is too long')
|
||||||
|
event_data = payload.get('data') or {}
|
||||||
|
if not isinstance(event_data, dict):
|
||||||
|
raise ValueError('Debug event data must be an object')
|
||||||
|
|
||||||
|
config = agent.get('config')
|
||||||
|
if not isinstance(config, dict):
|
||||||
|
raise ValueError('Agent configuration is invalid')
|
||||||
|
_, runner_id, runner_config = RunnerConfigResolver.resolve_agent_runner_config(config)
|
||||||
|
if not runner_id:
|
||||||
|
raise ValueError('Agent has no configured runner')
|
||||||
|
|
||||||
|
conversation_id = str(payload.get('conversation_id') or f'debug:{agent_uuid}').strip()
|
||||||
|
if not conversation_id or len(conversation_id) > 256:
|
||||||
|
raise ValueError('Invalid debug conversation_id')
|
||||||
|
|
||||||
|
actor_payload = payload.get('actor') or {
|
||||||
|
'actor_type': 'user',
|
||||||
|
'actor_id': 'debug-user',
|
||||||
|
'actor_name': 'Debug User',
|
||||||
|
}
|
||||||
|
subject_payload = payload.get('subject') or {
|
||||||
|
'subject_type': 'message' if event_type.startswith('message.') else event_type.split('.', 1)[0],
|
||||||
|
'subject_id': 'debug-subject',
|
||||||
|
'data': event_data,
|
||||||
|
}
|
||||||
|
if not isinstance(actor_payload, dict) or not isinstance(subject_payload, dict):
|
||||||
|
raise ValueError('Debug actor and subject must be objects')
|
||||||
|
|
||||||
|
event_id = f'debug:{agent_uuid}:{uuid.uuid4()}'
|
||||||
|
event = AgentEventEnvelope(
|
||||||
|
event_id=event_id,
|
||||||
|
event_type=event_type,
|
||||||
|
event_time=int(time.time()),
|
||||||
|
source='webui',
|
||||||
|
source_event_type=event_type,
|
||||||
|
workspace_id=context.workspace_uuid,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
actor=ActorContext.model_validate(actor_payload),
|
||||||
|
subject=SubjectContext.model_validate(subject_payload),
|
||||||
|
input=AgentInput.model_validate(
|
||||||
|
{
|
||||||
|
'text': text or event_type,
|
||||||
|
'contents': [
|
||||||
|
{'type': 'text', 'text': text or event_type},
|
||||||
|
],
|
||||||
|
'attachments': [],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
delivery=DeliveryContext(
|
||||||
|
surface='webui',
|
||||||
|
reply_target=None,
|
||||||
|
supports_streaming=False,
|
||||||
|
supports_edit=False,
|
||||||
|
supports_reaction=False,
|
||||||
|
platform_capabilities={
|
||||||
|
'event_type': event_type,
|
||||||
|
'debug': True,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
raw_ref=RawEventRef(ref_id=event_id, storage_key=None),
|
||||||
|
data=event_data,
|
||||||
|
)
|
||||||
|
binding = AgentBinding(
|
||||||
|
binding_id=f'debug:{agent_uuid}:{runner_id}',
|
||||||
|
scope=BindingScope(scope_type='agent', scope_id=agent_uuid),
|
||||||
|
event_types=[event_type],
|
||||||
|
runner_id=runner_id,
|
||||||
|
runner_config=runner_config,
|
||||||
|
resource_policy=ResourcePolicyProjector.from_runner_config(runner_config),
|
||||||
|
state_policy=StatePolicy(
|
||||||
|
state_scopes=['conversation', 'actor', 'subject', 'runner'],
|
||||||
|
),
|
||||||
|
delivery_policy=DeliveryPolicy(
|
||||||
|
enable_streaming=False,
|
||||||
|
enable_reply=False,
|
||||||
|
enable_interactions=False,
|
||||||
|
),
|
||||||
|
enabled=True,
|
||||||
|
agent_id=agent_uuid,
|
||||||
|
processor_type='agent',
|
||||||
|
processor_id=agent_uuid,
|
||||||
|
)
|
||||||
|
execution_context = ExecutionContext.from_request(
|
||||||
|
context,
|
||||||
|
query_uuid=event_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
output_items: list[dict[str, typing.Any]] = []
|
||||||
|
final_text = ''
|
||||||
|
async for output in self.ap.agent_run_orchestrator.run(
|
||||||
|
event,
|
||||||
|
binding,
|
||||||
|
adapter_context={'_execution_context': execution_context},
|
||||||
|
):
|
||||||
|
output_text = self._provider_output_to_text(output)
|
||||||
|
if output_text:
|
||||||
|
final_text = output_text
|
||||||
|
output_items.append(
|
||||||
|
{
|
||||||
|
'kind': output.__class__.__name__,
|
||||||
|
'role': str(getattr(output, 'role', '') or ''),
|
||||||
|
'text': output_text,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'event_id': event_id,
|
||||||
|
'event_type': event_type,
|
||||||
|
'conversation_id': conversation_id,
|
||||||
|
'final_text': final_text,
|
||||||
|
'outputs': output_items,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _supports_event_type(patterns: typing.Any, event_type: str) -> bool:
|
||||||
|
normalized = patterns if isinstance(patterns, list) else AGENT_DEFAULT_EVENT_PATTERNS
|
||||||
|
return any(isinstance(pattern, str) and fnmatch.fnmatchcase(event_type, pattern) for pattern in normalized)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _provider_output_to_text(output: typing.Any) -> str:
|
||||||
|
all_content = getattr(output, 'all_content', None)
|
||||||
|
if all_content:
|
||||||
|
return str(all_content)
|
||||||
|
content = getattr(output, 'content', None)
|
||||||
|
if content is None:
|
||||||
|
return ''
|
||||||
|
if isinstance(content, str):
|
||||||
|
return content
|
||||||
|
if isinstance(content, list):
|
||||||
|
parts: list[str] = []
|
||||||
|
for item in content:
|
||||||
|
item_data = item.model_dump(mode='json') if hasattr(item, 'model_dump') else item
|
||||||
|
if isinstance(item_data, dict) and item_data.get('text') is not None:
|
||||||
|
parts.append(str(item_data['text']))
|
||||||
|
elif item_data is not None and not isinstance(item_data, dict):
|
||||||
|
parts.append(str(item_data))
|
||||||
|
return ''.join(parts)
|
||||||
|
return str(content)
|
||||||
|
|
||||||
async def create_agent(self, context: TenantContext, agent_data: dict) -> dict[str, str]:
|
async def create_agent(self, context: TenantContext, agent_data: dict) -> dict[str, str]:
|
||||||
workspace_uuid = require_workspace_uuid(context)
|
workspace_uuid = require_workspace_uuid(context)
|
||||||
kind = agent_data.get('kind') or AGENT_KIND_AGENT
|
kind = agent_data.get('kind') or AGENT_KIND_AGENT
|
||||||
@@ -89,7 +274,7 @@ class AgentService:
|
|||||||
'description': agent_data.get('description') or '',
|
'description': agent_data.get('description') or '',
|
||||||
'emoji': agent_data.get('emoji') or '⚙️',
|
'emoji': agent_data.get('emoji') or '⚙️',
|
||||||
'config': {},
|
'config': {},
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
return {'uuid': pipeline_uuid, 'kind': AGENT_KIND_PIPELINE}
|
return {'uuid': pipeline_uuid, 'kind': AGENT_KIND_PIPELINE}
|
||||||
|
|
||||||
|
|||||||
@@ -111,9 +111,7 @@ class TestAgentServiceMetadata:
|
|||||||
)
|
)
|
||||||
|
|
||||||
metadata = await AgentService(app).get_agent_metadata(WORKSPACE_UUID)
|
metadata = await AgentService(app).get_agent_metadata(WORKSPACE_UUID)
|
||||||
app.pipeline_service.get_pipeline_metadata.assert_awaited_once_with(
|
app.pipeline_service.get_pipeline_metadata.assert_awaited_once_with(WORKSPACE_UUID)
|
||||||
WORKSPACE_UUID
|
|
||||||
)
|
|
||||||
|
|
||||||
assert metadata['runner_config'] == ai_metadata
|
assert metadata['runner_config'] == ai_metadata
|
||||||
assert metadata['kinds'] == [
|
assert metadata['kinds'] == [
|
||||||
@@ -130,6 +128,88 @@ class TestAgentServiceMetadata:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TestAgentServiceDebug:
|
||||||
|
async def test_debug_agent_runs_configured_runner_with_synthetic_event(self):
|
||||||
|
app = _make_app()
|
||||||
|
|
||||||
|
async def run_agent(event, binding, adapter_context):
|
||||||
|
yield SimpleNamespace(
|
||||||
|
role='assistant',
|
||||||
|
content='debug result',
|
||||||
|
all_content=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.agent_run_orchestrator = SimpleNamespace(run=Mock(side_effect=run_agent))
|
||||||
|
service = AgentService(app)
|
||||||
|
service.get_agent = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
'uuid': 'agent-1',
|
||||||
|
'kind': AGENT_KIND_AGENT,
|
||||||
|
'enabled': True,
|
||||||
|
'supported_event_patterns': ['*'],
|
||||||
|
'config': _agent_row().config,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
context = SimpleNamespace(
|
||||||
|
instance_uuid='instance-test',
|
||||||
|
workspace_uuid=WORKSPACE_UUID,
|
||||||
|
placement_generation=1,
|
||||||
|
principal=SimpleNamespace(account_uuid='account-test'),
|
||||||
|
entitlement_revision=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await service.debug_agent(
|
||||||
|
context,
|
||||||
|
'agent-1',
|
||||||
|
{
|
||||||
|
'event_type': 'group.member.joined',
|
||||||
|
'text': 'A member joined.',
|
||||||
|
'data': {'member_id': 'user-1'},
|
||||||
|
'conversation_id': 'debug-session',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result['event_type'] == 'group.member.joined'
|
||||||
|
assert result['conversation_id'] == 'debug-session'
|
||||||
|
assert result['final_text'] == 'debug result'
|
||||||
|
assert result['outputs'] == [
|
||||||
|
{
|
||||||
|
'kind': 'SimpleNamespace',
|
||||||
|
'role': 'assistant',
|
||||||
|
'text': 'debug result',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
event, binding = app.agent_run_orchestrator.run.call_args.args
|
||||||
|
assert event.workspace_id == WORKSPACE_UUID
|
||||||
|
assert event.data == {'member_id': 'user-1'}
|
||||||
|
assert binding.agent_id == 'agent-1'
|
||||||
|
assert binding.runner_id == 'plugin:test/runner/default'
|
||||||
|
assert (
|
||||||
|
app.agent_run_orchestrator.run.call_args.kwargs['adapter_context']['_execution_context'].workspace_uuid
|
||||||
|
== WORKSPACE_UUID
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_debug_agent_rejects_unsupported_event_type(self):
|
||||||
|
app = _make_app()
|
||||||
|
service = AgentService(app)
|
||||||
|
service.get_agent = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
'uuid': 'agent-1',
|
||||||
|
'kind': AGENT_KIND_AGENT,
|
||||||
|
'supported_event_patterns': ['message.*'],
|
||||||
|
'config': _agent_row().config,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
context = SimpleNamespace(workspace_uuid=WORKSPACE_UUID)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match='does not support'):
|
||||||
|
await service.debug_agent(
|
||||||
|
context,
|
||||||
|
'agent-1',
|
||||||
|
{'event_type': 'group.member.joined'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestAgentServiceListAndLookup:
|
class TestAgentServiceListAndLookup:
|
||||||
async def test_get_agents_merges_agents_and_pipelines_without_leaking_config(self):
|
async def test_get_agents_merges_agents_and_pipelines_without_leaking_config(self):
|
||||||
app = _make_app()
|
app = _make_app()
|
||||||
@@ -237,7 +317,7 @@ class TestAgentServiceCreateUpdateDelete:
|
|||||||
'description': 'Handles support events',
|
'description': 'Handles support events',
|
||||||
'emoji': 'S',
|
'emoji': 'S',
|
||||||
'component_ref': 'plugin:caller/must-not-win/default',
|
'component_ref': 'plugin:caller/must-not-win/default',
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
insert_values = _compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
|
insert_values = _compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
|
||||||
@@ -309,7 +389,7 @@ class TestAgentServiceCreateUpdateDelete:
|
|||||||
'runner': {'id': runner_id},
|
'runner': {'id': runner_id},
|
||||||
'runner_config': {runner_id: {field_name: invalid_value}},
|
'runner_config': {runner_id: {field_name: invalid_value}},
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
app.persistence_mgr.execute_async.assert_not_awaited()
|
app.persistence_mgr.execute_async.assert_not_awaited()
|
||||||
@@ -334,7 +414,7 @@ class TestAgentServiceCreateUpdateDelete:
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
app.persistence_mgr.execute_async.assert_not_awaited()
|
app.persistence_mgr.execute_async.assert_not_awaited()
|
||||||
@@ -352,7 +432,7 @@ class TestAgentServiceCreateUpdateDelete:
|
|||||||
'runner': {'id': ''},
|
'runner': {'id': ''},
|
||||||
'runner_config': {},
|
'runner_config': {},
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
insert_values = _compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
|
insert_values = _compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
|
||||||
@@ -491,7 +571,7 @@ class TestAgentServiceCreateUpdateDelete:
|
|||||||
'name': 'Pipeline Agent',
|
'name': 'Pipeline Agent',
|
||||||
'description': 'Legacy pipeline',
|
'description': 'Legacy pipeline',
|
||||||
'emoji': 'P',
|
'emoji': 'P',
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
await service.update_agent(
|
await service.update_agent(
|
||||||
WORKSPACE_UUID,
|
WORKSPACE_UUID,
|
||||||
@@ -508,7 +588,7 @@ class TestAgentServiceCreateUpdateDelete:
|
|||||||
'description': 'Legacy pipeline',
|
'description': 'Legacy pipeline',
|
||||||
'emoji': 'P',
|
'emoji': 'P',
|
||||||
'config': {},
|
'config': {},
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
app.pipeline_service.update_pipeline.assert_awaited_once_with(
|
app.pipeline_service.update_pipeline.assert_awaited_once_with(
|
||||||
WORKSPACE_UUID,
|
WORKSPACE_UUID,
|
||||||
|
|||||||
@@ -41,12 +41,8 @@ async def _create_test_client(agent_service: SimpleNamespace):
|
|||||||
ap = SimpleNamespace(
|
ap = SimpleNamespace(
|
||||||
agent_service=agent_service,
|
agent_service=agent_service,
|
||||||
user_service=user_service,
|
user_service=user_service,
|
||||||
apikey_service=SimpleNamespace(
|
apikey_service=SimpleNamespace(authenticate_api_key=AsyncMock(return_value=None)),
|
||||||
authenticate_api_key=AsyncMock(return_value=None)
|
workspace_collaboration_service=SimpleNamespace(resolve_account_workspace=AsyncMock(return_value=access)),
|
||||||
),
|
|
||||||
workspace_collaboration_service=SimpleNamespace(
|
|
||||||
resolve_account_workspace=AsyncMock(return_value=access)
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
AgentsRouterGroup = import_module('langbot.pkg.api.http.controller.groups.agents').AgentsRouterGroup
|
AgentsRouterGroup = import_module('langbot.pkg.api.http.controller.groups.agents').AgentsRouterGroup
|
||||||
group = AgentsRouterGroup(ap, app)
|
group = AgentsRouterGroup(ap, app)
|
||||||
@@ -91,3 +87,58 @@ async def test_update_agent_returns_bad_request_for_invalid_runner_config():
|
|||||||
'agent-1',
|
'agent-1',
|
||||||
{'config': {'runner': {'id': 7}}},
|
{'config': {'runner': {'id': 7}}},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_debug_agent_executes_with_runtime_permission():
|
||||||
|
result = {
|
||||||
|
'event_id': 'debug-event',
|
||||||
|
'event_type': 'message.received',
|
||||||
|
'conversation_id': 'debug-session',
|
||||||
|
'final_text': 'hello',
|
||||||
|
'outputs': [],
|
||||||
|
}
|
||||||
|
agent_service = SimpleNamespace(debug_agent=AsyncMock(return_value=result))
|
||||||
|
client = await _create_test_client(agent_service)
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
'/api/v1/agents/agent-1/debug',
|
||||||
|
json={
|
||||||
|
'event_type': 'message.received',
|
||||||
|
'text': 'hello',
|
||||||
|
'data': {},
|
||||||
|
'conversation_id': 'debug-session',
|
||||||
|
},
|
||||||
|
headers={'Authorization': 'Bearer test-token'},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert (await response.get_json())['data'] == result
|
||||||
|
agent_service.debug_agent.assert_awaited_once_with(
|
||||||
|
ANY,
|
||||||
|
'agent-1',
|
||||||
|
{
|
||||||
|
'event_type': 'message.received',
|
||||||
|
'text': 'hello',
|
||||||
|
'data': {},
|
||||||
|
'conversation_id': 'debug-session',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_debug_agent_returns_bad_request_for_invalid_event():
|
||||||
|
agent_service = SimpleNamespace(
|
||||||
|
debug_agent=AsyncMock(side_effect=ValueError('Invalid event_type')),
|
||||||
|
)
|
||||||
|
client = await _create_test_client(agent_service)
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
'/api/v1/agents/agent-1/debug',
|
||||||
|
json={'event_type': ''},
|
||||||
|
headers={'Authorization': 'Bearer test-token'},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert await response.get_json() == {
|
||||||
|
'code': -1,
|
||||||
|
'msg': 'Invalid event_type',
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,23 +1,31 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Bug, Settings } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
|
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||||
import { Agent } from '@/app/infra/entities/api';
|
import { Agent } from '@/app/infra/entities/api';
|
||||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||||
import PipelineDetailContent from '@/app/home/pipelines/PipelineDetailContent';
|
import PipelineDetailContent from '@/app/home/pipelines/PipelineDetailContent';
|
||||||
import AgentCreateContent from './components/AgentCreateContent';
|
import AgentCreateContent from './components/AgentCreateContent';
|
||||||
|
import AgentDebugPanel from './components/AgentDebugPanel';
|
||||||
import AgentFormComponent from './components/AgentFormComponent';
|
import AgentFormComponent from './components/AgentFormComponent';
|
||||||
|
|
||||||
export default function AgentDetailContent({ id }: { id: string }) {
|
export default function AgentDetailContent({ id }: { id: string }) {
|
||||||
const isCreateMode = id === 'new';
|
const isCreateMode = id === 'new';
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const currentWorkspace = useCurrentWorkspace();
|
||||||
|
const canOperate =
|
||||||
|
currentWorkspace?.permissions.includes('runtime.operate') ?? false;
|
||||||
const { refreshPipelines, pipelines, setDetailEntityName } = useSidebarData();
|
const { refreshPipelines, pipelines, setDetailEntityName } = useSidebarData();
|
||||||
const [agent, setAgent] = useState<Agent | null>(null);
|
const [agent, setAgent] = useState<Agent | null>(null);
|
||||||
const [loading, setLoading] = useState(!isCreateMode);
|
const [loading, setLoading] = useState(!isCreateMode);
|
||||||
const [formDirty, setFormDirty] = useState(false);
|
const [formDirty, setFormDirty] = useState(false);
|
||||||
const [formSaving, setFormSaving] = useState(false);
|
const [formSaving, setFormSaving] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState('config');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isCreateMode) {
|
if (isCreateMode) {
|
||||||
@@ -71,19 +79,42 @@ export default function AgentDetailContent({ id }: { id: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full min-w-0 flex-col">
|
||||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
<div className="flex items-center justify-between pb-4 shrink-0">
|
||||||
<h1 className="text-xl font-semibold">{t('agents.editAgent')}</h1>
|
<h1 className="text-xl font-semibold">{t('agents.editAgent')}</h1>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
form="agent-form"
|
form="agent-form"
|
||||||
disabled={!formDirty || formSaving}
|
disabled={!formDirty || formSaving}
|
||||||
|
className={activeTab !== 'config' ? 'invisible' : ''}
|
||||||
>
|
>
|
||||||
{t('common.save')}
|
{t('common.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto min-h-0">
|
<Tabs
|
||||||
|
key={id}
|
||||||
|
value={activeTab}
|
||||||
|
onValueChange={setActiveTab}
|
||||||
|
className="flex min-h-0 min-w-0 flex-1 flex-col"
|
||||||
|
>
|
||||||
|
<TabsList className="shrink-0">
|
||||||
|
<TabsTrigger value="config" className="gap-1.5">
|
||||||
|
<Settings className="size-3.5" />
|
||||||
|
{t('pipelines.configuration')}
|
||||||
|
</TabsTrigger>
|
||||||
|
{canOperate && (
|
||||||
|
<TabsTrigger value="debug" className="gap-1.5">
|
||||||
|
<Bug className="size-3.5" />
|
||||||
|
{t('agents.debugTab')}
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent
|
||||||
|
value="config"
|
||||||
|
className="mt-4 min-h-0 min-w-0 flex-1 overflow-hidden"
|
||||||
|
>
|
||||||
<AgentFormComponent
|
<AgentFormComponent
|
||||||
agentId={id}
|
agentId={id}
|
||||||
onFinish={() => {
|
onFinish={() => {
|
||||||
@@ -96,7 +127,23 @@ export default function AgentDetailContent({ id }: { id: string }) {
|
|||||||
onDirtyChange={setFormDirty}
|
onDirtyChange={setFormDirty}
|
||||||
onSavingChange={setFormSaving}
|
onSavingChange={setFormSaving}
|
||||||
/>
|
/>
|
||||||
</div>
|
</TabsContent>
|
||||||
|
|
||||||
|
{canOperate && (
|
||||||
|
<TabsContent
|
||||||
|
value="debug"
|
||||||
|
className="mt-4 min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden"
|
||||||
|
>
|
||||||
|
<AgentDebugPanel
|
||||||
|
agentId={id}
|
||||||
|
supportedEventPatterns={
|
||||||
|
agent.supported_event_patterns ??
|
||||||
|
agent.capability?.supported_event_patterns ?? ['*']
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,366 @@
|
|||||||
|
import { useMemo, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
Braces,
|
||||||
|
LoaderCircle,
|
||||||
|
MessageSquare,
|
||||||
|
Play,
|
||||||
|
RotateCcw,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
|
||||||
|
interface AgentDebugPanelProps {
|
||||||
|
agentId: string;
|
||||||
|
supportedEventPatterns?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DebugEntry {
|
||||||
|
id: string;
|
||||||
|
direction: 'input' | 'output' | 'error';
|
||||||
|
eventType: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EVENT_PRESETS = [
|
||||||
|
{
|
||||||
|
value: 'message.received',
|
||||||
|
labelKey: 'agents.debugMessageReceived',
|
||||||
|
text: '',
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'group.member.joined',
|
||||||
|
labelKey: 'agents.debugGroupMemberJoined',
|
||||||
|
text: 'A new member joined the group.',
|
||||||
|
data: {
|
||||||
|
group_id: 'debug-group',
|
||||||
|
member_id: 'debug-user',
|
||||||
|
member_name: 'Debug User',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'group.member.left',
|
||||||
|
labelKey: 'agents.debugGroupMemberLeft',
|
||||||
|
text: 'A member left the group.',
|
||||||
|
data: {
|
||||||
|
group_id: 'debug-group',
|
||||||
|
member_id: 'debug-user',
|
||||||
|
member_name: 'Debug User',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'friend.requested',
|
||||||
|
labelKey: 'agents.debugFriendRequested',
|
||||||
|
text: 'A user sent a friend request.',
|
||||||
|
data: {
|
||||||
|
requester_id: 'debug-user',
|
||||||
|
requester_name: 'Debug User',
|
||||||
|
message: 'Hello',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'feedback.received',
|
||||||
|
labelKey: 'agents.debugFeedbackReceived',
|
||||||
|
text: 'The user submitted feedback.',
|
||||||
|
data: {
|
||||||
|
rating: 5,
|
||||||
|
content: 'Debug feedback',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'custom',
|
||||||
|
labelKey: 'agents.debugCustomEvent',
|
||||||
|
text: '',
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function createDebugSessionId(agentId: string) {
|
||||||
|
const nonce = globalThis.crypto?.randomUUID?.() ?? String(Date.now());
|
||||||
|
return `webui:${agentId}:${nonce}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AgentDebugPanel({
|
||||||
|
agentId,
|
||||||
|
supportedEventPatterns = ['*'],
|
||||||
|
}: AgentDebugPanelProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [preset, setPreset] = useState('message.received');
|
||||||
|
const [customEventType, setCustomEventType] = useState('custom.event');
|
||||||
|
const [inputText, setInputText] = useState('');
|
||||||
|
const [eventDataText, setEventDataText] = useState('{}');
|
||||||
|
const [running, setRunning] = useState(false);
|
||||||
|
const [entries, setEntries] = useState<DebugEntry[]>([]);
|
||||||
|
const sessionIdRef = useRef(createDebugSessionId(agentId));
|
||||||
|
|
||||||
|
const eventType = preset === 'custom' ? customEventType.trim() : preset;
|
||||||
|
const isMessageEvent = eventType.startsWith('message.');
|
||||||
|
const supportedLabel = useMemo(
|
||||||
|
() => supportedEventPatterns.join(', '),
|
||||||
|
[supportedEventPatterns],
|
||||||
|
);
|
||||||
|
|
||||||
|
function selectPreset(value: string) {
|
||||||
|
setPreset(value);
|
||||||
|
const nextPreset = EVENT_PRESETS.find((item) => item.value === value);
|
||||||
|
if (!nextPreset) return;
|
||||||
|
setInputText(nextPreset.text);
|
||||||
|
setEventDataText(JSON.stringify(nextPreset.data, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetSession() {
|
||||||
|
sessionIdRef.current = createDebugSessionId(agentId);
|
||||||
|
setEntries([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runDebugEvent() {
|
||||||
|
if (!eventType) {
|
||||||
|
toast.error(t('agents.debugEventTypeRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isMessageEvent && !inputText.trim()) {
|
||||||
|
toast.error(t('agents.debugInputRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let eventData: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(eventDataText || '{}');
|
||||||
|
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
|
||||||
|
throw new Error('payload must be an object');
|
||||||
|
}
|
||||||
|
eventData = parsed as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
toast.error(t('agents.debugInvalidPayload'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = globalThis.crypto?.randomUUID?.() ?? String(Date.now());
|
||||||
|
setEntries((current) => [
|
||||||
|
...current,
|
||||||
|
{
|
||||||
|
id: `input:${requestId}`,
|
||||||
|
direction: 'input',
|
||||||
|
eventType,
|
||||||
|
text: inputText.trim() || JSON.stringify(eventData, null, 2),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setRunning(true);
|
||||||
|
try {
|
||||||
|
const result = await httpClient.debugAgent(agentId, {
|
||||||
|
event_type: eventType,
|
||||||
|
text: inputText.trim(),
|
||||||
|
data: eventData,
|
||||||
|
conversation_id: sessionIdRef.current,
|
||||||
|
});
|
||||||
|
setEntries((current) => [
|
||||||
|
...current,
|
||||||
|
{
|
||||||
|
id: `output:${result.event_id}`,
|
||||||
|
direction: 'output',
|
||||||
|
eventType,
|
||||||
|
text: result.final_text || t('agents.debugNoTextOutput'),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
if (isMessageEvent) setInputText('');
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
typeof error === 'object' && error && 'msg' in error
|
||||||
|
? String((error as { msg?: string }).msg || '')
|
||||||
|
: t('agents.debugRunFailed');
|
||||||
|
setEntries((current) => [
|
||||||
|
...current,
|
||||||
|
{
|
||||||
|
id: `error:${requestId}`,
|
||||||
|
direction: 'error',
|
||||||
|
eventType,
|
||||||
|
text: message || t('agents.debugRunFailed'),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
setRunning(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto grid w-full min-w-0 max-w-6xl gap-6 pb-8 lg:grid-cols-[minmax(0,1fr)_minmax(22rem,0.8fr)]">
|
||||||
|
<Card className="min-w-0">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
{isMessageEvent ? (
|
||||||
|
<MessageSquare className="size-5" />
|
||||||
|
) : (
|
||||||
|
<Braces className="size-5" />
|
||||||
|
)}
|
||||||
|
{t('agents.debugTitle')}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>{t('agents.debugDescription')}</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={resetSession}
|
||||||
|
>
|
||||||
|
<RotateCcw className="size-4" />
|
||||||
|
{t('agents.debugResetSession')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-5">
|
||||||
|
<Alert>
|
||||||
|
<AlertTriangle />
|
||||||
|
<AlertTitle>{t('agents.debugActualRun')}</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{t('agents.debugActualRunDescription')}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('agents.debugEventType')}</Label>
|
||||||
|
<Select value={preset} onValueChange={selectPreset}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{EVENT_PRESETS.map((item) => (
|
||||||
|
<SelectItem key={item.value} value={item.value}>
|
||||||
|
{t(item.labelKey)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{preset === 'custom' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="agent-debug-custom-event">
|
||||||
|
{t('agents.debugCustomEventType')}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="agent-debug-custom-event"
|
||||||
|
value={customEventType}
|
||||||
|
onChange={(event) => setCustomEventType(event.target.value)}
|
||||||
|
placeholder="custom.event"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="agent-debug-input">
|
||||||
|
{isMessageEvent
|
||||||
|
? t('agents.debugMessageInput')
|
||||||
|
: t('agents.debugEventSummary')}
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="agent-debug-input"
|
||||||
|
value={inputText}
|
||||||
|
onChange={(event) => setInputText(event.target.value)}
|
||||||
|
className="min-h-24 resize-y"
|
||||||
|
placeholder={t('agents.debugInputPlaceholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<Label htmlFor="agent-debug-payload">
|
||||||
|
{t('agents.debugEventPayload')}
|
||||||
|
</Label>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{t('agents.debugSupportedEvents')}: {supportedLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
id="agent-debug-payload"
|
||||||
|
value={eventDataText}
|
||||||
|
onChange={(event) => setEventDataText(event.target.value)}
|
||||||
|
className="min-h-40 resize-y font-mono text-xs"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button type="button" disabled={running} onClick={runDebugEvent}>
|
||||||
|
{running ? (
|
||||||
|
<LoaderCircle className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Play className="size-4" />
|
||||||
|
)}
|
||||||
|
{running ? t('agents.debugRunning') : t('agents.debugRun')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="min-h-[28rem] min-w-0">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t('agents.debugTranscript')}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t('agents.debugTranscriptDescription')}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{entries.length === 0 ? (
|
||||||
|
<div className="flex min-h-72 items-center justify-center rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">
|
||||||
|
{t('agents.debugEmptyTranscript')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="max-h-[42rem] space-y-4 overflow-y-auto pr-1">
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<div
|
||||||
|
key={entry.id}
|
||||||
|
className={`rounded-lg border p-3 ${
|
||||||
|
entry.direction === 'output'
|
||||||
|
? 'border-primary/20 bg-primary/5'
|
||||||
|
: entry.direction === 'error'
|
||||||
|
? 'border-destructive/30 bg-destructive/5'
|
||||||
|
: 'bg-muted/40'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
|
<Badge variant="outline">{entry.eventType}</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{entry.direction === 'output'
|
||||||
|
? t('agents.debugAgentOutput')
|
||||||
|
: entry.direction === 'error'
|
||||||
|
? t('common.error')
|
||||||
|
: t('agents.debugTestInput')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<pre className="min-w-0 whitespace-pre-wrap break-words font-sans text-sm leading-relaxed">
|
||||||
|
{entry.text}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import type React from 'react';
|
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
@@ -7,11 +6,8 @@ import { z } from 'zod';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
Brain,
|
|
||||||
CircleAlert,
|
CircleAlert,
|
||||||
CircleCheck,
|
CircleCheck,
|
||||||
FileJson2,
|
|
||||||
Info,
|
|
||||||
LoaderCircle,
|
LoaderCircle,
|
||||||
Power,
|
Power,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
@@ -26,7 +22,6 @@ import {
|
|||||||
} from '@/app/infra/entities/pipeline';
|
} from '@/app/infra/entities/pipeline';
|
||||||
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
|
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
|
||||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -65,12 +60,6 @@ interface AgentFormComponentProps {
|
|||||||
onSavingChange?: (saving: boolean) => void;
|
onSavingChange?: (saving: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SectionItem {
|
|
||||||
label: string;
|
|
||||||
name: 'basic' | 'runner' | 'events';
|
|
||||||
icon: React.ElementType;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AgentFormComponent({
|
export default function AgentFormComponent({
|
||||||
agentId,
|
agentId,
|
||||||
onFinish,
|
onFinish,
|
||||||
@@ -79,8 +68,6 @@ export default function AgentFormComponent({
|
|||||||
onSavingChange,
|
onSavingChange,
|
||||||
}: AgentFormComponentProps) {
|
}: AgentFormComponentProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [activeSection, setActiveSection] =
|
|
||||||
useState<SectionItem['name']>('basic');
|
|
||||||
const [runnerConfigSchema, setRunnerConfigSchema] =
|
const [runnerConfigSchema, setRunnerConfigSchema] =
|
||||||
useState<PipelineConfigTab | null>(null);
|
useState<PipelineConfigTab | null>(null);
|
||||||
const [pluginSystemStatus, setPluginSystemStatus] =
|
const [pluginSystemStatus, setPluginSystemStatus] =
|
||||||
@@ -183,12 +170,6 @@ export default function AgentFormComponent({
|
|||||||
void loadPluginSystemStatus();
|
void loadPluginSystemStatus();
|
||||||
}, [loadPluginSystemStatus]);
|
}, [loadPluginSystemStatus]);
|
||||||
|
|
||||||
const sections: SectionItem[] = [
|
|
||||||
{ label: t('agents.basicInfo'), name: 'basic', icon: Info },
|
|
||||||
{ label: t('agents.runnerSettings'), name: 'runner', icon: Brain },
|
|
||||||
{ label: t('agents.advanced'), name: 'events', icon: FileJson2 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const currentRunner = (form.watch('runner') as Record<string, any>)?.id;
|
const currentRunner = (form.watch('runner') as Record<string, any>)?.id;
|
||||||
const runnerOptions = useMemo(() => {
|
const runnerOptions = useMemo(() => {
|
||||||
const runnerStage = runnerConfigSchema?.stages.find(
|
const runnerStage = runnerConfigSchema?.stages.find(
|
||||||
@@ -450,38 +431,14 @@ export default function AgentFormComponent({
|
|||||||
<form
|
<form
|
||||||
id="agent-form"
|
id="agent-form"
|
||||||
onSubmit={form.handleSubmit(handleSubmit)}
|
onSubmit={form.handleSubmit(handleSubmit)}
|
||||||
className="h-full flex flex-col flex-1 min-h-0 mb-2"
|
className="mb-2 flex h-full min-h-0 min-w-0 flex-1 flex-col"
|
||||||
>
|
>
|
||||||
<div className="flex-1 flex flex-col md:flex-row min-h-0">
|
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||||
<nav className="shrink-0 mb-4 md:mb-0 md:w-44 md:pr-4 md:mr-4 md:border-r overflow-x-auto md:overflow-x-visible md:overflow-y-auto">
|
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden">
|
||||||
<ul className="flex md:flex-col gap-1 md:space-y-1">
|
<div className="mx-auto flex w-full min-w-0 max-w-5xl flex-col gap-6 pb-8">
|
||||||
{sections.map((section) => {
|
{
|
||||||
const Icon = section.icon;
|
<div className="contents">
|
||||||
return (
|
<Card className="order-2">
|
||||||
<li key={section.name}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setActiveSection(section.name)}
|
|
||||||
className={cn(
|
|
||||||
'w-full flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors text-left cursor-pointer whitespace-nowrap',
|
|
||||||
activeSection === section.name
|
|
||||||
? 'bg-accent text-accent-foreground'
|
|
||||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon className="size-4 shrink-0" />
|
|
||||||
{section.label}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto min-h-0">
|
|
||||||
{activeSection === 'basic' && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('agents.basicInfo')}</CardTitle>
|
<CardTitle>{t('agents.basicInfo')}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
@@ -500,7 +457,10 @@ export default function AgentFormComponent({
|
|||||||
<span className="text-destructive">*</span>
|
<span className="text-destructive">*</span>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} value={field.value ?? ''} />
|
<Input
|
||||||
|
{...field}
|
||||||
|
value={field.value ?? ''}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -564,7 +524,7 @@ export default function AgentFormComponent({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="border-destructive/50">
|
<Card className="order-4 border-destructive/50">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-destructive">
|
<CardTitle className="text-destructive">
|
||||||
{t('agents.dangerZone')}
|
{t('agents.dangerZone')}
|
||||||
@@ -597,10 +557,10 @@ export default function AgentFormComponent({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)}
|
}
|
||||||
|
|
||||||
{activeSection === 'runner' && (
|
{
|
||||||
<div className="space-y-6">
|
<div className="order-1 space-y-6">
|
||||||
{renderRunnerStatus()}
|
{renderRunnerStatus()}
|
||||||
{runnerConfigSchema?.stages.map((stage) =>
|
{runnerConfigSchema?.stages.map((stage) =>
|
||||||
renderDynamicStage(stage),
|
renderDynamicStage(stage),
|
||||||
@@ -616,10 +576,10 @@ export default function AgentFormComponent({
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
}
|
||||||
|
|
||||||
{activeSection === 'events' && (
|
{
|
||||||
<Card>
|
<Card className="order-3">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('agents.bindableEvents')}</CardTitle>
|
<CardTitle>{t('agents.bindableEvents')}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
@@ -632,7 +592,9 @@ export default function AgentFormComponent({
|
|||||||
name="supported_event_patterns_text"
|
name="supported_event_patterns_text"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t('agents.supportedEvents')}</FormLabel>
|
<FormLabel>
|
||||||
|
{t('agents.supportedEvents')}
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<Textarea
|
||||||
{...field}
|
{...field}
|
||||||
@@ -649,7 +611,8 @@ export default function AgentFormComponent({
|
|||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ export default function BotDetailContent({ id }: { id: string }) {
|
|||||||
// ==================== Create Mode ====================
|
// ==================== Create Mode ====================
|
||||||
if (isCreateMode) {
|
if (isCreateMode) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full min-w-0 flex-col">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
<div className="flex items-center justify-between pb-4 shrink-0">
|
||||||
<h1 className="text-xl font-semibold">{t('bots.createBot')}</h1>
|
<h1 className="text-xl font-semibold">{t('bots.createBot')}</h1>
|
||||||
@@ -145,8 +145,8 @@ export default function BotDetailContent({ id }: { id: string }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 overflow-y-auto min-h-0">
|
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden">
|
||||||
<div className="mx-auto max-w-3xl pb-8">
|
<div className="mx-auto w-full min-w-0 max-w-3xl pb-8">
|
||||||
<fieldset className="contents" disabled={!canManage}>
|
<fieldset className="contents" disabled={!canManage}>
|
||||||
<BotForm
|
<BotForm
|
||||||
initBotId={undefined}
|
initBotId={undefined}
|
||||||
@@ -163,7 +163,7 @@ export default function BotDetailContent({ id }: { id: string }) {
|
|||||||
// ==================== Edit Mode ====================
|
// ==================== Edit Mode ====================
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full min-w-0 flex-col">
|
||||||
{/* Sticky Header: title + enable switch + save button */}
|
{/* Sticky Header: title + enable switch + save button */}
|
||||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
<div className="flex items-center justify-between pb-4 shrink-0">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@@ -202,7 +202,7 @@ export default function BotDetailContent({ id }: { id: string }) {
|
|||||||
key={id}
|
key={id}
|
||||||
value={activeTab}
|
value={activeTab}
|
||||||
onValueChange={setActiveTab}
|
onValueChange={setActiveTab}
|
||||||
className="flex flex-1 flex-col min-h-0"
|
className="flex min-h-0 min-w-0 flex-1 flex-col"
|
||||||
>
|
>
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
@@ -253,9 +253,9 @@ export default function BotDetailContent({ id }: { id: string }) {
|
|||||||
{/* Tab: Configuration */}
|
{/* Tab: Configuration */}
|
||||||
<TabsContent
|
<TabsContent
|
||||||
value="config"
|
value="config"
|
||||||
className="flex-1 min-h-0 overflow-y-auto mt-4"
|
className="mt-4 min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden"
|
||||||
>
|
>
|
||||||
<div className="mx-auto max-w-3xl space-y-6 pb-8">
|
<div className="mx-auto w-full min-w-0 max-w-3xl space-y-6 pb-8">
|
||||||
<fieldset className="contents" disabled={!canManage}>
|
<fieldset className="contents" disabled={!canManage}>
|
||||||
<BotForm
|
<BotForm
|
||||||
initBotId={id}
|
initBotId={id}
|
||||||
|
|||||||
@@ -410,8 +410,12 @@ export default function BotForm({
|
|||||||
id="bot-form"
|
id="bot-form"
|
||||||
onSubmit={form.handleSubmit(onDynamicFormSubmit)}
|
onSubmit={form.handleSubmit(onDynamicFormSubmit)}
|
||||||
aria-busy={isLoading}
|
aria-busy={isLoading}
|
||||||
|
className="w-full min-w-0 max-w-full"
|
||||||
|
>
|
||||||
|
<fieldset
|
||||||
|
className="w-full min-w-0 max-w-full space-y-6"
|
||||||
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
<fieldset className="space-y-6" disabled={isLoading}>
|
|
||||||
{/* Card 1: Basic Information */}
|
{/* Card 1: Basic Information */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -181,13 +181,15 @@ function EmbedCodeField({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="min-w-0 max-w-full space-y-2">
|
||||||
<label className="text-sm font-medium leading-none">{label}</label>
|
<label className="text-sm font-medium leading-none">{label}</label>
|
||||||
{description && (
|
{description && (
|
||||||
<p className="text-sm text-muted-foreground">{description}</p>
|
<p className="break-words text-sm text-muted-foreground">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex min-w-0 max-w-full items-center gap-2">
|
||||||
<pre className="flex-1 overflow-x-auto rounded-md bg-muted p-3 text-sm font-mono select-all">
|
<pre className="min-w-0 max-w-full flex-1 overflow-x-auto rounded-md bg-muted p-3 text-sm font-mono select-all">
|
||||||
<code>{snippet}</code>
|
<code>{snippet}</code>
|
||||||
</pre>
|
</pre>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -279,6 +279,30 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
return this.delete(`/api/v1/agents/${uuid}`);
|
return this.delete(`/api/v1/agents/${uuid}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public debugAgent(
|
||||||
|
uuid: string,
|
||||||
|
payload: {
|
||||||
|
event_type: string;
|
||||||
|
text?: string;
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
conversation_id?: string;
|
||||||
|
actor?: Record<string, unknown>;
|
||||||
|
subject?: Record<string, unknown>;
|
||||||
|
},
|
||||||
|
): Promise<{
|
||||||
|
event_id: string;
|
||||||
|
event_type: string;
|
||||||
|
conversation_id: string;
|
||||||
|
final_text: string;
|
||||||
|
outputs: Array<{
|
||||||
|
kind: string;
|
||||||
|
role: string;
|
||||||
|
text: string;
|
||||||
|
}>;
|
||||||
|
}> {
|
||||||
|
return this.post(`/api/v1/agents/${uuid}/debug`, payload);
|
||||||
|
}
|
||||||
|
|
||||||
public getGeneralPipelineMetadata(): Promise<GetPipelineMetadataResponseData> {
|
public getGeneralPipelineMetadata(): Promise<GetPipelineMetadataResponseData> {
|
||||||
// as designed, this method will be deprecated, and only for developer to check the prefered config schema
|
// as designed, this method will be deprecated, and only for developer to check the prefered config schema
|
||||||
return this.get('/api/v1/pipelines/_/metadata');
|
return this.get('/api/v1/pipelines/_/metadata');
|
||||||
|
|||||||
@@ -730,6 +730,41 @@ const enUS = {
|
|||||||
runnerReady: 'Runner ready',
|
runnerReady: 'Runner ready',
|
||||||
runnerReadyDescription:
|
runnerReadyDescription:
|
||||||
'{{runner}} is registered and the plugin runtime is connected.',
|
'{{runner}} is registered and the plugin runtime is connected.',
|
||||||
|
debugTab: 'Event Debug',
|
||||||
|
debugTitle: 'Agent Event Debug',
|
||||||
|
debugDescription:
|
||||||
|
'Run the current Agent with a message or platform event and inspect the real output.',
|
||||||
|
debugResetSession: 'Reset session',
|
||||||
|
debugActualRun: 'This is a real run',
|
||||||
|
debugActualRunDescription:
|
||||||
|
'The test calls the configured runner, models, and authorized tools, but does not deliver output to a real chat platform.',
|
||||||
|
debugEventType: 'Event type',
|
||||||
|
debugMessageReceived: 'Message received',
|
||||||
|
debugGroupMemberJoined: 'Group member joined',
|
||||||
|
debugGroupMemberLeft: 'Group member left',
|
||||||
|
debugFriendRequested: 'Friend request received',
|
||||||
|
debugFeedbackReceived: 'Feedback received',
|
||||||
|
debugCustomEvent: 'Custom event',
|
||||||
|
debugCustomEventType: 'Custom event name',
|
||||||
|
debugMessageInput: 'Conversation input',
|
||||||
|
debugEventSummary: 'Event summary',
|
||||||
|
debugInputPlaceholder: 'Enter what the Agent should handle',
|
||||||
|
debugEventPayload: 'Event payload (JSON)',
|
||||||
|
debugSupportedEvents: 'Agent supports',
|
||||||
|
debugRun: 'Run test',
|
||||||
|
debugRunning: 'Running',
|
||||||
|
debugTranscript: 'Debug transcript',
|
||||||
|
debugTranscriptDescription:
|
||||||
|
'Inputs and Agent outputs from the current debug session.',
|
||||||
|
debugEmptyTranscript:
|
||||||
|
'Choose an event and run a test to see the result here.',
|
||||||
|
debugAgentOutput: 'Agent output',
|
||||||
|
debugTestInput: 'Test input',
|
||||||
|
debugNoTextOutput: 'The run completed without textual output.',
|
||||||
|
debugEventTypeRequired: 'Enter an event type',
|
||||||
|
debugInputRequired: 'Enter a conversation input',
|
||||||
|
debugInvalidPayload: 'The event payload must be a valid JSON object',
|
||||||
|
debugRunFailed: 'Agent debug run failed',
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
title: 'Extensions',
|
title: 'Extensions',
|
||||||
|
|||||||
@@ -698,6 +698,38 @@ const zhHans = {
|
|||||||
noRunnerSelected: '尚未选择运行器',
|
noRunnerSelected: '尚未选择运行器',
|
||||||
runnerReady: '运行器已就绪',
|
runnerReady: '运行器已就绪',
|
||||||
runnerReadyDescription: '{{runner}} 已注册,插件运行时连接正常。',
|
runnerReadyDescription: '{{runner}} 已注册,插件运行时连接正常。',
|
||||||
|
debugTab: '事件调试',
|
||||||
|
debugTitle: 'Agent 事件调试',
|
||||||
|
debugDescription: '用消息或平台事件直接运行当前 Agent,并查看真实输出。',
|
||||||
|
debugResetSession: '重置会话',
|
||||||
|
debugActualRun: '这是真实运行',
|
||||||
|
debugActualRunDescription:
|
||||||
|
'测试会调用当前运行器、模型与已授权工具,但不会把输出发送到真实聊天平台。',
|
||||||
|
debugEventType: '事件类型',
|
||||||
|
debugMessageReceived: '收到消息',
|
||||||
|
debugGroupMemberJoined: '成员加入群组',
|
||||||
|
debugGroupMemberLeft: '成员离开群组',
|
||||||
|
debugFriendRequested: '收到好友请求',
|
||||||
|
debugFeedbackReceived: '收到反馈',
|
||||||
|
debugCustomEvent: '自定义事件',
|
||||||
|
debugCustomEventType: '自定义事件名称',
|
||||||
|
debugMessageInput: '对话内容',
|
||||||
|
debugEventSummary: '事件说明',
|
||||||
|
debugInputPlaceholder: '输入希望 Agent 处理的内容',
|
||||||
|
debugEventPayload: '事件载荷(JSON)',
|
||||||
|
debugSupportedEvents: 'Agent 支持',
|
||||||
|
debugRun: '运行测试',
|
||||||
|
debugRunning: '运行中',
|
||||||
|
debugTranscript: '调试记录',
|
||||||
|
debugTranscriptDescription: '当前调试会话中的输入与 Agent 输出。',
|
||||||
|
debugEmptyTranscript: '选择事件并运行测试后,结果会显示在这里。',
|
||||||
|
debugAgentOutput: 'Agent 输出',
|
||||||
|
debugTestInput: '测试输入',
|
||||||
|
debugNoTextOutput: '运行完成,但没有产生文本输出。',
|
||||||
|
debugEventTypeRequired: '请输入事件类型',
|
||||||
|
debugInputRequired: '请输入对话内容',
|
||||||
|
debugInvalidPayload: '事件载荷必须是有效的 JSON 对象',
|
||||||
|
debugRunFailed: 'Agent 调试运行失败',
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
title: '插件扩展',
|
title: '插件扩展',
|
||||||
|
|||||||
Reference in New Issue
Block a user