// ================== 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 = '' + devs.map(d=>{ const label = devText(d.name, d.serial) + (d.model ? ' · ' + d.model : '') + (d.busy ? ' ⛔ 任务中:' + d.task_job : ''); return ''; }).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 = '' + (r.groups||[]).map(g=>'').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(); if(tgAutoCreateOn()){ // 用户勾了「探索完直接创建」:不再等确认,直接建(服务端仍会再校验一次) tgStatus('探索完成,正在直接创建任务…'); createDraft(true); endTaskGenStream(); return; } 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 = '' + '' + esc(tool||'') + ' ' + '' + esc(argsTxt || '') + ''; 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(['

📝 ' + esc(task.name || _tgDraft.summary || '任务草稿') + '

']) .concat(['
' + esc(_tgDraft.summary || '') + '
']) .concat(['
目标:' + esc(tgTargetText(task.target)) + ' | 调度:' + esc(tgScheduleText(task.schedule)) + ' | 时长上限:' + esc(String((task.params||{}).max_duration || 0)) + ' 秒
']) .concat(['
']) .concat(tgRenderSteps(steps, 0)) .concat(['
']); if((_tgWarnings||[]).length){ html.push('
⚠ ' + _tgWarnings.length + ' 处提醒(不拦,但请核对)' + _tgWarnings.map(w=>'
· ' + esc(w) + '
').join('') + '
'); } if((_tgDraft.notes||[]).length){ html.push('
需要人工复核' + _tgDraft.notes.map(n=>'
· ' + esc(n) + '
').join('') + '
'); } const ev = _tgDraft.evidence || []; if(ev.length){ html.push('
探索依据(' + ev.length + ' 条)' + ev.map(e=>'
' + esc(e.screen || '') + ' · ' + esc(e.selector_type || '') + '=' + esc(e.selector_value || '') + ' · ' + esc(e.verified || '') + '
').join('') + '
'); } html.push('
' + '' + '' + '' + '
'); box.innerHTML = '
' + html.join('') + '
'; } 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 = '
' + esc(pad) + '' + esc(s.type) + '' + (s.label ? ' 「' + esc(s.label) + '」' : '') + (bits.length ? ' ' + esc(bits.join(' · ')) + '' : '') + '
'; 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 tgAutoCreateOn(){ const c = document.getElementById('tg-auto-create'); return !!(c && c.checked); } // 把草稿直接建成任务(服务端取暂存草稿 → 再校验 → add_job → 清草稿) // auto=true 表示是勾了开关后的自动创建(不再弹确认) function createDraft(auto){ if(!_tgDraft){ showToast('还没有草稿','error'); return; } const name = ((_tgDraft.task||{}).name) || '这条任务'; if(!auto && !confirm('直接创建任务「' + name + '」?\n\n' + '创建后就是一条正式的可调度任务(可在任务页编辑/停用),' + '不再经过步骤编辑器的核对。')) return; const cfg = tgCollectSettings(); tgStatus('正在创建任务…'); apiPost('/api/agent/task_draft/create', { serial: ((document.getElementById('tg-serial')||{}).value||''), overrides: cfg ? cfg.settings : {} }).then(r=>{ if(!r || !r.ok){ const errs = (r && r.errors && r.errors.length) ? ':' + r.errors.slice(0,3).join(';') : ''; showToast(((r&&r.error)||'创建失败') + errs, 'error'); tgStatus('创建失败 —— 可以「在步骤编辑器中核对」后手工保存'); return; } const job = r.job || {}; _tgDraft = null; _tgWarnings = []; const box = document.getElementById('tg-draft'); if(box){ box.innerHTML = '

✅ 任务已创建:' + esc(job.name||'') + '

' + '
下次运行:' + esc(job.next_run || '手动触发') + ' | 调度:' + esc(tgScheduleText(job.schedule)) + ' | id ' + esc(job.id||'') + '
' + ((r.warnings||[]).length ? '
' + r.warnings.map(w=>'· ' + esc(w)).join('
') + '
' : '') + '
' + '' + '' + '
'; } tgStatus('任务已创建:' + (job.name||'')); showToast('任务「' + (job.name||'') + '」已创建', 'success'); if(typeof loadTasks === 'function') loadTasks(); }); } function tgResetDraftView(){ _tgDraft = null; _tgWarnings = []; const box = document.getElementById('tg-draft'); if(box) box.innerHTML = ''; tgStatus(''); } 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; } }); }