Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c2b440dce | ||
|
|
972db13f81 | ||
|
|
3f44cd1491 | ||
|
|
8072f4a380 | ||
|
|
91b2785c6b | ||
|
|
104964aa53 | ||
|
|
4e71f79a10 | ||
|
|
19560aba3e |
+15
-7
@@ -89,9 +89,10 @@ def get_elements(serial):
|
||||
1. 有 resource-id/text/content-desc 的元素:
|
||||
先预统计该属性在整棵树中的出现次数——
|
||||
- 唯一出现:直接用属性选择器 //*[@resource-id="x"]
|
||||
- 重复出现(如抖音信息流点赞按钮同 id 几十个):附加 [k] 位置谓词
|
||||
精确到具体实例 //*[@resource-id="x"][k]。uiautomator2 的 d.xpath()
|
||||
底层是 lxml 标准 XPath,[k] 与抓取时同一语义,不会误中屏幕外第一个。
|
||||
- 重复出现(如抖音底部导航 4 个 tab 同 id):用**整体加括号**的
|
||||
`(//*[@resource-id="x"])[k]` 精确到第 k 个匹配。
|
||||
注意 XPath 语义:`//*[@id="x"][k]` 是"在其父节点中排第 k",不是
|
||||
第 k 个匹配——历史实现踩过这个坑(多实例时 [2..n] 全部失配)。
|
||||
2. 无任何属性的元素:
|
||||
用最近一个有属性祖先的选择器限定范围 + 同 class 兄弟序号定位
|
||||
(如 //*[@resource-id="x"]/FrameLayout/ImageView[2]);
|
||||
@@ -153,14 +154,21 @@ def _extract(root, out):
|
||||
count_attrs(c)
|
||||
|
||||
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
|
||||
val = f'//*[@{attr}={_xpath_q(value)}]'
|
||||
base = f'//*[@{attr}={_xpath_q(value)}]'
|
||||
if cnt[value] > 1:
|
||||
val += f"[{seen[value]}]"
|
||||
val = f"({base})[{seen[value]}]"
|
||||
return val, {"type": "xpath", "value": val,
|
||||
"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):
|
||||
"""递归扁平化。
|
||||
|
||||
@@ -136,6 +136,7 @@
|
||||
## 6. 里程碑
|
||||
- **P0(本设计主体)**:designer 模式 → 自探(UI 树定位)→ 直接撰写 generic_steps draft → 服务端 schema 校验 → 前端步骤编辑器预填确认保存。验收:一句话在真实设备上生成一条可调度的 generic_steps,步骤全部来自 UI 树且编辑器可打开。
|
||||
- **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 控制。
|
||||
|
||||
## 7. 实现时需新增/改动文件(规划)
|
||||
|
||||
+15
-2
@@ -1193,7 +1193,7 @@ AI 可用设备列表(在线状态 + 是否有任务运行,前端据此把 b
|
||||
|
||||
订阅事件流(SSE,EventSource)。事件:
|
||||
- `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: error` `{message}` — 失败(若因 MCP Server 未启动/不可达,message 为明确文案「MCP server(8033) 不可达 …」,不再是 SDK 原始的 `Server returned an error response`)
|
||||
- 空闲时每 15s 发一行 `: keepalive` 注释防超时;`done`/`error` 后关流
|
||||
@@ -1252,7 +1252,7 @@ AI 可用设备列表(在线状态 + 是否有任务运行,前端据此把 b
|
||||
|
||||
### GET /api/agent/experience
|
||||
|
||||
经验记忆库列表(自进化,含最近一次巡检结论)。
|
||||
经验记忆库列表(自进化,含最近一次巡检结论)。另有一张**动作经验库**表 `agent_action`(命名动作 + 编辑器 schema 步骤 + 元素定位、禁坐标):任务成功后自动从**成功步骤**蒸馏沉淀,执行前按动作名/别名召回并注入;当前无独立查询接口(命中/沉淀在 AI 控制台的 🧠 卡片可见)。
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
@@ -1275,6 +1275,19 @@ AI 可用设备列表(在线状态 + 是否有任务运行,前端据此把 b
|
||||
|
||||
**响应**:`{"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
|
||||
|
||||
人工保留经验(撤销"建议删除",后续巡检不再重复建议)。**请求**(JSON):`{"id": 1}`
|
||||
|
||||
+25
-3
@@ -185,7 +185,8 @@ SQLAlchemy 模型,存于 `data/users.db`:
|
||||
|
||||
> 表名默认取类名小写(models.py 未写 `__tablename__`)。另有三张非模型表,由原生 SQL 幂等创建、**不走 SCHEMA_MIGRATIONS**:
|
||||
> - `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`):
|
||||
- 创建所有表
|
||||
@@ -229,6 +230,11 @@ APK 上传/解析/批量安装。
|
||||
|
||||
元素树解析:递归提取每个节点的 `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`)
|
||||
|
||||
条件判断的 `ocr` 选择器实现:截屏 → RapidOCR(ONNX 推理,中英文模型随包内置)→ 关键词匹配 → 返回文字中心像素坐标(与 u2 `d.click` 一致)。
|
||||
@@ -251,7 +257,7 @@ APK 上传/解析/批量安装。
|
||||
| `devices_api.py` | 设备池管理 + 自动发现(扫描/确认/忽略/手动重连/型号采集) |
|
||||
| `apks_api.py` | 应用管理 |
|
||||
| `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) |
|
||||
| `common.py` | 跨模块共享(合并设备列表/屏幕状态) |
|
||||
| `context.py` | 共享对象注入(mgr/apk_mgr/device_pool) |
|
||||
@@ -333,10 +339,26 @@ tasks/douyin/actions/like.py — @register_action(ACTIONS) LikeAction
|
||||
|
||||
(原"分组"顶级 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),不影响任务编辑窗口:
|
||||
1. 选择设备 → 2. 加载截图 + 元素树 → 3. 点击元素/边界框 → 4. 回填选择器
|
||||
5. **抓取时直接验证**(每条元素右侧两个按钮,不会与"点击回填"冲突):
|
||||
- 「▶ 点一下」:按元素 `bounds` 中心在设备上真点一次(`POST /api/screen/tap`,`snap=1` 自动吸附到可点元素),返回吸附结果并自动刷新截图——用于确认位置/是否可达;
|
||||
- 「✓ 测选择器」:用**将填入的选择器**真跑一次 click(`POST /api/steps/test`),返回 `命中/未找到/已执行`——用于确认回填的选择器在真实界面能命中(元素无有效选择器时不显示此按钮)。
|
||||
|
||||
---
|
||||
|
||||
|
||||
+2
-1
@@ -214,7 +214,7 @@ tasks/<app>/
|
||||
- 模型改 `core/models.py`,首次建表用 `create_all()`;SQLAlchemy 模型未写 `__tablename__` 时默认表名 = 小写类名
|
||||
- **已有数据的老库**:在 `core/models.py` 的 `SCHEMA_MIGRATIONS` 里加迁移(版本号递增 + SQL)
|
||||
- `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 改动必须同步文档
|
||||
|
||||
@@ -264,3 +264,4 @@ tasks/<app>/
|
||||
| [AI_TASK_GEN.md](AI_TASK_GEN.md) | AI 建任务设计文档 |
|
||||
| [staffdeck/KNOWLEDGE_BASE.md](staffdeck/KNOWLEDGE_BASE.md) | 给 StaffDeck 数字员工的知识库(MCP 接入/工具/约定/红线) |
|
||||
| [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` 命中后自动点击)。
|
||||
带选择器的步骤(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)。
|
||||
> **权威 schema**:`tasks/generic/task.py` 的 `STEP_TYPES`(后端执行器)与 `static/admin/editor.js` 的 `STEP_LIB`(前端操作库)**必须保持同步**——改节点结构两边要一起改。
|
||||
> **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`;如需收紧需补实现。
|
||||
@@ -116,15 +116,23 @@
|
||||
|
||||
## 4. 工作流(SOP)
|
||||
|
||||
> 详细规则与闸门见 `doc/staffdeck/KNOWLEDGE_BASE.md` §5(操作纪律)、§6(开跑前状态检查)、§7(执行中状态判据)、§8(SOP 全流程)。本节只给岗位层概览。
|
||||
|
||||
**五阶段**:
|
||||
```
|
||||
接收指令
|
||||
→ 澄清(目标 App / 目标动作 / 哪台设备 / 时长,缺失就问)
|
||||
→ 选设备(de_list_devices,排除 busy/offline)
|
||||
→ 预检(de_foreground_app + de_screenshot 确认当前状态)
|
||||
→ 执行(拆小步;L1 写操作,每步截图验证)
|
||||
→ 判定(成功 / 失败原因 / 需人工确认)
|
||||
→ 汇报(看板摘要 §2 + 证据)
|
||||
→ 收尾(如需还原:de_stop_app / 返回上一页;不强制改变设备状态)
|
||||
P0 接收与澄清 → P1 选设备与预检(含"屏幕是否点亮/解锁") → P2 到达起点
|
||||
→ P3 主流程:观察-行动-验证(OAV)循环 → P4 收尾与还原 → P5 汇报
|
||||
```
|
||||
|
||||
- **P0**:澄清目标 App/动作/设备/时长/成功标准;缺失且影响执行就先问;L2/L3 停下要授权。
|
||||
- **P1**:`de_list_devices` 选在线空闲设备;`de_screenshot` 确认**亮屏且非锁屏**(黑屏先 `de_wake`);`de_foreground_app` 确认前台;确认起点页特征与坐标基线。
|
||||
- **P2**:`de_open_app` 或 `back` 到起点;起点特征文本出现才算到位。
|
||||
- **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
@@ -1,151 +1,251 @@
|
||||
# 知识库 · 设备自动化平台(auto_control)接入说明
|
||||
# 知识库 · 设备自动化平台(auto_control)接入与操作手册
|
||||
|
||||
> 读者:StaffDeck 数字员工(外部 Agent)。
|
||||
> 目的:让你知道**能做什么、怎么调、什么不能做**,从而安全地代为操作手机设备。
|
||||
> 版本:2026-09-10 | 权威工具清单以 `doc/MCP.md` 为准,配置以 `mcp_server/config.py` 为准。
|
||||
> 目标:让你达到**与平台内置 AI 控制台同等**的操作水平——不是"能调工具",而是"会看、会判断、会收尾"。
|
||||
> 版本: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.1 首选:MCP(HTTP / streamable)
|
||||
- 地址:`http://<host>:8033/mcp`(FastMCP,`transport="http"`)。
|
||||
- 调用方式:支持 MCP 的客户端直接连;工具名即 `de_*`(见 §3)。
|
||||
- **鉴权现状**:MCP 层**无独立鉴权**——它自身用 `MCP_PLATFORM_USER/PASS` 登录平台(会话 + CSRF 由服务端内部处理,你不用管)。因此**谁能访问 8033 谁就能操控设备**,务必只在内网/Tailscale 内暴露,不要裸露公网。
|
||||
- **审计**:每次调用(含只读)都会写一行 JSON 审计(工具、serial、参数摘要、结果)。
|
||||
- 地址:`http://<host>:8033/mcp`。
|
||||
- **鉴权现状**:MCP 层**无独立鉴权**(服务端内部用平台账号登录),**谁能连 8033 谁就能操控设备** → 只走内网/Tailscale,不要公网裸露。
|
||||
- **审计**:每次调用(含只读)写一行 JSON。
|
||||
|
||||
### 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`(**只读**) | `1` 才允许点按/滑动/输入等**写操作**;为 0 时写工具返回 `write_disabled` |
|
||||
| `MCP_ALLOWED_SERIALS` | 空 | 逗号分隔设备白名单;非空时只允许这些 serial。**注意:为空时当前代码只校验 serial 非空,并不自动限平台设备池**——如需收紧请让部署方配置白名单 |
|
||||
| `MCP_PLATFORM_URL` | `http://127.0.0.1:18050` | 平台地址 |
|
||||
| `MCP_PLATFORM_USER` / `_PASS` | `admin` / 空 | 服务端登录平台用,与你无关 |
|
||||
| `MCP_PLATFORM_TIMEOUT` | `30` | 平台请求超时(秒) |
|
||||
| `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`
|
||||
| `MCP_ALLOW_WRITE` | `0` | `0` 时写工具全返回 `write_disabled`(只能看不能动) |
|
||||
| `MCP_ALLOWED_SERIALS` | 空 | 非空=白名单;**为空时不自动限设备池**(仅校验非空) |
|
||||
| `MCP_HTTP_HOST` / `MCP_HTTP_PORT` | `0.0.0.0` / `8033` | 监听 |
|
||||
| `MCP_SCREENSHOT_WIDTH` / `MCP_JPEG_QUALITY` | `540` / `70` | 截图尺寸/质量 |
|
||||
| `MCP_PLATFORM_TIMEOUT` | `30` | 平台请求超时 |
|
||||
| `MCP_AUDIT_FILE` | `/var/log/mcp/audit.log` | 审计文件 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 工具清单(19 个 `de_*`)
|
||||
## 3. 工具清单(19 个)
|
||||
|
||||
### 只读(观测,不触发设备占用锁;任务运行中也安全)
|
||||
| 工具 | 入参 | 返回要点 | 用途 |
|
||||
|---|---|---|---|
|
||||
| `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()` | — | 平台任务计划列表 | 只读了解平台任务(**不能增改**) |
|
||||
**只读(不触发占用锁)**:`de_list_devices` · `de_screenshot` · `de_ui_tree` · `de_ocr` · `de_foreground_app` · `de_list_apps` · `de_read_clipboard` · `de_list_tasks`
|
||||
|
||||
### 写操作(会先检查设备是否被任务占用;`MCP_ALLOW_WRITE=1` 才可用)
|
||||
| 工具 | 入参 | 说明 |
|
||||
|---|---|---|
|
||||
| `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`) |
|
||||
**写(受占用锁约束)**:`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`
|
||||
|
||||
> 平台级工具(**任务创建/修改/启停、分组、设备池管理、备份等**)**目前没有 MCP 工具**,只有上面这些设备能力;需要它们时请通过 REST(§9)或在平台 Web 后台操作。
|
||||
参数与返回详见 `doc/MCP.md`。
|
||||
|
||||
---
|
||||
|
||||
## 4. 通用调用约定
|
||||
## 4. 通用约定
|
||||
|
||||
### 4.1 设备标识 `serial`
|
||||
- 网络设备:`IP:5555`(如 `100.100.10.13:5555`、`192.168.20.206:5555`)。
|
||||
- USB 设备:纯串号(无冒号,如 `ZY322XXXX`)。
|
||||
- 用 `de_list_devices()` 获取当前可用清单,**不要凭空猜 serial**。
|
||||
### 4.1 serial
|
||||
- 网络 `IP:5555`(如 `100.100.10.13:5555`);USB 纯串号(如 `ZY322XXXX`)。
|
||||
- **用 `de_list_devices` 取,别猜。**
|
||||
|
||||
### 4.2 坐标空间(重要)
|
||||
- `de_screenshot` 返回的是**缩放图(≤540px 宽)+ `native_size`(原生分辨率)**。
|
||||
- `de_tap` / `de_swipe` 的坐标用**截图坐标系(display 空间)**,服务端按最近一次截图比例换算原生。
|
||||
- **必须先 `de_screenshot` 再坐标操作**,否则报「请先执行 de_screenshot」。
|
||||
- 屏幕可能旋转/滚动,坐标随时会变——**优先文字/元素定位,坐标仅兜底**。
|
||||
- `de_screenshot` 返回**缩放图(≤540px 宽)+ `native_size`**;`de_tap`/`de_swipe` 用**截图坐标系**,服务端换算原生。
|
||||
- **必须先截图再坐标操作**;否则报「请先执行 de_screenshot」。
|
||||
- 屏可能旋转/滚动 → **优先文字/元素,坐标仅兜底**。
|
||||
|
||||
### 4.3 设备占用锁(busy)
|
||||
- 写工具执行前会检查该设备是否有平台任务在跑:`running`/`connecting` → 返回 **`device_busy`**(“AI 不与任务抢设备”)。此时**换设备或等待**,不要硬重试。
|
||||
- 只读工具(截图/UI树/OCR/列表)不受此限制。
|
||||
### 4.3 占用锁(busy)
|
||||
- 写工具执行前查设备任务状态:`running`/`connecting` → **`device_busy`**(不与任务抢设备)。换设备或等待,别硬试。
|
||||
- 只读工具不受限(任务运行中也能安全截图)。
|
||||
|
||||
### 4.4 错误码
|
||||
| 错误 | 含义 | 应对 |
|
||||
|---|---|---|
|
||||
| `write_disabled` | 服务端未开写门控 | 告知部署方开 `MCP_ALLOW_WRITE=1` |
|
||||
| `device_busy` | 设备正被任务占用 | 换设备 / 等任务结束 |
|
||||
| `write_disabled` | 写门控关闭 | 上报部署方 |
|
||||
| `device_busy` | 设备被任务占用 | 换设备/等待 |
|
||||
| `device_offline` | 设备不可达 | 换设备;报运维 |
|
||||
| `device_not_allowed` | 不在白名单 | 换设备;让部署方加白名单 |
|
||||
| `text_not_found` | 屏上没有该文字 | 截图确认;改用 `de_ui_tree`/`de_ocr` |
|
||||
| `platform_unavailable` | 平台不可达 | 稍后重试;报运维 |
|
||||
| `device_not_allowed` | 不在白名单 | 换设备;加白名单 |
|
||||
| `text_not_found` | 屏上无该文字 | 重截图/`de_ui_tree`/`de_ocr` 复核 |
|
||||
| `platform_unavailable` | 平台不可达 | 稍后重试 |
|
||||
| `invalid_param` | 参数不合法 | 修参数 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 推荐操作模式(务必遵守,能显著提高成功率)
|
||||
## 5. 操作纪律(**与平台内置 AI 控制台等价**,务必遵守)
|
||||
|
||||
1. **文字语义点击优先**:`de_tap_text("关注")` > `de_ui_tree` 找元素 + `de_tap_element` > `de_tap` 坐标兜底。
|
||||
2. **每关键步后截图验证**:操作是否生效看截图/UI 树,别连续盲点。
|
||||
3. **不重复同坐标**:点击后画面无变化,禁止反复点同一坐标(多数 App 会误触)。
|
||||
4. **先确认前台与页面**:`de_foreground_app` / 截图确认在正确页面再操作。
|
||||
5. **一次一个明确目标**:拆成小步,每步可验证。
|
||||
> 这是平台内置 Agent 的系统规范,逐条对齐即可达到同等效果。
|
||||
|
||||
### 常见配方
|
||||
- **看视频养号(示意)**:`de_list_devices` → 选设备 → `de_open_app(pkg)` → 循环〔`de_screenshot` 确认在视频页 → 等待 → `de_swipe` 上滑〕→ 结束 `de_stop_app`。
|
||||
- **打开指定 App**:`de_list_apps(keyword)` 拿包名 → `de_open_app(package)` → 截图确认首页。
|
||||
- **输入文字**:`de_tap_text` 或 `de_tap_element` 点输入框 → `de_type_text` → 截图确认。
|
||||
- **巡检截图**:`de_screenshot` + `de_foreground_app` + `de_ocr`,只读汇报。
|
||||
1. **先看设备**:`de_list_devices` 确定目标设备(在线才可操作)。
|
||||
2. **先看屏**:任何决策前 `de_screenshot` 理解当前界面(图会给你)。
|
||||
3. **点击优先级**(不要自己推算像素坐标——精度最差):
|
||||
a) 有可见文字(按钮/菜单/标题/标签/输入框提示)→ **`de_tap_text`** 一步"找到并点"(原生与 WebView/图片文字都支持);
|
||||
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. **写操作前先确认**:不确定后果的操作(发送评论/私信、删除、支付类)**不要执行**,先截图回报请人工确认。
|
||||
4. **不做破坏性动作**:卸载应用、清除数据、改系统设置等一律不做。
|
||||
5. **留痕**:你的每次调用都进审计,请让动作与目标一致。
|
||||
6. **隐私**:不要把设备上读到的个人信息/凭据复述或外传。
|
||||
| # | 检查 | 怎么做 | 通过条件 | 不通过怎么办 |
|
||||
|---|---|---|---|---|
|
||||
| 1 | 设备在线且空闲 | `de_list_devices` | `online=true` 且 `worker_status` 非 running/connecting | 换设备;全忙则上报 |
|
||||
| 2 | **屏幕是否点亮/解锁** | `de_screenshot` 看 `screen_state` 与画面 | 亮屏且非锁屏界面 | **`de_wake`**(亮屏解锁)→ 重新 `de_screenshot` |
|
||||
| 3 | 是否能看清画面 | 截图 | 非纯黑/非"正在加载"白屏 | 黑屏→`de_wake`;白屏→等 2~3s 重截 |
|
||||
| 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`)。
|
||||
- MCP **无独立鉴权**(靠网络隔离),不要假设有 token。
|
||||
- `MCP_ALLOWED_SERIALS` 为空时**不会**自动限制为平台设备池(设计语义未实现)。
|
||||
- 平台 REST 的 CSRF 校验在当前版本可能未强制(按端点契约使用即可)。
|
||||
### 7.1 屏幕与锁屏
|
||||
- 息屏/黑屏 → `de_wake` → 重截确认;长任务中途可能再次息屏,**每轮循环先确认一次**。
|
||||
- 锁屏界面(有锁/时间/上滑提示)→ `de_wake` 解锁后重截;仍锁 → 上报(可能需要人工)。
|
||||
- 需要保持常亮时:没有专门工具,可在长流程中周期性 `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`
|
||||
- MCP 设计/分层与现状对照:`doc/MCP_DESIGN.md`
|
||||
- 平台接口目录:`doc/API.md`
|
||||
- AI 生成任务(规划,未实现):`doc/AI_TASK_GEN.md`
|
||||
### 8.0 总体:五个阶段 + 一个循环
|
||||
```
|
||||
P0 接收与澄清 → P1 选设备与预检(§6) → P2 到达起点 → P3 主流程(观察-行动-验证循环) → P4 收尾与还原 → P5 汇报
|
||||
└──────────── 循环体 ────────────┘
|
||||
```
|
||||
|
||||
### 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
@@ -252,15 +252,36 @@ function renderConvList(){
|
||||
}
|
||||
list.innerHTML = convs.map(c=>{
|
||||
const t = c.title || '新会话';
|
||||
const sid = String(c.id || '');
|
||||
return '<div class="agent-sess-item'+(c.id===cur?' active':'')
|
||||
+'" onclick="selectAgentSession(\''+c.id+'\')">'
|
||||
+'<button class="agent-sess-del" title="删除会话" '
|
||||
+'onclick="event.stopPropagation();deleteAgentSession(\''+c.id+'\')">✕</button>'
|
||||
+'<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('');
|
||||
});
|
||||
}
|
||||
// 复制会话 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(){
|
||||
const chat = document.getElementById('agent-chat');
|
||||
if(!chat) return;
|
||||
@@ -512,6 +533,105 @@ function endRun(){
|
||||
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(){
|
||||
document.getElementById('agent-exp-overlay').style.display = 'flex';
|
||||
|
||||
+43
-1
@@ -845,7 +845,10 @@ var _stepEditor={
|
||||
'</div>';
|
||||
document.getElementById('el-picker-footer').innerHTML=
|
||||
'<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 内部同时刷新两者)
|
||||
self._refreshShot();
|
||||
},
|
||||
@@ -999,6 +1002,14 @@ var _stepEditor={
|
||||
var indent=' '.repeat(Math.min(el.depth||0,6));
|
||||
var sg=el.suggested||{};
|
||||
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+')">'+
|
||||
'<span class="ep-depth">'+(el.depth||0)+'</span>'+
|
||||
'<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>'+
|
||||
(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>':'')+
|
||||
acts+
|
||||
'</div>';
|
||||
}).join('');
|
||||
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){
|
||||
if(!kw){this._renderEl(this._elData);this._renderOverlays();return;}
|
||||
kw=kw.toLowerCase();
|
||||
|
||||
@@ -30,6 +30,7 @@ worker 按 steps 顺序执行,支持 loop 步骤循环、停止信号、进度
|
||||
group - 动作组(含 children 步骤列表,按序执行一次,可折叠复用)
|
||||
"""
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
|
||||
from tasks.base import BaseTask, register_task
|
||||
@@ -39,6 +40,24 @@ from core.logger import get_logger
|
||||
|
||||
_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 = [
|
||||
@@ -165,6 +184,12 @@ class GenericStepsWorker(BaseWorker):
|
||||
stype = step.get("type", "")
|
||||
label = step.get("label", stype)
|
||||
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 按百分比概率决定本次是否执行
|
||||
prob = float(params.get("probability", 100))
|
||||
if prob < 100 and random.random() * 100 > prob:
|
||||
|
||||
@@ -480,6 +480,7 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
|
||||
<div class="agent-top-actions">
|
||||
<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="openActLib()" title="查看/管理可复用动作(命名动作 + 元素定位步骤,AI 自动沉淀,可编辑删除)">🎬 动作库</button>
|
||||
<button class="btn btn-xs" onclick="openAgentConfig()">⚙ 配置</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -563,6 +564,24 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
|
||||
</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: 日志 ========== -->
|
||||
<div id="tab-logs" class="tab-panel">
|
||||
<div class="page-title">日志查看</div>
|
||||
|
||||
+594
-27
@@ -186,23 +186,24 @@ def _qualify_experience(prompt, recipe):
|
||||
def _find_experiences(prompt, limit=2, threshold=0.10):
|
||||
"""按 bigram 重叠检索相似历史经验(prompt 与任务描述的字符相似度)。
|
||||
|
||||
命中的经验 hits+1(回写),让被反复参考的有效经验浮到前面。
|
||||
返回 (注入文本, 命中的配方列表);命中的经验 hits+1(回写),让被反复
|
||||
参考的有效经验浮到前面。配方列表供调用方展示「命中 N 条」与摘要。
|
||||
"""
|
||||
try:
|
||||
if _flask_app is None:
|
||||
return ""
|
||||
return "", []
|
||||
with _flask_app.app_context():
|
||||
_ensure_exp_table()
|
||||
rows = db.session.execute(db.text(
|
||||
"SELECT id, task_prompt, recipe, hits FROM agent_experience "
|
||||
"WHERE recipe != '' ORDER BY hits DESC, id DESC LIMIT 50")).fetchall()
|
||||
except Exception:
|
||||
return ""
|
||||
return "", []
|
||||
if not rows:
|
||||
return ""
|
||||
return "", []
|
||||
cur = _bigrams(prompt)
|
||||
if not cur:
|
||||
return ""
|
||||
return "", []
|
||||
scored = []
|
||||
for row_id, task_prompt, recipe, hits in rows:
|
||||
sim = len(cur & _bigrams(task_prompt)) / len(cur)
|
||||
@@ -220,10 +221,78 @@ def _find_experiences(prompt, limit=2, threshold=0.10):
|
||||
db.session.commit()
|
||||
except Exception:
|
||||
pass
|
||||
parts = []
|
||||
for sim, _hits, recipe, _row_id in scored[:limit]:
|
||||
parts.append(f"- {recipe[:600]}")
|
||||
return "\n".join(parts)
|
||||
picked = [recipe for _sim, _hits, recipe, _row_id in scored[:limit]]
|
||||
parts = [f"- {r[:600]}" for r in 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):
|
||||
"""清洗蒸馏输出:去掉模型可能加的「操作配方:」之类前缀标签与包裹引号。
|
||||
|
||||
旧逻辑用 `"配方" 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):
|
||||
@@ -232,22 +301,34 @@ def _distill_experience(cfg, prompt, tool_seq):
|
||||
import httpx
|
||||
body = {
|
||||
"model": cfg.get("model") or "deepseek-v4-flash-vision-exp",
|
||||
# 关推理:蒸馏是"给定轨迹写配方"的确定性任务,开启 thinking 会占满
|
||||
# 预算导致 content 空/被截断(实测);该代理支持 thinking.type=disabled
|
||||
"thinking": {"type": "disabled"},
|
||||
"messages": [{"role": "user",
|
||||
"content": "以下是一次成功的手机自动化操作记录。请提炼成简洁的"
|
||||
"「操作配方」(2-6 步,每步:目标 → 用哪个工具),"
|
||||
"供下次同类任务参考。不要解释,直接输出配方。\n"
|
||||
"供下次同类任务参考。不要解释,直接输出配方正文。\n"
|
||||
f"任务:{prompt[:300]}\n操作序列:{tool_seq[:800]}"}],
|
||||
"max_tokens": 600,
|
||||
"max_tokens": 800,
|
||||
}
|
||||
headers = {"Authorization": f"Bearer {cfg.get('api_key', '')}",
|
||||
"Content-Type": "application/json"}
|
||||
r = httpx.post(f"{(cfg.get('api_base') or 'https://api.deepseek.com').rstrip('/')}/chat/completions",
|
||||
json=body, headers=headers, timeout=25)
|
||||
if r.status_code != 200:
|
||||
return ""
|
||||
j = r.json()
|
||||
recipe = ((j.get("choices") or [{}])[0].get("message") or {}).get("content") or ""
|
||||
return recipe.strip()[:1500]
|
||||
url = f"{(cfg.get('api_base') or 'https://api.deepseek.com').rstrip('/')}/chat/completions"
|
||||
# content 为空(推理吃满预算)时重试一次——实测同样提示第二次常能出正文
|
||||
for _attempt in (1, 2):
|
||||
r = httpx.post(url, json=body, headers=headers, timeout=25)
|
||||
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 ""
|
||||
except Exception as e:
|
||||
_log.warning(f"经验提炼失败: {e}")
|
||||
return ""
|
||||
@@ -274,6 +355,371 @@ def _save_experience(prompt, recipe, tool_seq):
|
||||
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 质检,删除需人工确认) ==================
|
||||
_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)
|
||||
if r.status_code != 200:
|
||||
raise RuntimeError(f"HTTP {r.status_code}")
|
||||
text = (((r.json() or {}).get("choices") or [{}])[0]
|
||||
.get("message") or {}).get("content") or ""
|
||||
text = _msg_text(((r.json() or {}).get("choices") or [{}])[0]
|
||||
.get("message") or {})
|
||||
m = re.search(r"\{.*\}", text, re.S)
|
||||
if not m:
|
||||
raise RuntimeError("响应无 JSON")
|
||||
@@ -460,6 +906,94 @@ def agent_experience_keep():
|
||||
db.session.commit()
|
||||
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 式:多会话持久化) ==================
|
||||
# 会话 = 消息序列(JSON),一轮 run 的 user/assistant 文本完成后追加落库。
|
||||
# 单表 JSON 存储(每会话几十 KB 内,无需独立消息表)。
|
||||
@@ -814,7 +1348,8 @@ def _agent_thread(run_id, prompt, serial, cfg):
|
||||
def on_delta(text, kind):
|
||||
q.put(("delta", {"text": text, "kind": kind}))
|
||||
|
||||
tool_seq = [] # 本轮工具序列(经验提炼用)
|
||||
tool_seq = [] # 本轮工具序列(任务级配方提炼用)
|
||||
tool_trace = [] # 结构化轨迹:{tool, args, result}(动作经验提炼用,判成败)
|
||||
|
||||
def on_tool(step):
|
||||
# args 保留对象(json.dumps 序列化)——前端要解析 serial 做画面跟随;
|
||||
@@ -824,11 +1359,16 @@ def _agent_thread(run_id, prompt, serial, cfg):
|
||||
if step.get("image"):
|
||||
rec["image"] = _shrink_image(step["image"])
|
||||
q.put(("step", rec))
|
||||
# 记录精简工具序列
|
||||
# 记录精简工具序列 + 结构化轨迹(去 serial、结果截断)
|
||||
try:
|
||||
args = step.get("args") or {}
|
||||
brief = {k: v for k, v in args.items() if k != "serial"}
|
||||
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:
|
||||
pass
|
||||
|
||||
@@ -862,12 +1402,26 @@ def _agent_thread(run_id, prompt, serial, cfg):
|
||||
stop_evt = _stop_events.get(run_id)
|
||||
|
||||
# 经验检索:相似历史任务的操作配方注入 system(自进化记忆)
|
||||
exp_ctx = _find_experiences(prompt)
|
||||
exp_ctx, exp_items = _find_experiences(prompt)
|
||||
if exp_ctx:
|
||||
_log.info("命中历史经验,注入参考配方")
|
||||
brief = ";".join("".join(x.split())[:16] for x in exp_items[:2])
|
||||
q.put(("step", {"tool": "🧠 经验记忆",
|
||||
"args": f"命中 {exp_ctx.count(chr(10) + '- ')} 条同类历史经验,已注入参考",
|
||||
"args": f"命中 {len(exp_items)} 条同类历史经验,已注入参考"
|
||||
+ (f":{brief}" if brief else ""),
|
||||
"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", "")
|
||||
|
||||
@@ -897,7 +1451,7 @@ def _agent_thread(run_id, prompt, serial, cfg):
|
||||
on_delta=on_delta, on_tool=on_tool,
|
||||
should_stop=lambda: bool(
|
||||
stop_evt and stop_evt.is_set()),
|
||||
extra_context=exp_ctx)
|
||||
extra_context=recall_ctx)
|
||||
except Exception as e:
|
||||
if _mcp_unreachable(e):
|
||||
raise RuntimeError(
|
||||
@@ -935,8 +1489,8 @@ def _agent_thread(run_id, prompt, serial, cfg):
|
||||
# 提炼/保存失败静默(不阻塞、不影响结果),只在成功时推送 🧠 卡片。
|
||||
if tool_seq:
|
||||
try:
|
||||
recipe = _distill_experience(cfg, prompt, " -> ".join(tool_seq))
|
||||
if recipe and "配方" not in recipe[:50]:
|
||||
recipe = _clean_recipe(_distill_experience(cfg, prompt, " -> ".join(tool_seq)))
|
||||
if recipe:
|
||||
if _qualify_experience(prompt, recipe):
|
||||
if _save_experience(prompt, recipe, " -> ".join(tool_seq)):
|
||||
_log.info("经验已写入记忆库,随事件流提示")
|
||||
@@ -944,6 +1498,19 @@ def _agent_thread(run_id, prompt, serial, cfg):
|
||||
"args": "本轮操作已提炼为经验并写入记忆库"
|
||||
"(下次相似任务会自动参考)",
|
||||
"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:
|
||||
_log.warning(f"经验保存异常: {e}")
|
||||
q.put(("done", {"answer": answer}))
|
||||
|
||||
Reference in New Issue
Block a user