From 0b19fdd7d022b6819cde995875ccc25d4319f340 Mon Sep 17 00:00:00 2001 From: Svrnty Date: Sun, 24 May 2026 12:12:27 -0400 Subject: [PATCH] feat(plugin): Adwright + BTE Command Center panels (v0.4.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 chat message 3. Posts via existing btnSend pathway → message visible in chat 4. CMO sees + calls mcp_adwright_ 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 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) --- CLAUDE.md | 21 ++ CONNECTION-MAP.md | 18 +- manifest.yaml | 14 +- plugin.py | 13 + routes/adwright.py | 226 +++++++++++++ routes/bte_proxy.py | 117 +++++++ static/adwright.css | 458 +++++++++++++++++++++++++++ static/adwright.js | 717 ++++++++++++++++++++++++++++++++++++++++++ static/adwright/.keep | 0 static/bte.css | 346 ++++++++++++++++++++ static/bte.js | 550 ++++++++++++++++++++++++++++++++ static/bte/.gitkeep | 0 12 files changed, 2475 insertions(+), 5 deletions(-) create mode 100644 routes/adwright.py create mode 100644 routes/bte_proxy.py create mode 100644 static/adwright.css create mode 100644 static/adwright.js create mode 100644 static/adwright/.keep create mode 100644 static/bte.css create mode 100644 static/bte.js create mode 100644 static/bte/.gitkeep diff --git a/CLAUDE.md b/CLAUDE.md index e98d792..2cb74cc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/CONNECTION-MAP.md b/CONNECTION-MAP.md index 36be8f0..426f192 100644 --- a/CONNECTION-MAP.md +++ b/CONNECTION-MAP.md @@ -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` | diff --git a/manifest.yaml b/manifest.yaml index 02331b5..ffbde73 100644 --- a/manifest.yaml +++ b/manifest.yaml @@ -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/.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: diff --git a/plugin.py b/plugin.py index 0e15475..b1140bf 100644 --- a/plugin.py +++ b/plugin.py @@ -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) ✓ ] diff --git a/routes/adwright.py b/routes/adwright.py new file mode 100644 index 0000000..9ec5330 --- /dev/null +++ b/routes/adwright.py @@ -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 {: , ...}. Only keys in + _ALLOWED_CRED_KEYS are written; others are silently dropped. Values are + piped to `credctl set ` 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 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 diff --git a/routes/bte_proxy.py b/routes/bte_proxy.py new file mode 100644 index 0000000..0948106 --- /dev/null +++ b/routes/bte_proxy.py @@ -0,0 +1,117 @@ +"""GET|POST /api/bte/proxy?path= — 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//` 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//(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 diff --git a/static/adwright.css b/static/adwright.css new file mode 100644 index 0000000..5bc634a --- /dev/null +++ b/static/adwright.css @@ -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
──────────── */ +/* 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); } diff --git a/static/adwright.js b/static/adwright.js new file mode 100644 index 0000000..b472a6c --- /dev/null +++ b/static/adwright.js @@ -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
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:
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) => + `` + ).join(""); + const tabs = TABS.map((t) => + `
` + ).join(""); + return ( + '
' + + '
Adwright
' + + '
' + + '' + + 'checking…' + + '
' + + '
' + + '
' + + '' + + '
' + + '' + + tabs + + '
' + + '
' + ); + } + + 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 = + '
' + + '
Overview
' + + '' + + '
' + + '
' + + _kpiCard("Impressions", kpis.impressions, kpis.imprDelta) + + _kpiCard("CTR", kpis.ctr, kpis.ctrDelta) + + _kpiCard("Cycles", kpis.cycleCount, "") + + _kpiCard("Recipes", kpis.recipeCount, "") + + '
' + + '
' + + '
Spend
' + + '
$' + + _fmt(kpis.spend) + ' / $' + _fmt(kpis.budget) + '
' + + '
' + + '
' + + '
' + + '
Last flow timeline
' + + _renderTimeline(d.cycles) + + '
'; + _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 ( + '
' + + '
' + _esc(label) + '
' + + '
' + _esc(value) + '
' + + (delta ? '
' + _esc(delta) + '
' : '') + + '
' + ); + } + function _renderTimeline(cycles) { + if (!cycles || !cycles.length) { + return '
No recent activity. Click Refresh.
'; + } + return '
' + + cycles.slice(0, 5).map((c) => + '
' + + '
' + _esc(c.started_at || "") + '
' + + '
' + + _esc(c.title || ("Cycle #" + (c.id || "?"))) + + ' — ' + _esc(c.status || "") + '' + + '
' + + '
' + ).join("") + + '
'; + } + 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 = + '
' + + '
Cycles
' + + '' + + '
' + + (cycles.length + ? ('
' + + cycles.map((c) => _cycleRow(c)).join("") + + '
') + : '
No cycles loaded. Click Refresh cycles.
'); + _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 ( + '
' + + '
' + + '
' + _esc(c.title || ("Cycle #" + c.id)) + '
' + + '
' + _esc(c.status || "") + ' · started ' + _esc(c.started_at || "") + '
' + + '
' + + '
$' + _fmt(c.spend || 0) + ' / $' + _fmt(c.budget || 0) + '
' + + '
' + ); + } + 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 = '
Loading cycle #' + _esc(cycleId) + '…
'; + _fireAction("get-cycle", { id: cycleId }); + } + rowEl.parentNode.insertBefore(wrap, rowEl.nextSibling); + } + function _renderCycleDetail(d) { + const variants = d.variants || []; + if (!variants.length) return '
No variants for this cycle.
'; + return 'Variants (' + variants.length + ')' + + '
' + + variants.map((v) => + '
' + + '' + _esc(v.name || v.id || "variant") + '' + + '' + _esc(v.status || "") + ' · imp ' + _fmt(v.impressions || 0) + '' + + '
' + ).join("") + + '
'; + } + 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 = + '
' + + '
Audience segments
' + + '' + + '
' + + (segs.length + ? '
' + + segs.map((s) => + '
' + + '
' + + '
' + _esc(s.name || s.id) + '
' + + '
' + _esc(s.description || "") + '
' + + '
' + + '
' + _fmt(s.size || 0) + ' people
' + + '
' + ).join("") + + '
' + : '
No segments loaded. Click Refresh.
'); + _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 = + '
' + + '
Targeting matrix
' + + '' + + '
' + + (segs.length && recs.length + ? _renderMatrix(segs, recs) + : '
Load segments + recipes to see the matrix.
' + + '(Audience tab → Refresh, then Refresh recipes here.)
'); + _rewireActions(pane); + _refreshDisabledState(); + } + function _renderMatrix(segs, recs) { + const cols = recs.length + 1; + const cells = []; + cells.push('
'); + recs.forEach((r) => { + cells.push('
' + + _esc(r.name || r.id) + '
'); + }); + segs.forEach((s) => { + cells.push('
' + + _esc(s.name || s.id) + '
'); + recs.forEach((r) => { + cells.push('
·
'); + }); + }); + return '
' + + cells.join("") + '
'; + } + + // Connections — read status + provisioning form (governance exception) ─ + function _renderConnections() { + const pane = _pane("connections"); + if (!pane) return; + const conns = NS.state.data.connections || []; + pane.innerHTML = + '
' + + '
Connections
' + + '' + + '
' + + '
' + + (conns.length ? conns.map(_connRow).join("") : + '
No status loaded. Click Re-check.
') + + '
' + + '' + + '
' + + '
' + + 'Credentials are written direct to the credctl vault via the plugin backend — ' + + 'never through chat or MCP (governance exception, see PRD §6). ' + + 'Leave a field blank to keep its current value.' + + '
' + + _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") + + '
' + + '' + + '' + + '
' + + '
'; + _rewireActions(pane); + _refreshDisabledState(); + } + function _credField(name, label, type) { + return '
' + + '' + + '' + + '
'; + } + function _connRow(c) { + const okCls = c.ok ? " svrnty-aw-ok" : " svrnty-aw-fail"; + return ( + '
' + + '
' + + _esc(c.name || c.id || "unknown") + + '
' + + '' + + _esc(c.status || (c.ok ? "ok" : "fail")) + + '' + + '
' + ); + } + 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 " 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) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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
exists; observer scoped to 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
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(); + } +})(); diff --git a/static/adwright/.keep b/static/adwright/.keep new file mode 100644 index 0000000..e69de29 diff --git a/static/bte.css b/static/bte.css new file mode 100644 index 0000000..72fea57 --- /dev/null +++ b/static/bte.css @@ -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; +} diff --git a/static/bte.js b/static/bte.js new file mode 100644 index 0000000..91718a6 --- /dev/null +++ b/static/bte.js @@ -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 = + '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 = + '
' + + '
' + + 'Pick a mode, recipe family, and SKU to begin. Recent renders for the selected cell will appear here.' + + '
' + + '
' + + '
'; + return main; + } + + function _buildRail() { + const rail = document.createElement("div"); + rail.className = "svrnty-bte-rail"; + rail.innerHTML = + '
' + + '

CMO chat

' + + '

Tell CMO what to change about the next batch — warmer light, less white space, different framing — then re-run.

' + + '' + + '
' + + '
' + + '

Run status

' + + '
No active run.
' + + '
' + + '
' + + '

Selected cell

' + + '

' + + '
'; + // 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 + ? `` + : 'no thumb'; + const score = (a.meanScore != null) ? ` · ★${a.meanScore.toFixed(1)}` : ""; + const sel = (a.id === state.selected) ? " svrnty-bte-card-selected" : ""; + return ( + `
` + + `
${thumb}
` + + `
` + + `${_esc(a.lifecycle || "—")}` + + `${a.ratingCount || 0}r${score}` + + `
` + + `
` + ); + }).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 + ? `` + : "no preview"; + host.innerHTML = + '
' + + `
${thumb}
` + + '
' + + `
asset id
${_esc(asset.id)}
` + + `
recipe
${_esc(asset.recipeSlug || "—")} v${asset.recipeVersion || "?"}
` + + `
lifecycle
${_esc(asset.lifecycle || "—")}
` + + `
ratings
${asset.ratingCount || 0} · mean ${asset.meanScore != null ? asset.meanScore.toFixed(2) : "—"}
` + + '
rate
' + + '
' + + `` + + `` + + '' + + '
' + + `` + + '
' + + '
' + + '
'; + 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 = `
${_esc(text)}
`; + } + 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) => + ({ "<": "<", ">": ">", "&": "&", '"': """ }[c])); + } + + // ── Boot ───────────────────────────────────────────────────────────────── + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", _init); + } else { + _init(); + } +})(); diff --git a/static/bte/.gitkeep b/static/bte/.gitkeep new file mode 100644 index 0000000..e69de29