AI 控制台下新增子分栏「🧭 AI 建任务」:描述要做什么(例:建一个跑 2 小时的任务、自动刷
某 App、随机点赞),AI 用 de_* 工具自己在设备上探索(看屏/读元素树/点按验证),把走通的
路径写成一条任务草稿,经服务端校验后交人在步骤编辑器里核对/手改/试跑,保存才入库。
链路:POST /api/agent/run{mode:"designer", settings}
→ Agent 自探 → 本地工具 submit_task(draft)
→ core/task_draft 校验(失败把 errors 回灌模型让它改)
→ 只暂存(运行态 + app_meta.agent_task_draft,**不落库**)
→ SSE done{mode,draft,warnings} → 页面草稿预览 → openTaskModal(null, prefill) 预填
关键实现
- core/task_draft.py(新):把执行器的"静默跳过点"(未知 type/空 selector/空 children/
嵌套>5/节点>60/cron 非法/必填缺失)前移成显式 error——POST /api/jobs 对 params 是盲存的,
执行器又静默跳过错误步骤,没有这道闸门就是"任务建好了、跑起来什么都没做"。
归一化兜底任务名/target/schedule/retry/时长;页面填的设置以 overrides 优先于模型。
故意**不比执行器更严**:loop_mode 近义值归一(count→rounds)、缺 max_iterations 补默认 10
(执行器本来就默认)——实测卡太死会把一轮探索耗在改字段上。
有副作用的步骤(评论/发送/购买/删除…)只警告并把触发概率压到 30%(编辑器可改回)。
- mcp_agent/agent.py:双系统提示词(CHAT/DESIGNER)+ 平台级本地工具
(LOCAL_TOOL_SPECS,不进 MCP)+ 每工具调用上限 40 + designer 输出上限 8192 +
**json.loads 容错**(草稿被截断时给模型可读错误,而不是整轮失败)。
- web/agent_api.py:mode/settings 透传、submit_task 处理器(app_context 内校验+暂存)、
done 带 draft、GET /api/agent/task_draft{,+POST,/clear}(草稿走 app_meta,不新建表)。
- 前端:static/admin/taskgen.js + #agent-sub-taskgen 子面板(showSubTab 机制);
tasks.js 的 openTaskModal(jobId, prefill) + 信封归一化 + 唯一 draftKey;
agent.js 按 mode 门控(一个 run 只有一个事件队列,两个 EventSource 会互相瓜分事件)。
顺带修掉一个 chat 也踩的协议 bug:一轮里同时调 de_screenshot 与别的工具时,截图图像会被
插在两条 tool 消息之间 → 模型侧判"工具回应不足"直接 400。改为本轮 tool 消息发完再附图像,
_repair_tool_messages 也改成只数**连续**的 tool 消息。
真机实测(Redmi 22120RN86C,设置页):8 步探索(含 tap_text 验证)→ submit_task 一次通过 →
草稿 8 个顶层步骤(screen_on/open_app/wait_el/click/wait/key_event…)、max_duration 1800、
无 click_xy、3 条 evidence;页面恢复草稿 + 预填编辑器 + 提示块渲染均正常,无 JS 报错。
自测数据已清理(草稿已丢弃、未创建任何任务)。
文档:AI_TASK_GEN.md 状态改「P0 已实现」+ §10 实现记录(差异/护栏/未做项)、AI_CONSOLE.md
(子分栏、designer 分支、SSE done 负载、app_meta 键)、API.md、DATA_MODEL.md、ARCHITECTURE.md、
DEVELOPMENT.md(自测入口)、README.md 索引、backlog 勾掉 P0。
187 lines
8.6 KiB
JavaScript
187 lines
8.6 KiB
JavaScript
// 通用基础:esc/CSRF/权限/API/Toast、Tab 切换与页内子分栏、模态框、行内按钮操作
|
||
// ================== 通用工具 ==================
|
||
const ACTION_LABELS={like:'点赞',comment:'评论',follow:'关注',share:'分享'};
|
||
const STATUS_MAP={idle:{t:'未启动',c:'default'},connecting:{t:'连接中',c:'warning'},running:{t:'运行中',c:'success'},done:{t:'完成',c:'primary'},error:{t:'异常',c:'danger'},released:{t:'已释放',c:'default'},failed:{t:'失败',c:'danger'}};
|
||
const TARGET_MAP={all:'全部空闲',group:'按分组',serial:'指定设备'};
|
||
|
||
// 设备显示文案:**名称是设备身份**,地址只是当前连接位置。
|
||
// 有名称 → 「名称 · serial」;没名称(历史数据)→ 只显示 serial
|
||
function devText(name, serial){
|
||
const n=String(name||'').trim();
|
||
return n ? (n + ' · ' + serial) : String(serial||'');
|
||
}
|
||
const APP_NAMES={'com.ss.android.ugc.aweme':'抖音','com.ss.android.ugc.aweme.lite':'抖音极速版','com.smile.gifmaker':'快手','com.kuaishou.nebula':'快手极速版','com.tencent.mm':'微信','com.tencent.mobileqq':'QQ','com.tencent.qqlive':'腾讯视频','com.eg.android.AlipayGphone':'支付宝','com.taobao.taobao':'淘宝','com.tmall.wireless':'天猫','com.jingdong.app.mall':'京东','com.xunmeng.pinduoduo':'拼多多','com.ss.android.article.news':'今日头条','com.zhihu.android':'知乎','com.miui.home':'桌面','com.android.launcher':'桌面','com.android.settings':'设置','com.android.systemui':'系统','com.github.uiautomator':'U2','com.github.uiautomator.test':'U2测试'};
|
||
|
||
function esc(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
|
||
|
||
// ===== CSRF =====
|
||
let _csrfToken='';
|
||
// 页面加载后调用:获取 CSRF token,非 GET 请求需携带
|
||
async function initCsrf(){
|
||
try{
|
||
const r=await fetch('/api/csrf');
|
||
const d=await r.json();
|
||
_csrfToken=d.token||'';
|
||
}catch(e){}
|
||
}
|
||
function _csrfHeaders(){
|
||
return _csrfToken?{'X-CSRF-Token':_csrfToken}:{};
|
||
}
|
||
|
||
// ===== 当前用户与权限 =====
|
||
// _me 来自 /api/me:{username, is_admin, perms:[tasks/devices/apks/logs]}
|
||
// 管理员 perms 恒为全部。无权限的功能入口(tab/按钮)用 data-perm 标记或 _can() 判断隐藏。
|
||
let _me={is_admin:false,perms:[],username:''};
|
||
async function loadMe(){
|
||
try{
|
||
const r=await apiGet('/api/me');
|
||
if(r&&r.ok)_me=r.user||{is_admin:false,perms:[],username:''};
|
||
const un=document.getElementById('me-username');
|
||
if(un)un.textContent=_me.username||'';
|
||
// 隐藏无权限的 tab 和按钮(data-perm="admin" 表示仅管理员)
|
||
document.querySelectorAll('[data-perm]').forEach(function(el){
|
||
const p=el.dataset.perm;
|
||
const ok=(p==='admin')?_me.is_admin:(_me.perms||[]).includes(p);
|
||
if(!ok)el.style.display='none';
|
||
});
|
||
}catch(e){}
|
||
}
|
||
function _can(perm){
|
||
return _me.is_admin||(_me.perms||[]).includes(perm);
|
||
}
|
||
const PERM_LABELS={tasks:'任务管理',devices:'设备控制',apks:'应用管理',logs:'日志查看'};
|
||
|
||
async function apiGet(url){
|
||
const r=await fetch(url);
|
||
if(r.status===401){window.location='/login';return null;}
|
||
return r.json();
|
||
}
|
||
async function apiPost(url,body){
|
||
const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json',..._csrfHeaders()},body:body?JSON.stringify(body):'{}'});
|
||
if(r.status===401){window.location='/login';return null;}
|
||
return r.json();
|
||
}
|
||
async function apiPut(url,body){
|
||
const r=await fetch(url,{method:'PUT',headers:{'Content-Type':'application/json',..._csrfHeaders()},body:body?JSON.stringify(body):'{}'});
|
||
if(r.status===401){window.location='/login';return null;}
|
||
return r.json();
|
||
}
|
||
|
||
async function apiDelete(url){
|
||
const r=await fetch(url,{method:'DELETE',headers:_csrfHeaders()});
|
||
if(r.status===401){window.location='/login';return null;}
|
||
return r.json();
|
||
}
|
||
|
||
function showToast(msg,type){
|
||
const t=document.getElementById('toast');
|
||
t.className='toast show toast-'+(type||'success');
|
||
t.textContent=msg;
|
||
setTimeout(()=>t.className='toast',3000);
|
||
}
|
||
function handleResult(r,callback){
|
||
if(!r)return;
|
||
if(r.ok){showToast(r.msg||'操作成功','success');if(callback)callback();}
|
||
else{showToast(r.error||'操作失败','error');}
|
||
}
|
||
|
||
// ================== Tab 切换 ==================
|
||
let _activeTab='monitor';
|
||
let _monitorTimer=null;
|
||
let _logTimer=null;
|
||
|
||
function showTab(name){
|
||
_activeTab=name;
|
||
document.querySelectorAll('.tab-panel').forEach(p=>p.classList.remove('active'));
|
||
document.querySelectorAll('.navbar .tab').forEach(t=>t.classList.remove('active'));
|
||
const panel=document.getElementById('tab-'+name);
|
||
if(panel)panel.classList.add('active');
|
||
const btn=document.querySelector('.navbar .tab[data-tab="'+name+'"]');
|
||
if(btn)btn.classList.add('active');
|
||
// 停止所有定时器
|
||
if(_monitorTimer){clearInterval(_monitorTimer);_monitorTimer=null;}
|
||
if(_logTimer){clearInterval(_logTimer);_logTimer=null;}
|
||
if(_discoveryTimer){clearInterval(_discoveryTimer);_discoveryTimer=null;}
|
||
// 按需加载数据
|
||
if(name==='monitor'){loadMonitor();_monitorTimer=setInterval(loadMonitor,5000);}
|
||
if(name==='tasks'){showSubTab('tasks',_activeSubs.tasks);loadTasks();loadCustomActions();}
|
||
if(name==='tools'){showSubTab('tools',_activeSubs.tools);loadToolsDevices();loadAdbDevices();loadTailscaleDevices();loadApks();}
|
||
if(name==='logs'){loadLogs();if(document.getElementById('log-auto').checked)_logTimer=setInterval(loadLogs,3000);}
|
||
if(name==='agent'){
|
||
showSubTab('agent', _activeSubs.agent||'chat'); // 聊天 / AI 建任务
|
||
if(typeof initAgentChat==='function') initAgentChat();
|
||
}
|
||
if(name==='users')loadUsers();
|
||
if(name==='system'){showSubTab('system',_activeSubs.system||'backup');}
|
||
}
|
||
|
||
// ================== 页内子分栏(任务/工具 通用) ==================
|
||
// 每个带子分栏的 Tab 记住上次选中的子分栏,切走再切回来保持原位
|
||
let _activeSubs = {tasks: 'plan', tools: 'clipboard', system: 'backup', agent: 'chat'};
|
||
let _discoveryTimer = null; // 设备自动发现 10s 轮询(仅 devpool 子分栏激活时)
|
||
|
||
function showSubTab(tabId, name){
|
||
_activeSubs[tabId] = name;
|
||
const tab = document.getElementById('tab-'+tabId);
|
||
tab.querySelectorAll('.sub-tab').forEach(b=>b.classList.toggle('active', b.dataset.sub===name));
|
||
tab.querySelectorAll('.sub-panel').forEach(p=>p.classList.toggle('active', p.id===tabId+'-sub-'+name));
|
||
if(tabId==='agent' && name==='taskgen' && typeof initTaskGen==='function') initTaskGen();
|
||
if(name==='groups' && typeof loadGroups==='function') loadGroups();
|
||
if(name==='apks' && typeof loadAgentStore==='function') loadAgentStore();
|
||
if(name==='devpool' && typeof loadDevPool==='function'){
|
||
loadDevPool();
|
||
if(typeof loadDiscovery==='function'){
|
||
loadDiscovery();
|
||
if(_discoveryTimer){clearInterval(_discoveryTimer);_discoveryTimer=null;}
|
||
_discoveryTimer=setInterval(loadDiscovery,10000); // 10s 轮询:扫完自动出新结果
|
||
}
|
||
} else if(_discoveryTimer){clearInterval(_discoveryTimer);_discoveryTimer=null;}
|
||
}
|
||
|
||
// ================== 模态框 ==================
|
||
function showModal(){
|
||
document.getElementById('modal-overlay').classList.add('show');
|
||
}
|
||
function closeModal(){
|
||
// 先关闭弹窗(最重要的一步,确保一定执行)
|
||
const ov=document.getElementById('modal-overlay');
|
||
if(ov)ov.classList.remove('show');
|
||
// 再做清理工作,任何异常都不影响弹窗关闭
|
||
try{
|
||
stopShotTimer();
|
||
_shotSerial='';
|
||
const box=document.getElementById('modal-box');
|
||
if(box){box.classList.remove('shot-modal');box.classList.remove('wide-modal');}
|
||
}catch(e){console.warn('closeModal cleanup error:',e);}
|
||
}
|
||
|
||
// 测试此步骤弹窗关闭(editor.js 的测试按钮用;曾随 STF 清理误删)
|
||
function closeTestModal(){
|
||
const ov=document.getElementById('test-overlay');
|
||
if(ov)ov.classList.remove('show');
|
||
}
|
||
|
||
// 关闭元素抓取弹窗(不影响任务编辑窗口)
|
||
function closeElPicker(){
|
||
var ov=document.getElementById('el-picker-overlay');
|
||
if(ov)ov.classList.remove('show');
|
||
// 清理状态
|
||
try{ _stepEditor._elData=null; _stepEditor._elSerial=''; }catch(e){}
|
||
}
|
||
|
||
// ================== 行内按钮操作(停止/执行/启停等,自动 toast + 刷新)==================
|
||
async function apiAction(url,body){
|
||
const r=await apiPost(url,body);
|
||
if(!r)return;
|
||
if(r.ok){showToast(r.msg||'操作成功','success');if(_activeTab==='monitor')loadMonitor();if(_activeTab==='tasks')loadTasks();}
|
||
else{showToast(r.error||'操作失败','error');}
|
||
}
|
||
|
||
async function scanForeground(){
|
||
const r=await apiPost('/api/scan_foreground');
|
||
if(!r)return;
|
||
if(r.ok){showToast('扫描已启动,请等待几秒...','success');loadMonitor();}
|
||
else{showToast(r.error||'扫描失败','error');}
|
||
}
|
||
|