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。
This commit is contained in:
2026-09-13 23:08:59 +08:00
parent 0bc713137d
commit 46e6ea1f37
16 changed files with 1707 additions and 47 deletions
+416
View File
@@ -0,0 +1,416 @@
// ================== 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;
}
});
}