Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c2b440dce | ||
|
|
972db13f81 | ||
|
|
3f44cd1491 | ||
|
|
8072f4a380 | ||
|
|
91b2785c6b | ||
|
|
104964aa53 |
+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 为缩略截图)。另有两类伪卡片:`tool="🧠 经验记忆"` 表示命中历史经验(args 形如「命中 N 条同类历史经验,已注入参考:<配方摘要>」,N 为实际条数)或本轮已写入经验库
|
||||
- `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`;如需收紧需补实现。
|
||||
+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>
|
||||
|
||||
+564
-14
@@ -226,6 +226,60 @@ def _find_experiences(prompt, limit=2, threshold=0.10):
|
||||
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):
|
||||
"""清洗蒸馏输出:去掉模型可能加的「操作配方:」之类前缀标签与包裹引号。
|
||||
|
||||
@@ -247,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 ""
|
||||
@@ -289,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": ""}
|
||||
|
||||
@@ -327,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")
|
||||
@@ -475,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 内,无需独立消息表)。
|
||||
@@ -829,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 做画面跟随;
|
||||
@@ -839,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
|
||||
|
||||
@@ -885,6 +1410,18 @@ def _agent_thread(run_id, prompt, serial, cfg):
|
||||
"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", "")
|
||||
|
||||
@@ -914,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(
|
||||
@@ -961,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