diff --git a/mcp_agent/agent.py b/mcp_agent/agent.py index 15bca89..ec21534 100644 --- a/mcp_agent/agent.py +++ b/mcp_agent/agent.py @@ -134,13 +134,15 @@ class Agent: # ---------- 主循环(流式) ---------- async def run_stream(self, prompt: str, serial: str = "", - history=None, on_delta=None, on_tool=None): + history=None, on_delta=None, on_tool=None, + should_stop=None): """流式执行一轮指令,返回最终完整文本。 history:上一轮的 [{"role": "user"|"assistant", "content": 文本}] 列表, 用于多轮对话保持上下文(截图/工具消息不入历史,控制 token)。 on_delta(text, kind):content/reasoning 文本增量(实时推给前端) on_tool(step):工具调用完成(实时显示 MCP 步骤) + should_stop:可调用 fn() -> bool,每轮模型调用前检查(用户中断用) """ self.on_delta = on_delta self.on_tool = on_tool @@ -155,6 +157,9 @@ class Agent: self.messages.append({"role": "user", "content": prompt}) for _step in range(self.s.max_steps): + if should_stop and should_stop(): + _log.info("Agent 被用户中断") + return "(已按用户要求停止操作)" content_parts = [] tool_acc = {} # index -> {id, name, args} has_tool = False diff --git a/mcp_server/mcp_server.py b/mcp_server/mcp_server.py index 630fd24..74e0dcc 100644 --- a/mcp_server/mcp_server.py +++ b/mcp_server/mcp_server.py @@ -228,6 +228,23 @@ def de_tap_element(serial: str, by: str, value: str, index: int = 1) -> dict: "by": by, "value": value, "index": index}) +@mcp.tool() +def de_read_clipboard(serial: str) -> dict: + """读取设备当前剪贴板内容(ClipInject/atx-agent 通道读回,M1 起支持)。""" + try: + serial = _check_serial(serial) + import uiautomator2 as u2 + d = u2.connect(serial) + text = d.clipboard + except PlatformError as e: + return _err(e) + except Exception as e: + return _err(PlatformError("platform_unavailable", + f"剪贴板读取失败: {type(e).__name__}: {str(e)[:100]}")) + audit.audit("de_read_clipboard", serial, "", f"{len(text or '')} 字符") + return _ok({"clipboard": text or ""}) + + @mcp.tool() def de_wake(serial: str) -> dict: """点亮设备屏幕并解锁(熄屏时先调用它再截图)。""" diff --git a/static/admin/agent.js b/static/admin/agent.js index 2683e7b..2a23ef0 100644 --- a/static/admin/agent.js +++ b/static/admin/agent.js @@ -14,6 +14,8 @@ function initAgentChat(){ + '例如:「打开抖音搜索奚学东,告诉我第一个视频的标题」
' + '点击右上角 ⚙ 配置模型与 API Key。'; } + bindChatScroll(); + loadLiveDevices(); // 输入框快捷键 const inp = document.getElementById('agent-input'); inp.addEventListener('keydown', ev=>{ @@ -88,9 +90,18 @@ function clearEmpty(){ const empty = document.querySelector('#agent-chat .agent-empty'); if(empty) empty.remove(); } -function scrollChat(){ +let _chatPinned = true; // 用户是否在底部(贴底才自动跟随滚动) +function scrollChat(force){ const chat = document.getElementById('agent-chat'); - chat.scrollTop = chat.scrollHeight; + if(force || _chatPinned){ + chat.scrollTop = chat.scrollHeight; + } +} +function bindChatScroll(){ + const chat = document.getElementById('agent-chat'); + chat.addEventListener('scroll', ()=>{ + _chatPinned = chat.scrollHeight - chat.scrollTop - chat.clientHeight < 60; + }); } function clearAgentChat(){ document.getElementById('agent-chat').innerHTML = @@ -109,6 +120,18 @@ function newAgentSession(){ } // ================== 发送与流式接收 ================== +function setRunning(on){ + _agentBusy = on; + document.getElementById('agent-running-tag').style.display = on ? 'inline' : 'none'; + document.getElementById('btn-agent-stop').style.display = on ? 'inline-block' : 'none'; + document.getElementById('btn-agent-send').disabled = on; +} +function stopAgent(){ + apiPost('/api/agent/stop',{}).then(r=>{ + if(r&&r.ok) showToast('已请求停止,正在中断…','success'); + else showToast((r&&r.error)||'停止失败','error'); + }); +} function sendAgentMsg(){ if(_agentBusy){showToast('上一轮还在运行','error');return;} const inp = document.getElementById('agent-input'); @@ -125,9 +148,7 @@ function sendAgentMsg(){ showToast(msg,'error'); return; } - _agentBusy = true; - document.getElementById('agent-running-tag').style.display = 'inline'; - document.getElementById('btn-agent-send').disabled = true; + setRunning(true); listenStream(r.run_id); }); } @@ -158,6 +179,7 @@ function listenStream(runId){ es.addEventListener('step', ev=>{ const d = JSON.parse(ev.data); + followSerialFromArgs(d.args); if(!msgEl) msgEl = newAssistantMsg(); const cards = msgEl.querySelector('.agent-toolcards'); const card = document.createElement('div'); @@ -199,6 +221,49 @@ function listenStream(runId){ }; } +// ============ 实时画面(右侧,MJPEG 流) ============ +let _liveSerial = ''; +function showLive(serial){ + if(!serial || serial === _liveSerial) return; + _liveSerial = serial; + const img = document.getElementById('agent-live-img'); + img.src = '/api/screen/stream?serial=' + encodeURIComponent(serial) + '&q=80&fps=8&t=' + Date.now(); + img.style.display = 'block'; + document.getElementById('agent-live-empty').style.display = 'none'; + document.getElementById('agent-live-serial').textContent = serial; + const sel = document.getElementById('agent-live-select'); + if(sel) sel.value = serial; +} +function stopLive(){ + _liveSerial = ''; + const img = document.getElementById('agent-live-img'); + img.src = ''; + img.style.display = 'none'; + document.getElementById('agent-live-empty').style.display = 'block'; + document.getElementById('agent-live-serial').textContent = ''; +} +function followSerialFromArgs(argsStr){ + try{ + const a = JSON.parse(argsStr || '{}'); + if(a.serial) showLive(a.serial); + }catch(e){} +} +function watchDevice(serial){ + if(!serial){ stopLive(); return; } + showLive(serial); +} +function loadLiveDevices(){ + apiGet('/api/devices').then(r=>{ + if(!r||!r.ok)return; + const sel = document.getElementById('agent-live-select'); + if(!sel)return; + const cur = sel.value; + const devs = (r.devices||[]).filter(x=>x.indexOf(':')>=0); + sel.innerHTML = '' + + devs.map(s=>'').join(''); + }); +} + // 截图点击放大查看 function zoomScreenshot(img){ const ov = document.getElementById('agent-zoom'); @@ -226,10 +291,8 @@ document.addEventListener('click', ev=>{ }); function endRun(){ - _agentBusy = false; + setRunning(false); _agentStream && _agentStream.close(); _agentStream = null; - document.getElementById('agent-running-tag').style.display = 'none'; - document.getElementById('btn-agent-send').disabled = false; scrollChat(); } diff --git a/templates/admin/monitor.html b/templates/admin/monitor.html index 492d92c..368980e 100644 --- a/templates/admin/monitor.html +++ b/templates/admin/monitor.html @@ -305,6 +305,15 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14 .el-picker-item .ep-sel{font-size:10px;color:#fff;background:var(--primary);padding:1px 7px;border-radius:99px;flex-shrink:0;font-weight:600} /* ===== AI 控制台(聊天界面,浅色内容区——DeepSeek 风格,图片文字清晰) ===== */ +.agent-body{display:flex;flex:1;min-height:0} +.agent-col-chat{flex:1;display:flex;flex-direction:column;min-width:0} +.agent-col-live{width:300px;border-left:1px solid #e5e7eb;background:#fff;display:flex;flex-direction:column;padding:10px} +.agent-live-head{font-size:12.5px;font-weight:600;color:#374151;display:flex;align-items:center;justify-content:space-between;margin-bottom:8px} +.agent-live-serial{font-family:var(--mono);font-size:10.5px;color:#6b7280} +.agent-live-body{flex:1;background:#0b0f14;border-radius:10px;display:flex;align-items:center;justify-content:center;overflow:hidden;min-height:200px} +.agent-live-body img{width:100%;height:100%;object-fit:contain} +.agent-live-body .agent-empty{color:#4b5563;padding:20px;font-size:12px;line-height:1.8} +.agent-shell{display:flex;flex-direction:column;height:calc(100vh - 150px);min-height:520px;border:1px solid #e5e7eb;border-radius:14px;overflow:hidden;background:#fff} .agent-shell{display:flex;flex-direction:column;height:calc(100vh - 150px);min-height:480px;border:1px solid #e5e7eb;border-radius:14px;overflow:hidden;background:#fff} .agent-topbar{display:flex;align-items:center;justify-content:space-between;padding:10px 16px;border-bottom:1px solid #e5e7eb;background:#f9fafb} .agent-title{font-size:14px;font-weight:700;color:#111827} @@ -458,11 +467,25 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
🤖 AI 控制台
+
-
+
+
+
+
+
+
📺 实时画面
+
+ +
运行任务时自动跟随操作的设备;
也可手动选择设备观看
+
+ +
+
diff --git a/web/agent_api.py b/web/agent_api.py index d1e1759..5a7cf8e 100644 --- a/web/agent_api.py +++ b/web/agent_api.py @@ -41,6 +41,7 @@ _run = {"id": None, "state": "idle", "prompt": "", "serial": "", "answer": "", "error": "", "history": []} # 多轮对话历史 [{role: user|assistant, content}] _queues = {} # run_id -> queue.Queue(SSE 消费者读取) +_stop_events = {} # run_id -> threading.Event(用户中断) _lock = threading.Lock() @@ -107,6 +108,7 @@ def agent_run(): answer="", error="") # history 保留(同会话多轮对话),由前端「清空对话」调用 clear 重置 _queues[run_id] = queue.Queue() + _stop_events[run_id] = threading.Event() _log.info(f"Agent 启动: {prompt[:60]}") threading.Thread(target=_agent_thread, args=(run_id, prompt, serial, cfg), @@ -143,6 +145,20 @@ def agent_stream(): "X-Accel-Buffering": "no"}) +@bp.route("/api/agent/stop", methods=["POST"]) +@admin_required +def agent_stop(): + """中断当前运行的 Agent(下一个检查点生效,通常在数秒内)。""" + with _lock: + if _run["state"] != "running": + return jsonify({"ok": False, "error": "当前没有运行中的任务"}), 400 + evt = _stop_events.get(_run["id"]) + if evt: + evt.set() + _log.info("用户请求中断 Agent") + return jsonify({"ok": True, "msg": "已请求停止"}) + + @bp.route("/api/agent/clear", methods=["POST"]) @admin_required def agent_clear(): @@ -193,12 +209,15 @@ def _agent_thread(run_id, prompt, serial, cfg): with _lock: history = list(_run.get("history") or []) target = serial or cfg.get("default_serial") or "" + stop_evt = _stop_events.get(run_id) async def _execute(): await agent._load_tools() return await agent.run_stream(prompt, target, history=history, - on_delta=on_delta, on_tool=on_tool) + on_delta=on_delta, on_tool=on_tool, + should_stop=lambda: bool( + stop_evt and stop_evt.is_set())) # 整体超时保护:卡死时结束,释放单实例 answer = asyncio.run(asyncio.wait_for(_execute(), timeout=900)) @@ -220,3 +239,4 @@ def _agent_thread(run_id, prompt, serial, cfg): finally: q.put(None) # 关闭 SSE _queues.pop(run_id, None) + _stop_events.pop(run_id, None)