883 lines
34 KiB
Python
883 lines
34 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
工作手机Agent v3.0 - AI数字员工
|
||
运行在Android手机上,主动连接SDK服务器,保持实时通信
|
||
|
||
核心架构:
|
||
┌──────────────────────────────────────────────────────┐
|
||
│ 手机端 Agent (Python/Termux) │
|
||
│ ┌─────────────┐ ┌─────────────┐ ┌──────────────┐ │
|
||
│ │ WebSocket │ │ Skill引擎 │ │ 状态监控 │ │
|
||
│ │ 实时连接 │ │ 微信/抖音 │ │ 电量/网络 │ │
|
||
│ └──────┬──────┘ └──────┬──────┘ └──────┬───────┘ │
|
||
│ └────────────────┴─────────────────┘ │
|
||
│ ↕ uiautomator2 / AccessibilityService │
|
||
│ ┌────────────────────────────────────┐ │
|
||
│ │ Android系统 + 微信/抖音等APP │ │
|
||
│ └────────────────────────────────────┘ │
|
||
└──────────────────────────────────────────────────────┘
|
||
↕ WebSocket (wss://)
|
||
┌──────────────────────────────────────────────────────┐
|
||
│ SDK服务器 (FastAPI) │
|
||
│ 存客宝/触客宝通过API调用 → 服务器下发命令到手机 │
|
||
└──────────────────────────────────────────────────────┘
|
||
|
||
连接机制:
|
||
- 手机主动发起WebSocket连接到服务器
|
||
- 心跳保活: 每30秒发送心跳包,服务器10秒无响应则重连
|
||
- 指数退避重连: 2s → 4s → 8s → 16s → 30s(最大)
|
||
- 断线自动重连,永不断开
|
||
- 操作时界面无感(通过u2后台操作,用户无感知)
|
||
|
||
配置优先级: 环境变量 > 命令行参数 > config.json > 默认值
|
||
WP_DEVICE_ID 设备ID
|
||
WP_SERVER_URL WebSocket基础地址(如 ws://192.168.1.100:8899/ws/device)
|
||
WP_PROJECT_ID 项目ID
|
||
WP_HEARTBEAT 心跳间隔(秒)
|
||
|
||
@author 卡若
|
||
@version 3.0.0
|
||
"""
|
||
|
||
import asyncio
|
||
import json
|
||
import logging
|
||
import argparse
|
||
import os
|
||
import sys
|
||
import signal
|
||
import time
|
||
import base64
|
||
import random
|
||
from datetime import datetime
|
||
from typing import Optional, Dict, Any
|
||
|
||
# 确保 agent/ 目录在 sys.path 中,方便 Termux 等环境直接运行
|
||
_AGENT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||
if _AGENT_DIR not in sys.path:
|
||
sys.path.insert(0, _AGENT_DIR)
|
||
|
||
try:
|
||
import websockets
|
||
except ImportError:
|
||
print("❌ 请安装: pip install websockets>=12.0")
|
||
sys.exit(1)
|
||
|
||
try:
|
||
import uiautomator2 as u2
|
||
except ImportError:
|
||
u2 = None
|
||
print("⚠️ uiautomator2未安装,部分功能不可用(pip install uiautomator2>=3.0.0)")
|
||
|
||
# 配置日志
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format='%(asctime)s [%(levelname)s] %(message)s'
|
||
)
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class WorkPhoneAgent:
|
||
"""
|
||
AI数字员工 - 手机端Agent
|
||
|
||
核心职责:
|
||
1. 主动连接SDK服务器并保持实时通信
|
||
2. 接收服务器命令并在手机上执行
|
||
3. 实时上报设备状态(电量/网络/APP状态)
|
||
4. 支持微信/抖音/小红书等多APP控制
|
||
"""
|
||
|
||
VERSION = "3.0.0"
|
||
|
||
# 重连策略: 指数退避
|
||
MIN_RECONNECT_DELAY = 2 # 最小重连间隔(秒)
|
||
MAX_RECONNECT_DELAY = 30 # 最大重连间隔(秒)
|
||
|
||
def __init__(
|
||
self,
|
||
device_id: str,
|
||
server_url: str,
|
||
heartbeat_interval: int = 30,
|
||
project_id: str = "default",
|
||
):
|
||
self.device_id = device_id
|
||
self.server_url = server_url
|
||
self.heartbeat_interval = heartbeat_interval
|
||
self.project_id = project_id
|
||
|
||
self.ws: Optional[websockets.WebSocketClientProtocol] = None
|
||
self.running = False
|
||
self.connected = False
|
||
self.reconnect_attempts = 0
|
||
self.last_heartbeat_ack = time.time()
|
||
self.commands_executed = 0
|
||
self.start_time = None
|
||
|
||
# 初始化uiautomator2
|
||
self.d = None
|
||
if u2:
|
||
try:
|
||
self.d = u2.connect()
|
||
self.d.implicitly_wait(10.0)
|
||
# 设置u2操作不显示弹窗
|
||
self.d.settings['operation_delay'] = (0, 0)
|
||
self.d.settings['operation_delay_methods'] = []
|
||
logger.info(f"uiautomator2连接成功: {self.d.info.get('productName', 'Unknown')}")
|
||
except Exception as e:
|
||
logger.warning(f"uiautomator2连接失败: {e}")
|
||
|
||
logger.info(f"🤖 AI数字员工初始化完成")
|
||
logger.info(f" 设备ID: {device_id}")
|
||
logger.info(f" 服务器: {server_url}")
|
||
logger.info(f" 心跳间隔: {heartbeat_interval}秒")
|
||
|
||
# ====================================================================
|
||
# 一、连接管理(核心:主动连接 + 心跳保活 + 指数退避重连)
|
||
# ====================================================================
|
||
|
||
async def connect(self):
|
||
"""主连接循环 - 永不停止"""
|
||
while self.running:
|
||
try:
|
||
logger.info(f"📡 正在连接服务器: {self.server_url}")
|
||
|
||
async with websockets.connect(
|
||
self.server_url,
|
||
ping_interval=20,
|
||
ping_timeout=10,
|
||
close_timeout=5,
|
||
max_size=10 * 1024 * 1024,
|
||
) as ws:
|
||
self.ws = ws
|
||
self.connected = True
|
||
self.reconnect_attempts = 0 # 重置重连计数
|
||
self.last_heartbeat_ack = time.time()
|
||
|
||
logger.info("✅ 服务器连接成功!")
|
||
|
||
# 1. 发送注册消息
|
||
await self._register()
|
||
# 注册后上报 agent_started,便于服务端记录设备上线
|
||
await self._send_event("agent_started", {"device_id": self.device_id, "project_id": self.project_id})
|
||
# 向服务端拉取配置(如心跳间隔),ack 里会更新 self.heartbeat_interval
|
||
await self._send_device_request("get_config", {})
|
||
|
||
# 2. 启动并发任务
|
||
tasks = [
|
||
asyncio.create_task(self._heartbeat_loop()),
|
||
asyncio.create_task(self._status_report_loop()),
|
||
asyncio.create_task(self._message_loop()),
|
||
]
|
||
|
||
# 等待任何一个任务结束(通常是消息循环断开)
|
||
done, pending = await asyncio.wait(
|
||
tasks, return_when=asyncio.FIRST_COMPLETED
|
||
)
|
||
|
||
# 取消剩余任务
|
||
for task in pending:
|
||
task.cancel()
|
||
try:
|
||
await task
|
||
except asyncio.CancelledError:
|
||
pass
|
||
|
||
except websockets.ConnectionClosed as e:
|
||
logger.warning(f"🔌 连接断开: code={e.code}, reason={e.reason}")
|
||
except ConnectionRefusedError:
|
||
logger.warning("🚫 服务器拒绝连接")
|
||
except OSError as e:
|
||
logger.warning(f"🌐 网络错误: {e}")
|
||
except Exception as e:
|
||
logger.error(f"❌ 连接异常: {e}")
|
||
finally:
|
||
self.connected = False
|
||
self.ws = None
|
||
|
||
if self.running:
|
||
# 指数退避重连
|
||
delay = self._get_reconnect_delay()
|
||
self.reconnect_attempts += 1
|
||
logger.info(f"⏳ {delay:.1f}秒后第{self.reconnect_attempts}次重连...")
|
||
await asyncio.sleep(delay)
|
||
|
||
def _get_reconnect_delay(self) -> float:
|
||
"""计算重连延迟(指数退避 + 随机抖动)"""
|
||
delay = min(
|
||
self.MIN_RECONNECT_DELAY * (2 ** self.reconnect_attempts),
|
||
self.MAX_RECONNECT_DELAY
|
||
)
|
||
# 添加随机抖动避免雪崩
|
||
jitter = random.uniform(0, delay * 0.2)
|
||
return delay + jitter
|
||
|
||
async def _register(self):
|
||
"""发送设备注册信息"""
|
||
device_info = self._get_device_info()
|
||
|
||
await self.ws.send(json.dumps({
|
||
"type": "register",
|
||
"data": {
|
||
**device_info,
|
||
"project_id": self.project_id,
|
||
"agent_version": self.VERSION,
|
||
"registered_at": datetime.now().isoformat(),
|
||
}
|
||
}))
|
||
|
||
logger.info(f"📋 设备已注册: {device_info.get('model', 'Unknown')}")
|
||
|
||
# ====================================================================
|
||
# 二、心跳机制(参考抖音心跳: 应用层心跳 + 状态上报)
|
||
# ====================================================================
|
||
|
||
async def _heartbeat_loop(self):
|
||
"""
|
||
心跳循环 - 保持连接活性
|
||
|
||
机制说明(参考抖音等APP的心跳设计):
|
||
1. 每30秒发送应用层心跳(不同于WebSocket层ping)
|
||
2. 心跳包含设备状态摘要(电量、内存、网络)
|
||
3. 服务器需要在10秒内响应pong
|
||
4. 连续3次无响应则主动断开重连
|
||
"""
|
||
missed_count = 0
|
||
|
||
while self.connected:
|
||
try:
|
||
await asyncio.sleep(self.heartbeat_interval)
|
||
|
||
if not self.connected or not self.ws:
|
||
break
|
||
|
||
# 发送心跳(含状态摘要)
|
||
heartbeat_data = {
|
||
"type": "heartbeat",
|
||
"timestamp": int(time.time()),
|
||
"device_id": self.device_id,
|
||
"uptime": int(time.time() - self.start_time) if self.start_time else 0,
|
||
"commands_executed": self.commands_executed,
|
||
"status": self._get_quick_status(),
|
||
}
|
||
|
||
await self.ws.send(json.dumps(heartbeat_data))
|
||
|
||
# 检查上次心跳是否有ACK
|
||
time_since_ack = time.time() - self.last_heartbeat_ack
|
||
if time_since_ack > self.heartbeat_interval * 3:
|
||
missed_count += 1
|
||
logger.warning(f"⚠️ 心跳无响应 ({missed_count}/3),上次ACK: {time_since_ack:.0f}秒前")
|
||
|
||
if missed_count >= 3:
|
||
logger.error("💔 连续3次心跳无响应,主动断开重连")
|
||
await self.ws.close()
|
||
break
|
||
else:
|
||
missed_count = 0
|
||
|
||
except websockets.ConnectionClosed:
|
||
break
|
||
except Exception as e:
|
||
logger.error(f"心跳错误: {e}")
|
||
break
|
||
|
||
async def _status_report_loop(self):
|
||
"""定期上报详细设备状态(每5分钟)"""
|
||
while self.connected:
|
||
try:
|
||
await asyncio.sleep(300) # 5分钟
|
||
|
||
if not self.connected or not self.ws:
|
||
break
|
||
|
||
status = self._get_full_status()
|
||
await self.ws.send(json.dumps({
|
||
"type": "status_report",
|
||
"data": status
|
||
}))
|
||
|
||
logger.debug(f"📊 状态上报完成")
|
||
|
||
except Exception:
|
||
break
|
||
|
||
def _get_quick_status(self) -> dict:
|
||
"""获取快速状态摘要(心跳用,低开销)"""
|
||
status = {"online": True}
|
||
|
||
if self.d:
|
||
try:
|
||
info = self.d.info
|
||
status["screen_on"] = info.get("screenOn", False)
|
||
status["current_app"] = info.get("currentPackageName", "")
|
||
except:
|
||
pass
|
||
|
||
return status
|
||
|
||
def _get_full_status(self) -> dict:
|
||
"""获取完整设备状态"""
|
||
status = {
|
||
"device_id": self.device_id,
|
||
"agent_version": self.VERSION,
|
||
"uptime": int(time.time() - self.start_time) if self.start_time else 0,
|
||
"commands_executed": self.commands_executed,
|
||
"connected": self.connected,
|
||
"timestamp": datetime.now().isoformat(),
|
||
}
|
||
|
||
if self.d:
|
||
try:
|
||
info = self.d.info
|
||
status.update({
|
||
"screen_on": info.get("screenOn", False),
|
||
"current_app": info.get("currentPackageName", ""),
|
||
"display": {
|
||
"width": info.get("displayWidth", 0),
|
||
"height": info.get("displayHeight", 0),
|
||
},
|
||
"rotation": info.get("displayRotation", 0),
|
||
})
|
||
|
||
# 获取电池信息
|
||
try:
|
||
battery = self.d.shell("dumpsys battery | grep -E 'level|status|plugged'").output
|
||
for line in battery.strip().split('\n'):
|
||
line = line.strip()
|
||
if 'level:' in line:
|
||
status["battery_level"] = int(line.split(':')[1].strip())
|
||
elif 'status:' in line:
|
||
status["battery_status"] = int(line.split(':')[1].strip())
|
||
elif 'plugged:' in line:
|
||
status["charging"] = int(line.split(':')[1].strip()) > 0
|
||
except:
|
||
pass
|
||
|
||
# 获取网络信息
|
||
try:
|
||
wifi = self.d.shell("dumpsys wifi | grep 'Wi-Fi is'").output.strip()
|
||
status["wifi"] = "enabled" in wifi.lower()
|
||
except:
|
||
pass
|
||
|
||
except Exception as e:
|
||
status["error"] = str(e)
|
||
|
||
return status
|
||
|
||
# ====================================================================
|
||
# 三、消息处理(接收服务器命令,执行操作)
|
||
# ====================================================================
|
||
|
||
async def _message_loop(self):
|
||
"""消息接收循环"""
|
||
try:
|
||
async for message in self.ws:
|
||
await self._handle_message(message)
|
||
except websockets.ConnectionClosed:
|
||
logger.info("消息循环: 连接已关闭")
|
||
except Exception as e:
|
||
logger.error(f"消息循环错误: {e}")
|
||
|
||
async def _handle_message(self, message: str):
|
||
"""处理服务器消息"""
|
||
try:
|
||
data = json.loads(message)
|
||
msg_type = data.get("type")
|
||
command_id = data.get("command_id")
|
||
|
||
# 心跳ACK
|
||
if msg_type in ("pong", "heartbeat_ack"):
|
||
self.last_heartbeat_ack = time.time()
|
||
return
|
||
|
||
# 注册确认
|
||
if msg_type == "registered":
|
||
logger.info("✅ 服务器确认注册")
|
||
return
|
||
|
||
logger.info(f"📩 收到命令: {msg_type} (id={command_id})")
|
||
|
||
if msg_type == "execute":
|
||
cmd_data = data.get("data", {})
|
||
result = await self._execute_command(cmd_data)
|
||
self.commands_executed += 1
|
||
await self._send_response(command_id, result)
|
||
# 技能执行完毕后上报事件,供服务端落库/转发
|
||
if cmd_data.get("script"):
|
||
await self._send_event("skill_done", {
|
||
"script": cmd_data.get("script"),
|
||
"action": cmd_data.get("action"),
|
||
"code": result.get("code", 200),
|
||
"success": result.get("code") == 200,
|
||
})
|
||
|
||
elif msg_type == "agent_execute":
|
||
result = await self._execute_agent_task(data.get("data", {}))
|
||
self.commands_executed += 1
|
||
await self._send_response(command_id, result)
|
||
|
||
elif msg_type == "config_update":
|
||
# 服务器推送配置更新
|
||
await self._handle_config_update(data.get("data", {}))
|
||
|
||
elif msg_type == "device_request_ack":
|
||
# 服务端对 device_request 的应答,可应用下发的配置
|
||
ack_data = data.get("data") or {}
|
||
if ack_data.get("heartbeat_interval") is not None:
|
||
self.heartbeat_interval = int(ack_data["heartbeat_interval"])
|
||
logger.info(f"已应用服务端配置: heartbeat_interval={self.heartbeat_interval}")
|
||
logger.debug(f"device_request_ack: request_id={data.get('request_id')} success={data.get('success')}")
|
||
|
||
else:
|
||
logger.warning(f"未知消息类型: {msg_type}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"处理消息错误: {e}")
|
||
if command_id:
|
||
await self._send_response(command_id, {
|
||
"code": 500,
|
||
"message": str(e)
|
||
})
|
||
|
||
async def _send_response(self, command_id: str, result: dict):
|
||
"""发送命令执行结果"""
|
||
if not self.ws or not self.connected:
|
||
return
|
||
|
||
try:
|
||
await self.ws.send(json.dumps({
|
||
"type": "response",
|
||
"command_id": command_id,
|
||
"device_id": self.device_id,
|
||
"code": result.get("code", 200),
|
||
"message": result.get("message", "success"),
|
||
"data": result.get("data", {}),
|
||
"timestamp": int(time.time()),
|
||
}))
|
||
except Exception as e:
|
||
logger.error(f"发送响应失败: {e}")
|
||
|
||
async def _send_event(self, event: str, data: dict = None):
|
||
"""设备端事件上报(技能执行完毕、异常等),由服务端处理/落库/转发"""
|
||
if not self.ws or not self.connected:
|
||
return
|
||
try:
|
||
await self.ws.send(json.dumps({
|
||
"type": "event",
|
||
"device_id": self.device_id,
|
||
"event": event,
|
||
"data": data or {},
|
||
"timestamp": int(time.time()),
|
||
}))
|
||
except Exception as e:
|
||
logger.error(f"发送事件失败: {e}")
|
||
|
||
async def _send_device_request(self, action: str, params: dict = None) -> dict:
|
||
"""设备端请求服务端执行操作(拉配置、落库等),等待 device_request_ack(可选)"""
|
||
if not self.ws or not self.connected:
|
||
return {"success": False, "error": "未连接"}
|
||
request_id = f"req_{int(time.time() * 1000)}_{random.randint(1000, 9999)}"
|
||
try:
|
||
await self.ws.send(json.dumps({
|
||
"type": "device_request",
|
||
"device_id": self.device_id,
|
||
"request_id": request_id,
|
||
"action": action,
|
||
"params": params or {},
|
||
"timestamp": int(time.time()),
|
||
}))
|
||
except Exception as e:
|
||
logger.error(f"发送 device_request 失败: {e}")
|
||
return {"success": False, "error": str(e)}
|
||
return {"success": True, "request_id": request_id}
|
||
|
||
async def _handle_config_update(self, config: dict):
|
||
"""处理服务器推送的配置更新"""
|
||
if "heartbeat_interval" in config:
|
||
self.heartbeat_interval = config["heartbeat_interval"]
|
||
logger.info(f"心跳间隔更新为: {self.heartbeat_interval}秒")
|
||
|
||
# ====================================================================
|
||
# 四、命令执行(操作手机)
|
||
# ====================================================================
|
||
|
||
async def _execute_command(self, cmd: dict) -> dict:
|
||
"""执行命令"""
|
||
action = cmd.get("action")
|
||
params = cmd.get("params", {})
|
||
script = cmd.get("script")
|
||
|
||
try:
|
||
if script:
|
||
return await self._execute_skill(script, action, params)
|
||
|
||
if not self.d:
|
||
return {"code": 503, "message": "uiautomator2未连接"}
|
||
|
||
# 基础操作
|
||
if action == "screenshot":
|
||
return self._screenshot()
|
||
elif action == "click":
|
||
self.d.click(params["x"], params["y"])
|
||
return {"code": 200, "data": {"success": True}}
|
||
elif action == "click_text":
|
||
text = params["text"]
|
||
timeout = params.get("timeout", 10)
|
||
if self.d(text=text).wait(timeout=timeout):
|
||
self.d(text=text).click()
|
||
return {"code": 200, "data": {"success": True, "text": text}}
|
||
return {"code": 404, "message": f"未找到: {text}"}
|
||
elif action == "input":
|
||
if params.get("clear", True):
|
||
self.d.clear_text()
|
||
self.d.send_keys(params["text"])
|
||
return {"code": 200, "data": {"success": True}}
|
||
elif action == "swipe":
|
||
if "x1" in params and "y1" in params and "x2" in params and "y2" in params:
|
||
dur = params.get("duration", 300) / 1000.0
|
||
self.d.swipe(params["x1"], params["y1"], params["x2"], params["y2"], duration=dur)
|
||
else:
|
||
direction = params.get("direction", "up")
|
||
self.d.swipe_ext(direction, scale=params.get("scale", 0.8))
|
||
return {"code": 200, "data": {"success": True}}
|
||
elif action == "ui_tree":
|
||
xml = self.d.dump_hierarchy()
|
||
return {"code": 200, "data": {"xml": xml, "length": len(xml)}}
|
||
elif action == "app_start":
|
||
self.d.app_start(params["package"])
|
||
return {"code": 200, "data": {"success": True}}
|
||
elif action == "app_stop":
|
||
self.d.app_stop(params["package"])
|
||
return {"code": 200, "data": {"success": True}}
|
||
elif action == "press_key":
|
||
self.d.press(params.get("key", "home"))
|
||
return {"code": 200, "data": {"success": True}}
|
||
elif action == "device_info":
|
||
return {"code": 200, "data": self._get_device_info()}
|
||
elif action == "status":
|
||
return {"code": 200, "data": self._get_full_status()}
|
||
else:
|
||
return {"code": 400, "message": f"未知操作: {action}"}
|
||
|
||
except Exception as e:
|
||
logger.error(f"执行命令错误: {e}")
|
||
return {"code": 500, "message": str(e)}
|
||
|
||
async def _execute_skill(self, script: str, action: str, params: dict) -> dict:
|
||
"""执行APP技能"""
|
||
try:
|
||
if not self.d:
|
||
return {"code": 503, "message": "uiautomator2未连接"}
|
||
|
||
# 动态加载技能
|
||
# 通过技能注册表获取
|
||
from skills import get_skill
|
||
try:
|
||
skill_class = get_skill(script)
|
||
except ImportError as ie:
|
||
return {"code": 404, "message": str(ie)}
|
||
|
||
skill = skill_class(self.d)
|
||
|
||
# 调用方法
|
||
method = getattr(skill, action, None)
|
||
if not method:
|
||
return {"code": 404, "message": f"技能{script}不支持操作: {action}"}
|
||
|
||
# 执行(同步或异步)
|
||
if asyncio.iscoroutinefunction(method):
|
||
result = await method(**params)
|
||
else:
|
||
result = method(**params)
|
||
|
||
return {"code": 200, "data": result}
|
||
|
||
except Exception as e:
|
||
logger.error(f"执行技能错误 [{script}.{action}]: {e}")
|
||
return {"code": 500, "message": str(e)}
|
||
|
||
async def _execute_agent_task(self, data: dict) -> dict:
|
||
"""执行AI Agent任务(自然语言控制);优先走 SkillExecutor 微信/抖音复合任务,无 LLM 也可执行"""
|
||
task = (data.get("task") or "").strip()
|
||
logger.info(f"🤖 AI任务: {task}")
|
||
if not task:
|
||
return {"code": 400, "message": "task 为空", "data": {"success": False, "error": "task 为空"}}
|
||
|
||
try:
|
||
if not self.d:
|
||
return {"code": 503, "message": "uiautomator2未连接", "data": {"success": False, "error": "设备未连接"}}
|
||
try:
|
||
from skill_executor import SkillExecutor
|
||
executor = SkillExecutor(self.d)
|
||
except ImportError:
|
||
executor = None
|
||
if executor:
|
||
result = None
|
||
if "微信" in task:
|
||
result = executor.execute_wechat_task(task)
|
||
elif "抖音" in task:
|
||
result = executor.execute_douyin_task(task)
|
||
elif "小红书" in task:
|
||
result = executor.execute_xhs_task(task)
|
||
elif "闲鱼" in task:
|
||
result = executor.execute_xianyu_task(task)
|
||
else:
|
||
result = executor.execute_command(task)
|
||
if result is None:
|
||
result = {"success": False, "error": "未匹配到执行路径"}
|
||
return {"code": 200, "data": result}
|
||
return {"code": 200, "data": {"success": False, "error": "AI Agent任务引擎未加载", "task": task}}
|
||
except Exception as e:
|
||
logger.exception(f"AI任务执行异常: {e}")
|
||
return {"code": 500, "message": str(e), "data": {"success": False, "error": str(e), "task": task}}
|
||
|
||
# ====================================================================
|
||
# 五、设备信息
|
||
# ====================================================================
|
||
|
||
def _screenshot(self) -> dict:
|
||
"""截图"""
|
||
try:
|
||
img = self.d.screenshot(format='raw')
|
||
b64 = base64.b64encode(img).decode('utf-8')
|
||
info = self.d.info
|
||
return {
|
||
"code": 200,
|
||
"data": {
|
||
"base64": b64,
|
||
"width": info.get("displayWidth", 0),
|
||
"height": info.get("displayHeight", 0),
|
||
"size": len(img),
|
||
}
|
||
}
|
||
except Exception as e:
|
||
return {"code": 500, "message": str(e)}
|
||
|
||
def _get_device_info(self) -> dict:
|
||
"""获取设备信息"""
|
||
info = {}
|
||
|
||
if self.d:
|
||
try:
|
||
d_info = self.d.info
|
||
info = {
|
||
"device_id": self.device_id,
|
||
"model": d_info.get("productName", "Unknown"),
|
||
"brand": d_info.get("brand", "Unknown"),
|
||
"android_version": str(d_info.get("sdkVersion", "Unknown")),
|
||
"display": {
|
||
"width": d_info.get("displayWidth", 0),
|
||
"height": d_info.get("displayHeight", 0),
|
||
},
|
||
"screen_on": d_info.get("screenOn", False),
|
||
}
|
||
|
||
# 检测已安装的APP
|
||
apps = []
|
||
try:
|
||
output = self.d.shell("pm list packages -3").output
|
||
app_detect = {
|
||
'com.tencent.mm': 'wechat',
|
||
'com.ss.android.ugc.aweme': 'douyin',
|
||
'com.xingin.xhs': 'xhs',
|
||
'com.taobao.idlefish': 'xianyu',
|
||
'cn.soulapp.android': 'soul',
|
||
}
|
||
for line in output.strip().split('\n'):
|
||
pkg = line.replace('package:', '').strip()
|
||
for check_pkg, name in app_detect.items():
|
||
if check_pkg in pkg:
|
||
apps.append(name)
|
||
except:
|
||
pass
|
||
|
||
info["installed_apps"] = apps
|
||
info["capabilities"] = [
|
||
"u2", "screenshot", "click", "input", "swipe",
|
||
"ui_tree", "app_control", "skill_execute",
|
||
"skill_wechat", "skill_douyin", "skill_xhs", "skill_xianyu",
|
||
"event", "device_request"
|
||
]
|
||
|
||
except Exception as e:
|
||
info["error"] = str(e)
|
||
else:
|
||
info = {
|
||
"device_id": self.device_id,
|
||
"model": "Unknown (u2未连接)",
|
||
"capabilities": [],
|
||
}
|
||
|
||
info["agent_version"] = self.VERSION
|
||
info["project_id"] = self.project_id
|
||
|
||
return info
|
||
|
||
# ====================================================================
|
||
# 六、启动/停止
|
||
# ====================================================================
|
||
|
||
async def start(self):
|
||
"""启动Agent"""
|
||
self.running = True
|
||
self.start_time = time.time()
|
||
|
||
logger.info("=" * 50)
|
||
logger.info("🚀 AI数字员工 v3.0 启动")
|
||
logger.info(f" 设备ID: {self.device_id}")
|
||
logger.info(f" 服务器: {self.server_url}")
|
||
logger.info(f" 项目ID: {self.project_id}")
|
||
logger.info("=" * 50)
|
||
|
||
await self.connect()
|
||
|
||
def stop(self):
|
||
"""停止Agent"""
|
||
self.running = False
|
||
self.connected = False
|
||
logger.info("🛑 AI数字员工已停止")
|
||
|
||
|
||
def _detect_device_id() -> str:
|
||
"""自动检测设备ID(Termux / ADB / fallback)"""
|
||
import subprocess
|
||
# 1. Termux: getprop
|
||
try:
|
||
r = subprocess.run(['getprop', 'ro.serialno'], capture_output=True, text=True, timeout=5)
|
||
serial = r.stdout.strip()
|
||
if serial:
|
||
return serial
|
||
except Exception:
|
||
pass
|
||
# 2. ADB serial(从环境变量,模拟器常用)
|
||
serial = os.environ.get("ANDROID_SERIAL", "")
|
||
if serial:
|
||
return serial
|
||
# 3. 通过 uiautomator2 获取
|
||
if u2:
|
||
try:
|
||
d = u2.connect()
|
||
serial = d.serial
|
||
if serial:
|
||
return serial
|
||
except Exception:
|
||
pass
|
||
# 4. fallback: 基于时间戳
|
||
return f"agent-{int(time.time())}"
|
||
|
||
|
||
def _resolve_config(args) -> dict:
|
||
"""
|
||
配置级联解析(优先级: 环境变量 > 命令行 > config.json > 默认值)
|
||
返回 {device_id, server_url, heartbeat_interval, project_id}
|
||
"""
|
||
# 1. 读取 config.json
|
||
config = {}
|
||
config_path = args.config or os.path.join(_AGENT_DIR, 'config.json')
|
||
if os.path.exists(config_path):
|
||
try:
|
||
with open(config_path) as f:
|
||
config = json.load(f)
|
||
logger.info(f"📄 已加载配置: {config_path}")
|
||
except Exception as e:
|
||
logger.warning(f"读取配置文件失败: {e}")
|
||
|
||
# 2. 级联合并
|
||
device_id = (
|
||
os.environ.get("WP_DEVICE_ID")
|
||
or args.device_id
|
||
or config.get("device_id")
|
||
or _detect_device_id()
|
||
)
|
||
server_base = (
|
||
os.environ.get("WP_SERVER_URL")
|
||
or args.server
|
||
or config.get("server_url")
|
||
or "ws://192.168.1.100:8899/ws/device"
|
||
).rstrip("/")
|
||
heartbeat = int(
|
||
os.environ.get("WP_HEARTBEAT")
|
||
or (args.heartbeat if args.heartbeat is not None else 0)
|
||
or config.get("heartbeat_interval")
|
||
or 10
|
||
)
|
||
project_id = (
|
||
os.environ.get("WP_PROJECT_ID")
|
||
or args.project
|
||
or config.get("project_id")
|
||
or "cunkebao"
|
||
)
|
||
|
||
# 3. 拼接完整 WebSocket URL(基础地址 + /设备ID)
|
||
server_url = f"{server_base}/{device_id}"
|
||
|
||
return {
|
||
"device_id": device_id,
|
||
"server_url": server_url,
|
||
"heartbeat_interval": heartbeat,
|
||
"project_id": project_id,
|
||
}
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(
|
||
description='AI数字员工 - 工作手机Agent v3.0',
|
||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||
epilog="""
|
||
配置优先级: 环境变量 > 命令行参数 > config.json > 默认值
|
||
|
||
环境变量:
|
||
WP_DEVICE_ID 设备ID(默认自动检测)
|
||
WP_SERVER_URL WebSocket基础地址
|
||
WP_PROJECT_ID 项目ID
|
||
WP_HEARTBEAT 心跳间隔(秒)
|
||
|
||
示例:
|
||
# 基本启动(读 config.json)
|
||
python agent.py
|
||
|
||
# 指定服务器
|
||
python agent.py -s ws://192.168.1.100:8899/ws/device
|
||
|
||
# 环境变量
|
||
WP_SERVER_URL=ws://10.0.0.1:8899/ws/device python agent.py
|
||
"""
|
||
)
|
||
parser.add_argument('--device-id', '-d', default=None, help='设备ID(默认自动检测)')
|
||
parser.add_argument('--server', '-s', default=None, help='服务器WebSocket基础地址')
|
||
parser.add_argument('--heartbeat', '-hb', type=int, default=None, help='心跳间隔(秒),建议5/10/30')
|
||
parser.add_argument('--project', '-p', default=None, help='项目ID')
|
||
parser.add_argument('--config', '-c', default=None, help='配置文件路径(默认 config.json)')
|
||
|
||
args = parser.parse_args()
|
||
cfg = _resolve_config(args)
|
||
|
||
agent = WorkPhoneAgent(
|
||
device_id=cfg["device_id"],
|
||
server_url=cfg["server_url"],
|
||
heartbeat_interval=cfg["heartbeat_interval"],
|
||
project_id=cfg["project_id"],
|
||
)
|
||
|
||
# 信号处理:优雅关闭
|
||
def _signal_handler(sig, frame):
|
||
logger.info(f"收到信号 {sig},正在停止...")
|
||
agent.stop()
|
||
|
||
signal.signal(signal.SIGINT, _signal_handler)
|
||
signal.signal(signal.SIGTERM, _signal_handler)
|
||
|
||
try:
|
||
asyncio.run(agent.start())
|
||
except KeyboardInterrupt:
|
||
pass
|
||
finally:
|
||
agent.stop()
|
||
logger.info("👋 Agent已退出")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|