This commit is contained in:
RT15548
2025-12-19 02:19:10 +08:00
commit 593fce3c8c
45 changed files with 34004 additions and 0 deletions

257
modules/button-collapse.js Normal file
View 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
View 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);
}
}

View 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
// ═══════════════════════════════════════════════════════════════════════
// 日志渲染
// ═══════════════════════════════════════════════════════════════════════
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>

View 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;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

784
modules/iframe-renderer.js Normal file
View 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
View 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&ZeroWidthSpace;/&ZeroWidthSpace;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}&ZeroWidthSpace;/&ZeroWidthSpace;${message.swipes.length}`);
return;
}
} catch {}
}
$swipesCounter.html('1&ZeroWidthSpace;/&ZeroWidthSpace;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
View 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;">&lt;$1&gt;</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 };

File diff suppressed because it is too large Load Diff

View 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;
}

View 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>

View 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>

File diff suppressed because it is too large Load Diff

104
modules/script-assistant.js Normal file
View 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 };

View 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. 上一地点结算:给出 deviationcot_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;';

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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="例如:&lt;Info&gt;……&lt;/Info&gt;"></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 };

File diff suppressed because it is too large Load Diff

View 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,'&quot;'), 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; } }

File diff suppressed because it is too large Load Diff