2026-07-16 01:19:47 +08:00
|
|
|
|
"""L3 Actions 原子动作库:find / click / type / wait / screenshot。
|
|
|
|
|
|
|
|
|
|
|
|
Actions 是 L1 Backend 与 L2 Locator 的组合层:给定 kind 与窗口几何,
|
|
|
|
|
|
Locator 解析为 ElementHandle(图像优先、几何兜底),Backend 据此执行点击/输入。
|
|
|
|
|
|
L4 Flow 通过 Actions 组合出完整业务流程(如 SendTextFlow)。
|
|
|
|
|
|
|
|
|
|
|
|
策略:
|
|
|
|
|
|
- 图像路径(P2 启用):sel.by_image 非空且 locator.is_circuited 为 False 且 opencv 非 None 时优先
|
|
|
|
|
|
- 命中:record_image_success,返回 strategy=image
|
|
|
|
|
|
- 未命中:record_image_failure;require_image=True 抛 ElementNotFoundError;否则回退 _geom_find
|
|
|
|
|
|
- 几何兜底(P1 默认):按 GeomSpec 计算绝对坐标
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
|
|
import logging
|
|
|
|
|
|
import time
|
|
|
|
|
|
from typing import Optional
|
|
|
|
|
|
|
|
|
|
|
|
from woc_bridge.ui.backends.base import BackendProtocol, WindowGeometry
|
|
|
|
|
|
from woc_bridge.ui.locators.base import ElementHandle, Selector
|
|
|
|
|
|
from woc_bridge.ui.locators.registry import LocatorRegistry
|
|
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger("woc-bridge")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ElementNotFoundError(Exception):
|
|
|
|
|
|
"""元素未找到(图像匹配失败且 require_image=True,或几何规格缺失)。
|
|
|
|
|
|
|
|
|
|
|
|
TODO: P3 时统一到 errors.py 的 FlowError(code="ELEMENT_NOT_FOUND")。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Actions:
|
|
|
|
|
|
"""L3 原子动作库:组合 Backend(L1)与 Locator(L2)。
|
|
|
|
|
|
|
|
|
|
|
|
所有方法均为 async,由 L4 Flow 编排调用。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(
|
|
|
|
|
|
self, backend: BackendProtocol, locator: LocatorRegistry
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""初始化 Actions。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
backend: L1 Backend 实例(如 XdotoolBackend)
|
|
|
|
|
|
locator: L2 LocatorRegistry 实例
|
|
|
|
|
|
"""
|
|
|
|
|
|
self.backend = backend
|
|
|
|
|
|
self.locator = locator
|
2026-07-17 18:10:31 +08:00
|
|
|
|
# 截图缓存:同一次 flow 内连续查找可避免重复截图(CPU/IO 优化)
|
|
|
|
|
|
self._shot_cache: Optional[tuple[float, bytes]] = None
|
|
|
|
|
|
self._shot_cache_ttl_ms = 150
|
|
|
|
|
|
|
|
|
|
|
|
def _get_cached_screenshot(self) -> Optional[bytes]:
|
|
|
|
|
|
"""返回未过期的缓存截图,若无则 None。"""
|
|
|
|
|
|
if self._shot_cache is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
ts, shot = self._shot_cache
|
|
|
|
|
|
if (time.monotonic() - ts) * 1000 > self._shot_cache_ttl_ms:
|
|
|
|
|
|
self._shot_cache = None
|
|
|
|
|
|
return None
|
|
|
|
|
|
return shot
|
|
|
|
|
|
|
|
|
|
|
|
def _set_cached_screenshot(self, shot: bytes) -> None:
|
|
|
|
|
|
"""写入截图缓存。"""
|
|
|
|
|
|
self._shot_cache = (time.monotonic(), shot)
|
2026-07-16 01:19:47 +08:00
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# 元素查找
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
async def find_element(
|
|
|
|
|
|
self, kind: str, win_geom: WindowGeometry
|
|
|
|
|
|
) -> ElementHandle:
|
|
|
|
|
|
"""查找元素,优先图像匹配,失败回退几何兜底。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
kind: 元素类别键(如 search_box / send_button)
|
|
|
|
|
|
win_geom: 当前窗口几何(绝对坐标 + 宽高)
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
ElementHandle:命中元素(含绝对坐标与策略)
|
|
|
|
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
|
|
ElementNotFoundError: require_image=True 且图像匹配失败,
|
|
|
|
|
|
或 by_geom 为空且无图像命中
|
|
|
|
|
|
"""
|
|
|
|
|
|
sel = self.locator.get(kind)
|
|
|
|
|
|
|
|
|
|
|
|
# 图像路径(P2 启用,P1 阶段 opencv=None 直接跳过)
|
|
|
|
|
|
if (
|
|
|
|
|
|
sel.by_image
|
|
|
|
|
|
and not self.locator.is_circuited(kind)
|
|
|
|
|
|
and self.locator.opencv is not None
|
|
|
|
|
|
):
|
2026-07-17 18:10:31 +08:00
|
|
|
|
shot = self._get_cached_screenshot()
|
|
|
|
|
|
if shot is None:
|
|
|
|
|
|
shot = await self.backend.screenshot()
|
|
|
|
|
|
self._set_cached_screenshot(shot)
|
2026-07-16 01:19:47 +08:00
|
|
|
|
roi = (win_geom.x, win_geom.y, win_geom.width, win_geom.height)
|
|
|
|
|
|
try:
|
|
|
|
|
|
result = await self.locator.opencv.find_template(
|
|
|
|
|
|
shot,
|
|
|
|
|
|
sel.by_image,
|
|
|
|
|
|
threshold=sel.threshold,
|
|
|
|
|
|
roi=roi,
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
"[action] opencv.find_template exception kind=%s: %s",
|
|
|
|
|
|
kind,
|
|
|
|
|
|
exc,
|
|
|
|
|
|
)
|
|
|
|
|
|
result = None
|
|
|
|
|
|
|
|
|
|
|
|
if result is not None:
|
|
|
|
|
|
# result = (x, y, w, h, confidence)(绝对坐标,含 ROI 偏移)
|
|
|
|
|
|
x, y, w, h, conf = result
|
|
|
|
|
|
self.locator.record_image_success(kind)
|
2026-07-17 18:10:31 +08:00
|
|
|
|
logger.info(
|
|
|
|
|
|
"[action] image match success kind=%s at (%d,%d) conf=%.3f",
|
|
|
|
|
|
kind, x + w // 2, y + h // 2, conf,
|
|
|
|
|
|
)
|
2026-07-16 01:19:47 +08:00
|
|
|
|
return ElementHandle(
|
|
|
|
|
|
selector=sel,
|
|
|
|
|
|
x=x + w // 2,
|
|
|
|
|
|
y=y + h // 2,
|
|
|
|
|
|
w=w,
|
|
|
|
|
|
h=h,
|
|
|
|
|
|
confidence=conf,
|
|
|
|
|
|
strategy="image",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 图像未命中
|
|
|
|
|
|
self.locator.record_image_failure(kind)
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
"[action] image match failed kind=%s, fall back to geom", kind
|
|
|
|
|
|
)
|
|
|
|
|
|
if sel.require_image:
|
|
|
|
|
|
raise ElementNotFoundError(
|
|
|
|
|
|
f"image_match_failed kind={kind}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-17 18:10:31 +08:00
|
|
|
|
logger.debug("[action] geom fallback kind=%s", kind)
|
2026-07-16 01:19:47 +08:00
|
|
|
|
return self._geom_find(sel, win_geom)
|
|
|
|
|
|
|
|
|
|
|
|
async def click_element(
|
|
|
|
|
|
self, kind: str, win_geom: WindowGeometry
|
|
|
|
|
|
) -> ElementHandle:
|
|
|
|
|
|
"""查找并点击元素,返回 ElementHandle。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
kind: 元素类别键
|
|
|
|
|
|
win_geom: 当前窗口几何
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
命中的 ElementHandle
|
|
|
|
|
|
"""
|
|
|
|
|
|
elem = await self.find_element(kind, win_geom)
|
|
|
|
|
|
await self.backend.click(elem.x, elem.y)
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
"[action] click %s at (%d,%d) strategy=%s conf=%.3f",
|
|
|
|
|
|
kind,
|
|
|
|
|
|
elem.x,
|
|
|
|
|
|
elem.y,
|
|
|
|
|
|
elem.strategy,
|
|
|
|
|
|
elem.confidence,
|
|
|
|
|
|
)
|
|
|
|
|
|
return elem
|
|
|
|
|
|
|
|
|
|
|
|
async def type_into(
|
|
|
|
|
|
self,
|
|
|
|
|
|
kind: str,
|
|
|
|
|
|
text: str,
|
|
|
|
|
|
win_geom: WindowGeometry,
|
|
|
|
|
|
clear_first: bool = True,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""点击元素聚焦后输入文本,可选先清空。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
kind: 元素类别键(通常为 input_box)
|
|
|
|
|
|
text: 要输入的文本
|
|
|
|
|
|
win_geom: 当前窗口几何
|
|
|
|
|
|
clear_first: 是否先 BackSpace×30 清空旧内容
|
|
|
|
|
|
"""
|
2026-07-17 18:10:31 +08:00
|
|
|
|
t0 = time.perf_counter()
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
"[action] type_into start kind=%s text_len=%d clear_first=%s",
|
|
|
|
|
|
kind, len(text), clear_first,
|
|
|
|
|
|
)
|
2026-07-16 01:19:47 +08:00
|
|
|
|
await self.click_element(kind, win_geom)
|
|
|
|
|
|
if clear_first:
|
|
|
|
|
|
await self.backend.key_press("BackSpace", repeat=30)
|
|
|
|
|
|
await self.backend.type_text(text)
|
2026-07-17 18:10:31 +08:00
|
|
|
|
logger.info(
|
|
|
|
|
|
"[action] type_into done kind=%s text_len=%d (%.0fms)",
|
|
|
|
|
|
kind, len(text), (time.perf_counter() - t0) * 1000,
|
|
|
|
|
|
)
|
2026-07-16 01:19:47 +08:00
|
|
|
|
|
|
|
|
|
|
async def wait_for(
|
|
|
|
|
|
self,
|
|
|
|
|
|
kind: str,
|
|
|
|
|
|
win_geom: WindowGeometry,
|
|
|
|
|
|
timeout: float = 5.0,
|
|
|
|
|
|
interval: float = 0.3,
|
|
|
|
|
|
) -> Optional[ElementHandle]:
|
|
|
|
|
|
"""轮询等待元素出现,超时返回 None。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
kind: 元素类别键
|
|
|
|
|
|
win_geom: 当前窗口几何
|
|
|
|
|
|
timeout: 最大等待秒数
|
|
|
|
|
|
interval: 轮询间隔秒数
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
ElementHandle 或 None(超时)
|
|
|
|
|
|
"""
|
2026-07-17 18:10:31 +08:00
|
|
|
|
t0 = time.perf_counter()
|
2026-07-16 01:19:47 +08:00
|
|
|
|
deadline = time.monotonic() + timeout
|
2026-07-17 18:10:31 +08:00
|
|
|
|
attempts = 0
|
|
|
|
|
|
last_err: Optional[Exception] = None
|
2026-07-16 01:19:47 +08:00
|
|
|
|
while time.monotonic() < deadline:
|
2026-07-17 18:10:31 +08:00
|
|
|
|
attempts += 1
|
2026-07-16 01:19:47 +08:00
|
|
|
|
try:
|
|
|
|
|
|
elem = await self.find_element(kind, win_geom)
|
|
|
|
|
|
if elem:
|
2026-07-17 18:10:31 +08:00
|
|
|
|
logger.info(
|
|
|
|
|
|
"[action] wait_for OK kind=%s strategy=%s attempts=%d (%.0fms)",
|
|
|
|
|
|
kind, elem.strategy, attempts,
|
|
|
|
|
|
(time.perf_counter() - t0) * 1000,
|
|
|
|
|
|
)
|
2026-07-16 01:19:47 +08:00
|
|
|
|
return elem
|
2026-07-17 18:10:31 +08:00
|
|
|
|
except ElementNotFoundError as e:
|
|
|
|
|
|
last_err = e
|
2026-07-16 01:19:47 +08:00
|
|
|
|
await asyncio.sleep(interval)
|
2026-07-17 18:10:31 +08:00
|
|
|
|
logger.warning(
|
|
|
|
|
|
"[action] wait_for TIMEOUT kind=%s attempts=%d timeout=%.1fs last_err=%s",
|
|
|
|
|
|
kind, attempts, timeout, last_err,
|
|
|
|
|
|
)
|
2026-07-16 01:19:47 +08:00
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
async def screenshot(self) -> bytes:
|
2026-07-17 18:10:31 +08:00
|
|
|
|
"""截图代理方法,带 150ms 缓存。"""
|
|
|
|
|
|
logger.debug("[action] screenshot start")
|
|
|
|
|
|
data = self._get_cached_screenshot()
|
|
|
|
|
|
if data is None:
|
|
|
|
|
|
data = await self.backend.screenshot()
|
|
|
|
|
|
self._set_cached_screenshot(data)
|
|
|
|
|
|
logger.debug(
|
|
|
|
|
|
"[action] screenshot done bytes=%d", len(data) if data else 0
|
|
|
|
|
|
)
|
|
|
|
|
|
return data
|
2026-07-16 01:19:47 +08:00
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# 几何兜底
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
def _geom_find(
|
|
|
|
|
|
self, selector: Selector, win_geom: WindowGeometry
|
|
|
|
|
|
) -> ElementHandle:
|
|
|
|
|
|
"""按 GeomSpec 计算绝对坐标,返回 ElementHandle。
|
|
|
|
|
|
|
2026-07-17 18:10:31 +08:00
|
|
|
|
支持两种 relative_to 语义:
|
|
|
|
|
|
- window: 以窗口左上角为原点
|
2026-07-16 01:19:47 +08:00
|
|
|
|
base_x = win_geom.x + win_geom.width * g.x_ratio + g.x_offset
|
|
|
|
|
|
base_y = win_geom.y + win_geom.height * g.y_ratio + g.y_offset
|
2026-07-17 18:10:31 +08:00
|
|
|
|
- window_bottom_right: 以窗口右下角为原点(offset 含正负号)
|
|
|
|
|
|
base_x = win_geom.x + win_geom.width + g.x_offset
|
|
|
|
|
|
base_y = win_geom.y + win_geom.height + g.y_offset
|
2026-07-16 01:19:47 +08:00
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
selector: 元素选择器(必须含 by_geom)
|
|
|
|
|
|
win_geom: 当前窗口几何
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
ElementHandle(strategy=geom, confidence=1.0)
|
|
|
|
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
|
|
ElementNotFoundError: selector.by_geom 为空
|
|
|
|
|
|
"""
|
|
|
|
|
|
if selector.by_geom is None:
|
|
|
|
|
|
raise ElementNotFoundError(
|
|
|
|
|
|
f"no geom spec kind={selector.kind}"
|
|
|
|
|
|
)
|
|
|
|
|
|
g = selector.by_geom
|
2026-07-17 18:10:31 +08:00
|
|
|
|
if g.relative_to == "window_bottom_right":
|
|
|
|
|
|
base_x = win_geom.x + win_geom.width + g.x_offset
|
|
|
|
|
|
base_y = win_geom.y + win_geom.height + g.y_offset
|
|
|
|
|
|
else:
|
|
|
|
|
|
base_x = win_geom.x + win_geom.width * g.x_ratio + g.x_offset
|
|
|
|
|
|
base_y = win_geom.y + win_geom.height * g.y_ratio + g.y_offset
|
|
|
|
|
|
logger.debug(
|
|
|
|
|
|
"[action] geom_find kind=%s relative_to=%s win=(%d,%d,%dx%d) "
|
|
|
|
|
|
"ratio=(%.3f,%.3f) offset=(%d,%d) → abs=(%d,%d)",
|
|
|
|
|
|
selector.kind, g.relative_to,
|
|
|
|
|
|
win_geom.x, win_geom.y, win_geom.width, win_geom.height,
|
|
|
|
|
|
g.x_ratio, g.y_ratio, g.x_offset, g.y_offset,
|
|
|
|
|
|
int(base_x), int(base_y),
|
|
|
|
|
|
)
|
2026-07-16 01:19:47 +08:00
|
|
|
|
return ElementHandle(
|
|
|
|
|
|
selector=selector,
|
|
|
|
|
|
x=int(base_x),
|
|
|
|
|
|
y=int(base_y),
|
|
|
|
|
|
w=0,
|
|
|
|
|
|
h=0,
|
|
|
|
|
|
confidence=1.0,
|
|
|
|
|
|
strategy="geom",
|
|
|
|
|
|
)
|