Files
workphone-sdk/sdk/app/routers/gateway.py

619 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
工作手机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 APIAI 对接)
# ═══════════════════════════════════════════════
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 ProtocolMCP 对接)
# ═══════════════════════════════════════════════
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:]