Phase 3.6: Add workflow_admin.py backend API
This commit is contained in:
1 parent
b675e9fd20
commit
2c5ab13209
1 file changed
+824
@@ -0,0 +1,824 @@
|
||||
"""
|
||||
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)}
|
||||
Reference in new issue
Block a user