340 lines
13 KiB
Python
340 lines
13 KiB
Python
"""
|
||
工作手机SDK v3.0 - AI 智能命令引擎
|
||
使用本地 Ollama 或 Gemini 免费 API 解析自然语言命令
|
||
|
||
支持:
|
||
- Ollama 本地模型(免费,默认)
|
||
- Google Gemini Flash(免费 API)
|
||
- DeepSeek(付费备选)
|
||
"""
|
||
|
||
import json
|
||
import logging
|
||
import httpx
|
||
import os
|
||
from typing import List, Dict, Optional, Any
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# ========== AI 后端配置 ==========
|
||
|
||
# Ollama(本地免费,首选)
|
||
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434")
|
||
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "qwen2.5:1.5b")
|
||
|
||
# Gemini(免费 API,备选)
|
||
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "")
|
||
GEMINI_MODEL = "gemini-2.0-flash"
|
||
|
||
# ========== 系统提示词 ==========
|
||
|
||
SYSTEM_PROMPT = """你是安卓手机操作助手。用户说指令,你只输出JSON数组。
|
||
|
||
【重要】你只能用以下10种action,禁止发明新action:
|
||
open_app, click, input, swipe, back, home, wait, screenshot, key_event, shell
|
||
|
||
APP表:微信、QQ、抖音、小红书、快手、B站、淘宝、京东、拼多多、闲鱼、支付宝、美团、饿了么、高德地图、滴滴、设置、Chrome、Termux、飞书、钉钉、企业微信、相机、电话、短信、相册、文件管理、日历、地图
|
||
|
||
操作格式(只用这些):
|
||
{"action":"open_app","app":"APP名"}
|
||
{"action":"click","text":"按钮文字"}
|
||
{"action":"input","text":"内容"}
|
||
{"action":"swipe","direction":"up/down/left/right"}
|
||
{"action":"back"}
|
||
{"action":"home"}
|
||
{"action":"wait","seconds":2}
|
||
{"action":"screenshot"}
|
||
{"action":"key_event","key":"enter/back/home/search/del/tab"}
|
||
{"action":"shell","command":"adb shell命令"}
|
||
|
||
示例:
|
||
Q: 打开微信
|
||
A: [{"action":"open_app","app":"微信"}]
|
||
|
||
Q: 打开Chrome搜索今天天气
|
||
A: [{"action":"open_app","app":"Chrome"},{"action":"wait","seconds":2},{"action":"click","text":"搜索或输入网址"},{"action":"input","text":"今天天气"},{"action":"key_event","key":"enter"}]
|
||
|
||
Q: 打开微信发消息给文件传输助手说你好
|
||
A: [{"action":"open_app","app":"微信"},{"action":"wait","seconds":2},{"action":"click","text":"搜索"},{"action":"input","text":"文件传输助手"},{"action":"click","text":"文件传输助手"},{"action":"wait","seconds":1},{"action":"input","text":"你好"},{"action":"click","text":"发送"}]
|
||
|
||
Q: 打开抖音刷视频
|
||
A: [{"action":"open_app","app":"抖音"},{"action":"wait","seconds":3},{"action":"swipe","direction":"up"}]
|
||
|
||
Q: 打开设置看WIFI
|
||
A: [{"action":"open_app","app":"设置"},{"action":"wait","seconds":1},{"action":"click","text":"WLAN"}]
|
||
|
||
Q: 打开支付宝扫一扫
|
||
A: [{"action":"open_app","app":"支付宝"},{"action":"wait","seconds":2},{"action":"click","text":"扫一扫"}]
|
||
|
||
Q: 将系统语言设置成中文
|
||
A: [{"action":"open_app","app":"设置"},{"action":"wait","seconds":1},{"action":"click","text":"System"},{"action":"click","text":"Languages"},{"action":"click","text":"中文"}]
|
||
|
||
Q: 修改系统语言
|
||
A: [{"action":"open_app","app":"设置"},{"action":"wait","seconds":1},{"action":"shell","command":"settings put system system_locales zh-CN"},{"action":"shell","command":"setprop persist.sys.locale zh-CN"}]
|
||
|
||
Q: 点击设置里的某个选项
|
||
A: [{"action":"open_app","app":"设置"},{"action":"wait","seconds":1},{"action":"click","text":"选项文字"}]
|
||
|
||
Q: 注册微信账号
|
||
A: [{"action":"open_app","app":"微信"},{"action":"wait","seconds":3},{"action":"click","text":"注册"},{"action":"wait","seconds":2}]
|
||
|
||
规则:
|
||
1. 只输出JSON数组,无其他文字
|
||
2. action只能是上面10种之一,绝对不要发明新的action名
|
||
3. 打开APP后加wait等待加载
|
||
4. 搜索操作用input+key_event enter
|
||
5. 修改系统设置优先用shell命令
|
||
6. 不确定就拆成open_app+click步骤"""
|
||
|
||
|
||
class AICommandEngine:
|
||
"""AI 命令解析引擎"""
|
||
|
||
def __init__(self):
|
||
self._backend = None # 缓存已检测到的后端
|
||
self._available = None
|
||
|
||
async def detect_backend(self, force=False) -> str:
|
||
"""检测可用的 AI 后端"""
|
||
if self._backend and self._backend != "none" and not force:
|
||
return self._backend
|
||
|
||
# 1. Ollama(本地免费)
|
||
try:
|
||
async with httpx.AsyncClient(timeout=5.0) as c:
|
||
r = await c.get(f"{OLLAMA_URL}/api/tags")
|
||
if r.status_code == 200:
|
||
models = [m["name"] for m in r.json().get("models", [])]
|
||
if models:
|
||
self._backend = "ollama"
|
||
self._available = models
|
||
logger.info(f"AI 后端: Ollama ({', '.join(models)})")
|
||
return "ollama"
|
||
else:
|
||
logger.info("Ollama 在线但无模型(可能在下载中)")
|
||
except Exception:
|
||
pass
|
||
|
||
# 2. Gemini 免费
|
||
if GEMINI_API_KEY:
|
||
self._backend = "gemini"
|
||
logger.info("AI 后端: Gemini Flash (免费)")
|
||
return "gemini"
|
||
|
||
self._backend = "none"
|
||
logger.warning("无可用 AI 后端(Ollama 未运行/无模型,Gemini 未配置)")
|
||
return "none"
|
||
|
||
async def parse(self, user_input: str, context: Optional[Dict] = None) -> List[Dict[str, Any]]:
|
||
"""
|
||
解析自然语言命令,返回操作列表。
|
||
|
||
Args:
|
||
user_input: 用户输入的自然语言
|
||
context: 可选,当前屏幕上下文(当前APP等)
|
||
|
||
Returns:
|
||
操作列表 [{"action": "...", ...}, ...]
|
||
"""
|
||
backend = await self.detect_backend()
|
||
|
||
# 构建 prompt
|
||
prompt = user_input
|
||
if context:
|
||
prompt = f"当前APP: {context.get('current_app', '未知')}\n用户指令: {user_input}"
|
||
|
||
if backend == "ollama":
|
||
return await self._call_ollama(prompt)
|
||
elif backend == "gemini":
|
||
return await self._call_gemini(prompt)
|
||
else:
|
||
return []
|
||
|
||
async def _call_ollama(self, prompt: str) -> List[Dict]:
|
||
"""调用本地 Ollama"""
|
||
try:
|
||
async with httpx.AsyncClient(timeout=60.0) as c:
|
||
r = await c.post(f"{OLLAMA_URL}/api/chat", json={
|
||
"model": OLLAMA_MODEL,
|
||
"messages": [
|
||
{"role": "system", "content": SYSTEM_PROMPT},
|
||
{"role": "user", "content": prompt},
|
||
],
|
||
"stream": False,
|
||
"options": {"temperature": 0.3, "num_predict": 500},
|
||
})
|
||
if r.status_code != 200:
|
||
logger.error(f"Ollama 错误: {r.status_code} {r.text[:200]}")
|
||
return []
|
||
text = r.json().get("message", {}).get("content", "").strip()
|
||
logger.info(f"Ollama 原始回复: {text[:200]}")
|
||
return self._extract_actions(text)
|
||
except Exception as e:
|
||
logger.error(f"Ollama 调用失败: {type(e).__name__}: {e}")
|
||
return []
|
||
|
||
async def _call_gemini(self, prompt: str) -> List[Dict]:
|
||
"""调用 Google Gemini 免费 API"""
|
||
try:
|
||
url = f"https://generativelanguage.googleapis.com/v1beta/models/{GEMINI_MODEL}:generateContent?key={GEMINI_API_KEY}"
|
||
async with httpx.AsyncClient(timeout=30.0) as c:
|
||
r = await c.post(url, json={
|
||
"contents": [{"parts": [{"text": f"{SYSTEM_PROMPT}\n\n用户: {prompt}\n返回:"}]}],
|
||
"generationConfig": {"temperature": 0.3, "maxOutputTokens": 500},
|
||
})
|
||
if r.status_code != 200:
|
||
logger.error(f"Gemini 错误: {r.status_code} {r.text[:200]}")
|
||
return []
|
||
text = r.json()["candidates"][0]["content"]["parts"][0]["text"].strip()
|
||
return self._extract_actions(text)
|
||
except Exception as e:
|
||
logger.error(f"Gemini 调用失败: {e}")
|
||
return []
|
||
|
||
# 有效的 action 类型白名单
|
||
VALID_ACTIONS = {
|
||
"open_app", "click", "input", "swipe", "back", "home",
|
||
"wait", "screenshot", "key_event", "shell", "close_app",
|
||
}
|
||
|
||
# 无效 action 的智能修复映射
|
||
ACTION_FIX_MAP = {
|
||
# 系统设置相关
|
||
"settings": "open_app", # settings -> 打开设置
|
||
"setlocale": "shell", # setlocale -> shell 命令
|
||
"set_language": "shell", # set_language -> shell 命令
|
||
"change_language": "shell",
|
||
"system_setting": "open_app",
|
||
"setting": "open_app",
|
||
# 操作类别
|
||
"tap": "click",
|
||
"press": "key_event",
|
||
"type": "input",
|
||
"write": "input",
|
||
"scroll": "swipe",
|
||
"scroll_up": "swipe",
|
||
"scroll_down": "swipe",
|
||
"launch": "open_app",
|
||
"start": "open_app",
|
||
"open": "open_app",
|
||
"close": "close_app",
|
||
"stop": "close_app",
|
||
"kill": "close_app",
|
||
"go_back": "back",
|
||
"go_home": "home",
|
||
"return": "back",
|
||
"capture": "screenshot",
|
||
"delay": "wait",
|
||
"sleep": "wait",
|
||
"pause": "wait",
|
||
"send": "click", # send -> 点击发送按钮
|
||
"search": "click", # search -> 点击搜索
|
||
"register": "click", # register -> 点击注册
|
||
"login": "click", # login -> 点击登录
|
||
}
|
||
|
||
def _fix_action(self, action: Dict) -> Dict:
|
||
"""修复无效的 action 类型,尽量保留用户意图"""
|
||
act = action.get("action", "")
|
||
|
||
if act in self.VALID_ACTIONS:
|
||
return action # 已经有效
|
||
|
||
# 尝试映射修复
|
||
fixed_act = self.ACTION_FIX_MAP.get(act.lower())
|
||
|
||
if fixed_act:
|
||
logger.info(f"AI action 修复: {act} -> {fixed_act}")
|
||
new_action = dict(action)
|
||
new_action["action"] = fixed_act
|
||
|
||
# 根据修复后的类型补充参数
|
||
if fixed_act == "open_app" and "app" not in new_action:
|
||
# settings/setting -> 打开设置
|
||
if "setting" in act.lower():
|
||
new_action["app"] = "设置"
|
||
elif fixed_act == "shell":
|
||
if "command" not in new_action:
|
||
# setlocale/set_language -> 设置中文
|
||
if "locale" in act.lower() or "language" in act.lower():
|
||
new_action["command"] = "settings put system system_locales zh-CN"
|
||
elif fixed_act == "click":
|
||
if "text" not in new_action:
|
||
# 用 action 名称本身作为按钮文字
|
||
text_map = {
|
||
"send": "发送", "search": "搜索",
|
||
"register": "注册", "login": "登录",
|
||
}
|
||
new_action["text"] = text_map.get(act.lower(), act)
|
||
elif fixed_act == "swipe":
|
||
if "direction" not in new_action:
|
||
if "up" in act.lower():
|
||
new_action["direction"] = "up"
|
||
elif "down" in act.lower():
|
||
new_action["direction"] = "down"
|
||
else:
|
||
new_action["direction"] = "up"
|
||
|
||
return new_action
|
||
|
||
# 最后尝试:含有关键词的智能推断
|
||
act_lower = act.lower()
|
||
if any(kw in act_lower for kw in ["app", "open", "launch", "start"]):
|
||
action["action"] = "open_app"
|
||
if "app" not in action:
|
||
action["app"] = "设置"
|
||
return action
|
||
if any(kw in act_lower for kw in ["click", "tap", "press", "button"]):
|
||
action["action"] = "click"
|
||
return action
|
||
if any(kw in act_lower for kw in ["type", "input", "text", "write"]):
|
||
action["action"] = "input"
|
||
return action
|
||
|
||
logger.warning(f"AI 生成了无法修复的操作: {act},跳过")
|
||
return None # 无法修复
|
||
|
||
def _extract_actions(self, text: str) -> List[Dict]:
|
||
"""从 AI 返回的文本中提取 JSON 操作列表"""
|
||
text = text.strip()
|
||
# 去除 markdown 代码块
|
||
if "```json" in text:
|
||
text = text.split("```json")[-1].split("```")[0].strip()
|
||
elif "```" in text:
|
||
text = text.split("```")[1].split("```")[0].strip() if text.count("```") >= 2 else text
|
||
# 找到 JSON 数组
|
||
start = text.find("[")
|
||
end = text.rfind("]")
|
||
if start >= 0 and end > start:
|
||
text = text[start:end + 1]
|
||
try:
|
||
data = json.loads(text)
|
||
if isinstance(data, dict):
|
||
data = [data]
|
||
if isinstance(data, list):
|
||
raw_actions = [d for d in data if isinstance(d, dict) and "action" in d]
|
||
# 校验并修复每个 action
|
||
fixed_actions = []
|
||
for a in raw_actions:
|
||
fixed = self._fix_action(a)
|
||
if fixed is not None:
|
||
fixed_actions.append(fixed)
|
||
return fixed_actions
|
||
except json.JSONDecodeError:
|
||
logger.warning(f"AI 返回无法解析: {text[:200]}")
|
||
return []
|
||
|
||
async def get_status(self) -> Dict:
|
||
"""获取 AI 引擎状态(每次重新检测)"""
|
||
backend = await self.detect_backend(force=True)
|
||
return {
|
||
"backend": backend,
|
||
"model": OLLAMA_MODEL if backend == "ollama" else GEMINI_MODEL if backend == "gemini" else "无",
|
||
"available": backend != "none",
|
||
"models": self._available or [],
|
||
}
|
||
|
||
|
||
# 全局实例
|
||
ai_engine = AICommandEngine()
|