8 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
butubb 8072f4a380 docs: 补充动作库文档——ARCHITECTURE 加 agent_action 表与「AI 控制台记忆面板」小节、agent_api 职责含动作库;DEVELOPMENT 幂等建表清单加 agent_action 2026-09-10 14:03:09 +08:00
butubb 91b2785c6b feat: 动作库查看/管理(接口 + AI 控制台「🎬 动作库」面板)+ 未沉淀灰卡提示
- 后端:GET /api/agent/actions(列表)、POST /api/agent/actions/delete、
  POST /api/agent/actions/save(新增/编辑,steps 走 _sanitize_actions 校验:
  type 白名单 + 必填 + **拒绝坐标 click_xy**),均 @admin_required
- 前端:AI 控制台右上角新增「🎬 动作库」按钮与模态框——列出 名称/app/别名/
  步骤摘要(含元素定位)/命中次数/更新时间;支持编辑(表单 + steps JSON)、删除、
  手动新建;保存失败(如写坐标)即时 toast 原因
- 未沉淀提示:本轮无可沉淀动作时也推送一张 🧠 动作经验 灰卡
  「本轮未沉淀动作(步骤以坐标定位为主,缺少可复用的元素定位信息)」
- doc/API.md:登记三个新接口
实测:列表返回自动沉淀的动作;保存合法动作 200;提交 click_xy 步骤被 400 拒绝;
删除生效
2026-09-10 13:58:38 +08:00
butubb 104964aa53 feat: 动作经验库(agent_action)——成功步骤蒸馏命名动作(带元素定位/禁坐标)+ 执行前召回注入
- 新表 agent_action(name/app/aliases/params/steps/preconditions/hits/时间),
  独立于人工维护的 custom_action(2B 决策):AI 自学动作不污染手建动作
- 沉淀:任务成功后从**成功**工具轨迹(_ACTION_TOOLS: open_app/tap_text/tap_element/
  type_text/clipboard/swipe/press_key/wake/sleep)用模型蒸馏为命名动作;steps 用
  编辑器 schema,**必须元素定位**(xpath/text/resourceId/description…),
  **显式剔除 click_xy 等坐标类**;on_tool 记录带 result 的结构化轨迹以判成败
- 兼容模型形状漂移:顶层 {action,params} 自动归一为 {name,steps};宽容 JSON 解析
  (围栏/尾逗号/中文引号/坏对象逐条抢救),实测模型常返回带语法错误的 JSON
- 召回:执行前按动作名/别名命中(或相似度≥0.34)取 top3,注入 system prompt
  「可复用动作」段(含元素定位),模型可跳过重新探索;hits 回写
- 文档同步:ARCHITECTURE §3.6(agent_action 表)、API.md(🧠 动作经验 伪卡片 + 动作库
  说明)、AI_TASK_GEN P1(沉淀进展)
实测:跑「打开抖音,点搜索」→ 沉淀「打开抖音」;下一轮同指令命中并注入;日志
「命中可复用动作 1 个」「动作提炼: 轨迹 5 步, 成功可沉淀 1 步」「动作经验已保存 1 条」
2026-09-10 13:53:04 +08:00
butubb 4e71f79a10 fix: 经验库命中数算错(单条恒显示 0)+ 蒸馏前缀误杀 + 命中卡片带摘要
- _find_experiences 改为返回 (注入文本, 命中配方列表):此前卡片用
  exp_ctx.count('\n- ') 计数,单条恒为 0、两条为 1(永远少 1),用户看到
  「命中 0 条」误判为没参考;现用 len(列表) 得真实条数并附配方摘要
- 新增 _clean_recipe():清洗模型可能加的「操作配方:」前缀,替换旧判定
  "配方" not in recipe[:50]——该判定与蒸馏提示词(要求以「操作配方」作答)
  自相矛盾,模型照做即被整条丢弃(静默),是经验存不下来的主因
- doc/API.md:SSE step 说明补 🧠 伪卡片语义(N 为实际条数 + 摘要)
实测:同一指令由「命中 0 条」变为「命中 1 条同类历史经验,已注入参考:<摘要>」
2026-09-10 13:41:10 +08:00
butubb 19560aba3e docs: 数字员工知识库升级到平台同级——补操作纪律/状态检查/SOP
- KNOWLEDGE_BASE.md 重写:§0 快速开始;§5 操作纪律(与内置 AI 控制台等价的 10 条:
  先看设备/先看屏/定位三段优先级/吸附验证/输入两拍/每步验证/无变化不重点/如实汇报/
  效率/连续 6 步无进展停止);§6 开跑前状态检查 7 项(含「屏幕是否点亮/解锁」:
  用 screen_state+画面判断,黑屏先 de_wake 再重截,未确认亮屏不许点按);§7 执行中
  状态判据(息屏/锁屏、页面到位判据表、加载抖动、幂等重放注意);§8 SOP 五阶段+闸门
  (P0 澄清→P1 预检→P2 到起点→P3 观察-行动-验证循环→P4 收尾还原→P5 固定汇报口径)
  含卡住判定表(2 步换策略/6 步停);§9 异常处置速查;§10 效率预算
- JOB_SPEC.md §4 SOP 改为指向知识库 §5-§8 + 五阶段概览,两份不打架
2026-09-10 13:41:06 +08:00
14 changed files with 1134 additions and 148 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):
"""递归扁平化。 """递归扁平化。
+1
View File
@@ -136,6 +136,7 @@
## 6. 里程碑 ## 6. 里程碑
- **P0(本设计主体)**:designer 模式 → 自探(UI 树定位)→ 直接撰写 generic_steps draft → 服务端 schema 校验 → 前端步骤编辑器预填确认保存。验收:一句话在真实设备上生成一条可调度的 generic_steps,步骤全部来自 UI 树且编辑器可打开。 - **P0(本设计主体)**:designer 模式 → 自探(UI 树定位)→ 直接撰写 generic_steps draft → 服务端 schema 校验 → 前端步骤编辑器预填确认保存。验收:一句话在真实设备上生成一条可调度的 generic_steps,步骤全部来自 UI 树且编辑器可打开。
- **P1**:`douyin_nurture` 参数预设生成(照 default_params 结构);模板沉淀:把 draft+需求写入 `agent_experience`(新列存结构化 steps 或 JSON),相似需求注入参考;整链「演示试跑」(把 steps 在设备上以受控方式跑一遍并截图回报,需新增端点,复刻 `device_busy` 拒绝语义)。 - **P1**:`douyin_nurture` 参数预设生成(照 default_params 结构);模板沉淀:把 draft+需求写入 `agent_experience`(新列存结构化 steps 或 JSON),相似需求注入参考;整链「演示试跑」(把 steps 在设备上以受控方式跑一遍并截图回报,需新增端点,复刻 `device_busy` 拒绝语义)。
- **进展(2026-09-10)**:动作级沉淀已落地——`agent_action` 表 + 从**成功步骤**蒸馏"命名动作"(steps 用编辑器 schema、带元素定位、禁坐标)+ 执行前按名/别名召回注入(`web/agent_api.py`)。AI 建任务可直接把这些动作当作 generic_steps 的**预制件**复用。
- **P2**:自定义动作支持(内联展开成 group,或新增 `action_ref` 节点 + 执行器/编辑器同步);多设备并行;成本与 token 控制。 - **P2**:自定义动作支持(内联展开成 group,或新增 `action_ref` 节点 + 执行器/编辑器同步);多设备并行;成本与 token 控制。
## 7. 实现时需新增/改动文件(规划) ## 7. 实现时需新增/改动文件(规划)
+15 -2
View File
@@ -1193,7 +1193,7 @@ AI 可用设备列表(在线状态 + 是否有任务运行,前端据此把 b
订阅事件流(SSE,EventSource)。事件: 订阅事件流(SSE,EventSource)。事件:
- `event: delta` `{text, kind: content|reasoning}` — 流式文本增量 - `event: delta` `{text, kind: content|reasoning}` — 流式文本增量
- `event: step` `{tool, args, image?}` — 工具调用完成(MCP 步骤,image 为缩略截图) - `event: step` `{tool, args, image?}` — 工具调用完成(MCP 步骤,image 为缩略截图)。另有三类伪卡片:`tool="🧠 经验记忆"` 表示命中任务级经验(args 形如「命中 N 条同类历史经验,已注入参考:<配方摘要>」)或本轮已写入经验库;`tool="🧠 动作经验"` 表示命中**可复用动作**(「命中 N 个可复用动作,已注入参考:<动作名>」,执行前注入)或本轮已沉淀动作(「已沉淀 N 个可复用动作」,含元素定位、禁坐标)
- `event: done` `{answer}` — 完成 - `event: done` `{answer}` — 完成
- `event: error` `{message}` — 失败(若因 MCP Server 未启动/不可达,message 为明确文案「MCP server(8033) 不可达 …」,不再是 SDK 原始的 `Server returned an error response`) - `event: error` `{message}` — 失败(若因 MCP Server 未启动/不可达,message 为明确文案「MCP server(8033) 不可达 …」,不再是 SDK 原始的 `Server returned an error response`)
- 空闲时每 15s 发一行 `: keepalive` 注释防超时;`done`/`error` 后关流 - 空闲时每 15s 发一行 `: keepalive` 注释防超时;`done`/`error` 后关流
@@ -1252,7 +1252,7 @@ AI 可用设备列表(在线状态 + 是否有任务运行,前端据此把 b
### GET /api/agent/experience ### GET /api/agent/experience
经验记忆库列表(自进化,含最近一次巡检结论)。 经验记忆库列表(自进化,含最近一次巡检结论)。另有一张**动作经验库**表 `agent_action`(命名动作 + 编辑器 schema 步骤 + 元素定位、禁坐标):任务成功后自动从**成功步骤**蒸馏沉淀,执行前按动作名/别名召回并注入;当前无独立查询接口(命中/沉淀在 AI 控制台的 🧠 卡片可见)。
**响应**: **响应**:
```json ```json
@@ -1275,6 +1275,19 @@ AI 可用设备列表(在线状态 + 是否有任务运行,前端据此把 b
**响应**:`{"ok": true, "msg": "巡检已启动,完成后刷新列表查看建议"}` **响应**:`{"ok": true, "msg": "巡检已启动,完成后刷新列表查看建议"}`
### GET /api/agent/actions
动作经验库列表(命名动作 + 编辑器 schema 步骤 + 元素定位、禁坐标)。仅管理员。
响应:`{"ok":true,"actions":[{id,name,app,aliases,params,steps,preconditions,hits,updated_at}]}`。
由任务成功后的**成功步骤**自动蒸馏沉淀;执行前按动作名/别名召回并注入 system prompt。
### POST /api/agent/actions/delete
删除动作:`{id}`。仅管理员。
### POST /api/agent/actions/save
新增/编辑动作:`{id?, name, app?, aliases?, params?, steps, preconditions?}`。仅管理员。
`steps` 可为数组或 JSON 字符串,经服务端校验(白名单 type + 必填;**拒绝坐标 click_xy**)。
校验失败返回 400 并附原因。
### POST /api/agent/experience/keep ### POST /api/agent/experience/keep
人工保留经验(撤销"建议删除",后续巡检不再重复建议)。**请求**(JSON):`{"id": 1}` 人工保留经验(撤销"建议删除",后续巡检不再重复建议)。**请求**(JSON):`{"id": 1}`
+25 -3
View File
@@ -185,7 +185,8 @@ SQLAlchemy 模型,存于 `data/users.db`:
> 表名默认取类名小写(models.py 未写 `__tablename__`)。另有三张非模型表,由原生 SQL 幂等创建、**不走 SCHEMA_MIGRATIONS**: > 表名默认取类名小写(models.py 未写 `__tablename__`)。另有三张非模型表,由原生 SQL 幂等创建、**不走 SCHEMA_MIGRATIONS**:
> - `app_meta`(KV):`_migrate_schema()` 内建表,存 `schema_version`、`discovery_*`、agent 配置 `agent_*` 等; > - `app_meta`(KV):`_migrate_schema()` 内建表,存 `schema_version`、`discovery_*`、agent 配置 `agent_*` 等;
> - `agent_conversation` / `agent_experience` / `experience_audit`:AI 控制台会话 / 经验库 / 经验巡检(`web/agent_api.py` 顶部 `CREATE TABLE IF NOT EXISTS`)。 > - `agent_conversation` / `agent_experience` / `experience_audit`:AI 控制台会话 / 任务级经验(配方)/ 经验巡检(`web/agent_api.py` 顶部 `CREATE TABLE IF NOT EXISTS`)。
> - `agent_action`:**动作经验库**(命名动作 = 可复用单元,steps 用编辑器 schema 且带元素定位、禁坐标);由任务成功后从**成功步骤**蒸馏,执行前按名字/别名召回并注入(`web/agent_api.py` `_distill_actions/_find_actions`)。
**数据库初始化**(`init_db`): **数据库初始化**(`init_db`):
- 创建所有表 - 创建所有表
@@ -229,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` 一致)。
@@ -251,7 +257,7 @@ APK 上传/解析/批量安装。
| `devices_api.py` | 设备池管理 + 自动发现(扫描/确认/忽略/手动重连/型号采集) | | `devices_api.py` | 设备池管理 + 自动发现(扫描/确认/忽略/手动重连/型号采集) |
| `apks_api.py` | 应用管理 | | `apks_api.py` | 应用管理 |
| `tailscale_api.py` | Tailscale 管理 | | `tailscale_api.py` | Tailscale 管理 |
| `agent_api.py` | AI 控制台:会话 / 运行 / SSE / 停止 / 配置 / 经验库与巡检(读写 `agent_conversation`、`agent_experience`、app_meta `agent_*`) | | `agent_api.py` | AI 控制台:会话 / 运行 / SSE / 停止 / 配置 / 经验库与巡检 / **动作库**(读写 `agent_conversation`、`agent_experience`、`agent_action`、app_meta `agent_*`) |
| `system_api.py` | 系统数据备份导出 / 导入恢复(`/api/system/backup/*`,仅 admin) | | `system_api.py` | 系统数据备份导出 / 导入恢复(`/api/system/backup/*`,仅 admin) |
| `common.py` | 跨模块共享(合并设备列表/屏幕状态) | | `common.py` | 跨模块共享(合并设备列表/屏幕状态) |
| `context.py` | 共享对象注入(mgr/apk_mgr/device_pool) | | `context.py` | 共享对象注入(mgr/apk_mgr/device_pool) |
@@ -333,10 +339,26 @@ tasks/douyin/actions/like.py — @register_action(ACTIONS) LikeAction
(原"分组"顶级 Tab 与"维护/STF 服务"子分栏已不存在——分组已移入工具页子分栏,STF 已摘除。) (原"分组"顶级 Tab 与"维护/STF 服务"子分栏已不存在——分组已移入工具页子分栏,STF 已摘除。)
### 5.4 元素抓取模态框 ### 5.4 AI 控制台的记忆面板
AI 控制台(顶级 Tab)右上角两个模态框,管理自进化记忆:
- **🧠 经验库**:任务级经验(`agent_experience`,整任务配方)+ 每日 AI 巡检建议(删除需人工确认)。
- **🎬 动作库**:动作级经验(`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 元素抓取模态框
独立的第二层模态框(`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`),返回 `命中/未找到/已执行`——用于确认回填的选择器在真实界面能命中(元素无有效选择器时不显示此按钮)。
--- ---
+2 -1
View File
@@ -214,7 +214,7 @@ tasks/<app>/
- 模型改 `core/models.py`,首次建表用 `create_all()`;SQLAlchemy 模型未写 `__tablename__` 时默认表名 = 小写类名 - 模型改 `core/models.py`,首次建表用 `create_all()`;SQLAlchemy 模型未写 `__tablename__` 时默认表名 = 小写类名
- **已有数据的老库**:在 `core/models.py` 的 `SCHEMA_MIGRATIONS` 里加迁移(版本号递增 + SQL) - **已有数据的老库**:在 `core/models.py` 的 `SCHEMA_MIGRATIONS` 里加迁移(版本号递增 + SQL)
- `app_meta`(KV 配置表)由 `core/models.py` 的 `_migrate_schema()` 建表并维护 `schema_version` - `app_meta`(KV 配置表)由 `core/models.py` 的 `_migrate_schema()` 建表并维护 `schema_version`
- `agent_conversation` / `agent_experience` / `experience_audit` 由 `web/agent_api.py` 顶部的原生 `CREATE TABLE IF NOT EXISTS` 幂等创建(模块内首次用时执行),**不经 `SCHEMA_MIGRATIONS`,无版本管理** - `agent_conversation` / `agent_experience` / `experience_audit` / `agent_action`(动作经验库)由 `web/agent_api.py` 内的原生 `CREATE TABLE IF NOT EXISTS` 幂等创建(模块内首次用时执行),**不经 `SCHEMA_MIGRATIONS`,无版本管理**
### 5.6 改动必须同步文档 ### 5.6 改动必须同步文档
@@ -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`;如需收紧需补实现。
+16 -8
View File
@@ -116,15 +116,23 @@
## 4. 工作流(SOP) ## 4. 工作流(SOP)
> 详细规则与闸门见 `doc/staffdeck/KNOWLEDGE_BASE.md` §5(操作纪律)、§6(开跑前状态检查)、§7(执行中状态判据)、§8(SOP 全流程)。本节只给岗位层概览。
**五阶段**:
``` ```
接收指令 P0 接收与澄清 → P1 选设备与预检(含"屏幕是否点亮/解锁") → P2 到达起点
→ 澄清(目标 App / 目标动作 / 哪台设备 / 时长,缺失就问) → P3 主流程:观察-行动-验证(OAV)循环 → P4 收尾与还原 → P5 汇报
→ 选设备(de_list_devices,排除 busy/offline) ```
→ 预检(de_foreground_app + de_screenshot 确认当前状态)
→ 执行(拆小步;L1 写操作,每步截图验证) - **P0**:澄清目标 App/动作/设备/时长/成功标准;缺失且影响执行就先问;L2/L3 停下要授权。
→ 判定(成功 / 失败原因 / 需人工确认) - **P1**:`de_list_devices` 选在线空闲设备;`de_screenshot` 确认**亮屏且非锁屏**(黑屏先 `de_wake`);`de_foreground_app` 确认前台;确认起点页特征与坐标基线。
→ 汇报(看板摘要 §2 + 证据) - **P2**:`de_open_app` 或 `back` 到起点;起点特征文本出现才算到位。
→ 收尾(如需还原:de_stop_app / 返回上一页;不强制改变设备状态) - **P3**:每拍"观察(`de_screenshot`) → 决策(单步) → 行动 → 验证";定位优先 `de_tap_text` > `de_ui_tree`+`de_tap_element` > `de_tap`(坐标兜底);**每步要有推进证据**。
- **P4**:`de_stop_app`/`back` 把设备留在明确状态;不做非任务要求的破坏性操作。
- **P5**:按 JOB_SPEC §2 看板摘要 + §3.5 证据口径汇报。
**卡住即停**:同坐标无变化禁止重复点;连续 2 步无变化换策略;**连续 6 步无进展停止并汇报**。
**注意设备/运行时特性**:观察类工具(截图/UI树)若被你的平台做幂等重放(`idempotent_replay`),会拿到过期画面 → 必须确保每次观察是新结果(见 KNOWLEDGE_BASE §7.4)。
``` ```
--- ---
+198 -98
View File
@@ -1,151 +1,251 @@
# 知识库 · 设备自动化平台(auto_control)接入说明 # 知识库 · 设备自动化平台(auto_control)接入与操作手册
> 读者:StaffDeck 数字员工(外部 Agent)。 > 读者:StaffDeck 数字员工(外部 Agent)。
> 目的:让你知道**能做什么、怎么调、什么不能做**,从而安全地代为操作手机设备。 > 目标:让你达到**与平台内置 AI 控制台同等**的操作水平——不是"能调工具",而是"会看、会判断、会收尾"。
> 版本:2026-09-10 | 权威工具清单以 `doc/MCP.md` 为准,配置以 `mcp_server/config.py` 为准。 > 版本:2026-09-10 | 工具清单权威版:`doc/MCP.md`|配置权威版:`mcp_server/config.py`
--- ---
## 1. 这个平台是什么 ## 0. 快速开始(30 秒版)
一台**安卓设备自动化中台**:管理一批手机(网络 `IP:5555` 或 USB 串号),支持定时任务、步骤编排、看屏/操作,以及本知识库要用的 **MCP 工具**。 ```
1) de_list_devices → 选一台 online 且 worker_status 非 running/connecting 的设备
2) de_screenshot(serial) → 看当前屏;若画面黑/锁屏 → de_wake 后重截(见 §6、§7)
3) 判断当前页 → 不对就 de_open_app(package) 或 de_press_key(back) 回到起点
4) 循环体:观察 → 操作 → 验证(见 §8)
- 优先 de_tap_text(文字);有歧义用 de_ui_tree + de_tap_element;纯图形才 de_tap(坐标)
- 每次关键操作后再 de_screenshot 验证是否生效
5) 连续 6 步无进展 → 停止并如实汇报(不要空转、不要臆测成功)
```
- 平台本体:Web 服务(默认 `http://<host>:18050`),提供任务/分组/设备/看板等管理能力。 ---
- 你能用的入口:**MCP Server(推荐)**,它把平台已有的设备能力封装成 19 个工具。
## 1. 平台与能力边界
安卓设备自动化中台:管理一批手机(网络 `IP:5555` / USB 串号),支持任务调度、步骤编排、看屏与操作。
- 你能用的入口:**MCP Server**(推荐,19 个 `de_*` 工具);备选 REST(§2.2)。
- **可做**:看屏、截图、UI 树、OCR、开/关 App、点击、滑动、按键、输入、剪贴板、亮/熄屏、看前台包名、列应用、只读看平台任务。
- **不可做**:创建/修改/启停平台任务、分组/设备池/备份管理(MCP 未提供)。
--- ---
## 2. 接入方式 ## 2. 接入方式
### 2.1 首选:MCP(HTTP / streamable) ### 2.1 首选:MCP(HTTP / streamable)
- 地址:`http://<host>:8033/mcp`(FastMCP,`transport="http"`)。 - 地址:`http://<host>:8033/mcp`。
- 调用方式:支持 MCP 的客户端直接连;工具名即 `de_*`(见 §3)。 - **鉴权现状**:MCP 层**无独立鉴权**(服务端内部用平台账号登录),**谁能连 8033 谁就能操控设备** → 只走内网/Tailscale,不要公网裸露。
- **鉴权现状**:MCP 层**无独立鉴权**——它自身用 `MCP_PLATFORM_USER/PASS` 登录平台(会话 + CSRF 由服务端内部处理,你不用管)。因此**谁能访问 8033 谁就能操控设备**,务必只在内网/Tailscale 内暴露,不要裸露公网。 - **审计**:每次调用(含只读)写一行 JSON。
- **审计**:每次调用(含只读)都会写一行 JSON 审计(工具、serial、参数摘要、结果)。
### 2.2 备选:平台 REST(`http://<host>:18050`) ### 2.2 备选:平台 REST(`http://<host>:18050`)
仅在你不支持 MCP 时使用。认证是「表单登录 → 会话 Cookie + `X-CSRF-Token`」,无 API Token,接入成本较高。常用端点见 §9。 认证 = 表单登录 → 会话 Cookie + `X-CSRF-Token`。无 API Token。端点见 `doc/API.md`。
### 2.3 服务端配置(由部署方设置,你只需知道含义) ### 2.3 服务端配置(部署方设置,你只需知道含义)
| 变量 | 默认 | 对你的影响 |
| 变量 | 默认 | 含义 / 对你的影响 |
|---|---|---| |---|---|---|
| `MCP_HTTP_HOST` / `MCP_HTTP_PORT` | `0.0.0.0` / `8033` | 监听地址与端口 | | `MCP_ALLOW_WRITE` | `0` | `0` 时写工具全返回 `write_disabled`(只能看不能动) |
| `MCP_ALLOW_WRITE` | `0`(**只读**) | `1` 才允许点按/滑动/输入等**写操作**;为 0 时写工具返回 `write_disabled` | | `MCP_ALLOWED_SERIALS` | 空 | 非空=白名单;**为空时不自动限设备池**(仅校验非空) |
| `MCP_ALLOWED_SERIALS` | 空 | 逗号分隔设备白名单;非空时只允许这些 serial。**注意:为空时当前代码只校验 serial 非空,并不自动限平台设备池**——如需收紧请让部署方配置白名单 | | `MCP_HTTP_HOST` / `MCP_HTTP_PORT` | `0.0.0.0` / `8033` | 监听 |
| `MCP_PLATFORM_URL` | `http://127.0.0.1:18050` | 平台地址 | | `MCP_SCREENSHOT_WIDTH` / `MCP_JPEG_QUALITY` | `540` / `70` | 截图尺寸/质量 |
| `MCP_PLATFORM_USER` / `_PASS` | `admin` / 空 | 服务端登录平台用,与你无关 | | `MCP_PLATFORM_TIMEOUT` | `30` | 平台请求超时 |
| `MCP_PLATFORM_TIMEOUT` | `30` | 平台请求超时(秒) | | `MCP_AUDIT_FILE` | `/var/log/mcp/audit.log` | 审计文件 |
| `MCP_SCREENSHOT_WIDTH` / `MCP_JPEG_QUALITY` | `540` / `70` | 截图缩放宽/JPEG 质量(见坐标约定) |
| `MCP_AUDIT_FILE` | `/var/log/mcp/audit.log` | 审计文件路径 |
启动(部署方操作,供排障参考):
- 220 生产容器:`scripts/start.sh` 会自动后台拉起(`MCP_ENABLED=0` 可关)。
- 本机/手工:`MCP_ALLOW_WRITE=1 MCP_PLATFORM_USER=admin MCP_PLATFORM_PASS=<密码> python -m mcp_server.mcp_server`
--- ---
## 3. 工具清单(19 个 `de_*`) ## 3. 工具清单(19 个)
### 只读(观测,不触发设备占用锁;任务运行中也安全) **只读(不触发占用锁)**:`de_list_devices` · `de_screenshot` · `de_ui_tree` · `de_ocr` · `de_foreground_app` · `de_list_apps` · `de_read_clipboard` · `de_list_tasks`
| 工具 | 入参 | 返回要点 | 用途 |
|---|---|---|---|
| `de_list_devices()` | — | `[{serial, model, online, worker_status, foreground_app}]` | 先看有哪些设备可用 |
| `de_screenshot(serial)` | serial | `{image(JPEG base64), width, height, native_size, screen_state}` | 看当前屏(**必须**先截图才能坐标点按) |
| `de_ui_tree(serial, limit=150)` | serial, limit | `{count, elements:[{text,id,desc,class,clickable,bounds}]}` | 拿可点击元素(**优先用它定位**) |
| `de_ocr(serial)` | serial | `{count, texts:[{text,score}]}` | UI 树里没有的渲染文字(WebView/图片) |
| `de_foreground_app(serial)` | serial | `{foreground_app: pkg}` | 当前前台 App |
| `de_list_apps(serial, keyword?)` | serial, keyword | `{count, packages:[pkg]}` | 找包名(配合 `de_open_app`) |
| `de_read_clipboard(serial)` | serial | `{clipboard}` | 读剪贴板 |
| `de_list_tasks()` | — | 平台任务计划列表 | 只读了解平台任务(**不能增改**) |
### 写操作(会先检查设备是否被任务占用;`MCP_ALLOW_WRITE=1` 才可用) **写(受占用锁约束)**:`de_tap_text` · `de_tap_element` · `de_tap` · `de_swipe` · `de_press_key` · `de_type_text` · `de_set_clipboard` · `de_open_app` · `de_stop_app` · `de_wake` · `de_sleep`
| 工具 | 入参 | 说明 |
|---|---|---|
| `de_tap_text(serial, text)` | serial, text | **首选**语义点击:按屏上文字找并点(UI 树→OCR 兜底);找不到返回 `text_not_found` |
| `de_tap_element(serial, by, value, index=1)` | by=`text/id/desc/text_contains/desc_contains`, value, index | 元素定位点击(配合 `de_ui_tree`) |
| `de_tap(serial, x, y)` | serial, x, y | 坐标点击(截图坐标系,自动吸附到最近可点元素;返回 `snapped`/`label`) |
| `de_swipe(serial, x1,y1,x2,y2, duration?)` | 坐标 | 滑动 |
| `de_press_key(serial, key)` | key=`back/home/enter/menu/recent/delete` | 按键 |
| `de_type_text(serial, text)` | serial, text | 输入文字(中文/引号/换行可用) |
| `de_set_clipboard(serial, text)` | serial, text | 写剪贴板 |
| `de_open_app(serial, package)` | serial, package | 冷启动 App(adb monkey) |
| `de_stop_app(serial, package)` | serial, package | 强制结束 App |
| `de_wake(serial)` / `de_sleep(serial)` | serial | 亮屏解锁 / 息屏(走平台 `/api/device/screen_all`) |
> 平台级工具(**任务创建/修改/启停、分组、设备池管理、备份等**)**目前没有 MCP 工具**,只有上面这些设备能力;需要它们时请通过 REST(§9)或在平台 Web 后台操作。 参数与返回详见 `doc/MCP.md`。
--- ---
## 4. 通用调用约定 ## 4. 通用约定
### 4.1 设备标识 `serial` ### 4.1 serial
- 网络设备:`IP:5555`(如 `100.100.10.13:5555`、`192.168.20.206:5555`)。 - 网络 `IP:5555`(如 `100.100.10.13:5555`);USB 纯串号(如 `ZY322XXXX`)。
- USB 设备:纯串号(无冒号,如 `ZY322XXXX`)。 - **用 `de_list_devices` 取,别猜。**
- 用 `de_list_devices()` 获取当前可用清单,**不要凭空猜 serial**。
### 4.2 坐标空间(重要) ### 4.2 坐标空间(重要)
- `de_screenshot` 返回的是**缩放图(≤540px 宽)+ `native_size`(原生分辨率)**。 - `de_screenshot` 返回**缩放图(≤540px 宽)+ `native_size`**;`de_tap`/`de_swipe` 用**截图坐标系**,服务端换算原生。
- `de_tap` / `de_swipe` 的坐标用**截图坐标系(display 空间)**,服务端按最近一次截图比例换算原生。 - **必须先截图再坐标操作**;否则报「请先执行 de_screenshot」。
- **必须先 `de_screenshot` 再坐标操作**,否则报「请先执行 de_screenshot」。 - 屏可能旋转/滚动 → **优先文字/元素,坐标仅兜底**。
- 屏幕可能旋转/滚动,坐标随时会变——**优先文字/元素定位,坐标仅兜底**。
### 4.3 设备占用锁(busy) ### 4.3 占用锁(busy)
- 写工具执行前会检查该设备是否有平台任务在跑:`running`/`connecting` → 返回 **`device_busy`**(“AI 不与任务抢设备”)。此时**换设备或等待**,不要硬重试。 - 写工具执行前查设备任务状态:`running`/`connecting` → **`device_busy`**(不与任务抢设备)。换设备或等待,别硬试。
- 只读工具(截图/UI树/OCR/列表)不受此限制。 - 只读工具不受限(任务运行中也能安全截图)。
### 4.4 错误码 ### 4.4 错误码
| 错误 | 含义 | 应对 | | 错误 | 含义 | 应对 |
|---|---|---| |---|---|---|
| `write_disabled` | 服务端未开写门控 | 告知部署方开 `MCP_ALLOW_WRITE=1` | | `write_disabled` | 写门控关闭 | 上报部署方 |
| `device_busy` | 设备正被任务占用 | 换设备 / 等任务结束 | | `device_busy` | 设备被任务占用 | 换设备/等待 |
| `device_offline` | 设备不可达 | 换设备;报运维 | | `device_offline` | 设备不可达 | 换设备;报运维 |
| `device_not_allowed` | 不在白名单 | 换设备;让部署方加白名单 | | `device_not_allowed` | 不在白名单 | 换设备;加白名单 |
| `text_not_found` | 屏上没有该文字 | 截图确认;改用 `de_ui_tree`/`de_ocr` | | `text_not_found` | 屏上无该文字 | 重截图/`de_ui_tree`/`de_ocr` 复核 |
| `platform_unavailable` | 平台不可达 | 稍后重试;报运维 | | `platform_unavailable` | 平台不可达 | 稍后重试 |
| `invalid_param` | 参数不合法 | 修参数 | | `invalid_param` | 参数不合法 | 修参数 |
--- ---
## 5. 推荐操作模式(务必遵守,能显著提高成功率) ## 5. 操作纪律(**与平台内置 AI 控制台等价**,务必遵守)
1. **文字语义点击优先**:`de_tap_text("关注")` > `de_ui_tree` 找元素 + `de_tap_element` > `de_tap` 坐标兜底。 > 这是平台内置 Agent 的系统规范,逐条对齐即可达到同等效果。
2. **每关键步后截图验证**:操作是否生效看截图/UI 树,别连续盲点。
3. **不重复同坐标**:点击后画面无变化,禁止反复点同一坐标(多数 App 会误触)。
4. **先确认前台与页面**:`de_foreground_app` / 截图确认在正确页面再操作。
5. **一次一个明确目标**:拆成小步,每步可验证。
### 常见配方 1. **先看设备**:`de_list_devices` 确定目标设备(在线才可操作)。
- **看视频养号(示意)**:`de_list_devices` → 选设备 → `de_open_app(pkg)` → 循环〔`de_screenshot` 确认在视频页 → 等待 → `de_swipe` 上滑〕→ 结束 `de_stop_app`。 2. **先看屏**:任何决策前 `de_screenshot` 理解当前界面(图会给你)。
- **打开指定 App**:`de_list_apps(keyword)` 拿包名 → `de_open_app(package)` → 截图确认首页。 3. **点击优先级**(不要自己推算像素坐标——精度最差):
- **输入文字**:`de_tap_text` 或 `de_tap_element` 点输入框 → `de_type_text` → 截图确认。 a) 有可见文字(按钮/菜单/标题/标签/输入框提示)→ **`de_tap_text`** 一步"找到并点"(原生与 WebView/图片文字都支持);
- **巡检截图**:`de_screenshot` + `de_foreground_app` + `de_ocr`,只读汇报。 b) 文字有歧义或未命中 → **`de_ui_tree(limit=80)`** 看可点元素 → **`de_tap_element`**(`text`/`text_contains`);
c) 只有纯图形(视频/无文字图标且树里没有)→ 才 **`de_tap`** 给坐标(**大致对准中心即可,服务端自动吸附**)。
4. **验证点击**:`de_tap` 返回 `snapped=true` 表示已吸附命中(可核对 `label`);**截图有变化=成功,无变化=未命中**。
5. **输入文字**:先 `de_tap_text`/`de_tap` 点中输入框 → 再 `de_type_text`。
6. **每关键步后再截图验证**,直到完成目标。
7. **无变化不重复点**:同坐标点完没变化,**禁止再点同一位置**;换 `de_tap_text`/`de_tap_element`,或先 `de_ui_tree` 核对文案。
8. **如实汇报**:做了什么、当前状态、注意事项;失败就说失败,**不臆测成功**。
9. **效率**:界面没变就别重复截图/点击;每步都要推进目标。
10. **收敛**:**连续 6 步无进展**(截图内容未变/操作无效)→ 停止并总结原因。
--- ---
## 6. 红线与安全(**不可违反**) ## 6. 开跑前状态检查清单(Pre-flight,逐项过)
1. **绝不 `adb kill-server`、绝不 `adb disconnect`**(会断开所有设备共享的 adb 通道,影响全部运行中任务)。你也不需要这些操作。 | # | 检查 | 怎么做 | 通过条件 | 不通过怎么办 |
2. **不抢任务设备**:遇到 `device_busy` 就避开。 |---|---|---|---|---|
3. **写操作前先确认**:不确定后果的操作(发送评论/私信、删除、支付类)**不要执行**,先截图回报请人工确认。 | 1 | 设备在线且空闲 | `de_list_devices` | `online=true` 且 `worker_status` 非 running/connecting | 换设备;全忙则上报 |
4. **不做破坏性动作**:卸载应用、清除数据、改系统设置等一律不做。 | 2 | **屏幕是否点亮/解锁** | `de_screenshot` 看 `screen_state` 与画面 | 亮屏且非锁屏界面 | **`de_wake`**(亮屏解锁)→ 重新 `de_screenshot` |
5. **留痕**:你的每次调用都进审计,请让动作与目标一致。 | 3 | 是否能看清画面 | 截图 | 非纯黑/非"正在加载"白屏 | 黑屏→`de_wake`;白屏→等 2~3s 重截 |
6. **隐私**:不要把设备上读到的个人信息/凭据复述或外传。 | 4 | 前台 App 是否正确 | `de_foreground_app` | 是目标 App(或桌面,准备开) | `de_open_app(package)` 或 `de_press_key(back)` 回退 |
| 5 | 是否在起点页 | `de_ui_tree`/截图 | 元素文案符合"首页/入口"预期 | 逐级 `de_press_key(back)` 或重开 App |
| 6 | 有无拦截弹窗 | 截图/`de_ui_tree` | 无权限/更新/广告弹窗 | 找"取消/关闭/允许(按需)/以后再说"文字点掉,或 `back` |
| 7 | 坐标基线 | 记下最近一次截图 | 本轮坐标操作前**必须有一次新截图** | 补一次 `de_screenshot` |
> **判断"屏幕有没有开启"的标准做法**:`de_screenshot` 的 `screen_state` + 画面是否可辨认。**黑屏/息屏一律先 `de_wake`**,再重新截图确认;**不要在未确认亮屏的情况下点按/滑动**。
--- ---
## 7. 当前边界(别越界期待) ## 7. 执行中的状态判据与规则
- MCP **没有任务创建/修改/启停**(只有只读 `de_list_tasks`)。 ### 7.1 屏幕与锁屏
- MCP **无独立鉴权**(靠网络隔离),不要假设有 token。 - 息屏/黑屏 → `de_wake` → 重截确认;长任务中途可能再次息屏,**每轮循环先确认一次**。
- `MCP_ALLOWED_SERIALS` 为空时**不会**自动限制为平台设备池(设计语义未实现)。 - 锁屏界面(有锁/时间/上滑提示)→ `de_wake` 解锁后重截;仍锁 → 上报(可能需要人工)。
- 平台 REST 的 CSRF 校验在当前版本可能未强制(按端点契约使用即可)。 - 需要保持常亮时:没有专门工具,可在长流程中周期性 `de_wake` 兜底。
### 7.2 页面判据(怎么算"到位了")
| 目标 | 判据(以 `de_ui_tree`/截图文字为准) | 未达成的处置 |
|---|---|---|
| 已到 App 首页 | 出现底部导航/搜索框等首页特征文本 | `back` 一次或重开 App |
| 已到视频页 | 出现点赞/评论/分享等交互图标或全屏画面 | 等待/滑一次再看 |
| 已到目标详情 | 目标条目标题文本可见 | 继续上滑查找(有上限,见 §10) |
| 输入框已聚焦 | 出现键盘/光标或输入法界面 | 重新点输入框 |
| 弹窗已处理 | 弹窗文本消失 | 换"取消/关闭/以后再说"再点 |
### 7.3 加载与抖动
- "正在加载/白屏/骨架屏" → 等 2~3s 重截,**不要连续狂点**。
- 连续两次截图完全一致且不符预期 → 视为**卡住**,走 §8.3。
### 7.4 幂等重放(**注意你的运行时特性**)
- 若你的平台会对"相同参数的工具调用"做**幂等重放/去重缓存**(如返回 `idempotent_replay`):
**观察类工具**(`de_screenshot`/`de_ui_tree`/`de_ocr`/`de_foreground_app`)**必须每次拿到新结果**,否则你会基于**过期画面**决策(表现为"屏幕没变/点不动")。
- 处置:请让编排方**对观察类工具关闭去重**;或在参数/调用上确保不被判定为重复。
- 兜底:用 `de_ui_tree`(文本随页面变化)作为"状态是否变化"的佐证;`de_screenshot` 用于确认视觉。
--- ---
## 8. 相关文档 ## 8. SOP(标准作业流程)
- 工具手册(逐工具参数/返回/示例):`doc/MCP.md` ### 8.0 总体:五个阶段 + 一个循环
- MCP 设计/分层与现状对照:`doc/MCP_DESIGN.md` ```
- 平台接口目录:`doc/API.md` P0 接收与澄清 → P1 选设备与预检(§6) → P2 到达起点 → P3 主流程(观察-行动-验证循环) → P4 收尾与还原 → P5 汇报
- AI 生成任务(规划,未实现):`doc/AI_TASK_GEN.md` └──────────── 循环体 ────────────┘
```
### P0 接收与澄清
- **输入**:用户指令(自然语言)。
- **必查**:目标 App?要做什么动作?哪台设备(或指定)?时长/次数?成功标准是什么?
- **闸门**:任一缺失且影响执行 → **先问,不猜**。
- **拒绝项**:L2/L3 级操作(见 §11)→ 停下要授权。
### P1 选设备与预检
- 动作:跑完 §6 的 7 项。
- **闸门**:设备在线 + 空闲 + **亮屏可达** + 前台/起点已就位。任一不过 → 按 §6 处置;仍不过 → 上报。
- 产出:记下 `serial`、起点页特征、最近一次截图基线。
### P2 到达起点
- 动作:`de_open_app`(冷启动到首页)→ `de_screenshot`/`de_ui_tree` 确认;或 `back` 逐级回退。
- **闸门**:起点特征文本出现(§7.2)。
- 失败:重开 App 一次;仍不行 → 上报(App 异常/未安装:`de_list_apps` 核对包名)。
### P3 主流程 —— 观察-行动-验证(OAV 三拍循环)
每一拍都按下面的节奏,**不许跳步**:
1. **观察**:`de_screenshot`(必要时 `de_ui_tree`)→ 用一句话说清"现在在哪、看到什么"。
2. **决策**:选定下一步**唯一**动作(按 §5 的定位优先级)。
3. **行动**:执行单个工具调用。
4. **验证**:再观察一次 → 变化符合预期?→ 是:进入下一拍;否:见 §8.3。
- **闸门**:每拍结束必须"状态有推进"的证据(文字/画面变化)。
- **边界**:只在目标 App 内活动,不跳出到系统设置等无关界面(除非任务要求)。
### 8.3 卡住判定(重要)
| 情形 | 判定 | 处置 |
|---|---|---|
| 同坐标点击无变化 | 未命中 | **禁止重复点**;换 `de_tap_text`/`de_tap_element`,或先 `de_ui_tree` 看文案 |
| 连续 2 步无变化 | 策略无效 | 换定位方式 / 检查是否在正确页面 |
| **连续 6 步无进展** | 卡死 | **停止**,汇报"卡在哪、试过什么、可能原因" |
| `device_busy`/`device_offline` | 设备不可用 | 换设备;无可用则中止汇报 |
### P4 收尾与还原
- 动作:`de_stop_app`(结束 App)或 `back` 回到桌面;确认设备状态(亮/熄屏按需)。
- **闸门**:设备处于明确的已知状态(不留在中间页/输入框)。
- 注意:**不做**非任务要求的破坏性/不可逆操作。
### P5 汇报(固定口径)
```
【设备操作员 · 汇报】
设备:<serial> | 目标:<一句话>
过程:<关键 3~5 步:做了什么 → 是否生效>
结果:✅完成 / ⚠️部分完成 / ❌失败(原因)
证据:<关键步骤截图/元素文本>
遗留:<需人工处理 / 未做的高危步骤 / 设备状态>
```
---
## 9. 常见异常与处置(速查)
| 现象 | 可能原因 | 处置 |
|---|---|---|
| 截图全黑/息屏 | 屏幕关闭 | `de_wake` → 重截 |
| 停在锁屏 | 未解锁 | `de_wake`;仍锁 → 上报 |
| 停在启动页/闪屏 | App 未就绪 | 等 2~3s 重截;不行重开 |
| 权限/更新弹窗遮挡 | 系统弹窗 | 点"取消/关闭/以后再说";`back` |
| 点不动、画面不变 | 未命中/被遮挡/图是旧的 | 换定位方式;核对截图是否新鲜(§7.4) |
| `text_not_found` | 文字不在当前屏 | 重截、`de_ocr`、滑一屏再找 |
| `device_busy` | 设备跑任务 | 换设备/等待 |
| 一直加载 | 网络/内容未就绪 | 等待重截,勿狂点 |
---
## 10. 效率与预算
- 每步必须推进目标;界面未变不重复截图/点击(但**每拍开始时需要一次新鲜观察**)。
- 找不到目标时:滑动查找设上限(建议 ≤ 5 屏),超出即停止汇报。
- 连续 6 步无进展 → 停止(§8.3)。
- 不要为"确认"而反复截图同一画面(除非上一拍是写操作,需要验证)。
---
## 11. 安全红线(不可违反)
1. **绝不 `adb kill-server` / `adb disconnect`**。
2. **不抢任务设备**(`device_busy` 就避开)。
3. **L2 敏感写**(发评论/私信、关注取关、发布、下单支付、改资料)→ **默认不做**,先截图请人工确认。
4. **L3 破坏性**(卸载/清数据/改系统设置/恢复出厂/删文件)→ **一律不做**。
5. **不泄露**设备上的个人信息与凭据;不外传截图。
6. **不绕过**写门控/白名单/审计。
---
## 12. 当前边界与相关文档
- MCP 无任务 CRUD、无独立鉴权(靠网络隔离)。
- `doc/MCP.md`(工具手册)|`doc/MCP_DESIGN.md`(设计与现状对照)|`doc/API.md`(REST 目录)
- 岗位职责与授权分级:`doc/staffdeck/JOB_SPEC.md`
+121 -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;
@@ -512,6 +533,105 @@ function endRun(){
scrollChat(); scrollChat();
} }
// ================== 动作库(可复用动作:查看/编辑/删除/新建) ==================
let _actCache = [];
function openActLib(){
document.getElementById('agent-act-overlay').style.display = 'flex';
loadActLib();
}
function closeActLib(){
document.getElementById('agent-act-overlay').style.display = 'none';
}
function _actStepBrief(a){
return (a.steps||[]).map(s=>{
const p = s.params || {};
const loc = p.selector_value || p.package || p.fixed_text || '';
return (s.type || '?') + (loc ? (':' + loc) : '');
}).join(' → ');
}
function loadActLib(){
const list = document.getElementById('act-lib-list');
apiGet('/api/agent/actions').then(r=>{
if(!r||!r.ok){ showToast((r&&r.error)||'加载失败','error'); return; }
_actCache = r.actions || [];
document.getElementById('act-lib-hint').textContent = '共 ' + _actCache.length + ' 个动作';
if(!_actCache.length){
list.innerHTML = '<div class="text-muted" style="padding:20px;text-align:center">暂无动作——跑一轮以元素/文字点击为主的任务后会自动沉淀</div>';
return;
}
list.innerHTML = '';
_actCache.forEach(a=>{
const card = document.createElement('div');
card.style.cssText = 'border:1px solid var(--card-line);border-radius:10px;padding:9px 13px;background:#0d1117';
card.innerHTML = '<div style="display:flex;justify-content:space-between;gap:10px;align-items:flex-start">'
+ '<div style="flex:1;min-width:0">'
+ '<div style="font-weight:600">' + esc(a.name||'')
+ (a.app ? '<span class="text-muted" style="font-size:11px;font-weight:400;margin-left:6px">' + esc(a.app) + '</span>' : '')
+ '</div>'
+ '<div style="font-size:11.5px;color:#9fb3d1;margin-top:2px;word-break:break-all">' + esc(_actStepBrief(a) || '—') + '</div>'
+ '<div class="text-muted" style="font-size:11px;margin-top:2px">别名: ' + esc((a.aliases||[]).join('、')||'—')
+ ' | 命中 ' + (a.hits||0) + ' 次 | ' + esc(a.updated_at||'') + '</div>'
+ '</div>'
+ '<div style="display:flex;flex-direction:column;gap:4px;flex:none">'
+ '<button class="btn btn-xs" onclick="actEdit(' + a.id + ')">编辑</button>'
+ '<button class="btn btn-xs btn-danger" onclick="actDelete(' + a.id + ')">删除</button>'
+ '</div></div>';
list.appendChild(card);
});
});
}
function actDelete(id){
if(!confirm('确认删除该动作?删除后同类任务不再自动复用它。')) return;
apiPost('/api/agent/actions/delete',{id:id}).then(r=>{
showToast((r&&(r.msg||r.error))||'删除失败', (r&&r.ok)?'success':'error');
loadActLib();
});
}
function actNew(){ _actForm({name:'', app:'', aliases:[], params:[], steps:[]}); }
function actEdit(id){
const a = _actCache.find(x=>x.id===id);
if(a) _actForm(a);
}
function _actForm(a){
const list = document.getElementById('act-lib-list');
const isNew = !a.id;
list.innerHTML = '';
const box = document.createElement('div');
box.style.cssText = 'border:1px solid var(--card-line);border-radius:10px;padding:12px 14px;background:#0d1117';
box.innerHTML =
'<div style="font-weight:600;margin-bottom:8px">' + (isNew?'新建动作':'编辑动作 #'+a.id) + '</div>'
+ '<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:6px">'
+ '<input id="act-f-name" class="form-control" style="flex:1;min-width:180px" placeholder="动作名(如 打开抖音)" value="' + esc(a.name||'') + '">'
+ '<input id="act-f-app" class="form-control" style="flex:1;min-width:180px" placeholder="包名(可空,如 com.ss.android.ugc.aweme)" value="' + esc(a.app||'') + '">'
+ '</div>'
+ '<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:6px">'
+ '<input id="act-f-alias" class="form-control" style="flex:1;min-width:180px" placeholder="别名(逗号分隔)" value="' + esc((a.aliases||[]).join(',')) + '">'
+ '<input id="act-f-params" class="form-control" style="flex:1;min-width:180px" placeholder="参数名(逗号分隔,可空)" value="' + esc((a.params||[]).join(',')) + '">'
+ '</div>'
+ '<textarea id="act-f-steps" class="form-control" rows="9" style="font-family:monospace;font-size:12px" '
+ 'placeholder=\'步骤 JSON 数组,例:[{"type":"click","params":{"selector_type":"text","selector_value":"搜索"}}]\'>'
+ esc(JSON.stringify(a.steps||[], null, 2)) + '</textarea>'
+ '<div class="text-muted" style="font-size:11px;margin:6px 0">可用 type:open_app/click/input_text/swipe/wait/key_event/group…;click 必须有 selector_value(xpath/text/resourceId/description…);<b>不接受坐标(click_xy)</b>。</div>'
+ '<div style="display:flex;gap:8px"><button class="btn btn-primary btn-sm" onclick="actSave(' + (a.id||0) + ')">保存</button>'
+ '<button class="btn btn-sm" onclick="loadActLib()">取消</button></div>';
list.appendChild(box);
}
function actSave(id){
const g = (i)=>document.getElementById(i).value;
const split = (s)=>String(s||'').split(/[,,]/).map(x=>x.trim()).filter(x=>x);
let steps;
try{ steps = JSON.parse(g('act-f-steps')||'[]'); }
catch(e){ showToast('steps 不是合法 JSON: '+e.message,'error'); return; }
apiPost('/api/agent/actions/save', {
id: id||0, name: g('act-f-name'), app: g('act-f-app'),
aliases: split(g('act-f-alias')), params: split(g('act-f-params')), steps: steps
}).then(r=>{
if(!r||!r.ok){ showToast((r&&r.error)||'保存失败','error'); return; }
showToast(r.msg||'已保存','success');
loadActLib();
});
}
// ================== 经验库(自进化记忆管理) ================== // ================== 经验库(自进化记忆管理) ==================
function openExpLib(){ function openExpLib(){
document.getElementById('agent-exp-overlay').style.display = 'flex'; document.getElementById('agent-exp-overlay').style.display = 'flex';
+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:
+19
View File
@@ -480,6 +480,7 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
<div class="agent-top-actions"> <div class="agent-top-actions">
<span id="agent-running-tag" class="agent-running-tag" style="display:none">● 运行中</span> <span id="agent-running-tag" class="agent-running-tag" style="display:none">● 运行中</span>
<button class="btn btn-xs" onclick="openExpLib()" title="查看/管理自进化经验库(每日 AI 巡检建议,删除需确认)">🧠 经验库</button> <button class="btn btn-xs" onclick="openExpLib()" title="查看/管理自进化经验库(每日 AI 巡检建议,删除需确认)">🧠 经验库</button>
<button class="btn btn-xs" onclick="openActLib()" title="查看/管理可复用动作(命名动作 + 元素定位步骤,AI 自动沉淀,可编辑删除)">🎬 动作库</button>
<button class="btn btn-xs" onclick="openAgentConfig()">⚙ 配置</button> <button class="btn btn-xs" onclick="openAgentConfig()">⚙ 配置</button>
</div> </div>
</div> </div>
@@ -563,6 +564,24 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
</div> </div>
</div> </div>
<!-- ========== 动作库模态框(可复用动作:命名动作 + 元素定位步骤) ========== -->
<div id="agent-act-overlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:1320;align-items:center;justify-content:center">
<div style="background:#11151c;border:1px solid var(--card-line);border-radius:14px;padding:20px 22px;width:860px;max-width:94vw;max-height:86vh;display:flex;flex-direction:column">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px">
<div style="font-size:15px;font-weight:700">🎬 动作库<span class="text-muted" style="font-size:11.5px;font-weight:400;margin-left:8px" id="act-lib-hint"></span></div>
<div style="display:flex;gap:6px;align-items:center">
<button class="btn btn-xs" onclick="actNew()">+ 新建动作</button>
<button class="btn btn-xs" onclick="loadActLib()">刷新</button>
<button class="btn btn-xs" onclick="closeActLib()">✕ 关闭</button>
</div>
</div>
<div class="text-muted" style="font-size:11.5px;padding:0 2px 8px;border-bottom:1px solid var(--card-line);margin-bottom:10px">
任务成功后自动把<b>成功步骤</b>沉淀为可复用动作(命名动作 + 编辑器 schema 步骤 + 元素定位,<b>不含坐标</b>);执行前按动作名/别名召回注入,AI 可直接复用、跳过重新探索。可在此查看、编辑、删除或手动新建。
</div>
<div id="act-lib-list" style="flex:1;overflow-y:auto;min-height:200px;display:flex;flex-direction:column;gap:8px"></div>
</div>
</div>
<!-- ========== Tab 4: 日志 ========== --> <!-- ========== Tab 4: 日志 ========== -->
<div id="tab-logs" class="tab-panel"> <div id="tab-logs" class="tab-panel">
<div class="page-title">日志查看</div> <div class="page-title">日志查看</div>
+592 -25
View File
@@ -186,23 +186,24 @@ def _qualify_experience(prompt, recipe):
def _find_experiences(prompt, limit=2, threshold=0.10): def _find_experiences(prompt, limit=2, threshold=0.10):
"""按 bigram 重叠检索相似历史经验(prompt 与任务描述的字符相似度)。 """按 bigram 重叠检索相似历史经验(prompt 与任务描述的字符相似度)。
命中的经验 hits+1(回写),让被反复参考的有效经验浮到前面。 返回 (注入文本, 命中的配方列表);命中的经验 hits+1(回写),让被反复
参考的有效经验浮到前面。配方列表供调用方展示「命中 N 条」与摘要。
""" """
try: try:
if _flask_app is None: if _flask_app is None:
return "" return "", []
with _flask_app.app_context(): with _flask_app.app_context():
_ensure_exp_table() _ensure_exp_table()
rows = db.session.execute(db.text( rows = db.session.execute(db.text(
"SELECT id, task_prompt, recipe, hits FROM agent_experience " "SELECT id, task_prompt, recipe, hits FROM agent_experience "
"WHERE recipe != '' ORDER BY hits DESC, id DESC LIMIT 50")).fetchall() "WHERE recipe != '' ORDER BY hits DESC, id DESC LIMIT 50")).fetchall()
except Exception: except Exception:
return "" return "", []
if not rows: if not rows:
return "" return "", []
cur = _bigrams(prompt) cur = _bigrams(prompt)
if not cur: if not cur:
return "" return "", []
scored = [] scored = []
for row_id, task_prompt, recipe, hits in rows: for row_id, task_prompt, recipe, hits in rows:
sim = len(cur & _bigrams(task_prompt)) / len(cur) sim = len(cur & _bigrams(task_prompt)) / len(cur)
@@ -220,10 +221,78 @@ def _find_experiences(prompt, limit=2, threshold=0.10):
db.session.commit() db.session.commit()
except Exception: except Exception:
pass pass
parts = [] picked = [recipe for _sim, _hits, recipe, _row_id in scored[:limit]]
for sim, _hits, recipe, _row_id in scored[:limit]: parts = [f"- {r[:600]}" for r in picked]
parts.append(f"- {recipe[:600]}") return "\n".join(parts), picked
return "\n".join(parts)
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):
"""清洗蒸馏输出:去掉模型可能加的「操作配方:」之类前缀标签与包裹引号。
旧逻辑用 `"配方" not in recipe[:50]` 直接拒绝——而蒸馏提示词本就要求以
「操作配方」作答,模型一旦照做就会被整条丢弃(静默),导致经验很难存下。
改为清洗前缀而非丢弃。
"""
t = (recipe or "").strip()
if not t:
return ""
t = re.sub(r'^[^\n]{0,12}?配方[::]\s*', '', t, count=1).strip()
t = re.sub(r'^(操作步骤|步骤|流程)[::]\s*', '', t).strip()
return t.strip('"“”\'').strip()
def _distill_experience(cfg, prompt, tool_seq): def _distill_experience(cfg, prompt, tool_seq):
@@ -232,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 ""
@@ -274,6 +355,371 @@ def _save_experience(prompt, recipe, tool_seq):
return False return False
# ---------- 动作经验库(agent_action)----------
# 与「任务级配方」(agent_experience) 互补:动作 = 有语义名的可复用单元,可含 1~N 步,
# steps 直接用编辑器 schema(open_app/click/input_text…),带**元素定位**
# (selector_type/selector_value),**不含坐标**(分辨率/旋转/改版即失效)。
# 复用:执行前按 name/别名/App 召回并注入 system prompt,模型可跳过重新探索。
_ACTION_TABLE = (
"CREATE TABLE IF NOT EXISTS agent_action ("
"id INTEGER PRIMARY KEY AUTOINCREMENT,"
"name TEXT NOT NULL,"
"app TEXT DEFAULT '',"
"aliases TEXT DEFAULT '[]',"
"params TEXT DEFAULT '[]',"
"steps TEXT NOT NULL,"
"preconditions TEXT DEFAULT '',"
"hits INTEGER DEFAULT 0,"
"source_prompt TEXT DEFAULT '',"
"created_at TEXT DEFAULT '',"
"updated_at TEXT DEFAULT '')")
# 允许沉淀的步骤类型(编辑器 STEP_TYPES 子集;显式排除 click_xy 等坐标类)
_ACTION_STEP_TYPES = {
"open_app", "stop_app", "screen_on", "screen_off", "key_event",
"swipe", "swipe_until", "click", "long_click", "wait_el",
"input_text", "clipboard", "wait", "loop", "group", "if_el"}
_ACTION_REQUIRED = { # 类型 → 必需的 params 键(缺则丢弃该动作)
"open_app": ("package",), "stop_app": ("package",),
"click": ("selector_value",), "long_click": ("selector_value",),
"wait_el": ("selector_value",), "swipe_until": ("selector_value",),
"if_el": ("selector_value", "then"),
"group": ("children",), "loop": ("children",)}
# 可写入动作的关键工具(探索类 de_screenshot/de_ui_tree/de_ocr 不沉淀)
_ACTION_TOOLS = {
"de_open_app", "de_stop_app", "de_tap_text", "de_tap_element", "de_type_text",
"de_set_clipboard", "de_swipe", "de_press_key", "de_wake", "de_sleep"}
def _ensure_action_table():
try:
db.session.execute(db.text(_ACTION_TABLE))
db.session.commit()
except Exception:
pass
def _brief_result(result):
"""工具结果的精简摘要(判成败 + 供提炼模型参考)。"""
try:
s = json.dumps(result, ensure_ascii=False) if not isinstance(result, str) else result
except Exception:
s = str(result)
return (s or "")[:160]
def _tool_ok(tool, result):
"""启发式判断一次工具调用是否成功(只沉淀成功动作,避免把误点当经验)。"""
if result is None:
return False
if isinstance(result, dict):
if result.get("error") or result.get("ok") is False or result.get("success") is False:
return False
if tool == "de_tap_text":
return bool(result.get("matched")) or result.get("method") in ("ui", "ocr")
if result.get("matched") is False:
return False
s = str(result).lower()
return not any(k in s for k in ("not_found", "error", "failed", "occupied"))
def _normalize_action_item(a):
"""把模型可能的「单动作」形状({action/tool/type, params})归一成标准动作。
实测蒸馏模型常不按提示词的 name/steps 输出,而是照抄输入行给出
{"action":"de_open_app","params":{...}}——这里做兼容映射,避免全被丢弃。
"""
if not isinstance(a, dict):
return None
if isinstance(a.get("steps"), list): # 已是标准形状
return a
tool = str(a.get("action") or a.get("tool") or a.get("type") or "").strip()
p = a.get("params") if isinstance(a.get("params"), dict) else {}
if not tool:
return None
name = str(a.get("name") or "").strip()
t, sp = None, {}
if tool in ("de_open_app", "open_app"):
t, sp = "open_app", {"package": p.get("package", "")}
name = name or f"打开应用 {p.get('package', '')}"
elif tool in ("de_stop_app", "stop_app"):
t, sp = "stop_app", {"package": p.get("package", "")}
name = name or f"关闭应用 {p.get('package', '')}"
elif tool == "de_tap_text":
t, sp = "click", {"selector_type": "text", "selector_value": p.get("text", "")}
name = name or f"点击「{p.get('text', '')}」"
elif tool == "de_tap_element":
by = {"text": "text", "id": "resourceId", "desc": "description",
"text_contains": "text", "desc_contains": "descriptionContains"}.get(
p.get("by"), "text")
t, sp = "click", {"selector_type": by, "selector_value": p.get("value", "")}
name = name or f"点击元素 {p.get('value', '')}"
elif tool == "de_type_text":
t, sp = "input_text", {"mode": "fixed", "fixed_text": p.get("text", "")}
name = name or f"输入「{p.get('text', '')}」"
elif tool == "de_set_clipboard":
t, sp, name = "clipboard", {"text": p.get("text", "")}, name or "写入剪贴板"
elif tool == "de_swipe":
t, sp = "swipe", {"direction": p.get("direction") or "up"}
name = name or "滑动"
elif tool == "de_press_key":
t, sp = "key_event", {"key": p.get("key") or "back"}
name = name or f"按键 {p.get('key', '')}"
elif tool == "de_wake":
t, name = "screen_on", name or "亮屏解锁"
elif tool == "de_sleep":
t, name = "screen_off", name or "息屏"
if not t:
return None
return {"name": name, "app": a.get("app", ""), "aliases": a.get("aliases") or [],
"params": [], "preconditions": a.get("preconditions", ""),
"steps": [{"type": t, "params": sp}]}
def _sanitize_actions(actions):
"""校验/清洗模型产出的动作:名字非空、步骤白名单+必填、显式禁坐标。"""
out = []
for a in (actions or []):
a = _normalize_action_item(a)
if not isinstance(a, dict):
continue
name = str(a.get("name") or "").strip()[:40]
steps = a.get("steps")
if not name or not isinstance(steps, list) or not steps:
continue
good = []
for st in steps:
if not isinstance(st, dict):
continue
t = st.get("type")
if t not in _ACTION_STEP_TYPES: # click_xy 等坐标类在此被剔除
continue
p = st.get("params") if isinstance(st.get("params"), dict) else {}
if any(not p.get(k) for k in _ACTION_REQUIRED.get(t, ())):
continue
good.append({"type": t, "label": str(st.get("label") or "")[:20], "params": p})
if not good:
continue
out.append({
"name": name,
"app": str(a.get("app") or "")[:80],
"aliases": [str(x)[:20] for x in (a.get("aliases") or []) if str(x).strip()][:5],
"params": [str(x)[:20] for x in (a.get("params") or []) if str(x).strip()][:5],
"preconditions": str(a.get("preconditions") or "")[:100],
"steps": good})
return out
def _iter_json_objects(raw):
"""从文本里按大括号配对切出顶层 JSON 对象(容忍坏片段,逐条抢救)。"""
depth = 0
start = None
in_str = esc = False
for i, c in enumerate(raw):
if in_str:
if esc:
esc = False
elif c == "\\":
esc = True
elif c == '"':
in_str = False
continue
if c == '"':
in_str = True
elif c == "{":
if depth == 0:
start = i
depth += 1
elif c == "}":
depth -= 1
if depth == 0 and start is not None:
yield raw[start:i + 1]
start = None
def _loads_lenient(text):
"""尽量解析模型输出的 JSON 数组:容忍 markdown 围栏、尾逗号、中文引号、坏对象。"""
s = (text or "").strip()
s = re.sub(r"^```[a-zA-Z]*\s*|\s*```$", "", s).strip()
m = re.search(r"\[[\s\S]*\]", s)
raw = m.group(0) if m else s
candidates = [raw,
re.sub(r",\s*([\]}])", r"\1", raw), # 去尾逗号
re.sub(r"[“”]", '"', raw), # 中文引号 → 英文
re.sub(r"[“”]", '"', re.sub(r",\s*([\]}])", r"\1", raw))]
for cand in candidates:
try:
v = json.loads(cand)
if isinstance(v, list):
return v
except Exception:
continue
# 逐对象抢救(顶层大括号配对),坏的跳过
out = []
for obj in _iter_json_objects(raw):
try:
out.append(json.loads(obj))
except Exception:
continue
return out
def _distill_actions(cfg, prompt, trace):
"""从**成功**的工具轨迹提炼命名动作(JSON 数组)。失败返回 []。"""
ok_ops = [t for t in (trace or [])
if t.get("tool") in _ACTION_TOOLS and _tool_ok(t.get("tool"), t.get("result"))]
_log.info(f"动作提炼: 轨迹 {len(trace or [])} 步, 成功可沉淀 {len(ok_ops)} 步")
if not ok_ops:
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]
instruction = (
"以下是一次成功的手机自动化操作的**成功步骤**。请提炼为**至多 3 个**「动作」"
"(每个动作 = 一个有语义名的可复用单元,**至多 4 步**,字段尽量简短)。"
"只输出 JSON 数组,不要解释,不要思考过程。\n"
"字段:name(动作名,如「打开抖音」「搜索关键词」);app(包名,未知则空串);"
"aliases(别名数组);params(参数名数组,如[\"关键词\"]);steps(步骤数组)。\n"
"steps 每步:type + params,type 取值:open_app{package} / click{selector_type,"
"selector_value,wait_timeout?} / input_text{mode,fixed_text} / swipe{direction} /"
"wait{min,max} / key_event{key} / group{children}。\n"
"**定位必须用元素定位**:selector_type 取 xpath/text/resourceId/description/"
"descriptionContains,值用上面步骤里出现的真实文字或 id;**禁止坐标**。"
"若某步只能用坐标定位,就不要产出该动作。\n"
"输出示例(**顶层字段必须是 name/steps,禁止用 action/tool 当顶层字段**):\n"
'[{"name":"打开抖音","app":"com.ss.android.ugc.aweme","aliases":["启动抖音"],'
'"params":[],"steps":[{"type":"open_app","params":{"package":"com.ss.android.ugc.aweme"}}]},'
'{"name":"搜索关键词","app":"","aliases":["点搜索"],"params":["关键词"],'
'"steps":[{"type":"click","params":{"selector_type":"text","selector_value":"搜索"}}]}]\n'
f"任务:{prompt[:200]}\n成功步骤:\n" + "\n".join(lines)[:1500])
try:
import httpx
body = {"model": cfg.get("model") or "deepseek-v4-flash-vision-exp",
"thinking": {"type": "disabled"}, # 同 _distill_experience:关推理
"messages": [{"role": "user", "content": instruction}],
"max_tokens": 900}
headers = {"Authorization": f"Bearer {cfg.get('api_key', '')}",
"Content-Type": "application/json"}
url = f"{(cfg.get('api_base') or 'https://api.deepseek.com').rstrip('/')}/chat/completions"
content = ""
# 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:
_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))
if not acts:
_log.info(f"动作提炼: 解析后无有效动作(原始输出 {len(content)} 字符)"
f" 样本={content[:160]!r}")
return acts
except Exception as e:
_log.warning(f"动作提炼失败: {e}")
return []
def _save_actions(prompt, actions):
"""按 (name, app) upsert 保存动作。返回保存条数。"""
if not actions or _flask_app is None:
return 0
n = 0
try:
from datetime import datetime
now = datetime.now().strftime("%Y-%m-%d %H:%M")
with _flask_app.app_context():
_ensure_action_table()
for a in actions:
row = db.session.execute(db.text(
"SELECT id FROM agent_action WHERE name=:n AND app=:a"),
{"n": a["name"], "a": a["app"]}).fetchone()
if row:
db.session.execute(db.text(
"UPDATE agent_action SET aliases=:al, params=:p, steps=:s, "
"preconditions=:pc, updated_at=:t WHERE id=:i"),
{"al": json.dumps(a["aliases"], ensure_ascii=False),
"p": json.dumps(a["params"], ensure_ascii=False),
"s": json.dumps(a["steps"], ensure_ascii=False),
"pc": a["preconditions"], "t": now, "i": row[0]})
else:
db.session.execute(db.text(
"INSERT INTO agent_action(name, app, aliases, params, steps, "
"preconditions, hits, source_prompt, created_at, updated_at) "
"VALUES(:n,:a,:al,:p,:s,:pc,0,:sp,:t,:t)"),
{"n": a["name"], "a": a["app"],
"al": json.dumps(a["aliases"], ensure_ascii=False),
"p": json.dumps(a["params"], ensure_ascii=False),
"s": json.dumps(a["steps"], ensure_ascii=False),
"pc": a["preconditions"], "sp": prompt[:200], "t": now})
n += 1
db.session.commit()
_log.info(f"动作经验已保存 {n} 条")
except Exception as e:
_log.warning(f"动作保存失败: {e}")
return n
def _find_actions(prompt, limit=3):
"""召回可复用动作:动作名/别名命中 prompt,或与来源提示够相似。返回 (文本, 名字列表)。"""
try:
if _flask_app is None:
return "", []
with _flask_app.app_context():
_ensure_action_table()
rows = db.session.execute(db.text(
"SELECT id, name, app, aliases, params, steps, hits FROM agent_action "
"ORDER BY hits DESC, id DESC LIMIT 100")).fetchall()
except Exception:
return "", []
if not rows:
return "", []
p_norm = "".join(c for c in (prompt or "").lower()
if c.isalnum() or "一" <= c <= "鿿")
cur = _bigrams(prompt)
scored = []
for rid, name, app, aliases, params, steps, hits in rows:
try:
keys = [name] + [str(x) for x in (json.loads(aliases) if aliases else [])]
except Exception:
keys = [name]
hit = any(k and k.lower() in p_norm for k in keys if k)
sim = (len(cur & _bigrams(name)) / len(cur)) if cur else 0.0
if hit or sim >= 0.34:
scored.append((1 if hit else 0, sim, hits or 0, rid, name, app, params, steps))
if not scored:
return "", []
scored.sort(key=lambda x: (-x[0], -x[1], -x[2]))
picked = scored[:limit]
try:
with _flask_app.app_context():
for item in picked:
db.session.execute(db.text("UPDATE agent_action SET hits=hits+1 WHERE id=:i"),
{"i": item[3]})
db.session.commit()
except Exception:
pass
items, parts = [], []
for item in picked:
_hit, _sim, _hts, _rid, name, app, params, steps = item
try:
st = json.loads(steps) if steps else []
except Exception:
st = []
items.append(name)
brief = " → ".join(
str(s.get("params", {}).get("selector_value")
or s.get("params", {}).get("package")
or s.get("params", {}).get("fixed_text")
or s.get("type")) for s in st[:8])
parts.append(f"- 「{name}」" + (f"(app={app})" if app else "")
+ (f" 参数:{params}" if params else "") + f":{brief}")
return "\n".join(parts), items
# ================== 经验巡检(AI 质检,删除需人工确认) ================== # ================== 经验巡检(AI 质检,删除需人工确认) ==================
_audit_state = {"running": False, "last": "", "last_summary": ""} _audit_state = {"running": False, "last": "", "last_summary": ""}
@@ -312,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")
@@ -460,6 +906,94 @@ def agent_experience_keep():
db.session.commit() db.session.commit()
return jsonify({"ok": True, "msg": f"经验 #{exp_id} 已保留"}) return jsonify({"ok": True, "msg": f"经验 #{exp_id} 已保留"})
# ================== 动作经验库(agent_action:查看/删除/编辑) ==================
def _action_row_to_dict(r):
def _j(s, d):
try:
return json.loads(s) if s else d
except Exception:
return d
return {"id": r[0], "name": r[1], "app": r[2], "aliases": _j(r[3], []),
"params": _j(r[4], []), "steps": _j(r[5], []), "preconditions": r[6],
"hits": r[7] or 0, "updated_at": r[8] or ""}
@bp.route("/api/agent/actions")
@admin_required
def agent_actions_list():
"""动作经验库列表(命名动作 + 编辑器 schema 步骤 + 元素定位,禁坐标)。"""
with _flask_app.app_context():
_ensure_action_table()
rows = db.session.execute(db.text(
"SELECT id, name, app, aliases, params, steps, preconditions, hits, updated_at "
"FROM agent_action ORDER BY hits DESC, id DESC")).fetchall()
return jsonify({"ok": True, "actions": [_action_row_to_dict(r) for r in rows]})
@bp.route("/api/agent/actions/delete", methods=["POST"])
@admin_required
def agent_actions_delete():
aid = int((request.json or {}).get("id") or 0)
if aid <= 0:
return jsonify({"ok": False, "error": "缺少 id"}), 400
with _flask_app.app_context():
_ensure_action_table()
db.session.execute(db.text("DELETE FROM agent_action WHERE id=:i"), {"i": aid})
db.session.commit()
return jsonify({"ok": True, "msg": f"动作 #{aid} 已删除"})
@bp.route("/api/agent/actions/save", methods=["POST"])
@admin_required
def agent_actions_save():
"""新增/编辑动作:{id?, name, app?, aliases?, params?, steps, preconditions?}。
steps 可为数组或 JSON 字符串;经 _sanitize_actions 校验(白名单/必填/禁坐标)。
"""
from datetime import datetime
d = request.json or {}
steps = d.get("steps")
if isinstance(steps, str):
try:
steps = json.loads(steps)
except Exception:
return jsonify({"ok": False, "error": "steps 不是合法 JSON"}), 400
item = _sanitize_actions([{"name": d.get("name"), "app": d.get("app", ""),
"aliases": d.get("aliases") or [],
"params": d.get("params") or [],
"preconditions": d.get("preconditions", ""),
"steps": steps if isinstance(steps, list) else []}])
if not item:
return jsonify({"ok": False, "error": "动作无效:需要 name,且 steps 需为受支持的步骤"
"(click/open_app/input_text… 且带元素定位;不接受坐标)"}), 400
a = item[0]
now = datetime.now().strftime("%Y-%m-%d %H:%M")
with _flask_app.app_context():
_ensure_action_table()
aid = int(d.get("id") or 0)
if aid > 0:
db.session.execute(db.text(
"UPDATE agent_action SET name=:n, app=:a, aliases=:al, params=:p, steps=:s, "
"preconditions=:pc, updated_at=:t WHERE id=:i"),
{"n": a["name"], "a": a["app"],
"al": json.dumps(a["aliases"], ensure_ascii=False),
"p": json.dumps(a["params"], ensure_ascii=False),
"s": json.dumps(a["steps"], ensure_ascii=False),
"pc": a["preconditions"], "t": now, "i": aid})
else:
db.session.execute(db.text(
"INSERT INTO agent_action(name, app, aliases, params, steps, preconditions, "
"hits, source_prompt, created_at, updated_at) "
"VALUES(:n,:a,:al,:p,:s,:pc,0,'(人工新增)',:t,:t)"),
{"n": a["name"], "a": a["app"],
"al": json.dumps(a["aliases"], ensure_ascii=False),
"p": json.dumps(a["params"], ensure_ascii=False),
"s": json.dumps(a["steps"], ensure_ascii=False),
"pc": a["preconditions"], "t": now})
db.session.commit()
return jsonify({"ok": True, "msg": "动作已保存"})
# ================== 历史会话(DeepSeek 式:多会话持久化) ================== # ================== 历史会话(DeepSeek 式:多会话持久化) ==================
# 会话 = 消息序列(JSON),一轮 run 的 user/assistant 文本完成后追加落库。 # 会话 = 消息序列(JSON),一轮 run 的 user/assistant 文本完成后追加落库。
# 单表 JSON 存储(每会话几十 KB 内,无需独立消息表)。 # 单表 JSON 存储(每会话几十 KB 内,无需独立消息表)。
@@ -814,7 +1348,8 @@ def _agent_thread(run_id, prompt, serial, cfg):
def on_delta(text, kind): def on_delta(text, kind):
q.put(("delta", {"text": text, "kind": kind})) q.put(("delta", {"text": text, "kind": kind}))
tool_seq = [] # 本轮工具序列(经验提炼用) tool_seq = [] # 本轮工具序列(任务级配方提炼用)
tool_trace = [] # 结构化轨迹:{tool, args, result}(动作经验提炼用,判成败)
def on_tool(step): def on_tool(step):
# args 保留对象(json.dumps 序列化)——前端要解析 serial 做画面跟随; # args 保留对象(json.dumps 序列化)——前端要解析 serial 做画面跟随;
@@ -824,11 +1359,16 @@ def _agent_thread(run_id, prompt, serial, cfg):
if step.get("image"): if step.get("image"):
rec["image"] = _shrink_image(step["image"]) rec["image"] = _shrink_image(step["image"])
q.put(("step", rec)) q.put(("step", rec))
# 记录精简工具序列 # 记录精简工具序列 + 结构化轨迹(去 serial、结果截断)
try: try:
args = step.get("args") or {} args = step.get("args") or {}
brief = {k: v for k, v in args.items() if k != "serial"} brief = {k: v for k, v in args.items() if k != "serial"}
tool_seq.append(f"{step.get('tool')}({str(brief)[:60]})") tool_seq.append(f"{step.get('tool')}({str(brief)[:60]})")
res = step.get("result")
# 可沉淀工具保留原始 result(_tool_ok 需按字段判成败);其余只存摘要
tool_trace.append({"tool": step.get("tool"), "args": brief,
"result": res if step.get("tool") in _ACTION_TOOLS
else _brief_result(res)})
except Exception: except Exception:
pass pass
@@ -862,12 +1402,26 @@ def _agent_thread(run_id, prompt, serial, cfg):
stop_evt = _stop_events.get(run_id) stop_evt = _stop_events.get(run_id)
# 经验检索:相似历史任务的操作配方注入 system(自进化记忆) # 经验检索:相似历史任务的操作配方注入 system(自进化记忆)
exp_ctx = _find_experiences(prompt) exp_ctx, exp_items = _find_experiences(prompt)
if exp_ctx: if exp_ctx:
_log.info("命中历史经验,注入参考配方") _log.info("命中历史经验,注入参考配方")
brief = ";".join("".join(x.split())[:16] for x in exp_items[:2])
q.put(("step", {"tool": "🧠 经验记忆", q.put(("step", {"tool": "🧠 经验记忆",
"args": f"命中 {exp_ctx.count(chr(10) + '- ')} 条同类历史经验,已注入参考", "args": f"命中 {len(exp_items)} 条同类历史经验,已注入参考"
+ (f":{brief}" if brief else ""),
"image": None})) "image": None}))
# 动作经验:可复用的命名动作(带元素定位),优先复用可跳过重新探索
act_ctx, act_items = _find_actions(prompt)
if act_ctx:
_log.info(f"命中可复用动作 {len(act_items)} 个,注入参考")
q.put(("step", {"tool": "🧠 动作经验",
"args": f"命中 {len(act_items)} 个可复用动作,已注入参考"
+ (f":{'、'.join(act_items[:3])}" if act_items else ""),
"image": None}))
recall_ctx = exp_ctx
if act_ctx:
recall_ctx += ("\n\n## 可复用动作(优先按其中的元素定位操作;"
"若与当前界面不符,再自行截图确认)\n" + act_ctx)
mcp_url = getattr(getattr(agent, "s", None), "mcp_url", "") mcp_url = getattr(getattr(agent, "s", None), "mcp_url", "")
@@ -897,7 +1451,7 @@ def _agent_thread(run_id, prompt, serial, cfg):
on_delta=on_delta, on_tool=on_tool, on_delta=on_delta, on_tool=on_tool,
should_stop=lambda: bool( should_stop=lambda: bool(
stop_evt and stop_evt.is_set()), stop_evt and stop_evt.is_set()),
extra_context=exp_ctx) extra_context=recall_ctx)
except Exception as e: except Exception as e:
if _mcp_unreachable(e): if _mcp_unreachable(e):
raise RuntimeError( raise RuntimeError(
@@ -935,8 +1489,8 @@ def _agent_thread(run_id, prompt, serial, cfg):
# 提炼/保存失败静默(不阻塞、不影响结果),只在成功时推送 🧠 卡片。 # 提炼/保存失败静默(不阻塞、不影响结果),只在成功时推送 🧠 卡片。
if tool_seq: if tool_seq:
try: try:
recipe = _distill_experience(cfg, prompt, " -> ".join(tool_seq)) recipe = _clean_recipe(_distill_experience(cfg, prompt, " -> ".join(tool_seq)))
if recipe and "配方" not in recipe[:50]: if recipe:
if _qualify_experience(prompt, recipe): if _qualify_experience(prompt, recipe):
if _save_experience(prompt, recipe, " -> ".join(tool_seq)): if _save_experience(prompt, recipe, " -> ".join(tool_seq)):
_log.info("经验已写入记忆库,随事件流提示") _log.info("经验已写入记忆库,随事件流提示")
@@ -944,6 +1498,19 @@ def _agent_thread(run_id, prompt, serial, cfg):
"args": "本轮操作已提炼为经验并写入记忆库" "args": "本轮操作已提炼为经验并写入记忆库"
"(下次相似任务会自动参考)", "(下次相似任务会自动参考)",
"image": None})) "image": None}))
# 动作经验:把本轮**成功**步骤沉淀为命名动作(带元素定位,禁坐标)
acts = _distill_actions(cfg, prompt, tool_trace)
n_act = _save_actions(prompt, acts)
if n_act:
q.put(("step", {"tool": "🧠 动作经验",
"args": f"已沉淀 {n_act} 个可复用动作"
"(含元素定位,下次同类任务可直接复用)",
"image": None}))
else:
q.put(("step", {"tool": "🧠 动作经验",
"args": "本轮未沉淀动作(步骤以坐标定位为主,"
"缺少可复用的元素定位信息)",
"image": None}))
except Exception as e: except Exception as e:
_log.warning(f"经验保存异常: {e}") _log.warning(f"经验保存异常: {e}")
q.put(("done", {"answer": answer})) q.put(("done", {"answer": answer}))