Compare commits

...

34 Commits

Author SHA1 Message Date
RockChinQ 823feac7e0 fix(bots): clarify adapter test prerequisites 2026-08-26 23:14:42 +08:00
RockChinQ a7badf6258 fix(bots): move adapter debugger to configuration 2026-08-26 23:07:02 +08:00
RockChinQ 3d692fa8db feat(bots): add platform event debugger 2026-08-26 19:46:59 +08:00
RockChinQ 7990d36c78 fix(bots): align route event selector 2026-08-26 19:32:39 +08:00
RockChinQ 600a173918 refactor(bots): remove route execution test 2026-08-26 14:28:30 +08:00
RockChinQ 8b63cc0281 fix(events): show raw codes in test selectors 2026-08-26 14:12:52 +08:00
RockChinQ 4787799cd8 fix(agents): align debug event selection 2026-08-26 14:06:09 +08:00
RockChinQ 47f5515fa9 fix(pipelines): retain debug chat image history 2026-08-26 00:32:03 +08:00
RockChinQ 94fd3d274c refactor(pipelines): simplify connection status copy 2026-08-26 00:24:37 +08:00
RockChinQ 8380bfc7d4 refactor(pipelines): soften chat session selection 2026-08-26 00:22:22 +08:00
RockChinQ a936734efa fix(pipelines): move chat reset action to footer 2026-08-26 00:20:16 +08:00
RockChinQ 0c5656d0d0 refactor(pipelines): streamline debug chat controls 2026-08-26 00:18:46 +08:00
RockChinQ 532d1b73d2 refactor(bots): group event route creation menu 2026-08-26 00:10:51 +08:00
RockChinQ aaeb9ad178 fix(bots): restore event route drag sorting 2026-08-26 00:08:09 +08:00
RockChinQ d28b385a9f refactor(events): group event selection options 2026-08-25 23:59:52 +08:00
RockChinQ 4d7a333802 feat(agent): add visual event range picker 2026-08-25 23:51:24 +08:00
RockChinQ 114612a984 refactor(agent): move delete action to header 2026-08-25 23:34:35 +08:00
RockChinQ 2aceefce47 test(agent): remove obsolete enabled fixture 2026-08-25 23:28:37 +08:00
RockChinQ 80f1790e1d refactor(agent): remove enabled state 2026-08-25 23:27:45 +08:00
RockChinQ 22d9053bf1 feat(web): streamline management and knowledge settings 2026-08-25 23:02:33 +08:00
RockChinQ db2a9155f8 feat(web): edit entity details from page titles 2026-08-25 22:31:23 +08:00
RockChinQ 69ca7e21cd fix(bots): explain unavailable route tests 2026-08-25 22:03:57 +08:00
RockChinQ 792d961d28 fix(web): clarify route test guidance 2026-08-25 21:53:06 +08:00
RockChinQ a8eb265c11 fix(web): clarify route test warning 2026-08-25 21:31:02 +08:00
RockChinQ e62f8a957a fix(web): simplify route test dialog 2026-08-25 21:27:33 +08:00
RockChinQ ad6b8b3209 fix(web): restore bot card spacing 2026-08-25 21:21:36 +08:00
RockChinQ 68620c4572 fix(bots): simplify event routing status 2026-08-25 21:18:01 +08:00
RockChinQ 6a6a2b865b fix(web): make processor debugging reliable 2026-08-25 15:52:59 +08:00
RockChinQ 781d8a9ac8 fix(web): clean up pipeline debug websocket 2026-08-25 12:50:15 +08:00
RockChinQ e3150e66a4 refactor(web): use shadcn processor navigation 2026-08-25 12:36:57 +08:00
RockChinQ ac31f1f006 feat(web): surface agent basics and runner status 2026-08-25 12:17:59 +08:00
RockChinQ f65cca3f40 fix(web): contain pipeline debug scrolling 2026-08-25 12:03:43 +08:00
RockChinQ e70a0d3f01 fix(web): remove agent debug run notice 2026-08-25 11:58:54 +08:00
RockChinQ 46aea2b499 feat(web): unify processor detail workbench 2026-08-25 11:54:59 +08:00
78 changed files with 5046 additions and 2776 deletions
+1 -2
View File
@@ -34,7 +34,6 @@ class Agent(Base):
kind: str # 固定为 "agent"
component_ref: str # AgentRunner id
config: dict # runner + runner_config
enabled: bool
supported_event_patterns: list[str]
```
@@ -113,7 +112,7 @@ Binding 只保存引用与路由条件。它不复制 Pipeline 或 Agent 配置
1. 忽略 `enabled = false` 的 binding。
2. 检查 `event_pattern` 与结构化 filters。
3. 校验目标存在、启用且声明支持该事件。
3. 校验目标存在且声明支持该事件。
4.`priority` 从高到低选择;同优先级按稳定列表顺序。
5. 只执行一个响应目标。
@@ -70,7 +70,6 @@ class Agent(Base):
kind: str # 首版固定为 "agent"
component_ref: str # runner id / workflow id / future external ref
config: dict # runner 与 runner_config
enabled: bool
supported_event_patterns: list[str]
created_at: datetime
updated_at: datetime
@@ -188,11 +188,7 @@ try {
.getByText(/Event Routing|事件路由|イベントルーティング/)
.first()
.waitFor({ timeout: 15_000 });
await page
.getByText(
/Events this adapter can receive|此适配器可接收的事件|このアダプターが受信できるイベント/,
)
.waitFor();
await page.getByText(/Supported events|支持的事件|対応イベント/).waitFor();
await page
.getByText(/Message received|收到消息|メッセージを受信/)
.first()
@@ -216,11 +212,11 @@ try {
);
await page
.getByRole("button", { name: /Test route|测试路由|ルートをテスト/ })
.getByRole("button", { name: /Check route|检查路由|ルートを確認/ })
.click();
await page.getByRole("dialog").waitFor();
await page
.getByRole("button", { name: /Preview route|预览路由|ルートをプレビュー/ })
.getByRole("button", { name: /View match|查看匹配结果|一致結果を確認/ })
.click();
await page
.getByText(/Route matched|已命中路由|ルートに一致しました/)
@@ -231,28 +227,67 @@ try {
.waitFor();
result.visible_signals.push("dry-run-matched", "discard-target");
await page
.getByRole("button", {
name: /Run saved route|运行已保存路由|保存済みルートを実行/,
})
.click();
await page
.getByText(
/saved route ran successfully|已保存路由运行成功|保存済みルートを実行しました/,
)
.waitFor({ timeout: 20_000 });
result.visible_signals.push("test-event-dispatched");
await page
.getByRole("button", { name: /Close|关闭|閉じる/ })
.first()
.click();
await page.getByRole("dialog").waitFor({ state: "hidden" });
await page
.getByText(/Discarded|已丢弃|破棄済み/)
.first()
.waitFor({ timeout: 10_000 });
result.visible_signals.push("route-status-discarded");
const adapterConfigCard = page.locator('[data-slot="card"]').filter({
has: page.getByText(/Adapter Configuration|适配器配置|アダプター設定/, {
exact: true,
}),
});
await adapterConfigCard
.getByRole("button", {
name: /Listen for platform events|监听平台事件|プラットフォームイベントを監視/,
})
.click();
const adapterDialog = page.getByRole("dialog");
await adapterDialog.waitFor();
await adapterDialog
.getByText(/Listening|正在监听|監視中/, { exact: true })
.waitFor({ timeout: 15_000 });
const inboundText = `adapter event ${paths.runId}`;
const inbound = await apiJson(
backendUrl,
`/bots/${encodeURIComponent(botId)}`,
{
method: "POST",
token,
body: {
session_id: `adapter-debug-${paths.runId}`,
session_type: "person",
sender: { id: "adapter-debug-user", name: "Adapter QA" },
message: [{ type: "Plain", text: inboundText }],
},
},
);
result.api.adapter_event_webhook = {
http_status: inbound.status,
code: inbound.json.code ?? null,
};
if (inbound.status >= 400 || inbound.json.code !== 0) {
throw new Error(
inbound.json.msg || "The HTTP Bot adapter rejected the inbound event.",
);
}
await adapterDialog
.getByText(/Message received|收到消息|メッセージ受信/, { exact: true })
.waitFor({ timeout: 15_000 });
await adapterDialog.getByText("message.received", { exact: true }).waitFor();
await adapterDialog.getByText(inboundText, { exact: true }).waitFor();
result.visible_signals.push(
"adapter-event-listening",
"adapter-event-received",
"adapter-event-raw-code",
);
await adapterDialog
.getByRole("button", { name: /Close|关闭|閉じる/ })
.click();
await adapterDialog.waitFor({ state: "hidden" });
const text = await bodyText(page);
if (/\bEBA event\b/.test(text)) {
@@ -308,7 +343,7 @@ try {
}
result.status = "pass";
result.reason =
"Bot event routing, dry-run, synthetic dispatch, and visible route status passed in the WebUI.";
"Bot event routing, dry-run, real adapter input, and visible route status passed in the WebUI.";
} catch (error) {
if (!["blocked", "env_issue"].includes(result.status)) result.status = "fail";
result.reason = result.reason || error.message;
+1 -7
View File
@@ -64,7 +64,7 @@ The tools wrap the LangBot service layer. Current tools (v1):
| --- | --- |
| `get_system_info` | Version, edition, instance id |
| `list_bots` / `get_bot` / `create_bot` / `update_bot` / `delete_bot` | Manage messaging-platform bots (secrets redacted on read) |
| `list_bot_event_route_statuses` / `test_bot_event_route` | Inspect bot event-route runtime status and dispatch a synthetic test event through saved routes without sending real outbound platform messages |
| `list_bot_event_route_statuses` | Inspect bot event-route runtime status |
| `list_processors` / `get_processor` / `create_processor` / `update_processor` / `delete_processor` | Manage the peer Agent and Pipeline processor types |
| `list_pipelines` / `get_pipeline` / `create_pipeline` / `update_pipeline` / `delete_pipeline` | Manage pipelines |
| `list_llm_models` / `get_llm_model` / `list_embedding_models` / `list_model_providers` | Inspect models & providers |
@@ -78,12 +78,6 @@ shape as the corresponding HTTP API request body. Discover resources with the
`resource.view`; mutations require `resource.manage`. All service calls inherit
the immutable Workspace context authenticated at the MCP transport boundary.
`test_bot_event_route` uses the bot's saved runtime route table, injects a
synthetic event such as `message.received`, and suppresses platform delivery.
It still executes the selected processor, so tools and external services may
have side effects. Use `payload` for sample event fields, for example
`{"message_text": "hello", "chat_type": "private", "chat_id": "u1"}`.
## How to use
1. Get an API key (web UI key, or set `api.global_api_key` in config.yaml).
@@ -1,5 +1,5 @@
id: bot-event-routing-product-flow
title: "Bot event routing can be configured and tested from the WebUI"
title: "Bot event routing and adapter input can be inspected from the WebUI"
mode: agent-browser
area: bot
type: feature
@@ -33,16 +33,18 @@ steps:
- "Confirm the adapter capability summary, friendly event name, target, and route status are visible."
- "Confirm overlapping routes and unmatched-event fallback behavior are explained before save."
- "Open Test event route and run a dry-run against the current form."
- "Run the saved runtime route with a synthetic event."
- "Close the dialog and confirm the route card shows the latest discarded status."
- "Open Platform event debugging from Adapter Configuration and send a real inbound event through the HTTP Bot adapter."
- "Confirm the normalized event, raw event code, payload, and latest discarded route status are visible."
checks:
- "UI: A user can choose a channel and add a scenario-labeled behavior during initial Bot creation."
- "UI: Event routing uses user-facing labels and does not require the raw event name in the primary route card."
- "UI: Definite route shadowing and unmatched-event fallback behavior are visible without opening raw logs."
- "UI: Dry-run visibly reports that the route matched the discard processor."
- "UI: Saved-route execution visibly succeeds, explains its side-effect boundary, and updates route status to discarded."
- "UI: Adapter event debugging lives under Adapter Configuration, remains separate from route preview, and starts listening only after the dialog opens."
- "UI: A real adapter event shows its friendly name, raw code, and normalized event data."
- "UI: The route card updates to discarded after the real inbound event is handled."
- "Console: No unexpected frontend errors appear during the flow."
- "Network: Bot, dry-run, route-status, and test-event requests return without 5xx responses."
- "Network: Bot, dry-run, route-status, log, and HTTP Bot webhook requests return without 5xx responses."
- "Cleanup: The temporary Bot is deleted after evidence is collected."
evidence_required:
- ui
@@ -51,7 +53,8 @@ evidence_required:
- api_diagnostic
diagnostics:
- "The fixture deliberately uses the discard processor so the product-flow test cannot invoke a model, tool, or external callback."
- "A passing API call without the visible matched and discarded UI states is not a pass."
- "The adapter dialog observes normalized platform events; it does not simulate route matching."
- "A passing webhook call without the visible adapter event and discarded UI states is not a pass."
troubleshooting:
- backend-not-listening
- proxy-env-mismatch
@@ -22,7 +22,7 @@ class AgentBindingResolver:
event: AgentEventEnvelope,
agents: list[AgentConfig],
) -> AgentBinding:
"""Resolve exactly one enabled Agent for the event.
"""Resolve exactly one Agent for the event.
Callers that source agents from bot/workspace/global configuration must
pre-filter candidates to the event scope before calling this resolver.
@@ -30,7 +30,7 @@ class AgentBindingResolver:
Agent and does not carry enough scope metadata to make that decision
safely here.
"""
matches = [agent for agent in agents if agent.enabled and event.event_type in agent.event_types]
matches = [agent for agent in agents if event.event_type in agent.event_types]
if not matches:
raise AgentBindingResolutionError(f'No Agent binding matches event_type={event.event_type}')
@@ -59,7 +59,6 @@ class AgentBindingResolver:
resource_policy=agent.resource_policy,
state_policy=agent.state_policy,
delivery_policy=agent.delivery_policy,
enabled=agent.enabled,
agent_id=agent.agent_id,
processor_type=agent.processor_type,
processor_id=agent.processor_id or agent.agent_id,
+15 -1
View File
@@ -1,14 +1,17 @@
"""Agent runner errors."""
from __future__ import annotations
class AgentRunnerError(Exception):
"""Base error for agent runner operations."""
pass
class RunnerNotFoundError(AgentRunnerError):
"""Runner not found in registry."""
def __init__(self, runner_id: str):
self.runner_id = runner_id
super().__init__(f'Agent runner not found: {runner_id}')
@@ -16,6 +19,7 @@ class RunnerNotFoundError(AgentRunnerError):
class RunnerNotAuthorizedError(AgentRunnerError):
"""Runner not authorized for this binding."""
def __init__(self, runner_id: str, bound_plugins: list[str] | None):
self.runner_id = runner_id
self.bound_plugins = bound_plugins
@@ -24,6 +28,7 @@ class RunnerNotAuthorizedError(AgentRunnerError):
class RunnerProtocolError(AgentRunnerError):
"""Runner protocol version mismatch or invalid manifest."""
def __init__(self, runner_id: str, message: str):
self.runner_id = runner_id
super().__init__(f'Agent runner protocol error for {runner_id}: {message}')
@@ -31,7 +36,16 @@ class RunnerProtocolError(AgentRunnerError):
class RunnerExecutionError(AgentRunnerError):
"""Runner execution failed."""
def __init__(self, runner_id: str, message: str, retryable: bool = False):
def __init__(
self,
runner_id: str,
message: str,
retryable: bool = False,
error_code: str | None = None,
):
self.runner_id = runner_id
self.message = message
self.retryable = retryable
self.error_code = error_code
super().__init__(f'Agent runner {runner_id} execution failed: {message}')
@@ -181,9 +181,6 @@ class AgentConfig(pydantic.BaseModel):
event_types: list[str] = pydantic.Field(default_factory=lambda: ['message.received'])
"""Event types this Agent handles."""
enabled: bool = True
"""Whether this Agent can be selected by a binding resolver."""
metadata: dict[str, typing.Any] = pydantic.Field(default_factory=dict)
"""Non-protocol diagnostic metadata, such as legacy config source."""
@@ -219,9 +216,6 @@ class AgentBinding(pydantic.BaseModel):
delivery_policy: DeliveryPolicy = pydantic.Field(default_factory=DeliveryPolicy)
"""Delivery policy."""
enabled: bool = True
"""Whether binding is enabled."""
agent_id: str | None = None
"""Host-side Agent/config identifier for this binding."""
+5 -5
View File
@@ -58,21 +58,21 @@ class AgentRunnerInvoker:
except asyncio.TimeoutError as e:
raise RunnerExecutionError(
descriptor.id,
'Runner timed out (code: runner.timeout)',
'Runner timed out',
retryable=True,
error_code='runner.timeout',
) from e
except ActionCallTimeoutError as e:
raise RunnerExecutionError(
descriptor.id,
f'{e} (code: runner.timeout)',
str(e),
retryable=True,
error_code='runner.timeout',
) from e
except RunnerExecutionError:
raise
except Exception as e:
self.ap.logger.error(
f'Runner {descriptor.id} unexpected error: {traceback.format_exc()}'
)
self.ap.logger.error(f'Runner {descriptor.id} unexpected error: {traceback.format_exc()}')
raise RunnerExecutionError(
descriptor.id,
str(e),
@@ -151,7 +151,6 @@ class QueryEntryAdapter:
state_policy=state_policy,
delivery_policy=delivery_policy,
event_types=[event_type],
enabled=True,
metadata={'source': 'pipeline_adapter'},
)
@@ -152,10 +152,14 @@ class AgentResultNormalizer:
error_msg = data.get('error', 'Unknown error')
error_code = data.get('code', 'unknown')
retryable = data.get('retryable', False)
normalized_error_code = str(error_code or '').strip()
raise RunnerExecutionError(
descriptor.id,
f'{error_msg} (code: {error_code})',
str(error_msg),
retryable=retryable,
error_code=(
normalized_error_code if normalized_error_code and normalized_error_code != 'unknown' else None
),
)
elif result_type == 'action.requested':
@@ -2,6 +2,13 @@ from __future__ import annotations
import quart
from .....agent.runner.errors import (
AgentRunnerError,
RunnerExecutionError,
RunnerNotAuthorizedError,
RunnerNotFoundError,
RunnerProtocolError,
)
from ...authz import Permission, require_permission
from ...context import RequestContext
from .. import group
@@ -63,6 +70,36 @@ class AgentsRouterGroup(group.RouterGroup):
)
except ValueError as exc:
return self.http_status(400, -1, str(exc))
except RunnerExecutionError as exc:
return self.http_status(
422,
exc.error_code or 'runner_execution_failed',
exc.message,
)
except RunnerNotFoundError:
return self.http_status(
409,
'runner_not_found',
'The configured Agent runner is unavailable',
)
except RunnerNotAuthorizedError:
return self.http_status(
403,
'runner_not_authorized',
'The configured Agent runner is not authorized',
)
except RunnerProtocolError:
return self.http_status(
502,
'runner_protocol_error',
'The Agent runner returned an invalid response',
)
except AgentRunnerError:
return self.http_status(
502,
'runner_error',
'The Agent runner could not complete this test',
)
return self.success(data=result)
@self.route(
@@ -90,15 +90,9 @@ class BotsRouterGroup(group.RouterGroup):
permission=Permission.RESOURCE_VIEW,
)
async def _(bot_uuid: str, request_context: RequestContext) -> str:
return self.success(
data=await self.ap.bot_service.list_event_route_statuses(
request_context, bot_uuid
)
)
return self.success(data=await self.ap.bot_service.list_event_route_statuses(request_context, bot_uuid))
async def _dry_run_event_route(
bot_uuid: str, request_context: RequestContext
) -> str:
async def _dry_run_event_route(bot_uuid: str, request_context: RequestContext) -> str:
json_data = await quart.request.json
if not isinstance(json_data, dict):
return self.http_status(400, -1, 'invalid request body')
@@ -128,24 +122,6 @@ class BotsRouterGroup(group.RouterGroup):
permission=Permission.RESOURCE_VIEW,
)(_dry_run_event_route)
@self.route(
'/<bot_uuid>/event-routes/test',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RUNTIME_OPERATE,
)
async def _(bot_uuid: str, request_context: RequestContext) -> str:
json_data = await quart.request.json
if not isinstance(json_data, dict):
return self.http_status(400, -1, 'invalid request body')
result = await self.ap.bot_service.dispatch_test_event_route(
request_context,
bot_uuid=bot_uuid,
event_type=json_data.get('event_type'),
payload=json_data.get('event_data', json_data.get('payload')),
)
return self.success(data=result)
@self.route(
'/<bot_uuid>/send_message',
methods=['POST'],
+5 -14
View File
@@ -201,7 +201,6 @@ class AgentService:
enable_reply=False,
enable_interactions=False,
),
enabled=True,
agent_id=agent_uuid,
processor_type='agent',
processor_id=agent_uuid,
@@ -293,7 +292,6 @@ class AgentService:
'kind': AGENT_KIND_AGENT,
'component_ref': runner_id,
'config': config,
'enabled': agent_data.get('enabled', True),
'supported_event_patterns': agent_data.get('supported_event_patterns') or AGENT_DEFAULT_EVENT_PATTERNS,
}
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_agent.Agent).values(**values))
@@ -308,17 +306,11 @@ class AgentService:
await self.ap.pipeline_service.update_pipeline(context, agent_uuid, agent_data)
return
update_data = agent_data.copy()
for protected_field in (
'uuid',
'workspace_uuid',
'kind',
'component_ref',
'created_at',
'updated_at',
'capability',
):
update_data.pop(protected_field, None)
update_data = {
field: agent_data[field]
for field in ('name', 'description', 'emoji', 'config', 'supported_event_patterns')
if field in agent_data
}
if 'config' in update_data:
config, runner_id, _ = RunnerConfigResolver.resolve_agent_runner_config(update_data['config'])
update_data['config'] = config
@@ -425,7 +417,6 @@ class AgentService:
item = pipeline.copy()
item['kind'] = AGENT_KIND_PIPELINE
item['component_ref'] = 'pipeline'
item['enabled'] = True
item['supported_event_patterns'] = PIPELINE_EVENT_PATTERNS
item['capability'] = {
'supported_event_patterns': PIPELINE_EVENT_PATTERNS,
+26 -103
View File
@@ -18,7 +18,6 @@ class BotService:
ap: app.Application
FAILURE_ROUTE_NOT_FOUND = 'route_not_found'
FAILURE_PROCESSOR_DISABLED = 'processor_disabled'
FAILURE_PROCESSOR_NOT_FOUND = 'processor_not_found'
FAILURE_PROCESSOR_INCOMPATIBLE = 'processor_incompatible'
FAILURE_INVALID_EVENT = 'invalid_event'
@@ -276,14 +275,10 @@ class BotService:
)
return result.first()
async def _get_agent_entity(
self, context: TenantContext, agent_uuid: str
) -> persistence_agent.Agent | None:
async def _get_agent_entity(self, context: TenantContext, agent_uuid: str) -> persistence_agent.Agent | None:
result = await self.ap.persistence_mgr.execute_async(
scope_statement(
sqlalchemy.select(persistence_agent.Agent).where(
persistence_agent.Agent.uuid == agent_uuid
),
sqlalchemy.select(persistence_agent.Agent).where(persistence_agent.Agent.uuid == agent_uuid),
persistence_agent.Agent,
context,
)
@@ -390,11 +385,7 @@ class BotService:
}
],
)
pipeline = (
await self._get_pipeline_entity(tenant_context, target_uuid)
if target_uuid
else None
)
pipeline = await self._get_pipeline_entity(tenant_context, target_uuid) if target_uuid else None
if pipeline is None:
return self._diagnostic_result(
matched=False,
@@ -443,25 +434,6 @@ class BotService:
}
],
)
if not getattr(agent, 'enabled', True):
return self._diagnostic_result(
matched=False,
binding=selected_binding,
failure_code=self.FAILURE_PROCESSOR_DISABLED,
reason='Agent target is disabled',
diagnostic_steps=diagnostic_steps
+ [
{
'step': 'validate_processor',
'binding_id': selected_binding.get('id'),
'target_type': target_type,
'target_uuid': target_uuid,
'matched': False,
'failure_code': self.FAILURE_PROCESSOR_DISABLED,
'reason': 'Agent target is disabled',
}
],
)
if not RuntimeBot._agent_supports_event_type(getattr(agent, 'supported_event_patterns', None), event_type):
return self._diagnostic_result(
matched=False,
@@ -509,9 +481,7 @@ class BotService:
],
)
async def _normalize_event_bindings(
self, context: TenantContext, bindings: list[dict] | None
) -> list[dict]:
async def _normalize_event_bindings(self, context: TenantContext, bindings: list[dict] | None) -> list[dict]:
"""Validate and normalize Bot event bindings."""
if not bindings:
return []
@@ -544,9 +514,7 @@ class BotService:
elif target_type == 'agent':
result = await self.ap.persistence_mgr.execute_async(
scope_statement(
sqlalchemy.select(persistence_agent.Agent).where(
persistence_agent.Agent.uuid == target_uuid
),
sqlalchemy.select(persistence_agent.Agent).where(persistence_agent.Agent.uuid == target_uuid),
persistence_agent.Agent,
context,
)
@@ -577,9 +545,7 @@ class BotService:
return normalized
async def _prepare_bot_data(
self, context: TenantContext, bot_data: dict, *, include_uuid: bool
) -> dict:
async def _prepare_bot_data(self, context: TenantContext, bot_data: dict, *, include_uuid: bool) -> dict:
"""Normalize Bot write payloads to the current event-routing model."""
update_data = bot_data.copy()
if not include_uuid:
@@ -705,6 +671,17 @@ class BotService:
)
if getattr(result, 'rowcount', None) == 0:
raise WorkspaceNotFoundError('Bot not found')
runtime_fields = {'adapter', 'adapter_config', 'enable', 'event_bindings'}
if not runtime_fields.intersection(update_data):
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(context, bot_uuid)
if runtime_bot is not None:
if 'name' in update_data:
runtime_bot.bot_entity.name = update_data['name']
if 'description' in update_data:
runtime_bot.bot_entity.description = update_data['description']
return
await self.ap.platform_mgr.remove_bot(context, bot_uuid)
# select from db
@@ -750,21 +727,19 @@ class BotService:
return [log.to_json() for log in logs], total_count
async def list_event_route_statuses(
self, context: TenantContext, bot_uuid: str
) -> dict[str, typing.Any]:
async def list_event_route_statuses(self, context: TenantContext, bot_uuid: str) -> dict[str, typing.Any]:
"""Return recent runtime status for Bot event routes from in-memory Bot logs."""
from ....platform.botmgr import RuntimeBot
if await self.get_bot(context, bot_uuid, include_secret=False) is None:
bot = await self.get_bot(context, bot_uuid, include_secret=False)
if bot is None:
raise WorkspaceNotFoundError('Bot not found')
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(context, bot_uuid)
if runtime_bot is None:
raise Exception('Bot not found')
latest_by_binding: dict[str, dict[str, typing.Any]] = {}
unmatched_events: list[dict[str, typing.Any]] = []
for log in getattr(runtime_bot.logger, 'logs', []):
runtime_logs = getattr(getattr(runtime_bot, 'logger', None), 'logs', [])
for log in runtime_logs:
status = self._event_route_status_from_log(log)
if status is None:
continue
@@ -774,7 +749,10 @@ class BotService:
else:
unmatched_events.append(status)
raw_bindings = getattr(getattr(runtime_bot, 'bot_entity', None), 'event_bindings', [])
runtime_entity = getattr(runtime_bot, 'bot_entity', None)
raw_bindings = getattr(runtime_entity, 'event_bindings', None) if runtime_entity is not None else None
if raw_bindings is None:
raw_bindings = bot.get('event_bindings') or []
bindings = RuntimeBot._get_event_bindings_from_value(raw_bindings)
routes: list[dict[str, typing.Any]] = []
current_binding_ids: set[str] = set()
@@ -819,61 +797,6 @@ class BotService:
'stale_routes': stale_routes,
}
async def dispatch_test_event_route(
self,
context: TenantContext,
bot_uuid: str,
event_type: str,
payload: dict[str, typing.Any] | None = None,
) -> dict[str, typing.Any]:
"""Dispatch a synthetic event through the saved Bot runtime route configuration."""
event_type = str(event_type or '').strip()
if not event_type:
return {
'dispatched': False,
'event_type': '',
'failure_code': self.FAILURE_INVALID_EVENT,
'reason': 'event_type is required',
'suppressed_outputs': [],
'route_status': {
'routes': [],
'unmatched_events': [],
'stale_routes': [],
},
}
if payload is not None and not isinstance(payload, dict):
return {
'dispatched': False,
'event_type': event_type,
'failure_code': self.FAILURE_INVALID_EVENT,
'reason': 'payload must be an object',
'suppressed_outputs': [],
'route_status': {
'routes': [],
'unmatched_events': [],
'stale_routes': [],
},
}
if await self.get_bot(context, bot_uuid, include_secret=False) is None:
raise WorkspaceNotFoundError('Bot not found')
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(context, bot_uuid)
if runtime_bot is None:
raise Exception('Bot not found')
dispatch_result = await runtime_bot.dispatch_test_event(event_type, payload or {})
route_status = await self.list_event_route_statuses(context, bot_uuid)
return {
'dispatched': bool(dispatch_result.get('dispatched')),
'event_type': event_type,
'status': dispatch_result.get('status'),
'binding_id': dispatch_result.get('binding_id'),
'failure_code': dispatch_result.get('failure_code'),
'reason': dispatch_result.get('reason'),
'suppressed_outputs': dispatch_result.get('suppressed_outputs', []),
'route_status': route_status,
}
async def send_message(
self,
context: TenantContext,
-19
View File
@@ -132,25 +132,6 @@ class LangBotMCPServer:
async def list_bot_event_route_statuses(bot_uuid: str) -> str:
return _dump(await ap.bot_service.list_event_route_statuses(bot_uuid))
@mcp.tool(
description=(
'Dispatch a synthetic event through the saved bot event routes. '
'This validates routing without sending real outbound platform messages.'
)
)
async def test_bot_event_route(
bot_uuid: str,
event_type: str,
payload: dict | None = None,
) -> str:
return _dump(
await ap.bot_service.dispatch_test_event_route(
bot_uuid=bot_uuid,
event_type=event_type,
payload=payload,
)
)
# ----- Pipelines ----------------------------------------------- #
@mcp.tool(description='List all pipelines.')
async def list_pipelines() -> str:
@@ -20,7 +20,6 @@ class Agent(Base):
kind = sqlalchemy.Column(sqlalchemy.String(50), nullable=False, default='agent')
component_ref = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
enabled = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=True)
supported_event_patterns = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=['*'])
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
updated_at = sqlalchemy.Column(
@@ -0,0 +1,37 @@
"""Drop the obsolete Agent enabled state.
Revision ID: 0023_drop_agent_enabled
Revises: 0022_merge_agent_reasoning_heads
Create Date: 2026-08-25
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = '0023_drop_agent_enabled'
down_revision = '0022_merge_agent_reasoning_heads'
branch_labels = None
depends_on = None
def _column_exists(inspector: sa.Inspector, table_name: str, column_name: str) -> bool:
if table_name not in inspector.get_table_names():
return False
return any(column['name'] == column_name for column in inspector.get_columns(table_name))
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
if _column_exists(inspector, 'agents', 'enabled'):
with op.batch_alter_table('agents') as batch_op:
batch_op.drop_column('enabled')
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
if 'agents' in inspector.get_table_names() and not _column_exists(inspector, 'agents', 'enabled'):
with op.batch_alter_table('agents') as batch_op:
batch_op.add_column(sa.Column('enabled', sa.Boolean(), nullable=False, server_default=sa.true()))
@@ -25,7 +25,7 @@ class BanWordFilter(filter_model.ContentFilter):
return entities.FilterResult(
level=entities.ResultLevel.BLOCK,
replacement='',
user_notice='内容检查规则执行失败,请联系管理员',
user_notice='内容安全检查配置有误,请检查敏感词设置',
console_notice=f'Sensitive-word regex rejected: {exc}',
)
+27 -361
View File
@@ -51,226 +51,6 @@ from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
class SyntheticRouteTestAdapter:
"""Adapter wrapper that suppresses outbound platform delivery for test events."""
SIDE_EFFECT_API_NAMES = {
'send_message',
'reply_message',
'reply_message_chunk',
'create_message_card',
'edit_message',
'delete_message',
'add_reaction',
'remove_reaction',
'forward_message',
'set_group_name',
'mute_member',
'unmute_member',
'kick_member',
'leave_group',
'approve_friend_request',
'approve_group_invite',
'upload_file',
'call_platform_api',
}
def __init__(self, source: abstract_platform_adapter.AbstractMessagePlatformAdapter):
self.source = source
self.bot_account_id = getattr(source, 'bot_account_id', '')
self.config = getattr(source, 'config', {})
self.logger = getattr(source, 'logger', None)
self.suppressed_outputs: list[dict[str, typing.Any]] = []
@staticmethod
def _message_to_payload(message: platform_message.MessageChain) -> typing.Any:
return message.model_dump() if hasattr(message, 'model_dump') else str(message)
def _suppress(self, method: str, **payload: typing.Any) -> None:
self.suppressed_outputs.append({'method': method, **payload})
def __getattr__(self, name: str) -> typing.Any:
return getattr(self.source, name)
def get_supported_apis(self) -> list[str]:
get_supported_apis = getattr(self.source, 'get_supported_apis', None)
if not callable(get_supported_apis):
return []
return [api_name for api_name in get_supported_apis() if api_name not in self.SIDE_EFFECT_API_NAMES]
async def send_message(
self,
target_type: str,
target_id: str,
message: platform_message.MessageChain,
) -> dict[str, typing.Any]:
self._suppress(
'send_message',
target_type=target_type,
target_id=target_id,
message=self._message_to_payload(message),
)
return {'suppressed': True}
async def reply_message(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
quote_origin: bool = False,
) -> dict[str, typing.Any]:
self._suppress(
'reply_message',
message=self._message_to_payload(message),
quote_origin=quote_origin,
)
return {'suppressed': True}
async def reply_message_chunk(
self,
message_source: platform_events.MessageEvent,
bot_message: dict,
message: platform_message.MessageChain,
quote_origin: bool = False,
is_final: bool = False,
) -> dict[str, typing.Any]:
self._suppress(
'reply_message_chunk',
message=self._message_to_payload(message),
quote_origin=quote_origin,
is_final=is_final,
)
return {'suppressed': True}
async def create_message_card(
self,
message_id: str | int,
event: platform_events.MessageEvent,
) -> bool:
self._suppress('create_message_card', message_id=str(message_id))
return False
async def is_stream_output_supported(self) -> bool:
return False
async def edit_message(
self,
chat_type: str,
chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
new_content: platform_message.MessageChain,
) -> None:
self._suppress(
'edit_message',
chat_type=str(chat_type),
chat_id=str(chat_id),
message_id=str(message_id),
new_content=self._message_to_payload(new_content),
)
async def delete_message(
self,
chat_type: str,
chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
) -> None:
self._suppress(
'delete_message',
chat_type=str(chat_type),
chat_id=str(chat_id),
message_id=str(message_id),
)
async def forward_message(
self,
from_chat_type: str,
from_chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
to_chat_type: str,
to_chat_id: typing.Union[int, str],
) -> platform_events.MessageResult:
self._suppress(
'forward_message',
from_chat_type=str(from_chat_type),
from_chat_id=str(from_chat_id),
message_id=str(message_id),
to_chat_type=str(to_chat_type),
to_chat_id=str(to_chat_id),
)
return platform_events.MessageResult(raw={'suppressed': True})
async def set_group_name(
self,
group_id: typing.Union[int, str],
name: str,
) -> None:
self._suppress('set_group_name', group_id=str(group_id), name=name)
async def mute_member(
self,
group_id: typing.Union[int, str],
user_id: typing.Union[int, str],
duration: int = 0,
) -> None:
self._suppress(
'mute_member',
group_id=str(group_id),
user_id=str(user_id),
duration=duration,
)
async def unmute_member(
self,
group_id: typing.Union[int, str],
user_id: typing.Union[int, str],
) -> None:
self._suppress('unmute_member', group_id=str(group_id), user_id=str(user_id))
async def kick_member(
self,
group_id: typing.Union[int, str],
user_id: typing.Union[int, str],
) -> None:
self._suppress('kick_member', group_id=str(group_id), user_id=str(user_id))
async def leave_group(
self,
group_id: typing.Union[int, str],
) -> None:
self._suppress('leave_group', group_id=str(group_id))
async def approve_friend_request(
self,
request_id: typing.Union[int, str],
approve: bool = True,
remark: str | None = None,
) -> None:
self._suppress(
'approve_friend_request',
request_id=str(request_id),
approve=approve,
remark=remark,
)
async def approve_group_invite(
self,
request_id: typing.Union[int, str],
approve: bool = True,
) -> None:
self._suppress(
'approve_group_invite',
request_id=str(request_id),
approve=approve,
)
async def upload_file(self, file_data: bytes, filename: str) -> str:
self._suppress('upload_file', filename=filename, size=len(file_data))
return f'suppressed:{filename}'
async def call_platform_api(self, action: str, params: dict | None = None) -> dict:
self._suppress('call_platform_api', action=action, params=params or {})
return {'suppressed': True}
class RuntimeBot:
"""运行时机器人"""
@@ -568,126 +348,6 @@ class RuntimeBot:
"""Return the selected event binding plus per-binding diagnostic steps."""
return self._evaluate_eba_event_bindings(self._get_event_bindings(), event, event_type)
@staticmethod
def _build_test_platform_event(
event_type: str,
payload: dict[str, typing.Any] | None = None,
) -> platform_events.EBAEvent:
"""Build a synthetic platform event for route validation."""
payload = payload or {}
now = time.time()
common = {
'type': event_type,
'timestamp': payload.get('timestamp') or now,
'adapter_name': payload.get('adapter_name') or 'test-event',
'source_platform_object': {'synthetic': True, 'payload': payload},
}
user_id = str(payload.get('user_id') or payload.get('sender_id') or 'test-user')
user_name = str(payload.get('user_name') or payload.get('sender_name') or 'Test User')
group_id = str(payload.get('group_id') or payload.get('chat_id') or 'test-group')
group_name = str(payload.get('group_name') or 'Test Group')
if event_type == 'message.received':
chat_type_value = str(payload.get('chat_type') or 'private')
chat_type = (
platform_entities.ChatType.GROUP
if chat_type_value == platform_entities.ChatType.GROUP.value
else platform_entities.ChatType.PRIVATE
)
chat_id = str(
payload.get('chat_id') or (group_id if chat_type == platform_entities.ChatType.GROUP else user_id)
)
message_text = str(payload.get('message_text') or payload.get('text') or '')
message_chain_data = payload.get('message_chain')
if message_chain_data is None:
message_chain = platform_message.MessageChain([platform_message.Plain(text=message_text)])
else:
message_chain = platform_message.MessageChain.model_validate(message_chain_data)
group = (
platform_entities.UserGroup(id=chat_id, name=group_name)
if chat_type == platform_entities.ChatType.GROUP
else None
)
return platform_events.MessageReceivedEvent(
**common,
message_id=str(payload.get('message_id') or f'test-message:{uuid.uuid4()}'),
message_chain=message_chain,
sender=platform_entities.User(id=user_id, nickname=user_name),
chat_type=chat_type,
chat_id=chat_id,
group=group,
)
if event_type == 'group.member_joined':
return platform_events.MemberJoinedEvent(
**common,
group=platform_entities.UserGroup(id=group_id, name=group_name),
member=platform_entities.User(id=user_id, nickname=user_name),
inviter=platform_entities.User(
id=str(payload.get('inviter_id')),
nickname=str(payload.get('inviter_name') or ''),
)
if payload.get('inviter_id')
else None,
join_type=payload.get('join_type'),
)
if event_type == 'group.member_left':
return platform_events.MemberLeftEvent(
**common,
group=platform_entities.UserGroup(id=group_id, name=group_name),
member=platform_entities.User(id=user_id, nickname=user_name),
is_kicked=bool(payload.get('is_kicked', False)),
operator=platform_entities.User(
id=str(payload.get('operator_id')),
nickname=str(payload.get('operator_name') or ''),
)
if payload.get('operator_id')
else None,
)
if event_type == 'platform.specific':
return platform_events.PlatformSpecificEvent(
**common,
action=str(payload.get('action') or 'test'),
data=payload.get('data') if isinstance(payload.get('data'), dict) else payload,
)
return platform_events.EBAEvent(**common)
async def dispatch_test_event(
self,
event_type: str,
payload: dict[str, typing.Any] | None = None,
) -> dict[str, typing.Any]:
"""Dispatch a synthetic event through the real runtime route path."""
event_type = str(event_type or '').strip()
if not event_type:
raise ValueError('event_type is required')
event = self._build_test_platform_event(event_type, payload)
await self._record_event_route_trace(
event_type=event_type,
status='test_started',
reason='Synthetic test event dispatched from control plane',
text=f'Test event {event_type} dispatched from control plane',
)
test_adapter = SyntheticRouteTestAdapter(self.adapter)
outcome = await self._dispatch_eba_event_to_processor(
event,
typing.cast(abstract_platform_adapter.AbstractMessagePlatformAdapter, test_adapter),
)
return {
'event_type': event_type,
'dispatched': outcome['status'] in {'delivered', 'discarded'},
'status': outcome['status'],
'binding_id': outcome.get('binding_id'),
'failure_code': outcome.get('failure_code'),
'reason': outcome.get('reason'),
'suppressed_outputs': test_adapter.suppressed_outputs,
}
async def _record_event_route_trace(
self,
*,
@@ -784,6 +444,26 @@ class RuntimeBot:
compact[key] = value
return compact
async def _record_adapter_event(
self,
event: platform_events.EBAEvent,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
) -> dict[str, typing.Any]:
"""Record a normalized adapter event for the platform debugging surface."""
event_type = getattr(event, 'type', None) or event.__class__.__name__
metadata = {
'kind': 'adapter_event_received',
'event_type': event_type,
'event_data': self._compact_event_data(event),
'adapter': getattr(self.bot_entity, 'adapter', None) or adapter.__class__.__name__,
'bot_uuid': self.bot_entity.uuid,
}
await self.logger.info(
f'Platform adapter received {event_type}',
metadata=metadata,
)
return metadata
@staticmethod
def _get_entity_id(entity: typing.Any) -> str | None:
entity_id = getattr(entity, 'id', None)
@@ -1135,7 +815,6 @@ class RuntimeBot:
enable_reply=True,
enable_interactions=True,
),
enabled=True,
agent_id=agent.get('uuid'),
processor_type='agent',
processor_id=agent.get('uuid'),
@@ -1171,7 +850,7 @@ class RuntimeBot:
self,
envelope: AgentEventEnvelope,
outputs: list[provider_message.Message | provider_message.MessageChunk],
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter | SyntheticRouteTestAdapter | None = None,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter | None = None,
) -> None:
if not outputs or not envelope.delivery.reply_target:
return
@@ -1205,11 +884,13 @@ class RuntimeBot:
event: platform_events.EBAEvent,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
) -> None:
event.bot_uuid = self.bot_entity.uuid
await self._record_adapter_event(event, adapter)
if isinstance(event, platform_events.PlatformSpecificEvent) and event.action == 'interaction.submitted':
await self._handle_interaction_submission(event, adapter)
return
event.bot_uuid = self.bot_entity.uuid
plugin_event = self._eba_event_to_plugin_event(event)
if plugin_event is not None:
@@ -1322,17 +1003,6 @@ class RuntimeBot:
reason='Agent target not found',
text=f'EBA event {event_type} target agent not found: {target_uuid}',
)
if not agent.get('enabled', True):
return await self._record_event_route_trace(
event_type=event_type,
status='failed',
binding=event_binding,
target_type=target_type,
target_uuid=target_uuid,
failure_code='processor_disabled',
reason='Agent target is disabled',
text=f'EBA event {event_type} target agent disabled: {target_uuid}',
)
if not self._agent_supports_event_type(agent.get('supported_event_patterns'), event_type):
return await self._record_event_route_trace(
event_type=event_type,
@@ -1711,7 +1381,7 @@ class RuntimeBot:
self.execution_context,
record['processor_id'],
)
if not agent or agent.get('kind') != 'agent' or not agent.get('enabled', True):
if not agent or agent.get('kind') != 'agent':
raise ValueError(f'Interaction target Agent is unavailable: {record["processor_id"]}')
binding = self._agent_product_to_binding(
@@ -1810,9 +1480,7 @@ class RuntimeBot:
def tenant_scoped_listener(listener):
@functools.wraps(listener)
async def wrapped(*args, **kwargs):
tenant_scope = getattr(
self.ap.persistence_mgr, 'tenant_scope', None
)
tenant_scope = getattr(self.ap.persistence_mgr, 'tenant_scope', None)
cloud_runtime = (
getattr(
getattr(self.ap.persistence_mgr, 'mode', None),
@@ -1823,9 +1491,7 @@ class RuntimeBot:
)
if cloud_runtime:
if not callable(tenant_scope):
raise RuntimeError(
'Cloud platform callbacks require a tenant scope'
)
raise RuntimeError('Cloud platform callbacks require a tenant scope')
async with tenant_scope(self.workspace_uuid):
return await listener(*args, **kwargs)
return await listener(*args, **kwargs)
@@ -232,7 +232,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
@staticmethod
def _history_message_chain(message_chain: list[dict]) -> list[dict]:
"""Remove large transient payloads before retaining browser history."""
"""Retain renderable references without storing large inline payloads."""
history = []
for component in message_chain:
@@ -551,7 +551,10 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
Image / Voice / File components uploaded from the web client carry a
storage key in ``path``. Resolve it to a base64 data URI so downstream
stages (multimodal LLM input and the Box sandbox inbox) have a usable
payload, then drop the now-consumed storage object.
payload. Keep image objects for the short-lived browser history so the
authenticated image endpoint can render them; normal upload retention
cleanup removes them later. Other attachment types are consumed
immediately because the chat history does not render them by path.
Args:
message_chain_obj: 消息链对象列表
@@ -606,12 +609,13 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
mime_type = mimetypes.guess_type(comp_path)[0] or 'application/octet-stream'
component['base64'] = f'data:{mime_type};base64,{base64_str}'
await storage_mgr.delete_scoped_object_key(
execution_context,
comp_path,
expected_owner_type='upload_image',
)
component['path'] = ''
if comp_type != 'Image':
await storage_mgr.delete_scoped_object_key(
execution_context,
comp_path,
expected_owner_type='upload_image',
)
component['path'] = ''
except Exception as e:
await self.logger.error(f'Failed to load {comp_type} file {comp_path}: {e}')
raise
+4 -1
View File
@@ -7,7 +7,10 @@ from collections.abc import Sequence
import regex
MAX_PATTERN_COUNT = 64
# The bundled sensitive-word list already contains more than 64 entries. Keep
# the deterministic cap, but leave enough room for the built-in defaults and
# reasonable administrator customisation.
MAX_PATTERN_COUNT = 256
MAX_PATTERN_CHARS = 1024
MAX_INPUT_CHARS = 1024 * 1024
MAX_REPLACEMENT_CHARS = 64
-41
View File
@@ -171,18 +171,6 @@ def fake_bot_app():
'diagnostic_details': [{'step': 'evaluate_binding', 'binding_id': 'binding-1', 'matched': True}],
}
)
app.bot_service.dispatch_test_event_route = AsyncMock(
return_value={
'dispatched': True,
'event_type': 'message.received',
'suppressed_outputs': [],
'route_status': {
'routes': [],
'unmatched_events': [],
'stale_routes': [],
},
}
)
app.bot_service.send_message = AsyncMock()
# Platform manager
@@ -373,35 +361,6 @@ class TestBotEventRouteStatusEndpoint:
fake_bot_app.bot_service.list_event_route_statuses.assert_awaited_with(ANY, 'test-bot-uuid')
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestBotEventRouteTestEndpoint:
"""Tests for bot event route synthetic dispatch endpoint."""
@pytest.mark.asyncio
async def test_dispatch_test_event_route_success(self, quart_test_client, fake_bot_app):
"""POST test route dispatches a synthetic event."""
response = await quart_test_client.post(
'/api/v1/platform/bots/test-bot-uuid/event-routes/test',
headers={'Authorization': 'Bearer test_token'},
json={
'event_type': 'message.received',
'payload': {'message_text': 'hello'},
},
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert data['data']['dispatched'] is True
assert data['data']['event_type'] == 'message.received'
fake_bot_app.bot_service.dispatch_test_event_route.assert_awaited_with(
ANY,
bot_uuid='test-bot-uuid',
event_type='message.received',
payload={'message_text': 'hello'},
)
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestBotSendMessageEndpoint:
"""Tests for bot send message endpoint."""
@@ -108,7 +108,7 @@ class TestSQLiteMigrationUpgrade:
await run_alembic_upgrade(sqlite_engine, 'head')
assert await get_alembic_current(sqlite_engine) == _get_script_head()
assert _get_script_head() == '0022_merge_agent_reasoning_heads'
assert _get_script_head() == '0023_drop_agent_enabled'
@pytest.mark.asyncio
async def test_upgrade_from_development_workspace_head_to_merged_head(self, sqlite_engine):
@@ -120,7 +120,7 @@ class TestSQLiteMigrationUpgrade:
await run_alembic_upgrade(sqlite_engine, 'head')
assert await get_alembic_current(sqlite_engine) == _get_script_head()
assert _get_script_head() == '0022_merge_agent_reasoning_heads'
assert _get_script_head() == '0023_drop_agent_enabled'
@pytest.mark.asyncio
async def test_upgrade_from_reasoning_config_head_to_merged_head(self, sqlite_engine):
@@ -131,7 +131,23 @@ class TestSQLiteMigrationUpgrade:
await run_alembic_stamp(sqlite_engine, '0018_llm_reasoning_config')
await run_alembic_upgrade(sqlite_engine, 'head')
assert await get_alembic_current(sqlite_engine) == '0022_merge_agent_reasoning_heads'
assert await get_alembic_current(sqlite_engine) == '0023_drop_agent_enabled'
@pytest.mark.asyncio
async def test_upgrade_removes_agent_enabled_column(self, sqlite_engine):
async with sqlite_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await conn.exec_driver_sql('ALTER TABLE agents ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT 1')
await run_alembic_stamp(sqlite_engine, '0022_merge_agent_reasoning_heads')
await run_alembic_upgrade(sqlite_engine, 'head')
async with sqlite_engine.connect() as conn:
columns = await conn.run_sync(
lambda sync_conn: {column['name'] for column in sqlalchemy.inspect(sync_conn).get_columns('agents')}
)
assert 'enabled' not in columns
@pytest.mark.asyncio
async def test_upgrade_from_baseline_to_head(self, sqlite_engine):
+1 -27
View File
@@ -56,18 +56,6 @@ def build_ap() -> SimpleNamespace:
ap.bot_service = SimpleNamespace(
get_bots=AsyncMock(return_value=[{'uuid': 'bot-1', 'name': 'Demo Bot', 'adapter': 'telegram'}]),
list_event_route_statuses=AsyncMock(return_value={'routes': [], 'unmatched_events': [], 'stale_routes': []}),
dispatch_test_event_route=AsyncMock(
return_value={
'dispatched': True,
'event_type': 'message.received',
'suppressed_outputs': [],
'route_status': {
'routes': [],
'unmatched_events': [],
'stale_routes': [],
},
}
),
)
ap.pipeline_service = SimpleNamespace(get_pipelines=AsyncMock(return_value=[{'uuid': 'pl-1', 'name': 'default'}]))
ap.llm_model_service = SimpleNamespace(get_llm_models=AsyncMock(return_value=[]))
@@ -126,7 +114,7 @@ async def main() -> int:
tools = await session.list_tools()
names = [t.name for t in tools.tools]
print(f'PASS: listed {len(names)} tools')
for required in ('list_bots', 'get_system_info', 'list_skills', 'test_bot_event_route'):
for required in ('list_bots', 'get_system_info', 'list_skills'):
if required not in names:
failures.append(f'missing tool {required}')
@@ -144,20 +132,6 @@ async def main() -> int:
else:
print('PASS: get_system_info returned version')
res3 = await session.call_tool(
'test_bot_event_route',
{
'bot_uuid': 'bot-1',
'event_type': 'message.received',
'payload': {'message_text': 'hello'},
},
)
text3 = res3.content[0].text if res3.content else ''
if '"dispatched": true' not in text3:
failures.append(f'test_bot_event_route wrong: {text3!r}')
else:
print('PASS: test_bot_event_route returned dispatch result')
shutdown.set()
with contextlib.suppress(Exception):
await asyncio.wait_for(server_task, timeout=5)
@@ -74,7 +74,6 @@ class TestContextValidation:
runner_id='plugin:test/plugin/runner',
runner_config={'timeout': 300},
agent_id='pipeline_1',
enabled=True,
)
def _make_resources(self) -> BuilderResources:
@@ -1,4 +1,5 @@
"""Tests for EventLog, Transcript, and history/event APIs."""
from __future__ import annotations
import datetime
@@ -24,45 +25,46 @@ from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryCo
def make_event_envelope(
event_id: str = "evt_1",
event_type: str = "message.received",
conversation_id: str | None = "conv_1",
actor_id: str | None = "user_1",
input_text: str = "Hello",
event_id: str = 'evt_1',
event_type: str = 'message.received',
conversation_id: str | None = 'conv_1',
actor_id: str | None = 'user_1',
input_text: str = 'Hello',
) -> AgentEventEnvelope:
"""Create a test event envelope."""
return AgentEventEnvelope(
event_id=event_id,
event_type=event_type,
event_time=1700000000,
source="platform",
bot_id="bot_1",
source='platform',
bot_id='bot_1',
workspace_id=None,
conversation_id=conversation_id,
thread_id=None,
actor=ActorContext(
actor_type="user",
actor_type='user',
actor_id=actor_id,
actor_name="Test User",
) if actor_id else None,
actor_name='Test User',
)
if actor_id
else None,
subject=None,
input=AgentInput(text=input_text),
delivery=DeliveryContext(surface="test"),
delivery=DeliveryContext(surface='test'),
)
def make_binding(runner_id: str = "plugin:test/plugin/runner") -> AgentBinding:
def make_binding(runner_id: str = 'plugin:test/plugin/runner') -> AgentBinding:
"""Create a test binding."""
return AgentBinding(
binding_id="binding_1",
scope=BindingScope(scope_type="agent", scope_id="pipeline_1"),
event_types=["message.received"],
binding_id='binding_1',
scope=BindingScope(scope_type='agent', scope_id='pipeline_1'),
event_types=['message.received'],
runner_id=runner_id,
runner_config={},
resource_policy=ResourcePolicy(),
state_policy=StatePolicy(),
delivery_policy=DeliveryPolicy(),
enabled=True,
)
@@ -84,19 +86,19 @@ class TestEventLogStore:
mock_factory.return_value.__aenter__.return_value = mock_session
event_id = await store.append_event(
event_id="evt_1",
event_type="message.received",
source="platform",
bot_id="bot_1",
conversation_id="conv_1",
actor_type="user",
actor_id="user_1",
input_summary="Hello world",
run_id="run_1",
runner_id="plugin:test/plugin/runner",
event_id='evt_1',
event_type='message.received',
source='platform',
bot_id='bot_1',
conversation_id='conv_1',
actor_type='user',
actor_id='user_1',
input_summary='Hello world',
run_id='run_1',
runner_id='plugin:test/plugin/runner',
)
assert event_id == "evt_1"
assert event_id == 'evt_1'
stored_event = mock_session.add.call_args.args[0]
assert stored_event.metadata_json is None
@@ -115,20 +117,20 @@ class TestEventLogStore:
mock_factory.return_value.__aenter__.return_value = mock_session
event_id = await store.append_event(
event_id="evt_steering",
event_type="message.received",
source="platform",
run_id="run_1",
runner_id="plugin:test/plugin/runner",
event_id='evt_steering',
event_type='message.received',
source='platform',
run_id='run_1',
runner_id='plugin:test/plugin/runner',
metadata={
"steering": {
"status": "queued",
"claimed_by_run_id": "run_1",
'steering': {
'status': 'queued',
'claimed_by_run_id': 'run_1',
}
},
)
assert event_id == "evt_steering"
assert event_id == 'evt_steering'
stored_event = mock_session.add.call_args.args[0]
assert '"status": "queued"' in stored_event.metadata_json
assert '"claimed_by_run_id": "run_1"' in stored_event.metadata_json
@@ -147,15 +149,15 @@ class TestEventLogStore:
with patch.object(store, '_session_factory') as mock_factory:
mock_factory.return_value.__aenter__.return_value = mock_session
long_text = "x" * 2000
long_text = 'x' * 2000
event_id = await store.append_event(
event_id="evt_2",
event_type="message.received",
source="platform",
event_id='evt_2',
event_type='message.received',
source='platform',
input_summary=long_text,
)
assert event_id == "evt_2"
assert event_id == 'evt_2'
@pytest.mark.asyncio
async def test_page_events_with_conversation_filter(self, mock_db_engine):
@@ -174,7 +176,7 @@ class TestEventLogStore:
mock_factory.return_value.__aenter__.return_value = mock_session
items, next_seq, has_more = await store.page_events(
conversation_id="conv_1",
conversation_id='conv_1',
limit=10,
)
@@ -202,10 +204,10 @@ class TestTranscriptStore:
transcript_id = await store.append_transcript(
transcript_id=None, # Auto-generate
event_id="evt_1",
conversation_id="conv_1",
role="user",
content="Hello",
event_id='evt_1',
conversation_id='conv_1',
role='user',
content='Hello',
)
assert transcript_id is not None
@@ -227,13 +229,11 @@ class TestTranscriptStore:
transcript_id = await store.append_transcript(
transcript_id=None, # Auto-generate
event_id="evt_2",
conversation_id="conv_1",
role="assistant",
event_id='evt_2',
conversation_id='conv_1',
role='assistant',
content="Here's an image",
attachment_refs=[
{"id": "att_1", "type": "image", "url": "http://example.com/img.png"}
],
attachment_refs=[{'id': 'att_1', 'type': 'image', 'url': 'http://example.com/img.png'}],
)
assert transcript_id is not None
@@ -255,9 +255,9 @@ class TestTranscriptStore:
mock_factory.return_value.__aenter__.return_value = mock_session
items, next_seq, prev_seq, has_more = await store.page_transcript(
conversation_id="conv_1",
conversation_id='conv_1',
limit=10,
direction="backward",
direction='backward',
)
assert isinstance(items, list)
@@ -280,7 +280,7 @@ class TestTranscriptStore:
# Request more than the hard limit
items, next_seq, prev_seq, has_more = await store.page_transcript(
conversation_id="conv_1",
conversation_id='conv_1',
limit=200, # Request 200, but hard limit is 100
)
@@ -304,8 +304,8 @@ class TestTranscriptStore:
mock_factory.return_value.__aenter__.return_value = mock_session
items = await store.search_transcript(
conversation_id="conv_1",
query_text="database",
conversation_id='conv_1',
query_text='database',
top_k=10,
)
@@ -323,11 +323,11 @@ class TestHistoryPageAuthorization:
# Mock call_action to simulate the handler
result = await mock_handler.call_action(
PluginToRuntimeAction.HISTORY_PAGE,
{"run_id": None},
{'run_id': None},
)
# Should return error
assert result.get("ok") is False or "error" in str(result).lower()
assert result.get('ok') is False or 'error' in str(result).lower()
@pytest.mark.asyncio
async def test_history_page_validates_conversation_scope(self, mock_db_engine):
@@ -337,20 +337,20 @@ class TestHistoryPageAuthorization:
session_registry = get_session_registry()
await session_registry.register(
run_id="run_1",
runner_id="plugin:test/plugin/runner",
run_id='run_1',
runner_id='plugin:test/plugin/runner',
query_id=None,
plugin_identity="test/plugin",
resources={"models": [], "tools": [], "knowledge_bases": [], "storage": {"plugin_storage": True}},
conversation_id="conv_1",
plugin_identity='test/plugin',
resources={'models': [], 'tools': [], 'knowledge_bases': [], 'storage': {'plugin_storage': True}},
conversation_id='conv_1',
)
session = await session_registry.get("run_1")
session = await session_registry.get('run_1')
assert session is not None
assert session["authorization"]["conversation_id"] == "conv_1"
assert session['authorization']['conversation_id'] == 'conv_1'
# Cleanup
await session_registry.unregister("run_1")
await session_registry.unregister('run_1')
class TestEventGetAuthorization:
@@ -363,11 +363,11 @@ class TestEventGetAuthorization:
result = await mock_handler.call_action(
PluginToRuntimeAction.EVENT_GET,
{"run_id": None, "event_id": "evt_1"},
{'run_id': None, 'event_id': 'evt_1'},
)
# Should return error
assert result.get("ok") is False or "error" in str(result).lower()
assert result.get('ok') is False or 'error' in str(result).lower()
class TestContextAccessPopulation:
@@ -389,7 +389,7 @@ class TestContextAccessPopulation:
with patch.object(store, '_session_factory') as mock_factory:
mock_factory.return_value.__aenter__.return_value = mock_session
cursor = await store.get_latest_cursor("conv_1")
cursor = await store.get_latest_cursor('conv_1')
# Should return None or a cursor string
assert cursor is None or isinstance(cursor, str)
@@ -409,7 +409,7 @@ class TestContextAccessPopulation:
with patch.object(store, '_session_factory') as mock_factory:
mock_factory.return_value.__aenter__.return_value = mock_session
has_history = await store.has_history_before("conv_1", 10)
has_history = await store.has_history_before('conv_1', 10)
assert isinstance(has_history, bool)
@@ -422,7 +422,7 @@ class TestEventLogStoreRealSQLite:
from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.entity.persistence.base import Base
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
engine = create_async_engine('sqlite+aiosqlite:///:memory:')
# Create tables
async with engine.begin() as conn:
@@ -439,30 +439,30 @@ class TestEventLogStoreRealSQLite:
# Append event
event_id = await store.append_event(
event_id="evt_real_001",
event_type="message.received",
source="platform",
bot_id="bot_001",
conversation_id="conv_001",
actor_type="user",
actor_id="user_001",
actor_name="Test User",
input_summary="Hello world",
run_id="run_001",
runner_id="plugin:test/plugin/runner",
event_id='evt_real_001',
event_type='message.received',
source='platform',
bot_id='bot_001',
conversation_id='conv_001',
actor_type='user',
actor_id='user_001',
actor_name='Test User',
input_summary='Hello world',
run_id='run_001',
runner_id='plugin:test/plugin/runner',
)
assert event_id == "evt_real_001"
assert event_id == 'evt_real_001'
# Get event
event = await store.get_event(event_id)
assert event is not None
assert event["event_id"] == "evt_real_001"
assert event["event_type"] == "message.received"
assert event["source"] == "platform"
assert event["conversation_id"] == "conv_001"
assert event["actor_type"] == "user"
assert event["actor_id"] == "user_001"
assert event['event_id'] == 'evt_real_001'
assert event['event_type'] == 'message.received'
assert event['source'] == 'platform'
assert event['conversation_id'] == 'conv_001'
assert event['actor_type'] == 'user'
assert event['actor_id'] == 'user_001'
@pytest.mark.asyncio
async def test_page_events(self, db_engine):
@@ -472,16 +472,16 @@ class TestEventLogStoreRealSQLite:
# Append multiple events
for i in range(5):
await store.append_event(
event_id=f"evt_real_{i:03d}",
event_type="message.received",
source="platform",
conversation_id="conv_001",
input_summary=f"Message {i}",
event_id=f'evt_real_{i:03d}',
event_type='message.received',
source='platform',
conversation_id='conv_001',
input_summary=f'Message {i}',
)
# Page events
items, next_seq, has_more = await store.page_events(
conversation_id="conv_001",
conversation_id='conv_001',
limit=3,
)
@@ -496,14 +496,14 @@ class TestEventLogStoreRealSQLite:
# Append events
for i in range(3):
await store.append_event(
event_id=f"evt_cursor_{i:03d}",
event_type="message.received",
source="platform",
conversation_id="conv_cursor",
event_id=f'evt_cursor_{i:03d}',
event_type='message.received',
source='platform',
conversation_id='conv_cursor',
)
# Get latest cursor
cursor = await store.get_latest_cursor("conv_cursor")
cursor = await store.get_latest_cursor('conv_cursor')
assert cursor is not None
assert int(cursor) > 0
@@ -516,26 +516,26 @@ class TestEventLogStoreRealSQLite:
store = EventLogStore(db_engine)
cutoff = datetime.datetime.utcnow()
await store.append_event(
event_id="evt_cleanup_old",
event_type="message.received",
source="platform",
conversation_id="conv_cleanup",
event_id='evt_cleanup_old',
event_type='message.received',
source='platform',
conversation_id='conv_cleanup',
)
await store.append_event(
event_id="evt_cleanup_new",
event_type="message.received",
source="platform",
conversation_id="conv_cleanup",
event_id='evt_cleanup_new',
event_type='message.received',
source='platform',
conversation_id='conv_cleanup',
)
async with store._session_factory() as session:
await session.execute(
sqlalchemy.update(EventLog)
.where(EventLog.event_id == "evt_cleanup_old")
.where(EventLog.event_id == 'evt_cleanup_old')
.values(created_at=cutoff - datetime.timedelta(days=2))
)
await session.execute(
sqlalchemy.update(EventLog)
.where(EventLog.event_id == "evt_cleanup_new")
.where(EventLog.event_id == 'evt_cleanup_new')
.values(created_at=cutoff + datetime.timedelta(days=2))
)
await session.commit()
@@ -543,8 +543,8 @@ class TestEventLogStoreRealSQLite:
removed = await store.cleanup_events_older_than(cutoff)
assert removed == 1
assert await store.get_event("evt_cleanup_old") is None
assert await store.get_event("evt_cleanup_new") is not None
assert await store.get_event('evt_cleanup_old') is None
assert await store.get_event('evt_cleanup_new') is not None
class TestTranscriptStoreRealSQLite:
@@ -556,7 +556,7 @@ class TestTranscriptStoreRealSQLite:
from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.entity.persistence.base import Base
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
engine = create_async_engine('sqlite+aiosqlite:///:memory:')
# Create tables
async with engine.begin() as conn:
@@ -574,21 +574,21 @@ class TestTranscriptStoreRealSQLite:
# Append transcript items
for i in range(3):
await store.append_transcript(
transcript_id=f"trans_real_{i:03d}",
event_id=f"evt_{i:03d}",
conversation_id="conv_001",
role="user" if i % 2 == 0 else "assistant",
content=f"Message {i}",
transcript_id=f'trans_real_{i:03d}',
event_id=f'evt_{i:03d}',
conversation_id='conv_001',
role='user' if i % 2 == 0 else 'assistant',
content=f'Message {i}',
)
# Page transcript
items, next_seq, prev_seq, has_more = await store.page_transcript(
conversation_id="conv_001",
conversation_id='conv_001',
limit=10,
)
assert len(items) == 3
assert items[0]["conversation_id"] == "conv_001"
assert items[0]['conversation_id'] == 'conv_001'
@pytest.mark.asyncio
async def test_get_legacy_provider_messages_projects_transcript_history(self, db_engine):
@@ -596,37 +596,37 @@ class TestTranscriptStoreRealSQLite:
store = TranscriptStore(db_engine)
await store.append_transcript(
transcript_id="trans_view_001",
event_id="evt_view_001",
conversation_id="conv_view",
role="user",
content="User text",
transcript_id='trans_view_001',
event_id='evt_view_001',
conversation_id='conv_view',
role='user',
content='User text',
content_json={
"role": "user",
"content": [{"type": "text", "text": "User structured text"}],
'role': 'user',
'content': [{'type': 'text', 'text': 'User structured text'}],
},
)
await store.append_transcript(
transcript_id="trans_view_002",
event_id="evt_view_002",
conversation_id="conv_view",
role="tool",
item_type="tool_result",
content="ignored tool result",
transcript_id='trans_view_002',
event_id='evt_view_002',
conversation_id='conv_view',
role='tool',
item_type='tool_result',
content='ignored tool result',
)
await store.append_transcript(
transcript_id="trans_view_003",
event_id="evt_view_003",
conversation_id="conv_view",
role="assistant",
content="Assistant text",
transcript_id='trans_view_003',
event_id='evt_view_003',
conversation_id='conv_view',
role='assistant',
content='Assistant text',
)
messages = await store.get_legacy_provider_messages("conv_view")
messages = await store.get_legacy_provider_messages('conv_view')
assert [message.role for message in messages] == ["user", "assistant"]
assert messages[0].content[0].text == "User structured text"
assert messages[1].content == "Assistant text"
assert [message.role for message in messages] == ['user', 'assistant']
assert messages[0].content[0].text == 'User structured text'
assert messages[1].content == 'Assistant text'
@pytest.mark.asyncio
async def test_get_legacy_provider_messages_filters_scope(self, db_engine):
@@ -634,45 +634,45 @@ class TestTranscriptStoreRealSQLite:
store = TranscriptStore(db_engine)
await store.append_transcript(
transcript_id="trans_scope_001",
event_id="evt_scope_001",
conversation_id="conv_scope",
bot_id="bot_001",
workspace_id="workspace_001",
thread_id="thread_001",
role="user",
content="Current scope text",
transcript_id='trans_scope_001',
event_id='evt_scope_001',
conversation_id='conv_scope',
bot_id='bot_001',
workspace_id='workspace_001',
thread_id='thread_001',
role='user',
content='Current scope text',
)
await store.append_transcript(
transcript_id="trans_scope_002",
event_id="evt_scope_002",
conversation_id="conv_scope",
bot_id="bot_002",
workspace_id="workspace_001",
thread_id="thread_001",
role="assistant",
content="Other bot text",
transcript_id='trans_scope_002',
event_id='evt_scope_002',
conversation_id='conv_scope',
bot_id='bot_002',
workspace_id='workspace_001',
thread_id='thread_001',
role='assistant',
content='Other bot text',
)
await store.append_transcript(
transcript_id="trans_scope_003",
event_id="evt_scope_003",
conversation_id="conv_scope",
bot_id="bot_001",
workspace_id="workspace_001",
thread_id="thread_002",
role="assistant",
content="Other thread text",
transcript_id='trans_scope_003',
event_id='evt_scope_003',
conversation_id='conv_scope',
bot_id='bot_001',
workspace_id='workspace_001',
thread_id='thread_002',
role='assistant',
content='Other thread text',
)
messages = await store.get_legacy_provider_messages(
"conv_scope",
bot_id="bot_001",
workspace_id="workspace_001",
thread_id="thread_001",
'conv_scope',
bot_id='bot_001',
workspace_id='workspace_001',
thread_id='thread_001',
strict_thread=True,
)
assert [message.content for message in messages] == ["Current scope text"]
assert [message.content for message in messages] == ['Current scope text']
@pytest.mark.asyncio
async def test_search_transcript_real_db(self, db_engine):
@@ -681,24 +681,24 @@ class TestTranscriptStoreRealSQLite:
# Append transcript items
await store.append_transcript(
transcript_id="trans_search_001",
event_id="evt_search_001",
conversation_id="conv_search",
role="user",
content="I want to learn about databases",
transcript_id='trans_search_001',
event_id='evt_search_001',
conversation_id='conv_search',
role='user',
content='I want to learn about databases',
)
await store.append_transcript(
transcript_id="trans_search_002",
event_id="evt_search_002",
conversation_id="conv_search",
role="assistant",
content="Here is information about databases",
transcript_id='trans_search_002',
event_id='evt_search_002',
conversation_id='conv_search',
role='assistant',
content='Here is information about databases',
)
# Search for "database"
items = await store.search_transcript(
conversation_id="conv_search",
query_text="database",
conversation_id='conv_search',
query_text='database',
)
# Should find at least one match
@@ -712,15 +712,15 @@ class TestTranscriptStoreRealSQLite:
# Append transcript items
for i in range(3):
await store.append_transcript(
transcript_id=f"trans_cursor_{i:03d}",
event_id=f"evt_cursor_{i:03d}",
conversation_id="conv_cursor",
role="user",
content=f"Message {i}",
transcript_id=f'trans_cursor_{i:03d}',
event_id=f'evt_cursor_{i:03d}',
conversation_id='conv_cursor',
role='user',
content=f'Message {i}',
)
# Get latest cursor
cursor = await store.get_latest_cursor("conv_cursor")
cursor = await store.get_latest_cursor('conv_cursor')
assert cursor is not None
assert int(cursor) > 0
@@ -733,37 +733,37 @@ class TestTranscriptStoreRealSQLite:
store = TranscriptStore(db_engine)
cutoff = datetime.datetime.utcnow()
await store.append_transcript(
transcript_id="trans_cleanup_old",
event_id="evt_cleanup_old",
conversation_id="conv_cleanup",
role="user",
content="old",
transcript_id='trans_cleanup_old',
event_id='evt_cleanup_old',
conversation_id='conv_cleanup',
role='user',
content='old',
)
await store.append_transcript(
transcript_id="trans_cleanup_new",
event_id="evt_cleanup_new",
conversation_id="conv_cleanup",
role="assistant",
content="new",
transcript_id='trans_cleanup_new',
event_id='evt_cleanup_new',
conversation_id='conv_cleanup',
role='assistant',
content='new',
)
async with store._session_factory() as session:
await session.execute(
sqlalchemy.update(Transcript)
.where(Transcript.transcript_id == "trans_cleanup_old")
.where(Transcript.transcript_id == 'trans_cleanup_old')
.values(created_at=cutoff - datetime.timedelta(days=2))
)
await session.execute(
sqlalchemy.update(Transcript)
.where(Transcript.transcript_id == "trans_cleanup_new")
.where(Transcript.transcript_id == 'trans_cleanup_new')
.values(created_at=cutoff + datetime.timedelta(days=2))
)
await session.commit()
removed = await store.cleanup_transcripts_older_than(cutoff)
items, _, _, _ = await store.page_transcript("conv_cleanup", limit=10)
items, _, _, _ = await store.page_transcript('conv_cleanup', limit=10)
assert removed == 1
assert [item["content"] for item in items] == ["new"]
assert [item['content'] for item in items] == ['new']
# Fixtures
@@ -788,8 +788,8 @@ def mock_handler():
async def call_action(self, action, data, timeout=30):
# Simulate error response for missing run_id
if not data.get("run_id"):
return {"ok": False, "message": "run_id is required"}
return {"ok": True, "data": {}}
if not data.get('run_id'):
return {'ok': False, 'message': 'run_id is required'}
return {'ok': True, 'data': {}}
return MockHandler()
@@ -901,7 +901,7 @@ async def test_orchestrator_enforces_total_runner_deadline(clean_agent_state):
[message async for message in orchestrator.run_from_query(query)]
assert exc_info.value.retryable is True
assert 'runner.timeout' in str(exc_info.value)
assert exc_info.value.error_code == 'runner.timeout'
assert await get_session_registry().list_active_runs() == []
@@ -1012,6 +1012,7 @@ class TestQueryEntrySessionQueryId:
]
)
ap = FakeApplication(plugin_connector, db_engine)
async def build_resource_context(execution_query):
from langbot.pkg.provider.tools.loaders.mcp import (
_execution_context_from_query,
@@ -1025,9 +1026,7 @@ class TestQueryEntrySessionQueryId:
return 'Pinned documentation'
mcp_loader = types.SimpleNamespace(
build_resource_context_for_query=AsyncMock(
side_effect=build_resource_context
)
build_resource_context_for_query=AsyncMock(side_effect=build_resource_context)
)
ap.tool_mgr = types.SimpleNamespace(mcp_tool_loader=mcp_loader)
orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor))
@@ -1070,7 +1069,6 @@ class TestQueryEntrySessionQueryId:
resource_policy=ResourcePolicy(),
state_policy=StatePolicy(enable_state=False, state_scopes=[]),
delivery_policy=DeliveryPolicy(enable_streaming=True, enable_reply=True),
enabled=True,
)
messages = [
@@ -1105,14 +1103,8 @@ class TestQueryEntrySessionQueryId:
assert 'Pinned documentation' in plugin_connector.contexts[0]['input']['contents'][0]['text']
assert event.input.text == 'hello'
assert event.input.contents[0].text == 'hello'
assert (
plugin_connector.contexts[0]['conversation']['workspace_id']
== TEST_CONTEXT.workspace_uuid
)
assert (
plugin_connector.contexts[0]['runtime']['metadata']['workspace_id']
== TEST_CONTEXT.workspace_uuid
)
assert plugin_connector.contexts[0]['conversation']['workspace_id'] == TEST_CONTEXT.workspace_uuid
assert plugin_connector.contexts[0]['runtime']['metadata']['workspace_id'] == TEST_CONTEXT.workspace_uuid
assert 'Pinned documentation' not in str(execution_query.user_message.content)
mcp_loader.build_resource_context_for_query.assert_awaited_once_with(execution_query)
@@ -1,4 +1,5 @@
"""Tests for agent runner result normalizer."""
from __future__ import annotations
import pytest
@@ -12,6 +13,7 @@ from langbot_plugin.api.entities.builtin.provider import message as provider_mes
class FakeApplication:
"""Fake Application for testing."""
def __init__(self):
class FakeLogger:
def __init__(self):
@@ -19,10 +21,13 @@ class FakeApplication:
def info(self, msg):
pass
def debug(self, msg):
pass
def warning(self, msg):
self.warnings.append(msg)
def error(self, msg):
pass
@@ -192,6 +197,7 @@ class TestNormalizeRunFailed:
assert exc_info.value.runner_id == 'plugin:langbot-team/LocalAgent/default'
assert exc_info.value.retryable is True
assert exc_info.value.error_code == 'upstream.timeout'
assert 'timeout' in str(exc_info.value)
@@ -290,6 +296,7 @@ class TestNormalizeNonMessageResults:
assert result is None
assert app.logger.warnings
class TestNormalizeInvalidResults:
"""Tests for handling invalid results."""
@@ -46,7 +46,6 @@ def _agent_row(
'runner': {'id': 'plugin:test/runner/default', 'expire-time': 0},
'runner_config': {'plugin:test/runner/default': {'temperature': 0.2}},
},
enabled=True,
supported_event_patterns=supported_event_patterns or ['*'],
created_at=dt.datetime(2026, 1, 1, 9, 0, 0),
updated_at=updated_at or dt.datetime(2026, 1, 1, 10, 0, 0),
@@ -63,7 +62,6 @@ def _serialize_agent(model_cls, entity, masked_columns=None):
'kind': entity.kind,
'component_ref': entity.component_ref,
'config': entity.config,
'enabled': entity.enabled,
'supported_event_patterns': entity.supported_event_patterns,
'created_at': entity.created_at,
'updated_at': entity.updated_at,
@@ -145,7 +143,6 @@ class TestAgentServiceDebug:
return_value={
'uuid': 'agent-1',
'kind': AGENT_KIND_AGENT,
'enabled': True,
'supported_event_patterns': ['*'],
'config': _agent_row().config,
}
@@ -288,7 +285,7 @@ class TestAgentServiceListAndLookup:
result = await AgentService(app).get_agent(WORKSPACE_UUID, 'pipeline-1')
assert result['kind'] == AGENT_KIND_PIPELINE
assert result['enabled'] is True
assert 'enabled' not in result
assert result['config'] == {'ai': {'runner': {'id': 'pipeline-runner'}}}
assert result['capability']['message_only'] is True
@@ -329,7 +326,7 @@ class TestAgentServiceCreateUpdateDelete:
'runner': {'id': runner.id, 'expire-time': 0},
'runner_config': {runner.id: {'model': 'gpt-4.1', 'temperature': 0.2}},
}
assert insert_values['enabled'] is True
assert 'enabled' not in insert_values
assert insert_values['supported_event_patterns'] == AGENT_DEFAULT_EVENT_PATTERNS
app.pipeline_service._get_default_values_from_schema.assert_called_once_with(runner.config_schema)
@@ -62,9 +62,7 @@ def _set_discovered_adapters(ap, *webhook_adapters: str) -> None:
)
for adapter_name in webhook_adapters
]
ap.discover = SimpleNamespace(
get_components_by_kind=Mock(return_value=components)
)
ap.discover = SimpleNamespace(get_components_by_kind=Mock(return_value=components))
class TestBotServiceGetBots:
@@ -445,6 +443,7 @@ class TestBotServiceUpdateBot:
ap.persistence_mgr = SimpleNamespace()
ap.platform_mgr = SimpleNamespace()
ap.platform_mgr.remove_bot = AsyncMock()
ap.platform_mgr.get_bot_by_uuid = AsyncMock(return_value=None)
# Mock pipeline query - not updating pipeline
ap.persistence_mgr.execute_async = AsyncMock()
@@ -475,6 +474,7 @@ class TestBotServiceUpdateBot:
ap.persistence_mgr.execute_async = AsyncMock(return_value=Mock())
ap.platform_mgr = SimpleNamespace(
get_bot_by_uuid=AsyncMock(return_value=None),
remove_bot=AsyncMock(),
load_bot=AsyncMock(return_value=SimpleNamespace(enable=False)),
)
@@ -498,6 +498,29 @@ class TestBotServiceUpdateBot:
assert 'use_pipeline_uuid' not in update_params
assert 'use_pipeline_name' not in update_params
async def test_basic_info_update_does_not_restart_platform_adapter(self):
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace(execute_async=AsyncMock(return_value=SimpleNamespace(rowcount=1)))
runtime_entity = SimpleNamespace(name='Old name', description='Old description')
runtime_bot = SimpleNamespace(bot_entity=runtime_entity)
ap.platform_mgr = SimpleNamespace(
get_bot_by_uuid=AsyncMock(return_value=runtime_bot),
remove_bot=AsyncMock(),
load_bot=AsyncMock(),
)
service = BotService(ap)
await service.update_bot(
WORKSPACE_UUID,
'test-uuid',
{'name': 'New name', 'description': 'New description'},
)
assert runtime_entity.name == 'New name'
assert runtime_entity.description == 'New description'
ap.platform_mgr.remove_bot.assert_not_awaited()
ap.platform_mgr.load_bot.assert_not_awaited()
class TestBotServiceDeleteBot:
"""Tests for delete_bot method."""
@@ -583,6 +606,56 @@ class TestBotServiceListEventLogs:
assert total == 5
class TestBotServiceListEventRouteStatuses:
"""Tests for event route status when a persisted Bot is not running."""
async def test_returns_saved_routes_when_runtime_bot_is_unavailable(self):
ap = SimpleNamespace()
ap.platform_mgr = SimpleNamespace()
ap.platform_mgr.get_bot_by_uuid = AsyncMock(return_value=None)
service = BotService(ap)
service.get_bot = AsyncMock(
return_value={
'uuid': 'bot-uuid',
'event_bindings': [
{
'id': 'binding-1',
'event_pattern': 'message.received',
'target_type': 'agent',
'target_uuid': 'agent-1',
'enabled': True,
}
],
}
)
result = await service.list_event_route_statuses(WORKSPACE_UUID, 'bot-uuid')
assert result['routes'] == [
{
'binding_id': 'binding-1',
'event_pattern': 'message.received',
'event_type': None,
'target_type': 'agent',
'target_uuid': 'agent-1',
'last_status': None,
'failure_code': None,
'reason': None,
'run_id': None,
'timestamp': None,
'seq_id': None,
'level': None,
'message': '',
'order': 0,
'enabled': True,
'current': True,
}
]
assert result['unmatched_events'] == []
assert result['stale_routes'] == []
class TestBotServiceSendMessage:
"""Tests for send_message method."""
@@ -9,6 +9,8 @@ from unittest.mock import ANY, AsyncMock
import pytest
import quart
from langbot.pkg.agent.runner.errors import RunnerExecutionError
core_app_module = types.ModuleType('langbot.pkg.core.app')
core_app_module.Application = object
sys.modules.setdefault('langbot.pkg.core.app', core_app_module)
@@ -142,3 +144,28 @@ async def test_debug_agent_returns_bad_request_for_invalid_event():
'code': -1,
'msg': 'Invalid event_type',
}
async def test_debug_agent_returns_actionable_runner_error():
agent_service = SimpleNamespace(
debug_agent=AsyncMock(
side_effect=RunnerExecutionError(
'plugin:langbot-team/DifyAgent/default',
'api-key is required',
error_code='dify.config_invalid',
)
),
)
client = await _create_test_client(agent_service)
response = await client.post(
'/api/v1/agents/agent-1/debug',
json={'event_type': 'message.received', 'text': 'hello'},
headers={'Authorization': 'Bearer test-token'},
)
assert response.status_code == 422
assert await response.get_json() == {
'code': 'dify.config_invalid',
'msg': 'api-key is required',
}
+1 -38
View File
@@ -1,6 +1,5 @@
from __future__ import annotations
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock
@@ -20,18 +19,6 @@ def _make_app() -> SimpleNamespace:
update_bot=AsyncMock(),
delete_bot=AsyncMock(),
list_event_route_statuses=AsyncMock(return_value={'routes': [], 'unmatched_events': [], 'stale_routes': []}),
dispatch_test_event_route=AsyncMock(
return_value={
'dispatched': True,
'event_type': 'message.received',
'suppressed_outputs': [],
'route_status': {
'routes': [],
'unmatched_events': [],
'stale_routes': [],
},
}
),
)
app.pipeline_service = SimpleNamespace(
get_pipelines=AsyncMock(return_value=[]),
@@ -75,30 +62,6 @@ async def test_mcp_server_exposes_bot_event_route_tools():
tool_names = {tool.name for tool in tools}
assert 'list_bot_event_route_statuses' in tool_names
assert 'test_bot_event_route' in tool_names
assert 'test_bot_event_route' not in tool_names
assert 'list_processors' in tool_names
assert 'list_agents' not in tool_names
@pytest.mark.asyncio
async def test_mcp_test_bot_event_route_calls_service_layer():
app = _make_app()
server = LangBotMCPServer(app)
result_blocks, _ = await server.mcp.call_tool(
'test_bot_event_route',
{
'bot_uuid': 'bot-1',
'event_type': 'message.received',
'payload': {'message_text': 'hello'},
},
)
app.bot_service.dispatch_test_event_route.assert_awaited_once_with(
bot_uuid='bot-1',
event_type='message.received',
payload={'message_text': 'hello'},
)
data = json.loads(result_blocks[0].text)
assert data['dispatched'] is True
assert data['event_type'] == 'message.received'
+27 -176
View File
@@ -81,9 +81,9 @@ class TestEventRouteTrace:
'target_type': 'agent',
'target_uuid': 'agent-1',
},
failure_code='processor_disabled',
reason='Agent target is disabled',
text='disabled',
failure_code='processor_not_found',
reason='Agent target is unavailable',
text='unavailable',
)
bot.logger.warning.assert_awaited_once()
@@ -96,61 +96,34 @@ class TestEventRouteTrace:
assert metadata['status'] == 'failed'
@pytest.mark.asyncio
async def test_dispatch_test_event_suppresses_agent_output_delivery(self):
"""Synthetic test dispatch runs the route but does not call the real adapter."""
import langbot_plugin.api.entities.builtin.provider.message as provider_message
async def test_adapter_event_log_exposes_normalized_input_without_platform_object(self):
"""Adapter debugging records the shared event shape without opaque SDK data."""
from langbot_plugin.api.entities.builtin.platform import entities, events, message
captured_envelopes = []
async def fake_run(envelope, binding, adapter_context=None):
captured_envelopes.append(envelope)
yield provider_message.Message(role='assistant', content='test response')
bot = self._make_bot(
[
{
'id': 'agent-binding',
'enabled': True,
'event_pattern': 'message.received',
'target_type': 'agent',
'target_uuid': 'agent-1',
'priority': 0,
'order': 0,
}
]
)
bot.ap = SimpleNamespace(
workspace_service=active_workspace_service(),
agent_service=SimpleNamespace(
get_agent=AsyncMock(
return_value={
'uuid': 'agent-1',
'kind': 'agent',
'enabled': True,
'supported_event_patterns': ['message.received'],
'config': {'runner': {'id': 'runner-1'}, 'runner_config': {'runner-1': {}}},
}
)
),
agent_run_orchestrator=SimpleNamespace(run=fake_run),
)
bot.adapter = SimpleNamespace(
bot_account_id='bot-account',
config={},
logger=bot.logger,
send_message=AsyncMock(),
get_supported_apis=Mock(return_value=['send_message', 'edit_message', 'add_reaction', 'get_group_info']),
bot = self._make_bot([])
bot.bot_entity.adapter = 'test-adapter'
event = events.MessageReceivedEvent(
message_id='message-1',
message_chain=message.MessageChain([message.Plain(text='hello')]),
sender=entities.User(id='user-1', nickname='QA User'),
chat_type=entities.ChatType.PRIVATE,
chat_id='user-1',
source_platform_object={'access_token': 'must-not-be-logged'},
)
result = await bot.dispatch_test_event('message.received', {'chat_id': 'user-1', 'message_text': 'hello'})
metadata = await bot._record_adapter_event(event, SimpleNamespace())
bot.adapter.send_message.assert_not_awaited()
assert result['dispatched'] is True
assert result['status'] == 'delivered'
assert result['suppressed_outputs'][0]['method'] == 'send_message'
assert captured_envelopes[0].delivery.supports_edit is False
assert captured_envelopes[0].delivery.supports_reaction is False
assert captured_envelopes[0].delivery.platform_capabilities['supported_apis'] == ['get_group_info']
assert metadata['kind'] == 'adapter_event_received'
assert metadata['event_type'] == 'message.received'
assert metadata['adapter'] == 'test-adapter'
assert metadata['bot_uuid'] == 'bot-1'
assert metadata['event_data']['message_chain'] == [{'type': 'Plain', 'text': 'hello'}]
assert metadata['event_data']['sender']['id'] == 'user-1'
assert 'source_platform_object' not in metadata['event_data']
bot.logger.info.assert_awaited_once_with(
'Platform adapter received message.received',
metadata=metadata,
)
@pytest.mark.asyncio
async def test_dispatch_malformed_agent_config_fails_one_event_and_processes_next(self):
@@ -208,128 +181,6 @@ class TestEventRouteTrace:
assert delivered['status'] == 'delivered'
assert len(runner_calls) == 1
@pytest.mark.asyncio
async def test_dispatch_test_event_pipeline_receives_synthetic_adapter(self):
"""Pipeline route tests enqueue queries with the no-op adapter."""
bot = self._make_bot(
[
{
'id': 'pipeline-binding',
'enabled': True,
'event_pattern': 'message.received',
'target_type': 'pipeline',
'target_uuid': 'pipeline-1',
'priority': 0,
'order': 0,
}
]
)
bot.ap = SimpleNamespace(
workspace_service=active_workspace_service(),
msg_aggregator=SimpleNamespace(add_message=AsyncMock()),
)
bot.adapter = SimpleNamespace(
bot_account_id='bot-account',
config={},
logger=bot.logger,
send_message=AsyncMock(),
)
result = await bot.dispatch_test_event(
'message.received',
{'chat_id': 'user-1', 'message_text': 'hello'},
)
bot.adapter.send_message.assert_not_awaited()
bot.ap.msg_aggregator.add_message.assert_awaited_once()
_, kwargs = bot.ap.msg_aggregator.add_message.await_args
query_adapter = kwargs['adapter']
assert query_adapter is not bot.adapter
assert getattr(query_adapter, 'source') is bot.adapter
assert result['dispatched'] is True
assert result['status'] == 'delivered'
assert result['suppressed_outputs'] == []
@pytest.mark.asyncio
async def test_dispatch_test_event_reports_unmatched_route_as_failure(self):
"""Synthetic dispatch does not report success when no saved route matches."""
bot = self._make_bot([])
bot.adapter = SimpleNamespace(
bot_account_id='bot-account',
config={},
logger=bot.logger,
)
result = await bot.dispatch_test_event(
'message.received',
{'chat_id': 'user-1', 'message_text': 'hello'},
)
assert result['dispatched'] is False
assert result['status'] == 'not_matched'
assert result['failure_code'] == 'route_not_found'
assert result['reason'] == 'No event route matched'
@pytest.mark.asyncio
async def test_synthetic_adapter_suppresses_platform_side_effect_apis(self):
"""Synthetic adapter blocks optional platform APIs that mutate external state."""
from langbot.pkg.platform.botmgr import SyntheticRouteTestAdapter
import langbot_plugin.api.entities.builtin.platform.message as platform_message
source = SimpleNamespace(
bot_account_id='bot-account',
config={},
logger=Mock(),
get_supported_apis=Mock(
return_value=[
'send_message',
'delete_message',
'get_group_info',
'call_platform_api',
]
),
delete_message=AsyncMock(),
call_platform_api=AsyncMock(),
)
adapter = SyntheticRouteTestAdapter(source)
await adapter.delete_message('group', 'group-1', 'message-1')
await adapter.call_platform_api('set_title', {'name': 'New Title'})
upload_result = await adapter.upload_file(b'data', 'test.txt')
source.delete_message.assert_not_awaited()
source.call_platform_api.assert_not_awaited()
assert upload_result == 'suppressed:test.txt'
assert [item['method'] for item in adapter.suppressed_outputs] == [
'delete_message',
'call_platform_api',
'upload_file',
]
assert adapter.get_supported_apis() == ['get_group_info']
assert adapter._message_to_payload(platform_message.MessageChain([platform_message.Plain(text='ok')]))
def test_build_test_platform_event_message_received_uses_payload(self):
"""Synthetic message events preserve common route filter fields."""
from langbot.pkg.platform.botmgr import RuntimeBot
event = RuntimeBot._build_test_platform_event(
'message.received',
{
'chat_type': 'group',
'chat_id': 'group-1',
'group_name': 'QA Group',
'user_id': 'user-1',
'user_name': 'QA User',
'message_text': 'hello',
},
)
assert event.type == 'message.received'
assert str(event.chat_id) == 'group-1'
assert event.group.name == 'QA Group'
assert event.sender.nickname == 'QA User'
assert str(event.message_chain) == 'hello'
def test_agent_envelope_projects_adapter_delivery_capabilities(self):
"""Runner delivery context reflects the active adapter's declared APIs."""
from langbot_plugin.api.entities.builtin.platform import entities, events, message
@@ -2,9 +2,10 @@
The web debug client uploads Image / Voice / File components carrying a storage
key in ``path``. This helper resolves each to a base64 data URI (so multimodal
LLM input and the Box sandbox inbox have usable bytes), then deletes the
consumed upload. Covers mimetype selection per type and fail-closed error
handling.
LLM input and the Box sandbox inbox have usable bytes). Image uploads remain as
authenticated history references until storage retention cleanup, while other
consumed uploads are deleted. Covers mimetype selection per type and
fail-closed error handling.
"""
from __future__ import annotations
@@ -52,7 +53,7 @@ def _make_adapter(load_return=b'hello', load_side_effect=None):
@pytest.mark.asyncio
async def test_image_jpeg_mimetype_and_consumed_storage_key():
async def test_image_jpeg_mimetype_and_retained_history_key():
adapter, storage_mgr, _ = _make_adapter(load_return=b'\xff\xd8\xff')
path = f'{_UPLOAD_PREFIX}photo.jpg'
chain = [{'type': 'Image', 'path': path}]
@@ -61,12 +62,11 @@ async def test_image_jpeg_mimetype_and_consumed_storage_key():
expected_b64 = base64.b64encode(b'\xff\xd8\xff').decode('utf-8')
assert chain[0]['base64'] == f'data:image/jpeg;base64,{expected_b64}'
assert chain[0]['path'] == ''
storage_mgr.delete_scoped_object_key.assert_awaited_once_with(
_CONTEXT,
path,
expected_owner_type='upload_image',
)
assert chain[0]['path'] == path
storage_mgr.delete_scoped_object_key.assert_not_awaited()
history = adapter._history_message_chain(chain)
assert history == [{'type': 'Image', 'path': path, 'base64': ''}]
def test_history_retains_storage_key_without_large_base64_payload():
@@ -95,18 +95,22 @@ async def test_image_defaults_to_png():
@pytest.mark.asyncio
async def test_voice_uses_guessed_or_wav_mimetype():
adapter, _, _ = _make_adapter()
adapter, storage_mgr, _ = _make_adapter()
chain = [{'type': 'Voice', 'path': f'{_UPLOAD_PREFIX}clip.wav'}]
await adapter._process_image_components(_make_connection(), chain)
assert chain[0]['base64'].startswith('data:audio/')
assert chain[0]['path'] == ''
storage_mgr.delete_scoped_object_key.assert_awaited_once()
@pytest.mark.asyncio
async def test_file_uses_octet_stream_fallback():
adapter, _, _ = _make_adapter()
adapter, storage_mgr, _ = _make_adapter()
chain = [{'type': 'File', 'path': f'{_UPLOAD_PREFIX}unknownblob'}]
await adapter._process_image_components(_make_connection(), chain)
assert chain[0]['base64'].startswith('data:application/octet-stream;base64,')
assert chain[0]['path'] == ''
storage_mgr.delete_scoped_object_key.assert_awaited_once()
@pytest.mark.asyncio
@@ -482,7 +482,7 @@ async def test_attachment_key_must_belong_to_connection_upload_scope():
await adapter._process_image_components(connection, message_chain)
assert message_chain[0]['base64'].startswith('data:image/png;base64,')
assert message_chain[0]['path'] == ''
assert message_chain[0]['path'] == 'v1/current/upload_image/key.png'
storage_mgr.scoped_prefix.assert_called_once_with(
connection.execution_context,
owner_type='upload_image',
@@ -496,11 +496,7 @@ async def test_attachment_key_must_belong_to_connection_upload_scope():
'v1/current/upload_image/key.png',
expected_owner_type='upload_image',
)
storage_mgr.delete_scoped_object_key.assert_awaited_once_with(
connection.execution_context,
'v1/current/upload_image/key.png',
expected_owner_type='upload_image',
)
storage_mgr.delete_scoped_object_key.assert_not_awaited()
with pytest.raises(ValueError, match='does not belong'):
await adapter._process_image_components(
+19
View File
@@ -1,6 +1,8 @@
from __future__ import annotations
import json
import threading
from pathlib import Path
import pytest
@@ -53,6 +55,23 @@ async def test_matches_any_rejects_pattern_and_input_amplification():
)
@pytest.mark.asyncio
async def test_bundled_sensitive_words_fit_within_pattern_limit():
config_path = Path(__file__).parents[3] / 'src/langbot/templates/metadata/sensitive-words.json'
config = json.loads(config_path.read_text())
assert len(config['words']) <= safe_regex.MAX_PATTERN_COUNT
found, masked = await safe_regex.mask_patterns(
config['words'],
'普通消息',
mask=config['mask'],
mask_word=config['mask_word'],
)
assert found is False
assert masked == '普通消息'
@pytest.mark.asyncio
async def test_mask_patterns_bounds_replacement_growth_and_masks_matches():
found, masked = await safe_regex.mask_patterns(
+198 -71
View File
@@ -1,23 +1,41 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Bug, Settings } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { toast } from 'sonner';
import { Trash2 } from 'lucide-react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { useCurrentWorkspace } from '@/app/infra/http';
import { Agent } from '@/app/infra/entities/api';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import ProcessorDetailWorkbench from '@/app/home/components/processor-detail/ProcessorDetailWorkbench';
import EntityBasicInfoDialog, {
EntityBasicInfoValues,
} from '@/app/home/components/entity-basic-info/EntityBasicInfoDialog';
import EntityTitleEditButton from '@/app/home/components/entity-basic-info/EntityTitleEditButton';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import PipelineDetailContent from '@/app/home/pipelines/PipelineDetailContent';
import AgentCreateContent from './components/AgentCreateContent';
import AgentDebugPanel from './components/AgentDebugPanel';
import AgentFormComponent from './components/AgentFormComponent';
import AgentFormComponent, {
AgentFormHandle,
AgentRunnerStatus,
} from './components/AgentFormComponent';
export default function AgentDetailContent({ id }: { id: string }) {
const isCreateMode = id === 'new';
const navigate = useNavigate();
const { t } = useTranslation();
const currentWorkspace = useCurrentWorkspace();
const canManage =
currentWorkspace?.permissions.includes('resource.manage') ?? false;
const canOperate =
currentWorkspace?.permissions.includes('runtime.operate') ?? false;
const { refreshPipelines, pipelines, setDetailEntityName } = useSidebarData();
@@ -25,7 +43,19 @@ export default function AgentDetailContent({ id }: { id: string }) {
const [loading, setLoading] = useState(!isCreateMode);
const [formDirty, setFormDirty] = useState(false);
const [formSaving, setFormSaving] = useState(false);
const [activeTab, setActiveTab] = useState('config');
const [basicInfoOpen, setBasicInfoOpen] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const [runnerStatus, setRunnerStatus] = useState<AgentRunnerStatus | null>(
null,
);
const [availableEventTypes, setAvailableEventTypes] = useState<string[]>([
'message.received',
]);
const [supportedEventPatterns, setSupportedEventPatterns] = useState<
string[]
>(['*']);
const agentFormRef = useRef<AgentFormHandle>(null);
useEffect(() => {
if (isCreateMode) {
@@ -38,14 +68,33 @@ export default function AgentDetailContent({ id }: { id: string }) {
return () => setDetailEntityName(null);
}, [id, isCreateMode, pipelines, setDetailEntityName, t]);
useEffect(() => {
setRunnerStatus(null);
}, [id]);
useEffect(() => {
if (isCreateMode) return;
let cancelled = false;
setLoading(true);
httpClient
.getAgent(id)
.then((resp) => {
if (!cancelled) setAgent(resp.agent);
Promise.all([
httpClient.getAgent(id),
httpClient.getAdapters().catch(() => ({ adapters: [] })),
])
.then(([resp, adaptersResp]) => {
if (cancelled) return;
const adapterEvents = adaptersResp.adapters.flatMap(
(adapter) => adapter.spec.supported_events ?? [],
);
setAvailableEventTypes(
adapterEvents.length > 0
? Array.from(new Set(adapterEvents)).sort()
: ['message.received'],
);
setSupportedEventPatterns(
resp.agent.supported_event_patterns ??
resp.agent.capability?.supported_event_patterns ?? ['*'],
);
setAgent(resp.agent);
})
.finally(() => {
if (!cancelled) setLoading(false);
@@ -78,72 +127,150 @@ export default function AgentDetailContent({ id }: { id: string }) {
return <PipelineDetailContent id={id} routeBase="/home/agents" />;
}
async function saveBasicInfo(values: EntityBasicInfoValues) {
try {
await httpClient.updateAgent(id, values);
setAgent((current) => (current ? { ...current, ...values } : current));
agentFormRef.current?.syncBasicInfo(values);
await refreshPipelines();
toast.success(t('agents.saveSuccess'));
} catch (error) {
const message =
typeof error === 'object' && error && 'msg' in error
? String((error as { msg?: string }).msg || '')
: '';
toast.error(t('agents.saveError') + message);
throw error;
}
}
async function deleteAgent() {
setDeleting(true);
try {
await httpClient.deleteAgent(id);
toast.success(t('agents.deleteSuccess'));
setDeleteConfirmOpen(false);
await refreshPipelines();
navigate('/home/agents');
} catch (error) {
const message =
typeof error === 'object' && error && 'msg' in error
? String((error as { msg?: string }).msg || '')
: '';
toast.error(t('agents.deleteError') + message);
} finally {
setDeleting(false);
}
}
return (
<div className="flex h-full min-w-0 flex-col">
<div className="flex items-center justify-between pb-4 shrink-0">
<h1 className="text-xl font-semibold">{t('agents.editAgent')}</h1>
<Button
type="submit"
form="agent-form"
disabled={!formDirty || formSaving}
className={activeTab !== 'config' ? 'invisible' : ''}
>
{t('common.save')}
</Button>
</div>
<Tabs
<>
<ProcessorDetailWorkbench
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
agentId={id}
onFinish={() => {
refreshPipelines();
}}
onDeleted={() => {
refreshPipelines();
navigate('/home/agents');
}}
onDirtyChange={setFormDirty}
onSavingChange={setFormSaving}
/>
</TabsContent>
{canOperate && (
<TabsContent
value="debug"
className="mt-4 min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden"
>
title={`${agent.emoji || '🤖'} ${agent.name}`}
titleAction={
canManage ? (
<EntityTitleEditButton onClick={() => setBasicInfoOpen(true)} />
) : undefined
}
status={runnerStatus}
saveLabel={t('common.save')}
saveFormId="agent-form"
canSave={canManage}
isDirty={formDirty}
isSaving={formSaving}
headerActions={
canManage ? (
<Button
type="button"
variant="destructive"
disabled={formSaving || deleting}
onClick={() => setDeleteConfirmOpen(true)}
>
<Trash2 className="size-4" />
{t('common.delete')}
</Button>
) : undefined
}
configTitle={t('pipelines.configuration')}
configContent={
<fieldset className="contents" disabled={!canManage}>
<AgentFormComponent
ref={agentFormRef}
agentId={id}
availableEventTypes={availableEventTypes}
onFinish={(updatedAgent) => {
if (updatedAgent) {
setAgent((current) =>
current ? { ...current, ...updatedAgent } : current,
);
}
refreshPipelines();
}}
onDirtyChange={setFormDirty}
onSavingChange={setFormSaving}
onRunnerStatusChange={setRunnerStatus}
onSupportedEventPatternsChange={setSupportedEventPatterns}
/>
</fieldset>
}
debugTitle={canOperate ? t('agents.debugTab') : undefined}
debugContent={
canOperate ? (
<AgentDebugPanel
agentId={id}
supportedEventPatterns={
agent.supported_event_patterns ??
agent.capability?.supported_event_patterns ?? ['*']
hasUnsavedChanges={formDirty}
beforeRun={async () => agentFormRef.current?.save() ?? false}
onOpenRunnerConfig={() =>
agentFormRef.current?.openSection('runner_config')
}
supportedEventPatterns={supportedEventPatterns}
availableEventTypes={availableEventTypes}
/>
</TabsContent>
)}
</Tabs>
</div>
) : undefined
}
unsavedLabel={t('pipelines.unsavedChanges')}
/>
<EntityBasicInfoDialog
open={basicInfoOpen}
onOpenChange={setBasicInfoOpen}
values={{
name: agent.name,
description: agent.description || '',
emoji: agent.emoji || '🤖',
}}
defaultEmoji="🤖"
onSave={saveBasicInfo}
/>
<Dialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('common.confirmDelete')}</DialogTitle>
<DialogDescription>
{t('agents.deleteConfirmation')}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={deleting}
onClick={() => setDeleteConfirmOpen(false)}
>
{t('common.cancel')}
</Button>
<Button
type="button"
variant="destructive"
disabled={deleting}
onClick={deleteAgent}
>
<Trash2 className="size-4" />
{t('common.confirmDelete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -51,9 +51,12 @@ export default function AgentCreateContent({
});
function handleKindChange(nextKind: AgentKind) {
const previousDefaultEmoji = kind === 'pipeline' ? '⚙️' : '🤖';
const nextDefaultEmoji = nextKind === 'pipeline' ? '⚙️' : '🤖';
setKind(nextKind);
if (!form.getValues('emoji')) {
form.setValue('emoji', nextKind === 'pipeline' ? '⚙️' : '🤖');
const currentEmoji = form.getValues('emoji');
if (!currentEmoji || currentEmoji === previousDefaultEmoji) {
form.setValue('emoji', nextDefaultEmoji);
}
}
@@ -1,39 +1,50 @@
import { useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import {
AlertTriangle,
Braces,
AlertCircle,
ChevronDown,
CircleHelp,
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,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import {
eventGroupLabel,
eventPatternDescription,
eventPatternLabel,
groupEventPatterns,
} from '@/app/home/components/event-patterns/event-pattern-groups';
import EventSelectOptionContent from '@/app/home/components/event-patterns/EventSelectOptionContent';
interface AgentDebugPanelProps {
agentId: string;
availableEventTypes: string[];
supportedEventPatterns?: string[];
beforeRun?: () => Promise<boolean>;
hasUnsavedChanges?: boolean;
onOpenRunnerConfig?: () => void;
}
interface DebugEntry {
@@ -41,18 +52,19 @@ interface DebugEntry {
direction: 'input' | 'output' | 'error';
eventType: string;
text: string;
errorCode?: string;
detail?: string;
}
const EVENT_PRESETS = [
{
value: 'message.received',
labelKey: 'agents.debugMessageReceived',
const EVENT_PRESET_DATA: Record<
string,
{ text: string; data: Record<string, unknown> }
> = {
'message.received': {
text: '',
data: {},
},
{
value: 'group.member.joined',
labelKey: 'agents.debugGroupMemberJoined',
'group.member_joined': {
text: 'A new member joined the group.',
data: {
group_id: 'debug-group',
@@ -60,9 +72,7 @@ const EVENT_PRESETS = [
member_name: 'Debug User',
},
},
{
value: 'group.member.left',
labelKey: 'agents.debugGroupMemberLeft',
'group.member_left': {
text: 'A member left the group.',
data: {
group_id: 'debug-group',
@@ -70,9 +80,7 @@ const EVENT_PRESETS = [
member_name: 'Debug User',
},
},
{
value: 'friend.requested',
labelKey: 'agents.debugFriendRequested',
'friend.request_received': {
text: 'A user sent a friend request.',
data: {
requester_id: 'debug-user',
@@ -80,31 +88,32 @@ const EVENT_PRESETS = [
message: 'Hello',
},
},
{
value: 'feedback.received',
labelKey: 'agents.debugFeedbackReceived',
'feedback.received': {
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}`;
}
function matchesEventPattern(pattern: string, eventType: string) {
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`^${escaped.replaceAll('*', '.*')}$`).test(eventType);
}
export default function AgentDebugPanel({
agentId,
availableEventTypes,
supportedEventPatterns = ['*'],
beforeRun,
hasUnsavedChanges = false,
onOpenRunnerConfig,
}: AgentDebugPanelProps) {
const { t } = useTranslation();
const [preset, setPreset] = useState('message.received');
@@ -121,11 +130,39 @@ export default function AgentDebugPanel({
() => supportedEventPatterns.join(', '),
[supportedEventPatterns],
);
const availableEvents = useMemo(() => {
const concretePatterns = supportedEventPatterns.filter(
(pattern) => pattern !== '*' && !pattern.endsWith('.*'),
);
return Array.from(new Set([...availableEventTypes, ...concretePatterns]))
.filter((candidate) =>
supportedEventPatterns.some((pattern) =>
matchesEventPattern(pattern, candidate),
),
)
.sort();
}, [availableEventTypes, supportedEventPatterns]);
const eventGroups = useMemo(
() => groupEventPatterns(availableEvents),
[availableEvents],
);
const supportsCustomEvent = supportedEventPatterns.some(
(pattern) => pattern === '*' || pattern.endsWith('.*'),
);
useEffect(() => {
if (
availableEvents.includes(preset) ||
(preset === 'custom' && supportsCustomEvent)
) {
return;
}
selectPreset(availableEvents[0] ?? 'custom');
}, [availableEvents, preset, supportsCustomEvent]);
function selectPreset(value: string) {
setPreset(value);
const nextPreset = EVENT_PRESETS.find((item) => item.value === value);
if (!nextPreset) return;
const nextPreset = EVENT_PRESET_DATA[value] ?? { text: '', data: {} };
setInputText(nextPreset.text);
setEventDataText(JSON.stringify(nextPreset.data, null, 2));
}
@@ -144,6 +181,14 @@ export default function AgentDebugPanel({
toast.error(t('agents.debugInputRequired'));
return;
}
if (
!supportedEventPatterns.some((pattern) =>
matchesEventPattern(pattern, eventType),
)
) {
toast.error(t('agents.debugUnsupportedEvent'));
return;
}
let eventData: Record<string, unknown>;
try {
@@ -157,6 +202,12 @@ export default function AgentDebugPanel({
return;
}
setRunning(true);
if (hasUnsavedChanges && beforeRun && !(await beforeRun())) {
setRunning(false);
return;
}
const requestId = globalThis.crypto?.randomUUID?.() ?? String(Date.now());
setEntries((current) => [
...current,
@@ -167,7 +218,6 @@ export default function AgentDebugPanel({
text: inputText.trim() || JSON.stringify(eventData, null, 2),
},
]);
setRunning(true);
try {
const result = await httpClient.debugAgent(agentId, {
event_type: eventType,
@@ -186,17 +236,41 @@ export default function AgentDebugPanel({
]);
if (isMessageEvent) setInputText('');
} catch (error) {
const errorCode =
typeof error === 'object' && error && 'code' in error
? String((error as { code?: string }).code || '')
: '';
const message =
typeof error === 'object' && error && 'msg' in error
? String((error as { msg?: string }).msg || '')
: t('agents.debugRunFailed');
const isConfigError = errorCode.endsWith('.config_invalid');
const isExecutionError = errorCode === 'runner_execution_failed';
const isTimeout = errorCode === 'runner.timeout';
const friendlyMessage = isConfigError
? t('agents.debugRunnerConfigInvalidDescription', {
message:
message === 'api-key is required'
? t('agents.debugApiKeyRequired')
: message,
})
: isExecutionError
? t('agents.debugRunnerExecutionFailedDescription')
: isTimeout
? t('agents.debugRunnerTimeoutDescription')
: message || t('agents.debugRunFailed');
setEntries((current) => [
...current,
{
id: `error:${requestId}`,
direction: 'error',
eventType,
text: message || t('agents.debugRunFailed'),
text: friendlyMessage,
errorCode,
detail:
isExecutionError || isTimeout
? message || t('agents.debugRunFailed')
: undefined,
},
]);
} finally {
@@ -205,162 +279,215 @@ export default function AgentDebugPanel({
}
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)}
<div className="flex h-full min-h-0 min-w-0 flex-col">
<div className="shrink-0 space-y-3 border-b p-3">
<div className="flex items-end gap-2">
<div className="min-w-0 flex-1 space-y-1.5">
<Label>{t('agents.debugEventType')}</Label>
<Select value={preset} onValueChange={selectPreset}>
<SelectTrigger
className="w-full"
aria-label={t('agents.debugEventType')}
>
<SelectValue />
</SelectTrigger>
<SelectContent className="w-[var(--radix-select-trigger-width)] max-w-[calc(100vw-2rem)]">
{eventGroups.map((group) => (
<SelectGroup key={group.namespace}>
<SelectLabel>
{eventGroupLabel(group.namespace, t)}
</SelectLabel>
{group.patterns.map((event) => (
<SelectItem
key={event}
value={event}
description={eventPatternDescription(event, t)}
className="py-2"
>
<EventSelectOptionContent
event={event}
label={eventPatternLabel(event, t)}
/>
</SelectItem>
))}
</SelectGroup>
))}
{supportsCustomEvent && (
<SelectGroup>
<SelectLabel>{t('agents.debugCustomEvent')}</SelectLabel>
<SelectItem
value="custom"
description={t('bots.eventDescriptions.custom')}
className="py-2"
>
<EventSelectOptionContent
event="custom.event"
label={t('agents.debugCustomEvent')}
/>
</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>
)}
</SelectGroup>
)}
</SelectContent>
</Select>
</div>
<Button
type="button"
variant="outline"
size="icon"
onClick={resetSession}
title={t('agents.debugResetSession')}
>
<RotateCcw className="size-4" />
</Button>
</div>
<div className="space-y-2">
<Label htmlFor="agent-debug-input">
{isMessageEvent
? t('agents.debugMessageInput')
: t('agents.debugEventSummary')}
{preset === 'custom' && (
<div className="space-y-1.5">
<Label htmlFor="agent-debug-custom-event">
{t('agents.debugCustomEventType')}
</Label>
<Textarea
id="agent-debug-input"
value={inputText}
onChange={(event) => setInputText(event.target.value)}
className="min-h-24 resize-y"
placeholder={t('agents.debugInputPlaceholder')}
<Input
id="agent-debug-custom-event"
value={customEventType}
onChange={(event) => setCustomEventType(event.target.value)}
placeholder="custom.event"
/>
</div>
)}
</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>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
<div className="mb-3">
<p className="text-sm font-medium">{t('agents.debugTranscript')}</p>
<p className="text-xs text-muted-foreground">
{t('agents.debugTranscriptDescription')}
</p>
</div>
{entries.length === 0 ? (
<Alert className="my-4 bg-muted/20">
<CircleHelp className="size-4" />
<AlertTitle>{t('agents.debugEmptyTitle')}</AlertTitle>
<AlertDescription>
{t('agents.debugEmptyTranscript')}
</AlertDescription>
</Alert>
) : (
<div className="space-y-3">
{entries.map((entry) => (
<Alert
key={entry.id}
variant={
entry.direction === 'error' ? 'destructive' : 'default'
}
className={
entry.direction === 'output'
? 'border-primary/20 bg-primary/5'
: entry.direction === 'input'
? 'bg-muted/40'
: undefined
}
>
{entry.direction === 'error' && <AlertCircle />}
<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>
{entry.detail && (
<Collapsible className="mt-3">
<CollapsibleTrigger asChild>
<Button type="button" variant="ghost" size="sm">
{t('agents.debugErrorDetails')}
<ChevronDown className="size-3.5" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<pre className="mt-2 max-h-32 overflow-auto whitespace-pre-wrap break-words rounded-md bg-muted p-2 font-mono text-xs text-muted-foreground">
{entry.detail}
</pre>
</CollapsibleContent>
</Collapsible>
)}
{(entry.errorCode?.endsWith('.config_invalid') ||
entry.errorCode === 'runner_execution_failed' ||
entry.errorCode === 'runner.timeout') &&
onOpenRunnerConfig && (
<Button
type="button"
variant="outline"
size="sm"
className="mt-3"
onClick={onOpenRunnerConfig}
>
{t('agents.debugReviewRunnerConfig')}
</Button>
)}
</Alert>
))}
</div>
)}
</div>
<div className="shrink-0 space-y-3 border-t p-3">
<div className="space-y-1.5">
<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-20 resize-y"
placeholder={t('agents.debugInputPlaceholder')}
/>
</div>
<details className="rounded-md border bg-muted/20 px-3 py-2">
<summary className="cursor-pointer text-xs font-medium">
{t('agents.debugEventPayload')}
</summary>
<div className="mt-2 space-y-2">
<p className="text-xs text-muted-foreground">
{t('agents.debugSupportedEvents')}: {supportedLabel}
</p>
<Textarea
id="agent-debug-payload"
value={eventDataText}
onChange={(event) => setEventDataText(event.target.value)}
className="min-h-40 resize-y font-mono text-xs"
className="min-h-28 resize-y font-mono text-xs"
spellCheck={false}
/>
</div>
</details>
<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>
<Button
type="button"
className="w-full"
disabled={running}
onClick={runDebugEvent}
>
{running ? (
<LoaderCircle className="size-4 animate-spin" />
) : (
<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>
<Play className="size-4" />
)}
</CardContent>
</Card>
{running
? t('agents.debugRunning')
: hasUnsavedChanges
? t('agents.debugSaveAndRun')
: t('agents.debugRun')}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,173 @@
import { useMemo, useState } from 'react';
import { Check, ChevronsUpDown } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import {
eventGroupLabel,
eventNamespaces,
eventPatternDescription,
eventPatternLabel,
groupEventPatterns,
} from '@/app/home/components/event-patterns/event-pattern-groups';
const FALLBACK_EVENTS = ['message.received'];
interface AgentEventPatternPickerProps {
events: string[];
value: string[];
onChange: (patterns: string[]) => void;
}
export default function AgentEventPatternPicker({
events,
value,
onChange,
}: AgentEventPatternPickerProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const selectedPatterns = useMemo(
() => (value.length > 0 ? value : ['*']),
[value],
);
const options = useMemo(() => {
const concreteEvents = Array.from(
new Set([
...(events.length > 0 ? events : FALLBACK_EVENTS),
...selectedPatterns.filter(
(pattern) => pattern !== '*' && !pattern.endsWith('.*'),
),
]),
).sort();
const namespaces = Array.from(
new Set([
...eventNamespaces(concreteEvents),
...selectedPatterns.filter((pattern) => pattern.endsWith('.*')),
]),
).sort();
return ['*', ...namespaces, ...concreteEvents];
}, [events, selectedPatterns]);
const optionGroups = useMemo(() => groupEventPatterns(options), [options]);
function togglePattern(pattern: string) {
if (pattern === '*') {
onChange(['*']);
return;
}
if (selectedPatterns.includes(pattern)) {
const next = selectedPatterns.filter((item) => item !== pattern);
onChange(next.length > 0 ? next : ['*']);
return;
}
let next = selectedPatterns.filter((item) => item !== '*');
const namespace = pattern.split('.')[0];
if (pattern.endsWith('.*')) {
next = next.filter(
(item) => item.split('.')[0] !== namespace || item.endsWith('.*'),
);
} else {
next = next.filter((item) => item !== `${namespace}.*`);
}
onChange(Array.from(new Set([...next, pattern])));
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
aria-label={t('agents.supportedEvents')}
className="h-auto min-h-10 w-full min-w-0 justify-between gap-2 px-3 py-2 font-normal"
>
<span className="flex min-w-0 flex-1 flex-wrap gap-1.5">
{selectedPatterns.slice(0, 3).map((pattern) => (
<Badge
key={pattern}
variant="secondary"
className="max-w-full rounded-md font-normal"
>
<span className="truncate">
{eventPatternLabel(pattern, t)}
</span>
</Badge>
))}
{selectedPatterns.length > 3 && (
<Badge variant="outline" className="rounded-md font-normal">
+{selectedPatterns.length - 3}
</Badge>
)}
</span>
<ChevronsUpDown className="size-4 shrink-0 text-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] p-0"
>
<Command>
<CommandInput placeholder={t('agents.searchEvents')} />
<CommandList>
<CommandEmpty>{t('agents.noEventsFound')}</CommandEmpty>
{optionGroups.map((group) => (
<CommandGroup
key={group.namespace}
heading={eventGroupLabel(group.namespace, t)}
>
{group.patterns.map((pattern) => {
const selected = selectedPatterns.includes(pattern);
return (
<CommandItem
key={pattern}
value={`${eventPatternLabel(pattern, t)} ${pattern}`}
onSelect={() => togglePattern(pattern)}
className="items-start gap-2 py-2"
>
<Check
className={cn(
'mt-0.5 size-4 shrink-0',
selected ? 'opacity-100' : 'opacity-0',
)}
/>
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-center gap-2">
<span className="truncate font-medium">
{eventPatternLabel(pattern, t)}
</span>
<code className="shrink-0 text-[10px] text-muted-foreground">
{pattern}
</code>
</span>
<span className="mt-0.5 block text-xs text-muted-foreground">
{eventPatternDescription(pattern, t)}
</span>
</span>
</CommandItem>
);
})}
</CommandGroup>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
@@ -1,19 +1,19 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import {
forwardRef,
type ForwardedRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import {
CircleAlert,
CircleCheck,
LoaderCircle,
Power,
RefreshCw,
Trash2,
Unplug,
} from 'lucide-react';
import { Bot, SlidersHorizontal, Zap } from 'lucide-react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { Agent, ApiRespPluginSystemStatus } from '@/app/infra/entities/api';
import {
@@ -22,11 +22,7 @@ import {
} from '@/app/infra/entities/pipeline';
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import EmojiPicker from '@/components/ui/emoji-picker';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Card,
CardContent,
@@ -34,39 +30,85 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import AgentEventPatternPicker from './AgentEventPatternPicker';
export interface AgentRunnerStatus {
label: string;
description?: string;
tone: 'neutral' | 'success' | 'warning' | 'error';
}
interface AgentFormComponentProps {
agentId: string;
onFinish: () => void;
onDeleted: () => void;
availableEventTypes: string[];
onFinish: (agent?: Partial<Agent>) => void;
onDirtyChange?: (dirty: boolean) => void;
onSavingChange?: (saving: boolean) => void;
onRunnerStatusChange?: (status: AgentRunnerStatus) => void;
onSupportedEventPatternsChange?: (patterns: string[]) => void;
}
export default function AgentFormComponent({
agentId,
onFinish,
onDeleted,
onDirtyChange,
onSavingChange,
}: AgentFormComponentProps) {
export type AgentConfigSection = 'events' | 'runner' | 'runner_config';
export interface AgentFormHandle {
openSection: (section: AgentConfigSection) => void;
save: () => Promise<boolean>;
syncBasicInfo: (values: {
name: string;
description: string;
emoji?: string;
}) => void;
}
function isRequiredRunnerValueMissing(value: unknown): boolean {
if (value === null || value === undefined) return true;
if (typeof value === 'string') return value.trim() === '';
if (Array.isArray(value)) return value.length === 0;
if (typeof value === 'object' && 'primary' in value) {
return !String((value as { primary?: unknown }).primary || '').trim();
}
return false;
}
function isRunnerFieldVisible(
field: PipelineConfigStage['config'][number],
values: Record<string, unknown>,
) {
if (!field.show_if || field.show_if.field.startsWith('__system.')) {
return true;
}
const dependentValue = values[field.show_if.field];
if (field.show_if.operator === 'eq') {
return dependentValue === field.show_if.value;
}
if (field.show_if.operator === 'neq') {
return dependentValue !== field.show_if.value;
}
return (
Array.isArray(field.show_if.value) &&
field.show_if.value.includes(dependentValue)
);
}
function AgentFormComponent(
{
agentId,
availableEventTypes,
onFinish,
onDirtyChange,
onSavingChange,
onRunnerStatusChange,
onSupportedEventPatternsChange,
}: AgentFormComponentProps,
ref: ForwardedRef<AgentFormHandle>,
) {
const { t } = useTranslation();
const [runnerConfigSchema, setRunnerConfigSchema] =
useState<PipelineConfigTab | null>(null);
@@ -74,20 +116,20 @@ export default function AgentFormComponent({
useState<ApiRespPluginSystemStatus | null>(null);
const [pluginStatusLoading, setPluginStatusLoading] = useState(true);
const [pluginStatusError, setPluginStatusError] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [activeSection, setActiveSection] =
useState<AgentConfigSection>('runner');
const isSavingRef = useRef(false);
const hasUnsavedChangesRef = useRef(false);
const formSchema = z.object({
basic: z.object({
name: z.string().min(1, { message: t('agents.nameRequired') }),
description: z.string().optional(),
emoji: z.string().optional(),
enabled: z.boolean().optional(),
}),
runner: z.record(z.string(), z.any()),
runner_config: z.record(z.string(), z.any()),
supported_event_patterns_text: z.string(),
supported_event_patterns: z.array(z.string()).min(1),
});
type FormValues = z.infer<typeof formSchema>;
@@ -98,11 +140,10 @@ export default function AgentFormComponent({
name: '',
description: '',
emoji: '🤖',
enabled: true,
},
runner: {},
runner_config: {},
supported_event_patterns_text: '*',
supported_event_patterns: ['*'],
},
});
@@ -113,11 +154,17 @@ export default function AgentFormComponent({
if (!savedSnapshotRef.current) return false;
return JSON.stringify(watchedValues) !== savedSnapshotRef.current;
})();
hasUnsavedChangesRef.current = hasUnsavedChanges;
useEffect(() => {
onDirtyChange?.(hasUnsavedChanges);
}, [hasUnsavedChanges, onDirtyChange]);
const supportedEventPatterns = form.watch('supported_event_patterns');
useEffect(() => {
onSupportedEventPatternsChange?.(supportedEventPatterns);
}, [onSupportedEventPatternsChange, supportedEventPatterns]);
useEffect(() => {
let cancelled = false;
Promise.all([httpClient.getAgentMetadata(), httpClient.getAgent(agentId)])
@@ -131,15 +178,12 @@ export default function AgentFormComponent({
name: agent.name ?? '',
description: agent.description ?? '',
emoji: agent.emoji || '🤖',
enabled: agent.enabled ?? true,
},
runner: (config.runner as Record<string, unknown>) ?? {},
runner_config:
(config.runner_config as Record<string, unknown>) ?? {},
supported_event_patterns_text: (
agent.supported_event_patterns ??
agent.capability?.supported_event_patterns ?? ['*']
).join('\n'),
supported_event_patterns: agent.supported_event_patterns ??
agent.capability?.supported_event_patterns ?? ['*'],
};
form.reset(loadedValues);
savedSnapshotRef.current = JSON.stringify(loadedValues);
@@ -182,117 +226,137 @@ export default function AgentFormComponent({
const selectedRunnerOption = runnerOptions.find(
(option) => option.name === currentRunner,
);
const runnerSelectorStage = runnerConfigSchema?.stages.find(
(stage) => stage.name === 'runner',
);
const activeRunnerStage = runnerConfigSchema?.stages.find(
(stage) => stage.name === currentRunner,
);
const runnerConfigValues = form.watch('runner_config') as Record<
string,
Record<string, unknown>
>;
const activeRunnerValues = useMemo(
() => runnerConfigValues?.[currentRunner] ?? {},
[currentRunner, runnerConfigValues],
);
const missingRunnerFields = useMemo(
() =>
(activeRunnerStage?.config ?? []).filter(
(field) =>
field.required &&
isRunnerFieldVisible(field, activeRunnerValues) &&
isRequiredRunnerValueMissing(activeRunnerValues[field.name]),
),
[activeRunnerStage, activeRunnerValues],
);
const primarySections: Array<{
name: AgentConfigSection;
label: string;
icon: React.ElementType;
}> = [
{
name: 'runner',
label: t('agents.runnerSettings'),
icon: Bot,
},
{
name: 'runner_config',
label: selectedRunnerOption
? extractI18nObject(selectedRunnerOption.label)
: t('pipelines.configuration'),
icon: SlidersHorizontal,
},
{
name: 'events',
label: t('agents.bindableEvents'),
icon: Zap,
},
];
function renderRunnerStatusActions(showRetry = true) {
return (
<div className="mt-3 flex flex-wrap gap-2">
{showRetry && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void loadPluginSystemStatus()}
>
<RefreshCw className="size-4" />
{t('common.retry')}
</Button>
)}
<Button type="button" variant="outline" size="sm" asChild>
<Link to="/home/extensions">{t('plugins.title')}</Link>
</Button>
</div>
);
}
function renderRunnerStatus() {
const runnerStatus = useMemo<AgentRunnerStatus>(() => {
if (pluginStatusLoading) {
return (
<Alert>
<LoaderCircle className="animate-spin" />
<AlertTitle>{t('agents.runnerStatusLoading')}</AlertTitle>
</Alert>
);
return {
label: t('agents.runnerStatusLoading'),
tone: 'neutral',
};
}
if (pluginStatusError || !pluginSystemStatus) {
return (
<Alert variant="destructive">
<CircleAlert />
<AlertTitle>{t('agents.runnerStatusCheckFailed')}</AlertTitle>
<AlertDescription>
{t('agents.runnerStatusCheckFailedDescription')}
{renderRunnerStatusActions()}
</AlertDescription>
</Alert>
);
return {
label: t('agents.runnerStatusCheckFailed'),
description: t('agents.runnerStatusCheckFailedDescription'),
tone: 'error',
};
}
if (!pluginSystemStatus.is_enable) {
return (
<Alert variant="destructive">
<Power />
<AlertTitle>{t('plugins.systemDisabled')}</AlertTitle>
<AlertDescription>
{t('plugins.systemDisabledDesc')}
{renderRunnerStatusActions(false)}
</AlertDescription>
</Alert>
);
return {
label: t('plugins.systemDisabled'),
description: t('plugins.systemDisabledDesc'),
tone: 'error',
};
}
if (!pluginSystemStatus.is_connected) {
return (
<Alert variant="destructive">
<Unplug />
<AlertTitle>{t('plugins.connectionError')}</AlertTitle>
<AlertDescription>
{t('plugins.connectionErrorDesc')}
{renderRunnerStatusActions()}
</AlertDescription>
</Alert>
);
return {
label: t('plugins.connectionError'),
description: t('plugins.connectionErrorDesc'),
tone: 'error',
};
}
if (runnerOptions.length === 0) {
return (
<Alert variant="destructive">
<CircleAlert />
<AlertTitle>{t('agents.noRunnersAvailable')}</AlertTitle>
<AlertDescription>
{t('agents.noRunnersAvailableDescription')}
{renderRunnerStatusActions()}
</AlertDescription>
</Alert>
);
return {
label: t('agents.noRunnersAvailable'),
description: t('agents.noRunnersAvailableDescription'),
tone: 'error',
};
}
if (!currentRunner || !selectedRunnerOption) {
return (
<Alert variant="destructive">
<CircleAlert />
<AlertTitle>{t('agents.selectedRunnerUnavailable')}</AlertTitle>
<AlertDescription>
{t('agents.selectedRunnerUnavailableDescription', {
runner: currentRunner || t('agents.noRunnerSelected'),
})}
{renderRunnerStatusActions()}
</AlertDescription>
</Alert>
);
return {
label: t('agents.selectedRunnerUnavailable'),
description: t('agents.selectedRunnerUnavailableDescription', {
runner: currentRunner || t('agents.noRunnerSelected'),
}),
tone: 'warning',
};
}
return (
<Alert className="border-emerald-600/40 bg-emerald-500/5 text-emerald-950 dark:text-emerald-100">
<CircleCheck className="text-emerald-600" />
<AlertTitle>{t('agents.runnerReady')}</AlertTitle>
<AlertDescription>
{t('agents.runnerReadyDescription', {
runner: extractI18nObject(selectedRunnerOption.label),
})}
</AlertDescription>
</Alert>
);
}
if (missingRunnerFields.length > 0) {
return {
label: t('agents.runnerConfigIncomplete'),
description: t('agents.runnerConfigIncompleteDescription', {
fields: missingRunnerFields
.map((field) => extractI18nObject(field.label))
.join(', '),
}),
tone: 'warning',
};
}
return {
label: t('agents.runnerReady'),
description: t('agents.runnerReadyDescription', {
runner: extractI18nObject(selectedRunnerOption.label),
}),
tone: 'success',
};
}, [
currentRunner,
pluginStatusError,
pluginStatusLoading,
pluginSystemStatus,
runnerOptions.length,
missingRunnerFields,
selectedRunnerOption,
t,
]);
useEffect(() => {
onRunnerStatusChange?.(runnerStatus);
}, [onRunnerStatusChange, runnerStatus]);
function updateSnapshotIfInitial(stageKey: string) {
if (!initializedStagesRef.current.has(stageKey)) {
@@ -364,208 +428,125 @@ export default function AgentFormComponent({
);
}
function normalizeEventPatterns(value: string): string[] {
const patterns = value
.split(/[\n,]/)
.map((item) => item.trim())
.filter(Boolean);
return patterns.length > 0 ? patterns : ['*'];
}
const saveValues = useCallback(
async (values: FormValues) => {
if (isSavingRef.current) return false;
const submittedSnapshot = JSON.stringify(values);
const runner = values.runner || {};
const agent: Partial<Agent> = {
name: values.basic.name,
description: values.basic.description ?? '',
emoji: values.basic.emoji,
component_ref: (runner.id as string) || null,
supported_event_patterns: values.supported_event_patterns,
config: {
runner,
runner_config: values.runner_config ?? {},
},
};
isSavingRef.current = true;
onSavingChange?.(true);
try {
await httpClient.updateAgent(agentId, agent);
savedSnapshotRef.current = submittedSnapshot;
onFinish(agent);
toast.success(t('agents.saveSuccess'));
return true;
} catch (err) {
const message =
typeof err === 'object' && err && 'msg' in err
? String((err as { msg?: string }).msg || '')
: '';
toast.error(t('agents.saveError') + message);
return false;
} finally {
isSavingRef.current = false;
onSavingChange?.(false);
}
},
[agentId, onFinish, onSavingChange, t],
);
function handleSubmit(values: FormValues) {
if (isSavingRef.current) return;
const submittedSnapshot = JSON.stringify(values);
const runner = values.runner || {};
const agent: Partial<Agent> = {
name: values.basic.name,
description: values.basic.description ?? '',
emoji: values.basic.emoji,
enabled: values.basic.enabled ?? true,
component_ref: (runner.id as string) || null,
supported_event_patterns: normalizeEventPatterns(
values.supported_event_patterns_text,
),
config: {
runner,
runner_config: values.runner_config ?? {},
void saveValues(values);
}
useImperativeHandle(
ref,
() => ({
openSection: setActiveSection,
syncBasicInfo(values) {
form.setValue('basic', {
...form.getValues('basic'),
name: values.name,
description: values.description,
emoji: values.emoji || '🤖',
});
if (savedSnapshotRef.current) {
const snapshot = JSON.parse(savedSnapshotRef.current) as FormValues;
snapshot.basic = {
...snapshot.basic,
name: values.name,
description: values.description,
emoji: values.emoji || '🤖',
};
savedSnapshotRef.current = JSON.stringify(snapshot);
}
},
};
isSavingRef.current = true;
setIsSaving(true);
onSavingChange?.(true);
httpClient
.updateAgent(agentId, agent)
.then(() => {
savedSnapshotRef.current = submittedSnapshot;
onFinish();
toast.success(t('agents.saveSuccess'));
})
.catch((err) => {
toast.error(t('agents.saveError') + err.msg);
})
.finally(() => {
isSavingRef.current = false;
setIsSaving(false);
onSavingChange?.(false);
});
}
function confirmDelete() {
httpClient
.deleteAgent(agentId)
.then(() => {
toast.success(t('agents.deleteSuccess'));
setShowDeleteConfirm(false);
onDeleted();
})
.catch((err) => {
toast.error(t('agents.deleteError') + err.msg);
});
}
async save() {
if (!hasUnsavedChangesRef.current) return true;
if (isSavingRef.current) return false;
const valid = await form.trigger();
if (!valid) return false;
return (await saveValues(form.getValues())) ?? false;
},
}),
[form, saveValues],
);
return (
<>
<div className="h-full p-0 flex flex-col">
<Form {...form}>
<form
id="agent-form"
onSubmit={form.handleSubmit(handleSubmit)}
className="mb-2 flex h-full min-h-0 min-w-0 flex-1 flex-col"
>
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden">
<div className="mx-auto flex w-full min-w-0 max-w-5xl flex-col gap-6 pb-8">
{
<div className="contents">
<Card className="order-2">
<CardHeader>
<CardTitle>{t('agents.basicInfo')}</CardTitle>
<CardDescription>
{t('agents.basicInfoDescription')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-4 items-start">
<FormField
control={form.control}
name="basic.name"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
{t('common.name')}
<span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input
{...field}
value={field.value ?? ''}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="basic.emoji"
render={({ field }) => (
<FormItem>
<FormLabel>{t('common.icon')}</FormLabel>
<FormControl>
<EmojiPicker
value={field.value}
onChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="h-full p-0 flex flex-col">
<Form {...form}>
<form
id="agent-form"
onSubmit={form.handleSubmit(handleSubmit)}
className="mb-2 flex h-full min-h-0 min-w-0 flex-1 flex-col"
>
<nav className="mb-4 shrink-0 space-y-2 border-b pb-4">
<Tabs
value={activeSection}
onValueChange={(value) =>
setActiveSection(value as AgentConfigSection)
}
>
<div className="min-w-0">
<TabsList className="grid h-auto w-full min-w-0 grid-cols-3">
{primarySections.map((section) => {
const Icon = section.icon;
return (
<TabsTrigger
key={section.name}
value={section.name}
className="min-w-0 gap-1.5 px-2"
>
<Icon className="size-4 shrink-0" />
<span className="truncate">{section.label}</span>
</TabsTrigger>
);
})}
</TabsList>
</div>
</Tabs>
</nav>
<FormField
control={form.control}
name="basic.description"
render={({ field }) => (
<FormItem>
<FormLabel>{t('common.description')}</FormLabel>
<FormControl>
<Input {...field} value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="basic.enabled"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="flex items-center gap-2">
<Power className="size-4" />
{t('agents.enabled')}
</FormLabel>
<FormDescription>
{t('agents.enabledDescription')}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value ?? true}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</CardContent>
</Card>
<Card className="order-4 border-destructive/50">
<CardHeader>
<CardTitle className="text-destructive">
{t('agents.dangerZone')}
</CardTitle>
<CardDescription>
{t('agents.dangerZoneDescription')}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium">
{t('agents.deleteAgentAction')}
</p>
<p className="text-sm text-muted-foreground">
{t('agents.deleteAgentHint')}
</p>
</div>
<Button
type="button"
variant="destructive"
size="sm"
disabled={isSaving}
onClick={() => setShowDeleteConfirm(true)}
>
<Trash2 className="size-4 mr-1.5" />
{t('common.delete')}
</Button>
</div>
</CardContent>
</Card>
</div>
}
{
<div className="order-1 space-y-6">
{renderRunnerStatus()}
{runnerConfigSchema?.stages.map((stage) =>
renderDynamicStage(stage),
)}
{!runnerConfigSchema && (
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden">
<div className="mx-auto w-full min-w-0 max-w-5xl space-y-6 pb-8">
{activeSection === 'runner' && (
<div className="space-y-6">
{runnerSelectorStage
? renderDynamicStage(runnerSelectorStage)
: !runnerConfigSchema && (
<Card>
<CardHeader>
<CardTitle>{t('agents.runnerSettings')}</CardTitle>
@@ -575,69 +556,61 @@ export default function AgentFormComponent({
</CardHeader>
</Card>
)}
</div>
}
</div>
)}
{
<Card className="order-3">
{activeSection === 'runner_config' && (
<div className="space-y-6">
{activeRunnerStage ? (
renderDynamicStage(activeRunnerStage)
) : (
<Card>
<CardHeader>
<CardTitle>{t('agents.bindableEvents')}</CardTitle>
<CardTitle>{t('agents.runnerSettings')}</CardTitle>
<CardDescription>
{t('agents.bindableEventsDescription')}
{t('agents.noRunnerMetadata')}
</CardDescription>
</CardHeader>
<CardContent>
<FormField
control={form.control}
name="supported_event_patterns_text"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('agents.supportedEvents')}
</FormLabel>
<FormControl>
<Textarea
{...field}
className="min-h-32 font-mono text-sm"
placeholder={'*\nmessage.received\ngroup.*'}
/>
</FormControl>
<FormDescription>
{t('agents.supportedEventsDescription')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
}
)}
</div>
</div>
</div>
</form>
</Form>
</div>
)}
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('common.confirmDelete')}</DialogTitle>
</DialogHeader>
<div className="py-4">{t('agents.deleteConfirmation')}</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowDeleteConfirm(false)}
>
{t('common.cancel')}
</Button>
<Button variant="destructive" onClick={confirmDelete}>
{t('common.confirmDelete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
{activeSection === 'events' && (
<Card>
<CardHeader>
<CardTitle>{t('agents.bindableEvents')}</CardTitle>
<CardDescription>
{t('agents.bindableEventsDescription')}
</CardDescription>
</CardHeader>
<CardContent>
<FormField
control={form.control}
name="supported_event_patterns"
render={({ field }) => (
<FormItem>
<AgentEventPatternPicker
events={availableEventTypes}
value={field.value}
onChange={field.onChange}
/>
<FormDescription>
{t('agents.supportedEventsDescription')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
)}
</div>
</div>
</form>
</Form>
</div>
);
}
export default forwardRef(AgentFormComponent);
+59 -14
View File
@@ -19,7 +19,9 @@ import {
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import BotForm from '@/app/home/bots/components/bot-form/BotForm';
import BotForm, {
BotFormHandle,
} from '@/app/home/bots/components/bot-form/BotForm';
import { BotLogListComponent } from '@/app/home/bots/components/bot-log/view/BotLogListComponent';
import BotSessionMonitor from '@/app/home/bots/components/bot-session/BotSessionMonitor';
import type { BotSessionMonitorHandle } from '@/app/home/bots/components/bot-session/BotSessionMonitor';
@@ -30,6 +32,11 @@ import { Settings, FileText, Users, RefreshCw, Trash2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
import { useCurrentWorkspace } from '@/app/infra/http';
import { Bot } from '@/app/infra/entities/api';
import EntityBasicInfoDialog, {
EntityBasicInfoValues,
} from '@/app/home/components/entity-basic-info/EntityBasicInfoDialog';
import EntityTitleEditButton from '@/app/home/components/entity-basic-info/EntityTitleEditButton';
export default function BotDetailContent({ id }: { id: string }) {
const isCreateMode = id === 'new';
@@ -55,8 +62,11 @@ export default function BotDetailContent({ id }: { id: string }) {
const [activeTab, setActiveTab] = useState('config');
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [basicInfoOpen, setBasicInfoOpen] = useState(false);
const [bot, setBot] = useState<Bot | null>(null);
const [isRefreshingSessions, setIsRefreshingSessions] = useState(false);
const sessionMonitorRef = useRef<BotSessionMonitorHandle>(null);
const botFormRef = useRef<BotFormHandle>(null);
// Track whether the form has unsaved changes
const [formDirty, setFormDirty] = useState(false);
@@ -69,6 +79,7 @@ export default function BotDetailContent({ id }: { id: string }) {
useEffect(() => {
if (!isCreateMode) {
httpClient.getBot(id).then((res) => {
setBot(res.bot);
setBotEnabled(res.bot.enable ?? true);
setEnableLoaded(true);
});
@@ -80,16 +91,10 @@ export default function BotDetailContent({ id }: { id: string }) {
const prev = botEnabled;
setBotEnabled(checked);
try {
// Fetch current bot data to send a complete update
const res = await httpClient.getBot(id);
const bot = res.bot;
await httpClient.updateBot(id, {
name: bot.name,
description: bot.description,
adapter: bot.adapter,
adapter_config: bot.adapter_config,
enable: checked,
});
await httpClient.updateBot(id, { enable: checked });
setBot((current) =>
current ? { ...current, enable: checked } : current,
);
refreshBots();
} catch {
setBotEnabled(prev);
@@ -102,6 +107,7 @@ export default function BotDetailContent({ id }: { id: string }) {
function handleFormSubmit() {
// Re-sync enable state after form save (form may update enable too)
httpClient.getBot(id).then((res) => {
setBot(res.bot);
setBotEnabled(res.bot.enable ?? true);
});
refreshBots();
@@ -117,6 +123,26 @@ export default function BotDetailContent({ id }: { id: string }) {
navigate(`/home/bots?id=${encodeURIComponent(newBotId)}`);
}
async function saveBasicInfo(values: EntityBasicInfoValues) {
try {
await httpClient.updateBot(id, {
name: values.name,
description: values.description,
});
setBot((current) => (current ? { ...current, ...values } : current));
botFormRef.current?.syncBasicInfo(values);
await refreshBots();
toast.success(t('bots.saveSuccess'));
} catch (error) {
const message =
typeof error === 'object' && error && 'msg' in error
? String((error as { msg?: string }).msg || '')
: '';
toast.error(t('bots.saveError') + message);
throw error;
}
}
function confirmDelete() {
httpClient
.deleteBot(id)
@@ -166,8 +192,15 @@ export default function BotDetailContent({ id }: { id: string }) {
<div className="flex h-full min-w-0 flex-col">
{/* Sticky Header: title + enable switch + save button */}
<div className="flex items-center justify-between pb-4 shrink-0">
<div className="flex items-center gap-4">
<h1 className="text-xl font-semibold">{t('bots.editBot')}</h1>
<div className="flex min-w-0 items-center gap-4">
<div className="flex min-w-0 items-center gap-1">
<h1 className="truncate text-xl font-semibold">
{bot?.name || t('bots.editBot')}
</h1>
{canManage && (
<EntityTitleEditButton onClick={() => setBasicInfoOpen(true)} />
)}
</div>
{enableLoaded && (
<div className="flex items-center gap-2">
<Switch
@@ -255,9 +288,10 @@ export default function BotDetailContent({ id }: { id: string }) {
value="config"
className="mt-4 min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden"
>
<div className="mx-auto w-full min-w-0 max-w-3xl space-y-6 pb-8">
<div className="mx-auto flex w-full min-w-0 max-w-3xl flex-col gap-6 pb-8">
<fieldset className="contents" disabled={!canManage}>
<BotForm
ref={botFormRef}
initBotId={id}
onFormSubmit={handleFormSubmit}
onNewBotCreated={handleNewBotCreated}
@@ -344,6 +378,17 @@ export default function BotDetailContent({ id }: { id: string }) {
</DialogFooter>
</DialogContent>
</Dialog>
<EntityBasicInfoDialog
open={basicInfoOpen}
onOpenChange={setBasicInfoOpen}
values={{
name: bot?.name || '',
description: bot?.description || '',
}}
showEmoji={false}
onSave={saveBasicInfo}
/>
</>
);
}
@@ -0,0 +1,335 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Activity,
AlertCircle,
ChevronDown,
RadioTower,
Trash2,
} from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { ScrollArea } from '@/components/ui/scroll-area';
import { backendClient } from '@/app/infra/http';
import type { BotLog } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
import {
eventPatternDescription,
eventPatternLabel,
} from '@/app/home/components/event-patterns/event-pattern-groups';
const POLL_INTERVAL_MS = 1200;
const MAX_VISIBLE_EVENTS = 50;
interface ObservedAdapterEvent {
seqId: number;
timestamp: number;
eventType: string;
eventData: Record<string, unknown>;
}
type ListenerState = 'preparing' | 'listening' | 'error';
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function observedEventFromLog(log: BotLog): ObservedAdapterEvent | null {
const metadata = log.metadata;
if (!isRecord(metadata) || metadata.kind !== 'adapter_event_received') {
return null;
}
const eventType = metadata.event_type;
if (typeof eventType !== 'string' || !eventType) return null;
return {
seqId: log.seq_id,
timestamp: log.timestamp,
eventType,
eventData: isRecord(metadata.event_data) ? metadata.event_data : {},
};
}
function findEventPreview(value: unknown, depth = 0): string | null {
if (depth > 4 || value === null || value === undefined) return null;
if (Array.isArray(value)) {
for (const item of value) {
const preview = findEventPreview(item, depth + 1);
if (preview) return preview;
}
return null;
}
if (!isRecord(value)) return null;
for (const key of ['message_text', 'text', 'action']) {
const candidate = value[key];
if (typeof candidate === 'string' && candidate.trim()) {
const trimmed = candidate.trim();
return trimmed.length > 160 ? `${trimmed.slice(0, 160)}` : trimmed;
}
}
for (const child of Object.values(value)) {
const preview = findEventPreview(child, depth + 1);
if (preview) return preview;
}
return null;
}
export default function AdapterEventDebugDialog({
botId,
adapterLabel,
}: {
botId?: string;
adapterLabel: string;
}) {
const { t, i18n } = useTranslation();
const [open, setOpen] = useState(false);
const [listenerState, setListenerState] =
useState<ListenerState>('preparing');
const [events, setEvents] = useState<ObservedAdapterEvent[]>([]);
const baselineSeqRef = useRef<number | null>(null);
const pollInFlightRef = useRef(false);
const platformName = adapterLabel || t('bots.adapterEventCurrentPlatform');
const pollLogs = useCallback(async () => {
if (!botId || pollInFlightRef.current) return;
pollInFlightRef.current = true;
try {
const response = await backendClient.getBotLogs(botId, {
from_index: -1,
max_count: 100,
});
const latestSeq = response.logs.reduce(
(maximum, log) => Math.max(maximum, log.seq_id),
-1,
);
if (baselineSeqRef.current === null) {
baselineSeqRef.current = latestSeq;
setListenerState('listening');
return;
}
const baseline = baselineSeqRef.current;
const newlyObserved = response.logs
.filter((log) => log.seq_id > baseline)
.map(observedEventFromLog)
.filter((event): event is ObservedAdapterEvent => event !== null);
if (newlyObserved.length > 0) {
setEvents((current) => {
const bySeqId = new Map(
[...newlyObserved, ...current].map((event) => [event.seqId, event]),
);
return Array.from(bySeqId.values())
.sort((left, right) => right.seqId - left.seqId)
.slice(0, MAX_VISIBLE_EVENTS);
});
}
baselineSeqRef.current = Math.max(baseline, latestSeq);
setListenerState('listening');
} catch {
setListenerState('error');
} finally {
pollInFlightRef.current = false;
}
}, [botId]);
useEffect(() => {
if (!open || !botId) return;
baselineSeqRef.current = null;
pollInFlightRef.current = false;
setEvents([]);
setListenerState('preparing');
void pollLogs();
const interval = window.setInterval(
() => void pollLogs(),
POLL_INTERVAL_MS,
);
return () => window.clearInterval(interval);
}, [botId, open, pollLogs]);
const status = useMemo(() => {
if (listenerState === 'error') {
return {
text: t('bots.adapterEventListenerUnavailable'),
dot: 'bg-destructive',
};
}
if (listenerState === 'listening') {
return {
text: t('bots.adapterEventListening'),
dot: 'bg-emerald-500',
};
}
return {
text: t('bots.adapterEventPreparing'),
dot: 'bg-amber-500',
};
}, [listenerState, t]);
return (
<>
<Button
type="button"
variant="outline"
size="sm"
disabled={!botId}
onClick={() => setOpen(true)}
title={!botId ? t('bots.adapterEventNeedsSavedBot') : undefined}
>
<RadioTower className="mr-1 h-4 w-4" />
{t('bots.adapterEventDebugAction')}
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<div className="flex flex-wrap items-center gap-2 pr-8">
<DialogTitle>{t('bots.adapterEventDebugTitle')}</DialogTitle>
<Badge variant="outline" className="gap-1.5 font-normal">
<span className={`size-2 rounded-full ${status.dot}`} />
{status.text}
</Badge>
</div>
<DialogDescription>
{t('bots.adapterEventDebugDescription', {
platform: platformName,
})}
</DialogDescription>
</DialogHeader>
<Alert className="bg-muted/30">
<Activity className="h-4 w-4" />
<AlertDescription>
{t('bots.adapterEventObserveOnly')}
</AlertDescription>
</Alert>
{listenerState === 'error' && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{t('bots.adapterEventLoadFailed')}
</AlertDescription>
</Alert>
)}
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-medium">
{t('bots.adapterEventReceivedCount', { count: events.length })}
</p>
<Button
type="button"
variant="ghost"
size="sm"
disabled={events.length === 0}
onClick={() => setEvents([])}
>
<Trash2 className="mr-1 h-4 w-4" />
{t('bots.adapterEventClear')}
</Button>
</div>
<ScrollArea className="h-[min(52vh,420px)] rounded-lg border">
{events.length === 0 ? (
<div className="flex h-full min-h-64 flex-col items-center justify-center px-6 text-center">
<RadioTower className="mb-3 h-8 w-8 text-muted-foreground" />
<p className="font-medium">
{t('bots.adapterEventEmptyTitle')}
</p>
<p className="mt-1 max-w-md text-sm text-muted-foreground">
{t('bots.adapterEventEmptyDescription', {
platform: platformName,
})}
</p>
</div>
) : (
<div className="space-y-3 p-3">
{events.map((event) => {
const preview = findEventPreview(event.eventData);
return (
<Card key={event.seqId} className="gap-0 py-0">
<CardContent className="p-4">
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<p className="font-medium">
{eventPatternLabel(event.eventType, t)}
</p>
<code className="mt-1 block truncate text-xs text-muted-foreground">
{event.eventType}
</code>
</div>
<time className="shrink-0 text-xs text-muted-foreground">
{new Date(
event.timestamp * 1000,
).toLocaleTimeString(i18n.language, {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})}
</time>
</div>
<p className="mt-2 text-sm text-muted-foreground">
{preview ||
eventPatternDescription(event.eventType, t)}
</p>
<Collapsible className="mt-3">
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 px-2 text-xs text-muted-foreground"
>
{t('bots.adapterEventData')}
<ChevronDown className="ml-1 h-3.5 w-3.5" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<pre className="mt-2 max-h-64 overflow-auto rounded-md bg-muted p-3 text-xs leading-relaxed">
{JSON.stringify(event.eventData, null, 2)}
</pre>
</CollapsibleContent>
</Collapsible>
</CardContent>
</Card>
);
})}
</div>
)}
</ScrollArea>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
>
{t('common.close')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -1,4 +1,11 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, {
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import i18n from 'i18next';
import { IChooseAdapterEntity } from '@/app/home/bots/components/bot-form/ChooseEntity';
import {
@@ -15,6 +22,7 @@ import { Agent, Bot } from '@/app/infra/entities/api';
import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs';
import { ExternalLink, ChevronDown, ChevronRight } from 'lucide-react';
import EventBindingsEditor from './EventBindingsEditor';
import AdapterEventDebugDialog from './AdapterEventDebugDialog';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
@@ -79,17 +87,21 @@ const getFormSchema = (t: (key: string) => string) =>
.optional(),
});
export default function BotForm({
initBotId,
onFormSubmit,
onNewBotCreated,
onDirtyChange,
}: {
export interface BotFormHandle {
syncBasicInfo: (values: { name: string; description: string }) => void;
}
interface BotFormProps {
initBotId?: string;
onFormSubmit: (value: z.infer<ReturnType<typeof getFormSchema>>) => void;
onNewBotCreated: (botId: string) => void;
onDirtyChange?: (dirty: boolean) => void;
}) {
}
const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
{ initBotId, onFormSubmit, onNewBotCreated, onDirtyChange },
ref,
) {
const { t } = useTranslation();
const formSchema = getFormSchema(t);
@@ -174,6 +186,19 @@ export default function BotForm({
onDirtyChange?.(isDirty);
}, [isDirty, onDirtyChange]);
useImperativeHandle(ref, () => ({
syncBasicInfo(values) {
form.reset(
{
...form.getValues(),
name: values.name,
description: values.description,
},
{ keepDirtyValues: true },
);
},
}));
useEffect(() => {
setBotFormValues();
}, []);
@@ -416,46 +441,47 @@ export default function BotForm({
className="w-full min-w-0 max-w-full space-y-6"
disabled={isLoading}
>
{/* Card 1: Basic Information */}
<Card>
<CardHeader>
<CardTitle>{t('bots.basicInfo')}</CardTitle>
<CardDescription>
{t('bots.basicInfoDescription')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('bots.botName')}
<span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>{t('bots.botDescription')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
{!initBotId && (
<Card>
<CardHeader>
<CardTitle>{t('bots.basicInfo')}</CardTitle>
<CardDescription>
{t('bots.basicInfoDescription')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('bots.botName')}
<span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>{t('bots.botDescription')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
)}
{/* Card 2: Adapter Configuration */}
<Card>
@@ -662,6 +688,27 @@ export default function BotForm({
}}
/>
)}
{currentAdapter && initBotId && (
<div className="flex flex-col gap-3 border-t pt-4 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="text-sm font-medium">
{t('bots.adapterConfigurationTest')}
</p>
<p className="mt-1 text-sm text-muted-foreground">
{t('bots.adapterConfigurationTestDescription')}
</p>
</div>
<AdapterEventDebugDialog
botId={initBotId}
adapterLabel={
adapterNameList.find(
(adapter) => adapter.value === currentAdapter,
)?.label ?? currentAdapter
}
/>
</div>
)}
</CardContent>
</Card>
@@ -688,4 +735,6 @@ export default function BotForm({
</form>
</Form>
);
}
});
export default BotForm;
@@ -1,6 +1,13 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Fragment,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { UseFormReturn } from 'react-hook-form';
@@ -48,7 +55,9 @@ import {
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
@@ -61,11 +70,20 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { Alert, AlertDescription } from '@/components/ui/alert';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
@@ -94,9 +112,14 @@ import {
Agent,
BotRouteDryRunResult,
BotEventRouteStatus,
BotRouteTestResult,
} from '@/app/infra/entities/api';
import { backendClient } from '@/app/infra/http';
import {
eventGroupLabel,
eventNamespaces,
groupEventPatterns,
} from '@/app/home/components/event-patterns/event-pattern-groups';
import EventSelectOptionContent from '@/app/home/components/event-patterns/EventSelectOptionContent';
export const PIPELINE_DISCARD = '__discard__';
@@ -297,20 +320,6 @@ function agentSupportsEventPattern(agent: Agent, pattern: string) {
return patterns.some((p) => eventPatternCovers(p, pattern));
}
function eventNamespaces(events: string[]) {
// Only surface a `ns.*` wildcard when the namespace actually has 2+
// concrete events — otherwise the wildcard is redundant with the single event.
const counts = new Map<string, number>();
events.forEach((e) => {
const n = e.split('.')[0];
if (n) counts.set(n, (counts.get(n) ?? 0) + 1);
});
return Array.from(counts.entries())
.filter(([, c]) => c >= 2)
.map(([n]) => `${n}.*`)
.sort();
}
// Localized label for an event pattern. Concrete events look up
// `bots.eventNames.<event_with_underscores>`, falling back to the raw
// string when no translation exists (e.g. custom/unknown events).
@@ -766,7 +775,8 @@ function AdapterCapabilitySummary({
const [advancedOpen, setAdvancedOpen] = useState(false);
const concreteEvents =
supportedEvents.length > 0 ? supportedEvents : DEFAULT_EVENTS;
const previewEvents = concreteEvents.slice(0, 4);
const concreteEventGroups = groupEventPatterns(concreteEvents);
const optionGroups = groupEventPatterns(eventOptions);
return (
<div className="rounded-lg border bg-muted/20 p-3">
@@ -788,20 +798,22 @@ function AdapterCapabilitySummary({
</div>
</div>
<div className="flex flex-wrap gap-1.5">
{previewEvents.map((event) => (
{concreteEventGroups.slice(0, 4).map((group) => (
<Badge
key={event}
key={group.namespace}
variant="secondary"
className="max-w-full rounded-md px-2 py-0.5 font-normal"
title={event}
>
<span className="truncate">{eventLabel(event, t)}</span>
<span className="truncate">
{eventGroupLabel(group.namespace, t)} ·{' '}
{group.patterns.length}
</span>
</Badge>
))}
{concreteEvents.length > previewEvents.length && (
{concreteEventGroups.length > 4 && (
<Badge variant="outline" className="rounded-md px-2 py-0.5">
{t('bots.adapterEventsMore', {
count: concreteEvents.length - previewEvents.length,
count: concreteEventGroups.length - 4,
})}
</Badge>
)}
@@ -823,25 +835,40 @@ function AdapterCapabilitySummary({
</Button>
</div>
{advancedOpen && (
<div className="mt-3 grid gap-2 border-t pt-3 sm:grid-cols-2">
{eventOptions.map((event) => (
<div key={event} className="min-w-0 rounded-md bg-background p-2">
<div className="flex min-w-0 items-center justify-between gap-2">
<span className="truncate text-xs font-medium">
{eventLabel(event, t)}
</span>
{event.endsWith('.*') && (
<Badge variant="outline" className="shrink-0 text-[10px]">
{t('bots.eventGroup')}
</Badge>
)}
</div>
<p className="mt-1 line-clamp-2 text-[11px] leading-snug text-muted-foreground">
{eventDescription(event, t)}
<div className="mt-3 space-y-4 border-t pt-3">
{optionGroups.map((group) => (
<div key={group.namespace} className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">
{eventGroupLabel(group.namespace, t)}
</p>
<code className="mt-1 block truncate text-[11px] text-muted-foreground">
{event}
</code>
<div className="grid gap-2 sm:grid-cols-2">
{group.patterns.map((event) => (
<div
key={event}
className="min-w-0 rounded-md bg-background p-2"
>
<div className="flex min-w-0 items-center justify-between gap-2">
<span className="truncate text-xs font-medium">
{eventLabel(event, t)}
</span>
{event.endsWith('.*') && (
<Badge
variant="outline"
className="shrink-0 text-[10px]"
>
{t('bots.eventGroup')}
</Badge>
)}
</div>
<p className="mt-1 line-clamp-2 text-[11px] leading-snug text-muted-foreground">
{eventDescription(event, t)}
</p>
<code className="mt-1 block truncate text-[11px] text-muted-foreground">
{event}
</code>
</div>
))}
</div>
</div>
))}
</div>
@@ -857,13 +884,11 @@ function RouteDryRunDialog({
bindings,
eventOptions,
agentOptions,
onRouteStatusUpdate,
}: {
botId?: string;
bindings: EventBinding[];
eventOptions: string[];
agentOptions: Agent[];
onRouteStatusUpdate?: (statuses: BotEventRouteStatus[]) => void;
}) {
const { t } = useTranslation();
const firstEvent = eventOptions[0] ?? DEFAULT_EVENTS[0];
@@ -874,12 +899,9 @@ function RouteDryRunDialog({
);
const [advancedPayloadOpen, setAdvancedPayloadOpen] = useState(false);
const [isRunning, setIsRunning] = useState(false);
const [isDispatching, setIsDispatching] = useState(false);
const [payloadError, setPayloadError] = useState<string | null>(null);
const [runError, setRunError] = useState<string | null>(null);
const [result, setResult] = useState<BotRouteDryRunResult | null>(null);
const [dispatchResult, setDispatchResult] =
useState<BotRouteTestResult | null>(null);
useEffect(() => {
if (!eventOptions.includes(eventType)) {
@@ -891,7 +913,6 @@ function RouteDryRunDialog({
setPayloadText(JSON.stringify(samplePayloadForEvent(eventType), null, 2));
setPayloadError(null);
setResult(null);
setDispatchResult(null);
}, [eventType]);
function resolveTargetName(resultTarget?: BotRouteDryRunResult['target']) {
@@ -928,7 +949,6 @@ function RouteDryRunDialog({
async function runDryRun() {
setRunError(null);
setResult(null);
setDispatchResult(null);
const payload = parsePayload();
if (payload === null) return;
@@ -957,43 +977,6 @@ function RouteDryRunDialog({
}
}
async function dispatchTestEvent() {
setRunError(null);
setDispatchResult(null);
const payload = parsePayload();
if (payload === null) return;
if (!botId) {
setRunError(t('bots.dryRunNeedsSavedBot'));
return;
}
setIsDispatching(true);
try {
const testResult = await backendClient.testBotEventRoute(botId, {
event_type: eventType,
payload,
});
setDispatchResult(testResult);
onRouteStatusUpdate?.(testResult.route_status?.routes || []);
if (!testResult.dispatched) {
setRunError(
localizedFailureReason(
testResult.failure_code,
testResult.reason,
t,
) || t('bots.routeTestFailed'),
);
}
} catch (error) {
const err = error as { msg?: string };
setRunError(err.msg || t('bots.routeTestFailed'));
} finally {
setIsDispatching(false);
}
}
const targetName = result ? resolveTargetName(result.target) : '';
return (
@@ -1008,82 +991,88 @@ function RouteDryRunDialog({
{t('bots.testRoute')}
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-2xl">
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t('bots.dryRunTitle')}</DialogTitle>
<DialogDescription>{t('bots.dryRunDescription')}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-3">
<div className="space-y-1.5">
<label className="text-sm font-medium">
{t('bots.dryRunEventType')}
</label>
<Select value={eventType} onValueChange={setEventType}>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{eventOptions.map((event) => (
<SelectItem key={event} value={event}>
{eventLabel(event, t)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="rounded-md border bg-muted/20 px-3 py-2.5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-medium">
{t('bots.dryRunSampleReady')}
</p>
<p className="mt-0.5 text-xs leading-relaxed text-muted-foreground">
{t('bots.dryRunSampleDescription', {
event: eventLabel(eventType, t),
})}
</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 shrink-0 px-2 text-xs"
onClick={() => setAdvancedPayloadOpen((value) => !value)}
>
{advancedPayloadOpen ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
{advancedPayloadOpen
? t('bots.dryRunHidePayload')
: t('bots.dryRunEditPayload')}
</Button>
<div className="space-y-2">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end">
<div className="min-w-0 flex-1 space-y-1.5">
<label className="text-sm font-medium">
{t('bots.dryRunEventType')}
</label>
<Select value={eventType} onValueChange={setEventType}>
<SelectTrigger
className="h-auto min-h-9 w-full"
aria-label={t('bots.dryRunEventType')}
>
<SelectValue />
</SelectTrigger>
<SelectContent className="w-[var(--radix-select-trigger-width)] max-w-[calc(100vw-2rem)]">
{groupEventPatterns(eventOptions).map((group) => (
<SelectGroup key={group.namespace}>
<SelectLabel>
{eventGroupLabel(group.namespace, t)}
</SelectLabel>
{group.patterns.map((event) => (
<SelectItem
key={event}
value={event}
description={eventDescription(event, t)}
className="py-2"
>
<EventSelectOptionContent
event={event}
label={eventLabel(event, t)}
/>
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
</div>
{advancedPayloadOpen && (
<div className="mt-3 space-y-1.5 border-t pt-3">
<label className="text-xs font-medium">
{t('bots.dryRunPayload')}
</label>
<Textarea
value={payloadText}
onChange={(e) => setPayloadText(e.target.value)}
className="min-h-[118px] font-mono text-xs"
spellCheck={false}
placeholder='{"message_text": "hello"}'
/>
{payloadError ? (
<p className="text-xs text-destructive">{payloadError}</p>
) : (
<p className="text-xs text-muted-foreground">
{t('bots.dryRunPayloadHint')}
</p>
)}
</div>
)}
<Button
type="button"
variant="ghost"
size="sm"
className="h-9 shrink-0 self-start px-2 text-xs text-muted-foreground sm:self-auto"
onClick={() => setAdvancedPayloadOpen((value) => !value)}
>
{advancedPayloadOpen ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
{advancedPayloadOpen
? t('bots.dryRunHidePayload')
: t('bots.dryRunEditPayload')}
</Button>
</div>
{advancedPayloadOpen && (
<div className="space-y-1.5 rounded-md border bg-muted/20 p-3">
<label className="text-xs font-medium">
{t('bots.dryRunPayload')}
</label>
<Textarea
value={payloadText}
onChange={(e) => setPayloadText(e.target.value)}
className="min-h-[110px] font-mono text-xs"
spellCheck={false}
placeholder='{"message_text": "hello"}'
/>
{payloadError ? (
<p className="text-xs text-destructive">{payloadError}</p>
) : (
<p className="text-xs text-muted-foreground">
{t('bots.dryRunPayloadHint')}
</p>
)}
</div>
)}
</div>
{runError && (
@@ -1171,24 +1160,6 @@ function RouteDryRunDialog({
)}
</div>
)}
{dispatchResult?.dispatched && (
<Alert>
<CheckCircle2 className="h-4 w-4" />
<AlertDescription>
{t('bots.routeTestDispatched', {
count: dispatchResult.suppressed_outputs?.length || 0,
})}
</AlertDescription>
</Alert>
)}
<Alert className="border-amber-200 bg-amber-50/60 text-amber-900 dark:border-amber-900/50 dark:bg-amber-950/20 dark:text-amber-200">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{t('bots.routeTestSideEffectWarning')}
</AlertDescription>
</Alert>
</div>
<DialogFooter>
@@ -1199,25 +1170,10 @@ function RouteDryRunDialog({
>
{t('common.close')}
</Button>
<Button
type="button"
onClick={runDryRun}
disabled={isRunning || isDispatching}
>
<Button type="button" onClick={runDryRun} disabled={isRunning}>
<Play className="h-4 w-4 mr-1" />
{isRunning ? t('bots.dryRunRunning') : t('bots.dryRunAction')}
</Button>
<Button
type="button"
variant="outline"
onClick={dispatchTestEvent}
disabled={isRunning || isDispatching}
>
<Activity className="h-4 w-4 mr-1" />
{isDispatching
? t('bots.routeTestRunning')
: t('bots.routeTestAction')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -1252,6 +1208,7 @@ function BindingCardContent({
onUpdate,
onRemove,
dragHandleProps,
isOverlay = false,
}: BindingCardProps) {
const { t } = useTranslation();
const isEnabled = binding.enabled ?? true;
@@ -1263,13 +1220,21 @@ function BindingCardContent({
const statusDetail = routeStatusDetail(routeStatus, t);
return (
<div className="rounded-lg border bg-card">
<div
className={`rounded-lg border bg-card ${
isOverlay ? 'pointer-events-none shadow-lg ring-1 ring-primary/20' : ''
}`}
data-drag-overlay={isOverlay ? 'true' : undefined}
>
{/* main row */}
<div className="flex flex-wrap items-center gap-2 p-2.5">
{isEnabled && (
<button
type="button"
className="cursor-grab active:cursor-grabbing shrink-0 text-muted-foreground hover:text-foreground touch-none"
aria-label={t('bots.dragEventRoute', {
index: globalIndex + 1,
})}
{...dragHandleProps}
>
<GripVertical className="h-4 w-4" />
@@ -1300,29 +1265,28 @@ function BindingCardContent({
onUpdate(globalIndex, patch);
}}
>
<SelectTrigger className="h-8 min-w-[150px] flex-1 text-sm">
{binding.event_pattern ? (
<span className="truncate">
{eventLabel(binding.event_pattern, t)}
</span>
) : (
<SelectValue placeholder={t('bots.eventPatternPlaceholder')} />
)}
<SelectTrigger className="h-auto min-h-9 min-w-[220px] flex-1">
<SelectValue placeholder={t('bots.eventPatternPlaceholder')} />
</SelectTrigger>
<SelectContent>
{eventOptions.map((event) => {
const label = eventLabel(event, t);
return (
<SelectItem key={event} value={event}>
<span className="flex flex-col">
<span>{label}</span>
<span className="text-[11px] text-muted-foreground">
{eventDescription(event, t)}
</span>
</span>
</SelectItem>
);
})}
<SelectContent className="w-[var(--radix-select-trigger-width)] max-w-[calc(100vw-2rem)]">
{groupEventPatterns(eventOptions).map((group) => (
<SelectGroup key={group.namespace}>
<SelectLabel>{eventGroupLabel(group.namespace, t)}</SelectLabel>
{group.patterns.map((event) => (
<SelectItem
key={event}
value={event}
description={eventDescription(event, t)}
className="py-2"
>
<EventSelectOptionContent
event={event}
label={eventLabel(event, t)}
/>
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
@@ -1421,15 +1385,32 @@ function BindingCardContent({
// ── sortable wrapper ──────────────────────────────────────────────────────────
function SortableBindingCard(props: BindingCardProps) {
const { attributes, listeners, setNodeRef, transform, isDragging } =
useSortable({ id: props.binding.id ?? props.globalIndex });
interface SortableBindingCardProps extends BindingCardProps {
sortableId: string;
}
function SortableBindingCard({
sortableId,
...props
}: SortableBindingCardProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: sortableId });
return (
<div
ref={setNodeRef}
data-testid={`event-route-${sortableId}`}
style={{
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.3 : undefined,
position: 'relative',
zIndex: isDragging ? 1 : undefined,
}}
>
<BindingCardContent
@@ -1503,6 +1484,14 @@ export default function EventBindingsEditor({
),
[dryRunEventOptions],
);
const otherEventGroups = useMemo(() => {
const commonEventTypes = new Set(
behaviorPresets.map((preset) => preset.eventType),
);
return groupEventPatterns(
eventOptions.filter((event) => !commonEventTypes.has(event)),
);
}, [behaviorPresets, eventOptions]);
const refreshRouteStatuses = useCallback(async () => {
if (!botId) {
@@ -1516,8 +1505,8 @@ export default function EventBindingsEditor({
const response = await backendClient.getBotEventRouteStatuses(botId);
setRouteStatuses(response.routes || []);
} catch (error) {
const err = error as { msg?: string };
setRouteStatusError(err.msg || t('bots.routeStatusRefreshFailed'));
console.error('Failed to refresh Bot event route status', error);
setRouteStatusError(t('bots.routeStatusRefreshFailed'));
} finally {
setRouteStatusLoading(false);
}
@@ -1653,18 +1642,18 @@ export default function EventBindingsEditor({
</Alert>
)}
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
{catchAllRouteIndex >= 0
? t('bots.routeFallbackCatchAll', {
route: t('bots.dryRunRuleIndex', {
index: catchAllRouteIndex + 1,
}),
})
: t('bots.routeFallbackIgnored')}
</AlertDescription>
</Alert>
{catchAllRouteIndex >= 0 && (
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
{t('bots.routeFallbackCatchAll', {
route: t('bots.dryRunRuleIndex', {
index: catchAllRouteIndex + 1,
}),
})}
</AlertDescription>
</Alert>
)}
{/* enabled section */}
<DndContext
@@ -1672,6 +1661,7 @@ export default function EventBindingsEditor({
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={() => setActiveId(null)}
>
<SortableContext
items={idsRef.current}
@@ -1688,6 +1678,7 @@ export default function EventBindingsEditor({
return (
<SortableBindingCard
key={idsRef.current[sortIdx]}
sortableId={idsRef.current[sortIdx]}
binding={binding}
globalIndex={globalIdx}
routeStatus={
@@ -1706,7 +1697,7 @@ export default function EventBindingsEditor({
})}
</div>
</SortableContext>
<DragOverlay dropAnimation={null}>
<DragOverlay adjustScale={false} dropAnimation={null}>
{activeBinding && activeGlobalIdx >= 0 ? (
<BindingCardContent
binding={activeBinding}
@@ -1738,6 +1729,9 @@ export default function EventBindingsEditor({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[300px] max-w-[90vw]">
<DropdownMenuLabel className="px-2 pb-1 pt-1.5 text-xs font-normal text-muted-foreground">
{t('bots.commonScenarios')}
</DropdownMenuLabel>
{behaviorPresets.map((preset) => {
const Icon = preset.icon;
return (
@@ -1757,18 +1751,44 @@ export default function EventBindingsEditor({
);
})}
<DropdownMenuSeparator />
<DropdownMenuItem
className="items-start gap-2 py-2"
onClick={() => addBinding(dryRunEventOptions[0])}
>
<Workflow className="mt-0.5 h-4 w-4 shrink-0" />
<span className="flex min-w-0 flex-col gap-0.5">
<span>{t('bots.behaviorCustom')}</span>
<span className="text-xs text-muted-foreground">
{t('bots.behaviorCustomDescription')}
<DropdownMenuSub>
<DropdownMenuSubTrigger
className="items-start gap-2 py-2"
disabled={otherEventGroups.length === 0}
>
<Workflow className="mt-0.5 h-4 w-4 shrink-0" />
<span className="flex min-w-0 flex-col gap-0.5 pr-2">
<span>{t('bots.behaviorCustom')}</span>
<span className="text-xs font-normal text-muted-foreground">
{t('bots.behaviorCustomDescription')}
</span>
</span>
</span>
</DropdownMenuItem>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="max-h-[min(70vh,32rem)] w-[320px] max-w-[90vw] overflow-y-auto">
{otherEventGroups.map((group, groupIndex) => (
<Fragment key={group.namespace}>
{groupIndex > 0 && <DropdownMenuSeparator />}
<DropdownMenuLabel className="px-2 py-1 text-xs font-normal text-muted-foreground">
{eventGroupLabel(group.namespace, t)}
</DropdownMenuLabel>
{group.patterns.map((event) => (
<DropdownMenuItem
key={event}
className="items-start py-2"
onClick={() => addBinding(event)}
>
<span className="flex min-w-0 flex-col gap-0.5">
<span>{eventLabel(event, t)}</span>
<span className="text-xs text-muted-foreground">
{eventDescription(event, t)}
</span>
</span>
</DropdownMenuItem>
))}
</Fragment>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
<RouteDryRunDialog
@@ -1776,24 +1796,28 @@ export default function EventBindingsEditor({
bindings={bindings}
eventOptions={dryRunEventOptions}
agentOptions={agentOptions}
onRouteStatusUpdate={setRouteStatuses}
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={refreshRouteStatuses}
disabled={!botId || routeStatusLoading}
>
<RefreshCw
className={`h-4 w-4 mr-1 ${routeStatusLoading ? 'animate-spin' : ''}`}
/>
{t('bots.refreshRouteStatus')}
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className={`size-8 ${routeStatusError ? 'text-destructive' : 'text-muted-foreground'}`}
aria-label={t('bots.refreshRouteStatus')}
onClick={refreshRouteStatuses}
disabled={!botId || routeStatusLoading}
>
<RefreshCw
className={`h-4 w-4 ${routeStatusLoading ? 'animate-spin' : ''}`}
/>
</Button>
</TooltipTrigger>
<TooltipContent>
{routeStatusError || t('bots.refreshRouteStatus')}
</TooltipContent>
</Tooltip>
</div>
{routeStatusError && (
<p className="text-xs text-destructive">{routeStatusError}</p>
)}
{/* disabled section */}
{disabledBindings.length > 0 && (
@@ -0,0 +1,164 @@
import { FormEvent, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import EmojiPicker from '@/components/ui/emoji-picker';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
export interface EntityBasicInfoValues {
name: string;
description: string;
emoji?: string;
}
interface EntityBasicInfoDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
values: EntityBasicInfoValues;
defaultEmoji?: string;
showEmoji?: boolean;
onSave: (values: EntityBasicInfoValues) => Promise<void>;
}
export default function EntityBasicInfoDialog({
open,
onOpenChange,
values,
defaultEmoji,
showEmoji = true,
onSave,
}: EntityBasicInfoDialogProps) {
const { t } = useTranslation();
const [draft, setDraft] = useState(values);
const [isSaving, setIsSaving] = useState(false);
const [nameError, setNameError] = useState(false);
useEffect(() => {
if (!open) return;
setDraft({
name: values.name,
description: values.description,
emoji: values.emoji || defaultEmoji,
});
setNameError(false);
}, [defaultEmoji, open, values.description, values.emoji, values.name]);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const name = draft.name.trim();
if (!name) {
setNameError(true);
return;
}
setIsSaving(true);
try {
await onSave({
name,
description: draft.description.trim(),
emoji: showEmoji ? draft.emoji || defaultEmoji : undefined,
});
onOpenChange(false);
} catch {
// The caller presents the entity-specific error message.
} finally {
setIsSaving(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>{t('common.editBasicInfo')}</DialogTitle>
<DialogDescription>
{t(
showEmoji
? 'common.editBasicInfoDescription'
: 'common.editBasicInfoDescriptionNoIcon',
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-5">
<div className="flex items-start gap-4">
<div className="min-w-0 flex-1 space-y-2">
<Label htmlFor="entity-basic-name">{t('common.name')}</Label>
<Input
id="entity-basic-name"
value={draft.name}
aria-invalid={nameError}
onChange={(event) => {
setDraft((current) => ({
...current,
name: event.target.value,
}));
if (event.target.value.trim()) setNameError(false);
}}
autoFocus
/>
{nameError && (
<p className="text-sm text-destructive">
{t('common.fieldRequired')}
</p>
)}
</div>
{showEmoji && (
<div className="space-y-2">
<Label>{t('common.icon')}</Label>
<EmojiPicker
value={draft.emoji || defaultEmoji}
onChange={(emoji) =>
setDraft((current) => ({ ...current, emoji }))
}
ariaLabel={t('common.icon')}
/>
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="entity-basic-description">
{t('common.description')}
</Label>
<Input
id="entity-basic-description"
value={draft.description}
onChange={(event) =>
setDraft((current) => ({
...current,
description: event.target.value,
}))
}
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSaving}
>
{t('common.cancel')}
</Button>
<Button type="submit" disabled={isSaving}>
{isSaving ? t('common.saving') : t('common.save')}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,34 @@
import { Pencil } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
export default function EntityTitleEditButton({
onClick,
}: {
onClick: () => void;
}) {
const { t } = useTranslation();
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 shrink-0 text-muted-foreground"
aria-label={t('common.editBasicInfo')}
onClick={onClick}
>
<Pencil className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('common.editBasicInfo')}</TooltipContent>
</Tooltip>
);
}
@@ -0,0 +1,18 @@
interface EventSelectOptionContentProps {
event: string;
label: string;
}
export default function EventSelectOptionContent({
event,
label,
}: EventSelectOptionContentProps) {
return (
<span className="flex min-w-0 items-center gap-2">
<span className="truncate font-medium">{label}</span>
<code className="shrink-0 rounded-sm bg-muted px-1 py-0.5 font-mono text-[10px] font-normal text-muted-foreground">
{event}
</code>
</span>
);
}
@@ -0,0 +1,79 @@
import type { TFunction } from 'i18next';
export interface EventPatternGroup {
namespace: string;
patterns: string[];
}
function eventPatternNamespace(pattern: string) {
if (pattern === '*') return '*';
return pattern.split('.')[0] || pattern;
}
export function eventNamespaces(events: string[]) {
const counts = new Map<string, number>();
events.forEach((event) => {
if (event === '*' || event.endsWith('.*')) return;
const namespace = eventPatternNamespace(event);
counts.set(namespace, (counts.get(namespace) ?? 0) + 1);
});
return Array.from(counts.entries())
.filter(([, count]) => count >= 2)
.map(([namespace]) => `${namespace}.*`)
.sort();
}
export function groupEventPatterns(patterns: string[]): EventPatternGroup[] {
const groups = new Map<string, string[]>();
patterns.forEach((pattern) => {
const namespace = eventPatternNamespace(pattern);
const group = groups.get(namespace) ?? [];
if (!group.includes(pattern)) group.push(pattern);
groups.set(namespace, group);
});
return Array.from(groups.entries())
.sort(([left], [right]) => {
if (left === '*') return -1;
if (right === '*') return 1;
return left.localeCompare(right);
})
.map(([namespace, groupPatterns]) => ({
namespace,
patterns: groupPatterns.sort((left, right) => {
const leftWildcard = left.endsWith('.*');
const rightWildcard = right.endsWith('.*');
if (leftWildcard !== rightWildcard) return leftWildcard ? -1 : 1;
return left.localeCompare(right);
}),
}));
}
export function eventGroupLabel(namespace: string, t: TFunction) {
if (namespace === '*') return t('bots.eventWildcard');
const key = `bots.eventGroupNames.${namespace}`;
const label = t(key);
return label === key ? namespace : label;
}
export function eventPatternLabel(pattern: string, t: TFunction) {
if (pattern === '*') return t('bots.eventWildcard');
if (pattern.endsWith('.*')) {
return t('bots.eventNamespaceWildcard', {
namespace: pattern.replace('.*', ''),
});
}
const key = `bots.eventNames.${pattern.replace(/\./g, '_')}`;
const label = t(key);
return label === key ? pattern : label;
}
export function eventPatternDescription(pattern: string, t: TFunction) {
if (pattern === '*') return t('bots.eventDescriptions.all');
if (pattern.endsWith('.*')) {
return t('bots.eventDescriptions.namespace');
}
const key = `bots.eventDescriptions.${pattern.replace(/\./g, '_')}`;
const description = t(key);
return description === key ? t('bots.eventDescriptions.custom') : description;
}
@@ -1752,6 +1752,17 @@ function findSidebarChildForPath(pathname: string): SidebarChildVO | undefined {
);
if (matchedChild) return matchedChild;
// Keep the legacy Pipeline URL usable after Pipelines and Agents were
// unified under the Processors section.
if (
pathname === '/home/pipelines' ||
pathname.startsWith('/home/pipelines/')
) {
return sidebarConfigList.find(
(childConfig) => childConfig.id === 'pipelines',
);
}
if (
pathname === '/home/mcp' ||
pathname === '/home/skills' ||
@@ -0,0 +1,217 @@
import { ReactNode, useState } from 'react';
import { BarChart3, Bug, Settings } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
interface ProcessorMonitoringView {
label: string;
content: ReactNode;
}
export interface ProcessorDetailStatus {
label: string;
description?: string;
tone: 'neutral' | 'success' | 'warning' | 'error';
}
interface ProcessorDetailWorkbenchProps {
title: string;
titleAction?: ReactNode;
headerActions?: ReactNode;
status?: ProcessorDetailStatus | null;
saveLabel: string;
saveFormId: string;
canSave: boolean;
isDirty: boolean;
isSaving: boolean;
configTitle: string;
configContent: ReactNode;
debugTitle?: string;
debugContent?: ReactNode;
debugConnected?: boolean;
debugConnectedLabel?: string;
debugDisconnectedLabel?: string;
unsavedLabel?: string;
monitoring?: ProcessorMonitoringView;
}
export default function ProcessorDetailWorkbench({
title,
titleAction,
headerActions,
status,
saveLabel,
saveFormId,
canSave,
isDirty,
isSaving,
configTitle,
configContent,
debugTitle,
debugContent,
debugConnected,
debugConnectedLabel,
debugDisconnectedLabel,
unsavedLabel,
monitoring,
}: ProcessorDetailWorkbenchProps) {
const [activeView, setActiveView] = useState<'workbench' | 'monitoring'>(
'workbench',
);
const hasDebug = Boolean(debugTitle && debugContent);
return (
<div className="flex h-full min-h-0 min-w-0 flex-col">
<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">
<h1 className="truncate text-xl font-semibold">{title}</h1>
{titleAction}
{status && (
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
role="status"
aria-label={status.label}
tabIndex={0}
className={cn(
'rounded-full',
status.tone === 'success' &&
'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300',
status.tone === 'warning' &&
'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300',
status.tone === 'error' &&
'border-destructive/30 bg-destructive/10 text-destructive',
status.tone === 'neutral' &&
'border-border bg-muted/50 text-muted-foreground',
)}
>
<span
className={cn(
'size-1.5 rounded-full',
status.tone === 'success' && 'bg-emerald-500',
status.tone === 'warning' && 'bg-amber-500',
status.tone === 'error' && 'bg-destructive',
status.tone === 'neutral' &&
'animate-pulse bg-muted-foreground',
)}
/>
{status.label}
</Badge>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-72">
<p className="font-medium">{status.label}</p>
{status.description && (
<p className="mt-1 font-normal opacity-80">
{status.description}
</p>
)}
</TooltipContent>
</Tooltip>
)}
</div>
<div className="flex items-center gap-2">
{monitoring && (
<Button
type="button"
variant={activeView === 'monitoring' ? 'secondary' : 'outline'}
onClick={() =>
setActiveView((current) =>
current === 'monitoring' ? 'workbench' : 'monitoring',
)
}
>
<BarChart3 className="size-4" />
{monitoring.label}
</Button>
)}
{canSave && activeView === 'workbench' && (
<Button
type="submit"
form={saveFormId}
disabled={!isDirty || isSaving}
>
{saveLabel}
</Button>
)}
{activeView === 'workbench' && headerActions}
</div>
</div>
{activeView === 'monitoring' && monitoring ? (
<section
aria-label={monitoring.label}
className="min-h-0 flex-1 overflow-y-auto rounded-xl border bg-card p-4"
>
{monitoring.content}
</section>
) : (
<div className="min-h-0 flex-1 overflow-y-auto lg:overflow-hidden">
<div
className={cn(
'grid min-h-0 gap-3 lg:h-full',
hasDebug
? 'lg:grid-cols-[minmax(20rem,0.72fr)_minmax(0,1.28fr)]'
: 'grid-cols-1',
)}
>
{hasDebug && (
<section
aria-label={debugTitle}
className="flex min-h-[32rem] min-w-0 flex-col overflow-hidden rounded-xl border bg-card lg:min-h-0"
>
<div className="flex h-12 shrink-0 items-center justify-between gap-3 border-b px-4">
<div className="flex min-w-0 items-center gap-2 font-medium">
<Bug className="size-4 shrink-0" />
<span className="truncate">{debugTitle}</span>
</div>
{debugConnected !== undefined && (
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span
className={cn(
'size-2 rounded-full',
debugConnected ? 'bg-emerald-500' : 'bg-destructive',
)}
/>
{debugConnected
? debugConnectedLabel
: debugDisconnectedLabel}
</span>
)}
</div>
<div className="min-h-0 min-w-0 flex-1 overflow-hidden">
{debugContent}
</div>
</section>
)}
<section
aria-label={configTitle}
className="flex min-h-[36rem] min-w-0 flex-col overflow-hidden rounded-xl border bg-card lg:min-h-0"
>
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-4 font-medium">
<Settings className="size-4" />
<span className="truncate">{configTitle}</span>
{isDirty && (
<span className="ml-auto flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-400">
<span className="size-1.5 rounded-full bg-amber-500" />
{unsavedLabel}
</span>
)}
</div>
<div className="min-h-0 min-w-0 flex-1 overflow-hidden p-4">
{configContent}
</div>
</section>
</div>
</div>
)}
</div>
);
}
+61 -3
View File
@@ -28,6 +28,10 @@ import { CustomApiError } from '@/app/infra/entities/common';
import { toast } from 'sonner';
import { FileText, FolderOpen, Search, Trash2 } from 'lucide-react';
import { useCurrentWorkspace } from '@/app/infra/http';
import EntityBasicInfoDialog, {
EntityBasicInfoValues,
} from '@/app/home/components/entity-basic-info/EntityBasicInfoDialog';
import EntityTitleEditButton from '@/app/home/components/entity-basic-info/EntityTitleEditButton';
export default function KBDetailContent({ id }: { id: string }) {
const isCreateMode = id === 'new';
@@ -52,8 +56,10 @@ export default function KBDetailContent({ id }: { id: string }) {
const [activeTab, setActiveTab] = useState('metadata');
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showBasicInfoDialog, setShowBasicInfoDialog] = useState(false);
const [kbInfo, setKbInfo] = useState<KnowledgeBase | null>(null);
const [formDirty, setFormDirty] = useState(false);
const [formVersion, setFormVersion] = useState(0);
const loadKbInfo = useCallback(
async (kbId: string) => {
@@ -99,6 +105,34 @@ export default function KBDetailContent({ id }: { id: string }) {
loadKbInfo(id);
}
async function handleBasicInfoSave(values: EntityBasicInfoValues) {
if (!kbInfo) return;
const updateData: KnowledgeBase = {
name: values.name,
description: values.description,
emoji: values.emoji || '📚',
knowledge_engine_plugin_id: kbInfo.knowledge_engine_plugin_id,
creation_settings: kbInfo.creation_settings,
retrieval_settings: kbInfo.retrieval_settings,
};
try {
await httpClient.updateKnowledgeBase(id, updateData);
setKbInfo({ ...kbInfo, ...updateData });
setDetailEntityName(values.name);
setFormDirty(false);
setFormVersion((version) => version + 1);
refreshKnowledgeBases();
toast.success(t('knowledge.updateKnowledgeBaseSuccess'));
} catch (err) {
toast.error(
t('knowledge.updateKnowledgeBaseFailed') + (err as CustomApiError).msg,
);
throw err;
}
}
async function confirmDelete() {
try {
await httpClient.deleteKnowledgeBase(id);
@@ -151,9 +185,18 @@ export default function KBDetailContent({ id }: { id: string }) {
<div className="flex h-full flex-col">
{/* Sticky Header: title + save button */}
<div className="flex items-center justify-between pb-4 shrink-0">
<h1 className="text-xl font-semibold">
{t('knowledge.editKnowledgeBase')}
</h1>
<div className="flex min-w-0 items-center gap-1">
<h1 className="truncate text-xl font-semibold">
{kbInfo
? `${kbInfo.emoji || '📚'} ${kbInfo.name}`
: t('knowledge.editKnowledgeBase')}
</h1>
{canManage && kbInfo && (
<EntityTitleEditButton
onClick={() => setShowBasicInfoDialog(true)}
/>
)}
</div>
{canManage && (
<Button
type="submit"
@@ -198,6 +241,7 @@ export default function KBDetailContent({ id }: { id: string }) {
<div className="mx-auto max-w-3xl space-y-6 pb-8">
<fieldset className="contents" disabled={!canManage}>
<KBForm
key={`${id}-${formVersion}`}
initKbId={id}
onNewKbCreated={handleNewKbCreated}
onKbUpdated={handleKbUpdated}
@@ -268,6 +312,20 @@ export default function KBDetailContent({ id }: { id: string }) {
</Tabs>
</div>
{kbInfo && (
<EntityBasicInfoDialog
open={showBasicInfoDialog}
onOpenChange={setShowBasicInfoDialog}
values={{
name: kbInfo.name,
description: kbInfo.description,
emoji: kbInfo.emoji,
}}
defaultEmoji="📚"
onSave={handleBasicInfoSave}
/>
)}
{/* Delete confirmation dialog */}
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<DialogContent>
@@ -31,6 +31,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import { KnowledgeBase, KnowledgeEngine } from '@/app/infra/entities/api';
import { CustomApiError } from '@/app/infra/entities/common';
import { toast } from 'sonner';
@@ -100,7 +101,7 @@ export default function KBForm({
const [retrievalSettings, setRetrievalSettings] = useState<
Record<string, unknown>
>({});
const [isEditing, setIsEditing] = useState(false);
const [isEditing, setIsEditing] = useState(Boolean(initKbId));
const [loading, setLoading] = useState(true);
// Dirty tracking: snapshot of saved state for comparison
@@ -341,26 +342,59 @@ export default function KBForm({
id="kb-form"
className="space-y-6"
>
{/* Card 1: Basic Information */}
<Card>
<CardHeader>
<CardTitle>{t('knowledge.basicInfo')}</CardTitle>
<CardDescription>
{t('knowledge.basicInfoDescription')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Name and Emoji in same row */}
<div className="flex gap-4 items-start">
{/* Basic information is entered here only during creation. */}
{!isEditing && (
<Card>
<CardHeader>
<CardTitle>{t('knowledge.basicInfo')}</CardTitle>
<CardDescription>
{t('knowledge.basicInfoDescription')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Name and Emoji in same row */}
<div className="flex gap-4 items-start">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
{t('knowledge.kbName')}
<span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="emoji"
render={({ field }) => (
<FormItem>
<FormLabel>{t('common.icon')}</FormLabel>
<FormControl>
<EmojiPicker
value={field.value}
onChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* Description */}
<FormField
control={form.control}
name="name"
name="description"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
{t('knowledge.kbName')}
<span className="text-destructive">*</span>
</FormLabel>
<FormItem>
<FormLabel>{t('knowledge.kbDescription')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
@@ -368,40 +402,19 @@ export default function KBForm({
</FormItem>
)}
/>
<FormField
control={form.control}
name="emoji"
render={({ field }) => (
<FormItem>
<FormLabel>{t('common.icon')}</FormLabel>
<FormControl>
<EmojiPicker
value={field.value}
onChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</CardContent>
</Card>
)}
{/* Description */}
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>{t('knowledge.kbDescription')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Knowledge Engine Selector */}
{/* Knowledge engine selection and settings stay together. */}
<Card>
<CardHeader>
<CardTitle>{t('knowledge.engineSettings')}</CardTitle>
<CardDescription>
{t('knowledge.engineSettingsDescription')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<FormField
control={form.control}
name="ragEngineId"
@@ -484,36 +497,28 @@ export default function KBForm({
</FormItem>
)}
/>
{configFormItems.length > 0 && (
<>
<Separator />
<DynamicFormComponent
itemConfigList={configFormItems}
initialValues={configSettings as Record<string, object>}
onSubmit={(val) =>
setConfigSettings(val as Record<string, unknown>)
}
isEditing={isEditing}
externalDependentValues={retrievalSettings}
onValidate={(validateFn) =>
(configValidateRef.current = validateFn)
}
/>
</>
)}
</CardContent>
</Card>
{/* Card 2: Engine Settings (dynamic form from creation_schema) */}
{configFormItems.length > 0 && (
<Card>
<CardHeader>
<CardTitle>{t('knowledge.engineSettings')}</CardTitle>
<CardDescription>
{t('knowledge.engineSettingsDescription')}
</CardDescription>
</CardHeader>
<CardContent>
<DynamicFormComponent
itemConfigList={configFormItems}
initialValues={configSettings as Record<string, object>}
onSubmit={(val) =>
setConfigSettings(val as Record<string, unknown>)
}
isEditing={isEditing}
externalDependentValues={retrievalSettings}
onValidate={(validateFn) =>
(configValidateRef.current = validateFn)
}
/>
</CardContent>
</Card>
)}
{/* Card 3: Retrieval Settings (dynamic form from retrieval_schema) */}
{/* Retrieval Settings (dynamic form from retrieval_schema) */}
{retrievalFormItems.length > 0 && (
<Card>
<CardHeader>
+1 -1
View File
@@ -269,7 +269,7 @@ function HomeLayoutInner({ children }: { children: React.ReactNode }) {
</div>
</header>
<main className="flex-1 overflow-hidden min-w-0 px-4 pb-4 pt-0">
<main className="min-h-0 min-w-0 flex-1 overflow-clip px-4 pb-4 pt-0">
<div
className={`mx-auto h-full w-full min-w-0 ${HOME_CONTENT_MAX_WIDTH}`}
>
@@ -1,14 +1,22 @@
import { useState, useEffect } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import PipelineFormComponent from '@/app/home/pipelines/components/pipeline-form/PipelineFormComponent';
import PipelineFormComponent, {
PipelineFormHandle,
} from '@/app/home/pipelines/components/pipeline-form/PipelineFormComponent';
import DebugDialog from '@/app/home/pipelines/components/debug-dialog/DebugDialog';
import PipelineMonitoringTab from '@/app/home/pipelines/components/monitoring-tab/PipelineMonitoringTab';
import ProcessorDetailWorkbench from '@/app/home/components/processor-detail/ProcessorDetailWorkbench';
import EntityBasicInfoDialog, {
EntityBasicInfoValues,
} from '@/app/home/components/entity-basic-info/EntityBasicInfoDialog';
import EntityTitleEditButton from '@/app/home/components/entity-basic-info/EntityTitleEditButton';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { useTranslation } from 'react-i18next';
import { Settings, Bug, BarChart3 } from 'lucide-react';
import { useCurrentWorkspace } from '@/app/infra/http';
import { httpClient } from '@/app/infra/http/HttpClient';
import { Pipeline } from '@/app/infra/entities/api';
export default function PipelineDetailContent({
id,
@@ -40,15 +48,50 @@ export default function PipelineDetailContent({
return () => setDetailEntityName(null);
}, [id, isCreateMode, pipelines, setDetailEntityName, t]);
const [activeTab, setActiveTab] = useState('config');
const [isWebSocketConnected, setIsWebSocketConnected] = useState(false);
const [formDirty, setFormDirty] = useState(false);
const [formSaving, setFormSaving] = useState(false);
const [basicInfoOpen, setBasicInfoOpen] = useState(false);
const [pipelineDetails, setPipelineDetails] = useState<Pipeline | null>(null);
const pipelineFormRef = useRef<PipelineFormHandle>(null);
const sidebarPipeline = pipelines.find((item) => item.id === id);
useEffect(() => {
if (isCreateMode) return;
let cancelled = false;
httpClient.getPipeline(id).then((response) => {
if (!cancelled) setPipelineDetails(response.pipeline);
});
return () => {
cancelled = true;
};
}, [id, isCreateMode]);
function handleFinish() {
refreshPipelines();
}
async function saveBasicInfo(values: EntityBasicInfoValues) {
try {
await httpClient.updatePipeline(id, values);
setPipelineDetails((current) =>
current
? { ...current, ...values }
: ({ ...values, config: {} } as Pipeline),
);
pipelineFormRef.current?.syncBasicInfo(values);
await refreshPipelines();
toast.success(t('pipelines.saveSuccess'));
} catch (error) {
const message =
typeof error === 'object' && error && 'msg' in error
? String((error as { msg?: string }).msg || '')
: '';
toast.error(t('pipelines.saveError') + message);
throw error;
}
}
function handleNewPipelineCreated(newPipelineId: string) {
refreshPipelines();
navigate(`${routeBase}?id=${encodeURIComponent(newPipelineId)}`);
@@ -95,63 +138,33 @@ export default function PipelineDetailContent({
}
// ==================== Edit Mode ====================
const pipelineName =
pipelineDetails?.name ||
sidebarPipeline?.name ||
t('pipelines.editPipeline');
const pipelineEmoji =
pipelineDetails?.emoji || sidebarPipeline?.emoji || '⚙️';
return (
<div className="flex h-full flex-col">
{/* Sticky Header: title + save button */}
<div className="flex items-center justify-between pb-4 shrink-0">
<h1 className="text-xl font-semibold">{t('pipelines.editPipeline')}</h1>
{canManage && (
<Button
type="submit"
form="pipeline-form"
disabled={!formDirty || formSaving}
className={activeTab !== 'config' ? 'invisible' : ''}
>
{t('common.save')}
</Button>
)}
</div>
{/* Horizontal Tabs */}
<Tabs
<>
<ProcessorDetailWorkbench
key={id}
value={activeTab}
onValueChange={setActiveTab}
className="flex flex-1 flex-col min-h-0"
>
<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('pipelines.debugChat')}
{activeTab === 'debug' && (
<span
className={`inline-block size-2 rounded-full ${
isWebSocketConnected ? 'bg-green-500' : 'bg-red-500'
}`}
/>
)}
</TabsTrigger>
)}
{canViewMonitoring && (
<TabsTrigger value="monitoring" className="gap-1.5">
<BarChart3 className="size-3.5" />
{t('pipelines.monitoring.title')}
</TabsTrigger>
)}
</TabsList>
{/* Tab: Configuration */}
<TabsContent
value="config"
className="flex-1 min-h-0 overflow-y-auto mt-4"
>
title={`${pipelineEmoji} ${pipelineName}`}
titleAction={
canManage ? (
<EntityTitleEditButton onClick={() => setBasicInfoOpen(true)} />
) : undefined
}
saveLabel={t('common.save')}
saveFormId="pipeline-form"
canSave={canManage}
isDirty={formDirty}
isSaving={formSaving}
configTitle={t('pipelines.configuration')}
configContent={
<fieldset className="contents" disabled={!canManage}>
<PipelineFormComponent
ref={pipelineFormRef}
pipelineId={id}
isEditMode={true}
disableForm={!canManage}
@@ -164,35 +177,52 @@ export default function PipelineDetailContent({
onSavingChange={setFormSaving}
/>
</fieldset>
</TabsContent>
{/* Tab: Debug */}
{canOperate && (
<TabsContent value="debug" className="flex-1 min-h-0 mt-4">
}
debugTitle={canOperate ? t('pipelines.debugChat') : undefined}
debugConnected={canOperate ? isWebSocketConnected : undefined}
debugConnectedLabel={t('pipelines.debugDialog.connected')}
debugDisconnectedLabel={t('pipelines.debugDialog.disconnected')}
debugContent={
canOperate ? (
<DebugDialog
open={activeTab === 'debug'}
open={true}
pipelineId={id}
isEmbedded={true}
compact={true}
hasUnsavedChanges={formDirty}
beforeSend={async () => pipelineFormRef.current?.save() ?? false}
onConnectionStatusChange={setIsWebSocketConnected}
/>
</TabsContent>
)}
{/* Tab: Monitoring */}
{canViewMonitoring && (
<TabsContent
value="monitoring"
className="flex-1 min-h-0 overflow-y-auto mt-4"
>
<PipelineMonitoringTab
pipelineId={id}
onNavigateToMonitoring={() => {
navigate('/home/monitoring');
}}
/>
</TabsContent>
)}
</Tabs>
</div>
) : undefined
}
unsavedLabel={t('pipelines.unsavedChanges')}
monitoring={
canViewMonitoring
? {
label: t('pipelines.monitoring.title'),
content: (
<PipelineMonitoringTab
pipelineId={id}
onNavigateToMonitoring={() => {
navigate('/home/monitoring');
}}
/>
),
}
: undefined
}
/>
<EntityBasicInfoDialog
open={basicInfoOpen}
onOpenChange={setBasicInfoOpen}
values={{
name: pipelineName,
description: pipelineDetails?.description || '',
emoji: pipelineEmoji,
}}
defaultEmoji="⚙️"
onSave={saveBasicInfo}
/>
</>
);
}
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { httpClient } from '@/app/infra/http/HttpClient';
import { DialogContent } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Switch } from '@/components/ui/switch';
import { cn } from '@/lib/utils';
@@ -40,13 +40,17 @@ import {
Music,
Code,
AlignLeft,
RotateCcw,
} from 'lucide-react';
interface DebugDialogProps {
open: boolean;
pipelineId: string;
isEmbedded?: boolean;
compact?: boolean;
onConnectionStatusChange?: (isConnected: boolean) => void;
beforeSend?: () => Promise<boolean>;
hasUnsavedChanges?: boolean;
}
function AuthenticatedMessageImage({
@@ -115,7 +119,10 @@ export default function DebugDialog({
open,
pipelineId,
isEmbedded = false,
compact = false,
onConnectionStatusChange,
beforeSend,
hasUnsavedChanges = false,
}: DebugDialogProps) {
const { t } = useTranslation();
const [selectedPipelineId, setSelectedPipelineId] = useState(pipelineId);
@@ -142,43 +149,61 @@ export default function DebugDialog({
new Set(),
);
const [streamOutput, setStreamOutput] = useState(true);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const scrollAreaRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const wsClientRef = useRef<WebSocketClient | null>(null);
const isInitializingRef = useRef<boolean>(false);
const historyRequestGenerationRef = useRef(0);
const invalidateHistoryRequests = useCallback(() => {
historyRequestGenerationRef.current++;
}, []);
const scrollToBottom = useCallback(() => {
// Use setTimeout to ensure scroll happens after DOM update
setTimeout(() => {
const scrollArea = document.querySelector('.scroll-area') as HTMLElement;
if (scrollArea) {
scrollArea.scrollTo({
top: scrollArea.scrollHeight,
behavior: 'smooth',
});
}
// Also ensure messagesEndRef scrolls into view
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
const viewport = scrollAreaRef.current?.querySelector<HTMLElement>(
'[data-slot="scroll-area-viewport"]',
);
viewport?.scrollTo({
top: viewport.scrollHeight,
behavior: 'smooth',
});
}, 0);
}, []);
const loadMessages = useCallback(
async (pipelineId: string) => {
const generation = ++historyRequestGenerationRef.current;
try {
const response = await httpClient.getWebSocketHistoryMessages(
pipelineId,
sessionType,
);
setMessages(response.messages);
if (generation !== historyRequestGenerationRef.current) return;
setMessages(Array.isArray(response.messages) ? response.messages : []);
} catch (error) {
if (generation !== historyRequestGenerationRef.current) return;
console.error('Failed to load messages:', error);
}
},
[sessionType],
);
const resetConversation = useCallback(async () => {
try {
await httpClient.resetWebSocketSession(selectedPipelineId, sessionType);
invalidateHistoryRequests();
setMessages([]);
setQuotedMessage(null);
toast.success(t('pipelines.debugDialog.resetSuccess'));
} catch (error) {
console.error('Failed to reset Debug Chat session:', error);
toast.error(t('pipelines.debugDialog.resetFailed'));
}
}, [invalidateHistoryRequests, selectedPipelineId, sessionType, t]);
// Initialize WebSocket connection
const initWebSocket = useCallback(
async (pipelineId: string) => {
@@ -187,24 +212,30 @@ export default function DebugDialog({
return;
}
let wsClient: WebSocketClient | null = null;
let errorReported = false;
try {
isInitializingRef.current = true;
// Disconnect old connection
if (wsClientRef.current) {
wsClientRef.current.disconnect();
wsClientRef.current = null;
}
const previousClient = wsClientRef.current;
wsClientRef.current = null;
previousClient?.disconnect();
// Create new connection
const wsClient = new WebSocketClient(pipelineId, sessionType);
wsClient = new WebSocketClient(pipelineId, sessionType);
// Store the client before awaiting connect so effect cleanup can also
// cancel sockets that are still authenticating.
wsClientRef.current = wsClient;
wsClient
.onConnected(() => {
if (wsClientRef.current !== wsClient) return;
setIsConnected(true);
isInitializingRef.current = false;
})
.onMessage((wsMessage) => {
if (wsClientRef.current !== wsClient) return;
// Convert WebSocketMessage to Message type
const message: Message = {
...wsMessage,
@@ -229,26 +260,32 @@ export default function DebugDialog({
});
})
.onError((error) => {
if (wsClientRef.current !== wsClient) return;
errorReported = true;
console.error('WebSocket error:', error);
setIsConnected(false);
isInitializingRef.current = false;
toast.error(t('pipelines.debugDialog.connectionError'));
})
.onClose(() => {
if (wsClientRef.current !== wsClient) return;
setIsConnected(false);
isInitializingRef.current = false;
})
.onBroadcast((message) => {
if (wsClientRef.current !== wsClient) return;
toast.info(message);
});
await wsClient.connect();
wsClientRef.current = wsClient;
} catch (error) {
if (!wsClient || wsClientRef.current !== wsClient) return;
console.error('WebSocket connection failed:', error);
setIsConnected(false);
isInitializingRef.current = false;
toast.error(t('pipelines.debugDialog.connectionFailed'));
if (!errorReported) {
toast.error(t('pipelines.debugDialog.connectionFailed'));
}
}
},
[sessionType, t],
@@ -264,24 +301,28 @@ export default function DebugDialog({
if (open) {
setSelectedPipelineId(pipelineId);
} else {
invalidateHistoryRequests();
// Disconnect WebSocket immediately when dialog closes
if (wsClientRef.current) {
wsClientRef.current.disconnect();
const wsClient = wsClientRef.current;
wsClientRef.current = null;
wsClient.disconnect();
setIsConnected(false);
isInitializingRef.current = false;
}
}
return () => {
invalidateHistoryRequests();
// Disconnect WebSocket on component unmount
if (wsClientRef.current) {
wsClientRef.current.disconnect();
const wsClient = wsClientRef.current;
wsClientRef.current = null;
wsClient.disconnect();
isInitializingRef.current = false;
}
};
}, [open, pipelineId]);
}, [open, pipelineId, invalidateHistoryRequests]);
// Reload messages and reconnect when sessionType or selectedPipelineId changes
useEffect(() => {
@@ -321,7 +362,7 @@ export default function DebugDialog({
}
}, [showAtPopover]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
if (sessionType === 'group') {
if (value.endsWith('@')) {
@@ -412,8 +453,11 @@ export default function DebugDialog({
try {
setIsUploading(true);
if (hasUnsavedChanges && beforeSend && !(await beforeSend())) {
return;
}
const messageChain = [];
const messageChain: MessageChainComponent[] = [];
// Add quoted message if present
if (quotedMessage) {
@@ -467,17 +511,21 @@ export default function DebugDialog({
type: 'Image',
path: result.file_key,
});
} else {
} else if (attachment.kind === 'voice') {
// Voice / File go through the generic document upload endpoint,
// which returns a storage key the backend resolves into the
// sandbox inbox just like images.
const result = await httpClient.uploadDocumentFile(attachment.file);
messageChain.push({
type: attachment.kind === 'voice' ? 'Voice' : 'File',
type: 'Voice',
path: result.file_id,
...(attachment.kind === 'file'
? { name: attachment.file.name }
: {}),
});
} else {
const result = await httpClient.uploadDocumentFile(attachment.file);
messageChain.push({
type: 'File',
path: result.file_id,
name: attachment.file.name,
});
}
} catch (error) {
@@ -804,39 +852,58 @@ export default function DebugDialog({
};
const renderContent = () => (
<div className="flex flex-1 h-full min-h-0">
<div className="w-14 p-2 pl-0 shrink-0 flex flex-col justify-start gap-2">
<div className="flex flex-1 h-full min-h-0 flex-col">
<div
className={cn(
'flex shrink-0 flex-wrap items-center gap-1 border-b px-4 py-2',
compact && 'px-3',
)}
data-debug-session-toolbar="true"
>
<span className="mr-1 text-xs text-muted-foreground">
{t('pipelines.debugDialog.sessionType')}
</span>
<Button
type="button"
variant="ghost"
size="icon"
size="sm"
aria-pressed={sessionType === 'person'}
className={cn(
'w-10 h-10 justify-center rounded-md transition-none border-0 shadow-none',
sessionType === 'person'
? 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground',
'shadow-none',
sessionType === 'person' &&
'bg-primary/15 text-primary hover:bg-primary/20 hover:text-primary',
)}
onClick={() => setSessionType('person')}
>
<User className="size-5" />
<User className="size-4" />
{t('pipelines.debugDialog.privateChat')}
</Button>
<Button
type="button"
variant="ghost"
size="icon"
size="sm"
aria-pressed={sessionType === 'group'}
className={cn(
'w-10 h-10 justify-center rounded-md transition-none border-0 shadow-none',
sessionType === 'group'
? 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground',
'shadow-none',
sessionType === 'group' &&
'bg-primary/15 text-primary hover:bg-primary/20 hover:text-primary',
)}
onClick={() => setSessionType('group')}
>
<Users className="size-5" />
<Users className="size-4" />
{t('pipelines.debugDialog.groupChat')}
</Button>
</div>
<div className="flex-1 flex flex-col w-[10rem] h-full min-h-0">
<ScrollArea className="flex-1 p-6 overflow-y-auto min-h-0 scroll-area">
<div className="space-y-6">
<div className="flex-1 flex flex-col w-full h-full min-h-0">
<ScrollArea
ref={scrollAreaRef}
className={cn(
'flex-1 overflow-y-auto min-h-0 scroll-area',
compact ? 'p-3' : 'p-6',
)}
>
<div className={compact ? 'space-y-3' : 'space-y-6'}>
{messages.length === 0 ? (
<div className="text-center text-muted-foreground py-12 text-lg">
{t('pipelines.debugDialog.noMessages')}
@@ -852,7 +919,10 @@ export default function DebugDialog({
>
<div
className={cn(
'max-w-3xl px-5 py-3 rounded-2xl',
'rounded-2xl',
compact
? 'max-w-[92%] px-3 py-2 text-sm'
: 'max-w-3xl px-5 py-3',
message.role === 'user'
? 'user-message-bubble bg-primary/10 text-foreground rounded-br-none'
: 'bg-muted text-foreground rounded-bl-none',
@@ -919,7 +989,6 @@ export default function DebugDialog({
</div>
))
)}
<div ref={messagesEndRef} />
</div>
</ScrollArea>
@@ -990,8 +1059,11 @@ export default function DebugDialog({
</div>
)}
<div className="p-4 pb-0 flex gap-2">
<div className="flex gap-2 items-center">
<div
className={cn('shrink-0 border-t p-4', compact && 'p-3')}
data-debug-composer="true"
>
<div className="mb-2 flex items-center gap-2">
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground">
{t('pipelines.debugDialog.streamOutput')}
@@ -1020,69 +1092,93 @@ export default function DebugDialog({
>
<ImageIcon className="size-5" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="ml-auto text-muted-foreground"
onClick={() => void resetConversation()}
>
<RotateCcw className="size-4" />
{t('pipelines.debugDialog.reset')}
</Button>
</div>
<div className="flex-1 flex items-center gap-2">
{hasAt && (
<AtBadge targetName="websocketbot" onRemove={handleAtRemove} />
)}
<div className="relative flex-1">
<Input
ref={inputRef}
value={inputValue}
onChange={handleInputChange}
onKeyPress={handleKeyPress}
placeholder={t('pipelines.debugDialog.inputPlaceholder', {
type:
sessionType === 'person'
? t('pipelines.debugDialog.privateChat')
: t('pipelines.debugDialog.groupChat'),
})}
disabled={!isConnected || isUploading}
className="flex-1 rounded-md px-3 py-2 transition-none text-base disabled:opacity-50"
/>
{showAtPopover && (
<div
ref={popoverRef}
className="absolute bottom-full left-0 mb-2 w-auto rounded-md border bg-popover text-popover-foreground shadow-lg"
>
<div
className={cn(
'flex items-center gap-2 px-4 py-1.5 rounded cursor-pointer',
isHovering ? 'bg-accent' : '',
)}
onClick={handleAtSelect}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
>
<span>
@websocketbot - {t('pipelines.debugDialog.atTips')}
</span>
</div>
<div className="flex min-w-0 items-end gap-2">
<div className="min-w-0 flex-1">
{hasAt && (
<div className="mb-1">
<AtBadge
targetName="websocketbot"
onRemove={handleAtRemove}
/>
</div>
)}
<div className="relative">
<Textarea
ref={inputRef}
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyPress}
placeholder={t('pipelines.debugDialog.inputPlaceholder', {
type:
sessionType === 'person'
? t('pipelines.debugDialog.privateChat')
: t('pipelines.debugDialog.groupChat'),
})}
disabled={!isConnected || isUploading}
rows={1}
className="h-11 min-h-11 max-h-32 resize-y rounded-md px-3 py-2 text-sm transition-none disabled:opacity-50"
/>
{showAtPopover && (
<div
ref={popoverRef}
className="absolute bottom-full left-0 mb-2 w-auto rounded-md border bg-popover text-popover-foreground shadow-lg"
>
<div
className={cn(
'flex items-center gap-2 px-4 py-1.5 rounded cursor-pointer',
isHovering ? 'bg-accent' : '',
)}
onClick={handleAtSelect}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
>
<span>
@websocketbot - {t('pipelines.debugDialog.atTips')}
</span>
</div>
</div>
)}
</div>
</div>
<Button
onClick={sendMessage}
disabled={
(!inputValue.trim() &&
!hasAt &&
selectedImages.length === 0 &&
!quotedMessage) ||
!isConnected ||
isUploading
}
className={cn(
'h-11 shrink-0 rounded-md px-4 text-sm font-medium transition-none shadow-none disabled:opacity-50',
!compact && 'px-6 text-base',
)}
>
{isUploading ? (
t('pipelines.debugDialog.uploading')
) : (
<>
<Send className="size-4" />
{hasUnsavedChanges
? t('pipelines.debugDialog.saveAndSend')
: t('pipelines.debugDialog.send')}
</>
)}
</Button>
</div>
<Button
onClick={sendMessage}
disabled={
(!inputValue.trim() &&
!hasAt &&
selectedImages.length === 0 &&
!quotedMessage) ||
!isConnected ||
isUploading
}
className="rounded-md w-20 px-6 py-2 text-base font-medium transition-none flex items-center gap-2 shadow-none disabled:opacity-50"
>
{isUploading ? (
t('pipelines.debugDialog.uploading')
) : (
<>
<Send className="size-4" />
{t('pipelines.debugDialog.send')}
</>
)}
</Button>
</div>
</div>
</div>
@@ -1,4 +1,11 @@
import { useEffect, useRef, useState, useMemo } from 'react';
import {
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { GetPipelineResponseData, Pipeline } from '@/app/infra/entities/api';
import {
@@ -8,6 +15,7 @@ import {
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
import { getDefaultValues } from '@/app/home/components/dynamic-form/DynamicFormItemConfig';
import { Button } from '@/components/ui/button';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
@@ -31,7 +39,6 @@ import {
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { cn } from '@/lib/utils';
import {
Card,
CardContent,
@@ -51,17 +58,7 @@ import {
} from 'lucide-react';
import PipelineExtension from '@/app/home/pipelines/components/pipeline-extensions/PipelineExtension';
export default function PipelineFormComponent({
onFinish,
onNewPipelineCreated,
isEditMode,
pipelineId,
showButtons = true,
onDeletePipeline,
onCancel,
onDirtyChange,
onSavingChange,
}: {
interface PipelineFormComponentProps {
pipelineId?: string;
isEditMode: boolean;
disableForm: boolean;
@@ -72,7 +69,34 @@ export default function PipelineFormComponent({
onCancel?: () => void;
onDirtyChange?: (dirty: boolean) => void;
onSavingChange?: (saving: boolean) => void;
}) {
}
export interface PipelineFormHandle {
save: () => Promise<boolean>;
syncBasicInfo: (values: {
name: string;
description: string;
emoji?: string;
}) => void;
}
const PipelineFormComponent = forwardRef<
PipelineFormHandle,
PipelineFormComponentProps
>(function PipelineFormComponent(
{
onFinish,
onNewPipelineCreated,
isEditMode,
pipelineId,
showButtons = true,
onDeletePipeline,
onCancel,
onDirtyChange,
onSavingChange,
},
ref,
) {
const { t } = useTranslation();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showCopyConfirm, setShowCopyConfirm] = useState(false);
@@ -118,7 +142,7 @@ export default function PipelineFormComponent({
const formLabelList: SectionItem[] = isEditMode
? [
{
label: t('pipelines.basicInfo'),
label: t('common.management'),
name: 'basic',
icon: SECTION_ICONS.basic,
},
@@ -156,7 +180,20 @@ export default function PipelineFormComponent({
},
];
const [activeSection, setActiveSection] = useState(formLabelList[0].name);
const [activeSection, setActiveSection] = useState(
isEditMode ? 'trigger' : 'basic',
);
const primarySectionNames = ['trigger', 'ai', 'output'];
const primarySections = primarySectionNames
.map((name) => formLabelList.find((section) => section.name === name))
.filter((section): section is SectionItem => Boolean(section));
const secondarySections = formLabelList
.filter((section) => !primarySectionNames.includes(section.name))
.sort((left, right) => {
if (left.name === 'basic') return 1;
if (right.name === 'basic') return -1;
return 0;
});
const [aiConfigTabSchema, setAIConfigTabSchema] =
useState<PipelineConfigTab>();
@@ -259,7 +296,7 @@ export default function PipelineFormComponent({
function handleFormSubmit(values: FormValues) {
if (isEditMode) {
handleModify(values);
void handleModify(values);
} else {
handleCreate(values);
}
@@ -293,8 +330,8 @@ export default function PipelineFormComponent({
});
}
function handleModify(values: FormValues) {
if (isSavingRef.current) return;
async function handleModify(values: FormValues): Promise<boolean> {
if (isSavingRef.current) return false;
const submittedSnapshot = JSON.stringify(values);
const realConfig = {
ai: values.ai,
@@ -318,23 +355,54 @@ export default function PipelineFormComponent({
isSavingRef.current = true;
setIsSaving(true);
onSavingChange?.(true);
httpClient
.updatePipeline(pipelineId || '', pipeline)
.then(() => {
savedSnapshotRef.current = submittedSnapshot;
onFinish();
toast.success(t('pipelines.saveSuccess'));
})
.catch((err) => {
toast.error(t('pipelines.saveError') + err.msg);
})
.finally(() => {
isSavingRef.current = false;
setIsSaving(false);
onSavingChange?.(false);
});
try {
await httpClient.updatePipeline(pipelineId || '', pipeline);
savedSnapshotRef.current = submittedSnapshot;
onFinish();
toast.success(t('pipelines.saveSuccess'));
return true;
} catch (err) {
const message =
typeof err === 'object' && err && 'msg' in err
? String((err as { msg?: string }).msg || '')
: '';
toast.error(t('pipelines.saveError') + message);
return false;
} finally {
isSavingRef.current = false;
setIsSaving(false);
onSavingChange?.(false);
}
}
useImperativeHandle(ref, () => ({
syncBasicInfo(values) {
form.setValue('basic', {
...form.getValues('basic'),
name: values.name,
description: values.description,
emoji: values.emoji || '⚙️',
});
if (savedSnapshotRef.current) {
const snapshot = JSON.parse(savedSnapshotRef.current) as FormValues;
snapshot.basic = {
...snapshot.basic,
name: values.name,
description: values.description,
emoji: values.emoji || '⚙️',
};
savedSnapshotRef.current = JSON.stringify(snapshot);
}
},
async save() {
if (!hasUnsavedChangesRef.current) return true;
if (isSavingRef.current || !isEditMode) return false;
const valid = await form.trigger();
if (!valid) return false;
return handleModify(form.getValues());
},
}));
// Called from DynamicFormComponent onSubmit callbacks.
// On the first emission for a stage (mount-time default filling), the
// snapshot is synchronously re-captured so that hasUnsavedChanges stays false.
@@ -567,100 +635,133 @@ export default function PipelineFormComponent({
onSubmit={form.handleSubmit(handleFormSubmit)}
className="h-full flex flex-col flex-1 min-h-0 mb-2"
>
<div className="flex-1 flex flex-col md:flex-row min-h-0">
{/* Vertical section navigation (only show when multiple sections) */}
<div className="flex-1 flex min-h-0 flex-col">
{/* Keep the primary pipeline flow visible while editing. */}
{formLabelList.length > 1 && (
<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">
<ul className="flex md:flex-col gap-1 md:space-y-1">
{formLabelList.map((section) => {
const Icon = section.icon;
return (
<li key={section.name}>
<button
<nav className="mb-4 shrink-0 space-y-2 border-b pb-4">
<Tabs value={activeSection} onValueChange={setActiveSection}>
<div className="overflow-x-auto">
<TabsList className="grid min-w-[34rem] w-full grid-cols-3">
{primarySections.map((section) => {
const Icon = section.icon;
return (
<TabsTrigger
key={section.name}
value={section.name}
>
<Icon />
{section.label}
</TabsTrigger>
);
})}
</TabsList>
</div>
<div className="flex flex-wrap gap-1">
{secondarySections.map((section) => {
const Icon = section.icon;
return (
<Button
key={section.name}
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',
variant={
activeSection === section.name
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-muted hover:text-foreground',
)}
? 'secondary'
: 'ghost'
}
size="sm"
onClick={() => setActiveSection(section.name)}
>
<Icon className="size-4 shrink-0" />
<Icon />
{section.label}
</button>
</li>
);
})}
</ul>
</Button>
);
})}
</div>
</Tabs>
</nav>
)}
{/* Content panel */}
<div className="flex-1 overflow-y-auto min-h-0">
{/* Basic info section */}
{activeSection === 'basic' && (
<div className="space-y-6">
{/* Basic Information Card */}
<Card>
<CardHeader>
<CardTitle>{t('pipelines.basicInfo')}</CardTitle>
<CardTitle>
{isEditMode
? t('common.management')
: t('pipelines.basicInfo')}
</CardTitle>
<CardDescription>
{t('pipelines.basicInfoDescription')}
{isEditMode
? t('pipelines.managementDescription')
: t('pipelines.basicInfoDescription')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Name and Emoji in same row */}
<div className="flex gap-4 items-start">
<FormField
control={form.control}
name="basic.name"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
{t('common.name')}
<span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input {...field} value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="basic.emoji"
render={({ field }) => (
<FormItem>
<FormLabel>{t('common.icon')}</FormLabel>
<FormControl>
<EmojiPicker
value={field.value}
onChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{!isEditMode && (
<>
<div className="flex gap-4 items-start">
<FormField
control={form.control}
name="basic.name"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
{t('common.name')}
<span className="text-destructive">
*
</span>
</FormLabel>
<FormControl>
<Input
{...field}
value={field.value ?? ''}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="basic.emoji"
render={({ field }) => (
<FormItem>
<FormLabel>{t('common.icon')}</FormLabel>
<FormControl>
<EmojiPicker
value={field.value}
onChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="basic.description"
render={({ field }) => (
<FormItem>
<FormLabel>{t('common.description')}</FormLabel>
<FormControl>
<Input {...field} value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="basic.description"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('common.description')}
</FormLabel>
<FormControl>
<Input
{...field}
value={field.value ?? ''}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{/* Copy pipeline (edit mode only) */}
{isEditMode && (
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
@@ -851,7 +952,9 @@ export default function PipelineFormComponent({
</Dialog>
</>
);
}
});
export default PipelineFormComponent;
interface SectionItem {
label: string;
name: string;
-17
View File
@@ -177,7 +177,6 @@ export interface Agent {
kind: AgentKind;
component_ref?: string | null;
config?: Record<string, unknown>;
enabled?: boolean;
supported_event_patterns?: string[];
capability?: AgentCapability;
created_at?: string;
@@ -276,11 +275,6 @@ export interface BotRouteDryRunRequest {
event_bindings?: EventBinding[];
}
export interface BotRouteTestRequest {
event_type: string;
payload?: Record<string, unknown>;
}
export interface BotRouteDryRunTarget {
target_type: EventBinding['target_type'];
target_uuid?: string | null;
@@ -335,17 +329,6 @@ export interface BotEventRouteStatusResponse {
stale_routes: BotEventRouteStatus[];
}
export interface BotRouteTestResult {
dispatched: boolean;
event_type: string;
status?: BotEventRouteStatus['last_status'];
binding_id?: string | null;
failure_code?: string | null;
reason?: string | null;
suppressed_outputs: Array<Record<string, unknown>>;
route_status: BotEventRouteStatusResponse;
}
export interface ApiRespKnowledgeBases {
bases: KnowledgeBase[];
}
+1 -1
View File
@@ -19,7 +19,7 @@ export interface Plain extends MessageComponent {
// Quote component
export interface Quote extends MessageComponent {
type: 'Quote';
id?: number;
id?: number | string;
group_id?: number | string;
sender_id?: number | string;
target_id?: number | string;
+5 -14
View File
@@ -61,8 +61,6 @@ import {
ApiRespSkill,
BotRouteDryRunRequest,
BotRouteDryRunResult,
BotRouteTestRequest,
BotRouteTestResult,
BotEventRouteStatusResponse,
} from '@/app/infra/entities/api';
import { Plugin } from '@/app/infra/entities/plugin';
@@ -329,7 +327,10 @@ export class BackendClient extends BaseHttpClient {
return this.post('/api/v1/pipelines', pipeline);
}
public updatePipeline(uuid: string, pipeline: Pipeline): Promise<object> {
public updatePipeline(
uuid: string,
pipeline: Partial<Pipeline>,
): Promise<object> {
return this.put(`/api/v1/pipelines/${uuid}`, pipeline);
}
@@ -489,7 +490,7 @@ export class BackendClient extends BaseHttpClient {
return this.post('/api/v1/platform/bots', bot);
}
public updateBot(uuid: string, bot: Bot): Promise<object> {
public updateBot(uuid: string, bot: Partial<Bot>): Promise<object> {
return this.put(`/api/v1/platform/bots/${uuid}`, bot);
}
@@ -509,16 +510,6 @@ export class BackendClient extends BaseHttpClient {
return this.get(`/api/v1/platform/bots/${botId}/event-routes/status`);
}
public testBotEventRoute(
botId: string,
request: BotRouteTestRequest,
): Promise<BotRouteTestResult> {
return this.post(
`/api/v1/platform/bots/${botId}/event-routes/test`,
request,
);
}
public deleteBot(uuid: string): Promise<object> {
return this.delete(`/api/v1/platform/bots/${uuid}`);
}
@@ -4,9 +4,10 @@ export interface GetBotLogsResponse {
}
export interface BotLog {
images: [];
images: string[];
level: string;
message_session_id: string;
metadata?: Record<string, unknown> | null;
seq_id: number;
text: string;
timestamp: number;
+71 -18
View File
@@ -3,12 +3,13 @@
* WebSocket连接和消息处理
*/
import { getActiveWorkspaceUuid } from '@/app/infra/http/workspaceContext';
import type { MessageChainComponent } from '@/app/infra/entities/message';
export interface WebSocketMessage {
id: number;
role: 'user' | 'assistant';
content: string;
message_chain: Array<{ type: string; text?: string; target?: string }>;
message_chain: MessageChainComponent[];
timestamp: string;
is_final?: boolean;
connection_id?: string;
@@ -36,9 +37,12 @@ export class WebSocketClient {
private reconnectAttempts = 0;
private maxReconnectAttempts = 5;
private reconnectDelay = 3000; // 3秒重连间隔
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
private heartbeatInterval: NodeJS.Timeout | null = null;
private heartbeatIntervalMs = 30000; // 30秒
private isConnecting = false; // 防止重复连接
private shouldReconnect = true;
private disconnectedByUser = false;
// 事件回调
private onConnectedCallback?: (data: WebSocketResponse) => void;
@@ -59,6 +63,13 @@ export class WebSocketClient {
public connect(): Promise<string> {
return new Promise((resolve, reject) => {
try {
this.disconnectedByUser = false;
this.shouldReconnect = true;
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
// 防止重复连接
if (
this.isConnecting ||
@@ -87,22 +98,27 @@ export class WebSocketClient {
window.location.host;
const url = `${protocol}//${host}/api/v1/pipelines/${this.pipelineId}/ws/connect?session_type=${this.sessionType}`;
this.ws = new WebSocket(url);
const socket = new WebSocket(url);
this.ws = socket;
// 连接打开
this.ws.onopen = () => {
this.reconnectAttempts = 0;
socket.onopen = () => {
if (this.disconnectedByUser || this.ws !== socket) {
socket.close();
return;
}
this.isConnecting = false;
const token = this.token || localStorage.getItem('token');
const workspaceUuid = getActiveWorkspaceUuid();
if (!token || !workspaceUuid) {
const error = new Error('WebSocket认证信息缺失');
this.shouldReconnect = false;
this.onErrorCallback?.(error);
this.ws?.close();
socket.close();
reject(error);
return;
}
this.ws?.send(
socket.send(
JSON.stringify({
type: 'authenticate',
token,
@@ -112,13 +128,23 @@ export class WebSocketClient {
};
// 接收消息
this.ws.onmessage = (event) => {
socket.onmessage = (event) => {
if (this.disconnectedByUser || this.ws !== socket) return;
try {
const data: WebSocketResponse = JSON.parse(event.data);
this.handleMessage(data);
if (data.type === 'error' && !this.connectionId) {
reject(new Error(data.message || 'WebSocket连接失败'));
return;
}
// 第一次连接成功
if (data.type === 'connected' && data.connection_id) {
// Only a fully authenticated runtime connection should reset
// the retry budget. Resetting on TCP open makes server-side
// errors (for example a pipeline still loading) retry forever.
this.reconnectAttempts = 0;
this.connectionId = data.connection_id;
this.startHeartbeat();
resolve(data.connection_id);
@@ -130,22 +156,36 @@ export class WebSocketClient {
};
// 连接关闭
this.ws.onclose = () => {
socket.onclose = () => {
if (this.ws === socket) {
this.ws = null;
this.connectionId = null;
}
this.isConnecting = false;
this.stopHeartbeat();
if (this.disconnectedByUser) return;
this.onCloseCallback?.();
// 自动重连
if (this.reconnectAttempts < this.maxReconnectAttempts) {
if (
this.shouldReconnect &&
this.reconnectAttempts < this.maxReconnectAttempts
) {
this.reconnectAttempts++;
setTimeout(() => {
this.reconnectTimeout = setTimeout(() => {
this.reconnectTimeout = null;
if (!this.shouldReconnect || this.disconnectedByUser) return;
this.connect().catch(console.error);
}, this.reconnectDelay * this.reconnectAttempts);
}
};
// 连接错误
this.ws.onerror = (event) => {
socket.onerror = (event) => {
if (this.disconnectedByUser || this.ws !== socket) {
reject(new Error('WebSocket连接已取消'));
return;
}
console.error('WebSocket错误:', event);
this.isConnecting = false;
const error = new Error('WebSocket连接失败');
@@ -210,6 +250,13 @@ export class WebSocketClient {
case 'error':
const error = new Error(data.message || '未知错误');
this.onErrorCallback?.(error);
// Authentication/resource errors happen before the `connected`
// handshake. Retrying them cannot recover and would leak error toasts
// after the user leaves the Pipeline page.
if (!this.connectionId) {
this.shouldReconnect = false;
this.ws?.close();
}
break;
default:
@@ -221,7 +268,7 @@ export class WebSocketClient {
*
*/
public sendMessage(
messageChain: Array<{ type: string; text?: string; target?: string }>,
messageChain: MessageChainComponent[],
stream: boolean = true,
) {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
@@ -272,21 +319,27 @@ export class WebSocketClient {
*
*/
public disconnect() {
this.disconnectedByUser = true;
this.shouldReconnect = false;
this.reconnectAttempts = this.maxReconnectAttempts;
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
if (this.ws) {
this.stopHeartbeat();
// 停止自动重连
this.reconnectAttempts = this.maxReconnectAttempts;
const socket = this.ws;
// 发送断开消息
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: 'disconnect' }));
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'disconnect' }));
}
this.ws.close();
this.ws = null;
this.connectionId = null;
this.isConnecting = false;
socket.close();
}
}
-1
View File
@@ -839,7 +839,6 @@ export default function WizardPage() {
runner: { id: selectedRunner, 'expire-time': 0 },
runner_config: { [selectedRunner]: runnerConfig },
},
enabled: true,
supported_event_patterns: [selectedScenarioDefinition.eventType],
});
processorUuid = agentResp.uuid;
+3
View File
@@ -10,6 +10,7 @@ interface EmojiPickerProps {
value?: string;
onChange: (emoji: string) => void;
disabled?: boolean;
ariaLabel?: string;
}
// 扩展的emoji分类
@@ -179,6 +180,7 @@ export default function EmojiPicker({
value,
onChange,
disabled,
ariaLabel,
}: EmojiPickerProps) {
const [open, setOpen] = useState(false);
const [activeCategory, setActiveCategory] = useState<string>('common');
@@ -199,6 +201,7 @@ export default function EmojiPicker({
disabled={disabled}
className="w-16 h-16 text-3xl p-0 hover:bg-gray-100 dark:hover:bg-gray-800"
type="button"
aria-label={ariaLabel}
>
{value || '😀'}
</Button>
+2 -2
View File
@@ -207,7 +207,7 @@ function SidebarProvider({
} as React.CSSProperties
}
className={cn(
'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex h-svh w-full overflow-hidden',
'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex h-svh min-h-0 w-full overflow-clip',
className,
)}
{...props}
@@ -566,7 +566,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) {
<main
data-slot="sidebar-inset"
className={cn(
'bg-background relative flex w-full flex-1 flex-col min-w-0',
'bg-background relative flex min-h-0 w-full flex-1 flex-col overflow-clip min-w-0',
'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',
'dark:md:peer-data-[variant=inset]:border dark:md:peer-data-[variant=inset]:border-sidebar-border',
className,
+78 -41
View File
@@ -42,6 +42,10 @@ const enUS = {
joinDiscord: 'Join our Discord',
create: 'Create',
edit: 'Edit',
editBasicInfo: 'Edit basic information',
editBasicInfoDescription: 'Change the name, description, and icon.',
editBasicInfoDescriptionNoIcon: 'Change the name and description.',
management: 'Management',
delete: 'Delete',
add: 'Add',
select: 'Select',
@@ -384,11 +388,12 @@ const enUS = {
routingConnectionDescription:
'Bind the pipeline that processes messages for this bot',
eventRouting: 'Event Routing',
eventRoutingDescription:
'Choose which processor handles each event received by this bot. Edit the logic in the corresponding Agent or Pipeline configuration. Pipelines only support message events.',
eventRoutingDescription: 'Choose which processor handles each event.',
eventBindings: 'Event Routes',
addEventBinding: 'Add Route',
addBehavior: 'Add behavior',
commonScenarios: 'Common scenarios',
dragEventRoute: 'Drag route {{index}}',
behaviorReplyMessages: 'Reply to messages',
behaviorReplyMessagesDescription:
'Send incoming messages to an Agent or Pipeline.',
@@ -425,21 +430,50 @@ const enUS = {
disable: 'Disable',
enable: 'Enable',
disabledBindings: 'Disabled',
adapterEventsTitle: 'Events this adapter can receive',
adapterEventsDescription:
'{{count}} event types are available. Routes are matched in order, and unmatched events are not sent to a processor.',
adapterEventsTitle: 'Supported events',
adapterEventsDescription: '{{count}} event types',
adapterEventsMore: '{{count}} more',
advancedEventValues: 'Advanced event values',
advancedEventValues: 'View all',
eventGroup: 'Group',
eventGroupNames: {
bot: 'Bot status',
feedback: 'Feedback',
friend: 'Friends',
group: 'Groups',
message: 'Messages',
platform: 'Platform',
},
routeConflictTitle: 'Some routes overlap',
routeConflictShadowed:
'{{shadowed}} may never run because {{winner}} handles the same events first.',
routeConflictMore: '{{count}} more route conflicts need attention.',
routeFallbackCatchAll:
'{{route}} is the catch-all route. Routes with higher priority run first.',
routeFallbackCatchAll: '{{route}} is the catch-all route.',
routeFallbackIgnored:
'Events that match no route are ignored. Add a catch-all route only when every event needs an explicit outcome.',
testRoute: 'Test route',
testRoute: 'Check route',
adapterEventDebugAction: 'Listen for platform events',
adapterEventDebugTitle: 'Platform event debugging',
adapterEventDebugDescription:
'Trigger an event in {{platform}}. It will appear here when the adapter receives it.',
adapterEventObserveOnly:
'This window only observes events. Incoming events still follow the current routes.',
adapterEventPreparing: 'Preparing',
adapterEventListening: 'Listening',
adapterEventListenerUnavailable: 'Listening interrupted',
adapterEventLoadFailed:
'Platform events could not be read. Make sure the bot is running, then try again.',
adapterEventReceivedCount: '{{count}} events received',
adapterEventClear: 'Clear',
adapterEventEmptyTitle: 'Waiting for a platform event',
adapterEventEmptyDescription:
'Send a message or trigger an event in {{platform}}.',
adapterEventData: 'View event data',
adapterEventNeedsSavedBot:
'Save the bot before listening for platform events.',
adapterEventCurrentPlatform: 'the current platform',
adapterConfigurationTest: 'Test adapter configuration',
adapterConfigurationTestDescription:
'Save and enable the bot first, then trigger a platform event to confirm these settings work.',
refreshRouteStatus: 'Refresh status',
routeStatusIdle: 'No run yet',
routeStatusRefreshFailed: 'Failed to refresh route status.',
@@ -449,7 +483,6 @@ const enUS = {
discarded: 'Discarded',
failed: 'Failed',
not_matched: 'Not matched',
test_started: 'Testing',
},
routeStatusDetail: {
matched: 'This route matched the event.',
@@ -457,7 +490,6 @@ const enUS = {
discarded: 'The event was intentionally discarded.',
failed: 'The route could not finish.',
not_matched: 'No configured route matched the event.',
test_started: 'The saved route is running.',
},
routeFailure: {
binding_disabled: 'This route is disabled.',
@@ -468,35 +500,26 @@ const enUS = {
processor_incompatible:
'The selected processor cannot handle this event.',
processor_not_found: 'The selected processor is unavailable.',
processor_disabled: 'The selected processor is disabled.',
runner_failed: 'The Agent runner failed while processing the event.',
delivery_failed: 'The processor finished, but delivery failed.',
},
routeTestAction: 'Run saved route',
routeTestRunning: 'Running…',
routeTestFailed: 'Failed to run the saved route. Try again later.',
routeTestDispatched:
'The saved route ran successfully. {{count}} platform actions were blocked.',
routeTestSideEffectWarning:
'Running the saved route executes its processor. Platform messaging actions are blocked, but tools and external services may still have side effects.',
dryRunTitle: 'Test event route',
dryRunTitle: 'Check event route',
dryRunDescription:
'Preview the current form without running a processor, or run the saved route to validate the live configuration.',
'Choose an event to see which route and processor it matches.',
dryRunEventType: 'Event type',
dryRunSampleReady: 'Sample event is ready',
dryRunSampleDescription:
'LangBot prepared example data for {{event}}. Most route tests can use it as-is.',
dryRunEditPayload: 'Edit advanced data',
dryRunHidePayload: 'Hide advanced data',
dryRunPayload: 'Advanced event data (JSON)',
dryRunPayloadHint:
'Use simple fields such as message_text, chat_type, and chat_id to test conditions.',
dryRunEditPayload: 'Test data',
dryRunHidePayload: 'Hide data',
dryRunPayload: 'Test data (JSON)',
dryRunPayloadHint: 'Use this to test message and conversation conditions.',
dryRunPayloadJsonError: 'Enter valid JSON.',
dryRunPayloadObjectError: 'Payload must be a JSON object.',
dryRunNeedsSavedBot: 'Save the bot before testing routes.',
dryRunFailed: 'Route test failed. Try again later.',
dryRunAction: 'Preview route',
dryRunRunning: 'Testing…',
dryRunNeedsSavedBot: 'Save the bot before checking routes.',
dryRunFailed: 'Failed to check the route. Try again later.',
dryRunAction: 'View match',
dryRunRunning: 'Checking…',
dryRunMatched: 'Route matched',
dryRunNotMatched: 'No route matched',
dryRunTarget: 'Target processor',
@@ -689,7 +712,7 @@ const enUS = {
allEvents: 'Supports all events',
messageEventsOnly: 'Message events only',
basicInfo: 'Basic Information',
basicInfoDescription: 'Set the name, icon, description and enabled state',
basicInfoDescription: 'Set the name, icon and description',
runnerSettings: 'Runner',
advanced: 'Advanced',
bindableEvents: 'Bindable Event Range',
@@ -697,10 +720,9 @@ const enUS = {
'Limit which bot event routes can select this Agent. The default is suitable for most cases.',
supportedEvents: 'Event Range',
supportedEventsDescription:
'Use one event pattern per line, for example *, message.received, group.*. Pipelines are fixed to message.*.',
enabled: 'Enable Agent',
enabledDescription:
'When disabled, this Agent should not be selected by event routing.',
'Choose all events, an event group, or individual events. Bot routes will only list this Agent for matching events.',
searchEvents: 'Search events…',
noEventsFound: 'No matching events found',
nameRequired: 'Name cannot be empty',
createSuccess: 'Created successfully',
createError: 'Creation failed: ',
@@ -727,6 +749,8 @@ const enUS = {
selectedRunnerUnavailableDescription:
'{{runner}} is not currently registered. Select another runner or restore its extension.',
noRunnerSelected: 'No runner selected',
runnerConfigIncomplete: 'Runner configuration incomplete',
runnerConfigIncompleteDescription: 'Complete required fields: {{fields}}',
runnerReady: 'Runner ready',
runnerReadyDescription:
'{{runner}} is registered and the plugin runtime is connected.',
@@ -735,9 +759,6 @@ const enUS = {
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',
@@ -752,18 +773,32 @@ const enUS = {
debugEventPayload: 'Event payload (JSON)',
debugSupportedEvents: 'Agent supports',
debugRun: 'Run test',
debugSaveAndRun: 'Save and run',
debugRunning: 'Running',
debugTranscript: 'Debug transcript',
debugTranscriptDescription:
'Inputs and Agent outputs from the current debug session.',
debugEmptyTitle: 'Verify how this Agent behaves',
debugEmptyTranscript:
'Choose an event and run a test to see the result here.',
'Choose an event, enter test content, then select “Run test”. Results stay on this page.',
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',
debugUnsupportedEvent:
'This event is outside the Agents bindable event range',
debugRunnerConfigInvalidDescription:
'The runner configuration is incomplete: {{message}}',
debugRunnerExecutionFailedDescription:
'This run failed. Check the selected model and runner configuration, then try again.',
debugRunnerTimeoutDescription:
'The run timed out. Try again later or adjust the runner timeout.',
debugApiKeyRequired: 'API Key is missing',
debugOpenRunnerConfig: 'Open runner configuration',
debugReviewRunnerConfig: 'Review runner configuration',
debugErrorDetails: 'View error details',
debugRunFailed: 'Agent debug run failed',
},
plugins: {
@@ -1222,6 +1257,7 @@ const enUS = {
earliestEdited: 'Earliest Edited',
basicInfo: 'Basic Information',
basicInfoDescription: 'Set the pipeline name, icon and description',
managementDescription: 'Copy or delete this pipeline.',
aiCapabilities: 'AI',
triggerConditions: 'Trigger',
safetyControls: 'Safety',
@@ -1327,6 +1363,7 @@ const enUS = {
privateChat: 'Private Chat',
groupChat: 'Group Chat',
send: 'Send',
saveAndSend: 'Save and send',
reset: 'Reset Conversation',
inputPlaceholder: 'Send {{type}} message...',
noMessages: 'No messages',
@@ -1340,8 +1377,8 @@ const enUS = {
atTips: 'Mention the bot',
streaming: 'Streaming',
streamOutput: 'Stream',
connected: 'WebSocket connected',
disconnected: 'WebSocket disconnected',
connected: 'Connected',
disconnected: 'Disconnected',
connectionError: 'WebSocket connection error',
connectionFailed: 'WebSocket connection failed',
notConnected: 'WebSocket not connected, please try again later',
+6 -8
View File
@@ -525,8 +525,7 @@ const esES = {
allEvents: 'Compatible con todos los eventos',
messageEventsOnly: 'Solo eventos de mensaje',
basicInfo: 'Información básica',
basicInfoDescription:
'Establece el nombre, icono, descripción y estado de habilitación',
basicInfoDescription: 'Establece el nombre, icono y descripción',
runnerSettings: 'Runner',
advanced: 'Avanzado',
bindableEvents: 'Rango de eventos vinculables',
@@ -534,10 +533,9 @@ const esES = {
'Limita qué rutas de eventos del bot pueden seleccionar este Agent. El valor predeterminado sirve para la mayoría de los casos.',
supportedEvents: 'Rango de eventos',
supportedEventsDescription:
'Usa un patrón de evento por línea, por ejemplo *, message.received, group.*. Los Pipelines están fijos en message.*.',
enabled: 'Habilitar Agent',
enabledDescription:
'Cuando está deshabilitado, este Agent no debe ser seleccionado por el enrutamiento de eventos.',
'Elige todos los eventos, un grupo o eventos concretos. Las rutas del bot solo mostrarán este Agent para los eventos compatibles.',
searchEvents: 'Buscar eventos…',
noEventsFound: 'No se encontraron eventos',
nameRequired: 'El nombre no puede estar vacío',
createSuccess: 'Creado correctamente',
createError: 'Error al crear: ',
@@ -1137,8 +1135,8 @@ const esES = {
atTips: 'Mencionar al Bot',
streaming: 'Transmisión',
streamOutput: 'Transmisión',
connected: 'WebSocket conectado',
disconnected: 'WebSocket desconectado',
connected: 'Conectado',
disconnected: 'Desconectado',
connectionError: 'Error de conexión WebSocket',
connectionFailed: 'Conexión WebSocket fallida',
notConnected: 'WebSocket no conectado, por favor inténtalo más tarde',
+60 -39
View File
@@ -43,6 +43,10 @@ const jaJP = {
joinDiscord: 'Discord に参加',
create: '作成',
edit: '編集',
editBasicInfo: '基本情報を編集',
editBasicInfoDescription: '名前、説明、アイコンを変更します。',
editBasicInfoDescriptionNoIcon: '名前と説明を変更します。',
management: '管理',
delete: '削除',
add: '追加',
select: '選択してください',
@@ -390,11 +394,12 @@ const jaJP = {
routingConnectionDescription:
'このボットのメッセージを処理するパイプラインを紐付け',
eventRouting: 'イベントルーティング',
eventRoutingDescription:
'このボットが受信した各イベントをどのプロセッサーに渡すかを選択します。対応する Agent または Pipeline の設定で処理ロジックを編集します。Pipeline はメッセージイベントのみ対応します。',
eventRoutingDescription: 'イベントごとの処理先を設定します。',
eventBindings: 'イベントルート',
addEventBinding: 'ルートを追加',
addBehavior: '動作を追加',
commonScenarios: 'よく使うシーン',
dragEventRoute: 'ルート {{index}} をドラッグ',
behaviorReplyMessages: '受信メッセージに返信',
behaviorReplyMessagesDescription:
'受信メッセージを Agent または Pipeline で処理します。',
@@ -432,21 +437,50 @@ const jaJP = {
disable: '無効化',
enable: '有効化',
disabledBindings: '無効',
adapterEventsTitle: 'このアダプターが受信できるイベント',
adapterEventsDescription:
'{{count}} 種類のイベントを利用できます。ルートは上から順に照合され、未一致のイベントはプロセッサーへ送られません。',
adapterEventsTitle: '対応イベント',
adapterEventsDescription: '{{count}} 種類',
adapterEventsMore: 'ほか {{count}} 件',
advancedEventValues: '高度なイベント値',
advancedEventValues: 'すべて表示',
eventGroup: 'グループ',
eventGroupNames: {
bot: 'ボットの状態',
feedback: 'フィードバック',
friend: '友だち',
group: 'グループ',
message: 'メッセージ',
platform: 'プラットフォーム',
},
routeConflictTitle: '一部のルートが重複しています',
routeConflictShadowed:
'{{winner}} が同じイベントを先に処理するため、{{shadowed}} は実行されない可能性があります。',
routeConflictMore: 'ほか {{count}} 件のルート競合を確認してください。',
routeFallbackCatchAll:
'{{route}} はすべてのイベントを受けるフォールバックです。優先度の高いルートが先に実行されます。',
routeFallbackCatchAll: '{{route}} はフォールバックルートです。',
routeFallbackIgnored:
'どのルートにも一致しないイベントは無視されます。すべてのイベントに明示的な結果が必要な場合のみ、フォールバックを追加してください。',
testRoute: 'ルートをテスト',
testRoute: 'ルートを確認',
adapterEventDebugAction: 'プラットフォームイベントを監視',
adapterEventDebugTitle: 'プラットフォームイベントのデバッグ',
adapterEventDebugDescription:
'{{platform}} でイベントを発生させると、アダプターの受信後にここへ表示されます。',
adapterEventObserveOnly:
'この画面はイベントを監視するだけです。受信イベントは現在のルートで通常どおり処理されます。',
adapterEventPreparing: '準備中',
adapterEventListening: '監視中',
adapterEventListenerUnavailable: '監視が中断されました',
adapterEventLoadFailed:
'プラットフォームイベントを取得できません。ボットが起動していることを確認して、もう一度お試しください。',
adapterEventReceivedCount: '{{count}} 件のイベントを受信',
adapterEventClear: 'クリア',
adapterEventEmptyTitle: 'プラットフォームイベントを待機中',
adapterEventEmptyDescription:
'{{platform}} でメッセージを送るか、イベントを発生させてください。',
adapterEventData: 'イベントデータを表示',
adapterEventNeedsSavedBot:
'プラットフォームイベントを監視する前にボットを保存してください。',
adapterEventCurrentPlatform: '現在のプラットフォーム',
adapterConfigurationTest: 'アダプター設定をテスト',
adapterConfigurationTestDescription:
'先にボットを保存して有効にし、プラットフォームでイベントを発生させて設定を確認します。',
refreshRouteStatus: '状態を更新',
routeStatusIdle: '実行記録なし',
routeStatusRefreshFailed: 'ルート状態の更新に失敗しました。',
@@ -456,7 +490,6 @@ const jaJP = {
discarded: '破棄済み',
failed: '失敗',
not_matched: '未一致',
test_started: 'テスト中',
},
routeStatusDetail: {
matched: 'このルートがイベントに一致しました。',
@@ -464,7 +497,6 @@ const jaJP = {
discarded: '設定に従ってイベントを破棄しました。',
failed: 'ルートを完了できませんでした。',
not_matched: '設定済みルートに一致しませんでした。',
test_started: '保存済みルートを実行しています。',
},
routeFailure: {
binding_disabled: 'このルートは無効です。',
@@ -475,37 +507,26 @@ const jaJP = {
processor_incompatible:
'選択したプロセッサーはこのイベントを処理できません。',
processor_not_found: '選択したプロセッサーを利用できません。',
processor_disabled: '選択したプロセッサーは無効です。',
runner_failed: 'Agent Runner がイベント処理中に失敗しました。',
delivery_failed: '処理は完了しましたが、結果の配信に失敗しました。',
},
routeTestAction: '保存済みルートを実行',
routeTestRunning: '実行中…',
routeTestFailed:
'保存済みルートの実行に失敗しました。後でもう一度お試しください。',
routeTestDispatched:
'保存済みルートを実行しました。{{count}} 件のプラットフォーム操作を抑制しました。',
routeTestSideEffectWarning:
'保存済みルートを実行するとプロセッサーが動作します。プラットフォームのメッセージ操作は抑制されますが、ツールや外部サービスには副作用が生じる場合があります。',
dryRunTitle: 'イベントルートをテスト',
dryRunDescription:
'プロセッサーを実行せずに現在のフォームをプレビューするか、保存済みルートを実行して設定を検証します。',
dryRunTitle: 'イベントルートを確認',
dryRunDescription: 'イベントを選び、一致するルートと処理先を確認します。',
dryRunEventType: 'イベントタイプ',
dryRunSampleReady: 'サンプルイベントを準備しました',
dryRunSampleDescription:
'LangBot が「{{event}}」用のサンプルデータを準備しました。通常はそのままテストできます。',
dryRunEditPayload: '詳細データを編集',
dryRunHidePayload: '詳細データを閉じる',
dryRunPayload: '詳細イベントデータ(JSON',
dryRunPayloadHint:
'message_text、chat_type、chat_id などの簡単なフィールドで条件をテストできます。',
dryRunEditPayload: 'テストデータ',
dryRunHidePayload: 'データを閉じる',
dryRunPayload: 'テストデータ(JSON',
dryRunPayloadHint: 'メッセージや会話の条件テストに使用します。',
dryRunPayloadJsonError: '有効な JSON を入力してください。',
dryRunPayloadObjectError:
'ペイロードは JSON オブジェクトである必要があります。',
dryRunNeedsSavedBot: 'ルートをテストする前にボットを保存してください。',
dryRunFailed: 'ルートテストに失敗しました。後でもう一度お試しください。',
dryRunAction: 'ルートをプレビュー',
dryRunRunning: 'テスト中…',
dryRunNeedsSavedBot: 'ルートを確認する前にボットを保存してください。',
dryRunFailed: 'ルートを確認できませんでした。後でもう一度お試しください。',
dryRunAction: '一致結果を確認',
dryRunRunning: '確認中…',
dryRunMatched: 'ルートに一致しました',
dryRunNotMatched: '一致するルートはありません',
dryRunTarget: '対象プロセッサー',
@@ -704,7 +725,7 @@ const jaJP = {
allEvents: 'すべてのイベントに対応',
messageEventsOnly: 'メッセージイベントのみ',
basicInfo: '基本情報',
basicInfoDescription: '名前、アイコン、説明、有効状態を設定します',
basicInfoDescription: '名前、アイコン、説明を設定します',
runnerSettings: 'Runner',
advanced: '詳細',
bindableEvents: '紐付け可能なイベント範囲',
@@ -712,10 +733,9 @@ const jaJP = {
'この Agent を選択できるボットイベントルートの範囲を制限します。通常は既定値のままで問題ありません。',
supportedEvents: 'イベント範囲',
supportedEventsDescription:
'1 行に 1 つのイベントパターンを指定します。例: *、message.received、group.*。Pipeline は message.* 固定です。',
enabled: 'Agent を有効化',
enabledDescription:
'無効化すると、この Agent はイベントルーティングで選択されません。',
'すべてのイベント、イベントグループ、または個別のイベントを選択します。ボットルートでは一致するイベントにのみこの Agent が表示されます。',
searchEvents: 'イベントを検索…',
noEventsFound: '一致するイベントがありません',
nameRequired: '名前は必須です',
createSuccess: '作成に成功しました',
createError: '作成に失敗しました:',
@@ -1200,6 +1220,7 @@ const jaJP = {
earliestEdited: '最古編集',
basicInfo: '基本情報',
basicInfoDescription: 'パイプラインの名前、アイコン、説明を設定',
managementDescription: 'このパイプラインを複製または削除します。',
aiCapabilities: 'AI機能',
triggerConditions: 'トリガー条件',
safetyControls: '安全制御',
@@ -1318,8 +1339,8 @@ const jaJP = {
atTips: 'ボットをメンション',
streaming: 'ストリーミング',
streamOutput: 'ストリーム',
connected: 'WebSocket接続済み',
disconnected: 'WebSocket未接続',
connected: '接続済み',
disconnected: '未接続',
connectionError: 'WebSocket接続エラー',
connectionFailed: 'WebSocket接続に失敗しました',
notConnected:
+6 -7
View File
@@ -522,7 +522,7 @@ const ruRU = {
allEvents: 'Поддерживает все события',
messageEventsOnly: 'Только события сообщений',
basicInfo: 'Основная информация',
basicInfoDescription: 'Задайте имя, иконку, описание и статус активации',
basicInfoDescription: 'Задайте имя, иконку и описание',
runnerSettings: 'Runner',
advanced: 'Дополнительно',
bindableEvents: 'Диапазон привязываемых событий',
@@ -530,10 +530,9 @@ const ruRU = {
'Ограничьте, какие маршруты событий бота могут выбирать этот Agent. Обычно достаточно значения по умолчанию.',
supportedEvents: 'Диапазон событий',
supportedEventsDescription:
'Один шаблон события в строке, например *, message.received, group.*. Pipeline фиксирован на message.*.',
enabled: 'Включить Agent',
enabledDescription:
'При отключении этот Agent не должен выбираться маршрутизацией событий.',
'Выберите все события, группу или отдельные события. В маршрутах бота этот Agent будет доступен только для подходящих событий.',
searchEvents: 'Поиск событий…',
noEventsFound: 'Подходящие события не найдены',
nameRequired: 'Имя не может быть пустым',
createSuccess: 'Успешно создано',
createError: 'Ошибка создания: ',
@@ -1125,8 +1124,8 @@ const ruRU = {
atTips: 'Упомянуть бота',
streaming: 'Потоковая передача',
streamOutput: 'Поток',
connected: 'WebSocket подключён',
disconnected: 'WebSocket отключён',
connected: 'Подключено',
disconnected: 'Отключено',
connectionError: 'Ошибка подключения WebSocket',
connectionFailed: 'Ошибка подключения WebSocket',
notConnected: 'WebSocket не подключён, повторите попытку позже',
+6 -7
View File
@@ -508,7 +508,7 @@ const thTH = {
allEvents: 'รองรับทุกเหตุการณ์',
messageEventsOnly: 'เฉพาะเหตุการณ์ข้อความ',
basicInfo: 'ข้อมูลพื้นฐาน',
basicInfoDescription: 'ตั้งชื่อ ไอคอน คำอธิบาย และสถานะการเปิดใช้งาน',
basicInfoDescription: 'ตั้งชื่อ ไอคอน และคำอธิบาย',
runnerSettings: 'Runner',
advanced: 'ขั้นสูง',
bindableEvents: 'ช่วงเหตุการณ์ที่ผูกได้',
@@ -516,10 +516,9 @@ const thTH = {
'จำกัดว่าเส้นทางเหตุการณ์ของบอทใดสามารถเลือก Agent นี้ได้ ค่าเริ่มต้นเหมาะกับกรณีส่วนใหญ่',
supportedEvents: 'ช่วงเหตุการณ์',
supportedEventsDescription:
'หนึ่งรูปแบบเหตุการณ์ต่อบรรทัด เช่น *, message.received, group.* Pipeline ถูกกำหนดไว้ที่ message.*',
enabled: 'เปิดใช้งาน Agent',
enabledDescription:
'เมื่อปิดใช้งาน Agent นี้จะไม่ถูกเลือกโดยการกำหนดเส้นทางเหตุการณ์',
'เลือกเหตุการณ์ทั้งหมด กลุ่มเหตุการณ์ หรือเหตุการณ์ที่ต้องการ เส้นทางบอทจะแสดง Agent นี้เฉพาะเหตุการณ์ที่ตรงกัน',
searchEvents: 'ค้นหาเหตุการณ์…',
noEventsFound: 'ไม่พบเหตุการณ์ที่ตรงกัน',
nameRequired: 'ชื่อต้องไม่ว่างเปล่า',
createSuccess: 'สร้างสำเร็จ',
createError: 'สร้างล้มเหลว: ',
@@ -1100,8 +1099,8 @@ const thTH = {
atTips: 'กล่าวถึง Bot',
streaming: 'สตรีมมิ่ง',
streamOutput: 'สตรีม',
connected: 'เชื่อมต่อ WebSocket แล้ว',
disconnected: 'ยกเลิกการเชื่อมต่อ WebSocket',
connected: 'เชื่อมต่อแล้ว',
disconnected: 'ไม่ได้เชื่อมต่อ',
connectionError: 'ข้อผิดพลาดการเชื่อมต่อ WebSocket',
connectionFailed: 'เชื่อมต่อ WebSocket ล้มเหลว',
notConnected: 'ยังไม่ได้เชื่อมต่อ WebSocket กรุณาลองใหม่ภายหลัง',
+6 -7
View File
@@ -518,7 +518,7 @@ const viVN = {
allEvents: 'Hỗ trợ tất cả sự kiện',
messageEventsOnly: 'Chỉ sự kiện tin nhắn',
basicInfo: 'Thông tin cơ bản',
basicInfoDescription: 'Đặt tên, biểu tượng, mô tả và trạng thái kích hoạt',
basicInfoDescription: 'Đặt tên, biểu tượng và mô tả',
runnerSettings: 'Runner',
advanced: 'Nâng cao',
bindableEvents: 'Phạm vi sự kiện có thể gắn',
@@ -526,10 +526,9 @@ const viVN = {
'Giới hạn những tuyến sự kiện bot có thể chọn Agent này. Mặc định phù hợp với hầu hết trường hợp.',
supportedEvents: 'Phạm vi sự kiện',
supportedEventsDescription:
'Mỗi dòng một mẫu sự kiện, ví dụ *, message.received, group.*. Pipeline cố định ở message.*.',
enabled: 'Kích hoạt Agent',
enabledDescription:
'Khi bị tắt, Agent này sẽ không được định tuyến sự kiện chọn.',
'Chọn tất cả sự kiện, một nhóm hoặc từng sự kiện. Tuyến bot chỉ hiển thị Agent này cho các sự kiện phù hợp.',
searchEvents: 'Tìm sự kiện…',
noEventsFound: 'Không tìm thấy sự kiện phù hợp',
nameRequired: 'Tên không được để trống',
createSuccess: 'Tạo thành công',
createError: 'Tạo thất bại: ',
@@ -1117,8 +1116,8 @@ const viVN = {
atTips: 'Nhắc đến Bot',
streaming: 'Đang truyền',
streamOutput: 'Luồng',
connected: 'WebSocket đã kết nối',
disconnected: 'WebSocket đã ngắt kết nối',
connected: 'Đã kết nối',
disconnected: 'Đã ngắt kết nối',
connectionError: 'Lỗi kết nối WebSocket',
connectionFailed: 'Kết nối WebSocket thất bại',
notConnected: 'WebSocket chưa kết nối, vui lòng thử lại sau',
+74 -40
View File
@@ -41,6 +41,10 @@ const zhHans = {
joinDiscord: '加入 Discord 社区',
create: '创建',
edit: '编辑',
editBasicInfo: '编辑基本信息',
editBasicInfoDescription: '修改名称、描述和图标。',
editBasicInfoDescriptionNoIcon: '修改名称和描述。',
management: '管理',
delete: '删除',
add: '添加',
select: '请选择',
@@ -367,11 +371,12 @@ const zhHans = {
routingConnection: '路由与连接',
routingConnectionDescription: '绑定处理此机器人消息的流水线',
eventRouting: '事件路由',
eventRoutingDescription:
'选择此机器人收到不同事件时交给哪个处理器。在对应的 Agent 或 Pipeline 配置中编辑处理逻辑;Pipeline 仅支持消息事件。',
eventRoutingDescription: '设置收到事件后交给哪个处理器。',
eventBindings: '事件路由',
addEventBinding: '添加路由',
addBehavior: '添加行为',
commonScenarios: '常用场景',
dragEventRoute: '拖动第 {{index}} 条路由',
behaviorReplyMessages: '回复收到的消息',
behaviorReplyMessagesDescription:
'把收到的消息交给 Agent 或 Pipeline 处理。',
@@ -404,21 +409,48 @@ const zhHans = {
disable: '禁用',
enable: '启用',
disabledBindings: '已禁用',
adapterEventsTitle: '此适配器可接收的事件',
adapterEventsDescription:
'已识别 {{count}} 类事件。路由会按顺序匹配,未命中时不会交给处理器。',
adapterEventsTitle: '支持的事件',
adapterEventsDescription: '共 {{count}} 类',
adapterEventsMore: '另有 {{count}} 类',
advancedEventValues: '高级事件值',
advancedEventValues: '查看全部',
eventGroup: '事件组',
eventGroupNames: {
bot: '机器人状态',
feedback: '反馈',
friend: '好友',
group: '群组',
message: '消息',
platform: '平台',
},
routeConflictTitle: '部分路由存在覆盖冲突',
routeConflictShadowed:
'{{shadowed}} 可能永远不会运行,因为 {{winner}} 会先处理相同事件。',
routeConflictMore: '另有 {{count}} 个路由冲突需要处理。',
routeFallbackCatchAll:
'{{route}} 是全局兜底路由,优先级更高的路由会先运行。',
routeFallbackCatchAll: '{{route}} 是兜底路由。',
routeFallbackIgnored:
'未命中任何路由的事件会被忽略。只有需要为每个事件指定结果时,才添加全局兜底路由。',
testRoute: '测试路由',
testRoute: '检查路由',
adapterEventDebugAction: '监听平台事件',
adapterEventDebugTitle: '平台事件调试',
adapterEventDebugDescription:
'在 {{platform}} 中触发事件,适配器收到后会显示在这里。',
adapterEventObserveOnly:
'此窗口只负责观察;收到的事件仍会按当前路由正常处理。',
adapterEventPreparing: '准备中',
adapterEventListening: '正在监听',
adapterEventListenerUnavailable: '监听已中断',
adapterEventLoadFailed:
'无法读取平台事件。请确认机器人正在运行,然后稍后重试。',
adapterEventReceivedCount: '已收到 {{count}} 个事件',
adapterEventClear: '清空',
adapterEventEmptyTitle: '等待平台事件',
adapterEventEmptyDescription: '请在 {{platform}} 中发送消息或触发事件。',
adapterEventData: '查看事件数据',
adapterEventNeedsSavedBot: '请先保存机器人后再监听平台事件。',
adapterEventCurrentPlatform: '当前平台',
adapterConfigurationTest: '适配器测试',
adapterConfigurationTestDescription:
'请先保存并启用机器人,再到当前平台触发事件,确认这组配置是否已经生效。',
refreshRouteStatus: '刷新状态',
routeStatusIdle: '暂无运行记录',
routeStatusRefreshFailed: '刷新路由状态失败。',
@@ -428,7 +460,6 @@ const zhHans = {
discarded: '已丢弃',
failed: '失败',
not_matched: '未命中',
test_started: '测试中',
},
routeStatusDetail: {
matched: '此路由已命中事件。',
@@ -436,7 +467,6 @@ const zhHans = {
discarded: '此事件已按路由配置丢弃。',
failed: '此路由未能完成。',
not_matched: '没有已配置路由命中此事件。',
test_started: '正在运行已保存路由。',
},
routeFailure: {
binding_disabled: '此路由已禁用。',
@@ -446,34 +476,25 @@ const zhHans = {
route_not_found: '没有路由命中此事件。',
processor_incompatible: '所选处理器无法处理此事件。',
processor_not_found: '所选处理器不可用。',
processor_disabled: '所选处理器已禁用。',
runner_failed: 'Agent Runner 处理事件时失败。',
delivery_failed: '处理器已完成,但结果投递失败。',
},
routeTestAction: '运行已保存路由',
routeTestRunning: '运行中…',
routeTestFailed: '运行已保存路由失败,请稍后重试。',
routeTestDispatched: '已保存路由运行成功,{{count}} 个平台操作已被阻止。',
routeTestSideEffectWarning:
'运行已保存路由会执行处理器。平台消息操作会被阻止,但工具和外部服务仍可能产生副作用。',
dryRunTitle: '测试事件路由',
dryRunDescription:
'可以先预览当前表单而不运行处理器,也可以运行已保存路由来验证线上配置。',
dryRunTitle: '检查事件路由',
dryRunDescription: '选择事件,查看它会匹配哪条路由、交给哪个处理器。',
dryRunEventType: '事件类型',
dryRunSampleReady: '示例事件已准备好',
dryRunSampleDescription:
'LangBot 已为“{{event}}”准备示例数据,大多数路由测试可直接使用。',
dryRunEditPayload: '编辑高级数据',
dryRunHidePayload: '收起高级数据',
dryRunPayload: '高级事件数据(JSON',
dryRunPayloadHint:
'可填写 message_text、chat_type、chat_id 等简单字段,用于匹配触发条件。',
dryRunEditPayload: '测试数据',
dryRunHidePayload: '收起数据',
dryRunPayload: '测试数据(JSON',
dryRunPayloadHint: '用于测试消息内容、会话类型等条件。',
dryRunPayloadJsonError: '请输入合法 JSON。',
dryRunPayloadObjectError: '载荷必须是 JSON 对象。',
dryRunNeedsSavedBot: '请先保存机器人后再测试路由。',
dryRunFailed: '路由测试失败,请稍后重试。',
dryRunAction: '预览路由',
dryRunRunning: '测试中…',
dryRunNeedsSavedBot: '请先保存机器人后再检查路由。',
dryRunFailed: '无法检查路由,请稍后重试。',
dryRunAction: '查看匹配结果',
dryRunRunning: '检查中…',
dryRunMatched: '已命中路由',
dryRunNotMatched: '未命中路由',
dryRunTarget: '目标处理器',
@@ -660,7 +681,7 @@ const zhHans = {
allEvents: '支持全部事件',
messageEventsOnly: '仅支持消息事件',
basicInfo: '基础信息',
basicInfoDescription: '设置名称、图标描述和启用状态',
basicInfoDescription: '设置名称、图标描述',
runnerSettings: '运行器',
advanced: '高级',
bindableEvents: '可绑定事件范围',
@@ -668,9 +689,9 @@ const zhHans = {
'限制此 Agent 可被机器人事件路由选择的事件范围。通常保持默认即可。',
supportedEvents: '事件范围',
supportedEventsDescription:
'每行一个事件模式,例如 *、message.received、group.*。Pipeline 固定仅支持 message.*。',
enabled: '启用 Agent',
enabledDescription: '禁用后,此 Agent 不应被事件路由选中。',
'选择全部事件、事件组或具体事件。机器人路由只会在匹配的事件中显示此 Agent。',
searchEvents: '搜索事件…',
noEventsFound: '没有匹配的事件',
nameRequired: '名称不能为空',
createSuccess: '创建成功',
createError: '创建失败:',
@@ -696,15 +717,14 @@ const zhHans = {
selectedRunnerUnavailableDescription:
'{{runner}} 当前未注册。请选择其他运行器,或恢复对应扩展。',
noRunnerSelected: '尚未选择运行器',
runnerConfigIncomplete: '运行器配置待完善',
runnerConfigIncompleteDescription: '请填写必填项:{{fields}}',
runnerReady: '运行器已就绪',
runnerReadyDescription: '{{runner}} 已注册,插件运行时连接正常。',
debugTab: '事件调试',
debugTitle: 'Agent 事件调试',
debugDescription: '用消息或平台事件直接运行当前 Agent,并查看真实输出。',
debugResetSession: '重置会话',
debugActualRun: '这是真实运行',
debugActualRunDescription:
'测试会调用当前运行器、模型与已授权工具,但不会把输出发送到真实聊天平台。',
debugEventType: '事件类型',
debugMessageReceived: '收到消息',
debugGroupMemberJoined: '成员加入群组',
@@ -719,16 +739,28 @@ const zhHans = {
debugEventPayload: '事件载荷(JSON',
debugSupportedEvents: 'Agent 支持',
debugRun: '运行测试',
debugSaveAndRun: '保存并运行',
debugRunning: '运行中',
debugTranscript: '调试记录',
debugTranscriptDescription: '当前调试会话中的输入与 Agent 输出。',
debugEmptyTranscript: '选择事件并运行测试后,结果会显示在这里。',
debugEmptyTitle: '在这里验证 Agent 的实际效果',
debugEmptyTranscript:
'选择事件类型,填写测试内容,然后点击“运行测试”。结果只会显示在这里。',
debugAgentOutput: 'Agent 输出',
debugTestInput: '测试输入',
debugNoTextOutput: '运行完成,但没有产生文本输出。',
debugEventTypeRequired: '请输入事件类型',
debugInputRequired: '请输入对话内容',
debugInvalidPayload: '事件载荷必须是有效的 JSON 对象',
debugUnsupportedEvent: '这个事件不在当前 Agent 的可绑定事件范围内',
debugRunnerConfigInvalidDescription: '运行器配置不完整:{{message}}',
debugRunnerExecutionFailedDescription:
'本次运行失败。请检查所选模型和运行器配置后重试。',
debugRunnerTimeoutDescription: '运行超时。请稍后重试或调整运行器超时时间。',
debugApiKeyRequired: 'API Key 未填写',
debugOpenRunnerConfig: '前往运行器配置',
debugReviewRunnerConfig: '检查运行器配置',
debugErrorDetails: '查看详细错误',
debugRunFailed: 'Agent 调试运行失败',
},
plugins: {
@@ -1167,6 +1199,7 @@ const zhHans = {
earliestEdited: '最早编辑',
basicInfo: '基础信息',
basicInfoDescription: '设置流水线名称、图标和描述',
managementDescription: '复制或删除此流水线。',
aiCapabilities: 'AI 能力',
triggerConditions: '触发条件',
safetyControls: '安全控制',
@@ -1270,6 +1303,7 @@ const zhHans = {
privateChat: '私聊',
groupChat: '群聊',
send: '发送',
saveAndSend: '保存并发送',
reset: '重置对话',
inputPlaceholder: '发送 {{type}} 消息...',
noMessages: '暂无消息',
@@ -1283,8 +1317,8 @@ const zhHans = {
atTips: '提及机器人',
streaming: '流式传输',
streamOutput: '流式',
connected: 'WebSocket已连接',
disconnected: 'WebSocket未连接',
connected: '已连接',
disconnected: '未连接',
connectionError: 'WebSocket连接错误',
connectionFailed: 'WebSocket连接失败',
notConnected: 'WebSocket未连接,请稍后重试',
+6 -6
View File
@@ -491,7 +491,7 @@ const zhHant = {
allEvents: '支援全部事件',
messageEventsOnly: '僅支援訊息事件',
basicInfo: '基本資訊',
basicInfoDescription: '設定名稱、圖示描述和啟用狀態',
basicInfoDescription: '設定名稱、圖示描述',
runnerSettings: '執行器',
advanced: '進階',
bindableEvents: '可綁定事件範圍',
@@ -499,9 +499,9 @@ const zhHant = {
'限制此 Agent 可被機器人事件路由選擇的事件範圍。通常保持預設即可。',
supportedEvents: '事件範圍',
supportedEventsDescription:
'每行一個事件模式,例如 *、message.received、group.*。Pipeline 固定僅支援 message.*。',
enabled: '啟用 Agent',
enabledDescription: '停用後,此 Agent 不應被事件路由選中。',
'選擇全部事件、事件群組或具體事件。機器人路由只會在符合的事件中顯示此 Agent。',
searchEvents: '搜尋事件…',
noEventsFound: '沒有符合的事件',
nameRequired: '名稱不能為空',
createSuccess: '建立成功',
createError: '建立失敗:',
@@ -1066,8 +1066,8 @@ const zhHant = {
atTips: '提及機器人',
streaming: '串流傳輸',
streamOutput: '串流',
connected: 'WebSocket已連接',
disconnected: 'WebSocket未連接',
connected: '已連接',
disconnected: '未連接',
connectionError: 'WebSocket連接錯誤',
connectionFailed: 'WebSocket連接失敗',
notConnected: 'WebSocket未連接,請稍後重試',
+431 -95
View File
@@ -116,7 +116,7 @@ test.describe('frontend CRUD smoke flows', () => {
await expect(page.getByText('No logs yet')).toBeVisible();
await page.goto('/home/agents?id=pipeline-1');
await expect(page.getByRole('tab', { name: 'Dashboard' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Dashboard' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Debug Chat' })).toHaveCount(0);
await expect(page.getByRole('button', { name: /^Save$/ })).toHaveCount(0);
@@ -144,15 +144,21 @@ test.describe('frontend CRUD smoke flows', () => {
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
await page.reload();
await expect(page.locator('input[name="name"]')).toHaveValue('Support Bot');
await page
.locator('input[name="description"]')
await expect(
page.getByRole('heading', { name: 'Support Bot' }),
).toBeVisible();
await expect(page.locator('input[name="name"]')).toHaveCount(0);
await page.getByRole('button', { name: 'Edit basic information' }).click();
const botInfoDialog = page.getByRole('dialog');
await expect(botInfoDialog.getByLabel('Icon')).toHaveCount(0);
await botInfoDialog.getByLabel('Name').fill('Support Bot Updated');
await botInfoDialog
.getByLabel('Description')
.fill('Answers customer support questions with context.');
await save(page);
await expect(page.locator('input[name="description"]')).toHaveValue(
'Answers customer support questions with context.',
);
await botInfoDialog.getByRole('button', { name: 'Save' }).click();
await expect(
page.getByRole('heading', { name: 'Support Bot Updated' }),
).toBeVisible();
await page.getByRole('button', { name: /^Delete$/ }).click();
await confirmDelete(page);
@@ -176,18 +182,18 @@ test.describe('frontend CRUD smoke flows', () => {
await expect(page).toHaveURL(/\/home\/agents\?id=pipeline-1$/);
await page.reload();
await expect(page.locator('input[name="basic.name"]')).toHaveValue(
'Escalation Pipeline',
);
await page
.locator('input[name="basic.description"]')
await expect(
page.getByRole('heading', { name: /Escalation Pipeline/ }),
).toBeVisible();
await expect(page.locator('input[name="basic.name"]')).toHaveCount(0);
await page.getByRole('button', { name: 'Edit basic information' }).click();
const pipelineInfoDialog = page.getByRole('dialog');
await pipelineInfoDialog
.getByLabel('Description')
.fill('Routes urgent customer issues to operators.');
await save(page);
await expect(page.locator('input[name="basic.description"]')).toHaveValue(
'Routes urgent customer issues to operators.',
);
await pipelineInfoDialog.getByRole('button', { name: 'Save' }).click();
await page.getByRole('button', { name: 'Management' }).click();
await page.getByRole('button', { name: /^Delete$/ }).click();
await confirmDelete(page);
@@ -204,8 +210,10 @@ test.describe('frontend CRUD smoke flows', () => {
await page.goto('/home/agents?id=pipeline-ai');
await expect(page.locator('input[name="basic.name"]')).toBeVisible();
await page.getByRole('button', { name: /^AI$/ }).click();
await expect(
page.getByRole('heading', { name: /pipeline-ai/ }),
).toBeVisible();
await page.getByRole('tab', { name: /^AI$/ }).click();
await expect(page.getByText('Runtime')).toBeVisible();
await expect(
@@ -236,18 +244,25 @@ test.describe('frontend CRUD smoke flows', () => {
await expect(page).toHaveURL(/\/home\/knowledge\?id=knowledge-1$/);
await page.reload();
await expect(page.locator('input[name="name"]')).toHaveValue(
'Support Knowledge',
);
await page.waitForTimeout(600);
await expect(
page.getByRole('heading', { name: /Support Knowledge/ }),
).toBeVisible();
await expect(page.locator('input[name="name"]')).toHaveCount(0);
const engineSettings = page.locator('[data-slot="card"]').filter({
has: page.getByText('Engine Settings', { exact: true }),
});
await expect(engineSettings.getByRole('combobox')).toBeVisible();
await page
.locator('input[name="description"]')
await page.getByRole('button', { name: 'Edit basic information' }).click();
const kbInfoDialog = page.getByRole('dialog');
await kbInfoDialog.getByLabel('Name').fill('Support Knowledge Updated');
await kbInfoDialog
.getByLabel('Description')
.fill('Updated source material for support answers.');
await save(page);
await expect(page.locator('input[name="description"]')).toHaveValue(
'Updated source material for support answers.',
);
await kbInfoDialog.getByRole('button', { name: 'Save' }).click();
await expect(
page.getByRole('heading', { name: /Support Knowledge Updated/ }),
).toBeVisible();
await page.getByRole('button', { name: /^Delete$/ }).click();
await confirmDelete(page);
@@ -335,6 +350,226 @@ test.describe('frontend CRUD smoke flows', () => {
});
test.describe('bot advanced flows', () => {
test('keeps event routing compact and hides raw status errors', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
withAdapterEvents: true,
});
await page.route('**/api/v1/platform/bots/*/event-routes/status', (route) =>
route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ code: -1, msg: 'Internal server error' }),
}),
);
await page.route(
'**/api/v1/platform/bots/*/event-routes/dry-run',
(route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
code: 0,
msg: 'ok',
data: {
matched: true,
event_type: 'message.received',
matched_binding_id: 'binding-1',
matched_binding_index: 0,
target: {
target_type: 'agent',
target_uuid: 'agent-1',
target_name: 'NewAgent',
},
diagnostic_steps: ['Matched route 1'],
diagnostic_details: [],
},
}),
}),
);
let botLogPollCount = 0;
await page.route('**/api/v1/platform/bots/*/logs', (route) => {
botLogPollCount += 1;
const logs =
botLogPollCount === 1
? []
: [
{
seq_id: 7,
timestamp: Math.floor(Date.now() / 1000),
level: 'info',
text: 'Platform adapter received message.received',
images: [],
message_session_id: '',
metadata: {
kind: 'adapter_event_received',
event_type: 'message.received',
adapter: 'playwright-adapter',
bot_uuid: 'bot-1',
event_data: {
type: 'message.received',
chat_type: 'private',
chat_id: 'test-user',
message_chain: [{ type: 'Plain', text: 'adapter hello' }],
},
},
},
];
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
code: 0,
msg: 'ok',
data: { logs, total_count: logs.length },
}),
});
});
await page.goto('/home/bots?id=new');
await selectPlaywrightAdapter(page);
await page.locator('input[name="name"]').fill('Route Status Bot');
await submit(page);
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
await expect(page.getByText('Supported events')).toBeVisible();
await expect(page.getByText('5 event types')).toBeVisible();
await expect(page.getByText('Messages · 2')).toBeVisible();
await expect(page.getByText('Groups · 2')).toBeVisible();
await expect(page.getByText('Internal server error')).toHaveCount(0);
await expect(
page.getByText('Events that match no route are ignored.'),
).toHaveCount(0);
const routingCard = page
.locator('[data-slot="card"]')
.filter({ has: page.getByText('Event Routing', { exact: true }) });
const adapterCard = page.locator('[data-slot="card"]').filter({
has: page.getByText('Adapter Configuration', { exact: true }),
});
const dangerCard = page
.locator('[data-slot="card"]')
.filter({ has: page.getByText('Danger Zone', { exact: true }) });
const routingBox = await routingCard.boundingBox();
const dangerBox = await dangerCard.boundingBox();
expect(routingBox).not.toBeNull();
expect(dangerBox).not.toBeNull();
expect(
dangerBox!.y - (routingBox!.y + routingBox!.height),
).toBeGreaterThanOrEqual(20);
await routingCard.getByRole('button', { name: 'View all' }).click();
await expect(
routingCard.getByText('Messages', { exact: true }),
).toBeVisible();
await expect(
routingCard.getByText('Groups', { exact: true }),
).toBeVisible();
await routingCard.getByRole('button', { name: 'Add behavior' }).click();
await page.getByRole('menuitem', { name: /Reply to messages/ }).click();
const routeEventSelect = routingCard.getByRole('combobox').first();
await expect(routeEventSelect).toContainText('Message received');
await expect(routeEventSelect).toContainText('message.received');
await routeEventSelect.click();
const routeEventOption = page
.getByRole('option')
.filter({ hasText: 'Message received' });
await expect(routeEventOption).toContainText('message.received');
await expect(routeEventOption).toContainText(
'A user or group sends a new message to the bot.',
);
await page.keyboard.press('Escape');
await page.getByRole('button', { name: 'Refresh status' }).hover();
await expect(
page.getByText('Failed to refresh route status.'),
).toBeVisible();
await page.getByRole('button', { name: 'Check route' }).click();
const routeDialog = page.getByRole('dialog');
await expect(
routeDialog.getByText('Check event route', { exact: true }),
).toBeVisible();
await expect(
routeDialog.getByText(
'Choose an event to see which route and processor it matches.',
),
).toBeVisible();
await expect(routeDialog.getByText('Sample event is ready')).toHaveCount(0);
await expect(
routeDialog.getByRole('button', { name: 'Test data' }),
).toBeVisible();
await expect(
routeDialog.getByRole('button', { name: 'View match' }),
).toBeVisible();
await expect(
routeDialog.getByRole('button', { name: 'Run full test' }),
).toHaveCount(0);
const routeEventPicker = routeDialog.getByRole('combobox', {
name: 'Event type',
});
await expect(routeEventPicker).toContainText('message.received');
await routeEventPicker.click();
await expect(
page.getByText('Messages', { exact: true }).last(),
).toBeVisible();
await expect(
page.getByText('Groups', { exact: true }).last(),
).toBeVisible();
const receivedMessageOption = page
.getByRole('option')
.filter({ hasText: 'Message received' });
await expect(receivedMessageOption).toContainText('message.received');
await expect(receivedMessageOption).toContainText(
'A user or group sends a new message to the bot.',
);
await page.keyboard.press('Escape');
await routeDialog.getByRole('button', { name: 'View match' }).click();
await expect(routeDialog.getByText('Matched route')).toBeVisible();
await expect(routeDialog.getByText('Internal server error')).toHaveCount(0);
const dialogBox = await routeDialog.boundingBox();
expect(dialogBox).not.toBeNull();
expect(dialogBox!.height).toBeLessThan(500);
await routeDialog.getByRole('button', { name: 'Close' }).first().click();
await expect(
routingCard.getByRole('button', { name: 'Listen for platform events' }),
).toHaveCount(0);
await expect(
adapterCard.getByText('Test adapter configuration'),
).toBeVisible();
await adapterCard
.getByRole('button', { name: 'Listen for platform events' })
.click();
const adapterDialog = page.getByRole('dialog');
await expect(
adapterDialog.getByText('Platform event debugging', { exact: true }),
).toBeVisible();
await expect(adapterDialog).toContainText('Playwright Adapter');
await expect(adapterDialog).toContainText(
'This window only observes events. Incoming events still follow the current routes.',
);
await expect(
adapterDialog.getByText('Message received', { exact: true }),
).toBeVisible({ timeout: 5000 });
await expect(adapterDialog.getByText('message.received')).toBeVisible();
await expect(adapterDialog.getByText('adapter hello')).toBeVisible();
await adapterDialog
.getByRole('button', { name: 'View event data' })
.click();
await expect(
adapterDialog.getByText(/"chat_id": "test-user"/),
).toBeVisible();
await adapterDialog.getByRole('button', { name: 'Clear' }).click();
await expect(adapterDialog.getByText('0 events received')).toBeVisible();
await expect(
adapterDialog.getByText('Waiting for a platform event'),
).toBeVisible();
});
test('toggles bot enable/disable state', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true });
@@ -376,7 +611,9 @@ test.describe('bot advanced flows', () => {
await expect(
page.getByRole('tab', { name: /Configuration/ }),
).toHaveAttribute('data-state', 'active');
await expect(page.locator('input[name="name"]')).toBeVisible();
await expect(
page.getByRole('button', { name: 'Edit basic information' }),
).toBeVisible();
// Switch to Logs tab
await page.getByRole('tab', { name: /Logs/ }).click();
@@ -394,7 +631,9 @@ test.describe('bot advanced flows', () => {
// Switch back to Configuration
await page.getByRole('tab', { name: /Configuration/ }).click();
await expect(page.locator('input[name="name"]')).toBeVisible();
await expect(
page.getByRole('button', { name: 'Edit basic information' }),
).toBeVisible();
});
test('save button is disabled when form is clean', async ({ page }) => {
@@ -405,23 +644,22 @@ test.describe('bot advanced flows', () => {
await selectPlaywrightAdapter(page);
await page.locator('input[name="name"]').fill('Clean Form Bot');
await submit(page);
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
// Reload the persisted record so post-create initialization has completed.
await page.reload();
await expect(page.locator('input[name="name"]')).toHaveValue(
'Clean Form Bot',
);
await expect(
page.getByRole('heading', { name: 'Clean Form Bot' }),
).toBeVisible();
// After loading, save button should be disabled (form is clean)
const saveButton = page.getByRole('button', { name: /^Save$/ });
await expect(saveButton).toBeDisabled();
// Edit the form
await page.locator('input[name="description"]').fill('New description');
await expect(saveButton).toBeEnabled();
// Save
await saveButton.click();
await page.getByRole('button', { name: 'Edit basic information' }).click();
const infoDialog = page.getByRole('dialog');
await infoDialog.getByLabel('Description').fill('New description');
await infoDialog.getByRole('button', { name: 'Save' }).click();
await expect(saveButton).toBeDisabled();
});
@@ -457,7 +695,7 @@ test.describe('pipeline advanced flows', () => {
});
await page.goto('/home/agents?id=pipeline-scope');
await page.getByRole('button', { name: /^AI$/ }).click();
await page.getByRole('tab', { name: /^AI$/ }).click();
await expect(
page.getByRole('button', { name: 'Edit tools' }),
).toBeVisible();
@@ -485,22 +723,26 @@ test.describe('pipeline advanced flows', () => {
await page.locator('input[name="name"]').fill('Tab Test Pipeline');
await submit(page);
// Verify we're on the Configuration tab
await expect(
page.getByRole('tab', { name: /Configuration/ }),
).toHaveAttribute('data-state', 'active');
page.getByRole('region', { name: 'Configuration' }),
).toBeVisible();
// Switch to Monitoring tab (labeled "Dashboard" in the pipeline context)
// Skip Debug tab as it requires WebSocket connection
await page.getByRole('tab', { name: /Dashboard/ }).click();
await expect(page.getByRole('tab', { name: /Dashboard/ })).toHaveAttribute(
'data-state',
'active',
);
await page
.getByRole('button', { name: 'Dashboard', exact: true })
.last()
.click();
await expect(page.getByRole('region', { name: /Dashboard/ })).toBeVisible();
// Switch back to Configuration
await page.getByRole('tab', { name: /Configuration/ }).click();
await expect(page.locator('input[name="basic.name"]')).toBeVisible();
await page
.getByRole('button', { name: 'Dashboard', exact: true })
.last()
.click();
await expect(
page.getByRole('region', { name: 'Configuration' }),
).toBeVisible();
});
test('save button reflects form dirty state', async ({ page }) => {
@@ -512,20 +754,16 @@ test.describe('pipeline advanced flows', () => {
await page.locator('input[name="name"]').fill('Dirty Form Pipeline');
await submit(page);
// Wait for the page to fully load and form to reset
await page.waitForTimeout(500);
// Edit the form - use the name field which definitely triggers dirty state
await page
.locator('input[name="basic.name"]')
.fill('Dirty Form Pipeline Updated');
const saveButton = page.getByRole('button', { name: /^Save$/ });
await expect(saveButton).toBeEnabled();
// Save
await saveButton.click();
// Wait for save to complete
await page.waitForTimeout(500);
await expect(saveButton).toBeDisabled();
await page.getByRole('button', { name: 'Edit basic information' }).click();
const infoDialog = page.getByRole('dialog');
await infoDialog.getByLabel('Name').fill('Dirty Form Pipeline Updated');
await infoDialog.getByRole('button', { name: 'Save' }).click();
await expect(
page.getByRole('heading', { name: /Dirty Form Pipeline Updated/ }),
).toBeVisible();
await expect(saveButton).toBeDisabled();
});
test('shows validation error when pipeline name is empty', async ({
@@ -569,7 +807,8 @@ test.describe('agent runner resource selectors', () => {
});
await page.goto('/home/agents?id=agent-scope');
await page.getByRole('button', { name: /^Runner$/ }).click();
await page.getByRole('tab', { name: /^Runner$/ }).click();
await page.getByRole('tab', { name: 'Local Agent' }).click();
await page.getByRole('button', { name: 'Edit tools' }).click();
const dialog = page.getByRole('dialog');
@@ -603,7 +842,10 @@ test.describe('agent and pipeline save concurrency', () => {
test('agent save freezes its payload and keeps later edits dirty', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
await installLangBotApiMocks(page, {
authenticated: true,
withAdapterEvents: true,
});
const delayedSave = await installDelayedFirstSave(
page,
'/api/v1/agents/agent-save-race',
@@ -611,16 +853,27 @@ test.describe('agent and pipeline save concurrency', () => {
await page.goto('/home/agents?id=agent-save-race');
const saveButton = page.getByRole('button', { name: /^Save$/ });
const nameInput = page.locator('input[name="basic.name"]');
const descriptionInput = page.locator('input[name="basic.description"]');
await expect(nameInput).toBeVisible();
await page.getByRole('tab', { name: 'Bindable Event Range' }).click();
const eventPatterns = page.getByLabel('Event Range');
await expect(eventPatterns).toBeVisible();
await nameInput.fill('Submitted Agent');
await eventPatterns.click();
await page
.getByRole('option')
.filter({ hasText: 'message.received' })
.click();
await page.keyboard.press('Escape');
await saveButton.click();
await expect.poll(() => delayedSave.payloads.length).toBe(1);
await expect(saveButton).toBeDisabled();
await descriptionInput.fill('Edited while the agent save is pending');
await eventPatterns.click();
await page.getByRole('option').filter({ hasText: 'group.*' }).click();
await page
.getByRole('option')
.filter({ hasText: 'message.received' })
.click();
await page.keyboard.press('Escape');
await forceFormSubmit(page, '#agent-form');
expect(delayedSave.payloads).toHaveLength(1);
await expect(saveButton).toBeDisabled();
@@ -628,15 +881,13 @@ test.describe('agent and pipeline save concurrency', () => {
delayedSave.releaseFirstSave();
await expect(saveButton).toBeEnabled();
expect(delayedSave.payloads[0]).toMatchObject({
name: 'Submitted Agent',
description: '',
supported_event_patterns: ['message.received'],
});
await saveButton.click();
await expect.poll(() => delayedSave.payloads.length).toBe(2);
expect(delayedSave.payloads[1]).toMatchObject({
name: 'Submitted Agent',
description: 'Edited while the agent save is pending',
supported_event_patterns: ['group.*'],
});
await expect(saveButton).toBeDisabled();
});
@@ -651,39 +902,122 @@ test.describe('agent and pipeline save concurrency', () => {
);
await page.goto('/home/agents?id=pipeline-save-race');
const saveButton = page.getByRole('button', { name: /^Save$/ });
const nameInput = page.locator('input[name="basic.name"]');
const descriptionInput = page.locator('input[name="basic.description"]');
await expect(nameInput).toBeVisible();
await nameInput.fill('Submitted Pipeline');
await saveButton.click();
await page.getByRole('button', { name: 'Edit basic information' }).click();
let infoDialog = page.getByRole('dialog');
await infoDialog.getByLabel('Name').fill('Submitted Pipeline');
const dialogSaveButton = infoDialog.getByRole('button', { name: 'Save' });
await dialogSaveButton.click();
await expect.poll(() => delayedSave.payloads.length).toBe(1);
await expect(saveButton).toBeDisabled();
await descriptionInput.fill('Edited while the pipeline save is pending');
await forceFormSubmit(page, '#pipeline-form');
expect(delayedSave.payloads).toHaveLength(1);
await expect(saveButton).toBeDisabled();
await expect(
infoDialog.getByRole('button', { name: 'Saving...' }),
).toBeDisabled();
delayedSave.releaseFirstSave();
await expect(saveButton).toBeEnabled();
await expect(infoDialog).toHaveCount(0);
expect(delayedSave.payloads[0]).toMatchObject({
name: 'Submitted Pipeline',
description: '',
});
await saveButton.click();
await page.getByRole('button', { name: 'Edit basic information' }).click();
infoDialog = page.getByRole('dialog');
await infoDialog
.getByLabel('Description')
.fill('Edited in the next basic information save');
await infoDialog.getByRole('button', { name: 'Save' }).click();
await expect.poll(() => delayedSave.payloads.length).toBe(2);
expect(delayedSave.payloads[1]).toMatchObject({
name: 'Submitted Pipeline',
description: 'Edited while the pipeline save is pending',
description: 'Edited in the next basic information save',
});
await expect(saveButton).toBeDisabled();
await expect(infoDialog).toHaveCount(0);
});
});
test.describe('cross-resource flows', () => {
test('adds custom bot events and reorders routes with a drag preview', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
withAdapterEvents: true,
});
await page.goto('/home/bots?id=new');
await selectPlaywrightAdapter(page);
await page.locator('input[name="name"]').fill('Routing Bot');
await submit(page);
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
await page.getByRole('button', { name: 'Add behavior' }).click();
await expect(
page.getByText('Common scenarios', { exact: true }),
).toBeVisible();
await page.getByRole('menuitem', { name: /^Reply to messages/ }).click();
await page.getByRole('button', { name: 'Add behavior' }).click();
await page.getByRole('menuitem', { name: /^Welcome new members/ }).click();
const routeCards = page.locator('[data-testid^="event-route-"]');
await expect(routeCards).toHaveCount(2);
await expect(routeCards.nth(0)).toContainText('Message received');
await expect(routeCards.nth(1)).toContainText('Member joined group');
await page.getByRole('button', { name: 'Add behavior' }).click();
await page
.getByRole('menuitem', { name: /^Configure another event/ })
.hover();
const eventSubmenu = page.locator(
'[data-slot="dropdown-menu-sub-content"]',
);
await expect(
eventSubmenu.getByText('Messages', { exact: true }),
).toBeVisible();
await expect(
eventSubmenu.getByRole('menuitem', { name: /^Message edited/ }),
).toBeVisible();
await eventSubmenu
.getByRole('menuitem', { name: /^Message edited/ })
.click();
await expect(routeCards).toHaveCount(3);
await expect(routeCards.nth(2)).toContainText('Message edited');
const firstHandle = page.getByRole('button', { name: 'Drag route 1' });
const secondCard = routeCards.nth(1);
const handleBox = await firstHandle.boundingBox();
const targetBox = await secondCard.boundingBox();
expect(handleBox).not.toBeNull();
expect(targetBox).not.toBeNull();
await page.mouse.move(
handleBox!.x + handleBox!.width / 2,
handleBox!.y + handleBox!.height / 2,
);
await page.mouse.down();
await page.mouse.move(
handleBox!.x + handleBox!.width / 2,
handleBox!.y + handleBox!.height / 2 + 10,
{ steps: 4 },
);
await expect(page.locator('[data-drag-overlay="true"]')).toBeVisible();
await page.mouse.move(
targetBox!.x + targetBox!.width / 2,
targetBox!.y + targetBox!.height - 4,
{ steps: 12 },
);
await page.mouse.up();
await expect(page.locator('[data-drag-overlay="true"]')).toHaveCount(0);
await expect(routeCards.nth(0)).toContainText('Member joined group');
await expect(routeCards.nth(1)).toContainText('Message received');
await save(page);
await page.reload();
const savedRouteCards = page.locator('[data-testid^="event-route-"]');
await expect(savedRouteCards.nth(0)).toContainText('Member joined group');
await expect(savedRouteCards.nth(1)).toContainText('Message received');
await expect(savedRouteCards.nth(2)).toContainText('Message edited');
});
test('creates a pipeline then binds it to a bot', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true });
@@ -702,7 +1036,9 @@ test.describe('cross-resource flows', () => {
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
// Wait for form to fully load
await expect(page.locator('input[name="name"]')).toHaveValue('Bound Bot');
await expect(
page.getByRole('heading', { name: 'Bound Bot' }),
).toBeVisible();
await page.getByRole('button', { name: 'Add behavior' }).click();
await page.getByRole('menuitem', { name: /^Reply to messages/ }).click();
+65 -5
View File
@@ -106,6 +106,7 @@ interface LangBotApiMockState {
sessionAnalyses: Record<string, unknown>;
sessionMessages: Record<string, unknown[]>;
skills: SkillMock[];
withAdapterEvents: boolean;
withRunnerToolSelector: boolean;
workspaces: WorkspaceEntryMock[];
}
@@ -514,7 +515,7 @@ function makeBot(
};
}
function mockAdapters() {
function mockAdapters(withAdapterEvents = false) {
return [
{
name: 'playwright-adapter',
@@ -528,6 +529,17 @@ function mockAdapters() {
},
spec: {
categories: ['testing'],
...(withAdapterEvents
? {
supported_events: [
'message.received',
'message.edited',
'group.member_joined',
'group.member_left',
'feedback.received',
],
}
: {}),
config: [],
},
},
@@ -621,7 +633,9 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
}
if (path === '/api/v1/platform/adapters') {
return fulfillJson(route, { adapters: mockAdapters() });
return fulfillJson(route, {
adapters: mockAdapters(state.withAdapterEvents),
});
}
if (path === '/api/v1/platform/bots') {
@@ -647,7 +661,12 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
const botId = decodeURIComponent(botMatch[1]);
if (method === 'PUT') {
const bot = makeBot(state, parseJsonBody(route), botId);
const current = state.bots.find((item) => item.uuid === botId);
const bot = makeBot(
state,
{ ...(current || {}), ...parseJsonBody(route) },
botId,
);
state.bots = [...state.bots.filter((item) => item.uuid !== botId), bot];
return fulfillJson(route, {});
}
@@ -706,12 +725,35 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
return fulfillJson(route, { agents: state.pipelines });
}
const agentDebugMatch = path.match(/^\/api\/v1\/agents\/([^/]+)\/debug$/);
if (agentDebugMatch) {
const payload = parseJsonBody(route);
return fulfillJson(route, {
event_id: nextId(state, 'event'),
event_type: String(payload.event_type || 'message.received'),
conversation_id: String(payload.conversation_id || 'debug-session'),
final_text: 'Mock Agent response',
outputs: [
{
kind: 'message',
role: 'assistant',
text: 'Mock Agent response',
},
],
});
}
const agentMatch = path.match(/^\/api\/v1\/agents\/([^/]+)$/);
if (agentMatch) {
const agentId = decodeURIComponent(agentMatch[1]);
if (method === 'PUT') {
const agent = makePipeline(state, parseJsonBody(route), agentId);
const current = state.pipelines.find((item) => item.uuid === agentId);
const agent = makePipeline(
state,
{ ...(current || {}), ...parseJsonBody(route) },
agentId,
);
state.pipelines = [
...state.pipelines.filter((item) => item.uuid !== agentId),
agent,
@@ -756,12 +798,27 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
return fulfillJson(route, { pipelines: state.pipelines });
}
if (
/^\/api\/v1\/pipelines\/[^/]+\/ws\/messages\/(person|group)$/.test(path)
) {
return fulfillJson(route, { messages: [] });
}
if (/^\/api\/v1\/pipelines\/[^/]+\/ws\/reset\/(person|group)$/.test(path)) {
return fulfillJson(route, { message: 'reset' });
}
const pipelineMatch = path.match(/^\/api\/v1\/pipelines\/([^/]+)$/);
if (pipelineMatch) {
const pipelineId = decodeURIComponent(pipelineMatch[1]);
if (method === 'PUT') {
const pipeline = makePipeline(state, parseJsonBody(route), pipelineId);
const current = state.pipelines.find((item) => item.uuid === pipelineId);
const pipeline = makePipeline(
state,
{ ...(current || {}), ...parseJsonBody(route) },
pipelineId,
);
state.pipelines = [
...state.pipelines.filter((item) => item.uuid !== pipelineId),
pipeline,
@@ -1187,6 +1244,7 @@ export async function installLangBotApiMocks(
sessionAnalyses?: Record<string, unknown>;
sessionMessages?: Record<string, unknown[]>;
storage?: JsonRecord;
withAdapterEvents?: boolean;
withRunnerToolSelector?: boolean;
workspaces?: WorkspaceEntryMock[];
} = {},
@@ -1198,6 +1256,7 @@ export async function installLangBotApiMocks(
sessionAnalyses,
sessionMessages,
storage = {},
withAdapterEvents = false,
withRunnerToolSelector = false,
workspaces = [defaultWorkspaceEntry()],
} = options;
@@ -1213,6 +1272,7 @@ export async function installLangBotApiMocks(
sessionAnalyses: sessionAnalyses || {},
sessionMessages: sessionMessages || {},
skills: [],
withAdapterEvents,
withRunnerToolSelector,
workspaces,
};
@@ -0,0 +1,466 @@
import { expect, test } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
test.describe('processor detail workbench', () => {
test.use({ viewport: { width: 1440, height: 900 } });
test('agent keeps debugging left of its orchestration settings', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
withAdapterEvents: true,
withRunnerToolSelector: true,
});
await page.goto('/home/agents?id=agent-workbench');
const debugPanel = page.getByRole('region', { name: 'Debug' });
const configPanel = page.getByRole('region', { name: 'Configuration' });
await expect(debugPanel).toBeVisible();
await expect(configPanel).toBeVisible();
const debugBox = await debugPanel.boundingBox();
const configBox = await configPanel.boundingBox();
expect(debugBox).not.toBeNull();
expect(configBox).not.toBeNull();
expect(debugBox!.x).toBeLessThan(configBox!.x);
expect(configBox!.width).toBeGreaterThan(debugBox!.width);
const debugEventPicker = debugPanel.getByRole('combobox', {
name: 'Event type',
});
await expect(debugEventPicker).toContainText('message.received');
await debugEventPicker.click();
await expect(page.getByRole('group', { name: 'Messages' })).toBeVisible();
await expect(page.getByRole('group', { name: 'Groups' })).toBeVisible();
await expect(
page.getByRole('option').filter({ hasText: 'Member joined group' }),
).toContainText('A member joins a group where the bot is present.');
await expect(
page.getByRole('option').filter({ hasText: 'Member joined group' }),
).toContainText('group.member_joined');
await expect(
page.getByRole('option').filter({ hasText: 'Message edited' }),
).toContainText('The platform reports that an existing message changed.');
await expect(page.getByRole('option')).toHaveCount(6);
await page.keyboard.press('Escape');
const appShell = page.locator('[class*="group/sidebar-wrapper"]');
const sidebarInset = page.locator('[data-slot="sidebar-inset"]');
await expect(appShell).toHaveCSS('overflow', 'clip');
await expect(sidebarInset).toHaveCSS('overflow', 'clip');
await appShell.evaluate((element) => {
element.scrollTop = 300;
});
await sidebarInset.evaluate((element) => {
element.scrollTop = 300;
});
await expect
.poll(() => appShell.evaluate((element) => element.scrollTop))
.toBe(0);
await expect
.poll(() => sidebarInset.evaluate((element) => element.scrollTop))
.toBe(0);
expect(debugBox!.y).toBeGreaterThanOrEqual(0);
const flow = configPanel.getByRole('tablist');
await expect(flow.getByRole('tab').nth(0)).toContainText('Runner');
await expect(flow.getByRole('tab').nth(1)).toContainText('Local Agent');
await expect(flow.getByRole('tab').nth(2)).toContainText(
'Bindable Event Range',
);
await expect(flow.getByRole('tab')).toHaveCount(3);
await expect(flow.getByText('Management')).toHaveCount(0);
await page.setViewportSize({ width: 1024, height: 900 });
const tabListMetrics = await flow.evaluate((element) => ({
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
}));
expect(tabListMetrics.scrollWidth).toBeLessThanOrEqual(
tabListMetrics.clientWidth,
);
await expect(
page.getByRole('heading', { name: /agent-workbench/ }),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Edit basic information' }),
).toBeVisible();
const saveButton = page.getByRole('button', { name: 'Save' });
const deleteButton = page.getByRole('button', { name: 'Delete' });
await expect(saveButton).toBeVisible();
await expect(deleteButton).toBeVisible();
const saveBox = await saveButton.boundingBox();
const deleteBox = await deleteButton.boundingBox();
expect(saveBox).not.toBeNull();
expect(deleteBox).not.toBeNull();
expect(deleteBox!.x).toBeGreaterThan(saveBox!.x);
await expect(configPanel.getByLabel('Name')).toHaveCount(0);
await expect(configPanel.getByLabel('Icon')).toHaveCount(0);
await expect(configPanel.getByLabel('Description')).toHaveCount(0);
const runnerStatus = page.getByRole('status', { name: 'Runner ready' });
await expect(runnerStatus).toBeVisible();
await runnerStatus.hover();
await expect(
page
.getByText(
'Local Agent is registered and the plugin runtime is connected.',
)
.last(),
).toBeVisible();
await flow.getByRole('tab').nth(2).click();
await expect(
configPanel.getByText('Bindable Event Range', { exact: true }).last(),
).toBeVisible();
const eventPicker = configPanel.getByRole('combobox', {
name: 'Event Range',
});
await expect(eventPicker).toBeVisible();
await expect(configPanel.getByRole('textbox')).toHaveCount(0);
await eventPicker.click();
await expect(
page.getByRole('option').filter({ hasText: 'message.received' }),
).toBeVisible();
await expect(page.getByRole('group', { name: 'Messages' })).toHaveCount(1);
await expect(page.getByRole('group', { name: 'Groups' })).toHaveCount(1);
await page
.getByRole('option')
.filter({ hasText: 'message.*' })
.first()
.click();
await page.keyboard.press('Escape');
await debugEventPicker.click();
await expect(
page.getByRole('option').filter({ hasText: 'Message edited' }),
).toBeVisible();
await expect(
page.getByRole('option').filter({ hasText: 'Member joined group' }),
).toHaveCount(0);
await expect(page.getByRole('option')).toHaveCount(3);
await page.keyboard.press('Escape');
await eventPicker.click();
await page.getByRole('option').filter({ hasText: 'All events' }).click();
await page.keyboard.press('Escape');
await flow.getByRole('tab').nth(1).click();
await expect(
configPanel.getByText('Local Agent', { exact: true }).last(),
).toBeVisible();
await deleteButton.click();
const deleteDialog = page.getByRole('dialog');
await expect(deleteDialog).toContainText(
'Are you sure you want to delete this Agent?',
);
await deleteDialog.getByRole('button', { name: 'Cancel' }).click();
await expect(deleteDialog).toHaveCount(0);
});
test('agent saves edits before debugging and shows the real output', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
const requests: string[] = [];
page.on('request', (request) => {
const path = new URL(request.url()).pathname;
if (
request.method() === 'PUT' &&
path === '/api/v1/agents/agent-workbench'
) {
requests.push('save');
}
if (
request.method() === 'POST' &&
path === '/api/v1/agents/agent-workbench/debug'
) {
requests.push('debug');
}
});
await page.goto('/home/agents?id=agent-workbench');
await page.getByRole('button', { name: 'Edit basic information' }).click();
const basicInfoDialog = page.getByRole('dialog');
await expect(basicInfoDialog.getByLabel('Icon')).toBeVisible();
await basicInfoDialog
.getByLabel('Description')
.fill('Updated before debugging');
await basicInfoDialog.getByRole('button', { name: 'Save' }).click();
await expect(basicInfoDialog).toHaveCount(0);
await page
.getByRole('textbox', { name: 'Conversation input' })
.fill('Hello');
await page.getByRole('button', { name: 'Run test' }).click();
await expect(page.getByText('Mock Agent response')).toBeVisible();
expect(requests).toEqual(['save', 'debug']);
});
test('agent deletion is confirmed from the header and returns to the list', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.goto('/home/agents?id=agent-workbench');
const deleteRequest = page.waitForRequest(
(request) =>
request.method() === 'DELETE' &&
new URL(request.url()).pathname === '/api/v1/agents/agent-workbench',
);
await page.getByRole('button', { name: 'Delete' }).click();
await page
.getByRole('dialog')
.getByRole('button', { name: 'Confirm Delete' })
.click();
await deleteRequest;
await expect(page).toHaveURL(/\/home\/agents$/);
});
test('agent turns runner failures into an actionable message', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.route(
'**/api/v1/agents/agent-workbench/debug',
async (route) => {
await route.fulfill({
status: 422,
contentType: 'application/json',
body: JSON.stringify({
code: 'dify.config_invalid',
msg: 'api-key is required',
}),
});
},
);
await page.goto('/home/agents?id=agent-workbench');
await page
.getByRole('textbox', { name: 'Conversation input' })
.fill('Hello');
await page.getByRole('button', { name: 'Run test' }).click();
await expect(
page.getByText(
'The runner configuration is incomplete: API Key is missing',
),
).toBeVisible();
await expect(page.getByText('Internal server error')).toHaveCount(0);
await page
.getByRole('button', { name: 'Review runner configuration' })
.click();
await expect(
page.getByRole('tab', { name: 'Local Agent', exact: true }),
).toHaveAttribute('data-state', 'active');
});
test('pipeline keeps debug chat left and exposes its main flow first', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
const debugImageKey =
'v1/mock-instance/workspace-default/1/upload_image/mock-owner/debug-image.png';
const debugImageBytes = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9Zl1sAAAAASUVORK5CYII=',
'base64',
);
await page.route('**/api/v1/files/images', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
code: 0,
message: 'ok',
data: { file_key: debugImageKey },
timestamp: Date.now(),
}),
});
});
await page.route('**/api/v1/files/image/**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'image/png',
body: debugImageBytes,
});
});
await page.routeWebSocket('**/api/v1/pipelines/**/ws/connect**', (ws) => {
ws.onMessage((raw) => {
const message = JSON.parse(String(raw));
if (message.type === 'authenticate') {
ws.send(
JSON.stringify({
type: 'connected',
connection_id: 'playwright-connection',
pipeline_uuid: 'pipeline-workbench',
session_type: 'person',
}),
);
} else if (message.type === 'message') {
ws.send(
JSON.stringify({
type: 'user_message',
session_type: 'person',
data: {
id: 1,
role: 'user',
content: 'Describe this image',
message_chain: message.message,
timestamp: new Date().toISOString(),
is_final: true,
},
}),
);
}
});
});
await page.goto('/home/pipelines?id=pipeline-workbench');
const debugPanel = page.getByRole('region', { name: 'Debug Chat' });
const configPanel = page.getByRole('region', { name: 'Configuration' });
await expect(debugPanel).toBeVisible();
await expect(configPanel).toBeVisible();
await expect(
debugPanel.getByText('Connected', { exact: true }),
).toBeVisible();
await expect(debugPanel.getByText('WebSocket connected')).toHaveCount(0);
await expect(
debugPanel.getByRole('button', { name: 'Private Chat' }),
).toBeVisible();
await expect(
debugPanel.getByRole('button', { name: 'Group Chat' }),
).toBeVisible();
const sessionToolbar = debugPanel.locator(
'[data-debug-session-toolbar="true"]',
);
await expect(sessionToolbar.getByText('Session Type')).toBeVisible();
const privateChatButton = sessionToolbar.getByRole('button', {
name: 'Private Chat',
});
const groupChatButton = sessionToolbar.getByRole('button', {
name: 'Group Chat',
});
await expect(privateChatButton).toHaveAttribute('aria-pressed', 'true');
await expect(privateChatButton).toHaveClass(/bg-primary\/15/);
await expect(privateChatButton).toHaveClass(/text-primary/);
await expect(groupChatButton).not.toHaveClass(/bg-primary\/15/);
await groupChatButton.click();
await expect(groupChatButton).toHaveAttribute('aria-pressed', 'true');
await expect(groupChatButton).toHaveClass(/bg-primary\/15/);
await expect(groupChatButton).toHaveClass(/text-primary/);
await expect(privateChatButton).not.toHaveClass(/bg-primary\/15/);
await privateChatButton.click();
const composer = debugPanel.locator('[data-debug-composer="true"]');
const messageInput = composer.locator('textarea');
const sendButton = composer.getByRole('button', { name: 'Send' });
const resetButton = composer.getByRole('button', {
name: 'Reset Conversation',
});
await expect(messageInput).toBeVisible();
await expect(messageInput).toHaveAttribute('rows', '1');
await expect(resetButton).toBeVisible();
const inputBox = await messageInput.boundingBox();
const sendBox = await sendButton.boundingBox();
const toolbarBox = await sessionToolbar.boundingBox();
const emptyStateBox = await debugPanel
.getByText('No messages', { exact: true })
.boundingBox();
expect(inputBox).not.toBeNull();
expect(sendBox).not.toBeNull();
expect(toolbarBox).not.toBeNull();
expect(emptyStateBox).not.toBeNull();
expect(sendBox!.x).toBeGreaterThan(inputBox!.x + inputBox!.width);
expect(Math.abs(sendBox!.height - inputBox!.height)).toBeLessThanOrEqual(1);
expect(Math.abs(sendBox!.y - inputBox!.y)).toBeLessThanOrEqual(1);
expect(toolbarBox!.y).toBeLessThan(emptyStateBox!.y);
await composer.locator('input[type="file"]').setInputFiles({
name: 'debug-image.png',
mimeType: 'image/png',
buffer: debugImageBytes,
});
await expect(
debugPanel.locator('[data-debug-chat-attachment-preview="true"]'),
).toBeVisible();
await messageInput.fill('Describe this image');
await sendButton.click();
await expect(
debugPanel.locator('[data-debug-chat-message-image="true"]'),
).toBeVisible();
await expect(debugPanel.getByText('Describe this image')).toBeVisible();
const streamSwitchBox = await composer.getByRole('switch').boundingBox();
const resetBox = await resetButton.boundingBox();
expect(streamSwitchBox).not.toBeNull();
expect(resetBox).not.toBeNull();
expect(resetBox!.x).toBeGreaterThan(
streamSwitchBox!.x + streamSwitchBox!.width,
);
expect(
Math.abs(
resetBox!.y +
resetBox!.height / 2 -
(streamSwitchBox!.y + streamSwitchBox!.height / 2),
),
).toBeLessThanOrEqual(1);
await resetButton.click();
await expect(
page.getByText('Conversation reset successfully'),
).toBeVisible();
await expect(
page.getByRole('heading', { name: /pipeline-workbench/ }),
).toBeVisible();
await expect(configPanel.locator('input[name="basic.name"]')).toHaveCount(
0,
);
await page.getByRole('button', { name: 'Edit basic information' }).click();
const basicInfoDialog = page.getByRole('dialog');
await expect(basicInfoDialog.getByLabel('Icon')).toBeVisible();
await basicInfoDialog.getByLabel('Name').fill('Renamed Pipeline');
await basicInfoDialog
.getByLabel('Description')
.fill('Updated from the title dialog.');
await basicInfoDialog.getByRole('button', { name: 'Save' }).click();
await expect(
page.getByRole('heading', { name: /Renamed Pipeline/ }),
).toBeVisible();
const secondaryNavigation = configPanel.locator('nav').getByRole('button');
await expect(secondaryNavigation.last()).toHaveText('Management');
const debugBox = await debugPanel.boundingBox();
const configBox = await configPanel.boundingBox();
expect(debugBox).not.toBeNull();
expect(configBox).not.toBeNull();
expect(debugBox!.x).toBeLessThan(configBox!.x);
expect(configBox!.width).toBeGreaterThan(debugBox!.width);
const appShell = page.locator('[class*="group/sidebar-wrapper"]');
const sidebarInset = page.locator('[data-slot="sidebar-inset"]');
await expect(appShell).toHaveCSS('overflow', 'clip');
await expect(sidebarInset).toHaveCSS('overflow', 'clip');
await expect
.poll(() => appShell.evaluate((element) => element.scrollTop))
.toBe(0);
expect(debugBox!.y).toBeGreaterThanOrEqual(0);
const flow = configPanel.getByRole('tablist');
await expect(flow.getByRole('tab').nth(0)).toContainText('Trigger');
await expect(flow.getByRole('tab').nth(1)).toContainText('AI');
await expect(flow.getByRole('tab').nth(2)).toContainText('Output');
await flow.getByRole('tab').nth(1).click();
await expect(
configPanel.getByText('Runtime', { exact: true }).last(),
).toBeVisible();
});
});
@@ -0,0 +1,86 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const testDir = path.dirname(fileURLToPath(import.meta.url));
const webRoot = path.resolve(testDir, '../..');
function readSource(relativePath) {
return fs.readFileSync(path.join(webRoot, relativePath), 'utf8');
}
test('agent and pipeline details share the split processor workbench', () => {
const workbench = readSource(
'src/app/home/components/processor-detail/ProcessorDetailWorkbench.tsx',
);
const agentDetail = readSource('src/app/home/agents/AgentDetailContent.tsx');
const pipelineDetail = readSource(
'src/app/home/pipelines/PipelineDetailContent.tsx',
);
const websocketClient = readSource(
'src/app/infra/websocket/WebSocketClient.ts',
);
const pipelineDebug = readSource(
'src/app/home/pipelines/components/debug-dialog/DebugDialog.tsx',
);
assert.match(
workbench,
/lg:grid-cols-\[minmax\(20rem,0\.72fr\)_minmax\(0,1\.28fr\)\]/,
);
assert.ok(
workbench.indexOf('{debugContent}') < workbench.indexOf('{configContent}'),
);
assert.match(agentDetail, /<ProcessorDetailWorkbench/);
assert.match(agentDetail, /debugContent=/);
assert.match(pipelineDetail, /<ProcessorDetailWorkbench/);
assert.match(pipelineDetail, /compact=\{true\}/);
assert.doesNotMatch(
websocketClient,
/this\.ws\.onopen = \(\) => \{\s*this\.reconnectAttempts = 0/,
);
assert.match(
websocketClient,
/data\.type === 'connected'[\s\S]*this\.reconnectAttempts = 0/,
);
assert.match(websocketClient, /private reconnectTimeout:/);
assert.match(websocketClient, /private disconnectedByUser = false/);
assert.match(
websocketClient,
/if \(!this\.connectionId\)[\s\S]*this\.shouldReconnect = false/,
);
assert.match(
pipelineDebug,
/wsClientRef\.current = wsClient;[\s\S]*await wsClient\.connect\(\)/,
);
assert.match(
pipelineDebug,
/if \(wsClientRef\.current !== wsClient\) return;/,
);
assert.match(pipelineDebug, /data-slot="scroll-area-viewport"/);
assert.doesNotMatch(pipelineDebug, /scrollIntoView/);
});
test('processor forms expose their primary orchestration flow horizontally', () => {
const agentForm = readSource(
'src/app/home/agents/components/AgentFormComponent.tsx',
);
const pipelineForm = readSource(
'src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx',
);
assert.match(
agentForm,
/name: 'basic'[\s\S]*name: 'events'[\s\S]*name: 'runner'[\s\S]*name: 'runner_config'/,
);
assert.match(
pipelineForm,
/const primarySectionNames = \['trigger', 'ai', 'output'\]/,
);
assert.match(agentForm, /<TabsList[^>]*grid-cols-4/);
assert.match(pipelineForm, /<TabsList[^>]*grid-cols-3/);
assert.doesNotMatch(agentForm, /<ol className=/);
assert.doesNotMatch(pipelineForm, /<ol className=/);
});