Compare commits
6
Commits
p1_0910
...
24d57d3b96
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24d57d3b96 | ||
|
|
f4b5316436 | ||
|
|
98cdc39224 | ||
|
|
86c65236a2 | ||
|
|
d40c867d6f | ||
|
|
4b5b836d31 |
+81
-31
@@ -1,41 +1,91 @@
|
|||||||
# 环境变量示例:复制为 .env 并按需修改(.env 不进 git)
|
# ==============================================================================
|
||||||
# 生产环境建议用环境变量/密钥管理注入,不要把真实 token 写进 git。
|
# auto_control 环境变量示例
|
||||||
# 全部可配置项见 config.py(_env() 读取,未配置时用代码内默认值);
|
# - 复制为项目根目录的 .env 再按需修改(.env 已被 .gitignore 排除,不会进 git)
|
||||||
# 标"必须"的项务必设置,其余可留空用默认。
|
# - 加载方式:config.py 逐行解析后用 os.environ.setdefault 注入
|
||||||
|
# → 因此"真实环境变量"优先于 .env(容器/CI 里用 env 覆盖更方便)
|
||||||
|
# - 标【必须】的项务必设置;其余留空即用代码默认值
|
||||||
|
# - 完整说明见 doc/DEVELOPMENT.md §4 配置速查、doc/DEPLOY.md §2.2
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
# ==================== STF 平台 ====================
|
# ==================== Web 服务 ====================
|
||||||
# STF 服务地址(默认 http://192.168.20.220:7100)
|
# 会话密钥【必须,生产务必固定】:不配则每次启动随机生成,重启后登录态失效。
|
||||||
STF_URL=http://192.168.20.220:7100
|
|
||||||
|
|
||||||
# STF API Token(必须:STF 个人设置 → API Keys 生成)
|
|
||||||
STF_TOKEN=请填写你的STF_token
|
|
||||||
|
|
||||||
# 启动时自动释放残留 STF 占用(单实例无人值守开 true;多实例共用账户勿开,会误放另一实例的任务)
|
|
||||||
# AUTO_RELEASE_STALE_OCCUPY=true
|
|
||||||
|
|
||||||
# ==================== Web 会话 ====================
|
|
||||||
# Web 会话密钥(必须:生产设置为随机长字符串,重启不失效)
|
|
||||||
# 生成:python -c "import secrets; print(secrets.token_hex(32))"
|
# 生成:python -c "import secrets; print(secrets.token_hex(32))"
|
||||||
WEB_SECRET_KEY=请填写随机密钥
|
WEB_SECRET_KEY=请填写随机密钥
|
||||||
|
|
||||||
# ==================== SSH 到部署机(维护页重启 STF / 工具页 STF 设备管理) ====================
|
# 监听地址与端口是 config.py 里的常量(不进 .env):WEB_HOST=0.0.0.0、WEB_PORT=18050
|
||||||
# SSH 目标(默认 [email protected])
|
# 需要修改直接改 config.py(18050 是为了避开 Windows 动态端口段)
|
||||||
# [email protected]
|
|
||||||
|
|
||||||
# SSH 认证密码(推荐):配置后走 paramiko **纯密码**登录(不碰本地密钥/SSH agent,
|
|
||||||
# 不会弹授权框,跨平台无需 sshpass);留空则退回系统 ssh 免密密钥(BatchMode=yes)。
|
|
||||||
STF_SSH_PASSWORD=请填写SSH密码
|
|
||||||
|
|
||||||
# 220 上 STF / adb 的 Docker 容器名(默认 stf / adb,一般不用改)
|
# ==================== 设备与 adb ====================
|
||||||
# STF_DOCKER_CONTAINER=stf
|
# USB 设备(serial 无冒号)所在的部署机 —— 平台经它的 adb server 驱动这些设备。
|
||||||
# STF_ADB_CONTAINER=adb
|
# 默认 100.100.10.1:5037(220 的 Tailscale IP + adb 容器端口,host 网络模式)。
|
||||||
|
# USB_ADB_HOST=100.100.10.1
|
||||||
|
# USB_ADB_PORT=5037
|
||||||
|
|
||||||
# STF 设备池脚本路径(工具页"STF 设备管理"增删设备时读写其 DEVICES 列表,默认值见 config.py)
|
# 设备自动发现(扫描网段找开放 5555 的设备)。
|
||||||
# STF_SCRIPT_PATH=/mnt/data/openstf/connect_devices.sh
|
# 扫描网段在「工具 → 设备池管理」界面里配置(存数据库 app_meta),此处只管端口与周期。
|
||||||
|
# DISCOVERY_PORT=5555
|
||||||
|
# DISCOVERY_INTERVAL=60
|
||||||
|
|
||||||
# ==================== Tailscale 管理(工具页) ====================
|
# 把本机的 adb 客户端指向远程 adb server(注意:这三个键由 adb/adbutils 自己读取,
|
||||||
# API key(必须:Tailscale 后台 → Settings → API Access Tokens 生成)
|
# 不是本项目代码读的;两个变量名都要设,adb 实际认 ADDRESS,部分库读 HOST)。
|
||||||
|
# 用途:让本机 adb 直接看到 220 侧插着的 USB 设备。
|
||||||
|
# ANDROID_ADB_SERVER_ADDRESS=192.168.20.220
|
||||||
|
# ANDROID_ADB_SERVER_HOST=192.168.20.220
|
||||||
|
# ANDROID_ADB_SERVER_PORT=5037
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Tailscale 管理(工具 → Tailscale 管理) ====================
|
||||||
|
# API key:Tailscale 后台 → Settings → API Access Tokens 生成
|
||||||
TAILSCALE_API_KEY=请填写Tailscale_API_key
|
TAILSCALE_API_KEY=请填写Tailscale_API_key
|
||||||
|
|
||||||
# tailnet 名/ID(默认按邮箱前缀,如 1422726308@gmail.com)
|
# tailnet 名/ID(一般填登录邮箱,如 user@example.com)
|
||||||
# TAILSCALE_TAILNET=1422726308@gmail.com
|
# TAILSCALE_TAILNET=user@example.com
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== MCP Server(外部 AI 接入,:8033) ====================
|
||||||
|
# 注意:mcp_server 只读进程环境变量,**不读本文件**!
|
||||||
|
# 容器场景由 scripts/start.sh 用下面这些变量拉起进程;
|
||||||
|
# 本机手动启动请直接在命令行前加环境变量(见 doc/MCP.md §2)。
|
||||||
|
#
|
||||||
|
# MCP_ENABLED=1 # 仅 start.sh 消费:=0 则不自动拉起 MCP
|
||||||
|
# MCP_ALLOW_WRITE=1 # 写操作总开关(0=只读;start.sh 内强制为 1)
|
||||||
|
# MCP_PLATFORM_URL=http://127.0.0.1:18050
|
||||||
|
# MCP_PLATFORM_USER=admin
|
||||||
|
# MCP_PLATFORM_PASS=请填写平台admin密码 # 【改过 admin 密码必须同步,否则 MCP 登录失败】
|
||||||
|
# MCP_ALLOWED_SERIALS= # 设备白名单(逗号分隔);空=不限制(语义缺口见 backlog)
|
||||||
|
# MCP_HTTP_HOST=0.0.0.0
|
||||||
|
# MCP_HTTP_PORT=8033
|
||||||
|
# MCP_SCREENSHOT_WIDTH=540 # 返回给模型的截图宽度
|
||||||
|
# MCP_JPEG_QUALITY=70
|
||||||
|
# MCP_AUDIT_FILE=/tmp/mcp_audit.log
|
||||||
|
# MCP_PLATFORM_TIMEOUT=30
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== AI Agent(mcp_agent,命令行/独立运行时用) ====================
|
||||||
|
# 注意:同样只读进程环境变量、不读本文件。
|
||||||
|
# Web 的「AI 控制台」配置走数据库(app_meta 的 agent_* 键),与本组变量互不影响。
|
||||||
|
#
|
||||||
|
# AGENT_API_BASE=https://api.deepseek.com
|
||||||
|
# AGENT_MODEL=deepseek-v4-flash-vision-exp
|
||||||
|
# AGENT_API_KEY=请填写模型 API Key
|
||||||
|
# DEEPSEEK_API_KEY= # AGENT_API_KEY 的兼容别名
|
||||||
|
# AGENT_MCP_URL=http://127.0.0.1:8033/mcp
|
||||||
|
# AGENT_DEFAULT_SERIAL=
|
||||||
|
# AGENT_MAX_STEPS=40
|
||||||
|
# AGENT_TIMEOUT=120
|
||||||
|
# AGENT_LANG=zh
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 开发/测试 ====================
|
||||||
|
# 设置后不启动 cron 调度器(跑测试脚本时避免真实触发任务、占用设备)
|
||||||
|
# DISABLE_SCHEDULER=1
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# 已废弃的历史配置(STF 已从代码层摘除,下列键代码不再使用,保留仅为兼容旧 .env)
|
||||||
|
# STF_URL / STF_TOKEN / STF_SSH_TARGET / STF_SSH_PASSWORD /
|
||||||
|
# STF_DOCKER_CONTAINER / STF_ADB_CONTAINER / STF_SCRIPT_PATH /
|
||||||
|
# AUTO_RELEASE_STALE_OCCUPY
|
||||||
|
# 迁移背景见 doc/STF_REMOVAL.md
|
||||||
|
# ==============================================================================
|
||||||
|
|||||||
@@ -1,23 +1,58 @@
|
|||||||
# 设备自动化后台(platform-tools)
|
# auto_control — Android 多设备自动化任务平台
|
||||||
|
|
||||||
基于 **uiautomator2 + Flask + 自建设备池** 的 Android 多设备自动化任务执行平台(已摘除 OpenSTF 依赖)。
|
基于 **uiautomator2 + Flask + 自建设备池** 的 Android 多设备自动化平台(已摘除 OpenSTF 依赖,见 [doc/STF_REMOVAL.md](doc/STF_REMOVAL.md))。
|
||||||
|
|
||||||
提供 Web 管理后台,支持多设备并发任务执行、定时调度、设备分组管理、设备池管理(新增/停用/删除/型号采集)、APK 批量安装、网页远程看屏(MJPEG 实时流 + 触控)、UI 元素抓取等功能。内置抖音养号任务和通用步骤任务,可扩展任意 App 的自动化操作。
|
一台机器上统管一批 Android 设备:定时/手动下发任务、并发执行、实时看屏与远程触控、元素抓取与步骤编排、APK 批量安装、数据备份导出/导入;并通过 **MCP** 把手机控制能力开放给外部 AI(数字员工)。
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────── Web 管理后台(单页应用,:18050)────────────┐
|
||||||
|
浏览器 ────────────▶ │ 监控 │ 任务 │ 日志 │ 用户 │ 工具 │ AI 控制台 │ 系统 │
|
||||||
|
└───────────────────────┬─────────────────────────────────┘
|
||||||
|
│ Flask 蓝图(web/)
|
||||||
|
┌───────────────────────▼──────┐ ┌──────────────────────┐
|
||||||
|
│ 调度 TaskManager │ │ AI Agent(mcp_agent)│
|
||||||
|
│ 设备池 device_pool │ └──────────┬───────────┘
|
||||||
|
│ Worker(每设备一线程) │ │ MCP :8033
|
||||||
|
└───────┬──────────────┬───────┘ ┌──────────▼───────────┐
|
||||||
|
│ │ │ MCP Server(de_* 工具)│
|
||||||
|
adb 直连 uiautodev └──────────┬───────────┘
|
||||||
|
IP:5555 / USB :20242(抓元素) │
|
||||||
|
▼ ▼
|
||||||
|
Android 设备 ◀──────────────────────── Android 设备
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 目录
|
## 目录
|
||||||
|
|
||||||
|
- [核心能力](#核心能力)
|
||||||
- [快速上手](#快速上手)
|
- [快速上手](#快速上手)
|
||||||
- [项目结构](#项目结构)
|
- [项目结构](#项目结构)
|
||||||
- [核心概念](#核心概念)
|
- [核心概念](#核心概念)
|
||||||
- [配置说明](#配置说明)
|
|
||||||
- [Web 管理后台](#web-管理后台)
|
- [Web 管理后台](#web-管理后台)
|
||||||
- [任务系统](#任务系统)
|
- [任务与步骤](#任务与步骤)
|
||||||
- [日志系统](#日志系统)
|
- [AI 与 MCP](#ai-与-mcp)
|
||||||
- [常用脚本](#常用脚本)
|
- [配置说明](#配置说明)
|
||||||
|
- [日志](#日志)
|
||||||
- [常见问题](#常见问题)
|
- [常见问题](#常见问题)
|
||||||
- [更多文档](#更多文档)
|
- [文档索引](#文档索引)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 核心能力
|
||||||
|
|
||||||
|
| 能力 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| **多设备并发任务** | 每设备一个 Worker 线程;同一设备同时只跑一个任务(可配置"抢占"打断其他任务) |
|
||||||
|
| **设备池** | SQLite 清单 + adb 在线状态;支持手工添加、网段自动发现、一键重连、型号采集、启用/停用 |
|
||||||
|
| **任务调度** | 手动 / cron 定时 / 定时启停;运行窗口;失败重试(含端口耗尽类的长退避) |
|
||||||
|
| **步骤编辑器** | 可视化拖拽编排 18 种步骤(含循环/条件/OCR),可打包成"自定义动作"复用 |
|
||||||
|
| **元素抓取** | 拉取设备 UI 元素树 → 点选回填选择器;支持"点一下"与"测选择器"真机验证 |
|
||||||
|
| **实时看屏** | MJPEG 实时流 + 点击/滑动/按键/文字输入;全屏监控大屏(`/wall`) |
|
||||||
|
| **应用管理** | APK 上传/解析/批量安装;设备已装应用与版本查询;剪贴板注入 |
|
||||||
|
| **AI 控制台** | 用自然语言驱动 AI 操作指定设备(MCP 工具 + 截图),流式输出、Markdown 渲染、推理链折叠、token 统计;成功操作自动沉淀「经验库 / 动作库」并在相似任务中召回 |
|
||||||
|
| **MCP 接入** | 19 个 `de_*` 工具,把手机控制开放给外部 AI;写操作有开关、设备忙时拒绝、全量审计 |
|
||||||
|
| **备份导出/导入** | 一键导出 zip(库快照 + manifest + 可选 APK),导入前校验预览、自动预备份、重启生效 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -26,9 +61,8 @@
|
|||||||
### 环境要求
|
### 环境要求
|
||||||
|
|
||||||
- **Python 3.10+**(推荐 3.12)
|
- **Python 3.10+**(推荐 3.12)
|
||||||
- **Windows / Linux / macOS**均可(adb 二进制需放对应平台版本到 `bin/adb/`)
|
- Windows / Linux / macOS 均可(`bin/adb/` 需放对应平台的 adb 二进制)
|
||||||
- 设备需开启 **USB 调试**(USB 连上后 `adb tcpip 5555` 转网络调试),并加入 Tailscale 获得 `100.100.10.x` IP
|
- 设备开启 **USB 调试**;网络调试设备建议 `adb tcpip 5555` 并接入同一网络(本项目生产环境走 Tailscale `100.100.10.x`,见 [doc/DEPLOY.md](doc/DEPLOY.md))
|
||||||
- 新增设备在后台「工具 → 设备池管理」添加 `IP:5555`(自动连接,任务运行时 u2 自动推送 atx-agent)
|
|
||||||
|
|
||||||
### 三步启动
|
### 三步启动
|
||||||
|
|
||||||
@@ -36,421 +70,327 @@
|
|||||||
# 1. 安装依赖
|
# 1. 安装依赖
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
|
||||||
# 2. 修改配置(可选):USB 设备在 220 上时配置 USB_ADB_HOST/PORT(默认 100.100.10.1:5037 已可用)
|
# 2. 配置(可选):把 .env.example 复制成 .env,至少填 WEB_SECRET_KEY
|
||||||
# 密钥类配置放 .env(WEB_SECRET_KEY / TAILSCALE_API_KEY)
|
# 密钥类配置一律放 .env(不入 git)
|
||||||
|
|
||||||
# 3. 启动 Web 后台
|
# 3. 启动
|
||||||
python web_server.py
|
python web_server.py
|
||||||
```
|
```
|
||||||
|
|
||||||
启动后访问 **http://localhost:18050/**,默认账号 `admin` / `admin123`。
|
启动后访问 **http://localhost:18050/**,默认账号 `admin` / `admin123`(**首次登录请立即改密**)。
|
||||||
|
|
||||||
### 验证启动
|
### 验证启动
|
||||||
|
|
||||||
控制台看到以下日志即表示启动成功:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
[INFO] [core.worker] 心跳看门狗已启动
|
[INFO] [core.worker] 心跳看门狗已启动
|
||||||
[INFO] [core.tm] 从数据库加载 X 个分组, X 个任务
|
[INFO] [core.tm] 从数据库加载 X 个分组, X 个任务
|
||||||
|
[INFO] [web] uiautodev 服务已启动 (PID=...)
|
||||||
[INFO] [web] 管理后台: http://localhost:18050/ (admin/admin123)
|
[INFO] [web] 管理后台: http://localhost:18050/ (admin/admin123)
|
||||||
|
[INFO] [web] 设备池预连接完成: X 台在线
|
||||||
[INFO] [web] 启动服务: http://localhost:18050/
|
[INFO] [web] 启动服务: http://localhost:18050/
|
||||||
```
|
```
|
||||||
|
|
||||||
### 首次使用流程
|
### 首次使用
|
||||||
|
|
||||||
1. **登录后台** → 用 `admin/admin123` 登录,建议立即修改密码
|
1. **登录** → `admin/admin123`,改密
|
||||||
2. **添加设备** → "工具 → 设备池管理"添加设备(serial 形如 `100.100.10.20:5555`,自动连接并采集型号)
|
2. **加设备** → 「工具 → 设备池管理」添加 `IP:5555`(自动连接 + 采集型号);也可用「设备自动发现」扫描网段后确认入池
|
||||||
3. **查看设备** → 首页"监控"Tab 展示设备池状态(在线/型号/任务)
|
3. **看设备** → 「监控」页设备表(在线/型号/任务/进度/前台 App)
|
||||||
4. **创建分组**(可选)→ "分组"Tab 按批次/项目给设备分组
|
4. **建任务** → 「任务 → 任务计划 → 新建任务」(只有 `generic_steps` 一种类型)→ 拖步骤 → 选目标设备 → 保存
|
||||||
5. **创建任务** → "任务"Tab 新建任务,选择任务类型、目标设备、参数、调度
|
5. **跑任务** → 任务行「执行」,或在「任务」/「监控」页操作
|
||||||
6. **执行任务** → 任务列表点"立即执行",或在"监控"Tab 勾选设备批量操作(亮屏/息屏/停止)
|
6. **看日志** → 「日志」页按模块实时查看
|
||||||
7. **查看日志** → "日志"Tab 实时查看运行日志
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
platform-tools/
|
auto_control/
|
||||||
├── config.py # 根配置(部署配置统一从 .env 读,模板见 .env.example)
|
├── config.py # 程序级配置常量(部署配置从 .env 读,模板 .env.example)
|
||||||
├── web_server.py # Flask 入口(app 装配 + 蓝图注册 + 启动,193 行)
|
├── web_server.py # 入口:app 装配 + 蓝图注册 + 恢复消费 + 拉起 uiautodev + 启动
|
||||||
├── web/ # Web 层蓝图包(按功能域拆分,路由都在这里)
|
├── requirements.txt # 依赖清单
|
||||||
│ ├── auth.py # 登录/CSRF/权限装饰器/页面路由(/、/wall)
|
│
|
||||||
│ ├── monitor.py # 状态/运行控制/设备操作/远程看屏
|
├── web/ # Web 层(蓝图包,路由都在这里,全部无 url_prefix)
|
||||||
│ ├── tasks_api.py # 任务计划/分组/自定义动作/元素抓取
|
│ ├── auth.py # 登录/登出/CSRF/权限装饰器/页面路由(/、/wall)
|
||||||
|
│ ├── monitor.py # 设备状态/运行控制/设备操作/远程看屏(MJPEG、点击、按键)
|
||||||
|
│ ├── tasks_api.py # 任务计划/分组/自定义动作/元素抓取/步骤测试
|
||||||
│ ├── admin_api.py # 用户管理/日志
|
│ ├── admin_api.py # 用户管理/日志
|
||||||
│ ├── tools_api.py # adb 终端/剪贴板注入/应用版本
|
│ ├── tools_api.py # adb 终端/剪贴板注入/应用版本查询
|
||||||
│ ├── devices_api.py # 设备池管理
|
│ ├── devices_api.py # 设备池管理 + 自动发现
|
||||||
│ ├── apks_api.py # 应用管理
|
│ ├── apks_api.py # APK 上传/安装/删除
|
||||||
│ ├── tailscale_api.py # Tailscale 管理
|
│ ├── tailscale_api.py # Tailscale 管理(改名/授权/密钥/IP/auth key)
|
||||||
│ ├── common.py # 跨模块共享工具(合并设备列表/屏幕状态)
|
│ ├── agent_api.py # AI 控制台(会话/SSE/经验库/动作库/巡检)
|
||||||
│ └── context.py # 共享对象注入(mgr/apk_mgr/device_pool)
|
│ ├── system_api.py # 系统数据备份导出/导入
|
||||||
├── requirements.txt # Python 依赖清单
|
│ ├── common.py # 跨模块共享工具(合并设备列表、屏幕状态)
|
||||||
|
│ └── context.py # 共享对象注入(mgr / apk_mgr / device_pool)
|
||||||
│
|
│
|
||||||
├── core/ # 核心基础设施层
|
├── core/ # 基础设施层
|
||||||
│ ├── logger.py # 统一日志(分文件、10MB 滚动)
|
│ ├── models.py # SQLAlchemy 模型 + 建表 + 版本化迁移 + 旧 JSON 迁移
|
||||||
│ ├── device_pool.py # 设备池(SQLite 清单 + adb 在线状态 + 型号采集)
|
│ ├── device_pool.py # 设备池(清单/在线状态/型号)
|
||||||
│ ├── adb_helper.py # adb 命令封装(全局锁,绝不 kill-server)
|
│ ├── device_worker.py # BaseWorker + 设备生命周期 + 心跳看门狗 + 全局状态表
|
||||||
│ ├── device_worker.py # BaseWorker 基类 + 设备生命周期 + 心跳看门狗
|
│ ├── task_manager.py # 调度器:分组/任务/APScheduler/重试/运行控制
|
||||||
│ ├── task_manager.py # TaskManager 调度器 + 前台App扫描器
|
│ ├── adb_helper.py # adb 命令封装(全局锁,红线:绝不 kill-server)
|
||||||
│ ├── u2_helper.py # uiautomator2 通用辅助函数
|
│ ├── device_discovery.py # 网段扫描发现设备 → 待连接池
|
||||||
│ ├── uiauto_helper.py # uiautodev 元素抓取客户端
|
│ ├── u2_helper.py # uiautomator2 通用辅助(等首页/安全点击等)
|
||||||
|
│ ├── uiauto_helper.py # uiautodev 客户端(元素树 + XPath 建议)
|
||||||
|
│ ├── ocr.py # 屏幕 OCR(RapidOCR,条件判断用)
|
||||||
|
│ ├── clipboard_helper.py # 剪贴板注入(ClipInject 通道)
|
||||||
│ ├── apk_manager.py # APK 上传/解析/批量安装
|
│ ├── apk_manager.py # APK 上传/解析/批量安装
|
||||||
│ ├── ocr.py # 屏幕 OCR(RapidOCR,条件判断 OCR 选择器)
|
│ ├── system_backup.py # 数据备份导出/导入(重启生效)
|
||||||
│ ├── ssh_client.py # SSH 统一执行(paramiko 纯密码 / 免密密钥,预留运维用)
|
|
||||||
│ ├── tailscale_client.py # Tailscale API v2 客户端
|
│ ├── tailscale_client.py # Tailscale API v2 客户端
|
||||||
│ ├── models.py # SQLAlchemy 数据模型 + 数据库初始化
|
│ ├── ssh_client.py # SSH 封装(当前无人调用,预留)
|
||||||
│ └── actions/ # 全局 Action 框架
|
│ ├── logger.py # 分文件日志(core/task/web/action)
|
||||||
|
│ └── actions/ # 全局 Action 框架(BaseAction + 注册器)
|
||||||
│
|
│
|
||||||
├── tasks/ # 任务定义层(每个 App 一个子包)
|
├── tasks/ # 任务定义层
|
||||||
│ ├── __init__.py # 全局任务注册表
|
│ ├── base.py # BaseTask + _TASK_TYPES + register_task
|
||||||
│ ├── base.py # BaseTask 基类 + @register_task 装饰器
|
│ └── generic/ # 通用步骤任务(task_type=generic_steps,当前唯一类型)
|
||||||
│ ├── douyin/ # 抖音养号任务
|
│ └── task.py # STEP_TYPES(18 种步骤)+ Worker + 执行器
|
||||||
│ └── generic/ # 通用步骤任务(可视化编辑器编排)
|
|
||||||
│ └── task.py # 步骤执行引擎(open_app/click/swipe/if_el...)
|
|
||||||
│
|
│
|
||||||
├── templates/admin/ # 前端页面
|
├── mcp_server/ # MCP Server(19 个 de_* 工具,:8033)
|
||||||
│ ├── monitor.html # 单页应用(监控/任务/分组/日志/用户/工具)
|
│ ├── mcp_server.py # 工具定义 + 平台登录 + 门控
|
||||||
│ └── login.html # 登录页
|
│ ├── platform_client.py # 平台 HTTP 客户端(复用 Web 账号)
|
||||||
|
│ ├── direct_ops.py # 直连设备的 adb/u2 操作
|
||||||
|
│ ├── audit.py # 调用审计(JSON Lines)
|
||||||
|
│ └── config.py # MCP_* 环境变量
|
||||||
│
|
│
|
||||||
├── static/admin/ # 前端 JS 模块(monitor.html 按依赖顺序加载)
|
├── mcp_agent/ # AI Agent 编排层(OpenAI 兼容模型 → MCP 工具)
|
||||||
│ ├── base.js # 通用基础:API/CSRF/权限/Tab切换/子分栏/模态框
|
│ ├── agent.py # Agent:流式循环 + 工具调用 + 截图 + token 统计
|
||||||
│ ├── list.js # 统一列表组件(搜索+分页+排序)
|
│ ├── config.py # AGENT_* 环境变量
|
||||||
│ ├── monitor.js # 监控页(设备表/截图/异常汇总)
|
│ └── cli.py # 命令行入口
|
||||||
│ ├── editor.js # 步骤编辑器(拖拽/条件判断/元素抓取)
|
│
|
||||||
│ ├── tasks.js # 任务 Tab + 自定义动作
|
├── templates/admin/ # 页面
|
||||||
│ ├── tools.js # 工具 Tab(剪贴板/设备池管理/adb终端/Tailscale/远程看屏)
|
│ ├── monitor.html # 主单页应用(7 个顶级 Tab + 模态框 + 内联样式)
|
||||||
│ ├── apps.js # 工具 Tab-应用管理(APK/设备已装应用)
|
│ ├── login.html # 登录页
|
||||||
│ ├── admin.js # 管理 Tab(分组/日志/用户)+ 初始化
|
│ └── wall.html # 监控大屏(独立页面,自包含)
|
||||||
|
├── static/admin/ # 前端 JS(11 个文件,按顺序同步加载,见下)
|
||||||
├── static/fonts/ # 自托管字体(Bricolage Grotesque + IBM Plex Mono)
|
├── static/fonts/ # 自托管字体(Bricolage Grotesque + IBM Plex Mono)
|
||||||
│
|
│
|
||||||
├── data/
|
├── data/ # 运行时数据(不入 git)
|
||||||
│
|
│ ├── users.db # SQLite 主库
|
||||||
├── data/ # 运行时数据
|
│ ├── apks/ # 上传的 APK
|
||||||
│ ├── users.db # SQLite(用户/分组/任务/自定义动作/APK记录)
|
│ ├── backups/ # 导出 zip / 预恢复快照
|
||||||
│ └── apks/ # 上传的 APK 文件存储
|
│ ├── restore_staging/ # 导入暂存(TTL 30 分钟)
|
||||||
│
|
│ └── restore_pending/ # 待重启生效的恢复任务
|
||||||
├── logs/ # 日志文件(自动生成,10MB 滚动保留 5 份)
|
├── logs/ # 日志(10MB 滚动,保留 5 份)
|
||||||
├── bin/adb/ # adb 可执行文件(Windows: adb.exe + dll)
|
├── bin/adb/ # adb 二进制
|
||||||
├── scripts/ # 实用脚本
|
├── scripts/ # pack.py / start.sh / supervise.sh / regression_test.py
|
||||||
│ ├── pack.py # 打包项目为 zip(排除运行时产物)
|
└── doc/ # 完整文档(入口 → doc/README.md)
|
||||||
│ └── supervise.sh # 进程守护(崩溃自动重启)
|
```
|
||||||
└── doc/ # 项目文档
|
|
||||||
├── API.md # API 文档
|
前端 JS 加载顺序(`templates/admin/monitor.html` 底部,全部为全局脚本,无模块隔离):
|
||||||
├── ARCHITECTURE.md # 架构详解
|
|
||||||
├── DEPLOY.md # 部署指南
|
```
|
||||||
├── DEVELOPMENT.md # 开发指南
|
base.js → markdown.js → list.js → monitor.js → editor.js → tasks.js
|
||||||
└── TASK_DEV.md # 任务开发指南(新增 App 任务模板)
|
→ tools.js → apps.js → admin.js → agent.js → system.js
|
||||||
├── ARCHITECTURE.md # 架构详解
|
|
||||||
├── DEPLOY.md # 部署指南
|
|
||||||
└── API.md # API 接口文档
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 核心概念
|
## 核心概念
|
||||||
|
|
||||||
### 设备生命周期
|
### 设备与 Worker
|
||||||
|
|
||||||
```
|
一台设备对应一个 `Worker` 线程(`core/device_worker.py` 的 `BaseWorker`)。基类封装了设备获取、adb/u2 连接(带超时保护)、状态上报、异常分类、停止信号、心跳看门狗;子类只实现 `run_task(d)`。
|
||||||
设备池选定设备(TaskManager._running 内存锁保证互斥)
|
|
||||||
↓
|
|
||||||
IP:5555 → adb connect 直连;USB(serial 无冒号)→ 本机 adb 或 220 远程 adb server
|
|
||||||
↓
|
|
||||||
u2.connect(连接 uiautomator2,自动推送 atx-agent)
|
|
||||||
↓
|
|
||||||
Worker.run_task(执行业务逻辑)
|
|
||||||
↓
|
|
||||||
释放(不 disconnect,遵守共享 adb transport 红线)
|
|
||||||
```
|
|
||||||
|
|
||||||
> 单实例部署下互斥由调度器内存锁保证;多实例场景可扩展 SQLite 行锁(见 doc/STF_REMOVAL.md 阶段 4)。
|
执行链路:`设备池选定 → adb connect 直连(IP:5555)或经 220 远程 adb server(USB)→ u2.connect(自动推送 atx-agent)→ run_task → 释放(不断开连接)`。
|
||||||
|
|
||||||
### Worker — 单设备执行线程
|
**同一设备同时只允许一个 Worker**:由 `TaskManager._running` 内存锁保证;任务可开 `preempt` 抢占(先停掉正在跑的任务再接管,结束后归还)。
|
||||||
|
|
||||||
每台设备对应一个 `Worker` 线程,继承 `BaseWorker`(`core/device_worker.py`)。基类已封装:
|
### 任务类型(TaskType)
|
||||||
|
|
||||||
- 设备获取(IP:5555 直连 / USB 远程 adb server,try/finally 保证释放)
|
| task_type | 名称 | 说明 |
|
||||||
- adb 连接 + u2.connect(带 30 秒超时保护)
|
|-----------|------|------|
|
||||||
- 状态上报(实时推送到前端监控大屏)
|
| `generic_steps` | 通用步骤 | 步骤编辑器编排的流程(**当前唯一类型**) |
|
||||||
- 异常捕获(设备离线不重试,其他异常按策略重试)
|
|
||||||
- stop 停止信号(循环里检查 `self.stopped()`)
|
|
||||||
- 心跳看门狗(120 秒无心跳自动标记卡死)
|
|
||||||
|
|
||||||
子类只需实现 `run_task(d)` 方法专注业务逻辑。
|
新增专属任务类型的方法见 [doc/TASK_DEV.md](doc/TASK_DEV.md)。
|
||||||
|
|
||||||
### TaskType — 任务类型
|
### 任务计划(TaskJob)
|
||||||
|
|
||||||
| 任务类型 | task_type | 说明 |
|
描述"什么任务、跑哪些设备、什么参数、何时跑、失败怎么重试":
|
||||||
|---------|-----------|------|
|
|
||||||
| 抖音养号 | `douyin_nurture` | 自动看视频 + 随机点赞,被退出自动重连 |
|
|
||||||
| 通用步骤 | `generic_steps` | 可视化步骤编辑器编排流程,支持循环/点击/滑动等 |
|
|
||||||
|
|
||||||
### TaskJob — 任务计划
|
| 字段 | 说明 |
|
||||||
|
|
||||||
一个 TaskJob 描述"什么时候、在哪些设备上、用什么参数执行什么任务":
|
|
||||||
|
|
||||||
| 字段 | 说明 | 示例 |
|
|
||||||
|------|------|------|
|
|
||||||
| `task_type` | 任务类型 | `"douyin_nurture"` |
|
|
||||||
| `target` | 目标设备 | `{"mode": "all"}` 或 `{"mode": "group", "group_name": "A组"}` 或 `{"mode": "serial", "serial": "192.168.1.100:5555"}` |
|
|
||||||
| `params` | 任务参数(与默认值深合并) | `{"watch_count": 50, "actions": {"like": {"params": {"rate": 0.5}}}}` |
|
|
||||||
| `schedule` | 调度策略 | `{"mode": "once"}` 或 `{"mode": "cron", "cron": "0 9 * * *"}` 或 `{"mode": "cron_stop", "cron": "0 9 * * *", "stop_cron": "0 18 * * *"}` |
|
|
||||||
| `retry` | 重试策略 | `{"max_attempts": 3, "delay": 60}` |
|
|
||||||
| `enabled` | 是否启用 | `true` |
|
|
||||||
|
|
||||||
### 调度模式
|
|
||||||
|
|
||||||
| 模式 | 行为 |
|
|
||||||
|------|------|
|
|------|------|
|
||||||
| `once` | 手动执行(前端点"立即执行") |
|
| `task_type` | 任务类型(不传默认 `generic_steps`) |
|
||||||
| `cron` | 定时启动:到 cron 时间点自动启动所有目标设备 |
|
| `target` | `{"mode":"all"}` / `{"mode":"group","group_name":"A组"}` / `{"mode":"serial","serial":"100.100.10.20:5555"}` |
|
||||||
| `cron_stop` | 定时启停:启动 cron 到点启动,停止 cron 到点停止本任务 worker |
|
| `params` | 任务参数,与任务类默认值合并;含两个隐藏开关 `skip_offline`(默认 true)、`preempt`(默认 false) |
|
||||||
|
| `schedule` | `{"mode":"once"}` / `{"mode":"cron","cron":"0 9 * * *"}` / `{"mode":"cron_stop","cron":...,"stop_cron":...}`,可选 `window` 运行窗口 |
|
||||||
|
| `retry` | `{"max_attempts":1,"delay":60}` |
|
||||||
|
| `enabled` | 是否参与调度 |
|
||||||
|
|
||||||
**简单设置**:编辑任务时选"定时启动/定时启动+停止"后,频率用下拉选择——每天(选时间)、每小时(整点)、每隔 N 小时、每周(选星期+时间)——cron 表达式自动生成,不需要懂 cron 语法。老手可在"自定义 cron(高级)"里直接填。
|
**调度模式**:`once` 仅手动;`cron` 到点启动;`cron_stop` 到点启动 + 到点停止。cron 为标准 5 段 `分 时 日 月 周`,编辑器提供"每天/每小时/每隔 N 小时/每周"的可视化选择自动生成,也可手填。
|
||||||
|
|
||||||
cron 表达式为标准 5 段格式:`分 时 日 月 周`(如 `0 9 * * *` = 每天 9:00,`0 */2 * * *` = 每 2 小时整点,周 `0`/`7` 均为周日)
|
**运行窗口**(`schedule.window`,如 `21:00-09:00`,支持跨午夜):窗口外定时触发与手动执行都不启动。
|
||||||
|
|
||||||
**运行窗口**:任务可勾选"启用运行窗口",设置每天允许运行的时间段(如 `21:00-09:00` = 晚 9 点到次日早 9 点,支持跨午夜)。窗口外**定时触发和手动执行都不会启动**(手动执行会提示"当前不在运行窗口内")。典型用法:`每小时`定时 + 窗口 `09:00-21:00`,即只在白天每小时跑一次。
|
**目标解析**(`TaskJob.resolve_serials`):`all` = 设备池 ∩ 在线(开了抢占则取全部在线池内设备);`group` = 分组 ∩ 设备池;`serial` = 指定设备。后两者默认跳过离线设备。
|
||||||
|
|
||||||
### 设备分组
|
|
||||||
|
|
||||||
设备分组存于 SQLite,便于按批次/项目分组下发任务。一个 Job 指定 `target.mode="group"` 时,调度器展开为组内全部设备。
|
|
||||||
|
|
||||||
### 进度上报
|
### 进度上报
|
||||||
|
|
||||||
Worker 通过 `self.set_progress()` 上报通用进度字段,前端统一解析展示:
|
Worker 通过 `set_progress()` 上报统一字段,前端统一渲染:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
self.set_progress(done=5, total=80, unit="视频",
|
self.set_progress(done=5, total=80, unit="视频", action_counts={"like": 3}, elapsed=120)
|
||||||
action_counts={"like": 3})
|
|
||||||
```
|
```
|
||||||
|
|
||||||
前端展示:进度条 + `5/80 视频` + `点赞 3` 徽章。
|
前端展示为进度条 + `5/80 视频` + 计数徽章 + 运行时长。
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 配置说明
|
|
||||||
|
|
||||||
所有核心配置在 [config.py](file:///d:/platform-tools/config.py),**任务参数不放在这里**(放各自 `tasks/xxx.py` 顶部)。
|
|
||||||
|
|
||||||
| 配置项 | 默认值 | 说明 |
|
|
||||||
|-------|--------|------|
|
|
||||||
| `ADB_PATH` | 自动识别 | adb 二进制路径,自动区分 Windows/Linux |
|
|
||||||
| `WEB_HOST` | `0.0.0.0` | Web 监听地址(0.0.0.0 支持局域网访问) |
|
|
||||||
| `WEB_PORT` | `18050` | Web 端口(避开 Windows 动态端口范围) |
|
|
||||||
| `DATA_DIR` | `data/` | 持久化数据目录 |
|
|
||||||
| `APK_DIR` | `data/apks/` | APK 文件存储目录 |
|
|
||||||
| `USB_ADB_HOST` | `100.100.10.1` | USB 设备所在部署机(220)的 Tailscale IP(USB 设备远程 adb server) |
|
|
||||||
| `USB_ADB_PORT` | `5037` | 220 adb 容器监听端口(host 网络模式) |
|
|
||||||
| `TAILSCALE_API_KEY` | (.env 配置) | Tailscale 管理 API key(Settings → API Access Tokens) |
|
|
||||||
| `TAILSCALE_TAILNET` | 按邮箱前缀 | tailnet 名/ID |
|
|
||||||
|
|
||||||
**必须修改的配置**:`.env` 里的 `WEB_SECRET_KEY`(会话密钥,不入 git);其余均为可选(默认值开箱即用)。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Web 管理后台
|
## Web 管理后台
|
||||||
|
|
||||||
### 页面结构
|
单页应用(`templates/admin/monitor.html`),**7 个顶级 Tab**;「任务 / 工具 / 系统」内部还有页内子分栏(会记住上次选中位置)。
|
||||||
|
|
||||||
单页应用(`templates/admin/monitor.html`),6 个 Tab;任务/工具 Tab 内部再有页内子分栏:
|
| Tab | 内容 | 可见性 |
|
||||||
|
|
||||||
| Tab | 功能 | 可见性 |
|
|
||||||
|-----|------|--------|
|
|-----|------|--------|
|
||||||
| 监控 | 设备状态大屏:在线/离线、型号、运行任务、当前动作、进度条、前台 App、截图、勾选批量操作(亮屏/息屏/停止);导航栏「📺 大屏」打开全屏监控墙(/wall,缩略图+统计+时钟,挂墙/电视用) | 所有登录用户(设备操作按钮需"设备控制"权限) |
|
| **监控** | 统计卡片 + 设备表(在线/型号/任务状态/进度/前台 App/最近错误,支持排序、搜索、分页、多选批量操作)+ 异常汇总 + **任务运行概况**(每张任务卡:执行任务 / 停用任务 + 覆盖设备彩色 chip);导航栏「📺 大屏」打开 `/wall` | 所有登录用户(设备操作按钮需"设备控制") |
|
||||||
| 任务 | 子分栏:任务计划(CRUD/启用停用/立即执行/下次运行时间/**离线设备自动跳过**)、自定义动作(打包复用) | 所有登录用户可看,写操作需"任务管理"权限 |
|
| **任务** | 子分栏:**任务计划**(CRUD / 启停 / 执行 / 下次运行时间 / 离线跳过 / 步骤编辑器)、**自定义动作**(步骤打包复用) | 可看;写操作需"任务管理" |
|
||||||
| 分组 | 设备分组管理:创建/编辑/删除分组 | 所有登录用户可看,写操作需"任务管理"权限 |
|
| **日志** | 实时日志(按文件切换、可自动刷新) | 需"日志查看" |
|
||||||
| 日志 | 实时日志查看:按模块切换(core/task/web/action) | 需"日志查看"权限 |
|
| **用户** | 用户 CRUD、改密、分配权限 | 仅管理员 |
|
||||||
| 用户 | 用户管理:创建/删除/修改密码/分配权限 | 仅管理员 |
|
| **工具** | 8 个子分栏:剪贴板注入 / adb 远程终端 / Tailscale 管理 / 应用管理 / 应用版本管理 / 设备已装应用 / **设备池管理**(含自动发现)/ 设备分组 | 仅管理员 |
|
||||||
| 工具 | 子分栏:剪贴板注入、adb 远程终端(快捷命令/自动 `-s`)、**设备池管理**(新增/停用/删除/一键重连/型号采集)、**远程看屏**(MJPEG 实时流 + 点击/滑动/按键/文字)、Tailscale 管理(改名/授权/密钥不过期/设置IP/auth key)、应用管理(APK 上传安装)、应用版本管理(按包名查所有设备版本)、设备已装应用 | 仅管理员 |
|
| **AI 控制台** | 会话列表 + 对话区(Markdown 渲染、推理链折叠、token 统计)+ 实时画面(MJPEG)+ 目标设备选择;右上角:经验库 / 动作库 / 模型配置 | 仅管理员 |
|
||||||
|
| **系统** | 子分栏:数据备份(导出 zip)/ 导入恢复(上传→校验预览→应用,重启生效) | 仅管理员 |
|
||||||
|
|
||||||
任务/工具 Tab 的子分栏会记住上次选中的位置;无权限的 tab 和按钮自动隐藏。
|
### 权限模型
|
||||||
|
|
||||||
### 用户与权限
|
默认账号 `admin/admin123`(管理员,权限不受限)。权限位:
|
||||||
|
|
||||||
默认账号 `admin` / `admin123`(管理员,拥有全部权限)。管理员可在"用户"Tab 创建普通用户并分配权限:
|
| 权限位 | 键 | 覆盖 |
|
||||||
|
|--------|----|------|
|
||||||
|
| 任务管理 | `tasks` | 任务/分组/自定义动作的写操作 |
|
||||||
|
| 设备控制 | `devices` | 停止设备、亮息屏、定位、清异常、前台扫描、元素抓取、步骤测试 |
|
||||||
|
| 应用管理 | `apks` | APK 上传/安装/删除 |
|
||||||
|
| 日志查看 | `logs` | 日志 Tab |
|
||||||
|
|
||||||
| 权限位 | 说明 | 覆盖功能 |
|
|
||||||
|--------|------|---------|
|
|
||||||
| 任务管理 `tasks` | 任务/自定义动作/分组的增删改、启停、立即执行 | 任务 Tab、分组 Tab 的写操作 |
|
|
||||||
| 设备控制 `devices` | 停止设备、释放占用、清除异常、前台扫描、元素抓取 | 监控 Tab 的设备操作按钮、步骤编辑器的"抓取元素" |
|
|
||||||
| 应用管理 `apks` | APK 上传、安装、删除 | 应用管理/设备已装应用(当前并入工具 Tab,工具页整体仅管理员可见) |
|
|
||||||
| 日志查看 `logs` | 日志页 | 日志 Tab |
|
|
||||||
|
|
||||||
规则:
|
|
||||||
- **管理员拥有全部权限**,不受权限位限制
|
|
||||||
- 查看类接口(设备/任务/分组列表、状态、截图)所有登录用户可用
|
- 查看类接口(设备/任务/分组列表、状态、截图)所有登录用户可用
|
||||||
- **用户管理仅管理员可用**;不能删除/取消最后一个管理员
|
- 后端 `@perm_required` / `@admin_required` 拦截并返回 403;前端用 `data-perm` 属性与 `_can(perm)` 隐藏入口(**仅隐藏,安全依赖后端**)
|
||||||
- 无权限的 tab 和按钮在界面上自动隐藏(后端同样拦截,返回 403)
|
- 不能删除/降级最后一个管理员
|
||||||
|
|
||||||
### 前台 App 扫描
|
|
||||||
|
|
||||||
监控页"扫描前台App"按钮,获取所有设备当前前台 App。**不打扰设备**:
|
|
||||||
|
|
||||||
| 设备状态 | 处理方式 |
|
|
||||||
|---------|---------|
|
|
||||||
| worker 运行中(IP:5555) | 复用已有 ADB 连接查询 |
|
|
||||||
| worker 运行中(USB) | 经 220 远程 adb server 查询 |
|
|
||||||
| 完全空闲 | 返回"空闲"(不主动 connect,避免扰动共享 adb transport) |
|
|
||||||
|
|
||||||
### 截图功能
|
|
||||||
|
|
||||||
监控页每台设备可查看实时截图。用 `adb exec-out screencap -p`,只读操作,**任务运行中也能安全调用**(不抢占 u2 的 atx-agent 通道)。
|
|
||||||
|
|
||||||
### 元素抓取
|
|
||||||
|
|
||||||
任务编辑器的"抓取元素"按钮可拉取设备当前 UI 元素树,点击元素一键回填选择器。依赖本地运行的 uiautodev 服务(端口 20242),`web_server.py` 启动时会自动拉起。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 任务系统
|
## 任务与步骤
|
||||||
|
|
||||||
### 抖音养号(douyin_nurture)
|
`generic_steps` 的执行内容全在 `params.steps`(JSON 数组),由步骤编辑器产出。**没有默认步骤**——空步骤任务执行时会明确报错。
|
||||||
|
|
||||||
自动观看抖音视频,按配置随机执行点赞操作。参数(在 `tasks/douyin/task.py` 顶部):
|
**18 种步骤**(完整参数见 [doc/TASK_DEV.md](doc/TASK_DEV.md)):
|
||||||
|
|
||||||
| 参数 | 默认值 | 说明 |
|
| 类别 | 步骤 |
|
||||||
|
|------|------|
|
||||||
|
| 屏幕 | `screen_on` 亮屏 · `screen_off` 息屏 · `keep_screen` 保持亮屏 |
|
||||||
|
| 应用与输入 | `open_app` 打开 App · `stop_app` 结束 App · `input_text` 输入文字 · `clipboard` 剪贴板注入 · `key_event` 按键 |
|
||||||
|
| 交互 | `click` 点击元素 · `click_xy` 点击坐标 · `long_click` 长按 · `swipe` 滑动 · `swipe_until` 滑动直到元素出现 · `wait_el` 等待元素 · `wait` 等待时长 |
|
||||||
|
| 流程 | `loop` 循环块 · `group` 动作组 · `if_el` 条件判断(元素或 **OCR 识别**) |
|
||||||
|
|
||||||
|
要点:
|
||||||
|
|
||||||
|
- 每一步都可有 `probability`(0-100,默认 100)决定本次是否执行
|
||||||
|
- 容器类步骤(`loop`/`group`/`if_el`)可嵌套,**深度上限 5 层**
|
||||||
|
- 选择器支持 `xpath` / `description` / `text` / `resourceId` / `descriptionContains` / `className`(`ocr` 仅条件判断)
|
||||||
|
- **优先用文字/id 定位**,坐标 (`click_xy`) 是最脆的方式
|
||||||
|
- 未知步骤类型、缺必填参数只告警跳过,不会中断任务链(排查时留意"看起来成功但没做事")
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AI 与 MCP
|
||||||
|
|
||||||
|
### AI 控制台
|
||||||
|
|
||||||
|
在「AI 控制台」选一台设备,用自然语言下指令,AI 通过 MCP 工具看屏幕、点按、输入,边做边把过程流式显示出来。配置(模型 / API Key / 默认设备 / 最大步数)存在数据库 `app_meta`,不落 `.env`。
|
||||||
|
|
||||||
|
自带两个"自进化记忆":
|
||||||
|
|
||||||
|
- **经验库**:任务成功后把操作套路蒸馏成配方,下次相似任务自动召回注入;每日 03:47 由 AI 巡检建议清理(删除永远需人工确认)
|
||||||
|
- **动作库**:把成功步骤沉淀为带元素定位的命名动作(禁坐标),可复用、可编辑
|
||||||
|
|
||||||
|
详细机制见 [doc/AI_CONSOLE.md](doc/AI_CONSOLE.md)。
|
||||||
|
|
||||||
|
### MCP(外部 AI 接入)
|
||||||
|
|
||||||
|
MCP Server 监听 `:8033`,暴露 19 个 `de_*` 工具(截屏、点击、滑动、输入、OCR、元素树、应用管理等)。写操作需 `MCP_ALLOW_WRITE=1`;设备正在跑任务时拒绝(`device_busy`);每次调用写审计日志。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 本机手动启动(生产容器由 scripts/start.sh 自动拉起)
|
||||||
|
MCP_ALLOW_WRITE=1 MCP_PLATFORM_USER=admin MCP_PLATFORM_PASS=<密码> \
|
||||||
|
python -m mcp_server.mcp_server
|
||||||
|
```
|
||||||
|
|
||||||
|
工具清单与接入示例见 [doc/MCP.md](doc/MCP.md)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 配置说明
|
||||||
|
|
||||||
|
**密钥类配置统一放项目根 `.env`**(不入 git;模板见 [.env.example](.env.example))。加载规则:逐行解析后用 `os.environ.setdefault` 注入,**真实环境变量优先**。
|
||||||
|
|
||||||
|
| 配置 | 默认值 | 说明 |
|
||||||
|------|--------|------|
|
|------|--------|------|
|
||||||
| `watch_count` | 80 | 观看视频数量(0=不限,靠时长停止) |
|
| `WEB_SECRET_KEY` | 未配置则随机 | 会话密钥;不配则每次重启登录态失效(**生产必须固定**) |
|
||||||
| `watch_min` / `watch_max` | 5.0 / 35.0 | 单视频观看时长范围(秒) |
|
| `WEB_HOST` / `WEB_PORT` | `0.0.0.0` / `18050` | Web 监听(**常量,改 config.py**) |
|
||||||
| `max_duration` | 0 | 最大运行时长(秒),0=不限时 |
|
| `ADB_PATH` | 自动识别 `bin/adb/` | adb 二进制(常量,按平台自动选) |
|
||||||
| `swipe_min` / `swipe_max` | 0.25 / 0.50 | 上滑手势时长范围(秒) |
|
| `DATA_DIR` / `APK_DIR` | `data/` / `data/apks/` | 持久化目录(常量) |
|
||||||
| `gap_min` / `gap_max` | 1.0 / 3.0 | 视频间隔时长范围(秒) |
|
| `USB_ADB_HOST` / `USB_ADB_PORT` | `100.100.10.1` / `5037` | USB 设备所在的部署机(远程 adb server) |
|
||||||
| `actions.like.enabled` | true | 是否启用点赞 |
|
| `DISCOVERY_PORT` / `DISCOVERY_INTERVAL` | `5555` / `60` | 设备自动发现(网段也在工具页配置,存 `app_meta`) |
|
||||||
| `actions.like.params.rate` | 0.3 | 点赞概率(0~1) |
|
| `TAILSCALE_API_KEY` / `TAILSCALE_TAILNET` | 空 | Tailscale 管理功能 |
|
||||||
| `actions.like.params.method` | by_element | 点赞方式:by_element(找红心) / double_click(双击) |
|
| `WEB_SECRET_KEY` 之外的一切密钥 | — | 一律进 `.env`,**不要写进 config.py** |
|
||||||
|
|
||||||
终止条件(哪个先到就停):`watch_count` 数量到 / `max_duration` 时长到 / 手动停止。
|
> **任务参数不放 config.py**——放各自 `tasks/<name>/task.py` 顶部的 `DEFAULT_PARAMS`。
|
||||||
|
|
||||||
### 通用步骤(generic_steps)
|
完整键表(含 `MCP_*` / `AGENT_*`)见 [doc/DEVELOPMENT.md](doc/DEVELOPMENT.md) §配置速查。
|
||||||
|
|
||||||
通过可视化步骤编辑器编排任务流程,worker 按步骤顺序执行。支持的步骤类型:
|
|
||||||
|
|
||||||
| 步骤类型 | 说明 |
|
|
||||||
|---------|------|
|
|
||||||
| `open_app` | 启动 App(指定包名,可选等待首页) |
|
|
||||||
| `stop_app` | 强制结束 App(am force-stop,清后台,下次打开冷启动) |
|
|
||||||
| `screen_on` | 亮屏(息屏时唤醒并滑动解锁) |
|
|
||||||
| `screen_off` | 息屏 |
|
|
||||||
| `keep_screen` | 保持亮屏/恢复自动息屏(充电时屏幕常亮,适合长任务) |
|
|
||||||
| `key_event` | 按键:返回/Home/回车/菜单等(退出评论、返回上一页) |
|
|
||||||
| `swipe` | 滑动(上/下/左/右,可配置时长) |
|
|
||||||
| `swipe_until` | 滑动直到元素出现(最多 N 次,可选找到后点击) |
|
|
||||||
| `click_xy` | 点击坐标(屏幕百分比,无选择器时兜底) |
|
|
||||||
| `long_click` | 长按元素(选择器 + 时长) |
|
|
||||||
| `wait_el` | 等待元素出现(条件等待,替代固定时长) |
|
|
||||||
| `input_text` | 输入文字(随机候选/指定文字,可选输入前先清空) |
|
|
||||||
| `click` | 点击元素(支持 xpath/description/resourceId/text 选择器) |
|
|
||||||
| `wait` | 等待(可配置时长范围) |
|
|
||||||
| `loop` | 循环块(含子步骤,可配置循环次数) |
|
|
||||||
| `group` | 动作组(含子步骤,按序执行一次) |
|
|
||||||
| `if_el` | 条件判断:找元素(支持 xpath 等选择器或 **OCR识别** 截屏匹配图片文字),命中执行"找到时"分支,未命中执行"未找到时"分支;OCR 命中可自动点击。分支可嵌套循环/条件判断 |
|
|
||||||
|
|
||||||
步骤编辑器特性:操作库按分类分组、卡片可拖拽排序/跨层级嵌套(循环套循环)、☑ 多选打包自定义动作、
|
|
||||||
"测试此步骤"真机验证、"抓取元素"回填选择器、条件判断的 OCR 识别依赖 `rapidocr_onnxruntime`(跨平台)。
|
|
||||||
|
|
||||||
### 新增 App 任务
|
|
||||||
|
|
||||||
参照 `tasks/douyin/` 结构,6 步即可新增一个 App 任务,详见 [doc/TASK_DEV.md](doc/TASK_DEV.md)。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 日志系统
|
## 日志
|
||||||
|
|
||||||
日志按模块分文件,自动滚动(10MB 一份,保留 5 份历史):
|
按模块分文件,单文件 10MB 滚动、保留 5 份:
|
||||||
|
|
||||||
| 文件 | 模块前缀 | 内容 |
|
| 文件 | 前缀 | 内容 |
|
||||||
|------|---------|------|
|
|------|------|------|
|
||||||
| `logs/core.log` | `core.*` | adb/worker/task_manager/设备池 核心程序 |
|
| `logs/core.log` | `core.*` | adb / device_worker / task_manager / 设备池 |
|
||||||
| `logs/task.log` | `task.*` | 任务执行(worker 业务逻辑) |
|
| `logs/task.log` | `task.*` | 任务执行(worker 业务逻辑) |
|
||||||
| `logs/web.log` | `web.*` | Web 请求/管理 |
|
| `logs/web.log` | `web.*` | Web 请求与管理操作 |
|
||||||
| `logs/action.log` | `action.*` | 操作执行(点赞/评论等) |
|
| `logs/action.log` | `action.*` | 操作执行 |
|
||||||
|
|
||||||
使用方式:
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from core.logger import get_logger
|
from core.logger import get_logger
|
||||||
log = get_logger("task.douyin") # 写入 task.log
|
log = get_logger("task.generic") # → logs/task.log
|
||||||
log.info(f"[{self.serial}] 开始任务")
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Web 后台"日志"Tab 可实时查看各模块日志。
|
「日志」Tab 可在线查看。
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 常用脚本
|
|
||||||
|
|
||||||
| 脚本 | 用途 |
|
|
||||||
|------|------|
|
|
||||||
| `python web_server.py` | 启动 Web 管理后台 |
|
|
||||||
| `python scripts/pack.py` | 打包项目为 zip(排除日志/数据库/APK) |
|
|
||||||
| `bash scripts/supervise.sh` | 进程守护(web_server 崩溃自动重启) |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 常见问题
|
## 常见问题
|
||||||
|
|
||||||
### 端口被占用(WinError 10013/10048)
|
| 现象 | 处理 |
|
||||||
|
|------|------|
|
||||||
`web_server.py` 会自动重试候选端口(原端口 → 127.0.0.1:原端口 → 127.0.0.1:原端口+1~+5)。如果全部失败,检查 Windows 动态端口范围:
|
| 端口被占用(WinError 10013/10048) | 启动会自动回退候选端口(`18050` → `127.0.0.1:18050` → `127.0.0.1:18051..18055`);仍失败则改 `WEB_PORT`(注意 Windows 动态端口排除段) |
|
||||||
|
| 设备显示离线 | 「工具 → 设备池管理」一键重连;确认设备在线且同一网络(生产走 Tailscale 同 tailnet) |
|
||||||
```bash
|
| 新加的设备不被调度 | 必须**加入设备池**(仅 adb 连上不算);确认该设备为"启用"状态 |
|
||||||
netsh interface ipv4 show excludedportrange protocol=tcp
|
| `u2.connect` 卡死 | 基类有 30s 超时保护;频繁超时说明 atx-agent 异常,重启设备或重新推送 |
|
||||||
```
|
| 任务运行中被看门狗标记卡死 | 看门狗 120s 无心跳即判定;长循环内需周期性 `self.heartbeat()` |
|
||||||
|
| 任务"成功"但什么都没做 | 步骤类型未知/缺必填参数会被**告警跳过**;查 `logs/task.log` 的 WARNING |
|
||||||
修改 `config.py` 中的 `WEB_PORT` 到一个不在排除范围内的端口。
|
| 中文输入失败 | u2 默认 IME 不支持中文,需 `set_fastinput_ime(True)`(`input_text` 步骤已处理) |
|
||||||
|
| 改了 `core/` 不生效 | 服务以 `debug=False` 运行、不热重载;**必须重启**。前端改动还需强刷浏览器(Ctrl+Shift+R) |
|
||||||
### 设备显示离线
|
| 元素抓取按钮不可用 | 依赖本机 uiautodev(:20242),`web_server.py` 启动时自动拉起;未安装时 `pip install uiautodev` |
|
||||||
|
|
||||||
先确认本机 `adb devices` 能看到设备(工具 → 设备池管理 → 一键重连);看不到则检查 Tailscale 是否在线、设备是否加入了正确的 tailnet(平台与设备必须在同一 tailnet)。
|
|
||||||
|
|
||||||
### 新增设备后任务不调度它
|
|
||||||
|
|
||||||
新设备需在「工具 → 设备池管理」添加(serial 为 `IP:5555`),仅连上 adb 不会进入设备池。添加后自动连接并采集型号,下一轮任务即可调度。
|
|
||||||
|
|
||||||
### u2.connect 卡死
|
|
||||||
|
|
||||||
`u2.connect()` 在 atx-agent 无响应时会永久 hang。基类已用 `ThreadPoolExecutor + 30 秒超时` 保护,超时自动放弃。如果频繁超时,检查设备 atx-agent 是否正常(重启设备或重新推送 atx-agent)。
|
|
||||||
|
|
||||||
### 任务运行中看门狗误杀
|
|
||||||
|
|
||||||
看门狗 120 秒无心跳会标记卡死。长耗时操作(如长视频等待)需在循环内周期性调用 `self.heartbeat()`。
|
|
||||||
|
|
||||||
### 中文输入失败
|
|
||||||
|
|
||||||
uiautomator2 默认 IME 不支持中文,需切到 FastInput 输入法:
|
|
||||||
|
|
||||||
```python
|
|
||||||
try:
|
|
||||||
d.set_fastinput_ime(True)
|
|
||||||
d.send_keys("中文内容")
|
|
||||||
finally:
|
|
||||||
d.set_fastinput_ime(False)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 修改 core/ 后不生效
|
|
||||||
|
|
||||||
`web_server.py` 以 `debug=False` 运行,Python 不会热重载。修改 `core/` 目录下的文件后**必须重启 web_server 进程**。
|
|
||||||
|
|
||||||
### 元素抓取按钮不可用
|
|
||||||
|
|
||||||
依赖 uiautodev 服务(端口 20242)。确保已安装 `pip install uiautodev`。`web_server.py` 启动时会自动拉起该服务。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 更多文档
|
## 文档索引
|
||||||
|
|
||||||
|
**完整文档入口 → [doc/README.md](doc/README.md)**(文档地图、阅读路径、维护约定)
|
||||||
|
|
||||||
| 文档 | 内容 |
|
| 文档 | 内容 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| [doc/DEVELOPMENT.md](doc/DEVELOPMENT.md) | **开发手册**:开发流程、git 工作流、技术红线、环境、本地开发 |
|
| [doc/ARCHITECTURE.md](doc/ARCHITECTURE.md) | 架构详解(分层、装配顺序、线程模型、设备生命周期、调度链路、设计决策) |
|
||||||
| [doc/TASK_DEV.md](doc/TASK_DEV.md) | 任务开发指南(新增 App 任务的完整模板和规范) |
|
| [doc/DATA_MODEL.md](doc/DATA_MODEL.md) | 数据模型(表结构、迁移、`app_meta`、数据目录、备份覆盖清单) |
|
||||||
| [doc/ARCHITECTURE.md](doc/ARCHITECTURE.md) | 架构详解(分层设计、数据流、关键设计决策) |
|
| [doc/API.md](doc/API.md) | HTTP 接口全量说明 |
|
||||||
| [doc/DEPLOY.md](doc/DEPLOY.md) | 部署指南(环境准备、设备池配置、生产部署) |
|
| [doc/TASK_DEV.md](doc/TASK_DEV.md) | 任务与步骤开发(18 种步骤、选择器、自定义动作、新增任务类型) |
|
||||||
| [doc/STF_REMOVAL.md](doc/STF_REMOVAL.md) | STF 摘除迁移记录(阶段 0-3 已完成,含多实例锁方案) |
|
| [doc/MCP.md](doc/MCP.md) · [doc/MCP_DESIGN.md](doc/MCP_DESIGN.md) | MCP 使用手册 / 设计文档 |
|
||||||
| [doc/API.md](doc/API.md) | API 接口文档(全部 HTTP 接口说明) |
|
| [doc/AI_CONSOLE.md](doc/AI_CONSOLE.md) · [doc/AI_TASK_GEN.md](doc/AI_TASK_GEN.md) | AI 控制台机制 / AI 建任务设计(规划) |
|
||||||
|
| [doc/DEPLOY.md](doc/DEPLOY.md) | 部署与运维(含生产容器、备份导入、故障排查) |
|
||||||
|
| [doc/DEVELOPMENT.md](doc/DEVELOPMENT.md) | 开发手册(git 流程、技术红线、本地开发、文档同步约定) |
|
||||||
|
| [doc/STF_REMOVAL.md](doc/STF_REMOVAL.md) | 摘除 OpenSTF 的历史记录 |
|
||||||
|
|
||||||
> **注意**:所有 git 操作(含 push 到 dev)都需负责人确认后才能执行,详见开发手册。修改 `core/`、`tasks/`、`templates/` 后需重启服务/强刷浏览器才生效。
|
> ⚠️ **改动必须同步文档**:功能/配置/接口/表结构的任何增删改,都要在同一个 commit 里更新对应文档(红线,详见 [doc/README.md](doc/README.md) §3)。
|
||||||
|
> ⚠️ **git 操作需负责人确认**(含 push 到 dev),详见 [doc/DEVELOPMENT.md](doc/DEVELOPMENT.md)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -458,10 +398,10 @@ finally:
|
|||||||
|
|
||||||
| 组件 | 用途 |
|
| 组件 | 用途 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| Flask + Flask-Login | Web 后台 + 用户认证 |
|
| Flask + Flask-Login + Flask-SQLAlchemy | Web 后台 / 认证 / SQLite ORM |
|
||||||
| Flask-SQLAlchemy | SQLite 数据持久化 |
|
| APScheduler | 定时任务调度(任务 + 经验巡检两个独立调度器) |
|
||||||
| APScheduler | 定时任务调度 |
|
| uiautomator2 / uiautodev | Android UI 自动化 / 元素抓取 |
|
||||||
| uiautomator2 | Android UI 自动化 |
|
| rapidocr_onnxruntime (+ opencv-headless) | 屏幕 OCR |
|
||||||
| uiautodev | UI 元素抓取(步骤编辑器"抓取元素") |
|
|
||||||
| pyaxmlparser | APK 元信息解析 |
|
| pyaxmlparser | APK 元信息解析 |
|
||||||
| 自建设备池 | 设备清单/在线状态/型号(SQLite + adb) |
|
| fastmcp | MCP Server(Streamable HTTP)+ AI 控制台 Agent |
|
||||||
|
| adb(项目自带 `bin/adb/`) | 设备连接与底层操作 |
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
"""core 包:基础设施层。
|
"""core 包:基础设施层。
|
||||||
|
|
||||||
config — 全局配置常量(STF、adb 路径、养号参数)
|
config — 全局配置常量(adb 路径、设备发现等)
|
||||||
adb_helper — adb 命令封装(并发安全)
|
adb_helper — adb 命令封装(并发安全)
|
||||||
device_worker — 设备生命周期 + 养号 worker + 全局状态注册表
|
device_worker — 设备生命周期 + 任务 worker 基类 + 全局状态注册表
|
||||||
task_manager — 任务管理框架(分组/计划/调度/重试/持久化)
|
task_manager — 任务管理框架(分组/计划/调度/重试/持久化)
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""全局操作框架:所有 app 任务共用的 Action 基类与注册机制。
|
"""全局操作框架:所有 app 任务共用的 Action 基类与注册机制。
|
||||||
|
|
||||||
为什么放这里:
|
为什么放这里:
|
||||||
抖音的点赞/评论 xpath 只适用抖音,留在 tasks/douyin/actions/。
|
某个 app 专属的 xpath 只适用那个 app,留在 tasks/<app>/actions/。
|
||||||
但"操作"的抽象(BaseAction 接口、概率触发、注册机制)是通用的,
|
但"操作"的抽象(BaseAction 接口、概率触发、注册机制)是通用的,
|
||||||
放这里让所有 app 任务共享同一套操作开发范式。
|
放这里让所有 app 任务共享同一套操作开发范式。
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
设计要点:
|
设计要点:
|
||||||
1. BaseAction 定义统一接口:execute(d, params, worker) -> bool
|
1. BaseAction 定义统一接口:execute(d, params, worker) -> bool
|
||||||
2. 每个 app 任务有自己的注册表(create_action_registry),
|
2. 每个 app 任务有自己的注册表(create_action_registry),
|
||||||
互不污染——抖音的 like 和快手的 like 各注册各的。
|
互不污染——A 应用的 like 和 B 应用的 like 各注册各的。
|
||||||
3. 概率触发、状态上报等通用逻辑放这里,子类不重复写。
|
3. 概率触发、状态上报等通用逻辑放这里,子类不重复写。
|
||||||
4. ActionContext 封装执行上下文,方便扩展(传 d/worker/进度/计数器)。
|
4. ActionContext 封装执行上下文,方便扩展(传 d/worker/进度/计数器)。
|
||||||
|
|
||||||
新增 app 专属操作步骤(照着 tasks/douyin/actions/like.py 抄):
|
新增 app 专属操作步骤(在 tasks/<app>/actions/ 下新建,参考该包已有实现):
|
||||||
|
|
||||||
1. 在 tasks/<app>/actions/ 下新建 my_action.py
|
1. 在 tasks/<app>/actions/ 下新建 my_action.py
|
||||||
2. 写一个 BaseAction 子类,用 @register_action 装饰,实现 execute:
|
2. 写一个 BaseAction 子类,用 @register_action 装饰,实现 execute:
|
||||||
|
|||||||
+1
-1
@@ -41,7 +41,7 @@ def get_logger(name="core"):
|
|||||||
"""获取指定模块的 logger。
|
"""获取指定模块的 logger。
|
||||||
|
|
||||||
name: 模块名(core/task/web/action),决定写哪个文件。
|
name: 模块名(core/task/web/action),决定写哪个文件。
|
||||||
也可传子模块名如 "task.douyin",会归到 task.log。
|
也可传子模块名如 "task.generic",会归到 task.log。
|
||||||
返回配置好的 logging.Logger。
|
返回配置好的 logging.Logger。
|
||||||
"""
|
"""
|
||||||
if name in _LOGGERS:
|
if name in _LOGGERS:
|
||||||
|
|||||||
+2
-2
@@ -128,7 +128,7 @@ class TaskJob(db.Model):
|
|||||||
"""
|
"""
|
||||||
id = db.Column(db.String(32), primary_key=True) # uuid 前 8 位
|
id = db.Column(db.String(32), primary_key=True) # uuid 前 8 位
|
||||||
name = db.Column(db.String(120), nullable=False)
|
name = db.Column(db.String(120), nullable=False)
|
||||||
task_type = db.Column(db.String(60), default="douyin_nurture")
|
task_type = db.Column(db.String(60), default="generic_steps")
|
||||||
target = db.Column(db.Text, default='{"mode":"all"}') # JSON
|
target = db.Column(db.Text, default='{"mode":"all"}') # JSON
|
||||||
params = db.Column(db.Text, default="{}") # JSON
|
params = db.Column(db.Text, default="{}") # JSON
|
||||||
schedule = db.Column(db.Text, default='{"mode":"once"}') # JSON
|
schedule = db.Column(db.Text, default='{"mode":"once"}') # JSON
|
||||||
@@ -384,7 +384,7 @@ def _migrate_old_json():
|
|||||||
del actions["comment"]
|
del actions["comment"]
|
||||||
_log.info(f"迁移任务 {j.get('name')}: 已剔除废弃的 comment 配置")
|
_log.info(f"迁移任务 {j.get('name')}: 已剔除废弃的 comment 配置")
|
||||||
row = TaskJob(id=j["id"], name=j["name"],
|
row = TaskJob(id=j["id"], name=j["name"],
|
||||||
task_type=j.get("task_type", "douyin_nurture"),
|
task_type=j.get("task_type", "generic_steps"),
|
||||||
enabled=j.get("enabled", True))
|
enabled=j.get("enabled", True))
|
||||||
row.set_target(j.get("target", {"mode": "all"}))
|
row.set_target(j.get("target", {"mode": "all"}))
|
||||||
row.set_params(params)
|
row.set_params(params)
|
||||||
|
|||||||
+21
-2
@@ -38,16 +38,21 @@ DB_FILE = os.path.join(DATA_DIR, "users.db")
|
|||||||
# 判定「本平台备份库」的必需表(缺任何一张即拒绝导入)
|
# 判定「本平台备份库」的必需表(缺任何一张即拒绝导入)
|
||||||
REQUIRED_TABLES = ("app_meta", "user", "task_job", "device_group")
|
REQUIRED_TABLES = ("app_meta", "user", "task_job", "device_group")
|
||||||
# 预览/清单里展示行数的业务表(存在才统计)
|
# 预览/清单里展示行数的业务表(存在才统计)
|
||||||
|
# ⚠️ 红线:**新增任何持久化表,必须同步登记到这里**——备份覆盖清单以此为准,
|
||||||
|
# 漏登记的表在导出清单/预览里不可见(2026-09-10 动作库 agent_action 即因此
|
||||||
|
# 被误判为"没有备份":数据其实在 users.db 快照里,只是清单没列)。
|
||||||
SUMMARY_TABLES = (
|
SUMMARY_TABLES = (
|
||||||
"user", "device_group", "task_job", "custom_action",
|
"app_meta", "user", "device_group", "task_job", "custom_action",
|
||||||
"apk_file", "device", "pending_device",
|
"apk_file", "device", "pending_device",
|
||||||
"agent_conversation", "agent_experience", "experience_audit",
|
"agent_conversation", "agent_experience", "experience_audit", "agent_action",
|
||||||
)
|
)
|
||||||
TABLE_LABELS = {
|
TABLE_LABELS = {
|
||||||
|
"app_meta": "系统配置(app_meta)",
|
||||||
"user": "用户", "device_group": "设备分组", "task_job": "任务计划",
|
"user": "用户", "device_group": "设备分组", "task_job": "任务计划",
|
||||||
"custom_action": "自定义动作", "apk_file": "APK 记录", "device": "设备池",
|
"custom_action": "自定义动作", "apk_file": "APK 记录", "device": "设备池",
|
||||||
"pending_device": "待连接设备", "agent_conversation": "AI 会话",
|
"pending_device": "待连接设备", "agent_conversation": "AI 会话",
|
||||||
"agent_experience": "经验库", "experience_audit": "经验巡检",
|
"agent_experience": "经验库", "experience_audit": "经验巡检",
|
||||||
|
"agent_action": "动作库",
|
||||||
}
|
}
|
||||||
_STAGE_TTL = 1800 # 导入暂存有效期(秒)
|
_STAGE_TTL = 1800 # 导入暂存有效期(秒)
|
||||||
|
|
||||||
@@ -193,6 +198,13 @@ def create_export(include_apk=True):
|
|||||||
"tables": _summary_info(con),
|
"tables": _summary_info(con),
|
||||||
"apks": apk_meta,
|
"apks": apk_meta,
|
||||||
}
|
}
|
||||||
|
# 覆盖自检:登记在册的业务表若在快照里缺失(新增功能忘了登记 / 建表失败),
|
||||||
|
# 显式告警并写进 manifest——避免"以为备份了其实没有"(备份覆盖红线)。
|
||||||
|
_present = {t["table"] for t in manifest["tables"]}
|
||||||
|
_missing = [t for t in SUMMARY_TABLES if t not in _present]
|
||||||
|
if _missing:
|
||||||
|
manifest["coverage_missing"] = _missing
|
||||||
|
_log.warning(f"备份覆盖检查:以下登记表未纳入快照,请确认是否应备份: {_missing}")
|
||||||
finally:
|
finally:
|
||||||
con.close()
|
con.close()
|
||||||
|
|
||||||
@@ -241,6 +253,12 @@ def validate_backup(db_file):
|
|||||||
warnings.append("备份缺少部分可选表(" + "、".join(
|
warnings.append("备份缺少部分可选表(" + "、".join(
|
||||||
TABLE_LABELS.get(t, t) for t in missing_optional)
|
TABLE_LABELS.get(t, t) for t in missing_optional)
|
||||||
+ "),应用后启动会自动补建空表")
|
+ "),应用后启动会自动补建空表")
|
||||||
|
# 反向自检:备份里有"未登记"的表 → 提醒把它纳入覆盖清单(红线)
|
||||||
|
extra_tables = sorted(t for t in tables
|
||||||
|
if t not in SUMMARY_TABLES and not t.startswith("sqlite_"))
|
||||||
|
if extra_tables:
|
||||||
|
warnings.append("备份含未登记的其它表(" + "、".join(extra_tables)
|
||||||
|
+ ")——如属业务数据,请登记进 core/system_backup.py 的覆盖清单")
|
||||||
warnings.append("备份为全量快照:含用户口令哈希、AI 配置里的 API Key 等敏感信息,请妥善保管")
|
warnings.append("备份为全量快照:含用户口令哈希、AI 配置里的 API Key 等敏感信息,请妥善保管")
|
||||||
return True, {
|
return True, {
|
||||||
"integrity": integrity,
|
"integrity": integrity,
|
||||||
@@ -248,6 +266,7 @@ def validate_backup(db_file):
|
|||||||
"current_schema_version": CURRENT_SCHEMA_VERSION,
|
"current_schema_version": CURRENT_SCHEMA_VERSION,
|
||||||
"tables": _summary_info(con),
|
"tables": _summary_info(con),
|
||||||
"missing_optional": missing_optional,
|
"missing_optional": missing_optional,
|
||||||
|
"extra_tables": extra_tables,
|
||||||
"warnings": warnings,
|
"warnings": warnings,
|
||||||
}
|
}
|
||||||
except sqlite3.Error as e:
|
except sqlite3.Error as e:
|
||||||
|
|||||||
+27
-16
@@ -1,9 +1,9 @@
|
|||||||
"""通用任务管理框架:任务类型注册 + 设备分组 + 任务计划 + 定时调度 + 重试 + 持久化。
|
"""通用任务管理框架:任务类型注册 + 设备分组 + 任务计划 + 定时调度 + 重试 + 持久化。
|
||||||
|
|
||||||
设计目标:可扩展,未来加非抖音任务只需注册新的 Task 类。
|
设计目标:可扩展——新增任务类型只需在 tasks/ 下注册新的 Task 类(见 doc/TASK_DEV.md)。
|
||||||
|
|
||||||
核心概念:
|
核心概念:
|
||||||
TaskType — 任务类型(如"抖音养号"),可注册,含默认参数和 worker 工厂
|
TaskType — 任务类型(如"通用步骤"),可注册,含默认参数和 worker 工厂
|
||||||
DeviceGroup — 设备分组,持久化到 SQLite(core.models.DeviceGroup)
|
DeviceGroup — 设备分组,持久化到 SQLite(core.models.DeviceGroup)
|
||||||
TaskJob — 任务计划(类型+目标+参数+调度+重试),持久化到 SQLite(core.models.TaskJob)
|
TaskJob — 任务计划(类型+目标+参数+调度+重试),持久化到 SQLite(core.models.TaskJob)
|
||||||
TaskManager — 统管调度器、分组、任务、运行实例、状态
|
TaskManager — 统管调度器、分组、任务、运行实例、状态
|
||||||
@@ -121,7 +121,7 @@ class DeviceGroup:
|
|||||||
class TaskJob:
|
class TaskJob:
|
||||||
"""一个任务计划:什么任务、跑哪些设备、什么参数、何时跑、失败重试策略。"""
|
"""一个任务计划:什么任务、跑哪些设备、什么参数、何时跑、失败重试策略。"""
|
||||||
|
|
||||||
def __init__(self, job_id=None, name="", task_type="douyin_nurture",
|
def __init__(self, job_id=None, name="", task_type="generic_steps",
|
||||||
target=None, params=None, schedule=None, retry=None, enabled=True):
|
target=None, params=None, schedule=None, retry=None, enabled=True):
|
||||||
self.id = job_id or uuid.uuid4().hex[:8]
|
self.id = job_id or uuid.uuid4().hex[:8]
|
||||||
self.name = name
|
self.name = name
|
||||||
@@ -378,24 +378,22 @@ class TaskManager:
|
|||||||
for row in GroupRow.query.all():
|
for row in GroupRow.query.all():
|
||||||
g = DeviceGroup(row.name, row.get_serials(), row.description or "")
|
g = DeviceGroup(row.name, row.get_serials(), row.description or "")
|
||||||
self.groups[g.name] = g
|
self.groups[g.name] = g
|
||||||
dirty = False # 标记是否有任务参数需要写回
|
stale = [] # 任务类型已不存在(历史任务类型被删除)的任务
|
||||||
for row in JobRow.query.all():
|
for row in JobRow.query.all():
|
||||||
j = TaskJob(job_id=row.id, name=row.name, task_type=row.task_type,
|
j = TaskJob(job_id=row.id, name=row.name, task_type=row.task_type,
|
||||||
target=row.get_target(), params=row.get_params(),
|
target=row.get_target(), params=row.get_params(),
|
||||||
schedule=row.get_schedule(), retry=row.get_retry(),
|
schedule=row.get_schedule(), retry=row.get_retry(),
|
||||||
enabled=row.enabled)
|
enabled=row.enabled)
|
||||||
# 数据规整:移除已废弃的 action 配置(如抖音的 comment 已删除)
|
# 启动时告警:库里留着已删除的任务类型(如 douyin_nurture),
|
||||||
if j.task_type == "douyin_nurture":
|
# 这类任务跑不起来也不该静默——提示人工删除或改用现有类型。
|
||||||
actions = j.params.get("actions", {})
|
# 只告警不改数据(不自动删用户的任务)。
|
||||||
if "comment" in actions:
|
if not get_task_class(j.task_type):
|
||||||
del actions["comment"]
|
stale.append(j)
|
||||||
row.set_params(j.params)
|
|
||||||
dirty = True
|
|
||||||
_log.info(f"任务 {j.name}({j.id}): 已移除废弃的 comment 配置")
|
|
||||||
self.jobs[j.id] = j
|
self.jobs[j.id] = j
|
||||||
if dirty:
|
if stale:
|
||||||
db.session.commit()
|
_log.warning(
|
||||||
_log.info("已将规整后的任务参数写回数据库")
|
"以下任务的任务类型已不存在,无法执行,请在「任务」页删除或改用现有类型:"
|
||||||
|
+ ";".join(f"{j.name}({j.id}, type={j.task_type})" for j in stale))
|
||||||
_log.info(f"从数据库加载 {len(self.groups)} 个分组, {len(self.jobs)} 个任务")
|
_log.info(f"从数据库加载 {len(self.groups)} 个分组, {len(self.jobs)} 个任务")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_log.error(f"从数据库加载失败: {e}")
|
_log.error(f"从数据库加载失败: {e}")
|
||||||
@@ -608,6 +606,11 @@ class TaskManager:
|
|||||||
job = self.jobs.get(job_id)
|
job = self.jobs.get(job_id)
|
||||||
if not job:
|
if not job:
|
||||||
return {"ok": False, "error": "任务不存在"}
|
return {"ok": False, "error": "任务不存在"}
|
||||||
|
# 任务类型已不存在(历史类型被删除)→ 明确报错,别只返回"已触发"然后线程里静默失败
|
||||||
|
if not get_task_class(job.task_type):
|
||||||
|
return {"ok": False, "error":
|
||||||
|
f"任务类型 {job.task_type} 已不存在(该类型已被删除),"
|
||||||
|
f"请删除此任务或改用现有类型"}
|
||||||
# 在独立线程跑,不阻塞调用方
|
# 在独立线程跑,不阻塞调用方
|
||||||
t = threading.Thread(target=self._run_job, args=(job,), daemon=True)
|
t = threading.Thread(target=self._run_job, args=(job,), daemon=True)
|
||||||
t.start()
|
t.start()
|
||||||
@@ -754,7 +757,15 @@ class TaskManager:
|
|||||||
time.sleep(delay)
|
time.sleep(delay)
|
||||||
|
|
||||||
_log.error(f"{serial} 任务 {job.name} 重试耗尽,放弃")
|
_log.error(f"{serial} 任务 {job.name} 重试耗尽,放弃")
|
||||||
_update_status(serial, status="failed", last_error=f"{job.name} 重试{max_attempts}次失败")
|
# 带上最后一次的真实失败原因——只写"重试N次失败"会让用户看不到为什么失败
|
||||||
|
# (worker 出错时已把原因写进 _WORKERS[serial]["last_error"])
|
||||||
|
with _WORKERS_LOCK:
|
||||||
|
cause = (_WORKERS.get(serial, {}).get("last_error") or "").strip()
|
||||||
|
msg = (f"{job.name} 重试{max_attempts}次仍失败" if max_attempts > 1
|
||||||
|
else f"{job.name} 执行失败")
|
||||||
|
if cause and cause != msg:
|
||||||
|
msg += f":{cause}"
|
||||||
|
_update_status(serial, status="failed", last_error=msg[:200])
|
||||||
finally:
|
finally:
|
||||||
# 清除停止标志:整个重试循环结束(成功/失败/停止)后允许下次任务
|
# 清除停止标志:整个重试循环结束(成功/失败/停止)后允许下次任务
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
# AI 控制台(AI_CONSOLE)
|
||||||
|
|
||||||
|
> 适用读者:使用 AI 控制台的人 + 改这部分代码的开发者。
|
||||||
|
> 相关文档:[API.md](API.md) §12(接口)、[MCP.md](MCP.md)(AI 用的工具层)、[AI_TASK_GEN.md](AI_TASK_GEN.md)("一句话建任务"的设计稿,尚未实现)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 它是什么
|
||||||
|
|
||||||
|
「AI 控制台」是后台的一个顶级 Tab(仅管理员):选一台设备,用自然语言下指令,AI 通过 MCP 工具**看屏幕、点按、输入**,边做边把过程和结论流式显示出来。
|
||||||
|
|
||||||
|
```
|
||||||
|
你:「打开小红书搜索苏州好吃的饭店,把前 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 跑一轮
|
||||||
|
|
||||||
|
1. 选 **🎯 目标设备**(AI **只操作你选定的设备**;有任务在跑的设备不可选)
|
||||||
|
2. 输入指令,Enter 发送
|
||||||
|
3. 右侧「📺 实时画面」自动跟随 AI 操作的设备(MJPEG)
|
||||||
|
4. 中途可「■ 停止」(下一个检查点生效,通常几秒内)
|
||||||
|
5. 刷新/换窗口:会话与运行状态都会自动恢复(见 §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 不与任务抢设备")
|
||||||
|
|
||||||
|
### 3.2 会话消息模型
|
||||||
|
|
||||||
|
`agent_conversation.messages`(JSON 数组):
|
||||||
|
|
||||||
|
```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"}` | 最终答案 + 收尾 |
|
||||||
|
| `error` | `{"message"}` | 展示错误(MCP 不可达等已转成明确文案) |
|
||||||
|
|
||||||
|
**刷新/重连不丢进度**:服务端事件队列保留积压,页面重新订阅(`GET /api/agent/stream?run_id=`)后会补发 delta/step/usage/done;`EventSource.onerror` **刻意不结束运行**,靠自动重连续上。另外 `GET /api/agent/run` 提供状态快照(其他窗口/8s 轮询用)。
|
||||||
|
|
||||||
|
### 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` 的步骤不会被沉淀(坐标换个设备/分辨率就失效)
|
||||||
|
- 白名单步骤类型(18 种去掉 `click_xy`、`keep_screen`)
|
||||||
|
- 每类型有必填参数校验(如 `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 |
|
||||||
|
|
||||||
|
其它常量:单轮超时 900s、推理链落库上限 6000 字符、会话消息上限 60 条、历史上下文取最近 12 轮。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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` 等前端文件有缓存 |
|
||||||
+7
-5
@@ -1,7 +1,8 @@
|
|||||||
# AI 建任务(AI Task Generator)设计文档
|
# AI 建任务(AI Task Generator)设计文档
|
||||||
|
|
||||||
> 分支:dev | 状态:设计稿(未实现) | 日期:2026-09-09
|
> 状态:**设计稿,尚未实现**(P0 未开工)——本文描述的是「要做什么、为什么这么做」,不是现状。
|
||||||
> 关联:AI 控制台(web/agent_api.py)、MCP 设备工具(mcp_server/)、任务/步骤编辑器(tasks/generic、static/admin/editor.js)
|
> 现状请读:[AI_CONSOLE.md](AI_CONSOLE.md)(AI 控制台已实现的能力)、[TASK_DEV.md](TASK_DEV.md)(任务与步骤)、[MCP.md](MCP.md)(已实现的 19 个工具)。
|
||||||
|
> 关联代码:`web/agent_api.py`、`mcp_server/`、`mcp_agent/`、`tasks/generic/`、`static/admin/editor.js`。最后核对:2026-09-10。
|
||||||
|
|
||||||
## 1. 背景与目标
|
## 1. 背景与目标
|
||||||
|
|
||||||
@@ -23,7 +24,8 @@
|
|||||||
|
|
||||||
- **入口**:AI 控制台加「聊天 / AI 建任务」模式开关,复用会话/SSE/设备选择/停止/刷新恢复骨架。不新建顶级 Tab。
|
- **入口**:AI 控制台加「聊天 / AI 建任务」模式开关,复用会话/SSE/设备选择/停止/刷新恢复骨架。不新建顶级 Tab。
|
||||||
- **产出方式**:**AI 直接撰写编辑器的步骤 JSON**(非操作轨迹翻译)。自探信息只作上下文与审计;探索中的误点/多余截图不会混入任务。
|
- **产出方式**:**AI 直接撰写编辑器的步骤 JSON**(非操作轨迹翻译)。自探信息只作上下文与审计;探索中的误点/多余截图不会混入任务。
|
||||||
- **任务类型**:P0 **只产 `generic_steps`**(通用步骤)。`douyin_nurture` 预设参数生成放 P1。
|
- **任务类型**:**只产 `generic_steps`**(通用步骤)——平台当前唯一的任务类型
|
||||||
|
(原 `douyin_nurture` 预设参数生成已随该类型删除,不再做)。
|
||||||
- **定位原则:全部基于 UI 树**:
|
- **定位原则:全部基于 UI 树**:
|
||||||
- 模型定位/点击一律走 `de_ui_tree` 拿到的元素(`text`/`id`/`description`/`text_contains`),多实例用 index,或后端 `uiauto_helper` 生成的 `//*[@resource-id=..][k]` XPath;
|
- 模型定位/点击一律走 `de_ui_tree` 拿到的元素(`text`/`id`/`description`/`text_contains`),多实例用 index,或后端 `uiauto_helper` 生成的 `//*[@resource-id=..][k]` XPath;
|
||||||
- `de_tap_element` 确认能命中后才写进 `click` 步骤;
|
- `de_tap_element` 确认能命中后才写进 `click` 步骤;
|
||||||
@@ -134,8 +136,8 @@
|
|||||||
- 尽量不写死坐标;P0 默认禁 `click_xy`,产物以树元素定位为主。
|
- 尽量不写死坐标;P0 默认禁 `click_xy`,产物以树元素定位为主。
|
||||||
|
|
||||||
## 6. 里程碑
|
## 6. 里程碑
|
||||||
- **P0(本设计主体)**:designer 模式 → 自探(UI 树定位)→ 直接撰写 generic_steps draft → 服务端 schema 校验 → 前端步骤编辑器预填确认保存。验收:一句话在真实设备上生成一条可调度的 generic_steps,步骤全部来自 UI 树且编辑器可打开。
|
- **P0(本设计主体)**:designer 模式 → 自探(UI 树定位)→ 直接撰写 generic_steps draft → 服务端 schema 校验 → 前端步骤编辑器预填确认保存。验收:一句话在真实设备上生成一条可调度的 generic_steps,步骤全部来自 UI 树且编辑器可打开。(平台只剩 generic_steps 一种任务类型,与原规划一致)
|
||||||
- **P1**:`douyin_nurture` 参数预设生成(照 default_params 结构);模板沉淀:把 draft+需求写入 `agent_experience`(新列存结构化 steps 或 JSON),相似需求注入参考;整链「演示试跑」(把 steps 在设备上以受控方式跑一遍并截图回报,需新增端点,复刻 `device_busy` 拒绝语义)。
|
- **P1**:模板沉淀:把 draft+需求写入 `agent_experience`(新列存结构化 steps 或 JSON),相似需求注入参考;整链「演示试跑」(把 steps 在设备上以受控方式跑一遍并截图回报,需新增端点,复刻 `device_busy` 拒绝语义)。
|
||||||
- **进展(2026-09-10)**:动作级沉淀已落地——`agent_action` 表 + 从**成功步骤**蒸馏"命名动作"(steps 用编辑器 schema、带元素定位、禁坐标)+ 执行前按名/别名召回注入(`web/agent_api.py`)。AI 建任务可直接把这些动作当作 generic_steps 的**预制件**复用。
|
- **进展(2026-09-10)**:动作级沉淀已落地——`agent_action` 表 + 从**成功步骤**蒸馏"命名动作"(steps 用编辑器 schema、带元素定位、禁坐标)+ 执行前按名/别名召回注入(`web/agent_api.py`)。AI 建任务可直接把这些动作当作 generic_steps 的**预制件**复用。
|
||||||
- **P2**:自定义动作支持(内联展开成 group,或新增 `action_ref` 节点 + 执行器/编辑器同步);多设备并行;成本与 token 控制。
|
- **P2**:自定义动作支持(内联展开成 group,或新增 `action_ref` 节点 + 执行器/编辑器同步);多设备并行;成本与 token 控制。
|
||||||
|
|
||||||
|
|||||||
+492
-1183
File diff suppressed because it is too large
Load Diff
+285
-330
@@ -1,417 +1,372 @@
|
|||||||
# 架构详解
|
# 架构详解(ARCHITECTURE)
|
||||||
|
|
||||||
本文面向想深入理解 `auto_control` 内部设计的开发者。如果你只想使用,看 [README.md](../README.md) 即可。
|
> 适用读者:要改后端 / 前端 / 任务引擎的开发者。
|
||||||
|
> 相关文档:[DATA_MODEL.md](DATA_MODEL.md)(表结构)、[API.md](API.md)(接口清单)、[DEVELOPMENT.md](DEVELOPMENT.md)(流程与红线)。
|
||||||
|
> 文中引用为 `文件:行号`,以当前代码为准;**行号会随改动漂移,函数名与常量名是稳定锚点**。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. 分层设计
|
## 1. 总览
|
||||||
|
|
||||||
平台按"配置 / 核心 / 任务 / 前端 / 数据 / 日志 / 工具"分层,职责清晰、互不交叉:
|
### 1.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(设备) │
|
│ 表现层 templates/admin/*.html + static/admin/*.js(单页应用,无框架) │
|
||||||
│ (monitor.html│ └─────────────┘ └──────────────┘
|
│ 7 个顶级 Tab;轮询 / SSE / MJPEG 三类实时通道 │
|
||||||
│ fetch+DOM) │ ▲ │
|
└───────────────────────────┬──────────────────────────────────────────┘
|
||||||
└──────────────┘ │ 状态/心跳 │ u2 操作
|
│ fetch JSON / SSE / MJPEG / 表单
|
||||||
│ │ ▼
|
┌───────────────────────────▼──────────────────────────────────────────┐
|
||||||
│ JSON API │ ┌─────────────────┐
|
│ Web 层 web/(10 个 Flask 蓝图,全部 url_prefix 为空) │
|
||||||
▼ ┌──────────────┐ │ 设备 adb │
|
│ auth 鉴权 · monitor 状态与设备操作 · tasks 任务 · admin 用户与日志 │
|
||||||
┌──────────────┐ │ 看门狗监控 │ │ (IP:5555 直连 │
|
│ tools 运维 · devices 设备池 · apks 应用 · tailscale · agent AI 控制台 │
|
||||||
│ web/ 蓝图包 │ └──────────────┘ │ / USB 远程) │
|
│ system 备份 │
|
||||||
│ (Flask API) │ └─────────────────┘
|
└───────────────────────────┬──────────────────────────────────────────┘
|
||||||
└──────────────┘
|
│ 直接函数调用(共享对象由 web/context.py 注入)
|
||||||
|
┌───────────────────────────▼──────────────────────────────────────────┐
|
||||||
|
│ 领域层 task_manager(调度) device_worker(执行) device_pool(池) │
|
||||||
|
│ system_backup(备份) apk_manager(应用) device_discovery │
|
||||||
|
└───────────────────────────┬──────────────────────────────────────────┘
|
||||||
│
|
│
|
||||||
▼
|
┌───────────────────────────▼──────────────────────────────────────────┐
|
||||||
┌──────────────┐
|
│ 基础层 adb_helper · u2_helper · uiauto_helper · ocr · clipboard │
|
||||||
│ data/users.db│ SQLite 持久化(用户/分组/任务/设备池/待连接设备/自定义动作/APK记录/AI会话/经验库 + app_meta KV)
|
│ models(SQLite)· logger · config │
|
||||||
└──────────────┘
|
└──────────────────────────────────────────────────────────────────────┘
|
||||||
|
▲
|
||||||
|
┌───────────────────────────┴──────────────────────────────────────────┐
|
||||||
|
│ 任务定义层 tasks/(BaseTask 注册表 + generic/ 通用步骤引擎) │
|
||||||
|
└───────────────────────────┬──────────────────────────────────────────┘
|
||||||
|
▲ ▲
|
||||||
|
┌───────────────────────────┴────────┐ ┌───────────┴──────────────────┐
|
||||||
|
│ MCP Server(mcp_server/,:8033) │ │ AI Agent(mcp_agent/) │
|
||||||
|
│ 19 个 de_* 工具,供外部 AI 调用 │ │ OpenAI 兼容模型 → MCP 工具 │
|
||||||
|
└────────────────────────────────────┘ └──────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
### 任务执行流程
|
### 1.2 各层职责边界
|
||||||
|
|
||||||
1. 前端创建 TaskJob(HTTP POST `/api/jobs`)
|
| 层 | 做什么 | 不做什么 |
|
||||||
2. `TaskManager` 保存到 SQLite,如启用 cron 则注册到 APScheduler
|
|----|--------|---------|
|
||||||
3. 手动执行或 cron 触发时,`_run_job` 解析目标设备列表
|
| 表现层 | 渲染、交互、轮询/流式拉取、按权限隐藏入口 | 不校验权限(只隐藏);不做业务判断 |
|
||||||
4. 每台设备起一个线程 `_run_with_retry`,含重试循环
|
| Web 层 | 参数校验、鉴权装饰器、JSON 序列化、调用领域层 | 不直接操作 adb/u2(`monitor` 的看屏/截图除外,那本身就是"设备操作") |
|
||||||
5. 线程内 `task.create_worker()` 创建 Worker,`worker.start()` 启动
|
| 领域层 | 调度、并发、重试、状态机、持久化 | 不感知 HTTP |
|
||||||
6. `BaseWorker.run()` 执行设备生命周期:占用 → 连接 → setup → run_task → teardown → 释放
|
| 基础层 | adb / u2 / OCR / 数据库 / 日志的原子能力 | 不含业务规则 |
|
||||||
7. Worker 通过 `_update_status()` 实时上报状态到全局 `_WORKERS` 字典
|
| 任务定义层 | 任务类型注册 + 具体任务执行逻辑 | 不感知调度与设备获取(`BaseWorker` 已封装) |
|
||||||
8. 前端轮询 `/api/status`(5 秒缓存)获取设备 + Worker 状态
|
|
||||||
|
**装配方向**:`web_server.py` 是唯一组装点;`web/context.py` 注入 `mgr` / `apk_mgr` / `device_pool`,避免 Web 层与领域层循环 import。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. 核心模块详解
|
## 2. 启动与装配顺序
|
||||||
|
|
||||||
### 3.1 DevicePool(`core/device_pool.py`)
|
理解启动顺序很关键——**很多副作用发生在 import 期**。
|
||||||
|
|
||||||
设备池(已摘除 OpenSTF):SQLite `devices` 表 = 设备清单,本机 adb = 在线状态。
|
### 2.1 阶段 A:import 期副作用(`web_server.py:6-25`)
|
||||||
|
|
||||||
**关键设计**:
|
| 顺序 | 触发 | 副作用 |
|
||||||
- `list_configured()` — 清单(管理页维护,enabled=False 不参与调度)
|
|------|------|--------|
|
||||||
- `list_online()` — 本机 adb 在线设备;池内有 USB 设备(serial 无冒号)时合并 220
|
| 1 | `from core.task_manager import TaskManager` | 链式触发:`config.py` 模块体 → **读取根目录 `.env`**(`os.environ.setdefault`);`core/logger.py` → 创建 `logs/`;`tasks/__init__.py` → **任务类型注册**(`@register_task` 在 import 期执行) |
|
||||||
远程 adb server(`USB_ADB_HOST:PORT`,host 网络模式 5037)状态
|
| 2 | `get_logger` / `core.models` | 得到 `db` / `init_db` / `User` |
|
||||||
- `list_ready()` — 清单 ∩ 在线(任务调度用)
|
| 3 | `from config import WEB_HOST, WEB_PORT` | 读取端口常量 |
|
||||||
- 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`)
|
> `.env` 是"import config 的副作用",因此 `web_server.py:30` 读 `WEB_SECRET_KEY` 时它已生效。
|
||||||
|
|
||||||
**STFDevice** — 单设备生命周期管理:
|
### 2.2 阶段 B~D:Flask 与领域对象
|
||||||
- `acquire()`:IP:5555 直连 adb connect;USB(serial 无冒号)校验 220 远程 adb server 可见性
|
|
||||||
- `release()`:无操作(不 disconnect,红线)
|
|
||||||
- 互斥由 TaskManager `_running` 保证,设备池负责在线判断
|
|
||||||
|
|
||||||
**BaseWorker** — 通用 Worker 基类(继承 threading.Thread):
|
| 阶段 | 位置 | 做了什么 |
|
||||||
|
|------|------|---------|
|
||||||
|
| **B** | `:28-41` | `Flask(__name__)`;会话密钥(`.env` 的 `WEB_SECRET_KEY`,缺失则随机生成并 warning);`TEMPLATES_AUTO_RELOAD=True`;`SQLALCHEMY_DATABASE_URI=sqlite:///data/users.db`;`LoginManager` + `login_view="auth.login"` |
|
||||||
|
| **C** | `:43-50` | **恢复任务消费** `consume_pending_restore()` —— 必须在 engine 首次打开 `users.db` **之前**(Windows 无法替换被持有的文件)。失败只记日志,不阻塞启动 |
|
||||||
|
| **D** | `:53-61` | `init_db(app)`(建表 → 版本化迁移 → 默认管理员 → 旧 JSON 迁移);`device_pool.init_app`(**起一次性线程**,3s 后采集型号);`device_discovery.init_app`(**起常驻扫描线程**);`TaskManager(app=app)`(APScheduler + 看门狗 + 从库加载分组/任务 + 重注册 cron);`ApkManager(app=app)` |
|
||||||
|
|
||||||
```
|
### 2.3 阶段 E~G:蓝图、巡检调度器、真正启动
|
||||||
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 超时`
|
| **E** | `:64-67` | `context.init(...)`;`register_blueprints(app)`(10 个蓝图);`agent_api.set_app(app)`(供后台线程推 app context) |
|
||||||
|
| **F** | `:71-80` | **第二个独立 APScheduler**:`CronTrigger(hour=3, minute=47)` 挂经验库巡检;失败仅 warning |
|
||||||
|
| **G** | `:253-265`(`__main__`) | `_ensure_uiauto_running()`(拉起 uiautodev:20242,写 `data/uiauto.pid`,`atexit` 清理)→ `_preconnect_pool_devices()`(后台并发 connect 池内网络设备)→ `_run_server()`(候选端口依次 bind:`0.0.0.0:18050` → `127.0.0.1:18050` → `127.0.0.1:18051..18055`);退出时 `mgr.shutdown()` + `device_discovery.shutdown()` + 停 uiautodev |
|
||||||
|
|
||||||
**心跳看门狗**(`_Watchdog`):
|
> ⚠️ **阶段 A~F 在 import 期就会起线程/调度器**,只有 uiautodev 拉起与预连接在 `__main__` 分支。以 WSGI 方式 import 本模块会得到"半个启动"的进程——本地调试请直接 `python web_server.py`。
|
||||||
- 后台线程,每 30 秒扫描一次
|
|
||||||
- Worker 超过 120 秒无心跳 → 标记 `error`
|
|
||||||
- 防止设备被占用却不干活
|
|
||||||
|
|
||||||
**全局状态注册表**(`_WORKERS`):
|
---
|
||||||
- `serial -> status dict`,线程安全(`_WORKERS_LOCK`)
|
|
||||||
- 供 `web_server` 读取实时状态,前端通过 `/api/status` 展示
|
|
||||||
|
|
||||||
### 3.3 TaskManager(`core/task_manager.py`)
|
## 3. 线程与并发模型
|
||||||
|
|
||||||
统一管理:任务类型注册、设备分组、任务计划、定时调度、重试、持久化。
|
### 3.1 常驻线程一览
|
||||||
|
|
||||||
**核心组成**:
|
| 名称 | 启动位置 | 职责 | 周期 |
|
||||||
|
|------|---------|------|------|
|
||||||
|
| Flask 请求线程 | `app.run(threaded=True)` | 每请求一线程 | — |
|
||||||
|
| **任务调度器** `BackgroundScheduler` | `TaskManager.__init__` | cron 触发 / 停止任务 | 按 cron |
|
||||||
|
| **巡检调度器** `BackgroundScheduler` | `web_server.py` | 经验库 AI 巡检 | 每日 03:47 |
|
||||||
|
| `worker-watchdog` | `start_watchdog()` | `running/connecting` 心跳超时 → 标 `error` | 30s 检查 / 120s 阈值 |
|
||||||
|
| `device-discovery` | `device_discovery.init_app` | 网段扫描 + 断联重连 | 首轮延迟 15s,之后 interval(默认 60s) |
|
||||||
|
| `_refresh_models_bg` | `device_pool.init_app` | 启动后采集全部在线设备型号 | 一次性(3s 后) |
|
||||||
|
| `BaseWorker` × N | `TaskManager._run_with_retry` | 单设备任务执行 | 任务期 |
|
||||||
|
| `_run_with_retry` × N | 同上 | 单设备重试循环 | 任务期 |
|
||||||
|
| `fg-scan-once` | `_ForegroundScanner.scan_once` | 前台 App 扫描 | 手动触发,`Event` 防重入 |
|
||||||
|
| `apk-install` | `ApkManager.install` | 并发 5 台安装 APK | 安装期,全局单任务 |
|
||||||
|
| Agent 执行线程 | `web/agent_api.py` | AI 控制台一轮会话 | 按需 |
|
||||||
|
| 巡检手动线程 | `web/agent_api.py` | 手动触发巡检 | 按需 |
|
||||||
|
|
||||||
| 组件 | 说明 |
|
两个 APScheduler 相互独立,时区均固定 `Asia/Shanghai`。
|
||||||
|------|------|
|
|
||||||
| `scheduler` | APScheduler BackgroundScheduler,cron 触发任务 |
|
|
||||||
| `groups` | 设备分组(内存业务对象,持久化到 SQLite) |
|
|
||||||
| `jobs` | 任务计划(内存业务对象,持久化到 SQLite) |
|
|
||||||
| `_running` | 运行中的 worker(serial -> worker 信息) |
|
|
||||||
| `_stop_requested` | 用户请求停止的 serial 集合(阻止后续重试) |
|
|
||||||
| `_fg_scanner` | 前台 App 扫描器(不打扰设备) |
|
|
||||||
|
|
||||||
**调度模式**:
|
### 3.2 锁与并发保护
|
||||||
- `once`:不注册 cron,手动执行
|
|
||||||
- `cron`:注册启动 cron,到点启动所有目标设备
|
|
||||||
- `cron_stop`:注册启动 cron + 停止 cron,到点停止本任务 worker
|
|
||||||
|
|
||||||
**重试策略**:
|
| 锁 | 位置 | 保护对象 | 说明 |
|
||||||
- `DeviceOfflineError`:立即放弃,不重试(设备掉线短时间不会自愈)
|
|----|------|---------|------|
|
||||||
- 其他异常:按 `retry.max_attempts` 重试,间隔 `retry.delay`
|
| `_ADB_LOCK` | `core/adb_helper.py` | adb connect 串行化 | connect 很快,串行不影响整体并发;不覆盖长命令 |
|
||||||
- 临时错误(端口耗尽):退避 max(delay, 120s)
|
| `_WORKERS_LOCK` | `core/device_worker.py` | 全局设备状态表 `_WORKERS` | "检查+更新"在同一锁内,避免与任务启动竞态 |
|
||||||
- 用户停止:加入 `_stop_requested`,阻止任何后续重试
|
| `TaskManager._lock` | `core/task_manager.py` | `_running` / `_stop_requested` | **抢占时禁止在锁内调 `stop_device`(死锁)** |
|
||||||
|
| `_engine_lock` | `core/ocr.py` | OCR 引擎懒加载 + 推理串行 | 推理 0.2~0.5s,锁开销可忽略 |
|
||||||
|
| `_scan_lock` / `_stop_event` | `core/device_discovery.py` | 定时/手动扫描互斥 | `acquire(blocking=False)` |
|
||||||
|
| `_status_cache_lock` | `core/task_manager.py` | 状态缓存(TTL 5s) | 避免 `/api/status` 每次都查库 + adb |
|
||||||
|
|
||||||
**并发控制**:同一 serial 同时只允许一个 worker,避免冲突。
|
**无锁部分**:`device_pool` 与 `models` 不持显式锁,依赖"每次操作独立 app context + SQLite WAL + `busy_timeout=5000`"。
|
||||||
|
|
||||||
**状态缓存**:`get_status()` 带 5 秒缓存,避免每次 /api/status 都查库/adb 阻塞前端。
|
### 3.3 错峰与心跳
|
||||||
|
|
||||||
### 3.4 前台 App 扫描器(`_ForegroundScanner`)
|
- **错峰启动**:`_START_STAGGER_SEC = 0.2`,第 i 台设备延迟 `i × 0.2s` 启动(100 台 ≈ 20s 铺开),避免批量触发时的 adb 连接风暴。
|
||||||
|
- **心跳看门狗**:任何状态写入都会刷新 `last_heartbeat`;`running/connecting` 设备超过 120s 无心跳 → `status="error"` + `last_error="心跳超时…"`。长耗时的业务循环必须周期性 `self.heartbeat()`(`set_action` / `set_progress` 也会刷新)。
|
||||||
|
- **单实例约束**:同一 serial 同时只有一个 worker(`TaskManager._running`);重复触发同任务跳过,不同任务未开抢占也跳过。
|
||||||
|
|
||||||
**设计原则:不打扰设备**,扫描不会让设备退出当前 App。
|
---
|
||||||
|
|
||||||
| 设备状态 | 处理方式 | 是否打扰 |
|
## 4. 设备生命周期
|
||||||
|---------|---------|---------|
|
|
||||||
| worker 运行中(IP:5555) | 复用已有 ADB 连接查询 | 否 |
|
|
||||||
| worker 运行中(USB) | 经 220 远程 adb server 查询 | 否 |
|
|
||||||
| 完全空闲 | 返回"空闲"(不主动 connect) | 否 |
|
|
||||||
|
|
||||||
> **为什么不扫描空闲设备的前台 App**:IP:5555 的 adb transport 是共享的(历史与 STF provider 共用),外部 connect/disconnect 会扰动共享连接,遵守既有红线。
|
### 4.1 入池(三条路径)
|
||||||
|
|
||||||
### 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` 业务权限位) |
|
| 手工添加 | `POST /api/devices/pool/add` | `device_pool.add_device`(upsert)→ 有 `:` 则 `adb connect` → 后台采集型号 |
|
||||||
| `DeviceGroup` | device_group | 设备分组(serials 存 JSON) |
|
| 自动发现确认 | `POST /api/devices/discovery/confirm` | 扫描写 `pending_device` → 确认后 `add_device` + 删 pending + connect + 采型号 |
|
||||||
| `TaskJob` | task_job | 任务计划(target/params/schedule/retry 存 JSON) |
|
| 启动预连接 | `_preconnect_pool_devices()` | 进程启动时并发 connect 池内网络设备(仅 `IP:5555`) |
|
||||||
| `CustomAction` | custom_action | 自定义动作(步骤打包,steps 存 JSON) |
|
|
||||||
| `ApkFile` | apk_file | APK 文件元信息 |
|
|
||||||
| `Device` | device | 设备池清单(替代 STF 池;enabled=False 不参与调度,含 model 型号列) |
|
|
||||||
| `PendingDevice` | pending_device | 自动发现「待连接池」(扫描发现、用户确认后才入正式池) |
|
|
||||||
|
|
||||||
> 表名默认取类名小写(models.py 未写 `__tablename__`)。另有三张非模型表,由原生 SQL 幂等创建、**不走 SCHEMA_MIGRATIONS**:
|
断联设备的自动重连由发现线程每轮执行(只重连 `IP:5555`)。
|
||||||
> - `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`):
|
### 4.2 可用性判定
|
||||||
- 创建所有表
|
|
||||||
- 首次启动创建默认管理员 `admin/admin123`
|
|
||||||
- 自动迁移旧 `groups.json` / `jobs.json` 到 SQLite(迁移后归档为 `.migrated`)
|
|
||||||
- 版本化 schema 迁移(`SCHEMA_MIGRATIONS`,**当前到 v4**,见 `core/models.py`):结构变更必须追加迁移条目,`create_all` 只建新表不加列
|
|
||||||
|
|
||||||
### 权限模型
|
```
|
||||||
|
list_configured() 设备池中 enabled=True 的 serial
|
||||||
|
list_online() 本机 adb devices 中 state=device(池内有 USB 设备时并查 220 远程 adb server)
|
||||||
|
list_ready() 两者交集 ← 调度 "all" 模式取这个
|
||||||
|
```
|
||||||
|
|
||||||
- `User.perms` 存业务权限位 JSON 数组(`tasks`/`devices`/`apks`/`logs`),`is_admin=true` 拥有全部权限(`has_perm` 短路)
|
### 4.3 执行期状态字段
|
||||||
- 后端统一用 `@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`)
|
设备状态存在内存注册表 `_WORKERS[serial]`(**不落库,重启即清零**):
|
||||||
|
|
||||||
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 服务是否运行 |
|
| `status` | `idle` / `connecting` / `running` / `done` / `error` / `released` / `failed` |
|
||||||
| `list_devices()` | 获取 uiauto2 已连接的设备列表 |
|
| `serial` / `model` / `device_name` | 标识 |
|
||||||
| `get_screenshot(serial)` | 获取设备截图(JPEG) |
|
| `task_job` / `attempt` / `max_attempts` | 当前任务与重试进度 |
|
||||||
| `get_elements(serial)` | 获取设备 UI 元素树(扁平化列表) |
|
| `current_action` / `progress` | 当前动作与进度(前端直接渲染) |
|
||||||
|
| `last_error` / `last_warning` | 最近错误 / 选择器健康告警 |
|
||||||
|
| `last_heartbeat` / `end_time` | 看门狗与时长上限 |
|
||||||
|
| `present` / `ready` | 是否在线(对外 `/api/status` 字段) |
|
||||||
|
|
||||||
元素树解析:递归提取每个节点的 `resource-id/text/content-desc/class/bounds` 等属性,并推荐最佳选择器(优先 xpath)。
|
### 4.4 状态迁移
|
||||||
|
|
||||||
**XPath 序号语义(重要)**:同一属性多个实例时,生成 **`(//*[@resource-id="x"])[k]`**(整体加括号 = 第 k 个匹配)。
|
**Worker 侧**(`BaseWorker.run`):
|
||||||
不可写成 `//*[@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`)
|
```
|
||||||
|
connecting ──获取设备──▶ u2 连接 ──▶ running ──▶ setup ──▶ run_task ──▶ teardown
|
||||||
|
│
|
||||||
|
未被 stop ──▶ done │
|
||||||
|
异常 ──▶ error(DeviceOfflineError 单独分类,不重试)
|
||||||
|
finally ──▶ 仅当仍为 running/connecting 时置 released
|
||||||
|
```
|
||||||
|
|
||||||
条件判断的 `ocr` 选择器实现:截屏 → RapidOCR(ONNX 推理,中英文模型随包内置)→ 关键词匹配 → 返回文字中心像素坐标(与 u2 `d.click` 一致)。
|
> `finally` 里的状态判断是为了**不覆盖业务结果**(`done`/`error` 必须保留)。
|
||||||
|
|
||||||
- **跨平台**(Windows/Linux/macOS),依赖 `rapidocr_onnxruntime`;服务器无显示器环境建议将 opencv-python 换成 opencv-python-headless
|
**调度侧**(`_run_with_retry`):worker 结束后读 `status` → `done` 即成功返回;否则按 `max_attempts` 重试(`[transient]` 错误额外加长退避)→ 重试耗尽置 `status="failed"`,并把**真实失败原因**拼进 `last_error`(截断 200 字符)。
|
||||||
- 引擎懒加载单例 + 并发加锁(识别约 0.2-0.5s/次)
|
|
||||||
- 返回坐标约定:像素、原点左上
|
|
||||||
|
|
||||||
### 3.10 Web 层蓝图包(`web/`)
|
### 4.5 释放
|
||||||
|
|
||||||
路由按功能域拆分(模块化开发底线,便于定位问题):
|
`STFDevice.release()` 是**空实现**——直连模式下**绝不 disconnect**(共享 adb transport 红线)。类名 `STFDevice` / `STFError` 是 STF 时代的历史命名,功能上已与 STF 无关。
|
||||||
|
|
||||||
| 模块 | 职责 |
|
|
||||||
|------|------|
|
|
||||||
| `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. 任务系统设计
|
## 5. 任务调度链路
|
||||||
|
|
||||||
### 4.1 注册机制
|
### 5.1 完整调用链
|
||||||
|
|
||||||
```
|
```
|
||||||
tasks/__init__.py
|
① 注册 add_job / update_job / toggle_job
|
||||||
├── from .base import BaseTask, register_task, list_task_types, get_task_class
|
→ _add_cron:scheduler.add_job(_on_cron_trigger, CronTrigger.from_crontab(...),
|
||||||
├── from .douyin import task # 触发 @register_task
|
id=f"job_{id}_start", replace_existing=True)
|
||||||
└── from .generic import task # 触发 @register_task
|
(cron_stop 额外注册 job_{id}_stop;启动时 _load() 为 enabled 任务重注册)
|
||||||
|
|
||||||
|
② 触发 APScheduler → _on_cron_trigger(job_id)
|
||||||
|
→ 任务存在?→ _in_run_window(schedule)?→ _run_job(job)
|
||||||
|
|
||||||
|
③ 解析 job.resolve_serials(self) # all=池内在线 / group=分组∩池 / serial=指定
|
||||||
|
→ 为空则 warning 返回
|
||||||
|
|
||||||
|
④ 任务类 get_task_class(job.task_type) → 未知则 error 返回(run_job_now 会直接报错)
|
||||||
|
→ task_cls();max_attempts = max(1, retry.max_attempts)
|
||||||
|
|
||||||
|
⑤ 铺开 对每台设备起线程 _run_with_retry(task, serial, job, ..., idx * 0.2s)
|
||||||
|
|
||||||
|
⑥ 单设备 _run_with_retry:
|
||||||
|
sleep(错峰) → 停止检查 → 单实例/抢占判定 → 登记 _running[serial]
|
||||||
|
→ _update_status(task_job=..., attempt=...)
|
||||||
|
→ worker = task.create_worker(serial, job.params)
|
||||||
|
→ worker.start() → worker.join()
|
||||||
|
→ 读 status:done 成功;否则重试([transient] 退避 max(delay,120)s)
|
||||||
|
→ 耗尽:status="failed" + last_error(含真实原因)
|
||||||
|
→ finally:清停止标志;本任务若是抢占任务则归还设备(重跑被抢占任务)
|
||||||
|
|
||||||
|
⑦ 上报 worker 内 _update_status → 内存注册表
|
||||||
|
→ TaskManager.get_status(5s 缓存)
|
||||||
|
→ _merge_status(合并池信息、清理陈旧条目)
|
||||||
|
→ GET /api/status → 前端 5s 轮询渲染
|
||||||
|
|
||||||
|
⑧ 停止 stop_device / stop_all → _stop_requested.add + worker.stop()(置 Event)
|
||||||
|
业务循环检查 self.stopped()
|
||||||
|
|
||||||
|
⑨ 下次运行时间 next_run_of → _next_run_time(考虑运行窗口,最多向后探测 200 次)
|
||||||
```
|
```
|
||||||
|
|
||||||
`@register_task` 装饰器将 Task 类注册到全局 `_TASK_TYPES` 字典,key 为 `task_type` 字符串。
|
### 5.2 抢占机制
|
||||||
|
|
||||||
### 4.2 参数深合并
|
任务参数 `preempt=true` 时:`all` 模式目标集合变为"全部在线池内设备"(含正在跑的);遇到设备已被占用时在**锁外**调 `stop_device` 并最多等 30s 接管;本任务结束后自动重新启动被抢占的任务(`preempted_job` 必须定义在重试循环外,否则归还信息会丢)。
|
||||||
|
|
||||||
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. 前端设计
|
## 6. 前端架构
|
||||||
|
|
||||||
### 5.1 单页应用
|
### 6.1 单页应用
|
||||||
|
|
||||||
`templates/admin/monitor.html` 是纯 HTML+CSS+JS 单页应用,无框架依赖。
|
- 主页面 `templates/admin/monitor.html`:一个内联 `<style>` + 7 个 Tab 面板 + 7 个模态框容器 + 11 个 `<script src>`
|
||||||
|
- 独立页面:`login.html`(登录)、`wall.html`(监控大屏,**完全自包含**,自带 CSS/JS,不加载 `static/admin/*.js`)
|
||||||
|
- 服务端内联页:`GET /locate`(设备端定位大字页,免登录)
|
||||||
|
- 响应头强制 `no-store`,避免后台改版后浏览器拿旧页面
|
||||||
|
|
||||||
- **Tab 切换**:7 个顶级 Tab(监控/任务/日志/用户/工具/AI 控制台/系统),均在 monitor.html 内 `.tab-panel` 切换(纯 DOM 操作);独立页面仅 `/login`、`/wall`。原"分组"已无顶层入口(移到工具页子分栏)
|
### 6.2 Tab 与子分栏
|
||||||
- **数据获取**:`fetch()` 调 JSON API,5 秒轮询 `/api/status`
|
|
||||||
- **状态渲染**:设备表格、任务卡片、进度条、徽章,纯 DOM 操作
|
|
||||||
|
|
||||||
### 5.2 步骤编辑器
|
| 顶级 Tab | `data-tab` | 权限 | 子分栏 |
|
||||||
|
|---------|-----------|------|--------|
|
||||||
|
| 监控 | `monitor` | 登录即可 | — |
|
||||||
|
| 任务 | `tasks` | 登录即可(写操作需 `tasks`) | `plan` 任务计划 / `actions` 自定义动作 |
|
||||||
|
| 日志 | `logs` | `logs` | — |
|
||||||
|
| 用户 | `users` | `admin` | — |
|
||||||
|
| 工具 | `tools` | `admin` | `clipboard` / `adb` / `ts` / `apks` / `appver` / `devapps` / `devpool` / `groups` |
|
||||||
|
| AI 控制台 | `agent` | `admin` | — |
|
||||||
|
| 系统 | `system` | `admin` | `backup` 数据备份 / `restore` 导入恢复 |
|
||||||
|
|
||||||
`generic_steps` 任务的步骤编辑器(任务弹窗已加宽到 1000px):
|
子分栏会记住上次选中位置(`_activeSubs`);`devpool` 子分栏自带 10s 轮询,切走即停。
|
||||||
- 左侧操作库(拖拽源,按 交互操作/屏幕与App/流程控制 分组)
|
|
||||||
- 中间画布(步骤卡片列表,HTML5 Drag API 排序 + 跨层级嵌套:循环套循环、动作组)
|
|
||||||
- 每个步骤卡片可展开参数表单
|
|
||||||
- 选择器字段旁有"抓取元素"按钮(独立模态框)
|
|
||||||
- **容器步骤**(loop/group/if_el):卡片内嵌子步骤容器接收拖入;if_el 有"✅找到时/❌未找到时"两个独立分支容器,分支可嵌套任意步骤
|
|
||||||
- **条件判断**(if_el):选择器支持 xpath 等 UI 树选择器或 OCR识别(`core/ocr.py`,截屏匹配图片/画布文字,命中可自动点击)
|
|
||||||
- 所有递归操作(选择打包、存自定义动作、校验、防循环自套)统一遍历 children/then/else 三个子数组
|
|
||||||
|
|
||||||
### 5.3 页内子分栏
|
### 6.3 JS 分工
|
||||||
|
|
||||||
任务/工具/系统 Tab 用通用 `showSubTab(tabId, name)` 实现页内子分栏:每个子分栏一个 `.sub-panel`,
|
| 文件 | 职责 | 备注 |
|
||||||
`_activeSubs` 记住各 Tab 上次选中的子分栏。现状子分栏:
|
|------|------|------|
|
||||||
- **任务**:任务计划 / 自定义动作
|
| `base.js` | esc / CSRF / 权限 / API 封装 / Toast / Tab 与子分栏切换 / 模态框 / 常量表 | 所有模块的公共底座 |
|
||||||
- **工具**:剪贴板注入 / adb 远程终端 / Tailscale 管理 / 应用管理 / 应用版本管理 / 设备已装应用 / 设备池管理 / 设备分组
|
| `markdown.js` | 轻量 Markdown 渲染(`renderMarkdown`) | 无 CDN 依赖;**先转义再套标记** |
|
||||||
- **系统**:数据备份 / 导入恢复
|
| `list.js` | 统一列表组件:搜索 + 分页 + 排序 | 状态注册表 `_LIST_PAGERS`;`setListPager` **不重置**页码/搜索/排序(避免轮询刷新打断用户) |
|
||||||
|
| `monitor.js` | 监控页:设备表、批量操作、异常汇总、任务概况卡片(含覆盖设备 chip) | 5s 轮询 + 脏检查(签名不变不重渲染) |
|
||||||
|
| `editor.js` | 步骤编辑器(拖拽 / 参数表单 / 条件分支 / 元素抓取 / 测试此步骤)+ `saveTask` | 最大的前端文件;`_stepEditor` 单例 |
|
||||||
|
| `tasks.js` | 任务 Tab:任务 CRUD / 调度解析 / 运行窗口 + 自定义动作 | |
|
||||||
|
| `tools.js` | 工具 Tab:剪贴板、adb 终端、Tailscale、设备池、自动发现、远程看屏 | |
|
||||||
|
| `apps.js` | 应用管理:APK 上传/安装/删除、设备已装应用 | |
|
||||||
|
| `admin.js` | 分组、日志、用户 + **全局初始化入口**(末尾 `initCsrf(); loadMe(); showTab('monitor')`) | |
|
||||||
|
| `agent.js` | AI 控制台:会话、SSE 流、Markdown / 推理链 / token 渲染、实时画面、经验库 / 动作库 | |
|
||||||
|
| `system.js` | 系统 Tab:备份导出 / 导入预览与应用 | |
|
||||||
|
|
||||||
(原"分组"顶级 Tab 与"维护/STF 服务"子分栏已不存在——分组已移入工具页子分栏,STF 已摘除。)
|
加载顺序见根 [README](../README.md);都是全局脚本(非 ES module),靠加载顺序保证依赖。
|
||||||
|
|
||||||
### 5.4 AI 控制台的记忆面板
|
### 6.4 实时通道
|
||||||
|
|
||||||
AI 控制台(顶级 Tab)右上角两个模态框,管理自进化记忆:
|
| 通道 | 场景 | 机制 |
|
||||||
- **🧠 经验库**:任务级经验(`agent_experience`,整任务配方)+ 每日 AI 巡检建议(删除需人工确认)。
|
|------|------|------|
|
||||||
- **🎬 动作库**:动作级经验(`agent_action`)——命名动作(可含 1~N 步)+ 编辑器 schema 步骤 + **元素定位(禁坐标)**;由任务成功后从**成功步骤**自动蒸馏,执行前按名/别名召回注入;面板支持查看/编辑/删除/手动新建(保存经服务端校验,坐标步骤被拒)。
|
| **轮询** | 设备状态(5s)、日志(3s,可关)、自动发现(10s)、截图(3s)、APK 安装(3s)、AI 运行状态(8s) | `setInterval`,切 Tab 时统一清理 |
|
||||||
- **会话列表显示会话 ID**(前 8 位,等宽小字),点击即复制完整 ID——便于反馈问题时引用 `conv=<id>`。
|
| **SSE** | AI 控制台一轮会话 | `EventSource /api/agent/stream`;`onerror` **刻意不结束运行**,靠自动重连 + 服务端事件队列补发 |
|
||||||
|
| **MJPEG** | 实时看屏 | `img.src = /api/screen/stream?...`,浏览器原生长连接 |
|
||||||
|
|
||||||
> **蒸馏健壮性(2026-09-10)**:经验/动作靠**模型蒸馏**落库。推理型模型会把 token 预算烧在 `reasoning` 上,导致 `content` 为空或被截断(`finish_reason=length`)→ 早期只读 `content`,经验/动作被**静默丢弃**("小红书·苏州饭店"案例)。现策略:
|
### 6.5 前端权限
|
||||||
> 1. 蒸馏调用**关闭推理**:`"thinking": {"type": "disabled"}`(该代理支持;实测关掉后 reasoning=0、正文正常,配方 3/3 合格)——这是关键修复;
|
|
||||||
> 2. 配方用**纯文本问法**(不要放可照抄的占位示例,否则模型会原样当配方存下来)+ 质量门槛 `_recipe_ok`(过短/含省略号占位 → 丢弃并重试);
|
|
||||||
> 3. 动作提炼用 JSON + **截断容忍**提取(`_loads_lenient` 逐对象抢救)+ 顶层 `{action,params}` 形状归一 + 输入/产出限量(≤10 步输入、≤3 动作×4 步);
|
|
||||||
> 4. 两类失败都有日志(`经验提炼:` / `动作提炼:` 含样本),不再静默。
|
|
||||||
|
|
||||||
### 5.5 元素抓取模态框
|
三条并存:① `data-perm` 属性(`loadMe()` 时统一隐藏无权限元素);② `_can(perm)`(动态拼 HTML 时决定是否出按钮);③ 后端装饰器兜底(403)。
|
||||||
|
|
||||||
独立的第二层模态框(`el-picker-overlay`,z-index 1100),不影响任务编辑窗口:
|
> 前端隐藏只是体验优化,**安全完全依赖后端**;`data-perm` 只在页面加载时求值一次,改权限后需刷新页面。
|
||||||
1. 选择设备 → 2. 加载截图 + 元素树 → 3. 点击元素/边界框 → 4. 回填选择器
|
|
||||||
5. **抓取时直接验证**(每条元素右侧两个按钮,不会与"点击回填"冲突):
|
|
||||||
- 「▶ 点一下」:按元素 `bounds` 中心在设备上真点一次(`POST /api/screen/tap`,`snap=1` 自动吸附到可点元素),返回吸附结果并自动刷新截图——用于确认位置/是否可达;
|
|
||||||
- 「✓ 测选择器」:用**将填入的选择器**真跑一次 click(`POST /api/steps/test`),返回 `命中/未找到/已执行`——用于确认回填的选择器在真实界面能命中(元素无有效选择器时不显示此按钮)。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. 关键设计决策
|
## 7. 关键设计决策
|
||||||
|
|
||||||
### 6.1 为什么用 SQLite 而不是 JSON 文件
|
| 决策 | 为什么 | 代价 / 注意 |
|
||||||
|
|------|--------|------------|
|
||||||
- 支持用户/分组/任务的关系存储
|
| **SQLite + WAL** 而非 MySQL/PG | 单机部署、零运维;WAL 支持多线程读写 | 写并发有限;必须设 `busy_timeout`;`data/` 不入 git |
|
||||||
- 并发安全(WAL 模式)
|
| **threading 而非 asyncio** | uiautomator2 是同步阻塞库;每设备一线程模型直观 | 线程数随设备数增长;跨线程访问 DB 必须自推 app context |
|
||||||
- 迁移旧 JSON 时归档为 `.migrated`,避免删空后重启又复原
|
| **状态放内存** 而非落库 | 状态每秒都变,落库纯属浪费 | 重启丢失运行中状态(进程重启 = 任务终止) |
|
||||||
|
| **蓝图按功能域拆包** | `web_server.py` 只做装配,路由就近维护 | 拆包易出现 import 遗漏 → 用 `scripts/regression_test.py` 兜底 |
|
||||||
### 6.2 为什么用 threading 而不是 asyncio
|
| **单页应用 + 全局脚本** | 无构建步骤、无 npm 依赖,改完强刷即生效 | 11 个文件共享全局作用域,需手工维护加载顺序 |
|
||||||
|
| **任务类型注册表** | 新增任务类型不动调度器 | 删改类型要做兼容(未知类型必须显式报错,不能静默) |
|
||||||
- uiautomator2 是同步阻塞库,不适合 asyncio
|
| **自建设备池**(不用 STF) | 规避共享 adb transport 的历史坑,清单可控 | 需自己处理在线状态、型号、自动发现 |
|
||||||
- 多设备并发用多线程即可,每台设备一个 Worker 线程
|
| **备份"重启生效"** | Windows 无法替换被持有的 db;调度器/worker 持有内存态 | 导入后必须重启(在 `init_db` 之前消费恢复任务) |
|
||||||
- Flask `threaded=True` 处理并发 HTTP 请求
|
| **uiautodev 独立子进程** | 元素抓取是重活,隔离崩溃、可单独重启 | 固定端口 20242;PID 文件防重复拉起(杀之前必须校验 cmdline,防误杀同容器进程) |
|
||||||
|
| **MCP 独立端口** | 外部 AI 用标准协议接入,不侵入 Web 会话 | MCP 端点自身无鉴权,靠网络隔离;写操作用开关门控 |
|
||||||
### 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. 线程模型
|
## 8. 技术红线与实现位置
|
||||||
|
|
||||||
```
|
| 红线 | 为什么 | 代码里的体现 |
|
||||||
主线程(Flask)
|
|------|--------|-------------|
|
||||||
├── HTTP 请求处理(threaded=True,每请求一线程)
|
| **绝不 `adb kill-server`** | 会断掉所有设备的 adb transport,运行中任务全废 | `adb_helper` 只 connect 不 kill;Web 层硬拦截 `kill-server` / `disconnect` 字符串 |
|
||||||
├── TaskManager.scheduler(APScheduler,cron 触发任务计划)
|
| **绝不对 `IP:5555` 设备 `disconnect`** | 该地址的 adb transport 是共享的 | `adb_disconnect` 全项目**零调用方**;`STFDevice.release()` 空实现 |
|
||||||
├── 经验巡检 BackgroundScheduler(03:47 Asia/Shanghai,web_server 装配)
|
| **空闲设备扫描不碰 adb** | 避免扰动共享连接 | 前台扫描对空闲设备直接返回"空闲";设备发现用 socket 探测而非 adb |
|
||||||
├── 设备自动发现线程(device_discovery._discovery_loop,默认 60s 一轮)
|
| **adb key 不变** | 设备信任当前 key,换 key 全部变 unauthorized | 部署沿用既有 `~/.android/adbkey` |
|
||||||
├── 设备池型号采集后台线程(device_pool._refresh_models_bg,启动/手动触发)
|
| **生产只读原则** | 220 是生产机 | 任何写操作(重启 / pull / 改文件)都需负责人确认 |
|
||||||
├── 设备池预连接线程(web_server._preconnect_pool_devices,重启后加速恢复)
|
| **备份覆盖清单** | 漏登记 = 等于没备份 | `core/system_backup.py` 的 `SUMMARY_TABLES`,导出/导入双向自检(见 [DATA_MODEL.md](DATA_MODEL.md) §6) |
|
||||||
├── AI Agent 运行线程(agent_api._agent_thread,单实例 + SSE 推送)
|
| **文档同步** | 文档落后会误导开发与运维 | 功能/配置/接口改动的同一个 commit 里更新 `doc/`(索引见 [doc/README.md](README.md)) |
|
||||||
├── 看门狗线程(_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 的线程。
|
---
|
||||||
|
|
||||||
**线程安全**:
|
## 9. 已知问题与"坑"
|
||||||
- `_WORKERS_LOCK`:保护全局 worker 状态字典
|
|
||||||
- `_ADB_LOCK`:串行化所有 adb 调用
|
### 9.1 代码缺陷(截至 2026-09-10,详见 [backlog/TODO.md](backlog/TODO.md))
|
||||||
- `TaskManager._lock`:保护运行中任务字典
|
|
||||||
- `_ForegroundScanner._cache_lock`:保护前台 App 缓存
|
| 问题 | 现象 | 位置 |
|
||||||
|
|------|------|------|
|
||||||
|
| `GET /locate` 返回 500 | 设备定位大字页打不开(NameError:`render_template_string` / `_esc` 未导入) | `web/monitor.py` |
|
||||||
|
| `POST /api/device/locate`(`show=true`)失败 | 在设备浏览器打开定位页那步抛异常(`urllib` 未导入),被转成 502 | `web/monitor.py` |
|
||||||
|
| CSRF 未实际启用 | 前端会取并携带 `X-CSRF-Token`,但服务端**没有注册** `before_request` 校验 | `web_server.py` 只 import 了 `_csrf_protect` |
|
||||||
|
| 回归脚本在 Windows 不可用 | `scripts/regression_test.py` 用了 `signal.alarm`(Windows 无此 API),直接报错退出 | `scripts/regression_test.py` |
|
||||||
|
| 前端 `--card-line` 未定义 | AI 控制台相关深色容器边框失效(该变量只在 wall.html 定义) | `templates/admin/monitor.html` 的 `:root` |
|
||||||
|
| `countParts` 未定义 | 进度为 0 时可能抛 ReferenceError(被上层 try/catch 吞掉,用户无感) | `static/admin/monitor.js` |
|
||||||
|
|
||||||
|
### 9.2 代码里写明的坑(改代码前务必读)
|
||||||
|
|
||||||
|
| 坑 | 说明 |
|
||||||
|
|----|------|
|
||||||
|
| **跨线程访问 DB 必须自推 app context** | 后台线程用 `with app.app_context()`(各模块 `_ctx()` / `_db()` 已封装) |
|
||||||
|
| **`debug=False` 不热重载** | 改 `core/`、`tasks/` 后必须重启;改 JS 需强刷浏览器 |
|
||||||
|
| **Windows adb 输出不能用 `text=True`** | adb 输出含非 GBK 字节会崩;必须收 bytes 再解码 |
|
||||||
|
| **`u2.connect` / `d.info` 可能永久 hang** | 必须放 `ThreadPoolExecutor` 里加超时 |
|
||||||
|
| **Windows TCP 端口耗尽(WinError 10048)** | 属临时错误,重试需更长退避(代码识别后打 `[transient]`,退避 `max(delay,120)` 秒) |
|
||||||
|
| **抢占时不能在锁内 `stop_device`** | 会死锁 |
|
||||||
|
| **`preempted_job` 必须在重试循环外** | 否则被抢占任务永不归还 |
|
||||||
|
| **删除任务/分组必须显式删行** | 只 upsert 的话,重启会从库里"复活" |
|
||||||
|
| **旧 JSON 迁移后必须归档** | 否则用户删空数据后重启又复原 |
|
||||||
|
| **XPath 位置谓词语义** | `//*[@id="x"][k]` = 父节点下第 k 个;`(//*[@id="x"])[k]` = 第 k 个匹配。抓取器生成后者,执行器会自动纠正历史写法 |
|
||||||
|
| **容器重启后 PID 会被复用** | 杀残留 uiautodev 前必须校验 `/proc/<pid>/cmdline`,否则可能误杀同容器进程 |
|
||||||
|
| **`.env` 用 `setdefault` 注入** | 真实环境变量优先于 `.env` |
|
||||||
|
| **备份必须重启才生效** | 恢复任务在 `init_db` 之前被消费 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 扩展点(改哪里)
|
||||||
|
|
||||||
|
| 想做什么 | 改哪里 | 别忘了 |
|
||||||
|
|---------|--------|--------|
|
||||||
|
| 加一种步骤类型 | `tasks/generic/task.py` 的 `STEP_TYPES` + `_exec_<type>`;`static/admin/editor.js` 的 `STEP_LIB` | 两处保持一致;更新 [TASK_DEV.md](TASK_DEV.md) |
|
||||||
|
| 加一个专属任务类型 | `tasks/<app>/`(照 `tasks/generic/` 结构)+ `tasks/__init__.py` 注册 | 重启生效;更新 [TASK_DEV.md](TASK_DEV.md) |
|
||||||
|
| 加一个 HTTP 接口 | 对应功能域的 `web/xxx_api.py` | 加鉴权装饰器;更新 [API.md](API.md) |
|
||||||
|
| 加一个蓝图 | 新建 `web/xxx_api.py` + 在 `web/__init__.py` 注册 | 更新 [API.md](API.md) 与本文 §1 |
|
||||||
|
| 加一张表 | `core/models.py` 模型 + `SCHEMA_MIGRATIONS`(老库) | **登记进备份覆盖清单** + [DATA_MODEL.md](DATA_MODEL.md)(红线) |
|
||||||
|
| 加一个常驻线程 | 参考 `device_discovery` 的 `init_app` / `shutdown` 模式 | 更新本文 §3 与 [DEVELOPMENT.md](DEVELOPMENT.md) |
|
||||||
|
| 加一个前端模块 | `static/admin/<name>.js` + 在 `monitor.html` 按序引入 | 更新本文 §6 与 [DEVELOPMENT.md](DEVELOPMENT.md) |
|
||||||
|
| 加一个 MCP 工具 | `mcp_server/mcp_server.py`(需要时加 `direct_ops.py`) | 写操作要挂门控三连;更新 [MCP.md](MCP.md) |
|
||||||
|
| 加一个配置键 | `config.py`(程序级)或 `.env`(密钥类) | 更新 `.env.example` + [DEVELOPMENT.md](DEVELOPMENT.md) 配置速查 |
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
# 数据模型(DATA_MODEL)
|
||||||
|
|
||||||
|
> 适用读者:改后端 / 排数据问题的开发者与运维。
|
||||||
|
> 相关文档:[ARCHITECTURE.md](ARCHITECTURE.md)(运行时架构)、[DEPLOY.md](DEPLOY.md) §数据备份(备份覆盖红线)、[API.md](API.md)(消费这些数据的接口)。
|
||||||
|
> **表结构以 `core/models.py` 与各模块的建表 SQL 为准**,本文是它们的映射说明。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 总览
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|----|----|
|
||||||
|
| 引擎 | SQLite(单文件 `data/users.db`) |
|
||||||
|
| driver | Flask-SQLAlchemy(SQLAlchemy 2.x) |
|
||||||
|
| 连接 PRAGMA | `journal_mode=WAL`、`busy_timeout=5000`、`synchronous=NORMAL` |
|
||||||
|
| 建表方式 | ① 模型表:`db.create_all()`;② 版本化迁移 `SCHEMA_MIGRATIONS`;③ 幂等原生 `CREATE TABLE IF NOT EXISTS` |
|
||||||
|
| 主库文件 | `data/users.db`(运行时另有 `-wal` / `-shm`) |
|
||||||
|
| 当前 schema 版本 | `app_meta.schema_version = 4` |
|
||||||
|
|
||||||
|
**表清单(12 张业务表 + 1 张 SQLite 内部表)**
|
||||||
|
|
||||||
|
| # | 表 | 来源 | 用途 |
|
||||||
|
|---|---|------|------|
|
||||||
|
| 1 | `user` | 模型 | 登录用户与权限 |
|
||||||
|
| 2 | `device_group` | 模型 | 设备分组(JSON 存 serial 列表) |
|
||||||
|
| 3 | `task_job` | 模型 | 任务计划 |
|
||||||
|
| 4 | `custom_action` | 模型 | 自定义动作(可复用步骤包) |
|
||||||
|
| 5 | `apk_file` | 模型 | APK 记录 |
|
||||||
|
| 6 | `device` | 模型 + 迁移 v2/v3 | 设备池 |
|
||||||
|
| 7 | `pending_device` | 模型 + 迁移 v4 | 待确认的发现设备 |
|
||||||
|
| 8 | `app_meta` | 原生(迁移前建) | KV 配置(schema 版本、AI 配置、发现配置) |
|
||||||
|
| 9 | `agent_conversation` | 原生(`web/agent_api.py`) | AI 控制台会话 |
|
||||||
|
| 10 | `agent_experience` | 原生(`web/agent_api.py`) | 经验库(任务级配方) |
|
||||||
|
| 11 | `experience_audit` | 原生(`web/agent_api.py`) | 经验巡检结论 |
|
||||||
|
| 12 | `agent_action` | 原生(`web/agent_api.py`) | 动作库(命名动作) |
|
||||||
|
| — | `sqlite_sequence` | SQLite 内部 | `AUTOINCREMENT` 的附带产物(备份自检时按 `sqlite_` 前缀排除) |
|
||||||
|
|
||||||
|
> **没有外键、没有关系(relationship)、没有索引**:全部靠应用层维护一致性。分组 ↔ 设备是多对多的 **JSON 列表**(`device_group.serials`),删除设备不会级联清理分组里的 serial。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 模型表(`core/models.py`)
|
||||||
|
|
||||||
|
### 2.1 `user` — 登录用户
|
||||||
|
|
||||||
|
| 列 | 类型 | 默认 | 说明 |
|
||||||
|
|----|------|------|------|
|
||||||
|
| `id` | Integer | — | 主键 |
|
||||||
|
| `username` | String(80) | — | **唯一**,非空 |
|
||||||
|
| `password_hash` | String(255) | — | werkzeug 加盐哈希;兼容旧裸 SHA-256(校验通过后自动升级) |
|
||||||
|
| `is_admin` | Boolean | `True` | 管理员不受权限位限制 |
|
||||||
|
| `perms` | Text | `"[]"` | JSON 数组:`tasks` / `devices` / `apks` / `logs` |
|
||||||
|
|
||||||
|
方法:`get_perms` / `set_perms` / `has_perm` / `set_password` / `check_password`。
|
||||||
|
|
||||||
|
### 2.2 `device_group` — 设备分组
|
||||||
|
|
||||||
|
| 列 | 类型 | 默认 | 说明 |
|
||||||
|
|----|------|------|------|
|
||||||
|
| `id` | Integer | — | 主键 |
|
||||||
|
| `name` | String(80) | — | **唯一**,非空 |
|
||||||
|
| `serials` | Text | `"[]"` | 组内设备 serial 的 JSON 数组 |
|
||||||
|
| `description` | Text | `""` | 备注 |
|
||||||
|
|
||||||
|
### 2.3 `task_job` — 任务计划
|
||||||
|
|
||||||
|
| 列 | 类型 | 默认 | 说明 |
|
||||||
|
|----|------|------|------|
|
||||||
|
| `id` | String(32) | — | 主键,uuid 前 8 位 |
|
||||||
|
| `name` | String(120) | — | 非空 |
|
||||||
|
| `task_type` | String(60) | `"generic_steps"` | 必须已注册 |
|
||||||
|
| `target` | Text | `'{"mode":"all"}'` | JSON |
|
||||||
|
| `params` | Text | `"{}"` | JSON(与任务类默认值合并后使用) |
|
||||||
|
| `schedule` | Text | `'{"mode":"once"}'` | JSON |
|
||||||
|
| `retry` | Text | `'{"max_attempts":1,"delay":60}'` | JSON |
|
||||||
|
| `enabled` | Boolean | `True` | 是否参与调度 |
|
||||||
|
|
||||||
|
### 2.4 `custom_action` — 自定义动作
|
||||||
|
|
||||||
|
| 列 | 类型 | 默认 | 说明 |
|
||||||
|
|----|------|------|------|
|
||||||
|
| `id` | String(32) | — | 主键 |
|
||||||
|
| `name` | String(120) | — | 非空 |
|
||||||
|
| `icon` | String(4) | `"📦"` | 展示图标 |
|
||||||
|
| `steps` | Text | `"[]"` | JSON,schema 与 `generic_steps` 的 `params.steps` 一致 |
|
||||||
|
| `created_at` | String(20) | `""` | 时间串 |
|
||||||
|
|
||||||
|
### 2.5 `apk_file` — APK 记录
|
||||||
|
|
||||||
|
| 列 | 类型 | 默认 | 说明 |
|
||||||
|
|----|------|------|------|
|
||||||
|
| `id` | String(32) | — | 主键;磁盘文件名为 `<id>.apk` |
|
||||||
|
| `filename` | String(255) | — | 原始文件名 |
|
||||||
|
| `display_name` / `package_name` / `version_name` | String | `""` | 解析结果 |
|
||||||
|
| `version_code` / `size` | Integer | `0` | |
|
||||||
|
| `upload_time` | String(20) | `""` | |
|
||||||
|
|
||||||
|
### 2.6 `device` — 设备池
|
||||||
|
|
||||||
|
| 列 | 类型 | 默认 | 说明 |
|
||||||
|
|----|------|------|------|
|
||||||
|
| `serial` | String(120) | — | 主键:`IP:5555` 或 USB 序列号 |
|
||||||
|
| `name` | String(80) | `""` | 备注名 |
|
||||||
|
| `model` | String(120) | `""` | 型号(迁移 v3 追加) |
|
||||||
|
| `enabled` | Boolean | `True` | 停用则不参与调度 |
|
||||||
|
| `note` | Text | `""` | |
|
||||||
|
| `created_at` | String(20) | `""` | |
|
||||||
|
|
||||||
|
### 2.7 `pending_device` — 待确认的发现设备
|
||||||
|
|
||||||
|
| 列 | 类型 | 默认 | 说明 |
|
||||||
|
|----|------|------|------|
|
||||||
|
| `serial` | String(120) | — | 主键 |
|
||||||
|
| `source` | String(20) | `""` | `lan` / `tailscale` |
|
||||||
|
| `first_seen` / `last_seen` | String(20) | `""` | 时间串 |
|
||||||
|
|
||||||
|
> ⚠️ SQLAlchemy 模型的 `default=` 是 **Python 侧默认值**,SQLite 建表语句里没有 `DEFAULT` 子句;只有原生建表的表才有真正的 SQL DEFAULT。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 非模型表
|
||||||
|
|
||||||
|
### 3.1 `app_meta` — KV 配置
|
||||||
|
|
||||||
|
| 列 | 类型 |
|
||||||
|
|----|------|
|
||||||
|
| `key` | TEXT PRIMARY KEY |
|
||||||
|
| `value` | TEXT |
|
||||||
|
|
||||||
|
由 `_migrate_schema()` 建表(启动必执行)。**所有键见 §5**。
|
||||||
|
|
||||||
|
### 3.2 AI 相关四张表(`web/agent_api.py`,原生建表)
|
||||||
|
|
||||||
|
| 表 | 列 |
|
||||||
|
|----|----|
|
||||||
|
| `agent_conversation` | `id`(PK) · `title` · `messages`(JSON) · `created_at` · `updated_at` |
|
||||||
|
| `agent_experience` | `id`(PK AUTOINCREMENT) · `task_prompt` · `recipe` · `tool_seq` · `hits` · `created_at` |
|
||||||
|
| `experience_audit` | `id`(PK) · `exp_id` · `verdict`(keep/delete) · `score`(REAL) · `reason` · `hits` · `action`(pending/kept/deleted) · `audited_at` |
|
||||||
|
| `agent_action` | `id`(PK) · `name` · `app` · `aliases`(JSON) · `params`(JSON) · `steps`(JSON) · `preconditions` · `hits` · `source_prompt` · `created_at` · `updated_at` |
|
||||||
|
|
||||||
|
> ⚠️ 这四张表**不走 `SCHEMA_MIGRATIONS`,无版本管理**,由各模块首次使用时 `CREATE TABLE IF NOT EXISTS` 幂等创建;建表失败会被 `except: pass` 吞掉(后续 SQL 才会报错)。新增此类表时,务必同时登记进备份覆盖清单(§6)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 迁移机制
|
||||||
|
|
||||||
|
### 4.1 版本化迁移 `SCHEMA_MIGRATIONS`
|
||||||
|
|
||||||
|
| 版本 | 内容 | SQL 幂等性 |
|
||||||
|
|------|------|-----------|
|
||||||
|
| 1 | `user` 增加 `perms` | `ALTER TABLE`(非幂等,靠 duplicate column 兜底) |
|
||||||
|
| 2 | 建 `device` 表(**无 `model` 列**) | 幂等(`IF NOT EXISTS`) |
|
||||||
|
| 3 | `device` 增加 `model` | `ALTER TABLE`(同上兜底) |
|
||||||
|
| 4 | 建 `pending_device` 表 | 幂等 |
|
||||||
|
|
||||||
|
执行器 `_migrate_schema()`:
|
||||||
|
|
||||||
|
1. 先 `CREATE TABLE IF NOT EXISTS app_meta(...)`(幂等)
|
||||||
|
2. 读 `app_meta.schema_version`(缺省 0)
|
||||||
|
3. 只执行 `version > current` 的迁移;**SQL 报 `duplicate column name` 时回滚该语句但仍记版本号**(自愈:`create_all` 已建列而版本未记录时不再卡住),其它异常才抛出
|
||||||
|
4. 每条迁移后 `INSERT OR REPLACE app_meta('schema_version', v)` 并提交
|
||||||
|
5. 整体包 try:失败只记 error 日志,**不阻塞启动**
|
||||||
|
|
||||||
|
### 4.2 旧 JSON 迁移(一次性)
|
||||||
|
|
||||||
|
启动时若存在 `data/groups.json` / `data/jobs.json`:对应表为空则导入,随后把文件重命名为 `<name>.json.migrated` 归档。**库非空但 JSON 仍在 → 直接归档**,防止"用户删空数据后重启又复原"。
|
||||||
|
|
||||||
|
`init_db(app)` 顺序:`db.init_app` → `create_all()` → `_migrate_schema()` → `_ensure_default_admin()`(首次创建 `admin/admin123`)→ `_migrate_old_json()`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. `app_meta` 键清单
|
||||||
|
|
||||||
|
| key | 用途 | 写入方 |
|
||||||
|
|-----|------|--------|
|
||||||
|
| `schema_version` | 迁移版本游标 | `core/models.py` |
|
||||||
|
| `agent_api_base` | AI 接口地址 | AI 控制台配置页 |
|
||||||
|
| `agent_model` | 模型名 | 同上 |
|
||||||
|
| `agent_api_key` | API Key(**明文存库**) | 同上 |
|
||||||
|
| `agent_default_serial` | 默认目标设备 | 同上 |
|
||||||
|
| `agent_max_steps` | 最大步数(钳制 1-200,默认 40) | 同上 |
|
||||||
|
| `discovery_enabled` | 自动发现开关(`"1"`/`"0"`) | 工具页「设备池管理」 |
|
||||||
|
| `discovery_subnets` | 扫描网段 JSON 数组 | 同上 |
|
||||||
|
| `discovery_interval` | 扫描周期秒(10-3600) | 同上 |
|
||||||
|
| `discovery_port` | adb 探测端口(1-65535) | 同上 |
|
||||||
|
|
||||||
|
> ⚠️ `agent_api_key` 是**明文存储**,导出备份的 zip 里也含它——备份预览会固定给出"含敏感信息"告警。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 备份覆盖清单(红线)
|
||||||
|
|
||||||
|
`core/system_backup.py` 的 `SUMMARY_TABLES`(12 张,与库中实际业务表一一对应):
|
||||||
|
|
||||||
|
| 表 | 中文标签 |
|
||||||
|
|----|---------|
|
||||||
|
| `app_meta` | 系统配置(app_meta) |
|
||||||
|
| `user` | 用户 |
|
||||||
|
| `device_group` | 设备分组 |
|
||||||
|
| `task_job` | 任务计划 |
|
||||||
|
| `custom_action` | 自定义动作 |
|
||||||
|
| `apk_file` | APK 记录 |
|
||||||
|
| `device` | 设备池 |
|
||||||
|
| `pending_device` | 待连接设备 |
|
||||||
|
| `agent_conversation` | AI 会话 |
|
||||||
|
| `agent_experience` | 经验库 |
|
||||||
|
| `experience_audit` | 经验巡检 |
|
||||||
|
| `agent_action` | 动作库 |
|
||||||
|
|
||||||
|
**双向自检**:
|
||||||
|
|
||||||
|
- **导出侧**:登记在 `SUMMARY_TABLES` 但快照里缺失 → 写入 `manifest.coverage_missing` + 日志告警
|
||||||
|
- **导入侧**:备份里出现未登记的表(排除 `sqlite_` 前缀)→ 预览告警"请登记进 `core/system_backup.py` 的覆盖清单"(字段 `extra_tables`)
|
||||||
|
- `REQUIRED_TABLES = (app_meta, user, task_job, device_group)`:缺任一直接拒绝导入
|
||||||
|
|
||||||
|
> **红线**:**新增任何持久化表(含原生建表的 agent 类表),必须同时登记进 `SUMMARY_TABLES` 与 `TABLE_LABELS`**,并更新 [DEPLOY.md](DEPLOY.md) §数据备份。历史教训:`agent_action` 曾漏登记,导致"动作库看起来没备份"(数据其实在快照里,只是清单没列)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 数据目录
|
||||||
|
|
||||||
|
| 路径 | 内容 | 进 git |
|
||||||
|
|------|------|--------|
|
||||||
|
| `data/users.db`(+`-wal`/`-shm`) | SQLite 主库 | 否 |
|
||||||
|
| `data/apks/*.apk` | 上传的 APK | 否 |
|
||||||
|
| `data/backups/` | 导出临时 zip、`pre_restore_*.db`(导入前安全网)、`restore_failed_*` | 否 |
|
||||||
|
| `data/restore_staging/<token>/` | 导入暂存(TTL 1800s 自动清理) | 否 |
|
||||||
|
| `data/restore_pending/` | 待生效恢复任务(重启时消费) | 否 |
|
||||||
|
| `data/mcp_audit.log` | MCP 调用审计(路径由 `MCP_AUDIT_FILE` 指定) | 否 |
|
||||||
|
| `data/uiauto.pid` | uiautodev 子进程 PID | 否 |
|
||||||
|
| `data/*.json.migrated` | 旧 JSON 迁移归档 | 否 |
|
||||||
|
| `logs/*.log` | 运行日志 | 否 |
|
||||||
|
|
||||||
|
> `data/` 与 `logs/` 全部是运行时产物,**任何文件都不入 git**。生产机上的库由「系统 → 数据备份导出/导入」或整目录手工备份。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 约定与注意事项
|
||||||
|
|
||||||
|
1. **主键用 uuid 前 8 位字符串**(`task_job` / `custom_action` / `apk_file` / `agent_conversation`):可读性好,理论上有碰撞概率(8 位 hex = 32 bit)。
|
||||||
|
2. **JSON 字段一律 Text 存储**,读写通过 `get_*/set_*` 方法;解析失败回退默认值。
|
||||||
|
3. **时间统一为字符串**(`"YYYY-MM-DD HH:MM"` 或 `"YYYY-MM-DD HH:MM:SS"`),排序依赖字符串序。
|
||||||
|
4. **删除必须显式删行**(`delete_job` / `delete_group`):只 upsert 会导致重启后数据"复活"。
|
||||||
|
5. **跨线程访问 DB 必须自推 app context**(后台线程里用 `with app.app_context()`)。
|
||||||
|
6. **不要手工改库结构**:走 `SCHEMA_MIGRATIONS`(模型表)或幂等建表(原生表),否则 `create_all` 与版本号会不一致。
|
||||||
|
7. **改表结构后记得**:① 更新本文;② 若是新表,登记备份覆盖清单(§6 红线)。
|
||||||
+242
-315
@@ -1,360 +1,287 @@
|
|||||||
# 部署指南
|
# 部署与运维(DEPLOY)
|
||||||
|
|
||||||
本文介绍如何从零部署 `platform-tools` 设备自动化后台。
|
> 适用读者:部署与运维 `auto_control` 的人。
|
||||||
|
> 相关文档:[DEVELOPMENT.md](DEVELOPMENT.md)(开发流程/红线)、[DATA_MODEL.md](DATA_MODEL.md) §数据目录、[ARCHITECTURE.md](ARCHITECTURE.md) §启动装配。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. 环境准备
|
## 1. 环境准备
|
||||||
|
|
||||||
### 1.1 Python 环境
|
### 1.1 主机与 Python
|
||||||
|
|
||||||
- **Python 3.10+**(推荐 3.12)
|
- **Python 3.10+**(推荐 3.12)
|
||||||
- 安装后确认 `python --version` 和 `pip` 可用
|
- Windows / Linux / macOS 均可;生产跑在 Linux 容器里
|
||||||
|
- `pip install -r requirements.txt`
|
||||||
|
|
||||||
```bash
|
项目自带的 **adb 二进制**在 `bin/adb/`(Windows 为 `adb.exe` + 依赖 dll;Linux/macOS 为 `adb`,需 `chmod +x`)。`config.py` 按平台自动选路径,换成自己的 adb 覆盖该目录即可。
|
||||||
# 验证
|
|
||||||
python --version # 应输出 3.10+
|
|
||||||
pip --version
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.2 设备池(已摘除 OpenSTF 依赖)
|
### 1.2 设备接入
|
||||||
|
|
||||||
设备池由平台自身管理(SQLite `devices` 表 + 本机 adb 在线状态),**不再依赖 OpenSTF**。
|
|
||||||
新增设备在管理后台「工具 → 设备池管理」添加(serial 形如 `100.100.10.x:5555`)。
|
|
||||||
|
|
||||||
USB 设备(serial 无冒号)插在部署机(220)时,经 220 的 adb server 驱动:
|
|
||||||
- 220 的 adb 容器需为 **host 网络模式**(5037 已监听所有网卡,含 Tailscale)
|
|
||||||
- 平台配置 `USB_ADB_HOST`(默认 `100.100.10.1`)/ `USB_ADB_PORT`(默认 5037)
|
|
||||||
|
|
||||||
### 1.3 adb 工具
|
|
||||||
|
|
||||||
项目自带 adb 二进制在 `bin/adb/` 目录:
|
|
||||||
|
|
||||||
- **Windows**:`bin/adb/adb.exe` + 依赖 dll(已包含)
|
|
||||||
- **Linux**:`bin/adb/adb`(需 `chmod +x`)
|
|
||||||
- **macOS**:`bin/adb/adb`(需 `chmod +x`)
|
|
||||||
|
|
||||||
如需替换为自己的 adb 版本,把对应平台的 adb 放进 `bin/adb/` 即可,`config.py` 会自动识别操作系统。
|
|
||||||
|
|
||||||
### 1.4 设备准备
|
|
||||||
|
|
||||||
设备需满足:
|
设备需满足:
|
||||||
- 开启 **USB 调试**(设置 → 开发者选项)
|
|
||||||
- 转网络调试:USB 连上后 `adb -s <serial> tcpip 5555`(管理后台「adb 终端」有一键快捷命令)
|
|
||||||
- 设备加入 Tailscale(同一账号,获得 100.100.10.x IP)
|
|
||||||
- 在「工具 → 设备池管理」添加 `IP:5555`(自动尝试连接,任务运行时 u2 自动推送 atx-agent)
|
|
||||||
|
|
||||||
---
|
1. 开启 **USB 调试**
|
||||||
|
2. 转网络调试:USB 连上后 `adb -s <serial> tcpip 5555`(后台「工具 → adb 终端」有快捷命令)
|
||||||
|
3. 与平台**网络互通**(生产用 Tailscale,设备与部署机在同一 tailnet,serial 形如 `100.100.10.x:5555`)
|
||||||
|
4. 在「工具 → 设备池管理」加入设备池(**只连上 adb 不算入池,不参与调度**)
|
||||||
|
|
||||||
## 2. 安装部署
|
**USB 设备**(serial 无冒号)经部署机的 adb server 驱动:
|
||||||
|
|
||||||
### 2.1 获取代码
|
- adb 容器需 **host 网络模式**(5037 监听所有网卡,含 Tailscale)
|
||||||
|
- 平台通过 `USB_ADB_HOST`(默认 `100.100.10.1`)/ `USB_ADB_PORT`(默认 5037)访问它
|
||||||
|
|
||||||
```bash
|
> **adb key 必须沿用既有 key**(设备信任它)。换 key 会让全部设备变 `unauthorized`。
|
||||||
# 方式一:直接拷贝项目目录
|
|
||||||
# 方式二:解压打包文件(python scripts/pack.py 生成的 zip)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.2 安装依赖
|
### 1.3 端口
|
||||||
|
|
||||||
```bash
|
|
||||||
cd platform-tools
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
**依赖清单**(`requirements.txt`):
|
|
||||||
|
|
||||||
| 包 | 版本 | 用途 |
|
|
||||||
|----|------|------|
|
|
||||||
| Flask | >=2.3,<4.0 | Web 框架 |
|
|
||||||
| Flask-Login | >=0.6 | 用户认证 |
|
|
||||||
| Flask-SQLAlchemy | >=3.0,<4.0 | SQLite ORM |
|
|
||||||
| APScheduler | >=3.10,<4.0 | 定时调度 |
|
|
||||||
| requests | >=2.28 | HTTP 客户端 |
|
|
||||||
| uiautomator2 | >=3.0 | Android UI 自动化 |
|
|
||||||
| uiautodev | >=0.14 | UI 元素抓取 |
|
|
||||||
| pyaxmlparser | >=0.3.27 | APK 元信息解析 |
|
|
||||||
| rapidocr_onnxruntime | >=1.4 | 屏幕 OCR(条件判断的 OCR识别 选择器,中英文模型随包内置,跨平台) |
|
|
||||||
| fastmcp | >=2.0 | MCP Server 与 AI 控制台 Agent(Streamable HTTP 服务端/客户端) |
|
|
||||||
| paramiko | >=3.0 | SSH 运维预留(历史 STF SSH 通道退役;配置 `STF_SSH_PASSWORD` 时走密码认证,未配置则退回系统 ssh 免密) |
|
|
||||||
|
|
||||||
> 服务器(无显示器/Linux)环境建议把 opencv-python 换成 `opencv-python-headless`(rapidocr 依赖 cv2,两者取一)。
|
|
||||||
|
|
||||||
> uiautomator2 首次连接设备时会自动推送 atx-agent 到设备,无需手动安装。
|
|
||||||
|
|
||||||
### 2.3 修改配置
|
|
||||||
|
|
||||||
**密钥类配置统一放项目根目录 `.env`**(已被 `.gitignore` 排除,不会提交到 git;
|
|
||||||
`config.py` 不再内置任何密钥):
|
|
||||||
|
|
||||||
```ini
|
|
||||||
# .env 示例
|
|
||||||
WEB_SECRET_KEY=随机字符串(会话密钥,python -c "import secrets;print(secrets.token_hex(32))" 生成)
|
|
||||||
TAILSCALE_API_KEY=你的Tailscale_API_key
|
|
||||||
# USB_ADB_HOST=100.100.10.1 # 220 的 Tailscale IP(USB 设备远程 adb server,默认值已可用)
|
|
||||||
```
|
|
||||||
|
|
||||||
其他配置按需调整:
|
|
||||||
|
|
||||||
| 配置项 | 默认值 | 何时修改 |
|
|
||||||
|-------|--------|---------|
|
|
||||||
| `WEB_HOST` | `0.0.0.0` | 仅本机访问改为 `127.0.0.1` |
|
|
||||||
| `WEB_PORT` | `18050` | 端口冲突时修改 |
|
|
||||||
| `ADB_PATH` | 自动识别 | 用自定义 adb 时修改 |
|
|
||||||
| `USB_ADB_HOST` | `100.100.10.1` | USB 设备所在部署机(220)的 Tailscale IP |
|
|
||||||
| `USB_ADB_PORT` | `5037` | 220 adb 容器监听端口(host 网络模式) |
|
|
||||||
| `TAILSCALE_TAILNET` | 按邮箱前缀 | tailnet 名称/ID(个人账号一般为登录邮箱前缀) |
|
|
||||||
| `DISCOVERY_PORT` | `5555` | 设备自动发现扫描的 adb 端口(网段默认局域网 + Tailscale,可在设备池面板改) |
|
|
||||||
| `DISCOVERY_INTERVAL` | `60` | 自动发现扫描间隔(秒) |
|
|
||||||
|
|
||||||
> 配置来源区分:`WEB_HOST`/`WEB_PORT`/`ADB_PATH` 是 **config.py 常量**(直接改文件,不经 .env);
|
|
||||||
> `TAILSCALE_API_KEY`/`TAILSCALE_TAILNET`/`USB_ADB_HOST`/`USB_ADB_PORT`/`DISCOVERY_PORT`/`DISCOVERY_INTERVAL` 由 config.py 以环境变量读取,**可在 .env 覆盖**;
|
|
||||||
> `BACKUP_DIR`/`RESTORE_STAGING_DIR`/`RESTORE_PENDING_DIR` 是常量(硬编码到 `data/`,见 §3.5)。
|
|
||||||
|
|
||||||
> **未配置 `WEB_SECRET_KEY`**:启动时随机生成(每次重启登录态失效,生产务必配置固定值)。
|
|
||||||
> **工具页 Tailscale 管理前置**:`.env` 写入 `TAILSCALE_API_KEY` 后重启服务;
|
|
||||||
> 未配置时管理分区显示明确提示,不影响其他功能。
|
|
||||||
> (历史遗留的 `STF_URL`/`STF_TOKEN`/`STF_SSH_*` 等配置:`config.py` 仍读取但无任何功能使用,仅历史保留;生产 `.env` 可留空。)
|
|
||||||
|
|
||||||
### 2.4 启动服务
|
|
||||||
|
|
||||||
**命令行启动**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python web_server.py
|
|
||||||
```
|
|
||||||
|
|
||||||
(推荐配合 `scripts/supervise.sh` 进程守护,见 3.1 节)
|
|
||||||
|
|
||||||
**生产容器(220)入口 `scripts/start.sh`**(docker-compose 的 python-app 服务 command):
|
|
||||||
|
|
||||||
1. **依赖就绪守卫**:flask/u2/uiautodev/rapidocr/cv2/fastmcp 全可用则跳过安装;否则 `pip install -r requirements.txt` 并做 cv2 环境修复(卸 GUI opencv → 装 headless)
|
|
||||||
2. **后台拉起 MCP server**:`MCP_ENABLED` 默认 `1` 时执行 `python3 -m mcp_server.mcp_server`(`MCP_ALLOW_WRITE=1`、`MCP_PLATFORM_USER` 默认 admin、`MCP_PLATFORM_PASS` 兜底 `admin123`),日志 `/tmp/mcp_server.log`
|
|
||||||
3. **前台启动主服务**:`exec python -u web_server.py`
|
|
||||||
|
|
||||||
注意:
|
|
||||||
|
|
||||||
- `scripts/supervise.sh` **只守护 web_server,不拉起 MCP**——容器场景 MCP 由 start.sh 拉起,不要用 supervise.sh 替代容器入口
|
|
||||||
- **AI 控制台依赖 MCP**:`AGENT_MCP_URL` 默认 `http://127.0.0.1:8033/mcp`(`mcp_agent/config.py`)
|
|
||||||
- 手动起 web_server 需另启 MCP:`MCP_ALLOW_WRITE=1 python -m mcp_server.mcp_server`
|
|
||||||
- **改过 admin 密码务必同步 `MCP_PLATFORM_PASS`**(start.sh 兜底 `admin123` 会登录失败)
|
|
||||||
- 发布链路:dev 开发测试完成 → 负责人确认合并 main → 220 `git pull` → 重启 python-app 容器生效(详见 DEVELOPMENT §6)
|
|
||||||
|
|
||||||
### 2.5 验证部署
|
|
||||||
|
|
||||||
1. 控制台看到 `启动服务: http://localhost:18050/` 即成功
|
|
||||||
2. 浏览器访问 `http://localhost:18050/`
|
|
||||||
3. 用 `admin/admin123` 登录
|
|
||||||
4. 监控页应显示设备池中的设备(本机 adb 在线 + SQLite 配置清单,serial 形如 `100.100.10.x:5555`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 生产部署建议
|
|
||||||
|
|
||||||
### 3.1 进程守护
|
|
||||||
|
|
||||||
用进程守护工具确保服务自动重启:
|
|
||||||
|
|
||||||
**Windows(NSSM)**:
|
|
||||||
```bat
|
|
||||||
nssm install platform-tools "C:\Python312\python.exe" "D:\platform-tools\web_server.py"
|
|
||||||
nssm start platform-tools
|
|
||||||
```
|
|
||||||
|
|
||||||
**Linux(systemd)**:
|
|
||||||
```ini
|
|
||||||
# /etc/systemd/system/platform-tools.service
|
|
||||||
[Unit]
|
|
||||||
Description=Platform Tools Web Server
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=www
|
|
||||||
WorkingDirectory=/opt/platform-tools
|
|
||||||
ExecStart=/usr/bin/python3 web_server.py
|
|
||||||
Restart=always
|
|
||||||
RestartSec=10
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
```
|
|
||||||
|
|
||||||
**通用脚本(Linux/macOS,无需 systemd)**:
|
|
||||||
|
|
||||||
项目自带 `scripts/supervise.sh`:web_server 崩溃自动重启、带退避和重启上限(防崩溃死循环)。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 用 venv 的 python 守护
|
|
||||||
PYTHON=./.venv/bin/python bash scripts/supervise.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
> 进程守护只保证服务重启,不恢复已运行的任务(worker 状态在内存)。
|
|
||||||
>
|
|
||||||
> **崩溃后占用自愈(需人工核实)**:历史 `AUTO_RELEASE_STALE_OCCUPY` 机制已随 STF 摘除失效(全库代码不再读取该配置)。设备池的占用互斥仍在(任务 running/connecting 的设备会被锁),但崩溃后残留占用如何释放、是否自动清理,需按当前实现核实后补写本节。
|
|
||||||
|
|
||||||
**Docker(生产 python-app 容器)**:
|
|
||||||
|
|
||||||
docker-compose 已配置 `restart: unless-stopped`,web_server 进程退出 → 容器退出 → Docker 自动重启。建议再加健康检查,让编排感知服务存活:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# docker-compose.yaml 的 python-app 服务下添加
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:18050/api/health', timeout=3)"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 3
|
|
||||||
start_period: 10s
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2 反向代理(可选)
|
|
||||||
|
|
||||||
如需 HTTPS 或 80 端口,用 Nginx 反向代理:
|
|
||||||
|
|
||||||
```nginx
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name auto.example.com;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
proxy_pass http://127.0.0.1:18050;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.3 安全加固
|
|
||||||
|
|
||||||
- **修改默认密码**:登录后立即在"用户"Tab 修改 admin 密码
|
|
||||||
- **最小权限分配**:需要多人使用后台时,在"用户"Tab 创建普通用户并只勾选必要权限
|
|
||||||
(任务管理/设备控制/应用管理/日志查看),不要把 admin 密码共享出去
|
|
||||||
- **会话密钥 SECRET_KEY**:在 `.env` 写固定 `WEB_SECRET_KEY`(`python -c "import secrets;print(secrets.token_hex(32))"` 生成);未配置时启动随机生成(每次重启登录态失效,生产务必配置固定值)
|
|
||||||
- **限制访问**:生产环境把 `WEB_HOST` 改为 `127.0.0.1`,配合反向代理
|
|
||||||
- **防火墙**:只开放必要端口
|
|
||||||
|
|
||||||
### 3.4 日志管理
|
|
||||||
|
|
||||||
- 日志自动滚动(10MB 一份,保留 5 份)
|
|
||||||
- 日志目录 `logs/`,可在"日志"Tab 在线查看
|
|
||||||
- 长期运行建议定期清理或配置 logrotate
|
|
||||||
|
|
||||||
### 3.5 数据备份
|
|
||||||
|
|
||||||
**优先推荐平台功能「系统 → 数据备份导出/导入」(仅 admin)**:
|
|
||||||
|
|
||||||
- **导出**:`POST /api/system/backup/export` → 用 sqlite 在线备份 API 对 `data/users.db` 做一致快照,打包 zip(`users.db` + `manifest.json` + 可选 `apks/*.apk`)
|
|
||||||
- **导入**:上传 zip/`.db` → 校验预览(完整性/必需表/schema 版本告警)→ 确认后自动把当前库快照到 `data/backups/pre_restore_*.db`(安全网可回滚)→ 落 `data/restore_pending/` → **重启 web_server 生效**(web_server 在 `init_db` 前自动消费恢复任务)
|
|
||||||
|
|
||||||
目录与常量:
|
|
||||||
|
|
||||||
| 目录 | 用途 |
|
|
||||||
|---|---|
|
|
||||||
| `data/backups/` | 导出临时 zip + 恢复前快照 `pre_restore_*.db` + 校验失败的 `restore_failed_*` |
|
|
||||||
| `data/restore_staging/` | 导入暂存,TTL 30 分钟未应用自动清理 |
|
|
||||||
| `data/restore_pending/` | 待生效恢复任务(重启时消费) |
|
|
||||||
|
|
||||||
常量 `BACKUP_DIR` / `RESTORE_STAGING_DIR` / `RESTORE_PENDING_DIR`(config.py L63-68,非 .env,已 .gitignore)。
|
|
||||||
|
|
||||||
手工备份 `data/` 目录仍可作兜底,但**整库恢复建议走上述功能**(在线一致快照 + 预恢复备份 + 重启原子生效,避免手工替换被 WAL/占用文件破坏)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 网络配置
|
|
||||||
|
|
||||||
### 4.1 端口说明
|
|
||||||
|
|
||||||
| 端口 | 服务 | 说明 |
|
| 端口 | 服务 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| 18050 | Web 后台 | 主服务端口(config.py 可改) |
|
| 18050 | Web 后台 | `config.py` 的 `WEB_PORT`;绑定失败会自动回退候选端口 |
|
||||||
| 20242 | uiautodev | 元素抓取服务(自动启动,固定端口) |
|
| 8033 | MCP Server | `MCP_HTTP_PORT`;由 `scripts/start.sh` 拉起 |
|
||||||
| 8033 | MCP | MCP 手机控制 Server(scripts/start.sh 自动拉起,`MCP_ENABLED=0` 可关) |
|
| 20242 | uiautodev | 元素抓取;`web_server.py` 启动时自动 Popen |
|
||||||
| 5555 | adb | 设备 adb 网络端口(设备端) |
|
| 5555 | 设备 adb | 设备侧端口 |
|
||||||
|
|
||||||
### 4.2 Windows 端口问题(仅 Windows)
|
|
||||||
|
|
||||||
Windows 可能将某些端口范围划为动态排除范围,导致绑定失败(WinError 10013)。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 查看排除的端口范围
|
|
||||||
netsh interface ipv4 show excludedportrange protocol=tcp
|
|
||||||
```
|
|
||||||
|
|
||||||
如果 18050 在排除范围内,修改 `config.py` 的 `WEB_PORT` 到一个不在排除范围内的端口。
|
|
||||||
|
|
||||||
### 4.3 局域网访问
|
|
||||||
|
|
||||||
- `WEB_HOST = "0.0.0.0"` 允许局域网访问
|
|
||||||
- 需要添加防火墙入站规则(Windows 手动添加,或参考 `netsh advfirewall firewall add rule`)
|
|
||||||
- 局域网其他机器访问 `http://部署机IP:18050/`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. 更新升级
|
## 2. 安装与启动
|
||||||
|
|
||||||
### 5.1 代码更新
|
### 2.1 获取代码与依赖
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. 停止服务
|
cd auto_control
|
||||||
# 2. 替换代码文件(或解压新的 zip)
|
|
||||||
# 3. 重新安装依赖(如有新增)
|
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
# 4. 启动服务
|
```
|
||||||
|
|
||||||
|
主要依赖:Flask / Flask-Login / Flask-SQLAlchemy / APScheduler / uiautomator2 / uiautodev / rapidocr_onnxruntime / **opencv-python-headless** / pyaxmlparser / fastmcp / paramiko。
|
||||||
|
|
||||||
|
> ⚠️ **服务器与容器环境必须用 `opencv-python-headless`**:GUI 版 `opencv-python` 依赖 X11 库,`python:slim` 容器里 `import cv2` 直接崩 → OCR 步骤抛异常 → 任务失败退出。版本锁 `<5`(5.x wheel 没有 cv2 模块)。`scripts/start.sh` 会自动做这个替换。
|
||||||
|
|
||||||
|
### 2.2 配置
|
||||||
|
|
||||||
|
**密钥类配置统一放项目根 `.env`**(不入 git,模板见 `.env.example`)。加载方式:逐行解析 + `os.environ.setdefault`(**真实环境变量优先**)。
|
||||||
|
|
||||||
|
生产至少配:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
WEB_SECRET_KEY=<随机 64 hex> # 不配则每次重启登录态失效
|
||||||
|
# USB_ADB_HOST=100.100.10.1 # USB 设备所在的部署机(默认值通常可用)
|
||||||
|
# TAILSCALE_API_KEY=<...> # 需要 Tailscale 管理功能时
|
||||||
|
# MCP_PLATFORM_PASS=<admin 的密码> # 改过 admin 密码必须同步,否则 MCP 登录失败
|
||||||
|
```
|
||||||
|
|
||||||
|
其余配置项与默认值见 [DEVELOPMENT.md](DEVELOPMENT.md) §配置速查。
|
||||||
|
|
||||||
|
### 2.3 启动
|
||||||
|
|
||||||
|
```bash
|
||||||
python web_server.py
|
python web_server.py
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5.2 数据库迁移
|
启动成功日志:
|
||||||
|
|
||||||
- SQLite 表结构变化时,`init_db()` 会自动 `db.create_all()` 创建新表
|
```
|
||||||
- 旧 `groups.json` / `jobs.json` 首次启动自动迁移到 SQLite
|
[INFO] [core.worker] 心跳看门狗已启动
|
||||||
- 迁移后 JSON 文件归档为 `.migrated`(保留备份,不再迁移)
|
[INFO] [core.tm] 从数据库加载 X 个分组, X 个任务
|
||||||
|
[INFO] [web] uiautodev 服务已启动 (PID=...)
|
||||||
|
[INFO] [web] 管理后台: http://localhost:18050/ (admin/admin123)
|
||||||
|
[INFO] [web] 设备池预连接完成: X 台在线
|
||||||
|
[INFO] [web] 启动服务: http://localhost:18050/
|
||||||
|
```
|
||||||
|
|
||||||
### 5.3 注意事项
|
默认账号 **admin / admin123**(**登录后立即改密**)。
|
||||||
|
|
||||||
- **修改 `core/` 目录下的文件后必须重启 web_server**(`debug=False` 不热重载)
|
|
||||||
- **修改 `templates/` 下的 HTML 文件**:Flask 模板默认不缓存,但建议重启确保生效
|
|
||||||
- **修改 `tasks/` 下的文件后必须重启**(任务注册在启动时完成)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. 故障排查
|
## 3. 生产部署(220 容器)
|
||||||
|
|
||||||
### 6.1 启动失败
|
生产环境固定为 **192.168.20.220** 的 `/mnt/data/openstf/auto_control`,由 `docker-compose` 的 **`python-app`** 容器运行:
|
||||||
|
|
||||||
| 现象 | 原因 | 解决 |
|
| 项 | 值 |
|
||||||
|------|------|------|
|
|----|----|
|
||||||
| `ModuleNotFoundError: No module named 'flask'` | 依赖未安装 | `pip install -r requirements.txt` |
|
| 宿主目录 | `/mnt/data/openstf/auto_control`(git 仓库,分支 `main`) |
|
||||||
| `WinError 10013` | 端口被排除/权限不足 | 改端口或用管理员运行 |
|
| 容器挂载 | 宿主目录 → 容器 `/app` |
|
||||||
| `WinError 10048` | 端口被占用 | 改端口或杀占用进程 |
|
| 网络 | `network_mode: host` |
|
||||||
| 监控页设备列表为空 | 设备不在池中 / adb 连不上 | 「工具 → 设备池管理」确认已添加且在线,检查设备网络与 5555 端口 |
|
| 入口 | `scripts/start.sh`(`command`) |
|
||||||
| AI 控制台报「MCP server(8033) 不可达」 | MCP Server 未启动/不可达 | 启动 MCP Server:本机 `MCP_ALLOW_WRITE=1 MCP_PLATFORM_USER=admin MCP_PLATFORM_PASS=<密码> MCP_AUDIT_FILE=data/mcp_audit.log python -m mcp_server.mcp_server`;220 容器由 `scripts/start.sh` 自动拉起(`MCP_ENABLED=0` 会关) |
|
| 重启策略 | `restart: unless-stopped` |
|
||||||
|
| 同级容器 | `adb`(USB 远程 adb server,5037)、以及该机器上其它无关服务 |
|
||||||
|
|
||||||
### 6.2 设备连接失败
|
> ⚠️ **`docker-compose.yaml` 不在仓库里**,只存在于 220 上;仓库里只有 `scripts/start.sh` 这一半。
|
||||||
|
|
||||||
| 现象 | 原因 | 解决 |
|
### 3.1 容器入口 `scripts/start.sh` 做三件事
|
||||||
|------|------|------|
|
|
||||||
| `DeviceOfflineError` | u2 连接超时 / atx-agent 无响应 | 检查设备网络,重启设备或重新推送 atx-agent |
|
|
||||||
| `u2.connect 超时` | atx-agent 无响应 | 重启设备/重新推送 atx-agent |
|
|
||||||
| `adb connect failed` | 设备网络不通/端口未开放 | 检查设备 IP 和 5555 端口 |
|
|
||||||
| 设备显示离线 | 状态缓存/误报 | 管理后台设备池「重连」,或「扫描前台 App」复测 |
|
|
||||||
|
|
||||||
### 6.3 任务不执行
|
1. **依赖就绪守卫**:检查 9 个模块 + `cv2` 可用性;全部可用就跳过安装
|
||||||
|
2. **依赖安装与修复**(仅在需要时):`pip install -r requirements.txt` → 卸载 GUI `opencv-python` → 装 `opencv-python-headless>=4.8,<5` → 再验 cv2,仍异常则 `--force-reinstall`
|
||||||
|
3. **拉起 MCP + 前台启动 Web**:
|
||||||
|
|
||||||
| 现象 | 原因 | 解决 |
|
```
|
||||||
|------|------|------|
|
MCP_ALLOW_WRITE=1 # 脚本内强制开启写操作
|
||||||
| 任务列表有任务但不执行 | 任务未启用 / cron 未到点 | 检查 `enabled` 和 `schedule` |
|
MCP_PLATFORM_USER=${…:-admin}
|
||||||
| 立即执行无反应 | 无可用设备 | 检查设备池是否有空闲设备 |
|
MCP_PLATFORM_PASS=${…:-admin123} # ← 改过 admin 密码必须覆盖它
|
||||||
| worker 状态 error | 查看日志的 `last_error` | 查看 `logs/core.log` 和 `logs/task.log` |
|
MCP_AUDIT_FILE=${…:-/tmp/mcp_audit.log}
|
||||||
| 看门狗误杀 | 长操作未心跳 | 在长循环内加 `self.heartbeat()` |
|
python3 -m mcp_server.mcp_server > /tmp/mcp_server.log 2>&1 &
|
||||||
|
exec python -u web_server.py
|
||||||
|
```
|
||||||
|
|
||||||
### 6.4 日志查看
|
> **`scripts/supervise.sh` 只守护 web_server,不拉起 MCP**——容器场景不要用它替代 `start.sh`。
|
||||||
|
|
||||||
|
### 3.2 发布流程
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 查看核心日志
|
cd /mnt/data/openstf/auto_control
|
||||||
# 方式一:Web 后台"日志"Tab
|
git fetch origin && git checkout main && git pull --ff-only origin main
|
||||||
# 方式二:直接看文件
|
docker restart python-app
|
||||||
# logs/core.log — adb/worker/task_manager
|
|
||||||
# logs/task.log — 任务执行
|
|
||||||
# logs/web.log — Web 请求
|
|
||||||
# logs/action.log — 操作执行
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
重启后验证:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://127.0.0.1:18050/api/health # {"ok":true,"status":"up",...}
|
||||||
|
ss -ltnp | grep -E ':(18050|8033|20242)' # 三个端口都在
|
||||||
|
docker exec python-app tail -5 /tmp/mcp_server.log # MCP 已启动
|
||||||
|
tail -20 logs/web.log # 无 ERROR/Traceback
|
||||||
|
```
|
||||||
|
|
||||||
|
**发布前检查清单**:
|
||||||
|
|
||||||
|
- [ ] 本机 `dev` 已合并 `main` 并推送(且经负责人确认)
|
||||||
|
- [ ] 新增依赖已进 `requirements.txt`
|
||||||
|
- [ ] **新增持久化表已登记进备份覆盖清单**(`core/system_backup.py`,红线)
|
||||||
|
- [ ] 文档已同步(`doc/`)
|
||||||
|
- [ ] 若改过 admin 密码 → 同步容器环境变量 `MCP_PLATFORM_PASS`
|
||||||
|
- [ ] 启动日志里没有"任务类型已不存在"告警(有则说明库里有历史遗留任务,需人工处理)
|
||||||
|
|
||||||
|
> ⚠️ **重启会终止正在运行的任务**(worker 状态在内存)。选低峰期,或先在监控页「停止全部」。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 进程守护(非容器场景)
|
||||||
|
|
||||||
|
| 方式 | 用法 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `scripts/supervise.sh` | `PYTHON=./.venv/bin/python bash scripts/supervise.sh` | 崩溃自动重启;300s 内最多重启 10 次;**不拉起 MCP** |
|
||||||
|
| systemd | `ExecStart=/usr/bin/python3 web_server.py` + `Restart=always` | Linux 通用 |
|
||||||
|
| NSSM | `nssm install auto_control <python> web_server.py` | Windows |
|
||||||
|
| Docker | `restart: unless-stopped` | 生产用法 |
|
||||||
|
|
||||||
|
> 进程守护只保证**服务重启**,不恢复已运行的任务。生产建议加健康检查打 `/api/health`(`interval 30s` / `timeout 5s` / `retries 3` / `start_period 10s`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 数据备份(重要)
|
||||||
|
|
||||||
|
### 5.1 平台内置(推荐)
|
||||||
|
|
||||||
|
**「系统 → 数据备份 / 导入恢复」**(仅管理员):
|
||||||
|
|
||||||
|
- **导出**:`POST /api/system/backup/export` → sqlite 在线备份 API 做一致快照 → 打包 zip(`users.db` + `manifest.json` + 可选 `apks/*.apk`)
|
||||||
|
- **导入**:上传 zip/`.db` → 校验预览(完整性 / 必需表 / schema 版本 / 未登记表告警)→ 确认后自动把当前库快照到 `data/backups/pre_restore_*.db`(安全网)→ 落 `data/restore_pending/` → **重启服务生效**
|
||||||
|
|
||||||
|
### 5.2 备份覆盖清单(红线)
|
||||||
|
|
||||||
|
覆盖清单 = `core/system_backup.py` 的 `SUMMARY_TABLES`,当前 12 张表:`app_meta` / `user` / `device_group` / `task_job` / `custom_action` / `apk_file` / `device` / `pending_device` / `agent_conversation` / `agent_experience` / `experience_audit` / `agent_action`。完整说明见 [DATA_MODEL.md](DATA_MODEL.md) §6。
|
||||||
|
|
||||||
|
> **新增任何持久化表,必须同步登记进该清单**——否则导出预览里看不到它,会被误判为"没有备份"(2026-09-10 动作库 `agent_action` 就踩过:数据其实在快照里,只是清单漏列)。
|
||||||
|
> 导出侧有**覆盖自检**(登记表缺失 → `manifest.coverage_missing` + 日志告警);导入侧有**反向自检**(备份含未登记表 → 预览告警)。
|
||||||
|
|
||||||
|
### 5.3 目录与手工备份
|
||||||
|
|
||||||
|
| 目录 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `data/backups/` | 导出临时 zip、`pre_restore_*.db`、`restore_failed_*` |
|
||||||
|
| `data/restore_staging/` | 导入暂存(TTL 30 分钟自动清理) |
|
||||||
|
| `data/restore_pending/` | 待生效恢复任务(重启时消费) |
|
||||||
|
|
||||||
|
手工整目录备份 `data/` 仍可作兜底,但**整库恢复建议走内置功能**(在线一致快照 + 预恢复备份 + 重启原子生效,避免手工替换被 WAL/占用文件破坏)。
|
||||||
|
|
||||||
|
> ⚠️ 备份 zip 含**用户口令哈希与 AI 控制台 API Key(明文)**,注意保管与传输。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 安全加固
|
||||||
|
|
||||||
|
- **改默认密码**:登录后立即改 admin 密码;需要多人使用时在「用户」页建普通用户并只勾必要权限位
|
||||||
|
- **固定会话密钥**:`.env` 写 `WEB_SECRET_KEY`(`python -c "import secrets;print(secrets.token_hex(32))"`)
|
||||||
|
- **限制访问面**:`WEB_HOST` 改 `127.0.0.1` + Nginx 反代,或靠防火墙只放必要端口
|
||||||
|
- **MCP 端点(8033)自身无鉴权**:它只做**出站**登录平台,不对入站做校验 → **必须靠网络隔离**(同机/内网),不要直接暴露公网
|
||||||
|
- **写操作门控**:`MCP_ALLOW_WRITE=0`(默认)时 MCP 只读
|
||||||
|
- **HTTPS/80 端口**:用 Nginx 反代
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location / { proxy_pass http://127.0.0.1:18050; proxy_set_header Host $host; }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 升级与迁移
|
||||||
|
|
||||||
|
### 7.1 代码升级
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull --ff-only origin main
|
||||||
|
docker restart python-app # 容器场景
|
||||||
|
# 或 systemd: systemctl restart auto_control
|
||||||
|
```
|
||||||
|
|
||||||
|
- **改 `core/`、`tasks/`、`templates/` 后必须重启**(`debug=False` 不热重载)
|
||||||
|
- 改前端 JS 后浏览器需**强刷**(Ctrl+Shift+R)
|
||||||
|
|
||||||
|
### 7.2 数据库迁移
|
||||||
|
|
||||||
|
- 表结构变化由 `init_db()` 自动处理:`create_all()` 建新表 + `SCHEMA_MIGRATIONS` 补列(幂等,失败不阻塞启动)
|
||||||
|
- 旧 `groups.json` / `jobs.json` 首次启动自动迁移并归档为 `.migrated`
|
||||||
|
- **跨版本恢复**:用「导入恢复」上传旧库 → 预览会提示 schema 版本差异 → 应用后重启(新版本会自动补迁移)
|
||||||
|
|
||||||
|
### 7.3 跨机迁移(换部署机)
|
||||||
|
|
||||||
|
1. 新机装依赖、放好 `bin/adb/` 与 **同一把 adb key**
|
||||||
|
2. 复制 `.env`
|
||||||
|
3. 老机「系统 → 数据备份」导出 zip → 新机「导入恢复」上传并应用
|
||||||
|
4. **重启新机服务**(恢复任务在 `init_db` 之前被消费)
|
||||||
|
5. 核对设备池在线状态与任务列表
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 故障排查
|
||||||
|
|
||||||
|
### 8.1 启动类
|
||||||
|
|
||||||
|
| 现象 | 原因 / 处理 |
|
||||||
|
|------|------------|
|
||||||
|
| `ModuleNotFoundError: No module named 'flask'` | 依赖未装:`pip install -r requirements.txt` |
|
||||||
|
| `WinError 10013`(Windows) | 端口被排除/权限不足:`netsh interface ipv4 show excludedportrange protocol=tcp` 查看,或改 `WEB_PORT` |
|
||||||
|
| `WinError 10048` | 端口被占用:换端口或杀占用进程 |
|
||||||
|
| 启动日志只有一半 | 以 WSGI 方式 import 了模块(只跑了 import 期的装配,没跑 `__main__` 分支);直接 `python web_server.py` |
|
||||||
|
| 登录后立刻掉线 | 没配 `WEB_SECRET_KEY`(每次重启换密钥) |
|
||||||
|
| 容器里 `import cv2` 崩 | 装成了 GUI 版 opencv:换 `opencv-python-headless`(`start.sh` 会自动修) |
|
||||||
|
|
||||||
|
### 8.2 设备类
|
||||||
|
|
||||||
|
| 现象 | 处理 |
|
||||||
|
|------|------|
|
||||||
|
| 设备显示离线 | 「设备池管理 → 一键重连」;确认设备在线、网络互通(生产:同 tailnet) |
|
||||||
|
| 加了设备但不被调度 | 只连上 adb 不够,必须在**设备池**中且 `enabled=true` |
|
||||||
|
| `u2.connect 超时` | atx-agent 无响应:重启设备或重新推送 atx-agent(基类有 30s 超时保护) |
|
||||||
|
| 大量设备同时连接时超时 | Windows 端口耗尽(`WinError 10048`):代码会打 `[transient]` 并退避 120s 重试;减少并发或调整系统 TIME_WAIT |
|
||||||
|
| 设备 `unauthorized` | adb key 变了:恢复原 key |
|
||||||
|
|
||||||
|
### 8.3 任务类
|
||||||
|
|
||||||
|
| 现象 | 处理 |
|
||||||
|
|------|------|
|
||||||
|
| 任务不执行 | 检查 `enabled`、`schedule`、是否在运行窗口内、目标设备是否在线 |
|
||||||
|
| 立即执行无反应 | 无可用设备(`resolve_serials` 为空);看 `logs/core.log` |
|
||||||
|
| 任务"成功"但没做事 | 步骤类型未知/缺必填参数会被**告警跳过**;查 `logs/task.log` 的 WARNING |
|
||||||
|
| 执行报"任务类型 xxx 已不存在" | 库里残留了已删除类型的任务(如历史 `douyin_nurture`):在「任务」页删除或改用现有类型。启动日志也会点名列出 |
|
||||||
|
| 长任务被看门狗杀 | 120s 无心跳:业务循环里要周期性 `self.heartbeat()` |
|
||||||
|
| 任务卡在 running | 监控页「停止选中」;必要时重启服务(重启会清空内存状态) |
|
||||||
|
|
||||||
|
### 8.4 其它
|
||||||
|
|
||||||
|
| 现象 | 处理 |
|
||||||
|
|------|------|
|
||||||
|
| 元素抓取按钮不可用 | uiautodev(:20242)没起来;`web_server.py` 启动时自动拉起,手动可 `python -m uiautodev server --no-browser` |
|
||||||
|
| 抓元素超时 | 部分设备 dump 慢(~18s)超过平台超时(8s),见 [backlog/TODO.md](backlog/TODO.md) |
|
||||||
|
| AI 控制台报"MCP server(8033) 不可达" | MCP 没启动:容器由 `start.sh` 拉起,本机手动 `MCP_ALLOW_WRITE=1 python -m mcp_server.mcp_server` |
|
||||||
|
| AI 控制台报 401 | 模型 API Key 无效/过期:在「AI 控制台 ⚙ 配置」重填 |
|
||||||
|
| 备份导入后没变化 | **必须重启服务**,恢复任务在重启时才被消费 |
|
||||||
|
|
||||||
|
日志位置:`logs/core.log`(adb/调度)、`logs/task.log`(任务执行)、`logs/web.log`(Web/AI)、`logs/action.log`;容器内 MCP 日志 `/tmp/mcp_server.log`。
|
||||||
|
|||||||
+166
-197
@@ -1,267 +1,236 @@
|
|||||||
# 开发手册(DEVELOPMENT)
|
# 开发手册(DEVELOPMENT)
|
||||||
|
|
||||||
面向本项目开发者:开发流程、git 工作流、环境说明、技术红线、本地开发、常见开发任务。
|
> 适用读者:所有参与 `auto_control` 开发的人。**动手前先读 §2 技术红线**。
|
||||||
|
> 相关文档:[ARCHITECTURE.md](ARCHITECTURE.md)(架构,先懂再改)、[DATA_MODEL.md](DATA_MODEL.md)(表结构)、[doc/README.md](README.md)(文档索引与维护约定)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. 开发流程与 git 工作流
|
## 1. 开发流程
|
||||||
|
|
||||||
### 1.1 分支策略
|
### 1.1 分支策略
|
||||||
|
|
||||||
| 分支 | 用途 |
|
| 分支 | 用途 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `dev` | **开发分支**,所有新功能/修复都在这里开发 |
|
| `dev` | 开发分支,所有新功能/修复从它切出、合并回它 |
|
||||||
| `main` | **生产分支**(主分支),只放已确认的稳定版本 |
|
| `main` | 生产分支,只放已确认的稳定版本 |
|
||||||
|
| `fix/xxx` · `feat/xxx` · `chore/xxx` | 单个改动的临时分支(从 `dev` 切出) |
|
||||||
|
|
||||||
生产环境 = 部署机 `192.168.20.220` 的 `/mnt/data/openstf/auto_control`(python-app 容器运行 `web_server.py`)。
|
生产环境 = 部署机 `192.168.20.220` 的 `/mnt/data/openstf/auto_control`(`python-app` 容器)。
|
||||||
|
|
||||||
### 1.2 git 操作铁律(重要)
|
### 1.2 git 铁律
|
||||||
|
|
||||||
**所有 git 操作都必须先经项目负责人明确确认后才能执行**,包括但不限于:
|
**所有 git 操作都必须先经项目负责人明确确认**,包括但不限于 `commit` / `push`(**即使 push 到 dev 也要确认**)/ `merge` / `rebase` / `reset` / `branch -D`。
|
||||||
|
|
||||||
- `commit` / `push`(**即使 push 到 dev 也要确认**)
|
**标准流程**:
|
||||||
- `merge`(dev → main)
|
|
||||||
- `revert` / `checkout` / `reset` / `branch -D` 等
|
|
||||||
|
|
||||||
**标准流程:**
|
|
||||||
|
|
||||||
```
|
```
|
||||||
1. 在 dev 分支开发、本地测试
|
① 本机新建分支 fix/xxx 或 feat/xxx(从 dev 切出)
|
||||||
2. 完成改动 → 把改动清单 + 建议 commit 信息 列给负责人
|
② 分支上开发 + 自测 跑通本机(服务/接口/页面),能自测就别只靠"看代码没问题"
|
||||||
3. 负责人确认 → 才能 commit + push dev
|
③ 交负责人确认 ★ 未经确认不合 dev
|
||||||
4. 需要发布 → 负责人确认后再合并到 main
|
④ 合并到 dev 确认通过后(保持线性:rebase 后 ff-merge)
|
||||||
5. 部署生产 → 负责人明确指示后才 pull 到 220
|
⑤ dev 整体就绪 功能齐全、验证完毕
|
||||||
|
⑥ 合并到 main ★ 负责人确认后
|
||||||
|
⑦ 生产 220 部署 git pull → docker restart python-app → 验证(见 DEPLOY.md §3)
|
||||||
```
|
```
|
||||||
|
|
||||||
> 不允许"开发完顺手就 commit/push"。即使是一次性小改动,也要先确认。
|
> 每个改动**单独分支 + 单独 commit**,主题单一,便于评审与回退。不允许"开发完顺手 commit/push"。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. 环境说明
|
## 2. 技术红线(违反会打断共享 adb transport 或造成生产事故)
|
||||||
|
|
||||||
| 环境 | 位置 | 说明 |
|
| # | 红线 | 为什么 | 代码里的体现 |
|
||||||
|------|------|------|
|
|---|------|--------|-------------|
|
||||||
| 开发机 | 本机(192.168.20.57) | `.venv` + 本地运行 `web_server.py` |
|
| 1 | **绝不 `adb kill-server`** | 会断掉所有设备的 adb transport,运行中任务全废 | `core/adb_helper.py` 只 connect 不 kill;Web 层硬拦截 `kill-server`/`disconnect` 字符串 |
|
||||||
| 生产机 | 部署机 220 的 `auto_control` | python-app 容器,`network_mode: host` |
|
| 2 | **绝不对 `IP:5555` 设备 `adb disconnect`** | 该地址的 adb transport 是共享的 | `adb_disconnect` **全项目零调用方**;`STFDevice.release()` 空实现 |
|
||||||
| STF 服务 | ~~`192.168.20.220:7100`~~ | 已停用(代码已摘除依赖)。注意:此处"已停用"与 [STF_REMOVAL.md](STF_REMOVAL.md) 的"待人工确认"项矛盾(220 侧是否已 `docker stop stf` 未核实),需以 220 实际为准 |
|
| 3 | **空闲设备扫描不主动 connect/disconnect** | 避免扰动共享连接 | 前台扫描对空闲设备直接返回"空闲";设备发现用 socket 探测 |
|
||||||
| adb 容器 | 220 上 `adb`(host 网络 5037) | USB 设备远程 adb server;网络设备补连用 |
|
| 4 | **adb key 保持历史 key 不变** | 设备信任该 key,换 key 全部 `unauthorized` | 部署沿用 `~/.android/adbkey` |
|
||||||
| 设备 | Tailscale `100.100.10.x:5555` | Xiaomi 舰队,本机 `100.100.10.2` 在 tailnet 内 |
|
| 5 | **生产(220)默认只读** | 生产事故成本高 | 任何写操作(pull/重启/改文件)都需负责人确认 |
|
||||||
| uiautodev | 本机 `20242` | 元素抓取服务(web_server 自动拉起) |
|
| 6 | **新增持久化表必须登记备份覆盖清单** | 漏登记 = 等于没备份 | `core/system_backup.py` 的 `SUMMARY_TABLES` + `TABLE_LABELS`,详见 [DEPLOY.md](DEPLOY.md) §5.2 |
|
||||||
|
| 7 | **功能/配置/接口改动必须同步文档** | 文档落后会误导开发与运维 | 见 §6;索引 [doc/README.md](README.md) |
|
||||||
|
|
||||||
### 2.1 adb key(关键)
|
### 其它开发约束
|
||||||
|
|
||||||
- 本机 `~/.android/adbkey` 沿用历史 key(原取自 STF adb 容器,全部设备都信任)
|
- **`web_server.py` 以 `debug=False` 运行**:改 `core/`、`tasks/`、`templates/` 后**必须重启**;改前端 JS 后**强刷浏览器**
|
||||||
- **不要随意更换 key**——设备会变 unauthorized 连不上
|
- **任务参数放各自 `tasks/<app>/` 顶部**,不放 `config.py`
|
||||||
- 旧 key 备份在 `~/.android/adbkey.local.bak`
|
- **运行时数据不提交 git**:`data/`、`logs/` 全是运行时产物
|
||||||
- 生产环境的容器也需要用这把 key(部署时处理)
|
- **不要移除分页与错峰**:监控/列表页已分页(100 台设备只渲染 10 行/页);任务批量触发已错峰(`_START_STAGGER_SEC`)
|
||||||
|
- **不要手工改库结构**:走 `SCHEMA_MIGRATIONS`(模型表)或幂等原生建表
|
||||||
### 2.2 设备连接方式
|
|
||||||
|
|
||||||
- 设备 serial 是 `IP:5555`(Tailscale 地址),**直连**优先(只 connect、绝不 disconnect)
|
|
||||||
- USB 设备(serial 无冒号):插本机走本地 adb;插 220 走远程 adb server(`USB_ADB_HOST:5037`)
|
|
||||||
- 本机已在 tailnet 内,直连可靠且快(<1s)
|
|
||||||
- 设备加入/退出平台:工具 → 设备池管理(SQLite 清单,自动连接 + 型号采集)
|
|
||||||
|
|
||||||
### 2.3 配置键速查(`config.py` / `.env`)
|
|
||||||
|
|
||||||
`.env` 加载方式:项目根目录逐行解析、`os.environ.setdefault`(环境变量已设则不覆盖)。以下默认值以 `config.py` 为准:
|
|
||||||
|
|
||||||
| 键 | 默认值 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| `WEB_HOST` / `WEB_PORT` | `0.0.0.0` / `18050` | Web 后台监听(18050 避开 Windows 动态端口范围) |
|
|
||||||
| `DATA_DIR` / `APK_DIR` | `data/` / `data/apks/` | 持久化数据目录 / APK 存储目录 |
|
|
||||||
| `BACKUP_DIR` / `RESTORE_STAGING_DIR` / `RESTORE_PENDING_DIR` | `data/backups` / `data/restore_staging` / `data/restore_pending` | 系统备份:导出 zip、导入暂存、待重启生效的恢复目录 |
|
|
||||||
| `ADB_PATH` | `bin/adb/adb`(Windows 为 `adb.exe`) | 按平台自动识别,代码只拼路径 |
|
|
||||||
| `USB_ADB_HOST` / `USB_ADB_PORT` | `100.100.10.1` / `5037` | 220 的 adb 容器(host 网络),驱动远程 USB 设备 |
|
|
||||||
| `DISCOVERY_PORT` / `DISCOVERY_SUBNETS` / `DISCOVERY_INTERVAL` | `5555` / 局域网+Tailscale 网段 / `60` | 设备自动发现;可在工具页设备池面板改,存 `app_meta` `discovery_*` 覆盖默认 |
|
|
||||||
| `TAILSCALE_API_KEY` / `TAILSCALE_TAILNET` | 空 / 空 | 工具页 Tailscale 管理(官方 API v2) |
|
|
||||||
| `WEB_SECRET_KEY` | 未配置则随机生成 | 会话密钥(web_server 读 `.env`;不配则重启登录态失效) |
|
|
||||||
| `STF_URL` / `STF_TOKEN` / `STF_SSH_*` | 废弃 | STF 摘除后仅历史保留,代码不再使用 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. 技术红线(开发限制)—— 违反会打断共享 adb transport,需人工恢复
|
## 3. 本地开发
|
||||||
|
|
||||||
这些是踩过坑后总结的,**任何修改都不能引入**。违反任何一条都会导致设备连接被全部重建(历史原因:STF provider 共享同一 adb transport,摘除 STF 后仍保留此约束):
|
### 3.1 安装
|
||||||
|
|
||||||
1. **绝不 `adb kill-server`**
|
|
||||||
- 会断开所有设备的 adb transport,全部设备连接被重建,运行中任务中断
|
|
||||||
- 见 `core/adb_helper.py`
|
|
||||||
|
|
||||||
2. **绝不对 `IP:5555` 设备 `adb disconnect`**
|
|
||||||
- 该地址的 adb transport 是共享的(历史与 STF provider 共用),disconnect 会断掉全部相关连接
|
|
||||||
- 直连模式下 `release()` 不 disconnect
|
|
||||||
- 见 `core/device_worker.py` `STFDevice.release()`
|
|
||||||
|
|
||||||
3. **空闲设备扫描不主动 connect/disconnect**
|
|
||||||
- IP:5555 的 transport 由多方共享(历史与 STF provider 共用),外部 connect/disconnect 会扰动共享连接
|
|
||||||
- `_ForegroundScanner._scan_free` 对空闲设备直接返回"空闲",不碰 adb
|
|
||||||
- 见 `core/task_manager.py`
|
|
||||||
|
|
||||||
4. **adb key 保持历史 key 不变**(见 2.1,设备信任该 key)
|
|
||||||
|
|
||||||
5. **直连优先,不引入第三方桥接**(见 2.2)
|
|
||||||
|
|
||||||
### 3.1 其他开发限制
|
|
||||||
|
|
||||||
- **`web_server.py` 以 `debug=False` 运行,不热重载**:改 `core/`、`tasks/`、`templates/`、`static/` 后必须重启 web_server(前端 HTML 改完强刷浏览器)
|
|
||||||
- **任务参数放各自 `tasks/<app>/task.py` 顶部,不放 `config.py`**
|
|
||||||
- **生产环境(220)默认只读**:任何写操作(改文件/重启容器/部署)都必须先经负责人确认
|
|
||||||
- **数据库是 SQLite**(`data/users.db`,WAL 模式):运行时数据不提交 git
|
|
||||||
- **前端 JS 已拆分多文件**(均位于 `static/admin/`):`monitor.html` 按 `base.js → list.js → monitor.js → editor.js → tasks.js → tools.js → apps.js → admin.js → agent.js → system.js` 的顺序用 `<script src>` 加载
|
|
||||||
- **监控页/大列表已加分页**:100 台设备也只渲染 10 行/页,不要移除分页逻辑
|
|
||||||
- **任务批量触发已错峰**(`_START_STAGGER_SEC`):避免大量设备同时启动造成 adb 连接风暴,不要移除
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 本地开发手册
|
|
||||||
|
|
||||||
### 4.1 首次安装
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 用 Python 3.12 建 venv(README 推荐版本)
|
python -m venv .venv
|
||||||
/opt/homebrew/bin/python3.12 -m venv .venv
|
# Windows: .venv\Scripts\activate Linux/macOS: source .venv/bin/activate
|
||||||
.venv/bin/pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
|
||||||
# macOS:adb 用系统自带的(bin/adb/adb 是被 gitignore 的符号链接)
|
|
||||||
ln -sf /opt/homebrew/bin/adb bin/adb/adb
|
|
||||||
|
|
||||||
# 确认 adb key(必须是 STF 容器的 key,否则设备连不上)
|
|
||||||
ls -la ~/.android/adbkey
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4.2 启动
|
确认 adb key(沿用既有 key,否则设备连不上):`ls -la ~/.android/adbkey`。
|
||||||
|
|
||||||
|
### 3.2 启动
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
.venv/bin/python web_server.py
|
python web_server.py
|
||||||
# 访问 http://localhost:18050/ 账号 admin/admin123
|
# 访问 http://localhost:18050/ admin / admin123
|
||||||
```
|
```
|
||||||
|
|
||||||
启动日志看到以下即成功:
|
需要 AI 控制台 / MCP 时,**另起一个进程**:
|
||||||
```
|
|
||||||
[INFO] [core.worker] 心跳看门狗已启动
|
```bash
|
||||||
[INFO] [web] 管理后台: http://localhost:18050/ (admin/admin123)
|
MCP_ALLOW_WRITE=1 MCP_PLATFORM_USER=admin MCP_PLATFORM_PASS=<密码> \
|
||||||
[INFO] [web] 启动服务: http://localhost:18050/
|
python -m mcp_server.mcp_server # 默认 http://127.0.0.1:8033/mcp
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4.3 测试
|
(容器里由 `scripts/start.sh` 自动拉起,不需要手动。)
|
||||||
|
|
||||||
- **后端逻辑**:直接 `.venv/bin/python -c "..."` 调用(如 `tasks/`、`core/` 的函数)
|
### 3.3 调试
|
||||||
- **前端 UI**:Playwright(系统 `python3` 已装),脚本示例见下
|
|
||||||
- **浏览器冒烟**:切 6 个 tab、开任务编辑器,确认无 JS 错误
|
|
||||||
|
|
||||||
```python
|
| 目的 | 做法 |
|
||||||
# Playwright 冒烟示例(python3 运行)
|
|------|------|
|
||||||
from playwright.sync_api import sync_playwright
|
| 看日志 | `logs/core.log`(adb/调度)、`task.log`(任务)、`web.log`(Web/AI)、`action.log`;或「日志」Tab |
|
||||||
with sync_playwright() as p:
|
| 看设备/任务状态 | 监控页;或 `GET /api/status`、`GET /api/health` |
|
||||||
b = p.chromium.launch(headless=True)
|
| 后端逻辑验证 | 直接 `python -c "…"` 调用 `core/`、`tasks/` 的函数(如构造 worker 检查参数合并) |
|
||||||
pg = b.new_page()
|
| 前端验证 | 无 npm/构建,改完强刷;浏览器控制台看报错 |
|
||||||
pg.goto("http://127.0.0.1:18050/login")
|
| 接口 500 巡检 | `python scripts/regression_test.py`(**⚠️ 当前在 Windows 上会因 `signal.alarm` 报错**,Linux/macOS 可用) |
|
||||||
pg.fill("input[name=username]", "admin")
|
| 停止设备/清异常 | 监控页「停止全部 / 停止选中 / 清除全部异常」 |
|
||||||
pg.fill("input[name=password]", "admin123")
|
|
||||||
pg.click("input[type=submit], button[type=submit]")
|
|
||||||
pg.wait_for_load_state("networkidle")
|
|
||||||
# ... 检查各 tab、编辑器
|
|
||||||
b.close()
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.4 常用调试
|
> **改了数据库/配置想复原**:删 `data/users.db*` 会丢数据,别这么干;用「系统 → 备份/导入」或先手工复制一份 `data/`。
|
||||||
|
|
||||||
- **看日志**:`logs/` 下 `core.log` / `task.log` / `web.log` / `action.log`(10MB 滚动,保留 5 份)
|
### 3.4 写测试的约定(重要)
|
||||||
- **看设备/任务状态**:浏览器监控页,或 `GET /api/status`、`GET /api/health`
|
|
||||||
- **停止设备/清异常**:监控页工具条用"停止全部 / 停止选中 / 清除全部异常"(`AUTO_RELEASE_STALE_OCCUPY` 等 STF occupy 残留清理配置代码已不再读取);崩溃残留的 worker 状态重启即清零
|
本仓库没有单元测试框架,验证靠"跑起来 + 真实调用"。写验证脚本时**必须**:
|
||||||
- **打包项目**:`python scripts/pack.py`
|
|
||||||
|
- **不要碰真实数据**:需要会话/任务/分组时**自建**(如 `POST /api/agent/conversations` 建专属会话),绝不要依赖"当前选中项",也不要删自己没建的东西
|
||||||
|
- **改配置前后都要回读校验**:改前 GET 存原值,收尾写回后**再 GET 比对**,不一致要显式报错
|
||||||
|
- 临时数据用完即删,并核对"集合已复原"
|
||||||
|
|
||||||
|
> 教训:曾用浏览器脚本跑 AI 控制台冒烟,脚本清空 `localStorage` 后前端自动选中了**用户最近的会话**,收尾的"删除测试会话"把用户真实会话删了;同一脚本还把 AI 配置改成了假值。恢复手段见 [DATA_MODEL.md](DATA_MODEL.md) §7 与 git 历史。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 配置速查
|
||||||
|
|
||||||
|
### 4.1 配置文件与优先级
|
||||||
|
|
||||||
|
- `config.py`:**程序级常量**(端口、路径、USB 远程 adb 等),改它要重启
|
||||||
|
- `.env`(项目根,不入 git):**密钥与可覆盖配置**,逐行解析 + `os.environ.setdefault`(**真实环境变量优先**)
|
||||||
|
- `app_meta`(数据库 KV):**运行时可改的配置**(AI 配置、设备发现参数),在界面上改
|
||||||
|
|
||||||
|
### 4.2 `config.py` 常量
|
||||||
|
|
||||||
|
| 常量 | 默认值 | 来源 | 说明 |
|
||||||
|
|------|--------|------|------|
|
||||||
|
| `ADB_PATH` | `bin/adb/adb(.exe)` | 按平台自动 | **不可用 env 覆盖** |
|
||||||
|
| `WEB_HOST` / `WEB_PORT` | `0.0.0.0` / `18050` | 硬编码 | 18050 避开 Windows 动态端口段 |
|
||||||
|
| `DATA_DIR` / `APK_DIR` | `data/` / `data/apks/` | 代码计算 | |
|
||||||
|
| `BACKUP_DIR` / `RESTORE_STAGING_DIR` / `RESTORE_PENDING_DIR` | `data/backups` / `data/restore_staging` / `data/restore_pending` | 代码计算 | 备份相关 |
|
||||||
|
| `USB_ADB_HOST` / `USB_ADB_PORT` | `100.100.10.1` / `5037` | `.env` 可覆盖 | USB 设备所在部署机的远程 adb server |
|
||||||
|
| `TAILSCALE_API_KEY` / `TAILSCALE_TAILNET` | 空 | `.env` | Tailscale 管理功能 |
|
||||||
|
| `DISCOVERY_PORT` / `DISCOVERY_INTERVAL` | `5555` / `60` | `.env` 可覆盖 | 设备发现(网段在工具页配置,存 `app_meta`) |
|
||||||
|
| `DISCOVERY_SUBNETS` | 局域网 + Tailscale 网段 | **硬编码列表**(当前无 env 支持) | 默认扫描网段 |
|
||||||
|
| `STF_*` | 空 | `.env` | **已废弃**,仅历史保留,代码不再使用 |
|
||||||
|
|
||||||
|
### 4.3 进程读取的环境变量(不在 config.py)
|
||||||
|
|
||||||
|
| 变量 | 默认 | 谁读 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `WEB_SECRET_KEY` | 未配置则随机 | `web_server.py` | 会话密钥,**生产必须固定** |
|
||||||
|
| `DISABLE_SCHEDULER` | 未设 | `core/task_manager.py` | 设任意值则**不启动 cron 调度器**(测试用) |
|
||||||
|
| `MCP_ENABLED` | `1` | `scripts/start.sh` | =0 则不后台拉起 MCP |
|
||||||
|
| `MCP_ALLOW_WRITE` | `0`(脚本内强制 1) | `mcp_server/config.py` | 写操作总开关 |
|
||||||
|
| `MCP_PLATFORM_URL` / `_USER` / `_PASS` | `http://127.0.0.1:18050` / `admin` / 空 | `mcp_server/config.py` | 登录平台的凭据(改过 admin 密码要同步) |
|
||||||
|
| `MCP_ALLOWED_SERIALS` | 空=不限 | 同上 | 逗号分隔白名单(语义缺口见 [backlog](backlog/TODO.md)) |
|
||||||
|
| `MCP_HTTP_HOST` / `_PORT` | `0.0.0.0` / `8033` | 同上 | |
|
||||||
|
| `MCP_SCREENSHOT_WIDTH` / `MCP_JPEG_QUALITY` | `540` / `70` | 同上 | 返回给模型的截图层参数 |
|
||||||
|
| `MCP_AUDIT_FILE` | `/var/log/mcp/audit.log`(脚本兜底 `/tmp/mcp_audit.log`) | 同上 | 审计日志路径 |
|
||||||
|
| `AGENT_API_BASE` / `AGENT_MODEL` / `AGENT_API_KEY` / `DEEPSEEK_API_KEY` | DeepSeek 默认地址 / 默认模型 / 空 | `mcp_agent/config.py` | **仅 CLI 用**;Web 端 AI 配置优先读数据库 `app_meta` |
|
||||||
|
| `AGENT_MCP_URL` / `AGENT_MAX_STEPS` / `AGENT_TIMEOUT` / `AGENT_LANG` | `http://127.0.0.1:8033/mcp` / 40 / 120 / zh | 同上 | |
|
||||||
|
| `ANDROID_ADB_SERVER_ADDRESS` / `_HOST` / `_PORT` | 未设 | **adb 客户端自身**(非本项目代码) | 把 adb 调用指向远程 server;两套变量名都要设 |
|
||||||
|
|
||||||
|
> `mcp_server/config.py` 与 `mcp_agent/config.py` **不读 `.env`**(只读进程环境变量),与根 `config.py` 的行为不同。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. 常见开发任务
|
## 5. 常见开发任务
|
||||||
|
|
||||||
### 5.1 新增 App 任务
|
> 每一项的"改哪里"清单也见 [ARCHITECTURE.md](ARCHITECTURE.md) §10。
|
||||||
|
|
||||||
参照 `tasks/douyin/` 结构,详见 **[doc/TASK_DEV.md](TASK_DEV.md)**(6 步模板)。
|
### 5.1 新增 HTTP 接口
|
||||||
|
|
||||||
```
|
1. 在对应功能域的 `web/xxx_api.py` 加 `@bp.route(...)` + 鉴权装饰器(`@login_required` / `@perm_required(PERM_X)` / `@admin_required`)
|
||||||
tasks/<app>/
|
2. 新蓝图需在 `web/__init__.py` 的 `register_blueprints` 里注册
|
||||||
__init__.py # from . import task
|
3. 更新 [API.md](API.md)(路由索引表 + 详细小节)
|
||||||
task.py # DEFAULT_PARAMS + Worker + @register_task
|
|
||||||
actions/ # 专属操作(可选)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.2 新增专属操作
|
### 5.2 新增数据库字段/表
|
||||||
|
|
||||||
在 `tasks/<app>/actions/` 建 `.py`,继承 `BaseAction` + `@register_action(ACTIONS)`,在 `__init__.py` import。
|
- 模型改 `core/models.py`;新表 `create_all()` 会建
|
||||||
|
- **老库**要在 `SCHEMA_MIGRATIONS` 里加迁移(版本号递增 + SQL)
|
||||||
|
- **新增表**:登记进 `core/system_backup.py` 的 `SUMMARY_TABLES` + `TABLE_LABELS`(**红线**)
|
||||||
|
- 更新 [DATA_MODEL.md](DATA_MODEL.md) 与 [DEPLOY.md](DEPLOY.md) §5.2
|
||||||
|
|
||||||
### 5.3 修改前端
|
### 5.3 修改前端
|
||||||
|
|
||||||
| 文件 | 内容 |
|
| 改什么 | 文件 |
|
||||||
|------|------|
|
|--------|------|
|
||||||
| `templates/admin/monitor.html` | HTML 结构 + CSS + `<script src>` 引用 |
|
| 页面结构 / 样式 / 引入脚本 | `templates/admin/monitor.html` |
|
||||||
| `static/admin/`(base/list/monitor/editor/tasks/tools/apps/admin/agent/system.js) | 前端 JS(按 monitor.html 中 `<script src>` 顺序拆分加载,功能归属见各文件) |
|
| 公共工具(API/权限/Toast/Tab) | `static/admin/base.js` |
|
||||||
|
| 列表分页排序 | `static/admin/list.js` |
|
||||||
|
| 各功能域逻辑 | `static/admin/{monitor,editor,tasks,tools,apps,admin,agent,system,markdown}.js` |
|
||||||
|
|
||||||
改完**强刷浏览器**(Cmd/Ctrl+Shift+R),必要时重启 web_server。
|
新增 JS 模块:建文件 → 在 `monitor.html` 里按依赖顺序加 `<script src>` → 更新 [ARCHITECTURE.md](ARCHITECTURE.md) §6.3 与本文 §5.3。
|
||||||
|
|
||||||
### 5.4 新增 API
|
### 5.4 新增步骤类型 / 任务类型
|
||||||
|
|
||||||
路由按功能域放在 `web/` 蓝图包(`web/__init__.py` 的 `register_blueprints(app)` 统一注册 10 个蓝图:auth / monitor / tasks / admin / tools / devices / apks / tailscale / agent / system)。
|
见 [TASK_DEV.md](TASK_DEV.md)(步骤类型要同时改后端 `STEP_TYPES` 与前端 `STEP_LIB`)。
|
||||||
|
|
||||||
- 新增 API:在对应功能域的 `web/xxx_api.py` 里加 `@bp.route(...)` + 权限装饰器(如 `admin_required`)
|
### 5.5 新增 MCP 工具
|
||||||
- 新建蓝图:需在 `web/__init__.py` 里 import 并加进 `register_blueprints` 的注册元组
|
|
||||||
- `web_server.py` 只做 app 装配(初始化、`register_blueprints(app)`、常驻线程启动),一般不改
|
|
||||||
- 最后更新 **[doc/API.md](API.md)**
|
|
||||||
|
|
||||||
### 5.5 新增数据库字段/表
|
在 `mcp_server/mcp_server.py` 加 `@mcp.tool()` 函数;写操作必须挂门控(`_check_write` → `_check_serial` → `_ensure_device_free`);更新 [MCP.md](MCP.md)。
|
||||||
|
|
||||||
- 模型改 `core/models.py`,首次建表用 `create_all()`;SQLAlchemy 模型未写 `__tablename__` 时默认表名 = 小写类名
|
### 5.6 新增常驻线程
|
||||||
- **已有数据的老库**:在 `core/models.py` 的 `SCHEMA_MIGRATIONS` 里加迁移(版本号递增 + SQL)
|
|
||||||
- `app_meta`(KV 配置表)由 `core/models.py` 的 `_migrate_schema()` 建表并维护 `schema_version`
|
|
||||||
- `agent_conversation` / `agent_experience` / `experience_audit` / `agent_action`(动作经验库)由 `web/agent_api.py` 内的原生 `CREATE TABLE IF NOT EXISTS` 幂等创建(模块内首次用时执行),**不经 `SCHEMA_MIGRATIONS`,无版本管理**
|
|
||||||
|
|
||||||
### 5.6 改动必须同步文档
|
参考 `core/device_discovery.py` 的 `init_app(app)` / `shutdown()` 模式;更新 [ARCHITECTURE.md](ARCHITECTURE.md) §3。
|
||||||
|
|
||||||
任何功能/配置/接口/页面改动,须与代码同一 commit 同步更新对应文档:
|
---
|
||||||
|
|
||||||
| 改动类型 | 对应文档 |
|
## 6. 文档同步(红线)
|
||||||
|------|------|
|
|
||||||
|
**任何功能 / 配置 / 接口 / 表结构的增删改,都要在同一个 commit 里更新对应文档。**
|
||||||
|
|
||||||
|
| 改动类型 | 必须更新 |
|
||||||
|
|---------|---------|
|
||||||
| HTTP 接口 | [API.md](API.md) |
|
| HTTP 接口 | [API.md](API.md) |
|
||||||
| 数据表 / schema | [ARCHITECTURE.md](ARCHITECTURE.md) §3.6 |
|
| 表结构 / 迁移 | [DATA_MODEL.md](DATA_MODEL.md)(+ 备份覆盖清单) |
|
||||||
| `config.py` / `.env` 键增删 | 本文档 §2 + [.env.example](../.env.example) |
|
| `config.py` / `.env` 键 | 本文 §4 + [DEPLOY.md](DEPLOY.md) + `.env.example` |
|
||||||
| 页面 Tab / 子分栏 / 前端拆分 | [ARCHITECTURE.md](ARCHITECTURE.md) §5 |
|
| 页面 Tab / 子分栏 / 前端模块 | [ARCHITECTURE.md](ARCHITECTURE.md) §6 + 本文 §5.3 |
|
||||||
| 任务 / 步骤 | [TASK_DEV.md](TASK_DEV.md) |
|
| 任务类型 / 步骤 schema | [TASK_DEV.md](TASK_DEV.md) |
|
||||||
| 常驻线程 / 进程与装配 | [ARCHITECTURE.md](ARCHITECTURE.md) §7 |
|
| 常驻线程 / 装配顺序 | [ARCHITECTURE.md](ARCHITECTURE.md) §2-3 |
|
||||||
| MCP 工具 | [MCP.md](MCP.md) 与 [MCP_DESIGN.md](MCP_DESIGN.md) |
|
| MCP 工具 | [MCP.md](MCP.md) + [MCP_DESIGN.md](MCP_DESIGN.md) |
|
||||||
| 对外接入 / 数字员工知识库 | [staffdeck/KNOWLEDGE_BASE.md](staffdeck/KNOWLEDGE_BASE.md) 与 [staffdeck/JOB_SPEC.md](staffdeck/JOB_SPEC.md) |
|
| 对外接入约定 | [staffdeck/KNOWLEDGE_BASE.md](staffdeck/KNOWLEDGE_BASE.md) |
|
||||||
|
| 暂缓项 / 已知问题 | [backlog/TODO.md](backlog/TODO.md) |
|
||||||
|
|
||||||
注:STF_REMOVAL.md 是历史迁移记录,不改写。
|
新增文档时:登记进 [doc/README.md](README.md) §1 与根 [README](../README.md) 的文档索引。
|
||||||
|
历史文档([STF_REMOVAL.md](STF_REMOVAL.md))只增不改。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. 发布流程
|
## 7. 常见坑速查
|
||||||
|
|
||||||
详见 **[doc/DEPLOY.md](DEPLOY.md)**。摘要:
|
| 坑 | 说明 |
|
||||||
|
|----|------|
|
||||||
|
| 跨线程访问 DB | 必须自推 app context(`with app.app_context()`) |
|
||||||
|
| 改了不生效 | `core/`、`tasks/`、`templates/` 改动要重启;JS 要强刷 |
|
||||||
|
| Windows adb 输出 | 不能用 `text=True`(非 GBK 字节会崩),要收 bytes 再解码 |
|
||||||
|
| u2 卡死 | `u2.connect` / `d.info` 可能永久 hang,必须加超时 |
|
||||||
|
| 抢占死锁 | 不能在 `TaskManager._lock` 内调 `stop_device` |
|
||||||
|
| 删除不生效 | 删任务/分组必须显式删行,否则重启会"复活" |
|
||||||
|
| 前端写了 `data-perm` 仍可见 | 它只在 `loadMe()` 时求值一次;改权限后需刷新 |
|
||||||
|
| 新步骤没生效 | 后端 `STEP_TYPES` 与前端 `STEP_LIB` 要同时改 |
|
||||||
|
| 备份导入后没变化 | 必须重启服务 |
|
||||||
|
| `.env` 不生效 | `setdefault` 语义:**已存在的环境变量优先**,检查是否被系统环境覆盖 |
|
||||||
|
|
||||||
1. dev 开发测试完成
|
更完整的"代码里写明的坑"见 [ARCHITECTURE.md](ARCHITECTURE.md) §9.2。
|
||||||
2. 负责人确认 → 合并到 main
|
|
||||||
3. 负责人确认 → 生产 220 `git pull`
|
|
||||||
4. 重启 python-app 容器生效
|
|
||||||
|
|
||||||
**发布前检查**:生产容器 adb key、依赖、数据库迁移。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 文档索引
|
|
||||||
|
|
||||||
| 文档 | 内容 |
|
|
||||||
|------|------|
|
|
||||||
| [README](../README.md) | 项目总览、快速上手 |
|
|
||||||
| **[DEVELOPMENT.md](DEVELOPMENT.md)** | 本文档:流程/准则/限制/手册 |
|
|
||||||
| [TASK_DEV.md](TASK_DEV.md) | 任务开发指南(新增 App 任务模板) |
|
|
||||||
| [ARCHITECTURE.md](ARCHITECTURE.md) | 架构详解(分层、数据流、设计决策) |
|
|
||||||
| [API.md](API.md) | 全部 HTTP 接口说明 |
|
|
||||||
| [DEPLOY.md](DEPLOY.md) | 部署指南(环境、生产、故障排查) |
|
|
||||||
| [STF_REMOVAL.md](STF_REMOVAL.md) | 摘除 STF 的历史迁移记录(2026-08,不随现状改写) |
|
|
||||||
| [MCP.md](MCP.md) | MCP 手机控制使用手册(工具清单/用法) |
|
|
||||||
| [MCP_DESIGN.md](MCP_DESIGN.md) | MCP 架构与演进设计 |
|
|
||||||
| [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) | 待完成项(已确认但暂缓的功能/优化,完成时移出并同步文档) |
|
|
||||||
|
|||||||
+136
-106
@@ -1,147 +1,172 @@
|
|||||||
# MCP 手机控制 Server(`mcp_server/`)
|
# MCP 手机控制手册(MCP.md)
|
||||||
|
|
||||||
多模态 AI(DeepSeek/Claude 等)通过 [MCP(Model Context Protocol)](https://modelcontextprotocol.io) 实时操作 Android 手机的统一出口。AI 控制台(`mcp_agent/`)与外部 MCP 客户端都经它控制设备池中的手机。
|
多模态 AI(DeepSeek / Claude 等)通过 [MCP(Model Context Protocol)](https://modelcontextprotocol.io) 操作 Android 手机的统一出口。平台自带的「AI 控制台」(`mcp_agent/`)与外部 MCP 客户端使用的都是这一批工具。
|
||||||
|
|
||||||
> 本文档 = 使用手册(工具清单/用法)。架构与演进设计见 [MCP_DESIGN.md](MCP_DESIGN.md)。
|
> 本文 = **使用手册**(工具清单 / 用法 / 接入)。设计取舍与演进见 [MCP_DESIGN.md](MCP_DESIGN.md);AI 控制台的会话/经验/动作机制见 [AI_CONSOLE.md](AI_CONSOLE.md)。
|
||||||
|
|
||||||
## 架构
|
---
|
||||||
|
|
||||||
|
## 1. 架构与边界
|
||||||
|
|
||||||
```
|
```
|
||||||
AI 控制台 / 外部 MCP 客户端
|
AI 控制台 / 外部 MCP 客户端
|
||||||
│ Streamable HTTP
|
│ Streamable HTTP(http://<host>:8033/mcp)
|
||||||
▼
|
▼
|
||||||
MCP Server (:8033, mcp_server/mcp_server.py) ← 19 个 de_* 工具
|
MCP Server(mcp_server/mcp_server.py) ← 19 个 de_* 工具
|
||||||
│ 平台 HTTP API(登录 + CSRF)
|
│ 平台 HTTP API(登录 + CSRF) 或 轻量 adb/u2 直连
|
||||||
▼
|
▼
|
||||||
auto_control 平台 (:18050) ← 设备池/任务/看屏
|
auto_control 平台(:18050) ← 设备池 / 任务 / 看屏
|
||||||
│ adb / uiautomator2 / uiautodev / OCR
|
│ adb / uiautomator2 / uiautodev / OCR
|
||||||
▼
|
▼
|
||||||
Android 设备(IP:5555)
|
Android 设备(IP:5555)
|
||||||
```
|
```
|
||||||
|
|
||||||
- 轻量通道直连(adb monkey 开 App、u2 输入、本地 OCR)不绕平台,省时省 token
|
- **轻量通道直连**(adb monkey 开 App、u2 输入、本地 OCR)不绕平台,省时间省 token
|
||||||
- 坐标换算、UI 吸附、剪贴板注入等细节全部在 Server 层消化,模型只需给意图
|
- **坐标换算、UI 吸附、剪贴板注入**等细节全部在 Server 层消化,模型只给"意图"
|
||||||
|
- **只暴露设备层能力**:平台级的任务增改/分组/设备池/APK/备份等 REST 只给 Web 前端用,未做成 MCP 工具(补齐规划见 [AI_TASK_GEN.md](AI_TASK_GEN.md) §9)
|
||||||
|
|
||||||
## 运行与配置
|
---
|
||||||
|
|
||||||
服务跑在 220 的 `python-app` 容器内(`scripts/start.sh` 自动拉起,端口 8033)。
|
## 2. 运行与配置
|
||||||
生产访问方式:`http://192.168.20.220:8033/mcp`;本机调试 `python3 -m mcp_server.mcp_server`。
|
|
||||||
|
生产跑在 220 的 `python-app` 容器内(`scripts/start.sh` 自动拉起),端点 `http://192.168.20.220:8033/mcp`。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 本机手动启动
|
||||||
|
MCP_ALLOW_WRITE=1 MCP_PLATFORM_USER=admin MCP_PLATFORM_PASS=<平台密码> \
|
||||||
|
MCP_AUDIT_FILE=data/mcp_audit.log python -m mcp_server.mcp_server
|
||||||
|
```
|
||||||
|
|
||||||
| 环境变量 | 默认 | 说明 |
|
| 环境变量 | 默认 | 说明 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `MCP_PLATFORM_URL` | `http://127.0.0.1:18050` | 平台地址(Agent 与平台同机时用本机) |
|
| `MCP_PLATFORM_URL` | `http://127.0.0.1:18050` | 平台地址 |
|
||||||
| `MCP_PLATFORM_USER` / `MCP_PLATFORM_PASS` | `admin` / start.sh 兜底 `admin123`(手动直跑时默认空) | 平台登录账号 |
|
| `MCP_PLATFORM_USER` / `MCP_PLATFORM_PASS` | `admin` / 空(`start.sh` 兜底 `admin123`) | 平台登录凭据。**改过 admin 密码必须同步**,否则登录失败 |
|
||||||
| `MCP_ALLOW_WRITE` | `0` | **写门控**:=1 才允许点击/输入/开关 App 等写操作(只读工具不受限) |
|
| `MCP_ALLOW_WRITE` | `0`(`start.sh` 内强制 `1`) | **写门控**:=1 才允许点击/输入/开关 App(只读工具不受限) |
|
||||||
| `MCP_ALLOWED_SERIALS` | 空 | 设备白名单(逗号分隔):**非空=只允许列出的 serial**;为空时代码只校验 serial 非空、**不校验是否在平台设备池内**(设计稿语义未实现) |
|
| `MCP_ALLOWED_SERIALS` | 空 | 设备白名单(逗号分隔)。非空 = 只允许列出的 serial;**为空时只校验 serial 非空,不按平台设备池过滤**(语义缺口见 [backlog](backlog/TODO.md)) |
|
||||||
| `MCP_HTTP_HOST` / `MCP_HTTP_PORT` | `0.0.0.0` / `8033` | 监听地址 |
|
| `MCP_HTTP_HOST` / `MCP_HTTP_PORT` | `0.0.0.0` / `8033` | 监听 |
|
||||||
| `MCP_SCREENSHOT_WIDTH` | `540` | 截图返回宽度上限(px),模型看到的图即该坐标系 |
|
| `MCP_SCREENSHOT_WIDTH` | `540` | 截图返回宽度上限(模型看到的坐标系) |
|
||||||
| `MCP_JPEG_QUALITY` | `70` | 截图 JPEG 质量 |
|
| `MCP_JPEG_QUALITY` | `70` | 截图 JPEG 质量 |
|
||||||
| `MCP_AUDIT_FILE` | `/var/log/mcp/audit.log` | 审计日志(每次工具调用一行) |
|
| `MCP_AUDIT_FILE` | `/var/log/mcp/audit.log`(容器兜底 `/tmp/mcp_audit.log`) | 审计日志路径 |
|
||||||
| `MCP_PLATFORM_TIMEOUT` | `30` | 平台请求超时(秒) |
|
| `MCP_PLATFORM_TIMEOUT` | `30` | 平台请求超时(秒) |
|
||||||
|
| `MCP_ENABLED` | `1` | **只被 `scripts/start.sh` 消费**:=0 则不后台拉起 MCP |
|
||||||
|
|
||||||
生产(220 容器)已设 `MCP_ALLOW_WRITE=1`;`MCP_ALLOWED_SERIALS` 未设(= 代码只校验 serial 非空,**不限制到平台设备池内**;如需收紧请配置白名单)。
|
> `mcp_server/config.py` **只读进程环境变量,不读 `.env`**;`scripts/supervise.sh` 不拉起 MCP(容器场景由 `start.sh` 负责)。
|
||||||
|
|
||||||
> **MCP_ENABLED**:由 `scripts/start.sh` 消费(默认 `1`,=0 可关掉后台拉起的 MCP)。
|
---
|
||||||
> **MCP_PLATFORM_PASS**:start.sh 兜底 `admin123`——改过平台 admin 密码必须同步该变量,否则 MCP 登录平台失败。
|
|
||||||
> **supervise.sh 不拉起 MCP**:只守护 web_server;容器场景 MCP 由 start.sh 后台拉起(见 DEPLOY §2.4)。
|
|
||||||
|
|
||||||
## 坐标空间(重要约定)
|
## 3. 坐标空间(重要)
|
||||||
|
|
||||||
- `de_screenshot` 返回 ≤540px 宽的 JPEG(display 空间),并附 `native_size`(设备原生分辨率)
|
- `de_screenshot` 返回 **≤540px 宽**的 JPEG(display 空间),同时给出 `native_size`(设备原生分辨率)与 `screen_state`
|
||||||
- `de_tap` / `de_swipe` 的坐标一律使用 **de_screenshot 返回图像的坐标系**,Server 按比例换算为原生坐标
|
- `de_tap` / `de_swipe` 的坐标一律用 **`de_screenshot` 返回图像的坐标系**,Server 按比例换算成原生坐标
|
||||||
- **先截图、后点击**:Server 需要最近一次截图才能建立坐标空间(未截图就点击会报「请先执行 de_screenshot」)
|
- **必须先截图再点击**:Server 需要最近一次截图来建立坐标空间,否则报 `invalid_param`「请先对该设备执行 de_screenshot」
|
||||||
- `de_tap` 带**自动吸附**:点击点若落在某个可点击元素内,实际会点该元素中心——坐标只需大致对准,偏十几像素也能点准;点空白处则按原坐标
|
- `de_tap` 带**自动吸附**:落点若在某个可点击元素内,实际点该元素中心 → 坐标**只需大致对准**;返回 `snapped` / `label` 便于核对
|
||||||
- 返回的 `snapped/label` 可核对吸附结果(snapped=true 表示已吸到元素,label 为该元素文案)
|
|
||||||
|
|
||||||
## 工具清单(19 个)
|
---
|
||||||
|
|
||||||
### 设备与状态
|
## 4. 工具清单(19 个)
|
||||||
|
|
||||||
| 工具 | 用途 |
|
统一返回约定:
|
||||||
|---|---|
|
|
||||||
| `de_list_devices` | 列出可控制设备:serial / 型号 / 在线 / 任务状态 / 前台 App。**开局第一步** |
|
|
||||||
| `de_foreground_app(serial)` | 当前前台 App 包名(dumpsys,MIUI 焦点为空时自动兜底) |
|
|
||||||
|
|
||||||
### 观察屏幕(感知)
|
```json
|
||||||
|
{"ok": true, "data": { … }}
|
||||||
|
{"ok": false, "error": {"code": "device_busy", "message": "设备正在执行任务…"}}
|
||||||
|
```
|
||||||
|
|
||||||
| 工具 | 用途 |
|
错误码:`invalid_param` · `device_not_allowed` · `write_disabled` · `device_busy` · `platform_unavailable` · `device_offline` · `text_not_found`。
|
||||||
|---|---|
|
|
||||||
| `de_screenshot(serial)` | 截图并返回图像(≤540px JPEG)+ 尺寸 + 亮/熄屏状态。多模态模型直接看图 |
|
|
||||||
| `de_ui_tree(serial, limit=150)` | 当前界面元素树(text/id/desc/class/bounds)。**可点击元素排前**;limit 1-300 控制条数防 token 膨胀。用于确认界面上有什么 |
|
|
||||||
| `de_ocr(serial)` | OCR 识别当前屏幕文字(UI 树没有的图片/WebView 文字也能识别),返回 [{text, score}] |
|
|
||||||
|
|
||||||
### 点击与滑动(操作)
|
### 4.1 设备与状态(只读)
|
||||||
|
|
||||||
| 工具 | 用途 |
|
| 工具 | 参数 | 返回 / 说明 |
|
||||||
|---|---|
|
|------|------|------------|
|
||||||
| `de_tap_text(serial, text)` | **按屏幕文字点击**(推荐):给一个屏幕上可见的文字(子串匹配)即找到并点其中心。原生控件走 UI 树,WebView/图片文字自动 OCR 兜底。找不到返回明确错误 |
|
| `de_list_devices` | — | `[{serial, model, online, task_job, worker_status, foreground_app}]`。**开局第一步** |
|
||||||
| `de_tap_element(serial, by, value, index=1)` | 按元素点击:`by` = text / id / desc(精确)或 text_contains / desc_contains(模糊)。多命中用 index 取第几个 |
|
| `de_foreground_app` | `serial` | `{foreground_app}` 当前前台包名(dumpsys,MIUI 焦点为空时兜底) |
|
||||||
| `de_tap(serial, x, y)` | 坐标点击(截图坐标系,自动吸附,见上)。**纯图形目标(视频画面/无文字图标)才用它** |
|
| `de_list_tasks` | — | 平台任务计划 `[{id,name,task_type,enabled,schedule}]`(只读) |
|
||||||
| `de_swipe(serial, x1,y1,x2,y2, duration=0.2)` | 滑动(截图坐标系;长按=同点起止 + duration≥1) |
|
|
||||||
| `de_press_key(serial, key)` | 按键:back / home / recent / menu / power / volume_up / volume_down / enter / delete / search / camera |
|
|
||||||
|
|
||||||
### 输入与剪贴板
|
### 4.2 观察屏幕(只读)
|
||||||
|
|
||||||
| 工具 | 用途 |
|
| 工具 | 参数 | 返回 / 说明 |
|
||||||
|---|---|
|
|------|------|------------|
|
||||||
| `de_type_text(serial, text)` | 向当前界面输入框输入文字(支持中文,直设 EditText 不依赖剪贴板/粘贴) |
|
| `de_screenshot` | `serial` | `{image:{type:"image",data:<base64>,mimeType:"image/jpeg"}, width, height, native_size, screen_state}`。多模态模型直接看图 |
|
||||||
| `de_set_clipboard(serial, text)` | 写入设备剪贴板(ClipInject 通道注入 + 读回验证) |
|
| `de_ui_tree` | `serial`, `limit`(150,1-300) | `{count, elements:[{text,id,desc,class,clickable,bounds}]}`,**可点击元素排前**;`limit` 控 token |
|
||||||
| `de_read_clipboard(serial)` | 读取设备当前剪贴板内容 |
|
| `de_ocr` | `serial` | `{count, texts:[{text,score}]}`(≤100 条)。UI 树拿不到的图片/WebView 文字用它 |
|
||||||
|
| `de_read_clipboard` | `serial` | `{clipboard}` |
|
||||||
|
| `de_list_apps` | `serial`, `keyword`("") | `{count, packages}` 第三方已装包名(`pm list packages -3`,≤200) |
|
||||||
|
|
||||||
### App 管理(轻量 adb 直连,不建 u2 会话)
|
### 4.3 点击与滑动(**写操作**)
|
||||||
|
|
||||||
| 工具 | 用途 |
|
| 工具 | 参数 | 说明 |
|
||||||
|---|---|
|
|------|------|------|
|
||||||
| `de_open_app(serial, package)` | 打开 App(adb monkey 直启,无需知道 activity——最快的打开路径) |
|
| `de_tap_text` | `serial`, `text`(≤100 字符) | **推荐**:按屏幕可见文字点击。原生控件走 UI 树,WebView/图片文字自动 **OCR 兜底**;找不到 → `text_not_found` |
|
||||||
| `de_stop_app(serial, package)` | 强制停止 App(am force-stop) |
|
| `de_tap_element` | `serial`, `by`(`text`/`id`/`desc`/`text_contains`/`desc_contains`), `value`, `index`(1) | 按元素属性点击,无需坐标;多命中用 `index` |
|
||||||
| `de_list_apps(serial, keyword="")` | 列出第三方已装应用(pm list packages -3),keyword 可过滤(如 "douyin") |
|
| `de_tap` | `serial`, `x`, `y` | 坐标点击(截图坐标系 + 自动吸附)。**纯图形目标才用** |
|
||||||
|
| `de_swipe` | `serial`, `x1,y1,x2,y2`, `duration`(0.2) | 滑动(长按 = 同点起止 + `duration≥1`) |
|
||||||
|
| `de_press_key` | `serial`, `key` | `back` / `home` / `recent` / `menu` / `power` / `volume_up` / `volume_down` / `enter` / `delete` / `search` / `camera` |
|
||||||
|
|
||||||
> 上表三个工具走 `direct_ops` 轻量 **adb 直连**(monkey / am force-stop / pm list packages),不建 u2 会话。
|
### 4.4 输入与剪贴板(**写操作**)
|
||||||
|
|
||||||
### 亮屏与熄屏(平台 screen_all 通道)
|
| 工具 | 参数 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `de_type_text` | `serial`, `text` | 向当前输入框输入(支持中文,直设 EditText,不依赖剪贴板) |
|
||||||
|
| `de_set_clipboard` | `serial`, `text` | 写入设备剪贴板(ClipInject 通道 + 读回验证) |
|
||||||
|
|
||||||
| 工具 | 用途 |
|
### 4.5 App 管理(**写操作**,走 adb 直连不建 u2 会话)
|
||||||
|---|---|
|
|
||||||
| `de_sleep(serial)` | 熄屏(**运行中任务会中断,慎用**) |
|
|
||||||
| `de_wake(serial)` | 亮屏并解锁(熄屏时先调它再截图) |
|
|
||||||
|
|
||||||
> `de_sleep`/`de_wake` 走平台 `POST /api/device/screen_all`(`platform_client`),同样不在 MCP 进程内建 u2 会话。
|
| 工具 | 参数 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `de_open_app` | `serial`, `package` | `adb monkey` 直启(最快路径,无需知道 activity) |
|
||||||
|
| `de_stop_app` | `serial`, `package` | `am force-stop` |
|
||||||
|
|
||||||
### 平台联动
|
### 4.6 亮屏 / 熄屏(**写操作**)
|
||||||
|
|
||||||
| 工具 | 用途 |
|
| 工具 | 参数 | 说明 |
|
||||||
|---|---|
|
|------|------|------|
|
||||||
| `de_list_tasks()` | 列出平台任务计划(名称/类型/启用/调度),了解已自动化的工作 |
|
| `de_wake` | `serial` | 亮屏并解锁(熄屏时先调它再截图) |
|
||||||
|
| `de_sleep` | `serial` | 熄屏(**会中断正在运行的任务,慎用**) |
|
||||||
|
|
||||||
### 现状边界
|
> 后两组分别经 `direct_ops`(adb)与平台 `POST /api/device/screen_all` 实现,不在 MCP 进程内建 u2 会话。
|
||||||
|
|
||||||
本 Server 目前只有**设备层**的 `de_*`(控制/感知/只读)+ 平台**只读**的 `de_list_tasks`;平台级**任务增改/提交/CRUD、分组/设备池/自定义动作/APK/备份**等 REST 路由只给 Web 前端用,**未暴露 MCP 工具**。
|
### 4.7 写操作门控(三连)
|
||||||
|
|
||||||
补齐分层规划见 [AI_TASK_GEN.md](AI_TASK_GEN.md) §9。**每新增/修改/删除一个 MCP 工具或平台配置,必须同步更新本手册与 [MCP_DESIGN.md](MCP_DESIGN.md)(doc 同步红线)**。
|
所有写工具在执行前依次检查:
|
||||||
|
|
||||||
## 推荐使用模式(操作手机的正确姿势)
|
1. `_check_write()` —— `MCP_ALLOW_WRITE=1`?否则 `write_disabled`
|
||||||
|
2. `_check_serial()` —— serial 非空 / 在白名单内?否则 `device_not_allowed`
|
||||||
|
3. `_ensure_device_free()` —— 设备 `worker_status` 不是 `running`/`connecting`?否则 `device_busy`(**AI 不与任务抢设备**)
|
||||||
|
|
||||||
|
> 设备状态查询有 **5 秒缓存**;查询本身异常时**放行**(不阻塞),由平台侧兜底。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 推荐使用模式
|
||||||
|
|
||||||
1. **`de_list_devices`** 确认目标设备在线
|
1. **`de_list_devices`** 确认目标设备在线
|
||||||
2. **`de_screenshot`** 看图理解当前界面(图像会在下一次模型回合送达)
|
2. **`de_screenshot`** 看图理解当前界面(图像会在下一回合送达模型)
|
||||||
3. **点击定位优先级**(从高到低):
|
3. **点击定位优先级**:
|
||||||
- 目标有可见文字 → **`de_tap_text`**(一次调用完成「找到并点击」,最可靠)
|
- 目标有可见文字 → **`de_tap_text`**(一次调用完成"找到并点击",最可靠)
|
||||||
- 文字有歧义/多候选 → `de_ui_tree` 确认后 `de_tap_element`(text_contains 模糊匹配)
|
- 文字有歧义/多候选 → `de_ui_tree` 确认后用 `de_tap_element`(可用 `text_contains` 模糊匹配)
|
||||||
- 纯图形目标 → `de_tap` 坐标(**无需精算**,Server 自动吸附到可点元素中心)
|
- 纯图形目标 → `de_tap` 坐标(**无需精算**,会自动吸附)
|
||||||
4. 输入文字:先点中输入框(de_tap_text / de_tap),再 `de_type_text`
|
4. **输入文字**:先点中输入框,再 `de_type_text`
|
||||||
5. 每次关键操作后 `de_screenshot` 验证:界面变化 = 成功;无变化 = 未命中,换 de_tap_text / de_tap_element 重新定位,**不要重复点同一坐标**
|
5. **每步验证**:关键操作后再 `de_screenshot`——界面变了 = 成功;没变 = 未命中 → 换 `de_tap_text`/`de_tap_element` 重新定位,**不要重复点同一坐标**
|
||||||
6. 完成/失败时用中文总结:做了什么、当前状态、注意事项
|
6. **收尾**:用中文总结做了什么、当前状态、注意事项
|
||||||
7. 效率约束:界面未变不重复截图/点同位置;连续 6 步无进展停止并总结
|
7. **效率**:界面未变时不重复截图/点击;连续 6 步无进展就停止并总结
|
||||||
|
|
||||||
## 安全与审计
|
---
|
||||||
|
|
||||||
- **写门控**:`MCP_ALLOW_WRITE=0`(默认)时点击/输入/开关 App 全部拒绝,只读工具可用
|
## 6. 安全与审计
|
||||||
- **任务占用互斥**:写工具操作前调 `_ensure_device_free` 检查设备 `worker_status`,running/connecting 直接拒 `device_busy`(只读工具不受限),AI 不与任务抢设备
|
|
||||||
- **平台会话 + CSRF**:平台登录与会话由 `platform_client` 内部处理(`MCP_PLATFORM_USER/PASS` 登录拿 cookie、失效自动重登;POST 自动带 `X-CSRF-Token`),不向客户端暴露平台凭据
|
|
||||||
- **设备白名单**:`MCP_ALLOWED_SERIALS` 非空时限制可操作的 serial;空名单时代码只校验 serial 非空,不按平台设备池过滤
|
|
||||||
- **审计日志**:每次调用记录 `{ts, tool, serial, args, result}` 到 `MCP_AUDIT_FILE`(220 上 `/tmp/mcp_audit.log`)
|
|
||||||
- 操作对象限定平台设备池;`adb kill-server` / `adb disconnect` 属项目红线,任何工具不触碰
|
|
||||||
|
|
||||||
## 客户端接入示例
|
| 机制 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| **写门控** | `MCP_ALLOW_WRITE=0`(默认)时所有写操作被拒,只读可用 |
|
||||||
|
| **任务互斥** | 写操作前检查设备是否在跑任务,忙则 `device_busy` |
|
||||||
|
| **平台会话** | `platform_client` 内部登录(`MCP_PLATFORM_USER/PASS`)、会话失效自动重登、POST 自动带 `X-CSRF-Token`;**平台凭据不暴露给客户端** |
|
||||||
|
| **白名单** | `MCP_ALLOWED_SERIALS` 非空时限制可操作 serial |
|
||||||
|
| **审计** | 每次调用(**含只读**)写一行 JSON 到 `MCP_AUDIT_FILE`:`{ts, tool, serial, args 摘要, result 摘要}` |
|
||||||
|
| **红线** | 任何工具都不执行 `adb kill-server` / `adb disconnect` |
|
||||||
|
| **端点鉴权** | ⚠️ MCP HTTP 端点自身**没有** token/账号校验,只做出站登录 → **必须靠网络隔离**(同机/内网),不要直接暴露公网 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 客户端接入示例
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -150,24 +175,29 @@ from fastmcp import Client
|
|||||||
async def main():
|
async def main():
|
||||||
async with Client("http://192.168.20.220:8033/mcp", timeout=30) as c:
|
async with Client("http://192.168.20.220:8033/mcp", timeout=30) as c:
|
||||||
devs = await c.call_tool("de_list_devices", {})
|
devs = await c.call_tool("de_list_devices", {})
|
||||||
serial = devs.data["data"][0]["serial"] # 取第一台在线设备
|
serial = devs.data["data"][0]["serial"] # 第一台设备
|
||||||
shot = await c.call_tool("de_screenshot", {"serial": serial})
|
shot = await c.call_tool("de_screenshot", {"serial": serial})
|
||||||
img = shot.data["data"]["image"] # base64 JPEG(多模态模型可直接看图)
|
img = shot.data["data"]["image"] # base64 JPEG(给多模态模型看)
|
||||||
await c.call_tool("de_tap_text", {"serial": serial, "text": "搜索"})
|
await c.call_tool("de_tap_text", {"serial": serial, "text": "搜索"})
|
||||||
|
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
```
|
```
|
||||||
|
|
||||||
## AI 控制台(内置 Agent)
|
> 给外部数字员工的使用约定与红线,另见 [staffdeck/KNOWLEDGE_BASE.md](staffdeck/KNOWLEDGE_BASE.md)。
|
||||||
|
|
||||||
Web 端「AI 控制台」Tab 内建的 Agent(`mcp_agent/`,OpenAI 兼容协议:DeepSeek 等)也通过本 Server 的同一批工具跑任务:
|
---
|
||||||
|
|
||||||
- 流式输出 + 每步 MCP 工具调用实时展示(含截图缩略)
|
## 8. AI 控制台(内置 Agent)
|
||||||
- 多轮会话记忆(同会话上下文保留,新建会话清空)
|
|
||||||
- **自进化经验记忆**:一轮成功操作会被提炼成「配方」存入 `agent_experience` 表,下次相似任务自动注入参考(命中/写入均有 🧠 提示卡)
|
|
||||||
- 模型与 API Key 在 AI 控制台右上角 ⚙ 配置,存平台 `app_meta`
|
|
||||||
|
|
||||||
> **依赖提示**:AI 控制台依赖本 MCP Server(默认 `http://127.0.0.1:8033/mcp`)。若未启动,发起指令会直接失败——平台已把 SDK 的含糊报错(`Server returned an error response` 等)映射为明确文案:**「MCP server(8033) 不可达 …」**。
|
Web 的「AI 控制台」Tab 内建 Agent(`mcp_agent/`,OpenAI 兼容模型)用的就是本 Server 的同一批工具:
|
||||||
> 本机(非容器)需手动拉起:
|
|
||||||
> `MCP_ALLOW_WRITE=1 MCP_PLATFORM_USER=admin MCP_PLATFORM_PASS=<平台密码> MCP_AUDIT_FILE=data/mcp_audit.log python -m mcp_server.mcp_server`
|
- 流式输出 + 每步 MCP 调用实时展示(含截图缩略)
|
||||||
> 220 生产容器由 `scripts/start.sh` 自动拉起(`MCP_ENABLED=0` 会关)。
|
- 多轮会话(同会话保留上下文)
|
||||||
|
- **自进化记忆**:经验库(任务配方)+ 动作库(命名动作),相似任务自动注入参考
|
||||||
|
- 模型与 Key 在控制台右上角 ⚙ 配置(存平台 `app_meta`)
|
||||||
|
|
||||||
|
> **依赖提示**:AI 控制台依赖本 MCP Server(默认 `http://127.0.0.1:8033/mcp`)。未启动时平台会把 SDK 的含糊报错映射成明确文案「**MCP server(8033) 不可达 …**」。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **维护约定**:新增 / 修改 / 删除任何一个 MCP 工具或相关配置,必须**同步更新本文与 [MCP_DESIGN.md](MCP_DESIGN.md)**(doc 同步红线)。
|
||||||
|
|||||||
+4
-2
@@ -1,6 +1,8 @@
|
|||||||
# MCP 手机控制(Mobile Control MCP Server)设计文档
|
# MCP 手机控制(Mobile Control MCP Server)设计文档
|
||||||
|
|
||||||
> 分支:dev | 状态:设计稿/演进(实现现状以 doc/MCP.md 为准) | 日期:2026-09-04
|
> 状态:**设计稿 / 演进记录**——凡与实现不符处,以 [MCP.md](MCP.md)(使用手册,含 19 个工具的权威清单)与 `mcp_server/` 代码为准。
|
||||||
|
> 本文保留设计取舍与里程碑,便于回溯"为什么这么做";文中的"演进备选"均**未落地**。
|
||||||
|
> 最后核对:2026-09-10(对照 dev 现状)。
|
||||||
|
|
||||||
## 1. 背景与目标
|
## 1. 背景与目标
|
||||||
|
|
||||||
@@ -25,7 +27,7 @@
|
|||||||
```
|
```
|
||||||
┌──────────────┐ MCP (Streamable HTTP / stdio) ┌──────────────────┐
|
┌──────────────┐ MCP (Streamable HTTP / stdio) ┌──────────────────┐
|
||||||
│ 多模态 AI │ ────────────────────────────────▶ │ MCP Server │
|
│ 多模态 AI │ ────────────────────────────────▶ │ MCP Server │
|
||||||
│ Claude Desktop│ tools + image content block │ (220 独立进程) │
|
│ Claude Desktop│ tools + image content block │ (与平台同容器) │
|
||||||
│ Claude Code │ ◀──────────────────────────────── │ mcp_server/ 包 │
|
│ Claude Code │ ◀──────────────────────────────── │ mcp_server/ 包 │
|
||||||
└──────────────┘ 截图图像 / JSON 结果 └────────┬─────────┘
|
└──────────────┘ 截图图像 / JSON 结果 └────────┬─────────┘
|
||||||
│ 内部调用
|
│ 内部调用
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# auto_control 文档总索引
|
||||||
|
|
||||||
|
本目录是 `auto_control`(Android 多设备自动化任务平台)的**唯一权威文档源**。代码即事实,文档与代码不一致时以代码为准,并**顺手把文档改对**(见文末维护约定)。
|
||||||
|
|
||||||
|
> 项目名称统一为 **auto_control**(历史文档里出现过的 `platform-tools` 均为旧名,已全部改名)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 文档地图
|
||||||
|
|
||||||
|
| 文档 | 内容 | 主要读者 |
|
||||||
|
|------|------|---------|
|
||||||
|
| [ARCHITECTURE.md](ARCHITECTURE.md) | **架构详解**:分层、启动装配顺序、线程与并发模型、设备生命周期、任务调度链路、状态机、关键设计决策与扩展点 | 所有开发者(先读这篇) |
|
||||||
|
| [DATA_MODEL.md](DATA_MODEL.md) | **数据模型**:SQLite 表与字段、schema 迁移、非模型表、`app_meta` 配置键、数据目录、备份覆盖清单 | 后端开发、运维 |
|
||||||
|
| [API.md](API.md) | **HTTP 接口全量**:按蓝图分组的路由表、鉴权、请求/响应示例、非 JSON 响应、错误分支 | 前端开发、外部接入 |
|
||||||
|
| [TASK_DEV.md](TASK_DEV.md) | **任务与步骤开发**:TaskType/TaskJob 概念、18 种步骤全表、选择器与定位、自定义动作、新增任务类型模板 | 写任务的开发 |
|
||||||
|
| [MCP.md](MCP.md) | **MCP 手机控制手册**:19 个 `de_*` 工具用法、写操作门控、坐标换算、接入示例 | 接入方、数字员工 |
|
||||||
|
| [MCP_DESIGN.md](MCP_DESIGN.md) | **MCP 设计文档**:边界划分、错误码、白名单/审计设计、演进方向 | 平台开发者 |
|
||||||
|
| [AI_CONSOLE.md](AI_CONSOLE.md) | **AI 控制台**:会话/SSE、经验库、动作库、巡检、Markdown 渲染、推理链、token 统计 | 使用者、平台开发者 |
|
||||||
|
| [AI_TASK_GEN.md](AI_TASK_GEN.md) | **AI 建任务**:设计稿与里程碑(P0 未实现,属规划) | 平台开发者 |
|
||||||
|
| [DEPLOY.md](DEPLOY.md) | **部署与运维**:环境准备、生产容器、数据备份导出/导入、故障排查 | 运维、部署者 |
|
||||||
|
| [DEVELOPMENT.md](DEVELOPMENT.md) | **开发手册**:git 流程、技术红线、本地开发与调试、常见开发任务、文档同步约定 | 所有开发者 |
|
||||||
|
| [STF_REMOVAL.md](STF_REMOVAL.md) | **历史记录**:摘除 OpenSTF 的迁移过程(阶段 0-3) | 追溯背景时参考 |
|
||||||
|
| [staffdeck/KNOWLEDGE_BASE.md](staffdeck/KNOWLEDGE_BASE.md) | 给 StaffDeck 数字员工的知识库(MCP 接入/工具/约定/红线) | 外部 AI 接入方 |
|
||||||
|
| [staffdeck/JOB_SPEC.md](staffdeck/JOB_SPEC.md) | 数字员工岗位说明(岗位描述/看板摘要/执行约束) | 外部 AI 接入方 |
|
||||||
|
| [backlog/TODO.md](backlog/TODO.md) | 已确认但暂缓的待办(含已知问题) | 所有开发者 |
|
||||||
|
|
||||||
|
项目根目录的 [README.md](../README.md) 是**项目总览与快速上手**(面向第一次接触项目的人),细节都在本目录。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 推荐的阅读路径
|
||||||
|
|
||||||
|
| 你的目的 | 按顺序读 |
|
||||||
|
|---------|---------|
|
||||||
|
| **第一次接触项目** | 根 [README.md](../README.md) → [ARCHITECTURE.md](ARCHITECTURE.md) → [DATA_MODEL.md](DATA_MODEL.md) |
|
||||||
|
| **搭环境跑起来** | 根 [README.md](../README.md) 的「快速上手」→ [DEPLOY.md](DEPLOY.md) |
|
||||||
|
| **写/改任务** | [TASK_DEV.md](TASK_DEV.md) → [ARCHITECTURE.md](ARCHITECTURE.md) §任务调度 |
|
||||||
|
| **改后端/前端** | [DEVELOPMENT.md](DEVELOPMENT.md)(流程+红线+本地开发)→ [ARCHITECTURE.md](ARCHITECTURE.md) → [API.md](API.md) |
|
||||||
|
| **对外提供手机控制** | [MCP.md](MCP.md) → [staffdeck/KNOWLEDGE_BASE.md](staffdeck/KNOWLEDGE_BASE.md) |
|
||||||
|
| **排故障** | [DEPLOY.md](DEPLOY.md) §故障排查 → [DEVELOPMENT.md](DEVELOPMENT.md) §调试 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 文档维护约定(红线)
|
||||||
|
|
||||||
|
> 与「doc 同步红线」一致:**任何功能/配置/接口/表结构的增删改,必须在同一个 commit 里同步更新对应文档。**
|
||||||
|
|
||||||
|
| 改动类型 | 必须同步的文档 |
|
||||||
|
|---------|--------------|
|
||||||
|
| HTTP 接口(新增/改参数/改返回/改鉴权) | [API.md](API.md) |
|
||||||
|
| 数据库表/字段/迁移 | [DATA_MODEL.md](DATA_MODEL.md) + [ARCHITECTURE.md](ARCHITECTURE.md) |
|
||||||
|
| **新增持久化表** | 还要登记进 `core/system_backup.py` 的 `SUMMARY_TABLES` + [DEPLOY.md](DEPLOY.md) §3.5(**备份覆盖红线**) |
|
||||||
|
| `config.py` / `.env` 键 | [DEVELOPMENT.md](DEVELOPMENT.md) 配置速查 + [DEPLOY.md](DEPLOY.md) + `.env.example` |
|
||||||
|
| 页面 Tab / 子分栏 / 前端 JS 拆分 | [ARCHITECTURE.md](ARCHITECTURE.md) §前端 + [DEVELOPMENT.md](DEVELOPMENT.md) |
|
||||||
|
| 任务类型 / 步骤 schema | [TASK_DEV.md](TASK_DEV.md) |
|
||||||
|
| 常驻线程 / 进程装配 | [ARCHITECTURE.md](ARCHITECTURE.md) §线程与并发 |
|
||||||
|
| MCP 工具 | [MCP.md](MCP.md) + [MCP_DESIGN.md](MCP_DESIGN.md) |
|
||||||
|
| 对外接入约定 | [staffdeck/KNOWLEDGE_BASE.md](staffdeck/KNOWLEDGE_BASE.md) |
|
||||||
|
| 暂缓项 / 已知问题 | [backlog/TODO.md](backlog/TODO.md)(完成时移出并同步相关文档) |
|
||||||
|
|
||||||
|
**新增文档时**:在本文 §1 表格里登记一行,并在根 README 的「更多文档」里加链接——否则等于没写。
|
||||||
|
|
||||||
|
**历史文档不追改**:[STF_REMOVAL.md](STF_REMOVAL.md) 是迁移阶段的历史记录,只增不改,不随现状改写。
|
||||||
+340
-1065
File diff suppressed because it is too large
Load Diff
+109
-32
@@ -1,54 +1,131 @@
|
|||||||
# 待完成项(Backlog)
|
# 待完成项 / 已知问题(Backlog)
|
||||||
|
|
||||||
> 用途:记录**已确认但暂缓**的功能/优化,避免丢线索。完成时应移出本文件,并按「doc 同步红线」更新对应文档。
|
> 用途:记录**已确认但暂缓**的功能、优化与**已知缺陷**,避免丢线索。完成时移出本文件,并按「doc 同步红线」更新对应文档。
|
||||||
> 维护:新增条目写清 **背景 / 期望 / 涉及文件 / 验收**;完成后删除条目并在相关 doc 体现。
|
> 维护:新条目写清 **背景 / 期望 / 涉及文件 / 验收**;缺陷写清 **现象 / 复现 / 影响**。
|
||||||
|
> 相关:[doc/README.md](../README.md)(文档索引与维护约定)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 设备与连接
|
## A. 已知缺陷(尚未修)
|
||||||
|
|
||||||
- [ ] **adb 远程终端加「目标切换」(本机 / 220)**
|
### A1. `GET /locate` 返回 500(设备定位大字页打不开)
|
||||||
- 背景:终端默认走 `.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` 加下拉;默认值按需。
|
- **现象**:访问 `/locate?serial=…` 直接 500。
|
||||||
- 涉及:`web/tools_api.py`、`static/admin/tools.js`、`doc/API.md`、`doc/DEPLOY.md`、`doc/DEVELOPMENT.md`(配置速查)。
|
- **原因**:`web/monitor.py` 的 `locate_page()` 用了 `render_template_string` 与 `_esc`,但该文件只导入了 `render_template`,也没有 `_esc` 定义。
|
||||||
- 验收:切换「本机」时终端能列出本机 USB + 本机已连 `IP:5555`;切「220」时列出 220 侧设备;`adb cmd` 同样生效。
|
- **复现**:`curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:18050/locate?serial=1.2.3.4:5555"` → 500(路由扫描脚本已确认)。
|
||||||
|
- **影响**:「工具 → 设备池管理 → 定位」在设备上打开的大字页不可用(亮屏那半仍生效)。
|
||||||
|
- **涉及**:`web/monitor.py`(补 import / 补转义实现,或改用 `render_template` + 模板文件)。
|
||||||
|
- **验收**:`/locate` 返回 200 且页面正确显示 serial/IP;纳入回归脚本。
|
||||||
|
|
||||||
|
### A2. `POST /api/device/locate`(`show=true`)失败
|
||||||
|
|
||||||
|
- **现象**:带 `show=true` 调用时抛异常 → 502。
|
||||||
|
- **原因**:`web/monitor.py` 用了 `urllib.parse.quote` 但未 `import urllib`。
|
||||||
|
- **影响**:无法远程把设备浏览器打开到定位页。
|
||||||
|
- **涉及**:`web/monitor.py`。**验收**:`show=true` 时设备浏览器打开定位页且返回 200。
|
||||||
|
|
||||||
|
### A3. CSRF 实际未启用
|
||||||
|
|
||||||
|
- **现象**:`/api/csrf` 会签发 token、前端所有非 GET 请求都会带 `X-CSRF-Token`,但服务端**从不校验**。
|
||||||
|
- **原因**:`web_server.py` 只 `from web.auth import _csrf_protect`,**没有注册** `app.before_request(_csrf_protect)`;而 `web/auth.py` 的 docstring 声称"由 web_server 注册"。
|
||||||
|
- **影响**:CSRF 防护形同虚设(同网段内可伪造写请求)。
|
||||||
|
- **涉及**:`web_server.py`(注册钩子)+ 需要回归所有写接口(前端、脚本、MCP 的 `platform_client` 都已带 token,风险点是第三方直接调 API)。
|
||||||
|
- **验收**:注册后所有写接口在缺 token 时返回 403,且前端/MCP 全链路正常。
|
||||||
|
|
||||||
|
### A4. 路由回归脚本在 Windows 上不可用
|
||||||
|
|
||||||
|
- **现象**:`python scripts/regression_test.py` 直接报 `AttributeError: module 'signal' has no attribute 'alarm'` 退出。
|
||||||
|
- **原因**:脚本用了 Unix 专有的 `signal.alarm`(第 29 行),docstring 里的示例路径也是 `.venv/bin/python`。
|
||||||
|
- **影响**:本机(Windows)开发时没有"一条命令扫全接口 500"的手段——A1/A2 这类 import 遗漏本可被它第一时间发现。
|
||||||
|
- **涉及**:`scripts/regression_test.py`(把 `signal.alarm` 换成跨平台超时,或 `hasattr` 守卫)。
|
||||||
|
- **验收**:Windows 与 Linux 都能跑;能扫出 A1。
|
||||||
|
|
||||||
|
### A5. 前端 `--card-line` 变量未定义
|
||||||
|
|
||||||
|
- **现象**:AI 控制台的会话/经验/动作模态框边框失效(`border:1px solid var(--card-line)` 解析为无效值)。
|
||||||
|
- **原因**:该变量只在 `templates/admin/wall.html` 的 `:root` 定义,`monitor.html` 的 `:root` 里没有,但被引用了 5 处。
|
||||||
|
- **涉及**:`templates/admin/monitor.html`(`:root` 补 `--card-line:#232936`)。
|
||||||
|
|
||||||
|
### A6. `monitor.js` 引用了未定义变量 `countParts`
|
||||||
|
|
||||||
|
- **现象**:进度为 0 的边界分支会抛 `ReferenceError`(被外层 `try/catch` 吞掉,用户无感)。
|
||||||
|
- **涉及**:`static/admin/monitor.js`(该分支的进度渲染)。
|
||||||
|
|
||||||
|
### A7. 其它小问题(低优先)
|
||||||
|
|
||||||
|
- `static/admin/custom.css` 是**死文件**(无任何引用)。
|
||||||
|
- `templates/admin/monitor.html` 里 `.agent-shell` 有**完全重复的 CSS 声明**(后者覆盖前者的 `min-height`)。
|
||||||
|
- 设备表空态 `colspan="11"` 与实际列数(10)不符。
|
||||||
|
- `core/device_worker.py` 的 `_u2_connect_remote` 有两个结构相同的 `except Exception`,**后者是死代码**。
|
||||||
|
- `core/ssh_client.py` **全项目无人 import**(`STF_SSH_*` 配置也只被它读取);要么接上用途,要么删除。
|
||||||
|
- 若干按功能域拆包时留下的**重复函数定义**:`_merged_device_list`(`web/common.py` / `apks_api.py` / `tools_api.py` 各一份)、`tools_api.py` 的三个辅助函数、`monitor.py` 的屏相关函数、`tasks_api.py` 的 `_job_next_run`(各定义两次)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## B. 设备与连接
|
||||||
|
|
||||||
|
- [ ] **adb 远程终端加「目标切换」(本机 / 220)**
|
||||||
|
- 背景:终端默认走 `.env` 的 `ANDROID_ADB_SERVER_ADDRESS=192.168.20.220`(adb 客户端生效,非本项目代码读取),因此终端里看不到**本机 USB 设备**。
|
||||||
|
- 期望:`/api/adb/devices`、`/api/adb/cmd` 支持 `target=local|remote`;前端 `tools.js` 加下拉。
|
||||||
|
- 涉及:`web/tools_api.py`、`static/admin/tools.js`、`doc/API.md`、`doc/DEVELOPMENT.md`(配置速查)。
|
||||||
|
- 验收:切「本机」能列本机 USB + 已连 `IP:5555`;切「220」列 220 侧设备;`adb cmd` 同样生效。
|
||||||
|
|
||||||
- [ ] **`.env` 的 adb 路由键要在文档里说明(或去掉)**
|
- [ ] **`.env` 的 adb 路由键要在文档里说明(或去掉)**
|
||||||
- 背景:`ANDROID_ADB_SERVER_ADDRESS/HOST/PORT` 会被 `.env` 注入 `os.environ`,**adb 客户端**据此把所有 adb 调用指向 220;`ANDROID_ADB_SERVER_HOST` 是非标准键(adb 不认)。
|
- `ANDROID_ADB_SERVER_ADDRESS/HOST/PORT` 会被 `.env` 注入 `os.environ`,**adb 客户端**据此把所有调用指向 220;`ANDROID_ADB_SERVER_HOST` 是非标准键(adb 不认)。
|
||||||
- 期望:在 `doc/DEVELOPMENT.md` 配置速查 / `doc/DEPLOY.md` 写明该行为与取舍;或本地去掉这三行改走本机 adb(`USB_ADB_HOST` 保留,平台对 220 的 USB 访问走显式 `-H/-P`)。
|
- 期望:在配置速查里写明该行为与取舍,或本地去掉这三行改走本机 adb。
|
||||||
|
|
||||||
|
- [ ] **`.gitignore` 漏项**:`data/mcp_audit.log` 与 `data/uiauto.pid` 未被忽略(`git status` 会显示为未跟踪);`data/apks/` 目录无占位文件。
|
||||||
|
|
||||||
- [ ] 220 的 Tailscale / adb server(5037) 可达性巡检(曾出现连不通超时)。
|
- [ ] 220 的 Tailscale / adb server(5037) 可达性巡检(曾出现连不通超时)。
|
||||||
|
|
||||||
## 编辑器 / 元素抓取
|
---
|
||||||
|
|
||||||
- [ ] **未命中要"明确提示",别混成"执行了这步"**
|
## C. 编辑器 / 元素抓取
|
||||||
- 背景(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`。
|
|
||||||
|
|
||||||
|
- [ ] **未命中要「明确提示」,别混成「执行了这步」**
|
||||||
|
- 背景(2026-09-10):click/long_click/wait_el/swipe_until/if_el 未命中时只写 `[WARNING] ... 未找到元素`,前端进度仍按"执行了一步"计数 → 用户看到像"成功",实际没点(db03f16e 案例就是这么误判的)。
|
||||||
|
- 期望:执行器把**未命中**作为该步结果(✗)返回并计入统计;前端标红 ✗ 并给出所用选择器;任务结束汇总未命中步骤数。
|
||||||
|
- 涉及:`tasks/generic/task.py`、`static/admin/{tasks,monitor}.js`、`doc/TASK_DEV.md`。
|
||||||
|
|
||||||
- [ ] **元素抓取(点击元素)超时——部分设备 dump 慢于平台超时**
|
- [ ] **元素抓取超时——部分设备 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)。
|
- 背景(2026-09-10 实测):`core/uiauto_helper.py` 的 `_TIMEOUT=(1,8)`;某设备 dump **~18s** → 平台报「uiauto2 请求超时」,编辑器抓不到元素(设备本身正常)。
|
||||||
- 期望:提高读超时(如 `(2, 30)`)+ 前端 `/api/uiauto/elements` 加载中给明确提示;可选加「同一界面短时缓存」或「抓取前提示切到轻界面」。
|
- 期望:读超时提到 `(2, 30)` + 前端加载中有明确提示;可选"同界面短时缓存"。
|
||||||
- 涉及:`core/uiauto_helper.py`、`web/tasks_api.py`(/api/uiauto/elements)、`static/admin/editor.js`、`doc/API.md`、`doc/ARCHITECTURE.md`(§3.8)。
|
- 涉及:`core/uiauto_helper.py`、`web/tasks_api.py`、`static/admin/editor.js`、`doc/ARCHITECTURE.md`。
|
||||||
|
|
||||||
|
- [ ] **序号型 XPath / 界面就绪 的防呆**(2026-09-10 db03f16e 案例)
|
||||||
|
- 背景:`(//*[@resource-id="…"])[6]` 依赖"抓取那一刻该属性有 ≥6 个实例";运行时界面不同(App 仍在闪屏页)→ 序号必然失配。
|
||||||
|
- 期望:① `open_app` 提示勾选「等待首页」;② 抓取器对**带序号**的选择器加醒目提示;③ 抓取弹窗提醒"请先把设备停在任务运行到该步时的同一界面再抓"。
|
||||||
|
- 涉及:`core/uiauto_helper.py`、`static/admin/editor.js`、`tasks/generic/task.py`、`doc/TASK_DEV.md`。
|
||||||
|
|
||||||
- [ ] 自定义动作支持 `action_ref` 引用型节点(现状:拖入画布会**展开成 group**,改动需同步执行器 + 编辑器)。
|
- [ ] 自定义动作支持 `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 控制台 / 经验与动作
|
## D. AI 控制台 / 经验与动作
|
||||||
|
|
||||||
- [ ] 「🧠 经验库 / 🎬 动作库」合为一个面板(标签页切换)。
|
- [ ] 「🧠 经验库 / 🎬 动作库」合为一个面板(标签页切换)。
|
||||||
- [ ] AI 建任务 P0:平台级 MCP 工具(只读清单 `task_types/groups/pool` + `submit_task(draft)` 校验)+ 把**动作库**当作 generic_steps 的预制件复用(见 `doc/AI_TASK_GEN.md` §9)。
|
- [ ] AI 建任务 P0:平台级 MCP 工具(只读清单 `task_types/groups/pool` + `submit_task(draft)` 校验)+ 把**动作库**当作 generic_steps 的预制件复用(见 [AI_TASK_GEN.md](../AI_TASK_GEN.md) §9)。
|
||||||
- [ ] 新增 MCP `de_screen_text`(文本化看屏:前台包名 + screen_state + 可点元素文本 + OCR),并把操作纪律写进工具 description(对外部客户端尤其有用)。
|
- [ ] 新增 MCP `de_screen_text`(文本化看屏:前台包名 + screen_state + 可点元素文本 + OCR),并把操作纪律写进工具 description。
|
||||||
- [ ] 经验召回改进:现为 bigram + `ORDER BY hits`(候选只看 top50、hits 对所有命中行回写 → 马太效应);改为对称相似度/更合理候选集,hits 仅在真正注入时 +1。
|
- [ ] 经验召回改进:现为 bigram + `ORDER BY hits`(候选只看 top50、hits 对所有命中行回写 → 马太效应);改为对称相似度 / 更合理候选集,`hits` 仅在真正注入时 +1。
|
||||||
- [ ] 经验/动作入库依赖模型蒸馏成功:加失败重试与可视化观测(现在只在日志里)。
|
- [ ] 经验/动作入库依赖模型蒸馏成功:加失败重试与可视化观测(现在只在日志里)。
|
||||||
|
|
||||||
## 其他
|
---
|
||||||
|
|
||||||
- [ ] `platform-tools` → `auto_control` 命名统一(README/服务名/单元名,待定)。
|
## E. 工程化 / 文档
|
||||||
- [ ] STF 容器状态确认:`doc/DEVELOPMENT.md`(已停用)与 `doc/STF_REMOVAL.md`(待人工确认)说法矛盾,需以 220 实际为准后统一。
|
|
||||||
- [ ] MCP 白名单语义缺口:`MCP_ALLOWED_SERIALS` 为空时并未按设计限制为"平台设备池内"(当前只校验非空),见 `doc/MCP.md`;如需收紧需补实现。
|
- [ ] **MCP 白名单语义缺口**:`MCP_ALLOWED_SERIALS` 为空时并未按设计限制为"平台设备池内"(当前只校验非空),见 [MCP.md](../MCP.md);如需收紧需补实现。
|
||||||
|
- [ ] **`MCP_DESIGN.md` 里的"演进备选"(独立 mcp-server 容器)未落地**:已在文中标注,若确定不做可删除该节,避免后续误读。
|
||||||
|
- [ ] **STF 容器状态确认**:`doc/DEVELOPMENT.md`(已停用)与 `doc/STF_REMOVAL.md`(待人工确认)说法矛盾,需以 220 实际为准后统一。
|
||||||
|
- [ ] **`DISCOVERY_SUBNETS` 无 env 支持**:`config.py` 里是硬编码列表,其余发现配置走 `app_meta`;若要支持 `.env` 覆盖需补实现。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## F. 已完成(留档,便于追溯)
|
||||||
|
|
||||||
|
- [x] `platform-tools` → `auto_control` 命名统一(2026-09-10:文档/README/`scripts/pack.py` 产物名)。
|
||||||
|
- [x] 删除抖音养号任务类型(`douyin_nurture`),平台只保留 `generic_steps`;残留旧类型任务改为**启动告警 + 执行时明确报错**。
|
||||||
|
- [x] `generic_steps` 去掉默认步骤;空步骤任务执行时明确报错(不再静默空跑)。
|
||||||
|
- [x] 重试耗尽时的 `last_error` 带上真实失败原因(不再只有"重试 N 次失败")。
|
||||||
|
- [x] 监控页任务卡去掉「编辑/删除」,只留执行/停用 + 显示**覆盖设备**。
|
||||||
|
- [x] 备份覆盖清单补 `agent_action` / `app_meta` + 导出/导入双向自检。
|
||||||
|
- [x] AI 控制台:Markdown 渲染、推理链可折叠、token 用量显示。
|
||||||
|
|||||||
@@ -152,3 +152,5 @@ P0 接收与澄清 → P1 选设备与预检(含"屏幕是否点亮/解锁") →
|
|||||||
- 知识库(工具/约定/红线):`doc/staffdeck/KNOWLEDGE_BASE.md`
|
- 知识库(工具/约定/红线):`doc/staffdeck/KNOWLEDGE_BASE.md`
|
||||||
- 工具手册:`doc/MCP.md`;平台接口:`doc/API.md`
|
- 工具手册:`doc/MCP.md`;平台接口:`doc/API.md`
|
||||||
- 安全与配置:`doc/DEPLOY.md`、`doc/DEVELOPMENT.md`
|
- 安全与配置:`doc/DEPLOY.md`、`doc/DEVELOPMENT.md`
|
||||||
|
- AI 控制台机制 / 架构 / 数据:`doc/AI_CONSOLE.md`、`doc/ARCHITECTURE.md`、`doc/DATA_MODEL.md`
|
||||||
|
- 文档总索引:`doc/README.md`
|
||||||
|
|||||||
@@ -248,4 +248,6 @@ P0 接收与澄清 → P1 选设备与预检(§6) → P2 到达起点 → P3 主
|
|||||||
## 12. 当前边界与相关文档
|
## 12. 当前边界与相关文档
|
||||||
- MCP 无任务 CRUD、无独立鉴权(靠网络隔离)。
|
- MCP 无任务 CRUD、无独立鉴权(靠网络隔离)。
|
||||||
- `doc/MCP.md`(工具手册)|`doc/MCP_DESIGN.md`(设计与现状对照)|`doc/API.md`(REST 目录)
|
- `doc/MCP.md`(工具手册)|`doc/MCP_DESIGN.md`(设计与现状对照)|`doc/API.md`(REST 目录)
|
||||||
|
- 平台内部机制:`doc/AI_CONSOLE.md`(AI 控制台)、`doc/ARCHITECTURE.md`(架构)、`doc/DATA_MODEL.md`(数据)
|
||||||
|
- 文档总索引:`doc/README.md`
|
||||||
- 岗位职责与授权分级:`doc/staffdeck/JOB_SPEC.md`
|
- 岗位职责与授权分级:`doc/staffdeck/JOB_SPEC.md`
|
||||||
|
|||||||
+84
-2
@@ -8,6 +8,8 @@
|
|||||||
- content/reasoning_content 增量逐 chunk 回调(kind 区分)
|
- content/reasoning_content 增量逐 chunk 回调(kind 区分)
|
||||||
- tool_calls 分片累积(arguments 按 index 拼接),流结束后统一执行
|
- tool_calls 分片累积(arguments 按 index 拼接),流结束后统一执行
|
||||||
- 截图(de_screenshot)图像转 image_url 追加下一轮,同时 on_tool 回调带缩略
|
- 截图(de_screenshot)图像转 image_url 追加下一轮,同时 on_tool 回调带缩略
|
||||||
|
- token 用量:请求带 stream_options.include_usage,按「每次模型调用」累计,
|
||||||
|
on_usage 回调吐出累计值(Web 控制台展示)
|
||||||
"""
|
"""
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
@@ -22,6 +24,10 @@ _log = logging.getLogger("agent")
|
|||||||
|
|
||||||
S = AgentSettings()
|
S = AgentSettings()
|
||||||
|
|
||||||
|
|
||||||
|
class _UsageUnsupported(RuntimeError):
|
||||||
|
"""模型/网关不认 stream_options.include_usage(400/422 或报错点名该字段)——降级重试用。"""
|
||||||
|
|
||||||
SYSTEM_PROMPT = """你是手机自动化控制助手。你通过工具实时操作 Android 手机。
|
SYSTEM_PROMPT = """你是手机自动化控制助手。你通过工具实时操作 Android 手机。
|
||||||
|
|
||||||
工作规范:
|
工作规范:
|
||||||
@@ -56,8 +62,15 @@ class Agent:
|
|||||||
# 回调(Web 展示用,均可选):
|
# 回调(Web 展示用,均可选):
|
||||||
# on_delta(text, kind) kind: content | reasoning —— 流式文本增量
|
# on_delta(text, kind) kind: content | reasoning —— 流式文本增量
|
||||||
# on_tool(step) step: {tool, args, result, image} —— 工具调用完成
|
# on_tool(step) step: {tool, args, result, image} —— 工具调用完成
|
||||||
|
# on_usage(usage) usage: {prompt_tokens, completion_tokens,
|
||||||
|
# total_tokens, calls} —— 累计 token 用量
|
||||||
self.on_delta = None
|
self.on_delta = None
|
||||||
self.on_tool = None
|
self.on_tool = None
|
||||||
|
self.on_usage = None
|
||||||
|
# 本轮累计用量(run_stream 开始时重置)
|
||||||
|
self.usage = {"prompt_tokens": 0, "completion_tokens": 0,
|
||||||
|
"total_tokens": 0, "calls": 0}
|
||||||
|
self._include_usage = True # 模型不认 stream_options 时自动置 False
|
||||||
self._mcp = None
|
self._mcp = None
|
||||||
|
|
||||||
# ---------- MCP 工具桥 ----------
|
# ---------- MCP 工具桥 ----------
|
||||||
@@ -88,7 +101,23 @@ class Agent:
|
|||||||
|
|
||||||
# ---------- 模型调用(流式) ----------
|
# ---------- 模型调用(流式) ----------
|
||||||
async def _chat_stream(self):
|
async def _chat_stream(self):
|
||||||
"""流式 chat/completions:逐 chunk 产出 JSON(async generator)。"""
|
"""流式 chat/completions:逐 chunk 产出 JSON(async generator)。
|
||||||
|
|
||||||
|
默认要求服务端在末尾 chunk 带 usage(token 统计);个别网关不认
|
||||||
|
`stream_options` 会直接 400,此时自动降级重试一次(不影响主流程)。
|
||||||
|
"""
|
||||||
|
if self._include_usage:
|
||||||
|
try:
|
||||||
|
async for chunk in self._chat_stream_once(True):
|
||||||
|
yield chunk
|
||||||
|
return
|
||||||
|
except _UsageUnsupported as e:
|
||||||
|
_log.warning("模型不支持 stream_options.include_usage,降级重试:%s", e)
|
||||||
|
self._include_usage = False
|
||||||
|
async for chunk in self._chat_stream_once(False):
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
async def _chat_stream_once(self, with_usage):
|
||||||
body = {
|
body = {
|
||||||
"model": self.s.model,
|
"model": self.s.model,
|
||||||
"messages": self.messages,
|
"messages": self.messages,
|
||||||
@@ -96,6 +125,8 @@ class Agent:
|
|||||||
"max_tokens": 4096,
|
"max_tokens": 4096,
|
||||||
"stream": True,
|
"stream": True,
|
||||||
}
|
}
|
||||||
|
if with_usage:
|
||||||
|
body["stream_options"] = {"include_usage": True}
|
||||||
headers = {"Authorization": f"Bearer {self.s.api_key}",
|
headers = {"Authorization": f"Bearer {self.s.api_key}",
|
||||||
"Content-Type": "application/json"}
|
"Content-Type": "application/json"}
|
||||||
url = f"{self.s.api_base.rstrip('/')}/chat/completions"
|
url = f"{self.s.api_base.rstrip('/')}/chat/completions"
|
||||||
@@ -103,6 +134,11 @@ class Agent:
|
|||||||
async with client.stream("POST", url, json=body, headers=headers) as r:
|
async with client.stream("POST", url, json=body, headers=headers) as r:
|
||||||
if r.status_code != 200:
|
if r.status_code != 200:
|
||||||
text = (await r.aread()).decode(errors="replace")
|
text = (await r.aread()).decode(errors="replace")
|
||||||
|
# 本次开了 include_usage 却被打回(400/422,或报错里点名这个字段)
|
||||||
|
# → 视作网关不支持,交给上层降级重试(401/余额等真错误照常抛出)
|
||||||
|
if with_usage and (r.status_code in (400, 422)
|
||||||
|
or "stream_options" in text):
|
||||||
|
raise _UsageUnsupported(text[:200])
|
||||||
raise RuntimeError(f"模型 API HTTP {r.status_code}: {text[:300]}")
|
raise RuntimeError(f"模型 API HTTP {r.status_code}: {text[:300]}")
|
||||||
async for line in r.aiter_lines():
|
async for line in r.aiter_lines():
|
||||||
if not line.startswith("data:"):
|
if not line.startswith("data:"):
|
||||||
@@ -115,6 +151,37 @@ class Agent:
|
|||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# ---------- token 用量 ----------
|
||||||
|
@staticmethod
|
||||||
|
def _read_usage(raw):
|
||||||
|
"""把一次模型调用返回的 usage 规整为 {prompt, completion, total};无效返回 None。"""
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
pt = int(raw.get("prompt_tokens") or 0)
|
||||||
|
ct = int(raw.get("completion_tokens") or 0)
|
||||||
|
tt = int(raw.get("total_tokens") or 0) or (pt + ct)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if not (pt or ct or tt):
|
||||||
|
return None
|
||||||
|
return {"prompt_tokens": pt, "completion_tokens": ct, "total_tokens": tt}
|
||||||
|
|
||||||
|
def _accumulate_usage(self, raw):
|
||||||
|
"""把一次模型调用的 usage 累加进本轮总量,并回调 on_usage(累计值)。"""
|
||||||
|
u = self._read_usage(raw)
|
||||||
|
if not u:
|
||||||
|
return
|
||||||
|
self.usage["prompt_tokens"] += u["prompt_tokens"]
|
||||||
|
self.usage["completion_tokens"] += u["completion_tokens"]
|
||||||
|
self.usage["total_tokens"] += u["total_tokens"]
|
||||||
|
self.usage["calls"] += 1
|
||||||
|
if self.on_usage:
|
||||||
|
try:
|
||||||
|
self.on_usage(dict(self.usage))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# ---------- 工具执行 ----------
|
# ---------- 工具执行 ----------
|
||||||
async def _execute_tool(self, name, arguments):
|
async def _execute_tool(self, name, arguments):
|
||||||
"""执行 MCP 工具,返回 (文本结果, image_data_or_None)。"""
|
"""执行 MCP 工具,返回 (文本结果, image_data_or_None)。"""
|
||||||
@@ -161,7 +228,7 @@ class Agent:
|
|||||||
# ---------- 主循环(流式) ----------
|
# ---------- 主循环(流式) ----------
|
||||||
async def run_stream(self, prompt: str, serial: str = "",
|
async def run_stream(self, prompt: str, serial: str = "",
|
||||||
history=None, on_delta=None, on_tool=None,
|
history=None, on_delta=None, on_tool=None,
|
||||||
should_stop=None, extra_context=None):
|
should_stop=None, extra_context=None, on_usage=None):
|
||||||
"""流式执行一轮指令,返回最终完整文本。
|
"""流式执行一轮指令,返回最终完整文本。
|
||||||
|
|
||||||
history:上一轮的 [{"role": "user"|"assistant", "content": 文本}] 列表,
|
history:上一轮的 [{"role": "user"|"assistant", "content": 文本}] 列表,
|
||||||
@@ -170,9 +237,15 @@ class Agent:
|
|||||||
on_tool(step):工具调用完成(实时显示 MCP 步骤)
|
on_tool(step):工具调用完成(实时显示 MCP 步骤)
|
||||||
should_stop:可调用 fn() -> bool,每轮模型调用前检查(用户中断用)
|
should_stop:可调用 fn() -> bool,每轮模型调用前检查(用户中断用)
|
||||||
extra_context:附加文本(经验记忆注入,放在 system prompt 末尾)
|
extra_context:附加文本(经验记忆注入,放在 system prompt 末尾)
|
||||||
|
on_usage(usage):每完成一次模型调用回调一次(累计值,见 self.usage)
|
||||||
|
|
||||||
|
本轮累计 token 用量同时留在 self.usage(调用方可直接读)。
|
||||||
"""
|
"""
|
||||||
self.on_delta = on_delta
|
self.on_delta = on_delta
|
||||||
self.on_tool = on_tool
|
self.on_tool = on_tool
|
||||||
|
self.on_usage = on_usage
|
||||||
|
self.usage = {"prompt_tokens": 0, "completion_tokens": 0,
|
||||||
|
"total_tokens": 0, "calls": 0}
|
||||||
target = serial or self.s.default_serial
|
target = serial or self.s.default_serial
|
||||||
sys_txt = SYSTEM_PROMPT
|
sys_txt = SYSTEM_PROMPT
|
||||||
if target:
|
if target:
|
||||||
@@ -193,9 +266,13 @@ class Agent:
|
|||||||
tool_acc = {} # index -> {id, name, args}
|
tool_acc = {} # index -> {id, name, args}
|
||||||
has_tool = False
|
has_tool = False
|
||||||
retried = False
|
retried = False
|
||||||
|
call_usage = None # 本次模型调用的 usage(末尾 chunk 带)
|
||||||
while True:
|
while True:
|
||||||
|
call_usage = None # 重试时丢弃上一次(未完成)的用量
|
||||||
try:
|
try:
|
||||||
async for chunk in self._chat_stream():
|
async for chunk in self._chat_stream():
|
||||||
|
if chunk.get("usage"):
|
||||||
|
call_usage = chunk["usage"]
|
||||||
choice = (chunk.get("choices") or [{}])[0]
|
choice = (chunk.get("choices") or [{}])[0]
|
||||||
delta = choice.get("delta") or {}
|
delta = choice.get("delta") or {}
|
||||||
text = delta.get("content")
|
text = delta.get("content")
|
||||||
@@ -228,6 +305,7 @@ class Agent:
|
|||||||
continue
|
continue
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
self._accumulate_usage(call_usage)
|
||||||
full_content = "".join(content_parts)
|
full_content = "".join(content_parts)
|
||||||
|
|
||||||
if has_tool:
|
if has_tool:
|
||||||
@@ -272,13 +350,17 @@ class Agent:
|
|||||||
"已完成的部分、当前设备状态、未能完成的原因与下一步建议。"
|
"已完成的部分、当前设备状态、未能完成的原因与下一步建议。"
|
||||||
"不要调用任何工具。"})
|
"不要调用任何工具。"})
|
||||||
parts = []
|
parts = []
|
||||||
|
call_usage = None
|
||||||
async for chunk in self._chat_stream():
|
async for chunk in self._chat_stream():
|
||||||
|
if chunk.get("usage"):
|
||||||
|
call_usage = chunk["usage"]
|
||||||
delta = (chunk.get("choices") or [{}])[0].get("delta") or {}
|
delta = (chunk.get("choices") or [{}])[0].get("delta") or {}
|
||||||
text = delta.get("content")
|
text = delta.get("content")
|
||||||
if text:
|
if text:
|
||||||
parts.append(text)
|
parts.append(text)
|
||||||
if on_delta:
|
if on_delta:
|
||||||
on_delta(text, "content")
|
on_delta(text, "content")
|
||||||
|
self._accumulate_usage(call_usage)
|
||||||
self.tools_schema = saved_tools
|
self.tools_schema = saved_tools
|
||||||
summary = "".join(parts)
|
summary = "".join(parts)
|
||||||
return summary or "(已达步骤上限,模型未能生成总结)"
|
return summary or "(已达步骤上限,模型未能生成总结)"
|
||||||
|
|||||||
+2
-2
@@ -1,14 +1,14 @@
|
|||||||
"""打包脚本:把项目代码打成 zip 压缩包,排除运行时产物。
|
"""打包脚本:把项目代码打成 zip 压缩包,排除运行时产物。
|
||||||
|
|
||||||
用法:python scripts/pack.py
|
用法:python scripts/pack.py
|
||||||
输出:项目根目录下 platform-tools.zip
|
输出:项目根目录下 auto_control.zip
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
OUT = os.path.join(os.path.dirname(ROOT), "platform-tools.zip")
|
OUT = os.path.join(os.path.dirname(ROOT), "auto_control.zip")
|
||||||
|
|
||||||
SKIP_DIRS = {"__pycache__", ".git", ".idea", "venv", ".venv", "node_modules"}
|
SKIP_DIRS = {"__pycache__", ".git", ".idea", "venv", ".venv", "node_modules"}
|
||||||
SKIP_EXTS = {".pyc", ".pyo"}
|
SKIP_EXTS = {".pyc", ".pyo"}
|
||||||
|
|||||||
+130
-16
@@ -120,6 +120,11 @@ function pollRunState(){
|
|||||||
? '将操作:' + document.getElementById('agent-target-select').value : '');
|
? '将操作:' + document.getElementById('agent-target-select').value : '');
|
||||||
}
|
}
|
||||||
document.getElementById('btn-agent-send').disabled = running;
|
document.getElementById('btn-agent-send').disabled = running;
|
||||||
|
// 运行中同步 token(本窗口没订阅事件流时也能看到进度)
|
||||||
|
if(running && !_agentStream && r.usage && r.usage.total_tokens){
|
||||||
|
_liveUsage = r.usage;
|
||||||
|
updateSessionTokens();
|
||||||
|
}
|
||||||
if(!running && _agentBusy){ setRunning(false); } // 流异常丢失时复位
|
if(!running && _agentBusy){ setRunning(false); } // 流异常丢失时复位
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -173,11 +178,91 @@ function newAssistantMsg(){
|
|||||||
clearEmpty();
|
clearEmpty();
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = 'agent-msg assistant';
|
div.className = 'agent-msg assistant';
|
||||||
div.innerHTML = '<div class="agent-toolcards"></div><div class="agent-text"></div>';
|
// 结构:工具卡片 → 可折叠推理链 → 正文(Markdown)→ token 用量脚注
|
||||||
|
div.innerHTML =
|
||||||
|
'<div class="agent-toolcards"></div>'
|
||||||
|
+ '<details class="reasoning" style="display:none"><summary>💭 思考过程</summary>'
|
||||||
|
+ '<div class="reasoning-text"></div></details>'
|
||||||
|
+ '<div class="agent-text"></div>'
|
||||||
|
+ '<div class="agent-usage" style="display:none"></div>';
|
||||||
chat.appendChild(div);
|
chat.appendChild(div);
|
||||||
scrollChat();
|
scrollChat();
|
||||||
return div;
|
return div;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- 推理链(可折叠) ----------
|
||||||
|
function _appendReasoning(msgEl, text){
|
||||||
|
const box = msgEl.querySelector('.reasoning');
|
||||||
|
if(!box) return;
|
||||||
|
const body = box.querySelector('.reasoning-text');
|
||||||
|
body.textContent += text;
|
||||||
|
box.querySelector('summary').textContent =
|
||||||
|
'💭 思考过程(' + body.textContent.length + ' 字)';
|
||||||
|
box.style.display = 'block';
|
||||||
|
_bindReasoning(box);
|
||||||
|
if(!box.open) box.open = true; // 流式思考时展开,正文开始时自动收起
|
||||||
|
}
|
||||||
|
// 用户手动点过 summary 后不再自动收起/展开(尊重手动状态)
|
||||||
|
function _bindReasoning(box){
|
||||||
|
const sum = box.querySelector('summary');
|
||||||
|
if(sum && !sum.dataset.bound){
|
||||||
|
sum.dataset.bound = '1';
|
||||||
|
sum.addEventListener('click', ()=>{ box.dataset.touched = '1'; });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 正文 Markdown 渲染(渲染器见 markdown.js) ----------
|
||||||
|
function _renderMd(msgEl){
|
||||||
|
const el = msgEl.querySelector('.agent-text');
|
||||||
|
if(el) el.innerHTML = renderMarkdown(msgEl._md || '');
|
||||||
|
}
|
||||||
|
function _scheduleMd(msgEl){
|
||||||
|
if(msgEl._mdRaf) return; // 流式增量按帧合并渲染,避免每个 token 重排
|
||||||
|
msgEl._mdRaf = requestAnimationFrame(()=>{
|
||||||
|
msgEl._mdRaf = 0;
|
||||||
|
_renderMd(msgEl);
|
||||||
|
scrollChat();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- token 用量 ----------
|
||||||
|
let _convUsage = {}; // 当前会话历史累计(读会话时算)
|
||||||
|
let _liveUsage = {}; // 本轮运行中累计(SSE usage 事件)
|
||||||
|
function _fmtInt(n){ return String(n || 0).replace(/\B(?=(\d{3})+(?!\d))/g, ','); }
|
||||||
|
function _usageSum(a, b){
|
||||||
|
const o = {prompt_tokens:0, completion_tokens:0, total_tokens:0, calls:0};
|
||||||
|
[a, b].forEach(u=>{
|
||||||
|
if(!u) return;
|
||||||
|
o.prompt_tokens += (u.prompt_tokens || 0);
|
||||||
|
o.completion_tokens += (u.completion_tokens || 0);
|
||||||
|
o.total_tokens += (u.total_tokens || 0);
|
||||||
|
o.calls += (u.calls || 0);
|
||||||
|
});
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
// 单条消息脚注
|
||||||
|
function renderUsage(msgEl, u){
|
||||||
|
const el = msgEl.querySelector('.agent-usage');
|
||||||
|
if(!el) return;
|
||||||
|
if(!u || !u.total_tokens){ el.style.display = 'none'; return; }
|
||||||
|
el.style.display = 'block';
|
||||||
|
el.title = '输入 ' + _fmtInt(u.prompt_tokens) + ' + 输出 ' + _fmtInt(u.completion_tokens)
|
||||||
|
+ ' = ' + _fmtInt(u.total_tokens) + ' tokens,共 ' + (u.calls || 0) + ' 次模型调用';
|
||||||
|
el.textContent = '🪙 ' + _fmtInt(u.total_tokens) + ' tokens'
|
||||||
|
+ '(↑' + _fmtInt(u.prompt_tokens) + ' ↓' + _fmtInt(u.completion_tokens)
|
||||||
|
+ ' · ' + (u.calls || 0) + ' 次调用)';
|
||||||
|
}
|
||||||
|
// 顶栏「本会话累计」= 已落库历史 + 运行中本轮
|
||||||
|
function updateSessionTokens(){
|
||||||
|
const el = document.getElementById('agent-token-total');
|
||||||
|
if(!el) return;
|
||||||
|
const t = _usageSum(_convUsage, _liveUsage);
|
||||||
|
if(!t.total_tokens){ el.textContent = ''; el.title = ''; return; }
|
||||||
|
el.textContent = '🪙 本会话 ' + _fmtInt(t.total_tokens) + ' tokens';
|
||||||
|
el.title = '本会话累计:输入 ' + _fmtInt(t.prompt_tokens)
|
||||||
|
+ ' + 输出 ' + _fmtInt(t.completion_tokens)
|
||||||
|
+ ' = ' + _fmtInt(t.total_tokens) + ' tokens,共 ' + t.calls + ' 次模型调用';
|
||||||
|
}
|
||||||
function clearEmpty(){
|
function clearEmpty(){
|
||||||
const empty = document.querySelector('#agent-chat .agent-empty');
|
const empty = document.querySelector('#agent-chat .agent-empty');
|
||||||
if(empty) empty.remove();
|
if(empty) empty.remove();
|
||||||
@@ -197,6 +282,9 @@ function bindChatScroll(){
|
|||||||
}
|
}
|
||||||
function clearAgentChat(){
|
function clearAgentChat(){
|
||||||
document.getElementById('agent-chat').innerHTML = '';
|
document.getElementById('agent-chat').innerHTML = '';
|
||||||
|
_convUsage = {}; // 换会话:token 累计重新算
|
||||||
|
_liveUsage = {};
|
||||||
|
updateSessionTokens();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ================== 历史会话(DeepSeek 式:左侧列表,多会话持久化) ==================
|
// ================== 历史会话(DeepSeek 式:左侧列表,多会话持久化) ==================
|
||||||
@@ -293,15 +381,26 @@ function loadCurrentConvMessages(){
|
|||||||
apiGet('/api/agent/conversations/'+_currentConvId).then(r=>{
|
apiGet('/api/agent/conversations/'+_currentConvId).then(r=>{
|
||||||
if(!r||!r.ok)return;
|
if(!r||!r.ok)return;
|
||||||
chat.innerHTML = '';
|
chat.innerHTML = '';
|
||||||
|
_convUsage = {};
|
||||||
|
_liveUsage = {};
|
||||||
(r.messages||[]).forEach(m=>{
|
(r.messages||[]).forEach(m=>{
|
||||||
const txt = (m.content||'').trim();
|
const txt = (m.content||'').trim();
|
||||||
if(!txt) return;
|
if(m.role==='user'){ if(txt) addUserMsg(txt); return; }
|
||||||
if(m.role==='user'){ addUserMsg(txt); }
|
if(!txt && !m.reasoning && !(m.usage && m.usage.total_tokens)) return;
|
||||||
else{
|
|
||||||
const div = newAssistantMsg();
|
const div = newAssistantMsg();
|
||||||
div.querySelector('.agent-text').textContent = txt;
|
if(txt){ div._md = txt; _renderMd(div); }
|
||||||
|
if(m.reasoning){
|
||||||
|
_appendReasoning(div, m.reasoning);
|
||||||
|
const box = div.querySelector('.reasoning');
|
||||||
|
_bindReasoning(box);
|
||||||
|
box.open = false; // 历史消息默认折叠推理链
|
||||||
|
}
|
||||||
|
if(m.usage){
|
||||||
|
renderUsage(div, m.usage);
|
||||||
|
_convUsage = _usageSum(_convUsage, m.usage);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
updateSessionTokens();
|
||||||
if(!chat.children.length){
|
if(!chat.children.length){
|
||||||
chat.innerHTML = '<div class="agent-empty">新会话。给 AI 下达指令'
|
chat.innerHTML = '<div class="agent-empty">新会话。给 AI 下达指令'
|
||||||
+ '(先选 🎯 目标设备)…</div>';
|
+ '(先选 🎯 目标设备)…</div>';
|
||||||
@@ -379,21 +478,29 @@ function listenStream(runId){
|
|||||||
es.addEventListener('delta', ev=>{
|
es.addEventListener('delta', ev=>{
|
||||||
if(!msgEl) msgEl = newAssistantMsg();
|
if(!msgEl) msgEl = newAssistantMsg();
|
||||||
const d = JSON.parse(ev.data);
|
const d = JSON.parse(ev.data);
|
||||||
const textEl = msgEl.querySelector('.agent-text');
|
|
||||||
if(d.kind === 'reasoning'){
|
if(d.kind === 'reasoning'){
|
||||||
let r = msgEl.querySelector('.reasoning');
|
_appendReasoning(msgEl, d.text || '');
|
||||||
if(!r){
|
|
||||||
r = document.createElement('div');
|
|
||||||
r.className = 'reasoning';
|
|
||||||
msgEl.insertBefore(r, textEl);
|
|
||||||
}
|
|
||||||
r.textContent += d.text;
|
|
||||||
}else{
|
}else{
|
||||||
textEl.textContent += d.text;
|
// 正文开始 → 收起推理链(用户手动开过的保持原样)
|
||||||
|
const r = msgEl.querySelector('.reasoning');
|
||||||
|
if(r && r.open && !r.dataset.touched) r.open = false;
|
||||||
|
msgEl._md = (msgEl._md || '') + (d.text || '');
|
||||||
|
msgEl._mdPending = false;
|
||||||
|
_scheduleMd(msgEl);
|
||||||
}
|
}
|
||||||
scrollChat();
|
scrollChat();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 每次模型调用完成 → 实时刷新 token 计数(本轮累计)
|
||||||
|
es.addEventListener('usage', ev=>{
|
||||||
|
if(!msgEl) msgEl = newAssistantMsg();
|
||||||
|
let d = {};
|
||||||
|
try{ d = JSON.parse(ev.data) || {}; }catch(e){}
|
||||||
|
_liveUsage = d;
|
||||||
|
renderUsage(msgEl, d);
|
||||||
|
updateSessionTokens();
|
||||||
|
});
|
||||||
|
|
||||||
es.addEventListener('step', ev=>{
|
es.addEventListener('step', ev=>{
|
||||||
const d = JSON.parse(ev.data);
|
const d = JSON.parse(ev.data);
|
||||||
followSerialFromArgs(d.args);
|
followSerialFromArgs(d.args);
|
||||||
@@ -417,10 +524,16 @@ function listenStream(runId){
|
|||||||
|
|
||||||
es.addEventListener('done', ev=>{
|
es.addEventListener('done', ev=>{
|
||||||
const d = JSON.parse(ev.data);
|
const d = JSON.parse(ev.data);
|
||||||
if(d.answer){
|
if(d.answer || d.usage || !msgEl){
|
||||||
if(!msgEl) msgEl = newAssistantMsg();
|
if(!msgEl) msgEl = newAssistantMsg();
|
||||||
msgEl.querySelector('.agent-text').textContent = d.answer;
|
if(d.answer){ msgEl._md = d.answer; _renderMd(msgEl); }
|
||||||
|
if(d.usage) renderUsage(msgEl, d.usage);
|
||||||
}
|
}
|
||||||
|
// 本轮用量并入会话累计(脚注已展示,顶栏不该因此变小),再清空「运行中」
|
||||||
|
_convUsage = _usageSum(_convUsage, _liveUsage);
|
||||||
|
_liveUsage = {};
|
||||||
|
updateSessionTokens();
|
||||||
|
scrollChat();
|
||||||
endRun();
|
endRun();
|
||||||
renderConvList(); // 落库已完成:刷新标题/时间/轮数
|
renderConvList(); // 落库已完成:刷新标题/时间/轮数
|
||||||
});
|
});
|
||||||
@@ -432,6 +545,7 @@ function listenStream(runId){
|
|||||||
}catch(e){}
|
}catch(e){}
|
||||||
if(!msgEl) msgEl = newAssistantMsg();
|
if(!msgEl) msgEl = newAssistantMsg();
|
||||||
msgEl.querySelector('.agent-text').textContent = '⚠ ' + _friendlyAgentError(msg);
|
msgEl.querySelector('.agent-text').textContent = '⚠ ' + _friendlyAgentError(msg);
|
||||||
|
_liveUsage = {}; // 本轮可能已花费 token,但结果无效——不并入会话累计
|
||||||
endRun();
|
endRun();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
// 轻量 Markdown 渲染(AI 控制台回答用)——不引外部库/CDN(生产 220 在内网)。
|
||||||
|
// 安全:**先 esc() 转义,再套标记**——模型输出里的原始 HTML 只会以文本显示,
|
||||||
|
// 不会变成可执行标签。
|
||||||
|
// 支持:标题 / 段落 / 换行 / 粗体 / 斜体 / 删除线 / 行内代码 / 围栏代码块 /
|
||||||
|
// 有序与无序列表(含一层以上嵌套)/ 引用 / 表格 / 分隔线 / 链接。
|
||||||
|
// 依赖:esc()(base.js,本文件须在其后加载)
|
||||||
|
|
||||||
|
// 行内代码占位符(控制字符,正文几乎不可能出现)
|
||||||
|
const _MD_PH = '\u0001';
|
||||||
|
|
||||||
|
function _mdInline(s){
|
||||||
|
const codes = [];
|
||||||
|
// 行内代码先摘出,避免其中的 ** * ~~ 被继续当标记解析
|
||||||
|
s = s.replace(/`([^`]+)`/g, (m, c)=>{
|
||||||
|
codes.push(c);
|
||||||
|
return _MD_PH + (codes.length - 1) + _MD_PH;
|
||||||
|
});
|
||||||
|
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||||
|
.replace(/~~([^~]+)~~/g, '<del>$1</del>')
|
||||||
|
.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>')
|
||||||
|
.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
|
||||||
|
'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
|
||||||
|
return s.replace(new RegExp(_MD_PH + '(\\d+)' + _MD_PH, 'g'),
|
||||||
|
(m, i)=>'<code>' + codes[+i] + '</code>');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表格行 → 单元格数组
|
||||||
|
function _mdCells(line){
|
||||||
|
return line.trim().replace(/^\|/, '').replace(/\|$/, '')
|
||||||
|
.split('|').map(c=>c.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 列表项递归渲染(缩进 = 层级;同类标记合并,类型变了外层另起一个列表)
|
||||||
|
function _mdList(items, out, idx, indent){
|
||||||
|
const ordered = items[idx].ordered;
|
||||||
|
const tag = ordered ? 'ol' : 'ul';
|
||||||
|
out.push('<' + tag + ' class="md-list">');
|
||||||
|
while(idx < items.length && items[idx].indent >= indent){
|
||||||
|
const it = items[idx];
|
||||||
|
if(it.indent > indent) break; // 更深缩进块由外层另起列表(避免非法嵌套)
|
||||||
|
if(it.ordered !== ordered) break; // 同级但标记类型变了 → 交回外层
|
||||||
|
out.push('<li>' + _mdInline(esc(it.text)));
|
||||||
|
if(idx + 1 < items.length && items[idx + 1].indent > indent){
|
||||||
|
idx = _mdList(items, out, idx + 1, items[idx + 1].indent);
|
||||||
|
out.push('</li>');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push('</li>');
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
out.push('</' + tag + '>');
|
||||||
|
return idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主入口:Markdown 文本 → HTML 字符串
|
||||||
|
function renderMarkdown(src){
|
||||||
|
const lines = String(src == null ? '' : src).replace(/\r\n?/g, '\n').split('\n');
|
||||||
|
const out = [];
|
||||||
|
let para = [];
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
const flushPara = ()=>{
|
||||||
|
if(!para.length) return;
|
||||||
|
out.push('<p>' + para.map(l=>_mdInline(esc(l))).join('<br>') + '</p>');
|
||||||
|
para = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
while(i < lines.length){
|
||||||
|
const ln = lines[i];
|
||||||
|
let m;
|
||||||
|
|
||||||
|
// 围栏代码块 ```lang … ```
|
||||||
|
if(/^\s*```/.test(ln)){
|
||||||
|
flushPara();
|
||||||
|
const body = [];
|
||||||
|
i++;
|
||||||
|
while(i < lines.length && !/^\s*```/.test(lines[i])){ body.push(lines[i]); i++; }
|
||||||
|
if(i < lines.length) i++; // 跳过结束围栏
|
||||||
|
out.push('<pre class="md-code"><code>' + esc(body.join('\n')) + '</code></pre>');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表格:表头行 + 分隔行(|---|---|)
|
||||||
|
if(/^\s*\|.*\|\s*$/.test(ln) && i + 1 < lines.length
|
||||||
|
&& /^\s*\|[\s:|-]+\|\s*$/.test(lines[i + 1])){
|
||||||
|
flushPara();
|
||||||
|
const head = _mdCells(ln);
|
||||||
|
i += 2;
|
||||||
|
const rows = [];
|
||||||
|
while(i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])){
|
||||||
|
rows.push(_mdCells(lines[i])); i++;
|
||||||
|
}
|
||||||
|
out.push('<table class="md-table"><thead><tr>'
|
||||||
|
+ head.map(c=>'<th>' + _mdInline(esc(c)) + '</th>').join('') + '</tr></thead><tbody>'
|
||||||
|
+ rows.map(r=>'<tr>' + r.map(c=>'<td>' + _mdInline(esc(c)) + '</td>').join('')
|
||||||
|
+ '</tr>').join('')
|
||||||
|
+ '</tbody></table>');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标题 #~######
|
||||||
|
m = ln.match(/^\s{0,3}(#{1,6})\s+(.*)$/);
|
||||||
|
if(m){
|
||||||
|
flushPara();
|
||||||
|
const lv = m[1].length;
|
||||||
|
out.push('<h' + lv + ' class="md-h">' + _mdInline(esc(m[2])) + '</h' + lv + '>');
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分隔线 --- / *** / ___
|
||||||
|
if(/^\s{0,3}([-*_])\s*(\1\s*){2,}$/.test(ln)){
|
||||||
|
flushPara(); out.push('<hr>'); i++; continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 引用 >
|
||||||
|
if(/^\s{0,3}>\s?/.test(ln)){
|
||||||
|
flushPara();
|
||||||
|
const body = [];
|
||||||
|
while(i < lines.length && /^\s{0,3}>\s?/.test(lines[i])){
|
||||||
|
body.push(lines[i].replace(/^\s{0,3}>\s?/, '')); i++;
|
||||||
|
}
|
||||||
|
out.push('<blockquote class="md-quote">'
|
||||||
|
+ body.map(l=>_mdInline(esc(l))).join('<br>') + '</blockquote>');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 列表(- * + / 1. 1))——含缩进续行
|
||||||
|
m = ln.match(/^(\s*)([-*+]|\d+[.)])\s+(.*)$/);
|
||||||
|
if(m){
|
||||||
|
flushPara();
|
||||||
|
const items = [];
|
||||||
|
while(i < lines.length){
|
||||||
|
const mm = lines[i].match(/^(\s*)([-*+]|\d+[.)])\s+(.*)$/);
|
||||||
|
if(!mm) break;
|
||||||
|
items.push({indent: mm[1].replace(/\t/g, ' ').length,
|
||||||
|
ordered: /\d/.test(mm[2]), text: mm[3]});
|
||||||
|
i++;
|
||||||
|
// 紧跟的纯缩进行算上一条的续行
|
||||||
|
while(i < lines.length && /^\s+\S/.test(lines[i])
|
||||||
|
&& !/^\s*([-*+]|\d+[.)])\s+/.test(lines[i])){
|
||||||
|
items[items.length - 1].text += ' ' + lines[i].trim();
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let k = 0;
|
||||||
|
while(k < items.length){
|
||||||
|
const n = _mdList(items, out, k, items[k].indent);
|
||||||
|
if(n <= k) break; // 防死循环
|
||||||
|
k = n;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 空行 = 段落分隔
|
||||||
|
if(!ln.trim()){ flushPara(); i++; continue; }
|
||||||
|
|
||||||
|
para.push(ln);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
flushPara();
|
||||||
|
return out.join('');
|
||||||
|
}
|
||||||
+31
-4
@@ -445,6 +445,34 @@ function fmtParams(params){
|
|||||||
return parts.join('');
|
return parts.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 任务覆盖的设备:后端给 serial 清单(coverage),这里用实时状态标注在线/运行中
|
||||||
|
function _jobCoverageHtml(j){
|
||||||
|
const cov=j.coverage||{}, serials=cov.serials||[];
|
||||||
|
const st={};
|
||||||
|
if(_statusData)for(const d of (_statusData.devices||[]))st[d.serial]=d;
|
||||||
|
if(!serials.length){
|
||||||
|
return '<div class="job-cov"><span class="kv">覆盖设备 0 台</span>'
|
||||||
|
+'<span class="cov-note">该目标下暂无可调度设备(检查设备池/分组)</span></div>';
|
||||||
|
}
|
||||||
|
let onlineN=0,busyN=0;
|
||||||
|
const chips=serials.map(s=>{
|
||||||
|
const d=st[s]||{};
|
||||||
|
const online=!!d.present;
|
||||||
|
const busy=(d.worker_status==='running'||d.worker_status==='connecting');
|
||||||
|
if(online)onlineN++;
|
||||||
|
if(busy)busyN++;
|
||||||
|
const cls=busy?'running':(online?'online':'offline');
|
||||||
|
const title=s+(d.model?' · '+d.model:'')
|
||||||
|
+(busy?' · 任务中:'+(d.task_job||''):'')
|
||||||
|
+(online?'':' · 离线或不在设备池');
|
||||||
|
return '<span class="cov-chip '+cls+'" title="'+esc(title)+'">'
|
||||||
|
+esc(s.replace(/:5555$/,''))+(busy?' ⏳':'')+(online?'':' ✕')+'</span>';
|
||||||
|
}).join('');
|
||||||
|
return '<div class="job-cov"><span class="kv">覆盖 '+serials.length+' 台 · 在线 '+onlineN
|
||||||
|
+(busyN?' · 运行中 '+busyN:'')+'</span>'
|
||||||
|
+'<div class="cov-chips">'+chips+'</div></div>';
|
||||||
|
}
|
||||||
|
|
||||||
function renderJobCards(){
|
function renderJobCards(){
|
||||||
const jobs=_jobsData||[];
|
const jobs=_jobsData||[];
|
||||||
const running={};
|
const running={};
|
||||||
@@ -470,12 +498,11 @@ function renderJobCards(){
|
|||||||
'<span class="kv">调度:'+esc(sched)+'</span>'+
|
'<span class="kv">调度:'+esc(sched)+'</span>'+
|
||||||
'<span class="kv">重试:'+esc(retry)+'</span></div>'+
|
'<span class="kv">重试:'+esc(retry)+'</span></div>'+
|
||||||
'<div style="margin-top:4px">'+fmtParams(j.params)+'</div>'+
|
'<div style="margin-top:4px">'+fmtParams(j.params)+'</div>'+
|
||||||
|
_jobCoverageHtml(j)+
|
||||||
'</div>'+
|
'</div>'+
|
||||||
'<div class="job-actions">'+
|
'<div class="job-actions">'+
|
||||||
'<button class="btn btn-primary btn-xs" onclick="apiAction(\'/api/jobs/'+j.id+'/run\')">立即执行</button>'+
|
'<button class="btn btn-primary btn-xs" onclick="apiAction(\'/api/jobs/'+j.id+'/run\')">执行任务</button>'+
|
||||||
'<button class="btn btn-xs" onclick="apiAction(\'/api/jobs/'+j.id+'/toggle\',{enabled:'+(!j.enabled)+'})">'+(j.enabled?'停用':'启用')+'</button>'+
|
'<button class="btn btn-xs" onclick="apiAction(\'/api/jobs/'+j.id+'/toggle\',{enabled:'+(!j.enabled)+'})">'+(j.enabled?'停用任务':'启用任务')+'</button>'+
|
||||||
'<button class="btn btn-xs" onclick="openTaskModal(\''+j.id+'\')">编辑</button>'+
|
|
||||||
'<button class="btn btn-danger btn-xs" onclick="deleteTask(\''+j.id+'\')">删除</button>'+
|
|
||||||
'</div>'+
|
'</div>'+
|
||||||
'</div>';
|
'</div>';
|
||||||
}).join(''):'<div class="empty">暂无任务,到"任务"Tab 新建</div>';
|
}).join(''):'<div class="empty">暂无任务,到"任务"Tab 新建</div>';
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ function openTaskModal(jobId){
|
|||||||
// 任务编辑器含步骤编辑,加宽弹窗;关闭时在 closeModal 移除
|
// 任务编辑器含步骤编辑,加宽弹窗;关闭时在 closeModal 移除
|
||||||
document.getElementById('modal-box').classList.add('wide-modal');
|
document.getElementById('modal-box').classList.add('wide-modal');
|
||||||
|
|
||||||
const j=job||{name:'',task_type:'douyin_nurture',enabled:true,target:{mode:'all'},params:{},schedule:{mode:'once'},retry:{max_attempts:1,delay:60}};
|
const j=job||{name:'',task_type:'generic_steps',enabled:true,target:{mode:'all'},params:{},schedule:{mode:'once'},retry:{max_attempts:1,delay:60}};
|
||||||
const tt=_taskTypes.map(t=>'<option value="'+t.task_type+'"'+(t.task_type===j.task_type?' selected':'')+'>'+esc(t.name)+'</option>').join('');
|
const tt=_taskTypes.map(t=>'<option value="'+t.task_type+'"'+(t.task_type===j.task_type?' selected':'')+'>'+esc(t.name)+'</option>').join('');
|
||||||
const gl=_groupsList.map(g=>'<option value="'+g.name+'"'+(g.name===j.target.group_name?' selected':'')+'>'+esc(g.name)+'</option>').join('');
|
const gl=_groupsList.map(g=>'<option value="'+g.name+'"'+(g.name===j.target.group_name?' selected':'')+'>'+esc(g.name)+'</option>').join('');
|
||||||
const dl=_devicesList.map(s=>'<option value="'+s+'"'+(s===j.target.serial?' selected':'')+'>'+esc(s)+'</option>').join('');
|
const dl=_devicesList.map(s=>'<option value="'+s+'"'+(s===j.target.serial?' selected':'')+'>'+esc(s)+'</option>').join('');
|
||||||
@@ -88,7 +88,7 @@ function openTaskModal(jobId){
|
|||||||
|
|
||||||
document.getElementById('modal-body').innerHTML=
|
document.getElementById('modal-body').innerHTML=
|
||||||
'<div class="form-row">'+
|
'<div class="form-row">'+
|
||||||
'<div class="form-group"><label>任务名</label><input type="text" class="form-control" id="f-name" value="'+esc(j.name)+'" placeholder="如:抖音养号-上午"></div>'+
|
'<div class="form-group"><label>任务名</label><input type="text" class="form-control" id="f-name" value="'+esc(j.name)+'" placeholder="如:刷视频-上午"></div>'+
|
||||||
'<div class="form-group"><label>任务类型</label><select class="form-control" id="f-task_type" onchange="onTaskTypeChange()">'+tt+'</select></div>'+
|
'<div class="form-group"><label>任务类型</label><select class="form-control" id="f-task_type" onchange="onTaskTypeChange()">'+tt+'</select></div>'+
|
||||||
'</div>'+
|
'</div>'+
|
||||||
'<div class="form-group form-check"><input type="checkbox" id="f-enabled" '+(j.enabled?'checked':'')+'><label for="f-enabled">启用此任务</label></div>'+
|
'<div class="form-group form-check"><input type="checkbox" id="f-enabled" '+(j.enabled?'checked':'')+'><label for="f-enabled">启用此任务</label></div>'+
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
from .base import BaseTask, register_task, list_task_types, get_task_class
|
from .base import BaseTask, register_task, list_task_types, get_task_class
|
||||||
|
|
||||||
# 导入所有任务包,触发 @register_task 注册
|
# 导入所有任务包,触发 @register_task 注册
|
||||||
from .douyin import task # noqa: F401
|
# 当前只有一个任务类型:generic_steps(通用步骤,前端步骤编辑器编排)
|
||||||
from .generic import task # noqa: F401
|
from .generic import task # noqa: F401
|
||||||
|
|
||||||
__all__ = ["BaseTask", "register_task", "list_task_types", "get_task_class"]
|
__all__ = ["BaseTask", "register_task", "list_task_types", "get_task_class"]
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@
|
|||||||
base.py — 本任务的操作基类 + 注册器
|
base.py — 本任务的操作基类 + 注册器
|
||||||
xxx.py — 具体操作
|
xxx.py — 具体操作
|
||||||
|
|
||||||
新增任务步骤(照着 tasks/douyin/ 抄即可):
|
新增任务步骤(照着 tasks/generic/ 抄即可):
|
||||||
|
|
||||||
1. 在 tasks/ 下新建 my_task/ 子包(文件夹 + __init__.py)
|
1. 在 tasks/ 下新建 my_task/ 子包(文件夹 + __init__.py)
|
||||||
2. 在 my_task/task.py 顶部写默认参数:
|
2. 在 my_task/task.py 顶部写默认参数:
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
"""抖音养号任务包。
|
|
||||||
|
|
||||||
本包自包含:任务定义(task.py)+ 抖音专属操作(actions/)。
|
|
||||||
抖音的点赞/评论 xpath 只适用于抖音,不放全局,避免和其他任务混淆。
|
|
||||||
|
|
||||||
加新抖音操作:在 actions/ 下建 .py,继承本包 BaseAction + @register_action,
|
|
||||||
在 actions/__init__.py import。
|
|
||||||
"""
|
|
||||||
from . import task # noqa: F401 触发 @register_task 注册抖音任务
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
"""抖音操作注册包。import 触发各操作注册。
|
|
||||||
|
|
||||||
加新抖音操作:新建 xxx.py,在下面加一行 from . import xxx。
|
|
||||||
注意:必须先从 base 导入 ACTIONS,再导入各操作模块,否则循环导入。
|
|
||||||
"""
|
|
||||||
# 先初始化注册表(base.py 里定义了 ACTIONS = create_action_registry())
|
|
||||||
from .base import (
|
|
||||||
BaseAction, register_action, create_action_registry,
|
|
||||||
list_actions, get_action, should_trigger,
|
|
||||||
ACTIONS,
|
|
||||||
list_action_types, get_action_class,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 再导入各操作模块,触发 @register_action(ACTIONS) 注册
|
|
||||||
from . import like # noqa: F401
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"BaseAction", "register_action", "create_action_registry",
|
|
||||||
"list_actions", "get_action", "should_trigger",
|
|
||||||
"ACTIONS", "list_action_types", "get_action_class",
|
|
||||||
]
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
"""抖音操作基类与注册机制。
|
|
||||||
|
|
||||||
操作 = 抖音养号主循环里某个时机执行的一次具体行为(点赞/评论/关注等)。
|
|
||||||
这里的注册表只对抖音任务生效,其他任务有自己的 actions 包。
|
|
||||||
|
|
||||||
设计:抖音的 ACTIONS 注册表是独立的 dict,用 core.actions 的
|
|
||||||
register_action(ACTIONS) 装饰器注册,和全局/其他 app 互不污染。
|
|
||||||
|
|
||||||
新增抖音操作步骤(照着 like.py 抄):
|
|
||||||
|
|
||||||
1. 在 tasks/douyin/actions/ 下新建 my_action.py
|
|
||||||
2. 写一个 BaseAction 子类,用 @register_action 装饰,实现 execute:
|
|
||||||
|
|
||||||
from core.actions import BaseAction, register_action, should_trigger
|
|
||||||
from . import ACTIONS # 本任务注册表
|
|
||||||
|
|
||||||
@register_action(ACTIONS)
|
|
||||||
class MyAction(BaseAction):
|
|
||||||
action_type = "my_action"
|
|
||||||
name = "我的操作"
|
|
||||||
description = "做什么"
|
|
||||||
default_params = {"rate": 0.5}
|
|
||||||
|
|
||||||
def execute(self, d, params, worker):
|
|
||||||
if not should_trigger(params["rate"]):
|
|
||||||
return False
|
|
||||||
# ... 用 d(xpath=...) / d(text=...) 操作
|
|
||||||
return True
|
|
||||||
|
|
||||||
3. 在 tasks/douyin/actions/__init__.py 加一行:from . import my_action
|
|
||||||
|
|
||||||
做完前端自动出现该操作的可勾选项。
|
|
||||||
"""
|
|
||||||
from core.actions import (
|
|
||||||
BaseAction, register_action, create_action_registry,
|
|
||||||
list_actions, get_action, should_trigger,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 抖音专属操作注册表(独立 dict,不污染其他 app)
|
|
||||||
ACTIONS = create_action_registry()
|
|
||||||
|
|
||||||
|
|
||||||
# 兼容旧导入:tasks/douyin/actions/__init__.py 原来从这里导出
|
|
||||||
def list_action_types():
|
|
||||||
"""返回所有已注册抖音操作的元信息(供前端展示)。"""
|
|
||||||
return list_actions(ACTIONS)
|
|
||||||
|
|
||||||
|
|
||||||
def get_action_class(action_type):
|
|
||||||
"""按 action_type 取抖音操作类。"""
|
|
||||||
return get_action(ACTIONS, action_type)
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
"""抖音点赞操作。
|
|
||||||
|
|
||||||
两种方式:
|
|
||||||
by_element — 用 xpath 找右侧红心按钮点击(相对定位,不依赖视频卡片序号)
|
|
||||||
double_click — 双击屏幕中央(需明确选择此方式,抖音"双击点赞"手势)
|
|
||||||
|
|
||||||
by_element 找不到点赞按钮时**跳过本次,不点屏幕**(避免直播/无按钮页误触)。
|
|
||||||
要适配新版本抖音:用 weditor 重新抓当前界面,调整下面定位逻辑。
|
|
||||||
"""
|
|
||||||
from core.actions import BaseAction, register_action, should_trigger
|
|
||||||
from core.logger import get_logger
|
|
||||||
from . import ACTIONS
|
|
||||||
|
|
||||||
_log = get_logger("action.like")
|
|
||||||
|
|
||||||
|
|
||||||
@register_action(ACTIONS)
|
|
||||||
class LikeAction(BaseAction):
|
|
||||||
action_type = "like"
|
|
||||||
name = "点赞"
|
|
||||||
description = "看完视频后随机点赞(找红心按钮,找不到回退双击)"
|
|
||||||
default_params = {
|
|
||||||
"rate": 0.3, # 触发概率 0~1
|
|
||||||
"method": "by_element", # by_element=找红心 | double_click=双击屏幕
|
|
||||||
}
|
|
||||||
|
|
||||||
def execute(self, d, params, worker):
|
|
||||||
rate = float(params.get("rate", 0.3))
|
|
||||||
if not should_trigger(rate):
|
|
||||||
return False
|
|
||||||
method = params.get("method", "by_element")
|
|
||||||
if method == "by_element":
|
|
||||||
if self._like_by_element(d):
|
|
||||||
worker.set_action("点赞(红心)")
|
|
||||||
return True
|
|
||||||
# 找不到点赞按钮:跳过本次,不点屏幕,避免误触(如直播/无点赞按钮的页面)
|
|
||||||
worker.set_action("点赞(未找到,跳过)")
|
|
||||||
return False
|
|
||||||
# 仅在明确选择 double_click 方式时才双击屏幕
|
|
||||||
ok = self._double_click_center(d)
|
|
||||||
if ok:
|
|
||||||
worker.set_action("点赞(双击)")
|
|
||||||
return ok
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _like_by_element(d):
|
|
||||||
"""找右侧操作栏红心按钮点击,成功返回 True。
|
|
||||||
|
|
||||||
定位优先级:description 精确 → descriptionContains 模糊 → resourceId → xpath
|
|
||||||
每种方式找到即点即返回,找不到继续下一种。全程记日志。
|
|
||||||
"""
|
|
||||||
# 方式1:description 精确匹配(最稳,不依赖结构)
|
|
||||||
for desc in ("点赞", "未点赞", "like"):
|
|
||||||
el = d(description=desc)
|
|
||||||
if el.exists:
|
|
||||||
el.click()
|
|
||||||
_log.info(f"like 命中 description={desc}")
|
|
||||||
return True
|
|
||||||
# 方式2:descriptionContains 模糊匹配(适配不同版本文案)
|
|
||||||
for kw in ("赞", "like"):
|
|
||||||
el = d(descriptionContains=kw)
|
|
||||||
if el.exists:
|
|
||||||
el.click()
|
|
||||||
_log.info(f"like 命中 descriptionContains={kw}")
|
|
||||||
return True
|
|
||||||
# 方式3:resourceId 列表(扩充候选,覆盖更多抖音版本)
|
|
||||||
for rid in ("com.ss.android.ugc.aweme:id/aky",
|
|
||||||
"com.ss.android.ugc.aweme:id/d-like-view-icon",
|
|
||||||
"com.ss.android.ugc.aweme:id/h3r",
|
|
||||||
"com.ss.android.ugc.aweme:id/dfz",
|
|
||||||
"com.ss.android.ugc.aweme:id/c5w"):
|
|
||||||
el = d(resourceId=rid)
|
|
||||||
if el.exists:
|
|
||||||
el.click()
|
|
||||||
_log.info(f"like 命中 resourceId={rid}")
|
|
||||||
return True
|
|
||||||
# 方式4:xpath 相对定位——右侧操作栏 LinearLayout 的第一个 ImageView
|
|
||||||
# 定位思路:ViewPager 下当前 FrameLayout 里的 LinearLayout,
|
|
||||||
# 取其直接子 FrameLayout 里的第一个 ImageView(点赞在操作栏最上方)
|
|
||||||
try:
|
|
||||||
el = d.xpath(
|
|
||||||
'//androidx.viewpager.widget.ViewPager'
|
|
||||||
'/android.widget.FrameLayout'
|
|
||||||
'/com.bytedance.highperformanceview.layout.MeasureOnceRelativeLayout2'
|
|
||||||
'/com.bytedance.highperformanceview.layout.MeasureOnceRelativeLayout2'
|
|
||||||
'/android.widget.LinearLayout/android.widget.FrameLayout[2]'
|
|
||||||
'/android.widget.LinearLayout/android.widget.FrameLayout[1]'
|
|
||||||
'/android.widget.FrameLayout[1]/android.widget.ImageView'
|
|
||||||
)
|
|
||||||
if el.exists:
|
|
||||||
el.click()
|
|
||||||
_log.info("like 命中 xpath")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
_log.warning(f"like xpath 异常: {e}")
|
|
||||||
_log.info("like_by_element 全部失败,准备回退双击")
|
|
||||||
return False
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _double_click_center(d):
|
|
||||||
"""双击屏幕中央点赞。按屏幕尺寸算坐标,适配不同分辨率。"""
|
|
||||||
try:
|
|
||||||
info = d.info
|
|
||||||
w, h = info["displayWidth"], info["displayHeight"]
|
|
||||||
d.double_click(int(w * 0.5), int(h * 0.5))
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
@@ -1,269 +0,0 @@
|
|||||||
"""抖音养号任务定义。
|
|
||||||
|
|
||||||
本文件自包含所有抖音养号参数(观看、滑动、操作等),不依赖 core 的业务配置。
|
|
||||||
抖音专属操作(点赞/评论)在 actions/ 子包里,xpath 只适用于抖音。
|
|
||||||
|
|
||||||
修改养号行为:改下面 DEFAULT_PARAMS 默认值,或通过前端任务参数覆盖。
|
|
||||||
修改养号逻辑:改 DouyinNurtureWorker.run_task。
|
|
||||||
加新抖音操作:在 actions/ 下建 .py,继承 BaseAction + @register_action。
|
|
||||||
"""
|
|
||||||
import time
|
|
||||||
import random
|
|
||||||
|
|
||||||
from tasks.base import BaseTask, register_task
|
|
||||||
from .actions import list_action_types, get_action_class
|
|
||||||
from core.device_worker import BaseWorker, _update_status
|
|
||||||
from core.u2_helper import ensure_app_running, wait_for_app_home
|
|
||||||
from core.logger import get_logger
|
|
||||||
|
|
||||||
_log = get_logger("task.douyin")
|
|
||||||
|
|
||||||
|
|
||||||
# ================== 抖音养号参数(本任务专属,写在这里) ==================
|
|
||||||
DOUYIN_PKG = "com.ss.android.ugc.aweme"
|
|
||||||
|
|
||||||
DEFAULT_PARAMS = {
|
|
||||||
# 观看
|
|
||||||
"watch_count": 80, # 观看视频数量(0=不限数量,仅按 max_duration 终止)
|
|
||||||
"watch_min": 5.0, # 单个视频最短观看秒数
|
|
||||||
"watch_max": 35.0, # 单个视频最长观看秒数
|
|
||||||
# 运行时长终止(通用,0=不限时,按 watch_count 终止)
|
|
||||||
# 两个条件哪个先到就停;都为 0 则永不停止(需手动停止)
|
|
||||||
"max_duration": 0, # 最大运行时长(秒),如 1800=30分钟,0=不限时
|
|
||||||
# 滑动
|
|
||||||
"swipe_min": 0.25, # 上滑手势最短时长(秒)
|
|
||||||
"swipe_max": 0.50, # 上滑手势最长时长(秒)
|
|
||||||
"gap_min": 1.0, # 视频间隔最短秒数
|
|
||||||
"gap_max": 3.0, # 视频间隔最长秒数
|
|
||||||
# 操作(每个操作有 enabled 开关 + 自身参数)
|
|
||||||
"actions": {
|
|
||||||
"like": {
|
|
||||||
"enabled": True,
|
|
||||||
"params": {"rate": 0.3, "method": "by_element"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class DouyinNurtureWorker(BaseWorker):
|
|
||||||
"""抖音养号 worker:看视频 + 按配置执行操作(点赞/评论等),被退出自动重连。
|
|
||||||
|
|
||||||
只需实现 run_task(d),STF 占用/释放、u2 连接、异常、状态上报由基类处理。
|
|
||||||
操作参数优先级:任务 params.actions > 本文件 DEFAULT_PARAMS.actions。
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, serial, params=None):
|
|
||||||
super().__init__(serial, params)
|
|
||||||
p = {**DEFAULT_PARAMS, **(self.params or {})}
|
|
||||||
self.watch_count = int(p["watch_count"])
|
|
||||||
self.watch_min = float(p["watch_min"])
|
|
||||||
self.watch_max = float(p["watch_max"])
|
|
||||||
# 通用运行时长终止(0=不限时)
|
|
||||||
self.max_duration = int(p.get("max_duration", 0))
|
|
||||||
self.swipe_min = float(p["swipe_min"])
|
|
||||||
self.swipe_max = float(p["swipe_max"])
|
|
||||||
self.gap_min = float(p["gap_min"])
|
|
||||||
self.gap_max = float(p["gap_max"])
|
|
||||||
# 操作配置:{action_type: {"enabled":bool, "params":{...}}}
|
|
||||||
self.actions_cfg = p.get("actions", {})
|
|
||||||
# 实例化所有启用的操作
|
|
||||||
self._actions = []
|
|
||||||
for atype, cfg in self.actions_cfg.items():
|
|
||||||
if not cfg.get("enabled"):
|
|
||||||
continue
|
|
||||||
cls = get_action_class(atype)
|
|
||||||
if cls:
|
|
||||||
self._actions.append(cls())
|
|
||||||
# 启动日志:打印终止条件 + 启用的 action,便于排查
|
|
||||||
stop_conds = []
|
|
||||||
if self.watch_count > 0:
|
|
||||||
stop_conds.append(f"数量={self.watch_count}")
|
|
||||||
if self.max_duration > 0:
|
|
||||||
stop_conds.append(f"时长={self.max_duration}s")
|
|
||||||
if not stop_conds:
|
|
||||||
stop_conds.append("无(需手动停止)")
|
|
||||||
_log.info(f"[{serial}] 终止条件: {', '.join(stop_conds)}")
|
|
||||||
if self._actions:
|
|
||||||
summary = ", ".join(
|
|
||||||
f"{a.action_type}(rate={self.actions_cfg.get(a.action_type, {}).get('params', {}).get('rate', '?')})"
|
|
||||||
for a in self._actions
|
|
||||||
)
|
|
||||||
_log.info(f"[{serial}] 启用操作: {summary}")
|
|
||||||
else:
|
|
||||||
_log.warning(f"[{serial}] 未启用任何操作,整个任务期间不会执行点赞")
|
|
||||||
|
|
||||||
def run_task(self, d):
|
|
||||||
"""抖音养号主逻辑。d 是 u2.Device,已由基类连好。
|
|
||||||
|
|
||||||
终止条件(哪个先到就停):
|
|
||||||
- watch_count > 0:看完指定数量视频
|
|
||||||
- max_duration > 0:达到最大运行时长
|
|
||||||
- 两者都为 0:永不停止,需手动停止
|
|
||||||
"""
|
|
||||||
def is_home(d):
|
|
||||||
return (d(descriptionContains="拍摄").exists
|
|
||||||
or d(descriptionContains="首页").exists)
|
|
||||||
|
|
||||||
# 启动运行时长计时(基类通用方法)
|
|
||||||
self._start_timer()
|
|
||||||
|
|
||||||
d.app_start(DOUYIN_PKG, wait=True)
|
|
||||||
if not wait_for_app_home(d, DOUYIN_PKG, is_home, timeout=40):
|
|
||||||
self.set_action("首页加载超时,继续尝试")
|
|
||||||
|
|
||||||
watched = 0
|
|
||||||
action_counts = {a.action_type: 0 for a in self._actions}
|
|
||||||
# 初始化通用进度上报
|
|
||||||
# total=0 时前端不显示百分比,只显示已看数量 + 运行时长
|
|
||||||
total = self.watch_count if self.watch_count > 0 else 0
|
|
||||||
self.set_progress(done=0, total=total, unit="视频",
|
|
||||||
action_counts=action_counts)
|
|
||||||
while not self.stopped():
|
|
||||||
# 终止条件检查
|
|
||||||
if self.watch_count > 0 and watched >= self.watch_count:
|
|
||||||
break
|
|
||||||
if self.is_time_up():
|
|
||||||
_log.info(f"[{self.serial}] 达到最大运行时长 {self.max_duration}s,停止")
|
|
||||||
break
|
|
||||||
|
|
||||||
if not ensure_app_running(d, DOUYIN_PKG):
|
|
||||||
_update_status(self.serial, status="error",
|
|
||||||
last_error="抖音连续重启失败,放弃该设备")
|
|
||||||
return
|
|
||||||
_update_status(self.serial, douyin_running=True, last_error="")
|
|
||||||
|
|
||||||
watch = random.uniform(self.watch_min, self.watch_max)
|
|
||||||
# 动作描述包含运行时长,便于前端观察
|
|
||||||
elapsed = self.elapsed()
|
|
||||||
time_info = f",已运行 {elapsed//60}m{elapsed%60}s" if elapsed > 0 else ""
|
|
||||||
self.set_action(f"观看视频 {watched+1},{watch:.0f}s{time_info}")
|
|
||||||
time.sleep(watch)
|
|
||||||
|
|
||||||
# 检测当前是否为可操作的正常视频页(有右侧操作栏 = 点赞/评论按钮)
|
|
||||||
# 营销广告、直播、异常页通常没有这些元素,直接滑过去不做操作
|
|
||||||
is_normal = self._is_normal_video(d)
|
|
||||||
if is_normal:
|
|
||||||
# 正常视频:执行所有启用的操作(看完视频后、滑动前)
|
|
||||||
if not self._actions:
|
|
||||||
_log.info(f"[{self.serial}] 视频{watched+1}: 正常视频但无启用操作")
|
|
||||||
for action in self._actions:
|
|
||||||
if self.stopped():
|
|
||||||
break
|
|
||||||
cfg = self.actions_cfg.get(action.action_type, {})
|
|
||||||
params = {**action.default_params, **cfg.get("params", {})}
|
|
||||||
rate = params.get("rate", "?")
|
|
||||||
try:
|
|
||||||
ok = action.execute(d, params, self)
|
|
||||||
_log.info(f"[{self.serial}] 视频{watched+1}: {action.action_type} "
|
|
||||||
f"rate={rate} execute={ok}")
|
|
||||||
if ok:
|
|
||||||
action_counts[action.action_type] += 1
|
|
||||||
time.sleep(random.uniform(0.5, 1.5))
|
|
||||||
except Exception as e:
|
|
||||||
_log.error(f"[{self.serial}] 视频{watched+1}: 操作 {action.action_type} "
|
|
||||||
f"异常: {e}")
|
|
||||||
else:
|
|
||||||
self.set_action(f"跳过广告/直播 {watched+1}")
|
|
||||||
_log.info(f"[{self.serial}] 视频{watched+1}: 非正常视频页,跳过操作")
|
|
||||||
|
|
||||||
if self.stopped():
|
|
||||||
break
|
|
||||||
d.swipe(500, 1000, 500, 300, random.uniform(self.swipe_min, self.swipe_max))
|
|
||||||
time.sleep(random.uniform(self.gap_min, self.gap_max))
|
|
||||||
watched += 1
|
|
||||||
# 上报通用进度(含运行时长,前端展示)
|
|
||||||
elapsed = self.elapsed()
|
|
||||||
self.set_progress(done=watched, total=total, unit="视频",
|
|
||||||
action_counts=dict(action_counts),
|
|
||||||
elapsed=elapsed)
|
|
||||||
|
|
||||||
# 终止原因汇总
|
|
||||||
stop_reason = ""
|
|
||||||
if self.stopped():
|
|
||||||
stop_reason = "(手动停止)"
|
|
||||||
elif self.is_time_up():
|
|
||||||
stop_reason = f"(达到时长 {self.max_duration}s)"
|
|
||||||
elif self.watch_count > 0 and watched >= self.watch_count:
|
|
||||||
stop_reason = f"(达到数量 {self.watch_count})"
|
|
||||||
summary = f"完成 {watched} 个视频" + "".join(
|
|
||||||
f",{k} {v}次" for k, v in action_counts.items() if v
|
|
||||||
) + stop_reason
|
|
||||||
_update_status(self.serial, douyin_running=False, current_action=summary)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _is_normal_video(d):
|
|
||||||
"""判断当前是否为可操作的正常视频页。
|
|
||||||
|
|
||||||
正常视频页右侧有操作栏(点赞/评论/分享按钮)。
|
|
||||||
营销广告、直播、异常页通常没有这些元素 → 返回 False,直接滑过去。
|
|
||||||
检测任一特征元素存在即认为是正常视频页。
|
|
||||||
|
|
||||||
定位优先级:description 精确 → descriptionContains 模糊 → resourceId 列表 → 兜底
|
|
||||||
每次返回 False 时记日志,便于定位"为什么操作没执行"。
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# 1. description 精确匹配(最稳)
|
|
||||||
for desc in ("点赞", "未点赞", "评论", "未评论"):
|
|
||||||
if d(description=desc).exists:
|
|
||||||
return True
|
|
||||||
# 2. descriptionContains 模糊匹配(适配不同版本文案)
|
|
||||||
for kw in ("赞", "评论"):
|
|
||||||
if d(descriptionContains=kw).exists:
|
|
||||||
return True
|
|
||||||
# 3. resourceId 列表(扩充候选,覆盖更多抖音版本)
|
|
||||||
for rid in ("com.ss.android.ugc.aweme:id/aky",
|
|
||||||
"com.ss.android.ugc.aweme:id/akq",
|
|
||||||
"com.ss.android.ugc.aweme:id/d-like-view-icon",
|
|
||||||
"com.ss.android.ugc.aweme:id/d-comment-view-icon",
|
|
||||||
"com.ss.android.ugc.aweme:id/h3r"):
|
|
||||||
if d(resourceId=rid).exists:
|
|
||||||
return True
|
|
||||||
# 所有特征元素都没找到 → 非正常视频页(广告/直播/异常)
|
|
||||||
_log.info(f"_is_normal_video=False: 未找到点赞/评论特征元素")
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
_log.warning(f"_is_normal_video 异常: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
@register_task
|
|
||||||
class DouyinNurtureTask(BaseTask):
|
|
||||||
"""抖音养号:看视频 + 按配置执行操作(点赞/评论等),被退出自动重连。"""
|
|
||||||
task_type = "douyin_nurture"
|
|
||||||
name = "抖音养号"
|
|
||||||
description = "自动观看抖音视频,按配置执行点赞/评论等操作,被退出自动重连"
|
|
||||||
default_params = dict(DEFAULT_PARAMS)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def list_action_types(cls):
|
|
||||||
"""返回本任务支持的抖音操作列表。"""
|
|
||||||
return list_action_types()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_action_class(cls, action_type):
|
|
||||||
"""按 action_type 取抖音操作类。"""
|
|
||||||
return get_action_class(action_type)
|
|
||||||
|
|
||||||
def create_worker(self, serial, params):
|
|
||||||
merged = {**DEFAULT_PARAMS, **(params or {})}
|
|
||||||
# actions 字段深合并(参数级,保留前端没传的操作默认值)
|
|
||||||
# 确保即使前端只传 actions.like.enabled,like.params.rate 也能拿到默认 0.3
|
|
||||||
default_actions = DEFAULT_PARAMS["actions"]
|
|
||||||
merged_actions = params.get("actions", {}) if params else {}
|
|
||||||
for atype, dflt in default_actions.items():
|
|
||||||
if atype not in merged_actions:
|
|
||||||
# 前端完全没传这个操作 → 用默认
|
|
||||||
merged_actions[atype] = dflt
|
|
||||||
else:
|
|
||||||
# 前端传了 → 参数级深合并
|
|
||||||
cfg = merged_actions[atype]
|
|
||||||
merged_cfg = {}
|
|
||||||
for k in ("enabled", "params"):
|
|
||||||
merged_cfg[k] = cfg.get(k, dflt.get(k))
|
|
||||||
# params 再深合并一层
|
|
||||||
merged_params = dict(dflt.get("params", {}))
|
|
||||||
merged_params.update(cfg.get("params", {}))
|
|
||||||
merged_cfg["params"] = merged_params
|
|
||||||
merged_actions[atype] = merged_cfg
|
|
||||||
merged["actions"] = merged_actions
|
|
||||||
return DouyinNurtureWorker(serial, params=merged)
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"""通用步骤任务包。
|
"""通用步骤任务包。
|
||||||
|
|
||||||
通过前端步骤编辑器编排任务流程,worker 按 steps 顺序执行。
|
通过前端步骤编辑器编排任务流程,worker 按 steps 顺序执行。
|
||||||
与 douyin_nurture 共存,互不影响。
|
当前平台唯一的任务类型(task_type=generic_steps)。
|
||||||
"""
|
"""
|
||||||
from . import task # noqa: F401 触发 @register_task 注册通用步骤任务
|
from . import task # noqa: F401 触发 @register_task 注册通用步骤任务
|
||||||
|
|||||||
+7
-16
@@ -104,21 +104,8 @@ STEP_TYPES = [
|
|||||||
|
|
||||||
DEFAULT_PARAMS = {
|
DEFAULT_PARAMS = {
|
||||||
"max_duration": 0, # 最大运行时长(秒),0=不限时
|
"max_duration": 0, # 最大运行时长(秒),0=不限时
|
||||||
"steps": [ # 默认示例:打开抖音→循环(看视频→滑动)
|
# 注意:**没有默认 steps**。步骤由前端步骤编辑器编排产出,新建任务时从零开始拖;
|
||||||
{
|
# 缺 steps 的任务执行时会明确报错(见 run_task),不会静默空跑。
|
||||||
"id": "step_1", "type": "open_app", "label": "打开抖音",
|
|
||||||
"params": {"package": "com.ss.android.ugc.aweme", "wait_home": True, "home_feature": "拍摄"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "step_2", "type": "loop", "label": "循环看视频",
|
|
||||||
"params": {"max_iterations": 20, "children": [
|
|
||||||
{"id": "step_2_1", "type": "wait", "label": "观看视频",
|
|
||||||
"params": {"min": 5.0, "max": 35.0}},
|
|
||||||
{"id": "step_2_2", "type": "swipe", "label": "上滑下一个",
|
|
||||||
"params": {"direction": "up", "duration_min": 0.25, "duration_max": 0.50}},
|
|
||||||
]},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -157,6 +144,10 @@ class GenericStepsWorker(BaseWorker):
|
|||||||
|
|
||||||
def run_task(self, d):
|
def run_task(self, d):
|
||||||
"""按 steps 顺序执行。loop 步骤递归执行其 children。"""
|
"""按 steps 顺序执行。loop 步骤递归执行其 children。"""
|
||||||
|
if not self.steps:
|
||||||
|
# 任务没配步骤(DEFAULT_PARAMS 不含默认步骤,步骤只能由编辑器产出):
|
||||||
|
# 明确报错而不是"跑完 0 步"当成功——设备表/任务页会显示该错误
|
||||||
|
raise ValueError("通用步骤任务没有可执行步骤:请在「任务」页编辑该任务并添加步骤")
|
||||||
self._start_timer()
|
self._start_timer()
|
||||||
self.set_action("开始执行步骤链")
|
self.set_action("开始执行步骤链")
|
||||||
self.set_progress(done=0, total=0, unit="操作",
|
self.set_progress(done=0, total=0, unit="操作",
|
||||||
@@ -167,7 +158,7 @@ class GenericStepsWorker(BaseWorker):
|
|||||||
finally:
|
finally:
|
||||||
elapsed = self.elapsed()
|
elapsed = self.elapsed()
|
||||||
summary = f"完成步骤链,运行 {elapsed//60}m{elapsed%60}s"
|
summary = f"完成步骤链,运行 {elapsed//60}m{elapsed%60}s"
|
||||||
_update_status(self.serial, douyin_running=False, current_action=summary)
|
_update_status(self.serial, current_action=summary)
|
||||||
|
|
||||||
def _exec_steps(self, d, steps, depth=0):
|
def _exec_steps(self, d, steps, depth=0):
|
||||||
"""执行步骤列表。depth 防止无限嵌套。"""
|
"""执行步骤列表。depth 防止无限嵌套。"""
|
||||||
|
|||||||
@@ -169,6 +169,14 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
|
|||||||
.job-card .job-body{font-size:12px;color:var(--text-muted);margin-bottom:11px;line-height:1.65}
|
.job-card .job-body{font-size:12px;color:var(--text-muted);margin-bottom:11px;line-height:1.65}
|
||||||
.job-card .job-body .kv{display:inline-block;background:#faf9f5;border:1px solid var(--border);border-radius:6px;padding:1px 7px;margin:2px 3px 0 0;font-family:var(--mono);font-size:11px}
|
.job-card .job-body .kv{display:inline-block;background:#faf9f5;border:1px solid var(--border);border-radius:6px;padding:1px 7px;margin:2px 3px 0 0;font-family:var(--mono);font-size:11px}
|
||||||
.job-card .job-actions{display:flex;gap:6px;flex-wrap:wrap}
|
.job-card .job-actions{display:flex;gap:6px;flex-wrap:wrap}
|
||||||
|
/* 任务覆盖的设备(监控页任务概况) */
|
||||||
|
.job-cov{margin-top:6px}
|
||||||
|
.cov-chips{display:flex;flex-wrap:wrap;gap:4px;margin-top:4px}
|
||||||
|
.cov-chip{font-family:var(--mono);font-size:11px;padding:1px 7px;border-radius:9px;border:1px solid;white-space:nowrap;cursor:default}
|
||||||
|
.cov-chip.online{color:#0e7490;background:#e0f2fe;border-color:#bae6fd}
|
||||||
|
.cov-chip.running{color:#b45309;background:#fef3c7;border-color:#fde68a}
|
||||||
|
.cov-chip.offline{color:#9ca3af;background:#f3f4f6;border-color:#e5e7eb;text-decoration:line-through}
|
||||||
|
.cov-note{font-size:11px;color:#94a3b8;margin-left:4px}
|
||||||
|
|
||||||
/* 空状态 / 辅助文本 */
|
/* 空状态 / 辅助文本 */
|
||||||
.empty{text-align:center;padding:42px;color:var(--text-light);font-size:13px}
|
.empty{text-align:center;padding:42px;color:var(--text-light);font-size:13px}
|
||||||
@@ -338,6 +346,32 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
|
|||||||
.agent-msg.user{align-self:flex-end;background:#0e7490;color:#fff;border-bottom-right-radius:3px}
|
.agent-msg.user{align-self:flex-end;background:#0e7490;color:#fff;border-bottom-right-radius:3px}
|
||||||
.agent-msg.assistant{align-self:flex-start;background:#fff;border:1px solid #e5e7eb;border-bottom-left-radius:3px;color:#1f2937;box-shadow:0 1px 2px rgba(0,0,0,.04)}
|
.agent-msg.assistant{align-self:flex-start;background:#fff;border:1px solid #e5e7eb;border-bottom-left-radius:3px;color:#1f2937;box-shadow:0 1px 2px rgba(0,0,0,.04)}
|
||||||
.agent-msg.assistant .reasoning{color:#6b7280;font-size:12.5px;border-left:3px solid #d1d5db;padding-left:10px;margin:8px 0}
|
.agent-msg.assistant .reasoning{color:#6b7280;font-size:12.5px;border-left:3px solid #d1d5db;padding-left:10px;margin:8px 0}
|
||||||
|
/* 推理链(可折叠) */
|
||||||
|
.agent-msg.assistant details.reasoning{margin:6px 0 10px;background:#f9fafb;border:1px solid #eef0f3;border-radius:9px;padding:6px 10px}
|
||||||
|
.agent-msg.assistant details.reasoning>summary{cursor:pointer;font-size:12px;color:#6b7280;font-weight:600;user-select:none;outline:none}
|
||||||
|
.agent-msg.assistant details.reasoning>summary:hover{color:#0e7490}
|
||||||
|
.agent-msg.assistant .reasoning-text{margin-top:6px;color:#6b7280;font-size:12.5px;line-height:1.7;white-space:pre-wrap;word-break:break-word;max-height:320px;overflow-y:auto}
|
||||||
|
/* token 用量脚注 */
|
||||||
|
.agent-usage{margin-top:8px;padding-top:7px;border-top:1px dashed #e5e7eb;font-size:11px;color:#9ca3af;font-family:var(--mono)}
|
||||||
|
.agent-token-total{margin-left:8px;font-size:11px;color:#6b7280;font-family:var(--mono);background:#f3f4f6;padding:2px 8px;border-radius:10px}
|
||||||
|
/* 正文 Markdown(.agent-msg 默认 pre-wrap,正文改由标签排版) */
|
||||||
|
.agent-msg.assistant .agent-text{white-space:normal}
|
||||||
|
.agent-text p{margin:0 0 8px}
|
||||||
|
.agent-text p:last-child{margin-bottom:0}
|
||||||
|
.agent-text .md-h{margin:12px 0 6px;font-weight:700;line-height:1.4}
|
||||||
|
.agent-text h1.md-h{font-size:17px}.agent-text h2.md-h{font-size:16px}.agent-text h3.md-h{font-size:15px}
|
||||||
|
.agent-text h4.md-h,.agent-text h5.md-h,.agent-text h6.md-h{font-size:13.5px}
|
||||||
|
.agent-text .md-list{margin:0 0 8px;padding-left:22px}
|
||||||
|
.agent-text .md-list li{margin:2px 0}
|
||||||
|
.agent-text .md-code{background:#0f172a;color:#e2e8f0;border-radius:8px;padding:9px 12px;overflow-x:auto;font-size:12px;font-family:var(--mono);margin:8px 0;white-space:pre}
|
||||||
|
.agent-text code{background:#f1f5f9;border:1px solid #e2e8f0;border-radius:4px;padding:0 4px;font-size:12px;font-family:var(--mono);color:#0f766e}
|
||||||
|
.agent-text .md-code code{background:none;border:none;color:inherit;padding:0}
|
||||||
|
.agent-text .md-quote{margin:8px 0;padding:2px 0 2px 11px;border-left:3px solid #d1d5db;color:#4b5563}
|
||||||
|
.agent-text .md-table{border-collapse:collapse;margin:8px 0;font-size:12.5px;display:block;overflow-x:auto;max-width:100%}
|
||||||
|
.agent-text .md-table th,.agent-text .md-table td{border:1px solid #e5e7eb;padding:5px 10px;text-align:left;white-space:nowrap}
|
||||||
|
.agent-text .md-table th{background:#f3f4f6;font-weight:600}
|
||||||
|
.agent-text hr{border:none;border-top:1px solid #e5e7eb;margin:12px 0}
|
||||||
|
.agent-text a{color:#0e7490;text-decoration:underline;word-break:break-all}
|
||||||
.agent-toolcards{display:flex;flex-direction:column;gap:5px;margin-bottom:9px}
|
.agent-toolcards{display:flex;flex-direction:column;gap:5px;margin-bottom:9px}
|
||||||
.agent-toolcard{display:flex;align-items:flex-start;gap:8px;background:#f3f4f6;border:1px solid #e5e7eb;border-radius:9px;padding:6px 11px;font-size:12px;font-family:var(--mono);color:#374151;flex-wrap:wrap}
|
.agent-toolcard{display:flex;align-items:flex-start;gap:8px;background:#f3f4f6;border:1px solid #e5e7eb;border-radius:9px;padding:6px 11px;font-size:12px;font-family:var(--mono);color:#374151;flex-wrap:wrap}
|
||||||
.agent-toolcard .dot{width:7px;height:7px;border-radius:50%;background:#10b981;flex:none;margin-top:5px}
|
.agent-toolcard .dot{width:7px;height:7px;border-radius:50%;background:#10b981;flex:none;margin-top:5px}
|
||||||
@@ -476,7 +510,7 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
|
|||||||
<div id="tab-agent" class="tab-panel">
|
<div id="tab-agent" class="tab-panel">
|
||||||
<div class="agent-shell">
|
<div class="agent-shell">
|
||||||
<div class="agent-topbar">
|
<div class="agent-topbar">
|
||||||
<div class="agent-title">🤖 AI 控制台<span id="agent-model-tag" class="agent-model-tag"></span></div>
|
<div class="agent-title">🤖 AI 控制台<span id="agent-model-tag" class="agent-model-tag"></span><span id="agent-token-total" class="agent-token-total" title=""></span></div>
|
||||||
<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>
|
||||||
@@ -885,9 +919,10 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
|
|||||||
<!-- 子分栏 1:数据备份 -->
|
<!-- 子分栏 1:数据备份 -->
|
||||||
<div class="sub-panel active" id="system-sub-backup">
|
<div class="sub-panel active" id="system-sub-backup">
|
||||||
<div class="help">
|
<div class="help">
|
||||||
<b>导出</b>当前系统数据为 zip:含 <code>users.db</code>(用户 / 任务计划 / 设备分组 / 自定义动作 /
|
<b>导出</b>当前系统数据为 zip:含 <code>users.db</code> 在线一致快照(<b>全部业务表</b>:用户 / 任务计划 / 设备分组 /
|
||||||
设备池 / AI 会话 / 经验库等全部表,SQLite 在线一致快照)+ 可选的 <code>apks/</code> 应用文件。
|
自定义动作 / APK 记录 / 设备池 / 待连接设备 / AI 会话 / 经验库 / 经验巡检 / <b>动作库</b> / 系统配置)+ 可选的
|
||||||
恢复用:换机 / 整库迁移 / 出问题前留底。
|
<code>apks/</code> 应用文件。恢复用:换机 / 整库迁移 / 出问题前留底。
|
||||||
|
<br><span class="text-muted">清单见预览的「表数据行数」;新增持久化表须登记进备份覆盖清单(红线,见 doc/DEPLOY.md §5.2)。</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar" style="gap:8px">
|
<div class="toolbar" style="gap:8px">
|
||||||
<label style="display:inline-flex;align-items:center;gap:4px;margin:0 6px">
|
<label style="display:inline-flex;align-items:center;gap:4px;margin:0 6px">
|
||||||
@@ -972,6 +1007,7 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
|
|||||||
|
|
||||||
<!-- 前端模块(按依赖顺序加载:base → list → 页面模块 → admin(含初始化)) -->
|
<!-- 前端模块(按依赖顺序加载:base → list → 页面模块 → admin(含初始化)) -->
|
||||||
<script src="/static/admin/base.js"></script>
|
<script src="/static/admin/base.js"></script>
|
||||||
|
<script src="/static/admin/markdown.js"></script>
|
||||||
<script src="/static/admin/list.js"></script>
|
<script src="/static/admin/list.js"></script>
|
||||||
<script src="/static/admin/monitor.js"></script>
|
<script src="/static/admin/monitor.js"></script>
|
||||||
<script src="/static/admin/editor.js"></script>
|
<script src="/static/admin/editor.js"></script>
|
||||||
|
|||||||
+42
-7
@@ -5,7 +5,9 @@
|
|||||||
GET /api/agent/stream?run_id= → SSE 事件流(EventSource 订阅):
|
GET /api/agent/stream?run_id= → SSE 事件流(EventSource 订阅):
|
||||||
event: delta {text, kind: content|reasoning} 流式文本增量
|
event: delta {text, kind: content|reasoning} 流式文本增量
|
||||||
event: step {tool, args, image?} 工具调用完成(MCP 步骤)
|
event: step {tool, args, image?} 工具调用完成(MCP 步骤)
|
||||||
event: done {answer} 完成
|
event: usage {prompt_tokens, completion_tokens,
|
||||||
|
total_tokens, calls} 本轮累计 token 用量
|
||||||
|
event: done {answer, usage} 完成
|
||||||
event: error {message} 失败
|
event: error {message} 失败
|
||||||
GET/POST /api/agent/config → 配置读写(key 打码回显)
|
GET/POST /api/agent/config → 配置读写(key 打码回显)
|
||||||
|
|
||||||
@@ -38,9 +40,13 @@ _CFG_KEYS = {"api_base": "agent_api_base",
|
|||||||
"default_serial": "agent_default_serial",
|
"default_serial": "agent_default_serial",
|
||||||
"max_steps": "agent_max_steps"}
|
"max_steps": "agent_max_steps"}
|
||||||
|
|
||||||
|
# 推理链落库上限(字符):只留够回看的量,避免会话消息无限膨胀
|
||||||
|
_REASONING_KEEP = 6000
|
||||||
|
|
||||||
|
|
||||||
# ---------- 运行状态(单实例 + 事件队列) ----------
|
# ---------- 运行状态(单实例 + 事件队列) ----------
|
||||||
_run = {"id": None, "state": "idle", "prompt": "", "serial": "",
|
_run = {"id": None, "state": "idle", "prompt": "", "serial": "",
|
||||||
"answer": "", "error": "",
|
"answer": "", "error": "", "usage": {},
|
||||||
"history": []} # 多轮对话历史 [{role: user|assistant, content}]
|
"history": []} # 多轮对话历史 [{role: user|assistant, content}]
|
||||||
_queues = {} # run_id -> queue.Queue(SSE 消费者读取)
|
_queues = {} # run_id -> queue.Queue(SSE 消费者读取)
|
||||||
_stop_events = {} # run_id -> threading.Event(用户中断)
|
_stop_events = {} # run_id -> threading.Event(用户中断)
|
||||||
@@ -1192,7 +1198,7 @@ def agent_run():
|
|||||||
_run.update(id=run_id, state="running", prompt=prompt,
|
_run.update(id=run_id, state="running", prompt=prompt,
|
||||||
serial=serial, conv_id=conv_id,
|
serial=serial, conv_id=conv_id,
|
||||||
started=_dt.now().strftime("%H:%M:%S"),
|
started=_dt.now().strftime("%H:%M:%S"),
|
||||||
answer="", error="")
|
answer="", error="", usage={})
|
||||||
# history 保留(多轮上下文),由会话/「新建会话」管理
|
# history 保留(多轮上下文),由会话/「新建会话」管理
|
||||||
_queues[run_id] = queue.Queue()
|
_queues[run_id] = queue.Queue()
|
||||||
_stop_events[run_id] = threading.Event()
|
_stop_events[run_id] = threading.Event()
|
||||||
@@ -1244,6 +1250,7 @@ def agent_run_status():
|
|||||||
"started": _run.get("started", ""),
|
"started": _run.get("started", ""),
|
||||||
"answer": (_run.get("answer") or "")[:4000],
|
"answer": (_run.get("answer") or "")[:4000],
|
||||||
"error": (_run.get("error") or "")[:400],
|
"error": (_run.get("error") or "")[:400],
|
||||||
|
"usage": _run.get("usage") or {},
|
||||||
"history": hist[-16:]})
|
"history": hist[-16:]})
|
||||||
|
|
||||||
|
|
||||||
@@ -1345,9 +1352,22 @@ def _agent_thread(run_id, prompt, serial, cfg):
|
|||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
from mcp_agent.agent import Agent
|
from mcp_agent.agent import Agent
|
||||||
|
|
||||||
|
# 推理链(reasoning_content)累计——单纯流式展示会随页面刷新丢失,
|
||||||
|
# 落库后可在会话里折叠回看(只存文本,不回灌给模型)
|
||||||
|
reason_parts = []
|
||||||
|
reason_len = 0
|
||||||
|
|
||||||
def on_delta(text, kind):
|
def on_delta(text, kind):
|
||||||
|
nonlocal reason_len
|
||||||
|
if kind == "reasoning" and reason_len < _REASONING_KEEP:
|
||||||
|
reason_parts.append(text)
|
||||||
|
reason_len += len(text)
|
||||||
q.put(("delta", {"text": text, "kind": kind}))
|
q.put(("delta", {"text": text, "kind": kind}))
|
||||||
|
|
||||||
|
def on_usage(usage):
|
||||||
|
"""每次模型调用完成 → 推累计用量(前端实时刷新 token 计数)。"""
|
||||||
|
q.put(("usage", dict(usage)))
|
||||||
|
|
||||||
tool_seq = [] # 本轮工具序列(任务级配方提炼用)
|
tool_seq = [] # 本轮工具序列(任务级配方提炼用)
|
||||||
tool_trace = [] # 结构化轨迹:{tool, args, result}(动作经验提炼用,判成败)
|
tool_trace = [] # 结构化轨迹:{tool, args, result}(动作经验提炼用,判成败)
|
||||||
|
|
||||||
@@ -1451,7 +1471,8 @@ 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=recall_ctx)
|
extra_context=recall_ctx,
|
||||||
|
on_usage=on_usage)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if _mcp_unreachable(e):
|
if _mcp_unreachable(e):
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
@@ -1460,13 +1481,22 @@ def _agent_thread(run_id, prompt, serial, cfg):
|
|||||||
|
|
||||||
# 整体超时保护:卡死时结束,释放单实例
|
# 整体超时保护:卡死时结束,释放单实例
|
||||||
answer = asyncio.run(asyncio.wait_for(_execute(), timeout=900))
|
answer = asyncio.run(asyncio.wait_for(_execute(), timeout=900))
|
||||||
|
usage = dict(getattr(agent, "usage", None) or {})
|
||||||
with _lock:
|
with _lock:
|
||||||
_run["state"] = "done"
|
_run["state"] = "done"
|
||||||
_run["answer"] = answer
|
_run["answer"] = answer
|
||||||
# 追加本轮进历史(多轮连续性;上限 12 轮防 token 膨胀)
|
_run["usage"] = usage
|
||||||
|
# 追加本轮进历史(多轮连续性;上限 12 轮防 token 膨胀)。
|
||||||
|
# usage/reasoning 仅用于前端展示与落库,不进模型上下文(读回时只取 role/content)
|
||||||
hist = _run.setdefault("history", [])
|
hist = _run.setdefault("history", [])
|
||||||
hist.append({"role": "user", "content": prompt[:2000]})
|
hist.append({"role": "user", "content": prompt[:2000]})
|
||||||
hist.append({"role": "assistant", "content": (answer or "")[:4000]})
|
turn = {"role": "assistant", "content": (answer or "")[:4000]}
|
||||||
|
if usage:
|
||||||
|
turn["usage"] = usage
|
||||||
|
reason = "".join(reason_parts).strip()
|
||||||
|
if reason:
|
||||||
|
turn["reasoning"] = reason[:_REASONING_KEEP]
|
||||||
|
hist.append(turn)
|
||||||
_run["history"] = hist[-24:]
|
_run["history"] = hist[-24:]
|
||||||
# 会话落库:本轮追加写回(新会话自动用首条消息作标题)。
|
# 会话落库:本轮追加写回(新会话自动用首条消息作标题)。
|
||||||
# 后台线程 db 访问需 app context。
|
# 后台线程 db 访问需 app context。
|
||||||
@@ -1513,7 +1543,7 @@ def _agent_thread(run_id, prompt, serial, cfg):
|
|||||||
"image": None}))
|
"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, "usage": usage}))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_log.warning(f"Agent 运行异常: {e}")
|
_log.warning(f"Agent 运行异常: {e}")
|
||||||
# 诊断:打印消息结构(tool_calls 与 tool 消息配对检查)
|
# 诊断:打印消息结构(tool_calls 与 tool 消息配对检查)
|
||||||
@@ -1529,6 +1559,11 @@ def _agent_thread(run_id, prompt, serial, cfg):
|
|||||||
with _lock:
|
with _lock:
|
||||||
_run["state"] = "error"
|
_run["state"] = "error"
|
||||||
_run["error"] = f"{type(e).__name__}: {str(e)[:200]}"
|
_run["error"] = f"{type(e).__name__}: {str(e)[:200]}"
|
||||||
|
# 失败也保留已花费的 token(前端仍能展示本轮用量;agent 可能未建出来)
|
||||||
|
try:
|
||||||
|
_run["usage"] = dict(agent.usage or {})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
q.put(("error", {"message": str(e)[:200]}))
|
q.put(("error", {"message": str(e)[:200]}))
|
||||||
finally:
|
finally:
|
||||||
q.put(None) # 关闭 SSE
|
q.put(None) # 关闭 SSE
|
||||||
|
|||||||
+37
-1
@@ -54,13 +54,47 @@ def _job_next_run(job):
|
|||||||
nr = context.mgr.next_run_of(job)
|
nr = context.mgr.next_run_of(job)
|
||||||
return nr.strftime("%Y-%m-%d %H:%M") if nr else None
|
return nr.strftime("%Y-%m-%d %H:%M") if nr else None
|
||||||
|
|
||||||
|
def _job_coverage(job, configured):
|
||||||
|
"""任务覆盖的设备(监控页「任务运行概况」展示用)。
|
||||||
|
|
||||||
|
与 TaskJob.resolve_serials 的差别:这里按 **target 定义**给出「这台任务会覆盖
|
||||||
|
哪些设备」,不因设备当前是否空闲而变(在线/运行中由前端拿实时状态标注),
|
||||||
|
也不做离线过滤、不写调度日志——它是每 5s 轮询的展示接口。
|
||||||
|
"""
|
||||||
|
tgt = job.target or {}
|
||||||
|
mode = tgt.get("mode", "all")
|
||||||
|
try:
|
||||||
|
if mode == "serial":
|
||||||
|
serials = [tgt.get("serial") or ""]
|
||||||
|
elif mode == "group":
|
||||||
|
g = context.mgr.groups.get(tgt.get("group_name"))
|
||||||
|
serials = [s for s in (list(g.serials) if g else []) if s in configured]
|
||||||
|
else:
|
||||||
|
serials = sorted(configured)
|
||||||
|
serials = [s for s in serials if s]
|
||||||
|
return {"mode": mode, "serials": serials, "total": len(serials)}
|
||||||
|
except Exception as e:
|
||||||
|
_log.warning(f"任务「{job.name}」覆盖设备解析失败: {e}")
|
||||||
|
return {"mode": mode, "serials": [], "total": 0, "error": str(e)[:80]}
|
||||||
|
|
||||||
|
|
||||||
|
def _configured_set():
|
||||||
|
"""设备池清单(coverage 计算用,取一次给所有任务复用)。"""
|
||||||
|
try:
|
||||||
|
return set(device_pool.list_configured())
|
||||||
|
except Exception:
|
||||||
|
return set()
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/api/jobs")
|
@bp.route("/api/jobs")
|
||||||
@login_required
|
@login_required
|
||||||
def api_jobs_list():
|
def api_jobs_list():
|
||||||
|
configured = _configured_set()
|
||||||
jobs = []
|
jobs = []
|
||||||
for j in context.mgr.jobs.values():
|
for j in context.mgr.jobs.values():
|
||||||
d = j.to_dict()
|
d = j.to_dict()
|
||||||
d["next_run"] = _job_next_run(j)
|
d["next_run"] = _job_next_run(j)
|
||||||
|
d["coverage"] = _job_coverage(j, configured)
|
||||||
jobs.append(d)
|
jobs.append(d)
|
||||||
return jsonify({"ok": True, "jobs": jobs,
|
return jsonify({"ok": True, "jobs": jobs,
|
||||||
"task_types": list_task_types()})
|
"task_types": list_task_types()})
|
||||||
@@ -72,7 +106,7 @@ def api_jobs_create():
|
|||||||
name = (data.get("name") or "").strip()
|
name = (data.get("name") or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
return jsonify({"ok": False, "error": "任务名不能为空"}), 400
|
return jsonify({"ok": False, "error": "任务名不能为空"}), 400
|
||||||
task_type = data.get("task_type", "douyin_nurture")
|
task_type = data.get("task_type", "generic_steps")
|
||||||
if not get_task_class(task_type):
|
if not get_task_class(task_type):
|
||||||
return jsonify({"ok": False, "error": f"未知任务类型: {task_type}"}), 400
|
return jsonify({"ok": False, "error": f"未知任务类型: {task_type}"}), 400
|
||||||
job = context.mgr.add_job(
|
job = context.mgr.add_job(
|
||||||
@@ -84,6 +118,7 @@ def api_jobs_create():
|
|||||||
enabled=data.get("enabled", True))
|
enabled=data.get("enabled", True))
|
||||||
d = job.to_dict()
|
d = job.to_dict()
|
||||||
d["next_run"] = _job_next_run(job)
|
d["next_run"] = _job_next_run(job)
|
||||||
|
d["coverage"] = _job_coverage(job, _configured_set())
|
||||||
return jsonify({"ok": True, "msg": "任务已创建", "job": d})
|
return jsonify({"ok": True, "msg": "任务已创建", "job": d})
|
||||||
|
|
||||||
@bp.route("/api/jobs/<job_id>", methods=["PUT"])
|
@bp.route("/api/jobs/<job_id>", methods=["PUT"])
|
||||||
@@ -102,6 +137,7 @@ def api_jobs_update(job_id):
|
|||||||
context.mgr.update_job(job_id, **fields)
|
context.mgr.update_job(job_id, **fields)
|
||||||
d = job.to_dict()
|
d = job.to_dict()
|
||||||
d["next_run"] = _job_next_run(job)
|
d["next_run"] = _job_next_run(job)
|
||||||
|
d["coverage"] = _job_coverage(job, _configured_set())
|
||||||
return jsonify({"ok": True, "msg": "任务已更新", "job": d})
|
return jsonify({"ok": True, "msg": "任务已更新", "job": d})
|
||||||
|
|
||||||
@bp.route("/api/jobs/<job_id>", methods=["DELETE"])
|
@bp.route("/api/jobs/<job_id>", methods=["DELETE"])
|
||||||
|
|||||||
Reference in New Issue
Block a user