42 Commits
Author SHA1 Message Date
butubb 65bab7e25c fix: schema 迁移幂等兜底——create_all 已建列而 schema_version 未记录时重复 ALTER 报 duplicate column,遇错回滚并照记版本自愈
典型场景:create_all 已按当前模型把列/表直接建好(如 perms、device.model),
而 schema_version 又因历史中断没记录,导致每次启动重复 ALTER 报错。
duplicate column name 说明列已存在=迁移目标已达成:回滚本次语句后仍记录
版本号,一次启动即自愈;其它异常才中止本批迁移。
2026-09-09 14:16:15 +08:00
butubb 4c0572f73c fix: 设备自动发现本机IP枚举——git-bash hostname -I 报错炸线程,改 bytes 接收 + getaddrinfo 兜底
Windows 上 git-bash 的 coreutils hostname 不支持 -I,会把 GBK 报错写进
stderr;text=True 在 subprocess 后台读线程里 utf-8 严格解码会直接炸线程
(主线程 try/except 接不住异步线程异常)。抽 _local_ips():优先 hostname -I
且用 bytes 接收 errors=ignore 解码,不支持/失败时 getaddrinfo 枚举兜底。
扫描网段展开时剔除本机自身 IP,避免探测到自己 5555。
2026-09-09 14:16:13 +08:00
butubb caa646e89c fix: 会话落库/加载缺 app context——后台线程 db 操作必须包 _flask_app.app_context()(历史加载与完成写回两处),否则静默失败(消息不落库、标题不生成) 2026-09-06 15:14:30 +08:00
butubb a77635c925 feat: AI 控制台历史会话(DeepSeek 式)——①agent_conversation 表:会话持久化(单表 JSON 消息),一轮 run 完成后 user/assistant 自动落库、新会话以首条消息作标题;②会话 API:列表/新建/详情/删除/重命名;③run 绑定 conversation_id:history 从会话加载(多轮上下文延续)、完成写回;④前端三栏布局:左侧会话栏(新建/列表/高亮/悬停删除/轮数时间)+ 聊天 + 实时画面,切换会话即切换上下文,刷新后自动恢复当前会话与消息 2026-09-06 15:12:56 +08:00
butubb 9b8ecd63f7 fix: AI 控制台切页/刷新不丢——①EventSource onerror 不再误 endRun(断网/后台节流/瞬时抖动都会触发 onerror 而后端任务仍在跑;EventSource 自动重连 + 服务端队列保留积压事件,重连后补发,连接彻底关闭才提示可刷新恢复);②页面刷新恢复:GET /api/agent/run 增加 run_id/answer/error/history,前端 restoreAgentView 渲染历史轮次、running 时自动重订阅事件流、done/error 展示结果——切走再回来任务与对话都在 2026-09-06 09:41:02 +08:00
butubb 0f75db289d feat: AI 目标设备必选 + 设备任务占用锁——①前端新增「🎯 目标设备」选择器(在线设备含型号,任务运行中的设备禁选),发送必须选定设备,AI 只操作你指定的设备;②/api/agent/run 校验:serial 不在池/离线/任务 running-connecting → 拒绝(409 提示任务名);③MCP 11 个写工具加 busy 锁(_ensure_device_free,5s 缓存):任务运行中的设备 AI 一律拒绝,AI 不与任务抢设备(外部 MCP 客户端同样受保护);④运行状态端点 GET /api/agent/run + 前端 8s 轮询:多人/多窗口能看到运行中任务(发起时间/任务/设备)并可停止(stop 加确认);未选设备直接 400 引导请选择设备,去掉模型自己乱挑设备的行为 2026-09-06 09:32:11 +08:00
butubb 850d2e2e66 feat: 最大执行步骤可配置——AI 控制台 ⚙ 配置弹窗新增「最大执行步骤」输入(1-200,默认 40),存 app_meta(agent_max_steps),Agent 线程运行时覆盖 agent.s.max_steps;此前仅环境变量 AGENT_MAX_STEPS 可改 2026-09-06 09:26:53 +08:00
butubb f7aa71cca4 fix: AI 控制台实时画面跟随 AI 所选设备——后端 step 事件 args 原为 str() 的 Python repr(单引号),前端 JSON.parse 失败导致 followSerialFromArgs 从未生效;改为保留对象序列化,前端兼容对象/字符串并修 step 卡片 args 展示(避免 [object Object]) 2026-09-06 09:21:03 +08:00
butubb edfd529c59 feat: 正式池断联设备自动重连——①扫描线程每轮顺带对断联的网络设备 adb_connect_light(幂等,连上即恢复,无需人工;此前只有启动时预连接一次,断联后不会自己回来);②断联设备不删不进待连接池(pending 语义=未授权新设备),GET discovery 返回 pool_offline(serial/型号/备注);③POST /api/devices/discovery/reconnect 手动立即重连;④发现面板加「设备池断联设备」区块(自动重连中 + 立即重连按钮) 2026-09-05 09:58:42 +08:00
butubb 4865ed1d31 fix: 依赖守卫补 fastmcp——漏检导致 fastmcp 缺失时跳过安装,MCP server 持续启动失败 2026-09-04 16:30:55 +08:00
butubb b4513e78eb fix: fastmcp 加入主 requirements——MCP server 已由 start.sh 常驻启动(AI 控制台依赖),compose 全新重建容器时主依赖缺失导致 MCP 启动失败 2026-09-04 16:26:17 +08:00
butubb 4863b9fbbe fix: cv2 空壳修复改 pip --force-reinstall——手动 wheel 解压兜底在部分环境修不好(解压后 import cv2 仍无 __version__,OCR 持续不可用);force-reinstall 已在 220 实测恢复 cv2 4.14.0 2026-09-04 16:25:24 +08:00
butubb 7fd77fa33c fix: 容器启动幂等——依赖安装段移入 start.sh 并加「就绪即跳过」守卫(flask/u2/rapidocr/cv2 全部可用则秒级启动);配合 220 compose 去掉 command 里的 pip 段,消除每次容器重启重装 opencv 5.0(73MB) 并破坏 cv2 的崩溃循环帮凶 2026-09-04 16:23:17 +08:00
butubb fbd01a056c fix: 经验巡检评审失败重试——模型偶发输出解释文字不带 JSON(实测 4/11 条评审失败自动保留),失败后重试一次并强调只输出 JSON;仍失败才保守保留 2026-09-04 16:19:23 +08:00
butubb 04d07b2c62 fix: uiautodev 残留进程清理防误杀——容器重启后 PID namespace 重建、残留 uiauto.pid 的 PID 值会被 MCP/其它进程复用,原逻辑直接 os.kill 可能 SIGTERM 杀掉同容器的 MCP server(观察到 web 容器 12s 崩溃循环);改为 kill 前校验 /proc/<pid>/cmdline 含 uiautodev 2026-09-04 16:15:54 +08:00
butubb 5681989194 feat: 经验库每日 AI 巡检 + 管理面板——①experience_audit 表:逐条评审(具体可执行/无过时坐标/非对话续语/配方与序列一致 checklist),keep 直接归档、delete 标 pending 待人工,绝不自动删;人工 kept 后不再重复建议;②web_server 注册每日 03:47 巡检 job(Asia/Shanghai);③API:经验列表(含巡检建议)/确认删除/保留/手动触发巡检;④AI 控制台右上角「🧠 经验库」面板:经验卡片 + 建议徽章与理由 + 删除(confirm)/保留按钮 2026-09-04 15:53:08 +08:00
butubb c078e282ed docs: 新增 MCP 使用手册(doc/MCP.md)——19 个 de_* 工具全清单(参数/用途/推荐用法)、坐标空间与自动吸附约定、配置环境变量表、推荐操作模式(文字语义点击优先→元素→坐标兜底)、安全与审计说明、fastmcp 客户端示例;与 MCP_DESIGN.md 设计稿互链 2026-09-04 15:46:14 +08:00
butubb 9c700292c3 fix: 经验入库质量门槛——①对话续语/质疑/纠错轮次不再存为经验(prompt 过短、继续/还有等续语开头、你确定/还没有/不是吧等质疑词、无任务动词的疑问短句→弃),被用户纠正的失败轮次入库会教坏后续任务;②配方含不存在的 de_* 工具弃存(蒸馏模型编造 de_input 之类);③检索命中 hits 回写 +1,高频有效经验浮前 2026-09-04 15:43:21 +08:00
butubb 729f40893e feat: 经验写入即时提示——提炼+保存挪到 done 事件之前(原在 done 后执行、SSE 已关流,用户看不到),成功后推 🧠 经验记忆 step 卡片「已写入记忆库,下次相似任务自动参考」;_save_experience 返回成功与否;蒸馏超时 60s→25s 防收尾拖沓 2026-09-04 15:12:55 +08:00
butubb bdaaea3e6c fix: MCP server 固化进 start.sh 容器启动链——之前手动 docker exec 拉起、容器重启即丢(AI 控制台 agent 依赖 8033);MCP_ENABLED=0 可关,账号密码可经环境变量覆盖 2026-09-04 14:38:01 +08:00
butubb 4fad0b1198 fix: tap_text UI 树命中路径——u2 3.x 的 el.bounds 是方法不是属性,取 bounds 抛 TypeError 被吞导致永远走 OCR 兜底;改兼容写法 2026-09-04 14:36:34 +08:00
butubb 4d4f0ae666 fix: OCR 文字点击落点精度——命中文本块较长时(一行含多段文字)按关键词在文本中的位置比例估算 x,避免点整块中心偏离关键词(tap_text 与任务 OCR 条件共用受益) 2026-09-04 14:33:28 +08:00
butubb 44a9dafcd8 fix: AI 点击精度优化——①坐标吸附:/api/screen/tap 加 snap=1,MCP de_tap 固定开启(dump UI 树找包含点击点的最小可点击元素点中心,模型坐标偏 20-50px 也点得准,大屏触控不带 snap 行为不变);②新 de_tap_text 语义点击:按屏幕可见文字一次完成找+点(UI 树 textContains/descriptionContains → OCR 中心兜底,WebView/图片文字也能点);③de_tap_element 支持 text_contains/desc_contains 模糊匹配;④de_ui_tree 可点击元素优先 + limit 参数防 token 膨胀;⑤agent 提示词重写:文字语义点击优先、坐标仅纯图形兜底且自动吸附、点击后无变化禁止重复同坐标 2026-09-04 14:29:17 +08:00
butubb e5018c4e45 fix: de_foreground_app fallback mFocusedApp(MIUI 过渡期 mCurrentFocus=null 时前台解析为空) 2026-09-04 13:52:34 +08:00
butubb fab7e2b11a feat: MCP 工具补全(9→18)——de_open_app(adb monkey 直启,省 token)/de_stop_app/de_foreground_app/de_type_text(中文直接输入)/de_set_clipboard/de_sleep/de_ocr(屏幕文字识别)/de_list_apps/de_list_tasks;direct_ops 直连封装(adb/u2/OCR/剪贴板轻量通道) 2026-09-04 13:48:55 +08:00
butubb 1560a13d86 fix: tool_calls 流中断自愈——400 配对错误时移除不完整段重试一次;加消息结构诊断日志 2026-09-04 13:43:20 +08:00
butubb c0c1be05f8 fix: 经验保存缺 app context(后台线程 db 报错被吞)——register_blueprints 注入 app,线程内 db 操作包 app_context 2026-09-04 13:37:33 +08:00
butubb 1ada51a7f6 feat: 自进化经验记忆——任务成功后用模型把工具序列提炼成「操作配方」存入 agent_experience 表;下次相似任务(bigram 相似度检索 top2)自动注入 system prompt 参考,AI 越用越聪明(同类操作不再摸索) 2026-09-04 13:33:30 +08:00
butubb 5145f83f06 feat: AI 控制台增强——①de_read_clipboard 剪贴板回读工具;②任务可中断(stop_event 循环检查点 + API stop + 前端■停止按钮);③智能滚动(回看历史不被打断,贴底才跟随);④右侧实时画面(MJPEG 流自动跟随操作设备,可手动选择) 2026-09-04 13:32:35 +08:00
butubb 44cab88546 fix: Agent 步骤上限优化——max_steps 20→40(多步任务如打开抖音搜索),system prompt 加收敛规范(无进展 6 步主动停止总结),超限时无工具收尾请求让模型给有意义总结(不再机械提示);web 超时 600→900s 2026-09-04 13:23:04 +08:00
butubb ce4e5464b9 feat: AI 控制台会话化 + 视觉优化——①多轮对话连续性(后端 history 持久化 12 轮,Agent 注入历史上下文,DeepSeek 式会话:新建会话才清空);②聊天区改浅色(DeepSeek 风格,图片文字清晰);③截图点击放大查看 2026-09-04 13:18:36 +08:00
butubb e24458a085 feat: AI 控制台升级为顶级 Tab——DeepSeek 风格聊天界面(气泡+流式渲染+思考折叠),实时 MCP 步骤卡片(工具/参数/截图缩略),SSE 流式输出(delta/step/done/error 事件),配置模态(模型/Key/设备前端可配);工具页旧子栏移除 2026-09-04 13:09:34 +08:00
butubb 25ef7e7746 fix: agent_api 同步 dev(事务/_execute 遮蔽/600s 超时) 2026-09-04 13:04:44 +08:00
butubb 19126d1a59 fix: Agent MCP 连接短超时(10s 快速失败)+ web 600s 兜底 2026-09-04 12:59:32 +08:00
butubb fea5a57fc3 feat(M2): Web AI 控制台——工具页新增子分栏:模型/Key/设备前端可配置(app_meta 存储,key 打码回显),指令执行 + 步骤流轮询(含截图缩略)+ 回答展示;Agent 加 on_step 回调、后台线程单实例运行 2026-09-04 12:54:17 +08:00
butubb 02776b12b8 feat(M2): 语义层工具 de_ui_tree/de_tap_element——UI 树元素定位点击(text/id/desc,多命中 index),无需坐标更可靠;坐标 tap 保留兜底(WebView/画布元素) 2026-09-04 12:52:55 +08:00
butubb 7017ff3315 feat(M1): 补 de_wake/de_press_key 工具(Agent 熄屏可自唤醒);修复 MCP SDK v2 input_schema 字段警告 2026-09-04 12:50:55 +08:00
butubb 937a87b36c feat(M1): Agent 编排层(OpenAI 兼容)——DeepSeek 等第三方模型经 MCP 工具控制手机:工具桥转 function schema、截图图像转 image_url 多模态流、工具循环、CLI 入口(mock 验证通过) 2026-09-04 12:47:09 +08:00
butubb d7b6d83cd8 feat(M1): MCP 截图坐标空间统一——de_screenshot 附 native_size,de_tap/de_swipe 接受截图坐标由 server 按比例换算原生(模型只感知截图坐标系) 2026-09-04 12:47:09 +08:00
butubb 084ed59ad5 feat: 平台新增只读端点 /api/screen/size——返回设备屏幕原生分辨率(u2 window_size),供 MCP 截图坐标换算(截图是缩放图,操作需原生坐标) 2026-09-04 12:47:09 +08:00
butubb 2ba9f0c239 feat(M0): MCP 手机控制 Server 骨架——FastMCP HTTP 传输(8033),平台登录会话+CSRF 封装,de_list_devices/de_screenshot(图像块)/de_tap/de_swipe,写门控/白名单/审计,实测全链路通过 2026-09-04 12:42:16 +08:00
butubb c304996c7b docs: MCP 手机控制设计文档——架构(复用平台 REST/core 能力薄封装)、L1感知/L2操作/L3语义三层 tools 规格、图像链路(screenshot→image block)、安全(写门控/白名单/占用互斥/审计)、220 部署与 M0-M3 里程碑 2026-09-04 10:29:03 +08:00
27 changed files with 3703 additions and 47 deletions
+71 -10
View File
@@ -22,7 +22,7 @@ from config import (USB_ADB_HOST,
DISCOVERY_PORT, DISCOVERY_SUBNETS, DISCOVERY_INTERVAL)
from core.adb_helper import _adb, adb_connect_light
from core.logger import get_logger
from core.models import db, PendingDevice
from core.models import db, Device, PendingDevice
_log = get_logger("core.disc")
@@ -114,17 +114,39 @@ def save_settings(enabled=None, subnets=None, interval=None, port=None):
# ================== 网段展开与端口探测 ==================
def _local_ips():
"""本机自身 IP 集合(尽力而为):扫描时排除,避免探测到自己的 5555。
优先 `hostname -I`(Linux/macOS 支持,一行空格分隔多 IP);不支持/失败的
平台退回 socket.getaddrinfo 枚举。关键:外部命令必须用 bytes 收——Windows
上 git-bash 的 coreutils hostname 不支持 -I,会把 GBK 报错写进 stderr,
text=True 在 subprocess 后台读线程里 utf-8 严格解码会直接炸线程(主线程
try/except 接不住异步线程异常)。
"""
import subprocess
ips = set()
try:
r = subprocess.run(["hostname", "-I"], capture_output=True, timeout=3)
if r.returncode == 0:
ips.update(p for p in
r.stdout.decode("utf-8", errors="ignore").split() if p)
except Exception:
pass
if not ips: # 兜底:主机名解析出的接口 IPv4
try:
for info in socket.getaddrinfo(socket.gethostname(), None):
ip = info[4][0]
if ":" not in ip:
ips.add(ip)
except Exception:
pass
return ips
def _expand_subnets(subnets, max_hosts=_MAX_HOSTS_PER_SUBNET):
"""CIDR 列表 → IP 列表。排除 220 自身(USB_ADB_HOST)与本机 IP;
非法网段跳过记日志;单网段超过 max_hosts 截断并告警。"""
self_ips = {USB_ADB_HOST}
try: # 本机自身 IP(接口枚举,尽力而为;无 hostname -I 的平台跳过)
import subprocess
out = subprocess.run(["hostname", "-I"], capture_output=True, text=True,
timeout=3).stdout or ""
self_ips.update(p for p in out.split() if p)
except Exception:
pass
self_ips = {USB_ADB_HOST} | _local_ips()
ips = []
for cidr in subnets:
try:
@@ -220,8 +242,14 @@ def scan_once(manual=False):
first_seen=now, last_seen=now))
added += 1
db.session.commit()
# 6. 正式池断联设备自动重连:adb connect 会因 WiFi 波动/设备重启/
# adb 服务重启而断开——扫描线程每轮顺带重试(幂等轻量,内部
# 全局锁串行),连上即恢复在线,无需人工干预。pending 池是给
# 「未授权新设备」的,正式池设备断联不进 pending,而是自动重连。
back = _reconnect_offline(configured)
_log.info(f"发现: 探测开放 {len(open_ips)} 台,可连 {len(verified)} 台,"
f"新增待连接 {added} 台")
f"新增待连接 {added} 台"
+ (f",自动重连恢复 {len(back)} 台 {back}" if back else ""))
_last_scan = (now, len(open_ips), len(verified), added)
_last_error = ""
return True, {"found": len(open_ips), "verified": len(verified), "new": added}
@@ -240,6 +268,39 @@ def device_pool_list_configured():
return device_pool.list_configured()
# ================== 正式池断联设备:自动重连 ==================
def _reconnect_offline(configured):
"""对正式池中断联的网络设备逐个 adb 重连,返回恢复的 serial 列表。
只重连网络设备(IP:5555;USB 设备插着就在,无需 connect)。
幂等轻量:内部 adb 全局锁串行,失败静默(下轮扫描再试)。
"""
online_now = _parse_adb_devices(_adb("devices"))
targets = [s for s in (configured or [])
if ":" in s and s not in online_now]
if not targets:
return []
for serial in targets:
adb_connect_light(serial)
online_after = _parse_adb_devices(_adb("devices"))
return [s for s in targets if s in online_after]
def list_pool_offline():
"""正式设备池中断联的设备(serial + 型号 + 备注名),面板展示用。
断联设备仍是正式池成员(不删除、不进 pending)——pending 是给未授权
新设备的;它们由扫描线程每轮自动重连,也可前端手动立即重连。
"""
online = _parse_adb_devices(_adb("devices"))
with _ctx():
rows = [d.to_dict() for d in Device.query.filter_by(enabled=True)
.order_by(Device.serial).all()]
return [{"serial": r["serial"], "model": r.get("model") or "",
"name": r.get("name") or ""}
for r in rows if r["serial"] not in online]
# ================== 定时扫描线程 ==================
def _discovery_loop():
"""定时扫描 daemon 线程。每轮重读配置(开关/周期即时生效)。
+12 -1
View File
@@ -305,7 +305,18 @@ def _migrate_schema():
if version <= current:
continue
if sql:
db.session.execute(text(sql))
try:
db.session.execute(text(sql))
except Exception as e:
# 幂等兜底:目标结构已存在也视为该迁移生效。典型场景——
# create_all 已按当前模型把列/表直接建好(如 perms、device.model),
# 而 schema_version 又因历史中断没记录,导致每次启动重复 ALTER 报错。
# duplicate column name 说明列已存在=迁移目标已达成:回滚本次语句后
# 仍记录版本号,一次启动即自愈;其它异常才中止本批迁移。
if "duplicate column name" not in str(e).lower():
raise
db.session.rollback()
_log.info(f"schema 迁移 {version} 目标已存在,跳过: {desc}")
db.session.execute(
text("INSERT OR REPLACE INTO app_meta(key,value) VALUES('schema_version',:v)"),
{"v": str(version)})
+15 -4
View File
@@ -69,11 +69,22 @@ def recognize(image):
def find_on_screen(image, keyword):
"""在截图上查找包含 keyword 的文字。
返回 (found, center_xy, matched_text);center_xy 为文字中心像素坐标(可点击),
未命中返回 (False, None, "")。
返回 (found, center_xy, matched_text);center_xy 为**关键词**中心像素坐标
(可点击),未命中返回 (False, None, "")。
OCR 一行常含多段文字(如 "医值得推荐#苏州济世璞真…展开"),若直接点整块
中心会偏离关键词很远。命中块文本较长时,按关键词在文本中的位置比例估算 x
(块内文字近似等宽),y 取块中心——中文场景估算偏差小,点击能落准。
"""
for r in recognize(image):
if keyword in r["text"]:
text = r["text"]
if keyword in text:
b = r["box"]
return True, ((b[0] + b[2]) // 2, (b[1] + b[3]) // 2), r["text"]
if len(text) <= 12: # 短文本:整块中心即关键词中心
return True, ((b[0] + b[2]) // 2, (b[1] + b[3]) // 2), text
idx = text.find(keyword)
ratio = (idx + len(keyword) / 2) / len(text)
cx = b[0] + int((b[2] - b[0]) * ratio)
cy = (b[1] + b[3]) // 2
return True, (cx, cy), text
return False, None, ""
+147
View File
@@ -0,0 +1,147 @@
# MCP 手机控制 Server(`mcp_server/`)
多模态 AI(DeepSeek/Claude 等)通过 [MCP(Model Context Protocol)](https://modelcontextprotocol.io) 实时操作 Android 手机的统一出口。AI 控制台(`mcp_agent/`)与外部 MCP 客户端都经它控制设备池中的手机。
> 本文档 = 使用手册(工具清单/用法)。架构与演进设计见 [MCP_DESIGN.md](MCP_DESIGN.md)。
## 架构
```
AI 控制台 / 外部 MCP 客户端
│ Streamable HTTP
▼
MCP Server (:8033, mcp_server/mcp_server.py) ← 19 个 de_* 工具
│ 平台 HTTP API(登录 + CSRF)
▼
auto_control 平台 (:18050) ← 设备池/任务/看屏
│ adb / uiautomator2 / uiautodev / OCR
▼
Android 设备(IP:5555)
```
- 轻量通道直连(adb monkey 开 App、u2 输入、本地 OCR)不绕平台,省时省 token
- 坐标换算、UI 吸附、剪贴板注入等细节全部在 Server 层消化,模型只需给意图
## 运行与配置
服务跑在 220 的 `python-app` 容器内(`scripts/start.sh` 自动拉起,端口 8033)。
生产访问方式:`http://192.168.20.220:8033/mcp`;本机调试 `python3 -m mcp_server.mcp_server`。
| 环境变量 | 默认 | 说明 |
|---|---|---|
| `MCP_PLATFORM_URL` | `http://127.0.0.1:18050` | 平台地址(Agent 与平台同机时用本机) |
| `MCP_PLATFORM_USER` / `MCP_PLATFORM_PASS` | `admin` / 空 | 平台登录账号 |
| `MCP_ALLOW_WRITE` | `0` | **写门控**:=1 才允许点击/输入/开关 App 等写操作(只读工具不受限) |
| `MCP_ALLOWED_SERIALS` | 空=不限 | 设备白名单(逗号分隔),非空时只允许列出的 serial |
| `MCP_HTTP_HOST` / `MCP_HTTP_PORT` | `0.0.0.0` / `8033` | 监听地址 |
| `MCP_SCREENSHOT_WIDTH` | `540` | 截图返回宽度上限(px),模型看到的图即该坐标系 |
| `MCP_JPEG_QUALITY` | `70` | 截图 JPEG 质量 |
| `MCP_AUDIT_FILE` | `/var/log/mcp/audit.log` | 审计日志(每次工具调用一行) |
| `MCP_PLATFORM_TIMEOUT` | `30` | 平台请求超时(秒) |
生产(220 容器)已设 `MCP_ALLOW_WRITE=1`;`MCP_ALLOWED_SERIALS` 未设(= 平台设备池内可操作)。
## 坐标空间(重要约定)
- `de_screenshot` 返回 ≤540px 宽的 JPEG(display 空间),并附 `native_size`(设备原生分辨率)
- `de_tap` / `de_swipe` 的坐标一律使用 **de_screenshot 返回图像的坐标系**,Server 按比例换算为原生坐标
- **先截图、后点击**:Server 需要最近一次截图才能建立坐标空间(未截图就点击会报「请先执行 de_screenshot」)
- `de_tap` 带**自动吸附**:点击点若落在某个可点击元素内,实际会点该元素中心——坐标只需大致对准,偏十几像素也能点准;点空白处则按原坐标
- 返回的 `snapped/label` 可核对吸附结果(snapped=true 表示已吸到元素,label 为该元素文案)
## 工具清单(19 个)
### 设备与状态
| 工具 | 用途 |
|---|---|
| `de_list_devices` | 列出可控制设备:serial / 型号 / 在线 / 任务状态 / 前台 App。**开局第一步** |
| `de_foreground_app(serial)` | 当前前台 App 包名(dumpsys,MIUI 焦点为空时自动兜底) |
### 观察屏幕(感知)
| 工具 | 用途 |
|---|---|
| `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}] |
### 点击与滑动(操作)
| 工具 | 用途 |
|---|---|
| `de_tap_text(serial, text)` | **按屏幕文字点击**(推荐):给一个屏幕上可见的文字(子串匹配)即找到并点其中心。原生控件走 UI 树,WebView/图片文字自动 OCR 兜底。找不到返回明确错误 |
| `de_tap_element(serial, by, value, index=1)` | 按元素点击:`by` = text / id / desc(精确)或 text_contains / desc_contains(模糊)。多命中用 index 取第几个 |
| `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 |
### 输入与剪贴板
| 工具 | 用途 |
|---|---|
| `de_type_text(serial, text)` | 向当前界面输入框输入文字(支持中文,直设 EditText 不依赖剪贴板/粘贴) |
| `de_set_clipboard(serial, text)` | 写入设备剪贴板(ClipInject 通道注入 + 读回验证) |
| `de_read_clipboard(serial)` | 读取设备当前剪贴板内容 |
### App 管理(轻量 adb 直连,不建 u2 会话)
| 工具 | 用途 |
|---|---|
| `de_open_app(serial, package)` | 打开 App(adb monkey 直启,无需知道 activity——最快的打开路径) |
| `de_stop_app(serial, package)` | 强制停止 App(am force-stop) |
| `de_list_apps(serial, keyword="")` | 列出第三方已装应用(pm list packages -3),keyword 可过滤(如 "douyin") |
| `de_sleep(serial)` | 熄屏(**运行中任务会中断,慎用**) |
| `de_wake(serial)` | 亮屏并解锁(熄屏时先调它再截图) |
### 平台联动
| 工具 | 用途 |
|---|---|
| `de_list_tasks()` | 列出平台任务计划(名称/类型/启用/调度),了解已自动化的工作 |
## 推荐使用模式(操作手机的正确姿势)
1. **`de_list_devices`** 确认目标设备在线
2. **`de_screenshot`** 看图理解当前界面(图像会在下一次模型回合送达)
3. **点击定位优先级**(从高到低):
- 目标有可见文字 → **`de_tap_text`**(一次调用完成「找到并点击」,最可靠)
- 文字有歧义/多候选 → `de_ui_tree` 确认后 `de_tap_element`(text_contains 模糊匹配)
- 纯图形目标 → `de_tap` 坐标(**无需精算**,Server 自动吸附到可点元素中心)
4. 输入文字:先点中输入框(de_tap_text / de_tap),再 `de_type_text`
5. 每次关键操作后 `de_screenshot` 验证:界面变化 = 成功;无变化 = 未命中,换 de_tap_text / de_tap_element 重新定位,**不要重复点同一坐标**
6. 完成/失败时用中文总结:做了什么、当前状态、注意事项
7. 效率约束:界面未变不重复截图/点同位置;连续 6 步无进展停止并总结
## 安全与审计
- **写门控**:`MCP_ALLOW_WRITE=0`(默认)时点击/输入/开关 App 全部拒绝,只读工具可用
- **设备白名单**:`MCP_ALLOWED_SERIALS` 非空时限制可操作的 serial
- **审计日志**:每次调用记录 `{ts, tool, serial, args, result}` 到 `MCP_AUDIT_FILE`(220 上 `/tmp/mcp_audit.log`)
- 操作对象限定平台设备池;`adb kill-server` / `adb disconnect` 属项目红线,任何工具不触碰
## 客户端接入示例
```python
import asyncio
from fastmcp import Client
async def main():
async with Client("http://192.168.20.220:8033/mcp", timeout=30) as c:
devs = await c.call_tool("de_list_devices", {})
serial = devs.data["data"][0]["serial"] # 取第一台在线设备
shot = await c.call_tool("de_screenshot", {"serial": serial})
img = shot.data["data"]["image"] # base64 JPEG(多模态模型可直接看图)
await c.call_tool("de_tap_text", {"serial": serial, "text": "搜索"})
asyncio.run(main())
```
## AI 控制台(内置 Agent)
Web 端「AI 控制台」Tab 内建的 Agent(`mcp_agent/`,OpenAI 兼容协议:DeepSeek 等)也通过本 Server 的同一批工具跑任务:
- 流式输出 + 每步 MCP 工具调用实时展示(含截图缩略)
- 多轮会话记忆(同会话上下文保留,新建会话清空)
- **自进化经验记忆**:一轮成功操作会被提炼成「配方」存入 `agent_experience` 表,下次相似任务自动注入参考(命中/写入均有 🧠 提示卡)
- 模型与 API Key 在 AI 控制台右上角 ⚙ 配置,存平台 `app_meta`
+244
View File
@@ -0,0 +1,244 @@
# MCP 手机控制(Mobile Control MCP Server)设计文档
> 分支:dev:mcp | 状态:设计稿 | 日期:2026-09-04
## 1. 背景与目标
平台(auto_control,生产 220:18050)已具备完整的手机远程控制与自动化能力:设备池管理、u2 控制(tap/swipe/key/输入/剪贴板)、minicap 看屏(截图/流)、uiautodev 元素树、RapidOCR、任务调度与动作编辑器。
本设计的目标:**新增一个 MCP Server,把平台能力以 MCP 协议暴露给多模态 AI(Claude 等),使 AI 能像人一样"看到手机屏幕 → 理解 → 操作 → 再看到"地闭环控制手机**。
核心判断:平台能力全部已有,MCP Server 是**薄封装层**——主要工作在:工具规格设计、多模态图像链路、认证与安全、部署。
## 2. 术语
| 术语 | 说明 |
|---|---|
| MCP | Model Context Protocol,模型上下文协议(客户端-服务器,工具调用) |
| Tools | MCP 暴露的可调用能力(本设计按 L1/L2/L3 分层) |
| serial | 设备标识,如 `192.168.20.66:5555`(设备池唯一主键) |
| 平台 | 指 auto_control web 服务(18050)及其 core 层能力 |
| 宿主 | MCP Server 运行进程/容器 |
## 3. 总体架构
```
┌──────────────┐ MCP (Streamable HTTP / stdio) ┌──────────────────┐
│ 多模态 AI │ ────────────────────────────────▶ │ MCP Server │
│ Claude Desktop│ tools + image content block │ (220 独立进程) │
│ Claude Code │ ◀──────────────────────────────── │ mcp/ 包 │
└──────────────┘ 截图图像 / JSON 结果 └────────┬─────────┘
│ 内部调用
▼
┌──────────────────┐
│ auto_control │
│ 18050 REST API │
│ (登录会话 token)│
├──────────────────┤
│ core 层能力: │
│ u2 / minicap / │
│ uiautodev / OCR /│
│ 任务调度 / 设备池 │
└──────────────────┘
```
- **图像方向**:MCP 协议支持 `image` content block(base64),截图以此返回,多模态模型原生可读——这是"AI 看到手机"的关键链路。
- **控制方向**:AI 返回结构化工具调用(tap/swipe/type…),MCP Server 转发平台执行。
- **循环**:截图 → 模型视觉推理 → 工具调用 → 平台执行 → 截图验证。
## 4. 平台现有能力复用清单
| 平台能力 | 现有入口 | MCP 复用方式 |
|---|---|---|
| 设备列表/状态 | `GET /api/status`、`/api/devices/pool` | MCP server 内部 HTTP 调用(带会话) |
| 截图 | `GET /api/screen/thumb?serial=`(单帧 JPEG) | 直接转发为 image block(亦可直连 core u2 screenshot) |
| 看屏流 | `/api/screen/stream`(MJPEG) | M0-M2 不需要;M3 可选(视频理解场景) |
| 点击 | `POST /api/screen/tap {serial,x,y}` | 封装 tool `tap` |
| 滑动 | `POST /api/screen/swipe` | 封装 `swipe` |
| 按键 | `POST /api/screen/key` | 封装 `press_key` |
| 输入文字 | ClipInject 通道(core.clipboard_helper)+ u2 input | 封装 `type_text` |
| 剪贴板 | `POST /api/tools/clipboard/set`(ClipInject 通道) | 封装 `set_clipboard` |
| 元素树 | `GET /api/uiauto/...`(uiautodev) | 封装 `get_ui_tree` |
| OCR | core.ocr(RapidOCR) | 封装 `ocr_screen` |
| 打开 App | u2 `app_start`(经任务层/直连) | 封装 `open_app`(走 core device 直连) |
| 设备在线状态/亮熄屏 | `/api/device/screen_all`、dumpsys | 封装 `screen_state`/`wake` |
> 决策:MCP Server 优先走 **HTTP API**(薄封装,认证简单、与平台解耦、平台安全逻辑全复用)。对延迟敏感且 HTTP 无入口的能力(u2 直连截图/输入),经平台 core 模块进程内调用或新增少量只读端点,不绕过平台安全层。
## 5. MCP Server 设计
### 5.1 技术栈
- 语言/运行时:Python 3.11(与平台一致)
- 框架:**FastMCP**(`fastmcp`,官方 SDK 之上,装饰器式 tools,自带 Streamable HTTP/stdio 双传输)
- 依赖:`fastmcp`、`httpx`、`Pillow`(图像处理)、`mcp[cli]`
- 日志:logging → 平台同款格式(时间/级别/模块)
### 5.2 进程与容器
- 独立容器 `mcp-server`(220 docker-compose 追加),image `python:3.11-slim`
- 挂载:无数据挂载(无状态,配置走环境变量);网络 host 或独立端口(**候选:8033**,避免与 18050/18051 冲突)
- 独立于 auto_control 重启,互不阻塞;MCP Server 崩溃不影响平台,平台不可用时 MCP tools 返回明确错误
### 5.3 配置(环境变量)
| 变量 | 默认 | 说明 |
|---|---|---|
| `MCP_PLATFORM_URL` | `http://127.0.0.1:18050` | 平台地址 |
| `MCP_PLATFORM_USER` / `MCP_PLATFORM_PASS` | 空 | 平台登录账号(admin) |
| `MCP_ALLOW_WRITE` | `0` | 写操作总开关(0=只读感知,1=可操作) |
| `MCP_ALLOWED_SERIALS` | 空=全部 | 设备白名单(逗号分隔;为空时自动=设备池内设备) |
| `MCP_HTTP_PORT` | `8033` | HTTP 传输端口 |
| `MCP_SCREENSHOT_WIDTH` | `540` | 截图宽度(等比缩放,控图像 token 成本) |
| `MCP_JPEG_QUALITY` | `70` | 截图 JPEG 质量 |
| `MCP_AUDIT_FILE` | `/var/log/mcp/audit.log` | 审计日志路径 |
## 6. Tools 规格
命名空间 `de_`(device)前缀避免与常见工具冲突。全部工具对**未配置白名单/离线设备**返回明确错误,不做静默跳过。
### L1 感知层
#### `de_list_devices`
- 描述:列出可控制设备及其状态(在线/任务/型号/前台 App)
- 返回:`[{serial, model, online, task_job, worker_status, foreground_app, screen_state}]`
- 错误:平台不可达 → `platform_unavailable`
#### `de_screenshot(serial: str) -> image`
- 描述:截取设备当前屏幕(JPEG),返回 image content block;同时返回 `width/height/serial` 元信息
- 实现:平台 `/api/screen/thumb`(X-Screen-State 头复用判断亮熄屏);失败(离线/超时)→ 明确错误
- 图像规格:宽 ≤ `MCP_SCREENSHOT_WIDTH`(默认 540),质量 `MCP_JPEG_QUALITY`;**需保证图像方向正确**(设备可能横竖屏,含 EXIF 或由调用方按截图尺寸推断)
- 限制:截图频率 ≥ 1s/次(防 AI 疯狂截图),可配置
#### `de_ui_tree(serial: str) -> str`
- 描述:获取当前界面元素树(扁平 JSON:resource-id/text/content-desc/class/bounds),供定位
- 返回:文本 JSON(大树截断至 `MCP_UI_TREE_MAX` 字符,默认 20000,防 context 爆炸)
- 空界面/无元素 → 明确错误
#### `de_ocr(serial: str) -> str`
- 描述:OCR 识别当前屏幕文字,返回 `[{text, score, box}]`
- 场景:图片/画布/WebView 渲染文字(UI 树里没有的)
#### `de_screen_state(serial: str) -> str`
- 描述:亮屏/熄屏/未知
### L2 操作层(受 `MCP_ALLOW_WRITE=1` 门控)
#### `de_tap(serial, x: int, y: int)`
- 坐标:设备**原生分辨率**像素(与截图 1:1 换算——调用方用截图尺寸按比例换算,server 不做猜测)
#### `de_swipe(serial, x1,y1,x2,y2, duration: float=0.2)`
#### `de_press_key(serial, key: str)`
- key ∈ back/home/recent/menu/power/enter/delete…
#### `de_type_text(serial, text: str, clear_first: bool=True)`
- 实现:优先 ClipInject 通道(`core.clipboard_helper.inject_clipboard` 语义:写入剪贴板 + paste 到焦点输入框),失败回退 u2 input
- 中文/emoji 全支持(ClipInject 实测通过)
#### `de_set_clipboard(serial, text: str)`
- 写入设备剪贴板(ClipInject am start 通道),读回验证
#### `de_open_app(serial, package: str)`
- 打开指定 App(u2 app_start);`package` 需在已知清单或完全限定名
#### `de_wake(serial)` / `de_sleep(serial)`
### L3 语义层(M2 里程碑)
#### `de_find_and_tap(serial, target: str, by: "text"|"id"|"ocr"|"desc"=...)`
- UI 树/OCR 定位含 target 的元素 → 自动点击其中心;多命中返回候选列表让 AI 选择
- 复用平台元素选择器策略(属性唯一/重复序号消歧)
#### `de_wait_until(serial, condition: str, timeout: int=15)`
- 轮询 OCR/UI 树直到出现条件文本(或消失),返回命中的坐标/文本
#### `de_get_task_state(serial)`(若二期接任务系统)
- 设备当前任务状态;可选 `de_run_task(task_id)` 需单独授权
### 工具返回约定
- 全部工具返回结构化 JSON:`{ok: true, data: ...}` 或 `{ok: false, error: {code, message}}`
- 错误码:`platform_unavailable` / `device_offline` / `device_not_allowed` / `invalid_param` / `write_disabled` / `busy`(设备被任务占用)
- image 返回:`{ok:true, image: <content block>, width, height}`
## 7. 图像链路细节
1. 平台截图(`/api/screen/thumb`)→ JPEG 字节
2. MCP Server 校验尺寸 → 等比缩放到 `MCP_SCREENSHOT_WIDTH`(用 Pillow,不重压缩已够质量则跳过)
3. 组装 MCP `image` content block(`{"type":"image","data":<base64>,"mimeType":"image/jpeg"}`)
4. 客户端(Claude)原生把 image 传入视觉上下文
性能预算:540px JPEG ~40-80KB/张;模型一次多步操作 5-15 张 ≈ 1MB 以内,可接受。
## 8. 认证与安全
1. **平台会话**:MCP Server 启动时用 `MCP_PLATFORM_USER/PASS` 登录平台拿会话(Cookie),会话失效自动重登;不向客户端暴露平台凭据
2. **写操作门控**:`MCP_ALLOW_WRITE=0`(默认)时 L2/L3 操作全部拒绝(`write_disabled`)——先部署只读,验证感知链路后再开写
3. **设备白名单**:`MCP_ALLOWED_SERIALS` 指定;为空时自动限制为**设备池 enabled 设备**(平台口径)
4. **占用互斥**:操作前查设备 `worker_status`——running/connecting 中拒绝操作(`busy`),避免 MCP 与任务打架
5. **审计**:每次调用(含只读)写审计日志:`ts|tool|serial|args摘要|result`;落盘 `MCP_AUDIT_FILE`
6. **传输安全**:内网部署(host 网络 8033)默认无 TLS;若外网暴露需前置 TLS/网络隔离(平台红线:设备控制接口不进公网)
7. **无状态**:MCP Server 不存设备数据,全量透传平台
## 9. 部署(220 docker-compose 追加)
```yaml
mcp-server:
container_name: mcp-server
image: dockerproxy.net/library/python:3.11-slim
restart: unless-stopped
working_dir: /app
volumes:
- "./mcp_server:/app"
command: sh -c "pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt && python -m mcp_server"
environment:
- MCP_PLATFORM_URL=http://127.0.0.1:18050
- MCP_PLATFORM_USER=admin
- MCP_PLATFORM_PASS=${MCP_PLATFORM_PASS} # .env 提供
- MCP_ALLOW_WRITE=1
- MCP_ALLOWED_SERIALS=
network_mode: host
```
客户端接入(Claude Code / Desktop):
```json
// .mcp.json / claude_desktop_config.json
{ "mcpServers": { "mobile": {
"url": "http://192.168.20.220:8033/mcp",
"transport": "streamable-http"
}}}
```
## 10. 里程碑与验收
| 里程碑 | 内容 | 验收标准 |
|---|---|---|
| M0 | Server 骨架 + `de_list_devices` + `de_screenshot` + `de_tap`/`de_swipe`(HTTP 传输、登录会话、审计骨架) | Claude Code 连上后:能列设备、截图(AI 能描述屏幕内容)、点击指定坐标生效 |
| M1 | L1 完备(ui_tree/ocr/screen_state)+ L2 全量(key/type/clipboard/open_app/wake) | AI 完成「打开抖音搜索『奚学东』」多步操作 |
| M2 | L3 语义层(find_and_tap/wait_until)+ 图像尺寸/性能调优 | AI 用自然语言指令(含中文输入)完成跨 3 步以上操作且自适应页面变化 |
| M3 | 生产化:安全加固复核、审计看板、部署脚本、接入文档、任务系统互斥回归 | 日常稳定使用 1 周无异常 |
## 11. 风险与开放问题
1. **AI 误操作**:模型点击错位/误触关键按钮(如删除)——缓解:L3 定位优先于裸坐标、写门控先行、审计可追溯;**不做**二次确认(破坏自动化体验),靠白名单+占用互斥兜底
2. **截图成本**:长会话多截图 → token/延迟成本——缓解:尺寸/质量可调、截图上限、`de_ui_tree` 作为低成本替代(文本树比图像便宜)
3. **横竖屏/分辨率异构**:设备多样 → 坐标换算依赖截图 1:1;`de_find_and_tap`(语义层)不受分辨率影响
4. **图像 content block 兼容性**:Claude 系原生支持;其他多模态客户端需验证(M0 用 Claude Code 验证)
5. **是否接任务系统**:二期决策——`de_run_task` 语义与"单设备操作"不同(任务面向多设备调度),倾向二期用独立命名空间
6. **clipboard/input 通道差异**:个别 MIUI 需授权 ClipInject——M0 阶段在受控设备集验证,必要时 fallback 链已在 core 层
## 12. 附录:平台 API 映射(MCP → 平台)
| MCP tool | 平台调用 |
|---|---|
| de_screenshot | GET /api/screen/thumb?serial= |
| de_tap / de_swipe / de_press_key | POST /api/screen/tap|swipe|key |
| de_type_text / de_set_clipboard | POST /api/tools/clipboard/set(ClipInject)+ u2 input |
| de_ui_tree | GET /api/uiauto/…(或 core 直连) |
| de_ocr | core.ocr(进程内或新只读端点) |
| de_list_devices | GET /api/status + /api/devices/pool |
| de_open_app | core device app_start(或任务层 open_app 动作) |
| de_screen_state | dumpsys(core.common._device_screen_state) |
> 实现时对平台缺失的只读入口(如 ocr/open_app 的直连面)优先在平台加**只读端点**(复用现有权限体系),MCP 不越权直连设备。
+1
View File
@@ -0,0 +1 @@
"""Agent 编排层:第三方 LLM(OpenAI 兼容)经 MCP 工具控制手机。"""
+291
View File
@@ -0,0 +1,291 @@
"""Agent 编排层:OpenAI 兼容模型(DeepSeek 等)经 MCP 工具控制手机。
支持两种运行模式:
- run_stream():流式(SSE 逐 token + 工具调用实时回调)——Web AI 控制台用
- run():非流式收集结果——CLI 用(内部调 run_stream)
流式细节(OpenAI 兼容):
- content/reasoning_content 增量逐 chunk 回调(kind 区分)
- tool_calls 分片累积(arguments 按 index 拼接),流结束后统一执行
- 截图(de_screenshot)图像转 image_url 追加下一轮,同时 on_tool 回调带缩略
"""
import base64
import json
import logging
import httpx
from fastmcp import Client
from mcp_agent.config import AgentSettings
_log = logging.getLogger("agent")
S = AgentSettings()
SYSTEM_PROMPT = """你是手机自动化控制助手。你通过工具实时操作 Android 手机。
工作规范:
1. 先 de_list_devices 确定目标设备(在线才可操作)
2. 观察屏幕:先 de_screenshot 获取截图(图像会随后给你),基于截图理解当前界面
3. 点击定位分优先级(不要自己推算像素坐标,那是精度最差的方式):
a) 目标有可见文字(按钮/菜单/列表标题/标签/输入框提示)→ de_tap_text 直接给文字,
一次完成「找到并点击」,原生控件与 WebView/图片渲染文字都支持
b) 文字有歧义或 de_tap_text 未命中 → de_ui_tree(limit=80) 看可点元素后
用 de_tap_element(text/text_contains 匹配)
c) 只有纯图形目标(视频画面/无文字图标且树里没有)才用 de_tap 给坐标——
坐标只需大致对准目标中心,服务端会自动吸附到该处可点击元素中心,无需精算
4. de_tap 点击后若返回 snapped=true 表示已吸附命中元素(可核对 label);
截图判断界面变化=点击成功,无变化=未命中
5. 输入文字:先 de_tap_text 或 de_tap 点中输入框,再 de_type_text 输入
6. 每次关键操作后再次 de_screenshot 验证结果,直到完成用户目标
7. 若点击后截图无任何变化:不要重复点同一坐标,换 de_tap_text/de_tap_element
重新定位,或先 de_ui_tree 确认元素文案再试
8. 完成或失败时用中文总结:做了什么、当前状态、需要用户注意的事项
9. 设备不可用/操作失败时如实报告错误,不要臆测成功
10. 效率:界面未变化时不要重复截图/点击同一位置;每步都要推进目标;
若连续 6 步无进展(截图内容未变/操作无效),停止并总结原因,不要空转
可用工具清单将由系统提供。"""
class Agent:
def __init__(self, settings: AgentSettings = None):
self.s = settings or S
self.tools_schema = [] # OpenAI function schema
self.messages = []
# 回调(Web 展示用,均可选):
# on_delta(text, kind) kind: content | reasoning —— 流式文本增量
# on_tool(step) step: {tool, args, result, image} —— 工具调用完成
self.on_delta = None
self.on_tool = None
self._mcp = None
# ---------- MCP 工具桥 ----------
async def _load_tools(self):
"""从 MCP Server 拉工具,转 OpenAI function schema。"""
# 短连接超时:MCP 不可用时快速失败(默认会无限重试卡死线程)
self._mcp = Client(self.s.mcp_url, timeout=10.0, init_timeout=10.0)
await self._mcp.__aenter__()
tools = await self._mcp.list_tools()
self.tools_schema = []
for t in tools:
# MCP SDK v2 改名 input_schema,兼容新旧字段
schema = getattr(t, "input_schema", None) or getattr(t, "inputSchema", {})
name = getattr(t, "name", "")
desc = getattr(t, "description", "") or ""
self.tools_schema.append({
"type": "function",
"function": {"name": name, "description": desc,
"parameters": schema}})
_log.info("MCP 工具已加载: %s", [s["function"]["name"] for s in self.tools_schema])
async def close(self):
if self._mcp:
try:
await self._mcp.__aexit__(None, None, None)
except Exception:
pass
# ---------- 模型调用(流式) ----------
async def _chat_stream(self):
"""流式 chat/completions:逐 chunk 产出 JSON(async generator)。"""
body = {
"model": self.s.model,
"messages": self.messages,
"tools": self.tools_schema if self.tools_schema else None,
"max_tokens": 4096,
"stream": True,
}
headers = {"Authorization": f"Bearer {self.s.api_key}",
"Content-Type": "application/json"}
url = f"{self.s.api_base.rstrip('/')}/chat/completions"
async with httpx.AsyncClient(timeout=self.s.request_timeout) as client:
async with client.stream("POST", url, json=body, headers=headers) as r:
if r.status_code != 200:
text = (await r.aread()).decode(errors="replace")
raise RuntimeError(f"模型 API HTTP {r.status_code}: {text[:300]}")
async for line in r.aiter_lines():
if not line.startswith("data:"):
continue
data = line[5:].strip()
if data == "[DONE]":
break
try:
yield json.loads(data)
except json.JSONDecodeError:
continue
# ---------- 工具执行 ----------
async def _execute_tool(self, name, arguments):
"""执行 MCP 工具,返回 (文本结果, image_data_or_None)。"""
args = json.loads(arguments) if isinstance(arguments, str) else (arguments or {})
_log.info("执行工具 %s %s", name, args)
try:
result = await self._mcp.call_tool(name, args)
data = getattr(result, "data", result)
except Exception as e:
return {"ok": False, "error": f"工具执行失败: {e}"}, None
# de_screenshot:图像分离(作为 image_url 追加给模型看 + on_tool 缩略展示)
image_b64 = None
text_result = data
if name == "de_screenshot" and isinstance(data, dict) and data.get("ok"):
img = (data.get("data") or {}).get("image") or {}
if img.get("data"):
text_result = {k: v for k, v in (data.get("data") or {}).items()
if k != "image"}
image_b64 = img["data"]
if self.on_tool:
try:
self.on_tool({"tool": name, "args": args,
"result": text_result, "image": image_b64})
except Exception:
pass
return text_result, image_b64
def _repair_tool_messages(self):
"""修复 tool_calls 配对不完整:从尾部移除「assistant 带 tool_calls 但其后
tool 回应不足」的消息段(流中断可能丢失分片,400 重试前自愈)。"""
for i in range(len(self.messages) - 1, -1, -1):
m = self.messages[i]
if m.get("role") == "assistant" and m.get("tool_calls"):
# 统计其后 tool 消息数是否匹配
need = len(m["tool_calls"])
have = sum(1 for x in self.messages[i + 1:]
if x.get("role") == "tool")
if have < need:
_log.warning("修复不完整 tool_calls 段(need=%d have=%d),回退 %d 条消息",
need, have, len(self.messages) - i)
self.messages = self.messages[:i]
return
# ---------- 主循环(流式) ----------
async def run_stream(self, prompt: str, serial: str = "",
history=None, on_delta=None, on_tool=None,
should_stop=None, extra_context=None):
"""流式执行一轮指令,返回最终完整文本。
history:上一轮的 [{"role": "user"|"assistant", "content": 文本}] 列表,
用于多轮对话保持上下文(截图/工具消息不入历史,控制 token)。
on_delta(text, kind):content/reasoning 文本增量(实时推给前端)
on_tool(step):工具调用完成(实时显示 MCP 步骤)
should_stop:可调用 fn() -> bool,每轮模型调用前检查(用户中断用)
extra_context:附加文本(经验记忆注入,放在 system prompt 末尾)
"""
self.on_delta = on_delta
self.on_tool = on_tool
target = serial or self.s.default_serial
sys_txt = SYSTEM_PROMPT
if target:
sys_txt += f"\n\n本次默认目标设备 serial:{target}(未指定设备时用它)。"
if extra_context:
sys_txt += f"\n\n## 过往成功经验参考(同类任务,可参考其中的操作套路,但要根据当前界面灵活调整)\n{extra_context}"
self.messages = [{"role": "system", "content": sys_txt}]
for h in (history or []):
if h.get("role") in ("user", "assistant") and h.get("content"):
self.messages.append({"role": h["role"], "content": h["content"]})
self.messages.append({"role": "user", "content": prompt})
for _step in range(self.s.max_steps):
if should_stop and should_stop():
_log.info("Agent 被用户中断")
return "(已按用户要求停止操作)"
content_parts = []
tool_acc = {} # index -> {id, name, args}
has_tool = False
retried = False
while True:
try:
async for chunk in self._chat_stream():
choice = (chunk.get("choices") or [{}])[0]
delta = choice.get("delta") or {}
text = delta.get("content")
if text:
content_parts.append(text)
if on_delta:
on_delta(text, "content")
rtext = delta.get("reasoning_content")
if rtext:
if on_delta:
on_delta(rtext, "reasoning")
for tc in delta.get("tool_calls") or []:
has_tool = True
idx = tc.get("index", 0)
acc = tool_acc.setdefault(idx, {"id": "", "name": "", "args": ""})
if tc.get("id"):
acc["id"] = tc["id"]
fn = tc.get("function") or {}
if fn.get("name"):
acc["name"] += fn["name"]
if fn.get("arguments"):
acc["args"] += fn["arguments"]
break
except RuntimeError as e:
# 流中断导致 tool_calls 分片丢失:修复后重试一次
if ("tool_calls" in str(e) or "must be followed" in str(e)) and not retried:
_log.warning("tool_calls 消息不完整,自愈重试")
self._repair_tool_messages()
retried = True
continue
raise
full_content = "".join(content_parts)
if has_tool:
# 组装 assistant 消息(含 tool_calls)并执行工具
tcs = []
for idx in sorted(tool_acc):
acc = tool_acc[idx]
tcs.append({"id": acc["id"] or f"call_{idx}",
"type": "function",
"function": {"name": acc["name"],
"arguments": acc["args"]}})
self.messages.append({"role": "assistant",
"content": full_content,
"tool_calls": tcs})
for tc in tcs:
fn = tc["function"]
text_result, image_b64 = await self._execute_tool(
fn["name"], fn["arguments"])
self.messages.append({
"role": "tool", "tool_call_id": tc["id"],
"content": json.dumps(text_result, ensure_ascii=False)[:4000]})
if image_b64:
self.messages.append({
"role": "user",
"content": [{"type": "text",
"text": "这是最新屏幕截图,请基于它继续判断"},
{"type": "image_url",
"image_url": {"url":
f"data:image/jpeg;base64,{image_b64}"}}]})
continue
# 无工具调用:本轮即最终回答
return full_content
# 步骤超限:不带工具让模型做最终总结(避免机械提示,给用户有意义的结论)
try:
_log.warning("达到最大步骤数,请求模型收尾总结")
saved_tools = self.tools_schema
self.tools_schema = []
self.messages.append({"role": "user",
"content": "已达最大操作步骤数,请立即用中文总结:"
"已完成的部分、当前设备状态、未能完成的原因与下一步建议。"
"不要调用任何工具。"})
parts = []
async for chunk in self._chat_stream():
delta = (chunk.get("choices") or [{}])[0].get("delta") or {}
text = delta.get("content")
if text:
parts.append(text)
if on_delta:
on_delta(text, "content")
self.tools_schema = saved_tools
summary = "".join(parts)
return summary or "(已达步骤上限,模型未能生成总结)"
except Exception as e:
return f"(已达最大步骤数,且收尾总结失败: {e})"
# ---------- 非流式(CLI) ----------
async def run(self, prompt: str, serial: str = "") -> str:
"""非流式执行,返回最终文本(CLI 用,内部走流式收集)。"""
return await self.run_stream(prompt, serial)
+60
View File
@@ -0,0 +1,60 @@
"""Agent CLI:命令行给 AI 下指令控制手机。
用法:
export AGENT_API_KEY=sk-xxx # DeepSeek API Key
export AGENT_DEFAULT_SERIAL=192.168.20.66:5555 # 可选:默认设备
python -m mcp_agent.cli "打开抖音,搜索奚学东,截个图"
python -m mcp_agent.cli -s 192.168.20.66:5555 "打开微信"
python -m mcp_agent.cli # 交互模式(exit 退出)
"""
import argparse
import asyncio
import logging
import os
from mcp_agent.agent import Agent
logging.basicConfig(level=logging.INFO,
format="%(asctime)s %(levelname)s [%(name)s] %(message)s")
_log = logging.getLogger("cli")
async def _run_once(prompt, serial):
if not os.environ.get("AGENT_API_KEY") and not os.environ.get("DEEPSEEK_API_KEY"):
print("❌ 未配置 API Key:export AGENT_API_KEY=sk-xxx")
return
agent = Agent()
try:
await agent._load_tools()
print(f"🤖 指令: {prompt}\n")
answer = await agent.run(prompt, serial)
print(f"\n✅ 结果:\n{answer}")
finally:
await agent.close()
def main():
ap = argparse.ArgumentParser(description="MCP 手机控制 Agent CLI")
ap.add_argument("prompt", nargs="?", default="", help="指令(不填则交互模式)")
ap.add_argument("-s", "--serial", default="", help="目标设备 serial")
args = ap.parse_args()
if args.prompt:
asyncio.run(_run_once(args.prompt, args.serial))
return
print("交互模式:输入指令(如「打开抖音搜索奚学东」),exit 退出")
while True:
try:
prompt = input("\n指令> ").strip()
except (EOFError, KeyboardInterrupt):
break
if not prompt:
continue
if prompt.lower() in ("exit", "quit", "退出"):
break
asyncio.run(_run_once(prompt, args.serial))
if __name__ == "__main__":
main()
+30
View File
@@ -0,0 +1,30 @@
"""Agent 层配置(第三方 LLM API,OpenAI 兼容格式)。
DeepSeek 官方 API:https://api.deepseek.com(OpenAI 兼容)。
生产用 .env 注入 DEEPSEEK_API_KEY,不要提交 git。
"""
import os
def _env(key, default):
return os.environ.get(key, default)
class AgentSettings:
# 模型 API(OpenAI 兼容)
api_base = _env("AGENT_API_BASE", "https://api.deepseek.com")
api_key = _env("AGENT_API_KEY", _env("DEEPSEEK_API_KEY", ""))
model = _env("AGENT_MODEL", "deepseek-v4-flash-vision-exp")
# MCP Server(工具源)
mcp_url = _env("AGENT_MCP_URL", "http://127.0.0.1:8033/mcp")
# 默认目标设备(命令行不指定 serial 时用它;空则让模型先 de_list_devices)
default_serial = _env("AGENT_DEFAULT_SERIAL", "")
# Agent 循环上限与请求超时
max_steps = int(_env("AGENT_MAX_STEPS", "40"))
request_timeout = float(_env("AGENT_TIMEOUT", "120"))
# system prompt 语言
language = _env("AGENT_LANG", "zh")
+1
View File
@@ -0,0 +1 @@
"""MCP 手机控制 Server 包。"""
+27
View File
@@ -0,0 +1,27 @@
"""审计日志:每次 MCP 调用记录一行(含只读)。"""
import json
import os
from datetime import datetime
_file = None
def init(path):
global _file
os.makedirs(os.path.dirname(path), exist_ok=True)
_file = open(path, "a", encoding="utf-8")
audit("system", "startup", "", "")
def audit(tool, serial, args_summary, result):
global _file
if _file is None:
return
rec = {"ts": datetime.now().isoformat(timespec="seconds"),
"tool": tool, "serial": serial,
"args": str(args_summary)[:200], "result": str(result)[:200]}
try:
_file.write(json.dumps(rec, ensure_ascii=False) + "\n")
_file.flush()
except Exception:
pass
+32
View File
@@ -0,0 +1,32 @@
"""MCP Server 配置(环境变量,生产用 .env 注入)。"""
import os
def _env(key, default):
return os.environ.get(key, default)
class Settings:
# 平台(auto_control)地址与账号
platform_url = _env("MCP_PLATFORM_URL", "http://127.0.0.1:18050")
platform_user = _env("MCP_PLATFORM_USER", "admin")
platform_pass = _env("MCP_PLATFORM_PASS", "")
# 安全
allow_write = _env("MCP_ALLOW_WRITE", "0") == "1" # 写操作门控(默认只读)
allowed_serials = [s.strip() for s in
_env("MCP_ALLOWED_SERIALS", "").split(",") if s.strip()] # 空=不限
# 传输
http_host = _env("MCP_HTTP_HOST", "0.0.0.0")
http_port = int(_env("MCP_HTTP_PORT", "8033"))
# 截图
screenshot_width = int(_env("MCP_SCREENSHOT_WIDTH", "540"))
jpeg_quality = int(_env("MCP_JPEG_QUALITY", "70"))
# 审计
audit_file = _env("MCP_AUDIT_FILE", "/var/log/mcp/audit.log")
# 平台请求超时(秒)
platform_timeout = float(_env("MCP_PLATFORM_TIMEOUT", "30"))
+110
View File
@@ -0,0 +1,110 @@
"""MCP 直连操作封装:adb/u2/OCR/剪贴板——轻量通道优先。
为什么直连而不是全部走平台 HTTP:
- adb monkey 打开 App / am force-stop:一条 adb 命令,不建 u2 连接(省时省 token)
- 输入文字:u2 EditText.set_text 直接设文本(比剪贴板+粘贴少依赖)
- OCR:平台 RapidOCR(本地模型,截图即识别)
平台 REST 无法表达的操作(无独立端点)在此封装;安全(白名单/写门控)
仍在 MCP 工具层统一把关。绝不 kill-server / 绝不 disconnect(项目红线)。
"""
import re
import subprocess
from config import ADB_PATH
_log = None # mcp 层 logging 由调用方配置
def _adb(serial, *args, timeout=25):
"""对指定设备执行 adb shell 命令,返回输出文本(超时返回空)。"""
try:
r = subprocess.run([ADB_PATH, "-s", serial, "shell", *args],
capture_output=True, timeout=timeout)
return (r.stdout or b"").decode("utf-8", errors="replace")
except subprocess.TimeoutExpired:
return ""
except Exception:
return ""
def open_app(serial, package):
"""adb monkey 打开 App(无需知道 activity,最轻量)。"""
out = _adb(serial, "monkey", "-p", package,
"-c", "android.intent.category.LAUNCHER", "1")
return out or ""
def stop_app(serial, package):
"""强制停止 App(am force-stop)。"""
return _adb(serial, "am", "force-stop", package)
def foreground_app(serial):
"""当前前台 App 包名(dumpsys window;mCurrentFocus 为空时 fallback mFocusedApp)。"""
out = _adb(serial, "dumpsys", "window", timeout=15) or ""
m = re.search(r"mCurrentFocus=.*?([\w.]+)/", out)
if m:
return m.group(1)
# 部分 MIUI 焦点在 IME/过渡时 mCurrentFocus=null,用 mFocusedApp 兜底
m2 = re.search(r"mFocusedApp=.*?([\w.]+)/", out)
return m2.group(1) if m2 else ""
def list_apps(serial, keyword=""):
"""第三方已装应用包名列表(pm list packages -3,可关键词过滤)。"""
out = _adb(serial, "pm", "list", "packages", "-3")
pkgs = []
for line in (out or "").splitlines():
p = line.replace("package:", "").strip()
if p and (not keyword or keyword.lower() in p.lower()):
pkgs.append(p)
return pkgs
def type_text(serial, text):
"""向当前界面输入框输入文字(u2 定位 EditText set_text,支持中文)。
返回 (ok, msg)。
"""
import uiautomator2 as u2
d = u2.connect(serial)
# 优先聚焦输入框;找不到则第一个 EditText(与任务 input_text 兜底一致)
try:
el = d(focused=True)
if el.exists:
el.set_text(text)
return True, "已输入到聚焦输入框"
except Exception:
pass
try:
edit = d(className="android.widget.EditText")
if edit.exists:
edit.set_text(text)
return True, "已输入到输入框"
except Exception as e:
return False, f"输入失败: {type(e).__name__}: {str(e)[:100]}"
return False, "未找到输入框(请先点击输入框或提供界面信息)"
def set_clipboard(serial, text):
"""剪贴板注入(ClipInject 通道,读回验证)。"""
from core.clipboard_helper import inject_clipboard
return inject_clipboard(serial, text)
def ocr(serial):
"""截屏 + RapidOCR 识别,返回 [{text, score, box}]。"""
import uiautomator2 as u2
from core.ocr import recognize
d = u2.connect(serial)
img = d.screenshot()
if img is None:
return []
return recognize(img)
def read_clipboard(serial):
"""读设备剪贴板(u2)。"""
import uiautomator2 as u2
d = u2.connect(serial)
return d.clipboard or ""
+520
View File
@@ -0,0 +1,520 @@
"""MCP 手机控制 Server(M0:设备列表/截图/点击/滑动)。
运行:MCP_ALLOW_WRITE=1 python -m mcp_server.mcp_server
客户端:Streamable HTTP @ http://<host>:8033/mcp
"""
import base64
import io
import logging
from fastmcp import FastMCP
from PIL import Image
from mcp_server import audit, config
from mcp_server.platform_client import PlatformClient, PlatformError
logging.basicConfig(level=logging.INFO,
format="%(asctime)s %(levelname)s [%(name)s] %(message)s")
_log = logging.getLogger("mcp")
S = config.Settings()
# 坐标空间缓存:serial -> (display_w, display_h, native_w, native_h)
# de_screenshot 返回的图与 de_tap/de_swipe 的坐标同一空间(display),
# server 按比例换算为设备原生坐标(模型只感知截图坐标系,不感知原生分辨率)。
_coord = {}
audit.init(S.audit_file)
mcp = FastMCP("mobile-control")
_platform = None
def platform():
"""惰性初始化平台客户端(启动即登录,失败明确报错)。"""
global _platform
if _platform is None:
_platform = PlatformClient(S.platform_url, S.platform_user,
S.platform_pass, S.platform_timeout)
return _platform
def _check_serial(serial):
"""白名单校验:未配置时限制为平台设备池(enabled)设备。"""
if not serial:
raise PlatformError("invalid_param", "缺少 serial")
if S.allowed_serials and serial not in S.allowed_serials:
raise PlatformError("device_not_allowed", f"设备 {serial} 不在白名单")
return serial
def _check_write():
if not S.allow_write:
raise PlatformError("write_disabled", "写操作未启用(MCP_ALLOW_WRITE=1 开启)")
# 设备任务占用锁:serial -> (ts, worker_status, task_job)。AI 写操作前检查,
# 任务 running/connecting 的设备拒绝操作(AI 不与任务抢设备)。5s 缓存。
_busy_cache = {}
def _ensure_device_free(serial):
"""写操作前检查设备是否有任务在跑(worker running/connecting → 拒绝)。"""
import time
now = time.time()
c = _busy_cache.get(serial)
if not c or now - c[0] > 5:
try:
devs = platform().list_devices()
info = next((d for d in devs if d.get("serial") == serial), {})
c = (now, info.get("worker_status") or "idle",
info.get("task_job") or "")
_busy_cache[serial] = c
except Exception:
return # 状态查询失败不阻塞(操作失败会另行报错)
if c[1] in ("running", "connecting"):
raise PlatformError(
"device_busy",
f"设备正在执行任务「{c[2] or '未知'}」——AI 不与任务抢设备,"
f"任务结束后才能操作(可在平台任务页先停止任务)")
def _to_native(serial, x, y):
"""截图坐标 → 设备原生坐标(按最近一次截图的比例换算)。"""
c = _coord.get(serial)
if not c:
raise PlatformError("invalid_param",
"请先对该设备执行 de_screenshot(需要建立坐标空间)")
dw, dh, nw, nh = c
return (round(x * nw / dw), round(y * nh / dh))
def _err(e: PlatformError):
return {"ok": False, "error": {"code": e.code, "message": e.message}}
def _ok(data):
return {"ok": True, "data": data}
@mcp.tool()
def de_list_devices() -> dict:
"""列出可控制设备:serial/在线状态/型号/任务状态/前台 App。
返回 [{serial, model, online, task_job, worker_status, foreground_app}]。
"""
try:
devs = platform().list_devices()
except PlatformError as e:
return _err(e)
out = []
for d in devs:
out.append({
"serial": d.get("serial"),
"model": d.get("model") or d.get("device_name") or "",
"online": bool(d.get("present")),
"task_job": d.get("task_job") or "",
"worker_status": d.get("worker_status") or "idle",
"foreground_app": d.get("foreground_app") or "",
})
audit.audit("de_list_devices", "", "", f"{len(out)} 台")
return _ok(out)
@mcp.tool()
def de_screenshot(serial: str) -> dict:
"""截取设备屏幕并返回图像(image/jpeg,宽 ≤540px)。
同时返回 {width, height, screen_state}。多模态客户端可直接看图。
"""
try:
serial = _check_serial(serial)
jpeg, screen_state = platform().screenshot(serial)
img = Image.open(io.BytesIO(jpeg))
w, h = img.size
if w > S.screenshot_width:
ratio = S.screenshot_width / w
img = img.resize((S.screenshot_width, int(h * ratio)))
buf = io.BytesIO()
img.convert("RGB").save(buf, "JPEG", quality=S.jpeg_quality)
data = base64.b64encode(buf.getvalue()).decode()
# 记录坐标空间(display=返回图尺寸,native=设备原生),供 tap/swipe 换算
nw, nh = platform().screen_size(serial)
_coord[serial] = (img.size[0], img.size[1], nw, nh)
except PlatformError as e:
return _err(e)
except Exception as e:
return _err(PlatformError("platform_unavailable", f"截图处理失败: {e}"))
audit.audit("de_screenshot", serial, f"{w}x{h}", "ok")
dw, dh, nw, nh = _coord[serial]
return _ok({"image": {"type": "image", "data": data,
"mimeType": "image/jpeg"},
"width": dw, "height": dh,
"native_size": {"width": nw, "height": nh},
"screen_state": screen_state})
@mcp.tool()
def de_tap(serial: str, x: int, y: int) -> dict:
"""点击设备屏幕指定坐标(坐标空间 = de_screenshot 的图像坐标)。
自动吸附:若该点落在某个可点击元素内,实际点击会改为该元素的中心——
坐标只需大致对准目标即可(模型视觉定位常有偏差,吸附保证点准);
点在空白处则按原坐标点击。返回中的 snapped/label 可核对吸附结果。
"""
try:
_check_write()
serial = _check_serial(serial)
_ensure_device_free(serial)
if x < 0 or y < 0:
raise PlatformError("invalid_param", "坐标不能为负")
nx, ny = _to_native(serial, x, y)
res = platform().tap(serial, nx, ny, snap=True)
except PlatformError as e:
return _err(e)
audit.audit("de_tap", serial,
f"({x},{y})->native({nx},{ny})"
+ (f" 吸附[{res.get('label')}]" if res.get("snapped") else ""),
"ok")
return _ok({"action": "tap", "serial": serial, "x": x, "y": y,
"snapped": bool(res.get("snapped")),
"label": res.get("label") or ""})
@mcp.tool()
def de_swipe(serial: str, x1: int, y1: int, x2: int, y2: int,
duration: float = 0.2) -> dict:
"""在设备屏幕上滑动(坐标空间同 de_tap:截图坐标,server 换算原生)。"""
try:
_check_write()
serial = _check_serial(serial)
_ensure_device_free(serial)
nx1, ny1 = _to_native(serial, x1, y1)
nx2, ny2 = _to_native(serial, x2, y2)
platform().swipe(serial, nx1, ny1, nx2, ny2, duration)
except PlatformError as e:
return _err(e)
audit.audit("de_swipe", serial, f"({x1},{y1})->({x2},{y2})", "ok")
return _ok({"action": "swipe", "serial": serial})
_KEYS = ("back", "home", "recent", "menu", "power", "volume_up",
"volume_down", "enter", "delete", "search", "camera")
@mcp.tool()
def de_ui_tree(serial: str, limit: int = 150) -> dict:
"""获取当前界面元素树(文本 JSON):每元素含 text/resource_id/description/class/bounds。
可点击元素排在前面(可点性优先)。多数场景不需要读整棵树——直接给
de_tap_text 一个屏幕上可见的文字即可自动定位点击;本工具用于确认界面
上有什么、元素文案是否与预想一致。limit 控制返回条数(默认 150,防 token 膨胀)。
"""
try:
serial = _check_serial(serial)
if limit < 1 or limit > 300:
raise PlatformError("invalid_param", "limit 需在 1-300 之间")
els = platform().ui_elements(serial)
except PlatformError as e:
return _err(e)
# 精简输出:去掉 suggested/深度噪音,保留可定位属性;可点击优先、有文案优先
slim = []
for e in els:
slim.append({
"text": e.get("text", "")[:50],
"id": e.get("resource_id", "")[:80],
"desc": e.get("description", "")[:50],
"class": e.get("class", "").split(".")[-1],
"clickable": e.get("clickable", "") == "true",
"bounds": e.get("bounds", ""),
})
slim.sort(key=lambda x: (not x["clickable"], not (x["text"] or x["desc"])))
audit.audit("de_ui_tree", serial, "", f"{len(slim)} 元素")
return _ok({"count": len(slim), "elements": slim[:limit]})
@mcp.tool()
def de_tap_element(serial: str, by: str, value: str, index: int = 1) -> dict:
"""按元素点击(不需要坐标):by=text|id|desc|text_contains|desc_contains。
text/id/desc 为精确匹配;text_contains/desc_contains 为子串模糊匹配
(只记得部分文字时用,如 by=text_contains value=搜索)。
元素驱动操作比坐标可靠(界面变化自适应);元素不存在时返回错误,
可改用 de_ui_tree 查元素 / de_tap_text 按屏幕文字点 / de_tap 坐标兜底。
index 用于多命中取第几个(默认 1)。
"""
try:
_check_write()
serial = _check_serial(serial)
_ensure_device_free(serial)
if by not in ("text", "id", "desc", "text_contains", "desc_contains"):
raise PlatformError("invalid_param",
"by 可选 text/id/desc/text_contains/desc_contains")
if not value or index < 1:
raise PlatformError("invalid_param", "value 不能为空且 index>=1")
import uiautomator2 as u2
d = u2.connect(serial)
kw = {"text": value} if by == "text" else (
{"resourceId": value} if by == "id" else (
{"description": value} if by == "desc" else (
{"textContains": value} if by == "text_contains"
else {"descriptionContains": value})))
if index > 1:
kw["instance"] = index - 1
el = d(**kw)
if not el.exists:
raise PlatformError("device_offline",
f"未找到元素({by}={value},index={index})——"
f"建议 de_ui_tree 查看实际元素或 de_tap 用坐标")
el.click()
except PlatformError as e:
return _err(e)
except Exception as e:
return _err(PlatformError("platform_unavailable",
f"元素点击失败: {type(e).__name__}: {str(e)[:100]}"))
audit.audit("de_tap_element", serial, f"{by}={value}#{index}", "ok")
return _ok({"action": "tap_element", "serial": serial,
"by": by, "value": value, "index": index})
@mcp.tool()
def de_read_clipboard(serial: str) -> dict:
"""读取设备当前剪贴板内容(ClipInject/atx-agent 通道读回,M1 起支持)。"""
try:
serial = _check_serial(serial)
import uiautomator2 as u2
d = u2.connect(serial)
text = d.clipboard
except PlatformError as e:
return _err(e)
except Exception as e:
return _err(PlatformError("platform_unavailable",
f"剪贴板读取失败: {type(e).__name__}: {str(e)[:100]}"))
audit.audit("de_read_clipboard", serial, "", f"{len(text or '')} 字符")
return _ok({"clipboard": text or ""})
@mcp.tool()
def de_wake(serial: str) -> dict:
"""点亮设备屏幕并解锁(熄屏时先调用它再截图)。"""
try:
_check_write()
serial = _check_serial(serial)
_ensure_device_free(serial)
platform().wake(serial)
except PlatformError as e:
return _err(e)
audit.audit("de_wake", serial, "", "ok")
return _ok({"action": "wake", "serial": serial})
@mcp.tool()
def de_press_key(serial: str, key: str) -> dict:
"""按设备按键:back/home/recent/menu/power/enter/delete 等。"""
try:
_check_write()
serial = _check_serial(serial)
_ensure_device_free(serial)
if key not in _KEYS:
raise PlatformError("invalid_param", f"不支持的按键: {key}(可选 {_KEYS})")
platform().press_key(serial, key)
except PlatformError as e:
return _err(e)
audit.audit("de_press_key", serial, key, "ok")
return _ok({"action": "press_key", "serial": serial, "key": key})
# ================== L2 扩展工具(轻量通道:adb/u2 直连,省 token) ==================
@mcp.tool()
def de_open_app(serial: str, package: str) -> dict:
"""打开 App(adb monkey 直启,最快路径)。package 为应用包名,如 com.ss.android.ugc.aweme。"""
try:
_check_write()
serial = _check_serial(serial)
_ensure_device_free(serial)
if not package:
raise PlatformError("invalid_param", "缺少包名")
from mcp_server import direct_ops
direct_ops.open_app(serial, package)
except PlatformError as e:
return _err(e)
except Exception as e:
return _err(PlatformError("platform_unavailable", f"打开失败: {str(e)[:100]}"))
audit.audit("de_open_app", serial, package, "ok")
return _ok({"action": "open_app", "package": package})
@mcp.tool()
def de_stop_app(serial: str, package: str) -> dict:
"""强制停止 App(am force-stop)。"""
try:
_check_write()
serial = _check_serial(serial)
_ensure_device_free(serial)
if not package:
raise PlatformError("invalid_param", "缺少包名")
from mcp_server import direct_ops
direct_ops.stop_app(serial, package)
except PlatformError as e:
return _err(e)
except Exception as e:
return _err(PlatformError("platform_unavailable", f"停止失败: {str(e)[:100]}"))
audit.audit("de_stop_app", serial, package, "ok")
return _ok({"action": "stop_app", "package": package})
@mcp.tool()
def de_foreground_app(serial: str) -> dict:
"""查询设备当前前台运行的 App 包名(轻量 dumpsys,不打扰设备)。"""
try:
serial = _check_serial(serial)
from mcp_server import direct_ops
pkg = direct_ops.foreground_app(serial)
except PlatformError as e:
return _err(e)
except Exception as e:
return _err(PlatformError("platform_unavailable", f"查询失败: {str(e)[:100]}"))
audit.audit("de_foreground_app", serial, "", pkg or "未知")
return _ok({"foreground_app": pkg or ""})
@mcp.tool()
def de_type_text(serial: str, text: str) -> dict:
"""向设备当前输入框输入文字(支持中文,直接 set_text 不依赖剪贴板)。"""
try:
_check_write()
serial = _check_serial(serial)
_ensure_device_free(serial)
if not text:
raise PlatformError("invalid_param", "内容为空")
from mcp_server import direct_ops
ok, msg = direct_ops.type_text(serial, text)
if not ok:
raise PlatformError("device_offline", msg)
except PlatformError as e:
return _err(e)
except Exception as e:
return _err(PlatformError("platform_unavailable", f"输入失败: {str(e)[:100]}"))
audit.audit("de_type_text", serial, f"{text[:30]}...", "ok")
return _ok({"action": "type_text"})
@mcp.tool()
def de_set_clipboard(serial: str, text: str) -> dict:
"""写入设备剪贴板(ClipInject 通道,读回验证)。"""
try:
_check_write()
serial = _check_serial(serial)
_ensure_device_free(serial)
if not text:
raise PlatformError("invalid_param", "内容为空")
from mcp_server import direct_ops
ok, msg = direct_ops.set_clipboard(serial, text)
if not ok:
raise PlatformError("device_offline", msg)
except PlatformError as e:
return _err(e)
except Exception as e:
return _err(PlatformError("platform_unavailable", f"注入失败: {str(e)[:100]}"))
audit.audit("de_set_clipboard", serial, f"{text[:30]}...", "ok")
return _ok({"action": "set_clipboard"})
@mcp.tool()
def de_sleep(serial: str) -> dict:
"""熄灭设备屏幕(运行中任务会中断,慎用)。"""
try:
_check_write()
serial = _check_serial(serial)
_ensure_device_free(serial)
platform().sleep(serial)
except PlatformError as e:
return _err(e)
audit.audit("de_sleep", serial, "", "ok")
return _ok({"action": "sleep"})
@mcp.tool()
def de_ocr(serial: str) -> dict:
"""OCR 识别当前屏幕文字(图片/画布/WebView 里 UI 树没有的文字也能识别)。
返回 [{text, score}]——搜屏幕关键词后可配合 de_tap_element/de_tap 操作。
"""
try:
serial = _check_serial(serial)
from mcp_server import direct_ops
results = direct_ops.ocr(serial)
except PlatformError as e:
return _err(e)
except Exception as e:
return _err(PlatformError("platform_unavailable", f"OCR 失败: {str(e)[:100]}"))
slim = [{"text": r["text"], "score": round(r["score"], 2)} for r in results]
audit.audit("de_ocr", serial, "", f"{len(slim)} 条")
return _ok({"count": len(slim), "texts": slim[:100]})
@mcp.tool()
def de_tap_text(serial: str, text: str) -> dict:
"""点击屏幕上显示该文字的位置(语义点击:一次调用完成「找到并点击」,无需坐标)。
想点带文字的按钮/列表项/标签/链接时用它:text 只需是屏幕上可见文字的
一部分(子串匹配,如「搜索」「立即购买」)。原生控件直接命中;
WebView/图片/画布里渲染的文字自动走 OCR 兜底。多命中点第一处(想点
更靠下的请把文字换独特些)。屏幕确实没有该文字时返回错误提示,
请截图确认后换关键词。比 de_tap 坐标点击可靠,涉及文字目标时优先使用。
"""
try:
_check_write()
serial = _check_serial(serial)
_ensure_device_free(serial)
if not text or len(text) > 100:
raise PlatformError("invalid_param", "text 不能为空且 ≤100 字符")
res = platform().tap_text(serial, text)
except PlatformError as e:
return _err(e)
if not res.get("found"):
err = PlatformError("text_not_found",
f"屏幕上未找到文字「{text}」——先 de_screenshot 看当前界面,"
f"换用屏幕上实际存在的文字;若文字在需滑动后才可见请先滑动")
audit.audit("de_tap_text", serial, f"「{text[:30]}」", "未找到")
return _err(err)
audit.audit("de_tap_text", serial,
f"「{text[:30]}」via {res.get('method')} @({res.get('x')},{res.get('y')})", "ok")
return _ok({"action": "tap_text", "serial": serial, "text": text,
"method": res.get("method"), "matched": res.get("matched") or text,
"x": res.get("x"), "y": res.get("y")})
@mcp.tool()
def de_list_apps(serial: str, keyword: str = "") -> dict:
"""列出设备第三方已装应用包名(可关键词过滤,如 keyword='douyin' 找抖音)。"""
try:
serial = _check_serial(serial)
from mcp_server import direct_ops
pkgs = direct_ops.list_apps(serial, keyword)
except PlatformError as e:
return _err(e)
except Exception as e:
return _err(PlatformError("platform_unavailable", f"查询失败: {str(e)[:100]}"))
audit.audit("de_list_apps", serial, keyword or "", f"{len(pkgs)} 个")
return _ok({"count": len(pkgs), "packages": pkgs[:200]})
@mcp.tool()
def de_list_tasks() -> dict:
"""列出平台任务计划(名称/启用状态/调度),供了解可自动化的任务。"""
try:
tasks = platform().list_tasks()
except PlatformError as e:
return _err(e)
audit.audit("de_list_tasks", "", "", f"{len(tasks)} 个")
return _ok({"tasks": tasks})
def main():
_log.info("MCP mobile-control 启动: allow_write=%s port=%s",
S.allow_write, S.http_port)
mcp.run(transport="http", host=S.http_host, port=S.http_port)
if __name__ == "__main__":
main()
+186
View File
@@ -0,0 +1,186 @@
"""平台(auto_control)HTTP 客户端:登录会话 + CSRF + API 封装。
会话失效自动重登;CSRF token 每次登录后获取,POST 必带。
只读接口(GET)与写接口(POST)分离封装,写操作由 MCP 门控层决定是否调用。
"""
import httpx
import logging
_log = logging.getLogger("mcp.platform")
_CSRF_HEADER = "X-CSRF-Token"
class PlatformError(Exception):
def __init__(self, code, message):
super().__init__(message)
self.code = code
self.message = message
class PlatformClient:
def __init__(self, url, user, password, timeout=30.0):
self.url = url.rstrip("/")
self.user = user
self.password = password
self._client = httpx.Client(base_url=self.url, timeout=timeout,
follow_redirects=True)
self._csrf = ""
self._login()
# ---------- 会话 ----------
def _login(self):
"""登录平台,拿会话 cookie + CSRF token。"""
r = self._client.post("/login", data={
"username": self.user, "password": self.password})
if r.status_code != 302 and r.status_code != 200:
raise PlatformError("platform_unavailable",
f"平台登录失败 HTTP {r.status_code}: {r.text[:120]}")
csrf = self._client.get("/api/csrf")
if csrf.status_code == 200:
self._csrf = (csrf.json() or {}).get("token", "")
_log.info("平台登录成功: %s", self.user)
def _ensure_session(self):
"""会话过期(401/403/302 到登录页)时重登。"""
r = self._client.get("/api/status")
if r.status_code in (401, 403) or r.url.path.endswith("/login"):
_log.warning("会话失效,重新登录")
self._login()
return True
return False
# ---------- 基础请求 ----------
def _get(self, path, **params):
self._ensure_session()
return self._client.get(path, params=params)
def _post(self, path, json=None):
self._ensure_session()
headers = {_CSRF_HEADER: self._csrf} if self._csrf else {}
return self._client.post(path, json=json or {}, headers=headers)
# ---------- 平台 API 封装(M0) ----------
def list_devices(self):
"""设备列表与状态(GET /api/status)。"""
r = self._get("/api/status")
if r.status_code != 200:
raise PlatformError("platform_unavailable",
f"/api/status HTTP {r.status_code}")
j = r.json() or {}
return j.get("devices", [])
def screenshot(self, serial):
"""截图(GET /api/screen/thumb),返回 (jpeg_bytes, screen_state)。"""
r = self._get("/api/screen/thumb", serial=serial)
if r.status_code == 503:
raise PlatformError("device_offline", r.text[:120])
if r.status_code != 200:
raise PlatformError("platform_unavailable",
f"截图 HTTP {r.status_code}")
return r.content, r.headers.get("X-Screen-State", "unknown")
def screen_size(self, serial):
"""屏幕原生分辨率(GET /api/screen/size),返回 (w, h)。"""
r = self._get("/api/screen/size", serial=serial)
if r.status_code == 503:
raise PlatformError("device_offline", r.text[:120])
if r.status_code != 200:
raise PlatformError("platform_unavailable",
f"分辨率 HTTP {r.status_code}")
j = r.json() or {}
if not j.get("ok"):
raise PlatformError("device_offline", str(j.get("error", "取分辨率失败"))[:120])
return int(j["width"]), int(j["height"])
def tap(self, serial, x, y, snap=False):
"""点击(POST /api/screen/tap)。
snap=True:点落在可点击元素内则吸附到元素中心(AI 粗略坐标也能点准)。
返回平台 JSON(含 snapped/x/y/label)。
"""
r = self._post("/api/screen/tap", json={"serial": serial,
"x": int(x), "y": int(y),
"snap": 1 if snap else 0})
return self._check_op(r, "tap")
def tap_text(self, serial, text):
"""按屏幕文字点击(平台解析:UI 树子串匹配 → OCR 兜底)。
返回 {ok, found, method, matched, x, y}——found=false 是业务结果
(屏幕无该文字),非设备错误;设备离线/不可达仍抛 PlatformError。
"""
r = self._post("/api/screen/tap_text",
json={"serial": serial, "text": str(text)})
if r.status_code == 503:
raise PlatformError("device_offline", r.text[:120])
if r.status_code != 200:
raise PlatformError("platform_unavailable",
f"tap_text HTTP {r.status_code}: {r.text[:120]}")
return r.json() or {}
def swipe(self, serial, x1, y1, x2, y2, duration=0.2):
"""滑动(POST /api/screen/swipe)。"""
r = self._post("/api/screen/swipe", json={
"serial": serial, "x1": int(x1), "y1": int(y1),
"x2": int(x2), "y2": int(y2),
"duration": float(duration)})
return self._check_op(r, "swipe")
def ui_elements(self, serial):
"""UI 元素树(GET /api/uiauto/elements,uiautodev 服务)。"""
r = self._get("/api/uiauto/elements", serial=serial)
if r.status_code == 503:
raise PlatformError("device_offline", r.text[:120])
if r.status_code != 200:
raise PlatformError("platform_unavailable",
f"元素树 HTTP {r.status_code}")
j = r.json() or {}
if not j.get("ok"):
raise PlatformError("device_offline", str(j.get("error", "取元素失败"))[:120])
return j.get("elements", [])
def wake(self, serial):
"""亮屏并解锁(POST /api/device/screen_all mode=on)。"""
r = self._post("/api/device/screen_all",
json={"mode": "on", "serials": [serial]})
return self._check_op(r, "wake")
def sleep(self, serial):
"""熄屏(POST /api/device/screen_all mode=off)。"""
r = self._post("/api/device/screen_all",
json={"mode": "off", "serials": [serial]})
return self._check_op(r, "sleep")
def list_tasks(self):
"""任务计划列表(GET /api/jobs)。"""
r = self._get("/api/jobs")
if r.status_code != 200:
raise PlatformError("platform_unavailable",
f"/api/jobs HTTP {r.status_code}")
j = r.json() or {}
tasks = []
for t in j.get("jobs") or []:
tasks.append({"id": t.get("id"), "name": t.get("name"),
"task_type": t.get("task_type"),
"enabled": t.get("enabled"),
"schedule": (t.get("schedule") or {}).get("mode", "")})
return tasks
def press_key(self, serial, key):
"""按键(POST /api/screen/key)。"""
r = self._post("/api/screen/key",
json={"serial": serial, "key": key})
return self._check_op(r, "key")
@staticmethod
def _check_op(r, name):
if r.status_code == 503:
raise PlatformError("device_offline", r.text[:120])
if r.status_code != 200:
raise PlatformError("platform_unavailable",
f"{name} HTTP {r.status_code}: {r.text[:120]}")
j = r.json() or {}
if not j.get("ok"):
raise PlatformError("device_offline", str(j.get("error", "操作失败"))[:120])
return j
+3
View File
@@ -0,0 +1,3 @@
fastmcp>=2.0
httpx>=0.27
Pillow>=10.0
+3
View File
@@ -36,3 +36,6 @@ opencv-python-headless>=4.8,<5 # 锁定 4.x:5.x wheel 打包异常(无 cv2
# APK 元信息解析(应用管理功能:自动读取包名/版本/应用名)
pyaxmlparser>=0.3.27
# MCP Server + AI 控制台 Agent(fastmcp:Streamable HTTP 服务端/客户端)
fastmcp>=2.0
+57 -25
View File
@@ -1,6 +1,7 @@
#!/bin/bash
# 容器启动脚本:安装依赖 + 环境修复 + 启动 web_server。
# compose 的 python-app 服务 command 指向本脚本。
# 容器启动脚本:依赖安装(仅首次/缺失时)+ 环境修复 + 启动 web_server。
# compose 的 python-app 服务 command 指向本脚本(不要在 compose 里再跑 pip,
# 否则每次容器重启都重装依赖——实测每轮重新下载 opencv 5.0(73MB) 并破坏 cv2)。
#
# 为什么需要环境修复(必须在 pip install 之后):
# rapidocr_onnxruntime 的依赖声明是 opencv-python(GUI 版),pip 解析依赖时
@@ -8,18 +9,39 @@
# 直接崩溃 → RapidOCR 不可用 → 条件判断的 OCR 步骤抛异常 → 任务失败退出。
# 因此在安装完 requirements 后,最后强制卸载 GUI 版并装 headless(幂等)。
# 本地 Mac 有 GUI 环境不受影响。
if [ -f requirements.txt ]; then
pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt
fi
# 环境修复:最后强制 headless(覆盖 rapidocr 拉回的 GUI 版)
pip uninstall -y -q opencv-python 2>/dev/null || true
pip install -q -i https://pypi.tuna.tsinghua.edu.cn/simple "opencv-python-headless>=4.8,<5" # 5.x wheel 无 cv2 模块,锁定 4.x
# pip 24.0 解压 abi3 wheel 偶发丢根文件(4.14.0.94 实测 site-packages/cv2 只剩子目录,
# cv2.abi3.so / __init__.py 缺失 → import cv2 成空壳,OCR 全挂)→ 空壳检测 + wheel 手动解压兜底(幂等)
# ---- 依赖就绪守卫:flask/u2/uiautodev/rapidocr/cv2 全部可用则跳过安装 ----
# (容器重启秒级启动;只有首次部署或依赖缺失时才走完整安装)
python3 - <<'PY'
import glob, os, shutil, site, subprocess, sys, zipfile
import importlib.util, sys
mods = ("flask", "flask_login", "flask_sqlalchemy", "apscheduler",
"uiautomator2", "uiautodev", "rapidocr_onnxruntime", "PIL",
"fastmcp")
if all(importlib.util.find_spec(m) for m in mods):
try:
import cv2
ok = hasattr(cv2, "__version__") and cv2.__file__ is not None
except Exception:
ok = False
sys.exit(0 if ok else 1)
sys.exit(1)
PY
if [ $? -ne 0 ]; then
echo "[start] 依赖缺失或 cv2 异常,执行安装与环境修复(首次启动较慢)..."
if [ -f requirements.txt ]; then
pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt
fi
# 环境修复:最后强制 headless(覆盖 rapidocr 拉回的 GUI 版)
pip uninstall -y -q opencv-python 2>/dev/null || true
pip install -q -i https://pypi.tuna.tsinghua.edu.cn/simple "opencv-python-headless>=4.8,<5" # 5.x wheel 无 cv2 模块,锁定 4.x
# pip 24.0 解压 abi3 wheel 偶发丢根文件(site-packages/cv2 只剩子目录,
# cv2.abi3.so / __init__.py 缺失 → import cv2 成空壳,OCR 全挂)。
# 手动解压兜底在部分环境修不好(解压后仍无 cv2.__version__),
# 实测 pip --force-reinstall 完整安装有效(220 验证 cv2 4.14.0 恢复)。
python3 - <<'PY'
import subprocess, sys
def cv2_ok():
try:
@@ -30,20 +52,30 @@ def cv2_ok():
if cv2_ok():
sys.exit(0)
print("cv2 空壳,wheel 手动解压修复...")
r = subprocess.run(["pip", "download", "--no-deps", "-q",
print("cv2 异常,pip 强制重装 opencv-python-headless...")
r = subprocess.run(["pip", "install", "--force-reinstall", "-q",
"-i", "https://pypi.tuna.tsinghua.edu.cn/simple",
"-d", "/tmp/ocv", "opencv-python-headless>=4.8,<5"], capture_output=True)
if r.returncode != 0:
sys.exit(1)
w = sorted(glob.glob("/tmp/ocv/opencv_python_headless*.whl"))[-1]
with zipfile.ZipFile(w) as z:
z.extractall("/tmp/ocv")
dst = os.path.join(site.getsitepackages()[0], "cv2")
shutil.rmtree(dst, ignore_errors=True)
shutil.copytree("/tmp/ocv/cv2", dst)
import cv2
print(f"cv2 {cv2.__version__} 手动解压修复完成")
"opencv-python-headless>=4.8,<5"])
if r.returncode == 0:
import cv2
print(f"cv2 {cv2.__version__} 重装修复完成")
sys.exit(r.returncode)
PY
else
echo "[start] 依赖已就绪(cv2 $(python3 -c 'import cv2; print(cv2.__version__)' 2>/dev/null)),跳过安装"
fi
# 启动 MCP server(后台,AI 控制台/外部客户端经 127.0.0.1:8033 调用)。
# web_server.py 内 agent 依赖它,容器重启必须自动拉起(MCP_ENABLED=0 可关)。
# 平台账号:MCP_PLATFORM_USER 默认 admin;密码优先取环境变量(compose/.env),
# 兜底 admin123 与部署时的手动启动参数一致。
if [ -f mcp_server/mcp_server.py ] && [ "${MCP_ENABLED:-1}" = "1" ]; then
echo "[start] 启动 MCP server..."
MCP_ALLOW_WRITE=1 \
MCP_PLATFORM_USER="${MCP_PLATFORM_USER:-admin}" \
MCP_PLATFORM_PASS="${MCP_PLATFORM_PASS:-admin123}" \
MCP_AUDIT_FILE="${MCP_AUDIT_FILE:-/tmp/mcp_audit.log}" \
python3 -m mcp_server.mcp_server > /tmp/mcp_server.log 2>&1 &
fi
exec python -u web_server.py
+586
View File
@@ -0,0 +1,586 @@
// AI 控制台(顶级 Tab):DeepSeek 风格聊天 + 流式输出 + 实时 MCP 步骤
let _agentBusy = false;
let _agentStream = null; // EventSource
let _agentCfgLoaded = false;
// ================== 初始化 ==================
let _agentDefaultSerial = '';
function initAgentChat(){
if(_agentCfgLoaded)return;
_agentCfgLoaded = true;
loadAgentConfig();
const chat = document.getElementById('agent-chat');
if(chat && !chat.children.length){
chat.innerHTML = '<div class="agent-empty">👋 给 AI 下达指令,它将通过截图观察手机并执行操作。<br>'
+ '例如:「打开抖音搜索奚学东,告诉我第一个视频的标题」<br>'
+ '先选「🎯 目标设备」(AI 只操作你选定的设备),点击右上角 ⚙ 配置模型与 API Key。</div>';
}
bindChatScroll();
loadLiveDevices();
loadAgentTargetDevices();
startRunPoll();
renderConvList(); // 会话列表(决定当前会话)
loadCurrentConvMessages(); // 渲染当前会话历史
restoreRunningFlow(); // 若任务运行中:重订阅事件流
// 输入框快捷键
const inp = document.getElementById('agent-input');
inp.addEventListener('keydown', ev=>{
if(ev.key==='Enter' && !ev.shiftKey){
ev.preventDefault();
if(!_agentBusy) sendAgentMsg();
}
});
}
// ================== 页面刷新/重进恢复(运行中任务续流) ==================
// 历史轮次由 loadCurrentConvMessages 从会话渲染;这里只处理「任务仍在后台跑」:
// 重新订阅事件流(服务端队列保留积压,重连后补发 delta/step/done)。
function restoreRunningFlow(){
apiGet('/api/agent/run').then(r=>{
if(!r||!r.ok) return;
if(r.state === 'running' && r.run_id){
setRunning(true);
listenStream(r.run_id);
}else if(r.state === 'error' && r.error && _currentConvId){
// 错误提示仅当本会话相关时展示
const chat = document.getElementById('agent-chat');
if(chat && !chat.children.length){
const div = newAssistantMsg();
div.querySelector('.agent-text').textContent = '⚠ ' + r.error;
}
}
});
}
// ================== 配置 ==================
function loadAgentConfig(){
apiGet('/api/agent/config').then(r=>{
if(!r||!r.ok)return;
const tag = document.getElementById('agent-model-tag');
if(tag && r.model) tag.textContent = r.model;
_agentDefaultSerial = r.default_serial || '';
});
}
// ================== 目标设备选择(AI 只操作选定设备) ==================
function loadAgentTargetDevices(){
apiGet('/api/agent/devices').then(r=>{
if(!r||!r.ok)return;
const sel = document.getElementById('agent-target-select');
if(!sel)return;
const cur = sel.value;
const devs = (r.devices||[]).filter(x=>x.online);
sel.innerHTML = '<option value="">请选择设备(AI 只操作选定设备)…</option>'
+ devs.map(d=>{
const label = d.serial + (d.model ? ' · ' + d.model : '')
+ (d.busy ? ' ⛔ 任务中:' + d.task_job : '');
return '<option value="'+esc(d.serial)+'"' + (d.busy ? ' disabled' : '')
+ '>' + esc(label) + '</option>';
}).join('');
// 保留当前选择;否则预选配置的默认设备
if(cur && [...sel.options].some(o=>o.value===cur)) sel.value = cur;
else if(_agentDefaultSerial && [...sel.options].some(o=>o.value===_agentDefaultSerial))
sel.value = _agentDefaultSerial;
const hint = document.getElementById('agent-target-hint');
if(hint && sel.value) hint.textContent = '将操作:' + sel.value;
});
}
// ================== 跨窗口运行状态(多人/多窗口可见并可停止) ==================
let _runPoll = null;
function startRunPoll(){
if(_runPoll) return;
_runPoll = setInterval(pollRunState, 8000);
}
function pollRunState(){
if(_agentStream) return; // 本窗口正在跑(事件流驱动),不轮询
apiGet('/api/agent/run').then(r=>{
if(!r||!r.ok)return;
const running = r.state === 'running';
document.getElementById('agent-running-tag').style.display = running ? 'inline' : 'none';
document.getElementById('btn-agent-stop').style.display = running ? 'inline-block' : 'none';
const hint = document.getElementById('agent-target-hint');
if(hint){
hint.textContent = running
? ('⏳ 运行中' + (r.started ? ' ' + r.started + ' 起' : '')
+ (r.serial ? ' · ' + r.serial : '')
+ ':' + (r.prompt||'').slice(0,70))
: (document.getElementById('agent-target-select').value
? '将操作:' + document.getElementById('agent-target-select').value : '');
}
document.getElementById('btn-agent-send').disabled = running;
if(!running && _agentBusy){ setRunning(false); } // 流异常丢失时复位
});
}
function openAgentConfig(){
apiGet('/api/agent/config').then(r=>{
if(!r||!r.ok)return;
document.getElementById('acfg-base').value = r.api_base || 'https://api.deepseek.com';
document.getElementById('acfg-model').value = r.model || '';
document.getElementById('acfg-serial').value = r.default_serial || '';
document.getElementById('acfg-steps').value = r.max_steps || 40;
document.getElementById('acfg-key-hint').textContent =
r.api_key_masked ? ('已配置 ' + r.api_key_masked) : '未配置';
document.getElementById('acfg-key').value = '';
document.getElementById('agent-cfg-overlay').style.display = 'flex';
});
}
function closeAgentConfig(){
document.getElementById('agent-cfg-overlay').style.display = 'none';
}
function saveAgentConfig(){
const body = {
api_base: document.getElementById('acfg-base').value.trim(),
model: document.getElementById('acfg-model').value.trim(),
default_serial: document.getElementById('acfg-serial').value.trim(),
max_steps: document.getElementById('acfg-steps').value.trim(),
};
const key = document.getElementById('acfg-key').value.trim();
if(key) body.api_key = key;
apiPost('/api/agent/config', body).then(r=>{
if(r&&r.ok){
showToast('配置已保存','success');
closeAgentConfig();
loadAgentConfig();
loadAgentTargetDevices();
}else showToast('保存失败: ' + ((r&&r.error)||''),'error');
});
}
// ================== 聊天渲染 ==================
function addUserMsg(text){
const chat = document.getElementById('agent-chat');
clearEmpty();
const div = document.createElement('div');
div.className = 'agent-msg user';
div.textContent = text;
chat.appendChild(div);
scrollChat();
}
function newAssistantMsg(){
const chat = document.getElementById('agent-chat');
clearEmpty();
const div = document.createElement('div');
div.className = 'agent-msg assistant';
div.innerHTML = '<div class="agent-toolcards"></div><div class="agent-text"></div>';
chat.appendChild(div);
scrollChat();
return div;
}
function clearEmpty(){
const empty = document.querySelector('#agent-chat .agent-empty');
if(empty) empty.remove();
}
let _chatPinned = true; // 用户是否在底部(贴底才自动跟随滚动)
function scrollChat(force){
const chat = document.getElementById('agent-chat');
if(force || _chatPinned){
chat.scrollTop = chat.scrollHeight;
}
}
function bindChatScroll(){
const chat = document.getElementById('agent-chat');
chat.addEventListener('scroll', ()=>{
_chatPinned = chat.scrollHeight - chat.scrollTop - chat.clientHeight < 60;
});
}
function clearAgentChat(){
document.getElementById('agent-chat').innerHTML = '';
}
// ================== 历史会话(DeepSeek 式:左侧列表,多会话持久化) ==================
let _currentConvId = localStorage.getItem('agent_conv_id') || '';
function saveConvId(id){
_currentConvId = id || '';
if(id) localStorage.setItem('agent_conv_id', id);
else localStorage.removeItem('agent_conv_id');
}
function createAgentSession(){
if(_agentBusy){showToast('运行中不能新建会话','error');return;}
apiPost('/api/agent/conversations',{}).then(r=>{
if(r&&r.ok){
saveConvId(r.id);
clearAgentChat();
showToast('已新建会话','success');
renderConvList();
}else showToast('新建失败: '+((r&&r.error)||''),'error');
});
}
function selectAgentSession(id){
if(_agentBusy){showToast('运行中不能切换会话','error');return;}
if(id === _currentConvId) return;
saveConvId(id);
clearAgentChat();
loadCurrentConvMessages();
renderConvList();
}
function deleteAgentSession(id){
if(!confirm('删除该会话?历史消息将不可恢复。')) return;
apiDelete('/api/agent/conversations/'+id).then(r=>{
if(r&&r.ok){
if(_currentConvId === id) saveConvId('');
showToast('会话已删除','success');
renderConvList();
loadCurrentConvMessages();
}else showToast('删除失败: '+((r&&r.error)||''),'error');
});
}
function renderConvList(){
apiGet('/api/agent/conversations').then(r=>{
if(!r||!r.ok)return;
const list = document.getElementById('agent-sess-list');
if(!list)return;
const convs = r.conversations || [];
let cur = _currentConvId;
if(!convs.some(c=>c.id===cur)) cur = convs.length ? convs[0].id : '';
if(cur !== _currentConvId){ saveConvId(cur); clearAgentChat(); loadCurrentConvMessages(); }
if(!convs.length){
list.innerHTML = '<div class="agent-empty" style="padding:20px 10px;font-size:12px">'
+ '暂无历史会话<br><br>点「+ 新建会话」开始</div>';
return;
}
list.innerHTML = convs.map(c=>{
const t = c.title || '新会话';
return '<div class="agent-sess-item'+(c.id===cur?' active':'')
+'" onclick="selectAgentSession(\''+c.id+'\')">'
+'<button class="agent-sess-del" title="删除会话" '
+'onclick="event.stopPropagation();deleteAgentSession(\''+c.id+'\')">✕</button>'
+'<div class="t">'+esc(t)+'</div>'
+'<div class="m">'+esc(c.updated_at||'')+(c.count ? ' · '+c.count+' 轮' : '')+'</div></div>';
}).join('');
});
}
function loadCurrentConvMessages(){
const chat = document.getElementById('agent-chat');
if(!chat) return;
if(!_currentConvId){
chat.innerHTML = '<div class="agent-empty">👋 点左侧「+ 新建会话」开始,'
+ '给 AI 下达指令(先选 🎯 目标设备)。</div>';
return;
}
apiGet('/api/agent/conversations/'+_currentConvId).then(r=>{
if(!r||!r.ok)return;
chat.innerHTML = '';
(r.messages||[]).forEach(m=>{
const txt = (m.content||'').trim();
if(!txt) return;
if(m.role==='user'){ addUserMsg(txt); }
else{
const div = newAssistantMsg();
div.querySelector('.agent-text').textContent = txt;
}
});
if(!chat.children.length){
chat.innerHTML = '<div class="agent-empty">新会话。给 AI 下达指令'
+ '(先选 🎯 目标设备)…</div>';
}
});
}
// ================== 发送与流式接收 ==================
function setRunning(on){
_agentBusy = on;
document.getElementById('agent-running-tag').style.display = on ? 'inline' : 'none';
document.getElementById('btn-agent-stop').style.display = on ? 'inline-block' : 'none';
document.getElementById('btn-agent-send').disabled = on;
}
function stopAgent(){
if(!confirm('确定停止当前 AI 运行任务?\n(其他人发起的任务也会被停止)')) return;
apiPost('/api/agent/stop',{}).then(r=>{
if(r&&r.ok) showToast('已请求停止,正在中断…','success');
else showToast((r&&r.error)||'停止失败','error');
});
}
function sendAgentMsg(){
if(_agentBusy){showToast('上一轮还在运行','error');return;}
const sel = document.getElementById('agent-target-select');
const serial = sel ? sel.value : '';
if(!serial){
showToast('请先选择目标设备(AI 只操作你选定的设备;任务中的设备不可选)','error');
if(sel) sel.focus();
return;
}
const inp = document.getElementById('agent-input');
const text = inp.value.trim();
if(!text){showToast('请输入指令','error');return;}
inp.value = '';
addUserMsg(text);
// 会话:无当前会话时自动新建(DeepSeek 式:消息总归属于一个会话)
const doRun = (convId)=>{
apiPost('/api/agent/run', {prompt: text, serial: serial,
conversation_id: convId}).then(r=>{
if(!r||!r.ok){
const msg = r ? (r.error||'启动失败') : '请求失败';
const div = newAssistantMsg();
div.querySelector('.agent-text').textContent = '⚠ ' + msg;
showToast(msg,'error');
return;
}
setRunning(true);
listenStream(r.run_id);
});
};
if(_currentConvId){
doRun(_currentConvId);
}else{
apiPost('/api/agent/conversations',{}).then(r=>{
if(r&&r.ok){
saveConvId(r.id);
renderConvList();
doRun(r.id);
}else{
const div = newAssistantMsg();
div.querySelector('.agent-text').textContent = '⚠ 新建会话失败';
showToast('新建会话失败','error');
}
});
}
}
function listenStream(runId){
if(_agentStream) _agentStream.close();
const es = new EventSource('/api/agent/stream?run_id=' + runId);
_agentStream = es;
let msgEl = null;
es.addEventListener('delta', ev=>{
if(!msgEl) msgEl = newAssistantMsg();
const d = JSON.parse(ev.data);
const textEl = msgEl.querySelector('.agent-text');
if(d.kind === 'reasoning'){
let r = msgEl.querySelector('.reasoning');
if(!r){
r = document.createElement('div');
r.className = 'reasoning';
msgEl.insertBefore(r, textEl);
}
r.textContent += d.text;
}else{
textEl.textContent += d.text;
}
scrollChat();
});
es.addEventListener('step', ev=>{
const d = JSON.parse(ev.data);
followSerialFromArgs(d.args);
if(!msgEl) msgEl = newAssistantMsg();
const cards = msgEl.querySelector('.agent-toolcards');
const card = document.createElement('div');
card.className = 'agent-toolcard';
const argsTxt = typeof d.args === 'string' ? d.args
: JSON.stringify(d.args || {});
card.innerHTML = '<span class="dot"></span>'
+ '<code style="color:var(--teal)">' + esc(d.tool||'') + '</code>'
+ '<span class="text-muted">' + esc(argsTxt) + '</span>';
if(d.image){
const img = document.createElement('img');
img.src = 'data:image/jpeg;base64,' + d.image;
card.appendChild(img);
}
cards.appendChild(card);
scrollChat();
});
es.addEventListener('done', ev=>{
const d = JSON.parse(ev.data);
if(d.answer){
if(!msgEl) msgEl = newAssistantMsg();
msgEl.querySelector('.agent-text').textContent = d.answer;
}
endRun();
renderConvList(); // 落库已完成:刷新标题/时间/轮数
});
es.addEventListener('error', ev=>{
let msg = '连接中断';
try{
if(ev.data) msg = JSON.parse(ev.data).message || msg;
}catch(e){}
if(!msgEl) msgEl = newAssistantMsg();
msgEl.querySelector('.agent-text').textContent = '⚠ ' + msg;
endRun();
});
es.onerror = ()=>{
// 重要:不能在这里 endRun()——断网/后台标签页节流/服务端瞬时抖动都会触发
// onerror,但后端任务仍在跑。EventSource 会自动重连,服务端事件队列保留
// 积压(delta/step/done),重连成功后全部补发,界面无缝续上。
// 只有连接彻底关闭(非自动重连态)且任务还在跑时才提示。
if(_agentBusy && es.readyState === EventSource.CLOSED){
const hint = document.getElementById('agent-target-hint');
if(hint) hint.textContent = '⚠ 实时连接已中断,任务仍在后台运行——可刷新页面恢复查看';
// 连接彻底失败时周期探测,恢复后重订阅
setTimeout(()=>{
if(!_agentStream && _agentBusy){
apiGet('/api/agent/run').then(r=>{
if(r&&r.ok&&r.state==='running'&&r.run_id) listenStream(r.run_id);
});
}
}, 5000);
}
};
}
// ============ 实时画面(右侧,MJPEG 流) ============
let _liveSerial = '';
function showLive(serial){
if(!serial || serial === _liveSerial) return;
_liveSerial = serial;
const img = document.getElementById('agent-live-img');
img.src = '/api/screen/stream?serial=' + encodeURIComponent(serial) + '&q=80&fps=8&t=' + Date.now();
img.style.display = 'block';
document.getElementById('agent-live-empty').style.display = 'none';
document.getElementById('agent-live-serial').textContent = serial;
const sel = document.getElementById('agent-live-select');
if(sel) sel.value = serial;
}
function stopLive(){
_liveSerial = '';
const img = document.getElementById('agent-live-img');
img.src = '';
img.style.display = 'none';
document.getElementById('agent-live-empty').style.display = 'block';
document.getElementById('agent-live-serial').textContent = '';
}
function followSerialFromArgs(args){
// 工具 args(后端 SSE 已序列化为对象;兼容历史字符串格式)
let a = args;
if(typeof args === 'string'){
try{ a = JSON.parse(args); }catch(e){ return; }
}
if(a && a.serial) showLive(a.serial);
}
function watchDevice(serial){
if(!serial){ stopLive(); return; }
showLive(serial);
}
function loadLiveDevices(){
apiGet('/api/devices').then(r=>{
if(!r||!r.ok)return;
const sel = document.getElementById('agent-live-select');
if(!sel)return;
const cur = sel.value;
const devs = (r.devices||[]).filter(x=>x.indexOf(':')>=0);
sel.innerHTML = '<option value="">选择设备观看…</option>'
+ devs.map(s=>'<option value="'+esc(s)+'"'+(s===cur?' selected':'')+'>'+esc(s)+'</option>').join('');
});
}
// 截图点击放大查看
function zoomScreenshot(img){
const ov = document.getElementById('agent-zoom');
if(!ov){
const d = document.createElement('div');
d.id = 'agent-zoom';
d.style.cssText = 'display:none;position:fixed;inset:0;background:rgba(0,0,0,.8);z-index:1400;align-items:center;justify-content:center;cursor:zoom-out';
d.onclick = ()=>d.style.display='none';
document.body.appendChild(d);
}
const ovEl = document.getElementById('agent-zoom');
ovEl.innerHTML = '';
const big = document.createElement('img');
big.src = img.src;
big.style.cssText = 'max-width:88vw;max-height:88vh;border-radius:8px';
ovEl.appendChild(big);
ovEl.style.display = 'flex';
}
// 截图 img 绑定点击放大
document.addEventListener('click', ev=>{
const t = ev.target;
if(t && t.tagName==='IMG' && t.closest('.agent-toolcard') && !t.closest('#agent-zoom')){
zoomScreenshot(t);
}
});
function endRun(){
setRunning(false);
_agentStream && _agentStream.close();
_agentStream = null;
scrollChat();
}
// ================== 经验库(自进化记忆管理) ==================
function openExpLib(){
document.getElementById('agent-exp-overlay').style.display = 'flex';
loadExpLib();
}
function closeExpLib(){
document.getElementById('agent-exp-overlay').style.display = 'none';
}
function loadExpLib(){
const list = document.getElementById('exp-lib-list');
apiGet('/api/agent/experience').then(r=>{
if(!r||!r.ok){ showToast((r&&r.error)||'加载失败','error'); return; }
document.getElementById('exp-lib-last').textContent =
(r.last ? ('最近巡检 ' + r.last + ':' + (r.last_summary||'')) : '');
if(r.running){
list.innerHTML = '<div class="text-muted" style="padding:20px;text-align:center">🔍 AI 巡检进行中(约 1 分钟),完成后请刷新…</div>';
return;
}
const exps = r.experiences||[];
if(!exps.length){
list.innerHTML = '<div class="text-muted" style="padding:20px;text-align:center">暂无经验——跑一轮带手机操作的任务后会自动沉淀</div>';
return;
}
list.innerHTML = '';
exps.forEach(e=>{
const a = e.audit||{};
const card = document.createElement('div');
card.style.cssText = 'border:1px solid var(--card-line);border-radius:10px;padding:9px 13px;background:#0d1117';
// 徽章
let badge = '';
if(a.action === 'pending' && a.verdict === 'delete'){
badge = '<span style="background:#7f1d1d;color:#fecaca;font-size:10.5px;padding:1px 8px;border-radius:9px;margin-left:8px">⚠ 建议删除</span>';
}else if(a.action === 'kept'){
badge = '<span style="background:#0f3d2e;color:#a7f3d0;font-size:10.5px;padding:1px 8px;border-radius:9px;margin-left:8px">' +
(a.verdict === 'delete' ? '已人工保留' : '✓ 已保留') + '</span>';
}
let actBtns = '';
if(a.action === 'pending'){
actBtns = '<button class="btn btn-xs btn-danger" onclick="expDelete(' + e.id + ')">确认删除</button>'
+ '<button class="btn btn-xs" onclick="expKeep(' + e.id + ')">保留</button>';
}else{
actBtns = '<button class="btn btn-xs" style="border-color:#7f1d1d;color:#f87171" onclick="expDelete(' + e.id + ')" title="强制删除此经验">删除</button>';
}
card.innerHTML =
'<div style="display:flex;align-items:center;gap:6px;font-size:12px">'
+ '<span class="text-muted" style="font-family:var(--mono)">#' + e.id + '</span>'
+ '<span class="text-muted">' + esc(e.created_at||'') + '</span>'
+ '<span class="text-muted">引用 ' + (e.hits||0) + ' 次</span>' + badge
+ '<span style="flex:1"></span>' + actBtns + '</div>'
+ '<div style="font-size:13px;color:#e5e7eb;margin:5px 0 3px">' + esc(e.task_prompt) + '</div>'
+ '<div class="text-muted" style="font-size:11.5px;line-height:1.6;white-space:pre-wrap">' + esc((e.recipe||'').slice(0,220)) + '</div>'
+ (a.action === 'pending' && a.reason
? '<div style="font-size:11.5px;color:#fbbf24;margin-top:5px">AI 建议理由:' + esc(a.reason)
+ (a.score ? '(评分 ' + a.score + '/10)' : '') + '</div>'
: '');
list.appendChild(card);
});
});
}
function triggerExpAudit(){
apiPost('/api/agent/experience/audit',{}).then(r=>{
if(r&&r.ok){
showToast('巡检已启动,约 1 分钟完成','success');
document.getElementById('exp-lib-list').innerHTML =
'<div class="text-muted" style="padding:20px;text-align:center">🔍 AI 巡检进行中…</div>';
// 完成后自动刷新
setTimeout(()=>{ if(document.getElementById('agent-exp-overlay').style.display==='flex') loadExpLib(); }, 90000);
}else showToast((r&&r.error)||'巡检启动失败','error');
});
}
function expDelete(id){
if(!confirm('确认删除该经验?删除后不可恢复(下次相似任务不再自动参考)。')) return;
apiPost('/api/agent/experience/delete',{id:id}).then(r=>{
if(r&&r.ok){ showToast('已删除','success'); loadExpLib(); }
else showToast((r&&r.error)||'删除失败','error');
});
}
function expKeep(id){
apiPost('/api/agent/experience/keep',{id:id}).then(r=>{
if(r&&r.ok){ showToast('已保留,后续不再重复建议','success'); loadExpLib(); }
else showToast((r&&r.error)||'操作失败','error');
});
}
+1
View File
@@ -100,6 +100,7 @@ function showTab(name){
if(name==='tasks'){showSubTab('tasks',_activeSubs.tasks);loadTasks();loadCustomActions();}
if(name==='tools'){showSubTab('tools',_activeSubs.tools);loadToolsDevices();loadAdbDevices();loadTailscaleDevices();loadApks();}
if(name==='logs'){loadLogs();if(document.getElementById('log-auto').checked)_logTimer=setInterval(loadLogs,3000);}
if(name==='agent' && typeof initAgentChat==='function') initAgentChat();
if(name==='users')loadUsers();
}
+23
View File
@@ -81,6 +81,29 @@ function loadDiscovery(){
+'<button class="btn btn-xs" data-serial="'+esc(x.serial)+'" onclick="ignoreDiscoveryDev(this.dataset.serial)">忽略</button></td></tr>';
}).join('');
}
// 正式池断联设备:仍在设备池(不删除),扫描线程自动重连,可手动立即重连
const offTb=document.getElementById('tb-pool-offline');
if(offTb){
const off=(r.pool_offline||[]);
if(!off.length){
offTb.innerHTML='<tr><td colspan="5" class="empty">全部在线</td></tr>';
}else{
offTb.innerHTML=off.map(x=>{
return '<tr><td style="font-family:monospace">'+esc(x.serial)+'</td>'
+'<td>'+esc(x.model||'-')+'</td><td>'+esc(x.name||'')+'</td>'
+'<td><span class="label label-danger">断联·自动重连中</span></td>'
+'<td><button class="btn btn-xs" data-serial="'+esc(x.serial)+'" onclick="reconnectPoolDev(this.dataset.serial)">立即重连</button></td></tr>';
}).join('');
}
}
});
}
function reconnectPoolDev(serial){
apiPost('/api/devices/discovery/reconnect',{serial}).then(r=>{
if(r&&r.ok){
showToast('重连已启动(约 5-15 秒生效)','success');
setTimeout(loadDiscovery,8000);
}else showToast('重连失败: '+((r&&r.error)||''),'error');
});
}
function toggleDiscovery(checked){
+152
View File
@@ -303,6 +303,54 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
.el-picker-item .ep-info .ep-attrs{color:var(--text-light);font-size:11px;margin-top:2px;word-break:break-all}
.el-picker-item .ep-info .ep-attrs span{margin-right:8px}
.el-picker-item .ep-sel{font-size:10px;color:#fff;background:var(--primary);padding:1px 7px;border-radius:99px;flex-shrink:0;font-weight:600}
/* ===== AI 控制台(聊天界面,浅色内容区——DeepSeek 风格,图片文字清晰) ===== */
.agent-body{display:flex;flex:1;min-height:0}
.agent-col-sessions{width:236px;border-right:1px solid #e5e7eb;background:#f6f7f9;display:flex;flex-direction:column;min-width:0}
.agent-sess-head{padding:10px;border-bottom:1px solid #e5e7eb}
.agent-sess-list{flex:1;overflow-y:auto;padding:6px}
.agent-sess-item{padding:8px 10px;border-radius:9px;cursor:pointer;margin-bottom:2px;border:1px solid transparent;position:relative}
.agent-sess-item:hover{background:#eceff3}
.agent-sess-item.active{background:#e0f2fe;border-color:#bae6fd}
.agent-sess-item .t{font-size:12.5px;color:#1f2937;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding-right:20px}
.agent-sess-item .m{font-size:10.5px;color:#9ca3af;margin-top:2px}
.agent-sess-del{position:absolute;right:6px;top:6px;visibility:hidden;background:transparent;border:none;color:#9ca3af;cursor:pointer;font-size:12px;padding:2px 5px;border-radius:5px}
.agent-sess-item:hover .agent-sess-del{visibility:visible}
.agent-sess-del:hover{background:#fecaca;color:#b91c1c}
.agent-col-chat{flex:1;display:flex;flex-direction:column;min-width:0}
.agent-col-live{width:300px;border-left:1px solid #e5e7eb;background:#fff;display:flex;flex-direction:column;padding:10px}
.agent-live-head{font-size:12.5px;font-weight:600;color:#374151;display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}
.agent-live-serial{font-family:var(--mono);font-size:10.5px;color:#6b7280}
.agent-live-body{flex:1;background:#0b0f14;border-radius:10px;display:flex;align-items:center;justify-content:center;overflow:hidden;min-height:200px}
.agent-live-body img{width:100%;height:100%;object-fit:contain}
.agent-live-body .agent-empty{color:#4b5563;padding:20px;font-size:12px;line-height:1.8}
.agent-shell{display:flex;flex-direction:column;height:calc(100vh - 150px);min-height:520px;border:1px solid #e5e7eb;border-radius:14px;overflow:hidden;background:#fff}
.agent-shell{display:flex;flex-direction:column;height:calc(100vh - 150px);min-height:480px;border:1px solid #e5e7eb;border-radius:14px;overflow:hidden;background:#fff}
.agent-topbar{display:flex;align-items:center;justify-content:space-between;padding:10px 16px;border-bottom:1px solid #e5e7eb;background:#f9fafb}
.agent-title{font-size:14px;font-weight:700;color:#111827}
.agent-model-tag{margin-left:8px;font-size:11px;color:#0e7490;font-family:var(--mono);background:#e0f2fe;padding:2px 8px;border-radius:10px}
.agent-running-tag{color:#dc2626;font-size:12px;margin-right:8px}
.agent-top-actions{display:flex;align-items:center;gap:6px}
.agent-top-actions .btn{background:#fff;border:1px solid #d1d5db;color:#374151}
.agent-top-actions .btn:hover{border-color:#0e7490;color:#0e7490}
.agent-chat{flex:1;overflow-y:auto;padding:20px 26px;display:flex;flex-direction:column;gap:16px;background:#f7f8fa}
.agent-msg{max-width:80%;padding:11px 15px;border-radius:13px;font-size:13.5px;line-height:1.7;white-space:pre-wrap;word-break:break-word}
.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 .reasoning{color:#6b7280;font-size:12.5px;border-left:3px solid #d1d5db;padding-left:10px;margin:8px 0}
.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 .dot{width:7px;height:7px;border-radius:50%;background:#10b981;flex:none;margin-top:5px}
.agent-toolcard code{color:#0e7490}
.agent-toolcard .text-muted{color:#6b7280}
.agent-toolcard img{max-width:190px;border-radius:7px;margin-top:4px;border:1px solid #e5e7eb;display:block;cursor:zoom-in}
.agent-inputbar{display:flex;gap:10px;padding:12px 14px;border-top:1px solid #e5e7eb;background:#fff}
.agent-inputbar textarea{flex:1;resize:none;background:#f9fafb;border:1px solid #d1d5db;border-radius:10px;padding:9px 12px;color:#111827;font-size:13.5px;outline:none;line-height:1.5}
.agent-inputbar textarea:focus{border-color:#0e7490;box-shadow:0 0 0 2px rgba(14,116,144,.12)}
.agent-inputbar .btn-primary{background:#0e7490;border:none}
.agent-inputbar .btn-primary:hover{background:#155e75}
.agent-inputbar .btn-primary:disabled{background:#9ca3af}
.agent-empty{color:#6b7280;font-size:13px;text-align:center;padding:44px 0}
</style>
</head>
<body>
@@ -316,6 +364,7 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
<button class="tab" data-tab="logs" onclick="showTab('logs')" data-perm="logs">日志</button>
<button class="tab" data-tab="users" onclick="showTab('users')" data-perm="admin">用户</button>
<button class="tab" data-tab="tools" onclick="showTab('tools')" data-perm="admin">工具</button>
<button class="tab" data-tab="agent" onclick="showTab('agent')" data-perm="admin">AI 控制台</button>
</div>
<div class="user-info">
<span id="me-username">...</span>
@@ -422,6 +471,97 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
</div>
<!-- ========== Tab 3: 分组 ========== -->
<!-- ========== Tab: AI 控制台(DeepSeek 风格聊天 + 实时 MCP 步骤) ========== -->
<div id="tab-agent" class="tab-panel">
<div class="agent-shell">
<div class="agent-topbar">
<div class="agent-title">🤖 AI 控制台<span id="agent-model-tag" class="agent-model-tag"></span></div>
<div class="agent-top-actions">
<span id="agent-running-tag" class="agent-running-tag" style="display:none">● 运行中</span>
<button class="btn btn-xs" onclick="openExpLib()" title="查看/管理自进化经验库(每日 AI 巡检建议,删除需确认)">🧠 经验库</button>
<button class="btn btn-xs" onclick="openAgentConfig()">⚙ 配置</button>
</div>
</div>
<div class="agent-body">
<div class="agent-col-sessions">
<div class="agent-sess-head">
<button class="btn btn-primary" style="width:100%" onclick="createAgentSession()">+ 新建会话</button>
</div>
<div id="agent-sess-list" class="agent-sess-list"></div>
</div>
<div class="agent-col-chat">
<div id="agent-chat" class="agent-chat"></div>
</div>
<div class="agent-col-live">
<div class="agent-live-head">📺 实时画面<span id="agent-live-serial" class="agent-live-serial"></span></div>
<div class="agent-live-body" id="agent-live-body">
<img id="agent-live-img" alt="" style="display:none;width:100%">
<div class="agent-empty" id="agent-live-empty">运行任务时自动跟随操作的设备;<br>也可手动选择设备观看</div>
</div>
<select id="agent-live-select" class="form-control" style="margin-top:8px"
onchange="watchDevice(this.value)"><option value="">选择设备观看…</option></select>
</div>
</div>
<div class="agent-inputbar" style="align-items:flex-end;flex-direction:column">
<div style="display:flex;width:100%;gap:8px;align-items:center;margin-bottom:8px">
<label style="font-size:12.5px;color:#374151;font-weight:600;white-space:nowrap">🎯 目标设备</label>
<select id="agent-target-select" class="form-control" style="width:320px;font-family:monospace"
title="AI 只操作你选定的设备;有任务运行中的设备不可选">
<option value="">请选择设备(AI 只操作选定设备)…</option>
</select>
<span class="text-muted" id="agent-target-hint" style="font-size:11.5px"></span>
<span style="flex:1"></span>
<button class="btn btn-xs btn-danger" id="btn-agent-stop" style="display:none" onclick="stopAgent()">■ 停止</button>
</div>
<div style="display:flex;width:100%;gap:10px">
<textarea id="agent-input" rows="2"
placeholder="给 AI 下达指令,例如:打开抖音搜索奚学东,把第一个视频标题告诉我…(Enter 发送,Shift+Enter 换行)"></textarea>
<button class="btn btn-primary" id="btn-agent-send" onclick="sendAgentMsg()">发送</button>
</div>
</div>
</div>
</div>
<!-- ========== 配置模态框 ========== -->
<div id="agent-cfg-overlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:1300;align-items:center;justify-content:center">
<div style="background:#11151c;border:1px solid var(--card-line);border-radius:14px;padding:22px;width:460px;max-width:92vw">
<div style="font-size:15px;font-weight:700;margin-bottom:14px">模型配置(OpenAI 兼容 API)</div>
<div class="form-group"><label>API Base</label>
<input type="text" id="acfg-base" class="form-control" placeholder="https://api.deepseek.com" style="font-family:monospace"></div>
<div class="form-group"><label>模型名</label>
<input type="text" id="acfg-model" class="form-control" placeholder="deepseek-v4-flash-vision-exp"></div>
<div class="form-group"><label>API Key <span class="text-muted" id="acfg-key-hint"></span></label>
<input type="password" id="acfg-key" class="form-control" placeholder="留空则不修改"></div>
<div class="form-group"><label>默认设备 serial(可选)</label>
<input type="text" id="acfg-serial" class="form-control" placeholder="192.168.20.66:5555" style="font-family:monospace"></div>
<div class="form-group"><label>最大执行步骤(1-200,默认 40)</label>
<input type="number" id="acfg-steps" class="form-control" min="1" max="200" placeholder="40"
title="单轮任务允许的最大工具调用轮数;超限会收尾总结(防死循环)"></div>
<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:6px">
<button class="btn" onclick="closeAgentConfig()">取消</button>
<button class="btn btn-primary" onclick="saveAgentConfig()">保存</button>
</div>
</div>
</div>
<!-- ========== 经验库模态框(自进化记忆管理 + 每日 AI 巡检建议) ========== -->
<div id="agent-exp-overlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:1320;align-items:center;justify-content:center">
<div style="background:#11151c;border:1px solid var(--card-line);border-radius:14px;padding:20px 22px;width:760px;max-width:94vw;max-height:86vh;display:flex;flex-direction:column">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px">
<div style="font-size:15px;font-weight:700">🧠 经验库<span class="text-muted" style="font-size:11.5px;font-weight:400;margin-left:8px" id="exp-lib-hint"></span></div>
<div style="display:flex;gap:6px;align-items:center">
<button class="btn btn-xs" onclick="triggerExpAudit()">🔍 立即巡检</button>
<button class="btn btn-xs" onclick="closeExpLib()">✕ 关闭</button>
</div>
</div>
<div class="text-muted" style="font-size:11.5px;padding:0 2px 8px;border-bottom:1px solid var(--card-line);margin-bottom:10px">
每次任务成功后会提炼「操作配方」存入经验库,下次相似任务自动参考;每日 03:47 AI 巡检给出疑似问题建议(删除需人工确认)。
<span id="exp-lib-last" class="text-muted"></span>
</div>
<div id="exp-lib-list" style="flex:1;overflow-y:auto;min-height:200px;display:flex;flex-direction:column;gap:8px"></div>
</div>
</div>
<!-- ========== Tab 4: 日志 ========== -->
<div id="tab-logs" class="tab-panel">
<div class="page-title">日志查看</div>
@@ -656,6 +796,15 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
</table>
<div class="help">扫描局域网/Tailscale 网段中开放 adb 5555 的设备进入<b>待连接池</b>;
<b>确认后才加入正式设备池</b>(不自动连接、不参与任务调度)。未授权设备(手机未接受 RSA 弹窗)不会出现。</div>
<!-- 正式池断联设备:仍在设备池(不删除、不进待连接池),自动重连中 -->
<div style="margin-top:10px">
<div style="font-size:12.5px;font-weight:600;margin-bottom:6px">设备池断联设备
<span class="text-muted" style="font-weight:400">(仍在设备池内,扫描线程每轮自动重连;也可手动立即重连)</span></div>
<table class="table table-hover table-sm">
<thead><tr><th>serial</th><th>型号</th><th>备注</th><th>状态</th><th>操作</th></tr></thead>
<tbody id="tb-pool-offline"><tr><td colspan="5" class="empty">全部在线</td></tr></tbody>
</table>
</div>
</div>
<div class="toolbar">
<input type="text" id="devpool-serial" class="form-control" style="width:220px;font-family:monospace"
@@ -702,6 +851,8 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
<div class="help">管理设备分组,用于任务按分组执行。分组内的设备需先在<b>设备池管理</b>中确认(不在池内的设备不参与调度)。</div>
</div>
</div>
</div><!-- /.content -->
@@ -766,5 +917,6 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
<script src="/static/admin/tools.js"></script>
<script src="/static/admin/apps.js"></script>
<script src="/static/admin/admin.js"></script>
<script src="/static/admin/agent.js"></script>
</body>
</html>
+4 -1
View File
@@ -23,6 +23,9 @@ def register_blueprints(app):
from .devices_api import bp as devices_bp
from .apks_api import bp as apks_bp
from .tailscale_api import bp as tailscale_bp
from . import agent_api as _agent_mod
from .agent_api import bp as agent_bp
_agent_mod.set_app(app)
for bp in (auth_bp, monitor_bp, tasks_bp, admin_bp, tools_bp,
devices_bp, apks_bp, tailscale_bp):
devices_bp, apks_bp, tailscale_bp, agent_bp):
app.register_blueprint(bp)
+943
View File
@@ -0,0 +1,943 @@
"""AI 控制台 API:模型/Key 前端配置 + Agent 流式执行(SSE 事件流)。
流程:
POST /api/agent/run {prompt} → 启动 Agent 线程,返回 run_id
GET /api/agent/stream?run_id= → SSE 事件流(EventSource 订阅):
event: delta {text, kind: content|reasoning} 流式文本增量
event: step {tool, args, image?} 工具调用完成(MCP 步骤)
event: done {answer} 完成
event: error {message} 失败
GET/POST /api/agent/config → 配置读写(key 打码回显)
单实例:同时只允许一个 Agent 运行。
"""
import asyncio
import base64
import io
import json
import os
import queue
import re
import sys
import threading
import uuid
from flask import Blueprint, Response, jsonify, request
from core.logger import get_logger
from core.models import db
from web.auth import admin_required
_log = get_logger("web.agent")
bp = Blueprint("agent", __name__)
# ---------- 配置键(app_meta) ----------
_CFG_KEYS = {"api_base": "agent_api_base",
"model": "agent_model",
"api_key": "agent_api_key",
"default_serial": "agent_default_serial",
"max_steps": "agent_max_steps"}
# ---------- 运行状态(单实例 + 事件队列) ----------
_run = {"id": None, "state": "idle", "prompt": "", "serial": "",
"answer": "", "error": "",
"history": []} # 多轮对话历史 [{role: user|assistant, content}]
_queues = {} # run_id -> queue.Queue(SSE 消费者读取)
_stop_events = {} # run_id -> threading.Event(用户中断)
_lock = threading.Lock()
_flask_app = None # web_server 注册时注入(后台线程 db 操作需 app context)
def set_app(app):
global _flask_app
_flask_app = app
def _meta_get(key):
return db.session.execute(
db.text("SELECT value FROM app_meta WHERE key=:k"), {"k": key}).scalar() or ""
def _meta_put(key, value):
db.session.execute(
db.text("INSERT OR REPLACE INTO app_meta(key,value) VALUES(:k,:v)"),
{"k": key, "v": str(value)})
def _read_cfg():
return {k: _meta_get(v) for k, v in _CFG_KEYS.items()}
# ================== 经验记忆(自进化) ==================
# agent_experience:任务成功后的操作配方,下次相似任务检索注入 system prompt。
# 原始 SQLite(CREATE IF NOT EXISTS 幂等),不进模型层迁移。
_EXP_TABLE = """
CREATE TABLE IF NOT EXISTS agent_experience (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_prompt TEXT DEFAULT '',
recipe TEXT DEFAULT '',
tool_seq TEXT DEFAULT '',
hits INTEGER DEFAULT 0,
created_at VARCHAR(20) DEFAULT '')"""
# 经验巡检(AI 质检):每日定时把经验交给模型评审,疑似问题标 pending 待人工确认。
# 删除只允许人工(前端 confirm / 本 API),巡检绝不自动删。
_AUDIT_TABLE = """
CREATE TABLE IF NOT EXISTS experience_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
exp_id INTEGER NOT NULL,
verdict TEXT DEFAULT '', -- keep 保留 / delete 建议删除
score REAL DEFAULT 0, -- 0-10 可用性评分
reason TEXT DEFAULT '', -- 模型给出的理由
hits INTEGER DEFAULT 0, -- 巡检时的引用次数(hits>0 被删要更谨慎)
action TEXT DEFAULT 'pending', -- pending 待人工确认 / kept 已保留 / deleted 已删除
audited_at VARCHAR(20) DEFAULT '')"""
# 巡检评审提示词:模型只判「保留/建议删除」+ 评分 + 理由,不直接删。
_AUDIT_PROMPT = """你是「手机自动化经验库」质检员。经验库存放 AI 成功操作手机后提炼的
操作套路,下次相似任务会自动注入参考。请判断下面这条经验是否值得继续保留。
判断标准(全部满足才保留):
1. 具体可执行:步骤是明确的工具操作(de_* 或清晰的中文步骤),不是「de_tap / de_input」
这类空泛占位,也没有编造不存在的工具名
2. 不依赖易过时信息:不含写死的像素坐标(x,y 数字)这类界面一变就失效的步骤
3. 是独立任务的操作套路(如「打开X搜索Y并点赞」),不是对话追问/抱怨/单次性内容
4. 配方与下方实际操作序列大致一致,不是模型自由发挥编造
经验内容:
任务描述:{prompt}
操作配方:{recipe}
实际操作序列:{tool_seq}
被引用次数:{hits}
只输出 JSON:{{"keep": true或false, "score": 0到10的整数, "reason": "一句话中文理由"}}"""
def _ensure_exp_table():
try:
db.session.execute(db.text(_EXP_TABLE))
db.session.commit()
except Exception:
pass
def _bigrams(text):
"""中文/英文文本 bigram 集合(无空格分词,粗粒度相似度)。"""
t = "".join(c for c in (text or "").lower() if c.isalnum() or "\u4e00" <= c <= "\u9fff")
return {t[i:i + 2] for i in range(len(t) - 1)}
# ---------- 经验质量门槛 ----------
# 自进化经验只应保存「独立操作任务」的成功套路。多轮对话里用户的短句
# (质疑/纠正/催促,如「继续啊」「你确定我是卡1吗」「还没有完成啊」)
# 不是新任务——把执行出错被纠正的轮次存成经验会教坏后续任务。
# 用启发式过滤(零成本,可解释),配方层再校验工具名真实性。
# 任务性动词:命中任一视为有明确操作诉求(疑问/纠错短句一般不含它们)
_TASK_VERBS = ("打开", "搜索", "查看", "找到", "截图", "输入", "点击", "点开",
"发送", "安装", "卸载", "下载", "启动", "停止", "关闭", "退出",
"登录", "切换", "设置", "删除", "清理", "复制", "粘贴", "读取",
"剪贴板", "长按", "滑动", "播放", "发布", "检查", "看看",
"帮我", "给我", "请", "拍张", "查一下")
# 对话续语开头:几乎只出现在承接上一轮(「继续啊」「还有吗」)
_CONTINUE_PREFIXES = ("继续", "还有", "然后呢", "再来", "快点", "刚才",
"接着", "上一步")
# 强质疑/纠错信号(不含「为什么」——「查一下为什么」是正当任务)
_DOUBT_MARKS = ("你确定", "是不是", "不是吧", "不是吗", "怎么都", "怎么还",
"还没有", "没看到", "我说的是", "你听我说", "不对吧", "你又",
"重新来", "错了", "你说得", "你回答")
# 全部真实 MCP 工具(配方里出现不存在的 de_* 说明蒸馏模型在编造,弃存)
_KNOWN_TOOLS = frozenset({
"de_list_devices", "de_screenshot", "de_tap", "de_swipe", "de_ui_tree",
"de_tap_element", "de_read_clipboard", "de_wake", "de_press_key",
"de_open_app", "de_stop_app", "de_foreground_app", "de_type_text",
"de_set_clipboard", "de_sleep", "de_ocr", "de_tap_text", "de_list_apps",
"de_list_tasks"})
def _qualify_experience(prompt, recipe):
"""经验入库前质量门槛,返回 True=值得保存。
1) prompt 太短 / 纯续语开头 / 质疑纠错 → 非独立任务,弃
2) 疑问短句(≤30 字、以 吗/? 结尾)且无任务动词 → 追问/反问,弃
3) 配方含不存在的 de_* 工具(蒸馏模型自由发挥)→ 弃
"""
t = "".join(c for c in (prompt or "") if not c.isspace())
if len(t) < 6:
_log.info("经验弃存:prompt 过短「%s」", t[:20])
return False
if any(t.startswith(p) for p in _CONTINUE_PREFIXES):
_log.info("经验弃存:对话续语开头「%s」", t[:20])
return False
if any(m in t for m in _DOUBT_MARKS):
_log.info("经验弃存:质疑/纠错语气「%s」", t[:20])
return False
if (t.endswith("吗") or t.endswith("?") or t.endswith("?")) \
and len(t) <= 30 and not any(v in t for v in _TASK_VERBS):
_log.info("经验弃存:无操作诉求的追问「%s」", t[:20])
return False
for name in re.findall(r"de_[a-z_]+", recipe or ""):
if name not in _KNOWN_TOOLS:
_log.info("经验弃存:配方含不存在的工具 %s", name)
return False
return True
def _find_experiences(prompt, limit=2, threshold=0.10):
"""按 bigram 重叠检索相似历史经验(prompt 与任务描述的字符相似度)。
命中的经验 hits+1(回写),让被反复参考的有效经验浮到前面。
"""
try:
if _flask_app is None:
return ""
with _flask_app.app_context():
_ensure_exp_table()
rows = db.session.execute(db.text(
"SELECT id, task_prompt, recipe, hits FROM agent_experience "
"WHERE recipe != '' ORDER BY hits DESC, id DESC LIMIT 50")).fetchall()
except Exception:
return ""
if not rows:
return ""
cur = _bigrams(prompt)
if not cur:
return ""
scored = []
for row_id, task_prompt, recipe, hits in rows:
sim = len(cur & _bigrams(task_prompt)) / len(cur)
if sim >= threshold:
scored.append((sim, hits or 0, recipe, row_id))
scored.sort(key=lambda x: (-x[0], -x[1]))
if scored:
# hits 回写(尽力而为,失败不影响检索)
try:
with _flask_app.app_context():
for _sim, _hits, _recipe, row_id in scored:
db.session.execute(db.text(
"UPDATE agent_experience SET hits=hits+1 WHERE id=:i"),
{"i": row_id})
db.session.commit()
except Exception:
pass
parts = []
for sim, _hits, recipe, _row_id in scored[:limit]:
parts.append(f"- {recipe[:600]}")
return "\n".join(parts)
def _distill_experience(cfg, prompt, tool_seq):
"""任务完成后用模型把操作序列提炼为可复用配方(失败静默,不阻塞)。"""
try:
import httpx
body = {
"model": cfg.get("model") or "deepseek-v4-flash-vision-exp",
"messages": [{"role": "user",
"content": "以下是一次成功的手机自动化操作记录。请提炼成简洁的"
"「操作配方」(2-6 步,每步:目标 → 用哪个工具),"
"供下次同类任务参考。不要解释,直接输出配方。\n"
f"任务:{prompt[:300]}\n操作序列:{tool_seq[:800]}"}],
"max_tokens": 600,
}
headers = {"Authorization": f"Bearer {cfg.get('api_key', '')}",
"Content-Type": "application/json"}
r = httpx.post(f"{(cfg.get('api_base') or 'https://api.deepseek.com').rstrip('/')}/chat/completions",
json=body, headers=headers, timeout=25)
if r.status_code != 200:
return ""
j = r.json()
recipe = ((j.get("choices") or [{}])[0].get("message") or {}).get("content") or ""
return recipe.strip()[:1500]
except Exception as e:
_log.warning(f"经验提炼失败: {e}")
return ""
def _save_experience(prompt, recipe, tool_seq):
"""保存经验(后台线程调用,包 app context)。返回是否保存成功。"""
try:
if _flask_app is None:
return False
with _flask_app.app_context():
_ensure_exp_table()
from datetime import datetime
db.session.execute(db.text(
"INSERT INTO agent_experience(task_prompt, recipe, tool_seq, hits, created_at) "
"VALUES(:p, :r, :t, 0, :c)"),
{"p": prompt[:500], "r": recipe, "t": tool_seq[:1000],
"c": datetime.now().strftime("%Y-%m-%d %H:%M")})
db.session.commit()
_log.info("经验已保存(配方 %d 字符)", len(recipe))
return True
except Exception as e:
_log.warning(f"经验保存失败: {e}")
return False
# ================== 经验巡检(AI 质检,删除需人工确认) ==================
_audit_state = {"running": False, "last": "", "last_summary": ""}
def _ensure_audit_table():
try:
db.session.execute(db.text(_AUDIT_TABLE))
db.session.commit()
except Exception:
pass
def _review_one_exp(cfg, prompt, recipe, tool_seq, hits):
"""调模型评审单条经验,返回 (verdict, score, reason)。
模型偶尔输出解释文字不带 JSON——重试一次(强调只输出 JSON),
仍失败则保守返回保留。
"""
import httpx
base_prompt = _AUDIT_PROMPT.format(prompt=(prompt or "")[:400],
recipe=(recipe or "")[:600],
tool_seq=(tool_seq or "")[:600],
hits=hits or 0)
for attempt in (1, 2):
try:
content = base_prompt if attempt == 1 else (
base_prompt + "\n注意:只输出一个 JSON 对象,不要输出任何其它文字、解释或代码块标记。")
body = {
"model": cfg.get("model") or "deepseek-v4-flash-vision-exp",
"messages": [{"role": "user", "content": content}],
"max_tokens": 200,
}
headers = {"Authorization": f"Bearer {cfg.get('api_key', '')}",
"Content-Type": "application/json"}
r = httpx.post(f"{(cfg.get('api_base') or 'https://api.deepseek.com').rstrip('/')}/chat/completions",
json=body, headers=headers, timeout=20)
if r.status_code != 200:
raise RuntimeError(f"HTTP {r.status_code}")
text = (((r.json() or {}).get("choices") or [{}])[0]
.get("message") or {}).get("content") or ""
m = re.search(r"\{.*\}", text, re.S)
if not m:
raise RuntimeError("响应无 JSON")
j = json.loads(m.group(0))
keep = bool(j.get("keep", True))
score = max(0, min(10, int(j.get("score", 5) or 5)))
reason = str(j.get("reason", ""))[:200]
return ("keep" if keep else "delete"), score, reason
except Exception as e:
_log.warning(f"经验评审第 {attempt} 次失败: {e}")
return "keep", 0, "评审失败自动保留(两次尝试均无有效 JSON)"
def run_experience_audit():
"""巡检一轮:逐条评审经验 → 写 experience_audit。
规则:
- 最新已 kept / deleted 的经验不再重复评审(人工拍板过的尊重)
- 评审 keep → 直接 action=kept;delete → action=pending(等人工确认)
- 绝不自动删除;无 api_key 配置时跳过并记日志
由 web_server 每日定时调度或 API 手动触发(后台线程)。
"""
if _audit_state["running"]:
_log.info("经验巡检已在运行,跳过本次触发")
return
_audit_state["running"] = True
try:
if _flask_app is None:
return
with _flask_app.app_context():
_ensure_exp_table()
_ensure_audit_table()
cfg = _read_cfg()
if not cfg.get("api_key"):
_log.info("经验巡检跳过:未配置 API Key")
return
rows = db.session.execute(db.text(
"SELECT id, task_prompt, recipe, tool_seq, hits FROM agent_experience "
"ORDER BY id")).fetchall()
if not rows:
_log.info("经验巡检:经验库为空")
return
from datetime import datetime
now = datetime.now().strftime("%Y-%m-%d %H:%M")
reviewed = suggested = 0
for exp_id, prompt, recipe, tool_seq, hits in rows:
# 最新一次人工/评审结论:kept / deleted 的不再打扰
last = db.session.execute(db.text(
"SELECT action FROM experience_audit WHERE exp_id=:e "
"ORDER BY id DESC LIMIT 1"), {"e": exp_id}).scalar()
if last in ("kept", "deleted"):
continue
verdict, score, reason = _review_one_exp(
cfg, prompt, recipe, tool_seq, hits)
action = "kept" if verdict == "keep" else "pending"
db.session.execute(db.text(
"INSERT INTO experience_audit"
"(exp_id, verdict, score, reason, hits, action, audited_at) "
"VALUES(:e, :v, :s, :r, :h, :a, :t)"),
{"e": exp_id, "v": verdict, "s": score, "r": reason,
"h": hits or 0, "a": action, "t": now})
reviewed += 1
suggested += 1 if action == "pending" else 0
db.session.commit()
summary = f"评审 {reviewed} 条,建议删除 {suggested} 条(待人工确认)"
_audit_state.update(last=now, last_summary=summary)
_log.info("经验巡检完成: %s", summary)
except Exception as e:
_log.warning(f"经验巡检异常: {e}")
finally:
_audit_state["running"] = False
# ================== 经验库管理 API ==================
@bp.route("/api/agent/experience")
@admin_required
def agent_experience_list():
"""经验库列表(含最近一次巡检结论),供 AI 控制台「🧠 经验库」面板管理。"""
_ensure_exp_table()
_ensure_audit_table()
rows = db.session.execute(db.text(
"SELECT e.id, e.task_prompt, e.recipe, e.tool_seq, e.hits, e.created_at, "
"a.verdict, a.score, a.reason, a.action, a.audited_at "
"FROM agent_experience e LEFT JOIN experience_audit a ON a.id = "
"(SELECT id FROM experience_audit WHERE exp_id = e.id ORDER BY id DESC LIMIT 1) "
"ORDER BY e.id DESC")).fetchall()
exps = []
for r in rows:
(eid, task_prompt, recipe, tool_seq, hits, created_at,
verdict, score, reason, action, audited_at) = r
audit = None
if action:
audit = {"verdict": verdict, "score": round(score or 0, 1),
"reason": reason, "action": action, "at": audited_at}
exps.append({"id": eid, "task_prompt": task_prompt or "",
"recipe": recipe or "", "tool_seq": tool_seq or "",
"hits": hits or 0, "created_at": created_at or "",
"audit": audit})
return jsonify({"ok": True, "running": _audit_state["running"],
"last": _audit_state["last"],
"last_summary": _audit_state["last_summary"],
"experiences": exps})
@bp.route("/api/agent/experience/delete", methods=["POST"])
@admin_required
def agent_experience_delete():
"""人工确认删除经验(真删;前端有二次确认,巡检绝不自动调它)。"""
data = request.json or {}
exp_id = int(data.get("id") or 0)
if exp_id <= 0:
return jsonify({"ok": False, "error": "缺少 id"}), 400
db.session.execute(db.text(
"DELETE FROM agent_experience WHERE id=:i"), {"i": exp_id})
db.session.execute(db.text(
"UPDATE experience_audit SET action='deleted' "
"WHERE exp_id=:i AND action='pending'"), {"i": exp_id})
db.session.commit()
_log.info("人工确认删除经验 #%d", exp_id)
return jsonify({"ok": True, "msg": f"经验 #{exp_id} 已删除"})
@bp.route("/api/agent/experience/audit", methods=["POST"])
@admin_required
def agent_experience_audit_now():
"""手动触发一轮经验巡检(后台线程;进行中返回 409)。"""
if _audit_state["running"]:
return jsonify({"ok": False, "error": "巡检正在进行中"}), 409
threading.Thread(target=run_experience_audit, daemon=True).start()
return jsonify({"ok": True, "msg": "巡检已启动,完成后刷新列表查看建议"})
@bp.route("/api/agent/experience/keep", methods=["POST"])
@admin_required
def agent_experience_keep():
"""人工保留:撤销建议删除(该条后续巡检不再重复建议)。"""
data = request.json or {}
exp_id = int(data.get("id") or 0)
if exp_id <= 0:
return jsonify({"ok": False, "error": "缺少 id"}), 400
db.session.execute(db.text(
"UPDATE experience_audit SET action='kept' "
"WHERE exp_id=:i AND action='pending'"), {"i": exp_id})
db.session.commit()
return jsonify({"ok": True, "msg": f"经验 #{exp_id} 已保留"})
# ================== 历史会话(DeepSeek 式:多会话持久化) ==================
# 会话 = 消息序列(JSON),一轮 run 的 user/assistant 文本完成后追加落库。
# 单表 JSON 存储(每会话几十 KB 内,无需独立消息表)。
_CONV_TABLE = """
CREATE TABLE IF NOT EXISTS agent_conversation (
id VARCHAR(20) PRIMARY KEY,
title VARCHAR(100) DEFAULT '',
messages TEXT DEFAULT '[]',
created_at VARCHAR(20) DEFAULT '',
updated_at VARCHAR(20) DEFAULT '')"""
def _ensure_conv_table():
try:
db.session.execute(db.text(_CONV_TABLE))
db.session.commit()
except Exception:
pass
def _conv_msgs(conv_id):
"""读会话消息列表 [{role, content}]。会话不存在返回 None。"""
try:
row = db.session.execute(db.text(
"SELECT messages FROM agent_conversation WHERE id=:i"),
{"i": conv_id}).fetchone()
except Exception:
return None
if not row:
return None
try:
return json.loads(row[0] or "[]")
except Exception:
return []
def _conv_save_messages(conv_id, messages, title=None):
"""整段写回会话消息 + 时间戳(尽力而为,失败不影响主流程)。"""
try:
from datetime import datetime
now = datetime.now().strftime("%Y-%m-%d %H:%M")
msgs_json = json.dumps(messages[-60:], ensure_ascii=False)
if title:
db.session.execute(db.text(
"UPDATE agent_conversation SET messages=:m, title=:t, "
"updated_at=:u WHERE id=:i"),
{"m": msgs_json, "t": title[:100], "u": now, "i": conv_id})
else:
db.session.execute(db.text(
"UPDATE agent_conversation SET messages=:m, updated_at=:u "
"WHERE id=:i"),
{"m": msgs_json, "u": now, "i": conv_id})
db.session.commit()
return True
except Exception as e:
_log.warning(f"会话保存失败: {e}")
return False
@bp.route("/api/agent/conversations", methods=["GET"])
@admin_required
def agent_conversations_list():
"""会话列表(按最近更新倒序):{id, title, updated_at, count}。"""
_ensure_conv_table()
rows = db.session.execute(db.text(
"SELECT id, title, messages, updated_at FROM agent_conversation "
"ORDER BY updated_at DESC, created_at DESC")).fetchall()
out = []
for cid, title, messages, updated_at in rows:
try:
count = len(json.loads(messages or "[]")) // 2
except Exception:
count = 0
out.append({"id": cid, "title": title or "新会话",
"updated_at": updated_at or "", "count": count})
return jsonify({"ok": True, "conversations": out})
@bp.route("/api/agent/conversations", methods=["POST"])
@admin_required
def agent_conversations_create():
"""新建会话(空消息)。返回 {id}。"""
_ensure_conv_table()
from datetime import datetime
cid = uuid.uuid4().hex[:10]
now = datetime.now().strftime("%Y-%m-%d %H:%M")
db.session.execute(db.text(
"INSERT INTO agent_conversation(id, title, messages, created_at, updated_at) "
"VALUES(:i, '', '[]', :c, :c)"), {"i": cid, "c": now})
db.session.commit()
return jsonify({"ok": True, "id": cid, "title": "新会话"})
@bp.route("/api/agent/conversations/<conv_id>", methods=["GET"])
@admin_required
def agent_conversations_detail(conv_id):
"""会话详情(全部消息文本)。"""
_ensure_conv_table()
msgs = _conv_msgs(conv_id)
if msgs is None:
return jsonify({"ok": False, "error": "会话不存在"}), 404
row = db.session.execute(db.text(
"SELECT title, created_at, updated_at FROM agent_conversation "
"WHERE id=:i"), {"i": conv_id}).fetchone()
return jsonify({"ok": True, "id": conv_id,
"title": (row[0] if row else "") or "新会话",
"messages": msgs,
"created_at": row[1] if row else "",
"updated_at": row[2] if row else ""})
@bp.route("/api/agent/conversations/<conv_id>", methods=["DELETE"])
@admin_required
def agent_conversations_delete(conv_id):
"""删除会话(消息一并删除,不可恢复)。"""
_ensure_conv_table()
db.session.execute(db.text(
"DELETE FROM agent_conversation WHERE id=:i"), {"i": conv_id})
db.session.commit()
_log.info(f"删除会话 {conv_id}")
return jsonify({"ok": True, "msg": "会话已删除"})
@bp.route("/api/agent/conversations/<conv_id>/rename", methods=["POST"])
@admin_required
def agent_conversations_rename(conv_id):
"""重命名会话。"""
data = request.json or {}
title = (data.get("title") or "").strip()[:100]
if not title:
return jsonify({"ok": False, "error": "标题不能为空"}), 400
_ensure_conv_table()
db.session.execute(db.text(
"UPDATE agent_conversation SET title=:t WHERE id=:i"),
{"t": title, "i": conv_id})
db.session.commit()
return jsonify({"ok": True, "msg": "已重命名"})
# ================== 运行 ==================
@bp.route("/api/agent/run", methods=["POST"])
@admin_required
def agent_run():
"""启动 Agent:{prompt, serial?, conversation_id?}。运行中返回 409。"""
data = request.json or {}
prompt = (data.get("prompt") or "").strip()
if not prompt:
return jsonify({"ok": False, "error": "请输入指令"}), 400
serial = (data.get("serial") or "").strip()
conv_id = (data.get("conversation_id") or "").strip()
cfg = _read_cfg()
if not cfg.get("api_key"):
return jsonify({"ok": False, "error": "请先在配置区填写 API Key"}), 400
if not cfg.get("model"):
return jsonify({"ok": False, "error": "请先填写模型名"}), 400
if not serial and not (cfg.get("default_serial") or "").strip():
return jsonify({"ok": False, "error": "请先选择目标设备(AI 只操作你指定的设备)"}), 400
# 目标设备校验:必须在池、在线、且无任务运行(AI 不与任务抢设备;
# MCP 层另有 busy 锁兜底外部客户端)
if serial:
try:
from web import context
devs, err = context.mgr.get_status()
dev = next((x for x in (devs or []) if x.get("serial") == serial), None)
if err or dev is None:
return jsonify({"ok": False, "error": f"设备 {serial} 不在设备池"}), 400
if not dev.get("present"):
return jsonify({"ok": False, "error": f"设备 {serial} 当前离线,请稍后再试"}), 400
if dev.get("worker_status") in ("running", "connecting"):
return jsonify({"ok": False, "error":
f"设备 {serial} 正在执行任务「{dev.get('task_job') or ''}」——"
f"AI 不与任务抢设备,任务结束后才能操作(或在任务页先停止)"}), 409
except Exception:
pass # 状态服务异常不阻塞(MCP busy 锁兜底)
# 会话校验:不存在则自动新建(标题=首条消息截断)
if conv_id:
_ensure_conv_table()
if _conv_msgs(conv_id) is None:
try:
from datetime import datetime as _dt2
now = _dt2.now().strftime("%Y-%m-%d %H:%M")
db.session.execute(db.text(
"INSERT OR IGNORE INTO agent_conversation(id, messages, "
"created_at, updated_at) VALUES(:i, '[]', :c, :c)"),
{"i": conv_id, "c": now})
db.session.commit()
except Exception:
pass
with _lock:
if _run["state"] == "running":
return jsonify({"ok": False, "error":
f"已有 Agent 运行中({_run.get('prompt', '')[:40]}…),"
f"请等待完成或先停止"}), 409
run_id = uuid.uuid4().hex[:8]
from datetime import datetime as _dt
_run.update(id=run_id, state="running", prompt=prompt,
serial=serial, conv_id=conv_id,
started=_dt.now().strftime("%H:%M:%S"),
answer="", error="")
# history 保留(多轮上下文),由会话/「新建会话」管理
_queues[run_id] = queue.Queue()
_stop_events[run_id] = threading.Event()
_log.info(f"Agent 启动: {prompt[:60]} @ {serial or 'default'} conv={conv_id or '-'}")
threading.Thread(target=_agent_thread,
args=(run_id, prompt, serial, cfg),
daemon=True).start()
return jsonify({"ok": True, "run_id": run_id})
@bp.route("/api/agent/config", methods=["GET"])
@admin_required
def agent_config_get():
"""读 Agent 配置(key 打码返回)。"""
cfg = _read_cfg()
if cfg["api_key"]:
k = cfg["api_key"]
cfg["api_key_masked"] = k[:6] + "***" + k[-4:]
return jsonify({"ok": True, **cfg})
@bp.route("/api/agent/config", methods=["POST"])
@admin_required
def agent_config_save():
"""保存 Agent 配置:{api_base?, model?, api_key?, default_serial?} 部分更新。"""
data = request.json or {}
for key, meta_key in _CFG_KEYS.items():
if key in data and data[key] is not None:
_meta_put(meta_key, str(data[key]).strip())
db.session.commit()
return jsonify({"ok": True, "msg": "已保存"})
@bp.route("/api/agent/run", methods=["GET"])
@admin_required
def agent_run_status():
"""当前 Agent 运行状态(多窗口/页面刷新恢复用):
idle/running/done + run_id + 最终答案 + 会话历史。
前端刷新后据此:渲染历史轮次、running 时重新订阅事件流(服务端队列
保留积压事件,重连后补发)、done 时展示最终答案。
"""
with _lock:
hist = [{"role": h.get("role"), "content": (h.get("content") or "")[:4000]}
for h in (_run.get("history") or [])]
return jsonify({"ok": True,
"state": _run.get("state", "idle"),
"run_id": _run.get("id") or "",
"prompt": _run.get("prompt", ""),
"serial": _run.get("serial", ""),
"started": _run.get("started", ""),
"answer": (_run.get("answer") or "")[:4000],
"error": (_run.get("error") or "")[:400],
"history": hist[-16:]})
@bp.route("/api/agent/devices")
@admin_required
def agent_devices():
"""AI 可用设备列表:在线状态 + 是否有任务运行(前端选择器 busy 设备禁选)。
worker 状态实时(内存);busy = 任务 running/connecting。
"""
try:
from web import context
devs, err = context.mgr.get_status()
except Exception as e:
return jsonify({"ok": False, "error": str(e)[:120]}), 503
out = []
for d in (devs or []):
busy = d.get("worker_status") in ("running", "connecting")
out.append({"serial": d.get("serial"),
"model": d.get("model") or "",
"online": bool(d.get("present")),
"busy": busy,
"worker_status": d.get("worker_status") or "idle",
"task_job": d.get("task_job") or ""})
return jsonify({"ok": True, "devices": out})
@bp.route("/api/agent/stream")
@admin_required
def agent_stream():
"""SSE 事件流(EventSource):delta/step/done/error。"""
run_id = request.args.get("run_id", "")
with _lock:
if run_id != _run["id"]:
return jsonify({"ok": False, "error": "run_id 不存在"}), 404
q = _queues.get(run_id)
def gen():
while True:
try:
evt = q.get(timeout=15)
except queue.Empty:
yield ": keepalive\n\n" # 心跳防超时
continue
if evt is None:
break
kind, payload = evt
yield f"event: {kind}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n"
if kind in ("done", "error"):
break
return Response(gen(), mimetype="text/event-stream",
headers={"Cache-Control": "no-cache",
"X-Accel-Buffering": "no"})
@bp.route("/api/agent/stop", methods=["POST"])
@admin_required
def agent_stop():
"""中断当前运行的 Agent(下一个检查点生效,通常在数秒内)。"""
with _lock:
if _run["state"] != "running":
return jsonify({"ok": False, "error": "当前没有运行中的任务"}), 400
evt = _stop_events.get(_run["id"])
if evt:
evt.set()
_log.info("用户请求中断 Agent")
return jsonify({"ok": True, "msg": "已请求停止"})
@bp.route("/api/agent/clear", methods=["POST"])
@admin_required
def agent_clear():
"""清空对话历史。"""
with _lock:
_run["history"] = []
return jsonify({"ok": True, "msg": "已清空"})
def _shrink_image(b64, width=220, quality=50):
"""截图降采样(SSE step 事件用,控制传输体积)。失败原样返回。"""
try:
from PIL import Image
img = Image.open(io.BytesIO(base64.b64decode(b64)))
if img.width > width:
img = img.resize((width, int(img.height * width / img.width)))
buf = io.BytesIO()
img.convert("RGB").save(buf, "JPEG", quality=quality)
return base64.b64encode(buf.getvalue()).decode()
except Exception:
return b64
def _agent_thread(run_id, prompt, serial, cfg):
"""后台线程:Agent 流式执行,事件推入队列供 SSE 消费。"""
q = _queues.get(run_id)
try:
# 平台进程 cwd=/app(含 mcp_agent 包),进程内 import
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from mcp_agent.agent import Agent
def on_delta(text, kind):
q.put(("delta", {"text": text, "kind": kind}))
tool_seq = [] # 本轮工具序列(经验提炼用)
def on_tool(step):
# args 保留对象(json.dumps 序列化)——前端要解析 serial 做画面跟随;
# 之前 str() 成 Python repr(单引号)导致前端 JSON.parse 失败、跟随失效
rec = {"tool": step.get("tool"),
"args": step.get("args") or {}}
if step.get("image"):
rec["image"] = _shrink_image(step["image"])
q.put(("step", rec))
# 记录精简工具序列
try:
args = step.get("args") or {}
brief = {k: v for k, v in args.items() if k != "serial"}
tool_seq.append(f"{step.get('tool')}({str(brief)[:60]})")
except Exception:
pass
agent = Agent()
agent.s.api_base = cfg.get("api_base") or agent.s.api_base
agent.s.model = cfg.get("model") or agent.s.model
agent.s.api_key = cfg.get("api_key") or agent.s.api_key
agent.s.default_serial = cfg.get("default_serial") or agent.s.default_serial
try:
agent.s.max_steps = max(1, min(200, int(cfg.get("max_steps") or 40)))
except (TypeError, ValueError):
pass # 非法值用默认 40
with _lock:
conv_id = _run.get("conv_id") or ""
# 历史来源:绑定了会话 → 从会话读(多轮上下文延续,DeepSeek 式);
# 无会话(CLI/兼容)→ 用运行态 history
if conv_id:
# 后台线程需自行推 app context(与 _find_experiences 同思路)
with _flask_app.app_context():
msgs = _conv_msgs(conv_id)
history = [{"role": m.get("role"), "content": m.get("content", "")[:4000]}
for m in (msgs or []) if m.get("role") in ("user", "assistant")]
history = history[-24:]
with _lock:
_run["history"] = list(history)
else:
with _lock:
history = list(_run.get("history") or [])
target = serial or cfg.get("default_serial") or ""
stop_evt = _stop_events.get(run_id)
# 经验检索:相似历史任务的操作配方注入 system(自进化记忆)
exp_ctx = _find_experiences(prompt)
if exp_ctx:
_log.info("命中历史经验,注入参考配方")
q.put(("step", {"tool": "🧠 经验记忆",
"args": f"命中 {exp_ctx.count(chr(10) + '- ')} 条同类历史经验,已注入参考",
"image": None}))
async def _execute():
await agent._load_tools()
return await agent.run_stream(prompt, target,
history=history,
on_delta=on_delta, on_tool=on_tool,
should_stop=lambda: bool(
stop_evt and stop_evt.is_set()),
extra_context=exp_ctx)
# 整体超时保护:卡死时结束,释放单实例
answer = asyncio.run(asyncio.wait_for(_execute(), timeout=900))
with _lock:
_run["state"] = "done"
_run["answer"] = answer
# 追加本轮进历史(多轮连续性;上限 12 轮防 token 膨胀)
hist = _run.setdefault("history", [])
hist.append({"role": "user", "content": prompt[:2000]})
hist.append({"role": "assistant", "content": (answer or "")[:4000]})
_run["history"] = hist[-24:]
# 会话落库:本轮追加写回(新会话自动用首条消息作标题)。
# 后台线程 db 访问需 app context。
if conv_id and _flask_app is not None:
try:
with _flask_app.app_context():
saved = list(_run["history"])
title = None
if not db.session.execute(db.text(
"SELECT title FROM agent_conversation WHERE id=:i"),
{"i": conv_id}).scalar():
title = "".join(prompt.split())[:30] or "新会话"
_conv_save_messages(conv_id, saved, title=title)
_log.info(f"会话已保存: {conv_id}({len(saved)} 条消息)")
except Exception as e:
_log.warning(f"会话落库失败: {e}")
# 自进化:成功执行过工具则提炼配方写入经验。必须在 done 之前完成——
# done 发出后 SSE 关流,用户就看不到「已写入经验」的提示了。
# 提炼/保存失败静默(不阻塞、不影响结果),只在成功时推送 🧠 卡片。
if tool_seq:
try:
recipe = _distill_experience(cfg, prompt, " -> ".join(tool_seq))
if recipe and "配方" not in recipe[:50]:
if _qualify_experience(prompt, recipe):
if _save_experience(prompt, recipe, " -> ".join(tool_seq)):
_log.info("经验已写入记忆库,随事件流提示")
q.put(("step", {"tool": "🧠 经验记忆",
"args": "本轮操作已提炼为经验并写入记忆库"
"(下次相似任务会自动参考)",
"image": None}))
except Exception as e:
_log.warning(f"经验保存异常: {e}")
q.put(("done", {"answer": answer}))
except Exception as e:
_log.warning(f"Agent 运行异常: {e}")
# 诊断:打印消息结构(tool_calls 与 tool 消息配对检查)
try:
roles = [m.get("role", "?") for m in agent.messages]
tcs = sum(1 for m in agent.messages
if m.get("tool_calls") and isinstance(m.get("tool_calls"), list))
tools_msg = sum(1 for m in agent.messages if m.get("role") == "tool")
_log.warning(f"诊断 messages: {len(agent.messages)} 条 roles={roles[-8:]} "
f"tool_calls消息={tcs} tool回应={tools_msg}")
except Exception:
pass
with _lock:
_run["state"] = "error"
_run["error"] = f"{type(e).__name__}: {str(e)[:200]}"
q.put(("error", {"message": str(e)[:200]}))
finally:
q.put(None) # 关闭 SSE
_queues.pop(run_id, None)
_stop_events.pop(run_id, None)
+35 -1
View File
@@ -118,14 +118,16 @@ def api_devices_pool_refresh_models():
@bp.route("/api/devices/discovery", methods=["GET"])
@perm_required(PERM_DEVICES)
def api_devices_discovery():
"""发现状态 + 待连接列表(前端 10s 轮询一次请求)。"""
"""发现状态 + 待连接列表 + 正式池断联设备(前端 10s 轮询一次请求)。"""
try:
status = device_discovery.get_status()
pending = device_discovery.list_pending()
pool_offline = device_discovery.list_pool_offline()
except Exception as e:
_log.warning(f"discovery 状态获取失败: {e}")
return jsonify({"ok": False, "error": str(e)[:120]}), 503
status["pending"] = pending
status["pool_offline"] = pool_offline
return jsonify({"ok": True, **status})
@@ -182,6 +184,38 @@ def api_devices_discovery_ignore():
return jsonify({"ok": True, "msg": msg})
@bp.route("/api/devices/discovery/reconnect", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_devices_discovery_reconnect():
"""手动立即重连正式池断联设备(后台 adb connect + 采型号)。
日常无需手动——发现线程每轮(默认 60s)自动重连断联设备;
本端点用于不想等下一轮扫描时立即拉起。
"""
serial = (request.json or {}).get("serial", "").strip()
if not serial:
return jsonify({"ok": False, "error": "缺少 serial"}), 400
from core import device_pool
if serial not in device_pool.list_configured():
return jsonify({"ok": False, "error": "设备不在设备池中"}), 404
def _reconnect():
try:
if ":" in serial:
adb_connect(serial)
# 无论是否连上,采一次型号(模型失败静默)
try:
device_pool.refresh_model(serial)
except Exception:
pass
except Exception as e:
_log.warning(f"手动重连 {serial} 异常: {e}")
threading.Thread(target=_reconnect, daemon=True).start()
_log.info(f"设备池管理: 手动重连 {serial}")
return jsonify({"ok": True, "msg": "重连已启动(约 5-15 秒生效)"})
@bp.route("/api/devices/discovery/settings", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_devices_discovery_settings():
+118 -3
View File
@@ -1,6 +1,7 @@
"""监控域 API:状态/运行控制/设备操作/远程看屏。"""
import time
import threading
import re
import subprocess
import shlex
from concurrent.futures import ThreadPoolExecutor, as_completed
@@ -476,21 +477,116 @@ def api_screen_thumb():
return jsonify({"ok": False, "error": str(e)}), 503
def _snap_to_clickable(d, x, y):
"""坐标吸附:找包含 (x,y) 的最小可点击元素,返回其 bounds 中心。
AI/触控给的坐标常偏离目标 20-50px(模型视觉定位误差)。dump 当前 UI 树后
遍历 clickable 节点:点击点落在哪个可点元素内就点它的中心——偏了也点得准;
取包含元素中面积最小者(最具体的那个)。点空白处(收键盘等)无包含元素则
原坐标返回。dump 失败也不阻塞,直接原坐标。返回 (cx, cy, snapped, label)。
"""
try:
import xml.etree.ElementTree as ET
xml_str = d.dump_hierarchy()
best = None # (area, cx, cy, label)
for node in ET.fromstring(xml_str).iter("node"):
if node.get("clickable") != "true":
continue
if node.get("enabled") == "false":
continue
m = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", node.get("bounds", ""))
if not m:
continue
x1, y1, x2, y2 = map(int, m.groups())
if x1 <= x <= x2 and y1 <= y <= y2:
area = (x2 - x1) * (y2 - y1)
if best is None or area < best[0]:
best = (area, (x1 + x2) // 2, (y1 + y2) // 2,
(node.get("text") or node.get("content-desc") or "")[:40])
if best:
return best[1], best[2], True, best[3]
except Exception:
pass
return x, y, False, ""
@bp.route("/api/screen/tap", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_screen_tap():
"""点击:{serial, x, y}(设备原生分辨率坐标)。"""
"""点击:{serial, x, y, snap?}(设备原生分辨率坐标)。
snap=1 时先吸附:点落在可点击元素内则改点元素中心(MCP/AI 场景用,粗略
坐标也能点准);大屏精确触控不带 snap 保持原行为。
返回 {ok, snapped, x, y, label}。
"""
data = request.json or {}
serial, x, y = data.get("serial", ""), data.get("x"), data.get("y")
snap = int(data.get("snap", 0) or 0) == 1
if not serial or x is None or y is None:
return jsonify({"ok": False, "error": "缺少 serial/x/y"}), 400
try:
_screen_get_device(serial).click(int(x), int(y))
return jsonify({"ok": True})
d = _screen_get_device(serial)
sx, sy = int(x), int(y)
label = ""
snapped = False
if snap:
sx, sy, snapped, label = _snap_to_clickable(d, sx, sy)
d.click(sx, sy)
return jsonify({"ok": True, "snapped": snapped, "x": sx, "y": sy,
"label": label})
except Exception as e:
_screen_invalidate(serial)
return jsonify({"ok": False, "error": f"点击失败: {e}"}), 503
@bp.route("/api/screen/tap_text", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_screen_tap_text():
"""按屏幕文字点击:{serial, text}——找到显示该文字的位置并点中心(子串匹配)。
两步:① UI 树 textContains/descriptionContains 命中 → 点元素中心(原生控件);
② 未命中 → 截图 OCR 找文字中心(WebView/图片/画布渲染的文字)。
返回 {ok, found, method: ui|ocr, matched, x, y};found=false 表示屏幕确实
没有该文字(业务结果非设备错误);OCR 不可用且 UI 未命中时也返回 found=false。
"""
data = request.json or {}
serial = (data.get("serial") or "").strip()
text = (data.get("text") or "").strip()
if not serial or not text:
return jsonify({"ok": False, "error": "缺少 serial/text"}), 400
if len(text) > 100:
return jsonify({"ok": False, "error": "文字过长(≤100 字符)"}), 400
try:
d = _screen_get_device(serial)
# ① UI 树:text / content-desc 子串匹配(原生控件最快最准)
for kw in ({"textContains": text}, {"descriptionContains": text}):
try:
el = d(**kw)
if el.exists(timeout=1.5):
# u2 版本差异:3.x 部分版本 bounds 是方法(el.bounds())
b = el.bounds() if callable(el.bounds) else el.bounds
el.click()
return jsonify({"ok": True, "found": True, "method": "ui",
"matched": text,
"x": (b[0] + b[2]) // 2, "y": (b[1] + b[3]) // 2})
except Exception:
continue
# ② OCR 兜底:截图找文字中心(UI 树没有的渲染文字)
img = d.screenshot()
if img is not None:
from core.ocr import find_on_screen
found, center, matched = find_on_screen(img, text)
if found:
d.click(*center)
return jsonify({"ok": True, "found": True, "method": "ocr",
"matched": matched,
"x": center[0], "y": center[1]})
return jsonify({"ok": True, "found": False, "method": "",
"matched": "", "x": 0, "y": 0})
except Exception as e:
_screen_invalidate(serial)
return jsonify({"ok": False, "error": f"文字点击失败: {e}"}), 503
@bp.route("/api/screen/swipe", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_screen_swipe():
@@ -542,6 +638,25 @@ def api_screen_text():
return jsonify({"ok": False, "error": f"输入失败: {e}"}), 503
@bp.route("/api/screen/size")
@perm_required(PERM_DEVICES)
def api_screen_size():
"""屏幕原生分辨率(只读,供 MCP 坐标换算:截图是缩放图,操作需原生坐标)。
返回 {ok, width, height}(如 1080x2400);离线/不可达 → 503。
"""
serial = request.args.get("serial", "")
if not serial:
return jsonify({"ok": False, "error": "缺少 serial"}), 400
try:
d = _screen_get_device(serial)
w, h = d.window_size()
return jsonify({"ok": True, "width": w, "height": h})
except Exception as e:
_screen_invalidate(serial)
return jsonify({"ok": False, "error": f"获取分辨率失败: {e}"}), 503
# ================== API:应用管理(APK 上传/安装)==================
def _current_focus_pkg(serial):
+31 -2
View File
@@ -57,6 +57,19 @@ from web.auth import _csrf_protect, _csrf_token
context.init(mgr, apk_mgr, device_pool)
register_blueprints(app)
# 经验库每日 AI 巡检(凌晨 03:47):评审标记疑似问题经验,删除只走人工确认。
# 巡检线程在 run_experience_audit 内自建 app context,不依赖这里。
try:
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from web.agent_api import run_experience_audit
_audit_sched = BackgroundScheduler(timezone="Asia/Shanghai")
_audit_sched.add_job(run_experience_audit, CronTrigger(hour=3, minute=47))
_audit_sched.start()
_log.info("经验库每日巡检已注册(03:47 Asia/Shanghai)")
except Exception as e:
_log.warning(f"经验库巡检调度注册失败(不影响主服务): {e}")
@login_manager.user_loader
def load_user(user_id):
@@ -71,14 +84,30 @@ _UIAUTO_PID_FILE = os.path.join(
_uiauto_proc = None
def _pid_cmdline_has(pid, keyword):
"""校验 PID 对应的进程命令行含 keyword(容器重启后 PID 复用会误杀,
杀前必须确认目标真是残留的 uiautodev,而不是 MCP/web 等其它进程)。"""
try:
with open(f"/proc/{pid}/cmdline", "rb") as f:
cmd = f.read().replace(b"\x00", b" ").decode(errors="replace")
return keyword in cmd
except Exception:
return False
def _kill_stale_uiauto():
"""清理上次 web_server 残留的 uiautodev 进程(读取 PID 文件)。"""
"""清理上次 web_server 残留的 uiautodev 进程(读取 PID 文件)。
容器重启后 PID namespace 重建、PID 会被复用(MCP/其它进程可能拿到与
uiauto.pid 相同的值),因此 kill 前先校验命令行确为 uiautodev,防止
误杀同容器内的 MCP server 等进程导致连锁崩溃。
"""
if not os.path.exists(_UIAUTO_PID_FILE):
return
try:
with open(_UIAUTO_PID_FILE) as f:
pid = int(f.read().strip())
if pid > 0 and pid != os.getpid():
if pid > 0 and pid != os.getpid() and _pid_cmdline_has(pid, "uiautodev"):
try:
os.kill(pid, 0) # 探测进程是否存活
os.kill(pid, 15) # 终止残留