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>
176 lines
5.3 KiB
Python
176 lines
5.3 KiB
Python
"""UIState model + stable business fingerprint."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import uuid
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Optional
|
|
|
|
SCHEMA_STATE = "openclaw.ui_inspector.state.v1"
|
|
|
|
# Keys ignored when hashing business identity.
|
|
_FINGERPRINT_EXCLUDE_TOP = {
|
|
"captured_at",
|
|
"id",
|
|
"previous_state_id",
|
|
"tree_path",
|
|
"index_path",
|
|
"locatable_path",
|
|
"screenshot_path",
|
|
"duplicate_of",
|
|
"observation_count",
|
|
"change_reason",
|
|
}
|
|
|
|
_SESSION_KEYS = {
|
|
"pid",
|
|
"hwnd",
|
|
"process_id",
|
|
"thread_id",
|
|
"elapsed_ms",
|
|
"dump_ms",
|
|
"warmup_ms",
|
|
"timings",
|
|
"screenshot",
|
|
"screen_path",
|
|
}
|
|
|
|
|
|
def _now_iso() -> str:
|
|
return datetime.now().astimezone().isoformat()
|
|
|
|
|
|
def _new_id(prefix: str = "st") -> str:
|
|
return f"{prefix}_{uuid.uuid4().hex[:12]}"
|
|
|
|
|
|
def _strip_session(obj: Any) -> Any:
|
|
if isinstance(obj, dict):
|
|
out: dict[str, Any] = {}
|
|
for k, v in obj.items():
|
|
lk = str(k).lower()
|
|
if lk in _SESSION_KEYS or lk.endswith("_ms") or lk.endswith("_path"):
|
|
continue
|
|
if lk in {"left", "top", "right", "bottom", "x", "y", "width", "height"}:
|
|
# Absolute coordinates are not primary identity.
|
|
continue
|
|
if lk == "rect" or lk == "bounds" or lk == "bounding_rectangle":
|
|
continue
|
|
out[str(k)] = _strip_session(v)
|
|
return out
|
|
if isinstance(obj, list):
|
|
return [_strip_session(x) for x in obj]
|
|
return obj
|
|
|
|
|
|
def _locatable_signature(rows: list[dict[str, Any]], *, limit: int = 120) -> list[dict[str, Any]]:
|
|
"""Compact visible-business signature for fingerprinting."""
|
|
sig: list[dict[str, Any]] = []
|
|
for r in rows[:limit]:
|
|
if not isinstance(r, dict):
|
|
continue
|
|
entry = {
|
|
"control_type": r.get("control_type") or r.get("role") or "",
|
|
"name": r.get("name") or "",
|
|
"value": r.get("value") or r.get("text") or "",
|
|
"automation_id": r.get("automation_id") or r.get("test_id") or "",
|
|
"class_name": r.get("class_name") or "",
|
|
}
|
|
# Drop empty noise rows
|
|
if not any(str(v).strip() for v in entry.values()):
|
|
continue
|
|
sig.append(entry)
|
|
# Stable order
|
|
sig.sort(
|
|
key=lambda e: (
|
|
str(e.get("control_type") or ""),
|
|
str(e.get("automation_id") or ""),
|
|
str(e.get("name") or ""),
|
|
str(e.get("value") or ""),
|
|
)
|
|
)
|
|
return sig
|
|
|
|
|
|
def compute_state_fingerprint(
|
|
*,
|
|
surface: str,
|
|
driver: str = "",
|
|
url: str = "",
|
|
page_title: str = "",
|
|
window: Optional[dict[str, Any]] = None,
|
|
locatable: Optional[list[dict[str, Any]]] = None,
|
|
app: Optional[dict[str, Any]] = None,
|
|
extra_business: Optional[dict[str, Any]] = None,
|
|
) -> str:
|
|
"""
|
|
SHA-256 of stable business identity.
|
|
|
|
Excludes timestamps, pid/hwnd, screenshot paths, absolute coords, timings.
|
|
"""
|
|
win = dict(window or {})
|
|
app_d = dict(app or {})
|
|
payload = {
|
|
"surface": surface or "",
|
|
"driver": driver or "",
|
|
"url": url or win.get("url") or app_d.get("url") or "",
|
|
"page_title": page_title or "",
|
|
"window": {
|
|
"title": win.get("title") or app_d.get("title") or "",
|
|
"class_name": win.get("class_name") or app_d.get("class_name") or "",
|
|
"process_name": win.get("process_name") or app_d.get("process_name") or "",
|
|
},
|
|
"locatable": _locatable_signature(list(locatable or [])),
|
|
"extra": _strip_session(extra_business or {}),
|
|
}
|
|
raw = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
|
|
|
|
@dataclass
|
|
class UIState:
|
|
schema: str = SCHEMA_STATE
|
|
id: str = field(default_factory=lambda: _new_id("st"))
|
|
surface: str = "windows"
|
|
driver: str = ""
|
|
captured_at: str = field(default_factory=_now_iso)
|
|
fingerprint: str = ""
|
|
previous_state_id: Optional[str] = None
|
|
change_reason: str = ""
|
|
app: dict[str, Any] = field(default_factory=dict)
|
|
window: dict[str, Any] = field(default_factory=dict)
|
|
url: str = ""
|
|
page_title: str = ""
|
|
tree_path: str = ""
|
|
index_path: str = ""
|
|
locatable_path: str = ""
|
|
screenshot_path: str = ""
|
|
stats: dict[str, Any] = field(default_factory=dict)
|
|
selection: dict[str, Any] = field(default_factory=dict)
|
|
duplicate_of: Optional[str] = None
|
|
observation_count: int = 1
|
|
may_contain_sensitive_data: bool = False
|
|
# In-memory locatable used for fingerprint/diff (not always persisted as field).
|
|
locatable: list[dict[str, Any]] = field(default_factory=list, repr=False)
|
|
|
|
def ensure_fingerprint(self) -> str:
|
|
if self.fingerprint:
|
|
return self.fingerprint
|
|
self.fingerprint = compute_state_fingerprint(
|
|
surface=self.surface,
|
|
driver=self.driver,
|
|
url=self.url,
|
|
page_title=self.page_title,
|
|
window=self.window,
|
|
locatable=self.locatable,
|
|
app=self.app,
|
|
)
|
|
return self.fingerprint
|
|
|
|
def bump_observation(self) -> None:
|
|
self.observation_count = int(self.observation_count or 0) + 1
|
|
self.captured_at = _now_iso()
|