mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-06-13 18:31:16 +08:00
Merge branch 'dev'
This commit is contained in:
@@ -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",
|
||||
|
||||
188
tests/panel-bridge.mjs
Normal file
188
tests/panel-bridge.mjs
Normal file
@@ -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");
|
||||
@@ -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 =
|
||||
'<i class="fa-lg fa-solid fa-brain"></i><span>记忆图谱</span>';
|
||||
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 =
|
||||
'<i class="fa-lg fa-solid fa-brain"></i><span>记忆图谱</span>';
|
||||
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 =
|
||||
'<div class="fa-solid fa-brain extensionsMenuExtensionButton"></div><span>记忆图谱</span>';
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user