Files
auto_control/static/admin/agent.js
T

587 lines
23 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;
// ================== 初始化 ==================
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 = '⚠ ' + 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 = '⚠ ' + msg;
showToast(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 = '⚠ ' + 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();
}
// ================== 经验库(自进化记忆管理) ==================
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');
});
}