Files
auto_control/static/admin/taskgen.js
T
butubb 46e6ea1f37 feat(AI 建任务): AI 自己在真机探索 → 写出可调度任务 → 人工确认入库
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。
2026-09-13 23:08:59 +08:00

417 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ================== AI 建任务(AI 自己探索 → 写出任务步骤) ==================
// 链路:
// 需求 → POST /api/agent/run{mode:'designer'}
// → AI 用 de_* 工具在真机上探索(看屏 / 读元素树 / 点按验证)
// → 调平台级本地工具 submit_task 提交草稿(服务端用 core/task_draft 校验,
// 不通过就把 errors 回灌给模型让它改)
// → SSE done 带草稿 → 这里预览 → 打开步骤编辑器预填 → **用户点保存才入库**
// 设计见 doc/AI_TASK_GEN.md;契约与两条红线(AI 不直接落库、有副作用的动作不真做)在 §5。
let _tgInit = false;
let _tgStream = null; // EventSource
let _tgBusy = false;
let _tgDraft = null; // {summary, task, notes, evidence}
let _tgWarnings = [];
let _tgPrompt = ''; // 本次需求(回存草稿时带上)
let _tgLiveSerial = '';
// ---------------- 初始化 ----------------
function initTaskGen(){
if(!_tgInit){
_tgInit = true;
const inp = document.getElementById('tg-prompt');
if(inp){
inp.addEventListener('keydown', ev=>{
if(ev.key==='Enter' && (ev.ctrlKey||ev.metaKey)){ ev.preventDefault(); startExplore(); }
});
}
}
tgLoadDevices();
tgLoadGroups();
tgRestore();
}
function tgExample(){
const inp = document.getElementById('tg-prompt');
if(!inp) return;
inp.value = '建一个跑 2 小时的任务:自动刷抖音,随机点赞(概率低一点,像真人);'
+ '有弹窗就关掉;评论这种拿不准的先别真发。';
inp.focus();
}
function tgToggleAdv(){
const box = document.getElementById('tg-adv');
const arrow = document.getElementById('tg-adv-arrow');
if(!box) return;
const show = box.style.display === 'none';
box.style.display = show ? 'block' : 'none';
if(arrow) arrow.textContent = show ? '▾' : '▸';
}
function tgToggleTarget(){
const v = (document.getElementById('tg-target')||{}).value;
const w = document.getElementById('tg-group-wrap');
if(w) w.style.display = (v==='group') ? 'block' : 'none';
}
function tgToggleSched(){
const v = (document.getElementById('tg-sched-mode')||{}).value;
const w = document.getElementById('tg-sched-wrap');
if(w) w.style.display = (v==='daily') ? 'flex' : 'none';
}
// ---------------- 设备 / 分组 ----------------
function tgLoadDevices(){
apiGet('/api/agent/devices').then(r=>{
if(!r||!r.ok) return;
const sel = document.getElementById('tg-serial');
if(!sel) return;
const cur = sel.value;
const devs = (r.devices||[]).filter(x=>x.online);
// 建任务页不按 serial 形态过滤:USB 序列号与 IP:5555 都是合法的 AI 目标
sel.innerHTML = '<option value="">请选择设备…</option>'
+ devs.map(d=>{
const label = devText(d.name, 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;
});
}
function tgLoadGroups(){
apiGet('/api/groups').then(r=>{
if(!r||!r.ok) return;
const sel = document.getElementById('tg-group');
if(!sel) return;
sel.innerHTML = '<option value="">选择分组…</option>'
+ (r.groups||[]).map(g=>'<option value="'+esc(g.name)+'">'+esc(g.name)
+'('+((g.serials||[]).length)+' 台)</option>').join('');
});
}
// ---------------- 收集页面上的任务设置 ----------------
// 返回 {settings, hints}:settings 是服务端 validate_draft 的 overrides(页面填的说了算),
// hints 是要写进提示词告诉模型的自然语言(避免模型自己编一套跟页面冲突的调度)。
function tgCollectSettings(){
const v = id => { const e = document.getElementById(id); return e ? (e.value||'').trim() : ''; };
const s = {}, hints = [];
if(v('tg-name')) s.name = v('tg-name');
const tmode = v('tg-target');
const serial = v('tg-serial');
if(tmode){ s.target_mode = tmode; s.serial = serial; }
if(tmode === 'group'){
if(!v('tg-group')){ showToast('选了「按分组」但没选分组','error'); return null; }
s.group_name = v('tg-group');
}
const smode = v('tg-sched-mode');
if(smode === 'once'){ s.schedule = {mode:'once'}; s.schedule_hint = '手动启动(不自动跑)'; }
if(smode === 'daily'){
const st = v('tg-sched-start'), sp = v('tg-sched-stop');
if(!st){ showToast('选了「每天定时」但没填启动时间','error'); return null; }
const cron = tgTimeToCron(st);
if(sp){
s.schedule = {mode:'cron_stop', cron:cron, stop_cron:tgTimeToCron(sp)};
s.schedule_hint = '每天 ' + st + ' 启动、' + sp + ' 停止';
}else{
s.schedule = {mode:'cron', cron:cron};
s.schedule_hint = '每天 ' + st + ' 启动(不自动停止)';
}
}
const dur = parseInt(v('tg-maxdur'), 10);
if(dur > 0){ s.max_duration = dur; }
return {settings: s, hints: hints};
}
// "20:30" → "30 20 * * *"(每天)
function tgTimeToCron(hhmm){
const m = String(hhmm||'').match(/^(\d{1,2}):(\d{2})$/);
if(!m) return '';
return parseInt(m[2],10) + ' ' + parseInt(m[1],10) + ' * * *';
}
// ---------------- 发起探索 ----------------
function startExplore(){
if(_tgBusy) return;
const prompt = ((document.getElementById('tg-prompt')||{}).value||'').trim();
if(!prompt){ showToast('先说说要做什么','error'); return; }
const serial = ((document.getElementById('tg-serial')||{}).value||'').trim();
if(!serial){ showToast('请选择一台设备(AI 只操作你选定的设备)','error'); return; }
const cfg = tgCollectSettings();
if(!cfg) return;
_tgPrompt = prompt;
_tgDraft = null; _tgWarnings = [];
document.getElementById('tg-stream').innerHTML = '';
document.getElementById('tg-draft').innerHTML = '';
tgSetBusy(true, '正在启动…');
apiPost('/api/agent/run', {prompt: prompt, serial: serial,
mode: 'designer', settings: cfg.settings})
.then(r=>{
if(!r || !r.ok){
tgSetBusy(false, '');
showToast((r&&r.error)||'启动失败','error');
return;
}
tgWatch(serial);
listenTaskGenStream(r.run_id);
});
}
function stopExplore(){
apiPost('/api/agent/stop', {}).then(r=>{
showToast((r&&r.ok) ? '已请求停止' : ((r&&r.error)||'停止失败'),
(r&&r.ok)?'success':'error');
});
}
function tgSetBusy(busy, text){
_tgBusy = busy;
const b = document.getElementById('tg-start'), s = document.getElementById('tg-stop');
if(b) b.disabled = busy;
if(s) s.style.display = busy ? 'inline-block' : 'none';
if(text !== undefined) tgStatus(text);
}
function tgStatus(t){
const el = document.getElementById('tg-status');
if(el) el.textContent = t || '';
}
// ---------------- 事件流 ----------------
function listenTaskGenStream(runId){
if(_tgStream) _tgStream.close();
const es = new EventSource('/api/agent/stream?run_id=' + runId);
_tgStream = es;
let steps = 0;
es.addEventListener('delta', ev=>{
let d = {}; try{ d = JSON.parse(ev.data) || {}; }catch(e){}
if(d.kind === 'reasoning') return; // 推理链不铺在回放区(噪音太大)
if(d.text) tgStatus('AI 思考中… ' + String(d.text).replace(/\s+/g,' ').slice(-60));
});
es.addEventListener('usage', ev=>{
let d = {}; try{ d = JSON.parse(ev.data) || {}; }catch(e){}
tgToken(d);
});
es.addEventListener('step', ev=>{
let d = {}; try{ d = JSON.parse(ev.data) || {}; }catch(e){}
steps++;
tgFollow(d.args);
tgAddCard(d.tool, d.args, d.image, d.error);
tgStatus('已执行 ' + steps + ' 步…');
});
es.addEventListener('done', ev=>{
let d = {}; try{ d = JSON.parse(ev.data) || {}; }catch(e){}
tgSetBusy(false, '');
if(d.draft){
_tgDraft = d.draft; _tgWarnings = d.warnings || [];
tgRenderDraft();
tgStatus('探索完成,共 ' + steps + ' 步。请核对草稿后创建任务。');
}else{
tgStatus(steps ? ('探索结束(' + steps + ' 步),但没有产出草稿。')
: '没有什么可做的,AI 没给出草稿。');
tgAddCard('⚠ 未产出草稿',
(d.draft_error || 'AI 没有提交任务草稿 —— 可能是需求太模糊,或设备/App 不可用。'
+ ' 可以补一句更具体的要求再试。'));
}
endTaskGenStream();
});
es.addEventListener('error', ev=>{
let msg = '连接中断';
try{ if(ev.data) msg = (JSON.parse(ev.data)||{}).message || msg; }catch(e){}
tgSetBusy(false, '');
tgAddCard('⚠ 出错', msg);
endTaskGenStream();
});
es.onerror = ()=>{
// 与聊天页同理:不在这里收尾——断网/节流会触发 onerror,但后台仍在跑,
// EventSource 会自动重连,服务端队列保留积压事件。
if(_tgBusy && es.readyState === EventSource.CLOSED){
tgStatus('⚠ 实时连接中断,探索仍在后台进行;刷新页面可恢复。');
}
};
}
function endTaskGenStream(){
if(_tgStream){ _tgStream.close(); _tgStream = null; }
}
function tgAddCard(tool, args, image, err){
const box = document.getElementById('tg-stream');
if(!box) return;
const empty = box.querySelector('.agent-empty');
if(empty) empty.remove();
const card = document.createElement('div');
card.className = 'agent-toolcard';
const argsTxt = typeof args === 'string' ? args
: JSON.stringify(args || {}, null, 0).slice(0, 240);
card.innerHTML = '<span class="dot"></span>'
+ '<code style="color:var(--teal)">' + esc(tool||'') + '</code> '
+ '<span class="text-muted">' + esc(argsTxt || '') + '</span>';
if(err){
const e = document.createElement('div');
e.className = 'tg-err';
e.textContent = '⚠ ' + err;
card.appendChild(e);
}
if(image){
const img = document.createElement('img');
img.src = 'data:image/jpeg;base64,' + image;
img.style.cursor = 'zoom-in';
img.onclick = ()=> zoomScreenshot(img);
card.appendChild(img);
}
box.appendChild(card);
box.scrollTop = box.scrollHeight;
}
function tgToken(u){
const el = document.getElementById('tg-token');
if(el && u && u.total_tokens) el.textContent = 'token ' + _fmtInt(u.total_tokens)
+ '(' + (u.calls||0) + ' 次调用)';
}
// 跟随画面:AI 一动哪个设备就切哪个(复用平台 MJPEG 流)
function tgFollow(args){
let a = args;
if(typeof args === 'string'){ try{ a = JSON.parse(args); }catch(e){ return; } }
if(a && a.serial) tgWatch(a.serial);
}
function tgWatch(serial){
if(!serial || serial === _tgLiveSerial) return;
_tgLiveSerial = serial;
const card = document.getElementById('tg-live-card');
const img = document.getElementById('tg-live-img');
if(!card || !img) return;
card.style.display = 'block';
img.style.display = 'block';
img.src = '/api/screen/stream?serial=' + encodeURIComponent(serial)
+ '&q=80&fps=8&t=' + Date.now();
const s = document.getElementById('tg-live-serial');
if(s) s.textContent = serial;
}
// ---------------- 草稿预览 ----------------
function tgRenderDraft(){
const box = document.getElementById('tg-draft');
if(!box) return;
if(!_tgDraft){ box.innerHTML = ''; return; }
const task = _tgDraft.task || {};
const steps = ((task.params||{}).steps) || [];
const html = []
.concat(['<h4>📝 ' + esc(task.name || _tgDraft.summary || '任务草稿') + '</h4>'])
.concat(['<div class="tg-hint">' + esc(_tgDraft.summary || '') + '</div>'])
.concat(['<div class="tg-hint">目标:' + esc(tgTargetText(task.target))
+ ' | 调度:' + esc(tgScheduleText(task.schedule))
+ ' | 时长上限:' + esc(String((task.params||{}).max_duration || 0)) + ' 秒</div>'])
.concat(['<div style="margin-top:8px">'])
.concat(tgRenderSteps(steps, 0))
.concat(['</div>']);
if((_tgWarnings||[]).length){
html.push('<div class="tg-warn"><b>⚠ ' + _tgWarnings.length + ' 处提醒(不拦,但请核对)</b>'
+ _tgWarnings.map(w=>'<div>· ' + esc(w) + '</div>').join('') + '</div>');
}
if((_tgDraft.notes||[]).length){
html.push('<div class="tg-note"><b>需要人工复核</b>'
+ _tgDraft.notes.map(n=>'<div>· ' + esc(n) + '</div>').join('') + '</div>');
}
const ev = _tgDraft.evidence || [];
if(ev.length){
html.push('<details class="tg-evidence"><summary>探索依据(' + ev.length + ' 条)</summary>'
+ ev.map(e=>'<div>' + esc(e.screen || '') + ' · '
+ esc(e.selector_type || '') + '='
+ esc(e.selector_value || '') + ' · '
+ esc(e.verified || '') + '</div>').join('')
+ '</details>');
}
html.push('<div class="tg-btns">'
+ '<button class="btn btn-primary" onclick="openDraftInEditor()">在步骤编辑器中打开</button>'
+ '<button class="btn" onclick="discardDraft()">丢弃草稿</button>'
+ '</div>');
box.innerHTML = '<div class="tg-draft">' + html.join('') + '</div>';
}
function tgRenderSteps(steps, depth){
const pad = ' '.repeat(Math.min(depth, 5));
return (steps||[]).map(s=>{
const p = s.params || {};
const bits = [];
if(p.selector_value) bits.push(p.selector_type + '=' + p.selector_value);
if(p.package) bits.push(p.package);
if(p.direction) bits.push(p.direction);
if(p.key) bits.push(p.key);
if(p.min !== undefined && p.max !== undefined) bits.push(p.min + '~' + p.max + 's');
if(p.loop_mode) bits.push(p.loop_mode);
if(p.probability !== undefined && p.probability < 100) bits.push(p.probability + '%');
let row = '<div class="tg-step">' + esc(pad) + '<span class="t">' + esc(s.type) + '</span>'
+ (s.label ? ' 「' + esc(s.label) + '」' : '')
+ (bits.length ? ' <span class="p">' + esc(bits.join(' · ')) + '</span>' : '')
+ '</div>';
if(Array.isArray(p.children)) row += tgRenderSteps(p.children, depth+1);
if(Array.isArray(p.then)) row += tgRenderSteps(p.then, depth+1);
if(Array.isArray(p.else)) row += tgRenderSteps(p.else, depth+1);
return row;
}).join('');
}
function tgTargetText(t){
t = t || {};
if(t.mode === 'serial') return '指定设备 ' + (t.serial || '');
if(t.mode === 'group') return '分组 ' + (t.group_name || '');
return '全部空闲设备';
}
function tgScheduleText(s){
s = s || {};
if(s.mode === 'cron') return '每天/定时 ' + (s.cron || '');
if(s.mode === 'cron_stop') return '定时 ' + (s.cron || '') + ' → 停 ' + (s.stop_cron || '');
return '手动(不自动启动)';
}
// ---------------- 草稿的动作 ----------------
async function openDraftInEditor(){
if(!_tgDraft){ showToast('还没有草稿','error'); return; }
// 步骤编辑器要用到任务类型/分组/设备下拉,先确保它们已加载
await loadTasks();
openTaskModal(null, {task: _tgDraft.task, notes: _tgDraft.notes || [],
warnings: _tgWarnings});
}
function discardDraft(){
if(!confirm('丢弃这份草稿?')) return;
apiPost('/api/agent/task_draft/clear', {}).then(r=>{
if(!r||!r.ok){ showToast((r&&r.error)||'丢弃失败','error'); return; }
_tgDraft = null; _tgWarnings = [];
document.getElementById('tg-draft').innerHTML = '';
tgStatus('已丢弃草稿');
});
}
// ---------------- 页面恢复(刷新/重进) ----------------
function tgRestore(){
apiGet('/api/agent/task_draft').then(r=>{
if(!r||!r.ok) return;
// 运行中的 designer 由本页接管订阅;聊天页见 agent.js 的 mode 判断
if(r.running && r.mode === 'designer' && r.run_id){
tgSetBusy(true, '探索进行中…(刷新后已重新连接)');
listenTaskGenStream(r.run_id);
return;
}
if(r.draft){ // 运行态里就有(同一次会话内切页回来)
_tgDraft = r.draft; _tgWarnings = [];
tgRenderDraft();
return;
}
const saved = r.saved;
if(saved && saved.draft){
_tgDraft = saved.draft; _tgWarnings = saved.warnings || [];
tgRenderDraft();
tgStatus('已恢复 ' + (saved.created || '') + ' 的草稿(可直接创建,或重新探索)');
const inp = document.getElementById('tg-prompt');
if(inp && saved.prompt) inp.value = saved.prompt;
}
});
}