785 lines
32 KiB
Python
785 lines
32 KiB
Python
"""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())
|