Files
skill-template/tools/ui_inspector/artifacts.py
chendelian 633ecff4e2 chore: archive tools/ui_inspector snapshot before migration removal.
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>
2026-08-07 11:19:14 +08:00

736 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)