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>
1288 lines
52 KiB
JavaScript
1288 lines
52 KiB
JavaScript
(function () {
|
||
const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
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 `há ${m} min`;
|
||
const h = Math.floor(m / 60);
|
||
if (h < 24) return `há ${h}h`;
|
||
return `há ${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 A0–A7 + 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;
|
||
})();
|