用户报的"探索完无法点击创建任务"真因:一个 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 条用例、扇出单元用例、本地工具契约用例全绿。 自测产生的任务/草稿已全部清理(未碰用户既有数据)。
205 lines
8.5 KiB
Python
205 lines
8.5 KiB
Python
"""平台(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
|