453 lines
18 KiB
JavaScript
453 lines
18 KiB
JavaScript
// 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();
|
||
// 输入框快捷键
|
||
const inp = document.getElementById('agent-input');
|
||
inp.addEventListener('keydown', ev=>{
|
||
if(ev.key==='Enter' && !ev.shiftKey){
|
||
ev.preventDefault();
|
||
if(!_agentBusy) sendAgentMsg();
|
||
}
|
||
});
|
||
}
|
||
|
||
// ================== 配置 ==================
|
||
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 =
|
||
'<div class="agent-empty">对话已清空。给 AI 下达新指令…</div>';
|
||
}
|
||
// 新建会话:清后端历史 + 清前端对话区(像 DeepSeek 的新建会话)
|
||
function newAgentSession(){
|
||
if(_agentBusy){showToast('运行中不能新建会话','error');return;}
|
||
if(!confirm('新建会话将清空当前对话历史,确定?'))return;
|
||
apiPost('/api/agent/clear',{}).then(r=>{
|
||
if(r&&r.ok){
|
||
clearAgentChat();
|
||
showToast('已新建会话','success');
|
||
}else showToast('操作失败','error');
|
||
});
|
||
}
|
||
|
||
// ================== 发送与流式接收 ==================
|
||
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);
|
||
|
||
apiPost('/api/agent/run', {prompt: text, serial: serial}).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);
|
||
});
|
||
}
|
||
|
||
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();
|
||
});
|
||
|
||
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 = ()=>{
|
||
// 事件流正常结束(done 后服务器关流)会触发一次 error——done 已处理则忽略
|
||
if(_agentBusy) endRun();
|
||
};
|
||
}
|
||
|
||
// ============ 实时画面(右侧,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');
|
||
});
|
||
}
|