feat(AI 建任务): 直接创建 + 草稿沉淀 + MCP de_snapshot;修「建任务页收不到 done」
用户报的"探索完无法点击创建任务"真因:一个 run 的事件原先只有**一条** queue.Queue, 聊天页与建任务页同时开着时两个 EventSource 会**瓜分**它——建任务页的回放卡在中间、 `done` 被聊天页取走 → 永远等不到草稿,页面上自然没有可点的"创建"。 一、修(根因 + 表现) - `web/agent_api.py` 新增 `_Fanout`:**每个订阅者一个专属队列**,多开页面各看各的, 还带单轮事件缓冲(晚订阅/刷新重连也能补齐回放,终止事件一定送达)。 实测两路订阅者收到完全一致的 1039 条事件(含 done)。 - `static/admin/agent.js`:断线重连的兜底订阅也按 `mode` 让开(此前漏了这一处)。 二、补齐上一批的三项 - **「直接创建」**:`POST /api/agent/task_draft/create`(草稿体只在服务端、创建前再校验一次、 成功后清草稿避免重复建)+ 草稿预览里的「✓ 直接创建任务」按钮 + 「探索完直接创建任务」勾选框。 - **草稿沉淀经验/动作**:designer 轮次也走 `_distill_experience/_distill_actions`, 但**只在草稿通过校验时**(没走通的试错不入库,免得把误点当经验)。 - **MCP `de_snapshot`**(第 20 个工具):截图+元素树一次取齐(省一次来回、不会因界面在动而错位), 附带 `screen_state`/`unstable`;两套提示词都改为优先用它。 平台侧 `/api/uiauto/snapshot` 随之多返回 `screen_state`。 三、文档 - AI_TASK_GEN §10:§10.3 记两个 bug 的真因与修法、§10.4 三项标完成、§10.5 剩余项。 - AI_CONSOLE(扇出语义、多页面同时看一轮)、API(task_draft/create、snapshot 字段)、 MCP/MCP_DESIGN/staffdeck/README/ARCHITECTURE:工具数 19→20 + de_snapshot 条目。 - backlog:记一条新发现的缺陷——`mcp_server/platform_client._login()` 会把"登录页 200" 当成登录成功(现场进程缺 `MCP_PLATFORM_PASS` 时表现为含糊的 platform_unavailable)。 自测:真机浏览器端到端(勾上"探索完直接创建")→ 探索 12 步 → 草稿 → 自动建任务成功; `de_snapshot` 直连真机校验;校验器 21 条用例、扇出单元用例、本地工具契约用例全绿。 自测产生的任务/草稿已全部清理(未碰用户既有数据)。
This commit is contained in:
+71
-13
@@ -204,6 +204,23 @@ _KEYS = ("back", "home", "recent", "menu", "power", "volume_up",
|
||||
"volume_down", "enter", "delete", "search", "camera")
|
||||
|
||||
|
||||
def _slim_elements(els, limit):
|
||||
"""精简元素列表(de_ui_tree / de_snapshot 共用):去 suggested/深度噪音,
|
||||
可点击优先、有文案优先,控制 token。"""
|
||||
slim = []
|
||||
for e in els:
|
||||
slim.append({
|
||||
"text": e.get("text", "")[:50],
|
||||
"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"])))
|
||||
return slim[:limit]
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def de_ui_tree(serial: str, limit: int = 150) -> dict:
|
||||
"""获取当前界面元素树(文本 JSON):每元素含 text/resource_id/description/class/bounds。
|
||||
@@ -219,20 +236,61 @@ def de_ui_tree(serial: str, limit: int = 150) -> dict:
|
||||
els = platform().ui_elements(serial)
|
||||
except PlatformError as e:
|
||||
return _err(e)
|
||||
# 精简输出:去掉 suggested/深度噪音,保留可定位属性;可点击优先、有文案优先
|
||||
slim = []
|
||||
for e in els:
|
||||
slim.append({
|
||||
"text": e.get("text", "")[:50],
|
||||
"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"])))
|
||||
slim = _slim_elements(els, limit)
|
||||
audit.audit("de_ui_tree", serial, "", f"{len(slim)} 元素")
|
||||
return _ok({"count": len(slim), "elements": slim[:limit]})
|
||||
return _ok({"count": len(slim), "elements": slim})
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def de_snapshot(serial: str, limit: int = 120) -> dict:
|
||||
"""一次取齐:屏幕截图 + 元素树(**推荐用它代替 de_screenshot + de_ui_tree**)。
|
||||
|
||||
那两条是两次独立取数,中间隔着元素树 dump 本身的 1~2 秒——界面只要在动
|
||||
(信息流/视频/加载动画),返回的元素坐标就和截图对不上,照它点击会点偏。
|
||||
本工具在平台侧用**同一个连接背靠背取**,并额外做双截图校验:
|
||||
`unstable=true` 表示"抓取过程中界面在变化",此时别把元素坐标当准的,
|
||||
建议让设备停在静止界面再取一次。
|
||||
|
||||
返回 image(宽 ≤540 的可视截图,同时建立后续 de_tap/de_swipe 的坐标空间)、
|
||||
native_size、screen_state、unstable、cost_ms,以及 elements
|
||||
(结构与 de_ui_tree 相同;limit 控制条数,默认 120)。
|
||||
"""
|
||||
try:
|
||||
serial = _check_serial(serial)
|
||||
if limit < 1 or limit > 300:
|
||||
raise PlatformError("invalid_param", "limit 需在 1-300 之间")
|
||||
snap = platform().snapshot(serial)
|
||||
raw = snap.get("image") or ""
|
||||
b64 = raw.split(",", 1)[1] if "," in raw else raw
|
||||
img = Image.open(io.BytesIO(base64.b64decode(b64)))
|
||||
nw, nh = img.size
|
||||
if nw > S.screenshot_width:
|
||||
ratio = S.screenshot_width / nw
|
||||
img = img.resize((S.screenshot_width, int(nh * ratio)))
|
||||
buf = io.BytesIO()
|
||||
img.convert("RGB").save(buf, "JPEG", quality=S.jpeg_quality)
|
||||
data = base64.b64encode(buf.getvalue()).decode()
|
||||
# 与 de_screenshot 用同一套坐标口径:后续 de_tap/de_swipe 传的是这张图的坐标
|
||||
_coord[serial] = (img.size[0], img.size[1], nw, nh)
|
||||
except PlatformError as e:
|
||||
return _err(e)
|
||||
except Exception as e:
|
||||
return _err(PlatformError("platform_unavailable", f"快照处理失败: {e}"))
|
||||
slim = _slim_elements(snap.get("elements") or [], limit)
|
||||
unstable = bool(snap.get("unstable"))
|
||||
audit.audit("de_snapshot", serial, f"{len(slim)} 元素",
|
||||
"unstable" if unstable else "ok")
|
||||
out = {"image": {"type": "image", "data": data, "mimeType": "image/jpeg"},
|
||||
"width": img.size[0], "height": img.size[1],
|
||||
"native_size": {"width": nw, "height": nh},
|
||||
"screen_state": snap.get("screen_state", "unknown"),
|
||||
"unstable": unstable,
|
||||
"cost_ms": snap.get("cost_ms", 0),
|
||||
"count": len(slim), "elements": slim}
|
||||
if unstable:
|
||||
out["hint"] = ("抓取期间界面在变化(unstable):元素坐标可能已过时,"
|
||||
"让设备停在静止界面再取一次更稳")
|
||||
return _ok(out)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
|
||||
Reference in New Issue
Block a user