ligbox-ops-platform/projects/ops-desk/frontend/assets/agentic-ops.js
Ligbox Spec Hub 1640a93ae1 fix(desk): eliminar flash "Carregando…" no poll global Ligbox Ops
Refresh suave em todas as vistas, Agentic Ops com poll próprio e
patch incremental só quando dados mudam — sem apagar o ecrã a cada 30s.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 15:36:35 +00:00

1288 lines
52 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.

(function () {
const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const SEV_COLS = [
{ key: 'critical', label: 'Crítico', cls: 'ao-kanban-col--critical' },
{ key: 'high', label: 'Alto', cls: 'ao-kanban-col--high' },
{ key: 'warn', label: 'Aviso', cls: 'ao-kanban-col--warn' },
{ key: 'info', label: 'Info / OK', cls: 'ao-kanban-col--info' },
];
const AGENT_ICONS = {
A0: '🎯', A1: '💓', A2: '🛤', A3: '✉', A4: '🛡', A5: '👁',
A6: '🤖', A7: '🔧', sentinel: '📡', curator: '📚',
};
const AGENT_COLORS = {
A0: '#6366f1', A1: '#22c55e', A2: '#3b82f6', A3: '#06b6d4', A4: '#8b5cf6',
A5: '#ec4899', A6: '#a855f7', A7: '#ef4444', sentinel: '#f59e0b', curator: '#64748b',
};
const SUB_VIEWS = [
{ id: 'command', label: 'Squad Command', icon: '🎯' },
{ id: 'agents', label: 'Agent Management', icon: '👥' },
{ id: 'tasks', label: 'Task Board', icon: '📋' },
{ id: 'memory', label: 'Knowledge Graph', icon: '🧠' },
{ id: 'recurring', label: 'Recurring Tasks', icon: '🔄' },
{ id: 'activity', label: 'Activity Feed', icon: '📡' },
];
let state = {
selectedAgent: 'A6',
selectedIncidentId: null,
threadId: null,
mobileTab: 'board',
mainTab: 'missions',
subView: 'command',
chatModalOpen: false,
kbQuery: '',
lastActions: [],
pollTimer: null,
};
let cache = null;
let extraCache = { scenarios: null, activity: null, kbSources: null, kbHits: null };
let shellMounted = false;
let eventsBound = false;
let renderInFlight = false;
let pendingRender = null;
let lastThreadHtml = '';
let chatInFlight = false;
async function agentsApi(path, opts = {}) {
const h = authHeaders({ ...(opts.headers || {}) });
if (!(opts.body instanceof FormData) && !h['Content-Type']) h['Content-Type'] = 'application/json';
const isSlow = /\/chat$/.test(path) || path.includes('/chat');
const timeoutMs = isSlow ? 180000 : 60000;
const r = await fetchWithTimeout(`/api/v1/agents${path}`, { ...opts, headers: h }, timeoutMs);
if (r.status === 401) {
logout();
throw new Error('sessão expirada — faça login novamente');
}
if (!r.ok) throw new Error(`${r.status} ${(await r.text()).slice(0, 200)}`);
return r.json();
}
function escHtml(s) {
return esc(s);
}
function renderMarkdown(text) {
if (!text) return '';
const lines = String(text).split('\n');
const out = [];
let inList = false;
let inTable = false;
const flushList = () => {
if (inList) { out.push('</ul>'); inList = false; }
};
const flushTable = () => {
if (inTable) { out.push('</tbody></table>'); inTable = false; }
};
for (let i = 0; i < lines.length; i++) {
const raw = lines[i];
const line = raw.trimEnd();
if (/^\|.+\|$/.test(line.trim())) {
flushList();
const cells = line.trim().slice(1, -1).split('|').map((c) => c.trim());
if (/^[-:\s|]+$/.test(line.replace(/\|/g, ''))) continue;
if (!inTable) {
out.push('<table class="ao-md-table"><thead><tr>');
cells.forEach((c) => out.push(`<th>${inlineMd(c)}</th>`));
out.push('</tr></thead><tbody>');
inTable = true;
} else {
out.push('<tr>');
cells.forEach((c) => out.push(`<td>${inlineMd(c)}</td>`));
out.push('</tr>');
}
continue;
}
flushTable();
if (/^---+$/.test(line.trim())) {
flushList();
out.push('<hr class="ao-md-hr">');
} else if (/^### (.+)/.test(line)) {
flushList();
out.push(`<h4 class="ao-md-h">${inlineMd(line.replace(/^### /, ''))}</h4>`);
} else if (/^## (.+)/.test(line)) {
flushList();
out.push(`<h3 class="ao-md-h">${inlineMd(line.replace(/^## /, ''))}</h3>`);
} else if (/^[-*] (.+)/.test(line)) {
if (!inList) { out.push('<ul class="ao-md-ul">'); inList = true; }
out.push(`<li>${inlineMd(line.replace(/^[-*] /, ''))}</li>`);
} else if (/^\d+\. (.+)/.test(line)) {
if (!inList) { out.push('<ol class="ao-md-ul">'); inList = true; }
out.push(`<li>${inlineMd(line.replace(/^\d+\. /, ''))}</li>`);
} else if (!line.trim()) {
flushList();
out.push('');
} else {
flushList();
out.push(`<p class="ao-md-p">${inlineMd(line)}</p>`);
}
}
flushList();
flushTable();
return out.join('');
}
function inlineMd(s) {
return escHtml(s)
.replace(/`([^`]+)`/g, '<code class="ao-md-code">$1</code>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
}
function fmtMsgTime(iso) {
if (!iso) return 'agora';
try {
const d = new Date(iso);
const diff = Date.now() - d.getTime();
if (diff < 60000) return 'agora';
return d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
} catch {
return iso;
}
}
function chatErrorMessage(err) {
const msg = String(err?.message || err || '');
if (msg.includes('429') || msg.toLowerCase().includes('quota') || msg.toLowerCase().includes('balance')) {
return 'Conta KIMI sem saldo — recarregue em platform.moonshot.ai';
}
if (err?.name === 'AbortError' || msg.includes('aborted')) {
return 'Tempo esgotado — tente novamente ou verifique Groq/Ollama.';
}
return msg || 'Erro ao enviar mensagem';
}
function fmtAge(iso) {
if (!iso) return '—';
try {
const ms = Date.now() - new Date(iso).getTime();
const m = Math.floor(ms / 60000);
if (m < 1) return 'agora';
if (m < 60) return `${m} min`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h`;
return `${Math.floor(h / 24)}d`;
} catch {
return iso;
}
}
function liveDotClass(open) {
if ((open.critical || 0) > 0 || (open.high || 0) > 0) return 'ao-live-dot--alert';
if ((open.warn || 0) > 0) return 'ao-live-dot--warn';
return '';
}
function chatTargetLabel() {
if (state.selectedAgent === 'ALL') return 'A6 Copiloto';
const agents = cache?.roster?.agents || [];
const a = agents.find((x) => x.id === state.selectedAgent);
const icon = AGENT_ICONS[state.selectedAgent] || '🤖';
return a ? `${a.name} (${state.selectedAgent}) ${icon}` : `${state.selectedAgent} ${icon}`;
}
function chatAgentId() {
return state.selectedAgent === 'ALL' ? 'A6' : state.selectedAgent;
}
function msgBoxEl(root) {
if (!root) return null;
return root.querySelector(state.chatModalOpen ? '#ao-modal-thread-messages' : '#ao-thread-messages');
}
function chatInputEl(root) {
if (!root) return null;
return root.querySelector(state.chatModalOpen ? '#ao-modal-chat-input' : '#ao-chat-input');
}
function sendBtnEl(root) {
if (!root) return null;
return root.querySelector(state.chatModalOpen ? '#ao-modal-btn-send' : '#ao-btn-send');
}
function groupThreads(threads) {
const waiting = [];
const open = [];
threads.forEach((t) => {
if (t.status !== 'open') return;
if (t.pending_human > 0) waiting.push(t);
else open.push(t);
});
return { waiting, open };
}
function taskCardHtml(t) {
const active = state.threadId === t.id ? ' ao-task-card--active' : '';
const icon = AGENT_ICONS[t.primary_agent] || '🤖';
const badge = t.pending_human > 0 ? `<span class="ao-task-badge">aguarda você</span>` : '';
return `<article class="ao-task-card${active}" data-thread-id="${t.id}" data-task-agent="${esc(t.primary_agent)}">
<div class="ao-task-card-head">
<span class="ao-task-card-icon">${icon}</span>
<div class="ao-task-card-title">${esc(t.subject)}</div>
</div>
<div class="ao-task-card-meta">${esc(t.agent_name || t.primary_agent)} · ${fmtAge(t.updated_at)} · #${t.id}</div>
${badge}
</article>`;
}
function agentPickHtml(agents) {
const picks = agents.filter((a) => !['curator', 'sentinel'].includes(a.id)).slice(0, 8);
return picks.map((a) => {
const active = state.selectedAgent === a.id ? ' ao-agent-pick--active' : '';
const icon = AGENT_ICONS[a.id] || '🤖';
return `<button type="button" class="ao-agent-pick${active}" data-chat-agent="${esc(a.id)}">${icon} ${esc(a.name)}</button>`;
}).join('');
}
function taskBoardHtml(data) {
const threads = data.threads?.threads || [];
const { waiting, open } = groupThreads(threads);
const agents = (data.roster?.agents || []).filter((a) => a.id !== 'curator');
const waitCards = waiting.map(taskCardHtml).join('')
|| '<p class="ticket-meta" style="font-size:0.7rem;opacity:0.5;text-align:center">—</p>';
const openCards = open.slice(0, 12).map(taskCardHtml).join('')
|| '<p class="ticket-meta" style="font-size:0.7rem;opacity:0.5;text-align:center">Nenhuma conversa activa</p>';
return `
<div class="ao-task-board ao-task-board--full">
<div class="ao-task-col ao-task-col--wait"><h4>Aguardando você</h4>${waitCards}</div>
<div class="ao-task-col ao-task-col--open"><h4>Conversas abertas</h4>${openCards}</div>
<div class="ao-task-col ao-task-col--new">
<h4>Nova conversa</h4>
<div class="ao-new-chat-box">
<p><strong>Como conversar:</strong> escolha um agente, clique <strong>Abrir chat</strong> ou duplo-clique no Squad. O chat abre em modal grande.</p>
<div class="ao-agent-picks" id="ao-agent-picks">${agentPickHtml(agents)}</div>
<button type="button" class="ao-btn-mission" id="ao-btn-new-chat" style="width:100%">💬 Abrir chat com ${esc(chatAgentId())}</button>
</div>
</div>
</div>`;
}
function deriveView(data) {
const list = data.incidents.incidents || [];
const openAgents = new Set(list.map((i) => i.primary_agent));
const counts = {};
list.forEach((i) => { counts[i.primary_agent] = (counts[i.primary_agent] || 0) + 1; });
const filtered = state.selectedAgent && state.selectedAgent !== 'ALL'
? list.filter((i) => i.primary_agent === state.selectedAgent)
: list;
const bySev = { critical: [], high: [], warn: [], info: [] };
filtered.forEach((inc) => {
const k = bySev[inc.severity] ? inc.severity : 'warn';
(bySev[k] || bySev.warn).push(inc);
});
const sel = list.find((i) => i.id === state.selectedIncidentId) || null;
return { list, openAgents, counts, filtered, bySev, sel };
}
function incidentsFingerprint(data) {
return (data.incidents?.incidents || [])
.map((i) => `${i.id}:${i.status}:${i.severity}:${i.last_seen_at || ''}`)
.join('|');
}
function timelineFingerprint(timeline) {
return JSON.stringify(timeline?.ticks || []);
}
function agentStatus(id, openAgents) {
if (openAgents.has(id)) return { cls: 'ao-squad-status--alert', label: 'alerta' };
if (state.selectedAgent === id) return { cls: 'ao-squad-status--active', label: 'activo' };
return { cls: 'ao-squad-status--standby', label: 'standby' };
}
function squadCardHtml(a, openAgents, counts) {
const selected = state.selectedAgent === a.id ? ' ao-squad-card--selected' : '';
const alert = openAgents.has(a.id) ? ' ao-squad-card--alert' : '';
const st = agentStatus(a.id, openAgents);
const count = counts[a.id] || 0;
const badge = count > 0 ? `<span class="ao-squad-badge">${count}</span>` : '';
const color = AGENT_COLORS[a.id] || '#64748b';
const icon = AGENT_ICONS[a.id] || '🤖';
return `<div class="ao-squad-card${selected}${alert}" data-agent-id="${esc(a.id)}" role="button" tabindex="0">
${badge}
<div class="ao-avatar" style="background:linear-gradient(135deg,${color}33,${color}11);border-color:${color}55">
<span class="ao-avatar-ring"></span>${icon}
</div>
<div class="ao-squad-name">${esc(a.name)}</div>
<div class="ao-squad-id">${esc(a.id)} · ${esc(a.codename)}</div>
<span class="ao-squad-status ${st.cls}">${st.label}</span>
<button type="button" class="ao-squad-chat-btn" data-open-chat="${esc(a.id)}" title="Abrir chat">💬</button>
</div>`;
}
function squadHtml(data, view) {
const squadAgents = (data.roster.agents || []).filter((a) => a.id !== 'curator');
const allCard = `<div class="ao-squad-card${state.selectedAgent === 'ALL' ? ' ao-squad-card--selected' : ''}" data-agent-id="ALL" role="button">
<div class="ao-avatar" style="background:linear-gradient(135deg,#6366f133,#8b5cf611);border-color:#6366f155">🌐</div>
<div class="ao-squad-name">Todos</div>
<div class="ao-squad-id">visão global</div>
<span class="ao-squad-status ao-squad-status--standby">${view.list.length} abertos</span>
</div>`;
return allCard + squadAgents.map((a) => squadCardHtml(a, view.openAgents, view.counts)).join('');
}
function missionCardHtml(inc) {
const active = state.selectedIncidentId === inc.id ? ' ao-mission-card--active' : '';
const sevCls = ` ao-mission-card--${inc.severity || 'warn'}`;
const icon = AGENT_ICONS[inc.primary_agent] || '🤖';
return `<article class="ao-mission-card${active}${sevCls}" data-incident-id="${inc.id}" data-thread-id="${inc.thread_id || ''}">
<div class="ao-mission-card-title">${icon} ${esc(inc.title)}</div>
<div class="ao-mission-card-meta">${esc(inc.agent_name)} · ${esc(inc.scenario_id)} · ${fmtAge(inc.last_seen_at)} · ${inc.occurrence_count || 1}×</div>
<div class="ao-mission-card-action">${esc((inc.suggested_human_action || 'Investigar manualmente.').slice(0, 160))}</div>
<div class="ao-mission-card-btns">
<button type="button" data-open-incident="${inc.id}">Abrir</button>
<button type="button" data-ack-incident="${inc.id}">Ack</button>
</div>
</article>`;
}
function kanbanHtml(view) {
if (!view.filtered.length) {
return '<div class="ao-empty-state"><div class="ao-empty-icon">✓</div><p>Ambiente saudável — nenhum incidente aberto.</p></div>';
}
return SEV_COLS.map((col) => {
const cards = (view.bySev[col.key] || []).map(missionCardHtml).join('')
|| '<p class="ticket-meta" style="font-size:0.7rem;opacity:0.5;text-align:center">—</p>';
return `<div class="ao-kanban-col ${col.cls}"><h4>${col.label}</h4>${cards}</div>`;
}).join('');
}
function chipsHtml(overview, open) {
const tier = overview.tier === 't1' ? 'T1 LLM' : 'T0 Checks';
const provider = overview.provider || 'ollama';
const model = overview.model || '—';
let llmChip;
if (provider === 'groq') {
llmChip = `<span class="ao-chip ao-chip--ok">Groq · ${esc(model)}</span>`;
} else if (provider === 'kimi') {
llmChip = `<span class="ao-chip ao-chip--ok">KIMI · ${esc(model)}</span>`;
} else if (overview.ollama) {
llmChip = `<span class="ao-chip ao-chip--ok">Ollama · ${esc(model)}</span>`;
} else {
llmChip = '<span class="ao-chip ao-chip--warn">LLM offline</span>';
}
const warnChip = (open.high || open.critical) ? ' ao-chip--warn' : '';
return `
<span class="ao-chip ao-chip--tier">${esc(tier)}</span>
${llmChip}
<span class="ao-chip">Tick ${fmtAge(overview.last_tick_at)}</span>
<span class="ao-chip ao-chip--ok">${overview.scenarios_ok || 0}/${overview.scenarios_total || 9} cenários OK</span>
<span class="ao-chip${warnChip}">${open.critical || 0} crít · ${open.high || 0} alto · ${open.warn || 0} aviso</span>`;
}
function timelineHtml(timeline) {
const ticks = timeline.ticks || [];
if (!ticks.length) return '<p class="ticket-meta">Sem ticks recentes.</p>';
return ticks.map((t) =>
`<div class="ao-timeline-row"><span>${esc(fmtAge(t.at))}</span><span>${t.scenarios || '—'} cenários · ${t.findings || 0} findings</span></div>`
).join('');
}
function commsContextHtml(sel) {
if (!sel) return 'Seleccione um incidente no board ou fale com um agente.';
return `<strong>${esc(sel.title)}</strong><br>${esc(sel.scenario_id)} · ${esc(sel.agent_name)}`;
}
function msgContext(m) {
if (m.context) return m.context;
if (m.context_json) {
try { return JSON.parse(m.context_json); } catch { return {}; }
}
return {};
}
function actionsHtml(actions) {
if (!actions?.length) return '';
const btns = actions.map((a) => {
if (a.type === 'run_scenario') {
const sev = a.severity || 'info';
return `<button type="button" class="ao-action-btn ao-action-btn--run ao-action-btn--${esc(sev)}" data-run-scenario="${esc(a.scenario_id)}">${esc(a.label)}</button>`;
}
if (a.type === 'ack_incident') {
return `<button type="button" class="ao-action-btn ao-action-btn--ack" data-ack-incident-id="${a.incident_id}">${esc(a.label)}</button>`;
}
return '';
}).join('');
return `<div class="ao-action-bar"><span class="ao-action-bar-label">Acções rápidas</span>${btns}</div>`;
}
function threadBubble(m) {
const cls = m.from_type === 'human' ? 'ao-bubble-human' : 'ao-bubble-agent';
const ctx = msgContext(m);
const body = m.from_type === 'human'
? esc(m.body).replace(/\n/g, '<br>')
: renderMarkdown(m.body);
const model = ctx.model ? ` · ${esc(ctx.model)}` : '';
const actions = m.from_type === 'agent' ? actionsHtml(ctx.actions) : '';
return `<div class="ao-bubble ${cls}">
<div class="ao-bubble-meta">${esc(m.from_label || m.from_id)} · ${esc(fmtMsgTime(m.created_at))}${model}</div>
<div class="ao-bubble-body">${body}</div>
${actions}
</div>`;
}
function typingBubbleHtml(agentLabel) {
return `<div class="ao-bubble ao-bubble-agent ao-bubble--typing" id="ao-typing-bubble">
<div class="ao-bubble-meta">${esc(agentLabel)} · a escrever<span class="ao-typing-dots"><span>.</span><span>.</span><span>.</span></span></div>
<div class="ao-bubble-body ao-stream-body"></div>
</div>`;
}
function subnavHtml() {
return SUB_VIEWS.map((v) =>
`<button type="button" class="ao-subnav-btn${state.subView === v.id ? ' active' : ''}" data-sub-view="${v.id}">${v.icon} ${esc(v.label)}</button>`
).join('');
}
function agentManagementHtml(data) {
const agents = (data.roster?.agents || []).filter((a) => a.id !== 'curator');
const cards = agents.map((a) => {
const icon = AGENT_ICONS[a.id] || '🤖';
const color = AGENT_COLORS[a.id] || '#64748b';
const reads = (a.reads || []).slice(0, 3).map((r) => `<li>${esc(r)}</li>`).join('');
const actions = (a.actions || []).slice(0, 2).map((r) => `<li>${esc(r)}</li>`).join('');
const scenarios = (a.scenarios || []).length
? a.scenarios.map((s) => `<span class="ao-chip">${esc(s)}</span>`).join(' ')
: '<span class="ticket-meta">—</span>';
return `<article class="ao-agent-mgmt-card" data-agent-id="${esc(a.id)}">
<div class="ao-agent-mgmt-head">
<div class="ao-avatar ao-avatar--lg" style="background:linear-gradient(135deg,${color}33,${color}11);border-color:${color}55">${icon}</div>
<div>
<h3>${esc(a.name)} <span class="ao-squad-id">${esc(a.id)}</span></h3>
<p class="ao-agent-mgmt-role">${esc(a.role)}</p>
<p class="ticket-meta">Aprovação: ${esc(a.approval)}</p>
</div>
</div>
<div class="ao-agent-mgmt-grid">
<div><strong>Lê</strong><ul class="ao-agent-mgmt-list">${reads}</ul></div>
<div><strong>Actua</strong><ul class="ao-agent-mgmt-list">${actions}</ul></div>
</div>
<div class="ao-agent-mgmt-scenarios"><strong>Cenários:</strong> ${scenarios}</div>
<button type="button" class="ao-btn-mission ao-btn-chat-agent" data-open-chat="${esc(a.id)}">💬 Conversar com ${esc(a.name)}</button>
</article>`;
}).join('');
return `<div class="ao-screen-head"><h3>Agent Management</h3><p class="ticket-meta">Roster A0A7 + Vigia · clique Conversar para abrir chat em modal</p></div><div class="ao-agent-mgmt-grid-wrap">${cards}</div>`;
}
function knowledgeGraphHtml() {
const sources = extraCache.kbSources?.sources || [];
const total = extraCache.kbSources?.total_chunks || 0;
const hits = extraCache.kbHits?.hits || [];
const sourceNodes = sources.map((s) => {
const short = String(s.source_path || '').split('/').slice(-2).join('/');
return `<div class="ao-kg-node" data-kb-source="${esc(s.source_path)}">
<div class="ao-kg-node-title">📄 ${esc(short)}</div>
<div class="ao-kg-node-meta">${s.chunks} chunks · ${fmtAge(s.indexed_at)}</div>
</div>`;
}).join('') || '<p class="ticket-meta">Nenhum documento indexado — aguardar tick do Curador.</p>';
const hitCards = hits.map((h) =>
`<article class="ao-kg-hit"><div class="ao-kg-hit-src">${esc(h.source)}</div><div class="ao-kg-hit-snippet">${esc(h.snippet)}</div></article>`
).join('') || '<p class="ticket-meta">Pesquise specs, runbooks ou infra…</p>';
return `
<div class="ao-screen-head"><h3>Memory · Knowledge Graph</h3><p class="ticket-meta">${total} chunks indexados · RAG alimenta Copiloto e agentes</p></div>
<div class="ao-kg-search">
<input type="search" id="ao-kb-search" placeholder="Pesquisar base de conhecimento…" value="${esc(state.kbQuery)}"/>
<button type="button" class="ao-btn-mission" id="ao-kb-search-btn">Pesquisar</button>
</div>
<div class="ao-kg-layout">
<div class="ao-kg-sources"><p class="ao-section-label">Fontes indexadas</p><div class="ao-kg-graph">${sourceNodes}</div></div>
<div class="ao-kg-results"><p class="ao-section-label">Resultados</p>${hitCards}</div>
</div>`;
}
function recurringTasksHtml() {
const scenarios = extraCache.scenarios?.scenarios || [];
if (!scenarios.length) {
return '<div class="ao-screen-head"><h3>Natural Language Recurring Tasks</h3><p class="ticket-meta">A carregar cenários…</p></div>';
}
const rows = scenarios.map((s) => {
const statusCls = s.last_run_status === 'ok' ? 'ao-chip--ok' : (s.last_run_status === 'fail' ? 'ao-chip--warn' : '');
const enabled = s.enabled ? 'Activo' : 'Pausado';
return `<article class="ao-recurring-card">
<div class="ao-recurring-head">
<h4>${esc(s.title || s.id)}</h4>
<span class="ao-chip ${statusCls}">${esc(s.last_run_status || '—')}</span>
</div>
<div class="ao-recurring-meta">
<span class="ao-chip ao-chip--tier">${esc(s.id)}</span>
<span class="ao-chip">${esc(s.schedule || 'cron')}</span>
<span class="ao-chip">${enabled}</span>
<span class="ao-chip">Severidade ${esc(s.severity_default || 'warn')}</span>
</div>
<p class="ticket-meta">Última execução: ${fmtAge(s.last_run_at)} · ${s.last_run_message ? esc(String(s.last_run_message).slice(0, 120)) : '—'}</p>
</article>`;
}).join('');
return `<div class="ao-screen-head"><h3>Natural Language Recurring Tasks</h3><p class="ticket-meta">Cenários T0 vigilância 24/7 — linguagem natural descreve o check, worker executa no schedule</p></div><div class="ao-recurring-list">${rows}</div>`;
}
function activityFeedHtml() {
const events = extraCache.activity?.events || [];
if (!events.length) {
return '<div class="ao-screen-head"><h3>Activity Feed</h3><p class="ticket-meta">A carregar eventos…</p></div>';
}
const rows = events.map((e) => {
const payload = e.payload_json ? (() => { try { return JSON.parse(e.payload_json); } catch { return {}; } })() : {};
const agent = e.agent_id ? `<span class="ao-chip">${esc(e.agent_id)}</span>` : '';
return `<article class="ao-activity-row">
<div class="ao-activity-time">${esc(fmtAge(e.ts))}</div>
<div class="ao-activity-body">
<div class="ao-activity-type">${esc(e.event_type)} ${agent}</div>
<div class="ao-activity-msg">${esc(e.message || '')}</div>
${payload.model ? `<div class="ticket-meta">model: ${esc(payload.model)}</div>` : ''}
</div>
</article>`;
}).join('');
return `<div class="ao-screen-head"><h3>Activity Feed</h3><p class="ticket-meta">Últimos eventos agentic — ticks, chat, acks, runs</p></div><div class="ao-activity-feed">${rows}</div>`;
}
function patchHtml(node, html) {
if (!node || node.innerHTML === html) return false;
node.innerHTML = html;
return true;
}
function chatModalHtml() {
return `
<div class="ao-modal" id="ao-chat-modal" aria-hidden="true">
<div class="ao-modal-backdrop" data-close-modal></div>
<div class="ao-modal-dialog" role="dialog" aria-labelledby="ao-modal-title">
<header class="ao-modal-header">
<div class="ao-modal-agent" id="ao-modal-title">
<span class="ao-modal-icon" id="ao-modal-icon">🤖</span>
<div>
<div class="ao-modal-name" id="ao-modal-name">Copiloto</div>
<div class="ao-modal-sub" id="ao-modal-sub">Thread: nenhuma</div>
</div>
</div>
<button type="button" class="ao-modal-close" id="ao-modal-close" aria-label="Fechar">×</button>
</header>
<div class="ao-modal-context" id="ao-modal-context"></div>
<div class="ao-modal-feed" id="ao-modal-thread-messages"><p class="ticket-meta">Nova conversa — escreva abaixo.</p></div>
<div class="ao-modal-composer">
<label for="ao-modal-chat-input" id="ao-modal-chat-label">Mensagem</label>
<textarea id="ao-modal-chat-input" rows="4" placeholder="Pergunte ao agente…"></textarea>
<div class="ao-modal-actions">
<button type="button" class="ao-btn-send" id="ao-modal-btn-send">Enviar mensagem</button>
</div>
</div>
</div>
</div>`;
}
function mountShell(el) {
el.innerHTML = `
<div class="ao-console">
<header class="ao-mission-header">
<div>
<div class="ao-mission-title">
<span class="ao-live-dot" id="ao-live-dot"></span>
<h2>Agent Squad</h2>
</div>
<div class="ao-stat-chips" id="ao-stat-chips"></div>
</div>
<div class="ao-header-actions">
<button type="button" class="ao-btn-mission ao-btn-chat-fab" id="ao-btn-open-chat">💬 Chat · ${esc(chatAgentId())}</button>
<button type="button" class="ao-btn-mission" id="ao-btn-refresh">↻ Actualizar (R)</button>
</div>
</header>
<nav class="ao-subnav" id="ao-subnav">${subnavHtml()}</nav>
<div class="ao-subview ao-subview--active" data-subview="command">
<div class="ao-mobile-tabs" id="ao-mobile-tabs">
<button type="button" data-ao-tab="squad">Squad</button>
<button type="button" data-ao-tab="board">Board</button>
<button type="button" data-ao-tab="tasks">Tasks</button>
</div>
<section class="ao-pane" data-ao-pane="squad">
<p class="ao-section-label">Agent Squad — clique filtra · duplo-clique ou 💬 abre chat modal</p>
<div class="ao-squad-grid" id="ao-squad-grid"></div>
</section>
<div class="ao-main ao-main--full">
<section class="ao-pane ao-pane--active" data-ao-pane="board">
<div class="ao-content-tabs" id="ao-content-tabs">
<button type="button" data-main-tab="missions">Mission Board</button>
<button type="button" data-main-tab="tasks">Task Board</button>
</div>
<div class="ao-content-pane ao-content-pane--active" data-main-pane="missions">
<p class="ao-section-label" id="ao-board-label">Mission Board</p>
<div class="ao-kanban" id="ao-kanban"></div>
<div class="ao-timeline-strip">
<p class="ao-section-label" style="margin-bottom:0.4rem">Timeline · últimas 24h</p>
<div id="ao-timeline"></div>
</div>
</div>
<div class="ao-content-pane" data-main-pane="tasks">
<p class="ao-section-label">Task Board — conversas com agentes</p>
<div id="ao-task-board"></div>
</div>
</section>
</div>
<aside class="ao-comms-compact" id="ao-comms-compact">
<p><strong>Comms</strong> — chat em modal grande. Duplo-clique num agente ou use o botão 💬 Chat.</p>
<button type="button" class="ao-btn-mission" id="ao-btn-open-chat-inline">Abrir chat com ${esc(chatAgentId())}</button>
</aside>
</div>
<div class="ao-subview" data-subview="agents" id="ao-pane-agents"></div>
<div class="ao-subview" data-subview="tasks" id="ao-pane-tasks"></div>
<div class="ao-subview" data-subview="memory" id="ao-pane-memory"></div>
<div class="ao-subview" data-subview="recurring" id="ao-pane-recurring"></div>
<div class="ao-subview" data-subview="activity" id="ao-pane-activity"></div>
${chatModalHtml()}
</div>`;
shellMounted = true;
}
function updateSubViews(el) {
el.querySelectorAll('.ao-subnav-btn').forEach((btn) => {
btn.classList.toggle('active', btn.dataset.subView === state.subView);
});
el.querySelectorAll('.ao-subview').forEach((pane) => {
pane.classList.toggle('ao-subview--active', pane.dataset.subview === state.subView);
});
}
function updateMainTabs(el) {
el.querySelectorAll('[data-main-tab]').forEach((btn) => {
btn.classList.toggle('active', btn.dataset.mainTab === state.mainTab);
});
el.querySelectorAll('[data-main-pane]').forEach((pane) => {
pane.classList.toggle('ao-content-pane--active', pane.dataset.mainPane === state.mainTab);
});
}
function updateModalChrome(el, sel) {
const modal = el.querySelector('#ao-chat-modal');
if (!modal) return;
const agentId = chatAgentId();
const icon = AGENT_ICONS[agentId] || '🤖';
const iconEl = modal.querySelector('#ao-modal-icon');
const nameEl = modal.querySelector('#ao-modal-name');
const subEl = modal.querySelector('#ao-modal-sub');
const ctxEl = modal.querySelector('#ao-modal-context');
const labelEl = modal.querySelector('#ao-modal-chat-label');
if (iconEl) iconEl.textContent = icon;
if (nameEl) nameEl.textContent = chatTargetLabel();
if (subEl) subEl.textContent = state.threadId ? `Thread #${state.threadId}` : 'Nova conversa';
if (ctxEl) ctxEl.innerHTML = commsContextHtml(sel);
if (labelEl) labelEl.textContent = `Mensagem para ${chatTargetLabel()}`;
modal.classList.toggle('ao-modal--open', state.chatModalOpen);
modal.setAttribute('aria-hidden', state.chatModalOpen ? 'false' : 'true');
document.body.classList.toggle('ao-modal-open', state.chatModalOpen);
}
function updateCommsChrome(el, sel) {
const fab = el.querySelector('#ao-btn-open-chat');
const inline = el.querySelector('#ao-btn-open-chat-inline');
const label = `💬 Chat · ${chatAgentId()}`;
if (fab) fab.textContent = label;
if (inline) inline.textContent = `Abrir chat com ${chatAgentId()}`;
updateModalChrome(el, sel);
}
function updateMobileTabs(el) {
if (state.subView !== 'command') return;
el.querySelectorAll('.ao-mobile-tabs button').forEach((btn) => {
btn.classList.toggle('active', btn.dataset.aoTab === state.mobileTab);
});
el.querySelectorAll('[data-subview="command"] .ao-pane').forEach((pane) => {
const paneName = pane.dataset.aoPane;
const active = paneName === state.mobileTab
|| (state.mobileTab === 'tasks' && paneName === 'board');
pane.classList.toggle('ao-pane--active', active);
});
}
function patchSubViewPanes(el, data) {
patchHtml(el.querySelector('#ao-pane-agents'), agentManagementHtml(data));
patchHtml(el.querySelector('#ao-pane-tasks'), taskBoardHtml(data));
patchHtml(el.querySelector('#ao-pane-memory'), knowledgeGraphHtml());
patchHtml(el.querySelector('#ao-pane-recurring'), recurringTasksHtml());
patchHtml(el.querySelector('#ao-pane-activity'), activityFeedHtml());
}
function patchDashboard(el, data, opts = {}) {
const { soft = false } = opts;
const view = deriveView(data);
const open = data.overview.incidents_open || {};
const dot = el.querySelector('#ao-live-dot');
if (dot) dot.className = `ao-live-dot ${liveDotClass(open)}`.trim();
patchHtml(el.querySelector('#ao-stat-chips'), chipsHtml(data.overview, open));
const incFp = incidentsFingerprint(data);
const tickFp = timelineFingerprint(data.timeline);
const boardChanged = !soft || incFp !== state._aoIncidentsFp;
const timelineChanged = !soft || tickFp !== state._aoTimelineFp;
if (boardChanged) {
patchHtml(el.querySelector('#ao-subnav'), subnavHtml());
patchHtml(el.querySelector('#ao-squad-grid'), squadHtml(data, view));
patchHtml(el.querySelector('#ao-kanban'), kanbanHtml(view));
state._aoIncidentsFp = incFp;
}
if (timelineChanged) {
patchHtml(el.querySelector('#ao-timeline'), timelineHtml(data.timeline));
state._aoTimelineFp = tickFp;
}
if (!soft) {
patchHtml(el.querySelector('#ao-task-board'), taskBoardHtml(data));
patchSubViewPanes(el, data);
}
const boardLabel = el.querySelector('#ao-board-label');
if (boardLabel) {
boardLabel.textContent = state.selectedAgent !== 'ALL'
? `Mission Board · filtro ${state.selectedAgent}`
: 'Mission Board';
}
if (!soft) {
updateSubViews(el);
}
updateMainTabs(el);
updateCommsChrome(el, view.sel);
updateMobileTabs(el);
return view;
}
function openChatModal(root, agentId) {
if (agentId && agentId !== 'ALL') state.selectedAgent = agentId;
state.chatModalOpen = true;
const view = cache ? deriveView(cache) : { sel: null };
updateModalChrome(root, view.sel);
if (!state.threadId) {
const box = root.querySelector('#ao-modal-thread-messages');
if (box && !box.querySelector('.ao-bubble')) {
box.innerHTML = '<p class="ticket-meta">Nova conversa — escreva a primeira mensagem abaixo.</p>';
}
}
root.querySelector('#ao-modal-chat-input')?.focus();
}
function closeChatModal(root) {
state.chatModalOpen = false;
updateModalChrome(root, cache ? deriveView(cache).sel : null);
}
async function sendChatStream(root, path, body) {
const h = authHeaders({ 'Content-Type': 'application/json', Accept: 'text/event-stream' });
const r = await fetchWithTimeout(`/api/v1/agents${path}`, {
method: 'POST',
headers: h,
body: JSON.stringify(body),
}, 180000);
if (r.status === 401) {
logout();
throw new Error('sessão expirada — faça login novamente');
}
if (!r.ok) throw new Error(`${r.status} ${(await r.text()).slice(0, 200)}`);
if (!r.body) throw new Error('stream indisponível');
return r.body.getReader();
}
async function sendChatMessage(root) {
const input = chatInputEl(root);
const btn = sendBtnEl(root);
const box = msgBoxEl(root);
const q = (input?.value || '').trim();
if (!q || chatInFlight) return;
const target = chatAgentId();
chatInFlight = true;
if (btn) btn.disabled = true;
let streamText = '';
let typingEl = null;
if (box) {
box.querySelector('#ao-typing-bubble')?.remove();
box.querySelector('.ticket-meta')?.remove();
box.insertAdjacentHTML('beforeend', `<div class="ao-bubble ao-bubble-human"><div class="ao-bubble-meta">Você · agora</div><div class="ao-bubble-body">${esc(q)}</div></div>`);
box.insertAdjacentHTML('beforeend', typingBubbleHtml(chatTargetLabel()));
typingEl = box.querySelector('#ao-typing-bubble');
box.scrollTop = box.scrollHeight;
}
try {
const path = state.threadId
? `/threads/${state.threadId}/chat/stream`
: '/chat/stream';
const reader = await sendChatStream(root, path, {
question: q,
include_findings: true,
target_agent: target,
});
const decoder = new TextDecoder();
let buf = '';
let doneEvt = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const parts = buf.split('\n\n');
buf = parts.pop() || '';
for (const part of parts) {
const line = part.trim();
if (!line.startsWith('data: ')) continue;
let evt;
try { evt = JSON.parse(line.slice(6)); } catch { continue; }
if (evt.type === 'start' && evt.thread_id) {
state.threadId = evt.thread_id;
updateCommsChrome(root, cache ? deriveView(cache).sel : null);
}
if (evt.type === 'token' && evt.text) {
streamText += evt.text;
const streamBody = typingEl?.querySelector('.ao-stream-body');
if (streamBody) {
streamBody.innerHTML = renderMarkdown(streamText);
box.scrollTop = box.scrollHeight;
}
}
if (evt.type === 'done') doneEvt = evt;
if (evt.type === 'error') throw new Error(evt.message || 'Erro no stream');
}
}
input.value = '';
lastThreadHtml = '';
if (doneEvt?.thread_id) state.threadId = doneEvt.thread_id;
if (doneEvt?.actions?.length) {
state.lastActions = doneEvt.actions;
if (typingEl) typingEl.insertAdjacentHTML('beforeend', actionsHtml(doneEvt.actions));
}
if (state.threadId) {
await loadThread(root, state.threadId);
} else if (typingEl && streamText) {
typingEl.id = '';
typingEl.classList.remove('ao-bubble--typing');
const meta = typingEl.querySelector('.ao-bubble-meta');
if (meta) meta.textContent = `${chatTargetLabel()} · agora${doneEvt?.model ? ` · ${doneEvt.model}` : ''}`;
}
try {
const threads = await agentsApi('/threads?limit=40');
if (cache) cache.threads = threads;
patchHtml(root.querySelector('#ao-pane-tasks'), cache ? taskBoardHtml(cache) : '');
patchHtml(root.querySelector('#ao-task-board'), cache ? taskBoardHtml(cache) : '');
} catch { /* ignore */ }
} catch (err) {
typingEl?.remove();
if (box) {
box.insertAdjacentHTML('beforeend', `<p class="error">${esc(chatErrorMessage(err))}</p>`);
box.scrollTop = box.scrollHeight;
}
} finally {
chatInFlight = false;
if (btn) btn.disabled = false;
input?.focus();
}
}
async function runScenarioAction(root, scenarioId, btn) {
if (btn) { btn.disabled = true; btn.dataset.prev = btn.textContent; btn.textContent = '⏳ A executar…'; }
const box = msgBoxEl(root);
try {
const res = await agentsApi(`/runs/${encodeURIComponent(scenarioId)}`, { method: 'POST' });
const ok = res.ok && res.status === 'ok';
const msg = ok
? `✅ **Check concluído** — \`${scenarioId}\` saudável (sem findings).`
: `⚠️ **Check concluído** — \`${scenarioId}\`: status \`${res.status}\`, ${res.findings_count || 0} finding(s). Run #${res.run_id || '?'}`;
if (box) {
box.insertAdjacentHTML('beforeend', `<div class="ao-bubble ao-bubble-system"><div class="ao-bubble-meta">Sistema · agora</div><div class="ao-bubble-body">${renderMarkdown(msg)}</div></div>`);
box.scrollTop = box.scrollHeight;
}
await renderAgenticOps({ soft: true, force: true });
} catch (err) {
if (box) {
box.insertAdjacentHTML('beforeend', `<p class="error">${esc(chatErrorMessage(err))}</p>`);
}
} finally {
if (btn) {
btn.disabled = false;
btn.textContent = btn.dataset.prev || btn.textContent;
}
}
}
async function ackIncidentAction(root, incidentId, btn) {
if (btn) { btn.disabled = true; btn.textContent = '⏳…'; }
try {
await agentsApi(`/incidents/${incidentId}/ack`, { method: 'POST' });
const box = msgBoxEl(root);
if (box) {
box.insertAdjacentHTML('beforeend', `<div class="ao-bubble ao-bubble-system"><div class="ao-bubble-meta">Sistema · agora</div><div class="ao-bubble-body">✓ Incidente #${incidentId} reconhecido (ack).</div></div>`);
box.scrollTop = box.scrollHeight;
}
await renderAgenticOps({ soft: true, force: true });
} catch (err) {
const box = msgBoxEl(root);
if (box) box.insertAdjacentHTML('beforeend', `<p class="error">${esc(chatErrorMessage(err))}</p>`);
} finally {
if (btn) btn.disabled = false;
}
}
function focusChat(root, agentId) {
if (agentId && agentId !== 'ALL') state.selectedAgent = agentId;
openChatModal(root, agentId);
}
async function openTaskThread(root, threadId, agentId) {
state.threadId = threadId;
if (agentId) state.selectedAgent = agentId;
lastThreadHtml = '';
openChatModal(root, agentId);
if (cache) patchDashboard(root, cache);
await loadThread(root, threadId);
updateCommsChrome(root, cache ? deriveView(cache).sel : null);
root.querySelector('#ao-modal-chat-input')?.focus();
}
function preserveInputs(el) {
return {
chat: chatInputEl(el)?.value || '',
modalChat: el.querySelector('#ao-modal-chat-input')?.value || '',
kb: el.querySelector('#ao-kb-search')?.value || state.kbQuery,
};
}
function restoreInputs(el, saved) {
const chat = chatInputEl(el);
if (chat && saved.chat) chat.value = saved.chat;
const modal = el.querySelector('#ao-modal-chat-input');
if (modal && saved.modalChat) modal.value = saved.modalChat;
const kb = el.querySelector('#ao-kb-search');
if (kb && saved.kb) kb.value = saved.kb;
}
async function loadThread(el, threadId, options = {}) {
const box = msgBoxEl(el);
if (!box || !threadId) return;
const silent = options.silent === true;
const prevScroll = box.scrollTop;
const atBottom = box.scrollHeight - box.scrollTop - box.clientHeight < 40;
if (!silent && state.threadId !== threadId) {
box.innerHTML = '<p class="ticket-meta" style="opacity:0.6">A carregar thread…</p>';
}
state.threadId = threadId;
const data = await agentsApi(`/threads/${threadId}/messages`);
const html = data.messages.map(threadBubble).join('') || '<p class="ticket-meta">Sem mensagens.</p>';
if (html === lastThreadHtml && state.threadId === threadId) {
if (atBottom) box.scrollTop = box.scrollHeight;
else box.scrollTop = prevScroll;
return;
}
lastThreadHtml = html;
box.innerHTML = html;
box.scrollTop = atBottom ? box.scrollHeight : prevScroll;
updateModalChrome(el, cache ? deriveView(cache).sel : null);
}
async function fetchDashboard() {
const [overview, incidents, roster, timeline, threads] = await Promise.all([
agentsApi('/overview'),
agentsApi('/incidents?status=open&limit=50'),
agentsApi('/roster'),
agentsApi('/timeline?limit=12'),
agentsApi('/threads?limit=40'),
]);
return { overview, incidents, roster, timeline, threads };
}
async function fetchExtraData() {
const [scenarios, activity, kbSources] = await Promise.all([
agentsApi('/scenarios'),
agentsApi('/action-log?limit=80'),
agentsApi('/kb/sources'),
]);
extraCache.scenarios = scenarios;
extraCache.activity = activity;
extraCache.kbSources = kbSources;
if (state.kbQuery.trim()) {
extraCache.kbHits = await agentsApi(`/kb/search?q=${encodeURIComponent(state.kbQuery.trim())}`);
}
}
async function runKbSearch(root) {
const input = root.querySelector('#ao-kb-search');
state.kbQuery = (input?.value || '').trim();
if (!state.kbQuery) return;
extraCache.kbHits = await agentsApi(`/kb/search?q=${encodeURIComponent(state.kbQuery)}`);
if (cache) patchHtml(root.querySelector('#ao-pane-memory'), knowledgeGraphHtml());
}
function bindEventsOnce(el) {
if (eventsBound) return;
eventsBound = true;
el.addEventListener('dblclick', (ev) => {
const root = document.getElementById('agentic-ops-content');
const agentNode = ev.target.closest('[data-agent-id]');
if (!root || !agentNode || agentNode.dataset.agentId === 'ALL') return;
if (ev.target.closest('.ao-squad-chat-btn')) return;
ev.preventDefault();
state.threadId = null;
lastThreadHtml = '';
focusChat(root, agentNode.dataset.agentId);
});
el.addEventListener('keydown', (ev) => {
const root = document.getElementById('agentic-ops-content');
if (!root || !root.contains(ev.target)) return;
if (ev.key === 'Enter' && !ev.shiftKey && (ev.target.id === 'ao-chat-input' || ev.target.id === 'ao-modal-chat-input')) {
ev.preventDefault();
sendChatMessage(root);
}
if (ev.key === 'Escape' && state.chatModalOpen) {
ev.preventDefault();
closeChatModal(root);
}
});
el.addEventListener('click', async (ev) => {
const root = document.getElementById('agentic-ops-content');
if (!root || !root.contains(ev.target)) return;
if (ev.target.closest('[data-close-modal]') || ev.target.closest('#ao-modal-close')) {
closeChatModal(root);
return;
}
const runBtn = ev.target.closest('[data-run-scenario]');
if (runBtn) {
ev.preventDefault();
await runScenarioAction(root, runBtn.dataset.runScenario, runBtn);
return;
}
const ackActionBtn = ev.target.closest('[data-ack-incident-id]');
if (ackActionBtn) {
ev.preventDefault();
await ackIncidentAction(root, parseInt(ackActionBtn.dataset.ackIncidentId, 10), ackActionBtn);
return;
}
const subBtn = ev.target.closest('[data-sub-view]');
if (subBtn && subBtn.classList.contains('ao-subnav-btn')) {
state.subView = subBtn.dataset.subView;
updateSubViews(root);
if (cache) patchSubViewPanes(root, cache);
return;
}
const openChatBtn = ev.target.closest('[data-open-chat], #ao-btn-open-chat, #ao-btn-open-chat-inline');
if (openChatBtn) {
ev.stopPropagation();
const agent = openChatBtn.dataset.openChat || chatAgentId();
state.threadId = openChatBtn.dataset.openChat ? null : state.threadId;
if (openChatBtn.dataset.openChat) lastThreadHtml = '';
focusChat(root, agent);
return;
}
const refreshBtn = ev.target.closest('#ao-btn-refresh');
if (refreshBtn) {
ev.preventDefault();
await renderAgenticOps({ force: true });
return;
}
const kbBtn = ev.target.closest('#ao-kb-search-btn');
if (kbBtn) {
await runKbSearch(root);
return;
}
const mainTabBtn = ev.target.closest('[data-main-tab]');
if (mainTabBtn) {
state.mainTab = mainTabBtn.dataset.mainTab;
updateMainTabs(root);
return;
}
const chatAgentBtn = ev.target.closest('[data-chat-agent]');
if (chatAgentBtn) {
state.selectedAgent = chatAgentBtn.dataset.chatAgent;
state.threadId = null;
lastThreadHtml = '';
if (cache) patchDashboard(root, cache);
focusChat(root, chatAgentBtn.dataset.chatAgent);
return;
}
const newChatBtn = ev.target.closest('#ao-btn-new-chat');
if (newChatBtn) {
state.threadId = null;
lastThreadHtml = '';
focusChat(root, chatAgentId());
return;
}
const taskCard = ev.target.closest('[data-thread-id]');
if (taskCard && taskCard.classList.contains('ao-task-card')) {
const tid = parseInt(taskCard.dataset.threadId, 10);
await openTaskThread(root, tid, taskCard.dataset.taskAgent);
return;
}
const agentNode = ev.target.closest('[data-agent-id]');
if (agentNode && !ev.target.closest('[data-ack-incident]') && !ev.target.closest('[data-open-chat]')) {
state.selectedAgent = agentNode.dataset.agentId;
if (cache) patchDashboard(root, cache);
return;
}
const ackBtn = ev.target.closest('[data-ack-incident]');
if (ackBtn) {
ev.stopPropagation();
const id = parseInt(ackBtn.dataset.ackIncident, 10);
await agentsApi(`/incidents/${id}/ack`, { method: 'POST' });
if (state.selectedIncidentId === id) {
state.selectedIncidentId = null;
state.threadId = null;
lastThreadHtml = '';
}
await renderAgenticOps({ soft: true, force: true });
return;
}
const incNode = ev.target.closest('[data-incident-id], [data-open-incident]');
if (incNode && !ev.target.closest('[data-ack-incident]')) {
const id = parseInt(incNode.dataset.incidentId || incNode.dataset.openIncident, 10);
state.selectedIncidentId = id;
if (cache) {
const view = patchDashboard(root, cache);
const tid = parseInt(root.querySelector(`[data-incident-id="${id}"]`)?.dataset.threadId, 10);
if (view.sel && tid) await openTaskThread(root, tid, view.sel.primary_agent);
else if (view.sel?.thread_id) await openTaskThread(root, view.sel.thread_id, view.sel.primary_agent);
}
return;
}
const tabBtn = ev.target.closest('.ao-mobile-tabs button');
if (tabBtn) {
state.mobileTab = tabBtn.dataset.aoTab;
if (tabBtn.dataset.aoTab === 'tasks') state.mainTab = 'tasks';
updateMobileTabs(root);
updateMainTabs(root);
return;
}
if (ev.target.closest('#ao-btn-send') || ev.target.closest('#ao-modal-btn-send')) {
await sendChatMessage(root);
}
});
}
function schedulePoll() {
if (state.pollTimer) clearInterval(state.pollTimer);
state.pollTimer = setInterval(() => {
if (document.hidden) return;
const view = document.getElementById('view-agentic-ops');
if (view && !view.hidden) renderAgenticOps({ poll: true });
}, 30000);
}
async function renderAgenticOps(options = {}) {
const el = document.getElementById('agentic-ops-content');
if (!el) return;
if (renderInFlight) {
if (options.poll) return;
pendingRender = { ...options, force: true };
return;
}
const soft = shellMounted && (options.poll || options.soft) && !options.force;
renderInFlight = true;
if (shellMounted && !el.querySelector('#ao-subnav')) {
shellMounted = false;
eventsBound = false;
}
if (!soft && !shellMounted) {
el.innerHTML = '<div class="ao-console ao-loading"><p>Carregando Agent Squad…</p></div>';
}
if (!getToken()) {
shellMounted = false;
eventsBound = false;
el.innerHTML = '<p class="error">Sessão não encontrada. <a href="/login.html">Fazer login</a></p>';
renderInFlight = false;
return;
}
if (!soft && typeof ensureValidSession === 'function') {
const ok = await ensureValidSession();
if (!ok) {
shellMounted = false;
eventsBound = false;
el.innerHTML = '<p class="error">Sessão expirada. <a href="/login.html">Fazer login</a></p>';
renderInFlight = false;
return;
}
}
try {
const saved = soft ? preserveInputs(el) : null;
const data = await fetchDashboard();
cache = data;
try {
await fetchExtraData();
} catch {
/* extra panes degrade gracefully */
}
if (!shellMounted) {
mountShell(el);
bindEventsOnce(el);
}
const view = patchDashboard(el, data, { soft });
if (saved) restoreInputs(el, saved);
if (state.threadId && state.chatModalOpen) {
await loadThread(el, state.threadId, { silent: true });
} else if (state.threadId && !state.chatModalOpen && view.sel?.thread_id) {
await loadThread(el, view.sel.thread_id);
}
if (!state.pollTimer) schedulePoll();
} catch (err) {
if (!soft) el.innerHTML = `<p class="error">Erro: ${esc(err.message)}</p>`;
} finally {
renderInFlight = false;
if (pendingRender) {
const next = pendingRender;
pendingRender = null;
await renderAgenticOps(next);
}
}
}
document.addEventListener('keydown', (ev) => {
const view = document.getElementById('view-agentic-ops');
if (!view || view.hidden) return;
if (ev.key === 'r' && !ev.ctrlKey && !ev.metaKey) {
const t = ev.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA')) return;
ev.preventDefault();
renderAgenticOps({ force: true });
}
if (ev.key === 'Escape' && !state.chatModalOpen) {
state.selectedIncidentId = null;
state.threadId = null;
lastThreadHtml = '';
const el = document.getElementById('agentic-ops-content');
if (el && cache) patchDashboard(el, cache);
}
});
window.renderAgenticOps = renderAgenticOps;
})();