agent-Specialization/modules/workflow_manager.py
JOJO d071292c81 feat(workflow): 工作流编辑器——可视化画布编辑 + WORKFLOW.md 落盘 + REST CRUD + 侧边栏入口
- 编辑器:/workflows 库页 + /workflow/<name> 编辑器(Vue Flow 画布,开始/结束/阶段/审核/分支五类节点,白前进/蓝通过/红驳回三色语义连线,dagre 自动排版)
- 落盘:modules/workflow_manager.py;host 存运行态根 workflows/,web/docker 存 users/<user>/personal/workflows/;源码树 workflows/ 为内置种子,用户库同名覆盖、内置不可删
- REST:GET/PUT/DELETE /api/workflows[/<name>],保存时后端强制 error 级结构校验
- 路由根治:新增 isConversationIndependentRoute() 统一谓词(URL 派生),收敛全部 9 处对话接管/URL 写回判断点,根治 /workflows 下地址栏被拽回对话的问题
- 深色主题 token 提亮(surface-soft/card/muted 去近黑色阶)
2026-08-21 01:38:03 +08:00

329 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""工作流Workflow存储管理WORKFLOW.md 的加载、保存、列举、删除。
存储布局(对齐 skills 的双源合并模式):
- 内置示例:源码树 ``workflows/<name>/WORKFLOW.md``(只读种子)
- 用户库host 模式 ``<runtime_root>/host/workflows/``web/docker 模式
``users/<user>/personal/workflows/``(从 workspace data_dir 推断)
文件格式YAML frontmattersnake_case 结构)+ markdown 正文body
API 层传输使用 camelCase dict本模块负责双向转换。
"""
from __future__ import annotations
import os
import re
import shutil
import tempfile
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
import yaml
from config.paths import CUSTOM_SKILLS_DIR, IS_HOST_MODE
# 内置示例种子目录(源码树 workflows/
BUILTIN_WORKFLOWS_DIR = Path(__file__).resolve().parent.parent / "workflows"
WORKFLOW_FILENAME = "WORKFLOW.md"
# host 模式用户库:与 CUSTOM_SKILLS_DIR 平级(<runtime_root>/<mode>/workflows
CUSTOM_WORKFLOWS_DIR = str(Path(CUSTOM_SKILLS_DIR).parent / "workflows")
_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
# ---------------------------------------------------------------- 路径推断
def infer_user_workflows_dir(data_dir: str | Path | None) -> Optional[Path]:
"""从 workspace data_dir 推断用户工作流库目录(对齐 infer_private_skills_dir
host 模式:统一运行态根下的 workflows/,不按用户拆分。
web/dockerusers/<user>/personal/workflows/。
"""
if IS_HOST_MODE:
root = Path(CUSTOM_WORKFLOWS_DIR).expanduser().resolve()
root.mkdir(parents=True, exist_ok=True)
return root
if not data_dir:
return None
try:
data_path = Path(data_dir).expanduser().resolve()
if data_path.name == "data" and data_path.parent.parent.name == "workspaces":
root = (data_path.parent.parent.parent / "workflows").resolve()
root.mkdir(parents=True, exist_ok=True)
return root
if data_path.name == "data" and data_path.parent.parent.name in ("projects", "project"):
user_root = data_path.parent.parent.parent
root = (user_root / "personal" / "workflows").resolve()
root.mkdir(parents=True, exist_ok=True)
return root
except Exception:
return None
return None
def _workflow_file(root: Path, name: str) -> Path:
return (root / name / WORKFLOW_FILENAME).resolve()
def _safe_name(name: str) -> str:
"""校验并返回合法的工作流目录名slug"""
cleaned = (name or "").strip()
if not _NAME_RE.match(cleaned):
raise ValueError(f"工作流名称不合法:{cleaned!r}(仅限小写字母/数字/连字符3-64 字符)")
return cleaned
# ---------------------------------------------------------------- camelCase ↔ snake_case 转换
_NODE_KINDS = ("start", "end", "stage", "review", "branch")
def _node_to_yaml(node: Dict[str, Any]) -> Dict[str, Any]:
out: Dict[str, Any] = {"id": node.get("id"), "kind": node.get("kind")}
if node.get("name"):
out["name"] = node["name"]
kind = node.get("kind")
if kind == "stage":
out["goal"] = node.get("goal", "")
out["instructions"] = node.get("instructions", "")
out["next"] = node.get("next")
elif kind == "review":
out["prompt"] = node.get("prompt", "")
out["next"] = node.get("next")
out["reject_to"] = node.get("rejectTo")
out["max_rejects"] = node.get("maxRejects", 3)
elif kind == "branch":
out["next"] = [
{"target": r.get("target"), "condition": r.get("condition", "")}
for r in node.get("next", [])
]
elif kind == "start":
out["next"] = node.get("next")
if node.get("position"):
out["position"] = {"x": round(node["position"].get("x", 0)), "y": round(node["position"].get("y", 0))}
return out
def _node_from_yaml(data: Dict[str, Any]) -> Dict[str, Any]:
kind = data.get("kind")
node: Dict[str, Any] = {
"id": str(data.get("id") or ""),
"kind": kind if kind in _NODE_KINDS else "stage",
"name": str(data.get("name") or data.get("id") or ""),
}
if node["kind"] == "stage":
node["goal"] = str(data.get("goal") or "")
node["instructions"] = str(data.get("instructions") or "")
node["next"] = data.get("next") or None
elif node["kind"] == "review":
node["prompt"] = str(data.get("prompt") or "")
node["next"] = data.get("next") or None
node["rejectTo"] = data.get("reject_to") or None
node["maxRejects"] = int(data.get("max_rejects") or 3)
elif node["kind"] == "branch":
routes = []
for r in data.get("next") or []:
if isinstance(r, dict) and r.get("target"):
routes.append({"target": str(r["target"]), "condition": str(r.get("condition") or "")})
node["next"] = routes
elif node["kind"] == "start":
node["next"] = data.get("next") or None
pos = data.get("position")
if isinstance(pos, dict) and isinstance(pos.get("x"), (int, float)):
node["position"] = {"x": float(pos["x"]), "y": float(pos.get("y") or 0)}
return node
def workflow_to_markdown(wf: Dict[str, Any]) -> str:
"""camelCase dict → WORKFLOW.md 文本。"""
meta = {
"name": wf.get("name"),
"description": wf.get("description", ""),
"review_mode": wf.get("reviewMode", "active"),
"max_stage_rounds": int(wf.get("maxStageRounds", 20)),
"end_conditions": wf.get("endConditions", ""),
"updated_at": wf.get("updatedAt") or datetime.now().strftime("%Y-%m-%d %H:%M"),
"nodes": [_node_to_yaml(n) for n in wf.get("nodes", [])],
}
frontmatter = yaml.safe_dump(meta, allow_unicode=True, sort_keys=False).strip()
body = (wf.get("body") or "").strip()
return f"---\n{frontmatter}\n---\n\n{body}\n"
def workflow_from_markdown(text: str, source: str) -> Dict[str, Any]:
"""WORKFLOW.md 文本 → camelCase dict。"""
match = re.match(r"^---\s*\n(.*?)\n---\s*\n?(.*)$", text, re.DOTALL)
if not match:
raise ValueError("WORKFLOW.md 缺少 YAML frontmatter")
meta = yaml.safe_load(match.group(1)) or {}
body = match.group(2).strip()
return {
"name": str(meta.get("name") or ""),
"description": str(meta.get("description") or ""),
"reviewMode": meta.get("review_mode") if meta.get("review_mode") in ("readonly", "active") else "active",
"maxStageRounds": int(meta.get("max_stage_rounds") or 20),
"endConditions": str(meta.get("end_conditions") or ""),
"updatedAt": str(meta.get("updated_at") or ""),
"body": body,
"source": source,
"nodes": [_node_from_yaml(n) for n in meta.get("nodes") or [] if isinstance(n, dict)],
}
# ---------------------------------------------------------------- 结构校验(保存时强制 error 级)
def validate_structure(wf: Dict[str, Any]) -> List[str]:
"""返回 error 级问题列表(空 = 可保存)。规则与前端 validateWorkflow 对齐。"""
errors: List[str] = []
if not str(wf.get("name") or "").strip():
errors.append("工作流缺少 name")
nodes = wf.get("nodes") or []
if not nodes:
errors.append("至少需要一个开始节点和一个结束节点")
return errors
by_id: Dict[str, Dict[str, Any]] = {}
for n in nodes:
nid = n.get("id")
if nid in by_id:
errors.append(f"节点 id 重复:{nid}")
by_id[nid] = n
starts = [n for n in nodes if n.get("kind") == "start"]
ends = [n for n in nodes if n.get("kind") == "end"]
if len(starts) == 0:
errors.append("缺少开始节点")
elif len(starts) > 1:
errors.append(f"开始节点只能有一个(当前 {len(starts)} 个)")
if not ends:
errors.append("缺少结束节点")
def check_ref(owner: Dict[str, Any], target: Any, label: str) -> None:
if not target:
errors.append(f"{label}未连接")
elif target not in by_id:
errors.append(f"{label}指向不存在的节点:{target}")
elif by_id[target].get("kind") == "start":
errors.append(f"{label}不能指向开始节点")
for n in nodes:
kind = n.get("kind")
name = n.get("name") or n.get("id")
if kind == "start":
check_ref(n, n.get("next"), "开始节点")
elif kind == "stage":
check_ref(n, n.get("next"), f"阶段「{name}")
elif kind == "review":
check_ref(n, n.get("next"), f"审核「{name}」的通过路由")
check_ref(n, n.get("rejectTo"), f"审核「{name}」的驳回路由")
if not isinstance(n.get("maxRejects"), int) or n["maxRejects"] < 1:
errors.append(f"审核「{name}」驳回上限必须 ≥ 1")
elif kind == "branch":
for r in n.get("next") or []:
check_ref(n, r.get("target"), f"分支「{name}」的出线")
return errors
# ---------------------------------------------------------------- CRUD
def _iter_workflow_dirs(root: Path) -> List[Tuple[str, Path]]:
if not root.exists() or not root.is_dir():
return []
out = []
for child in sorted(root.iterdir()):
wf_file = child / WORKFLOW_FILENAME
if child.is_dir() and wf_file.exists():
out.append((child.name, wf_file))
return out
def list_workflows(data_dir: str | Path | None) -> List[Dict[str, Any]]:
"""双源合并列举(用户库同名覆盖内置)。"""
merged: Dict[str, Dict[str, Any]] = {}
for source, root in (("builtin", BUILTIN_WORKFLOWS_DIR), ("user", infer_user_workflows_dir(data_dir))):
if not root:
continue
for name, wf_file in _iter_workflow_dirs(Path(root)):
try:
wf = workflow_from_markdown(wf_file.read_text(encoding="utf-8"), source)
merged[wf["name"] or name] = {
"name": wf["name"] or name,
"description": wf["description"],
"source": source,
"updatedAt": wf["updatedAt"],
"nodeCount": len(wf["nodes"]),
}
except Exception:
# 损坏文件不拖垮列表
merged[name] = {
"name": name,
"description": "(文件解析失败)",
"source": source,
"updatedAt": "",
"nodeCount": 0,
}
return sorted(merged.values(), key=lambda x: x["name"])
def load_workflow(name: str, data_dir: str | Path | None) -> Dict[str, Any]:
"""加载完整工作流(用户库优先,其次内置)。返回 camelCase dict。"""
_safe_name(name)
user_root = infer_user_workflows_dir(data_dir)
if user_root:
user_file = _workflow_file(user_root, name)
if user_file.exists():
return workflow_from_markdown(user_file.read_text(encoding="utf-8"), "user")
builtin_file = _workflow_file(BUILTIN_WORKFLOWS_DIR, name)
if builtin_file.exists():
return workflow_from_markdown(builtin_file.read_text(encoding="utf-8"), "builtin")
raise FileNotFoundError(f"工作流不存在:{name}")
def save_workflow(wf: Dict[str, Any], data_dir: str | Path | None) -> Path:
"""保存到用户库(原子写)。结构 error 时 raise ValueError。"""
name = _safe_name(str(wf.get("name") or ""))
errors = validate_structure(wf)
if errors:
raise ValueError("工作流结构校验未通过:" + "".join(errors))
root = infer_user_workflows_dir(data_dir)
if not root:
raise ValueError("无法确定用户工作流库目录")
wf = dict(wf)
wf["name"] = name
wf["updatedAt"] = datetime.now().strftime("%Y-%m-%d %H:%M")
target_dir = (root / name).resolve()
if not str(target_dir).startswith(str(root.resolve())):
raise ValueError("非法路径")
target_dir.mkdir(parents=True, exist_ok=True)
content = workflow_to_markdown(wf)
fd, tmp_path = tempfile.mkstemp(dir=str(target_dir), prefix=".WORKFLOW.", suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(content)
os.replace(tmp_path, target_dir / WORKFLOW_FILENAME)
finally:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
return target_dir / WORKFLOW_FILENAME
def delete_workflow(name: str, data_dir: str | Path | None) -> None:
"""删除用户库中的工作流(内置示例不可删)。"""
_safe_name(name)
root = infer_user_workflows_dir(data_dir)
if not root:
raise ValueError("无法确定用户工作流库目录")
target_dir = (root / name).resolve()
if not str(target_dir).startswith(str(root.resolve())):
raise ValueError("非法路径")
if not target_dir.exists():
builtin_file = _workflow_file(BUILTIN_WORKFLOWS_DIR, name)
if builtin_file.exists():
raise ValueError("内置示例不可删除(可复制为用户工作流后删除副本)")
raise FileNotFoundError(f"工作流不存在:{name}")
shutil.rmtree(target_dir)