Files
butubb ed9e8bacb1 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 条用例、扇出单元用例、本地工具契约用例全绿。
自测产生的任务/草稿已全部清理(未碰用户既有数据)。
2026-09-14 08:14:11 +08:00

205 lines
8.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""平台(auto_control)HTTP 客户端:登录会话 + CSRF + API 封装。
会话失效自动重登;CSRF token 每次登录后获取,POST 必带。
只读接口(GET)与写接口(POST)分离封装,写操作由 MCP 门控层决定是否调用。
"""
import httpx
import logging
_log = logging.getLogger("mcp.platform")
_CSRF_HEADER = "X-CSRF-Token"
class PlatformError(Exception):
def __init__(self, code, message):
super().__init__(message)
self.code = code
self.message = message
class PlatformClient:
def __init__(self, url, user, password, timeout=30.0):
self.url = url.rstrip("/")
self.user = user
self.password = password
self._client = httpx.Client(base_url=self.url, timeout=timeout,
follow_redirects=True)
self._csrf = ""
self._login()
# ---------- 会话 ----------
def _login(self):
"""登录平台,拿会话 cookie + CSRF token。"""
r = self._client.post("/login", data={
"username": self.user, "password": self.password})
if r.status_code != 302 and r.status_code != 200:
raise PlatformError("platform_unavailable",
f"平台登录失败 HTTP {r.status_code}: {r.text[:120]}")
csrf = self._client.get("/api/csrf")
if csrf.status_code == 200:
self._csrf = (csrf.json() or {}).get("token", "")
_log.info("平台登录成功: %s", self.user)
def _ensure_session(self):
"""会话过期(401/403/302 到登录页)时重登。"""
r = self._client.get("/api/status")
if r.status_code in (401, 403) or r.url.path.endswith("/login"):
_log.warning("会话失效,重新登录")
self._login()
return True
return False
# ---------- 基础请求 ----------
def _get(self, path, **params):
self._ensure_session()
return self._client.get(path, params=params)
def _post(self, path, json=None):
self._ensure_session()
headers = {_CSRF_HEADER: self._csrf} if self._csrf else {}
return self._client.post(path, json=json or {}, headers=headers)
# ---------- 平台 API 封装(M0) ----------
def list_devices(self):
"""设备列表与状态(GET /api/status)。"""
r = self._get("/api/status")
if r.status_code != 200:
raise PlatformError("platform_unavailable",
f"/api/status HTTP {r.status_code}")
j = r.json() or {}
return j.get("devices", [])
def screenshot(self, serial):
"""截图(GET /api/screen/thumb),返回 (jpeg_bytes, screen_state)。"""
r = self._get("/api/screen/thumb", serial=serial)
if r.status_code == 503:
raise PlatformError("device_offline", r.text[:120])
if r.status_code != 200:
raise PlatformError("platform_unavailable",
f"截图 HTTP {r.status_code}")
return r.content, r.headers.get("X-Screen-State", "unknown")
def snapshot(self, serial):
"""一次取齐截图+元素树(GET /api/uiauto/snapshot)。
平台侧用**同一个 u2 连接**背靠背 dump+screenshot,并做双截图校验
(unstable 表示"抓取期间界面在变化")。返回平台 JSON:
{ok, image(data-url), width, height, elements, unstable, screen_state, cost_ms}
"""
r = self._get("/api/uiauto/snapshot", serial=serial, _t=0)
if r.status_code in (502, 503):
raise PlatformError("device_offline", r.text[:160])
if r.status_code != 200:
raise PlatformError("platform_unavailable",
f"快照 HTTP {r.status_code}")
j = r.json() or {}
if not j.get("ok"):
raise PlatformError("device_offline", str(j.get("error", "快照失败"))[:160])
return j
def screen_size(self, serial):
"""屏幕原生分辨率(GET /api/screen/size),返回 (w, h)。"""
r = self._get("/api/screen/size", serial=serial)
if r.status_code == 503:
raise PlatformError("device_offline", r.text[:120])
if r.status_code != 200:
raise PlatformError("platform_unavailable",
f"分辨率 HTTP {r.status_code}")
j = r.json() or {}
if not j.get("ok"):
raise PlatformError("device_offline", str(j.get("error", "取分辨率失败"))[:120])
return int(j["width"]), int(j["height"])
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),
"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={
"serial": serial, "x1": int(x1), "y1": int(y1),
"x2": int(x2), "y2": int(y2),
"duration": float(duration)})
return self._check_op(r, "swipe")
def ui_elements(self, serial):
"""UI 元素树(GET /api/uiauto/elements,uiautodev 服务)。"""
r = self._get("/api/uiauto/elements", serial=serial)
if r.status_code == 503:
raise PlatformError("device_offline", r.text[:120])
if r.status_code != 200:
raise PlatformError("platform_unavailable",
f"元素树 HTTP {r.status_code}")
j = r.json() or {}
if not j.get("ok"):
raise PlatformError("device_offline", str(j.get("error", "取元素失败"))[:120])
return j.get("elements", [])
def wake(self, serial):
"""亮屏并解锁(POST /api/device/screen_all mode=on)。"""
r = self._post("/api/device/screen_all",
json={"mode": "on", "serials": [serial]})
return self._check_op(r, "wake")
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",
json={"serial": serial, "key": key})
return self._check_op(r, "key")
@staticmethod
def _check_op(r, name):
if r.status_code == 503:
raise PlatformError("device_offline", r.text[:120])
if r.status_code != 200:
raise PlatformError("platform_unavailable",
f"{name} HTTP {r.status_code}: {r.text[:120]}")
j = r.json() or {}
if not j.get("ok"):
raise PlatformError("device_offline", str(j.get("error", "操作失败"))[:120])
return j