svrnty-hermes-webui-plugin/static/bte.js
Svrnty e9554fa051
Some checks failed
plugin-tests / test (push) Failing after 6s
fix(bte panel): store BTE-relative thumbUrl so renderer's PROXY_BASE wrap doesn't double-encode
Browser console: 'Failed to load resource: ...%252Fb6078c89...%252Fimage:0'.
_refreshGrid normalized thumbUrl as a full proxy URL, then _renderGrid +
_renderDetail wrap it again with PROXY_BASE+encodeURIComponent → %2F →
%252F. Fix: store raw BTE path; let the existing renderer wrap it once.

Karpathy 4 rules: surface root cause (double-wrap, not the encoder),
smallest fix (one expression), surgical (no other shape change).
2026-05-24 14:19:09 -04:00

549 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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: listen for sidebar nav events from svrnty_nav.js ───────────────
// The floating launcher is gone; sidebar button (injected by svrnty_nav.js)
// calls switchPanel('bte') which sets main.svrnty-showing-bte. We mirror
// that with overlay open/close + remove any stale launcher from prior load.
function _init() {
// Remove any prior floating launcher (older plugin versions injected one).
document.querySelectorAll(".svrnty-bte-launcher").forEach((el) => el.remove());
window.addEventListener("svrnty:panel-switch", (ev) => {
const name = ev && ev.detail && ev.detail.name;
if (name === "bte") _openOverlay();
else _closeOverlay();
});
// If page boots already on bte (deep link via switchPanel), show now.
const main = document.querySelector("main.main");
if (main && main.classList.contains("svrnty-showing-bte")) _openOverlay();
}
// ── Overlay lifecycle ────────────────────────────────────────────────────
// Mount inside <main> so the left sidebar stays visible. CSS
// (.svrnty-bte-overlay rules in bte.css) handles show/hide via
// main.svrnty-showing-bte class set by svrnty_nav.js wrapper.
function _openOverlay() {
let overlay = document.getElementById("svrntyBteOverlay");
const mountTarget = document.querySelector("main.main") || document.body;
if (!overlay) {
overlay = _buildOverlay();
mountTarget.appendChild(overlay);
} else if (overlay.parentElement !== mountTarget && mountTarget.tagName === "MAIN") {
// Migrate from body to main (handles older sessions that mounted on body).
mountTarget.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 ───────────────────────────────────────────────
// Uses /api/query/assetDtos (live BTE endpoint) instead of /api/query/assetGrid
// (PRD §5.4 spec — not yet implemented). Returns all brand-scoped assets so
// the grid shows every render CMO has produced via bte_image_generate.
function _refreshGrid() {
// assetDtos is brand-scoped, not recipe-scoped. Always fetch.
const body = { pageSize: 48, pageNumber: 1, sortBy: "createdAt", sortDescending: true };
_proxyPost("/api/query/assetDtos", body)
.then((resp) => {
if (!resp.ok) {
_log(`assetDtos ← ${resp.status}`);
return;
}
const json = _safeJson(resp.bodyText) || { data: [] };
// Normalize AssetDto rows to the panel's `assets` shape so _renderGrid stays unchanged.
state.assets = (json.data || [])
.filter((a) => a.brandId === state.brand || !state.brand || state.brand === "planb")
.map((a) => ({
id: a.id,
thumbUrl: a.hasImageData ? ("/api/assets/" + a.id + "/image") : null,
lifecycle: a.status,
ratingCount: 0,
meanScore: a.visualPolishScore || a.brandFitScore || null,
recipeSlug: state.family || "(any)",
prompt: a.prompt || "",
width: a.width,
height: a.height,
createdAt: a.createdAt,
}));
_renderGrid();
const stillInFlight = state.assets.some((a) => a.lifecycle === "requested" || a.lifecycle === "generating" || a.lifecycle === "evaluating");
if (!stillInFlight && state.pollTimer) {
clearInterval(state.pollTimer);
state.pollTimer = null;
_log("polling stopped — no in-flight renders");
}
})
.catch((e) => _log(`assetDtos × ${e.message}`));
}
function _startPolling() {
if (state.pollTimer) clearInterval(state.pollTimer);
_refreshGrid();
state.pollTimer = setInterval(_refreshGrid, POLL_INTERVAL_MS);
}
function _renderGrid() {
const grid = document.getElementById("svrntyBteGrid");
const empty = document.getElementById("svrntyBteEmpty");
if (!grid || !empty) return;
if (!state.assets.length) {
grid.innerHTML = "";
empty.style.display = "";
return;
}
empty.style.display = "none";
grid.innerHTML = state.assets.map((a) => {
const thumb = a.thumbUrl
? `<img src="${PROXY_BASE}${encodeURIComponent(a.thumbUrl)}" alt="">`
: '<span>no thumb</span>';
const score = (a.meanScore != null) ? ` · ★${a.meanScore.toFixed(1)}` : "";
const sel = (a.id === state.selected) ? " svrnty-bte-card-selected" : "";
return (
`<div class="svrnty-bte-card${sel}" data-id="${_esc(a.id)}">` +
`<div class="svrnty-bte-card-thumb">${thumb}</div>` +
`<div class="svrnty-bte-card-meta">` +
`<span class="svrnty-bte-status svrnty-bte-status-${_esc(a.lifecycle || "generating")}">${_esc(a.lifecycle || "—")}</span>` +
`<span>${a.ratingCount || 0}r${score}</span>` +
`</div>` +
`</div>`
);
}).join("");
grid.querySelectorAll(".svrnty-bte-card").forEach((card) => {
card.addEventListener("click", () => _selectAsset(card.dataset.id));
});
}
function _selectAsset(id) {
state.selected = id;
const asset = state.assets.find((a) => a.id === id);
_renderGrid();
_renderDetail(asset);
}
function _renderDetail(asset) {
const host = document.getElementById("svrntyBteDetail");
if (!host) return;
if (!asset) { host.innerHTML = ""; return; }
const thumb = asset.thumbUrl
? `<img src="${PROXY_BASE}${encodeURIComponent(asset.thumbUrl)}" alt="">`
: "no preview";
host.innerHTML =
'<div class="svrnty-bte-detail">' +
`<div class="svrnty-bte-detail-preview">${thumb}</div>` +
'<dl class="svrnty-bte-detail-meta">' +
`<dt>asset id</dt><dd>${_esc(asset.id)}</dd>` +
`<dt>recipe</dt><dd>${_esc(asset.recipeSlug || "—")} v${asset.recipeVersion || "?"}</dd>` +
`<dt>lifecycle</dt><dd>${_esc(asset.lifecycle || "—")}</dd>` +
`<dt>ratings</dt><dd>${asset.ratingCount || 0} · mean ${asset.meanScore != null ? asset.meanScore.toFixed(2) : "—"}</dd>` +
'<dt>rate</dt><dd>' +
'<div class="svrnty-bte-rate-row">' +
`<button class="svrnty-bte-rate-btn svrnty-bte-rate-btn-up" data-v="1" data-id="${_esc(asset.id)}" type="button">+1</button>` +
`<button class="svrnty-bte-rate-btn svrnty-bte-rate-btn-down" data-v="-1" data-id="${_esc(asset.id)}" type="button">1</button>` +
'<select class="svrnty-bte-select" id="svrntyBteScore">' +
'<option value="">— score —</option>' +
'<option value="1">1</option><option value="2">2</option><option value="3">3</option><option value="4">4</option><option value="5">5</option>' +
'</select>' +
'</div>' +
`<textarea class="svrnty-bte-comment" id="svrntyBteComment" placeholder="Comment (optional) — e.g. 'too plastic', 'needs warmer light'"></textarea>` +
'</dd>' +
'</dl>' +
'</div>';
host.querySelectorAll(".svrnty-bte-rate-btn").forEach((btn) => {
btn.addEventListener("click", () => _rateAsset(btn.dataset.id, parseInt(btn.dataset.v, 10)));
});
}
function _rateAsset(id, verdict) {
const scoreSel = document.getElementById("svrntyBteScore");
const commentEl = document.getElementById("svrntyBteComment");
const body = {
assetId: id,
verdict: verdict > 0 ? "accept" : "reject",
score: scoreSel && scoreSel.value ? parseInt(scoreSel.value, 10) : null,
comment: commentEl ? (commentEl.value || "").trim() : "",
ratedBy: localStorage.getItem("svrnty-bte.rater") || "jp",
};
_log(`POST rateAsset ${id} ${body.verdict}`);
_proxyPost("/api/command/rateAsset", body)
.then((resp) => {
if (resp.status === 404 || resp.status === 501) {
_banner("BTE /api/command/rateAsset not yet implemented (PRD §7).", "warn");
return;
}
if (!resp.ok) {
_log(`rateAsset ← ${resp.status}`);
return;
}
_log(`rateAsset ← ok`);
_refreshGrid();
})
.catch((e) => _log(`rateAsset × ${e.message}`));
}
// ── CMO chat handoff ─────────────────────────────────────────────────────
function _openCmoChat() {
// Honest minimum: surface the WebUI chat panel (whichever profile is
// active). Full CMO-scoped iframe / component reuse is COMMAND-CENTER-PRD
// §6.5 Phase E — not v1 of this panel.
_closeOverlay();
if (typeof window.switchPanel === "function") {
try { window.switchPanel("chat", { fromRailClick: true }); } catch (_) { /* ignore */ }
}
}
// ── Proxy helpers ────────────────────────────────────────────────────────
function _proxyPost(btePath, body) {
return fetch(PROXY_BASE + encodeURIComponent(btePath), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}).then(_responseEnvelope);
}
function _responseEnvelope(r) {
return r.text().then((t) => ({ ok: r.ok, status: r.status, bodyText: t }));
}
function _safeJson(s) {
try { return JSON.parse(s); } catch (_) { return null; }
}
// ── UI utils ─────────────────────────────────────────────────────────────
function _banner(text, kind) {
const host = document.getElementById("svrntyBteBanner");
if (!host) return;
host.innerHTML = `<div class="svrnty-bte-banner svrnty-bte-banner-${kind === "warn" ? "warn" : "info"}">${_esc(text)}</div>`;
}
function _log(line) {
state.log.unshift(`${new Date().toLocaleTimeString()} ${line}`);
state.log = state.log.slice(0, 50);
const el = document.getElementById("svrntyBteRunLog");
if (el) el.textContent = state.log.join("\n");
}
function _esc(s) {
return String(s == null ? "" : s).replace(/[<>&"]/g, (c) =>
({ "<": "&lt;", ">": "&gt;", "&": "&amp;", '"': "&quot;" }[c]));
}
// ── Boot ─────────────────────────────────────────────────────────────────
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", _init);
} else {
_init();
}
})();