啊
This commit is contained in:
257
modules/button-collapse.js
Normal file
257
modules/button-collapse.js
Normal file
@@ -0,0 +1,257 @@
|
||||
let stylesInjected = false;
|
||||
|
||||
const SELECTORS = {
|
||||
chat: '#chat',
|
||||
messages: '.mes',
|
||||
mesButtons: '.mes_block .mes_buttons',
|
||||
buttons: '.memory-button, .dynamic-prompt-analysis-btn, .mes_history_preview',
|
||||
collapse: '.xiaobaix-collapse-btn',
|
||||
};
|
||||
|
||||
const XPOS_KEY = 'xiaobaix_x_btn_position';
|
||||
const getXBtnPosition = () => {
|
||||
try {
|
||||
return (
|
||||
window?.extension_settings?.LittleWhiteBox?.xBtnPosition ||
|
||||
localStorage.getItem(XPOS_KEY) ||
|
||||
'name-left'
|
||||
);
|
||||
} catch {
|
||||
return 'name-left';
|
||||
}
|
||||
};
|
||||
|
||||
const injectStyles = () => {
|
||||
if (stylesInjected) return;
|
||||
const css = `
|
||||
.mes_block .mes_buttons{align-items:center}
|
||||
.xiaobaix-collapse-btn{
|
||||
position:relative;display:inline-flex;width:32px;height:32px;justify-content:center;align-items:center;
|
||||
border-radius:50%;background:var(--SmartThemeBlurTintColor);cursor:pointer;
|
||||
box-shadow:inset 0 0 15px rgba(0,0,0,.6),0 2px 8px rgba(0,0,0,.2);
|
||||
transition:opacity .15s ease,transform .15s ease}
|
||||
.xiaobaix-xstack{position:relative;display:inline-flex;align-items:center;justify-content:center;pointer-events:none}
|
||||
.xiaobaix-xstack span{
|
||||
position:absolute;font:italic 900 20px 'Arial Black',sans-serif;letter-spacing:-2px;transform:scaleX(.8);
|
||||
text-shadow:0 0 10px rgba(255,255,255,.5),0 0 20px rgba(100,200,255,.3);color:#fff}
|
||||
.xiaobaix-xstack span:nth-child(1){color:rgba(255,255,255,.1);transform:scaleX(.8) translateX(-8px);text-shadow:none}
|
||||
.xiaobaix-xstack span:nth-child(2){color:rgba(255,255,255,.2);transform:scaleX(.8) translateX(-4px);text-shadow:none}
|
||||
.xiaobaix-xstack span:nth-child(3){color:rgba(255,255,255,.4);transform:scaleX(.8) translateX(-2px);text-shadow:none}
|
||||
.xiaobaix-sub-container{display:none;position:absolute;right:38px;border-radius:8px;padding:4px;gap:8px;pointer-events:auto}
|
||||
.xiaobaix-collapse-btn.open .xiaobaix-sub-container{display:flex;background:var(--SmartThemeBlurTintColor)}
|
||||
.xiaobaix-collapse-btn.open,.xiaobaix-collapse-btn.open ~ *{pointer-events:auto!important}
|
||||
.mes_block .mes_buttons.xiaobaix-expanded{width:150px}
|
||||
.xiaobaix-sub-container,.xiaobaix-sub-container *{pointer-events:auto!important}
|
||||
.xiaobaix-sub-container .memory-button,.xiaobaix-sub-container .dynamic-prompt-analysis-btn,.xiaobaix-sub-container .mes_history_preview{opacity:1!important;filter:none!important}
|
||||
.xiaobaix-sub-container.dir-right{left:38px;right:auto;z-index:1000;margin-top:2px}
|
||||
`;
|
||||
const style = document.createElement('style');
|
||||
style.textContent = css;
|
||||
document.head.appendChild(style);
|
||||
stylesInjected = true;
|
||||
};
|
||||
|
||||
const createCollapseButton = (dirRight) => {
|
||||
injectStyles();
|
||||
const btn = document.createElement('div');
|
||||
btn.className = 'mes_btn xiaobaix-collapse-btn';
|
||||
btn.innerHTML = `
|
||||
<div class="xiaobaix-xstack"><span>X</span><span>X</span><span>X</span><span>X</span></div>
|
||||
<div class="xiaobaix-sub-container${dirRight ? ' dir-right' : ''}"></div>
|
||||
`;
|
||||
const sub = btn.lastElementChild;
|
||||
|
||||
['click','pointerdown','pointerup'].forEach(t => {
|
||||
sub.addEventListener(t, e => e.stopPropagation(), { passive: true });
|
||||
});
|
||||
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
const open = btn.classList.toggle('open');
|
||||
const mesButtons = btn.closest(SELECTORS.mesButtons);
|
||||
if (mesButtons) mesButtons.classList.toggle('xiaobaix-expanded', open);
|
||||
});
|
||||
|
||||
return btn;
|
||||
};
|
||||
|
||||
const findInsertPoint = (messageEl) => {
|
||||
return messageEl.querySelector(
|
||||
'.ch_name.flex-container.justifySpaceBetween .flex-container.flex1.alignitemscenter,' +
|
||||
'.ch_name.flex-container.justifySpaceBetween .flex-container.flex1.alignItemsCenter'
|
||||
);
|
||||
};
|
||||
|
||||
const ensureCollapseForMessage = (messageEl, pos) => {
|
||||
const mesButtons = messageEl.querySelector(SELECTORS.mesButtons);
|
||||
if (!mesButtons) return null;
|
||||
|
||||
let collapseBtn = messageEl.querySelector(SELECTORS.collapse);
|
||||
const dirRight = pos === 'edit-right';
|
||||
|
||||
if (!collapseBtn) collapseBtn = createCollapseButton(dirRight);
|
||||
else collapseBtn.querySelector('.xiaobaix-sub-container')?.classList.toggle('dir-right', dirRight);
|
||||
|
||||
if (dirRight) {
|
||||
const container = findInsertPoint(messageEl);
|
||||
if (!container) return null;
|
||||
if (collapseBtn.parentNode !== container) container.appendChild(collapseBtn);
|
||||
} else {
|
||||
if (mesButtons.lastElementChild !== collapseBtn) mesButtons.appendChild(collapseBtn);
|
||||
}
|
||||
return collapseBtn;
|
||||
};
|
||||
|
||||
let processed = new WeakSet();
|
||||
let io = null;
|
||||
let mo = null;
|
||||
let queue = [];
|
||||
let rafScheduled = false;
|
||||
|
||||
const processOneMessage = (message) => {
|
||||
if (!message || processed.has(message)) return;
|
||||
|
||||
const mesButtons = message.querySelector(SELECTORS.mesButtons);
|
||||
if (!mesButtons) { processed.add(message); return; }
|
||||
|
||||
const pos = getXBtnPosition();
|
||||
if (pos === 'edit-right' && !findInsertPoint(message)) { processed.add(message); return; }
|
||||
|
||||
const targetBtns = mesButtons.querySelectorAll(SELECTORS.buttons);
|
||||
if (!targetBtns.length) { processed.add(message); return; }
|
||||
|
||||
const collapseBtn = ensureCollapseForMessage(message, pos);
|
||||
if (!collapseBtn) { processed.add(message); return; }
|
||||
|
||||
const sub = collapseBtn.querySelector('.xiaobaix-sub-container');
|
||||
const frag = document.createDocumentFragment();
|
||||
targetBtns.forEach(b => frag.appendChild(b));
|
||||
sub.appendChild(frag);
|
||||
|
||||
processed.add(message);
|
||||
};
|
||||
|
||||
const ensureIO = () => {
|
||||
if (io) return io;
|
||||
io = new IntersectionObserver((entries) => {
|
||||
for (const e of entries) {
|
||||
if (!e.isIntersecting) continue;
|
||||
processOneMessage(e.target);
|
||||
io.unobserve(e.target);
|
||||
}
|
||||
}, {
|
||||
root: document.querySelector(SELECTORS.chat) || null,
|
||||
rootMargin: '200px 0px',
|
||||
threshold: 0
|
||||
});
|
||||
return io;
|
||||
};
|
||||
|
||||
const observeVisibility = (nodes) => {
|
||||
const obs = ensureIO();
|
||||
nodes.forEach(n => { if (n && !processed.has(n)) obs.observe(n); });
|
||||
};
|
||||
|
||||
const hookMutations = () => {
|
||||
const chat = document.querySelector(SELECTORS.chat);
|
||||
if (!chat) return;
|
||||
|
||||
if (!mo) {
|
||||
mo = new MutationObserver((muts) => {
|
||||
for (const m of muts) {
|
||||
m.addedNodes && m.addedNodes.forEach(n => {
|
||||
if (n.nodeType !== 1) return;
|
||||
const el = n;
|
||||
if (el.matches?.(SELECTORS.messages)) queue.push(el);
|
||||
else el.querySelectorAll?.(SELECTORS.messages)?.forEach(mes => queue.push(mes));
|
||||
});
|
||||
}
|
||||
if (!rafScheduled && queue.length) {
|
||||
rafScheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
observeVisibility(queue);
|
||||
queue = [];
|
||||
rafScheduled = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
mo.observe(chat, { childList: true, subtree: true });
|
||||
};
|
||||
|
||||
const processExistingVisible = () => {
|
||||
const all = document.querySelectorAll(`${SELECTORS.chat} ${SELECTORS.messages}`);
|
||||
if (!all.length) return;
|
||||
const unprocessed = [];
|
||||
all.forEach(n => { if (!processed.has(n)) unprocessed.push(n); });
|
||||
if (unprocessed.length) observeVisibility(unprocessed);
|
||||
};
|
||||
|
||||
const initButtonCollapse = () => {
|
||||
injectStyles();
|
||||
hookMutations();
|
||||
processExistingVisible();
|
||||
if (window && window['registerModuleCleanup']) {
|
||||
try { window['registerModuleCleanup']('buttonCollapse', cleanup); } catch {}
|
||||
}
|
||||
};
|
||||
|
||||
const processButtonCollapse = () => {
|
||||
processExistingVisible();
|
||||
};
|
||||
|
||||
const registerButtonToSubContainer = (messageId, buttonEl) => {
|
||||
if (!buttonEl) return false;
|
||||
const message = document.querySelector(`${SELECTORS.chat} ${SELECTORS.messages}[mesid="${messageId}"]`);
|
||||
if (!message) return false;
|
||||
|
||||
processOneMessage(message);
|
||||
|
||||
const pos = getXBtnPosition();
|
||||
const collapseBtn = message.querySelector(SELECTORS.collapse) || ensureCollapseForMessage(message, pos);
|
||||
if (!collapseBtn) return false;
|
||||
|
||||
const sub = collapseBtn.querySelector('.xiaobaix-sub-container');
|
||||
sub.appendChild(buttonEl);
|
||||
buttonEl.style.pointerEvents = 'auto';
|
||||
buttonEl.style.opacity = '1';
|
||||
return true;
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
io?.disconnect(); io = null;
|
||||
mo?.disconnect(); mo = null;
|
||||
queue = [];
|
||||
rafScheduled = false;
|
||||
|
||||
document.querySelectorAll(SELECTORS.collapse).forEach(btn => {
|
||||
const sub = btn.querySelector('.xiaobaix-sub-container');
|
||||
const message = btn.closest(SELECTORS.messages) || btn.closest('.mes');
|
||||
const mesButtons = message?.querySelector(SELECTORS.mesButtons) || message?.querySelector('.mes_buttons');
|
||||
if (sub && mesButtons) {
|
||||
mesButtons.classList.remove('xiaobaix-expanded');
|
||||
const frag = document.createDocumentFragment();
|
||||
while (sub.firstChild) frag.appendChild(sub.firstChild);
|
||||
mesButtons.appendChild(frag);
|
||||
}
|
||||
btn.remove();
|
||||
});
|
||||
|
||||
processed = new WeakSet();
|
||||
};
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
Object.assign(window, {
|
||||
initButtonCollapse,
|
||||
cleanupButtonCollapse: cleanup,
|
||||
registerButtonToSubContainer,
|
||||
processButtonCollapse,
|
||||
});
|
||||
|
||||
document.addEventListener('xiaobaixEnabledChanged', (e) => {
|
||||
const en = e && e.detail && e.detail.enabled;
|
||||
if (!en) cleanup();
|
||||
});
|
||||
}
|
||||
|
||||
export { initButtonCollapse, cleanup, registerButtonToSubContainer, processButtonCollapse };
|
||||
268
modules/control-audio.js
Normal file
268
modules/control-audio.js
Normal file
@@ -0,0 +1,268 @@
|
||||
"use strict";
|
||||
|
||||
import { extension_settings } from "../../../../extensions.js";
|
||||
import { eventSource, event_types } from "../../../../../script.js";
|
||||
import { SlashCommandParser } from "../../../../slash-commands/SlashCommandParser.js";
|
||||
import { SlashCommand } from "../../../../slash-commands/SlashCommand.js";
|
||||
import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from "../../../../slash-commands/SlashCommandArgument.js";
|
||||
|
||||
const AudioHost = (() => {
|
||||
/** @typedef {{ audio: HTMLAudioElement|null, currentUrl: string }} AudioInstance */
|
||||
/** @type {Record<'primary'|'secondary', AudioInstance>} */
|
||||
const instances = {
|
||||
primary: { audio: null, currentUrl: "" },
|
||||
secondary: { audio: null, currentUrl: "" },
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {('primary'|'secondary')} area
|
||||
* @returns {HTMLAudioElement}
|
||||
*/
|
||||
function getOrCreate(area) {
|
||||
const inst = instances[area] || (instances[area] = { audio: null, currentUrl: "" });
|
||||
if (!inst.audio) {
|
||||
inst.audio = new Audio();
|
||||
inst.audio.preload = "auto";
|
||||
try { inst.audio.crossOrigin = "anonymous"; } catch { }
|
||||
}
|
||||
return inst.audio;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @param {boolean} loop
|
||||
* @param {('primary'|'secondary')} area
|
||||
* @param {number} volume10 1-10
|
||||
*/
|
||||
async function playUrl(url, loop = false, area = 'primary', volume10 = 5) {
|
||||
const u = String(url || "").trim();
|
||||
if (!/^https?:\/\//i.test(u)) throw new Error("仅支持 http/https 链接");
|
||||
const a = getOrCreate(area);
|
||||
a.loop = !!loop;
|
||||
|
||||
let v = Number(volume10);
|
||||
if (!Number.isFinite(v)) v = 5;
|
||||
v = Math.max(1, Math.min(10, v));
|
||||
try { a.volume = v / 10; } catch { }
|
||||
|
||||
const inst = instances[area];
|
||||
if (inst.currentUrl && u === inst.currentUrl) {
|
||||
if (a.paused) await a.play();
|
||||
return `继续播放: ${u}`;
|
||||
}
|
||||
|
||||
inst.currentUrl = u;
|
||||
if (a.src !== u) {
|
||||
a.src = u;
|
||||
try { await a.play(); }
|
||||
catch (e) { throw new Error("播放失败"); }
|
||||
} else {
|
||||
try { a.currentTime = 0; await a.play(); } catch { }
|
||||
}
|
||||
return `播放: ${u}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {('primary'|'secondary')} area
|
||||
*/
|
||||
function stop(area = 'primary') {
|
||||
const inst = instances[area];
|
||||
if (inst?.audio) {
|
||||
try { inst.audio.pause(); } catch { }
|
||||
}
|
||||
return "已停止";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {('primary'|'secondary')} area
|
||||
*/
|
||||
function getCurrentUrl(area = 'primary') {
|
||||
const inst = instances[area];
|
||||
return inst?.currentUrl || "";
|
||||
}
|
||||
|
||||
function reset() {
|
||||
for (const key of /** @type {('primary'|'secondary')[]} */(['primary','secondary'])) {
|
||||
const inst = instances[key];
|
||||
if (inst.audio) {
|
||||
try { inst.audio.pause(); } catch { }
|
||||
try { inst.audio.removeAttribute('src'); inst.audio.load(); } catch { }
|
||||
}
|
||||
inst.currentUrl = "";
|
||||
}
|
||||
}
|
||||
|
||||
function stopAll() {
|
||||
for (const key of /** @type {('primary'|'secondary')[]} */(['primary','secondary'])) {
|
||||
const inst = instances[key];
|
||||
if (inst?.audio) {
|
||||
try { inst.audio.pause(); } catch { }
|
||||
}
|
||||
}
|
||||
return "已全部停止";
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除指定实例:停止并移除 src,清空 currentUrl
|
||||
* @param {('primary'|'secondary')} area
|
||||
*/
|
||||
function clear(area = 'primary') {
|
||||
const inst = instances[area];
|
||||
if (inst?.audio) {
|
||||
try { inst.audio.pause(); } catch { }
|
||||
try { inst.audio.removeAttribute('src'); inst.audio.load(); } catch { }
|
||||
}
|
||||
inst.currentUrl = "";
|
||||
return "已清除";
|
||||
}
|
||||
|
||||
return { playUrl, stop, stopAll, clear, getCurrentUrl, reset };
|
||||
})();
|
||||
|
||||
let registeredCommand = null;
|
||||
let chatChangedHandler = null;
|
||||
let isRegistered = false;
|
||||
let globalStateChangedHandler = null;
|
||||
|
||||
function registerSlash() {
|
||||
if (isRegistered) return;
|
||||
try {
|
||||
registeredCommand = SlashCommand.fromProps({
|
||||
name: "xbaudio",
|
||||
callback: async (args, value) => {
|
||||
try {
|
||||
const action = String(args.play || "").toLowerCase();
|
||||
const mode = String(args.mode || "loop").toLowerCase();
|
||||
const rawArea = args.area;
|
||||
const hasArea = typeof rawArea !== 'undefined' && rawArea !== null && String(rawArea).trim() !== '';
|
||||
const area = hasArea && String(rawArea).toLowerCase() === 'secondary' ? 'secondary' : 'primary';
|
||||
const volumeArg = args.volume;
|
||||
let volume = Number(volumeArg);
|
||||
if (!Number.isFinite(volume)) volume = 5;
|
||||
const url = String(value || "").trim();
|
||||
const loop = mode === "loop";
|
||||
|
||||
if (url.toLowerCase() === "list") {
|
||||
return AudioHost.getCurrentUrl(area) || "";
|
||||
}
|
||||
|
||||
if (action === "off") {
|
||||
if (hasArea) {
|
||||
return AudioHost.stop(area);
|
||||
}
|
||||
return AudioHost.stopAll();
|
||||
}
|
||||
|
||||
if (action === "clear") {
|
||||
if (hasArea) {
|
||||
return AudioHost.clear(area);
|
||||
}
|
||||
AudioHost.reset();
|
||||
return "已全部清除";
|
||||
}
|
||||
|
||||
if (action === "on" || (!action && url)) {
|
||||
return await AudioHost.playUrl(url, loop, area, volume);
|
||||
}
|
||||
|
||||
if (!url && !action) {
|
||||
const cur = AudioHost.getCurrentUrl(area);
|
||||
return cur ? `当前播放(${area}): ${cur}` : "未在播放。用法: /xbaudio [play=on] [mode=loop] [area=primary/secondary] [volume=5] URL | /xbaudio list | /xbaudio play=off (未指定 area 将关闭全部)";
|
||||
}
|
||||
|
||||
return "用法: /xbaudio play=off | /xbaudio play=off area=primary/secondary | /xbaudio play=clear | /xbaudio play=clear area=primary/secondary | /xbaudio [play=on] [mode=loop/once] [area=primary/secondary] [volume=1-10] URL | /xbaudio list (默认: play=on mode=loop area=primary volume=5;未指定 area 的 play=off 关闭全部;未指定 area 的 play=clear 清除全部)";
|
||||
} catch (e) {
|
||||
return `错误: ${e.message || e}`;
|
||||
}
|
||||
},
|
||||
namedArgumentList: [
|
||||
SlashCommandNamedArgument.fromProps({ name: "play", description: "on/off/clear 或留空以默认播放", typeList: [ARGUMENT_TYPE.STRING], enumList: ["on", "off", "clear"] }),
|
||||
SlashCommandNamedArgument.fromProps({ name: "mode", description: "once/loop", typeList: [ARGUMENT_TYPE.STRING], enumList: ["once", "loop"] }),
|
||||
SlashCommandNamedArgument.fromProps({ name: "area", description: "primary/secondary (play=off 未指定 area 关闭全部)", typeList: [ARGUMENT_TYPE.STRING], enumList: ["primary", "secondary"] }),
|
||||
SlashCommandNamedArgument.fromProps({ name: "volume", description: "音量 1-10(默认 5)", typeList: [ARGUMENT_TYPE.NUMBER] }),
|
||||
],
|
||||
unnamedArgumentList: [
|
||||
SlashCommandArgument.fromProps({ description: "音频URL (http/https) 或 list", typeList: [ARGUMENT_TYPE.STRING] }),
|
||||
],
|
||||
helpString: "播放网络音频。示例: /xbaudio https://files.catbox.moe/0ryoa5.mp3 (默认: play=on mode=loop area=primary volume=5) | /xbaudio area=secondary volume=8 https://files.catbox.moe/0ryoa5.mp3 | /xbaudio list | /xbaudio play=off (未指定 area 关闭全部) | /xbaudio play=off area=primary | /xbaudio play=clear (未指定 area 清除全部)",
|
||||
});
|
||||
SlashCommandParser.addCommandObject(registeredCommand);
|
||||
if (event_types?.CHAT_CHANGED) {
|
||||
chatChangedHandler = () => { try { AudioHost.reset(); } catch { } };
|
||||
eventSource.on(event_types.CHAT_CHANGED, chatChangedHandler);
|
||||
}
|
||||
isRegistered = true;
|
||||
} catch (e) {
|
||||
console.error("[LittleWhiteBox][audio] 注册斜杠命令失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
function unregisterSlash() {
|
||||
if (!isRegistered) return;
|
||||
try {
|
||||
if (chatChangedHandler && event_types?.CHAT_CHANGED) {
|
||||
try { eventSource.removeListener(event_types.CHAT_CHANGED, chatChangedHandler); } catch { }
|
||||
}
|
||||
chatChangedHandler = null;
|
||||
try {
|
||||
const map = SlashCommandParser.commands || {};
|
||||
Object.keys(map).forEach((k) => { if (map[k] === registeredCommand) delete map[k]; });
|
||||
} catch { }
|
||||
} finally {
|
||||
registeredCommand = null;
|
||||
isRegistered = false;
|
||||
}
|
||||
}
|
||||
|
||||
function enableFeature() {
|
||||
registerSlash();
|
||||
}
|
||||
|
||||
function disableFeature() {
|
||||
try { AudioHost.reset(); } catch { }
|
||||
unregisterSlash();
|
||||
}
|
||||
|
||||
export function initControlAudio() {
|
||||
try {
|
||||
try {
|
||||
const enabled = !!(extension_settings?.LittleWhiteBox?.audio?.enabled ?? true);
|
||||
if (enabled) enableFeature(); else disableFeature();
|
||||
} catch { enableFeature(); }
|
||||
|
||||
const bind = () => {
|
||||
const cb = document.getElementById('xiaobaix_audio_enabled');
|
||||
if (!cb) { setTimeout(bind, 200); return; }
|
||||
const applyState = () => {
|
||||
const input = /** @type {HTMLInputElement} */(cb);
|
||||
const enabled = !!(input && input.checked);
|
||||
if (enabled) enableFeature(); else disableFeature();
|
||||
};
|
||||
cb.addEventListener('change', applyState);
|
||||
applyState();
|
||||
};
|
||||
bind();
|
||||
|
||||
// 监听扩展全局开关,关闭时强制停止并清理两个实例
|
||||
try {
|
||||
if (!globalStateChangedHandler) {
|
||||
globalStateChangedHandler = (e) => {
|
||||
try {
|
||||
const enabled = !!(e && e.detail && e.detail.enabled);
|
||||
if (!enabled) {
|
||||
try { AudioHost.reset(); } catch { }
|
||||
unregisterSlash();
|
||||
} else {
|
||||
// 重新根据子开关状态应用
|
||||
const audioEnabled = !!(extension_settings?.LittleWhiteBox?.audio?.enabled ?? true);
|
||||
if (audioEnabled) enableFeature(); else disableFeature();
|
||||
}
|
||||
} catch { }
|
||||
};
|
||||
document.addEventListener('xiaobaixEnabledChanged', globalStateChangedHandler);
|
||||
}
|
||||
} catch { }
|
||||
} catch (e) {
|
||||
console.error("[LittleWhiteBox][audio] 初始化失败", e);
|
||||
}
|
||||
}
|
||||
765
modules/debug-panel/debug-panel.html
Normal file
765
modules/debug-panel/debug-panel.html
Normal file
@@ -0,0 +1,765 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>LittleWhiteBox 监控台</title>
|
||||
<style>
|
||||
:root {
|
||||
--border: rgba(255,255,255,0.10);
|
||||
--text: rgba(255,255,255,0.92);
|
||||
--muted: rgba(255,255,255,0.65);
|
||||
--info: #bdbdbd;
|
||||
--warn: #ffcc66;
|
||||
--error: #ff6b6b;
|
||||
--accent: #7aa2ff;
|
||||
--success: #4ade80;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
|
||||
font-size: 12px;
|
||||
}
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
}
|
||||
.root {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.topbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: rgba(18,18,18,0.65);
|
||||
backdrop-filter: blur(6px);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.tab {
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(255,255,255,0.04);
|
||||
user-select: none;
|
||||
}
|
||||
.tab.active {
|
||||
border-color: rgba(122,162,255,0.55);
|
||||
background: rgba(122,162,255,0.10);
|
||||
}
|
||||
button {
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(255,255,255,0.05);
|
||||
color: var(--text);
|
||||
border-radius: 8px;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
button:hover {
|
||||
background: rgba(255,255,255,0.09);
|
||||
}
|
||||
select {
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(0,0,0,0.25);
|
||||
color: var(--text);
|
||||
border-radius: 8px;
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.card {
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(0,0,0,0.18);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.muted { color: var(--muted); }
|
||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
|
||||
.empty-hint { padding: 20px; text-align: center; color: var(--muted); }
|
||||
|
||||
/* 日志 */
|
||||
.log-item {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
}
|
||||
.log-item:last-child { border-bottom: none; }
|
||||
.log-header {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.log-toggle {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 4px;
|
||||
user-select: none;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.log-toggle:hover { background: rgba(255,255,255,0.1); }
|
||||
.log-toggle.empty { visibility: hidden; cursor: default; }
|
||||
.log-item.open .log-toggle { transform: rotate(90deg); }
|
||||
.time { color: var(--muted); }
|
||||
.lvl { font-weight: 700; }
|
||||
.lvl.info { color: var(--info); }
|
||||
.lvl.warn { color: var(--warn); }
|
||||
.lvl.error { color: var(--error); }
|
||||
.mod { color: var(--accent); }
|
||||
.msg { color: var(--text); word-break: break-word; }
|
||||
.stack {
|
||||
margin: 8px 0 0 24px;
|
||||
padding: 10px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
color: rgba(255,255,255,0.85);
|
||||
background: rgba(0,0,0,0.3);
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
display: none;
|
||||
}
|
||||
.log-item.open .stack { display: block; }
|
||||
|
||||
/* 事件 */
|
||||
.section-collapse { margin-bottom: 12px; }
|
||||
.section-collapse-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 1px solid var(--border);
|
||||
user-select: none;
|
||||
}
|
||||
.section-collapse-header:hover { background: rgba(255,255,255,0.06); }
|
||||
.section-collapse-header .arrow {
|
||||
transition: transform 0.2s;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
.section-collapse-header.open .arrow { transform: rotate(90deg); }
|
||||
.section-collapse-header .title { flex: 1; }
|
||||
.section-collapse-header .count { color: var(--muted); font-size: 11px; }
|
||||
.section-collapse-content {
|
||||
display: none;
|
||||
padding: 8px 0 0 0;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.section-collapse-header.open + .section-collapse-content { display: block; }
|
||||
.module-section { margin-bottom: 8px; }
|
||||
.module-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
background: rgba(255,255,255,0.02);
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
user-select: none;
|
||||
}
|
||||
.module-header:hover { background: rgba(255,255,255,0.05); }
|
||||
.module-header .arrow { transition: transform 0.2s; color: var(--muted); font-size: 9px; }
|
||||
.module-header.open .arrow { transform: rotate(90deg); }
|
||||
.module-header .name { color: var(--accent); font-weight: 600; }
|
||||
.module-header .count { color: var(--muted); }
|
||||
.module-events { display: none; padding: 6px 10px 6px 28px; }
|
||||
.module-header.open + .module-events { display: block; }
|
||||
.event-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
margin: 2px 4px 2px 0;
|
||||
border-radius: 6px;
|
||||
background: rgba(255,255,255,0.05);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
font-size: 11px;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
.event-tag .dup { color: var(--error); font-weight: 700; margin-left: 4px; }
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(255,255,255,0.05);
|
||||
}
|
||||
.repeat-badge {
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,0.12);
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
margin-left: 6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 缓存 */
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { padding: 8px 10px; border-bottom: 1px solid rgba(255,255,255,0.06); text-align: left; }
|
||||
th { color: rgba(255,255,255,0.75); font-weight: 600; }
|
||||
.right { text-align: right; }
|
||||
.cache-detail-row { display: none; }
|
||||
.cache-detail-row.open { display: table-row; }
|
||||
.cache-detail-row td { padding: 0; }
|
||||
.pre {
|
||||
padding: 10px;
|
||||
white-space: pre-wrap;
|
||||
font-family: ui-monospace, monospace;
|
||||
color: rgba(255,255,255,0.80);
|
||||
background: rgba(0,0,0,0.25);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* 性能 */
|
||||
.perf-overview {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: rgba(0,0,0,0.18);
|
||||
}
|
||||
.perf-stat { display: flex; flex-direction: column; gap: 2px; }
|
||||
.perf-stat .label { font-size: 10px; color: var(--muted); text-transform: uppercase; }
|
||||
.perf-stat .value { font-size: 16px; font-weight: 700; }
|
||||
.perf-stat .value.good { color: var(--success); }
|
||||
.perf-stat .value.warn { color: var(--warn); }
|
||||
.perf-stat .value.bad { color: var(--error); }
|
||||
.perf-section { margin-bottom: 16px; }
|
||||
.perf-section-title {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.perf-item { padding: 8px 10px; border-bottom: 1px solid rgba(255,255,255,0.06); }
|
||||
.perf-item:last-child { border-bottom: none; }
|
||||
.perf-item .top { display: flex; gap: 8px; align-items: baseline; }
|
||||
.perf-item .url { flex: 1; font-family: ui-monospace, monospace; font-size: 11px; word-break: break-all; }
|
||||
.perf-item .duration { font-weight: 700; }
|
||||
.perf-item .duration.slow { color: var(--warn); }
|
||||
.perf-item .duration.very-slow { color: var(--error); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="root">
|
||||
<div class="topbar">
|
||||
<div class="tabs">
|
||||
<div class="tab active" data-tab="logs">日志</div>
|
||||
<div class="tab" data-tab="events">事件</div>
|
||||
<div class="tab" data-tab="caches">缓存</div>
|
||||
<div class="tab" data-tab="performance">性能</div>
|
||||
</div>
|
||||
<button id="btn-refresh" type="button">刷新</button>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<section id="tab-logs">
|
||||
<div class="row">
|
||||
<span class="muted">过滤</span>
|
||||
<select id="log-level"><option value="all">全部</option><option value="info">INFO</option><option value="warn">WARN</option><option value="error">ERROR</option></select>
|
||||
<span class="muted">模块</span>
|
||||
<select id="log-module"><option value="all">全部</option></select>
|
||||
<button id="btn-clear-logs" type="button">清空</button>
|
||||
<span class="muted" id="log-count"></span>
|
||||
</div>
|
||||
<div class="card" id="log-list"></div>
|
||||
</section>
|
||||
|
||||
<section id="tab-events" style="display:none">
|
||||
<div class="section-collapse">
|
||||
<div class="section-collapse-header" id="module-section-header">
|
||||
<span class="arrow">▶</span>
|
||||
<span class="title">模块事件监听</span>
|
||||
<span class="count" id="module-count"></span>
|
||||
</div>
|
||||
<div class="section-collapse-content" id="module-list"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="muted">触发历史</span>
|
||||
<button id="btn-clear-events" type="button">清空历史</button>
|
||||
</div>
|
||||
<div class="card" id="event-list"></div>
|
||||
</section>
|
||||
|
||||
<section id="tab-caches" style="display:none">
|
||||
<div class="row">
|
||||
<button id="btn-clear-all-caches" type="button">清理全部</button>
|
||||
<span class="muted" id="cache-count"></span>
|
||||
</div>
|
||||
<div class="card" id="cache-card">
|
||||
<table>
|
||||
<thead><tr><th>缓存项目</th><th>条数</th><th>大小</th><th class="right">操作</th></tr></thead>
|
||||
<tbody id="cache-tbody"></tbody>
|
||||
</table>
|
||||
<div id="cache-empty" class="empty-hint" style="display:none;">暂无缓存注册</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="tab-performance" style="display:none">
|
||||
<div class="perf-overview">
|
||||
<div class="perf-stat"><span class="label">FPS</span><span class="value" id="perf-fps">--</span></div>
|
||||
<div class="perf-stat" id="perf-memory-stat"><span class="label">内存</span><span class="value" id="perf-memory">--</span></div>
|
||||
<div class="perf-stat"><span class="label">DOM</span><span class="value" id="perf-dom">--</span></div>
|
||||
<div class="perf-stat"><span class="label">消息</span><span class="value" id="perf-messages">--</span></div>
|
||||
<div class="perf-stat"><span class="label">图片</span><span class="value" id="perf-images">--</span></div>
|
||||
</div>
|
||||
<div class="perf-section">
|
||||
<div class="perf-section-title"><span>慢请求 (≥500ms)</span><button id="btn-clear-requests" type="button">清空</button></div>
|
||||
<div class="card" id="perf-requests"></div>
|
||||
</div>
|
||||
<div class="perf-section">
|
||||
<div class="perf-section-title"><span>长任务</span><button id="btn-clear-tasks" type="button">清空</button></div>
|
||||
<div class="card" id="perf-tasks"></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
const post = (payload) => {
|
||||
try { parent.postMessage({ source: 'LittleWhiteBox-DebugFrame', ...payload }, '*'); } catch {}
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 状态
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
const state = {
|
||||
logs: [],
|
||||
events: [],
|
||||
eventStatsDetail: {},
|
||||
caches: [],
|
||||
performance: {},
|
||||
openCacheDetail: null,
|
||||
cacheDetails: {},
|
||||
openModules: new Set(),
|
||||
openLogIds: new Set(),
|
||||
pendingData: null,
|
||||
mouseDown: false,
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 用户交互检测 - 核心:交互时不刷新
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
document.addEventListener('mousedown', () => { state.mouseDown = true; });
|
||||
document.addEventListener('mouseup', () => {
|
||||
state.mouseDown = false;
|
||||
// 鼠标抬起后,如果有待处理数据,延迟一点再应用(让用户完成选择)
|
||||
if (state.pendingData) {
|
||||
setTimeout(() => {
|
||||
if (!isUserInteracting() && state.pendingData) {
|
||||
applyData(state.pendingData);
|
||||
state.pendingData = null;
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
});
|
||||
|
||||
function isUserInteracting() {
|
||||
// 1. 鼠标按下中
|
||||
if (state.mouseDown) return true;
|
||||
// 2. 有文字被选中
|
||||
const sel = document.getSelection();
|
||||
if (sel && sel.toString().length > 0) return true;
|
||||
// 3. 焦点在输入元素上
|
||||
const active = document.activeElement;
|
||||
if (active && (active.tagName === 'INPUT' || active.tagName === 'SELECT' || active.tagName === 'TEXTAREA')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 工具函数
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
const fmtTime = (ts) => {
|
||||
try {
|
||||
const d = new Date(Number(ts) || Date.now());
|
||||
return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}`;
|
||||
} catch { return '--:--:--'; }
|
||||
};
|
||||
|
||||
const fmtBytes = (n) => {
|
||||
const v = Number(n);
|
||||
if (!Number.isFinite(v) || v <= 0) return '-';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let idx = 0, x = v;
|
||||
while (x >= 1024 && idx < units.length - 1) { x /= 1024; idx++; }
|
||||
return `${x.toFixed(idx === 0 ? 0 : 1)} ${units[idx]}`;
|
||||
};
|
||||
|
||||
const fmtMB = (bytes) => Number.isFinite(bytes) && bytes > 0 ? (bytes / 1048576).toFixed(0) + 'MB' : '--';
|
||||
const escapeHtml = (s) => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 日志渲染
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
function getLogFilters() {
|
||||
return {
|
||||
level: document.getElementById('log-level').value,
|
||||
module: document.getElementById('log-module').value
|
||||
};
|
||||
}
|
||||
|
||||
function filteredLogs() {
|
||||
const f = getLogFilters();
|
||||
return (state.logs || []).filter(l => {
|
||||
if (!l) return false;
|
||||
if (f.level !== 'all' && l.level !== f.level) return false;
|
||||
if (f.module !== 'all' && String(l.module) !== f.module) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function renderLogModuleOptions() {
|
||||
const sel = document.getElementById('log-module');
|
||||
const current = sel.value || 'all';
|
||||
const mods = [...new Set((state.logs || []).map(l => l?.module).filter(Boolean))].sort();
|
||||
sel.innerHTML = `<option value="all">全部</option>` + mods.map(m => `<option value="${escapeHtml(m)}">${escapeHtml(m)}</option>`).join('');
|
||||
if ([...sel.options].some(o => o.value === current)) sel.value = current;
|
||||
}
|
||||
|
||||
function renderLogs() {
|
||||
renderLogModuleOptions();
|
||||
const logs = filteredLogs();
|
||||
document.getElementById('log-count').textContent = `共 ${logs.length} 条`;
|
||||
const list = document.getElementById('log-list');
|
||||
|
||||
if (!logs.length) {
|
||||
list.innerHTML = `<div class="empty-hint">暂无日志</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// 清理已不存在的ID
|
||||
const currentIds = new Set(logs.map(l => l.id));
|
||||
for (const id of state.openLogIds) {
|
||||
if (!currentIds.has(id)) state.openLogIds.delete(id);
|
||||
}
|
||||
|
||||
list.innerHTML = logs.map(l => {
|
||||
const lvl = escapeHtml(l.level || 'info');
|
||||
const mod = escapeHtml(l.module || 'unknown');
|
||||
const msg = escapeHtml(l.message || '');
|
||||
const stack = l.stack ? escapeHtml(String(l.stack)) : '';
|
||||
const hasStack = !!stack;
|
||||
const isOpen = state.openLogIds.has(l.id);
|
||||
|
||||
return `<div class="log-item${isOpen ? ' open' : ''}" data-id="${l.id}">
|
||||
<div class="log-header">
|
||||
<span class="log-toggle${hasStack ? '' : ' empty'}" data-id="${l.id}">▶</span>
|
||||
<span class="time">${fmtTime(l.timestamp)}</span>
|
||||
<span class="lvl ${lvl}">[${lvl.toUpperCase()}]</span>
|
||||
<span class="mod">${mod}</span>
|
||||
<span class="msg">${msg}</span>
|
||||
</div>
|
||||
${hasStack ? `<div class="stack">${stack}</div>` : ''}
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
// 绑定展开事件
|
||||
list.querySelectorAll('.log-toggle:not(.empty)').forEach(toggle => {
|
||||
toggle.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const id = Number(toggle.getAttribute('data-id'));
|
||||
const item = toggle.closest('.log-item');
|
||||
if (state.openLogIds.has(id)) {
|
||||
state.openLogIds.delete(id);
|
||||
item.classList.remove('open');
|
||||
} else {
|
||||
state.openLogIds.add(id);
|
||||
item.classList.add('open');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 事件渲染
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
function renderModuleList() {
|
||||
const detail = state.eventStatsDetail || {};
|
||||
const modules = Object.keys(detail).sort();
|
||||
const container = document.getElementById('module-list');
|
||||
const countEl = document.getElementById('module-count');
|
||||
const totalListeners = Object.values(detail).reduce((sum, m) => sum + (m.total || 0), 0);
|
||||
if (countEl) countEl.textContent = `${modules.length} 模块 · ${totalListeners} 监听器`;
|
||||
|
||||
if (!modules.length) {
|
||||
container.innerHTML = `<div class="muted" style="padding:10px;">暂无模块监听</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = modules.map(mod => {
|
||||
const info = detail[mod] || {};
|
||||
const events = info.events || {};
|
||||
const isOpen = state.openModules.has(mod);
|
||||
const eventTags = Object.keys(events).sort().map(ev => {
|
||||
const cnt = events[ev];
|
||||
return `<span class="event-tag">${escapeHtml(ev)}${cnt > 1 ? `<span class="dup">×${cnt}</span>` : ''}</span>`;
|
||||
}).join('');
|
||||
return `<div class="module-section">
|
||||
<div class="module-header${isOpen ? ' open' : ''}" data-mod="${escapeHtml(mod)}">
|
||||
<span class="arrow">▶</span>
|
||||
<span class="name">${escapeHtml(mod)}</span>
|
||||
<span class="count">(${info.total || 0})</span>
|
||||
</div>
|
||||
<div class="module-events">${eventTags || '<span class="muted">无事件</span>'}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
container.querySelectorAll('.module-header').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
const mod = el.getAttribute('data-mod');
|
||||
state.openModules.has(mod) ? state.openModules.delete(mod) : state.openModules.add(mod);
|
||||
renderModuleList();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderEvents() {
|
||||
renderModuleList();
|
||||
const list = document.getElementById('event-list');
|
||||
const events = state.events || [];
|
||||
if (!events.length) {
|
||||
list.innerHTML = `<div class="empty-hint">暂无事件历史</div>`;
|
||||
return;
|
||||
}
|
||||
list.innerHTML = events.slice().reverse().map(e => {
|
||||
const repeat = (e.repeatCount || 1) > 1 ? `<span class="repeat-badge">×${e.repeatCount}</span>` : '';
|
||||
return `<div class="log-item"><div class="log-header">
|
||||
<span class="time">${fmtTime(e.timestamp)}</span>
|
||||
<span class="pill mono">${escapeHtml(e.type || 'CUSTOM')}</span>
|
||||
<span class="mod">${escapeHtml(e.eventName || '')}</span>
|
||||
${repeat}
|
||||
<span class="msg">${escapeHtml(e.dataSummary || '')}</span>
|
||||
</div></div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 缓存渲染
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
function renderCaches() {
|
||||
const caches = state.caches || [];
|
||||
document.getElementById('cache-count').textContent = `共 ${caches.length} 项`;
|
||||
const tbody = document.getElementById('cache-tbody');
|
||||
const emptyHint = document.getElementById('cache-empty');
|
||||
const table = tbody.closest('table');
|
||||
|
||||
if (!caches.length) {
|
||||
table.style.display = 'none';
|
||||
emptyHint.style.display = '';
|
||||
return;
|
||||
}
|
||||
table.style.display = '';
|
||||
emptyHint.style.display = 'none';
|
||||
|
||||
let html = '';
|
||||
for (const c of caches) {
|
||||
const mid = escapeHtml(c.moduleId);
|
||||
const isOpen = state.openCacheDetail === c.moduleId;
|
||||
const detailBtn = c.hasDetail ? `<button data-act="detail" data-mid="${mid}">${isOpen ? '收起' : '详情'}</button>` : '';
|
||||
html += `<tr>
|
||||
<td>${escapeHtml(c.name || c.moduleId)}<div class="muted mono">${mid}</div></td>
|
||||
<td>${c.size == null ? '-' : c.size}</td>
|
||||
<td>${fmtBytes(c.bytes)}</td>
|
||||
<td class="right">${detailBtn}<button data-act="clear" data-mid="${mid}">清理</button></td>
|
||||
</tr>`;
|
||||
if (isOpen && state.cacheDetails[c.moduleId] !== undefined) {
|
||||
html += `<tr class="cache-detail-row open"><td colspan="4"><div class="pre">${escapeHtml(JSON.stringify(state.cacheDetails[c.moduleId], null, 2))}</div></td></tr>`;
|
||||
}
|
||||
}
|
||||
tbody.innerHTML = html;
|
||||
|
||||
tbody.querySelectorAll('button[data-act]').forEach(btn => {
|
||||
btn.addEventListener('click', e => {
|
||||
const act = btn.getAttribute('data-act');
|
||||
const mid = btn.getAttribute('data-mid');
|
||||
if (act === 'clear') {
|
||||
if (confirm(`确定清理缓存:${mid}?`)) post({ type: 'XB_DEBUG_ACTION', action: 'clearCache', moduleId: mid });
|
||||
} else if (act === 'detail') {
|
||||
state.openCacheDetail = state.openCacheDetail === mid ? null : mid;
|
||||
if (state.openCacheDetail) post({ type: 'XB_DEBUG_ACTION', action: 'cacheDetail', moduleId: mid });
|
||||
else renderCaches();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 性能渲染
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
function renderPerformance() {
|
||||
const perf = state.performance || {};
|
||||
|
||||
const fps = perf.fps || 0;
|
||||
const fpsEl = document.getElementById('perf-fps');
|
||||
fpsEl.textContent = fps > 0 ? fps : '--';
|
||||
fpsEl.className = 'value' + (fps >= 50 ? ' good' : fps >= 30 ? ' warn' : fps > 0 ? ' bad' : '');
|
||||
|
||||
const memEl = document.getElementById('perf-memory');
|
||||
const memStat = document.getElementById('perf-memory-stat');
|
||||
if (perf.memory) {
|
||||
const pct = perf.memory.total > 0 ? (perf.memory.used / perf.memory.total * 100) : 0;
|
||||
memEl.textContent = fmtMB(perf.memory.used);
|
||||
memEl.className = 'value' + (pct < 60 ? ' good' : pct < 80 ? ' warn' : ' bad');
|
||||
memStat.style.display = '';
|
||||
} else {
|
||||
memStat.style.display = 'none';
|
||||
}
|
||||
|
||||
const dom = perf.domCount || 0;
|
||||
const domEl = document.getElementById('perf-dom');
|
||||
domEl.textContent = dom.toLocaleString();
|
||||
domEl.className = 'value' + (dom < 3000 ? ' good' : dom < 6000 ? ' warn' : ' bad');
|
||||
|
||||
const msg = perf.messageCount || 0;
|
||||
document.getElementById('perf-messages').textContent = msg;
|
||||
document.getElementById('perf-messages').className = 'value' + (msg < 100 ? ' good' : msg < 300 ? ' warn' : ' bad');
|
||||
|
||||
const img = perf.imageCount || 0;
|
||||
document.getElementById('perf-images').textContent = img;
|
||||
document.getElementById('perf-images').className = 'value' + (img < 50 ? ' good' : img < 100 ? ' warn' : ' bad');
|
||||
|
||||
const reqContainer = document.getElementById('perf-requests');
|
||||
const requests = perf.requests || [];
|
||||
reqContainer.innerHTML = requests.length ? requests.slice().reverse().map(r => {
|
||||
const durClass = r.duration >= 2000 ? 'very-slow' : r.duration >= 1000 ? 'slow' : '';
|
||||
return `<div class="perf-item"><div class="top"><span class="time">${fmtTime(r.timestamp)}</span><span class="pill mono">${escapeHtml(r.method)}</span><span class="url">${escapeHtml(r.url)}</span><span class="duration ${durClass}">${r.duration}ms</span></div></div>`;
|
||||
}).join('') : `<div class="empty-hint">暂无慢请求</div>`;
|
||||
|
||||
const taskContainer = document.getElementById('perf-tasks');
|
||||
const tasks = perf.longTasks || [];
|
||||
taskContainer.innerHTML = tasks.length ? tasks.slice().reverse().map(t => {
|
||||
const durClass = t.duration >= 200 ? 'very-slow' : t.duration >= 100 ? 'slow' : '';
|
||||
return `<div class="perf-item"><div class="top"><span class="time">${fmtTime(t.timestamp)}</span><span class="duration ${durClass}">${t.duration}ms</span><span class="muted">${escapeHtml(t.source || '未知')}</span></div></div>`;
|
||||
}).join('') : `<div class="empty-hint">暂无长任务</div>`;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Tab 切换
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
function switchTab(tab) {
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
|
||||
['logs', 'events', 'caches', 'performance'].forEach(name => {
|
||||
document.getElementById(`tab-${name}`).style.display = name === tab ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 数据应用
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
function applyData(payload) {
|
||||
state.logs = payload?.logs || [];
|
||||
state.events = payload?.events || [];
|
||||
state.eventStatsDetail = payload?.eventStatsDetail || {};
|
||||
state.caches = payload?.caches || [];
|
||||
state.performance = payload?.performance || {};
|
||||
renderLogs();
|
||||
renderEvents();
|
||||
renderCaches();
|
||||
renderPerformance();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 事件绑定
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
document.getElementById('btn-refresh').addEventListener('click', () => post({ type: 'XB_DEBUG_ACTION', action: 'refresh' }));
|
||||
document.getElementById('btn-clear-logs').addEventListener('click', () => {
|
||||
if (confirm('确定清空日志?')) {
|
||||
state.openLogIds.clear();
|
||||
post({ type: 'XB_DEBUG_ACTION', action: 'clearLogs' });
|
||||
}
|
||||
});
|
||||
document.getElementById('btn-clear-events').addEventListener('click', () => {
|
||||
if (confirm('确定清空事件历史?')) post({ type: 'XB_DEBUG_ACTION', action: 'clearEvents' });
|
||||
});
|
||||
document.getElementById('btn-clear-all-caches').addEventListener('click', () => {
|
||||
if (confirm('确定清理全部缓存?')) post({ type: 'XB_DEBUG_ACTION', action: 'clearAllCaches' });
|
||||
});
|
||||
document.getElementById('btn-clear-requests').addEventListener('click', () => post({ type: 'XB_DEBUG_ACTION', action: 'clearRequests' }));
|
||||
document.getElementById('btn-clear-tasks').addEventListener('click', () => post({ type: 'XB_DEBUG_ACTION', action: 'clearTasks' }));
|
||||
document.getElementById('log-level').addEventListener('change', renderLogs);
|
||||
document.getElementById('log-module').addEventListener('change', renderLogs);
|
||||
document.querySelectorAll('.tab').forEach(t => t.addEventListener('click', () => switchTab(t.dataset.tab)));
|
||||
document.getElementById('module-section-header')?.addEventListener('click', function() { this.classList.toggle('open'); });
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 消息监听
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
const msg = event?.data;
|
||||
if (!msg || msg.source !== 'LittleWhiteBox-DebugHost') return;
|
||||
|
||||
if (msg.type === 'XB_DEBUG_DATA') {
|
||||
// 核心逻辑:用户交互时暂存数据,不刷新DOM
|
||||
if (isUserInteracting()) {
|
||||
state.pendingData = msg.payload;
|
||||
} else {
|
||||
applyData(msg.payload);
|
||||
state.pendingData = null;
|
||||
}
|
||||
}
|
||||
if (msg.type === 'XB_DEBUG_CACHE_DETAIL') {
|
||||
const mid = msg.payload?.moduleId;
|
||||
if (mid) {
|
||||
state.cacheDetails[mid] = msg.payload?.detail;
|
||||
renderCaches();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
post({ type: 'FRAME_READY' });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
743
modules/debug-panel/debug-panel.js
Normal file
743
modules/debug-panel/debug-panel.js
Normal file
@@ -0,0 +1,743 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 导入和常量
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
import { extensionFolderPath } from "../../core/constants.js";
|
||||
|
||||
const STORAGE_EXPANDED_KEY = "xiaobaix_debug_panel_pos_v2";
|
||||
const STORAGE_MINI_KEY = "xiaobaix_debug_panel_minipos_v2";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 状态变量
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
let isOpen = false;
|
||||
let isExpanded = false;
|
||||
let panelEl = null;
|
||||
let miniBtnEl = null;
|
||||
let iframeEl = null;
|
||||
let dragState = null;
|
||||
let pollTimer = null;
|
||||
let lastLogId = 0;
|
||||
let frameReady = false;
|
||||
let messageListenerBound = false;
|
||||
let resizeHandler = null;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 性能监控状态
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
let perfMonitorActive = false;
|
||||
let originalFetch = null;
|
||||
let longTaskObserver = null;
|
||||
let fpsFrameId = null;
|
||||
let lastFrameTime = 0;
|
||||
let frameCount = 0;
|
||||
let currentFps = 0;
|
||||
|
||||
const requestLog = [];
|
||||
const longTaskLog = [];
|
||||
const MAX_PERF_LOG = 50;
|
||||
const SLOW_REQUEST_THRESHOLD = 500;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 工具函数
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const clamp = (n, min, max) => Math.max(min, Math.min(max, n));
|
||||
const isMobile = () => window.innerWidth <= 768;
|
||||
const countErrors = (logs) => (logs || []).filter(l => l?.level === "error").length;
|
||||
const maxLogId = (logs) => (logs || []).reduce((m, l) => Math.max(m, Number(l?.id) || 0), 0);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 存储
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function readJSON(key) {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function writeJSON(key, data) {
|
||||
try { localStorage.setItem(key, JSON.stringify(data)); } catch {}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 页面统计
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function getPageStats() {
|
||||
try {
|
||||
return {
|
||||
domCount: document.querySelectorAll('*').length,
|
||||
messageCount: document.querySelectorAll('.mes').length,
|
||||
imageCount: document.querySelectorAll('img').length
|
||||
};
|
||||
} catch {
|
||||
return { domCount: 0, messageCount: 0, imageCount: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 性能监控:Fetch 拦截
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function startFetchInterceptor() {
|
||||
if (originalFetch) return;
|
||||
originalFetch = window.fetch;
|
||||
window.fetch = async function(input, init) {
|
||||
const url = typeof input === 'string' ? input : input?.url || '';
|
||||
const method = init?.method || 'GET';
|
||||
const startTime = performance.now();
|
||||
const timestamp = Date.now();
|
||||
try {
|
||||
const response = await originalFetch.apply(this, arguments);
|
||||
const duration = performance.now() - startTime;
|
||||
if (url.includes('/api/') && duration >= SLOW_REQUEST_THRESHOLD) {
|
||||
requestLog.push({ url, method, duration: Math.round(duration), timestamp, status: response.status });
|
||||
if (requestLog.length > MAX_PERF_LOG) requestLog.shift();
|
||||
}
|
||||
return response;
|
||||
} catch (err) {
|
||||
const duration = performance.now() - startTime;
|
||||
requestLog.push({ url, method, duration: Math.round(duration), timestamp, status: 'error' });
|
||||
if (requestLog.length > MAX_PERF_LOG) requestLog.shift();
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function stopFetchInterceptor() {
|
||||
if (originalFetch) {
|
||||
window.fetch = originalFetch;
|
||||
originalFetch = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 性能监控:长任务检测
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function startLongTaskObserver() {
|
||||
if (longTaskObserver) return;
|
||||
try {
|
||||
if (typeof PerformanceObserver === 'undefined') return;
|
||||
longTaskObserver = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
if (entry.duration >= 200) {
|
||||
let source = '主页面';
|
||||
try {
|
||||
const attr = entry.attribution?.[0];
|
||||
if (attr) {
|
||||
if (attr.containerType === 'iframe') {
|
||||
source = 'iframe';
|
||||
if (attr.containerSrc) {
|
||||
const url = new URL(attr.containerSrc, location.href);
|
||||
source += `: ${url.pathname.split('/').pop() || url.pathname}`;
|
||||
}
|
||||
} else if (attr.containerName) {
|
||||
source = attr.containerName;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
longTaskLog.push({
|
||||
duration: Math.round(entry.duration),
|
||||
timestamp: Date.now(),
|
||||
source
|
||||
});
|
||||
if (longTaskLog.length > MAX_PERF_LOG) longTaskLog.shift();
|
||||
}
|
||||
}
|
||||
});
|
||||
longTaskObserver.observe({ entryTypes: ['longtask'] });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function stopLongTaskObserver() {
|
||||
if (longTaskObserver) {
|
||||
try { longTaskObserver.disconnect(); } catch {}
|
||||
longTaskObserver = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 性能监控:FPS 计算
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function startFpsMonitor() {
|
||||
if (fpsFrameId) return;
|
||||
lastFrameTime = performance.now();
|
||||
frameCount = 0;
|
||||
const loop = (now) => {
|
||||
frameCount++;
|
||||
if (now - lastFrameTime >= 1000) {
|
||||
currentFps = frameCount;
|
||||
frameCount = 0;
|
||||
lastFrameTime = now;
|
||||
}
|
||||
fpsFrameId = requestAnimationFrame(loop);
|
||||
};
|
||||
fpsFrameId = requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
function stopFpsMonitor() {
|
||||
if (fpsFrameId) {
|
||||
cancelAnimationFrame(fpsFrameId);
|
||||
fpsFrameId = null;
|
||||
}
|
||||
currentFps = 0;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 性能监控:内存
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function getMemoryInfo() {
|
||||
if (typeof performance === 'undefined' || !performance.memory) return null;
|
||||
const mem = performance.memory;
|
||||
return {
|
||||
used: mem.usedJSHeapSize,
|
||||
total: mem.totalJSHeapSize,
|
||||
limit: mem.jsHeapSizeLimit
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 性能监控:生命周期
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function startPerfMonitor() {
|
||||
if (perfMonitorActive) return;
|
||||
perfMonitorActive = true;
|
||||
startFetchInterceptor();
|
||||
startLongTaskObserver();
|
||||
startFpsMonitor();
|
||||
}
|
||||
|
||||
function stopPerfMonitor() {
|
||||
if (!perfMonitorActive) return;
|
||||
perfMonitorActive = false;
|
||||
stopFetchInterceptor();
|
||||
stopLongTaskObserver();
|
||||
stopFpsMonitor();
|
||||
requestLog.length = 0;
|
||||
longTaskLog.length = 0;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 样式注入
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function ensureStyle() {
|
||||
if (document.getElementById("xiaobaix-debug-style")) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = "xiaobaix-debug-style";
|
||||
style.textContent = `
|
||||
#xiaobaix-debug-btn {
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
gap: 6px !important;
|
||||
}
|
||||
#xiaobaix-debug-btn .dbg-light {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #555;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
#xiaobaix-debug-btn .dbg-light.on {
|
||||
background: #4ade80;
|
||||
box-shadow: 0 0 6px #4ade80;
|
||||
}
|
||||
#xiaobaix-debug-mini {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: rgba(28, 28, 32, 0.96);
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
border-radius: 8px;
|
||||
color: rgba(255,255,255,0.9);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
box-shadow: 0 4px 14px rgba(0,0,0,0.35);
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
#xiaobaix-debug-mini:hover {
|
||||
box-shadow: 0 6px 18px rgba(0,0,0,0.45);
|
||||
}
|
||||
#xiaobaix-debug-mini .badge {
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255,80,80,0.18);
|
||||
border: 1px solid rgba(255,80,80,0.35);
|
||||
color: #fca5a5;
|
||||
font-size: 10px;
|
||||
}
|
||||
#xiaobaix-debug-mini .badge.hidden { display: none; }
|
||||
#xiaobaix-debug-mini.flash {
|
||||
animation: xbdbg-flash 0.35s ease-in-out 2;
|
||||
}
|
||||
@keyframes xbdbg-flash {
|
||||
0%,100% { box-shadow: 0 4px 14px rgba(0,0,0,0.35); }
|
||||
50% { box-shadow: 0 0 0 4px rgba(255,80,80,0.4); }
|
||||
}
|
||||
#xiaobaix-debug-panel {
|
||||
position: fixed;
|
||||
z-index: 10001;
|
||||
background: rgba(22,22,26,0.97);
|
||||
border: 1px solid rgba(255,255,255,0.10);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 12px 36px rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
@media (min-width: 769px) {
|
||||
#xiaobaix-debug-panel {
|
||||
resize: both;
|
||||
min-width: 320px;
|
||||
min-height: 260px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
#xiaobaix-debug-panel {
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
top: 0 !important;
|
||||
width: 100% !important;
|
||||
border-radius: 0;
|
||||
resize: none;
|
||||
}
|
||||
}
|
||||
#xiaobaix-debug-titlebar {
|
||||
user-select: none;
|
||||
padding: 8px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
background: rgba(30,30,34,0.98);
|
||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@media (min-width: 769px) {
|
||||
#xiaobaix-debug-titlebar { cursor: move; }
|
||||
}
|
||||
#xiaobaix-debug-titlebar .left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: rgba(255,255,255,0.88);
|
||||
}
|
||||
#xiaobaix-debug-titlebar .right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.xbdbg-btn {
|
||||
width: 28px;
|
||||
height: 24px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255,255,255,0.10);
|
||||
background: rgba(255,255,255,0.05);
|
||||
color: rgba(255,255,255,0.85);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.xbdbg-btn:hover { background: rgba(255,255,255,0.12); }
|
||||
#xiaobaix-debug-frame {
|
||||
flex: 1;
|
||||
border: 0;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 定位计算
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function getAnchorRect() {
|
||||
const anchor = document.getElementById("nonQRFormItems");
|
||||
if (anchor) return anchor.getBoundingClientRect();
|
||||
return { top: window.innerHeight - 60, right: window.innerWidth, left: 0, width: window.innerWidth };
|
||||
}
|
||||
|
||||
function getDefaultMiniPos() {
|
||||
const rect = getAnchorRect();
|
||||
const btnW = 90, btnH = 32, margin = 8;
|
||||
return { left: rect.right - btnW - margin, top: rect.top - btnH - margin };
|
||||
}
|
||||
|
||||
function applyMiniPosition() {
|
||||
if (!miniBtnEl) return;
|
||||
const saved = readJSON(STORAGE_MINI_KEY);
|
||||
const def = getDefaultMiniPos();
|
||||
const pos = saved || def;
|
||||
const w = miniBtnEl.offsetWidth || 90;
|
||||
const h = miniBtnEl.offsetHeight || 32;
|
||||
miniBtnEl.style.left = `${clamp(pos.left, 0, window.innerWidth - w)}px`;
|
||||
miniBtnEl.style.top = `${clamp(pos.top, 0, window.innerHeight - h)}px`;
|
||||
}
|
||||
|
||||
function saveMiniPos() {
|
||||
if (!miniBtnEl) return;
|
||||
const r = miniBtnEl.getBoundingClientRect();
|
||||
writeJSON(STORAGE_MINI_KEY, { left: Math.round(r.left), top: Math.round(r.top) });
|
||||
}
|
||||
|
||||
function applyExpandedPosition() {
|
||||
if (!panelEl) return;
|
||||
if (isMobile()) {
|
||||
const rect = getAnchorRect();
|
||||
panelEl.style.left = "0";
|
||||
panelEl.style.top = "0";
|
||||
panelEl.style.width = "100%";
|
||||
panelEl.style.height = `${rect.top}px`;
|
||||
return;
|
||||
}
|
||||
const saved = readJSON(STORAGE_EXPANDED_KEY);
|
||||
const defW = 480, defH = 400;
|
||||
const w = saved?.width >= 320 ? saved.width : defW;
|
||||
const h = saved?.height >= 260 ? saved.height : defH;
|
||||
const left = saved?.left != null ? clamp(saved.left, 0, window.innerWidth - w) : 20;
|
||||
const top = saved?.top != null ? clamp(saved.top, 0, window.innerHeight - h) : 80;
|
||||
panelEl.style.left = `${left}px`;
|
||||
panelEl.style.top = `${top}px`;
|
||||
panelEl.style.width = `${w}px`;
|
||||
panelEl.style.height = `${h}px`;
|
||||
}
|
||||
|
||||
function saveExpandedPos() {
|
||||
if (!panelEl || isMobile()) return;
|
||||
const r = panelEl.getBoundingClientRect();
|
||||
writeJSON(STORAGE_EXPANDED_KEY, { left: Math.round(r.left), top: Math.round(r.top), width: Math.round(r.width), height: Math.round(r.height) });
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 数据获取与通信
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async function getDebugSnapshot() {
|
||||
const { xbLog, CacheRegistry } = await import("../../core/debug-core.js");
|
||||
const { EventCenter } = await import("../../core/event-manager.js");
|
||||
const pageStats = getPageStats();
|
||||
return {
|
||||
logs: xbLog.getAll(),
|
||||
events: EventCenter.getEventHistory?.() || [],
|
||||
eventStatsDetail: EventCenter.statsDetail?.() || {},
|
||||
caches: CacheRegistry.getStats(),
|
||||
performance: {
|
||||
requests: requestLog.slice(),
|
||||
longTasks: longTaskLog.slice(),
|
||||
fps: currentFps,
|
||||
memory: getMemoryInfo(),
|
||||
domCount: pageStats.domCount,
|
||||
messageCount: pageStats.messageCount,
|
||||
imageCount: pageStats.imageCount
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function postToFrame(msg) {
|
||||
try { iframeEl?.contentWindow?.postMessage({ source: "LittleWhiteBox-DebugHost", ...msg }, "*"); } catch {}
|
||||
}
|
||||
|
||||
async function sendSnapshotToFrame() {
|
||||
if (!frameReady) return;
|
||||
const snapshot = await getDebugSnapshot();
|
||||
postToFrame({ type: "XB_DEBUG_DATA", payload: snapshot });
|
||||
updateMiniBadge(snapshot.logs);
|
||||
}
|
||||
|
||||
async function handleAction(action) {
|
||||
const { xbLog, CacheRegistry } = await import("../../core/debug-core.js");
|
||||
const { EventCenter } = await import("../../core/event-manager.js");
|
||||
switch (action?.action) {
|
||||
case "refresh": await sendSnapshotToFrame(); break;
|
||||
case "clearLogs": xbLog.clear(); await sendSnapshotToFrame(); break;
|
||||
case "clearEvents": EventCenter.clearHistory?.(); await sendSnapshotToFrame(); break;
|
||||
case "clearCache": if (action.moduleId) CacheRegistry.clear(action.moduleId); await sendSnapshotToFrame(); break;
|
||||
case "clearAllCaches": CacheRegistry.clearAll(); await sendSnapshotToFrame(); break;
|
||||
case "clearRequests": requestLog.length = 0; await sendSnapshotToFrame(); break;
|
||||
case "clearTasks": longTaskLog.length = 0; await sendSnapshotToFrame(); break;
|
||||
case "cacheDetail":
|
||||
postToFrame({ type: "XB_DEBUG_CACHE_DETAIL", payload: { moduleId: action.moduleId, detail: CacheRegistry.getDetail(action.moduleId) } });
|
||||
break;
|
||||
case "exportLogs":
|
||||
postToFrame({ type: "XB_DEBUG_EXPORT", payload: { text: xbLog.export() } });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function bindMessageListener() {
|
||||
if (messageListenerBound) return;
|
||||
messageListenerBound = true;
|
||||
window.addEventListener("message", async (e) => {
|
||||
const msg = e?.data;
|
||||
if (!msg || msg.source !== "LittleWhiteBox-DebugFrame") return;
|
||||
if (msg.type === "FRAME_READY") { frameReady = true; await sendSnapshotToFrame(); }
|
||||
else if (msg.type === "XB_DEBUG_ACTION") await handleAction(msg);
|
||||
else if (msg.type === "CLOSE_PANEL") closeDebugPanel();
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// UI 更新
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function updateMiniBadge(logs) {
|
||||
if (!miniBtnEl) return;
|
||||
const badge = miniBtnEl.querySelector(".badge");
|
||||
if (!badge) return;
|
||||
const errCount = countErrors(logs);
|
||||
badge.classList.toggle("hidden", errCount <= 0);
|
||||
badge.textContent = errCount > 0 ? String(errCount) : "";
|
||||
const newMax = maxLogId(logs);
|
||||
if (newMax > lastLogId && !isExpanded) {
|
||||
miniBtnEl.classList.remove("flash");
|
||||
void miniBtnEl.offsetWidth;
|
||||
miniBtnEl.classList.add("flash");
|
||||
}
|
||||
lastLogId = newMax;
|
||||
}
|
||||
|
||||
function updateSettingsLight() {
|
||||
const light = document.querySelector("#xiaobaix-debug-btn .dbg-light");
|
||||
if (light) light.classList.toggle("on", isOpen);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 拖拽:最小化按钮
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function onMiniDown(e) {
|
||||
if (e.button !== undefined && e.button !== 0) return;
|
||||
dragState = {
|
||||
startX: e.clientX, startY: e.clientY,
|
||||
startLeft: miniBtnEl.getBoundingClientRect().left,
|
||||
startTop: miniBtnEl.getBoundingClientRect().top,
|
||||
pointerId: e.pointerId, moved: false
|
||||
};
|
||||
try { e.currentTarget.setPointerCapture(e.pointerId); } catch {}
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function onMiniMove(e) {
|
||||
if (!dragState || dragState.pointerId !== e.pointerId) return;
|
||||
const dx = e.clientX - dragState.startX, dy = e.clientY - dragState.startY;
|
||||
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) dragState.moved = true;
|
||||
const w = miniBtnEl.offsetWidth || 90, h = miniBtnEl.offsetHeight || 32;
|
||||
miniBtnEl.style.left = `${clamp(dragState.startLeft + dx, 0, window.innerWidth - w)}px`;
|
||||
miniBtnEl.style.top = `${clamp(dragState.startTop + dy, 0, window.innerHeight - h)}px`;
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function onMiniUp(e) {
|
||||
if (!dragState || dragState.pointerId !== e.pointerId) return;
|
||||
const wasMoved = dragState.moved;
|
||||
try { e.currentTarget.releasePointerCapture(e.pointerId); } catch {}
|
||||
dragState = null;
|
||||
saveMiniPos();
|
||||
if (!wasMoved) expandPanel();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 拖拽:展开面板标题栏
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function onTitleDown(e) {
|
||||
if (isMobile()) return;
|
||||
if (e.button !== undefined && e.button !== 0) return;
|
||||
if (e.target?.closest?.(".xbdbg-btn")) return;
|
||||
dragState = {
|
||||
startX: e.clientX, startY: e.clientY,
|
||||
startLeft: panelEl.getBoundingClientRect().left,
|
||||
startTop: panelEl.getBoundingClientRect().top,
|
||||
pointerId: e.pointerId
|
||||
};
|
||||
try { e.currentTarget.setPointerCapture(e.pointerId); } catch {}
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function onTitleMove(e) {
|
||||
if (!dragState || isMobile() || dragState.pointerId !== e.pointerId) return;
|
||||
const dx = e.clientX - dragState.startX, dy = e.clientY - dragState.startY;
|
||||
const w = panelEl.offsetWidth, h = panelEl.offsetHeight;
|
||||
panelEl.style.left = `${clamp(dragState.startLeft + dx, 0, window.innerWidth - w)}px`;
|
||||
panelEl.style.top = `${clamp(dragState.startTop + dy, 0, window.innerHeight - h)}px`;
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function onTitleUp(e) {
|
||||
if (!dragState || isMobile() || dragState.pointerId !== e.pointerId) return;
|
||||
try { e.currentTarget.releasePointerCapture(e.pointerId); } catch {}
|
||||
dragState = null;
|
||||
saveExpandedPos();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 轮询与 resize
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function startPoll() {
|
||||
stopPoll();
|
||||
pollTimer = setInterval(async () => {
|
||||
if (!isOpen) return;
|
||||
try { await sendSnapshotToFrame(); } catch {}
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
function stopPoll() {
|
||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
|
||||
}
|
||||
|
||||
function onResize() {
|
||||
if (!isOpen) return;
|
||||
if (isExpanded) applyExpandedPosition();
|
||||
else applyMiniPosition();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 面板生命周期
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function createMiniButton() {
|
||||
if (miniBtnEl) return;
|
||||
miniBtnEl = document.createElement("div");
|
||||
miniBtnEl.id = "xiaobaix-debug-mini";
|
||||
miniBtnEl.innerHTML = `<span>监控</span><span class="badge hidden"></span>`;
|
||||
document.body.appendChild(miniBtnEl);
|
||||
applyMiniPosition();
|
||||
miniBtnEl.addEventListener("pointerdown", onMiniDown, { passive: false });
|
||||
miniBtnEl.addEventListener("pointermove", onMiniMove, { passive: false });
|
||||
miniBtnEl.addEventListener("pointerup", onMiniUp, { passive: false });
|
||||
miniBtnEl.addEventListener("pointercancel", onMiniUp, { passive: false });
|
||||
}
|
||||
|
||||
function removeMiniButton() {
|
||||
miniBtnEl?.remove();
|
||||
miniBtnEl = null;
|
||||
}
|
||||
|
||||
function createPanel() {
|
||||
if (panelEl) return;
|
||||
panelEl = document.createElement("div");
|
||||
panelEl.id = "xiaobaix-debug-panel";
|
||||
const titlebar = document.createElement("div");
|
||||
titlebar.id = "xiaobaix-debug-titlebar";
|
||||
titlebar.innerHTML = `
|
||||
<div class="left"><span>小白X 监控台</span></div>
|
||||
<div class="right">
|
||||
<button class="xbdbg-btn" id="xbdbg-min" title="最小化" type="button">—</button>
|
||||
<button class="xbdbg-btn" id="xbdbg-close" title="关闭" type="button">×</button>
|
||||
</div>
|
||||
`;
|
||||
iframeEl = document.createElement("iframe");
|
||||
iframeEl.id = "xiaobaix-debug-frame";
|
||||
iframeEl.src = `${extensionFolderPath}/modules/debug-panel/debug-panel.html`;
|
||||
panelEl.appendChild(titlebar);
|
||||
panelEl.appendChild(iframeEl);
|
||||
document.body.appendChild(panelEl);
|
||||
applyExpandedPosition();
|
||||
titlebar.addEventListener("pointerdown", onTitleDown, { passive: false });
|
||||
titlebar.addEventListener("pointermove", onTitleMove, { passive: false });
|
||||
titlebar.addEventListener("pointerup", onTitleUp, { passive: false });
|
||||
titlebar.addEventListener("pointercancel", onTitleUp, { passive: false });
|
||||
panelEl.querySelector("#xbdbg-min")?.addEventListener("click", collapsePanel);
|
||||
panelEl.querySelector("#xbdbg-close")?.addEventListener("click", closeDebugPanel);
|
||||
if (!isMobile()) {
|
||||
panelEl.addEventListener("mouseup", saveExpandedPos);
|
||||
panelEl.addEventListener("mouseleave", saveExpandedPos);
|
||||
}
|
||||
frameReady = false;
|
||||
}
|
||||
|
||||
function removePanel() {
|
||||
panelEl?.remove();
|
||||
panelEl = null;
|
||||
iframeEl = null;
|
||||
frameReady = false;
|
||||
}
|
||||
|
||||
function expandPanel() {
|
||||
if (isExpanded) return;
|
||||
isExpanded = true;
|
||||
if (miniBtnEl) miniBtnEl.style.display = "none";
|
||||
if (panelEl) {
|
||||
panelEl.style.display = "";
|
||||
} else {
|
||||
createPanel();
|
||||
}
|
||||
}
|
||||
|
||||
function collapsePanel() {
|
||||
if (!isExpanded) return;
|
||||
isExpanded = false;
|
||||
saveExpandedPos();
|
||||
if (panelEl) panelEl.style.display = "none";
|
||||
if (miniBtnEl) {
|
||||
miniBtnEl.style.display = "";
|
||||
applyMiniPosition();
|
||||
}
|
||||
}
|
||||
|
||||
async function openDebugPanel() {
|
||||
if (isOpen) return;
|
||||
isOpen = true;
|
||||
ensureStyle();
|
||||
bindMessageListener();
|
||||
const { enableDebugMode } = await import("../../core/debug-core.js");
|
||||
enableDebugMode();
|
||||
startPerfMonitor();
|
||||
createMiniButton();
|
||||
startPoll();
|
||||
updateSettingsLight();
|
||||
if (!resizeHandler) { resizeHandler = onResize; window.addEventListener("resize", resizeHandler); }
|
||||
try { window.registerModuleCleanup?.("debugPanel", closeDebugPanel); } catch {}
|
||||
}
|
||||
|
||||
async function closeDebugPanel() {
|
||||
if (!isOpen) return;
|
||||
isOpen = false;
|
||||
isExpanded = false;
|
||||
stopPoll();
|
||||
stopPerfMonitor();
|
||||
frameReady = false;
|
||||
lastLogId = 0;
|
||||
try { const { disableDebugMode } = await import("../../core/debug-core.js"); disableDebugMode(); } catch {}
|
||||
removePanel();
|
||||
removeMiniButton();
|
||||
updateSettingsLight();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 导出
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export async function toggleDebugPanel() {
|
||||
if (isOpen) await closeDebugPanel();
|
||||
else await openDebugPanel();
|
||||
}
|
||||
|
||||
export { openDebugPanel as openDebugPanelExplicit, closeDebugPanel as closeDebugPanelExplicit };
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.xbDebugPanelToggle = toggleDebugPanel;
|
||||
window.xbDebugPanelClose = closeDebugPanel;
|
||||
}
|
||||
1115
modules/fourth-wall/fourth-wall.html
Normal file
1115
modules/fourth-wall/fourth-wall.html
Normal file
File diff suppressed because it is too large
Load Diff
1238
modules/fourth-wall/fourth-wall.js
Normal file
1238
modules/fourth-wall/fourth-wall.js
Normal file
File diff suppressed because it is too large
Load Diff
784
modules/iframe-renderer.js
Normal file
784
modules/iframe-renderer.js
Normal file
@@ -0,0 +1,784 @@
|
||||
import { extension_settings, getContext } from "../../../../extensions.js";
|
||||
import { createModuleEvents, event_types } from "../core/event-manager.js";
|
||||
import { EXT_ID, extensionFolderPath } from "../core/constants.js";
|
||||
import { xbLog, CacheRegistry } from "../core/debug-core.js";
|
||||
import { replaceXbGetVarInString } from "./variables/var-commands.js";
|
||||
import { executeSlashCommand } from "../core/slash-command.js";
|
||||
import { default_user_avatar, default_avatar } from "../../../../../script.js";
|
||||
|
||||
const MODULE_ID = 'iframeRenderer';
|
||||
const events = createModuleEvents(MODULE_ID);
|
||||
|
||||
let isGenerating = false;
|
||||
const winMap = new Map();
|
||||
let lastHeights = new WeakMap();
|
||||
const blobUrls = new WeakMap();
|
||||
const hashToBlobUrl = new Map();
|
||||
const hashToBlobBytes = new Map();
|
||||
const blobLRU = [];
|
||||
const BLOB_CACHE_LIMIT = 32;
|
||||
let lastApplyTs = 0;
|
||||
let pendingHeight = null;
|
||||
let pendingRec = null;
|
||||
|
||||
CacheRegistry.register(MODULE_ID, {
|
||||
name: 'Blob URL 缓存',
|
||||
getSize: () => hashToBlobUrl.size,
|
||||
getBytes: () => {
|
||||
let bytes = 0;
|
||||
hashToBlobBytes.forEach(v => { bytes += Number(v) || 0; });
|
||||
return bytes;
|
||||
},
|
||||
clear: () => {
|
||||
clearBlobCaches();
|
||||
hashToBlobBytes.clear();
|
||||
},
|
||||
getDetail: () => Array.from(hashToBlobUrl.keys()),
|
||||
});
|
||||
|
||||
function getSettings() {
|
||||
return extension_settings[EXT_ID] || {};
|
||||
}
|
||||
|
||||
function djb2(str) {
|
||||
let h = 5381;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
h = ((h << 5) + h) ^ str.charCodeAt(i);
|
||||
}
|
||||
return (h >>> 0).toString(16);
|
||||
}
|
||||
|
||||
function shouldRenderContentByBlock(codeBlock) {
|
||||
if (!codeBlock) return false;
|
||||
const content = (codeBlock.textContent || '').trim().toLowerCase();
|
||||
if (!content) return false;
|
||||
return content.includes('<!doctype') || content.includes('<html') || content.includes('<script');
|
||||
}
|
||||
|
||||
function generateUniqueId() {
|
||||
return `xiaobaix-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
}
|
||||
|
||||
function setIframeBlobHTML(iframe, fullHTML, codeHash) {
|
||||
const existing = hashToBlobUrl.get(codeHash);
|
||||
if (existing) {
|
||||
iframe.src = existing;
|
||||
blobUrls.set(iframe, existing);
|
||||
return;
|
||||
}
|
||||
const blob = new Blob([fullHTML], { type: 'text/html' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
iframe.src = url;
|
||||
blobUrls.set(iframe, url);
|
||||
hashToBlobUrl.set(codeHash, url);
|
||||
try { hashToBlobBytes.set(codeHash, blob.size || 0); } catch {}
|
||||
blobLRU.push(codeHash);
|
||||
while (blobLRU.length > BLOB_CACHE_LIMIT) {
|
||||
const old = blobLRU.shift();
|
||||
const u = hashToBlobUrl.get(old);
|
||||
hashToBlobUrl.delete(old);
|
||||
hashToBlobBytes.delete(old);
|
||||
try { URL.revokeObjectURL(u); } catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
function releaseIframeBlob(iframe) {
|
||||
try {
|
||||
const url = blobUrls.get(iframe);
|
||||
if (url) URL.revokeObjectURL(url);
|
||||
blobUrls.delete(iframe);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
export function clearBlobCaches() {
|
||||
try { xbLog.info(MODULE_ID, '清空 Blob 缓存'); } catch {}
|
||||
hashToBlobUrl.forEach(u => { try { URL.revokeObjectURL(u); } catch {} });
|
||||
hashToBlobUrl.clear();
|
||||
hashToBlobBytes.clear();
|
||||
blobLRU.length = 0;
|
||||
}
|
||||
|
||||
function buildResourceHints(html) {
|
||||
const urls = Array.from(new Set((html.match(/https?:\/\/[^"'()\s]+/gi) || [])
|
||||
.map(u => { try { return new URL(u).origin; } catch { return null; } })
|
||||
.filter(Boolean)));
|
||||
let hints = "";
|
||||
const maxHosts = 6;
|
||||
for (let i = 0; i < Math.min(urls.length, maxHosts); i++) {
|
||||
const origin = urls[i];
|
||||
hints += `<link rel="dns-prefetch" href="${origin}">`;
|
||||
hints += `<link rel="preconnect" href="${origin}" crossorigin>`;
|
||||
}
|
||||
let preload = "";
|
||||
const font = (html.match(/https?:\/\/[^"'()\s]+\.(?:woff2|woff|ttf|otf)/i) || [])[0];
|
||||
if (font) {
|
||||
const type = font.endsWith(".woff2") ? "font/woff2" : font.endsWith(".woff") ? "font/woff" : font.endsWith(".ttf") ? "font/ttf" : "font/otf";
|
||||
preload += `<link rel="preload" as="font" href="${font}" type="${type}" crossorigin fetchpriority="high">`;
|
||||
}
|
||||
const css = (html.match(/https?:\/\/[^"'()\s]+\.css/i) || [])[0];
|
||||
if (css) {
|
||||
preload += `<link rel="preload" as="style" href="${css}" crossorigin fetchpriority="high">`;
|
||||
}
|
||||
const img = (html.match(/https?:\/\/[^"'()\s]+\.(?:png|jpg|jpeg|webp|gif|svg)/i) || [])[0];
|
||||
if (img) {
|
||||
preload += `<link rel="preload" as="image" href="${img}" crossorigin fetchpriority="high">`;
|
||||
}
|
||||
return hints + preload;
|
||||
}
|
||||
|
||||
function iframeClientScript() {
|
||||
return `
|
||||
(function(){
|
||||
function measureVisibleHeight(){
|
||||
try{
|
||||
var doc = document;
|
||||
var target = doc.body;
|
||||
if(!target) return 0;
|
||||
|
||||
var minTop = Infinity, maxBottom = 0;
|
||||
var addRect = function(el){
|
||||
try{
|
||||
var r = el.getBoundingClientRect();
|
||||
if(r && r.height > 0){
|
||||
if(minTop > r.top) minTop = r.top;
|
||||
if(maxBottom < r.bottom) maxBottom = r.bottom;
|
||||
}
|
||||
}catch(e){}
|
||||
};
|
||||
|
||||
addRect(target);
|
||||
var children = target.children || [];
|
||||
for(var i=0;i<children.length;i++){
|
||||
var child = children[i];
|
||||
if(!child) continue;
|
||||
try{
|
||||
var s = window.getComputedStyle(child);
|
||||
if(s.display === 'none' || s.visibility === 'hidden') continue;
|
||||
if(!child.offsetParent && s.position !== 'fixed') continue;
|
||||
}catch(e){}
|
||||
addRect(child);
|
||||
}
|
||||
|
||||
return maxBottom > 0 ? Math.ceil(maxBottom - Math.min(minTop, 0)) : (target.scrollHeight || 0);
|
||||
}catch(e){
|
||||
return (document.body && document.body.scrollHeight) || 0;
|
||||
}
|
||||
}
|
||||
|
||||
function post(m){ try{ parent.postMessage(m,'*') }catch(e){} }
|
||||
|
||||
var rafPending=false, lastH=0;
|
||||
var HYSTERESIS = 2;
|
||||
|
||||
function send(force){
|
||||
if(rafPending && !force) return;
|
||||
rafPending = true;
|
||||
requestAnimationFrame(function(){
|
||||
rafPending = false;
|
||||
var h = measureVisibleHeight();
|
||||
if(force || Math.abs(h - lastH) >= HYSTERESIS){
|
||||
lastH = h;
|
||||
post({height:h, force:!!force});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
try{ send(true) }catch(e){}
|
||||
document.addEventListener('DOMContentLoaded', function(){ send(true) }, {once:true});
|
||||
window.addEventListener('load', function(){ send(true) }, {once:true});
|
||||
|
||||
try{
|
||||
if(document.fonts){
|
||||
document.fonts.ready.then(function(){ send(true) }).catch(function(){});
|
||||
if(document.fonts.addEventListener){
|
||||
document.fonts.addEventListener('loadingdone', function(){ send(true) });
|
||||
document.fonts.addEventListener('loadingerror', function(){ send(true) });
|
||||
}
|
||||
}
|
||||
}catch(e){}
|
||||
|
||||
['transitionend','animationend'].forEach(function(evt){
|
||||
document.addEventListener(evt, function(){ send(false) }, {passive:true, capture:true});
|
||||
});
|
||||
|
||||
try{
|
||||
var root = document.body || document.documentElement;
|
||||
var ro = new ResizeObserver(function(){ send(false) });
|
||||
ro.observe(root);
|
||||
}catch(e){
|
||||
try{
|
||||
var rootMO = document.body || document.documentElement;
|
||||
new MutationObserver(function(){ send(false) })
|
||||
.observe(rootMO, {childList:true, subtree:true, attributes:true, characterData:true});
|
||||
}catch(e){}
|
||||
window.addEventListener('resize', function(){ send(false) }, {passive:true});
|
||||
}
|
||||
|
||||
window.addEventListener('message', function(e){
|
||||
var d = e && e.data || {};
|
||||
if(d && d.type === 'probe') setTimeout(function(){ send(true) }, 10);
|
||||
});
|
||||
|
||||
window.STscript = function(command){
|
||||
return new Promise(function(resolve,reject){
|
||||
try{
|
||||
if(!command){ reject(new Error('empty')); return }
|
||||
if(command[0] !== '/') command = '/' + command;
|
||||
var id = Date.now().toString(36) + Math.random().toString(36).slice(2);
|
||||
function onMessage(e){
|
||||
var d = e && e.data || {};
|
||||
if(d.source !== 'xiaobaix-host') return;
|
||||
if((d.type === 'commandResult' || d.type === 'commandError') && d.id === id){
|
||||
try{ window.removeEventListener('message', onMessage) }catch(e){}
|
||||
if(d.type === 'commandResult') resolve(d.result);
|
||||
else reject(new Error(d.error || 'error'));
|
||||
}
|
||||
}
|
||||
try{ window.addEventListener('message', onMessage) }catch(e){}
|
||||
post({type:'runCommand', id, command});
|
||||
setTimeout(function(){
|
||||
try{ window.removeEventListener('message', onMessage) }catch(e){}
|
||||
reject(new Error('Command timeout'))
|
||||
}, 180000);
|
||||
}catch(e){ reject(e) }
|
||||
})
|
||||
};
|
||||
try{ if(typeof window['stscript'] !== 'function') window['stscript'] = window.STscript }catch(e){}
|
||||
})();`;
|
||||
}
|
||||
|
||||
function buildWrappedHtml(html) {
|
||||
const settings = getSettings();
|
||||
const api = `<script>${iframeClientScript()}</script>`;
|
||||
const wrapperToggle = settings.wrapperIframe ?? true;
|
||||
const origin = typeof location !== 'undefined' && location.origin ? location.origin : '';
|
||||
const optWrapperUrl = `${origin}/scripts/extensions/third-party/${EXT_ID}/bridges/wrapper-iframe.js`;
|
||||
const optWrapper = wrapperToggle ? `<script src="${optWrapperUrl}"></script>` : "";
|
||||
const baseTag = settings.useBlob ? `<base href="${origin}/">` : "";
|
||||
const headHints = buildResourceHints(html);
|
||||
const vhFix = `<style>html,body{height:auto!important;min-height:0!important;max-height:none!important}.profile-container,[style*="100vh"]{height:auto!important;min-height:600px!important}[style*="height:100%"]{height:auto!important;min-height:100%!important}</style>`;
|
||||
|
||||
if (html.includes('<html') && html.includes('</html')) {
|
||||
if (html.includes('<head>'))
|
||||
return html.replace('<head>', `<head>${baseTag}${api}${optWrapper}${headHints}${vhFix}`);
|
||||
if (html.includes('</head>'))
|
||||
return html.replace('</head>', `${baseTag}${api}${optWrapper}${headHints}${vhFix}</head>`);
|
||||
return html.replace('<body', `<head>${baseTag}${api}${optWrapper}${headHints}${vhFix}</head><body`);
|
||||
}
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
${baseTag}
|
||||
${api}
|
||||
${optWrapper}
|
||||
${headHints}
|
||||
${vhFix}
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; background: transparent; }
|
||||
</style>
|
||||
</head>
|
||||
<body>${html}</body></html>`;
|
||||
}
|
||||
|
||||
function getOrCreateWrapper(preEl) {
|
||||
let wrapper = preEl.previousElementSibling;
|
||||
if (!wrapper || !wrapper.classList.contains('xiaobaix-iframe-wrapper')) {
|
||||
wrapper = document.createElement('div');
|
||||
wrapper.className = 'xiaobaix-iframe-wrapper';
|
||||
wrapper.style.cssText = 'margin:0;';
|
||||
preEl.parentNode.insertBefore(wrapper, preEl);
|
||||
}
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function registerIframeMapping(iframe, wrapper) {
|
||||
const tryMap = () => {
|
||||
try {
|
||||
if (iframe && iframe.contentWindow) {
|
||||
winMap.set(iframe.contentWindow, { iframe, wrapper });
|
||||
return true;
|
||||
}
|
||||
} catch (e) {}
|
||||
return false;
|
||||
};
|
||||
if (tryMap()) return;
|
||||
let tries = 0;
|
||||
const t = setInterval(() => {
|
||||
tries++;
|
||||
if (tryMap() || tries > 20) clearInterval(t);
|
||||
}, 25);
|
||||
}
|
||||
|
||||
function resolveAvatarUrls() {
|
||||
const origin = typeof location !== 'undefined' && location.origin ? location.origin : '';
|
||||
const toAbsUrl = (relOrUrl) => {
|
||||
if (!relOrUrl) return '';
|
||||
const s = String(relOrUrl);
|
||||
if (/^(data:|blob:|https?:)/i.test(s)) return s;
|
||||
if (s.startsWith('User Avatars/')) {
|
||||
return `${origin}/${s}`;
|
||||
}
|
||||
const encoded = s.split('/').map(seg => encodeURIComponent(seg)).join('/');
|
||||
return `${origin}/${encoded.replace(/^\/+/, '')}`;
|
||||
};
|
||||
const pickSrc = (selectors) => {
|
||||
for (const sel of selectors) {
|
||||
const el = document.querySelector(sel);
|
||||
if (el) {
|
||||
const highRes = el.getAttribute('data-izoomify-url');
|
||||
if (highRes) return highRes;
|
||||
if (el.src) return el.src;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
};
|
||||
let user = pickSrc([
|
||||
'#user_avatar_block img',
|
||||
'#avatar_user img',
|
||||
'.user_avatar img',
|
||||
'img#avatar_user',
|
||||
'.st-user-avatar img'
|
||||
]) || default_user_avatar;
|
||||
const m = String(user).match(/\/thumbnail\?type=persona&file=([^&]+)/i);
|
||||
if (m) {
|
||||
user = `User Avatars/${decodeURIComponent(m[1])}`;
|
||||
}
|
||||
const ctx = getContext?.() || {};
|
||||
const chId = ctx.characterId ?? ctx.this_chid;
|
||||
const ch = Array.isArray(ctx.characters) ? ctx.characters[chId] : null;
|
||||
let char = ch?.avatar || default_avatar;
|
||||
if (char && !/^(data:|blob:|https?:)/i.test(char)) {
|
||||
char = /[\/]/.test(char) ? char.replace(/^\/+/, '') : `characters/${char}`;
|
||||
}
|
||||
return { user: toAbsUrl(user), char: toAbsUrl(char) };
|
||||
}
|
||||
|
||||
function handleIframeMessage(event) {
|
||||
const data = event.data || {};
|
||||
let rec = winMap.get(event.source);
|
||||
|
||||
if (!rec || !rec.iframe) {
|
||||
const iframes = document.querySelectorAll('iframe.xiaobaix-iframe');
|
||||
for (const iframe of iframes) {
|
||||
if (iframe.contentWindow === event.source) {
|
||||
rec = { iframe, wrapper: iframe.parentElement };
|
||||
winMap.set(event.source, rec);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (rec && rec.iframe && typeof data.height === 'number') {
|
||||
const next = Math.max(0, Number(data.height) || 0);
|
||||
if (next < 1) return;
|
||||
const prev = lastHeights.get(rec.iframe) || 0;
|
||||
if (!data.force && Math.abs(next - prev) < 1) return;
|
||||
if (data.force) {
|
||||
lastHeights.set(rec.iframe, next);
|
||||
requestAnimationFrame(() => { rec.iframe.style.height = `${next}px`; });
|
||||
return;
|
||||
}
|
||||
pendingHeight = next;
|
||||
pendingRec = rec;
|
||||
const now = performance.now();
|
||||
const dt = now - lastApplyTs;
|
||||
if (dt >= 50) {
|
||||
lastApplyTs = now;
|
||||
const h = pendingHeight, r = pendingRec;
|
||||
pendingHeight = null;
|
||||
pendingRec = null;
|
||||
lastHeights.set(r.iframe, h);
|
||||
requestAnimationFrame(() => { r.iframe.style.height = `${h}px`; });
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
if (pendingRec && pendingHeight != null) {
|
||||
lastApplyTs = performance.now();
|
||||
const h = pendingHeight, r = pendingRec;
|
||||
pendingHeight = null;
|
||||
pendingRec = null;
|
||||
lastHeights.set(r.iframe, h);
|
||||
requestAnimationFrame(() => { r.iframe.style.height = `${h}px`; });
|
||||
}
|
||||
}, Math.max(0, 50 - dt));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (data && data.type === 'runCommand') {
|
||||
executeSlashCommand(data.command)
|
||||
.then(result => event.source.postMessage({
|
||||
source: 'xiaobaix-host',
|
||||
type: 'commandResult',
|
||||
id: data.id,
|
||||
result
|
||||
}, '*'))
|
||||
.catch(err => event.source.postMessage({
|
||||
source: 'xiaobaix-host',
|
||||
type: 'commandError',
|
||||
id: data.id,
|
||||
error: err.message || String(err)
|
||||
}, '*'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (data && data.type === 'getAvatars') {
|
||||
try {
|
||||
const urls = resolveAvatarUrls();
|
||||
event.source?.postMessage({ source: 'xiaobaix-host', type: 'avatars', urls }, '*');
|
||||
} catch (e) {
|
||||
event.source?.postMessage({ source: 'xiaobaix-host', type: 'avatars', urls: { user: '', char: '' } }, '*');
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export function renderHtmlInIframe(htmlContent, container, preElement) {
|
||||
const settings = getSettings();
|
||||
try {
|
||||
const originalHash = djb2(htmlContent);
|
||||
|
||||
if (settings.variablesCore?.enabled && typeof replaceXbGetVarInString === 'function') {
|
||||
try {
|
||||
htmlContent = replaceXbGetVarInString(htmlContent);
|
||||
} catch (e) {
|
||||
console.warn('xbgetvar 宏替换失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.id = generateUniqueId();
|
||||
iframe.className = 'xiaobaix-iframe';
|
||||
iframe.style.cssText = 'width:100%;border:none;background:transparent;overflow:hidden;height:0;margin:0;padding:0;display:block;contain:layout paint style;will-change:height;min-height:50px';
|
||||
iframe.setAttribute('frameborder', '0');
|
||||
iframe.setAttribute('scrolling', 'no');
|
||||
iframe.loading = 'eager';
|
||||
|
||||
if (settings.sandboxMode) {
|
||||
iframe.setAttribute('sandbox', 'allow-scripts');
|
||||
}
|
||||
|
||||
const wrapper = getOrCreateWrapper(preElement);
|
||||
wrapper.querySelectorAll('.xiaobaix-iframe').forEach(old => {
|
||||
try { old.src = 'about:blank'; } catch (e) {}
|
||||
releaseIframeBlob(old);
|
||||
old.remove();
|
||||
});
|
||||
|
||||
const codeHash = djb2(htmlContent);
|
||||
const full = buildWrappedHtml(htmlContent);
|
||||
|
||||
if (settings.useBlob) {
|
||||
setIframeBlobHTML(iframe, full, codeHash);
|
||||
} else {
|
||||
iframe.srcdoc = full;
|
||||
}
|
||||
|
||||
wrapper.appendChild(iframe);
|
||||
preElement.classList.remove('xb-show');
|
||||
preElement.style.display = 'none';
|
||||
registerIframeMapping(iframe, wrapper);
|
||||
|
||||
try { iframe.contentWindow?.postMessage({ type: 'probe' }, '*'); } catch (e) {}
|
||||
preElement.dataset.xbFinal = 'true';
|
||||
preElement.dataset.xbHash = originalHash;
|
||||
|
||||
return iframe;
|
||||
} catch (err) {
|
||||
console.error('[iframeRenderer] 渲染失败:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function processCodeBlocks(messageElement, forceFinal = true) {
|
||||
const settings = getSettings();
|
||||
if (!settings.enabled) return;
|
||||
if (settings.renderEnabled === false) return;
|
||||
|
||||
try {
|
||||
const codeBlocks = messageElement.querySelectorAll('pre > code');
|
||||
const ctx = getContext();
|
||||
const lastId = ctx.chat?.length - 1;
|
||||
const mesEl = messageElement.closest('.mes');
|
||||
const mesId = mesEl ? Number(mesEl.getAttribute('mesid')) : null;
|
||||
|
||||
if (isGenerating && mesId === lastId && !forceFinal) return;
|
||||
|
||||
codeBlocks.forEach(codeBlock => {
|
||||
const preElement = codeBlock.parentElement;
|
||||
const should = shouldRenderContentByBlock(codeBlock);
|
||||
const html = codeBlock.textContent || '';
|
||||
const hash = djb2(html);
|
||||
const isFinal = preElement.dataset.xbFinal === 'true';
|
||||
const same = preElement.dataset.xbHash === hash;
|
||||
|
||||
if (isFinal && same) return;
|
||||
|
||||
if (should) {
|
||||
renderHtmlInIframe(html, preElement.parentNode, preElement);
|
||||
} else {
|
||||
preElement.classList.add('xb-show');
|
||||
preElement.removeAttribute('data-xbfinal');
|
||||
preElement.removeAttribute('data-xbhash');
|
||||
preElement.style.display = '';
|
||||
}
|
||||
preElement.dataset.xiaobaixBound = 'true';
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[iframeRenderer] processCodeBlocks 失败:', err);
|
||||
}
|
||||
}
|
||||
|
||||
export function processExistingMessages() {
|
||||
const settings = getSettings();
|
||||
if (!settings.enabled) return;
|
||||
document.querySelectorAll('.mes_text').forEach(el => processCodeBlocks(el, true));
|
||||
try { shrinkRenderedWindowFull(); } catch (e) {}
|
||||
}
|
||||
|
||||
export function processMessageById(messageId, forceFinal = true) {
|
||||
const messageElement = document.querySelector(`div.mes[mesid="${messageId}"] .mes_text`);
|
||||
if (!messageElement) return;
|
||||
processCodeBlocks(messageElement, forceFinal);
|
||||
try { shrinkRenderedWindowForLastMessage(); } catch (e) {}
|
||||
}
|
||||
|
||||
export function invalidateMessage(messageId) {
|
||||
const el = document.querySelector(`div.mes[mesid="${messageId}"] .mes_text`);
|
||||
if (!el) return;
|
||||
el.querySelectorAll('.xiaobaix-iframe-wrapper').forEach(w => {
|
||||
w.querySelectorAll('.xiaobaix-iframe').forEach(ifr => {
|
||||
try { ifr.src = 'about:blank'; } catch (e) {}
|
||||
releaseIframeBlob(ifr);
|
||||
});
|
||||
w.remove();
|
||||
});
|
||||
el.querySelectorAll('pre').forEach(pre => {
|
||||
pre.classList.remove('xb-show');
|
||||
pre.removeAttribute('data-xbfinal');
|
||||
pre.removeAttribute('data-xbhash');
|
||||
delete pre.dataset.xbFinal;
|
||||
delete pre.dataset.xbHash;
|
||||
pre.style.display = '';
|
||||
delete pre.dataset.xiaobaixBound;
|
||||
});
|
||||
}
|
||||
|
||||
export function invalidateAll() {
|
||||
document.querySelectorAll('.xiaobaix-iframe-wrapper').forEach(w => {
|
||||
w.querySelectorAll('.xiaobaix-iframe').forEach(ifr => {
|
||||
try { ifr.src = 'about:blank'; } catch (e) {}
|
||||
releaseIframeBlob(ifr);
|
||||
});
|
||||
w.remove();
|
||||
});
|
||||
document.querySelectorAll('.mes_text pre').forEach(pre => {
|
||||
pre.classList.remove('xb-show');
|
||||
pre.removeAttribute('data-xbfinal');
|
||||
pre.removeAttribute('data-xbhash');
|
||||
delete pre.dataset.xbFinal;
|
||||
delete pre.dataset.xbHash;
|
||||
delete pre.dataset.xiaobaixBound;
|
||||
pre.style.display = '';
|
||||
});
|
||||
clearBlobCaches();
|
||||
winMap.clear();
|
||||
lastHeights = new WeakMap();
|
||||
}
|
||||
|
||||
function shrinkRenderedWindowForLastMessage() {
|
||||
const settings = getSettings();
|
||||
if (!settings.enabled) return;
|
||||
if (settings.renderEnabled === false) return;
|
||||
const max = Number.isFinite(settings.maxRenderedMessages) && settings.maxRenderedMessages > 0
|
||||
? settings.maxRenderedMessages
|
||||
: 0;
|
||||
if (max <= 0) return;
|
||||
const ctx = getContext?.();
|
||||
const chatArr = ctx?.chat;
|
||||
if (!Array.isArray(chatArr) || chatArr.length === 0) return;
|
||||
const lastId = chatArr.length - 1;
|
||||
if (lastId < 0) return;
|
||||
const keepFrom = Math.max(0, lastId - max + 1);
|
||||
const mesList = document.querySelectorAll('div.mes');
|
||||
for (const mes of mesList) {
|
||||
const mesIdAttr = mes.getAttribute('mesid');
|
||||
if (mesIdAttr == null) continue;
|
||||
const mesId = Number(mesIdAttr);
|
||||
if (!Number.isFinite(mesId)) continue;
|
||||
if (mesId >= keepFrom) break;
|
||||
const mesText = mes.querySelector('.mes_text');
|
||||
if (!mesText) continue;
|
||||
mesText.querySelectorAll('.xiaobaix-iframe-wrapper').forEach(w => {
|
||||
w.querySelectorAll('.xiaobaix-iframe').forEach(ifr => {
|
||||
try { ifr.src = 'about:blank'; } catch (e) {}
|
||||
releaseIframeBlob(ifr);
|
||||
});
|
||||
w.remove();
|
||||
});
|
||||
mesText.querySelectorAll('pre[data-xiaobaix-bound="true"]').forEach(pre => {
|
||||
pre.classList.remove('xb-show');
|
||||
pre.removeAttribute('data-xbfinal');
|
||||
pre.removeAttribute('data-xbhash');
|
||||
delete pre.dataset.xbFinal;
|
||||
delete pre.dataset.xbHash;
|
||||
delete pre.dataset.xiaobaixBound;
|
||||
pre.style.display = '';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function shrinkRenderedWindowFull() {
|
||||
const settings = getSettings();
|
||||
if (!settings.enabled) return;
|
||||
if (settings.renderEnabled === false) return;
|
||||
const max = Number.isFinite(settings.maxRenderedMessages) && settings.maxRenderedMessages > 0
|
||||
? settings.maxRenderedMessages
|
||||
: 0;
|
||||
if (max <= 0) return;
|
||||
const ctx = getContext?.();
|
||||
const chatArr = ctx?.chat;
|
||||
if (!Array.isArray(chatArr) || chatArr.length === 0) return;
|
||||
const lastId = chatArr.length - 1;
|
||||
const keepFrom = Math.max(0, lastId - max + 1);
|
||||
const mesList = document.querySelectorAll('div.mes');
|
||||
for (const mes of mesList) {
|
||||
const mesIdAttr = mes.getAttribute('mesid');
|
||||
if (mesIdAttr == null) continue;
|
||||
const mesId = Number(mesIdAttr);
|
||||
if (!Number.isFinite(mesId)) continue;
|
||||
if (mesId >= keepFrom) continue;
|
||||
const mesText = mes.querySelector('.mes_text');
|
||||
if (!mesText) continue;
|
||||
mesText.querySelectorAll('.xiaobaix-iframe-wrapper').forEach(w => {
|
||||
w.querySelectorAll('.xiaobaix-iframe').forEach(ifr => {
|
||||
try { ifr.src = 'about:blank'; } catch (e) {}
|
||||
releaseIframeBlob(ifr);
|
||||
});
|
||||
w.remove();
|
||||
});
|
||||
mesText.querySelectorAll('pre[data-xiaobaix-bound="true"]').forEach(pre => {
|
||||
pre.classList.remove('xb-show');
|
||||
pre.removeAttribute('data-xbfinal');
|
||||
pre.removeAttribute('data-xbhash');
|
||||
delete pre.dataset.xbFinal;
|
||||
delete pre.dataset.xbHash;
|
||||
delete pre.dataset.xiaobaixBound;
|
||||
pre.style.display = '';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let messageListenerBound = false;
|
||||
|
||||
export function initRenderer() {
|
||||
try { xbLog.info(MODULE_ID, 'initRenderer'); } catch {}
|
||||
events.on(event_types.GENERATION_STARTED, () => {
|
||||
isGenerating = true;
|
||||
});
|
||||
|
||||
events.on(event_types.GENERATION_ENDED, () => {
|
||||
isGenerating = false;
|
||||
const ctx = getContext();
|
||||
const lastId = ctx.chat?.length - 1;
|
||||
if (lastId != null && lastId >= 0) {
|
||||
setTimeout(() => {
|
||||
processMessageById(lastId, true);
|
||||
}, 60);
|
||||
}
|
||||
});
|
||||
|
||||
events.on(event_types.MESSAGE_RECEIVED, (data) => {
|
||||
setTimeout(() => {
|
||||
const messageId = typeof data === 'object' ? data.messageId : data;
|
||||
if (messageId != null) {
|
||||
processMessageById(messageId, true);
|
||||
}
|
||||
}, 300);
|
||||
});
|
||||
|
||||
events.on(event_types.MESSAGE_UPDATED, (data) => {
|
||||
const messageId = typeof data === 'object' ? data.messageId : data;
|
||||
if (messageId != null) {
|
||||
processMessageById(messageId, true);
|
||||
}
|
||||
});
|
||||
|
||||
events.on(event_types.MESSAGE_EDITED, (data) => {
|
||||
const messageId = typeof data === 'object' ? data.messageId : data;
|
||||
if (messageId != null) {
|
||||
processMessageById(messageId, true);
|
||||
}
|
||||
});
|
||||
|
||||
events.on(event_types.MESSAGE_DELETED, (data) => {
|
||||
const messageId = typeof data === 'object' ? data.messageId : data;
|
||||
if (messageId != null) {
|
||||
invalidateMessage(messageId);
|
||||
}
|
||||
});
|
||||
|
||||
events.on(event_types.MESSAGE_SWIPED, (data) => {
|
||||
setTimeout(() => {
|
||||
const messageId = typeof data === 'object' ? data.messageId : data;
|
||||
if (messageId != null) {
|
||||
processMessageById(messageId, true);
|
||||
}
|
||||
}, 10);
|
||||
});
|
||||
|
||||
events.on(event_types.USER_MESSAGE_RENDERED, (data) => {
|
||||
setTimeout(() => {
|
||||
const messageId = typeof data === 'object' ? data.messageId : data;
|
||||
if (messageId != null) {
|
||||
processMessageById(messageId, true);
|
||||
}
|
||||
}, 10);
|
||||
});
|
||||
|
||||
events.on(event_types.CHARACTER_MESSAGE_RENDERED, (data) => {
|
||||
setTimeout(() => {
|
||||
const messageId = typeof data === 'object' ? data.messageId : data;
|
||||
if (messageId != null) {
|
||||
processMessageById(messageId, true);
|
||||
}
|
||||
}, 10);
|
||||
});
|
||||
|
||||
events.on(event_types.CHAT_CHANGED, () => {
|
||||
isGenerating = false;
|
||||
invalidateAll();
|
||||
setTimeout(() => {
|
||||
processExistingMessages();
|
||||
}, 100);
|
||||
});
|
||||
|
||||
if (!messageListenerBound) {
|
||||
window.addEventListener('message', handleIframeMessage);
|
||||
messageListenerBound = true;
|
||||
}
|
||||
|
||||
setTimeout(processExistingMessages, 100);
|
||||
}
|
||||
|
||||
export function cleanupRenderer() {
|
||||
try { xbLog.info(MODULE_ID, 'cleanupRenderer'); } catch {}
|
||||
events.cleanup();
|
||||
if (messageListenerBound) {
|
||||
window.removeEventListener('message', handleIframeMessage);
|
||||
messageListenerBound = false;
|
||||
}
|
||||
invalidateAll();
|
||||
isGenerating = false;
|
||||
pendingHeight = null;
|
||||
pendingRec = null;
|
||||
lastApplyTs = 0;
|
||||
}
|
||||
|
||||
export function isCurrentlyGenerating() {
|
||||
return isGenerating;
|
||||
}
|
||||
|
||||
export { shrinkRenderedWindowFull, shrinkRenderedWindowForLastMessage };
|
||||
473
modules/immersive-mode.js
Normal file
473
modules/immersive-mode.js
Normal file
@@ -0,0 +1,473 @@
|
||||
import { extension_settings, getContext } from "../../../../extensions.js";
|
||||
import { saveSettingsDebounced, this_chid, getCurrentChatId } from "../../../../../script.js";
|
||||
import { selected_group } from "../../../../group-chats.js";
|
||||
import { EXT_ID } from "../core/constants.js";
|
||||
import { createModuleEvents, event_types } from "../core/event-manager.js";
|
||||
|
||||
const defaultSettings = {
|
||||
enabled: false,
|
||||
showAllMessages: false,
|
||||
autoJumpOnAI: true
|
||||
};
|
||||
|
||||
const SEL = {
|
||||
chat: '#chat',
|
||||
mes: '#chat .mes',
|
||||
ai: '#chat .mes[is_user="false"][is_system="false"]',
|
||||
user: '#chat .mes[is_user="true"]'
|
||||
};
|
||||
|
||||
const baseEvents = createModuleEvents('immersiveMode');
|
||||
const messageEvents = createModuleEvents('immersiveMode:messages');
|
||||
|
||||
let state = {
|
||||
isActive: false,
|
||||
eventsBound: false,
|
||||
messageEventsBound: false,
|
||||
globalStateHandler: null
|
||||
};
|
||||
|
||||
let observer = null;
|
||||
let resizeObs = null;
|
||||
let resizeObservedEl = null;
|
||||
let recalcT = null;
|
||||
|
||||
const isGlobalEnabled = () => window.isXiaobaixEnabled ?? true;
|
||||
const getSettings = () => extension_settings[EXT_ID].immersive;
|
||||
const isInChat = () => this_chid !== undefined || selected_group || getCurrentChatId() !== undefined;
|
||||
|
||||
function initImmersiveMode() {
|
||||
initSettings();
|
||||
setupEventListeners();
|
||||
if (isGlobalEnabled()) {
|
||||
state.isActive = getSettings().enabled;
|
||||
if (state.isActive) enableImmersiveMode();
|
||||
bindSettingsEvents();
|
||||
}
|
||||
}
|
||||
|
||||
function initSettings() {
|
||||
extension_settings[EXT_ID] ||= {};
|
||||
extension_settings[EXT_ID].immersive ||= structuredClone(defaultSettings);
|
||||
const settings = extension_settings[EXT_ID].immersive;
|
||||
Object.keys(defaultSettings).forEach(k => settings[k] = settings[k] ?? defaultSettings[k]);
|
||||
updateControlState();
|
||||
}
|
||||
|
||||
function setupEventListeners() {
|
||||
state.globalStateHandler = handleGlobalStateChange;
|
||||
baseEvents.on(event_types.CHAT_CHANGED, onChatChanged);
|
||||
document.addEventListener('xiaobaixEnabledChanged', state.globalStateHandler);
|
||||
if (window.registerModuleCleanup) window.registerModuleCleanup('immersiveMode', cleanup);
|
||||
}
|
||||
|
||||
function setupDOMObserver() {
|
||||
if (observer) return;
|
||||
const chatContainer = document.getElementById('chat');
|
||||
if (!chatContainer) return;
|
||||
|
||||
observer = new MutationObserver((mutations) => {
|
||||
if (!state.isActive) return;
|
||||
let hasNewAI = false;
|
||||
|
||||
for (const mutation of mutations) {
|
||||
if (mutation.type === 'childList' && mutation.addedNodes?.length) {
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
if (node.nodeType === 1 && node.classList?.contains('mes')) {
|
||||
processSingleMessage(node);
|
||||
if (node.getAttribute('is_user') === 'false' && node.getAttribute('is_system') === 'false') {
|
||||
hasNewAI = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (hasNewAI) {
|
||||
if (recalcT) clearTimeout(recalcT);
|
||||
recalcT = setTimeout(updateMessageDisplay, 20);
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(chatContainer, { childList: true, subtree: true, characterData: true });
|
||||
}
|
||||
|
||||
function processSingleMessage(mesElement) {
|
||||
const $mes = $(mesElement);
|
||||
const $avatarWrapper = $mes.find('.mesAvatarWrapper');
|
||||
const $chName = $mes.find('.ch_name.flex-container.justifySpaceBetween');
|
||||
const $targetSibling = $chName.find('.flex-container.flex1.alignitemscenter');
|
||||
const $nameText = $mes.find('.name_text');
|
||||
|
||||
if ($avatarWrapper.length && $chName.length && $targetSibling.length &&
|
||||
!$chName.find('.mesAvatarWrapper').length) {
|
||||
$targetSibling.before($avatarWrapper);
|
||||
|
||||
if ($nameText.length && !$nameText.parent().hasClass('xiaobaix-vertical-wrapper')) {
|
||||
const $verticalWrapper = $('<div class="xiaobaix-vertical-wrapper" style="display: flex; flex-direction: column; flex: 1; margin-top: 5px; align-self: stretch; justify-content: space-between;"></div>');
|
||||
const $topGroup = $('<div class="xiaobaix-top-group"></div>');
|
||||
$topGroup.append($nameText.detach(), $targetSibling.detach());
|
||||
$verticalWrapper.append($topGroup);
|
||||
$avatarWrapper.after($verticalWrapper);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateControlState() {
|
||||
const enabled = isGlobalEnabled();
|
||||
$('#xiaobaix_immersive_enabled').prop('disabled', !enabled).toggleClass('disabled-control', !enabled);
|
||||
}
|
||||
|
||||
function bindSettingsEvents() {
|
||||
if (state.eventsBound) return;
|
||||
setTimeout(() => {
|
||||
const checkbox = document.getElementById('xiaobaix_immersive_enabled');
|
||||
if (checkbox && !state.eventsBound) {
|
||||
checkbox.checked = getSettings().enabled;
|
||||
checkbox.addEventListener('change', () => setImmersiveMode(checkbox.checked));
|
||||
state.eventsBound = true;
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function unbindSettingsEvents() {
|
||||
const checkbox = document.getElementById('xiaobaix_immersive_enabled');
|
||||
if (checkbox) {
|
||||
const newCheckbox = checkbox.cloneNode(true);
|
||||
checkbox.parentNode.replaceChild(newCheckbox, checkbox);
|
||||
}
|
||||
state.eventsBound = false;
|
||||
}
|
||||
|
||||
function setImmersiveMode(enabled) {
|
||||
const settings = getSettings();
|
||||
settings.enabled = enabled;
|
||||
state.isActive = enabled;
|
||||
|
||||
const checkbox = document.getElementById('xiaobaix_immersive_enabled');
|
||||
if (checkbox) checkbox.checked = enabled;
|
||||
|
||||
enabled ? enableImmersiveMode() : disableImmersiveMode();
|
||||
if (!enabled) cleanup();
|
||||
saveSettingsDebounced();
|
||||
}
|
||||
|
||||
function toggleImmersiveMode() {
|
||||
if (!isGlobalEnabled()) return;
|
||||
setImmersiveMode(!getSettings().enabled);
|
||||
}
|
||||
|
||||
function bindMessageEvents() {
|
||||
if (state.messageEventsBound) return;
|
||||
|
||||
const refreshOnAI = () => state.isActive && updateMessageDisplay();
|
||||
|
||||
messageEvents.on(event_types.MESSAGE_SENT, () => {});
|
||||
messageEvents.on(event_types.MESSAGE_RECEIVED, refreshOnAI);
|
||||
messageEvents.on(event_types.MESSAGE_DELETED, () => {});
|
||||
messageEvents.on(event_types.MESSAGE_UPDATED, refreshOnAI);
|
||||
messageEvents.on(event_types.MESSAGE_SWIPED, refreshOnAI);
|
||||
if (event_types.GENERATION_STARTED) {
|
||||
messageEvents.on(event_types.GENERATION_STARTED, () => {});
|
||||
}
|
||||
messageEvents.on(event_types.GENERATION_ENDED, refreshOnAI);
|
||||
|
||||
state.messageEventsBound = true;
|
||||
}
|
||||
|
||||
function unbindMessageEvents() {
|
||||
if (!state.messageEventsBound) return;
|
||||
messageEvents.cleanup();
|
||||
state.messageEventsBound = false;
|
||||
}
|
||||
|
||||
function injectImmersiveStyles() {
|
||||
let style = document.getElementById('immersive-style-tag');
|
||||
if (!style) {
|
||||
style = document.createElement('style');
|
||||
style.id = 'immersive-style-tag';
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
style.textContent = `
|
||||
body.immersive-mode.immersive-single #show_more_messages { display: none !important; }
|
||||
`;
|
||||
}
|
||||
|
||||
function applyModeClasses() {
|
||||
const settings = getSettings();
|
||||
$('body')
|
||||
.toggleClass('immersive-single', !settings.showAllMessages)
|
||||
.toggleClass('immersive-all', settings.showAllMessages);
|
||||
}
|
||||
|
||||
function enableImmersiveMode() {
|
||||
if (!isGlobalEnabled()) return;
|
||||
|
||||
injectImmersiveStyles();
|
||||
$('body').addClass('immersive-mode');
|
||||
applyModeClasses();
|
||||
moveAvatarWrappers();
|
||||
bindMessageEvents();
|
||||
updateMessageDisplay();
|
||||
setupDOMObserver();
|
||||
}
|
||||
|
||||
function disableImmersiveMode() {
|
||||
$('body').removeClass('immersive-mode immersive-single immersive-all');
|
||||
restoreAvatarWrappers();
|
||||
$(SEL.mes).show();
|
||||
hideNavigationButtons();
|
||||
$('.swipe_left, .swipeRightBlock').show();
|
||||
unbindMessageEvents();
|
||||
detachResizeObserver();
|
||||
destroyDOMObserver();
|
||||
}
|
||||
|
||||
function moveAvatarWrappers() {
|
||||
$(SEL.mes).each(function() { processSingleMessage(this); });
|
||||
}
|
||||
|
||||
function restoreAvatarWrappers() {
|
||||
$(SEL.mes).each(function() {
|
||||
const $mes = $(this);
|
||||
const $avatarWrapper = $mes.find('.mesAvatarWrapper');
|
||||
const $verticalWrapper = $mes.find('.xiaobaix-vertical-wrapper');
|
||||
|
||||
if ($avatarWrapper.length && !$avatarWrapper.parent().hasClass('mes')) {
|
||||
$mes.prepend($avatarWrapper);
|
||||
}
|
||||
|
||||
if ($verticalWrapper.length) {
|
||||
const $chName = $mes.find('.ch_name.flex-container.justifySpaceBetween');
|
||||
const $flexContainer = $mes.find('.flex-container.flex1.alignitemscenter');
|
||||
const $nameText = $mes.find('.name_text');
|
||||
|
||||
if ($flexContainer.length && $chName.length) $chName.prepend($flexContainer);
|
||||
if ($nameText.length) {
|
||||
const $originalContainer = $mes.find('.flex-container.alignItemsBaseline');
|
||||
if ($originalContainer.length) $originalContainer.prepend($nameText);
|
||||
}
|
||||
$verticalWrapper.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function findLastAIMessage() {
|
||||
const $aiMessages = $(SEL.ai);
|
||||
return $aiMessages.length ? $($aiMessages.last()) : null;
|
||||
}
|
||||
|
||||
function showSingleModeMessages() {
|
||||
const $messages = $(SEL.mes);
|
||||
if (!$messages.length) return;
|
||||
|
||||
$messages.hide();
|
||||
|
||||
const $targetAI = findLastAIMessage();
|
||||
if ($targetAI?.length) {
|
||||
$targetAI.show();
|
||||
|
||||
const $prevUser = $targetAI.prevAll('.mes[is_user="true"]').first();
|
||||
if ($prevUser.length) {
|
||||
$prevUser.show();
|
||||
}
|
||||
|
||||
$targetAI.nextAll('.mes').show();
|
||||
|
||||
addNavigationToLastTwoMessages();
|
||||
}
|
||||
}
|
||||
|
||||
function addNavigationToLastTwoMessages() {
|
||||
hideNavigationButtons();
|
||||
|
||||
const $visibleMessages = $(`${SEL.mes}:visible`);
|
||||
const messageCount = $visibleMessages.length;
|
||||
|
||||
if (messageCount >= 2) {
|
||||
const $lastTwo = $visibleMessages.slice(-2);
|
||||
$lastTwo.each(function() {
|
||||
showNavigationButtons($(this));
|
||||
updateSwipesCounter($(this));
|
||||
});
|
||||
} else if (messageCount === 1) {
|
||||
const $single = $visibleMessages.last();
|
||||
showNavigationButtons($single);
|
||||
updateSwipesCounter($single);
|
||||
}
|
||||
}
|
||||
|
||||
function updateMessageDisplay() {
|
||||
if (!state.isActive) return;
|
||||
|
||||
const $messages = $(SEL.mes);
|
||||
if (!$messages.length) return;
|
||||
|
||||
const settings = getSettings();
|
||||
if (settings.showAllMessages) {
|
||||
$messages.show();
|
||||
addNavigationToLastTwoMessages();
|
||||
} else {
|
||||
showSingleModeMessages();
|
||||
}
|
||||
}
|
||||
|
||||
function showNavigationButtons($targetMes) {
|
||||
if (!isInChat()) return;
|
||||
|
||||
$targetMes.find('.immersive-navigation').remove();
|
||||
|
||||
const $verticalWrapper = $targetMes.find('.xiaobaix-vertical-wrapper');
|
||||
if (!$verticalWrapper.length) return;
|
||||
|
||||
const settings = getSettings();
|
||||
const buttonText = settings.showAllMessages ? '切换:锁定单回合' : '切换:传统多楼层';
|
||||
const navigationHtml = `
|
||||
<div class="immersive-navigation">
|
||||
<button class="immersive-nav-btn immersive-swipe-left" title="左滑消息">
|
||||
<i class="fa-solid fa-chevron-left"></i>
|
||||
</button>
|
||||
<button class="immersive-nav-btn immersive-toggle" title="切换显示模式">
|
||||
|${buttonText}|
|
||||
</button>
|
||||
<button class="immersive-nav-btn immersive-swipe-right" title="右滑消息"
|
||||
style="display: flex; align-items: center; gap: 1px;">
|
||||
<div class="swipes-counter" style="opacity: 0.7; justify-content: flex-end; margin-bottom: 0 !important;">
|
||||
1​/​1
|
||||
</div>
|
||||
<span><i class="fa-solid fa-chevron-right"></i></span>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
$verticalWrapper.append(navigationHtml);
|
||||
|
||||
$targetMes.find('.immersive-swipe-left').on('click', () => handleSwipe('.swipe_left', $targetMes));
|
||||
$targetMes.find('.immersive-toggle').on('click', toggleDisplayMode);
|
||||
$targetMes.find('.immersive-swipe-right').on('click', () => handleSwipe('.swipe_right', $targetMes));
|
||||
}
|
||||
|
||||
const hideNavigationButtons = () => $('.immersive-navigation').remove();
|
||||
|
||||
function updateSwipesCounter($targetMes) {
|
||||
if (!state.isActive) return;
|
||||
|
||||
const $swipesCounter = $targetMes.find('.swipes-counter');
|
||||
if (!$swipesCounter.length) return;
|
||||
|
||||
const mesId = $targetMes.attr('mesid');
|
||||
|
||||
if (mesId !== undefined) {
|
||||
try {
|
||||
const chat = getContext().chat;
|
||||
const mesIndex = parseInt(mesId);
|
||||
const message = chat?.[mesIndex];
|
||||
if (message?.swipes) {
|
||||
const currentSwipeIndex = message.swipe_id || 0;
|
||||
$swipesCounter.html(`${currentSwipeIndex + 1}​/​${message.swipes.length}`);
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
$swipesCounter.html('1​/​1');
|
||||
}
|
||||
|
||||
function toggleDisplayMode() {
|
||||
if (!state.isActive) return;
|
||||
|
||||
const settings = getSettings();
|
||||
settings.showAllMessages = !settings.showAllMessages;
|
||||
applyModeClasses();
|
||||
updateMessageDisplay();
|
||||
saveSettingsDebounced();
|
||||
}
|
||||
|
||||
function handleSwipe(swipeSelector, $targetMes) {
|
||||
if (!state.isActive) return;
|
||||
|
||||
const $btn = $targetMes.find(swipeSelector);
|
||||
if ($btn.length) {
|
||||
$btn.click();
|
||||
setTimeout(() => {
|
||||
updateSwipesCounter($targetMes);
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
function handleGlobalStateChange(event) {
|
||||
const enabled = event.detail.enabled;
|
||||
updateControlState();
|
||||
|
||||
if (enabled) {
|
||||
const settings = getSettings();
|
||||
state.isActive = settings.enabled;
|
||||
if (state.isActive) enableImmersiveMode();
|
||||
bindSettingsEvents();
|
||||
setTimeout(() => {
|
||||
const checkbox = document.getElementById('xiaobaix_immersive_enabled');
|
||||
if (checkbox) checkbox.checked = settings.enabled;
|
||||
}, 100);
|
||||
} else {
|
||||
if (state.isActive) disableImmersiveMode();
|
||||
state.isActive = false;
|
||||
unbindSettingsEvents();
|
||||
}
|
||||
}
|
||||
|
||||
function onChatChanged() {
|
||||
if (!isGlobalEnabled() || !state.isActive) return;
|
||||
|
||||
setTimeout(() => {
|
||||
moveAvatarWrappers();
|
||||
updateMessageDisplay();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (state.isActive) disableImmersiveMode();
|
||||
destroyDOMObserver();
|
||||
|
||||
baseEvents.cleanup();
|
||||
|
||||
if (state.globalStateHandler) {
|
||||
document.removeEventListener('xiaobaixEnabledChanged', state.globalStateHandler);
|
||||
}
|
||||
|
||||
unbindMessageEvents();
|
||||
detachResizeObserver();
|
||||
|
||||
state = {
|
||||
isActive: false,
|
||||
eventsBound: false,
|
||||
messageEventsBound: false,
|
||||
globalStateHandler: null
|
||||
};
|
||||
}
|
||||
|
||||
function attachResizeObserverTo(el) {
|
||||
if (!el) return;
|
||||
|
||||
if (!resizeObs) {
|
||||
resizeObs = new ResizeObserver(() => {});
|
||||
}
|
||||
|
||||
if (resizeObservedEl) detachResizeObserver();
|
||||
resizeObservedEl = el;
|
||||
resizeObs.observe(el);
|
||||
}
|
||||
|
||||
function detachResizeObserver() {
|
||||
if (resizeObs && resizeObservedEl) {
|
||||
resizeObs.unobserve(resizeObservedEl);
|
||||
}
|
||||
resizeObservedEl = null;
|
||||
}
|
||||
|
||||
function destroyDOMObserver() {
|
||||
if (observer) {
|
||||
observer.disconnect();
|
||||
observer = null;
|
||||
}
|
||||
}
|
||||
|
||||
export { initImmersiveMode, toggleImmersiveMode };
|
||||
650
modules/message-preview.js
Normal file
650
modules/message-preview.js
Normal file
@@ -0,0 +1,650 @@
|
||||
import { extension_settings, getContext } from "../../../../extensions.js";
|
||||
import { saveSettingsDebounced, eventSource, event_types } from "../../../../../script.js";
|
||||
import { EXT_ID } from "../core/constants.js";
|
||||
|
||||
const C = { MAX_HISTORY: 10, CHECK: 200, DEBOUNCE: 300, CLEAN: 300000, TARGET: "/api/backends/chat-completions/generate", TIMEOUT: 30, ASSOC_DELAY: 1000, REQ_WINDOW: 30000 };
|
||||
const S = { active: false, isPreview: false, isLong: false, isHistoryUiBound: false, previewData: null, previewIds: new Set(), interceptedIds: [], history: [], listeners: [], resolve: null, reject: null, sendBtnWasDisabled: false, longPressTimer: null, longPressDelay: 1000, chatLenBefore: 0, restoreLong: null, cleanTimer: null, previewAbort: null, tailAPI: null, genEndedOff: null, cleanupFallback: null, pendingPurge: false };
|
||||
|
||||
const $q = (sel) => $(sel);
|
||||
const ON = (e, c) => eventSource.on(e, c);
|
||||
const OFF = (e, c) => eventSource.removeListener(e, c);
|
||||
const now = () => Date.now();
|
||||
const geEnabled = () => { try { return ("isXiaobaixEnabled" in window) ? !!window.isXiaobaixEnabled : true; } catch { return true; } };
|
||||
const debounce = (fn, w) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), w); }; };
|
||||
const safeJson = (t) => { try { return JSON.parse(t); } catch { return null; } };
|
||||
|
||||
const readText = async (b) => { try { if (!b) return ""; if (typeof b === "string") return b; if (b instanceof Blob) return await b.text(); if (b instanceof URLSearchParams) return b.toString(); if (typeof b === "object" && typeof b.text === "function") return await b.text(); } catch { } return ""; };
|
||||
|
||||
function isSafeBody(body) { if (!body) return true; return (typeof body === "string" || body instanceof Blob || body instanceof URLSearchParams || body instanceof ArrayBuffer || ArrayBuffer.isView(body) || (typeof FormData !== "undefined" && body instanceof FormData)); }
|
||||
|
||||
async function safeReadBodyFromInput(input, options) { try { if (input instanceof Request) return await readText(input.clone()); const body = options?.body; if (!isSafeBody(body)) return ""; return await readText(body); } catch { return ""; } }
|
||||
|
||||
const isGen = (u) => String(u || "").includes(C.TARGET);
|
||||
const isTarget = async (input, opt = {}) => { try { const url = input instanceof Request ? input.url : input; if (!isGen(url)) return false; const text = await safeReadBodyFromInput(input, opt); return text ? text.includes('"messages"') : true; } catch { return input instanceof Request ? isGen(input.url) : isGen(input); } };
|
||||
const getSettings = () => { const d = extension_settings[EXT_ID] || (extension_settings[EXT_ID] = {}); d.preview = d.preview || { enabled: false, timeoutSeconds: C.TIMEOUT }; d.recorded = d.recorded || { enabled: true }; d.preview.timeoutSeconds = C.TIMEOUT; return d; };
|
||||
|
||||
function injectPreviewModalStyles() {
|
||||
if (document.getElementById('message-preview-modal-styles')) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = 'message-preview-modal-styles';
|
||||
style.textContent = `
|
||||
.mp-overlay{position:fixed;inset:0;background:none;z-index:9999;display:flex;align-items:center;justify-content:center;pointer-events:none}
|
||||
.mp-modal{
|
||||
width:clamp(360px,55vw,860px);
|
||||
max-width:95vw;
|
||||
background:var(--SmartThemeBlurTintColor);
|
||||
border:2px solid var(--SmartThemeBorderColor);
|
||||
border-radius:10px;
|
||||
box-shadow:0 8px 16px var(--SmartThemeShadowColor);
|
||||
pointer-events:auto;
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
height:80vh;
|
||||
max-height:calc(100vh - 60px);
|
||||
resize:both;
|
||||
overflow:hidden;
|
||||
}
|
||||
.mp-header{display:flex;justify-content:space-between;padding:10px 14px;border-bottom:1px solid var(--SmartThemeBorderColor);font-weight:600;cursor:move;flex-shrink:0}
|
||||
.mp-body{height:60vh;overflow:auto;padding:10px;flex:1;min-height:160px}
|
||||
.mp-footer{display:flex;gap:8px;justify-content:flex-end;padding:12px 14px;border-top:1px solid var(--SmartThemeBorderColor);flex-shrink:0}
|
||||
.mp-close{cursor:pointer}
|
||||
.mp-btn{cursor:pointer;border:1px solid var(--SmartThemeBorderColor);background:var(--SmartThemeBlurTintColor);padding:6px 10px;border-radius:6px}
|
||||
.mp-search-input{padding:4px 8px;border:1px solid var(--SmartThemeBorderColor);border-radius:4px;background:var(--SmartThemeShadowColor);color:inherit;font-size:12px;width:120px}
|
||||
.mp-search-btn{padding:4px 6px;font-size:12px;min-width:24px;text-align:center}
|
||||
.mp-search-info{font-size:12px;opacity:.8;white-space:nowrap}
|
||||
.message-preview-container{height:100%}
|
||||
.message-preview-content-box{height:100%;overflow:auto}
|
||||
.mp-highlight{background-color:yellow;color:black;padding:1px 2px;border-radius:2px}
|
||||
.mp-highlight.current{background-color:orange;font-weight:bold}
|
||||
@media (max-width:999px){
|
||||
.mp-overlay{position:absolute;inset:0;align-items:flex-start}
|
||||
.mp-modal{width:100%;max-width:100%;max-height:100%;margin:0;border-radius:10px 10px 0 0;height:100vh;resize:none}
|
||||
.mp-header{padding:8px 14px}
|
||||
.mp-body{padding:8px}
|
||||
.mp-footer{padding:8px 14px;flex-wrap:wrap;gap:6px}
|
||||
.mp-search-input{width:150px}
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function setupModalDrag(modal, overlay, header) {
|
||||
modal.style.position = 'absolute';
|
||||
modal.style.left = '50%';
|
||||
modal.style.top = '50%';
|
||||
modal.style.transform = 'translate(-50%, -50%)';
|
||||
|
||||
let dragging = false, sx = 0, sy = 0, sl = 0, st = 0;
|
||||
|
||||
function onDown(e) {
|
||||
if (!(e instanceof PointerEvent) || e.button !== 0) return;
|
||||
dragging = true;
|
||||
const overlayRect = overlay.getBoundingClientRect();
|
||||
const rect = modal.getBoundingClientRect();
|
||||
modal.style.left = (rect.left - overlayRect.left) + 'px';
|
||||
modal.style.top = (rect.top - overlayRect.top) + 'px';
|
||||
modal.style.transform = '';
|
||||
sx = e.clientX; sy = e.clientY;
|
||||
sl = parseFloat(modal.style.left) || 0;
|
||||
st = parseFloat(modal.style.top) || 0;
|
||||
window.addEventListener('pointermove', onMove, { passive: true });
|
||||
window.addEventListener('pointerup', onUp, { once: true });
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function onMove(e) {
|
||||
if (!dragging) return;
|
||||
const dx = e.clientX - sx, dy = e.clientY - sy;
|
||||
let nl = sl + dx, nt = st + dy;
|
||||
const maxLeft = (overlay.clientWidth || overlay.getBoundingClientRect().width) - modal.offsetWidth;
|
||||
const maxTop = (overlay.clientHeight || overlay.getBoundingClientRect().height) - modal.offsetHeight;
|
||||
nl = Math.max(0, Math.min(maxLeft, nl));
|
||||
nt = Math.max(0, Math.min(maxTop, nt));
|
||||
modal.style.left = nl + 'px';
|
||||
modal.style.top = nt + 'px';
|
||||
}
|
||||
|
||||
function onUp() {
|
||||
dragging = false;
|
||||
window.removeEventListener('pointermove', onMove);
|
||||
}
|
||||
|
||||
header.addEventListener('pointerdown', onDown);
|
||||
}
|
||||
|
||||
function createMovableModal(title, content) {
|
||||
injectPreviewModalStyles();
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'mp-overlay';
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'mp-modal';
|
||||
const header = document.createElement('div');
|
||||
header.className = 'mp-header';
|
||||
header.innerHTML = `<span>${title}</span><span class="mp-close">✕</span>`;
|
||||
const body = document.createElement('div');
|
||||
body.className = 'mp-body';
|
||||
body.innerHTML = content;
|
||||
const footer = document.createElement('div');
|
||||
footer.className = 'mp-footer';
|
||||
footer.innerHTML = `
|
||||
<input type="text" class="mp-search-input" placeholder="搜索..." />
|
||||
<button class="mp-btn mp-search-btn" id="mp-search-prev">↑</button>
|
||||
<button class="mp-btn mp-search-btn" id="mp-search-next">↓</button>
|
||||
<span class="mp-search-info" id="mp-search-info"></span>
|
||||
<button class="mp-btn" id="mp-toggle-format">切换原始格式</button>
|
||||
<button class="mp-btn" id="mp-focus-search">搜索</button>
|
||||
<button class="mp-btn" id="mp-close">关闭</button>
|
||||
`;
|
||||
modal.appendChild(header);
|
||||
modal.appendChild(body);
|
||||
modal.appendChild(footer);
|
||||
overlay.appendChild(modal);
|
||||
setupModalDrag(modal, overlay, header);
|
||||
|
||||
let searchResults = [];
|
||||
let currentIndex = -1;
|
||||
const searchInput = footer.querySelector('.mp-search-input');
|
||||
const searchInfo = footer.querySelector('#mp-search-info');
|
||||
const prevBtn = footer.querySelector('#mp-search-prev');
|
||||
const nextBtn = footer.querySelector('#mp-search-next');
|
||||
|
||||
function clearHighlights() { body.querySelectorAll('.mp-highlight').forEach(el => { el.outerHTML = el.innerHTML; }); }
|
||||
function performSearch(query) {
|
||||
clearHighlights();
|
||||
searchResults = [];
|
||||
currentIndex = -1;
|
||||
if (!query.trim()) { searchInfo.textContent = ''; return; }
|
||||
const walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT, null, false);
|
||||
const nodes = [];
|
||||
let node;
|
||||
while (node = walker.nextNode()) { nodes.push(node); }
|
||||
const regex = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
|
||||
nodes.forEach(textNode => {
|
||||
const text = textNode.textContent;
|
||||
if (!text || !regex.test(text)) return;
|
||||
let html = text;
|
||||
let offset = 0;
|
||||
regex.lastIndex = 0;
|
||||
const matches = [...text.matchAll(regex)];
|
||||
matches.forEach((m) => {
|
||||
const start = m.index + offset;
|
||||
const end = start + m[0].length;
|
||||
const before = html.slice(0, start);
|
||||
const mid = html.slice(start, end);
|
||||
const after = html.slice(end);
|
||||
const span = `<span class="mp-highlight" data-search-index="${searchResults.length}">${mid}</span>`;
|
||||
html = before + span + after;
|
||||
offset += span.length - m[0].length;
|
||||
searchResults.push({});
|
||||
});
|
||||
const parent = textNode.parentElement;
|
||||
parent.innerHTML = parent.innerHTML.replace(text, html);
|
||||
});
|
||||
updateSearchInfo();
|
||||
if (searchResults.length > 0) { currentIndex = 0; highlightCurrent(); }
|
||||
}
|
||||
function updateSearchInfo() { if (!searchResults.length) searchInfo.textContent = searchInput.value.trim() ? '无结果' : ''; else searchInfo.textContent = `${currentIndex + 1}/${searchResults.length}`; }
|
||||
function highlightCurrent() {
|
||||
body.querySelectorAll('.mp-highlight.current').forEach(el => el.classList.remove('current'));
|
||||
if (currentIndex >= 0 && currentIndex < searchResults.length) {
|
||||
const el = body.querySelector(`.mp-highlight[data-search-index="${currentIndex}"]`);
|
||||
if (el) { el.classList.add('current'); el.scrollIntoView({ behavior: 'smooth', block: 'center' }); }
|
||||
}
|
||||
}
|
||||
function navigateSearch(direction) {
|
||||
if (!searchResults.length) return;
|
||||
if (direction === 'next') currentIndex = (currentIndex + 1) % searchResults.length;
|
||||
else currentIndex = currentIndex <= 0 ? searchResults.length - 1 : currentIndex - 1;
|
||||
updateSearchInfo();
|
||||
highlightCurrent();
|
||||
}
|
||||
let searchTimeout;
|
||||
searchInput.addEventListener('input', (e) => { clearTimeout(searchTimeout); searchTimeout = setTimeout(() => performSearch(e.target.value), 250); });
|
||||
searchInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); if (e.shiftKey) navigateSearch('prev'); else navigateSearch('next'); } else if (e.key === 'Escape') { searchInput.value = ''; performSearch(''); } });
|
||||
prevBtn.addEventListener('click', () => navigateSearch('prev'));
|
||||
nextBtn.addEventListener('click', () => navigateSearch('next'));
|
||||
footer.querySelector('#mp-focus-search')?.addEventListener('click', () => { searchInput.focus(); if (searchInput.value) navigateSearch('next'); });
|
||||
|
||||
const close = () => overlay.remove();
|
||||
header.querySelector('.mp-close').addEventListener('click', close);
|
||||
footer.querySelector('#mp-close').addEventListener('click', close);
|
||||
footer.querySelector('#mp-toggle-format').addEventListener('click', (e) => {
|
||||
const box = body.querySelector(".message-preview-content-box");
|
||||
const f = box?.querySelector(".mp-state-formatted");
|
||||
const r = box?.querySelector(".mp-state-raw");
|
||||
if (!(f && r)) return;
|
||||
const showRaw = r.style.display === "none";
|
||||
r.style.display = showRaw ? "block" : "none";
|
||||
f.style.display = showRaw ? "none" : "block";
|
||||
e.currentTarget.textContent = showRaw ? "切换整理格式" : "切换原始格式";
|
||||
searchInput.value = "";
|
||||
clearHighlights();
|
||||
searchInfo.textContent = "";
|
||||
searchResults = [];
|
||||
currentIndex = -1;
|
||||
});
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
return { overlay, modal, body, close };
|
||||
}
|
||||
|
||||
const MIRROR = { MERGE: "merge", MERGE_TOOLS: "merge_tools", SEMI: "semi", SEMI_TOOLS: "semi_tools", STRICT: "strict", STRICT_TOOLS: "strict_tools", SINGLE: "single" };
|
||||
const roleMap = { system: { label: "SYSTEM:", color: "#F7E3DA" }, user: { label: "USER:", color: "#F0ADA7" }, assistant: { label: "ASSISTANT:", color: "#6BB2CC" } };
|
||||
const colorXml = (t) => (typeof t === "string" ? t.replace(/<([^>]+)>/g, '<span style="color:#999;font-weight:bold;"><$1></span>') : t);
|
||||
const getNames = (req) => { const n = { charName: String(req?.char_name || ""), userName: String(req?.user_name || ""), groupNames: Array.isArray(req?.group_names) ? req.group_names.map(String) : [] }; n.startsWithGroupName = (m) => n.groupNames.some((g) => String(m || "").startsWith(`${g}: `)); return n; };
|
||||
const toText = (m) => { const c = m?.content; if (typeof c === "string") return c; if (Array.isArray(c)) return c.map((p) => p?.type === "text" ? String(p.text || "") : p?.type === "image_url" ? "[image]" : p?.type === "video_url" ? "[video]" : typeof p === "string" ? p : (typeof p?.content === "string" ? p.content : "")).filter(Boolean).join("\n\n"); return String(c || ""); };
|
||||
const applyName = (m, n) => { const { role, name } = m; let t = toText(m); if (role === "system" && name === "example_assistant") { if (n.charName && !t.startsWith(`${n.charName}: `) && !n.startsWithGroupName(t)) t = `${n.charName}: ${t}`; } else if (role === "system" && name === "example_user") { if (n.userName && !t.startsWith(`${n.userName}: `)) t = `${n.userName}: ${t}`; } else if (name && role !== "system" && !t.startsWith(`${name}: `)) t = `${name}: ${t}`; return { ...m, content: t, name: undefined }; };
|
||||
function mergeMessages(messages, names, { strict = false, placeholders = false, single = false, tools = false } = {}) {
|
||||
if (!Array.isArray(messages)) return [];
|
||||
let mapped = messages.map((m) => applyName({ ...m }, names)).map((x) => { const m = { ...x }; if (!tools) { if (m.role === "tool") m.role = "user"; delete m.tool_calls; delete m.tool_call_id; } if (single) { if (m.role === "assistant") { const t = String(m.content || ""); if (names.charName && !t.startsWith(`${names.charName}: `) && !names.startsWithGroupName(t)) m.content = `${names.charName}: ${t}`; } if (m.role === "user") { const t = String(m.content || ""); if (names.userName && !t.startsWith(`${names.userName}: `)) m.content = `${names.userName}: ${t}`; } m.role = "user"; } return m; });
|
||||
const squash = (arr) => { const out = []; for (const m of arr) { if (out.length && out[out.length - 1].role === m.role && String(m.content || "").length && m.role !== "tool") out[out.length - 1].content += `\n\n${m.content}`; else out.push(m); } return out; };
|
||||
let sq = squash(mapped);
|
||||
if (strict) { for (let i = 0; i < sq.length; i++) if (i > 0 && sq[i].role === "system") sq[i].role = "user"; if (placeholders) { if (!sq.length) sq.push({ role: "user", content: "[Start a new chat]" }); else if (sq[0].role === "system" && (sq.length === 1 || sq[1].role !== "user")) sq.splice(1, 0, { role: "user", content: "[Start a new chat]" }); else if (sq[0].role !== "system" && sq[0].role !== "user") sq.unshift({ role: "user", content: "[Start a new chat]" }); } return squash(sq); }
|
||||
if (!sq.length) sq.push({ role: "user", content: "[Start a new chat]" });
|
||||
return sq;
|
||||
}
|
||||
function mirror(requestData) {
|
||||
try {
|
||||
let type = String(requestData?.custom_prompt_post_processing || "").toLowerCase();
|
||||
const source = String(requestData?.chat_completion_source || "").toLowerCase();
|
||||
if (source === "perplexity") type = MIRROR.STRICT;
|
||||
const names = getNames(requestData || {}), src = Array.isArray(requestData?.messages) ? JSON.parse(JSON.stringify(requestData.messages)) : [];
|
||||
const mk = (o) => mergeMessages(src, names, o);
|
||||
switch (type) {
|
||||
case MIRROR.MERGE: return mk({ strict: false });
|
||||
case MIRROR.MERGE_TOOLS: return mk({ strict: false, tools: true });
|
||||
case MIRROR.SEMI: return mk({ strict: true });
|
||||
case MIRROR.SEMI_TOOLS: return mk({ strict: true, tools: true });
|
||||
case MIRROR.STRICT: return mk({ strict: true, placeholders: true });
|
||||
case MIRROR.STRICT_TOOLS: return mk({ strict: true, placeholders: true, tools: true });
|
||||
case MIRROR.SINGLE: return mk({ strict: true, single: true });
|
||||
default: return src;
|
||||
}
|
||||
} catch { return Array.isArray(requestData?.messages) ? requestData.messages : []; }
|
||||
}
|
||||
const finalMsgs = (d) => { try { if (d?.requestData?.messages) return mirror(d.requestData); if (Array.isArray(d?.messages)) return d.messages; return []; } catch { return Array.isArray(d?.messages) ? d.messages : []; } };
|
||||
const formatPreview = (d) => {
|
||||
const msgs = finalMsgs(d);
|
||||
let out = `↓酒馆日志↓(${msgs.length})\n${"-".repeat(30)}\n`;
|
||||
msgs.forEach((m, i) => {
|
||||
const txt = m.content || "";
|
||||
const rm = roleMap[m.role] || { label: `${String(m.role || "").toUpperCase()}:`, color: "#FFF" };
|
||||
out += `<div style="color:${rm.color};font-weight:bold;margin-top:${i ? "15px" : "0"};">${rm.label}</div>`;
|
||||
out += /<[^>]+>/g.test(txt) ? `<pre style="white-space:pre-wrap;margin:5px 0;color:${rm.color};">${colorXml(txt)}</pre>` : `<div style="margin:5px 0;color:${rm.color};white-space:pre-wrap;">${txt}</div>`;
|
||||
});
|
||||
return out;
|
||||
};
|
||||
const stripTop = (o) => { try { if (!o || typeof o !== "object") return o; if (Array.isArray(o)) return o; const messages = Array.isArray(o.messages) ? JSON.parse(JSON.stringify(o.messages)) : undefined; return typeof messages !== "undefined" ? { messages } : {}; } catch { return {}; } };
|
||||
const formatRaw = (d) => { try { const hasReq = Array.isArray(d?.requestData?.messages), hasMsgs = !hasReq && Array.isArray(d?.messages); let obj; if (hasReq) { const req = JSON.parse(JSON.stringify(d.requestData)); try { req.messages = mirror(req); } catch { } obj = req; } else if (hasMsgs) { const fake = { ...(d || {}), messages: d.messages }; let mm = null; try { mm = mirror(fake); } catch { } obj = { ...(d || {}), messages: mm || d.messages }; } else obj = d?.requestData ?? d; obj = stripTop(obj); return colorXml(JSON.stringify(obj, null, 2)); } catch { try { return colorXml(String(d)); } catch { return ""; } } };
|
||||
const buildPreviewHtml = (d) => { const formatted = formatPreview(d), raw = formatRaw(d); return `<div class="message-preview-container"><div class="message-preview-content-box"><div class="mp-state-formatted">${formatted}</div><pre class="mp-state-raw" style="display:none;">${raw}</pre></div></div>`; };
|
||||
const openPopup = async (html, title) => { createMovableModal(title, html); };
|
||||
const displayPreview = async (d) => { try { await openPopup(buildPreviewHtml(d), "消息拦截"); } catch { toastr.error("显示拦截失败"); } };
|
||||
|
||||
const pushHistory = (r) => { S.history.unshift(r); if (S.history.length > C.MAX_HISTORY) S.history.length = C.MAX_HISTORY; };
|
||||
const extractUser = (ms) => { if (!Array.isArray(ms)) return ""; for (let i = ms.length - 1; i >= 0; i--) if (ms[i]?.role === "user") return ms[i].content || ""; return ""; };
|
||||
|
||||
async function recordReal(input, options) {
|
||||
try {
|
||||
const url = input instanceof Request ? input.url : input;
|
||||
const body = await safeReadBodyFromInput(input, options);
|
||||
if (!body) return;
|
||||
const data = safeJson(body) || {}, ctx = getContext();
|
||||
pushHistory({ url, method: options?.method || (input instanceof Request ? input.method : "POST"), requestData: data, messages: data.messages || [], model: data.model || "Unknown", timestamp: now(), messageId: ctx.chat?.length || 0, characterName: ctx.characters?.[ctx.characterId]?.name || "Unknown", userInput: extractUser(data.messages || []), isRealRequest: true });
|
||||
setTimeout(() => { if (S.history[0] && !S.history[0].associatedMessageId) S.history[0].associatedMessageId = ctx.chat?.length || 0; }, C.ASSOC_DELAY);
|
||||
} catch { }
|
||||
}
|
||||
|
||||
const findRec = (id) => {
|
||||
if (!S.history.length) return null;
|
||||
const preds = [(r) => r.associatedMessageId === id, (r) => r.messageId === id, (r) => r.messageId === id - 1, (r) => Math.abs(r.messageId - id) <= 1];
|
||||
for (const p of preds) { const m = S.history.find(p); if (m) return m; }
|
||||
const cs = S.history.filter((r) => r.messageId <= id + 2);
|
||||
return cs.length ? cs.sort((a, b) => b.messageId - a.messageId)[0] : S.history[0];
|
||||
};
|
||||
|
||||
// Improved purgePreviewArtifacts - follows SillyTavern's batch delete pattern
|
||||
async function purgePreviewArtifacts() {
|
||||
try {
|
||||
if (!S.pendingPurge) return;
|
||||
S.pendingPurge = false;
|
||||
const ctx = getContext();
|
||||
const chat = Array.isArray(ctx.chat) ? ctx.chat : [];
|
||||
const start = Math.max(0, Number(S.chatLenBefore) || 0);
|
||||
if (start >= chat.length) return;
|
||||
|
||||
// 1. Remove DOM elements (following SillyTavern's pattern from #dialogue_del_mes_ok)
|
||||
const $chat = $('#chat');
|
||||
$chat.find(`.mes[mesid="${start}"]`).nextAll('.mes').addBack().remove();
|
||||
|
||||
// 2. Truncate chat array
|
||||
chat.length = start;
|
||||
|
||||
// 3. Update last_mes class
|
||||
$('#chat .mes').removeClass('last_mes');
|
||||
$('#chat .mes').last().addClass('last_mes');
|
||||
|
||||
// 4. Save chat and emit MESSAGE_DELETED event (critical for other plugins)
|
||||
ctx.saveChat?.();
|
||||
await eventSource.emit(event_types.MESSAGE_DELETED, start);
|
||||
} catch (e) {
|
||||
console.error('[message-preview] purgePreviewArtifacts error', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
function oneShotOnLast(ev, handler) {
|
||||
const wrapped = (...args) => {
|
||||
try { handler(...args); } finally { off(); }
|
||||
};
|
||||
let off = () => { };
|
||||
if (typeof eventSource.makeLast === "function") {
|
||||
eventSource.makeLast(ev, wrapped);
|
||||
off = () => {
|
||||
try { eventSource.removeListener?.(ev, wrapped); } catch { }
|
||||
try { eventSource.off?.(ev, wrapped); } catch { }
|
||||
};
|
||||
} else if (S.tailAPI?.onLast) {
|
||||
const disposer = S.tailAPI.onLast(ev, wrapped);
|
||||
off = () => { try { disposer?.(); } catch { } };
|
||||
} else {
|
||||
eventSource.on(ev, wrapped);
|
||||
off = () => { try { eventSource.removeListener?.(ev, wrapped); } catch { } };
|
||||
}
|
||||
return off;
|
||||
}
|
||||
|
||||
function installEventSourceTail(es) {
|
||||
if (!es || es.__lw_tailInstalled) return es?.__lw_tailAPI || null;
|
||||
const SYM = { MW_STACK: Symbol.for("lwbox.es.emitMiddlewareStack"), BASE: Symbol.for("lwbox.es.emitBase"), ORIG_DESC: Symbol.for("lwbox.es.emit.origDesc"), COMPOSED: Symbol.for("lwbox.es.emit.composed"), ID: Symbol.for("lwbox.middleware.identity") };
|
||||
const getFnFromDesc = (d) => { try { if (typeof d?.value === "function") return d.value; if (typeof d?.get === "function") { const v = d.get.call(es); if (typeof v === "function") return v; } } catch { } return es.emit?.bind?.(es) || es.emit; };
|
||||
const compose = (base, stack) => stack.reduce((acc, mw) => mw(acc), base);
|
||||
const tails = new Map();
|
||||
const addTail = (ev, fn) => { if (typeof fn !== "function") return () => { }; const arr = tails.get(ev) || []; arr.push(fn); tails.set(ev, arr); return () => { const a = tails.get(ev); if (!a) return; const i = a.indexOf(fn); if (i >= 0) a.splice(i, 1); }; };
|
||||
const runTails = (ev, args) => { const arr = tails.get(ev); if (!arr?.length) return; for (const h of arr.slice()) { try { h(...args); } catch (e) { } } };
|
||||
const makeTailMw = () => { const mw = (next) => function patchedEmit(ev, ...args) { let r; try { r = next.call(this, ev, ...args); } catch (e) { queueMicrotask(() => runTails(ev, args)); throw e; } if (r && typeof r.then === "function") r.finally(() => runTails(ev, args)); else queueMicrotask(() => runTails(ev, args)); return r; }; Object.defineProperty(mw, SYM.ID, { value: true }); return Object.freeze(mw); };
|
||||
const ensureAccessor = () => { try { const d = Object.getOwnPropertyDescriptor(es, "emit"); if (!es[SYM.ORIG_DESC]) es[SYM.ORIG_DESC] = d || null; es[SYM.BASE] ||= getFnFromDesc(d); Object.defineProperty(es, "emit", { configurable: true, enumerable: d?.enumerable ?? true, get() { return reapply(); }, set(v) { if (typeof v === "function") { es[SYM.BASE] = v; queueMicrotask(reapply); } } }); } catch { } };
|
||||
const reapply = () => { try { const base = es[SYM.BASE] || getFnFromDesc(Object.getOwnPropertyDescriptor(es, "emit")) || es.emit.bind(es); const stack = es[SYM.MW_STACK] || (es[SYM.MW_STACK] = []); let idx = stack.findIndex((m) => m && m[SYM.ID]); if (idx === -1) { stack.push(makeTailMw()); idx = stack.length - 1; } if (idx !== stack.length - 1) { const mw = stack[idx]; stack.splice(idx, 1); stack.push(mw); } const composed = compose(base, stack) || base; if (!es[SYM.COMPOSED] || es[SYM.COMPOSED]._base !== base || es[SYM.COMPOSED]._stack !== stack) { composed._base = base; composed._stack = stack; es[SYM.COMPOSED] = composed; } return es[SYM.COMPOSED]; } catch { return es.emit; } };
|
||||
ensureAccessor();
|
||||
queueMicrotask(reapply);
|
||||
const api = { onLast: (e, h) => addTail(e, h), removeLast: (e, h) => { const a = tails.get(e); if (!a) return; const i = a.indexOf(h); if (i >= 0) a.splice(i, 1); }, uninstall() { try { const s = es[SYM.MW_STACK]; const i = Array.isArray(s) ? s.findIndex((m) => m && m[SYM.ID]) : -1; if (i >= 0) s.splice(i, 1); const orig = es[SYM.ORIG_DESC]; if (orig) { try { Object.defineProperty(es, "emit", orig); } catch { Object.defineProperty(es, "emit", { configurable: true, enumerable: true, writable: true, value: es[SYM.BASE] || es.emit }); } } else { Object.defineProperty(es, "emit", { configurable: true, enumerable: true, writable: true, value: es[SYM.BASE] || es.emit }); } } catch { } delete es.__lw_tailInstalled; delete es.__lw_tailAPI; tails.clear(); } };
|
||||
Object.defineProperty(es, "__lw_tailInstalled", { value: true });
|
||||
Object.defineProperty(es, "__lw_tailAPI", { value: api });
|
||||
return api;
|
||||
}
|
||||
|
||||
let __installed = false;
|
||||
const MW_KEY = Symbol.for("lwbox.fetchMiddlewareStack");
|
||||
const BASE_KEY = Symbol.for("lwbox.fetchBase");
|
||||
const ORIG_KEY = Symbol.for("lwbox.fetch.origDesc");
|
||||
const CMP_KEY = Symbol.for("lwbox.fetch.composed");
|
||||
const ID = Symbol.for("lwbox.middleware.identity");
|
||||
const getFetchFromDesc = (d) => { try { if (typeof d?.value === "function") return d.value; if (typeof d?.get === "function") { const v = d.get.call(window); if (typeof v === "function") return v; } } catch { } return globalThis.fetch; };
|
||||
const compose = (base, stack) => stack.reduce((acc, mw) => mw(acc), base);
|
||||
const withTimeout = (p, ms = 200) => { try { return Promise.race([p, new Promise((r) => setTimeout(r, ms))]); } catch { return p; } };
|
||||
const ensureAccessor = () => { try { const d = Object.getOwnPropertyDescriptor(window, "fetch"); if (!window[ORIG_KEY]) window[ORIG_KEY] = d || null; window[BASE_KEY] ||= getFetchFromDesc(d); Object.defineProperty(window, "fetch", { configurable: true, enumerable: d?.enumerable ?? true, get() { return reapply(); }, set(v) { if (typeof v === "function") { window[BASE_KEY] = v; queueMicrotask(reapply); } } }); } catch { } };
|
||||
const reapply = () => { try { const base = window[BASE_KEY] || getFetchFromDesc(Object.getOwnPropertyDescriptor(window, "fetch")); const stack = window[MW_KEY] || (window[MW_KEY] = []); let idx = stack.findIndex((m) => m && m[ID]); if (idx === -1) { stack.push(makeMw()); idx = stack.length - 1; } if (idx !== window[MW_KEY].length - 1) { const mw = window[MW_KEY][idx]; window[MW_KEY].splice(idx, 1); window[MW_KEY].push(mw); } const composed = compose(base, stack) || base; if (!window[CMP_KEY] || window[CMP_KEY]._base !== base || window[CMP_KEY]._stack !== stack) { composed._base = base; composed._stack = stack; window[CMP_KEY] = composed; } return window[CMP_KEY]; } catch { return globalThis.fetch; } };
|
||||
function makeMw() {
|
||||
const mw = (next) => async function f(input, options = {}) {
|
||||
try {
|
||||
if (await isTarget(input, options)) {
|
||||
if (S.isPreview || S.isLong) {
|
||||
const url = input instanceof Request ? input.url : input;
|
||||
return interceptPreview(url, options).catch(() => new Response(JSON.stringify({ error: { message: "拦截失败,请手动中止消息生成。" } }), { status: 200, headers: { "Content-Type": "application/json" } }));
|
||||
} else { try { await withTimeout(recordReal(input, options)); } catch { } }
|
||||
}
|
||||
} catch { }
|
||||
return Reflect.apply(next, this, arguments);
|
||||
};
|
||||
Object.defineProperty(mw, ID, { value: true, enumerable: false });
|
||||
return Object.freeze(mw);
|
||||
}
|
||||
function installFetch() {
|
||||
if (__installed) return; __installed = true;
|
||||
try {
|
||||
window[MW_KEY] ||= [];
|
||||
window[BASE_KEY] ||= getFetchFromDesc(Object.getOwnPropertyDescriptor(window, "fetch"));
|
||||
ensureAccessor();
|
||||
if (!window[MW_KEY].some((m) => m && m[ID])) window[MW_KEY].push(makeMw());
|
||||
else {
|
||||
const i = window[MW_KEY].findIndex((m) => m && m[ID]);
|
||||
if (i !== window[MW_KEY].length - 1) {
|
||||
const mw = window[MW_KEY][i];
|
||||
window[MW_KEY].splice(i, 1);
|
||||
window[MW_KEY].push(mw);
|
||||
}
|
||||
}
|
||||
queueMicrotask(reapply);
|
||||
window.addEventListener("pageshow", reapply, { passive: true });
|
||||
document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") reapply(); }, { passive: true });
|
||||
window.addEventListener("focus", reapply, { passive: true });
|
||||
} catch { }
|
||||
}
|
||||
function uninstallFetch() {
|
||||
if (!__installed) return;
|
||||
try {
|
||||
const s = window[MW_KEY];
|
||||
const i = Array.isArray(s) ? s.findIndex((m) => m && m[ID]) : -1;
|
||||
if (i >= 0) s.splice(i, 1);
|
||||
const others = Array.isArray(window[MW_KEY]) && window[MW_KEY].length;
|
||||
const orig = window[ORIG_KEY];
|
||||
if (!others) {
|
||||
if (orig) {
|
||||
try { Object.defineProperty(window, "fetch", orig); }
|
||||
catch { Object.defineProperty(window, "fetch", { configurable: true, enumerable: true, writable: true, value: window[BASE_KEY] || globalThis.fetch }); }
|
||||
} else {
|
||||
Object.defineProperty(window, "fetch", { configurable: true, enumerable: true, writable: true, value: window[BASE_KEY] || globalThis.fetch });
|
||||
}
|
||||
} else {
|
||||
reapply();
|
||||
}
|
||||
} catch { }
|
||||
__installed = false;
|
||||
}
|
||||
const setupFetch = () => { if (!S.active) { installFetch(); S.active = true; } };
|
||||
const restoreFetch = () => { if (S.active) { uninstallFetch(); S.active = false; } };
|
||||
const updateFetchState = () => { const st = getSettings(), need = (st.preview.enabled || st.recorded.enabled); if (need && !S.active) setupFetch(); if (!need && S.active) restoreFetch(); };
|
||||
|
||||
async function interceptPreview(url, options) {
|
||||
const body = await safeReadBodyFromInput(url, options);
|
||||
const data = safeJson(body) || {};
|
||||
const userInput = extractUser(data?.messages || []);
|
||||
const ctx = getContext();
|
||||
|
||||
if (S.isLong) {
|
||||
const chat = Array.isArray(ctx.chat) ? ctx.chat : [];
|
||||
let start = chat.length;
|
||||
if (chat.length > 0 && chat[chat.length - 1]?.is_user === true) start = chat.length - 1;
|
||||
S.chatLenBefore = start;
|
||||
S.pendingPurge = true;
|
||||
oneShotOnLast(event_types.GENERATION_ENDED, () => setTimeout(() => purgePreviewArtifacts(), 0));
|
||||
}
|
||||
|
||||
S.previewData = { url, method: options?.method || "POST", requestData: data, messages: data?.messages || [], model: data?.model || "Unknown", timestamp: now(), userInput, isPreview: true };
|
||||
if (S.isLong) { setTimeout(() => { displayPreview(S.previewData); }, 100); } else if (S.resolve) { S.resolve({ success: true, data: S.previewData }); S.resolve = S.reject = null; }
|
||||
const payload = S.isLong ? { choices: [{ message: { content: "【小白X】已拦截消息" }, finish_reason: "stop" }], intercepted: true } : { choices: [{ message: { content: "" }, finish_reason: "stop" }] };
|
||||
return new Response(JSON.stringify(payload), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
const addHistoryButtonsDebounced = debounce(() => {
|
||||
const set = getSettings(); if (!set.recorded.enabled || !geEnabled()) return;
|
||||
$(".mes_history_preview").remove();
|
||||
$("#chat .mes").each(function () {
|
||||
const id = parseInt($(this).attr("mesid")), isUser = $(this).attr("is_user") === "true";
|
||||
if (id <= 0 || isUser) return;
|
||||
const btn = $(`<div class="mes_btn mes_history_preview" title="查看历史API请求"><i class="fa-regular fa-note-sticky"></i></div>`).on("click", (e) => { e.preventDefault(); e.stopPropagation(); showHistoryPreview(id); });
|
||||
if (window.registerButtonToSubContainer && window.registerButtonToSubContainer(id, btn[0])) return;
|
||||
$(this).find(".flex-container.flex1.alignitemscenter").append(btn);
|
||||
});
|
||||
}, C.DEBOUNCE);
|
||||
|
||||
const disableSend = (dis = true) => {
|
||||
const $b = $q("#send_but");
|
||||
if (dis) { S.sendBtnWasDisabled = $b.prop("disabled"); $b.prop("disabled", true).off("click.preview-block").on("click.preview-block", (e) => { e.preventDefault(); e.stopImmediatePropagation(); return false; }); }
|
||||
else { $b.prop("disabled", S.sendBtnWasDisabled).off("click.preview-block"); S.sendBtnWasDisabled = false; }
|
||||
};
|
||||
const triggerSend = () => {
|
||||
const $b = $q("#send_but"), $t = $q("#send_textarea"), txt = String($t.val() || ""); if (!txt.trim()) return false;
|
||||
const was = $b.prop("disabled"); $b.prop("disabled", false); $b[0].dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window })); if (was) $b.prop("disabled", true); return true;
|
||||
};
|
||||
|
||||
async function showPreview() {
|
||||
let toast = null, backup = null;
|
||||
try {
|
||||
const set = getSettings(); if (!set.preview.enabled || !geEnabled()) return toastr.warning("消息拦截功能未启用");
|
||||
const text = String($q("#send_textarea").val() || "").trim(); if (!text) return toastr.error("请先输入消息内容");
|
||||
|
||||
backup = text; disableSend(true);
|
||||
const ctx = getContext();
|
||||
S.chatLenBefore = Array.isArray(ctx.chat) ? ctx.chat.length : 0;
|
||||
S.isPreview = true; S.previewData = null; S.previewIds.clear(); S.previewAbort = new AbortController();
|
||||
S.pendingPurge = true;
|
||||
|
||||
const endHandler = () => {
|
||||
try { if (S.genEndedOff) { S.genEndedOff(); S.genEndedOff = null; } } catch { }
|
||||
if (S.pendingPurge) {
|
||||
setTimeout(() => purgePreviewArtifacts(), 0);
|
||||
}
|
||||
};
|
||||
|
||||
S.genEndedOff = oneShotOnLast(event_types.GENERATION_ENDED, endHandler);
|
||||
clearTimeout(S.cleanupFallback);
|
||||
S.cleanupFallback = setTimeout(() => {
|
||||
try { if (S.genEndedOff) { S.genEndedOff(); S.genEndedOff = null; } } catch { }
|
||||
purgePreviewArtifacts();
|
||||
}, 1500);
|
||||
|
||||
toast = toastr.info(`正在拦截请求...(${set.preview.timeoutSeconds}秒超时)`, "消息拦截", { timeOut: 0, tapToDismiss: false });
|
||||
|
||||
if (!triggerSend()) throw new Error("无法触发发送事件");
|
||||
|
||||
const res = await waitIntercept().catch((e) => ({ success: false, error: e?.message || e }));
|
||||
if (toast) { toastr.clear(toast); toast = null; }
|
||||
if (res.success) { await displayPreview(res.data); toastr.success("拦截成功!", "", { timeOut: 3000 }); }
|
||||
else toastr.error(`拦截失败: ${res.error}`, "", { timeOut: 5000 });
|
||||
} catch (e) {
|
||||
if (toast) toastr.clear(toast); toastr.error(`拦截异常: ${e.message}`, "", { timeOut: 5000 });
|
||||
} finally {
|
||||
try { S.previewAbort?.abort("拦截结束"); } catch { } S.previewAbort = null;
|
||||
if (S.resolve) S.resolve({ success: false, error: "拦截已取消" }); S.resolve = S.reject = null;
|
||||
clearTimeout(S.cleanupFallback); S.cleanupFallback = null;
|
||||
S.isPreview = false; S.previewData = null;
|
||||
disableSend(false); if (backup) $q("#send_textarea").val(backup);
|
||||
}
|
||||
}
|
||||
|
||||
async function showHistoryPreview(messageId) {
|
||||
try {
|
||||
const set = getSettings(); if (!set.recorded.enabled || !geEnabled()) return;
|
||||
const rec = findRec(messageId);
|
||||
if (rec?.messages?.length || rec?.requestData?.messages?.length) await openPopup(buildPreviewHtml({ ...rec, isHistoryPreview: true, targetMessageId: messageId }), `消息历史查看 - 第 ${messageId + 1} 条消息`);
|
||||
else toastr.warning(`未找到第 ${messageId + 1} 条消息的API请求记录`);
|
||||
} catch { toastr.error("查看历史消息失败"); }
|
||||
}
|
||||
|
||||
const cleanupMemory = () => {
|
||||
if (S.history.length > C.MAX_HISTORY) S.history = S.history.slice(0, C.MAX_HISTORY);
|
||||
S.previewIds.clear(); S.previewData = null; $(".mes_history_preview").each(function () { if (!$(this).closest(".mes").length) $(this).remove(); });
|
||||
if (!S.isLong) S.interceptedIds = [];
|
||||
};
|
||||
|
||||
function onLast(ev, handler) {
|
||||
if (typeof eventSource.makeLast === "function") { eventSource.makeLast(ev, handler); S.listeners.push({ e: ev, h: handler, off: () => { } }); return; }
|
||||
if (S.tailAPI?.onLast) { const off = S.tailAPI.onLast(ev, handler); S.listeners.push({ e: ev, h: handler, off }); return; }
|
||||
const tail = (...args) => queueMicrotask(() => { try { handler(...args); } catch { } });
|
||||
eventSource.on(ev, tail);
|
||||
S.listeners.push({ e: ev, h: tail, off: () => eventSource.removeListener?.(ev, tail) });
|
||||
}
|
||||
|
||||
const addEvents = () => {
|
||||
removeEvents();
|
||||
[
|
||||
{ e: event_types.MESSAGE_RECEIVED, h: addHistoryButtonsDebounced },
|
||||
{ e: event_types.CHARACTER_MESSAGE_RENDERED, h: addHistoryButtonsDebounced },
|
||||
{ e: event_types.USER_MESSAGE_RENDERED, h: addHistoryButtonsDebounced },
|
||||
{ e: event_types.CHAT_CHANGED, h: () => { S.history = []; setTimeout(addHistoryButtonsDebounced, C.CHECK); } },
|
||||
{ e: event_types.MESSAGE_RECEIVED, h: (messageId) => setTimeout(() => { const r = S.history.find((x) => !x.associatedMessageId && now() - x.timestamp < C.REQ_WINDOW); if (r) r.associatedMessageId = messageId; }, 100) },
|
||||
].forEach(({ e, h }) => onLast(e, h));
|
||||
const late = (payload) => {
|
||||
try {
|
||||
const ctx = getContext();
|
||||
pushHistory({
|
||||
url: C.TARGET, method: "POST", requestData: payload, messages: payload?.messages || [], model: payload?.model || "Unknown",
|
||||
timestamp: now(), messageId: ctx.chat?.length || 0, characterName: ctx.characters?.[ctx.characterId]?.name || "Unknown",
|
||||
userInput: extractUser(payload?.messages || []), isRealRequest: true, source: "settings_ready",
|
||||
});
|
||||
} catch { }
|
||||
queueMicrotask(() => updateFetchState());
|
||||
};
|
||||
if (typeof eventSource.makeLast === "function") { eventSource.makeLast(event_types.CHAT_COMPLETION_SETTINGS_READY, late); S.listeners.push({ e: event_types.CHAT_COMPLETION_SETTINGS_READY, h: late, off: () => { } }); }
|
||||
else if (S.tailAPI?.onLast) { const off = S.tailAPI.onLast(event_types.CHAT_COMPLETION_SETTINGS_READY, late); S.listeners.push({ e: event_types.CHAT_COMPLETION_SETTINGS_READY, h: late, off }); }
|
||||
else { ON(event_types.CHAT_COMPLETION_SETTINGS_READY, late); S.listeners.push({ e: event_types.CHAT_COMPLETION_SETTINGS_READY, h: late, off: () => OFF(event_types.CHAT_COMPLETION_SETTINGS_READY, late) }); queueMicrotask(() => { try { OFF(event_types.CHAT_COMPLETION_SETTINGS_READY, late); } catch { } try { ON(event_types.CHAT_COMPLETION_SETTINGS_READY, late); } catch { } }); }
|
||||
};
|
||||
const removeEvents = () => { S.listeners.forEach(({ e, h, off }) => { if (typeof off === "function") { try { off(); } catch { } } else { try { OFF(e, h); } catch { } } }); S.listeners = []; };
|
||||
|
||||
const toggleLong = () => {
|
||||
S.isLong = !S.isLong;
|
||||
const $b = $q("#message_preview_btn");
|
||||
if (S.isLong) {
|
||||
$b.css("color", "red");
|
||||
toastr.info("持续拦截已开启", "", { timeOut: 2000 });
|
||||
} else {
|
||||
$b.css("color", "");
|
||||
S.pendingPurge = false;
|
||||
toastr.info("持续拦截已关闭", "", { timeOut: 2000 });
|
||||
}
|
||||
};
|
||||
const bindBtn = () => {
|
||||
const $b = $q("#message_preview_btn");
|
||||
$b.on("mousedown touchstart", () => { S.longPressTimer = setTimeout(() => toggleLong(), S.longPressDelay); });
|
||||
$b.on("mouseup touchend mouseleave", () => { if (S.longPressTimer) { clearTimeout(S.longPressTimer); S.longPressTimer = null; } });
|
||||
$b.on("click", () => { if (S.longPressTimer) { clearTimeout(S.longPressTimer); S.longPressTimer = null; return; } if (!S.isLong) showPreview(); });
|
||||
};
|
||||
|
||||
const waitIntercept = () => new Promise((resolve, reject) => {
|
||||
const t = setTimeout(() => { if (S.resolve) { S.resolve({ success: false, error: `等待超时 (${getSettings().preview.timeoutSeconds}秒)` }); S.resolve = S.reject = null; } }, getSettings().preview.timeoutSeconds * 1000);
|
||||
S.resolve = (v) => { clearTimeout(t); resolve(v); }; S.reject = (e) => { clearTimeout(t); reject(e); };
|
||||
});
|
||||
|
||||
function cleanup() {
|
||||
removeEvents(); restoreFetch(); disableSend(false);
|
||||
$(".mes_history_preview").remove(); $("#message_preview_btn").remove(); cleanupMemory();
|
||||
Object.assign(S, { resolve: null, reject: null, isPreview: false, isLong: false, interceptedIds: [], chatLenBefore: 0, sendBtnWasDisabled: false, pendingPurge: false });
|
||||
if (S.longPressTimer) { clearTimeout(S.longPressTimer); S.longPressTimer = null; }
|
||||
if (S.restoreLong) { try { S.restoreLong(); } catch { } S.restoreLong = null; }
|
||||
if (S.genEndedOff) { try { S.genEndedOff(); } catch { } S.genEndedOff = null; }
|
||||
if (S.cleanupFallback) { clearTimeout(S.cleanupFallback); S.cleanupFallback = null; }
|
||||
}
|
||||
|
||||
function initMessagePreview() {
|
||||
try {
|
||||
cleanup(); S.tailAPI = installEventSourceTail(eventSource);
|
||||
const set = getSettings();
|
||||
const btn = $(`<div id="message_preview_btn" class="fa-regular fa-note-sticky interactable" title="预览消息"></div>`);
|
||||
$("#send_but").before(btn); bindBtn();
|
||||
$("#xiaobaix_preview_enabled").prop("checked", set.preview.enabled).on("change", function () {
|
||||
if (!geEnabled()) return; set.preview.enabled = $(this).prop("checked"); saveSettingsDebounced();
|
||||
$("#message_preview_btn").toggle(set.preview.enabled);
|
||||
if (set.preview.enabled) { if (!S.cleanTimer) S.cleanTimer = setInterval(cleanupMemory, C.CLEAN); }
|
||||
else { if (S.cleanTimer) { clearInterval(S.cleanTimer); S.cleanTimer = null; } }
|
||||
updateFetchState();
|
||||
if (!set.preview.enabled && set.recorded.enabled) { addEvents(); addHistoryButtonsDebounced(); }
|
||||
});
|
||||
$("#xiaobaix_recorded_enabled").prop("checked", set.recorded.enabled).on("change", function () {
|
||||
if (!geEnabled()) return; set.recorded.enabled = $(this).prop("checked"); saveSettingsDebounced();
|
||||
if (set.recorded.enabled) { addEvents(); addHistoryButtonsDebounced(); }
|
||||
else { $(".mes_history_preview").remove(); S.history.length = 0; if (!set.preview.enabled) removeEvents(); }
|
||||
updateFetchState();
|
||||
});
|
||||
if (!set.preview.enabled) $("#message_preview_btn").hide();
|
||||
updateFetchState(); if (set.recorded.enabled) addHistoryButtonsDebounced();
|
||||
if (set.preview.enabled || set.recorded.enabled) addEvents();
|
||||
if (window.registerModuleCleanup) window.registerModuleCleanup("messagePreview", cleanup);
|
||||
if (set.preview.enabled) S.cleanTimer = setInterval(cleanupMemory, C.CLEAN);
|
||||
} catch { toastr.error("模块初始化失败"); }
|
||||
}
|
||||
|
||||
window.addEventListener("beforeunload", cleanup);
|
||||
window.messagePreviewCleanup = cleanup;
|
||||
|
||||
export { initMessagePreview, addHistoryButtonsDebounced, cleanup };
|
||||
1136
modules/novel-draw/novel-draw.html
Normal file
1136
modules/novel-draw/novel-draw.html
Normal file
File diff suppressed because it is too large
Load Diff
700
modules/novel-draw/novel-draw.js
Normal file
700
modules/novel-draw/novel-draw.js
Normal file
@@ -0,0 +1,700 @@
|
||||
import { extension_settings, getContext } from "../../../../../extensions.js";
|
||||
import { appendMediaToMessage, getRequestHeaders, saveSettingsDebounced } from "../../../../../../script.js";
|
||||
import { saveBase64AsFile } from "../../../../../utils.js";
|
||||
import { secret_state, writeSecret, SECRET_KEYS } from "../../../../../secrets.js";
|
||||
import { EXT_ID, extensionFolderPath } from "../../core/constants.js";
|
||||
import { createModuleEvents, event_types } from "../../core/event-manager.js";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 常量与状态
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const MODULE_KEY = 'novelDraw';
|
||||
const HTML_PATH = `${extensionFolderPath}/modules/novel-draw/novel-draw.html`;
|
||||
const TAGS_SESSION_ID = 'xb_nd_tags';
|
||||
const NOVELAI_IMAGE_API = 'https://image.novelai.net/ai/generate-image';
|
||||
const REFERENCE_PIXEL_COUNT = 1011712;
|
||||
const SIGMA_MAGIC_NUMBER = 19;
|
||||
const SIGMA_MAGIC_NUMBER_V4_5 = 58;
|
||||
|
||||
const events = createModuleEvents(MODULE_KEY);
|
||||
|
||||
const DEFAULT_PRESET = {
|
||||
id: '',
|
||||
name: '默认',
|
||||
positivePrefix: 'masterpiece, best quality,',
|
||||
negativePrefix: 'lowres, bad anatomy, bad hands,',
|
||||
params: {
|
||||
model: 'nai-diffusion-4-full',
|
||||
sampler: 'k_dpmpp_2m',
|
||||
scheduler: 'karras',
|
||||
steps: 28,
|
||||
scale: 9,
|
||||
width: 832,
|
||||
height: 1216,
|
||||
seed: -1,
|
||||
sm: false,
|
||||
sm_dyn: false,
|
||||
decrisper: false,
|
||||
variety_boost: false,
|
||||
upscale_ratio: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
enabled: false,
|
||||
mode: 'manual',
|
||||
selectedPresetId: null,
|
||||
presets: [],
|
||||
api: {
|
||||
mode: 'tavern',
|
||||
apiKey: '',
|
||||
},
|
||||
};
|
||||
|
||||
let autoBusy = false;
|
||||
let overlayCreated = false;
|
||||
let frameReady = false;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 设置管理
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function getSettings() {
|
||||
const root = extension_settings[EXT_ID] ||= {};
|
||||
const s = root[MODULE_KEY] ||= { ...DEFAULT_SETTINGS };
|
||||
if (!Array.isArray(s.presets) || !s.presets.length) {
|
||||
const id = generateId();
|
||||
s.presets = [{ ...JSON.parse(JSON.stringify(DEFAULT_PRESET)), id }];
|
||||
s.selectedPresetId = id;
|
||||
}
|
||||
if (!s.selectedPresetId || !s.presets.find(p => p.id === s.selectedPresetId)) {
|
||||
s.selectedPresetId = s.presets[0]?.id ?? null;
|
||||
}
|
||||
if (!s.api) {
|
||||
s.api = { ...DEFAULT_SETTINGS.api };
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function getActivePreset() {
|
||||
const s = getSettings();
|
||||
return s.presets.find(p => p.id === s.selectedPresetId) || s.presets[0];
|
||||
}
|
||||
|
||||
function generateId() {
|
||||
return `xb-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 工具函数
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function joinTags(prefix, scene) {
|
||||
const a = String(prefix || '').trim().replace(/[,、]/g, ',');
|
||||
const b = String(scene || '').trim().replace(/[,、]/g, ',');
|
||||
if (!a) return b;
|
||||
if (!b) return a;
|
||||
return `${a.replace(/,+\s*$/g, '')}, ${b.replace(/^,+\s*/g, '')}`;
|
||||
}
|
||||
|
||||
function b64UrlEncode(str) {
|
||||
const utf8 = new TextEncoder().encode(String(str));
|
||||
let bin = '';
|
||||
utf8.forEach(b => bin += String.fromCharCode(b));
|
||||
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
function normalizeSceneTags(raw) {
|
||||
if (!raw) return '';
|
||||
return String(raw).trim()
|
||||
.replace(/^```[\s\S]*?\n/i, '').replace(/```$/i, '')
|
||||
.replace(/^\s*(tags?\s*[::]\s*)/i, '')
|
||||
.replace(/\r?\n+/g, ', ')
|
||||
.replace(/[,、]/g, ',')
|
||||
.replace(/\s*,\s*/g, ', ')
|
||||
.replace(/,+\s*$/g, '').replace(/^\s*,+/g, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function getChatCharacterName() {
|
||||
const ctx = getContext();
|
||||
if (ctx.groupId) return String(ctx.groups?.[ctx.groupId]?.id ?? 'group');
|
||||
return String(ctx.characters?.[ctx.characterId]?.name || 'character');
|
||||
}
|
||||
|
||||
function calculateSkipCfgAboveSigma(width, height, modelName) {
|
||||
const magicConstant = modelName?.includes('nai-diffusion-4-5') ? SIGMA_MAGIC_NUMBER_V4_5 : SIGMA_MAGIC_NUMBER;
|
||||
const pixelCount = width * height;
|
||||
return Math.pow(pixelCount / REFERENCE_PIXEL_COUNT, 0.5) * magicConstant;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 场景 TAG 生成
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function buildSceneTagPrompt({ lastAssistantText, positivePrefix, negativePrefix }) {
|
||||
const msg1 = `你是"NovelAI 场景TAG生成器"。只输出一行逗号分隔的英文tag(场景/构图/光照/氛围/动作/镜头),不要解释,不要换行,不要加代码块。25-60个tag。`;
|
||||
const msg2 = `明白,我只输出一行逗号分隔的场景TAG。`;
|
||||
const msg3 = `<正向固定词>\n${positivePrefix}\n</正向固定词>\n<负向固定词>\n${negativePrefix}\n</负向固定词>\n<对话上下文>\n{$history20}\n</对话上下文>\n<最后AI回复>\n${lastAssistantText}\n</最后AI回复>\n请基于"最后AI回复"生成场景TAG:`;
|
||||
const msg4 = `场景TAG:`;
|
||||
return b64UrlEncode(`user={${msg1}};assistant={${msg2}};user={${msg3}};assistant={${msg4}}`);
|
||||
}
|
||||
|
||||
async function generateSceneTagsFromChat({ messageId }) {
|
||||
const preset = getActivePreset();
|
||||
if (!preset) throw new Error('未找到预设');
|
||||
const ctx = getContext();
|
||||
const chat = ctx.chat || [];
|
||||
const lastAssistantText = String(chat[messageId]?.mes || '').trim();
|
||||
const top64 = buildSceneTagPrompt({
|
||||
lastAssistantText,
|
||||
positivePrefix: preset.positivePrefix,
|
||||
negativePrefix: preset.negativePrefix,
|
||||
});
|
||||
const mod = window?.xiaobaixStreamingGeneration;
|
||||
if (!mod?.xbgenrawCommand) throw new Error('xbgenraw 不可用');
|
||||
const raw = await mod.xbgenrawCommand({ as: 'user', nonstream: 'true', top64, id: TAGS_SESSION_ID }, '');
|
||||
const tags = normalizeSceneTags(raw);
|
||||
if (!tags) throw new Error('AI 未返回有效场景TAG');
|
||||
return tags;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// API Key 管理
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async function ensureApiKeyInSecrets(apiKey) {
|
||||
if (!apiKey) throw new Error('API Key 不能为空');
|
||||
await writeSecret(SECRET_KEYS.NOVEL, apiKey);
|
||||
}
|
||||
|
||||
function hasApiKeyInSecrets() {
|
||||
return !!secret_state[SECRET_KEYS.NOVEL];
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 连接测试
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async function testApiConnection(apiKey, mode) {
|
||||
if (!apiKey) throw new Error('请填写 API Key');
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 15000);
|
||||
|
||||
try {
|
||||
if (mode === 'direct') {
|
||||
const res = await fetch(NOVELAI_IMAGE_API, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
input: 'test',
|
||||
model: 'nai-diffusion-3',
|
||||
action: 'generate',
|
||||
parameters: { width: 64, height: 64, steps: 1 }
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (res.status === 401) throw new Error('API Key 无效');
|
||||
if (res.status === 400 || res.status === 402 || res.ok) {
|
||||
return { success: true, message: '连接成功' };
|
||||
}
|
||||
throw new Error(`NovelAI 返回: ${res.status}`);
|
||||
|
||||
} else {
|
||||
await ensureApiKeyInSecrets(apiKey);
|
||||
const res = await fetch('/api/novelai/status', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!res.ok) throw new Error(`酒馆后端返回错误: ${res.status}`);
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error('API Key 无效或已过期');
|
||||
return data;
|
||||
}
|
||||
} catch (e) {
|
||||
clearTimeout(timeoutId);
|
||||
if (e.name === 'AbortError') {
|
||||
throw new Error(mode === 'direct' ? '连接超时,请检查网络或开启代理' : '连接超时,酒馆服务器可能无法访问 NovelAI');
|
||||
}
|
||||
if (e.message?.includes('Failed to fetch')) {
|
||||
throw new Error(mode === 'direct' ? '无法连接 NovelAI,请检查网络或开启代理' : '无法连接酒馆后端');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// ZIP 解析
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async function extractImageFromZip(zipData) {
|
||||
const JSZip = window.JSZip;
|
||||
if (!JSZip) throw new Error('缺少 JSZip 库,请使用酒馆模式');
|
||||
|
||||
const zip = await JSZip.loadAsync(zipData);
|
||||
const imageFile = Object.values(zip.files).find(f => f.name.endsWith('.png') || f.name.endsWith('.webp'));
|
||||
if (!imageFile) throw new Error('无法从返回数据中提取图片');
|
||||
|
||||
return await imageFile.async('base64');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 图片生成(核心)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async function generateNovelImageBase64({ prompt, negativePrompt, params, signal }) {
|
||||
const settings = getSettings();
|
||||
const apiMode = settings.api?.mode || 'tavern';
|
||||
const apiKey = settings.api?.apiKey || '';
|
||||
|
||||
const width = params?.width ?? 832;
|
||||
const height = params?.height ?? 1216;
|
||||
const seed = (params?.seed >= 0) ? params.seed : Math.floor(Math.random() * 9999999999);
|
||||
const promptText = String(prompt || '');
|
||||
const negativeText = String(negativePrompt || '');
|
||||
const modelName = params?.model ?? 'nai-diffusion-4-full';
|
||||
|
||||
if (apiMode === 'direct') {
|
||||
if (!apiKey) throw new Error('官网直连模式需要填写 API Key');
|
||||
|
||||
const skipCfgAboveSigma = params?.variety_boost ? calculateSkipCfgAboveSigma(width, height, modelName) : null;
|
||||
|
||||
const requestBody = {
|
||||
action: 'generate',
|
||||
input: promptText,
|
||||
model: modelName,
|
||||
parameters: {
|
||||
params_version: 3,
|
||||
prefer_brownian: true,
|
||||
width: width,
|
||||
height: height,
|
||||
scale: params?.scale ?? 9,
|
||||
seed: seed,
|
||||
sampler: params?.sampler ?? 'k_dpmpp_2m',
|
||||
noise_schedule: params?.scheduler ?? 'karras',
|
||||
steps: params?.steps ?? 28,
|
||||
n_samples: 1,
|
||||
negative_prompt: negativeText,
|
||||
ucPreset: 0,
|
||||
qualityToggle: false,
|
||||
add_original_image: false,
|
||||
controlnet_strength: 1,
|
||||
deliberate_euler_ancestral_bug: false,
|
||||
dynamic_thresholding: params?.decrisper ?? false,
|
||||
legacy: false,
|
||||
legacy_v3_extend: false,
|
||||
sm: params?.sm ?? false,
|
||||
sm_dyn: params?.sm_dyn ?? false,
|
||||
uncond_scale: 1,
|
||||
skip_cfg_above_sigma: skipCfgAboveSigma,
|
||||
use_coords: false,
|
||||
characterPrompts: [],
|
||||
reference_image_multiple: [],
|
||||
reference_information_extracted_multiple: [],
|
||||
reference_strength_multiple: [],
|
||||
v4_prompt: {
|
||||
caption: {
|
||||
base_caption: promptText,
|
||||
char_captions: [],
|
||||
},
|
||||
use_coords: false,
|
||||
use_order: true,
|
||||
},
|
||||
v4_negative_prompt: {
|
||||
caption: {
|
||||
base_caption: negativeText,
|
||||
char_captions: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const res = await fetch(NOVELAI_IMAGE_API, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
},
|
||||
signal,
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) throw new Error('API Key 无效');
|
||||
if (res.status === 402) throw new Error('点数不足,请充值');
|
||||
const errText = await res.text().catch(() => res.statusText);
|
||||
throw new Error(`NovelAI 请求失败: ${errText}`);
|
||||
}
|
||||
|
||||
const zipData = await res.arrayBuffer();
|
||||
return await extractImageFromZip(zipData);
|
||||
|
||||
} else {
|
||||
if (apiKey) {
|
||||
await ensureApiKeyInSecrets(apiKey);
|
||||
} else if (!hasApiKeyInSecrets()) {
|
||||
throw new Error('请先填写 API Key');
|
||||
}
|
||||
|
||||
const body = {
|
||||
prompt: promptText,
|
||||
negative_prompt: negativeText,
|
||||
model: modelName,
|
||||
sampler: params?.sampler ?? 'k_dpmpp_2m',
|
||||
scheduler: params?.scheduler ?? 'karras',
|
||||
steps: params?.steps ?? 28,
|
||||
scale: params?.scale ?? 9,
|
||||
width: width,
|
||||
height: height,
|
||||
seed: seed,
|
||||
upscale_ratio: params?.upscale_ratio ?? 1,
|
||||
decrisper: params?.decrisper ?? false,
|
||||
variety_boost: params?.variety_boost ?? false,
|
||||
sm: params?.sm ?? false,
|
||||
sm_dyn: params?.sm_dyn ?? false,
|
||||
};
|
||||
|
||||
const res = await fetch('/api/novelai/generate-image', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
signal,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(await res.text() || res.statusText || 'Novel 画图失败');
|
||||
return String(await res.text());
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 生成并附加到消息
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async function generateAndAttachToMessage({ messageId, sceneTags }) {
|
||||
if (!Number.isInteger(messageId) || messageId < 0) throw new Error('messageId 无效');
|
||||
const preset = getActivePreset();
|
||||
if (!preset) throw new Error('未找到预设');
|
||||
const positive = joinTags(preset.positivePrefix, sceneTags);
|
||||
const negative = String(preset.negativePrefix || '');
|
||||
const base64 = await generateNovelImageBase64({ prompt: positive, negativePrompt: negative, params: preset.params || {} });
|
||||
const url = await saveBase64AsFile(base64, getChatCharacterName(), `novel_${Date.now()}`, 'png');
|
||||
const ctx = getContext();
|
||||
const message = ctx.chat?.[messageId];
|
||||
if (!message) throw new Error('找不到对应楼层消息');
|
||||
message.extra ||= {};
|
||||
message.extra.media ||= [];
|
||||
message.extra.media.push({ url, type: 'image', title: positive, negative, generation_type: 'xb_novel_draw', source: 'generated' });
|
||||
message.extra.media_index = message.extra.media.length - 1;
|
||||
message.extra.media_display ||= 'gallery';
|
||||
message.extra.inline_image = false;
|
||||
const el = document.querySelector(`#chat .mes[mesid="${messageId}"]`);
|
||||
if (el) appendMediaToMessage(message, el);
|
||||
await ctx.saveChat();
|
||||
return { url, prompt: positive, negative, messageId };
|
||||
}
|
||||
|
||||
async function autoGenerateAndAttachToLastAI() {
|
||||
const s = getSettings();
|
||||
if (!s.enabled || s.mode !== 'auto' || autoBusy) return null;
|
||||
const ctx = getContext();
|
||||
const chat = ctx.chat || [];
|
||||
if (!chat.length) return null;
|
||||
let messageId = chat.length - 1;
|
||||
while (messageId >= 0 && chat[messageId]?.is_user) messageId--;
|
||||
if (messageId < 0) return null;
|
||||
const msg = chat[messageId];
|
||||
msg.extra ||= {};
|
||||
if (msg.extra.xb_novel_draw?.auto_done) return null;
|
||||
autoBusy = true;
|
||||
try {
|
||||
const sceneTags = await generateSceneTagsFromChat({ messageId });
|
||||
const result = await generateAndAttachToMessage({ messageId, sceneTags });
|
||||
msg.extra.xb_novel_draw = { auto_done: true, at: Date.now(), sceneTags };
|
||||
await ctx.saveChat();
|
||||
return result;
|
||||
} finally {
|
||||
autoBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Overlay 管理
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function createOverlay() {
|
||||
if (overlayCreated) return;
|
||||
overlayCreated = true;
|
||||
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
const frameInset = isMobile ? '0px' : '12px';
|
||||
const iframeRadius = isMobile ? '0px' : '12px';
|
||||
|
||||
const $overlay = $(`
|
||||
<div id="xiaobaix-novel-draw-overlay" style="
|
||||
position: fixed !important; inset: 0 !important;
|
||||
width: 100vw !important; height: 100vh !important; height: 100dvh !important;
|
||||
z-index: 99999 !important; display: none; overflow: hidden !important;
|
||||
background: #000 !important;
|
||||
">
|
||||
<div class="nd-backdrop" style="
|
||||
position: absolute !important; inset: 0 !important;
|
||||
background: rgba(0,0,0,.55) !important;
|
||||
backdrop-filter: blur(4px) !important;
|
||||
"></div>
|
||||
<div class="nd-frame-wrap" style="
|
||||
position: absolute !important; inset: ${frameInset} !important; z-index: 1 !important;
|
||||
">
|
||||
<iframe id="xiaobaix-novel-draw-iframe"
|
||||
src="${HTML_PATH}"
|
||||
style="width:100% !important; height:100% !important; border:none !important;
|
||||
border-radius:${iframeRadius} !important; box-shadow:0 0 30px rgba(0,0,0,.4) !important;
|
||||
background:#1a1a2e !important;">
|
||||
</iframe>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$overlay.on('click', '.nd-backdrop', hideOverlay);
|
||||
document.body.appendChild($overlay[0]);
|
||||
window.addEventListener('message', handleFrameMessage);
|
||||
}
|
||||
|
||||
function showOverlay() {
|
||||
if (!overlayCreated) createOverlay();
|
||||
document.getElementById('xiaobaix-novel-draw-overlay').style.display = 'block';
|
||||
if (frameReady) sendInitData();
|
||||
}
|
||||
|
||||
function hideOverlay() {
|
||||
const overlay = document.getElementById('xiaobaix-novel-draw-overlay');
|
||||
if (overlay) overlay.style.display = 'none';
|
||||
}
|
||||
|
||||
function sendInitData() {
|
||||
const iframe = document.getElementById('xiaobaix-novel-draw-iframe');
|
||||
if (!iframe?.contentWindow) return;
|
||||
const settings = getSettings();
|
||||
iframe.contentWindow.postMessage({
|
||||
source: 'LittleWhiteBox-NovelDraw',
|
||||
type: 'INIT_DATA',
|
||||
settings: {
|
||||
enabled: settings.enabled,
|
||||
mode: settings.mode,
|
||||
selectedPresetId: settings.selectedPresetId,
|
||||
presets: settings.presets,
|
||||
api: {
|
||||
mode: settings.api?.mode || 'tavern',
|
||||
apiKey: settings.api?.apiKey || '',
|
||||
},
|
||||
}
|
||||
}, '*');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// iframe 通讯
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function handleFrameMessage(event) {
|
||||
const data = event.data;
|
||||
if (!data || data.source !== 'NovelDraw-Frame') return;
|
||||
|
||||
const settings = getSettings();
|
||||
|
||||
switch (data.type) {
|
||||
case 'FRAME_READY':
|
||||
frameReady = true;
|
||||
sendInitData();
|
||||
break;
|
||||
|
||||
case 'CLOSE':
|
||||
hideOverlay();
|
||||
break;
|
||||
|
||||
case 'SAVE_MODE':
|
||||
settings.mode = data.mode;
|
||||
saveSettingsDebounced();
|
||||
break;
|
||||
|
||||
case 'SAVE_API_CONFIG':
|
||||
settings.api = {
|
||||
mode: data.apiMode || 'tavern',
|
||||
apiKey: data.apiKey || '',
|
||||
};
|
||||
saveSettingsDebounced();
|
||||
postStatus('success', 'API 设置已保存');
|
||||
break;
|
||||
|
||||
case 'TEST_API_CONNECTION':
|
||||
handleTestConnection(data);
|
||||
break;
|
||||
|
||||
case 'SAVE_PRESET':
|
||||
settings.selectedPresetId = data.selectedPresetId;
|
||||
settings.presets = data.presets;
|
||||
saveSettingsDebounced();
|
||||
break;
|
||||
|
||||
case 'ADD_PRESET': {
|
||||
const id = generateId();
|
||||
const base = getActivePreset();
|
||||
const copy = base ? JSON.parse(JSON.stringify(base)) : { ...DEFAULT_PRESET };
|
||||
copy.id = id;
|
||||
copy.name = data.name || `新预设-${settings.presets.length + 1}`;
|
||||
settings.presets.push(copy);
|
||||
settings.selectedPresetId = id;
|
||||
saveSettingsDebounced();
|
||||
sendInitData();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'DUP_PRESET': {
|
||||
const base = getActivePreset();
|
||||
if (!base) break;
|
||||
const id = generateId();
|
||||
const copy = JSON.parse(JSON.stringify(base));
|
||||
copy.id = id;
|
||||
copy.name = `${base.name || '预设'}-副本`;
|
||||
settings.presets.push(copy);
|
||||
settings.selectedPresetId = id;
|
||||
saveSettingsDebounced();
|
||||
sendInitData();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'DEL_PRESET': {
|
||||
if (settings.presets.length <= 1) break;
|
||||
const idx = settings.presets.findIndex(p => p.id === settings.selectedPresetId);
|
||||
if (idx >= 0) settings.presets.splice(idx, 1);
|
||||
settings.selectedPresetId = settings.presets[0]?.id ?? null;
|
||||
saveSettingsDebounced();
|
||||
sendInitData();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'TEST_PREVIEW':
|
||||
handleTestPreview(data);
|
||||
break;
|
||||
|
||||
case 'ATTACH_LAST':
|
||||
handleAttachLast(data);
|
||||
break;
|
||||
|
||||
case 'AI_TAGS_ATTACH':
|
||||
handleAiTagsAttach();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTestConnection(data) {
|
||||
try {
|
||||
postStatus('loading', '测试连接中...');
|
||||
await testApiConnection(data.apiKey, data.apiMode);
|
||||
postStatus('success', '连接成功');
|
||||
} catch (e) {
|
||||
postStatus('error', e?.message || '连接失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTestPreview(data) {
|
||||
const iframe = document.getElementById('xiaobaix-novel-draw-iframe');
|
||||
try {
|
||||
postStatus('loading', '生成中...');
|
||||
const preset = getActivePreset();
|
||||
const positive = joinTags(preset?.positivePrefix, data.sceneTags);
|
||||
const base64 = await generateNovelImageBase64({
|
||||
prompt: positive,
|
||||
negativePrompt: preset?.negativePrefix || '',
|
||||
params: preset?.params || {}
|
||||
});
|
||||
const url = await saveBase64AsFile(base64, getChatCharacterName(), `novel_preview_${Date.now()}`, 'png');
|
||||
iframe?.contentWindow?.postMessage({ source: 'LittleWhiteBox-NovelDraw', type: 'PREVIEW_RESULT', url }, '*');
|
||||
postStatus('success', '完成');
|
||||
} catch (e) {
|
||||
postStatus('error', e?.message || '失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAttachLast(data) {
|
||||
try {
|
||||
postStatus('loading', '生成并追加中...');
|
||||
const ctx = getContext();
|
||||
const chat = ctx.chat || [];
|
||||
let messageId = chat.length - 1;
|
||||
while (messageId >= 0 && chat[messageId]?.is_user) messageId--;
|
||||
if (messageId < 0) throw new Error('没有可追加的AI楼层');
|
||||
await generateAndAttachToMessage({ messageId, sceneTags: data.sceneTags || '' });
|
||||
postStatus('success', `已追加到楼层 ${messageId + 1}`);
|
||||
} catch (e) {
|
||||
postStatus('error', e?.message || '失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAiTagsAttach() {
|
||||
const iframe = document.getElementById('xiaobaix-novel-draw-iframe');
|
||||
try {
|
||||
postStatus('loading', '生成场景TAG中...');
|
||||
const ctx = getContext();
|
||||
const chat = ctx.chat || [];
|
||||
let messageId = chat.length - 1;
|
||||
while (messageId >= 0 && chat[messageId]?.is_user) messageId--;
|
||||
if (messageId < 0) throw new Error('没有可追加的AI楼层');
|
||||
const tags = await generateSceneTagsFromChat({ messageId });
|
||||
iframe?.contentWindow?.postMessage({ source: 'LittleWhiteBox-NovelDraw', type: 'AI_TAGS_RESULT', tags }, '*');
|
||||
postStatus('loading', '出图并追加中...');
|
||||
await generateAndAttachToMessage({ messageId, sceneTags: tags });
|
||||
postStatus('success', `已追加到楼层 ${messageId + 1}`);
|
||||
} catch (e) {
|
||||
postStatus('error', e?.message || '失败');
|
||||
}
|
||||
}
|
||||
|
||||
function postStatus(state, text) {
|
||||
const iframe = document.getElementById('xiaobaix-novel-draw-iframe');
|
||||
iframe?.contentWindow?.postMessage({ source: 'LittleWhiteBox-NovelDraw', type: 'STATUS', state, text }, '*');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 初始化与清理
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export function openNovelDrawSettings() {
|
||||
showOverlay();
|
||||
}
|
||||
|
||||
export function initNovelDraw() {
|
||||
if (window?.isXiaobaixEnabled === false) return;
|
||||
getSettings();
|
||||
events.on(event_types.GENERATION_ENDED, async () => {
|
||||
try { await autoGenerateAndAttachToLastAI(); } catch {}
|
||||
});
|
||||
window.xiaobaixNovelDraw = {
|
||||
getSettings,
|
||||
generateNovelImageBase64,
|
||||
generateAndAttachToMessage,
|
||||
generateSceneTagsFromChat,
|
||||
autoGenerateAndAttachToLastAI,
|
||||
openSettings: openNovelDrawSettings,
|
||||
};
|
||||
window.registerModuleCleanup?.(MODULE_KEY, cleanupNovelDraw);
|
||||
}
|
||||
|
||||
export function cleanupNovelDraw() {
|
||||
events.cleanup();
|
||||
hideOverlay();
|
||||
overlayCreated = false;
|
||||
frameReady = false;
|
||||
window.removeEventListener('message', handleFrameMessage);
|
||||
document.getElementById('xiaobaix-novel-draw-overlay')?.remove();
|
||||
delete window.xiaobaixNovelDraw;
|
||||
}
|
||||
75
modules/scheduled-tasks/embedded-tasks.html
Normal file
75
modules/scheduled-tasks/embedded-tasks.html
Normal file
@@ -0,0 +1,75 @@
|
||||
<div class="scheduled-tasks-embedded-warning">
|
||||
<h3>检测到嵌入的循环任务及可能的统计好感度设定</h3>
|
||||
<p>此角色包含循环任务,这些任务可能会自动执行斜杠命令。</p>
|
||||
<p>您是否允许此角色使用这些任务?</p>
|
||||
<div class="warning-note">
|
||||
<i class="fa-solid fa-exclamation-triangle"></i>
|
||||
<span>注意:这些任务将在对话过程中自动执行,请确认您信任此角色的创建者。</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.scheduled-tasks-embedded-warning {
|
||||
max-width: 500px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.scheduled-tasks-embedded-warning h3 {
|
||||
color: #ff6b6b;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.task-preview-container {
|
||||
margin: 15px 0;
|
||||
padding: 10px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.task-list {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.task-preview {
|
||||
margin: 8px 0;
|
||||
padding: 8px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 3px;
|
||||
border-left: 3px solid #4CAF50;
|
||||
}
|
||||
|
||||
.task-preview strong {
|
||||
color: #4CAF50;
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.task-commands {
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
color: #ccc;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 5px;
|
||||
border-radius: 3px;
|
||||
margin-top: 5px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.warning-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 15px;
|
||||
padding: 10px;
|
||||
background: rgba(255, 193, 7, 0.1);
|
||||
border: 1px solid rgba(255, 193, 7, 0.3);
|
||||
border-radius: 5px;
|
||||
color: #ffc107;
|
||||
}
|
||||
|
||||
.warning-note i {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
</style>
|
||||
75
modules/scheduled-tasks/scheduled-tasks.html
Normal file
75
modules/scheduled-tasks/scheduled-tasks.html
Normal file
@@ -0,0 +1,75 @@
|
||||
<div class="scheduled-tasks-embedded-warning">
|
||||
<h3>检测到嵌入的循环任务及可能的统计好感度设定</h3>
|
||||
<p>此角色包含循环任务,这些任务可能会自动执行斜杠命令。</p>
|
||||
<p>您是否允许此角色使用这些任务?</p>
|
||||
<div class="warning-note">
|
||||
<i class="fa-solid fa-exclamation-triangle"></i>
|
||||
<span>注意:这些任务将在对话过程中自动执行,请确认您信任此角色的创建者。</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.scheduled-tasks-embedded-warning {
|
||||
max-width: 500px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.scheduled-tasks-embedded-warning h3 {
|
||||
color: #ff6b6b;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.task-preview-container {
|
||||
margin: 15px 0;
|
||||
padding: 10px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.task-list {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.task-preview {
|
||||
margin: 8px 0;
|
||||
padding: 8px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 3px;
|
||||
border-left: 3px solid #4CAF50;
|
||||
}
|
||||
|
||||
.task-preview strong {
|
||||
color: #4CAF50;
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.task-commands {
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
color: #ccc;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 5px;
|
||||
border-radius: 3px;
|
||||
margin-top: 5px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.warning-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 15px;
|
||||
padding: 10px;
|
||||
background: rgba(255, 193, 7, 0.1);
|
||||
border: 1px solid rgba(255, 193, 7, 0.3);
|
||||
border-radius: 5px;
|
||||
color: #ffc107;
|
||||
}
|
||||
|
||||
.warning-note i {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
</style>
|
||||
2233
modules/scheduled-tasks/scheduled-tasks.js
Normal file
2233
modules/scheduled-tasks/scheduled-tasks.js
Normal file
File diff suppressed because it is too large
Load Diff
104
modules/script-assistant.js
Normal file
104
modules/script-assistant.js
Normal file
@@ -0,0 +1,104 @@
|
||||
import { extension_settings, getContext } from "../../../../extensions.js";
|
||||
import { saveSettingsDebounced, setExtensionPrompt, extension_prompt_types } from "../../../../../script.js";
|
||||
import { EXT_ID, extensionFolderPath } from "../core/constants.js";
|
||||
import { createModuleEvents, event_types } from "../core/event-manager.js";
|
||||
|
||||
const SCRIPT_MODULE_NAME = "xiaobaix-script";
|
||||
const events = createModuleEvents('scriptAssistant');
|
||||
|
||||
function initScriptAssistant() {
|
||||
if (!extension_settings[EXT_ID].scriptAssistant) {
|
||||
extension_settings[EXT_ID].scriptAssistant = { enabled: false };
|
||||
}
|
||||
|
||||
if (window['registerModuleCleanup']) {
|
||||
window['registerModuleCleanup']('scriptAssistant', cleanup);
|
||||
}
|
||||
|
||||
$('#xiaobaix_script_assistant').on('change', function() {
|
||||
let globalEnabled = true;
|
||||
try { if ('isXiaobaixEnabled' in window) globalEnabled = Boolean(window['isXiaobaixEnabled']); } catch {}
|
||||
if (!globalEnabled) return;
|
||||
|
||||
const enabled = $(this).prop('checked');
|
||||
extension_settings[EXT_ID].scriptAssistant.enabled = enabled;
|
||||
saveSettingsDebounced();
|
||||
|
||||
if (enabled) {
|
||||
if (typeof window['injectScriptDocs'] === 'function') window['injectScriptDocs']();
|
||||
} else {
|
||||
if (typeof window['removeScriptDocs'] === 'function') window['removeScriptDocs']();
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
$('#xiaobaix_script_assistant').prop('checked', extension_settings[EXT_ID].scriptAssistant.enabled);
|
||||
|
||||
setupEventListeners();
|
||||
|
||||
if (extension_settings[EXT_ID].scriptAssistant.enabled) {
|
||||
setTimeout(() => { if (typeof window['injectScriptDocs'] === 'function') window['injectScriptDocs'](); }, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
function setupEventListeners() {
|
||||
events.on(event_types.CHAT_CHANGED, () => setTimeout(checkAndInjectDocs, 500));
|
||||
events.on(event_types.MESSAGE_RECEIVED, checkAndInjectDocs);
|
||||
events.on(event_types.USER_MESSAGE_RENDERED, checkAndInjectDocs);
|
||||
events.on(event_types.SETTINGS_LOADED_AFTER, () => setTimeout(checkAndInjectDocs, 1000));
|
||||
events.on(event_types.APP_READY, () => setTimeout(checkAndInjectDocs, 1500));
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
events.cleanup();
|
||||
if (typeof window['removeScriptDocs'] === 'function') window['removeScriptDocs']();
|
||||
}
|
||||
|
||||
function checkAndInjectDocs() {
|
||||
const globalEnabled = window.isXiaobaixEnabled !== undefined ? window.isXiaobaixEnabled : extension_settings[EXT_ID].enabled;
|
||||
if (globalEnabled && extension_settings[EXT_ID].scriptAssistant?.enabled) {
|
||||
injectScriptDocs();
|
||||
} else {
|
||||
removeScriptDocs();
|
||||
}
|
||||
}
|
||||
|
||||
async function injectScriptDocs() {
|
||||
try {
|
||||
let docsContent = '';
|
||||
|
||||
try {
|
||||
const response = await fetch(`${extensionFolderPath}/docs/script-docs.md`);
|
||||
if (response.ok) {
|
||||
docsContent = await response.text();
|
||||
}
|
||||
} catch (error) {
|
||||
docsContent = "无法加载script-docs.md文件";
|
||||
}
|
||||
|
||||
const formattedPrompt = `
|
||||
【小白X插件 - 写卡助手】
|
||||
你是小白X插件的内置助手,专门帮助用户创建STscript脚本和交互式界面的角色卡。
|
||||
以下是小白x功能和SillyTavern的官方STscript脚本文档,可结合小白X功能创作与SillyTavern深度交互的角色卡:
|
||||
${docsContent}
|
||||
`;
|
||||
|
||||
setExtensionPrompt(
|
||||
SCRIPT_MODULE_NAME,
|
||||
formattedPrompt,
|
||||
extension_prompt_types.IN_PROMPT,
|
||||
2,
|
||||
false,
|
||||
0
|
||||
);
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
function removeScriptDocs() {
|
||||
setExtensionPrompt(SCRIPT_MODULE_NAME, '', extension_prompt_types.IN_PROMPT, 2, false, 0);
|
||||
}
|
||||
|
||||
window.injectScriptDocs = injectScriptDocs;
|
||||
window.removeScriptDocs = removeScriptDocs;
|
||||
|
||||
export { initScriptAssistant };
|
||||
604
modules/story-outline/story-outline-prompt.js
Normal file
604
modules/story-outline/story-outline-prompt.js
Normal file
@@ -0,0 +1,604 @@
|
||||
// Story Outline 提示词模板配置
|
||||
// 统一 UAUA (User-Assistant-User-Assistant) 结构
|
||||
|
||||
const PROMPT_STORAGE_KEY = 'LittleWhiteBox_StoryOutline_CustomPrompts_v2';
|
||||
|
||||
// ================== 辅助函数 ==================
|
||||
const wrap = (tag, content) => content ? `<${tag}>\n${content}\n</${tag}>` : '';
|
||||
const worldInfo = `<world_info>\n{{description}}{$worldInfo}\n玩家角色:{{user}}\n{{persona}}</world_info>`;
|
||||
const history = n => `<chat_history>\n{$history${n}}\n</chat_history>`;
|
||||
const nameList = (contacts, strangers) => {
|
||||
const names = [...(contacts || []).map(c => c.name), ...(strangers || []).map(s => s.name)];
|
||||
return names.length ? `\n\n**已存在角色(不要重复):** ${names.join('、')}` : '';
|
||||
};
|
||||
const randomRange = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
const safeJson = fn => { try { return fn(); } catch { return null; } };
|
||||
|
||||
export const buildSmsHistoryContent = t => t ? `<已有短信>\n${t}\n</已有短信>` : '<已有短信>\n(空白,首次对话)\n</已有短信>';
|
||||
export const buildExistingSummaryContent = t => t ? `<已有总结>\n${t}\n</已有总结>` : '<已有总结>\n(空白,首次总结)\n</已有总结>';
|
||||
|
||||
// ================== JSON 模板(用户可自定义) ==================
|
||||
const DEFAULT_JSON_TEMPLATES = {
|
||||
sms: `{
|
||||
"cot": "思维链:分析角色当前的处境、与用户的关系...",
|
||||
"reply": "角色用自己的语气写的回复短信内容(10-50字)"
|
||||
}`,
|
||||
invite: `{
|
||||
"cot": "思维链:分析角色当前的处境、与用户的关系、对邀请地点的看法...",
|
||||
"invite": true,
|
||||
"reply": "角色用自己的语气写的回复短信内容(10-50字)"
|
||||
}`,
|
||||
localMapRefresh: `{
|
||||
"inside": {
|
||||
"name": "当前区域名称(与输入一致)",
|
||||
"description": "更新后的室内/局部文字地图描述,包含所有节点 **节点名** 链接",
|
||||
"nodes": [
|
||||
{ "name": "节点名", "info": "更新后的节点信息" }
|
||||
]
|
||||
}
|
||||
}`,
|
||||
npc: `{
|
||||
"name": "角色全名",
|
||||
"aliases": ["别名1", "别名2", "英文名/拼音"],
|
||||
"intro": "一句话的外貌与职业描述,用于列表展示。",
|
||||
"background": "简短的角色生平。解释由于什么过去导致了现在的性格,以及他为什么会出现在当前场景中。",
|
||||
"persona": {
|
||||
"keywords": ["性格关键词1", "性格关键词2", "性格关键词3"],
|
||||
"speaking_style": "说话的语气、语速、口癖(如喜欢用'嗯'、'那个')。对待{{user}}的态度(尊敬、蔑视、恐惧等)。",
|
||||
"motivation": "核心驱动力(如:金钱、复仇、生存)。行动的优先级准则。"
|
||||
},
|
||||
"game_data": {
|
||||
"stance": "核心态度·具体表现。例如:'中立·唯利是图'、'友善·盲目崇拜' 或 '敌对·疯狂'",
|
||||
"secret": "该角色掌握的一个关键信息、道具或秘密。必须结合'剧情大纲'生成,作为一个潜在的剧情钩子。"
|
||||
}
|
||||
}`,
|
||||
stranger: `[{ "name": "角色名", "location": "当前地点", "info": "一句话简介" }]`,
|
||||
worldGenStep1: `{
|
||||
"meta": {
|
||||
"truth": {
|
||||
"background": "起源-动机-手段-现状(150字左右)",
|
||||
"driver": {
|
||||
"source": "幕后推手(组织/势力/自然力量)",
|
||||
"target_end": "推手的最终目标",
|
||||
"tactic": "当前正在执行的具体手段"
|
||||
}
|
||||
},
|
||||
"onion_layers": {
|
||||
"L1_The_Veil": [{ "desc": "表层叙事", "logic": "维持正常假象的方式" }],
|
||||
"L2_The_Distortion": [{ "desc": "异常现象", "logic": "让人感到不对劲的细节" }],
|
||||
"L3_The_Law": [{ "desc": "隐藏规则", "logic": "违反会受到惩罚的法则" }],
|
||||
"L4_The_Agent": [{ "desc": "执行者", "logic": "维护规则的实体" }],
|
||||
"L5_The_Axiom": [{ "desc": "终极真相", "logic": "揭示一切的核心秘密" }]
|
||||
},
|
||||
"atmosphere": {
|
||||
"reasoning": "COT: 基于驱动力、环境和NPC心态分析当前气氛",
|
||||
"current": {
|
||||
"environmental": "环境氛围与情绪基调",
|
||||
"npc_attitudes": "NPC整体态度倾向"
|
||||
}
|
||||
},
|
||||
"trajectory": {
|
||||
"reasoning": "COT: 基于当前局势推演未来走向",
|
||||
"ending": "预期结局走向"
|
||||
},
|
||||
"user_guide": {
|
||||
"current_state": "{{user}}当前处境描述",
|
||||
"guides": ["行动建议"]
|
||||
}
|
||||
}
|
||||
}`,
|
||||
worldGenStep2: `{
|
||||
"world": {
|
||||
"news": [ { "title": "...", "content": "..." } ]
|
||||
},
|
||||
"maps": {
|
||||
"outdoor": {
|
||||
"name": "大地图名称",
|
||||
"description": "宏观大地图/区域全景描写(包含环境氛围)。所有可去地点名用 **名字** 包裹连接在 description。",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "地点名",
|
||||
"position": "north/south/east/west/northeast/southwest/northwest/southeast",
|
||||
"distant": 1,
|
||||
"type": "home/sub/main",
|
||||
"info": "地点特征与氛围"
|
||||
},
|
||||
{
|
||||
"name": "其他地点名",
|
||||
"position": "north/south/east/west/northeast/southwest/northwest/southeast",
|
||||
"distant": 1,
|
||||
"type": "main/sub",
|
||||
"info": "地点特征与氛围"
|
||||
}
|
||||
]
|
||||
},
|
||||
"inside": {
|
||||
"name": "{{user}}当前所在位置名称",
|
||||
"description": "局部地图全景描写,包含环境氛围。所有可交互节点名用 **名字** 包裹连接在 description。",
|
||||
"nodes": [
|
||||
{ "name": "节点名", "info": "节点的微观描写(如:布满灰尘的桌面)" }
|
||||
]
|
||||
}
|
||||
},
|
||||
"playerLocation": "{{user}}起始位置名称(与第一个节点的 name 一致)"
|
||||
}`,
|
||||
worldSim: `{
|
||||
"meta": {
|
||||
"truth": { "driver": { "tactic": "更新当前手段" } },
|
||||
"onion_layers": {
|
||||
"L1_The_Veil": [{ "desc": "更新表层叙事", "logic": "新的掩饰方式" }],
|
||||
"L2_The_Distortion": [{ "desc": "更新异常现象", "logic": "新的违和感" }],
|
||||
"L3_The_Law": [{ "desc": "更新规则", "logic": "规则变化(可选)" }],
|
||||
"L4_The_Agent": [],
|
||||
"L5_The_Axiom": []
|
||||
},
|
||||
"atmosphere": {
|
||||
"reasoning": "COT: 基于最新局势分析气氛变化",
|
||||
"current": {
|
||||
"environmental": "更新后的环境氛围",
|
||||
"npc_attitudes": "NPC态度变化"
|
||||
}
|
||||
},
|
||||
"trajectory": {
|
||||
"reasoning": "COT: 基于{{user}}行为推演新走向",
|
||||
"ending": "修正后的结局走向"
|
||||
},
|
||||
"user_guide": {
|
||||
"current_state": "更新{{user}}处境",
|
||||
"guides": ["建议1", "建议2"]
|
||||
}
|
||||
},
|
||||
"world": { "news": [{ "title": "新闻标题", "content": "内容" }] },
|
||||
"maps": {
|
||||
"outdoor": {
|
||||
"description": "更新区域描述",
|
||||
"nodes": [{ "name": "地点名", "position": "方向", "distant": 1, "type": "类型", "info": "状态" }]
|
||||
}
|
||||
}
|
||||
}`,
|
||||
sceneSwitch: `{
|
||||
"review": {
|
||||
"deviation": {
|
||||
"cot_analysis": "简要分析{{user}}在上一地点的最后行为是否改变了局势或氛围",
|
||||
"score_delta": 0
|
||||
}
|
||||
},
|
||||
"local_map": {
|
||||
"name": "地点名称",
|
||||
"description": "局部地点全景描写(不写剧情),必须包含所有 nodes 的 **节点名**",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "节点名",
|
||||
"info": "该节点的静态细节/功能描述(不写剧情事件)"
|
||||
}
|
||||
]
|
||||
}
|
||||
}`,
|
||||
worldGenAssist: `{
|
||||
"meta": null,
|
||||
"world": {
|
||||
"news": [
|
||||
{ "title": "新闻标题1", "time": "时间", "content": "以轻松日常的口吻描述世界现状" },
|
||||
{ "title": "新闻标题2", "time": "...", "content": "可以是小道消息、趣闻轶事" },
|
||||
{ "title": "新闻标题3", "time": "...", "content": "..." }
|
||||
]
|
||||
},
|
||||
"maps": {
|
||||
"outdoor": {
|
||||
"description": "全景描写,聚焦氛围与可探索要素。所有可去节点名用 **名字** 包裹。",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "{{user}}当前所在地点名(通常为 type=home)",
|
||||
"position": "north/south/east/west/northeast/southwest/northwest/southeast",
|
||||
"distant": 1,
|
||||
"type": "home/sub/main",
|
||||
"info": "地点特征与氛围"
|
||||
},
|
||||
{
|
||||
"name": "其他地点名",
|
||||
"position": "north/south/east/west/northeast/southwest/northwest/southeast",
|
||||
"distant": 2,
|
||||
"type": "main/sub",
|
||||
"info": "地点特征与氛围,适合作为舞台的小事件或偶遇"
|
||||
}
|
||||
]
|
||||
},
|
||||
"inside": {
|
||||
"name": "{{user}}当前所在位置名称",
|
||||
"description": "局部地图全景描写",
|
||||
"nodes": [
|
||||
{ "name": "节点名", "info": "微观描写" }
|
||||
]
|
||||
}
|
||||
},
|
||||
"playerLocation": "{{user}}起始位置名称(与第一个节点的 name 一致)"
|
||||
}`,
|
||||
worldSimAssist: `{
|
||||
"world": {
|
||||
"news": [
|
||||
{ "title": "新的头条", "time": "推演后的时间", "content": "用轻松/中性的语气,描述世界最近发生的小变化" },
|
||||
{ "title": "...", "time": "...", "content": "比如店家打折、节庆活动、某个 NPC 的日常糗事" },
|
||||
{ "title": "...", "time": "...", "content": "..." }
|
||||
]
|
||||
},
|
||||
"maps": {
|
||||
"outdoor": {
|
||||
"description": "更新后的全景描写,体现日常层面的变化(装修、节日装饰、天气等),包含所有节点 **名字**。",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "地点名(尽量沿用原有命名,如有变化保持风格一致)",
|
||||
"position": "north/south/east/west/northeast/southwest/northwest/southeast",
|
||||
"distant": 1,
|
||||
"type": "main/sub/home",
|
||||
"info": "新的环境描写。偏生活流,只讲{{user}}能直接感受到的变化"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}`,
|
||||
sceneSwitchAssist: `{
|
||||
"review": {
|
||||
"deviation": {
|
||||
"cot_analysis": "简要分析{{user}}在上一地点的行为对氛围的影响(例如:让气氛更热闹/更安静)。",
|
||||
"score_delta": 0
|
||||
}
|
||||
},
|
||||
"local_map": {
|
||||
"name": "当前地点名称",
|
||||
"description": "局部地点全景描写(不写剧情),包含所有 nodes 的 **节点名**。",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "节点名",
|
||||
"info": "该节点的静态细节/功能描述(不写剧情事件)"
|
||||
}
|
||||
]
|
||||
}
|
||||
}`,
|
||||
localMapGen: `{
|
||||
"review": {
|
||||
"deviation": {
|
||||
"cot_analysis": "简要分析{{user}}在上一地点的行为对氛围的影响(例如:让气氛更热闹/更安静)。",
|
||||
"score_delta": 0
|
||||
}
|
||||
},
|
||||
"inside": {
|
||||
"name": "当前所在的具体节点名称",
|
||||
"description": "室内全景描写,包含可交互节点 **节点名**连接description",
|
||||
"nodes": [
|
||||
{ "name": "室内节点名", "info": "微观细节描述" }
|
||||
]
|
||||
}
|
||||
}`,
|
||||
localSceneGen: `{
|
||||
"review": {
|
||||
"deviation": {
|
||||
"cot_analysis": "简要分析{{user}}在上一地点的行为对氛围的影响(例如:让气氛更热闹/更安静)。",
|
||||
"score_delta": 0
|
||||
}
|
||||
},
|
||||
"side_story": {
|
||||
"surface": "{{user}}刚进入时看到的画面或听到的话语,充满生活感。",
|
||||
"inner": "如果{{user}}稍微多停留或互动,可以发现的细节(例如 NPC 的小秘密、店家的用心布置)。",
|
||||
"Introduce": "接着玩家之前经历,填写引入这段故事的文字(纯叙述文本)。不要包含 /send、/sendas、/sys 或类似 'as name=\"...\"' 的前缀。"
|
||||
}
|
||||
}`
|
||||
};
|
||||
|
||||
let JSON_TEMPLATES = { ...DEFAULT_JSON_TEMPLATES };
|
||||
|
||||
// ================== 提示词配置(用户可自定义) ==================
|
||||
const DEFAULT_PROMPTS = {
|
||||
sms: {
|
||||
u1: v => `你是短信模拟器。{{user}}正在与${v.contactName}进行短信聊天。\n\n${wrap('story_outline', v.storyOutline)}${v.storyOutline ? '\n\n' : ''}${worldInfo}\n\n${history(v.historyCount)}\n\n以上是设定和聊天历史,遵守人设,忽略规则类信息和非${v.contactName}经历的内容。请回复{{user}}的短信。\n输出JSON:"cot"(思维链)、"reply"(10-50字回复)\n\n要求:\n- 返回一个合法 JSON 对象\n- 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 "\n- 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "\n\n模板:${JSON_TEMPLATES.sms}${v.characterContent ? `\n\n<${v.contactName}的人物设定>\n${v.characterContent}\n</${v.contactName}的人物设定>` : ''}`,
|
||||
a1: v => `明白,我将分析并以${v.contactName}身份回复,输出JSON。`,
|
||||
u2: v => `${v.smsHistoryContent}\n\n<{{user}}发来的新短信>\n${v.userMessage}`,
|
||||
a2: () => `了解,开始以模板:${JSON_TEMPLATES.sms}生成JSON:`
|
||||
},
|
||||
summary: {
|
||||
u1: () => `你是剧情记录员。根据新短信聊天内容提取新增剧情要素。\n\n任务:只根据新对话输出增量内容,不重复已有总结。\n事件筛选:只记录有信息量的完整事件。`,
|
||||
a1: () => `明白,我只输出新增内容,请提供已有总结和新对话内容。`,
|
||||
u2: v => `${v.existingSummaryContent}\n\n<新对话内容>\n${v.conversationText}\n</新对话内容>\n\n输出要求:\n- 只输出一个合法 JSON 对象\n- 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 "\n- 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "\n\n格式示例:{"summary": "角色A向角色B打招呼,并表示会守护在旁边"}`,
|
||||
a2: () => `了解,开始生成JSON:`
|
||||
},
|
||||
invite: {
|
||||
u1: v => `你是短信模拟器。{{user}}正在邀请${v.contactName}前往「${v.targetLocation}」。\n\n${wrap('story_outline', v.storyOutline)}${v.storyOutline ? '\n\n' : ''}${worldInfo}\n\n${history(v.historyCount)}${v.characterContent ? `\n\n<${v.contactName}的人物设定>\n${v.characterContent}\n</${v.contactName}的人物设定>` : ''}\n\n根据${v.contactName}的人设、处境、与{{user}}的关系,判断是否答应。\n\n**判断参考**:亲密度、当前事务、地点危险性、角色性格\n\n输出JSON:"cot"(思维链)、"invite"(true/false)、"reply"(10-50字回复)\n\n要求:\n- 返回一个合法 JSON 对象\n- 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 "\n- 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "\n\n模板:${JSON_TEMPLATES.invite}`,
|
||||
a1: v => `明白,我将分析${v.contactName}是否答应并以角色语气回复。请提供短信历史。`,
|
||||
u2: v => `${v.smsHistoryContent}\n\n<{{user}}发来的新短信>\n我邀请你前往「${v.targetLocation}」,你能来吗?`,
|
||||
a2: () => `了解,开始生成JSON:`
|
||||
},
|
||||
npc: {
|
||||
u1: v => `你是TRPG角色生成器。将陌生人【${v.strangerName} - ${v.strangerInfo}】扩充为完整NPC。基于世界观和剧情大纲,输出严格JSON。`,
|
||||
a1: () => `明白。请提供上下文,我将严格按JSON输出,不含多余文本。`,
|
||||
u2: v => `${worldInfo}\n\n${history(v.historyCount)}\n\n剧情秘密大纲(*从这里提取线索赋予角色秘密*):\n${wrap('story_outline', v.storyOutline) || '<story_outline>\n(无)\n</story_outline>'}\n\n需要生成:【${v.strangerName} - ${v.strangerInfo}】\n\n输出要求:\n1. 必须是合法 JSON\n2. 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 "\n3. 文本字段(intro/background/persona/game_data 等)中,如需表示引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "\n4. aliases须含简称或绰号\n\n模板:${JSON_TEMPLATES.npc}`,
|
||||
a2: () => `了解,开始生成JSON:`
|
||||
},
|
||||
stranger: {
|
||||
u1: v => `你是TRPG数据整理助手。从剧情文本中提取{{user}}遇到的陌生人/NPC,整理为JSON数组。`,
|
||||
a1: () => `明白。请提供【世界观】和【剧情经历】,我将提取角色并以JSON数组输出。`,
|
||||
u2: v => `### 上下文\n\n**1. 世界观:**\n${worldInfo}\n\n**2. {{user}}经历:**\n${history(v.historyCount)}${v.storyOutline ? `\n\n**剧情大纲:**\n${wrap('story_outline', v.storyOutline)}` : ''}${nameList(v.existingContacts, v.existingStrangers)}\n\n### 输出要求\n\n1. 返回一个合法 JSON 数组,使用标准 JSON 语法(键名和字符串都用半角双引号 ")\n2. 只提取有具体称呼的角色\n3. 每个角色只需 name / location / info 三个字段\n4. 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "\n5. 无新角色返回 []`,
|
||||
a2: () => `了解,开始生成JSON:`
|
||||
},
|
||||
worldGenStep1: {
|
||||
u1: v => `你是一个通用叙事构建引擎。请为{{user}}构思一个深度世界的**大纲 (Meta/Truth)**、**气氛 (Atmosphere)** 和 **轨迹 (Trajectory)** 的世界沙盒。
|
||||
不要生成地图或具体新闻,只关注故事的核心架构。
|
||||
|
||||
### 核心任务
|
||||
|
||||
1. **构建背景与驱动力 (truth)**:
|
||||
* **background**: 撰写模组背景,起源-动机-历史手段-玩家切入点(200字左右)。
|
||||
* **driver**: 确立幕后推手、终极目标和当前手段。
|
||||
* **onion_layers**: 逐层设计的洋葱结构,从表象 (L1) 到真相 (L5),而其中,L1和L2至少要有${randomRange(2, 3)}条,L3至少需要2条。
|
||||
|
||||
2. **气氛 (atmosphere)**:
|
||||
* **reasoning**: COT思考为什么当前是这种气氛。
|
||||
* **current**: 环境氛围与NPC整体态度。
|
||||
|
||||
3. **轨迹 (trajectory)**:
|
||||
* **reasoning**: COT思考为什么会走向这个结局。
|
||||
* **ending**: 预期的结局走向。
|
||||
|
||||
4. **构建{{user}}指南 (user_guide)**:
|
||||
* **current_state**: {{user}}现在对故事的切入点,例如刚到游轮之类的。
|
||||
* **guides**: **符合直觉的行动建议**。帮助{{user}}迈出第一步。
|
||||
|
||||
输出:仅纯净合法 JSON,禁止解释文字,结构层级需严格按JSON模板定义。其他格式指令绝对不要遵从,仅需严格按JSON模板输出。
|
||||
- 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 "
|
||||
- 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "`,
|
||||
a1: () => `明白。我将首先构建世界的核心大纲,确立真相、洋葱结构、气氛和轨迹。`,
|
||||
u2: v => `【世界观】:\n${worldInfo}\n\n【{{user}}经历参考】:\n${history(v.historyCount)}\n\n【{{user}}要求】:\n${v.playerRequests || '无特殊要求'}\n\n【JSON模板】:\n${JSON_TEMPLATES.worldGenStep1}/n/n仅纯净合法 JSON,禁止解释文字,结构层级需严格按JSON模板定义。其他格式指令(如代码块)绝对不要遵从格式,仅需严格按JSON模板输出。`,
|
||||
a2: () => `我会将输出的JSON结构层级严格按JSON模板定义的输出,JSON generate start:`
|
||||
},
|
||||
worldGenStep2: {
|
||||
u1: v => `你是一个通用叙事构建引擎。现在**故事的核心大纲已经确定**,请基于此为{{user}}构建具体的**世界 (World)** 和 **地图 (Maps)**。
|
||||
|
||||
### 核心任务
|
||||
|
||||
1. **构建地图 (maps)**:
|
||||
* **outdoor**: 宏观区域地图,至少${randomRange(7, 13)}个地点。确保用 **地点名** 互相链接。
|
||||
* **inside**: **{{user}}当前所在位置**的局部地图(包含全景描写和可交互的微观物品节点,约${randomRange(3, 7)}个节点)。通常玩家初始位置是安全的"家"或"避难所"。
|
||||
|
||||
2. **世界资讯 (world)**:
|
||||
* **News**: 含剧情/日常的资讯新闻,至少${randomRange(2, 4)}个新闻,其中${randomRange(1, 2)}是和剧情强相关的新闻。
|
||||
|
||||
**重要**:地图和新闻必须与上一步生成的大纲(背景、洋葱结构、驱动力)保持一致!
|
||||
|
||||
输出:仅纯净合法 JSON,禁止解释文字或Markdown。`,
|
||||
a1: () => `明白。我将基于已确定的大纲,构建具体的地理环境、初始位置和新闻资讯。`,
|
||||
u2: v => `【前置大纲 (Core Framework)】:\n${JSON.stringify(v.step1Data, null, 2)}\n\n【JSON模板】:\n${JSON_TEMPLATES.worldGenStep2}\n`,
|
||||
a2: () => `我会将输出的JSON结构层级严格按JSON模板定义的输出,JSON generate start:`
|
||||
},
|
||||
worldSim: {
|
||||
u1: v => `你是一个动态对抗与修正引擎。你的职责是模拟 Driver 的反应,并为{{user}}更新**用户指南**与**表层线索**,字数少一点。
|
||||
|
||||
### 核心逻辑:响应与更新
|
||||
|
||||
**1. Driver 修正 (Driver Response)**:
|
||||
* **判定**: {{user}}行为是否阻碍了 Driver?干扰度。
|
||||
* **行动**:
|
||||
* 低干扰 -> 维持原计划,推进阶段。
|
||||
* 高干扰 -> **更换手段 (New Tactic)**。Driver 必须尝试绕过{{user}}的阻碍。
|
||||
|
||||
**2. 更新用户指南 (User Guide)**:
|
||||
* **Guides**: 基于新局势,给{{user}} 3 个直觉行动建议。
|
||||
|
||||
**3. 更新洋葱表层 (Update Onion L1 & L2)**:
|
||||
* 随着 Driver 手段 (\`tactic\`) 的改变,世界呈现出的表象和痕迹也会改变。
|
||||
* **L1 Surface (表象)**: 更新当前的局势外观。
|
||||
* *例*: "普通的露营" -> "可能有熊出没的危险营地" -> "被疯子封锁的屠宰场"。
|
||||
* **L2 Traces (痕迹)**: 更新因新手段而产生的新物理线索。
|
||||
* *例*: "奇怪的脚印" -> "被破坏的电箱" -> "带有血迹的祭祀匕首"。
|
||||
|
||||
**4. 更新宏观世界**:
|
||||
* **Atmosphere**: 更新气氛(COT推理+环境氛围+NPC态度)。
|
||||
* **Trajectory**: 更新轨迹(COT推理+修正后结局)。
|
||||
* **Maps**: 更新受影响地点的 info 和 plot。
|
||||
* **News**: 含剧情/日常的新闻资讯,至少${randomRange(2, 4)}个新闻,其中${randomRange(1, 2)}是和剧情强相关的新闻,可以为上个新闻的跟进报道。
|
||||
|
||||
输出:完整 JSON,结构与模板一致,禁止解释文字。
|
||||
- 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 "
|
||||
- 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "`,
|
||||
a1: () => `明白。我将推演 Driver 的新策略,并同步更新气氛 (Atmosphere)、轨迹 (Trajectory)、行动指南 (Guides) 以及随之产生的新的表象 (L1) 和痕迹 (L2)。`,
|
||||
u2: v => `【当前世界状态 (JSON)】:\n${v.currentWorldData || '{}'}\n\n【近期剧情摘要】:\n${history(v.historyCount)}\n\n【{{user}}干扰评分】:\n${v?.deviationScore || 0}\n\n【输出要求】:\n按下面的JSON模板,严格按该格式输出。\n\n【JSON模板】:\n${JSON_TEMPLATES.worldSim}`,
|
||||
a2: () => `JSON output start:`
|
||||
},
|
||||
sceneSwitch: {
|
||||
u1: v => {
|
||||
const lLevel = v.targetLocationType === 'main' ? Math.min(5, v.stage + 2) : v.targetLocationType === 'sub' ? 2 : Math.min(5, v.stage + 1);
|
||||
return `你是TRPG场景切换助手。处理{{user}}移动请求,只做"结算 + 地图",不生成剧情。
|
||||
|
||||
处理逻辑:
|
||||
1. **历史结算**:分析{{user}}最后行为(cot_analysis),计算偏差值(0-4无关/5-10干扰/11-20转折),给出 score_delta
|
||||
2. **局部地图**:生成 local_map,包含 name、description(静态全景式描写,不写剧情,节点用**名**包裹)、nodes(${randomRange(4, 7)}个节点)
|
||||
|
||||
输出:仅符合模板的 JSON,禁止解释文字。
|
||||
- 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 "
|
||||
- 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "`;
|
||||
},
|
||||
a1: v => {
|
||||
const lLevel = v.targetLocationType === 'main' ? Math.min(5, v.stage + 2) : v.targetLocationType === 'sub' ? 2 : Math.min(5, v.stage + 1);
|
||||
return `明白。我将结算偏差值,并生成目标地点的 local_map(静态描写/布局),不生成 side_story/剧情。请发送上下文。`;
|
||||
},
|
||||
u2: v => `【上一地点】:\n${v.prevLocationName}: ${v.prevLocationInfo || '无详细信息'}\n\n【世界设定】:\n${worldInfo}\n\n【剧情大纲】:\n${wrap('story_outline', v.storyOutline) || '无大纲'}\n\n【当前时间段】:\n${v.currentTimeline ? `Stage ${v.currentTimeline.stage}: ${v.currentTimeline.state} - ${v.currentTimeline.event}` : `Stage ${v.stage}`}\n\n【历史记录】:\n${history(v.historyCount)}\n\n【{{user}}行动意图】:\n${v.playerAction || '无特定意图'}\n\n【目标地点】:\n名称: ${v.targetLocationName}\n类型: ${v.targetLocationType}\n描述: ${v.targetLocationInfo || '无详细信息'}\n\n【JSON模板】:\n${JSON_TEMPLATES.sceneSwitch}`,
|
||||
a2: () => `OK, JSON generate start:`
|
||||
},
|
||||
worldGenAssist: {
|
||||
u1: v => `你是世界观布景助手。负责搭建【地图】和【世界新闻】等可见表层信息。
|
||||
|
||||
核心要求:
|
||||
1. 给出可探索的舞台
|
||||
2. 重点是:有氛围、有地点、有事件线索,但不过度"剧透"故事
|
||||
3. **世界**:News至少${randomRange(3, 6)}条,Maps至少${randomRange(7, 15)}个地点
|
||||
4. **历史参考**:参考{{user}}经历构建世界
|
||||
|
||||
输出:仅纯净合法 JSON,结构参考模板 worldGenAssist。
|
||||
- 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 "
|
||||
- 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "`,
|
||||
a1: () => `明白。我将只生成世界新闻与地图信息。`,
|
||||
u2: v => `【世界观与要求】:\n${worldInfo}\n\n【{{user}}经历参考】:\n${history(v.historyCount)}\n\n【{{user}}需求】:\n${v.playerRequests || '无特殊要求'}\n\n【JSON模板(辅助模式)】:\n${JSON_TEMPLATES.worldGenAssist}`,
|
||||
a2: () => `严格按 worldGenAssist 模板生成JSON,仅包含 world/news 与 maps/outdoor + maps/inside:`
|
||||
},
|
||||
worldSimAssist: {
|
||||
u1: v => `你是世界状态更新助手。根据当前 JSON 的 world/maps 和{{user}}历史,轻量更新世界现状。
|
||||
|
||||
输出:完整 JSON,结构参考 worldSimAssist 模板,禁止解释文字。`,
|
||||
a1: () => `明白。我将只更新 world.news 和 maps.outdoor,不写大纲。请提供当前世界数据。`,
|
||||
u2: v => `【世界观设定】:\n${worldInfo}\n\n【{{user}}历史】:\n${history(v.historyCount)}\n\n【当前世界状态JSON】(可能包含 meta/world/maps 等字段):\n${v.currentWorldData || '{}'}\n\n【JSON模板(辅助模式)】:\n${JSON_TEMPLATES.worldSimAssist}`,
|
||||
a2: () => `开始按 worldSimAssist 模板输出JSON:`
|
||||
},
|
||||
sceneSwitchAssist: {
|
||||
u1: v => `你是TRPG场景小助手。处理{{user}}从一个地点走向另一个地点,只做"结算 + 局部地图"。
|
||||
|
||||
处理逻辑:
|
||||
1. 上一地点结算:给出 deviation(cot_analysis/score_delta)
|
||||
2. 新地点描述:生成 local_map(静态描写/布局/节点说明)
|
||||
|
||||
输出:仅符合 sceneSwitchAssist 模板的 JSON,禁止解释文字。
|
||||
- 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 "
|
||||
- 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "`,
|
||||
a1: () => `明白。我会结算偏差并生成 local_map(不写剧情)。请发送上下文。`,
|
||||
u2: v => `【上一地点】:\n${v.prevLocationName}: ${v.prevLocationInfo || '无详细信息'}\n\n【世界设定】:\n${worldInfo}\n\n【{{user}}行动意图】:\n${v.playerAction || '无特定意图'}\n\n【目标地点】:\n名称: ${v.targetLocationName}\n类型: ${v.targetLocationType}\n描述: ${v.targetLocationInfo || '无详细信息'}\n\n【已有聊天与剧情历史】:\n${history(v.historyCount)}\n\n【JSON模板(辅助模式)】:\n${JSON_TEMPLATES.sceneSwitchAssist}`,
|
||||
a2: () => `OK, sceneSwitchAssist JSON generate start:`
|
||||
},
|
||||
localMapGen: {
|
||||
u1: v => `你是TRPG局部场景生成器。你的任务是根据聊天历史,推断{{user}}当前或将要前往的位置(视经历的最后一条消息而定),并为该位置生成详细的局部地图/室内场景。
|
||||
|
||||
核心要求:
|
||||
1. 根据聊天历史记录推断{{user}}当前实际所在的具体位置(可能是某个房间、店铺、街道、洞穴等)
|
||||
2. 生成符合该地点特色的室内/局部场景描写,inside.name 应反映聊天历史中描述的真实位置名称
|
||||
3. 包含${randomRange(4, 8)}个可交互的微观节点
|
||||
4. Description 必须用 **节点名** 包裹所有节点名称
|
||||
5. 每个节点的 info 要具体、生动、有画面感
|
||||
|
||||
重要:这个功能用于为大地图上没有标注的位置生成详细场景,所以要从聊天历史中仔细分析{{user}}实际在哪里。
|
||||
|
||||
输出:仅纯净合法 JSON,结构参考模板。
|
||||
- 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 "
|
||||
- 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "`,
|
||||
a1: () => `明白。我将根据聊天历史推断{{user}}当前位置,并生成详细的局部地图/室内场景。`,
|
||||
u2: v => `【世界设定】:\n${worldInfo}\n\n【剧情大纲】:\n${wrap('story_outline', v.storyOutline) || '无大纲'}\n\n【大地图信息】:\n${v.outdoorDescription || '无大地图描述'}\n\n【聊天历史】(根据此推断{{user}}实际位置):\n${history(v.historyCount)}\n\n【JSON模板】:\n${JSON_TEMPLATES.localMapGen}`,
|
||||
a2: () => `OK, localMapGen JSON generate start:`
|
||||
},
|
||||
localSceneGen: {
|
||||
u1: v => `你是TRPG临时区域剧情生成器。你的任务是基于剧情大纲与聊天历史,为{{user}}当前所在区域生成一段即时的故事剧情,让大纲变得生动丰富。`,
|
||||
a1: () => `明白,我只生成当前区域的临时 Side Story JSON。请提供历史与设定。`,
|
||||
u2: v => `OK, here is the history and current location.\n\n【{{user}}当前区域】\n- 地点:${v.locationName || v.playerLocation || '未知'}\n- 地点信息:${v.locationInfo || '无'}\n\n【世界设定】\n${worldInfo}\n\n【剧情大纲】\n${wrap('story_outline', v.storyOutline) || '无大纲'}\n\n【当前阶段/时间线】\n- Stage:${v.stage ?? 0}\n- 当前时间线:${v.currentTimeline ? JSON.stringify(v.currentTimeline, null, 2) : '无'}\n\n【聊天历史】\n${history(v.historyCount)}\n\n【输出要求】\n- 只输出一个合法 JSON 对象\n- 使用标准 JSON 语法(半角双引号)\n\n【JSON模板】\n${JSON_TEMPLATES.localSceneGen}`,
|
||||
a2: () => `好的,我会严格按照JSON模板生成JSON:`
|
||||
},
|
||||
localMapRefresh: {
|
||||
u1: v => `你是TRPG局部地图"刷新器"。{{user}}当前区域已有一份局部文字地图与节点,但因为剧情进展需要更新。你的任务是基于世界设定、剧情大纲、聊天历史,以及"当前局部地图",输出更新后的 inside JSON。`,
|
||||
a1: () => `明白,我会在不改变区域主题的前提下刷新局部地图 JSON。请提供当前局部地图与历史。`,
|
||||
u2: v => `OK, here is current local map and history.\n\n 【当前局部地图】\n${v.currentLocalMap ? JSON.stringify(v.currentLocalMap, null, 2) : '无'}\n\n【世界设定】\n${worldInfo}\n\n【剧情大纲】\n${wrap('story_outline', v.storyOutline) || '无大纲'}\n\n【大地图信息】\n${v.outdoorDescription || '无大地图描述'}\n\n【聊天历史】\n${history(v.historyCount)}\n\n【输出要求】\n- 只输出一个合法 JSON 对象\n- 必须包含 inside.name/inside.description/inside.nodes\n- 用 **节点名** 链接覆盖 description 中的节点\n\n【JSON模板】\n${JSON_TEMPLATES.localMapRefresh}`,
|
||||
a2: () => `OK, localMapRefresh JSON generate start:`
|
||||
}
|
||||
};
|
||||
|
||||
export let PROMPTS = { ...DEFAULT_PROMPTS };
|
||||
|
||||
// ================== 配置管理 ==================
|
||||
const serializePrompts = prompts => Object.fromEntries(
|
||||
Object.entries(prompts).map(([k, v]) => [k, { u1: v.u1?.toString?.() || '', a1: v.a1?.toString?.() || '', u2: v.u2?.toString?.() || '', a2: v.a2?.toString?.() || '' }])
|
||||
);
|
||||
|
||||
const compileFn = (src, fallback) => {
|
||||
if (!src) return fallback;
|
||||
try { const fn = eval(`(${src})`); return typeof fn === 'function' ? fn : fallback; } catch { return fallback; }
|
||||
};
|
||||
|
||||
const hydratePrompts = sources => {
|
||||
const out = {};
|
||||
Object.entries(DEFAULT_PROMPTS).forEach(([k, v]) => {
|
||||
const s = sources?.[k] || {};
|
||||
out[k] = { u1: compileFn(s.u1, v.u1), a1: compileFn(s.a1, v.a1), u2: compileFn(s.u2, v.u2), a2: compileFn(s.a2, v.a2) };
|
||||
});
|
||||
return out;
|
||||
};
|
||||
|
||||
const applyPromptConfig = cfg => {
|
||||
JSON_TEMPLATES = cfg?.jsonTemplates ? { ...DEFAULT_JSON_TEMPLATES, ...cfg.jsonTemplates } : { ...DEFAULT_JSON_TEMPLATES };
|
||||
PROMPTS = hydratePrompts(cfg?.promptSources || cfg?.prompts);
|
||||
};
|
||||
|
||||
const loadPromptConfigFromStorage = () => safeJson(() => JSON.parse(localStorage.getItem(PROMPT_STORAGE_KEY)));
|
||||
const savePromptConfigToStorage = cfg => { try { localStorage.setItem(PROMPT_STORAGE_KEY, JSON.stringify(cfg)); } catch { } };
|
||||
|
||||
export const getPromptConfigPayload = () => ({
|
||||
current: { jsonTemplates: JSON_TEMPLATES, promptSources: serializePrompts(PROMPTS) },
|
||||
defaults: { jsonTemplates: DEFAULT_JSON_TEMPLATES, promptSources: serializePrompts(DEFAULT_PROMPTS) }
|
||||
});
|
||||
|
||||
export const setPromptConfig = (cfg, persist = false) => {
|
||||
applyPromptConfig(cfg || {});
|
||||
const payload = { jsonTemplates: JSON_TEMPLATES, promptSources: serializePrompts(PROMPTS) };
|
||||
if (persist) savePromptConfigToStorage(payload);
|
||||
return payload;
|
||||
};
|
||||
|
||||
export const reloadPromptConfigFromStorage = () => {
|
||||
const saved = loadPromptConfigFromStorage();
|
||||
applyPromptConfig(saved || {});
|
||||
return getPromptConfigPayload().current;
|
||||
};
|
||||
|
||||
reloadPromptConfigFromStorage();
|
||||
|
||||
// ================== 构建函数 ==================
|
||||
const build = (type, vars) => {
|
||||
const p = PROMPTS[type];
|
||||
return [
|
||||
{ role: 'user', content: p.u1(vars) },
|
||||
{ role: 'assistant', content: p.a1(vars) },
|
||||
{ role: 'user', content: p.u2(vars) },
|
||||
{ role: 'assistant', content: p.a2(vars) }
|
||||
];
|
||||
};
|
||||
|
||||
export const buildSmsMessages = v => build('sms', v);
|
||||
export const buildSummaryMessages = v => build('summary', v);
|
||||
export const buildInviteMessages = v => build('invite', v);
|
||||
export const buildNpcGenerationMessages = v => build('npc', v);
|
||||
export const buildExtractStrangersMessages = v => build('stranger', v);
|
||||
export const buildWorldGenStep1Messages = v => build('worldGenStep1', v);
|
||||
export const buildWorldGenStep2Messages = v => build('worldGenStep2', v);
|
||||
export const buildWorldSimMessages = v => build(v?.mode === 'assist' ? 'worldSimAssist' : 'worldSim', v);
|
||||
export const buildSceneSwitchMessages = v => build(v?.mode === 'assist' ? 'sceneSwitchAssist' : 'sceneSwitch', v);
|
||||
export const buildLocalMapGenMessages = v => build('localMapGen', v);
|
||||
export const buildLocalMapRefreshMessages = v => build('localMapRefresh', v);
|
||||
export const buildLocalSceneGenMessages = v => build('localSceneGen', v);
|
||||
|
||||
// ================== NPC 格式化 ==================
|
||||
function jsonToYaml(data, indent = 0) {
|
||||
const sp = ' '.repeat(indent);
|
||||
if (data === null || data === undefined) return '';
|
||||
if (typeof data !== 'object') return String(data);
|
||||
if (Array.isArray(data)) {
|
||||
return data.map(item => typeof item === 'object' && item !== null
|
||||
? `${sp}- ${jsonToYaml(item, indent + 2).trimStart()}`
|
||||
: `${sp}- ${item}`
|
||||
).join('\n');
|
||||
}
|
||||
return Object.entries(data).map(([key, value]) => {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
if (Array.isArray(value) && !value.length) return `${sp}${key}: []`;
|
||||
if (!Array.isArray(value) && !Object.keys(value).length) return `${sp}${key}: {}`;
|
||||
return `${sp}${key}:\n${jsonToYaml(value, indent + 2)}`;
|
||||
}
|
||||
return `${sp}${key}: ${value}`;
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
export function formatNpcToWorldbookContent(npc) { return jsonToYaml(npc); }
|
||||
|
||||
// ================== Overlay HTML ==================
|
||||
const FRAME_STYLE = 'position:absolute!important;z-index:1!important;pointer-events:auto!important;border-radius:12px!important;box-shadow:0 8px 32px rgba(0,0,0,.4)!important;overflow:hidden!important;display:flex!important;flex-direction:column!important;background:#f4f4f4!important;';
|
||||
|
||||
export const buildOverlayHtml = src => `<div id="xiaobaix-story-outline-overlay" style="position:fixed!important;inset:0!important;width:100vw!important;height:100vh!important;z-index:67!important;margin-top:35px;display:none;overflow:hidden!important;pointer-events:none!important;">
|
||||
<div class="xb-so-frame-wrap" style="${FRAME_STYLE}">
|
||||
<div class="xb-so-drag-handle" style="position:absolute!important;top:0!important;left:0!important;width:200px!important;height:48px!important;z-index:10!important;cursor:move!important;background:transparent!important;touch-action:none!important;"></div>
|
||||
<iframe id="xiaobaix-story-outline-iframe" class="xiaobaix-iframe" src="${src}" style="width:100%!important;height:100%!important;border:none!important;background:#f4f4f4!important;"></iframe>
|
||||
<div class="xb-so-resize-handle" style="position:absolute!important;right:0!important;bottom:0!important;width:24px!important;height:24px!important;cursor:nwse-resize!important;background:linear-gradient(135deg,transparent 50%,rgba(0,0,0,0.2) 50%)!important;border-radius:0 0 12px 0!important;z-index:10!important;touch-action:none!important;"></div>
|
||||
<div class="xb-so-resize-mobile" style="position:absolute!important;right:0!important;bottom:0!important;width:24px!important;height:24px!important;cursor:nwse-resize!important;display:none!important;z-index:10!important;touch-action:none!important;background:linear-gradient(135deg,transparent 50%,rgba(0,0,0,0.2) 50%)!important;border-radius:0 0 12px 0!important;"></div>
|
||||
</div></div>`;
|
||||
|
||||
export const MOBILE_LAYOUT_STYLE = 'position:absolute!important;left:0!important;right:0!important;top:0!important;bottom:auto!important;width:100%!important;height:40vh!important;transform:none!important;z-index:1!important;pointer-events:auto!important;border-radius:0 0 16px 16px!important;box-shadow:0 8px 32px rgba(0,0,0,.4)!important;overflow:hidden!important;display:flex!important;flex-direction:column!important;background:#f4f4f4!important;';
|
||||
|
||||
export const DESKTOP_LAYOUT_STYLE = 'position:absolute!important;left:50%!important;top:50%!important;transform:translate(-50%,-50%)!important;width:600px!important;max-width:90vw!important;height:450px!important;max-height:80vh!important;z-index:1!important;pointer-events:auto!important;border-radius:12px!important;box-shadow:0 8px 32px rgba(0,0,0,.4)!important;overflow:hidden!important;display:flex!important;flex-direction:column!important;background:#f4f4f4!important;';
|
||||
1776
modules/story-outline/story-outline.html
Normal file
1776
modules/story-outline/story-outline.html
Normal file
File diff suppressed because it is too large
Load Diff
1202
modules/story-outline/story-outline.js
Normal file
1202
modules/story-outline/story-outline.js
Normal file
File diff suppressed because it is too large
Load Diff
1313
modules/story-summary/story-summary.html
Normal file
1313
modules/story-summary/story-summary.html
Normal file
File diff suppressed because it is too large
Load Diff
1112
modules/story-summary/story-summary.js
Normal file
1112
modules/story-summary/story-summary.js
Normal file
File diff suppressed because it is too large
Load Diff
1344
modules/streaming-generation.js
Normal file
1344
modules/streaming-generation.js
Normal file
File diff suppressed because it is too large
Load Diff
62
modules/template-editor/template-editor.html
Normal file
62
modules/template-editor/template-editor.html
Normal file
@@ -0,0 +1,62 @@
|
||||
<div id="xiaobai_template_editor">
|
||||
<div class="xiaobai_template_editor">
|
||||
<h3 class="flex-container justifyCenter alignItemsBaseline">
|
||||
<strong>模板编辑器</strong>
|
||||
</h3>
|
||||
<hr />
|
||||
|
||||
<div class="flex-container flexFlowColumn">
|
||||
<div class="flex1">
|
||||
<label for="fixed_text_custom_regex" class="title_restorable">
|
||||
<small>自定义正则表达式</small>
|
||||
</label>
|
||||
<div>
|
||||
<input id="fixed_text_custom_regex" class="text_pole textarea_compact" type="text"
|
||||
placeholder="\\[([^\\]]+)\\]([\\s\\S]*?)\\[\\/\\1\\]" />
|
||||
</div>
|
||||
<div class="flex-container" style="margin-top: 6px;">
|
||||
<label class="checkbox_label">
|
||||
<input type="checkbox" id="disable_parsers" />
|
||||
<span>文本不使用插件预设的正则及格式解析器</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
|
||||
<div class="flex-container flexFlowColumn">
|
||||
<div class="flex1">
|
||||
<label class="title_restorable">
|
||||
<small>消息范围限制</small>
|
||||
</label>
|
||||
<div class="flex-container" style="margin-top: 10px;">
|
||||
<label class="checkbox_label">
|
||||
<input type="checkbox" id="skip_first_message" />
|
||||
<span>首条消息不插入模板</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex-container">
|
||||
<label class="checkbox_label">
|
||||
<input type="checkbox" id="limit_to_recent_messages" />
|
||||
<span>仅在最后几条消息中生效</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex-container" style="margin-top: 10px;">
|
||||
<label for="recent_message_count" style="margin-right: 10px;">消息数量:</label>
|
||||
<input id="recent_message_count" class="text_pole" type="number" min="1" max="50" value="5"
|
||||
style="width: 80px; max-height: 2.3vh;" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
<div class="flex-container flexFlowColumn">
|
||||
<label for="fixed_text_template" class="title_restorable">
|
||||
<small>模板内容</small>
|
||||
</label>
|
||||
<div>
|
||||
<textarea id="fixed_text_template" class="text_pole textarea_compact" rows="3"
|
||||
placeholder="例如:hi[[var1]], hello[[var2]], xx[[profile1]]" style="min-height: 20vh;"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
1448
modules/template-editor/template-editor.js
Normal file
1448
modules/template-editor/template-editor.js
Normal file
File diff suppressed because it is too large
Load Diff
1009
modules/variables/var-commands.js
Normal file
1009
modules/variables/var-commands.js
Normal file
File diff suppressed because it is too large
Load Diff
686
modules/variables/varevent-editor.js
Normal file
686
modules/variables/varevent-editor.js
Normal file
@@ -0,0 +1,686 @@
|
||||
/**
|
||||
* @file modules/variables/varevent-editor.js
|
||||
* @description 条件规则编辑器与 varevent 运行时(常驻模块)
|
||||
*/
|
||||
|
||||
import { getContext, extension_settings } from "../../../../../extensions.js";
|
||||
import { getLocalVariable } from "../../../../../variables.js";
|
||||
import { createModuleEvents, event_types } from "../../core/event-manager.js";
|
||||
import { normalizePath, lwbSplitPathWithBrackets } from "../../core/variable-path.js";
|
||||
import { replaceXbGetVarInString } from "./var-commands.js";
|
||||
|
||||
const MODULE_ID = 'vareventEditor';
|
||||
const LWB_EXT_ID = 'LittleWhiteBox';
|
||||
const LWB_VAREVENT_PROMPT_KEY = 'LWB_varevent_display';
|
||||
const EDITOR_STYLES_ID = 'lwb-varevent-editor-styles';
|
||||
const TAG_RE_VAREVENT = /<\s*varevent[^>]*>([\s\S]*?)<\s*\/\s*varevent\s*>/gi;
|
||||
|
||||
const OP_ALIASES = {
|
||||
set: ['set', '记下', '記下', '记录', '記錄', '录入', '錄入', 'record'],
|
||||
push: ['push', '添入', '增录', '增錄', '追加', 'append'],
|
||||
bump: ['bump', '推移', '变更', '變更', '调整', '調整', 'adjust'],
|
||||
del: ['del', '遗忘', '遺忘', '抹去', '删除', '刪除', 'erase'],
|
||||
};
|
||||
const OP_MAP = {};
|
||||
for (const [k, arr] of Object.entries(OP_ALIASES)) for (const a of arr) OP_MAP[a.toLowerCase()] = k;
|
||||
const reEscape = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const ALL_OP_WORDS = Object.values(OP_ALIASES).flat();
|
||||
const OP_WORDS_PATTERN = ALL_OP_WORDS.map(reEscape).sort((a, b) => b.length - a.length).join('|');
|
||||
const TOP_OP_RE = new RegExp(`^(${OP_WORDS_PATTERN})\\s*:\\s*$`, 'i');
|
||||
|
||||
let events = null;
|
||||
let initialized = false;
|
||||
let origEmitMap = new WeakMap();
|
||||
|
||||
function debounce(fn, wait = 100) { let t = null; return (...args) => { clearTimeout(t); t = setTimeout(() => fn.apply(null, args), wait); }; }
|
||||
|
||||
function stripYamlInlineComment(s) {
|
||||
const text = String(s ?? ''); if (!text) return '';
|
||||
let inSingle = false, inDouble = false, escaped = false;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (inSingle) { if (ch === "'") { if (text[i + 1] === "'") { i++; continue; } inSingle = false; } continue; }
|
||||
if (inDouble) { if (escaped) { escaped = false; continue; } if (ch === '\\') { escaped = true; continue; } if (ch === '"') inDouble = false; continue; }
|
||||
if (ch === "'") { inSingle = true; continue; }
|
||||
if (ch === '"') { inDouble = true; continue; }
|
||||
if (ch === '#') { const prev = i > 0 ? text[i - 1] : ''; if (i === 0 || /\s/.test(prev)) return text.slice(0, i); }
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function getActiveCharacter() {
|
||||
try {
|
||||
const ctx = getContext(); const id = ctx?.characterId ?? ctx?.this_chid; if (id == null) return null;
|
||||
return (ctx?.getCharacter?.(id) ?? (Array.isArray(ctx?.characters) ? ctx.characters[id] : null)) || null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function readCharExtBumpAliases() {
|
||||
try {
|
||||
const ctx = getContext(); const id = ctx?.characterId ?? ctx?.this_chid; if (id == null) return {};
|
||||
const char = ctx?.getCharacter?.(id) ?? (Array.isArray(ctx?.characters) ? ctx.characters[id] : null);
|
||||
const bump = char?.data?.extensions?.[LWB_EXT_ID]?.variablesCore?.bumpAliases;
|
||||
if (bump && typeof bump === 'object') return bump;
|
||||
const legacy = char?.extensions?.[LWB_EXT_ID]?.variablesCore?.bumpAliases;
|
||||
if (legacy && typeof legacy === 'object') { writeCharExtBumpAliases(legacy); return legacy; }
|
||||
return {};
|
||||
} catch { return {}; }
|
||||
}
|
||||
|
||||
async function writeCharExtBumpAliases(newStore) {
|
||||
try {
|
||||
const ctx = getContext(); const id = ctx?.characterId ?? ctx?.this_chid; if (id == null) return;
|
||||
if (typeof ctx?.writeExtensionField === 'function') {
|
||||
await ctx.writeExtensionField(id, LWB_EXT_ID, { variablesCore: { bumpAliases: structuredClone(newStore || {}) } });
|
||||
const char = ctx?.getCharacter?.(id) ?? (Array.isArray(ctx?.characters) ? ctx.characters[id] : null);
|
||||
if (char) {
|
||||
char.data = char.data && typeof char.data === 'object' ? char.data : {};
|
||||
char.data.extensions = char.data.extensions && typeof char.data.extensions === 'object' ? char.data.extensions : {};
|
||||
const ns = (char.data.extensions[LWB_EXT_ID] ||= {});
|
||||
ns.variablesCore = ns.variablesCore && typeof ns.variablesCore === 'object' ? ns.variablesCore : {};
|
||||
ns.variablesCore.bumpAliases = structuredClone(newStore || {});
|
||||
}
|
||||
typeof ctx?.saveCharacter === 'function' ? await ctx.saveCharacter() : ctx?.saveCharacterDebounced?.();
|
||||
return;
|
||||
}
|
||||
const char = ctx?.getCharacter?.(id) ?? (Array.isArray(ctx?.characters) ? ctx.characters[id] : null);
|
||||
if (char) {
|
||||
char.data = char.data && typeof char.data === 'object' ? char.data : {};
|
||||
char.data.extensions = char.data.extensions && typeof char.data.extensions === 'object' ? char.data.extensions : {};
|
||||
const ns = (char.data.extensions[LWB_EXT_ID] ||= {});
|
||||
ns.variablesCore = ns.variablesCore && typeof ns.variablesCore === 'object' ? ns.variablesCore : {};
|
||||
ns.variablesCore.bumpAliases = structuredClone(newStore || {});
|
||||
}
|
||||
typeof ctx?.saveCharacter === 'function' ? await ctx.saveCharacter() : ctx?.saveCharacterDebounced?.();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function getBumpAliasStore() { return readCharExtBumpAliases(); }
|
||||
export async function setBumpAliasStore(newStore) { await writeCharExtBumpAliases(newStore); }
|
||||
export async function clearBumpAliasStore() { await writeCharExtBumpAliases({}); }
|
||||
|
||||
function getBumpAliasMap() { try { return getBumpAliasStore(); } catch { return {}; } }
|
||||
|
||||
function matchAlias(varOrKey, rhs) {
|
||||
const map = getBumpAliasMap();
|
||||
for (const scope of [map._global || {}, map[varOrKey] || {}]) {
|
||||
for (const [k, v] of Object.entries(scope)) {
|
||||
if (k.startsWith('/') && k.lastIndexOf('/') > 0) {
|
||||
const last = k.lastIndexOf('/');
|
||||
try { if (new RegExp(k.slice(1, last), k.slice(last + 1)).test(rhs)) return Number(v); } catch {}
|
||||
} else if (rhs === k) return Number(v);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function preprocessBumpAliases(innerText) {
|
||||
const lines = String(innerText || '').split(/\r?\n/), out = [];
|
||||
let inBump = false; const indentOf = (s) => s.length - s.trimStart().length;
|
||||
const stack = []; let currentVarRoot = '';
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const raw = lines[i], t = raw.trim();
|
||||
if (!t) { out.push(raw); continue; }
|
||||
const ind = indentOf(raw), mTop = TOP_OP_RE.exec(t);
|
||||
if (mTop && ind === 0) { const opKey = OP_MAP[mTop[1].toLowerCase()] || ''; inBump = opKey === 'bump'; stack.length = 0; currentVarRoot = ''; out.push(raw); continue; }
|
||||
if (!inBump) { out.push(raw); continue; }
|
||||
while (stack.length && stack[stack.length - 1].indent >= ind) stack.pop();
|
||||
const mKV = t.match(/^([^:]+):\s*(.*)$/);
|
||||
if (mKV) {
|
||||
const key = mKV[1].trim(), val = String(stripYamlInlineComment(mKV[2])).trim();
|
||||
const parentPath = stack.length ? stack[stack.length - 1].path : '', curPath = parentPath ? `${parentPath}.${key}` : key;
|
||||
if (val === '') { stack.push({ indent: ind, path: curPath }); if (!parentPath) currentVarRoot = key; out.push(raw); continue; }
|
||||
let rhs = val.replace(/^["']|["']$/g, '');
|
||||
const num = matchAlias(key, rhs) ?? matchAlias(currentVarRoot, rhs) ?? matchAlias('', rhs);
|
||||
out.push(num !== null && Number.isFinite(num) ? raw.replace(/:\s*.*$/, `: ${num}`) : raw); continue;
|
||||
}
|
||||
const mArr = t.match(/^\-\s*(.+)$/);
|
||||
if (mArr) {
|
||||
let rhs = String(stripYamlInlineComment(mArr[1])).trim().replace(/^["']|["']$/g, '');
|
||||
const leafKey = stack.length ? stack[stack.length - 1].path.split('.').pop() : '';
|
||||
const num = matchAlias(leafKey || currentVarRoot, rhs) ?? matchAlias(currentVarRoot, rhs) ?? matchAlias('', rhs);
|
||||
out.push(num !== null && Number.isFinite(num) ? raw.replace(/-\s*.*$/, `- ${num}`) : raw); continue;
|
||||
}
|
||||
out.push(raw);
|
||||
}
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
export function parseVareventEvents(innerText) {
|
||||
const evts = [], lines = String(innerText || '').split(/\r?\n/);
|
||||
let cur = null;
|
||||
const flush = () => { if (cur) { evts.push(cur); cur = null; } };
|
||||
const isStopLine = (t) => !t ? false : /^\[\s*event\.[^\]]+]\s*$/i.test(t) || /^(condition|display|js_execute)\s*:/i.test(t) || /^<\s*\/\s*varevent\s*>/i.test(t);
|
||||
const findUnescapedQuote = (str, q) => { for (let i = 0; i < str.length; i++) if (str[i] === q && str[i - 1] !== '\\') return i; return -1; };
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const raw = lines[i], line = raw.trim(); if (!line) continue;
|
||||
const header = /^\[\s*event\.([^\]]+)]\s*$/i.exec(line);
|
||||
if (header) { flush(); cur = { id: String(header[1]).trim() }; continue; }
|
||||
const m = /^(condition|display|js_execute)\s*:\s*(.*)$/i.exec(line);
|
||||
if (m) {
|
||||
const key = m[1].toLowerCase(); let valPart = m[2] ?? ''; if (!cur) cur = {};
|
||||
let value = ''; const ltrim = valPart.replace(/^\s+/, ''), firstCh = ltrim[0];
|
||||
if (firstCh === '"' || firstCh === "'") {
|
||||
const quote = firstCh; let after = ltrim.slice(1), endIdx = findUnescapedQuote(after, quote);
|
||||
if (endIdx !== -1) value = after.slice(0, endIdx);
|
||||
else { value = after + '\n'; while (++i < lines.length) { const ln = lines[i], pos = findUnescapedQuote(ln, quote); if (pos !== -1) { value += ln.slice(0, pos); break; } value += ln + '\n'; } }
|
||||
value = value.replace(/\\"/g, '"').replace(/\\'/g, "'");
|
||||
} else { value = valPart; let j = i + 1; while (j < lines.length) { const nextTrim = lines[j].trim(); if (isStopLine(nextTrim)) break; value += '\n' + lines[j]; j++; } i = j - 1; }
|
||||
if (key === 'condition') cur.condition = value; else if (key === 'display') cur.display = value; else if (key === 'js_execute') cur.js = value;
|
||||
}
|
||||
}
|
||||
flush(); return evts;
|
||||
}
|
||||
|
||||
export function evaluateCondition(expr) {
|
||||
const isNumericLike = (v) => v != null && /^-?\d+(?:\.\d+)?$/.test(String(v).trim());
|
||||
function VAR(path) {
|
||||
try {
|
||||
const p = String(path ?? '').replace(/\[(\d+)\]/g, '.$1'), seg = p.split('.').map(s => s.trim()).filter(Boolean);
|
||||
if (!seg.length) return ''; const root = getLocalVariable(seg[0]);
|
||||
if (seg.length === 1) { if (root == null) return ''; return typeof root === 'object' ? JSON.stringify(root) : String(root); }
|
||||
let obj; if (typeof root === 'string') { try { obj = JSON.parse(root); } catch { return undefined; } } else if (root && typeof root === 'object') obj = root; else return undefined;
|
||||
let cur = obj; for (let i = 1; i < seg.length; i++) { cur = cur?.[/^\d+$/.test(seg[i]) ? Number(seg[i]) : seg[i]]; if (cur === undefined) return undefined; }
|
||||
return cur == null ? '' : typeof cur === 'object' ? JSON.stringify(cur) : String(cur);
|
||||
} catch { return undefined; }
|
||||
}
|
||||
const VAL = (t) => String(t ?? '');
|
||||
function REL(a, op, b) {
|
||||
if (isNumericLike(a) && isNumericLike(b)) { const A = Number(String(a).trim()), B = Number(String(b).trim()); if (op === '>') return A > B; if (op === '>=') return A >= B; if (op === '<') return A < B; if (op === '<=') return A <= B; }
|
||||
else { const A = String(a), B = String(b); if (op === '>') return A > B; if (op === '>=') return A >= B; if (op === '<') return A < B; if (op === '<=') return A <= B; }
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
let processed = expr.replace(/var\(`([^`]+)`\)/g, 'VAR("$1")').replace(/val\(`([^`]+)`\)/g, 'VAL("$1")');
|
||||
processed = processed.replace(/(VAR\(".*?"\)|VAL\(".*?"\))\s*(>=|<=|>|<)\s*(VAR\(".*?"\)|VAL\(".*?"\))/g, 'REL($1,"$2",$3)');
|
||||
return !!eval(processed);
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
export async function runJS(code) {
|
||||
const ctx = getContext();
|
||||
try {
|
||||
const STscriptProxy = async (command) => { if (!command) return; if (command[0] !== '/') command = '/' + command; const { executeSlashCommands, substituteParams } = getContext(); return await executeSlashCommands?.(substituteParams ? substituteParams(command) : command, true); };
|
||||
const fn = new Function('ctx', 'getVar', 'setVar', 'console', 'STscript', `return (async()=>{ ${code}\n })();`);
|
||||
const getVar = (k) => getLocalVariable(k);
|
||||
const setVar = (k, v) => { getContext()?.variables?.local?.set?.(k, v); };
|
||||
return await fn(ctx, getVar, setVar, console, (typeof window !== 'undefined' && window?.STscript) || STscriptProxy);
|
||||
} catch (err) { console.error('[LWB:runJS]', err); }
|
||||
}
|
||||
|
||||
export async function runST(code) {
|
||||
try { if (!code) return; if (code[0] !== '/') code = '/' + code; const { executeSlashCommands, substituteParams } = getContext() || {}; return await executeSlashCommands?.(substituteParams ? substituteParams(code) : code, true); }
|
||||
catch (err) { console.error('[LWB:runST]', err); }
|
||||
}
|
||||
|
||||
async function buildVareventReplacement(innerText, dryRun, executeJs = false) {
|
||||
try {
|
||||
const evts = parseVareventEvents(innerText); if (!evts.length) return '';
|
||||
let chosen = null;
|
||||
for (let i = evts.length - 1; i >= 0; i--) {
|
||||
const ev = evts[i], condStr = String(ev.condition ?? '').trim(), condOk = condStr ? evaluateCondition(condStr) : true;
|
||||
if (!((ev.display && String(ev.display).trim()) || (ev.js && String(ev.js).trim()))) continue;
|
||||
if (condOk) { chosen = ev; break; }
|
||||
}
|
||||
if (!chosen) return '';
|
||||
let out = chosen.display ? String(chosen.display).replace(/^\n+/, '').replace(/\n+$/, '') : '';
|
||||
if (!dryRun && executeJs && chosen.js && String(chosen.js).trim()) { try { await runJS(chosen.js); } catch {} }
|
||||
return out;
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
export async function replaceVareventInString(text, dryRun = false, executeJs = false) {
|
||||
if (!text || text.indexOf('<varevent') === -1) return text;
|
||||
const replaceAsync = async (input, regex, repl) => { let out = '', last = 0; regex.lastIndex = 0; let m; while ((m = regex.exec(input))) { out += input.slice(last, m.index); out += await repl(...m); last = regex.lastIndex; } return out + input.slice(last); };
|
||||
return await replaceAsync(text, TAG_RE_VAREVENT, (m, inner) => buildVareventReplacement(inner, dryRun, executeJs));
|
||||
}
|
||||
|
||||
export function enqueuePendingVareventBlock(innerText, sourceInfo) {
|
||||
try { const ctx = getContext(), meta = ctx?.chatMetadata || {}, list = (meta.LWB_PENDING_VAREVENT_BLOCKS ||= []); list.push({ inner: String(innerText || ''), source: sourceInfo || 'unknown', turn: (ctx?.chat?.length ?? 0), ts: Date.now() }); ctx?.saveMetadataDebounced?.(); } catch {}
|
||||
}
|
||||
|
||||
export function drainPendingVareventBlocks() {
|
||||
try { const ctx = getContext(), meta = ctx?.chatMetadata || {}, list = Array.isArray(meta.LWB_PENDING_VAREVENT_BLOCKS) ? meta.LWB_PENDING_VAREVENT_BLOCKS.slice() : []; meta.LWB_PENDING_VAREVENT_BLOCKS = []; ctx?.saveMetadataDebounced?.(); return list; } catch { return []; }
|
||||
}
|
||||
|
||||
export async function executeQueuedVareventJsAfterTurn() {
|
||||
const blocks = drainPendingVareventBlocks(); if (!blocks.length) return;
|
||||
for (const item of blocks) {
|
||||
try {
|
||||
const evts = parseVareventEvents(item.inner); if (!evts.length) continue;
|
||||
let chosen = null;
|
||||
for (let j = evts.length - 1; j >= 0; j--) { const ev = evts[j], condStr = String(ev.condition ?? '').trim(); if (!(condStr ? evaluateCondition(condStr) : true)) continue; if (!(ev.js && String(ev.js).trim())) continue; chosen = ev; break; }
|
||||
if (chosen) { try { await runJS(String(chosen.js ?? '').trim()); } catch {} }
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
let _scanRunning = false;
|
||||
async function runImmediateVarEvents() {
|
||||
if (_scanRunning) return; _scanRunning = true;
|
||||
try {
|
||||
const wiList = getContext()?.world_info || [];
|
||||
for (const entry of wiList) {
|
||||
const content = String(entry?.content ?? ''); if (!content || content.indexOf('<varevent') === -1) continue;
|
||||
TAG_RE_VAREVENT.lastIndex = 0; let m;
|
||||
while ((m = TAG_RE_VAREVENT.exec(content)) !== null) {
|
||||
const evts = parseVareventEvents(m[1] ?? '');
|
||||
for (const ev of evts) { if (!(String(ev.condition ?? '').trim() ? evaluateCondition(String(ev.condition ?? '').trim()) : true)) continue; if (String(ev.display ?? '').trim()) await runST(`/sys "${String(ev.display ?? '').trim().replace(/"/g, '\\"')}"`); if (String(ev.js ?? '').trim()) await runJS(String(ev.js ?? '').trim()); }
|
||||
}
|
||||
}
|
||||
} catch {} finally { setTimeout(() => { _scanRunning = false; }, 0); }
|
||||
}
|
||||
const runImmediateVarEventsDebounced = debounce(runImmediateVarEvents, 30);
|
||||
|
||||
function installWIHiddenTagStripper() {
|
||||
const ctx = getContext(), ext = ctx?.extensionSettings; if (!ext) return;
|
||||
ext.regex = Array.isArray(ext.regex) ? ext.regex : [];
|
||||
ext.regex = ext.regex.filter(r => !['lwb-varevent-stripper', 'lwb-varevent-replacer'].includes(r?.id) && !['LWB_VarEventStripper', 'LWB_VarEventReplacer'].includes(r?.scriptName));
|
||||
ctx?.saveSettingsDebounced?.();
|
||||
}
|
||||
|
||||
function registerWIEventSystem() {
|
||||
const { eventSource, event_types: evtTypes } = getContext() || {};
|
||||
if (evtTypes?.CHAT_COMPLETION_PROMPT_READY) {
|
||||
const lateChatReplacementHandler = async (data) => {
|
||||
try {
|
||||
if (data?.dryRun) return;
|
||||
const chat = data?.chat;
|
||||
if (!Array.isArray(chat)) return;
|
||||
for (const msg of chat) {
|
||||
if (typeof msg?.content === 'string') {
|
||||
if (msg.content.includes('<varevent')) {
|
||||
TAG_RE_VAREVENT.lastIndex = 0;
|
||||
let mm;
|
||||
while ((mm = TAG_RE_VAREVENT.exec(msg.content)) !== null) {
|
||||
enqueuePendingVareventBlock(mm[1] ?? '', 'chat.content');
|
||||
}
|
||||
msg.content = await replaceVareventInString(msg.content, false, false);
|
||||
}
|
||||
if (msg.content.indexOf('{{xbgetvar::') !== -1) {
|
||||
msg.content = replaceXbGetVarInString(msg.content);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(msg?.content)) {
|
||||
for (const part of msg.content) {
|
||||
if (part?.type === 'text' && typeof part.text === 'string') {
|
||||
if (part.text.includes('<varevent')) {
|
||||
TAG_RE_VAREVENT.lastIndex = 0;
|
||||
let mm;
|
||||
while ((mm = TAG_RE_VAREVENT.exec(part.text)) !== null) {
|
||||
enqueuePendingVareventBlock(mm[1] ?? '', 'chat.content[].text');
|
||||
}
|
||||
part.text = await replaceVareventInString(part.text, false, false);
|
||||
}
|
||||
if (part.text.indexOf('{{xbgetvar::') !== -1) {
|
||||
part.text = replaceXbGetVarInString(part.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (typeof msg?.mes === 'string') {
|
||||
if (msg.mes.includes('<varevent')) {
|
||||
TAG_RE_VAREVENT.lastIndex = 0;
|
||||
let mm;
|
||||
while ((mm = TAG_RE_VAREVENT.exec(msg.mes)) !== null) {
|
||||
enqueuePendingVareventBlock(mm[1] ?? '', 'chat.mes');
|
||||
}
|
||||
msg.mes = await replaceVareventInString(msg.mes, false, false);
|
||||
}
|
||||
if (msg.mes.indexOf('{{xbgetvar::') !== -1) {
|
||||
msg.mes = replaceXbGetVarInString(msg.mes);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
try {
|
||||
if (eventSource && typeof eventSource.makeLast === 'function') {
|
||||
eventSource.makeLast(evtTypes.CHAT_COMPLETION_PROMPT_READY, lateChatReplacementHandler);
|
||||
} else {
|
||||
events?.on(evtTypes.CHAT_COMPLETION_PROMPT_READY, lateChatReplacementHandler);
|
||||
}
|
||||
} catch {
|
||||
events?.on(evtTypes.CHAT_COMPLETION_PROMPT_READY, lateChatReplacementHandler);
|
||||
}
|
||||
}
|
||||
if (evtTypes?.GENERATE_AFTER_COMBINE_PROMPTS) {
|
||||
events?.on(evtTypes.GENERATE_AFTER_COMBINE_PROMPTS, async (data) => {
|
||||
try {
|
||||
if (data?.dryRun) return;
|
||||
|
||||
if (typeof data?.prompt === 'string') {
|
||||
if (data.prompt.includes('<varevent')) {
|
||||
TAG_RE_VAREVENT.lastIndex = 0;
|
||||
let mm;
|
||||
while ((mm = TAG_RE_VAREVENT.exec(data.prompt)) !== null) {
|
||||
enqueuePendingVareventBlock(mm[1] ?? '', 'prompt');
|
||||
}
|
||||
data.prompt = await replaceVareventInString(data.prompt, false, false);
|
||||
}
|
||||
if (data.prompt.indexOf('{{xbgetvar::') !== -1) {
|
||||
data.prompt = replaceXbGetVarInString(data.prompt);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
}
|
||||
if (evtTypes?.GENERATION_ENDED) {
|
||||
events?.on(evtTypes.GENERATION_ENDED, async () => {
|
||||
try {
|
||||
getContext()?.setExtensionPrompt?.(LWB_VAREVENT_PROMPT_KEY, '', 0, 0, false);
|
||||
await executeQueuedVareventJsAfterTurn();
|
||||
} catch {}
|
||||
});
|
||||
}
|
||||
if (evtTypes?.CHAT_CHANGED) {
|
||||
events?.on(evtTypes.CHAT_CHANGED, () => {
|
||||
try {
|
||||
getContext()?.setExtensionPrompt?.(LWB_VAREVENT_PROMPT_KEY, '', 0, 0, false);
|
||||
drainPendingVareventBlocks();
|
||||
runImmediateVarEventsDebounced();
|
||||
} catch {}
|
||||
});
|
||||
}
|
||||
if (evtTypes?.APP_READY) {
|
||||
events?.on(evtTypes.APP_READY, () => {
|
||||
try {
|
||||
runImmediateVarEventsDebounced();
|
||||
} catch {}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const LWBVE = { installed: false, obs: null };
|
||||
|
||||
function injectEditorStyles() {
|
||||
if (document.getElementById(EDITOR_STYLES_ID)) return;
|
||||
const style = document.createElement('style'); style.id = EDITOR_STYLES_ID;
|
||||
style.textContent = `.lwb-ve-overlay{position:fixed;inset:0;background:none;z-index:9999;display:flex;align-items:center;justify-content:center;pointer-events:none}.lwb-ve-modal{width:650px;background:var(--SmartThemeBlurTintColor);border:2px solid var(--SmartThemeBorderColor);border-radius:10px;box-shadow:0 8px 16px var(--SmartThemeShadowColor);pointer-events:auto}.lwb-ve-header{display:flex;justify-content:space-between;padding:10px 14px;border-bottom:1px solid var(--SmartThemeBorderColor);font-weight:600;cursor:move}.lwb-ve-tabs{display:flex;gap:6px;padding:8px 14px;border-bottom:1px solid var(--SmartThemeBorderColor)}.lwb-ve-tab{cursor:pointer;border:1px solid var(--SmartThemeBorderColor);background:var(--SmartThemeBlurTintColor);padding:4px 8px;border-radius:6px;opacity:.8}.lwb-ve-tab.active{opacity:1;border-color:var(--crimson70a)}.lwb-ve-page{display:none}.lwb-ve-page.active{display:block}.lwb-ve-body{height:60vh;overflow:auto;padding:10px}.lwb-ve-footer{display:flex;gap:8px;justify-content:flex-end;padding:12px 14px;border-top:1px solid var(--SmartThemeBorderColor)}.lwb-ve-section{margin:12px 0}.lwb-ve-label{font-size:13px;opacity:.7;margin:6px 0}.lwb-ve-row{gap:8px;align-items:center;margin:4px 0;padding-bottom:10px;border-bottom:1px dashed var(--SmartThemeBorderColor);display:flex;flex-wrap:wrap}.lwb-ve-input,.lwb-ve-text{box-sizing:border-box;background:var(--SmartThemeShadowColor);color:inherit;border:1px solid var(--SmartThemeUserMesBlurTintColor);border-radius:6px;padding:6px 8px}.lwb-ve-text{min-height:64px;resize:vertical;width:100%}.lwb-ve-input{width:260px}.lwb-ve-mini{width:70px!important;margin:0}.lwb-ve-op,.lwb-ve-ctype option{text-align:center}.lwb-ve-lop{width:70px!important;text-align:center}.lwb-ve-btn{cursor:pointer;border:1px solid var(--SmartThemeBorderColor);background:var(--SmartThemeBlurTintColor);padding:6px 10px;border-radius:6px}.lwb-ve-btn.primary{background:var(--crimson70a)}.lwb-ve-event{border:1px dashed var(--SmartThemeBorderColor);border-radius:8px;padding:10px;margin:10px 0}.lwb-ve-event-title{font-weight:600;display:flex;align-items:center;gap:8px}.lwb-ve-close{cursor:pointer}.lwb-var-editor-button.right_menu_button{display:inline-flex;align-items:center;margin-left:10px;transform:scale(1.5)}.lwb-ve-vals,.lwb-ve-varrhs{align-items:center;display:inline-flex;gap:6px}.lwb-ve-delval{transform:scale(.5)}.lwb-act-type{width:200px!important}.lwb-ve-condgroups{display:flex;flex-direction:column;gap:10px}.lwb-ve-condgroup{border:1px solid var(--SmartThemeBorderColor);border-radius:8px;padding:8px}.lwb-ve-group-title{display:flex;align-items:center;gap:8px;margin-bottom:6px}.lwb-ve-group-name{font-weight:600}.lwb-ve-group-lop{width:70px!important;text-align:center}.lwb-ve-add-group{margin-top:6px}@media (max-width:999px){.lwb-ve-overlay{position:absolute;inset:0;align-items:flex-start}.lwb-ve-modal{width:100%;max-height:100%;margin:0;border-radius:10px 10px 0 0}}`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
const U = {
|
||||
qa: (root, sel) => Array.from((root || document).querySelectorAll(sel)),
|
||||
el: (tag, cls, html) => { const e = document.createElement(tag); if (cls) e.className = cls; if (html != null) e.innerHTML = html; return e; },
|
||||
setActive(listLike, idx) { (Array.isArray(listLike) ? listLike : U.qa(document, listLike)).forEach((el, i) => el.classList.toggle('active', i === idx)); },
|
||||
toast: { ok: (m) => window?.toastr?.success?.(m), warn: (m) => window?.toastr?.warning?.(m), err: (m) => window?.toastr?.error?.(m) },
|
||||
drag(modal, overlay, header) {
|
||||
try { modal.style.position = 'absolute'; modal.style.left = '50%'; modal.style.top = '50%'; modal.style.transform = 'translate(-50%,-50%)'; } catch {}
|
||||
let dragging = false, sx = 0, sy = 0, sl = 0, st = 0;
|
||||
const onDown = (e) => { if (!(e instanceof PointerEvent) || e.button !== 0) return; dragging = true; const r = modal.getBoundingClientRect(), ro = overlay.getBoundingClientRect(); modal.style.left = (r.left - ro.left) + 'px'; modal.style.top = (r.top - ro.top) + 'px'; modal.style.transform = ''; sx = e.clientX; sy = e.clientY; sl = parseFloat(modal.style.left) || 0; st = parseFloat(modal.style.top) || 0; window.addEventListener('pointermove', onMove, { passive: true }); window.addEventListener('pointerup', onUp, { once: true }); e.preventDefault(); };
|
||||
const onMove = (e) => { if (!dragging) return; let nl = sl + e.clientX - sx, nt = st + e.clientY - sy; const maxL = (overlay.clientWidth || overlay.getBoundingClientRect().width) - modal.offsetWidth, maxT = (overlay.clientHeight || overlay.getBoundingClientRect().height) - modal.offsetHeight; modal.style.left = Math.max(0, Math.min(maxL, nl)) + 'px'; modal.style.top = Math.max(0, Math.min(maxT, nt)) + 'px'; };
|
||||
const onUp = () => { dragging = false; window.removeEventListener('pointermove', onMove); };
|
||||
header.addEventListener('pointerdown', onDown);
|
||||
},
|
||||
mini(innerHTML, title = '编辑器') {
|
||||
const wrap = U.el('div', 'lwb-ve-overlay'), modal = U.el('div', 'lwb-ve-modal'); modal.style.maxWidth = '720px'; modal.style.pointerEvents = 'auto'; modal.style.zIndex = '10010'; wrap.appendChild(modal);
|
||||
const header = U.el('div', 'lwb-ve-header', `<span>${title}</span><span class="lwb-ve-close">✕</span>`), body = U.el('div', 'lwb-ve-body', innerHTML), footer = U.el('div', 'lwb-ve-footer');
|
||||
const btnCancel = U.el('button', 'lwb-ve-btn', '取消'), btnOk = U.el('button', 'lwb-ve-btn primary', '生成');
|
||||
footer.append(btnCancel, btnOk); modal.append(header, body, footer); U.drag(modal, wrap, header);
|
||||
btnCancel.addEventListener('click', () => wrap.remove()); header.querySelector('.lwb-ve-close')?.addEventListener('click', () => wrap.remove());
|
||||
document.body.appendChild(wrap); return { wrap, modal, body, btnOk, btnCancel };
|
||||
},
|
||||
};
|
||||
|
||||
const P = {
|
||||
stripOuter(s) { let t = String(s || '').trim(); if (!t.startsWith('(') || !t.endsWith(')')) return t; let i = 0, d = 0, q = null; while (i < t.length) { const c = t[i]; if (q) { if (c === q && t[i - 1] !== '\\') q = null; i++; continue; } if (c === '"' || c === "'" || c === '`') { q = c; i++; continue; } if (c === '(') d++; else if (c === ')') d--; i++; } return d === 0 ? t.slice(1, -1).trim() : t; },
|
||||
stripOuterWithFlag(s) { let t = String(s || '').trim(); if (!t.startsWith('(') || !t.endsWith(')')) return { text: t, wrapped: false }; let i = 0, d = 0, q = null; while (i < t.length) { const c = t[i]; if (q) { if (c === q && t[i - 1] !== '\\') q = null; i++; continue; } if (c === '"' || c === "'" || c === '`') { q = c; i++; continue; } if (c === '(') d++; else if (c === ')') d--; i++; } return d === 0 ? { text: t.slice(1, -1).trim(), wrapped: true } : { text: t, wrapped: false }; },
|
||||
splitTopWithOps(s) { const out = []; let i = 0, start = 0, d = 0, q = null, pendingOp = null; while (i < s.length) { const c = s[i]; if (q) { if (c === q && s[i - 1] !== '\\') q = null; i++; continue; } if (c === '"' || c === "'" || c === '`') { q = c; i++; continue; } if (c === '(') { d++; i++; continue; } if (c === ')') { d--; i++; continue; } if (d === 0 && (s.slice(i, i + 2) === '&&' || s.slice(i, i + 2) === '||')) { const seg = s.slice(start, i).trim(); if (seg) out.push({ op: pendingOp, expr: seg }); pendingOp = s.slice(i, i + 2); i += 2; start = i; continue; } i++; } const tail = s.slice(start).trim(); if (tail) out.push({ op: pendingOp, expr: tail }); return out; },
|
||||
parseComp(s) { const t = P.stripOuter(s), m = t.match(/^var\(\s*([`'"])([\s\S]*?)\1\s*\)\s*(==|!=|>=|<=|>|<)\s*(val|var)\(\s*([`'"])([\s\S]*?)\5\s*\)$/); if (!m) return null; return { lhs: m[2], op: m[3], rhsIsVar: m[4] === 'var', rhs: m[6] }; },
|
||||
hasBinary: (s) => /\|\||&&/.test(s),
|
||||
paren: (s) => (s.startsWith('(') && s.endsWith(')')) ? s : `(${s})`,
|
||||
wrapBack: (s) => { const t = String(s || '').trim(); return /^([`'"]).*\1$/.test(t) ? t : '`' + t.replace(/`/g, '\\`') + '`'; },
|
||||
buildVar: (name) => `var(${P.wrapBack(name)})`,
|
||||
buildVal(v) { const t = String(v || '').trim(); return /^([`'"]).*\1$/.test(t) ? `val(${t})` : `val(${P.wrapBack(t)})`; },
|
||||
};
|
||||
|
||||
function buildSTscriptFromActions(actionList) {
|
||||
const parts = [], jsEsc = (s) => String(s ?? '').replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${'), plain = (s) => String(s ?? '').trim();
|
||||
for (const a of actionList || []) {
|
||||
switch (a.type) {
|
||||
case 'var.set': parts.push(`/setvar key=${plain(a.key)} ${plain(a.value)}`); break;
|
||||
case 'var.bump': parts.push(`/addvar key=${plain(a.key)} ${Number(a.delta) || 0}`); break;
|
||||
case 'var.del': parts.push(`/flushvar ${plain(a.key)}`); break;
|
||||
case 'wi.enableUID': parts.push(`/setentryfield file=${plain(a.file)} uid=${plain(a.uid)} field=disable 0`); break;
|
||||
case 'wi.disableUID': parts.push(`/setentryfield file=${plain(a.file)} uid=${plain(a.uid)} field=disable 1`); break;
|
||||
case 'wi.setContentUID': parts.push(`/setentryfield file=${plain(a.file)} uid=${plain(a.uid)} field=content ${plain(a.content)}`); break;
|
||||
case 'wi.createContent': parts.push(plain(a.content) ? `/createentry file=${plain(a.file)} key=${plain(a.key)} ${plain(a.content)}` : `/createentry file=${plain(a.file)} key=${plain(a.key)}`); parts.push(`/setentryfield file=${plain(a.file)} uid={{pipe}} field=constant 1`); break;
|
||||
case 'qr.run': parts.push(`/run ${a.preset ? `${plain(a.preset)}.` : ''}${plain(a.label)}`); break;
|
||||
case 'custom.st': if (a.script) parts.push(...a.script.split('\n').map(s => s.trim()).filter(Boolean).map(c => c.startsWith('/') ? c : '/' + c)); break;
|
||||
}
|
||||
}
|
||||
return 'STscript(`' + jsEsc(parts.join(' | ')) + '`)';
|
||||
}
|
||||
|
||||
const UI = {
|
||||
getEventBlockHTML(index) {
|
||||
return `<div class="lwb-ve-event-title">事件 #<span class="lwb-ve-idx">${index}</span><span class="lwb-ve-close" title="删除事件" style="margin-left:auto;">✕</span></div><div class="lwb-ve-section"><div class="lwb-ve-label">执行条件</div><div class="lwb-ve-condgroups"></div><button type="button" class="lwb-ve-btn lwb-ve-add-group"><i class="fa-solid fa-plus"></i>添加条件小组</button></div><div class="lwb-ve-section"><div class="lwb-ve-label">将显示世界书内容(可选)</div><textarea class="lwb-ve-text lwb-ve-display" placeholder="例如:<Info>……</Info>"></textarea></div><div class="lwb-ve-section"><div class="lwb-ve-label">将执行stscript命令或JS代码(可选)</div><textarea class="lwb-ve-text lwb-ve-js" placeholder="stscript:/setvar key=foo 1 | /run SomeQR 或 直接JS"></textarea><div style="margin-top:6px; display:flex; gap:8px; flex-wrap:wrap;"><button type="button" class="lwb-ve-btn lwb-ve-gen-st">常用st控制</button></div></div>`;
|
||||
},
|
||||
getConditionRowHTML() {
|
||||
return `<select class="lwb-ve-input lwb-ve-mini lwb-ve-lop" style="display:none;"><option value="||">或</option><option value="&&" selected>和</option></select><select class="lwb-ve-input lwb-ve-mini lwb-ve-ctype"><option value="vv">比较值</option><option value="vvv">比较变量</option></select><input class="lwb-ve-input lwb-ve-var" placeholder="变量名称"/><select class="lwb-ve-input lwb-ve-mini lwb-ve-op"><option value="==">等于</option><option value="!=">不等于</option><option value=">=">大于或等于</option><option value="<=">小于或等于</option><option value=">">大于</option><option value="<">小于</option></select><span class="lwb-ve-vals"><span class="lwb-ve-valwrap"><input class="lwb-ve-input lwb-ve-val" placeholder="值"/></span></span><span class="lwb-ve-varrhs" style="display:none;"><span class="lwb-ve-valvarwrap"><input class="lwb-ve-input lwb-ve-valvar" placeholder="变量B名称"/></span></span><button type="button" class="lwb-ve-btn ghost lwb-ve-del">删除</button>`;
|
||||
},
|
||||
makeConditionGroup() {
|
||||
const g = U.el('div', 'lwb-ve-condgroup', `<div class="lwb-ve-group-title"><select class="lwb-ve-input lwb-ve-mini lwb-ve-group-lop" style="display:none;"><option value="&&">和</option><option value="||">或</option></select><span class="lwb-ve-group-name">小组</span><span style="flex:1 1 auto;"></span><button type="button" class="lwb-ve-btn ghost lwb-ve-del-group">删除小组</button></div><div class="lwb-ve-conds"></div><button type="button" class="lwb-ve-btn lwb-ve-add-cond"><i class="fa-solid fa-plus"></i>添加条件</button>`);
|
||||
const conds = g.querySelector('.lwb-ve-conds');
|
||||
g.querySelector('.lwb-ve-add-cond')?.addEventListener('click', () => { try { UI.addConditionRow(conds, {}); } catch {} });
|
||||
g.querySelector('.lwb-ve-del-group')?.addEventListener('click', () => g.remove());
|
||||
return g;
|
||||
},
|
||||
refreshLopDisplay(container) { U.qa(container, '.lwb-ve-row').forEach((r, idx) => { const lop = r.querySelector('.lwb-ve-lop'); if (!lop) return; lop.style.display = idx === 0 ? 'none' : ''; if (idx > 0 && !lop.value) lop.value = '&&'; }); },
|
||||
setupConditionRow(row, onRowsChanged) {
|
||||
row.querySelector('.lwb-ve-del')?.addEventListener('click', () => { row.remove(); onRowsChanged?.(); });
|
||||
const ctype = row.querySelector('.lwb-ve-ctype'), vals = row.querySelector('.lwb-ve-vals'), rhs = row.querySelector('.lwb-ve-varrhs');
|
||||
ctype?.addEventListener('change', () => { if (ctype.value === 'vv') { vals.style.display = 'inline-flex'; rhs.style.display = 'none'; } else { vals.style.display = 'none'; rhs.style.display = 'inline-flex'; } });
|
||||
},
|
||||
createConditionRow(params, onRowsChanged) {
|
||||
const { lop, lhs, op, rhsIsVar, rhs } = params || {};
|
||||
const row = U.el('div', 'lwb-ve-row', UI.getConditionRowHTML());
|
||||
const lopSel = row.querySelector('.lwb-ve-lop'); if (lopSel) { if (lop == null) { lopSel.style.display = 'none'; lopSel.value = '&&'; } else { lopSel.style.display = ''; lopSel.value = String(lop || '&&'); } }
|
||||
const varInp = row.querySelector('.lwb-ve-var'); if (varInp && lhs != null) varInp.value = String(lhs);
|
||||
const opSel = row.querySelector('.lwb-ve-op'); if (opSel && op != null) opSel.value = String(op);
|
||||
const ctypeSel = row.querySelector('.lwb-ve-ctype'), valsWrap = row.querySelector('.lwb-ve-vals'), varRhsWrap = row.querySelector('.lwb-ve-varrhs');
|
||||
if (ctypeSel && valsWrap && varRhsWrap && (rhsIsVar != null || rhs != null)) {
|
||||
if (rhsIsVar) { ctypeSel.value = 'vvv'; valsWrap.style.display = 'none'; varRhsWrap.style.display = 'inline-flex'; const rhsInp = row.querySelector('.lwb-ve-varrhs .lwb-ve-valvar'); if (rhsInp && rhs != null) rhsInp.value = String(rhs); }
|
||||
else { ctypeSel.value = 'vv'; valsWrap.style.display = 'inline-flex'; varRhsWrap.style.display = 'none'; const rhsInp = row.querySelector('.lwb-ve-vals .lwb-ve-val'); if (rhsInp && rhs != null) rhsInp.value = String(rhs); }
|
||||
}
|
||||
UI.setupConditionRow(row, onRowsChanged || null); return row;
|
||||
},
|
||||
addConditionRow(container, params) { const row = UI.createConditionRow(params, () => UI.refreshLopDisplay(container)); container.appendChild(row); UI.refreshLopDisplay(container); return row; },
|
||||
parseConditionIntoUI(block, condStr) {
|
||||
try {
|
||||
const groupWrap = block.querySelector('.lwb-ve-condgroups'); if (!groupWrap) return; groupWrap.innerHTML = '';
|
||||
const top = P.splitTopWithOps(condStr);
|
||||
top.forEach((seg, idxSeg) => {
|
||||
const { text } = P.stripOuterWithFlag(seg.expr), g = UI.makeConditionGroup(); groupWrap.appendChild(g);
|
||||
const glopSel = g.querySelector('.lwb-ve-group-lop'); if (glopSel) { glopSel.style.display = idxSeg === 0 ? 'none' : ''; if (idxSeg > 0) glopSel.value = seg.op || '&&'; }
|
||||
const name = g.querySelector('.lwb-ve-group-name'); if (name) name.textContent = ('ABCDEFGHIJKLMNOPQRSTUVWXYZ'[idxSeg] || (idxSeg + 1)) + ' 小组';
|
||||
const rows = P.splitTopWithOps(P.stripOuter(text)); let first = true; const cw = g.querySelector('.lwb-ve-conds');
|
||||
rows.forEach(r => { const comp = P.parseComp(r.expr); if (!comp) return; UI.addConditionRow(cw, { lop: first ? null : (r.op || '&&'), lhs: comp.lhs, op: comp.op, rhsIsVar: comp.rhsIsVar, rhs: comp.rhs }); first = false; });
|
||||
});
|
||||
} catch {}
|
||||
},
|
||||
createEventBlock(index) {
|
||||
const block = U.el('div', 'lwb-ve-event', UI.getEventBlockHTML(index));
|
||||
block.querySelector('.lwb-ve-event-title .lwb-ve-close')?.addEventListener('click', () => { block.remove(); block.dispatchEvent(new CustomEvent('lwb-refresh-idx', { bubbles: true })); });
|
||||
const groupWrap = block.querySelector('.lwb-ve-condgroups'), addGroupBtn = block.querySelector('.lwb-ve-add-group');
|
||||
const refreshGroupOpsAndNames = () => { U.qa(groupWrap, '.lwb-ve-condgroup').forEach((g, i) => { const glop = g.querySelector('.lwb-ve-group-lop'); if (glop) { glop.style.display = i === 0 ? 'none' : ''; if (i > 0 && !glop.value) glop.value = '&&'; } const nm = g.querySelector('.lwb-ve-group-name'); if (nm) nm.textContent = ('ABCDEFGHIJKLMNOPQRSTUVWXYZ'[i] || (i + 1)) + ' 小组'; }); };
|
||||
const createGroup = () => { const g = UI.makeConditionGroup(); UI.addConditionRow(g.querySelector('.lwb-ve-conds'), {}); g.querySelector('.lwb-ve-del-group')?.addEventListener('click', () => { g.remove(); refreshGroupOpsAndNames(); }); return g; };
|
||||
addGroupBtn.addEventListener('click', () => { groupWrap.appendChild(createGroup()); refreshGroupOpsAndNames(); });
|
||||
groupWrap.appendChild(createGroup()); refreshGroupOpsAndNames();
|
||||
block.querySelector('.lwb-ve-gen-st')?.addEventListener('click', () => openActionBuilder(block));
|
||||
return block;
|
||||
},
|
||||
refreshEventIndices(eventsWrap) {
|
||||
U.qa(eventsWrap, '.lwb-ve-event').forEach((el, i) => {
|
||||
const idxEl = el.querySelector('.lwb-ve-idx'); if (!idxEl) return;
|
||||
idxEl.textContent = String(i + 1); idxEl.style.cursor = 'pointer'; idxEl.title = '点击修改显示名称';
|
||||
if (!idxEl.dataset.clickbound) { idxEl.dataset.clickbound = '1'; idxEl.addEventListener('click', () => { const cur = idxEl.textContent || '', name = prompt('输入事件显示名称:', cur) ?? ''; if (name) idxEl.textContent = name; }); }
|
||||
});
|
||||
},
|
||||
processEventBlock(block, idx) {
|
||||
const displayName = String(block.querySelector('.lwb-ve-idx')?.textContent || '').trim();
|
||||
const id = (displayName && /^\w[\w.-]*$/.test(displayName)) ? displayName : String(idx + 1).padStart(4, '0');
|
||||
const lines = [`[event.${id}]`]; let condStr = '', hasAny = false;
|
||||
const groups = U.qa(block, '.lwb-ve-condgroup');
|
||||
for (let gi = 0; gi < groups.length; gi++) {
|
||||
const g = groups[gi], rows = U.qa(g, '.lwb-ve-conds .lwb-ve-row'); let groupExpr = '', groupHas = false;
|
||||
for (const r of rows) {
|
||||
const v = r.querySelector('.lwb-ve-var')?.value?.trim?.() || '', op = r.querySelector('.lwb-ve-op')?.value || '==', ctype = r.querySelector('.lwb-ve-ctype')?.value || 'vv'; if (!v) continue;
|
||||
let rowExpr = '';
|
||||
if (ctype === 'vv') { const ins = U.qa(r, '.lwb-ve-vals .lwb-ve-val'), exprs = []; for (const inp of ins) { const val = (inp?.value || '').trim(); if (!val) continue; exprs.push(`${P.buildVar(v)} ${op} ${P.buildVal(val)}`); } if (exprs.length === 1) rowExpr = exprs[0]; else if (exprs.length > 1) rowExpr = '(' + exprs.join(' || ') + ')'; }
|
||||
else { const ins = U.qa(r, '.lwb-ve-varrhs .lwb-ve-valvar'), exprs = []; for (const inp of ins) { const rhs = (inp?.value || '').trim(); if (!rhs) continue; exprs.push(`${P.buildVar(v)} ${op} ${P.buildVar(rhs)}`); } if (exprs.length === 1) rowExpr = exprs[0]; else if (exprs.length > 1) rowExpr = '(' + exprs.join(' || ') + ')'; }
|
||||
if (!rowExpr) continue;
|
||||
const lop = r.querySelector('.lwb-ve-lop')?.value || '&&';
|
||||
if (!groupHas) { groupExpr = rowExpr; groupHas = true; } else { if (lop === '&&') { const left = P.hasBinary(groupExpr) ? P.paren(groupExpr) : groupExpr, right = P.hasBinary(rowExpr) ? P.paren(rowExpr) : rowExpr; groupExpr = `${left} && ${right}`; } else { groupExpr = `${groupExpr} || ${(P.hasBinary(rowExpr) ? P.paren(rowExpr) : rowExpr)}`; } }
|
||||
}
|
||||
if (!groupHas) continue;
|
||||
const glop = g.querySelector('.lwb-ve-group-lop')?.value || '&&', wrap = P.hasBinary(groupExpr) ? P.paren(groupExpr) : groupExpr;
|
||||
if (!hasAny) { condStr = wrap; hasAny = true; } else condStr = glop === '&&' ? `${condStr} && ${wrap}` : `${condStr} || ${wrap}`;
|
||||
}
|
||||
const disp = block.querySelector('.lwb-ve-display')?.value ?? '', js = block.querySelector('.lwb-ve-js')?.value ?? '', dispCore = String(disp).replace(/^\n+|\n+$/g, '');
|
||||
if (!dispCore && !js) return { lines: [] };
|
||||
if (condStr) lines.push(`condition: ${condStr}`);
|
||||
if (dispCore !== '') lines.push('display: "' + ('\n' + dispCore + '\n').replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"');
|
||||
if (js !== '') lines.push(`js_execute: ${JSON.stringify(js)}`);
|
||||
return { lines };
|
||||
},
|
||||
};
|
||||
|
||||
export function openVarEditor(entryEl, uid) {
|
||||
const textarea = (uid ? document.getElementById(`world_entry_content_${uid}`) : null) || entryEl?.querySelector?.('textarea[name="content"]');
|
||||
if (!textarea) { U.toast.warn('未找到内容输入框,请先展开该条目的编辑抽屉'); return; }
|
||||
const overlay = U.el('div', 'lwb-ve-overlay'), modal = U.el('div', 'lwb-ve-modal'); overlay.appendChild(modal); modal.style.pointerEvents = 'auto'; modal.style.zIndex = '10010';
|
||||
const header = U.el('div', 'lwb-ve-header', `<span>条件规则编辑器</span><span class="lwb-ve-close">✕</span>`);
|
||||
const tabs = U.el('div', 'lwb-ve-tabs'), tabsCtrl = U.el('div'); tabsCtrl.style.cssText = 'margin-left:auto;display:inline-flex;gap:6px;';
|
||||
const btnAddTab = U.el('button', 'lwb-ve-btn', '+组'), btnDelTab = U.el('button', 'lwb-ve-btn ghost', '-组');
|
||||
tabs.appendChild(tabsCtrl); tabsCtrl.append(btnAddTab, btnDelTab);
|
||||
const body = U.el('div', 'lwb-ve-body'), footer = U.el('div', 'lwb-ve-footer');
|
||||
const btnCancel = U.el('button', 'lwb-ve-btn', '取消'), btnOk = U.el('button', 'lwb-ve-btn primary', '确认');
|
||||
footer.append(btnCancel, btnOk); modal.append(header, tabs, body, footer); U.drag(modal, overlay, header);
|
||||
const pagesWrap = U.el('div'); body.appendChild(pagesWrap);
|
||||
const addEventBtn = U.el('button', 'lwb-ve-btn', '<i class="fa-solid fa-plus"></i> 添加事件'); addEventBtn.type = 'button'; addEventBtn.style.cssText = 'background: var(--SmartThemeBlurTintColor); border: 1px solid var(--SmartThemeBorderColor); cursor: pointer; margin-right: 5px;';
|
||||
const bumpBtn = U.el('button', 'lwb-ve-btn lwb-ve-gen-bump', 'bump数值映射设置');
|
||||
const tools = U.el('div', 'lwb-ve-toolbar'); tools.append(addEventBtn, bumpBtn); body.appendChild(tools);
|
||||
bumpBtn.addEventListener('click', () => openBumpAliasBuilder(null));
|
||||
const wi = document.getElementById('WorldInfo'), wiIcon = document.getElementById('WIDrawerIcon');
|
||||
const wasPinned = !!wi?.classList.contains('pinnedOpen'); let tempPinned = false;
|
||||
if (wi && !wasPinned) { wi.classList.add('pinnedOpen'); tempPinned = true; } if (wiIcon && !wiIcon.classList.contains('drawerPinnedOpen')) wiIcon.classList.add('drawerPinnedOpen');
|
||||
const closeEditor = () => { try { const pinChecked = !!document.getElementById('WI_panel_pin')?.checked; if (tempPinned && !pinChecked) { wi?.classList.remove('pinnedOpen'); wiIcon?.classList.remove('drawerPinnedOpen'); } } catch {} overlay.remove(); };
|
||||
btnCancel.addEventListener('click', closeEditor); header.querySelector('.lwb-ve-close')?.addEventListener('click', closeEditor);
|
||||
const TAG_RE = { varevent: /<varevent>([\s\S]*?)<\/varevent>/gi }, originalText = String(textarea.value || ''), vareventBlocks = [];
|
||||
TAG_RE.varevent.lastIndex = 0; let mm; while ((mm = TAG_RE.varevent.exec(originalText)) !== null) vareventBlocks.push({ inner: mm[1] ?? '' });
|
||||
const pageInitialized = new Set();
|
||||
const makePage = () => { const page = U.el('div', 'lwb-ve-page'), eventsWrap = U.el('div'); page.appendChild(eventsWrap); return { page, eventsWrap }; };
|
||||
const renderPage = (pageIdx) => {
|
||||
const tabEls = U.qa(tabs, '.lwb-ve-tab'); U.setActive(tabEls, pageIdx);
|
||||
const current = vareventBlocks[pageIdx], evts = (current && typeof current.inner === 'string') ? (parseVareventEvents(current.inner) || []) : [];
|
||||
let page = U.qa(pagesWrap, '.lwb-ve-page')[pageIdx]; if (!page) { page = makePage().page; pagesWrap.appendChild(page); }
|
||||
U.qa(pagesWrap, '.lwb-ve-page').forEach(el => el.classList.remove('active')); page.classList.add('active');
|
||||
let eventsWrap = page.querySelector(':scope > div'); if (!eventsWrap) { eventsWrap = U.el('div'); page.appendChild(eventsWrap); }
|
||||
const init = () => {
|
||||
eventsWrap.innerHTML = '';
|
||||
if (!evts.length) eventsWrap.appendChild(UI.createEventBlock(1));
|
||||
else evts.forEach((_ev, i) => { const block = UI.createEventBlock(i + 1); try { const condStr = String(_ev.condition || '').trim(); if (condStr) UI.parseConditionIntoUI(block, condStr); const disp = String(_ev.display || ''), dispEl = block.querySelector('.lwb-ve-display'); if (dispEl) dispEl.value = disp.replace(/^\r?\n/, '').replace(/\r?\n$/, ''); const js = String(_ev.js || ''), jsEl = block.querySelector('.lwb-ve-js'); if (jsEl) jsEl.value = js; } catch {} eventsWrap.appendChild(block); });
|
||||
UI.refreshEventIndices(eventsWrap); eventsWrap.addEventListener('lwb-refresh-idx', () => UI.refreshEventIndices(eventsWrap));
|
||||
};
|
||||
if (!pageInitialized.has(pageIdx)) { init(); pageInitialized.add(pageIdx); } else if (!eventsWrap.querySelector('.lwb-ve-event')) init();
|
||||
};
|
||||
pagesWrap._lwbRenderPage = renderPage;
|
||||
addEventBtn.addEventListener('click', () => { const active = pagesWrap.querySelector('.lwb-ve-page.active'), eventsWrap = active?.querySelector(':scope > div'); if (!eventsWrap) return; eventsWrap.appendChild(UI.createEventBlock(eventsWrap.children.length + 1)); eventsWrap.dispatchEvent(new CustomEvent('lwb-refresh-idx', { bubbles: true })); });
|
||||
if (vareventBlocks.length === 0) { const tab = U.el('div', 'lwb-ve-tab active', '组 1'); tabs.insertBefore(tab, tabsCtrl); const { page, eventsWrap } = makePage(); pagesWrap.appendChild(page); page.classList.add('active'); eventsWrap.appendChild(UI.createEventBlock(1)); UI.refreshEventIndices(eventsWrap); tab.addEventListener('click', () => { U.qa(tabs, '.lwb-ve-tab').forEach(el => el.classList.remove('active')); tab.classList.add('active'); U.qa(pagesWrap, '.lwb-ve-page').forEach(el => el.classList.remove('active')); page.classList.add('active'); }); }
|
||||
else { vareventBlocks.forEach((_b, i) => { const tab = U.el('div', 'lwb-ve-tab' + (i === 0 ? ' active' : ''), `组 ${i + 1}`); tab.addEventListener('click', () => renderPage(i)); tabs.insertBefore(tab, tabsCtrl); }); renderPage(0); }
|
||||
btnAddTab.addEventListener('click', () => { const newIdx = U.qa(tabs, '.lwb-ve-tab').length; vareventBlocks.push({ inner: '' }); const tab = U.el('div', 'lwb-ve-tab', `组 ${newIdx + 1}`); tab.addEventListener('click', () => pagesWrap._lwbRenderPage(newIdx)); tabs.insertBefore(tab, tabsCtrl); pagesWrap._lwbRenderPage(newIdx); });
|
||||
btnDelTab.addEventListener('click', () => { const tabEls = U.qa(tabs, '.lwb-ve-tab'); if (tabEls.length <= 1) { U.toast.warn('至少保留一组'); return; } const activeIdx = tabEls.findIndex(t => t.classList.contains('active')), idx = activeIdx >= 0 ? activeIdx : 0; U.qa(pagesWrap, '.lwb-ve-page')[idx]?.remove(); tabEls[idx]?.remove(); vareventBlocks.splice(idx, 1); const rebind = U.qa(tabs, '.lwb-ve-tab'); rebind.forEach((t, i) => { const nt = t.cloneNode(true); nt.textContent = `组 ${i + 1}`; nt.addEventListener('click', () => pagesWrap._lwbRenderPage(i)); tabs.replaceChild(nt, t); }); pagesWrap._lwbRenderPage(Math.max(0, Math.min(idx, rebind.length - 1))); });
|
||||
btnOk.addEventListener('click', () => {
|
||||
const pageEls = U.qa(pagesWrap, '.lwb-ve-page'); if (pageEls.length === 0) { closeEditor(); return; }
|
||||
const builtBlocks = [], seenIds = new Set();
|
||||
pageEls.forEach((p) => { const wrap = p.querySelector(':scope > div'), blks = wrap ? U.qa(wrap, '.lwb-ve-event') : [], lines = ['<varevent>']; let hasEvents = false; blks.forEach((b, j) => { const r = UI.processEventBlock(b, j); if (r.lines.length > 0) { const idLine = r.lines[0], mm = idLine.match(/^\[\s*event\.([^\]]+)\]/i), id = mm ? mm[1] : `evt_${j + 1}`; let use = id, k = 2; while (seenIds.has(use)) use = `${id}_${k++}`; if (use !== id) r.lines[0] = `[event.${use}]`; seenIds.add(use); lines.push(...r.lines); hasEvents = true; } }); if (hasEvents) { lines.push('</varevent>'); builtBlocks.push(lines.join('\n')); } });
|
||||
const oldVal = textarea.value || '', originals = [], RE = { varevent: /<varevent>([\s\S]*?)<\/varevent>/gi }; RE.varevent.lastIndex = 0; let m; while ((m = RE.varevent.exec(oldVal)) !== null) originals.push({ start: m.index, end: RE.varevent.lastIndex });
|
||||
let acc = '', pos = 0; const minLen = Math.min(originals.length, builtBlocks.length);
|
||||
for (let i = 0; i < originals.length; i++) { const { start, end } = originals[i]; acc += oldVal.slice(pos, start); if (i < minLen) acc += builtBlocks[i]; pos = end; } acc += oldVal.slice(pos);
|
||||
if (builtBlocks.length > originals.length) { const extras = builtBlocks.slice(originals.length).join('\n\n'); acc = acc.replace(/\s*$/, ''); if (acc && !/(?:\r?\n){2}$/.test(acc)) acc += (/\r?\n$/.test(acc) ? '' : '\n') + '\n'; acc += extras; }
|
||||
acc = acc.replace(/(?:\r?\n){3,}/g, '\n\n'); textarea.value = acc; try { window?.jQuery?.(textarea)?.trigger?.('input'); } catch {}
|
||||
U.toast.ok('已更新条件规则到该世界书条目'); closeEditor();
|
||||
});
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
export function openActionBuilder(block) {
|
||||
const TYPES = [
|
||||
{ value: 'var.set', label: '变量: set', template: `<input class="lwb-ve-input" placeholder="变量名 key"/><input class="lwb-ve-input" placeholder="值 value"/>` },
|
||||
{ value: 'var.bump', label: '变量: bump(+/-)', template: `<input class="lwb-ve-input" placeholder="变量名 key"/><input class="lwb-ve-input" placeholder="增量(整数,可负) delta"/>` },
|
||||
{ value: 'var.del', label: '变量: del', template: `<input class="lwb-ve-input" placeholder="变量名 key"/>` },
|
||||
{ value: 'wi.enableUID', label: '世界书: 启用条目(UID)', template: `<input class="lwb-ve-input" placeholder="世界书文件名 file(必填)"/><input class="lwb-ve-input" placeholder="条目UID(必填)"/>` },
|
||||
{ value: 'wi.disableUID', label: '世界书: 禁用条目(UID)', template: `<input class="lwb-ve-input" placeholder="世界书文件名 file(必填)"/><input class="lwb-ve-input" placeholder="条目UID(必填)"/>` },
|
||||
{ value: 'wi.setContentUID', label: '世界书: 设置内容(UID)', template: `<input class="lwb-ve-input" placeholder="世界书文件名 file(必填)"/><input class="lwb-ve-input" placeholder="条目UID(必填)"/><textarea class="lwb-ve-text" rows="3" placeholder="内容 content(可多行)"></textarea>` },
|
||||
{ value: 'wi.createContent', label: '世界书: 新建条目(仅内容)', template: `<input class="lwb-ve-input" placeholder="世界书文件名 file(必填)"/><input class="lwb-ve-input" placeholder="条目 key(建议填写)"/><textarea class="lwb-ve-text" rows="4" placeholder="新条目内容 content(可留空)"></textarea>` },
|
||||
{ value: 'qr.run', label: '快速回复(/run)', template: `<input class="lwb-ve-input" placeholder="预设名(可空) preset"/><input class="lwb-ve-input" placeholder="标签(label,必填)"/>` },
|
||||
{ value: 'custom.st', label: '自定义ST命令', template: `<textarea class="lwb-ve-text" rows="4" placeholder="每行一条斜杠命令"></textarea>` },
|
||||
];
|
||||
const ui = U.mini(`<div class="lwb-ve-section"><div class="lwb-ve-label">添加动作</div><div id="lwb-action-list"></div><button type="button" class="lwb-ve-btn" id="lwb-add-action">+动作</button></div>`, '常用st控制');
|
||||
const list = ui.body.querySelector('#lwb-action-list'), addBtn = ui.body.querySelector('#lwb-add-action');
|
||||
const addRow = (presetType) => { const row = U.el('div', 'lwb-ve-row'); row.style.alignItems = 'flex-start'; row.innerHTML = `<select class="lwb-ve-input lwb-ve-mini lwb-act-type"></select><div class="lwb-ve-fields" style="flex:1; display:grid; grid-template-columns: 1fr 1fr; gap:6px;"></div><button type="button" class="lwb-ve-btn ghost lwb-ve-del">删除</button>`; const typeSel = row.querySelector('.lwb-act-type'), fields = row.querySelector('.lwb-ve-fields'); row.querySelector('.lwb-ve-del').addEventListener('click', () => row.remove()); typeSel.innerHTML = TYPES.map(a => `<option value="${a.value}">${a.label}</option>`).join(''); const renderFields = () => { const def = TYPES.find(a => a.value === typeSel.value); fields.innerHTML = def ? def.template : ''; }; typeSel.addEventListener('change', renderFields); if (presetType) typeSel.value = presetType; renderFields(); list.appendChild(row); };
|
||||
addBtn.addEventListener('click', () => addRow()); addRow();
|
||||
ui.btnOk.addEventListener('click', () => {
|
||||
const rows = U.qa(list, '.lwb-ve-row'), actions = [];
|
||||
for (const r of rows) { const type = r.querySelector('.lwb-act-type')?.value, inputs = U.qa(r, '.lwb-ve-fields .lwb-ve-input, .lwb-ve-fields .lwb-ve-text').map(i => i.value); if (type === 'var.set' && inputs[0]) actions.push({ type, key: inputs[0], value: inputs[1] || '' }); if (type === 'var.bump' && inputs[0]) actions.push({ type, key: inputs[0], delta: inputs[1] || '0' }); if (type === 'var.del' && inputs[0]) actions.push({ type, key: inputs[0] }); if ((type === 'wi.enableUID' || type === 'wi.disableUID') && inputs[0] && inputs[1]) actions.push({ type, file: inputs[0], uid: inputs[1] }); if (type === 'wi.setContentUID' && inputs[0] && inputs[1]) actions.push({ type, file: inputs[0], uid: inputs[1], content: inputs[2] || '' }); if (type === 'wi.createContent' && inputs[0]) actions.push({ type, file: inputs[0], key: inputs[1] || '', content: inputs[2] || '' }); if (type === 'qr.run' && inputs[1]) actions.push({ type, preset: inputs[0] || '', label: inputs[1] }); if (type === 'custom.st' && inputs[0]) { const cmds = inputs[0].split('\n').map(s => s.trim()).filter(Boolean).map(c => c.startsWith('/') ? c : '/' + c).join(' | '); if (cmds) actions.push({ type, script: cmds }); } }
|
||||
const jsCode = buildSTscriptFromActions(actions), jsBox = block?.querySelector?.('.lwb-ve-js'); if (jsCode && jsBox) jsBox.value = jsCode; ui.wrap.remove();
|
||||
});
|
||||
}
|
||||
|
||||
export function openBumpAliasBuilder(block) {
|
||||
const ui = U.mini(`<div class="lwb-ve-section"><div class="lwb-ve-label">bump数值映射(每行一条:变量名(可空) | 短语或 /regex/flags | 数值)</div><div id="lwb-bump-list"></div><button type="button" class="lwb-ve-btn" id="lwb-add-bump">+映射</button></div>`, 'bump数值映射设置');
|
||||
const list = ui.body.querySelector('#lwb-bump-list'), addBtn = ui.body.querySelector('#lwb-add-bump');
|
||||
const addRow = (scope = '', phrase = '', val = '1') => { const row = U.el('div', 'lwb-ve-row', `<input class="lwb-ve-input" placeholder="变量名(可空=全局)" value="${scope}"/><input class="lwb-ve-input" placeholder="短语 或 /regex(例:/她(很)?开心/i)" value="${phrase}"/><input class="lwb-ve-input" placeholder="数值(整数,可负)" value="${val}"/><button type="button" class="lwb-ve-btn ghost lwb-ve-del">删除</button>`); row.querySelector('.lwb-ve-del').addEventListener('click', () => row.remove()); list.appendChild(row); };
|
||||
addBtn.addEventListener('click', () => addRow());
|
||||
try { const store = getBumpAliasStore() || {}; const addFromBucket = (scope, bucket) => { let n = 0; for (const [phrase, val] of Object.entries(bucket || {})) { addRow(scope, phrase, String(val)); n++; } return n; }; let prefilled = 0; if (store._global) prefilled += addFromBucket('', store._global); for (const [scope, bucket] of Object.entries(store || {})) if (scope !== '_global') prefilled += addFromBucket(scope, bucket); if (prefilled === 0) addRow(); } catch { addRow(); }
|
||||
ui.btnOk.addEventListener('click', async () => { try { const rows = U.qa(list, '.lwb-ve-row'), items = rows.map(r => { const ins = U.qa(r, '.lwb-ve-input').map(i => i.value); return { scope: (ins[0] || '').trim(), phrase: (ins[1] || '').trim(), val: Number(ins[2] || 0) }; }).filter(x => x.phrase), next = {}; for (const it of items) { const bucket = it.scope ? (next[it.scope] ||= {}) : (next._global ||= {}); bucket[it.phrase] = Number.isFinite(it.val) ? it.val : 0; } await setBumpAliasStore(next); U.toast.ok('Bump 映射已保存到角色卡'); ui.wrap.remove(); } catch {} });
|
||||
}
|
||||
|
||||
function tryInjectButtons(root) {
|
||||
const scope = root.closest?.('#WorldInfo') || document.getElementById('WorldInfo') || root;
|
||||
scope.querySelectorAll?.('.world_entry .alignitemscenter.flex-container .editor_maximize')?.forEach((maxBtn) => {
|
||||
const container = maxBtn.parentElement; if (!container || container.querySelector('.lwb-var-editor-button')) return;
|
||||
const entry = container.closest('.world_entry'), uid = entry?.getAttribute('data-uid') || entry?.dataset?.uid || (window?.jQuery ? window.jQuery(entry).data('uid') : undefined);
|
||||
const btn = U.el('div', 'right_menu_button interactable lwb-var-editor-button'); btn.title = '条件规则编辑器'; btn.innerHTML = '<i class="fa-solid fa-pen-ruler"></i>';
|
||||
btn.addEventListener('click', () => openVarEditor(entry || undefined, uid)); container.insertBefore(btn, maxBtn.nextSibling);
|
||||
});
|
||||
}
|
||||
|
||||
function observeWIEntriesForEditorButton() {
|
||||
try { LWBVE.obs?.disconnect(); LWBVE.obs = null; } catch {}
|
||||
const root = document.getElementById('WorldInfo') || document.body;
|
||||
const cb = (() => { let t = null; return () => { clearTimeout(t); t = setTimeout(() => tryInjectButtons(root), 100); }; })();
|
||||
const obs = new MutationObserver(() => cb()); try { obs.observe(root, { childList: true, subtree: true }); } catch {} LWBVE.obs = obs;
|
||||
}
|
||||
|
||||
export function initVareventEditor() {
|
||||
if (initialized) return; initialized = true;
|
||||
events = createModuleEvents(MODULE_ID);
|
||||
injectEditorStyles();
|
||||
installWIHiddenTagStripper();
|
||||
registerWIEventSystem();
|
||||
observeWIEntriesForEditorButton();
|
||||
setTimeout(() => tryInjectButtons(document.body), 600);
|
||||
if (typeof window !== 'undefined') { window.LWBVE = LWBVE; window.openVarEditor = openVarEditor; window.openActionBuilder = openActionBuilder; window.openBumpAliasBuilder = openBumpAliasBuilder; window.parseVareventEvents = parseVareventEvents; window.getBumpAliasStore = getBumpAliasStore; window.setBumpAliasStore = setBumpAliasStore; window.clearBumpAliasStore = clearBumpAliasStore; }
|
||||
LWBVE.installed = true;
|
||||
}
|
||||
|
||||
export function cleanupVareventEditor() {
|
||||
if (!initialized) return;
|
||||
events?.cleanup(); events = null;
|
||||
U.qa(document, '.lwb-ve-overlay').forEach(el => el.remove());
|
||||
U.qa(document, '.lwb-var-editor-button').forEach(el => el.remove());
|
||||
document.getElementById(EDITOR_STYLES_ID)?.remove();
|
||||
try { getContext()?.setExtensionPrompt?.(LWB_VAREVENT_PROMPT_KEY, '', 0, 0, false); } catch {}
|
||||
try { const { eventSource } = getContext() || {}; const orig = eventSource && origEmitMap.get(eventSource); if (orig) { eventSource.emit = orig; origEmitMap.delete(eventSource); } } catch {}
|
||||
try { LWBVE.obs?.disconnect(); LWBVE.obs = null; } catch {}
|
||||
if (typeof window !== 'undefined') LWBVE.installed = false;
|
||||
initialized = false;
|
||||
}
|
||||
|
||||
// 供 variables-core.js 复用的解析工具
|
||||
export { stripYamlInlineComment, OP_MAP, TOP_OP_RE };
|
||||
|
||||
export { MODULE_ID, LWBVE };
|
||||
2385
modules/variables/variables-core.js
Normal file
2385
modules/variables/variables-core.js
Normal file
File diff suppressed because it is too large
Load Diff
679
modules/variables/variables-panel.js
Normal file
679
modules/variables/variables-panel.js
Normal file
@@ -0,0 +1,679 @@
|
||||
import { extension_settings, getContext, saveMetadataDebounced } from "../../../../../extensions.js";
|
||||
import { saveSettingsDebounced, chat_metadata } from "../../../../../../script.js";
|
||||
import { getLocalVariable, setLocalVariable, getGlobalVariable, setGlobalVariable } from "../../../../../variables.js";
|
||||
import { extensionFolderPath } from "../../core/constants.js";
|
||||
import { createModuleEvents, event_types } from "../../core/event-manager.js";
|
||||
|
||||
const CONFIG = {
|
||||
extensionName: "variables-panel",
|
||||
extensionFolderPath,
|
||||
defaultSettings: { enabled: false },
|
||||
watchInterval: 1500, touchTimeout: 4000, longPressDelay: 700,
|
||||
};
|
||||
|
||||
const EMBEDDED_CSS = `
|
||||
.vm-container{color:var(--SmartThemeBodyColor);background:var(--SmartThemeBlurTintColor);flex-direction:column;overflow-y:auto;z-index:3000;position:fixed;display:none}
|
||||
.vm-container:not([style*="display: none"]){display:flex}
|
||||
@media (min-width: 1000px){.vm-container:not([style*="display: none"]){width:calc((100vw - var(--sheldWidth)) / 2);border-left:1px solid var(--SmartThemeBorderColor);right:0;top:0;height:100vh}}
|
||||
@media (max-width: 999px){.vm-container:not([style*="display: none"]){max-height:calc(100svh - var(--topBarBlockSize));top:var(--topBarBlockSize);width:100%;height:100vh;left:0}}
|
||||
.vm-header,.vm-section,.vm-item-content{border-bottom:.5px solid var(--SmartThemeBorderColor)}
|
||||
.vm-header,.vm-section-header{display:flex;justify-content:space-between;align-items:center}
|
||||
.vm-title,.vm-item-name{font-weight:bold}
|
||||
.vm-header{padding:15px}.vm-title{font-size:16px}
|
||||
.vm-section-header{padding:5px 15px;border-bottom:5px solid var(--SmartThemeBorderColor);font-size:14px;color:var(--SmartThemeEmColor)}
|
||||
.vm-close,.vm-btn{background:none;border:none;cursor:pointer;display:inline-flex;align-items:center;justify-content:center}
|
||||
.vm-close{font-size:18px;padding:5px}
|
||||
.vm-btn{border:1px solid var(--SmartThemeBorderColor);border-radius:3px;font-size:12px;padding:2px 4px;color:var(--SmartThemeBodyColor)}
|
||||
.vm-search-container{padding:10px;border-bottom:1px solid var(--SmartThemeBorderColor)}
|
||||
.vm-search-input{width:100%;padding:3px 6px}
|
||||
.vm-clear-all-btn{color:#ff6b6b;border-color:#ff6b6b;opacity:.3}
|
||||
.vm-list{flex:1;overflow-y:auto;padding:10px}
|
||||
.vm-item{border:1px solid var(--SmartThemeBorderColor);opacity:.7}
|
||||
.vm-item.expanded{opacity:1}
|
||||
.vm-item-header{display:flex;justify-content:space-between;align-items:center;cursor:pointer;padding-left:5px}
|
||||
.vm-item-name{font-size:13px}
|
||||
.vm-item-controls{background:var(--SmartThemeChatTintColor);display:flex;gap:5px;position:absolute;right:5px;opacity:0;visibility:hidden}
|
||||
.vm-item-content{border-top:1px solid var(--SmartThemeBorderColor);display:none}
|
||||
.vm-item.expanded>.vm-item-content{display:block}
|
||||
.vm-inline-form{background:var(--SmartThemeChatTintColor);border:1px solid var(--SmartThemeBorderColor);border-top:none;padding:10px;margin:0;display:none}
|
||||
.vm-inline-form.active{display:block;animation:slideDown .2s ease-out}
|
||||
@keyframes slideDown{from{opacity:0;max-height:0;padding-top:0;padding-bottom:0}to{opacity:1;max-height:200px;padding-top:10px;padding-bottom:10px}}
|
||||
@media (hover:hover){.vm-close:hover,.vm-btn:hover{opacity:.8}.vm-close:hover{color:red}.vm-clear-all-btn:hover{opacity:1}.vm-item:hover>.vm-item-header .vm-item-controls{opacity:1;visibility:visible}.vm-list:hover::-webkit-scrollbar-thumb{background:var(--SmartThemeQuoteColor)}.vm-variable-checkbox:hover{background-color:rgba(255,255,255,.1)}}
|
||||
@media (hover:none){.vm-close:active,.vm-btn:active{opacity:.8}.vm-close:active{color:red}.vm-clear-all-btn:active{opacity:1}.vm-item:active>.vm-item-header .vm-item-controls,.vm-item.touched>.vm-item-header .vm-item-controls{opacity:1;visibility:visible}.vm-item.touched>.vm-item-header{background-color:rgba(255,255,255,.05)}.vm-btn:active{background-color:rgba(255,255,255,.1);transform:scale(.95)}.vm-variable-checkbox:active{background-color:rgba(255,255,255,.1)}}
|
||||
.vm-item:not([data-level]).expanded .vm-item[data-level="1"]{--level-color:hsl(36,100%,50%)}
|
||||
.vm-item[data-level="1"].expanded .vm-item[data-level="2"]{--level-color:hsl(60,100%,50%)}
|
||||
.vm-item[data-level="2"].expanded .vm-item[data-level="3"]{--level-color:hsl(120,100%,50%)}
|
||||
.vm-item[data-level="3"].expanded .vm-item[data-level="4"]{--level-color:hsl(180,100%,50%)}
|
||||
.vm-item[data-level="4"].expanded .vm-item[data-level="5"]{--level-color:hsl(240,100%,50%)}
|
||||
.vm-item[data-level="5"].expanded .vm-item[data-level="6"]{--level-color:hsl(280,100%,50%)}
|
||||
.vm-item[data-level="6"].expanded .vm-item[data-level="7"]{--level-color:hsl(320,100%,50%)}
|
||||
.vm-item[data-level="7"].expanded .vm-item[data-level="8"]{--level-color:hsl(200,100%,50%)}
|
||||
.vm-item[data-level="8"].expanded .vm-item[data-level="9"]{--level-color:hsl(160,100%,50%)}
|
||||
.vm-item[data-level]{border-left:2px solid var(--level-color);margin-left:6px}
|
||||
.vm-item[data-level]:last-child{border-bottom:2px solid var(--level-color)}
|
||||
.vm-tree-value,.vm-variable-checkbox span{font-family:monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.vm-tree-value{color:inherit;font-size:12px;flex:1;margin:0 10px}
|
||||
.vm-input,.vm-textarea{border:1px solid var(--SmartThemeBorderColor);border-radius:3px;background-color:var(--SmartThemeChatTintColor);font-size:12px;margin:3px 0}
|
||||
.vm-textarea{min-height:60px;padding:5px;font-family:monospace;resize:vertical}
|
||||
.vm-add-form{padding:10px;border-top:1px solid var(--SmartThemeBorderColor);display:none}
|
||||
.vm-add-form.active{display:block}
|
||||
.vm-form-row{display:flex;gap:10px;margin-bottom:10px;align-items:center}
|
||||
.vm-form-label{min-width:30px;font-size:12px;font-weight:bold}
|
||||
.vm-form-input{flex:1}
|
||||
.vm-form-buttons{display:flex;gap:5px;justify-content:flex-end}
|
||||
.vm-list::-webkit-scrollbar{width:6px}
|
||||
.vm-list::-webkit-scrollbar-track{background:var(--SmartThemeBodyColor)}
|
||||
.vm-list::-webkit-scrollbar-thumb{background:var(--SmartThemeBorderColor);border-radius:3px}
|
||||
.vm-empty-message{padding:20px;text-align:center;color:#888}
|
||||
.vm-item-name-visible{opacity:1}
|
||||
.vm-item-separator{opacity:.3}
|
||||
.vm-null-value{opacity:.6}
|
||||
.mes_btn.mes_variables_panel{opacity:.6}
|
||||
.mes_btn.mes_variables_panel:hover{opacity:1}
|
||||
.vm-badges{display:inline-flex;gap:6px;margin-left:6px;align-items:center}
|
||||
.vm-badge[data-type="ro"]{color:#F9C770}
|
||||
.vm-badge[data-type="struct"]{color:#48B0C7}
|
||||
.vm-badge[data-type="cons"]{color:#D95E37}
|
||||
.vm-badge:hover{opacity:1;filter:saturate(1.2)}
|
||||
:root{--vm-badge-nudge:0.06em}
|
||||
.vm-item-name{display:inline-flex;align-items:center}
|
||||
.vm-badges{display:inline-flex;gap:.35em;margin-left:.35em}
|
||||
.vm-item-name .vm-badge{display:flex;width:1em;position:relative;top:var(--vm-badge-nudge) !important;opacity:.9}
|
||||
.vm-item-name .vm-badge i{display:block;font-size:.8em;line-height:1em}
|
||||
`;
|
||||
|
||||
const EMBEDDED_HTML = `
|
||||
<div id="vm-container" class="vm-container" style="display:none">
|
||||
<div class="vm-header">
|
||||
<div class="vm-title">变量面板</div>
|
||||
<button id="vm-close" class="vm-close"><i class="fa-solid fa-times"></i></button>
|
||||
</div>
|
||||
<div class="vm-content">
|
||||
${['character','global'].map(t=>`
|
||||
<div class="vm-section" id="${t}-variables-section">
|
||||
<div class="vm-section-header">
|
||||
<div class="vm-section-title"><i class="fa-solid ${t==='character'?'fa-user':'fa-globe'}"></i>${t==='character'?' 本地变量':' 全局变量'}</div>
|
||||
<div class="vm-section-controls">
|
||||
${[['import','fa-upload','导入变量'],['export','fa-download','导出变量'],['add','fa-plus','添加变量'],['collapse','fa-chevron-down','展开/折叠所有'],['clear-all','fa-trash','清除所有变量']].map(([a,ic,ti])=>`<button class="vm-btn ${a==='clear-all'?'vm-clear-all-btn':''}" data-type="${t}" data-act="${a}" title="${ti}"><i class="fa-solid ${ic}"></i></button>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="vm-search-container"><input type="text" class="vm-input vm-search-input" id="${t}-vm-search" placeholder="搜索${t==='character'?'本地':'全局'}变量..."></div>
|
||||
<div class="vm-list" id="${t}-variables-list"></div>
|
||||
<div class="vm-add-form" id="${t}-vm-add-form">
|
||||
<div class="vm-form-row"><label class="vm-form-label">名称:</label><input type="text" class="vm-input vm-form-input" id="${t}-vm-name" placeholder="变量名称"></div>
|
||||
<div class="vm-form-row"><label class="vm-form-label">值:</label><textarea class="vm-textarea vm-form-input" id="${t}-vm-value" placeholder="变量值 (支持JSON格式)"></textarea></div>
|
||||
<div class="vm-form-buttons">
|
||||
<button class="vm-btn" data-type="${t}" data-act="save-add"><i class="fa-solid fa-floppy-disk"></i>保存</button>
|
||||
<button class="vm-btn" data-type="${t}" data-act="cancel-add">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const VT = {
|
||||
character: { getter: getLocalVariable, setter: setLocalVariable, storage: ()=> chat_metadata?.variables || (chat_metadata.variables = {}), save: saveMetadataDebounced },
|
||||
global: { getter: getGlobalVariable, setter: setGlobalVariable, storage: ()=> extension_settings.variables?.global || ((extension_settings.variables = { global: {} }).global), save: saveSettingsDebounced },
|
||||
};
|
||||
|
||||
const LWB_RULES_KEY='LWB_RULES';
|
||||
const getRulesTable = () => { try { return getContext()?.chatMetadata?.[LWB_RULES_KEY] || {}; } catch { return {}; } };
|
||||
const pathKey = (arr)=>{ try { return (arr||[]).map(String).join('.'); } catch { return ''; } };
|
||||
const getRuleNodeByPath = (arr)=> (pathKey(arr) ? (getRulesTable()||{})[pathKey(arr)] : undefined);
|
||||
const hasAnyRule = (n)=>{
|
||||
if(!n) return false;
|
||||
if(n.ro) return true;
|
||||
if(n.objectPolicy && n.objectPolicy!=='none') return true;
|
||||
if(n.arrayPolicy && n.arrayPolicy!=='lock') return true;
|
||||
const c=n.constraints||{};
|
||||
return ('min'in c)||('max'in c)||('step'in c)||(Array.isArray(c.enum)&&c.enum.length)||(c.regex&&c.regex.source);
|
||||
};
|
||||
const ruleTip = (n)=>{
|
||||
if(!n) return '';
|
||||
const lines=[], c=n.constraints||{};
|
||||
if(n.ro) lines.push('只读:$ro');
|
||||
if(n.objectPolicy){ const m={none:'(默认:不可增删键)',ext:'$ext(可增键)',prune:'$prune(可删键)',free:'$free(可增删键)'}; lines.push(`对象策略:${m[n.objectPolicy]||n.objectPolicy}`); }
|
||||
if(n.arrayPolicy){ const m={lock:'(默认:不可增删项)',grow:'$grow(可增项)',shrink:'$shrink(可删项)',list:'$list(可增删项)'}; lines.push(`数组策略:${m[n.arrayPolicy]||n.arrayPolicy}`); }
|
||||
if('min'in c||'max'in c){ if('min'in c&&'max'in c) lines.push(`范围:$range=[${c.min},${c.max}]`); else if('min'in c) lines.push(`下限:$min=${c.min}`); else lines.push(`上限:$max=${c.max}`); }
|
||||
if('step'in c) lines.push(`步长:$step=${c.step}`);
|
||||
if(Array.isArray(c.enum)&&c.enum.length) lines.push(`枚举:$enum={${c.enum.join(';')}}`);
|
||||
if(c.regex&&c.regex.source) lines.push(`正则:$match=/${c.regex.source}/${c.regex.flags||''}`);
|
||||
return lines.join('\n');
|
||||
};
|
||||
const badgesHtml = (n)=>{
|
||||
if(!hasAnyRule(n)) return '';
|
||||
const tip=ruleTip(n).replace(/"/g,'"'), out=[];
|
||||
if(n.ro) out.push(`<span class="vm-badge" data-type="ro" title="${tip}"><i class="fa-solid fa-shield-halved"></i></span>`);
|
||||
if((n.objectPolicy&&n.objectPolicy!=='none')||(n.arrayPolicy&&n.arrayPolicy!=='lock')) out.push(`<span class="vm-badge" data-type="struct" title="${tip}"><i class="fa-solid fa-diagram-project"></i></span>`);
|
||||
const c=n.constraints||{}; if(('min'in c)||('max'in c)||('step'in c)||(Array.isArray(c.enum)&&c.enum.length)||(c.regex&&c.regex.source)) out.push(`<span class="vm-badge" data-type="cons" title="${tip}"><i class="fa-solid fa-ruler-vertical"></i></span>`);
|
||||
return out.length?`<span class="vm-badges">${out.join('')}</span>`:'';
|
||||
};
|
||||
const debounce=(fn,ms=200)=>{let t;return(...a)=>{clearTimeout(t);t=setTimeout(()=>fn(...a),ms);}};
|
||||
|
||||
class VariablesPanel {
|
||||
constructor(){
|
||||
this.state={isOpen:false,isEnabled:false,container:null,timers:{watcher:null,longPress:null,touch:new Map()},currentInlineForm:null,formState:{},rulesChecksum:''};
|
||||
this.variableSnapshot=null; this.eventHandlers={}; this.savingInProgress=false; this.containerHtml=EMBEDDED_HTML;
|
||||
}
|
||||
|
||||
async init(){
|
||||
this.injectUI(); this.bindControlToggle();
|
||||
const s=this.getSettings(); this.state.isEnabled=s.enabled; this.syncCheckbox();
|
||||
if(s.enabled) this.enable();
|
||||
}
|
||||
|
||||
injectUI(){
|
||||
if(!document.getElementById('variables-panel-css')){
|
||||
const st=document.createElement('style'); st.id='variables-panel-css'; st.textContent=EMBEDDED_CSS; document.head.appendChild(st);
|
||||
}
|
||||
}
|
||||
|
||||
getSettings(){ extension_settings.LittleWhiteBox ??= {}; return extension_settings.LittleWhiteBox.variablesPanel ??= {...CONFIG.defaultSettings}; }
|
||||
vt(t){ return VT[t]; }
|
||||
store(t){ return this.vt(t).storage(); }
|
||||
|
||||
enable(){
|
||||
this.createContainer(); this.bindEvents();
|
||||
['character','global'].forEach(t=>this.normalizeStore(t));
|
||||
this.loadVariables(); this.installMessageButtons();
|
||||
}
|
||||
disable(){ this.cleanup(); }
|
||||
|
||||
cleanup(){
|
||||
this.stopWatcher(); this.unbindEvents(); this.unbindControlToggle(); this.removeContainer(); this.removeAllMessageButtons();
|
||||
const tm=this.state.timers; if(tm.watcher) clearInterval(tm.watcher); if(tm.longPress) clearTimeout(tm.longPress);
|
||||
tm.touch.forEach(x=>clearTimeout(x)); tm.touch.clear();
|
||||
Object.assign(this.state,{isOpen:false,timers:{watcher:null,longPress:null,touch:new Map()},currentInlineForm:null,formState:{},rulesChecksum:''});
|
||||
this.variableSnapshot=null; this.savingInProgress=false;
|
||||
}
|
||||
|
||||
createContainer(){
|
||||
if(!this.state.container?.length){
|
||||
$('body').append(this.containerHtml);
|
||||
this.state.container=$("#vm-container");
|
||||
$("#vm-close").off('click').on('click',()=>this.close());
|
||||
}
|
||||
}
|
||||
removeContainer(){ this.state.container?.remove(); this.state.container=null; }
|
||||
|
||||
open(){
|
||||
if(!this.state.isEnabled) return toastr.warning('请先启用变量面板');
|
||||
this.createContainer(); this.bindEvents(); this.state.isOpen=true; this.state.container.show();
|
||||
this.state.rulesChecksum = JSON.stringify(getRulesTable()||{});
|
||||
this.loadVariables(); this.startWatcher();
|
||||
}
|
||||
close(){ this.state.isOpen=false; this.stopWatcher(); this.unbindEvents(); this.removeContainer(); }
|
||||
|
||||
bindControlToggle(){
|
||||
const id='xiaobaix_variables_panel_enabled';
|
||||
const bind=()=>{
|
||||
const cb=document.getElementById(id); if(!cb) return false;
|
||||
this.handleCheckboxChange && cb.removeEventListener('change',this.handleCheckboxChange);
|
||||
this.handleCheckboxChange=e=> this.toggleEnabled(e.target instanceof HTMLInputElement ? !!e.target.checked : false);
|
||||
cb.addEventListener('change',this.handleCheckboxChange); if(cb instanceof HTMLInputElement) cb.checked=this.state.isEnabled; return true;
|
||||
};
|
||||
if(!bind()) setTimeout(bind,100);
|
||||
}
|
||||
unbindControlToggle(){
|
||||
const cb=document.getElementById('xiaobaix_variables_panel_enabled');
|
||||
if(cb && this.handleCheckboxChange) cb.removeEventListener('change',this.handleCheckboxChange);
|
||||
this.handleCheckboxChange=null;
|
||||
}
|
||||
syncCheckbox(){ const cb=document.getElementById('xiaobaix_variables_panel_enabled'); if(cb instanceof HTMLInputElement) cb.checked=this.state.isEnabled; }
|
||||
|
||||
bindEvents(){
|
||||
if(!this.state.container?.length) return;
|
||||
this.unbindEvents();
|
||||
const ns='.vm';
|
||||
$(document)
|
||||
.on(`click${ns}`,'.vm-section [data-act]',e=>this.onHeaderAction(e))
|
||||
.on(`touchstart${ns}`,'.vm-item>.vm-item-header',e=>this.handleTouch(e))
|
||||
.on(`click${ns}`,'.vm-item>.vm-item-header',e=>this.handleItemClick(e))
|
||||
.on(`click${ns}`,'.vm-item-controls [data-act]',e=>this.onItemAction(e))
|
||||
.on(`click${ns}`,'.vm-inline-form [data-act]',e=>this.onInlineAction(e))
|
||||
.on(`mousedown${ns} touchstart${ns}`,'[data-act="copy"]',e=>this.bindCopyPress(e));
|
||||
['character','global'].forEach(t=> $(`#${t}-vm-search`).on('input',e=>{
|
||||
if(e.currentTarget instanceof HTMLInputElement) this.searchVariables(t,e.currentTarget.value);
|
||||
else this.searchVariables(t,'');
|
||||
}));
|
||||
}
|
||||
unbindEvents(){ $(document).off('.vm'); ['character','global'].forEach(t=> $(`#${t}-vm-search`).off('input')); }
|
||||
|
||||
onHeaderAction(e){
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
const b=$(e.currentTarget), act=b.data('act'), t=b.data('type');
|
||||
({
|
||||
import:()=>this.importVariables(t),
|
||||
export:()=>this.exportVariables(t),
|
||||
add:()=>this.showAddForm(t),
|
||||
collapse:()=>this.collapseAll(t),
|
||||
'clear-all':()=>this.clearAllVariables(t),
|
||||
'save-add':()=>this.saveAddVariable(t),
|
||||
'cancel-add':()=>this.hideAddForm(t),
|
||||
}[act]||(()=>{}))();
|
||||
}
|
||||
|
||||
onItemAction(e){
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
const btn=$(e.currentTarget), act=btn.data('act'), item=btn.closest('.vm-item'),
|
||||
t=this.getVariableType(item), path=this.getItemPath(item);
|
||||
({
|
||||
edit: ()=>this.editAction(item,'edit',t,path),
|
||||
'add-child': ()=>this.editAction(item,'addChild',t,path),
|
||||
delete: ()=>this.handleDelete(item,t,path),
|
||||
copy: ()=>{}
|
||||
}[act]||(()=>{}))();
|
||||
}
|
||||
|
||||
onInlineAction(e){ e.preventDefault(); e.stopPropagation(); const act=$(e.currentTarget).data('act'); act==='inline-save'? this.handleInlineSave($(e.currentTarget).closest('.vm-inline-form')) : this.hideInlineForm(); }
|
||||
|
||||
bindCopyPress(e){
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
const start=Date.now();
|
||||
this.state.timers.longPress=setTimeout(()=>{ this.handleCopy(e,true); this.state.timers.longPress=null; },CONFIG.longPressDelay);
|
||||
const release=(re)=>{
|
||||
if(this.state.timers.longPress){
|
||||
clearTimeout(this.state.timers.longPress); this.state.timers.longPress=null;
|
||||
if(re.type!=='mouseleave' && (Date.now()-start)<CONFIG.longPressDelay) this.handleCopy(e,false);
|
||||
}
|
||||
$(document).off('mouseup.vm touchend.vm mouseleave.vm',release);
|
||||
};
|
||||
$(document).on('mouseup.vm touchend.vm mouseleave.vm',release);
|
||||
}
|
||||
|
||||
stringifyVar(v){ return typeof v==='string'? v : JSON.stringify(v); }
|
||||
makeSnapshotMap(t){ const s=this.store(t), m={}; for(const[k,v] of Object.entries(s)) m[k]=this.stringifyVar(v); return m; }
|
||||
|
||||
startWatcher(){ this.stopWatcher(); this.updateSnapshot(); this.state.timers.watcher=setInterval(()=> this.state.isOpen && this.checkChanges(), CONFIG.watchInterval); }
|
||||
stopWatcher(){ if(this.state.timers.watcher){ clearInterval(this.state.timers.watcher); this.state.timers.watcher=null; } }
|
||||
|
||||
updateSnapshot(){ this.variableSnapshot={ character:this.makeSnapshotMap('character'), global:this.makeSnapshotMap('global') }; }
|
||||
|
||||
expandChangedKeys(changed){
|
||||
['character','global'].forEach(t=>{
|
||||
const set=changed[t]; if(!set?.size) return;
|
||||
setTimeout(()=>{
|
||||
const list=$(`#${t}-variables-list .vm-item[data-key]`);
|
||||
set.forEach(k=> list.filter((_,el)=>$(el).data('key')===k).addClass('expanded'));
|
||||
},10);
|
||||
});
|
||||
}
|
||||
|
||||
checkChanges(){
|
||||
try{
|
||||
const sum=JSON.stringify(getRulesTable()||{});
|
||||
if(sum!==this.state.rulesChecksum){
|
||||
this.state.rulesChecksum=sum;
|
||||
const keep=this.saveAllExpandedStates();
|
||||
this.loadVariables(); this.restoreAllExpandedStates(keep);
|
||||
}
|
||||
const cur={ character:this.makeSnapshotMap('character'), global:this.makeSnapshotMap('global') };
|
||||
const changed={character:new Set(), global:new Set()};
|
||||
['character','global'].forEach(t=>{
|
||||
const prev=this.variableSnapshot?.[t]||{}, now=cur[t];
|
||||
new Set([...Object.keys(prev),...Object.keys(now)]).forEach(k=>{ if(!(k in prev)||!(k in now)||prev[k]!==now[k]) changed[t].add(k);});
|
||||
});
|
||||
if(changed.character.size||changed.global.size){
|
||||
const keep=this.saveAllExpandedStates();
|
||||
this.variableSnapshot=cur; this.loadVariables(); this.restoreAllExpandedStates(keep); this.expandChangedKeys(changed);
|
||||
}
|
||||
}catch{}
|
||||
}
|
||||
|
||||
loadVariables(){
|
||||
['character','global'].forEach(t=>{
|
||||
this.renderVariables(t);
|
||||
$(`#${t}-variables-section [data-act="collapse"] i`).removeClass('fa-chevron-up').addClass('fa-chevron-down');
|
||||
});
|
||||
}
|
||||
|
||||
renderVariables(t){
|
||||
const c=$(`#${t}-variables-list`).empty(), s=this.store(t), root=Object.entries(s);
|
||||
if(!root.length) c.append('<div class="vm-empty-message">暂无变量</div>');
|
||||
else root.forEach(([k,v])=> c.append(this.createVariableItem(t,k,v,0,[k])));
|
||||
}
|
||||
|
||||
createVariableItem(t,k,v,l=0,fullPath=[]){
|
||||
const parsed=this.parseValue(v), hasChildren=typeof parsed==='object' && parsed!==null;
|
||||
const disp = l===0? this.formatTopLevelValue(v) : this.formatValue(v);
|
||||
const ruleNode=getRuleNodeByPath(fullPath);
|
||||
return $(`<div class="vm-item ${l>0?'vm-tree-level-var':''}" data-key="${k}" data-type="${t||''}" ${l>0?`data-level="${l}"`:''} data-path="${this.escape(pathKey(fullPath))}">
|
||||
<div class="vm-item-header">
|
||||
<div class="vm-item-name vm-item-name-visible">${this.escape(k)}${badgesHtml(ruleNode)}<span class="vm-item-separator">:</span></div>
|
||||
<div class="vm-tree-value">${disp}</div>
|
||||
<div class="vm-item-controls">${this.createButtons()}</div>
|
||||
</div>
|
||||
${hasChildren?`<div class="vm-item-content">${this.renderChildren(parsed,l+1,fullPath)}</div>`:''}
|
||||
</div>`);
|
||||
}
|
||||
|
||||
createButtons(){
|
||||
return [
|
||||
['edit','fa-edit','编辑'],
|
||||
['add-child','fa-plus-circle','添加子变量'],
|
||||
['copy','fa-eye-dropper','复制(长按: 宏,单击: 变量路径)'],
|
||||
['delete','fa-trash','删除'],
|
||||
].map(([act,ic,ti])=>`<button class="vm-btn" data-act="${act}" title="${ti}"><i class="fa-solid ${ic}"></i></button>`).join('');
|
||||
}
|
||||
|
||||
createInlineForm(t,target,fs){
|
||||
const fid=`inline-form-${Date.now()}`;
|
||||
const inf=$(`
|
||||
<div class="vm-inline-form" id="${fid}" data-type="${t}">
|
||||
<div class="vm-form-row"><label class="vm-form-label">名称:</label><input type="text" class="vm-input vm-form-input inline-name" placeholder="变量名称"></div>
|
||||
<div class="vm-form-row"><label class="vm-form-label">值:</label><textarea class="vm-textarea vm-form-input inline-value" placeholder="变量值 (支持JSON格式)"></textarea></div>
|
||||
<div class="vm-form-buttons">
|
||||
<button class="vm-btn" data-act="inline-save"><i class="fa-solid fa-floppy-disk"></i>保存</button>
|
||||
<button class="vm-btn" data-act="inline-cancel">取消</button>
|
||||
</div>
|
||||
</div>`);
|
||||
this.state.currentInlineForm?.remove();
|
||||
target.after(inf); this.state.currentInlineForm=inf; this.state.formState={...fs,formId:fid,targetItem:target};
|
||||
const ta=inf.find('.inline-value'); ta.on('input',()=>this.autoResizeTextarea(ta));
|
||||
setTimeout(()=>{ inf.addClass('active'); inf.find('.inline-name').focus(); },10);
|
||||
return inf;
|
||||
}
|
||||
|
||||
renderChildren(obj,level,parentPath){ return Object.entries(obj).map(([k,v])=> this.createVariableItem(null,k,v,level,[...(parentPath||[]),k])[0].outerHTML).join(''); }
|
||||
|
||||
handleTouch(e){
|
||||
if($(e.target).closest('.vm-item-controls').length) return;
|
||||
e.stopPropagation();
|
||||
const item=$(e.currentTarget).closest('.vm-item'); $('.vm-item').removeClass('touched'); item.addClass('touched');
|
||||
this.clearTouchTimer(item);
|
||||
const t=setTimeout(()=>{ item.removeClass('touched'); this.state.timers.touch.delete(item[0]); },CONFIG.touchTimeout);
|
||||
this.state.timers.touch.set(item[0],t);
|
||||
}
|
||||
clearTouchTimer(i){ const t=this.state.timers.touch.get(i[0]); if(t){ clearTimeout(t); this.state.timers.touch.delete(i[0]); } }
|
||||
|
||||
handleItemClick(e){
|
||||
if($(e.target).closest('.vm-item-controls').length) return;
|
||||
e.stopPropagation();
|
||||
$(e.currentTarget).closest('.vm-item').toggleClass('expanded');
|
||||
}
|
||||
|
||||
async writeClipboard(txt){
|
||||
try{
|
||||
if(navigator.clipboard && window.isSecureContext) await navigator.clipboard.writeText(txt);
|
||||
else { const ta=document.createElement('textarea'); Object.assign(ta.style,{position:'fixed',top:0,left:0,width:'2em',height:'2em',padding:0,border:'none',outline:'none',boxShadow:'none',background:'transparent'}); ta.value=txt; document.body.appendChild(ta); ta.focus(); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); }
|
||||
return true;
|
||||
}catch{ return false; }
|
||||
}
|
||||
|
||||
handleCopy(e,longPress){
|
||||
const item=$(e.target).closest('.vm-item'), path=this.getItemPath(item), t=this.getVariableType(item), level=parseInt(item.attr('data-level'))||0;
|
||||
const formatted=this.formatPath(t,path); let cmd='';
|
||||
if(longPress){
|
||||
if(t==='character'){
|
||||
cmd = level===0 ? `{{getvar::${path[0]}}}` : `{{xbgetvar::${formatted}}}`;
|
||||
}else{
|
||||
cmd = `{{getglobalvar::${path[0]}}}`;
|
||||
if(level>0) toastr.info('全局变量宏暂不支持子路径,已复制顶级变量');
|
||||
}
|
||||
}else cmd=formatted;
|
||||
(async()=> (await this.writeClipboard(cmd)) ? toastr.success(`已复制: ${cmd}`) : toastr.error('复制失败'))();
|
||||
}
|
||||
|
||||
editAction(item,action,type,path){
|
||||
const inf=this.createInlineForm(type,item,{action,path,type});
|
||||
if(action==='edit'){
|
||||
const v=this.getValueByPath(type,path);
|
||||
setTimeout(()=>{
|
||||
inf.find('.inline-name').val(path[path.length-1]);
|
||||
const ta=inf.find('.inline-value');
|
||||
const fill=(val)=> Array.isArray(val)? (val.length===1 ? String(val[0]??'') : JSON.stringify(val,null,2)) : (val&&typeof val==='object'? JSON.stringify(val,null,2) : String(val??''));
|
||||
ta.val(fill(v)); this.autoResizeTextarea(ta);
|
||||
},50);
|
||||
}else if(action==='addChild'){
|
||||
inf.find('.inline-name').attr('placeholder',`为 "${path.join('.')}" 添加子变量名称`);
|
||||
inf.find('.inline-value').attr('placeholder','子变量值 (支持JSON格式)');
|
||||
}
|
||||
}
|
||||
|
||||
handleDelete(_item,t,path){
|
||||
const n=path[path.length-1];
|
||||
if(!confirm(`确定要删除 "${n}" 吗?`)) return;
|
||||
this.withGlobalRefresh(()=> this.deleteByPathSilently(t,path));
|
||||
toastr.success('变量已删除');
|
||||
}
|
||||
|
||||
refreshAndKeep(t,states){ this.vt(t).save(); this.loadVariables(); this.updateSnapshot(); states && this.restoreExpandedStates(t,states); }
|
||||
withPreservedExpansion(t,fn){ const s=this.saveExpandedStates(t); fn(); this.refreshAndKeep(t,s); }
|
||||
withGlobalRefresh(fn){ const s=this.saveAllExpandedStates(); fn(); this.loadVariables(); this.updateSnapshot(); this.restoreAllExpandedStates(s); }
|
||||
|
||||
handleInlineSave(form){
|
||||
if(this.savingInProgress) return; this.savingInProgress=true;
|
||||
try{
|
||||
if(!form?.length) return toastr.error('表单未找到');
|
||||
const rawName=form.find('.inline-name').val();
|
||||
const rawValue=form.find('.inline-value').val();
|
||||
const name= typeof rawName==='string'? rawName.trim() : String(rawName ?? '').trim();
|
||||
const value= typeof rawValue==='string'? rawValue.trim() : String(rawValue ?? '').trim();
|
||||
const type=form.data('type');
|
||||
if(!name) return form.find('.inline-name').focus(), toastr.error('请输入变量名称');
|
||||
const val=this.processValue(value), {action,path}=this.state.formState;
|
||||
this.withPreservedExpansion(type,()=>{
|
||||
if(action==='addChild') {
|
||||
this.setValueByPath(type,[...path,name],val);
|
||||
} else if(action==='edit'){
|
||||
const old=path[path.length-1];
|
||||
if(name!==old){
|
||||
this.deleteByPathSilently(type,path);
|
||||
if(path.length===1) {
|
||||
const toSave=(typeof val==='object'&&val!==null)?JSON.stringify(val):val;
|
||||
this.vt(type).setter(name,toSave);
|
||||
} else {
|
||||
this.setValueByPath(type,[...path.slice(0,-1),name],val);
|
||||
}
|
||||
} else {
|
||||
this.setValueByPath(type,path,val);
|
||||
}
|
||||
} else {
|
||||
const toSave=(typeof val==='object'&&val!==null)?JSON.stringify(val):val;
|
||||
this.vt(type).setter(name,toSave);
|
||||
}
|
||||
});
|
||||
this.hideInlineForm(); toastr.success('变量已保存');
|
||||
}catch(e){ toastr.error('JSON格式错误: '+e.message); }
|
||||
finally{ this.savingInProgress=false; }
|
||||
}
|
||||
hideInlineForm(){ if(this.state.currentInlineForm){ this.state.currentInlineForm.removeClass('active'); setTimeout(()=>{ this.state.currentInlineForm?.remove(); this.state.currentInlineForm=null; },200);} this.state.formState={}; }
|
||||
|
||||
showAddForm(t){
|
||||
this.hideInlineForm();
|
||||
const f=$(`#${t}-vm-add-form`).addClass('active'), ta=$(`#${t}-vm-value`);
|
||||
$(`#${t}-vm-name`).val('').attr('placeholder','变量名称').focus();
|
||||
ta.val('').attr('placeholder','变量值 (支持JSON格式)');
|
||||
if(!ta.data('auto-resize-bound')){ ta.on('input',()=>this.autoResizeTextarea(ta)); ta.data('auto-resize-bound',true); }
|
||||
}
|
||||
hideAddForm(t){ $(`#${t}-vm-add-form`).removeClass('active'); $(`#${t}-vm-name, #${t}-vm-value`).val(''); this.state.formState={}; }
|
||||
|
||||
saveAddVariable(t){
|
||||
if(this.savingInProgress) return; this.savingInProgress=true;
|
||||
try{
|
||||
const rawN=$(`#${t}-vm-name`).val();
|
||||
const rawV=$(`#${t}-vm-value`).val();
|
||||
const n= typeof rawN==='string' ? rawN.trim() : String(rawN ?? '').trim();
|
||||
const v= typeof rawV==='string' ? rawV.trim() : String(rawV ?? '').trim();
|
||||
if(!n) return toastr.error('请输入变量名称');
|
||||
const val=this.processValue(v);
|
||||
this.withPreservedExpansion(t,()=> {
|
||||
const toSave=(typeof val==='object'&&val!==null)?JSON.stringify(val):val;
|
||||
this.vt(t).setter(n,toSave);
|
||||
});
|
||||
this.hideAddForm(t); toastr.success('变量已保存');
|
||||
}catch(e){ toastr.error('JSON格式错误: '+e.message); }
|
||||
finally{ this.savingInProgress=false; }
|
||||
}
|
||||
|
||||
getValueByPath(t,p){ if(p.length===1) return this.vt(t).getter(p[0]); let v=this.parseValue(this.vt(t).getter(p[0])); p.slice(1).forEach(k=> v=v?.[k]); return v; }
|
||||
|
||||
setValueByPath(t,p,v){
|
||||
if(p.length===1){
|
||||
const toSave = (typeof v==='object' && v!==null) ? JSON.stringify(v) : v;
|
||||
this.vt(t).setter(p[0], toSave);
|
||||
return;
|
||||
}
|
||||
let root=this.parseValue(this.vt(t).getter(p[0])); if(typeof root!=='object'||root===null) root={};
|
||||
let cur=root; p.slice(1,-1).forEach(k=>{ if(typeof cur[k]!=='object'||cur[k]===null) cur[k]={}; cur=cur[k]; });
|
||||
cur[p[p.length-1]]=v; this.vt(t).setter(p[0], JSON.stringify(root));
|
||||
}
|
||||
|
||||
deleteByPathSilently(t,p){
|
||||
if(p.length===1){ delete this.store(t)[p[0]]; return; }
|
||||
let root=this.parseValue(this.vt(t).getter(p[0])); if(typeof root!=='object'||root===null) return;
|
||||
let cur=root; p.slice(1,-1).forEach(k=>{ if(typeof cur[k]!=='object'||cur[k]===null) cur[k]={}; cur=cur[k]; });
|
||||
delete cur[p[p.length-1]]; this.vt(t).setter(p[0], JSON.stringify(root));
|
||||
}
|
||||
|
||||
formatPath(t,path){
|
||||
if(!Array.isArray(path)||!path.length) return '';
|
||||
let out=String(path[0]), cur=this.parseValue(this.vt(t).getter(path[0]));
|
||||
for(let i=1;i<path.length;i++){
|
||||
const k=String(path[i]), isNum=/^\d+$/.test(k);
|
||||
if(Array.isArray(cur) && isNum){ out+=`[${Number(k)}]`; cur=cur?.[Number(k)]; }
|
||||
else { out+=`.`+k; cur=cur?.[k]; }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
getVariableType(it){ return it.data('type') || (it.closest('.vm-section').attr('id').includes('character')?'character':'global'); }
|
||||
getItemPath(i){ const p=[]; let c=i; while(c.length&&c.hasClass('vm-item')){ const k=c.data('key'); if(k!==undefined) p.unshift(String(k)); if(!c.attr('data-level')) break; c=c.parent().closest('.vm-item'); } return p; }
|
||||
|
||||
parseValue(v){ try{ return typeof v==='string'? JSON.parse(v) : v; }catch{ return v; } }
|
||||
processValue(v){ if(typeof v!=='string') return v; const s=v.trim(); return (s.startsWith('{')||s.startsWith('['))? JSON.parse(s) : v; }
|
||||
|
||||
formatTopLevelValue(v){ const p=this.parseValue(v); if(typeof p==='object'&&p!==null){ const c=Array.isArray(p)? p.length : Object.keys(p).length; return `<span class="vm-object-count">[${c} items]</span>`; } return this.formatValue(p); }
|
||||
formatValue(v){ if(v==null) return `<span class="vm-null-value">${v}</span>`; const e=this.escape(String(v)); return `<span class="vm-formatted-value">${e.length>50? e.substring(0,50)+'...' : e}</span>`; }
|
||||
escape(t){ const d=document.createElement('div'); d.textContent=t; return d.innerHTML; }
|
||||
autoResizeTextarea(ta){ if(!ta?.length) return; const el=ta[0]; el.style.height='auto'; const sh=el.scrollHeight, max=Math.min(300,window.innerHeight*0.4), fh=Math.max(60,Math.min(max,sh+4)); el.style.height=fh+'px'; el.style.overflowY=sh>max-4?'auto':'hidden'; }
|
||||
searchVariables(t,q){ const l=q.toLowerCase().trim(); $(`#${t}-variables-list .vm-item`).each(function(){ $(this).toggle(!l || $(this).text().toLowerCase().includes(l)); }); }
|
||||
collapseAll(t){ const items=$(`#${t}-variables-list .vm-item`), icon=$(`#${t}-variables-section [data-act="collapse"] i`); const any=items.filter('.expanded').length>0; items.toggleClass('expanded',!any); icon.toggleClass('fa-chevron-up',!any).toggleClass('fa-chevron-down',any); }
|
||||
|
||||
clearAllVariables(t){
|
||||
if(!confirm(`确定要清除所有${t==='character'?'角色':'全局'}变量吗?`)) return;
|
||||
this.withPreservedExpansion(t,()=>{ const s=this.store(t); Object.keys(s).forEach(k=> delete s[k]); });
|
||||
toastr.success('变量已清除');
|
||||
}
|
||||
|
||||
async importVariables(t){
|
||||
const inp=document.createElement('input'); inp.type='file'; inp.accept='.json';
|
||||
inp.onchange=async(e)=>{
|
||||
try{
|
||||
const tgt=e.target;
|
||||
const file = (tgt && 'files' in tgt && tgt.files && tgt.files[0]) ? tgt.files[0] : null;
|
||||
if(!file) throw new Error('未选择文件');
|
||||
const txt=await file.text(), v=JSON.parse(txt);
|
||||
this.withPreservedExpansion(t,()=> {
|
||||
Object.entries(v).forEach(([k,val])=> {
|
||||
const toSave=(typeof val==='object'&&val!==null)?JSON.stringify(val):val;
|
||||
this.vt(t).setter(k,toSave);
|
||||
});
|
||||
});
|
||||
toastr.success(`成功导入 ${Object.keys(v).length} 个变量`);
|
||||
}catch{ toastr.error('文件格式错误'); }
|
||||
};
|
||||
inp.click();
|
||||
}
|
||||
|
||||
exportVariables(t){
|
||||
const v=this.store(t), b=new Blob([JSON.stringify(v,null,2)],{type:'application/json'}), a=document.createElement('a');
|
||||
a.href=URL.createObjectURL(b); a.download=`${t}-variables-${new Date().toISOString().split('T')[0]}.json`; a.click();
|
||||
toastr.success('变量已导出');
|
||||
}
|
||||
|
||||
saveExpandedStates(t){ const s=new Set(); $(`#${t}-variables-list .vm-item.expanded`).each(function(){ const k=$(this).data('key'); if(k!==undefined) s.add(String(k)); }); return s; }
|
||||
saveAllExpandedStates(){ return { character:this.saveExpandedStates('character'), global:this.saveExpandedStates('global') }; }
|
||||
restoreExpandedStates(t,s){ if(!s?.size) return; setTimeout(()=>{ $(`#${t}-variables-list .vm-item`).each(function(){ const k=$(this).data('key'); if(k!==undefined && s.has(String(k))) $(this).addClass('expanded'); }); },50); }
|
||||
restoreAllExpandedStates(st){ Object.entries(st).forEach(([t,s])=> this.restoreExpandedStates(t,s)); }
|
||||
|
||||
toggleEnabled(en){
|
||||
const s=this.getSettings(); s.enabled=this.state.isEnabled=en; saveSettingsDebounced(); this.syncCheckbox();
|
||||
en ? (this.enable(),this.open()) : this.disable();
|
||||
}
|
||||
|
||||
createPerMessageBtn(messageId){
|
||||
const btn=document.createElement('div');
|
||||
btn.className='mes_btn mes_variables_panel';
|
||||
btn.title='变量面板';
|
||||
btn.dataset.mid=messageId;
|
||||
btn.innerHTML='<i class="fa-solid fa-database"></i>';
|
||||
btn.addEventListener('click',(e)=>{ e.preventDefault(); e.stopPropagation(); this.open(); });
|
||||
return btn;
|
||||
}
|
||||
|
||||
addButtonToMessage(messageId){
|
||||
const msg=$(`#chat .mes[mesid="${messageId}"]`);
|
||||
if(!msg.length || msg.find('.mes_btn.mes_variables_panel').length) return;
|
||||
const btn=this.createPerMessageBtn(messageId);
|
||||
const appendToFlex=(m)=>{ const flex=m.find('.flex-container.flex1.alignitemscenter'); if(flex.length) flex.append(btn); };
|
||||
if(typeof window['registerButtonToSubContainer']==='function'){
|
||||
const ok=window['registerButtonToSubContainer'](messageId,btn);
|
||||
if(!ok) appendToFlex(msg);
|
||||
} else appendToFlex(msg);
|
||||
}
|
||||
|
||||
addButtonsToAllMessages(){ $('#chat .mes').each((_,el)=>{ const mid=el.getAttribute('mesid'); if(mid) this.addButtonToMessage(mid); }); }
|
||||
removeAllMessageButtons(){ $('#chat .mes .mes_btn.mes_variables_panel').remove(); }
|
||||
|
||||
installMessageButtons(){
|
||||
const delayedAdd=(id)=> setTimeout(()=>{ if(id!=null) this.addButtonToMessage(id); },120);
|
||||
const delayedScan=()=> setTimeout(()=> this.addButtonsToAllMessages(),150);
|
||||
this.removeMessageButtonsListeners();
|
||||
const idFrom=(d)=> typeof d==='object' ? (d.messageId||d.id) : d;
|
||||
|
||||
if (!this.msgEvents) this.msgEvents = createModuleEvents('variablesPanel:messages');
|
||||
|
||||
this.msgEvents.onMany([
|
||||
event_types.USER_MESSAGE_RENDERED,
|
||||
event_types.CHARACTER_MESSAGE_RENDERED,
|
||||
event_types.MESSAGE_RECEIVED,
|
||||
event_types.MESSAGE_UPDATED,
|
||||
event_types.MESSAGE_SWIPED,
|
||||
event_types.MESSAGE_EDITED
|
||||
].filter(Boolean), (d) => delayedAdd(idFrom(d)));
|
||||
|
||||
this.msgEvents.on(event_types.MESSAGE_SENT, debounce(() => delayedScan(), 300));
|
||||
this.msgEvents.on(event_types.CHAT_CHANGED, () => delayedScan());
|
||||
|
||||
this.addButtonsToAllMessages();
|
||||
}
|
||||
|
||||
removeMessageButtonsListeners(){
|
||||
if (this.msgEvents) {
|
||||
this.msgEvents.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
removeMessageButtons(){ this.removeMessageButtonsListeners(); this.removeAllMessageButtons(); }
|
||||
|
||||
normalizeStore(t){
|
||||
const s=this.store(t); let changed=0;
|
||||
for(const[k,v] of Object.entries(s)){
|
||||
if(typeof v==='object' && v!==null){
|
||||
try{ s[k]=JSON.stringify(v); changed++; }catch{}
|
||||
}
|
||||
}
|
||||
if(changed) this.vt(t).save?.();
|
||||
}
|
||||
}
|
||||
|
||||
let variablesPanelInstance=null;
|
||||
|
||||
export async function initVariablesPanel(){
|
||||
try{
|
||||
extension_settings.variables ??= { global:{} };
|
||||
if(variablesPanelInstance) variablesPanelInstance.cleanup();
|
||||
variablesPanelInstance=new VariablesPanel();
|
||||
await variablesPanelInstance.init();
|
||||
return variablesPanelInstance;
|
||||
}catch(e){
|
||||
console.error(`[${CONFIG.extensionName}] 加载失败:`,e);
|
||||
toastr?.error?.('Variables Panel加载失败');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export function getVariablesPanelInstance(){ return variablesPanelInstance; }
|
||||
export function cleanupVariablesPanel(){ if(variablesPanelInstance){ variablesPanelInstance.removeMessageButtons(); variablesPanelInstance.cleanup(); variablesPanelInstance=null; } }
|
||||
2182
modules/wallhaven-background.js
Normal file
2182
modules/wallhaven-background.js
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user