用户场景(他原话):一台手机登录 5 个抖音号、一共 5 台手机,每个任务只让其中一个
目标号评论;每天跑一次但不知道什么时候跑完,于是"一直重复跑" → 结果
"一个手机还没评论到,一个手机都评论两次了"。
**根因不是"单设备重复",是跨设备没有共享的判断 + 进度不可见。** 所以做两件事:
① 幂等;② 把"谁做过了、还差谁"摆到台面上(不然只能靠重跑确认,而重跑又在制造重复)。
- `core/models.py`:新表 `done_mark`(迁移账本补 v7)。**判据只有 `scope_key` 的
唯一索引**——多台设备会同时判断"没做过","先查后插"有竞态(两台都插),
唯一索引 + `INSERT ... ON DUPLICATE KEY`/`INSERT OR IGNORE` 的**受影响行数**才原子。
- `core/dedup.py`(新):`build_key`(`任务|身份|时间桶`)/ `check` / `mark` /
`list_marks`(带"今天做了几台/几个号"统计)/ `delete_mark` / `clear_job` / `purge_old`。
自建 app context(照 device_pool 的 `_ctx()`),任务线程/Web/清理都不用关心。
- 任务侧两个部件(**检查在前、记账在后**):
· `if_el` 新增条件类型 `selector_type="dedup"`:命中=这个身份做过了 → 走 then 分支。
身份元素在 `ident_type`/`ident_value`(留空 = 用设备 serial,一号一机场景)。
· 新步骤 `mark_done`「记为已做」(22 种步骤):放动作**成功之后**。
拆两步的用意:动作失败就不记账,下次重跑还会重试该设备 —— 失败不丢。
- 有效期(`dedup_reset` = day/all/hours)放**任务级**:检查与记账两处各填一份的话,
填不一致就算出两个 key、去重会**静默失效**,所以强制只配一处(编辑器顶部下拉)。
- 三条防误伤规则(都有测试兜着):
· 身份读不到 / 身份值过长 → **不去重、当没做过照常执行**。绝不能把"读不到"
当成空身份——那会让所有设备共用一个 key、第一台记账后其余全被误判成"做过"。
· `kind='all'`(只做一次)的记录**永不清理**(清了等于语义失效);清理只删 day/hours。
· 去重的两个易错点在保存时直接告警:身份元素两边不一致、有检查没记账/有记账没检查。
- 「任务 → 去重记录」新子分栏(`static/admin/dedup.js`):统计行 + 明细表 +
删单条(那个号重跑)/ 清空任务(整批重跑)。接口 3 个(GET/delete/clear,PERM_TASKS)。
- 每日 04:23 清理(挂现有 APScheduler),`TABLE_LABELS` 补中文名(备份覆盖自动派生)。
- AI 建任务草稿校验同步:`dedup` 走自己的规则(要 ident_value、xpath 前缀校验),
没填身份元素只警告不拦(用设备当身份是合法用法);普通条件空选择器仍然拦。
- 文档:TASK_DEV §4.6(去重专章 + App 内检测的兜底配方与它的三个局限)、
DATA_MODEL §2.9、API 三个接口、ARCHITECTURE(分层/装配/子分栏/JS 分工/清理)、
DEPLOY §5.2(15 张表)、步骤数 21→22 全库同步。
自测:单元 + 集成 33 项(**含 8 线程抢同一个身份、恰好一个成功**的原子性断言,
以及"all 记录不被清理""身份读不到不去重""清了能重跑")、
**真机端到端**(cs1 上"检查→动作→记账"跑两遍:第二遍被拦、换 serial 的"另一台设备"
同样被拦、删记录后能重跑)、草稿校验 5 项、GET 冒烟 56 路由 0 个 500。
(注:本分支基于 feat/if-el-multi-value,因为它俩都要改 task.py 的 STEP_TYPES 与
editor.js 的 STEP_LIB 同一区域,分开从 dev 拉必然冲突——这份是超集,合一次两份都进。)
13 KiB
AI 控制台(AI_CONSOLE)
适用读者:使用 AI 控制台的人 + 改这部分代码的开发者。 相关文档:API.md §12(接口)、MCP.md(AI 用的工具层)、AI_TASK_GEN.md("一句话建任务"的设计稿,尚未实现)。
1. 它是什么
「AI 控制台」是后台的一个顶级 Tab(仅管理员),下面有两个子分栏:
| 子分栏 | 做什么 | 文档 |
|---|---|---|
| 💬 聊天 | 选一台设备用自然语言下指令,AI 通过 MCP 工具看屏幕、点按、输入,边做边把过程和结论流式显示出来(本文内容) | 本文 |
| 🧭 AI 建任务 | 描述"要什么样的自动化",AI 自己在真机上探索(看屏/读元素树/点按验证),把走通的路径写成一条可调度任务,校验后交人工在步骤编辑器确认 | AI_TASK_GEN.md |
两个子分栏共用同一个运行槽(全平台同时只允许一个 Agent 运行):建任务在探索时,聊天页会显示"● 建任务探索中"并禁用发送,反之亦然。
你:「打开小红书搜索苏州好吃的饭店,把前 5 条列出来」
AI:de_list_devices → de_screenshot → de_tap_text("搜索") → de_type_text("苏州好吃的饭店")
→ de_screenshot → de_ui_tree → … → 汇总结论(Markdown 表格)
两个"自进化记忆"让它越用越顺:
| 记忆 | 存什么 | 怎么产生 | 怎么用 |
|---|---|---|---|
| 🧠 经验库 | 任务级操作配方(这一步该怎么做) | 一轮任务成功后由模型蒸馏 | 相似任务开始时召回注入 system prompt |
| 🎬 动作库 | 命名动作(可复用的动作单元,带元素定位) | 从成功步骤蒸馏,禁坐标 | 相似任务开始时召回注入,可直接复用定位 |
2. 使用
2.1 配置(首次必做)
右上角 ⚙ 配置:
| 项 | 说明 |
|---|---|
| API Base | OpenAI 兼容地址(默认 https://api.deepseek.com) |
| 模型名 | 如 deepseek-v4-flash-vision-exp(需支持视觉,因为要看截图) |
| API Key | 只存数据库 app_meta,回显打码 |
| 默认设备 | 不选目标设备时用它 |
| 最大步数 | 1-200,默认 40(每轮模型调用算一步) |
配置存在数据库(
app_meta的agent_*键),不在.env。CLI(mcp_agent/cli.py)走的是环境变量AGENT_*,两套互不影响。
2.2 跑一轮
- 选 🎯 目标设备(AI 只操作你选定的设备;有任务在跑的设备不可选)
- 输入指令,Enter 发送
- 右侧「📺 实时画面」自动跟随 AI 操作的设备(MJPEG)
- 中途可「■ 停止」(下一个检查点生效,通常几秒内)
- 刷新/换窗口:会话与运行状态都会自动恢复(见 §3.3)
2.3 会话管理
左侧会话列表 = 多轮对话(DeepSeek 风格):不同话题建不同会话,历史消息会作为上下文续上(最近 12 轮)。会话条目上显示 ID(前 8 位,点击复制),便于反馈问题时引用 conv=<id>。
3. 机制
3.1 执行链路
POST /api/agent/run web/agent_api.py 起后台线程(单实例:同时只允许一个)
│
├─ 经验召回 _find_experiences(prompt) ─┐
└─ 动作召回 _find_actions(prompt) ├─ 拼成 extra_context 注入 system prompt
┘
▼
mcp_agent.Agent.run_stream(prompt, serial, history, on_delta, on_tool, on_usage, …)
│ 循环(最多 max_steps 轮):
│ ① 流式调模型(OpenAI 兼容 /chat/completions)
│ ② 有 tool_calls → 执行 MCP 工具 → 结果回灌 → 继续
│ 截图工具的结果会转成 image_url 追加,模型"看得见"
│ ③ 无 tool_calls → 本轮即最终回答
▼
事件 → queue → SSE /api/agent/stream → 前端
- 一轮整体超时 900 秒;达到步数上限会让模型做一次收尾总结
- 工具调用由 MCP Server 执行(
:8033),后者再调平台 HTTP 接口/直连设备 - 设备忙时拒绝:目标设备正在跑任务 →
409("AI 不与任务抢设备") mode:"designer"(AI 建任务)走同一条链路,差别:换一套 system prompt(任务设计师)、 输出上限提到 8192、不吃聊天历史、多一个平台级本地工具submit_task(不经 MCP,服务端用core/task_draft校验,见 AI_TASK_GEN.md)- 工具结果必须是连续的 tool 消息:一轮里若同时调了截图与别的工具,图像会攒到本轮工具 消息发完后再作为一条 user 消息附上(否则模型侧会以"工具回应不足"报 400)
3.2 会话消息模型
agent_conversation.messages(JSON 数组):
[{"role": "user", "content": "…"},
{"role": "assistant", "content": "最终回答(Markdown)",
"usage": {"prompt_tokens": 3480, "completion_tokens": 126, "total_tokens": 3606, "calls": 1},
"reasoning": "模型的推理链(最多保留 6000 字符)"}]
usage/reasoning只用于前端展示与回看;回灌给模型的历史只取role/content(不污染上下文预算)。
3.3 SSE 事件与刷新恢复
| event | payload | 前端行为 |
|---|---|---|
delta |
{"text","kind":"content"|"reasoning"} |
正文增量渲染 Markdown;推理增量进入可折叠「💭 思考过程」 |
step |
{"tool","args","image"?} |
追加工具卡片(含缩略截图,点击放大);伪卡片提示经验/动作命中与沉淀 |
usage |
{"prompt_tokens","completion_tokens","total_tokens","calls"} |
刷新单条消息脚注与顶栏「本会话累计」 |
done |
{"answer","usage","mode","draft"?,"warnings"?,"draft_error"?} |
最终答案 + 收尾;mode=designer 时带任务草稿(或草稿被拦的原因) |
error |
{"message"} |
展示错误(MCP 不可达等已转成明确文案) |
刷新/重连不丢进度:服务端事件队列保留积压,页面重新订阅(GET /api/agent/stream?run_id=)后会补发 delta/step/usage/done;EventSource.onerror 刻意不结束运行,靠自动重连续上。另外 GET /api/agent/run 提供状态快照(其他窗口/8s 轮询用)。
多页面同时看同一轮:一轮的事件用扇出(web/agent_api.py 的 _Fanout)发给每个订阅者各自
的队列——两个页面都收到全量事件。不要退回"一个 run 一个 queue.Queue":那样第二个页面一订阅,
两个 EventSource 就开始瓜分同一条队列,谁先取到算谁的(2026-09-14 实测:聊天页把建任务页的 done
取走了,建任务页永远等不到草稿)。
3.4 token 统计
- 请求带
stream_options.include_usage,服务端在末尾 chunk 返回 usage - 按「每次模型调用」累加(多轮工具调用会累加多次);
calls= 模型调用次数 - 个别网关不认该参数会直接 400/422 → 自动关掉并重试一次,不影响主流程(只是没有 token 数字)
- 展示位置:单条消息脚注(
🪙 3,606 tokens(↑3,480 ↓126 · 1 次调用))+ 顶栏「🪙 本会话 3,606 tokens」
3.5 回答渲染
- Markdown:自研轻量渲染器(
static/admin/markdown.js,无 CDN 依赖,生产在内网) 支持标题/段落/列表(含嵌套)/表格/代码块/引用/链接;先esc()转义再套标记,所以模型输出里的 HTML 只会显示为文本 - 推理链:
<details>折叠块,流式时展开、正文开始时自动收起;手动点过后不再自动改;摘要显示字数 - 工具卡片:每步 MCP 调用一行(工具名 + 参数 + 截图缩略)
4. 经验库
4.1 产生(蒸馏)
一轮任务成功执行过工具后,把「任务描述 + 工具序列」交给模型,提炼成一段可复用的操作配方(纯文本,不含具体坐标)。
健壮性设计(都是踩坑后加的):
| 措施 | 原因 |
|---|---|
蒸馏调用关闭推理(thinking: {"type":"disabled"}) |
推理模型会把 token 预算烧在 reasoning 上,导致 content 为空/被截断 → 经验被静默丢弃 |
| 纯文本问法 + 质量门槛 | 早期提示词里放了可照抄的占位示例,模型会把 "1. …\n2. …" 原样当配方存下来 |
| 截断容忍解析 | JSON 被截断时逐个对象抢救 |
| 失败有日志 | 不再静默 |
4.2 召回
相似任务开始时按 bigram 相似度检索(阈值 + 取前 2 条),拼进 system prompt,并在对话里推一张「🧠 经验记忆」卡片("命中 N 条同类历史经验,已注入参考")。命中会累加 hits。
4.3 巡检(质量治理)
- 每日 03:47(独立 APScheduler)自动跑一轮:把经验交给模型评审,输出「保留 / 建议删除」+ 评分 + 理由,写入
experience_audit - 也可在面板里手动触发
- 删除永远需要人工确认(巡检只打标
pending,面板上显示 ⚠ 建议删除 + 理由,人工点「确认删除」或「保留」) - 「保留」会记
action='kept',后续不再重复建议
4.4 面板
「🧠 经验库」按钮打开:列表(任务描述、配方摘要、引用次数、巡检建议)+ 删除/保留操作。
5. 动作库
5.1 与经验库的区别
| 经验库 | 动作库 | |
|---|---|---|
| 粒度 | 整个任务的操作套路(文字) | 单个可复用动作(结构化步骤) |
| 内容 | 自由文本配方 | 命名动作 + steps(编辑器 schema,带元素定位) |
| 用途 | 让 AI 知道"这类任务一般怎么做" | 让 AI 直接复用"打开抖音"这种动作,跳过重新探索 |
5.2 产生(蒸馏)
从本轮成功的步骤轨迹里提炼命名动作(如「打开抖音」)。硬约束:
- 禁坐标:带
click_xy的步骤不会被沉淀(坐标换个设备/分辨率就失效) - 白名单步骤类型(显式列举 16 种,见
web/agent_api.py的_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——坐标类(click_xy)与流程标记类 (keep_screen/gesture/notify/stop_self/mark_done)都不沉淀 - 每类型有必填参数校验(如
click必须有选择器) - 输入/产出限量:最多 10 步输入、最多 3 个动作 × 4 步
- 保存时服务端再校验一次,含坐标的提交直接 400
5.3 召回与使用
相似任务时按动作名/别名匹配(子串或 bigram 相似度),注入「## 可复用动作」段,并推「🧠 动作经验」卡片。AI 被提示"优先按其中的元素定位操作;若与当前界面不符,再自行截图确认"。
5.4 面板
「🎬 动作库」:查看/编辑/删除/手动新建。编辑时直接改 steps JSON(有格式说明),保存走同一套校验。
6. 配置项(app_meta)
| key | 含义 | 默认 |
|---|---|---|
agent_api_base |
模型接口地址 | https://api.deepseek.com |
agent_model |
模型名 | — |
agent_api_key |
API Key(明文存库) | — |
agent_default_serial |
默认目标设备 | 空 |
agent_max_steps |
最大步数(钳制 1-200) | 40 |
agent_task_draft |
AI 建任务最近一份草稿(JSON:{draft, warnings, prompt, created},只留最近一份;不是任务,入库仍走人工确认) |
空 |
其它常量:单轮超时 900s、推理链落库上限 6000 字符、会话消息上限 60 条、历史上下文取最近 12 轮;
建任务模式(designer)输出上限 8192、单工具调用上限 40 次(mcp_agent/agent.py)。
7. 故障排查
| 现象 | 原因 / 处理 |
|---|---|
| 报「MCP server(8033) 不可达」 | MCP 没启动:容器由 start.sh 拉起;本机手动 MCP_ALLOW_WRITE=1 python -m mcp_server.mcp_server |
| 报模型 API 401 | API Key 无效/过期 → ⚙ 配置重填 |
| 报"请先选择目标设备" | 没选设备且没配默认设备 |
| 报"设备正在执行任务…"(409) | AI 不与任务抢设备:等任务结束,或去监控页停止该任务 |
| 没有 token 数字 | 该网关不支持 stream_options.include_usage(已自动降级,功能不受影响) |
| 经验/动作没沉淀 | 只有成功执行过工具才会蒸馏;看 logs/web.log 的「经验提炼 / 动作提炼」日志 |
| 推理链看不到 | 模型未返回 reasoning_content(非推理模型),或本轮没有推理输出 |
| 界面样式/脚本异常 | 强刷浏览器(Ctrl+Shift+R)——markdown.js 等前端文件有缓存 |