mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 17:52:01 +00:00
964eeaa829
在画布上拖拽编排 agent 工作流,并实时看着它执行。 后端 FastAPI + LangGraph:把画布 JSON 编译成 StateGraph,15 种节点 (模型/Agent/多Agent、工具/沙箱、分支/循环/子图、记忆/检索/整形、 人工介入/结构校验)。选 LangGraph 是因为可视化编排真正难的是暂停、 恢复和回放,而 checkpointer + interrupt() 正好把这几件事做在了底层。 前端 React + React Flow:节点元数据驱动属性面板与连接桩,运行时 节点高亮、边流动画、卡片内流式 token 全部由同一份事件流驱动。 关键实现: - Agent 内工具审批用 @task 缓存模型调用,interrupt 重放时不重复计费 - 沙箱保持只读根文件系统,代码经 exec+base64 写入(put_archive 会被 只读容器拒绝),断网、非 root、内存/CPU/超时限额 - 检索为向量与 BM25 混合,本地哈希 embedding 零配置可用 - 分支条件走 AST 白名单求值器;HTTP 工具逐 IP 拦截内网与元数据地址 - Mock provider 让无 API Key 也能跑通完整链路 7 个内置模板全部冒烟通过,含真实模型端到端验证。
221 lines
6.6 KiB
Python
221 lines
6.6 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.db.base import get_session
|
|
from app.db.models import Run, Workflow, WorkflowVersion
|
|
from app.engine.schema import GraphSpec, validate_graph
|
|
|
|
router = APIRouter(prefix="/api/workflows", tags=["workflows"])
|
|
|
|
|
|
class WorkflowIn(BaseModel):
|
|
name: str = Field(min_length=1, max_length=200)
|
|
description: str = ""
|
|
graph: dict[str, Any] = Field(default_factory=dict)
|
|
tags: list[str] = Field(default_factory=list)
|
|
|
|
|
|
class WorkflowPatch(BaseModel):
|
|
name: str | None = None
|
|
description: str | None = None
|
|
graph: dict[str, Any] | None = None
|
|
tags: list[str] | None = None
|
|
note: str = ""
|
|
|
|
|
|
class WorkflowOut(BaseModel):
|
|
id: str
|
|
name: str
|
|
description: str
|
|
graph: dict[str, Any]
|
|
tags: list[str]
|
|
version: int
|
|
is_template: bool
|
|
created_at: Any = None
|
|
updated_at: Any = None
|
|
run_count: int = 0
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
@router.get("", response_model=list[WorkflowOut])
|
|
async def list_workflows(session: AsyncSession = Depends(get_session)) -> list[WorkflowOut]:
|
|
rows = list(
|
|
(await session.execute(select(Workflow).order_by(Workflow.updated_at.desc()))).scalars()
|
|
)
|
|
counts = dict(
|
|
(await session.execute(select(Run.workflow_id, func.count(Run.id)).group_by(Run.workflow_id))).all()
|
|
)
|
|
out: list[WorkflowOut] = []
|
|
for row in rows:
|
|
item = WorkflowOut.model_validate(row)
|
|
item.run_count = counts.get(row.id, 0)
|
|
out.append(item)
|
|
return out
|
|
|
|
|
|
@router.post("", response_model=WorkflowOut, status_code=201)
|
|
async def create_workflow(
|
|
payload: WorkflowIn, session: AsyncSession = Depends(get_session)
|
|
) -> Workflow:
|
|
workflow = Workflow(
|
|
name=payload.name,
|
|
description=payload.description,
|
|
graph=payload.graph,
|
|
tags=payload.tags,
|
|
)
|
|
session.add(workflow)
|
|
await session.flush()
|
|
session.add(
|
|
WorkflowVersion(workflow_id=workflow.id, version=1, graph=payload.graph, note="初始版本")
|
|
)
|
|
await session.commit()
|
|
await session.refresh(workflow)
|
|
return workflow
|
|
|
|
|
|
@router.get("/{workflow_id}", response_model=WorkflowOut)
|
|
async def get_workflow(
|
|
workflow_id: str, session: AsyncSession = Depends(get_session)
|
|
) -> Workflow:
|
|
workflow = await session.get(Workflow, workflow_id)
|
|
if not workflow:
|
|
raise HTTPException(404, "工作流不存在")
|
|
return workflow
|
|
|
|
|
|
@router.patch("/{workflow_id}", response_model=WorkflowOut)
|
|
async def update_workflow(
|
|
workflow_id: str, payload: WorkflowPatch, session: AsyncSession = Depends(get_session)
|
|
) -> Workflow:
|
|
workflow = await session.get(Workflow, workflow_id)
|
|
if not workflow:
|
|
raise HTTPException(404, "工作流不存在")
|
|
|
|
if payload.name is not None:
|
|
workflow.name = payload.name
|
|
if payload.description is not None:
|
|
workflow.description = payload.description
|
|
if payload.tags is not None:
|
|
workflow.tags = payload.tags
|
|
if payload.graph is not None and payload.graph != workflow.graph:
|
|
# 图有实际变化才留快照,避免改个名字就堆一堆版本
|
|
workflow.graph = payload.graph
|
|
workflow.version += 1
|
|
session.add(
|
|
WorkflowVersion(
|
|
workflow_id=workflow.id,
|
|
version=workflow.version,
|
|
graph=payload.graph,
|
|
note=payload.note,
|
|
)
|
|
)
|
|
await session.commit()
|
|
await session.refresh(workflow)
|
|
return workflow
|
|
|
|
|
|
@router.delete("/{workflow_id}", status_code=204)
|
|
async def delete_workflow(
|
|
workflow_id: str, session: AsyncSession = Depends(get_session)
|
|
) -> None:
|
|
workflow = await session.get(Workflow, workflow_id)
|
|
if not workflow:
|
|
raise HTTPException(404, "工作流不存在")
|
|
await session.delete(workflow)
|
|
await session.commit()
|
|
|
|
|
|
@router.post("/{workflow_id}/duplicate", response_model=WorkflowOut, status_code=201)
|
|
async def duplicate_workflow(
|
|
workflow_id: str, session: AsyncSession = Depends(get_session)
|
|
) -> Workflow:
|
|
source = await session.get(Workflow, workflow_id)
|
|
if not source:
|
|
raise HTTPException(404, "工作流不存在")
|
|
copy = Workflow(
|
|
name=f"{source.name} 副本",
|
|
description=source.description,
|
|
graph=source.graph,
|
|
tags=list(source.tags or []),
|
|
)
|
|
session.add(copy)
|
|
await session.commit()
|
|
await session.refresh(copy)
|
|
return copy
|
|
|
|
|
|
class VersionOut(BaseModel):
|
|
id: str
|
|
version: int
|
|
note: str
|
|
created_at: Any = None
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
@router.get("/{workflow_id}/versions", response_model=list[VersionOut])
|
|
async def list_versions(
|
|
workflow_id: str, session: AsyncSession = Depends(get_session)
|
|
) -> list[WorkflowVersion]:
|
|
rows = await session.execute(
|
|
select(WorkflowVersion)
|
|
.where(WorkflowVersion.workflow_id == workflow_id)
|
|
.order_by(WorkflowVersion.version.desc())
|
|
)
|
|
return list(rows.scalars())
|
|
|
|
|
|
@router.post("/{workflow_id}/versions/{version}/restore", response_model=WorkflowOut)
|
|
async def restore_version(
|
|
workflow_id: str, version: int, session: AsyncSession = Depends(get_session)
|
|
) -> Workflow:
|
|
workflow = await session.get(Workflow, workflow_id)
|
|
if not workflow:
|
|
raise HTTPException(404, "工作流不存在")
|
|
snapshot = (
|
|
await session.execute(
|
|
select(WorkflowVersion).where(
|
|
WorkflowVersion.workflow_id == workflow_id,
|
|
WorkflowVersion.version == version,
|
|
)
|
|
)
|
|
).scalar_one_or_none()
|
|
if not snapshot:
|
|
raise HTTPException(404, "版本不存在")
|
|
|
|
workflow.graph = snapshot.graph
|
|
workflow.version += 1
|
|
session.add(
|
|
WorkflowVersion(
|
|
workflow_id=workflow.id,
|
|
version=workflow.version,
|
|
graph=snapshot.graph,
|
|
note=f"回滚到 v{version}",
|
|
)
|
|
)
|
|
await session.commit()
|
|
await session.refresh(workflow)
|
|
return workflow
|
|
|
|
|
|
class ValidateIn(BaseModel):
|
|
graph: dict[str, Any]
|
|
|
|
|
|
@router.post("/validate")
|
|
async def validate(payload: ValidateIn) -> dict[str, Any]:
|
|
"""画布上的实时校验。前端每次改动都会调它来标红。"""
|
|
try:
|
|
spec = GraphSpec.model_validate(payload.graph)
|
|
except Exception as e: # noqa: BLE001
|
|
return {"ok": False, "issues": [{"level": "error", "message": f"图结构非法:{e}"}]}
|
|
result = validate_graph(spec)
|
|
return result.model_dump()
|