Keep the last in-template copy on main so history can restore it; product continues in jiangchang-ui-recorder. Co-authored-by: Cursor <cursoragent@cursor.com>
736 lines
23 KiB
Python
736 lines
23 KiB
Python
"""Flow-oriented capture artifacts: one flow document, many steps."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import Any, Optional
|
||
|
||
try:
|
||
from . import __version__
|
||
except ImportError:
|
||
try:
|
||
import importlib.util
|
||
from pathlib import Path
|
||
|
||
_spec = importlib.util.spec_from_file_location(
|
||
"ui_inspector_pkg", Path(__file__).resolve().parent / "__init__.py"
|
||
)
|
||
_mod = importlib.util.module_from_spec(_spec) # type: ignore[arg-type]
|
||
assert _spec and _spec.loader
|
||
_spec.loader.exec_module(_mod)
|
||
__version__ = getattr(_mod, "__version__", "0.16.0")
|
||
except Exception:
|
||
__version__ = "0.16.0"
|
||
|
||
|
||
def _local_ts() -> str:
|
||
"""Local wall-clock stamp for capture folder names: YYYYMMDDHHMMSS."""
|
||
return datetime.now().astimezone().strftime("%Y%m%d%H%M%S")
|
||
|
||
|
||
def safe_slug(text: str, *, fallback: str = "session") -> str:
|
||
text = (text or "").strip()
|
||
if not text:
|
||
return fallback
|
||
text = re.sub(r"\s+", "-", text)
|
||
text = re.sub(r"[^\w\u4e00-\u9fff\-]+", "", text, flags=re.UNICODE)
|
||
text = text.strip("-_") or fallback
|
||
return text[:80]
|
||
|
||
|
||
def _clean_ui_text(text: str) -> str:
|
||
"""Strip WeCom-style rich markup and collapse whitespace."""
|
||
s = (text or "").strip()
|
||
if not s:
|
||
return ""
|
||
s = re.sub(r"<[^>]+>", "", s)
|
||
s = re.sub(r"\s+", " ", s).strip()
|
||
return s
|
||
|
||
|
||
# Name/AutomationId keys that usually mark "what page am I on"
|
||
_PAGE_HINT_KEYS: dict[str, int] = {
|
||
"panel_title_label": 100,
|
||
"title": 95,
|
||
"ww_btn_text": 70,
|
||
"main_chattext": 45,
|
||
}
|
||
|
||
_LAYOUT_NOISE = {
|
||
"bg",
|
||
"navigation_bar",
|
||
"outer_layout",
|
||
"content_container",
|
||
"title_layout",
|
||
"closebtn",
|
||
"VerticalLayoutUI",
|
||
"HorizontalLayoutUI",
|
||
"wbtn_container",
|
||
"search_layout",
|
||
"search_layout_border",
|
||
"inner_search_layout",
|
||
"ControlUI",
|
||
"LabelUI",
|
||
"item",
|
||
"externals",
|
||
"status_layout",
|
||
"search_button_region",
|
||
"search_icon",
|
||
"ww_btn_icon",
|
||
"wbadge_label",
|
||
"lasttime",
|
||
"groupbuddydescription",
|
||
"bottom_text",
|
||
"chattext",
|
||
"pane",
|
||
"Pane",
|
||
}
|
||
|
||
_CHROME_TEXT = {
|
||
"关闭",
|
||
"最小化",
|
||
"最大化",
|
||
"还原",
|
||
"取消",
|
||
"确定",
|
||
"关闭窗口",
|
||
}
|
||
|
||
# Left-rail / chrome values that appear on every WeCom page — not page identity
|
||
_GLOBAL_NAV_TEXT = {
|
||
"消息",
|
||
"邮件",
|
||
"文档",
|
||
"日程",
|
||
"待办",
|
||
"会议",
|
||
"智能文档",
|
||
"通讯录",
|
||
"微盘",
|
||
"高级功能",
|
||
"工作台",
|
||
"收藏",
|
||
"智能总结",
|
||
"回到最新位置",
|
||
}
|
||
|
||
|
||
def _row_key(r: dict[str, Any]) -> str:
|
||
return _clean_ui_text(str(r.get("name") or r.get("automation_id") or ""))
|
||
|
||
|
||
def _looks_like_search_dialog(
|
||
window_title: str, window_class: str, rows: list[dict[str, Any]]
|
||
) -> bool:
|
||
t = window_title or ""
|
||
c = window_class or ""
|
||
if "Search" in c or "搜索" in t or "添加客户" in t or "添加联系人" in t:
|
||
return True
|
||
for r in rows:
|
||
if _row_key(r) == "title":
|
||
v = _clean_ui_text(str(r.get("value") or ""))
|
||
if "添加" in v or "搜索" in v:
|
||
return True
|
||
return False
|
||
|
||
|
||
def _short_entity(text: str, *, limit: int = 16) -> str:
|
||
"""Clean contact/chat title: strip markup leftovers and @微信 suffix."""
|
||
s = _clean_ui_text(text)
|
||
if not s:
|
||
return ""
|
||
s = re.sub(r"@微信\s*$", "", s)
|
||
s = re.sub(r"@WeCom\s*$", "", s, flags=re.I)
|
||
s = s.strip(" -_|")
|
||
if len(s) > limit:
|
||
s = s[:limit].rstrip("-_")
|
||
return s
|
||
|
||
|
||
def _raw_value(r: dict[str, Any]) -> str:
|
||
return str(r.get("value") or "")
|
||
|
||
|
||
def _is_selected_buddy(raw_value: str) -> bool:
|
||
# WeCom marks the active conversation with semi-transparent white color markup.
|
||
return "#99ffffff" in (raw_value or "").lower()
|
||
|
||
|
||
def derive_step_label(
|
||
*,
|
||
window_title: str = "",
|
||
window_class: str = "",
|
||
locatable: Optional[list[dict[str, Any]]] = None,
|
||
max_hints: int = 2,
|
||
) -> str:
|
||
"""
|
||
Build a human-readable step label from window + page-specific UIA hints.
|
||
|
||
Prefers: search box text, page title, selected chat, compose draft, action buttons.
|
||
Avoids bare「会话列表」when a concrete chat/search/input signal exists.
|
||
"""
|
||
rows = list(locatable or [])
|
||
base = _clean_ui_text(window_title)
|
||
if not base:
|
||
for r in rows:
|
||
if (r.get("control_type") or "") == "WindowControl":
|
||
base = _clean_ui_text(str(r.get("name") or ""))
|
||
if base:
|
||
break
|
||
if not base and window_class:
|
||
base = window_class
|
||
|
||
has_panel_title = any(
|
||
_row_key(r) == "panel_title_label" and _clean_ui_text(str(r.get("value") or ""))
|
||
for r in rows
|
||
)
|
||
has_groupbuddy = any(_row_key(r) == "groupbuddyname" for r in rows)
|
||
has_search_result = any(_row_key(r) == "search_result" for r in rows)
|
||
has_searchedit = any(_row_key(r) in {"searchedit", "search_edit"} for r in rows)
|
||
search_dialog = _looks_like_search_dialog(window_title, window_class, rows)
|
||
|
||
scored: list[tuple[int, str]] = []
|
||
searchedit_value = ""
|
||
selected_buddy = ""
|
||
first_buddy = ""
|
||
|
||
for r in rows:
|
||
key = _row_key(r)
|
||
if not key or key in _LAYOUT_NOISE:
|
||
continue
|
||
raw = _raw_value(r)
|
||
value = _clean_ui_text(raw)
|
||
name_disp = _clean_ui_text(str(r.get("name") or ""))
|
||
ctype = str(r.get("control_type") or "")
|
||
|
||
if key in {"searchedit", "search_edit"} and value:
|
||
searchedit_value = _short_entity(value, limit=20)
|
||
# Main-window search AND add-friend dialog both matter.
|
||
scored.append((98, f"搜索{searchedit_value}"))
|
||
continue
|
||
|
||
if key == "send_rich_edit" and value:
|
||
draft = _short_entity(value, limit=12)
|
||
if draft:
|
||
scored.append((86, f"输入{draft}"))
|
||
continue
|
||
|
||
if key == "groupbuddyname" and value:
|
||
entity = _short_entity(value, limit=14)
|
||
if not entity:
|
||
continue
|
||
if _is_selected_buddy(raw) and not selected_buddy:
|
||
selected_buddy = entity
|
||
scored.append((92, entity))
|
||
elif not first_buddy and not r.get("is_offscreen"):
|
||
first_buddy = entity
|
||
continue
|
||
|
||
pri = _PAGE_HINT_KEYS.get(key)
|
||
if pri is not None and value:
|
||
if value in _CHROME_TEXT or value in _GLOBAL_NAV_TEXT:
|
||
continue
|
||
if key == "title" and value == base:
|
||
continue
|
||
if key == "main_chattext" and (has_panel_title or has_groupbuddy):
|
||
continue
|
||
entity = _short_entity(value, limit=16)
|
||
if not entity:
|
||
continue
|
||
scored.append((pri, entity))
|
||
continue
|
||
|
||
if ctype in {
|
||
"ButtonControl",
|
||
"EditControl",
|
||
"TabItemControl",
|
||
"TextControl",
|
||
"HyperlinkControl",
|
||
"MenuItemControl",
|
||
"CheckBoxControl",
|
||
"RadioButtonControl",
|
||
"ComboBoxControl",
|
||
"ListItemControl",
|
||
}:
|
||
label = _short_entity(value or name_disp, limit=16)
|
||
if (
|
||
not label
|
||
or label in _CHROME_TEXT
|
||
or label in _LAYOUT_NOISE
|
||
or label in _GLOBAL_NAV_TEXT
|
||
):
|
||
continue
|
||
scored.append((40, label))
|
||
|
||
# Conversation context when no selected buddy / search text yet
|
||
if (
|
||
has_groupbuddy
|
||
and not selected_buddy
|
||
and first_buddy
|
||
and not has_panel_title
|
||
and not searchedit_value
|
||
):
|
||
scored.append((78, first_buddy))
|
||
|
||
strong = any(p >= 90 for p, _ in scored)
|
||
if search_dialog and has_searchedit and not searchedit_value and not strong:
|
||
scored.append((75, "搜索框"))
|
||
elif search_dialog and has_search_result and searchedit_value and not strong:
|
||
scored.append((50, "搜索结果"))
|
||
elif (
|
||
has_groupbuddy
|
||
and not has_panel_title
|
||
and not strong
|
||
and not first_buddy
|
||
and not selected_buddy
|
||
):
|
||
scored.append((60, "会话列表"))
|
||
|
||
scored.sort(key=lambda x: (-x[0], x[1]))
|
||
hints: list[str] = []
|
||
seen: set[str] = set()
|
||
for _pri, text in scored:
|
||
if not text or text == base or text in seen:
|
||
continue
|
||
if text in _CHROME_TEXT or text in _GLOBAL_NAV_TEXT:
|
||
continue
|
||
compact = (
|
||
text[2:] if text.startswith(("搜索", "输入")) and len(text) > 2 else text
|
||
)
|
||
if compact and any(
|
||
compact == s or s.endswith(compact) or compact in s for s in seen
|
||
):
|
||
continue
|
||
seen.add(text)
|
||
if compact:
|
||
seen.add(compact)
|
||
hints.append(text)
|
||
if len(hints) >= max_hints:
|
||
break
|
||
|
||
parts = [p for p in [base, *hints] if p]
|
||
if not parts:
|
||
parts = ["step"]
|
||
return "_".join(parts)
|
||
|
||
|
||
def _label_slug_key(label: str) -> str:
|
||
return safe_slug(label, fallback="step")
|
||
|
||
|
||
def _unique_step_dirname(steps_root: Path, idx: int, label: str) -> str:
|
||
"""NN_label, with -2/-3 suffix if the folder already exists."""
|
||
slug = _label_slug_key(label)
|
||
base = f"{idx:02d}_{slug}"
|
||
if not (steps_root / base).exists():
|
||
return base
|
||
n = 2
|
||
while (steps_root / f"{base}-{n}").exists():
|
||
n += 1
|
||
return f"{base}-{n}"
|
||
|
||
|
||
def _existing_label_slugs(flow: dict[str, Any]) -> set[str]:
|
||
out: set[str] = set()
|
||
for s in flow.get("steps") or []:
|
||
lab = s.get("label") or ""
|
||
name = str(s.get("name") or "")
|
||
if lab:
|
||
out.add(_label_slug_key(str(lab)))
|
||
m = re.match(r"^\d{2}_(.+?)(?:-\d+)?$", name)
|
||
if m:
|
||
out.add(m.group(1))
|
||
return out
|
||
|
||
|
||
def resolve_step_label(
|
||
flow: dict[str, Any],
|
||
*,
|
||
window_title: str = "",
|
||
window_class: str = "",
|
||
locatable: Optional[list[dict[str, Any]]] = None,
|
||
) -> str:
|
||
"""Derive label; if it collides with earlier steps, add more hints."""
|
||
used = _existing_label_slugs(flow)
|
||
label = derive_step_label(
|
||
window_title=window_title,
|
||
window_class=window_class,
|
||
locatable=locatable,
|
||
max_hints=2,
|
||
)
|
||
if _label_slug_key(label) not in used:
|
||
return label
|
||
label3 = derive_step_label(
|
||
window_title=window_title,
|
||
window_class=window_class,
|
||
locatable=locatable,
|
||
max_hints=3,
|
||
)
|
||
if _label_slug_key(label3) not in used:
|
||
return label3
|
||
# Last resort: keep richest label; folder uniquifier adds -2
|
||
return label3
|
||
|
||
|
||
def default_captures_root(tool_dir: Optional[Path] = None) -> Path:
|
||
base = tool_dir or Path(__file__).resolve().parent
|
||
return base / "_captures"
|
||
|
||
|
||
def write_json(path: Path, data: Any) -> None:
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
path.write_text(
|
||
json.dumps(data, ensure_ascii=False, indent=2) + "\n",
|
||
encoding="utf-8",
|
||
)
|
||
|
||
|
||
def write_jsonl(path: Path, rows: list[dict[str, Any]]) -> None:
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
with path.open("w", encoding="utf-8") as fh:
|
||
for row in rows:
|
||
fh.write(json.dumps(row, ensure_ascii=False) + "\n")
|
||
|
||
|
||
def read_json(path: Path) -> dict[str, Any]:
|
||
return json.loads(path.read_text(encoding="utf-8"))
|
||
|
||
|
||
def build_meta(
|
||
*,
|
||
window: dict[str, Any],
|
||
scene: str,
|
||
skill_root: Optional[str] = None,
|
||
extra: Optional[dict[str, Any]] = None,
|
||
surface: str = "windows",
|
||
driver: str = "uiautomation",
|
||
url: str = "",
|
||
) -> dict[str, Any]:
|
||
now = datetime.now().astimezone()
|
||
meta = {
|
||
"schema": "openclaw.ui_inspector.capture.v1",
|
||
"surface": surface or "windows",
|
||
"captured_at": now.isoformat(),
|
||
"scene": scene,
|
||
"skill_root": skill_root,
|
||
"app": {
|
||
"title": window.get("title"),
|
||
"class_name": window.get("class_name"),
|
||
"process_name": window.get("process_name"),
|
||
"pid": window.get("pid"),
|
||
"hwnd": window.get("hwnd"),
|
||
"url": url or window.get("url") or "",
|
||
},
|
||
"environment": {
|
||
"os": "windows",
|
||
"tz": now.tzname(),
|
||
},
|
||
"tool": {
|
||
"name": "openclaw-ui-inspector",
|
||
"version": __version__,
|
||
"driver": driver or "uiautomation",
|
||
},
|
||
}
|
||
if extra:
|
||
meta["extra"] = extra
|
||
return meta
|
||
|
||
|
||
def noteworthy_from_index(index: list[dict[str, Any]], *, limit: int = 80) -> list[dict[str, Any]]:
|
||
rows = [
|
||
r
|
||
for r in index
|
||
if (r.get("name") or r.get("value") or r.get("automation_id"))
|
||
]
|
||
rows.sort(
|
||
key=lambda r: (
|
||
0 if r.get("value") else 1,
|
||
0 if r.get("name") else 1,
|
||
int(r.get("depth") or 0) if isinstance(r.get("depth"), int) and r.get("depth") >= 0 else 99,
|
||
)
|
||
)
|
||
return rows[:limit]
|
||
|
||
|
||
def capture_window_png(hwnd: int, dest: Path) -> bool:
|
||
try:
|
||
from PIL import ImageGrab
|
||
except ImportError:
|
||
return False
|
||
try:
|
||
import ctypes
|
||
from ctypes import wintypes
|
||
|
||
user32 = ctypes.windll.user32
|
||
rect = wintypes.RECT()
|
||
if not user32.GetWindowRect(int(hwnd), ctypes.byref(rect)):
|
||
return False
|
||
bbox = (int(rect.left), int(rect.top), int(rect.right), int(rect.bottom))
|
||
if bbox[2] <= bbox[0] or bbox[3] <= bbox[1]:
|
||
return False
|
||
img = ImageGrab.grab(bbox=bbox, all_screens=True)
|
||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||
img.save(str(dest))
|
||
return True
|
||
except Exception:
|
||
try:
|
||
img = ImageGrab.grab(all_screens=True)
|
||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||
img.save(str(dest))
|
||
return True
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
# --- Flow document API -------------------------------------------------
|
||
|
||
def create_flow(
|
||
captures_root: Path,
|
||
*,
|
||
flow_name: str = "desktop",
|
||
recording_mode: str = "auto",
|
||
) -> Path:
|
||
"""开始流程:新建一份流程文档目录(v1 steps + 可选 v2 资产目录)。"""
|
||
captures_root.mkdir(parents=True, exist_ok=True)
|
||
slug = safe_slug(flow_name, fallback="flow")
|
||
flow_dir = captures_root / f"{_local_ts()}_{slug}"
|
||
flow_dir.mkdir(parents=True, exist_ok=False)
|
||
(flow_dir / "steps").mkdir(parents=True, exist_ok=True)
|
||
# v2 dual-write directories (empty until recorder writes)
|
||
for sub in ("states", "observations", "actions", "elements", "journal", "diagnostics"):
|
||
(flow_dir / sub).mkdir(parents=True, exist_ok=True)
|
||
now = datetime.now().astimezone().isoformat()
|
||
flow = {
|
||
"schema": "openclaw.ui_inspector.flow.v1",
|
||
"flow_name": flow_name,
|
||
"status": "open",
|
||
"created_at": now,
|
||
"updated_at": now,
|
||
"ended_at": None,
|
||
"step_count": 0,
|
||
"steps": [],
|
||
"tool": {
|
||
"name": "openclaw-ui-inspector",
|
||
"version": __version__,
|
||
},
|
||
# Optional v2 pointers — old readers ignore unknown fields.
|
||
"v2": {
|
||
"recording_mode": recording_mode or "auto",
|
||
"action_flow": "action_flow.json",
|
||
"element_catalog": "element_catalog.json",
|
||
"states_dir": "states/",
|
||
"observations_dir": "observations/",
|
||
"actions_dir": "actions/",
|
||
"elements_dir": "elements/",
|
||
"raw_event_journal": "journal/raw_events.jsonl",
|
||
"pages": "pages.json",
|
||
"diagnostics_dir": "diagnostics/",
|
||
"validation": "diagnostics/validation.json",
|
||
"replay_report": "diagnostics/replay_report.json",
|
||
},
|
||
}
|
||
write_json(flow_dir / "flow.json", flow)
|
||
write_flow_summary(flow_dir, flow)
|
||
return flow_dir
|
||
|
||
|
||
def load_flow(flow_dir: Path) -> dict[str, Any]:
|
||
return read_json(flow_dir / "flow.json")
|
||
|
||
|
||
def save_flow(flow_dir: Path, flow: dict[str, Any]) -> None:
|
||
flow["updated_at"] = datetime.now().astimezone().isoformat()
|
||
write_json(flow_dir / "flow.json", flow)
|
||
write_flow_summary(flow_dir, flow)
|
||
|
||
|
||
def next_step_index(flow: dict[str, Any]) -> int:
|
||
return int(flow.get("step_count") or 0) + 1
|
||
|
||
|
||
def add_step_dir(
|
||
flow_dir: Path,
|
||
flow: dict[str, Any],
|
||
*,
|
||
window_title: str = "",
|
||
window_class: str = "",
|
||
locatable: Optional[list[dict[str, Any]]] = None,
|
||
label: Optional[str] = None,
|
||
) -> tuple[Path, int, str]:
|
||
"""Allocate steps/NN_<page-fingerprint>/ under an open flow."""
|
||
if flow.get("status") != "open":
|
||
raise RuntimeError("当前流程已结束,请先「开始流程」。")
|
||
idx = next_step_index(flow)
|
||
if not label:
|
||
label = resolve_step_label(
|
||
flow,
|
||
window_title=window_title,
|
||
window_class=window_class,
|
||
locatable=locatable,
|
||
)
|
||
steps_root = flow_dir / "steps"
|
||
steps_root.mkdir(parents=True, exist_ok=True)
|
||
step_name = _unique_step_dirname(steps_root, idx, label)
|
||
step_dir = steps_root / step_name
|
||
step_dir.mkdir(parents=True, exist_ok=False)
|
||
return step_dir, idx, step_name
|
||
|
||
|
||
def register_step(
|
||
flow_dir: Path,
|
||
flow: dict[str, Any],
|
||
*,
|
||
step_index: int,
|
||
step_name: str,
|
||
window: dict[str, Any],
|
||
stats: dict[str, Any],
|
||
label: Optional[str] = None,
|
||
surface: str = "windows",
|
||
url: str = "",
|
||
) -> dict[str, Any]:
|
||
entry = {
|
||
"index": step_index,
|
||
"name": step_name,
|
||
"label": label or step_name,
|
||
"captured_at": datetime.now().astimezone().isoformat(),
|
||
"surface": surface or "windows",
|
||
"url": url or window.get("url") or "",
|
||
"window_title": window.get("title"),
|
||
"window_class": window.get("class_name"),
|
||
"pid": window.get("pid"),
|
||
"hwnd": window.get("hwnd"),
|
||
"findall": stats.get("findall_count"),
|
||
"locatable": stats.get("locatable_count"),
|
||
"kept": stats.get("nodes_kept"),
|
||
"uia_sparse": stats.get("uia_sparse"),
|
||
"path": f"steps/{step_name}",
|
||
}
|
||
steps = list(flow.get("steps") or [])
|
||
steps.append(entry)
|
||
flow["steps"] = steps
|
||
flow["step_count"] = len(steps)
|
||
save_flow(flow_dir, flow)
|
||
return entry
|
||
|
||
|
||
def end_flow(flow_dir: Path, flow: dict[str, Any]) -> dict[str, Any]:
|
||
if flow.get("status") == "ended":
|
||
return flow
|
||
flow["status"] = "ended"
|
||
flow["ended_at"] = datetime.now().astimezone().isoformat()
|
||
save_flow(flow_dir, flow)
|
||
return flow
|
||
|
||
|
||
def write_flow_summary(flow_dir: Path, flow: dict[str, Any]) -> None:
|
||
lines = [
|
||
f"# 流程:{flow.get('flow_name')}",
|
||
"",
|
||
f"- status: `{flow.get('status')}`",
|
||
f"- created_at: `{flow.get('created_at')}`",
|
||
f"- updated_at: `{flow.get('updated_at')}`",
|
||
f"- ended_at: `{flow.get('ended_at')}`",
|
||
f"- step_count: `{flow.get('step_count')}`",
|
||
"",
|
||
"## 步骤",
|
||
"",
|
||
]
|
||
steps = flow.get("steps") or []
|
||
if not steps:
|
||
lines.append("(尚无拾取步骤。点「拾取当前页」追加。)")
|
||
lines.append("")
|
||
else:
|
||
for s in steps:
|
||
lines.append(f"### {s.get('index')}. `{s.get('name')}`")
|
||
lines.append("")
|
||
if s.get("label"):
|
||
lines.append(f"- label: `{s.get('label')}`")
|
||
lines.append(f"- surface: `{s.get('surface') or 'windows'}`")
|
||
if s.get("url"):
|
||
lines.append(f"- url: `{s.get('url')}`")
|
||
lines.append(
|
||
f"- window: `{s.get('window_title')}` / `{s.get('window_class')}`"
|
||
)
|
||
lines.append(
|
||
f"- stats: findall=`{s.get('findall')}` locatable=`{s.get('locatable')}` "
|
||
f"kept=`{s.get('kept')}` sparse=`{s.get('uia_sparse')}`"
|
||
)
|
||
lines.append(f"- path: `{s.get('path')}/`")
|
||
lines.append("")
|
||
lines.extend(
|
||
[
|
||
"## 说明",
|
||
"",
|
||
"- 一个流程对应本目录一份文档(`flow.json` + `SUMMARY.md` + `steps/`)。",
|
||
"- 每拾取一页,在 `steps/` 下新增一步;给 AI 时优先读本 `SUMMARY.md` 与各步 `locatable.json`。",
|
||
"- `surface=windows` 为桌面 UIA;`surface=browser` 为 Playwright 整页 dump。",
|
||
"- Name ≠ 界面中文时请看 Value;浏览器步优先看 `primary_locator` / `playwright_candidates`。",
|
||
"- v2(若存在):`action_flow.json` + `element_catalog.json` + `states/` + `actions/` + `elements/`。",
|
||
"- 给 AI 编程工具时优先提供 `action_flow.json` 与 `element_catalog.json`,诊断再用 steps/ 原始树。",
|
||
"",
|
||
]
|
||
)
|
||
(flow_dir / "SUMMARY.md").write_text("\n".join(lines), encoding="utf-8")
|
||
|
||
|
||
def write_step_summary(
|
||
step_dir: Path,
|
||
*,
|
||
meta: dict[str, Any],
|
||
stats: Optional[dict[str, Any]] = None,
|
||
noteworthy: Optional[list[dict[str, Any]]] = None,
|
||
) -> None:
|
||
app = meta.get("app") or {}
|
||
tool = meta.get("tool") or {}
|
||
lines = [
|
||
"# Step Summary",
|
||
"",
|
||
f"- captured_at: `{meta.get('captured_at')}`",
|
||
f"- surface: `{meta.get('surface')}` driver: `{tool.get('driver')}`",
|
||
f"- window: `{app.get('title')}` / `{app.get('class_name')}` pid=`{app.get('pid')}`",
|
||
"",
|
||
]
|
||
if app.get("url"):
|
||
lines.append(f"- url: `{app.get('url')}`")
|
||
lines.append("")
|
||
if stats:
|
||
lines.extend(
|
||
[
|
||
"## Stats",
|
||
"",
|
||
f"- kept: `{stats.get('nodes_kept')}`",
|
||
f"- findall: `{stats.get('findall_count')}`",
|
||
f"- locatable: `{stats.get('locatable_count')}`",
|
||
f"- uia_sparse: `{stats.get('uia_sparse')}`",
|
||
"",
|
||
]
|
||
)
|
||
if noteworthy:
|
||
lines.extend(["## Notable", ""])
|
||
for item in noteworthy[:40]:
|
||
primary = (item.get("primary_locator") or {}).get("expr") or ""
|
||
extra = f" locator=`{primary}`" if primary else ""
|
||
lines.append(
|
||
f"- `{item.get('control_type')}` Name=`{item.get('name')}` "
|
||
f"Value=`{item.get('value') or item.get('text')}`{extra}"
|
||
)
|
||
lines.append("")
|
||
lines.extend(
|
||
[
|
||
"## Files",
|
||
"",
|
||
"- `meta.json` / `tree.json` / `index.jsonl` / `locatable.json` / `screen.png`",
|
||
"",
|
||
]
|
||
)
|
||
(step_dir / "SUMMARY.md").write_text("\n".join(lines), encoding="utf-8")
|
||
|
||
|
||
|
||
# Back-compat aliases used by older smoke scripts
|
||
def create_session_dir(captures_root: Path, *, scene: str = "desktop") -> Path:
|
||
return create_flow(captures_root, flow_name=scene)
|