462 lines
17 KiB
Python
462 lines
17 KiB
Python
"""Conversation-driven candidate workflow planning APIs.
|
||
|
||
The routes in this module intentionally stop before execution. A user can
|
||
select and edit a proposed graph here; ``workflow_runs.start_run`` is the only
|
||
place that turns a confirmed proposal into a durable run.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import contextlib
|
||
from collections.abc import Awaitable, Callable
|
||
from typing import Any
|
||
|
||
from fastapi import APIRouter, HTTPException, Request
|
||
from fastapi.responses import StreamingResponse
|
||
from pydantic import BaseModel, Field
|
||
|
||
from app.gateway.deps import get_current_user, get_optional_user_from_request
|
||
from app.gateway.services import format_sse
|
||
from app.gateway.workflow_proposal_planner import (
|
||
WorkflowPlannerUnavailableError,
|
||
build_workflow_proposals,
|
||
)
|
||
from app.gateway.workflow_resource_checks import build_resource_catalog, make_resource_checker
|
||
from deerflow.config.app_config import get_app_config
|
||
from deerflow.persistence.workflow_planning import (
|
||
WorkflowPlanningConflictError,
|
||
WorkflowPlanningProposalNotFoundError,
|
||
WorkflowPlanningSessionNotFoundError,
|
||
WorkflowPlanningStore,
|
||
)
|
||
from deerflow.workflows.schemas import WorkflowGraph
|
||
from deerflow.workflows.validator import validate_workflow_graph
|
||
|
||
router = APIRouter(prefix="/api/workflows", tags=["workflow-planning"])
|
||
PlanningProgressEmitter = Callable[[dict[str, Any]], Awaitable[None]]
|
||
|
||
# A small, valid SSE comment after a real frame makes the response observable
|
||
# immediately through development proxies as well as nginx. Some proxies hold
|
||
# sub-kilobyte chunks until the controller has finished, which makes a genuine
|
||
# progress stream appear to be a single JSON response. Comments are ignored
|
||
# by SSE consumers and never become business progress in the UI.
|
||
_SSE_FLUSH_PADDING = " " * 2_048
|
||
_SSE_HEARTBEAT_SECONDS = 1.0
|
||
|
||
|
||
class CreatePlanningSessionBody(BaseModel):
|
||
query: str = Field(min_length=1, max_length=12_000)
|
||
model_name: str | None = Field(default=None, alias="modelName", max_length=191)
|
||
|
||
model_config = {"populate_by_name": True}
|
||
|
||
|
||
class UpdateProposalGraphBody(BaseModel):
|
||
graph: dict[str, Any]
|
||
canvas_schema_json: str | None = Field(default=None, alias="canvasSchemaJson", max_length=5_000_000)
|
||
expected_revision: int = Field(alias="expectedRevision", ge=0)
|
||
|
||
model_config = {"populate_by_name": True}
|
||
|
||
|
||
def _cfg():
|
||
return get_app_config().workflows
|
||
|
||
|
||
def _require_enabled() -> None:
|
||
if not _cfg().enabled:
|
||
raise HTTPException(status_code=503, detail="Workflow Studio is disabled")
|
||
|
||
|
||
def _selected_model_name(value: str | None) -> str | None:
|
||
model_name = str(value or "").strip()
|
||
if not model_name:
|
||
return None
|
||
if get_app_config().get_model_config(model_name) is None:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail={"code": "WORKFLOW_MODEL_INVALID", "message": "所选模型不存在或已不可用"},
|
||
)
|
||
return model_name
|
||
|
||
|
||
def _store(request: Request) -> WorkflowPlanningStore:
|
||
store = getattr(request.app.state, "workflow_planning_store", None)
|
||
if store is None:
|
||
raise HTTPException(status_code=503, detail="Workflow planning store not available")
|
||
return store
|
||
|
||
|
||
def _definitions(request: Request):
|
||
store = getattr(request.app.state, "workflow_store", None)
|
||
if store is None:
|
||
raise HTTPException(status_code=503, detail="Workflow store not available")
|
||
return store
|
||
|
||
|
||
async def _is_admin(request: Request) -> bool:
|
||
user = await get_optional_user_from_request(request)
|
||
return user is not None and getattr(user, "system_role", None) == "admin"
|
||
|
||
|
||
async def _owned_session(request: Request, session_id: str) -> dict[str, Any]:
|
||
session = await _store(request).get_session(session_id)
|
||
if session is None:
|
||
raise HTTPException(status_code=404, detail={"code": "WORKFLOW_PLANNING_NOT_FOUND", "message": "规划会话不存在"})
|
||
user_id = await get_current_user(request)
|
||
if session.get("owner_id") != user_id and not await _is_admin(request):
|
||
raise HTTPException(status_code=403, detail={"code": "WORKFLOW_FORBIDDEN", "message": "无权访问该规划会话"})
|
||
return session
|
||
|
||
|
||
def _public_proposal(proposal: dict[str, Any]) -> dict[str, Any]:
|
||
graph = proposal.get("graph") if isinstance(proposal.get("graph"), dict) else {}
|
||
nodes = graph.get("nodes") if isinstance(graph.get("nodes"), list) else []
|
||
roles: list[dict[str, str]] = []
|
||
steps: list[str] = []
|
||
for node in nodes:
|
||
if not isinstance(node, dict):
|
||
continue
|
||
kind = str(node.get("type") or "")
|
||
name = str(node.get("name") or kind or "节点").strip()
|
||
if kind not in {"start", "output"} and name:
|
||
steps.append(name)
|
||
if kind not in {"agent", "skill"}:
|
||
continue
|
||
config = node.get("config") if isinstance(node.get("config"), dict) else {}
|
||
roles.append(
|
||
{
|
||
"nodeId": str(node.get("id") or ""),
|
||
"name": name or "智能体",
|
||
"kind": kind,
|
||
"agentId": str(config.get("agentId") or config.get("agent_id") or "default"),
|
||
}
|
||
)
|
||
return {
|
||
"proposalId": proposal["id"],
|
||
"position": proposal["position"],
|
||
"status": proposal["status"],
|
||
"strategy": proposal["strategy"],
|
||
"title": proposal["title"],
|
||
"summary": proposal.get("summary") or "",
|
||
"rationale": proposal.get("rationale") or "",
|
||
"estimatedDurationSeconds": proposal.get("estimated_duration_seconds"),
|
||
"graph": graph,
|
||
"canvasSchemaJson": proposal.get("canvas_schema") or "{}",
|
||
"validation": proposal.get("validation") or {},
|
||
"graphRevision": int(proposal.get("graph_revision") or 0),
|
||
"updatedAt": proposal.get("updated_at"),
|
||
# Presentation only: derived afresh from the executable graph so it
|
||
# can never become a second, stale planning authority.
|
||
"roles": roles,
|
||
"steps": steps[:12],
|
||
}
|
||
|
||
|
||
def _public_session(session: dict[str, Any]) -> dict[str, Any]:
|
||
return {
|
||
"planningSessionId": session["id"],
|
||
"workflowId": session["workflow_id"],
|
||
"query": session["query"],
|
||
"status": session["status"],
|
||
"sourceDraftRevision": session.get("source_draft_revision"),
|
||
"selectedProposalId": session.get("selected_proposal_id"),
|
||
"confirmedRunId": session.get("confirmed_run_id"),
|
||
"createdAt": session.get("created_at"),
|
||
"updatedAt": session.get("updated_at"),
|
||
"proposals": [_public_proposal(proposal) for proposal in session.get("proposals") or []],
|
||
}
|
||
|
||
|
||
async def _validation_payload(
|
||
request: Request,
|
||
*,
|
||
workflow_id: str,
|
||
user_id: str,
|
||
raw_graph: dict[str, Any],
|
||
) -> tuple[dict[str, Any], WorkflowGraph | None]:
|
||
try:
|
||
graph = WorkflowGraph.model_validate(raw_graph)
|
||
except Exception as exc: # malformed edits are retained with a readable card error
|
||
return (
|
||
{
|
||
"valid": False,
|
||
"issues": [
|
||
{
|
||
"code": "WORKFLOW_SCHEMA_INVALID",
|
||
"message": f"执行图不符合 WorkflowGraph v1.0: {exc}",
|
||
}
|
||
],
|
||
},
|
||
None,
|
||
)
|
||
catalog = await build_resource_catalog(request, user_id=user_id)
|
||
checker = make_resource_checker(
|
||
catalog,
|
||
config=_cfg(),
|
||
current_workflow_id=workflow_id,
|
||
)
|
||
issues = validate_workflow_graph(
|
||
graph,
|
||
resource_checker=checker,
|
||
system_max_steps=_cfg().max_steps,
|
||
system_max_loop_iterations=_cfg().max_loop_iterations,
|
||
system_max_parallelism=_cfg().max_parallelism,
|
||
)
|
||
return (
|
||
{
|
||
"valid": not issues,
|
||
"issues": [issue.model_dump(by_alias=True) for issue in issues],
|
||
},
|
||
graph,
|
||
)
|
||
|
||
|
||
async def _create_planning_session(
|
||
request: Request,
|
||
workflow_id: str,
|
||
body: CreatePlanningSessionBody,
|
||
*,
|
||
on_progress: PlanningProgressEmitter | None = None,
|
||
) -> dict[str, Any]:
|
||
"""Plan 2–3 editable, executable candidate flows from the current draft."""
|
||
_require_enabled()
|
||
user_id = await get_current_user(request)
|
||
definition = await _definitions(request).get_definition(workflow_id, include_draft=True)
|
||
if definition is None:
|
||
raise HTTPException(status_code=404, detail="工作流不存在")
|
||
if definition.get("owner_id") != user_id and not await _is_admin(request):
|
||
raise HTTPException(status_code=403, detail={"code": "WORKFLOW_FORBIDDEN", "message": "无权规划该工作流"})
|
||
raw_graph = definition.get("draft_graph") or {}
|
||
try:
|
||
source_graph = WorkflowGraph.model_validate(raw_graph)
|
||
except Exception as exc:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail={
|
||
"code": "WORKFLOW_SCHEMA_INVALID",
|
||
"message": f"当前画布没有可规划的执行图:{exc}",
|
||
},
|
||
) from exc
|
||
try:
|
||
model_name = _selected_model_name(body.model_name)
|
||
proposals = await build_workflow_proposals(
|
||
request.app,
|
||
source_graph=source_graph,
|
||
query=body.query.strip(),
|
||
owner_id=user_id,
|
||
model_name=model_name,
|
||
source_canvas_schema=str(definition.get("draft_canvas_schema") or "{}"),
|
||
on_progress=on_progress,
|
||
)
|
||
except WorkflowPlannerUnavailableError as exc:
|
||
raise HTTPException(
|
||
status_code=503,
|
||
detail={"code": "WORKFLOW_PLANNER_UNAVAILABLE", "message": str(exc)},
|
||
) from exc
|
||
if not proposals:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail={"code": "WORKFLOW_PLANNING_EMPTY", "message": "当前画布无法生成候选流程,请至少保留开始和可执行节点"},
|
||
)
|
||
if on_progress is not None:
|
||
await on_progress(
|
||
{
|
||
"phase": "validation",
|
||
"status": "running",
|
||
"message": "正在校验候选流程的节点、连线和权限。",
|
||
}
|
||
)
|
||
for proposal in proposals:
|
||
validation, _ = await _validation_payload(
|
||
request,
|
||
workflow_id=workflow_id,
|
||
user_id=user_id,
|
||
raw_graph=proposal["graph"],
|
||
)
|
||
proposal["validation"] = validation
|
||
session = await _store(request).create_session(
|
||
{
|
||
"workflow_id": workflow_id,
|
||
"owner_id": user_id,
|
||
"query": body.query.strip(),
|
||
"source_draft_revision": definition.get("draft_revision"),
|
||
"proposals": proposals,
|
||
}
|
||
)
|
||
public = _public_session(session)
|
||
if on_progress is not None:
|
||
await on_progress(
|
||
{
|
||
"phase": "validation",
|
||
"status": "completed",
|
||
"message": "候选流程校验完成。",
|
||
}
|
||
)
|
||
await on_progress(
|
||
{
|
||
"phase": "complete",
|
||
"status": "completed",
|
||
"message": f"已生成 {len(public['proposals'])} 条可编辑候选流程。",
|
||
}
|
||
)
|
||
return public
|
||
|
||
|
||
@router.post("/{workflow_id}/planning-sessions/stream")
|
||
async def create_planning_session_stream(
|
||
request: Request,
|
||
workflow_id: str,
|
||
body: CreatePlanningSessionBody,
|
||
) -> StreamingResponse:
|
||
"""Stream the real DeerFlow controller milestones, then one final session."""
|
||
|
||
def flush_frame(event: str, payload: dict[str, Any]) -> str:
|
||
return f"{format_sse(event, payload)}: stream-flush{_SSE_FLUSH_PADDING}\n\n"
|
||
|
||
def heartbeat_frame() -> str:
|
||
return f": keepalive{_SSE_FLUSH_PADDING}\n\n"
|
||
|
||
async def event_stream():
|
||
queue: asyncio.Queue[tuple[str, dict[str, Any] | None]] = asyncio.Queue()
|
||
|
||
async def emit_progress(payload: dict[str, Any]) -> None:
|
||
await queue.put(("progress", payload))
|
||
|
||
async def create() -> None:
|
||
try:
|
||
session = await _create_planning_session(
|
||
request,
|
||
workflow_id,
|
||
body,
|
||
on_progress=emit_progress,
|
||
)
|
||
await queue.put(("result", session))
|
||
except HTTPException as exc:
|
||
detail = exc.detail
|
||
if isinstance(detail, dict):
|
||
message = str(detail.get("message") or detail.get("detail") or "工作流规划失败")
|
||
code = str(detail.get("code") or f"http_{exc.status_code}")
|
||
else:
|
||
message = str(detail or "工作流规划失败")
|
||
code = f"http_{exc.status_code}"
|
||
await queue.put(("error", {"code": code, "message": message}))
|
||
except Exception: # noqa: BLE001 - SSE requires a terminal error frame
|
||
await queue.put(("error", {"code": "WORKFLOW_PLANNING_FAILED", "message": "工作流规划失败,请稍后重试"}))
|
||
finally:
|
||
await queue.put(("done", None))
|
||
|
||
try:
|
||
# This is a transport milestone, not a fabricated model phase. It
|
||
# gives the user immediate confirmation that their request reached
|
||
# the server before the first catalog/controller callback arrives.
|
||
yield flush_frame(
|
||
"progress",
|
||
{
|
||
"phase": "connection",
|
||
"status": "running",
|
||
"message": "已接收需求,正在连接工作流总控。",
|
||
},
|
||
)
|
||
task = asyncio.create_task(create())
|
||
while True:
|
||
try:
|
||
event, payload = await asyncio.wait_for(
|
||
queue.get(),
|
||
timeout=_SSE_HEARTBEAT_SECONDS,
|
||
)
|
||
except TimeoutError:
|
||
# Keep an otherwise quiet model connection visible and
|
||
# prevent an intermediary from buffering the next real
|
||
# controller milestone.
|
||
yield heartbeat_frame()
|
||
continue
|
||
if event == "done":
|
||
break
|
||
yield flush_frame(event, payload or {})
|
||
finally:
|
||
if "task" in locals() and not task.done():
|
||
task.cancel()
|
||
with contextlib.suppress(asyncio.CancelledError):
|
||
await task
|
||
|
||
return StreamingResponse(
|
||
event_stream(),
|
||
media_type="text/event-stream",
|
||
headers={
|
||
"Cache-Control": "no-cache, no-transform",
|
||
"Connection": "keep-alive",
|
||
"X-Accel-Buffering": "no",
|
||
},
|
||
)
|
||
|
||
|
||
@router.post("/{workflow_id}/planning-sessions")
|
||
async def create_planning_session(
|
||
request: Request,
|
||
workflow_id: str,
|
||
body: CreatePlanningSessionBody,
|
||
) -> dict[str, Any]:
|
||
"""Non-streaming compatibility endpoint for existing integrations."""
|
||
return await _create_planning_session(request, workflow_id, body)
|
||
|
||
|
||
@router.get("/planning-sessions/{session_id}")
|
||
async def get_planning_session(request: Request, session_id: str) -> dict[str, Any]:
|
||
_require_enabled()
|
||
return _public_session(await _owned_session(request, session_id))
|
||
|
||
|
||
@router.post("/planning-sessions/{session_id}/proposals/{proposal_id}/select")
|
||
async def select_planning_proposal(request: Request, session_id: str, proposal_id: str) -> dict[str, Any]:
|
||
_require_enabled()
|
||
await _owned_session(request, session_id)
|
||
try:
|
||
session = await _store(request).select_proposal(session_id, proposal_id)
|
||
except WorkflowPlanningProposalNotFoundError as exc:
|
||
raise HTTPException(status_code=404, detail={"code": "WORKFLOW_PROPOSAL_NOT_FOUND", "message": "候选流程不存在"}) from exc
|
||
except WorkflowPlanningConflictError as exc:
|
||
raise HTTPException(status_code=409, detail={"code": "WORKFLOW_PLANNING_CONFLICT", "message": str(exc)}) from exc
|
||
return _public_session(session)
|
||
|
||
|
||
@router.patch("/planning-sessions/{session_id}/proposals/{proposal_id}/graph")
|
||
async def update_planning_proposal_graph(
|
||
request: Request,
|
||
session_id: str,
|
||
proposal_id: str,
|
||
body: UpdateProposalGraphBody,
|
||
) -> dict[str, Any]:
|
||
_require_enabled()
|
||
session = await _owned_session(request, session_id)
|
||
user_id = await get_current_user(request)
|
||
validation, _ = await _validation_payload(
|
||
request,
|
||
workflow_id=session["workflow_id"],
|
||
user_id=user_id,
|
||
raw_graph=body.graph,
|
||
)
|
||
try:
|
||
updated = await _store(request).update_proposal(
|
||
session_id,
|
||
proposal_id,
|
||
graph=body.graph,
|
||
canvas_schema=body.canvas_schema_json,
|
||
validation=validation,
|
||
expected_revision=body.expected_revision,
|
||
)
|
||
except WorkflowPlanningSessionNotFoundError as exc:
|
||
raise HTTPException(status_code=404, detail={"code": "WORKFLOW_PLANNING_NOT_FOUND", "message": "规划会话不存在"}) from exc
|
||
except WorkflowPlanningProposalNotFoundError as exc:
|
||
raise HTTPException(status_code=404, detail={"code": "WORKFLOW_PROPOSAL_NOT_FOUND", "message": "候选流程不存在"}) from exc
|
||
except WorkflowPlanningConflictError as exc:
|
||
detail: dict[str, Any] = {"code": "WORKFLOW_PLANNING_CONFLICT", "message": str(exc)}
|
||
if exc.current_revision is not None:
|
||
detail["currentRevision"] = exc.current_revision
|
||
raise HTTPException(status_code=409, detail=detail) from exc
|
||
return _public_session(updated)
|
||
|
||
|
||
__all__ = ["router"]
|