""" Agent 可视化编排 API(管理员) Phase 3.6: Agent Visual Orchestration - DAG Workflow Editor """ from fastapi import APIRouter, Depends, HTTPException, Query, Body from sqlalchemy.orm import Session from sqlalchemy import or_, func from pydantic import BaseModel from typing import Optional, List, Any import json import uuid from datetime import datetime from ...database import get_db, SessionLocal from ...models import MarketApp, EvolutionSkill, AIModel, KnowledgeBase from ...auth import get_current_admin from ...middleware.log_service import LogService router = APIRouter() # ============================================================ # Pydantic models # ============================================================ class WorkflowNode(BaseModel): id: str type: str # start / llm / knowledge / skill / condition / http / code / end position: dict # {x, y} data: dict # {label, config: {...}} class WorkflowEdge(BaseModel): id: str source: str target: str sourceHandle: Optional[str] = None targetHandle: Optional[str] = None class WorkflowCreate(BaseModel): name: str description: Optional[str] = None agent_id: Optional[str] = None nodes: List[WorkflowNode] = [] edges: List[WorkflowEdge] = [] variables: Optional[dict] = None class WorkflowUpdate(BaseModel): name: Optional[str] = None description: Optional[str] = None agent_id: Optional[str] = None nodes: Optional[List[WorkflowNode]] = None edges: Optional[List[WorkflowEdge]] = None variables: Optional[dict] = None status: Optional[str] = None # draft / published / archived class WorkflowExecuteRequest(BaseModel): input_text: str = "" variables: Optional[dict] = None # ============================================================ # Node type definitions (templates) # ============================================================ NODE_TEMPLATES = { "start": { "type": "start", "label": "开始", "icon": "play", "color": "#3B6D11", "description": "工作流入口节点", "config_schema": { "input_variable": {"type": "string", "label": "输入变量名", "default": "input"} } }, "llm": { "type": "llm", "label": "LLM 调用", "icon": "brain", "color": "#534AB7", "description": "调用大语言模型生成回复", "config_schema": { "model_id": {"type": "select", "label": "模型", "options_from": "models"}, "system_prompt": {"type": "textarea", "label": "系统提示词", "default": "你是一个有用的AI助手。"}, "user_prompt": {"type": "textarea", "label": "用户提示词模板", "default": "{input}", "help": "可用变量: {input}, {prev_output}"}, "temperature": {"type": "number", "label": "温度", "default": 0.7, "min": 0, "max": 2}, "max_tokens": {"type": "number", "label": "最大Token", "default": 2048} } }, "knowledge": { "type": "knowledge", "label": "知识库检索", "icon": "book", "color": "#185FA5", "description": "从知识库检索相关文档", "config_schema": { "kb_id": {"type": "select", "label": "知识库", "options_from": "knowledge_bases"}, "query_template": {"type": "textarea", "label": "查询模板", "default": "{input}"}, "top_k": {"type": "number", "label": "返回数量", "default": 3} } }, "skill": { "type": "skill", "label": "Skill 调用", "icon": "zap", "color": "#0F6E56", "description": "执行已注册的 Skill", "config_schema": { "skill_id": {"type": "select", "label": "Skill", "options_from": "skills"}, "params_template": {"type": "textarea", "label": "参数模板 (JSON)", "default": "{}"} } }, "condition": { "type": "condition", "label": "条件分支", "icon": "git-branch", "color": "#BA7517", "description": "根据条件选择分支", "config_schema": { "condition_expr": {"type": "textarea", "label": "条件表达式", "default": "len(input) > 0", "help": "Python表达式, 返回True走sourceHandle=true, False走false"} } }, "http": { "type": "http", "label": "HTTP 请求", "icon": "globe", "color": "#D4537E", "description": "发送 HTTP 请求", "config_schema": { "url": {"type": "string", "label": "URL", "default": "https://api.example.com"}, "method": {"type": "select", "label": "方法", "options": ["GET", "POST", "PUT", "DELETE"], "default": "GET"}, "headers": {"type": "textarea", "label": "请求头 (JSON)", "default": "{}"}, "body_template": {"type": "textarea", "label": "请求体模板", "default": ""} } }, "code": { "type": "code", "label": "代码执行", "icon": "terminal", "color": "#5F5E5A", "description": "执行 Python 代码片段", "config_schema": { "code": {"type": "textarea", "label": "Python 代码", "default": "result = input\noutput = result", "help": "可用变量: input, prev_output, context. 必须设置 output 变量"} } }, "end": { "type": "end", "label": "结束", "icon": "stop", "color": "#A32D2D", "description": "工作流出口节点", "config_schema": { "output_variable": {"type": "string", "label": "输出变量名", "default": "output"} } } } # ============================================================ # Helpers # ============================================================ def _get_workflow_table(): """Get the agent_workflows table via raw SQL (avoid model changes)""" from sqlalchemy import text return text def workflow_to_dict(row) -> dict: """Convert a workflow DB row (dict) to response dict""" return { "id": row.get("id"), "name": row.get("name"), "description": row.get("description"), "agent_id": row.get("agent_id"), "nodes": json.loads(row["nodes"]) if isinstance(row.get("nodes"), str) else (row.get("nodes") or []), "edges": json.loads(row["edges"]) if isinstance(row.get("edges"), str) else (row.get("edges") or []), "variables": json.loads(row["variables"]) if isinstance(row.get("variables"), str) else (row.get("variables") or {}), "status": row.get("status", "draft"), "version": row.get("version", 1), "created_by": row.get("created_by"), "created_at": row["created_at"].isoformat() if row.get("created_at") else None, "updated_at": row["updated_at"].isoformat() if row.get("updated_at") else None, } def _execute_raw(db, sql, params=None): """Execute raw SQL and return rows as dicts""" from sqlalchemy import text result = db.execute(text(sql), params or {}) if result.returns_rows: cols = result.keys() return [dict(zip(cols, row)) for row in result.fetchall()] return [] # ============================================================ # Endpoints # ============================================================ @router.get("/templates", summary="获取节点模板") async def get_node_templates( admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """获取所有可用的节点类型模板和配置 schema""" # Fetch options for select fields options = {} # Models models = db.query(AIModel).filter(AIModel.is_enabled == True).all() options["models"] = [{"value": m.id, "label": f"{getattr(m, 'display_name', None) or m.name}"} for m in models] # Knowledge bases kbs = db.query(KnowledgeBase).all() options["knowledge_bases"] = [{"value": kb.id, "label": kb.name} for kb in kbs] # Skills skills = db.query(EvolutionSkill).filter(EvolutionSkill.state == "active").all() options["skills"] = [{"value": s.id, "label": s.skill_name} for s in skills] return { "code": 0, "data": { "node_templates": NODE_TEMPLATES, "options": options, } } @router.get("/", summary="获取工作流列表") async def list_workflows( keyword: str = Query(None), status: str = Query(None), page: int = Query(1, ge=1), page_size: int = Query(15, ge=1, le=100), admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """获取所有工作流列表(分页)""" conditions = [] params = {} if keyword: conditions.append("(name LIKE :kw OR description LIKE :kw)") params["kw"] = f"%{keyword}%" if status: conditions.append("status = :status") params["status"] = status where_clause = " AND ".join(conditions) if conditions else "1=1" # Count count_sql = f"SELECT COUNT(*) as total FROM agent_workflows WHERE {where_clause}" count_rows = _execute_raw(db, count_sql, params) total = count_rows[0]["total"] if count_rows else 0 # Query offset = (page - 1) * page_size query_sql = f""" SELECT * FROM agent_workflows WHERE {where_clause} ORDER BY updated_at DESC LIMIT :limit OFFSET :offset """ params["limit"] = page_size params["offset"] = offset rows = _execute_raw(db, query_sql, params) return { "code": 0, "data": { "total": total, "page": page, "page_size": page_size, "items": [workflow_to_dict(r) for r in rows], } } @router.get("/stats", summary="工作流统计") async def get_workflow_stats( admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """获取工作流统计数据""" rows = _execute_raw(db, "SELECT status, COUNT(*) as cnt FROM agent_workflows GROUP BY status") stats = {r["status"]: r["cnt"] for r in rows} total = sum(stats.values()) return { "code": 0, "data": { "total": total, "draft": stats.get("draft", 0), "published": stats.get("published", 0), "archived": stats.get("archived", 0), } } @router.get("/{workflow_id}", summary="获取工作流详情") async def get_workflow( workflow_id: str, admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """获取单个工作流详情""" rows = _execute_raw(db, "SELECT * FROM agent_workflows WHERE id = :id", {"id": workflow_id}) if not rows: raise HTTPException(status_code=404, detail="工作流不存在") result = workflow_to_dict(rows[0]) # Get agent info if linked if result.get("agent_id"): agent = db.query(MarketApp).filter(MarketApp.id == result["agent_id"]).first() if agent: result["agent_name"] = agent.name return {"code": 0, "data": result} @router.post("/", summary="创建工作流") async def create_workflow( req: WorkflowCreate, admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """创建新的工作流""" from sqlalchemy import text wf_id = str(uuid.uuid4()) now = datetime.now() # Validate: must have exactly one start node start_nodes = [n for n in req.nodes if n.type == "start"] if len(start_nodes) == 0 and len(req.nodes) > 0: raise HTTPException(status_code=400, detail="工作流必须包含一个开始节点") if len(start_nodes) > 1: raise HTTPException(status_code=400, detail="工作流只能有一个开始节点") sql = text(""" INSERT INTO agent_workflows (id, name, description, agent_id, nodes, edges, variables, status, version, created_by, created_at, updated_at) VALUES (:id, :name, :description, :agent_id, :nodes, :edges, :variables, 'draft', 1, :created_by, :created_at, :updated_at) """) db.execute(sql, { "id": wf_id, "name": req.name, "description": req.description or "", "agent_id": req.agent_id, "nodes": json.dumps([n.dict() for n in req.nodes], ensure_ascii=False), "edges": json.dumps([e.dict() for e in req.edges], ensure_ascii=False), "variables": json.dumps(req.variables or {}, ensure_ascii=False), "created_by": admin_user.id, "created_at": now, "updated_at": now, }) db.commit() LogService.log_audit( admin_id=admin_user.id, admin_name=admin_user.username, action="create_workflow", target_type="workflow", target_id=wf_id, detail=f"创建工作流: {req.name}", ) # Return created rows = _execute_raw(db, "SELECT * FROM agent_workflows WHERE id = :id", {"id": wf_id}) return {"code": 0, "message": "工作流创建成功", "data": workflow_to_dict(rows[0])} @router.put("/{workflow_id}", summary="更新工作流") async def update_workflow( workflow_id: str, req: WorkflowUpdate, admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """更新工作流""" from sqlalchemy import text rows = _execute_raw(db, "SELECT * FROM agent_workflows WHERE id = :id", {"id": workflow_id}) if not rows: raise HTTPException(status_code=404, detail="工作流不存在") existing = rows[0] sets = [] params = {"id": workflow_id, "updated_at": datetime.now()} if req.name is not None: sets.append("name = :name") params["name"] = req.name if req.description is not None: sets.append("description = :description") params["description"] = req.description if req.agent_id is not None: sets.append("agent_id = :agent_id") params["agent_id"] = req.agent_id if req.nodes is not None: sets.append("nodes = :nodes") params["nodes"] = json.dumps([n.dict() for n in req.nodes], ensure_ascii=False) if req.edges is not None: sets.append("edges = :edges") params["edges"] = json.dumps([e.dict() for e in req.edges], ensure_ascii=False) if req.variables is not None: sets.append("variables = :variables") params["variables"] = json.dumps(req.variables, ensure_ascii=False) if req.status is not None: sets.append("status = :status") params["status"] = req.status if sets: sets.append("version = version + 1") sql = text(f"UPDATE agent_workflows SET {', '.join(sets)} WHERE id = :id") db.execute(sql, params) db.commit() LogService.log_audit( admin_id=admin_user.id, admin_name=admin_user.username, action="update_workflow", target_type="workflow", target_id=workflow_id, detail=f"更新工作流: {existing.get('name', '')}", ) rows = _execute_raw(db, "SELECT * FROM agent_workflows WHERE id = :id", {"id": workflow_id}) return {"code": 0, "message": "工作流已更新", "data": workflow_to_dict(rows[0])} @router.delete("/{workflow_id}", summary="删除工作流") async def delete_workflow( workflow_id: str, admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """删除工作流""" from sqlalchemy import text rows = _execute_raw(db, "SELECT * FROM agent_workflows WHERE id = :id", {"id": workflow_id}) if not rows: raise HTTPException(status_code=404, detail="工作流不存在") wf_name = rows[0].get("name", "") db.execute(text("DELETE FROM agent_workflows WHERE id = :id"), {"id": workflow_id}) db.commit() LogService.log_audit( admin_id=admin_user.id, admin_name=admin_user.username, action="delete_workflow", target_type="workflow", target_id=workflow_id, detail=f"删除工作流: {wf_name}", ) return {"code": 0, "message": "工作流已删除"} @router.put("/{workflow_id}/toggle", summary="发布/归档工作流") async def toggle_workflow( workflow_id: str, is_active: bool = Query(..., description="True=发布, False=归档"), admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """发布或归档工作流""" from sqlalchemy import text rows = _execute_raw(db, "SELECT * FROM agent_workflows WHERE id = :id", {"id": workflow_id}) if not rows: raise HTTPException(status_code=404, detail="工作流不存在") new_status = "published" if is_active else "archived" db.execute( text("UPDATE agent_workflows SET status = :status, updated_at = :now WHERE id = :id"), {"status": new_status, "now": datetime.now(), "id": workflow_id} ) db.commit() return {"code": 0, "message": f"工作流已{'发布' if is_active else '归档'}"} @router.post("/{workflow_id}/execute", summary="执行工作流") async def execute_workflow( workflow_id: str, req: WorkflowExecuteRequest, admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """执行工作流 — 按 DAG 拓扑顺序处理节点""" rows = _execute_raw(db, "SELECT * FROM agent_workflows WHERE id = :id", {"id": workflow_id}) if not rows: raise HTTPException(status_code=404, detail="工作流不存在") wf = workflow_to_dict(rows[0]) nodes = wf["nodes"] edges = wf["edges"] if not nodes: raise HTTPException(status_code=400, detail="工作流没有节点") # Build adjacency map node_map = {n["id"]: n for n in nodes} edge_map = {} # source_id -> [{target, handle}] for e in edges: src = e["source"] if src not in edge_map: edge_map[src] = [] edge_map[src].append({ "target": e["target"], "sourceHandle": e.get("sourceHandle") }) # Find start node start_node = None for n in nodes: if n["type"] == "start": start_node = n break if not start_node: start_node = nodes[0] # fallback to first node # Execute context = { "input": req.input_text, "variables": req.variables or {}, "prev_output": "", "results": {}, "log": [], } visited = set() queue = [start_node["id"]] final_output = "" while queue: node_id = queue.pop(0) if node_id in visited: continue visited.add(node_id) node = node_map.get(node_id) if not node: continue node_type = node["type"] node_config = node.get("data", {}).get("config", {}) node_label = node.get("data", {}).get("label", node_type) try: result = _execute_node(node_type, node_config, context, db) context["results"][node_id] = result context["prev_output"] = result.get("output", "") context["log"].append({ "node_id": node_id, "node_label": node_label, "node_type": node_type, "success": True, "output_preview": str(result.get("output", ""))[:200], }) if node_type == "end": final_output = result.get("output", "") break # Determine next nodes next_edges = edge_map.get(node_id, []) if node_type == "condition": # Evaluate condition cond_expr = node_config.get("condition_expr", "True") try: cond_result = eval(cond_expr, {"__builtins__": {}}, { "input": context["input"], "prev_output": context["prev_output"], "len": len, "str": str, }) except Exception: cond_result = True for edge in next_edges: handle = edge["sourceHandle"] if (cond_result and handle == "true") or (not cond_result and handle == "false") or handle is None: queue.append(edge["target"]) else: for edge in next_edges: queue.append(edge["target"]) except Exception as e: context["log"].append({ "node_id": node_id, "node_label": node_label, "node_type": node_type, "success": False, "error": str(e), }) # Stop execution on error break if not final_output: final_output = context["prev_output"] return { "code": 0, "data": { "output": final_output, "log": context["log"], "results": context["results"], } } def _execute_node(node_type: str, config: dict, context: dict, db: Session) -> dict: """Execute a single node and return result""" if node_type == "start": return {"output": context["input"]} elif node_type == "llm": return _execute_llm_node(config, context, db) elif node_type == "knowledge": return _execute_knowledge_node(config, context, db) elif node_type == "skill": return _execute_skill_node(config, context, db) elif node_type == "condition": return {"output": context["prev_output"]} elif node_type == "http": return _execute_http_node(config, context) elif node_type == "code": return _execute_code_node(config, context) elif node_type == "end": var_name = config.get("output_variable", "output") return {"output": context.get(var_name, context.get("prev_output", ""))} return {"output": ""} def _execute_llm_node(config: dict, context: dict, db: Session) -> dict: """Execute LLM call""" import httpx model_id = config.get("model_id") if not model_id: return {"output": "[LLM] 未配置模型", "error": "no model_id"} model = db.query(AIModel).filter(AIModel.id == model_id).first() if not model: return {"output": "[LLM] 模型不存在", "error": "model not found"} system_prompt = config.get("system_prompt", "你是一个有用的AI助手。") user_prompt = config.get("user_prompt", "{input}") # Template substitution user_prompt = user_prompt.replace("{input}", str(context.get("input", ""))) user_prompt = user_prompt.replace("{prev_output}", str(context.get("prev_output", ""))) api_key = model.api_key or "" base_url = model.api_base or "https://api.siliconflow.cn/v1" model_name = model.model_id or model.name headers = { "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", } payload = { "model": model_name, "messages": [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, ], "temperature": float(config.get("temperature", 0.7)), "max_tokens": int(config.get("max_tokens", 2048)), } try: with httpx.Client(timeout=60) as client: resp = client.post(f"{base_url}/chat/completions", headers=headers, json=payload) resp.raise_for_status() data = resp.json() output = data["choices"][0]["message"]["content"] return {"output": output, "usage": data.get("usage", {})} except Exception as e: return {"output": f"[LLM] 调用失败: {str(e)}", "error": str(e)} def _execute_knowledge_node(config: dict, context: dict, db: Session) -> dict: """Execute knowledge base search""" kb_id = config.get("kb_id") if not kb_id: return {"output": "[KB] 未配置知识库", "error": "no kb_id"} query = config.get("query_template", "{input}") query = query.replace("{input}", str(context.get("input", ""))) query = query.replace("{prev_output}", str(context.get("prev_output", ""))) top_k = int(config.get("top_k", 3)) # Use BM25 search (same as chat.py) try: from ...models import KnowledgeDocumentChunk chunks = db.query(KnowledgeDocumentChunk).filter( KnowledgeDocumentChunk.knowledge_base_id == kb_id ).limit(100).all() if not chunks: return {"output": "[KB] 知识库无文档", "results": []} # Simple keyword matching (BM25 simplified) query_words = set(query.lower().split()) scored = [] for chunk in chunks: content = (chunk.content or "").lower() score = sum(1 for w in query_words if w in content) if score > 0: scored.append((score, chunk.content[:500])) scored.sort(key=lambda x: -x[0]) results = [s[1] for s in scored[:top_k]] if not results: results = [c.content[:500] for c in chunks[:top_k]] combined = "\n\n---\n\n".join(results) return {"output": combined, "results": results} except Exception as e: return {"output": f"[KB] 检索失败: {str(e)}", "error": str(e)} def _execute_skill_node(config: dict, context: dict, db: Session) -> dict: """Execute skill call""" skill_id = config.get("skill_id") if not skill_id: return {"output": "[Skill] 未配置 Skill", "error": "no skill_id"} skill = db.query(EvolutionSkill).filter(EvolutionSkill.id == skill_id).first() if not skill: return {"output": "[Skill] Skill 不存在", "error": "skill not found"} # For now, return skill info (actual execution would call the skill handler) return { "output": f"[Skill] {skill.skill_name} 已调用", "skill_id": skill_id, "skill_name": skill.skill_name, } def _execute_http_node(config: dict, context: dict) -> dict: """Execute HTTP request""" import httpx url = config.get("url", "") method = config.get("method", "GET") headers_str = config.get("headers", "{}") body_template = config.get("body_template", "") # Template substitution url = url.replace("{input}", str(context.get("input", ""))) body_template = body_template.replace("{input}", str(context.get("input", ""))) body_template = body_template.replace("{prev_output}", str(context.get("prev_output", ""))) try: headers = json.loads(headers_str) if headers_str else {} except Exception: headers = {} try: with httpx.Client(timeout=30) as client: if method == "GET": resp = client.get(url, headers=headers) elif method == "POST": resp = client.post(url, headers=headers, content=body_template) elif method == "PUT": resp = client.put(url, headers=headers, content=body_template) elif method == "DELETE": resp = client.delete(url, headers=headers) else: return {"output": f"[HTTP] 不支持的方法: {method}"} return {"output": resp.text[:2000], "status_code": resp.status_code} except Exception as e: return {"output": f"[HTTP] 请求失败: {str(e)}", "error": str(e)} def _execute_code_node(config: dict, context: dict) -> dict: """Execute Python code (sandboxed)""" code = config.get("code", "output = input") # Very basic sandbox — restrict builtins safe_builtins = { "len": len, "str": str, "int": int, "float": float, "bool": bool, "list": list, "dict": dict, "set": set, "range": range, "enumerate": enumerate, "zip": zip, "min": min, "max": max, "sum": sum, "abs": abs, "round": round, "sorted": sorted, "reversed": reversed, "print": print, "json": json, "True": True, "False": False, "None": None, } local_vars = { "input": context.get("input", ""), "prev_output": context.get("prev_output", ""), "context": context, "output": "", } try: exec(code, {"__builtins__": safe_builtins}, local_vars) return {"output": str(local_vars.get("output", ""))} except Exception as e: return {"output": f"[Code] 执行失败: {str(e)}", "error": str(e)}