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>
551 lines
22 KiB
JavaScript
551 lines
22 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: 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();
|
||
}
|
||
})();
|