Compare commits

...

19 Commits

Author SHA1 Message Date
dadachann 5f8a3e9114 fix(qqofficial): allocate fresh msg_seq per inbound msg_id to avoid 40054005 dedup
QQ Official v2 API deduplicates passive messages by (msg_id, msg_seq).
The adapter reused msg_seq across multiple sends under the same inbound
msg_id, so multi-part text replies, rich media, and especially streaming
chunks (msg_seq pinned at 1) were rejected with:

  40054005 消息被去重,请检查请求msgseq

Add a per-inbound-msg_id sequence allocator (next_reply_msg_seq) on
QQOfficialClient, backed by a bounded OrderedDict + asyncio lock, and use
it for C2C/group text sends and rich media. Streaming now advances
ctx['msg_seq'] per chunk. Proactive sends (no msg_id) fall back to the
existing global counter.

Closes #2290
2026-06-29 01:33:27 -04:00
huanghuoguoguo f78f29d315 docs(agent-runner): align protocol compatibility notes 2026-06-29 08:40:41 +08:00
wangcham a1066d502c fix(platform): align eba event declarations 2026-06-28 17:44:12 +08:00
huanghuoguoguo 3de16bbbae fix(debug-chat): preserve websocket pipeline routing 2026-06-27 01:31:18 +08:00
huanghuoguoguo d0f6fe2cec feat(agent-runner): support scoped token counting 2026-06-27 01:31:08 +08:00
Junyan Qin ae49753f74 i18n: add missing agents block to es-ES, ru-RU, th-TH, vi-VN, zh-Hant
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:47:58 +08:00
Junyan Qin e9a7d7e58b feat(sidebar): add group-by-type toggle to installed extensions section
- Add group-by-type button to extensions category header (mirrors the
  agent group-by-kind pattern) — syncs with the extensions page Switch
  via shared SidebarDataContext state
- Relocate both group and refresh controls to sit left-aligned
  immediately after the title for both Agent and Extensions sections
- Add plugins.groupByTypeShort i18n key to all 8 locales

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:42:02 +08:00
Junyan Qin 957396b6e2 feat(sidebar): add group-by-kind toggle to Agent section
Add a toggle (left of the "+" button) that groups the Agent section by
kind, showing "Agent" and "Pipeline" sub-headers. State persists in
localStorage, mirroring the extensions group-by-type pattern.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:17:50 +08:00
Junyan Qin 89221b59ed refine(sidebar): show Agent/Pipeline marker as icon only
Drop the text label; keep the icon with a title tooltip so names have
more room in the narrow sidebar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:11:33 +08:00
Junyan Qin fac56e30aa feat(sidebar): mark Agent entries as Agent or Pipeline
Add a trailing badge (icon + label) to each entry in the sidebar Agent
section so users can tell Agent orchestration apart from legacy Pipeline.
Thread the agent `kind` field through SidebarEntityItem.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:10:00 +08:00
Junyan Qin 0ad39b884f fix(bots): hide redundant namespace wildcard and show raw event code
- Only surface `ns.*` wildcard when the namespace has 2+ concrete events,
  so single-event adapters (legacy) show just the one event
- Show raw event code as a muted subtitle under each option's label

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 19:58:50 +08:00
Junyan Qin f157174ae4 feat(bots): refine event binding editor UI and i18n
- Move conditions toggle between event select and arrow; drop "IF" label
- Remove "all events" (*) option from event select
- Add i18n labels for concrete event names (zh/en/ja) via bots.eventNames
- Narrow fallback event set to message.received for adapters without
  declared supported_events (legacy adapters only emit message.received)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 19:52:01 +08:00
Junyan Qin a5acf41df1 fix(adapters): add categories to telegram EBA manifest
The Telegram (EBA) manifest was missing spec.categories, so it fell
into the uncategorized/protocol bucket. Restore popular + global to
match the legacy telegram adapter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 19:18:14 +08:00
Junyan Qin ce31fc81b8 feat(adapters): mark EBA-superseded adapters as legacy and collapse them
The 12 old adapters that now have an EBA replacement are tagged
`spec.legacy: true` in their source manifests. Principle: don't delete,
de-emphasize.

- sources/*.yaml (aiocqhttp, dingtalk, discord, kook, lark,
  officialaccount, qqofficial, slack, telegram, wecom, wecombot,
  wecomcs): add spec.legacy: true
- Adapter / IChooseAdapterEntity types: add optional legacy flag
- BotForm adapter Select: split legacy adapters into a collapsed,
  grayscale group at the bottom with an explanatory hint; auto-expand
  when the bot already uses a legacy adapter
- Wizard platform picker: same collapsed legacy section
- i18n: legacyAdapters / legacyAdaptersHint (zh-Hans, en-US)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 19:10:07 +08:00
Junyan Qin 40e7481032 fix(web): remove discard option from event target selector
Unorchestrated events are discarded by default, so an explicit discard
target is redundant. Drop the discard CommandGroup (and the now-unused
CommandSeparator import); the currentLabel() discard branch is kept so
any pre-existing discard bindings still render correctly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 19:09:35 +08:00
Junyan Qin af459c1a72 fix(web): place each adapter in a single category bucket
groupByCategory pushed multi-category adapters (lark, wecom, discord,
slack) into every matching bucket, so the adapter Select rendered
duplicate SelectItem values — triggering React duplicate-key warnings
and corrupting Radix item tracking. Assign each item to its highest
-priority matching category only. Also de-dupes the wizard card grid.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 16:38:59 +08:00
Junyan Qin 1577567a78 feat(eba): consolidate event bindings, fix command.tsx pointer-events
- Replace legacy pipeline binding card + RoutingRulesEditor with unified
  EventBindingsEditor; remove use_pipeline_uuid/pipeline_routing_rules
  from bot form schema and API update handler
- Add _augment_event_data() to botmgr for filter virtual fields
  (message_text, message_element_types, chat_type)
- Add alembic migration 0009: migrate use_pipeline_uuid and
  pipeline_routing_rules into event_bindings on first run
- Fix command.tsx: data-[disabled] -> data-[disabled=true] so cmdk 1.x
  items (data-disabled=false) are not pointer-events:none
- EventBindingsEditor: onSelect on CommandItems, filter conditions panel,
  disabled bindings section, dnd reorder
- i18n: add filter/condition keys for zh-Hans and en-US
- Update tests to match new bot service behavior

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 16:27:25 +08:00
huanghuoguoguo d02e1a14a3 test(agent): cover agent service event bindings 2026-06-25 00:04:59 +08:00
huanghuoguoguo cd6a39d3a2 test(agent): cover pluginized agent runner runtime 2026-06-24 20:46:32 +08:00
81 changed files with 5258 additions and 1063 deletions
@@ -19,16 +19,16 @@
| 能力 | 当前分支状态 | 后续归属 | 后续接入方式 | 禁止事项 |
| --- | --- | --- | --- | --- |
| Product `Agent` | 已有运行期 `AgentConfig` / `AgentBinding` 投影;还没有正式持久化产品对象。 | Agent Platform / binding persistence UI。 | 持久 Agent 保存 runner id、runner config、resource/state/delivery policy;运行前投影为 `AgentBinding`。 | 不把持久 Agent schema 加进 SDK 协议;插件实例边界见 PROTOCOL_V1 §13。 |
| Product `Agent` | 已有 `agents` 产品表 / API 和运行期 `AgentConfig` / `AgentBinding` 投影;完整 binding persistence / EventRouter / UI 闭环仍未完成。 | Agent Platform / binding persistence UI。 | 持久 Agent 保存 runner id、runner config、resource/state/delivery policy;运行前投影为 `AgentBinding`。 | 不把持久 Agent schema 加进 SDK 协议;插件实例边界见 PROTOCOL_V1 §13。 |
| Bot / channel 绑定 Agent | 已有单次运行前的 `AgentBinding` 解析投影;目标调度语义见 PROTOCOL_V1 §13。 | EBA / Agent Platform。 | EventRouter 根据 bot、channel、workspace、conversation、event type 解析有效 `AgentBinding`。 | 不在本矩阵重定义 fan-out / observer 语义;需要时按 §3 新增设计。 |
| Agent session / run | 当前只有 `run_id` 和 active `AgentRunSessionRegistry`,用于权限校验和生命周期。 | Agent Platform / Runtime Control Plane。 | 如需要可新增持久 `AgentRun` / `AgentSession` / task 表,但执行仍回到 `run(event, binding)` 或 runtime-managed 等价入口。 | 不把持久 session 字段塞进 `AgentRunContext` 顶层;不要求所有 runner 长期持有 LangBot session。 |
| Agent session / run | 已有持久 `AgentRun` / `AgentRunEvent` ledger 和 active `AgentRunSessionRegistry`;还没有独立 `AgentSession` / task 产品模型。 | Agent Platform / Runtime Control Plane。 | 如需要可新增 `AgentSession` / task 表,但执行仍回到 `run(event, binding)` 或 runtime-managed 等价入口。 | 不把持久 session 字段塞进 `AgentRunContext` 顶层;不要求所有 runner 长期持有 LangBot session。 |
| EventLog / Transcript / Sandbox files | 已完成 Host-owned store、history pull API 和 sandbox 文件边界;runner 不直接写 DB。 | 本分支持续维护底座;Agent Platform 可复用。 | 外部 EBA、scheduler、integration、runtime task 都写同一套 EventLog / Transcript;当前 run 文件通过 sandbox/workspace staging 共享。 | 不让 runner / sandbox 直接访问 Host DB;不把大 payload 内联进 prompt。 |
| Host-owned state / storage | 已有 state snapshot、`state.updated` 处理和 State APIstorage 作为授权能力保留。 | 本分支持续维护底座;Runtime / Platform 可复用。 | 外部 session id、working directory、checkpoint 等小 JSON 用 state;当前 run 大对象用 sandbox/workspace 文件。 | 不把跨轮次状态存在插件实例内;不绕过 run-scoped authorization。 |
| EventGateway / EventRouter | 本分支只提供 event-first envelope 和 `run(event, binding)` 入口。 | EBA 分支(联调中)。 | EventGateway 规范化平台/WebUI/API/scheduler 事件;EventRouter 解析一个 binding;调用现有 orchestrator。 | 不为 EBA 新增另一套 runner 调用协议;不把非消息事件伪装成 user message。 |
| Scheduler / Automation | 不实现。文档中只把 `scheduler` 作为 future event source。 | EBA / Agent Platform。 | 定时任务触发 `schedule.triggered` host event,复用 EventGateway -> EventRouter -> `run(event, binding)`。 | 不直接调用某个 runner 插件;不绕过 EventLog / authorization。 |
| Integration provider | 不实现。IM platform adapter 仍是当前平台接入系统。 | EBA / Agent Platform。 | OAuth/webhook/outbound provider 应先转成 canonical host event 或 platform action,再交给 AgentRunner。 | 不把 Linear/Slack/GitHub 等 provider 私有 payload 扩散到 runner 协议顶层。 |
| Platform action / delivery | `action.requested` 已预留但当前仅 telemetry,不执行。`DeliveryContext` 只作为上下文/策略投影。 | EBA / platform action executor。 | 后续 executor 校验 runner capability、binding policy、actor/bot/workspace 权限和审批后执行。 | 不让 runner 直接调用平台 adapter 私有 API;不把平台动作伪装成文本回复副作用。 |
| Runtime registry / worker / task queue | 不实现。当前官方外部 harness 通过 ACP、远端 daemon、本机 subprocess 或外部 HTTP API runner 调用目标运行环境,不在本分支维护通用 worker。 | Runtime Control Plane v2。 | 第一阶段先补 Host-owned `AgentRun` / `AgentRunEvent` / run control primitives;完整 runtime registry、heartbeat、task queue、daemon claim、progress/audit 是后续可选阶段。 | 不把 heartbeat/task/warm pool 放进 Protocol v1;不让管理插件拥有 runtime/task 事实源。 |
| Runtime registry / worker / task queue | 已落地 Host-owned `AgentRun` / `AgentRunEvent`、run control primitives、最小 runtime registry / heartbeat / claim lease当前官方外部 harness 通过 ACP、远端 daemon、本机 subprocess 或外部 HTTP API runner 调用目标运行环境,不在本分支维护完整通用 worker 队列。 | Runtime Control Plane v2。 | 后续可在现有 Host 事实源上补 queued run producer、daemon wakeup、claim execution loop、progress/audit 和运维诊断。 | 不把 heartbeat/task/warm pool 放进 Protocol v1;不让管理插件拥有 runtime/task 事实源。 |
| Warm pool / reconcile / diagnose | 不实现。 | Runtime Control Plane v2 / deployment layer。 | 作为 task/runtime 的运维能力,围绕 Host-owned runtime/task/audit 表实现。 | 不把 runtime 运维语义写进普通 runner 协议;不把 pod/task 细节泄漏给普通 runner。 |
| Agent memory | 不实现通用长期记忆产品层;提供 history/state/storage 和 sandbox 文件基础能力。 | Agent Platform 或具体 runner/plugin。 | 平台 memory 可通过 Host storage/state 或独立产品表实现,runner 通过授权 API 拉取。 | 不在 Host core 内置通用 agentic memory 策略;不默认把 memory 全量 inline 到 context。 |
| External harness native session | ACP / Claude Code / Codex 等 runner 支持 external session id state handoff 和 LangBot resource projection。 | 官方 runner 后续增强;Runtime Control Plane v2 可接管执行。 | 外部 harness 调用继续走 `runner.run(ctx)`;如后续引入长连接/daemon 模式,按 external session key 串行 turnreader 独占 native stream。 | 不把具体 provider native wire 变成 LangBot 协议;全局锁边界见 PROTOCOL_V1 §13。 |
@@ -48,7 +48,7 @@ Agent / binding 的持久化形态。
- 新增可选字段保持向后兼容。
- 删除字段或改变既有字段语义,需要在 SDK 发布前完成;发布后应走新的显式兼容方案。
- 结果流演进:Host **必须忽略未知 result type 并记录 warning**(除非该 type 明确要求强校验)。SDK envelope 接收入站未知 `type` 字符串,runner 侧可按原字符串转发或忽略;新增 result type 不提升大版本。
- SDK 入站 context 类实体偏宽松,用于兼容 Host 附加的非核心字段;manifest、result payload、page/result 返回与错误模型偏严格,未知字段默认禁止。安全边界仍在 Host,SDK 校验只提升开发体验。
- SDK 入站 context 类实体偏宽松,用于兼容 Host 附加的非核心字段;Host 返回 DTOhistory/event/steering/run/runtime/stats/error 等)忽略未知字段,保证 Host 增加可选返回字段时旧 SDK 不会解析失败;manifest、runner result payload 等由插件/runner 提交给 Host 的输入合同仍偏严格。安全边界仍在 Host,SDK 校验只提升开发体验。
## 4. Discovery 协议
+1 -1
View File
@@ -26,7 +26,7 @@
- `action.requested` 仍只作为 telemetry / reserved surfaceplatform action executor 不在本分支执行。
- EventGateway / EventRouter 完整实现由外部 EBA 分支联调;本分支只提供 event-first host envelope / binding / run 入口。
- State 与 storage 的长期类型边界仍可继续收窄;当前合同只要求 JSON-safe state 与受控 storage API。
- `ToolResource` 当前只带 `tool_name` / `tool_type` / `description` / `operations`,不含 `parameters` full schemarunner(如 local-agent `build_llm_tools`)需逐个 `get_tool_detail` 往返。拟在 `ToolResource` 增补 `parameters`,由 Host 在构造 `ctx.resources` 时一次塞齐
- `ToolResource.parameters` 已作为 best-effort full schema 由 Host 在构造 `ctx.resources` 时一次塞齐;无 schema 时 runner 仍需兼容 `parameters=None` 或按需调用 detail API
- EventLog / Transcript 已提供显式 cleanup primitive;长期 retention 默认值、TTL 调度接入和 sandbox/workspace 文件清理仍是运维收尾项,应在 Runtime Control Plane 产品化前补齐。
- External harness 的 native shell / filesystem / CLI / MCP 权限不受 manifest permissions 约束;manifest permissions 只约束 LangBot 持有的资源访问。
- LangBot 当前不承诺 managed sandboxexternal harness 的 OS/process/network quota、workspace GC、provider-native tool 权限由用户或部署环境承担。
+3 -2
View File
@@ -10,7 +10,7 @@ The DingTalk adapter now has an Event-Based Agents adapter package with:
- `manifest.yaml` for adapter metadata, configuration, events, common APIs, and platform-specific APIs.
- `adapter.py` for DingTalk client startup, native callback handling, legacy compatibility, and EBA dispatch.
- `event_converter.py` for native DingTalk events to common EBA events.
- `event_converter.py` for native DingTalk events and card callbacks to common EBA events.
- `message_converter.py` for DingTalk message payloads to/from common `MessageChain` components.
- `api_impl.py` for common EBA API implementations.
- `platform_api.py` for DingTalk-specific `call_platform_api` actions.
@@ -33,7 +33,8 @@ The legacy DingTalk HTTP client now returns successful JSON response bodies from
| Event | Support | Evidence |
|-------|---------|----------|
| `message.received` | implemented | `plugin-e2e-ui` private text and emoji-as-text. |
| `platform.specific` | implemented | Not reproduced in the latest UI run. |
| `feedback.received` | unit covered | DingTalk card callback actions with feedback-like values (`like`, `dislike`, `cancel`, or `1`/`2`/`3`) map to `FeedbackReceivedEvent`. Other card actions remain `platform.specific`. |
| `platform.specific` | implemented | Non-feedback card callbacks and unmapped callback/message shapes are emitted as structured platform-specific events. |
## Receive Components
@@ -44,7 +44,6 @@ Discord declares these EBA events:
- `message.reaction`
- `group.member_joined`
- `group.member_left`
- `group.member_banned`
- `bot.invited_to_group`
- `bot.removed_from_group`
- `platform.specific`
@@ -24,7 +24,7 @@ Status: partial migration. Unit/API-shape coverage is present, and private text
| Event | Evidence | Notes |
| --- | --- | --- |
| `message.received` | plugin-e2e-ui, unit | Text UI message verified through WeChat Official Account on `dev.rockchin.top`; image and voice webhook payloads are covered by unit tests. |
| `platform.specific` | unit | Subscribe/menu/etc. native events are emitted as structured `PlatformSpecificEvent`. |
| `platform.specific` | unit | Subscribe/unsubscribe/menu/etc. native events are emitted as structured `PlatformSpecificEvent`. |
## Common APIs
@@ -22,7 +22,12 @@ Status: partial migration. The EBA adapter structure, manifest, converters, cach
| Event | Evidence | Notes |
| --- | --- | --- |
| `message.received` | adapter-live, unit | `C2C_MESSAGE_CREATE`, `DIRECT_MESSAGE_CREATE`, `GROUP_AT_MESSAGE_CREATE`, and `AT_MESSAGE_CREATE` map to common `MessageReceivedEvent`. A real WebSocket-mode QQ Official bot reached the LangBot pipeline on `dev.rockchin.top`; plugin JSONL evidence remains pending. |
| `platform.specific` | unit, blocked | Unmapped gateway events are emitted as structured `PlatformSpecificEvent`; live evidence is pending. |
| `message.reaction` | unit | `MESSAGE_REACTION_ADD` and `MESSAGE_REACTION_REMOVE` map to common `MessageReactionEvent`. Live gateway evidence is pending. |
| `group.member_joined` | unit | `GUILD_MEMBER_ADD` and `GROUP_MEMBER_ADD` map to common `MemberJoinedEvent` when the gateway payload carries a group/guild/channel ID and member openid. |
| `group.member_left` | unit | `GUILD_MEMBER_REMOVE` and `GROUP_MEMBER_REMOVE` map to common `MemberLeftEvent`. Live gateway evidence is pending. |
| `bot.invited_to_group` | unit | `GUILD_CREATE` and `GROUP_ADD_ROBOT` map to common `BotInvitedToGroupEvent`. |
| `bot.removed_from_group` | unit | `GUILD_DELETE` and `GROUP_DEL_ROBOT` map to common `BotRemovedFromGroupEvent`. |
| `platform.specific` | unit | Unmapped gateway events are emitted as structured `PlatformSpecificEvent`; live evidence is pending. |
## Common APIs
@@ -102,7 +102,7 @@ try {
});
Object.assign(result, prepared);
if (result.pipeline_id) {
result.pipeline_url = `${frontendUrl.replace(/\/$/, "")}/home/pipelines?id=${encodeURIComponent(result.pipeline_id)}`;
result.pipeline_url = `${frontendUrl.replace(/\/$/, "")}/home/agents?id=${encodeURIComponent(result.pipeline_id)}`;
}
if (writeEnv && result.pipeline_id) {
@@ -89,7 +89,7 @@ try {
});
Object.assign(result, prepared);
if (result.pipeline_id) {
result.pipeline_url = `${frontendUrl.replace(/\/$/, "")}/home/pipelines?id=${encodeURIComponent(result.pipeline_id)}`;
result.pipeline_url = `${frontendUrl.replace(/\/$/, "")}/home/agents?id=${encodeURIComponent(result.pipeline_id)}`;
}
if (writeEnv && result.pipeline_id) {
@@ -74,7 +74,7 @@ try {
});
Object.assign(result, prepared);
if (result.pipeline_id) {
result.pipeline_url = `${frontendUrl.replace(/\/$/, "")}/home/pipelines?id=${encodeURIComponent(result.pipeline_id)}`;
result.pipeline_url = `${frontendUrl.replace(/\/$/, "")}/home/agents?id=${encodeURIComponent(result.pipeline_id)}`;
}
if (writeEnv && result.pipeline_id) {
+1 -1
View File
@@ -1448,7 +1448,7 @@ test("generic pipeline readiness accepts either URL or name target", () => {
LANGBOT_BROWSER_PROFILE: "/tmp/langbot-test-profile",
LANGBOT_CHROMIUM_EXECUTABLE: "/tmp/langbot-test-chromium",
}, () => {
process.env.LANGBOT_PIPELINE_URL = "http://127.0.0.1:3000/home/pipelines?id=only-url";
process.env.LANGBOT_PIPELINE_URL = "http://127.0.0.1:3000/home/agents?id=only-url";
process.env.LANGBOT_PIPELINE_NAME = "";
const ready = capture(() => commandTestPlan(ctx(["test", "plan", "pipeline-debug-chat", "--json"])));
+30 -2
View File
@@ -1,6 +1,7 @@
import re
import time
import asyncio
import collections
from quart import request
import httpx
from quart import Quart
@@ -35,6 +36,11 @@ class QQOfficialClient:
self.access_token_expiry_time = None
self.logger = logger
self._msg_seq_counter = 0
# Per-inbound-msg_id 回复序号分配器。QQ 官方 v2 API 通过 (msg_id, msg_seq)
# 对被动消息去重,同一 msg_id 复用 msg_seq 会被拒绝 (40054005 消息被去重)。
self._reply_msg_seq: 'collections.OrderedDict[str, int]' = collections.OrderedDict()
self._reply_msg_seq_lock = asyncio.Lock()
self._reply_msg_seq_max_keys = 1024
self._token_refresh_task: Optional[asyncio.Task] = None
async def check_access_token(self):
@@ -177,6 +183,27 @@ class QQOfficialClient:
content_type = attachment.get('content_type', '')
return content_type.startswith('image/')
async def next_reply_msg_seq(self, msg_id: Optional[str]) -> int:
"""为同一条入站 msg_id 关联的每次被动下发分配递增的 msg_seq。
QQ 官方 v2 API 通过 (msg_id, msg_seq) 去重同一 msg_id 复用 msg_seq 会被
拒绝 (40054005 消息被去重)多段文本富媒体重试等场景下每次发送都需要
新的 msg_seq msg_id主动消息等时回退到全局自增计数器
"""
if not msg_id:
self._msg_seq_counter += 1
return self._msg_seq_counter
key = str(msg_id)
async with self._reply_msg_seq_lock:
seq = self._reply_msg_seq.get(key, 0) + 1
self._reply_msg_seq[key] = seq
self._reply_msg_seq.move_to_end(key)
# 限制字典大小,避免长期运行的内存增长
while len(self._reply_msg_seq) > self._reply_msg_seq_max_keys:
self._reply_msg_seq.popitem(last=False)
return seq
async def send_private_text_msg(self, user_openid: str, content: str, msg_id: str):
"""发送私聊消息"""
if not await self.check_access_token():
@@ -192,6 +219,7 @@ class QQOfficialClient:
'content': content,
'msg_type': 0,
'msg_id': msg_id,
'msg_seq': await self.next_reply_msg_seq(msg_id),
}
response = await client.post(url, headers=headers, json=data)
response_data = response.json()
@@ -216,6 +244,7 @@ class QQOfficialClient:
'content': content,
'msg_type': 0,
'msg_id': msg_id,
'msg_seq': await self.next_reply_msg_seq(msg_id),
}
response = await client.post(url, headers=headers, json=data)
if response.status_code == 200:
@@ -363,8 +392,7 @@ class QQOfficialClient:
else:
raise ValueError(f'Unsupported target_type: {target_type}')
self._msg_seq_counter += 1
msg_seq = self._msg_seq_counter
msg_seq = await self.next_reply_msg_seq(msg_id)
body = {
'msg_type': 7,
'media': {'file_info': file_info},
@@ -184,7 +184,7 @@ class AgentRunContextBuilder:
def _is_llm_model_resource(model_resource: ModelResource) -> bool:
operations = model_resource.get('operations')
if isinstance(operations, list) and operations:
return bool({'invoke', 'stream'} & {str(operation) for operation in operations})
return bool({'invoke', 'stream', 'count_tokens'} & {str(operation) for operation in operations})
return model_resource.get('model_type') != 'rerank'
async def _build_model_context_window_tokens(self, resources: AgentResources) -> int | None:
+4 -2
View File
@@ -146,8 +146,10 @@ class AgentRunnerRegistry:
Returns:
List of runner descriptors
"""
if use_cache and self._cache is not None:
# Filter from cache
if use_cache and self._cache is not None and self._cache:
# Filter from cache. Do not treat an empty cache as final because the
# plugin runtime may still be launching installed plugins when the
# first metadata request arrives.
return self._filter_runners_by_bound_plugins(self._cache, bound_plugins)
# Discover fresh (always full list)
@@ -101,9 +101,9 @@ class AgentResourceBuilder:
seen_model_ids: set[str] = set()
model_perms = set(manifest_perms.models)
include_llm = bool({'invoke', 'stream'} & model_perms)
include_llm = bool({'invoke', 'stream', 'count_tokens'} & model_perms)
include_rerank = 'rerank' in model_perms
llm_operations = [operation for operation in ('invoke', 'stream') if operation in model_perms]
llm_operations = [operation for operation in ('invoke', 'stream', 'count_tokens') if operation in model_perms]
if not include_llm and not include_rerank:
return models
@@ -13,7 +13,7 @@ from .context_builder import AgentResources
MAX_STEERING_QUEUE_ITEMS = 100
DEFAULT_RESOURCE_OPERATIONS: dict[str, set[str]] = {
'model': {'invoke', 'stream', 'rerank'},
'model': {'invoke', 'stream', 'rerank', 'count_tokens'},
'tool': {'detail', 'call'},
'knowledge_base': {'list', 'retrieve'},
'skill': {'activate'},
+4 -23
View File
@@ -190,17 +190,6 @@ class BotService:
# TODO: 检查配置信息格式
bot_data['uuid'] = str(uuid.uuid4())
# bind the most recently updated pipeline if any exist
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_pipeline.LegacyPipeline)
.order_by(persistence_pipeline.LegacyPipeline.updated_at.desc())
.limit(1)
)
pipeline = result.first()
if pipeline is not None:
bot_data['use_pipeline_uuid'] = pipeline.uuid
bot_data['use_pipeline_name'] = pipeline.name
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_bot.Bot).values(bot_data))
bot = await self.get_bot(bot_data['uuid'])
@@ -219,18 +208,10 @@ class BotService:
if 'event_bindings' in update_data:
update_data['event_bindings'] = await self._normalize_event_bindings(update_data.get('event_bindings'))
# set use_pipeline_name
if 'use_pipeline_uuid' in update_data:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
persistence_pipeline.LegacyPipeline.uuid == update_data['use_pipeline_uuid']
)
)
pipeline = result.first()
if pipeline is not None:
update_data['use_pipeline_name'] = pipeline.name
else:
raise Exception('Pipeline not found')
# clear legacy routing fields — routing is now fully managed via event_bindings
update_data.pop('use_pipeline_uuid', None)
update_data.pop('use_pipeline_name', None)
update_data.pop('pipeline_routing_rules', None)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_bot.Bot).values(update_data).where(persistence_bot.Bot.uuid == bot_uuid)
@@ -10,7 +10,7 @@ ssereadtimeout) live in ``extra_args`` and are left untouched — the
auto-detecting remote transport consumes them regardless.
Revision ID: 0006_normalize_mcp_remote_mode
Revises: 0005_add_llm_context_length
Revises: 8d3a1f2c4b6e
Create Date: 2026-06-21
"""
@@ -18,7 +18,7 @@ import sqlalchemy as sa
from alembic import op
revision = '0006_normalize_mcp_remote_mode'
down_revision = '0005_add_llm_context_length'
down_revision = '8d3a1f2c4b6e'
branch_labels = None
depends_on = None
@@ -0,0 +1,111 @@
"""migrate use_pipeline_uuid and pipeline_routing_rules into event_bindings
Revision ID: 0009_migrate_routing_to_event_bindings
Revises: 0008_agent_product_surface
Create Date: 2026-06-26
"""
import json
import uuid
import sqlalchemy as sa
from alembic import op
revision = '0009_migrate_routing_to_event_bindings'
down_revision = '0008_agent_product_surface'
depends_on = None
def _rule_to_filters(rule: dict) -> list[dict] | None:
"""Convert a pipeline_routing_rule to event_binding filters (best effort).
Rules that don't map cleanly (message_content, message_has_element) are
skipped callers should handle None as "cannot migrate".
"""
rule_type = rule.get('type')
operator = rule.get('operator', 'eq')
value = rule.get('value', '')
if rule_type == 'launcher_type':
if value == 'group':
return [{'field': 'group', 'operator': 'neq', 'value': None}]
if value == 'person':
return [{'field': 'group', 'operator': 'eq', 'value': None}]
elif rule_type == 'launcher_id':
return [{'field': 'chat_id', 'operator': operator, 'value': value}]
return None # message_content / message_has_element: no clean mapping
def upgrade() -> None:
bind = op.get_bind()
rows = bind.execute(
sa.text('SELECT uuid, use_pipeline_uuid, pipeline_routing_rules, event_bindings FROM bots')
).fetchall()
for bot_uuid, use_pipeline_uuid, routing_rules_raw, event_bindings_raw in rows:
try:
existing = (
json.loads(event_bindings_raw) if isinstance(event_bindings_raw, str) else (event_bindings_raw or [])
)
except Exception:
existing = []
if existing:
continue # already has event_bindings — skip
try:
routing_rules = (
json.loads(routing_rules_raw) if isinstance(routing_rules_raw, str) else (routing_rules_raw or [])
)
except Exception:
routing_rules = []
new_bindings: list[dict] = []
base_priority = len(routing_rules)
for i, rule in enumerate(routing_rules):
target_uuid = rule.get('pipeline_uuid', '')
if not target_uuid:
continue
filters = _rule_to_filters(rule)
if filters is None:
continue
new_bindings.append(
{
'id': str(uuid.uuid4()),
'event_pattern': 'message.*',
'target_type': 'pipeline',
'target_uuid': target_uuid,
'filters': filters,
'priority': base_priority - i,
'enabled': True,
'description': f'Migrated from routing rule ({rule.get("type")})',
'order': i,
}
)
if use_pipeline_uuid:
new_bindings.append(
{
'id': str(uuid.uuid4()),
'event_pattern': 'message.*',
'target_type': 'pipeline',
'target_uuid': use_pipeline_uuid,
'filters': [],
'priority': 0,
'enabled': True,
'description': 'Migrated from default pipeline binding',
'order': len(new_bindings),
}
)
if new_bindings:
bind.execute(
sa.text('UPDATE bots SET event_bindings = :b WHERE uuid = :u'),
{'b': json.dumps(new_bindings, ensure_ascii=False), 'u': bot_uuid},
)
def downgrade() -> None:
pass # not reversible
@@ -3,6 +3,7 @@ from __future__ import annotations
import traceback
import typing
import dingtalk_stream
import pydantic
from langbot.libs.dingtalk_api.api import DingTalkClient
@@ -19,6 +20,32 @@ from langbot_plugin.api.entities.builtin.platform import message as platform_mes
from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError
class DingTalkCardCallbackHandler(dingtalk_stream.CallbackHandler):
def __init__(self, adapter: 'DingTalkAdapter'):
super().__init__()
self.adapter = adapter
async def process(self, message: dingtalk_stream.CallbackMessage):
callback = dingtalk_stream.CardCallbackMessage.from_dict(message.data or {})
event = DingTalkEvent.from_payload(
{
'conversation_type': 'CardCallback',
'Type': 'card_callback',
'CardCallback': {
'extension': callback.extension,
'corp_id': callback.corp_id,
'user_id': callback.user_id,
'content': callback.content,
'space_id': callback.space_id,
'card_instance_id': callback.card_instance_id,
},
}
)
if event is not None:
await self.adapter._handle_native_event(event)
return dingtalk_stream.AckMessage.STATUS_OK, 'OK'
class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapter):
bot: DingTalkClient = pydantic.Field(exclude=True)
@@ -68,6 +95,7 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
def get_supported_events(self) -> list[str]:
return [
'message.received',
'feedback.received',
'platform.specific',
]
@@ -198,6 +226,10 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
self.bot.on_message('FriendMessage')(on_message)
self.bot.on_message('GroupMessage')(on_message)
self.bot.client.register_callback_handler(
dingtalk_stream.CallbackHandler.TOPIC_CARD_CALLBACK,
DingTalkCardCallbackHandler(self),
)
async def _handle_native_event(self, event: DingTalkEvent):
try:
@@ -19,6 +19,11 @@ class DingTalkEventConverter(abstract_platform_adapter.AbstractEventConverter):
async def target2yiri(event: DingTalkEvent, bot_name: str) -> platform_events.Event | None:
if event.conversation in {'FriendMessage', 'GroupMessage'}:
return await DingTalkEventConverter.message_to_eba(event, bot_name)
if event.conversation == 'CardCallback':
feedback = DingTalkEventConverter.card_callback_to_feedback(event)
if feedback is not None:
return feedback
return DingTalkEventConverter.platform_specific(event, 'card.callback')
return DingTalkEventConverter.platform_specific(event, f'message.{event.conversation or "unknown"}')
@staticmethod
@@ -57,6 +62,54 @@ class DingTalkEventConverter(abstract_platform_adapter.AbstractEventConverter):
source_platform_object=event,
)
@staticmethod
def card_callback_to_feedback(event: DingTalkEvent) -> platform_events.FeedbackReceivedEvent | None:
callback = event.get('CardCallback') or {}
content = callback.get('content') or {}
extension = callback.get('extension') or {}
action = str(
content.get('action') or content.get('actionKey') or content.get('value') or extension.get('action') or ''
).lower()
feedback_type = DingTalkEventConverter._feedback_type(
action or content.get('feedback_type') or content.get('feedbackType')
)
if feedback_type is None:
return None
feedback_id = str(
content.get('feedback_id')
or content.get('feedbackId')
or callback.get('card_instance_id')
or callback.get('user_id')
or ''
)
return platform_events.FeedbackReceivedEvent(
type='feedback.received',
adapter_name=ADAPTER_NAME,
feedback_id=feedback_id,
feedback_type=feedback_type,
feedback_content=content.get('feedback_content') or content.get('feedbackContent') or content.get('reason'),
inaccurate_reasons=content.get('inaccurate_reasons') or content.get('inaccurateReasons'),
user_id=callback.get('user_id') or None,
session_id=callback.get('space_id') or None,
message_id=content.get('message_id') or content.get('messageId'),
stream_id=content.get('stream_id') or content.get('streamId'),
timestamp=0.0,
source_platform_object=event,
)
@staticmethod
def _feedback_type(value: typing.Any) -> int | None:
if isinstance(value, int) and value in {1, 2, 3}:
return value
normalized = str(value or '').lower()
if normalized in {'1', 'like', 'liked', 'thumb_up', 'thumbup', 'up', 'good'}:
return 1
if normalized in {'2', 'dislike', 'disliked', 'thumb_down', 'thumbdown', 'down', 'bad'}:
return 2
if normalized in {'3', 'cancel', 'remove', 'removed', 'clear'}:
return 3
return None
@staticmethod
def user_from_event(event: DingTalkEvent) -> platform_entities.User:
incoming_message = event.incoming_message
@@ -92,6 +92,7 @@ spec:
supported_events:
- message.received
- feedback.received
- platform.specific
supported_apis:
@@ -1,3 +1,3 @@
from __future__ import annotations
ADAPTER_NAME = 'dingtalk'
ADAPTER_NAME = 'dingtalk-eba'
@@ -161,7 +161,11 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
)
def get_supported_events(self) -> list[str]:
return ['message.received', 'bot.invited_to_group', 'platform.specific']
return [
'message.received',
'bot.invited_to_group',
'platform.specific',
]
def get_supported_apis(self) -> list[str]:
return [
@@ -11,7 +11,16 @@ from langbot.libs.qq_official_api.api import QQOfficialClient
from langbot.libs.qq_official_api.qqofficialevent import QQOfficialEvent
from langbot.pkg.platform.adapters.qqofficial.api_impl import QQOfficialAPIMixin
from langbot.pkg.platform.adapters.qqofficial.errors import NotSupportedError
from langbot.pkg.platform.adapters.qqofficial.event_converter import QQOfficialEventConverter
from langbot.pkg.platform.adapters.qqofficial.event_converter import (
BOT_INVITED_EVENT_TYPES,
BOT_REMOVED_EVENT_TYPES,
MEMBER_JOINED_EVENT_TYPES,
MEMBER_LEFT_EVENT_TYPES,
MESSAGE_EVENT_TYPES,
REACTION_ADD_EVENT_TYPES,
REACTION_REMOVE_EVENT_TYPES,
QQOfficialEventConverter,
)
from langbot.pkg.platform.adapters.qqofficial.message_converter import QQOfficialMessageConverter
from langbot.pkg.platform.adapters.qqofficial.platform_api import PLATFORM_API_MAP
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
@@ -89,6 +98,11 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
def get_supported_events(self) -> list[str]:
return [
'message.received',
'message.reaction',
'group.member_joined',
'group.member_left',
'bot.invited_to_group',
'bot.removed_from_group',
'platform.specific',
]
@@ -111,7 +125,9 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
target_id: str,
message: platform_message.MessageChain,
) -> platform_events.MessageResult:
raw = await self._send_content_list(str(target_type), str(target_id), await QQOfficialMessageConverter.yiri2target(message))
raw = await self._send_content_list(
str(target_type), str(target_id), await QQOfficialMessageConverter.yiri2target(message)
)
return platform_events.MessageResult(raw={'results': raw})
async def reply_message(
@@ -212,8 +228,14 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
is_final: bool = False,
):
await self._cleanup_stale_streams()
chunk_text = '\n\n'.join(component.text for component in message if isinstance(component, platform_message.Plain))
message_id = bot_message.get('resp_message_id') if isinstance(bot_message, dict) else getattr(bot_message, 'resp_message_id', None)
chunk_text = '\n\n'.join(
component.text for component in message if isinstance(component, platform_message.Plain)
)
message_id = (
bot_message.get('resp_message_id')
if isinstance(bot_message, dict)
else getattr(bot_message, 'resp_message_id', None)
)
if not message_id or message_id not in self._stream_ctx:
if chunk_text:
self._fallback_text[message_id] = self._fallback_text.get(message_id, '') + chunk_text
@@ -221,7 +243,11 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
if is_final:
full_text = self._fallback_text.pop(message_id, '')
if full_text:
await self.reply_message(message_source, platform_message.MessageChain([platform_message.Plain(text=full_text)]), quote_origin)
await self.reply_message(
message_source,
platform_message.MessageChain([platform_message.Plain(text=full_text)]),
quote_origin,
)
return
ctx = self._stream_ctx[message_id]
@@ -254,12 +280,23 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
ctx['stream_msg_id'] = resp['id']
ctx['sent_length'] = len(ctx['accumulated_text'])
ctx['index'] += 1
# 每个 chunk 单独 HTTP 下发,msg_seq 必须递增,否则后续 chunk 会被 QQ
# 以 (msg_id, msg_seq) 去重 (40054005 消息被去重)。
ctx['msg_seq'] += 1
if is_final:
self._stream_ctx.pop(message_id, None)
self._stream_ctx_ts.pop(message_id, None)
def _register_native_handlers(self):
for event_type in ('C2C_MESSAGE_CREATE', 'DIRECT_MESSAGE_CREATE', 'GROUP_AT_MESSAGE_CREATE', 'AT_MESSAGE_CREATE'):
for event_type in (
MESSAGE_EVENT_TYPES
| REACTION_ADD_EVENT_TYPES
| REACTION_REMOVE_EVENT_TYPES
| MEMBER_JOINED_EVENT_TYPES
| MEMBER_LEFT_EVENT_TYPES
| BOT_INVITED_EVENT_TYPES
| BOT_REMOVED_EVENT_TYPES
):
self.bot.on_message(event_type)(self._handle_native_event)
async def _handle_native_event(self, event: QQOfficialEvent):
@@ -305,12 +342,12 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
await self.logger.info('QQ Official WebSocket connected and ready')
async def on_event(event_type: str, event_data: dict):
if event_type not in {'C2C_MESSAGE_CREATE', 'DIRECT_MESSAGE_CREATE', 'GROUP_AT_MESSAGE_CREATE', 'AT_MESSAGE_CREATE'}:
await self._dispatch_eba_event(QQOfficialEventConverter.platform_specific(QQOfficialEvent({'t': event_type, **(event_data or {})}), f'qqofficial.{event_type}'))
return
if not isinstance(event_data, dict):
await self.logger.warning(f'Event data is not dict, skipping: {event_type} -> {type(event_data)}')
return
if event_type not in MESSAGE_EVENT_TYPES:
await self._handle_native_event(QQOfficialEvent({'t': event_type, **event_data}))
return
payload = {'t': event_type, 'd': event_data}
message_data = await self.bot.get_message(payload)
if message_data:
@@ -337,7 +374,9 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
return 'channel_private', event.guild_id
raise NotSupportedError(f'reply_message:{event.t or "unknown_event"}')
async def _send_content_list(self, target_type: str, target_id: str, content_list: list[dict], msg_id: str | None = None) -> list[dict]:
async def _send_content_list(
self, target_type: str, target_id: str, content_list: list[dict], msg_id: str | None = None
) -> list[dict]:
target_type = self._normalize_target_type(target_type)
results: list[dict] = []
for content in content_list:
@@ -361,10 +400,14 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
raise NotSupportedError(f'send_message:{target_type}')
results.append({'type': content_type, 'raw': raw})
elif content_type == 'image':
raw = await self.bot.send_image_msg(target_type, target_id, file_url=content.get('url'), file_data=content.get('base64'), msg_id=msg_id)
raw = await self.bot.send_image_msg(
target_type, target_id, file_url=content.get('url'), file_data=content.get('base64'), msg_id=msg_id
)
results.append({'type': content_type, 'raw': raw})
elif content_type == 'voice':
raw = await self.bot.send_voice_msg(target_type, target_id, file_url=content.get('url'), file_data=content.get('base64'), msg_id=msg_id)
raw = await self.bot.send_voice_msg(
target_type, target_id, file_url=content.get('url'), file_data=content.get('base64'), msg_id=msg_id
)
results.append({'type': content_type, 'raw': raw})
elif content_type == 'file':
raw = await self.bot.send_file_msg(
@@ -12,12 +12,28 @@ from langbot_plugin.api.entities.builtin.platform import entities as platform_en
from langbot_plugin.api.entities.builtin.platform import events as platform_events
MESSAGE_EVENT_TYPES = {
'C2C_MESSAGE_CREATE',
'DIRECT_MESSAGE_CREATE',
'GROUP_AT_MESSAGE_CREATE',
'AT_MESSAGE_CREATE',
}
REACTION_ADD_EVENT_TYPES = {'MESSAGE_REACTION_ADD'}
REACTION_REMOVE_EVENT_TYPES = {'MESSAGE_REACTION_REMOVE'}
MEMBER_JOINED_EVENT_TYPES = {'GUILD_MEMBER_ADD', 'GROUP_MEMBER_ADD'}
MEMBER_LEFT_EVENT_TYPES = {'GUILD_MEMBER_REMOVE', 'GROUP_MEMBER_REMOVE'}
BOT_INVITED_EVENT_TYPES = {'GUILD_CREATE', 'GROUP_ADD_ROBOT'}
BOT_REMOVED_EVENT_TYPES = {'GUILD_DELETE', 'GROUP_DEL_ROBOT'}
class QQOfficialEventConverter(abstract_platform_adapter.AbstractEventConverter):
@staticmethod
async def yiri2target(event: platform_events.Event) -> typing.Any:
return getattr(event, 'source_platform_object', None)
async def target2legacy(self, event: QQOfficialEvent) -> platform_events.FriendMessage | platform_events.GroupMessage | None:
async def target2legacy(
self, event: QQOfficialEvent
) -> platform_events.FriendMessage | platform_events.GroupMessage | None:
eba_event = await self.target2yiri(event)
if not isinstance(eba_event, platform_events.MessageReceivedEvent):
return None
@@ -50,8 +66,18 @@ class QQOfficialEventConverter(abstract_platform_adapter.AbstractEventConverter)
)
async def target2yiri(self, event: QQOfficialEvent) -> platform_events.Event:
if event.t in {'C2C_MESSAGE_CREATE', 'DIRECT_MESSAGE_CREATE', 'GROUP_AT_MESSAGE_CREATE', 'AT_MESSAGE_CREATE'}:
if event.t in MESSAGE_EVENT_TYPES:
return await self.message_to_eba(event)
if event.t in REACTION_ADD_EVENT_TYPES | REACTION_REMOVE_EVENT_TYPES:
return self.reaction_to_eba(event, event.t in REACTION_ADD_EVENT_TYPES)
if event.t in MEMBER_JOINED_EVENT_TYPES:
return self.member_joined_to_eba(event)
if event.t in MEMBER_LEFT_EVENT_TYPES:
return self.member_left_to_eba(event)
if event.t in BOT_INVITED_EVENT_TYPES:
return self.bot_invited_to_eba(event)
if event.t in BOT_REMOVED_EVENT_TYPES:
return self.bot_removed_to_eba(event)
return self.platform_specific(event, f'qqofficial.{event.t or "unknown"}')
async def message_to_eba(self, event: QQOfficialEvent) -> platform_events.MessageReceivedEvent:
@@ -82,6 +108,100 @@ class QQOfficialEventConverter(abstract_platform_adapter.AbstractEventConverter)
source_platform_object=event,
)
def reaction_to_eba(self, event: QQOfficialEvent, is_add: bool) -> platform_events.MessageReactionEvent:
chat_type, chat_id, group = self._chat_from_event(event)
return platform_events.MessageReactionEvent(
type='message.reaction',
adapter_name=ADAPTER_NAME,
message_id=_event_value(event, 'message_id', 'msg_id', 'target_id', 'id'),
user=platform_entities.User(
id=_event_value(event, 'user_openid', 'member_openid', 'openid', 'user_id'),
nickname=_event_value(event, 'username', 'nick', 'nickname', 'user_openid', 'openid'),
),
reaction=_event_value(event, 'emoji', 'emoji_id', 'reaction', 'reaction_id', 'type'),
is_add=is_add,
chat_type=chat_type,
chat_id=chat_id,
group=group,
timestamp=_timestamp_value(event.timestamp),
source_platform_object=event,
)
def member_joined_to_eba(self, event: QQOfficialEvent) -> platform_events.MemberJoinedEvent:
group = self._group_from_event(event)
member = self._user_from_event(event, 'openid', 'member_openid', 'user_openid', 'user_id')
inviter_id = _event_value(event, 'inviter_openid', 'operator_openid', 'op_user_id')
return platform_events.MemberJoinedEvent(
type='group.member_joined',
adapter_name=ADAPTER_NAME,
group=group,
member=member,
inviter=platform_entities.User(id=inviter_id, nickname=inviter_id) if inviter_id else None,
timestamp=_timestamp_value(event.timestamp),
source_platform_object=event,
)
def member_left_to_eba(self, event: QQOfficialEvent) -> platform_events.MemberLeftEvent:
group = self._group_from_event(event)
member = self._user_from_event(event, 'openid', 'member_openid', 'user_openid', 'user_id')
operator_id = _event_value(event, 'operator_openid', 'op_user_id', 'user_openid')
return platform_events.MemberLeftEvent(
type='group.member_left',
adapter_name=ADAPTER_NAME,
group=group,
member=member,
operator=platform_entities.User(id=operator_id, nickname=operator_id) if operator_id else None,
is_kicked=event.t in {'GROUP_MEMBER_REMOVE', 'GUILD_MEMBER_REMOVE'},
timestamp=_timestamp_value(event.timestamp),
source_platform_object=event,
)
def bot_invited_to_eba(self, event: QQOfficialEvent) -> platform_events.BotInvitedToGroupEvent:
group = self._group_from_event(event)
inviter_id = _event_value(event, 'op_user_id', 'operator_openid', 'user_openid', 'member_openid', 'openid')
return platform_events.BotInvitedToGroupEvent(
type='bot.invited_to_group',
adapter_name=ADAPTER_NAME,
group=group,
inviter=platform_entities.User(id=inviter_id, nickname=inviter_id) if inviter_id else None,
request_id=_event_value(event, 'event_id', 'id'),
timestamp=_timestamp_value(event.timestamp),
source_platform_object=event,
)
def bot_removed_to_eba(self, event: QQOfficialEvent) -> platform_events.BotRemovedFromGroupEvent:
group = self._group_from_event(event)
operator_id = _event_value(event, 'op_user_id', 'operator_openid', 'user_openid', 'member_openid', 'openid')
return platform_events.BotRemovedFromGroupEvent(
type='bot.removed_from_group',
adapter_name=ADAPTER_NAME,
group=group,
operator=platform_entities.User(id=operator_id, nickname=operator_id) if operator_id else None,
timestamp=_timestamp_value(event.timestamp),
source_platform_object=event,
)
def _chat_from_event(
self, event: QQOfficialEvent
) -> tuple[platform_entities.ChatType, str, platform_entities.UserGroup | None]:
group_id = _event_value(event, 'group_openid', 'guild_id', 'channel_id', 'group_id')
if group_id:
return platform_entities.ChatType.GROUP, group_id, self._group_from_event(event)
private_id = _event_value(event, 'user_openid', 'member_openid', 'openid', 'user_id')
return platform_entities.ChatType.PRIVATE, private_id, None
@staticmethod
def _group_from_event(event: QQOfficialEvent) -> platform_entities.UserGroup:
group_id = _event_value(event, 'group_openid', 'guild_id', 'channel_id', 'group_id')
group_name = _event_value(event, 'group_name', 'guild_name', 'channel_name', 'name') or group_id
return platform_entities.UserGroup(id=group_id, name=group_name)
@staticmethod
def _user_from_event(event: QQOfficialEvent, *keys: str) -> platform_entities.User:
user_id = _event_value(event, *keys)
nickname = _event_value(event, 'username', 'nick', 'nickname') or user_id
return platform_entities.User(id=user_id, nickname=nickname)
@staticmethod
def _sender_id(event: QQOfficialEvent) -> str:
member_openid = event.member_openid or event.get('member_openid', '')
@@ -107,6 +227,17 @@ class QQOfficialEventConverter(abstract_platform_adapter.AbstractEventConverter)
)
def _event_value(event: QQOfficialEvent, *keys: str) -> str:
nested = event.get('d') if isinstance(event.get('d'), dict) else {}
for key in keys:
value = event.get(key)
if value in (None, '', {}):
value = nested.get(key)
if value not in (None, '', {}):
return str(value)
return ''
def _timestamp_value(value: str) -> float:
if not value:
return time.time()
@@ -88,6 +88,11 @@ spec:
supported_events:
- message.received
- message.reaction
- group.member_joined
- group.member_left
- bot.invited_to_group
- bot.removed_from_group
- platform.specific
supported_apis:
@@ -12,6 +12,9 @@ metadata:
icon: telegram.svg
spec:
categories:
- popular
- global
config:
- name: token
label:
+38 -8
View File
@@ -192,6 +192,25 @@ class RuntimeBot:
return False
return False
@classmethod
def _augment_event_data(
cls,
event_data: dict[str, typing.Any],
) -> dict[str, typing.Any]:
"""Inject virtual computed fields to simplify common filter patterns."""
message_chain = event_data.get('message_chain')
if isinstance(message_chain, list):
text_parts = [
comp.get('text', '') for comp in message_chain if isinstance(comp, dict) and comp.get('type') == 'Plain'
]
event_data['message_text'] = ''.join(text_parts)
event_data['message_element_types'] = [
comp.get('type', '') for comp in message_chain if isinstance(comp, dict)
]
if 'group' in event_data:
event_data['chat_type'] = 'group' if event_data.get('group') is not None else 'person'
return event_data
@classmethod
def _match_event_filters(
cls,
@@ -203,7 +222,7 @@ class RuntimeBot:
if not isinstance(filters, list):
return False
event_data = cls._safe_model_dump(event)
event_data = cls._augment_event_data(cls._safe_model_dump(event))
return all(
cls._match_event_filter(event_data, event_filter)
for event_filter in filters
@@ -854,11 +873,8 @@ class RuntimeBot:
launcher_id = custom_launcher_id
if pipeline_uuid_override is None:
message_text = str(event.message_chain)
element_types = [comp.type for comp in event.message_chain]
pipeline_uuid, routed_by_rule = self.resolve_pipeline_uuid(
launcher_kind, launcher_id, message_text, element_types
)
pipeline_uuid = None
routed_by_rule = False
else:
pipeline_uuid = pipeline_uuid_override
routed_by_rule = routed_by_event_binding
@@ -906,17 +922,31 @@ class RuntimeBot:
)
async def initialize(self):
def websocket_pipeline_uuid(event, adapter):
if adapter.__class__.__name__ != 'WebSocketAdapter':
return None
value = getattr(event, '_langbot_pipeline_uuid', None)
return value if isinstance(value, str) and value else None
async def on_friend_message(
event: platform_events.FriendMessage,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
):
await self._handle_legacy_message_event(event, adapter)
await self._handle_legacy_message_event(
event,
adapter,
pipeline_uuid_override=websocket_pipeline_uuid(event, adapter),
)
async def on_group_message(
event: platform_events.GroupMessage,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
):
await self._handle_legacy_message_event(event, adapter)
await self._handle_legacy_message_event(
event,
adapter,
pipeline_uuid_override=websocket_pipeline_uuid(event, adapter),
)
self.adapter.register_listener(platform_events.FriendMessage, on_friend_message)
self.adapter.register_listener(platform_events.GroupMessage, on_group_message)
@@ -12,6 +12,7 @@ metadata:
zh_Hant: OneBot v11 適配器,用於接入 QQ 機器人協定端,請查看文件了解使用方式
icon: onebot.png
spec:
legacy: true
categories:
- protocol
help_links:
@@ -12,6 +12,7 @@ metadata:
zh_Hant: 釘釘適配器,請查看文件了解使用方式
icon: dingtalk.svg
spec:
legacy: true
categories:
- china
help_links:
@@ -20,6 +20,7 @@ metadata:
es_ES: Adaptador de Discord, requiere un entorno de red con acceso al servidor de Discord
icon: discord.svg
spec:
legacy: true
categories:
- popular
- global
@@ -12,6 +12,7 @@ metadata:
zh_Hant: KOOK 適配器(原開黑啦),支援頻道訊息和私聊訊息
icon: kook.png
spec:
legacy: true
categories:
- china
help_links:
@@ -14,6 +14,7 @@ metadata:
ja_JP: Lark アダプター、長期接続およびWebhookモードの両方をサポートしています。使用方法の詳細については、ドキュメントを参照してください。
icon: lark.svg
spec:
legacy: true
categories:
- popular
- china
@@ -12,6 +12,7 @@ metadata:
zh_Hant: 微信公眾號適配器,需要公網地址以接收訊息推送,請查看文件了解使用方式
icon: officialaccount.png
spec:
legacy: true
categories:
- china
help_links:
@@ -12,6 +12,7 @@ metadata:
zh_Hant: QQ 官方 API,支援 Webhook 和 WebSocket 兩種連線模式
icon: qqofficial.svg
spec:
legacy: true
categories:
- china
help_links:
@@ -20,6 +20,7 @@ metadata:
es_ES: Adaptador de Slack, requiere una dirección pública para recibir notificaciones de mensajes de Slack, consulte la documentación para obtener instrucciones de uso
icon: slack.png
spec:
legacy: true
categories:
- popular
- global
@@ -20,6 +20,7 @@ metadata:
es_ES: Adaptador de Telegram, consulte la documentación para obtener instrucciones de uso
icon: telegram.svg
spec:
legacy: true
categories:
- popular
- global
@@ -447,6 +447,8 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
sender=sender, message_chain=message_chain, time=datetime.now().timestamp()
)
object.__setattr__(event, '_langbot_pipeline_uuid', pipeline_uuid)
# 设置流水线UUID (proxy bot always needs it for reply_message routing)
self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid = pipeline_uuid
if owner_bot is not None:
@@ -12,6 +12,7 @@ metadata:
zh_Hant: 企業微信內部機器人,請查看文件了解使用方式
icon: wecom.png
spec:
legacy: true
categories:
- popular
- china
@@ -12,6 +12,7 @@ metadata:
zh_Hant: 企業微信智慧機器人,支援長連線和 Webhook 兩種接入方式,請查看文件了解使用方式
icon: wecombot.png
spec:
legacy: true
categories:
- china
help_links:
@@ -12,6 +12,7 @@ metadata:
zh_Hant: 企業微信對外客服機器人,需要公網地址以接收訊息推送,請查看文件了解使用方式
icon: wecom.png
spec:
legacy: true
categories:
- china
help_links:
+49
View File
@@ -556,6 +556,55 @@ class RuntimeConnectionHandler(handler.Handler):
},
)
@self.action(PluginToRuntimeAction.COUNT_TOKENS)
async def count_tokens(data: dict[str, Any]) -> handler.ActionResponse:
"""Count model input tokens.
For AgentRunner calls: requires run_id and validates model_uuid against session.resources.models.
For regular plugin calls: no run_id, unrestricted access (backward compatibility).
"""
llm_model_uuid = data['llm_model_uuid']
messages = data['messages']
funcs = data.get('funcs', [])
extra_args = data.get('extra_args', {})
run_id = data.get('run_id')
caller_plugin_identity = data.get('caller_plugin_identity')
if run_id:
_session, error = await _validate_run_authorization(
run_id, 'model', llm_model_uuid, self.ap, caller_plugin_identity, operation='count_tokens'
)
if error:
return error
llm_model = await self.ap.model_mgr.get_model_by_uuid(llm_model_uuid)
if llm_model is None:
return handler.ActionResponse.error(
message=f'LLM model with llm_model_uuid {llm_model_uuid} not found',
)
messages_obj = [provider_message.Message.model_validate(message) for message in messages]
async def _placeholder_func(**kwargs):
pass
funcs_obj = [resource_tool.LLMTool.model_validate({**func, 'func': _placeholder_func}) for func in funcs]
count_tokens_method = getattr(llm_model.provider.requester, 'count_tokens', None)
if not callable(count_tokens_method):
return handler.ActionResponse.error(message='LLM provider does not support token counting')
try:
tokens = await count_tokens_method(
model=llm_model,
messages=messages_obj,
funcs=funcs_obj,
extra_args=extra_args,
)
except Exception as exc:
return handler.ActionResponse.error(message=f'Token counting failed: {exc}')
return handler.ActionResponse.success(data={'tokens': tokens})
@self.action(PluginToRuntimeAction.INVOKE_LLM)
async def invoke_llm(data: dict[str, Any]) -> handler.ActionResponse:
"""Invoke llm
@@ -411,6 +411,20 @@ class ProviderAPIRequester(metaclass=abc.ABCMeta):
"""
pass
async def count_tokens(
self,
model: RuntimeLLMModel,
messages: typing.List[provider_message.Message],
funcs: typing.List[resource_tool.LLMTool] = None,
extra_args: dict[str, typing.Any] = {},
) -> int:
"""Count model input tokens before invoking the model.
Requesters should use the same provider/model conversion path as
``invoke_llm`` so the preflight count matches the actual request shape.
"""
raise NotImplementedError('This requester does not support token counting')
async def invoke_llm_stream(
self,
query: pipeline_query.Query,
@@ -521,6 +521,33 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
return args
async def count_tokens(
self,
model: requester.RuntimeLLMModel,
messages: typing.List[provider_message.Message],
funcs: typing.List[resource_tool.LLMTool] = None,
extra_args: dict[str, typing.Any] = {},
) -> int:
"""Count input tokens with LiteLLM's model-aware tokenizer."""
args = await self._build_completion_args(model, messages, funcs, extra_args, stream=False)
count_args: dict[str, typing.Any] = {
'model': args['model'],
'messages': args['messages'],
}
if 'tools' in args:
count_args['tools'] = args['tools']
if 'tool_choice' in args:
count_args['tool_choice'] = args['tool_choice']
try:
tokens = litellm.token_counter(**count_args)
except Exception as e:
self._handle_litellm_error(e)
if isinstance(tokens, bool) or not isinstance(tokens, int) or tokens < 0:
raise errors.RequesterError(f'token counter returned invalid value: {tokens!r}')
return tokens
async def invoke_llm(
self,
query: pipeline_query.Query,
+1 -1
View File
@@ -61,7 +61,7 @@ def langbot_process(e2e_config_path, e2e_port, e2e_tmpdir):
project_root=project_root,
work_dir=e2e_tmpdir, # Run in tmpdir where data/config.yaml exists
port=e2e_port,
timeout=60, # Longer timeout for first startup
timeout=180, # Longer timeout for first startup
collect_coverage=collect_coverage,
)
@@ -0,0 +1,473 @@
"""E2E tests for pluginized AgentRunner execution.
This module starts the real LangBot backend with the plugin system enabled and
loads a deterministic AgentRunner plugin through the real SDK Plugin Runtime.
"""
from __future__ import annotations
import shutil
import socket
import sqlite3
import subprocess
import tempfile
import textwrap
import time
from pathlib import Path
import httpx
import pytest
from tests.e2e.utils.config_factory import create_minimal_config, create_test_directories
from tests.e2e.utils.process_manager import LangBotProcess, find_project_root
pytestmark = pytest.mark.e2e
QA_RUNNER_ID = 'plugin:e2e/agent-runner-qa/default'
QA_PLUGIN_DIRNAME = 'e2e__agent-runner-qa'
@pytest.fixture(scope='session')
def agent_runner_e2e_port():
"""Port for the AgentRunner plugin-runtime E2E process."""
return 15310
@pytest.fixture(scope='session')
def agent_runner_e2e_tmpdir():
"""Create temporary directory for AgentRunner E2E testing."""
tmpdir = Path(tempfile.mkdtemp(prefix='langbot_agent_runner_e2e_'))
yield tmpdir
shutil.rmtree(tmpdir, ignore_errors=True)
def _write_qa_agent_runner_plugin(plugin_root: Path) -> None:
"""Write a deterministic AgentRunner plugin used by this E2E."""
runner_dir = plugin_root / 'components' / 'agent_runner'
runner_dir.mkdir(parents=True, exist_ok=True)
(plugin_root / 'assets').mkdir(parents=True, exist_ok=True)
(plugin_root / 'assets' / 'icon.svg').write_text(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"></svg>',
encoding='utf-8',
)
(plugin_root / 'manifest.yaml').write_text(
textwrap.dedent(
"""
apiVersion: langbot/v1
kind: Plugin
metadata:
author: e2e
name: agent-runner-qa
version: 0.1.0
label:
en_US: AgentRunner QA
zh_Hans: AgentRunner QA
description:
en_US: Deterministic AgentRunner E2E probe.
zh_Hans: 确定性的 AgentRunner E2E 探针
icon: assets/icon.svg
spec:
version: 0.1.0
config: []
components:
AgentRunner:
fromDirs:
- path: components/agent_runner/
pages: []
execution:
python:
path: main.py
attr: AgentRunnerQAPlugin
"""
).strip()
+ '\n',
encoding='utf-8',
)
(plugin_root / 'main.py').write_text(
textwrap.dedent(
"""
from __future__ import annotations
from langbot_plugin.api.definition.plugin import BasePlugin
class AgentRunnerQAPlugin(BasePlugin):
async def initialize(self) -> None:
pass
"""
).strip()
+ '\n',
encoding='utf-8',
)
(runner_dir / 'default.yaml').write_text(
textwrap.dedent(
"""
apiVersion: langbot/v1
kind: AgentRunner
metadata:
name: default
label:
en_US: QA Echo Runner
zh_Hans: QA Echo Runner
description:
en_US: Echoes input and exercises run-scoped state APIs.
zh_Hans: 回显输入并验证运行级状态 API
spec:
config: []
capabilities:
streaming: false
permissions: {}
execution:
python:
path: default.py
attr: DefaultAgentRunner
"""
).strip()
+ '\n',
encoding='utf-8',
)
(runner_dir / 'default.py').write_text(
textwrap.dedent(
"""
from __future__ import annotations
from typing import AsyncGenerator
from langbot_plugin.api.definition.components.agent_runner.runner import AgentRunner
from langbot_plugin.api.entities.builtin.agent_runner.context import AgentRunContext
from langbot_plugin.api.entities.builtin.agent_runner.result import AgentRunResult
from langbot_plugin.api.entities.builtin.provider.message import Message
class DefaultAgentRunner(AgentRunner):
async def run(self, ctx: AgentRunContext) -> AsyncGenerator[AgentRunResult, None]:
text = ctx.input.to_text()
yield AgentRunResult.message_completed(
ctx.run_id,
Message(role='assistant', content=f'e2e echo: {text}'),
)
yield AgentRunResult.state_updated(
ctx.run_id,
'e2e.echo_count',
{'count': 1},
scope='conversation',
)
yield AgentRunResult.run_completed(ctx.run_id, finish_reason='stop')
"""
).strip()
+ '\n',
encoding='utf-8',
)
def _free_port() -> int:
"""Reserve a currently-free localhost TCP port for this E2E process."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(('127.0.0.1', 0))
return int(sock.getsockname()[1])
@pytest.fixture(scope='session')
def agent_runner_runtime_ports():
"""Control/debug ports for the standalone plugin runtime."""
control_port = _free_port()
debug_port = _free_port()
while debug_port == control_port:
debug_port = _free_port()
return control_port, debug_port
@pytest.fixture(scope='session')
def agent_runner_e2e_config_path(agent_runner_e2e_tmpdir, agent_runner_e2e_port, agent_runner_runtime_ports):
"""Create a plugin-enabled config and deterministic AgentRunner fixture."""
config_path = create_minimal_config(agent_runner_e2e_tmpdir, port=agent_runner_e2e_port)
create_test_directories(agent_runner_e2e_tmpdir)
import yaml
with open(config_path, encoding='utf-8') as f:
config = yaml.safe_load(f)
config['api']['global_api_key'] = 'e2e-agent-runner-key'
runtime_control_port, _runtime_debug_port = agent_runner_runtime_ports
config['plugin']['enable'] = True
config['plugin']['runtime_ws_url'] = f'ws://127.0.0.1:{runtime_control_port}/control/ws'
config['plugin']['enable_marketplace'] = False
config['box']['enabled'] = False
config['system']['jwt']['secret'] = 'e2e-agent-runner-secret-key'
with open(config_path, 'w', encoding='utf-8') as f:
yaml.safe_dump(config, f, default_flow_style=False)
_write_qa_agent_runner_plugin(agent_runner_e2e_tmpdir / 'data' / 'plugins' / QA_PLUGIN_DIRNAME)
return config_path
@pytest.fixture(scope='session')
def agent_runner_runtime_process(agent_runner_e2e_tmpdir, agent_runner_runtime_ports):
"""Start the real SDK plugin runtime over WebSocket."""
control_port, debug_port = agent_runner_runtime_ports
stdout_path = agent_runner_e2e_tmpdir / 'plugin-runtime.stdout.log'
stderr_path = agent_runner_e2e_tmpdir / 'plugin-runtime.stderr.log'
stdout_file = open(stdout_path, 'wb')
stderr_file = open(stderr_path, 'wb')
proc = subprocess.Popen(
[
str(find_project_root() / '.venv' / 'bin' / 'python'),
'-m',
'langbot_plugin.cli.__init__',
'rt',
'--ws-control-port',
str(control_port),
'--ws-debug-port',
str(debug_port),
],
cwd=agent_runner_e2e_tmpdir,
stdout=stdout_file,
stderr=stderr_file,
start_new_session=True,
)
yield proc
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
stdout_file.close()
stderr_file.close()
@pytest.fixture(scope='session')
def agent_runner_langbot_process(
agent_runner_e2e_config_path,
agent_runner_e2e_port,
agent_runner_e2e_tmpdir,
agent_runner_runtime_process,
):
"""Start real LangBot with plugin runtime enabled."""
project_root = find_project_root()
proc = LangBotProcess(
project_root=project_root,
work_dir=agent_runner_e2e_tmpdir,
port=agent_runner_e2e_port,
timeout=180,
debug=True,
cli_args=['--standalone-runtime'],
)
success = proc.start()
if not success:
stdout, stderr = proc.get_logs()
pytest.fail(f'LangBot failed to start with AgentRunner plugin runtime:\nstdout: {stdout}\nstderr: {stderr}')
yield proc
proc.stop()
@pytest.fixture
def agent_runner_client(agent_runner_e2e_port, agent_runner_langbot_process):
"""HTTP client for the AgentRunner E2E backend."""
with httpx.Client(
base_url=f'http://127.0.0.1:{agent_runner_e2e_port}',
timeout=90.0,
trust_env=False,
) as client:
yield client
def _init_and_auth(client: httpx.Client) -> str:
"""Initialize the test admin user and return a bearer token."""
init_resp = client.post('/api/v1/user/init', json={'user': 'admin', 'password': 'admin'})
assert init_resp.status_code == 200
assert init_resp.json()['code'] in [0, 1]
auth_resp = client.post('/api/v1/user/auth', json={'user': 'admin', 'password': 'admin'})
assert auth_resp.status_code == 200
payload = auth_resp.json()
assert payload['code'] == 0
return payload['data']['token']
def test_plugin_runtime_discovers_agent_runner(agent_runner_client, agent_runner_langbot_process):
"""Pipeline metadata should include the real runtime-discovered QA runner."""
token = _init_and_auth(agent_runner_client)
start = time.time()
while time.time() - start < 60:
response = agent_runner_client.get(
'/api/v1/pipelines/_/metadata',
headers={'Authorization': f'Bearer {token}'},
)
assert response.status_code == 200
data = response.json()
assert data['code'] == 0
metadata_groups = data['data']['configs']
ai_metadata = next(group for group in metadata_groups if group.get('name') == 'ai')
runner_stage = next(stage for stage in ai_metadata['stages'] if stage['name'] == 'runner')
runner_select = next(item for item in runner_stage['config'] if item['name'] == 'id')
option_names = {option['name'] for option in runner_select['options']}
if QA_RUNNER_ID in option_names:
return
time.sleep(2)
assert QA_RUNNER_ID in option_names
def test_host_orchestrator_runs_agent_runner_and_records_ledger(
agent_runner_e2e_config_path,
agent_runner_e2e_tmpdir,
agent_runner_runtime_process,
):
"""The Host orchestrator should run the pluginized runner and persist run side effects."""
import asyncio
import os
from langbot.pkg.agent.runner.host_models import (
AgentBinding,
AgentEventEnvelope,
BindingScope,
DeliveryPolicy,
StatePolicy,
)
from langbot.pkg.core import boot
from langbot.pkg.utils import platform as platform_utils
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
from langbot_plugin.api.entities.builtin.agent_runner.event import ActorContext, SubjectContext
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
async def _run_probe():
previous_cwd = Path.cwd()
previous_standalone_runtime = platform_utils.standalone_runtime
os.chdir(agent_runner_e2e_tmpdir)
platform_utils.standalone_runtime = True
ap = None
try:
ap = await boot.make_app(asyncio.get_running_loop())
for _ in range(60):
handler = getattr(ap.plugin_connector, 'handler', None)
if handler is not None:
await handler.ping()
break
await asyncio.sleep(1)
else:
raise AssertionError('Plugin runtime did not connect')
for _ in range(60):
runners = await ap.agent_runner_registry.list_runners(use_cache=False)
if any(runner.id == QA_RUNNER_ID for runner in runners):
break
await asyncio.sleep(1)
else:
raise AssertionError(f'{QA_RUNNER_ID} was not discovered')
event = AgentEventEnvelope(
event_id='e2e-orchestrator-event-001',
event_type='message.received',
source='api',
conversation_id='e2e-conversation',
thread_id='e2e-thread',
actor=ActorContext(actor_type='user', actor_id='user-001', actor_name='E2E User'),
subject=SubjectContext(subject_type='chat', subject_id='chat-001'),
input=AgentInput(text='hello from orchestrator e2e'),
delivery=DeliveryContext(surface='e2e'),
)
binding = AgentBinding(
binding_id='e2e-binding',
scope=BindingScope(scope_type='global'),
runner_id=QA_RUNNER_ID,
state_policy=StatePolicy(enable_state=True, state_scopes=['conversation']),
delivery_policy=DeliveryPolicy(enable_streaming=False, enable_reply=True),
)
return [message async for message in ap.agent_run_orchestrator.run(event, binding)]
finally:
if ap is not None:
ap.dispose()
platform_utils.standalone_runtime = previous_standalone_runtime
os.chdir(previous_cwd)
messages = asyncio.run(_run_probe())
assert len(messages) == 1
assert messages[0].role == 'assistant'
assert messages[0].content == 'e2e echo: hello from orchestrator e2e'
db_path = agent_runner_e2e_tmpdir / 'data' / 'langbot.db'
conn = sqlite3.connect(str(db_path))
try:
run_row = conn.execute(
"SELECT status, runner_id FROM agent_run WHERE event_id = 'e2e-orchestrator-event-001'"
).fetchone()
assert run_row == ('completed', QA_RUNNER_ID)
event_types = {
row[0]
for row in conn.execute(
"SELECT type FROM agent_run_event WHERE run_id = (SELECT run_id FROM agent_run WHERE event_id = 'e2e-orchestrator-event-001')"
).fetchall()
}
assert {'state.updated', 'message.completed', 'run.completed'}.issubset(event_types)
state_row = conn.execute(
"SELECT value_json FROM agent_runner_state WHERE state_key = 'e2e.echo_count'"
).fetchone()
assert state_row is not None
assert '"count": 1' in state_row[0]
finally:
conn.close()
def test_pluginized_agent_runner_executes_through_runtime(agent_runner_client, agent_runner_langbot_process):
"""The Host debug surface should invoke the QA runner through the real Plugin Runtime."""
token = _init_and_auth(agent_runner_client)
start = time.time()
while time.time() - start < 60:
metadata_response = agent_runner_client.get(
'/api/v1/pipelines/_/metadata',
headers={'Authorization': f'Bearer {token}'},
)
assert metadata_response.status_code == 200
metadata = metadata_response.json()['data']['configs']
ai_metadata = next(group for group in metadata if group.get('name') == 'ai')
runner_stage = next(stage for stage in ai_metadata['stages'] if stage['name'] == 'runner')
runner_select = next(item for item in runner_stage['config'] if item['name'] == 'id')
if QA_RUNNER_ID in {option['name'] for option in runner_select['options']}:
break
time.sleep(2)
else:
pytest.fail(f'{QA_RUNNER_ID} was not discovered before run_agent')
response = agent_runner_client.post(
'/api/v1/system/debug/plugin/action',
headers={'Authorization': f'Bearer {token}'},
json={
'action': 'run_agent',
'timeout': 60,
'data': {
'plugin_author': 'e2e',
'plugin_name': 'agent-runner-qa',
'runner_name': 'default',
'context': {
'run_id': 'e2e-run-001',
'trigger': {'type': 'message.received'},
'event': {
'event_id': 'e2e-event-001',
'event_type': 'message.received',
'source': 'api',
},
'input': {'text': 'hello from real e2e'},
'delivery': {'surface': 'e2e'},
'resources': {},
'runtime': {},
},
},
},
)
assert response.status_code == 200
payload = response.json()
assert payload['code'] == 0
result = payload['data']
assert result['type'] == 'message.completed', result
assert result['data']['message']['role'] == 'assistant'
assert result['data']['message']['content'] == 'e2e echo: hello from real e2e'
+25
View File
@@ -141,6 +141,27 @@ def create_minimal_config(tmpdir: Path, port: int = 15300) -> Path:
'delete_batch_size': 1000,
},
},
'box': {
'enabled': False,
'backend': 'local',
'runtime': {
'endpoint': '',
},
'local': {
'profile': 'default',
'image': '',
'host_root': str(tmpdir / 'box'),
'default_workspace': '',
'skills_root': 'skills',
'allowed_mount_roots': [str(tmpdir / 'box'), '/tmp'],
'workspace_quota_mb': None,
},
'e2b': {
'api_key': '',
'api_url': '',
'template': '',
},
},
'space': {
'url': 'https://space.langbot.app',
'models_gateway_api_url': 'https://api.langbot.cloud/v1',
@@ -168,8 +189,12 @@ def create_test_directories(tmpdir: Path) -> dict[str, Path]:
"""Create necessary directories for LangBot testing."""
directories = {
'data': tmpdir / 'data',
'labels': tmpdir / 'data' / 'labels',
'metadata': tmpdir / 'data' / 'metadata',
'logs': tmpdir / 'logs',
'data_logs': tmpdir / 'data' / 'logs',
'storage': tmpdir / 'storage',
'box': tmpdir / 'box',
'chroma': tmpdir / 'chroma',
}
+40 -7
View File
@@ -9,6 +9,7 @@ import subprocess
import time
import signal
import os
import sys
from pathlib import Path
from typing import Optional
import logging
@@ -26,13 +27,21 @@ class LangBotProcess:
port: int = 15300,
timeout: int = 30,
collect_coverage: bool = True,
debug: bool = False,
cli_args: list[str] | None = None,
):
self.project_root = project_root
self.work_dir = work_dir # Directory containing data/config.yaml
self.port = port
self.timeout = timeout
self.collect_coverage = collect_coverage
self.debug = debug
self.cli_args = cli_args or []
self.process: Optional[subprocess.Popen] = None
self._stdout_file = None
self._stderr_file = None
self._stdout_path = self.work_dir / 'langbot.stdout.log'
self._stderr_path = self.work_dir / 'langbot.stderr.log'
self._stdout_data: bytes = b''
self._stderr_data: bytes = b''
self._coverage_file: Optional[Path] = None
@@ -63,13 +72,14 @@ class LangBotProcess:
# Disable telemetry
env['SPACE__DISABLE_TELEMETRY'] = 'true'
env['SPACE__DISABLE_MODELS_SERVICE'] = 'true'
if self.debug:
env['DEBUG'] = 'true'
# Build command
if self.collect_coverage:
# Use coverage.py to collect coverage data
# Set COVERAGE_PROCESS_START to enable coverage in subprocess
self._coverage_file = self.work_dir / '.coverage.e2e'
env['COVERAGE_PROCESS_START'] = str(self.project_root / '.coveragerc')
env['COVERAGE_FILE'] = str(self._coverage_file)
# Create .coveragerc for subprocess
@@ -88,27 +98,33 @@ precision = 2
coveragerc_path = self.work_dir / '.coveragerc'
with open(coveragerc_path, 'w') as f:
f.write(coveragerc_content)
env['COVERAGE_PROCESS_START'] = str(coveragerc_path)
cmd = [
sys.executable,
'-m',
'coverage',
'run',
'--rcfile=' + str(coveragerc_path),
'-m',
'langbot',
*self.cli_args,
]
else:
cmd = ['uv', 'run', 'python', '-m', 'langbot']
cmd = [sys.executable, '-m', 'langbot', *self.cli_args]
logger.info(f'Starting LangBot in: {self.work_dir}')
logger.info(f'Command: {cmd}')
# Start process (run in work_dir so it finds data/config.yaml)
self._stdout_file = open(self._stdout_path, 'wb')
self._stderr_file = open(self._stderr_path, 'wb')
self.process = subprocess.Popen(
cmd,
cwd=self.work_dir,
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
stdout=self._stdout_file,
stderr=self._stderr_file,
preexec_fn=os.setsid if os.name != 'nt' else None,
)
@@ -117,7 +133,14 @@ precision = 2
while time.time() - start_time < self.timeout:
# Check if process died
if self.process.poll() is not None:
self._stdout_data, self._stderr_data = self.process.communicate()
if self._stdout_file:
self._stdout_file.close()
self._stdout_file = None
if self._stderr_file:
self._stderr_file.close()
self._stderr_file = None
self._stdout_data = self._stdout_path.read_bytes() if self._stdout_path.exists() else b''
self._stderr_data = self._stderr_path.read_bytes() if self._stderr_path.exists() else b''
logger.error(f'LangBot process died: {self._stderr_data.decode()}')
return False
@@ -170,8 +193,14 @@ precision = 2
self.process.wait()
# Collect output for debugging
if self.process.stdout or self.process.stderr:
self._stdout_data, self._stderr_data = self.process.communicate()
if self._stdout_file:
self._stdout_file.close()
self._stdout_file = None
if self._stderr_file:
self._stderr_file.close()
self._stderr_file = None
self._stdout_data = self._stdout_path.read_bytes() if self._stdout_path.exists() else b''
self._stderr_data = self._stderr_path.read_bytes() if self._stderr_path.exists() else b''
self.process = None
@@ -183,6 +212,10 @@ precision = 2
"""Get stdout and stderr logs."""
stdout = self._stdout_data.decode('utf-8', errors='replace')
stderr = self._stderr_data.decode('utf-8', errors='replace')
if not stdout and self._stdout_path.exists():
stdout = self._stdout_path.read_text(encoding='utf-8', errors='replace')
if not stderr and self._stderr_path.exists():
stderr = self._stderr_path.read_text(encoding='utf-8', errors='replace')
return stdout, stderr
def get_coverage_file(self) -> Optional[Path]:
+1 -1
View File
@@ -77,7 +77,7 @@ def make_session(
}
authorized_operations: dict[str, dict[str, set[str]]] = {
'model': {
m.get('model_id'): set(m.get('operations') or ['invoke', 'stream', 'rerank'])
m.get('model_id'): set(m.get('operations') or ['invoke', 'stream', 'rerank', 'count_tokens'])
for m in res.get('models', [])
if m.get('model_id')
},
@@ -14,7 +14,7 @@ from langbot.pkg.agent.runner.resource_builder import AgentResourceBuilder
RUNNER_ID = 'plugin:test/runner/default'
FULL_PERMISSIONS = {
'models': ['invoke', 'stream', 'rerank'],
'models': ['count_tokens', 'invoke', 'stream', 'rerank'],
'tools': ['detail', 'call'],
'knowledge_bases': ['list', 'retrieve'],
'history': ['page', 'search'],
@@ -139,9 +139,24 @@ async def test_build_models_authorizes_config_declared_llm_and_rerank_models(app
resources = await build_resources(app, query, descriptor)
assert resources['models'] == [
{'model_id': 'primary', 'model_type': 'llm', 'provider': 'test-provider', 'operations': ['invoke', 'stream']},
{'model_id': 'fallback', 'model_type': 'llm', 'provider': 'test-provider', 'operations': ['invoke', 'stream']},
{'model_id': 'aux', 'model_type': 'llm', 'provider': 'aux-provider', 'operations': ['invoke', 'stream']},
{
'model_id': 'primary',
'model_type': 'llm',
'provider': 'test-provider',
'operations': ['invoke', 'stream', 'count_tokens'],
},
{
'model_id': 'fallback',
'model_type': 'llm',
'provider': 'test-provider',
'operations': ['invoke', 'stream', 'count_tokens'],
},
{
'model_id': 'aux',
'model_type': 'llm',
'provider': 'aux-provider',
'operations': ['invoke', 'stream', 'count_tokens'],
},
{'model_id': 'rerank', 'model_type': 'rerank', 'provider': 'rerank-provider', 'operations': ['rerank']},
]
@@ -189,7 +204,12 @@ async def test_build_models_authorizes_rerank_and_llm_refs_from_config(app):
resources = await build_resources(app, query, descriptor)
assert resources['models'] == [
{'model_id': 'llm', 'model_type': 'llm', 'provider': 'test-provider', 'operations': ['invoke', 'stream']},
{
'model_id': 'llm',
'model_type': 'llm',
'provider': 'test-provider',
'operations': ['invoke', 'stream', 'count_tokens'],
},
{'model_id': 'rerank', 'model_type': 'rerank', 'provider': 'rerank-provider', 'operations': ['rerank']},
]
@@ -222,7 +242,12 @@ async def test_build_resources_accepts_dynamic_form_type_aliases(app):
resources = await build_resources(app, query, descriptor)
assert resources['models'] == [
{'model_id': 'llm_alias', 'model_type': 'llm', 'provider': 'test-provider', 'operations': ['invoke', 'stream']},
{
'model_id': 'llm_alias',
'model_type': 'llm',
'provider': 'test-provider',
'operations': ['invoke', 'stream', 'count_tokens'],
},
]
assert resources['knowledge_bases'] == [
{'kb_id': 'kb_alias', 'kb_name': 'name-kb_alias', 'kb_type': 'default', 'operations': ['list', 'retrieve']},
@@ -28,7 +28,7 @@ class _PersistenceManager:
return _FakeResult(SimpleNamespace(name='Updated Pipeline'))
async def test_update_bot_copies_input_before_filtering_and_setting_pipeline_name():
async def test_update_bot_copies_input_before_filtering_legacy_routing_fields():
persistence_mgr = _PersistenceManager()
runtime_bot = SimpleNamespace(enable=False)
platform_mgr = SimpleNamespace(
@@ -46,17 +46,17 @@ async def test_update_bot_copies_input_before_filtering_and_setting_pipeline_nam
'uuid': 'caller-owned-uuid',
'name': 'Test Bot',
'use_pipeline_uuid': 'pipeline-1',
'pipeline_routing_rules': [{'type': 'launcher_type'}],
}
await service.update_bot('bot-1', payload)
# caller's dict must not be mutated
assert payload == {
'uuid': 'caller-owned-uuid',
'name': 'Test Bot',
'use_pipeline_uuid': 'pipeline-1',
'pipeline_routing_rules': [{'type': 'launcher_type'}],
}
assert persistence_mgr.update_values == {
'name': 'Test Bot',
'use_pipeline_uuid': 'pipeline-1',
'use_pipeline_name': 'Updated Pipeline',
}
# legacy routing fields are stripped; only name is persisted
assert persistence_mgr.update_values == {'name': 'Test Bot'}
@@ -0,0 +1,309 @@
from __future__ import annotations
import datetime as dt
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from langbot.pkg.api.http.service.agent import (
AGENT_DEFAULT_EVENT_PATTERNS,
AGENT_KIND_AGENT,
AGENT_KIND_PIPELINE,
PIPELINE_EVENT_PATTERNS,
AgentService,
)
pytestmark = pytest.mark.asyncio
def _result(items: list | None = None, first_item=None):
result = Mock()
result.all = Mock(return_value=items or [])
result.first = Mock(return_value=first_item)
return result
def _agent_row(
agent_uuid: str = 'agent-1',
name: str = 'Test Agent',
updated_at: dt.datetime | str | None = None,
config: dict | None = None,
supported_event_patterns: list[str] | None = None,
):
return SimpleNamespace(
uuid=agent_uuid,
name=name,
description='Agent description',
emoji='A',
kind=AGENT_KIND_AGENT,
component_ref='plugin:test/runner/default',
config=config or {
'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),
)
def _serialize_agent(model_cls, entity, masked_columns=None):
return {
'uuid': entity.uuid,
'name': entity.name,
'description': entity.description,
'emoji': entity.emoji,
'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,
}
def _compiled_params(statement):
return statement.compile().params
def _compiled_update_values(statement):
return {
key: value
for key, value in statement.compile().params.items()
if not key.startswith('uuid_')
}
def _make_app():
app = SimpleNamespace()
app.persistence_mgr = SimpleNamespace(
execute_async=AsyncMock(),
serialize_model=Mock(side_effect=_serialize_agent),
)
app.pipeline_service = SimpleNamespace(
get_pipeline_metadata=AsyncMock(return_value=[]),
get_pipelines=AsyncMock(return_value=[]),
get_pipeline=AsyncMock(return_value=None),
create_pipeline=AsyncMock(),
update_pipeline=AsyncMock(),
delete_pipeline=AsyncMock(),
_get_default_values_from_schema=Mock(return_value={}),
)
app.agent_runner_registry = None
app.logger = Mock()
return app
class TestAgentServiceMetadata:
async def test_get_agent_metadata_exposes_runner_config_and_kind_capabilities(self):
app = _make_app()
ai_metadata = {'name': 'ai', 'stages': [{'name': 'runner'}]}
app.pipeline_service.get_pipeline_metadata = AsyncMock(
return_value=[{'name': 'trigger'}, ai_metadata, {'name': 'output'}]
)
metadata = await AgentService(app).get_agent_metadata()
assert metadata['runner_config'] == ai_metadata
assert metadata['kinds'] == [
{
'name': AGENT_KIND_AGENT,
'supported_event_patterns': AGENT_DEFAULT_EVENT_PATTERNS,
'message_only': False,
},
{
'name': AGENT_KIND_PIPELINE,
'supported_event_patterns': PIPELINE_EVENT_PATTERNS,
'message_only': True,
},
]
class TestAgentServiceListAndLookup:
async def test_get_agents_merges_agents_and_pipelines_without_leaking_config(self):
app = _make_app()
app.persistence_mgr.execute_async = AsyncMock(
return_value=_result(
items=[
_agent_row(
agent_uuid='agent-1',
updated_at=dt.datetime(2026, 1, 1, 10, 0, 0),
supported_event_patterns=['platform.member.*'],
)
]
)
)
app.pipeline_service.get_pipelines = AsyncMock(
return_value=[
{
'uuid': 'pipeline-1',
'name': 'Pipeline Agent',
'description': 'Legacy pipeline',
'emoji': 'P',
'config': {'ai': {'runner': {'id': 'pipeline-runner'}}},
'created_at': '2026-01-01T08:00:00',
'updated_at': '2026-01-01T11:00:00',
}
]
)
agents = await AgentService(app).get_agents(sort_by='updated_at', sort_order='DESC')
assert [agent['uuid'] for agent in agents] == ['pipeline-1', 'agent-1']
assert agents[0]['kind'] == AGENT_KIND_PIPELINE
assert agents[0]['component_ref'] == 'pipeline'
assert agents[0]['capability'] == {
'supported_event_patterns': PIPELINE_EVENT_PATTERNS,
'message_only': True,
}
assert agents[1]['kind'] == AGENT_KIND_AGENT
assert agents[1]['capability'] == {
'supported_event_patterns': ['platform.member.*'],
'message_only': False,
}
assert all('config' not in agent for agent in agents)
async def test_get_agent_returns_agent_with_config_before_pipeline_fallback(self):
app = _make_app()
agent = _agent_row(agent_uuid='agent-1')
app.persistence_mgr.execute_async = AsyncMock(return_value=_result(first_item=agent))
result = await AgentService(app).get_agent('agent-1')
assert result['uuid'] == 'agent-1'
assert result['kind'] == AGENT_KIND_AGENT
assert result['config'] == agent.config
app.pipeline_service.get_pipeline.assert_not_awaited()
async def test_get_agent_falls_back_to_pipeline_product_item_with_config(self):
app = _make_app()
app.persistence_mgr.execute_async = AsyncMock(return_value=_result(first_item=None))
app.pipeline_service.get_pipeline = AsyncMock(
return_value={
'uuid': 'pipeline-1',
'name': 'Pipeline Agent',
'description': 'Legacy pipeline',
'emoji': 'P',
'config': {'ai': {'runner': {'id': 'pipeline-runner'}}},
'created_at': '2026-01-01T08:00:00',
'updated_at': '2026-01-01T11:00:00',
}
)
result = await AgentService(app).get_agent('pipeline-1')
assert result['kind'] == AGENT_KIND_PIPELINE
assert result['enabled'] is True
assert result['config'] == {'ai': {'runner': {'id': 'pipeline-runner'}}}
assert result['capability']['message_only'] is True
class TestAgentServiceCreateUpdateDelete:
async def test_create_agent_uses_default_runner_config_from_registry(self):
app = _make_app()
runner = SimpleNamespace(
id='plugin:langbot/local-agent/default',
config_schema=[
{'name': 'model', 'default': 'gpt-4.1'},
{'name': 'temperature', 'default': 0.2},
{'name': 'no-default'},
],
)
app.agent_runner_registry = SimpleNamespace(list_runners=AsyncMock(return_value=[runner]))
app.pipeline_service._get_default_values_from_schema = Mock(
return_value={'model': 'gpt-4.1', 'temperature': 0.2}
)
app.persistence_mgr.execute_async = AsyncMock(return_value=Mock())
result = await AgentService(app).create_agent(
{
'name': 'Support Agent',
'description': 'Handles support events',
'emoji': 'S',
}
)
insert_values = _compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
assert result['kind'] == AGENT_KIND_AGENT
assert result['uuid'] == insert_values['uuid']
assert insert_values['name'] == 'Support Agent'
assert insert_values['component_ref'] == runner.id
assert insert_values['config'] == {
'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 insert_values['supported_event_patterns'] == AGENT_DEFAULT_EVENT_PATTERNS
app.pipeline_service._get_default_values_from_schema.assert_called_once_with(runner.config_schema)
async def test_update_agent_protects_immutable_fields_and_recalculates_component_ref(self):
app = _make_app()
app.persistence_mgr.execute_async = AsyncMock(
side_effect=[
_result(first_item=_agent_row(agent_uuid='agent-1')),
Mock(),
]
)
new_config = {
'runner': {'id': 'plugin:test/new-runner/default', 'expire-time': 0},
'runner_config': {'plugin:test/new-runner/default': {'timeout': 30}},
}
await AgentService(app).update_agent(
'agent-1',
{
'uuid': 'caller-owned-uuid',
'kind': AGENT_KIND_PIPELINE,
'created_at': '2020-01-01T00:00:00',
'updated_at': '2020-01-01T00:00:00',
'capability': {'message_only': True},
'name': 'Updated Agent',
'config': new_config,
'supported_event_patterns': [],
},
)
update_values = _compiled_update_values(app.persistence_mgr.execute_async.await_args_list[1].args[0])
assert update_values == {
'name': 'Updated Agent',
'config': new_config,
'supported_event_patterns': AGENT_DEFAULT_EVENT_PATTERNS,
'component_ref': 'plugin:test/new-runner/default',
}
async def test_pipeline_kind_create_update_delete_delegate_to_pipeline_service(self):
app = _make_app()
app.persistence_mgr.execute_async = AsyncMock(return_value=_result(first_item=None))
app.pipeline_service.create_pipeline = AsyncMock(return_value='pipeline-created')
app.pipeline_service.get_pipeline = AsyncMock(return_value={'uuid': 'pipeline-1'})
service = AgentService(app)
created = await service.create_agent(
{
'kind': AGENT_KIND_PIPELINE,
'name': 'Pipeline Agent',
'description': 'Legacy pipeline',
'emoji': 'P',
}
)
await service.update_agent('pipeline-1', {'name': 'Updated Pipeline'})
await service.delete_agent('pipeline-1')
assert created == {'uuid': 'pipeline-created', 'kind': AGENT_KIND_PIPELINE}
app.pipeline_service.create_pipeline.assert_awaited_once_with(
{
'name': 'Pipeline Agent',
'description': 'Legacy pipeline',
'emoji': 'P',
'config': {},
}
)
app.pipeline_service.update_pipeline.assert_awaited_once_with(
'pipeline-1',
{'name': 'Updated Pipeline'},
)
app.pipeline_service.delete_pipeline.assert_awaited_once_with('pipeline-1')
+193 -100
View File
@@ -52,6 +52,15 @@ def _create_mock_result(items: list = None, first_item=None):
return result
def _compiled_update_values(statement):
"""Return update values without SQLAlchemy WHERE bind params."""
return {
key: value
for key, value in statement.compile().params.items()
if not key.startswith('uuid_')
}
def _create_mock_discover(adapter_webhook_flags: dict[str, bool] = None):
"""Create mock ComponentDiscoveryEngine exposing MessagePlatformAdapter manifests.
@@ -386,59 +395,6 @@ class TestBotServiceCreateBot:
assert bot_uuid is not None
assert len(bot_uuid) == 36 # UUID format
async def test_create_bot_sets_default_pipeline(self):
"""Sets default pipeline when one exists."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {'system': {'limitation': {'max_bots': -1}}}
ap.platform_mgr = SimpleNamespace()
ap.platform_mgr.load_bot = AsyncMock()
# Mock default pipeline
mock_pipeline = SimpleNamespace()
mock_pipeline.uuid = 'default-pipeline-uuid'
mock_pipeline.name = 'Default Pipeline'
pipeline_result = Mock()
pipeline_result.first = Mock(return_value=mock_pipeline)
# Mock bot after insert
bot_result = Mock()
bot_result.first = Mock(return_value=_create_mock_bot())
call_count = 0
async def mock_execute(query):
nonlocal call_count
call_count += 1
if call_count == 1:
return pipeline_result # Check default pipeline
elif call_count == 2:
return Mock() # Insert
return bot_result # Get bot
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
ap.persistence_mgr.serialize_model = Mock(
return_value={
'uuid': 'new-uuid',
'name': 'New Bot',
'use_pipeline_uuid': 'default-pipeline-uuid',
'use_pipeline_name': 'Default Pipeline',
}
)
service = BotService(ap)
# Execute
bot_data = {'name': 'New Bot', 'adapter': 'telegram', 'adapter_config': {}}
bot_uuid = await service.create_bot(bot_data)
# Verify - pipeline uuid and name were set
assert 'use_pipeline_uuid' in bot_data
assert 'use_pipeline_name' in bot_data
assert bot_uuid is not None # Verify UUID was returned
class TestBotServiceUpdateBot:
"""Tests for update_bot method."""
@@ -472,63 +428,200 @@ class TestBotServiceUpdateBot:
assert update_params['name'] == 'Updated Name'
assert 'should-be-removed' not in update_params.values()
async def test_update_bot_pipeline_not_found_raises(self):
"""Raises Exception when updating with nonexistent pipeline UUID."""
# Setup
class TestBotServiceEventBindings:
"""Tests for EBA event binding validation and persistence."""
async def test_normalize_event_bindings_validates_targets_and_preserves_order(self):
"""Valid bindings are normalized with stable order and target data."""
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
# Mock pipeline query returns None
pipeline_result = Mock()
pipeline_result.first = Mock(return_value=None)
ap.persistence_mgr.execute_async = AsyncMock(return_value=pipeline_result)
ap.persistence_mgr.execute_async = AsyncMock(
side_effect=[
_create_mock_result(first_item=SimpleNamespace(uuid='pipeline-1')),
_create_mock_result(
first_item=SimpleNamespace(
uuid='agent-1',
supported_event_patterns=['platform.member.*'],
)
),
]
)
service = BotService(ap)
# Execute & Verify
with pytest.raises(Exception, match='Pipeline not found'):
await service.update_bot('test-uuid', {'use_pipeline_uuid': 'nonexistent-pipeline'})
normalized = await service._normalize_event_bindings(
[
{
'event_pattern': ' message.received ',
'target_type': 'pipeline',
'target_uuid': ' pipeline-1 ',
'filters': [{'field': 'sender.id', 'operator': 'eq', 'value': '1000'}],
'priority': '5',
'enabled': False,
'description': 'Route message events',
},
{
'id': 'agent-binding',
'event_pattern': 'platform.member.joined',
'target_type': 'agent',
'target_uuid': 'agent-1',
'priority': 7,
},
{
'event_pattern': 'platform.member.left',
'target_type': 'discard',
'target_uuid': 'ignored-target',
},
]
)
async def test_update_bot_sets_pipeline_name(self):
"""Sets use_pipeline_name when updating use_pipeline_uuid."""
# Setup
uuid.UUID(normalized[0]['id'])
assert normalized == [
{
'id': normalized[0]['id'],
'event_pattern': 'message.received',
'target_type': 'pipeline',
'target_uuid': 'pipeline-1',
'filters': [{'field': 'sender.id', 'operator': 'eq', 'value': '1000'}],
'priority': 5,
'enabled': False,
'description': 'Route message events',
'order': 0,
},
{
'id': 'agent-binding',
'event_pattern': 'platform.member.joined',
'target_type': 'agent',
'target_uuid': 'agent-1',
'filters': [],
'priority': 7,
'enabled': True,
'description': '',
'order': 1,
},
{
'id': normalized[2]['id'],
'event_pattern': 'platform.member.left',
'target_type': 'discard',
'target_uuid': '',
'filters': [],
'priority': 0,
'enabled': True,
'description': '',
'order': 2,
},
]
async def test_normalize_event_bindings_rejects_pipeline_for_non_message_event(self):
"""Pipeline targets are limited to message events."""
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace(execute_async=AsyncMock())
service = BotService(ap)
with pytest.raises(ValueError, match='Pipeline can only be bound to message events'):
await service._normalize_event_bindings(
[
{
'event_pattern': 'platform.member.joined',
'target_type': 'pipeline',
'target_uuid': 'pipeline-1',
}
]
)
ap.persistence_mgr.execute_async.assert_not_awaited()
@pytest.mark.parametrize(
('agent', 'error'),
[
(None, 'Agent not found'),
(
SimpleNamespace(uuid='agent-1', supported_event_patterns=['message.*']),
'Agent does not support this event pattern',
),
],
)
async def test_normalize_event_bindings_rejects_invalid_agent_target(self, agent, error):
"""Agent targets must exist and support the requested event pattern."""
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace(
execute_async=AsyncMock(return_value=_create_mock_result(first_item=agent))
)
service = BotService(ap)
with pytest.raises(ValueError, match=error):
await service._normalize_event_bindings(
[
{
'event_pattern': 'platform.member.joined',
'target_type': 'agent',
'target_uuid': 'agent-1',
}
]
)
async def test_update_bot_persists_normalized_event_bindings_and_reloads_runtime_bot(self):
"""update_bot stores normalized bindings before reloading the runtime bot."""
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.platform_mgr = SimpleNamespace()
ap.platform_mgr.remove_bot = AsyncMock()
# Mock pipeline query
mock_pipeline = SimpleNamespace()
mock_pipeline.name = 'Updated Pipeline'
pipeline_result = Mock()
pipeline_result.first = Mock(return_value=mock_pipeline)
call_count = 0
async def mock_execute(query):
nonlocal call_count
call_count += 1
if call_count == 1:
return pipeline_result
return Mock()
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
ap.sess_mgr = SimpleNamespace()
ap.sess_mgr.session_list = []
ap.persistence_mgr.execute_async = AsyncMock(
side_effect=[
_create_mock_result(
first_item=SimpleNamespace(
uuid='agent-1',
supported_event_patterns=['platform.member.*'],
)
),
Mock(),
]
)
runtime_bot = SimpleNamespace(enable=True, run=AsyncMock())
loaded_bot = {'uuid': 'bot-1', 'name': 'Bot with bindings'}
ap.platform_mgr = SimpleNamespace(
remove_bot=AsyncMock(),
load_bot=AsyncMock(return_value=runtime_bot),
)
bot_session = SimpleNamespace(using_conversation=SimpleNamespace(bot_uuid='bot-1'))
other_session = SimpleNamespace(using_conversation=SimpleNamespace(bot_uuid='other-bot'))
ap.sess_mgr = SimpleNamespace(session_list=[bot_session, other_session])
service = BotService(ap)
service.get_bot = AsyncMock(return_value={'uuid': 'test-uuid'})
service.get_bot = AsyncMock(return_value=loaded_bot)
runtime_bot = SimpleNamespace()
runtime_bot.enable = False
ap.platform_mgr.load_bot = AsyncMock(return_value=runtime_bot)
await service.update_bot(
'bot-1',
{
'event_bindings': [
{
'id': 'binding-1',
'event_pattern': 'platform.member.joined',
'target_type': 'agent',
'target_uuid': 'agent-1',
'priority': '9',
}
]
},
)
# Execute
await service.update_bot('test-uuid', {'use_pipeline_uuid': 'pipeline-uuid'})
update_params = ap.persistence_mgr.execute_async.await_args_list[1].args[0].compile().params
assert update_params['use_pipeline_uuid'] == 'pipeline-uuid'
assert update_params['use_pipeline_name'] == 'Updated Pipeline'
update_values = _compiled_update_values(ap.persistence_mgr.execute_async.await_args_list[1].args[0])
assert update_values['event_bindings'] == [
{
'id': 'binding-1',
'event_pattern': 'platform.member.joined',
'target_type': 'agent',
'target_uuid': 'agent-1',
'filters': [],
'priority': 9,
'enabled': True,
'description': '',
'order': 0,
}
]
ap.platform_mgr.remove_bot.assert_awaited_once_with('bot-1')
service.get_bot.assert_awaited_once_with('bot-1')
ap.platform_mgr.load_bot.assert_awaited_once_with(loaded_bot)
runtime_bot.run.assert_awaited_once()
assert bot_session.using_conversation is None
assert other_session.using_conversation.bot_uuid == 'other-bot'
class TestBotServiceDeleteBot:
@@ -36,6 +36,7 @@ class DummyLogger(AbstractEventLogger):
class DummyDingTalkClient(DingTalkClient):
def __init__(self, *args, **kwargs):
self._message_handlers = {}
self.client = SimpleNamespace(register_callback_handler=AsyncMock())
self.markdown_card = kwargs.get('markdown_card', True)
self.access_token = ''
self.send_message = AsyncMock()
@@ -115,6 +116,30 @@ def dingtalk_event(conversation='GroupMessage', **overrides) -> DingTalkEvent:
return DingTalkEvent.from_payload(payload)
def dingtalk_card_callback_event(**overrides) -> DingTalkEvent:
return DingTalkEvent.from_payload(
{
'conversation_type': 'CardCallback',
'Type': 'card_callback',
'CardCallback': {
'extension': overrides.get('extension', {}),
'corp_id': overrides.get('corp_id', 'corp-1'),
'user_id': overrides.get('user_id', 'user-1'),
'content': overrides.get(
'content',
{
'action': 'like',
'feedback_id': 'feedback-1',
'message_id': 'bot-msg-1',
},
),
'space_id': overrides.get('space_id', 'space-1'),
'card_instance_id': overrides.get('card_instance_id', 'card-1'),
},
}
)
def test_dingtalk_supported_events_match_manifest():
assert make_adapter().get_supported_events() == manifest()['spec']['supported_events']
@@ -195,7 +220,7 @@ async def test_dingtalk_event_converter_maps_group_and_private_message():
group_event = await DingTalkEventConverter.target2yiri(dingtalk_event(), 'LangBot')
assert isinstance(group_event, platform_events.MessageReceivedEvent)
assert group_event.adapter_name == 'dingtalk'
assert group_event.adapter_name == 'dingtalk-eba'
assert group_event.chat_type == platform_entities.ChatType.GROUP
assert group_event.chat_id == 'group-1'
assert group_event.group.name == 'LangBot Team'
@@ -212,6 +237,42 @@ async def test_dingtalk_event_converter_maps_group_and_private_message():
assert private_event.group is None
@pytest.mark.asyncio
async def test_dingtalk_event_converter_maps_card_feedback():
feedback = await DingTalkEventConverter.target2yiri(dingtalk_card_callback_event(), 'LangBot')
callback = await DingTalkEventConverter.target2yiri(
dingtalk_card_callback_event(content={'action': 'open_details'}),
'LangBot',
)
assert isinstance(feedback, platform_events.FeedbackReceivedEvent)
assert feedback.adapter_name == 'dingtalk-eba'
assert feedback.feedback_id == 'feedback-1'
assert feedback.feedback_type == 1
assert feedback.user_id == 'user-1'
assert feedback.session_id == 'space-1'
assert feedback.message_id == 'bot-msg-1'
assert isinstance(callback, platform_events.PlatformSpecificEvent)
assert callback.action == 'card.callback'
@pytest.mark.asyncio
async def test_dingtalk_adapter_dispatches_card_feedback_event():
adapter = make_adapter()
calls: list[platform_events.Event] = []
async def listener(event, adapter):
calls.append(event)
adapter.register_listener(platform_events.FeedbackReceivedEvent, listener)
await adapter._handle_native_event(dingtalk_card_callback_event())
assert len(calls) == 1
assert isinstance(calls[0], platform_events.FeedbackReceivedEvent)
assert calls[0].feedback_type == 1
@pytest.mark.asyncio
async def test_dingtalk_adapter_dispatches_and_caches_message_event():
adapter = make_adapter()
@@ -143,6 +143,7 @@ def qq_event(event_type: str = 'C2C_MESSAGE_CREATE', **overrides) -> QQOfficialE
'image_attachments': overrides.get('image_attachments'),
'content_type': overrides.get('content_type', 'image/png'),
}
payload.update(overrides.get('extra', {}))
return QQOfficialEvent(payload)
@@ -194,7 +195,7 @@ async def test_qqofficial_event_converter_maps_private_group_and_platform_specif
private_event = await QQOfficialEventConverter().target2yiri(qq_event('C2C_MESSAGE_CREATE'))
group_event = await QQOfficialEventConverter().target2yiri(qq_event('GROUP_AT_MESSAGE_CREATE'))
channel_event = await QQOfficialEventConverter().target2yiri(qq_event('AT_MESSAGE_CREATE'))
platform_event = await QQOfficialEventConverter().target2yiri(qq_event('GUILD_CREATE'))
platform_event = await QQOfficialEventConverter().target2yiri(qq_event('UNKNOWN_EVENT'))
assert isinstance(private_event, platform_events.MessageReceivedEvent)
assert private_event.adapter_name == 'qqofficial-eba'
@@ -209,7 +210,67 @@ async def test_qqofficial_event_converter_maps_private_group_and_platform_specif
assert channel_event.chat_id == 'channel-1'
assert isinstance(platform_event, platform_events.PlatformSpecificEvent)
assert platform_event.action == 'qqofficial.GUILD_CREATE'
assert platform_event.action == 'qqofficial.UNKNOWN_EVENT'
@pytest.mark.asyncio
async def test_qqofficial_event_converter_maps_declared_lifecycle_events():
converter = QQOfficialEventConverter()
reaction_add = await converter.target2yiri(
qq_event(
'MESSAGE_REACTION_ADD',
extra={
'message_id': 'reacted-msg',
'emoji': 'thumbs_up',
'group_openid': 'group-openid',
'openid': 'reactor-openid',
},
)
)
reaction_remove = await converter.target2yiri(
qq_event('MESSAGE_REACTION_REMOVE', extra={'message_id': 'reacted-msg', 'emoji_id': '1'})
)
member_joined = await converter.target2yiri(
qq_event('GROUP_MEMBER_ADD', extra={'openid': 'new-member', 'group_openid': 'group-openid'})
)
member_left = await converter.target2yiri(
qq_event('GROUP_MEMBER_REMOVE', extra={'openid': 'old-member', 'operator_openid': 'operator-openid'})
)
bot_invited = await converter.target2yiri(
qq_event('GROUP_ADD_ROBOT', extra={'group_openid': 'group-openid', 'op_user_id': 'inviter-openid'})
)
bot_removed = await converter.target2yiri(
qq_event('GROUP_DEL_ROBOT', extra={'group_openid': 'group-openid', 'op_user_id': 'operator-openid'})
)
assert isinstance(reaction_add, platform_events.MessageReactionEvent)
assert reaction_add.type == 'message.reaction'
assert reaction_add.is_add is True
assert reaction_add.message_id == 'reacted-msg'
assert reaction_add.reaction == 'thumbs_up'
assert reaction_add.chat_type == platform_entities.ChatType.GROUP
assert isinstance(reaction_remove, platform_events.MessageReactionEvent)
assert reaction_remove.is_add is False
assert isinstance(member_joined, platform_events.MemberJoinedEvent)
assert member_joined.type == 'group.member_joined'
assert member_joined.group.id == 'group-openid'
assert member_joined.member.id == 'new-member'
assert isinstance(member_left, platform_events.MemberLeftEvent)
assert member_left.type == 'group.member_left'
assert member_left.member.id == 'old-member'
assert member_left.operator.id == 'operator-openid'
assert isinstance(bot_invited, platform_events.BotInvitedToGroupEvent)
assert bot_invited.type == 'bot.invited_to_group'
assert bot_invited.inviter.id == 'inviter-openid'
assert isinstance(bot_removed, platform_events.BotRemovedFromGroupEvent)
assert bot_removed.type == 'bot.removed_from_group'
assert bot_removed.operator.id == 'operator-openid'
@pytest.mark.asyncio
@@ -236,6 +297,24 @@ async def test_qqofficial_adapter_dispatches_eba_and_legacy_and_caches_group_eve
assert (await adapter.get_group_member_info('group-openid', 'member-openid')).user.id == 'member-openid'
@pytest.mark.asyncio
async def test_qqofficial_adapter_dispatches_non_message_eba_events():
adapter = make_adapter()
calls: list[platform_events.Event] = []
async def member_listener(event, adapter):
calls.append(event)
adapter.register_listener(platform_events.MemberJoinedEvent, member_listener)
await adapter._handle_native_event(
qq_event('GROUP_MEMBER_ADD', extra={'openid': 'new-member', 'group_openid': 'group-openid'})
)
assert len(calls) == 1
assert isinstance(calls[0], platform_events.MemberJoinedEvent)
assert calls[0].member.id == 'new-member'
@pytest.mark.asyncio
async def test_qqofficial_send_reply_stream_platform_api_and_unsupported():
adapter = make_adapter()
@@ -252,7 +331,9 @@ async def test_qqofficial_send_reply_stream_platform_api_and_unsupported():
assert ('private_text', 'user-openid', 'reply', 'msg-1') in adapter.bot.sent
assert any(call[0] == 'image' and call[1] == 'c2c' for call in adapter.bot.sent)
await adapter.send_message('group', 'group-openid', platform_message.MessageChain([platform_message.Plain(text='hello group')]))
await adapter.send_message(
'group', 'group-openid', platform_message.MessageChain([platform_message.Plain(text='hello group')])
)
assert ('group_text', 'group-openid', 'hello group', None) in adapter.bot.sent
assert await adapter.call_platform_api('get_mode', {}) == {
@@ -265,3 +346,92 @@ async def test_qqofficial_send_reply_stream_platform_api_and_unsupported():
with pytest.raises(NotSupportedError):
await adapter.call_platform_api('missing', {})
@pytest.mark.asyncio
async def test_qqofficial_client_next_reply_msg_seq_allocator():
"""msg_seq must increment per inbound msg_id to avoid 40054005 dedup (issue #2290)."""
from langbot.libs.qq_official_api.api import QQOfficialClient
client = QQOfficialClient(secret='s', token='t', app_id='a', logger=DummyLogger())
# same msg_id -> strictly increasing 1, 2, 3
assert [await client.next_reply_msg_seq('m1') for _ in range(3)] == [1, 2, 3]
# a different msg_id is tracked independently
assert await client.next_reply_msg_seq('m2') == 1
# missing msg_id falls back to the global proactive counter, independent of per-id seqs
assert [await client.next_reply_msg_seq(None) for _ in range(2)] == [1, 2]
# concurrent allocations under the same msg_id are unique and gap-free
import asyncio as _asyncio
concurrent = await _asyncio.gather(*[client.next_reply_msg_seq('m3') for _ in range(50)])
assert sorted(concurrent) == list(range(1, 51))
@pytest.mark.asyncio
async def test_qqofficial_text_send_carries_incrementing_msg_seq(monkeypatch):
"""Multiple text replies under one inbound msg_id get distinct msg_seq values."""
from langbot.libs.qq_official_api.api import QQOfficialClient
client = QQOfficialClient(secret='s', token='t', app_id='a', logger=DummyLogger())
client.access_token = 'token'
client.access_token_expiry_time = 9999999999
captured = []
class _Resp:
status_code = 200
def json(self):
return {'id': 'ok'}
class _DummyAsyncClient:
def __init__(self, *a, **k):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
async def post(self, url, headers=None, json=None):
captured.append(json)
return _Resp()
monkeypatch.setattr('langbot.libs.qq_official_api.api.httpx.AsyncClient', _DummyAsyncClient)
await client.send_private_text_msg('user-1', 'first', 'inbound-1')
await client.send_private_text_msg('user-1', 'second', 'inbound-1')
await client.send_group_text_msg('group-1', 'third', 'inbound-1')
seqs = [body['msg_seq'] for body in captured]
assert seqs == [1, 2, 3]
assert all(body['msg_id'] == 'inbound-1' for body in captured)
@pytest.mark.asyncio
async def test_qqofficial_stream_chunks_increment_msg_seq():
"""Each stream chunk must advance msg_seq so QQ does not dedup later chunks (issue #2290)."""
adapter = make_adapter()
source_event = await QQOfficialEventConverter().target2yiri(qq_event('C2C_MESSAGE_CREATE'))
assert await adapter.create_message_card('msg-1', source_event) is True
bot_message = {'resp_message_id': 'msg-1'}
for idx, text in enumerate(['hello ', 'world ', 'done']):
# reset the per-chunk rate limiter so each chunk actually sends
adapter._stream_ctx['msg-1']['last_update_ts'] = 0
await adapter.reply_message_chunk(
source_event,
bot_message,
platform_message.MessageChain([platform_message.Plain(text=text)]),
is_final=(idx == 2),
)
stream_calls = [call[1] for call in adapter.bot.sent if call[0] == 'stream']
seqs = [kwargs['msg_seq'] for kwargs in stream_calls]
# strictly increasing, no duplicates
assert seqs == sorted(set(seqs))
assert len(seqs) == len(set(seqs))
assert seqs[0] == 1
@@ -2,6 +2,7 @@
RuntimeBot.resolve_pipeline_uuid and _match_operator unit tests
"""
from types import SimpleNamespace
from unittest.mock import Mock
@@ -278,3 +279,108 @@ class TestResolvePipelineUuid:
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'normal message')
assert uuid == 'default-uuid'
assert routed is False
class TestEBAEventBindings:
"""Test RuntimeBot EBA event binding helpers."""
@staticmethod
def _make_bot(bindings):
from langbot.pkg.platform.botmgr import RuntimeBot
bot = object.__new__(RuntimeBot)
bot.bot_entity = SimpleNamespace(event_bindings=bindings)
return bot
def test_resolve_eba_event_binding_uses_enabled_pattern_filters_priority_and_order(self):
"""The selected binding is the first matching highest-priority binding."""
bot = self._make_bot(
[
{
'id': 'disabled',
'enabled': False,
'event_pattern': 'platform.member.joined',
'target_type': 'agent',
'target_uuid': 'agent-disabled',
'priority': 100,
'order': 0,
},
{
'id': 'wrong-room',
'event_pattern': 'platform.member.joined',
'target_type': 'agent',
'target_uuid': 'agent-wrong-room',
'filters': [{'field': 'room.id', 'operator': 'eq', 'value': 'room-2'}],
'priority': 50,
'order': 1,
},
{
'id': 'first-high',
'event_pattern': 'platform.member.joined',
'target_type': 'agent',
'target_uuid': 'agent-first',
'filters': [{'field': 'room.id', 'operator': 'eq', 'value': 'room-1'}],
'priority': 10,
'order': 2,
},
{
'id': 'second-high',
'event_pattern': 'platform.member.*',
'target_type': 'agent',
'target_uuid': 'agent-second',
'filters': [{'field': 'room.id', 'operator': 'eq', 'value': 'room-1'}],
'priority': 10,
'order': 3,
},
{
'id': 'fallback',
'event_pattern': '*',
'target_type': 'discard',
'priority': 1,
'order': 4,
},
]
)
selected = bot._resolve_eba_event_binding(
{'room': {'id': 'room-1'}},
'platform.member.joined',
)
assert selected['id'] == 'first-high'
assert selected['target_uuid'] == 'agent-first'
def test_agent_product_to_binding_projects_runner_config_and_policies(self):
"""Agent products become bot-scoped runner bindings for EBA dispatch."""
from langbot.pkg.platform.botmgr import RuntimeBot
binding = RuntimeBot._agent_product_to_binding(
{
'uuid': 'agent-1',
'component_ref': 'plugin:test/fallback/default',
'config': {
'runner': {'id': 'plugin:test/runner/default'},
'runner_config': {
'plugin:test/runner/default': {
'temperature': 0.2,
'max_tokens': 1000,
}
},
},
},
{'id': 'binding-1'},
'platform.member.joined',
'bot-1',
)
assert binding is not None
assert binding.binding_id == 'bot:bot-1:binding-1'
assert binding.scope.scope_type == 'bot'
assert binding.scope.scope_id == 'bot-1'
assert binding.event_types == ['platform.member.joined']
assert binding.runner_id == 'plugin:test/runner/default'
assert binding.runner_config == {'temperature': 0.2, 'max_tokens': 1000}
assert binding.delivery_policy.enable_streaming is False
assert binding.delivery_policy.enable_reply is True
assert binding.state_policy.state_scopes == ['conversation', 'actor', 'subject', 'runner']
assert binding.agent_id == 'agent-1'
@@ -10,11 +10,17 @@ type and graceful error handling.
from __future__ import annotations
import base64
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from langbot.pkg.platform.sources.websocket_adapter import WebSocketAdapter
import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
import langbot_plugin.api.entities.builtin.platform.message as platform_message
from langbot.pkg.platform.botmgr import RuntimeBot
from langbot.pkg.platform.sources.websocket_adapter import WebSocketAdapter, WebSocketSession
def _make_adapter(load_return=b'hello', load_side_effect=None):
@@ -90,3 +96,64 @@ async def test_load_failure_is_logged_not_raised():
await adapter._process_image_components(chain)
assert 'base64' not in chain[0]
adapter.logger.error.assert_awaited_once()
@pytest.mark.asyncio
async def test_handle_websocket_message_marks_event_with_pipeline_uuid():
adapter, _ = _make_adapter()
adapter.websocket_person_session = WebSocketSession(id='websocketperson')
adapter.listeners = {}
adapter.listeners[platform_events.FriendMessage] = AsyncMock()
adapter.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid = ''
connection = SimpleNamespace(
pipeline_uuid='pipeline-123',
session_type='person',
connection_id='conn-1',
)
await adapter.handle_websocket_message(
connection,
{'message': [{'type': 'Plain', 'text': 'hello'}], 'stream': True},
)
await asyncio.sleep(0)
event = adapter.listeners[platform_events.FriendMessage].await_args.args[0]
assert getattr(event, '_langbot_pipeline_uuid') == 'pipeline-123'
@pytest.mark.asyncio
async def test_runtime_bot_websocket_listener_uses_event_pipeline_uuid():
app = Mock()
app.msg_aggregator.add_message = AsyncMock()
app.webhook_pusher = None
logger = Mock()
logger.info = AsyncMock()
logger.warning = AsyncMock()
logger.error = AsyncMock()
bot_entity = Mock()
bot_entity.uuid = 'websocket-proxy-bot'
bot_entity.enable = True
bot_entity.use_pipeline_uuid = ''
adapter = WebSocketAdapter.model_construct(
ap=app,
logger=Mock(error=AsyncMock()),
listeners={},
websocket_person_session=WebSocketSession(id='websocketperson'),
websocket_group_session=WebSocketSession(id='websocketgroup'),
)
bot = RuntimeBot(ap=app, bot_entity=bot_entity, adapter=adapter, logger=logger)
await bot.initialize()
event = platform_events.FriendMessage(
sender=platform_entities.Friend(id='sender-1', nickname='User', remark='User'),
message_chain=platform_message.MessageChain([platform_message.Plain(text='hello')]),
time=1,
)
object.__setattr__(event, '_langbot_pipeline_uuid', 'pipeline-123')
await adapter.listeners[platform_events.FriendMessage](event, adapter)
app.msg_aggregator.add_message.assert_awaited_once()
assert app.msg_aggregator.add_message.await_args.kwargs['pipeline_uuid'] == 'pipeline-123'
@@ -615,6 +615,94 @@ class TestAgentRunProxyActions:
assert response.data['usage'] == usage
assert model_requester.LLM_USAGE_QUERY_VARIABLE not in query.variables
@pytest.mark.asyncio
async def test_count_tokens_validates_run_authorization_and_calls_provider(self, app):
"""COUNT_TOKENS is run-scoped and forwards messages/tools to the model requester."""
from langbot.pkg.agent.runner.session_registry import get_session_registry
run_id = 'run_proxy_count_tokens'
query = self.query()
app.query_pool.cached_queries[906] = query
registry = get_session_registry()
await registry.unregister(run_id)
await registry.register(
run_id=run_id,
runner_id='plugin:test/runner/default',
query_id=906,
plugin_identity='test/runner',
resources=make_agent_resources(
models=[{'model_id': 'llm_count_001', 'operations': ['count_tokens']}],
),
)
requester = SimpleNamespace(count_tokens=AsyncMock(return_value=37))
model = SimpleNamespace(
model_entity=SimpleNamespace(abilities=[], extra_args={'temperature': 0.2}),
provider=SimpleNamespace(requester=requester),
)
app.model_mgr.get_model_by_uuid.return_value = model
runtime_handler = make_handler(app)
try:
response = await runtime_handler.actions[PluginToRuntimeAction.COUNT_TOKENS.value]({
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
'llm_model_uuid': 'llm_count_001',
'messages': [{'role': 'user', 'content': 'hello'}],
'funcs': [{
'name': 'search',
'human_desc': 'Search',
'description': 'Search',
'parameters': {'type': 'object'},
}],
'extra_args': {'temperature': 0.7},
})
finally:
await registry.unregister(run_id)
assert response.code == 0
assert response.data == {'tokens': 37}
requester.count_tokens.assert_awaited_once()
kwargs = requester.count_tokens.await_args.kwargs
assert kwargs['model'] is model
assert kwargs['messages'][0].content == 'hello'
assert [tool.name for tool in kwargs['funcs']] == ['search']
assert kwargs['extra_args'] == {'temperature': 0.7}
@pytest.mark.asyncio
async def test_count_tokens_rejects_model_without_operation(self, app):
"""COUNT_TOKENS requires the explicit model operation in the run snapshot."""
from langbot.pkg.agent.runner.session_registry import get_session_registry
run_id = 'run_proxy_count_tokens_denied'
registry = get_session_registry()
await registry.unregister(run_id)
await registry.register(
run_id=run_id,
runner_id='plugin:test/runner/default',
query_id=None,
plugin_identity='test/runner',
resources=make_agent_resources(
models=[{'model_id': 'llm_count_002', 'operations': ['invoke']}],
),
)
runtime_handler = make_handler(app)
try:
response = await runtime_handler.actions[PluginToRuntimeAction.COUNT_TOKENS.value]({
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
'llm_model_uuid': 'llm_count_002',
'messages': [{'role': 'user', 'content': 'hello'}],
})
finally:
await registry.unregister(run_id)
assert response.code != 0
assert 'operation count_tokens' in response.message
app.model_mgr.get_model_by_uuid.assert_not_awaited()
@pytest.mark.asyncio
async def test_invoke_llm_stream_restores_query_and_options(self, app):
"""INVOKE_LLM_STREAM applies the same host context as non-streaming calls."""
@@ -1,11 +1,17 @@
"""Unit tests for provider_specific_fields round-trip in LiteLLMRequester.
"""Unit tests for LiteLLMRequester message/tool conversion.
This tests the fix for GitHub issue #1899: Gemini requires thought_signature
to be preserved across tool call rounds for function calls to work correctly.
This includes provider_specific_fields round-trip coverage for GitHub issue
#1899 and token counting preflight behavior for AgentRunner context budgeting.
"""
import langbot_plugin.api.entities.builtin.provider.message as provider_message
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock, patch
import pytest
import langbot_plugin.api.entities.builtin.provider.message as provider_message
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
from langbot.pkg.provider.modelmgr import requester as model_requester
from langbot.pkg.provider.modelmgr.requesters.litellmchat import LiteLLMRequester
@@ -14,6 +20,84 @@ def _make_requester() -> LiteLLMRequester:
return LiteLLMRequester.__new__(LiteLLMRequester)
def _make_configured_requester() -> LiteLLMRequester:
req = LiteLLMRequester.__new__(LiteLLMRequester)
req.requester_cfg = {
'base_url': '',
'timeout': 120,
'custom_llm_provider': 'openai',
'drop_params': False,
'num_retries': 0,
'api_version': '',
}
req.ap = SimpleNamespace(
tool_mgr=SimpleNamespace(
generate_tools_for_openai=AsyncMock(
return_value=[
{
'type': 'function',
'function': {
'name': 'search',
'description': 'Search',
'parameters': {'type': 'object'},
},
}
]
)
)
)
return req
def _make_runtime_model() -> model_requester.RuntimeLLMModel:
provider = SimpleNamespace(token_mgr=SimpleNamespace(get_token=Mock(return_value='sk-test')))
return SimpleNamespace(
model_entity=SimpleNamespace(
name='gpt-4.1',
extra_args={'temperature': 0.2},
),
provider=provider,
)
@pytest.mark.asyncio
async def test_count_tokens_uses_litellm_counter_with_request_messages_and_tools():
"""Token preflight uses the same LiteLLM request shape as chat completion."""
req = _make_configured_requester()
model = _make_runtime_model()
tool = resource_tool.LLMTool(
name='search',
human_desc='Search',
description='Search',
parameters={'type': 'object'},
func=lambda **kwargs: None,
)
with patch('langbot.pkg.provider.modelmgr.requesters.litellmchat.litellm.token_counter', return_value=42) as counter:
tokens = await req.count_tokens(
model=model,
messages=[
provider_message.Message(
role='user',
content=[
provider_message.ContentElement(type='text', text='hello'),
provider_message.ContentElement(type='file_url', file_url='https://example.test/a.pdf'),
],
)
],
funcs=[tool],
extra_args={'presence_penalty': 0.1},
)
assert tokens == 42
counter.assert_called_once()
kwargs = counter.call_args.kwargs
assert kwargs['model'] == 'openai/gpt-4.1'
assert kwargs['messages'] == [{'role': 'user', 'content': [{'type': 'text', 'text': 'hello'}]}]
assert kwargs['tools'][0]['function']['name'] == 'search'
assert kwargs['tool_choice'] == 'auto'
def test_convert_messages_preserves_tool_call_provider_specific_fields():
"""Tool calls should retain provider_specific_fields through _convert_messages."""
req = _make_requester()
+2
View File
@@ -58,6 +58,7 @@
"axios": "^1.16.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"highlight.js": "^11.11.1",
"i18next": "^25.1.2",
"i18next-browser-languagedetector": "^8.1.0",
@@ -66,6 +67,7 @@
"lucide-react": "^0.507.0",
"postcss": "^8.5.10",
"qrcode": "^1.5.4",
"radix-ui": "^1.6.0",
"react": "19.2.1",
"react-dom": "19.2.1",
"react-hook-form": "^7.56.3",
+1414
View File
File diff suppressed because it is too large Load Diff
@@ -1,9 +1,6 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import i18n from 'i18next';
import {
IChooseAdapterEntity,
IPipelineEntity,
} from '@/app/home/bots/components/bot-form/ChooseEntity';
import { IChooseAdapterEntity } from '@/app/home/bots/components/bot-form/ChooseEntity';
import {
DynamicFormItemConfig,
getDefaultValues,
@@ -16,8 +13,7 @@ import { httpClient } from '@/app/infra/http/HttpClient';
import { systemInfo } from '@/app/infra/http';
import { Agent, Bot } from '@/app/infra/entities/api';
import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs';
import { ExternalLink } from 'lucide-react';
import RoutingRulesEditor from './RoutingRulesEditor';
import { ExternalLink, ChevronDown, ChevronRight } from 'lucide-react';
import EventBindingsEditor from './EventBindingsEditor';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -66,29 +62,6 @@ const getFormSchema = (t: (key: string) => string) =>
adapter: z.string().min(1, { message: t('bots.adapterRequired') }),
adapter_config: z.record(z.string(), z.any()),
enable: z.boolean().optional(),
use_pipeline_uuid: z.string().optional(),
pipeline_routing_rules: z
.array(
z.object({
type: z.enum([
'launcher_type',
'launcher_id',
'message_content',
'message_has_element',
]),
operator: z.enum([
'eq',
'neq',
'contains',
'not_contains',
'starts_with',
'regex',
]),
value: z.string(),
pipeline_uuid: z.string(),
}),
)
.optional(),
event_bindings: z
.array(
z.object({
@@ -128,8 +101,6 @@ export default function BotForm({
adapter: '',
adapter_config: {},
enable: true,
use_pipeline_uuid: '',
pipeline_routing_rules: [],
event_bindings: [],
},
});
@@ -154,9 +125,6 @@ export default function BotForm({
Record<string, string[]>
>({});
const [pipelineNameList, setPipelineNameList] = useState<IPipelineEntity[]>(
[],
);
const [agentNameList, setAgentNameList] = useState<Agent[]>([]);
const [dynamicFormConfigList, setDynamicFormConfigList] = useState<
@@ -170,11 +138,35 @@ export default function BotForm({
const currentAdapter = form.watch('adapter');
const currentAdapterConfig = form.watch('adapter_config');
// Group adapters by category for the Select dropdown
const groupedAdapters = useMemo(
() => groupByCategory(adapterNameList),
// Group adapters by category for the Select dropdown. Legacy adapters
// (those superseded by an EBA implementation) are split out and shown in a
// collapsed group at the bottom so they're de-emphasized but still usable.
const activeAdapters = useMemo(
() => adapterNameList.filter((a) => !a.legacy),
[adapterNameList],
);
const legacyAdapters = useMemo(
() => adapterNameList.filter((a) => a.legacy),
[adapterNameList],
);
const groupedAdapters = useMemo(
() => groupByCategory(activeAdapters),
[activeAdapters],
);
// Whether the collapsed legacy adapter group is expanded in the Select.
const [showLegacyAdapters, setShowLegacyAdapters] = useState(false);
// Auto-expand the legacy group when the selected adapter is itself legacy,
// so editing an existing bot on a legacy adapter still reveals the choice.
useEffect(() => {
if (
currentAdapter &&
legacyAdapters.some((a) => a.value === currentAdapter)
) {
setShowLegacyAdapters(true);
}
}, [currentAdapter, legacyAdapters]);
// Notify parent when dirty state changes
const { isDirty } = form.formState;
@@ -200,8 +192,6 @@ export default function BotForm({
adapter: val.adapter,
adapter_config: val.adapter_config,
enable: val.enable,
use_pipeline_uuid: val.use_pipeline_uuid || '',
pipeline_routing_rules: val.pipeline_routing_rules || [],
event_bindings: val.event_bindings || [],
});
handleAdapterSelect(val.adapter);
@@ -231,17 +221,6 @@ export default function BotForm({
}
async function initBotFormComponent() {
const pipelinesRes = await httpClient.getPipelines();
setPipelineNameList(
pipelinesRes.pipelines.map((item) => {
return {
label: item.name,
value: item.uuid ?? '',
emoji: item.emoji,
};
}),
);
const agentsRes = await httpClient.getAgents();
setAgentNameList(agentsRes.agents);
@@ -252,6 +231,7 @@ export default function BotForm({
label: extractI18nObject(item.label),
value: item.name,
categories: item.spec.categories,
legacy: item.spec.legacy,
};
}),
);
@@ -331,8 +311,6 @@ export default function BotForm({
name: bot.name,
adapter_config: bot.adapter_config,
enable: bot.enable ?? true,
use_pipeline_uuid: bot.use_pipeline_uuid ?? '',
pipeline_routing_rules: bot.pipeline_routing_rules ?? [],
event_bindings: bot.event_bindings ?? [],
webhook_full_url: runtimeValues?.webhook_full_url as
| string
@@ -377,8 +355,6 @@ export default function BotForm({
adapter: form.getValues().adapter,
adapter_config: form.getValues().adapter_config,
enable: form.getValues().enable,
use_pipeline_uuid: form.getValues().use_pipeline_uuid,
pipeline_routing_rules: form.getValues().pipeline_routing_rules ?? [],
event_bindings: form.getValues().event_bindings ?? [],
};
httpClient
@@ -468,79 +444,7 @@ export default function BotForm({
</CardContent>
</Card>
{/* Card 2: Pipeline Binding (edit mode only) */}
{initBotId && (
<Card>
<CardHeader>
<CardTitle>{t('bots.routingConnection')}</CardTitle>
<CardDescription>
{t('bots.routingConnectionDescription')}
</CardDescription>
</CardHeader>
<CardContent>
<FormField
control={form.control}
name="use_pipeline_uuid"
render={({ field }) => (
<FormItem>
<FormLabel>{t('bots.bindPipeline')}</FormLabel>
<FormControl>
<Select onValueChange={field.onChange} {...field}>
<SelectTrigger>
{field.value ? (
(() => {
const pipeline = pipelineNameList.find(
(p) => p.value === field.value,
);
return (
<div className="flex items-center gap-2">
{pipeline?.emoji && (
<span className="text-sm shrink-0">
{pipeline.emoji}
</span>
)}
<span>{pipeline?.label ?? field.value}</span>
</div>
);
})()
) : (
<SelectValue
placeholder={t('bots.selectPipeline')}
/>
)}
</SelectTrigger>
<SelectContent>
<SelectGroup>
{pipelineNameList.map((item) => (
<SelectItem key={item.value} value={item.value}>
<div className="flex items-center gap-2">
{item.emoji && (
<span className="text-sm shrink-0">
{item.emoji}
</span>
)}
<span>{item.label}</span>
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
{/* Pipeline Routing Rules */}
<RoutingRulesEditor
form={form}
pipelineNameList={pipelineNameList}
/>
</CardContent>
</Card>
)}
{/* Card 3: Event Orchestration (edit mode only) */}
{/* Card 2: Event Orchestration (edit mode only) */}
{initBotId && (
<Card>
<CardHeader>
@@ -635,6 +539,62 @@ export default function BotForm({
))}
</SelectGroup>
))}
{legacyAdapters.length > 0 && (
<>
<div
role="button"
tabIndex={0}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setShowLegacyAdapters((v) => !v);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setShowLegacyAdapters((v) => !v);
}
}}
className="flex cursor-pointer items-center gap-1 px-2 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground border-t mt-1 pt-2"
>
{showLegacyAdapters ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
{t('bots.legacyAdapters')}
<span className="ml-1 rounded bg-muted px-1.5 py-0.5 text-[10px]">
{legacyAdapters.length}
</span>
</div>
{showLegacyAdapters && (
<>
<p className="px-2 pb-1 text-[11px] leading-snug text-muted-foreground">
{t('bots.legacyAdaptersHint')}
</p>
<SelectGroup>
{legacyAdapters.map((item) => (
<SelectItem
key={`legacy:${item.value}`}
value={item.value}
>
<div className="flex items-center gap-2 opacity-70">
<img
src={httpClient.getAdapterIconURL(
item.value,
)}
alt=""
className="h-5 w-5 rounded grayscale"
/>
<span>{item.label}</span>
</div>
</SelectItem>
))}
</SelectGroup>
</>
)}
</>
)}
</SelectContent>
</Select>
{currentAdapter &&
@@ -2,6 +2,7 @@ export interface IChooseAdapterEntity {
label: string;
value: string;
categories?: string[];
legacy?: boolean;
}
export interface IPipelineEntity {
File diff suppressed because it is too large Load Diff
@@ -1,479 +0,0 @@
'use client';
import { useTranslation } from 'react-i18next';
import { UseFormReturn } from 'react-hook-form';
import {
PipelineRoutingRule,
RoutingRuleOperator,
} from '@/app/infra/entities/api';
import { Ban, GripVertical, Plus, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { FormLabel } from '@/components/ui/form';
import {
Select,
SelectContent,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
DndContext,
DragOverlay,
closestCenter,
PointerSensor,
KeyboardSensor,
useSensor,
useSensors,
DragEndEvent,
DragStartEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { useRef, useMemo, useState } from 'react';
export const PIPELINE_DISCARD = '__discard__';
interface PipelineOption {
value: string;
label: string;
emoji?: string;
}
interface RoutingRulesEditorProps {
form: UseFormReturn<any>;
pipelineNameList: PipelineOption[];
}
const OPERATORS_BY_TYPE: Record<
PipelineRoutingRule['type'],
{ value: RoutingRuleOperator; labelKey: string }[]
> = {
launcher_type: [
{ value: 'eq', labelKey: 'bots.operatorEq' },
{ value: 'neq', labelKey: 'bots.operatorNeq' },
],
launcher_id: [
{ value: 'eq', labelKey: 'bots.operatorEq' },
{ value: 'neq', labelKey: 'bots.operatorNeq' },
{ value: 'contains', labelKey: 'bots.operatorContains' },
{ value: 'not_contains', labelKey: 'bots.operatorNotContains' },
{ value: 'regex', labelKey: 'bots.operatorRegex' },
],
message_content: [
{ value: 'eq', labelKey: 'bots.operatorEq' },
{ value: 'neq', labelKey: 'bots.operatorNeq' },
{ value: 'contains', labelKey: 'bots.operatorContains' },
{ value: 'not_contains', labelKey: 'bots.operatorNotContains' },
{ value: 'starts_with', labelKey: 'bots.operatorStartsWith' },
{ value: 'regex', labelKey: 'bots.operatorRegex' },
],
message_has_element: [
{ value: 'eq', labelKey: 'bots.operatorHas' },
{ value: 'neq', labelKey: 'bots.operatorNotHas' },
],
};
function getValuePlaceholder(
t: (key: string) => string,
rule: PipelineRoutingRule,
): string {
if (rule.type === 'launcher_id')
return t('bots.ruleValueLauncherIdPlaceholder');
if (rule.type === 'message_has_element')
return t('bots.ruleValueElementPlaceholder');
if (rule.operator === 'regex') return t('bots.ruleValueRegexpPlaceholder');
return t('bots.ruleValueMessagePlaceholder');
}
/* ── Static rule row (used in DragOverlay) ─────────────────────────── */
interface RuleRowContentProps {
rule: PipelineRoutingRule;
index: number;
pipelineNameList: PipelineOption[];
updateRule: (index: number, patch: Partial<PipelineRoutingRule>) => void;
removeRule: (index: number) => void;
dragHandleProps?: Record<string, unknown>;
isOverlay?: boolean;
}
function RuleRowContent({
rule,
index,
pipelineNameList,
updateRule,
removeRule,
dragHandleProps,
isOverlay,
}: RuleRowContentProps) {
const { t } = useTranslation();
const operatorsForType =
OPERATORS_BY_TYPE[rule.type] || OPERATORS_BY_TYPE.message_content;
const isDiscard = rule.pipeline_uuid === PIPELINE_DISCARD;
return (
<div
className={`flex items-center gap-2 mt-2 p-3 border rounded-md bg-muted/30 ${
isOverlay ? 'shadow-lg ring-2 ring-primary/20 bg-background' : ''
}`}
>
{/* Drag handle */}
<button
type="button"
className="cursor-grab active:cursor-grabbing shrink-0 text-muted-foreground hover:text-foreground touch-none"
{...dragHandleProps}
>
<GripVertical className="h-4 w-4" />
</button>
{/* Field selector */}
<Select
value={rule.type}
onValueChange={(val) => {
updateRule(index, {
type: val as PipelineRoutingRule['type'],
operator: 'eq',
value: '',
});
}}
>
<SelectTrigger className="w-[130px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="launcher_type">
{t('bots.ruleTypeLauncherType')}
</SelectItem>
<SelectItem value="launcher_id">
{t('bots.ruleTypeLauncherId')}
</SelectItem>
<SelectItem value="message_content">
{t('bots.ruleTypeMessageContent')}
</SelectItem>
<SelectItem value="message_has_element">
{t('bots.ruleTypeMessageHasElement')}
</SelectItem>
</SelectContent>
</Select>
{/* Operator selector */}
<Select
value={rule.operator || 'eq'}
onValueChange={(val) => {
updateRule(index, { operator: val as RoutingRuleOperator });
}}
>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{operatorsForType.map((op) => (
<SelectItem key={op.value} value={op.value}>
{t(op.labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Value input */}
{rule.type === 'launcher_type' ? (
<Select
value={rule.value}
onValueChange={(val) => updateRule(index, { value: val })}
>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder={t('bots.ruleValuePlaceholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="person">
{t('bots.sessionTypePerson')}
</SelectItem>
<SelectItem value="group">{t('bots.sessionTypeGroup')}</SelectItem>
</SelectContent>
</Select>
) : rule.type === 'message_has_element' ? (
<Select
value={rule.value}
onValueChange={(val) => updateRule(index, { value: val })}
>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder={t('bots.ruleValueElementPlaceholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="Image">{t('bots.elementImage')}</SelectItem>
<SelectItem value="Voice">{t('bots.elementVoice')}</SelectItem>
<SelectItem value="File">{t('bots.elementFile')}</SelectItem>
<SelectItem value="Forward">{t('bots.elementForward')}</SelectItem>
<SelectItem value="Face">{t('bots.elementFace')}</SelectItem>
<SelectItem value="At">{t('bots.elementAt')}</SelectItem>
<SelectItem value="AtAll">{t('bots.elementAtAll')}</SelectItem>
<SelectItem value="Quote">{t('bots.elementQuote')}</SelectItem>
</SelectContent>
</Select>
) : (
<Input
className="flex-1"
placeholder={getValuePlaceholder(t, rule)}
value={rule.value}
onChange={(e) => updateRule(index, { value: e.target.value })}
/>
)}
<span className="text-sm text-muted-foreground shrink-0"></span>
{/* Pipeline selector */}
<Select
value={rule.pipeline_uuid}
onValueChange={(val) => updateRule(index, { pipeline_uuid: val })}
>
<SelectTrigger className="w-[200px]">
{rule.pipeline_uuid ? (
isDiscard ? (
<div className="flex items-center gap-2 text-destructive">
<Ban className="h-3.5 w-3.5 shrink-0" />
<span>{t('bots.pipelineDiscard')}</span>
</div>
) : (
(() => {
const p = pipelineNameList.find(
(p) => p.value === rule.pipeline_uuid,
);
return (
<div className="flex items-center gap-2">
{p?.emoji && (
<span className="text-sm shrink-0">{p.emoji}</span>
)}
<span>{p?.label ?? rule.pipeline_uuid}</span>
</div>
);
})()
)
) : (
<SelectValue placeholder={t('bots.selectPipeline')} />
)}
</SelectTrigger>
<SelectContent>
<SelectItem value={PIPELINE_DISCARD}>
<div className="flex items-center gap-2 text-destructive">
<Ban className="h-3.5 w-3.5 shrink-0" />
<span>{t('bots.pipelineDiscard')}</span>
</div>
</SelectItem>
<SelectSeparator />
{pipelineNameList.map((item) => (
<SelectItem key={item.value} value={item.value}>
<div className="flex items-center gap-2">
{item.emoji && (
<span className="text-sm shrink-0">{item.emoji}</span>
)}
<span>{item.label}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0"
onClick={() => removeRule(index)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
);
}
/* ── Sortable rule row ─────────────────────────────────────────────── */
interface SortableRuleRowProps {
id: string;
rule: PipelineRoutingRule;
index: number;
pipelineNameList: PipelineOption[];
updateRule: (index: number, patch: Partial<PipelineRoutingRule>) => void;
removeRule: (index: number) => void;
}
function SortableRuleRow({
id,
rule,
index,
pipelineNameList,
updateRule,
removeRule,
}: SortableRuleRowProps) {
const { attributes, listeners, setNodeRef, transform, isDragging } =
useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
// No transition — items reorder visually during drag via transform;
// on drop the data updates and transform resets, so animating would
// cause a redundant "swap" flicker.
opacity: isDragging ? 0.3 : undefined,
};
return (
<div ref={setNodeRef} style={style}>
<RuleRowContent
rule={rule}
index={index}
pipelineNameList={pipelineNameList}
updateRule={updateRule}
removeRule={removeRule}
dragHandleProps={{ ...attributes, ...listeners }}
/>
</div>
);
}
/* ── Main editor ───────────────────────────────────────────────────── */
export default function RoutingRulesEditor({
form,
pipelineNameList,
}: RoutingRulesEditorProps) {
const { t } = useTranslation();
const [activeId, setActiveId] = useState<string | null>(null);
const rules: PipelineRoutingRule[] =
form.watch('pipeline_routing_rules') || [];
// Stable unique ids for sortable items.
// We keep a running counter so newly added rules always get fresh ids.
const nextId = useRef(0);
const idsRef = useRef<string[]>([]);
const sortableIds = useMemo(() => {
// Grow the id list to match rules length (newly added items get new ids).
while (idsRef.current.length < rules.length) {
idsRef.current.push(`rule-${nextId.current++}`);
}
// Shrink if rules were removed from the end.
if (idsRef.current.length > rules.length) {
idsRef.current = idsRef.current.slice(0, rules.length);
}
return idsRef.current;
}, [rules.length]);
const updateRules = (newRules: PipelineRoutingRule[]) => {
form.setValue('pipeline_routing_rules', newRules, { shouldDirty: true });
};
const addRule = () => {
updateRules([
...rules,
{
type: 'launcher_type',
operator: 'eq',
value: '',
pipeline_uuid: '',
},
]);
};
const updateRule = (index: number, patch: Partial<PipelineRoutingRule>) => {
const updated = [...rules];
updated[index] = { ...updated[index], ...patch };
updateRules(updated);
};
const removeRule = (index: number) => {
const updated = [...rules];
updated.splice(index, 1);
// Also remove the corresponding sortable id so indices stay in sync.
idsRef.current.splice(index, 1);
updateRules(updated);
};
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const handleDragStart = (event: DragStartEvent) => {
setActiveId(event.active.id as string);
};
const handleDragEnd = (event: DragEndEvent) => {
setActiveId(null);
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = sortableIds.indexOf(active.id as string);
const newIndex = sortableIds.indexOf(over.id as string);
if (oldIndex === -1 || newIndex === -1) return;
idsRef.current = arrayMove(idsRef.current, oldIndex, newIndex);
updateRules(arrayMove(rules, oldIndex, newIndex));
};
const activeIndex = activeId ? sortableIds.indexOf(activeId) : -1;
const activeRule = activeIndex >= 0 ? rules[activeIndex] : null;
return (
<div className="mt-6">
<div className="flex items-center justify-between mb-2">
<div>
<FormLabel>{t('bots.routingRules')}</FormLabel>
<p className="text-sm text-muted-foreground mt-1">
{t('bots.routingRulesDescription')}
</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={addRule}>
<Plus className="h-4 w-4 mr-1" />
{t('bots.addRoutingRule')}
</Button>
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext
items={sortableIds}
strategy={verticalListSortingStrategy}
>
{rules.map((rule, index) => (
<SortableRuleRow
key={sortableIds[index]}
id={sortableIds[index]}
rule={rule}
index={index}
pipelineNameList={pipelineNameList}
updateRule={updateRule}
removeRule={removeRule}
/>
))}
</SortableContext>
<DragOverlay dropAnimation={null}>
{activeRule ? (
<RuleRowContent
rule={activeRule}
index={activeIndex}
pipelineNameList={pipelineNameList}
updateRule={updateRule}
removeRule={removeRule}
isOverlay
/>
) : null}
</DragOverlay>
</DndContext>
</div>
);
}
@@ -28,7 +28,7 @@ import {
Quote,
Voice,
} from '@/app/infra/entities/message';
import { PIPELINE_DISCARD } from '@/app/home/bots/components/bot-form/RoutingRulesEditor';
import { PIPELINE_DISCARD } from '@/app/home/bots/components/bot-form/EventBindingsEditor';
interface SessionInfo {
session_id: string;
@@ -32,6 +32,9 @@ import {
Server,
Puzzle,
RefreshCcw,
Bot,
Workflow,
ListTree,
} from 'lucide-react';
import { useTheme } from '@/components/providers/theme-provider';
@@ -507,6 +510,7 @@ function NavItems({
const isSkill = categoryId === 'skills';
const isBot = categoryId === 'bots';
const isMCP = categoryId === 'mcp';
const isAgents = categoryId === 'pipelines';
const resolveItemRoute = (item: SidebarEntityItem): string => {
if (item.extensionType === 'mcp') {
@@ -589,6 +593,18 @@ function NavItems({
!inPopover &&
sidebarData.extensionsGroupByType;
const showAgentGroupHeaders =
isAgents && !inPopover && sidebarData.agentsGroupByKind;
const agentGroupOrder: Array<'agent' | 'pipeline'> = [
'agent',
'pipeline',
];
const agentGroupLabelKey: Record<'agent' | 'pipeline', string> = {
agent: 'agents.kindBadgeAgent',
pipeline: 'agents.kindBadgePipeline',
};
const groupOrder: Array<'plugin' | 'mcp' | 'skill'> = [
'plugin',
'mcp',
@@ -723,6 +739,22 @@ function NavItems({
/>
) : null}
<span className="truncate">{item.name}</span>
{item.kind && (
<span
className="ml-auto flex shrink-0 items-center text-muted-foreground"
title={
item.kind === 'pipeline'
? t('agents.kindBadgePipeline')
: t('agents.kindBadgeAgent')
}
>
{item.kind === 'pipeline' ? (
<Workflow className="size-3.5" />
) : (
<Bot className="size-3.5" />
)}
</span>
)}
{item.debug && (
<Bug className="size-3.5 shrink-0 text-orange-400" />
)}
@@ -774,6 +806,24 @@ function NavItems({
</div>
);
})
: showAgentGroupHeaders
? agentGroupOrder.map((kind) => {
const groupItems = visibleItems.filter(
(it) => (it.kind ?? 'agent') === kind,
);
if (groupItems.length === 0) return null;
return (
<div
key={kind}
className="flex flex-col gap-0.5 mt-0.5"
>
<div className="px-2 pt-1 pb-0.5 text-[0.65rem] font-semibold uppercase tracking-wide text-muted-foreground">
{t(agentGroupLabelKey[kind])}
</div>
{groupItems.map((item) => renderItem(item))}
</div>
);
})
: visibleItems.map((item) => renderItem(item))}
{/* Show more / less toggle when items exceed limit */}
{sortedItems.length > maxItems && !inPopover && (
@@ -1011,7 +1061,51 @@ function NavItems({
<span className="cursor-pointer select-none">
{config.name}
</span>
<div className="ml-auto flex items-center gap-0.5 -mr-1">
{/* Group/refresh controls — left-aligned, hugging the title */}
{(isAgents || isExtensionsCategory) && (
<div className="flex items-center gap-0.5">
{isAgents && (
<button
type="button"
title={t('agents.groupByKind')}
className={cn(
'flex items-center gap-1 px-1.5 py-1 rounded-sm text-[10px] transition-all',
sidebarData.agentsGroupByKind
? 'text-sidebar-accent-foreground bg-sidebar-accent'
: 'text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
)}
onClick={(e) => {
e.stopPropagation();
sidebarData.setAgentsGroupByKind(
!sidebarData.agentsGroupByKind,
);
}}
>
<ListTree className="size-3.5" />
<span>{t('agents.groupByKindShort')}</span>
</button>
)}
{isExtensionsCategory && (
<button
type="button"
title={t('plugins.groupByType')}
className={cn(
'flex items-center gap-1 px-1.5 py-1 rounded-sm text-[10px] transition-all',
sidebarData.extensionsGroupByType
? 'text-sidebar-accent-foreground bg-sidebar-accent'
: 'text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
)}
onClick={(e) => {
e.stopPropagation();
sidebarData.setExtensionsGroupByType(
!sidebarData.extensionsGroupByType,
);
}}
>
<ListTree className="size-3.5" />
<span>{t('plugins.groupByTypeShort')}</span>
</button>
)}
{isExtensionsCategory && (
<button
type="button"
@@ -1027,6 +1121,9 @@ function NavItems({
/>
</button>
)}
</div>
)}
<div className="ml-auto flex items-center gap-0.5 -mr-1">
{canCreate &&
(isPlugin ? (
<DropdownMenu>
@@ -28,6 +28,8 @@ export interface SidebarEntityItem {
debug?: boolean;
// Set when this item appears in the unified extensions list
extensionType?: 'plugin' | 'mcp' | 'skill';
// Agent-specific: distinguishes Agent orchestration from legacy Pipeline
kind?: 'agent' | 'pipeline';
}
// Plugin page registered by a plugin
@@ -64,6 +66,9 @@ export interface SidebarDataContextValue {
// Whether the extensions list is grouped by type (shared between page and sidebar)
extensionsGroupByType: boolean;
setExtensionsGroupByType: (enabled: boolean) => void;
// Whether the Agent list is grouped by kind (Agent vs Pipeline)
agentsGroupByKind: boolean;
setAgentsGroupByKind: (enabled: boolean) => void;
}
const SidebarDataContext = createContext<SidebarDataContextValue | null>(null);
@@ -95,6 +100,21 @@ export function SidebarDataProvider({
}
}, []);
const [agentsGroupByKind, setAgentsGroupByKindState] = useState<boolean>(
() => {
if (typeof window === 'undefined') return false;
return localStorage.getItem('agents_group_by_kind') === 'true';
},
);
const setAgentsGroupByKind = useCallback((enabled: boolean) => {
setAgentsGroupByKindState(enabled);
try {
localStorage.setItem('agents_group_by_kind', String(enabled));
} catch {
// ignore
}
}, []);
const refreshBots = useCallback(async () => {
try {
const resp = await httpClient.getBots();
@@ -123,6 +143,7 @@ export function SidebarDataProvider({
description: p.description,
emoji: p.emoji,
updatedAt: p.updated_at,
kind: p.kind,
})),
);
} catch (error) {
@@ -308,6 +329,8 @@ export function SidebarDataProvider({
setDetailEntityName,
extensionsGroupByType,
setExtensionsGroupByType,
agentsGroupByKind,
setAgentsGroupByKind,
}}
>
{children}
@@ -30,9 +30,13 @@ export function getOrderedCategories(): AdapterCategoryId[] {
/**
* Groups items that have a `categories` string array into ordered category
* buckets. An item can appear in multiple groups if it belongs to multiple
* categories. Items without any recognised category are collected into a
* trailing "uncategorized" group (null key).
* buckets. Each item is placed into exactly one bucket its highest-priority
* matching category in display order (e.g. an adapter tagged both `popular`
* and `china` lands in `popular`). This keeps item values unique, which is
* required when the result feeds a Select (duplicate values break Radix's
* item tracking and trigger React duplicate-key warnings). Items without any
* recognised category are collected into a trailing "uncategorized" group
* (null key).
*/
export function groupByCategory<T extends { categories?: string[] }>(
items: T[],
@@ -54,10 +58,13 @@ export function groupByCategory<T extends { categories?: string[] }>(
}
let placed = false;
for (const cat of cats) {
if (ordered.includes(cat as AdapterCategoryId)) {
buckets.get(cat as AdapterCategoryId)!.push(item);
// Assign to the highest-priority matching category (display order) only,
// so each item appears in exactly one bucket.
for (const cat of ordered) {
if (cats.includes(cat)) {
buckets.get(cat)!.push(item);
placed = true;
break;
}
}
if (!placed) {
+1
View File
@@ -205,6 +205,7 @@ export interface Adapter {
icon?: string;
spec: {
categories?: string[];
legacy?: boolean;
help_links?: Record<string, string>;
supported_events?: string[];
supported_apis?: string[];
+74 -2
View File
@@ -7,6 +7,8 @@ import {
ArrowLeft,
ArrowRight,
Check,
ChevronDown,
ChevronRight,
Sparkles,
PartyPopper,
Loader2,
@@ -765,14 +767,24 @@ function StepPlatform({
onSelect: (name: string) => void;
}) {
const { t } = useTranslation();
const [showLegacy, setShowLegacy] = useState(false);
const activeAdapters = useMemo(
() => adapters.filter((a) => !a.spec.legacy),
[adapters],
);
const legacyAdapters = useMemo(
() => adapters.filter((a) => a.spec.legacy),
[adapters],
);
const groupedAdapters = useMemo(() => {
const withCategories = adapters.map((a) => ({
const withCategories = activeAdapters.map((a) => ({
...a,
categories: a.spec.categories,
}));
return groupByCategory(withCategories);
}, [adapters]);
}, [activeAdapters]);
return (
<div className="space-y-6 max-w-4xl mx-auto">
@@ -848,6 +860,66 @@ function StepPlatform({
</div>
</div>
))}
{legacyAdapters.length > 0 && (
<div className="border-t pt-4 space-y-3">
<button
type="button"
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground"
onClick={() => setShowLegacy((v) => !v)}
>
{showLegacy ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
{t('bots.legacyAdapters')}
<span className="rounded bg-muted px-1.5 py-0.5 text-xs">
{legacyAdapters.length}
</span>
</button>
{showLegacy && (
<>
<p className="text-xs text-muted-foreground">
{t('bots.legacyAdaptersHint')}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 opacity-60">
{legacyAdapters.map((adapter) => (
<Card
key={adapter.name}
className={cn(
'cursor-pointer transition-all hover:shadow-md',
selected === adapter.name
? 'ring-2 ring-primary shadow-md'
: 'hover:border-primary/50',
)}
onClick={() => onSelect(adapter.name)}
>
<CardHeader className="flex flex-row items-center gap-3 pb-2">
<img
src={httpClient.getAdapterIconURL(adapter.name)}
alt=""
className="w-10 h-10 rounded-lg shrink-0 grayscale"
/>
<div className="min-w-0">
<CardTitle className="text-base truncate">
{extractI18nObject(adapter.label)}
</CardTitle>
</div>
{selected === adapter.name && (
<div className="ml-auto shrink-0">
<div className="w-5 h-5 rounded-full bg-primary flex items-center justify-center">
<Check className="w-3 h-3 text-primary-foreground" />
</div>
</div>
)}
</CardHeader>
</Card>
))}
</div>
</>
)}
</div>
)}
</div>
);
}
+113
View File
@@ -0,0 +1,113 @@
import * as React from 'react';
import { Command as CommandPrimitive } from 'cmdk';
import { Search } from 'lucide-react';
import { cn } from '@/lib/utils';
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
className,
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
'flex h-9 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
className,
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50',
className,
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn('-mx-1 h-px bg-border', className)}
{...props}
/>
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
export {
Command,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandSeparator,
};
+45
View File
@@ -339,6 +339,9 @@ const enUS = {
deleteConfirmation: 'Are you sure you want to delete this bot?',
platformAdapter: 'Platform/Adapter Selection',
selectAdapter: 'Select Adapter',
legacyAdapters: 'Legacy adapters',
legacyAdaptersHint:
'These adapters are superseded by their newer (EBA) versions. They are kept only for existing configurations and are not recommended for new bots.',
adapterConfig: 'Adapter Configuration',
viewAdapterDocs: 'View Docs',
bindPipeline: 'Bind Pipeline',
@@ -372,14 +375,51 @@ const enUS = {
targetPipeline: 'Pipeline',
targetDiscard: 'Discard',
selectTarget: 'Select handling logic',
searchTarget: 'Search…',
noTargetFound: 'No results found',
priority: 'Priority',
enabled: 'Enabled',
eventBindingDescriptionPlaceholder: 'Rule description',
noEventBindings: 'No event bindings',
unsupportedPipelineEvent: 'Pipelines can only be used for message.* events',
disable: 'Disable',
enable: 'Enable',
disabledBindings: 'Disabled',
eventCustom: 'Custom event',
eventWildcard: 'All events',
eventNamespaceWildcard: '{{namespace}}.*',
eventNames: {
message_received: 'Message received',
message_edited: 'Message edited',
message_deleted: 'Message deleted',
message_reaction: 'Message reaction',
feedback_received: 'Feedback received',
friend_request_received: 'Friend request received',
friend_added: 'Friend added',
group_member_joined: 'Member joined group',
group_member_left: 'Member left group',
group_member_banned: 'Member banned',
bot_invited_to_group: 'Bot invited to group',
bot_removed_from_group: 'Bot removed from group',
bot_muted: 'Bot muted',
bot_unmuted: 'Bot unmuted',
platform_specific: 'Platform-specific event',
},
conditions: 'Conditions',
conditionsDescription:
'All conditions must match to trigger this binding. Leave empty to always trigger.',
conditionsEmpty: 'No conditions — always triggers.',
addFilter: 'Add condition',
filterChatType: 'Session type',
filterChatId: 'Session ID',
filterMessageText: 'Message text',
filterMessageElement: 'Message element',
operator_eq: 'equals',
operator_neq: 'not equals',
operator_contains: 'contains',
operator_not_contains: 'not contains',
operator_starts_with: 'starts with',
operator_regex: 'regex',
routingRules: 'Conditional Routing Rules',
routingRulesDescription:
'Rules are evaluated in order; first match routes to its pipeline. Fallback to the default pipeline above if none match.',
@@ -477,6 +517,10 @@ const enUS = {
agentOrchestrationDescription:
'Event-first handling logic for messages, group members, friends, feedback, and other EBA events.',
pipelineType: 'Pipeline',
kindBadgeAgent: 'Agent',
kindBadgePipeline: 'Pipeline',
groupByKind: 'Group by type',
groupByKindShort: 'Group',
pipelineTypeDescription:
'Keep the existing no-code message pipeline for backward compatibility. It only handles message events.',
allEvents: 'Supports all EBA events',
@@ -539,6 +583,7 @@ const enUS = {
noExtensionInstalled: 'No extensions installed',
loadingExtensions: 'Loading extensions...',
groupByType: 'Group by format',
groupByTypeShort: 'Group',
pluginConfig: 'Plugin Configuration',
pluginSort: 'Plugin Sort',
pluginSortDescription:
+51
View File
@@ -457,6 +457,56 @@ const esES = {
botMessage: 'Asistente',
},
},
agents: {
title: 'Agent',
description:
'Gestiona orquestaciones de Agent y Pipelines, y vincúlalos a eventos de bot',
create: 'Crear Agent',
editAgent: 'Editar orquestación de Agent',
selectFromSidebar: 'Selecciona un Agent o Pipeline desde la barra lateral',
agentOrchestration: 'Orquestación de Agent',
agentOrchestrationDescription:
'Lógica de procesamiento orientada a eventos EBA para mensajes, miembros de grupo, amigos, retroalimentación y otros eventos.',
pipelineType: 'Pipeline',
kindBadgeAgent: 'Agent',
kindBadgePipeline: 'Pipeline',
groupByKind: 'Agrupar por tipo',
groupByKindShort: 'Agrupar',
pipelineTypeDescription:
'Mantiene el pipeline de mensajes sin código para compatibilidad con versiones anteriores. Solo procesa eventos de mensaje.',
allEvents: 'Compatible con todos los eventos EBA',
messageEventsOnly: 'Solo eventos de mensaje',
basicInfo: 'Información básica',
basicInfoDescription:
'Establece el nombre, icono, descripción y estado de habilitación',
runnerSettings: 'Runner',
eventCapability: 'Capacidad de eventos',
eventCapabilityDescription:
'Declara a qué eventos puede vincularse esta orquestación de Agent. Un patrón de evento por línea; se admiten * y namespace.*.',
supportedEvents: 'Eventos admitidos',
supportedEventsDescription:
'Ejemplos: *, 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.',
nameRequired: 'El nombre no puede estar vacío',
createSuccess: 'Creado correctamente',
createError: 'Error al crear: ',
loadError: 'Error al cargar: ',
saveSuccess: 'Guardado correctamente',
saveError: 'Error al guardar: ',
deleteSuccess: 'Eliminado correctamente',
deleteError: 'Error al eliminar: ',
deleteConfirmation:
'¿Estás seguro de que deseas eliminar esta orquestación de Agent?',
dangerZone: 'Zona de peligro',
dangerZoneDescription: 'Acciones irreversibles y destructivas',
deleteAgentAction: 'Eliminar esta orquestación de Agent',
deleteAgentHint:
'Una vez eliminado, los eventos vinculados a él ya no podrán ejecutarse.',
noRunnerMetadata:
'No hay metadatos de AgentRunner disponibles actualmente.',
},
plugins: {
title: 'Extensiones',
description:
@@ -486,6 +536,7 @@ const esES = {
noExtensionInstalled: 'No hay extensiones instaladas',
loadingExtensions: 'Cargando extensiones...',
groupByType: 'Agrupar por formato',
groupByTypeShort: 'Agrupar',
pluginConfig: 'Configuración del plugin',
pluginSort: 'Orden de plugins',
pluginSortDescription:
+22
View File
@@ -387,6 +387,23 @@ const jaJP = {
eventCustom: 'カスタムイベント',
eventWildcard: 'すべてのイベント',
eventNamespaceWildcard: '{{namespace}}.*',
eventNames: {
message_received: 'メッセージ受信',
message_edited: 'メッセージ編集',
message_deleted: 'メッセージ削除',
message_reaction: 'メッセージリアクション',
feedback_received: 'フィードバック受信',
friend_request_received: '友達リクエスト受信',
friend_added: '友達追加',
group_member_joined: 'メンバー参加',
group_member_left: 'メンバー退出',
group_member_banned: 'メンバーBAN',
bot_invited_to_group: 'ボットがグループに招待された',
bot_removed_from_group: 'ボットがグループから削除された',
bot_muted: 'ボットがミュートされた',
bot_unmuted: 'ボットのミュート解除',
platform_specific: 'プラットフォーム固有イベント',
},
routingRules: '条件付きルーティングルール',
routingRulesDescription:
'ルールは順番に評価され、最初に一致したルールのパイプラインにルーティングされます。一致しない場合はデフォルトパイプラインが使用されます。',
@@ -484,6 +501,10 @@ const jaJP = {
agentOrchestrationDescription:
'メッセージ、グループメンバー、友だち、フィードバックなどの EBA イベント向けの処理ロジックです。',
pipelineType: 'Pipeline',
kindBadgeAgent: 'Agent',
kindBadgePipeline: 'パイプライン',
groupByKind: 'タイプ別にグループ化',
groupByKindShort: 'グループ',
pipelineTypeDescription:
'既存のノーコードメッセージ Pipeline を互換性のため保持します。メッセージイベントのみ処理できます。',
allEvents: 'すべての EBA イベントに対応',
@@ -544,6 +565,7 @@ const jaJP = {
noExtensionInstalled: '拡張機能がインストールされていません',
loadingExtensions: '拡張機能を読み込み中...',
groupByType: '形式でグループ化',
groupByTypeShort: 'グループ',
pluginConfig: 'プラグイン設定',
pluginSort: 'プラグインの並び替え',
pluginSortDescription:
+48
View File
@@ -455,6 +455,53 @@ const ruRU = {
botMessage: 'Ассистент',
},
},
agents: {
title: 'Agent',
description:
'Управляйте оркестровками Agent и Pipeline, привязывая их к событиям бота',
create: 'Создать Agent',
editAgent: 'Редактировать оркестровку Agent',
selectFromSidebar: 'Выберите Agent или Pipeline на боковой панели',
agentOrchestration: 'Оркестровка Agent',
agentOrchestrationDescription:
'Логика обработки событий EBA для сообщений, участников групп, друзей, обратной связи и других событий.',
pipelineType: 'Pipeline',
kindBadgeAgent: 'Agent',
kindBadgePipeline: 'Pipeline',
groupByKind: 'Группировать по типу',
groupByKindShort: 'Группа',
pipelineTypeDescription:
'Сохраняет существующий no-code конвейер сообщений для обратной совместимости. Обрабатывает только события сообщений.',
allEvents: 'Поддерживает все события EBA',
messageEventsOnly: 'Только события сообщений',
basicInfo: 'Основная информация',
basicInfoDescription: 'Задайте имя, иконку, описание и статус активации',
runnerSettings: 'Runner',
eventCapability: 'Возможности событий',
eventCapabilityDescription:
'Объявите, к каким событиям может быть привязана эта оркестровка Agent. Один шаблон события в строке; поддерживаются * и namespace.*.',
supportedEvents: 'Поддерживаемые события',
supportedEventsDescription:
'Примеры: *, message.received, group.*. Pipeline фиксирован на message.*.',
enabled: 'Включить Agent',
enabledDescription:
'При отключении этот Agent не должен выбираться маршрутизацией событий.',
nameRequired: 'Имя не может быть пустым',
createSuccess: 'Успешно создано',
createError: 'Ошибка создания: ',
loadError: 'Ошибка загрузки: ',
saveSuccess: 'Успешно сохранено',
saveError: 'Ошибка сохранения: ',
deleteSuccess: 'Успешно удалено',
deleteError: 'Ошибка удаления: ',
deleteConfirmation: 'Вы уверены, что хотите удалить эту оркестровку Agent?',
dangerZone: 'Опасная зона',
dangerZoneDescription: 'Необратимые и деструктивные действия',
deleteAgentAction: 'Удалить эту оркестровку Agent',
deleteAgentHint:
'После удаления события, привязанные к ней, больше не смогут выполняться.',
noRunnerMetadata: 'Метаданные AgentRunner в данный момент недоступны.',
},
plugins: {
title: 'Расширения',
description:
@@ -485,6 +532,7 @@ const ruRU = {
noExtensionInstalled: 'Расширения не установлены',
loadingExtensions: 'Загрузка расширений...',
groupByType: 'Группировать по формату',
groupByTypeShort: 'Группа',
pluginConfig: 'Настройка плагина',
pluginSort: 'Порядок плагинов',
pluginSortDescription:
+48
View File
@@ -441,6 +441,53 @@ const thTH = {
botMessage: 'ผู้ช่วย',
},
},
agents: {
title: 'Agent',
description:
'จัดการการประสาน Agent และ Pipeline แล้วเชื่อมกับเหตุการณ์ของบอท',
create: 'สร้าง Agent',
editAgent: 'แก้ไขการประสาน Agent',
selectFromSidebar: 'เลือก Agent หรือ Pipeline จากแถบด้านข้าง',
agentOrchestration: 'การประสาน Agent',
agentOrchestrationDescription:
'ตรรกะการประมวลผลที่เน้นเหตุการณ์ EBA สำหรับข้อความ สมาชิกกลุ่ม เพื่อน ฟีดแบ็ก และเหตุการณ์อื่นๆ',
pipelineType: 'Pipeline',
kindBadgeAgent: 'Agent',
kindBadgePipeline: 'Pipeline',
groupByKind: 'จัดกลุ่มตามประเภท',
groupByKindShort: 'จัดกลุ่ม',
pipelineTypeDescription:
'คงไว้ซึ่ง pipeline ข้อความแบบไม่ต้องเขียนโค้ดเพื่อความเข้ากันได้ย้อนหลัง รองรับเฉพาะเหตุการณ์ข้อความ',
allEvents: 'รองรับทุกเหตุการณ์ EBA',
messageEventsOnly: 'เฉพาะเหตุการณ์ข้อความ',
basicInfo: 'ข้อมูลพื้นฐาน',
basicInfoDescription: 'ตั้งชื่อ ไอคอน คำอธิบาย และสถานะการเปิดใช้งาน',
runnerSettings: 'Runner',
eventCapability: 'ความสามารถด้านเหตุการณ์',
eventCapabilityDescription:
'ประกาศว่าการประสาน Agent นี้สามารถเชื่อมกับเหตุการณ์ใดได้บ้าง หนึ่งรูปแบบเหตุการณ์ต่อบรรทัด รองรับ * และ namespace.*',
supportedEvents: 'เหตุการณ์ที่รองรับ',
supportedEventsDescription:
'ตัวอย่าง: *, message.received, group.* Pipeline ถูกกำหนดไว้ที่ message.*',
enabled: 'เปิดใช้งาน Agent',
enabledDescription:
'เมื่อปิดใช้งาน Agent นี้จะไม่ถูกเลือกโดยการกำหนดเส้นทางเหตุการณ์',
nameRequired: 'ชื่อต้องไม่ว่างเปล่า',
createSuccess: 'สร้างสำเร็จ',
createError: 'สร้างล้มเหลว: ',
loadError: 'โหลดล้มเหลว: ',
saveSuccess: 'บันทึกสำเร็จ',
saveError: 'บันทึกล้มเหลว: ',
deleteSuccess: 'ลบสำเร็จ',
deleteError: 'ลบล้มเหลว: ',
deleteConfirmation: 'คุณแน่ใจหรือว่าต้องการลบการประสาน Agent นี้?',
dangerZone: 'โซนอันตราย',
dangerZoneDescription: 'การดำเนินการที่ไม่สามารถย้อนกลับและทำลายข้อมูล',
deleteAgentAction: 'ลบการประสาน Agent นี้',
deleteAgentHint:
'เมื่อลบแล้ว เหตุการณ์ที่เชื่อมกับมันจะไม่สามารถดำเนินการต่อได้',
noRunnerMetadata: 'ขณะนี้ไม่มีข้อมูลเมตา AgentRunner ที่พร้อมใช้งาน',
},
plugins: {
title: 'ส่วนขยาย',
description:
@@ -470,6 +517,7 @@ const thTH = {
noExtensionInstalled: 'ยังไม่มีส่วนขยายที่ติดตั้ง',
loadingExtensions: 'กำลังโหลดส่วนขยาย...',
groupByType: 'จัดกลุ่มตามรูปแบบ',
groupByTypeShort: 'จัดกลุ่ม',
pluginConfig: 'การกำหนดค่าปลั๊กอิน',
pluginSort: 'เรียงลำดับปลั๊กอิน',
pluginSortDescription:
+48
View File
@@ -451,6 +451,53 @@ const viVN = {
botMessage: 'Trợ lý',
},
},
agents: {
title: 'Agent',
description:
'Quản lý dàn dựng Agent và Pipeline, sau đó gắn chúng vào sự kiện của bot',
create: 'Tạo Agent',
editAgent: 'Chỉnh sửa dàn dựng Agent',
selectFromSidebar: 'Chọn một Agent hoặc Pipeline từ thanh bên',
agentOrchestration: 'Dàn dựng Agent',
agentOrchestrationDescription:
'Logic xử lý hướng sự kiện cho tin nhắn, thành viên nhóm, bạn bè, phản hồi và các sự kiện EBA khác.',
pipelineType: 'Pipeline',
kindBadgeAgent: 'Agent',
kindBadgePipeline: 'Pipeline',
groupByKind: 'Nhóm theo loại',
groupByKindShort: 'Nhóm',
pipelineTypeDescription:
'Giữ lại pipeline tin nhắn không cần mã hiện có để tương thích ngược. Chỉ xử lý sự kiện tin nhắn.',
allEvents: 'Hỗ trợ tất cả sự kiện EBA',
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',
runnerSettings: 'Runner',
eventCapability: 'Khả năng sự kiện',
eventCapabilityDescription:
'Khai báo những sự kiện mà dàn dựng Agent này có thể được gắn vào. Mỗi dòng một mẫu sự kiện; hỗ trợ * và namespace.*.',
supportedEvents: 'Sự kiện được hỗ trợ',
supportedEventsDescription:
'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.',
nameRequired: 'Tên không được để trống',
createSuccess: 'Tạo thành công',
createError: 'Tạo thất bại: ',
loadError: 'Tải thất bại: ',
saveSuccess: 'Lưu thành công',
saveError: 'Lưu thất bại: ',
deleteSuccess: 'Xóa thành công',
deleteError: 'Xóa thất bại: ',
deleteConfirmation: 'Bạn có chắc muốn xóa dàn dựng Agent này không?',
dangerZone: 'Vùng nguy hiểm',
dangerZoneDescription: 'Hành động không thể hoàn tác và mang tính phá hủy',
deleteAgentAction: 'Xóa dàn dựng Agent này',
deleteAgentHint:
'Sau khi xóa, các sự kiện đã gắn vào nó sẽ không thể thực thi được nữa.',
noRunnerMetadata: 'Hiện chưa có siêu dữ liệu AgentRunner khả dụng.',
},
plugins: {
title: 'Tiện ích mở rộng',
description:
@@ -480,6 +527,7 @@ const viVN = {
noExtensionInstalled: 'Chưa cài đặt tiện ích mở rộng nào',
loadingExtensions: 'Đang tải tiện ích mở rộng...',
groupByType: 'Nhóm theo định dạng',
groupByTypeShort: 'Nhóm',
pluginConfig: 'Cấu hình Plugin',
pluginSort: 'Sắp xếp Plugin',
pluginSortDescription:
+44
View File
@@ -324,6 +324,9 @@ const zhHans = {
deleteConfirmation: '你确定要删除这个机器人吗?',
platformAdapter: '平台/适配器选择',
selectAdapter: '选择适配器',
legacyAdapters: '旧版适配器',
legacyAdaptersHint:
'这些适配器已被新版(EBA 架构)取代,仅为兼容存量配置保留,不建议新建机器人时使用。',
adapterConfig: '适配器配置',
viewAdapterDocs: '查看文档',
bindPipeline: '绑定流水线',
@@ -356,14 +359,50 @@ const zhHans = {
targetPipeline: 'Pipeline',
targetDiscard: '丢弃',
selectTarget: '选择处理逻辑',
searchTarget: '搜索处理逻辑…',
noTargetFound: '未找到匹配项',
priority: '优先级',
enabled: '启用',
eventBindingDescriptionPlaceholder: '规则说明',
noEventBindings: '暂无事件绑定',
unsupportedPipelineEvent: 'Pipeline 仅可用于 message.* 事件',
disable: '禁用',
enable: '启用',
disabledBindings: '已禁用',
eventCustom: '自定义事件',
eventWildcard: '全部事件',
eventNamespaceWildcard: '{{namespace}}.*',
eventNames: {
message_received: '收到消息',
message_edited: '消息被编辑',
message_deleted: '消息被删除',
message_reaction: '消息表态',
feedback_received: '收到反馈',
friend_request_received: '收到好友请求',
friend_added: '好友添加成功',
group_member_joined: '成员加入群组',
group_member_left: '成员离开群组',
group_member_banned: '成员被封禁',
bot_invited_to_group: '机器人被邀入群',
bot_removed_from_group: '机器人被移出群',
bot_muted: '机器人被禁言',
bot_unmuted: '机器人被解除禁言',
platform_specific: '平台特定事件',
},
conditions: '触发条件',
conditionsDescription: '满足所有条件时才触发此绑定,不添加则无条件触发。',
conditionsEmpty: '无条件,始终触发。',
addFilter: '添加条件',
filterChatType: '会话类型',
filterChatId: '会话 ID',
filterMessageText: '消息文本',
filterMessageElement: '消息元素',
operator_eq: '等于',
operator_neq: '不等于',
operator_contains: '包含',
operator_not_contains: '不包含',
operator_starts_with: '前缀匹配',
operator_regex: '正则匹配',
routingRules: '条件路由规则',
routingRulesDescription:
'按顺序匹配,命中第一条规则后路由到对应流水线;都不匹配时使用上方默认流水线',
@@ -458,6 +497,10 @@ const zhHans = {
agentOrchestrationDescription:
'面向 EBA 事件的处理逻辑,可用于消息、群成员、好友、反馈等事件。',
pipelineType: 'Pipeline',
kindBadgeAgent: 'Agent',
kindBadgePipeline: '流水线',
groupByKind: '按类型分组',
groupByKindShort: '分组',
pipelineTypeDescription:
'保留现有无代码消息流水线,兼容旧配置,只能处理消息事件。',
allEvents: '支持全部 EBA 事件',
@@ -517,6 +560,7 @@ const zhHans = {
noExtensionInstalled: '暂未安装任何扩展',
loadingExtensions: '正在加载扩展...',
groupByType: '按格式分组',
groupByTypeShort: '分组',
pluginSort: '插件排序',
pluginSortDescription:
'插件顺序会影响同一事件内的处理顺序,请拖动插件卡片排序',
+45
View File
@@ -427,6 +427,50 @@ const zhHant = {
botMessage: '助手',
},
},
agents: {
title: 'Agent',
description: '管理 Agent 編排與 Pipeline,並將它們綁定到機器人事件',
create: '建立 Agent',
editAgent: '編輯 Agent 編排',
selectFromSidebar: '從側邊欄選擇一個 Agent 或 Pipeline',
agentOrchestration: 'Agent 編排',
agentOrchestrationDescription:
'面向 EBA 事件的處理邏輯,可用於訊息、群成員、好友、回饋等事件。',
pipelineType: 'Pipeline',
kindBadgeAgent: 'Agent',
kindBadgePipeline: '流水線',
groupByKind: '依類型分組',
groupByKindShort: '分組',
pipelineTypeDescription:
'保留現有無程式碼訊息流水線,相容舊設定,只能處理訊息事件。',
allEvents: '支援全部 EBA 事件',
messageEventsOnly: '僅支援訊息事件',
basicInfo: '基本資訊',
basicInfoDescription: '設定名稱、圖示、描述和啟用狀態',
runnerSettings: '執行器',
eventCapability: '事件能力',
eventCapabilityDescription:
'宣告此 Agent 編排可被綁定到哪些事件。每行一個事件模式,支援 * 與 namespace.*。',
supportedEvents: '支援的事件',
supportedEventsDescription:
'例如 *、message.received、group.*。Pipeline 固定僅支援 message.*。',
enabled: '啟用 Agent',
enabledDescription: '停用後,此 Agent 不應被事件路由選中。',
nameRequired: '名稱不能為空',
createSuccess: '建立成功',
createError: '建立失敗:',
loadError: '載入失敗:',
saveSuccess: '儲存成功',
saveError: '儲存失敗:',
deleteSuccess: '刪除成功',
deleteError: '刪除失敗:',
deleteConfirmation: '你確定要刪除這個 Agent 編排嗎?',
dangerZone: '危險區域',
dangerZoneDescription: '不可逆的操作',
deleteAgentAction: '刪除此 Agent 編排',
deleteAgentHint: '刪除後,綁定到它的事件將無法繼續執行。',
noRunnerMetadata: '目前沒有可用的 AgentRunner 中繼資料。',
},
plugins: {
title: '外掛擴展',
description: '安裝和設定用於擴展功能的外掛,請在流程線配置中選用',
@@ -457,6 +501,7 @@ const zhHant = {
noExtensionInstalled: '暫未安裝任何擴充功能',
loadingExtensions: '正在載入擴充功能...',
groupByType: '依格式分組',
groupByTypeShort: '分組',
pluginSort: '外掛排序',
pluginSortDescription:
'外掛順序會影響同一事件內的處理順序,請拖曳外掛卡片排序',