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。
417 lines
16 KiB
JavaScript
417 lines
16 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();
|
||
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;
|
||
}
|
||
});
|
||
}
|