Compare commits
3
Commits
8072f4a380
...
p1_0910
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c2b440dce | ||
|
|
972db13f81 | ||
|
|
3f44cd1491 |
+15
-7
@@ -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):
|
||||||
"""递归扁平化。
|
"""递归扁平化。
|
||||||
|
|||||||
@@ -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`),返回 `命中/未找到/已执行`——用于确认回填的选择器在真实界面能命中(元素无有效选择器时不显示此按钮)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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) | 待完成项(已确认但暂缓的功能/优化,完成时移出并同步文档) |
|
||||||
|
|||||||
@@ -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**。
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
+94
-17
@@ -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 为空(推理吃满预算)时重试一次——实测同样提示第二次常能出正文
|
||||||
|
for _attempt in (1, 2):
|
||||||
|
r = httpx.post(url, json=body, headers=headers, timeout=25)
|
||||||
if r.status_code != 200:
|
if r.status_code != 200:
|
||||||
|
_log.warning(f"经验提炼: 模型返回 HTTP {r.status_code}")
|
||||||
|
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 ""
|
return ""
|
||||||
j = r.json()
|
|
||||||
recipe = ((j.get("choices") or [{}])[0].get("message") or {}).get("content") or ""
|
|
||||||
return recipe.strip()[:1500]
|
|
||||||
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
|
||||||
|
# (这里能结构化提取 JSON 数组,草稿里也常含可用 JSON,故允许回退)
|
||||||
|
for _attempt in (1, 2):
|
||||||
|
r = httpx.post(url, json=body, headers=headers, timeout=30)
|
||||||
if r.status_code != 200:
|
if r.status_code != 200:
|
||||||
_log.warning(f"动作提炼: 模型返回 HTTP {r.status_code}")
|
_log.warning(f"动作提炼: 模型返回 HTTP {r.status_code}")
|
||||||
return []
|
continue
|
||||||
content = (((r.json().get("choices") or [{}])[0].get("message") or {}).get("content") or "")
|
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")
|
||||||
|
|||||||
Reference in New Issue
Block a user