Переглянути джерело

Add settings and workflow execution

codex 1 місяць тому
батько
коміт
10be37fab2

+ 63 - 0
api-docs.md

@@ -72,6 +72,34 @@ AI 模块分为项目内部统一 AI 服务层和具体供应商 API 对接层
 
 服务商 API Key 可为空,接口返回时只返回 `api_key_set`,不会返回明文 Key。
 
+## 系统设置
+
+### 查询系统设置
+
+`GET /api/settings`
+
+返回全局默认 AI 服务商、模型、温度,以及自动化文件保存相对路径。
+
+### 更新系统设置
+
+`PUT /api/settings`
+
+```json
+{
+  "default_ai_provider_id": 1,
+  "default_ai_model_id": 1,
+  "default_ai_temperature": 0.1,
+  "automation_file_root": "automation",
+  "automation_screen_path": "automation/screens",
+  "automation_error_path": "automation/errors",
+  "automation_runtime_path": "automation/runtime",
+  "automation_auto_screenshot_enabled": false,
+  "automation_auto_screenshot_interval": 30
+}
+```
+
+路径必须是相对路径,后端会解析到 `backend/data` 目录下。
+
 ### 查询 AI 服务商
 
 `GET /api/ai/providers`
@@ -669,6 +697,18 @@ smartctl -a -d jmb39x,1 /dev/sdb
 
 后端会截取当前 Windows 屏幕,调用支持视觉输入的 AI 模型识别界面名称、描述、是否为 Windows 桌面、是否为浏览器网页,以及可操作元素列表。AI 返回的百分比坐标会按原始截图分辨率换算为像素坐标;截图和识别结果会保存到数据库。
 
+### 截取当前屏幕
+
+`POST /api/automation/vision/screenshot`
+
+```json
+{
+  "save": true
+}
+```
+
+只截屏并返回图片,不进行 AI 分析。自动化操作页面的手动截屏和自动截屏使用该接口。
+
 ### 高层自动化动作接口
 
 以下接口会在动作执行前获取一次当前进程列表,执行后再次获取并对比新增进程。若请求携带 `screen_id`,后端会先截图当前屏幕,并把当前截图和数据库中保存的目标界面截图发送给 AI 做界面对比;不匹配时会写入自动化错误记录并终止动作。
@@ -761,8 +801,31 @@ smartctl -a -d jmb39x,1 /dev/sdb
 
 节点类型当前支持:`mouse`、`keyboard`、`text_input`、`start_program`、`close_programs`。
 
+节点可附带画布位置和连接关系:
+
+```json
+{
+  "node_key": "node_1",
+  "position_x": 120,
+  "position_y": 160,
+  "next_node_keys": ["node_2"]
+}
+```
+
 `GET /api/automation/workflows/{workflow_id}`
 
+`POST /api/automation/workflows/{workflow_id}/run`
+
+```json
+{
+  "provider_id": null,
+  "model_id": null,
+  "temperature": null
+}
+```
+
+如果不传 AI 参数,后端会使用系统设置中的默认 AI 服务商、模型和温度。后端会按照工作流节点连接关系执行;如果没有连接关系,则按节点序号执行。
+
 `PUT /api/automation/workflows/{workflow_id}`
 
 `DELETE /api/automation/workflows/{workflow_id}`

+ 199 - 16
backend/app/automation_service.py

@@ -10,15 +10,17 @@ from typing import Any
 import psutil
 from fastapi import HTTPException
 
-from . import ai_service, windows_automation
+from . import ai_service, settings_service, windows_automation
 from .database import DATA_DIR, get_db
 from .scanner import now_iso
 from .schemas import (
     AutomationKeyboardActionRequest,
     AutomationMouseActionRequest,
+    AutomationScreenshotCaptureRequest,
     AutomationStartProgramRequest,
     AutomationTextInputRequest,
     AutomationVisionAnalyzeRequest,
+    AutomationWorkflowRunRequest,
     AutomationWorkflowSaveRequest,
 )
 
@@ -66,13 +68,28 @@ SCREEN_COMPARE_PROMPT = """请作为 AI 视觉自动化校验器判断两张截
 
 def ensure_dirs() -> None:
     """确保自动化截图、错误截图和运行时目录存在。"""
-    for path in [SCREEN_DIR, ERROR_DIR, RUNTIME_DIR]:
+    for path in [screen_dir(), error_dir(), runtime_dir()]:
         path.mkdir(parents=True, exist_ok=True)
 
 
+def screen_dir() -> Path:
+    """根据系统设置获取已识别界面截图目录。"""
+    return settings_service.resolve_data_path("automation_screen_path", "automation/screens")
+
+
+def error_dir() -> Path:
+    """根据系统设置获取错误截图目录。"""
+    return settings_service.resolve_data_path("automation_error_path", "automation/errors")
+
+
+def runtime_dir() -> Path:
+    """根据系统设置获取临时截图目录。"""
+    return settings_service.resolve_data_path("automation_runtime_path", "automation/runtime")
+
+
 def image_to_base64(path: str | Path) -> dict[str, str]:
     """读取图片文件并转为 AI 服务可接收的 base64 结构。"""
-    file_path = Path(path)
+    file_path = stored_path(path)
     mime_type = mimetypes.guess_type(file_path.name)[0] or "image/png"
     return {
         "base64": base64.b64encode(file_path.read_bytes()).decode("ascii"),
@@ -95,19 +112,70 @@ def take_screenshot_file(folder: Path, prefix: str) -> dict[str, Any]:
     path = folder / filename
     result = windows_automation.take_screenshot(str(path), include_base64=True)
     result["path"] = str(path)
+    result["db_path"] = data_relative_path(path)
     return result
 
 
+def data_relative_path(path: str | Path) -> str:
+    """把 data 目录下的文件路径转换为数据库保存用的相对路径。"""
+    file_path = Path(path).resolve()
+    try:
+        return file_path.relative_to(DATA_DIR.resolve()).as_posix()
+    except ValueError:
+        return str(file_path)
+
+
+def stored_path(path: str | Path) -> Path:
+    """把数据库中的相对路径还原成真实文件路径,同时兼容旧的绝对路径。"""
+    file_path = Path(path)
+    if file_path.is_absolute():
+        return file_path
+    return (DATA_DIR / file_path).resolve()
+
+
+def resolve_ai_params(
+    provider_id: int | None,
+    model_id: int | None,
+    temperature: float | None,
+) -> tuple[int, int, float]:
+    """合并请求参数和系统默认 AI 参数。"""
+    defaults = settings_service.default_ai_params()
+    resolved_provider = provider_id or defaults.get("provider_id")
+    resolved_model = model_id or defaults.get("model_id")
+    resolved_temperature = temperature if temperature is not None else defaults.get("temperature", 0.1)
+    if not resolved_provider or not resolved_model:
+        raise HTTPException(status_code=400, detail="AI provider and model are required. Configure system defaults or pass them explicitly.")
+    return int(resolved_provider), int(resolved_model), float(resolved_temperature)
+
+
+def capture_screenshot(payload: AutomationScreenshotCaptureRequest) -> dict[str, Any]:
+    """截取当前屏幕并返回给前端显示,不进行 AI 分析。"""
+    if payload.save:
+        screenshot = take_screenshot_file(runtime_dir(), "manual_screenshot")
+    else:
+        screenshot = windows_automation.take_screenshot(None, include_base64=True)
+        screenshot["path"] = None
+        screenshot["db_path"] = None
+    return {
+        "width": screenshot["width"],
+        "height": screenshot["height"],
+        "image_base64": screenshot["image_base64"],
+        "mime_type": screenshot["mime_type"],
+        "path": screenshot.get("db_path"),
+    }
+
+
 def analyze_screen(payload: AutomationVisionAnalyzeRequest) -> dict[str, Any]:
     """截图当前屏幕,调用 AI 识别界面和可操作元素,并保存识别结果。"""
-    screenshot = take_screenshot_file(SCREEN_DIR, "screen")
+    provider_id, model_id, temperature = resolve_ai_params(payload.provider_id, payload.model_id, payload.temperature)
+    screenshot = take_screenshot_file(screen_dir(), "screen")
     image = image_to_base64(screenshot["path"])
     ai_result = ai_service.chat_with_images(
-        payload.provider_id,
-        payload.model_id,
+        provider_id,
+        model_id,
         SCREEN_ANALYZE_PROMPT,
         [image],
-        payload.temperature,
+        temperature,
     )
     try:
         parsed = json_from_ai(ai_result["content"])
@@ -130,7 +198,7 @@ def analyze_screen(payload: AutomationVisionAnalyzeRequest) -> dict[str, Any]:
             (
                 str(parsed.get("interface_name") or "未命名界面")[:160],
                 parsed.get("description"),
-                screenshot["path"],
+                screenshot["db_path"],
                 width,
                 height,
                 1 if bool(parsed.get("is_windows_desktop")) else 0,
@@ -236,7 +304,7 @@ def get_screen(screen_id: int, include_image: bool = False) -> dict[str, Any]:
         ).fetchall()
     item = public_screen(screen)
     item["elements"] = [public_element(row) for row in elements]
-    if include_image and Path(item["image_path"]).exists():
+    if include_image and stored_path(item["image_path"]).exists():
         image = image_to_base64(item["image_path"])
         item["image_base64"] = image["base64"]
         item["mime_type"] = image["mime_type"]
@@ -300,11 +368,10 @@ def validate_screen_before_action(
     """如果动作绑定了界面 ID,则先用 AI 判断当前屏幕是否仍处于目标界面。"""
     if screen_id is None:
         return None
-    if provider_id is None or model_id is None:
-        raise HTTPException(status_code=400, detail="provider_id and model_id are required when screen_id is provided")
+    provider_id, model_id, temperature = resolve_ai_params(provider_id, model_id, temperature)
 
     target = get_screen(screen_id)
-    current = take_screenshot_file(RUNTIME_DIR, "compare_current")
+    current = take_screenshot_file(error_dir(), "compare_current")
     prompt = SCREEN_COMPARE_PROMPT.replace("{description}", target.get("description") or target.get("interface_name") or "")
     ai_result = ai_service.chat_with_images(
         provider_id,
@@ -329,7 +396,7 @@ def validate_screen_before_action(
             node_id=node_id,
             similarity=similarity,
             expected_image_path=target["image_path"],
-            actual_image_path=current["path"],
+            actual_image_path=current["db_path"],
             compare_result=parsed,
         )
         raise HTTPException(status_code=409, detail={"message": error["message"], "error": error})
@@ -520,16 +587,21 @@ def insert_workflow_nodes(conn, workflow_id: int, nodes: list[Any], now: str) ->
         conn.execute(
             """
             INSERT INTO automation_workflow_nodes (
-                workflow_id, node_index, node_type, screen_id, title, config_json, created_at, updated_at
+                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 (?, ?, ?, ?, ?, ?, ?, ?)
+            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,
@@ -580,6 +652,113 @@ def delete_workflow(workflow_id: int) -> dict[str, Any]:
     return {"deleted": cursor.rowcount}
 
 
+def run_workflow(workflow_id: int, payload: AutomationWorkflowRunRequest) -> dict[str, Any]:
+    """按数据库中保存的工作流节点和连线顺序在后端执行整个工作流。"""
+    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] = []
+
+    for node in nodes:
+        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})
+        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}
+            results.append(failure)
+            return {"workflow_id": workflow_id, "status": "FAILED", "failed": failure, "results": results}
+    return {"workflow_id": workflow_id, "status": "SUCCESS", "results": results}
+
+
+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 execute_workflow_node(
+    workflow_id: int,
+    node: dict[str, Any],
+    provider_id: int | None,
+    model_id: int | None,
+    temperature: float,
+    opened_pids: list[int],
+) -> 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"),
+    }
+    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)
@@ -587,6 +766,10 @@ def public_node(row: dict[str, Any]) -> dict[str, Any]:
         item["config"] = json.loads(item.pop("config_json") or "{}")
     except json.JSONDecodeError:
         item["config"] = {}
+    try:
+        item["next_node_keys"] = json.loads(item.get("next_node_keys") or "[]")
+    except json.JSONDecodeError:
+        item["next_node_keys"] = []
     return item
 
 
@@ -618,7 +801,7 @@ def get_error(error_id: int, include_images: bool = False) -> dict[str, Any]:
     if include_images:
         for key in ["expected_image_path", "actual_image_path"]:
             path = item.get(key)
-            if path and Path(path).exists():
+            if path and stored_path(path).exists():
                 image = image_to_base64(path)
                 item[key.replace("_path", "_base64")] = image["base64"]
                 item[key.replace("_path", "_mime_type")] = image["mime_type"]

+ 47 - 0
backend/app/database.py

@@ -150,6 +150,13 @@ def init_db() -> None:
             CREATE INDEX IF NOT EXISTS idx_ai_models_provider
                 ON ai_models(provider_id);
 
+            CREATE TABLE IF NOT EXISTS app_settings (
+                key TEXT PRIMARY KEY,
+                value TEXT,
+                description TEXT,
+                updated_at TEXT NOT NULL
+            );
+
             CREATE TABLE IF NOT EXISTS automation_screens (
                 id INTEGER PRIMARY KEY AUTOINCREMENT,
                 interface_name TEXT NOT NULL,
@@ -194,9 +201,13 @@ def init_db() -> None:
                 id INTEGER PRIMARY KEY AUTOINCREMENT,
                 workflow_id INTEGER NOT NULL,
                 node_index INTEGER NOT NULL,
+                node_key TEXT,
                 node_type TEXT NOT NULL,
                 screen_id INTEGER,
                 title TEXT,
+                position_x REAL NOT NULL DEFAULT 80,
+                position_y REAL NOT NULL DEFAULT 80,
+                next_node_keys TEXT,
                 config_json TEXT NOT NULL,
                 created_at TEXT NOT NULL,
                 updated_at TEXT NOT NULL,
@@ -228,7 +239,18 @@ def init_db() -> None:
                 ON automation_errors(created_at DESC);
             """
         )
+        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")
+        ensure_column(conn, "automation_workflow_nodes", "next_node_keys", "TEXT")
         seed_default_tags(conn)
+        seed_default_settings(conn)
+
+
+def ensure_column(conn: sqlite3.Connection, table: str, column: str, definition: str) -> None:
+    existing = {row["name"] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
+    if column not in existing:
+        conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")
 
 
 def seed_default_tags(conn: sqlite3.Connection) -> None:
@@ -259,3 +281,28 @@ def seed_default_tags(conn: sqlite3.Connection) -> None:
             """,
             (name, description, is_controllable, is_builtin, now, now),
         )
+
+
+def seed_default_settings(conn: sqlite3.Connection) -> None:
+    settings = [
+        ("default_ai_provider_id", "", "全局默认 AI 服务商 ID"),
+        ("default_ai_model_id", "", "全局默认 AI 模型 ID"),
+        ("default_ai_temperature", "0.1", "全局默认 AI 温度参数"),
+        ("automation_file_root", "automation", "自动化文件保存根路径"),
+        ("automation_screen_path", "automation/screens", "已识别界面截图保存路径"),
+        ("automation_error_path", "automation/errors", "自动化错误截图保存路径"),
+        ("automation_runtime_path", "automation/runtime", "自动化临时截图保存路径"),
+        ("automation_auto_screenshot_enabled", "0", "自动化操作页面是否默认自动截屏"),
+        ("automation_auto_screenshot_interval", "30", "自动化操作页面默认自动截屏间隔秒数"),
+    ]
+    now = __import__("datetime").datetime.now().astimezone().isoformat(timespec="seconds")
+    for key, value, description in settings:
+        conn.execute(
+            """
+            INSERT INTO app_settings (key, value, description, updated_at)
+            VALUES (?, ?, ?, ?)
+            ON CONFLICT(key) DO UPDATE SET
+                description = excluded.description
+            """,
+            (key, value, description, now),
+        )

+ 24 - 1
backend/app/main.py

@@ -7,7 +7,7 @@ from typing import Any
 from fastapi import FastAPI, HTTPException, Query
 from fastapi.middleware.cors import CORSMiddleware
 
-from . import ai_service, automation_service, windows_automation
+from . import ai_service, automation_service, settings_service, windows_automation
 from .control import (
     CONFIRMED_CONTROL_STATUSES,
     restart_service,
@@ -33,16 +33,19 @@ from .schemas import (
     AutomationMouseActionRequest,
     AutomationPowerRequest,
     AutomationCloseProgramsRequest,
+    AutomationScreenshotCaptureRequest,
     AutomationStartProgramRequest,
     AutomationProgramStartRequest,
     AutomationProgramStopRequest,
     AutomationScreenshotRequest,
     AutomationTextInputRequest,
     AutomationVisionAnalyzeRequest,
+    AutomationWorkflowRunRequest,
     AutomationWorkflowSaveRequest,
     BatchStatusUpdate,
     PromptRequest,
     StatusUpdate,
+    SystemSettingsUpdate,
     TagAssignRequest,
     TagCreate,
     TagUpdate,
@@ -675,6 +678,16 @@ def ai_test(payload: AiChatRequest) -> dict[str, Any]:
     return ai_service.chat(payload.provider_id, payload.model_id, payload.prompt, payload.temperature)
 
 
+@app.get("/api/settings")
+def system_settings() -> dict[str, Any]:
+    return settings_service.list_settings()
+
+
+@app.put("/api/settings")
+def system_settings_update(payload: SystemSettingsUpdate) -> dict[str, Any]:
+    return settings_service.update_settings(payload)
+
+
 @app.post("/api/automation/power/shutdown")
 def automation_shutdown(payload: AutomationPowerRequest) -> dict[str, Any]:
     return windows_automation.shutdown_windows(payload.delay_seconds, payload.force, payload.reason)
@@ -739,6 +752,11 @@ def automation_vision_analyze(payload: AutomationVisionAnalyzeRequest) -> dict[s
     return automation_service.analyze_screen(payload)
 
 
+@app.post("/api/automation/vision/screenshot")
+def automation_vision_screenshot(payload: AutomationScreenshotCaptureRequest) -> dict[str, Any]:
+    return automation_service.capture_screenshot(payload)
+
+
 @app.post("/api/automation/actions/mouse")
 def automation_action_mouse(payload: AutomationMouseActionRequest) -> dict[str, Any]:
     return automation_service.execute_mouse_action(payload)
@@ -779,6 +797,11 @@ def automation_workflow_detail(workflow_id: int) -> dict[str, Any]:
     return automation_service.get_workflow(workflow_id)
 
 
+@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.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)

+ 28 - 2
backend/app/schemas.py

@@ -125,11 +125,15 @@ class AutomationKeyboardRequest(BaseModel):
 
 
 class AutomationVisionAnalyzeRequest(BaseModel):
-    provider_id: int
-    model_id: int
+    provider_id: int | None = None
+    model_id: int | None = None
     temperature: float = Field(default=0.1, ge=0, le=2)
 
 
+class AutomationScreenshotCaptureRequest(BaseModel):
+    save: bool = True
+
+
 class AutomationActionBase(BaseModel):
     screen_id: int | None = None
     provider_id: int | None = None
@@ -164,9 +168,13 @@ class AutomationCloseProgramsRequest(BaseModel):
 
 
 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)
 
 
@@ -176,6 +184,24 @@ class AutomationWorkflowSaveRequest(BaseModel):
     nodes: list[AutomationWorkflowNode] = 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)
+
+
+class SystemSettingsUpdate(BaseModel):
+    default_ai_provider_id: int | None = None
+    default_ai_model_id: int | None = None
+    default_ai_temperature: float | None = Field(default=None, ge=0, le=2)
+    automation_file_root: str | None = None
+    automation_screen_path: str | None = None
+    automation_error_path: str | None = None
+    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)
+
+
 class AiProviderCreate(BaseModel):
     name: str = Field(min_length=1, max_length=120)
     provider_type: AiProviderType

+ 129 - 0
backend/app/settings_service.py

@@ -0,0 +1,129 @@
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any
+
+from fastapi import HTTPException
+
+from .database import DATA_DIR, get_db
+from .scanner import now_iso
+from .schemas import SystemSettingsUpdate
+
+
+SETTING_KEYS = {
+    "default_ai_provider_id",
+    "default_ai_model_id",
+    "default_ai_temperature",
+    "automation_file_root",
+    "automation_screen_path",
+    "automation_error_path",
+    "automation_runtime_path",
+    "automation_auto_screenshot_enabled",
+    "automation_auto_screenshot_interval",
+}
+
+
+def list_settings() -> dict[str, Any]:
+    with get_db() as conn:
+        rows = conn.execute("SELECT * FROM app_settings ORDER BY key ASC").fetchall()
+    values = {row["key"]: row["value"] for row in rows}
+    return {"settings": normalize_settings(values), "items": rows}
+
+
+def update_settings(payload: SystemSettingsUpdate) -> dict[str, Any]:
+    values = payload.model_dump(exclude_unset=True)
+    now = now_iso()
+    with get_db() as conn:
+        for key, value in values.items():
+            if key not in SETTING_KEYS:
+                continue
+            if key.endswith("_path") or key == "automation_file_root":
+                value = normalize_relative_path(value)
+            conn.execute(
+                """
+                INSERT INTO app_settings (key, value, description, updated_at)
+                VALUES (?, ?, COALESCE((SELECT description FROM app_settings WHERE key = ?), ''), ?)
+                ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
+                """,
+                (key, serialize_value(value), key, now),
+            )
+    return list_settings()
+
+
+def normalize_settings(values: dict[str, str | None]) -> dict[str, Any]:
+    return {
+        "default_ai_provider_id": optional_int(values.get("default_ai_provider_id")),
+        "default_ai_model_id": optional_int(values.get("default_ai_model_id")),
+        "default_ai_temperature": optional_float(values.get("default_ai_temperature"), 0.1),
+        "automation_file_root": values.get("automation_file_root") or "automation",
+        "automation_screen_path": values.get("automation_screen_path") or "automation/screens",
+        "automation_error_path": values.get("automation_error_path") or "automation/errors",
+        "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),
+    }
+
+
+def get_settings_dict() -> dict[str, Any]:
+    return list_settings()["settings"]
+
+
+def default_ai_params() -> dict[str, Any]:
+    settings = get_settings_dict()
+    return {
+        "provider_id": settings.get("default_ai_provider_id"),
+        "model_id": settings.get("default_ai_model_id"),
+        "temperature": settings.get("default_ai_temperature", 0.1),
+    }
+
+
+def resolve_data_path(setting_key: str, fallback: str) -> Path:
+    settings = get_settings_dict()
+    relative = settings.get(setting_key) or fallback
+    normalized = normalize_relative_path(relative)
+    path = (DATA_DIR / normalized).resolve()
+    data_root = DATA_DIR.resolve()
+    if data_root != path and data_root not in path.parents:
+        raise HTTPException(status_code=400, detail="Configured path escapes data directory")
+    path.mkdir(parents=True, exist_ok=True)
+    return path
+
+
+def normalize_relative_path(value: str | None) -> str:
+    raw = (value or "").strip().replace("\\", "/")
+    if not raw:
+        return ""
+    path = Path(raw)
+    if path.is_absolute() or ".." in path.parts:
+        raise HTTPException(status_code=400, detail="Path must be relative and must not contain ..")
+    return "/".join(part for part in path.parts if part not in {"", "."})
+
+
+def optional_int(value: Any, default: int | None = None) -> int | None:
+    if value in (None, ""):
+        return default
+    try:
+        return int(value)
+    except (TypeError, ValueError):
+        return default
+
+
+def optional_float(value: Any, default: float | None = None) -> float | None:
+    if value in (None, ""):
+        return default
+    try:
+        return float(value)
+    except (TypeError, ValueError):
+        return default
+
+
+def parse_bool(value: Any) -> bool:
+    return str(value).lower() in {"1", "true", "yes", "on"}
+
+
+def serialize_value(value: Any) -> str:
+    if value is None:
+        return ""
+    if isinstance(value, bool):
+        return "1" if value else "0"
+    return str(value)

+ 13 - 1
deployment.md

@@ -56,6 +56,8 @@ cd frontend
 npm run dev -- --port 5173
 ```
 
+当前前端开发服务器默认监听 `0.0.0.0:5173`,同一局域网内其他设备可通过 `http://目标设备IP:5173` 访问。
+
 生产部署建议使用前端构建产物 `frontend\dist`,可由 Nginx、IIS 或其他静态文件服务承载。
 
 ## 5. 安装 smartmontools
@@ -146,7 +148,11 @@ npm run dev -- --host 0.0.0.0 --port 5173
 http://目标设备IP:5173
 ```
 
-如果前端和后端不在同一台机器,或后端不是 `127.0.0.1:8000`,请在启动或构建前设置:
+前端默认会把 API 地址解析为当前访问主机的 `8000` 端口。例如从局域网访问 `http://192.168.1.10:5173` 时,前端会默认请求 `http://192.168.1.10:8000`。
+
+如需局域网访问,请在 Windows 防火墙中放行 TCP 5173 端口。开发模式适合测试自动化操作;生产环境仍建议把 `frontend\dist` 交给 IIS、Nginx 或其他静态文件服务。
+
+如果前端和后端不在同一台机器,或后端不是当前主机的 `8000` 端口,请在启动或构建前设置:
 
 ```powershell
 $env:VITE_API_BASE="http://目标设备IP:8000"
@@ -167,6 +173,12 @@ nssm install WinMonitorApi
 - Startup directory:`C:\Apps\win_monitor\backend`
 - Arguments:`-m uvicorn app.main:app --host 127.0.0.1 --port 8000`
 
+如果需要让局域网设备访问后端,Arguments 改为:
+
+```text
+-m uvicorn app.main:app --host 0.0.0.0 --port 8000
+```
+
 保存后启动:
 
 ```powershell

+ 10 - 0
frontend/src/App.vue

@@ -8,6 +8,7 @@
         <el-menu-item index="services">Windows 服务</el-menu-item>
         <el-menu-item index="processes">Windows 进程</el-menu-item>
         <el-menu-item index="tags">标签管理</el-menu-item>
+        <el-menu-item index="settings">系统设置</el-menu-item>
         <el-sub-menu index="ai">
           <template #title>AI 配置</template>
           <el-menu-item index="ai-providers">AI 服务商管理</el-menu-item>
@@ -77,6 +78,10 @@
         <TagManager />
       </section>
 
+      <section v-if="activeView === 'settings'">
+        <SystemSettingsView ref="systemSettingsView" />
+      </section>
+
       <section v-if="activeView === 'ai-providers'">
         <AiProviderManager ref="aiProviderManager" />
       </section>
@@ -144,6 +149,7 @@ import AutomationWorkflowView from './components/AutomationWorkflowView.vue'
 import ItemTable from './components/ItemTable.vue'
 import SensorView from './components/SensorView.vue'
 import SmartView from './components/SmartView.vue'
+import SystemSettingsView from './components/SystemSettingsView.vue'
 import TagManager from './components/TagManager.vue'
 
 const activeView = ref('dashboard')
@@ -158,6 +164,7 @@ const pendingProcessTable = ref(null)
 const aiProviderManager = ref(null)
 const aiModelManager = ref(null)
 const aiTestView = ref(null)
+const systemSettingsView = ref(null)
 const automationActionView = ref(null)
 const automationWorkflowView = ref(null)
 const automationScreensView = ref(null)
@@ -169,6 +176,7 @@ const title = computed(() => ({
   services: 'Windows 服务',
   processes: 'Windows 进程',
   tags: '标签管理',
+  settings: '系统设置',
   'ai-providers': 'AI 服务商管理',
   'ai-models': 'AI 模型管理',
   'ai-test': 'AI 服务测试',
@@ -209,6 +217,7 @@ async function refreshCurrent() {
   aiProviderManager.value?.load()
   aiModelManager.value?.refreshAll()
   aiTestView.value?.loadOptions()
+  systemSettingsView.value?.load()
   automationActionView.value?.loadOptions()
   automationWorkflowView.value?.load()
   automationScreensView.value?.load()
@@ -231,6 +240,7 @@ async function runScan() {
 watch(activeView, async (view) => {
   if (view === 'dashboard') await loadDashboard()
   if (view === 'scans') await loadScans()
+  if (view === 'settings') await systemSettingsView.value?.load()
   if (view === 'automation-workflows') await automationWorkflowView.value?.load()
   if (view === 'automation-screens') await automationScreensView.value?.load()
   if (view === 'automation-errors') await automationErrorsView.value?.load()

+ 3 - 1
frontend/src/api.js

@@ -1,7 +1,9 @@
 import axios from 'axios'
 
+const defaultBaseUrl = `${window.location.protocol}//${window.location.hostname}:8000`
+
 export const api = axios.create({
-  baseURL: import.meta.env.VITE_API_BASE || 'http://127.0.0.1:8000',
+  baseURL: import.meta.env.VITE_API_BASE || defaultBaseUrl,
   timeout: 120000,
 })
 

+ 50 - 3
frontend/src/components/AutomationActionView.vue

@@ -10,7 +10,11 @@
             <el-option v-for="model in providerModels" :key="model.id" :label="model.display_name || model.name" :value="model.id" />
           </el-select>
           <el-input-number v-model="ai.temperature" :min="0" :max="2" :step="0.1" />
+          <el-button :loading="screenshotLoading" @click="captureScreenshot">截屏</el-button>
           <el-button type="primary" :loading="analyzing" @click="analyzeScreen">分析界面</el-button>
+          <el-switch v-model="autoScreenshot.enabled" active-text="自动截屏" inactive-text="不自动" />
+          <span class="muted">间隔</span>
+          <el-input-number v-model="autoScreenshot.interval" :min="1" :max="3600" />
         </div>
       </div>
 
@@ -116,13 +120,14 @@
 </template>
 
 <script setup>
-import { computed, nextTick, onMounted, reactive, ref } from 'vue'
+import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import { api } from '../api'
 
 const providers = ref([])
 const models = ref([])
 const analyzing = ref(false)
+const screenshotLoading = ref(false)
 const savingWorkflow = ref(false)
 const currentScreen = ref(null)
 const recording = ref(false)
@@ -142,6 +147,11 @@ const ai = reactive({
   model_id: null,
   temperature: 0.1,
 })
+const autoScreenshot = reactive({
+  enabled: false,
+  interval: 30,
+})
+let autoScreenshotTimer = null
 
 const enabledProviders = computed(() => providers.value.filter((item) => item.enabled))
 const providerModels = computed(() => models.value.filter((item) => item.provider_id === ai.provider_id))
@@ -155,11 +165,21 @@ const canvasStyle = computed(() => {
 })
 
 async function loadOptions() {
-  const [providerResult, modelResult] = await Promise.all([api.get('/api/ai/providers'), api.get('/api/ai/models')])
+  const [settingsResult, providerResult, modelResult] = await Promise.all([
+    api.get('/api/settings'),
+    api.get('/api/ai/providers'),
+    api.get('/api/ai/models'),
+  ])
   providers.value = providerResult.data.items
   models.value = modelResult.data.items
-  if (!ai.provider_id) ai.provider_id = enabledProviders.value[0]?.id || null
+  const settings = settingsResult.data.settings
+  ai.provider_id = settings.default_ai_provider_id || enabledProviders.value[0]?.id || null
+  ai.temperature = settings.default_ai_temperature ?? 0.1
+  autoScreenshot.enabled = Boolean(settings.automation_auto_screenshot_enabled)
+  autoScreenshot.interval = settings.automation_auto_screenshot_interval || 30
   selectDefaultModel()
+  if (settings.default_ai_model_id) ai.model_id = settings.default_ai_model_id
+  resetAutoScreenshotTimer()
 }
 
 function selectDefaultModel() {
@@ -193,6 +213,19 @@ async function analyzeScreen() {
   }
 }
 
+async function captureScreenshot(silent = false) {
+  screenshotLoading.value = true
+  try {
+    const { data } = await api.post('/api/automation/vision/screenshot', { save: true })
+    currentScreen.value = { ...data, id: null, elements: [] }
+    if (!silent) ElMessage.success('截屏已更新')
+  } catch (error) {
+    if (!silent) ElMessage.error(error.response?.data?.detail || '截屏失败')
+  } finally {
+    screenshotLoading.value = false
+  }
+}
+
 function markerStyle(element) {
   const left = currentScreen.value?.width ? (element.x / currentScreen.value.width) * 100 : element.x_percent
   const top = currentScreen.value?.height ? (element.y / currentScreen.value.height) * 100 : element.y_percent
@@ -362,5 +395,19 @@ async function finishRecording() {
 }
 
 defineExpose({ loadOptions })
+function resetAutoScreenshotTimer() {
+  if (autoScreenshotTimer) {
+    clearInterval(autoScreenshotTimer)
+    autoScreenshotTimer = null
+  }
+  if (autoScreenshot.enabled) {
+    autoScreenshotTimer = setInterval(() => captureScreenshot(true), autoScreenshot.interval * 1000)
+  }
+}
+
+watch(() => [autoScreenshot.enabled, autoScreenshot.interval], resetAutoScreenshotTimer)
 onMounted(loadOptions)
+onBeforeUnmount(() => {
+  if (autoScreenshotTimer) clearInterval(autoScreenshotTimer)
+})
 </script>

+ 263 - 101
frontend/src/components/AutomationWorkflowView.vue

@@ -1,97 +1,129 @@
 <template>
-  <div class="panel">
-    <div class="toolbar">
-      <div class="filters">
-        <el-button type="primary" @click="createEmpty">新建空工作流</el-button>
-        <el-button @click="load">刷新</el-button>
+  <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>
+      <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>
-
-    <el-table :data="workflows.items" border stripe>
-      <el-table-column prop="id" label="ID" width="80" />
-      <el-table-column prop="name" label="名称" min-width="180" />
-      <el-table-column prop="description" label="描述" min-width="260" show-overflow-tooltip />
-      <el-table-column prop="node_count" label="节点数" width="100" />
-      <el-table-column prop="updated_at" label="更新时间" min-width="180" />
-      <el-table-column label="操作" width="210" fixed="right">
-        <template #default="{ row }">
-          <el-button size="small" @click="openEdit(row)">编辑</el-button>
-          <el-button size="small" type="danger" @click="remove(row)">删除</el-button>
-        </template>
-      </el-table-column>
-    </el-table>
-
-    <el-dialog v-model="dialog" :title="form.id ? '编辑工作流' : '新建工作流'" width="860px">
-      <el-form label-width="90px">
-        <el-form-item label="名称">
-          <el-input v-model="form.name" />
-        </el-form-item>
-        <el-form-item label="描述">
-          <el-input v-model="form.description" type="textarea" :rows="2" />
-        </el-form-item>
-      </el-form>
 
       <div class="toolbar">
         <div class="filters">
-          <el-select v-model="newNodeType" style="width: 180px">
-            <el-option v-for="item in nodeTypes" :key="item.value" :label="item.label" :value="item.value" />
-          </el-select>
-          <el-button @click="addNode">添加节点</el-button>
+          <el-button v-for="item in nodeTypes" :key="item.value" @click="addNode(item.value)">{{ item.label }}</el-button>
         </div>
       </div>
 
-      <div class="node-editor">
+      <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, index) in form.nodes"
-          :key="index"
-          class="node-row"
-          draggable="true"
-          @dragstart="dragIndex = index"
-          @dragover.prevent
-          @drop="dropNode(index)"
+          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="node-order">{{ index + 1 }}</div>
-          <el-select v-model="node.node_type" style="width: 150px">
-            <el-option v-for="item in nodeTypes" :key="item.value" :label="item.label" :value="item.value" />
-          </el-select>
-          <el-input v-model="node.title" placeholder="节点标题" style="width: 190px" />
-          <el-input-number v-model="node.screen_id" :min="1" placeholder="界面 ID" />
-          <el-input
-            v-model="node.configText"
-            type="textarea"
-            :rows="2"
-            placeholder="节点 JSON 配置"
-            class="node-config"
-          />
-          <el-button type="danger" @click="form.nodes.splice(index, 1)">删除</el-button>
+          <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>
 
-      <template #footer>
-        <el-button @click="dialog = false">取消</el-button>
-        <el-button type="primary" @click="save">保存</el-button>
+    <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>
       </template>
-    </el-dialog>
+      <div v-else class="muted">点击画布中的节点进行编辑。开启连接节点后,依次点击源节点和目标节点建立连线。</div>
+
+      <div class="section-title">执行结果</div>
+      <pre class="workflow-run-output">{{ runOutput || '暂无执行结果' }}</pre>
+    </aside>
   </div>
 </template>
 
 <script setup>
-import { onMounted, reactive, ref } from 'vue'
+import { computed, onMounted, reactive, ref } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import { api } from '../api'
 
 const nodeTypes = [
-  { label: '鼠标操作', value: 'mouse' },
-  { label: '键盘操作', value: 'keyboard' },
-  { label: '键盘输入', value: 'text_input' },
-  { label: '启动程序', value: 'start_program' },
-  { label: '关闭程序', value: 'close_programs' },
+  { 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 workflows = ref({ items: [] })
-const dialog = ref(false)
-const newNodeType = ref('mouse')
-const dragIndex = ref(null)
+const canvasRef = ref(null)
+const selectedNodeKey = ref(null)
+const connectMode = ref(false)
+const connectSourceKey = ref(null)
+const running = ref(false)
+const runOutput = ref('')
 const form = reactive({
   id: null,
   name: '',
@@ -99,6 +131,26 @@ const form = reactive({
   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
+})
+
 async function load() {
   const { data } = await api.get('/api/automation/workflows')
   workflows.value = data
@@ -106,15 +158,15 @@ async function load() {
 
 function resetForm() {
   form.id = null
-  form.name = ''
+  form.name = `空工作流 ${new Date().toLocaleString()}`
   form.description = ''
   form.nodes = []
+  selectedNodeKey.value = null
+  runOutput.value = ''
 }
 
 function createEmpty() {
   resetForm()
-  form.name = `空工作流 ${new Date().toLocaleString()}`
-  dialog.value = true
 }
 
 async function openEdit(row) {
@@ -122,29 +174,125 @@ async function openEdit(row) {
   form.id = data.id
   form.name = data.name
   form.description = data.description || ''
-  form.nodes = (data.nodes || []).map((node) => ({
+  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,
-    title: node.title || '',
-    configText: JSON.stringify(node.config || {}, null, 2),
-  }))
-  dialog.value = true
+    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() {
-  form.nodes.push({
-    node_type: newNodeType.value,
+function addNode(type) {
+  const index = form.nodes.length
+  const node = {
+    node_key: `node_${Date.now()}_${index}`,
+    node_type: type,
     screen_id: null,
-    title: nodeTypes.find((item) => item.value === newNodeType.value)?.label || newNodeType.value,
-    configText: '{}',
-  })
+    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 dropNode(targetIndex) {
-  if (dragIndex.value === null || dragIndex.value === targetIndex) return
-  const [node] = form.nodes.splice(dragIndex.value, 1)
-  form.nodes.splice(targetIndex, 0, node)
-  dragIndex.value = null
+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() {
@@ -154,31 +302,45 @@ async function save() {
   }
   let nodes
   try {
-    nodes = form.nodes.map((node) => ({
-      node_type: node.node_type,
-      screen_id: node.screen_id || null,
-      title: node.title,
-      config: JSON.parse(node.configText || '{}'),
-    }))
-  } catch {
-    ElMessage.error('节点配置不是合法 JSON')
+    nodes = buildPayloadNodes()
+  } catch (error) {
+    ElMessage.error(error.message)
     return
   }
   const payload = { name: form.name.trim(), description: form.description, nodes }
-  if (form.id) await api.put(`/api/automation/workflows/${form.id}`, payload)
-  else await api.post('/api/automation/workflows', payload)
-  dialog.value = false
+  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`, {})
+    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
+  }
+}
+
 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()
 }
 
 defineExpose({ load })
-onMounted(load)
+onMounted(async () => {
+  await load()
+  resetForm()
+})
 </script>

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

@@ -0,0 +1,108 @@
+<template>
+  <div class="panel">
+    <div class="toolbar">
+      <div class="filters">
+        <el-button type="primary" :loading="saving" @click="save">保存设置</el-button>
+        <el-button @click="load">刷新</el-button>
+      </div>
+    </div>
+
+    <el-form label-width="190px" class="settings-form">
+      <div class="section-title">AI 默认参数</div>
+      <el-form-item label="默认 AI 服务商">
+        <el-select v-model="form.default_ai_provider_id" clearable placeholder="请选择" style="width: 360px" @change="selectDefaultModel">
+          <el-option v-for="provider in enabledProviders" :key="provider.id" :label="provider.name" :value="provider.id" />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="默认 AI 模型">
+        <el-select v-model="form.default_ai_model_id" clearable placeholder="请选择" style="width: 360px">
+          <el-option v-for="model in providerModels" :key="model.id" :label="model.display_name || model.name" :value="model.id" />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="默认温度">
+        <el-input-number v-model="form.default_ai_temperature" :min="0" :max="2" :step="0.1" />
+      </el-form-item>
+
+      <div class="section-title">文件保存路径</div>
+      <el-alert type="info" show-icon :closable="false" title="路径保存为相对路径,并解析到后端 backend/data 目录下。" />
+      <el-form-item label="自动化文件根路径">
+        <el-input v-model="form.automation_file_root" placeholder="automation" />
+      </el-form-item>
+      <el-form-item label="已识别界面截图路径">
+        <el-input v-model="form.automation_screen_path" placeholder="automation/screens" />
+      </el-form-item>
+      <el-form-item label="错误截图路径">
+        <el-input v-model="form.automation_error_path" placeholder="automation/errors" />
+      </el-form-item>
+      <el-form-item label="临时截图路径">
+        <el-input v-model="form.automation_runtime_path" placeholder="automation/runtime" />
+      </el-form-item>
+
+      <div class="section-title">自动化操作页</div>
+      <el-form-item label="默认自动截屏">
+        <el-switch v-model="form.automation_auto_screenshot_enabled" active-text="开启" inactive-text="关闭" />
+      </el-form-item>
+      <el-form-item label="自动截屏间隔秒数">
+        <el-input-number v-model="form.automation_auto_screenshot_interval" :min="1" :max="3600" />
+      </el-form-item>
+    </el-form>
+  </div>
+</template>
+
+<script setup>
+import { computed, onMounted, reactive, ref } from 'vue'
+import { ElMessage } from 'element-plus'
+import { api } from '../api'
+
+const providers = ref([])
+const models = ref([])
+const saving = ref(false)
+const form = reactive({
+  default_ai_provider_id: null,
+  default_ai_model_id: null,
+  default_ai_temperature: 0.1,
+  automation_file_root: 'automation',
+  automation_screen_path: 'automation/screens',
+  automation_error_path: 'automation/errors',
+  automation_runtime_path: 'automation/runtime',
+  automation_auto_screenshot_enabled: false,
+  automation_auto_screenshot_interval: 30,
+})
+
+const enabledProviders = computed(() => providers.value.filter((item) => item.enabled))
+const providerModels = computed(() => models.value.filter((item) => item.provider_id === form.default_ai_provider_id))
+
+async function load() {
+  const [settingsResult, providerResult, modelResult] = await Promise.all([
+    api.get('/api/settings'),
+    api.get('/api/ai/providers'),
+    api.get('/api/ai/models'),
+  ])
+  providers.value = providerResult.data.items
+  models.value = modelResult.data.items
+  Object.assign(form, settingsResult.data.settings)
+}
+
+function selectDefaultModel() {
+  const available = providerModels.value
+  if (!available.some((item) => item.id === form.default_ai_model_id)) {
+    form.default_ai_model_id = available.find((item) => item.is_default)?.id || available[0]?.id || null
+  }
+}
+
+async function save() {
+  saving.value = true
+  try {
+    await api.put('/api/settings', form)
+    ElMessage.success('系统设置已保存')
+    await load()
+  } catch (error) {
+    ElMessage.error(error.response?.data?.detail || '保存设置失败')
+  } finally {
+    saving.value = false
+  }
+}
+
+defineExpose({ load })
+onMounted(load)
+</script>

+ 104 - 2
frontend/src/styles.css

@@ -314,6 +314,106 @@ body {
   background: #111827;
 }
 
+.settings-form {
+  max-width: 860px;
+}
+
+.workflow-page {
+  display: grid;
+  grid-template-columns: 320px minmax(620px, 1fr) 340px;
+  gap: 14px;
+  min-height: 720px;
+}
+
+.workflow-list,
+.workflow-canvas-panel,
+.workflow-inspector {
+  min-width: 0;
+}
+
+.workflow-canvas {
+  position: relative;
+  height: 620px;
+  overflow: auto;
+  border: 1px solid #d1d5db;
+  border-radius: 8px;
+  background:
+    linear-gradient(#eef2f7 1px, transparent 1px),
+    linear-gradient(90deg, #eef2f7 1px, transparent 1px),
+    #f8fafc;
+  background-size: 24px 24px;
+}
+
+.workflow-lines {
+  position: absolute;
+  inset: 0;
+  width: 1800px;
+  height: 1200px;
+  pointer-events: none;
+}
+
+.workflow-lines line {
+  stroke: #2563eb;
+  stroke-width: 2;
+}
+
+.workflow-lines marker path {
+  fill: #2563eb;
+}
+
+.workflow-node {
+  position: absolute;
+  width: 220px;
+  min-height: 84px;
+  padding: 10px 12px;
+  border: 1px solid #cbd5e1;
+  border-radius: 8px;
+  background: #fff;
+  box-shadow: 0 8px 20px rgb(15 23 42 / 10%);
+  cursor: move;
+  user-select: none;
+}
+
+.workflow-node.selected {
+  border-color: #2563eb;
+  box-shadow: 0 0 0 3px rgb(37 99 235 / 18%);
+}
+
+.workflow-node.connecting {
+  border-color: #f59e0b;
+}
+
+.workflow-node-type {
+  color: #2563eb;
+  font-size: 12px;
+  font-weight: 700;
+}
+
+.workflow-node-title {
+  margin-top: 6px;
+  font-weight: 700;
+  word-break: break-word;
+}
+
+.workflow-node-meta {
+  margin-top: 6px;
+  color: #64748b;
+  font-size: 12px;
+}
+
+.workflow-run-output {
+  min-height: 220px;
+  max-height: 360px;
+  overflow: auto;
+  padding: 10px;
+  border: 1px solid #e5e7eb;
+  border-radius: 6px;
+  background: #0f172a;
+  color: #e5e7eb;
+  white-space: pre-wrap;
+  word-break: break-word;
+}
+
 @media (max-width: 760px) {
   .app-shell {
     display: block;
@@ -329,7 +429,8 @@ body {
 
   .automation-workspace,
   .screen-detail-layout,
-  .error-images {
+  .error-images,
+  .workflow-page {
     grid-template-columns: 1fr;
   }
 
@@ -339,7 +440,8 @@ body {
 }
 
 @media (max-width: 1180px) {
-  .automation-workspace {
+  .automation-workspace,
+  .workflow-page {
     grid-template-columns: 1fr;
   }
 }

+ 1 - 1
frontend/vite.config.js

@@ -4,7 +4,7 @@ import vue from '@vitejs/plugin-vue'
 export default defineConfig({
   plugins: [vue()],
   server: {
-    host: '127.0.0.1',
+    host: '0.0.0.0',
     port: 5173,
   },
 })

+ 6 - 0
task.md

@@ -51,6 +51,11 @@
 - [x] 扩展 AI 服务模块,支持 OpenAI/OpenAI 兼容和 Gemini 的图片输入视觉调用
 - [x] 增加 AI 视觉识别界面、界面对比校验、自动化动作执行、工作流管理、错误记录查询接口
 - [x] 增加前端自动化菜单和自动化操作、自动化工作流、已识别界面、自动化错误记录页面
+- [x] 增加系统设置表和前端系统设置页面,支持全局默认 AI 服务商、模型、温度和自动化文件相对保存路径
+- [x] 自动化操作页面增加手动截屏、自动截屏开关和截屏间隔配置
+- [x] 自动化工作流页面改为可拖动、可连线的大画布编辑,并支持点击执行工作流
+- [x] 后端增加按工作流 ID 执行完整工作流接口
+- [x] 前端开发服务和默认 API 地址支持局域网访问,并更新部署文档
 
 ## 进度日志
 
@@ -67,3 +72,4 @@
 - 2026-05-10:AI 提示词和导入格式增加 tags 字段;后端导入时支持自动新增缺失标签并更新服务/进程标签关联,前端确认弹窗展示标签变更。
 - 2026-05-10:开始并完成 Windows 自动化操作模块,新增关机/重启、程序启动/关闭、屏幕截图、pyautogui 鼠标和键盘操作接口;更新后端依赖和接口文档。
 - 2026-05-10:完成 AI 视觉自动化基础功能,支持截图识别并保存界面元素、动作前界面对比校验、错误记录、自动化工作流保存与节点管理;前端增加自动化四个菜单页面,并验证后端编译、前端构建和页面渲染。
+- 2026-05-11:提交 AI 自动化基础代码基线;新增系统设置、自动化截图刷新、工作流画布编辑和后端整条工作流执行接口;前端支持局域网访问默认 API 地址。