mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-27 04:37:13 +00:00
Compare commits
27 Commits
6a6a2b865b
...
dev/4.11.x
| Author | SHA1 | Date | |
|---|---|---|---|
| 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -275,15 +285,48 @@ export default function AgentDebugPanel({
|
||||
<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">
|
||||
<SelectTrigger
|
||||
className="w-full"
|
||||
aria-label={t('agents.debugEventType')}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availablePresets.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{t(item.labelKey)}
|
||||
</SelectItem>
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -22,6 +22,8 @@ export interface ProcessorDetailStatus {
|
||||
|
||||
interface ProcessorDetailWorkbenchProps {
|
||||
title: string;
|
||||
titleAction?: ReactNode;
|
||||
headerActions?: ReactNode;
|
||||
status?: ProcessorDetailStatus | null;
|
||||
saveLabel: string;
|
||||
saveFormId: string;
|
||||
@@ -41,6 +43,8 @@ interface ProcessorDetailWorkbenchProps {
|
||||
|
||||
export default function ProcessorDetailWorkbench({
|
||||
title,
|
||||
titleAction,
|
||||
headerActions,
|
||||
status,
|
||||
saveLabel,
|
||||
saveFormId,
|
||||
@@ -67,6 +71,7 @@ export default function ProcessorDetailWorkbench({
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 pb-4">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<h1 className="truncate text-xl font-semibold">{title}</h1>
|
||||
{titleAction}
|
||||
{status && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -135,11 +140,15 @@ 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">
|
||||
<section
|
||||
aria-label={monitoring.label}
|
||||
className="min-h-0 flex-1 overflow-y-auto rounded-xl border bg-card p-4"
|
||||
>
|
||||
{monitoring.content}
|
||||
</section>
|
||||
) : (
|
||||
|
||||
@@ -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,91 @@ 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'),
|
||||
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',
|
||||
@@ -689,7 +712,7 @@ const enUS = {
|
||||
allEvents: 'Supports all events',
|
||||
messageEventsOnly: 'Message events only',
|
||||
basicInfo: 'Basic Information',
|
||||
basicInfoDescription: 'Set the name, icon, description and enabled state',
|
||||
basicInfoDescription: 'Set the name, icon and description',
|
||||
runnerSettings: 'Runner',
|
||||
advanced: 'Advanced',
|
||||
bindableEvents: 'Bindable Event Range',
|
||||
@@ -697,10 +720,9 @@ const enUS = {
|
||||
'Limit which bot event routes can select this Agent. The default is suitable for most cases.',
|
||||
supportedEvents: 'Event Range',
|
||||
supportedEventsDescription:
|
||||
'Use one event pattern per line, for example *, message.received, group.*. Pipelines are fixed to message.*.',
|
||||
enabled: 'Enable Agent',
|
||||
enabledDescription:
|
||||
'When disabled, this Agent should not be selected by event routing.',
|
||||
'Choose all events, an event group, or individual events. Bot routes will only list this Agent for matching events.',
|
||||
searchEvents: 'Search events…',
|
||||
noEventsFound: 'No matching events found',
|
||||
nameRequired: 'Name cannot be empty',
|
||||
createSuccess: 'Created successfully',
|
||||
createError: 'Creation failed: ',
|
||||
@@ -1235,6 +1257,7 @@ const enUS = {
|
||||
earliestEdited: 'Earliest Edited',
|
||||
basicInfo: 'Basic Information',
|
||||
basicInfoDescription: 'Set the pipeline name, icon and description',
|
||||
managementDescription: 'Copy or delete this pipeline.',
|
||||
aiCapabilities: 'AI',
|
||||
triggerConditions: 'Trigger',
|
||||
safetyControls: 'Safety',
|
||||
@@ -1354,8 +1377,8 @@ const enUS = {
|
||||
atTips: 'Mention the bot',
|
||||
streaming: 'Streaming',
|
||||
streamOutput: 'Stream',
|
||||
connected: 'WebSocket connected',
|
||||
disconnected: 'WebSocket disconnected',
|
||||
connected: 'Connected',
|
||||
disconnected: 'Disconnected',
|
||||
connectionError: 'WebSocket connection error',
|
||||
connectionFailed: 'WebSocket connection failed',
|
||||
notConnected: 'WebSocket not connected, please try again later',
|
||||
|
||||
@@ -525,8 +525,7 @@ const esES = {
|
||||
allEvents: 'Compatible con todos los eventos',
|
||||
messageEventsOnly: 'Solo eventos de mensaje',
|
||||
basicInfo: 'Información básica',
|
||||
basicInfoDescription:
|
||||
'Establece el nombre, icono, descripción y estado de habilitación',
|
||||
basicInfoDescription: 'Establece el nombre, icono y descripción',
|
||||
runnerSettings: 'Runner',
|
||||
advanced: 'Avanzado',
|
||||
bindableEvents: 'Rango de eventos vinculables',
|
||||
@@ -534,10 +533,9 @@ const esES = {
|
||||
'Limita qué rutas de eventos del bot pueden seleccionar este Agent. El valor predeterminado sirve para la mayoría de los casos.',
|
||||
supportedEvents: 'Rango de eventos',
|
||||
supportedEventsDescription:
|
||||
'Usa un patrón de evento por línea, por ejemplo *, message.received, group.*. Los Pipelines están fijos en message.*.',
|
||||
enabled: 'Habilitar Agent',
|
||||
enabledDescription:
|
||||
'Cuando está deshabilitado, este Agent no debe ser seleccionado por el enrutamiento de eventos.',
|
||||
'Elige todos los eventos, un grupo o eventos concretos. Las rutas del bot solo mostrarán este Agent para los eventos compatibles.',
|
||||
searchEvents: 'Buscar eventos…',
|
||||
noEventsFound: 'No se encontraron eventos',
|
||||
nameRequired: 'El nombre no puede estar vacío',
|
||||
createSuccess: 'Creado correctamente',
|
||||
createError: 'Error al crear: ',
|
||||
@@ -1137,8 +1135,8 @@ const esES = {
|
||||
atTips: 'Mencionar al Bot',
|
||||
streaming: 'Transmisión',
|
||||
streamOutput: 'Transmisión',
|
||||
connected: 'WebSocket conectado',
|
||||
disconnected: 'WebSocket desconectado',
|
||||
connected: 'Conectado',
|
||||
disconnected: 'Desconectado',
|
||||
connectionError: 'Error de conexión WebSocket',
|
||||
connectionFailed: 'Conexión WebSocket fallida',
|
||||
notConnected: 'WebSocket no conectado, por favor inténtalo más tarde',
|
||||
|
||||
@@ -43,6 +43,10 @@ const jaJP = {
|
||||
joinDiscord: 'Discord に参加',
|
||||
create: '作成',
|
||||
edit: '編集',
|
||||
editBasicInfo: '基本情報を編集',
|
||||
editBasicInfoDescription: '名前、説明、アイコンを変更します。',
|
||||
editBasicInfoDescriptionNoIcon: '名前と説明を変更します。',
|
||||
management: '管理',
|
||||
delete: '削除',
|
||||
add: '追加',
|
||||
select: '選択してください',
|
||||
@@ -390,11 +394,12 @@ const jaJP = {
|
||||
routingConnectionDescription:
|
||||
'このボットのメッセージを処理するパイプラインを紐付け',
|
||||
eventRouting: 'イベントルーティング',
|
||||
eventRoutingDescription:
|
||||
'このボットが受信した各イベントをどのプロセッサーに渡すかを選択します。対応する Agent または Pipeline の設定で処理ロジックを編集します。Pipeline はメッセージイベントのみ対応します。',
|
||||
eventRoutingDescription: 'イベントごとの処理先を設定します。',
|
||||
eventBindings: 'イベントルート',
|
||||
addEventBinding: 'ルートを追加',
|
||||
addBehavior: '動作を追加',
|
||||
commonScenarios: 'よく使うシーン',
|
||||
dragEventRoute: 'ルート {{index}} をドラッグ',
|
||||
behaviorReplyMessages: '受信メッセージに返信',
|
||||
behaviorReplyMessagesDescription:
|
||||
'受信メッセージを Agent または Pipeline で処理します。',
|
||||
@@ -432,21 +437,50 @@ const jaJP = {
|
||||
disable: '無効化',
|
||||
enable: '有効化',
|
||||
disabledBindings: '無効',
|
||||
adapterEventsTitle: 'このアダプターが受信できるイベント',
|
||||
adapterEventsDescription:
|
||||
'{{count}} 種類のイベントを利用できます。ルートは上から順に照合され、未一致のイベントはプロセッサーへ送られません。',
|
||||
adapterEventsTitle: '対応イベント',
|
||||
adapterEventsDescription: '{{count}} 種類',
|
||||
adapterEventsMore: 'ほか {{count}} 件',
|
||||
advancedEventValues: '高度なイベント値',
|
||||
advancedEventValues: 'すべて表示',
|
||||
eventGroup: 'グループ',
|
||||
eventGroupNames: {
|
||||
bot: 'ボットの状態',
|
||||
feedback: 'フィードバック',
|
||||
friend: '友だち',
|
||||
group: 'グループ',
|
||||
message: 'メッセージ',
|
||||
platform: 'プラットフォーム',
|
||||
},
|
||||
routeConflictTitle: '一部のルートが重複しています',
|
||||
routeConflictShadowed:
|
||||
'{{winner}} が同じイベントを先に処理するため、{{shadowed}} は実行されない可能性があります。',
|
||||
routeConflictMore: 'ほか {{count}} 件のルート競合を確認してください。',
|
||||
routeFallbackCatchAll:
|
||||
'{{route}} はすべてのイベントを受けるフォールバックです。優先度の高いルートが先に実行されます。',
|
||||
routeFallbackCatchAll: '{{route}} はフォールバックルートです。',
|
||||
routeFallbackIgnored:
|
||||
'どのルートにも一致しないイベントは無視されます。すべてのイベントに明示的な結果が必要な場合のみ、フォールバックを追加してください。',
|
||||
testRoute: 'ルートをテスト',
|
||||
testRoute: 'ルートを確認',
|
||||
adapterEventDebugAction: 'プラットフォームイベントを監視',
|
||||
adapterEventDebugTitle: 'プラットフォームイベントのデバッグ',
|
||||
adapterEventDebugDescription:
|
||||
'{{platform}} でイベントを発生させると、アダプターの受信後にここへ表示されます。',
|
||||
adapterEventObserveOnly:
|
||||
'この画面はイベントを監視するだけです。受信イベントは現在のルートで通常どおり処理されます。',
|
||||
adapterEventPreparing: '準備中',
|
||||
adapterEventListening: '監視中',
|
||||
adapterEventListenerUnavailable: '監視が中断されました',
|
||||
adapterEventLoadFailed:
|
||||
'プラットフォームイベントを取得できません。ボットが起動していることを確認して、もう一度お試しください。',
|
||||
adapterEventReceivedCount: '{{count}} 件のイベントを受信',
|
||||
adapterEventClear: 'クリア',
|
||||
adapterEventEmptyTitle: 'プラットフォームイベントを待機中',
|
||||
adapterEventEmptyDescription:
|
||||
'{{platform}} でメッセージを送るか、イベントを発生させてください。',
|
||||
adapterEventData: 'イベントデータを表示',
|
||||
adapterEventNeedsSavedBot:
|
||||
'プラットフォームイベントを監視する前にボットを保存してください。',
|
||||
adapterEventCurrentPlatform: '現在のプラットフォーム',
|
||||
adapterConfigurationTest: 'アダプター設定をテスト',
|
||||
adapterConfigurationTestDescription:
|
||||
'先にボットを保存して有効にし、プラットフォームでイベントを発生させて設定を確認します。',
|
||||
refreshRouteStatus: '状態を更新',
|
||||
routeStatusIdle: '実行記録なし',
|
||||
routeStatusRefreshFailed: 'ルート状態の更新に失敗しました。',
|
||||
@@ -456,7 +490,6 @@ const jaJP = {
|
||||
discarded: '破棄済み',
|
||||
failed: '失敗',
|
||||
not_matched: '未一致',
|
||||
test_started: 'テスト中',
|
||||
},
|
||||
routeStatusDetail: {
|
||||
matched: 'このルートがイベントに一致しました。',
|
||||
@@ -464,7 +497,6 @@ const jaJP = {
|
||||
discarded: '設定に従ってイベントを破棄しました。',
|
||||
failed: 'ルートを完了できませんでした。',
|
||||
not_matched: '設定済みルートに一致しませんでした。',
|
||||
test_started: '保存済みルートを実行しています。',
|
||||
},
|
||||
routeFailure: {
|
||||
binding_disabled: 'このルートは無効です。',
|
||||
@@ -475,37 +507,26 @@ const jaJP = {
|
||||
processor_incompatible:
|
||||
'選択したプロセッサーはこのイベントを処理できません。',
|
||||
processor_not_found: '選択したプロセッサーを利用できません。',
|
||||
processor_disabled: '選択したプロセッサーは無効です。',
|
||||
runner_failed: 'Agent Runner がイベント処理中に失敗しました。',
|
||||
delivery_failed: '処理は完了しましたが、結果の配信に失敗しました。',
|
||||
},
|
||||
routeTestAction: '保存済みルートを実行',
|
||||
routeTestRunning: '実行中…',
|
||||
routeTestFailed:
|
||||
'保存済みルートの実行に失敗しました。後でもう一度お試しください。',
|
||||
routeTestDispatched:
|
||||
'保存済みルートを実行しました。{{count}} 件のプラットフォーム操作を抑制しました。',
|
||||
routeTestSideEffectWarning:
|
||||
'保存済みルートを実行するとプロセッサーが動作します。プラットフォームのメッセージ操作は抑制されますが、ツールや外部サービスには副作用が生じる場合があります。',
|
||||
dryRunTitle: 'イベントルートをテスト',
|
||||
dryRunDescription:
|
||||
'プロセッサーを実行せずに現在のフォームをプレビューするか、保存済みルートを実行して設定を検証します。',
|
||||
dryRunTitle: 'イベントルートを確認',
|
||||
dryRunDescription: 'イベントを選び、一致するルートと処理先を確認します。',
|
||||
dryRunEventType: 'イベントタイプ',
|
||||
dryRunSampleReady: 'サンプルイベントを準備しました',
|
||||
dryRunSampleDescription:
|
||||
'LangBot が「{{event}}」用のサンプルデータを準備しました。通常はそのままテストできます。',
|
||||
dryRunEditPayload: '詳細データを編集',
|
||||
dryRunHidePayload: '詳細データを閉じる',
|
||||
dryRunPayload: '詳細イベントデータ(JSON)',
|
||||
dryRunPayloadHint:
|
||||
'message_text、chat_type、chat_id などの簡単なフィールドで条件をテストできます。',
|
||||
dryRunEditPayload: 'テストデータ',
|
||||
dryRunHidePayload: 'データを閉じる',
|
||||
dryRunPayload: 'テストデータ(JSON)',
|
||||
dryRunPayloadHint: 'メッセージや会話の条件テストに使用します。',
|
||||
dryRunPayloadJsonError: '有効な JSON を入力してください。',
|
||||
dryRunPayloadObjectError:
|
||||
'ペイロードは JSON オブジェクトである必要があります。',
|
||||
dryRunNeedsSavedBot: 'ルートをテストする前にボットを保存してください。',
|
||||
dryRunFailed: 'ルートテストに失敗しました。後でもう一度お試しください。',
|
||||
dryRunAction: 'ルートをプレビュー',
|
||||
dryRunRunning: 'テスト中…',
|
||||
dryRunNeedsSavedBot: 'ルートを確認する前にボットを保存してください。',
|
||||
dryRunFailed: 'ルートを確認できませんでした。後でもう一度お試しください。',
|
||||
dryRunAction: '一致結果を確認',
|
||||
dryRunRunning: '確認中…',
|
||||
dryRunMatched: 'ルートに一致しました',
|
||||
dryRunNotMatched: '一致するルートはありません',
|
||||
dryRunTarget: '対象プロセッサー',
|
||||
@@ -704,7 +725,7 @@ const jaJP = {
|
||||
allEvents: 'すべてのイベントに対応',
|
||||
messageEventsOnly: 'メッセージイベントのみ',
|
||||
basicInfo: '基本情報',
|
||||
basicInfoDescription: '名前、アイコン、説明、有効状態を設定します',
|
||||
basicInfoDescription: '名前、アイコン、説明を設定します',
|
||||
runnerSettings: 'Runner',
|
||||
advanced: '詳細',
|
||||
bindableEvents: '紐付け可能なイベント範囲',
|
||||
@@ -712,10 +733,9 @@ const jaJP = {
|
||||
'この Agent を選択できるボットイベントルートの範囲を制限します。通常は既定値のままで問題ありません。',
|
||||
supportedEvents: 'イベント範囲',
|
||||
supportedEventsDescription:
|
||||
'1 行に 1 つのイベントパターンを指定します。例: *、message.received、group.*。Pipeline は message.* 固定です。',
|
||||
enabled: 'Agent を有効化',
|
||||
enabledDescription:
|
||||
'無効化すると、この Agent はイベントルーティングで選択されません。',
|
||||
'すべてのイベント、イベントグループ、または個別のイベントを選択します。ボットルートでは一致するイベントにのみこの Agent が表示されます。',
|
||||
searchEvents: 'イベントを検索…',
|
||||
noEventsFound: '一致するイベントがありません',
|
||||
nameRequired: '名前は必須です',
|
||||
createSuccess: '作成に成功しました',
|
||||
createError: '作成に失敗しました:',
|
||||
@@ -1200,6 +1220,7 @@ const jaJP = {
|
||||
earliestEdited: '最古編集',
|
||||
basicInfo: '基本情報',
|
||||
basicInfoDescription: 'パイプラインの名前、アイコン、説明を設定',
|
||||
managementDescription: 'このパイプラインを複製または削除します。',
|
||||
aiCapabilities: 'AI機能',
|
||||
triggerConditions: 'トリガー条件',
|
||||
safetyControls: '安全制御',
|
||||
@@ -1318,8 +1339,8 @@ const jaJP = {
|
||||
atTips: 'ボットをメンション',
|
||||
streaming: 'ストリーミング',
|
||||
streamOutput: 'ストリーム',
|
||||
connected: 'WebSocket接続済み',
|
||||
disconnected: 'WebSocket未接続',
|
||||
connected: '接続済み',
|
||||
disconnected: '未接続',
|
||||
connectionError: 'WebSocket接続エラー',
|
||||
connectionFailed: 'WebSocket接続に失敗しました',
|
||||
notConnected:
|
||||
|
||||
@@ -522,7 +522,7 @@ const ruRU = {
|
||||
allEvents: 'Поддерживает все события',
|
||||
messageEventsOnly: 'Только события сообщений',
|
||||
basicInfo: 'Основная информация',
|
||||
basicInfoDescription: 'Задайте имя, иконку, описание и статус активации',
|
||||
basicInfoDescription: 'Задайте имя, иконку и описание',
|
||||
runnerSettings: 'Runner',
|
||||
advanced: 'Дополнительно',
|
||||
bindableEvents: 'Диапазон привязываемых событий',
|
||||
@@ -530,10 +530,9 @@ const ruRU = {
|
||||
'Ограничьте, какие маршруты событий бота могут выбирать этот Agent. Обычно достаточно значения по умолчанию.',
|
||||
supportedEvents: 'Диапазон событий',
|
||||
supportedEventsDescription:
|
||||
'Один шаблон события в строке, например *, message.received, group.*. Pipeline фиксирован на message.*.',
|
||||
enabled: 'Включить Agent',
|
||||
enabledDescription:
|
||||
'При отключении этот Agent не должен выбираться маршрутизацией событий.',
|
||||
'Выберите все события, группу или отдельные события. В маршрутах бота этот Agent будет доступен только для подходящих событий.',
|
||||
searchEvents: 'Поиск событий…',
|
||||
noEventsFound: 'Подходящие события не найдены',
|
||||
nameRequired: 'Имя не может быть пустым',
|
||||
createSuccess: 'Успешно создано',
|
||||
createError: 'Ошибка создания: ',
|
||||
@@ -1125,8 +1124,8 @@ const ruRU = {
|
||||
atTips: 'Упомянуть бота',
|
||||
streaming: 'Потоковая передача',
|
||||
streamOutput: 'Поток',
|
||||
connected: 'WebSocket подключён',
|
||||
disconnected: 'WebSocket отключён',
|
||||
connected: 'Подключено',
|
||||
disconnected: 'Отключено',
|
||||
connectionError: 'Ошибка подключения WebSocket',
|
||||
connectionFailed: 'Ошибка подключения WebSocket',
|
||||
notConnected: 'WebSocket не подключён, повторите попытку позже',
|
||||
|
||||
@@ -508,7 +508,7 @@ const thTH = {
|
||||
allEvents: 'รองรับทุกเหตุการณ์',
|
||||
messageEventsOnly: 'เฉพาะเหตุการณ์ข้อความ',
|
||||
basicInfo: 'ข้อมูลพื้นฐาน',
|
||||
basicInfoDescription: 'ตั้งชื่อ ไอคอน คำอธิบาย และสถานะการเปิดใช้งาน',
|
||||
basicInfoDescription: 'ตั้งชื่อ ไอคอน และคำอธิบาย',
|
||||
runnerSettings: 'Runner',
|
||||
advanced: 'ขั้นสูง',
|
||||
bindableEvents: 'ช่วงเหตุการณ์ที่ผูกได้',
|
||||
@@ -516,10 +516,9 @@ const thTH = {
|
||||
'จำกัดว่าเส้นทางเหตุการณ์ของบอทใดสามารถเลือก Agent นี้ได้ ค่าเริ่มต้นเหมาะกับกรณีส่วนใหญ่',
|
||||
supportedEvents: 'ช่วงเหตุการณ์',
|
||||
supportedEventsDescription:
|
||||
'หนึ่งรูปแบบเหตุการณ์ต่อบรรทัด เช่น *, message.received, group.* Pipeline ถูกกำหนดไว้ที่ message.*',
|
||||
enabled: 'เปิดใช้งาน Agent',
|
||||
enabledDescription:
|
||||
'เมื่อปิดใช้งาน Agent นี้จะไม่ถูกเลือกโดยการกำหนดเส้นทางเหตุการณ์',
|
||||
'เลือกเหตุการณ์ทั้งหมด กลุ่มเหตุการณ์ หรือเหตุการณ์ที่ต้องการ เส้นทางบอทจะแสดง Agent นี้เฉพาะเหตุการณ์ที่ตรงกัน',
|
||||
searchEvents: 'ค้นหาเหตุการณ์…',
|
||||
noEventsFound: 'ไม่พบเหตุการณ์ที่ตรงกัน',
|
||||
nameRequired: 'ชื่อต้องไม่ว่างเปล่า',
|
||||
createSuccess: 'สร้างสำเร็จ',
|
||||
createError: 'สร้างล้มเหลว: ',
|
||||
@@ -1100,8 +1099,8 @@ const thTH = {
|
||||
atTips: 'กล่าวถึง Bot',
|
||||
streaming: 'สตรีมมิ่ง',
|
||||
streamOutput: 'สตรีม',
|
||||
connected: 'เชื่อมต่อ WebSocket แล้ว',
|
||||
disconnected: 'ยกเลิกการเชื่อมต่อ WebSocket',
|
||||
connected: 'เชื่อมต่อแล้ว',
|
||||
disconnected: 'ไม่ได้เชื่อมต่อ',
|
||||
connectionError: 'ข้อผิดพลาดการเชื่อมต่อ WebSocket',
|
||||
connectionFailed: 'เชื่อมต่อ WebSocket ล้มเหลว',
|
||||
notConnected: 'ยังไม่ได้เชื่อมต่อ WebSocket กรุณาลองใหม่ภายหลัง',
|
||||
|
||||
@@ -518,7 +518,7 @@ const viVN = {
|
||||
allEvents: 'Hỗ trợ tất cả sự kiện',
|
||||
messageEventsOnly: 'Chỉ sự kiện tin nhắn',
|
||||
basicInfo: 'Thông tin cơ bản',
|
||||
basicInfoDescription: 'Đặt tên, biểu tượng, mô tả và trạng thái kích hoạt',
|
||||
basicInfoDescription: 'Đặt tên, biểu tượng và mô tả',
|
||||
runnerSettings: 'Runner',
|
||||
advanced: 'Nâng cao',
|
||||
bindableEvents: 'Phạm vi sự kiện có thể gắn',
|
||||
@@ -526,10 +526,9 @@ const viVN = {
|
||||
'Giới hạn những tuyến sự kiện bot có thể chọn Agent này. Mặc định phù hợp với hầu hết trường hợp.',
|
||||
supportedEvents: 'Phạm vi sự kiện',
|
||||
supportedEventsDescription:
|
||||
'Mỗi dòng một mẫu sự kiện, ví dụ *, message.received, group.*. Pipeline cố định ở message.*.',
|
||||
enabled: 'Kích hoạt Agent',
|
||||
enabledDescription:
|
||||
'Khi bị tắt, Agent này sẽ không được định tuyến sự kiện chọn.',
|
||||
'Chọn tất cả sự kiện, một nhóm hoặc từng sự kiện. Tuyến bot chỉ hiển thị Agent này cho các sự kiện phù hợp.',
|
||||
searchEvents: 'Tìm sự kiện…',
|
||||
noEventsFound: 'Không tìm thấy sự kiện phù hợp',
|
||||
nameRequired: 'Tên không được để trống',
|
||||
createSuccess: 'Tạo thành công',
|
||||
createError: 'Tạo thất bại: ',
|
||||
@@ -1117,8 +1116,8 @@ const viVN = {
|
||||
atTips: 'Nhắc đến Bot',
|
||||
streaming: 'Đang truyền',
|
||||
streamOutput: 'Luồng',
|
||||
connected: 'WebSocket đã kết nối',
|
||||
disconnected: 'WebSocket đã ngắt kết nối',
|
||||
connected: 'Đã kết nối',
|
||||
disconnected: 'Đã ngắt kết nối',
|
||||
connectionError: 'Lỗi kết nối WebSocket',
|
||||
connectionFailed: 'Kết nối WebSocket thất bại',
|
||||
notConnected: 'WebSocket chưa kết nối, vui lòng thử lại sau',
|
||||
|
||||
@@ -41,6 +41,10 @@ const zhHans = {
|
||||
joinDiscord: '加入 Discord 社区',
|
||||
create: '创建',
|
||||
edit: '编辑',
|
||||
editBasicInfo: '编辑基本信息',
|
||||
editBasicInfoDescription: '修改名称、描述和图标。',
|
||||
editBasicInfoDescriptionNoIcon: '修改名称和描述。',
|
||||
management: '管理',
|
||||
delete: '删除',
|
||||
add: '添加',
|
||||
select: '请选择',
|
||||
@@ -367,11 +371,12 @@ const zhHans = {
|
||||
routingConnection: '路由与连接',
|
||||
routingConnectionDescription: '绑定处理此机器人消息的流水线',
|
||||
eventRouting: '事件路由',
|
||||
eventRoutingDescription:
|
||||
'选择此机器人收到不同事件时交给哪个处理器。在对应的 Agent 或 Pipeline 配置中编辑处理逻辑;Pipeline 仅支持消息事件。',
|
||||
eventRoutingDescription: '设置收到事件后交给哪个处理器。',
|
||||
eventBindings: '事件路由',
|
||||
addEventBinding: '添加路由',
|
||||
addBehavior: '添加行为',
|
||||
commonScenarios: '常用场景',
|
||||
dragEventRoute: '拖动第 {{index}} 条路由',
|
||||
behaviorReplyMessages: '回复收到的消息',
|
||||
behaviorReplyMessagesDescription:
|
||||
'把收到的消息交给 Agent 或 Pipeline 处理。',
|
||||
@@ -404,21 +409,48 @@ const zhHans = {
|
||||
disable: '禁用',
|
||||
enable: '启用',
|
||||
disabledBindings: '已禁用',
|
||||
adapterEventsTitle: '此适配器可接收的事件',
|
||||
adapterEventsDescription:
|
||||
'已识别 {{count}} 类事件。路由会按顺序匹配,未命中时不会交给处理器。',
|
||||
adapterEventsTitle: '支持的事件',
|
||||
adapterEventsDescription: '共 {{count}} 类',
|
||||
adapterEventsMore: '另有 {{count}} 类',
|
||||
advancedEventValues: '高级事件值',
|
||||
advancedEventValues: '查看全部',
|
||||
eventGroup: '事件组',
|
||||
eventGroupNames: {
|
||||
bot: '机器人状态',
|
||||
feedback: '反馈',
|
||||
friend: '好友',
|
||||
group: '群组',
|
||||
message: '消息',
|
||||
platform: '平台',
|
||||
},
|
||||
routeConflictTitle: '部分路由存在覆盖冲突',
|
||||
routeConflictShadowed:
|
||||
'{{shadowed}} 可能永远不会运行,因为 {{winner}} 会先处理相同事件。',
|
||||
routeConflictMore: '另有 {{count}} 个路由冲突需要处理。',
|
||||
routeFallbackCatchAll:
|
||||
'{{route}} 是全局兜底路由,优先级更高的路由会先运行。',
|
||||
routeFallbackCatchAll: '{{route}} 是兜底路由。',
|
||||
routeFallbackIgnored:
|
||||
'未命中任何路由的事件会被忽略。只有需要为每个事件指定结果时,才添加全局兜底路由。',
|
||||
testRoute: '测试路由',
|
||||
testRoute: '检查路由',
|
||||
adapterEventDebugAction: '监听平台事件',
|
||||
adapterEventDebugTitle: '平台事件调试',
|
||||
adapterEventDebugDescription:
|
||||
'在 {{platform}} 中触发事件,适配器收到后会显示在这里。',
|
||||
adapterEventObserveOnly:
|
||||
'此窗口只负责观察;收到的事件仍会按当前路由正常处理。',
|
||||
adapterEventPreparing: '准备中',
|
||||
adapterEventListening: '正在监听',
|
||||
adapterEventListenerUnavailable: '监听已中断',
|
||||
adapterEventLoadFailed:
|
||||
'无法读取平台事件。请确认机器人正在运行,然后稍后重试。',
|
||||
adapterEventReceivedCount: '已收到 {{count}} 个事件',
|
||||
adapterEventClear: '清空',
|
||||
adapterEventEmptyTitle: '等待平台事件',
|
||||
adapterEventEmptyDescription: '请在 {{platform}} 中发送消息或触发事件。',
|
||||
adapterEventData: '查看事件数据',
|
||||
adapterEventNeedsSavedBot: '请先保存机器人后再监听平台事件。',
|
||||
adapterEventCurrentPlatform: '当前平台',
|
||||
adapterConfigurationTest: '适配器测试',
|
||||
adapterConfigurationTestDescription:
|
||||
'请先保存并启用机器人,再到当前平台触发事件,确认这组配置是否已经生效。',
|
||||
refreshRouteStatus: '刷新状态',
|
||||
routeStatusIdle: '暂无运行记录',
|
||||
routeStatusRefreshFailed: '刷新路由状态失败。',
|
||||
@@ -428,7 +460,6 @@ const zhHans = {
|
||||
discarded: '已丢弃',
|
||||
failed: '失败',
|
||||
not_matched: '未命中',
|
||||
test_started: '测试中',
|
||||
},
|
||||
routeStatusDetail: {
|
||||
matched: '此路由已命中事件。',
|
||||
@@ -436,7 +467,6 @@ const zhHans = {
|
||||
discarded: '此事件已按路由配置丢弃。',
|
||||
failed: '此路由未能完成。',
|
||||
not_matched: '没有已配置路由命中此事件。',
|
||||
test_started: '正在运行已保存路由。',
|
||||
},
|
||||
routeFailure: {
|
||||
binding_disabled: '此路由已禁用。',
|
||||
@@ -446,34 +476,25 @@ const zhHans = {
|
||||
route_not_found: '没有路由命中此事件。',
|
||||
processor_incompatible: '所选处理器无法处理此事件。',
|
||||
processor_not_found: '所选处理器不可用。',
|
||||
processor_disabled: '所选处理器已禁用。',
|
||||
runner_failed: 'Agent Runner 处理事件时失败。',
|
||||
delivery_failed: '处理器已完成,但结果投递失败。',
|
||||
},
|
||||
routeTestAction: '运行已保存路由',
|
||||
routeTestRunning: '运行中…',
|
||||
routeTestFailed: '运行已保存路由失败,请稍后重试。',
|
||||
routeTestDispatched: '已保存路由运行成功,{{count}} 个平台操作已被阻止。',
|
||||
routeTestSideEffectWarning:
|
||||
'运行已保存路由会执行处理器。平台消息操作会被阻止,但工具和外部服务仍可能产生副作用。',
|
||||
dryRunTitle: '测试事件路由',
|
||||
dryRunDescription:
|
||||
'可以先预览当前表单而不运行处理器,也可以运行已保存路由来验证线上配置。',
|
||||
dryRunTitle: '检查事件路由',
|
||||
dryRunDescription: '选择事件,查看它会匹配哪条路由、交给哪个处理器。',
|
||||
dryRunEventType: '事件类型',
|
||||
dryRunSampleReady: '示例事件已准备好',
|
||||
dryRunSampleDescription:
|
||||
'LangBot 已为“{{event}}”准备示例数据,大多数路由测试可直接使用。',
|
||||
dryRunEditPayload: '编辑高级数据',
|
||||
dryRunHidePayload: '收起高级数据',
|
||||
dryRunPayload: '高级事件数据(JSON)',
|
||||
dryRunPayloadHint:
|
||||
'可填写 message_text、chat_type、chat_id 等简单字段,用于匹配触发条件。',
|
||||
dryRunEditPayload: '测试数据',
|
||||
dryRunHidePayload: '收起数据',
|
||||
dryRunPayload: '测试数据(JSON)',
|
||||
dryRunPayloadHint: '用于测试消息内容、会话类型等条件。',
|
||||
dryRunPayloadJsonError: '请输入合法 JSON。',
|
||||
dryRunPayloadObjectError: '载荷必须是 JSON 对象。',
|
||||
dryRunNeedsSavedBot: '请先保存机器人后再测试路由。',
|
||||
dryRunFailed: '路由测试失败,请稍后重试。',
|
||||
dryRunAction: '预览路由',
|
||||
dryRunRunning: '测试中…',
|
||||
dryRunNeedsSavedBot: '请先保存机器人后再检查路由。',
|
||||
dryRunFailed: '无法检查路由,请稍后重试。',
|
||||
dryRunAction: '查看匹配结果',
|
||||
dryRunRunning: '检查中…',
|
||||
dryRunMatched: '已命中路由',
|
||||
dryRunNotMatched: '未命中路由',
|
||||
dryRunTarget: '目标处理器',
|
||||
@@ -660,7 +681,7 @@ const zhHans = {
|
||||
allEvents: '支持全部事件',
|
||||
messageEventsOnly: '仅支持消息事件',
|
||||
basicInfo: '基础信息',
|
||||
basicInfoDescription: '设置名称、图标、描述和启用状态',
|
||||
basicInfoDescription: '设置名称、图标和描述',
|
||||
runnerSettings: '运行器',
|
||||
advanced: '高级',
|
||||
bindableEvents: '可绑定事件范围',
|
||||
@@ -668,9 +689,9 @@ const zhHans = {
|
||||
'限制此 Agent 可被机器人事件路由选择的事件范围。通常保持默认即可。',
|
||||
supportedEvents: '事件范围',
|
||||
supportedEventsDescription:
|
||||
'每行一个事件模式,例如 *、message.received、group.*。Pipeline 固定仅支持 message.*。',
|
||||
enabled: '启用 Agent',
|
||||
enabledDescription: '禁用后,此 Agent 不应被事件路由选中。',
|
||||
'选择全部事件、事件组或具体事件。机器人路由只会在匹配的事件中显示此 Agent。',
|
||||
searchEvents: '搜索事件…',
|
||||
noEventsFound: '没有匹配的事件',
|
||||
nameRequired: '名称不能为空',
|
||||
createSuccess: '创建成功',
|
||||
createError: '创建失败:',
|
||||
@@ -1178,6 +1199,7 @@ const zhHans = {
|
||||
earliestEdited: '最早编辑',
|
||||
basicInfo: '基础信息',
|
||||
basicInfoDescription: '设置流水线名称、图标和描述',
|
||||
managementDescription: '复制或删除此流水线。',
|
||||
aiCapabilities: 'AI 能力',
|
||||
triggerConditions: '触发条件',
|
||||
safetyControls: '安全控制',
|
||||
@@ -1295,8 +1317,8 @@ const zhHans = {
|
||||
atTips: '提及机器人',
|
||||
streaming: '流式传输',
|
||||
streamOutput: '流式',
|
||||
connected: 'WebSocket已连接',
|
||||
disconnected: 'WebSocket未连接',
|
||||
connected: '已连接',
|
||||
disconnected: '未连接',
|
||||
connectionError: 'WebSocket连接错误',
|
||||
connectionFailed: 'WebSocket连接失败',
|
||||
notConnected: 'WebSocket未连接,请稍后重试',
|
||||
|
||||
@@ -491,7 +491,7 @@ const zhHant = {
|
||||
allEvents: '支援全部事件',
|
||||
messageEventsOnly: '僅支援訊息事件',
|
||||
basicInfo: '基本資訊',
|
||||
basicInfoDescription: '設定名稱、圖示、描述和啟用狀態',
|
||||
basicInfoDescription: '設定名稱、圖示和描述',
|
||||
runnerSettings: '執行器',
|
||||
advanced: '進階',
|
||||
bindableEvents: '可綁定事件範圍',
|
||||
@@ -499,9 +499,9 @@ const zhHant = {
|
||||
'限制此 Agent 可被機器人事件路由選擇的事件範圍。通常保持預設即可。',
|
||||
supportedEvents: '事件範圍',
|
||||
supportedEventsDescription:
|
||||
'每行一個事件模式,例如 *、message.received、group.*。Pipeline 固定僅支援 message.*。',
|
||||
enabled: '啟用 Agent',
|
||||
enabledDescription: '停用後,此 Agent 不應被事件路由選中。',
|
||||
'選擇全部事件、事件群組或具體事件。機器人路由只會在符合的事件中顯示此 Agent。',
|
||||
searchEvents: '搜尋事件…',
|
||||
noEventsFound: '沒有符合的事件',
|
||||
nameRequired: '名稱不能為空',
|
||||
createSuccess: '建立成功',
|
||||
createError: '建立失敗:',
|
||||
@@ -1066,8 +1066,8 @@ const zhHant = {
|
||||
atTips: '提及機器人',
|
||||
streaming: '串流傳輸',
|
||||
streamOutput: '串流',
|
||||
connected: 'WebSocket已連接',
|
||||
disconnected: 'WebSocket未連接',
|
||||
connected: '已連接',
|
||||
disconnected: '未連接',
|
||||
connectionError: 'WebSocket連接錯誤',
|
||||
connectionFailed: 'WebSocket連接失敗',
|
||||
notConnected: 'WebSocket未連接,請稍後重試',
|
||||
|
||||
@@ -116,7 +116,7 @@ test.describe('frontend CRUD smoke flows', () => {
|
||||
await expect(page.getByText('No logs yet')).toBeVisible();
|
||||
|
||||
await page.goto('/home/agents?id=pipeline-1');
|
||||
await expect(page.getByRole('tab', { name: 'Dashboard' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Dashboard' })).toBeVisible();
|
||||
await expect(page.getByRole('tab', { name: 'Debug Chat' })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: /^Save$/ })).toHaveCount(0);
|
||||
|
||||
@@ -144,15 +144,21 @@ test.describe('frontend CRUD smoke flows', () => {
|
||||
|
||||
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
|
||||
await page.reload();
|
||||
await expect(page.locator('input[name="name"]')).toHaveValue('Support Bot');
|
||||
|
||||
await page
|
||||
.locator('input[name="description"]')
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Support Bot' }),
|
||||
).toBeVisible();
|
||||
await expect(page.locator('input[name="name"]')).toHaveCount(0);
|
||||
await page.getByRole('button', { name: 'Edit basic information' }).click();
|
||||
const botInfoDialog = page.getByRole('dialog');
|
||||
await expect(botInfoDialog.getByLabel('Icon')).toHaveCount(0);
|
||||
await botInfoDialog.getByLabel('Name').fill('Support Bot Updated');
|
||||
await botInfoDialog
|
||||
.getByLabel('Description')
|
||||
.fill('Answers customer support questions with context.');
|
||||
await save(page);
|
||||
await expect(page.locator('input[name="description"]')).toHaveValue(
|
||||
'Answers customer support questions with context.',
|
||||
);
|
||||
await botInfoDialog.getByRole('button', { name: 'Save' }).click();
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Support Bot Updated' }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: /^Delete$/ }).click();
|
||||
await confirmDelete(page);
|
||||
@@ -176,18 +182,18 @@ test.describe('frontend CRUD smoke flows', () => {
|
||||
|
||||
await expect(page).toHaveURL(/\/home\/agents\?id=pipeline-1$/);
|
||||
await page.reload();
|
||||
await expect(page.locator('input[name="basic.name"]')).toHaveValue(
|
||||
'Escalation Pipeline',
|
||||
);
|
||||
|
||||
await page
|
||||
.locator('input[name="basic.description"]')
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /Escalation Pipeline/ }),
|
||||
).toBeVisible();
|
||||
await expect(page.locator('input[name="basic.name"]')).toHaveCount(0);
|
||||
await page.getByRole('button', { name: 'Edit basic information' }).click();
|
||||
const pipelineInfoDialog = page.getByRole('dialog');
|
||||
await pipelineInfoDialog
|
||||
.getByLabel('Description')
|
||||
.fill('Routes urgent customer issues to operators.');
|
||||
await save(page);
|
||||
await expect(page.locator('input[name="basic.description"]')).toHaveValue(
|
||||
'Routes urgent customer issues to operators.',
|
||||
);
|
||||
await pipelineInfoDialog.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Management' }).click();
|
||||
await page.getByRole('button', { name: /^Delete$/ }).click();
|
||||
await confirmDelete(page);
|
||||
|
||||
@@ -204,8 +210,10 @@ test.describe('frontend CRUD smoke flows', () => {
|
||||
|
||||
await page.goto('/home/agents?id=pipeline-ai');
|
||||
|
||||
await expect(page.locator('input[name="basic.name"]')).toBeVisible();
|
||||
await page.getByRole('button', { name: /^AI$/ }).click();
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /pipeline-ai/ }),
|
||||
).toBeVisible();
|
||||
await page.getByRole('tab', { name: /^AI$/ }).click();
|
||||
|
||||
await expect(page.getByText('Runtime')).toBeVisible();
|
||||
await expect(
|
||||
@@ -236,18 +244,25 @@ test.describe('frontend CRUD smoke flows', () => {
|
||||
|
||||
await expect(page).toHaveURL(/\/home\/knowledge\?id=knowledge-1$/);
|
||||
await page.reload();
|
||||
await expect(page.locator('input[name="name"]')).toHaveValue(
|
||||
'Support Knowledge',
|
||||
);
|
||||
await page.waitForTimeout(600);
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /Support Knowledge/ }),
|
||||
).toBeVisible();
|
||||
await expect(page.locator('input[name="name"]')).toHaveCount(0);
|
||||
const engineSettings = page.locator('[data-slot="card"]').filter({
|
||||
has: page.getByText('Engine Settings', { exact: true }),
|
||||
});
|
||||
await expect(engineSettings.getByRole('combobox')).toBeVisible();
|
||||
|
||||
await page
|
||||
.locator('input[name="description"]')
|
||||
await page.getByRole('button', { name: 'Edit basic information' }).click();
|
||||
const kbInfoDialog = page.getByRole('dialog');
|
||||
await kbInfoDialog.getByLabel('Name').fill('Support Knowledge Updated');
|
||||
await kbInfoDialog
|
||||
.getByLabel('Description')
|
||||
.fill('Updated source material for support answers.');
|
||||
await save(page);
|
||||
await expect(page.locator('input[name="description"]')).toHaveValue(
|
||||
'Updated source material for support answers.',
|
||||
);
|
||||
await kbInfoDialog.getByRole('button', { name: 'Save' }).click();
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /Support Knowledge Updated/ }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: /^Delete$/ }).click();
|
||||
await confirmDelete(page);
|
||||
@@ -335,6 +350,226 @@ test.describe('frontend CRUD smoke flows', () => {
|
||||
});
|
||||
|
||||
test.describe('bot advanced flows', () => {
|
||||
test('keeps event routing compact and hides raw status errors', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
withAdapterEvents: true,
|
||||
});
|
||||
await page.route('**/api/v1/platform/bots/*/event-routes/status', (route) =>
|
||||
route.fulfill({
|
||||
status: 500,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ code: -1, msg: 'Internal server error' }),
|
||||
}),
|
||||
);
|
||||
await page.route(
|
||||
'**/api/v1/platform/bots/*/event-routes/dry-run',
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
code: 0,
|
||||
msg: 'ok',
|
||||
data: {
|
||||
matched: true,
|
||||
event_type: 'message.received',
|
||||
matched_binding_id: 'binding-1',
|
||||
matched_binding_index: 0,
|
||||
target: {
|
||||
target_type: 'agent',
|
||||
target_uuid: 'agent-1',
|
||||
target_name: 'NewAgent',
|
||||
},
|
||||
diagnostic_steps: ['Matched route 1'],
|
||||
diagnostic_details: [],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
let botLogPollCount = 0;
|
||||
await page.route('**/api/v1/platform/bots/*/logs', (route) => {
|
||||
botLogPollCount += 1;
|
||||
const logs =
|
||||
botLogPollCount === 1
|
||||
? []
|
||||
: [
|
||||
{
|
||||
seq_id: 7,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
level: 'info',
|
||||
text: 'Platform adapter received message.received',
|
||||
images: [],
|
||||
message_session_id: '',
|
||||
metadata: {
|
||||
kind: 'adapter_event_received',
|
||||
event_type: 'message.received',
|
||||
adapter: 'playwright-adapter',
|
||||
bot_uuid: 'bot-1',
|
||||
event_data: {
|
||||
type: 'message.received',
|
||||
chat_type: 'private',
|
||||
chat_id: 'test-user',
|
||||
message_chain: [{ type: 'Plain', text: 'adapter hello' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
code: 0,
|
||||
msg: 'ok',
|
||||
data: { logs, total_count: logs.length },
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.goto('/home/bots?id=new');
|
||||
await selectPlaywrightAdapter(page);
|
||||
await page.locator('input[name="name"]').fill('Route Status Bot');
|
||||
await submit(page);
|
||||
|
||||
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
|
||||
await expect(page.getByText('Supported events')).toBeVisible();
|
||||
await expect(page.getByText('5 event types')).toBeVisible();
|
||||
await expect(page.getByText('Messages · 2')).toBeVisible();
|
||||
await expect(page.getByText('Groups · 2')).toBeVisible();
|
||||
await expect(page.getByText('Internal server error')).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByText('Events that match no route are ignored.'),
|
||||
).toHaveCount(0);
|
||||
|
||||
const routingCard = page
|
||||
.locator('[data-slot="card"]')
|
||||
.filter({ has: page.getByText('Event Routing', { exact: true }) });
|
||||
const adapterCard = page.locator('[data-slot="card"]').filter({
|
||||
has: page.getByText('Adapter Configuration', { exact: true }),
|
||||
});
|
||||
const dangerCard = page
|
||||
.locator('[data-slot="card"]')
|
||||
.filter({ has: page.getByText('Danger Zone', { exact: true }) });
|
||||
const routingBox = await routingCard.boundingBox();
|
||||
const dangerBox = await dangerCard.boundingBox();
|
||||
expect(routingBox).not.toBeNull();
|
||||
expect(dangerBox).not.toBeNull();
|
||||
expect(
|
||||
dangerBox!.y - (routingBox!.y + routingBox!.height),
|
||||
).toBeGreaterThanOrEqual(20);
|
||||
|
||||
await routingCard.getByRole('button', { name: 'View all' }).click();
|
||||
await expect(
|
||||
routingCard.getByText('Messages', { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
routingCard.getByText('Groups', { exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
await routingCard.getByRole('button', { name: 'Add behavior' }).click();
|
||||
await page.getByRole('menuitem', { name: /Reply to messages/ }).click();
|
||||
const routeEventSelect = routingCard.getByRole('combobox').first();
|
||||
await expect(routeEventSelect).toContainText('Message received');
|
||||
await expect(routeEventSelect).toContainText('message.received');
|
||||
await routeEventSelect.click();
|
||||
const routeEventOption = page
|
||||
.getByRole('option')
|
||||
.filter({ hasText: 'Message received' });
|
||||
await expect(routeEventOption).toContainText('message.received');
|
||||
await expect(routeEventOption).toContainText(
|
||||
'A user or group sends a new message to the bot.',
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await page.getByRole('button', { name: 'Refresh status' }).hover();
|
||||
await expect(
|
||||
page.getByText('Failed to refresh route status.'),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Check route' }).click();
|
||||
const routeDialog = page.getByRole('dialog');
|
||||
await expect(
|
||||
routeDialog.getByText('Check event route', { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
routeDialog.getByText(
|
||||
'Choose an event to see which route and processor it matches.',
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(routeDialog.getByText('Sample event is ready')).toHaveCount(0);
|
||||
await expect(
|
||||
routeDialog.getByRole('button', { name: 'Test data' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
routeDialog.getByRole('button', { name: 'View match' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
routeDialog.getByRole('button', { name: 'Run full test' }),
|
||||
).toHaveCount(0);
|
||||
const routeEventPicker = routeDialog.getByRole('combobox', {
|
||||
name: 'Event type',
|
||||
});
|
||||
await expect(routeEventPicker).toContainText('message.received');
|
||||
await routeEventPicker.click();
|
||||
await expect(
|
||||
page.getByText('Messages', { exact: true }).last(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Groups', { exact: true }).last(),
|
||||
).toBeVisible();
|
||||
const receivedMessageOption = page
|
||||
.getByRole('option')
|
||||
.filter({ hasText: 'Message received' });
|
||||
await expect(receivedMessageOption).toContainText('message.received');
|
||||
await expect(receivedMessageOption).toContainText(
|
||||
'A user or group sends a new message to the bot.',
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await routeDialog.getByRole('button', { name: 'View match' }).click();
|
||||
await expect(routeDialog.getByText('Matched route')).toBeVisible();
|
||||
await expect(routeDialog.getByText('Internal server error')).toHaveCount(0);
|
||||
const dialogBox = await routeDialog.boundingBox();
|
||||
expect(dialogBox).not.toBeNull();
|
||||
expect(dialogBox!.height).toBeLessThan(500);
|
||||
|
||||
await routeDialog.getByRole('button', { name: 'Close' }).first().click();
|
||||
await expect(
|
||||
routingCard.getByRole('button', { name: 'Listen for platform events' }),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
adapterCard.getByText('Test adapter configuration'),
|
||||
).toBeVisible();
|
||||
await adapterCard
|
||||
.getByRole('button', { name: 'Listen for platform events' })
|
||||
.click();
|
||||
const adapterDialog = page.getByRole('dialog');
|
||||
await expect(
|
||||
adapterDialog.getByText('Platform event debugging', { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(adapterDialog).toContainText('Playwright Adapter');
|
||||
await expect(adapterDialog).toContainText(
|
||||
'This window only observes events. Incoming events still follow the current routes.',
|
||||
);
|
||||
await expect(
|
||||
adapterDialog.getByText('Message received', { exact: true }),
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
await expect(adapterDialog.getByText('message.received')).toBeVisible();
|
||||
await expect(adapterDialog.getByText('adapter hello')).toBeVisible();
|
||||
await adapterDialog
|
||||
.getByRole('button', { name: 'View event data' })
|
||||
.click();
|
||||
await expect(
|
||||
adapterDialog.getByText(/"chat_id": "test-user"/),
|
||||
).toBeVisible();
|
||||
await adapterDialog.getByRole('button', { name: 'Clear' }).click();
|
||||
await expect(adapterDialog.getByText('0 events received')).toBeVisible();
|
||||
await expect(
|
||||
adapterDialog.getByText('Waiting for a platform event'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('toggles bot enable/disable state', async ({ page }) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
@@ -376,7 +611,9 @@ test.describe('bot advanced flows', () => {
|
||||
await expect(
|
||||
page.getByRole('tab', { name: /Configuration/ }),
|
||||
).toHaveAttribute('data-state', 'active');
|
||||
await expect(page.locator('input[name="name"]')).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Edit basic information' }),
|
||||
).toBeVisible();
|
||||
|
||||
// Switch to Logs tab
|
||||
await page.getByRole('tab', { name: /Logs/ }).click();
|
||||
@@ -394,7 +631,9 @@ test.describe('bot advanced flows', () => {
|
||||
|
||||
// Switch back to Configuration
|
||||
await page.getByRole('tab', { name: /Configuration/ }).click();
|
||||
await expect(page.locator('input[name="name"]')).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Edit basic information' }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('save button is disabled when form is clean', async ({ page }) => {
|
||||
@@ -405,23 +644,22 @@ test.describe('bot advanced flows', () => {
|
||||
await selectPlaywrightAdapter(page);
|
||||
await page.locator('input[name="name"]').fill('Clean Form Bot');
|
||||
await submit(page);
|
||||
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
|
||||
|
||||
// Reload the persisted record so post-create initialization has completed.
|
||||
await page.reload();
|
||||
await expect(page.locator('input[name="name"]')).toHaveValue(
|
||||
'Clean Form Bot',
|
||||
);
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Clean Form Bot' }),
|
||||
).toBeVisible();
|
||||
|
||||
// After loading, save button should be disabled (form is clean)
|
||||
const saveButton = page.getByRole('button', { name: /^Save$/ });
|
||||
await expect(saveButton).toBeDisabled();
|
||||
|
||||
// Edit the form
|
||||
await page.locator('input[name="description"]').fill('New description');
|
||||
await expect(saveButton).toBeEnabled();
|
||||
|
||||
// Save
|
||||
await saveButton.click();
|
||||
await page.getByRole('button', { name: 'Edit basic information' }).click();
|
||||
const infoDialog = page.getByRole('dialog');
|
||||
await infoDialog.getByLabel('Description').fill('New description');
|
||||
await infoDialog.getByRole('button', { name: 'Save' }).click();
|
||||
await expect(saveButton).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -457,7 +695,7 @@ test.describe('pipeline advanced flows', () => {
|
||||
});
|
||||
|
||||
await page.goto('/home/agents?id=pipeline-scope');
|
||||
await page.getByRole('button', { name: /^AI$/ }).click();
|
||||
await page.getByRole('tab', { name: /^AI$/ }).click();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Edit tools' }),
|
||||
).toBeVisible();
|
||||
@@ -485,22 +723,26 @@ test.describe('pipeline advanced flows', () => {
|
||||
await page.locator('input[name="name"]').fill('Tab Test Pipeline');
|
||||
await submit(page);
|
||||
|
||||
// Verify we're on the Configuration tab
|
||||
await expect(
|
||||
page.getByRole('tab', { name: /Configuration/ }),
|
||||
).toHaveAttribute('data-state', 'active');
|
||||
page.getByRole('region', { name: 'Configuration' }),
|
||||
).toBeVisible();
|
||||
|
||||
// Switch to Monitoring tab (labeled "Dashboard" in the pipeline context)
|
||||
// Skip Debug tab as it requires WebSocket connection
|
||||
await page.getByRole('tab', { name: /Dashboard/ }).click();
|
||||
await expect(page.getByRole('tab', { name: /Dashboard/ })).toHaveAttribute(
|
||||
'data-state',
|
||||
'active',
|
||||
);
|
||||
await page
|
||||
.getByRole('button', { name: 'Dashboard', exact: true })
|
||||
.last()
|
||||
.click();
|
||||
await expect(page.getByRole('region', { name: /Dashboard/ })).toBeVisible();
|
||||
|
||||
// Switch back to Configuration
|
||||
await page.getByRole('tab', { name: /Configuration/ }).click();
|
||||
await expect(page.locator('input[name="basic.name"]')).toBeVisible();
|
||||
await page
|
||||
.getByRole('button', { name: 'Dashboard', exact: true })
|
||||
.last()
|
||||
.click();
|
||||
await expect(
|
||||
page.getByRole('region', { name: 'Configuration' }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('save button reflects form dirty state', async ({ page }) => {
|
||||
@@ -512,20 +754,16 @@ test.describe('pipeline advanced flows', () => {
|
||||
await page.locator('input[name="name"]').fill('Dirty Form Pipeline');
|
||||
await submit(page);
|
||||
|
||||
// Wait for the page to fully load and form to reset
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Edit the form - use the name field which definitely triggers dirty state
|
||||
await page
|
||||
.locator('input[name="basic.name"]')
|
||||
.fill('Dirty Form Pipeline Updated');
|
||||
const saveButton = page.getByRole('button', { name: /^Save$/ });
|
||||
await expect(saveButton).toBeEnabled();
|
||||
|
||||
// Save
|
||||
await saveButton.click();
|
||||
// Wait for save to complete
|
||||
await page.waitForTimeout(500);
|
||||
await expect(saveButton).toBeDisabled();
|
||||
await page.getByRole('button', { name: 'Edit basic information' }).click();
|
||||
const infoDialog = page.getByRole('dialog');
|
||||
await infoDialog.getByLabel('Name').fill('Dirty Form Pipeline Updated');
|
||||
await infoDialog.getByRole('button', { name: 'Save' }).click();
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /Dirty Form Pipeline Updated/ }),
|
||||
).toBeVisible();
|
||||
await expect(saveButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test('shows validation error when pipeline name is empty', async ({
|
||||
@@ -569,7 +807,8 @@ test.describe('agent runner resource selectors', () => {
|
||||
});
|
||||
|
||||
await page.goto('/home/agents?id=agent-scope');
|
||||
await page.getByRole('button', { name: /^Runner$/ }).click();
|
||||
await page.getByRole('tab', { name: /^Runner$/ }).click();
|
||||
await page.getByRole('tab', { name: 'Local Agent' }).click();
|
||||
await page.getByRole('button', { name: 'Edit tools' }).click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
@@ -603,7 +842,10 @@ test.describe('agent and pipeline save concurrency', () => {
|
||||
test('agent save freezes its payload and keeps later edits dirty', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
withAdapterEvents: true,
|
||||
});
|
||||
const delayedSave = await installDelayedFirstSave(
|
||||
page,
|
||||
'/api/v1/agents/agent-save-race',
|
||||
@@ -611,16 +853,27 @@ test.describe('agent and pipeline save concurrency', () => {
|
||||
|
||||
await page.goto('/home/agents?id=agent-save-race');
|
||||
const saveButton = page.getByRole('button', { name: /^Save$/ });
|
||||
const nameInput = page.locator('input[name="basic.name"]');
|
||||
const descriptionInput = page.locator('input[name="basic.description"]');
|
||||
await expect(nameInput).toBeVisible();
|
||||
await page.getByRole('tab', { name: 'Bindable Event Range' }).click();
|
||||
const eventPatterns = page.getByLabel('Event Range');
|
||||
await expect(eventPatterns).toBeVisible();
|
||||
|
||||
await nameInput.fill('Submitted Agent');
|
||||
await eventPatterns.click();
|
||||
await page
|
||||
.getByRole('option')
|
||||
.filter({ hasText: 'message.received' })
|
||||
.click();
|
||||
await page.keyboard.press('Escape');
|
||||
await saveButton.click();
|
||||
await expect.poll(() => delayedSave.payloads.length).toBe(1);
|
||||
await expect(saveButton).toBeDisabled();
|
||||
|
||||
await descriptionInput.fill('Edited while the agent save is pending');
|
||||
await eventPatterns.click();
|
||||
await page.getByRole('option').filter({ hasText: 'group.*' }).click();
|
||||
await page
|
||||
.getByRole('option')
|
||||
.filter({ hasText: 'message.received' })
|
||||
.click();
|
||||
await page.keyboard.press('Escape');
|
||||
await forceFormSubmit(page, '#agent-form');
|
||||
expect(delayedSave.payloads).toHaveLength(1);
|
||||
await expect(saveButton).toBeDisabled();
|
||||
@@ -628,15 +881,13 @@ test.describe('agent and pipeline save concurrency', () => {
|
||||
delayedSave.releaseFirstSave();
|
||||
await expect(saveButton).toBeEnabled();
|
||||
expect(delayedSave.payloads[0]).toMatchObject({
|
||||
name: 'Submitted Agent',
|
||||
description: '',
|
||||
supported_event_patterns: ['message.received'],
|
||||
});
|
||||
|
||||
await saveButton.click();
|
||||
await expect.poll(() => delayedSave.payloads.length).toBe(2);
|
||||
expect(delayedSave.payloads[1]).toMatchObject({
|
||||
name: 'Submitted Agent',
|
||||
description: 'Edited while the agent save is pending',
|
||||
supported_event_patterns: ['group.*'],
|
||||
});
|
||||
await expect(saveButton).toBeDisabled();
|
||||
});
|
||||
@@ -651,39 +902,122 @@ test.describe('agent and pipeline save concurrency', () => {
|
||||
);
|
||||
|
||||
await page.goto('/home/agents?id=pipeline-save-race');
|
||||
const saveButton = page.getByRole('button', { name: /^Save$/ });
|
||||
const nameInput = page.locator('input[name="basic.name"]');
|
||||
const descriptionInput = page.locator('input[name="basic.description"]');
|
||||
await expect(nameInput).toBeVisible();
|
||||
|
||||
await nameInput.fill('Submitted Pipeline');
|
||||
await saveButton.click();
|
||||
await page.getByRole('button', { name: 'Edit basic information' }).click();
|
||||
let infoDialog = page.getByRole('dialog');
|
||||
await infoDialog.getByLabel('Name').fill('Submitted Pipeline');
|
||||
const dialogSaveButton = infoDialog.getByRole('button', { name: 'Save' });
|
||||
await dialogSaveButton.click();
|
||||
await expect.poll(() => delayedSave.payloads.length).toBe(1);
|
||||
await expect(saveButton).toBeDisabled();
|
||||
|
||||
await descriptionInput.fill('Edited while the pipeline save is pending');
|
||||
await forceFormSubmit(page, '#pipeline-form');
|
||||
expect(delayedSave.payloads).toHaveLength(1);
|
||||
await expect(saveButton).toBeDisabled();
|
||||
await expect(
|
||||
infoDialog.getByRole('button', { name: 'Saving...' }),
|
||||
).toBeDisabled();
|
||||
|
||||
delayedSave.releaseFirstSave();
|
||||
await expect(saveButton).toBeEnabled();
|
||||
await expect(infoDialog).toHaveCount(0);
|
||||
expect(delayedSave.payloads[0]).toMatchObject({
|
||||
name: 'Submitted Pipeline',
|
||||
description: '',
|
||||
});
|
||||
|
||||
await saveButton.click();
|
||||
await page.getByRole('button', { name: 'Edit basic information' }).click();
|
||||
infoDialog = page.getByRole('dialog');
|
||||
await infoDialog
|
||||
.getByLabel('Description')
|
||||
.fill('Edited in the next basic information save');
|
||||
await infoDialog.getByRole('button', { name: 'Save' }).click();
|
||||
await expect.poll(() => delayedSave.payloads.length).toBe(2);
|
||||
expect(delayedSave.payloads[1]).toMatchObject({
|
||||
name: 'Submitted Pipeline',
|
||||
description: 'Edited while the pipeline save is pending',
|
||||
description: 'Edited in the next basic information save',
|
||||
});
|
||||
await expect(saveButton).toBeDisabled();
|
||||
await expect(infoDialog).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('cross-resource flows', () => {
|
||||
test('adds custom bot events and reorders routes with a drag preview', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
withAdapterEvents: true,
|
||||
});
|
||||
|
||||
await page.goto('/home/bots?id=new');
|
||||
await selectPlaywrightAdapter(page);
|
||||
await page.locator('input[name="name"]').fill('Routing Bot');
|
||||
await submit(page);
|
||||
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
|
||||
|
||||
await page.getByRole('button', { name: 'Add behavior' }).click();
|
||||
await expect(
|
||||
page.getByText('Common scenarios', { exact: true }),
|
||||
).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: /^Reply to messages/ }).click();
|
||||
await page.getByRole('button', { name: 'Add behavior' }).click();
|
||||
await page.getByRole('menuitem', { name: /^Welcome new members/ }).click();
|
||||
|
||||
const routeCards = page.locator('[data-testid^="event-route-"]');
|
||||
await expect(routeCards).toHaveCount(2);
|
||||
await expect(routeCards.nth(0)).toContainText('Message received');
|
||||
await expect(routeCards.nth(1)).toContainText('Member joined group');
|
||||
|
||||
await page.getByRole('button', { name: 'Add behavior' }).click();
|
||||
await page
|
||||
.getByRole('menuitem', { name: /^Configure another event/ })
|
||||
.hover();
|
||||
const eventSubmenu = page.locator(
|
||||
'[data-slot="dropdown-menu-sub-content"]',
|
||||
);
|
||||
await expect(
|
||||
eventSubmenu.getByText('Messages', { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
eventSubmenu.getByRole('menuitem', { name: /^Message edited/ }),
|
||||
).toBeVisible();
|
||||
await eventSubmenu
|
||||
.getByRole('menuitem', { name: /^Message edited/ })
|
||||
.click();
|
||||
await expect(routeCards).toHaveCount(3);
|
||||
await expect(routeCards.nth(2)).toContainText('Message edited');
|
||||
|
||||
const firstHandle = page.getByRole('button', { name: 'Drag route 1' });
|
||||
const secondCard = routeCards.nth(1);
|
||||
const handleBox = await firstHandle.boundingBox();
|
||||
const targetBox = await secondCard.boundingBox();
|
||||
expect(handleBox).not.toBeNull();
|
||||
expect(targetBox).not.toBeNull();
|
||||
|
||||
await page.mouse.move(
|
||||
handleBox!.x + handleBox!.width / 2,
|
||||
handleBox!.y + handleBox!.height / 2,
|
||||
);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(
|
||||
handleBox!.x + handleBox!.width / 2,
|
||||
handleBox!.y + handleBox!.height / 2 + 10,
|
||||
{ steps: 4 },
|
||||
);
|
||||
await expect(page.locator('[data-drag-overlay="true"]')).toBeVisible();
|
||||
await page.mouse.move(
|
||||
targetBox!.x + targetBox!.width / 2,
|
||||
targetBox!.y + targetBox!.height - 4,
|
||||
{ steps: 12 },
|
||||
);
|
||||
await page.mouse.up();
|
||||
|
||||
await expect(page.locator('[data-drag-overlay="true"]')).toHaveCount(0);
|
||||
await expect(routeCards.nth(0)).toContainText('Member joined group');
|
||||
await expect(routeCards.nth(1)).toContainText('Message received');
|
||||
|
||||
await save(page);
|
||||
await page.reload();
|
||||
const savedRouteCards = page.locator('[data-testid^="event-route-"]');
|
||||
await expect(savedRouteCards.nth(0)).toContainText('Member joined group');
|
||||
await expect(savedRouteCards.nth(1)).toContainText('Message received');
|
||||
await expect(savedRouteCards.nth(2)).toContainText('Message edited');
|
||||
});
|
||||
|
||||
test('creates a pipeline then binds it to a bot', async ({ page }) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
@@ -702,7 +1036,9 @@ test.describe('cross-resource flows', () => {
|
||||
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
|
||||
|
||||
// Wait for form to fully load
|
||||
await expect(page.locator('input[name="name"]')).toHaveValue('Bound Bot');
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Bound Bot' }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Add behavior' }).click();
|
||||
await page.getByRole('menuitem', { name: /^Reply to messages/ }).click();
|
||||
|
||||
@@ -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,
|
||||
@@ -1215,6 +1244,7 @@ export async function installLangBotApiMocks(
|
||||
sessionAnalyses?: Record<string, unknown>;
|
||||
sessionMessages?: Record<string, unknown[]>;
|
||||
storage?: JsonRecord;
|
||||
withAdapterEvents?: boolean;
|
||||
withRunnerToolSelector?: boolean;
|
||||
workspaces?: WorkspaceEntryMock[];
|
||||
} = {},
|
||||
@@ -1226,6 +1256,7 @@ export async function installLangBotApiMocks(
|
||||
sessionAnalyses,
|
||||
sessionMessages,
|
||||
storage = {},
|
||||
withAdapterEvents = false,
|
||||
withRunnerToolSelector = false,
|
||||
workspaces = [defaultWorkspaceEntry()],
|
||||
} = options;
|
||||
@@ -1241,6 +1272,7 @@ export async function installLangBotApiMocks(
|
||||
sessionAnalyses: sessionAnalyses || {},
|
||||
sessionMessages: sessionMessages || {},
|
||||
skills: [],
|
||||
withAdapterEvents,
|
||||
withRunnerToolSelector,
|
||||
workspaces,
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ test.describe('processor detail workbench', () => {
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
withAdapterEvents: true,
|
||||
withRunnerToolSelector: true,
|
||||
});
|
||||
|
||||
@@ -26,6 +27,25 @@ 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');
|
||||
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 +65,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 +184,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 +264,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 +301,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 +326,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