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:
@@ -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 计划。"""
|
||||
|
||||
@@ -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,
|
||||
|
||||
162
backend/app/api/orchestration_templates.py
Normal file
162
backend/app/api/orchestration_templates.py
Normal 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"}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
27
backend/app/models/orchestration_template.py
Normal file
27
backend/app/models/orchestration_template.py
Normal 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})>"
|
||||
@@ -35,6 +35,10 @@
|
||||
<el-icon><ChatLineSquare /></el-icon>
|
||||
<span>Agent对话</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="agent-orchestration" @click="router.push('/agent-orchestration')">
|
||||
<el-icon><Share /></el-icon>
|
||||
<span>Agent协作</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="agent-schedules" @click="router.push('/agent-schedules')">
|
||||
<el-icon><Clock /></el-icon>
|
||||
<span>定时任务</span>
|
||||
@@ -63,9 +67,13 @@
|
||||
<el-icon><Star /></el-icon>
|
||||
<span>模板市场</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item
|
||||
<el-menu-item index="agent-market" @click="router.push('/agent-market')">
|
||||
<el-icon><Shop /></el-icon>
|
||||
<span>Agent技能商店</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item
|
||||
v-if="userStore.user?.role === 'admin'"
|
||||
index="permissions"
|
||||
index="permissions"
|
||||
@click="router.push('/permissions')"
|
||||
>
|
||||
<el-icon><Lock /></el-icon>
|
||||
@@ -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') {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
595
frontend/src/views/AgentOrchestration.vue
Normal file
595
frontend/src/views/AgentOrchestration.vue
Normal file
@@ -0,0 +1,595 @@
|
||||
<template>
|
||||
<MainLayout>
|
||||
<div class="orch-page">
|
||||
<!-- Toolbar -->
|
||||
<div class="orch-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<h3>Agent 协作工作台</h3>
|
||||
<el-tag size="small" type="info">{{ nodes.length }} 节点 · {{ edges.length }} 连线</el-tag>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<el-button @click="handleSaveTemplate" :disabled="nodes.length === 0">
|
||||
<el-icon><FolderChecked /></el-icon> 保存模板
|
||||
</el-button>
|
||||
<el-button @click="showLoadDialog = true">
|
||||
<el-icon><FolderOpened /></el-icon> 加载模板
|
||||
</el-button>
|
||||
<el-button @click="handleAddConditionNode" type="warning" plain>
|
||||
<el-icon><Switch /></el-icon> 添加条件
|
||||
</el-button>
|
||||
<el-button @click="handleClear" :disabled="nodes.length === 0">清空</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main content -->
|
||||
<div class="orch-body">
|
||||
<!-- Left: Agent list -->
|
||||
<div class="orch-left-panel">
|
||||
<div class="panel-title">Agent 列表</div>
|
||||
<el-input v-model="agentSearch" placeholder="搜索 Agent..." size="small" clearable style="margin-bottom: 8px" />
|
||||
<div class="agent-list">
|
||||
<div
|
||||
v-for="agent in filteredAgents"
|
||||
:key="agent.id"
|
||||
class="agent-item"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart($event, agent)"
|
||||
>
|
||||
<div class="agent-item-icon"><el-icon><User /></el-icon></div>
|
||||
<div class="agent-item-info">
|
||||
<div class="agent-item-name">{{ agent.name }}</div>
|
||||
<div class="agent-item-desc">{{ agent.description || '无描述' }}</div>
|
||||
</div>
|
||||
<el-tag size="small" :type="agent.status === 'published' ? 'success' : 'info'">
|
||||
{{ agent.status === 'published' ? '已发布' : '草稿' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div v-if="filteredAgents.length === 0" class="agent-empty">
|
||||
{{ agentSearch ? '无匹配 Agent' : '暂无 Agent,请先创建' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-hint">拖拽 Agent 到画布上</div>
|
||||
</div>
|
||||
|
||||
<!-- Center: Vue Flow canvas -->
|
||||
<div
|
||||
class="orch-canvas"
|
||||
@drop="onDrop"
|
||||
@dragover.prevent
|
||||
@dragenter.prevent
|
||||
>
|
||||
<VueFlow
|
||||
ref="vueFlowRef"
|
||||
v-model:nodes="nodes"
|
||||
v-model:edges="edges"
|
||||
:node-types="nodeTypes"
|
||||
:default-edge-options="defaultEdgeOptions"
|
||||
:connection-line-style="{ stroke: '#409EFF', strokeWidth: 2 }"
|
||||
fit-view-on-init
|
||||
@node-click="onNodeClick"
|
||||
@pane-click="selectedNodeId = null"
|
||||
@connect="onConnect"
|
||||
>
|
||||
<Background :gap="16" />
|
||||
<Controls />
|
||||
<MiniMap />
|
||||
</VueFlow>
|
||||
<div class="canvas-hint" v-if="nodes.length === 0">
|
||||
拖拽左侧 Agent 到此区域,或添加条件节点开始编排
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Node config panel -->
|
||||
<div class="orch-right-panel" v-if="selectedNode">
|
||||
<div class="panel-title">
|
||||
{{ selectedNode.type === 'condition' ? '条件配置' : 'Agent 配置' }}
|
||||
<el-button link size="small" @click="handleDeleteNode" type="danger">删除</el-button>
|
||||
</div>
|
||||
|
||||
<template v-if="selectedNode.type === 'condition'">
|
||||
<el-form label-position="top" size="small">
|
||||
<el-form-item label="节点名称">
|
||||
<el-input v-model="selectedNode.data.name" placeholder="条件节点" />
|
||||
</el-form-item>
|
||||
<el-form-item label="操作符">
|
||||
<el-select v-model="selectedNode.data.operator" style="width: 100%">
|
||||
<el-option label="包含 (contains)" value="contains" />
|
||||
<el-option label="不包含 (not_contains)" value="not_contains" />
|
||||
<el-option label="等于 (equals)" value="equals" />
|
||||
<el-option label="不等于 (not_equals)" value="not_equals" />
|
||||
<el-option label="以...开头 (starts_with)" value="starts_with" />
|
||||
<el-option label="以...结尾 (ends_with)" value="ends_with" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="匹配值">
|
||||
<el-input v-model="selectedNode.data.value" placeholder="要匹配的文本" />
|
||||
</el-form-item>
|
||||
<el-form-item label="条件说明">
|
||||
<el-input v-model="selectedNode.data.condition" placeholder="条件说明(可选)" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<el-form label-position="top" size="small">
|
||||
<el-form-item label="System Prompt">
|
||||
<el-input v-model="selectedNode.data.system_prompt" type="textarea" :rows="4" placeholder="你是一个有用的AI助手。" />
|
||||
</el-form-item>
|
||||
<el-form-item label="模型">
|
||||
<el-select v-model="selectedNode.data.model" style="width: 100%">
|
||||
<el-option label="DeepSeek V4 Flash" value="deepseek-v4-flash" />
|
||||
<el-option label="DeepSeek V4 Pro" value="deepseek-v4-pro" />
|
||||
<el-option label="GPT-4o Mini" value="gpt-4o-mini" />
|
||||
<el-option label="GPT-4o" value="gpt-4o" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="Temperature">
|
||||
<el-slider v-model="selectedNode.data.temperature" :min="0" :max="2" :step="0.1" show-input />
|
||||
</el-form-item>
|
||||
<el-form-item label="最大迭代次数">
|
||||
<el-input-number v-model="selectedNode.data.max_iterations" :min="1" :max="50" />
|
||||
</el-form-item>
|
||||
<el-form-item label="工具白名单">
|
||||
<el-select v-model="selectedNode.data.tools" multiple filterable placeholder="全部工具" style="width: 100%">
|
||||
<el-option v-for="t in availableTools" :key="t" :label="t" :value="t" />
|
||||
</el-select>
|
||||
<span class="form-hint">留空=全部可用工具</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
</div>
|
||||
<div class="orch-right-panel" v-else>
|
||||
<div class="panel-title">节点配置</div>
|
||||
<div class="panel-empty">点击画布中的节点查看配置</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom: Input + Results -->
|
||||
<div class="orch-bottom">
|
||||
<div class="bottom-input">
|
||||
<el-input
|
||||
v-model="userInput"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="输入消息,测试编排流程..."
|
||||
@keydown.enter.exact.prevent="handleExecute"
|
||||
:disabled="executing"
|
||||
/>
|
||||
<el-button type="primary" @click="handleExecute" :loading="executing" :disabled="!userInput.trim() || nodes.length === 0">
|
||||
{{ executing ? '执行中...' : '执行编排' }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- Result display -->
|
||||
<div class="bottom-result" v-if="executionResult || executionError">
|
||||
<div v-if="executionError" class="result-error">
|
||||
<el-alert type="error" :closable="false" show-icon :title="executionError" />
|
||||
</div>
|
||||
<div v-if="executionResult" class="result-content">
|
||||
<div class="result-header">
|
||||
<el-tag type="success">执行完成</el-tag>
|
||||
<span class="result-mode">{{ executionResult.steps.length }} 步</span>
|
||||
</div>
|
||||
<div class="result-answer">
|
||||
<div class="result-section-title">最终回答</div>
|
||||
<div class="message-text" v-html="renderMarkdown(executionResult.final_answer)"></div>
|
||||
</div>
|
||||
<div class="result-steps">
|
||||
<div class="result-section-title">执行步骤</div>
|
||||
<div v-for="(step, i) in executionResult.steps" :key="i" class="result-step" :class="{ expanded: step._open }">
|
||||
<div class="result-step-header" @click="step._open = !step._open">
|
||||
<el-icon><CaretRight :style="{ transform: step._open ? 'rotate(90deg)' : '' }" /></el-icon>
|
||||
<el-tag size="small" :type="step.error ? 'danger' : 'success'" round>{{ step.agent_name }}</el-tag>
|
||||
<span class="step-meta">{{ step.iterations_used }} 步 · {{ step.tool_calls_made }} 次工具</span>
|
||||
</div>
|
||||
<div v-show="step._open" class="result-step-body">
|
||||
<div class="message-text" v-html="renderMarkdown(step.output)"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Load template dialog -->
|
||||
<el-dialog v-model="showLoadDialog" title="加载编排模板" width="550px">
|
||||
<el-table :data="templates" @row-click="handleLoadTemplate" highlight-current-row stripe max-height="350px">
|
||||
<el-table-column prop="name" label="名称" />
|
||||
<el-table-column prop="description" label="描述" />
|
||||
<el-table-column label="更新日期" width="160">
|
||||
<template #default="{ row }">
|
||||
{{ row.updated_at ? new Date(row.updated_at).toLocaleDateString() : '' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-if="templates.length === 0" style="text-align: center; padding: 24px; color: #999;">暂无模板</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Save template dialog -->
|
||||
<el-dialog v-model="showSaveDialog" title="保存编排模板" width="450px" @closed="templateName = ''; templateDesc = ''">
|
||||
<el-form label-width="80px" size="small">
|
||||
<el-form-item label="模板名称">
|
||||
<el-input v-model="templateName" placeholder="输入模板名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="templateDesc" type="textarea" :rows="2" placeholder="可选描述" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showSaveDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="doSaveTemplate" :disabled="!templateName.trim()">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</MainLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, markRaw } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { VueFlow, useVueFlow } from '@vue-flow/core'
|
||||
import { Background } from '@vue-flow/background'
|
||||
import { Controls } from '@vue-flow/controls'
|
||||
import { MiniMap } from '@vue-flow/minimap'
|
||||
import { User, Switch, FolderChecked, FolderOpened, CaretRight } from '@element-plus/icons-vue'
|
||||
import { h } from 'vue'
|
||||
import MainLayout from '@/components/MainLayout.vue'
|
||||
import api from '@/api'
|
||||
import type { Agent } from '@/stores/agent'
|
||||
|
||||
// ---- Custom Agent Node Component ----
|
||||
const AgentNodeComponent = {
|
||||
props: ['data', 'selected'],
|
||||
setup(props: any) {
|
||||
return () => h('div', {
|
||||
class: 'orch-agent-node' + (props.selected ? ' selected' : ''),
|
||||
style: { padding: '10px 14px', borderRadius: '10px', background: props.selected ? '#ecf5ff' : '#f0f9eb', border: props.selected ? '2px solid #409EFF' : '2px solid #c8e6c9', minWidth: '160px', fontSize: '13px' }
|
||||
}, [
|
||||
h('div', { style: { display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '4px' } }, [
|
||||
h('span', { style: { fontSize: '16px' } }, '🤖'),
|
||||
h('strong', {}, props.data?.name || props.data?.agent_name || 'Agent'),
|
||||
]),
|
||||
h('div', { style: { fontSize: '11px', color: '#888' } }, props.data?.model || 'deepseek-v4-flash'),
|
||||
h('div', { style: { fontSize: '11px', color: '#aaa', marginTop: '2px' } },
|
||||
(props.data?.tools || []).length > 0 ? `${props.data.tools.length} 个工具` : '全部工具'
|
||||
),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Custom Condition Node Component ----
|
||||
const ConditionNodeComponent = {
|
||||
props: ['data', 'selected'],
|
||||
setup(props: any) {
|
||||
return () => h('div', {
|
||||
class: 'orch-condition-node' + (props.selected ? ' selected' : ''),
|
||||
style: { padding: '8px 14px', borderRadius: '8px', background: props.selected ? '#fdf6ec' : '#fef0f0', border: props.selected ? '2px solid #E6A23C' : '2px solid #f5dab1', minWidth: '120px', textAlign: 'center', fontSize: '13px', transform: 'rotate(2deg)' }
|
||||
}, [
|
||||
h('div', { style: { fontSize: '14px', marginBottom: '2px' } }, '◇'),
|
||||
h('strong', { style: { fontSize: '12px' } }, props.data?.name || '条件'),
|
||||
h('div', { style: { fontSize: '10px', color: '#888', marginTop: '2px' } },
|
||||
`${props.data?.operator || 'contains'} "${props.data?.value || ''}"`),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
const nodeTypes = {
|
||||
'agent-node': markRaw(AgentNodeComponent),
|
||||
'condition-node': markRaw(ConditionNodeComponent),
|
||||
}
|
||||
|
||||
const defaultEdgeOptions = {
|
||||
animated: true,
|
||||
style: { strokeWidth: 2 },
|
||||
}
|
||||
|
||||
// ---- State ----
|
||||
const vueFlowRef = ref()
|
||||
const { nodes, edges, onConnect: vfConnect, addNodes, addEdges } = useVueFlow()
|
||||
|
||||
// Override onConnect to add animated + deletable
|
||||
function onConnect(connection: any) {
|
||||
if (!connection.source || !connection.target) return
|
||||
edges.value.push({
|
||||
id: `e-${connection.source}-${connection.target}-${Date.now()}`,
|
||||
source: connection.source,
|
||||
target: connection.target,
|
||||
sourceHandle: connection.sourceHandle || '',
|
||||
animated: true,
|
||||
style: { strokeWidth: 2 },
|
||||
})
|
||||
}
|
||||
|
||||
let nextNodeId = 1
|
||||
function makeNodeId() { return `node-${Date.now()}-${nextNodeId++}` }
|
||||
|
||||
const selectedNodeId = ref<string | null>(null)
|
||||
const userInput = ref('')
|
||||
const executing = ref(false)
|
||||
const executionResult = ref<any>(null)
|
||||
const executionError = ref('')
|
||||
|
||||
// Templates
|
||||
const templates = ref<any[]>([])
|
||||
const showLoadDialog = ref(false)
|
||||
const showSaveDialog = ref(false)
|
||||
const templateName = ref('')
|
||||
const templateDesc = ref('')
|
||||
|
||||
// Agent list
|
||||
const agents = ref<Agent[]>([])
|
||||
const agentSearch = ref('')
|
||||
const filteredAgents = computed(() => {
|
||||
if (!agentSearch.value) return agents.value
|
||||
const s = agentSearch.value.toLowerCase()
|
||||
return agents.value.filter(a => a.name.toLowerCase().includes(s) || (a.description || '').toLowerCase().includes(s))
|
||||
})
|
||||
|
||||
// Common tools for the dropdown
|
||||
const availableTools = [
|
||||
'datetime', 'system_info', 'file_read', 'file_write',
|
||||
'http_request', 'database_query', 'web_search', 'calculator',
|
||||
'grep_search', 'list_files', 'execute_code',
|
||||
]
|
||||
|
||||
// Selected node
|
||||
const selectedNode = computed(() => {
|
||||
if (!selectedNodeId.value) return null
|
||||
return nodes.value.find(n => n.id === selectedNodeId.value) || null
|
||||
})
|
||||
|
||||
// ---- Drag & Drop ----
|
||||
function onDragStart(event: DragEvent, agent: Agent) {
|
||||
if (!event.dataTransfer) return
|
||||
event.dataTransfer.setData('application/agent', JSON.stringify(agent))
|
||||
event.dataTransfer.effectAllowed = 'copy'
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent) {
|
||||
const raw = event.dataTransfer?.getData('application/agent')
|
||||
if (!raw) return
|
||||
const agent: Agent = JSON.parse(raw)
|
||||
const pos = vueFlowRef.value?.screenToFlowCoordinate?.({ x: event.clientX, y: event.clientY }) || { x: event.clientX - 350, y: event.clientY - 100 }
|
||||
|
||||
const wc = agent.workflow_config || {}
|
||||
const agentNodes = wc.nodes || []
|
||||
const agentNodeCfg = agentNodes.find((n: any) => ['agent', 'llm', 'template'].includes(n.type || ''))
|
||||
const data = agentNodeCfg?.data || {}
|
||||
|
||||
const node = {
|
||||
id: makeNodeId(),
|
||||
type: 'agent-node',
|
||||
position: { x: pos.x - 80, y: pos.y - 30 },
|
||||
data: {
|
||||
agent_id: agent.id,
|
||||
name: agent.name,
|
||||
agent_name: agent.name,
|
||||
system_prompt: data.system_prompt || agent.description || '你是一个有用的AI助手。',
|
||||
model: data.model || 'deepseek-v4-flash',
|
||||
provider: data.provider || 'deepseek',
|
||||
temperature: data.temperature || 0.7,
|
||||
max_iterations: data.max_iterations || 10,
|
||||
tools: data.tools || [],
|
||||
},
|
||||
}
|
||||
addNodes([node])
|
||||
}
|
||||
|
||||
// ---- Node interaction ----
|
||||
function onNodeClick({ node }: any) {
|
||||
selectedNodeId.value = node.id
|
||||
}
|
||||
|
||||
function handleAddConditionNode() {
|
||||
const pos = { x: 200 + nodes.value.length * 50, y: 200 + nodes.value.length * 30 }
|
||||
const node = {
|
||||
id: makeNodeId(),
|
||||
type: 'condition-node',
|
||||
position: pos,
|
||||
data: {
|
||||
name: '条件',
|
||||
condition: '',
|
||||
operator: 'contains',
|
||||
value: '',
|
||||
},
|
||||
}
|
||||
addNodes([node])
|
||||
}
|
||||
|
||||
function handleDeleteNode() {
|
||||
if (!selectedNodeId.value) return
|
||||
nodes.value = nodes.value.filter(n => n.id !== selectedNodeId.value)
|
||||
edges.value = edges.value.filter(e => e.source !== selectedNodeId.value && e.target !== selectedNodeId.value)
|
||||
selectedNodeId.value = null
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
nodes.value = []
|
||||
edges.value = []
|
||||
selectedNodeId.value = null
|
||||
executionResult.value = null
|
||||
executionError.value = ''
|
||||
}
|
||||
|
||||
// ---- Templates ----
|
||||
async function loadTemplates() {
|
||||
try {
|
||||
const resp = await api.get('/api/v1/orchestration-templates')
|
||||
templates.value = resp.data || []
|
||||
} catch { templates.value = [] }
|
||||
}
|
||||
|
||||
async function handleSaveTemplate() {
|
||||
await loadTemplates()
|
||||
showSaveDialog.value = true
|
||||
}
|
||||
|
||||
async function doSaveTemplate() {
|
||||
try {
|
||||
await api.post('/api/v1/orchestration-templates', {
|
||||
name: templateName.value.trim(),
|
||||
description: templateDesc.value.trim(),
|
||||
nodes: JSON.parse(JSON.stringify(nodes.value)),
|
||||
edges: JSON.parse(JSON.stringify(edges.value)),
|
||||
})
|
||||
ElMessage.success('模板已保存')
|
||||
showSaveDialog.value = false
|
||||
templateName.value = ''
|
||||
templateDesc.value = ''
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.response?.data?.detail || '保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLoadTemplate(row: any) {
|
||||
try {
|
||||
const resp = await api.get(`/api/v1/orchestration-templates/${row.id}`)
|
||||
const tpl = resp.data
|
||||
nodes.value = tpl.nodes || []
|
||||
edges.value = tpl.edges || []
|
||||
nextNodeId = nodes.value.length + 1
|
||||
showLoadDialog.value = false
|
||||
ElMessage.success(`已加载: ${tpl.name}`)
|
||||
} catch {
|
||||
ElMessage.error('加载失败')
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Execution ----
|
||||
async function handleExecute() {
|
||||
if (!userInput.value.trim() || nodes.value.length === 0) return
|
||||
executing.value = true
|
||||
executionResult.value = null
|
||||
executionError.value = ''
|
||||
|
||||
try {
|
||||
const resp = await api.post('/api/v1/agent-chat/orchestrate/graph', {
|
||||
message: userInput.value.trim(),
|
||||
nodes: JSON.parse(JSON.stringify(nodes.value)),
|
||||
edges: JSON.parse(JSON.stringify(edges.value)),
|
||||
})
|
||||
const data = resp.data
|
||||
data.steps.forEach((s: any) => { s._open = false })
|
||||
executionResult.value = data
|
||||
} catch (e: any) {
|
||||
executionError.value = e.response?.data?.detail || e.message || '执行失败'
|
||||
} finally {
|
||||
executing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
function renderMarkdown(text: string): string {
|
||||
if (!text) return ''
|
||||
return text.replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code class="language-$1">$2</code></pre>')
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\n/g, '<br>')
|
||||
}
|
||||
|
||||
// ---- Init ----
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const resp = await api.get('/api/v1/agents')
|
||||
agents.value = resp.data || []
|
||||
} catch { /* ignore */ }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.orch-page {
|
||||
display: flex; flex-direction: column; height: calc(100vh - 60px);
|
||||
max-width: 100%; margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Toolbar */
|
||||
.orch-toolbar {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 8px 16px; border-bottom: 1px solid var(--el-border-color-light);
|
||||
flex-shrink: 0; background: var(--el-bg-color);
|
||||
}
|
||||
.toolbar-left { display: flex; align-items: center; gap: 12px; }
|
||||
.toolbar-left h3 { margin: 0; font-size: 16px; }
|
||||
.toolbar-right { display: flex; gap: 8px; }
|
||||
|
||||
/* Body */
|
||||
.orch-body {
|
||||
display: flex; flex: 1; min-height: 0; overflow: hidden;
|
||||
}
|
||||
|
||||
/* Left panel */
|
||||
.orch-left-panel {
|
||||
width: 220px; flex-shrink: 0; border-right: 1px solid var(--el-border-color-light);
|
||||
padding: 12px; display: flex; flex-direction: column; overflow-y: auto;
|
||||
}
|
||||
.panel-title { font-size: 14px; font-weight: 600; margin-bottom: 8px; color: var(--el-text-color-primary); }
|
||||
.agent-list { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 6px; }
|
||||
.agent-item {
|
||||
display: flex; align-items: center; gap: 8px; padding: 8px 10px;
|
||||
border: 1px solid var(--el-border-color-lighter); border-radius: 8px;
|
||||
cursor: grab; background: var(--el-bg-color); transition: all 0.15s;
|
||||
}
|
||||
.agent-item:hover { border-color: var(--el-color-primary); background: var(--el-color-primary-light-9); }
|
||||
.agent-item:active { cursor: grabbing; }
|
||||
.agent-item-icon { font-size: 18px; color: var(--el-color-primary); flex-shrink: 0; }
|
||||
.agent-item-info { flex: 1; min-width: 0; }
|
||||
.agent-item-name { font-size: 13px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.agent-item-desc { font-size: 11px; color: var(--el-text-color-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.agent-empty { text-align: center; padding: 24px; color: var(--el-text-color-placeholder); font-size: 13px; }
|
||||
.panel-hint { font-size: 11px; color: var(--el-text-color-placeholder); margin-top: 8px; text-align: center; }
|
||||
|
||||
/* Canvas */
|
||||
.orch-canvas {
|
||||
flex: 1; position: relative; min-width: 0; background: var(--el-fill-color-lighter);
|
||||
}
|
||||
.canvas-hint {
|
||||
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
|
||||
color: var(--el-text-color-placeholder); font-size: 14px; pointer-events: none; z-index: 1;
|
||||
}
|
||||
|
||||
/* Right panel */
|
||||
.orch-right-panel {
|
||||
width: 260px; flex-shrink: 0; border-left: 1px solid var(--el-border-color-light);
|
||||
padding: 12px; overflow-y: auto; background: var(--el-bg-color);
|
||||
}
|
||||
.panel-empty { text-align: center; padding: 24px; color: var(--el-text-color-placeholder); font-size: 13px; }
|
||||
.form-hint { font-size: 11px; color: var(--el-text-color-placeholder); }
|
||||
|
||||
/* Bottom */
|
||||
.orch-bottom {
|
||||
flex-shrink: 0; border-top: 1px solid var(--el-border-color-light);
|
||||
padding: 10px 16px; background: var(--el-bg-color);
|
||||
}
|
||||
.bottom-input { display: flex; gap: 10px; align-items: flex-end; }
|
||||
.bottom-input .el-textarea { flex: 1; }
|
||||
|
||||
/* Result */
|
||||
.bottom-result { margin-top: 10px; max-height: 300px; overflow-y: auto; }
|
||||
.result-error { margin-bottom: 8px; }
|
||||
.result-content { font-size: 14px; }
|
||||
.result-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||||
.result-mode { font-size: 12px; color: var(--el-text-color-secondary); }
|
||||
.result-section-title { font-size: 13px; font-weight: 600; margin-bottom: 6px; padding-bottom: 4px; border-bottom: 1px solid var(--el-border-color-light); }
|
||||
.result-answer { margin-bottom: 10px; }
|
||||
|
||||
/* Steps */
|
||||
.result-steps { display: flex; flex-direction: column; gap: 4px; }
|
||||
.result-step { border: 1px solid var(--el-border-color-lighter); border-radius: 8px; overflow: hidden; }
|
||||
.result-step-header { display: flex; align-items: center; gap: 6px; padding: 6px 10px; cursor: pointer; background: var(--el-fill-color-lighter); font-size: 13px; }
|
||||
.result-step-header:hover { background: var(--el-fill-color-light); }
|
||||
.step-meta { font-size: 11px; color: var(--el-text-color-placeholder); margin-left: auto; }
|
||||
.result-step-body { padding: 8px 12px; font-size: 13px; }
|
||||
|
||||
/* Message text */
|
||||
.message-text :deep(pre) { background: var(--el-fill-color); padding: 10px; border-radius: 6px; overflow-x: auto; font-size: 13px; }
|
||||
.message-text :deep(code) { background: var(--el-fill-color); padding: 2px 4px; border-radius: 3px; font-size: 13px; }
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* Global overrides for Vue Flow (non-scoped) */
|
||||
@import '@vue-flow/core/dist/style.css';
|
||||
@import '@vue-flow/core/dist/theme-default.css';
|
||||
@import '@vue-flow/controls/dist/style.css';
|
||||
@import '@vue-flow/minimap/dist/style.css';
|
||||
</style>
|
||||
Reference in New Issue
Block a user