feat(plugin): Adwright + BTE Command Center panels (v0.4.0)
Some checks failed
plugin-tests / test (push) Failing after 5s
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:
parent
33014fbea9
commit
0b19fdd7d0
21
CLAUDE.md
21
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.
|
||||
|
||||
@ -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` |
|
||||
|
||||
|
||||
@ -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/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:
|
||||
|
||||
13
plugin.py
13
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) ✓
|
||||
]
|
||||
|
||||
226
routes/adwright.py
Normal file
226
routes/adwright.py
Normal 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
117
routes/bte_proxy.py
Normal 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
458
static/adwright.css
Normal 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
717
static/adwright.js
Normal 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) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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
0
static/adwright/.keep
Normal file
346
static/bte.css
Normal file
346
static/bte.css
Normal 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
550
static/bte.js
Normal 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) =>
|
||||
({ "<": "<", ">": ">", "&": "&", '"': """ }[c]));
|
||||
}
|
||||
|
||||
// ── Boot ─────────────────────────────────────────────────────────────────
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", _init);
|
||||
} else {
|
||||
_init();
|
||||
}
|
||||
})();
|
||||
0
static/bte/.gitkeep
Normal file
0
static/bte/.gitkeep
Normal file
Loading…
Reference in New Issue
Block a user