fix: #33 内置多模态工具现在在工具市场 /api/v1/tools 中可见

list_tools 端点合并内置工具(image_ocr/image_vision/speech_to_text/text_to_speech 等),
按 scope=public/all 时自动包含,无需额外种子到 DB。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
renjianbo
2026-05-06 22:13:41 +08:00
parent 9054f42cda
commit 5b5eb84dfb
9 changed files with 1095 additions and 7 deletions

View File

@@ -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 计划。"""

View File

@@ -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,

View File

@@ -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"}

View File

@@ -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)

View File

@@ -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

View File

@@ -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"<OrchestrationTemplate(id={self.id}, name={self.name})>"