automation_service.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635
  1. from __future__ import annotations
  2. import base64
  3. import json
  4. import mimetypes
  5. import time
  6. from pathlib import Path
  7. from typing import Any
  8. import psutil
  9. from fastapi import HTTPException
  10. from . import ai_service, windows_automation
  11. from .database import DATA_DIR, get_db
  12. from .scanner import now_iso
  13. from .schemas import (
  14. AutomationKeyboardActionRequest,
  15. AutomationMouseActionRequest,
  16. AutomationStartProgramRequest,
  17. AutomationTextInputRequest,
  18. AutomationVisionAnalyzeRequest,
  19. AutomationWorkflowSaveRequest,
  20. )
  21. AUTOMATION_DIR = DATA_DIR / "automation"
  22. SCREEN_DIR = AUTOMATION_DIR / "screens"
  23. ERROR_DIR = AUTOMATION_DIR / "errors"
  24. RUNTIME_DIR = AUTOMATION_DIR / "runtime"
  25. OPENED_PROCESS_IDS: set[int] = set()
  26. SCREEN_ANALYZE_PROMPT = """请作为 AI 视觉自动化助手分析这张 Windows 屏幕截图,并严格只输出 JSON 对象。
  27. 输出字段:
  28. - interface_name:界面名称,简洁中文。
  29. - description:界面描述,说明当前主要窗口或桌面内容。
  30. - is_windows_desktop:boolean,截图是否处于 Windows 桌面。
  31. - is_browser_webpage:boolean,截图是否为浏览器中的网页。
  32. - elements:可操作元素数组。
  33. 元素字段:
  34. - name:元素名称。
  35. - x_percent:元素中心点 X 相对整张截图宽度的百分比,范围 0-100,可以保留 2 位小数。
  36. - y_percent:元素中心点 Y 相对整张截图高度的百分比,范围 0-100,可以保留 2 位小数。
  37. 判断规则:
  38. 1. 如果截图位于 Windows 桌面,请识别桌面图标、开始菜单入口、任务栏应用、托盘区域等可操作元素。
  39. 2. 如果不是 Windows 桌面,也就是存在打开的前台窗口或全屏界面,只识别该前台窗口内的可操作元素,不要识别被遮挡的桌面元素。
  40. 3. 不要输出 Markdown,不要解释,只输出 JSON。
  41. """
  42. SCREEN_COMPARE_PROMPT = """请作为 AI 视觉自动化校验器判断两张截图是否处于同一个目标界面。
  43. 图片1是当前实际屏幕截图。图片2是数据库中保存的目标界面截图。
  44. 目标界面描述如下:
  45. {description}
  46. 请严格只输出 JSON 对象,字段为:
  47. - is_match:boolean,图片1是否仍然处于目标界面。
  48. - similarity:0 到 1 的数值,表示相似度。
  49. - reason:简短中文原因。
  50. 判断时可以允许小的光标位置、时间、列表内容滚动或轻微刷新差异,但如果前台窗口、网页、弹窗、主要页面或应用已经不同,应返回 false。
  51. """
  52. def ensure_dirs() -> None:
  53. """确保自动化截图、错误截图和运行时目录存在。"""
  54. for path in [SCREEN_DIR, ERROR_DIR, RUNTIME_DIR]:
  55. path.mkdir(parents=True, exist_ok=True)
  56. def image_to_base64(path: str | Path) -> dict[str, str]:
  57. """读取图片文件并转为 AI 服务可接收的 base64 结构。"""
  58. file_path = Path(path)
  59. mime_type = mimetypes.guess_type(file_path.name)[0] or "image/png"
  60. return {
  61. "base64": base64.b64encode(file_path.read_bytes()).decode("ascii"),
  62. "mime_type": mime_type,
  63. }
  64. def json_from_ai(content: str) -> dict[str, Any]:
  65. """从 AI 输出中提取 JSON 对象,兼容模型误加代码块的情况。"""
  66. parsed = json.loads(ai_service.extract_json_text(content))
  67. if not isinstance(parsed, dict):
  68. raise ValueError("AI output must be a JSON object")
  69. return parsed
  70. def take_screenshot_file(folder: Path, prefix: str) -> dict[str, Any]:
  71. """截取当前屏幕并保存为 PNG 文件,同时返回 base64 和分辨率信息。"""
  72. ensure_dirs()
  73. filename = f"{prefix}_{int(time.time() * 1000)}.png"
  74. path = folder / filename
  75. result = windows_automation.take_screenshot(str(path), include_base64=True)
  76. result["path"] = str(path)
  77. return result
  78. def analyze_screen(payload: AutomationVisionAnalyzeRequest) -> dict[str, Any]:
  79. """截图当前屏幕,调用 AI 识别界面和可操作元素,并保存识别结果。"""
  80. screenshot = take_screenshot_file(SCREEN_DIR, "screen")
  81. image = image_to_base64(screenshot["path"])
  82. ai_result = ai_service.chat_with_images(
  83. payload.provider_id,
  84. payload.model_id,
  85. SCREEN_ANALYZE_PROMPT,
  86. [image],
  87. payload.temperature,
  88. )
  89. try:
  90. parsed = json_from_ai(ai_result["content"])
  91. except (json.JSONDecodeError, ValueError) as exc:
  92. raise HTTPException(status_code=502, detail=f"AI vision output is not valid JSON: {exc}") from exc
  93. width = int(screenshot["width"])
  94. height = int(screenshot["height"])
  95. elements = normalize_elements(parsed.get("elements"), width, height)
  96. now = now_iso()
  97. with get_db() as conn:
  98. cursor = conn.execute(
  99. """
  100. INSERT INTO automation_screens (
  101. interface_name, description, image_path, width, height,
  102. is_windows_desktop, is_browser_webpage, raw_ai_json, created_at, updated_at
  103. )
  104. VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  105. """,
  106. (
  107. str(parsed.get("interface_name") or "未命名界面")[:160],
  108. parsed.get("description"),
  109. screenshot["path"],
  110. width,
  111. height,
  112. 1 if bool(parsed.get("is_windows_desktop")) else 0,
  113. 1 if bool(parsed.get("is_browser_webpage")) else 0,
  114. json.dumps(parsed, ensure_ascii=False),
  115. now,
  116. now,
  117. ),
  118. )
  119. screen_id = cursor.lastrowid
  120. for index, element in enumerate(elements, start=1):
  121. conn.execute(
  122. """
  123. INSERT INTO automation_screen_elements (
  124. screen_id, element_index, name, x_percent, y_percent, x, y, raw_json, created_at
  125. )
  126. VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
  127. """,
  128. (
  129. screen_id,
  130. index,
  131. element["name"],
  132. element["x_percent"],
  133. element["y_percent"],
  134. element["x"],
  135. element["y"],
  136. json.dumps(element.get("raw") or element, ensure_ascii=False),
  137. now,
  138. ),
  139. )
  140. detail = get_screen(screen_id)
  141. detail["image_base64"] = screenshot["image_base64"]
  142. detail["mime_type"] = screenshot["mime_type"]
  143. detail["ai_raw_content"] = ai_result["content"]
  144. return detail
  145. def normalize_elements(raw_elements: Any, width: int, height: int) -> list[dict[str, Any]]:
  146. """把 AI 返回的百分比坐标转换为截图像素坐标。"""
  147. if not isinstance(raw_elements, list):
  148. return []
  149. result = []
  150. for item in raw_elements:
  151. if not isinstance(item, dict):
  152. continue
  153. name = str(item.get("name") or f"元素 {len(result) + 1}")[:160]
  154. x_percent = normalize_percent(item.get("x_percent"))
  155. y_percent = normalize_percent(item.get("y_percent"))
  156. x = round(width * x_percent / 100)
  157. y = round(height * y_percent / 100)
  158. result.append(
  159. {
  160. "name": name,
  161. "x_percent": x_percent,
  162. "y_percent": y_percent,
  163. "x": max(0, min(width - 1, x)),
  164. "y": max(0, min(height - 1, y)),
  165. "raw": item,
  166. }
  167. )
  168. return result
  169. def normalize_percent(value: Any) -> float:
  170. """规范化百分比数值,兼容模型偶尔输出 0-1 小数的情况。"""
  171. try:
  172. number = float(value)
  173. except (TypeError, ValueError):
  174. return 0.0
  175. if 0 <= number <= 1:
  176. number *= 100
  177. return max(0.0, min(100.0, round(number, 2)))
  178. def list_screens(page: int, page_size: int) -> dict[str, Any]:
  179. """分页查询已识别界面列表。"""
  180. offset = (page - 1) * page_size
  181. with get_db() as conn:
  182. total = conn.execute("SELECT COUNT(*) AS total FROM automation_screens").fetchone()["total"]
  183. rows = conn.execute(
  184. """
  185. SELECT s.*, COUNT(e.id) AS element_count
  186. FROM automation_screens s
  187. LEFT JOIN automation_screen_elements e ON e.screen_id = s.id
  188. GROUP BY s.id
  189. ORDER BY s.created_at DESC
  190. LIMIT ? OFFSET ?
  191. """,
  192. (page_size, offset),
  193. ).fetchall()
  194. return {"items": [public_screen(row) for row in rows], "total": total, "page": page, "page_size": page_size}
  195. def get_screen(screen_id: int, include_image: bool = False) -> dict[str, Any]:
  196. """读取单个已识别界面的详情和可操作元素。"""
  197. with get_db() as conn:
  198. screen = conn.execute("SELECT * FROM automation_screens WHERE id = ?", (screen_id,)).fetchone()
  199. if not screen:
  200. raise HTTPException(status_code=404, detail="Automation screen not found")
  201. elements = conn.execute(
  202. "SELECT * FROM automation_screen_elements WHERE screen_id = ? ORDER BY element_index ASC",
  203. (screen_id,),
  204. ).fetchall()
  205. item = public_screen(screen)
  206. item["elements"] = [public_element(row) for row in elements]
  207. if include_image and Path(item["image_path"]).exists():
  208. image = image_to_base64(item["image_path"])
  209. item["image_base64"] = image["base64"]
  210. item["mime_type"] = image["mime_type"]
  211. return item
  212. def delete_screen(screen_id: int) -> dict[str, Any]:
  213. """删除已识别界面记录,图片文件保留用于审计。"""
  214. with get_db() as conn:
  215. cursor = conn.execute("DELETE FROM automation_screens WHERE id = ?", (screen_id,))
  216. if cursor.rowcount == 0:
  217. raise HTTPException(status_code=404, detail="Automation screen not found")
  218. return {"deleted": cursor.rowcount}
  219. def public_screen(row: dict[str, Any]) -> dict[str, Any]:
  220. """把数据库中的界面行转换为接口返回格式。"""
  221. item = dict(row)
  222. item["is_windows_desktop"] = bool(item.get("is_windows_desktop"))
  223. item["is_browser_webpage"] = bool(item.get("is_browser_webpage"))
  224. return item
  225. def public_element(row: dict[str, Any]) -> dict[str, Any]:
  226. """把数据库中的元素行转换为接口返回格式。"""
  227. item = dict(row)
  228. return item
  229. def process_snapshot() -> dict[int, dict[str, Any]]:
  230. """获取当前进程快照,只用于自动化动作前后对比,不写入进程扫描表。"""
  231. snapshot: dict[int, dict[str, Any]] = {}
  232. for proc in psutil.process_iter(["pid", "name", "exe"]):
  233. try:
  234. snapshot[int(proc.info["pid"])] = {
  235. "pid": int(proc.info["pid"]),
  236. "name": proc.info.get("name"),
  237. "exe": proc.info.get("exe"),
  238. }
  239. except (psutil.Error, OSError, TypeError, ValueError):
  240. continue
  241. return snapshot
  242. def diff_new_processes(before: dict[int, dict[str, Any]], after: dict[int, dict[str, Any]]) -> list[dict[str, Any]]:
  243. """比较动作前后的进程快照,找出本次自动化动作新增的进程。"""
  244. new_items = [after[pid] for pid in sorted(set(after) - set(before))]
  245. OPENED_PROCESS_IDS.update(item["pid"] for item in new_items)
  246. return new_items
  247. def validate_screen_before_action(
  248. screen_id: int | None,
  249. provider_id: int | None,
  250. model_id: int | None,
  251. temperature: float,
  252. action_type: str,
  253. workflow_id: int | None = None,
  254. node_id: int | None = None,
  255. ) -> dict[str, Any] | None:
  256. """如果动作绑定了界面 ID,则先用 AI 判断当前屏幕是否仍处于目标界面。"""
  257. if screen_id is None:
  258. return None
  259. if provider_id is None or model_id is None:
  260. raise HTTPException(status_code=400, detail="provider_id and model_id are required when screen_id is provided")
  261. target = get_screen(screen_id)
  262. current = take_screenshot_file(RUNTIME_DIR, "compare_current")
  263. prompt = SCREEN_COMPARE_PROMPT.replace("{description}", target.get("description") or target.get("interface_name") or "")
  264. ai_result = ai_service.chat_with_images(
  265. provider_id,
  266. model_id,
  267. prompt,
  268. [image_to_base64(current["path"]), image_to_base64(target["image_path"])],
  269. temperature,
  270. )
  271. try:
  272. parsed = json_from_ai(ai_result["content"])
  273. except (json.JSONDecodeError, ValueError) as exc:
  274. raise HTTPException(status_code=502, detail=f"AI compare output is not valid JSON: {exc}") from exc
  275. is_match = bool(parsed.get("is_match"))
  276. similarity = safe_float(parsed.get("similarity"))
  277. if not is_match:
  278. error = record_error(
  279. action_type=action_type,
  280. message=str(parsed.get("reason") or "界面对比失败,当前屏幕不是目标界面"),
  281. screen_id=screen_id,
  282. workflow_id=workflow_id,
  283. node_id=node_id,
  284. similarity=similarity,
  285. expected_image_path=target["image_path"],
  286. actual_image_path=current["path"],
  287. compare_result=parsed,
  288. )
  289. raise HTTPException(status_code=409, detail={"message": error["message"], "error": error})
  290. return parsed
  291. def safe_float(value: Any) -> float | None:
  292. """安全转换浮点数。"""
  293. try:
  294. return float(value)
  295. except (TypeError, ValueError):
  296. return None
  297. def record_error(
  298. action_type: str,
  299. message: str,
  300. screen_id: int | None = None,
  301. workflow_id: int | None = None,
  302. node_id: int | None = None,
  303. similarity: float | None = None,
  304. expected_image_path: str | None = None,
  305. actual_image_path: str | None = None,
  306. compare_result: dict[str, Any] | None = None,
  307. ) -> dict[str, Any]:
  308. """保存自动化错误记录,便于在错误记录菜单中回看。"""
  309. now = now_iso()
  310. with get_db() as conn:
  311. cursor = conn.execute(
  312. """
  313. INSERT INTO automation_errors (
  314. workflow_id, node_id, screen_id, action_type, message, similarity,
  315. expected_image_path, actual_image_path, compare_result_json, created_at
  316. )
  317. VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  318. """,
  319. (
  320. workflow_id,
  321. node_id,
  322. screen_id,
  323. action_type,
  324. message,
  325. similarity,
  326. expected_image_path,
  327. actual_image_path,
  328. json.dumps(compare_result or {}, ensure_ascii=False),
  329. now,
  330. ),
  331. )
  332. row = conn.execute("SELECT * FROM automation_errors WHERE id = ?", (cursor.lastrowid,)).fetchone()
  333. return public_error(row)
  334. def execute_mouse_action(payload: AutomationMouseActionRequest) -> dict[str, Any]:
  335. """执行鼠标点击类动作,并记录动作前后新增进程。"""
  336. before = process_snapshot()
  337. compare = validate_screen_before_action(
  338. payload.screen_id,
  339. payload.provider_id,
  340. payload.model_id,
  341. payload.temperature,
  342. f"mouse_{payload.mouse_action}",
  343. payload.workflow_id,
  344. payload.node_id,
  345. )
  346. action_map = {"click": "click", "double_click": "double_click", "right_click": "right_click"}
  347. result = windows_automation.mouse_action(action_map[payload.mouse_action], x=payload.x, y=payload.y)
  348. time.sleep(0.5)
  349. new_processes = diff_new_processes(before, process_snapshot())
  350. return {"result": result, "compare": compare, "new_processes": new_processes}
  351. def execute_keyboard_action(payload: AutomationKeyboardActionRequest) -> dict[str, Any]:
  352. """执行键盘组合键动作,并记录动作前后新增进程。"""
  353. before = process_snapshot()
  354. compare = validate_screen_before_action(
  355. payload.screen_id,
  356. payload.provider_id,
  357. payload.model_id,
  358. payload.temperature,
  359. "keyboard",
  360. payload.workflow_id,
  361. payload.node_id,
  362. )
  363. result = windows_automation.keyboard_action("hotkey" if len(payload.keys) > 1 else "press", key=payload.keys[0], keys=payload.keys)
  364. time.sleep(0.5)
  365. new_processes = diff_new_processes(before, process_snapshot())
  366. return {"result": result, "compare": compare, "new_processes": new_processes}
  367. def execute_text_input(payload: AutomationTextInputRequest) -> dict[str, Any]:
  368. """通过剪贴板粘贴文本,避免直接模拟按键时中文输入不稳定。"""
  369. before = process_snapshot()
  370. compare = validate_screen_before_action(
  371. payload.screen_id,
  372. payload.provider_id,
  373. payload.model_id,
  374. payload.temperature,
  375. "text_input",
  376. payload.workflow_id,
  377. payload.node_id,
  378. )
  379. try:
  380. import pyperclip
  381. except ImportError as exc:
  382. raise HTTPException(status_code=500, detail="pyperclip is not installed") from exc
  383. pyperclip.copy(payload.text)
  384. result = windows_automation.keyboard_action("hotkey", keys=["ctrl", "v"])
  385. time.sleep(0.5)
  386. new_processes = diff_new_processes(before, process_snapshot())
  387. return {"result": result, "compare": compare, "new_processes": new_processes}
  388. def execute_start_program(payload: AutomationStartProgramRequest) -> dict[str, Any]:
  389. """启动程序,并把动作后新增的进程记录为本次自动化打开的程序。"""
  390. before = process_snapshot()
  391. compare = validate_screen_before_action(
  392. payload.screen_id,
  393. payload.provider_id,
  394. payload.model_id,
  395. payload.temperature,
  396. "start_program",
  397. payload.workflow_id,
  398. payload.node_id,
  399. )
  400. result = windows_automation.start_program(payload.command, payload.cwd, payload.shell)
  401. time.sleep(1)
  402. new_processes = diff_new_processes(before, process_snapshot())
  403. if result.get("pid"):
  404. OPENED_PROCESS_IDS.add(int(result["pid"]))
  405. return {"result": result, "compare": compare, "new_processes": new_processes}
  406. def close_opened_programs(pids: list[int] | None = None) -> dict[str, Any]:
  407. """关闭本次自动化过程中记录的新进程。"""
  408. targets = sorted(set(pids or list(OPENED_PROCESS_IDS)))
  409. closed = []
  410. for pid in targets:
  411. try:
  412. closed.append(windows_automation.stop_program(pid=pid))
  413. OPENED_PROCESS_IDS.discard(pid)
  414. except HTTPException as exc:
  415. closed.append({"pid": pid, "error": exc.detail})
  416. return {"action": "close_opened_programs", "items": closed}
  417. def save_workflow(payload: AutomationWorkflowSaveRequest) -> dict[str, Any]:
  418. """保存前端记录或手动编辑的自动化工作流和节点。"""
  419. now = now_iso()
  420. raw_json = payload.model_dump()
  421. with get_db() as conn:
  422. cursor = conn.execute(
  423. """
  424. INSERT INTO automation_workflows (name, description, raw_json, created_at, updated_at)
  425. VALUES (?, ?, ?, ?, ?)
  426. """,
  427. (payload.name.strip(), payload.description, json.dumps(raw_json, ensure_ascii=False), now, now),
  428. )
  429. workflow_id = cursor.lastrowid
  430. insert_workflow_nodes(conn, workflow_id, payload.nodes, now)
  431. return get_workflow(workflow_id)
  432. def update_workflow(workflow_id: int, payload: AutomationWorkflowSaveRequest) -> dict[str, Any]:
  433. """更新工作流基础信息和节点列表。"""
  434. now = now_iso()
  435. raw_json = payload.model_dump()
  436. with get_db() as conn:
  437. existing = conn.execute("SELECT id FROM automation_workflows WHERE id = ?", (workflow_id,)).fetchone()
  438. if not existing:
  439. raise HTTPException(status_code=404, detail="Automation workflow not found")
  440. conn.execute(
  441. """
  442. UPDATE automation_workflows
  443. SET name = ?, description = ?, raw_json = ?, updated_at = ?
  444. WHERE id = ?
  445. """,
  446. (payload.name.strip(), payload.description, json.dumps(raw_json, ensure_ascii=False), now, workflow_id),
  447. )
  448. conn.execute("DELETE FROM automation_workflow_nodes WHERE workflow_id = ?", (workflow_id,))
  449. insert_workflow_nodes(conn, workflow_id, payload.nodes, now)
  450. return get_workflow(workflow_id)
  451. def insert_workflow_nodes(conn, workflow_id: int, nodes: list[Any], now: str) -> None:
  452. """批量写入工作流节点。"""
  453. for index, node in enumerate(nodes, start=1):
  454. conn.execute(
  455. """
  456. INSERT INTO automation_workflow_nodes (
  457. workflow_id, node_index, node_type, screen_id, title, config_json, created_at, updated_at
  458. )
  459. VALUES (?, ?, ?, ?, ?, ?, ?, ?)
  460. """,
  461. (
  462. workflow_id,
  463. index,
  464. node.node_type,
  465. node.screen_id,
  466. node.title,
  467. json.dumps(node.config, ensure_ascii=False),
  468. now,
  469. now,
  470. ),
  471. )
  472. def list_workflows(page: int, page_size: int) -> dict[str, Any]:
  473. """分页查询自动化工作流列表。"""
  474. offset = (page - 1) * page_size
  475. with get_db() as conn:
  476. total = conn.execute("SELECT COUNT(*) AS total FROM automation_workflows").fetchone()["total"]
  477. rows = conn.execute(
  478. """
  479. SELECT w.*, COUNT(n.id) AS node_count
  480. FROM automation_workflows w
  481. LEFT JOIN automation_workflow_nodes n ON n.workflow_id = w.id
  482. GROUP BY w.id
  483. ORDER BY w.updated_at DESC
  484. LIMIT ? OFFSET ?
  485. """,
  486. (page_size, offset),
  487. ).fetchall()
  488. return {"items": rows, "total": total, "page": page, "page_size": page_size}
  489. def get_workflow(workflow_id: int) -> dict[str, Any]:
  490. """读取工作流详情和节点列表。"""
  491. with get_db() as conn:
  492. workflow = conn.execute("SELECT * FROM automation_workflows WHERE id = ?", (workflow_id,)).fetchone()
  493. if not workflow:
  494. raise HTTPException(status_code=404, detail="Automation workflow not found")
  495. nodes = conn.execute(
  496. "SELECT * FROM automation_workflow_nodes WHERE workflow_id = ? ORDER BY node_index ASC",
  497. (workflow_id,),
  498. ).fetchall()
  499. item = dict(workflow)
  500. item["nodes"] = [public_node(row) for row in nodes]
  501. return item
  502. def delete_workflow(workflow_id: int) -> dict[str, Any]:
  503. """删除工作流及其节点。"""
  504. with get_db() as conn:
  505. cursor = conn.execute("DELETE FROM automation_workflows WHERE id = ?", (workflow_id,))
  506. if cursor.rowcount == 0:
  507. raise HTTPException(status_code=404, detail="Automation workflow not found")
  508. return {"deleted": cursor.rowcount}
  509. def public_node(row: dict[str, Any]) -> dict[str, Any]:
  510. """把工作流节点行转换为接口返回格式。"""
  511. item = dict(row)
  512. try:
  513. item["config"] = json.loads(item.pop("config_json") or "{}")
  514. except json.JSONDecodeError:
  515. item["config"] = {}
  516. return item
  517. def list_errors(page: int, page_size: int) -> dict[str, Any]:
  518. """分页查询自动化错误记录。"""
  519. offset = (page - 1) * page_size
  520. with get_db() as conn:
  521. total = conn.execute("SELECT COUNT(*) AS total FROM automation_errors").fetchone()["total"]
  522. rows = conn.execute(
  523. """
  524. SELECT e.*, s.interface_name
  525. FROM automation_errors e
  526. LEFT JOIN automation_screens s ON s.id = e.screen_id
  527. ORDER BY e.created_at DESC
  528. LIMIT ? OFFSET ?
  529. """,
  530. (page_size, offset),
  531. ).fetchall()
  532. return {"items": [public_error(row) for row in rows], "total": total, "page": page, "page_size": page_size}
  533. def get_error(error_id: int, include_images: bool = False) -> dict[str, Any]:
  534. """读取单条自动化错误详情,可附带目标截图和实际截图。"""
  535. with get_db() as conn:
  536. row = conn.execute("SELECT * FROM automation_errors WHERE id = ?", (error_id,)).fetchone()
  537. if not row:
  538. raise HTTPException(status_code=404, detail="Automation error not found")
  539. item = public_error(row)
  540. if include_images:
  541. for key in ["expected_image_path", "actual_image_path"]:
  542. path = item.get(key)
  543. if path and Path(path).exists():
  544. image = image_to_base64(path)
  545. item[key.replace("_path", "_base64")] = image["base64"]
  546. item[key.replace("_path", "_mime_type")] = image["mime_type"]
  547. return item
  548. def public_error(row: dict[str, Any]) -> dict[str, Any]:
  549. """把错误记录行转换为接口返回格式。"""
  550. item = dict(row)
  551. try:
  552. item["compare_result"] = json.loads(item.pop("compare_result_json") or "{}")
  553. except json.JSONDecodeError:
  554. item["compare_result"] = {}
  555. return item