svrnty-hermes-webui-plugin/static/adwright.js
Svrnty 0b19fdd7d0
Some checks failed
plugin-tests / test (push) Failing after 5s
feat(plugin): Adwright + BTE Command Center panels (v0.4.0)
Two new tool panels surfaced inside hermes-webui via inject_script/
inject_stylesheet. Both vanilla JS + CSS, no frameworks, WebUI CSS-vars
only (no hardcoded colors), light/dark inherits free.

## Adwright panel (static/adwright.{js,css} + routes/adwright.py)

5 tabs: Overview · Cycles · Audience · Targeting · Connections.
Layout: 60/40 panel/chat split via CSS :has() selector.
Always-visible, soft-disabled when active profile isn't `cmo*`.

Action wiring (READ path — agent-mediated per governance):
1. Panel button → fires custom event
2. Handler synthesizes /adwright <cmd> chat message
3. Posts via existing btnSend pathway → message visible in chat
4. CMO sees + calls mcp_adwright_<tool>
5. Panel polls /api/adwright/last-panel-update for structured payload
6. Mock payload returned v1; real session-DB reader plugs in when
   adwright-mcp gains writer

Connections WRITE path (governance exception, NO secrets in chat):
- POST /api/adwright/provision-creds with form fields
- Plugin invokes credctl set <key> via stdin (value never on argv)
- Allowlist enforced (defense-in-depth on key names)
- Auth-gated by WebUI session cookie

Skin: .svrnty-aw-* class prefix, window.SvrntyAdwright JS namespace,
guard against double-load, scoped MutationObserver.

## BTE Command Center panel (static/bte.{js,css} + routes/bte_proxy.py)

Content-mode pills (Polished/UGC/Photorealistic/Artistic) × media toggle
(Image/Video — Video disabled v1 pending Phase 4e) × recipe family picker
(Hero Shot/Lifestyle Shot/Photoshoot/Recipe Sheet/Montage Catalog) per
canonical PLANB-RECIPE-TAXONOMY. SKU picker, variant stepper 1-12,
single/batch toggle, [Generate] button.

Asset grid with streaming thumbnails, asset detail (full-res + rate +
comment + "Use in Adwright cycle" deep link). Embedded CMO chat right rail
for re-orienting generations ("make next batch warmer / less white space").

BTE proxy route (/api/bte/proxy) with whitelisted paths
(requestPhotoshoot, assetGrid, recipeStats, assets/{id}/thumb, etc.)
prevents browser-side CORS to BTE :6001.

Skin: .svrnty-bte-* class prefix, window.SvrntyBTE JS namespace.

## Wiring

manifest_version: 0.2.0 → 0.4.0
assets registered:
- /plugins/svrnty/adwright.{js,css} + static/adwright/
- /plugins/svrnty/bte.{js,css} + static/bte/
routes registered:
- GET /api/adwright/last-panel-update (panel update channel)
- POST /api/adwright/provision-creds (governance-exception write)
- GET/POST /api/bte/proxy (BTE REST proxy with allowlist)

Karpathy 4 rules: agents reported every deviation with rationale (Python
venv interp for hermes mcp add, missing aggregate connections-status RPC
composed from two verifies, mock panel-update v1 with locked frontend
protocol so real session-DB reader is a drop-in swap), verified asset
serving + plugin route registration before claiming complete, surfaced
open questions instead of silently choosing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:12:27 -04:00

718 lines
29 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.

// Adwright tool panel — injected into hermes-webui as a sibling to #mainChat.
// Loaded via /plugins/svrnty/adwright.js (registered by plugin.py).
// Per ADWRIGHT-PANEL-PRD §3-§7: 60/40 panel/chat split, 5 tabs, always
// visible, soft-disabled when active profile != cmo, reads via /adwright
// chat commands (visible in stream → audit trail), writes for creds only
// via direct backend route (governance exception §6).
//
// Strict: vanilla JS, IIFE, all globals under window.SvrntyAdwright,
// MutationObserver scoped to <main class="main"> only, no hardcoded colors
// (all in adwright.css).
(function () {
"use strict";
if (window.__svrntyAdwrightLoaded) return;
window.__svrntyAdwrightLoaded = true;
const NS = (window.SvrntyAdwright = window.SvrntyAdwright || {});
const TABS = [
{ id: "overview", label: "Overview" },
{ id: "cycles", label: "Cycles" },
{ id: "audience", label: "Audience" },
{ id: "targeting", label: "Targeting" },
{ id: "connections", label: "Connections" },
];
const POLL_INTERVAL_MS = 2000;
const POLL_MAX_MS = 30000;
// ── State ──────────────────────────────────────────────────────────────
NS.state = {
activeTab: "overview",
mounted: false,
pollTimer: null,
pollStartedAt: 0,
lastSeenTs: 0,
pendingTool: null,
data: {
cycles: null,
cycleDetail: {},
segments: null,
recipes: null,
connections: null,
},
};
// ── Active-profile detection (PRD §3 visibility gating) ────────────────
// hermes-webui exposes S.activeProfile (see static/ui.js line 1). We probe
// it defensively — the panel always renders, but enables only for CMO.
function _activeProfile() {
try {
return (window.S && window.S.activeProfile) || "default";
} catch (_) { return "default"; }
}
function _isCmoActive() {
return String(_activeProfile()).toLowerCase().indexOf("cmo") === 0;
}
function _activeSessionId() {
try {
return (window.S && window.S.session && window.S.session.session_id) || "";
} catch (_) { return ""; }
}
// ── Mount / unmount ────────────────────────────────────────────────────
// Anchor: <main class="main"> contains #mainChat (the WebUI chat view).
// We inject our panel as a SIBLING before #mainChat. CSS handles the 60/40
// split via :has() (modern browsers — Chrome 105+, Safari 15.4+, Firefox 121+).
function _findMountTargets() {
const main = document.querySelector("main.main");
const chat = document.getElementById("mainChat");
if (!main || !chat) return null;
return { main, chat };
}
function _mount() {
if (NS.state.mounted) return true;
if (document.querySelector(".svrnty-aw-panel")) {
NS.state.mounted = true;
return true;
}
const targets = _findMountTargets();
if (!targets) return false;
const panel = document.createElement("div");
panel.className = "svrnty-aw-panel";
panel.id = "svrntyAdwrightPanel";
panel.innerHTML = _renderShell();
targets.main.insertBefore(panel, targets.chat);
_wireNav(panel);
_wireOverview(panel);
_wireCycles(panel);
_wireConnections(panel);
_refreshDisabledState();
_renderTab(NS.state.activeTab);
NS.state.mounted = true;
return true;
}
// ── Render: shell + tabs ───────────────────────────────────────────────
function _renderShell() {
const nav = TABS.map((t) =>
`<button class="svrnty-aw-nav-item${t.id === NS.state.activeTab ? " svrnty-aw-nav-active" : ""}" data-svrnty-aw-tab="${t.id}">${t.label}</button>`
).join("");
const tabs = TABS.map((t) =>
`<div class="svrnty-aw-tab" data-svrnty-aw-pane="${t.id}"></div>`
).join("");
return (
'<div class="svrnty-aw-header">' +
'<div class="svrnty-aw-title">Adwright</div>' +
'<div class="svrnty-aw-status">' +
'<span class="svrnty-aw-status-dot" id="svrntyAwDot"></span>' +
'<span id="svrntyAwStatusText">checking…</span>' +
'</div>' +
'</div>' +
'<div class="svrnty-aw-body">' +
'<nav class="svrnty-aw-nav">' + nav + '</nav>' +
'<div class="svrnty-aw-content">' +
'<div class="svrnty-aw-disabled-banner" id="svrntyAwDisabledBanner" style="display:none">' +
'Adwright is read-only — switch to the <strong>cmo</strong> profile to run actions.' +
'</div>' +
tabs +
'</div>' +
'</div>'
);
}
function _wireNav(panel) {
panel.querySelectorAll("[data-svrnty-aw-tab]").forEach((btn) => {
btn.addEventListener("click", () => {
const id = btn.getAttribute("data-svrnty-aw-tab");
_activateTab(id);
});
});
}
function _activateTab(id) {
NS.state.activeTab = id;
const panel = document.getElementById("svrntyAdwrightPanel");
if (!panel) return;
panel.querySelectorAll(".svrnty-aw-nav-item").forEach((btn) => {
btn.classList.toggle("svrnty-aw-nav-active",
btn.getAttribute("data-svrnty-aw-tab") === id);
});
panel.querySelectorAll(".svrnty-aw-tab").forEach((pane) => {
pane.classList.toggle("svrnty-aw-tab-active",
pane.getAttribute("data-svrnty-aw-pane") === id);
});
_renderTab(id);
}
function _refreshDisabledState() {
const dot = document.getElementById("svrntyAwDot");
const txt = document.getElementById("svrntyAwStatusText");
const banner = document.getElementById("svrntyAwDisabledBanner");
if (!dot || !txt) return;
const cmo = _isCmoActive();
dot.classList.toggle("svrnty-aw-active", cmo);
dot.classList.toggle("svrnty-aw-inactive", !cmo);
txt.textContent = cmo
? "cmo · ready"
: (_activeProfile() + " · view-only");
if (banner) banner.style.display = cmo ? "none" : "block";
// Disable all action buttons when CMO not active.
const panel = document.getElementById("svrntyAdwrightPanel");
if (panel) {
panel.querySelectorAll("[data-svrnty-aw-needs-cmo]").forEach((b) => {
b.disabled = !cmo;
});
}
}
// ── Tab renderers ─────────────────────────────────────────────────────
function _renderTab(id) {
if (id === "overview") return _renderOverview();
if (id === "cycles") return _renderCycles();
if (id === "audience") return _renderAudience();
if (id === "targeting") return _renderTargeting();
if (id === "connections") return _renderConnections();
}
function _pane(id) {
const panel = document.getElementById("svrntyAdwrightPanel");
if (!panel) return null;
return panel.querySelector(`[data-svrnty-aw-pane="${id}"]`);
}
// Overview — KPIs + spend bar + last-flow timeline ─────────────────────
function _renderOverview() {
const pane = _pane("overview");
if (!pane) return;
const d = NS.state.data;
const kpis = _deriveKpis(d.cycles, d.recipes);
pane.innerHTML =
'<div class="svrnty-aw-tab-header">' +
'<div class="svrnty-aw-tab-title">Overview</div>' +
'<button class="svrnty-aw-btn" data-svrnty-aw-action="refresh-overview" data-svrnty-aw-needs-cmo>Refresh</button>' +
'</div>' +
'<div class="svrnty-aw-kpi-grid">' +
_kpiCard("Impressions", kpis.impressions, kpis.imprDelta) +
_kpiCard("CTR", kpis.ctr, kpis.ctrDelta) +
_kpiCard("Cycles", kpis.cycleCount, "") +
_kpiCard("Recipes", kpis.recipeCount, "") +
'</div>' +
'<div class="svrnty-aw-section">' +
'<div class="svrnty-aw-section-title">Spend</div>' +
'<div style="font-size:12px;color:var(--muted)">$' +
_fmt(kpis.spend) + ' / $' + _fmt(kpis.budget) + '</div>' +
'<div class="svrnty-aw-spend-bar"><div class="svrnty-aw-spend-bar-fill" style="width:' +
Math.min(100, Math.round((kpis.spend / Math.max(1, kpis.budget)) * 100)) + '%"></div></div>' +
'</div>' +
'<div class="svrnty-aw-section">' +
'<div class="svrnty-aw-section-title">Last flow timeline</div>' +
_renderTimeline(d.cycles) +
'</div>';
_rewireActions(pane);
_refreshDisabledState();
}
function _wireOverview(panel) {
// Initial paint on first mount triggers a refresh attempt.
// Defer so the panel renders first; chat send needs S.session ready.
setTimeout(() => {
if (NS.state.activeTab === "overview" && _isCmoActive()) {
_fireAction("refresh-overview");
}
}, 500);
}
function _kpiCard(label, value, delta) {
const dCls = delta && delta.startsWith("-")
? "svrnty-aw-kpi-delta-down"
: (delta ? "svrnty-aw-kpi-delta-up" : "");
return (
'<div class="svrnty-aw-kpi">' +
'<div class="svrnty-aw-kpi-label">' + _esc(label) + '</div>' +
'<div class="svrnty-aw-kpi-value">' + _esc(value) + '</div>' +
(delta ? '<div class="svrnty-aw-kpi-delta ' + dCls + '">' + _esc(delta) + '</div>' : '') +
'</div>'
);
}
function _renderTimeline(cycles) {
if (!cycles || !cycles.length) {
return '<div class="svrnty-aw-empty">No recent activity. Click Refresh.</div>';
}
return '<div class="svrnty-aw-timeline">' +
cycles.slice(0, 5).map((c) =>
'<div class="svrnty-aw-timeline-item">' +
'<div class="svrnty-aw-timeline-time">' + _esc(c.started_at || "") + '</div>' +
'<div class="svrnty-aw-timeline-text">' +
_esc(c.title || ("Cycle #" + (c.id || "?"))) +
' — <span style="color:var(--muted)">' + _esc(c.status || "") + '</span>' +
'</div>' +
'</div>'
).join("") +
'</div>';
}
function _deriveKpis(cycles, recipes) {
const c = cycles || [];
const r = recipes || [];
const spend = c.reduce((s, x) => s + (x.spend || 0), 0);
const budget = c.reduce((s, x) => s + (x.budget || 0), 0) || 6000;
const impressions = c.reduce((s, x) => s + (x.impressions || 0), 0);
const ctrs = c.map((x) => x.ctr || 0).filter((v) => v > 0);
const ctr = ctrs.length ? (ctrs.reduce((s, x) => s + x, 0) / ctrs.length) : 0;
return {
impressions: impressions ? _abbrev(impressions) : "—",
imprDelta: impressions ? "+12%" : "",
ctr: ctr ? (ctr.toFixed(2) + "%") : "—",
ctrDelta: ctr ? "-0.3%" : "",
cycleCount: String(c.length || "—"),
recipeCount: String(r.length || "—"),
spend: spend,
budget: budget,
};
}
// Cycles — list with click-to-expand ───────────────────────────────────
function _renderCycles() {
const pane = _pane("cycles");
if (!pane) return;
const cycles = NS.state.data.cycles || [];
pane.innerHTML =
'<div class="svrnty-aw-tab-header">' +
'<div class="svrnty-aw-tab-title">Cycles</div>' +
'<button class="svrnty-aw-btn" data-svrnty-aw-action="refresh-cycles" data-svrnty-aw-needs-cmo>Refresh cycles</button>' +
'</div>' +
(cycles.length
? ('<div class="svrnty-aw-list">' +
cycles.map((c) => _cycleRow(c)).join("") +
'</div>')
: '<div class="svrnty-aw-empty">No cycles loaded. Click Refresh cycles.</div>');
_rewireActions(pane);
pane.querySelectorAll("[data-svrnty-aw-cycle]").forEach((row) => {
row.addEventListener("click", () => {
const id = row.getAttribute("data-svrnty-aw-cycle");
_toggleCycleDetail(row, id);
});
});
_refreshDisabledState();
}
function _cycleRow(c) {
return (
'<div class="svrnty-aw-row" data-svrnty-aw-cycle="' + _attr(c.id) + '">' +
'<div class="svrnty-aw-row-main">' +
'<div class="svrnty-aw-row-title">' + _esc(c.title || ("Cycle #" + c.id)) + '</div>' +
'<div class="svrnty-aw-row-sub">' + _esc(c.status || "") + ' · started ' + _esc(c.started_at || "") + '</div>' +
'</div>' +
'<div class="svrnty-aw-row-meta">$' + _fmt(c.spend || 0) + ' / $' + _fmt(c.budget || 0) + '</div>' +
'</div>'
);
}
function _toggleCycleDetail(rowEl, cycleId) {
const existing = rowEl.nextElementSibling;
if (existing && existing.classList.contains("svrnty-aw-row-expanded")) {
existing.remove();
return;
}
const detail = NS.state.data.cycleDetail[cycleId];
const wrap = document.createElement("div");
wrap.className = "svrnty-aw-row-expanded";
if (detail) {
wrap.innerHTML = _renderCycleDetail(detail);
} else {
wrap.innerHTML = '<div class="svrnty-aw-loading">Loading cycle #' + _esc(cycleId) + '…</div>';
_fireAction("get-cycle", { id: cycleId });
}
rowEl.parentNode.insertBefore(wrap, rowEl.nextSibling);
}
function _renderCycleDetail(d) {
const variants = d.variants || [];
if (!variants.length) return '<div class="svrnty-aw-empty">No variants for this cycle.</div>';
return '<strong style="font-size:12px">Variants (' + variants.length + ')</strong>' +
'<div style="margin-top:6px;display:flex;flex-direction:column;gap:4px">' +
variants.map((v) =>
'<div style="display:flex;justify-content:space-between;font-size:12px">' +
'<span>' + _esc(v.name || v.id || "variant") + '</span>' +
'<span style="color:var(--muted)">' + _esc(v.status || "") + ' · imp ' + _fmt(v.impressions || 0) + '</span>' +
'</div>'
).join("") +
'</div>';
}
function _wireCycles(panel) { /* nav handler fires render */ }
// Audience — segments list ────────────────────────────────────────────
function _renderAudience() {
const pane = _pane("audience");
if (!pane) return;
const segs = NS.state.data.segments || [];
pane.innerHTML =
'<div class="svrnty-aw-tab-header">' +
'<div class="svrnty-aw-tab-title">Audience segments</div>' +
'<button class="svrnty-aw-btn" data-svrnty-aw-action="list-segments" data-svrnty-aw-needs-cmo>Refresh</button>' +
'</div>' +
(segs.length
? '<div class="svrnty-aw-list">' +
segs.map((s) =>
'<div class="svrnty-aw-row">' +
'<div class="svrnty-aw-row-main">' +
'<div class="svrnty-aw-row-title">' + _esc(s.name || s.id) + '</div>' +
'<div class="svrnty-aw-row-sub">' + _esc(s.description || "") + '</div>' +
'</div>' +
'<div class="svrnty-aw-row-meta">' + _fmt(s.size || 0) + ' people</div>' +
'</div>'
).join("") +
'</div>'
: '<div class="svrnty-aw-empty">No segments loaded. Click Refresh.</div>');
_rewireActions(pane);
_refreshDisabledState();
}
// Targeting — segment × recipe matrix ─────────────────────────────────
function _renderTargeting() {
const pane = _pane("targeting");
if (!pane) return;
const segs = NS.state.data.segments || [];
const recs = NS.state.data.recipes || [];
pane.innerHTML =
'<div class="svrnty-aw-tab-header">' +
'<div class="svrnty-aw-tab-title">Targeting matrix</div>' +
'<button class="svrnty-aw-btn" data-svrnty-aw-action="list-recipes" data-svrnty-aw-needs-cmo>Refresh recipes</button>' +
'</div>' +
(segs.length && recs.length
? _renderMatrix(segs, recs)
: '<div class="svrnty-aw-empty">Load segments + recipes to see the matrix.<br>' +
'<span style="font-size:11px">(Audience tab → Refresh, then Refresh recipes here.)</span></div>');
_rewireActions(pane);
_refreshDisabledState();
}
function _renderMatrix(segs, recs) {
const cols = recs.length + 1;
const cells = [];
cells.push('<div class="svrnty-aw-matrix-cell svrnty-aw-matrix-head"></div>');
recs.forEach((r) => {
cells.push('<div class="svrnty-aw-matrix-cell svrnty-aw-matrix-head">' +
_esc(r.name || r.id) + '</div>');
});
segs.forEach((s) => {
cells.push('<div class="svrnty-aw-matrix-cell svrnty-aw-matrix-row-label">' +
_esc(s.name || s.id) + '</div>');
recs.forEach((r) => {
cells.push('<div class="svrnty-aw-matrix-cell">·</div>');
});
});
return '<div class="svrnty-aw-matrix" style="grid-template-columns:repeat(' + cols + ',minmax(80px,1fr))">' +
cells.join("") + '</div>';
}
// Connections — read status + provisioning form (governance exception) ─
function _renderConnections() {
const pane = _pane("connections");
if (!pane) return;
const conns = NS.state.data.connections || [];
pane.innerHTML =
'<div class="svrnty-aw-tab-header">' +
'<div class="svrnty-aw-tab-title">Connections</div>' +
'<button class="svrnty-aw-btn" data-svrnty-aw-action="connections-status" data-svrnty-aw-needs-cmo>Re-check</button>' +
'</div>' +
'<div class="svrnty-aw-conn-list">' +
(conns.length ? conns.map(_connRow).join("") :
'<div class="svrnty-aw-empty">No status loaded. Click Re-check.</div>') +
'</div>' +
'<button class="svrnty-aw-btn svrnty-aw-btn-primary" data-svrnty-aw-action="open-cred-form">Fix credentials</button>' +
'<form class="svrnty-aw-form" id="svrntyAwCredForm">' +
'<div class="svrnty-aw-form-note">' +
'Credentials are written direct to the credctl vault via the plugin backend — ' +
'<strong>never through chat or MCP</strong> (governance exception, see PRD §6). ' +
'Leave a field blank to keep its current value.' +
'</div>' +
_credField("meta-app-id", "Meta — App ID", "text") +
_credField("meta-app-secret", "Meta — App Secret", "password") +
_credField("meta-sandbox-access-token", "Meta — Sandbox Access Token", "password") +
_credField("meta-sandbox-ad-account", "Meta — Sandbox Ad Account ID", "text") +
_credField("meta-sandbox-page-id", "Meta — Sandbox Page ID", "text") +
_credField("woocommerce-ck", "WooCommerce — Consumer Key", "password") +
_credField("woocommerce-cs", "WooCommerce — Consumer Secret", "password") +
'<div class="svrnty-aw-form-actions">' +
'<button type="button" class="svrnty-aw-btn" data-svrnty-aw-action="close-cred-form">Cancel</button>' +
'<button type="submit" class="svrnty-aw-btn svrnty-aw-btn-primary">Save credentials</button>' +
'</div>' +
'</form>';
_rewireActions(pane);
_refreshDisabledState();
}
function _credField(name, label, type) {
return '<div class="svrnty-aw-form-row">' +
'<label for="svrntyAwCred-' + name + '">' + _esc(label) + '</label>' +
'<input id="svrntyAwCred-' + name + '" name="' + name + '" type="' + type + '" autocomplete="off">' +
'</div>';
}
function _connRow(c) {
const okCls = c.ok ? " svrnty-aw-ok" : " svrnty-aw-fail";
return (
'<div class="svrnty-aw-conn">' +
'<div class="svrnty-aw-conn-name">' +
_esc(c.name || c.id || "unknown") +
'</div>' +
'<span class="svrnty-aw-conn-pill' + okCls + '">' +
_esc(c.status || (c.ok ? "ok" : "fail")) +
'</span>' +
'</div>'
);
}
function _wireConnections(panel) {
panel.addEventListener("submit", (e) => {
if (e.target && e.target.id === "svrntyAwCredForm") {
e.preventDefault();
_submitCredForm(e.target);
}
});
}
function _submitCredForm(form) {
const fd = new FormData(form);
const payload = {};
fd.forEach((v, k) => { if (v && String(v).trim()) payload[k] = String(v); });
if (!Object.keys(payload).length) {
_toast("No fields filled — nothing to save.", "error");
return;
}
_toast("Provisioning credentials…");
fetch("/api/adwright/provision-creds", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
.then((r) => r.json())
.then((r) => {
if (r && r.ok) {
_toast("Credentials saved (" + (r.written || []).length + " field" +
((r.written || []).length === 1 ? "" : "s") + ").", "success");
form.reset();
form.classList.remove("svrnty-aw-open");
_fireAction("connections-status");
} else {
_toast("Provisioning failed: " + ((r && r.error) || "unknown"), "error");
}
})
.catch((e) => _toast("Provisioning request failed: " + (e && e.message), "error"));
}
// ── Action wiring ──────────────────────────────────────────────────────
// Reads: panel button → custom event → injects "/adwright <verb>" into the
// existing WebUI composer (#msg) and clicks #btnSend so the user message
// shows in chat. Then polls /api/adwright/last-panel-update.
// Writes (creds only): direct POST to /api/adwright/provision-creds.
function _rewireActions(scope) {
scope.querySelectorAll("[data-svrnty-aw-action]").forEach((btn) => {
if (btn.dataset.svrntyAwWired === "1") return;
btn.dataset.svrntyAwWired = "1";
btn.addEventListener("click", (e) => {
e.preventDefault();
const action = btn.getAttribute("data-svrnty-aw-action");
_handleAction(action, btn);
});
});
}
function _handleAction(action, btn) {
if (action === "open-cred-form") {
const f = document.getElementById("svrntyAwCredForm");
if (f) f.classList.add("svrnty-aw-open");
return;
}
if (action === "close-cred-form") {
const f = document.getElementById("svrntyAwCredForm");
if (f) f.classList.remove("svrnty-aw-open");
return;
}
_fireAction(action);
}
function _fireAction(action, args) {
if (!_isCmoActive()) {
_toast("Switch to CMO profile to run /adwright commands.", "error");
return;
}
args = args || {};
const cmd = _commandFor(action, args);
if (!cmd) return;
// Dispatch event for any external listeners (audit / tests).
try {
document.dispatchEvent(new CustomEvent("adwright:action",
{ detail: { action: action, args: args, command: cmd } }));
} catch (_) { /* ignore */ }
NS.state.pendingTool = _toolFor(action);
if (!_sendChatCommand(cmd)) {
_toast("Couldn't post to chat — is a session open?", "error");
return;
}
_startPolling();
}
function _commandFor(action, args) {
switch (action) {
case "refresh-overview": return "/adwright refresh-cycles";
case "refresh-cycles": return "/adwright refresh-cycles";
case "get-cycle": return "/adwright get-cycle " + (args.id || "");
case "list-segments": return "/adwright list-segments";
case "list-recipes": return "/adwright list-recipes";
case "connections-status": return "/adwright connections-status";
default: return null;
}
}
function _toolFor(action) {
switch (action) {
case "refresh-overview":
case "refresh-cycles": return "adwright_refresh_cycles";
case "get-cycle": return "adwright_get_cycle";
case "list-segments": return "adwright_list_segments";
case "list-recipes": return "adwright_list_recipes";
case "connections-status": return "adwright_get_connections_status";
default: return null;
}
}
function _sendChatCommand(text) {
const msg = document.getElementById("msg");
const btn = document.getElementById("btnSend");
if (!msg || !btn) return false;
msg.value = text;
// Notify WebUI listeners that the textarea changed (enables btnSend).
try { msg.dispatchEvent(new Event("input", { bubbles: true })); } catch (_) {}
// Wait one tick so btnSend's disabled state updates, then click.
setTimeout(() => {
try { btn.click(); } catch (_) { /* ignore */ }
}, 30);
return true;
}
// ── Polling: /api/adwright/last-panel-update ──────────────────────────
function _startPolling() {
if (NS.state.pollTimer) return;
NS.state.pollStartedAt = Date.now();
NS.state.pollTimer = setInterval(_pollOnce, POLL_INTERVAL_MS);
// Run once immediately so we don't wait the first 2s.
_pollOnce();
}
function _stopPolling() {
if (NS.state.pollTimer) {
clearInterval(NS.state.pollTimer);
NS.state.pollTimer = null;
}
}
function _pollOnce() {
if (Date.now() - NS.state.pollStartedAt > POLL_MAX_MS) {
_stopPolling();
return;
}
const sid = _activeSessionId();
const tool = NS.state.pendingTool || "";
const url = "/api/adwright/last-panel-update?session_id=" +
encodeURIComponent(sid) + "&since=" + NS.state.lastSeenTs +
"&tool=" + encodeURIComponent(tool);
fetch(url)
.then((r) => r.json())
.then((r) => {
if (!r || !r.update) return;
const u = r.update;
if (u.ts && u.ts <= NS.state.lastSeenTs) return;
NS.state.lastSeenTs = u.ts || Date.now();
_ingestUpdate(u);
// Got our update — stop polling, render.
if (u.tool === tool || !tool) {
NS.state.pendingTool = null;
_stopPolling();
}
_renderTab(NS.state.activeTab);
})
.catch(() => { /* polling is best-effort */ });
}
function _ingestUpdate(u) {
const tool = u.tool || "";
const payload = u.payload || {};
if (tool === "adwright_refresh_cycles" || tool === "adwright_list_cycles") {
NS.state.data.cycles = payload.cycles || [];
} else if (tool === "adwright_get_cycle") {
if (payload.id != null) NS.state.data.cycleDetail[payload.id] = payload;
} else if (tool === "adwright_list_segments") {
NS.state.data.segments = payload.segments || [];
} else if (tool === "adwright_list_recipes") {
NS.state.data.recipes = payload.recipes || [];
} else if (tool === "adwright_get_connections_status") {
NS.state.data.connections = payload.connections || [];
}
}
// ── Toast ─────────────────────────────────────────────────────────────
function _toast(text, kind) {
let el = document.getElementById("svrntyAwToast");
if (!el) {
el = document.createElement("div");
el.id = "svrntyAwToast";
el.className = "svrnty-aw-toast";
document.body.appendChild(el);
}
el.className = "svrnty-aw-toast" + (kind ? " svrnty-aw-toast-" + kind : "");
el.textContent = text;
requestAnimationFrame(() => el.classList.add("svrnty-aw-toast-show"));
clearTimeout(_toast._t);
_toast._t = setTimeout(() => {
el.classList.remove("svrnty-aw-toast-show");
}, 3000);
}
// ── Helpers ───────────────────────────────────────────────────────────
function _esc(s) {
return String(s == null ? "" : s).replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
function _attr(s) { return _esc(s); }
function _fmt(n) {
n = Number(n) || 0;
return n.toLocaleString("en-US", { maximumFractionDigits: 0 });
}
function _abbrev(n) {
n = Number(n) || 0;
if (n >= 1e6) return (n / 1e6).toFixed(1) + "M";
if (n >= 1e3) return (n / 1e3).toFixed(1) + "K";
return String(n);
}
// ── Bootstrap ─────────────────────────────────────────────────────────
// Mount when <main class="main"> exists; observer scoped to <body> children
// (NOT subtree) so we don't fight other observers. Reattach the profile
// watcher whenever the panel is (re)mounted.
function _bootstrap() {
if (_mount()) {
_startProfileWatcher();
return;
}
const obs = new MutationObserver(() => {
if (_mount()) {
_startProfileWatcher();
obs.disconnect();
}
});
obs.observe(document.body, { childList: true, subtree: false });
// Fallback re-check (some shells rebuild <main> after route changes).
setTimeout(() => { _mount(); _startProfileWatcher(); }, 1500);
}
let _profileWatcherStarted = false;
function _startProfileWatcher() {
if (_profileWatcherStarted) return;
_profileWatcherStarted = true;
let last = _activeProfile();
setInterval(() => {
const now = _activeProfile();
if (now !== last) {
last = now;
_refreshDisabledState();
}
}, 1000);
}
NS.mount = _mount;
NS.fireAction = _fireAction;
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", _bootstrap);
} else {
_bootstrap();
}
})();