feat(plugin): Adwright + BTE Command Center panels (v0.4.0)
plugin-tests / test (push) Failing after 5s
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:
+550
@@ -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();
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user