Some checks failed
plugin-tests / test (push) Failing after 6s
JP question: 'is there any way to see the generated images by the cmo inside the bte panel app'.
The Command Center PRD §5.4 specifies `/api/query/assetGrid` for the grid;
that endpoint isn't implemented yet on BTE. But `/api/query/assetDtos`
WORKS today and returns every brand-scoped asset including the ones CMO
just generated via bte_image_generate (asset ids 664787c4-... + dbe21e15-...).
Changes:
- routes/bte_proxy.py: allowlist /api/query/assetDtos + /api/assets/{id}/image
(the latter so panel can render real PNG thumbnails — not just /thumb stubs)
- static/bte.js: _refreshGrid now POSTs assetDtos with {pageSize:48, sortBy:createdAt desc}.
AssetDto rows normalized to panel's existing asset shape (id, thumbUrl,
lifecycle, scores, prompt, dims). thumbUrl points at the live image bytes
via the new proxy allowlist entry. _renderGrid stays untouched — same
shape it expected.
Result: BTE panel grid now shows every Plan B asset including freshly-
generated CMO images. Polling stops when no in-flight renders remain.
When BTE Command Center §5.4 ships the real assetGrid endpoint, swap
the path back — frontend won't need any other change.
Karpathy 4 rules: smallest possible adapter (normalize one shape to another,
no abstraction added), surgical (one new allowlist entry, one function body
rewritten, no other panel logic touched), verified via curl that assetDtos
returns 10 assets including the CMO-generated ones before committing.
549 lines
23 KiB
JavaScript
549 lines
23 KiB
JavaScript
// 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 ? (PROXY_BASE + encodeURIComponent("/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) =>
|
||
({ "<": "<", ">": ">", "&": "&", '"': """ }[c]));
|
||
}
|
||
|
||
// ── Boot ─────────────────────────────────────────────────────────────────
|
||
if (document.readyState === "loading") {
|
||
document.addEventListener("DOMContentLoaded", _init);
|
||
} else {
|
||
_init();
|
||
}
|
||
})();
|