Files
auto_control/static/admin/agent.js
T
butubb 91b2785c6b feat: 动作库查看/管理(接口 + AI 控制台「🎬 动作库」面板)+ 未沉淀灰卡提示
- 后端:GET /api/agent/actions(列表)、POST /api/agent/actions/delete、
  POST /api/agent/actions/save(新增/编辑,steps 走 _sanitize_actions 校验:
  type 白名单 + 必填 + **拒绝坐标 click_xy**),均 @admin_required
- 前端:AI 控制台右上角新增「🎬 动作库」按钮与模态框——列出 名称/app/别名/
  步骤摘要(含元素定位)/命中次数/更新时间;支持编辑(表单 + steps JSON)、删除、
  手动新建;保存失败(如写坐标)即时 toast 原因
- 未沉淀提示:本轮无可沉淀动作时也推送一张 🧠 动作经验 灰卡
  「本轮未沉淀动作(步骤以坐标定位为主,缺少可复用的元素定位信息)」
- doc/API.md:登记三个新接口
实测:列表返回自动沉淀的动作;保存合法动作 200;提交 click_xy 步骤被 400 拒绝;
删除生效
2026-09-10 13:58:38 +08:00

697 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// AI 控制台(顶级 Tab):DeepSeek 风格聊天 + 流式输出 + 实时 MCP 步骤
let _agentBusy = false;
let _agentStream = null; // EventSource
let _agentCfgLoaded = false;
// 后端异常文案兜底:MCP 客户端在工具服务不可达时只抛
// "Server returned an error response" 这类含糊字样,这里映射成明确指引。
function _friendlyAgentError(msg){
const raw = String(msg || '');
const s = raw.replace(/^[A-Za-z_]*Error:\s*/, ''); // 去掉 "RuntimeError: " 之类前缀
if(/不可达|连接中断/.test(s)) return s; // 后端已给出明确文案,直接用
if(/mcp_server|server returned an error response|connecterror|connection refused|all connection attempts failed|server disconnected|network is unreachable|connect timeout/i.test(s))
return 'MCP server(8033) 不可达:AI 无法连接设备工具服务,请确认 MCP server 已启动(本机可运行 python -m mcp_server.mcp_server)';
return raw;
}
// ================== 初始化 ==================
let _agentDefaultSerial = '';
function initAgentChat(){
if(_agentCfgLoaded)return;
_agentCfgLoaded = true;
loadAgentConfig();
const chat = document.getElementById('agent-chat');
if(chat && !chat.children.length){
chat.innerHTML = '<div class="agent-empty">👋 给 AI 下达指令,它将通过截图观察手机并执行操作。<br>'
+ '例如:「打开抖音搜索奚学东,告诉我第一个视频的标题」<br>'
+ '先选「🎯 目标设备」(AI 只操作你选定的设备),点击右上角 ⚙ 配置模型与 API Key。</div>';
}
bindChatScroll();
loadLiveDevices();
loadAgentTargetDevices();
startRunPoll();
renderConvList(); // 会话列表(决定当前会话)
loadCurrentConvMessages(); // 渲染当前会话历史
restoreRunningFlow(); // 若任务运行中:重订阅事件流
// 输入框快捷键
const inp = document.getElementById('agent-input');
inp.addEventListener('keydown', ev=>{
if(ev.key==='Enter' && !ev.shiftKey){
ev.preventDefault();
if(!_agentBusy) sendAgentMsg();
}
});
}
// ================== 页面刷新/重进恢复(运行中任务续流) ==================
// 历史轮次由 loadCurrentConvMessages 从会话渲染;这里只处理「任务仍在后台跑」:
// 重新订阅事件流(服务端队列保留积压,重连后补发 delta/step/done)。
function restoreRunningFlow(){
apiGet('/api/agent/run').then(r=>{
if(!r||!r.ok) return;
if(r.state === 'running' && r.run_id){
setRunning(true);
listenStream(r.run_id);
}else if(r.state === 'error' && r.error && _currentConvId){
// 错误提示仅当本会话相关时展示
const chat = document.getElementById('agent-chat');
if(chat && !chat.children.length){
const div = newAssistantMsg();
div.querySelector('.agent-text').textContent = '⚠ ' + _friendlyAgentError(r.error);
}
}
});
}
// ================== 配置 ==================
function loadAgentConfig(){
apiGet('/api/agent/config').then(r=>{
if(!r||!r.ok)return;
const tag = document.getElementById('agent-model-tag');
if(tag && r.model) tag.textContent = r.model;
_agentDefaultSerial = r.default_serial || '';
});
}
// ================== 目标设备选择(AI 只操作选定设备) ==================
function loadAgentTargetDevices(){
apiGet('/api/agent/devices').then(r=>{
if(!r||!r.ok)return;
const sel = document.getElementById('agent-target-select');
if(!sel)return;
const cur = sel.value;
const devs = (r.devices||[]).filter(x=>x.online);
sel.innerHTML = '<option value="">请选择设备(AI 只操作选定设备)…</option>'
+ devs.map(d=>{
const label = 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;
else if(_agentDefaultSerial && [...sel.options].some(o=>o.value===_agentDefaultSerial))
sel.value = _agentDefaultSerial;
const hint = document.getElementById('agent-target-hint');
if(hint && sel.value) hint.textContent = '将操作:' + sel.value;
});
}
// ================== 跨窗口运行状态(多人/多窗口可见并可停止) ==================
let _runPoll = null;
function startRunPoll(){
if(_runPoll) return;
_runPoll = setInterval(pollRunState, 8000);
}
function pollRunState(){
if(_agentStream) return; // 本窗口正在跑(事件流驱动),不轮询
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';
document.getElementById('btn-agent-stop').style.display = running ? 'inline-block' : 'none';
const hint = document.getElementById('agent-target-hint');
if(hint){
hint.textContent = running
? ('⏳ 运行中' + (r.started ? ' ' + r.started + ' 起' : '')
+ (r.serial ? ' · ' + r.serial : '')
+ ':' + (r.prompt||'').slice(0,70))
: (document.getElementById('agent-target-select').value
? '将操作:' + document.getElementById('agent-target-select').value : '');
}
document.getElementById('btn-agent-send').disabled = running;
if(!running && _agentBusy){ setRunning(false); } // 流异常丢失时复位
});
}
function openAgentConfig(){
apiGet('/api/agent/config').then(r=>{
if(!r||!r.ok)return;
document.getElementById('acfg-base').value = r.api_base || 'https://api.deepseek.com';
document.getElementById('acfg-model').value = r.model || '';
document.getElementById('acfg-serial').value = r.default_serial || '';
document.getElementById('acfg-steps').value = r.max_steps || 40;
document.getElementById('acfg-key-hint').textContent =
r.api_key_masked ? ('已配置 ' + r.api_key_masked) : '未配置';
document.getElementById('acfg-key').value = '';
document.getElementById('agent-cfg-overlay').style.display = 'flex';
});
}
function closeAgentConfig(){
document.getElementById('agent-cfg-overlay').style.display = 'none';
}
function saveAgentConfig(){
const body = {
api_base: document.getElementById('acfg-base').value.trim(),
model: document.getElementById('acfg-model').value.trim(),
default_serial: document.getElementById('acfg-serial').value.trim(),
max_steps: document.getElementById('acfg-steps').value.trim(),
};
const key = document.getElementById('acfg-key').value.trim();
if(key) body.api_key = key;
apiPost('/api/agent/config', body).then(r=>{
if(r&&r.ok){
showToast('配置已保存','success');
closeAgentConfig();
loadAgentConfig();
loadAgentTargetDevices();
}else showToast('保存失败: ' + ((r&&r.error)||''),'error');
});
}
// ================== 聊天渲染 ==================
function addUserMsg(text){
const chat = document.getElementById('agent-chat');
clearEmpty();
const div = document.createElement('div');
div.className = 'agent-msg user';
div.textContent = text;
chat.appendChild(div);
scrollChat();
}
function newAssistantMsg(){
const chat = document.getElementById('agent-chat');
clearEmpty();
const div = document.createElement('div');
div.className = 'agent-msg assistant';
div.innerHTML = '<div class="agent-toolcards"></div><div class="agent-text"></div>';
chat.appendChild(div);
scrollChat();
return div;
}
function clearEmpty(){
const empty = document.querySelector('#agent-chat .agent-empty');
if(empty) empty.remove();
}
let _chatPinned = true; // 用户是否在底部(贴底才自动跟随滚动)
function scrollChat(force){
const chat = document.getElementById('agent-chat');
if(force || _chatPinned){
chat.scrollTop = chat.scrollHeight;
}
}
function bindChatScroll(){
const chat = document.getElementById('agent-chat');
chat.addEventListener('scroll', ()=>{
_chatPinned = chat.scrollHeight - chat.scrollTop - chat.clientHeight < 60;
});
}
function clearAgentChat(){
document.getElementById('agent-chat').innerHTML = '';
}
// ================== 历史会话(DeepSeek 式:左侧列表,多会话持久化) ==================
let _currentConvId = localStorage.getItem('agent_conv_id') || '';
function saveConvId(id){
_currentConvId = id || '';
if(id) localStorage.setItem('agent_conv_id', id);
else localStorage.removeItem('agent_conv_id');
}
function createAgentSession(){
if(_agentBusy){showToast('运行中不能新建会话','error');return;}
apiPost('/api/agent/conversations',{}).then(r=>{
if(r&&r.ok){
saveConvId(r.id);
clearAgentChat();
showToast('已新建会话','success');
renderConvList();
}else showToast('新建失败: '+((r&&r.error)||''),'error');
});
}
function selectAgentSession(id){
if(_agentBusy){showToast('运行中不能切换会话','error');return;}
if(id === _currentConvId) return;
saveConvId(id);
clearAgentChat();
loadCurrentConvMessages();
renderConvList();
}
function deleteAgentSession(id){
if(!confirm('删除该会话?历史消息将不可恢复。')) return;
apiDelete('/api/agent/conversations/'+id).then(r=>{
if(r&&r.ok){
if(_currentConvId === id) saveConvId('');
showToast('会话已删除','success');
renderConvList();
loadCurrentConvMessages();
}else showToast('删除失败: '+((r&&r.error)||''),'error');
});
}
function renderConvList(){
apiGet('/api/agent/conversations').then(r=>{
if(!r||!r.ok)return;
const list = document.getElementById('agent-sess-list');
if(!list)return;
const convs = r.conversations || [];
let cur = _currentConvId;
if(!convs.some(c=>c.id===cur)) cur = convs.length ? convs[0].id : '';
if(cur !== _currentConvId){ saveConvId(cur); clearAgentChat(); loadCurrentConvMessages(); }
if(!convs.length){
list.innerHTML = '<div class="agent-empty" style="padding:20px 10px;font-size:12px">'
+ '暂无历史会话<br><br>点「+ 新建会话」开始</div>';
return;
}
list.innerHTML = convs.map(c=>{
const t = c.title || '新会话';
return '<div class="agent-sess-item'+(c.id===cur?' active':'')
+'" onclick="selectAgentSession(\''+c.id+'\')">'
+'<button class="agent-sess-del" title="删除会话" '
+'onclick="event.stopPropagation();deleteAgentSession(\''+c.id+'\')">✕</button>'
+'<div class="t">'+esc(t)+'</div>'
+'<div class="m">'+esc(c.updated_at||'')+(c.count ? ' · '+c.count+' 轮' : '')+'</div></div>';
}).join('');
});
}
function loadCurrentConvMessages(){
const chat = document.getElementById('agent-chat');
if(!chat) return;
if(!_currentConvId){
chat.innerHTML = '<div class="agent-empty">👋 点左侧「+ 新建会话」开始,'
+ '给 AI 下达指令(先选 🎯 目标设备)。</div>';
return;
}
apiGet('/api/agent/conversations/'+_currentConvId).then(r=>{
if(!r||!r.ok)return;
chat.innerHTML = '';
(r.messages||[]).forEach(m=>{
const txt = (m.content||'').trim();
if(!txt) return;
if(m.role==='user'){ addUserMsg(txt); }
else{
const div = newAssistantMsg();
div.querySelector('.agent-text').textContent = txt;
}
});
if(!chat.children.length){
chat.innerHTML = '<div class="agent-empty">新会话。给 AI 下达指令'
+ '(先选 🎯 目标设备)…</div>';
}
});
}
// ================== 发送与流式接收 ==================
function setRunning(on){
_agentBusy = on;
document.getElementById('agent-running-tag').style.display = on ? 'inline' : 'none';
document.getElementById('btn-agent-stop').style.display = on ? 'inline-block' : 'none';
document.getElementById('btn-agent-send').disabled = on;
}
function stopAgent(){
if(!confirm('确定停止当前 AI 运行任务?\n(其他人发起的任务也会被停止)')) return;
apiPost('/api/agent/stop',{}).then(r=>{
if(r&&r.ok) showToast('已请求停止,正在中断…','success');
else showToast((r&&r.error)||'停止失败','error');
});
}
function sendAgentMsg(){
if(_agentBusy){showToast('上一轮还在运行','error');return;}
const sel = document.getElementById('agent-target-select');
const serial = sel ? sel.value : '';
if(!serial){
showToast('请先选择目标设备(AI 只操作你选定的设备;任务中的设备不可选)','error');
if(sel) sel.focus();
return;
}
const inp = document.getElementById('agent-input');
const text = inp.value.trim();
if(!text){showToast('请输入指令','error');return;}
inp.value = '';
addUserMsg(text);
// 会话:无当前会话时自动新建(DeepSeek 式:消息总归属于一个会话)
const doRun = (convId)=>{
apiPost('/api/agent/run', {prompt: text, serial: serial,
conversation_id: convId}).then(r=>{
if(!r||!r.ok){
const msg = r ? (r.error||'启动失败') : '请求失败';
const div = newAssistantMsg();
div.querySelector('.agent-text').textContent = '⚠ ' + _friendlyAgentError(msg);
showToast(_friendlyAgentError(msg),'error');
return;
}
setRunning(true);
listenStream(r.run_id);
});
};
if(_currentConvId){
doRun(_currentConvId);
}else{
apiPost('/api/agent/conversations',{}).then(r=>{
if(r&&r.ok){
saveConvId(r.id);
renderConvList();
doRun(r.id);
}else{
const div = newAssistantMsg();
div.querySelector('.agent-text').textContent = '⚠ 新建会话失败';
showToast('新建会话失败','error');
}
});
}
}
function listenStream(runId){
if(_agentStream) _agentStream.close();
const es = new EventSource('/api/agent/stream?run_id=' + runId);
_agentStream = es;
let msgEl = null;
es.addEventListener('delta', ev=>{
if(!msgEl) msgEl = newAssistantMsg();
const d = JSON.parse(ev.data);
const textEl = msgEl.querySelector('.agent-text');
if(d.kind === 'reasoning'){
let r = msgEl.querySelector('.reasoning');
if(!r){
r = document.createElement('div');
r.className = 'reasoning';
msgEl.insertBefore(r, textEl);
}
r.textContent += d.text;
}else{
textEl.textContent += d.text;
}
scrollChat();
});
es.addEventListener('step', ev=>{
const d = JSON.parse(ev.data);
followSerialFromArgs(d.args);
if(!msgEl) msgEl = newAssistantMsg();
const cards = msgEl.querySelector('.agent-toolcards');
const card = document.createElement('div');
card.className = 'agent-toolcard';
const argsTxt = typeof d.args === 'string' ? d.args
: JSON.stringify(d.args || {});
card.innerHTML = '<span class="dot"></span>'
+ '<code style="color:var(--teal)">' + esc(d.tool||'') + '</code>'
+ '<span class="text-muted">' + esc(argsTxt) + '</span>';
if(d.image){
const img = document.createElement('img');
img.src = 'data:image/jpeg;base64,' + d.image;
card.appendChild(img);
}
cards.appendChild(card);
scrollChat();
});
es.addEventListener('done', ev=>{
const d = JSON.parse(ev.data);
if(d.answer){
if(!msgEl) msgEl = newAssistantMsg();
msgEl.querySelector('.agent-text').textContent = d.answer;
}
endRun();
renderConvList(); // 落库已完成:刷新标题/时间/轮数
});
es.addEventListener('error', ev=>{
let msg = '连接中断';
try{
if(ev.data) msg = JSON.parse(ev.data).message || msg;
}catch(e){}
if(!msgEl) msgEl = newAssistantMsg();
msgEl.querySelector('.agent-text').textContent = '⚠ ' + _friendlyAgentError(msg);
endRun();
});
es.onerror = ()=>{
// 重要:不能在这里 endRun()——断网/后台标签页节流/服务端瞬时抖动都会触发
// onerror,但后端任务仍在跑。EventSource 会自动重连,服务端事件队列保留
// 积压(delta/step/done),重连成功后全部补发,界面无缝续上。
// 只有连接彻底关闭(非自动重连态)且任务还在跑时才提示。
if(_agentBusy && es.readyState === EventSource.CLOSED){
const hint = document.getElementById('agent-target-hint');
if(hint) hint.textContent = '⚠ 实时连接已中断,任务仍在后台运行——可刷新页面恢复查看';
// 连接彻底失败时周期探测,恢复后重订阅
setTimeout(()=>{
if(!_agentStream && _agentBusy){
apiGet('/api/agent/run').then(r=>{
if(r&&r.ok&&r.state==='running'&&r.run_id) listenStream(r.run_id);
});
}
}, 5000);
}
};
}
// ============ 实时画面(右侧,MJPEG 流) ============
let _liveSerial = '';
function showLive(serial){
if(!serial || serial === _liveSerial) return;
_liveSerial = serial;
const img = document.getElementById('agent-live-img');
img.src = '/api/screen/stream?serial=' + encodeURIComponent(serial) + '&q=80&fps=8&t=' + Date.now();
img.style.display = 'block';
document.getElementById('agent-live-empty').style.display = 'none';
document.getElementById('agent-live-serial').textContent = serial;
const sel = document.getElementById('agent-live-select');
if(sel) sel.value = serial;
}
function stopLive(){
_liveSerial = '';
const img = document.getElementById('agent-live-img');
img.src = '';
img.style.display = 'none';
document.getElementById('agent-live-empty').style.display = 'block';
document.getElementById('agent-live-serial').textContent = '';
}
function followSerialFromArgs(args){
// 工具 args(后端 SSE 已序列化为对象;兼容历史字符串格式)
let a = args;
if(typeof args === 'string'){
try{ a = JSON.parse(args); }catch(e){ return; }
}
if(a && a.serial) showLive(a.serial);
}
function watchDevice(serial){
if(!serial){ stopLive(); return; }
showLive(serial);
}
function loadLiveDevices(){
apiGet('/api/devices').then(r=>{
if(!r||!r.ok)return;
const sel = document.getElementById('agent-live-select');
if(!sel)return;
const cur = sel.value;
const devs = (r.devices||[]).filter(x=>x.indexOf(':')>=0);
sel.innerHTML = '<option value="">选择设备观看…</option>'
+ devs.map(s=>'<option value="'+esc(s)+'"'+(s===cur?' selected':'')+'>'+esc(s)+'</option>').join('');
});
}
// 截图点击放大查看
function zoomScreenshot(img){
const ov = document.getElementById('agent-zoom');
if(!ov){
const d = document.createElement('div');
d.id = 'agent-zoom';
d.style.cssText = 'display:none;position:fixed;inset:0;background:rgba(0,0,0,.8);z-index:1400;align-items:center;justify-content:center;cursor:zoom-out';
d.onclick = ()=>d.style.display='none';
document.body.appendChild(d);
}
const ovEl = document.getElementById('agent-zoom');
ovEl.innerHTML = '';
const big = document.createElement('img');
big.src = img.src;
big.style.cssText = 'max-width:88vw;max-height:88vh;border-radius:8px';
ovEl.appendChild(big);
ovEl.style.display = 'flex';
}
// 截图 img 绑定点击放大
document.addEventListener('click', ev=>{
const t = ev.target;
if(t && t.tagName==='IMG' && t.closest('.agent-toolcard') && !t.closest('#agent-zoom')){
zoomScreenshot(t);
}
});
function endRun(){
setRunning(false);
_agentStream && _agentStream.close();
_agentStream = null;
scrollChat();
}
// ================== 动作库(可复用动作:查看/编辑/删除/新建) ==================
let _actCache = [];
function openActLib(){
document.getElementById('agent-act-overlay').style.display = 'flex';
loadActLib();
}
function closeActLib(){
document.getElementById('agent-act-overlay').style.display = 'none';
}
function _actStepBrief(a){
return (a.steps||[]).map(s=>{
const p = s.params || {};
const loc = p.selector_value || p.package || p.fixed_text || '';
return (s.type || '?') + (loc ? (':' + loc) : '');
}).join(' → ');
}
function loadActLib(){
const list = document.getElementById('act-lib-list');
apiGet('/api/agent/actions').then(r=>{
if(!r||!r.ok){ showToast((r&&r.error)||'加载失败','error'); return; }
_actCache = r.actions || [];
document.getElementById('act-lib-hint').textContent = '共 ' + _actCache.length + ' 个动作';
if(!_actCache.length){
list.innerHTML = '<div class="text-muted" style="padding:20px;text-align:center">暂无动作——跑一轮以元素/文字点击为主的任务后会自动沉淀</div>';
return;
}
list.innerHTML = '';
_actCache.forEach(a=>{
const card = document.createElement('div');
card.style.cssText = 'border:1px solid var(--card-line);border-radius:10px;padding:9px 13px;background:#0d1117';
card.innerHTML = '<div style="display:flex;justify-content:space-between;gap:10px;align-items:flex-start">'
+ '<div style="flex:1;min-width:0">'
+ '<div style="font-weight:600">' + esc(a.name||'')
+ (a.app ? '<span class="text-muted" style="font-size:11px;font-weight:400;margin-left:6px">' + esc(a.app) + '</span>' : '')
+ '</div>'
+ '<div style="font-size:11.5px;color:#9fb3d1;margin-top:2px;word-break:break-all">' + esc(_actStepBrief(a) || '—') + '</div>'
+ '<div class="text-muted" style="font-size:11px;margin-top:2px">别名: ' + esc((a.aliases||[]).join('、')||'—')
+ ' | 命中 ' + (a.hits||0) + ' 次 | ' + esc(a.updated_at||'') + '</div>'
+ '</div>'
+ '<div style="display:flex;flex-direction:column;gap:4px;flex:none">'
+ '<button class="btn btn-xs" onclick="actEdit(' + a.id + ')">编辑</button>'
+ '<button class="btn btn-xs btn-danger" onclick="actDelete(' + a.id + ')">删除</button>'
+ '</div></div>';
list.appendChild(card);
});
});
}
function actDelete(id){
if(!confirm('确认删除该动作?删除后同类任务不再自动复用它。')) return;
apiPost('/api/agent/actions/delete',{id:id}).then(r=>{
showToast((r&&(r.msg||r.error))||'删除失败', (r&&r.ok)?'success':'error');
loadActLib();
});
}
function actNew(){ _actForm({name:'', app:'', aliases:[], params:[], steps:[]}); }
function actEdit(id){
const a = _actCache.find(x=>x.id===id);
if(a) _actForm(a);
}
function _actForm(a){
const list = document.getElementById('act-lib-list');
const isNew = !a.id;
list.innerHTML = '';
const box = document.createElement('div');
box.style.cssText = 'border:1px solid var(--card-line);border-radius:10px;padding:12px 14px;background:#0d1117';
box.innerHTML =
'<div style="font-weight:600;margin-bottom:8px">' + (isNew?'新建动作':'编辑动作 #'+a.id) + '</div>'
+ '<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:6px">'
+ '<input id="act-f-name" class="form-control" style="flex:1;min-width:180px" placeholder="动作名(如 打开抖音)" value="' + esc(a.name||'') + '">'
+ '<input id="act-f-app" class="form-control" style="flex:1;min-width:180px" placeholder="包名(可空,如 com.ss.android.ugc.aweme)" value="' + esc(a.app||'') + '">'
+ '</div>'
+ '<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:6px">'
+ '<input id="act-f-alias" class="form-control" style="flex:1;min-width:180px" placeholder="别名(逗号分隔)" value="' + esc((a.aliases||[]).join(',')) + '">'
+ '<input id="act-f-params" class="form-control" style="flex:1;min-width:180px" placeholder="参数名(逗号分隔,可空)" value="' + esc((a.params||[]).join(',')) + '">'
+ '</div>'
+ '<textarea id="act-f-steps" class="form-control" rows="9" style="font-family:monospace;font-size:12px" '
+ 'placeholder=\'步骤 JSON 数组,例:[{"type":"click","params":{"selector_type":"text","selector_value":"搜索"}}]\'>'
+ esc(JSON.stringify(a.steps||[], null, 2)) + '</textarea>'
+ '<div class="text-muted" style="font-size:11px;margin:6px 0">可用 type:open_app/click/input_text/swipe/wait/key_event/group…;click 必须有 selector_value(xpath/text/resourceId/description…);<b>不接受坐标(click_xy)</b>。</div>'
+ '<div style="display:flex;gap:8px"><button class="btn btn-primary btn-sm" onclick="actSave(' + (a.id||0) + ')">保存</button>'
+ '<button class="btn btn-sm" onclick="loadActLib()">取消</button></div>';
list.appendChild(box);
}
function actSave(id){
const g = (i)=>document.getElementById(i).value;
const split = (s)=>String(s||'').split(/[,,]/).map(x=>x.trim()).filter(x=>x);
let steps;
try{ steps = JSON.parse(g('act-f-steps')||'[]'); }
catch(e){ showToast('steps 不是合法 JSON: '+e.message,'error'); return; }
apiPost('/api/agent/actions/save', {
id: id||0, name: g('act-f-name'), app: g('act-f-app'),
aliases: split(g('act-f-alias')), params: split(g('act-f-params')), steps: steps
}).then(r=>{
if(!r||!r.ok){ showToast((r&&r.error)||'保存失败','error'); return; }
showToast(r.msg||'已保存','success');
loadActLib();
});
}
// ================== 经验库(自进化记忆管理) ==================
function openExpLib(){
document.getElementById('agent-exp-overlay').style.display = 'flex';
loadExpLib();
}
function closeExpLib(){
document.getElementById('agent-exp-overlay').style.display = 'none';
}
function loadExpLib(){
const list = document.getElementById('exp-lib-list');
apiGet('/api/agent/experience').then(r=>{
if(!r||!r.ok){ showToast((r&&r.error)||'加载失败','error'); return; }
document.getElementById('exp-lib-last').textContent =
(r.last ? ('最近巡检 ' + r.last + ':' + (r.last_summary||'')) : '');
if(r.running){
list.innerHTML = '<div class="text-muted" style="padding:20px;text-align:center">🔍 AI 巡检进行中(约 1 分钟),完成后请刷新…</div>';
return;
}
const exps = r.experiences||[];
if(!exps.length){
list.innerHTML = '<div class="text-muted" style="padding:20px;text-align:center">暂无经验——跑一轮带手机操作的任务后会自动沉淀</div>';
return;
}
list.innerHTML = '';
exps.forEach(e=>{
const a = e.audit||{};
const card = document.createElement('div');
card.style.cssText = 'border:1px solid var(--card-line);border-radius:10px;padding:9px 13px;background:#0d1117';
// 徽章
let badge = '';
if(a.action === 'pending' && a.verdict === 'delete'){
badge = '<span style="background:#7f1d1d;color:#fecaca;font-size:10.5px;padding:1px 8px;border-radius:9px;margin-left:8px">⚠ 建议删除</span>';
}else if(a.action === 'kept'){
badge = '<span style="background:#0f3d2e;color:#a7f3d0;font-size:10.5px;padding:1px 8px;border-radius:9px;margin-left:8px">' +
(a.verdict === 'delete' ? '已人工保留' : '✓ 已保留') + '</span>';
}
let actBtns = '';
if(a.action === 'pending'){
actBtns = '<button class="btn btn-xs btn-danger" onclick="expDelete(' + e.id + ')">确认删除</button>'
+ '<button class="btn btn-xs" onclick="expKeep(' + e.id + ')">保留</button>';
}else{
actBtns = '<button class="btn btn-xs" style="border-color:#7f1d1d;color:#f87171" onclick="expDelete(' + e.id + ')" title="强制删除此经验">删除</button>';
}
card.innerHTML =
'<div style="display:flex;align-items:center;gap:6px;font-size:12px">'
+ '<span class="text-muted" style="font-family:var(--mono)">#' + e.id + '</span>'
+ '<span class="text-muted">' + esc(e.created_at||'') + '</span>'
+ '<span class="text-muted">引用 ' + (e.hits||0) + ' 次</span>' + badge
+ '<span style="flex:1"></span>' + actBtns + '</div>'
+ '<div style="font-size:13px;color:#e5e7eb;margin:5px 0 3px">' + esc(e.task_prompt) + '</div>'
+ '<div class="text-muted" style="font-size:11.5px;line-height:1.6;white-space:pre-wrap">' + esc((e.recipe||'').slice(0,220)) + '</div>'
+ (a.action === 'pending' && a.reason
? '<div style="font-size:11.5px;color:#fbbf24;margin-top:5px">AI 建议理由:' + esc(a.reason)
+ (a.score ? '(评分 ' + a.score + '/10)' : '') + '</div>'
: '');
list.appendChild(card);
});
});
}
function triggerExpAudit(){
apiPost('/api/agent/experience/audit',{}).then(r=>{
if(r&&r.ok){
showToast('巡检已启动,约 1 分钟完成','success');
document.getElementById('exp-lib-list').innerHTML =
'<div class="text-muted" style="padding:20px;text-align:center">🔍 AI 巡检进行中…</div>';
// 完成后自动刷新
setTimeout(()=>{ if(document.getElementById('agent-exp-overlay').style.display==='flex') loadExpLib(); }, 90000);
}else showToast((r&&r.error)||'巡检启动失败','error');
});
}
function expDelete(id){
if(!confirm('确认删除该经验?删除后不可恢复(下次相似任务不再自动参考)。')) return;
apiPost('/api/agent/experience/delete',{id:id}).then(r=>{
if(r&&r.ok){ showToast('已删除','success'); loadExpLib(); }
else showToast((r&&r.error)||'删除失败','error');
});
}
function expKeep(id){
apiPost('/api/agent/experience/keep',{id:id}).then(r=>{
if(r&&r.ok){ showToast('已保留,后续不再重复建议','success'); loadExpLib(); }
else showToast((r&&r.error)||'操作失败','error');
});
}