837 lines
31 KiB
Python
837 lines
31 KiB
Python
"""
|
||
工作手机SDK v3.0 - AI Agent路由
|
||
自然语言控制手机(支持 WebSocket Agent 和 ADB 直连双模式)
|
||
增强版:智能命令引擎,支持多步骤、坐标点击、复合命令
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from fastapi import APIRouter, HTTPException
|
||
from typing import Optional, List
|
||
from pydantic import BaseModel
|
||
import logging
|
||
import re
|
||
import time
|
||
import asyncio
|
||
|
||
from services.ws_hub import ws_hub
|
||
from services.device_manager import device_manager
|
||
from services.adb_device import adb_manager
|
||
from services.brain_skill_registry import SKILL_REGISTRY, registry_summary
|
||
from config import settings
|
||
|
||
router = APIRouter()
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# ========== 常用 APP 包名映射(全量) ==========
|
||
|
||
APP_PACKAGES = {
|
||
# 社交
|
||
"微信": "com.tencent.mm", "wechat": "com.tencent.mm",
|
||
"QQ": "com.tencent.mobileqq", "qq": "com.tencent.mobileqq",
|
||
"飞书": "com.ss.android.lark", "lark": "com.ss.android.lark",
|
||
"钉钉": "com.alibaba.android.rimet",
|
||
"企业微信": "com.tencent.wework",
|
||
# 短视频/内容
|
||
"抖音": "com.ss.android.ugc.aweme", "douyin": "com.ss.android.ugc.aweme",
|
||
"小红书": "com.xingin.xhs", "xhs": "com.xingin.xhs",
|
||
"快手": "com.smile.gifmaker",
|
||
"B站": "tv.danmaku.bili", "bilibili": "tv.danmaku.bili",
|
||
# 电商
|
||
"闲鱼": "com.taobao.idlefish", "xianyu": "com.taobao.idlefish",
|
||
"淘宝": "com.taobao.taobao", "taobao": "com.taobao.taobao",
|
||
"京东": "com.jingdong.app.mall", "jd": "com.jingdong.app.mall",
|
||
"拼多多": "com.xunmeng.pinduoduo",
|
||
# 支付/金融
|
||
"支付宝": "com.eg.android.AlipayGphone", "alipay": "com.eg.android.AlipayGphone",
|
||
# 系统/工具
|
||
"设置": "com.android.settings", "settings": "com.android.settings",
|
||
"相机": "com.android.camera2", "camera": "com.android.camera2",
|
||
"浏览器": "com.android.chrome", "chrome": "com.android.chrome",
|
||
"Chrome": "com.android.chrome",
|
||
"电话": "com.google.android.dialer", "拨号": "com.google.android.dialer",
|
||
"短信": "com.google.android.apps.messaging", "消息": "com.google.android.apps.messaging",
|
||
"相册": "com.google.android.apps.photos", "photos": "com.google.android.apps.photos",
|
||
"文件管理": "com.android.documentsui",
|
||
"日历": "com.google.android.calendar",
|
||
"计算器": "com.google.android.calculator",
|
||
"时钟": "com.google.android.deskclock",
|
||
"地图": "com.google.android.apps.maps", "maps": "com.google.android.apps.maps",
|
||
# 终端
|
||
"termux": "com.termux", "Termux": "com.termux", "终端": "com.termux",
|
||
# 外卖/生活
|
||
"美团": "com.sankuai.meituan",
|
||
"饿了么": "me.ele",
|
||
"高德": "com.autonavi.minimap", "高德地图": "com.autonavi.minimap",
|
||
"滴滴": "com.sdu.didi.psnger",
|
||
}
|
||
|
||
# ========== 数据模型 ==========
|
||
|
||
class ExecuteTaskRequest(BaseModel):
|
||
"""执行任务请求"""
|
||
device_id: str
|
||
task: str
|
||
llm_provider: str = "configured" # 统一 AI 配置(默认龙虾U盘/OpenAI兼容)
|
||
max_steps: int = 30
|
||
|
||
|
||
class ExecuteTaskResponse(BaseModel):
|
||
"""执行任务响应"""
|
||
success: bool
|
||
steps: List[str] = []
|
||
duration_ms: int = 0
|
||
error: Optional[str] = None
|
||
|
||
|
||
# ========== 智能命令引擎 ==========
|
||
|
||
def _execute_single(adb_dev, cmd: str) -> dict:
|
||
"""执行单条解析后的命令,返回 {success, step, detail}"""
|
||
cmd = cmd.strip()
|
||
if not cmd:
|
||
return {"success": True, "step": "空命令跳过"}
|
||
|
||
# --- 打开 APP ---
|
||
for name, pkg in APP_PACKAGES.items():
|
||
if cmd == name or cmd == f"打开{name}" or cmd.lower() == f"open {name.lower()}":
|
||
r = adb_dev.start_app(pkg)
|
||
return {"success": r.get("code") == 200, "step": f"打开 {name}", "detail": r}
|
||
|
||
# 通过包名打开
|
||
pkg_match = re.match(r"^(?:打开|启动|open)\s*(com\.\S+)", cmd)
|
||
if pkg_match:
|
||
pkg = pkg_match.group(1)
|
||
r = adb_dev.start_app(pkg)
|
||
return {"success": r.get("code") == 200, "step": f"打开 {pkg}", "detail": r}
|
||
|
||
# --- 按键 ---
|
||
if cmd in ("主页", "回主页", "回首页", "home", "Home", "HOME"):
|
||
r = adb_dev.press_key("home")
|
||
return {"success": r.get("code") == 200, "step": "Home", "detail": r}
|
||
if cmd in ("返回", "back", "Back", "BACK"):
|
||
r = adb_dev.press_key("back")
|
||
return {"success": r.get("code") == 200, "step": "返回", "detail": r}
|
||
if cmd in ("最近任务", "多任务", "recent", "Recent"):
|
||
r = adb_dev.press_key("recent")
|
||
return {"success": r.get("code") == 200, "step": "最近任务", "detail": r}
|
||
if cmd in ("回车", "确认", "enter", "Enter"):
|
||
r = adb_dev.press_key("enter")
|
||
return {"success": r.get("code") == 200, "step": "回车", "detail": r}
|
||
if cmd in ("音量加", "音量+", "volume_up"):
|
||
r = adb_dev.press_key("volume_up")
|
||
return {"success": r.get("code") == 200, "step": "音量+", "detail": r}
|
||
if cmd in ("音量减", "音量-", "volume_down"):
|
||
r = adb_dev.press_key("volume_down")
|
||
return {"success": r.get("code") == 200, "step": "音量-", "detail": r}
|
||
if cmd in ("锁屏", "电源", "power"):
|
||
r = adb_dev.press_key("KEYCODE_POWER")
|
||
return {"success": r.get("code") == 200, "step": "电源键", "detail": r}
|
||
|
||
# --- 截图 ---
|
||
if cmd in ("截图", "截屏", "screenshot"):
|
||
r = adb_dev.screenshot()
|
||
return {"success": r.get("code") == 200, "step": "截图", "detail": r}
|
||
|
||
# --- 滑动 ---
|
||
swipe_map = {
|
||
"上滑": "up", "往上滑": "up", "向上滑": "up", "翻页": "up", "下一页": "up",
|
||
"下滑": "down", "往下滑": "down", "向下滑": "down", "刷新": "down", "上一页": "down",
|
||
"左滑": "left", "往左滑": "left", "向左滑": "left",
|
||
"右滑": "right", "往右滑": "right", "向右滑": "right",
|
||
}
|
||
if cmd in swipe_map:
|
||
r = adb_dev.swipe(swipe_map[cmd])
|
||
return {"success": r.get("code") == 200, "step": cmd, "detail": r}
|
||
|
||
# --- 坐标点击: "点击 540 1200" 或 "tap 540 1200" ---
|
||
coord_match = re.match(r"(?:点击|tap|click)\s*(\d+)[,\s]+(\d+)", cmd)
|
||
if coord_match:
|
||
x, y = int(coord_match.group(1)), int(coord_match.group(2))
|
||
r = adb_dev.click(x, y)
|
||
return {"success": r.get("code") == 200, "step": f"点击坐标 ({x},{y})", "detail": r}
|
||
|
||
# --- 纯坐标 "540,1200" 或 "540 1200" ---
|
||
pure_coord = re.match(r"^(\d{2,4})[,\s]+(\d{2,4})$", cmd)
|
||
if pure_coord:
|
||
x, y = int(pure_coord.group(1)), int(pure_coord.group(2))
|
||
r = adb_dev.click(x, y)
|
||
return {"success": r.get("code") == 200, "step": f"点击坐标 ({x},{y})", "detail": r}
|
||
|
||
# --- 点击文字 ---
|
||
click_match = re.match(r"(?:点击|点|按|选择|找到|tap|click)\s*[「\"']?(.+?)[」\"']?\s*$", cmd)
|
||
if click_match:
|
||
text = click_match.group(1).strip()
|
||
r = adb_dev.click_text(text)
|
||
return {"success": r.get("code") == 200, "step": f"点击「{text}」", "detail": r}
|
||
|
||
# --- 输入文字 ---
|
||
input_match = re.match(r"(?:输入|打字|写|type|input|write)\s*[::\s]*(.+)", cmd)
|
||
if input_match:
|
||
text = input_match.group(1).strip()
|
||
r = adb_dev.input_text(text)
|
||
return {"success": r.get("code") == 200, "step": f"输入「{text}」", "detail": r}
|
||
|
||
# --- 等待 ---
|
||
wait_match = re.match(r"(?:等待|等|wait|sleep)\s*(\d+)\s*(?:秒|s)?", cmd)
|
||
if wait_match:
|
||
secs = min(int(wait_match.group(1)), 30) # 最多30秒
|
||
time.sleep(secs)
|
||
return {"success": True, "step": f"等待 {secs} 秒"}
|
||
|
||
# --- 查看当前APP ---
|
||
if cmd in ("当前APP", "当前应用", "current_app", "current"):
|
||
r = adb_dev.current_app()
|
||
return {"success": r.get("code") == 200, "step": "查看当前APP", "detail": r}
|
||
|
||
# --- 已安装APP ---
|
||
if cmd in ("已安装", "装了什么", "app列表", "list_apps"):
|
||
r = adb_dev.installed_apps()
|
||
return {"success": r.get("code") == 200, "step": "查看已安装APP", "detail": r}
|
||
|
||
# --- UI树 ---
|
||
if cmd in ("ui树", "UI树", "ui_tree", "界面分析"):
|
||
r = adb_dev.get_ui_tree()
|
||
return {"success": r.get("code") == 200, "step": "获取UI树", "detail": r}
|
||
|
||
# --- 关闭APP ---
|
||
close_match = re.match(r"(?:关闭|停止|杀掉|kill|stop)\s*(.+)", cmd)
|
||
if close_match:
|
||
app_name = close_match.group(1).strip()
|
||
pkg = APP_PACKAGES.get(app_name, app_name)
|
||
r = adb_dev.stop_app(pkg)
|
||
return {"success": r.get("code") == 200, "step": f"关闭 {app_name}", "detail": r}
|
||
|
||
return None # 未匹配
|
||
|
||
|
||
def _execute_ai_action(adb_dev, action: dict) -> dict:
|
||
"""执行 AI 返回的单个操作"""
|
||
act = action.get("action", "")
|
||
steps = []
|
||
|
||
if act == "open_app":
|
||
app_name = action.get("app", "")
|
||
pkg = APP_PACKAGES.get(app_name, app_name)
|
||
# 如果还不是包名格式,尝试模糊匹配
|
||
if "." not in pkg:
|
||
for name, p in APP_PACKAGES.items():
|
||
if name.lower() == app_name.lower() or app_name.lower() in name.lower():
|
||
pkg = p
|
||
break
|
||
r = adb_dev.start_app(pkg)
|
||
return {"success": r.get("code") == 200, "step": f"打开 {app_name}", "detail": r}
|
||
|
||
elif act == "click":
|
||
if "text" in action:
|
||
r = adb_dev.click_text(action["text"])
|
||
return {"success": r.get("code") == 200, "step": f"点击「{action['text']}」", "detail": r}
|
||
elif "x" in action and "y" in action:
|
||
r = adb_dev.click(int(action["x"]), int(action["y"]))
|
||
return {"success": r.get("code") == 200, "step": f"点击 ({action['x']},{action['y']})", "detail": r}
|
||
|
||
elif act == "input":
|
||
text = action.get("text", "")
|
||
r = adb_dev.input_text(text)
|
||
return {"success": r.get("code") == 200, "step": f"输入「{text}」", "detail": r}
|
||
|
||
elif act == "back":
|
||
r = adb_dev.press_key("back")
|
||
return {"success": r.get("code") == 200, "step": "返回", "detail": r}
|
||
|
||
elif act == "home":
|
||
r = adb_dev.press_key("home")
|
||
return {"success": r.get("code") == 200, "step": "主页", "detail": r}
|
||
|
||
elif act == "swipe":
|
||
direction = action.get("direction", "up")
|
||
r = adb_dev.swipe(direction)
|
||
return {"success": r.get("code") == 200, "step": f"滑动{direction}", "detail": r}
|
||
|
||
elif act == "wait":
|
||
secs = min(int(action.get("seconds", 1)), 3) # AI 等待最多3秒
|
||
time.sleep(secs)
|
||
return {"success": True, "step": f"等待 {secs} 秒"}
|
||
|
||
elif act == "screenshot":
|
||
r = adb_dev.screenshot()
|
||
return {"success": r.get("code") == 200, "step": "截图", "detail": r}
|
||
|
||
elif act == "key_event":
|
||
key = action.get("key", "")
|
||
key_map = {
|
||
"enter": "enter", "回车": "enter",
|
||
"back": "back", "返回": "back",
|
||
"home": "home", "主页": "home",
|
||
"tab": "tab",
|
||
"delete": "del", "删除": "del",
|
||
"search": "search", "搜索": "search",
|
||
}
|
||
key_name = key_map.get(key.lower(), key)
|
||
r = adb_dev.press_key(key_name)
|
||
return {"success": r.get("code") == 200, "step": f"按键 {key}", "detail": r}
|
||
|
||
elif act == "close_app":
|
||
app_name = action.get("app", "")
|
||
pkg = APP_PACKAGES.get(app_name, app_name)
|
||
r = adb_dev.stop_app(pkg)
|
||
return {"success": r.get("code") == 200, "step": f"关闭 {app_name}", "detail": r}
|
||
|
||
elif act == "shell":
|
||
# 执行 ADB shell 命令(用于系统设置等高级操作)
|
||
command = action.get("command", "")
|
||
if not command:
|
||
return {"success": False, "step": "shell 命令为空"}
|
||
# 安全检查:禁止危险命令
|
||
dangerous = ["rm -rf /", "format", "wipe", "factory_reset", "reboot"]
|
||
if any(d in command.lower() for d in dangerous):
|
||
return {"success": False, "step": f"危险命令被阻止: {command}"}
|
||
try:
|
||
r = adb_dev._shell(command, timeout=10)
|
||
return {"success": True, "step": f"执行: {command}", "detail": {"output": r}}
|
||
except Exception as e:
|
||
return {"success": False, "step": f"shell 失败: {command}", "detail": {"error": str(e)}}
|
||
|
||
# 未知操作:尝试智能回退
|
||
logger.warning(f"[AI] 未知操作类型: {act},尝试智能回退")
|
||
|
||
# 回退策略1:如果有 app 字段,当作 open_app
|
||
if "app" in action:
|
||
app_name = action["app"]
|
||
pkg = APP_PACKAGES.get(app_name, app_name)
|
||
if "." not in pkg:
|
||
for name, p in APP_PACKAGES.items():
|
||
if name.lower() == app_name.lower():
|
||
pkg = p
|
||
break
|
||
r = adb_dev.start_app(pkg)
|
||
return {"success": r.get("code") == 200, "step": f"打开 {app_name} (回退)", "detail": r}
|
||
|
||
# 回退策略2:如果有 text 字段,当作 click
|
||
if "text" in action:
|
||
r = adb_dev.click_text(action["text"])
|
||
return {"success": r.get("code") == 200, "step": f"点击「{action['text']}」(回退)", "detail": r}
|
||
|
||
# 回退策略3:如果有 command 字段,当作 shell
|
||
if "command" in action:
|
||
try:
|
||
r = adb_dev._shell(action["command"], timeout=10)
|
||
return {"success": True, "step": f"执行: {action['command']} (回退)"}
|
||
except Exception:
|
||
pass
|
||
|
||
return {"success": False, "step": f"未知操作: {act}"}
|
||
|
||
|
||
def _parse_and_execute_adb(device_id: str, task: str) -> dict:
|
||
"""
|
||
智能命令引擎:解析自然语言,支持单步和多步命令。
|
||
用 "然后" / "," / ";" 分隔多步骤。
|
||
如果无法精确匹配,尝试智能推断。
|
||
"""
|
||
adb_dev = adb_manager.get_device(device_id)
|
||
if not adb_dev:
|
||
return {"success": False, "error": f"ADB 设备 {device_id} 不可用"}
|
||
|
||
task = task.strip()
|
||
if not task:
|
||
return {"success": False, "error": "命令为空"}
|
||
|
||
# 分割多步命令(支持 "然后"、","、";"、";"、"→"、"->")
|
||
parts = re.split(r"[;;]\s*|然后\s*|之后\s*|→\s*|->\s*", task)
|
||
# 如果只有一段且包含逗号分隔的完整子命令,也拆分
|
||
if len(parts) == 1 and "," in task:
|
||
# 只在确实是多个命令时才按逗号拆(不拆坐标等)
|
||
potential = re.split(r"[,]\s*", task)
|
||
if len(potential) > 1 and all(len(p.strip()) > 1 for p in potential):
|
||
parts = potential
|
||
|
||
all_steps = []
|
||
all_success = True
|
||
last_detail = None
|
||
needs_ai_parts = []
|
||
|
||
for part in parts:
|
||
part = part.strip()
|
||
if not part:
|
||
continue
|
||
|
||
result = _execute_single(adb_dev, part)
|
||
|
||
if result is None:
|
||
# 智能推断:尝试作为 APP 名打开
|
||
for name, pkg in APP_PACKAGES.items():
|
||
if name.lower() == part.lower() or name == part:
|
||
r = adb_dev.start_app(pkg)
|
||
result = {"success": r.get("code") == 200, "step": f"打开 {name}", "detail": r}
|
||
break
|
||
|
||
if result is None:
|
||
# 智能推断:纯数字当成等待秒数(<=10)或当成文字点击
|
||
if part.isdigit():
|
||
num = int(part)
|
||
if num <= 10:
|
||
time.sleep(num)
|
||
result = {"success": True, "step": f"等待 {num} 秒"}
|
||
else:
|
||
# 当作文字点击
|
||
r = adb_dev.click_text(part)
|
||
result = {"success": r.get("code") == 200, "step": f"点击「{part}」", "detail": r}
|
||
|
||
if result is None:
|
||
# 最终尝试:当作要点击的文字(UI上找这个文字)
|
||
r = adb_dev.click_text(part)
|
||
if r.get("code") == 200:
|
||
result = {"success": True, "step": f"点击「{part}」", "detail": r}
|
||
else:
|
||
# 标记为需要 AI 处理
|
||
result = {"success": False, "step": part, "needs_ai": True}
|
||
|
||
all_steps.append(result.get("step", part))
|
||
last_detail = result.get("detail")
|
||
if not result.get("success"):
|
||
if result.get("needs_ai"):
|
||
# 收集需要 AI 处理的部分
|
||
needs_ai_parts.append(part)
|
||
else:
|
||
all_success = False
|
||
if result.get("error"):
|
||
all_steps[-1] += f" (失败: {result['error']})"
|
||
|
||
# 多步之间短暂等待
|
||
if len(parts) > 1:
|
||
time.sleep(0.5)
|
||
|
||
# 如果有需要 AI 处理的部分,在最后返回时标记
|
||
if needs_ai_parts:
|
||
return {
|
||
"success": all_success if not needs_ai_parts else None, # None = 需要 AI
|
||
"steps": all_steps,
|
||
"detail": last_detail,
|
||
"total_steps": len(all_steps),
|
||
"needs_ai": True,
|
||
"ai_query": task, # 把原始完整命令传给 AI
|
||
}
|
||
|
||
return {
|
||
"success": all_success,
|
||
"steps": all_steps,
|
||
"detail": last_detail,
|
||
"total_steps": len(all_steps),
|
||
}
|
||
|
||
|
||
# ========== AI Agent接口 ==========
|
||
|
||
@router.post("/agent/execute", response_model=dict)
|
||
async def execute_task(req: ExecuteTaskRequest):
|
||
"""
|
||
执行自然语言任务(自动选择 WebSocket Agent 或 ADB 直连模式)
|
||
|
||
示例任务:
|
||
- "打开微信"
|
||
- "回主页"
|
||
- "点击 发现"
|
||
- "输入 你好"
|
||
"""
|
||
|
||
# 优先使用 WebSocket Agent(功能更强)
|
||
if ws_hub.is_online(req.device_id):
|
||
from services.ai_provider import get_config
|
||
ai_config = get_config()
|
||
if req.llm_provider == "configured" and not ai_config.get("api_key"):
|
||
raise HTTPException(status_code=400, detail="统一 AI 接口尚未配置 API Key")
|
||
|
||
result = await ws_hub.send_command(req.device_id, {
|
||
"type": "agent_execute",
|
||
"data": {
|
||
"task": req.task,
|
||
"llm_provider": req.llm_provider,
|
||
"max_steps": req.max_steps
|
||
}
|
||
}, timeout=120)
|
||
|
||
if result.get("code") != 200:
|
||
return {
|
||
"code": result.get("code", 500),
|
||
"data": {"success": False, "error": result.get("message", "执行失败")}
|
||
}
|
||
|
||
try:
|
||
await device_manager.log_command(req.device_id, "agent.execute", {"task": req.task}, result)
|
||
except Exception:
|
||
pass
|
||
|
||
return {"code": 200, "data": result.get("data", {})}
|
||
|
||
# 降级:ADB 本地直连执行
|
||
adb_dev = adb_manager.get_device(req.device_id)
|
||
if adb_dev and adb_dev.is_online():
|
||
logger.info(f"[agent] ADB 直连执行: {req.device_id} -> {req.task}")
|
||
result = _parse_and_execute_adb(req.device_id, req.task)
|
||
|
||
# 如果模式匹配失败,调用 AI 引擎
|
||
if result.get("needs_ai"):
|
||
logger.info(f"[agent] 模式匹配失败,调用 AI: {req.task}")
|
||
from services.ai_command import ai_engine
|
||
|
||
# 获取当前屏幕上下文
|
||
context = None
|
||
try:
|
||
app_info = adb_dev.current_app()
|
||
if app_info.get("code") == 200:
|
||
context = {"current_app": app_info["data"].get("package", "unknown")}
|
||
except Exception:
|
||
pass
|
||
|
||
ai_actions = await ai_engine.parse(req.task, context)
|
||
if ai_actions:
|
||
ai_steps = []
|
||
ai_success = True
|
||
for action in ai_actions:
|
||
r = _execute_ai_action(adb_dev, action)
|
||
ai_steps.append(r.get("step", str(action)))
|
||
if not r.get("success"):
|
||
ai_success = False
|
||
time.sleep(0.5)
|
||
|
||
return {"code": 200, "data": {
|
||
"success": ai_success,
|
||
"steps": ai_steps,
|
||
"total_steps": len(ai_steps),
|
||
"engine": "ai",
|
||
}}
|
||
else:
|
||
# AI 也失败了
|
||
return {"code": 200, "data": {
|
||
"success": False,
|
||
"error": f"无法理解命令: {req.task}",
|
||
"hint": "AI 引擎无法解析。试试: 打开微信/点击XXX/输入XXX/截图 等",
|
||
}}
|
||
|
||
return {"code": 200, "data": result}
|
||
|
||
raise HTTPException(status_code=503, detail="设备不在线(WebSocket 和 ADB 均不可用)")
|
||
|
||
|
||
@router.get("/agent/status/{device_id}", response_model=dict)
|
||
async def get_agent_status(device_id: str):
|
||
"""获取Agent状态"""
|
||
|
||
if not ws_hub.is_online(device_id):
|
||
return {
|
||
"code": 200,
|
||
"data": {
|
||
"status": "offline",
|
||
"device_id": device_id
|
||
}
|
||
}
|
||
|
||
result = await ws_hub.send_command(device_id, {
|
||
"type": "agent_status"
|
||
}, timeout=5)
|
||
|
||
return {
|
||
"code": 200,
|
||
"data": result.get("data", {"status": "ready"})
|
||
}
|
||
|
||
|
||
@router.post("/agent/stop/{device_id}", response_model=dict)
|
||
async def stop_agent(device_id: str):
|
||
"""停止正在执行的Agent任务"""
|
||
|
||
if not ws_hub.is_online(device_id):
|
||
raise HTTPException(status_code=503, detail="设备不在线")
|
||
|
||
result = await ws_hub.send_command(device_id, {
|
||
"type": "agent_stop"
|
||
}, timeout=5)
|
||
|
||
return {
|
||
"code": 200,
|
||
"data": {"stopped": True}
|
||
}
|
||
|
||
|
||
# ========== AI 引擎端点 ==========
|
||
|
||
@router.get("/ai/status", response_model=dict)
|
||
async def ai_status():
|
||
"""获取 AI 引擎状态"""
|
||
from services.ai_command import ai_engine
|
||
status = await ai_engine.get_status()
|
||
return {"code": 200, "data": status}
|
||
|
||
|
||
@router.get("/ai/config", response_model=dict)
|
||
async def get_ai_config():
|
||
"""读取统一 AI 接口配置(密钥仅返回已配置状态)。"""
|
||
from services.ai_provider import public_config
|
||
return {"code": 200, "data": public_config()}
|
||
|
||
|
||
class AIConfigRequest(BaseModel):
|
||
provider: Optional[str] = None
|
||
base_url: Optional[str] = None
|
||
api_key: Optional[str] = None
|
||
model: Optional[str] = None
|
||
enabled: Optional[bool] = None
|
||
|
||
|
||
@router.post("/ai/config", response_model=dict)
|
||
async def save_ai_config(req: AIConfigRequest):
|
||
"""保存统一 AI 接口配置,供 Agent 与设备管理共同使用。"""
|
||
from services.ai_provider import save_config
|
||
try:
|
||
data = save_config(req.model_dump(exclude_none=True))
|
||
return {"code": 200, "data": data, "message": "统一 AI 接口已保存"}
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=400, detail=str(exc))
|
||
|
||
|
||
class AIChatRequest(BaseModel):
|
||
message: str
|
||
device_id: Optional[str] = None
|
||
|
||
|
||
@router.post("/ai/chat", response_model=dict)
|
||
async def ai_chat(req: AIChatRequest):
|
||
"""
|
||
AI 对话控制手机 —— 核心端点
|
||
用户输入自然语言,AI 返回并执行操作
|
||
"""
|
||
from services.ai_command import ai_engine
|
||
|
||
t0 = time.time()
|
||
|
||
# 1. 获取设备(跳过 current_app 避免 3 秒开销,AI 不需要这个上下文)
|
||
context = None
|
||
adb_dev = None
|
||
if req.device_id:
|
||
adb_dev = adb_manager.get_device(req.device_id)
|
||
|
||
t1 = time.time()
|
||
logger.info(f"[ai_chat] 设备获取: {t1-t0:.2f}s")
|
||
|
||
# 2. AI 解析
|
||
actions = await ai_engine.parse(req.message, context)
|
||
|
||
t2 = time.time()
|
||
logger.info(f"[ai_chat] AI 解析: {t2-t1:.2f}s ({len(actions)} 步)")
|
||
|
||
if not actions:
|
||
return {"code": 200, "data": {
|
||
"success": False,
|
||
"message": f"AI 无法解析: {req.message}",
|
||
"actions": [],
|
||
}}
|
||
|
||
# 3. 如果有设备,自动执行(放到线程池避免阻塞事件循环)
|
||
executed_steps = []
|
||
if adb_dev:
|
||
def _run_actions():
|
||
results = []
|
||
for i, action in enumerate(actions):
|
||
ta = time.time()
|
||
try:
|
||
r = _execute_ai_action(adb_dev, action)
|
||
results.append(r)
|
||
tb = time.time()
|
||
logger.info(f"[ai_chat] 步骤{i+1} {action.get('action')}: {tb-ta:.2f}s {'✅' if r.get('success') else '❌'}")
|
||
# wait 动作已自带延时,其他操作短暂间隔
|
||
if action.get("action") != "wait":
|
||
time.sleep(0.2)
|
||
except Exception as e:
|
||
results.append({"success": False, "step": f"异常: {e}"})
|
||
logger.error(f"[ai_chat] 步骤{i+1} 异常: {e}")
|
||
return results
|
||
|
||
try:
|
||
executed_steps = await asyncio.wait_for(
|
||
asyncio.to_thread(_run_actions),
|
||
timeout=20.0 # 总执行时间上限
|
||
)
|
||
except asyncio.TimeoutError:
|
||
executed_steps.append({"success": False, "step": "执行超时(20秒上限)"})
|
||
logger.warning(f"[ai_chat] 执行超时!")
|
||
|
||
t3 = time.time()
|
||
logger.info(f"[ai_chat] 总耗时: {t3-t0:.2f}s")
|
||
|
||
return {"code": 200, "data": {
|
||
"success": all(s.get("success") for s in executed_steps) if executed_steps else True,
|
||
"message": f"AI 解析 {len(actions)} 步" + (f",已执行 {len(executed_steps)} 步" if executed_steps else ""),
|
||
"actions": actions,
|
||
"executed": executed_steps,
|
||
"engine": "ai",
|
||
}}
|
||
|
||
|
||
# ========== AI Brain 技能注册表 ==========
|
||
# 真源: sdk/app/services/brain_skill_registry.py
|
||
|
||
@router.get("/ai/brain/skill-registry")
|
||
async def get_skill_registry():
|
||
"""AI Brain 技能注册表 — 返回所有可用技能、模块、操作的完整清单"""
|
||
return {
|
||
"code": 200,
|
||
"data": {
|
||
"skills": SKILL_REGISTRY,
|
||
"summary": registry_summary(),
|
||
},
|
||
}
|
||
|
||
|
||
@router.get("/ai/brain/dashboard")
|
||
async def ai_brain_dashboard():
|
||
"""AI Brain 仪表盘 — 汇总 AI Brain 在全部设备上的状态"""
|
||
online_devices = ws_hub.get_online_devices() if hasattr(ws_hub, 'get_online_devices') else []
|
||
try:
|
||
all_devices = await device_manager.get_all_devices() if hasattr(device_manager, 'get_all_devices') else []
|
||
except Exception:
|
||
all_devices = []
|
||
try:
|
||
adb_count = len(adb_manager.scan_devices()) if hasattr(adb_manager, 'scan_devices') else 0
|
||
except Exception:
|
||
adb_count = 0
|
||
|
||
total_skills = len(SKILL_REGISTRY)
|
||
total_actions = sum(len(a) for s in SKILL_REGISTRY.values() for a in s["modules"].values())
|
||
|
||
channels = {
|
||
"frida_hook": {"name": "Frida Hook", "priority": 1, "desc": "直接调用 APP 内部方法,50-200ms"},
|
||
"u2_automation": {"name": "uiautomator2", "priority": 2, "desc": "UI 自动化模拟操作,2-15s"},
|
||
"adb_shell": {"name": "ADB Shell", "priority": 3, "desc": "ADB 命令控制,1-10s"},
|
||
"ai_agent": {"name": "AI Agent", "priority": 4, "desc": "LLM 意图解析 + 步骤执行"},
|
||
}
|
||
|
||
return {
|
||
"code": 200,
|
||
"data": {
|
||
"devices": {
|
||
"ws_online": len(online_devices) if isinstance(online_devices, list) else online_devices,
|
||
"adb_online": adb_count,
|
||
"total_registered": len(all_devices) if isinstance(all_devices, list) else all_devices,
|
||
},
|
||
"skills": {"total_skills": total_skills, "total_actions": total_actions},
|
||
"channels": channels,
|
||
"ai_brain": {
|
||
"version": "1.0.0",
|
||
"architecture": "v3.1 Frida优先 → u2兜底",
|
||
"heartbeat_interval": 60,
|
||
"features": [
|
||
"心跳驱动自主决策",
|
||
"离线自主运行模式",
|
||
"Frida 优先通道",
|
||
"任务队列管理",
|
||
"常驻指令执行",
|
||
"离线缓冲上传",
|
||
],
|
||
},
|
||
},
|
||
}
|
||
|
||
|
||
@router.post("/ai/brain/execute-script")
|
||
async def execute_brain_script(request: dict):
|
||
"""
|
||
AI Brain 脚本执行器 — 向指定设备发送技能脚本
|
||
Body: {device_id, script, action, params, channel?}
|
||
"""
|
||
device_id = request.get("device_id")
|
||
script = request.get("script")
|
||
action = request.get("action")
|
||
params = request.get("params", {})
|
||
channel = request.get("channel", "auto")
|
||
|
||
if not device_id or not script or not action:
|
||
raise HTTPException(status_code=400, detail="device_id, script, action 必填")
|
||
|
||
if ws_hub.is_online(device_id):
|
||
cmd = {
|
||
"type": "execute",
|
||
"script": script,
|
||
"action": action,
|
||
"params": params,
|
||
"channel": channel,
|
||
}
|
||
result = await ws_hub.send_command(device_id, cmd, timeout=60)
|
||
return {"code": 200, "data": result}
|
||
|
||
adb_dev = adb_manager.get_device(device_id)
|
||
if adb_dev and adb_dev.is_online():
|
||
raise HTTPException(status_code=501, detail="ADB 模式暂不支持直接技能执行,请通过 /agent/execute 调用")
|
||
|
||
raise HTTPException(status_code=503, detail=f"设备 {device_id} 不在线")
|
||
|
||
|
||
@router.post("/ai/brain/batch-execute")
|
||
async def batch_execute_script(request: dict):
|
||
"""
|
||
AI Brain 批量脚本执行 — 向多台设备同时发送相同脚本
|
||
Body: {device_ids: [...], script, action, params}
|
||
"""
|
||
device_ids = request.get("device_ids", [])
|
||
script = request.get("script")
|
||
action = request.get("action")
|
||
params = request.get("params", {})
|
||
|
||
if not device_ids or not script or not action:
|
||
raise HTTPException(status_code=400, detail="device_ids, script, action 必填")
|
||
|
||
results = {}
|
||
tasks = []
|
||
for did in device_ids:
|
||
if ws_hub.is_online(did):
|
||
cmd = {"type": "execute", "script": script, "action": action, "params": params}
|
||
tasks.append((did, ws_hub.send_command(did, cmd, timeout=60)))
|
||
else:
|
||
results[did] = {"code": 503, "message": "设备不在线"}
|
||
|
||
for did, task in tasks:
|
||
try:
|
||
result = await task
|
||
results[did] = result
|
||
except Exception as e:
|
||
results[did] = {"code": 500, "message": str(e)}
|
||
|
||
return {
|
||
"code": 200,
|
||
"data": {
|
||
"results": results,
|
||
"total": len(device_ids),
|
||
"success": sum(1 for r in results.values() if isinstance(r, dict) and r.get("code") == 200),
|
||
},
|
||
}
|
||
|
||
|
||
# ========== OpenAI 兼容代理(给 OpenClaw 用) ==========
|
||
|
||
@router.post("/v1/chat/completions", response_model=dict)
|
||
async def openai_proxy(request: dict):
|
||
"""
|
||
OpenAI 兼容端点 —— 代理到本地 Ollama
|
||
OpenClaw 可以配置 baseUrl 指向这里
|
||
"""
|
||
import httpx
|
||
from services.ai_command import OLLAMA_URL, OLLAMA_MODEL
|
||
|
||
model = request.get("model", OLLAMA_MODEL)
|
||
messages = request.get("messages", [])
|
||
max_tokens = request.get("max_tokens", 200)
|
||
|
||
try:
|
||
async with httpx.AsyncClient(timeout=60.0) as c:
|
||
r = await c.post(f"{OLLAMA_URL}/v1/chat/completions", json={
|
||
"model": model,
|
||
"messages": messages,
|
||
"max_tokens": max_tokens,
|
||
"stream": False,
|
||
})
|
||
return r.json()
|
||
except Exception as e:
|
||
return {
|
||
"error": {"message": str(e), "type": "proxy_error"},
|
||
"choices": [],
|
||
}
|