feat: AI 目标设备必选 + 设备任务占用锁——①前端新增「🎯 目标设备」选择器(在线设备含型号,任务运行中的设备禁选),发送必须选定设备,AI 只操作你指定的设备;②/api/agent/run 校验:serial 不在池/离线/任务 running-connecting → 拒绝(409 提示任务名);③MCP 11 个写工具加 busy 锁(_ensure_device_free,5s 缓存):任务运行中的设备 AI 一律拒绝,AI 不与任务抢设备(外部 MCP 客户端同样受保护);④运行状态端点 GET /api/agent/run + 前端 8s 轮询:多人/多窗口能看到运行中任务(发起时间/任务/设备)并可停止(stop 加确认);未选设备直接 400 引导请选择设备,去掉模型自己乱挑设备的行为
This commit is contained in:
@@ -52,6 +52,32 @@ def _check_write():
|
||||
raise PlatformError("write_disabled", "写操作未启用(MCP_ALLOW_WRITE=1 开启)")
|
||||
|
||||
|
||||
# 设备任务占用锁:serial -> (ts, worker_status, task_job)。AI 写操作前检查,
|
||||
# 任务 running/connecting 的设备拒绝操作(AI 不与任务抢设备)。5s 缓存。
|
||||
_busy_cache = {}
|
||||
|
||||
|
||||
def _ensure_device_free(serial):
|
||||
"""写操作前检查设备是否有任务在跑(worker running/connecting → 拒绝)。"""
|
||||
import time
|
||||
now = time.time()
|
||||
c = _busy_cache.get(serial)
|
||||
if not c or now - c[0] > 5:
|
||||
try:
|
||||
devs = platform().list_devices()
|
||||
info = next((d for d in devs if d.get("serial") == serial), {})
|
||||
c = (now, info.get("worker_status") or "idle",
|
||||
info.get("task_job") or "")
|
||||
_busy_cache[serial] = c
|
||||
except Exception:
|
||||
return # 状态查询失败不阻塞(操作失败会另行报错)
|
||||
if c[1] in ("running", "connecting"):
|
||||
raise PlatformError(
|
||||
"device_busy",
|
||||
f"设备正在执行任务「{c[2] or '未知'}」——AI 不与任务抢设备,"
|
||||
f"任务结束后才能操作(可在平台任务页先停止任务)")
|
||||
|
||||
|
||||
def _to_native(serial, x, y):
|
||||
"""截图坐标 → 设备原生坐标(按最近一次截图的比例换算)。"""
|
||||
c = _coord.get(serial)
|
||||
@@ -138,6 +164,7 @@ def de_tap(serial: str, x: int, y: int) -> dict:
|
||||
try:
|
||||
_check_write()
|
||||
serial = _check_serial(serial)
|
||||
_ensure_device_free(serial)
|
||||
if x < 0 or y < 0:
|
||||
raise PlatformError("invalid_param", "坐标不能为负")
|
||||
nx, ny = _to_native(serial, x, y)
|
||||
@@ -160,6 +187,7 @@ def de_swipe(serial: str, x1: int, y1: int, x2: int, y2: int,
|
||||
try:
|
||||
_check_write()
|
||||
serial = _check_serial(serial)
|
||||
_ensure_device_free(serial)
|
||||
nx1, ny1 = _to_native(serial, x1, y1)
|
||||
nx2, ny2 = _to_native(serial, x2, y2)
|
||||
platform().swipe(serial, nx1, ny1, nx2, ny2, duration)
|
||||
@@ -217,6 +245,7 @@ def de_tap_element(serial: str, by: str, value: str, index: int = 1) -> dict:
|
||||
try:
|
||||
_check_write()
|
||||
serial = _check_serial(serial)
|
||||
_ensure_device_free(serial)
|
||||
if by not in ("text", "id", "desc", "text_contains", "desc_contains"):
|
||||
raise PlatformError("invalid_param",
|
||||
"by 可选 text/id/desc/text_contains/desc_contains")
|
||||
@@ -270,6 +299,7 @@ def de_wake(serial: str) -> dict:
|
||||
try:
|
||||
_check_write()
|
||||
serial = _check_serial(serial)
|
||||
_ensure_device_free(serial)
|
||||
platform().wake(serial)
|
||||
except PlatformError as e:
|
||||
return _err(e)
|
||||
@@ -283,6 +313,7 @@ def de_press_key(serial: str, key: str) -> dict:
|
||||
try:
|
||||
_check_write()
|
||||
serial = _check_serial(serial)
|
||||
_ensure_device_free(serial)
|
||||
if key not in _KEYS:
|
||||
raise PlatformError("invalid_param", f"不支持的按键: {key}(可选 {_KEYS})")
|
||||
platform().press_key(serial, key)
|
||||
@@ -299,6 +330,7 @@ def de_open_app(serial: str, package: str) -> dict:
|
||||
try:
|
||||
_check_write()
|
||||
serial = _check_serial(serial)
|
||||
_ensure_device_free(serial)
|
||||
if not package:
|
||||
raise PlatformError("invalid_param", "缺少包名")
|
||||
from mcp_server import direct_ops
|
||||
@@ -317,6 +349,7 @@ def de_stop_app(serial: str, package: str) -> dict:
|
||||
try:
|
||||
_check_write()
|
||||
serial = _check_serial(serial)
|
||||
_ensure_device_free(serial)
|
||||
if not package:
|
||||
raise PlatformError("invalid_param", "缺少包名")
|
||||
from mcp_server import direct_ops
|
||||
@@ -350,6 +383,7 @@ def de_type_text(serial: str, text: str) -> dict:
|
||||
try:
|
||||
_check_write()
|
||||
serial = _check_serial(serial)
|
||||
_ensure_device_free(serial)
|
||||
if not text:
|
||||
raise PlatformError("invalid_param", "内容为空")
|
||||
from mcp_server import direct_ops
|
||||
@@ -370,6 +404,7 @@ def de_set_clipboard(serial: str, text: str) -> dict:
|
||||
try:
|
||||
_check_write()
|
||||
serial = _check_serial(serial)
|
||||
_ensure_device_free(serial)
|
||||
if not text:
|
||||
raise PlatformError("invalid_param", "内容为空")
|
||||
from mcp_server import direct_ops
|
||||
@@ -390,6 +425,7 @@ def de_sleep(serial: str) -> dict:
|
||||
try:
|
||||
_check_write()
|
||||
serial = _check_serial(serial)
|
||||
_ensure_device_free(serial)
|
||||
platform().sleep(serial)
|
||||
except PlatformError as e:
|
||||
return _err(e)
|
||||
@@ -429,6 +465,7 @@ def de_tap_text(serial: str, text: str) -> dict:
|
||||
try:
|
||||
_check_write()
|
||||
serial = _check_serial(serial)
|
||||
_ensure_device_free(serial)
|
||||
if not text or len(text) > 100:
|
||||
raise PlatformError("invalid_param", "text 不能为空且 ≤100 字符")
|
||||
res = platform().tap_text(serial, text)
|
||||
|
||||
+66
-2
@@ -4,6 +4,7 @@ let _agentStream = null; // EventSource
|
||||
let _agentCfgLoaded = false;
|
||||
|
||||
// ================== 初始化 ==================
|
||||
let _agentDefaultSerial = '';
|
||||
function initAgentChat(){
|
||||
if(_agentCfgLoaded)return;
|
||||
_agentCfgLoaded = true;
|
||||
@@ -12,10 +13,12 @@ function initAgentChat(){
|
||||
if(chat && !chat.children.length){
|
||||
chat.innerHTML = '<div class="agent-empty">👋 给 AI 下达指令,它将通过截图观察手机并执行操作。<br>'
|
||||
+ '例如:「打开抖音搜索奚学东,告诉我第一个视频的标题」<br>'
|
||||
+ '点击右上角 ⚙ 配置模型与 API Key。</div>';
|
||||
+ '先选「🎯 目标设备」(AI 只操作你选定的设备),点击右上角 ⚙ 配置模型与 API Key。</div>';
|
||||
}
|
||||
bindChatScroll();
|
||||
loadLiveDevices();
|
||||
loadAgentTargetDevices();
|
||||
startRunPoll();
|
||||
// 输入框快捷键
|
||||
const inp = document.getElementById('agent-input');
|
||||
inp.addEventListener('keydown', ev=>{
|
||||
@@ -32,6 +35,58 @@ function loadAgentConfig(){
|
||||
if(!r||!r.ok)return;
|
||||
const tag = document.getElementById('agent-model-tag');
|
||||
if(tag && r.model) tag.textContent = r.model;
|
||||
_agentDefaultSerial = r.default_serial || '';
|
||||
});
|
||||
}
|
||||
|
||||
// ================== 目标设备选择(AI 只操作选定设备) ==================
|
||||
function loadAgentTargetDevices(){
|
||||
apiGet('/api/agent/devices').then(r=>{
|
||||
if(!r||!r.ok)return;
|
||||
const sel = document.getElementById('agent-target-select');
|
||||
if(!sel)return;
|
||||
const cur = sel.value;
|
||||
const devs = (r.devices||[]).filter(x=>x.online);
|
||||
sel.innerHTML = '<option value="">请选择设备(AI 只操作选定设备)…</option>'
|
||||
+ devs.map(d=>{
|
||||
const label = d.serial + (d.model ? ' · ' + d.model : '')
|
||||
+ (d.busy ? ' ⛔ 任务中:' + d.task_job : '');
|
||||
return '<option value="'+esc(d.serial)+'"' + (d.busy ? ' disabled' : '')
|
||||
+ '>' + esc(label) + '</option>';
|
||||
}).join('');
|
||||
// 保留当前选择;否则预选配置的默认设备
|
||||
if(cur && [...sel.options].some(o=>o.value===cur)) sel.value = cur;
|
||||
else if(_agentDefaultSerial && [...sel.options].some(o=>o.value===_agentDefaultSerial))
|
||||
sel.value = _agentDefaultSerial;
|
||||
const hint = document.getElementById('agent-target-hint');
|
||||
if(hint && sel.value) hint.textContent = '将操作:' + sel.value;
|
||||
});
|
||||
}
|
||||
|
||||
// ================== 跨窗口运行状态(多人/多窗口可见并可停止) ==================
|
||||
let _runPoll = null;
|
||||
function startRunPoll(){
|
||||
if(_runPoll) return;
|
||||
_runPoll = setInterval(pollRunState, 8000);
|
||||
}
|
||||
function pollRunState(){
|
||||
if(_agentStream) return; // 本窗口正在跑(事件流驱动),不轮询
|
||||
apiGet('/api/agent/run').then(r=>{
|
||||
if(!r||!r.ok)return;
|
||||
const running = r.state === 'running';
|
||||
document.getElementById('agent-running-tag').style.display = running ? 'inline' : 'none';
|
||||
document.getElementById('btn-agent-stop').style.display = running ? 'inline-block' : 'none';
|
||||
const hint = document.getElementById('agent-target-hint');
|
||||
if(hint){
|
||||
hint.textContent = running
|
||||
? ('⏳ 运行中' + (r.started ? ' ' + r.started + ' 起' : '')
|
||||
+ (r.serial ? ' · ' + r.serial : '')
|
||||
+ ':' + (r.prompt||'').slice(0,70))
|
||||
: (document.getElementById('agent-target-select').value
|
||||
? '将操作:' + document.getElementById('agent-target-select').value : '');
|
||||
}
|
||||
document.getElementById('btn-agent-send').disabled = running;
|
||||
if(!running && _agentBusy){ setRunning(false); } // 流异常丢失时复位
|
||||
});
|
||||
}
|
||||
function openAgentConfig(){
|
||||
@@ -64,6 +119,7 @@ function saveAgentConfig(){
|
||||
showToast('配置已保存','success');
|
||||
closeAgentConfig();
|
||||
loadAgentConfig();
|
||||
loadAgentTargetDevices();
|
||||
}else showToast('保存失败: ' + ((r&&r.error)||''),'error');
|
||||
});
|
||||
}
|
||||
@@ -129,6 +185,7 @@ function setRunning(on){
|
||||
document.getElementById('btn-agent-send').disabled = on;
|
||||
}
|
||||
function stopAgent(){
|
||||
if(!confirm('确定停止当前 AI 运行任务?\n(其他人发起的任务也会被停止)')) return;
|
||||
apiPost('/api/agent/stop',{}).then(r=>{
|
||||
if(r&&r.ok) showToast('已请求停止,正在中断…','success');
|
||||
else showToast((r&&r.error)||'停止失败','error');
|
||||
@@ -136,13 +193,20 @@ function stopAgent(){
|
||||
}
|
||||
function sendAgentMsg(){
|
||||
if(_agentBusy){showToast('上一轮还在运行','error');return;}
|
||||
const sel = document.getElementById('agent-target-select');
|
||||
const serial = sel ? sel.value : '';
|
||||
if(!serial){
|
||||
showToast('请先选择目标设备(AI 只操作你选定的设备;任务中的设备不可选)','error');
|
||||
if(sel) sel.focus();
|
||||
return;
|
||||
}
|
||||
const inp = document.getElementById('agent-input');
|
||||
const text = inp.value.trim();
|
||||
if(!text){showToast('请输入指令','error');return;}
|
||||
inp.value = '';
|
||||
addUserMsg(text);
|
||||
|
||||
apiPost('/api/agent/run', {prompt: text}).then(r=>{
|
||||
apiPost('/api/agent/run', {prompt: text, serial: serial}).then(r=>{
|
||||
if(!r||!r.ok){
|
||||
const msg = r ? (r.error||'启动失败') : '请求失败';
|
||||
const div = newAssistantMsg();
|
||||
|
||||
@@ -467,7 +467,6 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
|
||||
<div class="agent-title">🤖 AI 控制台<span id="agent-model-tag" class="agent-model-tag"></span></div>
|
||||
<div class="agent-top-actions">
|
||||
<span id="agent-running-tag" class="agent-running-tag" style="display:none">● 运行中</span>
|
||||
<button class="btn btn-xs btn-danger" id="btn-agent-stop" style="display:none" onclick="stopAgent()">■ 停止</button>
|
||||
<button class="btn btn-xs" onclick="newAgentSession()" title="清空当前对话并开启新会话">+ 新建会话</button>
|
||||
<button class="btn btn-xs" onclick="openExpLib()" title="查看/管理自进化经验库(每日 AI 巡检建议,删除需确认)">🧠 经验库</button>
|
||||
<button class="btn btn-xs" onclick="openAgentConfig()">⚙ 配置</button>
|
||||
@@ -487,13 +486,25 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
|
||||
onchange="watchDevice(this.value)"><option value="">选择设备观看…</option></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="agent-inputbar">
|
||||
<div class="agent-inputbar" style="align-items:flex-end;flex-direction:column">
|
||||
<div style="display:flex;width:100%;gap:8px;align-items:center;margin-bottom:8px">
|
||||
<label style="font-size:12.5px;color:#374151;font-weight:600;white-space:nowrap">🎯 目标设备</label>
|
||||
<select id="agent-target-select" class="form-control" style="width:320px;font-family:monospace"
|
||||
title="AI 只操作你选定的设备;有任务运行中的设备不可选">
|
||||
<option value="">请选择设备(AI 只操作选定设备)…</option>
|
||||
</select>
|
||||
<span class="text-muted" id="agent-target-hint" style="font-size:11.5px"></span>
|
||||
<span style="flex:1"></span>
|
||||
<button class="btn btn-xs btn-danger" id="btn-agent-stop" style="display:none" onclick="stopAgent()">■ 停止</button>
|
||||
</div>
|
||||
<div style="display:flex;width:100%;gap:10px">
|
||||
<textarea id="agent-input" rows="2"
|
||||
placeholder="给 AI 下达指令,例如:打开抖音搜索奚学东,把第一个视频标题告诉我…(Enter 发送,Shift+Enter 换行)"></textarea>
|
||||
<button class="btn btn-primary" id="btn-agent-send" onclick="sendAgentMsg()">发送</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ========== 配置模态框 ========== -->
|
||||
<div id="agent-cfg-overlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:1300;align-items:center;justify-content:center">
|
||||
|
||||
+65
-3
@@ -498,23 +498,85 @@ def agent_run():
|
||||
return jsonify({"ok": False, "error": "请先在配置区填写 API Key"}), 400
|
||||
if not cfg.get("model"):
|
||||
return jsonify({"ok": False, "error": "请先填写模型名"}), 400
|
||||
if not serial and not (cfg.get("default_serial") or "").strip():
|
||||
return jsonify({"ok": False, "error": "请先选择目标设备(AI 只操作你指定的设备)"}), 400
|
||||
# 目标设备校验:必须在池、在线、且无任务运行(AI 不与任务抢设备;
|
||||
# MCP 层另有 busy 锁兜底外部客户端)
|
||||
if serial:
|
||||
try:
|
||||
from web import context
|
||||
devs, err = context.mgr.get_status()
|
||||
dev = next((x for x in (devs or []) if x.get("serial") == serial), None)
|
||||
if err or dev is None:
|
||||
return jsonify({"ok": False, "error": f"设备 {serial} 不在设备池"}), 400
|
||||
if not dev.get("present"):
|
||||
return jsonify({"ok": False, "error": f"设备 {serial} 当前离线,请稍后再试"}), 400
|
||||
if dev.get("worker_status") in ("running", "connecting"):
|
||||
return jsonify({"ok": False, "error":
|
||||
f"设备 {serial} 正在执行任务「{dev.get('task_job') or ''}」——"
|
||||
f"AI 不与任务抢设备,任务结束后才能操作(或在任务页先停止)"}), 409
|
||||
except Exception:
|
||||
pass # 状态服务异常不阻塞(MCP busy 锁兜底)
|
||||
with _lock:
|
||||
if _run["state"] == "running":
|
||||
return jsonify({"ok": False, "error": "已有 Agent 运行中,请等待完成"}), 409
|
||||
return jsonify({"ok": False, "error":
|
||||
f"已有 Agent 运行中({_run.get('prompt', '')[:40]}…),"
|
||||
f"请等待完成或先停止"}), 409
|
||||
run_id = uuid.uuid4().hex[:8]
|
||||
from datetime import datetime as _dt
|
||||
_run.update(id=run_id, state="running", prompt=prompt,
|
||||
serial=(data.get("serial") or "").strip(),
|
||||
serial=serial,
|
||||
started=_dt.now().strftime("%H:%M:%S"),
|
||||
answer="", error="")
|
||||
# history 保留(同会话多轮对话),由前端「清空对话」调用 clear 重置
|
||||
_queues[run_id] = queue.Queue()
|
||||
_stop_events[run_id] = threading.Event()
|
||||
_log.info(f"Agent 启动: {prompt[:60]}")
|
||||
_log.info(f"Agent 启动: {prompt[:60]} @ {serial or 'default'}")
|
||||
threading.Thread(target=_agent_thread,
|
||||
args=(run_id, prompt, serial, cfg),
|
||||
daemon=True).start()
|
||||
return jsonify({"ok": True, "run_id": run_id})
|
||||
|
||||
|
||||
@bp.route("/api/agent/run", methods=["GET"])
|
||||
@admin_required
|
||||
def agent_run_status():
|
||||
"""当前 Agent 运行状态(多窗口/多人可见):idle / running + 任务摘要。
|
||||
|
||||
前端轮询它同步「运行中」状态(换浏览器/他人启动的任务也能看到并停止)。
|
||||
"""
|
||||
with _lock:
|
||||
return jsonify({"ok": True,
|
||||
"state": _run.get("state", "idle"),
|
||||
"prompt": _run.get("prompt", ""),
|
||||
"serial": _run.get("serial", ""),
|
||||
"started": _run.get("started", "")})
|
||||
|
||||
|
||||
@bp.route("/api/agent/devices")
|
||||
@admin_required
|
||||
def agent_devices():
|
||||
"""AI 可用设备列表:在线状态 + 是否有任务运行(前端选择器 busy 设备禁选)。
|
||||
|
||||
worker 状态实时(内存);busy = 任务 running/connecting。
|
||||
"""
|
||||
try:
|
||||
from web import context
|
||||
devs, err = context.mgr.get_status()
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "error": str(e)[:120]}), 503
|
||||
out = []
|
||||
for d in (devs or []):
|
||||
busy = d.get("worker_status") in ("running", "connecting")
|
||||
out.append({"serial": d.get("serial"),
|
||||
"model": d.get("model") or "",
|
||||
"online": bool(d.get("present")),
|
||||
"busy": busy,
|
||||
"worker_status": d.get("worker_status") or "idle",
|
||||
"task_job": d.get("task_job") or ""})
|
||||
return jsonify({"ok": True, "devices": out})
|
||||
|
||||
|
||||
@bp.route("/api/agent/stream")
|
||||
@admin_required
|
||||
def agent_stream():
|
||||
|
||||
Reference in New Issue
Block a user