feat(megaprompt-v4): arcade cabinet DOM widget with reels, lever, theme and onExecuted sync
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f8eac6ea23
commit
6b97a20329
@@ -1,6 +1,8 @@
|
||||
// Node harness for web/mega_prompt_v4.js (no npm deps): node tests/js/test_mega_prompt_v4.mjs
|
||||
import assert from "node:assert/strict";
|
||||
import { REELS, ROLL_REELS, readState, shortText, pickLang, historyRows, TEXT } from "../../web/mega_prompt_v4_core.js";
|
||||
import { installFakeDom } from "./fake_dom.mjs";
|
||||
import { loadExtension } from "./load_extension.mjs";
|
||||
|
||||
const tests = [];
|
||||
const test = (name, fn) => tests.push([name, fn]);
|
||||
@@ -33,6 +35,85 @@ test("TEXT has the same keys in both languages", () => {
|
||||
assert.equal(REELS.length, 5); assert.deepEqual(ROLL_REELS, ["subject", "location", "style", "effects"]);
|
||||
});
|
||||
|
||||
// --- the cabinet ----------------------------------------------------------
|
||||
const fake = installFakeDom();
|
||||
const { app, api } = await loadExtension();
|
||||
const ext = app.extensions.find((e) => e.name === "Isulion.MegaPromptV4");
|
||||
const nodeType = function () {};
|
||||
ext?.beforeRegisterNodeDef(nodeType, { name: "IsulionMegaPromptV4" });
|
||||
|
||||
function makeNode() {
|
||||
const node = {
|
||||
widgets: [{ name: "machine_state", value: '{"version": 1}' }],
|
||||
size: [300, 200], properties: {},
|
||||
graph: { changes: 0, change() { this.changes++; } },
|
||||
setSize(s) { this.size = s; }, setDirtyCanvas() {},
|
||||
addDOMWidget(name, type, el, opts) { this.dom = { name, type, el, opts }; return this.dom; },
|
||||
};
|
||||
Object.setPrototypeOf(node, nodeType.prototype);
|
||||
return node;
|
||||
}
|
||||
const themes = [{ name: "anime", display: "🎌 Anime", icon: "🎌", label: "Anime" }, { name: "cyberpunk", display: "🤖 Cyberpunk", icon: "🤖", label: "Cyberpunk" }];
|
||||
const payload = (over = {}) => ({ state: { ...readState(null), theme: { name: "anime", locked: false } }, prompt: "hero, forest", rolled: ["theme", "subject", "location"],
|
||||
segments: [{ reel: "subject", text: "hero" }, { reel: "location", text: "forest" }], seed: 482913, theme: themes[0], ...over });
|
||||
const tick = () => new Promise((r) => setImmediate(r));
|
||||
async function createNode() {
|
||||
api.responders["/isulion/megaprompt_v4/themes"] = themes;
|
||||
const node = makeNode(); node.onNodeCreated(); fake.flushFrames(); await tick(); await tick();
|
||||
return node;
|
||||
}
|
||||
const lastRoll = () => api.calls.filter((c) => c.route === "/isulion/megaprompt_v4/roll").at(-1);
|
||||
const click = (el) => el.dispatchEvent(new Event("click"));
|
||||
|
||||
test("registers extension and builds the cabinet once per node", async () => {
|
||||
assert.ok(ext); const node = await createNode();
|
||||
assert.ok(node._jackpot); assert.equal(node.dom.opts.serialize, false); assert.equal(node.dom.opts.hideOnZoom, false);
|
||||
assert.equal(node.widgets[0].hidden, true); assert.equal(node.widgets[0].type, "converted-widget");
|
||||
await createNode();
|
||||
assert.equal(document.head.children.filter((c) => c.id === "isu-mpv4-styles").length, 1);
|
||||
});
|
||||
test("lever posts a roll for all reels and applies the result", async () => {
|
||||
const node = await createNode(); api.responders["/isulion/megaprompt_v4/roll"] = payload();
|
||||
click(node._jackpot.ui.lever); await tick(); await tick();
|
||||
assert.deepEqual(lastRoll().body.reels, REELS); fake.runTimers(5000);
|
||||
assert.equal(JSON.parse(node.widgets[0].value).theme.name, "anime");
|
||||
assert.ok(node._jackpot.ui.out.textContent.includes("hero, forest")); assert.ok(node.graph.changes >= 1);
|
||||
assert.equal(node.properties.isu_mpv4.last.seed, 482913);
|
||||
});
|
||||
test("lock writes the state without rolling", async () => {
|
||||
const node = await createNode(); const before = api.calls.length;
|
||||
click(node._jackpot.ui.reels.subject.btns.lock);
|
||||
assert.equal(JSON.parse(node.widgets[0].value).reels.subject.locked, true); assert.equal(api.calls.length, before);
|
||||
});
|
||||
test("next theme locks the theme and rerolls the other reels", async () => {
|
||||
const node = await createNode(); api.responders["/isulion/megaprompt_v4/roll"] = payload();
|
||||
node._jackpot.state.theme.name = "anime";
|
||||
click(node._jackpot.ui.reels.theme.btns.next); await tick();
|
||||
const { body } = lastRoll(); assert.deepEqual(body.state.theme, { name: "cyberpunk", locked: true }); assert.deepEqual(body.reels, ROLL_REELS);
|
||||
});
|
||||
test("onExecuted replays the server result and records history", async () => {
|
||||
const node = await createNode(); node.onExecuted({ draw: [payload({ prompt: "from server", segments: [{ reel: "subject", text: "from server" }] })] });
|
||||
fake.runTimers(5000);
|
||||
assert.equal(node._jackpot.ui.out.textContent.replace(/\s+$/, ""), "from server");
|
||||
assert.equal(node.properties.isu_mpv4.draws, 1); assert.ok(node._jackpot.ui.seed.textContent.includes("482913"));
|
||||
});
|
||||
test("late roll response is ignored after onExecuted", async () => {
|
||||
const node = await createNode(); let resolve; api.responders["/isulion/megaprompt_v4/roll"] = () => new Promise((r) => (resolve = r));
|
||||
click(node._jackpot.ui.lever); await tick();
|
||||
node.onExecuted({ draw: [payload({ prompt: "server wins", segments: [{ reel: "subject", text: "server wins" }] })] });
|
||||
resolve(payload({ prompt: "late", segments: [{ reel: "subject", text: "late" }] })); await tick(); await tick(); fake.runTimers(5000);
|
||||
assert.equal(node._jackpot.ui.out.textContent.replace(/\s+$/, ""), "server wins");
|
||||
});
|
||||
test("removing the node twice is safe and leaves nothing running", async () => {
|
||||
const obsBefore = fake.observers.length, docBefore = fake.docListeners();
|
||||
const node = await createNode(); api.responders["/isulion/megaprompt_v4/roll"] = payload();
|
||||
click(node._jackpot.ui.lever); await tick(); await tick();
|
||||
node.onRemoved(); node.dom.onRemove(); node._disposeJackpot();
|
||||
assert.equal(node._jackpot, null); assert.equal(fake.pendingTimers(), 0);
|
||||
assert.equal(fake.docListeners(), docBefore);
|
||||
const mine = fake.observers.slice(obsBefore); assert.ok(mine.length >= 1 && mine.every((o) => o.disconnected));
|
||||
});
|
||||
|
||||
// --- runner (keep last) ---------------------------------------------------
|
||||
let failures = 0;
|
||||
for (const [name, fn] of tests) {
|
||||
|
||||
@@ -0,0 +1,702 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { api } from "../../scripts/api.js";
|
||||
import { REELS, ROLL_REELS, readState, shortText, pickLang, historyRows, TEXT } from "./mega_prompt_v4_core.js";
|
||||
|
||||
// Mega Prompt V4 « Prompt Jackpot »: an arcade slot machine in a DOM widget.
|
||||
// The Python side owns every draw and the prompt assembly; this file edits the
|
||||
// hidden machine_state widget, shows what the server returns and animates it.
|
||||
|
||||
const NODE_NAME = "IsulionMegaPromptV4";
|
||||
const ROUTE = "/isulion/megaprompt_v4";
|
||||
const REEL_ICONS = { subject: "👤", location: "🗺️", style: "🎨", effects: "✨" };
|
||||
const LANG_KEY = "isu-mpv4-lang";
|
||||
|
||||
let themesPromise = null; // one /themes request shared by every node
|
||||
|
||||
function lsGet(key) {
|
||||
try { return localStorage.getItem(key); } catch { return null; }
|
||||
}
|
||||
function lsSet(key, value) {
|
||||
try { localStorage.setItem(key, value); } catch { /* private mode: language just isn't remembered */ }
|
||||
}
|
||||
|
||||
function hideWidget(w) {
|
||||
if (!w) return;
|
||||
w.hidden = true;
|
||||
w.type = "converted-widget";
|
||||
w.computeSize = () => [0, -4];
|
||||
if (w.element) w.element.style.display = "none";
|
||||
}
|
||||
|
||||
function el(tag, cls, text) {
|
||||
const e = document.createElement(tag);
|
||||
if (cls) e.className = cls;
|
||||
if (text != null) e.textContent = text;
|
||||
return e;
|
||||
}
|
||||
|
||||
const stop = (e) => e.stopPropagation();
|
||||
|
||||
const CSS = `
|
||||
.isu-mpv4-root{width:100%;height:100%;box-sizing:border-box;font:12px system-ui,sans-serif;color:#e5e7eb}
|
||||
.isu-mpv4-cab{position:relative;height:100%;box-sizing:border-box;display:flex;flex-direction:column;gap:7px;padding:9px;border-radius:14px;
|
||||
background:linear-gradient(#24103f,#12081f);border:3px solid #ff2fd0;box-shadow:0 0 18px #ff2fd088,inset 0 0 22px #00e5ff33;animation:isu-mpv4-neon 3s ease-in-out infinite;overflow:hidden}
|
||||
@keyframes isu-mpv4-neon{50%{box-shadow:0 0 28px #00e5ffaa,inset 0 0 22px #ff2fd044;border-color:#00e5ff}}
|
||||
.isu-mpv4-head{display:flex;align-items:center;justify-content:center;position:relative}
|
||||
.isu-mpv4-marquee{font:bold 15px "Courier New",monospace;letter-spacing:.2em;color:#ffe600;text-shadow:0 0 8px #ffe600,0 0 18px #ff2fd0}
|
||||
.isu-mpv4-star{animation:isu-mpv4-blink 1.2s infinite}.isu-mpv4-star+.isu-mpv4-star{animation-delay:.6s}
|
||||
@keyframes isu-mpv4-blink{50%{opacity:.2}}
|
||||
.isu-mpv4-helpbtn{position:absolute;right:0;top:-2px;width:22px;height:22px;border-radius:50%;border:2px solid #00e5ff;background:#020617;color:#00e5ff;
|
||||
font:bold 12px "Courier New",monospace;cursor:pointer;box-shadow:0 0 8px #00e5ff;padding:0}
|
||||
.isu-mpv4-top{display:flex;gap:8px}
|
||||
.isu-mpv4-crt{flex:1;position:relative;border-radius:12px;background:radial-gradient(ellipse at center,#06122a,#020617 75%);padding:7px;overflow:hidden;
|
||||
border:2px solid #0f2a4a;font-family:"Courier New",monospace}
|
||||
.isu-mpv4-crt::after,.isu-mpv4-screen::after{content:"";position:absolute;inset:0;background:repeating-linear-gradient(#0000 0 2px,#0005 2px 3px);pointer-events:none;animation:isu-mpv4-roll 6s linear infinite}
|
||||
@keyframes isu-mpv4-roll{to{background-position:0 60px}}
|
||||
.isu-mpv4-reels{display:grid;grid-template-columns:1.2fr 1fr 1fr 1fr 1fr;gap:4px}
|
||||
.isu-mpv4-reel{position:relative;height:108px;border:2px solid #00e5ff66;border-radius:4px;background:#020617;display:flex;flex-direction:column;
|
||||
align-items:center;justify-content:space-between;padding:3px 2px;overflow:hidden}
|
||||
.isu-mpv4-name{font-size:8px;letter-spacing:.08em;color:#00e5ff;text-shadow:0 0 4px #00e5ff}
|
||||
.isu-mpv4-face{display:flex;flex-direction:column;align-items:center;text-align:center;min-width:0}
|
||||
.isu-mpv4-icon{font-size:24px;line-height:1.15}
|
||||
.isu-mpv4-label{font-size:8.5px;color:#cfe9ff;line-height:1.15;max-height:21px;overflow:hidden;word-break:break-word}
|
||||
.isu-mpv4-btns{display:flex;gap:2px}
|
||||
.isu-mpv4-btn{background:none;border:0;padding:0 2px;font-size:10px;cursor:pointer;border-radius:3px;color:#cfe9ff}
|
||||
.isu-mpv4-btn:hover{background:#ffffff22}
|
||||
.isu-mpv4-locked{border-color:#ffe600;box-shadow:0 0 9px #ffe600}
|
||||
.isu-mpv4-off{opacity:.3;filter:grayscale(1)}
|
||||
.isu-mpv4-custom .isu-mpv4-name::after{content:" ✏️"}
|
||||
.isu-mpv4-spin .isu-mpv4-face{animation:isu-mpv4-spin .09s linear infinite}
|
||||
@keyframes isu-mpv4-spin{0%{transform:translateY(-12px);filter:blur(2px)}100%{transform:translateY(12px);filter:blur(2px)}}
|
||||
.isu-mpv4-land .isu-mpv4-face{animation:isu-mpv4-bounce .45s cubic-bezier(.3,1.6,.5,1)}
|
||||
@keyframes isu-mpv4-bounce{from{transform:translateY(-16px)}to{transform:none}}
|
||||
.isu-mpv4-stick{width:36px;display:flex;flex-direction:column;align-items:center;padding-top:4px;cursor:pointer;background:none;border:0}
|
||||
.isu-mpv4-ball{width:30px;height:30px;border-radius:50%;background:radial-gradient(circle at 35% 35%,#ff9af3,#ff2fd0 60%,#7a0a63);box-shadow:0 0 14px #ff2fd0;transition:transform .25s}
|
||||
.isu-mpv4-shaft{width:6px;height:74px;background:linear-gradient(90deg,#555,#eee,#555);border-radius:3px;transform-origin:bottom;transition:transform .25s}
|
||||
.isu-mpv4-base{width:26px;height:10px;border-radius:4px;background:#444}
|
||||
.isu-mpv4-pull .isu-mpv4-shaft{transform:scaleY(.4)}.isu-mpv4-pull .isu-mpv4-ball{transform:translateY(44px)}
|
||||
.isu-mpv4-pulltxt{font:bold 8px "Courier New",monospace;color:#ff9af3;margin-top:4px}
|
||||
.isu-mpv4-panel{display:flex;gap:6px;align-items:center;font-family:"Courier New",monospace}
|
||||
.isu-mpv4-sw{display:flex;border:2px solid #00e5ff66;border-radius:6px;overflow:hidden}
|
||||
.isu-mpv4-sw button{background:none;border:0;padding:4px 7px;font:9.5px "Courier New",monospace;color:#7dd3fc;cursor:pointer;letter-spacing:.05em}
|
||||
.isu-mpv4-sw button.isu-mpv4-on{background:#00e5ff;color:#020617;box-shadow:0 0 10px #00e5ff;font-weight:bold}
|
||||
.isu-mpv4-lora{flex:1;display:flex;align-items:center;gap:5px;border:2px solid #ff2fd066;border-radius:6px;padding:2px 6px;min-width:0}
|
||||
.isu-mpv4-lora span{font-size:9px;color:#ff9af3}
|
||||
.isu-mpv4-lora input{flex:1;min-width:0;background:transparent;border:0;outline:0;color:#ffd6fa;font:11px "Courier New",monospace}
|
||||
.isu-mpv4-screen{position:relative;flex:1;min-height:0;display:flex;flex-direction:column;border-radius:10px;background:#020617;border:2px solid #0f2a4a;padding:8px;font-family:"Courier New",monospace;overflow:hidden}
|
||||
.isu-mpv4-hd{display:flex;justify-content:space-between;align-items:center;gap:6px;font-size:9px;color:#ffe600;margin-bottom:4px}
|
||||
.isu-mpv4-act{background:none;border:0;color:#ffe600;font:9px "Courier New",monospace;cursor:pointer;padding:0 2px}
|
||||
.isu-mpv4-act:hover{text-shadow:0 0 6px #ffe600}
|
||||
.isu-mpv4-frozen{color:#7fe7ff;text-shadow:0 0 5px #7fe7ff}
|
||||
.isu-mpv4-out{font-size:11.5px;line-height:1.7;min-height:56px;color:#7CFF6B;word-break:break-word}
|
||||
.isu-mpv4-seg{padding:1px 3px;border-radius:3px;text-shadow:0 0 5px currentColor;animation:isu-mpv4-pop .4s both}
|
||||
.isu-mpv4-seg-subject,.isu-mpv4-seg-location,.isu-mpv4-seg-style,.isu-mpv4-seg-effects{cursor:pointer}
|
||||
.isu-mpv4-seg:hover{outline:1px dashed currentColor}
|
||||
@keyframes isu-mpv4-pop{from{opacity:0}to{opacity:1}}
|
||||
.isu-mpv4-seg-subject{color:#ff8cc0}.isu-mpv4-seg-location{color:#7fe7ff}.isu-mpv4-seg-style{color:#ffd08a}.isu-mpv4-seg-effects{color:#a6f0a8}
|
||||
.isu-mpv4-seg-complexity{color:#c4b5fd}.isu-mpv4-seg-lora{color:#e2e8f0;border:1px dashed #64748b}.isu-mpv4-seg-frozen{color:#7fe7ff}.isu-mpv4-seg-error{color:#ff6b6b}
|
||||
.isu-mpv4-cursor{display:inline-block;width:7px;height:12px;background:#7CFF6B;vertical-align:middle;animation:isu-mpv4-blink .7s infinite}
|
||||
.isu-mpv4-hint{color:#6b7280}
|
||||
.isu-mpv4-hist{display:flex;flex-direction:column;gap:3px;margin-top:6px}
|
||||
.isu-mpv4-histtitle{font-size:9px;color:#ffe600;letter-spacing:.08em}
|
||||
.isu-mpv4-row{display:flex;gap:6px;align-items:baseline;font-size:10.5px;padding:3px 7px;border-radius:6px;background:#1e1036;color:#d8b4fe;border:1px solid #ff2fd033;cursor:pointer}
|
||||
.isu-mpv4-row:hover{border-color:#ff2fd0;background:#2a1450}
|
||||
.isu-mpv4-no{color:#ff2fd0;flex:0 0 auto}
|
||||
.isu-mpv4-tx{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.isu-mpv4-st{flex:0 0 auto;color:#6b5a8a;background:none;border:0;cursor:pointer;font-size:11px;padding:0}
|
||||
.isu-mpv4-fav{border-color:#ffe60088;color:#fff3a3}.isu-mpv4-fav .isu-mpv4-st{color:#ffe600;text-shadow:0 0 5px #ffe600}
|
||||
.isu-mpv4-foot{display:flex;justify-content:space-between;font-size:9px;color:#ffe600;margin-top:auto;padding-top:5px}
|
||||
.isu-mpv4-overlay{position:absolute;inset:6px;z-index:5;background:#020617f2;border:2px solid #00e5ff;border-radius:8px;padding:6px;display:flex;flex-direction:column;gap:5px}
|
||||
.isu-mpv4-overlay[hidden]{display:none}
|
||||
.isu-mpv4-overlay input,.isu-mpv4-overlay textarea{width:100%;box-sizing:border-box;background:#0b1530;border:1px solid #00e5ff66;color:#cfe9ff;font:11px "Courier New",monospace;padding:4px 6px;border-radius:4px;outline:0}
|
||||
.isu-mpv4-overlay textarea{flex:1;resize:none;min-height:60px}
|
||||
.isu-mpv4-list{display:grid;grid-template-columns:1fr 1fr;gap:2px;overflow:auto;min-height:0}
|
||||
.isu-mpv4-list div{font-size:10px;color:#cfe9ff;padding:3px 5px;border-radius:3px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.isu-mpv4-list div:hover{background:#00e5ff33}
|
||||
.isu-mpv4-editrow{display:flex;gap:4px;justify-content:flex-end}
|
||||
.isu-mpv4-editrow button{background:#0b1530;border:1px solid #00e5ff66;color:#00e5ff;font:10px "Courier New",monospace;border-radius:4px;cursor:pointer;padding:3px 8px}
|
||||
.isu-mpv4-help{position:absolute;inset:8px;z-index:10;background:#0b0618;border:2px solid #ff2fd0;border-radius:12px;padding:10px 12px;display:flex;flex-direction:column;gap:6px;box-shadow:0 0 24px #ff2fd088}
|
||||
.isu-mpv4-help[hidden]{display:none}
|
||||
.isu-mpv4-helphd{display:flex;align-items:center;gap:6px;font:bold 13px "Courier New",monospace;color:#ffe600}
|
||||
.isu-mpv4-helphd span{flex:1}
|
||||
.isu-mpv4-helphd button{background:none;border:1px solid #ff2fd066;color:#ff9af3;border-radius:4px;cursor:pointer;font:10px "Courier New",monospace;padding:2px 6px}
|
||||
.isu-mpv4-helphd button.isu-mpv4-on{background:#ff2fd0;color:#fff}
|
||||
.isu-mpv4-helpbody{overflow:auto;min-height:0;font:12px/1.45 system-ui,sans-serif;color:#e5e7eb}
|
||||
.isu-mpv4-helpbody h4{margin:8px 0 2px;color:#00e5ff;font-size:12px}
|
||||
.isu-mpv4-helpbody p{margin:0}
|
||||
.isu-mpv4-paused,.isu-mpv4-paused *{animation-play-state:paused!important}
|
||||
`;
|
||||
|
||||
function injectStyles() {
|
||||
if (document.getElementById("isu-mpv4-styles")) return;
|
||||
const style = el("style", null, CSS);
|
||||
style.id = "isu-mpv4-styles";
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
class PromptJackpot {
|
||||
constructor(node, dataWidget, root) {
|
||||
Object.assign(this, { node, dataWidget, root, reqId: 0, timers: new Set(), typing: null, disposed: false, visible: true, themes: [] });
|
||||
this.lang = pickLang(navigator.language, lsGet(LANG_KEY));
|
||||
this.knownThemes = new Map(); // theme entries seen in roll results, before /themes answers
|
||||
this.docCleanups = [];
|
||||
injectStyles();
|
||||
this.build();
|
||||
|
||||
this.onVisibility = () => this.updatePaused();
|
||||
document.addEventListener("visibilitychange", this.onVisibility);
|
||||
this.intersection = new IntersectionObserver(([e]) => { this.visible = !!e?.isIntersecting; this.updatePaused(); });
|
||||
this.intersection.observe(root);
|
||||
this.onWheel = (e) => app.canvas?.canvas?.dispatchEvent(new WheelEvent(e.type, e)); // keep graph zoom working
|
||||
root.addEventListener("pointerdown", stop);
|
||||
root.addEventListener("wheel", this.onWheel, { passive: true });
|
||||
|
||||
this.load();
|
||||
this.loadThemes();
|
||||
}
|
||||
|
||||
get t() { return TEXT[this.lang]; }
|
||||
|
||||
// --- DOM ----------------------------------------------------------------
|
||||
build() {
|
||||
const ui = (this.ui = {});
|
||||
this.root.className = "isu-mpv4-root";
|
||||
const cab = el("div", "isu-mpv4-cab");
|
||||
this.root.appendChild(cab);
|
||||
|
||||
const head = el("div", "isu-mpv4-head");
|
||||
ui.marquee = el("span", "isu-mpv4-marquee");
|
||||
ui.helpBtn = el("button", "isu-mpv4-helpbtn", "?");
|
||||
ui.helpBtn.addEventListener("click", () => this.toggleHelp());
|
||||
head.append(el("span", "isu-mpv4-marquee isu-mpv4-star", "★ "), ui.marquee, el("span", "isu-mpv4-marquee isu-mpv4-star", " ★"), ui.helpBtn);
|
||||
|
||||
const top = el("div", "isu-mpv4-top");
|
||||
ui.crt = el("div", "isu-mpv4-crt");
|
||||
const grid = el("div", "isu-mpv4-reels");
|
||||
ui.reels = {};
|
||||
for (const name of REELS) grid.appendChild(this.buildReel(name));
|
||||
ui.crt.append(grid, this.buildSearch(), this.buildEditor());
|
||||
ui.lever = el("button", "isu-mpv4-stick");
|
||||
ui.pullText = el("span", "isu-mpv4-pulltxt");
|
||||
ui.lever.append(el("span", "isu-mpv4-ball"), el("span", "isu-mpv4-shaft"), el("span", "isu-mpv4-base"), ui.pullText);
|
||||
ui.lever.addEventListener("click", () => this.pullLever());
|
||||
top.append(ui.crt, ui.lever);
|
||||
|
||||
const panel = el("div", "isu-mpv4-panel");
|
||||
const sw = el("div", "isu-mpv4-sw");
|
||||
ui.cx = {};
|
||||
for (const v of ["simple", "detailed", "complex"]) {
|
||||
const b = (ui.cx[v] = el("button"));
|
||||
b.addEventListener("click", () => this.setComplexity(v));
|
||||
sw.appendChild(b);
|
||||
}
|
||||
const lora = el("label", "isu-mpv4-lora");
|
||||
ui.loraLabel = el("span");
|
||||
ui.lora = el("input");
|
||||
ui.lora.addEventListener("keydown", (e) => { e.stopPropagation(); if (e.key === "Enter") ui.lora.blur(); });
|
||||
ui.lora.addEventListener("change", () => this.setLora(ui.lora.value));
|
||||
lora.append(ui.loraLabel, ui.lora);
|
||||
panel.append(sw, lora);
|
||||
|
||||
const screen = el("div", "isu-mpv4-screen");
|
||||
const hd = el("div", "isu-mpv4-hd");
|
||||
ui.promptTitle = el("span");
|
||||
ui.frozenBadge = el("span", "isu-mpv4-frozen");
|
||||
ui.frozenBadge.hidden = true;
|
||||
const acts = el("span");
|
||||
ui.copyBtn = el("button", "isu-mpv4-act");
|
||||
ui.copyBtn.addEventListener("click", () => this.copyPrompt());
|
||||
ui.editBtn = el("button", "isu-mpv4-act");
|
||||
ui.editBtn.addEventListener("click", () => this.openPromptEditor());
|
||||
acts.append(ui.copyBtn, ui.editBtn);
|
||||
hd.append(ui.promptTitle, ui.frozenBadge, acts);
|
||||
ui.out = el("div", "isu-mpv4-out");
|
||||
ui.hist = el("div", "isu-mpv4-hist");
|
||||
ui.hist.rows = [];
|
||||
const foot = el("div", "isu-mpv4-foot");
|
||||
ui.draws = el("span");
|
||||
ui.seed = el("span");
|
||||
foot.append(ui.draws, ui.seed);
|
||||
screen.append(hd, ui.out, ui.hist, foot);
|
||||
|
||||
cab.append(head, top, panel, screen, this.buildHelp());
|
||||
}
|
||||
|
||||
buildReel(name) {
|
||||
const box = el("div", "isu-mpv4-reel");
|
||||
const nameEl = el("div", "isu-mpv4-name");
|
||||
const face = el("div", "isu-mpv4-face");
|
||||
const icon = el("span", "isu-mpv4-icon");
|
||||
const label = el("span", "isu-mpv4-label");
|
||||
face.append(icon, label);
|
||||
const btnBox = el("div", "isu-mpv4-btns");
|
||||
const btns = {};
|
||||
const add = (key, action) => {
|
||||
const b = (btns[key] = el("button", "isu-mpv4-btn"));
|
||||
b.addEventListener("click", action);
|
||||
btnBox.appendChild(b);
|
||||
};
|
||||
if (name === "theme") add("prev", () => this.stepTheme(-1));
|
||||
add("lock", () => this.toggleLock(name));
|
||||
if (name === "theme") {
|
||||
add("search", () => this.openSearch());
|
||||
add("next", () => this.stepTheme(1));
|
||||
}
|
||||
if (name === "subject" || name === "location") add("edit", () => this.openInlineEdit(name));
|
||||
if (name !== "theme" && name !== "subject") add("toggle", () => this.toggleEnabled(name));
|
||||
box.append(nameEl, face, btnBox);
|
||||
this.ui.reels[name] = { box, name: nameEl, face, icon, label, btns };
|
||||
return box;
|
||||
}
|
||||
|
||||
buildSearch() {
|
||||
const panel = el("div", "isu-mpv4-overlay");
|
||||
panel.hidden = true;
|
||||
const input = el("input");
|
||||
const list = el("div", "isu-mpv4-list");
|
||||
list.addEventListener("wheel", stop, { passive: true });
|
||||
input.addEventListener("input", () => this.renderSearch(input.value));
|
||||
input.addEventListener("keydown", (e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Escape") this.closeSearch();
|
||||
if (e.key === "Enter") { const first = this.filterThemes(input.value)[0]; if (first) this.setTheme(first.name); }
|
||||
});
|
||||
panel.append(input, list);
|
||||
this.ui.search = { panel, input, list };
|
||||
return panel;
|
||||
}
|
||||
|
||||
buildEditor() {
|
||||
const panel = el("div", "isu-mpv4-overlay");
|
||||
panel.hidden = true;
|
||||
const input = el("input");
|
||||
const textarea = el("textarea");
|
||||
textarea.addEventListener("wheel", stop, { passive: true });
|
||||
const row = el("div", "isu-mpv4-editrow");
|
||||
const clear = el("button", null, "✕");
|
||||
const cancel = el("button");
|
||||
const ok = el("button");
|
||||
row.append(clear, cancel, ok);
|
||||
panel.append(input, textarea, row);
|
||||
this.ui.editor = { panel, input, textarea, clear, cancel, ok };
|
||||
return panel;
|
||||
}
|
||||
|
||||
buildHelp() {
|
||||
const panel = el("div", "isu-mpv4-help");
|
||||
panel.hidden = true;
|
||||
const hd = el("div", "isu-mpv4-helphd");
|
||||
const title = el("span");
|
||||
const langFr = el("button", null, "FR");
|
||||
const langEn = el("button", null, "EN");
|
||||
const close = el("button");
|
||||
langFr.addEventListener("click", () => this.setLang("fr"));
|
||||
langEn.addEventListener("click", () => this.setLang("en"));
|
||||
close.addEventListener("click", () => this.closeHelp());
|
||||
hd.append(title, langFr, langEn, close);
|
||||
const body = el("div", "isu-mpv4-helpbody");
|
||||
body.addEventListener("wheel", stop, { passive: true });
|
||||
panel.append(hd, body);
|
||||
this.ui.help = { panel, title, langFr, langEn, close, body };
|
||||
return panel;
|
||||
}
|
||||
|
||||
// --- state ----------------------------------------------------------------
|
||||
load() {
|
||||
this.state = readState(this.dataWidget.value);
|
||||
this.view = this.node.properties.isu_mpv4 ??= { draws: 0, seed: null, history: [], last: null };
|
||||
if (this.view.last?.theme) this.knownThemes.set(this.view.last.theme.name, this.view.last.theme);
|
||||
this.render();
|
||||
}
|
||||
|
||||
commit() {
|
||||
const v = JSON.stringify(this.state);
|
||||
if (v === this.dataWidget.value) return;
|
||||
this.dataWidget.value = v;
|
||||
this.dataWidget.callback?.(v);
|
||||
this.node.graph?.change();
|
||||
this.node.setDirtyCanvas(true, true);
|
||||
}
|
||||
|
||||
saveView() {
|
||||
this.node.properties.isu_mpv4 = this.view;
|
||||
}
|
||||
|
||||
loadThemes() {
|
||||
themesPromise ??= api.fetchApi(`${ROUTE}/themes`)
|
||||
.then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); })
|
||||
.catch((err) => { themesPromise = null; throw err; });
|
||||
themesPromise
|
||||
.then((list) => { if (this.disposed || !Array.isArray(list)) return; this.themes = list; this.renderReel("theme"); })
|
||||
.catch((err) => console.warn("[Isulion] Mega Prompt V4: theme list unavailable", err));
|
||||
}
|
||||
|
||||
themeInfo() {
|
||||
const name = this.state.theme.name;
|
||||
return this.themes.find((t) => t.name === name) ?? this.knownThemes.get(name) ?? { name, icon: "🎰", label: name || "?" };
|
||||
}
|
||||
|
||||
// --- server ---------------------------------------------------------------
|
||||
async roll(reels) {
|
||||
const id = ++this.reqId;
|
||||
let r, payload;
|
||||
try {
|
||||
r = await api.fetchApi(`${ROUTE}/roll`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ state: this.state, reels }),
|
||||
});
|
||||
payload = await r.json();
|
||||
} catch (err) {
|
||||
if (id === this.reqId && !this.disposed) this.showError(String(err?.message ?? err));
|
||||
return;
|
||||
}
|
||||
if (id !== this.reqId || this.disposed) return; // a newer roll or an execution result won
|
||||
if (!r.ok) return this.showError(payload?.error ?? `HTTP ${r.status}`);
|
||||
const changed = payload.rolled?.length > 0;
|
||||
this.applyResult(payload, { animate: changed, record: changed });
|
||||
}
|
||||
|
||||
applyResult(payload, { animate, record }) {
|
||||
this.state = readState(payload.state);
|
||||
this.commit();
|
||||
if (payload.theme?.name) this.knownThemes.set(payload.theme.name, payload.theme);
|
||||
this.view.last = payload;
|
||||
this.view.seed = payload.seed ?? null;
|
||||
if (record) {
|
||||
this.view.draws += 1;
|
||||
this.pushHistory(payload);
|
||||
}
|
||||
this.saveView();
|
||||
this.renderFooter();
|
||||
const segments = payload.segments ?? [];
|
||||
if (animate) {
|
||||
this.spinReels(payload.rolled ?? [], () => { this.renderReels(); this.typePrompt(segments); });
|
||||
} else {
|
||||
this.renderReels();
|
||||
this.renderPrompt(segments);
|
||||
}
|
||||
}
|
||||
|
||||
onExecuted(payload) {
|
||||
if (!payload) return;
|
||||
this.reqId++; // any roll still in flight is now stale
|
||||
this.applyResult(payload, { animate: true, record: true });
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
for (const name of REELS) this.ui.reels[name].box.classList.remove("isu-mpv4-spin");
|
||||
this.renderPrompt([{ reel: "error", text: `${this.t.error}: ${message}` }]);
|
||||
}
|
||||
|
||||
// --- actions --------------------------------------------------------------
|
||||
pullLever() {
|
||||
if (this.state.frozen_prompt) this.state.frozen_prompt = "";
|
||||
this.pullAnimation();
|
||||
for (const name of this.freeReels()) this.ui.reels[name].box.classList.add("isu-mpv4-spin");
|
||||
this.roll(REELS);
|
||||
}
|
||||
|
||||
freeReels() {
|
||||
return REELS.filter((name) => (name === "theme"
|
||||
? !this.state.theme.locked
|
||||
: !this.state.reels[name].locked && this.state.reels[name].enabled !== false));
|
||||
}
|
||||
|
||||
toggleLock(name) {
|
||||
const target = name === "theme" ? this.state.theme : this.state.reels[name];
|
||||
target.locked = !target.locked;
|
||||
this.commit();
|
||||
this.renderReel(name);
|
||||
}
|
||||
|
||||
toggleEnabled(name) {
|
||||
const reel = this.state.reels[name];
|
||||
reel.enabled = !reel.enabled;
|
||||
this.commit();
|
||||
this.renderReel(name);
|
||||
this.roll([]);
|
||||
}
|
||||
|
||||
stepTheme(dir) {
|
||||
const list = this.themes;
|
||||
if (!list.length) return;
|
||||
const i = list.findIndex((t) => t.name === this.state.theme.name);
|
||||
const j = i < 0 ? 0 : (i + dir + list.length) % list.length;
|
||||
this.setTheme(list[j].name);
|
||||
}
|
||||
|
||||
setTheme(name) {
|
||||
this.state.theme = { name, locked: true };
|
||||
this.commit();
|
||||
this.closeSearch();
|
||||
this.renderReel("theme");
|
||||
for (const r of ROLL_REELS) if (this.freeReels().includes(r)) this.ui.reels[r].box.classList.add("isu-mpv4-spin");
|
||||
this.roll(ROLL_REELS);
|
||||
}
|
||||
|
||||
setComplexity(v) {
|
||||
this.state.complexity = v;
|
||||
this.commit();
|
||||
this.renderComplexity();
|
||||
this.roll([]);
|
||||
}
|
||||
|
||||
setLora(v) {
|
||||
this.state.lora = v;
|
||||
this.commit();
|
||||
this.roll([]);
|
||||
}
|
||||
|
||||
copyPrompt() {
|
||||
navigator.clipboard?.writeText(this.view.last?.prompt ?? "")?.catch?.(() => {});
|
||||
this.ui.copyBtn.textContent = this.t.copied;
|
||||
this.setTimer(() => { this.ui.copyBtn.textContent = this.t.copy; }, 1200);
|
||||
}
|
||||
|
||||
// search panel
|
||||
filterThemes(q) {
|
||||
const needle = String(q).trim().toLowerCase();
|
||||
return this.themes.filter((t) => t.label.toLowerCase().includes(needle));
|
||||
}
|
||||
|
||||
openSearch() {
|
||||
const { panel, input } = this.ui.search;
|
||||
panel.hidden = false;
|
||||
input.value = "";
|
||||
this.renderSearch("");
|
||||
input.focus();
|
||||
}
|
||||
|
||||
closeSearch() {
|
||||
this.ui.search.panel.hidden = true;
|
||||
}
|
||||
|
||||
renderSearch(q) {
|
||||
const rows = this.filterThemes(q).map((t) => {
|
||||
const row = el("div", null, `${t.icon} ${t.label}`);
|
||||
row.addEventListener("click", () => this.setTheme(t.name));
|
||||
return row;
|
||||
});
|
||||
this.ui.search.list.replaceChildren(...rows);
|
||||
}
|
||||
|
||||
// inline editors (Tâche 9)
|
||||
openInlineEdit() {}
|
||||
openPromptEditor() {}
|
||||
|
||||
// history (Tâche 10)
|
||||
pushHistory() {}
|
||||
renderHistory() {}
|
||||
|
||||
// help (Tâche 11)
|
||||
toggleHelp() {}
|
||||
closeHelp() {}
|
||||
setLang() {}
|
||||
|
||||
// --- rendering ------------------------------------------------------------
|
||||
render() {
|
||||
this.renderLabels();
|
||||
this.renderReels();
|
||||
this.renderComplexity();
|
||||
this.ui.lora.value = this.state.lora;
|
||||
this.renderFooter();
|
||||
this.renderHistory();
|
||||
if (this.view.last) this.renderPrompt(this.view.last.segments ?? []);
|
||||
else this.renderPrompt([]);
|
||||
}
|
||||
|
||||
renderLabels() {
|
||||
const t = this.t, ui = this.ui;
|
||||
ui.marquee.textContent = t.marquee;
|
||||
for (const name of REELS) ui.reels[name].name.textContent = t.reels[name];
|
||||
ui.pullText.textContent = t.pull;
|
||||
for (const [v, b] of Object.entries(ui.cx)) b.textContent = t.complexity[v];
|
||||
ui.loraLabel.textContent = t.lora;
|
||||
ui.promptTitle.textContent = t.promptTitle;
|
||||
ui.copyBtn.textContent = t.copy;
|
||||
ui.editBtn.textContent = t.edit;
|
||||
ui.frozenBadge.textContent = t.frozenBadge;
|
||||
ui.search.input.placeholder = t.searchPlaceholder;
|
||||
ui.editor.input.placeholder = t.editPlaceholder;
|
||||
ui.editor.cancel.textContent = t.cancel;
|
||||
ui.editor.ok.textContent = t.ok;
|
||||
}
|
||||
|
||||
renderReels() {
|
||||
for (const name of REELS) this.renderReel(name);
|
||||
}
|
||||
|
||||
renderReel(name) {
|
||||
const r = this.ui.reels[name];
|
||||
let icon, label, locked, off = false, custom = false;
|
||||
if (name === "theme") {
|
||||
const info = this.themeInfo();
|
||||
icon = info.icon;
|
||||
label = info.label;
|
||||
locked = this.state.theme.locked;
|
||||
} else {
|
||||
const reel = this.state.reels[name];
|
||||
custom = !!reel.custom;
|
||||
icon = custom ? "✏️" : REEL_ICONS[name];
|
||||
label = shortText(reel.text || reel.custom);
|
||||
locked = reel.locked;
|
||||
off = reel.enabled === false;
|
||||
if (r.btns.toggle) r.btns.toggle.textContent = off ? "🚫" : "👁";
|
||||
}
|
||||
r.icon.textContent = icon;
|
||||
r.label.textContent = label;
|
||||
r.btns.lock.textContent = locked ? "🔒" : "🔓";
|
||||
if (r.btns.prev) { r.btns.prev.textContent = "◀"; r.btns.next.textContent = "▶"; r.btns.search.textContent = "🔍"; }
|
||||
if (r.btns.edit) r.btns.edit.textContent = "✏️";
|
||||
r.box.classList.toggle("isu-mpv4-locked", locked);
|
||||
r.box.classList.toggle("isu-mpv4-off", off);
|
||||
r.box.classList.toggle("isu-mpv4-custom", custom);
|
||||
}
|
||||
|
||||
renderComplexity() {
|
||||
for (const [v, b] of Object.entries(this.ui.cx)) b.classList.toggle("isu-mpv4-on", v === this.state.complexity);
|
||||
}
|
||||
|
||||
renderFooter() {
|
||||
this.ui.draws.textContent = this.t.draw + String(this.view.draws).padStart(3, "0");
|
||||
this.ui.seed.textContent = this.t.seed + (this.view.seed ?? "—");
|
||||
}
|
||||
|
||||
cursor() {
|
||||
return el("span", "isu-mpv4-cursor");
|
||||
}
|
||||
|
||||
renderPrompt(segments) {
|
||||
clearInterval(this.typing);
|
||||
this.typing = null;
|
||||
const out = this.ui.out;
|
||||
out.replaceChildren();
|
||||
if (!segments.length) out.appendChild(el("span", "isu-mpv4-hint", this.t.emptyHint));
|
||||
segments.forEach((seg, i) => {
|
||||
if (i) out.append(", ");
|
||||
const span = el("span", `isu-mpv4-seg isu-mpv4-seg-${seg.reel}`, seg.text);
|
||||
if (ROLL_REELS.includes(seg.reel)) span.addEventListener("click", () => this.roll([seg.reel]));
|
||||
out.appendChild(span);
|
||||
});
|
||||
out.appendChild(this.cursor());
|
||||
this.ui.frozenBadge.hidden = !this.state.frozen_prompt.trim();
|
||||
}
|
||||
|
||||
typePrompt(segments) {
|
||||
clearInterval(this.typing);
|
||||
const full = segments.map((s) => s.text).join(", ");
|
||||
if (!full) return this.renderPrompt(segments);
|
||||
let k = 0;
|
||||
this.typing = setInterval(() => {
|
||||
k += 3;
|
||||
this.ui.out.textContent = full.slice(0, k);
|
||||
this.ui.out.appendChild(this.cursor());
|
||||
if (k >= full.length) this.renderPrompt(segments);
|
||||
}, 14);
|
||||
}
|
||||
|
||||
// --- animation ------------------------------------------------------------
|
||||
setTimer(fn, ms) {
|
||||
const id = setTimeout(() => { this.timers.delete(id); if (!this.disposed) fn(); }, ms);
|
||||
this.timers.add(id);
|
||||
}
|
||||
|
||||
pullAnimation() {
|
||||
this.ui.lever.classList.add("isu-mpv4-pull");
|
||||
this.setTimer(() => this.ui.lever.classList.remove("isu-mpv4-pull"), 320);
|
||||
}
|
||||
|
||||
spinReels(names, done) {
|
||||
if (!names.length) return done();
|
||||
this.pullAnimation();
|
||||
names.forEach((name, i) => {
|
||||
const box = this.ui.reels[name].box;
|
||||
box.classList.remove("isu-mpv4-land");
|
||||
box.classList.add("isu-mpv4-spin");
|
||||
this.setTimer(() => {
|
||||
box.classList.remove("isu-mpv4-spin");
|
||||
box.classList.add("isu-mpv4-land");
|
||||
this.renderReel(name);
|
||||
if (i === names.length - 1) done();
|
||||
}, 450 + 280 * i);
|
||||
});
|
||||
}
|
||||
|
||||
updatePaused() {
|
||||
this.root.classList.toggle("isu-mpv4-paused", !this.visible || document.hidden);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
this.reqId++;
|
||||
for (const id of this.timers) clearTimeout(id);
|
||||
this.timers.clear();
|
||||
clearInterval(this.typing);
|
||||
this.typing = null;
|
||||
this.intersection.disconnect();
|
||||
document.removeEventListener("visibilitychange", this.onVisibility);
|
||||
for (const fn of this.docCleanups.splice(0)) fn();
|
||||
this.root.removeEventListener("pointerdown", stop);
|
||||
this.root.removeEventListener("wheel", this.onWheel);
|
||||
this.root.replaceChildren();
|
||||
}
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "Isulion.MegaPromptV4",
|
||||
beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name !== NODE_NAME) return;
|
||||
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
const r = onNodeCreated?.apply(this, arguments);
|
||||
const data = this.widgets?.find((w) => w.name === "machine_state");
|
||||
hideWidget(data);
|
||||
const root = document.createElement("div");
|
||||
const dom = this.addDOMWidget("prompt_jackpot", "isulion-prompt-jackpot", root, {
|
||||
getMinHeight: () => 560, hideOnZoom: false, serialize: false,
|
||||
});
|
||||
this.setSize([Math.max(this.size[0], 480), Math.max(this.size[1], 640)]);
|
||||
// root has no size before the first frame; cancel if the node is removed meanwhile
|
||||
this._jackpotRaf = requestAnimationFrame(() => {
|
||||
this._jackpotRaf = null;
|
||||
if (!data) return;
|
||||
try { this._jackpot = new PromptJackpot(this, data, root); }
|
||||
catch (err) { console.error("[Isulion] Mega Prompt V4 failed to start", err); }
|
||||
});
|
||||
dom.onRemove = () => this._disposeJackpot();
|
||||
return r;
|
||||
};
|
||||
|
||||
const onConfigure = nodeType.prototype.onConfigure;
|
||||
nodeType.prototype.onConfigure = function () {
|
||||
const r = onConfigure?.apply(this, arguments);
|
||||
this._jackpot?.load(); // widgets_values and properties were just restored
|
||||
return r;
|
||||
};
|
||||
|
||||
const onExecuted = nodeType.prototype.onExecuted;
|
||||
nodeType.prototype.onExecuted = function (message) {
|
||||
const r = onExecuted?.apply(this, arguments);
|
||||
this._jackpot?.onExecuted(message?.draw?.[0]); // message is the "ui" dict, values are lists
|
||||
return r;
|
||||
};
|
||||
|
||||
const onRemoved = nodeType.prototype.onRemoved;
|
||||
nodeType.prototype.onRemoved = function () {
|
||||
const r = onRemoved?.apply(this, arguments);
|
||||
this._disposeJackpot();
|
||||
return r;
|
||||
};
|
||||
|
||||
// called by both onRemoved and the DOM widget's onRemove; safe to run twice
|
||||
nodeType.prototype._disposeJackpot = function () {
|
||||
if (this._jackpotRaf != null) cancelAnimationFrame(this._jackpotRaf);
|
||||
this._jackpotRaf = null;
|
||||
this._jackpot?.dispose();
|
||||
this._jackpot = null;
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user