3 Commits
Author SHA1 Message Date
butubb 1c2b440dce docs: 新增 doc/backlog/TODO.md(待完成项)并登记进文档索引
收录:adb 远程终端目标切换(本机/220)、未命中要明确提示、uiautodev 超时 8s→30s、
序号型选择器/界面就绪防呆、动作库后续(合并面板/预制件)、MCP 平台级工具与 de_screen_text、
经验召回改进、命名统一、STF 容器状态矛盾、MCP 白名单语义缺口等。
2026-09-10 15:31:42 +08:00
butubb 972db13f81 fix: 经验/动作蒸馏不再静默丢弃(关推理 + 质量门槛 + 截断容忍)+ 前端会话显示 ID
问题:蒸馏模型把 token 预算烧在 reasoning 上 → content 为空/被截断(finish_reason=length),
代码只读 content → 经验与动作被静默丢弃("使用小红书找苏州饭店"跑完什么都没存,动作侧日志
'原始输出 0 字符')。

修复(web/agent_api.py):
- 蒸馏调用统一加 "thinking": {"type":"disabled"}(该代理支持;实测关掉后 reasoning=0、
  配方 3/3 合格)——关键修复
- 配方:纯文本问法 + _recipe_ok 质量门槛(过短/含省略号占位丢弃,避免把提示词示例当真配方;
  曾因提示词里写了占位示例,模型照抄成 "1. …\n2. …" 存进库)+ 空则重试一次;
  仅动作提炼允许回退 reasoning_content(配方不回退,防思考草稿污染)
- 动作:JSON 输出 + _loads_lenient 截断容忍(逐对象抢救)+ {action,params} 形状归一 +
  输入/产出限量(≤10 步、≤3 动作×4 步);失败日志带样本
- 前端 agent.js:会话列表显示会话 ID 前 8 位(等宽小字),点击复制完整 ID(便于引用 conv=<id>)
- doc/ARCHITECTURE.md:§3.8 序号语义 + §5.4 蒸馏健壮性与会话 ID 说明
实测:真机复跑同一句需求 → 经验已保存(配方 196 字符)+ 动作经验已保存 2 条
2026-09-10 15:31:39 +08:00
butubb 3f44cd1491 fix: 元素选择器序号语义 + 抓取弹窗直接点击测试
- uiauto_helper: 同属性多实例的选择器由 `//*[@id="x"][k]` 改为 `(//*[@id="x"])[k]`——
  前者在 XPath 里是"父节点内排第 k",多实例时 [2..n] 全部失配(实测抖音底部 4 个同 id tab:
  仅 [1] 可用),任务里表现为"未找到元素"但界面上元素明明存在。
- tasks/generic/task.py: 新增 _norm_legacy_xpath,执行前把**历史遗留**的
  `//*[@attr=…][k]` 窄范围纠正为带括号形式(只改前缀,结构路径 …/FrameLayout[2] 的
  兄弟序号保持不动)——已存任务无需重抓即可恢复。
- editor.js: 抓取弹窗每条元素新增「▶ 点一下」(按 bounds 中心真点一次,/api/screen/tap
  snap=1 吸附)与「✓ 测选择器」(用将填入的选择器跑 /api/steps/test 验证命中),
  点击后自动刷新截图;底部加用法提示。
- doc/TASK_DEV.md:写明 xpath 序号必须整体加括号 + 旧形态自动纠正 + 优先文字/唯一 id。
2026-09-10 15:31:33 +08:00
9 changed files with 279 additions and 30 deletions
+15 -7
View File
@@ -89,9 +89,10 @@ def get_elements(serial):
1. 有 resource-id/text/content-desc 的元素: 1. 有 resource-id/text/content-desc 的元素:
先预统计该属性在整棵树中的出现次数—— 先预统计该属性在整棵树中的出现次数——
- 唯一出现:直接用属性选择器 //*[@resource-id="x"] - 唯一出现:直接用属性选择器 //*[@resource-id="x"]
- 重复出现(如抖音信息流点赞按钮同 id 几十个):附加 [k] 位置谓词 - 重复出现(如抖音底部导航 4 个 tab 同 id):用**整体加括号**的
精确到具体实例 //*[@resource-id="x"][k]。uiautomator2 的 d.xpath() `(//*[@resource-id="x"])[k]` 精确到第 k 个匹配。
底层是 lxml 标准 XPath,[k] 与抓取时同一语义,不会误中屏幕外第一个。 注意 XPath 语义:`//*[@id="x"][k]` 是"在其父节点中排第 k",不是
第 k 个匹配——历史实现踩过这个坑(多实例时 [2..n] 全部失配)。
2. 无任何属性的元素: 2. 无任何属性的元素:
用最近一个有属性祖先的选择器限定范围 + 同 class 兄弟序号定位 用最近一个有属性祖先的选择器限定范围 + 同 class 兄弟序号定位
(如 //*[@resource-id="x"]/FrameLayout/ImageView[2]); (如 //*[@resource-id="x"]/FrameLayout/ImageView[2]);
@@ -153,14 +154,21 @@ def _extract(root, out):
count_attrs(c) count_attrs(c)
def attr_selector(attr, value, cnt, seen): def attr_selector(attr, value, cnt, seen):
"""属性选择器:唯一直接出,重复加 [k] 位置谓词。返回 (选择器, suggested)。""" """属性选择器:唯一直接出;重复时用 `(…)[k]` 精确到**第 k 个匹配**。
重要(XPath 位置谓词语义):
//*[@resource-id="x"][2] → 「在**其父节点**中排第 2 的属性匹配」,**不是**第 2 个匹配
(//*[@resource-id="x"])[2] → 「第 2 个匹配」← 我们要的
历史实现写成前者,导致同 id 多实例(如抖音底部导航 4 个 tab 同 id)时
[2..n] 全部匹配不到 → 运行时"未找到元素"(2026-09-10 实测修复)。
"""
seen[value] += 1 seen[value] += 1
val = f'//*[@{attr}={_xpath_q(value)}]' base = f'//*[@{attr}={_xpath_q(value)}]'
if cnt[value] > 1: if cnt[value] > 1:
val += f"[{seen[value]}]" val = f"({base})[{seen[value]}]"
return val, {"type": "xpath", "value": val, return val, {"type": "xpath", "value": val,
"indexed": True, "occ": seen[value], "total": cnt[value]} "indexed": True, "occ": seen[value], "total": cnt[value]}
return val, {"type": "xpath", "value": val} return base, {"type": "xpath", "value": base}
def flatten(node, depth=0, path="", ctx=None, tag_path="", tag_index=1): def flatten(node, depth=0, path="", ctx=None, tag_path="", tag_index=1):
"""递归扁平化。 """递归扁平化。
+15
View File
@@ -230,6 +230,11 @@ APK 上传/解析/批量安装。
元素树解析:递归提取每个节点的 `resource-id/text/content-desc/class/bounds` 等属性,并推荐最佳选择器(优先 xpath)。 元素树解析:递归提取每个节点的 `resource-id/text/content-desc/class/bounds` 等属性,并推荐最佳选择器(优先 xpath)。
**XPath 序号语义(重要)**:同一属性多个实例时,生成 **`(//*[@resource-id="x"])[k]`**(整体加括号 = 第 k 个匹配)。
不可写成 `//*[@resource-id="x"][k]`——那在 XPath 里是"**在其父节点中排第 k**",多实例时 `[2..n]` 会全部匹配不到
(2026-09-10 实测修复:抖音底部 4 个 tab 同 id,旧写法除 `[1]` 外全失效)。执行器 `tasks/generic/task.py`
对**历史遗留**的 `//*[@attr=…][k]` 形态做窄范围纠正(`_norm_legacy_xpath`,只改前缀、不动结构路径的兄弟序号)。
### 3.9 屏幕 OCR(`core/ocr.py`) ### 3.9 屏幕 OCR(`core/ocr.py`)
条件判断的 `ocr` 选择器实现:截屏 → RapidOCR(ONNX 推理,中英文模型随包内置)→ 关键词匹配 → 返回文字中心像素坐标(与 u2 `d.click` 一致)。 条件判断的 `ocr` 选择器实现:截屏 → RapidOCR(ONNX 推理,中英文模型随包内置)→ 关键词匹配 → 返回文字中心像素坐标(与 u2 `d.click` 一致)。
@@ -339,11 +344,21 @@ tasks/douyin/actions/like.py — @register_action(ACTIONS) LikeAction
AI 控制台(顶级 Tab)右上角两个模态框,管理自进化记忆: AI 控制台(顶级 Tab)右上角两个模态框,管理自进化记忆:
- **🧠 经验库**:任务级经验(`agent_experience`,整任务配方)+ 每日 AI 巡检建议(删除需人工确认)。 - **🧠 经验库**:任务级经验(`agent_experience`,整任务配方)+ 每日 AI 巡检建议(删除需人工确认)。
- **🎬 动作库**:动作级经验(`agent_action`)——命名动作(可含 1~N 步)+ 编辑器 schema 步骤 + **元素定位(禁坐标)**;由任务成功后从**成功步骤**自动蒸馏,执行前按名/别名召回注入;面板支持查看/编辑/删除/手动新建(保存经服务端校验,坐标步骤被拒)。 - **🎬 动作库**:动作级经验(`agent_action`)——命名动作(可含 1~N 步)+ 编辑器 schema 步骤 + **元素定位(禁坐标)**;由任务成功后从**成功步骤**自动蒸馏,执行前按名/别名召回注入;面板支持查看/编辑/删除/手动新建(保存经服务端校验,坐标步骤被拒)。
- **会话列表显示会话 ID**(前 8 位,等宽小字),点击即复制完整 ID——便于反馈问题时引用 `conv=<id>`。
> **蒸馏健壮性(2026-09-10)**:经验/动作靠**模型蒸馏**落库。推理型模型会把 token 预算烧在 `reasoning` 上,导致 `content` 为空或被截断(`finish_reason=length`)→ 早期只读 `content`,经验/动作被**静默丢弃**("小红书·苏州饭店"案例)。现策略:
> 1. 蒸馏调用**关闭推理**:`"thinking": {"type": "disabled"}`(该代理支持;实测关掉后 reasoning=0、正文正常,配方 3/3 合格)——这是关键修复;
> 2. 配方用**纯文本问法**(不要放可照抄的占位示例,否则模型会原样当配方存下来)+ 质量门槛 `_recipe_ok`(过短/含省略号占位 → 丢弃并重试);
> 3. 动作提炼用 JSON + **截断容忍**提取(`_loads_lenient` 逐对象抢救)+ 顶层 `{action,params}` 形状归一 + 输入/产出限量(≤10 步输入、≤3 动作×4 步);
> 4. 两类失败都有日志(`经验提炼:` / `动作提炼:` 含样本),不再静默。
### 5.5 元素抓取模态框 ### 5.5 元素抓取模态框
独立的第二层模态框(`el-picker-overlay`,z-index 1100),不影响任务编辑窗口: 独立的第二层模态框(`el-picker-overlay`,z-index 1100),不影响任务编辑窗口:
1. 选择设备 → 2. 加载截图 + 元素树 → 3. 点击元素/边界框 → 4. 回填选择器 1. 选择设备 → 2. 加载截图 + 元素树 → 3. 点击元素/边界框 → 4. 回填选择器
5. **抓取时直接验证**(每条元素右侧两个按钮,不会与"点击回填"冲突):
- 「▶ 点一下」:按元素 `bounds` 中心在设备上真点一次(`POST /api/screen/tap`,`snap=1` 自动吸附到可点元素),返回吸附结果并自动刷新截图——用于确认位置/是否可达;
- 「✓ 测选择器」:用**将填入的选择器**真跑一次 click(`POST /api/steps/test`),返回 `命中/未找到/已执行`——用于确认回填的选择器在真实界面能命中(元素无有效选择器时不显示此按钮)。
--- ---
+1
View File
@@ -264,3 +264,4 @@ tasks/<app>/
| [AI_TASK_GEN.md](AI_TASK_GEN.md) | AI 建任务设计文档 | | [AI_TASK_GEN.md](AI_TASK_GEN.md) | AI 建任务设计文档 |
| [staffdeck/KNOWLEDGE_BASE.md](staffdeck/KNOWLEDGE_BASE.md) | 给 StaffDeck 数字员工的知识库(MCP 接入/工具/约定/红线) | | [staffdeck/KNOWLEDGE_BASE.md](staffdeck/KNOWLEDGE_BASE.md) | 给 StaffDeck 数字员工的知识库(MCP 接入/工具/约定/红线) |
| [staffdeck/JOB_SPEC.md](staffdeck/JOB_SPEC.md) | 数字员工岗位说明(岗位描述/看板摘要/执行约束) | | [staffdeck/JOB_SPEC.md](staffdeck/JOB_SPEC.md) | 数字员工岗位说明(岗位描述/看板摘要/执行约束) |
| [backlog/TODO.md](backlog/TODO.md) | 待完成项(已确认但暂缓的功能/优化,完成时移出并同步文档) |
+6
View File
@@ -317,6 +317,12 @@ while not self.stopped():
`className`;**仅 `if_el` 额外支持 `ocr`**(截屏 OCR 按文字匹配,UI 树里没有的文字也能找到,可选 `ocr_click` 命中后自动点击)。 `className`;**仅 `if_el` 额外支持 `ocr`**(截屏 OCR 按文字匹配,UI 树里没有的文字也能找到,可选 `ocr_click` 命中后自动点击)。
带选择器的步骤(click/long_click/swipe_until/wait_el/if_el)都必须填 `selector_value`。 带选择器的步骤(click/long_click/swipe_until/wait_el/if_el)都必须填 `selector_value`。
> **xpath 序号必须整体加括号**(2026-09-10 修复):同一属性多个实例时,"第 k 个匹配"要写
> **`(//*[@resource-id="x"])[k]`**;写成 `//*[@resource-id="x"][k]` 是"在其**父节点**中排第 k",
> 多实例时 `[2..n]` 全部匹配不到(表现为运行时"未找到元素",但界面上明明有这个元素)。
> 元素抓取器现已生成带括号形式;执行器 `_norm_legacy_xpath` 会自动纠正**旧任务**里的前者(只改前缀,
> 结构路径 `.../FrameLayout[2]` 的兄弟序号不动)。等价工具定位优先用 `text`/`resourceId`,比序号 xpath 稳。
> **静默跳过语义**:worker 对**未知 type / 缺必填**(如 `package`、`selector_value` 为空)**只打 warning 跳过,不会报错失败**——任务会"看起来成功但啥也没干"。因此写任务必须**自行校验**:用编辑器内置 validate + "测试此步骤"逐个验证选择器(见 §2.11)。 > **静默跳过语义**:worker 对**未知 type / 缺必填**(如 `package`、`selector_value` 为空)**只打 warning 跳过,不会报错失败**——任务会"看起来成功但啥也没干"。因此写任务必须**自行校验**:用编辑器内置 validate + "测试此步骤"逐个验证选择器(见 §2.11)。
> **权威 schema**:`tasks/generic/task.py` 的 `STEP_TYPES`(后端执行器)与 `static/admin/editor.js` 的 `STEP_LIB`(前端操作库)**必须保持同步**——改节点结构两边要一起改。 > **权威 schema**:`tasks/generic/task.py` 的 `STEP_TYPES`(后端执行器)与 `static/admin/editor.js` 的 `STEP_LIB`(前端操作库)**必须保持同步**——改节点结构两边要一起改。
> **AI 辅助生成**:用一句话需求 → AI 生成 generic_steps 任务(步骤 JSON → 编辑器预填 → 人工确认)的规划见 **doc/AI_TASK_GEN.md**。 > **AI 辅助生成**:用一句话需求 → AI 生成 generic_steps 任务(步骤 JSON → 编辑器预填 → 人工确认)的规划见 **doc/AI_TASK_GEN.md**。
+54
View File
@@ -0,0 +1,54 @@
# 待完成项(Backlog)
> 用途:记录**已确认但暂缓**的功能/优化,避免丢线索。完成时应移出本文件,并按「doc 同步红线」更新对应文档。
> 维护:新增条目写清 **背景 / 期望 / 涉及文件 / 验收**;完成后删除条目并在相关 doc 体现。
---
## 设备与连接
- [ ] **adb 远程终端加「目标切换」(本机 / 220)**
- 背景:终端默认走 `.env` 的 `ANDROID_ADB_SERVER_ADDRESS=192.168.20.220`(adb 客户端生效,非本项目代码读取),因此终端里看不到**本机 USB 设备**(实测本机 adb 有 `bebeoft4ztgiskba`,平台终端只显示 220 侧的 `192.168.20.206:5555`)。
- 期望:`web/tools_api.py` 的 `/api/adb/devices`、`/api/adb/cmd` 支持 `target=local|remote`;前端 `static/admin/tools.js` 加下拉;默认值按需。
- 涉及:`web/tools_api.py`、`static/admin/tools.js`、`doc/API.md`、`doc/DEPLOY.md`、`doc/DEVELOPMENT.md`(配置速查)。
- 验收:切换「本机」时终端能列出本机 USB + 本机已连 `IP:5555`;切「220」时列出 220 侧设备;`adb cmd` 同样生效。
- [ ] **`.env` 的 adb 路由键要在文档里说明(或去掉)**
- 背景:`ANDROID_ADB_SERVER_ADDRESS/HOST/PORT` 会被 `.env` 注入 `os.environ`,**adb 客户端**据此把所有 adb 调用指向 220;`ANDROID_ADB_SERVER_HOST` 是非标准键(adb 不认)。
- 期望:在 `doc/DEVELOPMENT.md` 配置速查 / `doc/DEPLOY.md` 写明该行为与取舍;或本地去掉这三行改走本机 adb(`USB_ADB_HOST` 保留,平台对 220 的 USB 访问走显式 `-H/-P`)。
- [ ] 220 的 Tailscale / adb server(5037) 可达性巡检(曾出现连不通超时)。
## 编辑器 / 元素抓取
- [ ] **未命中要"明确提示",别混成"执行了这步"**
- 背景(2026-09-10):click/long_click/wait_el/swipe_until/if_el 未命中时只写 `[WARNING] ... 未找到元素`,前端进度仍按"执行了一步"计数、任务日志 INFO 行也照常打 → 用户看到像"成功",实际没点(db03f16e 案例就是这么误判的)。
- 期望:执行器把**未命中**作为该步结果(✗)返回并计入统计(如"未命中 N 步");前端任务进度/日志**标红 ✗** 并给出所用选择器;任务结束汇总未命中步骤数。
- 涉及:`tasks/generic/task.py`(各 handler 返回/记录命中结果)、`static/admin/tasks.js`/`monitor.js`(渲染)、`doc/API.md`(如需新增字段)、`doc/TASK_DEV.md`。
- [ ] **元素抓取(点击元素)超时——部分设备 dump 慢于平台超时**
- 背景(2026-09-10 实测):`core/uiauto_helper.py:24` `_TIMEOUT=(1,8)`;`192.168.20.72:5555` dump 2.4s 正常,而 `192.168.20.206:5555` 与 USB 设备 dump **~18s** → 平台报「uiauto2 请求超时」,编辑器抓不到元素、无法选取/回填/测试点击。设备本身正常(直接请求 uiautodev 45s 内 HTTP 200)。
- 期望:提高读超时(如 `(2, 30)`)+ 前端 `/api/uiauto/elements` 加载中给明确提示;可选加「同一界面短时缓存」或「抓取前提示切到轻界面」。
- 涉及:`core/uiauto_helper.py`、`web/tasks_api.py`(/api/uiauto/elements)、`static/admin/editor.js`、`doc/API.md`、`doc/ARCHITECTURE.md`(§3.8)。
- [ ] 自定义动作支持 `action_ref` 引用型节点(现状:拖入画布会**展开成 group**,改动需同步执行器 + 编辑器)。
- [ ] **序号型 XPath / 界面就绪 的防呆**(2026-09-10 db03f16e 案例)
- 背景:`(//*[@resource-id="…"])[6]` 这类**带序号**的选择器依赖"抓取那一刻该属性有 ≥6 个实例";运行时界面不同(实测抖音冷启动 5s 后仍在 `.splash.SplashActivity`,`content_layout` 只有 5 个)→ 序号必然失配。且步骤只有 `open_app(wait_home=false)+wait`,没有"等首页就绪"。
- 期望:①`open_app` 默认/提示勾选「等待首页」,或在编辑器给"点击前自动等某元素"的建议;②抓取器对**带 `#k/N` 序号**的选择器加醒目提示("界面变化会失配,优先用文字/唯一 id");③抓取弹窗提醒"请先把设备停在任务运行到该步时的同一界面再抓"。
- 涉及:`core/uiauto_helper.py`、`static/admin/editor.js`(picker 提示)、`tasks/generic/task.py`(open_app 等待策略)、`doc/TASK_DEV.md`。
## AI 控制台 / 经验与动作
- [ ] 「🧠 经验库 / 🎬 动作库」合为一个面板(标签页切换)。
- [ ] AI 建任务 P0:平台级 MCP 工具(只读清单 `task_types/groups/pool` + `submit_task(draft)` 校验)+ 把**动作库**当作 generic_steps 的预制件复用(见 `doc/AI_TASK_GEN.md` §9)。
- [ ] 新增 MCP `de_screen_text`(文本化看屏:前台包名 + screen_state + 可点元素文本 + OCR),并把操作纪律写进工具 description(对外部客户端尤其有用)。
- [ ] 经验召回改进:现为 bigram + `ORDER BY hits`(候选只看 top50、hits 对所有命中行回写 → 马太效应);改为对称相似度/更合理候选集,hits 仅在真正注入时 +1。
- [ ] 经验/动作入库依赖模型蒸馏成功:加失败重试与可视化观测(现在只在日志里)。
## 其他
- [ ] `platform-tools` → `auto_control` 命名统一(README/服务名/单元名,待定)。
- [ ] STF 容器状态确认:`doc/DEVELOPMENT.md`(已停用)与 `doc/STF_REMOVAL.md`(待人工确认)说法矛盾,需以 220 实际为准后统一。
- [ ] MCP 白名单语义缺口:`MCP_ALLOWED_SERIALS` 为空时并未按设计限制为"平台设备池内"(当前只校验非空),见 `doc/MCP.md`;如需收紧需补实现。
+22 -1
View File
@@ -252,15 +252,36 @@ function renderConvList(){
} }
list.innerHTML = convs.map(c=>{ list.innerHTML = convs.map(c=>{
const t = c.title || '新会话'; const t = c.title || '新会话';
const sid = String(c.id || '');
return '<div class="agent-sess-item'+(c.id===cur?' active':'') return '<div class="agent-sess-item'+(c.id===cur?' active':'')
+'" onclick="selectAgentSession(\''+c.id+'\')">' +'" onclick="selectAgentSession(\''+c.id+'\')">'
+'<button class="agent-sess-del" title="删除会话" ' +'<button class="agent-sess-del" title="删除会话" '
+'onclick="event.stopPropagation();deleteAgentSession(\''+c.id+'\')">✕</button>' +'onclick="event.stopPropagation();deleteAgentSession(\''+c.id+'\')">✕</button>'
+'<div class="t">'+esc(t)+'</div>' +'<div class="t">'+esc(t)+'</div>'
+'<div class="m">'+esc(c.updated_at||'')+(c.count ? ' · '+c.count+' 轮' : '')+'</div></div>'; +'<div class="m">'+esc(c.updated_at||'')+(c.count ? ' · '+c.count+' 轮' : '')
+' · <span style="font-family:monospace;opacity:.75" title="会话 ID:'+esc(sid)+'(点击复制)" '
+'onclick="event.stopPropagation();copyConvId(\''+sid+'\')">#'+esc(sid.slice(0,8))+'</span>'
+'</div></div>';
}).join(''); }).join('');
}); });
} }
// 复制会话 ID(便于反馈问题时引用,如 conv=2ba4da0e43)
function copyConvId(id){
const done = ()=>showToast('会话 ID 已复制: '+id,'success');
try{
if(navigator.clipboard && navigator.clipboard.writeText){
navigator.clipboard.writeText(id).then(done).catch(()=>fallback());
return;
}
}catch(e){}
fallback();
function fallback(){
const ta=document.createElement('textarea');
ta.value=id; document.body.appendChild(ta); ta.select();
try{ document.execCommand('copy'); done(); }catch(e){ showToast('会话 ID: '+id,'success'); }
ta.remove();
}
}
function loadCurrentConvMessages(){ function loadCurrentConvMessages(){
const chat = document.getElementById('agent-chat'); const chat = document.getElementById('agent-chat');
if(!chat) return; if(!chat) return;
+43 -1
View File
@@ -845,7 +845,10 @@ var _stepEditor={
'</div>'; '</div>';
document.getElementById('el-picker-footer').innerHTML= document.getElementById('el-picker-footer').innerHTML=
'<button class="btn" onclick="_stepEditor._showDevicePicker()">← 返回选设备</button>'+ '<button class="btn" onclick="_stepEditor._showDevicePicker()">← 返回选设备</button>'+
'<button class="btn" onclick="closeElPicker()">取消</button>'; '<button class="btn" onclick="closeElPicker()">取消</button>'+
'<span class="text-muted" style="margin-left:10px;font-size:11.5px">'
+'提示:先「▶ 点一下」在设备上验证位置,再点元素回填选择器;'
+'「✓ 测选择器」会用将填入的选择器真跑一次点击</span>';
// 加载截图和元素树(_refreshShot 内部同时刷新两者) // 加载截图和元素树(_refreshShot 内部同时刷新两者)
self._refreshShot(); self._refreshShot();
}, },
@@ -999,6 +1002,14 @@ var _stepEditor={
var indent=' '.repeat(Math.min(el.depth||0,6)); var indent=' '.repeat(Math.min(el.depth||0,6));
var sg=el.suggested||{}; var sg=el.suggested||{};
if(sg.invalid)attrs.push('<span style="color:#dc2626">⚠ '+esc(sg.reason||'无可用属性')+'</span>'); if(sg.invalid)attrs.push('<span style="color:#dc2626">⚠ '+esc(sg.reason||'无可用属性')+'</span>');
var acts='<div class="ep-acts" style="margin-left:auto;display:flex;gap:4px;flex:none">'
+'<button class="btn btn-xs" title="直接在设备上点这个元素(按元素中心坐标点击,服务端自动吸附到可点元素)" '
+'onclick="event.stopPropagation();_stepEditor._testTap('+idx+')">▶ 点一下</button>'
+(!sg.invalid&&sg.value
? '<button class="btn btn-xs" title="用将填入的选择器在设备上真跑一次 click,验证是否命中" '
+'onclick="event.stopPropagation();_stepEditor._testSelector('+idx+')">✓ 测选择器</button>'
: '')
+'</div>';
return '<div class="el-picker-item" data-idx="'+idx+'" title="点击填入: '+esc(sg.value||'')+'" onclick="_stepEditor._pickEl('+idx+')">'+ return '<div class="el-picker-item" data-idx="'+idx+'" title="点击填入: '+esc(sg.value||'')+'" onclick="_stepEditor._pickEl('+idx+')">'+
'<span class="ep-depth">'+(el.depth||0)+'</span>'+ '<span class="ep-depth">'+(el.depth||0)+'</span>'+
'<div class="ep-info"><div class="ep-name">'+indent+esc(el.name||el.class||'')+'</div>'+ '<div class="ep-info"><div class="ep-name">'+indent+esc(el.name||el.class||'')+'</div>'+
@@ -1006,10 +1017,41 @@ var _stepEditor={
'<span class="ep-sel">'+esc(sg.type)+'</span>'+ '<span class="ep-sel">'+esc(sg.type)+'</span>'+
(sg.indexed?' <span class="label label-info" title="界面中同属性元素共 '+sg.total+' 个,按从上到下这是第 '+sg.occ+' 个,选择器已带序号精确定位">#'+sg.occ+'/'+sg.total+'</span>':'')+ (sg.indexed?' <span class="label label-info" title="界面中同属性元素共 '+sg.total+' 个,按从上到下这是第 '+sg.occ+' 个,选择器已带序号精确定位">#'+sg.occ+'/'+sg.total+'</span>':'')+
(sg.broad?' <span class="label label-warning" title="无唯一属性,选择器可能误匹配其他元素,建议选带文字/id 的元素">⚠宽泛</span>':'')+ (sg.broad?' <span class="label label-warning" title="无唯一属性,选择器可能误匹配其他元素,建议选带文字/id 的元素">⚠宽泛</span>':'')+
acts+
'</div>'; '</div>';
}).join(''); }).join('');
document.getElementById('el-list').innerHTML=html||'<div class="se-empty">无元素</div>'; document.getElementById('el-list').innerHTML=html||'<div class="se-empty">无元素</div>';
}, },
// 抓取时"直接点一下":按元素 bounds 中心在设备上点击(服务端 snap 吸附)
_testTap:function(idx){
var el=this._elData&&this._elData[idx]; var serial=this._elSerial;
if(!el||!serial)return;
var m=(el.bounds||'').match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
if(!m){ showToast('该元素没有 bounds,无法坐标点击','error'); return; }
var x=Math.round((+m[1]+ +m[3])/2), y=Math.round((+m[2]+ +m[4])/2);
var self=this;
apiPost('/api/screen/tap', {serial:serial, x:x, y:y, snap:1}).then(function(r){
if(!r||!r.ok){ showToast((r&&r.error)||'点击失败','error'); return; }
showToast('已点击'+(r.label?('「'+r.label+'」'):'')+' ('+r.x+','+r.y+')'+(r.snapped?' [已吸附]':''),'success');
setTimeout(function(){ if(self._elSerial===serial)self._refreshShot(); }, 800);
});
},
// 抓取时验证"将填入的选择器":走 /api/steps/test 真跑一次 click
_testSelector:function(idx){
var el=this._elData&&this._elData[idx]; var serial=this._elSerial;
if(!el||!serial)return;
var sg=el.suggested||{};
if(!sg.value){ showToast('该元素没有可用选择器','error'); return; }
var self=this;
var step={type:'click', label:'测试点击',
params:{selector_type:sg.type||'xpath', selector_value:sg.value, wait_timeout:2}};
apiPost('/api/steps/test', {serial:serial, step:step}).then(function(r){
if(!r||!r.ok){ showToast((r&&r.error)||'测试失败','error'); return; }
var ok=(r.result==='命中');
showToast('选择器测试: '+(r.result||'')+(r.msg?(' — '+r.msg):''), ok?'success':'error');
setTimeout(function(){ if(self._elSerial===serial)self._refreshShot(); }, 800);
});
},
_filterEl:function(kw){ _filterEl:function(kw){
if(!kw){this._renderEl(this._elData);this._renderOverlays();return;} if(!kw){this._renderEl(this._elData);this._renderOverlays();return;}
kw=kw.toLowerCase(); kw=kw.toLowerCase();
+25
View File
@@ -30,6 +30,7 @@ worker 按 steps 顺序执行,支持 loop 步骤循环、停止信号、进度
group - 动作组(含 children 步骤列表,按序执行一次,可折叠复用) group - 动作组(含 children 步骤列表,按序执行一次,可折叠复用)
""" """
import random import random
import re
import time import time
from tasks.base import BaseTask, register_task from tasks.base import BaseTask, register_task
@@ -39,6 +40,24 @@ from core.logger import get_logger
_log = get_logger("task.generic") _log = get_logger("task.generic")
# 历史抓取器生成的"伪序号"选择器://*[@resource-id="x"][4]
# XPath 里这是"父节点内排第 4",不是"第 4 个匹配"——同属性多实例时 [2..n] 全部失配。
_LEGACY_IDX_XPATH = re.compile(r'^(//\*\[@[^\]]+\])\[(\d+)\](.*)$')
def _norm_legacy_xpath(sel_val):
"""把历史 `//*[@attr=…][k]` 纠正为 `(//*[@attr=…])[k]`(只改整体前缀,保留后续子路径)。
窄范围:只匹配"属性XPath + 数字谓词"开头的形态;结构路径里
`.../FrameLayout[2]` 的兄弟序号是有意为之,不受影响。
"""
if not isinstance(sel_val, str):
return sel_val
m = _LEGACY_IDX_XPATH.match(sel_val.strip())
if not m:
return sel_val
return f"({m.group(1)})[{m.group(2)}]{m.group(3)}"
# ================== 步骤类型定义(前端操作库 + 后端执行共用)================== # ================== 步骤类型定义(前端操作库 + 后端执行共用)==================
STEP_TYPES = [ STEP_TYPES = [
@@ -165,6 +184,12 @@ class GenericStepsWorker(BaseWorker):
stype = step.get("type", "") stype = step.get("type", "")
label = step.get("label", stype) label = step.get("label", stype)
params = step.get("params", {}) params = step.get("params", {})
# 兼容历史选择器://*[@attr=…][k] → (//*[@attr=…])[k](XPath 位置谓词语义)
if params.get("selector_type") == "xpath" and params.get("selector_value"):
fixed = _norm_legacy_xpath(params["selector_value"])
if fixed != params["selector_value"]:
params = dict(params, selector_value=fixed)
_log.info(f"[{self.serial}] 选择器已纠正为: {fixed}")
# 概率触发:probability=100 必执行,<100 按百分比概率决定本次是否执行 # 概率触发:probability=100 必执行,<100 按百分比概率决定本次是否执行
prob = float(params.get("probability", 100)) prob = float(params.get("probability", 100))
if prob < 100 and random.random() * 100 > prob: if prob < 100 and random.random() * 100 > prob:
+98 -21
View File
@@ -226,6 +226,60 @@ def _find_experiences(prompt, limit=2, threshold=0.10):
return "\n".join(parts), picked return "\n".join(parts), picked
def _msg_text(message, allow_reasoning=False):
"""取模型回复正文。
推理模型(DeepSeek 等)**偶发**把 token 预算全花在 reasoning 上、content 为空
(finish_reason=length),直接读 content 会当空处理 → 经验/动作被静默丢弃
(2026-09-10 实测)。默认**不回退 reasoning**(那是思考草稿,做"配方"会污染);
仅在能结构化解析的场景(动作 JSON 提取)才允许回退。
"""
m = message or {}
text = (m.get("content") or "").strip()
if text:
return text
if allow_reasoning:
return (m.get("reasoning_content") or "").strip()
return ""
_RECIPE_JSON_RE = re.compile(r'"recipe"\s*:\s*"((?:[^"\\]|\\.)*)"', re.S)
def _recipe_ok(recipe):
"""配方质量门槛:太短、或只有序号+省略号(模型照抄占位)视为无效。
2026-09-10 实测:提示词里写了示例 JSON,模型会把占位符 `1. …\\n2. …`
原样当成配方存下来 → 这里拦掉,宁可重试/不存,也不污染经验库。
"""
t = (recipe or "").strip()
if len(t) < 15:
return False
if "…" in t and len(t) < 30:
return False
return True
def _extract_recipe(text):
"""从(可能被截断的)模型输出里取配方正文。
蒸馏提示词要求输出 {"recipe": "…"};推理模型会把预算烧在 reasoning 上并
截断(finish_reason=length),严格 json.loads 会失败,故用正则直接抠字段。
"""
if not text:
return ""
m = _RECIPE_JSON_RE.search(text)
if m:
try:
return json.loads('"' + m.group(1) + '"').strip()
except Exception:
return m.group(1).replace("\\n", "\n").strip()
t = text.strip()
if t.startswith("{") or t.startswith("["):
return "" # JSON 外壳但没抠到 recipe:视为无效,避免把草稿当配方
return t
def _clean_recipe(recipe): def _clean_recipe(recipe):
"""清洗蒸馏输出:去掉模型可能加的「操作配方:」之类前缀标签与包裹引号。 """清洗蒸馏输出:去掉模型可能加的「操作配方:」之类前缀标签与包裹引号。
@@ -247,22 +301,34 @@ def _distill_experience(cfg, prompt, tool_seq):
import httpx import httpx
body = { body = {
"model": cfg.get("model") or "deepseek-v4-flash-vision-exp", "model": cfg.get("model") or "deepseek-v4-flash-vision-exp",
# 关推理:蒸馏是"给定轨迹写配方"的确定性任务,开启 thinking 会占满
# 预算导致 content 空/被截断(实测);该代理支持 thinking.type=disabled
"thinking": {"type": "disabled"},
"messages": [{"role": "user", "messages": [{"role": "user",
"content": "以下是一次成功的手机自动化操作记录。请提炼成简洁的" "content": "以下是一次成功的手机自动化操作记录。请提炼成简洁的"
"「操作配方」(2-6 步,每步:目标 → 用哪个工具)," "「操作配方」(2-6 步,每步:目标 → 用哪个工具),"
"供下次同类任务参考。不要解释,直接输出配方。\n" "供下次同类任务参考。不要解释,直接输出配方正文。\n"
f"任务:{prompt[:300]}\n操作序列:{tool_seq[:800]}"}], f"任务:{prompt[:300]}\n操作序列:{tool_seq[:800]}"}],
"max_tokens": 600, "max_tokens": 800,
} }
headers = {"Authorization": f"Bearer {cfg.get('api_key', '')}", headers = {"Authorization": f"Bearer {cfg.get('api_key', '')}",
"Content-Type": "application/json"} "Content-Type": "application/json"}
r = httpx.post(f"{(cfg.get('api_base') or 'https://api.deepseek.com').rstrip('/')}/chat/completions", url = f"{(cfg.get('api_base') or 'https://api.deepseek.com').rstrip('/')}/chat/completions"
json=body, headers=headers, timeout=25) # content 为空(推理吃满预算)时重试一次——实测同样提示第二次常能出正文
if r.status_code != 200: for _attempt in (1, 2):
return "" r = httpx.post(url, json=body, headers=headers, timeout=25)
j = r.json() if r.status_code != 200:
recipe = ((j.get("choices") or [{}])[0].get("message") or {}).get("content") or "" _log.warning(f"经验提炼: 模型返回 HTTP {r.status_code}")
return recipe.strip()[:1500] continue
msg = (r.json().get("choices") or [{}])[0].get("message") or {}
# 正文优先;为空时从 reasoning 里抠(可能含清晰步骤),都要过质量门槛
for cand in (_msg_text(msg), _extract_recipe(msg.get("reasoning_content"))):
if _recipe_ok(cand):
return cand.strip()[:1500]
if cand:
_log.info(f"经验提炼: 候选配方质量不足({len(cand)} 字符)被丢弃")
_log.info("经验提炼: 两次均未产出可用配方(推理占满预算/输出被截断/仅占位)")
return ""
except Exception as e: except Exception as e:
_log.warning(f"经验提炼失败: {e}") _log.warning(f"经验提炼失败: {e}")
return "" return ""
@@ -505,10 +571,13 @@ def _distill_actions(cfg, prompt, trace):
_log.info(f"动作提炼: 轨迹 {len(trace or [])} 步, 成功可沉淀 {len(ok_ops)} 步") _log.info(f"动作提炼: 轨迹 {len(trace or [])} 步, 成功可沉淀 {len(ok_ops)} 步")
if not ok_ops: if not ok_ops:
return [] return []
# 输入截断到前 10 步:步数过多会让模型输出变长被截断(实测 10 步 → 2002 字符无有效 JSON)
ok_ops = ok_ops[:10]
lines = [f"- {t['tool']} 参数={t['args']} 结果={_brief_result(t['result'])}" for t in ok_ops] lines = [f"- {t['tool']} 参数={t['args']} 结果={_brief_result(t['result'])}" for t in ok_ops]
instruction = ( instruction = (
"以下是一次成功的手机自动化操作的**成功步骤**。请把它们提炼为若干「动作」" "以下是一次成功的手机自动化操作的**成功步骤**。请提炼为**至多 3 个**「动作」"
"(每个动作 = 一个有语义名的可复用单元,可含 1~N 步)。只输出 JSON 数组,不要解释。\n" "(每个动作 = 一个有语义名的可复用单元,**至多 4 步**,字段尽量简短)。"
"只输出 JSON 数组,不要解释,不要思考过程。\n"
"字段:name(动作名,如「打开抖音」「搜索关键词」);app(包名,未知则空串);" "字段:name(动作名,如「打开抖音」「搜索关键词」);app(包名,未知则空串);"
"aliases(别名数组);params(参数名数组,如[\"关键词\"]);steps(步骤数组)。\n" "aliases(别名数组);params(参数名数组,如[\"关键词\"]);steps(步骤数组)。\n"
"steps 每步:type + params,type 取值:open_app{package} / click{selector_type," "steps 每步:type + params,type 取值:open_app{package} / click{selector_type,"
@@ -526,20 +595,28 @@ def _distill_actions(cfg, prompt, trace):
try: try:
import httpx import httpx
body = {"model": cfg.get("model") or "deepseek-v4-flash-vision-exp", body = {"model": cfg.get("model") or "deepseek-v4-flash-vision-exp",
"thinking": {"type": "disabled"}, # 同 _distill_experience:关推理
"messages": [{"role": "user", "content": instruction}], "messages": [{"role": "user", "content": instruction}],
"max_tokens": 900} "max_tokens": 900}
headers = {"Authorization": f"Bearer {cfg.get('api_key', '')}", headers = {"Authorization": f"Bearer {cfg.get('api_key', '')}",
"Content-Type": "application/json"} "Content-Type": "application/json"}
r = httpx.post( url = f"{(cfg.get('api_base') or 'https://api.deepseek.com').rstrip('/')}/chat/completions"
f"{(cfg.get('api_base') or 'https://api.deepseek.com').rstrip('/')}/chat/completions", content = ""
json=body, headers=headers, timeout=30) # content 空(推理吃满预算)→ 重试一次;仍空则回退 reasoning_content
if r.status_code != 200: # (这里能结构化提取 JSON 数组,草稿里也常含可用 JSON,故允许回退)
_log.warning(f"动作提炼: 模型返回 HTTP {r.status_code}") for _attempt in (1, 2):
return [] r = httpx.post(url, json=body, headers=headers, timeout=30)
content = (((r.json().get("choices") or [{}])[0].get("message") or {}).get("content") or "") if r.status_code != 200:
_log.warning(f"动作提炼: 模型返回 HTTP {r.status_code}")
continue
msg = (r.json().get("choices") or [{}])[0].get("message") or {}
content = _msg_text(msg, allow_reasoning=True)
if content:
break
acts = _sanitize_actions(_loads_lenient(content)) acts = _sanitize_actions(_loads_lenient(content))
if not acts: if not acts:
_log.info(f"动作提炼: 解析后无有效动作(原始输出 {len(content)} 字符)") _log.info(f"动作提炼: 解析后无有效动作(原始输出 {len(content)} 字符)"
f" 样本={content[:160]!r}")
return acts return acts
except Exception as e: except Exception as e:
_log.warning(f"动作提炼失败: {e}") _log.warning(f"动作提炼失败: {e}")
@@ -681,8 +758,8 @@ def _review_one_exp(cfg, prompt, recipe, tool_seq, hits):
json=body, headers=headers, timeout=20) json=body, headers=headers, timeout=20)
if r.status_code != 200: if r.status_code != 200:
raise RuntimeError(f"HTTP {r.status_code}") raise RuntimeError(f"HTTP {r.status_code}")
text = (((r.json() or {}).get("choices") or [{}])[0] text = _msg_text(((r.json() or {}).get("choices") or [{}])[0]
.get("message") or {}).get("content") or "" .get("message") or {})
m = re.search(r"\{.*\}", text, re.S) m = re.search(r"\{.*\}", text, re.S)
if not m: if not m:
raise RuntimeError("响应无 JSON") raise RuntimeError("响应无 JSON")