feat(plugin): Adwright + BTE Command Center panels (v0.4.0)
Some checks failed
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

View File

@ -52,3 +52,24 @@ tests/{unit,integration,evals}/
- After ANY structural change: `python3 scripts/ast-connection-map.py` then commit the regen. CI fails PR otherwise
- New loader API methods require: protocol PRD bump + `protocol-validate.sh` assertion + fork loader patch update
- Adding upstream imports: justify in `CONNECTION-MAP.md` BEFORE the import lands — the AST gen will flag it, but reviewer should see the rationale committed alongside
## Site map — where to find anything in cortex-os
Read these in order to ground any session:
| What | Where |
|---|---|
| **Karpathy 4 rules** | `~/.claude/CLAUDE.md` (auto-inherited every session) |
| **Workspace contract + repo map** | `~/workspaces/hermes/CLAUDE.md` |
| **SOT library orientation** | `~/workspaces/hermes/sot/README.md` |
| **Curator-generated SOT index** | `~/workspaces/hermes/sot/INDEX.md` |
| **Profile catalog (5 profiles + tool disclosure + governance)** | `~/workspaces/hermes/sot/06-REGISTRY/PROFILE-CATALOG.md` |
| **Profile distribution protocol (T1)** | `~/workspaces/hermes/sot/03-PROTOCOLS/PROFILE-DISTRIBUTION-PROTOCOL.md` |
| **Frontmatter spec (T1)** | `~/workspaces/hermes/sot/04-STANDARDS/FRONTMATTER-SPEC.md` |
| **SOT enforcement (pre-commit + curator + pre-push)** | `~/workspaces/hermes/sot/04-STANDARDS/SOT-ENFORCEMENT.md` |
| **Living graph artifact** | `~/workspaces/hermes/graph/umbrella.json` (curator-maintained) |
| **Living graph UI panel (planned)** | `/umbrella` route in hermes-webui per `sot/03-PROTOCOLS/CORTEX-OS-UMBRELLA-VIZ-PRD.md` |
| **This repo's CONTRACT.md** | `./CONTRACT.md` if present (T1 — wins over everything in this repo) |
If you're new to a session: read the workspace contract first, then this file, then the SOT orientation. Don't guess about cortex-os structure — anchor to these.

View File

@ -1,8 +1,8 @@
# CONNECTION MAP — svrnty-hermes-webui-plugin → nesquena/hermes-webui
**Upstream version:** v0.51.118
**Plugin version:** 0.2.0
**Total dependencies:** 10 (9 public API · 0 forced internal · 1 frontend)
**Plugin version:** 0.4.0
**Total dependencies:** 24 (19 public API · 0 forced internal · 5 frontend)
> **Auto-generated by `scripts/ast-connection-map.py`. Do not hand-edit.**
> To change a justification, edit the `# CONNECTION:` comment above the
@ -18,6 +18,16 @@
| `plugin.py:34` | `api.register_static` | `api.register_static(STATIC_PREFIX, str(STATIC_DIR))` |
| `plugin.py:35` | `api.inject_stylesheet` | `api.inject_stylesheet(f"/plugins/{STATIC_PREFIX}/app.css")` |
| `plugin.py:36` | `api.inject_script` | `api.inject_script(f"/plugins/{STATIC_PREFIX}/app.js")` |
| `plugin.py:40` | `api.inject_stylesheet` | `api.inject_stylesheet(f"/plugins/{STATIC_PREFIX}/adwright.css")` |
| `plugin.py:41` | `api.inject_script` | `api.inject_script(f"/plugins/{STATIC_PREFIX}/adwright.js")` |
| `plugin.py:46` | `api.inject_stylesheet` | `api.inject_stylesheet(f"/plugins/{STATIC_PREFIX}/bte.css")` |
| `plugin.py:47` | `api.inject_script` | `api.inject_script(f"/plugins/{STATIC_PREFIX}/bte.js")` |
| `routes/adwright.py:43` | `api.logger` | `log = api.logger("svrnty.routes.adwright")` |
| `routes/adwright.py:44` | `api.register_route` | `api.register_route(` |
| `routes/adwright.py:46` | `api.register_route` | `api.register_route(` |
| `routes/bte_proxy.py:40` | `api.logger` | `log = api.logger("svrnty.routes.bte_proxy")` |
| `routes/bte_proxy.py:41` | `api.register_route` | `api.register_route("/api/bte/proxy", "GET", _handle_proxy)` |
| `routes/bte_proxy.py:42` | `api.register_route` | `api.register_route("/api/bte/proxy", "POST", _handle_proxy)` |
| `routes/transcribe.py:37` | `api.logger` | `log = api.logger("svrnty.routes.transcribe")` |
| `routes/transcribe.py:38` | `api.register_route` | `api.register_route("/api/transcribe", "POST", _handle_transcribe)` |
| `routes/transcribe.py:39` | `api.register_audio_attachment_processor` | `api.register_audio_attachment_processor(_transcribe_audio_attachments)` |
@ -38,5 +48,9 @@ _None. Plugin uses only the public API._ ✓
| File | Line | URL |
|---|---|---|
| `static/bte.js` | 330 | `/api/command/requestPhotoshoot` |
| `static/bte.js` | 369 | `/api/query/assetGrid` |
| `static/bte.js` | 483 | `/api/command/rateAsset` |
| `static/adwright.js` | 478 | `/api/adwright/provision-creds` |
| `static/app.js` | 165 | `/api/vault/status` |

View File

@ -1,7 +1,7 @@
# svrnty-hermes-webui-plugin — manifest.
# Read by hermes-webui plugin loader + sync tooling. Machine-readable identity.
plugin_name: svrnty-hermes-webui-plugin
plugin_version: 0.2.0
plugin_version: 0.4.0
entry_point: svrnty_hermes_webui_plugin:register
upstream:
@ -28,14 +28,22 @@ public_api:
assets:
scripts:
- /plugins/svrnty/app.js
- /plugins/svrnty/adwright.js
- /plugins/svrnty/bte.js
stylesheets:
- /plugins/svrnty/app.css
- /plugins/svrnty/adwright.css
- /plugins/svrnty/bte.css
# Routes this plugin registers at load time (declarative cross-check vs runtime).
# Each row maps to a routes/<file>.py.
routes:
- { path: /api/transcribe, method: POST, file: routes/transcribe.py, status: live }
- { path: /api/vault/status, method: GET, file: routes/vault_status.py, status: live }
- { path: /api/transcribe, method: POST, file: routes/transcribe.py, status: live }
- { path: /api/vault/status, method: GET, file: routes/vault_status.py, status: live }
- { path: /api/adwright/last-panel-update, method: GET, file: routes/adwright.py, status: mock }
- { path: /api/adwright/provision-creds, method: POST, file: routes/adwright.py, status: live }
- { path: /api/bte/proxy, method: GET, file: routes/bte_proxy.py, status: live }
- { path: /api/bte/proxy, method: POST, file: routes/bte_proxy.py, status: live }
# Audio-attachment processors (called by streaming.py before agent receives message).
audio_processors:

View File

@ -34,6 +34,17 @@ def register(api):
api.register_static(STATIC_PREFIX, str(STATIC_DIR))
api.inject_stylesheet(f"/plugins/{STATIC_PREFIX}/app.css")
api.inject_script(f"/plugins/{STATIC_PREFIX}/app.js")
# Adwright tool panel (ADWRIGHT-PANEL-PRD §7) — additional assets
# served from the same static dir, injected after the brand skin so
# adwright.css overrides any conflicting brand defaults.
api.inject_stylesheet(f"/plugins/{STATIC_PREFIX}/adwright.css")
api.inject_script(f"/plugins/{STATIC_PREFIX}/adwright.js")
# BTE Command Center panel (COMMAND-CENTER-PRD §3 + PLANB-RECIPE-TAXONOMY).
# Independent IIFE under window.SvrntyBTE namespace; ordering doesn't
# matter — both panels coexist via distinct .svrnty-bte-* / adwright
# selectors and namespaces.
api.inject_stylesheet(f"/plugins/{STATIC_PREFIX}/bte.css")
api.inject_script(f"/plugins/{STATIC_PREFIX}/bte.js")
log.info("static + assets wired at /plugins/%s/", STATIC_PREFIX)
# Routes — each feature lives in its own module under routes/.
@ -61,4 +72,6 @@ def _phase2_routes():
return [
"transcribe", # P2.A — STT + voice-message audio processor ✓
"vault_status", # P2.B — vault connections status ✓
"adwright", # P2.C — Adwright tool panel routes (PRD §5+§6) ✓
"bte_proxy", # P2.D — BTE Command Center same-origin proxy (PRD §3) ✓
]

226
routes/adwright.py Normal file
View File

@ -0,0 +1,226 @@
"""Adwright tool panel — plugin backend routes.
Per ADWRIGHT-PANEL-PRD §5-§6:
GET /api/adwright/last-panel-update read channel for the frontend panel
(polled while a /adwright cmd is
pending; reads from session DB).
v1 returns mocked data per tool name
while adwright-mcp is still wiring
its writes (PRD §5 [8] TBD).
POST /api/adwright/provision-creds governance exception write path.
Writes Meta + Woo credentials direct
to the credctl vault, bypassing chat
and MCP. NO secret ever logged.
Public API surface used: api.register_route, api.logger.
No forced internal dependencies uses subprocess to call credctl directly.
"""
import json
import os
import subprocess
import time
import urllib.parse
# Same credctl path as routes/vault_status.py (consistency).
_DEFAULT_CREDCTL = "/home/svrnty/workspaces/cortex/L6-svrnty.core-credentials/credctl"
# Credential keys the panel form is allowed to write. Anything else in the
# POST body is silently dropped — defense in depth against a compromised
# frontend trying to write arbitrary vault keys.
_ALLOWED_CRED_KEYS = frozenset({
"meta-app-id",
"meta-app-secret",
"meta-sandbox-access-token",
"meta-sandbox-ad-account",
"meta-sandbox-page-id",
"woocommerce-ck",
"woocommerce-cs",
})
def register(api):
"""Wire both Adwright plugin routes."""
log = api.logger("svrnty.routes.adwright")
api.register_route(
"/api/adwright/last-panel-update", "GET", _handle_last_panel_update)
api.register_route(
"/api/adwright/provision-creds", "POST", _handle_provision_creds)
log.info("adwright panel routes registered (last-panel-update + provision-creds)")
# ── GET /api/adwright/last-panel-update ─────────────────────────────────────
# v1: until adwright-mcp writes structured payloads to the session DB
# (PRD §5 step [8], table adwright_panel_updates), this returns mocked
# data keyed by ?tool= so the panel can be exercised end-to-end.
# Real wiring lands when adwright-mcp ships in parallel.
_MOCK_CYCLES = [
{"id": 14, "title": "Cycle #14 · May 2026", "status": "running",
"started_at": "2026-05-20", "spend": 4210, "budget": 6000,
"impressions": 1240000, "ctr": 2.1},
{"id": 13, "title": "Cycle #13 · Apr 2026", "status": "complete",
"started_at": "2026-04-15", "spend": 5800, "budget": 6000,
"impressions": 980000, "ctr": 1.8},
{"id": 12, "title": "Cycle #12 · Mar 2026", "status": "complete",
"started_at": "2026-03-10", "spend": 5990, "budget": 6000,
"impressions": 1100000, "ctr": 2.4},
]
_MOCK_SEGMENTS = [
{"id": "seg-1", "name": "Lookalike — buyers 30d", "size": 850000,
"description": "Meta lookalike from WooCommerce buyers, 30-day window"},
{"id": "seg-2", "name": "Cart abandoners", "size": 12400,
"description": "Pixel: AddToCart without Purchase, 14d"},
{"id": "seg-3", "name": "Newsletter subscribers", "size": 4800,
"description": "Mailchimp list sync"},
]
_MOCK_RECIPES = [
{"id": "rec-1", "name": "Prospecting · video"},
{"id": "rec-2", "name": "Retargeting · carousel"},
{"id": "rec-3", "name": "Brand · static"},
]
_MOCK_CONNECTIONS = [
{"id": "meta", "name": "Meta (Facebook + Instagram)", "status": "ok", "ok": True},
{"id": "woocommerce", "name": "WooCommerce", "status": "ok", "ok": True},
]
def _mock_payload_for(tool: str) -> dict:
if tool in ("adwright_list_cycles", "adwright_refresh_cycles"):
return {"cycles": _MOCK_CYCLES}
if tool == "adwright_get_cycle":
# Return cycle #14's expanded form with mock variants.
return {
"id": 14, "title": "Cycle #14 · May 2026", "status": "running",
"variants": [
{"id": "v-1", "name": "video · audience A", "status": "active",
"impressions": 540000},
{"id": "v-2", "name": "video · audience B", "status": "active",
"impressions": 410000},
{"id": "v-3", "name": "carousel · retarget",
"status": "paused", "impressions": 290000},
],
}
if tool == "adwright_list_segments":
return {"segments": _MOCK_SEGMENTS}
if tool == "adwright_list_recipes":
return {"recipes": _MOCK_RECIPES}
if tool == "adwright_get_connections_status":
return {"connections": _MOCK_CONNECTIONS}
return {}
def _handle_last_panel_update(handler, parsed):
"""GET /api/adwright/last-panel-update?session_id=&since=&tool=
v1 returns a mocked update for the requested tool. Real impl will read
from session DB (table: adwright_panel_updates) once adwright-mcp writes
structured payloads see PRD §5 step [8] and §9 decision #12.
"""
qs = urllib.parse.parse_qs(parsed.query or "")
tool = (qs.get("tool", [""])[0] or "").strip()
since = qs.get("since", ["0"])[0]
try:
since_ts = int(since)
except (TypeError, ValueError):
since_ts = 0
if not tool:
# No tool specified → nothing pending; return empty (frontend treats
# as "no update").
return _send_json(handler, {"update": None, "mock": True}, 200)
now_ms = int(time.time() * 1000)
if since_ts and (now_ms - since_ts) < 500:
# Spec polls every 2s; if frontend just got an update <500ms ago,
# return empty so we don't double-render.
return _send_json(handler, {"update": None, "mock": True}, 200)
payload = _mock_payload_for(tool)
if not payload:
return _send_json(handler, {"update": None, "mock": True}, 200)
return _send_json(handler, {
"update": {
"ts": now_ms,
"tool": tool,
"payload": payload,
},
"mock": True,
}, 200)
# ── POST /api/adwright/provision-creds ──────────────────────────────────────
def _handle_provision_creds(handler, parsed):
"""POST /api/adwright/provision-creds — direct vault write (PRD §6).
Body: JSON object {<cred-key>: <value>, ...}. Only keys in
_ALLOWED_CRED_KEYS are written; others are silently dropped. Values are
piped to `credctl set <key>` via stdin never on argv, never in logs.
Returns {ok: bool, written: [keys], error?: str}. The list of WRITTEN
keys is safe to return (key names are public; values are not). Per PRD
§6, secrets NEVER touch CMO, adwright-mcp, hermes-agent, or session DB.
"""
length = int(handler.headers.get("Content-Length", "0") or 0)
if not length:
return _send_json(handler, {"ok": False, "error": "empty body"}, 400)
raw = handler.rfile.read(length)
try:
body = json.loads(raw.decode("utf-8"))
except Exception:
return _send_json(handler, {"ok": False, "error": "invalid JSON"}, 400)
if not isinstance(body, dict):
return _send_json(handler, {"ok": False, "error": "body must be object"}, 400)
credctl = os.environ.get("CREDCTL", _DEFAULT_CREDCTL)
written = []
errors = []
for key, val in body.items():
if key not in _ALLOWED_CRED_KEYS:
continue
if not isinstance(val, str) or not val.strip():
continue
try:
# credctl set <key> reads value from stdin; never on argv.
proc = subprocess.run(
[credctl, "set", key],
input=val,
text=True,
capture_output=True,
timeout=10,
)
if proc.returncode == 0:
written.append(key)
else:
# Never include `val` or proc.stderr in the response —
# stderr may echo the value back.
errors.append(key)
except Exception:
errors.append(key)
if errors and not written:
return _send_json(handler, {
"ok": False,
"written": [],
"error": "credctl write failed for: " + ",".join(errors),
}, 500)
return _send_json(handler, {
"ok": True,
"written": written,
"failed": errors,
}, 200)
# ── helpers ─────────────────────────────────────────────────────────────────
def _send_json(handler, payload: dict, status: int) -> bool:
body = json.dumps(payload).encode("utf-8")
handler.send_response(status)
handler.send_header("Content-Type", "application/json; charset=utf-8")
handler.send_header("Content-Length", str(len(body)))
handler.send_header("Cache-Control", "no-store")
handler.end_headers()
handler.wfile.write(body)
return True

117
routes/bte_proxy.py Normal file
View File

@ -0,0 +1,117 @@
"""GET|POST /api/bte/proxy?path=<bte-endpoint> — same-origin proxy to BTE REST.
The browser hits the plugin (same origin as webui :8787), the plugin forwards
to BTE REST (:6001 by default). This avoids cross-origin CORS, keeps BTE
free of webui-origin allowances, and lets us whitelist exactly which BTE
endpoints the panel can reach.
Configuration (read at call time, never persisted):
BTE_BASE_URL BTE REST base (default http://localhost:6001)
BTE_TENANT_ID default X-Tenant-Id forwarded to BTE (Plan B tenant uuid)
Public API surface used: api.register_route, api.logger.
Stdlib only no urllib3, no requests.
"""
import json
import os
import re
import urllib.error
import urllib.parse
import urllib.request
_DEFAULT_BTE_BASE = "http://localhost:6001"
_DEFAULT_TENANT = "00000000-0000-0000-0000-000000000001" # Plan B tenant
# Whitelist of allowed BTE paths. Static prefixes match exact path; pattern
# entries match a literal `/api/assets/<id>/<suffix>` form. Keep tight — the
# proxy is a privilege amplifier, only the panel's needs go here.
_ALLOWED_EXACT = frozenset({
"/api/command/requestPhotoshoot",
"/api/command/rateAsset",
"/api/query/assetGrid",
"/api/query/recipeStats",
})
# Pattern: /api/assets/<uuid-or-id>/(thumb|status). id-segment may not contain '/' or '?'.
_ALLOWED_PATTERN = re.compile(r"^/api/assets/[A-Za-z0-9_\-]+/(thumb|status)$")
def register(api):
"""Wire the GET + POST /api/bte/proxy routes."""
log = api.logger("svrnty.routes.bte_proxy")
api.register_route("/api/bte/proxy", "GET", _handle_proxy)
api.register_route("/api/bte/proxy", "POST", _handle_proxy)
log.info("bte proxy endpoint registered (GET+POST)")
def _is_allowed(path: str) -> bool:
if path in _ALLOWED_EXACT:
return True
if _ALLOWED_PATTERN.match(path):
return True
return False
def _handle_proxy(handler, parsed):
"""Forward a single request to BTE. Returns BTE response body verbatim."""
qs = urllib.parse.parse_qs(parsed.query or "")
target_path = (qs.get("path") or [""])[0].strip()
if not target_path or not target_path.startswith("/api/"):
return _send_json(handler, {"ok": False, "error": "missing or invalid path"}, 400)
if not _is_allowed(target_path):
return _send_json(handler, {"ok": False, "error": f"path not allowed: {target_path}"}, 403)
base = os.environ.get("BTE_BASE_URL", _DEFAULT_BTE_BASE).rstrip("/")
tenant = (handler.headers.get("X-Tenant-Id")
or os.environ.get("BTE_TENANT_ID", _DEFAULT_TENANT))
target_url = base + target_path
method = handler.command # 'GET' / 'POST'
body = b""
content_type = handler.headers.get("Content-Type", "application/json")
if method in ("POST", "PUT", "PATCH"):
length = int(handler.headers.get("Content-Length", "0") or 0)
if length > 0:
body = handler.rfile.read(length)
req = urllib.request.Request(target_url, data=body if body else None, method=method)
req.add_header("X-Tenant-Id", tenant)
if body:
req.add_header("Content-Type", content_type)
auth = handler.headers.get("Authorization")
if auth:
req.add_header("Authorization", auth)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
resp_body = resp.read()
resp_ctype = resp.headers.get("Content-Type", "application/octet-stream")
resp_status = resp.status
except urllib.error.HTTPError as e:
# Forward BTE's own error status + body — panel renders "endpoint coming
# soon" placeholders when it sees 404/501.
resp_body = e.read() or b""
resp_ctype = e.headers.get("Content-Type", "application/json") if e.headers else "application/json"
resp_status = e.code
except urllib.error.URLError as e:
return _send_json(handler, {"ok": False, "error": f"bte unreachable: {e.reason}"}, 502)
except Exception as e:
return _send_json(handler, {"ok": False, "error": f"proxy error: {e}"}, 500)
handler.send_response(resp_status)
handler.send_header("Content-Type", resp_ctype)
handler.send_header("Content-Length", str(len(resp_body)))
handler.send_header("Cache-Control", "no-store")
handler.end_headers()
handler.wfile.write(resp_body)
return True
def _send_json(handler, payload: dict, status: int) -> bool:
body = json.dumps(payload).encode("utf-8")
handler.send_response(status)
handler.send_header("Content-Type", "application/json; charset=utf-8")
handler.send_header("Content-Length", str(len(body)))
handler.send_header("Cache-Control", "no-store")
handler.end_headers()
handler.wfile.write(body)
return True

458
static/adwright.css Normal file
View File

@ -0,0 +1,458 @@
/* ============================================================================
Adwright tool panel injected into hermes-webui as a sibling to #mainChat.
Per ADWRIGHT-PANEL-PRD §7: ZERO hardcoded colors, all selectors prefixed
.svrnty-aw-*, themed via WebUI CSS vars so light/dark token-flips for free.
============================================================================ */
/* ── Layout — 60/40 panel/chat split inside <main class="main"> ──────────── */
/* When the Adwright panel is mounted, force main into a row flex so the
panel sits at 60% and #mainChat (existing) sits at 40%. We only override
when our wrapper is present so we don't break other views. */
main.main:has(> .svrnty-aw-panel) {
display: flex;
flex-direction: row;
align-items: stretch;
}
main.main:has(> .svrnty-aw-panel) > .svrnty-aw-panel {
flex: 0 0 60%;
max-width: 60%;
}
main.main:has(> .svrnty-aw-panel) > #mainChat {
flex: 0 0 40%;
max-width: 40%;
}
.svrnty-aw-panel {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
background: var(--bg);
color: var(--text);
font-family: var(--font-ui);
border-right: 1px solid var(--border2);
overflow: hidden;
}
/* ── Header ─────────────────────────────────────────────────────────────── */
.svrnty-aw-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
border-bottom: 1px solid var(--border2);
background: var(--surface);
flex: 0 0 auto;
}
.svrnty-aw-title {
font-weight: 600;
font-size: 14px;
letter-spacing: 0.02em;
}
.svrnty-aw-status {
font-size: 11px;
color: var(--muted);
display: inline-flex;
align-items: center;
gap: 6px;
}
.svrnty-aw-status-dot {
width: 7px; height: 7px;
border-radius: var(--radius-pill, 9999px);
background: var(--muted);
flex-shrink: 0;
}
.svrnty-aw-status-dot.svrnty-aw-active { background: var(--success); }
.svrnty-aw-status-dot.svrnty-aw-inactive { background: var(--muted); }
/* ── Body — left nav + content ─────────────────────────────────────────── */
.svrnty-aw-body {
display: flex;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.svrnty-aw-nav {
flex: 0 0 140px;
border-right: 1px solid var(--border2);
background: var(--sidebar, var(--surface));
padding: 8px 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.svrnty-aw-nav-item {
appearance: none;
background: transparent;
border: 0;
border-left: 2px solid transparent;
color: var(--muted);
text-align: left;
padding: 8px 14px;
font-family: var(--font-ui);
font-size: 13px;
cursor: pointer;
transition: background 0.1s, color 0.1s, border-color 0.1s;
}
.svrnty-aw-nav-item:hover {
background: var(--hover-bg);
color: var(--text);
}
.svrnty-aw-nav-item.svrnty-aw-nav-active {
color: var(--accent-text, var(--accent));
border-left-color: var(--accent);
background: var(--accent-bg);
font-weight: 500;
}
.svrnty-aw-content {
flex: 1 1 auto;
overflow-y: auto;
padding: 16px;
min-width: 0;
}
.svrnty-aw-disabled-banner {
margin: 0 0 12px 0;
padding: 10px 12px;
background: var(--code-bg);
border: 1px solid var(--border2);
border-radius: var(--radius-md);
font-size: 12px;
color: var(--muted);
}
/* ── Tab pane visibility ─────────────────────────────────────────────────── */
.svrnty-aw-tab { display: none; }
.svrnty-aw-tab.svrnty-aw-tab-active { display: block; }
.svrnty-aw-tab-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
gap: 12px;
}
.svrnty-aw-tab-title {
font-size: 15px;
font-weight: 600;
}
/* ── Buttons ─────────────────────────────────────────────────────────────── */
.svrnty-aw-btn {
appearance: none;
background: transparent;
border: 1px solid var(--border2);
color: var(--text);
font-family: var(--font-ui);
font-size: 12px;
padding: 5px 12px;
border-radius: var(--radius-sm, 8px);
cursor: pointer;
transition: background 0.1s, border-color 0.1s, color 0.1s;
}
.svrnty-aw-btn:hover {
background: var(--hover-bg);
border-color: var(--accent);
color: var(--accent-text, var(--accent));
}
.svrnty-aw-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.svrnty-aw-btn-primary {
background: var(--accent);
border-color: var(--accent);
color: var(--bg);
}
.svrnty-aw-btn-primary:hover {
background: var(--accent-hover);
border-color: var(--accent-hover);
color: var(--bg);
}
/* ── KPI cards (Overview) ────────────────────────────────────────────────── */
.svrnty-aw-kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 10px;
margin-bottom: 16px;
}
.svrnty-aw-kpi {
padding: 12px;
background: var(--surface);
border: 1px solid var(--border2);
border-radius: var(--radius-md);
}
.svrnty-aw-kpi-label {
font-size: 11px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.svrnty-aw-kpi-value {
font-size: 22px;
font-weight: 600;
margin-top: 4px;
color: var(--strong, var(--text));
}
.svrnty-aw-kpi-delta {
font-size: 11px;
margin-top: 2px;
}
.svrnty-aw-kpi-delta-up { color: var(--success); }
.svrnty-aw-kpi-delta-down { color: var(--error); }
.svrnty-aw-spend-bar {
height: 10px;
background: var(--code-bg);
border-radius: var(--radius-pill, 9999px);
overflow: hidden;
margin-top: 6px;
border: 1px solid var(--border2);
}
.svrnty-aw-spend-bar-fill {
height: 100%;
background: var(--accent);
transition: width 0.3s ease;
}
/* ── Timeline (last-flow on Overview) ─────────────────────────────────── */
.svrnty-aw-section {
margin-top: 18px;
}
.svrnty-aw-section-title {
font-size: 12px;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 8px;
}
.svrnty-aw-timeline {
display: flex;
flex-direction: column;
gap: 6px;
}
.svrnty-aw-timeline-item {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 8px 10px;
background: var(--surface);
border: 1px solid var(--border2);
border-radius: var(--radius-sm, 8px);
}
.svrnty-aw-timeline-time {
font-size: 11px;
color: var(--muted);
flex: 0 0 80px;
}
.svrnty-aw-timeline-text {
font-size: 12px;
flex: 1 1 auto;
color: var(--text);
}
/* ── List rows (Cycles, Audience) ────────────────────────────────────────── */
.svrnty-aw-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.svrnty-aw-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
background: var(--surface);
border: 1px solid var(--border2);
border-radius: var(--radius-md);
cursor: pointer;
transition: border-color 0.1s, background 0.1s;
}
.svrnty-aw-row:hover {
border-color: var(--accent);
background: var(--hover-bg);
}
.svrnty-aw-row-main {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.svrnty-aw-row-title {
font-size: 13px;
font-weight: 500;
color: var(--text);
}
.svrnty-aw-row-sub {
font-size: 11px;
color: var(--muted);
}
.svrnty-aw-row-meta {
font-size: 12px;
color: var(--muted);
text-align: right;
flex: 0 0 auto;
}
.svrnty-aw-row-expanded {
margin-top: 6px;
padding: 10px 12px;
background: var(--code-bg);
border: 1px solid var(--border2);
border-radius: var(--radius-sm, 8px);
font-size: 12px;
color: var(--text);
}
/* ── Targeting matrix ────────────────────────────────────────────────────── */
.svrnty-aw-matrix {
display: grid;
border: 1px solid var(--border2);
border-radius: var(--radius-md);
overflow: hidden;
font-size: 12px;
}
.svrnty-aw-matrix-cell {
padding: 8px 10px;
border-right: 1px solid var(--border2);
border-bottom: 1px solid var(--border2);
background: var(--surface);
color: var(--text);
text-align: center;
}
.svrnty-aw-matrix-head {
background: var(--code-bg);
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
font-size: 10px;
letter-spacing: 0.05em;
}
.svrnty-aw-matrix-row-label {
background: var(--code-bg);
text-align: left;
font-weight: 500;
color: var(--text);
}
/* ── Connections (status + provisioning form) ────────────────────────────── */
.svrnty-aw-conn-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
}
.svrnty-aw-conn {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
background: var(--surface);
border: 1px solid var(--border2);
border-radius: var(--radius-md);
}
.svrnty-aw-conn-name {
font-size: 13px;
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 8px;
}
.svrnty-aw-conn-pill {
font-size: 11px;
padding: 3px 10px;
border-radius: var(--radius-pill, 9999px);
background: var(--code-bg);
color: var(--muted);
border: 1px solid var(--border2);
}
.svrnty-aw-conn-pill.svrnty-aw-ok { color: var(--success); border-color: var(--success); }
.svrnty-aw-conn-pill.svrnty-aw-fail { color: var(--error); border-color: var(--error); }
.svrnty-aw-form {
display: none;
flex-direction: column;
gap: 10px;
padding: 14px;
background: var(--surface);
border: 1px solid var(--border2);
border-radius: var(--radius-md);
margin-top: 8px;
}
.svrnty-aw-form.svrnty-aw-open { display: flex; }
.svrnty-aw-form-row {
display: flex;
flex-direction: column;
gap: 4px;
}
.svrnty-aw-form-row label {
font-size: 11px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.svrnty-aw-form-row input {
font-family: var(--font-ui);
font-size: 13px;
padding: 7px 10px;
background: var(--input-bg);
border: 1px solid var(--border2);
border-radius: var(--radius-sm, 8px);
color: var(--text);
}
.svrnty-aw-form-row input:focus {
outline: 2px solid var(--focus-ring);
outline-offset: -1px;
border-color: var(--accent);
}
.svrnty-aw-form-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 4px;
}
.svrnty-aw-form-note {
font-size: 11px;
color: var(--muted);
background: var(--code-bg);
padding: 8px 10px;
border-radius: var(--radius-sm, 8px);
border: 1px solid var(--border2);
}
/* ── Empty + loading states ─────────────────────────────────────────────── */
.svrnty-aw-empty,
.svrnty-aw-loading {
padding: 24px 12px;
text-align: center;
font-size: 12px;
color: var(--muted);
}
/* ── Toast (action feedback) ────────────────────────────────────────────── */
.svrnty-aw-toast {
position: fixed;
bottom: 20px;
right: 20px;
padding: 10px 14px;
background: var(--surface);
border: 1px solid var(--border2);
border-radius: var(--radius-md);
font-size: 12px;
color: var(--text);
z-index: 9999;
box-shadow: 0 4px 12px var(--focus-glow);
opacity: 0;
transform: translateY(8px);
transition: opacity 0.2s, transform 0.2s;
pointer-events: none;
}
.svrnty-aw-toast.svrnty-aw-toast-show {
opacity: 1;
transform: translateY(0);
}
.svrnty-aw-toast.svrnty-aw-toast-error { border-color: var(--error); color: var(--error); }
.svrnty-aw-toast.svrnty-aw-toast-success { border-color: var(--success); color: var(--success); }

717
static/adwright.js Normal file
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();
}
})();

0
static/adwright/.keep Normal file
View File

346
static/bte.css Normal file
View File

@ -0,0 +1,346 @@
/* ============================================================================
svrnty-bte: BTE Command Center panel brand asset gen + rate + iterate.
Class prefix .svrnty-bte-* on EVERY selector. CSS vars only (no hex colors).
Loaded after app.css. Skin-agnostic inherits whatever WebUI vars resolve.
============================================================================ */
/* Local scope tokens (svrnty-bte-only keep all color values inside :root-like
blocks per app.css convention; downstream selectors reference these vars).
--svrnty-bte-on-accent: text color rendered atop --accent (brand-fixed white).
--svrnty-bte-shadow: elevation shadow (CSS-var-compatible neutral). */
.svrnty-bte-launcher,
.svrnty-bte-overlay {
--svrnty-bte-on-accent: #FFFFFF;
--svrnty-bte-shadow: 0 4px 14px rgba(0, 0, 0, 0.25);
}
/* ── Floating launcher button (lives at bottom-right, always-on) ──────────── */
.svrnty-bte-launcher {
position: fixed;
right: 16px;
bottom: 16px;
z-index: 9990;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
background: var(--accent);
color: var(--svrnty-bte-on-accent);
border: none;
border-radius: var(--radius-pill, 9999px);
font-family: var(--font-ui, sans-serif);
font-size: 13px;
font-weight: 600;
cursor: pointer;
box-shadow: var(--svrnty-bte-shadow);
}
.svrnty-bte-launcher:hover { background: var(--accent-hover); }
.svrnty-bte-launcher[hidden] { display: none; }
/* ── Full-screen overlay panel ────────────────────────────────────────────── */
.svrnty-bte-overlay {
position: fixed;
inset: 0;
z-index: 9991;
background: var(--bg);
display: grid;
grid-template-rows: auto 1fr;
font-family: var(--font-ui, sans-serif);
color: var(--text);
}
.svrnty-bte-overlay[hidden] { display: none; }
/* ── Top toolbar ──────────────────────────────────────────────────────────── */
.svrnty-bte-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 14px;
padding: 10px 16px;
background: var(--sidebar);
border-bottom: 1px solid var(--border);
}
.svrnty-bte-toolbar-title {
font-size: 13px;
font-weight: 600;
color: var(--strong);
margin-right: 4px;
}
.svrnty-bte-toolbar-group {
display: inline-flex;
align-items: center;
gap: 4px;
}
.svrnty-bte-toolbar-label {
font-size: 11px;
color: var(--muted);
margin-right: 4px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.svrnty-bte-close {
margin-left: auto;
background: none;
border: 1px solid var(--border2);
color: var(--text);
padding: 4px 10px;
border-radius: var(--radius-sm, 8px);
font-size: 12px;
cursor: pointer;
}
.svrnty-bte-close:hover { background: var(--hover-bg); }
/* ── Pills (mode / media / recipe) ────────────────────────────────────────── */
.svrnty-bte-pill {
background: var(--input-bg);
color: var(--text);
border: 1px solid var(--border2);
padding: 5px 12px;
border-radius: var(--radius-pill, 9999px);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: background 0.12s;
}
.svrnty-bte-pill:hover:not(:disabled) { background: var(--hover-bg); }
.svrnty-bte-pill[aria-pressed="true"] {
background: var(--accent-bg-strong);
color: var(--accent-text);
border-color: var(--accent);
}
.svrnty-bte-pill:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ── Selects + steppers ───────────────────────────────────────────────────── */
.svrnty-bte-select,
.svrnty-bte-stepper {
background: var(--input-bg);
color: var(--text);
border: 1px solid var(--border2);
border-radius: var(--radius-sm, 8px);
padding: 4px 8px;
font-size: 12px;
font-family: inherit;
}
.svrnty-bte-stepper { width: 60px; text-align: center; }
/* ── Generate button ──────────────────────────────────────────────────────── */
.svrnty-bte-generate {
background: var(--accent);
color: var(--svrnty-bte-on-accent);
border: none;
padding: 7px 18px;
border-radius: var(--radius-sm, 8px);
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.svrnty-bte-generate:hover:not(:disabled) { background: var(--accent-hover); }
.svrnty-bte-generate:disabled {
background: var(--surface);
color: var(--muted);
cursor: not-allowed;
}
/* ── Main pane: grid + right rail ─────────────────────────────────────────── */
.svrnty-bte-body {
display: grid;
grid-template-columns: 1fr 380px;
overflow: hidden;
}
.svrnty-bte-main {
overflow-y: auto;
padding: 16px;
background: var(--main-bg);
}
.svrnty-bte-rail {
border-left: 1px solid var(--border);
background: var(--surface);
overflow-y: auto;
padding: 14px;
display: flex;
flex-direction: column;
gap: 12px;
}
/* ── Status bar / banners ─────────────────────────────────────────────────── */
.svrnty-bte-banner {
padding: 8px 12px;
margin-bottom: 12px;
border-radius: var(--radius-sm, 8px);
font-size: 12px;
border: 1px solid var(--border2);
}
.svrnty-bte-banner-info {
background: var(--accent-bg);
color: var(--text);
border-color: var(--accent);
}
.svrnty-bte-banner-warn {
background: var(--input-bg);
color: var(--warning);
border-color: var(--warning);
}
/* ── Asset grid ───────────────────────────────────────────────────────────── */
.svrnty-bte-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
}
.svrnty-bte-card {
background: var(--surface);
border: 1px solid var(--border2);
border-radius: var(--radius-card, 12px);
overflow: hidden;
cursor: pointer;
display: flex;
flex-direction: column;
}
.svrnty-bte-card:hover { border-color: var(--accent); }
.svrnty-bte-card-selected { border-color: var(--accent); box-shadow: 0 0 0 2px var(--focus-glow); }
.svrnty-bte-card-thumb {
aspect-ratio: 1 / 1;
background: var(--code-bg);
display: flex;
align-items: center;
justify-content: center;
color: var(--muted);
font-size: 11px;
}
.svrnty-bte-card-thumb img { width: 100%; height: 100%; object-fit: cover; }
.svrnty-bte-card-meta {
padding: 8px 10px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
font-size: 11px;
}
.svrnty-bte-status {
padding: 2px 8px;
border-radius: var(--radius-pill, 9999px);
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.svrnty-bte-status-generating { background: var(--accent-bg); color: var(--accent-text); }
.svrnty-bte-status-approved { background: var(--input-bg); color: var(--success); }
.svrnty-bte-status-rejected { background: var(--input-bg); color: var(--error); }
.svrnty-bte-status-golden { background: var(--input-bg); color: var(--gold); }
/* ── Rate buttons (on detail view) ────────────────────────────────────────── */
.svrnty-bte-rate-row {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
}
.svrnty-bte-rate-btn {
background: var(--input-bg);
color: var(--text);
border: 1px solid var(--border2);
border-radius: var(--radius-sm, 8px);
padding: 4px 10px;
font-size: 12px;
cursor: pointer;
}
.svrnty-bte-rate-btn:hover { background: var(--hover-bg); }
.svrnty-bte-rate-btn-up:hover { color: var(--success); }
.svrnty-bte-rate-btn-down:hover { color: var(--error); }
/* ── Asset detail view ───────────────────────────────────────────────────── */
.svrnty-bte-detail {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 16px;
margin-top: 16px;
padding: 16px;
background: var(--surface);
border: 1px solid var(--border2);
border-radius: var(--radius-card, 12px);
}
.svrnty-bte-detail-preview {
background: var(--code-bg);
border-radius: var(--radius-sm, 8px);
display: flex;
align-items: center;
justify-content: center;
min-height: 320px;
}
.svrnty-bte-detail-preview img { max-width: 100%; max-height: 480px; }
.svrnty-bte-detail-meta { font-size: 12px; }
.svrnty-bte-detail-meta dt {
color: var(--muted);
font-size: 11px;
margin-top: 8px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.svrnty-bte-detail-meta dd {
margin: 2px 0 0;
color: var(--text);
}
.svrnty-bte-comment {
width: 100%;
margin-top: 8px;
background: var(--input-bg);
color: var(--text);
border: 1px solid var(--border2);
border-radius: var(--radius-sm, 8px);
padding: 6px 8px;
font-family: inherit;
font-size: 12px;
resize: vertical;
min-height: 60px;
}
/* ── Right rail: embedded CMO chat reference + run status ─────────────────── */
.svrnty-bte-rail-section h4 {
margin: 0 0 6px;
font-size: 12px;
font-weight: 600;
color: var(--strong);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.svrnty-bte-rail-section p {
margin: 0;
font-size: 12px;
color: var(--muted);
line-height: 1.4;
}
.svrnty-bte-cmo-link {
display: inline-block;
margin-top: 6px;
padding: 5px 12px;
background: var(--accent-bg);
color: var(--accent-text);
border: 1px solid var(--accent);
border-radius: var(--radius-sm, 8px);
font-size: 12px;
cursor: pointer;
}
.svrnty-bte-cmo-link:hover { background: var(--accent-bg-strong); }
.svrnty-bte-run-log {
font-family: var(--font-ui, monospace);
font-size: 11px;
color: var(--muted);
background: var(--code-bg);
border-radius: var(--radius-sm, 8px);
padding: 8px;
max-height: 200px;
overflow-y: auto;
white-space: pre-wrap;
}
.svrnty-bte-empty {
text-align: center;
padding: 40px 20px;
color: var(--muted);
font-size: 13px;
}

550
static/bte.js Normal file
View File

@ -0,0 +1,550 @@
// svrnty-bte: BTE Command Center panel — brand asset gen + rate + iterate.
// IIFE, idempotent, namespace window.SvrntyBTE. Class prefix .svrnty-bte-*.
// Per COMMAND-CENTER-PRD §3 + PLANB-RECIPE-TAXONOMY.md §5.
(function () {
"use strict";
if (window.__svrntyBteLoaded) return;
window.__svrntyBteLoaded = true;
// ── Taxonomy (mirror of sot/07-BRAND/PLANB-RECIPE-TAXONOMY.md) ─────────────
const MODES = [
{ slug: "polished", label: "Polished" },
{ slug: "ugc", label: "UGC" },
{ slug: "photoreal", label: "Photorealistic" },
{ slug: "artistic", label: "Artistic" },
];
const MEDIA = [
{ slug: "image", label: "Image", enabled: true },
{ slug: "video", label: "Video (soon)", enabled: false },
];
const FAMILIES = [
{ slug: "hero-shot", label: "Hero Shot" },
{ slug: "lifestyle-shot", label: "Lifestyle Shot" },
{ slug: "photoshoot", label: "Photoshoot" },
{ slug: "recipe-sheet", label: "Recipe Sheet" },
{ slug: "montage-catalog", label: "Montage Catalog" },
];
// Placeholder SKUs — to be replaced with Woo catalog when piped in.
const PLACEHOLDER_SKUS = [
{ id: "42", name: "Poulet tao (placeholder)" },
{ id: "43", name: "Boeuf bourguignon (placeholder)" },
{ id: "44", name: "Saumon teriyaki (placeholder)" },
{ id: "45", name: "Tofu sauté (placeholder)" },
];
const PROXY_BASE = "/api/bte/proxy?path=";
const POLL_INTERVAL_MS = 2000;
// ── State ────────────────────────────────────────────────────────────────
const state = {
brand: "planb",
mode: "polished",
media: "image",
family: null,
skuId: null,
variants: 4,
batch: false,
runId: null,
assets: [], // [{id, thumbUrl, lifecycle, ratingCount, meanScore, recipeSlug, ...}]
selected: null, // asset id
pollTimer: null,
log: [],
};
// ── Public namespace ─────────────────────────────────────────────────────
const SvrntyBTE = {
open: () => _openOverlay(),
close: () => _closeOverlay(),
state,
};
window.SvrntyBTE = SvrntyBTE;
// ── Init: inject launcher button when DOM ready ─────────────────────────
function _init() {
_installLauncher();
// Some WebUI flows rebuild body content; re-install on DOM mutations.
const observer = new MutationObserver(() => _installLauncher());
observer.observe(document.body, { childList: true, subtree: false });
}
function _installLauncher() {
if (document.querySelector(".svrnty-bte-launcher")) return;
const btn = document.createElement("button");
btn.className = "svrnty-bte-launcher";
btn.type = "button";
btn.title = "Open BTE Command Center";
btn.innerHTML =
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
'<rect x="3" y="3" width="7" height="7" rx="1"/>' +
'<rect x="14" y="3" width="7" height="7" rx="1"/>' +
'<rect x="3" y="14" width="7" height="7" rx="1"/>' +
'<rect x="14" y="14" width="7" height="7" rx="1"/>' +
'</svg>BTE Command Center';
btn.addEventListener("click", _openOverlay);
document.body.appendChild(btn);
}
// ── Overlay lifecycle ────────────────────────────────────────────────────
function _openOverlay() {
let overlay = document.getElementById("svrntyBteOverlay");
if (!overlay) {
overlay = _buildOverlay();
document.body.appendChild(overlay);
}
overlay.hidden = false;
_refreshGrid();
}
function _closeOverlay() {
const overlay = document.getElementById("svrntyBteOverlay");
if (overlay) overlay.hidden = true;
if (state.pollTimer) {
clearInterval(state.pollTimer);
state.pollTimer = null;
}
}
// ── Overlay DOM build ────────────────────────────────────────────────────
function _buildOverlay() {
const root = document.createElement("div");
root.id = "svrntyBteOverlay";
root.className = "svrnty-bte-overlay";
root.appendChild(_buildToolbar());
const body = document.createElement("div");
body.className = "svrnty-bte-body";
body.appendChild(_buildMain());
body.appendChild(_buildRail());
root.appendChild(body);
return root;
}
function _buildToolbar() {
const bar = document.createElement("div");
bar.className = "svrnty-bte-toolbar";
const title = document.createElement("span");
title.className = "svrnty-bte-toolbar-title";
title.textContent = "BTE Command Center";
bar.appendChild(title);
// Brand selector (single-option for now — Plan B)
bar.appendChild(_labeled("Brand", _select("svrntyBteBrand",
[{ value: "planb", label: "Plan B" }],
state.brand, (v) => { state.brand = v; })));
// Content mode pills
bar.appendChild(_pillGroup("Mode", "svrnty-bte-pill-mode", MODES, state.mode, (v) => {
state.mode = v; _updateToolbarPressed(); _updateGenerateEnabled();
}));
// Media toggle
bar.appendChild(_pillGroup("Media", "svrnty-bte-pill-media",
MEDIA.map((m) => ({ slug: m.slug, label: m.label, disabled: !m.enabled })),
state.media, (v) => {
state.media = v; _updateToolbarPressed(); _updateGenerateEnabled();
}));
// Recipe family pills
bar.appendChild(_pillGroup("Recipe", "svrnty-bte-pill-family", FAMILIES, state.family, (v) => {
state.family = v; _updateToolbarPressed(); _updateGenerateEnabled(); _refreshGrid();
}));
// SKU dropdown
const skuOpts = [{ value: "", label: "— pick SKU —" }].concat(
PLACEHOLDER_SKUS.map((s) => ({ value: s.id, label: s.name })));
bar.appendChild(_labeled("SKU", _select("svrntyBteSku", skuOpts, state.skuId || "", (v) => {
state.skuId = v || null; _updateGenerateEnabled();
})));
// Variants stepper
const stepper = document.createElement("input");
stepper.type = "number";
stepper.min = "1"; stepper.max = "12"; stepper.value = String(state.variants);
stepper.className = "svrnty-bte-stepper";
stepper.addEventListener("change", () => {
const n = parseInt(stepper.value, 10);
state.variants = isNaN(n) ? 1 : Math.max(1, Math.min(12, n));
stepper.value = String(state.variants);
});
bar.appendChild(_labeled("Variants", stepper));
// Batch toggle
bar.appendChild(_pillGroup("Run", "svrnty-bte-pill-batch",
[{ slug: "single", label: "Single" }, { slug: "batch", label: "Batch" }],
state.batch ? "batch" : "single",
(v) => { state.batch = (v === "batch"); }));
// Generate button
const gen = document.createElement("button");
gen.id = "svrntyBteGenerate";
gen.className = "svrnty-bte-generate";
gen.type = "button";
gen.textContent = "Generate";
gen.disabled = true;
gen.addEventListener("click", _onGenerate);
bar.appendChild(gen);
// Close
const close = document.createElement("button");
close.className = "svrnty-bte-close";
close.type = "button";
close.textContent = "Close";
close.addEventListener("click", _closeOverlay);
bar.appendChild(close);
return bar;
}
function _buildMain() {
const main = document.createElement("div");
main.className = "svrnty-bte-main";
main.id = "svrntyBteMain";
main.innerHTML =
'<div id="svrntyBteBanner"></div>' +
'<div class="svrnty-bte-empty" id="svrntyBteEmpty">' +
'Pick a mode, recipe family, and SKU to begin. Recent renders for the selected cell will appear here.' +
'</div>' +
'<div class="svrnty-bte-grid" id="svrntyBteGrid"></div>' +
'<div id="svrntyBteDetail"></div>';
return main;
}
function _buildRail() {
const rail = document.createElement("div");
rail.className = "svrnty-bte-rail";
rail.innerHTML =
'<div class="svrnty-bte-rail-section">' +
'<h4>CMO chat</h4>' +
'<p>Tell CMO what to change about the next batch — warmer light, less white space, different framing — then re-run.</p>' +
'<button class="svrnty-bte-cmo-link" id="svrntyBteCmoOpen" type="button">Open CMO chat →</button>' +
'</div>' +
'<div class="svrnty-bte-rail-section">' +
'<h4>Run status</h4>' +
'<div class="svrnty-bte-run-log" id="svrntyBteRunLog">No active run.</div>' +
'</div>' +
'<div class="svrnty-bte-rail-section">' +
'<h4>Selected cell</h4>' +
'<p id="svrntyBteCellLabel">—</p>' +
'</div>';
// CMO chat link: simplest honest path — switch the WebUI rail to the chat
// panel so JP can talk to whichever profile is active. Real CMO-scoped
// iframe deferred (PRD §6.5 option A — Phase E).
setTimeout(() => {
const cmoBtn = document.getElementById("svrntyBteCmoOpen");
if (cmoBtn) cmoBtn.addEventListener("click", _openCmoChat);
}, 0);
return rail;
}
// ── DOM helpers ──────────────────────────────────────────────────────────
function _labeled(label, child) {
const wrap = document.createElement("div");
wrap.className = "svrnty-bte-toolbar-group";
const lab = document.createElement("span");
lab.className = "svrnty-bte-toolbar-label";
lab.textContent = label;
wrap.appendChild(lab);
wrap.appendChild(child);
return wrap;
}
function _select(id, options, value, onChange) {
const sel = document.createElement("select");
sel.id = id;
sel.className = "svrnty-bte-select";
options.forEach((o) => {
const opt = document.createElement("option");
opt.value = o.value;
opt.textContent = o.label;
if (o.value === value) opt.selected = true;
sel.appendChild(opt);
});
sel.addEventListener("change", () => onChange(sel.value));
return sel;
}
function _pillGroup(label, className, items, currentValue, onPick) {
const wrap = document.createElement("div");
wrap.className = "svrnty-bte-toolbar-group";
const lab = document.createElement("span");
lab.className = "svrnty-bte-toolbar-label";
lab.textContent = label;
wrap.appendChild(lab);
items.forEach((it) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "svrnty-bte-pill " + className;
btn.dataset.value = it.slug;
btn.textContent = it.label;
btn.setAttribute("aria-pressed", it.slug === currentValue ? "true" : "false");
if (it.disabled) btn.disabled = true;
btn.addEventListener("click", () => {
if (btn.disabled) return;
onPick(it.slug);
});
wrap.appendChild(btn);
});
return wrap;
}
function _updateToolbarPressed() {
const sync = (cls, val) => {
document.querySelectorAll("." + cls).forEach((b) => {
b.setAttribute("aria-pressed", b.dataset.value === val ? "true" : "false");
});
};
sync("svrnty-bte-pill-mode", state.mode);
sync("svrnty-bte-pill-media", state.media);
sync("svrnty-bte-pill-family", state.family);
sync("svrnty-bte-pill-batch", state.batch ? "batch" : "single");
_renderCellLabel();
}
function _updateGenerateEnabled() {
const gen = document.getElementById("svrntyBteGenerate");
if (!gen) return;
gen.disabled = !(state.mode && state.media && state.family && state.skuId);
}
function _renderCellLabel() {
const el = document.getElementById("svrntyBteCellLabel");
if (!el) return;
if (!state.family) { el.textContent = "—"; return; }
el.textContent = `${state.family}__${state.mode}__${state.media}`;
}
// ── Generate flow ────────────────────────────────────────────────────────
function _onGenerate() {
if (!state.family || !state.skuId) return;
const sku = PLACEHOLDER_SKUS.find((s) => s.id === state.skuId);
const recipeLabel = `${state.family}__${state.mode}__${state.media}`;
const body = {
brandId: state.brand,
recipeSlug: state.family,
recipeLabel: recipeLabel,
items: [{ offeringId: parseInt(state.skuId, 10), offeringName: sku ? sku.name : state.skuId }],
variantsPerScenario: state.variants,
tags: { source: "svrnty-bte-panel" },
};
_log(`POST requestPhotoshoot (${recipeLabel}, sku=${state.skuId}, variants=${state.variants})`);
_proxyPost("/api/command/requestPhotoshoot", body)
.then((resp) => {
if (resp.status === 404 || resp.status === 501) {
_banner("BTE endpoint /api/command/requestPhotoshoot not yet implemented — see COMMAND-CENTER-PRD §5.8 (status: implementing).", "warn");
_log(`${resp.status} (endpoint not yet built)`);
return;
}
if (!resp.ok) {
_banner(`BTE returned ${resp.status}: ${resp.bodyText.slice(0, 200)}`, "warn");
_log(`${resp.status} ${resp.bodyText.slice(0, 80)}`);
return;
}
const json = _safeJson(resp.bodyText) || {};
state.runId = json.runId || json.run_id || null;
_log(`← runId=${state.runId || "(none)"}, assetIds=${(json.assetIds || []).length}`);
_banner(`Run ${state.runId || "started"} — polling grid every ${POLL_INTERVAL_MS / 1000}s.`, "info");
_startPolling();
})
.catch((e) => {
_banner(`Proxy error: ${e.message}`, "warn");
_log(`× ${e.message}`);
});
}
// ── Grid refresh + polling ───────────────────────────────────────────────
function _refreshGrid() {
if (!state.family) return;
const filters = {
brandId: state.brand,
recipeSlug: state.family,
lifecycle: ["approved", "generating", "evaluating"],
};
if (state.runId) filters.runId = state.runId;
const body = {
filters: filters,
page: 1,
pageSize: 24,
sort: "-created_at",
};
_proxyPost("/api/query/assetGrid", body)
.then((resp) => {
if (resp.status === 404 || resp.status === 501) {
state.assets = [];
_renderGrid();
_banner("BTE endpoint /api/query/assetGrid not yet implemented (PRD §5.4 status: implementing). Grid will populate once endpoint ships.", "warn");
return;
}
if (!resp.ok) {
_log(`assetGrid ← ${resp.status}`);
return;
}
const json = _safeJson(resp.bodyText) || { items: [] };
state.assets = json.items || [];
_renderGrid();
// Stop polling when no rendering asset remains.
const stillInFlight = state.assets.some((a) => a.lifecycle === "generating" || a.lifecycle === "evaluating");
if (!stillInFlight && state.pollTimer) {
clearInterval(state.pollTimer);
state.pollTimer = null;
_log("polling stopped — all renders complete");
}
})
.catch((e) => _log(`assetGrid × ${e.message}`));
}
function _startPolling() {
if (state.pollTimer) clearInterval(state.pollTimer);
_refreshGrid();
state.pollTimer = setInterval(_refreshGrid, POLL_INTERVAL_MS);
}
function _renderGrid() {
const grid = document.getElementById("svrntyBteGrid");
const empty = document.getElementById("svrntyBteEmpty");
if (!grid || !empty) return;
if (!state.assets.length) {
grid.innerHTML = "";
empty.style.display = "";
return;
}
empty.style.display = "none";
grid.innerHTML = state.assets.map((a) => {
const thumb = a.thumbUrl
? `<img src="${PROXY_BASE}${encodeURIComponent(a.thumbUrl)}" alt="">`
: '<span>no thumb</span>';
const score = (a.meanScore != null) ? ` · ★${a.meanScore.toFixed(1)}` : "";
const sel = (a.id === state.selected) ? " svrnty-bte-card-selected" : "";
return (
`<div class="svrnty-bte-card${sel}" data-id="${_esc(a.id)}">` +
`<div class="svrnty-bte-card-thumb">${thumb}</div>` +
`<div class="svrnty-bte-card-meta">` +
`<span class="svrnty-bte-status svrnty-bte-status-${_esc(a.lifecycle || "generating")}">${_esc(a.lifecycle || "—")}</span>` +
`<span>${a.ratingCount || 0}r${score}</span>` +
`</div>` +
`</div>`
);
}).join("");
grid.querySelectorAll(".svrnty-bte-card").forEach((card) => {
card.addEventListener("click", () => _selectAsset(card.dataset.id));
});
}
function _selectAsset(id) {
state.selected = id;
const asset = state.assets.find((a) => a.id === id);
_renderGrid();
_renderDetail(asset);
}
function _renderDetail(asset) {
const host = document.getElementById("svrntyBteDetail");
if (!host) return;
if (!asset) { host.innerHTML = ""; return; }
const thumb = asset.thumbUrl
? `<img src="${PROXY_BASE}${encodeURIComponent(asset.thumbUrl)}" alt="">`
: "no preview";
host.innerHTML =
'<div class="svrnty-bte-detail">' +
`<div class="svrnty-bte-detail-preview">${thumb}</div>` +
'<dl class="svrnty-bte-detail-meta">' +
`<dt>asset id</dt><dd>${_esc(asset.id)}</dd>` +
`<dt>recipe</dt><dd>${_esc(asset.recipeSlug || "—")} v${asset.recipeVersion || "?"}</dd>` +
`<dt>lifecycle</dt><dd>${_esc(asset.lifecycle || "—")}</dd>` +
`<dt>ratings</dt><dd>${asset.ratingCount || 0} · mean ${asset.meanScore != null ? asset.meanScore.toFixed(2) : "—"}</dd>` +
'<dt>rate</dt><dd>' +
'<div class="svrnty-bte-rate-row">' +
`<button class="svrnty-bte-rate-btn svrnty-bte-rate-btn-up" data-v="1" data-id="${_esc(asset.id)}" type="button">+1</button>` +
`<button class="svrnty-bte-rate-btn svrnty-bte-rate-btn-down" data-v="-1" data-id="${_esc(asset.id)}" type="button">1</button>` +
'<select class="svrnty-bte-select" id="svrntyBteScore">' +
'<option value="">— score —</option>' +
'<option value="1">1</option><option value="2">2</option><option value="3">3</option><option value="4">4</option><option value="5">5</option>' +
'</select>' +
'</div>' +
`<textarea class="svrnty-bte-comment" id="svrntyBteComment" placeholder="Comment (optional) — e.g. 'too plastic', 'needs warmer light'"></textarea>` +
'</dd>' +
'</dl>' +
'</div>';
host.querySelectorAll(".svrnty-bte-rate-btn").forEach((btn) => {
btn.addEventListener("click", () => _rateAsset(btn.dataset.id, parseInt(btn.dataset.v, 10)));
});
}
function _rateAsset(id, verdict) {
const scoreSel = document.getElementById("svrntyBteScore");
const commentEl = document.getElementById("svrntyBteComment");
const body = {
assetId: id,
verdict: verdict > 0 ? "accept" : "reject",
score: scoreSel && scoreSel.value ? parseInt(scoreSel.value, 10) : null,
comment: commentEl ? (commentEl.value || "").trim() : "",
ratedBy: localStorage.getItem("svrnty-bte.rater") || "jp",
};
_log(`POST rateAsset ${id} ${body.verdict}`);
_proxyPost("/api/command/rateAsset", body)
.then((resp) => {
if (resp.status === 404 || resp.status === 501) {
_banner("BTE /api/command/rateAsset not yet implemented (PRD §7).", "warn");
return;
}
if (!resp.ok) {
_log(`rateAsset ← ${resp.status}`);
return;
}
_log(`rateAsset ← ok`);
_refreshGrid();
})
.catch((e) => _log(`rateAsset × ${e.message}`));
}
// ── CMO chat handoff ─────────────────────────────────────────────────────
function _openCmoChat() {
// Honest minimum: surface the WebUI chat panel (whichever profile is
// active). Full CMO-scoped iframe / component reuse is COMMAND-CENTER-PRD
// §6.5 Phase E — not v1 of this panel.
_closeOverlay();
if (typeof window.switchPanel === "function") {
try { window.switchPanel("chat", { fromRailClick: true }); } catch (_) { /* ignore */ }
}
}
// ── Proxy helpers ────────────────────────────────────────────────────────
function _proxyPost(btePath, body) {
return fetch(PROXY_BASE + encodeURIComponent(btePath), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}).then(_responseEnvelope);
}
function _responseEnvelope(r) {
return r.text().then((t) => ({ ok: r.ok, status: r.status, bodyText: t }));
}
function _safeJson(s) {
try { return JSON.parse(s); } catch (_) { return null; }
}
// ── UI utils ─────────────────────────────────────────────────────────────
function _banner(text, kind) {
const host = document.getElementById("svrntyBteBanner");
if (!host) return;
host.innerHTML = `<div class="svrnty-bte-banner svrnty-bte-banner-${kind === "warn" ? "warn" : "info"}">${_esc(text)}</div>`;
}
function _log(line) {
state.log.unshift(`${new Date().toLocaleTimeString()} ${line}`);
state.log = state.log.slice(0, 50);
const el = document.getElementById("svrntyBteRunLog");
if (el) el.textContent = state.log.join("\n");
}
function _esc(s) {
return String(s == null ? "" : s).replace(/[<>&"]/g, (c) =>
({ "<": "&lt;", ">": "&gt;", "&": "&amp;", '"': "&quot;" }[c]));
}
// ── Boot ─────────────────────────────────────────────────────────────────
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", _init);
} else {
_init();
}
})();

0
static/bte/.gitkeep Normal file
View File