feat: AI 控制台增强——①de_read_clipboard 剪贴板回读工具;②任务可中断(stop_event 循环检查点 + API stop + 前端■停止按钮);③智能滚动(回看历史不被打断,贴底才跟随);④右侧实时画面(MJPEG 流自动跟随操作设备,可手动选择)
This commit is contained in:
+6
-1
@@ -134,13 +134,15 @@ class Agent:
|
|||||||
|
|
||||||
# ---------- 主循环(流式) ----------
|
# ---------- 主循环(流式) ----------
|
||||||
async def run_stream(self, prompt: str, serial: str = "",
|
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": 文本}] 列表,
|
history:上一轮的 [{"role": "user"|"assistant", "content": 文本}] 列表,
|
||||||
用于多轮对话保持上下文(截图/工具消息不入历史,控制 token)。
|
用于多轮对话保持上下文(截图/工具消息不入历史,控制 token)。
|
||||||
on_delta(text, kind):content/reasoning 文本增量(实时推给前端)
|
on_delta(text, kind):content/reasoning 文本增量(实时推给前端)
|
||||||
on_tool(step):工具调用完成(实时显示 MCP 步骤)
|
on_tool(step):工具调用完成(实时显示 MCP 步骤)
|
||||||
|
should_stop:可调用 fn() -> bool,每轮模型调用前检查(用户中断用)
|
||||||
"""
|
"""
|
||||||
self.on_delta = on_delta
|
self.on_delta = on_delta
|
||||||
self.on_tool = on_tool
|
self.on_tool = on_tool
|
||||||
@@ -155,6 +157,9 @@ class Agent:
|
|||||||
self.messages.append({"role": "user", "content": prompt})
|
self.messages.append({"role": "user", "content": prompt})
|
||||||
|
|
||||||
for _step in range(self.s.max_steps):
|
for _step in range(self.s.max_steps):
|
||||||
|
if should_stop and should_stop():
|
||||||
|
_log.info("Agent 被用户中断")
|
||||||
|
return "(已按用户要求停止操作)"
|
||||||
content_parts = []
|
content_parts = []
|
||||||
tool_acc = {} # index -> {id, name, args}
|
tool_acc = {} # index -> {id, name, args}
|
||||||
has_tool = False
|
has_tool = False
|
||||||
|
|||||||
@@ -228,6 +228,23 @@ def de_tap_element(serial: str, by: str, value: str, index: int = 1) -> dict:
|
|||||||
"by": by, "value": value, "index": index})
|
"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()
|
@mcp.tool()
|
||||||
def de_wake(serial: str) -> dict:
|
def de_wake(serial: str) -> dict:
|
||||||
"""点亮设备屏幕并解锁(熄屏时先调用它再截图)。"""
|
"""点亮设备屏幕并解锁(熄屏时先调用它再截图)。"""
|
||||||
|
|||||||
+71
-8
@@ -14,6 +14,8 @@ function initAgentChat(){
|
|||||||
+ '例如:「打开抖音搜索奚学东,告诉我第一个视频的标题」<br>'
|
+ '例如:「打开抖音搜索奚学东,告诉我第一个视频的标题」<br>'
|
||||||
+ '点击右上角 ⚙ 配置模型与 API Key。</div>';
|
+ '点击右上角 ⚙ 配置模型与 API Key。</div>';
|
||||||
}
|
}
|
||||||
|
bindChatScroll();
|
||||||
|
loadLiveDevices();
|
||||||
// 输入框快捷键
|
// 输入框快捷键
|
||||||
const inp = document.getElementById('agent-input');
|
const inp = document.getElementById('agent-input');
|
||||||
inp.addEventListener('keydown', ev=>{
|
inp.addEventListener('keydown', ev=>{
|
||||||
@@ -88,9 +90,18 @@ function clearEmpty(){
|
|||||||
const empty = document.querySelector('#agent-chat .agent-empty');
|
const empty = document.querySelector('#agent-chat .agent-empty');
|
||||||
if(empty) empty.remove();
|
if(empty) empty.remove();
|
||||||
}
|
}
|
||||||
function scrollChat(){
|
let _chatPinned = true; // 用户是否在底部(贴底才自动跟随滚动)
|
||||||
|
function scrollChat(force){
|
||||||
const chat = document.getElementById('agent-chat');
|
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(){
|
function clearAgentChat(){
|
||||||
document.getElementById('agent-chat').innerHTML =
|
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(){
|
function sendAgentMsg(){
|
||||||
if(_agentBusy){showToast('上一轮还在运行','error');return;}
|
if(_agentBusy){showToast('上一轮还在运行','error');return;}
|
||||||
const inp = document.getElementById('agent-input');
|
const inp = document.getElementById('agent-input');
|
||||||
@@ -125,9 +148,7 @@ function sendAgentMsg(){
|
|||||||
showToast(msg,'error');
|
showToast(msg,'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_agentBusy = true;
|
setRunning(true);
|
||||||
document.getElementById('agent-running-tag').style.display = 'inline';
|
|
||||||
document.getElementById('btn-agent-send').disabled = true;
|
|
||||||
listenStream(r.run_id);
|
listenStream(r.run_id);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -158,6 +179,7 @@ function listenStream(runId){
|
|||||||
|
|
||||||
es.addEventListener('step', ev=>{
|
es.addEventListener('step', ev=>{
|
||||||
const d = JSON.parse(ev.data);
|
const d = JSON.parse(ev.data);
|
||||||
|
followSerialFromArgs(d.args);
|
||||||
if(!msgEl) msgEl = newAssistantMsg();
|
if(!msgEl) msgEl = newAssistantMsg();
|
||||||
const cards = msgEl.querySelector('.agent-toolcards');
|
const cards = msgEl.querySelector('.agent-toolcards');
|
||||||
const card = document.createElement('div');
|
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 = '<option value="">选择设备观看…</option>'
|
||||||
|
+ devs.map(s=>'<option value="'+esc(s)+'"'+(s===cur?' selected':'')+'>'+esc(s)+'</option>').join('');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 截图点击放大查看
|
// 截图点击放大查看
|
||||||
function zoomScreenshot(img){
|
function zoomScreenshot(img){
|
||||||
const ov = document.getElementById('agent-zoom');
|
const ov = document.getElementById('agent-zoom');
|
||||||
@@ -226,10 +291,8 @@ document.addEventListener('click', ev=>{
|
|||||||
});
|
});
|
||||||
|
|
||||||
function endRun(){
|
function endRun(){
|
||||||
_agentBusy = false;
|
setRunning(false);
|
||||||
_agentStream && _agentStream.close();
|
_agentStream && _agentStream.close();
|
||||||
_agentStream = null;
|
_agentStream = null;
|
||||||
document.getElementById('agent-running-tag').style.display = 'none';
|
|
||||||
document.getElementById('btn-agent-send').disabled = false;
|
|
||||||
scrollChat();
|
scrollChat();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
.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 风格,图片文字清晰) ===== */
|
/* ===== 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-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-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}
|
.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
|
|||||||
<div class="agent-title">🤖 AI 控制台<span id="agent-model-tag" class="agent-model-tag"></span></div>
|
<div class="agent-title">🤖 AI 控制台<span id="agent-model-tag" class="agent-model-tag"></span></div>
|
||||||
<div class="agent-top-actions">
|
<div class="agent-top-actions">
|
||||||
<span id="agent-running-tag" class="agent-running-tag" style="display:none">● 运行中</span>
|
<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="newAgentSession()" title="清空当前对话并开启新会话">+ 新建会话</button>
|
||||||
<button class="btn btn-xs" onclick="openAgentConfig()">⚙ 配置</button>
|
<button class="btn btn-xs" onclick="openAgentConfig()">⚙ 配置</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="agent-chat" class="agent-chat"></div>
|
<div class="agent-body">
|
||||||
|
<div class="agent-col-chat">
|
||||||
|
<div id="agent-chat" class="agent-chat"></div>
|
||||||
|
</div>
|
||||||
|
<div class="agent-col-live">
|
||||||
|
<div class="agent-live-head">📺 实时画面<span id="agent-live-serial" class="agent-live-serial"></span></div>
|
||||||
|
<div class="agent-live-body" id="agent-live-body">
|
||||||
|
<img id="agent-live-img" alt="" style="display:none;width:100%">
|
||||||
|
<div class="agent-empty" id="agent-live-empty">运行任务时自动跟随操作的设备;<br>也可手动选择设备观看</div>
|
||||||
|
</div>
|
||||||
|
<select id="agent-live-select" class="form-control" style="margin-top:8px"
|
||||||
|
onchange="watchDevice(this.value)"><option value="">选择设备观看…</option></select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="agent-inputbar">
|
<div class="agent-inputbar">
|
||||||
<textarea id="agent-input" rows="2"
|
<textarea id="agent-input" rows="2"
|
||||||
placeholder="给 AI 下达指令,例如:打开抖音搜索奚学东,把第一个视频标题告诉我…(Enter 发送,Shift+Enter 换行)"></textarea>
|
placeholder="给 AI 下达指令,例如:打开抖音搜索奚学东,把第一个视频标题告诉我…(Enter 发送,Shift+Enter 换行)"></textarea>
|
||||||
|
|||||||
+21
-1
@@ -41,6 +41,7 @@ _run = {"id": None, "state": "idle", "prompt": "", "serial": "",
|
|||||||
"answer": "", "error": "",
|
"answer": "", "error": "",
|
||||||
"history": []} # 多轮对话历史 [{role: user|assistant, content}]
|
"history": []} # 多轮对话历史 [{role: user|assistant, content}]
|
||||||
_queues = {} # run_id -> queue.Queue(SSE 消费者读取)
|
_queues = {} # run_id -> queue.Queue(SSE 消费者读取)
|
||||||
|
_stop_events = {} # run_id -> threading.Event(用户中断)
|
||||||
_lock = threading.Lock()
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
@@ -107,6 +108,7 @@ def agent_run():
|
|||||||
answer="", error="")
|
answer="", error="")
|
||||||
# history 保留(同会话多轮对话),由前端「清空对话」调用 clear 重置
|
# history 保留(同会话多轮对话),由前端「清空对话」调用 clear 重置
|
||||||
_queues[run_id] = queue.Queue()
|
_queues[run_id] = queue.Queue()
|
||||||
|
_stop_events[run_id] = threading.Event()
|
||||||
_log.info(f"Agent 启动: {prompt[:60]}")
|
_log.info(f"Agent 启动: {prompt[:60]}")
|
||||||
threading.Thread(target=_agent_thread,
|
threading.Thread(target=_agent_thread,
|
||||||
args=(run_id, prompt, serial, cfg),
|
args=(run_id, prompt, serial, cfg),
|
||||||
@@ -143,6 +145,20 @@ def agent_stream():
|
|||||||
"X-Accel-Buffering": "no"})
|
"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"])
|
@bp.route("/api/agent/clear", methods=["POST"])
|
||||||
@admin_required
|
@admin_required
|
||||||
def agent_clear():
|
def agent_clear():
|
||||||
@@ -193,12 +209,15 @@ def _agent_thread(run_id, prompt, serial, cfg):
|
|||||||
with _lock:
|
with _lock:
|
||||||
history = list(_run.get("history") or [])
|
history = list(_run.get("history") or [])
|
||||||
target = serial or cfg.get("default_serial") or ""
|
target = serial or cfg.get("default_serial") or ""
|
||||||
|
stop_evt = _stop_events.get(run_id)
|
||||||
|
|
||||||
async def _execute():
|
async def _execute():
|
||||||
await agent._load_tools()
|
await agent._load_tools()
|
||||||
return await agent.run_stream(prompt, target,
|
return await agent.run_stream(prompt, target,
|
||||||
history=history,
|
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))
|
answer = asyncio.run(asyncio.wait_for(_execute(), timeout=900))
|
||||||
@@ -220,3 +239,4 @@ def _agent_thread(run_id, prompt, serial, cfg):
|
|||||||
finally:
|
finally:
|
||||||
q.put(None) # 关闭 SSE
|
q.put(None) # 关闭 SSE
|
||||||
_queues.pop(run_id, None)
|
_queues.pop(run_id, None)
|
||||||
|
_stop_events.pop(run_id, None)
|
||||||
|
|||||||
Reference in New Issue
Block a user