From 5b5eb84dfb5efe9daf3374ea5a5ea22e7066a3d0 Mon Sep 17 00:00:00 2001 From: renjianbo <18691577328@163.com> Date: Wed, 6 May 2026 22:13:41 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20#33=20=E5=86=85=E7=BD=AE=E5=A4=9A?= =?UTF-8?q?=E6=A8=A1=E6=80=81=E5=B7=A5=E5=85=B7=E7=8E=B0=E5=9C=A8=E5=9C=A8?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E5=B8=82=E5=9C=BA=20/api/v1/tools=20?= =?UTF-8?q?=E4=B8=AD=E5=8F=AF=E8=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit list_tools 端点合并内置工具(image_ocr/image_vision/speech_to_text/text_to_speech 等), 按 scope=public/all 时自动包含,无需额外种子到 DB。 Co-Authored-By: Claude Opus 4.6 --- backend/app/agent_runtime/orchestrator.py | 182 ++++++ backend/app/api/agent_chat.py | 44 ++ backend/app/api/orchestration_templates.py | 162 +++++ backend/app/api/tools.py | 60 +- backend/app/main.py | 3 +- backend/app/models/orchestration_template.py | 27 + frontend/src/components/MainLayout.vue | 17 +- frontend/src/router/index.ts | 12 + frontend/src/views/AgentOrchestration.vue | 595 +++++++++++++++++++ 9 files changed, 1095 insertions(+), 7 deletions(-) create mode 100644 backend/app/api/orchestration_templates.py create mode 100644 backend/app/models/orchestration_template.py create mode 100644 frontend/src/views/AgentOrchestration.vue diff --git a/backend/app/agent_runtime/orchestrator.py b/backend/app/agent_runtime/orchestrator.py index 28ef8d7..c2852b1 100644 --- a/backend/app/agent_runtime/orchestrator.py +++ b/backend/app/agent_runtime/orchestrator.py @@ -612,6 +612,188 @@ class AgentOrchestrator: agent_results=execution_results, ) + async def _graph( + self, question: str, nodes: List[Dict[str, Any]], edges: List[Dict[str, Any]], + on_llm_call: Optional[Callable] = None, + ) -> OrchestratorResult: + """图编排模式:按 DAG 拓扑顺序执行节点,支持 agent 和 condition 类型。""" + if not nodes: + return OrchestratorResult(mode="graph", final_answer="无节点可执行") + + # 建立节点索引 + node_map: Dict[str, Dict[str, Any]] = {n["id"]: n for n in nodes} + + # 建立邻接表和入度 + adj: Dict[str, List[tuple]] = {} # source_id → [(target_id, source_handle)] + in_degree: Dict[str, int] = {n["id"]: 0 for n in nodes} + for e in edges: + src = e["source"] + tgt = e["target"] + sh = e.get("sourceHandle", "") + if src not in adj: + adj[src] = [] + adj[src].append((tgt, sh)) + if tgt in in_degree: + in_degree[tgt] += 1 + + # 找起始节点(入度为 0) + start_ids = [nid for nid, deg in in_degree.items() if deg == 0] + if not start_ids: + start_ids = [nodes[0]["id"]] + + steps: List[OrchestratorStep] = [] + node_outputs: Dict[str, str] = {} # node_id → output text + + # BFS 拓扑执行 + from collections import deque + queue = deque(start_ids) + # 将初始输入注入起始节点的"上游输出" + for sid in start_ids: + node_outputs[f"__input__{sid}"] = question + + while queue: + node_id = queue.popleft() + node = node_map.get(node_id) + if not node: + continue + + node_type = node.get("type", "agent") + node_data = node.get("data", {}) + + # 收集上游输出作为本节点输入 + upstream_inputs = [] + for e in edges: + if e["target"] == node_id: + src_output = node_outputs.get(e["source"], "") + if src_output: + upstream_inputs.append(src_output) + context_input = "\n\n".join(upstream_inputs) if upstream_inputs else question + + if node_type == "condition": + # 条件节点:根据上游输出来决定走哪个分支 + condition_expr = node_data.get("condition", "") + condition_field = node_data.get("field", "output") + + # 取最后一个上游输出作为判断依据 + last_output = upstream_inputs[-1] if upstream_inputs else question + + # 简单条件评估:支持 contains / not_contains / equals + op = node_data.get("operator", "contains") + value = node_data.get("value", "") + result_true = self._eval_condition(last_output, op, value) + + branch = "true" if result_true else "false" + steps.append(OrchestratorStep( + agent_id=node_id, + agent_name=f"条件: {condition_expr or node_data.get('name', node_id)}", + input=f"判断: {op} '{value}' → {branch}", + output=branch, + )) + node_outputs[node_id] = branch + + # 只沿匹配的分支继续 + for tgt, sh in adj.get(node_id, []): + if sh == branch: + in_degree[tgt] -= 1 + if in_degree[tgt] == 0: + queue.append(tgt) + continue + + # agent 节点:构建 AgentRuntime 并执行 + agent_name = node_data.get("name", node_data.get("agent_name", node.get("label", node_id))) + system_prompt = node_data.get("system_prompt", "你是一个有用的AI助手。") + model = node_data.get("model", "deepseek-v4-flash") + provider = node_data.get("provider", "deepseek") + temperature = float(node_data.get("temperature", 0.7)) + max_iterations = int(node_data.get("max_iterations", 10)) + tools = node_data.get("tools", []) + + runtime = AgentRuntime( + AgentConfig( + name=agent_name, + system_prompt=system_prompt, + llm=AgentLLMConfig( + model=model, provider=provider, + temperature=temperature, max_iterations=max_iterations, + ), + tools=AgentToolConfig(include_tools=tools if isinstance(tools, list) else []), + ), + on_llm_call=on_llm_call, + ) + + # 构建带上下文的输入 + if len(upstream_inputs) > 1: + agent_input = f"原始问题: {question}\n\n前序步骤的输出:\n{context_input}\n\n请基于以上信息继续处理。" + elif len(upstream_inputs) == 1 and upstream_inputs[0] != question: + agent_input = f"原始问题: {question}\n\n前一步输出:\n{upstream_inputs[0]}\n\n请基于以上信息继续处理。" + else: + agent_input = question + + result = await runtime.run(agent_input) + + steps.append(OrchestratorStep( + agent_id=node_id, + agent_name=agent_name, + input=agent_input[:200], + output=result.content[:500], + iterations_used=result.iterations_used, + tool_calls_made=result.tool_calls_made, + error=None if result.success else result.error, + )) + node_outputs[node_id] = result.content + + if not result.success: + logger.warning(f"Graph 节点 {agent_name} ({node_id}) 执行失败: {result.error}") + + # 将下游节点的入度减 1 + for tgt, sh in adj.get(node_id, []): + if tgt in in_degree: + in_degree[tgt] -= 1 + if in_degree[tgt] == 0: + queue.append(tgt) + + # 收集最终输出(出度为 0 的节点) + out_degree: Dict[str, int] = {n["id"]: 0 for n in nodes} + for e in edges: + out_degree[e["source"]] = out_degree.get(e["source"], 0) + 1 + end_ids = [nid for nid, deg in out_degree.items() if deg == 0] + if not end_ids: + end_ids = [steps[-1].agent_id] if steps else [] + + final_parts = [] + for eid in end_ids: + out = node_outputs.get(eid, "") + if out and out not in ("true", "false"): + final_parts.append(out) + final_answer = "\n\n".join(final_parts) if final_parts else (steps[-1].output if steps else "无输出") + + return OrchestratorResult( + mode="graph", + final_answer=final_answer, + steps=steps, + agent_results=[ + {"agent_id": s.agent_id, "agent_name": s.agent_name, "output": s.output} + for s in steps + ], + ) + + @staticmethod + def _eval_condition(text: str, op: str, value: str) -> bool: + """评估简单条件表达式。""" + if op == "contains": + return value.lower() in text.lower() + elif op == "not_contains": + return value.lower() not in text.lower() + elif op == "equals": + return text.strip().lower() == value.lower() + elif op == "not_equals": + return text.strip().lower() != value.lower() + elif op == "starts_with": + return text.strip().lower().startswith(value.lower()) + elif op == "ends_with": + return text.strip().lower().endswith(value.lower()) + return True + @staticmethod def _parse_plan(text: str) -> dict: """从 Planner 输出中解析 JSON 计划。""" diff --git a/backend/app/api/agent_chat.py b/backend/app/api/agent_chat.py index 2edeb0f..52cc616 100644 --- a/backend/app/api/agent_chat.py +++ b/backend/app/api/agent_chat.py @@ -183,6 +183,50 @@ async def orchestrate_agents( ) +class GraphOrchestrateRequest(BaseModel): + """图编排请求 — 以 nodes + edges 描述 DAG""" + message: str + nodes: List[Dict[str, Any]] = Field(..., description="编排节点列表") + edges: List[Dict[str, Any]] = Field(default_factory=list, description="编排连线列表") + model: Optional[str] = None + + +@router.post("/orchestrate/graph", response_model=OrchestrateResponse) +async def orchestrate_graph( + req: GraphOrchestrateRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """图编排模式:按 DAG 拓扑顺序执行 Agent 和条件节点。""" + on_llm_call = _make_llm_logger(db, agent_id=None, user_id=current_user.id) + orchestrator = AgentOrchestrator( + default_llm_config=AgentLLMConfig( + model=req.model or "deepseek-v4-flash", + temperature=0.3, + ), + ) + result = await orchestrator._graph( + req.message, req.nodes, req.edges, on_llm_call=on_llm_call, + ) + return OrchestrateResponse( + mode=result.mode, + final_answer=result.final_answer, + steps=[ + OrchestrateStepItem( + agent_id=s.agent_id, + agent_name=s.agent_name, + input=s.input, + output=s.output, + iterations_used=s.iterations_used, + tool_calls_made=s.tool_calls_made, + error=s.error, + ) + for s in result.steps + ], + agent_results=result.agent_results, + ) + + @router.post("/bare", response_model=ChatResponse) async def chat_bare( req: ChatRequest, diff --git a/backend/app/api/orchestration_templates.py b/backend/app/api/orchestration_templates.py new file mode 100644 index 0000000..f484f53 --- /dev/null +++ b/backend/app/api/orchestration_templates.py @@ -0,0 +1,162 @@ +""" +编排模板 CRUD API +""" +from typing import List, Optional +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.api.auth import get_current_user +from app.models.user import User +from app.models.orchestration_template import OrchestrationTemplate + +router = APIRouter(prefix="/api/v1/orchestration-templates", tags=["orchestration-templates"]) + + +class TemplateCreate(BaseModel): + name: str + description: str = "" + nodes: List[dict] = Field(..., description="编排节点列表") + edges: List[dict] = Field(..., description="编排连线列表") + + +class TemplateUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + nodes: Optional[List[dict]] = None + edges: Optional[List[dict]] = None + + +class TemplateResponse(BaseModel): + id: str + name: str + description: str + nodes: List[dict] + edges: List[dict] + user_id: Optional[str] = None + created_at: Optional[str] = None + updated_at: Optional[str] = None + + +@router.get("", response_model=List[TemplateResponse]) +async def list_templates( + search: Optional[str] = Query(None, description="按名称搜索"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """获取当前用户的编排模板列表""" + q = db.query(OrchestrationTemplate).filter(OrchestrationTemplate.user_id == current_user.id) + if search: + q = q.filter(OrchestrationTemplate.name.contains(search)) + q = q.order_by(OrchestrationTemplate.updated_at.desc()) + templates = q.all() + return [ + TemplateResponse( + id=t.id, name=t.name, description=t.description or "", + nodes=t.nodes or [], edges=t.edges or [], + user_id=t.user_id, + created_at=t.created_at.isoformat() if t.created_at else None, + updated_at=t.updated_at.isoformat() if t.updated_at else None, + ) + for t in templates + ] + + +@router.post("", response_model=TemplateResponse) +async def create_template( + body: TemplateCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """创建编排模板""" + template = OrchestrationTemplate( + name=body.name, + description=body.description, + nodes=body.nodes, + edges=body.edges, + user_id=current_user.id, + ) + db.add(template) + db.commit() + db.refresh(template) + return TemplateResponse( + id=template.id, name=template.name, description=template.description or "", + nodes=template.nodes or [], edges=template.edges or [], + user_id=template.user_id, + created_at=template.created_at.isoformat() if template.created_at else None, + updated_at=template.updated_at.isoformat() if template.updated_at else None, + ) + + +@router.get("/{template_id}", response_model=TemplateResponse) +async def get_template( + template_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """获取模板详情""" + template = db.query(OrchestrationTemplate).filter( + OrchestrationTemplate.id == template_id, + OrchestrationTemplate.user_id == current_user.id, + ).first() + if not template: + raise HTTPException(status_code=404, detail="模板不存在") + return TemplateResponse( + id=template.id, name=template.name, description=template.description or "", + nodes=template.nodes or [], edges=template.edges or [], + user_id=template.user_id, + created_at=template.created_at.isoformat() if template.created_at else None, + updated_at=template.updated_at.isoformat() if template.updated_at else None, + ) + + +@router.put("/{template_id}", response_model=TemplateResponse) +async def update_template( + template_id: str, + body: TemplateUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """更新模板""" + template = db.query(OrchestrationTemplate).filter( + OrchestrationTemplate.id == template_id, + OrchestrationTemplate.user_id == current_user.id, + ).first() + if not template: + raise HTTPException(status_code=404, detail="模板不存在") + if body.name is not None: + template.name = body.name + if body.description is not None: + template.description = body.description + if body.nodes is not None: + template.nodes = body.nodes + if body.edges is not None: + template.edges = body.edges + db.commit() + db.refresh(template) + return TemplateResponse( + id=template.id, name=template.name, description=template.description or "", + nodes=template.nodes or [], edges=template.edges or [], + user_id=template.user_id, + created_at=template.created_at.isoformat() if template.created_at else None, + updated_at=template.updated_at.isoformat() if template.updated_at else None, + ) + + +@router.delete("/{template_id}") +async def delete_template( + template_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """删除模板""" + template = db.query(OrchestrationTemplate).filter( + OrchestrationTemplate.id == template_id, + OrchestrationTemplate.user_id == current_user.id, + ).first() + if not template: + raise HTTPException(status_code=404, detail="模板不存在") + db.delete(template) + db.commit() + return {"detail": "ok"} diff --git a/backend/app/api/tools.py b/backend/app/api/tools.py index 761c354..0a53d11 100644 --- a/backend/app/api/tools.py +++ b/backend/app/api/tools.py @@ -96,6 +96,44 @@ def _tool_to_dict(tool: Tool) -> dict: # ─── 工具市场浏览 ────────────────────────────────────────────── +def _builtin_schema_to_tool_dict(schema: dict) -> dict: + """将 tool_registry 中的 schema 转为与 DB Tool 一致的字典格式。""" + func = schema.get("function", schema) + name = func.get("name", "") + desc = func.get("description", "") + params = func.get("parameters", {}) + # 根据工具名自动归类 + cat = "系统工具" + if name in ("image_ocr", "image_vision"): + cat = "多模态" + elif name in ("speech_to_text", "text_to_speech"): + cat = "多模态" + elif name.startswith("file_"): + cat = "文件操作" + elif name.startswith("http") or name.startswith("url"): + cat = "网络请求" + elif name.startswith("database") or name.startswith("sql"): + cat = "数据库" + elif name.startswith("agent_"): + cat = "AI Agent" + elif name in ("web_search", "send_email", "browser_use"): + cat = "网络请求" + return { + "id": f"builtin_{name}", + "name": name, + "description": desc, + "category": cat, + "function_schema": schema, + "implementation_type": "builtin", + "implementation_config": None, + "is_public": True, + "use_count": 0, + "user_id": None, + "created_at": "", + "updated_at": "", + } + + @router.get("", response_model=List[ToolResponse]) async def list_tools( category: Optional[str] = Query(None, description="按分类筛选"), @@ -104,7 +142,7 @@ async def list_tools( db: Session = Depends(get_db), current_user: Optional[User] = Depends(get_current_user), ): - """浏览工具市场。""" + """浏览工具市场(含内置工具 + 数据库工具)。""" query = db.query(Tool) if scope == "public": @@ -122,7 +160,23 @@ async def list_tools( ) tools = query.order_by(Tool.use_count.desc(), Tool.created_at.desc()).all() - return [_tool_to_dict(t) for t in tools] + result = [_tool_to_dict(t) for t in tools] + db_names = {t["name"] for t in result} + + # 合并内置工具(未在 DB 中覆盖的) + if scope != "mine": + for schema in tool_registry.get_all_tool_schemas(): + entry = _builtin_schema_to_tool_dict(schema) + if entry["name"] not in db_names: + if category and entry["category"] != category: + continue + if search: + kw = search.lower() + if kw not in entry["name"].lower() and kw not in entry["description"].lower(): + continue + result.append(entry) + + return result @router.get("/categories", response_model=List[str]) @@ -131,7 +185,7 @@ async def list_categories(db: Session = Depends(get_db)): rows = db.query(Tool.category).filter(Tool.category.isnot(None)).distinct().all() cats = sorted(set(r[0] for r in rows if r[0])) # 加上常用分类 - defaults = ["数据处理", "网络请求", "文件操作", "AI服务", "数据库", "通知", "自定义"] + defaults = ["数据处理", "网络请求", "文件操作", "AI服务", "数据库", "通知", "自定义", "多模态", "系统工具"] for d in defaults: if d not in cats: cats.append(d) diff --git a/backend/app/main.py b/backend/app/main.py index 0689fee..398779b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -263,7 +263,7 @@ async def startup_event(): logger.error(f"人参果1号长连接启动失败: {e}") # 注册路由 -from app.api import auth, uploads, workflows, executions, websocket, execution_logs, data_sources, agents, platform_templates, model_configs, webhooks, template_market, batch_operations, collaboration, permissions, monitoring, alert_rules, node_test, node_templates, tools, agent_chat, agent_monitoring, knowledge_base, agent_schedules, notifications, feishu_bind, approval, orchestration_templates, plugins +from app.api import auth, uploads, workflows, executions, websocket, execution_logs, data_sources, agents, platform_templates, model_configs, webhooks, template_market, batch_operations, collaboration, permissions, monitoring, alert_rules, node_test, node_templates, tools, agent_chat, agent_monitoring, knowledge_base, agent_schedules, notifications, feishu_bind, approval, orchestration_templates, plugins, agent_market app.include_router(auth.router) app.include_router(uploads.router) @@ -294,6 +294,7 @@ app.include_router(feishu_bind.router) app.include_router(approval.router) app.include_router(plugins.router) app.include_router(orchestration_templates.router) +app.include_router(agent_market.router) if __name__ == "__main__": import uvicorn diff --git a/backend/app/models/orchestration_template.py b/backend/app/models/orchestration_template.py new file mode 100644 index 0000000..42256c2 --- /dev/null +++ b/backend/app/models/orchestration_template.py @@ -0,0 +1,27 @@ +""" +编排模板模型 — 保存可视化 Agent 编排画布配置 +""" +from sqlalchemy import Column, String, Text, JSON, DateTime, ForeignKey, func +from sqlalchemy.dialects.mysql import CHAR +from sqlalchemy.orm import relationship +from app.core.database import Base +import uuid + + +class OrchestrationTemplate(Base): + """Agent 编排模板表""" + __tablename__ = "orchestration_templates" + + id = Column(CHAR(36), primary_key=True, default=lambda: str(uuid.uuid4()), comment="模板ID") + name = Column(String(100), nullable=False, comment="模板名称") + description = Column(Text, comment="模板描述") + nodes = Column(JSON, nullable=False, comment="编排节点(含Agent配置)") + edges = Column(JSON, nullable=False, comment="编排连线") + user_id = Column(CHAR(36), ForeignKey("users.id"), comment="创建者ID") + created_at = Column(DateTime, default=func.now(), comment="创建时间") + updated_at = Column(DateTime, default=func.now(), onupdate=func.now(), comment="更新时间") + + user = relationship("User", backref="orchestration_templates") + + def __repr__(self): + return f"" diff --git a/frontend/src/components/MainLayout.vue b/frontend/src/components/MainLayout.vue index 8fb66e3..ce780b3 100644 --- a/frontend/src/components/MainLayout.vue +++ b/frontend/src/components/MainLayout.vue @@ -35,6 +35,10 @@ Agent对话 + + + Agent协作 + 定时任务 @@ -63,9 +67,13 @@ 模板市场 - + + Agent技能商店 + + @@ -98,7 +106,7 @@ import { computed } from 'vue' import { useRouter, useRoute } from 'vue-router' import { useUserStore } from '@/stores/user' -import { Document, User, List, Connection, Setting, Star, Lock, Monitor, Bell, Grid, DataAnalysis, Tools, Clock } from '@element-plus/icons-vue' +import { Document, User, List, Connection, Setting, Star, Lock, Monitor, Bell, Grid, DataAnalysis, Tools, Clock, Share, ChatLineSquare, Shop } from '@element-plus/icons-vue' const router = useRouter() const route = useRoute() @@ -117,6 +125,7 @@ const activeMenu = computed(() => { if (route.path === '/node-templates') return 'node-templates' if (route.path === '/permissions') return 'permissions' if (route.path === '/template-market') return 'template-market' + if (route.path === '/agent-market') return 'agent-market' if (route.path === '/monitoring') return 'monitoring' if (route.path === '/agent-monitoring') return 'agent-monitoring' if (route.path === '/alert-rules') return 'alert-rules' @@ -145,6 +154,8 @@ const handleMenuSelect = (key: string) => { router.push('/node-templates') } else if (key === 'template-market') { router.push('/template-market') + } else if (key === 'agent-market') { + router.push('/agent-market') } else if (key === 'permissions') { router.push('/permissions') } else if (key === 'monitoring') { diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 8d73ca1..05f094d 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -141,6 +141,18 @@ const router = createRouter({ name: 'plugin-market', component: () => import('@/views/PluginMarket.vue'), meta: { requiresAuth: true } + }, + { + path: '/agent-orchestration', + name: 'agent-orchestration', + component: () => import('@/views/AgentOrchestration.vue'), + meta: { requiresAuth: true } + }, + { + path: '/agent-market', + name: 'agent-market', + component: () => import('@/views/AgentMarket.vue'), + meta: { requiresAuth: true } } ] }) diff --git a/frontend/src/views/AgentOrchestration.vue b/frontend/src/views/AgentOrchestration.vue new file mode 100644 index 0000000..bf8a986 --- /dev/null +++ b/frontend/src/views/AgentOrchestration.vue @@ -0,0 +1,595 @@ + + + + + + +