mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-27 12:47:13 +00:00
Compare commits
46 Commits
6a6a2b865b
...
dev/4.11.x
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b888ad390 | |||
| 7d9bdf0562 | |||
| 5e144ac7c8 | |||
| 21ffaf9a14 | |||
| f3c1887e92 | |||
| 7671aefed7 | |||
| e6f6789e8b | |||
| 05fe93e108 | |||
| e20a73c83a | |||
| fde31d590e | |||
| af03dbe958 | |||
| bd7aeab53d | |||
| 91f484e0ce | |||
| a9b24e59ec | |||
| 139494cd63 | |||
| b4d9659ffe | |||
| 3f0ca13d6d | |||
| 6b40c2cf66 | |||
| 847bfc8df5 | |||
| 823feac7e0 | |||
| a7badf6258 | |||
| 3d692fa8db | |||
| 7990d36c78 | |||
| 600a173918 | |||
| 8b63cc0281 | |||
| 4787799cd8 | |||
| 47f5515fa9 | |||
| 94fd3d274c | |||
| 8380bfc7d4 | |||
| a936734efa | |||
| 0c5656d0d0 | |||
| 532d1b73d2 | |||
| aaeb9ad178 | |||
| d28b385a9f | |||
| 4d7a333802 | |||
| 114612a984 | |||
| 2aceefce47 | |||
| 80f1790e1d | |||
| 22d9053bf1 | |||
| db2a9155f8 | |||
| 69ca7e21cd | |||
| 792d961d28 | |||
| a8eb265c11 | |||
| e62f8a957a | |||
| ad6b8b3209 | |||
| 68620c4572 |
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -151,7 +151,6 @@ class QueryEntryAdapter:
|
||||
state_policy=state_policy,
|
||||
delivery_policy=delivery_policy,
|
||||
event_types=[event_type],
|
||||
enabled=True,
|
||||
metadata={'source': 'pipeline_adapter'},
|
||||
)
|
||||
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()))
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1069,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 = [
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
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';
|
||||
@@ -28,9 +43,18 @@ export default function AgentDetailContent({ id }: { id: string }) {
|
||||
const [loading, setLoading] = useState(!isCreateMode);
|
||||
const [formDirty, setFormDirty] = useState(false);
|
||||
const [formSaving, setFormSaving] = useState(false);
|
||||
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(() => {
|
||||
@@ -52,10 +76,25 @@ export default function AgentDetailContent({ id }: { id: string }) {
|
||||
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);
|
||||
@@ -88,58 +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 (
|
||||
<ProcessorDetailWorkbench
|
||||
key={id}
|
||||
title={`${agent.emoji || '🤖'} ${agent.name}`}
|
||||
status={runnerStatus}
|
||||
saveLabel={t('common.save')}
|
||||
saveFormId="agent-form"
|
||||
canSave={canManage}
|
||||
isDirty={formDirty}
|
||||
isSaving={formSaving}
|
||||
configTitle={t('pipelines.configuration')}
|
||||
configContent={
|
||||
<fieldset className="contents" disabled={!canManage}>
|
||||
<AgentFormComponent
|
||||
ref={agentFormRef}
|
||||
agentId={id}
|
||||
onFinish={(updatedAgent) => {
|
||||
if (updatedAgent) {
|
||||
setAgent((current) =>
|
||||
current ? { ...current, ...updatedAgent } : current,
|
||||
);
|
||||
<>
|
||||
<ProcessorDetailWorkbench
|
||||
key={id}
|
||||
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}
|
||||
hasUnsavedChanges={formDirty}
|
||||
beforeRun={async () => agentFormRef.current?.save() ?? false}
|
||||
onOpenRunnerConfig={() =>
|
||||
agentFormRef.current?.openSection('runner_config')
|
||||
}
|
||||
refreshPipelines();
|
||||
}}
|
||||
onDeleted={() => {
|
||||
refreshPipelines();
|
||||
navigate('/home/agents');
|
||||
}}
|
||||
onDirtyChange={setFormDirty}
|
||||
onSavingChange={setFormSaving}
|
||||
onRunnerStatusChange={setRunnerStatus}
|
||||
/>
|
||||
</fieldset>
|
||||
}
|
||||
debugTitle={canOperate ? t('agents.debugTab') : undefined}
|
||||
debugContent={
|
||||
canOperate ? (
|
||||
<AgentDebugPanel
|
||||
agentId={id}
|
||||
hasUnsavedChanges={formDirty}
|
||||
beforeRun={async () => agentFormRef.current?.save() ?? false}
|
||||
onOpenRunnerConfig={() =>
|
||||
agentFormRef.current?.openSection('runner_config')
|
||||
}
|
||||
supportedEventPatterns={
|
||||
agent.supported_event_patterns ??
|
||||
agent.capability?.supported_event_patterns ?? ['*']
|
||||
}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
unsavedLabel={t('pipelines.unsavedChanges')}
|
||||
/>
|
||||
supportedEventPatterns={supportedEventPatterns}
|
||||
availableEventTypes={availableEventTypes}
|
||||
/>
|
||||
) : 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import type React from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
@@ -8,8 +7,8 @@ import { toast } from 'sonner';
|
||||
import { Bot, Workflow } from 'lucide-react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { AgentKind } from '@/app/infra/entities/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -27,6 +26,7 @@ import {
|
||||
} from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import EmojiPicker from '@/components/ui/emoji-picker';
|
||||
import ProcessorTypeDiagram from './ProcessorTypeDiagram';
|
||||
|
||||
export default function AgentCreateContent({
|
||||
onCreated,
|
||||
@@ -77,26 +77,18 @@ export default function AgentCreateContent({
|
||||
});
|
||||
}
|
||||
|
||||
const typeOptions: Array<{
|
||||
kind: AgentKind;
|
||||
icon: React.ElementType;
|
||||
title: string;
|
||||
description: string;
|
||||
badge: string;
|
||||
}> = [
|
||||
const typeOptions = [
|
||||
{
|
||||
kind: 'agent',
|
||||
kind: 'agent' as const,
|
||||
icon: Bot,
|
||||
title: t('agents.agentType'),
|
||||
description: t('agents.agentTypeDescription'),
|
||||
badge: t('agents.allEvents'),
|
||||
},
|
||||
{
|
||||
kind: 'pipeline',
|
||||
kind: 'pipeline' as const,
|
||||
icon: Workflow,
|
||||
title: t('agents.pipelineType'),
|
||||
description: t('agents.pipelineTypeDescription'),
|
||||
badge: t('agents.messageEventsOnly'),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -110,106 +102,136 @@ export default function AgentCreateContent({
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<div className="mx-auto max-w-2xl space-y-6">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{typeOptions.map((option) => {
|
||||
const Icon = option.icon;
|
||||
const selected = kind === option.kind;
|
||||
return (
|
||||
<button
|
||||
key={option.kind}
|
||||
type="button"
|
||||
onClick={() => handleKindChange(option.kind)}
|
||||
className={cn(
|
||||
'rounded-lg border bg-card p-4 text-left transition-colors',
|
||||
selected
|
||||
? 'border-primary ring-2 ring-primary/20'
|
||||
: 'hover:border-primary/60',
|
||||
)}
|
||||
<div className="mx-auto max-w-6xl pb-6">
|
||||
<div className="grid items-stretch gap-5 lg:grid-cols-[minmax(340px,0.78fr)_minmax(0,1.22fr)]">
|
||||
<div className="space-y-5">
|
||||
<section
|
||||
aria-labelledby="processor-kind-heading"
|
||||
className="space-y-3"
|
||||
>
|
||||
<div>
|
||||
<h2
|
||||
id="processor-kind-heading"
|
||||
className="text-base font-semibold"
|
||||
>
|
||||
{t('agents.chooseType')}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t('agents.chooseTypeDescription')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={kind}
|
||||
onValueChange={(value) => {
|
||||
if (value) handleKindChange(value as AgentKind);
|
||||
}}
|
||||
variant="outline"
|
||||
spacing={3}
|
||||
className="grid w-full gap-3 sm:grid-cols-2 lg:grid-cols-1"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Icon className="mt-0.5 size-5 text-blue-500" />
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">{option.title}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{option.badge}
|
||||
{typeOptions.map((option) => {
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<ToggleGroupItem
|
||||
key={option.kind}
|
||||
value={option.kind}
|
||||
data-processor-kind={option.kind}
|
||||
aria-label={`${option.title} ${option.description}`}
|
||||
className="h-auto min-h-28 w-full items-start justify-start gap-3 rounded-lg border px-4 py-4 text-left whitespace-normal shadow-none hover:bg-muted/40 data-[state=on]:border-[#2288ee]/50 data-[state=on]:bg-blue-50/60 data-[state=on]:text-foreground data-[state=on]:shadow-none dark:data-[state=on]:border-blue-500/50 dark:data-[state=on]:bg-blue-500/10"
|
||||
>
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-md border bg-background text-[#2288ee] shadow-xs">
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
<span className="min-w-0 space-y-1.5">
|
||||
<span className="block text-sm font-medium text-foreground">
|
||||
{option.title}
|
||||
</span>
|
||||
<span className="block text-sm font-normal leading-relaxed text-muted-foreground">
|
||||
{option.description}
|
||||
</span>
|
||||
</span>
|
||||
</ToggleGroupItem>
|
||||
);
|
||||
})}
|
||||
</ToggleGroup>
|
||||
</section>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('agents.basicInfo')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('agents.basicInfoDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="agent-create-form"
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="flex gap-4 items-start">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="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="emoji"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('common.icon')}</FormLabel>
|
||||
<FormControl>
|
||||
<EmojiPicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{option.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('common.description')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value ?? ''} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="min-h-[600px] overflow-hidden py-0 dark:border-white/16 lg:min-h-[680px]">
|
||||
<CardContent className="flex h-full items-center p-0">
|
||||
<ProcessorTypeDiagram kind={kind} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('agents.basicInfo')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('agents.basicInfoDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="agent-create-form"
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="flex gap-4 items-start">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="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="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="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('common.description')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value ?? ''} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,9 @@ import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
@@ -28,9 +30,17 @@ import {
|
||||
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;
|
||||
@@ -46,16 +56,15 @@ interface DebugEntry {
|
||||
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',
|
||||
@@ -63,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',
|
||||
@@ -73,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',
|
||||
@@ -83,22 +88,14 @@ 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());
|
||||
@@ -112,6 +109,7 @@ function matchesEventPattern(pattern: string, eventType: string) {
|
||||
|
||||
export default function AgentDebugPanel({
|
||||
agentId,
|
||||
availableEventTypes,
|
||||
supportedEventPatterns = ['*'],
|
||||
beforeRun,
|
||||
hasUnsavedChanges = false,
|
||||
@@ -132,27 +130,39 @@ export default function AgentDebugPanel({
|
||||
() => supportedEventPatterns.join(', '),
|
||||
[supportedEventPatterns],
|
||||
);
|
||||
const availablePresets = useMemo(
|
||||
() =>
|
||||
EVENT_PRESETS.filter(
|
||||
(item) =>
|
||||
item.value === 'custom' ||
|
||||
supportedEventPatterns.some((pattern) =>
|
||||
matchesEventPattern(pattern, item.value),
|
||||
),
|
||||
),
|
||||
[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 (availablePresets.some((item) => item.value === preset)) return;
|
||||
selectPreset(availablePresets[0]?.value ?? 'custom');
|
||||
}, [availablePresets, preset]);
|
||||
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));
|
||||
}
|
||||
@@ -270,49 +280,6 @@ export default function AgentDebugPanel({
|
||||
|
||||
return (
|
||||
<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">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availablePresets.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{t(item.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={resetSession}
|
||||
title={t('agents.debugResetSession')}
|
||||
>
|
||||
<RotateCcw className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{preset === 'custom' && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="agent-debug-custom-event">
|
||||
{t('agents.debugCustomEventType')}
|
||||
</Label>
|
||||
<Input
|
||||
id="agent-debug-custom-event"
|
||||
value={customEventType}
|
||||
onChange={(event) => setCustomEventType(event.target.value)}
|
||||
placeholder="custom.event"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
<div className="mb-3">
|
||||
<p className="text-sm font-medium">{t('agents.debugTranscript')}</p>
|
||||
@@ -394,6 +361,80 @@ export default function AgentDebugPanel({
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 space-y-3 border-t 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>
|
||||
</SelectGroup>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={resetSession}
|
||||
title={t('agents.debugResetSession')}
|
||||
>
|
||||
<RotateCcw className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{preset === 'custom' && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="agent-debug-custom-event">
|
||||
{t('agents.debugCustomEventType')}
|
||||
</Label>
|
||||
<Input
|
||||
id="agent-debug-custom-event"
|
||||
value={customEventType}
|
||||
onChange={(event) => setCustomEventType(event.target.value)}
|
||||
placeholder="custom.event"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="agent-debug-input">
|
||||
{isMessageEvent
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import { Bot, Info, Power, SlidersHorizontal, Trash2, Zap } 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,12 +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 { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
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 {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -35,22 +30,14 @@ 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 AgentEventPatternPicker from './AgentEventPatternPicker';
|
||||
|
||||
export interface AgentRunnerStatus {
|
||||
label: string;
|
||||
@@ -60,19 +47,24 @@ export interface AgentRunnerStatus {
|
||||
|
||||
interface AgentFormComponentProps {
|
||||
agentId: string;
|
||||
availableEventTypes: string[];
|
||||
onFinish: (agent?: Partial<Agent>) => void;
|
||||
onDeleted: () => void;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
onSavingChange?: (saving: boolean) => void;
|
||||
onRunnerStatusChange?: (status: AgentRunnerStatus) => void;
|
||||
onSupportedEventPatternsChange?: (patterns: string[]) => void;
|
||||
}
|
||||
|
||||
export type AgentConfigSection =
|
||||
'events' | 'runner' | 'runner_config' | 'basic';
|
||||
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 {
|
||||
@@ -108,11 +100,12 @@ function isRunnerFieldVisible(
|
||||
function AgentFormComponent(
|
||||
{
|
||||
agentId,
|
||||
availableEventTypes,
|
||||
onFinish,
|
||||
onDeleted,
|
||||
onDirtyChange,
|
||||
onSavingChange,
|
||||
onRunnerStatusChange,
|
||||
onSupportedEventPatternsChange,
|
||||
}: AgentFormComponentProps,
|
||||
ref: ForwardedRef<AgentFormHandle>,
|
||||
) {
|
||||
@@ -123,10 +116,8 @@ 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>('basic');
|
||||
useState<AgentConfigSection>('runner');
|
||||
const isSavingRef = useRef(false);
|
||||
const hasUnsavedChangesRef = useRef(false);
|
||||
|
||||
@@ -135,11 +126,10 @@ function AgentFormComponent(
|
||||
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>;
|
||||
|
||||
@@ -150,11 +140,10 @@ function AgentFormComponent(
|
||||
name: '',
|
||||
description: '',
|
||||
emoji: '🤖',
|
||||
enabled: true,
|
||||
},
|
||||
runner: {},
|
||||
runner_config: {},
|
||||
supported_event_patterns_text: '*',
|
||||
supported_event_patterns: ['*'],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -171,6 +160,11 @@ function AgentFormComponent(
|
||||
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)])
|
||||
@@ -184,15 +178,12 @@ 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);
|
||||
@@ -264,16 +255,6 @@ function AgentFormComponent(
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
}> = [
|
||||
{
|
||||
name: 'basic',
|
||||
label: t('agents.basicInfo'),
|
||||
icon: Info,
|
||||
},
|
||||
{
|
||||
name: 'events',
|
||||
label: t('agents.bindableEvents'),
|
||||
icon: Zap,
|
||||
},
|
||||
{
|
||||
name: 'runner',
|
||||
label: t('agents.runnerSettings'),
|
||||
@@ -286,6 +267,11 @@ function AgentFormComponent(
|
||||
: t('pipelines.configuration'),
|
||||
icon: SlidersHorizontal,
|
||||
},
|
||||
{
|
||||
name: 'events',
|
||||
label: t('agents.bindableEvents'),
|
||||
icon: Zap,
|
||||
},
|
||||
];
|
||||
|
||||
const runnerStatus = useMemo<AgentRunnerStatus>(() => {
|
||||
@@ -442,14 +428,6 @@ 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;
|
||||
@@ -459,11 +437,8 @@ function AgentFormComponent(
|
||||
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,
|
||||
),
|
||||
supported_event_patterns: values.supported_event_patterns,
|
||||
config: {
|
||||
runner,
|
||||
runner_config: values.runner_config ?? {},
|
||||
@@ -471,7 +446,6 @@ function AgentFormComponent(
|
||||
};
|
||||
|
||||
isSavingRef.current = true;
|
||||
setIsSaving(true);
|
||||
onSavingChange?.(true);
|
||||
try {
|
||||
await httpClient.updateAgent(agentId, agent);
|
||||
@@ -488,7 +462,6 @@ function AgentFormComponent(
|
||||
return false;
|
||||
} finally {
|
||||
isSavingRef.current = false;
|
||||
setIsSaving(false);
|
||||
onSavingChange?.(false);
|
||||
}
|
||||
},
|
||||
@@ -503,6 +476,24 @@ function AgentFormComponent(
|
||||
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);
|
||||
}
|
||||
},
|
||||
async save() {
|
||||
if (!hasUnsavedChangesRef.current) return true;
|
||||
if (isSavingRef.current) return false;
|
||||
@@ -514,268 +505,111 @@ function AgentFormComponent(
|
||||
[form, saveValues],
|
||||
);
|
||||
|
||||
function confirmDelete() {
|
||||
httpClient
|
||||
.deleteAgent(agentId)
|
||||
.then(() => {
|
||||
toast.success(t('agents.deleteSuccess'));
|
||||
setShowDeleteConfirm(false);
|
||||
onDeleted();
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(t('agents.deleteError') + err.msg);
|
||||
});
|
||||
}
|
||||
|
||||
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"
|
||||
>
|
||||
<nav className="mb-4 shrink-0 space-y-2 border-b pb-4">
|
||||
<Tabs
|
||||
value={activeSection}
|
||||
onValueChange={(value) =>
|
||||
setActiveSection(value as AgentConfigSection)
|
||||
}
|
||||
>
|
||||
<div className="overflow-x-auto">
|
||||
<TabsList className="grid min-w-[44rem] w-full grid-cols-4">
|
||||
{primarySections.map((section) => {
|
||||
const Icon = section.icon;
|
||||
return (
|
||||
<TabsTrigger key={section.name} value={section.name}>
|
||||
<Icon />
|
||||
{section.label}
|
||||
</TabsTrigger>
|
||||
);
|
||||
})}
|
||||
</TabsList>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<CardDescription>
|
||||
{t('agents.noRunnerMetadata')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</Tabs>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
<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>
|
||||
<CardDescription>
|
||||
{t('agents.noRunnerMetadata')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === 'runner_config' && (
|
||||
<div className="space-y-6">
|
||||
{activeRunnerStage ? (
|
||||
renderDynamicStage(activeRunnerStage)
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('agents.runnerSettings')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('agents.noRunnerMetadata')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === 'events' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('agents.bindableEvents')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('agents.bindableEventsDescription')}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{activeSection === 'basic' && (
|
||||
<div className="space-y-6">
|
||||
{activeSection === 'runner_config' && (
|
||||
<div className="space-y-6">
|
||||
{activeRunnerStage ? (
|
||||
renderDynamicStage(activeRunnerStage)
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('agents.basicInfo')}</CardTitle>
|
||||
<CardTitle>{t('agents.runnerSettings')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('agents.basicInfoDescription')}
|
||||
{t('agents.noRunnerMetadata')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<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}
|
||||
ariaLabel={t('common.icon')}
|
||||
/>
|
||||
</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.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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card className="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 gap-4">
|
||||
<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="mr-1.5 size-4" />
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{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>
|
||||
</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>
|
||||
</>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
import { useId } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { AgentKind } from '@/app/infra/entities/api';
|
||||
|
||||
const LANGBOT_BLUE = '#2288ee';
|
||||
const LANGBOT_CYAN = '#19b8c9';
|
||||
|
||||
function DiagramMotionStyles() {
|
||||
return (
|
||||
<style>{`
|
||||
@keyframes processor-line-flow {
|
||||
to { stroke-dashoffset: -30; }
|
||||
}
|
||||
|
||||
@keyframes processor-link-breathe {
|
||||
0%, 100% { stroke-opacity: var(--processor-relation-opacity-min); }
|
||||
50% { stroke-opacity: var(--processor-relation-opacity-max); }
|
||||
}
|
||||
|
||||
.processor-diagram {
|
||||
--processor-blue-node-fill: color-mix(in srgb, ${LANGBOT_BLUE} 7%, var(--card));
|
||||
--processor-cyan-node-fill: color-mix(in srgb, ${LANGBOT_CYAN} 7%, var(--card));
|
||||
--processor-node-stroke-opacity: 0.18;
|
||||
--processor-flow-opacity: 0.34;
|
||||
--processor-relation-opacity-min: 0.28;
|
||||
--processor-relation-opacity-max: 0.72;
|
||||
--processor-capability-fill: color-mix(in srgb, ${LANGBOT_CYAN} 4%, var(--card));
|
||||
--processor-capability-stroke-opacity: 0.45;
|
||||
--processor-icon-fill: color-mix(in srgb, ${LANGBOT_CYAN} 10%, var(--card));
|
||||
}
|
||||
|
||||
.dark .processor-diagram {
|
||||
--processor-blue-node-fill: color-mix(in srgb, ${LANGBOT_BLUE} 17%, var(--card));
|
||||
--processor-cyan-node-fill: color-mix(in srgb, ${LANGBOT_CYAN} 15%, var(--card));
|
||||
--processor-node-stroke-opacity: 0.5;
|
||||
--processor-flow-opacity: 0.72;
|
||||
--processor-relation-opacity-min: 0.58;
|
||||
--processor-relation-opacity-max: 1;
|
||||
--processor-capability-fill: color-mix(in srgb, ${LANGBOT_CYAN} 10%, var(--card));
|
||||
--processor-capability-stroke-opacity: 0.78;
|
||||
--processor-icon-fill: color-mix(in srgb, ${LANGBOT_CYAN} 20%, var(--card));
|
||||
}
|
||||
|
||||
.processor-line-flow {
|
||||
animation: processor-line-flow 1.8s linear infinite;
|
||||
}
|
||||
|
||||
.processor-link-breathe {
|
||||
animation: processor-link-breathe 2.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.processor-line-flow,
|
||||
.processor-link-breathe {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
);
|
||||
}
|
||||
|
||||
function ArrowMarker({ id }: { id: string }) {
|
||||
return (
|
||||
<defs>
|
||||
<marker
|
||||
id={id}
|
||||
markerWidth="8"
|
||||
markerHeight="8"
|
||||
refX="7"
|
||||
refY="4"
|
||||
orient="auto"
|
||||
>
|
||||
<path d="M0 0 8 4 0 8Z" fill={LANGBOT_BLUE} />
|
||||
</marker>
|
||||
</defs>
|
||||
);
|
||||
}
|
||||
|
||||
function CapabilityIcon({
|
||||
kind,
|
||||
x,
|
||||
y,
|
||||
}: {
|
||||
kind: 'model' | 'tool' | 'action';
|
||||
x: number;
|
||||
y: number;
|
||||
}) {
|
||||
return (
|
||||
<g
|
||||
transform={`translate(${x - 9} ${y - 9})`}
|
||||
fill="none"
|
||||
stroke={LANGBOT_CYAN}
|
||||
strokeWidth="1.7"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{kind === 'model' && (
|
||||
<>
|
||||
<path d="m7 1 1.4 4.1L12.5 6.5 8.4 8 7 12 5.6 8 1.5 6.5l4.1-1.4L7 1Z" />
|
||||
<path d="m14.5 10 .7 2.3 2.3.7-2.3.8-.7 2.2-.8-2.2-2.2-.8 2.2-.7.8-2.3Z" />
|
||||
</>
|
||||
)}
|
||||
{kind === 'tool' && (
|
||||
<path d="M12.8 2.1a4.1 4.1 0 0 0-5.2 5.2L2 12.9a2.1 2.1 0 1 0 3 3l5.6-5.6a4.1 4.1 0 0 0 5.2-5.2l-2.7 2.7-2.8-2.8 2.5-2.9Z" />
|
||||
)}
|
||||
{kind === 'action' && (
|
||||
<path d="M10.5 1 3 10.5h6L8 17l7.5-9.5h-6L10.5 1Z" />
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentDiagram() {
|
||||
const { t } = useTranslation();
|
||||
const arrowId = `agent-arrow-${useId().replace(/:/g, '')}`;
|
||||
const inputs = [
|
||||
{ label: t('agents.diagramMessages'), y: 155 },
|
||||
{ label: t('agents.diagramMembers'), y: 275 },
|
||||
{ label: t('agents.diagramFeedback'), y: 395 },
|
||||
];
|
||||
const outputs = [
|
||||
{ kind: 'model' as const, label: t('agents.diagramModel'), y: 155 },
|
||||
{ kind: 'tool' as const, label: t('agents.diagramTools'), y: 275 },
|
||||
{ kind: 'action' as const, label: t('agents.diagramActions'), y: 395 },
|
||||
];
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 760 620"
|
||||
role="img"
|
||||
aria-label={t('agents.agentDiagramTitle')}
|
||||
className="processor-diagram h-full w-full"
|
||||
data-testid="agent-diagram"
|
||||
>
|
||||
<desc>{t('agents.agentDiagramDescription')}</desc>
|
||||
<ArrowMarker id={arrowId} />
|
||||
<DiagramMotionStyles />
|
||||
|
||||
<rect width="760" height="620" fill="var(--card)" />
|
||||
|
||||
<text
|
||||
x="74"
|
||||
y="112"
|
||||
fill="var(--muted-foreground)"
|
||||
fontSize="13"
|
||||
fontWeight="600"
|
||||
>
|
||||
{t('agents.diagramEvents')}
|
||||
</text>
|
||||
|
||||
{inputs.map((item, index) => (
|
||||
<g key={item.label}>
|
||||
<path
|
||||
d={`M222 ${item.y + 29} C286 ${item.y + 29} 292 310 324 310`}
|
||||
fill="none"
|
||||
stroke={LANGBOT_BLUE}
|
||||
strokeWidth="2"
|
||||
strokeDasharray="4 11"
|
||||
opacity="var(--processor-flow-opacity)"
|
||||
markerEnd={`url(#${arrowId})`}
|
||||
className="processor-line-flow"
|
||||
data-motion="flow"
|
||||
style={{ animationDelay: `${index * -0.35}s` }}
|
||||
/>
|
||||
<rect
|
||||
x="64"
|
||||
y={item.y}
|
||||
width="158"
|
||||
height="58"
|
||||
rx="14"
|
||||
fill="var(--processor-blue-node-fill)"
|
||||
stroke={LANGBOT_BLUE}
|
||||
strokeOpacity="var(--processor-node-stroke-opacity)"
|
||||
/>
|
||||
<circle
|
||||
cx="91"
|
||||
cy={item.y + 29}
|
||||
r="5"
|
||||
fill={index === 1 ? LANGBOT_CYAN : LANGBOT_BLUE}
|
||||
/>
|
||||
<text
|
||||
x="110"
|
||||
y={item.y + 34}
|
||||
fill="var(--foreground)"
|
||||
fontSize="14"
|
||||
fontWeight="550"
|
||||
>
|
||||
{item.label}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
<rect
|
||||
x="324"
|
||||
y="278"
|
||||
width="112"
|
||||
height="64"
|
||||
rx="16"
|
||||
fill={LANGBOT_BLUE}
|
||||
/>
|
||||
<text
|
||||
x="380"
|
||||
y="317"
|
||||
textAnchor="middle"
|
||||
fill="var(--primary-foreground)"
|
||||
fontSize="18"
|
||||
fontWeight="700"
|
||||
>
|
||||
{t('agents.agentType')}
|
||||
</text>
|
||||
|
||||
<text
|
||||
x="538"
|
||||
y="112"
|
||||
fill="var(--muted-foreground)"
|
||||
fontSize="13"
|
||||
fontWeight="600"
|
||||
>
|
||||
{t('agents.diagramAgentCanUse')}
|
||||
</text>
|
||||
|
||||
{outputs.map((item, index) => (
|
||||
<g key={item.label}>
|
||||
<path
|
||||
d={`M436 310 C468 310 474 ${item.y + 29} 538 ${item.y + 29}`}
|
||||
fill="none"
|
||||
stroke={LANGBOT_CYAN}
|
||||
strokeWidth="1.8"
|
||||
strokeDasharray="5 6"
|
||||
className="processor-link-breathe"
|
||||
data-motion="relation"
|
||||
style={{ animationDelay: `${index * -0.45}s` }}
|
||||
/>
|
||||
<rect
|
||||
x="538"
|
||||
y={item.y}
|
||||
width="158"
|
||||
height="58"
|
||||
rx="14"
|
||||
fill="var(--processor-capability-fill)"
|
||||
stroke={LANGBOT_CYAN}
|
||||
strokeOpacity="var(--processor-capability-stroke-opacity)"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<circle
|
||||
cx="565"
|
||||
cy={item.y + 29}
|
||||
r="11"
|
||||
fill="var(--processor-icon-fill)"
|
||||
stroke={LANGBOT_CYAN}
|
||||
strokeOpacity="var(--processor-capability-stroke-opacity)"
|
||||
/>
|
||||
<CapabilityIcon kind={item.kind} x={565} y={item.y + 29} />
|
||||
<text
|
||||
x="584"
|
||||
y={item.y + 34}
|
||||
fill="var(--foreground)"
|
||||
fontSize="14"
|
||||
fontWeight="550"
|
||||
>
|
||||
{item.label}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function PipelineDiagram() {
|
||||
const { t } = useTranslation();
|
||||
const arrowId = `pipeline-arrow-${useId().replace(/:/g, '')}`;
|
||||
const steps = [
|
||||
t('agents.diagramMessage'),
|
||||
t('agents.diagramPreprocess'),
|
||||
t('agents.diagramAI'),
|
||||
t('agents.diagramPostprocess'),
|
||||
t('agents.diagramOutput'),
|
||||
];
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 760 620"
|
||||
role="img"
|
||||
aria-label={t('agents.pipelineDiagramTitle')}
|
||||
className="processor-diagram h-full w-full"
|
||||
data-testid="pipeline-diagram"
|
||||
>
|
||||
<desc>{t('agents.pipelineDiagramDescription')}</desc>
|
||||
<ArrowMarker id={arrowId} />
|
||||
<DiagramMotionStyles />
|
||||
|
||||
<rect width="760" height="620" fill="var(--card)" />
|
||||
<text
|
||||
x="40"
|
||||
y="238"
|
||||
fill="var(--muted-foreground)"
|
||||
fontSize="13"
|
||||
fontWeight="600"
|
||||
>
|
||||
{t('agents.pipelineDiagramFlow')}
|
||||
</text>
|
||||
<path
|
||||
d="M156 310H738"
|
||||
fill="none"
|
||||
stroke={LANGBOT_BLUE}
|
||||
strokeWidth="2"
|
||||
opacity="var(--processor-flow-opacity)"
|
||||
markerEnd={`url(#${arrowId})`}
|
||||
/>
|
||||
<path
|
||||
d="M156 310H724"
|
||||
fill="none"
|
||||
stroke={LANGBOT_BLUE}
|
||||
strokeWidth="3"
|
||||
strokeDasharray="4 11"
|
||||
strokeLinecap="round"
|
||||
className="processor-line-flow"
|
||||
data-motion="flow"
|
||||
data-dash-cycle="15"
|
||||
/>
|
||||
|
||||
{steps.map((step, index) => {
|
||||
const x = 40 + index * 142;
|
||||
const active = index === 2;
|
||||
return (
|
||||
<g key={step}>
|
||||
<rect
|
||||
x={x}
|
||||
y="281"
|
||||
width="116"
|
||||
height="58"
|
||||
rx="14"
|
||||
fill={
|
||||
active
|
||||
? LANGBOT_BLUE
|
||||
: index === 3
|
||||
? 'var(--processor-cyan-node-fill)'
|
||||
: 'var(--processor-blue-node-fill)'
|
||||
}
|
||||
stroke={
|
||||
active
|
||||
? LANGBOT_BLUE
|
||||
: index === 3
|
||||
? LANGBOT_CYAN
|
||||
: LANGBOT_BLUE
|
||||
}
|
||||
strokeOpacity={
|
||||
active ? 1 : 'var(--processor-node-stroke-opacity)'
|
||||
}
|
||||
/>
|
||||
{!active && (
|
||||
<circle
|
||||
cx={x + 25}
|
||||
cy="310"
|
||||
r="5"
|
||||
fill={index === 3 ? LANGBOT_CYAN : LANGBOT_BLUE}
|
||||
/>
|
||||
)}
|
||||
<text
|
||||
x={active ? x + 58 : x + 44}
|
||||
y="315"
|
||||
textAnchor={active ? 'middle' : 'start'}
|
||||
fill={active ? 'var(--primary-foreground)' : 'var(--foreground)'}
|
||||
fontSize="13"
|
||||
fontWeight="600"
|
||||
>
|
||||
{step}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProcessorTypeDiagram({ kind }: { kind: AgentKind }) {
|
||||
return kind === 'agent' ? <AgentDiagram /> : <PipelineDiagram />;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -7,10 +7,12 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ProcessorMonitoringView {
|
||||
label: string;
|
||||
workbenchLabel: string;
|
||||
content: ReactNode;
|
||||
}
|
||||
|
||||
@@ -22,6 +24,8 @@ export interface ProcessorDetailStatus {
|
||||
|
||||
interface ProcessorDetailWorkbenchProps {
|
||||
title: string;
|
||||
titleAction?: ReactNode;
|
||||
headerActions?: ReactNode;
|
||||
status?: ProcessorDetailStatus | null;
|
||||
saveLabel: string;
|
||||
saveFormId: string;
|
||||
@@ -41,6 +45,8 @@ interface ProcessorDetailWorkbenchProps {
|
||||
|
||||
export default function ProcessorDetailWorkbench({
|
||||
title,
|
||||
titleAction,
|
||||
headerActions,
|
||||
status,
|
||||
saveLabel,
|
||||
saveFormId,
|
||||
@@ -63,10 +69,37 @@ export default function ProcessorDetailWorkbench({
|
||||
const hasDebug = Boolean(debugTitle && debugContent);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 min-w-0 flex-col">
|
||||
<Tabs
|
||||
value={activeView}
|
||||
onValueChange={(value) =>
|
||||
setActiveView(value as 'workbench' | 'monitoring')
|
||||
}
|
||||
className="flex h-full min-h-0 min-w-0 flex-col gap-0"
|
||||
>
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 pb-4">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<h1 className="truncate text-xl font-semibold">{title}</h1>
|
||||
{titleAction}
|
||||
{monitoring && (
|
||||
<TabsList
|
||||
aria-label={`${monitoring.workbenchLabel} / ${monitoring.label}`}
|
||||
className="ml-1"
|
||||
>
|
||||
<TabsTrigger value="workbench" className="gap-1.5 px-3">
|
||||
<Settings className="size-4" />
|
||||
{monitoring.workbenchLabel}
|
||||
{isDirty && (
|
||||
<span className="size-1.5 rounded-full bg-amber-500">
|
||||
<span className="sr-only">{unsavedLabel}</span>
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="monitoring" className="gap-1.5 px-3">
|
||||
<BarChart3 className="size-4" />
|
||||
{monitoring.label}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
)}
|
||||
{status && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -112,20 +145,6 @@ export default function ProcessorDetailWorkbench({
|
||||
)}
|
||||
</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"
|
||||
@@ -135,74 +154,86 @@ export default function ProcessorDetailWorkbench({
|
||||
{saveLabel}
|
||||
</Button>
|
||||
)}
|
||||
{activeView === 'workbench' && headerActions}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeView === 'monitoring' && monitoring ? (
|
||||
<section 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',
|
||||
)}
|
||||
{monitoring && (
|
||||
<TabsContent
|
||||
value="monitoring"
|
||||
className="mt-0 min-h-0 flex-1 overflow-hidden"
|
||||
>
|
||||
<section
|
||||
aria-label={monitoring.label}
|
||||
className="h-full min-h-0 overflow-y-auto rounded-xl border bg-card p-4"
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
{monitoring.content}
|
||||
</section>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
<TabsContent
|
||||
value="workbench"
|
||||
className="mt-0 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={configTitle}
|
||||
className="flex min-h-[36rem] min-w-0 flex-col overflow-hidden rounded-xl border bg-card lg:min-h-0"
|
||||
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 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}
|
||||
<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 p-4">
|
||||
{configContent}
|
||||
<div className="min-h-0 min-w-0 flex-1 overflow-hidden">
|
||||
{debugContent}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import PipelineFormComponent, {
|
||||
PipelineFormHandle,
|
||||
@@ -7,9 +8,15 @@ import 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 { useCurrentWorkspace } from '@/app/infra/http';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { Pipeline } from '@/app/infra/entities/api';
|
||||
|
||||
export default function PipelineDetailContent({
|
||||
id,
|
||||
@@ -44,13 +51,47 @@ export default function PipelineDetailContent({
|
||||
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 pipeline = pipelines.find((item) => item.id === id);
|
||||
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)}`);
|
||||
@@ -97,66 +138,92 @@ export default function PipelineDetailContent({
|
||||
}
|
||||
|
||||
// ==================== Edit Mode ====================
|
||||
const pipelineName =
|
||||
pipelineDetails?.name ||
|
||||
sidebarPipeline?.name ||
|
||||
t('pipelines.editPipeline');
|
||||
const pipelineEmoji =
|
||||
pipelineDetails?.emoji || sidebarPipeline?.emoji || '⚙️';
|
||||
|
||||
return (
|
||||
<ProcessorDetailWorkbench
|
||||
key={id}
|
||||
title={`${pipeline?.emoji || '⚙️'} ${pipeline?.name || t('pipelines.editPipeline')}`}
|
||||
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}
|
||||
showButtons={false}
|
||||
onFinish={handleFinish}
|
||||
onNewPipelineCreated={handleNewPipelineCreated}
|
||||
onDeletePipeline={handleDeletePipeline}
|
||||
onCancel={() => navigate(routeBase)}
|
||||
onDirtyChange={setFormDirty}
|
||||
onSavingChange={setFormSaving}
|
||||
/>
|
||||
</fieldset>
|
||||
}
|
||||
debugTitle={canOperate ? t('pipelines.debugChat') : undefined}
|
||||
debugConnected={canOperate ? isWebSocketConnected : undefined}
|
||||
debugConnectedLabel={t('pipelines.debugDialog.connected')}
|
||||
debugDisconnectedLabel={t('pipelines.debugDialog.disconnected')}
|
||||
debugContent={
|
||||
canOperate ? (
|
||||
<DebugDialog
|
||||
open={true}
|
||||
pipelineId={id}
|
||||
isEmbedded={true}
|
||||
compact={true}
|
||||
hasUnsavedChanges={formDirty}
|
||||
beforeSend={async () => pipelineFormRef.current?.save() ?? false}
|
||||
onConnectionStatusChange={setIsWebSocketConnected}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
unsavedLabel={t('pipelines.unsavedChanges')}
|
||||
monitoring={
|
||||
canViewMonitoring
|
||||
? {
|
||||
label: t('pipelines.monitoring.title'),
|
||||
content: (
|
||||
<PipelineMonitoringTab
|
||||
pipelineId={id}
|
||||
onNavigateToMonitoring={() => {
|
||||
navigate('/home/monitoring');
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<>
|
||||
<ProcessorDetailWorkbench
|
||||
key={id}
|
||||
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}
|
||||
showButtons={false}
|
||||
onFinish={handleFinish}
|
||||
onNewPipelineCreated={handleNewPipelineCreated}
|
||||
onDeletePipeline={handleDeletePipeline}
|
||||
onCancel={() => navigate(routeBase)}
|
||||
onDirtyChange={setFormDirty}
|
||||
onSavingChange={setFormSaving}
|
||||
/>
|
||||
</fieldset>
|
||||
}
|
||||
debugTitle={canOperate ? t('pipelines.debugChat') : undefined}
|
||||
debugConnected={canOperate ? isWebSocketConnected : undefined}
|
||||
debugConnectedLabel={t('pipelines.debugDialog.connected')}
|
||||
debugDisconnectedLabel={t('pipelines.debugDialog.disconnected')}
|
||||
debugContent={
|
||||
canOperate ? (
|
||||
<DebugDialog
|
||||
open={true}
|
||||
pipelineId={id}
|
||||
isEmbedded={true}
|
||||
compact={true}
|
||||
hasUnsavedChanges={formDirty}
|
||||
beforeSend={async () => pipelineFormRef.current?.save() ?? false}
|
||||
onConnectionStatusChange={setIsWebSocketConnected}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
unsavedLabel={t('pipelines.unsavedChanges')}
|
||||
monitoring={
|
||||
canViewMonitoring
|
||||
? {
|
||||
label: t('pipelines.monitoring.title'),
|
||||
workbenchLabel: t('pipelines.monitoring.workbench'),
|
||||
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';
|
||||
@@ -42,11 +42,6 @@ import {
|
||||
AlignLeft,
|
||||
RotateCcw,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
|
||||
interface DebugDialogProps {
|
||||
open: boolean;
|
||||
@@ -155,7 +150,7 @@ export default function DebugDialog({
|
||||
);
|
||||
const [streamOutput, setStreamOutput] = useState(true);
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const wsClientRef = useRef<WebSocketClient | null>(null);
|
||||
@@ -367,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('@')) {
|
||||
@@ -462,7 +457,7 @@ export default function DebugDialog({
|
||||
return;
|
||||
}
|
||||
|
||||
const messageChain = [];
|
||||
const messageChain: MessageChainComponent[] = [];
|
||||
|
||||
// Add quoted message if present
|
||||
if (quotedMessage) {
|
||||
@@ -516,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) {
|
||||
@@ -853,75 +852,50 @@ export default function DebugDialog({
|
||||
};
|
||||
|
||||
const renderContent = () => (
|
||||
<div className="flex flex-1 h-full min-h-0">
|
||||
<div className="flex flex-1 h-full min-h-0 flex-col">
|
||||
<div
|
||||
className={cn(
|
||||
'w-14 p-2 pl-0 shrink-0 flex flex-col justify-start gap-2',
|
||||
compact && 'w-12 p-1.5 pl-1',
|
||||
'flex shrink-0 flex-wrap items-center gap-1 border-b px-4 py-2',
|
||||
compact && 'px-3',
|
||||
)}
|
||||
data-debug-session-toolbar="true"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t('pipelines.debugDialog.privateChat')}
|
||||
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',
|
||||
)}
|
||||
onClick={() => setSessionType('person')}
|
||||
>
|
||||
<User className="size-5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
{t('pipelines.debugDialog.privateChat')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t('pipelines.debugDialog.groupChat')}
|
||||
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',
|
||||
)}
|
||||
onClick={() => setSessionType('group')}
|
||||
>
|
||||
<Users className="size-5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
{t('pipelines.debugDialog.groupChat')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t('pipelines.debugDialog.reset')}
|
||||
className="w-10 h-10 justify-center rounded-md text-muted-foreground"
|
||||
onClick={() => void resetConversation()}
|
||||
>
|
||||
<RotateCcw className="size-5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
{t('pipelines.debugDialog.reset')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className="mr-1 text-xs text-muted-foreground">
|
||||
{t('pipelines.debugDialog.sessionType')}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-pressed={sessionType === 'person'}
|
||||
className={cn(
|
||||
'shadow-none',
|
||||
sessionType === 'person' &&
|
||||
'bg-primary/15 text-primary hover:bg-primary/20 hover:text-primary',
|
||||
)}
|
||||
onClick={() => setSessionType('person')}
|
||||
>
|
||||
<User className="size-4" />
|
||||
{t('pipelines.debugDialog.privateChat')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-pressed={sessionType === 'group'}
|
||||
className={cn(
|
||||
'shadow-none',
|
||||
sessionType === 'group' &&
|
||||
'bg-primary/15 text-primary hover:bg-primary/20 hover:text-primary',
|
||||
)}
|
||||
onClick={() => setSessionType('group')}
|
||||
>
|
||||
<Users className="size-4" />
|
||||
{t('pipelines.debugDialog.groupChat')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col w-[10rem] h-full min-h-0">
|
||||
<div className="flex-1 flex flex-col w-full h-full min-h-0">
|
||||
<ScrollArea
|
||||
ref={scrollAreaRef}
|
||||
className={cn(
|
||||
@@ -1086,9 +1060,10 @@ export default function DebugDialog({
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn('p-4 pb-0 flex gap-2', compact && 'flex-col p-3 pb-0')}
|
||||
className={cn('shrink-0 border-t p-4', compact && 'p-3')}
|
||||
data-debug-composer="true"
|
||||
>
|
||||
<div className="flex gap-2 items-center">
|
||||
<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')}
|
||||
@@ -1117,74 +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={cn(
|
||||
'rounded-md w-20 px-6 py-2 text-base font-medium transition-none flex items-center gap-2 shadow-none disabled:opacity-50',
|
||||
compact && 'w-auto px-3 text-sm',
|
||||
)}
|
||||
>
|
||||
{isUploading ? (
|
||||
t('pipelines.debugDialog.uploading')
|
||||
) : (
|
||||
<>
|
||||
<Send className="size-4" />
|
||||
{hasUnsavedChanges
|
||||
? t('pipelines.debugDialog.saveAndSend')
|
||||
: t('pipelines.debugDialog.send')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -73,6 +73,11 @@ interface PipelineFormComponentProps {
|
||||
|
||||
export interface PipelineFormHandle {
|
||||
save: () => Promise<boolean>;
|
||||
syncBasicInfo: (values: {
|
||||
name: string;
|
||||
description: string;
|
||||
emoji?: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
const PipelineFormComponent = forwardRef<
|
||||
@@ -137,7 +142,7 @@ const PipelineFormComponent = forwardRef<
|
||||
const formLabelList: SectionItem[] = isEditMode
|
||||
? [
|
||||
{
|
||||
label: t('pipelines.basicInfo'),
|
||||
label: t('common.management'),
|
||||
name: 'basic',
|
||||
icon: SECTION_ICONS.basic,
|
||||
},
|
||||
@@ -182,9 +187,13 @@ const PipelineFormComponent = forwardRef<
|
||||
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),
|
||||
);
|
||||
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>();
|
||||
@@ -367,6 +376,24 @@ const PipelineFormComponent = forwardRef<
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -656,69 +683,85 @@ const PipelineFormComponent = forwardRef<
|
||||
|
||||
{/* 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">
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -16,7 +17,12 @@ export interface WebSocketMessage {
|
||||
|
||||
export interface WebSocketResponse {
|
||||
type:
|
||||
'connected' | 'response' | 'user_message' | 'pong' | 'broadcast' | 'error';
|
||||
| 'connected'
|
||||
| 'response'
|
||||
| 'user_message'
|
||||
| 'pong'
|
||||
| 'broadcast'
|
||||
| 'error';
|
||||
connection_id?: string;
|
||||
pipeline_uuid?: string;
|
||||
session_type?: string;
|
||||
@@ -262,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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
@@ -678,18 +701,43 @@ const enUS = {
|
||||
selectFromSidebar: 'Select an Agent or Pipeline from the sidebar',
|
||||
agentType: 'Agent',
|
||||
agentTypeDescription:
|
||||
'Use a runner to handle messages, group members, friends, feedback, and other platform events.',
|
||||
'Use a runner to handle messages, group members, friends, feedback, and other platform events. Best for scenarios that need autonomous decisions, tool use, or non-message events.',
|
||||
pipelineType: 'Pipeline',
|
||||
kindBadgeAgent: 'Agent',
|
||||
kindBadgePipeline: 'Pipeline',
|
||||
groupByKind: 'Group by type',
|
||||
groupByKindShort: 'Group',
|
||||
pipelineTypeDescription:
|
||||
'Use a visual multi-stage flow to control message preprocessing, AI, postprocessing, extensions, and output. Message events only.',
|
||||
'A classic “receive a message, ask AI, reply to the user” flow with common configuration options. Handles message events only and works best for clear, predictable processes.',
|
||||
allEvents: 'Supports all events',
|
||||
messageEventsOnly: 'Message events only',
|
||||
chooseType: 'Choose how it works',
|
||||
chooseTypeDescription:
|
||||
'Choose the processor model first. You can configure it further after creation.',
|
||||
diagramHint: 'How it works',
|
||||
agentDiagramTitle: 'Agent: decide and act',
|
||||
agentDiagramDescription:
|
||||
'An Agent can receive many platform events, use models and tools to understand the situation, and decide what to do next.',
|
||||
pipelineDiagramTitle: 'Pipeline: follow a defined flow',
|
||||
pipelineDiagramDescription:
|
||||
'A Pipeline handles message events only and runs each step in the order you configure.',
|
||||
pipelineDiagramFlow: 'Messages move through a fixed sequence',
|
||||
diagramEvents: 'Platform events',
|
||||
diagramAgentCanUse: 'Available to Agent',
|
||||
diagramMessages: 'Messages',
|
||||
diagramMembers: 'Members',
|
||||
diagramFeedback: 'Feedback',
|
||||
diagramDecide: 'Understand and decide',
|
||||
diagramModel: 'Use model',
|
||||
diagramTools: 'Use tools',
|
||||
diagramActions: 'Take action',
|
||||
diagramMessage: 'Message',
|
||||
diagramPreprocess: 'Prepare',
|
||||
diagramAI: 'AI',
|
||||
diagramPostprocess: 'Refine',
|
||||
diagramOutput: 'Output',
|
||||
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 +745,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: ',
|
||||
@@ -1235,6 +1282,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',
|
||||
@@ -1354,8 +1402,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',
|
||||
@@ -1371,7 +1419,8 @@ const enUS = {
|
||||
uploading: 'Uploading...',
|
||||
},
|
||||
monitoring: {
|
||||
title: 'Dashboard',
|
||||
title: 'Run logs',
|
||||
workbench: 'Configure & debug',
|
||||
description:
|
||||
'View execution logs and errors for this pipeline (last 24 hours)',
|
||||
detailedLogs: 'Detailed Logs',
|
||||
|
||||
@@ -515,18 +515,17 @@ const esES = {
|
||||
agentType: 'Agent',
|
||||
agentTypeDescription:
|
||||
'Usa un runner para procesar mensajes, miembros de grupo, amigos, retroalimentación y otros eventos de plataforma.',
|
||||
pipelineType: 'Pipeline',
|
||||
pipelineType: 'Flujo de trabajo',
|
||||
kindBadgeAgent: 'Agent',
|
||||
kindBadgePipeline: 'Pipeline',
|
||||
groupByKind: 'Agrupar por tipo',
|
||||
groupByKindShort: 'Agrupar',
|
||||
pipelineTypeDescription:
|
||||
'Usa un flujo visual de varias etapas para controlar el preprocesamiento, la IA, el posprocesamiento, las extensiones y la salida. Solo procesa eventos de mensaje.',
|
||||
'El flujo clásico de «recibir un mensaje, consultar a la IA y responder al usuario», con opciones de configuración habituales. Solo procesa eventos de mensaje y es ideal para procesos claros y predecibles.',
|
||||
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',
|
||||
@@ -1154,7 +1152,8 @@ const esES = {
|
||||
uploading: 'Subiendo...',
|
||||
},
|
||||
monitoring: {
|
||||
title: 'Panel de control',
|
||||
title: 'Registros de ejecución',
|
||||
workbench: 'Configurar y depurar',
|
||||
description:
|
||||
'Ver registros de ejecución y errores de este Pipeline (últimas 24 horas)',
|
||||
detailedLogs: 'Registros detallados',
|
||||
|
||||
@@ -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: '対象プロセッサー',
|
||||
@@ -693,18 +714,43 @@ const jaJP = {
|
||||
'サイドバーから Agent または Pipeline を選択してください',
|
||||
agentType: 'Agent',
|
||||
agentTypeDescription:
|
||||
'Runner を使ってメッセージ、グループメンバー、友だち、フィードバックなどのプラットフォームイベントを処理します。',
|
||||
pipelineType: 'Pipeline',
|
||||
'Runner を使ってメッセージ、グループメンバー、友だち、フィードバックなどのプラットフォームイベントを処理します。自律的な判断、ツール利用、メッセージ以外のイベント対応が必要な場合に適しています。',
|
||||
pipelineType: 'パイプライン',
|
||||
kindBadgeAgent: 'Agent',
|
||||
kindBadgePipeline: 'パイプライン',
|
||||
groupByKind: 'タイプ別にグループ化',
|
||||
groupByKindShort: 'グループ',
|
||||
pipelineTypeDescription:
|
||||
'ビジュアルな多段フローで、メッセージの前処理、AI、後処理、拡張、出力を制御します。メッセージイベントのみ処理できます。',
|
||||
'「メッセージを受信し、AIに問い合わせ、ユーザーへ返信する」という定番のフローに、よく使う設定機能を加えたものです。メッセージイベントのみを処理し、手順が明確で安定した実行が必要な場合に適しています。',
|
||||
allEvents: 'すべてのイベントに対応',
|
||||
messageEventsOnly: 'メッセージイベントのみ',
|
||||
chooseType: '処理方法を選択',
|
||||
chooseTypeDescription:
|
||||
'最初にプロセッサーの動作方式を選びます。作成後に詳しく設定できます。',
|
||||
diagramHint: '動作イメージ',
|
||||
agentDiagramTitle: 'Agent:判断して行動',
|
||||
agentDiagramDescription:
|
||||
'Agent はさまざまなプラットフォームイベントを受け取り、モデルやツールを使って状況を理解し、次の行動を判断します。',
|
||||
pipelineDiagramTitle: 'Pipeline:決めたフローで処理',
|
||||
pipelineDiagramDescription:
|
||||
'Pipeline はメッセージイベントのみを、設定した固定ステップの順番で処理します。',
|
||||
pipelineDiagramFlow: 'メッセージを固定フローで順番に処理',
|
||||
diagramEvents: 'イベント',
|
||||
diagramAgentCanUse: 'Agent が利用可能',
|
||||
diagramMessages: 'メッセージ',
|
||||
diagramMembers: 'メンバー',
|
||||
diagramFeedback: 'フィードバック',
|
||||
diagramDecide: '理解して判断',
|
||||
diagramModel: 'モデル',
|
||||
diagramTools: 'ツール',
|
||||
diagramActions: 'アクション',
|
||||
diagramMessage: 'メッセージ',
|
||||
diagramPreprocess: '前処理',
|
||||
diagramAI: 'AI',
|
||||
diagramPostprocess: '後処理',
|
||||
diagramOutput: '出力',
|
||||
basicInfo: '基本情報',
|
||||
basicInfoDescription: '名前、アイコン、説明、有効状態を設定します',
|
||||
basicInfoDescription: '名前、アイコン、説明を設定します',
|
||||
runnerSettings: 'Runner',
|
||||
advanced: '詳細',
|
||||
bindableEvents: '紐付け可能なイベント範囲',
|
||||
@@ -712,10 +758,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 +1245,7 @@ const jaJP = {
|
||||
earliestEdited: '最古編集',
|
||||
basicInfo: '基本情報',
|
||||
basicInfoDescription: 'パイプラインの名前、アイコン、説明を設定',
|
||||
managementDescription: 'このパイプラインを複製または削除します。',
|
||||
aiCapabilities: 'AI機能',
|
||||
triggerConditions: 'トリガー条件',
|
||||
safetyControls: '安全制御',
|
||||
@@ -1318,8 +1364,8 @@ const jaJP = {
|
||||
atTips: 'ボットをメンション',
|
||||
streaming: 'ストリーミング',
|
||||
streamOutput: 'ストリーム',
|
||||
connected: 'WebSocket接続済み',
|
||||
disconnected: 'WebSocket未接続',
|
||||
connected: '接続済み',
|
||||
disconnected: '未接続',
|
||||
connectionError: 'WebSocket接続エラー',
|
||||
connectionFailed: 'WebSocket接続に失敗しました',
|
||||
notConnected:
|
||||
@@ -1336,7 +1382,8 @@ const jaJP = {
|
||||
uploading: 'アップロード中...',
|
||||
},
|
||||
monitoring: {
|
||||
title: 'ダッシュボード',
|
||||
title: '実行ログ',
|
||||
workbench: '設定とデバッグ',
|
||||
description: 'このパイプラインの実行ログとエラー情報を表示(過去24時間)',
|
||||
detailedLogs: '詳細ログ',
|
||||
},
|
||||
|
||||
@@ -512,17 +512,17 @@ const ruRU = {
|
||||
agentType: 'Agent',
|
||||
agentTypeDescription:
|
||||
'Используйте runner для обработки сообщений, участников групп, друзей, обратной связи и других событий платформы.',
|
||||
pipelineType: 'Pipeline',
|
||||
pipelineType: 'Конвейер',
|
||||
kindBadgeAgent: 'Agent',
|
||||
kindBadgePipeline: 'Pipeline',
|
||||
groupByKind: 'Группировать по типу',
|
||||
groupByKindShort: 'Группа',
|
||||
pipelineTypeDescription:
|
||||
'Визуальный многоэтапный процесс управляет предобработкой сообщений, AI, постобработкой, расширениями и выводом. Только события сообщений.',
|
||||
'Классический процесс «получить сообщение, обратиться к AI, ответить пользователю» с основными настройками. Обрабатывает только события сообщений и подходит для четких, предсказуемых сценариев.',
|
||||
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 не подключён, повторите попытку позже',
|
||||
@@ -1142,7 +1141,8 @@ const ruRU = {
|
||||
uploading: 'Загрузка...',
|
||||
},
|
||||
monitoring: {
|
||||
title: 'Мониторинг',
|
||||
title: 'Журнал выполнения',
|
||||
workbench: 'Настройка и отладка',
|
||||
description:
|
||||
'Просмотр журналов выполнения и ошибок конвейера (за последние 24 часа)',
|
||||
detailedLogs: 'Подробные журналы',
|
||||
|
||||
@@ -498,17 +498,17 @@ const thTH = {
|
||||
agentType: 'Agent',
|
||||
agentTypeDescription:
|
||||
'ใช้ runner เพื่อประมวลผลข้อความ สมาชิกกลุ่ม เพื่อน ฟีดแบ็ก และเหตุการณ์แพลตฟอร์มอื่นๆ',
|
||||
pipelineType: 'Pipeline',
|
||||
pipelineType: 'ไปป์ไลน์',
|
||||
kindBadgeAgent: 'Agent',
|
||||
kindBadgePipeline: 'Pipeline',
|
||||
groupByKind: 'จัดกลุ่มตามประเภท',
|
||||
groupByKindShort: 'จัดกลุ่ม',
|
||||
pipelineTypeDescription:
|
||||
'ใช้โฟลว์แบบหลายขั้นตอนที่มองเห็นได้เพื่อควบคุมการประมวลผลก่อน AI การประมวลผลหลัง ส่วนขยาย และผลลัพธ์ รองรับเฉพาะเหตุการณ์ข้อความ',
|
||||
'โฟลว์มาตรฐาน “รับข้อความ ขอคำตอบจาก AI และตอบกลับผู้ใช้” พร้อมตัวเลือกการตั้งค่าที่ใช้บ่อย รองรับเฉพาะเหตุการณ์ข้อความและเหมาะกับงานที่มีขั้นตอนชัดเจนและคาดเดาได้',
|
||||
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 กรุณาลองใหม่ภายหลัง',
|
||||
@@ -1117,7 +1116,8 @@ const thTH = {
|
||||
uploading: 'กำลังอัปโหลด...',
|
||||
},
|
||||
monitoring: {
|
||||
title: 'แดชบอร์ด',
|
||||
title: 'บันทึกการทำงาน',
|
||||
workbench: 'กำหนดค่าและดีบัก',
|
||||
description:
|
||||
'ดูบันทึกการดำเนินการและข้อผิดพลาดของ Pipeline นี้ (24 ชั่วโมงล่าสุด)',
|
||||
detailedLogs: 'บันทึกโดยละเอียด',
|
||||
|
||||
@@ -508,17 +508,17 @@ const viVN = {
|
||||
agentType: 'Agent',
|
||||
agentTypeDescription:
|
||||
'Dùng runner để xử lý tin nhắn, thành viên nhóm, bạn bè, phản hồi và các sự kiện nền tảng khác.',
|
||||
pipelineType: 'Pipeline',
|
||||
pipelineType: 'Quy trình',
|
||||
kindBadgeAgent: 'Agent',
|
||||
kindBadgePipeline: 'Pipeline',
|
||||
groupByKind: 'Nhóm theo loại',
|
||||
groupByKindShort: 'Nhóm',
|
||||
pipelineTypeDescription:
|
||||
'Dùng luồng trực quan nhiều giai đoạn để kiểm soát tiền xử lý, AI, hậu xử lý, tiện ích mở rộng và đầu ra. Chỉ xử lý sự kiện tin nhắn.',
|
||||
'Quy trình quen thuộc “nhận tin nhắn, hỏi AI, trả lời người dùng” cùng các tùy chọn cấu hình thông dụng. Chỉ xử lý sự kiện tin nhắn và phù hợp với các quy trình rõ ràng, dễ dự đoán.',
|
||||
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',
|
||||
@@ -1134,7 +1133,8 @@ const viVN = {
|
||||
uploading: 'Đang tải lên...',
|
||||
},
|
||||
monitoring: {
|
||||
title: 'Bảng điều khiển',
|
||||
title: 'Nhật ký chạy',
|
||||
workbench: 'Cấu hình và gỡ lỗi',
|
||||
description: 'Xem nhật ký thực thi và lỗi của Pipeline này (24 giờ qua)',
|
||||
detailedLogs: 'Nhật ký chi tiết',
|
||||
},
|
||||
|
||||
@@ -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: '目标处理器',
|
||||
@@ -649,18 +670,43 @@ const zhHans = {
|
||||
editAgent: '编辑 Agent',
|
||||
selectFromSidebar: '从侧边栏选择一个 Agent 或 Pipeline',
|
||||
agentType: 'Agent',
|
||||
agentTypeDescription: '通过运行器处理消息、群成员、好友、反馈等平台事件。',
|
||||
pipelineType: 'Pipeline',
|
||||
agentTypeDescription:
|
||||
'通过运行器处理消息、群成员、好友、反馈等平台事件。适合需要自主判断、调用工具或响应非消息事件的场景。',
|
||||
pipelineType: '流水线',
|
||||
kindBadgeAgent: 'Agent',
|
||||
kindBadgePipeline: '流水线',
|
||||
groupByKind: '按类型分组',
|
||||
groupByKindShort: '分组',
|
||||
pipelineTypeDescription:
|
||||
'通过可视化多阶段流程控制消息的预处理、AI、后处理、扩展和输出;仅处理消息事件。',
|
||||
'流水线即为经典的“收到消息、请求AI、回复用户”流程,并辅以常用的配置功能。仅处理消息事件,适合步骤明确、需要稳定控制处理过程的场景。',
|
||||
allEvents: '支持全部事件',
|
||||
messageEventsOnly: '仅支持消息事件',
|
||||
chooseType: '选择处理方式',
|
||||
chooseTypeDescription: '先选择处理器的工作方式,创建后可继续完善配置。',
|
||||
diagramHint: '工作方式示意',
|
||||
agentDiagramTitle: 'Agent:自主判断并行动',
|
||||
agentDiagramDescription:
|
||||
'Agent 可以接收多种平台事件,结合模型和工具理解当前情况,并自主决定下一步动作。',
|
||||
pipelineDiagramTitle: 'Pipeline:按流程逐步处理',
|
||||
pipelineDiagramDescription:
|
||||
'Pipeline 只处理消息事件,并严格按照你编排的固定步骤依次执行。',
|
||||
pipelineDiagramFlow: '消息沿固定流程依次处理',
|
||||
diagramEvents: '平台事件',
|
||||
diagramAgentCanUse: 'Agent 可使用',
|
||||
diagramMessages: '消息',
|
||||
diagramMembers: '群成员',
|
||||
diagramFeedback: '反馈',
|
||||
diagramDecide: '理解事件并决定下一步',
|
||||
diagramModel: '调用模型',
|
||||
diagramTools: '使用工具',
|
||||
diagramActions: '执行动作',
|
||||
diagramMessage: '收到消息',
|
||||
diagramPreprocess: '预处理',
|
||||
diagramAI: 'AI 处理',
|
||||
diagramPostprocess: '后处理',
|
||||
diagramOutput: '输出',
|
||||
basicInfo: '基础信息',
|
||||
basicInfoDescription: '设置名称、图标、描述和启用状态',
|
||||
basicInfoDescription: '设置名称、图标和描述',
|
||||
runnerSettings: '运行器',
|
||||
advanced: '高级',
|
||||
bindableEvents: '可绑定事件范围',
|
||||
@@ -668,9 +714,9 @@ const zhHans = {
|
||||
'限制此 Agent 可被机器人事件路由选择的事件范围。通常保持默认即可。',
|
||||
supportedEvents: '事件范围',
|
||||
supportedEventsDescription:
|
||||
'每行一个事件模式,例如 *、message.received、group.*。Pipeline 固定仅支持 message.*。',
|
||||
enabled: '启用 Agent',
|
||||
enabledDescription: '禁用后,此 Agent 不应被事件路由选中。',
|
||||
'选择全部事件、事件组或具体事件。机器人路由只会在匹配的事件中显示此 Agent。',
|
||||
searchEvents: '搜索事件…',
|
||||
noEventsFound: '没有匹配的事件',
|
||||
nameRequired: '名称不能为空',
|
||||
createSuccess: '创建成功',
|
||||
createError: '创建失败:',
|
||||
@@ -1178,6 +1224,7 @@ const zhHans = {
|
||||
earliestEdited: '最早编辑',
|
||||
basicInfo: '基础信息',
|
||||
basicInfoDescription: '设置流水线名称、图标和描述',
|
||||
managementDescription: '复制或删除此流水线。',
|
||||
aiCapabilities: 'AI 能力',
|
||||
triggerConditions: '触发条件',
|
||||
safetyControls: '安全控制',
|
||||
@@ -1295,8 +1342,8 @@ const zhHans = {
|
||||
atTips: '提及机器人',
|
||||
streaming: '流式传输',
|
||||
streamOutput: '流式',
|
||||
connected: 'WebSocket已连接',
|
||||
disconnected: 'WebSocket未连接',
|
||||
connected: '已连接',
|
||||
disconnected: '未连接',
|
||||
connectionError: 'WebSocket连接错误',
|
||||
connectionFailed: 'WebSocket连接失败',
|
||||
notConnected: 'WebSocket未连接,请稍后重试',
|
||||
@@ -1312,7 +1359,8 @@ const zhHans = {
|
||||
uploading: '上传中...',
|
||||
},
|
||||
monitoring: {
|
||||
title: '监控日志',
|
||||
title: '运行日志',
|
||||
workbench: '配置与调试',
|
||||
description: '查看此流水线的运行记录和错误信息(最近24小时)',
|
||||
detailedLogs: '详细日志',
|
||||
},
|
||||
|
||||
@@ -481,17 +481,17 @@ const zhHant = {
|
||||
selectFromSidebar: '從側邊欄選擇一個 Agent 或 Pipeline',
|
||||
agentType: 'Agent',
|
||||
agentTypeDescription: '透過執行器處理訊息、群成員、好友、回饋等平台事件。',
|
||||
pipelineType: 'Pipeline',
|
||||
pipelineType: '流程線',
|
||||
kindBadgeAgent: 'Agent',
|
||||
kindBadgePipeline: '流水線',
|
||||
groupByKind: '依類型分組',
|
||||
groupByKindShort: '分組',
|
||||
pipelineTypeDescription:
|
||||
'透過視覺化多階段流程控制訊息的前處理、AI、後處理、擴充與輸出;僅處理訊息事件。',
|
||||
'流程線即為經典的「收到訊息、請求 AI、回覆使用者」流程,並輔以常用的設定功能。僅處理訊息事件,適合步驟明確、需要穩定控制處理過程的場景。',
|
||||
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未連接,請稍後重試',
|
||||
@@ -1083,7 +1083,8 @@ const zhHant = {
|
||||
uploading: '上傳中...',
|
||||
},
|
||||
monitoring: {
|
||||
title: '監控日誌',
|
||||
title: '執行日誌',
|
||||
workbench: '設定與除錯',
|
||||
description: '檢視此流程線的執行記錄和錯誤資訊(最近24小時)',
|
||||
detailedLogs: '詳細日誌',
|
||||
},
|
||||
|
||||
+491
-103
@@ -74,6 +74,25 @@ async function forceFormSubmit(page: Page, formSelector: string) {
|
||||
}
|
||||
|
||||
test.describe('frontend CRUD smoke flows', () => {
|
||||
test('localizes the pipeline processor type in Simplified Chinese', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
language: 'zh-Hans',
|
||||
});
|
||||
|
||||
await page.goto('/home/agents?id=new');
|
||||
await expect(
|
||||
page.locator('[data-processor-kind="pipeline"]'),
|
||||
).toContainText('流水线');
|
||||
await expect(
|
||||
page.locator('[data-processor-kind="pipeline"]'),
|
||||
).toContainText(
|
||||
'流水线即为经典的“收到消息、请求AI、回复用户”流程,并辅以常用的配置功能。仅处理消息事件,适合步骤明确、需要稳定控制处理过程的场景。',
|
||||
);
|
||||
});
|
||||
|
||||
test('viewer keeps ordinary bot and pipeline monitoring access', async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -97,7 +116,7 @@ test.describe('frontend CRUD smoke flows', () => {
|
||||
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
|
||||
|
||||
await page.goto('/home/agents?id=new');
|
||||
await page.getByRole('button', { name: /^Pipeline/ }).click();
|
||||
await page.getByRole('radio', { name: /^Pipeline/ }).click();
|
||||
await page.locator('input[name="name"]').fill('Viewer Pipeline');
|
||||
await page
|
||||
.locator('input[name="description"]')
|
||||
@@ -116,7 +135,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('tab', { name: 'Run logs' })).toBeVisible();
|
||||
await expect(page.getByRole('tab', { name: 'Debug Chat' })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: /^Save$/ })).toHaveCount(0);
|
||||
|
||||
@@ -144,15 +163,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);
|
||||
@@ -165,8 +190,42 @@ test.describe('frontend CRUD smoke flows', () => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
await page.goto('/home/agents?id=new');
|
||||
await page.getByRole('button', { name: /^Pipeline/ }).click();
|
||||
const agentTypeCard = page.locator('[data-processor-kind="agent"]');
|
||||
const pipelineTypeCard = page.locator('[data-processor-kind="pipeline"]');
|
||||
await expect(agentTypeCard).toHaveAttribute(
|
||||
'data-slot',
|
||||
'toggle-group-item',
|
||||
);
|
||||
await expect(pipelineTypeCard).toHaveAttribute(
|
||||
'data-slot',
|
||||
'toggle-group-item',
|
||||
);
|
||||
await expect(agentTypeCard).toHaveAttribute('aria-checked', 'true');
|
||||
await expect(page.getByTestId('agent-diagram')).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId('agent-diagram').locator('[data-motion="flow"]'),
|
||||
).toHaveCount(3);
|
||||
await expect(
|
||||
page.getByTestId('agent-diagram').locator('[data-motion="relation"]'),
|
||||
).toHaveCount(3);
|
||||
await expect(page.getByTestId('pipeline-diagram')).toHaveCount(0);
|
||||
|
||||
await page.evaluate(() => document.documentElement.classList.add('dark'));
|
||||
await expect(page.getByTestId('agent-diagram')).toHaveCSS(
|
||||
'--processor-flow-opacity',
|
||||
'0.72',
|
||||
);
|
||||
await expect(page.getByTestId('agent-diagram')).toHaveCSS(
|
||||
'--processor-node-stroke-opacity',
|
||||
'0.5',
|
||||
);
|
||||
await page.getByRole('radio', { name: /^Pipeline/ }).click();
|
||||
await expect(pipelineTypeCard).toHaveAttribute('aria-checked', 'true');
|
||||
await expect(page.getByTestId('pipeline-diagram')).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId('pipeline-diagram').locator('[data-motion="flow"]'),
|
||||
).toHaveAttribute('data-dash-cycle', '15');
|
||||
await expect(page.getByTestId('agent-diagram')).toHaveCount(0);
|
||||
await expect(page.locator('input[name="name"]')).toBeVisible();
|
||||
await page.locator('input[name="name"]').fill('Escalation Pipeline');
|
||||
await page
|
||||
@@ -176,18 +235,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 +263,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 +297,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 +403,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 +664,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 +684,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 +697,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 +748,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();
|
||||
@@ -481,26 +772,29 @@ test.describe('pipeline advanced flows', () => {
|
||||
|
||||
// Create a pipeline
|
||||
await page.goto('/home/agents?id=new');
|
||||
await page.getByRole('button', { name: /^Pipeline/ }).click();
|
||||
await page.getByRole('radio', { name: /^Pipeline/ }).click();
|
||||
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',
|
||||
);
|
||||
const viewSwitcher = page.getByRole('tablist', {
|
||||
name: 'Configure & debug / Run logs',
|
||||
});
|
||||
const switcherPosition = await viewSwitcher.boundingBox();
|
||||
await page.getByRole('tab', { name: 'Run logs' }).click();
|
||||
await expect(page.getByRole('region', { name: 'Run logs' })).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => (await viewSwitcher.boundingBox())?.x)
|
||||
.toBe(switcherPosition?.x);
|
||||
|
||||
// Switch back to Configuration
|
||||
await page.getByRole('tab', { name: /Configuration/ }).click();
|
||||
await expect(page.locator('input[name="basic.name"]')).toBeVisible();
|
||||
await page.getByRole('tab', { name: 'Configure & debug' }).click();
|
||||
await expect(
|
||||
page.getByRole('region', { name: 'Configuration' }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('save button reflects form dirty state', async ({ page }) => {
|
||||
@@ -508,24 +802,20 @@ test.describe('pipeline advanced flows', () => {
|
||||
|
||||
// Create a pipeline
|
||||
await page.goto('/home/agents?id=new');
|
||||
await page.getByRole('button', { name: /^Pipeline/ }).click();
|
||||
await page.getByRole('radio', { name: /^Pipeline/ }).click();
|
||||
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 ({
|
||||
@@ -534,7 +824,7 @@ test.describe('pipeline advanced flows', () => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
await page.goto('/home/agents?id=new');
|
||||
await page.getByRole('button', { name: /^Pipeline/ }).click();
|
||||
await page.getByRole('radio', { name: /^Pipeline/ }).click();
|
||||
|
||||
// Submit without filling name
|
||||
await submit(page);
|
||||
@@ -569,7 +859,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 +894,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 +905,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 +933,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,45 +954,128 @@ 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 });
|
||||
|
||||
// Create a pipeline first
|
||||
await page.goto('/home/agents?id=new');
|
||||
await page.getByRole('button', { name: /^Pipeline/ }).click();
|
||||
await page.getByRole('radio', { name: /^Pipeline/ }).click();
|
||||
await page.locator('input[name="name"]').fill('Production Pipeline');
|
||||
await submit(page);
|
||||
await expect(page).toHaveURL(/\/home\/agents\?id=pipeline-1$/);
|
||||
@@ -702,7 +1088,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();
|
||||
|
||||
@@ -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, {});
|
||||
}
|
||||
@@ -729,7 +748,12 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
|
||||
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,
|
||||
@@ -789,7 +813,12 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
|
||||
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,
|
||||
@@ -1210,22 +1239,26 @@ export async function installLangBotApiMocks(
|
||||
page: Page,
|
||||
options: {
|
||||
authenticated?: boolean;
|
||||
language?: string;
|
||||
monitoringData?: unknown;
|
||||
monitoringSessions?: unknown[];
|
||||
sessionAnalyses?: Record<string, unknown>;
|
||||
sessionMessages?: Record<string, unknown[]>;
|
||||
storage?: JsonRecord;
|
||||
withAdapterEvents?: boolean;
|
||||
withRunnerToolSelector?: boolean;
|
||||
workspaces?: WorkspaceEntryMock[];
|
||||
} = {},
|
||||
) {
|
||||
const {
|
||||
authenticated = false,
|
||||
language = 'en-US',
|
||||
monitoringData,
|
||||
monitoringSessions,
|
||||
sessionAnalyses,
|
||||
sessionMessages,
|
||||
storage = {},
|
||||
withAdapterEvents = false,
|
||||
withRunnerToolSelector = false,
|
||||
workspaces = [defaultWorkspaceEntry()],
|
||||
} = options;
|
||||
@@ -1241,13 +1274,14 @@ export async function installLangBotApiMocks(
|
||||
sessionAnalyses: sessionAnalyses || {},
|
||||
sessionMessages: sessionMessages || {},
|
||||
skills: [],
|
||||
withAdapterEvents,
|
||||
withRunnerToolSelector,
|
||||
workspaces,
|
||||
};
|
||||
|
||||
await page.addInitScript(
|
||||
({ authenticated, storage }) => {
|
||||
localStorage.setItem('langbot_language', 'en-US');
|
||||
({ authenticated, language, storage }) => {
|
||||
localStorage.setItem('langbot_language', language);
|
||||
localStorage.setItem('extensions_group_by_type', 'false');
|
||||
|
||||
if (authenticated) {
|
||||
@@ -1262,7 +1296,7 @@ export async function installLangBotApiMocks(
|
||||
localStorage.setItem(key, String(value));
|
||||
}
|
||||
},
|
||||
{ authenticated, storage },
|
||||
{ authenticated, language, storage },
|
||||
);
|
||||
|
||||
await page.route('**/api/v1/**', (route) => handleBackendApi(route, state));
|
||||
|
||||
@@ -163,7 +163,7 @@ test.describe('pipeline monitoring conversation turns', () => {
|
||||
});
|
||||
|
||||
await page.goto(`/home/agents?id=${pipeline.id}`);
|
||||
await page.getByRole('tab', { name: 'Dashboard' }).click();
|
||||
await page.getByRole('tab', { name: 'Run logs' }).click();
|
||||
|
||||
await expect(page.getByText('2 conversation turns')).toBeVisible();
|
||||
await expect(
|
||||
|
||||
@@ -10,6 +10,7 @@ test.describe('processor detail workbench', () => {
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
withAdapterEvents: true,
|
||||
withRunnerToolSelector: true,
|
||||
});
|
||||
|
||||
@@ -26,6 +27,37 @@ test.describe('processor detail workbench', () => {
|
||||
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');
|
||||
const transcriptBox = await debugPanel
|
||||
.getByText('Debug transcript', { exact: true })
|
||||
.boundingBox();
|
||||
const eventPickerBox = await debugEventPicker.boundingBox();
|
||||
const conversationInputBox = await debugPanel
|
||||
.getByRole('textbox', { name: 'Conversation input' })
|
||||
.boundingBox();
|
||||
expect(transcriptBox).not.toBeNull();
|
||||
expect(eventPickerBox).not.toBeNull();
|
||||
expect(conversationInputBox).not.toBeNull();
|
||||
expect(eventPickerBox!.y).toBeGreaterThan(transcriptBox!.y);
|
||||
expect(eventPickerBox!.y).toBeLessThan(conversationInputBox!.y);
|
||||
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');
|
||||
@@ -45,36 +77,101 @@ test.describe('processor detail workbench', () => {
|
||||
expect(debugBox!.y).toBeGreaterThanOrEqual(0);
|
||||
|
||||
const flow = configPanel.getByRole('tablist');
|
||||
await expect(flow.getByRole('tab').nth(0)).toContainText(
|
||||
'Basic Information',
|
||||
);
|
||||
await expect(flow.getByRole('tab').nth(1)).toContainText(
|
||||
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').nth(2)).toContainText('Runner');
|
||||
await expect(flow.getByRole('tab').nth(3)).toContainText('Local Agent');
|
||||
await expect(flow.getByRole('tab')).toHaveCount(3);
|
||||
await expect(flow.getByText('Management')).toHaveCount(0);
|
||||
|
||||
await expect(configPanel.getByLabel('Name')).toBeVisible();
|
||||
await expect(configPanel.getByLabel('Icon')).toBeVisible();
|
||||
await expect(configPanel.getByLabel('Description')).toBeVisible();
|
||||
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.',
|
||||
),
|
||||
page
|
||||
.getByText(
|
||||
'Local Agent is registered and the plugin runtime is connected.',
|
||||
)
|
||||
.last(),
|
||||
).toBeVisible();
|
||||
|
||||
await flow.getByRole('tab').nth(1).click();
|
||||
await flow.getByRole('tab').nth(2).click();
|
||||
await expect(
|
||||
configPanel.getByText('Bindable Event Range', { exact: true }).last(),
|
||||
).toBeVisible();
|
||||
await flow.getByRole('tab').nth(3).click();
|
||||
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 ({
|
||||
@@ -99,16 +196,44 @@ test.describe('processor detail workbench', () => {
|
||||
});
|
||||
|
||||
await page.goto('/home/agents?id=agent-workbench');
|
||||
await page.getByLabel('Description').fill('Updated before debugging');
|
||||
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: 'Save and run' }).click();
|
||||
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,
|
||||
}) => {
|
||||
@@ -151,6 +276,31 @@ test.describe('processor detail workbench', () => {
|
||||
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));
|
||||
@@ -163,6 +313,21 @@ test.describe('processor detail 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,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -173,19 +338,117 @@ test.describe('processor detail workbench', () => {
|
||||
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();
|
||||
await debugPanel
|
||||
.getByRole('button', { name: 'Reset Conversation' })
|
||||
.click();
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user