fix: AI 点击精度优化——①坐标吸附:/api/screen/tap 加 snap=1,MCP de_tap 固定开启(dump UI 树找包含点击点的最小可点击元素点中心,模型坐标偏 20-50px 也点得准,大屏触控不带 snap 行为不变);②新 de_tap_text 语义点击:按屏幕可见文字一次完成找+点(UI 树 textContains/descriptionContains → OCR 中心兜底,WebView/图片文字也能点);③de_tap_element 支持 text_contains/desc_contains 模糊匹配;④de_ui_tree 可点击元素优先 + limit 参数防 token 膨胀;⑤agent 提示词重写:文字语义点击优先、坐标仅纯图形兜底且自动吸附、点击后无变化禁止重复同坐标

This commit is contained in:
2026-09-04 14:29:17 +08:00
parent e5018c4e45
commit 44a9dafcd8
4 changed files with 203 additions and 49 deletions
+65 -15
View File
@@ -129,10 +129,11 @@ def de_screenshot(serial: str) -> dict:
@mcp.tool()
def de_tap(serial: str, x: int, y: int) -> dict:
"""点击设备屏幕指定坐标。
"""点击设备屏幕指定坐标(坐标空间 = de_screenshot 的图像坐标)。
坐标空间 = de_screenshot 返回的图像坐标(display 空间)——先截图拿到
native_size 后再点击,server 自动换算为设备原生坐标。
自动吸附:若该点落在某个可点击元素内,实际点击会改为该元素的中心——
坐标只需大致对准目标即可(模型视觉定位常有偏差,吸附保证点准);
点在空白处则按原坐标点击。返回中的 snapped/label 可核对吸附结果。
"""
try:
_check_write()
@@ -140,11 +141,16 @@ def de_tap(serial: str, x: int, y: int) -> dict:
if x < 0 or y < 0:
raise PlatformError("invalid_param", "坐标不能为负")
nx, ny = _to_native(serial, x, y)
platform().tap(serial, nx, ny)
res = platform().tap(serial, nx, ny, snap=True)
except PlatformError as e:
return _err(e)
audit.audit("de_tap", serial, f"({x},{y})->native({nx},{ny})", "ok")
return _ok({"action": "tap", "serial": serial, "x": x, "y": y})
audit.audit("de_tap", serial,
f"({x},{y})->native({nx},{ny})"
+ (f" 吸附[{res.get('label')}]" if res.get("snapped") else ""),
"ok")
return _ok({"action": "tap", "serial": serial, "x": x, "y": y,
"snapped": bool(res.get("snapped")),
"label": res.get("label") or ""})
@mcp.tool()
@@ -168,17 +174,21 @@ _KEYS = ("back", "home", "recent", "menu", "power", "volume_up",
@mcp.tool()
def de_ui_tree(serial: str) -> dict:
def de_ui_tree(serial: str, limit: int = 150) -> dict:
"""获取当前界面元素树(文本 JSON):每元素含 text/resource_id/description/class/bounds。
优先用它定位元素(元素驱动操作),比纯坐标点击更可靠。
可点击元素排在前面(可点性优先)。多数场景不需要读整棵树——直接给
de_tap_text 一个屏幕上可见的文字即可自动定位点击;本工具用于确认界面
上有什么、元素文案是否与预想一致。limit 控制返回条数(默认 150,防 token 膨胀)。
"""
try:
serial = _check_serial(serial)
if limit < 1 or limit > 300:
raise PlatformError("invalid_param", "limit 需在 1-300 之间")
els = platform().ui_elements(serial)
except PlatformError as e:
return _err(e)
# 精简输出:去掉 suggested/深度噪音,保留可定位属性
# 精简输出:去掉 suggested/深度噪音,保留可定位属性;可点击优先、有文案优先
slim = []
for e in els:
slim.append({
@@ -186,30 +196,39 @@ def de_ui_tree(serial: str) -> dict:
"id": e.get("resource_id", "")[:80],
"desc": e.get("description", "")[:50],
"class": e.get("class", "").split(".")[-1],
"clickable": e.get("clickable", "") == "true",
"bounds": e.get("bounds", ""),
})
slim.sort(key=lambda x: (not x["clickable"], not (x["text"] or x["desc"])))
audit.audit("de_ui_tree", serial, "", f"{len(slim)} 元素")
return _ok({"count": len(slim), "elements": slim[:300]})
return _ok({"count": len(slim), "elements": slim[:limit]})
@mcp.tool()
def de_tap_element(serial: str, by: str, value: str, index: int = 1) -> dict:
"""按元素点击(不需要坐标):by=text|id|desc,value 为匹配文本/资源 id/描述。
"""按元素点击(不需要坐标):by=text|id|desc|text_contains|desc_contains。
text/id/desc 为精确匹配;text_contains/desc_contains 为子串模糊匹配
(只记得部分文字时用,如 by=text_contains value=搜索)。
元素驱动操作比坐标可靠(界面变化自适应);元素不存在时返回错误,
可改用 de_ui_tree 查元素或 de_tap 坐标兜底。index 用于多命中取第几个(默认 1)。
可改用 de_ui_tree 查元素 / de_tap_text 按屏幕文字点 / de_tap 坐标兜底。
index 用于多命中取第几个(默认 1)。
"""
try:
_check_write()
serial = _check_serial(serial)
if by not in ("text", "id", "desc"):
raise PlatformError("invalid_param", "by 可选 text/id/desc")
if by not in ("text", "id", "desc", "text_contains", "desc_contains"):
raise PlatformError("invalid_param",
"by 可选 text/id/desc/text_contains/desc_contains")
if not value or index < 1:
raise PlatformError("invalid_param", "value 不能为空且 index>=1")
import uiautomator2 as u2
d = u2.connect(serial)
kw = {"text": value} if by == "text" else (
{"resourceId": value} if by == "id" else {"description": value})
{"resourceId": value} if by == "id" else (
{"description": value} if by == "desc" else (
{"textContains": value} if by == "text_contains"
else {"descriptionContains": value})))
if index > 1:
kw["instance"] = index - 1
el = d(**kw)
@@ -397,6 +416,37 @@ def de_ocr(serial: str) -> dict:
return _ok({"count": len(slim), "texts": slim[:100]})
@mcp.tool()
def de_tap_text(serial: str, text: str) -> dict:
"""点击屏幕上显示该文字的位置(语义点击:一次调用完成「找到并点击」,无需坐标)。
想点带文字的按钮/列表项/标签/链接时用它:text 只需是屏幕上可见文字的
一部分(子串匹配,如「搜索」「立即购买」)。原生控件直接命中;
WebView/图片/画布里渲染的文字自动走 OCR 兜底。多命中点第一处(想点
更靠下的请把文字换独特些)。屏幕确实没有该文字时返回错误提示,
请截图确认后换关键词。比 de_tap 坐标点击可靠,涉及文字目标时优先使用。
"""
try:
_check_write()
serial = _check_serial(serial)
if not text or len(text) > 100:
raise PlatformError("invalid_param", "text 不能为空且 ≤100 字符")
res = platform().tap_text(serial, text)
except PlatformError as e:
return _err(e)
if not res.get("found"):
err = PlatformError("text_not_found",
f"屏幕上未找到文字「{text}」——先 de_screenshot 看当前界面,"
f"换用屏幕上实际存在的文字;若文字在需滑动后才可见请先滑动")
audit.audit("de_tap_text", serial, f"「{text[:30]}」", "未找到")
return _err(err)
audit.audit("de_tap_text", serial,
f"「{text[:30]}」via {res.get('method')} @({res.get('x')},{res.get('y')})", "ok")
return _ok({"action": "tap_text", "serial": serial, "text": text,
"method": res.get("method"), "matched": res.get("matched") or text,
"x": res.get("x"), "y": res.get("y")})
@mcp.tool()
def de_list_apps(serial: str, keyword: str = "") -> dict:
"""列出设备第三方已装应用包名(可关键词过滤,如 keyword='douyin' 找抖音)。"""
+23 -24
View File
@@ -93,12 +93,32 @@ class PlatformClient:
raise PlatformError("device_offline", str(j.get("error", "取分辨率失败"))[:120])
return int(j["width"]), int(j["height"])
def tap(self, serial, x, y):
"""点击(POST /api/screen/tap)。"""
def tap(self, serial, x, y, snap=False):
"""点击(POST /api/screen/tap)。
snap=True:点落在可点击元素内则吸附到元素中心(AI 粗略坐标也能点准)。
返回平台 JSON(含 snapped/x/y/label)。
"""
r = self._post("/api/screen/tap", json={"serial": serial,
"x": int(x), "y": int(y)})
"x": int(x), "y": int(y),
"snap": 1 if snap else 0})
return self._check_op(r, "tap")
def tap_text(self, serial, text):
"""按屏幕文字点击(平台解析:UI 树子串匹配 → OCR 兜底)。
返回 {ok, found, method, matched, x, y}——found=false 是业务结果
(屏幕无该文字),非设备错误;设备离线/不可达仍抛 PlatformError。
"""
r = self._post("/api/screen/tap_text",
json={"serial": serial, "text": str(text)})
if r.status_code == 503:
raise PlatformError("device_offline", r.text[:120])
if r.status_code != 200:
raise PlatformError("platform_unavailable",
f"tap_text HTTP {r.status_code}: {r.text[:120]}")
return r.json() or {}
def swipe(self, serial, x1, y1, x2, y2, duration=0.2):
"""滑动(POST /api/screen/swipe)。"""
r = self._post("/api/screen/swipe", json={
@@ -147,27 +167,6 @@ class PlatformClient:
"schedule": (t.get("schedule") or {}).get("mode", "")})
return tasks
def sleep(self, serial):
"""熄屏(POST /api/device/screen_all mode=off)。"""
r = self._post("/api/device/screen_all",
json={"mode": "off", "serials": [serial]})
return self._check_op(r, "sleep")
def list_tasks(self):
"""任务计划列表(GET /api/jobs)。"""
r = self._get("/api/jobs")
if r.status_code != 200:
raise PlatformError("platform_unavailable",
f"/api/jobs HTTP {r.status_code}")
j = r.json() or {}
tasks = []
for t in j.get("jobs") or []:
tasks.append({"id": t.get("id"), "name": t.get("name"),
"task_type": t.get("task_type"),
"enabled": t.get("enabled"),
"schedule": (t.get("schedule") or {}).get("mode", "")})
return tasks
def press_key(self, serial, key):
"""按键(POST /api/screen/key)。"""
r = self._post("/api/screen/key",