feat(agent): add event orchestration surface

This commit is contained in:
Junyan Qin
2026-06-23 23:23:09 +08:00
parent d7b97741e3
commit ed3598f8ac
35 changed files with 2983 additions and 142 deletions
@@ -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.*` 可选。
### P3EventRouter 执行接入
- EBA 事件先广播插件 observer。
- 然后按 `event_bindings` 的事件模式、filters、priority 和顺序选择 Agent 编排。
- 消息事件继续优先保留现有 Pipeline / MessageAggregator 兼容路径。
- 非消息事件只调用 Agent 编排,不调用 PipelineAgentRunner 输出有平台 reply target 时会投递回平台。
## 7. 不做的事
- 不把 Pipeline 改名成 Agent,也不删除 Pipeline 的配置模型。
- 不把非消息事件伪装成用户文本塞入 Pipeline。
- 不在首版做多 Agent 串并联;需要多步骤处理时留给后续 workflow。
+28 -1
View File
@@ -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",
+2 -1
View File
@@ -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()
+220
View File
@@ -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
+82
View File
@@ -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(
+29 -1
View File
@@ -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:
+3
View File
@@ -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
+4
View File
@@ -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
View File
@@ -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>
</>
);
}
+19
View File
@@ -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',
+1
View File
@@ -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);
}}
+54
View File
@@ -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[];
}
+35
View File
@@ -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');
+65
View File
@@ -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:
+64
View File
@@ -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:
+61
View File
@@ -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: '安装和配置用于扩展功能的插件,请在流水线配置中选用',
+11
View File
@@ -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: (