From e6b68f26f9ac8b4aac0fdead8b8899019c62b915 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 14:24:01 +0000 Subject: [PATCH 1/3] chore: bump manifest version [skip ci] --- manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifest.json b/manifest.json index 158b7f6..1eeb247 100644 --- a/manifest.json +++ b/manifest.json @@ -6,6 +6,6 @@ "js": "index.js", "css": "style.css", "author": "Youzini", - "version": "7.2.7", + "version": "7.2.8", "homePage": "https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology" } From 71be5b47749f355a9411bea482329a8e7768380f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 14:39:18 +0000 Subject: [PATCH 2/3] chore: bump manifest version [skip ci] --- manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifest.json b/manifest.json index 1eeb247..909992b 100644 --- a/manifest.json +++ b/manifest.json @@ -6,6 +6,6 @@ "js": "index.js", "css": "style.css", "author": "Youzini", - "version": "7.2.8", + "version": "7.2.9", "homePage": "https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology" } From 6736a8e426a5007cae91a08e2eb01bb4c170560c Mon Sep 17 00:00:00 2001 From: youzini Date: Thu, 4 Jun 2026 18:20:00 +0000 Subject: [PATCH 3/3] fix(panel): restore magic-wand graph entry --- package.json | 1 + tests/panel-bridge.mjs | 188 +++++++++++++++++++++++++++++++++++++++++ ui/panel-bridge.js | 63 +++++++++++--- 3 files changed, 240 insertions(+), 12 deletions(-) create mode 100644 tests/panel-bridge.mjs diff --git a/package.json b/package.json index f617251..ee45d7f 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version:bump-manifest": "node scripts/bump-manifest-version.mjs", "build:native:wasm": "node scripts/build-native-wasm.mjs", "test:p0": "node tests/p0-regressions.mjs", + "test:panel-bridge": "node tests/panel-bridge.mjs", "test:recall-inject-decoupling": "node tests/recall-inject-decoupling.mjs", "test:recall-reapply-block": "node tests/recall-reapply-block.mjs", "test:triviumdb-poc": "node tests/triviumdb-poc.mjs", diff --git a/tests/panel-bridge.mjs b/tests/panel-bridge.mjs new file mode 100644 index 0000000..94a4eda --- /dev/null +++ b/tests/panel-bridge.mjs @@ -0,0 +1,188 @@ +import assert from "node:assert/strict"; + +let timerId = 0; +const timers = new Map(); +globalThis.setTimeout = (callback, delay = 0) => { + const id = ++timerId; + timers.set(id, { callback, delay }); + return id; +}; + +function runNextTimer() { + const [id, timer] = timers.entries().next().value || []; + if (!id) return false; + timers.delete(id); + timer.callback(); + return true; +} + +class FakeElement { + constructor(tagName, document) { + this.tagName = tagName.toUpperCase(); + this.ownerDocument = document; + this.children = []; + this.parentNode = null; + this.style = {}; + this.dataset = {}; + this.listeners = new Map(); + this.className = ""; + this.innerHTML = ""; + this.textContent = ""; + this._id = ""; + } + + set id(value) { + if (this._id) this.ownerDocument.unregisterId(this._id, this); + this._id = String(value || ""); + if (this._id) this.ownerDocument.registerId(this._id, this); + } + + get id() { + return this._id; + } + + setAttribute(name, value) { + if (name === "id") this.id = value; + else if (name === "class") this.className = String(value || ""); + else this[name] = String(value || ""); + } + + appendChild(child) { + child.parentNode = this; + this.children.push(child); + if (child.id) this.ownerDocument.registerId(child.id, child); + return child; + } + + insertBefore(child, reference) { + child.parentNode = this; + const index = this.children.indexOf(reference); + if (index >= 0) this.children.splice(index, 0, child); + else this.children.push(child); + if (child.id) this.ownerDocument.registerId(child.id, child); + return child; + } + + addEventListener(type, listener) { + const list = this.listeners.get(type) || []; + list.push(listener); + this.listeners.set(type, list); + } + + async click() { + for (const listener of this.listeners.get("click") || []) { + await listener({ target: this }); + } + } +} + +class FakeDocument { + constructor() { + this.byId = new Map(); + this.documentElement = new FakeElement("html", this); + this.body = new FakeElement("body", this); + this.documentElement.appendChild(this.body); + } + + registerId(id, element) { + this.byId.set(id, element); + } + + unregisterId(id, element) { + if (this.byId.get(id) === element) this.byId.delete(id); + } + + createElement(tagName) { + return new FakeElement(tagName, this); + } + + getElementById(id) { + return this.byId.get(id) || null; + } + + querySelector(selector) { + if (selector === "#options .options-content") { + const options = this.getElementById("options"); + return options?.children.find((child) => String(child.className || "").split(/\s+/).includes("options-content")) || null; + } + return null; + } +} + +function appendElement(document, parent, tagName, { id, className, style } = {}) { + const element = document.createElement(tagName); + if (id) element.id = id; + if (className) element.className = className; + if (style) Object.assign(element.style, style); + parent.appendChild(element); + return element; +} + +function buildDocument({ includeExtensionsMenu = true } = {}) { + const document = new FakeDocument(); + const overlay = appendElement(document, document.body, "div", { id: "st-bme-panel-overlay" }); + appendElement(document, overlay, "div", { id: "st-bme-panel" }); + const options = appendElement(document, document.body, "div", { id: "options" }); + const optionsContent = appendElement(document, options, "div", { className: "options-content" }); + appendElement(document, optionsContent, "a", { id: "option_toggle_logprobs" }); + if (includeExtensionsMenu) { + appendElement(document, document.body, "div", { id: "extensionsMenuButton", style: { display: "none" } }); + appendElement(document, document.body, "div", { id: "extensionsMenu", className: "options-content" }); + } + return document; +} + +function buildRuntime(document) { + const calls = { opened: 0, hidden: [], css: [] }; + const panelModule = { openPanel: () => { calls.opened += 1; } }; + return { + calls, + console, + document, + getPanelModule: () => panelModule, + getSettings: () => ({}), + $: (selector) => ({ + hide: () => calls.hidden.push(selector), + css: (name, value) => calls.css.push([selector, name, value]), + }), + }; +} + +const { initializePanelBridgeController } = await import("../ui/panel-bridge.js"); + +{ + const document = buildDocument(); + const runtime = buildRuntime(document); + + await initializePanelBridgeController(runtime); + + const optionsEntry = document.getElementById("option_st_bme_panel"); + const wandEntry = document.getElementById("st_bme_extensions_menu_entry"); + assert.ok(optionsEntry, "legacy options menu entry is injected"); + assert.ok(wandEntry, "magic-wand extensions menu entry is injected"); + assert.equal(document.getElementById("extensionsMenuButton")?.style.display, "flex", "magic-wand button is shown after BME entry injection"); + assert.equal(timers.size, 0, "no retry remains when both menu targets are ready"); + + await wandEntry.click(); + assert.equal(runtime.calls.opened, 1, "magic-wand entry opens BME panel"); + assert.ok(runtime.calls.hidden.includes("#extensionsMenu"), "magic-wand entry closes extensions menu"); +} + +{ + const document = buildDocument({ includeExtensionsMenu: false }); + const runtime = buildRuntime(document); + + await initializePanelBridgeController(runtime); + assert.ok(document.getElementById("option_st_bme_panel"), "options entry is injected even before wand DOM exists"); + assert.equal(document.getElementById("st_bme_extensions_menu_entry"), null, "wand entry waits for wand DOM"); + assert.ok(timers.size > 0, "retry remains scheduled until wand DOM exists"); + + appendElement(document, document.body, "div", { id: "extensionsMenuButton", style: { display: "none" } }); + appendElement(document, document.body, "div", { id: "extensionsMenu", className: "options-content" }); + runNextTimer(); + + assert.ok(document.getElementById("st_bme_extensions_menu_entry"), "retry injects magic-wand entry once wand DOM appears"); + assert.equal(document.getElementById("extensionsMenuButton")?.style.display, "flex", "retry also shows magic-wand button"); +} + +console.log("panel-bridge tests passed"); diff --git a/ui/panel-bridge.js b/ui/panel-bridge.js index 0d73b4f..8dc73b5 100644 --- a/ui/panel-bridge.js +++ b/ui/panel-bridge.js @@ -2,6 +2,8 @@ import { debugLog } from "../runtime/debug-logging.js"; const MENU_ENTRY_RETRY_MS = 400; const MENU_ENTRY_MAX_ATTEMPTS = 30; +const OPTIONS_MENU_ENTRY_ID = "option_st_bme_panel"; +const EXTENSIONS_MENU_ENTRY_ID = "st_bme_extensions_menu_entry"; function resolvePanelTheme(settings) { return settings?.panelTheme || "crimson"; @@ -26,25 +28,30 @@ export function openPanelController(runtime) { runtime.getPanelModule()?.openPanel?.(); } -function injectOptionsMenuEntry(runtime) { - const doc = runtime.document; - if (!doc || doc.getElementById("option_st_bme_panel")) { - return true; - } - const menuItem = doc.createElement("a"); - menuItem.id = "option_st_bme_panel"; - menuItem.innerHTML = - '记忆图谱'; - menuItem.addEventListener("click", async () => { +function bindOpenPanelClick(runtime, element) { + element.addEventListener("click", async () => { try { await ensurePanelBridgeReady(runtime); openPanelController(runtime); runtime.$?.("#options")?.hide?.(); + runtime.$?.("#extensionsMenu")?.hide?.(); } catch (error) { runtime.console.error("[ST-BME] 点击菜单打开面板失败:", error); globalThis.toastr?.error?.("记忆图谱面板加载失败,请查看控制台报错", "ST-BME"); } }); +} + +function injectOptionsMenuEntry(runtime) { + const doc = runtime.document; + if (!doc || doc.getElementById(OPTIONS_MENU_ENTRY_ID)) { + return true; + } + const menuItem = doc.createElement("a"); + menuItem.id = OPTIONS_MENU_ENTRY_ID; + menuItem.innerHTML = + '记忆图谱'; + bindOpenPanelClick(runtime, menuItem); const anchor = doc.getElementById("option_toggle_logprobs"); const optionsContent = doc.querySelector("#options .options-content"); @@ -60,6 +67,36 @@ function injectOptionsMenuEntry(runtime) { return false; } +function injectExtensionsMenuEntry(runtime) { + const doc = runtime.document; + if (!doc) return false; + + const existing = doc.getElementById(EXTENSIONS_MENU_ENTRY_ID); + const menu = doc.getElementById("extensionsMenu"); + const button = doc.getElementById("extensionsMenuButton"); + if (existing) { + if (button?.style) button.style.display = "flex"; + runtime.$?.("#extensionsMenuButton")?.css?.("display", "flex"); + return true; + } + if (!menu) return false; + + const menuItem = doc.createElement("div"); + menuItem.id = EXTENSIONS_MENU_ENTRY_ID; + menuItem.className = "list-group-item flex-container flexGap5"; + menuItem.innerHTML = + '
记忆图谱'; + bindOpenPanelClick(runtime, menuItem); + menu.appendChild(menuItem); + + // SillyTavern shows the magic-wand button only while #extensionsMenu has + // visible children. Its polling can stop before late third-party entries are + // injected, so make the button visible after adding BME's entry. + if (button?.style) button.style.display = "flex"; + runtime.$?.("#extensionsMenuButton")?.css?.("display", "flex"); + return true; +} + function injectFloatingBootstrap(runtime) { const doc = runtime.document; if (!doc) return false; @@ -101,7 +138,9 @@ function scheduleOptionsMenuInjection(runtime, attempt = 0) { } try { - if (injectOptionsMenuEntry(runtime)) { + const optionsReady = injectOptionsMenuEntry(runtime); + const extensionsReady = injectExtensionsMenuEntry(runtime); + if (optionsReady && extensionsReady) { return; } } catch (error) { @@ -110,7 +149,7 @@ function scheduleOptionsMenuInjection(runtime, attempt = 0) { if (attempt >= MENU_ENTRY_MAX_ATTEMPTS) { runtime.console.warn( - "[ST-BME] 操控面板菜单入口注入失败:宿主 options DOM 长时间未就绪", + "[ST-BME] 操控面板菜单入口注入失败:宿主菜单 DOM 长时间未就绪", ); return; }