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