Explorar o código

Add keyed workflow remote execution

codex hai 1 mes
pai
achega
f231512186

+ 141 - 18
api-docs.md

@@ -94,11 +94,12 @@ AI 模块分为项目内部统一 AI 服务层和具体供应商 API 对接层
   "automation_error_path": "automation/errors",
   "automation_runtime_path": "automation/runtime",
   "automation_auto_screenshot_enabled": false,
-  "automation_auto_screenshot_interval": 30
+  "automation_auto_screenshot_interval": 30,
+  "automation_remote_token": "your-remote-token"
 }
 ```
 
-路径必须是相对路径,后端会解析到 `backend/data` 目录下。
+路径必须是相对路径,后端会解析到 `backend/data` 目录下。`automation_remote_token` 用于按 key 远程执行工作流,远程调用时通过 `X-Automation-Token` 请求头传入。
 
 ### 查询 AI 服务商
 
@@ -813,54 +814,176 @@ smartctl -a -d jmb39x,1 /dev/sdb
 
 `GET /api/automation/workflows`
 
+返回工作流列表,包含 `schema_version`、`node_count`、`edge_count`。
+
+`GET /api/automation/workflow-nodes`
+
+返回后端注册的节点定义,前端可据此生成节点库和参数表单。每个节点包含 `type`、`category`、`label`、`params`、`inputs`、`outputs`、`control_ports`。
+
+常用节点示例:`browser.open_url` 打开网页,`wait.seconds` 等待,`mouse.double_click` 双击坐标,`text.input` 输入文本。
+
+`POST /api/automation/workflows/plan`
+
+根据用户需求调用 AI 生成 `workflow/v1` 草稿。
+
+```json
+{
+  "requirement": "打开记事本并输入 hello",
+  "provider_id": null,
+  "model_id": null,
+  "temperature": null
+}
+```
+
+返回:
+
+```json
+{
+  "session_id": "uuid",
+  "plan": {
+    "summary": "计划摘要",
+    "questions": [],
+    "workflow": {
+      "schema_version": "workflow/v1",
+      "name": "示例",
+      "nodes": [],
+      "edges": []
+    }
+  },
+  "ai_raw_content": "..."
+}
+```
+
+`POST /api/automation/workflows/plan/continue`
+
+继续一次 AI 工作流规划对话。
+
+```json
+{
+  "session_id": "uuid",
+  "user_message": "补充说明",
+  "provider_id": null,
+  "model_id": null,
+  "temperature": null
+}
+```
+
 `POST /api/automation/workflows`
 
 ```json
 {
+  "schema_version": "workflow/v1",
+  "workflow_key": "browser-click-demo",
   "name": "打开浏览器并点击",
   "description": "示例工作流",
+  "variables": {
+    "target_text": {
+      "type": "string",
+      "default": "hello"
+    }
+  },
+  "settings": {
+    "max_steps": 100,
+    "default_timeout_ms": 30000,
+    "on_unhandled_error": "pause_for_user"
+  },
   "nodes": [
     {
-      "node_type": "start_program",
+      "id": "start_1",
+      "type": "flow.start",
+      "title": "开始",
+      "position": { "x": 80, "y": 120 },
+      "params": {},
+      "inputs": {}
+    },
+    {
+      "id": "program_1",
+      "type": "program.start",
       "title": "启动 Edge",
-      "config": { "command": "msedge" }
+      "position": { "x": 320, "y": 120 },
+      "params": { "command": "msedge", "shell": true },
+      "inputs": {}
     },
     {
-      "node_type": "mouse",
-      "screen_id": 1,
+      "id": "mouse_1",
+      "type": "mouse.click",
       "title": "点击按钮",
-      "config": { "x": 420, "y": 260, "mouse_action": "click" }
+      "position": { "x": 560, "y": 120 },
+      "params": { "button": "left", "clicks": 1 },
+      "inputs": {
+        "x": { "source": "literal", "value": 420 },
+        "y": { "source": "literal", "value": 260 }
+      }
+    }
+  ],
+  "edges": [
+    {
+      "id": "edge_1",
+      "kind": "control",
+      "source": "start_1",
+      "source_port": "next",
+      "target": "program_1",
+      "target_port": "run"
+    },
+    {
+      "id": "edge_2",
+      "kind": "control",
+      "source": "program_1",
+      "source_port": "success",
+      "target": "mouse_1",
+      "target_port": "run"
     }
   ]
 }
 ```
 
-节点类型当前支持:`mouse`、`keyboard`、`text_input`、`start_program`、`close_programs`。
-
-节点可附带画布位置和连接关系:
+输入值支持四种来源:
 
 ```json
-{
-  "node_key": "node_1",
-  "position_x": 120,
-  "position_y": 160,
-  "next_node_keys": ["node_2"]
-}
+{ "source": "literal", "value": 100 }
+{ "source": "variable", "name": "target_text" }
+{ "source": "node_output", "node_id": "locate_1", "output": "x" }
+{ "source": "runtime", "name": "current_screenshot_path" }
 ```
 
+连线 `kind` 分为 `control` 和 `data`。`control` 决定执行顺序,`data` 把源节点输出写入目标节点输入。完整格式见 `workflow-format.md`。
+
 `GET /api/automation/workflows/{workflow_id}`
 
+`GET /api/automation/workflows/by-key/{workflow_key}`
+
 `POST /api/automation/workflows/{workflow_id}/run`
 
 ```json
 {
   "provider_id": null,
   "model_id": null,
-  "temperature": null
+  "temperature": null,
+  "variables": {
+    "target_text": "本次运行覆盖值"
+  }
 }
 ```
 
-如果不传 AI 参数,后端会使用系统设置中的默认 AI 服务商、模型和温度。后端会按照工作流节点连接关系执行;如果没有连接关系,则按节点序号执行。
+如果不传 AI 参数,后端会使用系统设置中的默认 AI 服务商、模型和温度。后端会从 `flow.start` 或没有入边的节点开始,沿 `control` 连线执行。节点输出会写入运行上下文,供后续节点通过 `node_output` 或 `data` 连线读取。节点失败时,返回项会尽量包含 `artifacts.screenshot_path`,用于前端展示失败截图并继续询问用户。
+
+`POST /api/automation/workflows/by-key/{workflow_key}/run`
+
+按稳定 key 远程执行工作流。该接口必须先在系统设置中配置 `automation_remote_token`,并在请求头携带:
+
+```text
+X-Automation-Token: your-remote-token
+```
+
+请求体与按 ID 执行一致:
+
+```json
+{
+  "variables": {
+    "channel_url": "https://tv.cctv.com/live/cctv5"
+  }
+}
+```
 
 `PUT /api/automation/workflows/{workflow_id}`
 

+ 4 - 0
backend/app/automation/__init__.py

@@ -0,0 +1,4 @@
+from . import nodes  # noqa: F401 - importing registers built-in workflow nodes.
+from .registry import NODE_REGISTRY, get_node_definitions, get_node_executor
+
+__all__ = ["NODE_REGISTRY", "get_node_definitions", "get_node_executor"]

+ 29 - 0
backend/app/automation/context.py

@@ -0,0 +1,29 @@
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Any
+
+
+class WorkflowPaused(Exception):
+    """Raised by nodes that need user input before the run can continue."""
+
+    def __init__(self, message: str, payload: dict[str, Any] | None = None) -> None:
+        super().__init__(message)
+        self.message = message
+        self.payload = payload or {}
+
+
+@dataclass
+class WorkflowContext:
+    workflow_id: int | None
+    provider_id: int | None = None
+    model_id: int | None = None
+    temperature: float = 0.1
+    variables: dict[str, Any] = field(default_factory=dict)
+    runtime: dict[str, Any] = field(default_factory=dict)
+    outputs: dict[str, dict[str, Any]] = field(default_factory=dict)
+    opened_pids: list[int] = field(default_factory=list)
+
+    def remember_pid(self, pid: int | None) -> None:
+        if pid is not None and pid not in self.opened_pids:
+            self.opened_pids.append(pid)

+ 3 - 0
backend/app/automation/nodes/__init__.py

@@ -0,0 +1,3 @@
+from . import flow, human, keyboard, mouse, program, screen, text, wait
+
+__all__ = ["flow", "human", "keyboard", "mouse", "program", "screen", "text", "wait"]

+ 80 - 0
backend/app/automation/nodes/flow.py

@@ -0,0 +1,80 @@
+from __future__ import annotations
+
+from typing import Any
+
+from ..context import WorkflowContext
+from ..registry import control_ports, register_node
+
+
+def start_node(node: dict[str, Any], inputs: dict[str, Any], context: WorkflowContext) -> dict[str, Any]:
+    return {"message": "workflow started"}
+
+
+def end_node(node: dict[str, Any], inputs: dict[str, Any], context: WorkflowContext) -> dict[str, Any]:
+    return {"message": "workflow ended"}
+
+
+def condition_node(node: dict[str, Any], inputs: dict[str, Any], context: WorkflowContext) -> dict[str, Any]:
+    left = inputs.get("left", node.get("params", {}).get("left"))
+    right = inputs.get("right", node.get("params", {}).get("right"))
+    operator = node.get("params", {}).get("operator") or "equals"
+    matched = {
+        "equals": left == right,
+        "not_equals": left != right,
+        "truthy": bool(left),
+        "falsy": not bool(left),
+        "contains": str(right) in str(left),
+    }.get(operator, False)
+    return {"matched": matched, "next_port": "true" if matched else "false"}
+
+
+register_node(
+    {
+        "type": "flow.start",
+        "category": "flow",
+        "label": "开始",
+        "params": {},
+        "inputs": {},
+        "outputs": {},
+        "control_ports": {"inputs": [], "outputs": ["next"]},
+    },
+    start_node,
+)
+
+register_node(
+    {
+        "type": "flow.end",
+        "category": "flow",
+        "label": "结束",
+        "params": {},
+        "inputs": {},
+        "outputs": {},
+        "control_ports": {"inputs": ["run"], "outputs": []},
+    },
+    end_node,
+)
+
+register_node(
+    {
+        "type": "flow.condition",
+        "category": "flow",
+        "label": "条件判断",
+        "params": {
+            "operator": {
+                "type": "select",
+                "label": "判断方式",
+                "default": "equals",
+                "options": ["equals", "not_equals", "truthy", "falsy", "contains"],
+            },
+            "left": {"type": "text", "label": "左值"},
+            "right": {"type": "text", "label": "右值"},
+        },
+        "inputs": {
+            "left": {"type": "any", "label": "左值"},
+            "right": {"type": "any", "label": "右值"},
+        },
+        "outputs": {"matched": {"type": "boolean", "label": "是否匹配"}},
+        "control_ports": control_ports(["true", "false", "failure"]),
+    },
+    condition_node,
+)

+ 32 - 0
backend/app/automation/nodes/human.py

@@ -0,0 +1,32 @@
+from __future__ import annotations
+
+from typing import Any
+
+from ..context import WorkflowContext, WorkflowPaused
+from ..registry import control_ports, field_def, register_node
+
+
+def ask_user_node(node: dict[str, Any], inputs: dict[str, Any], context: WorkflowContext) -> dict[str, Any]:
+    question = str(inputs.get("question", node.get("params", {}).get("question")) or "请确认下一步操作。")
+    raise WorkflowPaused(
+        question,
+        {
+            "node_id": node.get("id"),
+            "question": question,
+            "screenshot_path": context.runtime.get("current_screenshot_path"),
+        },
+    )
+
+
+register_node(
+    {
+        "type": "human.ask_user",
+        "category": "human",
+        "label": "询问用户",
+        "params": {"question": field_def("textarea", "问题", "请确认下一步操作。")},
+        "inputs": {"question": field_def("string", "问题")},
+        "outputs": {"answer": {"type": "string", "label": "用户回答"}},
+        "control_ports": control_ports(["answered", "failure"]),
+    },
+    ask_user_node,
+)

+ 71 - 0
backend/app/automation/nodes/keyboard.py

@@ -0,0 +1,71 @@
+from __future__ import annotations
+
+from typing import Any
+
+from ... import windows_automation
+from ..context import WorkflowContext
+from ..registry import control_ports, field_def, register_node
+
+
+def press_node(node: dict[str, Any], inputs: dict[str, Any], context: WorkflowContext) -> dict[str, Any]:
+    key = str(inputs.get("key", node.get("params", {}).get("key")) or "")
+    result = windows_automation.keyboard_action("press", key=key)
+    return {"key": result.get("key"), "action": result.get("action")}
+
+
+def hotkey_node(node: dict[str, Any], inputs: dict[str, Any], context: WorkflowContext) -> dict[str, Any]:
+    keys = inputs.get("keys", node.get("params", {}).get("keys")) or []
+    if isinstance(keys, str):
+        keys = [part.strip() for part in keys.split("+") if part.strip()]
+    result = windows_automation.keyboard_action("hotkey", keys=keys)
+    return {"keys": result.get("keys"), "action": result.get("action")}
+
+
+def key_down_node(node: dict[str, Any], inputs: dict[str, Any], context: WorkflowContext) -> dict[str, Any]:
+    key = str(inputs.get("key", node.get("params", {}).get("key")) or "")
+    result = windows_automation.keyboard_action("key_down", key=key)
+    return {"key": result.get("key"), "action": result.get("action")}
+
+
+def key_up_node(node: dict[str, Any], inputs: dict[str, Any], context: WorkflowContext) -> dict[str, Any]:
+    key = str(inputs.get("key", node.get("params", {}).get("key")) or "")
+    result = windows_automation.keyboard_action("key_up", key=key)
+    return {"key": result.get("key"), "action": result.get("action")}
+
+
+KEY_OUTPUTS = {
+    "key": {"type": "string", "label": "按键"},
+    "keys": {"type": "array", "label": "组合键"},
+    "action": {"type": "string", "label": "动作名称"},
+}
+
+for node_type, label, executor in [
+    ("keyboard.press", "按下按键", press_node),
+    ("keyboard.key_down", "按键按下", key_down_node),
+    ("keyboard.key_up", "按键释放", key_up_node),
+]:
+    register_node(
+        {
+            "type": node_type,
+            "category": "keyboard",
+            "label": label,
+            "params": {"key": field_def("text", "按键", required=True)},
+            "inputs": {"key": field_def("string", "按键")},
+            "outputs": KEY_OUTPUTS,
+            "control_ports": control_ports(),
+        },
+        executor,
+    )
+
+register_node(
+    {
+        "type": "keyboard.hotkey",
+        "category": "keyboard",
+        "label": "组合键",
+        "params": {"keys": field_def("array", "组合键", ["ctrl", "s"], required=True)},
+        "inputs": {"keys": field_def("array", "组合键")},
+        "outputs": KEY_OUTPUTS,
+        "control_ports": control_ports(),
+    },
+    hotkey_node,
+)

+ 123 - 0
backend/app/automation/nodes/mouse.py

@@ -0,0 +1,123 @@
+from __future__ import annotations
+
+from typing import Any
+
+from ... import windows_automation
+from ..context import WorkflowContext
+from ..registry import control_ports, field_def, register_node
+
+
+def _number(value: Any, default: float = 0) -> float:
+    try:
+        return float(value)
+    except (TypeError, ValueError):
+        return default
+
+
+def click_node(node: dict[str, Any], inputs: dict[str, Any], context: WorkflowContext) -> dict[str, Any]:
+    params = node.get("params", {})
+    result = windows_automation.mouse_action(
+        "click",
+        x=int(_number(inputs.get("x", params.get("x")))),
+        y=int(_number(inputs.get("y", params.get("y")))),
+        duration=_number(params.get("duration"), 0),
+        button=params.get("button") or "left",
+        clicks=int(_number(params.get("clicks"), 1)),
+    )
+    return {"x": result.get("x"), "y": result.get("y"), "action": result.get("action")}
+
+
+def double_click_node(node: dict[str, Any], inputs: dict[str, Any], context: WorkflowContext) -> dict[str, Any]:
+    params = node.get("params", {})
+    result = windows_automation.mouse_action(
+        "double_click",
+        x=int(_number(inputs.get("x", params.get("x")))),
+        y=int(_number(inputs.get("y", params.get("y")))),
+        button=params.get("button") or "left",
+    )
+    return {"x": result.get("x"), "y": result.get("y"), "action": result.get("action")}
+
+
+def right_click_node(node: dict[str, Any], inputs: dict[str, Any], context: WorkflowContext) -> dict[str, Any]:
+    params = node.get("params", {})
+    result = windows_automation.mouse_action(
+        "right_click",
+        x=int(_number(inputs.get("x", params.get("x")))),
+        y=int(_number(inputs.get("y", params.get("y")))),
+    )
+    return {"x": result.get("x"), "y": result.get("y"), "action": result.get("action")}
+
+
+def move_node(node: dict[str, Any], inputs: dict[str, Any], context: WorkflowContext) -> dict[str, Any]:
+    params = node.get("params", {})
+    result = windows_automation.mouse_action(
+        "move_to",
+        x=int(_number(inputs.get("x", params.get("x")))),
+        y=int(_number(inputs.get("y", params.get("y")))),
+        duration=_number(params.get("duration"), 0),
+    )
+    return {"x": result.get("x"), "y": result.get("y"), "action": result.get("action")}
+
+
+def scroll_node(node: dict[str, Any], inputs: dict[str, Any], context: WorkflowContext) -> dict[str, Any]:
+    amount = int(_number(inputs.get("amount", node.get("params", {}).get("amount")), 0))
+    result = windows_automation.mouse_action("scroll", amount=amount)
+    return {"x": result.get("x"), "y": result.get("y"), "amount": amount, "action": result.get("action")}
+
+
+COORD_INPUTS = {
+    "x": field_def("number", "X 坐标", required=True),
+    "y": field_def("number", "Y 坐标", required=True),
+}
+
+MOUSE_OUTPUTS = {
+    "x": {"type": "number", "label": "鼠标 X 坐标"},
+    "y": {"type": "number", "label": "鼠标 Y 坐标"},
+    "action": {"type": "string", "label": "动作名称"},
+}
+
+for node_type, label, executor, params in [
+    (
+        "mouse.click",
+        "鼠标点击",
+        click_node,
+        {
+            "button": field_def("select", "按键", "left", options=["left", "middle", "right"]),
+            "clicks": field_def("number", "点击次数", 1, minimum=1, maximum=20),
+            "duration": field_def("number", "移动耗时", 0, minimum=0, maximum=60),
+        },
+    ),
+    (
+        "mouse.double_click",
+        "鼠标双击",
+        double_click_node,
+        {"button": field_def("select", "按键", "left", options=["left", "middle", "right"])},
+    ),
+    ("mouse.right_click", "鼠标右键", right_click_node, {}),
+    ("mouse.move", "鼠标移动", move_node, {"duration": field_def("number", "移动耗时", 0, minimum=0, maximum=60)}),
+]:
+    register_node(
+        {
+            "type": node_type,
+            "category": "mouse",
+            "label": label,
+            "params": params,
+            "inputs": COORD_INPUTS,
+            "outputs": MOUSE_OUTPUTS,
+            "control_ports": control_ports(),
+        },
+        executor,
+    )
+
+register_node(
+    {
+        "type": "mouse.scroll",
+        "category": "mouse",
+        "label": "鼠标滚动",
+        "params": {"amount": field_def("number", "滚动量", 0)},
+        "inputs": {"amount": field_def("number", "滚动量")},
+        "outputs": MOUSE_OUTPUTS | {"amount": {"type": "number", "label": "滚动量"}},
+        "control_ports": control_ports(),
+    },
+    scroll_node,
+)

+ 137 - 0
backend/app/automation/nodes/program.py

@@ -0,0 +1,137 @@
+from __future__ import annotations
+
+from typing import Any
+
+from ... import windows_automation
+from ..context import WorkflowContext
+from ..registry import control_ports, field_def, register_node
+
+
+def start_program_node(node: dict[str, Any], inputs: dict[str, Any], context: WorkflowContext) -> dict[str, Any]:
+    params = node.get("params", {})
+    command = str(inputs.get("command", params.get("command")) or "")
+    result = windows_automation.start_program(
+        command=command,
+        cwd=inputs.get("cwd", params.get("cwd")),
+        shell=bool(inputs.get("shell", params.get("shell", True))),
+    )
+    context.remember_pid(result.get("pid"))
+    return result
+
+
+def stop_program_node(node: dict[str, Any], inputs: dict[str, Any], context: WorkflowContext) -> dict[str, Any]:
+    params = node.get("params", {})
+    pid = inputs.get("pid", params.get("pid"))
+    result = windows_automation.stop_program(
+        pid=int(pid) if pid not in (None, "") else None,
+        name=inputs.get("name", params.get("name")),
+        timeout_seconds=float(params.get("timeout_seconds", 8)),
+        kill_after_timeout=bool(params.get("kill_after_timeout", True)),
+    )
+    return result
+
+
+def close_opened_programs_node(node: dict[str, Any], inputs: dict[str, Any], context: WorkflowContext) -> dict[str, Any]:
+    closed = []
+    for pid in list(context.opened_pids):
+        try:
+            closed.append(windows_automation.stop_program(pid=pid))
+        except Exception as exc:
+            closed.append({"pid": pid, "error": str(exc)})
+        finally:
+            if pid in context.opened_pids:
+                context.opened_pids.remove(pid)
+    return {"action": "close_opened_programs", "items": closed}
+
+
+def open_url_node(node: dict[str, Any], inputs: dict[str, Any], context: WorkflowContext) -> dict[str, Any]:
+    params = node.get("params", {})
+    result = windows_automation.open_url(
+        url=str(inputs.get("url", params.get("url")) or ""),
+        browser=inputs.get("browser", params.get("browser")),
+        new_window=bool(inputs.get("new_window", params.get("new_window", True))),
+    )
+    context.remember_pid(result.get("pid"))
+    return result
+
+
+register_node(
+    {
+        "type": "browser.open_url",
+        "category": "program",
+        "label": "打开网页",
+        "params": {
+            "url": field_def("text", "网址", "https://example.com", required=True),
+            "browser": field_def("select", "浏览器", "default", options=["default", "edge"]),
+            "new_window": field_def("boolean", "新窗口", True),
+        },
+        "inputs": {
+            "url": field_def("string", "网址"),
+            "browser": field_def("string", "浏览器"),
+            "new_window": field_def("boolean", "新窗口"),
+        },
+        "outputs": {
+            "url": {"type": "string", "label": "网址"},
+            "browser": {"type": "string", "label": "浏览器"},
+            "pid": {"type": "number", "label": "启动进程 PID"},
+        },
+        "control_ports": control_ports(),
+    },
+    open_url_node,
+)
+
+register_node(
+    {
+        "type": "program.start",
+        "category": "program",
+        "label": "启动程序",
+        "params": {
+            "command": field_def("text", "命令", "notepad", required=True),
+            "cwd": field_def("text", "工作目录"),
+            "shell": field_def("boolean", "使用 Shell", True),
+        },
+        "inputs": {
+            "command": field_def("string", "命令"),
+            "cwd": field_def("string", "工作目录"),
+            "shell": field_def("boolean", "使用 Shell"),
+        },
+        "outputs": {
+            "pid": {"type": "number", "label": "进程 PID"},
+            "command": {"type": "string", "label": "命令"},
+            "cwd": {"type": "string", "label": "工作目录"},
+        },
+        "control_ports": control_ports(),
+    },
+    start_program_node,
+)
+
+register_node(
+    {
+        "type": "program.stop",
+        "category": "program",
+        "label": "关闭程序",
+        "params": {
+            "pid": field_def("number", "PID"),
+            "name": field_def("text", "进程名"),
+            "timeout_seconds": field_def("number", "等待秒数", 8, minimum=0, maximum=60),
+            "kill_after_timeout": field_def("boolean", "超时后强制结束", True),
+        },
+        "inputs": {"pid": field_def("number", "PID"), "name": field_def("string", "进程名")},
+        "outputs": {"matched": {"type": "number", "label": "匹配数量"}, "items": {"type": "array", "label": "关闭结果"}},
+        "control_ports": control_ports(),
+    },
+    stop_program_node,
+)
+
+register_node(
+    {
+        "type": "program.close_opened",
+        "category": "program",
+        "label": "关闭本流程打开的程序",
+        "params": {},
+        "inputs": {},
+        "outputs": {"items": {"type": "array", "label": "关闭结果"}},
+        "control_ports": control_ports(),
+    },
+    close_opened_programs_node,
+)

+ 34 - 0
backend/app/automation/nodes/screen.py

@@ -0,0 +1,34 @@
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any
+
+from ... import windows_automation
+from ..context import WorkflowContext
+from ..registry import control_ports, field_def, register_node
+
+
+def screenshot_node(node: dict[str, Any], inputs: dict[str, Any], context: WorkflowContext) -> dict[str, Any]:
+    save_path = inputs.get("save_path", node.get("params", {}).get("save_path"))
+    result = windows_automation.take_screenshot(str(Path(save_path)) if save_path else None, include_base64=False)
+    if result.get("path"):
+        context.runtime["current_screenshot_path"] = result["path"]
+    return result
+
+
+register_node(
+    {
+        "type": "screen.screenshot",
+        "category": "screen",
+        "label": "屏幕截图",
+        "params": {"save_path": field_def("text", "保存路径")},
+        "inputs": {"save_path": field_def("string", "保存路径")},
+        "outputs": {
+            "path": {"type": "string", "label": "图片路径"},
+            "width": {"type": "number", "label": "宽度"},
+            "height": {"type": "number", "label": "高度"},
+        },
+        "control_ports": control_ports(),
+    },
+    screenshot_node,
+)

+ 38 - 0
backend/app/automation/nodes/text.py

@@ -0,0 +1,38 @@
+from __future__ import annotations
+
+from typing import Any
+
+from fastapi import HTTPException
+
+from ... import windows_automation
+from ..context import WorkflowContext
+from ..registry import control_ports, field_def, register_node
+
+
+def input_text_node(node: dict[str, Any], inputs: dict[str, Any], context: WorkflowContext) -> dict[str, Any]:
+    text = str(inputs.get("text", node.get("params", {}).get("text")) or "")
+    try:
+        import pyperclip
+    except ImportError as exc:
+        raise HTTPException(status_code=500, detail="pyperclip is not installed") from exc
+    pyperclip.copy(text)
+    result = windows_automation.keyboard_action("hotkey", keys=["ctrl", "v"])
+    return {"text": text, "length": len(text), "action": result.get("action")}
+
+
+register_node(
+    {
+        "type": "text.input",
+        "category": "text",
+        "label": "输入文本",
+        "params": {"text": field_def("textarea", "文本", "")},
+        "inputs": {"text": field_def("string", "文本")},
+        "outputs": {
+            "text": {"type": "string", "label": "输入文本"},
+            "length": {"type": "number", "label": "文本长度"},
+            "action": {"type": "string", "label": "动作名称"},
+        },
+        "control_ports": control_ports(),
+    },
+    input_text_node,
+)

+ 28 - 0
backend/app/automation/nodes/wait.py

@@ -0,0 +1,28 @@
+from __future__ import annotations
+
+import time
+from typing import Any
+
+from ..context import WorkflowContext
+from ..registry import control_ports, field_def, register_node
+
+
+def seconds_node(node: dict[str, Any], inputs: dict[str, Any], context: WorkflowContext) -> dict[str, Any]:
+    seconds = float(inputs.get("seconds", node.get("params", {}).get("seconds", 1)))
+    seconds = max(0, min(seconds, 3600))
+    time.sleep(seconds)
+    return {"seconds": seconds}
+
+
+register_node(
+    {
+        "type": "wait.seconds",
+        "category": "wait",
+        "label": "等待秒数",
+        "params": {"seconds": field_def("number", "秒数", 1, minimum=0, maximum=3600)},
+        "inputs": {"seconds": field_def("number", "秒数")},
+        "outputs": {"seconds": {"type": "number", "label": "实际等待秒数"}},
+        "control_ports": control_ports(),
+    },
+    seconds_node,
+)

+ 57 - 0
backend/app/automation/registry.py

@@ -0,0 +1,57 @@
+from __future__ import annotations
+
+from typing import Any, Callable
+
+from .context import WorkflowContext
+
+
+NodeExecutor = Callable[[dict[str, Any], dict[str, Any], WorkflowContext], dict[str, Any]]
+
+
+NODE_REGISTRY: dict[str, dict[str, Any]] = {}
+NODE_EXECUTORS: dict[str, NodeExecutor] = {}
+
+
+def register_node(definition: dict[str, Any], executor: NodeExecutor) -> None:
+    node_type = str(definition["type"])
+    NODE_REGISTRY[node_type] = definition
+    NODE_EXECUTORS[node_type] = executor
+
+
+def get_node_definitions() -> list[dict[str, Any]]:
+    return sorted(NODE_REGISTRY.values(), key=lambda item: (item.get("category", ""), item.get("label", "")))
+
+
+def get_node_executor(node_type: str) -> NodeExecutor:
+    if node_type not in NODE_EXECUTORS:
+        raise KeyError(f"Unsupported workflow node type: {node_type}")
+    return NODE_EXECUTORS[node_type]
+
+
+def field_def(
+    field_type: str,
+    label: str,
+    default: Any = None,
+    required: bool = False,
+    options: list[Any] | None = None,
+    minimum: float | None = None,
+    maximum: float | None = None,
+) -> dict[str, Any]:
+    item: dict[str, Any] = {
+        "type": field_type,
+        "label": label,
+        "required": required,
+    }
+    if default is not None:
+        item["default"] = default
+    if options is not None:
+        item["options"] = options
+    if minimum is not None:
+        item["min"] = minimum
+    if maximum is not None:
+        item["max"] = maximum
+    return item
+
+
+def control_ports(outputs: list[str] | None = None) -> dict[str, list[str]]:
+    return {"inputs": ["run"], "outputs": outputs or ["success", "failure"]}

+ 319 - 162
backend/app/automation_service.py

@@ -3,7 +3,10 @@ from __future__ import annotations
 import base64
 import json
 import mimetypes
+import re
+import sqlite3
 import time
+import uuid
 from pathlib import Path
 from typing import Any
 
@@ -11,6 +14,8 @@ import psutil
 from fastapi import HTTPException
 
 from . import ai_service, settings_service, windows_automation
+from .automation import get_node_definitions, get_node_executor
+from .automation.context import WorkflowContext, WorkflowPaused
 from .database import DATA_DIR, get_db
 from .scanner import now_iso
 from .schemas import (
@@ -23,6 +28,8 @@ from .schemas import (
     AutomationVisionAnalyzeRequest,
     AutomationWorkflowRunRequest,
     AutomationWorkflowSaveRequest,
+    AutomationWorkflowPlanRequest,
+    AutomationWorkflowPlanContinueRequest,
 )
 
 
@@ -626,69 +633,69 @@ def close_opened_programs(pids: list[int] | None = None) -> dict[str, Any]:
 
 
 def save_workflow(payload: AutomationWorkflowSaveRequest) -> dict[str, Any]:
-    """保存前端记录或手动编辑的自动化工作流和节点。"""
+    """保存 workflow/v1 工作流图。"""
     now = now_iso()
-    raw_json = payload.model_dump()
-    with get_db() as conn:
-        cursor = conn.execute(
-            """
-            INSERT INTO automation_workflows (name, description, raw_json, created_at, updated_at)
-            VALUES (?, ?, ?, ?, ?)
-            """,
-            (payload.name.strip(), payload.description, json.dumps(raw_json, ensure_ascii=False), now, now),
-        )
-        workflow_id = cursor.lastrowid
-        insert_workflow_nodes(conn, workflow_id, payload.nodes, now)
+    workflow_json = normalize_workflow_payload(payload)
+    workflow_key = normalize_workflow_key(payload.workflow_key)
+    try:
+        with get_db() as conn:
+            cursor = conn.execute(
+                """
+                INSERT INTO automation_workflows (workflow_key, name, description, raw_json, created_at, updated_at)
+                VALUES (?, ?, ?, ?, ?, ?)
+                """,
+                (workflow_key, payload.name.strip(), payload.description, json.dumps(workflow_json, ensure_ascii=False), now, now),
+            )
+            workflow_id = cursor.lastrowid
+            conn.execute("DELETE FROM automation_workflow_nodes WHERE workflow_id = ?", (workflow_id,))
+    except sqlite3.IntegrityError as exc:
+        raise HTTPException(status_code=409, detail="Workflow key already exists") from exc
     return get_workflow(workflow_id)
 
 
 def update_workflow(workflow_id: int, payload: AutomationWorkflowSaveRequest) -> dict[str, Any]:
-    """更新工作流基础信息和节点列表。"""
+    """更新 workflow/v1 工作流图。"""
     now = now_iso()
-    raw_json = payload.model_dump()
-    with get_db() as conn:
-        existing = conn.execute("SELECT id FROM automation_workflows WHERE id = ?", (workflow_id,)).fetchone()
-        if not existing:
-            raise HTTPException(status_code=404, detail="Automation workflow not found")
-        conn.execute(
-            """
-            UPDATE automation_workflows
-            SET name = ?, description = ?, raw_json = ?, updated_at = ?
-            WHERE id = ?
-            """,
-            (payload.name.strip(), payload.description, json.dumps(raw_json, ensure_ascii=False), now, workflow_id),
-        )
-        conn.execute("DELETE FROM automation_workflow_nodes WHERE workflow_id = ?", (workflow_id,))
-        insert_workflow_nodes(conn, workflow_id, payload.nodes, now)
+    workflow_json = normalize_workflow_payload(payload)
+    workflow_key = normalize_workflow_key(payload.workflow_key)
+    try:
+        with get_db() as conn:
+            existing = conn.execute("SELECT id FROM automation_workflows WHERE id = ?", (workflow_id,)).fetchone()
+            if not existing:
+                raise HTTPException(status_code=404, detail="Automation workflow not found")
+            conn.execute(
+                """
+                UPDATE automation_workflows
+                SET workflow_key = ?, name = ?, description = ?, raw_json = ?, updated_at = ?
+                WHERE id = ?
+                """,
+                (workflow_key, payload.name.strip(), payload.description, json.dumps(workflow_json, ensure_ascii=False), now, workflow_id),
+            )
+            conn.execute("DELETE FROM automation_workflow_nodes WHERE workflow_id = ?", (workflow_id,))
+    except sqlite3.IntegrityError as exc:
+        raise HTTPException(status_code=409, detail="Workflow key already exists") from exc
     return get_workflow(workflow_id)
 
 
-def insert_workflow_nodes(conn, workflow_id: int, nodes: list[Any], now: str) -> None:
-    """批量写入工作流节点。"""
-    for index, node in enumerate(nodes, start=1):
-        conn.execute(
-            """
-            INSERT INTO automation_workflow_nodes (
-                workflow_id, node_index, node_key, node_type, screen_id, title,
-                position_x, position_y, next_node_keys, config_json, created_at, updated_at
-            )
-            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
-            """,
-            (
-                workflow_id,
-                index,
-                node.node_key or f"node_{index}",
-                node.node_type,
-                node.screen_id,
-                node.title,
-                node.position_x,
-                node.position_y,
-                json.dumps(node.next_node_keys, ensure_ascii=False),
-                json.dumps(node.config, ensure_ascii=False),
-                now,
-                now,
-            ),
-        )
+def normalize_workflow_payload(payload: AutomationWorkflowSaveRequest) -> dict[str, Any]:
+    """把请求模型转换为持久化的 workflow/v1 JSON。"""
+    workflow_json = payload.model_dump()
+    workflow_json["schema_version"] = "workflow/v1"
+    workflow_json["workflow_key"] = normalize_workflow_key(payload.workflow_key)
+    workflow_json["name"] = payload.name.strip()
+    workflow_json.setdefault("variables", {})
+    workflow_json.setdefault("settings", {})
+    workflow_json.setdefault("edges", [])
+    return workflow_json
+
+
+def normalize_workflow_key(value: str | None) -> str | None:
+    key = (value or "").strip()
+    if not key:
+        return None
+    if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]*", key):
+        raise HTTPException(status_code=400, detail="Workflow key can only contain letters, numbers, underscores, and hyphens")
+    return key
 
 
 def list_workflows(page: int, page_size: int) -> dict[str, Any]:
@@ -698,33 +705,38 @@ def list_workflows(page: int, page_size: int) -> dict[str, Any]:
         total = conn.execute("SELECT COUNT(*) AS total FROM automation_workflows").fetchone()["total"]
         rows = conn.execute(
             """
-            SELECT w.*, COUNT(n.id) AS node_count
-            FROM automation_workflows w
-            LEFT JOIN automation_workflow_nodes n ON n.workflow_id = w.id
-            GROUP BY w.id
-            ORDER BY w.updated_at DESC
+            SELECT *
+            FROM automation_workflows
+            ORDER BY updated_at DESC
             LIMIT ? OFFSET ?
             """,
             (page_size, offset),
         ).fetchall()
-    return {"items": rows, "total": total, "page": page, "page_size": page_size}
+    return {"items": [workflow_summary(row) for row in rows], "total": total, "page": page, "page_size": page_size}
 
 
 def get_workflow(workflow_id: int) -> dict[str, Any]:
-    """读取工作流详情和节点列表。"""
+    """读取 workflow/v1 工作流详情。"""
     with get_db() as conn:
         workflow = conn.execute("SELECT * FROM automation_workflows WHERE id = ?", (workflow_id,)).fetchone()
         if not workflow:
             raise HTTPException(status_code=404, detail="Automation workflow not found")
-        nodes = conn.execute(
-            "SELECT * FROM automation_workflow_nodes WHERE workflow_id = ? ORDER BY node_index ASC",
-            (workflow_id,),
-        ).fetchall()
-    item = dict(workflow)
-    item["nodes"] = [public_node(row) for row in nodes]
+    item = workflow_to_public(workflow)
     return item
 
 
+def get_workflow_by_key(workflow_key: str) -> dict[str, Any]:
+    """按稳定 key 读取 workflow/v1 工作流详情。"""
+    key = normalize_workflow_key(workflow_key)
+    if not key:
+        raise HTTPException(status_code=400, detail="Workflow key is required")
+    with get_db() as conn:
+        workflow = conn.execute("SELECT * FROM automation_workflows WHERE workflow_key = ?", (key,)).fetchone()
+        if not workflow:
+            raise HTTPException(status_code=404, detail="Automation workflow not found")
+    return workflow_to_public(workflow)
+
+
 def delete_workflow(workflow_id: int) -> dict[str, Any]:
     """删除工作流及其节点。"""
     with get_db() as conn:
@@ -735,124 +747,269 @@ def delete_workflow(workflow_id: int) -> dict[str, Any]:
 
 
 def run_workflow(workflow_id: int, payload: AutomationWorkflowRunRequest) -> dict[str, Any]:
-    """按数据库中保存的工作流节点和连线顺序在后端执行整个工作流。"""
+    """执行 workflow/v1 工作流图。"""
     workflow = get_workflow(workflow_id)
     defaults = settings_service.default_ai_params()
     provider_id = payload.provider_id or defaults.get("provider_id")
     model_id = payload.model_id or defaults.get("model_id")
     temperature = payload.temperature if payload.temperature is not None else defaults.get("temperature", 0.1)
-    nodes = ordered_workflow_nodes(workflow.get("nodes") or [])
-    results: list[dict[str, Any]] = []
-    opened_pids: list[int] = []
+    context = WorkflowContext(
+        workflow_id=workflow_id,
+        provider_id=provider_id,
+        model_id=model_id,
+        temperature=float(temperature),
+        variables=workflow_variables(workflow, payload.variables),
+    )
+    nodes = workflow.get("nodes") or []
+    edges = workflow.get("edges") or []
+    node_map = {node["id"]: node for node in nodes}
+    start_id = first_workflow_node_id(nodes, edges)
+    if not start_id:
+        return {"workflow_id": workflow_id, "status": "SUCCESS", "results": []}
 
-    for node in nodes:
+    results: list[dict[str, Any]] = []
+    current_id: str | None = start_id
+    visited_steps = 0
+    max_steps = int(workflow.get("settings", {}).get("max_steps") or 100)
+
+    while current_id and visited_steps < max_steps:
+        visited_steps += 1
+        node = node_map.get(current_id)
+        if not node:
+            return {"workflow_id": workflow_id, "status": "FAILED", "detail": f"Missing node: {current_id}", "results": results}
         try:
-            result = execute_workflow_node(workflow_id, node, provider_id, model_id, temperature, opened_pids)
-            opened_pids.extend(
-                item["pid"]
-                for item in result.get("new_processes", [])
-                if item.get("pid") and item["pid"] not in opened_pids
-            )
-            results.append({"node": node, "status": "SUCCESS", "result": result})
+            resolved_inputs = resolve_node_inputs(node, edges, context)
+            outputs = execute_workflow_node(node, resolved_inputs, context)
+            context.outputs[node["id"]] = outputs
+            results.append({"node_id": node["id"], "node": node, "status": "SUCCESS", "inputs": resolved_inputs, "outputs": outputs})
+            if node.get("type") == "flow.end":
+                return {"workflow_id": workflow_id, "status": "SUCCESS", "results": results, "outputs": context.outputs}
+            next_port = str(outputs.get("next_port") or "success")
+            current_id = next_control_node_id(node["id"], next_port, edges) or next_control_node_id(node["id"], "next", edges)
         except HTTPException as exc:
-            if not (isinstance(exc.detail, dict) and exc.detail.get("error")):
-                record_error(
-                    action_type=node.get("node_type") or "workflow",
-                    message=str(exc.detail),
-                    screen_id=node.get("screen_id"),
-                    workflow_id=workflow_id,
-                    node_id=node.get("id"),
-                )
-            failure = {"node": node, "status": "FAILED", "detail": exc.detail}
+            failure = {
+                "node_id": node.get("id"),
+                "node": node,
+                "status": "FAILED",
+                "detail": exc.detail,
+                "artifacts": capture_failure_artifacts(context),
+            }
             results.append(failure)
             return {"workflow_id": workflow_id, "status": "FAILED", "failed": failure, "results": results}
-    return {"workflow_id": workflow_id, "status": "SUCCESS", "results": results}
+        except WorkflowPaused as exc:
+            paused = {"node_id": node.get("id"), "node": node, "status": "PAUSED", "detail": exc.payload}
+            results.append(paused)
+            return {"workflow_id": workflow_id, "status": "PAUSED", "paused": paused, "results": results}
+        except Exception as exc:
+            failure = {
+                "node_id": node.get("id"),
+                "node": node,
+                "status": "FAILED",
+                "detail": str(exc),
+                "artifacts": capture_failure_artifacts(context),
+            }
+            results.append(failure)
+            return {"workflow_id": workflow_id, "status": "FAILED", "failed": failure, "results": results}
+    if visited_steps >= max_steps:
+        return {"workflow_id": workflow_id, "status": "FAILED", "detail": f"Workflow exceeded max_steps={max_steps}", "results": results}
+    return {"workflow_id": workflow_id, "status": "SUCCESS", "results": results, "outputs": context.outputs}
 
 
-def ordered_workflow_nodes(nodes: list[dict[str, Any]]) -> list[dict[str, Any]]:
-    """根据节点连线得到执行顺序;没有连线时沿用节点序号。"""
-    if not nodes:
-        return []
-    by_key = {node.get("node_key") or f"node_{node.get('node_index')}": node for node in nodes}
-    targeted = {key for node in nodes for key in node.get("next_node_keys", [])}
-    start_keys = [key for key in by_key if key not in targeted] or [next(iter(by_key))]
-    ordered: list[dict[str, Any]] = []
-    visited: set[str] = set()
-
-    def visit(key: str) -> None:
-        if key in visited or key not in by_key:
-            return
-        visited.add(key)
-        node = by_key[key]
-        ordered.append(node)
-        for next_key in node.get("next_node_keys", []):
-            visit(next_key)
-
-    for key in start_keys:
-        visit(key)
-    for key in by_key:
-        visit(key)
-    return ordered
+def run_workflow_by_key(workflow_key: str, payload: AutomationWorkflowRunRequest) -> dict[str, Any]:
+    workflow = get_workflow_by_key(workflow_key)
+    return run_workflow(int(workflow["id"]), payload)
 
 
 def execute_workflow_node(
-    workflow_id: int,
     node: dict[str, Any],
-    provider_id: int | None,
-    model_id: int | None,
-    temperature: float,
-    opened_pids: list[int],
+    inputs: dict[str, Any],
+    context: WorkflowContext,
 ) -> dict[str, Any]:
-    """把工作流节点配置转换成已有高层动作并执行。"""
-    node_type = node.get("node_type")
-    config = node.get("config") or {}
-    base = {
-        "screen_id": node.get("screen_id"),
-        "provider_id": provider_id,
-        "model_id": model_id,
-        "temperature": temperature,
-        "workflow_id": workflow_id,
-        "node_id": node.get("id"),
+    """通过节点注册表执行 workflow/v1 节点。"""
+    try:
+        executor = get_node_executor(str(node.get("type") or ""))
+    except KeyError as exc:
+        raise HTTPException(status_code=400, detail=str(exc)) from exc
+    return executor(node, inputs, context)
+
+
+def capture_failure_artifacts(context: WorkflowContext) -> dict[str, Any]:
+    """工作流失败时尽量保存一张当前屏幕截图,供前端询问用户。"""
+    artifacts: dict[str, Any] = {}
+    try:
+        screenshot = take_screenshot_file(error_dir(), "workflow_failure")
+    except Exception as exc:
+        artifacts["screenshot_error"] = str(exc)
+        return artifacts
+    artifacts["screenshot_path"] = screenshot.get("db_path") or screenshot.get("path")
+    artifacts["width"] = screenshot.get("width")
+    artifacts["height"] = screenshot.get("height")
+    context.runtime["current_screenshot_path"] = artifacts["screenshot_path"]
+    return artifacts
+
+
+def workflow_to_public(row: dict[str, Any]) -> dict[str, Any]:
+    item = workflow_summary(row)
+    workflow_json = parse_workflow_json(row.get("raw_json"))
+    item.update(workflow_json)
+    item["id"] = row["id"]
+    item["workflow_key"] = row.get("workflow_key") or workflow_json.get("workflow_key")
+    item["created_at"] = row["created_at"]
+    item["updated_at"] = row["updated_at"]
+    item["node_count"] = len(item.get("nodes") or [])
+    item["edge_count"] = len(item.get("edges") or [])
+    return item
+
+
+def workflow_summary(row: dict[str, Any]) -> dict[str, Any]:
+    workflow_json = parse_workflow_json(row.get("raw_json"))
+    return {
+        "id": row["id"],
+        "workflow_key": row.get("workflow_key") or workflow_json.get("workflow_key"),
+        "name": row["name"],
+        "description": row.get("description"),
+        "schema_version": workflow_json.get("schema_version") or "workflow/v1",
+        "node_count": len(workflow_json.get("nodes") or []),
+        "edge_count": len(workflow_json.get("edges") or []),
+        "created_at": row.get("created_at"),
+        "updated_at": row.get("updated_at"),
     }
-    if node_type == "mouse":
-        return execute_mouse_action(
-            AutomationMouseActionRequest(
-                **base,
-                x=int(config.get("x", 0)),
-                y=int(config.get("y", 0)),
-                mouse_action=config.get("mouse_action") or "click",
-            )
-        )
-    if node_type == "keyboard":
-        return execute_keyboard_action(AutomationKeyboardActionRequest(**base, keys=config.get("keys") or []))
-    if node_type == "text_input":
-        return execute_text_input(AutomationTextInputRequest(**base, text=str(config.get("text") or "")))
-    if node_type == "start_program":
-        return execute_start_program(
-            AutomationStartProgramRequest(
-                **base,
-                command=str(config.get("command") or ""),
-                cwd=config.get("cwd"),
-                shell=bool(config.get("shell", True)),
-            )
-        )
-    if node_type == "close_programs":
-        return close_opened_programs(config.get("pids") or opened_pids)
-    raise HTTPException(status_code=400, detail=f"Unsupported workflow node type: {node_type}")
 
 
-def public_node(row: dict[str, Any]) -> dict[str, Any]:
-    """把工作流节点行转换为接口返回格式。"""
-    item = dict(row)
+def parse_workflow_json(raw_json: str | None) -> dict[str, Any]:
+    if not raw_json:
+        return empty_workflow_json()
     try:
-        item["config"] = json.loads(item.pop("config_json") or "{}")
+        parsed = json.loads(raw_json)
     except json.JSONDecodeError:
-        item["config"] = {}
+        return empty_workflow_json()
+    if not isinstance(parsed, dict):
+        return empty_workflow_json()
+    parsed.setdefault("schema_version", "workflow/v1")
+    parsed.setdefault("variables", {})
+    parsed.setdefault("settings", {})
+    parsed.setdefault("nodes", [])
+    parsed.setdefault("edges", [])
+    return parsed
+
+
+def empty_workflow_json() -> dict[str, Any]:
+    return {"schema_version": "workflow/v1", "variables": {}, "settings": {}, "nodes": [], "edges": []}
+
+
+def workflow_variables(workflow: dict[str, Any], overrides: dict[str, Any]) -> dict[str, Any]:
+    variables: dict[str, Any] = {}
+    for name, definition in (workflow.get("variables") or {}).items():
+        if isinstance(definition, dict):
+            variables[name] = definition.get("default")
+        else:
+            variables[name] = definition
+    variables.update(overrides or {})
+    return variables
+
+
+def first_workflow_node_id(nodes: list[dict[str, Any]], edges: list[dict[str, Any]]) -> str | None:
+    if not nodes:
+        return None
+    for node in nodes:
+        if node.get("type") == "flow.start":
+            return node.get("id")
+    targeted = {edge.get("target") for edge in edges if edge.get("kind") == "control"}
+    for node in nodes:
+        if node.get("id") not in targeted:
+            return node.get("id")
+    return nodes[0].get("id")
+
+
+def next_control_node_id(source_id: str, source_port: str, edges: list[dict[str, Any]]) -> str | None:
+    fallback = None
+    for edge in edges:
+        if edge.get("kind") != "control" or edge.get("source") != source_id:
+            continue
+        if edge.get("source_port") == source_port:
+            return edge.get("target")
+        if edge.get("source_port") in (None, "", "success", "next") and fallback is None:
+            fallback = edge.get("target")
+    return fallback
+
+
+def resolve_node_inputs(node: dict[str, Any], edges: list[dict[str, Any]], context: WorkflowContext) -> dict[str, Any]:
+    resolved: dict[str, Any] = {}
+    for key, value in (node.get("inputs") or {}).items():
+        resolved[key] = resolve_value_ref(value, context)
+    for edge in edges:
+        if edge.get("kind") != "data" or edge.get("target") != node.get("id"):
+            continue
+        source_outputs = context.outputs.get(edge.get("source") or "", {})
+        resolved[edge.get("target_port") or "value"] = source_outputs.get(edge.get("source_port") or "value")
+    return resolved
+
+
+def resolve_value_ref(value: Any, context: WorkflowContext) -> Any:
+    if not isinstance(value, dict) or "source" not in value:
+        return value
+    source = value.get("source")
+    if source == "literal":
+        return value.get("value")
+    if source == "variable":
+        return context.variables.get(value.get("name") or "")
+    if source == "node_output":
+        return context.outputs.get(value.get("node_id") or "", {}).get(value.get("output") or "")
+    if source == "runtime":
+        return context.runtime.get(value.get("name") or "")
+    return None
+
+
+def list_workflow_node_definitions() -> dict[str, Any]:
+    """返回前端可用于生成节点库和属性表单的节点定义。"""
+    return {"schema_version": "workflow/v1", "items": get_node_definitions()}
+
+
+def plan_workflow(payload: AutomationWorkflowPlanRequest) -> dict[str, Any]:
+    """让 AI 根据用户需求和节点定义生成 workflow/v1 草稿。"""
+    provider_id, model_id, temperature = resolve_ai_params(payload.provider_id, payload.model_id, payload.temperature)
+    prompt = build_workflow_plan_prompt(payload.requirement)
+    ai_result = ai_service.chat(provider_id, model_id, prompt, temperature)
     try:
-        item["next_node_keys"] = json.loads(item.get("next_node_keys") or "[]")
-    except json.JSONDecodeError:
-        item["next_node_keys"] = []
-    return item
+        parsed = json_from_ai(ai_result["content"])
+    except (json.JSONDecodeError, ValueError) as exc:
+        raise HTTPException(status_code=502, detail=f"AI workflow plan output is not valid JSON: {exc}") from exc
+    session_id = str(uuid.uuid4())
+    return {"session_id": session_id, "plan": parsed, "ai_raw_content": ai_result["content"]}
+
+
+def continue_workflow_plan(payload: AutomationWorkflowPlanContinueRequest) -> dict[str, Any]:
+    """继续一次 AI 工作流规划对话,返回新的草稿建议。"""
+    provider_id, model_id, temperature = resolve_ai_params(payload.provider_id, payload.model_id, payload.temperature)
+    prompt = build_workflow_plan_prompt(payload.user_message, session_id=payload.session_id)
+    ai_result = ai_service.chat(provider_id, model_id, prompt, temperature)
+    try:
+        parsed = json_from_ai(ai_result["content"])
+    except (json.JSONDecodeError, ValueError) as exc:
+        raise HTTPException(status_code=502, detail=f"AI workflow plan output is not valid JSON: {exc}") from exc
+    return {"session_id": payload.session_id, "plan": parsed, "ai_raw_content": ai_result["content"]}
+
+
+def build_workflow_plan_prompt(requirement: str, session_id: str | None = None) -> str:
+    node_defs = json.dumps(get_node_definitions(), ensure_ascii=False, indent=2)
+    return f"""请作为 Windows 自动化工作流规划器,根据用户需求生成 workflow/v1 JSON 草稿。
+
+要求:
+1. 只能使用节点定义列表中的 type。
+2. 输出严格 JSON 对象,不要 Markdown。
+3. JSON 字段必须包含 summary、questions、workflow。
+4. workflow 必须包含 schema_version、name、description、variables、settings、nodes、edges。
+5. 不确定的坐标或界面状态,优先添加 human.ask_user 节点或 screen.screenshot 节点。
+6. 控制流连线 kind 使用 control,数据连线 kind 使用 data。
+
+会话 ID:{session_id or "new"}
+
+用户需求:
+{requirement}
+
+可用节点定义:
+{node_defs}
+"""
 
 
 def list_errors(page: int, page_size: int) -> dict[str, Any]:

+ 10 - 0
backend/app/database.py

@@ -192,6 +192,7 @@ def init_db() -> None:
 
             CREATE TABLE IF NOT EXISTS automation_workflows (
                 id INTEGER PRIMARY KEY AUTOINCREMENT,
+                workflow_key TEXT UNIQUE,
                 name TEXT NOT NULL,
                 description TEXT,
                 raw_json TEXT,
@@ -241,6 +242,14 @@ def init_db() -> None:
                 ON automation_errors(created_at DESC);
             """
         )
+        ensure_column(conn, "automation_workflows", "workflow_key", "TEXT")
+        conn.execute(
+            """
+            CREATE UNIQUE INDEX IF NOT EXISTS idx_automation_workflows_key
+                ON automation_workflows(workflow_key)
+                WHERE workflow_key IS NOT NULL AND workflow_key != ''
+            """
+        )
         ensure_column(conn, "automation_workflow_nodes", "node_key", "TEXT")
         ensure_column(conn, "automation_workflow_nodes", "position_x", "REAL NOT NULL DEFAULT 80")
         ensure_column(conn, "automation_workflow_nodes", "position_y", "REAL NOT NULL DEFAULT 80")
@@ -298,6 +307,7 @@ def seed_default_settings(conn: sqlite3.Connection) -> None:
         ("automation_runtime_path", "automation/runtime", "自动化临时截图保存路径"),
         ("automation_auto_screenshot_enabled", "0", "自动化操作页面是否默认自动截屏"),
         ("automation_auto_screenshot_interval", "30", "自动化操作页面默认自动截屏间隔秒数"),
+        ("automation_remote_token", "", "远程执行工作流接口 Token,设置后按 key 执行接口必须携带 X-Automation-Token"),
     ]
     now = __import__("datetime").datetime.now().astimezone().isoformat(timespec="seconds")
     for key, value, description in settings:

+ 42 - 1
backend/app/main.py

@@ -1,10 +1,11 @@
 from __future__ import annotations
 
 import json
+import secrets
 import sqlite3
 from typing import Any
 
-from fastapi import FastAPI, HTTPException, Query
+from fastapi import FastAPI, Header, HTTPException, Query
 from fastapi.middleware.cors import CORSMiddleware
 
 from . import ai_service, automation_service, settings_service, windows_automation
@@ -43,6 +44,8 @@ from .schemas import (
     AutomationVisionAnalyzeRequest,
     AutomationWorkflowRunRequest,
     AutomationWorkflowSaveRequest,
+    AutomationWorkflowPlanRequest,
+    AutomationWorkflowPlanContinueRequest,
     BatchStatusUpdate,
     PromptRequest,
     StatusUpdate,
@@ -112,6 +115,14 @@ app.add_middleware(
 )
 
 
+def verify_automation_token(x_automation_token: str | None = Header(default=None)) -> None:
+    configured = settings_service.automation_remote_token()
+    if not configured:
+        raise HTTPException(status_code=403, detail="Automation remote token is not configured")
+    if not x_automation_token or not secrets.compare_digest(x_automation_token, configured):
+        raise HTTPException(status_code=401, detail="Invalid automation token")
+
+
 @app.on_event("startup")
 def startup() -> None:
     init_db()
@@ -793,6 +804,21 @@ def automation_workflows(page: int = Query(default=1, ge=1), page_size: int = Qu
     return automation_service.list_workflows(page, page_size)
 
 
+@app.get("/api/automation/workflow-nodes")
+def automation_workflow_nodes() -> dict[str, Any]:
+    return automation_service.list_workflow_node_definitions()
+
+
+@app.post("/api/automation/workflows/plan")
+def automation_workflow_plan(payload: AutomationWorkflowPlanRequest) -> dict[str, Any]:
+    return automation_service.plan_workflow(payload)
+
+
+@app.post("/api/automation/workflows/plan/continue")
+def automation_workflow_plan_continue(payload: AutomationWorkflowPlanContinueRequest) -> dict[str, Any]:
+    return automation_service.continue_workflow_plan(payload)
+
+
 @app.post("/api/automation/workflows")
 def automation_workflow_create(payload: AutomationWorkflowSaveRequest) -> dict[str, Any]:
     return automation_service.save_workflow(payload)
@@ -803,11 +829,26 @@ def automation_workflow_detail(workflow_id: int) -> dict[str, Any]:
     return automation_service.get_workflow(workflow_id)
 
 
+@app.get("/api/automation/workflows/by-key/{workflow_key}")
+def automation_workflow_detail_by_key(workflow_key: str) -> dict[str, Any]:
+    return automation_service.get_workflow_by_key(workflow_key)
+
+
 @app.post("/api/automation/workflows/{workflow_id}/run")
 def automation_workflow_run(workflow_id: int, payload: AutomationWorkflowRunRequest) -> dict[str, Any]:
     return automation_service.run_workflow(workflow_id, payload)
 
 
+@app.post("/api/automation/workflows/by-key/{workflow_key}/run")
+def automation_workflow_run_by_key(
+    workflow_key: str,
+    payload: AutomationWorkflowRunRequest,
+    x_automation_token: str | None = Header(default=None),
+) -> dict[str, Any]:
+    verify_automation_token(x_automation_token)
+    return automation_service.run_workflow_by_key(workflow_key, payload)
+
+
 @app.put("/api/automation/workflows/{workflow_id}")
 def automation_workflow_update(workflow_id: int, payload: AutomationWorkflowSaveRequest) -> dict[str, Any]:
     return automation_service.update_workflow(workflow_id, payload)

+ 42 - 9
backend/app/schemas.py

@@ -10,7 +10,7 @@ ItemType = Literal["service", "process"]
 AiProviderType = Literal["OPENAI", "OPENAI_COMPATIBLE", "GOOGLE_GEMINI"]
 MouseAutomationAction = Literal["move_to", "move_rel", "click", "double_click", "right_click", "drag_to", "scroll"]
 KeyboardAutomationAction = Literal["press", "hotkey", "write", "key_down", "key_up"]
-AutomationNodeType = Literal["mouse", "keyboard", "text_input", "start_program", "close_programs"]
+AutomationNodeType = str
 
 
 class StatusUpdate(BaseModel):
@@ -173,27 +173,59 @@ class AutomationCloseProgramsRequest(BaseModel):
     pids: list[int] | None = None
 
 
+class AutomationWorkflowPosition(BaseModel):
+    x: float = 80
+    y: float = 80
+
+
+class AutomationWorkflowValueRef(BaseModel):
+    source: Literal["literal", "variable", "node_output", "runtime"]
+    value: Any = None
+    name: str | None = None
+    node_id: str | None = None
+    output: str | None = None
+
+
 class AutomationWorkflowNode(BaseModel):
-    node_key: str | None = None
-    node_type: AutomationNodeType
-    screen_id: int | None = None
-    title: str | None = None
-    position_x: float = 80
-    position_y: float = 80
-    next_node_keys: list[str] = Field(default_factory=list)
-    config: dict[str, Any] = Field(default_factory=dict)
+    id: str = Field(min_length=1, max_length=120)
+    type: AutomationNodeType = Field(min_length=1, max_length=120)
+    title: str | None = Field(default=None, max_length=160)
+    position: AutomationWorkflowPosition = Field(default_factory=AutomationWorkflowPosition)
+    params: dict[str, Any] = Field(default_factory=dict)
+    inputs: dict[str, AutomationWorkflowValueRef | Any] = Field(default_factory=dict)
 
 
 class AutomationWorkflowSaveRequest(BaseModel):
+    schema_version: Literal["workflow/v1"] = "workflow/v1"
+    workflow_key: str | None = Field(default=None, min_length=1, max_length=80, pattern=r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$")
     name: str = Field(min_length=1, max_length=160)
     description: str | None = None
+    variables: dict[str, Any] = Field(default_factory=dict)
+    settings: dict[str, Any] = Field(default_factory=dict)
     nodes: list[AutomationWorkflowNode] = Field(default_factory=list)
+    edges: list[dict[str, Any]] = Field(default_factory=list)
 
 
 class AutomationWorkflowRunRequest(BaseModel):
     provider_id: int | None = None
     model_id: int | None = None
     temperature: float | None = Field(default=None, ge=0, le=2)
+    variables: dict[str, Any] = Field(default_factory=dict)
+
+
+class AutomationWorkflowPlanRequest(BaseModel):
+    requirement: str = Field(min_length=1, max_length=4000)
+    provider_id: int | None = None
+    model_id: int | None = None
+    temperature: float | None = Field(default=None, ge=0, le=2)
+
+
+class AutomationWorkflowPlanContinueRequest(BaseModel):
+    session_id: str = Field(min_length=1)
+    user_message: str = Field(min_length=1, max_length=4000)
+    provider_id: int | None = None
+    model_id: int | None = None
+    temperature: float | None = Field(default=None, ge=0, le=2)
 
 
 class SystemSettingsUpdate(BaseModel):
@@ -206,6 +238,7 @@ class SystemSettingsUpdate(BaseModel):
     automation_runtime_path: str | None = None
     automation_auto_screenshot_enabled: bool | None = None
     automation_auto_screenshot_interval: int | None = Field(default=None, ge=1, le=3600)
+    automation_remote_token: str | None = Field(default=None, max_length=256)
 
 
 class AiProviderCreate(BaseModel):

+ 6 - 0
backend/app/settings_service.py

@@ -20,6 +20,7 @@ SETTING_KEYS = {
     "automation_runtime_path",
     "automation_auto_screenshot_enabled",
     "automation_auto_screenshot_interval",
+    "automation_remote_token",
 }
 
 
@@ -61,9 +62,14 @@ def normalize_settings(values: dict[str, str | None]) -> dict[str, Any]:
         "automation_runtime_path": values.get("automation_runtime_path") or "automation/runtime",
         "automation_auto_screenshot_enabled": parse_bool(values.get("automation_auto_screenshot_enabled")),
         "automation_auto_screenshot_interval": optional_int(values.get("automation_auto_screenshot_interval"), 30),
+        "automation_remote_token": values.get("automation_remote_token") or "",
     }
 
 
+def automation_remote_token() -> str:
+    return str(get_settings_dict().get("automation_remote_token") or "").strip()
+
+
 def get_settings_dict() -> dict[str, Any]:
     return list_settings()["settings"]
 

+ 29 - 0
backend/app/windows_automation.py

@@ -4,6 +4,7 @@ import base64
 import locale
 import os
 import subprocess
+import webbrowser
 from pathlib import Path
 from typing import Any, Literal
 
@@ -140,6 +141,34 @@ def start_program(command: str, cwd: str | None = None, shell: bool = True) -> d
     return {"action": "start_program", "pid": proc.pid, "command": command, "cwd": working_dir}
 
 
+def open_url(url: str, browser: str | None = None, new_window: bool = True) -> dict[str, Any]:
+    """打开网页 URL。优先使用 Windows shell/浏览器注册表,避免 shell 命令静默失败。"""
+    target = str(url).strip()
+    if not target:
+        raise HTTPException(status_code=400, detail="url is required")
+
+    browser_name = (browser or "").strip().lower()
+    if browser_name in {"edge", "msedge"}:
+        args = ["cmd.exe", "/c", "start", "", "msedge"]
+        if new_window:
+            args.append("--new-window")
+        args.append(target)
+        proc = subprocess.Popen(args, creationflags=hidden_creationflags())
+        return {"action": "open_url", "browser": "msedge", "url": target, "pid": proc.pid}
+
+    if os.name == "nt":
+        try:
+            os.startfile(target)  # type: ignore[attr-defined]
+            return {"action": "open_url", "browser": "default", "url": target}
+        except OSError as exc:
+            raise HTTPException(status_code=500, detail=str(exc)) from exc
+
+    opened = webbrowser.open(target, new=1 if new_window else 0)
+    if not opened:
+        raise HTTPException(status_code=500, detail="Failed to open url")
+    return {"action": "open_url", "browser": "default", "url": target}
+
+
 def stop_program(pid: int | None = None, name: str | None = None, timeout_seconds: float = 8, kill_after_timeout: bool = True) -> dict[str, Any]:
     """按 PID 或进程名关闭程序;优先温和终止,超时后可选择强制结束。"""
     processes = find_processes(pid=pid, name=name)

+ 232 - 0
frontend/package-lock.json

@@ -6,6 +6,9 @@
     "": {
       "dependencies": {
         "@vitejs/plugin-vue": "^5.1.4",
+        "@vue-flow/background": "^1.3.2",
+        "@vue-flow/controls": "^1.1.3",
+        "@vue-flow/core": "^1.48.2",
         "axios": "^1.7.7",
         "element-plus": "^2.8.4",
         "vite": "^5.4.10",
@@ -852,6 +855,130 @@
         "vue": "^3.2.25"
       }
     },
+    "node_modules/@vue-flow/background": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/@vue-flow/background/-/background-1.3.2.tgz",
+      "integrity": "sha512-eJPhDcLj1wEo45bBoqTXw1uhl0yK2RaQGnEINqvvBsAFKh/camHJd5NPmOdS1w+M9lggc9igUewxaEd3iCQX2w==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@vue-flow/core": "^1.23.0",
+        "vue": "^3.3.0"
+      }
+    },
+    "node_modules/@vue-flow/controls": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/@vue-flow/controls/-/controls-1.1.3.tgz",
+      "integrity": "sha512-XCf+G+jCvaWURdFlZmOjifZGw3XMhN5hHlfMGkWh9xot+9nH9gdTZtn+ldIJKtarg3B21iyHU8JjKDhYcB6JMw==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@vue-flow/core": "^1.23.0",
+        "vue": "^3.3.0"
+      }
+    },
+    "node_modules/@vue-flow/core": {
+      "version": "1.48.2",
+      "resolved": "https://registry.npmjs.org/@vue-flow/core/-/core-1.48.2.tgz",
+      "integrity": "sha512-raxhgKWE+G/mcEvXJjGFUDYW9rAI3GOtiHR3ZkNpwBWuIaCC1EYiBmKGwJOoNzVFgwO7COgErnK7i08i287AFA==",
+      "license": "MIT",
+      "dependencies": {
+        "@vueuse/core": "^10.5.0",
+        "d3-drag": "^3.0.0",
+        "d3-interpolate": "^3.0.1",
+        "d3-selection": "^3.0.0",
+        "d3-zoom": "^3.0.0"
+      },
+      "peerDependencies": {
+        "vue": "^3.3.0"
+      }
+    },
+    "node_modules/@vue-flow/core/node_modules/@vueuse/core": {
+      "version": "10.11.1",
+      "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.11.1.tgz",
+      "integrity": "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/web-bluetooth": "^0.0.20",
+        "@vueuse/metadata": "10.11.1",
+        "@vueuse/shared": "10.11.1",
+        "vue-demi": ">=0.14.8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      }
+    },
+    "node_modules/@vue-flow/core/node_modules/@vueuse/core/node_modules/vue-demi": {
+      "version": "0.14.10",
+      "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
+      "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "vue-demi-fix": "bin/vue-demi-fix.js",
+        "vue-demi-switch": "bin/vue-demi-switch.js"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      },
+      "peerDependencies": {
+        "@vue/composition-api": "^1.0.0-rc.1",
+        "vue": "^3.0.0-0 || ^2.6.0"
+      },
+      "peerDependenciesMeta": {
+        "@vue/composition-api": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@vue-flow/core/node_modules/@vueuse/metadata": {
+      "version": "10.11.1",
+      "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.11.1.tgz",
+      "integrity": "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      }
+    },
+    "node_modules/@vue-flow/core/node_modules/@vueuse/shared": {
+      "version": "10.11.1",
+      "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.11.1.tgz",
+      "integrity": "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==",
+      "license": "MIT",
+      "dependencies": {
+        "vue-demi": ">=0.14.8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      }
+    },
+    "node_modules/@vue-flow/core/node_modules/@vueuse/shared/node_modules/vue-demi": {
+      "version": "0.14.10",
+      "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
+      "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "vue-demi-fix": "bin/vue-demi-fix.js",
+        "vue-demi-switch": "bin/vue-demi-switch.js"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      },
+      "peerDependencies": {
+        "@vue/composition-api": "^1.0.0-rc.1",
+        "vue": "^3.0.0-0 || ^2.6.0"
+      },
+      "peerDependenciesMeta": {
+        "@vue/composition-api": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/@vue/compiler-core": {
       "version": "3.5.33",
       "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.33.tgz",
@@ -1042,6 +1169,111 @@
       "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
       "license": "MIT"
     },
+    "node_modules/d3-color": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+      "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-dispatch": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
+      "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-drag": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
+      "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-dispatch": "1 - 3",
+        "d3-selection": "3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-ease": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
+      "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-interpolate": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+      "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-color": "1 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-selection": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
+      "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-timer": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
+      "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-transition": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
+      "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-color": "1 - 3",
+        "d3-dispatch": "1 - 3",
+        "d3-ease": "1 - 3",
+        "d3-interpolate": "1 - 3",
+        "d3-timer": "1 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "peerDependencies": {
+        "d3-selection": "2 - 3"
+      }
+    },
+    "node_modules/d3-zoom": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
+      "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-dispatch": "1 - 3",
+        "d3-drag": "2 - 3",
+        "d3-interpolate": "1 - 3",
+        "d3-selection": "2 - 3",
+        "d3-transition": "2 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
     "node_modules/dayjs": {
       "version": "1.11.20",
       "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz",

+ 6 - 4
frontend/package.json

@@ -6,10 +6,12 @@
   },
   "dependencies": {
     "@vitejs/plugin-vue": "^5.1.4",
+    "@vue-flow/background": "^1.3.2",
+    "@vue-flow/controls": "^1.1.3",
+    "@vue-flow/core": "^1.48.2",
     "axios": "^1.7.7",
     "element-plus": "^2.8.4",
-    "vue": "^3.5.12",
-    "vite": "^5.4.10"
-  },
-  "devDependencies": {}
+    "vite": "^5.4.10",
+    "vue": "^3.5.12"
+  }
 }

+ 29 - 4
frontend/src/App.vue

@@ -1,6 +1,6 @@
 <template>
   <div class="app-shell">
-    <aside class="sidebar">
+    <aside v-if="activeView !== 'automation-workflow-editor'" class="sidebar">
       <div class="brand">Windows 监控</div>
       <el-menu :default-active="activeView" background-color="#1f2937" text-color="#d1d5db" active-text-color="#fff" @select="activeView = $event">
         <el-menu-item index="dashboard">仪表盘</el-menu-item>
@@ -28,8 +28,8 @@
       </el-menu>
     </aside>
 
-    <main class="main">
-      <div class="topbar">
+    <main class="main" :class="{ 'main-fullscreen': activeView === 'automation-workflow-editor' }">
+      <div v-if="activeView !== 'automation-workflow-editor'" class="topbar">
         <div>
           <div class="page-title">{{ title }}</div>
           <div class="muted">{{ subtitle }}</div>
@@ -99,7 +99,16 @@
       </section>
 
       <section v-if="activeView === 'automation-workflows'">
-        <AutomationWorkflowView ref="automationWorkflowView" />
+        <AutomationWorkflowView ref="automationWorkflowView" @create="openWorkflowEditor(null)" @edit="openWorkflowEditor" />
+      </section>
+
+      <section v-if="activeView === 'automation-workflow-editor'">
+        <AutomationWorkflowEditorPage
+          :key="workflowEditorKey"
+          :workflow-id="editingWorkflowId"
+          @back="closeWorkflowEditor"
+          @saved="editingWorkflowId = $event"
+        />
       </section>
 
       <section v-if="activeView === 'automation-screens'">
@@ -144,6 +153,7 @@ import AiProviderManager from './components/AiProviderManager.vue'
 import AiTestView from './components/AiTestView.vue'
 import AutomationActionView from './components/AutomationActionView.vue'
 import AutomationErrorsView from './components/AutomationErrorsView.vue'
+import AutomationWorkflowEditorPage from './components/AutomationWorkflowEditorPage.vue'
 import AutomationScreensView from './components/AutomationScreensView.vue'
 import AutomationWorkflowView from './components/AutomationWorkflowView.vue'
 import ItemTable from './components/ItemTable.vue'
@@ -169,6 +179,8 @@ const automationActionView = ref(null)
 const automationWorkflowView = ref(null)
 const automationScreensView = ref(null)
 const automationErrorsView = ref(null)
+const editingWorkflowId = ref(null)
+const workflowEditorKey = ref(0)
 
 const title = computed(() => ({
   dashboard: '仪表盘',
@@ -182,6 +194,7 @@ const title = computed(() => ({
   'ai-test': 'AI 服务测试',
   'automation-actions': '自动化操作',
   'automation-workflows': '自动化工作流',
+  'automation-workflow-editor': '工作流编辑器',
   'automation-screens': '已识别界面',
   'automation-errors': '自动化错误记录',
   sensors: '传感器信息',
@@ -224,6 +237,18 @@ async function refreshCurrent() {
   automationErrorsView.value?.load()
 }
 
+function openWorkflowEditor(id) {
+  editingWorkflowId.value = id
+  workflowEditorKey.value += 1
+  activeView.value = 'automation-workflow-editor'
+}
+
+async function closeWorkflowEditor() {
+  activeView.value = 'automation-workflows'
+  await nextTick()
+  await automationWorkflowView.value?.load()
+}
+
 async function runScan() {
   scanning.value = true
   try {

+ 467 - 0
frontend/src/components/AutomationWorkflowEditorPage.vue

@@ -0,0 +1,467 @@
+<template>
+  <div class="workflow-editor-page">
+    <header class="workflow-editor-topbar">
+      <div class="workflow-editor-title">
+        <el-button @click="$emit('back')">返回</el-button>
+        <el-input v-model="workflowName" placeholder="工作流名称" class="workflow-name-input" />
+        <el-input v-model="workflowKey" placeholder="workflow key" class="workflow-key-input" />
+        <el-input v-model="workflowDescription" placeholder="描述" class="workflow-desc-input" />
+      </div>
+      <div class="filters">
+        <el-button type="primary" :loading="saving" @click="save">保存</el-button>
+        <el-button type="success" :disabled="!workflowId" :loading="running" @click="runWorkflow">执行</el-button>
+        <el-button @click="fitView()">适配画布</el-button>
+      </div>
+    </header>
+
+    <div class="workflow-editor-body">
+      <aside class="workflow-node-palette">
+        <div class="section-title">节点库</div>
+        <el-collapse v-model="openedCategories">
+          <el-collapse-item v-for="group in groupedNodeDefinitions" :key="group.category" :title="categoryLabel(group.category)" :name="group.category">
+            <button v-for="definition in group.items" :key="definition.type" class="palette-node" type="button" @click="addNode(definition)">
+              <span>{{ definition.label }}</span>
+              <small>{{ definition.type }}</small>
+            </button>
+          </el-collapse-item>
+        </el-collapse>
+      </aside>
+
+      <main class="workflow-flow-wrap">
+        <VueFlow
+          v-model:nodes="flowNodes"
+          v-model:edges="flowEdges"
+          class="workflow-flow"
+          :default-viewport="{ zoom: 0.9 }"
+          :min-zoom="0.2"
+          :max-zoom="2"
+          fit-view-on-init
+          @connect="onConnect"
+          @node-click="onNodeClick"
+          @pane-click="selectedNodeId = null"
+        >
+          <Background pattern-color="#cbd5e1" :gap="24" />
+          <Controls />
+        </VueFlow>
+      </main>
+
+      <aside class="workflow-inspector-panel">
+        <div class="section-title">工作流变量</div>
+        <div class="workflow-variable-list">
+          <div v-for="(variable, name) in workflowVariables" :key="name" class="workflow-variable-row">
+            <div class="workflow-variable-head">
+              <strong>{{ name }}</strong>
+              <el-button size="small" type="danger" @click="deleteVariable(name)">删除</el-button>
+            </div>
+            <div class="workflow-variable-fields">
+              <el-select v-model="ensureVariableObject(name).type" size="small">
+                <el-option label="文本" value="string" />
+                <el-option label="数字" value="number" />
+                <el-option label="布尔" value="boolean" />
+              </el-select>
+              <component
+                :is="variableDefaultComponent(ensureVariableObject(name))"
+                v-model="ensureVariableObject(name).default"
+                v-bind="variableDefaultProps(ensureVariableObject(name))"
+                placeholder="默认值"
+              />
+            </div>
+            <el-input v-model="ensureVariableObject(name).description" size="small" placeholder="说明" />
+          </div>
+          <el-button class="add-variable-button" @click="addVariable">新增变量</el-button>
+        </div>
+
+        <template v-if="selectedNode">
+          <div class="section-title">节点属性</div>
+          <el-form label-width="86px" class="workflow-inspector-form">
+            <el-form-item label="标题">
+              <el-input v-model="selectedNode.data.title" @input="syncNodeLabel" />
+            </el-form-item>
+            <el-form-item label="类型">
+              <el-tag>{{ selectedNode.data.nodeType }}</el-tag>
+            </el-form-item>
+          </el-form>
+
+          <div class="section-title">参数</div>
+          <el-form label-width="92px" class="workflow-inspector-form">
+            <el-form-item v-for="(field, key) in selectedDefinition?.params || {}" :key="key" :label="field.label || key">
+              <component
+                :is="fieldComponent(field)"
+                v-model="selectedNode.data.params[key]"
+                v-bind="fieldProps(field)"
+                class="workflow-field"
+              >
+                <el-option v-for="option in field.options || []" :key="option" :label="option" :value="option" />
+              </component>
+            </el-form-item>
+            <div v-if="!Object.keys(selectedDefinition?.params || {}).length" class="muted">此节点没有固定参数。</div>
+          </el-form>
+
+          <div class="section-title">输入</div>
+          <div v-for="(field, key) in selectedDefinition?.inputs || {}" :key="key" class="input-binding-row">
+            <div class="input-binding-name">{{ field.label || key }}</div>
+            <el-select v-model="inputBinding(key).source" class="input-source">
+              <el-option label="固定值" value="literal" />
+              <el-option label="变量" value="variable" />
+              <el-option label="节点输出" value="node_output" />
+              <el-option label="运行时" value="runtime" />
+            </el-select>
+            <el-input v-if="inputBinding(key).source === 'literal'" v-model="inputBinding(key).value" placeholder="值" />
+            <el-input v-if="inputBinding(key).source === 'variable'" v-model="inputBinding(key).name" placeholder="变量名" />
+            <template v-if="inputBinding(key).source === 'node_output'">
+              <el-select v-model="inputBinding(key).node_id" placeholder="节点">
+                <el-option v-for="node in flowNodes.filter((item) => item.id !== selectedNode.id)" :key="node.id" :label="node.data.title" :value="node.id" />
+              </el-select>
+              <el-input v-model="inputBinding(key).output" placeholder="输出名" />
+            </template>
+            <el-input v-if="inputBinding(key).source === 'runtime'" v-model="inputBinding(key).name" placeholder="运行时键名" />
+          </div>
+          <div v-if="!Object.keys(selectedDefinition?.inputs || {}).length" class="muted">此节点不需要输入。</div>
+
+          <div class="section-title">连线</div>
+          <el-table :data="selectedEdges" size="small" border>
+            <el-table-column prop="label" label="连线" min-width="130" />
+            <el-table-column label="类型" width="100">
+              <template #default="{ row }">
+                <el-select v-model="row.data.kind" size="small" @change="syncEdgeLabel(row)">
+                  <el-option label="控制流" value="control" />
+                  <el-option label="数据流" value="data" />
+                </el-select>
+              </template>
+            </el-table-column>
+            <el-table-column label="操作" width="74">
+              <template #default="{ row }">
+                <el-button size="small" type="danger" @click="deleteEdge(row.id)">删</el-button>
+              </template>
+            </el-table-column>
+          </el-table>
+
+          <div class="filters inspector-actions">
+            <el-button type="danger" @click="deleteSelectedNode">删除节点</el-button>
+          </div>
+        </template>
+        <div v-else class="muted">从左侧添加节点,或点击画布中的节点编辑参数和输入绑定。</div>
+
+        <div class="section-title">执行结果</div>
+        <pre class="workflow-run-output">{{ runOutput || '暂无执行结果' }}</pre>
+      </aside>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { computed, nextTick, onMounted, ref } from 'vue'
+import { VueFlow, useVueFlow } from '@vue-flow/core'
+import { Background } from '@vue-flow/background'
+import { Controls } from '@vue-flow/controls'
+import '@vue-flow/core/dist/style.css'
+import '@vue-flow/core/dist/theme-default.css'
+import '@vue-flow/controls/dist/style.css'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { api } from '../api'
+
+const props = defineProps({
+  workflowId: { type: Number, default: null },
+})
+
+const emit = defineEmits(['back', 'saved'])
+const { fitView } = useVueFlow()
+
+const nodeDefinitions = ref([])
+const flowNodes = ref([])
+const flowEdges = ref([])
+const workflowName = ref('')
+const workflowKey = ref('')
+const workflowDescription = ref('')
+const workflowVariables = ref({})
+const workflowSettings = ref({ max_steps: 100, default_timeout_ms: 30000, on_unhandled_error: 'pause_for_user' })
+const workflowId = ref(props.workflowId)
+const selectedNodeId = ref(null)
+const openedCategories = ref(['flow', 'mouse', 'keyboard'])
+const saving = ref(false)
+const running = ref(false)
+const runOutput = ref('')
+
+const selectedNode = computed(() => flowNodes.value.find((node) => node.id === selectedNodeId.value))
+const selectedDefinition = computed(() => nodeDefinitions.value.find((item) => item.type === selectedNode.value?.data?.nodeType))
+const selectedEdges = computed(() => flowEdges.value.filter((edge) => edge.source === selectedNodeId.value || edge.target === selectedNodeId.value))
+const groupedNodeDefinitions = computed(() => {
+  const groups = new Map()
+  for (const item of nodeDefinitions.value) {
+    if (!groups.has(item.category)) groups.set(item.category, [])
+    groups.get(item.category).push(item)
+  }
+  return [...groups.entries()].map(([category, items]) => ({ category, items }))
+})
+
+function categoryLabel(category) {
+  return {
+    flow: '流程',
+    mouse: '鼠标',
+    keyboard: '键盘',
+    text: '文本',
+    program: '程序',
+    screen: '屏幕',
+    wait: '等待',
+    human: '人工交互',
+  }[category] || category
+}
+
+async function loadDefinitions() {
+  const { data } = await api.get('/api/automation/workflow-nodes')
+  nodeDefinitions.value = data.items || []
+}
+
+async function loadWorkflow() {
+  if (!workflowId.value) {
+    workflowName.value = `新工作流 ${new Date().toLocaleString()}`
+    workflowKey.value = ''
+    workflowDescription.value = ''
+    workflowVariables.value = {}
+    workflowSettings.value = { max_steps: 100, default_timeout_ms: 30000, on_unhandled_error: 'pause_for_user' }
+    flowNodes.value = []
+    flowEdges.value = []
+    return
+  }
+  const { data } = await api.get(`/api/automation/workflows/${workflowId.value}`)
+  workflowName.value = data.name
+  workflowKey.value = data.workflow_key || ''
+  workflowDescription.value = data.description || ''
+  workflowVariables.value = data.variables || {}
+  workflowSettings.value = data.settings || {}
+  flowNodes.value = (data.nodes || []).map(workflowNodeToFlow)
+  flowEdges.value = (data.edges || []).map(workflowEdgeToFlow)
+}
+
+function workflowNodeToFlow(node) {
+  return {
+    id: node.id,
+    type: 'default',
+    label: node.title || node.type,
+    position: node.position || { x: 80, y: 80 },
+    data: {
+      title: node.title || node.type,
+      nodeType: node.type,
+      params: structuredClone(node.params || {}),
+      inputs: structuredClone(node.inputs || {}),
+    },
+  }
+}
+
+function workflowEdgeToFlow(edge) {
+  const kind = edge.kind || 'control'
+  return {
+    id: edge.id || `${edge.source}-${edge.target}-${Date.now()}`,
+    source: edge.source,
+    target: edge.target,
+    sourceHandle: edge.source_port || null,
+    targetHandle: edge.target_port || null,
+    label: edgeLabel(kind),
+    data: { kind },
+    animated: kind === 'control',
+    style: { stroke: kind === 'data' ? '#10b981' : '#2563eb' },
+  }
+}
+
+function addNode(definition) {
+  const index = flowNodes.value.length
+  const node = {
+    id: `node_${Date.now()}_${index}`,
+    type: 'default',
+    label: definition.label,
+    position: { x: 120 + index * 40, y: 120 + index * 36 },
+    data: {
+      title: definition.label,
+      nodeType: definition.type,
+      params: defaultValues(definition.params || {}),
+      inputs: {},
+    },
+  }
+  flowNodes.value.push(node)
+  selectedNodeId.value = node.id
+  nextTick(() => fitView())
+}
+
+function defaultValues(fields) {
+  const values = {}
+  for (const [key, field] of Object.entries(fields)) {
+    values[key] = structuredClone(field.default ?? defaultValueForType(field.type))
+  }
+  return values
+}
+
+function defaultValueForType(type) {
+  if (type === 'number') return 0
+  if (type === 'boolean') return false
+  if (type === 'array') return []
+  return ''
+}
+
+function onConnect(connection) {
+  const kind = 'control'
+  flowEdges.value.push({
+    id: `edge_${Date.now()}`,
+    ...connection,
+    label: edgeLabel(kind),
+    data: { kind },
+    animated: true,
+    style: { stroke: '#2563eb' },
+  })
+}
+
+function onNodeClick(event) {
+  selectedNodeId.value = event.node.id
+}
+
+function syncNodeLabel() {
+  if (!selectedNode.value) return
+  selectedNode.value.label = selectedNode.value.data.title
+}
+
+function syncEdgeLabel(edge) {
+  edge.label = edgeLabel(edge.data.kind)
+  edge.animated = edge.data.kind === 'control'
+  edge.style = { stroke: edge.data.kind === 'data' ? '#10b981' : '#2563eb' }
+}
+
+function edgeLabel(kind) {
+  return kind === 'data' ? '数据' : '控制'
+}
+
+function fieldComponent(field) {
+  if (field.type === 'boolean') return 'el-switch'
+  if (field.type === 'select') return 'el-select'
+  if (field.type === 'number') return 'el-input-number'
+  if (field.type === 'textarea') return 'el-input'
+  return 'el-input'
+}
+
+function fieldProps(field) {
+  if (field.type === 'textarea') return { type: 'textarea', rows: 4 }
+  if (field.type === 'number') return { min: field.min, max: field.max }
+  if (field.type === 'array') return { type: 'textarea', rows: 3, placeholder: 'JSON 数组或逗号分隔文本' }
+  return {}
+}
+
+function inputBinding(key) {
+  if (!selectedNode.value.data.inputs[key]) {
+    selectedNode.value.data.inputs[key] = { source: 'literal', value: '' }
+  }
+  return selectedNode.value.data.inputs[key]
+}
+
+function ensureVariableObject(name) {
+  if (!workflowVariables.value[name] || typeof workflowVariables.value[name] !== 'object') {
+    workflowVariables.value[name] = {
+      type: typeof workflowVariables.value[name],
+      default: workflowVariables.value[name],
+      description: '',
+    }
+  }
+  workflowVariables.value[name].type ||= 'string'
+  return workflowVariables.value[name]
+}
+
+function variableDefaultComponent(variable) {
+  if (variable.type === 'number') return 'el-input-number'
+  if (variable.type === 'boolean') return 'el-switch'
+  return 'el-input'
+}
+
+function variableDefaultProps(variable) {
+  if (variable.type === 'number') return { controlsPosition: 'right' }
+  return {}
+}
+
+async function addVariable() {
+  const { value } = await ElMessageBox.prompt('请输入变量名', '新增变量', {
+    inputPattern: /^[A-Za-z_][A-Za-z0-9_]*$/,
+    inputErrorMessage: '变量名只能使用字母、数字和下划线,且不能以数字开头',
+  })
+  if (workflowVariables.value[value]) {
+    ElMessage.warning('变量已存在')
+    return
+  }
+  workflowVariables.value[value] = { type: 'string', default: '', description: '' }
+}
+
+function deleteVariable(name) {
+  delete workflowVariables.value[name]
+}
+
+function deleteEdge(edgeId) {
+  flowEdges.value = flowEdges.value.filter((edge) => edge.id !== edgeId)
+}
+
+function deleteSelectedNode() {
+  if (!selectedNodeId.value) return
+  flowNodes.value = flowNodes.value.filter((node) => node.id !== selectedNodeId.value)
+  flowEdges.value = flowEdges.value.filter((edge) => edge.source !== selectedNodeId.value && edge.target !== selectedNodeId.value)
+  selectedNodeId.value = null
+}
+
+function buildPayload() {
+  return {
+    schema_version: 'workflow/v1',
+    workflow_key: workflowKey.value.trim() || null,
+    name: workflowName.value.trim(),
+    description: workflowDescription.value,
+    variables: workflowVariables.value,
+    settings: workflowSettings.value,
+    nodes: flowNodes.value.map((node) => ({
+      id: node.id,
+      type: node.data.nodeType,
+      title: node.data.title,
+      position: node.position,
+      params: node.data.params || {},
+      inputs: node.data.inputs || {},
+    })),
+    edges: flowEdges.value.map((edge) => ({
+      id: edge.id,
+      kind: edge.data?.kind || 'control',
+      source: edge.source,
+      source_port: edge.sourceHandle || (edge.data?.kind === 'data' ? 'value' : 'success'),
+      target: edge.target,
+      target_port: edge.targetHandle || (edge.data?.kind === 'data' ? 'value' : 'run'),
+    })),
+  }
+}
+
+async function save() {
+  if (!workflowName.value.trim()) {
+    ElMessage.warning('请输入工作流名称')
+    return
+  }
+  saving.value = true
+  try {
+    const payload = buildPayload()
+    const { data } = workflowId.value
+      ? await api.put(`/api/automation/workflows/${workflowId.value}`, payload)
+      : await api.post('/api/automation/workflows', payload)
+    workflowId.value = data.id
+    ElMessage.success('已保存')
+    emit('saved', data.id)
+  } finally {
+    saving.value = false
+  }
+}
+
+async function runWorkflow() {
+  if (!workflowId.value) return
+  running.value = true
+  try {
+    const { data } = await api.post(`/api/automation/workflows/${workflowId.value}/run`, {})
+    runOutput.value = JSON.stringify(data, null, 2)
+    ElMessage[data.status === 'SUCCESS' ? 'success' : 'warning'](data.status === 'SUCCESS' ? '工作流执行完成' : '工作流执行中止')
+  } catch (error) {
+    ElMessage.error(error.response?.data?.detail || '执行工作流失败')
+  } finally {
+    running.value = false
+  }
+}
+
+onMounted(async () => {
+  await loadDefinitions()
+  await loadWorkflow()
+})
+</script>

+ 75 - 309
frontend/src/components/AutomationWorkflowView.vue

@@ -1,346 +1,112 @@
 <template>
-  <div class="workflow-page">
-    <aside class="workflow-list panel">
-      <div class="toolbar">
-        <div class="filters">
-          <el-button type="primary" @click="createEmpty">新建</el-button>
-          <el-button @click="load">刷新</el-button>
-        </div>
+  <div class="panel">
+    <div class="toolbar">
+      <div class="filters">
+        <el-button type="primary" @click="$emit('create')">新建工作流</el-button>
+        <el-button type="success" @click="planDialogVisible = true">AI 生成</el-button>
+        <el-button @click="load">刷新</el-button>
       </div>
-      <el-table :data="workflows.items" height="620" border stripe highlight-current-row @row-click="openEdit">
-        <el-table-column prop="name" label="工作流" min-width="150" show-overflow-tooltip />
-        <el-table-column prop="node_count" label="节点" width="70" />
-        <el-table-column label="操作" width="86">
-          <template #default="{ row }">
-            <el-button size="small" type="danger" @click.stop="remove(row)">删除</el-button>
-          </template>
-        </el-table-column>
-      </el-table>
-    </aside>
-
-    <main class="workflow-canvas-panel panel">
-      <div class="toolbar">
-        <div class="filters">
-          <el-input v-model="form.name" placeholder="工作流名称" style="width: 220px" />
-          <el-button type="primary" @click="save">保存</el-button>
-          <el-button type="success" :disabled="!form.id" :loading="running" @click="runWorkflow">执行工作流</el-button>
-          <el-button :type="connectMode ? 'warning' : 'default'" @click="toggleConnectMode">
-            {{ connectMode ? '退出连线' : '连接节点' }}
-          </el-button>
-          <el-button type="danger" :disabled="!selectedNode" @click="deleteSelectedNode">删除节点</el-button>
-        </div>
-      </div>
-
-      <div class="toolbar">
-        <div class="filters">
-          <el-button v-for="item in nodeTypes" :key="item.value" @click="addNode(item.value)">{{ item.label }}</el-button>
-        </div>
-      </div>
-
-      <div ref="canvasRef" class="workflow-canvas" @click="selectedNodeKey = null">
-        <svg class="workflow-lines">
-          <line
-            v-for="line in connectionLines"
-            :key="line.key"
-            :x1="line.x1"
-            :y1="line.y1"
-            :x2="line.x2"
-            :y2="line.y2"
-            marker-end="url(#arrow)"
-          />
-          <defs>
-            <marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
-              <path d="M0,0 L8,4 L0,8 Z" />
-            </marker>
-          </defs>
-        </svg>
-
-        <div
-          v-for="node in form.nodes"
-          :key="node.node_key"
-          class="workflow-node"
-          :class="{ selected: selectedNodeKey === node.node_key, connecting: connectSourceKey === node.node_key }"
-          :style="{ left: `${node.position_x}px`, top: `${node.position_y}px` }"
-          @click.stop="selectNode(node)"
-          @mousedown.stop="startDrag(node, $event)"
-        >
-          <div class="workflow-node-type">{{ nodeTypeLabel(node.node_type) }}</div>
-          <div class="workflow-node-title">{{ node.title || nodeTypeLabel(node.node_type) }}</div>
-          <div class="workflow-node-meta">ID: {{ node.screen_id || '-' }}</div>
-        </div>
-      </div>
-    </main>
-
-    <aside class="workflow-inspector panel">
-      <div class="section-title">节点属性</div>
-      <template v-if="selectedNode">
-        <el-form label-width="86px">
-          <el-form-item label="类型">
-            <el-select v-model="selectedNode.node_type">
-              <el-option v-for="item in nodeTypes" :key="item.value" :label="item.label" :value="item.value" />
-            </el-select>
-          </el-form-item>
-          <el-form-item label="标题">
-            <el-input v-model="selectedNode.title" />
-          </el-form-item>
-          <el-form-item label="界面 ID">
-            <el-input-number v-model="selectedNode.screen_id" :min="1" />
-          </el-form-item>
-          <el-form-item label="配置">
-            <el-input v-model="selectedNode.configText" type="textarea" :rows="8" class="node-config" />
-          </el-form-item>
-          <el-form-item label="下游">
-            <el-tag v-for="key in selectedNode.next_node_keys" :key="key" closable @close="disconnect(key)">
-              {{ nodeTitle(key) }}
-            </el-tag>
-          </el-form-item>
-        </el-form>
+    </div>
+
+    <el-table :data="workflows.items" border stripe height="680" @row-dblclick="$emit('edit', $event.id)">
+      <el-table-column prop="name" label="工作流" min-width="220" show-overflow-tooltip />
+      <el-table-column prop="workflow_key" label="Key" min-width="150" show-overflow-tooltip />
+      <el-table-column prop="description" label="描述" min-width="260" show-overflow-tooltip />
+      <el-table-column prop="schema_version" label="格式" width="120" />
+      <el-table-column prop="node_count" label="节点" width="80" />
+      <el-table-column prop="edge_count" label="连线" width="80" />
+      <el-table-column prop="updated_at" label="更新时间" min-width="180" />
+      <el-table-column label="操作" width="230" fixed="right">
+        <template #default="{ row }">
+          <div class="row-actions">
+            <el-button size="small" type="primary" @click="$emit('edit', row.id)">编辑</el-button>
+            <el-button size="small" type="success" :loading="runningId === row.id" @click="run(row)">执行</el-button>
+            <el-button size="small" type="danger" @click="remove(row)">删除</el-button>
+          </div>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <el-dialog v-model="runDialogVisible" title="执行结果" width="760px">
+      <pre class="workflow-run-output">{{ runOutput }}</pre>
+    </el-dialog>
+
+    <el-dialog v-model="planDialogVisible" title="AI 生成工作流草稿" width="720px">
+      <el-input v-model="planRequirement" type="textarea" :rows="8" placeholder="描述你希望 Windows 自动完成的任务" />
+      <template #footer>
+        <el-button @click="planDialogVisible = false">取消</el-button>
+        <el-button type="primary" :loading="planning" @click="createPlan">生成并打开编辑器</el-button>
       </template>
-      <div v-else class="muted">点击画布中的节点进行编辑。开启连接节点后,依次点击源节点和目标节点建立连线。</div>
-
-      <div class="section-title">执行结果</div>
-      <pre class="workflow-run-output">{{ runOutput || '暂无执行结果' }}</pre>
-    </aside>
+    </el-dialog>
   </div>
 </template>
 
 <script setup>
-import { computed, onMounted, reactive, ref } from 'vue'
+import { onMounted, ref } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import { api } from '../api'
 
-const nodeTypes = [
-  { label: '鼠标操作', value: 'mouse', config: { x: 0, y: 0, mouse_action: 'click' } },
-  { label: '键盘操作', value: 'keyboard', config: { keys: ['ctrl', 's'] } },
-  { label: '键盘输入', value: 'text_input', config: { text: '' } },
-  { label: '启动程序', value: 'start_program', config: { command: 'msedge' } },
-  { label: '关闭程序', value: 'close_programs', config: {} },
-]
+const emit = defineEmits(['create', 'edit'])
 
 const workflows = ref({ items: [] })
-const canvasRef = ref(null)
-const selectedNodeKey = ref(null)
-const connectMode = ref(false)
-const connectSourceKey = ref(null)
-const running = ref(false)
+const runningId = ref(null)
 const runOutput = ref('')
-const form = reactive({
-  id: null,
-  name: '',
-  description: '',
-  nodes: [],
-})
-
-const selectedNode = computed(() => form.nodes.find((node) => node.node_key === selectedNodeKey.value))
-const connectionLines = computed(() => {
-  const lines = []
-  const map = new Map(form.nodes.map((node) => [node.node_key, node]))
-  for (const node of form.nodes) {
-    for (const nextKey of node.next_node_keys || []) {
-      const target = map.get(nextKey)
-      if (!target) continue
-      lines.push({
-        key: `${node.node_key}-${nextKey}`,
-        x1: node.position_x + 110,
-        y1: node.position_y + 42,
-        x2: target.position_x,
-        y2: target.position_y + 42,
-      })
-    }
-  }
-  return lines
-})
+const runDialogVisible = ref(false)
+const planDialogVisible = ref(false)
+const planRequirement = ref('')
+const planning = ref(false)
 
 async function load() {
   const { data } = await api.get('/api/automation/workflows')
   workflows.value = data
 }
 
-function resetForm() {
-  form.id = null
-  form.name = `空工作流 ${new Date().toLocaleString()}`
-  form.description = ''
-  form.nodes = []
-  selectedNodeKey.value = null
-  runOutput.value = ''
-}
-
-function createEmpty() {
-  resetForm()
-}
-
-async function openEdit(row) {
-  const { data } = await api.get(`/api/automation/workflows/${row.id}`)
-  form.id = data.id
-  form.name = data.name
-  form.description = data.description || ''
-  form.nodes = (data.nodes || []).map((node, index) => normalizeNode(node, index))
-  selectedNodeKey.value = form.nodes[0]?.node_key || null
-}
-
-function normalizeNode(node, index) {
-  const key = node.node_key || `node_${Date.now()}_${index}`
-  return {
-    node_key: key,
-    node_type: node.node_type,
-    screen_id: node.screen_id || null,
-    title: node.title || nodeTypeLabel(node.node_type),
-    position_x: node.position_x ?? 80 + index * 36,
-    position_y: node.position_y ?? 80 + index * 36,
-    next_node_keys: node.next_node_keys || [],
-    configText: JSON.stringify(node.config || defaultConfig(node.node_type), null, 2),
-  }
-}
-
-function addNode(type) {
-  const index = form.nodes.length
-  const node = {
-    node_key: `node_${Date.now()}_${index}`,
-    node_type: type,
-    screen_id: null,
-    title: nodeTypeLabel(type),
-    position_x: 90 + index * 32,
-    position_y: 90 + index * 32,
-    next_node_keys: [],
-    configText: JSON.stringify(defaultConfig(type), null, 2),
-  }
-  form.nodes.push(node)
-  selectedNodeKey.value = node.node_key
-}
-
-function defaultConfig(type) {
-  return nodeTypes.find((item) => item.value === type)?.config || {}
-}
-
-function nodeTypeLabel(type) {
-  return nodeTypes.find((item) => item.value === type)?.label || type
-}
-
-function nodeTitle(key) {
-  const node = form.nodes.find((item) => item.node_key === key)
-  return node?.title || key
-}
-
-function selectNode(node) {
-  if (connectMode.value) {
-    if (!connectSourceKey.value) {
-      connectSourceKey.value = node.node_key
-      selectedNodeKey.value = node.node_key
-      return
-    }
-    if (connectSourceKey.value !== node.node_key) {
-      const source = form.nodes.find((item) => item.node_key === connectSourceKey.value)
-      if (source && !source.next_node_keys.includes(node.node_key)) source.next_node_keys.push(node.node_key)
-    }
-    connectSourceKey.value = null
-    connectMode.value = false
-  }
-  selectedNodeKey.value = node.node_key
-}
-
-function toggleConnectMode() {
-  connectMode.value = !connectMode.value
-  connectSourceKey.value = null
-}
-
-function disconnect(key) {
-  if (!selectedNode.value) return
-  selectedNode.value.next_node_keys = selectedNode.value.next_node_keys.filter((item) => item !== key)
-}
-
-function deleteSelectedNode() {
-  if (!selectedNodeKey.value) return
-  form.nodes = form.nodes.filter((node) => node.node_key !== selectedNodeKey.value)
-  for (const node of form.nodes) {
-    node.next_node_keys = node.next_node_keys.filter((key) => key !== selectedNodeKey.value)
-  }
-  selectedNodeKey.value = null
-}
-
-function startDrag(node, event) {
-  const startX = event.clientX
-  const startY = event.clientY
-  const originX = node.position_x
-  const originY = node.position_y
-  const move = (moveEvent) => {
-    node.position_x = Math.max(0, originX + moveEvent.clientX - startX)
-    node.position_y = Math.max(0, originY + moveEvent.clientY - startY)
-  }
-  const up = () => {
-    window.removeEventListener('mousemove', move)
-    window.removeEventListener('mouseup', up)
-  }
-  window.addEventListener('mousemove', move)
-  window.addEventListener('mouseup', up)
-}
-
-function buildPayloadNodes() {
-  return form.nodes.map((node) => {
-    let config
-    try {
-      config = JSON.parse(node.configText || '{}')
-    } catch {
-      throw new Error(`节点“${node.title}”配置不是合法 JSON`)
-    }
-    return {
-      node_key: node.node_key,
-      node_type: node.node_type,
-      screen_id: node.screen_id || null,
-      title: node.title,
-      position_x: node.position_x,
-      position_y: node.position_y,
-      next_node_keys: node.next_node_keys,
-      config,
-    }
-  })
-}
-
-async function save() {
-  if (!form.name.trim()) {
-    ElMessage.warning('请输入工作流名称')
-    return
-  }
-  let nodes
+async function run(row) {
+  runningId.value = row.id
   try {
-    nodes = buildPayloadNodes()
-  } catch (error) {
-    ElMessage.error(error.message)
-    return
-  }
-  const payload = { name: form.name.trim(), description: form.description, nodes }
-  const { data } = form.id
-    ? await api.put(`/api/automation/workflows/${form.id}`, payload)
-    : await api.post('/api/automation/workflows', payload)
-  form.id = data.id
-  ElMessage.success('已保存')
-  await load()
-}
-
-async function runWorkflow() {
-  if (!form.id) return
-  running.value = true
-  try {
-    const { data } = await api.post(`/api/automation/workflows/${form.id}/run`, {})
+    const { data } = await api.post(`/api/automation/workflows/${row.id}/run`, {})
     runOutput.value = JSON.stringify(data, null, 2)
+    runDialogVisible.value = true
     ElMessage[data.status === 'SUCCESS' ? 'success' : 'warning'](data.status === 'SUCCESS' ? '工作流执行完成' : '工作流执行中止')
   } catch (error) {
     ElMessage.error(error.response?.data?.detail || '执行工作流失败')
   } finally {
-    running.value = false
+    runningId.value = null
   }
 }
 
 async function remove(row) {
   await ElMessageBox.confirm(`确认删除工作流“${row.name}”?`, '删除工作流', { type: 'warning' })
   await api.delete(`/api/automation/workflows/${row.id}`)
-  if (form.id === row.id) resetForm()
   ElMessage.success('已删除')
   await load()
 }
 
+async function createPlan() {
+  if (!planRequirement.value.trim()) {
+    ElMessage.warning('请输入自动化需求')
+    return
+  }
+  planning.value = true
+  try {
+    const { data } = await api.post('/api/automation/workflows/plan', { requirement: planRequirement.value.trim() })
+    const workflow = data.plan?.workflow
+    if (!workflow?.nodes || !workflow?.edges) {
+      throw new Error('AI 未返回有效 workflow 草稿')
+    }
+    const saved = await api.post('/api/automation/workflows', workflow)
+    ElMessage.success('已生成工作流草稿')
+    planDialogVisible.value = false
+    planRequirement.value = ''
+    await load()
+    emit('edit', saved.data.id)
+  } catch (error) {
+    ElMessage.error(error.response?.data?.detail || error.message || 'AI 生成失败')
+  } finally {
+    planning.value = false
+  }
+}
+
 defineExpose({ load })
-onMounted(async () => {
-  await load()
-  resetForm()
-})
+onMounted(load)
 </script>

+ 7 - 0
frontend/src/components/SystemSettingsView.vue

@@ -45,6 +45,12 @@
       <el-form-item label="自动截屏间隔秒数">
         <el-input-number v-model="form.automation_auto_screenshot_interval" :min="1" :max="3600" />
       </el-form-item>
+
+      <div class="section-title">远程执行</div>
+      <el-alert type="warning" show-icon :closable="false" title="按 key 远程执行工作流时必须携带此 Token。请只在可信局域网或 VPN 内开放后端端口。" />
+      <el-form-item label="远程执行 Token">
+        <el-input v-model="form.automation_remote_token" show-password placeholder="用于 X-Automation-Token 请求头" />
+      </el-form-item>
     </el-form>
   </div>
 </template>
@@ -67,6 +73,7 @@ const form = reactive({
   automation_runtime_path: 'automation/runtime',
   automation_auto_screenshot_enabled: false,
   automation_auto_screenshot_interval: 30,
+  automation_remote_token: '',
 })
 
 const enabledProviders = computed(() => providers.value.filter((item) => item.enabled))

+ 175 - 0
frontend/src/styles.css

@@ -34,6 +34,12 @@ body {
   padding: 22px;
 }
 
+.main-fullscreen {
+  padding: 0;
+  height: 100vh;
+  overflow: hidden;
+}
+
 .topbar {
   display: flex;
   align-items: center;
@@ -420,6 +426,175 @@ body {
   word-break: break-word;
 }
 
+.workflow-editor-page {
+  height: 100vh;
+  display: grid;
+  grid-template-rows: 58px minmax(0, 1fr);
+  background: #f8fafc;
+}
+
+.workflow-editor-topbar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  padding: 10px 14px;
+  border-bottom: 1px solid #d8dee8;
+  background: #fff;
+}
+
+.workflow-editor-title {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  min-width: 0;
+  flex: 1;
+}
+
+.workflow-name-input {
+  width: 260px;
+}
+
+.workflow-key-input {
+  width: 190px;
+}
+
+.workflow-desc-input {
+  max-width: 520px;
+}
+
+.workflow-editor-body {
+  min-height: 0;
+  display: grid;
+  grid-template-columns: 260px minmax(0, 1fr) 390px;
+}
+
+.workflow-node-palette,
+.workflow-inspector-panel {
+  min-width: 0;
+  padding: 14px;
+  overflow: auto;
+  border-right: 1px solid #d8dee8;
+  background: #fff;
+}
+
+.workflow-inspector-panel {
+  border-right: 0;
+  border-left: 1px solid #d8dee8;
+}
+
+.workflow-flow-wrap {
+  min-width: 0;
+  min-height: 0;
+}
+
+.workflow-flow {
+  width: 100%;
+  height: 100%;
+  background:
+    linear-gradient(#eef2f7 1px, transparent 1px),
+    linear-gradient(90deg, #eef2f7 1px, transparent 1px),
+    #f8fafc;
+  background-size: 24px 24px;
+}
+
+.palette-node {
+  display: block;
+  width: 100%;
+  padding: 9px 10px;
+  margin-bottom: 8px;
+  border: 1px solid #d8dee8;
+  border-radius: 8px;
+  background: #fff;
+  color: #1f2937;
+  text-align: left;
+  cursor: pointer;
+}
+
+.palette-node:hover {
+  border-color: #2563eb;
+  background: #eff6ff;
+}
+
+.palette-node span,
+.palette-node small {
+  display: block;
+}
+
+.palette-node small {
+  margin-top: 3px;
+  color: #64748b;
+}
+
+.workflow-inspector-form {
+  margin-bottom: 12px;
+}
+
+.workflow-field {
+  width: 100%;
+}
+
+.input-binding-row {
+  display: grid;
+  grid-template-columns: 88px 100px minmax(0, 1fr);
+  gap: 8px;
+  align-items: center;
+  margin-bottom: 8px;
+}
+
+.input-binding-name {
+  color: #374151;
+  font-size: 13px;
+}
+
+.input-source {
+  width: 100px;
+}
+
+.inspector-actions {
+  margin-top: 12px;
+}
+
+.workflow-variable-list {
+  display: grid;
+  gap: 10px;
+  margin-bottom: 18px;
+}
+
+.workflow-variable-row {
+  display: grid;
+  gap: 8px;
+  padding: 10px;
+  border: 1px solid #e5e7eb;
+  border-radius: 8px;
+  background: #f8fafc;
+}
+
+.workflow-variable-head,
+.workflow-variable-fields {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.workflow-variable-head {
+  justify-content: space-between;
+}
+
+.workflow-variable-fields .el-select {
+  width: 90px;
+  flex: 0 0 90px;
+}
+
+.workflow-variable-fields .el-input,
+.workflow-variable-fields .el-input-number {
+  flex: 1;
+}
+
+.add-variable-button {
+  width: 100%;
+}
+
 @media (max-width: 760px) {
   .app-shell {
     display: block;

+ 13 - 0
task.md

@@ -58,6 +58,16 @@
 - [x] 前端开发服务和默认 API 地址支持局域网访问,并更新部署文档
 - [x] 调整界面分析流程:可操作元素先只记录大致位置,按单个元素再调用 AI 精确定位坐标
 - [x] 优化键盘操作弹窗,支持手动选择 Win/Ctrl/Alt/Shift 组合键并规范化方向键名称
+- [x] 重构自动化相关 Python 后端结构,新增 automation 包和 nodes 节点能力目录
+- [ ] 继续拆分通用后端 API、schema、service 平铺模块
+- [x] 设计并落地 workflow/v1 数据格式,支持 nodes、edges、params、inputs、outputs、variables 和运行设置
+- [x] 增加自动化节点注册表,每类节点集中在独立文件中定义元数据、参数、输入输出和执行逻辑
+- [x] 改造工作流保存、读取、执行接口,使用新的节点/连线图结构,不再兼容旧 next_node_keys JSON
+- [x] 前端工作流列表改为打开全屏可视化编辑页面,支持网格画布、鼠标拖拽节点、连线、节点属性编辑
+- [x] 增加 AI 根据用户需求生成基础 workflow 计划、交互式执行和失败截图询问用户的基础能力
+- [x] 编写新的 workflow 数据格式与节点说明文档,并同步更新 API 文档
+- [x] 增加 workflow_key,支持按稳定 key 远程执行工作流
+- [x] 增加自动化远程执行 Token 设置,并为按 key 执行接口校验 X-Automation-Token
 
 ## 进度日志
 
@@ -77,3 +87,6 @@
 - 2026-05-11:提交 AI 自动化基础代码基线;新增系统设置、自动化截图刷新、工作流画布编辑和后端整条工作流执行接口;前端支持局域网访问默认 API 地址。
 - 2026-05-11:根据本地多模态模型定位效果,调整自动化界面分析为“元素清单 + 大致位置”,新增单元素“找位置”接口和前端按钮,定位成功后再更新元素坐标并绘制标记。
 - 2026-05-11:修复浏览器无法可靠捕获 Win 组合键的问题;键盘操作改为可手动选择修饰键和主键,并在后端将 ArrowUp/Meta 等按键名转换为 pyautogui 兼容名称。
+- 2026-05-12:开始工作流引擎升级:规划 workflow/v1 数据格式、自动化节点注册表、全屏可视化编辑器、AI 规划和交互式修复流程。
+- 2026-05-12:完成 workflow/v1 后端保存/读取/执行、节点注册表、Vue Flow 全屏编辑器、AI 生成草稿入口、失败截图工件和 workflow/API 文档更新;后续继续拆分通用后端平铺模块。
+- 2026-05-16:新增 workflow_key、按 key 执行接口和远程执行 Token;CCTV 直播示例设置 key 为 cctv-live,便于 iPhone 快捷指令调用。

+ 139 - 0
workflow-format.md

@@ -0,0 +1,139 @@
+# Workflow v1 数据格式与节点说明
+
+本项目的自动化工作流使用 `workflow/v1`。该格式把工作流看成一个有向图:节点负责执行动作或产出数据,连线负责控制执行顺序或传递数据。
+
+## 顶层结构
+
+```json
+{
+  "schema_version": "workflow/v1",
+  "workflow_key": "notepad-demo",
+  "name": "打开记事本并输入文本",
+  "description": "示例",
+  "variables": {
+    "target_text": {
+      "type": "string",
+      "default": "hello",
+      "description": "要输入的文本"
+    }
+  },
+  "settings": {
+    "max_steps": 100,
+    "default_timeout_ms": 30000,
+    "on_unhandled_error": "pause_for_user"
+  },
+  "nodes": [],
+  "edges": []
+}
+```
+
+字段说明:
+
+- `workflow_key`:可选的稳定调用 key,只能使用字母、数字、下划线和连字符;适合手机快捷指令等远程入口按 key 执行。
+- `variables`:工作流变量。运行时可以通过接口传入同名变量覆盖默认值。
+- `settings.max_steps`:防止流程循环或异常跳转导致无限执行。
+- `nodes`:节点实例列表。
+- `edges`:节点之间的连线,包括控制流和数据流。
+
+## 节点结构
+
+```json
+{
+  "id": "mouse_1",
+  "type": "mouse.click",
+  "title": "点击目标",
+  "position": { "x": 360, "y": 180 },
+  "params": {
+    "button": "left",
+    "clicks": 1
+  },
+  "inputs": {
+    "x": { "source": "node_output", "node_id": "locate_1", "output": "x" },
+    "y": { "source": "node_output", "node_id": "locate_1", "output": "y" }
+  }
+}
+```
+
+- `type` 必须来自 `GET /api/automation/workflow-nodes` 返回的节点定义。
+- `params` 是界面中固定编辑的参数。
+- `inputs` 是运行时输入,可以来自固定值、变量、上游节点输出或运行时上下文。
+- `position` 只影响前端画布显示。
+
+## 输入来源
+
+```json
+{ "source": "literal", "value": 100 }
+{ "source": "variable", "name": "target_text" }
+{ "source": "node_output", "node_id": "node_a", "output": "x" }
+{ "source": "runtime", "name": "current_screenshot_path" }
+```
+
+第一版执行器支持以上四类输入。数据流连线也会在运行时转换成目标节点的输入值。
+
+## 连线结构
+
+控制流连线决定执行顺序:
+
+```json
+{
+  "id": "edge_1",
+  "kind": "control",
+  "source": "start_1",
+  "source_port": "next",
+  "target": "program_1",
+  "target_port": "run"
+}
+```
+
+数据流连线把源节点输出传给目标节点输入:
+
+```json
+{
+  "id": "edge_data_x",
+  "kind": "data",
+  "source": "locate_1",
+  "source_port": "x",
+  "target": "mouse_1",
+  "target_port": "x"
+}
+```
+
+## 内置节点类型
+
+当前内置节点由后端注册表集中提供:
+
+- `flow.start`、`flow.end`、`flow.condition`
+- `mouse.click`、`mouse.double_click`、`mouse.right_click`、`mouse.move`、`mouse.scroll`
+- `keyboard.press`、`keyboard.hotkey`、`keyboard.key_down`、`keyboard.key_up`
+- `text.input`
+- `browser.open_url`
+- `program.start`、`program.stop`、`program.close_opened`
+- `screen.screenshot`
+- `wait.seconds`
+- `human.ask_user`
+
+每个节点类型的参数、输入、输出和控制端口以 `GET /api/automation/workflow-nodes` 为准。前端节点库和属性面板应使用该接口动态生成。
+
+## 执行结果
+
+工作流运行接口按节点返回结果:
+
+```json
+{
+  "workflow_id": 1,
+  "status": "SUCCESS",
+  "results": [
+    {
+      "node_id": "program_1",
+      "status": "SUCCESS",
+      "inputs": {},
+      "outputs": { "pid": 1234, "command": "notepad" }
+    }
+  ],
+  "outputs": {
+    "program_1": { "pid": 1234, "command": "notepad" }
+  }
+}
+```
+
+如果节点需要用户判断,执行结果会返回 `status: "PAUSED"`,并包含暂停节点、问题和可选截图路径。节点失败时,后端会尽量保存当前屏幕截图到自动化错误目录,并在失败项的 `artifacts.screenshot_path` 返回路径,供前端展示给用户继续分析。