Browse Source

画布无法打开版本

master
MSI-7950X\刘泽明 7 days ago
parent
commit
135d01ebb9
  1. 277
      PLAN4.md
  2. 16
      backend/models/__init__.py
  3. 114
      backend/modules/agent_manager/router.py
  4. 111
      backend/modules/flow_engine/engine.py
  5. 133
      backend/modules/flow_engine/router.py
  6. 67
      backend/schemas/__init__.py
  7. 10
      frontend/src/api/index.ts
  8. 27
      frontend/src/router/index.ts
  9. 173
      frontend/src/views/agent/AgentList.vue
  10. 29
      frontend/src/views/flow/FlowEdge.vue
  11. 254
      frontend/src/views/flow/FlowEditor.vue
  12. 60
      frontend/src/views/flow/FlowMarket.vue
  13. 56
      frontend/src/views/flow/FlowNode.vue
  14. 123
      gemini.md

277
PLAN4.md

@ -0,0 +1,277 @@
# PLAN4 — 流编辑器深度重构:LLM→智能体→流节点→工作流 四层架构
## 一、问题诊断
### 当前架构痛点(逐一定位代码行级别)
| # | 问题 | 根因位置 | 影响 |
|---|------|----------|------|
| 1 | **LLM/智能体与节点未打通** | [engine.py#L101-L118](file:///c:/Users/刘泽明/Documents/Git/hg-agents/backend/modules/flow_engine/engine.py#L101-L118) `LLMNodeAgent` 每次硬编码新建 `OpenAIChatModel`,忽略节点配置中 `model`/`temperature`/`system_prompt` | 节点的LLM配置表单完全无效 |
| 2 | **节点只有壳,无法配置** | [FlowNode.vue](file:///c:/Users/刘泽明/Documents/Git/hg-agents/frontend/src/views/flow/FlowNode.vue) 固定 Top/Bottom Handle,条件节点只有一个出口 | 条件分支无法区分 true/false |
| 3 | **连线位置漂移** | [FlowEdge.vue](file:///c:/Users/刘泽明/Documents/Git/hg-agents/frontend/src/views/flow/FlowEdge.vue) 自定义 SVG path + 没有绑定 vue-flow edge marker | 线条起点/终点不精准 |
| 4 | **条件分支不可控** | [engine.py#L277-L293](file:///c:/Users/刘泽明/Documents/Git/hg-agents/backend/modules/flow_engine/engine.py#L277-L293) `ConditionNodeAgent` 只返回固定字符串 + 拓扑排序不支持跳过 | 即使 LLM 判断了真假,引擎也不会分支 |
| 5 | **触发节点无实际参数** | FlowEditor 触发节点只有标签,无企微回调 URL / webhook 配置 | 无法真正接入企微触发 |
| 6 | **AgentFactory 未集成** | [engine.py](file:///c:/Users/刘泽明/Documents/Git/hg-agents/backend/modules/flow_engine/engine.py) 从未调用 [factory.py](file:///c:/Users/刘泽明/Documents/Git/hg-agents/backend/agentscope_integration/factory.py) | 智能体复用、缓存管理能力全部浪费 |
---
## 二、目标架构:四层递进模型
```
┌──────────────────────────────────────────────┐
│ 第四层: 工作流 (Workflow) │
│ 多个节点串联的执行拓扑图,含条件分支、循环 │
│ → FlowEngine.execute() │
├──────────────────────────────────────────────┤
│ 第三层: 流节点 (Flow Node) │
│ 每个节点是可配置的 Agent 实例 │
│ → LLMNodeAgent / ToolNodeAgent / ... │
├──────────────────────────────────────────────┤
│ 第二层: 智能体 (Agent) │
│ 封装了 LLM + 工具 + 系统提示词的 agent │
│ → AgentFactory.get_agent(agent_type, config) │
├──────────────────────────────────────────────┤
│ 第一层: LLM 模型 (Model) │
│ 底层大语言模型配置 (api_key, model, base_url) │
│ → config.py Settings / 模型配置面板 │
└──────────────────────────────────────────────┘
```
用户操作流程:**新建 Agent(选定模型+提示词+工具)→ 拖入流编辑器作为节点 → 连线组装工作流 → 发布到企微**
---
## 三、实施方案
### P0 — 核心打通(必须完成)
#### P0-1: 节点 Handle 重构 — 按节点类型动态分配输入/输出端口
**文件**: [FlowNode.vue](file:///c:/Users/刘泽明/Documents/Git/hg-agents/frontend/src/views/flow/FlowNode.vue)
**现状**: 所有节点固定 Top=target, Bottom=source,条件节点无法双出口。
**目标**:
```
trigger: Top(target) ←可有可无 | Bottom(source)
llm: Left(target) | Right(source)
tool: Left(target) | Right(source)
mcp: Left(target) | Right(source)
wecom_notify: Left(target) | 无出口(终节点) OR Bottom(source)
condition: Left(target) | Right(source=true) + Bottom(source=false)
rag: Left(target) | Right(source)
output: Left(target) | 无出口
```
**实施方案**:
1. 为条件节点增加两个 `Handle`:`Position.Right` (id="true") 和 `Position.Bottom` (id="false")
2. 为终节点(output/wecom_notify)移除 source Handle
3. FlowEditor.vue 中 `onConnect` 记录 `sourceHandle`,条件节点时记录含 `true`/`false` 分支信息
4. 保存时 edges 格式增加 `condition` 字段:`{source, target, condition: "true"|"false"}`
#### P0-2: LLM节点集成 AgentFactory — 使用真实 Agent 配置
**文件**: [engine.py#L101-L160](file:///c:/Users/刘泽明/Documents/Git/hg-agents/backend/modules/flow_engine/engine.py#L101-L160)
**现状**: `LLMNodeAgent.reply()` 每次新建模型,忽略节点 `config`
**实施方案**:
```python
class LLMNodeAgent(AgentBase):
async def reply(self, msg: Msg, **kwargs) -> Msg:
# 从节点 config 读取参数,而非硬编码
system_prompt = self.config.get("system_prompt", "")
model_name = self.config.get("model", settings.LLM_MODEL)
temperature = float(self.config.get("temperature", 0.7))
agent_id = self.config.get("agent_id", "") # 可选:关联已有 Agent
# 优先使用 AgentFactory 获取或创建 agent
if agent_id:
agent = await Agents.get_or_create(agent_id, model_name, system_prompt)
else:
agent = await self._create_agent(model_name, system_prompt, temperature)
result = await agent(msg)
return self._to_msg(result)
```
#### P0-3: 条件分支引擎 — 支持 true/false 两条路径
**文件**: [engine.py#L277-L340](file:///c:/Users/刘泽明/Documents/Git/hg-agents/backend/modules/flow_engine/engine.py#L277-L340) + [engine.py#L45-L75](file:///c:/Users/刘泽明/Documents/Git/hg-agents/backend/modules/flow_engine/engine.py#L45-L75)
**现状**: 拓扑排序线性执行,无法跳过分支。
**实施方案**:
`FlowEngine.execute()` 从纯拓扑排序改为 **DAG 条件遍历**
```python
async def execute(self, input_msg, context):
nodes = self.definition["nodes"]
edges = self.definition["edges"]
# 构建邻接表: node_id -> [(target_id, condition)]
graph = self._build_graph(nodes, edges)
# 找到起始节点(入度为0的节点)
start_nodes = self._find_start_nodes(graph)
# BFS/DFS 带条件遍历
result = await self._traverse(start_nodes[0], input_msg, context, graph)
return result
async def _traverse(self, node_id, msg, context, graph):
agent = self._get_agent(node_id)
result = await agent.reply(msg)
context["_node_results"][node_id] = str(result)
# 如果是条件节点,根据判断结果选择分支
next_nodes = graph.get(node_id, [])
for target_id, cond in next_nodes:
if cond is None or cond == "true":
await self._traverse(target_id, result, context, graph)
return result
```
**关键**: 条件节点执行后解析 `condition:true|xxx``condition:false|xxx`,只沿对应分支继续。
#### P0-4: 连线位置修复 — 使用 vue-flow 原生 Handle 机制
**文件**: [FlowEditor.vue](file:///c:/Users/刘泽明/Documents/Git/hg-agents/frontend/src/views/flow/FlowEditor.vue#L247-L268)
**现状**: `elements` 用的是普通数组,节点自定义组件通过 `id` 查找,vue-flow 无法正确渲染 SVG 连线。
**实施方案**:
1. 使用 `useVueFlow``addNodes`/`addEdges` API 代替手动 `elements.value.push`
2. 注册 `@connect` 事件,让 vue-flow 原生处理连线渲染
3. 删除 [FlowEdge.vue](file:///c:/Users/刘泽明/Documents/Git/hg-agents/frontend/src/views/flow/FlowEdge.vue) 自定义 edge(vue-flow 内置 bezier 曲线更精准)
4. 条件节点的两条边使用不同颜色:true=绿, false=红
```typescript
const { addNodes, addEdges, onConnect: onVueConnect } = useVueFlow()
function onConnect(connection: Edge) {
const newEdge = {
...connection,
id: `edge_${connection.source}_${connection.target}`,
type: connection.sourceHandle === 'false' ? 'smoothstep-red' : 'default',
animated: true,
style: connection.sourceHandle === 'false'
? { stroke: '#F56C6C' }
: { stroke: '#409EFF' },
}
addEdges([newEdge])
}
```
---
### P1 — 节点配置增强(提升灵活性)
#### P1-1: LLM节点 — 支持选择已有 Agent
**文件**: FlowEditor.vue LLM 配置区域
- **新增** `agent_id` 下拉框:从后端 `GET /api/agents` 加载已有智能体列表
- 选择 Agent 后自动填入 `system_prompt`、`model`、`temperature`
- 不选 Agent 则手动填写(当前行为保留)
#### P1-2: 触发节点 — 企微 Webhook URL 配置
**文件**: FlowEditor.vue trigger 配置区域
- 触发节点增加 `wecom_webhook_url``event_type` 字段
- `event_type` 支持:`text_message` / `button_click` / `enter_chat`
- 发布流时自动注册企微回调(如果配置了 webhook)
#### P1-3: 节点配置持久化增强 — 支持 JSON Schema
**后端 schema 变更**: [schemas/__init__.py](file:///c:/Users/刘泽明/Documents/Git/hg-agents/backend/schemas/__init__.py#L179-L181)
`FlowNode.config` 目前是 `dict = {}`,改为结构化的各类型:
```python
class LLMNodeConfig(BaseModel):
system_prompt: str = ""
model: str = "gpt-4o-mini"
temperature: float = 0.7
agent_id: str = ""
class ToolNodeConfig(BaseModel):
tool_name: str = ""
class ConditionNodeConfig(BaseModel):
condition: str = ""
class WeComNotifyNodeConfig(BaseModel):
message_template: str = ""
target: str = "@all"
class FlowNode(BaseModel):
id: str | None = None
type: str # trigger/llm/tool/mcp/wecom_notify/condition/rag/output
label: str
config: LLMNodeConfig | ToolNodeConfig | ... = {}
```
#### P1-4: 新增 Agent 管理 API(后端)
**新文件**: `backend/modules/agent/router.py`
提供 `GET/POST/PUT/DELETE /api/agents`,管理可复用的 Agent:
```python
# Agent 模型
class Agent(Base):
__tablename__ = "agents"
name, system_prompt, model, temperature, tools: list[str], status
```
这样 FlowEditor 的 LLM 节点可以通过 API 获取已有 Agent 列表。
---
### P2 — 体验完善
#### P2-1: 撤销/重做 (Ctrl+Z / Ctrl+Y)
使用 vue-flow 内置的 `useUndoRedo` composable。
#### P2-2: 节点缩略图 MiniMap 增强
当前 MiniMap 已导入但不可用,改为真正的 Pane 节点缩略图。
#### P2-3: 流模板市场
预置 3-5 个常用工作流模板(文档处理流、企微通知流、数据分析流)。
---
## 四、文件变更清单
| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `frontend/src/views/flow/FlowNode.vue` | **重写** | 按节点类型动态 Handle |
| `frontend/src/views/flow/FlowEdge.vue` | **删除** | 用 vue-flow 内置 edge |
| `frontend/src/views/flow/FlowEditor.vue` | **重大重构** | useVueFlow API、Agent选择、条件边 |
| `backend/modules/flow_engine/engine.py` | **重大重构** | AgentFactory集成、条件DAG遍历 |
| `backend/modules/flow_engine/router.py` | 修改 | 新增agent列表查询端点 |
| `backend/modules/agent/router.py` | **新建** | Agent CRUD API |
| `backend/models/__init__.py` | 修改 | 新增Agent模型 |
| `backend/schemas/__init__.py` | 修改 | 新增Agent schema + FlowNode结构化config |
| `frontend/src/api/index.ts` | 修改 | 新增agentApi |
| `frontend/src/router/index.ts` | 修改 | 新增agent管理路由 |
---
## 五、实施顺序
```
Phase 1 (P0-1, P0-4) → Handle重构 + 连线修复 [前端为主]
Phase 2 (P0-3) → 条件分支引擎 [后端为主]
Phase 3 (P0-2, P1-1) → LLM节点集成AgentFactory [前后端联动]
Phase 4 (P1-2 ~ P1-4) → 节点配置增强 + Agent管理API [前后端联动]
Phase 5 (P2-1 ~ P2-3) → 体验完善 [前端为主]
```

16
backend/models/__init__.py

@ -198,6 +198,22 @@ class SystemMetric(Base):
collected_at = Column(DateTime, default=datetime.utcnow) collected_at = Column(DateTime, default=datetime.utcnow)
class AgentConfig(Base):
__tablename__ = "agent_configs"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String(100), nullable=False)
description = Column(String(500))
system_prompt = Column(Text, default="")
model = Column(String(50), default="gpt-4o-mini")
temperature = Column(Integer, default=7)
tools = Column(JSON, default=list)
status = Column(String(20), default="active")
creator_id = Column(UUID(as_uuid=True), ForeignKey("users.id"))
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class AuditLog(Base): class AuditLog(Base):
__tablename__ = "audit_logs" __tablename__ = "audit_logs"

114
backend/modules/agent_manager/router.py

@ -1,9 +1,10 @@
import uuid import uuid
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db from database import get_db
from models import User, ChatSession, ChatMessage from models import User, ChatSession, ChatMessage, AgentConfig
from schemas import AgentConfigCreate, AgentConfigUpdate, AgentConfigOut
from agentscope_integration.factory import AgentFactory from agentscope_integration.factory import AgentFactory
router = APIRouter(prefix="/api/agent", tags=["agent"]) router = APIRouter(prefix="/api/agent", tags=["agent"])
@ -16,10 +17,6 @@ async def agent_chat(
payload: dict, payload: dict,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""
与智能体对话
agent_type: employee | manager | task | document
"""
user_ctx = request.state.user user_ctx = request.state.user
user_id = uuid.UUID(user_ctx["id"]) user_id = uuid.UUID(user_ctx["id"])
msg_content = payload.get("message", "") msg_content = payload.get("message", "")
@ -28,7 +25,6 @@ async def agent_chat(
result = await db.execute(select(User).where(User.id == user_id)) result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if not user: if not user:
from fastapi import HTTPException
raise HTTPException(404, "用户不存在") raise HTTPException(404, "用户不存在")
session_result = await db.execute( session_result = await db.execute(
@ -80,18 +76,107 @@ async def agent_chat(
@router.get("/list") @router.get("/list")
async def get_agent_list(request: Request): async def get_agent_list(request: Request, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(AgentConfig).where(AgentConfig.status == "active").order_by(AgentConfig.updated_at.desc())
)
agents = result.scalars().all()
return { return {
"code": 200, "code": 200,
"data": [ "data": [{
{"type": "employee", "name": "员工AI助手", "description": "日常问答、文档处理、知识查询"}, "id": str(a.id),
{"type": "manager", "name": "管理分析助手", "description": "下属工作分析、效能评估"}, "name": a.name,
{"type": "task", "name": "任务管理助手", "description": "任务创建、分派、追踪"}, "description": a.description,
{"type": "document", "name": "文档处理助手", "description": "格式修正、内容提取、导入导出"}, "system_prompt": a.system_prompt,
], "model": a.model,
"temperature": float(a.temperature) / 10.0,
"tools": a.tools or [],
"status": a.status,
} for a in agents],
} }
@router.post("/", response_model=AgentConfigOut)
async def create_agent(req: AgentConfigCreate, request: Request, db: AsyncSession = Depends(get_db)):
user_ctx = request.state.user
agent = AgentConfig(
name=req.name,
description=req.description,
system_prompt=req.system_prompt,
model=req.model,
temperature=int(req.temperature * 10),
tools=req.tools,
creator_id=uuid.UUID(user_ctx["id"]),
)
db.add(agent)
await db.flush()
return AgentConfigOut(
id=agent.id, name=agent.name, description=agent.description,
system_prompt=agent.system_prompt, model=agent.model,
temperature=float(agent.temperature) / 10.0,
tools=agent.tools or [], status=agent.status,
creator_id=agent.creator_id,
created_at=agent.created_at, updated_at=agent.updated_at,
)
@router.get("/{agent_id}", response_model=AgentConfigOut)
async def get_agent(agent_id: uuid.UUID, request: Request, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(AgentConfig).where(AgentConfig.id == agent_id))
agent = result.scalar_one_or_none()
if not agent:
raise HTTPException(404, "Agent不存在")
return AgentConfigOut(
id=agent.id, name=agent.name, description=agent.description,
system_prompt=agent.system_prompt, model=agent.model,
temperature=float(agent.temperature) / 10.0,
tools=agent.tools or [], status=agent.status,
creator_id=agent.creator_id,
created_at=agent.created_at, updated_at=agent.updated_at,
)
@router.put("/{agent_id}", response_model=AgentConfigOut)
async def update_agent(agent_id: uuid.UUID, req: AgentConfigUpdate, request: Request, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(AgentConfig).where(AgentConfig.id == agent_id))
agent = result.scalar_one_or_none()
if not agent:
raise HTTPException(404, "Agent不存在")
if req.name is not None:
agent.name = req.name
if req.description is not None:
agent.description = req.description
if req.system_prompt is not None:
agent.system_prompt = req.system_prompt
if req.model is not None:
agent.model = req.model
if req.temperature is not None:
agent.temperature = int(req.temperature * 10)
if req.tools is not None:
agent.tools = req.tools
if req.status is not None:
agent.status = req.status
await db.flush()
return AgentConfigOut(
id=agent.id, name=agent.name, description=agent.description,
system_prompt=agent.system_prompt, model=agent.model,
temperature=float(agent.temperature) / 10.0,
tools=agent.tools or [], status=agent.status,
creator_id=agent.creator_id,
created_at=agent.created_at, updated_at=agent.updated_at,
)
@router.delete("/{agent_id}")
async def delete_agent(agent_id: uuid.UUID, request: Request, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(AgentConfig).where(AgentConfig.id == agent_id))
agent = result.scalar_one_or_none()
if not agent:
raise HTTPException(404, "Agent不存在")
await db.delete(agent)
return {"code": 200, "message": "已删除"}
@router.get("/history/{session_id}") @router.get("/history/{session_id}")
async def get_chat_history( async def get_chat_history(
session_id: str, session_id: str,
@ -103,7 +188,6 @@ async def get_chat_history(
) )
session = session_result.scalar_one_or_none() session = session_result.scalar_one_or_none()
if not session: if not session:
from fastapi import HTTPException
raise HTTPException(404, "会话不存在") raise HTTPException(404, "会话不存在")
msg_result = await db.execute( msg_result = await db.execute(

111
backend/modules/flow_engine/engine.py

@ -1,7 +1,7 @@
import json import json
import uuid import uuid
import logging import logging
from collections import deque import re
from agentscope.agent import AgentBase from agentscope.agent import AgentBase
from agentscope.message import Msg from agentscope.message import Msg
from agentscope.tool import Toolkit from agentscope.tool import Toolkit
@ -21,20 +21,33 @@ class FlowEngine:
self._agent_cache: dict[str, AgentBase] = {} self._agent_cache: dict[str, AgentBase] = {}
async def execute(self, input_msg: Msg, context: dict) -> Msg: async def execute(self, input_msg: Msg, context: dict) -> Msg:
execution_order = self._topological_sort() graph = self._build_graph()
current_msg = input_msg start_nodes = self._find_start_nodes(graph)
if not start_nodes:
start_nodes = list(self.nodes.keys())[:1]
for i, node_id in enumerate(execution_order): visited: set[str] = set()
agent = await self._get_or_create_agent(node_id, context) last_result: Msg | None = None
node = self.nodes[node_id]
async def traverse(node_id: str, incoming_msg: Msg) -> None:
nonlocal last_result
if node_id in visited:
return
visited.add(node_id)
node = self.nodes.get(node_id)
if not node:
return
enriched_content = self._resolve_input_mapping(node, current_msg, context) agent = await self._get_or_create_agent(node_id, context)
enriched_content = self._resolve_input_mapping(node, incoming_msg, context)
current_msg = incoming_msg
if enriched_content.strip(): if enriched_content.strip():
user_text = current_msg.get_text_content() if hasattr(current_msg, 'get_text_content') else str(current_msg) user_text = current_msg.get_text_content() if hasattr(current_msg, 'get_text_content') else str(current_msg)
current_msg = Msg(name="user", content=f"{enriched_content}\n\n---\n{user_text}", role="user") current_msg = Msg(name="user", content=f"{enriched_content}\n\n---\n{user_text}", role="user")
try: try:
result = await agent(current_msg) result = await agent.reply(current_msg)
exec_record = { exec_record = {
"node_id": node_id, "node_id": node_id,
"node_type": node.get("type"), "node_type": node.get("type"),
@ -43,7 +56,17 @@ class FlowEngine:
"output": result.get_text_content()[:500] if hasattr(result, 'get_text_content') else str(result)[:500], "output": result.get_text_content()[:500] if hasattr(result, 'get_text_content') else str(result)[:500],
} }
context.setdefault("_node_results", {})[node_id] = exec_record context.setdefault("_node_results", {})[node_id] = exec_record
current_msg = result last_result = result
is_condition = node.get("type") == "condition"
cond_result = self._parse_condition_result(result)
for target_id, edge_cond in graph.get(node_id, []):
if is_condition:
if edge_cond and edge_cond == cond_result:
await traverse(target_id, result)
else:
await traverse(target_id, result)
except Exception as e: except Exception as e:
logger.error(f"节点 {node.get('label', node_id)} 执行失败: {e}") logger.error(f"节点 {node.get('label', node_id)} 执行失败: {e}")
exec_record = { exec_record = {
@ -54,9 +77,39 @@ class FlowEngine:
"error": str(e), "error": str(e),
} }
context.setdefault("_node_results", {})[node_id] = exec_record context.setdefault("_node_results", {})[node_id] = exec_record
current_msg = Msg(name="system", content=f"[节点 {node.get('label', node_id)} 执行失败: {e}]", role="system") error_msg = Msg(name="system", content=f"[节点 {node.get('label', node_id)} 执行失败: {e}]", role="system")
last_result = error_msg
return current_msg if start_nodes:
await traverse(start_nodes[0], input_msg)
return last_result or input_msg
def _build_graph(self) -> dict[str, list[tuple[str, str | None]]]:
graph: dict[str, list[tuple[str, str | None]]] = {nid: [] for nid in self.nodes}
for edge in self.edges:
source = edge.get("source") or edge.get("from")
target = edge.get("target") or edge.get("to")
cond = edge.get("condition") or edge.get("sourceHandle")
if cond == "source":
cond = None
if source and target and source in self.nodes and target in self.nodes:
graph[source].append((target, cond))
return graph
def _find_start_nodes(self, graph: dict) -> list[str]:
target_nodes: set[str] = set()
for targets in graph.values():
for target_id, _ in targets:
target_nodes.add(target_id)
return [nid for nid in self.nodes if nid not in target_nodes]
def _parse_condition_result(self, result: Msg) -> str | None:
content = result.get_text_content() if hasattr(result, 'get_text_content') else str(result)
m = re.search(r'condition:(true|false)', content)
if m:
return m.group(1)
return None
async def _get_or_create_agent(self, node_id: str, context: dict) -> AgentBase: async def _get_or_create_agent(self, node_id: str, context: dict) -> AgentBase:
if node_id in self._agent_cache: if node_id in self._agent_cache:
@ -67,32 +120,6 @@ class FlowEngine:
self._agent_cache[node_id] = agent self._agent_cache[node_id] = agent
return agent return agent
def _topological_sort(self) -> list[str]:
in_degree: dict[str, int] = {nid: 0 for nid in self.nodes}
adj: dict[str, list[str]] = {nid: [] for nid in self.nodes}
for edge in self.edges:
source = edge.get("from") or edge.get("source")
target = edge.get("to") or edge.get("target")
if source and target and source in self.nodes and target in self.nodes:
adj[source].append(target)
in_degree[target] = in_degree.get(target, 0) + 1
queue = deque([nid for nid, deg in in_degree.items() if deg == 0])
order = []
while queue:
node_id = queue.popleft()
order.append(node_id)
for neighbor in adj.get(node_id, []):
in_degree[neighbor] -= 1
if in_degree[neighbor] == 0:
queue.append(neighbor)
remaining = [nid for nid in self.nodes if nid not in order]
order.extend(remaining)
return order
def _resolve_input_mapping(self, node: dict, current_msg: Msg, context: dict) -> str: def _resolve_input_mapping(self, node: dict, current_msg: Msg, context: dict) -> str:
config = node.get("config", {}) config = node.get("config", {})
input_mapping = config.get("input_mapping") input_mapping = config.get("input_mapping")
@ -176,16 +203,10 @@ class LLMNodeAgent(AgentBase):
self.temperature = temperature self.temperature = temperature
async def reply(self, msg: Msg, **kwargs) -> Msg: async def reply(self, msg: Msg, **kwargs) -> Msg:
from agentscope.model import OpenAIChatModel from agentscope_integration.factory import AgentFactory
from agentscope.formatter import OpenAIChatFormatter from agentscope.formatter import OpenAIChatFormatter
model = OpenAIChatModel( model = AgentFactory._get_model()
config_name=f"flow_llm_{self.name}",
model_name=self.model_name,
api_key=settings.LLM_API_KEY,
api_base=settings.LLM_API_BASE,
)
user_text = msg.get_text_content() if hasattr(msg, 'get_text_content') else str(msg) user_text = msg.get_text_content() if hasattr(msg, 'get_text_content') else str(msg)
formatter = OpenAIChatFormatter() formatter = OpenAIChatFormatter()

133
backend/modules/flow_engine/router.py

@ -233,6 +233,101 @@ async def test_flow(flow_id: uuid.UUID, request: Request, db: AsyncSession = Dep
return {"code": 200, "data": validation} return {"code": 200, "data": validation}
FLOW_TEMPLATES = [
{
"id": "tpl_doc_process",
"name": "文档处理流",
"description": "自动解析文档内容,提取关键信息并生成摘要",
"icon": "Document",
"nodes": [
{"id": "n1", "type": "trigger", "label": "文档上传", "config": {"event_type": "document_upload"}, "position": {"x": 100, "y": 100}},
{"id": "n2", "type": "tool", "label": "解析文档", "config": {"tool_name": "parse_document"}, "position": {"x": 400, "y": 100}},
{"id": "n3", "type": "llm", "label": "生成摘要", "config": {"system_prompt": "请为以下文档内容生成简洁摘要", "model": "gpt-4o-mini", "temperature": 0.5}, "position": {"x": 700, "y": 100}},
{"id": "n4", "type": "output", "label": "输出结果", "config": {"format": "text"}, "position": {"x": 1000, "y": 100}},
],
"edges": [
{"source": "n1", "target": "n2", "sourceHandle": "source"},
{"source": "n2", "target": "n3", "sourceHandle": "source"},
{"source": "n3", "target": "n4", "sourceHandle": "source"},
],
},
{
"id": "tpl_wecom_notify",
"name": "企微通知流",
"description": "接收触发后查询数据并推送企微通知",
"icon": "Bell",
"nodes": [
{"id": "n1", "type": "trigger", "label": "定时触发", "config": {"event_type": "scheduled"}, "position": {"x": 100, "y": 100}},
{"id": "n2", "type": "tool", "label": "查询任务", "config": {"tool_name": "list_tasks"}, "position": {"x": 400, "y": 100}},
{"id": "n3", "type": "condition", "label": "有待办任务?", "config": {"condition": "tasks.length > 0"}, "position": {"x": 700, "y": 100}},
{"id": "n4", "type": "wecom_notify", "label": "推送通知", "config": {"message_template": "您有{{tasks.length}}条待办任务", "target": "@all"}, "position": {"x": 1000, "y": 50}},
{"id": "n5", "type": "output", "label": "无任务", "config": {"format": "text"}, "position": {"x": 1000, "y": 200}},
],
"edges": [
{"source": "n1", "target": "n2", "sourceHandle": "source"},
{"source": "n2", "target": "n3", "sourceHandle": "source"},
{"source": "n3", "target": "n4", "sourceHandle": "true"},
{"source": "n3", "target": "n5", "sourceHandle": "false"},
],
},
{
"id": "tpl_data_analysis",
"name": "数据分析流",
"description": "查询员工数据并生成效率分析报告",
"icon": "DataAnalysis",
"nodes": [
{"id": "n1", "type": "trigger", "label": "分析请求", "config": {"event_type": "button_click"}, "position": {"x": 100, "y": 100}},
{"id": "n2", "type": "tool", "label": "查询下属", "config": {"tool_name": "list_subordinates"}, "position": {"x": 400, "y": 100}},
{"id": "n3", "type": "tool", "label": "统计数据", "config": {"tool_name": "get_task_statistics"}, "position": {"x": 700, "y": 100}},
{"id": "n4", "type": "llm", "label": "生成报告", "config": {"system_prompt": "基于以下数据生成团队效率分析报告", "model": "gpt-4o", "temperature": 0.7}, "position": {"x": 1000, "y": 100}},
{"id": "n5", "type": "output", "label": "报告输出", "config": {"format": "json"}, "position": {"x": 1300, "y": 100}},
],
"edges": [
{"source": "n1", "target": "n2", "sourceHandle": "source"},
{"source": "n2", "target": "n3", "sourceHandle": "source"},
{"source": "n3", "target": "n4", "sourceHandle": "source"},
{"source": "n4", "target": "n5", "sourceHandle": "source"},
],
},
{
"id": "tpl_rag_qa",
"name": "知识库问答流",
"description": "从知识库检索信息后由LLM回答",
"icon": "Search",
"nodes": [
{"id": "n1", "type": "trigger", "label": "问题触发", "config": {"event_type": "text_message"}, "position": {"x": 100, "y": 100}},
{"id": "n2", "type": "rag", "label": "知识检索", "config": {"knowledge_base": "default", "top_k": 5}, "position": {"x": 400, "y": 100}},
{"id": "n3", "type": "llm", "label": "生成回答", "config": {"system_prompt": "基于知识库检索结果回答用户问题", "model": "gpt-4o-mini", "temperature": 0.3}, "position": {"x": 700, "y": 100}},
{"id": "n4", "type": "output", "label": "输出答案", "config": {"format": "text"}, "position": {"x": 1000, "y": 100}},
],
"edges": [
{"source": "n1", "target": "n2", "sourceHandle": "source"},
{"source": "n2", "target": "n3", "sourceHandle": "source"},
{"source": "n3", "target": "n4", "sourceHandle": "source"},
],
},
{
"id": "tpl_task_auto",
"name": "任务自动分配流",
"description": "根据描述自动创建任务并分派给合适人员",
"icon": "Tools",
"nodes": [
{"id": "n1", "type": "trigger", "label": "任务描述", "config": {"event_type": "text_message"}, "position": {"x": 100, "y": 100}},
{"id": "n2", "type": "llm", "label": "分析任务", "config": {"system_prompt": "分析以下任务描述,提取标题、优先级、负责人", "model": "gpt-4o-mini", "temperature": 0.5}, "position": {"x": 400, "y": 100}},
{"id": "n3", "type": "tool", "label": "创建任务", "config": {"tool_name": "create_task"}, "position": {"x": 700, "y": 100}},
{"id": "n4", "type": "wecom_notify", "label": "通知负责人", "config": {"message_template": "您有新任务: {{task_title}}", "target": "@all"}, "position": {"x": 1000, "y": 100}},
{"id": "n5", "type": "output", "label": "完成", "config": {"format": "text"}, "position": {"x": 1300, "y": 100}},
],
"edges": [
{"source": "n1", "target": "n2", "sourceHandle": "source"},
{"source": "n2", "target": "n3", "sourceHandle": "source"},
{"source": "n3", "target": "n4", "sourceHandle": "source"},
{"source": "n4", "target": "n5", "sourceHandle": "source"},
],
},
]
@router.get("/market", response_model=list[FlowDefinitionOut]) @router.get("/market", response_model=list[FlowDefinitionOut])
async def flow_market(request: Request, db: AsyncSession = Depends(get_db)): async def flow_market(request: Request, db: AsyncSession = Depends(get_db)):
result = await db.execute( result = await db.execute(
@ -250,6 +345,44 @@ async def flow_market(request: Request, db: AsyncSession = Depends(get_db)):
) for f in flows] ) for f in flows]
@router.get("/templates")
async def get_flow_templates(request: Request):
return {"code": 200, "data": FLOW_TEMPLATES}
@router.post("/templates/{template_id}/use")
async def use_flow_template(
template_id: str,
request: Request,
db: AsyncSession = Depends(get_db),
):
template = next((t for t in FLOW_TEMPLATES if t["id"] == template_id), None)
if not template:
raise HTTPException(404, "模板不存在")
user_ctx = request.state.user
flow = FlowDefinition(
name=template["name"] + " (副本)",
description=template["description"],
definition_json={
"nodes": template["nodes"],
"edges": template["edges"],
"trigger": {},
},
creator_id=uuid.UUID(user_ctx["id"]),
)
db.add(flow)
await db.flush()
return FlowDefinitionOut(
id=flow.id, name=flow.name, description=flow.description,
version=flow.version, status=flow.status,
definition_json=flow.definition_json,
published_to_wecom=flow.published_to_wecom,
created_at=flow.created_at, updated_at=flow.updated_at,
)
@router.get("/executions") @router.get("/executions")
async def list_executions(request: Request, db: AsyncSession = Depends(get_db)): async def list_executions(request: Request, db: AsyncSession = Depends(get_db)):
result = await db.execute( result = await db.execute(

67
backend/schemas/__init__.py

@ -176,6 +176,36 @@ class EmployeeAnalysis(BaseModel):
# --- Flow --- # --- Flow ---
class TriggerNodeConfig(BaseModel):
event_type: str = "text_message"
class LLMNodeConfig(BaseModel):
system_prompt: str = ""
model: str = "gpt-4o-mini"
temperature: float = 0.7
agent_id: str = ""
class ToolNodeConfig(BaseModel):
tool_name: str = ""
class MCPNodeConfig(BaseModel):
mcp_server: str = ""
tool_name: str = ""
class WeComNotifyNodeConfig(BaseModel):
message_template: str = ""
target: str = ""
class ConditionNodeConfig(BaseModel):
condition: str = ""
class RAGNodeConfig(BaseModel):
knowledge_base: str = ""
top_k: int = 5
class OutputNodeConfig(BaseModel):
format: str = "text"
class FlowNode(BaseModel): class FlowNode(BaseModel):
id: str | None = None id: str | None = None
type: str type: str
@ -257,6 +287,43 @@ class MCPServiceOut(BaseModel):
from_attributes = True from_attributes = True
# --- Agent Config ---
class AgentConfigCreate(BaseModel):
name: str
description: str | None = None
system_prompt: str = ""
model: str = "gpt-4o-mini"
temperature: float = 0.7
tools: list[str] = []
class AgentConfigUpdate(BaseModel):
name: str | None = None
description: str | None = None
system_prompt: str | None = None
model: str | None = None
temperature: float | None = None
tools: list[str] | None = None
status: str | None = None
class AgentConfigOut(BaseModel):
id: uuid.UUID
name: str
description: str | None = None
system_prompt: str
model: str
temperature: float
tools: list[str] = []
status: str
creator_id: uuid.UUID | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
class Config:
from_attributes = True
# --- Notification --- # --- Notification ---
class NotificationTemplateCreate(BaseModel): class NotificationTemplateCreate(BaseModel):
name: str name: str

10
frontend/src/api/index.ts

@ -18,11 +18,11 @@ api.interceptors.request.use((config) => {
api.interceptors.response.use( api.interceptors.response.use(
(response) => response.data, (response) => response.data,
(error) => { (error) => {
const msg = error.response?.data?.message || error.message || '请求失败'
if (error.response?.status === 401) { if (error.response?.status === 401) {
localStorage.removeItem('token') localStorage.removeItem('token')
router.push('/login') return Promise.reject(error)
} }
const msg = error.response?.data?.message || error.message || '请求失败'
ElMessage.error(msg) ElMessage.error(msg)
return Promise.reject(error) return Promise.reject(error)
} }
@ -82,6 +82,8 @@ export const flowApi = {
executeFlow: (id: string, data: any) => api.post(`/flow/definitions/${id}/execute`, data), executeFlow: (id: string, data: any) => api.post(`/flow/definitions/${id}/execute`, data),
testFlow: (id: string) => api.post(`/flow/definitions/${id}/test`), testFlow: (id: string) => api.post(`/flow/definitions/${id}/test`),
getMarket: () => api.get('/flow/market'), getMarket: () => api.get('/flow/market'),
getTemplates: () => api.get('/flow/templates'),
useTemplate: (id: string) => api.post(`/flow/templates/${id}/use`),
} }
export const wecomApi = { export const wecomApi = {
@ -94,6 +96,10 @@ export const agentApi = {
chat: (type: string, data: any) => api.post(`/agent/chat/${type}`, data), chat: (type: string, data: any) => api.post(`/agent/chat/${type}`, data),
getList: () => api.get('/agent/list'), getList: () => api.get('/agent/list'),
getHistory: (sessionId: string) => api.get(`/agent/history/${sessionId}`), getHistory: (sessionId: string) => api.get(`/agent/history/${sessionId}`),
getAgent: (id: string) => api.get(`/agent/${id}`),
createAgent: (data: any) => api.post('/agent/', data),
updateAgent: (id: string, data: any) => api.put(`/agent/${id}`, data),
deleteAgent: (id: string) => api.delete(`/agent/${id}`),
} }
export const mcpApi = { export const mcpApi = {

27
frontend/src/router/index.ts

@ -4,6 +4,14 @@ import { useUserStore } from '@/stores/user'
const router = createRouter({ const router = createRouter({
history: createWebHashHistory(), history: createWebHashHistory(),
routes: [ routes: [
{
path: '/',
redirect: '/login',
},
{
path: '/:pathMatch(.*)*',
redirect: '/login',
},
{ {
path: '/login', path: '/login',
name: 'Login', name: 'Login',
@ -200,31 +208,32 @@ const router = createRouter({
], ],
}) })
router.beforeEach(async (to, _from, next) => { router.beforeEach(async (to, _from) => {
const userStore = useUserStore() const userStore = useUserStore()
if (!userStore.token) { if (!userStore.token) {
if (to.name === 'Login') { next(); return } if (to.name === 'Login') { return true }
next({ name: 'Login', query: { redirect: to.fullPath } }) return { name: 'Login', query: { redirect: to.fullPath } }
return
} }
if (!userStore.user) { if (!userStore.user) {
try {
await userStore.fetchUser() await userStore.fetchUser()
} catch {
return { name: 'Login', query: { redirect: to.fullPath } }
}
if (!userStore.isLoggedIn) { if (!userStore.isLoggedIn) {
next({ name: 'Login', query: { redirect: to.fullPath } }) return { name: 'Login', query: { redirect: to.fullPath } }
return
} }
} }
if (to.meta.perms && Array.isArray(to.meta.perms) && to.meta.perms.length > 0) { if (to.meta.perms && Array.isArray(to.meta.perms) && to.meta.perms.length > 0) {
if (!userStore.hasPermission(to.meta.perms[0])) { if (!userStore.hasPermission(to.meta.perms[0])) {
next('/user/dashboard') return '/user/dashboard'
return
} }
} }
next() return true
}) })
export default router export default router

173
frontend/src/views/agent/AgentList.vue

@ -2,51 +2,164 @@
<div class="agent-page"> <div class="agent-page">
<el-card> <el-card>
<template #header> <template #header>
<span>智能体列表</span> <div style="display:flex;justify-content:space-between;align-items:center">
<span>智能体管理</span>
<el-button type="primary" @click="openDialog()">新建智能体</el-button>
</div>
</template> </template>
<el-row :gutter="20"> <el-table :data="agents" style="width: 100%" v-loading="loading">
<el-col :span="6" v-for="agent in agents" :key="agent.type" style="margin-bottom: 20px"> <el-table-column prop="name" label="名称" width="180" />
<el-card shadow="hover" class="agent-card" @click="$router.push(`/user/agent/chat/${agent.type}`)"> <el-table-column prop="description" label="描述" show-overflow-tooltip />
<div class="agent-icon-wrapper"> <el-table-column prop="model" label="模型" width="140" />
<el-icon :size="40" color="#409EFF"><ChatDotRound /></el-icon> <el-table-column prop="temperature" label="温度" width="80" />
</div> <el-table-column prop="status" label="状态" width="100">
<h4 style="margin: 12px 0 4px; text-align: center">{{ agent.name }}</h4> <template #default="{ row }">
<p style="font-size: 13px; color: #909399; text-align: center">{{ agent.description }}</p> <el-tag :type="row.status === 'active' ? 'success' : 'info'">{{ row.status === 'active' ? '启用' : '禁用' }}</el-tag>
<div style="text-align: center; margin-top: 8px"> </template>
<el-tag size="small" type="info">{{ agent.type }}</el-tag> </el-table-column>
</div> <el-table-column label="操作" width="200">
</el-card> <template #default="{ row }">
</el-col> <el-button size="small" @click="openDialog(row)">编辑</el-button>
</el-row> <el-button size="small" type="danger" @click="deleteAgent(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card> </el-card>
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑智能体' : '新建智能体'" width="600px">
<el-form :model="form" label-width="100px">
<el-form-item label="名称" required>
<el-input v-model="form.name" placeholder="智能体名称" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" placeholder="简要描述" />
</el-form-item>
<el-form-item label="系统提示词">
<el-input v-model="form.system_prompt" type="textarea" :rows="4" placeholder="定义智能体的角色和行为" />
</el-form-item>
<el-form-item label="模型">
<el-select v-model="form.model" style="width: 100%">
<el-option label="GPT-4o-mini" value="gpt-4o-mini" />
<el-option label="GPT-4o" value="gpt-4o" />
<el-option label="GPT-3.5-turbo" value="gpt-3.5-turbo" />
<el-option label="DeepSeek-V3" value="deepseek-chat" />
</el-select>
</el-form-item>
<el-form-item label="温度">
<el-slider v-model="form.temperature" :min="0" :max="2" :step="0.1" />
</el-form-item>
<el-form-item label="工具">
<el-select v-model="form.tools" multiple placeholder="选择工具" style="width: 100%">
<el-option label="解析文档" value="parse_document" />
<el-option label="修正格式" value="format_correction" />
<el-option label="发送企微通知" value="send_notification" />
<el-option label="查询任务列表" value="list_tasks" />
<el-option label="创建任务" value="create_task" />
<el-option label="查询下属" value="list_subordinates" />
<el-option label="生成效率报告" value="generate_efficiency_report" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveAgent" :loading="saving">保存</el-button>
</template>
</el-dialog>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { agentApi } from '@/api' import { agentApi } from '@/api'
import { ChatDotRound } from '@element-plus/icons-vue'
const agents = ref<any[]>([]) const agents = ref<any[]>([])
const loading = ref(false)
const dialogVisible = ref(false)
const isEdit = ref(false)
const saving = ref(false)
const currentId = ref('')
const form = ref({
name: '',
description: '',
system_prompt: '',
model: 'gpt-4o-mini',
temperature: 0.7,
tools: [] as string[],
})
onMounted(async () => { async function loadAgents() {
loading.value = true
try {
const res: any = await agentApi.getList() const res: any = await agentApi.getList()
agents.value = res?.data || res || [] agents.value = res?.data || res || []
}) } finally {
</script> loading.value = false
}
}
function openDialog(row?: any) {
if (row) {
isEdit.value = true
currentId.value = row.id
form.value = {
name: row.name || '',
description: row.description || '',
system_prompt: row.system_prompt || '',
model: row.model || 'gpt-4o-mini',
temperature: row.temperature ?? 0.7,
tools: row.tools || [],
}
} else {
isEdit.value = false
currentId.value = ''
form.value = {
name: '',
description: '',
system_prompt: '',
model: 'gpt-4o-mini',
temperature: 0.7,
tools: [],
}
}
dialogVisible.value = true
}
<style scoped> async function saveAgent() {
.agent-card { if (!form.value.name) {
cursor: pointer; ElMessage.warning('请输入名称')
transition: all 0.3s; return
} }
.agent-card:hover { saving.value = true
transform: translateY(-4px); try {
box-shadow: 0 4px 20px rgba(0,0,0,0.12); if (isEdit.value) {
await agentApi.updateAgent(currentId.value, form.value)
ElMessage.success('更新成功')
} else {
await agentApi.createAgent(form.value)
ElMessage.success('创建成功')
} }
.agent-icon-wrapper { dialogVisible.value = false
text-align: center; await loadAgents()
padding: 16px 0 8px; } catch {
ElMessage.error('保存失败')
} finally {
saving.value = false
} }
</style> }
async function deleteAgent(id: string) {
try {
await ElMessageBox.confirm('确定删除此智能体?', '确认', { type: 'warning' })
await agentApi.deleteAgent(id)
ElMessage.success('已删除')
await loadAgents()
} catch {
// cancelled
}
}
onMounted(loadAgents)
</script>

29
frontend/src/views/flow/FlowEdge.vue

@ -1,29 +0,0 @@
<template>
<path
:d="`M0,0 C${offset},0 ${targetX - sourceX - offset},${targetY - sourceY} ${targetX - sourceX},${targetY - sourceY}`"
class="flow-edge-path"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
id: string
sourceX: number
sourceY: number
targetX: number
targetY: number
}>()
const offset = computed(() => Math.abs(props.targetX - props.sourceX) * 0.5)
</script>
<style scoped>
.flow-edge-path {
fill: none;
stroke: #b1b1b7;
stroke-width: 2;
stroke-linecap: round;
}
</style>

254
frontend/src/views/flow/FlowEditor.vue

@ -8,10 +8,9 @@
<el-input v-model="flowDesc" placeholder="描述" style="width: 300px; margin-left: 12px" /> <el-input v-model="flowDesc" placeholder="描述" style="width: 300px; margin-left: 12px" />
<el-button type="primary" @click="saveFlow" :loading="saving">保存</el-button> <el-button type="primary" @click="saveFlow" :loading="saving">保存</el-button>
<el-button @click="testFlow">验证</el-button> <el-button @click="testFlow">验证</el-button>
<el-button @click="undo" :disabled="!canUndo">撤销</el-button>
<el-button @click="redo" :disabled="!canRedo">重做</el-button>
<el-button v-if="isEdit" type="success" @click="publishFlow">上架到企微</el-button> <el-button v-if="isEdit" type="success" @click="publishFlow">上架到企微</el-button>
<el-button type="info" @click="toggleMarket" v-if="isEdit && flowStatus === 'published'">
{{ marketVisible ? '关闭预览' : '流市场预览' }}
</el-button>
</div> </div>
</el-card> </el-card>
@ -32,8 +31,9 @@
<div class="panel-title">操作提示</div> <div class="panel-title">操作提示</div>
<div class="panel-hint"> <div class="panel-hint">
<p>拖拽节点到画布</p> <p>拖拽节点到画布</p>
<p>点击节点端口连线</p> <p>从绿色圆点拖线(true)</p>
<p>右键删除连线</p> <p>从红色圆点拖线(false)</p>
<p>右键点击边可删除</p>
<p>滚轮缩放画布</p> <p>滚轮缩放画布</p>
</div> </div>
</div> </div>
@ -45,13 +45,21 @@
</div> </div>
<VueFlow <VueFlow
ref="vueFlowRef" ref="vueFlowRef"
v-model="elements" v-model:nodes="nodes"
v-model:edges="edges"
:default-viewport="{ zoom: 1, x: 0, y: 0 }" :default-viewport="{ zoom: 1, x: 0, y: 0 }"
:min-zoom="0.2" :min-zoom="0.2"
:max-zoom="4" :max-zoom="4"
:snap-to-grid="true" :snap-to-grid="true"
:snap-grid="[20, 20]" :snap-grid="[20, 20]"
:undo-redo="true"
:connection-line-style="{ stroke: '#409EFF', strokeWidth: 2 }" :connection-line-style="{ stroke: '#409EFF', strokeWidth: 2 }"
:default-edge-options="{
type: 'smoothstep',
animated: true,
markerEnd: MarkerType.ArrowClosed,
style: { stroke: '#409EFF', strokeWidth: 2 },
}"
fit-view-on-init fit-view-on-init
@nodes-change="onNodesChange" @nodes-change="onNodesChange"
@edges-change="onEdgesChange" @edges-change="onEdgesChange"
@ -63,7 +71,13 @@
> >
<Background :gap="20" :size="1" /> <Background :gap="20" :size="1" />
<Controls position="bottom-right" /> <Controls position="bottom-right" />
<MiniMap position="bottom-left" /> <MiniMap
position="bottom-left"
:node-stroke-color="'#409EFF'"
:node-color="getMiniMapColor"
pannable
zoomable
/>
<template #node-custom="nodeProps"> <template #node-custom="nodeProps">
<FlowNode <FlowNode
@ -73,17 +87,6 @@
@delete="removeNode(nodeProps.id)" @delete="removeNode(nodeProps.id)"
/> />
</template> </template>
<template #edge-custom="edgeProps">
<FlowEdge
:id="edgeProps.id"
:source-x="edgeProps.sourceX"
:source-y="edgeProps.sourceY"
:target-x="edgeProps.targetX"
:target-y="edgeProps.targetY"
@delete="removeEdge(edgeProps.id)"
/>
</template>
</VueFlow> </VueFlow>
</div> </div>
@ -100,7 +103,22 @@
<el-input v-model="selectedNodeData.label" @change="onConfigLabelChange" /> <el-input v-model="selectedNodeData.label" @change="onConfigLabelChange" />
</el-form-item> </el-form-item>
<template v-if="selectedNode?.type === 'llm'"> <template v-if="selectedNode?.data?.type === 'trigger'">
<el-form-item label="事件类型">
<el-select v-model="selectedNodeData.event_type" @change="onConfigChange" placeholder="企微触发事件">
<el-option label="文本消息" value="text_message" />
<el-option label="按钮点击" value="button_click" />
<el-option label="进入聊天" value="enter_chat" />
</el-select>
</el-form-item>
</template>
<template v-if="selectedNode?.data?.type === 'llm'">
<el-form-item label="选择Agent">
<el-select v-model="selectedNodeData.agent_id" @change="onAgentSelect" placeholder="手动配置或选择已有Agent" clearable>
<el-option v-for="a in agentList" :key="a.id" :label="a.name" :value="a.id" />
</el-select>
</el-form-item>
<el-form-item label="系统提示词"> <el-form-item label="系统提示词">
<el-input v-model="selectedNodeData.system_prompt" type="textarea" :rows="4" @change="onConfigChange" /> <el-input v-model="selectedNodeData.system_prompt" type="textarea" :rows="4" @change="onConfigChange" />
</el-form-item> </el-form-item>
@ -117,7 +135,7 @@
</el-form-item> </el-form-item>
</template> </template>
<template v-if="selectedNode?.type === 'tool'"> <template v-if="selectedNode?.data?.type === 'tool'">
<el-form-item label="工具名称"> <el-form-item label="工具名称">
<el-select v-model="selectedNodeData.tool_name" @change="onConfigChange"> <el-select v-model="selectedNodeData.tool_name" @change="onConfigChange">
<el-option label="解析文档" value="parse_document" /> <el-option label="解析文档" value="parse_document" />
@ -137,7 +155,7 @@
</el-form-item> </el-form-item>
</template> </template>
<template v-if="selectedNode?.type === 'mcp'"> <template v-if="selectedNode?.data?.type === 'mcp'">
<el-form-item label="MCP服务"> <el-form-item label="MCP服务">
<el-select v-model="selectedNodeData.mcp_server" @change="onConfigChange"> <el-select v-model="selectedNodeData.mcp_server" @change="onConfigChange">
<el-option label="未选择" value="" /> <el-option label="未选择" value="" />
@ -149,7 +167,7 @@
</el-form-item> </el-form-item>
</template> </template>
<template v-if="selectedNode?.type === 'wecom_notify'"> <template v-if="selectedNode?.data?.type === 'wecom_notify'">
<el-form-item label="消息模板"> <el-form-item label="消息模板">
<el-input v-model="selectedNodeData.message_template" type="textarea" :rows="3" @change="onConfigChange" /> <el-input v-model="selectedNodeData.message_template" type="textarea" :rows="3" @change="onConfigChange" />
</el-form-item> </el-form-item>
@ -158,13 +176,13 @@
</el-form-item> </el-form-item>
</template> </template>
<template v-if="selectedNode?.type === 'condition'"> <template v-if="selectedNode?.data?.type === 'condition'">
<el-form-item label="条件表达式"> <el-form-item label="条件表达式">
<el-input v-model="selectedNodeData.condition" placeholder="如: status == 'done'" @change="onConfigChange" /> <el-input v-model="selectedNodeData.condition" placeholder="如: status == 'done'" @change="onConfigChange" />
</el-form-item> </el-form-item>
</template> </template>
<template v-if="selectedNode?.type === 'rag'"> <template v-if="selectedNode?.data?.type === 'rag'">
<el-form-item label="知识库"> <el-form-item label="知识库">
<el-input v-model="selectedNodeData.knowledge_base" placeholder="知识库ID" @change="onConfigChange" /> <el-input v-model="selectedNodeData.knowledge_base" placeholder="知识库ID" @change="onConfigChange" />
</el-form-item> </el-form-item>
@ -173,7 +191,7 @@
</el-form-item> </el-form-item>
</template> </template>
<template v-if="selectedNode?.type === 'output'"> <template v-if="selectedNode?.data?.type === 'output'">
<el-form-item label="输出格式"> <el-form-item label="输出格式">
<el-select v-model="selectedNodeData.format" @change="onConfigChange"> <el-select v-model="selectedNodeData.format" @change="onConfigChange">
<el-option label="纯文本" value="text" /> <el-option label="纯文本" value="text" />
@ -188,10 +206,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, markRaw, watch } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { VueFlow, useVueFlow } from '@vue-flow/core' import { VueFlow, useVueFlow, MarkerType } from '@vue-flow/core'
import { Background } from '@vue-flow/background' import { Background } from '@vue-flow/background'
import { Controls } from '@vue-flow/controls' import { Controls } from '@vue-flow/controls'
import { MiniMap } from '@vue-flow/minimap' import { MiniMap } from '@vue-flow/minimap'
@ -199,13 +217,12 @@ import '@vue-flow/core/dist/style.css'
import '@vue-flow/core/dist/theme-default.css' import '@vue-flow/core/dist/theme-default.css'
import '@vue-flow/controls/dist/style.css' import '@vue-flow/controls/dist/style.css'
import '@vue-flow/minimap/dist/style.css' import '@vue-flow/minimap/dist/style.css'
import { flowApi, mcpApi } from '@/api' import { flowApi, mcpApi, agentApi } from '@/api'
import { import {
Promotion, ChatDotRound, Tools, Connection, Bell, Promotion, ChatDotRound, Tools, Connection, Bell,
DataAnalysis, Search, DataAnalysis, Search,
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import FlowNode from './FlowNode.vue' import FlowNode from './FlowNode.vue'
import FlowEdge from './FlowEdge.vue'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@ -216,11 +233,10 @@ const flowName = ref('新工作流')
const flowDesc = ref('') const flowDesc = ref('')
const flowStatus = ref('') const flowStatus = ref('')
const saving = ref(false) const saving = ref(false)
const marketVisible = ref(false)
const selectedNodeId = ref('') const selectedNodeId = ref('')
const selectedNodeData = ref<any>({}) const selectedNodeData = ref<any>({})
const vueFlowRef = ref()
const mcpServers = ref<any[]>([]) const mcpServers = ref<any[]>([])
const agentList = ref<any[]>([])
const nodeTypes = [ const nodeTypes = [
{ type: 'trigger', label: '触发节点', icon: Promotion, typeDesc: '企微触发' }, { type: 'trigger', label: '触发节点', icon: Promotion, typeDesc: '企微触发' },
@ -244,13 +260,23 @@ const colorMap: Record<string, string> = {
output: '#722ed1', output: '#722ed1',
} }
const elements = ref<any[]>([]) const vueFlowStore = useVueFlow()
const { nodes, edges, addNodes, addEdges, removeNodes, removeEdges, toObject } = vueFlowStore
const storeAny = vueFlowStore as any
const undo = () => storeAny.undo?.()
const redo = () => storeAny.redo?.()
const canUndo = computed(() => !!(storeAny.canUndo ?? false))
const canRedo = computed(() => !!(storeAny.canRedo ?? false))
let nodeCounter = 0 let nodeCounter = 0
const selectedNode = computed(() => { const selectedNode = computed(() => {
return elements.value.find((el: any) => el.id === selectedNodeId.value && el.type !== 'edge') || null return nodes.value.find((n: any) => n.id === selectedNodeId.value) || null
}) })
function getMiniMapColor(node: any) {
return colorMap[node?.data?.type || node?.type] || '#409EFF'
}
function onDragStart(event: DragEvent, node: (typeof nodeTypes)[0]) { function onDragStart(event: DragEvent, node: (typeof nodeTypes)[0]) {
if (event.dataTransfer) { if (event.dataTransfer) {
event.dataTransfer.setData('application/vueflow', JSON.stringify(node)) event.dataTransfer.setData('application/vueflow', JSON.stringify(node))
@ -273,10 +299,10 @@ function onDrop(event: DragEvent) {
y: event.clientY - bounds.top - 20, y: event.clientY - bounds.top - 20,
} }
} }
} catch { /* fallback to default position */ } } catch { /* fallback */ }
const id = `node_${nodeCounter++}` const id = `node_${++nodeCounter}`
const newNode: any = { const newNode = {
id, id,
type: 'custom', type: 'custom',
position, position,
@ -286,91 +312,116 @@ function onDrop(event: DragEvent) {
typeDesc: nodeData.typeDesc, typeDesc: nodeData.typeDesc,
color: colorMap[nodeData.type] || '#409EFF', color: colorMap[nodeData.type] || '#409EFF',
config: nodeData.type === 'llm' config: nodeData.type === 'llm'
? { system_prompt: '', model: 'gpt-4o-mini', temperature: 0.7 } ? { system_prompt: '', model: 'gpt-4o-mini', temperature: 0.7, agent_id: '' }
: {}, : {},
}, },
draggable: true, draggable: true,
connectable: true, connectable: true,
} }
addNodes([newNode])
elements.value = [...elements.value, newNode]
} }
function onConnect(connection: any) { function onConnect(connection: any) {
const newEdge: any = { const sourceHandle = connection.sourceHandle || 'source'
id: `edge_${connection.source}_${connection.target}_${nodeCounter++}`, const edgeId = `edge_${connection.source}_${connection.target}_${sourceHandle}`
const isConditionFalse = sourceHandle === 'false'
const newEdge = {
id: edgeId,
source: connection.source, source: connection.source,
target: connection.target, target: connection.target,
sourceHandle: connection.sourceHandle, sourceHandle: connection.sourceHandle,
targetHandle: connection.targetHandle, targetHandle: connection.targetHandle,
type: 'custom', type: 'smoothstep',
animated: true,
markerEnd: MarkerType.ArrowClosed,
style: isConditionFalse
? { stroke: '#F56C6C', strokeWidth: 2 }
: { stroke: '#409EFF', strokeWidth: 2 },
} }
elements.value.push(newEdge) addEdges([newEdge])
} }
function onNodeClick({ node }: any) { function onNodeClick({ node }: any) {
selectedNodeId.value = node.id selectedNodeId.value = node.id
const found = elements.value.find((el: any) => el.id === node.id && el.type !== 'edge') const d = node.data || {}
if (found) {
const d = found.data || {}
selectedNodeData.value = { selectedNodeData.value = {
label: d.label || '', label: d.label || '',
typeDesc: d.typeDesc || '', typeDesc: d.typeDesc || '',
...(d.config || {}), ...(d.config || {}),
} }
} }
}
function onPaneClick() { function onPaneClick() {
selectedNodeId.value = '' selectedNodeId.value = ''
} }
function onConfigLabelChange() { function onConfigLabelChange() {
const found = elements.value.find((el: any) => el.id === selectedNodeId.value && el.type !== 'edge') const idx = nodes.value.findIndex((n: any) => n.id === selectedNodeId.value)
if (found) { if (idx !== -1) {
found.data = { ...found.data, label: selectedNodeData.value.label } const updated = { ...nodes.value[idx] }
updated.data = { ...updated.data, label: selectedNodeData.value.label }
nodes.value[idx] = updated
}
}
function onAgentSelect(val: string) {
if (!val) return
const agent = agentList.value.find(a => a.id === val)
if (agent) {
selectedNodeData.value.system_prompt = agent.system_prompt || ''
selectedNodeData.value.model = agent.model || 'gpt-4o-mini'
selectedNodeData.value.temperature = agent.temperature ?? 0.7
} }
onConfigChange()
} }
function onConfigChange() { function onConfigChange() {
const found = elements.value.find((el: any) => el.id === selectedNodeId.value && el.type !== 'edge') const idx = nodes.value.findIndex((n: any) => n.id === selectedNodeId.value)
if (found) { if (idx === -1) return
const found = nodes.value[idx]
const nodeType = found.data.type
const cfg: any = {} const cfg: any = {}
if (found.data.type === 'llm') {
if (nodeType === 'trigger') {
cfg.event_type = selectedNodeData.value.event_type
} else if (nodeType === 'llm') {
cfg.system_prompt = selectedNodeData.value.system_prompt cfg.system_prompt = selectedNodeData.value.system_prompt
cfg.model = selectedNodeData.value.model cfg.model = selectedNodeData.value.model
cfg.temperature = selectedNodeData.value.temperature cfg.temperature = selectedNodeData.value.temperature
} else if (found.data.type === 'tool') { cfg.agent_id = selectedNodeData.value.agent_id
} else if (nodeType === 'tool') {
cfg.tool_name = selectedNodeData.value.tool_name cfg.tool_name = selectedNodeData.value.tool_name
} else if (found.data.type === 'mcp') { } else if (nodeType === 'mcp') {
cfg.mcp_server = selectedNodeData.value.mcp_server cfg.mcp_server = selectedNodeData.value.mcp_server
cfg.tool_name = selectedNodeData.value.tool_name cfg.tool_name = selectedNodeData.value.tool_name
} else if (found.data.type === 'wecom_notify') { } else if (nodeType === 'wecom_notify') {
cfg.message_template = selectedNodeData.value.message_template cfg.message_template = selectedNodeData.value.message_template
cfg.target = selectedNodeData.value.target cfg.target = selectedNodeData.value.target
} else if (found.data.type === 'condition') { } else if (nodeType === 'condition') {
cfg.condition = selectedNodeData.value.condition cfg.condition = selectedNodeData.value.condition
} else if (found.data.type === 'rag') { } else if (nodeType === 'rag') {
cfg.knowledge_base = selectedNodeData.value.knowledge_base cfg.knowledge_base = selectedNodeData.value.knowledge_base
cfg.top_k = selectedNodeData.value.top_k cfg.top_k = selectedNodeData.value.top_k
} else if (found.data.type === 'output') { } else if (nodeType === 'output') {
cfg.format = selectedNodeData.value.format cfg.format = selectedNodeData.value.format
} }
found.data = { ...found.data, config: cfg }
} const updated = { ...found }
updated.data = { ...found.data, config: cfg }
nodes.value[idx] = updated
} }
function removeNode(id: string) { function removeNode(id: string) {
elements.value = elements.value.filter((el: any) => el.id !== id && el.source !== id && el.target !== id) removeNodes([id])
edges.value = edges.value.filter((e: any) => e.source !== id && e.target !== id)
if (selectedNodeId.value === id) selectedNodeId.value = '' if (selectedNodeId.value === id) selectedNodeId.value = ''
} }
function removeEdge(id: string) {
elements.value = elements.value.filter((el: any) => el.id !== id)
}
function clearCanvas() { function clearCanvas() {
elements.value = [] nodes.value = []
edges.value = []
nodeCounter = 0 nodeCounter = 0
selectedNodeId.value = '' selectedNodeId.value = ''
} }
@ -382,22 +433,21 @@ async function saveFlow() {
if (!flowName.value) { ElMessage.warning('请输入流名称'); return } if (!flowName.value) { ElMessage.warning('请输入流名称'); return }
saving.value = true saving.value = true
try { try {
const nodes = elements.value const serializedNodes = nodes.value.map((n: any) => ({
.filter((el: any) => el.type !== 'edge')
.map((n: any) => ({
id: n.id, id: n.id,
type: n.data?.type || n.type, type: n.data?.type || n.type,
label: n.data?.label || n.id, label: n.data?.label || n.id,
config: n.data?.config || {}, config: n.data?.config || {},
position: n.position,
})) }))
const edges = elements.value const serializedEdges = edges.value.map((e: any) => ({
.filter((el: any) => el.type === 'edge' || el.source)
.map((e: any) => ({
source: e.source, source: e.source,
target: e.target, target: e.target,
sourceHandle: e.sourceHandle || 'source',
condition: e.sourceHandle === 'false' ? 'false' : (e.sourceHandle === 'true' ? 'true' : undefined),
})) }))
const payload = { name: flowName.value, description: flowDesc.value, nodes, edges, trigger: {} } const payload = { name: flowName.value, description: flowDesc.value, nodes: serializedNodes, edges: serializedEdges, trigger: {} }
if (isEdit.value) { if (isEdit.value) {
await flowApi.updateFlow(flowId.value, payload) await flowApi.updateFlow(flowId.value, payload)
@ -435,10 +485,6 @@ async function publishFlow() {
await loadFlow() await loadFlow()
} }
function toggleMarket() {
marketVisible.value = !marketVisible.value
}
async function loadFlow() { async function loadFlow() {
if (!isEdit.value) return if (!isEdit.value) return
try { try {
@ -449,11 +495,14 @@ async function loadFlow() {
flowStatus.value = flow.status || '' flowStatus.value = flow.status || ''
const definition = flow.definition_json || {} const definition = flow.definition_json || {}
const loadedElements: any[] = [] const loadedNodes: any[] = []
const loadedEdges: any[] = []
const loadedNodes = (definition.nodes || []).map((n: any, i: number) => { const defNodes = definition.nodes || []
for (let i = 0; i < defNodes.length; i++) {
const n = defNodes[i]
const nt = nodeTypes.find(t => t.type === n.type) const nt = nodeTypes.find(t => t.type === n.type)
return { loadedNodes.push({
id: n.id || `node_${i}`, id: n.id || `node_${i}`,
type: 'custom', type: 'custom',
position: n.position || { x: 100 + (i % 4) * 250, y: 60 + Math.floor(i / 4) * 150 }, position: n.position || { x: 100 + (i % 4) * 250, y: 60 + Math.floor(i / 4) * 150 },
@ -461,26 +510,37 @@ async function loadFlow() {
label: n.label || n.id || `节点${i}`, label: n.label || n.id || `节点${i}`,
type: n.type, type: n.type,
typeDesc: nt?.typeDesc || n.type, typeDesc: nt?.typeDesc || n.type,
icon: n.type,
color: colorMap[n.type] || '#409EFF', color: colorMap[n.type] || '#409EFF',
config: n.config || {}, config: n.config || {},
}, },
draggable: true, draggable: true,
connectable: true, connectable: true,
}
}) })
loadedElements.push(...loadedNodes) }
const loadedEdges = (definition.edges || []).map((e: any, i: number) => ({ const defEdges = definition.edges || []
id: e.id || `edge_${e.source}_${e.target}`, for (const e of defEdges) {
source: e.source || e.from, const source = e.source || e.from
target: e.target || e.to, const target = e.target || e.to
type: 'custom', const cond = e.condition || e.sourceHandle
})) const isFalse = cond === 'false'
loadedElements.push(...loadedEdges) loadedEdges.push({
id: e.id || `edge_${source}_${target}`,
source,
target,
sourceHandle: cond || 'source',
type: 'smoothstep',
animated: true,
markerEnd: MarkerType.ArrowClosed,
style: isFalse
? { stroke: '#F56C6C', strokeWidth: 2 }
: { stroke: '#409EFF', strokeWidth: 2 },
})
}
elements.value = loadedElements nodes.value = loadedNodes
nodeCounter = loadedNodes.length edges.value = loadedEdges
nodeCounter = defNodes.length
} catch { /**/ } } catch { /**/ }
} }
@ -491,11 +551,19 @@ async function loadMcpServers() {
} catch { /**/ } } catch { /**/ }
} }
async function loadAgents() {
try {
const res: any = await agentApi.getList()
agentList.value = Array.isArray(res?.data) ? res.data : (Array.isArray(res) ? res : [])
} catch { /**/ }
}
onMounted(async () => { onMounted(async () => {
if (isEdit.value) { if (isEdit.value) {
await loadFlow() await loadFlow()
} }
await loadMcpServers() await loadMcpServers()
await loadAgents()
}) })
</script> </script>

60
frontend/src/views/flow/FlowMarket.vue

@ -2,7 +2,31 @@
<div class="flow-market-page"> <div class="flow-market-page">
<el-card> <el-card>
<template #header> <template #header>
<span>流市场 - 已上架的工作流</span> <span>流模板市场</span>
</template>
<el-row :gutter="20">
<el-col :span="8" v-for="tpl in templates" :key="tpl.id" style="margin-bottom: 20px">
<el-card shadow="hover" class="flow-card">
<div class="flow-card-header">
<h4>{{ tpl.name }}</h4>
<el-icon :size="20"><component :is="tpl.icon" /></el-icon>
</div>
<p class="flow-desc">{{ tpl.description || '暂无描述' }}</p>
<div class="flow-card-footer">
<el-tag size="small" type="info">{{ tpl.nodes?.length || 0 }} 节点</el-tag>
<el-button size="small" type="primary" @click="useTemplate(tpl.id)">使用模板</el-button>
</div>
</el-card>
</el-col>
</el-row>
<el-empty v-if="!templates.length" description="暂无流模板" />
</el-card>
<el-card style="margin-top: 20px">
<template #header>
<span>已上架的工作流</span>
</template> </template>
<el-row :gutter="20"> <el-row :gutter="20">
@ -31,14 +55,44 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { flowApi } from '@/api' import { flowApi } from '@/api'
import { Document, Bell, DataAnalysis, Search, Tools } from '@element-plus/icons-vue'
const router = useRouter()
const flows = ref<any[]>([]) const flows = ref<any[]>([])
const templates = ref<any[]>([])
const iconMap: Record<string, any> = {
Document, Bell, DataAnalysis, Search, Tools,
}
onMounted(async () => { onMounted(async () => {
const res: any = await flowApi.getMarket() const [marketRes, tplRes]: any = await Promise.all([
flows.value = res || [] flowApi.getMarket().catch(() => ({ data: [] })),
flowApi.getTemplates().catch(() => ({ data: [] })),
])
flows.value = Array.isArray(marketRes) ? marketRes : (marketRes?.data || [])
const rawTemplates = Array.isArray(tplRes) ? tplRes : (tplRes?.data || [])
templates.value = rawTemplates.map((t: any) => ({
...t,
icon: iconMap[t.icon] || Tools,
}))
}) })
async function useTemplate(templateId: string) {
try {
const res: any = await flowApi.useTemplate(templateId)
const data = res?.data || res || {}
if (data.id) {
ElMessage.success('模板已创建,请继续编辑')
router.push(`/admin/flow/editor/${data.id}`)
}
} catch (e) {
ElMessage.error('创建失败')
}
}
</script> </script>
<style scoped> <style scoped>

56
frontend/src/views/flow/FlowNode.vue

@ -8,8 +8,22 @@
</div> </div>
<button class="node-delete-btn" @click="$emit('delete')" title="删除">×</button> <button class="node-delete-btn" @click="$emit('delete')" title="删除">×</button>
<Handle type="target" :position="Position.Top" class="node-handle" /> <template v-if="data?.type !== 'trigger'">
<Handle type="source" :position="Position.Bottom" class="node-handle" /> <Handle type="target" :position="Position.Left" id="target" class="node-handle" />
</template>
<template v-if="data?.type === 'trigger' || data?.type === 'llm' || data?.type === 'tool' || data?.type === 'mcp' || data?.type === 'rag'">
<Handle type="source" :position="Position.Right" id="source" class="node-handle" />
</template>
<template v-if="data?.type === 'condition'">
<Handle type="source" :position="Position.Right" id="true" class="node-handle node-handle-true">
<span class="handle-label-true"></span>
</Handle>
<Handle type="source" :position="Position.Bottom" id="false" class="node-handle node-handle-false">
<span class="handle-label-false"></span>
</Handle>
</template>
</div> </div>
</template> </template>
@ -82,10 +96,44 @@ defineEmits<{
display: flex; display: flex;
} }
.node-handle { .node-handle {
width: 10px; width: 12px;
height: 10px; height: 12px;
background: #409EFF; background: #409EFF;
border: 2px solid #fff; border: 2px solid #fff;
border-radius: 50%; border-radius: 50%;
} }
.node-handle-true {
background: #67C23A;
width: 14px;
height: 14px;
border-color: #fff;
}
.node-handle-false {
background: #F56C6C;
width: 14px;
height: 14px;
border-color: #fff;
}
.handle-label-true {
position: absolute;
top: -18px;
left: 50%;
transform: translateX(-50%);
font-size: 11px;
color: #67C23A;
font-weight: bold;
pointer-events: none;
white-space: nowrap;
}
.handle-label-false {
position: absolute;
right: 16px;
top: 50%;
transform: translateY(-50%);
font-size: 11px;
color: #F56C6C;
font-weight: bold;
pointer-events: none;
white-space: nowrap;
}
</style> </style>

123
gemini.md

@ -0,0 +1,123 @@
基于 AgentScope 搭建一个无代码(No-Code)的大模型智能体工作流平台是一个非常具有商业价值和技术深度的方向。你提出的 “LLM -> 智能体(Agent) -> 流节点(Node) -> 工作流(Workflow)” 逐层递进的架构逻辑非常清晰,完全契合目前主流的 AI 编排平台(如 Coze, Dify, FastGPT)的设计哲学。
AgentScope 本身自带了 Msg(消息传递)、Agent(智能体封装)和 Pipeline(流程控制)的底层抽象,这为你做上层无代码可视化封装提供了极好的基础。
以下我为你设计的系统大纲以及具体实现的落地思路:
第一部分:核心概念映射设计(架构分层)
在你的无代码平台中,这四层架构如何与前端 UI 和后端的 AgentScope 对应:
层级 平台抽象概念 (UI 展示) AgentScope 底层映射 作用描述
1. LLM 模型配置中心 (选择模型、填 API Key) ModelWrapper (模型配置) 提供纯粹的推理能力,相当于大脑的神经元。
2. 智能体 Agent 调试面板 (人设、提示词、工具箱、知识库) AgentBase (如 DialogAgent, ReActAgent) 赋予 LLM 具体的人格、记忆和执行动作的能力(大脑+手脚)。
3. 流节点 画布上的单个卡片 (输入参数、执行逻辑、输出变量) 自定义的 Node Wrapper 或 Pipeline 原子件 将 Agent 包装成流水线上的一个工位,规定好它的标准输入和输出格式。
4. 工作流 拖拽连线构成的有向无环图 (DAG) Pipeline (如 Sequential, IfElse, Switch) 编排全局逻辑,决定数据流的走向,协调多个 Agent 协作。
第二部分:平台系统大纲设计
1. 前端架构 (可视化画布)
技术栈: React / Vue3 + React Flow / Vue Flow (核心拓扑图库)
核心模块:
节点组件库 (Node Panel): 包含触发器节点、Agent 节点、逻辑节点(条件分支、循环)、工具节点(HTTP 请求、代码执行)、结束节点。
画布区 (Canvas): 拖拽连线,支持缩放、对齐、自动布局。
配置面板 (Property Panel): 点击节点后,右侧弹出详细配置(如给 Agent 节点选定某个 LLM,配置 Prompt 和连线变量映射)。
调试终端 (Debug Panel): 实时查看工作流的执行日志和 Agent 对话过程。
2. 后端架构 (业务与 API 层)
技术栈: Python + FastAPI + PostgreSQL + Redis
核心模块:
资产管理: 管理用户的 API Keys、Prompts、工具定义(Tools)、知识库(RAG 向量存储)。
蓝图管理: 接收前端发来的 JSON DAG(有向无环图),保存为工作流草稿或发布版本。
调度 API: 触发工作流执行的接口(支持同步返回或 SSE 流式输出)。
3. 核心执行引擎 (Workflow Engine based on AgentScope)
解析器 (Graph Parser): 将前端的 JSON 拓扑图解析为执行图。
封装器 (AgentScope Builder): 动态实例化 AgentScope 的 Model, Agent 和 Pipeline。
运行时 (Runtime): 维护运行时的上下文(Context / Memory),捕获 AgentScope 生成的 Msg,并通过 WebSocket/SSE 推送给前端。
第三部分:具体怎么实现好?(核心实现思路)
要将前端连线变成 AgentScope 的代码执行,关键在于**“图的解析与执行”**。以下是具体的落地步骤:
步骤 1:定义标准化的 JSON 数据结构
前端的 React Flow 导出的图结构必须规范化,这是前后端沟通的桥梁。
code
JSON
{
"workflow_id": "wf_001",
"nodes": [
{
"id": "node_start",
"type": "StartNode",
"data": { "input_schema": ["user_query"] }
},
{
"id": "node_agent_1",
"type": "AgentNode",
"data": {
"agent_name": "内容规划师",
"llm_config": "gpt-4o-config",
"sys_prompt": "你是一个规划师,根据用户的 {{user_query}} 生成大纲。",
"memory_type": "default"
}
},
{
"id": "node_end",
"type": "EndNode"
}
],
"edges": [
{ "source": "node_start", "target": "node_agent_1" },
{ "source": "node_agent_1", "target": "node_end" }
]
}
步骤 2:在后端实现 Node 到 AgentScope 的映射
在 Python 后端,你需要写一个引擎来解析上面的 JSON。AgentScope 提供了灵活的机制。
实现思路A:静态生成法(推荐用于初期)
将前端 JSON 翻译成一段标准的 Python 脚本(利用 Jinja2 模板),然后在沙箱环境中 subprocess 运行它。这种方式最简单,调试也方便。
实现思路B:动态执行法(推荐用于生产,更易于流式反馈)
在内存中动态构建 AgentScope 对象,并利用拓扑排序执行。
你可以自己写一个 DAG 执行器,把 AgentScope 的 Agent 包装在你的节点类中:
code
Python
import agentscope
from agentscope.agents import DialogAgent
from agentscope.message import Msg
class AgentNodeWrapper:
def __init__(self, node_config):
self.config = node_config
# 1. 动态初始化模型配置
agentscope.init(model_configs=[self.config['llm_config']])
# 2. 动态实例化 AgentScope 的 Agent
self.agent = DialogAgent(
name=self.config['agent_name'],
sys_prompt=self.config['sys_prompt'],
model_config_name=self.config['llm_config']['config_name']
)
def execute(self, input_context):
# input_context 是上一级节点传来的字典或 Msg
# 3. 变量替换:将 prompt 里的 {{xxx}} 替换为 input_context 里的真实值
prompt = self.render_prompt(self.config['sys_prompt'], input_context)
# 4. 封装为 AgentScope 的 Msg
msg = Msg(name="System", role="user", content=prompt)
# 5. 调用 AgentScope 的核心执行方法
response = self.agent(msg)
# 6. 返回结果给下一个节点
return response.content
步骤 3:利用 AgentScope 的 Pipeline 处理复杂路由
AgentScope 原生提供了 SequentialPipeline, IfElsePipeline, SwitchPipeline, ForLoopPipeline。
当解析 JSON 时发现有条件分支:
前端是一个 "If/Else 节点"。
后端将其翻译为 AgentScope 的 IfElsePipeline。将该节点前置 Agent 的输出作为 condition 判定函数,走入不同的下一级 Agent 链路。
步骤 4:上下文与数据流 (Data Flow) 管理
工作流最难的是数据穿透(比如节点 C 需要用到节点 A 的输出)。
解决方案: 引入一个全局的 WorkflowContext 对象(类似一个字典 dict)。
每个节点执行完毕后,将它的输出写入 Context[node_id_output] = result。
下级节点在执行前,根据连线规则,从 Context 中提取所需的变量,拼接到当前 Agent 的输入 Msg 中。
第四部分:开发推进建议(MVP 最小可行性产品)
不要一开始就做大而全,建议按以下 3 个迭代阶段推进:
Phase 1 (验证期): 线性工作流。
只做一条直线的连线:开始 -> Agent A -> Agent B -> 结束。
打通前端 React Flow 连线 -> 后端解析 -> AgentScope SequentialPipeline 执行 -> 打印日志。
Phase 2 (进阶期): 引入分支与工具。
增加 If/Else 节点(底层映射 AgentScope 的 IfElsePipeline)。
给 Agent 节点增加 Tools 配置面板,利用 AgentScope 自带的工具(WebSearch, Python执行等),构建 ReAct Agent 节点。
Phase 3 (体验期): 完善流式响应与记忆。
将后端的执行过程通过 Server-Sent Events (SSE) 实时推送前端,实现打字机效果。
接入 AgentScope 的 Memory 管理模块,实现多轮对话状态下的工作流触发。
Loading…
Cancel
Save