mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-11 13:20:58 +00:00
feat(agent): add event orchestration surface
This commit is contained in:
committed by
huanghuoguoguo
parent
3dd49a8a6e
commit
59e0edb3d5
@@ -0,0 +1,180 @@
|
|||||||
|
# Agent 页面与事件编排产品设计
|
||||||
|
|
||||||
|
> 状态:实施稿(2026-06-23)
|
||||||
|
>
|
||||||
|
> 本文档修订 [07-agent-orchestration.md](./07-agent-orchestration.md) 中“Agent 替代 Pipeline”的表述。当前产品形态应保留两种同级处理单元:**Agent 编排**与**Pipeline**。Agent 页面是统一入口,但不是把 Pipeline 消除。
|
||||||
|
|
||||||
|
## 1. 产品边界
|
||||||
|
|
||||||
|
LangBot 的处理逻辑分成两种同级形态:
|
||||||
|
|
||||||
|
| 形态 | 定位 | 可处理事件 | 典型用户 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Agent 编排 | 面向 EBA 的事件优先处理单元,承载 AgentRunner / 外部 runner / 后续工作流 | `message.*`、`group.*`、`friend.*`、`bot.*`、`feedback.*`、`platform.*` 等 | 希望按事件类型配置不同智能处理逻辑的用户 |
|
||||||
|
| Pipeline | 现有无代码消息流水线与向后兼容形态 | 仅 `message.*`,首版等价于 `message.received` | 已有 Pipeline 用户、只需要消息处理的用户 |
|
||||||
|
|
||||||
|
Agent 页面负责统一管理这两种处理单元:
|
||||||
|
|
||||||
|
- 创建时选择 **Agent 编排** 或 **Pipeline**;
|
||||||
|
- 列表中清晰标注类型与事件能力;
|
||||||
|
- Pipeline 的编辑、调试、监控继续复用现有能力;
|
||||||
|
- Agent 编排保存 runner 配置与事件能力,并通过 Bot 事件绑定进入运行时执行。
|
||||||
|
|
||||||
|
## 2. 信息架构
|
||||||
|
|
||||||
|
### 2.1 Agent 页面
|
||||||
|
|
||||||
|
路径:`/home/agents`
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
1. 展示所有可被事件绑定的处理单元,包括 Agent 编排与 Pipeline。
|
||||||
|
2. 创建时先选择类型:
|
||||||
|
- Agent 编排:创建一条 Agent 配置对象,默认支持所有 EBA 事件;
|
||||||
|
- Pipeline:创建现有 legacy pipeline,只能处理消息事件。
|
||||||
|
3. 编辑时按类型进入不同表单:
|
||||||
|
- Pipeline:沿用原 Pipeline 配置页,包括 AI、触发、安全、输出、扩展、Debug、Monitoring;
|
||||||
|
- Agent 编排:配置基础信息、runner、runner config 和事件能力。
|
||||||
|
|
||||||
|
`/home/pipelines` 保留为兼容路由,但新导航入口使用 `/home/agents`。
|
||||||
|
|
||||||
|
### 2.2 Bot 的事件编排
|
||||||
|
|
||||||
|
Bot 上维护“事件 -> 处理单元”的绑定规则:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Bot
|
||||||
|
└─ EventBinding[]
|
||||||
|
├─ event_pattern: message.received / group.member_joined / group.* / *
|
||||||
|
├─ target_type: agent / pipeline / discard
|
||||||
|
├─ target_uuid: Agent UUID 或 Pipeline UUID
|
||||||
|
├─ filters: 事件字段过滤条件
|
||||||
|
├─ priority: 数字越大越优先
|
||||||
|
└─ enabled
|
||||||
|
```
|
||||||
|
|
||||||
|
Pipeline 只能被绑定到 `message.*`。如果用户选择非消息事件,目标选择器不展示 Pipeline。
|
||||||
|
|
||||||
|
## 3. 持久化模型
|
||||||
|
|
||||||
|
### 3.1 Agent 编排实例
|
||||||
|
|
||||||
|
新增 `agents` 表,只保存 Agent 编排形态。Pipeline 继续保存在 `legacy_pipelines`。
|
||||||
|
|
||||||
|
```python
|
||||||
|
class Agent(Base):
|
||||||
|
uuid: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
emoji: str
|
||||||
|
kind: str # 首版固定为 "agent"
|
||||||
|
component_ref: str # runner id / workflow id / future external ref
|
||||||
|
config: dict # runner 与 runner_config
|
||||||
|
enabled: bool
|
||||||
|
supported_event_patterns: list[str]
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
```
|
||||||
|
|
||||||
|
Agent 聚合 API 把 `agents` 与 `legacy_pipelines` 投影成同一个前端列表:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"uuid": "...",
|
||||||
|
"name": "...",
|
||||||
|
"kind": "agent | pipeline",
|
||||||
|
"capability": {
|
||||||
|
"supported_event_patterns": ["*"],
|
||||||
|
"message_only": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Pipeline 投影时固定:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": "pipeline",
|
||||||
|
"capability": {
|
||||||
|
"supported_event_patterns": ["message.*"],
|
||||||
|
"message_only": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Bot 事件绑定
|
||||||
|
|
||||||
|
Bot 新增 `event_bindings` JSON 字段,首版作为轻量配置面。后续当 EventRouter 查询、审计和多作用域规则稳定后,再拆成独立表。
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"event_pattern": "group.member_joined",
|
||||||
|
"target_type": "agent",
|
||||||
|
"target_uuid": "...",
|
||||||
|
"filters": [],
|
||||||
|
"priority": 100,
|
||||||
|
"enabled": true,
|
||||||
|
"description": "Welcome new group members"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 匹配规则
|
||||||
|
|
||||||
|
事件模式支持三层:
|
||||||
|
|
||||||
|
1. 精确匹配:`group.member_joined`
|
||||||
|
2. 命名空间通配:`group.*`
|
||||||
|
3. 全局通配:`*`
|
||||||
|
|
||||||
|
优先级:
|
||||||
|
|
||||||
|
1. `enabled = true`
|
||||||
|
2. event pattern 命中
|
||||||
|
3. filters 全部命中
|
||||||
|
4. `priority` 数值高者优先
|
||||||
|
5. 同优先级按列表顺序
|
||||||
|
|
||||||
|
## 5. 兼容策略
|
||||||
|
|
||||||
|
1. 现有 `legacy_pipelines` 不迁移、不改语义。
|
||||||
|
2. 现有 Bot 的 `use_pipeline_uuid` 仍作为消息事件默认 Pipeline。
|
||||||
|
3. 现有 `pipeline_routing_rules` 仍只作用于消息事件。
|
||||||
|
4. 新增 `event_bindings` 是 EBA 事件编排配置,允许 Pipeline 目标但只限 `message.*`。
|
||||||
|
5. `/api/v1/pipelines` 继续存在;新增 `/api/v1/agents` 作为聚合入口。
|
||||||
|
|
||||||
|
## 6. 分阶段落地
|
||||||
|
|
||||||
|
### P0:产品入口统一
|
||||||
|
|
||||||
|
- 新增 `/home/agents`。
|
||||||
|
- 侧边栏显示“Agent”,但列表包含 Agent 编排与 Pipeline。
|
||||||
|
- 创建时选择 Agent 编排或 Pipeline。
|
||||||
|
- `/home/pipelines` 保留兼容。
|
||||||
|
|
||||||
|
### P1:配置模型落地
|
||||||
|
|
||||||
|
- 新增 `agents` 表与 `/api/v1/agents`。
|
||||||
|
- Agent 编排可保存 runner 与 runner_config。
|
||||||
|
- Pipeline 继续使用原 Pipeline 表单与 API。
|
||||||
|
|
||||||
|
### P2:事件编排配置面
|
||||||
|
|
||||||
|
- Bot 表单新增事件编排编辑器。
|
||||||
|
- 读取 adapter manifest 的 `supported_events` 生成事件选项。
|
||||||
|
- 根据事件类型过滤可选目标:Pipeline 仅在 `message.*` 可选。
|
||||||
|
|
||||||
|
### P3:EventRouter 执行接入
|
||||||
|
|
||||||
|
- EBA 事件先广播插件 observer。
|
||||||
|
- 然后按 `event_bindings` 的事件模式、filters、priority 和顺序选择 Agent 编排。
|
||||||
|
- 消息事件继续优先保留现有 Pipeline / MessageAggregator 兼容路径。
|
||||||
|
- 非消息事件只调用 Agent 编排,不调用 Pipeline;AgentRunner 输出有平台 reply target 时会投递回平台。
|
||||||
|
|
||||||
|
## 7. 不做的事
|
||||||
|
|
||||||
|
- 不把 Pipeline 改名成 Agent,也不删除 Pipeline 的配置模型。
|
||||||
|
- 不把非消息事件伪装成用户文本塞入 Pipeline。
|
||||||
|
- 不在首版做多 Agent 串并联;需要多步骤处理时留给后续 workflow。
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
{
|
{
|
||||||
"directory": "langbot-mcp-ops",
|
"directory": "langbot-mcp-ops",
|
||||||
"name": "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": [],
|
"references": [],
|
||||||
"cases": [],
|
"cases": [],
|
||||||
"case_summaries": [],
|
"case_summaries": [],
|
||||||
@@ -134,6 +134,7 @@
|
|||||||
"references/pipeline-debug-chat.md",
|
"references/pipeline-debug-chat.md",
|
||||||
"references/plugin-e2e-smoke.md",
|
"references/plugin-e2e-smoke.md",
|
||||||
"references/sandbox-skill-authoring.md",
|
"references/sandbox-skill-authoring.md",
|
||||||
|
"references/skill-all-tool-acceptance.md",
|
||||||
"references/troubleshooting.md",
|
"references/troubleshooting.md",
|
||||||
"references/web-ui-testing.md"
|
"references/web-ui-testing.md"
|
||||||
],
|
],
|
||||||
@@ -182,6 +183,7 @@
|
|||||||
"qa-plugin-smoke-live-install",
|
"qa-plugin-smoke-live-install",
|
||||||
"sandbox-skill-authoring-e2e",
|
"sandbox-skill-authoring-e2e",
|
||||||
"sandbox-skill-authoring-edit-existing-e2e",
|
"sandbox-skill-authoring-edit-existing-e2e",
|
||||||
|
"skill-discovery-via-mcp-gateway",
|
||||||
"webui-login-state"
|
"webui-login-state"
|
||||||
],
|
],
|
||||||
"case_summaries": [
|
"case_summaries": [
|
||||||
@@ -1387,6 +1389,31 @@
|
|||||||
"filesystem"
|
"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",
|
"id": "webui-login-state",
|
||||||
"title": "Configured frontend opens with authenticated LangBot WebUI state",
|
"title": "Configured frontend opens with authenticated LangBot WebUI state",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: 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".
|
||||||
---
|
---
|
||||||
|
|
||||||
# LangBot MCP Operations
|
# LangBot MCP Operations
|
||||||
@@ -58,6 +58,7 @@ The tools wrap the LangBot service layer. Current tools (v1):
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `get_system_info` | Version, edition, instance id |
|
| `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_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_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_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) |
|
| `list_knowledge_bases` / `get_knowledge_base` / `retrieve_knowledge_base` | RAG knowledge bases (incl. semantic search) |
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import quart
|
||||||
|
|
||||||
|
from .. import group
|
||||||
|
|
||||||
|
|
||||||
|
@group.group_class('agents', '/api/v1/agents')
|
||||||
|
class AgentsRouterGroup(group.RouterGroup):
|
||||||
|
async def initialize(self) -> None:
|
||||||
|
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
|
async def _() -> str:
|
||||||
|
if quart.request.method == 'GET':
|
||||||
|
sort_by = quart.request.args.get('sort_by', 'updated_at')
|
||||||
|
sort_order = quart.request.args.get('sort_order', 'DESC')
|
||||||
|
return self.success(data={'agents': await self.ap.agent_service.get_agents(sort_by, sort_order)})
|
||||||
|
|
||||||
|
json_data = await quart.request.json
|
||||||
|
created = await self.ap.agent_service.create_agent(json_data)
|
||||||
|
return self.success(data=created)
|
||||||
|
|
||||||
|
@self.route('/_/metadata', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
|
async def _() -> str:
|
||||||
|
return self.success(data=await self.ap.agent_service.get_agent_metadata())
|
||||||
|
|
||||||
|
@self.route('/<agent_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
|
async def _(agent_uuid: str) -> str:
|
||||||
|
if quart.request.method == 'GET':
|
||||||
|
agent = await self.ap.agent_service.get_agent(agent_uuid)
|
||||||
|
if agent is None:
|
||||||
|
return self.http_status(404, -1, 'agent not found')
|
||||||
|
return self.success(data={'agent': agent})
|
||||||
|
|
||||||
|
if quart.request.method == 'PUT':
|
||||||
|
json_data = await quart.request.json
|
||||||
|
await self.ap.agent_service.update_agent(agent_uuid, json_data)
|
||||||
|
return self.success()
|
||||||
|
|
||||||
|
await self.ap.agent_service.delete_agent(agent_uuid)
|
||||||
|
return self.success()
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import uuid
|
||||||
|
import typing
|
||||||
|
|
||||||
|
import sqlalchemy
|
||||||
|
|
||||||
|
from ....core import app
|
||||||
|
from ....entity.persistence import agent as persistence_agent
|
||||||
|
|
||||||
|
|
||||||
|
AGENT_KIND_AGENT = 'agent'
|
||||||
|
AGENT_KIND_PIPELINE = 'pipeline'
|
||||||
|
PIPELINE_EVENT_PATTERNS = ['message.*']
|
||||||
|
AGENT_DEFAULT_EVENT_PATTERNS = ['*']
|
||||||
|
|
||||||
|
|
||||||
|
class AgentService:
|
||||||
|
"""Unified product surface for Agent orchestration instances and Pipelines."""
|
||||||
|
|
||||||
|
ap: app.Application
|
||||||
|
|
||||||
|
def __init__(self, ap: app.Application) -> None:
|
||||||
|
self.ap = ap
|
||||||
|
|
||||||
|
async def get_agent_metadata(self) -> dict[str, typing.Any]:
|
||||||
|
"""Return metadata needed by Agent forms."""
|
||||||
|
pipeline_metadata = await self.ap.pipeline_service.get_pipeline_metadata()
|
||||||
|
ai_metadata = next((item for item in pipeline_metadata if item.get('name') == 'ai'), None)
|
||||||
|
return {
|
||||||
|
'runner_config': ai_metadata,
|
||||||
|
'kinds': [
|
||||||
|
{
|
||||||
|
'name': AGENT_KIND_AGENT,
|
||||||
|
'supported_event_patterns': AGENT_DEFAULT_EVENT_PATTERNS,
|
||||||
|
'message_only': False,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': AGENT_KIND_PIPELINE,
|
||||||
|
'supported_event_patterns': PIPELINE_EVENT_PATTERNS,
|
||||||
|
'message_only': True,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_agents(self, sort_by: str = 'updated_at', sort_order: str = 'DESC') -> list[dict]:
|
||||||
|
agents = await self._get_agent_rows()
|
||||||
|
pipelines = await self.ap.pipeline_service.get_pipelines(sort_by='updated_at', sort_order='DESC')
|
||||||
|
|
||||||
|
items = [self._agent_to_product_item(agent) for agent in agents]
|
||||||
|
items.extend(self._pipeline_to_product_item(pipeline) for pipeline in pipelines)
|
||||||
|
|
||||||
|
reverse = sort_order == 'DESC'
|
||||||
|
sort_key = sort_by if sort_by in {'created_at', 'updated_at'} else 'updated_at'
|
||||||
|
return sorted(items, key=lambda item: self._parse_sort_time(item.get(sort_key)), reverse=reverse)
|
||||||
|
|
||||||
|
async def get_agent(self, agent_uuid: str) -> dict | None:
|
||||||
|
agent = await self._get_agent_row(agent_uuid)
|
||||||
|
if agent is not None:
|
||||||
|
return self._agent_to_product_item(agent, include_config=True)
|
||||||
|
|
||||||
|
pipeline = await self.ap.pipeline_service.get_pipeline(agent_uuid)
|
||||||
|
if pipeline is not None:
|
||||||
|
return self._pipeline_to_product_item(pipeline, include_config=True)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def create_agent(self, agent_data: dict) -> dict[str, str]:
|
||||||
|
kind = agent_data.get('kind') or AGENT_KIND_AGENT
|
||||||
|
if kind == AGENT_KIND_PIPELINE:
|
||||||
|
pipeline_uuid = await self.ap.pipeline_service.create_pipeline(
|
||||||
|
{
|
||||||
|
'name': agent_data.get('name') or 'New Pipeline',
|
||||||
|
'description': agent_data.get('description') or '',
|
||||||
|
'emoji': agent_data.get('emoji') or '⚙️',
|
||||||
|
'config': {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {'uuid': pipeline_uuid, 'kind': AGENT_KIND_PIPELINE}
|
||||||
|
|
||||||
|
if kind != AGENT_KIND_AGENT:
|
||||||
|
raise ValueError(f'Unsupported agent kind: {kind}')
|
||||||
|
|
||||||
|
config = agent_data.get('config') or await self._get_default_agent_config()
|
||||||
|
runner_id = self._resolve_runner_id(config)
|
||||||
|
new_uuid = str(uuid.uuid4())
|
||||||
|
values = {
|
||||||
|
'uuid': new_uuid,
|
||||||
|
'name': agent_data.get('name') or 'New Agent',
|
||||||
|
'description': agent_data.get('description') or '',
|
||||||
|
'emoji': agent_data.get('emoji') or '🤖',
|
||||||
|
'kind': AGENT_KIND_AGENT,
|
||||||
|
'component_ref': agent_data.get('component_ref') or runner_id,
|
||||||
|
'config': config,
|
||||||
|
'enabled': agent_data.get('enabled', True),
|
||||||
|
'supported_event_patterns': agent_data.get('supported_event_patterns') or AGENT_DEFAULT_EVENT_PATTERNS,
|
||||||
|
}
|
||||||
|
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_agent.Agent).values(**values))
|
||||||
|
return {'uuid': new_uuid, 'kind': AGENT_KIND_AGENT}
|
||||||
|
|
||||||
|
async def update_agent(self, agent_uuid: str, agent_data: dict) -> None:
|
||||||
|
existing_agent = await self._get_agent_row(agent_uuid)
|
||||||
|
if existing_agent is None:
|
||||||
|
pipeline = await self.ap.pipeline_service.get_pipeline(agent_uuid)
|
||||||
|
if pipeline is None:
|
||||||
|
raise ValueError(f'Agent {agent_uuid} not found')
|
||||||
|
await self.ap.pipeline_service.update_pipeline(agent_uuid, agent_data)
|
||||||
|
return
|
||||||
|
|
||||||
|
update_data = agent_data.copy()
|
||||||
|
for protected_field in ('uuid', 'kind', 'created_at', 'updated_at', 'capability'):
|
||||||
|
update_data.pop(protected_field, None)
|
||||||
|
if 'config' in update_data:
|
||||||
|
update_data['component_ref'] = update_data.get('component_ref') or self._resolve_runner_id(
|
||||||
|
update_data['config']
|
||||||
|
)
|
||||||
|
if 'supported_event_patterns' in update_data and not update_data['supported_event_patterns']:
|
||||||
|
update_data['supported_event_patterns'] = AGENT_DEFAULT_EVENT_PATTERNS
|
||||||
|
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.update(persistence_agent.Agent)
|
||||||
|
.where(persistence_agent.Agent.uuid == agent_uuid)
|
||||||
|
.values(**update_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def delete_agent(self, agent_uuid: str) -> None:
|
||||||
|
existing_agent = await self._get_agent_row(agent_uuid)
|
||||||
|
if existing_agent is not None:
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.delete(persistence_agent.Agent).where(persistence_agent.Agent.uuid == agent_uuid)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
pipeline = await self.ap.pipeline_service.get_pipeline(agent_uuid)
|
||||||
|
if pipeline is None:
|
||||||
|
raise ValueError(f'Agent {agent_uuid} not found')
|
||||||
|
await self.ap.pipeline_service.delete_pipeline(agent_uuid)
|
||||||
|
|
||||||
|
async def _get_agent_rows(self) -> list[persistence_agent.Agent]:
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_agent.Agent))
|
||||||
|
return list(result.all())
|
||||||
|
|
||||||
|
async def _get_agent_row(self, agent_uuid: str) -> persistence_agent.Agent | None:
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(persistence_agent.Agent).where(persistence_agent.Agent.uuid == agent_uuid)
|
||||||
|
)
|
||||||
|
return result.first()
|
||||||
|
|
||||||
|
async def _get_default_agent_config(self) -> dict[str, typing.Any]:
|
||||||
|
runners = []
|
||||||
|
if getattr(self.ap, 'agent_runner_registry', None) is not None:
|
||||||
|
try:
|
||||||
|
runners = await self.ap.agent_runner_registry.list_runners(bound_plugins=None)
|
||||||
|
except Exception as e:
|
||||||
|
if getattr(self.ap, 'logger', None):
|
||||||
|
self.ap.logger.warning(f'Failed to load plugin agent runners for default agent config: {e}')
|
||||||
|
|
||||||
|
if not runners:
|
||||||
|
return {'runner': {'id': '', 'expire-time': 0}, 'runner_config': {}}
|
||||||
|
|
||||||
|
selected_runner = runners[0]
|
||||||
|
return {
|
||||||
|
'runner': {'id': selected_runner.id, 'expire-time': 0},
|
||||||
|
'runner_config': {
|
||||||
|
selected_runner.id: self.ap.pipeline_service._get_default_values_from_schema(
|
||||||
|
selected_runner.config_schema
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_runner_id(config: dict[str, typing.Any]) -> str | None:
|
||||||
|
runner = config.get('runner') if isinstance(config, dict) else None
|
||||||
|
if isinstance(runner, dict):
|
||||||
|
runner_id = runner.get('id')
|
||||||
|
if runner_id:
|
||||||
|
return runner_id
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _agent_to_product_item(
|
||||||
|
self,
|
||||||
|
agent: persistence_agent.Agent,
|
||||||
|
include_config: bool = False,
|
||||||
|
) -> dict[str, typing.Any]:
|
||||||
|
item = self.ap.persistence_mgr.serialize_model(persistence_agent.Agent, agent)
|
||||||
|
item['kind'] = AGENT_KIND_AGENT
|
||||||
|
item['capability'] = {
|
||||||
|
'supported_event_patterns': item.get('supported_event_patterns') or AGENT_DEFAULT_EVENT_PATTERNS,
|
||||||
|
'message_only': False,
|
||||||
|
}
|
||||||
|
if not include_config:
|
||||||
|
item.pop('config', None)
|
||||||
|
return item
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _pipeline_to_product_item(pipeline: dict, include_config: bool = False) -> dict[str, typing.Any]:
|
||||||
|
item = pipeline.copy()
|
||||||
|
item['kind'] = AGENT_KIND_PIPELINE
|
||||||
|
item['component_ref'] = 'pipeline'
|
||||||
|
item['enabled'] = True
|
||||||
|
item['supported_event_patterns'] = PIPELINE_EVENT_PATTERNS
|
||||||
|
item['capability'] = {
|
||||||
|
'supported_event_patterns': PIPELINE_EVENT_PATTERNS,
|
||||||
|
'message_only': True,
|
||||||
|
}
|
||||||
|
if not include_config:
|
||||||
|
item.pop('config', None)
|
||||||
|
return item
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_sort_time(value: typing.Any) -> datetime.datetime:
|
||||||
|
if isinstance(value, datetime.datetime):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
return datetime.datetime.fromisoformat(value)
|
||||||
|
except ValueError:
|
||||||
|
return datetime.datetime.min
|
||||||
|
return datetime.datetime.min
|
||||||
@@ -6,6 +6,7 @@ import typing
|
|||||||
|
|
||||||
from ....core import app
|
from ....core import app
|
||||||
from ....discover import engine
|
from ....discover import engine
|
||||||
|
from ....entity.persistence import agent as persistence_agent
|
||||||
from ....entity.persistence import bot as persistence_bot
|
from ....entity.persistence import bot as persistence_bot
|
||||||
from ....entity.persistence import pipeline as persistence_pipeline
|
from ....entity.persistence import pipeline as persistence_pipeline
|
||||||
|
|
||||||
@@ -36,6 +37,84 @@ class BotService:
|
|||||||
return True
|
return True
|
||||||
return False
|
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]:
|
async def get_bots(self, include_secret: bool = True) -> list[dict]:
|
||||||
"""获取所有机器人"""
|
"""获取所有机器人"""
|
||||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_bot.Bot))
|
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_bot.Bot))
|
||||||
@@ -137,6 +216,9 @@ class BotService:
|
|||||||
if 'uuid' in update_data:
|
if 'uuid' in update_data:
|
||||||
del update_data['uuid']
|
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
|
# set use_pipeline_name
|
||||||
if 'use_pipeline_uuid' in update_data:
|
if 'use_pipeline_uuid' in update_data:
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ if typing.TYPE_CHECKING:
|
|||||||
|
|
||||||
INSTRUCTIONS = """\
|
INSTRUCTIONS = """\
|
||||||
This MCP server manages a LangBot instance. LangBot is an LLM-native instant
|
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.
|
models, knowledge bases, MCP servers, and skills.
|
||||||
|
|
||||||
Authentication uses a LangBot API key (web-UI-created `lbk_...` key or the
|
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)
|
await ap.pipeline_service.delete_pipeline(pipeline_uuid)
|
||||||
return _dump({'ok': True})
|
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 -------------------------------------------------- #
|
# ----- Models -------------------------------------------------- #
|
||||||
@mcp.tool(description='List all configured LLM models. Secrets are redacted.')
|
@mcp.tool(description='List all configured LLM models. Secrets are redacted.')
|
||||||
async def list_llm_models() -> str:
|
async def list_llm_models() -> str:
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from ..api.http.service import space as space_service
|
|||||||
from ..api.http.service import model as model_service
|
from ..api.http.service import model as model_service
|
||||||
from ..api.http.service import provider as provider_service
|
from ..api.http.service import provider as provider_service
|
||||||
from ..api.http.service import pipeline as pipeline_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 bot as bot_service
|
||||||
from ..api.http.service import knowledge as knowledge_service
|
from ..api.http.service import knowledge as knowledge_service
|
||||||
from ..api.http.service import mcp as mcp_service
|
from ..api.http.service import mcp as mcp_service
|
||||||
@@ -147,6 +148,8 @@ class Application:
|
|||||||
|
|
||||||
pipeline_service: pipeline_service.PipelineService = None
|
pipeline_service: pipeline_service.PipelineService = None
|
||||||
|
|
||||||
|
agent_service: agent_service.AgentService = None
|
||||||
|
|
||||||
bot_service: bot_service.BotService = None
|
bot_service: bot_service.BotService = None
|
||||||
|
|
||||||
knowledge_service: knowledge_service.KnowledgeService = None
|
knowledge_service: knowledge_service.KnowledgeService = None
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from ...api.http.service import space as space_service
|
|||||||
from ...api.http.service import model as model_service
|
from ...api.http.service import model as model_service
|
||||||
from ...api.http.service import provider as provider_service
|
from ...api.http.service import provider as provider_service
|
||||||
from ...api.http.service import pipeline as pipeline_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 bot as bot_service
|
||||||
from ...api.http.service import knowledge as knowledge_service
|
from ...api.http.service import knowledge as knowledge_service
|
||||||
from ...api.http.service import mcp as mcp_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)
|
pipeline_service_inst = pipeline_service.PipelineService(ap)
|
||||||
ap.pipeline_service = pipeline_service_inst
|
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)
|
bot_service_inst = bot_service.BotService(ap)
|
||||||
ap.bot_service = bot_service_inst
|
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(),
|
||||||
|
)
|
||||||
@@ -31,6 +31,7 @@ class Bot(Base):
|
|||||||
use_pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
use_pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
use_pipeline_uuid = 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='[]')
|
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())
|
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||||
updated_at = sqlalchemy.Column(
|
updated_at = sqlalchemy.Column(
|
||||||
sqlalchemy.DateTime,
|
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
|
# Import all ORM models so they are registered with Base.metadata
|
||||||
# This is required for autogenerate to detect model changes
|
# This is required for autogenerate to detect model changes
|
||||||
from langbot.pkg.entity.persistence import (
|
from langbot.pkg.entity.persistence import (
|
||||||
|
agent, # noqa: F401
|
||||||
agent_run, # noqa: F401
|
agent_run, # noqa: F401
|
||||||
agent_runner_state, # noqa: F401
|
agent_runner_state, # noqa: F401
|
||||||
apikey, # noqa: F401
|
apikey, # noqa: F401
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""Merge agent runner and MCP migration heads.
|
||||||
|
|
||||||
|
Revision ID: 0007_merge_agent_mcp_heads
|
||||||
|
Revises: 8d3a1f2c4b6e, 0006_normalize_mcp_remote_mode
|
||||||
|
Create Date: 2026-06-23
|
||||||
|
"""
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '0007_merge_agent_mcp_heads'
|
||||||
|
down_revision = ('8d3a1f2c4b6e', '0006_normalize_mcp_remote_mode')
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Add Agent product surface tables.
|
||||||
|
|
||||||
|
Revision ID: 0008_agent_product_surface
|
||||||
|
Revises: 0007_merge_agent_mcp_heads
|
||||||
|
Create Date: 2026-06-23
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = '0008_agent_product_surface'
|
||||||
|
down_revision = '0007_merge_agent_mcp_heads'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
|
||||||
|
return table_name in inspector.get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def _column_exists(inspector: sa.Inspector, table_name: str, column_name: str) -> bool:
|
||||||
|
if not _table_exists(inspector, table_name):
|
||||||
|
return False
|
||||||
|
return any(column['name'] == column_name for column in inspector.get_columns(table_name))
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
|
||||||
|
if not _table_exists(inspector, 'agents'):
|
||||||
|
op.create_table(
|
||||||
|
'agents',
|
||||||
|
sa.Column('uuid', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('description', sa.String(length=255), nullable=False, server_default=''),
|
||||||
|
sa.Column('emoji', sa.String(length=10), nullable=True),
|
||||||
|
sa.Column('kind', sa.String(length=50), nullable=False, server_default='agent'),
|
||||||
|
sa.Column('component_ref', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('config', sa.JSON(), nullable=False, server_default='{}'),
|
||||||
|
sa.Column('enabled', sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column('supported_event_patterns', sa.JSON(), nullable=False, server_default='["*"]'),
|
||||||
|
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('uuid'),
|
||||||
|
sa.UniqueConstraint('uuid'),
|
||||||
|
)
|
||||||
|
|
||||||
|
if _table_exists(inspector, 'bots') and not _column_exists(inspector, 'bots', 'event_bindings'):
|
||||||
|
with op.batch_alter_table('bots') as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('event_bindings', sa.JSON(), nullable=False, server_default='[]'))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
|
||||||
|
if _table_exists(inspector, 'bots') and _column_exists(inspector, 'bots', 'event_bindings'):
|
||||||
|
with op.batch_alter_table('bots') as batch_op:
|
||||||
|
batch_op.drop_column('event_bindings')
|
||||||
|
|
||||||
|
if _table_exists(inspector, 'agents'):
|
||||||
|
op.drop_table('agents')
|
||||||
+692
-118
@@ -3,7 +3,10 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
|
import typing
|
||||||
|
import uuid
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
|
|
||||||
from ..core import app, entities as core_entities, taskmgr
|
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.persistence import pipeline as persistence_pipeline
|
||||||
|
|
||||||
from ..entity.errors import platform as platform_errors
|
from ..entity.errors import platform as platform_errors
|
||||||
|
from ..agent.runner.host_models import (
|
||||||
|
AgentBinding,
|
||||||
|
AgentEventEnvelope,
|
||||||
|
BindingScope,
|
||||||
|
DeliveryPolicy,
|
||||||
|
ResourcePolicy,
|
||||||
|
StatePolicy,
|
||||||
|
)
|
||||||
|
|
||||||
from .logger import EventLogger
|
from .logger import EventLogger
|
||||||
|
|
||||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
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.events as plugin_events
|
||||||
import langbot_plugin.api.entities.builtin.platform.events as platform_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.entities.builtin.platform.message as platform_message
|
||||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
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:
|
class RuntimeBot:
|
||||||
@@ -77,6 +96,7 @@ class RuntimeBot:
|
|||||||
|
|
||||||
PIPELINE_DISCARD = '__discard__'
|
PIPELINE_DISCARD = '__discard__'
|
||||||
PIPELINE_DISCARD_DISPLAY_NAME = 'Discarded'
|
PIPELINE_DISCARD_DISPLAY_NAME = 'Discarded'
|
||||||
|
EVENT_DATA_MAX_STRING_BYTES = 512
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _eba_event_to_plugin_event(event: platform_events.EBAEvent) -> plugin_events.BaseEventModel | None:
|
def _eba_event_to_plugin_event(event: platform_events.EBAEvent) -> plugin_events.BaseEventModel | None:
|
||||||
@@ -103,6 +123,572 @@ class RuntimeBot:
|
|||||||
|
|
||||||
return None
|
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(
|
def resolve_pipeline_uuid(
|
||||||
self,
|
self,
|
||||||
launcher_type: str,
|
launcher_type: str,
|
||||||
@@ -222,128 +808,115 @@ class RuntimeBot:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
await self.logger.error(f'Failed to record discarded message: {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 initialize(self):
|
||||||
async def on_friend_message(
|
async def on_friend_message(
|
||||||
event: platform_events.FriendMessage,
|
event: platform_events.FriendMessage,
|
||||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||||||
):
|
):
|
||||||
image_components = [
|
await self._handle_legacy_message_event(event, adapter)
|
||||||
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')
|
|
||||||
|
|
||||||
async def on_group_message(
|
async def on_group_message(
|
||||||
event: platform_events.GroupMessage,
|
event: platform_events.GroupMessage,
|
||||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||||||
):
|
):
|
||||||
image_components = [
|
await self._handle_legacy_message_event(event, adapter)
|
||||||
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')
|
|
||||||
|
|
||||||
self.adapter.register_listener(platform_events.FriendMessage, on_friend_message)
|
self.adapter.register_listener(platform_events.FriendMessage, on_friend_message)
|
||||||
self.adapter.register_listener(platform_events.GroupMessage, on_group_message)
|
self.adapter.register_listener(platform_events.GroupMessage, on_group_message)
|
||||||
@@ -398,13 +971,14 @@ class RuntimeBot:
|
|||||||
):
|
):
|
||||||
event.bot_uuid = self.bot_entity.uuid
|
event.bot_uuid = self.bot_entity.uuid
|
||||||
plugin_event = self._eba_event_to_plugin_event(event)
|
plugin_event = self._eba_event_to_plugin_event(event)
|
||||||
if plugin_event is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
if plugin_event is not None:
|
||||||
await self.ap.plugin_connector.emit_event(plugin_event)
|
try:
|
||||||
except Exception:
|
await self.ap.plugin_connector.emit_event(plugin_event)
|
||||||
await self.logger.error(f'Failed to dispatch EBA event to plugins: {traceback.format_exc()}')
|
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)
|
self.adapter.register_listener(platform_events.EBAEvent, on_eba_event)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
|
import { Agent } from '@/app/infra/entities/api';
|
||||||
|
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||||
|
import PipelineDetailContent from '@/app/home/pipelines/PipelineDetailContent';
|
||||||
|
import AgentCreateContent from './components/AgentCreateContent';
|
||||||
|
import AgentFormComponent from './components/AgentFormComponent';
|
||||||
|
|
||||||
|
export default function AgentDetailContent({ id }: { id: string }) {
|
||||||
|
const isCreateMode = id === 'new';
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { refreshPipelines, pipelines, setDetailEntityName } = useSidebarData();
|
||||||
|
const [agent, setAgent] = useState<Agent | null>(null);
|
||||||
|
const [loading, setLoading] = useState(!isCreateMode);
|
||||||
|
const [formDirty, setFormDirty] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isCreateMode) {
|
||||||
|
setDetailEntityName(t('agents.create'));
|
||||||
|
return () => setDetailEntityName(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sidebarItem = pipelines.find((p) => p.id === id);
|
||||||
|
setDetailEntityName(sidebarItem?.name ?? id);
|
||||||
|
return () => setDetailEntityName(null);
|
||||||
|
}, [id, isCreateMode, pipelines, setDetailEntityName, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isCreateMode) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
httpClient
|
||||||
|
.getAgent(id)
|
||||||
|
.then((resp) => {
|
||||||
|
if (!cancelled) setAgent(resp.agent);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [id, isCreateMode]);
|
||||||
|
|
||||||
|
if (isCreateMode) {
|
||||||
|
return (
|
||||||
|
<AgentCreateContent
|
||||||
|
onCreated={(newAgentId) => {
|
||||||
|
refreshPipelines();
|
||||||
|
navigate(`/home/agents?id=${encodeURIComponent(newAgentId)}`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading || !agent) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||||
|
{t('common.loading')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (agent.kind === 'pipeline') {
|
||||||
|
return <PipelineDetailContent id={id} routeBase="/home/agents" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="flex items-center justify-between pb-4 shrink-0">
|
||||||
|
<h1 className="text-xl font-semibold">{t('agents.editAgent')}</h1>
|
||||||
|
<Button type="submit" form="agent-form" disabled={!formDirty}>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto min-h-0">
|
||||||
|
<AgentFormComponent
|
||||||
|
agentId={id}
|
||||||
|
onFinish={() => {
|
||||||
|
refreshPipelines();
|
||||||
|
setFormDirty(false);
|
||||||
|
}}
|
||||||
|
onDeleted={() => {
|
||||||
|
refreshPipelines();
|
||||||
|
navigate('/home/agents');
|
||||||
|
}}
|
||||||
|
onDirtyChange={setFormDirty}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import type React from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Bot, Workflow } from 'lucide-react';
|
||||||
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
|
import { AgentKind } from '@/app/infra/entities/api';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@/components/ui/form';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import EmojiPicker from '@/components/ui/emoji-picker';
|
||||||
|
|
||||||
|
export default function AgentCreateContent({
|
||||||
|
onCreated,
|
||||||
|
}: {
|
||||||
|
onCreated: (agentId: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [kind, setKind] = useState<AgentKind>('agent');
|
||||||
|
const formSchema = z.object({
|
||||||
|
name: z.string().min(1, { message: t('agents.nameRequired') }),
|
||||||
|
description: z.string().optional(),
|
||||||
|
emoji: z.string().optional(),
|
||||||
|
});
|
||||||
|
type FormValues = z.infer<typeof formSchema>;
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
emoji: '🤖',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleKindChange(nextKind: AgentKind) {
|
||||||
|
setKind(nextKind);
|
||||||
|
if (!form.getValues('emoji')) {
|
||||||
|
form.setValue('emoji', nextKind === 'pipeline' ? '⚙️' : '🤖');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit(values: FormValues) {
|
||||||
|
httpClient
|
||||||
|
.createAgent({
|
||||||
|
kind,
|
||||||
|
name: values.name,
|
||||||
|
description: values.description ?? '',
|
||||||
|
emoji: values.emoji || (kind === 'pipeline' ? '⚙️' : '🤖'),
|
||||||
|
})
|
||||||
|
.then((resp) => {
|
||||||
|
toast.success(t('agents.createSuccess'));
|
||||||
|
onCreated(resp.uuid);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast.error(t('agents.createError') + err.msg);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeOptions: Array<{
|
||||||
|
kind: AgentKind;
|
||||||
|
icon: React.ElementType;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
badge: string;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
kind: 'agent',
|
||||||
|
icon: Bot,
|
||||||
|
title: t('agents.agentOrchestration'),
|
||||||
|
description: t('agents.agentOrchestrationDescription'),
|
||||||
|
badge: t('agents.allEvents'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'pipeline',
|
||||||
|
icon: Workflow,
|
||||||
|
title: t('agents.pipelineType'),
|
||||||
|
description: t('agents.pipelineTypeDescription'),
|
||||||
|
badge: t('agents.messageEventsOnly'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="flex items-center justify-between pb-4 shrink-0">
|
||||||
|
<h1 className="text-xl font-semibold">{t('agents.create')}</h1>
|
||||||
|
<Button type="submit" form="agent-create-form">
|
||||||
|
{t('common.submit')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto min-h-0">
|
||||||
|
<div className="mx-auto max-w-2xl space-y-6">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{typeOptions.map((option) => {
|
||||||
|
const Icon = option.icon;
|
||||||
|
const selected = kind === option.kind;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.kind}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleKindChange(option.kind)}
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border bg-card p-4 text-left transition-colors',
|
||||||
|
selected
|
||||||
|
? 'border-primary ring-2 ring-primary/20'
|
||||||
|
: 'hover:border-primary/60',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Icon className="mt-0.5 size-5 text-blue-500" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="font-medium">{option.title}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{option.badge}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{option.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t('agents.basicInfo')}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t('agents.basicInfoDescription')}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
id="agent-create-form"
|
||||||
|
onSubmit={form.handleSubmit(handleSubmit)}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<div className="flex gap-4 items-start">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1">
|
||||||
|
<FormLabel>
|
||||||
|
{t('common.name')}
|
||||||
|
<span className="text-destructive">*</span>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} value={field.value ?? ''} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="emoji"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t('common.icon')}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<EmojiPicker
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t('common.description')}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} value={field.value ?? ''} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,509 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import type React from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Brain, FileJson2, Info, Power, Trash2 } from 'lucide-react';
|
||||||
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
|
import { Agent } from '@/app/infra/entities/api';
|
||||||
|
import {
|
||||||
|
PipelineConfigStage,
|
||||||
|
PipelineConfigTab,
|
||||||
|
} from '@/app/infra/entities/pipeline';
|
||||||
|
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
|
||||||
|
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import EmojiPicker from '@/components/ui/emoji-picker';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@/components/ui/form';
|
||||||
|
|
||||||
|
interface AgentFormComponentProps {
|
||||||
|
agentId: string;
|
||||||
|
onFinish: () => void;
|
||||||
|
onDeleted: () => void;
|
||||||
|
onDirtyChange?: (dirty: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SectionItem {
|
||||||
|
label: string;
|
||||||
|
name: 'basic' | 'runner' | 'events';
|
||||||
|
icon: React.ElementType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AgentFormComponent({
|
||||||
|
agentId,
|
||||||
|
onFinish,
|
||||||
|
onDeleted,
|
||||||
|
onDirtyChange,
|
||||||
|
}: AgentFormComponentProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [activeSection, setActiveSection] =
|
||||||
|
useState<SectionItem['name']>('basic');
|
||||||
|
const [runnerConfigSchema, setRunnerConfigSchema] =
|
||||||
|
useState<PipelineConfigTab | null>(null);
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
basic: z.object({
|
||||||
|
name: z.string().min(1, { message: t('agents.nameRequired') }),
|
||||||
|
description: z.string().optional(),
|
||||||
|
emoji: z.string().optional(),
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
|
}),
|
||||||
|
runner: z.record(z.string(), z.any()),
|
||||||
|
runner_config: z.record(z.string(), z.any()),
|
||||||
|
supported_event_patterns_text: z.string(),
|
||||||
|
});
|
||||||
|
type FormValues = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
basic: {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
emoji: '🤖',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
runner: {},
|
||||||
|
runner_config: {},
|
||||||
|
supported_event_patterns_text: '*',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const savedSnapshotRef = useRef('');
|
||||||
|
const initializedStagesRef = useRef<Set<string>>(new Set());
|
||||||
|
const watchedValues = form.watch();
|
||||||
|
const hasUnsavedChanges = useMemo(() => {
|
||||||
|
if (!savedSnapshotRef.current) return false;
|
||||||
|
return JSON.stringify(watchedValues) !== savedSnapshotRef.current;
|
||||||
|
}, [watchedValues]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onDirtyChange?.(hasUnsavedChanges);
|
||||||
|
}, [hasUnsavedChanges, onDirtyChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
Promise.all([httpClient.getAgentMetadata(), httpClient.getAgent(agentId)])
|
||||||
|
.then(([metadata, resp]) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setRunnerConfigSchema(metadata.runner_config ?? null);
|
||||||
|
const agent = resp.agent;
|
||||||
|
const config = (agent.config ?? {}) as Record<string, any>;
|
||||||
|
const loadedValues: FormValues = {
|
||||||
|
basic: {
|
||||||
|
name: agent.name ?? '',
|
||||||
|
description: agent.description ?? '',
|
||||||
|
emoji: agent.emoji || '🤖',
|
||||||
|
enabled: agent.enabled ?? true,
|
||||||
|
},
|
||||||
|
runner: (config.runner as Record<string, unknown>) ?? {},
|
||||||
|
runner_config:
|
||||||
|
(config.runner_config as Record<string, unknown>) ?? {},
|
||||||
|
supported_event_patterns_text: (
|
||||||
|
agent.supported_event_patterns ??
|
||||||
|
agent.capability?.supported_event_patterns ?? ['*']
|
||||||
|
).join('\n'),
|
||||||
|
};
|
||||||
|
form.reset(loadedValues);
|
||||||
|
savedSnapshotRef.current = JSON.stringify(loadedValues);
|
||||||
|
initializedStagesRef.current.clear();
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast.error(t('agents.loadError') + err.msg);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [agentId, form, t]);
|
||||||
|
|
||||||
|
const sections: SectionItem[] = [
|
||||||
|
{ label: t('agents.basicInfo'), name: 'basic', icon: Info },
|
||||||
|
{ label: t('agents.runnerSettings'), name: 'runner', icon: Brain },
|
||||||
|
{ label: t('agents.eventCapability'), name: 'events', icon: FileJson2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const currentRunner = (form.watch('runner') as Record<string, any>)?.id;
|
||||||
|
|
||||||
|
function updateSnapshotIfInitial(stageKey: string) {
|
||||||
|
if (!initializedStagesRef.current.has(stageKey)) {
|
||||||
|
initializedStagesRef.current.add(stageKey);
|
||||||
|
if (!hasUnsavedChanges) {
|
||||||
|
savedSnapshotRef.current = JSON.stringify(form.getValues());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDynamicFormEmit(
|
||||||
|
formName: 'runner' | 'runner_config',
|
||||||
|
stageName: string,
|
||||||
|
values: object,
|
||||||
|
) {
|
||||||
|
if (formName === 'runner') {
|
||||||
|
form.setValue('runner', values, { shouldDirty: true });
|
||||||
|
updateSnapshotIfInitial(`runner.${stageName}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentRunnerConfigs =
|
||||||
|
(form.getValues('runner_config') as Record<string, unknown>) || {};
|
||||||
|
form.setValue(
|
||||||
|
'runner_config',
|
||||||
|
{
|
||||||
|
...currentRunnerConfigs,
|
||||||
|
[stageName]: values,
|
||||||
|
},
|
||||||
|
{ shouldDirty: true },
|
||||||
|
);
|
||||||
|
updateSnapshotIfInitial(`runner_config.${stageName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDynamicStage(stage: PipelineConfigStage) {
|
||||||
|
const isRunnerSelector = stage.name === 'runner';
|
||||||
|
if (!isRunnerSelector && stage.name !== currentRunner) return null;
|
||||||
|
|
||||||
|
const initialValues = isRunnerSelector
|
||||||
|
? (form.watch('runner') as Record<string, unknown>) || {}
|
||||||
|
: ((form.watch('runner_config') as Record<string, any>) || {})[
|
||||||
|
stage.name
|
||||||
|
] || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={stage.name}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{extractI18nObject(stage.label)}</CardTitle>
|
||||||
|
{stage.description && (
|
||||||
|
<CardDescription>
|
||||||
|
{extractI18nObject(stage.description)}
|
||||||
|
</CardDescription>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<DynamicFormComponent
|
||||||
|
itemConfigList={stage.config}
|
||||||
|
initialValues={initialValues}
|
||||||
|
onSubmit={(values) =>
|
||||||
|
handleDynamicFormEmit(
|
||||||
|
isRunnerSelector ? 'runner' : 'runner_config',
|
||||||
|
stage.name,
|
||||||
|
values,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEventPatterns(value: string): string[] {
|
||||||
|
const patterns = value
|
||||||
|
.split(/[\n,]/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
return patterns.length > 0 ? patterns : ['*'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit(values: FormValues) {
|
||||||
|
const runner = values.runner || {};
|
||||||
|
const agent: Partial<Agent> = {
|
||||||
|
name: values.basic.name,
|
||||||
|
description: values.basic.description ?? '',
|
||||||
|
emoji: values.basic.emoji,
|
||||||
|
enabled: values.basic.enabled ?? true,
|
||||||
|
component_ref: (runner.id as string) || null,
|
||||||
|
supported_event_patterns: normalizeEventPatterns(
|
||||||
|
values.supported_event_patterns_text,
|
||||||
|
),
|
||||||
|
config: {
|
||||||
|
runner,
|
||||||
|
runner_config: values.runner_config ?? {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
httpClient
|
||||||
|
.updateAgent(agentId, agent)
|
||||||
|
.then(() => {
|
||||||
|
const snapshotValues = form.getValues();
|
||||||
|
savedSnapshotRef.current = JSON.stringify(snapshotValues);
|
||||||
|
onFinish();
|
||||||
|
toast.success(t('agents.saveSuccess'));
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast.error(t('agents.saveError') + err.msg);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete() {
|
||||||
|
httpClient
|
||||||
|
.deleteAgent(agentId)
|
||||||
|
.then(() => {
|
||||||
|
toast.success(t('agents.deleteSuccess'));
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
|
onDeleted();
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast.error(t('agents.deleteError') + err.msg);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="h-full p-0 flex flex-col">
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
id="agent-form"
|
||||||
|
onSubmit={form.handleSubmit(handleSubmit)}
|
||||||
|
className="h-full flex flex-col flex-1 min-h-0 mb-2"
|
||||||
|
>
|
||||||
|
<div className="flex-1 flex flex-col md:flex-row min-h-0">
|
||||||
|
<nav className="shrink-0 mb-4 md:mb-0 md:w-44 md:pr-4 md:mr-4 md:border-r overflow-x-auto md:overflow-x-visible md:overflow-y-auto">
|
||||||
|
<ul className="flex md:flex-col gap-1 md:space-y-1">
|
||||||
|
{sections.map((section) => {
|
||||||
|
const Icon = section.icon;
|
||||||
|
return (
|
||||||
|
<li key={section.name}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveSection(section.name)}
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors text-left cursor-pointer whitespace-nowrap',
|
||||||
|
activeSection === section.name
|
||||||
|
? 'bg-accent text-accent-foreground'
|
||||||
|
: 'text-muted-foreground hover:bg-muted hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="size-4 shrink-0" />
|
||||||
|
{section.label}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto min-h-0">
|
||||||
|
{activeSection === 'basic' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t('agents.basicInfo')}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t('agents.basicInfoDescription')}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex gap-4 items-start">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="basic.name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1">
|
||||||
|
<FormLabel>
|
||||||
|
{t('common.name')}
|
||||||
|
<span className="text-destructive">*</span>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} value={field.value ?? ''} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="basic.emoji"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t('common.icon')}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<EmojiPicker
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="basic.description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t('common.description')}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} value={field.value ?? ''} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="basic.enabled"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex items-center justify-between rounded-lg border p-4">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel className="flex items-center gap-2">
|
||||||
|
<Power className="size-4" />
|
||||||
|
{t('agents.enabled')}
|
||||||
|
</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
{t('agents.enabledDescription')}
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value ?? true}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-destructive/50">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-destructive">
|
||||||
|
{t('agents.dangerZone')}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t('agents.dangerZoneDescription')}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{t('agents.deleteAgentAction')}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('agents.deleteAgentHint')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4 mr-1.5" />
|
||||||
|
{t('common.delete')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeSection === 'runner' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{runnerConfigSchema?.stages.map((stage) =>
|
||||||
|
renderDynamicStage(stage),
|
||||||
|
)}
|
||||||
|
{!runnerConfigSchema && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t('agents.runnerSettings')}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t('agents.noRunnerMetadata')}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeSection === 'events' && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t('agents.eventCapability')}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t('agents.eventCapabilityDescription')}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="supported_event_patterns_text"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t('agents.supportedEvents')}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
{...field}
|
||||||
|
className="min-h-32 font-mono text-sm"
|
||||||
|
placeholder={'*\nmessage.received\ngroup.*'}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{t('agents.supportedEventsDescription')}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('common.confirmDelete')}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-4">{t('agents.deleteConfirmation')}</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowDeleteConfirm(false)}
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={confirmDelete}>
|
||||||
|
{t('common.confirmDelete')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import AgentDetailContent from './AgentDetailContent';
|
||||||
|
|
||||||
|
export default function AgentsPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const detailId = searchParams.get('id');
|
||||||
|
|
||||||
|
if (detailId) {
|
||||||
|
return <AgentDetailContent id={detailId} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||||
|
<p>{t('agents.selectFromSidebar')}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,10 +14,11 @@ import { UUID } from 'uuidjs';
|
|||||||
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
|
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import { systemInfo } from '@/app/infra/http';
|
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 { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs';
|
||||||
import { ExternalLink } from 'lucide-react';
|
import { ExternalLink } from 'lucide-react';
|
||||||
import RoutingRulesEditor from './RoutingRulesEditor';
|
import RoutingRulesEditor from './RoutingRulesEditor';
|
||||||
|
import EventBindingsEditor from './EventBindingsEditor';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
@@ -88,6 +89,21 @@ const getFormSchema = (t: (key: string) => string) =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.optional(),
|
.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({
|
export default function BotForm({
|
||||||
@@ -114,6 +130,7 @@ export default function BotForm({
|
|||||||
enable: true,
|
enable: true,
|
||||||
use_pipeline_uuid: '',
|
use_pipeline_uuid: '',
|
||||||
pipeline_routing_rules: [],
|
pipeline_routing_rules: [],
|
||||||
|
event_bindings: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -133,10 +150,14 @@ export default function BotForm({
|
|||||||
const [adapterHelpLinks, setAdapterHelpLinks] = useState<
|
const [adapterHelpLinks, setAdapterHelpLinks] = useState<
|
||||||
Record<string, Record<string, string>>
|
Record<string, Record<string, string>>
|
||||||
>({});
|
>({});
|
||||||
|
const [adapterSupportedEvents, setAdapterSupportedEvents] = useState<
|
||||||
|
Record<string, string[]>
|
||||||
|
>({});
|
||||||
|
|
||||||
const [pipelineNameList, setPipelineNameList] = useState<IPipelineEntity[]>(
|
const [pipelineNameList, setPipelineNameList] = useState<IPipelineEntity[]>(
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
const [agentNameList, setAgentNameList] = useState<Agent[]>([]);
|
||||||
|
|
||||||
const [dynamicFormConfigList, setDynamicFormConfigList] = useState<
|
const [dynamicFormConfigList, setDynamicFormConfigList] = useState<
|
||||||
IDynamicFormItemSchema[]
|
IDynamicFormItemSchema[]
|
||||||
@@ -181,6 +202,7 @@ export default function BotForm({
|
|||||||
enable: val.enable,
|
enable: val.enable,
|
||||||
use_pipeline_uuid: val.use_pipeline_uuid || '',
|
use_pipeline_uuid: val.use_pipeline_uuid || '',
|
||||||
pipeline_routing_rules: val.pipeline_routing_rules || [],
|
pipeline_routing_rules: val.pipeline_routing_rules || [],
|
||||||
|
event_bindings: val.event_bindings || [],
|
||||||
});
|
});
|
||||||
handleAdapterSelect(val.adapter);
|
handleAdapterSelect(val.adapter);
|
||||||
|
|
||||||
@@ -220,6 +242,9 @@ export default function BotForm({
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const agentsRes = await httpClient.getAgents();
|
||||||
|
setAgentNameList(agentsRes.agents);
|
||||||
|
|
||||||
const adaptersRes = await httpClient.getAdapters();
|
const adaptersRes = await httpClient.getAdapters();
|
||||||
setAdapterNameList(
|
setAdapterNameList(
|
||||||
adaptersRes.adapters.map((item) => {
|
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) => {
|
adaptersRes.adapters.forEach((rawAdapter) => {
|
||||||
adapterNameToDynamicConfigMap.set(
|
adapterNameToDynamicConfigMap.set(
|
||||||
rawAdapter.name,
|
rawAdapter.name,
|
||||||
@@ -298,6 +333,7 @@ export default function BotForm({
|
|||||||
enable: bot.enable ?? true,
|
enable: bot.enable ?? true,
|
||||||
use_pipeline_uuid: bot.use_pipeline_uuid ?? '',
|
use_pipeline_uuid: bot.use_pipeline_uuid ?? '',
|
||||||
pipeline_routing_rules: bot.pipeline_routing_rules ?? [],
|
pipeline_routing_rules: bot.pipeline_routing_rules ?? [],
|
||||||
|
event_bindings: bot.event_bindings ?? [],
|
||||||
webhook_full_url: runtimeValues?.webhook_full_url as
|
webhook_full_url: runtimeValues?.webhook_full_url as
|
||||||
| string
|
| string
|
||||||
| undefined,
|
| undefined,
|
||||||
@@ -343,6 +379,7 @@ export default function BotForm({
|
|||||||
enable: form.getValues().enable,
|
enable: form.getValues().enable,
|
||||||
use_pipeline_uuid: form.getValues().use_pipeline_uuid,
|
use_pipeline_uuid: form.getValues().use_pipeline_uuid,
|
||||||
pipeline_routing_rules: form.getValues().pipeline_routing_rules ?? [],
|
pipeline_routing_rules: form.getValues().pipeline_routing_rules ?? [],
|
||||||
|
event_bindings: form.getValues().event_bindings ?? [],
|
||||||
};
|
};
|
||||||
httpClient
|
httpClient
|
||||||
.updateBot(initBotId, updateBot)
|
.updateBot(initBotId, updateBot)
|
||||||
@@ -503,7 +540,26 @@ export default function BotForm({
|
|||||||
</Card>
|
</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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('bots.adapterConfig')}</CardTitle>
|
<CardTitle>{t('bots.adapterConfig')}</CardTitle>
|
||||||
@@ -561,7 +617,10 @@ export default function BotForm({
|
|||||||
</SelectLabel>
|
</SelectLabel>
|
||||||
)}
|
)}
|
||||||
{group.items.map((item) => (
|
{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">
|
<div className="flex items-center gap-2">
|
||||||
<img
|
<img
|
||||||
src={httpClient.getAdapterIconURL(
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -400,7 +400,7 @@ export default function DynamicFormComponent({
|
|||||||
}: {
|
}: {
|
||||||
itemConfigList: IDynamicFormItemSchema[];
|
itemConfigList: IDynamicFormItemSchema[];
|
||||||
onSubmit?: (val: object) => unknown;
|
onSubmit?: (val: object) => unknown;
|
||||||
initialValues?: Record<string, object>;
|
initialValues?: Record<string, any>;
|
||||||
onFileUploaded?: (fileKey: string) => void;
|
onFileUploaded?: (fileKey: string) => void;
|
||||||
isEditing?: boolean;
|
isEditing?: boolean;
|
||||||
externalDependentValues?: Record<string, unknown>;
|
externalDependentValues?: Record<string, unknown>;
|
||||||
|
|||||||
@@ -77,6 +77,51 @@ function hasUsableOptionName(option: { name?: string | null }): boolean {
|
|||||||
return typeof option.name === 'string' && option.name.trim().length > 0;
|
return typeof option.name === 'string' && option.name.trim().length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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({
|
export default function DynamicFormItemComponent({
|
||||||
config,
|
config,
|
||||||
field,
|
field,
|
||||||
@@ -416,10 +461,20 @@ export default function DynamicFormItemComponent({
|
|||||||
);
|
);
|
||||||
|
|
||||||
case DynamicFormItemType.SELECT:
|
case DynamicFormItemType.SELECT:
|
||||||
|
const selectedOption = config.options?.find(
|
||||||
|
(option) => option.name === field.value,
|
||||||
|
);
|
||||||
return (
|
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]">
|
<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>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
@@ -429,7 +484,11 @@ export default function DynamicFormItemComponent({
|
|||||||
value={option.name}
|
value={option.name}
|
||||||
description={option.name}
|
description={option.name}
|
||||||
>
|
>
|
||||||
{extractI18nObject(option.label)}
|
<SelectOptionContent
|
||||||
|
label={extractI18nObject(option.label)}
|
||||||
|
value={option.name}
|
||||||
|
showDescription
|
||||||
|
/>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ const ENTITY_KEY_MAP: Record<
|
|||||||
// Route prefix map for entity detail pages
|
// Route prefix map for entity detail pages
|
||||||
const ENTITY_ROUTE_MAP: Record<EntityCategoryId, string> = {
|
const ENTITY_ROUTE_MAP: Record<EntityCategoryId, string> = {
|
||||||
bots: '/home/bots',
|
bots: '/home/bots',
|
||||||
pipelines: '/home/pipelines',
|
pipelines: '/home/agents',
|
||||||
knowledge: '/home/knowledge',
|
knowledge: '/home/knowledge',
|
||||||
plugins: '/home/extensions',
|
plugins: '/home/extensions',
|
||||||
mcp: '/home/mcp',
|
mcp: '/home/mcp',
|
||||||
|
|||||||
@@ -115,9 +115,9 @@ export function SidebarDataProvider({
|
|||||||
|
|
||||||
const refreshPipelines = useCallback(async () => {
|
const refreshPipelines = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const resp = await httpClient.getPipelines();
|
const resp = await httpClient.getAgents();
|
||||||
setPipelines(
|
setPipelines(
|
||||||
resp.pipelines.map((p) => ({
|
resp.agents.map((p) => ({
|
||||||
id: p.uuid || '',
|
id: p.uuid || '',
|
||||||
name: p.name,
|
name: p.name,
|
||||||
description: p.description,
|
description: p.description,
|
||||||
@@ -126,7 +126,7 @@ export function SidebarDataProvider({
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} 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({
|
new SidebarChildVO({
|
||||||
id: 'pipelines',
|
id: 'pipelines',
|
||||||
name: t('pipelines.title'),
|
name: t('agents.title'),
|
||||||
icon: <Workflow className="text-blue-500" />,
|
icon: <Workflow className="text-blue-500" />,
|
||||||
route: '/home/pipelines',
|
route: '/home/agents',
|
||||||
description: t('pipelines.description'),
|
description: t('agents.description'),
|
||||||
helpLink: {
|
helpLink: {
|
||||||
en_US: 'https://link.langbot.app/en/docs/pipelines',
|
en_US: 'https://link.langbot.app/en/docs/pipelines',
|
||||||
zh_Hans: 'https://link.langbot.app/zh/docs/pipelines',
|
zh_Hans: 'https://link.langbot.app/zh/docs/pipelines',
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ const EXTENSIONS_ROUTES = [
|
|||||||
const HOME_TITLE_KEYS: { match: (path: string) => boolean; key: string }[] = [
|
const HOME_TITLE_KEYS: { match: (path: string) => boolean; key: string }[] = [
|
||||||
{ match: (p) => p.startsWith('/home/monitoring'), key: 'monitoring.title' },
|
{ match: (p) => p.startsWith('/home/monitoring'), key: 'monitoring.title' },
|
||||||
{ match: (p) => p.startsWith('/home/bots'), key: 'bots.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/pipelines'), key: 'pipelines.title' },
|
||||||
{
|
{
|
||||||
match: (p) => p.startsWith('/home/add-extension'),
|
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 { useTranslation } from 'react-i18next';
|
||||||
import { Settings, Bug, BarChart3 } from 'lucide-react';
|
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 isCreateMode = id === 'new';
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -36,7 +42,7 @@ export default function PipelineDetailContent({ id }: { id: string }) {
|
|||||||
|
|
||||||
function handleNewPipelineCreated(newPipelineId: string) {
|
function handleNewPipelineCreated(newPipelineId: string) {
|
||||||
refreshPipelines();
|
refreshPipelines();
|
||||||
navigate(`/home/pipelines?id=${encodeURIComponent(newPipelineId)}`);
|
navigate(`${routeBase}?id=${encodeURIComponent(newPipelineId)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Create Mode ====================
|
// ==================== Create Mode ====================
|
||||||
@@ -71,7 +77,7 @@ export default function PipelineDetailContent({ id }: { id: string }) {
|
|||||||
|
|
||||||
function handleDeletePipeline() {
|
function handleDeletePipeline() {
|
||||||
refreshPipelines();
|
refreshPipelines();
|
||||||
navigate('/home/pipelines');
|
navigate(routeBase);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Edit Mode ====================
|
// ==================== Edit Mode ====================
|
||||||
@@ -132,7 +138,7 @@ export default function PipelineDetailContent({ id }: { id: string }) {
|
|||||||
onFinish={handleFinish}
|
onFinish={handleFinish}
|
||||||
onNewPipelineCreated={handleNewPipelineCreated}
|
onNewPipelineCreated={handleNewPipelineCreated}
|
||||||
onDeletePipeline={handleDeletePipeline}
|
onDeletePipeline={handleDeletePipeline}
|
||||||
onCancel={() => navigate('/home/pipelines')}
|
onCancel={() => navigate(routeBase)}
|
||||||
onDirtyChange={setFormDirty}
|
onDirtyChange={setFormDirty}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -138,6 +138,45 @@ export interface ApiRespPipelines {
|
|||||||
pipelines: Pipeline[];
|
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 {
|
export interface Pipeline {
|
||||||
uuid?: string;
|
uuid?: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -167,6 +206,8 @@ export interface Adapter {
|
|||||||
spec: {
|
spec: {
|
||||||
categories?: string[];
|
categories?: string[];
|
||||||
help_links?: Record<string, string>;
|
help_links?: Record<string, string>;
|
||||||
|
supported_events?: string[];
|
||||||
|
supported_apis?: string[];
|
||||||
config: IDynamicFormItemSchema[];
|
config: IDynamicFormItemSchema[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -189,6 +230,7 @@ export interface Bot {
|
|||||||
use_pipeline_name?: string;
|
use_pipeline_name?: string;
|
||||||
use_pipeline_uuid?: string;
|
use_pipeline_uuid?: string;
|
||||||
pipeline_routing_rules?: PipelineRoutingRule[];
|
pipeline_routing_rules?: PipelineRoutingRule[];
|
||||||
|
event_bindings?: EventBinding[];
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
adapter_runtime_values?: object;
|
adapter_runtime_values?: object;
|
||||||
@@ -213,6 +255,18 @@ export interface PipelineRoutingRule {
|
|||||||
pipeline_uuid: string;
|
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 {
|
export interface ApiRespKnowledgeBases {
|
||||||
bases: KnowledgeBase[];
|
bases: KnowledgeBase[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import {
|
|||||||
ApiRespProviderLLMModel,
|
ApiRespProviderLLMModel,
|
||||||
LLMModel,
|
LLMModel,
|
||||||
ApiRespPipelines,
|
ApiRespPipelines,
|
||||||
|
ApiRespAgents,
|
||||||
|
ApiRespAgent,
|
||||||
|
Agent,
|
||||||
Pipeline,
|
Pipeline,
|
||||||
ApiRespPlatformAdapters,
|
ApiRespPlatformAdapters,
|
||||||
ApiRespPlatformAdapter,
|
ApiRespPlatformAdapter,
|
||||||
@@ -22,6 +25,7 @@ import {
|
|||||||
ApiRespUserToken,
|
ApiRespUserToken,
|
||||||
GetPipelineResponseData,
|
GetPipelineResponseData,
|
||||||
GetPipelineMetadataResponseData,
|
GetPipelineMetadataResponseData,
|
||||||
|
GetAgentMetadataResponseData,
|
||||||
AsyncTask,
|
AsyncTask,
|
||||||
ApiRespWebChatMessages,
|
ApiRespWebChatMessages,
|
||||||
ApiRespKnowledgeBases,
|
ApiRespKnowledgeBases,
|
||||||
@@ -229,6 +233,37 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============ Pipeline API ============
|
// ============ 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> {
|
public getGeneralPipelineMetadata(): Promise<GetPipelineMetadataResponseData> {
|
||||||
// as designed, this method will be deprecated, and only for developer to check the prefered config schema
|
// as designed, this method will be deprecated, and only for developer to check the prefered config schema
|
||||||
return this.get('/api/v1/pipelines/_/metadata');
|
return this.get('/api/v1/pipelines/_/metadata');
|
||||||
|
|||||||
@@ -359,6 +359,27 @@ const enUS = {
|
|||||||
routingConnection: 'Routing & Connection',
|
routingConnection: 'Routing & Connection',
|
||||||
routingConnectionDescription:
|
routingConnectionDescription:
|
||||||
'Bind the pipeline that processes messages for this bot',
|
'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',
|
routingRules: 'Conditional Routing Rules',
|
||||||
routingRulesDescription:
|
routingRulesDescription:
|
||||||
'Rules are evaluated in order; first match routes to its pipeline. Fallback to the default pipeline above if none match.',
|
'Rules are evaluated in order; first match routes to its pipeline. Fallback to the default pipeline above if none match.',
|
||||||
@@ -465,6 +486,50 @@ const enUS = {
|
|||||||
removeAdminTitle: 'Remove admin',
|
removeAdminTitle: 'Remove admin',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
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: {
|
plugins: {
|
||||||
title: 'Extensions',
|
title: 'Extensions',
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -365,6 +365,28 @@ const jaJP = {
|
|||||||
routingConnection: 'ルーティングと接続',
|
routingConnection: 'ルーティングと接続',
|
||||||
routingConnectionDescription:
|
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: '条件付きルーティングルール',
|
routingRules: '条件付きルーティングルール',
|
||||||
routingRulesDescription:
|
routingRulesDescription:
|
||||||
'ルールは順番に評価され、最初に一致したルールのパイプラインにルーティングされます。一致しない場合はデフォルトパイプラインが使用されます。',
|
'ルールは順番に評価され、最初に一致したルールのパイプラインにルーティングされます。一致しない場合はデフォルトパイプラインが使用されます。',
|
||||||
@@ -471,6 +493,48 @@ const jaJP = {
|
|||||||
configureAdmins: 'Manage Admins',
|
configureAdmins: 'Manage Admins',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
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: {
|
plugins: {
|
||||||
title: '拡張機能',
|
title: '拡張機能',
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -343,6 +343,27 @@ const zhHans = {
|
|||||||
basicInfoDescription: '设置机器人名称和描述',
|
basicInfoDescription: '设置机器人名称和描述',
|
||||||
routingConnection: '路由与连接',
|
routingConnection: '路由与连接',
|
||||||
routingConnectionDescription: '绑定处理此机器人消息的流水线',
|
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: '条件路由规则',
|
routingRules: '条件路由规则',
|
||||||
routingRulesDescription:
|
routingRulesDescription:
|
||||||
'按顺序匹配,命中第一条规则后路由到对应流水线;都不匹配时使用上方默认流水线',
|
'按顺序匹配,命中第一条规则后路由到对应流水线;都不匹配时使用上方默认流水线',
|
||||||
@@ -446,6 +467,46 @@ const zhHans = {
|
|||||||
removeAdminTitle: '移除管理员权限',
|
removeAdminTitle: '移除管理员权限',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
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: {
|
plugins: {
|
||||||
title: '插件扩展',
|
title: '插件扩展',
|
||||||
description: '安装和配置用于扩展功能的插件,请在流水线配置中选用',
|
description: '安装和配置用于扩展功能的插件,请在流水线配置中选用',
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import SpaceCallbackPage from '@/app/auth/space/callback/page';
|
|||||||
import HomePage from '@/app/home/page';
|
import HomePage from '@/app/home/page';
|
||||||
import MonitoringPage from '@/app/home/monitoring/page';
|
import MonitoringPage from '@/app/home/monitoring/page';
|
||||||
import BotsPage from '@/app/home/bots/page';
|
import BotsPage from '@/app/home/bots/page';
|
||||||
|
import AgentsPage from '@/app/home/agents/page';
|
||||||
import PipelinesPage from '@/app/home/pipelines/page';
|
import PipelinesPage from '@/app/home/pipelines/page';
|
||||||
import PluginsPage from '@/app/home/plugins/page';
|
import PluginsPage from '@/app/home/plugins/page';
|
||||||
import AddExtensionPage from '@/app/home/add-extension/page';
|
import AddExtensionPage from '@/app/home/add-extension/page';
|
||||||
@@ -104,6 +105,16 @@ export const router = createBrowserRouter([
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/home/agents',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<HomeLayout>
|
||||||
|
<AgentsPage />
|
||||||
|
</HomeLayout>
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/home/pipelines',
|
path: '/home/pipelines',
|
||||||
element: (
|
element: (
|
||||||
|
|||||||
Reference in New Issue
Block a user