Files
skill-template/tools/ui_inspector/app.py
chendelian 46bc573777
All checks were successful
技能自动化发布 / release (push) Successful in 10s
Release v1.0.66: table default_sort_json for data management
2026-08-02 12:17:55 +08:00

785 lines
32 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.
"""Tk panel v0.8.1: 开始流程 → 手动拾取 / 自动串行循环 → 结束流程。
企微主窗 / 搜索浮层在识别/拾取前静默调用 kit ensure(wecom / wecom_search)。
"""
from __future__ import annotations
import argparse
import os
import subprocess
import sys
import threading
from pathlib import Path
import tkinter as tk
from tkinter import messagebox, simpledialog, ttk
_HERE = Path(__file__).resolve().parent
if str(_HERE) not in sys.path:
sys.path.insert(0, str(_HERE))
import artifacts as art # noqa: E402
import desktop_uia as uia # noqa: E402
try:
from . import __version__
except Exception:
try:
import importlib.util
_spec = importlib.util.spec_from_file_location(
"ui_inspector_pkg", _HERE / "__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.8.1")
except Exception:
__version__ = "0.8.1"
# Auto loop: after each dump finishes, wait this long, then dump again.
_AUTO_GAP_MS = 2000
# After frontmost target is resolved, short delay before first dump.
_AUTO_START_DELAY_MS = 400
# After「开始流程」with auto-pick: countdown so user can focus the target app.
_START_COUNTDOWN_SEC = 5
_FRONTMOST_WAIT_SEC = 15.0
class InspectorApp:
def __init__(
self,
*,
captures_root: Path,
scene: str = "desktop",
max_depth: int = 16,
max_nodes: int = 8000,
) -> None:
self.captures_root = captures_root
self.scene = scene
self.max_depth = max_depth
self.max_nodes = max_nodes
self.flow_dir: Path | None = None
self.flow: dict | None = None
self.last_window: dict | None = None
self.sticky_pid: int | None = None
self._warmed_pids: set[int] = set()
self._busy = False
self._auto_loop_active = False
self._pending_end = False
self._auto_after_id: str | None = None
self._countdown_after_id: str | None = None
self._countdown_left = 0
self.root = tk.Tk()
self.root.title(f"OpenClaw UI Inspector v{__version__} (desktop)")
self.root.geometry("840x660")
self.root.minsize(720, 540)
self.root.attributes("-topmost", True)
self.flow_name_var = tk.StringVar(value=self.scene)
self.auto_pick_var = tk.BooleanVar(value=True)
self.flow_var = tk.StringVar(value="流程:未开始(请先点「开始流程」)")
self.target_var = tk.StringVar(value="尚未拾取")
self.status_var = tk.StringVar(
value="用法:开始流程 →(默认自动拾取)5 秒倒数 → 串行 dump。"
"企微将自动 ensure_ui_ready(需 platform-kit≥1.2.5);选窗看焦点,不强制放大。"
)
self._build_ui()
self.root.protocol("WM_DELETE_WINDOW", self._on_close)
def _build_ui(self) -> None:
pad = {"padx": 8, "pady": 4}
top = ttk.Frame(self.root)
top.pack(fill=tk.X, **pad)
ttk.Label(top, text="流程名").pack(side=tk.LEFT)
ttk.Entry(top, textvariable=self.flow_name_var, width=28).pack(side=tk.LEFT, padx=4)
ttk.Button(top, text="打开输出目录", command=self.on_open_out).pack(side=tk.LEFT, padx=8)
btns = ttk.Frame(self.root)
btns.pack(fill=tk.X, **pad)
self.start_btn = ttk.Button(btns, text="1. 开始流程", command=self.on_start_flow)
self.start_btn.pack(side=tk.LEFT, padx=2)
self.pick_btn = ttk.Button(btns, text="2. 拾取当前页", command=self.on_pick_page)
self.pick_btn.pack(side=tk.LEFT, padx=2)
self.end_btn = ttk.Button(btns, text="3. 结束流程", command=self.on_end_flow)
self.end_btn.pack(side=tk.LEFT, padx=2)
self.auto_chk = ttk.Checkbutton(
btns,
text="自动拾取(串行·步间2秒)",
variable=self.auto_pick_var,
command=self.on_auto_toggle,
)
self.auto_chk.pack(side=tk.LEFT, padx=12)
info = ttk.LabelFrame(self.root, text="状态")
info.pack(fill=tk.X, **pad)
ttk.Label(info, textvariable=self.flow_var, wraplength=800, justify=tk.LEFT).pack(
anchor=tk.W, padx=6, pady=2
)
ttk.Label(info, textvariable=self.target_var, wraplength=800, justify=tk.LEFT).pack(
anchor=tk.W, padx=6, pady=2
)
ttk.Label(info, textvariable=self.status_var, wraplength=800, justify=tk.LEFT).pack(
anchor=tk.W, padx=6, pady=2
)
tree_box = ttk.LabelFrame(self.root, text="本步拾取结果")
tree_box.pack(fill=tk.BOTH, expand=True, **pad)
scroll = ttk.Scrollbar(tree_box)
scroll.pack(side=tk.RIGHT, fill=tk.Y)
self.detail = tk.Text(
tree_box,
height=24,
wrap=tk.WORD,
yscrollcommand=scroll.set,
)
self.detail.pack(fill=tk.BOTH, expand=True, padx=6, pady=6)
scroll.config(command=self.detail.yview)
self.detail.insert(
tk.END,
"1) 填写流程名,点「开始流程」——新建一份流程文档\n"
"2) 默认已勾选「自动拾取」:先 5 秒倒数(请点目标窗到最前端),\n"
" 然后串行:dump → 保存 → 等 2 秒 → 再 dump … 直到「结束流程」\n"
" 本窗口保持置顶方便看进度;选目标看焦点并排除自身\n"
" 不强制最大化目标窗(保持你当时的大小/位置)\n"
"3) 也可取消自动,改为手动点「拾取当前页」\n"
"4) 点「结束流程」——退出循环并关闭文档\n\n"
"完整内容在 _captures/<流程>/flow.json、SUMMARY.md、steps/\n",
)
self.detail.configure(state=tk.DISABLED)
tip = (
"一个流程 = 一份文档;多次拾取 = 多个 steps。"
"自动拾取严格串行:上一拍完全结束才会开始下一拍。"
"给 AI 时优先丢 SUMMARY.md + 各步 locatable.json。"
)
ttk.Label(self.root, text=tip, wraplength=800, foreground="#444").pack(
fill=tk.X, padx=8, pady=6
)
self._refresh_button_states()
def _flow_is_open(self) -> bool:
return bool(self.flow_dir and self.flow and self.flow.get("status") == "open")
def _in_start_countdown(self) -> bool:
return self._countdown_after_id is not None or self._countdown_left > 0
def _refresh_button_states(self) -> None:
open_flow = self._flow_is_open()
counting = self._in_start_countdown()
# Manual pick only when open, idle, and not in auto loop / countdown
can_manual = (
open_flow
and not self._busy
and not self._auto_loop_active
and not counting
)
self.pick_btn.configure(state=tk.NORMAL if can_manual else tk.DISABLED)
# End must stay clickable during a long dump so the loop can be stopped
self.end_btn.configure(state=tk.NORMAL if open_flow else tk.DISABLED)
self.start_btn.configure(
state=tk.DISABLED if (self._busy or counting) else tk.NORMAL
)
# Don't flip checkbox mid-dump in a confusing way; allow toggle to stop loop
self.auto_chk.configure(state=tk.NORMAL)
def _set_status(self, text: str) -> None:
self.status_var.set(text)
def _set_detail(self, text: str) -> None:
self.detail.configure(state=tk.NORMAL)
self.detail.delete("1.0", tk.END)
self.detail.insert(tk.END, text)
self.detail.see("1.0")
self.detail.configure(state=tk.DISABLED)
def _show_picking_placeholder(self, *, auto: bool) -> None:
n = int((self.flow or {}).get("step_count") or 0) + 1
mode = "自动" if auto else "手动"
self.target_var.set(f"{mode}拾取中…(即将写入第 {n} 步)")
self._set_status(f"正在{mode}拾取:已清空本框上次结果,请稍候…")
self._set_detail(
"(已清空上次本步结果)\n\n"
f"正在读取控件树,完成后写入当前流程第 {n} 步…\n"
)
def _exclude_hwnds(self) -> set[int]:
try:
return {int(self.root.winfo_id())}
except Exception:
return set()
def _update_flow_label(self) -> None:
if not self.flow_dir or not self.flow:
self.flow_var.set("流程:未开始(请先点「开始流程」)")
return
if self._auto_loop_active:
auto = "LOOP"
elif self.auto_pick_var.get() and self._flow_is_open():
auto = "armed"
else:
auto = "off"
self.flow_var.set(
f"流程:{self.flow.get('flow_name')} | status={self.flow.get('status')} | "
f"steps={self.flow.get('step_count')} | auto={auto} | {self.flow_dir}"
)
def _cancel_auto_timer(self) -> None:
if self._auto_after_id is not None:
try:
self.root.after_cancel(self._auto_after_id)
except Exception:
pass
self._auto_after_id = None
def _cancel_countdown(self) -> None:
if self._countdown_after_id is not None:
try:
self.root.after_cancel(self._countdown_after_id)
except Exception:
pass
self._countdown_after_id = None
self._countdown_left = 0
def _stop_auto_loop(self) -> None:
self._auto_loop_active = False
self._cancel_auto_timer()
self._cancel_countdown()
def _start_auto_loop(self, *, first_delay_ms: int = _AUTO_START_DELAY_MS) -> None:
self._auto_loop_active = True
self._pending_end = False
self._update_flow_label()
self._refresh_button_states()
self._schedule_auto_next(first_delay_ms)
def _set_inspector_topmost(self, enabled: bool) -> None:
try:
self.root.attributes("-topmost", bool(enabled))
except Exception:
pass
def _ensure_inspector_visible(self) -> None:
"""Keep panel on-screen for progress; do not force-focus (avoids stealing FG)."""
try:
if str(self.root.state()) == "iconic":
self.root.deiconify()
except Exception:
pass
def _arm_auto_with_countdown(self, *, seconds: int = _START_COUNTDOWN_SEC) -> None:
"""Visible countdown, then resolve frontmost and enter auto loop."""
if not self._flow_is_open():
return
# Stop prior loop/timers without clearing the countdown we are about to arm.
self._auto_loop_active = False
self._cancel_auto_timer()
self._cancel_countdown()
self._ensure_inspector_visible()
# Stay topmost for progress; capture still uses focus + exclude self.
self._set_inspector_topmost(True)
self._countdown_left = max(1, int(seconds))
self._update_flow_label()
self._set_status(
f"倒计时 {self._countdown_left} 秒:请把要采集的窗口点到最前端"
"(本面板置顶看进度;不强制放大目标窗)。"
)
self._set_detail(
f"流程:{self.flow_dir}\n\n"
f"- {self._countdown_left} 秒内请点击目标窗口(给它焦点)\n"
f"- 本 Inspector 保持置顶,方便看进度;选窗排除自身\n"
f"- 倒计时结束后:识别最前端 → dump → 等 2 秒 → 再 dump …\n"
f"- 目标窗保持你当前的大小/位置(不强制最大化)\n"
f"- 可点「结束流程」或取消「自动拾取」中止\n"
)
self._refresh_button_states()
self._countdown_after_id = self.root.after(1000, self._countdown_tick)
def _countdown_tick(self) -> None:
self._countdown_after_id = None
if not self._flow_is_open():
self._countdown_left = 0
self._refresh_button_states()
return
if not self.auto_pick_var.get():
self._countdown_left = 0
self._refresh_button_states()
self._set_status("已取消自动拾取倒计时;可手动点「拾取当前页」。")
return
self._countdown_left -= 1
if self._countdown_left > 0:
self._set_status(
f"倒计时 {self._countdown_left} 秒:请把要采集的窗口点到最前端…"
)
self._refresh_button_states()
self._countdown_after_id = self.root.after(1000, self._countdown_tick)
return
self._countdown_left = 0
self._refresh_button_states()
self._set_status("倒计时结束,正在识别最前端窗口…")
self._begin_auto_with_probe()
def _begin_auto_with_probe(self) -> None:
"""Resolve frontmost target (not Inspector), warmup best-effort, then loop."""
if self._busy:
return
self._busy = True
self._auto_loop_active = False
self._refresh_button_states()
# Keep topmost for progress; target = focused window (Inspector hwnd excluded).
self._set_inspector_topmost(True)
self._ensure_inspector_visible()
self._set_status(
"自动拾取:正在识别最前端窗口(焦点窗=采集目标,已排除本面板)…"
)
self._set_detail(
"识别最前端窗口中…\n\n"
"- 规则:谁有焦点就采谁(与窗口大小无关)\n"
"- 本面板可置顶;选目标时排除 Inspector 自身\n"
"- 不强制最大化目标窗\n"
"- 若是企微:自动调用 kit ensure_ui_ready(与技能同路径)\n"
"- FindAll 多少只作提示,不会因此改采别的软件\n"
)
def work() -> None:
try:
uia.ensure_com_sta()
report = uia.prepare_frontmost_for_capture(
exclude_hwnds=self._exclude_hwnds(),
timeout_sec=_FRONTMOST_WAIT_SEC,
)
self.root.after(0, lambda r=report: self._on_frontmost_ready(r))
except Exception as exc:
err = exc
self.root.after(0, lambda e=err: self._on_frontmost_fail(e))
threading.Thread(target=work, daemon=True).start()
def _on_frontmost_ready(self, report: dict) -> None:
self._busy = False
self._refresh_button_states()
win = report.get("window") or {}
if win.get("pid"):
self.sticky_pid = int(win["pid"])
self._warmed_pids.add(int(win["pid"]))
import kit_wake
kit = report.get("kit_wake") or {}
kit_line = kit_wake.format_kit_wake_line(kit)
sparse_note = ""
if report.get("provider_cold") or report.get("uia_sparse"):
if kit.get("reason") == "kit_not_installed":
sparse_note = (
f"(仍偏稀 FindAll={report.get('findall')}:未安装 platform-kit,"
"企微无法走 n2-wake)"
)
elif kit.get("applicable") and not kit.get("ok"):
sparse_note = (
f"(kit 暖树未成功 FindAll={report.get('findall')} "
f"code={kit.get('error_code')};仍按此窗采集)"
)
else:
sparse_note = (
f"(UIA 仍偏稀 FindAll={report.get('findall')};仍按此前端窗采集)"
)
self._set_status(
f"已锁定最前端:{win.get('title')!r} / {win.get('class_name')!r} "
f"findall={report.get('findall')} — 开始自动串行拾取。{sparse_note}"
)
self._set_detail(
f"采集目标(最前端):\n"
f" title={win.get('title')!r}\n"
f" class={win.get('class_name')!r}\n"
f" hwnd={win.get('hwnd')} pid={win.get('pid')}\n"
f" findall={report.get('findall')} sparse={report.get('uia_sparse')} "
f"cold={report.get('provider_cold')}\n"
f" {kit_line}\n\n"
"即将进入自动循环(步间 2 秒)…\n"
)
self._set_inspector_topmost(True)
self._ensure_inspector_visible()
self._start_auto_loop(first_delay_ms=_AUTO_START_DELAY_MS)
def _on_frontmost_fail(self, exc: BaseException) -> None:
self._busy = False
self._auto_loop_active = False
# Keep auto checked (default on); user can retry or end flow.
self._set_inspector_topmost(True)
self._ensure_inspector_visible()
self._refresh_button_states()
self._set_status(f"未能锁定最前端目标: {exc}")
messagebox.showerror("UI Inspector", f"未能锁定最前端目标:\n{exc}")
def _schedule_auto_next(self, delay_ms: int) -> None:
self._cancel_auto_timer()
if not self._auto_loop_active or not self._flow_is_open():
return
self._auto_after_id = self.root.after(delay_ms, self._auto_loop_tick)
def _auto_loop_tick(self) -> None:
self._auto_after_id = None
if not self._auto_loop_active or not self._flow_is_open():
return
if self._busy:
# Should not happen in serial design; re-check soon.
self._schedule_auto_next(200)
return
self.on_pick_page(auto=True)
def _continue_auto_loop_after_pick(self) -> None:
if self._pending_end:
self._pending_end = False
self._stop_auto_loop()
self._finish_end_flow()
return
if self._auto_loop_active and self._flow_is_open():
self._set_status(
f"自动循环:第 {self.flow.get('step_count') if self.flow else '?'} 步已处理;"
f"{_AUTO_GAP_MS // 1000} 秒后开始下一拍。点「结束流程」退出循环。"
)
self._schedule_auto_next(_AUTO_GAP_MS)
def on_auto_toggle(self) -> None:
if self.auto_pick_var.get():
if (
self._flow_is_open()
and not self._auto_loop_active
and not self._busy
and not self._in_start_countdown()
):
self._arm_auto_with_countdown()
else:
self._update_flow_label()
else:
was = self._auto_loop_active or self._in_start_countdown()
self._stop_auto_loop()
self._update_flow_label()
self._refresh_button_states()
if was and self._flow_is_open():
self._set_status("已停止自动循环/倒计时;仍可手动点「拾取当前页」。")
elif self._flow_is_open():
self._set_status("自动拾取已关闭;可手动点「拾取当前页」。")
def on_open_out(self) -> None:
path = self.flow_dir if self.flow_dir else self.captures_root
path.mkdir(parents=True, exist_ok=True)
try:
os.startfile(str(path)) # type: ignore[attr-defined]
except Exception:
subprocess.Popen(["explorer", str(path)])
def on_start_flow(self) -> None:
if self._busy:
return
if self.flow and self.flow.get("status") == "open":
if not messagebox.askyesno(
"UI Inspector",
"当前已有进行中的流程。\n是否结束旧流程并开始新流程?",
):
return
self._stop_auto_loop()
self._pending_end = False
try:
art.end_flow(self.flow_dir, self.flow) # type: ignore[arg-type]
except Exception:
pass
name = (self.flow_name_var.get() or "").strip()
if not name:
name = simpledialog.askstring("流程名", "请输入流程名称:", initialvalue="desktop-flow")
if not name:
return
self.flow_name_var.set(name)
try:
self.flow_dir = art.create_flow(self.captures_root, flow_name=name)
self.flow = art.load_flow(self.flow_dir)
self._pending_end = False
self._warmed_pids.clear()
self._update_flow_label()
self._refresh_button_states()
if self.auto_pick_var.get():
# No modal / no minimize: keep panel visible for countdown + progress.
self._arm_auto_with_countdown(seconds=_START_COUNTDOWN_SEC)
else:
self._stop_auto_loop()
self._ensure_inspector_visible()
self._set_status(
f"已开始流程:{self.flow_dir.name}。请翻到目标页后点「拾取当前页」。"
)
self._set_detail(
f"流程已创建:{self.flow_dir}\n\n"
f"- flow.json / SUMMARY.md / steps/\n"
f"- 手动点「拾取当前页」追加一步;或勾选「自动拾取」进入串行循环。\n"
)
except Exception as exc:
messagebox.showerror("UI Inspector", str(exc))
def _finish_end_flow(self) -> None:
if not self.flow_dir or not self.flow:
return
if self.flow.get("status") != "open":
self._refresh_button_states()
return
try:
self.flow = art.end_flow(self.flow_dir, self.flow)
self._update_flow_label()
self._refresh_button_states()
n = self.flow.get("step_count") or 0
self._set_status(f"流程已结束,共 {n} 步。可再点「开始流程」开新文档。")
self._set_detail(
f"流程已结束:{self.flow_dir}\n\n"
f"- 共 {n} 步\n"
f"- 请打开 SUMMARY.md 查看总览\n"
)
messagebox.showinfo("UI Inspector", f"流程已结束\n共 {n} 步\n{self.flow_dir}")
except Exception as exc:
messagebox.showerror("UI Inspector", str(exc))
def on_end_flow(self) -> None:
if not self.flow_dir or not self.flow:
messagebox.showwarning("UI Inspector", "没有进行中的流程。")
return
if self.flow.get("status") != "open":
messagebox.showinfo("UI Inspector", "流程已经结束。")
return
self._stop_auto_loop()
# Keep「自动拾取」勾选状态(默认开),方便下一轮直接开始。
if self._busy:
self._pending_end = True
self._set_status("已请求结束:等待当前这一拍 dump/保存完成…")
self._refresh_button_states()
return
self._pending_end = False
self._finish_end_flow()
def on_pick_page(self, *, auto: bool = False) -> None:
if self._busy:
return
if not self._flow_is_open():
if not auto:
messagebox.showwarning("UI Inspector", "请先点「开始流程」。")
return
if auto and not self._auto_loop_active:
return
self._busy = True
self._refresh_button_states()
self._show_picking_placeholder(auto=auto)
self.root.update_idletasks()
def work() -> None:
try:
uia.ensure_com_sta()
# Keep panel topmost for progress; selection excludes Inspector hwnd.
self.root.after(0, lambda: self._set_inspector_topmost(True))
flow = art.load_flow(self.flow_dir) # type: ignore[arg-type]
if flow.get("status") != "open":
raise RuntimeError("流程已结束,取消本步拾取。")
info, cands = uia.find_capture_target(
exclude_hwnds=self._exclude_hwnds(),
prefer_pid=None if auto else self.sticky_pid,
)
# Prefer light after PID already warmed; dump_window_tree escalates
# to full poke if locatable still sparse (still no force maximize).
warmup_mode = (
"light" if int(info.pid) in self._warmed_pids else "full"
)
tree, index, stats, locatable = uia.dump_window_tree(
info.hwnd,
max_depth=self.max_depth,
max_nodes=self.max_nodes,
only_visible=False,
warmup_mode=warmup_mode,
merge_same_pid_if_sparse=True,
)
self._warmed_pids.add(int(info.pid))
window = info.to_dict()
label = art.resolve_step_label(
flow,
window_title=info.title or "",
window_class=info.class_name or "",
locatable=locatable,
)
step_dir, step_idx, step_name = art.add_step_dir(
self.flow_dir, # type: ignore[arg-type]
flow,
window_title=info.title or "",
window_class=info.class_name or "",
locatable=locatable,
label=label,
)
meta = art.build_meta(
window=window,
scene=flow.get("flow_name") or "flow",
extra={
"flow_dir": str(self.flow_dir),
"step_index": step_idx,
"step_name": step_name,
"step_label": label,
"auto_pick": auto,
"dump_stats": stats,
"selection": {
"strategy": "z-order+last-active-popup",
"sticky_pid": self.sticky_pid,
"candidate_count": len(cands),
},
},
)
art.write_json(step_dir / "meta.json", meta)
art.write_json(step_dir / "tree.json", tree)
art.write_jsonl(step_dir / "index.jsonl", index)
art.write_json(step_dir / "locatable.json", locatable)
png_ok = art.capture_window_png(info.hwnd, step_dir / "screen.png")
noteworthy = art.noteworthy_from_index(index)
art.write_step_summary(
step_dir, meta=meta, stats=stats, noteworthy=noteworthy
)
art.register_step(
self.flow_dir, # type: ignore[arg-type]
flow,
step_index=step_idx,
step_name=step_name,
window=window,
stats=stats,
label=label,
)
flow = art.load_flow(self.flow_dir) # type: ignore[arg-type]
loc_text = uia.format_locatable_text(locatable)
tree_text = uia.format_tree_text(tree)
import kit_wake
warm = stats.get("warmup") or {}
kit = stats.get("kit_wake") or {}
kit_line = kit_wake.format_kit_wake_line(kit)
sparse_note = ""
if stats.get("uia_sparse"):
sparse_note = "\n⚠ uia_sparse=true:可定位候选偏少,请结合 screen.png。\n"
mode = "自动" if auto else "手动"
header = (
f"流程步骤: #{step_idx} `{step_name}`({mode})\n"
f"窗口: title={info.title!r} class={info.class_name!r} "
f"pid={info.pid} hwnd={info.hwnd}\n"
f"kit: {kit_line}\n"
f"软poke: findall {warm.get('findall_before')} → {warm.get('findall_after')} "
f"woke={warm.get('woke')}\n"
f"统计: kept={stats.get('nodes_kept')} findall={stats.get('findall_count')} "
f"locatable={stats.get('locatable_count')} png={png_ok} "
f"sparse={stats.get('uia_sparse')} cold={stats.get('provider_cold')}\n"
f"已写入: {step_dir}\n"
f"流程文档: {self.flow_dir}\n"
f"{sparse_note}"
f"{'=' * 60}\n"
)
body = header + "\n" + loc_text + "\n\n" + ("=" * 60) + "\n控件树:\n" + tree_text
self.root.after(
0,
lambda i=info, f=flow, t=body, sn=step_name, a=auto: self._on_pick_ok(
i, f, t, sn, auto=a
),
)
except Exception as exc:
err = exc
self.root.after(0, lambda e=err, a=auto: self._on_fail(e, auto=a))
threading.Thread(target=work, daemon=True).start()
def _on_pick_ok(
self,
info: uia.WindowInfo,
flow: dict,
text: str,
step_name: str,
*,
auto: bool = False,
) -> None:
self._busy = False
self.flow = flow
self.last_window = info.to_dict()
self.sticky_pid = int(info.pid)
self._update_flow_label()
self._refresh_button_states()
self.target_var.set(
f"本步目标: title={info.title!r} class={info.class_name!r} | step=`{step_name}`"
)
self._set_detail(text)
if not auto:
self._set_status(
f"第 {flow.get('step_count')} 步已写入。可换页继续拾取,或点「结束流程」。"
)
try:
self.root.lift()
self.root.attributes("-topmost", True)
except Exception:
pass
if self._pending_end:
self._pending_end = False
self._finish_end_flow()
return
# auto: either pending end, or continue loop after gap
self._continue_auto_loop_after_pick()
def _on_fail(self, exc: BaseException, *, auto: bool = False) -> None:
self._busy = False
self._refresh_button_states()
self._set_status(f"失败: {exc}")
if not auto:
messagebox.showerror("UI Inspector", str(exc))
if self._pending_end:
self._pending_end = False
self._finish_end_flow()
return
# Auto: do not pop modal; keep looping after gap (unless ending)
self._continue_auto_loop_after_pick()
def _on_close(self) -> None:
self._stop_auto_loop()
self._pending_end = False
self.root.destroy()
def run(self) -> None:
self.captures_root.mkdir(parents=True, exist_ok=True)
self.root.mainloop()
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
p = argparse.ArgumentParser(
description="OpenClaw desktop UI Inspector v0.6(流程文档:开始/拾取/结束/自动串行)"
)
p.add_argument("--out", type=Path, default=None, help="输出根目录")
p.add_argument("--scene", default="desktop", help="默认流程名")
p.add_argument("--max-depth", type=int, default=16)
p.add_argument("--max-nodes", type=int, default=8000)
return p.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
out = args.out.resolve() if args.out else art.default_captures_root(_HERE)
try:
uia.ensure_uia()
except Exception as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
app = InspectorApp(
captures_root=out,
scene=args.scene,
max_depth=args.max_depth,
max_nodes=args.max_nodes,
)
app.run()
return 0
if __name__ == "__main__":
raise SystemExit(main())