6 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
12 changed files with 890 additions and 29 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 为缩略截图)。另有两类伪卡片:`tool="🧠 经验记忆"` 表示命中历史经验(args 形如「命中 N 条同类历史经验,已注入参考:<配方摘要>」,N 为实际条数)或本轮已写入经验库 - `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`;如需收紧需补实现。
+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>
+562 -12
View File
@@ -226,6 +226,60 @@ def _find_experiences(prompt, limit=2, threshold=0.10):
return "\n".join(parts), picked return "\n".join(parts), picked
def _msg_text(message, allow_reasoning=False):
"""取模型回复正文。
推理模型(DeepSeek 等)**偶发**把 token 预算全花在 reasoning 上、content 为空
(finish_reason=length),直接读 content 会当空处理 → 经验/动作被静默丢弃
(2026-09-10 实测)。默认**不回退 reasoning**(那是思考草稿,做"配方"会污染);
仅在能结构化解析的场景(动作 JSON 提取)才允许回退。
"""
m = message or {}
text = (m.get("content") or "").strip()
if text:
return text
if allow_reasoning:
return (m.get("reasoning_content") or "").strip()
return ""
_RECIPE_JSON_RE = re.compile(r'"recipe"\s*:\s*"((?:[^"\\]|\\.)*)"', re.S)
def _recipe_ok(recipe):
"""配方质量门槛:太短、或只有序号+省略号(模型照抄占位)视为无效。
2026-09-10 实测:提示词里写了示例 JSON,模型会把占位符 `1. …\\n2. …`
原样当成配方存下来 → 这里拦掉,宁可重试/不存,也不污染经验库。
"""
t = (recipe or "").strip()
if len(t) < 15:
return False
if "…" in t and len(t) < 30:
return False
return True
def _extract_recipe(text):
"""从(可能被截断的)模型输出里取配方正文。
蒸馏提示词要求输出 {"recipe": "…"};推理模型会把预算烧在 reasoning 上并
截断(finish_reason=length),严格 json.loads 会失败,故用正则直接抠字段。
"""
if not text:
return ""
m = _RECIPE_JSON_RE.search(text)
if m:
try:
return json.loads('"' + m.group(1) + '"').strip()
except Exception:
return m.group(1).replace("\\n", "\n").strip()
t = text.strip()
if t.startswith("{") or t.startswith("["):
return "" # JSON 外壳但没抠到 recipe:视为无效,避免把草稿当配方
return t
def _clean_recipe(recipe): def _clean_recipe(recipe):
"""清洗蒸馏输出:去掉模型可能加的「操作配方:」之类前缀标签与包裹引号。 """清洗蒸馏输出:去掉模型可能加的「操作配方:」之类前缀标签与包裹引号。
@@ -247,22 +301,34 @@ def _distill_experience(cfg, prompt, tool_seq):
import httpx import httpx
body = { body = {
"model": cfg.get("model") or "deepseek-v4-flash-vision-exp", "model": cfg.get("model") or "deepseek-v4-flash-vision-exp",
# 关推理:蒸馏是"给定轨迹写配方"的确定性任务,开启 thinking 会占满
# 预算导致 content 空/被截断(实测);该代理支持 thinking.type=disabled
"thinking": {"type": "disabled"},
"messages": [{"role": "user", "messages": [{"role": "user",
"content": "以下是一次成功的手机自动化操作记录。请提炼成简洁的" "content": "以下是一次成功的手机自动化操作记录。请提炼成简洁的"
"「操作配方」(2-6 步,每步:目标 → 用哪个工具)," "「操作配方」(2-6 步,每步:目标 → 用哪个工具),"
"供下次同类任务参考。不要解释,直接输出配方。\n" "供下次同类任务参考。不要解释,直接输出配方正文。\n"
f"任务:{prompt[:300]}\n操作序列:{tool_seq[:800]}"}], f"任务:{prompt[:300]}\n操作序列:{tool_seq[:800]}"}],
"max_tokens": 600, "max_tokens": 800,
} }
headers = {"Authorization": f"Bearer {cfg.get('api_key', '')}", headers = {"Authorization": f"Bearer {cfg.get('api_key', '')}",
"Content-Type": "application/json"} "Content-Type": "application/json"}
r = httpx.post(f"{(cfg.get('api_base') or 'https://api.deepseek.com').rstrip('/')}/chat/completions", url = f"{(cfg.get('api_base') or 'https://api.deepseek.com').rstrip('/')}/chat/completions"
json=body, headers=headers, timeout=25) # content 为空(推理吃满预算)时重试一次——实测同样提示第二次常能出正文
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 ""
@@ -289,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": ""}
@@ -327,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")
@@ -475,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 内,无需独立消息表)。
@@ -829,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 做画面跟随;
@@ -839,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
@@ -885,6 +1410,18 @@ def _agent_thread(run_id, prompt, serial, cfg):
"args": f"命中 {len(exp_items)} 条同类历史经验,已注入参考" "args": f"命中 {len(exp_items)} 条同类历史经验,已注入参考"
+ (f":{brief}" if brief else ""), + (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", "")
@@ -914,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(
@@ -961,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}))