619 lines
22 KiB
Python
619 lines
22 KiB
Python
"""
|
||
工作手机SDK v3.0 - 外部对接网关
|
||
支持三种对接形式:REST API / OpenAI Compatible / MCP Protocol
|
||
|
||
阿机负责 · 阿桥对接
|
||
"""
|
||
|
||
from fastapi import APIRouter, HTTPException, Request, Depends
|
||
from pydantic import BaseModel, Field
|
||
from typing import Optional, List, Any
|
||
import asyncio
|
||
import logging
|
||
import time
|
||
import json
|
||
|
||
from services.ws_hub import ws_hub
|
||
from services.adb_device import adb_manager
|
||
from services.device_manager import device_manager
|
||
|
||
router = APIRouter()
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
# ═══════════════════════════════════════════════
|
||
# 一、OpenAI Compatible API(AI 对接)
|
||
# ═══════════════════════════════════════════════
|
||
|
||
class ChatMessage(BaseModel):
|
||
role: str = "user"
|
||
content: str
|
||
|
||
class ChatRequest(BaseModel):
|
||
model: str = "workphone-agent"
|
||
messages: List[ChatMessage]
|
||
device_id: Optional[str] = None
|
||
stream: bool = False
|
||
temperature: float = 0.7
|
||
|
||
class ChatChoice(BaseModel):
|
||
index: int = 0
|
||
message: ChatMessage
|
||
finish_reason: str = "stop"
|
||
|
||
class ChatUsage(BaseModel):
|
||
prompt_tokens: int = 0
|
||
completion_tokens: int = 0
|
||
total_tokens: int = 0
|
||
|
||
class ChatResponse(BaseModel):
|
||
id: str = "chatcmpl-workphone"
|
||
object: str = "chat.completion"
|
||
created: int = 0
|
||
model: str = "workphone-agent"
|
||
choices: List[ChatChoice] = []
|
||
usage: ChatUsage = ChatUsage()
|
||
|
||
|
||
@router.post("/gateway/v1/chat/completions", tags=["外部对接网关"])
|
||
async def openai_compatible_chat(req: ChatRequest):
|
||
"""
|
||
OpenAI 兼容的 Chat API —— 外部程序可像调用 GPT 一样控制手机。
|
||
|
||
用法:把 base_url 改为 http://服务器:8899/api/v3/gateway/v1,
|
||
即可用任何 OpenAI SDK 直接对接。
|
||
"""
|
||
user_msg = next((m.content for m in reversed(req.messages) if m.role == "user"), "")
|
||
if not user_msg:
|
||
raise HTTPException(400, "messages 中无 user 消息")
|
||
|
||
device_id = req.device_id
|
||
if not device_id:
|
||
devices = list(ws_hub.connections.keys())
|
||
if not devices:
|
||
adb_devs = await adb_manager.async_scan_devices()
|
||
devices = adb_devs
|
||
device_id = devices[0] if devices else None
|
||
|
||
result_text = ""
|
||
executed_steps = []
|
||
|
||
try:
|
||
from services import ai_agent as ai_agent_service
|
||
ai_result = await ai_agent_service.execute_ai_command(
|
||
device_id=device_id or "none",
|
||
command=user_msg,
|
||
mode="ai"
|
||
)
|
||
if isinstance(ai_result, dict):
|
||
result_text = ai_result.get("message", ai_result.get("response", str(ai_result)))
|
||
executed_steps = ai_result.get("executed", [])
|
||
else:
|
||
result_text = str(ai_result)
|
||
except Exception as e:
|
||
result_text = f"执行失败: {e}"
|
||
|
||
response_content = result_text
|
||
if executed_steps:
|
||
steps_text = "\n".join(
|
||
f"{'✅' if s.get('success') else '❌'} {s.get('step', '未知步骤')}"
|
||
for s in executed_steps
|
||
)
|
||
response_content = f"{result_text}\n\n执行步骤:\n{steps_text}"
|
||
|
||
return ChatResponse(
|
||
id=f"chatcmpl-wp-{int(time.time())}",
|
||
created=int(time.time()),
|
||
model=req.model,
|
||
choices=[ChatChoice(
|
||
message=ChatMessage(role="assistant", content=response_content)
|
||
)],
|
||
usage=ChatUsage(
|
||
prompt_tokens=len(user_msg),
|
||
completion_tokens=len(response_content),
|
||
total_tokens=len(user_msg) + len(response_content)
|
||
),
|
||
)
|
||
|
||
|
||
@router.get("/gateway/v1/models", tags=["外部对接网关"])
|
||
async def list_models():
|
||
"""OpenAI 兼容 —— 列出可用模型"""
|
||
return {
|
||
"object": "list",
|
||
"data": [
|
||
{
|
||
"id": "workphone-agent",
|
||
"object": "model",
|
||
"created": 1700000000,
|
||
"owned_by": "workphone-sdk",
|
||
"description": "工作手机 AI Agent — 自然语言控制手机"
|
||
},
|
||
{
|
||
"id": "workphone-adb",
|
||
"object": "model",
|
||
"created": 1700000000,
|
||
"owned_by": "workphone-sdk",
|
||
"description": "ADB 直控模式 — 精确设备操作"
|
||
},
|
||
]
|
||
}
|
||
|
||
|
||
# ═══════════════════════════════════════════════
|
||
# 二、MCP Protocol(MCP 对接)
|
||
# ═══════════════════════════════════════════════
|
||
|
||
MCP_TOOLS = [
|
||
{
|
||
"name": "send_message",
|
||
"description": "通过微信发送消息给指定联系人",
|
||
"inputSchema": {
|
||
"type": "object",
|
||
"properties": {
|
||
"device_id": {"type": "string", "description": "设备ID"},
|
||
"to": {"type": "string", "description": "接收人昵称或备注名"},
|
||
"content": {"type": "string", "description": "消息内容"},
|
||
"platform": {"type": "string", "default": "wechat"}
|
||
},
|
||
"required": ["to", "content"]
|
||
}
|
||
},
|
||
{
|
||
"name": "take_screenshot",
|
||
"description": "对手机截屏,返回当前屏幕画面",
|
||
"inputSchema": {
|
||
"type": "object",
|
||
"properties": {
|
||
"device_id": {"type": "string", "description": "设备ID"}
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"name": "open_app",
|
||
"description": "打开手机上的APP",
|
||
"inputSchema": {
|
||
"type": "object",
|
||
"properties": {
|
||
"device_id": {"type": "string", "description": "设备ID"},
|
||
"app_name": {"type": "string", "description": "APP名称: wechat/douyin/xhs/xianyu"}
|
||
},
|
||
"required": ["app_name"]
|
||
}
|
||
},
|
||
{
|
||
"name": "get_device_status",
|
||
"description": "获取设备的实时状态信息(电量、网络、当前APP等)",
|
||
"inputSchema": {
|
||
"type": "object",
|
||
"properties": {
|
||
"device_id": {"type": "string", "description": "设备ID"}
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"name": "list_devices",
|
||
"description": "列出所有已连接的设备及其状态",
|
||
"inputSchema": {"type": "object", "properties": {}}
|
||
},
|
||
{
|
||
"name": "execute_task",
|
||
"description": "用自然语言描述一个任务,AI Agent 自动执行",
|
||
"inputSchema": {
|
||
"type": "object",
|
||
"properties": {
|
||
"device_id": {"type": "string", "description": "设备ID"},
|
||
"task": {"type": "string", "description": "任务描述(自然语言)"}
|
||
},
|
||
"required": ["task"]
|
||
}
|
||
},
|
||
{
|
||
"name": "add_friend",
|
||
"description": "在微信中添加好友",
|
||
"inputSchema": {
|
||
"type": "object",
|
||
"properties": {
|
||
"device_id": {"type": "string", "description": "设备ID"},
|
||
"phone_or_wxid": {"type": "string", "description": "手机号或微信号"},
|
||
"greeting": {"type": "string", "description": "打招呼消息", "default": "你好"}
|
||
},
|
||
"required": ["phone_or_wxid"]
|
||
}
|
||
},
|
||
{
|
||
"name": "batch_send",
|
||
"description": "批量发送消息给多个联系人",
|
||
"inputSchema": {
|
||
"type": "object",
|
||
"properties": {
|
||
"device_id": {"type": "string", "description": "设备ID"},
|
||
"targets": {"type": "array", "items": {"type": "string"}, "description": "接收人列表"},
|
||
"content": {"type": "string", "description": "消息内容"},
|
||
"interval_seconds": {"type": "number", "default": 30, "description": "每条间隔秒数"}
|
||
},
|
||
"required": ["targets", "content"]
|
||
}
|
||
},
|
||
]
|
||
|
||
|
||
@router.get("/gateway/mcp/tools", tags=["外部对接网关"])
|
||
async def mcp_list_tools():
|
||
"""MCP Protocol —— 列出所有可用工具"""
|
||
return {"tools": MCP_TOOLS}
|
||
|
||
|
||
@router.post("/gateway/mcp/call", tags=["外部对接网关"])
|
||
async def mcp_call_tool(request: Request):
|
||
"""
|
||
MCP Protocol —— 调用指定工具
|
||
|
||
请求体: {"name": "send_message", "arguments": {"to": "张三", "content": "你好"}}
|
||
"""
|
||
body = await request.json()
|
||
tool_name = body.get("name", "")
|
||
args = body.get("arguments", {})
|
||
device_id = args.get("device_id")
|
||
|
||
if not device_id:
|
||
ws_devices = list(ws_hub.connections.keys())
|
||
adb_devices = await adb_manager.async_scan_devices()
|
||
device_id = (ws_devices + adb_devices + ["none"])[0]
|
||
|
||
try:
|
||
result = await _execute_mcp_tool(tool_name, device_id, args)
|
||
return {
|
||
"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False, indent=2)}],
|
||
"isError": False
|
||
}
|
||
except Exception as e:
|
||
return {
|
||
"content": [{"type": "text", "text": f"执行失败: {e}"}],
|
||
"isError": True
|
||
}
|
||
|
||
|
||
async def _execute_mcp_tool(name: str, device_id: str, args: dict) -> dict:
|
||
"""执行 MCP 工具调用"""
|
||
|
||
APP_PACKAGES = {
|
||
"wechat": "com.tencent.mm",
|
||
"douyin": "com.ss.android.ugc.aweme",
|
||
"xhs": "com.xingin.xhs",
|
||
"xianyu": "com.taobao.idlefish",
|
||
}
|
||
|
||
if name == "list_devices":
|
||
ws_online = ws_hub.get_online_devices()
|
||
adb_devs = await adb_manager.async_scan_devices()
|
||
return {
|
||
"devices": [
|
||
{"device_id": d["device_id"], "status": "online", "type": "websocket"}
|
||
for d in ws_online
|
||
] + [
|
||
{"device_id": s, "status": "adb", "type": "adb"}
|
||
for s in adb_devs
|
||
],
|
||
"total": len(ws_online) + len(adb_devs)
|
||
}
|
||
|
||
if name == "take_screenshot":
|
||
adb_dev = adb_manager.get_device(device_id)
|
||
if adb_dev:
|
||
result = await adb_dev.async_screenshot()
|
||
return {"success": True, "width": result.get("width"), "height": result.get("height")}
|
||
return {"success": False, "error": "设备不可用"}
|
||
|
||
if name == "open_app":
|
||
app_name = args.get("app_name", "wechat")
|
||
package = APP_PACKAGES.get(app_name, app_name)
|
||
adb_dev = adb_manager.get_device(device_id)
|
||
if adb_dev:
|
||
result = await adb_dev.async_start_app(package)
|
||
return {"success": True, "app": app_name, "package": package}
|
||
return {"success": False, "error": "设备不可用"}
|
||
|
||
if name == "get_device_status":
|
||
adb_dev = adb_manager.get_device(device_id)
|
||
if adb_dev:
|
||
info = adb_dev.get_info()
|
||
return {"device_id": device_id, **info}
|
||
ws_info = ws_hub.device_info.get(device_id, {})
|
||
return {"device_id": device_id, **ws_info} if ws_info else {"error": "设备不在线"}
|
||
|
||
if name == "send_message":
|
||
from services.wechat_adb_engine import WeChatADBEngine
|
||
adb_dev = adb_manager.get_device(device_id)
|
||
if not adb_dev:
|
||
return {"success": False, "error": f"设备 {device_id} 不可用"}
|
||
engine = WeChatADBEngine(adb_dev)
|
||
result = await asyncio.get_running_loop().run_in_executor(
|
||
None, engine.send_message, args.get("to", ""), args.get("content", "")
|
||
)
|
||
record_journey(device_id, f"send_message → {args.get('to','')}", result, "mcp")
|
||
return {"success": True, "result": result}
|
||
|
||
if name == "add_friend":
|
||
from services.wechat_adb_engine import WeChatADBEngine
|
||
adb_dev = adb_manager.get_device(device_id)
|
||
if not adb_dev:
|
||
return {"success": False, "error": f"设备 {device_id} 不可用"}
|
||
engine = WeChatADBEngine(adb_dev)
|
||
result = await asyncio.get_running_loop().run_in_executor(
|
||
None, engine.add_friend, args.get("phone_or_wxid", ""), args.get("greeting", "你好")
|
||
)
|
||
record_journey(device_id, f"add_friend → {args.get('phone_or_wxid','')}", result, "mcp")
|
||
return {"success": True, "result": result}
|
||
|
||
if name == "execute_task":
|
||
from services import ai_agent as ai_agent_service
|
||
result = await ai_agent_service.execute_ai_command(
|
||
device_id=device_id,
|
||
command=args["task"],
|
||
mode="ai"
|
||
)
|
||
record_journey(device_id, f"execute_task: {args['task'][:50]}", result, "mcp")
|
||
return result if isinstance(result, dict) else {"result": str(result)}
|
||
|
||
if name == "batch_send":
|
||
from services.wechat_adb_engine import WeChatADBEngine
|
||
adb_dev = adb_manager.get_device(device_id)
|
||
if not adb_dev:
|
||
return {"success": False, "error": f"设备 {device_id} 不可用"}
|
||
engine = WeChatADBEngine(adb_dev)
|
||
results = []
|
||
for target in args.get("targets", []):
|
||
try:
|
||
r = await asyncio.get_running_loop().run_in_executor(
|
||
None, engine.send_message, target, args.get("content", "")
|
||
)
|
||
results.append({"target": target, "success": True})
|
||
record_journey(device_id, f"batch_send → {target}", "ok", "mcp")
|
||
except Exception as e:
|
||
results.append({"target": target, "success": False, "error": str(e)})
|
||
interval = args.get("interval_seconds", 30)
|
||
if interval > 0:
|
||
await asyncio.sleep(interval)
|
||
return {"results": results, "total": len(results)}
|
||
|
||
raise HTTPException(404, f"未知工具: {name}")
|
||
|
||
|
||
# ═══════════════════════════════════════════════
|
||
# 三、REST API 网关元信息
|
||
# ═══════════════════════════════════════════════
|
||
|
||
@router.get("/gateway/info", tags=["外部对接网关"])
|
||
async def gateway_info():
|
||
"""网关信息 —— 三种对接方式的入口和说明"""
|
||
return {
|
||
"name": "工作手机SDK 外部对接网关",
|
||
"version": "1.0.0",
|
||
"protocols": {
|
||
"rest_api": {
|
||
"base_url": "/api/v3",
|
||
"docs": "/docs",
|
||
"description": "192+ REST API 端点,完整的微信/设备控制能力",
|
||
"auth": "暂未启用(生产环境需配置 API Key)"
|
||
},
|
||
"openai_compatible": {
|
||
"base_url": "/api/v3/gateway/v1",
|
||
"endpoints": {
|
||
"chat": "POST /api/v3/gateway/v1/chat/completions",
|
||
"models": "GET /api/v3/gateway/v1/models"
|
||
},
|
||
"description": "OpenAI 兼容接口,可直接用 openai SDK 对接",
|
||
"usage": {
|
||
"python": "from openai import OpenAI\nclient = OpenAI(base_url='http://服务器:8899/api/v3/gateway/v1', api_key='any')\nclient.chat.completions.create(model='workphone-agent', messages=[{'role':'user','content':'打开微信发消息给张三'}])",
|
||
"curl": "curl -X POST http://服务器:8899/api/v3/gateway/v1/chat/completions -H 'Content-Type: application/json' -d '{\"model\":\"workphone-agent\",\"messages\":[{\"role\":\"user\",\"content\":\"打开微信\"}]}'"
|
||
}
|
||
},
|
||
"mcp": {
|
||
"tools_endpoint": "GET /api/v3/gateway/mcp/tools",
|
||
"call_endpoint": "POST /api/v3/gateway/mcp/call",
|
||
"description": "MCP Protocol 兼容,8 个工具可直接被 AI Agent 调用",
|
||
"tools_count": len(MCP_TOOLS),
|
||
"tools": [t["name"] for t in MCP_TOOLS]
|
||
}
|
||
},
|
||
"devices": {
|
||
"ws_online": len(ws_hub.connections),
|
||
"ws_device_ids": list(ws_hub.connections.keys()),
|
||
}
|
||
}
|
||
|
||
|
||
# ═══════════════════════════════════════════════
|
||
# 四、多设备批量控制(Fleet Management)
|
||
# ═══════════════════════════════════════════════
|
||
|
||
class FleetCommandRequest(BaseModel):
|
||
device_ids: Optional[List[str]] = None
|
||
command: str
|
||
params: dict = {}
|
||
|
||
class DeviceGroupRequest(BaseModel):
|
||
group_name: str
|
||
device_ids: List[str]
|
||
|
||
_device_groups: dict = {}
|
||
|
||
|
||
@router.get("/gateway/fleet/status", tags=["外部对接网关"])
|
||
async def fleet_status():
|
||
"""多设备总览 —— 所有设备的实时状态汇总"""
|
||
ws_devices = ws_hub.get_online_devices()
|
||
adb_devices = await adb_manager.async_scan_devices()
|
||
|
||
fleet = []
|
||
seen = set()
|
||
|
||
for d in ws_devices:
|
||
did = d.get("device_id", "")
|
||
if did in seen:
|
||
continue
|
||
seen.add(did)
|
||
info = ws_hub.device_info.get(did, {})
|
||
fleet.append({
|
||
"device_id": did,
|
||
"status": "online",
|
||
"channel": "websocket",
|
||
"model": info.get("model", ""),
|
||
"battery": info.get("battery_level", -1),
|
||
"network": info.get("network_type", ""),
|
||
"current_app": info.get("current_app", ""),
|
||
"uptime_minutes": info.get("uptime_minutes", 0),
|
||
"last_heartbeat": info.get("last_heartbeat", ""),
|
||
})
|
||
|
||
for serial in adb_devices:
|
||
if serial in seen:
|
||
continue
|
||
seen.add(serial)
|
||
adb_dev = adb_manager.get_device(serial)
|
||
info = adb_dev.get_info() if adb_dev else {}
|
||
fleet.append({
|
||
"device_id": serial,
|
||
"status": "adb",
|
||
"channel": "adb",
|
||
"model": info.get("model", ""),
|
||
"battery": info.get("battery_level", -1),
|
||
"network": info.get("network_type", ""),
|
||
"current_app": "",
|
||
"uptime_minutes": 0,
|
||
"last_heartbeat": "",
|
||
})
|
||
|
||
healthy = len([d for d in fleet if d["status"] in ("online", "adb")])
|
||
return {
|
||
"code": 200,
|
||
"data": {
|
||
"total": len(fleet),
|
||
"healthy": healthy,
|
||
"offline": len(fleet) - healthy,
|
||
"devices": fleet,
|
||
"groups": {name: ids for name, ids in _device_groups.items()},
|
||
"timestamp": int(time.time()),
|
||
}
|
||
}
|
||
|
||
|
||
@router.post("/gateway/fleet/broadcast", tags=["外部对接网关"])
|
||
async def fleet_broadcast(req: FleetCommandRequest):
|
||
"""
|
||
向多台设备广播命令 —— 批量控制
|
||
|
||
如果 device_ids 为空,则向所有在线设备广播。
|
||
"""
|
||
target_ids = req.device_ids
|
||
if not target_ids:
|
||
target_ids = list(ws_hub.connections.keys())
|
||
adb_devs = await adb_manager.async_scan_devices()
|
||
target_ids += [s for s in adb_devs if s not in target_ids]
|
||
|
||
if not target_ids:
|
||
return {"code": 200, "data": {"results": [], "message": "无在线设备"}}
|
||
|
||
results = []
|
||
for did in target_ids:
|
||
try:
|
||
if did in ws_hub.connections:
|
||
resp = await ws_hub.send_command(did, {
|
||
"type": "execute",
|
||
"data": {"action": req.command, "params": req.params}
|
||
}, timeout=15)
|
||
results.append({"device_id": did, "success": True, "result": resp})
|
||
else:
|
||
adb_dev = adb_manager.get_device(did)
|
||
if adb_dev:
|
||
results.append({"device_id": did, "success": True, "channel": "adb"})
|
||
else:
|
||
results.append({"device_id": did, "success": False, "error": "设备不在线"})
|
||
except Exception as e:
|
||
results.append({"device_id": did, "success": False, "error": str(e)})
|
||
|
||
success_count = len([r for r in results if r["success"]])
|
||
return {
|
||
"code": 200,
|
||
"data": {
|
||
"total": len(results),
|
||
"success": success_count,
|
||
"failed": len(results) - success_count,
|
||
"results": results
|
||
}
|
||
}
|
||
|
||
|
||
@router.post("/gateway/fleet/group", tags=["外部对接网关"])
|
||
async def create_device_group(req: DeviceGroupRequest):
|
||
"""创建设备分组 —— 方便批量管理"""
|
||
_device_groups[req.group_name] = req.device_ids
|
||
return {"code": 200, "data": {"group": req.group_name, "devices": req.device_ids}}
|
||
|
||
|
||
@router.get("/gateway/fleet/groups", tags=["外部对接网关"])
|
||
async def list_device_groups():
|
||
"""列出所有设备分组"""
|
||
return {"code": 200, "data": _device_groups}
|
||
|
||
|
||
# ═══════════════════════════════════════════════
|
||
# 五、AI 旅程追踪(Journey Tracking)
|
||
# ═══════════════════════════════════════════════
|
||
|
||
_journey_store: List[dict] = []
|
||
|
||
|
||
@router.get("/gateway/journey/{device_id}", tags=["外部对接网关"])
|
||
async def get_device_journey(device_id: str, limit: int = 50):
|
||
"""
|
||
获取设备的 AI 旅程 —— 每台手机的操作历史和 AI 决策记录
|
||
"""
|
||
entries = [j for j in _journey_store if j.get("device_id") == device_id]
|
||
entries.sort(key=lambda x: x.get("timestamp", 0), reverse=True)
|
||
return {
|
||
"code": 200,
|
||
"data": {
|
||
"device_id": device_id,
|
||
"entries": entries[:limit],
|
||
"total": len(entries),
|
||
}
|
||
}
|
||
|
||
|
||
@router.get("/gateway/journey", tags=["外部对接网关"])
|
||
async def get_all_journeys(limit: int = 100):
|
||
"""获取所有设备的 AI 旅程"""
|
||
entries = sorted(_journey_store, key=lambda x: x.get("timestamp", 0), reverse=True)
|
||
by_device = {}
|
||
for e in entries:
|
||
did = e.get("device_id", "unknown")
|
||
if did not in by_device:
|
||
by_device[did] = []
|
||
if len(by_device[did]) < 20:
|
||
by_device[did].append(e)
|
||
return {
|
||
"code": 200,
|
||
"data": {
|
||
"total_entries": len(_journey_store),
|
||
"devices": list(by_device.keys()),
|
||
"journeys": by_device,
|
||
}
|
||
}
|
||
|
||
|
||
def record_journey(device_id: str, action: str, result: Any = None, source: str = "api"):
|
||
"""记录一条 AI 旅程条目(内部调用)"""
|
||
entry = {
|
||
"device_id": device_id,
|
||
"action": action,
|
||
"result": str(result)[:500] if result else None,
|
||
"source": source,
|
||
"timestamp": time.time(),
|
||
"time_str": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||
}
|
||
_journey_store.append(entry)
|
||
if len(_journey_store) > 5000:
|
||
_journey_store[:] = _journey_store[-3000:]
|