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 开启)")
|
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):
|
def _to_native(serial, x, y):
|
||||||
"""截图坐标 → 设备原生坐标(按最近一次截图的比例换算)。"""
|
"""截图坐标 → 设备原生坐标(按最近一次截图的比例换算)。"""
|
||||||
c = _coord.get(serial)
|
c = _coord.get(serial)
|
||||||
@@ -138,6 +164,7 @@ def de_tap(serial: str, x: int, y: int) -> dict:
|
|||||||
try:
|
try:
|
||||||
_check_write()
|
_check_write()
|
||||||
serial = _check_serial(serial)
|
serial = _check_serial(serial)
|
||||||
|
_ensure_device_free(serial)
|
||||||
if x < 0 or y < 0:
|
if x < 0 or y < 0:
|
||||||
raise PlatformError("invalid_param", "坐标不能为负")
|
raise PlatformError("invalid_param", "坐标不能为负")
|
||||||
nx, ny = _to_native(serial, x, y)
|
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:
|
try:
|
||||||
_check_write()
|
_check_write()
|
||||||
serial = _check_serial(serial)
|
serial = _check_serial(serial)
|
||||||
|
_ensure_device_free(serial)
|
||||||
nx1, ny1 = _to_native(serial, x1, y1)
|
nx1, ny1 = _to_native(serial, x1, y1)
|
||||||
nx2, ny2 = _to_native(serial, x2, y2)
|
nx2, ny2 = _to_native(serial, x2, y2)
|
||||||
platform().swipe(serial, nx1, ny1, nx2, ny2, duration)
|
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:
|
try:
|
||||||
_check_write()
|
_check_write()
|
||||||
serial = _check_serial(serial)
|
serial = _check_serial(serial)
|
||||||
|
_ensure_device_free(serial)
|
||||||
if by not in ("text", "id", "desc", "text_contains", "desc_contains"):
|
if by not in ("text", "id", "desc", "text_contains", "desc_contains"):
|
||||||
raise PlatformError("invalid_param",
|
raise PlatformError("invalid_param",
|
||||||
"by 可选 text/id/desc/text_contains/desc_contains")
|
"by 可选 text/id/desc/text_contains/desc_contains")
|
||||||
@@ -270,6 +299,7 @@ def de_wake(serial: str) -> dict:
|
|||||||
try:
|
try:
|
||||||
_check_write()
|
_check_write()
|
||||||
serial = _check_serial(serial)
|
serial = _check_serial(serial)
|
||||||
|
_ensure_device_free(serial)
|
||||||
platform().wake(serial)
|
platform().wake(serial)
|
||||||
except PlatformError as e:
|
except PlatformError as e:
|
||||||
return _err(e)
|
return _err(e)
|
||||||
@@ -283,6 +313,7 @@ def de_press_key(serial: str, key: str) -> dict:
|
|||||||
try:
|
try:
|
||||||
_check_write()
|
_check_write()
|
||||||
serial = _check_serial(serial)
|
serial = _check_serial(serial)
|
||||||
|
_ensure_device_free(serial)
|
||||||
if key not in _KEYS:
|
if key not in _KEYS:
|
||||||
raise PlatformError("invalid_param", f"不支持的按键: {key}(可选 {_KEYS})")
|
raise PlatformError("invalid_param", f"不支持的按键: {key}(可选 {_KEYS})")
|
||||||
platform().press_key(serial, key)
|
platform().press_key(serial, key)
|
||||||
@@ -299,6 +330,7 @@ def de_open_app(serial: str, package: str) -> dict:
|
|||||||
try:
|
try:
|
||||||
_check_write()
|
_check_write()
|
||||||
serial = _check_serial(serial)
|
serial = _check_serial(serial)
|
||||||
|
_ensure_device_free(serial)
|
||||||
if not package:
|
if not package:
|
||||||
raise PlatformError("invalid_param", "缺少包名")
|
raise PlatformError("invalid_param", "缺少包名")
|
||||||
from mcp_server import direct_ops
|
from mcp_server import direct_ops
|
||||||
@@ -317,6 +349,7 @@ def de_stop_app(serial: str, package: str) -> dict:
|
|||||||
try:
|
try:
|
||||||
_check_write()
|
_check_write()
|
||||||
serial = _check_serial(serial)
|
serial = _check_serial(serial)
|
||||||
|
_ensure_device_free(serial)
|
||||||
if not package:
|
if not package:
|
||||||
raise PlatformError("invalid_param", "缺少包名")
|
raise PlatformError("invalid_param", "缺少包名")
|
||||||
from mcp_server import direct_ops
|
from mcp_server import direct_ops
|
||||||
@@ -350,6 +383,7 @@ def de_type_text(serial: str, text: str) -> dict:
|
|||||||
try:
|
try:
|
||||||
_check_write()
|
_check_write()
|
||||||
serial = _check_serial(serial)
|
serial = _check_serial(serial)
|
||||||
|
_ensure_device_free(serial)
|
||||||
if not text:
|
if not text:
|
||||||
raise PlatformError("invalid_param", "内容为空")
|
raise PlatformError("invalid_param", "内容为空")
|
||||||
from mcp_server import direct_ops
|
from mcp_server import direct_ops
|
||||||
@@ -370,6 +404,7 @@ def de_set_clipboard(serial: str, text: str) -> dict:
|
|||||||
try:
|
try:
|
||||||
_check_write()
|
_check_write()
|
||||||
serial = _check_serial(serial)
|
serial = _check_serial(serial)
|
||||||
|
_ensure_device_free(serial)
|
||||||
if not text:
|
if not text:
|
||||||
raise PlatformError("invalid_param", "内容为空")
|
raise PlatformError("invalid_param", "内容为空")
|
||||||
from mcp_server import direct_ops
|
from mcp_server import direct_ops
|
||||||
@@ -390,6 +425,7 @@ def de_sleep(serial: str) -> dict:
|
|||||||
try:
|
try:
|
||||||
_check_write()
|
_check_write()
|
||||||
serial = _check_serial(serial)
|
serial = _check_serial(serial)
|
||||||
|
_ensure_device_free(serial)
|
||||||
platform().sleep(serial)
|
platform().sleep(serial)
|
||||||
except PlatformError as e:
|
except PlatformError as e:
|
||||||
return _err(e)
|
return _err(e)
|
||||||
@@ -429,6 +465,7 @@ def de_tap_text(serial: str, text: str) -> dict:
|
|||||||
try:
|
try:
|
||||||
_check_write()
|
_check_write()
|
||||||
serial = _check_serial(serial)
|
serial = _check_serial(serial)
|
||||||
|
_ensure_device_free(serial)
|
||||||
if not text or len(text) > 100:
|
if not text or len(text) > 100:
|
||||||
raise PlatformError("invalid_param", "text 不能为空且 ≤100 字符")
|
raise PlatformError("invalid_param", "text 不能为空且 ≤100 字符")
|
||||||
res = platform().tap_text(serial, text)
|
res = platform().tap_text(serial, text)
|
||||||
|
|||||||
+66
-2
@@ -4,6 +4,7 @@ let _agentStream = null; // EventSource
|
|||||||
let _agentCfgLoaded = false;
|
let _agentCfgLoaded = false;
|
||||||
|
|
||||||
// ================== 初始化 ==================
|
// ================== 初始化 ==================
|
||||||
|
let _agentDefaultSerial = '';
|
||||||
function initAgentChat(){
|
function initAgentChat(){
|
||||||
if(_agentCfgLoaded)return;
|
if(_agentCfgLoaded)return;
|
||||||
_agentCfgLoaded = true;
|
_agentCfgLoaded = true;
|
||||||
@@ -12,10 +13,12 @@ function initAgentChat(){
|
|||||||
if(chat && !chat.children.length){
|
if(chat && !chat.children.length){
|
||||||
chat.innerHTML = '<div class="agent-empty">👋 给 AI 下达指令,它将通过截图观察手机并执行操作。<br>'
|
chat.innerHTML = '<div class="agent-empty">👋 给 AI 下达指令,它将通过截图观察手机并执行操作。<br>'
|
||||||
+ '例如:「打开抖音搜索奚学东,告诉我第一个视频的标题」<br>'
|
+ '例如:「打开抖音搜索奚学东,告诉我第一个视频的标题」<br>'
|
||||||
+ '点击右上角 ⚙ 配置模型与 API Key。</div>';
|
+ '先选「🎯 目标设备」(AI 只操作你选定的设备),点击右上角 ⚙ 配置模型与 API Key。</div>';
|
||||||
}
|
}
|
||||||
bindChatScroll();
|
bindChatScroll();
|
||||||
loadLiveDevices();
|
loadLiveDevices();
|
||||||
|
loadAgentTargetDevices();
|
||||||
|
startRunPoll();
|
||||||
// 输入框快捷键
|
// 输入框快捷键
|
||||||
const inp = document.getElementById('agent-input');
|
const inp = document.getElementById('agent-input');
|
||||||
inp.addEventListener('keydown', ev=>{
|
inp.addEventListener('keydown', ev=>{
|
||||||
@@ -32,6 +35,58 @@ function loadAgentConfig(){
|
|||||||
if(!r||!r.ok)return;
|
if(!r||!r.ok)return;
|
||||||
const tag = document.getElementById('agent-model-tag');
|
const tag = document.getElementById('agent-model-tag');
|
||||||
if(tag && r.model) tag.textContent = r.model;
|
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(){
|
function openAgentConfig(){
|
||||||
@@ -64,6 +119,7 @@ function saveAgentConfig(){
|
|||||||
showToast('配置已保存','success');
|
showToast('配置已保存','success');
|
||||||
closeAgentConfig();
|
closeAgentConfig();
|
||||||
loadAgentConfig();
|
loadAgentConfig();
|
||||||
|
loadAgentTargetDevices();
|
||||||
}else showToast('保存失败: ' + ((r&&r.error)||''),'error');
|
}else showToast('保存失败: ' + ((r&&r.error)||''),'error');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -129,6 +185,7 @@ function setRunning(on){
|
|||||||
document.getElementById('btn-agent-send').disabled = on;
|
document.getElementById('btn-agent-send').disabled = on;
|
||||||
}
|
}
|
||||||
function stopAgent(){
|
function stopAgent(){
|
||||||
|
if(!confirm('确定停止当前 AI 运行任务?\n(其他人发起的任务也会被停止)')) return;
|
||||||
apiPost('/api/agent/stop',{}).then(r=>{
|
apiPost('/api/agent/stop',{}).then(r=>{
|
||||||
if(r&&r.ok) showToast('已请求停止,正在中断…','success');
|
if(r&&r.ok) showToast('已请求停止,正在中断…','success');
|
||||||
else showToast((r&&r.error)||'停止失败','error');
|
else showToast((r&&r.error)||'停止失败','error');
|
||||||
@@ -136,13 +193,20 @@ function stopAgent(){
|
|||||||
}
|
}
|
||||||
function sendAgentMsg(){
|
function sendAgentMsg(){
|
||||||
if(_agentBusy){showToast('上一轮还在运行','error');return;}
|
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 inp = document.getElementById('agent-input');
|
||||||
const text = inp.value.trim();
|
const text = inp.value.trim();
|
||||||
if(!text){showToast('请输入指令','error');return;}
|
if(!text){showToast('请输入指令','error');return;}
|
||||||
inp.value = '';
|
inp.value = '';
|
||||||
addUserMsg(text);
|
addUserMsg(text);
|
||||||
|
|
||||||
apiPost('/api/agent/run', {prompt: text}).then(r=>{
|
apiPost('/api/agent/run', {prompt: text, serial: serial}).then(r=>{
|
||||||
if(!r||!r.ok){
|
if(!r||!r.ok){
|
||||||
const msg = r ? (r.error||'启动失败') : '请求失败';
|
const msg = r ? (r.error||'启动失败') : '请求失败';
|
||||||
const div = newAssistantMsg();
|
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-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="openExpLib()" title="查看/管理自进化经验库(每日 AI 巡检建议,删除需确认)">🧠 经验库</button>
|
<button class="btn btn-xs" onclick="openExpLib()" title="查看/管理自进化经验库(每日 AI 巡检建议,删除需确认)">🧠 经验库</button>
|
||||||
<button class="btn btn-xs" onclick="openAgentConfig()">⚙ 配置</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>
|
onchange="watchDevice(this.value)"><option value="">选择设备观看…</option></select>
|
||||||
</div>
|
</div>
|
||||||
</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"
|
<textarea id="agent-input" rows="2"
|
||||||
placeholder="给 AI 下达指令,例如:打开抖音搜索奚学东,把第一个视频标题告诉我…(Enter 发送,Shift+Enter 换行)"></textarea>
|
placeholder="给 AI 下达指令,例如:打开抖音搜索奚学东,把第一个视频标题告诉我…(Enter 发送,Shift+Enter 换行)"></textarea>
|
||||||
<button class="btn btn-primary" id="btn-agent-send" onclick="sendAgentMsg()">发送</button>
|
<button class="btn btn-primary" id="btn-agent-send" onclick="sendAgentMsg()">发送</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
<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
|
return jsonify({"ok": False, "error": "请先在配置区填写 API Key"}), 400
|
||||||
if not cfg.get("model"):
|
if not cfg.get("model"):
|
||||||
return jsonify({"ok": False, "error": "请先填写模型名"}), 400
|
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:
|
with _lock:
|
||||||
if _run["state"] == "running":
|
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]
|
run_id = uuid.uuid4().hex[:8]
|
||||||
|
from datetime import datetime as _dt
|
||||||
_run.update(id=run_id, state="running", prompt=prompt,
|
_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="")
|
answer="", error="")
|
||||||
# history 保留(同会话多轮对话),由前端「清空对话」调用 clear 重置
|
# history 保留(同会话多轮对话),由前端「清空对话」调用 clear 重置
|
||||||
_queues[run_id] = queue.Queue()
|
_queues[run_id] = queue.Queue()
|
||||||
_stop_events[run_id] = threading.Event()
|
_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,
|
threading.Thread(target=_agent_thread,
|
||||||
args=(run_id, prompt, serial, cfg),
|
args=(run_id, prompt, serial, cfg),
|
||||||
daemon=True).start()
|
daemon=True).start()
|
||||||
return jsonify({"ok": True, "run_id": run_id})
|
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")
|
@bp.route("/api/agent/stream")
|
||||||
@admin_required
|
@admin_required
|
||||||
def agent_stream():
|
def agent_stream():
|
||||||
|
|||||||
Reference in New Issue
Block a user