用户报的"探索完无法点击创建任务"真因:一个 run 的事件原先只有**一条** queue.Queue, 聊天页与建任务页同时开着时两个 EventSource 会**瓜分**它——建任务页的回放卡在中间、 `done` 被聊天页取走 → 永远等不到草稿,页面上自然没有可点的"创建"。 一、修(根因 + 表现) - `web/agent_api.py` 新增 `_Fanout`:**每个订阅者一个专属队列**,多开页面各看各的, 还带单轮事件缓冲(晚订阅/刷新重连也能补齐回放,终止事件一定送达)。 实测两路订阅者收到完全一致的 1039 条事件(含 done)。 - `static/admin/agent.js`:断线重连的兜底订阅也按 `mode` 让开(此前漏了这一处)。 二、补齐上一批的三项 - **「直接创建」**:`POST /api/agent/task_draft/create`(草稿体只在服务端、创建前再校验一次、 成功后清草稿避免重复建)+ 草稿预览里的「✓ 直接创建任务」按钮 + 「探索完直接创建任务」勾选框。 - **草稿沉淀经验/动作**:designer 轮次也走 `_distill_experience/_distill_actions`, 但**只在草稿通过校验时**(没走通的试错不入库,免得把误点当经验)。 - **MCP `de_snapshot`**(第 20 个工具):截图+元素树一次取齐(省一次来回、不会因界面在动而错位), 附带 `screen_state`/`unstable`;两套提示词都改为优先用它。 平台侧 `/api/uiauto/snapshot` 随之多返回 `screen_state`。 三、文档 - AI_TASK_GEN §10:§10.3 记两个 bug 的真因与修法、§10.4 三项标完成、§10.5 剩余项。 - AI_CONSOLE(扇出语义、多页面同时看一轮)、API(task_draft/create、snapshot 字段)、 MCP/MCP_DESIGN/staffdeck/README/ARCHITECTURE:工具数 19→20 + de_snapshot 条目。 - backlog:记一条新发现的缺陷——`mcp_server/platform_client._login()` 会把"登录页 200" 当成登录成功(现场进程缺 `MCP_PLATFORM_PASS` 时表现为含糊的 platform_unavailable)。 自测:真机浏览器端到端(勾上"探索完直接创建")→ 探索 12 步 → 草稿 → 自动建任务成功; `de_snapshot` 直连真机校验;校验器 21 条用例、扇出单元用例、本地工具契约用例全绿。 自测产生的任务/草稿已全部清理(未碰用户既有数据)。
483 lines
19 KiB
JavaScript
483 lines
19 KiB
JavaScript
// ================== 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();
|
||
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 = '<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="createDraft(false)" title="按草稿直接建成任务(可在任务页继续改);不需要人工核对时用它">'
|
||
+ '✓ 直接创建任务</button>'
|
||
+ '<button class="btn" onclick="openDraftInEditor()" title="打开步骤编辑器预填,核对/试跑后再保存">'
|
||
+ '在步骤编辑器中核对</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 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 = '<div class="tg-draft"><h4>✅ 任务已创建:' + esc(job.name||'') + '</h4>'
|
||
+ '<div class="tg-hint">下次运行:' + esc(job.next_run || '手动触发')
|
||
+ ' | 调度:' + esc(tgScheduleText(job.schedule))
|
||
+ ' | id ' + esc(job.id||'') + '</div>'
|
||
+ ((r.warnings||[]).length
|
||
? '<div class="tg-warn">' + r.warnings.map(w=>'· ' + esc(w)).join('<br>') + '</div>'
|
||
: '')
|
||
+ '<div class="tg-btns">'
|
||
+ '<button class="btn btn-primary" onclick="showTab(\'tasks\')">去任务页看</button>'
|
||
+ '<button class="btn" onclick="tgResetDraftView()">再探索一条</button>'
|
||
+ '</div></div>';
|
||
}
|
||
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;
|
||
}
|
||
});
|
||
}
|