Files
auto_control/doc/ARCHITECTURE.md
T
butubb 4b5b836d31 feat: AI 控制台回答支持 Markdown 渲染 + 推理链可折叠 + token 用量显示
- markdown.js(新增,无 CDN 依赖):轻量 Markdown 渲染(标题/列表含嵌套/表格/
  代码块/引用/链接…),先 esc() 转义再套标记,模型输出的 HTML 只当文本显示
- agent.js:回答改走 Markdown;推理链改为 <details> 可折叠(流式时展开、正文开始
  自动收起、手动点过后不再自动改);单条消息 token 脚注 + 顶栏「本会话累计」
- monitor.html:消息结构加 .reasoning/.agent-usage、顶栏 token 徽标、md 相关样式,
  引入 markdown.js(base.js 之后、agent.js 之前)
- mcp_agent/agent.py:请求带 stream_options.include_usage,按「每次模型调用」累计
  usage(末尾 chunk),on_usage 回调吐累计值;网关不认该参数(400/422/点名)时
  自动降级重试一次
- web/agent_api.py:SSE 新增 usage 事件、done 带 usage;推理链与用量随会话落库
  (_REASONING_KEEP=6000 截断),回灌模型时只取 role/content
- 文档:API.md(usage 事件/done/会话消息字段)、ARCHITECTURE §5.4.1、DEVELOPMENT
  前端 JS 清单

自测:假模型端点单测 3/3(正常/降级/多轮累加);Edge headless 全链路 27 项全通过
(真实 Flask+SSE+SQLite,含 XSS 转义、刷新后回看);Markdown 渲染器 18 用例全通过
2026-09-10 18:22:20 +08:00

431 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 架构详解
本文面向想深入理解 `auto_control` 内部设计的开发者。如果你只想使用,看 [README.md](../README.md) 即可。
---
## 1. 分层设计
平台按"配置 / 核心 / 任务 / 前端 / 数据 / 日志 / 工具"分层,职责清晰、互不交叉:
| 层 | 路径 | 职责 |
|----|------|------|
| 配置层 | `config.py` | 项目根配置:adb 路径、web 端口、USB 远程 adb server 等基础设施。**不放任务参数** |
| 核心层 | `core/` | 框架运行时:日志、设备池、adb 操作、Worker 基类、任务管理器、u2 辅助、Action 基类 |
| 任务层 | `tasks/` | 每个 App 一个子包,自包含 `task.py` + `actions/`,互不依赖 |
| 前端层 | `templates/admin/` | 单页应用(纯 HTML+CSS+JS,无框架) |
| 数据层 | `data/` | SQLite 持久化 |
| 日志层 | `logs/` | 四类日志,10MB 滚动保留 5 份 |
| 工具层 | `bin/adb/` | adb 可执行文件 |
| 脚本层 | `scripts/` | 实用脚本 |
### 分层原则
- **任务自包含**:每个任务的参数、Worker、操作都放在 `tasks/<app>/` 下,不污染全局
- **核心不依赖任务**:`core/` 不 import `tasks/`,任务通过注册机制接入
- **配置最小化**:`config.py` 只放基础设施配置,任务参数在各自 `task.py` 顶部
---
## 2. 数据流
```
┌──────────────┐ 创建/编辑任务 ┌─────────────┐ 分发 worker ┌──────────────┐
│ 单页应用前端 │ ───────────────► │ TaskManager │ ─────────────► │ Worker(设备) │
│ (monitor.html│ └─────────────┘ └──────────────┘
│ fetch+DOM) │ ▲ │
└──────────────┘ │ 状态/心跳 │ u2 操作
│ │ ▼
│ JSON API │ ┌─────────────────┐
▼ ┌──────────────┐ │ 设备 adb │
┌──────────────┐ │ 看门狗监控 │ │ (IP:5555 直连 │
│ web/ 蓝图包 │ └──────────────┘ │ / USB 远程) │
│ (Flask API) │ └─────────────────┘
└──────────────┘
│
▼
┌──────────────┐
│ data/users.db│ SQLite 持久化(用户/分组/任务/设备池/待连接设备/自定义动作/APK记录/AI会话/经验库 + app_meta KV)
└──────────────┘
```
### 任务执行流程
1. 前端创建 TaskJob(HTTP POST `/api/jobs`)
2. `TaskManager` 保存到 SQLite,如启用 cron 则注册到 APScheduler
3. 手动执行或 cron 触发时,`_run_job` 解析目标设备列表
4. 每台设备起一个线程 `_run_with_retry`,含重试循环
5. 线程内 `task.create_worker()` 创建 Worker,`worker.start()` 启动
6. `BaseWorker.run()` 执行设备生命周期:占用 → 连接 → setup → run_task → teardown → 释放
7. Worker 通过 `_update_status()` 实时上报状态到全局 `_WORKERS` 字典
8. 前端轮询 `/api/status`(5 秒缓存)获取设备 + Worker 状态
---
## 3. 核心模块详解
### 3.1 DevicePool(`core/device_pool.py`)
设备池(已摘除 OpenSTF):SQLite `devices` 表 = 设备清单,本机 adb = 在线状态。
**关键设计**:
- `list_configured()` — 清单(管理页维护,enabled=False 不参与调度)
- `list_online()` — 本机 adb 在线设备;池内有 USB 设备(serial 无冒号)时合并 220
远程 adb server(`USB_ADB_HOST:PORT`,host 网络模式 5037)状态
- `list_ready()` — 清单 ∩ 在线(任务调度用)
- CRUD — `add_device`(upsert)/ `remove_device` / `set_enabled`
- 单实例互斥由 `TaskManager._running[serial]` 内存锁保证(无跨实例占用概念)
- 全模块不 connect/kill-server/disconnect(遵守共享 adb transport 红线)
### 3.2 STFDevice + BaseWorker(`core/device_worker.py`)
**STFDevice** — 单设备生命周期管理:
- `acquire()`:IP:5555 直连 adb connect;USB(serial 无冒号)校验 220 远程 adb server 可见性
- `release()`:无操作(不 disconnect,红线)
- 互斥由 TaskManager `_running` 保证,设备池负责在线判断
**BaseWorker** — 通用 Worker 基类(继承 threading.Thread):
```
run() 主循环(不要重写):
1. acquire 设备
2. u2.connect(30s 超时保护)
3. setup(d) ← 子类可选钩子
4. run_task(d) ← 子类必须实现
5. teardown(d) ← 子类可选钩子
6. finally: release 设备
```
**超时保护**:
- `u2.connect()` 用 `ThreadPoolExecutor + 30s 超时`,防止 atx-agent 无响应永久 hang
- `d.info` 用 `ThreadPoolExecutor + 10s 超时`
**心跳看门狗**(`_Watchdog`):
- 后台线程,每 30 秒扫描一次
- Worker 超过 120 秒无心跳 → 标记 `error`
- 防止设备被占用却不干活
**全局状态注册表**(`_WORKERS`):
- `serial -> status dict`,线程安全(`_WORKERS_LOCK`)
- 供 `web_server` 读取实时状态,前端通过 `/api/status` 展示
### 3.3 TaskManager(`core/task_manager.py`)
统一管理:任务类型注册、设备分组、任务计划、定时调度、重试、持久化。
**核心组成**:
| 组件 | 说明 |
|------|------|
| `scheduler` | APScheduler BackgroundScheduler,cron 触发任务 |
| `groups` | 设备分组(内存业务对象,持久化到 SQLite) |
| `jobs` | 任务计划(内存业务对象,持久化到 SQLite) |
| `_running` | 运行中的 worker(serial -> worker 信息) |
| `_stop_requested` | 用户请求停止的 serial 集合(阻止后续重试) |
| `_fg_scanner` | 前台 App 扫描器(不打扰设备) |
**调度模式**:
- `once`:不注册 cron,手动执行
- `cron`:注册启动 cron,到点启动所有目标设备
- `cron_stop`:注册启动 cron + 停止 cron,到点停止本任务 worker
**重试策略**:
- `DeviceOfflineError`:立即放弃,不重试(设备掉线短时间不会自愈)
- 其他异常:按 `retry.max_attempts` 重试,间隔 `retry.delay`
- 临时错误(端口耗尽):退避 max(delay, 120s)
- 用户停止:加入 `_stop_requested`,阻止任何后续重试
**并发控制**:同一 serial 同时只允许一个 worker,避免冲突。
**状态缓存**:`get_status()` 带 5 秒缓存,避免每次 /api/status 都查库/adb 阻塞前端。
### 3.4 前台 App 扫描器(`_ForegroundScanner`)
**设计原则:不打扰设备**,扫描不会让设备退出当前 App。
| 设备状态 | 处理方式 | 是否打扰 |
|---------|---------|---------|
| worker 运行中(IP:5555) | 复用已有 ADB 连接查询 | 否 |
| worker 运行中(USB) | 经 220 远程 adb server 查询 | 否 |
| 完全空闲 | 返回"空闲"(不主动 connect) | 否 |
> **为什么不扫描空闲设备的前台 App**:IP:5555 的 adb transport 是共享的(历史与 STF provider 共用),外部 connect/disconnect 会扰动共享连接,遵守既有红线。
### 3.5 ADB 操作(`core/adb_helper.py`)
**全局锁串行化**:`_ADB_LOCK` 确保所有 adb 调用串行执行,避免多线程竞争 adb server。
**铁律:绝不 kill-server**:
- `adb kill-server` 会断开所有设备的 adb transport
- 全部设备连接被重建,影响所有运行中的任务
- 同理绝不 disconnect IP:5555(共享 transport 红线,见 DEVELOPMENT.md)
| 函数 | 说明 |
|------|------|
| `adb_connect(url, retries=5)` | adb connect(带重试,绝不 kill-server) |
| `adb_connect_light(url)` | 轻量 connect(单次尝试,扫描专用) |
| `adb_disconnect(url)` | adb disconnect |
| `screenshot(serial)` | 截图(adb exec-out screencap -p,只读安全) |
| `get_foreground_app(url)` | 获取前台 App 包名(dumpsys window) |
| `identify_device(serial)` | 让设备响铃识别 |
### 3.6 数据模型(`core/models.py`)
SQLAlchemy 模型,存于 `data/users.db`:
| 模型 | 表名 | 说明 |
|------|------|------|
| `User` | user | 后台用户(Flask-Login 认证,Werkzeug 哈希密码;`is_admin` 管理员 + `perms` 业务权限位) |
| `DeviceGroup` | device_group | 设备分组(serials 存 JSON) |
| `TaskJob` | task_job | 任务计划(target/params/schedule/retry 存 JSON) |
| `CustomAction` | custom_action | 自定义动作(步骤打包,steps 存 JSON) |
| `ApkFile` | apk_file | APK 文件元信息 |
| `Device` | device | 设备池清单(替代 STF 池;enabled=False 不参与调度,含 model 型号列) |
| `PendingDevice` | pending_device | 自动发现「待连接池」(扫描发现、用户确认后才入正式池) |
> 表名默认取类名小写(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_action`:**动作经验库**(命名动作 = 可复用单元,steps 用编辑器 schema 且带元素定位、禁坐标);由任务成功后从**成功步骤**蒸馏,执行前按名字/别名召回并注入(`web/agent_api.py` `_distill_actions/_find_actions`)。
**数据库初始化**(`init_db`):
- 创建所有表
- 首次启动创建默认管理员 `admin/admin123`
- 自动迁移旧 `groups.json` / `jobs.json` 到 SQLite(迁移后归档为 `.migrated`)
- 版本化 schema 迁移(`SCHEMA_MIGRATIONS`,**当前到 v4**,见 `core/models.py`):结构变更必须追加迁移条目,`create_all` 只建新表不加列
### 权限模型
- `User.perms` 存业务权限位 JSON 数组(`tasks`/`devices`/`apks`/`logs`),`is_admin=true` 拥有全部权限(`has_perm` 短路)
- 后端统一用 `@perm_required(PERM_X)` / `@admin_required` 装饰器拦截(web/auth.py),无权限返回 403;
查看类 GET 接口只要求登录;用户管理、adb 终端(`/api/adb/cmd`)强制 `admin_required`
- adb 终端安全红线:拒绝 `kill-server` / `disconnect`(共享 adb transport)
- 前端 `loadMe()` 拉取 `/api/me`,用 `data-perm` 属性隐藏无权限的 tab/按钮,行内按钮用 `_can(perm)` 判断
- 安全兜底:**前端隐藏只是 UX,权限强制在后端**;新增路由时按"写操作必须带权限装饰器"的约定
### 3.7 APK 管理(`core/apk_manager.py`)
APK 上传/解析/批量安装。
**设备连接策略(直连)**:
- 直接 `adb connect serial`(serial 是 IP:5555)
- 不经过占用/释放(单实例互斥由调度器内存锁保证)
- 安装后不主动 disconnect(共享 adb transport 红线)
**安装流程**:
1. 上传 APK → 保存到 `data/apks/` → pyaxmlparser 解析包名/版本 → 入库
2. 批量安装 → 后台线程 → 每台设备直连 adb install → 验证包名
3. 跳过 worker 运行中的设备(避免打断任务)
### 3.8 元素抓取(`core/uiauto_helper.py`)
封装 uiautodev 本地服务(端口 20242)的客户端。
| 函数 | 说明 |
|------|------|
| `is_running()` | 探测 uiauto2 服务是否运行 |
| `list_devices()` | 获取 uiauto2 已连接的设备列表 |
| `get_screenshot(serial)` | 获取设备截图(JPEG) |
| `get_elements(serial)` | 获取设备 UI 元素树(扁平化列表) |
元素树解析:递归提取每个节点的 `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` 一致)。
- **跨平台**(Windows/Linux/macOS),依赖 `rapidocr_onnxruntime`;服务器无显示器环境建议将 opencv-python 换成 opencv-python-headless
- 引擎懒加载单例 + 并发加锁(识别约 0.2-0.5s/次)
- 返回坐标约定:像素、原点左上
### 3.10 Web 层蓝图包(`web/`)
路由按功能域拆分(模块化开发底线,便于定位问题):
| 模块 | 职责 |
|------|------|
| `auth.py` | 登录/登出/CSRF/权限装饰器/页面路由(/、/wall) |
| `monitor.py` | 状态/运行控制/设备操作/远程看屏(流+缩略图+触控) |
| `tasks_api.py` | 任务计划/分组/自定义动作/元素抓取/步骤测试 |
| `admin_api.py` | 用户管理/日志 |
| `tools_api.py` | adb 终端/剪贴板注入/应用版本 |
| `devices_api.py` | 设备池管理 + 自动发现(扫描/确认/忽略/手动重连/型号采集) |
| `apks_api.py` | 应用管理 |
| `tailscale_api.py` | Tailscale 管理 |
| `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) |
`web_server.py` 只做装配与启动(266 行):app 创建;`init_db` 前消费待生效备份恢复(`consume_pending_restore`);初始化 device_pool / device_discovery;装配 TaskManager / ApkManager;注册 10 个蓝图(auth/monitor/tasks/admin/tools/devices/apks/tailscale/agent/system,`web/__init__.py`);注册经验巡检 APScheduler(03:47 Asia/Shanghai);uiautodev 子进程启停与设备池预连接线程;启动。
---
## 4. 任务系统设计
### 4.1 注册机制
```
tasks/__init__.py
├── from .base import BaseTask, register_task, list_task_types, get_task_class
├── from .douyin import task # 触发 @register_task
└── from .generic import task # 触发 @register_task
```
`@register_task` 装饰器将 Task 类注册到全局 `_TASK_TYPES` 字典,key 为 `task_type` 字符串。
### 4.2 参数深合并
Job 下发时只传需要覆盖的字段,调度器做三层合并:
1. **顶层字段**:Job params 覆盖 DEFAULT_PARAMS
2. **actions 字段**:参数级深合并
- 前端没传的 action → 用默认
- 前端传了 → `enabled` 和 `params` 分别合并
- `params` 再深合并一层(保留前端没传的子参数)
示例:只想改点赞概率,Job params 只需:
```json
{"actions": {"like": {"params": {"rate": 0.5}}}}
```
### 4.3 Action 系统
每个 App 有独立的 Action 注册表(`create_action_registry()`),互不污染。
```
core/actions/base.py — BaseAction 全局基类 + should_trigger + register_action
tasks/douyin/actions/base.py — ACTIONS = create_action_registry() + list/get 函数
tasks/douyin/actions/like.py — @register_action(ACTIONS) LikeAction
```
**循环导入坑**:`actions/__init__.py` 必须先 `from .base import ACTIONS`,再 `from . import like`。
---
## 5. 前端设计
### 5.1 单页应用
`templates/admin/monitor.html` 是纯 HTML+CSS+JS 单页应用,无框架依赖。
- **Tab 切换**:7 个顶级 Tab(监控/任务/日志/用户/工具/AI 控制台/系统),均在 monitor.html 内 `.tab-panel` 切换(纯 DOM 操作);独立页面仅 `/login`、`/wall`。原"分组"已无顶层入口(移到工具页子分栏)
- **数据获取**:`fetch()` 调 JSON API,5 秒轮询 `/api/status`
- **状态渲染**:设备表格、任务卡片、进度条、徽章,纯 DOM 操作
### 5.2 步骤编辑器
`generic_steps` 任务的步骤编辑器(任务弹窗已加宽到 1000px):
- 左侧操作库(拖拽源,按 交互操作/屏幕与App/流程控制 分组)
- 中间画布(步骤卡片列表,HTML5 Drag API 排序 + 跨层级嵌套:循环套循环、动作组)
- 每个步骤卡片可展开参数表单
- 选择器字段旁有"抓取元素"按钮(独立模态框)
- **容器步骤**(loop/group/if_el):卡片内嵌子步骤容器接收拖入;if_el 有"✅找到时/❌未找到时"两个独立分支容器,分支可嵌套任意步骤
- **条件判断**(if_el):选择器支持 xpath 等 UI 树选择器或 OCR识别(`core/ocr.py`,截屏匹配图片/画布文字,命中可自动点击)
- 所有递归操作(选择打包、存自定义动作、校验、防循环自套)统一遍历 children/then/else 三个子数组
### 5.3 页内子分栏
任务/工具/系统 Tab 用通用 `showSubTab(tabId, name)` 实现页内子分栏:每个子分栏一个 `.sub-panel`,
`_activeSubs` 记住各 Tab 上次选中的子分栏。现状子分栏:
- **任务**:任务计划 / 自定义动作
- **工具**:剪贴板注入 / adb 远程终端 / Tailscale 管理 / 应用管理 / 应用版本管理 / 设备已装应用 / 设备池管理 / 设备分组
- **系统**:数据备份 / 导入恢复
(原"分组"顶级 Tab 与"维护/STF 服务"子分栏已不存在——分组已移入工具页子分栏,STF 已摘除。)
### 5.4 AI 控制台的记忆面板
AI 控制台(顶级 Tab)右上角两个模态框,管理自进化记忆:
- **🧠 经验库**:任务级经验(`agent_experience`,整任务配方)+ 每日 AI 巡检建议(删除需人工确认)。
- **🎬 动作库**:动作级经验(`agent_action`)——命名动作(可含 1~N 步)+ 编辑器 schema 步骤 + **元素定位(禁坐标)**;由任务成功后从**成功步骤**自动蒸馏,执行前按名/别名召回注入;面板支持查看/编辑/删除/手动新建(保存经服务端校验,坐标步骤被拒)。
- **会话列表显示会话 ID**(前 8 位,等宽小字),点击即复制完整 ID——便于反馈问题时引用 `conv=<id>`。
#### 5.4.1 回答渲染与 token(2026-09-10)
| 能力 | 落点 | 说明 |
|------|------|------|
| **Markdown 渲染** | `static/admin/markdown.js`(`renderMarkdown()`) | 自研轻量渲染器,**不引 CDN**(生产 220 在内网):标题/段落/软换行/粗斜体/删除线/行内代码/围栏代码块/有序无序列表(含嵌套)/引用/表格/分隔线/链接。**先 `esc()` 转义再套标记**,模型输出里的 HTML 只显示为文本(防注入) |
| **推理链可折叠** | `agent.js` `_appendReasoning()` + `<details class="reasoning">` | 流式思考时自动展开、正文开始时自动收起;用户手动点过 `summary` 后不再自动改(`dataset.touched`);摘要显示「思考过程(N 字)」 |
| **token 显示** | `mcp_agent/agent.py` `_accumulate_usage()` + `agent_api` SSE `usage` 事件 | 每次模型调用完成后推**本轮累计**(`prompt/completion/total/calls`);单条消息脚注 + 顶栏「本会话累计」(历史 + 运行中) |
| **推理链/用量落库** | `_agent_thread` 把 `usage`、`reasoning` 写进会话 assistant 消息 | 刷新页面后仍可回看;**回灌模型上下文时只取 `role`/`content`**(不污染 token) |
> **token 采集的兼容性**:请求带 `stream_options: {"include_usage": true}`,按「每次模型调用」取末尾 chunk 的 `usage` 累加(多轮工具调用会多次累加)。个别网关不认该参数会直接 **HTTP 400** → `_UsageUnsupported` 捕获后**自动关掉并重试一次**(`self._include_usage=False`),不影响主流程。
>
> **推理链体积**:只保留前 `_REASONING_KEEP`=6000 字符落库(会话消息上限 60 条),避免历史无限膨胀。
> **蒸馏健壮性(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`),返回 `命中/未找到/已执行`——用于确认回填的选择器在真实界面能命中(元素无有效选择器时不显示此按钮)。
---
## 6. 关键设计决策
### 6.1 为什么用 SQLite 而不是 JSON 文件
- 支持用户/分组/任务的关系存储
- 并发安全(WAL 模式)
- 迁移旧 JSON 时归档为 `.migrated`,避免删空后重启又复原
### 6.2 为什么用 threading 而不是 asyncio
- uiautomator2 是同步阻塞库,不适合 asyncio
- 多设备并发用多线程即可,每台设备一个 Worker 线程
- Flask `threaded=True` 处理并发 HTTP 请求
### 6.3 为什么绝不 kill-server
`adb kill-server` / `adb disconnect` 会断开共享的 adb transport(历史与 STF provider 共享;STF 摘除后红线仍保留——多 worker、前台扫描、设备自动发现共用同一 adb server),影响所有运行中的任务。连接失败就返回 False,由调用方处理。
### 6.4 为什么状态查询带缓存
状态源 = 本地 SQLite 设备池 + adb + 内存 worker 状态。5 秒缓存避免每次 `/api/status` 都查库/adb 阻塞 Flask;Worker 实时状态读内存,不受缓存影响。
### 6.5 为什么 DeviceOfflineError 不重试
设备掉线后短时间内不会自愈,重试只会浪费配额并阻塞调度器。让设备进入冷却,可依赖设备自动发现(device_discovery 对正式池断联设备每轮 adb 重连)恢复后再启用。
---
## 7. 线程模型
```
主线程(Flask)
├── HTTP 请求处理(threaded=True,每请求一线程)
├── TaskManager.scheduler(APScheduler,cron 触发任务计划)
├── 经验巡检 BackgroundScheduler(03:47 Asia/Shanghai,web_server 装配)
├── 设备自动发现线程(device_discovery._discovery_loop,默认 60s 一轮)
├── 设备池型号采集后台线程(device_pool._refresh_models_bg,启动/手动触发)
├── 设备池预连接线程(web_server._preconnect_pool_devices,重启后加速恢复)
├── AI Agent 运行线程(agent_api._agent_thread,单实例 + SSE 推送)
├── 看门狗线程(_Watchdog,30s 间隔)
├── uiautodev 子进程(PID + cmdline 校验,防容器 PID 复用误杀)
└── Worker 线程(每台设备一个)
├── _run_with_retry 线程(重试循环)
└── BaseWorker 线程(设备生命周期 + run_task)
```
> MCP server(127.0.0.1:8033)是**独立进程**(scripts/start.sh 拉起),不是 web_server 的线程。
**线程安全**:
- `_WORKERS_LOCK`:保护全局 worker 状态字典
- `_ADB_LOCK`:串行化所有 adb 调用
- `TaskManager._lock`:保护运行中任务字典
- `_ForegroundScanner._cache_lock`:保护前台 App 缓存