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:
+11
-4
@@ -55,6 +55,9 @@ function initAgentChat(){
|
||||
function restoreRunningFlow(){
|
||||
apiGet('/api/agent/run').then(r=>{
|
||||
if(!r||!r.ok) return;
|
||||
// 建任务模式(designer)的运行由「AI 建任务」子页订阅。这里必须让开:
|
||||
// 服务端一个 run 只有一个事件队列,两个 EventSource 同时消费会互相瓜分事件。
|
||||
if(r.mode === 'designer') return;
|
||||
if(r.state === 'running' && r.run_id){
|
||||
setRunning(true);
|
||||
listenStream(r.run_id);
|
||||
@@ -116,14 +119,18 @@ function pollRunState(){
|
||||
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';
|
||||
// 运行槽是全平台唯一的:建任务探索在跑时,聊天页也不能再发起(如实显示)
|
||||
const tag = document.getElementById('agent-running-tag');
|
||||
tag.style.display = running ? 'inline' : 'none';
|
||||
tag.textContent = running && r.mode === 'designer' ? '● 建任务探索中' : '● 运行中';
|
||||
document.getElementById('btn-agent-stop').style.display = running ? 'inline-block' : 'none';
|
||||
const hint = document.getElementById('agent-target-hint');
|
||||
if(hint){
|
||||
const dev = r.serial ? ' · ' + _agentTargetText(r.serial) : '';
|
||||
hint.textContent = running
|
||||
? ('⏳ 运行中' + (r.started ? ' ' + r.started + ' 起' : '')
|
||||
+ (r.serial ? ' · ' + _agentTargetText(r.serial) : '')
|
||||
+ ':' + (r.prompt||'').slice(0,70))
|
||||
? ((r.mode === 'designer' ? '⏳ AI 正在「AI 建任务」页探索设备'
|
||||
: '⏳ 运行中' + (r.started ? ' ' + r.started + ' 起' : ''))
|
||||
+ dev + ':' + (r.prompt || '').slice(0, 70))
|
||||
: (document.getElementById('agent-target-select').value
|
||||
? '将操作:' + _agentTargetText(document.getElementById('agent-target-select').value)
|
||||
: '');
|
||||
|
||||
@@ -107,14 +107,17 @@ function showTab(name){
|
||||
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' && typeof initAgentChat==='function') initAgentChat();
|
||||
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'};
|
||||
let _activeSubs = {tasks: 'plan', tools: 'clipboard', system: 'backup', agent: 'chat'};
|
||||
let _discoveryTimer = null; // 设备自动发现 10s 轮询(仅 devpool 子分栏激活时)
|
||||
|
||||
function showSubTab(tabId, name){
|
||||
@@ -122,6 +125,7 @@ function showSubTab(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'){
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
+41
-5
@@ -64,14 +64,35 @@ function renderTaskRow(j){
|
||||
'</tr>';
|
||||
}
|
||||
|
||||
function openTaskModal(jobId){
|
||||
// 任务信封归一化:补齐 7 个键,避免下游无保护访问
|
||||
// (原文只对"已存在的 job"安全,AI 草稿/半成品信封会让 j.retry.max_attempts 之类直接抛错)
|
||||
function _normalizeTaskEnv(env){
|
||||
const e=env||{}, t=e.target||{}, s=e.schedule||{}, r=e.retry||{}, p=e.params||{};
|
||||
return {
|
||||
name:e.name||'',
|
||||
task_type:e.task_type||'generic_steps',
|
||||
enabled:(e.enabled===undefined)?true:!!e.enabled,
|
||||
target:{mode:t.mode||'all', group_name:t.group_name||'', serial:t.serial||''},
|
||||
schedule:{mode:s.mode||'once', cron:s.cron||'', stop_cron:s.stop_cron||'',
|
||||
window:s.window||null},
|
||||
retry:{max_attempts:r.max_attempts||1, delay:r.delay||60},
|
||||
params:Object.assign({}, p)
|
||||
};
|
||||
}
|
||||
|
||||
// openTaskModal(jobId, prefill)
|
||||
// jobId —— 传 id = 编辑已有任务
|
||||
// prefill —— AI 建任务的草稿:{task, notes, warnings}(服务端已校验,这里只预填给人核对)
|
||||
function openTaskModal(jobId, prefill){
|
||||
const job=jobId?(_jobsData||[]).find(j=>j.id===jobId):null;
|
||||
const isEdit=!!job;
|
||||
document.getElementById('modal-title').textContent=isEdit?'编辑任务':'新建任务';
|
||||
const pf=(prefill&&prefill.task)?prefill:null;
|
||||
document.getElementById('modal-title').textContent=
|
||||
isEdit?'编辑任务':(pf?'AI 生成任务(请核对后保存)':'新建任务');
|
||||
// 任务编辑器含步骤编辑,加宽弹窗;关闭时在 closeModal 移除
|
||||
document.getElementById('modal-box').classList.add('wide-modal');
|
||||
|
||||
const j=job||{name:'',task_type:'generic_steps',enabled:true,target:{mode:'all'},params:{},schedule:{mode:'once'},retry:{max_attempts:1,delay:60}};
|
||||
const j=_normalizeTaskEnv(job||(pf&&pf.task)||{});
|
||||
const tt=_taskTypes.map(t=>'<option value="'+t.task_type+'"'+(t.task_type===j.task_type?' selected':'')+'>'+esc(t.name)+'</option>').join('');
|
||||
const gl=_groupsList.map(g=>'<option value="'+g.name+'"'+(g.name===j.target.group_name?' selected':'')+'>'+esc(g.name)+'</option>').join('');
|
||||
const dl=_devicesList.map(x=>'<option value="'+esc(x.serial)+'"'+(x.serial===j.target.serial?' selected':'')+'>'
|
||||
@@ -160,8 +181,23 @@ function openTaskModal(jobId){
|
||||
'</div>'+
|
||||
'</div>';
|
||||
|
||||
// 初始化步骤编辑器(generic_steps 时显示,其他隐藏);jobId 作为草稿 key
|
||||
_stepEditor.init(j.params&&j.params.steps?j.params.steps:[],j.params&&j.params.max_duration?j.params.max_duration:0,jobId||'');
|
||||
// AI 草稿的提醒/复核项:贴在步骤编辑器上方,别让人漏看
|
||||
if(pf&&((pf.notes&&pf.notes.length)||(pf.warnings&&pf.warnings.length))){
|
||||
const se=document.getElementById('step-editor');
|
||||
if(se) se.insertAdjacentHTML('beforebegin',
|
||||
'<div class="tg-note" style="margin-bottom:10px"><b>AI 探索说明(请核对)</b>'
|
||||
+(pf.notes||[]).map(n=>'<div>· '+esc(n)+'</div>').join('')
|
||||
+((pf.warnings||[]).length
|
||||
? '<div style="margin-top:6px;color:#b45309"><b>校验提醒</b>'
|
||||
+pf.warnings.map(w=>'<div>· '+esc(w)+'</div>').join('')+'</div>'
|
||||
: '')
|
||||
+'</div>');
|
||||
}
|
||||
// 初始化步骤编辑器(generic_steps 时显示,其他隐藏);jobId 作为草稿 key。
|
||||
// AI 草稿用一个唯一 key:既避开 localStorage 里"新建任务"的旧草稿覆盖预填内容,
|
||||
// 又保留自动存草稿(保存成功时 saveTask 会 clearDraft 清掉它)。
|
||||
const draftKey=jobId||(pf?('ai:'+Date.now()):'');
|
||||
_stepEditor.init(j.params&&j.params.steps?j.params.steps:[],j.params&&j.params.max_duration?j.params.max_duration:0,draftKey);
|
||||
|
||||
document.getElementById('modal-footer').innerHTML=
|
||||
'<button class="btn" onclick="closeModal()">取消</button>'+
|
||||
|
||||
Reference in New Issue
Block a user