feat(plugin): Adwright + BTE Command Center panels (v0.4.0)
plugin-tests / test (push) Failing after 5s

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>
This commit is contained in:
Svrnty
2026-05-24 12:12:27 -04:00
parent 33014fbea9
commit 0b19fdd7d0
12 changed files with 2475 additions and 5 deletions
+717
View File
@@ -0,0 +1,717 @@
// 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();
}
})();