用户报的"探索完无法点击创建任务"真因:一个 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 条用例、扇出单元用例、本地工具契约用例全绿。 自测产生的任务/草稿已全部清理(未碰用户既有数据)。
854 lines
36 KiB
JavaScript
854 lines
36 KiB
JavaScript
// 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 = '';
|
||
// 目标设备显示文案:名称优先(设备身份),没有名称退地址
|
||
let _agentTargets={}; // serial -> name
|
||
function _agentTargetText(serial){
|
||
const nm=_agentTargets[serial];
|
||
return nm ? (nm + ' · ' + serial) : String(serial||'');
|
||
}
|
||
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;
|
||
// 建任务模式(designer)的运行由「AI 建任务」子页订阅。这里必须让开:
|
||
// 服务端一个 run 只有一个事件队列,两个 EventSource 同时消费会互相瓜分事件。
|
||
if(r.mode === 'designer') 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);
|
||
_agentTargets = {};
|
||
devs.forEach(d=>{ if(d.name) _agentTargets[d.serial]=d.name; });
|
||
sel.innerHTML = '<option value="">请选择设备(AI 只操作选定设备)…</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;
|
||
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 = '将操作:' + _agentTargetText(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';
|
||
// 运行槽是全平台唯一的:建任务探索在跑时,聊天页也不能再发起(如实显示)
|
||
const tag = document.getElementById('agent-running-tag');
|
||
tag.style.display = running ? 'inline' : 'none';
|
||
tag.textContent = running && r.mode === 'designer' ? '● 建任务探索中' : '● 运行中';
|
||
document.getElementById('btn-agent-stop').style.display = running ? 'inline-block' : 'none';
|
||
const hint = document.getElementById('agent-target-hint');
|
||
if(hint){
|
||
const dev = r.serial ? ' · ' + _agentTargetText(r.serial) : '';
|
||
hint.textContent = running
|
||
? ((r.mode === 'designer' ? '⏳ AI 正在「AI 建任务」页探索设备'
|
||
: '⏳ 运行中' + (r.started ? ' ' + r.started + ' 起' : ''))
|
||
+ dev + ':' + (r.prompt || '').slice(0, 70))
|
||
: (document.getElementById('agent-target-select').value
|
||
? '将操作:' + _agentTargetText(document.getElementById('agent-target-select').value)
|
||
: '');
|
||
}
|
||
document.getElementById('btn-agent-send').disabled = running;
|
||
// 运行中同步 token(本窗口没订阅事件流时也能看到进度)
|
||
if(running && !_agentStream && r.usage && r.usage.total_tokens){
|
||
_liveUsage = r.usage;
|
||
updateSessionTokens();
|
||
}
|
||
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';
|
||
// 结构:工具卡片 → 可折叠推理链 → 正文(Markdown)→ token 用量脚注
|
||
div.innerHTML =
|
||
'<div class="agent-toolcards"></div>'
|
||
+ '<details class="reasoning" style="display:none"><summary>💭 思考过程</summary>'
|
||
+ '<div class="reasoning-text"></div></details>'
|
||
+ '<div class="agent-text"></div>'
|
||
+ '<div class="agent-usage" style="display:none"></div>';
|
||
chat.appendChild(div);
|
||
scrollChat();
|
||
return div;
|
||
}
|
||
|
||
// ---------- 推理链(可折叠) ----------
|
||
function _appendReasoning(msgEl, text){
|
||
const box = msgEl.querySelector('.reasoning');
|
||
if(!box) return;
|
||
const body = box.querySelector('.reasoning-text');
|
||
body.textContent += text;
|
||
box.querySelector('summary').textContent =
|
||
'💭 思考过程(' + body.textContent.length + ' 字)';
|
||
box.style.display = 'block';
|
||
_bindReasoning(box);
|
||
if(!box.open) box.open = true; // 流式思考时展开,正文开始时自动收起
|
||
}
|
||
// 用户手动点过 summary 后不再自动收起/展开(尊重手动状态)
|
||
function _bindReasoning(box){
|
||
const sum = box.querySelector('summary');
|
||
if(sum && !sum.dataset.bound){
|
||
sum.dataset.bound = '1';
|
||
sum.addEventListener('click', ()=>{ box.dataset.touched = '1'; });
|
||
}
|
||
}
|
||
|
||
// ---------- 正文 Markdown 渲染(渲染器见 markdown.js) ----------
|
||
function _renderMd(msgEl){
|
||
const el = msgEl.querySelector('.agent-text');
|
||
if(el) el.innerHTML = renderMarkdown(msgEl._md || '');
|
||
}
|
||
function _scheduleMd(msgEl){
|
||
if(msgEl._mdRaf) return; // 流式增量按帧合并渲染,避免每个 token 重排
|
||
msgEl._mdRaf = requestAnimationFrame(()=>{
|
||
msgEl._mdRaf = 0;
|
||
_renderMd(msgEl);
|
||
scrollChat();
|
||
});
|
||
}
|
||
|
||
// ---------- token 用量 ----------
|
||
let _convUsage = {}; // 当前会话历史累计(读会话时算)
|
||
let _liveUsage = {}; // 本轮运行中累计(SSE usage 事件)
|
||
function _fmtInt(n){ return String(n || 0).replace(/\B(?=(\d{3})+(?!\d))/g, ','); }
|
||
function _usageSum(a, b){
|
||
const o = {prompt_tokens:0, completion_tokens:0, total_tokens:0, calls:0};
|
||
[a, b].forEach(u=>{
|
||
if(!u) return;
|
||
o.prompt_tokens += (u.prompt_tokens || 0);
|
||
o.completion_tokens += (u.completion_tokens || 0);
|
||
o.total_tokens += (u.total_tokens || 0);
|
||
o.calls += (u.calls || 0);
|
||
});
|
||
return o;
|
||
}
|
||
// 单条消息脚注
|
||
function renderUsage(msgEl, u){
|
||
const el = msgEl.querySelector('.agent-usage');
|
||
if(!el) return;
|
||
if(!u || !u.total_tokens){ el.style.display = 'none'; return; }
|
||
el.style.display = 'block';
|
||
el.title = '输入 ' + _fmtInt(u.prompt_tokens) + ' + 输出 ' + _fmtInt(u.completion_tokens)
|
||
+ ' = ' + _fmtInt(u.total_tokens) + ' tokens,共 ' + (u.calls || 0) + ' 次模型调用';
|
||
el.textContent = '🪙 ' + _fmtInt(u.total_tokens) + ' tokens'
|
||
+ '(↑' + _fmtInt(u.prompt_tokens) + ' ↓' + _fmtInt(u.completion_tokens)
|
||
+ ' · ' + (u.calls || 0) + ' 次调用)';
|
||
}
|
||
// 顶栏「本会话累计」= 已落库历史 + 运行中本轮
|
||
function updateSessionTokens(){
|
||
const el = document.getElementById('agent-token-total');
|
||
if(!el) return;
|
||
const t = _usageSum(_convUsage, _liveUsage);
|
||
if(!t.total_tokens){ el.textContent = ''; el.title = ''; return; }
|
||
el.textContent = '🪙 本会话 ' + _fmtInt(t.total_tokens) + ' tokens';
|
||
el.title = '本会话累计:输入 ' + _fmtInt(t.prompt_tokens)
|
||
+ ' + 输出 ' + _fmtInt(t.completion_tokens)
|
||
+ ' = ' + _fmtInt(t.total_tokens) + ' tokens,共 ' + t.calls + ' 次模型调用';
|
||
}
|
||
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 = '';
|
||
_convUsage = {}; // 换会话:token 累计重新算
|
||
_liveUsage = {};
|
||
updateSessionTokens();
|
||
}
|
||
|
||
// ================== 历史会话(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 || '新会话';
|
||
const sid = String(c.id || '');
|
||
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+' 轮' : '')
|
||
+' · <span style="font-family:monospace;opacity:.75" title="会话 ID:'+esc(sid)+'(点击复制)" '
|
||
+'onclick="event.stopPropagation();copyConvId(\''+sid+'\')">#'+esc(sid.slice(0,8))+'</span>'
|
||
+'</div></div>';
|
||
}).join('');
|
||
});
|
||
}
|
||
// 复制会话 ID(便于反馈问题时引用,如 conv=2ba4da0e43)
|
||
function copyConvId(id){
|
||
const done = ()=>showToast('会话 ID 已复制: '+id,'success');
|
||
try{
|
||
if(navigator.clipboard && navigator.clipboard.writeText){
|
||
navigator.clipboard.writeText(id).then(done).catch(()=>fallback());
|
||
return;
|
||
}
|
||
}catch(e){}
|
||
fallback();
|
||
function fallback(){
|
||
const ta=document.createElement('textarea');
|
||
ta.value=id; document.body.appendChild(ta); ta.select();
|
||
try{ document.execCommand('copy'); done(); }catch(e){ showToast('会话 ID: '+id,'success'); }
|
||
ta.remove();
|
||
}
|
||
}
|
||
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 = '';
|
||
_convUsage = {};
|
||
_liveUsage = {};
|
||
(r.messages||[]).forEach(m=>{
|
||
const txt = (m.content||'').trim();
|
||
if(m.role==='user'){ if(txt) addUserMsg(txt); return; }
|
||
if(!txt && !m.reasoning && !(m.usage && m.usage.total_tokens)) return;
|
||
const div = newAssistantMsg();
|
||
if(txt){ div._md = txt; _renderMd(div); }
|
||
if(m.reasoning){
|
||
_appendReasoning(div, m.reasoning);
|
||
const box = div.querySelector('.reasoning');
|
||
_bindReasoning(box);
|
||
box.open = false; // 历史消息默认折叠推理链
|
||
}
|
||
if(m.usage){
|
||
renderUsage(div, m.usage);
|
||
_convUsage = _usageSum(_convUsage, m.usage);
|
||
}
|
||
});
|
||
updateSessionTokens();
|
||
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);
|
||
if(d.kind === 'reasoning'){
|
||
_appendReasoning(msgEl, d.text || '');
|
||
}else{
|
||
// 正文开始 → 收起推理链(用户手动开过的保持原样)
|
||
const r = msgEl.querySelector('.reasoning');
|
||
if(r && r.open && !r.dataset.touched) r.open = false;
|
||
msgEl._md = (msgEl._md || '') + (d.text || '');
|
||
msgEl._mdPending = false;
|
||
_scheduleMd(msgEl);
|
||
}
|
||
scrollChat();
|
||
});
|
||
|
||
// 每次模型调用完成 → 实时刷新 token 计数(本轮累计)
|
||
es.addEventListener('usage', ev=>{
|
||
if(!msgEl) msgEl = newAssistantMsg();
|
||
let d = {};
|
||
try{ d = JSON.parse(ev.data) || {}; }catch(e){}
|
||
_liveUsage = d;
|
||
renderUsage(msgEl, d);
|
||
updateSessionTokens();
|
||
});
|
||
|
||
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 || d.usage || !msgEl){
|
||
if(!msgEl) msgEl = newAssistantMsg();
|
||
if(d.answer){ msgEl._md = d.answer; _renderMd(msgEl); }
|
||
if(d.usage) renderUsage(msgEl, d.usage);
|
||
}
|
||
// 本轮用量并入会话累计(脚注已展示,顶栏不该因此变小),再清空「运行中」
|
||
_convUsage = _usageSum(_convUsage, _liveUsage);
|
||
_liveUsage = {};
|
||
updateSessionTokens();
|
||
scrollChat();
|
||
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);
|
||
_liveUsage = {}; // 本轮可能已花费 token,但结果无效——不并入会话累计
|
||
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=>{
|
||
// 建任务模式(designer)归「AI 建任务」子页订阅,这里同样要让开
|
||
if(r&&r.ok&&r.state==='running'&&r.run_id&&r.mode!=='designer')
|
||
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;
|
||
// items 带设备名称(设备身份);老接口只有 serial 列表时兜底
|
||
const items = (r.items && r.items.length ? r.items
|
||
: (r.devices||[]).map(x=>({serial:x, name:''})))
|
||
.filter(x=>String(x.serial||'').indexOf(':')>=0);
|
||
sel.innerHTML = '<option value="">选择设备观看…</option>'
|
||
+ items.map(x=>'<option value="'+esc(x.serial)+'"'+(x.serial===cur?' selected':'')+'>'
|
||
+esc(devText(x.name, x.serial))+'</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');
|
||
});
|
||
}
|