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 新增设计。 | | 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。 | | 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。 | | 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。 | | 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。 | | 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 协议顶层。 | | 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;不把平台动作伪装成文本回复副作用。 | | 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。 | | 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。 | | 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。 | | 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 发布前完成;发布后应走新的显式兼容方案。 - 删除字段或改变既有字段语义,需要在 SDK 发布前完成;发布后应走新的显式兼容方案。
- 结果流演进:Host **必须忽略未知 result type 并记录 warning**(除非该 type 明确要求强校验)。SDK envelope 接收入站未知 `type` 字符串,runner 侧可按原字符串转发或忽略;新增 result type 不提升大版本。 - 结果流演进: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 协议 ## 4. Discovery 协议
+1 -1
View File
@@ -26,7 +26,7 @@
- `action.requested` 仍只作为 telemetry / reserved surfaceplatform action executor 不在本分支执行。 - `action.requested` 仍只作为 telemetry / reserved surfaceplatform action executor 不在本分支执行。
- EventGateway / EventRouter 完整实现由外部 EBA 分支联调;本分支只提供 event-first host envelope / binding / run 入口。 - EventGateway / EventRouter 完整实现由外部 EBA 分支联调;本分支只提供 event-first host envelope / binding / run 入口。
- State 与 storage 的长期类型边界仍可继续收窄;当前合同只要求 JSON-safe state 与受控 storage API。 - 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 产品化前补齐。 - EventLog / Transcript 已提供显式 cleanup primitive;长期 retention 默认值、TTL 调度接入和 sandbox/workspace 文件清理仍是运维收尾项,应在 Runtime Control Plane 产品化前补齐。
- External harness 的 native shell / filesystem / CLI / MCP 权限不受 manifest permissions 约束;manifest permissions 只约束 LangBot 持有的资源访问。 - 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 权限由用户或部署环境承担。 - 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. - `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. - `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. - `message_converter.py` for DingTalk message payloads to/from common `MessageChain` components.
- `api_impl.py` for common EBA API implementations. - `api_impl.py` for common EBA API implementations.
- `platform_api.py` for DingTalk-specific `call_platform_api` actions. - `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 | | Event | Support | Evidence |
|-------|---------|----------| |-------|---------|----------|
| `message.received` | implemented | `plugin-e2e-ui` private text and emoji-as-text. | | `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 ## Receive Components
@@ -44,7 +44,6 @@ Discord declares these EBA events:
- `message.reaction` - `message.reaction`
- `group.member_joined` - `group.member_joined`
- `group.member_left` - `group.member_left`
- `group.member_banned`
- `bot.invited_to_group` - `bot.invited_to_group`
- `bot.removed_from_group` - `bot.removed_from_group`
- `platform.specific` - `platform.specific`
@@ -24,7 +24,7 @@ Status: partial migration. Unit/API-shape coverage is present, and private text
| Event | Evidence | Notes | | 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. | | `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 ## Common APIs
@@ -22,7 +22,12 @@ Status: partial migration. The EBA adapter structure, manifest, converters, cach
| Event | Evidence | Notes | | 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. | | `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 ## Common APIs
@@ -102,7 +102,7 @@ try {
}); });
Object.assign(result, prepared); Object.assign(result, prepared);
if (result.pipeline_id) { 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) { if (writeEnv && result.pipeline_id) {
@@ -89,7 +89,7 @@ try {
}); });
Object.assign(result, prepared); Object.assign(result, prepared);
if (result.pipeline_id) { 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) { if (writeEnv && result.pipeline_id) {
@@ -74,7 +74,7 @@ try {
}); });
Object.assign(result, prepared); Object.assign(result, prepared);
if (result.pipeline_id) { 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) { 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_BROWSER_PROFILE: "/tmp/langbot-test-profile",
LANGBOT_CHROMIUM_EXECUTABLE: "/tmp/langbot-test-chromium", 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 = ""; process.env.LANGBOT_PIPELINE_NAME = "";
const ready = capture(() => commandTestPlan(ctx(["test", "plan", "pipeline-debug-chat", "--json"]))); const ready = capture(() => commandTestPlan(ctx(["test", "plan", "pipeline-debug-chat", "--json"])));
+30 -2
View File
@@ -1,6 +1,7 @@
import re import re
import time import time
import asyncio import asyncio
import collections
from quart import request from quart import request
import httpx import httpx
from quart import Quart from quart import Quart
@@ -35,6 +36,11 @@ class QQOfficialClient:
self.access_token_expiry_time = None self.access_token_expiry_time = None
self.logger = logger self.logger = logger
self._msg_seq_counter = 0 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 self._token_refresh_task: Optional[asyncio.Task] = None
async def check_access_token(self): async def check_access_token(self):
@@ -177,6 +183,27 @@ class QQOfficialClient:
content_type = attachment.get('content_type', '') content_type = attachment.get('content_type', '')
return content_type.startswith('image/') 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): async def send_private_text_msg(self, user_openid: str, content: str, msg_id: str):
"""发送私聊消息""" """发送私聊消息"""
if not await self.check_access_token(): if not await self.check_access_token():
@@ -192,6 +219,7 @@ class QQOfficialClient:
'content': content, 'content': content,
'msg_type': 0, 'msg_type': 0,
'msg_id': msg_id, 'msg_id': msg_id,
'msg_seq': await self.next_reply_msg_seq(msg_id),
} }
response = await client.post(url, headers=headers, json=data) response = await client.post(url, headers=headers, json=data)
response_data = response.json() response_data = response.json()
@@ -216,6 +244,7 @@ class QQOfficialClient:
'content': content, 'content': content,
'msg_type': 0, 'msg_type': 0,
'msg_id': msg_id, 'msg_id': msg_id,
'msg_seq': await self.next_reply_msg_seq(msg_id),
} }
response = await client.post(url, headers=headers, json=data) response = await client.post(url, headers=headers, json=data)
if response.status_code == 200: if response.status_code == 200:
@@ -363,8 +392,7 @@ class QQOfficialClient:
else: else:
raise ValueError(f'Unsupported target_type: {target_type}') raise ValueError(f'Unsupported target_type: {target_type}')
self._msg_seq_counter += 1 msg_seq = await self.next_reply_msg_seq(msg_id)
msg_seq = self._msg_seq_counter
body = { body = {
'msg_type': 7, 'msg_type': 7,
'media': {'file_info': file_info}, 'media': {'file_info': file_info},
@@ -184,7 +184,7 @@ class AgentRunContextBuilder:
def _is_llm_model_resource(model_resource: ModelResource) -> bool: def _is_llm_model_resource(model_resource: ModelResource) -> bool:
operations = model_resource.get('operations') operations = model_resource.get('operations')
if isinstance(operations, list) and 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' return model_resource.get('model_type') != 'rerank'
async def _build_model_context_window_tokens(self, resources: AgentResources) -> int | None: async def _build_model_context_window_tokens(self, resources: AgentResources) -> int | None:
+4 -2
View File
@@ -146,8 +146,10 @@ class AgentRunnerRegistry:
Returns: Returns:
List of runner descriptors List of runner descriptors
""" """
if use_cache and self._cache is not None: if use_cache and self._cache is not None and self._cache:
# Filter from 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) return self._filter_runners_by_bound_plugins(self._cache, bound_plugins)
# Discover fresh (always full list) # Discover fresh (always full list)
@@ -101,9 +101,9 @@ class AgentResourceBuilder:
seen_model_ids: set[str] = set() seen_model_ids: set[str] = set()
model_perms = set(manifest_perms.models) 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 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: if not include_llm and not include_rerank:
return models return models
@@ -13,7 +13,7 @@ from .context_builder import AgentResources
MAX_STEERING_QUEUE_ITEMS = 100 MAX_STEERING_QUEUE_ITEMS = 100
DEFAULT_RESOURCE_OPERATIONS: dict[str, set[str]] = { DEFAULT_RESOURCE_OPERATIONS: dict[str, set[str]] = {
'model': {'invoke', 'stream', 'rerank'}, 'model': {'invoke', 'stream', 'rerank', 'count_tokens'},
'tool': {'detail', 'call'}, 'tool': {'detail', 'call'},
'knowledge_base': {'list', 'retrieve'}, 'knowledge_base': {'list', 'retrieve'},
'skill': {'activate'}, 'skill': {'activate'},
+4 -23
View File
@@ -190,17 +190,6 @@ class BotService:
# TODO: 检查配置信息格式 # TODO: 检查配置信息格式
bot_data['uuid'] = str(uuid.uuid4()) 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)) await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_bot.Bot).values(bot_data))
bot = await self.get_bot(bot_data['uuid']) bot = await self.get_bot(bot_data['uuid'])
@@ -219,18 +208,10 @@ class BotService:
if 'event_bindings' in update_data: if 'event_bindings' in update_data:
update_data['event_bindings'] = await self._normalize_event_bindings(update_data.get('event_bindings')) update_data['event_bindings'] = await self._normalize_event_bindings(update_data.get('event_bindings'))
# set use_pipeline_name # clear legacy routing fields — routing is now fully managed via event_bindings
if 'use_pipeline_uuid' in update_data: update_data.pop('use_pipeline_uuid', None)
result = await self.ap.persistence_mgr.execute_async( update_data.pop('use_pipeline_name', None)
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where( update_data.pop('pipeline_routing_rules', None)
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')
await self.ap.persistence_mgr.execute_async( await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_bot.Bot).values(update_data).where(persistence_bot.Bot.uuid == bot_uuid) 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. auto-detecting remote transport consumes them regardless.
Revision ID: 0006_normalize_mcp_remote_mode Revision ID: 0006_normalize_mcp_remote_mode
Revises: 0005_add_llm_context_length Revises: 8d3a1f2c4b6e
Create Date: 2026-06-21 Create Date: 2026-06-21
""" """
@@ -18,7 +18,7 @@ import sqlalchemy as sa
from alembic import op from alembic import op
revision = '0006_normalize_mcp_remote_mode' revision = '0006_normalize_mcp_remote_mode'
down_revision = '0005_add_llm_context_length' down_revision = '8d3a1f2c4b6e'
branch_labels = None branch_labels = None
depends_on = 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 traceback
import typing import typing
import dingtalk_stream
import pydantic import pydantic
from langbot.libs.dingtalk_api.api import DingTalkClient 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 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): class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapter):
bot: DingTalkClient = pydantic.Field(exclude=True) bot: DingTalkClient = pydantic.Field(exclude=True)
@@ -68,6 +95,7 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
def get_supported_events(self) -> list[str]: def get_supported_events(self) -> list[str]:
return [ return [
'message.received', 'message.received',
'feedback.received',
'platform.specific', 'platform.specific',
] ]
@@ -198,6 +226,10 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
self.bot.on_message('FriendMessage')(on_message) self.bot.on_message('FriendMessage')(on_message)
self.bot.on_message('GroupMessage')(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): async def _handle_native_event(self, event: DingTalkEvent):
try: try:
@@ -19,6 +19,11 @@ class DingTalkEventConverter(abstract_platform_adapter.AbstractEventConverter):
async def target2yiri(event: DingTalkEvent, bot_name: str) -> platform_events.Event | None: async def target2yiri(event: DingTalkEvent, bot_name: str) -> platform_events.Event | None:
if event.conversation in {'FriendMessage', 'GroupMessage'}: if event.conversation in {'FriendMessage', 'GroupMessage'}:
return await DingTalkEventConverter.message_to_eba(event, bot_name) 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"}') return DingTalkEventConverter.platform_specific(event, f'message.{event.conversation or "unknown"}')
@staticmethod @staticmethod
@@ -57,6 +62,54 @@ class DingTalkEventConverter(abstract_platform_adapter.AbstractEventConverter):
source_platform_object=event, 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 @staticmethod
def user_from_event(event: DingTalkEvent) -> platform_entities.User: def user_from_event(event: DingTalkEvent) -> platform_entities.User:
incoming_message = event.incoming_message incoming_message = event.incoming_message
@@ -92,6 +92,7 @@ spec:
supported_events: supported_events:
- message.received - message.received
- feedback.received
- platform.specific - platform.specific
supported_apis: supported_apis:
@@ -1,3 +1,3 @@
from __future__ import annotations 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]: 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]: def get_supported_apis(self) -> list[str]:
return [ 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.libs.qq_official_api.qqofficialevent import QQOfficialEvent
from langbot.pkg.platform.adapters.qqofficial.api_impl import QQOfficialAPIMixin 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.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.message_converter import QQOfficialMessageConverter
from langbot.pkg.platform.adapters.qqofficial.platform_api import PLATFORM_API_MAP from langbot.pkg.platform.adapters.qqofficial.platform_api import PLATFORM_API_MAP
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter 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]: def get_supported_events(self) -> list[str]:
return [ return [
'message.received', 'message.received',
'message.reaction',
'group.member_joined',
'group.member_left',
'bot.invited_to_group',
'bot.removed_from_group',
'platform.specific', 'platform.specific',
] ]
@@ -111,7 +125,9 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
target_id: str, target_id: str,
message: platform_message.MessageChain, message: platform_message.MessageChain,
) -> platform_events.MessageResult: ) -> 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}) return platform_events.MessageResult(raw={'results': raw})
async def reply_message( async def reply_message(
@@ -212,8 +228,14 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
is_final: bool = False, is_final: bool = False,
): ):
await self._cleanup_stale_streams() await self._cleanup_stale_streams()
chunk_text = '\n\n'.join(component.text for component in message if isinstance(component, platform_message.Plain)) chunk_text = '\n\n'.join(
message_id = bot_message.get('resp_message_id') if isinstance(bot_message, dict) else getattr(bot_message, 'resp_message_id', None) 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 not message_id or message_id not in self._stream_ctx:
if chunk_text: if chunk_text:
self._fallback_text[message_id] = self._fallback_text.get(message_id, '') + 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: if is_final:
full_text = self._fallback_text.pop(message_id, '') full_text = self._fallback_text.pop(message_id, '')
if full_text: 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 return
ctx = self._stream_ctx[message_id] ctx = self._stream_ctx[message_id]
@@ -254,12 +280,23 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
ctx['stream_msg_id'] = resp['id'] ctx['stream_msg_id'] = resp['id']
ctx['sent_length'] = len(ctx['accumulated_text']) ctx['sent_length'] = len(ctx['accumulated_text'])
ctx['index'] += 1 ctx['index'] += 1
# 每个 chunk 单独 HTTP 下发,msg_seq 必须递增,否则后续 chunk 会被 QQ
# 以 (msg_id, msg_seq) 去重 (40054005 消息被去重)。
ctx['msg_seq'] += 1
if is_final: if is_final:
self._stream_ctx.pop(message_id, None) self._stream_ctx.pop(message_id, None)
self._stream_ctx_ts.pop(message_id, None) self._stream_ctx_ts.pop(message_id, None)
def _register_native_handlers(self): 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) self.bot.on_message(event_type)(self._handle_native_event)
async def _handle_native_event(self, event: QQOfficialEvent): 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') await self.logger.info('QQ Official WebSocket connected and ready')
async def on_event(event_type: str, event_data: dict): 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): if not isinstance(event_data, dict):
await self.logger.warning(f'Event data is not dict, skipping: {event_type} -> {type(event_data)}') await self.logger.warning(f'Event data is not dict, skipping: {event_type} -> {type(event_data)}')
return 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} payload = {'t': event_type, 'd': event_data}
message_data = await self.bot.get_message(payload) message_data = await self.bot.get_message(payload)
if message_data: if message_data:
@@ -337,7 +374,9 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
return 'channel_private', event.guild_id return 'channel_private', event.guild_id
raise NotSupportedError(f'reply_message:{event.t or "unknown_event"}') 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) target_type = self._normalize_target_type(target_type)
results: list[dict] = [] results: list[dict] = []
for content in content_list: for content in content_list:
@@ -361,10 +400,14 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
raise NotSupportedError(f'send_message:{target_type}') raise NotSupportedError(f'send_message:{target_type}')
results.append({'type': content_type, 'raw': raw}) results.append({'type': content_type, 'raw': raw})
elif content_type == 'image': 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}) results.append({'type': content_type, 'raw': raw})
elif content_type == 'voice': 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}) results.append({'type': content_type, 'raw': raw})
elif content_type == 'file': elif content_type == 'file':
raw = await self.bot.send_file_msg( 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 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): class QQOfficialEventConverter(abstract_platform_adapter.AbstractEventConverter):
@staticmethod @staticmethod
async def yiri2target(event: platform_events.Event) -> typing.Any: async def yiri2target(event: platform_events.Event) -> typing.Any:
return getattr(event, 'source_platform_object', None) 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) eba_event = await self.target2yiri(event)
if not isinstance(eba_event, platform_events.MessageReceivedEvent): if not isinstance(eba_event, platform_events.MessageReceivedEvent):
return None return None
@@ -50,8 +66,18 @@ class QQOfficialEventConverter(abstract_platform_adapter.AbstractEventConverter)
) )
async def target2yiri(self, event: QQOfficialEvent) -> platform_events.Event: 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) 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"}') return self.platform_specific(event, f'qqofficial.{event.t or "unknown"}')
async def message_to_eba(self, event: QQOfficialEvent) -> platform_events.MessageReceivedEvent: 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, 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 @staticmethod
def _sender_id(event: QQOfficialEvent) -> str: def _sender_id(event: QQOfficialEvent) -> str:
member_openid = event.member_openid or event.get('member_openid', '') 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: def _timestamp_value(value: str) -> float:
if not value: if not value:
return time.time() return time.time()
@@ -88,6 +88,11 @@ spec:
supported_events: supported_events:
- message.received - message.received
- message.reaction
- group.member_joined
- group.member_left
- bot.invited_to_group
- bot.removed_from_group
- platform.specific - platform.specific
supported_apis: supported_apis:
@@ -12,6 +12,9 @@ metadata:
icon: telegram.svg icon: telegram.svg
spec: spec:
categories:
- popular
- global
config: config:
- name: token - name: token
label: label:
+38 -8
View File
@@ -192,6 +192,25 @@ class RuntimeBot:
return False return False
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 @classmethod
def _match_event_filters( def _match_event_filters(
cls, cls,
@@ -203,7 +222,7 @@ class RuntimeBot:
if not isinstance(filters, list): if not isinstance(filters, list):
return False return False
event_data = cls._safe_model_dump(event) event_data = cls._augment_event_data(cls._safe_model_dump(event))
return all( return all(
cls._match_event_filter(event_data, event_filter) cls._match_event_filter(event_data, event_filter)
for event_filter in filters for event_filter in filters
@@ -854,11 +873,8 @@ class RuntimeBot:
launcher_id = custom_launcher_id launcher_id = custom_launcher_id
if pipeline_uuid_override is None: if pipeline_uuid_override is None:
message_text = str(event.message_chain) pipeline_uuid = None
element_types = [comp.type for comp in event.message_chain] routed_by_rule = False
pipeline_uuid, routed_by_rule = self.resolve_pipeline_uuid(
launcher_kind, launcher_id, message_text, element_types
)
else: else:
pipeline_uuid = pipeline_uuid_override pipeline_uuid = pipeline_uuid_override
routed_by_rule = routed_by_event_binding routed_by_rule = routed_by_event_binding
@@ -906,17 +922,31 @@ class RuntimeBot:
) )
async def initialize(self): 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( async def on_friend_message(
event: platform_events.FriendMessage, event: platform_events.FriendMessage,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, 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( async def on_group_message(
event: platform_events.GroupMessage, event: platform_events.GroupMessage,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, 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.FriendMessage, on_friend_message)
self.adapter.register_listener(platform_events.GroupMessage, on_group_message) self.adapter.register_listener(platform_events.GroupMessage, on_group_message)
@@ -12,6 +12,7 @@ metadata:
zh_Hant: OneBot v11 適配器,用於接入 QQ 機器人協定端,請查看文件了解使用方式 zh_Hant: OneBot v11 適配器,用於接入 QQ 機器人協定端,請查看文件了解使用方式
icon: onebot.png icon: onebot.png
spec: spec:
legacy: true
categories: categories:
- protocol - protocol
help_links: help_links:
@@ -12,6 +12,7 @@ metadata:
zh_Hant: 釘釘適配器,請查看文件了解使用方式 zh_Hant: 釘釘適配器,請查看文件了解使用方式
icon: dingtalk.svg icon: dingtalk.svg
spec: spec:
legacy: true
categories: categories:
- china - china
help_links: help_links:
@@ -20,6 +20,7 @@ metadata:
es_ES: Adaptador de Discord, requiere un entorno de red con acceso al servidor de Discord es_ES: Adaptador de Discord, requiere un entorno de red con acceso al servidor de Discord
icon: discord.svg icon: discord.svg
spec: spec:
legacy: true
categories: categories:
- popular - popular
- global - global
@@ -12,6 +12,7 @@ metadata:
zh_Hant: KOOK 適配器(原開黑啦),支援頻道訊息和私聊訊息 zh_Hant: KOOK 適配器(原開黑啦),支援頻道訊息和私聊訊息
icon: kook.png icon: kook.png
spec: spec:
legacy: true
categories: categories:
- china - china
help_links: help_links:
@@ -14,6 +14,7 @@ metadata:
ja_JP: Lark アダプター、長期接続およびWebhookモードの両方をサポートしています。使用方法の詳細については、ドキュメントを参照してください。 ja_JP: Lark アダプター、長期接続およびWebhookモードの両方をサポートしています。使用方法の詳細については、ドキュメントを参照してください。
icon: lark.svg icon: lark.svg
spec: spec:
legacy: true
categories: categories:
- popular - popular
- china - china
@@ -12,6 +12,7 @@ metadata:
zh_Hant: 微信公眾號適配器,需要公網地址以接收訊息推送,請查看文件了解使用方式 zh_Hant: 微信公眾號適配器,需要公網地址以接收訊息推送,請查看文件了解使用方式
icon: officialaccount.png icon: officialaccount.png
spec: spec:
legacy: true
categories: categories:
- china - china
help_links: help_links:
@@ -12,6 +12,7 @@ metadata:
zh_Hant: QQ 官方 API,支援 Webhook 和 WebSocket 兩種連線模式 zh_Hant: QQ 官方 API,支援 Webhook 和 WebSocket 兩種連線模式
icon: qqofficial.svg icon: qqofficial.svg
spec: spec:
legacy: true
categories: categories:
- china - china
help_links: 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 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 icon: slack.png
spec: spec:
legacy: true
categories: categories:
- popular - popular
- global - global
@@ -20,6 +20,7 @@ metadata:
es_ES: Adaptador de Telegram, consulte la documentación para obtener instrucciones de uso es_ES: Adaptador de Telegram, consulte la documentación para obtener instrucciones de uso
icon: telegram.svg icon: telegram.svg
spec: spec:
legacy: true
categories: categories:
- popular - popular
- global - global
@@ -447,6 +447,8 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
sender=sender, message_chain=message_chain, time=datetime.now().timestamp() 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) # 设置流水线UUID (proxy bot always needs it for reply_message routing)
self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid = pipeline_uuid self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid = pipeline_uuid
if owner_bot is not None: if owner_bot is not None:
@@ -12,6 +12,7 @@ metadata:
zh_Hant: 企業微信內部機器人,請查看文件了解使用方式 zh_Hant: 企業微信內部機器人,請查看文件了解使用方式
icon: wecom.png icon: wecom.png
spec: spec:
legacy: true
categories: categories:
- popular - popular
- china - china
@@ -12,6 +12,7 @@ metadata:
zh_Hant: 企業微信智慧機器人,支援長連線和 Webhook 兩種接入方式,請查看文件了解使用方式 zh_Hant: 企業微信智慧機器人,支援長連線和 Webhook 兩種接入方式,請查看文件了解使用方式
icon: wecombot.png icon: wecombot.png
spec: spec:
legacy: true
categories: categories:
- china - china
help_links: help_links:
@@ -12,6 +12,7 @@ metadata:
zh_Hant: 企業微信對外客服機器人,需要公網地址以接收訊息推送,請查看文件了解使用方式 zh_Hant: 企業微信對外客服機器人,需要公網地址以接收訊息推送,請查看文件了解使用方式
icon: wecom.png icon: wecom.png
spec: spec:
legacy: true
categories: categories:
- china - china
help_links: 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) @self.action(PluginToRuntimeAction.INVOKE_LLM)
async def invoke_llm(data: dict[str, Any]) -> handler.ActionResponse: async def invoke_llm(data: dict[str, Any]) -> handler.ActionResponse:
"""Invoke llm """Invoke llm
@@ -411,6 +411,20 @@ class ProviderAPIRequester(metaclass=abc.ABCMeta):
""" """
pass 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( async def invoke_llm_stream(
self, self,
query: pipeline_query.Query, query: pipeline_query.Query,
@@ -521,6 +521,33 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
return args 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( async def invoke_llm(
self, self,
query: pipeline_query.Query, 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, project_root=project_root,
work_dir=e2e_tmpdir, # Run in tmpdir where data/config.yaml exists work_dir=e2e_tmpdir, # Run in tmpdir where data/config.yaml exists
port=e2e_port, port=e2e_port,
timeout=60, # Longer timeout for first startup timeout=180, # Longer timeout for first startup
collect_coverage=collect_coverage, 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, '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': { 'space': {
'url': 'https://space.langbot.app', 'url': 'https://space.langbot.app',
'models_gateway_api_url': 'https://api.langbot.cloud/v1', '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.""" """Create necessary directories for LangBot testing."""
directories = { directories = {
'data': tmpdir / 'data', 'data': tmpdir / 'data',
'labels': tmpdir / 'data' / 'labels',
'metadata': tmpdir / 'data' / 'metadata',
'logs': tmpdir / 'logs', 'logs': tmpdir / 'logs',
'data_logs': tmpdir / 'data' / 'logs',
'storage': tmpdir / 'storage', 'storage': tmpdir / 'storage',
'box': tmpdir / 'box',
'chroma': tmpdir / 'chroma', 'chroma': tmpdir / 'chroma',
} }
+40 -7
View File
@@ -9,6 +9,7 @@ import subprocess
import time import time
import signal import signal
import os import os
import sys
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
import logging import logging
@@ -26,13 +27,21 @@ class LangBotProcess:
port: int = 15300, port: int = 15300,
timeout: int = 30, timeout: int = 30,
collect_coverage: bool = True, collect_coverage: bool = True,
debug: bool = False,
cli_args: list[str] | None = None,
): ):
self.project_root = project_root self.project_root = project_root
self.work_dir = work_dir # Directory containing data/config.yaml self.work_dir = work_dir # Directory containing data/config.yaml
self.port = port self.port = port
self.timeout = timeout self.timeout = timeout
self.collect_coverage = collect_coverage self.collect_coverage = collect_coverage
self.debug = debug
self.cli_args = cli_args or []
self.process: Optional[subprocess.Popen] = None 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._stdout_data: bytes = b''
self._stderr_data: bytes = b'' self._stderr_data: bytes = b''
self._coverage_file: Optional[Path] = None self._coverage_file: Optional[Path] = None
@@ -63,13 +72,14 @@ class LangBotProcess:
# Disable telemetry # Disable telemetry
env['SPACE__DISABLE_TELEMETRY'] = 'true' env['SPACE__DISABLE_TELEMETRY'] = 'true'
env['SPACE__DISABLE_MODELS_SERVICE'] = 'true' env['SPACE__DISABLE_MODELS_SERVICE'] = 'true'
if self.debug:
env['DEBUG'] = 'true'
# Build command # Build command
if self.collect_coverage: if self.collect_coverage:
# Use coverage.py to collect coverage data # Use coverage.py to collect coverage data
# Set COVERAGE_PROCESS_START to enable coverage in subprocess # Set COVERAGE_PROCESS_START to enable coverage in subprocess
self._coverage_file = self.work_dir / '.coverage.e2e' self._coverage_file = self.work_dir / '.coverage.e2e'
env['COVERAGE_PROCESS_START'] = str(self.project_root / '.coveragerc')
env['COVERAGE_FILE'] = str(self._coverage_file) env['COVERAGE_FILE'] = str(self._coverage_file)
# Create .coveragerc for subprocess # Create .coveragerc for subprocess
@@ -88,27 +98,33 @@ precision = 2
coveragerc_path = self.work_dir / '.coveragerc' coveragerc_path = self.work_dir / '.coveragerc'
with open(coveragerc_path, 'w') as f: with open(coveragerc_path, 'w') as f:
f.write(coveragerc_content) f.write(coveragerc_content)
env['COVERAGE_PROCESS_START'] = str(coveragerc_path)
cmd = [ cmd = [
sys.executable,
'-m',
'coverage', 'coverage',
'run', 'run',
'--rcfile=' + str(coveragerc_path), '--rcfile=' + str(coveragerc_path),
'-m', '-m',
'langbot', 'langbot',
*self.cli_args,
] ]
else: 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'Starting LangBot in: {self.work_dir}')
logger.info(f'Command: {cmd}') logger.info(f'Command: {cmd}')
# Start process (run in work_dir so it finds data/config.yaml) # 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( self.process = subprocess.Popen(
cmd, cmd,
cwd=self.work_dir, cwd=self.work_dir,
env=env, env=env,
stdout=subprocess.PIPE, stdout=self._stdout_file,
stderr=subprocess.PIPE, stderr=self._stderr_file,
preexec_fn=os.setsid if os.name != 'nt' else None, preexec_fn=os.setsid if os.name != 'nt' else None,
) )
@@ -117,7 +133,14 @@ precision = 2
while time.time() - start_time < self.timeout: while time.time() - start_time < self.timeout:
# Check if process died # Check if process died
if self.process.poll() is not None: 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()}') logger.error(f'LangBot process died: {self._stderr_data.decode()}')
return False return False
@@ -170,8 +193,14 @@ precision = 2
self.process.wait() self.process.wait()
# Collect output for debugging # Collect output for debugging
if self.process.stdout or self.process.stderr: if self._stdout_file:
self._stdout_data, self._stderr_data = self.process.communicate() 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 self.process = None
@@ -183,6 +212,10 @@ precision = 2
"""Get stdout and stderr logs.""" """Get stdout and stderr logs."""
stdout = self._stdout_data.decode('utf-8', errors='replace') stdout = self._stdout_data.decode('utf-8', errors='replace')
stderr = self._stderr_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 return stdout, stderr
def get_coverage_file(self) -> Optional[Path]: 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]]] = { authorized_operations: dict[str, dict[str, set[str]]] = {
'model': { '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', []) for m in res.get('models', [])
if m.get('model_id') if m.get('model_id')
}, },
@@ -14,7 +14,7 @@ from langbot.pkg.agent.runner.resource_builder import AgentResourceBuilder
RUNNER_ID = 'plugin:test/runner/default' RUNNER_ID = 'plugin:test/runner/default'
FULL_PERMISSIONS = { FULL_PERMISSIONS = {
'models': ['invoke', 'stream', 'rerank'], 'models': ['count_tokens', 'invoke', 'stream', 'rerank'],
'tools': ['detail', 'call'], 'tools': ['detail', 'call'],
'knowledge_bases': ['list', 'retrieve'], 'knowledge_bases': ['list', 'retrieve'],
'history': ['page', 'search'], '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) resources = await build_resources(app, query, descriptor)
assert resources['models'] == [ 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': 'primary',
{'model_id': 'aux', 'model_type': 'llm', 'provider': 'aux-provider', 'operations': ['invoke', 'stream']}, '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']}, {'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) resources = await build_resources(app, query, descriptor)
assert resources['models'] == [ 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']}, {'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) resources = await build_resources(app, query, descriptor)
assert resources['models'] == [ 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'] == [ assert resources['knowledge_bases'] == [
{'kb_id': 'kb_alias', 'kb_name': 'name-kb_alias', 'kb_type': 'default', 'operations': ['list', 'retrieve']}, {'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')) 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() persistence_mgr = _PersistenceManager()
runtime_bot = SimpleNamespace(enable=False) runtime_bot = SimpleNamespace(enable=False)
platform_mgr = SimpleNamespace( platform_mgr = SimpleNamespace(
@@ -46,17 +46,17 @@ async def test_update_bot_copies_input_before_filtering_and_setting_pipeline_nam
'uuid': 'caller-owned-uuid', 'uuid': 'caller-owned-uuid',
'name': 'Test Bot', 'name': 'Test Bot',
'use_pipeline_uuid': 'pipeline-1', 'use_pipeline_uuid': 'pipeline-1',
'pipeline_routing_rules': [{'type': 'launcher_type'}],
} }
await service.update_bot('bot-1', payload) await service.update_bot('bot-1', payload)
# caller's dict must not be mutated
assert payload == { assert payload == {
'uuid': 'caller-owned-uuid', 'uuid': 'caller-owned-uuid',
'name': 'Test Bot', 'name': 'Test Bot',
'use_pipeline_uuid': 'pipeline-1', 'use_pipeline_uuid': 'pipeline-1',
'pipeline_routing_rules': [{'type': 'launcher_type'}],
} }
assert persistence_mgr.update_values == { # legacy routing fields are stripped; only name is persisted
'name': 'Test Bot', assert persistence_mgr.update_values == {'name': 'Test Bot'}
'use_pipeline_uuid': 'pipeline-1',
'use_pipeline_name': 'Updated Pipeline',
}
@@ -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 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): def _create_mock_discover(adapter_webhook_flags: dict[str, bool] = None):
"""Create mock ComponentDiscoveryEngine exposing MessagePlatformAdapter manifests. """Create mock ComponentDiscoveryEngine exposing MessagePlatformAdapter manifests.
@@ -386,59 +395,6 @@ class TestBotServiceCreateBot:
assert bot_uuid is not None assert bot_uuid is not None
assert len(bot_uuid) == 36 # UUID format 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: class TestBotServiceUpdateBot:
"""Tests for update_bot method.""" """Tests for update_bot method."""
@@ -472,63 +428,200 @@ class TestBotServiceUpdateBot:
assert update_params['name'] == 'Updated Name' assert update_params['name'] == 'Updated Name'
assert 'should-be-removed' not in update_params.values() 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.""" class TestBotServiceEventBindings:
# Setup """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 = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace() ap.persistence_mgr = SimpleNamespace()
ap.persistence_mgr.execute_async = AsyncMock(
# Mock pipeline query returns None side_effect=[
pipeline_result = Mock() _create_mock_result(first_item=SimpleNamespace(uuid='pipeline-1')),
pipeline_result.first = Mock(return_value=None) _create_mock_result(
ap.persistence_mgr.execute_async = AsyncMock(return_value=pipeline_result) first_item=SimpleNamespace(
uuid='agent-1',
supported_event_patterns=['platform.member.*'],
)
),
]
)
service = BotService(ap) service = BotService(ap)
# Execute & Verify normalized = await service._normalize_event_bindings(
with pytest.raises(Exception, match='Pipeline not found'): [
await service.update_bot('test-uuid', {'use_pipeline_uuid': 'nonexistent-pipeline'}) {
'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): uuid.UUID(normalized[0]['id'])
"""Sets use_pipeline_name when updating use_pipeline_uuid.""" assert normalized == [
# Setup {
'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 = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace() ap.persistence_mgr = SimpleNamespace()
ap.platform_mgr = SimpleNamespace() ap.persistence_mgr.execute_async = AsyncMock(
ap.platform_mgr.remove_bot = AsyncMock() side_effect=[
_create_mock_result(
# Mock pipeline query first_item=SimpleNamespace(
mock_pipeline = SimpleNamespace() uuid='agent-1',
mock_pipeline.name = 'Updated Pipeline' supported_event_patterns=['platform.member.*'],
pipeline_result = Mock() )
pipeline_result.first = Mock(return_value=mock_pipeline) ),
Mock(),
call_count = 0 ]
)
async def mock_execute(query): runtime_bot = SimpleNamespace(enable=True, run=AsyncMock())
nonlocal call_count loaded_bot = {'uuid': 'bot-1', 'name': 'Bot with bindings'}
call_count += 1 ap.platform_mgr = SimpleNamespace(
if call_count == 1: remove_bot=AsyncMock(),
return pipeline_result load_bot=AsyncMock(return_value=runtime_bot),
return Mock() )
bot_session = SimpleNamespace(using_conversation=SimpleNamespace(bot_uuid='bot-1'))
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute) other_session = SimpleNamespace(using_conversation=SimpleNamespace(bot_uuid='other-bot'))
ap.sess_mgr = SimpleNamespace() ap.sess_mgr = SimpleNamespace(session_list=[bot_session, other_session])
ap.sess_mgr.session_list = []
service = BotService(ap) service = BotService(ap)
service.get_bot = AsyncMock(return_value={'uuid': 'test-uuid'}) service.get_bot = AsyncMock(return_value=loaded_bot)
runtime_bot = SimpleNamespace() await service.update_bot(
runtime_bot.enable = False 'bot-1',
ap.platform_mgr.load_bot = AsyncMock(return_value=runtime_bot) {
'event_bindings': [
{
'id': 'binding-1',
'event_pattern': 'platform.member.joined',
'target_type': 'agent',
'target_uuid': 'agent-1',
'priority': '9',
}
]
},
)
# Execute update_values = _compiled_update_values(ap.persistence_mgr.execute_async.await_args_list[1].args[0])
await service.update_bot('test-uuid', {'use_pipeline_uuid': 'pipeline-uuid'}) assert update_values['event_bindings'] == [
{
update_params = ap.persistence_mgr.execute_async.await_args_list[1].args[0].compile().params 'id': 'binding-1',
assert update_params['use_pipeline_uuid'] == 'pipeline-uuid' 'event_pattern': 'platform.member.joined',
assert update_params['use_pipeline_name'] == 'Updated Pipeline' '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: class TestBotServiceDeleteBot:
@@ -36,6 +36,7 @@ class DummyLogger(AbstractEventLogger):
class DummyDingTalkClient(DingTalkClient): class DummyDingTalkClient(DingTalkClient):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self._message_handlers = {} self._message_handlers = {}
self.client = SimpleNamespace(register_callback_handler=AsyncMock())
self.markdown_card = kwargs.get('markdown_card', True) self.markdown_card = kwargs.get('markdown_card', True)
self.access_token = '' self.access_token = ''
self.send_message = AsyncMock() self.send_message = AsyncMock()
@@ -115,6 +116,30 @@ def dingtalk_event(conversation='GroupMessage', **overrides) -> DingTalkEvent:
return DingTalkEvent.from_payload(payload) 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(): def test_dingtalk_supported_events_match_manifest():
assert make_adapter().get_supported_events() == manifest()['spec']['supported_events'] 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') group_event = await DingTalkEventConverter.target2yiri(dingtalk_event(), 'LangBot')
assert isinstance(group_event, platform_events.MessageReceivedEvent) 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_type == platform_entities.ChatType.GROUP
assert group_event.chat_id == 'group-1' assert group_event.chat_id == 'group-1'
assert group_event.group.name == 'LangBot Team' 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 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 @pytest.mark.asyncio
async def test_dingtalk_adapter_dispatches_and_caches_message_event(): async def test_dingtalk_adapter_dispatches_and_caches_message_event():
adapter = make_adapter() adapter = make_adapter()
@@ -143,6 +143,7 @@ def qq_event(event_type: str = 'C2C_MESSAGE_CREATE', **overrides) -> QQOfficialE
'image_attachments': overrides.get('image_attachments'), 'image_attachments': overrides.get('image_attachments'),
'content_type': overrides.get('content_type', 'image/png'), 'content_type': overrides.get('content_type', 'image/png'),
} }
payload.update(overrides.get('extra', {}))
return QQOfficialEvent(payload) 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')) private_event = await QQOfficialEventConverter().target2yiri(qq_event('C2C_MESSAGE_CREATE'))
group_event = await QQOfficialEventConverter().target2yiri(qq_event('GROUP_AT_MESSAGE_CREATE')) group_event = await QQOfficialEventConverter().target2yiri(qq_event('GROUP_AT_MESSAGE_CREATE'))
channel_event = await QQOfficialEventConverter().target2yiri(qq_event('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 isinstance(private_event, platform_events.MessageReceivedEvent)
assert private_event.adapter_name == 'qqofficial-eba' 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 channel_event.chat_id == 'channel-1'
assert isinstance(platform_event, platform_events.PlatformSpecificEvent) 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 @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' 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 @pytest.mark.asyncio
async def test_qqofficial_send_reply_stream_platform_api_and_unsupported(): async def test_qqofficial_send_reply_stream_platform_api_and_unsupported():
adapter = make_adapter() 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 ('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) 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 ('group_text', 'group-openid', 'hello group', None) in adapter.bot.sent
assert await adapter.call_platform_api('get_mode', {}) == { 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): with pytest.raises(NotSupportedError):
await adapter.call_platform_api('missing', {}) 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 RuntimeBot.resolve_pipeline_uuid and _match_operator unit tests
""" """
from types import SimpleNamespace
from unittest.mock import Mock from unittest.mock import Mock
@@ -278,3 +279,108 @@ class TestResolvePipelineUuid:
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'normal message') uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'normal message')
assert uuid == 'default-uuid' assert uuid == 'default-uuid'
assert routed is False 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 from __future__ import annotations
import base64 import base64
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock from unittest.mock import AsyncMock, Mock
import pytest 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): 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) await adapter._process_image_components(chain)
assert 'base64' not in chain[0] assert 'base64' not in chain[0]
adapter.logger.error.assert_awaited_once() 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 response.data['usage'] == usage
assert model_requester.LLM_USAGE_QUERY_VARIABLE not in query.variables 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 @pytest.mark.asyncio
async def test_invoke_llm_stream_restores_query_and_options(self, app): async def test_invoke_llm_stream_restores_query_and_options(self, app):
"""INVOKE_LLM_STREAM applies the same host context as non-streaming calls.""" """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 This includes provider_specific_fields round-trip coverage for GitHub issue
to be preserved across tool call rounds for function calls to work correctly. #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 from langbot.pkg.provider.modelmgr.requesters.litellmchat import LiteLLMRequester
@@ -14,6 +20,84 @@ def _make_requester() -> LiteLLMRequester:
return LiteLLMRequester.__new__(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(): def test_convert_messages_preserves_tool_call_provider_specific_fields():
"""Tool calls should retain provider_specific_fields through _convert_messages.""" """Tool calls should retain provider_specific_fields through _convert_messages."""
req = _make_requester() req = _make_requester()
+2
View File
@@ -58,6 +58,7 @@
"axios": "^1.16.0", "axios": "^1.16.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"i18next": "^25.1.2", "i18next": "^25.1.2",
"i18next-browser-languagedetector": "^8.1.0", "i18next-browser-languagedetector": "^8.1.0",
@@ -66,6 +67,7 @@
"lucide-react": "^0.507.0", "lucide-react": "^0.507.0",
"postcss": "^8.5.10", "postcss": "^8.5.10",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"radix-ui": "^1.6.0",
"react": "19.2.1", "react": "19.2.1",
"react-dom": "19.2.1", "react-dom": "19.2.1",
"react-hook-form": "^7.56.3", "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 React, { useEffect, useMemo, useRef, useState } from 'react';
import i18n from 'i18next'; import i18n from 'i18next';
import { import { IChooseAdapterEntity } from '@/app/home/bots/components/bot-form/ChooseEntity';
IChooseAdapterEntity,
IPipelineEntity,
} from '@/app/home/bots/components/bot-form/ChooseEntity';
import { import {
DynamicFormItemConfig, DynamicFormItemConfig,
getDefaultValues, getDefaultValues,
@@ -16,8 +13,7 @@ import { httpClient } from '@/app/infra/http/HttpClient';
import { systemInfo } from '@/app/infra/http'; import { systemInfo } from '@/app/infra/http';
import { Agent, Bot } from '@/app/infra/entities/api'; import { Agent, Bot } from '@/app/infra/entities/api';
import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs'; import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs';
import { ExternalLink } from 'lucide-react'; import { ExternalLink, ChevronDown, ChevronRight } from 'lucide-react';
import RoutingRulesEditor from './RoutingRulesEditor';
import EventBindingsEditor from './EventBindingsEditor'; import EventBindingsEditor from './EventBindingsEditor';
import { zodResolver } from '@hookform/resolvers/zod'; 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: z.string().min(1, { message: t('bots.adapterRequired') }),
adapter_config: z.record(z.string(), z.any()), adapter_config: z.record(z.string(), z.any()),
enable: z.boolean().optional(), 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 event_bindings: z
.array( .array(
z.object({ z.object({
@@ -128,8 +101,6 @@ export default function BotForm({
adapter: '', adapter: '',
adapter_config: {}, adapter_config: {},
enable: true, enable: true,
use_pipeline_uuid: '',
pipeline_routing_rules: [],
event_bindings: [], event_bindings: [],
}, },
}); });
@@ -154,9 +125,6 @@ export default function BotForm({
Record<string, string[]> Record<string, string[]>
>({}); >({});
const [pipelineNameList, setPipelineNameList] = useState<IPipelineEntity[]>(
[],
);
const [agentNameList, setAgentNameList] = useState<Agent[]>([]); const [agentNameList, setAgentNameList] = useState<Agent[]>([]);
const [dynamicFormConfigList, setDynamicFormConfigList] = useState< const [dynamicFormConfigList, setDynamicFormConfigList] = useState<
@@ -170,11 +138,35 @@ export default function BotForm({
const currentAdapter = form.watch('adapter'); const currentAdapter = form.watch('adapter');
const currentAdapterConfig = form.watch('adapter_config'); const currentAdapterConfig = form.watch('adapter_config');
// Group adapters by category for the Select dropdown // Group adapters by category for the Select dropdown. Legacy adapters
const groupedAdapters = useMemo( // (those superseded by an EBA implementation) are split out and shown in a
() => groupByCategory(adapterNameList), // collapsed group at the bottom so they're de-emphasized but still usable.
const activeAdapters = useMemo(
() => adapterNameList.filter((a) => !a.legacy),
[adapterNameList], [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 // Notify parent when dirty state changes
const { isDirty } = form.formState; const { isDirty } = form.formState;
@@ -200,8 +192,6 @@ export default function BotForm({
adapter: val.adapter, adapter: val.adapter,
adapter_config: val.adapter_config, adapter_config: val.adapter_config,
enable: val.enable, enable: val.enable,
use_pipeline_uuid: val.use_pipeline_uuid || '',
pipeline_routing_rules: val.pipeline_routing_rules || [],
event_bindings: val.event_bindings || [], event_bindings: val.event_bindings || [],
}); });
handleAdapterSelect(val.adapter); handleAdapterSelect(val.adapter);
@@ -231,17 +221,6 @@ export default function BotForm({
} }
async function initBotFormComponent() { 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(); const agentsRes = await httpClient.getAgents();
setAgentNameList(agentsRes.agents); setAgentNameList(agentsRes.agents);
@@ -252,6 +231,7 @@ export default function BotForm({
label: extractI18nObject(item.label), label: extractI18nObject(item.label),
value: item.name, value: item.name,
categories: item.spec.categories, categories: item.spec.categories,
legacy: item.spec.legacy,
}; };
}), }),
); );
@@ -331,8 +311,6 @@ export default function BotForm({
name: bot.name, name: bot.name,
adapter_config: bot.adapter_config, adapter_config: bot.adapter_config,
enable: bot.enable ?? true, enable: bot.enable ?? true,
use_pipeline_uuid: bot.use_pipeline_uuid ?? '',
pipeline_routing_rules: bot.pipeline_routing_rules ?? [],
event_bindings: bot.event_bindings ?? [], event_bindings: bot.event_bindings ?? [],
webhook_full_url: runtimeValues?.webhook_full_url as webhook_full_url: runtimeValues?.webhook_full_url as
| string | string
@@ -377,8 +355,6 @@ export default function BotForm({
adapter: form.getValues().adapter, adapter: form.getValues().adapter,
adapter_config: form.getValues().adapter_config, adapter_config: form.getValues().adapter_config,
enable: form.getValues().enable, 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 ?? [], event_bindings: form.getValues().event_bindings ?? [],
}; };
httpClient httpClient
@@ -468,79 +444,7 @@ export default function BotForm({
</CardContent> </CardContent>
</Card> </Card>
{/* Card 2: Pipeline Binding (edit mode only) */} {/* Card 2: Event Orchestration (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) */}
{initBotId && ( {initBotId && (
<Card> <Card>
<CardHeader> <CardHeader>
@@ -635,6 +539,62 @@ export default function BotForm({
))} ))}
</SelectGroup> </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> </SelectContent>
</Select> </Select>
{currentAdapter && {currentAdapter &&
@@ -2,6 +2,7 @@ export interface IChooseAdapterEntity {
label: string; label: string;
value: string; value: string;
categories?: string[]; categories?: string[];
legacy?: boolean;
} }
export interface IPipelineEntity { 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, Quote,
Voice, Voice,
} from '@/app/infra/entities/message'; } 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 { interface SessionInfo {
session_id: string; session_id: string;
@@ -32,6 +32,9 @@ import {
Server, Server,
Puzzle, Puzzle,
RefreshCcw, RefreshCcw,
Bot,
Workflow,
ListTree,
} from 'lucide-react'; } from 'lucide-react';
import { useTheme } from '@/components/providers/theme-provider'; import { useTheme } from '@/components/providers/theme-provider';
@@ -507,6 +510,7 @@ function NavItems({
const isSkill = categoryId === 'skills'; const isSkill = categoryId === 'skills';
const isBot = categoryId === 'bots'; const isBot = categoryId === 'bots';
const isMCP = categoryId === 'mcp'; const isMCP = categoryId === 'mcp';
const isAgents = categoryId === 'pipelines';
const resolveItemRoute = (item: SidebarEntityItem): string => { const resolveItemRoute = (item: SidebarEntityItem): string => {
if (item.extensionType === 'mcp') { if (item.extensionType === 'mcp') {
@@ -589,6 +593,18 @@ function NavItems({
!inPopover && !inPopover &&
sidebarData.extensionsGroupByType; 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'> = [ const groupOrder: Array<'plugin' | 'mcp' | 'skill'> = [
'plugin', 'plugin',
'mcp', 'mcp',
@@ -723,6 +739,22 @@ function NavItems({
/> />
) : null} ) : null}
<span className="truncate">{item.name}</span> <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 && ( {item.debug && (
<Bug className="size-3.5 shrink-0 text-orange-400" /> <Bug className="size-3.5 shrink-0 text-orange-400" />
)} )}
@@ -774,6 +806,24 @@ function NavItems({
</div> </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))} : visibleItems.map((item) => renderItem(item))}
{/* Show more / less toggle when items exceed limit */} {/* Show more / less toggle when items exceed limit */}
{sortedItems.length > maxItems && !inPopover && ( {sortedItems.length > maxItems && !inPopover && (
@@ -1011,7 +1061,51 @@ function NavItems({
<span className="cursor-pointer select-none"> <span className="cursor-pointer select-none">
{config.name} {config.name}
</span> </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 && ( {isExtensionsCategory && (
<button <button
type="button" type="button"
@@ -1027,6 +1121,9 @@ function NavItems({
/> />
</button> </button>
)} )}
</div>
)}
<div className="ml-auto flex items-center gap-0.5 -mr-1">
{canCreate && {canCreate &&
(isPlugin ? ( (isPlugin ? (
<DropdownMenu> <DropdownMenu>
@@ -28,6 +28,8 @@ export interface SidebarEntityItem {
debug?: boolean; debug?: boolean;
// Set when this item appears in the unified extensions list // Set when this item appears in the unified extensions list
extensionType?: 'plugin' | 'mcp' | 'skill'; extensionType?: 'plugin' | 'mcp' | 'skill';
// Agent-specific: distinguishes Agent orchestration from legacy Pipeline
kind?: 'agent' | 'pipeline';
} }
// Plugin page registered by a plugin // 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) // Whether the extensions list is grouped by type (shared between page and sidebar)
extensionsGroupByType: boolean; extensionsGroupByType: boolean;
setExtensionsGroupByType: (enabled: boolean) => void; 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); 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 () => { const refreshBots = useCallback(async () => {
try { try {
const resp = await httpClient.getBots(); const resp = await httpClient.getBots();
@@ -123,6 +143,7 @@ export function SidebarDataProvider({
description: p.description, description: p.description,
emoji: p.emoji, emoji: p.emoji,
updatedAt: p.updated_at, updatedAt: p.updated_at,
kind: p.kind,
})), })),
); );
} catch (error) { } catch (error) {
@@ -308,6 +329,8 @@ export function SidebarDataProvider({
setDetailEntityName, setDetailEntityName,
extensionsGroupByType, extensionsGroupByType,
setExtensionsGroupByType, setExtensionsGroupByType,
agentsGroupByKind,
setAgentsGroupByKind,
}} }}
> >
{children} {children}
@@ -30,9 +30,13 @@ export function getOrderedCategories(): AdapterCategoryId[] {
/** /**
* Groups items that have a `categories` string array into ordered category * Groups items that have a `categories` string array into ordered category
* buckets. An item can appear in multiple groups if it belongs to multiple * buckets. Each item is placed into exactly one bucket its highest-priority
* categories. Items without any recognised category are collected into a * matching category in display order (e.g. an adapter tagged both `popular`
* trailing "uncategorized" group (null key). * 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[] }>( export function groupByCategory<T extends { categories?: string[] }>(
items: T[], items: T[],
@@ -54,10 +58,13 @@ export function groupByCategory<T extends { categories?: string[] }>(
} }
let placed = false; let placed = false;
for (const cat of cats) { // Assign to the highest-priority matching category (display order) only,
if (ordered.includes(cat as AdapterCategoryId)) { // so each item appears in exactly one bucket.
buckets.get(cat as AdapterCategoryId)!.push(item); for (const cat of ordered) {
if (cats.includes(cat)) {
buckets.get(cat)!.push(item);
placed = true; placed = true;
break;
} }
} }
if (!placed) { if (!placed) {
+1
View File
@@ -205,6 +205,7 @@ export interface Adapter {
icon?: string; icon?: string;
spec: { spec: {
categories?: string[]; categories?: string[];
legacy?: boolean;
help_links?: Record<string, string>; help_links?: Record<string, string>;
supported_events?: string[]; supported_events?: string[];
supported_apis?: string[]; supported_apis?: string[];
+74 -2
View File
@@ -7,6 +7,8 @@ import {
ArrowLeft, ArrowLeft,
ArrowRight, ArrowRight,
Check, Check,
ChevronDown,
ChevronRight,
Sparkles, Sparkles,
PartyPopper, PartyPopper,
Loader2, Loader2,
@@ -765,14 +767,24 @@ function StepPlatform({
onSelect: (name: string) => void; onSelect: (name: string) => void;
}) { }) {
const { t } = useTranslation(); 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 groupedAdapters = useMemo(() => {
const withCategories = adapters.map((a) => ({ const withCategories = activeAdapters.map((a) => ({
...a, ...a,
categories: a.spec.categories, categories: a.spec.categories,
})); }));
return groupByCategory(withCategories); return groupByCategory(withCategories);
}, [adapters]); }, [activeAdapters]);
return ( return (
<div className="space-y-6 max-w-4xl mx-auto"> <div className="space-y-6 max-w-4xl mx-auto">
@@ -848,6 +860,66 @@ function StepPlatform({
</div> </div>
</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> </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?', deleteConfirmation: 'Are you sure you want to delete this bot?',
platformAdapter: 'Platform/Adapter Selection', platformAdapter: 'Platform/Adapter Selection',
selectAdapter: 'Select Adapter', 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', adapterConfig: 'Adapter Configuration',
viewAdapterDocs: 'View Docs', viewAdapterDocs: 'View Docs',
bindPipeline: 'Bind Pipeline', bindPipeline: 'Bind Pipeline',
@@ -372,14 +375,51 @@ const enUS = {
targetPipeline: 'Pipeline', targetPipeline: 'Pipeline',
targetDiscard: 'Discard', targetDiscard: 'Discard',
selectTarget: 'Select handling logic', selectTarget: 'Select handling logic',
searchTarget: 'Search…',
noTargetFound: 'No results found',
priority: 'Priority', priority: 'Priority',
enabled: 'Enabled', enabled: 'Enabled',
eventBindingDescriptionPlaceholder: 'Rule description', eventBindingDescriptionPlaceholder: 'Rule description',
noEventBindings: 'No event bindings', noEventBindings: 'No event bindings',
unsupportedPipelineEvent: 'Pipelines can only be used for message.* events', unsupportedPipelineEvent: 'Pipelines can only be used for message.* events',
disable: 'Disable',
enable: 'Enable',
disabledBindings: 'Disabled',
eventCustom: 'Custom event', eventCustom: 'Custom event',
eventWildcard: 'All events', eventWildcard: 'All events',
eventNamespaceWildcard: '{{namespace}}.*', 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', routingRules: 'Conditional Routing Rules',
routingRulesDescription: routingRulesDescription:
'Rules are evaluated in order; first match routes to its pipeline. Fallback to the default pipeline above if none match.', '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: agentOrchestrationDescription:
'Event-first handling logic for messages, group members, friends, feedback, and other EBA events.', 'Event-first handling logic for messages, group members, friends, feedback, and other EBA events.',
pipelineType: 'Pipeline', pipelineType: 'Pipeline',
kindBadgeAgent: 'Agent',
kindBadgePipeline: 'Pipeline',
groupByKind: 'Group by type',
groupByKindShort: 'Group',
pipelineTypeDescription: pipelineTypeDescription:
'Keep the existing no-code message pipeline for backward compatibility. It only handles message events.', 'Keep the existing no-code message pipeline for backward compatibility. It only handles message events.',
allEvents: 'Supports all EBA events', allEvents: 'Supports all EBA events',
@@ -539,6 +583,7 @@ const enUS = {
noExtensionInstalled: 'No extensions installed', noExtensionInstalled: 'No extensions installed',
loadingExtensions: 'Loading extensions...', loadingExtensions: 'Loading extensions...',
groupByType: 'Group by format', groupByType: 'Group by format',
groupByTypeShort: 'Group',
pluginConfig: 'Plugin Configuration', pluginConfig: 'Plugin Configuration',
pluginSort: 'Plugin Sort', pluginSort: 'Plugin Sort',
pluginSortDescription: pluginSortDescription:
+51
View File
@@ -457,6 +457,56 @@ const esES = {
botMessage: 'Asistente', 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: { plugins: {
title: 'Extensiones', title: 'Extensiones',
description: description:
@@ -486,6 +536,7 @@ const esES = {
noExtensionInstalled: 'No hay extensiones instaladas', noExtensionInstalled: 'No hay extensiones instaladas',
loadingExtensions: 'Cargando extensiones...', loadingExtensions: 'Cargando extensiones...',
groupByType: 'Agrupar por formato', groupByType: 'Agrupar por formato',
groupByTypeShort: 'Agrupar',
pluginConfig: 'Configuración del plugin', pluginConfig: 'Configuración del plugin',
pluginSort: 'Orden de plugins', pluginSort: 'Orden de plugins',
pluginSortDescription: pluginSortDescription:
+22
View File
@@ -387,6 +387,23 @@ const jaJP = {
eventCustom: 'カスタムイベント', eventCustom: 'カスタムイベント',
eventWildcard: 'すべてのイベント', eventWildcard: 'すべてのイベント',
eventNamespaceWildcard: '{{namespace}}.*', 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: '条件付きルーティングルール', routingRules: '条件付きルーティングルール',
routingRulesDescription: routingRulesDescription:
'ルールは順番に評価され、最初に一致したルールのパイプラインにルーティングされます。一致しない場合はデフォルトパイプラインが使用されます。', 'ルールは順番に評価され、最初に一致したルールのパイプラインにルーティングされます。一致しない場合はデフォルトパイプラインが使用されます。',
@@ -484,6 +501,10 @@ const jaJP = {
agentOrchestrationDescription: agentOrchestrationDescription:
'メッセージ、グループメンバー、友だち、フィードバックなどの EBA イベント向けの処理ロジックです。', 'メッセージ、グループメンバー、友だち、フィードバックなどの EBA イベント向けの処理ロジックです。',
pipelineType: 'Pipeline', pipelineType: 'Pipeline',
kindBadgeAgent: 'Agent',
kindBadgePipeline: 'パイプライン',
groupByKind: 'タイプ別にグループ化',
groupByKindShort: 'グループ',
pipelineTypeDescription: pipelineTypeDescription:
'既存のノーコードメッセージ Pipeline を互換性のため保持します。メッセージイベントのみ処理できます。', '既存のノーコードメッセージ Pipeline を互換性のため保持します。メッセージイベントのみ処理できます。',
allEvents: 'すべての EBA イベントに対応', allEvents: 'すべての EBA イベントに対応',
@@ -544,6 +565,7 @@ const jaJP = {
noExtensionInstalled: '拡張機能がインストールされていません', noExtensionInstalled: '拡張機能がインストールされていません',
loadingExtensions: '拡張機能を読み込み中...', loadingExtensions: '拡張機能を読み込み中...',
groupByType: '形式でグループ化', groupByType: '形式でグループ化',
groupByTypeShort: 'グループ',
pluginConfig: 'プラグイン設定', pluginConfig: 'プラグイン設定',
pluginSort: 'プラグインの並び替え', pluginSort: 'プラグインの並び替え',
pluginSortDescription: pluginSortDescription:
+48
View File
@@ -455,6 +455,53 @@ const ruRU = {
botMessage: 'Ассистент', 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: { plugins: {
title: 'Расширения', title: 'Расширения',
description: description:
@@ -485,6 +532,7 @@ const ruRU = {
noExtensionInstalled: 'Расширения не установлены', noExtensionInstalled: 'Расширения не установлены',
loadingExtensions: 'Загрузка расширений...', loadingExtensions: 'Загрузка расширений...',
groupByType: 'Группировать по формату', groupByType: 'Группировать по формату',
groupByTypeShort: 'Группа',
pluginConfig: 'Настройка плагина', pluginConfig: 'Настройка плагина',
pluginSort: 'Порядок плагинов', pluginSort: 'Порядок плагинов',
pluginSortDescription: pluginSortDescription:
+48
View File
@@ -441,6 +441,53 @@ const thTH = {
botMessage: 'ผู้ช่วย', 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: { plugins: {
title: 'ส่วนขยาย', title: 'ส่วนขยาย',
description: description:
@@ -470,6 +517,7 @@ const thTH = {
noExtensionInstalled: 'ยังไม่มีส่วนขยายที่ติดตั้ง', noExtensionInstalled: 'ยังไม่มีส่วนขยายที่ติดตั้ง',
loadingExtensions: 'กำลังโหลดส่วนขยาย...', loadingExtensions: 'กำลังโหลดส่วนขยาย...',
groupByType: 'จัดกลุ่มตามรูปแบบ', groupByType: 'จัดกลุ่มตามรูปแบบ',
groupByTypeShort: 'จัดกลุ่ม',
pluginConfig: 'การกำหนดค่าปลั๊กอิน', pluginConfig: 'การกำหนดค่าปลั๊กอิน',
pluginSort: 'เรียงลำดับปลั๊กอิน', pluginSort: 'เรียงลำดับปลั๊กอิน',
pluginSortDescription: pluginSortDescription:
+48
View File
@@ -451,6 +451,53 @@ const viVN = {
botMessage: 'Trợ lý', 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: { plugins: {
title: 'Tiện ích mở rộng', title: 'Tiện ích mở rộng',
description: description:
@@ -480,6 +527,7 @@ const viVN = {
noExtensionInstalled: 'Chưa cài đặt tiện ích mở rộng nào', noExtensionInstalled: 'Chưa cài đặt tiện ích mở rộng nào',
loadingExtensions: 'Đang tải tiện ích mở rộng...', loadingExtensions: 'Đang tải tiện ích mở rộng...',
groupByType: 'Nhóm theo định dạng', groupByType: 'Nhóm theo định dạng',
groupByTypeShort: 'Nhóm',
pluginConfig: 'Cấu hình Plugin', pluginConfig: 'Cấu hình Plugin',
pluginSort: 'Sắp xếp Plugin', pluginSort: 'Sắp xếp Plugin',
pluginSortDescription: pluginSortDescription:
+44
View File
@@ -324,6 +324,9 @@ const zhHans = {
deleteConfirmation: '你确定要删除这个机器人吗?', deleteConfirmation: '你确定要删除这个机器人吗?',
platformAdapter: '平台/适配器选择', platformAdapter: '平台/适配器选择',
selectAdapter: '选择适配器', selectAdapter: '选择适配器',
legacyAdapters: '旧版适配器',
legacyAdaptersHint:
'这些适配器已被新版(EBA 架构)取代,仅为兼容存量配置保留,不建议新建机器人时使用。',
adapterConfig: '适配器配置', adapterConfig: '适配器配置',
viewAdapterDocs: '查看文档', viewAdapterDocs: '查看文档',
bindPipeline: '绑定流水线', bindPipeline: '绑定流水线',
@@ -356,14 +359,50 @@ const zhHans = {
targetPipeline: 'Pipeline', targetPipeline: 'Pipeline',
targetDiscard: '丢弃', targetDiscard: '丢弃',
selectTarget: '选择处理逻辑', selectTarget: '选择处理逻辑',
searchTarget: '搜索处理逻辑…',
noTargetFound: '未找到匹配项',
priority: '优先级', priority: '优先级',
enabled: '启用', enabled: '启用',
eventBindingDescriptionPlaceholder: '规则说明', eventBindingDescriptionPlaceholder: '规则说明',
noEventBindings: '暂无事件绑定', noEventBindings: '暂无事件绑定',
unsupportedPipelineEvent: 'Pipeline 仅可用于 message.* 事件', unsupportedPipelineEvent: 'Pipeline 仅可用于 message.* 事件',
disable: '禁用',
enable: '启用',
disabledBindings: '已禁用',
eventCustom: '自定义事件', eventCustom: '自定义事件',
eventWildcard: '全部事件', eventWildcard: '全部事件',
eventNamespaceWildcard: '{{namespace}}.*', 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: '条件路由规则', routingRules: '条件路由规则',
routingRulesDescription: routingRulesDescription:
'按顺序匹配,命中第一条规则后路由到对应流水线;都不匹配时使用上方默认流水线', '按顺序匹配,命中第一条规则后路由到对应流水线;都不匹配时使用上方默认流水线',
@@ -458,6 +497,10 @@ const zhHans = {
agentOrchestrationDescription: agentOrchestrationDescription:
'面向 EBA 事件的处理逻辑,可用于消息、群成员、好友、反馈等事件。', '面向 EBA 事件的处理逻辑,可用于消息、群成员、好友、反馈等事件。',
pipelineType: 'Pipeline', pipelineType: 'Pipeline',
kindBadgeAgent: 'Agent',
kindBadgePipeline: '流水线',
groupByKind: '按类型分组',
groupByKindShort: '分组',
pipelineTypeDescription: pipelineTypeDescription:
'保留现有无代码消息流水线,兼容旧配置,只能处理消息事件。', '保留现有无代码消息流水线,兼容旧配置,只能处理消息事件。',
allEvents: '支持全部 EBA 事件', allEvents: '支持全部 EBA 事件',
@@ -517,6 +560,7 @@ const zhHans = {
noExtensionInstalled: '暂未安装任何扩展', noExtensionInstalled: '暂未安装任何扩展',
loadingExtensions: '正在加载扩展...', loadingExtensions: '正在加载扩展...',
groupByType: '按格式分组', groupByType: '按格式分组',
groupByTypeShort: '分组',
pluginSort: '插件排序', pluginSort: '插件排序',
pluginSortDescription: pluginSortDescription:
'插件顺序会影响同一事件内的处理顺序,请拖动插件卡片排序', '插件顺序会影响同一事件内的处理顺序,请拖动插件卡片排序',
+45
View File
@@ -427,6 +427,50 @@ const zhHant = {
botMessage: '助手', 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: { plugins: {
title: '外掛擴展', title: '外掛擴展',
description: '安裝和設定用於擴展功能的外掛,請在流程線配置中選用', description: '安裝和設定用於擴展功能的外掛,請在流程線配置中選用',
@@ -457,6 +501,7 @@ const zhHant = {
noExtensionInstalled: '暫未安裝任何擴充功能', noExtensionInstalled: '暫未安裝任何擴充功能',
loadingExtensions: '正在載入擴充功能...', loadingExtensions: '正在載入擴充功能...',
groupByType: '依格式分組', groupByType: '依格式分組',
groupByTypeShort: '分組',
pluginSort: '外掛排序', pluginSort: '外掛排序',
pluginSortDescription: pluginSortDescription:
'外掛順序會影響同一事件內的處理順序,請拖曳外掛卡片排序', '外掛順序會影響同一事件內的處理順序,請拖曳外掛卡片排序',