mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-24 06:24:20 +00:00
feat(agent): add event orchestration surface
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
# Agent 页面与事件编排产品设计
|
||||
|
||||
> 状态:实施稿(2026-06-23)
|
||||
>
|
||||
> 本文档修订 [07-agent-orchestration.md](./07-agent-orchestration.md) 中“Agent 替代 Pipeline”的表述。当前产品形态应保留两种同级处理单元:**Agent 编排**与**Pipeline**。Agent 页面是统一入口,但不是把 Pipeline 消除。
|
||||
|
||||
## 1. 产品边界
|
||||
|
||||
LangBot 的处理逻辑分成两种同级形态:
|
||||
|
||||
| 形态 | 定位 | 可处理事件 | 典型用户 |
|
||||
| --- | --- | --- | --- |
|
||||
| Agent 编排 | 面向 EBA 的事件优先处理单元,承载 AgentRunner / 外部 runner / 后续工作流 | `message.*`、`group.*`、`friend.*`、`bot.*`、`feedback.*`、`platform.*` 等 | 希望按事件类型配置不同智能处理逻辑的用户 |
|
||||
| Pipeline | 现有无代码消息流水线与向后兼容形态 | 仅 `message.*`,首版等价于 `message.received` | 已有 Pipeline 用户、只需要消息处理的用户 |
|
||||
|
||||
Agent 页面负责统一管理这两种处理单元:
|
||||
|
||||
- 创建时选择 **Agent 编排** 或 **Pipeline**;
|
||||
- 列表中清晰标注类型与事件能力;
|
||||
- Pipeline 的编辑、调试、监控继续复用现有能力;
|
||||
- Agent 编排保存 runner 配置与事件能力,并通过 Bot 事件绑定进入运行时执行。
|
||||
|
||||
## 2. 信息架构
|
||||
|
||||
### 2.1 Agent 页面
|
||||
|
||||
路径:`/home/agents`
|
||||
|
||||
职责:
|
||||
|
||||
1. 展示所有可被事件绑定的处理单元,包括 Agent 编排与 Pipeline。
|
||||
2. 创建时先选择类型:
|
||||
- Agent 编排:创建一条 Agent 配置对象,默认支持所有 EBA 事件;
|
||||
- Pipeline:创建现有 legacy pipeline,只能处理消息事件。
|
||||
3. 编辑时按类型进入不同表单:
|
||||
- Pipeline:沿用原 Pipeline 配置页,包括 AI、触发、安全、输出、扩展、Debug、Monitoring;
|
||||
- Agent 编排:配置基础信息、runner、runner config 和事件能力。
|
||||
|
||||
`/home/pipelines` 保留为兼容路由,但新导航入口使用 `/home/agents`。
|
||||
|
||||
### 2.2 Bot 的事件编排
|
||||
|
||||
Bot 上维护“事件 -> 处理单元”的绑定规则:
|
||||
|
||||
```text
|
||||
Bot
|
||||
└─ EventBinding[]
|
||||
├─ event_pattern: message.received / group.member_joined / group.* / *
|
||||
├─ target_type: agent / pipeline / discard
|
||||
├─ target_uuid: Agent UUID 或 Pipeline UUID
|
||||
├─ filters: 事件字段过滤条件
|
||||
├─ priority: 数字越大越优先
|
||||
└─ enabled
|
||||
```
|
||||
|
||||
Pipeline 只能被绑定到 `message.*`。如果用户选择非消息事件,目标选择器不展示 Pipeline。
|
||||
|
||||
## 3. 持久化模型
|
||||
|
||||
### 3.1 Agent 编排实例
|
||||
|
||||
新增 `agents` 表,只保存 Agent 编排形态。Pipeline 继续保存在 `legacy_pipelines`。
|
||||
|
||||
```python
|
||||
class Agent(Base):
|
||||
uuid: str
|
||||
name: str
|
||||
description: str
|
||||
emoji: str
|
||||
kind: str # 首版固定为 "agent"
|
||||
component_ref: str # runner id / workflow id / future external ref
|
||||
config: dict # runner 与 runner_config
|
||||
enabled: bool
|
||||
supported_event_patterns: list[str]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
```
|
||||
|
||||
Agent 聚合 API 把 `agents` 与 `legacy_pipelines` 投影成同一个前端列表:
|
||||
|
||||
```json
|
||||
{
|
||||
"uuid": "...",
|
||||
"name": "...",
|
||||
"kind": "agent | pipeline",
|
||||
"capability": {
|
||||
"supported_event_patterns": ["*"],
|
||||
"message_only": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Pipeline 投影时固定:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "pipeline",
|
||||
"capability": {
|
||||
"supported_event_patterns": ["message.*"],
|
||||
"message_only": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Bot 事件绑定
|
||||
|
||||
Bot 新增 `event_bindings` JSON 字段,首版作为轻量配置面。后续当 EventRouter 查询、审计和多作用域规则稳定后,再拆成独立表。
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "uuid",
|
||||
"event_pattern": "group.member_joined",
|
||||
"target_type": "agent",
|
||||
"target_uuid": "...",
|
||||
"filters": [],
|
||||
"priority": 100,
|
||||
"enabled": true,
|
||||
"description": "Welcome new group members"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## 4. 匹配规则
|
||||
|
||||
事件模式支持三层:
|
||||
|
||||
1. 精确匹配:`group.member_joined`
|
||||
2. 命名空间通配:`group.*`
|
||||
3. 全局通配:`*`
|
||||
|
||||
优先级:
|
||||
|
||||
1. `enabled = true`
|
||||
2. event pattern 命中
|
||||
3. filters 全部命中
|
||||
4. `priority` 数值高者优先
|
||||
5. 同优先级按列表顺序
|
||||
|
||||
## 5. 兼容策略
|
||||
|
||||
1. 现有 `legacy_pipelines` 不迁移、不改语义。
|
||||
2. 现有 Bot 的 `use_pipeline_uuid` 仍作为消息事件默认 Pipeline。
|
||||
3. 现有 `pipeline_routing_rules` 仍只作用于消息事件。
|
||||
4. 新增 `event_bindings` 是 EBA 事件编排配置,允许 Pipeline 目标但只限 `message.*`。
|
||||
5. `/api/v1/pipelines` 继续存在;新增 `/api/v1/agents` 作为聚合入口。
|
||||
|
||||
## 6. 分阶段落地
|
||||
|
||||
### P0:产品入口统一
|
||||
|
||||
- 新增 `/home/agents`。
|
||||
- 侧边栏显示“Agent”,但列表包含 Agent 编排与 Pipeline。
|
||||
- 创建时选择 Agent 编排或 Pipeline。
|
||||
- `/home/pipelines` 保留兼容。
|
||||
|
||||
### P1:配置模型落地
|
||||
|
||||
- 新增 `agents` 表与 `/api/v1/agents`。
|
||||
- Agent 编排可保存 runner 与 runner_config。
|
||||
- Pipeline 继续使用原 Pipeline 表单与 API。
|
||||
|
||||
### P2:事件编排配置面
|
||||
|
||||
- Bot 表单新增事件编排编辑器。
|
||||
- 读取 adapter manifest 的 `supported_events` 生成事件选项。
|
||||
- 根据事件类型过滤可选目标:Pipeline 仅在 `message.*` 可选。
|
||||
|
||||
### P3:EventRouter 执行接入
|
||||
|
||||
- EBA 事件先广播插件 observer。
|
||||
- 然后按 `event_bindings` 的事件模式、filters、priority 和顺序选择 Agent 编排。
|
||||
- 消息事件继续优先保留现有 Pipeline / MessageAggregator 兼容路径。
|
||||
- 非消息事件只调用 Agent 编排,不调用 Pipeline;AgentRunner 输出有平台 reply target 时会投递回平台。
|
||||
|
||||
## 7. 不做的事
|
||||
|
||||
- 不把 Pipeline 改名成 Agent,也不删除 Pipeline 的配置模型。
|
||||
- 不把非消息事件伪装成用户文本塞入 Pipeline。
|
||||
- 不在首版做多 Agent 串并联;需要多步骤处理时留给后续 workflow。
|
||||
@@ -64,7 +64,7 @@
|
||||
{
|
||||
"directory": "langbot-mcp-ops",
|
||||
"name": "langbot-mcp-ops",
|
||||
"description": "Operate a LangBot instance through its built-in MCP (Model Context Protocol) server. Use when an AI agent needs to manage LangBot — list/create/update/delete bots, pipelines, models, knowledge bases, MCP servers, and skills — over MCP instead of raw HTTP. Covers the /mcp endpoint, API-key auth (web-UI lbk_ keys and the config.yaml global key), the tool surface, and client configuration. Triggers on \"langbot mcp\", \"manage langbot via mcp\", \"langbot /mcp\", \"langbot mcp server\".",
|
||||
"description": "Operate a LangBot instance through its built-in MCP (Model Context Protocol) server. Use when an AI agent needs to manage LangBot — list/create/update/delete bots, agents, pipelines, models, knowledge bases, MCP servers, and skills — over MCP instead of raw HTTP. Covers the /mcp endpoint, API-key auth (web-UI lbk_ keys and the config.yaml global key), the tool surface, and client configuration. Triggers on \"langbot mcp\", \"manage langbot via mcp\", \"langbot /mcp\", \"langbot mcp server\".",
|
||||
"references": [],
|
||||
"cases": [],
|
||||
"case_summaries": [],
|
||||
@@ -133,6 +133,7 @@
|
||||
"references/pipeline-debug-chat.md",
|
||||
"references/plugin-e2e-smoke.md",
|
||||
"references/sandbox-skill-authoring.md",
|
||||
"references/skill-all-tool-acceptance.md",
|
||||
"references/troubleshooting.md",
|
||||
"references/web-ui-testing.md"
|
||||
],
|
||||
@@ -170,6 +171,7 @@
|
||||
"qa-plugin-smoke-live-install",
|
||||
"sandbox-skill-authoring-e2e",
|
||||
"sandbox-skill-authoring-edit-existing-e2e",
|
||||
"skill-discovery-via-mcp-gateway",
|
||||
"webui-login-state"
|
||||
],
|
||||
"case_summaries": [
|
||||
@@ -1033,6 +1035,31 @@
|
||||
"filesystem"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skill-discovery-via-mcp-gateway",
|
||||
"title": "External harness discovers LangBot skills via langbot_list_assets (all-tool model)",
|
||||
"mode": "agent-browser",
|
||||
"area": "sandbox",
|
||||
"type": "regression",
|
||||
"priority": "p2",
|
||||
"risk": "medium",
|
||||
"ci_eligible": false,
|
||||
"tags": [
|
||||
"skills",
|
||||
"mcp-gateway",
|
||||
"acp-agent-runner",
|
||||
"all-tool-model",
|
||||
"tools"
|
||||
],
|
||||
"automation": "scripts/e2e/pipeline-debug-chat.mjs",
|
||||
"setup_automation": [],
|
||||
"setup_provides_env": [],
|
||||
"evidence_required": [
|
||||
"ui",
|
||||
"screenshot",
|
||||
"backend_log"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "webui-login-state",
|
||||
"title": "Configured frontend opens with authenticated LangBot WebUI state",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: langbot-mcp-ops
|
||||
description: Operate a LangBot instance through its built-in MCP (Model Context Protocol) server. Use when an AI agent needs to manage LangBot — list/create/update/delete bots, pipelines, models, knowledge bases, MCP servers, and skills — over MCP instead of raw HTTP. Covers the /mcp endpoint, API-key auth (web-UI lbk_ keys and the config.yaml global key), the tool surface, and client configuration. Triggers on "langbot mcp", "manage langbot via mcp", "langbot /mcp", "langbot mcp server".
|
||||
description: Operate a LangBot instance through its built-in MCP (Model Context Protocol) server. Use when an AI agent needs to manage LangBot — list/create/update/delete bots, agents, pipelines, models, knowledge bases, MCP servers, and skills — over MCP instead of raw HTTP. Covers the /mcp endpoint, API-key auth (web-UI lbk_ keys and the config.yaml global key), the tool surface, and client configuration. Triggers on "langbot mcp", "manage langbot via mcp", "langbot /mcp", "langbot mcp server".
|
||||
---
|
||||
|
||||
# LangBot MCP Operations
|
||||
@@ -58,6 +58,7 @@ The tools wrap the LangBot service layer. Current tools (v1):
|
||||
| --- | --- |
|
||||
| `get_system_info` | Version, edition, instance id |
|
||||
| `list_bots` / `get_bot` / `create_bot` / `update_bot` / `delete_bot` | Manage messaging-platform bots (secrets redacted on read) |
|
||||
| `list_agents` / `get_agent` / `create_agent` / `update_agent` / `delete_agent` | Manage the Agent product surface, including Agent orchestrations and Pipelines |
|
||||
| `list_pipelines` / `get_pipeline` / `create_pipeline` / `update_pipeline` / `delete_pipeline` | Manage pipelines |
|
||||
| `list_llm_models` / `get_llm_model` / `list_embedding_models` / `list_model_providers` | Inspect models & providers |
|
||||
| `list_knowledge_bases` / `get_knowledge_base` / `retrieve_knowledge_base` | RAG knowledge bases (incl. semantic search) |
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import quart
|
||||
|
||||
from .. import group
|
||||
|
||||
|
||||
@group.group_class('agents', '/api/v1/agents')
|
||||
class AgentsRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _() -> str:
|
||||
if quart.request.method == 'GET':
|
||||
sort_by = quart.request.args.get('sort_by', 'updated_at')
|
||||
sort_order = quart.request.args.get('sort_order', 'DESC')
|
||||
return self.success(data={'agents': await self.ap.agent_service.get_agents(sort_by, sort_order)})
|
||||
|
||||
json_data = await quart.request.json
|
||||
created = await self.ap.agent_service.create_agent(json_data)
|
||||
return self.success(data=created)
|
||||
|
||||
@self.route('/_/metadata', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _() -> str:
|
||||
return self.success(data=await self.ap.agent_service.get_agent_metadata())
|
||||
|
||||
@self.route('/<agent_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _(agent_uuid: str) -> str:
|
||||
if quart.request.method == 'GET':
|
||||
agent = await self.ap.agent_service.get_agent(agent_uuid)
|
||||
if agent is None:
|
||||
return self.http_status(404, -1, 'agent not found')
|
||||
return self.success(data={'agent': agent})
|
||||
|
||||
if quart.request.method == 'PUT':
|
||||
json_data = await quart.request.json
|
||||
await self.ap.agent_service.update_agent(agent_uuid, json_data)
|
||||
return self.success()
|
||||
|
||||
await self.ap.agent_service.delete_agent(agent_uuid)
|
||||
return self.success()
|
||||
@@ -0,0 +1,220 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import uuid
|
||||
import typing
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
from ....core import app
|
||||
from ....entity.persistence import agent as persistence_agent
|
||||
|
||||
|
||||
AGENT_KIND_AGENT = 'agent'
|
||||
AGENT_KIND_PIPELINE = 'pipeline'
|
||||
PIPELINE_EVENT_PATTERNS = ['message.*']
|
||||
AGENT_DEFAULT_EVENT_PATTERNS = ['*']
|
||||
|
||||
|
||||
class AgentService:
|
||||
"""Unified product surface for Agent orchestration instances and Pipelines."""
|
||||
|
||||
ap: app.Application
|
||||
|
||||
def __init__(self, ap: app.Application) -> None:
|
||||
self.ap = ap
|
||||
|
||||
async def get_agent_metadata(self) -> dict[str, typing.Any]:
|
||||
"""Return metadata needed by Agent forms."""
|
||||
pipeline_metadata = await self.ap.pipeline_service.get_pipeline_metadata()
|
||||
ai_metadata = next((item for item in pipeline_metadata if item.get('name') == 'ai'), None)
|
||||
return {
|
||||
'runner_config': ai_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,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
async def get_agents(self, sort_by: str = 'updated_at', sort_order: str = 'DESC') -> list[dict]:
|
||||
agents = await self._get_agent_rows()
|
||||
pipelines = await self.ap.pipeline_service.get_pipelines(sort_by='updated_at', sort_order='DESC')
|
||||
|
||||
items = [self._agent_to_product_item(agent) for agent in agents]
|
||||
items.extend(self._pipeline_to_product_item(pipeline) for pipeline in pipelines)
|
||||
|
||||
reverse = sort_order == 'DESC'
|
||||
sort_key = sort_by if sort_by in {'created_at', 'updated_at'} else 'updated_at'
|
||||
return sorted(items, key=lambda item: self._parse_sort_time(item.get(sort_key)), reverse=reverse)
|
||||
|
||||
async def get_agent(self, agent_uuid: str) -> dict | None:
|
||||
agent = await self._get_agent_row(agent_uuid)
|
||||
if agent is not None:
|
||||
return self._agent_to_product_item(agent, include_config=True)
|
||||
|
||||
pipeline = await self.ap.pipeline_service.get_pipeline(agent_uuid)
|
||||
if pipeline is not None:
|
||||
return self._pipeline_to_product_item(pipeline, include_config=True)
|
||||
|
||||
return None
|
||||
|
||||
async def create_agent(self, agent_data: dict) -> dict[str, str]:
|
||||
kind = agent_data.get('kind') or AGENT_KIND_AGENT
|
||||
if kind == AGENT_KIND_PIPELINE:
|
||||
pipeline_uuid = await self.ap.pipeline_service.create_pipeline(
|
||||
{
|
||||
'name': agent_data.get('name') or 'New Pipeline',
|
||||
'description': agent_data.get('description') or '',
|
||||
'emoji': agent_data.get('emoji') or '⚙️',
|
||||
'config': {},
|
||||
}
|
||||
)
|
||||
return {'uuid': pipeline_uuid, 'kind': AGENT_KIND_PIPELINE}
|
||||
|
||||
if kind != AGENT_KIND_AGENT:
|
||||
raise ValueError(f'Unsupported agent kind: {kind}')
|
||||
|
||||
config = agent_data.get('config') or await self._get_default_agent_config()
|
||||
runner_id = self._resolve_runner_id(config)
|
||||
new_uuid = str(uuid.uuid4())
|
||||
values = {
|
||||
'uuid': new_uuid,
|
||||
'name': agent_data.get('name') or 'New Agent',
|
||||
'description': agent_data.get('description') or '',
|
||||
'emoji': agent_data.get('emoji') or '🤖',
|
||||
'kind': AGENT_KIND_AGENT,
|
||||
'component_ref': agent_data.get('component_ref') or runner_id,
|
||||
'config': config,
|
||||
'enabled': agent_data.get('enabled', True),
|
||||
'supported_event_patterns': agent_data.get('supported_event_patterns') or AGENT_DEFAULT_EVENT_PATTERNS,
|
||||
}
|
||||
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_agent.Agent).values(**values))
|
||||
return {'uuid': new_uuid, 'kind': AGENT_KIND_AGENT}
|
||||
|
||||
async def update_agent(self, agent_uuid: str, agent_data: dict) -> None:
|
||||
existing_agent = await self._get_agent_row(agent_uuid)
|
||||
if existing_agent is None:
|
||||
pipeline = await self.ap.pipeline_service.get_pipeline(agent_uuid)
|
||||
if pipeline is None:
|
||||
raise ValueError(f'Agent {agent_uuid} not found')
|
||||
await self.ap.pipeline_service.update_pipeline(agent_uuid, agent_data)
|
||||
return
|
||||
|
||||
update_data = agent_data.copy()
|
||||
for protected_field in ('uuid', 'kind', 'created_at', 'updated_at', 'capability'):
|
||||
update_data.pop(protected_field, None)
|
||||
if 'config' in update_data:
|
||||
update_data['component_ref'] = update_data.get('component_ref') or self._resolve_runner_id(
|
||||
update_data['config']
|
||||
)
|
||||
if 'supported_event_patterns' in update_data and not update_data['supported_event_patterns']:
|
||||
update_data['supported_event_patterns'] = AGENT_DEFAULT_EVENT_PATTERNS
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_agent.Agent)
|
||||
.where(persistence_agent.Agent.uuid == agent_uuid)
|
||||
.values(**update_data)
|
||||
)
|
||||
|
||||
async def delete_agent(self, agent_uuid: str) -> None:
|
||||
existing_agent = await self._get_agent_row(agent_uuid)
|
||||
if existing_agent is not None:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.delete(persistence_agent.Agent).where(persistence_agent.Agent.uuid == agent_uuid)
|
||||
)
|
||||
return
|
||||
|
||||
pipeline = await self.ap.pipeline_service.get_pipeline(agent_uuid)
|
||||
if pipeline is None:
|
||||
raise ValueError(f'Agent {agent_uuid} not found')
|
||||
await self.ap.pipeline_service.delete_pipeline(agent_uuid)
|
||||
|
||||
async def _get_agent_rows(self) -> list[persistence_agent.Agent]:
|
||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_agent.Agent))
|
||||
return list(result.all())
|
||||
|
||||
async def _get_agent_row(self, agent_uuid: str) -> persistence_agent.Agent | None:
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_agent.Agent).where(persistence_agent.Agent.uuid == agent_uuid)
|
||||
)
|
||||
return result.first()
|
||||
|
||||
async def _get_default_agent_config(self) -> dict[str, typing.Any]:
|
||||
runners = []
|
||||
if getattr(self.ap, 'agent_runner_registry', None) is not None:
|
||||
try:
|
||||
runners = await self.ap.agent_runner_registry.list_runners(bound_plugins=None)
|
||||
except Exception as e:
|
||||
if getattr(self.ap, 'logger', None):
|
||||
self.ap.logger.warning(f'Failed to load plugin agent runners for default agent config: {e}')
|
||||
|
||||
if not runners:
|
||||
return {'runner': {'id': '', 'expire-time': 0}, 'runner_config': {}}
|
||||
|
||||
selected_runner = runners[0]
|
||||
return {
|
||||
'runner': {'id': selected_runner.id, 'expire-time': 0},
|
||||
'runner_config': {
|
||||
selected_runner.id: self.ap.pipeline_service._get_default_values_from_schema(
|
||||
selected_runner.config_schema
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _resolve_runner_id(config: dict[str, typing.Any]) -> str | None:
|
||||
runner = config.get('runner') if isinstance(config, dict) else None
|
||||
if isinstance(runner, dict):
|
||||
runner_id = runner.get('id')
|
||||
if runner_id:
|
||||
return runner_id
|
||||
return None
|
||||
|
||||
def _agent_to_product_item(
|
||||
self,
|
||||
agent: persistence_agent.Agent,
|
||||
include_config: bool = False,
|
||||
) -> dict[str, typing.Any]:
|
||||
item = self.ap.persistence_mgr.serialize_model(persistence_agent.Agent, agent)
|
||||
item['kind'] = AGENT_KIND_AGENT
|
||||
item['capability'] = {
|
||||
'supported_event_patterns': item.get('supported_event_patterns') or AGENT_DEFAULT_EVENT_PATTERNS,
|
||||
'message_only': False,
|
||||
}
|
||||
if not include_config:
|
||||
item.pop('config', None)
|
||||
return item
|
||||
|
||||
@staticmethod
|
||||
def _pipeline_to_product_item(pipeline: dict, include_config: bool = False) -> dict[str, typing.Any]:
|
||||
item = pipeline.copy()
|
||||
item['kind'] = AGENT_KIND_PIPELINE
|
||||
item['component_ref'] = 'pipeline'
|
||||
item['enabled'] = True
|
||||
item['supported_event_patterns'] = PIPELINE_EVENT_PATTERNS
|
||||
item['capability'] = {
|
||||
'supported_event_patterns': PIPELINE_EVENT_PATTERNS,
|
||||
'message_only': True,
|
||||
}
|
||||
if not include_config:
|
||||
item.pop('config', None)
|
||||
return item
|
||||
|
||||
@staticmethod
|
||||
def _parse_sort_time(value: typing.Any) -> datetime.datetime:
|
||||
if isinstance(value, datetime.datetime):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return datetime.datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return datetime.datetime.min
|
||||
return datetime.datetime.min
|
||||
@@ -6,6 +6,7 @@ import typing
|
||||
|
||||
from ....core import app
|
||||
from ....discover import engine
|
||||
from ....entity.persistence import agent as persistence_agent
|
||||
from ....entity.persistence import bot as persistence_bot
|
||||
from ....entity.persistence import pipeline as persistence_pipeline
|
||||
|
||||
@@ -36,6 +37,84 @@ class BotService:
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _is_message_event_pattern(event_pattern: str) -> bool:
|
||||
return event_pattern == 'message.*' or event_pattern.startswith('message.')
|
||||
|
||||
@staticmethod
|
||||
def _event_pattern_covers(supported_pattern: str, binding_pattern: str) -> bool:
|
||||
if supported_pattern == '*':
|
||||
return True
|
||||
if supported_pattern == binding_pattern:
|
||||
return True
|
||||
if binding_pattern == '*':
|
||||
return False
|
||||
if supported_pattern.endswith('.*'):
|
||||
namespace = supported_pattern[:-2]
|
||||
return binding_pattern == f'{namespace}.*' or binding_pattern.startswith(f'{namespace}.')
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def _agent_supports_event_pattern(cls, supported_patterns: list[str] | None, event_pattern: str) -> bool:
|
||||
patterns = supported_patterns or ['*']
|
||||
return any(cls._event_pattern_covers(pattern, event_pattern) for pattern in patterns)
|
||||
|
||||
async def _normalize_event_bindings(self, bindings: list[dict] | None) -> list[dict]:
|
||||
"""Validate and normalize Bot event bindings."""
|
||||
if not bindings:
|
||||
return []
|
||||
|
||||
normalized: list[dict] = []
|
||||
for index, raw_binding in enumerate(bindings):
|
||||
if not isinstance(raw_binding, dict):
|
||||
continue
|
||||
|
||||
event_pattern = str(raw_binding.get('event_pattern') or '').strip()
|
||||
target_type = str(raw_binding.get('target_type') or '').strip()
|
||||
target_uuid = str(raw_binding.get('target_uuid') or '').strip()
|
||||
if not event_pattern or not target_type:
|
||||
continue
|
||||
|
||||
if target_type == 'pipeline':
|
||||
if not self._is_message_event_pattern(event_pattern):
|
||||
raise ValueError('Pipeline can only be bound to message events')
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_pipeline.LegacyPipeline.uuid).where(
|
||||
persistence_pipeline.LegacyPipeline.uuid == target_uuid
|
||||
)
|
||||
)
|
||||
if result.first() is None:
|
||||
raise ValueError('Pipeline not found')
|
||||
elif target_type == 'agent':
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_agent.Agent).where(persistence_agent.Agent.uuid == target_uuid)
|
||||
)
|
||||
agent = result.first()
|
||||
if agent is None:
|
||||
raise ValueError('Agent not found')
|
||||
if not self._agent_supports_event_pattern(agent.supported_event_patterns, event_pattern):
|
||||
raise ValueError('Agent does not support this event pattern')
|
||||
elif target_type == 'discard':
|
||||
target_uuid = ''
|
||||
else:
|
||||
raise ValueError(f'Unsupported event binding target type: {target_type}')
|
||||
|
||||
normalized.append(
|
||||
{
|
||||
'id': raw_binding.get('id') or str(uuid.uuid4()),
|
||||
'event_pattern': event_pattern,
|
||||
'target_type': target_type,
|
||||
'target_uuid': target_uuid,
|
||||
'filters': raw_binding.get('filters') or [],
|
||||
'priority': int(raw_binding.get('priority') or 0),
|
||||
'enabled': bool(raw_binding.get('enabled', True)),
|
||||
'description': raw_binding.get('description') or '',
|
||||
'order': index,
|
||||
}
|
||||
)
|
||||
|
||||
return normalized
|
||||
|
||||
async def get_bots(self, include_secret: bool = True) -> list[dict]:
|
||||
"""获取所有机器人"""
|
||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_bot.Bot))
|
||||
@@ -137,6 +216,9 @@ class BotService:
|
||||
if 'uuid' in update_data:
|
||||
del update_data['uuid']
|
||||
|
||||
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(
|
||||
|
||||
@@ -28,7 +28,7 @@ if typing.TYPE_CHECKING:
|
||||
|
||||
INSTRUCTIONS = """\
|
||||
This MCP server manages a LangBot instance. LangBot is an LLM-native instant
|
||||
messaging bot platform. Use these tools to inspect and manage bots, pipelines,
|
||||
messaging bot platform. Use these tools to inspect and manage bots, agents, pipelines,
|
||||
models, knowledge bases, MCP servers, and skills.
|
||||
|
||||
Authentication uses a LangBot API key (web-UI-created `lbk_...` key or the
|
||||
@@ -141,6 +141,34 @@ class LangBotMCPServer:
|
||||
await ap.pipeline_service.delete_pipeline(pipeline_uuid)
|
||||
return _dump({'ok': True})
|
||||
|
||||
# ----- Agents -------------------------------------------------- #
|
||||
@mcp.tool(description='List product-level Agents, including Agent orchestrations and Pipelines.')
|
||||
async def list_agents() -> str:
|
||||
return _dump(await ap.agent_service.get_agents())
|
||||
|
||||
@mcp.tool(description='Get a product-level Agent or Pipeline by UUID.')
|
||||
async def get_agent(agent_uuid: str) -> str:
|
||||
return _dump(await ap.agent_service.get_agent(agent_uuid))
|
||||
|
||||
@mcp.tool(
|
||||
description=(
|
||||
'Create an Agent orchestration or Pipeline. `agent_data` matches '
|
||||
'POST /api/v1/agents; set kind to `agent` or `pipeline`. Returns the new UUID and kind.'
|
||||
)
|
||||
)
|
||||
async def create_agent(agent_data: dict) -> str:
|
||||
return _dump(await ap.agent_service.create_agent(agent_data))
|
||||
|
||||
@mcp.tool(description='Update an Agent orchestration or Pipeline by UUID.')
|
||||
async def update_agent(agent_uuid: str, agent_data: dict) -> str:
|
||||
await ap.agent_service.update_agent(agent_uuid, agent_data)
|
||||
return _dump({'ok': True})
|
||||
|
||||
@mcp.tool(description='Delete an Agent orchestration or Pipeline by UUID.')
|
||||
async def delete_agent(agent_uuid: str) -> str:
|
||||
await ap.agent_service.delete_agent(agent_uuid)
|
||||
return _dump({'ok': True})
|
||||
|
||||
# ----- Models -------------------------------------------------- #
|
||||
@mcp.tool(description='List all configured LLM models. Secrets are redacted.')
|
||||
async def list_llm_models() -> str:
|
||||
|
||||
@@ -27,6 +27,7 @@ from ..api.http.service import space as space_service
|
||||
from ..api.http.service import model as model_service
|
||||
from ..api.http.service import provider as provider_service
|
||||
from ..api.http.service import pipeline as pipeline_service
|
||||
from ..api.http.service import agent as agent_service
|
||||
from ..api.http.service import bot as bot_service
|
||||
from ..api.http.service import knowledge as knowledge_service
|
||||
from ..api.http.service import mcp as mcp_service
|
||||
@@ -147,6 +148,8 @@ class Application:
|
||||
|
||||
pipeline_service: pipeline_service.PipelineService = None
|
||||
|
||||
agent_service: agent_service.AgentService = None
|
||||
|
||||
bot_service: bot_service.BotService = None
|
||||
|
||||
knowledge_service: knowledge_service.KnowledgeService = None
|
||||
|
||||
@@ -23,6 +23,7 @@ from ...api.http.service import space as space_service
|
||||
from ...api.http.service import model as model_service
|
||||
from ...api.http.service import provider as provider_service
|
||||
from ...api.http.service import pipeline as pipeline_service
|
||||
from ...api.http.service import agent as agent_service
|
||||
from ...api.http.service import bot as bot_service
|
||||
from ...api.http.service import knowledge as knowledge_service
|
||||
from ...api.http.service import mcp as mcp_service
|
||||
@@ -75,6 +76,9 @@ class BuildAppStage(stage.BootingStage):
|
||||
pipeline_service_inst = pipeline_service.PipelineService(ap)
|
||||
ap.pipeline_service = pipeline_service_inst
|
||||
|
||||
agent_service_inst = agent_service.AgentService(ap)
|
||||
ap.agent_service = agent_service_inst
|
||||
|
||||
bot_service_inst = bot_service.BotService(ap)
|
||||
ap.bot_service = bot_service_inst
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import sqlalchemy
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class Agent(Base):
|
||||
"""Product-level Agent orchestration instance."""
|
||||
|
||||
__tablename__ = 'agents'
|
||||
|
||||
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
|
||||
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
description = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, default='')
|
||||
emoji = sqlalchemy.Column(sqlalchemy.String(10), nullable=True, default='🤖')
|
||||
kind = sqlalchemy.Column(sqlalchemy.String(50), nullable=False, default='agent')
|
||||
component_ref = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
|
||||
enabled = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=True)
|
||||
supported_event_patterns = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=['*'])
|
||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||
updated_at = sqlalchemy.Column(
|
||||
sqlalchemy.DateTime,
|
||||
nullable=False,
|
||||
server_default=sqlalchemy.func.now(),
|
||||
onupdate=sqlalchemy.func.now(),
|
||||
)
|
||||
@@ -17,6 +17,7 @@ class Bot(Base):
|
||||
use_pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
use_pipeline_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
pipeline_routing_rules = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, server_default='[]')
|
||||
event_bindings = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, server_default='[]')
|
||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||
updated_at = sqlalchemy.Column(
|
||||
sqlalchemy.DateTime,
|
||||
|
||||
@@ -16,6 +16,7 @@ from langbot.pkg.entity.persistence.base import Base
|
||||
# Import all ORM models so they are registered with Base.metadata
|
||||
# This is required for autogenerate to detect model changes
|
||||
from langbot.pkg.entity.persistence import (
|
||||
agent, # noqa: F401
|
||||
agent_run, # noqa: F401
|
||||
agent_runner_state, # noqa: F401
|
||||
apikey, # noqa: F401
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Merge agent runner and MCP migration heads.
|
||||
|
||||
Revision ID: 0007_merge_agent_mcp_heads
|
||||
Revises: 8d3a1f2c4b6e, 0006_normalize_mcp_remote_mode
|
||||
Create Date: 2026-06-23
|
||||
"""
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '0007_merge_agent_mcp_heads'
|
||||
down_revision = ('8d3a1f2c4b6e', '0006_normalize_mcp_remote_mode')
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Add Agent product surface tables.
|
||||
|
||||
Revision ID: 0008_agent_product_surface
|
||||
Revises: 0007_merge_agent_mcp_heads
|
||||
Create Date: 2026-06-23
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = '0008_agent_product_surface'
|
||||
down_revision = '0007_merge_agent_mcp_heads'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def _column_exists(inspector: sa.Inspector, table_name: str, column_name: str) -> bool:
|
||||
if not _table_exists(inspector, table_name):
|
||||
return False
|
||||
return any(column['name'] == column_name for column in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if not _table_exists(inspector, 'agents'):
|
||||
op.create_table(
|
||||
'agents',
|
||||
sa.Column('uuid', sa.String(length=255), nullable=False),
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
sa.Column('description', sa.String(length=255), nullable=False, server_default=''),
|
||||
sa.Column('emoji', sa.String(length=10), nullable=True),
|
||||
sa.Column('kind', sa.String(length=50), nullable=False, server_default='agent'),
|
||||
sa.Column('component_ref', sa.String(length=255), nullable=True),
|
||||
sa.Column('config', sa.JSON(), nullable=False, server_default='{}'),
|
||||
sa.Column('enabled', sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column('supported_event_patterns', sa.JSON(), nullable=False, server_default='["*"]'),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('uuid'),
|
||||
sa.UniqueConstraint('uuid'),
|
||||
)
|
||||
|
||||
if _table_exists(inspector, 'bots') and not _column_exists(inspector, 'bots', 'event_bindings'):
|
||||
with op.batch_alter_table('bots') as batch_op:
|
||||
batch_op.add_column(sa.Column('event_bindings', sa.JSON(), nullable=False, server_default='[]'))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if _table_exists(inspector, 'bots') and _column_exists(inspector, 'bots', 'event_bindings'):
|
||||
with op.batch_alter_table('bots') as batch_op:
|
||||
batch_op.drop_column('event_bindings')
|
||||
|
||||
if _table_exists(inspector, 'agents'):
|
||||
op.drop_table('agents')
|
||||
+692
-118
@@ -3,7 +3,10 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import traceback
|
||||
import typing
|
||||
import uuid
|
||||
import sqlalchemy
|
||||
|
||||
from ..core import app, entities as core_entities, taskmgr
|
||||
@@ -14,14 +17,30 @@ from ..entity.persistence import bot as persistence_bot
|
||||
from ..entity.persistence import pipeline as persistence_pipeline
|
||||
|
||||
from ..entity.errors import platform as platform_errors
|
||||
from ..agent.runner.host_models import (
|
||||
AgentBinding,
|
||||
AgentEventEnvelope,
|
||||
BindingScope,
|
||||
DeliveryPolicy,
|
||||
ResourcePolicy,
|
||||
StatePolicy,
|
||||
)
|
||||
|
||||
from .logger import EventLogger
|
||||
|
||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
import langbot_plugin.api.entities.events as plugin_events
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.event import (
|
||||
ActorContext,
|
||||
SubjectContext,
|
||||
RawEventRef,
|
||||
)
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
|
||||
|
||||
|
||||
class RuntimeBot:
|
||||
@@ -77,6 +96,7 @@ class RuntimeBot:
|
||||
|
||||
PIPELINE_DISCARD = '__discard__'
|
||||
PIPELINE_DISCARD_DISPLAY_NAME = 'Discarded'
|
||||
EVENT_DATA_MAX_STRING_BYTES = 512
|
||||
|
||||
@staticmethod
|
||||
def _eba_event_to_plugin_event(event: platform_events.EBAEvent) -> plugin_events.BaseEventModel | None:
|
||||
@@ -103,6 +123,572 @@ class RuntimeBot:
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _match_event_pattern(event_type: str, pattern: str) -> bool:
|
||||
if not event_type or not pattern:
|
||||
return False
|
||||
if pattern == '*':
|
||||
return True
|
||||
if pattern.endswith('.*'):
|
||||
return event_type.startswith(f'{pattern[:-2]}.')
|
||||
return event_type == pattern
|
||||
|
||||
@classmethod
|
||||
def _is_message_event_type(cls, event_type: str) -> bool:
|
||||
return cls._match_event_pattern(event_type, 'message.*')
|
||||
|
||||
@classmethod
|
||||
def _agent_supports_event_type(
|
||||
cls,
|
||||
supported_patterns: list[str] | None,
|
||||
event_type: str,
|
||||
) -> bool:
|
||||
return any(cls._match_event_pattern(event_type, pattern) for pattern in (supported_patterns or ['*']))
|
||||
|
||||
@staticmethod
|
||||
def _get_nested_value(data: dict[str, typing.Any], path: str) -> typing.Any:
|
||||
current: typing.Any = data
|
||||
for key in path.split('.'):
|
||||
if isinstance(current, dict):
|
||||
current = current.get(key)
|
||||
else:
|
||||
current = getattr(current, key, None)
|
||||
if current is None:
|
||||
return None
|
||||
return current
|
||||
|
||||
@classmethod
|
||||
def _match_event_filter(
|
||||
cls,
|
||||
event_data: dict[str, typing.Any],
|
||||
event_filter: dict[str, typing.Any],
|
||||
) -> bool:
|
||||
field = str(event_filter.get('field') or event_filter.get('path') or '').strip()
|
||||
if not field:
|
||||
return True
|
||||
|
||||
operator = str(event_filter.get('operator') or 'eq')
|
||||
expected = event_filter.get('value')
|
||||
actual = cls._get_nested_value(event_data, field)
|
||||
|
||||
if operator == 'eq':
|
||||
return actual == expected
|
||||
if operator == 'neq':
|
||||
return actual != expected
|
||||
if operator == 'contains':
|
||||
if isinstance(actual, (list, tuple, set)):
|
||||
return expected in actual
|
||||
return str(expected) in str(actual or '')
|
||||
if operator == 'not_contains':
|
||||
if isinstance(actual, (list, tuple, set)):
|
||||
return expected not in actual
|
||||
return str(expected) not in str(actual or '')
|
||||
if operator == 'starts_with':
|
||||
return str(actual or '').startswith(str(expected))
|
||||
if operator == 'regex':
|
||||
try:
|
||||
return bool(re.search(str(expected), str(actual or '')))
|
||||
except re.error:
|
||||
return False
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def _match_event_filters(
|
||||
cls,
|
||||
event: platform_events.EBAEvent,
|
||||
filters: typing.Any,
|
||||
) -> bool:
|
||||
if not filters:
|
||||
return True
|
||||
if not isinstance(filters, list):
|
||||
return False
|
||||
|
||||
event_data = cls._safe_model_dump(event)
|
||||
return all(
|
||||
cls._match_event_filter(event_data, event_filter)
|
||||
for event_filter in filters
|
||||
if isinstance(event_filter, dict)
|
||||
)
|
||||
|
||||
def _resolve_eba_event_binding(
|
||||
self,
|
||||
event: platform_events.EBAEvent,
|
||||
event_type: str,
|
||||
) -> dict[str, typing.Any] | None:
|
||||
"""Resolve the highest priority Bot event binding for a platform event."""
|
||||
raw_bindings = self.bot_entity.event_bindings or []
|
||||
if isinstance(raw_bindings, str):
|
||||
try:
|
||||
raw_bindings = json.loads(raw_bindings)
|
||||
except json.JSONDecodeError:
|
||||
raw_bindings = []
|
||||
if not isinstance(raw_bindings, list):
|
||||
return None
|
||||
|
||||
matched: list[tuple[int, int, dict[str, typing.Any]]] = []
|
||||
for index, binding in enumerate(raw_bindings):
|
||||
if not isinstance(binding, dict) or not binding.get('enabled', True):
|
||||
continue
|
||||
|
||||
event_pattern = str(binding.get('event_pattern') or '')
|
||||
if not self._match_event_pattern(event_type, event_pattern):
|
||||
continue
|
||||
if not self._match_event_filters(event, binding.get('filters')):
|
||||
continue
|
||||
|
||||
priority = int(binding.get('priority') or 0)
|
||||
order = int(binding.get('order', index))
|
||||
matched.append((priority, -order, binding))
|
||||
|
||||
if not matched:
|
||||
return None
|
||||
|
||||
matched.sort(key=lambda item: (item[0], item[1]), reverse=True)
|
||||
return matched[0][2]
|
||||
|
||||
@staticmethod
|
||||
def _safe_model_dump(model: typing.Any) -> dict[str, typing.Any]:
|
||||
if model is None:
|
||||
return {}
|
||||
if hasattr(model, 'model_dump'):
|
||||
try:
|
||||
return model.model_dump(mode='json')
|
||||
except TypeError:
|
||||
try:
|
||||
return model.model_dump()
|
||||
except Exception:
|
||||
return {}
|
||||
except Exception:
|
||||
return {}
|
||||
if isinstance(model, dict):
|
||||
return model
|
||||
return {}
|
||||
|
||||
@classmethod
|
||||
def _compact_event_data(cls, event: platform_events.EBAEvent) -> dict[str, typing.Any]:
|
||||
raw_event_data = cls._safe_model_dump(event)
|
||||
compact: dict[str, typing.Any] = {}
|
||||
for key, value in raw_event_data.items():
|
||||
if key == 'source_platform_object' or key.startswith('_'):
|
||||
continue
|
||||
if value is None or isinstance(value, (bool, int, float)):
|
||||
compact[key] = value
|
||||
continue
|
||||
if isinstance(value, str):
|
||||
if len(value.encode('utf-8')) <= cls.EVENT_DATA_MAX_STRING_BYTES:
|
||||
compact[key] = value
|
||||
continue
|
||||
if isinstance(value, (list, dict)):
|
||||
try:
|
||||
encoded = json.dumps(value, ensure_ascii=False)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if len(encoded.encode('utf-8')) <= cls.EVENT_DATA_MAX_STRING_BYTES:
|
||||
compact[key] = value
|
||||
return compact
|
||||
|
||||
@staticmethod
|
||||
def _get_entity_id(entity: typing.Any) -> str | None:
|
||||
entity_id = getattr(entity, 'id', None)
|
||||
if entity_id is None and isinstance(entity, dict):
|
||||
entity_id = entity.get('id')
|
||||
if entity_id is None or entity_id == '':
|
||||
return None
|
||||
return str(entity_id)
|
||||
|
||||
@staticmethod
|
||||
def _get_entity_name(entity: typing.Any) -> str | None:
|
||||
if entity is None:
|
||||
return None
|
||||
if hasattr(entity, 'get_name'):
|
||||
try:
|
||||
name = entity.get_name()
|
||||
if name:
|
||||
return str(name)
|
||||
except Exception:
|
||||
pass
|
||||
for attr in ('nickname', 'member_name', 'name', 'display_name'):
|
||||
value = getattr(entity, attr, None)
|
||||
if value:
|
||||
return str(value)
|
||||
if isinstance(entity, dict):
|
||||
for attr in ('nickname', 'member_name', 'name', 'display_name'):
|
||||
value = entity.get(attr)
|
||||
if value:
|
||||
return str(value)
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _infer_actor_context(cls, event: platform_events.EBAEvent) -> ActorContext | None:
|
||||
actor = getattr(event, 'sender', None) or getattr(event, 'member', None) or getattr(event, 'user', None)
|
||||
actor_id = cls._get_entity_id(actor)
|
||||
actor_name = cls._get_entity_name(actor)
|
||||
|
||||
if actor_id is None:
|
||||
user_id = getattr(event, 'user_id', None)
|
||||
if user_id:
|
||||
actor_id = str(user_id)
|
||||
|
||||
if actor_id is None:
|
||||
return None
|
||||
|
||||
return ActorContext(
|
||||
actor_type='user',
|
||||
actor_id=actor_id,
|
||||
actor_name=actor_name,
|
||||
metadata={},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _infer_subject_context(cls, event: platform_events.EBAEvent) -> SubjectContext:
|
||||
group = getattr(event, 'group', None)
|
||||
if group is not None:
|
||||
group_id = cls._get_entity_id(group)
|
||||
return SubjectContext(
|
||||
subject_type='group',
|
||||
subject_id=group_id,
|
||||
data={'group_name': cls._get_entity_name(group)},
|
||||
)
|
||||
|
||||
message_id = getattr(event, 'message_id', None)
|
||||
if message_id:
|
||||
return SubjectContext(
|
||||
subject_type='message',
|
||||
subject_id=str(message_id),
|
||||
data={},
|
||||
)
|
||||
|
||||
feedback_id = getattr(event, 'feedback_id', None)
|
||||
if feedback_id:
|
||||
return SubjectContext(
|
||||
subject_type='feedback',
|
||||
subject_id=str(feedback_id),
|
||||
data={'message_id': getattr(event, 'message_id', None)},
|
||||
)
|
||||
|
||||
action = getattr(event, 'action', None)
|
||||
if action:
|
||||
return SubjectContext(
|
||||
subject_type='platform_action',
|
||||
subject_id=str(action),
|
||||
data={},
|
||||
)
|
||||
|
||||
return SubjectContext(
|
||||
subject_type='event',
|
||||
subject_id=getattr(event, 'type', None),
|
||||
data={},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _session_to_reply_target(session_id: str | None) -> tuple[str | None, str | None]:
|
||||
if not session_id or '_' not in session_id:
|
||||
return None, None
|
||||
target_type, target_id = session_id.split('_', 1)
|
||||
if target_type == 'person':
|
||||
target_type = 'person'
|
||||
elif target_type == 'group':
|
||||
target_type = 'group'
|
||||
else:
|
||||
return None, None
|
||||
return target_type, target_id or None
|
||||
|
||||
@classmethod
|
||||
def _infer_reply_target(
|
||||
cls,
|
||||
event: platform_events.EBAEvent,
|
||||
) -> tuple[str | None, str | None, dict[str, typing.Any]]:
|
||||
metadata: dict[str, typing.Any] = {}
|
||||
group = getattr(event, 'group', None)
|
||||
group_id = cls._get_entity_id(group)
|
||||
if group_id:
|
||||
metadata['group_id'] = group_id
|
||||
return 'group', group_id, metadata
|
||||
|
||||
chat_id = getattr(event, 'chat_id', None)
|
||||
chat_type = getattr(event, 'chat_type', None)
|
||||
chat_type_value = getattr(chat_type, 'value', chat_type)
|
||||
if chat_id:
|
||||
metadata['chat_id'] = str(chat_id)
|
||||
if chat_type_value == 'group':
|
||||
return 'group', str(chat_id), metadata
|
||||
return 'person', str(chat_id), metadata
|
||||
|
||||
session_target_type, session_target_id = cls._session_to_reply_target(getattr(event, 'session_id', None))
|
||||
if session_target_type and session_target_id:
|
||||
return session_target_type, session_target_id, metadata
|
||||
|
||||
raw_data = getattr(event, 'data', None)
|
||||
if isinstance(raw_data, dict):
|
||||
target_type = raw_data.get('target_type') or raw_data.get('chat_type')
|
||||
target_id = (
|
||||
raw_data.get('target_id')
|
||||
or raw_data.get('chat_id')
|
||||
or raw_data.get('group_id')
|
||||
or raw_data.get('user_id')
|
||||
)
|
||||
if target_type and target_id:
|
||||
return str(target_type), str(target_id), metadata
|
||||
|
||||
return None, None, metadata
|
||||
|
||||
@classmethod
|
||||
def _build_agent_input(cls, event: platform_events.EBAEvent) -> AgentInput:
|
||||
text = None
|
||||
contents: list[dict[str, typing.Any]] = []
|
||||
|
||||
message_chain = getattr(event, 'message_chain', None)
|
||||
if message_chain:
|
||||
text_parts: list[str] = []
|
||||
try:
|
||||
for component in message_chain:
|
||||
if isinstance(component, platform_message.Plain):
|
||||
text_parts.append(component.text)
|
||||
elif isinstance(component, platform_message.Image):
|
||||
if component.url:
|
||||
contents.append({'type': 'image_url', 'image_url': {'url': component.url}})
|
||||
elif component.base64:
|
||||
contents.append({'type': 'image_base64', 'image_base64': component.base64})
|
||||
except TypeError:
|
||||
text_parts.append(str(message_chain))
|
||||
text = ''.join(text_parts) or str(message_chain)
|
||||
|
||||
if text is None:
|
||||
feedback_content = getattr(event, 'feedback_content', None)
|
||||
if feedback_content:
|
||||
text = str(feedback_content)
|
||||
elif getattr(event, 'action', None):
|
||||
text = str(getattr(event, 'action'))
|
||||
else:
|
||||
text = str(getattr(event, 'type', 'event'))
|
||||
|
||||
if text:
|
||||
contents.insert(0, {'type': 'text', 'text': text})
|
||||
|
||||
return AgentInput(
|
||||
text=text,
|
||||
contents=contents,
|
||||
attachments=[],
|
||||
)
|
||||
|
||||
def _eba_event_to_agent_envelope(
|
||||
self,
|
||||
event: platform_events.EBAEvent,
|
||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||||
) -> AgentEventEnvelope:
|
||||
event_type = getattr(event, 'type', None) or event.__class__.__name__
|
||||
event_time = getattr(event, 'timestamp', None) or time.time()
|
||||
event_id = (
|
||||
getattr(event, 'message_id', None) or getattr(event, 'feedback_id', None) or f'{event_type}:{uuid.uuid4()}'
|
||||
)
|
||||
target_type, target_id, target_metadata = self._infer_reply_target(event)
|
||||
|
||||
conversation_id = None
|
||||
if target_type and target_id:
|
||||
conversation_id = f'{target_type}_{target_id}'
|
||||
elif getattr(event, 'session_id', None):
|
||||
conversation_id = str(getattr(event, 'session_id'))
|
||||
|
||||
return AgentEventEnvelope(
|
||||
event_id=f'platform:{self.bot_entity.uuid}:{event_id}',
|
||||
event_type=event_type,
|
||||
event_time=int(event_time) if isinstance(event_time, (int, float)) else None,
|
||||
source='platform',
|
||||
source_event_type=event_type,
|
||||
bot_id=self.bot_entity.uuid,
|
||||
workspace_id=None,
|
||||
conversation_id=conversation_id,
|
||||
thread_id=None,
|
||||
actor=self._infer_actor_context(event),
|
||||
subject=self._infer_subject_context(event),
|
||||
input=self._build_agent_input(event),
|
||||
delivery=DeliveryContext(
|
||||
surface='platform',
|
||||
reply_target={
|
||||
'target_type': target_type,
|
||||
'target_id': target_id,
|
||||
'message_id': getattr(event, 'message_id', None),
|
||||
**target_metadata,
|
||||
},
|
||||
supports_streaming=False,
|
||||
supports_edit=False,
|
||||
supports_reaction=False,
|
||||
platform_capabilities={
|
||||
'adapter': adapter.__class__.__name__,
|
||||
'event_type': event_type,
|
||||
},
|
||||
),
|
||||
raw_ref=RawEventRef(ref_id=str(event_id), storage_key=None),
|
||||
data=self._compact_event_data(event),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _agent_product_to_binding(
|
||||
agent: dict[str, typing.Any],
|
||||
event_binding: dict[str, typing.Any],
|
||||
event_type: str,
|
||||
bot_uuid: str,
|
||||
) -> AgentBinding | None:
|
||||
config = agent.get('config') if isinstance(agent, dict) else None
|
||||
if not isinstance(config, dict):
|
||||
return None
|
||||
|
||||
runner = config.get('runner')
|
||||
runner_id = None
|
||||
if isinstance(runner, dict):
|
||||
runner_id = runner.get('id')
|
||||
runner_id = runner_id or agent.get('component_ref')
|
||||
if not runner_id:
|
||||
return None
|
||||
|
||||
runner_config_map = config.get('runner_config')
|
||||
runner_config = {}
|
||||
if isinstance(runner_config_map, dict):
|
||||
runner_config = runner_config_map.get(runner_id) or {}
|
||||
|
||||
return AgentBinding(
|
||||
binding_id=f'bot:{bot_uuid}:{event_binding.get("id") or uuid.uuid4()}',
|
||||
scope=BindingScope(scope_type='bot', scope_id=bot_uuid),
|
||||
event_types=[event_type],
|
||||
runner_id=runner_id,
|
||||
runner_config=runner_config,
|
||||
resource_policy=ResourcePolicy(),
|
||||
state_policy=StatePolicy(state_scopes=['conversation', 'actor', 'subject', 'runner']),
|
||||
delivery_policy=DeliveryPolicy(enable_streaming=False, enable_reply=True),
|
||||
enabled=True,
|
||||
agent_id=agent.get('uuid'),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _provider_content_to_text(content: typing.Any) -> str:
|
||||
if content is None:
|
||||
return ''
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
parts: list[str] = []
|
||||
for item in content:
|
||||
item_data = item.model_dump(mode='json') if hasattr(item, 'model_dump') else item
|
||||
if isinstance(item_data, dict):
|
||||
if item_data.get('type') == 'text' and item_data.get('text') is not None:
|
||||
parts.append(str(item_data.get('text')))
|
||||
elif item_data.get('text') is not None:
|
||||
parts.append(str(item_data.get('text')))
|
||||
elif item_data is not None:
|
||||
parts.append(str(item_data))
|
||||
return ''.join(parts)
|
||||
return str(content)
|
||||
|
||||
@classmethod
|
||||
def _provider_output_to_text(cls, result: provider_message.Message | provider_message.MessageChunk) -> str:
|
||||
if getattr(result, 'all_content', None):
|
||||
return str(getattr(result, 'all_content'))
|
||||
return cls._provider_content_to_text(getattr(result, 'content', None))
|
||||
|
||||
async def _deliver_agent_outputs(
|
||||
self,
|
||||
envelope: AgentEventEnvelope,
|
||||
outputs: list[provider_message.Message | provider_message.MessageChunk],
|
||||
) -> None:
|
||||
if not outputs or not envelope.delivery.reply_target:
|
||||
return
|
||||
|
||||
reply_target = envelope.delivery.reply_target
|
||||
target_type = reply_target.get('target_type')
|
||||
target_id = reply_target.get('target_id')
|
||||
if not target_type or not target_id:
|
||||
return
|
||||
|
||||
final_text = ''
|
||||
for output in outputs:
|
||||
output_text = self._provider_output_to_text(output)
|
||||
if isinstance(output, provider_message.Message):
|
||||
final_text = output_text or final_text
|
||||
elif output_text:
|
||||
final_text = output_text
|
||||
|
||||
if not final_text:
|
||||
return
|
||||
|
||||
await self.adapter.send_message(
|
||||
str(target_type),
|
||||
str(target_id),
|
||||
platform_message.MessageChain([platform_message.Plain(text=final_text)]),
|
||||
)
|
||||
|
||||
async def _dispatch_eba_event_to_agent(
|
||||
self,
|
||||
event: platform_events.EBAEvent,
|
||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||||
) -> None:
|
||||
event_type = getattr(event, 'type', None) or event.__class__.__name__
|
||||
|
||||
event_binding = self._resolve_eba_event_binding(event, event_type)
|
||||
if event_binding is None:
|
||||
if isinstance(event, platform_events.MessageReceivedEvent):
|
||||
await self._dispatch_eba_message_to_pipeline(event, adapter)
|
||||
return
|
||||
|
||||
target_type = event_binding.get('target_type')
|
||||
if target_type == 'discard':
|
||||
if isinstance(event, platform_events.MessageReceivedEvent):
|
||||
await self._dispatch_eba_message_to_pipeline(
|
||||
event,
|
||||
adapter,
|
||||
pipeline_uuid=self.PIPELINE_DISCARD,
|
||||
routed_by_event_binding=True,
|
||||
)
|
||||
return
|
||||
await self.logger.info(f'EBA event {event_type} discarded by event binding')
|
||||
return
|
||||
if target_type == 'pipeline':
|
||||
if not self._is_message_event_type(event_type):
|
||||
await self.logger.warning(f'EBA event {event_type} ignored Pipeline target for non-message event')
|
||||
return
|
||||
await self._dispatch_eba_message_to_pipeline(
|
||||
event,
|
||||
adapter,
|
||||
pipeline_uuid=event_binding.get('target_uuid'),
|
||||
routed_by_event_binding=True,
|
||||
)
|
||||
return
|
||||
if target_type != 'agent':
|
||||
await self.logger.warning(f'EBA event {event_type} ignored unsupported target type {target_type}')
|
||||
return
|
||||
|
||||
target_uuid = event_binding.get('target_uuid')
|
||||
agent = await self.ap.agent_service.get_agent(target_uuid)
|
||||
if not agent or agent.get('kind') != 'agent':
|
||||
await self.logger.warning(f'EBA event {event_type} target agent not found: {target_uuid}')
|
||||
return
|
||||
if not agent.get('enabled', True):
|
||||
await self.logger.info(f'EBA event {event_type} target agent disabled: {target_uuid}')
|
||||
return
|
||||
if not self._agent_supports_event_type(agent.get('supported_event_patterns'), event_type):
|
||||
await self.logger.info(f'EBA event {event_type} target agent does not support this event: {target_uuid}')
|
||||
return
|
||||
|
||||
binding = self._agent_product_to_binding(agent, event_binding, event_type, self.bot_entity.uuid)
|
||||
if binding is None:
|
||||
await self.logger.warning(f'EBA event {event_type} target agent has no runner: {target_uuid}')
|
||||
return
|
||||
|
||||
envelope = self._eba_event_to_agent_envelope(event, adapter)
|
||||
outputs: list[provider_message.Message | provider_message.MessageChunk] = []
|
||||
try:
|
||||
async for output in self.ap.agent_run_orchestrator.run(envelope, binding):
|
||||
outputs.append(output)
|
||||
except Exception:
|
||||
await self.logger.error(f'Failed to run Agent for EBA event {event_type}: {traceback.format_exc()}')
|
||||
return
|
||||
|
||||
try:
|
||||
await self._deliver_agent_outputs(envelope, outputs)
|
||||
except Exception:
|
||||
await self.logger.error(
|
||||
f'Failed to deliver Agent output for EBA event {event_type}: {traceback.format_exc()}'
|
||||
)
|
||||
|
||||
def resolve_pipeline_uuid(
|
||||
self,
|
||||
launcher_type: str,
|
||||
@@ -222,128 +808,115 @@ class RuntimeBot:
|
||||
except Exception as e:
|
||||
await self.logger.error(f'Failed to record discarded message: {e}')
|
||||
|
||||
async def _handle_legacy_message_event(
|
||||
self,
|
||||
event: platform_events.FriendMessage | platform_events.GroupMessage,
|
||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||||
pipeline_uuid_override: str | None = None,
|
||||
routed_by_event_binding: bool = False,
|
||||
) -> None:
|
||||
is_group_message = isinstance(event, platform_events.GroupMessage)
|
||||
launcher_kind = 'group' if is_group_message else 'person'
|
||||
launcher_type = (
|
||||
provider_session.LauncherTypes.GROUP if is_group_message else provider_session.LauncherTypes.PERSON
|
||||
)
|
||||
launcher_id = event.group.id if is_group_message else event.sender.id
|
||||
sender_id = event.sender.id
|
||||
|
||||
image_components = [
|
||||
component for component in event.message_chain if isinstance(component, platform_message.Image)
|
||||
]
|
||||
|
||||
await self.logger.info(
|
||||
f'{event.message_chain}',
|
||||
images=image_components,
|
||||
message_session_id=f'{launcher_kind}_{launcher_id}',
|
||||
)
|
||||
|
||||
skip_pipeline = False
|
||||
if hasattr(self.ap, 'webhook_pusher') and self.ap.webhook_pusher:
|
||||
if is_group_message:
|
||||
skip_pipeline = await self.ap.webhook_pusher.push_group_message(
|
||||
event, self.bot_entity.uuid, adapter.__class__.__name__
|
||||
)
|
||||
else:
|
||||
skip_pipeline = await self.ap.webhook_pusher.push_person_message(
|
||||
event, self.bot_entity.uuid, adapter.__class__.__name__
|
||||
)
|
||||
|
||||
if skip_pipeline:
|
||||
await self.logger.info(f'Pipeline skipped for {launcher_kind} message due to webhook response')
|
||||
return
|
||||
|
||||
if hasattr(adapter, 'get_launcher_id'):
|
||||
custom_launcher_id = adapter.get_launcher_id(event)
|
||||
if custom_launcher_id:
|
||||
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
|
||||
)
|
||||
else:
|
||||
pipeline_uuid = pipeline_uuid_override
|
||||
routed_by_rule = routed_by_event_binding
|
||||
|
||||
if pipeline_uuid == self.PIPELINE_DISCARD:
|
||||
await self.logger.info(f'{launcher_kind.title()} message discarded by routing rule')
|
||||
await self._record_discarded_message(
|
||||
launcher_type,
|
||||
launcher_id,
|
||||
sender_id,
|
||||
event,
|
||||
event.message_chain,
|
||||
)
|
||||
return
|
||||
|
||||
await self.ap.msg_aggregator.add_message(
|
||||
bot_uuid=self.bot_entity.uuid,
|
||||
launcher_type=launcher_type,
|
||||
launcher_id=launcher_id,
|
||||
sender_id=sender_id,
|
||||
message_event=event,
|
||||
message_chain=event.message_chain,
|
||||
adapter=adapter,
|
||||
pipeline_uuid=pipeline_uuid,
|
||||
routed_by_rule=routed_by_rule,
|
||||
)
|
||||
|
||||
async def _dispatch_eba_message_to_pipeline(
|
||||
self,
|
||||
event: platform_events.EBAEvent,
|
||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||||
pipeline_uuid: str | None = None,
|
||||
routed_by_event_binding: bool = False,
|
||||
) -> None:
|
||||
if not isinstance(event, platform_events.MessageReceivedEvent):
|
||||
event_type = getattr(event, 'type', None) or event.__class__.__name__
|
||||
await self.logger.warning(f'EBA event {event_type} cannot be dispatched to legacy Pipeline')
|
||||
return
|
||||
|
||||
await self._handle_legacy_message_event(
|
||||
event.to_legacy_event(),
|
||||
adapter,
|
||||
pipeline_uuid_override=pipeline_uuid,
|
||||
routed_by_event_binding=routed_by_event_binding,
|
||||
)
|
||||
|
||||
async def initialize(self):
|
||||
async def on_friend_message(
|
||||
event: platform_events.FriendMessage,
|
||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||||
):
|
||||
image_components = [
|
||||
component for component in event.message_chain if isinstance(component, platform_message.Image)
|
||||
]
|
||||
|
||||
await self.logger.info(
|
||||
f'{event.message_chain}',
|
||||
images=image_components,
|
||||
message_session_id=f'person_{event.sender.id}',
|
||||
)
|
||||
|
||||
# Push to webhooks and check if pipeline should be skipped
|
||||
skip_pipeline = False
|
||||
if hasattr(self.ap, 'webhook_pusher') and self.ap.webhook_pusher:
|
||||
skip_pipeline = await self.ap.webhook_pusher.push_person_message(
|
||||
event, self.bot_entity.uuid, adapter.__class__.__name__
|
||||
)
|
||||
|
||||
# Only add to query pool if no webhook requested to skip pipeline
|
||||
if not skip_pipeline:
|
||||
launcher_id = event.sender.id
|
||||
|
||||
if hasattr(adapter, 'get_launcher_id'):
|
||||
custom_launcher_id = adapter.get_launcher_id(event)
|
||||
if custom_launcher_id:
|
||||
launcher_id = custom_launcher_id
|
||||
|
||||
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(
|
||||
'person', launcher_id, message_text, element_types
|
||||
)
|
||||
|
||||
if pipeline_uuid == self.PIPELINE_DISCARD:
|
||||
await self.logger.info('Person message discarded by routing rule')
|
||||
await self._record_discarded_message(
|
||||
provider_session.LauncherTypes.PERSON,
|
||||
launcher_id,
|
||||
event.sender.id,
|
||||
event,
|
||||
event.message_chain,
|
||||
)
|
||||
return
|
||||
|
||||
await self.ap.msg_aggregator.add_message(
|
||||
bot_uuid=self.bot_entity.uuid,
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=launcher_id,
|
||||
sender_id=event.sender.id,
|
||||
message_event=event,
|
||||
message_chain=event.message_chain,
|
||||
adapter=adapter,
|
||||
pipeline_uuid=pipeline_uuid,
|
||||
routed_by_rule=routed_by_rule,
|
||||
)
|
||||
else:
|
||||
await self.logger.info('Pipeline skipped for person message due to webhook response')
|
||||
await self._handle_legacy_message_event(event, adapter)
|
||||
|
||||
async def on_group_message(
|
||||
event: platform_events.GroupMessage,
|
||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||||
):
|
||||
image_components = [
|
||||
component for component in event.message_chain if isinstance(component, platform_message.Image)
|
||||
]
|
||||
|
||||
await self.logger.info(
|
||||
f'{event.message_chain}',
|
||||
images=image_components,
|
||||
message_session_id=f'group_{event.group.id}',
|
||||
)
|
||||
|
||||
# Push to webhooks and check if pipeline should be skipped
|
||||
skip_pipeline = False
|
||||
if hasattr(self.ap, 'webhook_pusher') and self.ap.webhook_pusher:
|
||||
skip_pipeline = await self.ap.webhook_pusher.push_group_message(
|
||||
event, self.bot_entity.uuid, adapter.__class__.__name__
|
||||
)
|
||||
|
||||
# Only add to query pool if no webhook requested to skip pipeline
|
||||
if not skip_pipeline:
|
||||
launcher_id = event.group.id
|
||||
|
||||
if hasattr(adapter, 'get_launcher_id'):
|
||||
custom_launcher_id = adapter.get_launcher_id(event)
|
||||
if custom_launcher_id:
|
||||
launcher_id = custom_launcher_id
|
||||
|
||||
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(
|
||||
'group', launcher_id, message_text, element_types
|
||||
)
|
||||
|
||||
if pipeline_uuid == self.PIPELINE_DISCARD:
|
||||
await self.logger.info('Group message discarded by routing rule')
|
||||
await self._record_discarded_message(
|
||||
provider_session.LauncherTypes.GROUP,
|
||||
launcher_id,
|
||||
event.sender.id,
|
||||
event,
|
||||
event.message_chain,
|
||||
)
|
||||
return
|
||||
|
||||
await self.ap.msg_aggregator.add_message(
|
||||
bot_uuid=self.bot_entity.uuid,
|
||||
launcher_type=provider_session.LauncherTypes.GROUP,
|
||||
launcher_id=launcher_id,
|
||||
sender_id=event.sender.id,
|
||||
message_event=event,
|
||||
message_chain=event.message_chain,
|
||||
adapter=adapter,
|
||||
pipeline_uuid=pipeline_uuid,
|
||||
routed_by_rule=routed_by_rule,
|
||||
)
|
||||
else:
|
||||
await self.logger.info('Pipeline skipped for group message due to webhook response')
|
||||
await self._handle_legacy_message_event(event, adapter)
|
||||
|
||||
self.adapter.register_listener(platform_events.FriendMessage, on_friend_message)
|
||||
self.adapter.register_listener(platform_events.GroupMessage, on_group_message)
|
||||
@@ -398,13 +971,14 @@ class RuntimeBot:
|
||||
):
|
||||
event.bot_uuid = self.bot_entity.uuid
|
||||
plugin_event = self._eba_event_to_plugin_event(event)
|
||||
if plugin_event is None:
|
||||
return
|
||||
|
||||
try:
|
||||
await self.ap.plugin_connector.emit_event(plugin_event)
|
||||
except Exception:
|
||||
await self.logger.error(f'Failed to dispatch EBA event to plugins: {traceback.format_exc()}')
|
||||
if plugin_event is not None:
|
||||
try:
|
||||
await self.ap.plugin_connector.emit_event(plugin_event)
|
||||
except Exception:
|
||||
await self.logger.error(f'Failed to dispatch EBA event to plugins: {traceback.format_exc()}')
|
||||
|
||||
await self._dispatch_eba_event_to_agent(event, adapter)
|
||||
|
||||
self.adapter.register_listener(platform_events.EBAEvent, on_eba_event)
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { Agent } from '@/app/infra/entities/api';
|
||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
import PipelineDetailContent from '@/app/home/pipelines/PipelineDetailContent';
|
||||
import AgentCreateContent from './components/AgentCreateContent';
|
||||
import AgentFormComponent from './components/AgentFormComponent';
|
||||
|
||||
export default function AgentDetailContent({ id }: { id: string }) {
|
||||
const isCreateMode = id === 'new';
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const { refreshPipelines, pipelines, setDetailEntityName } = useSidebarData();
|
||||
const [agent, setAgent] = useState<Agent | null>(null);
|
||||
const [loading, setLoading] = useState(!isCreateMode);
|
||||
const [formDirty, setFormDirty] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isCreateMode) {
|
||||
setDetailEntityName(t('agents.create'));
|
||||
return () => setDetailEntityName(null);
|
||||
}
|
||||
|
||||
const sidebarItem = pipelines.find((p) => p.id === id);
|
||||
setDetailEntityName(sidebarItem?.name ?? id);
|
||||
return () => setDetailEntityName(null);
|
||||
}, [id, isCreateMode, pipelines, setDetailEntityName, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isCreateMode) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
httpClient
|
||||
.getAgent(id)
|
||||
.then((resp) => {
|
||||
if (!cancelled) setAgent(resp.agent);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [id, isCreateMode]);
|
||||
|
||||
if (isCreateMode) {
|
||||
return (
|
||||
<AgentCreateContent
|
||||
onCreated={(newAgentId) => {
|
||||
refreshPipelines();
|
||||
navigate(`/home/agents?id=${encodeURIComponent(newAgentId)}`);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading || !agent) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (agent.kind === 'pipeline') {
|
||||
return <PipelineDetailContent id={id} routeBase="/home/agents" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
||||
<h1 className="text-xl font-semibold">{t('agents.editAgent')}</h1>
|
||||
<Button type="submit" form="agent-form" disabled={!formDirty}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<AgentFormComponent
|
||||
agentId={id}
|
||||
onFinish={() => {
|
||||
refreshPipelines();
|
||||
setFormDirty(false);
|
||||
}}
|
||||
onDeleted={() => {
|
||||
refreshPipelines();
|
||||
navigate('/home/agents');
|
||||
}}
|
||||
onDirtyChange={setFormDirty}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { useState } from 'react';
|
||||
import type React from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import { Bot, Workflow } from 'lucide-react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { AgentKind } from '@/app/infra/entities/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import EmojiPicker from '@/components/ui/emoji-picker';
|
||||
|
||||
export default function AgentCreateContent({
|
||||
onCreated,
|
||||
}: {
|
||||
onCreated: (agentId: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [kind, setKind] = useState<AgentKind>('agent');
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(1, { message: t('agents.nameRequired') }),
|
||||
description: z.string().optional(),
|
||||
emoji: z.string().optional(),
|
||||
});
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
description: '',
|
||||
emoji: '🤖',
|
||||
},
|
||||
});
|
||||
|
||||
function handleKindChange(nextKind: AgentKind) {
|
||||
setKind(nextKind);
|
||||
if (!form.getValues('emoji')) {
|
||||
form.setValue('emoji', nextKind === 'pipeline' ? '⚙️' : '🤖');
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit(values: FormValues) {
|
||||
httpClient
|
||||
.createAgent({
|
||||
kind,
|
||||
name: values.name,
|
||||
description: values.description ?? '',
|
||||
emoji: values.emoji || (kind === 'pipeline' ? '⚙️' : '🤖'),
|
||||
})
|
||||
.then((resp) => {
|
||||
toast.success(t('agents.createSuccess'));
|
||||
onCreated(resp.uuid);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(t('agents.createError') + err.msg);
|
||||
});
|
||||
}
|
||||
|
||||
const typeOptions: Array<{
|
||||
kind: AgentKind;
|
||||
icon: React.ElementType;
|
||||
title: string;
|
||||
description: string;
|
||||
badge: string;
|
||||
}> = [
|
||||
{
|
||||
kind: 'agent',
|
||||
icon: Bot,
|
||||
title: t('agents.agentOrchestration'),
|
||||
description: t('agents.agentOrchestrationDescription'),
|
||||
badge: t('agents.allEvents'),
|
||||
},
|
||||
{
|
||||
kind: 'pipeline',
|
||||
icon: Workflow,
|
||||
title: t('agents.pipelineType'),
|
||||
description: t('agents.pipelineTypeDescription'),
|
||||
badge: t('agents.messageEventsOnly'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
||||
<h1 className="text-xl font-semibold">{t('agents.create')}</h1>
|
||||
<Button type="submit" form="agent-create-form">
|
||||
{t('common.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<div className="mx-auto max-w-2xl space-y-6">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{typeOptions.map((option) => {
|
||||
const Icon = option.icon;
|
||||
const selected = kind === option.kind;
|
||||
return (
|
||||
<button
|
||||
key={option.kind}
|
||||
type="button"
|
||||
onClick={() => handleKindChange(option.kind)}
|
||||
className={cn(
|
||||
'rounded-lg border bg-card p-4 text-left transition-colors',
|
||||
selected
|
||||
? 'border-primary ring-2 ring-primary/20'
|
||||
: 'hover:border-primary/60',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Icon className="mt-0.5 size-5 text-blue-500" />
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">{option.title}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{option.badge}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{option.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('agents.basicInfo')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('agents.basicInfoDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="agent-create-form"
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="flex gap-4 items-start">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
{t('common.name')}
|
||||
<span className="text-destructive">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value ?? ''} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="emoji"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('common.icon')}</FormLabel>
|
||||
<FormControl>
|
||||
<EmojiPicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('common.description')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value ?? ''} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,509 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type React from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import { Brain, FileJson2, Info, Power, Trash2 } from 'lucide-react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { Agent } from '@/app/infra/entities/api';
|
||||
import {
|
||||
PipelineConfigStage,
|
||||
PipelineConfigTab,
|
||||
} from '@/app/infra/entities/pipeline';
|
||||
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import EmojiPicker from '@/components/ui/emoji-picker';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
|
||||
interface AgentFormComponentProps {
|
||||
agentId: string;
|
||||
onFinish: () => void;
|
||||
onDeleted: () => void;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
}
|
||||
|
||||
interface SectionItem {
|
||||
label: string;
|
||||
name: 'basic' | 'runner' | 'events';
|
||||
icon: React.ElementType;
|
||||
}
|
||||
|
||||
export default function AgentFormComponent({
|
||||
agentId,
|
||||
onFinish,
|
||||
onDeleted,
|
||||
onDirtyChange,
|
||||
}: AgentFormComponentProps) {
|
||||
const { t } = useTranslation();
|
||||
const [activeSection, setActiveSection] =
|
||||
useState<SectionItem['name']>('basic');
|
||||
const [runnerConfigSchema, setRunnerConfigSchema] =
|
||||
useState<PipelineConfigTab | null>(null);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
const formSchema = z.object({
|
||||
basic: z.object({
|
||||
name: z.string().min(1, { message: t('agents.nameRequired') }),
|
||||
description: z.string().optional(),
|
||||
emoji: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
}),
|
||||
runner: z.record(z.string(), z.any()),
|
||||
runner_config: z.record(z.string(), z.any()),
|
||||
supported_event_patterns_text: z.string(),
|
||||
});
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
basic: {
|
||||
name: '',
|
||||
description: '',
|
||||
emoji: '🤖',
|
||||
enabled: true,
|
||||
},
|
||||
runner: {},
|
||||
runner_config: {},
|
||||
supported_event_patterns_text: '*',
|
||||
},
|
||||
});
|
||||
|
||||
const savedSnapshotRef = useRef('');
|
||||
const initializedStagesRef = useRef<Set<string>>(new Set());
|
||||
const watchedValues = form.watch();
|
||||
const hasUnsavedChanges = useMemo(() => {
|
||||
if (!savedSnapshotRef.current) return false;
|
||||
return JSON.stringify(watchedValues) !== savedSnapshotRef.current;
|
||||
}, [watchedValues]);
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyChange?.(hasUnsavedChanges);
|
||||
}, [hasUnsavedChanges, onDirtyChange]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
Promise.all([httpClient.getAgentMetadata(), httpClient.getAgent(agentId)])
|
||||
.then(([metadata, resp]) => {
|
||||
if (cancelled) return;
|
||||
setRunnerConfigSchema(metadata.runner_config ?? null);
|
||||
const agent = resp.agent;
|
||||
const config = (agent.config ?? {}) as Record<string, any>;
|
||||
const loadedValues: FormValues = {
|
||||
basic: {
|
||||
name: agent.name ?? '',
|
||||
description: agent.description ?? '',
|
||||
emoji: agent.emoji || '🤖',
|
||||
enabled: agent.enabled ?? true,
|
||||
},
|
||||
runner: (config.runner as Record<string, unknown>) ?? {},
|
||||
runner_config:
|
||||
(config.runner_config as Record<string, unknown>) ?? {},
|
||||
supported_event_patterns_text: (
|
||||
agent.supported_event_patterns ??
|
||||
agent.capability?.supported_event_patterns ?? ['*']
|
||||
).join('\n'),
|
||||
};
|
||||
form.reset(loadedValues);
|
||||
savedSnapshotRef.current = JSON.stringify(loadedValues);
|
||||
initializedStagesRef.current.clear();
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(t('agents.loadError') + err.msg);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [agentId, form, t]);
|
||||
|
||||
const sections: SectionItem[] = [
|
||||
{ label: t('agents.basicInfo'), name: 'basic', icon: Info },
|
||||
{ label: t('agents.runnerSettings'), name: 'runner', icon: Brain },
|
||||
{ label: t('agents.eventCapability'), name: 'events', icon: FileJson2 },
|
||||
];
|
||||
|
||||
const currentRunner = (form.watch('runner') as Record<string, any>)?.id;
|
||||
|
||||
function updateSnapshotIfInitial(stageKey: string) {
|
||||
if (!initializedStagesRef.current.has(stageKey)) {
|
||||
initializedStagesRef.current.add(stageKey);
|
||||
if (!hasUnsavedChanges) {
|
||||
savedSnapshotRef.current = JSON.stringify(form.getValues());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleDynamicFormEmit(
|
||||
formName: 'runner' | 'runner_config',
|
||||
stageName: string,
|
||||
values: object,
|
||||
) {
|
||||
if (formName === 'runner') {
|
||||
form.setValue('runner', values, { shouldDirty: true });
|
||||
updateSnapshotIfInitial(`runner.${stageName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentRunnerConfigs =
|
||||
(form.getValues('runner_config') as Record<string, unknown>) || {};
|
||||
form.setValue(
|
||||
'runner_config',
|
||||
{
|
||||
...currentRunnerConfigs,
|
||||
[stageName]: values,
|
||||
},
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
updateSnapshotIfInitial(`runner_config.${stageName}`);
|
||||
}
|
||||
|
||||
function renderDynamicStage(stage: PipelineConfigStage) {
|
||||
const isRunnerSelector = stage.name === 'runner';
|
||||
if (!isRunnerSelector && stage.name !== currentRunner) return null;
|
||||
|
||||
const initialValues = isRunnerSelector
|
||||
? (form.watch('runner') as Record<string, unknown>) || {}
|
||||
: ((form.watch('runner_config') as Record<string, any>) || {})[
|
||||
stage.name
|
||||
] || {};
|
||||
|
||||
return (
|
||||
<Card key={stage.name}>
|
||||
<CardHeader>
|
||||
<CardTitle>{extractI18nObject(stage.label)}</CardTitle>
|
||||
{stage.description && (
|
||||
<CardDescription>
|
||||
{extractI18nObject(stage.description)}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<DynamicFormComponent
|
||||
itemConfigList={stage.config}
|
||||
initialValues={initialValues}
|
||||
onSubmit={(values) =>
|
||||
handleDynamicFormEmit(
|
||||
isRunnerSelector ? 'runner' : 'runner_config',
|
||||
stage.name,
|
||||
values,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeEventPatterns(value: string): string[] {
|
||||
const patterns = value
|
||||
.split(/[\n,]/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
return patterns.length > 0 ? patterns : ['*'];
|
||||
}
|
||||
|
||||
function handleSubmit(values: FormValues) {
|
||||
const runner = values.runner || {};
|
||||
const agent: Partial<Agent> = {
|
||||
name: values.basic.name,
|
||||
description: values.basic.description ?? '',
|
||||
emoji: values.basic.emoji,
|
||||
enabled: values.basic.enabled ?? true,
|
||||
component_ref: (runner.id as string) || null,
|
||||
supported_event_patterns: normalizeEventPatterns(
|
||||
values.supported_event_patterns_text,
|
||||
),
|
||||
config: {
|
||||
runner,
|
||||
runner_config: values.runner_config ?? {},
|
||||
},
|
||||
};
|
||||
|
||||
httpClient
|
||||
.updateAgent(agentId, agent)
|
||||
.then(() => {
|
||||
const snapshotValues = form.getValues();
|
||||
savedSnapshotRef.current = JSON.stringify(snapshotValues);
|
||||
onFinish();
|
||||
toast.success(t('agents.saveSuccess'));
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(t('agents.saveError') + err.msg);
|
||||
});
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
httpClient
|
||||
.deleteAgent(agentId)
|
||||
.then(() => {
|
||||
toast.success(t('agents.deleteSuccess'));
|
||||
setShowDeleteConfirm(false);
|
||||
onDeleted();
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(t('agents.deleteError') + err.msg);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-full p-0 flex flex-col">
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="agent-form"
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className="h-full flex flex-col flex-1 min-h-0 mb-2"
|
||||
>
|
||||
<div className="flex-1 flex flex-col md:flex-row min-h-0">
|
||||
<nav className="shrink-0 mb-4 md:mb-0 md:w-44 md:pr-4 md:mr-4 md:border-r overflow-x-auto md:overflow-x-visible md:overflow-y-auto">
|
||||
<ul className="flex md:flex-col gap-1 md:space-y-1">
|
||||
{sections.map((section) => {
|
||||
const Icon = section.icon;
|
||||
return (
|
||||
<li key={section.name}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveSection(section.name)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors text-left cursor-pointer whitespace-nowrap',
|
||||
activeSection === section.name
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4 shrink-0" />
|
||||
{section.label}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
{activeSection === 'basic' && (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('agents.basicInfo')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('agents.basicInfoDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex gap-4 items-start">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="basic.name"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
{t('common.name')}
|
||||
<span className="text-destructive">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value ?? ''} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="basic.emoji"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('common.icon')}</FormLabel>
|
||||
<FormControl>
|
||||
<EmojiPicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="basic.description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('common.description')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value ?? ''} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="basic.enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="flex items-center gap-2">
|
||||
<Power className="size-4" />
|
||||
{t('agents.enabled')}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t('agents.enabledDescription')}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value ?? true}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-destructive/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">
|
||||
{t('agents.dangerZone')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('agents.dangerZoneDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{t('agents.deleteAgentAction')}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('agents.deleteAgentHint')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
>
|
||||
<Trash2 className="size-4 mr-1.5" />
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === 'runner' && (
|
||||
<div className="space-y-6">
|
||||
{runnerConfigSchema?.stages.map((stage) =>
|
||||
renderDynamicStage(stage),
|
||||
)}
|
||||
{!runnerConfigSchema && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('agents.runnerSettings')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('agents.noRunnerMetadata')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === 'events' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('agents.eventCapability')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('agents.eventCapabilityDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="supported_event_patterns_text"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('agents.supportedEvents')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
{...field}
|
||||
className="min-h-32 font-mono text-sm"
|
||||
placeholder={'*\nmessage.received\ngroup.*'}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('agents.supportedEventsDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('common.confirmDelete')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-4">{t('agents.deleteConfirmation')}</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={confirmDelete}>
|
||||
{t('common.confirmDelete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import AgentDetailContent from './AgentDetailContent';
|
||||
|
||||
export default function AgentsPage() {
|
||||
const { t } = useTranslation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const detailId = searchParams.get('id');
|
||||
|
||||
if (detailId) {
|
||||
return <AgentDetailContent id={detailId} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
<p>{t('agents.selectFromSidebar')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,10 +14,11 @@ import { UUID } from 'uuidjs';
|
||||
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { systemInfo } from '@/app/infra/http';
|
||||
import { Bot } from '@/app/infra/entities/api';
|
||||
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 EventBindingsEditor from './EventBindingsEditor';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
@@ -88,6 +89,21 @@ const getFormSchema = (t: (key: string) => string) =>
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
event_bindings: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().optional(),
|
||||
event_pattern: z.string(),
|
||||
target_type: z.enum(['agent', 'pipeline', 'discard']),
|
||||
target_uuid: z.string(),
|
||||
filters: z.array(z.record(z.string(), z.any())).optional(),
|
||||
priority: z.number(),
|
||||
enabled: z.boolean(),
|
||||
description: z.string().optional(),
|
||||
order: z.number().optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export default function BotForm({
|
||||
@@ -114,6 +130,7 @@ export default function BotForm({
|
||||
enable: true,
|
||||
use_pipeline_uuid: '',
|
||||
pipeline_routing_rules: [],
|
||||
event_bindings: [],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -133,10 +150,14 @@ export default function BotForm({
|
||||
const [adapterHelpLinks, setAdapterHelpLinks] = useState<
|
||||
Record<string, Record<string, string>>
|
||||
>({});
|
||||
const [adapterSupportedEvents, setAdapterSupportedEvents] = useState<
|
||||
Record<string, string[]>
|
||||
>({});
|
||||
|
||||
const [pipelineNameList, setPipelineNameList] = useState<IPipelineEntity[]>(
|
||||
[],
|
||||
);
|
||||
const [agentNameList, setAgentNameList] = useState<Agent[]>([]);
|
||||
|
||||
const [dynamicFormConfigList, setDynamicFormConfigList] = useState<
|
||||
IDynamicFormItemSchema[]
|
||||
@@ -181,6 +202,7 @@ export default function BotForm({
|
||||
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);
|
||||
|
||||
@@ -220,6 +242,9 @@ export default function BotForm({
|
||||
}),
|
||||
);
|
||||
|
||||
const agentsRes = await httpClient.getAgents();
|
||||
setAgentNameList(agentsRes.agents);
|
||||
|
||||
const adaptersRes = await httpClient.getAdapters();
|
||||
setAdapterNameList(
|
||||
adaptersRes.adapters.map((item) => {
|
||||
@@ -253,6 +278,16 @@ export default function BotForm({
|
||||
),
|
||||
);
|
||||
|
||||
setAdapterSupportedEvents(
|
||||
adaptersRes.adapters.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.name] = item.spec.supported_events || [];
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string[]>,
|
||||
),
|
||||
);
|
||||
|
||||
adaptersRes.adapters.forEach((rawAdapter) => {
|
||||
adapterNameToDynamicConfigMap.set(
|
||||
rawAdapter.name,
|
||||
@@ -298,6 +333,7 @@ export default function BotForm({
|
||||
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
|
||||
| undefined,
|
||||
@@ -343,6 +379,7 @@ export default function BotForm({
|
||||
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
|
||||
.updateBot(initBotId, updateBot)
|
||||
@@ -503,7 +540,26 @@ export default function BotForm({
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Card 3: Adapter Configuration */}
|
||||
{/* Card 3: Event Orchestration (edit mode only) */}
|
||||
{initBotId && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('bots.eventOrchestration')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('bots.eventOrchestrationDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<EventBindingsEditor
|
||||
form={form}
|
||||
supportedEvents={adapterSupportedEvents[currentAdapter] || []}
|
||||
agentOptions={agentNameList}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Card 4: Adapter Configuration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('bots.adapterConfig')}</CardTitle>
|
||||
@@ -561,7 +617,10 @@ export default function BotForm({
|
||||
</SelectLabel>
|
||||
)}
|
||||
{group.items.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
<SelectItem
|
||||
key={`${group.categoryId ?? 'uncategorized'}:${item.value}`}
|
||||
value={item.value}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={httpClient.getAdapterIconURL(
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { UseFormReturn } from 'react-hook-form';
|
||||
import { Ban, Bot, Plus, Trash2, Workflow } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { FormLabel } from '@/components/ui/form';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { EventBinding, Agent, AgentKind } from '@/app/infra/entities/api';
|
||||
|
||||
interface EventBindingsEditorProps {
|
||||
form: UseFormReturn<any>;
|
||||
supportedEvents: string[];
|
||||
agentOptions: Agent[];
|
||||
}
|
||||
|
||||
const DEFAULT_EVENTS = [
|
||||
'message.received',
|
||||
'feedback.received',
|
||||
'group.member_joined',
|
||||
'group.member_left',
|
||||
'friend.request_received',
|
||||
'bot.invited_to_group',
|
||||
'platform.specific',
|
||||
];
|
||||
|
||||
function isMessageEventPattern(pattern: string): boolean {
|
||||
return pattern === 'message.*' || pattern.startsWith('message.');
|
||||
}
|
||||
|
||||
function eventPatternCovers(
|
||||
supportedPattern: string,
|
||||
bindingPattern: string,
|
||||
): boolean {
|
||||
if (supportedPattern === '*') return true;
|
||||
if (supportedPattern === bindingPattern) return true;
|
||||
if (bindingPattern === '*') return false;
|
||||
if (supportedPattern.endsWith('.*')) {
|
||||
const namespace = supportedPattern.replace('.*', '');
|
||||
return (
|
||||
bindingPattern === `${namespace}.*` ||
|
||||
bindingPattern.startsWith(`${namespace}.`)
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function agentSupportsEventPattern(
|
||||
agent: Agent,
|
||||
bindingPattern: string,
|
||||
): boolean {
|
||||
const patterns = agent.supported_event_patterns ??
|
||||
agent.capability?.supported_event_patterns ?? ['*'];
|
||||
return patterns.some((pattern) =>
|
||||
eventPatternCovers(pattern, bindingPattern),
|
||||
);
|
||||
}
|
||||
|
||||
function eventNamespaces(events: string[]): string[] {
|
||||
const namespaces = new Set<string>();
|
||||
for (const event of events) {
|
||||
const namespace = event.split('.')[0];
|
||||
if (namespace) namespaces.add(`${namespace}.*`);
|
||||
}
|
||||
return Array.from(namespaces).sort();
|
||||
}
|
||||
|
||||
function targetLabel(agent: Agent): string {
|
||||
return `${agent.emoji ? `${agent.emoji} ` : ''}${agent.name}`;
|
||||
}
|
||||
|
||||
export default function EventBindingsEditor({
|
||||
form,
|
||||
supportedEvents,
|
||||
agentOptions,
|
||||
}: EventBindingsEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const bindings: EventBinding[] = form.watch('event_bindings') || [];
|
||||
|
||||
const eventOptions = useMemo(() => {
|
||||
const concreteEvents =
|
||||
supportedEvents.length > 0 ? supportedEvents : DEFAULT_EVENTS;
|
||||
return ['*', ...eventNamespaces(concreteEvents), ...concreteEvents].filter(
|
||||
(event, index, list) => list.indexOf(event) === index,
|
||||
);
|
||||
}, [supportedEvents]);
|
||||
|
||||
function updateBindings(nextBindings: EventBinding[]) {
|
||||
form.setValue('event_bindings', nextBindings, { shouldDirty: true });
|
||||
}
|
||||
|
||||
function addBinding() {
|
||||
updateBindings([
|
||||
...bindings,
|
||||
{
|
||||
event_pattern: 'message.received',
|
||||
target_type: 'agent',
|
||||
target_uuid: '',
|
||||
priority: bindings.length,
|
||||
enabled: true,
|
||||
description: '',
|
||||
filters: [],
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function updateBinding(index: number, patch: Partial<EventBinding>) {
|
||||
const updated = [...bindings];
|
||||
updated[index] = { ...updated[index], ...patch };
|
||||
updateBindings(updated);
|
||||
}
|
||||
|
||||
function removeBinding(index: number) {
|
||||
const updated = [...bindings];
|
||||
updated.splice(index, 1);
|
||||
updateBindings(updated);
|
||||
}
|
||||
|
||||
function getTargetOptions(binding: EventBinding, kind: AgentKind): Agent[] {
|
||||
return agentOptions.filter((agent) => {
|
||||
if (agent.kind !== kind) return false;
|
||||
if (kind === 'pipeline') {
|
||||
return isMessageEventPattern(binding.event_pattern);
|
||||
}
|
||||
if (kind === 'agent') {
|
||||
return agentSupportsEventPattern(agent, binding.event_pattern);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<FormLabel>{t('bots.eventBindings')}</FormLabel>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t('bots.eventOrchestrationDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addBinding}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{t('bots.addEventBinding')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{bindings.length === 0 && (
|
||||
<div className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
|
||||
{t('bots.noEventBindings')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{bindings.map((binding, index) => {
|
||||
const pipelineAllowed = isMessageEventPattern(binding.event_pattern);
|
||||
const targetType = binding.target_type || 'agent';
|
||||
const targetOptions =
|
||||
targetType === 'discard'
|
||||
? []
|
||||
: getTargetOptions(binding, targetType as AgentKind);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={binding.id ?? index}
|
||||
className="grid gap-2 rounded-md border bg-muted/30 p-3 lg:grid-cols-[1.2fr_0.9fr_1.4fr_80px_72px_36px]"
|
||||
>
|
||||
<Select
|
||||
value={binding.event_pattern}
|
||||
onValueChange={(eventPattern) => {
|
||||
const patch: Partial<EventBinding> = {
|
||||
event_pattern: eventPattern,
|
||||
};
|
||||
if (
|
||||
binding.target_type === 'pipeline' &&
|
||||
!isMessageEventPattern(eventPattern)
|
||||
) {
|
||||
patch.target_type = 'agent';
|
||||
patch.target_uuid = '';
|
||||
}
|
||||
updateBinding(index, patch);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t('bots.eventPatternPlaceholder')}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{eventOptions.map((event) => (
|
||||
<SelectItem key={event} value={event}>
|
||||
{event === '*'
|
||||
? t('bots.eventWildcard')
|
||||
: event.endsWith('.*')
|
||||
? t('bots.eventNamespaceWildcard', {
|
||||
namespace: event.replace('.*', ''),
|
||||
})
|
||||
: event}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={targetType}
|
||||
onValueChange={(nextType) => {
|
||||
updateBinding(index, {
|
||||
target_type: nextType as EventBinding['target_type'],
|
||||
target_uuid: '',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="agent">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="size-3.5" />
|
||||
{t('bots.targetAgent')}
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="pipeline" disabled={!pipelineAllowed}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Workflow className="size-3.5" />
|
||||
{t('bots.targetPipeline')}
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectSeparator />
|
||||
<SelectItem value="discard">
|
||||
<div className="flex items-center gap-2 text-destructive">
|
||||
<Ban className="size-3.5" />
|
||||
{t('bots.targetDiscard')}
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{targetType === 'discard' ? (
|
||||
<div className="flex items-center rounded-md border bg-background px-3 text-sm text-destructive">
|
||||
<Ban className="mr-2 size-3.5" />
|
||||
{t('bots.targetDiscard')}
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={binding.target_uuid}
|
||||
onValueChange={(targetUuid) =>
|
||||
updateBinding(index, { target_uuid: targetUuid })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('bots.selectTarget')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{targetOptions.map((agent) => (
|
||||
<SelectItem key={agent.uuid} value={agent.uuid || ''}>
|
||||
{targetLabel(agent)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
<Input
|
||||
type="number"
|
||||
value={binding.priority ?? 0}
|
||||
onChange={(event) =>
|
||||
updateBinding(index, {
|
||||
priority: Number(event.target.value || 0),
|
||||
})
|
||||
}
|
||||
aria-label={t('bots.priority')}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-center rounded-md border bg-background">
|
||||
<Switch
|
||||
checked={binding.enabled ?? true}
|
||||
onCheckedChange={(enabled) =>
|
||||
updateBinding(index, { enabled })
|
||||
}
|
||||
aria-label={t('bots.enabled')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeBinding(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
|
||||
{!pipelineAllowed && binding.target_type === 'pipeline' && (
|
||||
<div className="lg:col-span-6 text-xs text-destructive">
|
||||
{t('bots.unsupportedPipelineEvent')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -311,7 +311,7 @@ export default function DynamicFormComponent({
|
||||
}: {
|
||||
itemConfigList: IDynamicFormItemSchema[];
|
||||
onSubmit?: (val: object) => unknown;
|
||||
initialValues?: Record<string, object>;
|
||||
initialValues?: Record<string, any>;
|
||||
onFileUploaded?: (fileKey: string) => void;
|
||||
isEditing?: boolean;
|
||||
externalDependentValues?: Record<string, unknown>;
|
||||
|
||||
@@ -65,6 +65,51 @@ import SettingsDialog, {
|
||||
SettingsSection,
|
||||
} from '@/app/home/components/settings-dialog/SettingsDialog';
|
||||
|
||||
function getPluginComponentIconURL(value?: string): string | null {
|
||||
if (!value?.startsWith('plugin:')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = value.match(/^plugin:([^/]+)\/([^/]+)(?:\/|$)/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return httpClient.getPluginIconURL(match[1], match[2]);
|
||||
}
|
||||
|
||||
function SelectOptionContent({
|
||||
label,
|
||||
value,
|
||||
showDescription = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
showDescription?: boolean;
|
||||
}) {
|
||||
const iconURL = getPluginComponentIconURL(value);
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{iconURL && (
|
||||
<img
|
||||
src={iconURL}
|
||||
alt=""
|
||||
className="size-5 shrink-0 rounded object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0 flex flex-col">
|
||||
<span className="truncate">{label}</span>
|
||||
{showDescription && (
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{value}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DynamicFormItemComponent({
|
||||
config,
|
||||
field,
|
||||
@@ -378,10 +423,20 @@ export default function DynamicFormItemComponent({
|
||||
);
|
||||
|
||||
case DynamicFormItemType.SELECT:
|
||||
const selectedOption = config.options?.find(
|
||||
(option) => option.name === field.value,
|
||||
);
|
||||
return (
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<Select value={field.value ?? ''} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="w-full max-w-md bg-[#ffffff] dark:bg-[#2a2a2e]">
|
||||
<SelectValue placeholder={t('common.select')} />
|
||||
{selectedOption ? (
|
||||
<SelectOptionContent
|
||||
label={extractI18nObject(selectedOption.label)}
|
||||
value={selectedOption.name}
|
||||
/>
|
||||
) : (
|
||||
<SelectValue placeholder={t('common.select')} />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
@@ -391,7 +446,11 @@ export default function DynamicFormItemComponent({
|
||||
value={option.name}
|
||||
description={option.name}
|
||||
>
|
||||
{extractI18nObject(option.label)}
|
||||
<SelectOptionContent
|
||||
label={extractI18nObject(option.label)}
|
||||
value={option.name}
|
||||
showDescription
|
||||
/>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
|
||||
@@ -195,7 +195,7 @@ const ENTITY_KEY_MAP: Record<
|
||||
// Route prefix map for entity detail pages
|
||||
const ENTITY_ROUTE_MAP: Record<EntityCategoryId, string> = {
|
||||
bots: '/home/bots',
|
||||
pipelines: '/home/pipelines',
|
||||
pipelines: '/home/agents',
|
||||
knowledge: '/home/knowledge',
|
||||
plugins: '/home/extensions',
|
||||
mcp: '/home/mcp',
|
||||
|
||||
@@ -115,9 +115,9 @@ export function SidebarDataProvider({
|
||||
|
||||
const refreshPipelines = useCallback(async () => {
|
||||
try {
|
||||
const resp = await httpClient.getPipelines();
|
||||
const resp = await httpClient.getAgents();
|
||||
setPipelines(
|
||||
resp.pipelines.map((p) => ({
|
||||
resp.agents.map((p) => ({
|
||||
id: p.uuid || '',
|
||||
name: p.name,
|
||||
description: p.description,
|
||||
@@ -126,7 +126,7 @@ export function SidebarDataProvider({
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch pipelines for sidebar:', error);
|
||||
console.error('Failed to fetch agents for sidebar:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -57,10 +57,10 @@ export const sidebarConfigList = [
|
||||
}),
|
||||
new SidebarChildVO({
|
||||
id: 'pipelines',
|
||||
name: t('pipelines.title'),
|
||||
name: t('agents.title'),
|
||||
icon: <Workflow className="text-blue-500" />,
|
||||
route: '/home/pipelines',
|
||||
description: t('pipelines.description'),
|
||||
route: '/home/agents',
|
||||
description: t('agents.description'),
|
||||
helpLink: {
|
||||
en_US: 'https://link.langbot.app/en/docs/pipelines',
|
||||
zh_Hans: 'https://link.langbot.app/zh/docs/pipelines',
|
||||
|
||||
@@ -60,6 +60,7 @@ const EXTENSIONS_ROUTES = [
|
||||
const HOME_TITLE_KEYS: { match: (path: string) => boolean; key: string }[] = [
|
||||
{ match: (p) => p.startsWith('/home/monitoring'), key: 'monitoring.title' },
|
||||
{ match: (p) => p.startsWith('/home/bots'), key: 'bots.title' },
|
||||
{ match: (p) => p.startsWith('/home/agents'), key: 'agents.title' },
|
||||
{ match: (p) => p.startsWith('/home/pipelines'), key: 'pipelines.title' },
|
||||
{
|
||||
match: (p) => p.startsWith('/home/add-extension'),
|
||||
|
||||
@@ -9,7 +9,13 @@ import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataCo
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Settings, Bug, BarChart3 } from 'lucide-react';
|
||||
|
||||
export default function PipelineDetailContent({ id }: { id: string }) {
|
||||
export default function PipelineDetailContent({
|
||||
id,
|
||||
routeBase = '/home/pipelines',
|
||||
}: {
|
||||
id: string;
|
||||
routeBase?: string;
|
||||
}) {
|
||||
const isCreateMode = id === 'new';
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
@@ -36,7 +42,7 @@ export default function PipelineDetailContent({ id }: { id: string }) {
|
||||
|
||||
function handleNewPipelineCreated(newPipelineId: string) {
|
||||
refreshPipelines();
|
||||
navigate(`/home/pipelines?id=${encodeURIComponent(newPipelineId)}`);
|
||||
navigate(`${routeBase}?id=${encodeURIComponent(newPipelineId)}`);
|
||||
}
|
||||
|
||||
// ==================== Create Mode ====================
|
||||
@@ -71,7 +77,7 @@ export default function PipelineDetailContent({ id }: { id: string }) {
|
||||
|
||||
function handleDeletePipeline() {
|
||||
refreshPipelines();
|
||||
navigate('/home/pipelines');
|
||||
navigate(routeBase);
|
||||
}
|
||||
|
||||
// ==================== Edit Mode ====================
|
||||
@@ -132,7 +138,7 @@ export default function PipelineDetailContent({ id }: { id: string }) {
|
||||
onFinish={handleFinish}
|
||||
onNewPipelineCreated={handleNewPipelineCreated}
|
||||
onDeletePipeline={handleDeletePipeline}
|
||||
onCancel={() => navigate('/home/pipelines')}
|
||||
onCancel={() => navigate(routeBase)}
|
||||
onDirtyChange={setFormDirty}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
@@ -325,7 +325,7 @@ export default function PipelineFormComponent({
|
||||
const isFirstEmission = !initializedStagesRef.current.has(stageKey);
|
||||
|
||||
const currentValues =
|
||||
(form.getValues(formName) as Record<string, unknown>) || {};
|
||||
(form.getValues(formName) as Record<string, unknown>) || {};
|
||||
form.setValue(formName, {
|
||||
...currentValues,
|
||||
[stageName]: values,
|
||||
@@ -402,7 +402,7 @@ export default function PipelineFormComponent({
|
||||
<DynamicFormComponent
|
||||
itemConfigList={stage.config}
|
||||
initialValues={
|
||||
(form.watch(formName) as Record<string, unknown>)?.[
|
||||
(form.watch(formName) as Record<string, unknown>)?.[
|
||||
stage.name
|
||||
] || {}
|
||||
}
|
||||
@@ -451,7 +451,7 @@ export default function PipelineFormComponent({
|
||||
<CardContent className="space-y-6">
|
||||
<DynamicFormComponent
|
||||
itemConfigList={stage.config}
|
||||
initialValues={effectiveInitialValues}
|
||||
initialValues={effectiveInitialValues}
|
||||
onSubmit={(values) => {
|
||||
handleRunnerConfigEmit(stage.name, values);
|
||||
}}
|
||||
|
||||
@@ -138,6 +138,45 @@ export interface ApiRespPipelines {
|
||||
pipelines: Pipeline[];
|
||||
}
|
||||
|
||||
export type AgentKind = 'agent' | 'pipeline';
|
||||
|
||||
export interface AgentCapability {
|
||||
supported_event_patterns: string[];
|
||||
message_only: boolean;
|
||||
}
|
||||
|
||||
export interface Agent {
|
||||
uuid?: string;
|
||||
name: string;
|
||||
description: string;
|
||||
emoji?: string;
|
||||
kind: AgentKind;
|
||||
component_ref?: string | null;
|
||||
config?: Record<string, unknown>;
|
||||
enabled?: boolean;
|
||||
supported_event_patterns?: string[];
|
||||
capability?: AgentCapability;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface ApiRespAgents {
|
||||
agents: Agent[];
|
||||
}
|
||||
|
||||
export interface ApiRespAgent {
|
||||
agent: Agent;
|
||||
}
|
||||
|
||||
export interface GetAgentMetadataResponseData {
|
||||
runner_config?: PipelineConfigTab;
|
||||
kinds: Array<{
|
||||
name: AgentKind;
|
||||
supported_event_patterns: string[];
|
||||
message_only: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface Pipeline {
|
||||
uuid?: string;
|
||||
name: string;
|
||||
@@ -167,6 +206,8 @@ export interface Adapter {
|
||||
spec: {
|
||||
categories?: string[];
|
||||
help_links?: Record<string, string>;
|
||||
supported_events?: string[];
|
||||
supported_apis?: string[];
|
||||
config: IDynamicFormItemSchema[];
|
||||
};
|
||||
}
|
||||
@@ -189,6 +230,7 @@ export interface Bot {
|
||||
use_pipeline_name?: string;
|
||||
use_pipeline_uuid?: string;
|
||||
pipeline_routing_rules?: PipelineRoutingRule[];
|
||||
event_bindings?: EventBinding[];
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
adapter_runtime_values?: object;
|
||||
@@ -213,6 +255,18 @@ export interface PipelineRoutingRule {
|
||||
pipeline_uuid: string;
|
||||
}
|
||||
|
||||
export interface EventBinding {
|
||||
id?: string;
|
||||
event_pattern: string;
|
||||
target_type: 'agent' | 'pipeline' | 'discard';
|
||||
target_uuid: string;
|
||||
filters?: Array<Record<string, unknown>>;
|
||||
priority: number;
|
||||
enabled: boolean;
|
||||
description?: string;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export interface ApiRespKnowledgeBases {
|
||||
bases: KnowledgeBase[];
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
ApiRespProviderLLMModel,
|
||||
LLMModel,
|
||||
ApiRespPipelines,
|
||||
ApiRespAgents,
|
||||
ApiRespAgent,
|
||||
Agent,
|
||||
Pipeline,
|
||||
ApiRespPlatformAdapters,
|
||||
ApiRespPlatformAdapter,
|
||||
@@ -22,6 +25,7 @@ import {
|
||||
ApiRespUserToken,
|
||||
GetPipelineResponseData,
|
||||
GetPipelineMetadataResponseData,
|
||||
GetAgentMetadataResponseData,
|
||||
AsyncTask,
|
||||
ApiRespWebChatMessages,
|
||||
ApiRespKnowledgeBases,
|
||||
@@ -227,6 +231,37 @@ export class BackendClient extends BaseHttpClient {
|
||||
}
|
||||
|
||||
// ============ Pipeline API ============
|
||||
public getAgents(
|
||||
sortBy?: string,
|
||||
sortOrder?: string,
|
||||
): Promise<ApiRespAgents> {
|
||||
const params = new URLSearchParams();
|
||||
if (sortBy) params.append('sort_by', sortBy);
|
||||
if (sortOrder) params.append('sort_order', sortOrder);
|
||||
const queryString = params.toString();
|
||||
return this.get(`/api/v1/agents${queryString ? `?${queryString}` : ''}`);
|
||||
}
|
||||
|
||||
public getAgent(uuid: string): Promise<ApiRespAgent> {
|
||||
return this.get(`/api/v1/agents/${uuid}`);
|
||||
}
|
||||
|
||||
public getAgentMetadata(): Promise<GetAgentMetadataResponseData> {
|
||||
return this.get('/api/v1/agents/_/metadata');
|
||||
}
|
||||
|
||||
public createAgent(agent: Agent): Promise<{ uuid: string; kind: string }> {
|
||||
return this.post('/api/v1/agents', agent);
|
||||
}
|
||||
|
||||
public updateAgent(uuid: string, agent: Partial<Agent>): Promise<object> {
|
||||
return this.put(`/api/v1/agents/${uuid}`, agent);
|
||||
}
|
||||
|
||||
public deleteAgent(uuid: string): Promise<object> {
|
||||
return this.delete(`/api/v1/agents/${uuid}`);
|
||||
}
|
||||
|
||||
public getGeneralPipelineMetadata(): Promise<GetPipelineMetadataResponseData> {
|
||||
// as designed, this method will be deprecated, and only for developer to check the prefered config schema
|
||||
return this.get('/api/v1/pipelines/_/metadata');
|
||||
|
||||
@@ -359,6 +359,27 @@ const enUS = {
|
||||
routingConnection: 'Routing & Connection',
|
||||
routingConnectionDescription:
|
||||
'Bind the pipeline that processes messages for this bot',
|
||||
eventOrchestration: 'Event Orchestration',
|
||||
eventOrchestrationDescription:
|
||||
'Bind different handling logic to different bot events. Pipelines only support message events.',
|
||||
eventBindings: 'Event Bindings',
|
||||
addEventBinding: 'Add Event Binding',
|
||||
eventPattern: 'Event',
|
||||
eventPatternPlaceholder: 'Select event',
|
||||
targetType: 'Target Type',
|
||||
target: 'Handling Logic',
|
||||
targetAgent: 'Agent Orchestration',
|
||||
targetPipeline: 'Pipeline',
|
||||
targetDiscard: 'Discard',
|
||||
selectTarget: 'Select handling logic',
|
||||
priority: 'Priority',
|
||||
enabled: 'Enabled',
|
||||
eventBindingDescriptionPlaceholder: 'Rule description',
|
||||
noEventBindings: 'No event bindings',
|
||||
unsupportedPipelineEvent: 'Pipelines can only be used for message.* events',
|
||||
eventCustom: 'Custom event',
|
||||
eventWildcard: 'All events',
|
||||
eventNamespaceWildcard: '{{namespace}}.*',
|
||||
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.',
|
||||
@@ -445,6 +466,50 @@ const enUS = {
|
||||
botMessage: 'Assistant',
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
title: 'Agent',
|
||||
description:
|
||||
'Manage Agent orchestrations and Pipelines, then bind them to bot events',
|
||||
create: 'Create Agent',
|
||||
editAgent: 'Edit Agent Orchestration',
|
||||
selectFromSidebar: 'Select an Agent or Pipeline from the sidebar',
|
||||
agentOrchestration: 'Agent Orchestration',
|
||||
agentOrchestrationDescription:
|
||||
'Event-first handling logic for messages, group members, friends, feedback, and other EBA events.',
|
||||
pipelineType: 'Pipeline',
|
||||
pipelineTypeDescription:
|
||||
'Keep the existing no-code message pipeline for backward compatibility. It only handles message events.',
|
||||
allEvents: 'Supports all EBA events',
|
||||
messageEventsOnly: 'Message events only',
|
||||
basicInfo: 'Basic Information',
|
||||
basicInfoDescription: 'Set the name, icon, description and enabled state',
|
||||
runnerSettings: 'Runner',
|
||||
eventCapability: 'Event Capability',
|
||||
eventCapabilityDescription:
|
||||
'Declare which events this Agent orchestration can be bound to. Use one event pattern per line; * and namespace.* are supported.',
|
||||
supportedEvents: 'Supported Events',
|
||||
supportedEventsDescription:
|
||||
'Examples: *, message.received, group.*. Pipelines are fixed to message.*.',
|
||||
enabled: 'Enable Agent',
|
||||
enabledDescription:
|
||||
'When disabled, this Agent should not be selected by event routing.',
|
||||
nameRequired: 'Name cannot be empty',
|
||||
createSuccess: 'Created successfully',
|
||||
createError: 'Creation failed: ',
|
||||
loadError: 'Load failed: ',
|
||||
saveSuccess: 'Saved successfully',
|
||||
saveError: 'Save failed: ',
|
||||
deleteSuccess: 'Deleted successfully',
|
||||
deleteError: 'Delete failed: ',
|
||||
deleteConfirmation:
|
||||
'Are you sure you want to delete this Agent orchestration?',
|
||||
dangerZone: 'Danger Zone',
|
||||
dangerZoneDescription: 'Irreversible and destructive actions',
|
||||
deleteAgentAction: 'Delete this Agent orchestration',
|
||||
deleteAgentHint:
|
||||
'Once deleted, events bound to it can no longer be executed.',
|
||||
noRunnerMetadata: 'No AgentRunner metadata is currently available.',
|
||||
},
|
||||
plugins: {
|
||||
title: 'Extensions',
|
||||
description:
|
||||
|
||||
@@ -365,6 +365,28 @@ const jaJP = {
|
||||
routingConnection: 'ルーティングと接続',
|
||||
routingConnectionDescription:
|
||||
'このボットのメッセージを処理するパイプラインを紐付け',
|
||||
eventOrchestration: 'イベント編成',
|
||||
eventOrchestrationDescription:
|
||||
'このボットのイベントごとに異なる処理ロジックを紐付けます。Pipeline はメッセージイベントのみ対応します。',
|
||||
eventBindings: 'イベントバインディング',
|
||||
addEventBinding: 'イベントバインディングを追加',
|
||||
eventPattern: 'イベント',
|
||||
eventPatternPlaceholder: 'イベントを選択',
|
||||
targetType: 'ターゲットタイプ',
|
||||
target: '処理ロジック',
|
||||
targetAgent: 'Agent 編成',
|
||||
targetPipeline: 'Pipeline',
|
||||
targetDiscard: '破棄',
|
||||
selectTarget: '処理ロジックを選択',
|
||||
priority: '優先度',
|
||||
enabled: '有効',
|
||||
eventBindingDescriptionPlaceholder: 'ルール説明',
|
||||
noEventBindings: 'イベントバインディングはありません',
|
||||
unsupportedPipelineEvent:
|
||||
'Pipeline は message.* イベントにのみ使用できます',
|
||||
eventCustom: 'カスタムイベント',
|
||||
eventWildcard: 'すべてのイベント',
|
||||
eventNamespaceWildcard: '{{namespace}}.*',
|
||||
routingRules: '条件付きルーティングルール',
|
||||
routingRulesDescription:
|
||||
'ルールは順番に評価され、最初に一致したルールのパイプラインにルーティングされます。一致しない場合はデフォルトパイプラインが使用されます。',
|
||||
@@ -451,6 +473,48 @@ const jaJP = {
|
||||
botMessage: 'アシスタント',
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
title: 'Agent',
|
||||
description: 'Agent 編成と Pipeline を管理し、ボットのイベントに紐付けます',
|
||||
create: 'Agent を作成',
|
||||
editAgent: 'Agent 編成を編集',
|
||||
selectFromSidebar:
|
||||
'サイドバーから Agent または Pipeline を選択してください',
|
||||
agentOrchestration: 'Agent 編成',
|
||||
agentOrchestrationDescription:
|
||||
'メッセージ、グループメンバー、友だち、フィードバックなどの EBA イベント向けの処理ロジックです。',
|
||||
pipelineType: 'Pipeline',
|
||||
pipelineTypeDescription:
|
||||
'既存のノーコードメッセージ Pipeline を互換性のため保持します。メッセージイベントのみ処理できます。',
|
||||
allEvents: 'すべての EBA イベントに対応',
|
||||
messageEventsOnly: 'メッセージイベントのみ',
|
||||
basicInfo: '基本情報',
|
||||
basicInfoDescription: '名前、アイコン、説明、有効状態を設定します',
|
||||
runnerSettings: 'Runner',
|
||||
eventCapability: 'イベント能力',
|
||||
eventCapabilityDescription:
|
||||
'この Agent 編成をどのイベントに紐付けられるかを宣言します。1 行に 1 つのイベントパターンを指定し、* と 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:
|
||||
|
||||
@@ -343,6 +343,27 @@ const zhHans = {
|
||||
basicInfoDescription: '设置机器人名称和描述',
|
||||
routingConnection: '路由与连接',
|
||||
routingConnectionDescription: '绑定处理此机器人消息的流水线',
|
||||
eventOrchestration: '事件编排',
|
||||
eventOrchestrationDescription:
|
||||
'为此机器人不同事件绑定不同处理逻辑。Pipeline 仅支持消息事件。',
|
||||
eventBindings: '事件绑定',
|
||||
addEventBinding: '添加事件绑定',
|
||||
eventPattern: '事件',
|
||||
eventPatternPlaceholder: '选择事件',
|
||||
targetType: '目标类型',
|
||||
target: '处理逻辑',
|
||||
targetAgent: 'Agent 编排',
|
||||
targetPipeline: 'Pipeline',
|
||||
targetDiscard: '丢弃',
|
||||
selectTarget: '选择处理逻辑',
|
||||
priority: '优先级',
|
||||
enabled: '启用',
|
||||
eventBindingDescriptionPlaceholder: '规则说明',
|
||||
noEventBindings: '暂无事件绑定',
|
||||
unsupportedPipelineEvent: 'Pipeline 仅可用于 message.* 事件',
|
||||
eventCustom: '自定义事件',
|
||||
eventWildcard: '全部事件',
|
||||
eventNamespaceWildcard: '{{namespace}}.*',
|
||||
routingRules: '条件路由规则',
|
||||
routingRulesDescription:
|
||||
'按顺序匹配,命中第一条规则后路由到对应流水线;都不匹配时使用上方默认流水线',
|
||||
@@ -427,6 +448,46 @@ const zhHans = {
|
||||
botMessage: '助手',
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
title: 'Agent',
|
||||
description: '管理 Agent 编排与 Pipeline,并将它们绑定到机器人事件',
|
||||
create: '创建 Agent',
|
||||
editAgent: '编辑 Agent 编排',
|
||||
selectFromSidebar: '从侧边栏选择一个 Agent 或 Pipeline',
|
||||
agentOrchestration: 'Agent 编排',
|
||||
agentOrchestrationDescription:
|
||||
'面向 EBA 事件的处理逻辑,可用于消息、群成员、好友、反馈等事件。',
|
||||
pipelineType: 'Pipeline',
|
||||
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: '安装和配置用于扩展功能的插件,请在流水线配置中选用',
|
||||
|
||||
@@ -16,6 +16,7 @@ import SpaceCallbackPage from '@/app/auth/space/callback/page';
|
||||
import HomePage from '@/app/home/page';
|
||||
import MonitoringPage from '@/app/home/monitoring/page';
|
||||
import BotsPage from '@/app/home/bots/page';
|
||||
import AgentsPage from '@/app/home/agents/page';
|
||||
import PipelinesPage from '@/app/home/pipelines/page';
|
||||
import PluginsPage from '@/app/home/plugins/page';
|
||||
import AddExtensionPage from '@/app/home/add-extension/page';
|
||||
@@ -104,6 +105,16 @@ export const router = createBrowserRouter([
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/home/agents',
|
||||
element: (
|
||||
<Suspense fallback={<Loading />}>
|
||||
<HomeLayout>
|
||||
<AgentsPage />
|
||||
</HomeLayout>
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/home/pipelines',
|
||||
element: (
|
||||
|
||||
Reference in New Issue
Block a user