1314 lines
59 KiB
JavaScript
1314 lines
59 KiB
JavaScript
|
|
import { extension_settings, getContext, writeExtensionField } from "../../../../../extensions.js";
|
|||
|
|
import { saveSettingsDebounced, characters, this_chid, updateMessageBlock } from "../../../../../../script.js";
|
|||
|
|
import { callGenericPopup, POPUP_TYPE } from "../../../../../popup.js";
|
|||
|
|
import { selected_group } from "../../../../../group-chats.js";
|
|||
|
|
import { findChar, download } from "../../../../../utils.js";
|
|||
|
|
import { executeSlashCommand } from "../../core/slash-command.js";
|
|||
|
|
import { EXT_ID, extensionFolderPath } from "../../core/constants.js";
|
|||
|
|
import { createModuleEvents, event_types } from "../../core/event-manager.js";
|
|||
|
|
import { xbLog, CacheRegistry } from "../../core/debug-core.js";
|
|||
|
|
import { getIframeBaseScript, getWrapperScript, getTemplateExtrasScript } from "../../core/wrapper-inline.js";
|
|||
|
|
import { postToIframe, getIframeTargetOrigin } from "../../core/iframe-messaging.js";
|
|||
|
|
|
|||
|
|
const TEMPLATE_MODULE_NAME = "xiaobaix-template";
|
|||
|
|
const events = createModuleEvents('templateEditor');
|
|||
|
|
|
|||
|
|
async function STscript(command) {
|
|||
|
|
if (!command) return { error: "命令为空" };
|
|||
|
|
if (!command.startsWith('/')) command = '/' + command;
|
|||
|
|
return await executeSlashCommand(command);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const DEFAULT_CHAR_SETTINGS = {
|
|||
|
|
enabled: false,
|
|||
|
|
template: "",
|
|||
|
|
customRegex: "\\[([^\\]]+)\\]([\\s\\S]*?)\\[\\/\\1\\]",
|
|||
|
|
disableParsers: false,
|
|||
|
|
limitToRecentMessages: false,
|
|||
|
|
recentMessageCount: 5,
|
|||
|
|
skipFirstMessage: false
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const state = {
|
|||
|
|
isStreamingCheckActive: false,
|
|||
|
|
messageVariables: new Map(),
|
|||
|
|
caches: { template: new Map(), regex: new Map(), dom: new Map() },
|
|||
|
|
variableHistory: new Map(),
|
|||
|
|
observers: { message: null, streaming: null },
|
|||
|
|
pendingUpdates: new Map(),
|
|||
|
|
isGenerating: false,
|
|||
|
|
clear() {
|
|||
|
|
this.messageVariables.clear();
|
|||
|
|
this.caches.template.clear();
|
|||
|
|
this.caches.dom.clear();
|
|||
|
|
},
|
|||
|
|
getElement(selector, parent = document) {
|
|||
|
|
const key = `${parent === document ? 'doc' : 'el'}-${selector}`;
|
|||
|
|
const cached = this.caches.dom.get(key);
|
|||
|
|
if (cached?.isConnected) return cached;
|
|||
|
|
const element = parent.querySelector(selector);
|
|||
|
|
if (element) this.caches.dom.set(key, element);
|
|||
|
|
return element;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const utils = {
|
|||
|
|
getCharAvatar: msg => msg?.original_avatar ||
|
|||
|
|
(msg?.name && findChar({ name: msg.name, allowAvatar: true })?.avatar) ||
|
|||
|
|
(!selected_group && this_chid !== undefined && Number(this_chid) >= 0 && characters[Number(this_chid)]?.avatar) || null,
|
|||
|
|
isEnabled: () => (window['isXiaobaixEnabled'] ?? true) && TemplateSettings.get().enabled,
|
|||
|
|
isCustomTemplate: content => ['<html', '<!DOCTYPE', '<script'].some(tag => content?.includes(tag)),
|
|||
|
|
escapeHtml: html => html.replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''')
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|||
|
|
// 设置管理 - 核心改动:分离存储
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
class TemplateSettings {
|
|||
|
|
static get() {
|
|||
|
|
const settings = extension_settings[EXT_ID] = extension_settings[EXT_ID] || {};
|
|||
|
|
settings.templateEditor = settings.templateEditor || { enabled: false, characterBindings: {} };
|
|||
|
|
return settings.templateEditor;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 获取当前角色设置(优先角色卡,fallback 到 settings)
|
|||
|
|
static getCurrentChar() {
|
|||
|
|
if (this_chid === undefined || !characters[this_chid]) return DEFAULT_CHAR_SETTINGS;
|
|||
|
|
|
|||
|
|
const character = characters[this_chid];
|
|||
|
|
const avatar = character.avatar;
|
|||
|
|
|
|||
|
|
// 1. 优先从角色卡读取
|
|||
|
|
const embedded = character.data?.extensions?.[TEMPLATE_MODULE_NAME];
|
|||
|
|
if (embedded?.template) {
|
|||
|
|
return embedded;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 2. fallback 到旧的 characterBindings(兼容迁移前的数据)
|
|||
|
|
const binding = this.get().characterBindings[avatar];
|
|||
|
|
if (binding?.template) {
|
|||
|
|
return { ...DEFAULT_CHAR_SETTINGS, ...binding };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return DEFAULT_CHAR_SETTINGS;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 保存当前角色设置
|
|||
|
|
static async saveCurrentChar(charSettings) {
|
|||
|
|
if (this_chid === undefined || !characters[this_chid]) return;
|
|||
|
|
|
|||
|
|
const avatar = characters[this_chid].avatar;
|
|||
|
|
state.caches.template.clear();
|
|||
|
|
|
|||
|
|
// 1. 完整内容只存角色卡
|
|||
|
|
await writeExtensionField(Number(this_chid), TEMPLATE_MODULE_NAME, charSettings);
|
|||
|
|
|
|||
|
|
// 2. extension_settings 只存标记,不存内容
|
|||
|
|
const globalSettings = this.get();
|
|||
|
|
globalSettings.characterBindings[avatar] = {
|
|||
|
|
enabled: !!charSettings.enabled,
|
|||
|
|
// 不存 template, customRegex 等大字段
|
|||
|
|
};
|
|||
|
|
saveSettingsDebounced();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 获取角色模板(渲染时调用)
|
|||
|
|
static getCharTemplate(avatar) {
|
|||
|
|
if (!avatar || !utils.isEnabled()) return null;
|
|||
|
|
|
|||
|
|
// 检查缓存
|
|||
|
|
if (state.caches.template.has(avatar)) {
|
|||
|
|
return state.caches.template.get(avatar);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let result = null;
|
|||
|
|
|
|||
|
|
// 1. 优先从当前角色卡读取
|
|||
|
|
if (this_chid !== undefined && characters[this_chid]?.avatar === avatar) {
|
|||
|
|
const embedded = characters[this_chid].data?.extensions?.[TEMPLATE_MODULE_NAME];
|
|||
|
|
if (embedded?.enabled && embedded?.template) {
|
|||
|
|
result = embedded;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 2. 如果当前角色卡没有,尝试从 characterBindings 读取(兼容旧数据)
|
|||
|
|
if (!result) {
|
|||
|
|
const binding = this.get().characterBindings[avatar];
|
|||
|
|
if (binding?.enabled && binding?.template) {
|
|||
|
|
result = binding;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (result) {
|
|||
|
|
state.caches.template.set(avatar, result);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return result;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 数据迁移:清理 characterBindings 中的大数据
|
|||
|
|
static migrateAndCleanup() {
|
|||
|
|
const settings = this.get();
|
|||
|
|
const bindings = settings.characterBindings || {};
|
|||
|
|
let cleaned = false;
|
|||
|
|
|
|||
|
|
for (const [avatar, data] of Object.entries(bindings)) {
|
|||
|
|
if (data && typeof data === 'object') {
|
|||
|
|
// 如果存在大字段,只保留标记
|
|||
|
|
if (data.template || data.customRegex) {
|
|||
|
|
settings.characterBindings[avatar] = {
|
|||
|
|
enabled: !!data.enabled
|
|||
|
|
};
|
|||
|
|
cleaned = true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (cleaned) {
|
|||
|
|
saveSettingsDebounced();
|
|||
|
|
console.log('[TemplateEditor] 已清理 characterBindings 中的冗余数据');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return cleaned;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|||
|
|
// 模板处理器(无改动)
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
class TemplateProcessor {
|
|||
|
|
static getRegex(pattern = DEFAULT_CHAR_SETTINGS.customRegex) {
|
|||
|
|
if (!pattern) pattern = DEFAULT_CHAR_SETTINGS.customRegex;
|
|||
|
|
if (state.caches.regex.has(pattern)) return state.caches.regex.get(pattern);
|
|||
|
|
let regex = null;
|
|||
|
|
try {
|
|||
|
|
const p = String(pattern);
|
|||
|
|
if (p.startsWith('/') && p.lastIndexOf('/') > 0) {
|
|||
|
|
const last = p.lastIndexOf('/');
|
|||
|
|
const body = p.slice(1, last);
|
|||
|
|
let flags = p.slice(last + 1);
|
|||
|
|
if (!flags) flags = 'g';
|
|||
|
|
if (!flags.includes('g')) flags += 'g';
|
|||
|
|
regex = new RegExp(body, flags);
|
|||
|
|
} else {
|
|||
|
|
regex = new RegExp(p, 'g');
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
try {
|
|||
|
|
regex = new RegExp(/\[([^\]]+)\]([\s\S]*?)\[\/\1\]/.source, 'g');
|
|||
|
|
} catch {
|
|||
|
|
regex = /\[([^\]]+)\]([\s\S]*?)\[\/\1\]/g;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
state.caches.regex.set(pattern, regex);
|
|||
|
|
return regex;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static extractVars(content, customRegex = null) {
|
|||
|
|
if (!content || typeof content !== 'string') return {};
|
|||
|
|
const extractors = [
|
|||
|
|
() => this.extractRegex(content, customRegex),
|
|||
|
|
() => this.extractFromCodeBlocks(content, 'json', this.parseJsonDirect),
|
|||
|
|
() => this.extractJsonFromIncompleteXml(content),
|
|||
|
|
() => this.isJsonFormat(content) ? this.parseJsonDirect(content) : null,
|
|||
|
|
() => this.extractFromCodeBlocks(content, 'ya?ml', this.parseYamlDirect),
|
|||
|
|
() => this.isYamlFormat(content) ? this.parseYamlDirect(content) : null,
|
|||
|
|
() => this.extractJsonFromXmlWrapper(content)
|
|||
|
|
];
|
|||
|
|
for (const extractor of extractors) {
|
|||
|
|
const vars = extractor();
|
|||
|
|
if (vars && Object.keys(vars).length) return vars;
|
|||
|
|
}
|
|||
|
|
return {};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static extractJsonFromIncompleteXml(content) {
|
|||
|
|
const vars = {};
|
|||
|
|
const incompleteXmlPattern = /<[^>]+>([^<]*(?:\{[\s\S]*|\w+\s*:[\s\S]*))/g;
|
|||
|
|
let match;
|
|||
|
|
while ((match = incompleteXmlPattern.exec(content))) {
|
|||
|
|
const innerContent = match[1]?.trim();
|
|||
|
|
if (!innerContent) continue;
|
|||
|
|
if (innerContent.startsWith('{')) {
|
|||
|
|
try {
|
|||
|
|
const jsonVars = this.parseJsonDirect(innerContent);
|
|||
|
|
if (jsonVars && Object.keys(jsonVars).length) {
|
|||
|
|
Object.assign(vars, jsonVars);
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
} catch {}
|
|||
|
|
}
|
|||
|
|
if (this.isYamlFormat(innerContent)) {
|
|||
|
|
try {
|
|||
|
|
const yamlVars = this.parseYamlDirect(innerContent);
|
|||
|
|
if (yamlVars && Object.keys(yamlVars).length) {
|
|||
|
|
Object.assign(vars, yamlVars);
|
|||
|
|
}
|
|||
|
|
} catch {}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return Object.keys(vars).length ? vars : null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static extractJsonFromXmlWrapper(content) {
|
|||
|
|
const vars = {};
|
|||
|
|
const xmlPattern = /<[^>]+>([\s\S]*?)<\/[^>]+>/g;
|
|||
|
|
let match;
|
|||
|
|
while ((match = xmlPattern.exec(content))) {
|
|||
|
|
const innerContent = match[1]?.trim();
|
|||
|
|
if (!innerContent) continue;
|
|||
|
|
if (innerContent.startsWith('{') && innerContent.includes('}')) {
|
|||
|
|
try {
|
|||
|
|
const jsonVars = this.parseJsonDirect(innerContent);
|
|||
|
|
if (jsonVars && Object.keys(jsonVars).length) {
|
|||
|
|
Object.assign(vars, jsonVars);
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
} catch {}
|
|||
|
|
}
|
|||
|
|
if (this.isYamlFormat(innerContent)) {
|
|||
|
|
try {
|
|||
|
|
const yamlVars = this.parseYamlDirect(innerContent);
|
|||
|
|
if (yamlVars && Object.keys(yamlVars).length) {
|
|||
|
|
Object.assign(vars, yamlVars);
|
|||
|
|
}
|
|||
|
|
} catch {}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return Object.keys(vars).length ? vars : null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static extractRegex(content, customRegex) {
|
|||
|
|
const vars = {};
|
|||
|
|
const regex = this.getRegex(customRegex);
|
|||
|
|
regex.lastIndex = 0;
|
|||
|
|
let match;
|
|||
|
|
while ((match = regex.exec(content))) {
|
|||
|
|
vars[match[1].trim()] = match[2].trim();
|
|||
|
|
}
|
|||
|
|
return Object.keys(vars).length ? vars : null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static extractFromCodeBlocks(content, language, parser) {
|
|||
|
|
const vars = {};
|
|||
|
|
const regex = new RegExp(`\`\`\`${language}\\s*\\n([\\s\\S]*?)(?:\\n\`\`\`|$)`, 'gi');
|
|||
|
|
let match;
|
|||
|
|
while ((match = regex.exec(content))) {
|
|||
|
|
try {
|
|||
|
|
const parsed = parser.call(this, match[1].trim());
|
|||
|
|
if (parsed) Object.assign(vars, parsed);
|
|||
|
|
} catch {}
|
|||
|
|
}
|
|||
|
|
return Object.keys(vars).length ? vars : null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static parseJsonDirect(jsonContent) {
|
|||
|
|
try {
|
|||
|
|
return JSON.parse(jsonContent.trim());
|
|||
|
|
} catch {
|
|||
|
|
return this.parsePartialJsonDirect(jsonContent.trim());
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static parsePartialJsonDirect(jsonContent) {
|
|||
|
|
const vars = {};
|
|||
|
|
if (!jsonContent.startsWith('{')) return vars;
|
|||
|
|
try {
|
|||
|
|
const parsed = JSON.parse(jsonContent);
|
|||
|
|
return parsed;
|
|||
|
|
} catch {}
|
|||
|
|
const lines = jsonContent.split('\n');
|
|||
|
|
let currentKey = null;
|
|||
|
|
let objectContent = '';
|
|||
|
|
let braceLevel = 0;
|
|||
|
|
let bracketLevel = 0;
|
|||
|
|
let inObject = false;
|
|||
|
|
let inArray = false;
|
|||
|
|
for (const line of lines) {
|
|||
|
|
const trimmed = line.trim();
|
|||
|
|
if (!trimmed || trimmed === '{' || trimmed === '}') continue;
|
|||
|
|
const stringMatch = trimmed.match(/^"([^"]+)"\s*:\s*"([^"]*)"[,]?$/);
|
|||
|
|
if (stringMatch && !inObject && !inArray) {
|
|||
|
|
vars[stringMatch[1]] = stringMatch[2];
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
const numMatch = trimmed.match(/^"([^"]+)"\s*:\s*(\d+)[,]?$/);
|
|||
|
|
if (numMatch && !inObject && !inArray) {
|
|||
|
|
vars[numMatch[1]] = parseInt(numMatch[2]);
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
const arrayStartMatch = trimmed.match(/^"([^"]+)"\s*:\s*\[(.*)$/);
|
|||
|
|
if (arrayStartMatch && !inObject && !inArray) {
|
|||
|
|
currentKey = arrayStartMatch[1];
|
|||
|
|
objectContent = '[' + arrayStartMatch[2];
|
|||
|
|
inArray = true;
|
|||
|
|
bracketLevel = 1;
|
|||
|
|
const openBrackets = (arrayStartMatch[2].match(/\[/g) || []).length;
|
|||
|
|
const closeBrackets = (arrayStartMatch[2].match(/\]/g) || []).length;
|
|||
|
|
bracketLevel += openBrackets - closeBrackets;
|
|||
|
|
if (bracketLevel === 0) {
|
|||
|
|
try { vars[currentKey] = JSON.parse(objectContent); } catch {}
|
|||
|
|
inArray = false;
|
|||
|
|
currentKey = null;
|
|||
|
|
objectContent = '';
|
|||
|
|
}
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
const objStartMatch = trimmed.match(/^"([^"]+)"\s*:\s*\{(.*)$/);
|
|||
|
|
if (objStartMatch && !inObject && !inArray) {
|
|||
|
|
currentKey = objStartMatch[1];
|
|||
|
|
objectContent = '{' + objStartMatch[2];
|
|||
|
|
inObject = true;
|
|||
|
|
braceLevel = 1;
|
|||
|
|
const openBraces = (objStartMatch[2].match(/\{/g) || []).length;
|
|||
|
|
const closeBraces = (objStartMatch[2].match(/\}/g) || []).length;
|
|||
|
|
braceLevel += openBraces - closeBraces;
|
|||
|
|
if (braceLevel === 0) {
|
|||
|
|
try { vars[currentKey] = JSON.parse(objectContent); } catch {}
|
|||
|
|
inObject = false;
|
|||
|
|
currentKey = null;
|
|||
|
|
objectContent = '';
|
|||
|
|
}
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
if (inArray) {
|
|||
|
|
objectContent += '\n' + line;
|
|||
|
|
const openBrackets = (trimmed.match(/\[/g) || []).length;
|
|||
|
|
const closeBrackets = (trimmed.match(/\]/g) || []).length;
|
|||
|
|
bracketLevel += openBrackets - closeBrackets;
|
|||
|
|
if (bracketLevel <= 0) {
|
|||
|
|
try { vars[currentKey] = JSON.parse(objectContent); } catch {
|
|||
|
|
const cleaned = objectContent.replace(/,\s*$/, '');
|
|||
|
|
try { vars[currentKey] = JSON.parse(cleaned); } catch {
|
|||
|
|
const attempts = [cleaned + '"]', cleaned + ']'];
|
|||
|
|
for (const attempt of attempts) {
|
|||
|
|
try { vars[currentKey] = JSON.parse(attempt); break; } catch {}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
inArray = false;
|
|||
|
|
currentKey = null;
|
|||
|
|
objectContent = '';
|
|||
|
|
bracketLevel = 0;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (inObject) {
|
|||
|
|
objectContent += '\n' + line;
|
|||
|
|
const openBraces = (trimmed.match(/\{/g) || []).length;
|
|||
|
|
const closeBraces = (trimmed.match(/\}/g) || []).length;
|
|||
|
|
braceLevel += openBraces - closeBraces;
|
|||
|
|
if (braceLevel <= 0) {
|
|||
|
|
try { vars[currentKey] = JSON.parse(objectContent); } catch {
|
|||
|
|
const cleaned = objectContent.replace(/,\s*$/, '');
|
|||
|
|
try { vars[currentKey] = JSON.parse(cleaned); } catch { vars[currentKey] = objectContent; }
|
|||
|
|
}
|
|||
|
|
inObject = false;
|
|||
|
|
currentKey = null;
|
|||
|
|
objectContent = '';
|
|||
|
|
braceLevel = 0;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (inArray && currentKey && objectContent) {
|
|||
|
|
try {
|
|||
|
|
const attempts = [objectContent + ']', objectContent.replace(/,\s*$/, '') + ']', objectContent + '"]'];
|
|||
|
|
for (const attempt of attempts) {
|
|||
|
|
try { vars[currentKey] = JSON.parse(attempt); break; } catch {}
|
|||
|
|
}
|
|||
|
|
} catch {}
|
|||
|
|
}
|
|||
|
|
if (inObject && currentKey && objectContent) {
|
|||
|
|
try {
|
|||
|
|
const attempts = [objectContent + '}', objectContent.replace(/,\s*$/, '') + '}'];
|
|||
|
|
for (const attempt of attempts) {
|
|||
|
|
try { vars[currentKey] = JSON.parse(attempt); break; } catch {}
|
|||
|
|
}
|
|||
|
|
} catch {}
|
|||
|
|
}
|
|||
|
|
return vars;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static parseYamlDirect(yamlContent) {
|
|||
|
|
const vars = {};
|
|||
|
|
const lines = yamlContent.split('\n');
|
|||
|
|
let i = 0;
|
|||
|
|
while (i < lines.length) {
|
|||
|
|
const line = lines[i];
|
|||
|
|
const trimmed = line.trim();
|
|||
|
|
if (!trimmed || trimmed.startsWith('#')) { i++; continue; }
|
|||
|
|
const colonIndex = trimmed.indexOf(':');
|
|||
|
|
if (colonIndex <= 0) { i++; continue; }
|
|||
|
|
const key = trimmed.substring(0, colonIndex).trim();
|
|||
|
|
const afterColon = trimmed.substring(colonIndex + 1).trim();
|
|||
|
|
const currentIndent = line.length - line.trimStart().length;
|
|||
|
|
if (afterColon === '|' || afterColon === '>') {
|
|||
|
|
const result = this.parseMultilineString(lines, i, currentIndent, afterColon === '|');
|
|||
|
|
vars[key] = result.value;
|
|||
|
|
i = result.nextIndex;
|
|||
|
|
} else if (afterColon === '' || afterColon === '{}') {
|
|||
|
|
const result = this.parseNestedObject(lines, i, currentIndent);
|
|||
|
|
if (result.value && Object.keys(result.value).length > 0) vars[key] = result.value;
|
|||
|
|
else vars[key] = '';
|
|||
|
|
i = result.nextIndex;
|
|||
|
|
} else if (afterColon.startsWith('-') || (afterColon === '' && i + 1 < lines.length && lines[i + 1].trim().startsWith('-'))) {
|
|||
|
|
const result = this.parseArray(lines, i, currentIndent, afterColon.startsWith('-') ? afterColon : '');
|
|||
|
|
vars[key] = result.value;
|
|||
|
|
i = result.nextIndex;
|
|||
|
|
} else {
|
|||
|
|
let value = afterColon.replace(/^["']|["']$/g, '');
|
|||
|
|
if (/^\d+$/.test(value)) vars[key] = parseInt(value);
|
|||
|
|
else if (/^\d+\.\d+$/.test(value)) vars[key] = parseFloat(value);
|
|||
|
|
else vars[key] = value;
|
|||
|
|
i++;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return Object.keys(vars).length ? vars : null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static parseMultilineString(lines, startIndex, baseIndent, preserveNewlines) {
|
|||
|
|
const contentLines = [];
|
|||
|
|
let i = startIndex + 1;
|
|||
|
|
while (i < lines.length) {
|
|||
|
|
const line = lines[i];
|
|||
|
|
const lineIndent = line.length - line.trimStart().length;
|
|||
|
|
if (line.trim() === '') { contentLines.push(''); i++; continue; }
|
|||
|
|
if (lineIndent <= baseIndent && line.trim() !== '') break;
|
|||
|
|
contentLines.push(line.substring(baseIndent + 2));
|
|||
|
|
i++;
|
|||
|
|
}
|
|||
|
|
const value = preserveNewlines ? contentLines.join('\n') : contentLines.join(' ').replace(/\s+/g, ' ');
|
|||
|
|
return { value: value.trim(), nextIndex: i };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static parseNestedObject(lines, startIndex, baseIndent) {
|
|||
|
|
const obj = {};
|
|||
|
|
let i = startIndex + 1;
|
|||
|
|
while (i < lines.length) {
|
|||
|
|
const line = lines[i];
|
|||
|
|
const trimmed = line.trim();
|
|||
|
|
const lineIndent = line.length - line.trimStart().length;
|
|||
|
|
if (!trimmed || trimmed.startsWith('#')) { i++; continue; }
|
|||
|
|
if (lineIndent <= baseIndent) break;
|
|||
|
|
const colonIndex = trimmed.indexOf(':');
|
|||
|
|
if (colonIndex > 0) {
|
|||
|
|
const key = trimmed.substring(0, colonIndex).trim();
|
|||
|
|
const value = trimmed.substring(colonIndex + 1).trim();
|
|||
|
|
if (value === '|' || value === '>') {
|
|||
|
|
const result = this.parseMultilineString(lines, i, lineIndent, value === '|');
|
|||
|
|
obj[key] = result.value;
|
|||
|
|
i = result.nextIndex;
|
|||
|
|
} else if (value === '' || value === '{}') {
|
|||
|
|
const result = this.parseNestedObject(lines, i, lineIndent);
|
|||
|
|
obj[key] = result.value;
|
|||
|
|
i = result.nextIndex;
|
|||
|
|
} else if (value.startsWith('-') || (value === '' && i + 1 < lines.length && lines[i + 1].trim().startsWith('-'))) {
|
|||
|
|
const result = this.parseArray(lines, i, lineIndent, value.startsWith('-') ? value : '');
|
|||
|
|
obj[key] = result.value;
|
|||
|
|
i = result.nextIndex;
|
|||
|
|
} else {
|
|||
|
|
let cleanValue = value.replace(/^["']|["']$/g, '');
|
|||
|
|
if (/^\d+$/.test(cleanValue)) obj[key] = parseInt(cleanValue);
|
|||
|
|
else if (/^\d+\.\d+$/.test(cleanValue)) obj[key] = parseFloat(cleanValue);
|
|||
|
|
else obj[key] = cleanValue;
|
|||
|
|
i++;
|
|||
|
|
}
|
|||
|
|
} else i++;
|
|||
|
|
}
|
|||
|
|
return { value: obj, nextIndex: i };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static parseArray(lines, startIndex, baseIndent, firstItem) {
|
|||
|
|
const arr = [];
|
|||
|
|
let i = startIndex;
|
|||
|
|
if (firstItem.startsWith('-')) {
|
|||
|
|
const value = firstItem.substring(1).trim();
|
|||
|
|
if (value) {
|
|||
|
|
let cleanValue = value.replace(/^["']|["']$/g, '');
|
|||
|
|
if (/^\d+$/.test(cleanValue)) arr.push(parseInt(cleanValue));
|
|||
|
|
else if (/^\d+\.\d+$/.test(cleanValue)) arr.push(parseFloat(cleanValue));
|
|||
|
|
else arr.push(cleanValue);
|
|||
|
|
}
|
|||
|
|
i++;
|
|||
|
|
}
|
|||
|
|
while (i < lines.length) {
|
|||
|
|
const line = lines[i];
|
|||
|
|
const trimmed = line.trim();
|
|||
|
|
const lineIndent = line.length - line.trimStart().length;
|
|||
|
|
if (!trimmed || trimmed.startsWith('#')) { i++; continue; }
|
|||
|
|
if (lineIndent <= baseIndent && !trimmed.startsWith('-')) break;
|
|||
|
|
if (trimmed.startsWith('-')) {
|
|||
|
|
const value = trimmed.substring(1).trim();
|
|||
|
|
if (value) {
|
|||
|
|
let cleanValue = value.replace(/^["']|["']$/g, '');
|
|||
|
|
if (/^\d+$/.test(cleanValue)) arr.push(parseInt(cleanValue));
|
|||
|
|
else if (/^\d+\.\d+$/.test(cleanValue)) arr.push(parseFloat(cleanValue));
|
|||
|
|
else arr.push(cleanValue);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
i++;
|
|||
|
|
}
|
|||
|
|
return { value: arr, nextIndex: i };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static isYamlFormat(content) {
|
|||
|
|
const trimmed = content.trim();
|
|||
|
|
return !trimmed.startsWith('{') && !trimmed.startsWith('[') &&
|
|||
|
|
trimmed.split('\n').some(line => {
|
|||
|
|
const t = line.trim();
|
|||
|
|
if (!t || t.startsWith('#')) return false;
|
|||
|
|
const colonIndex = t.indexOf(':');
|
|||
|
|
return colonIndex > 0 && /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(t.substring(0, colonIndex).trim());
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static isJsonFormat(content) {
|
|||
|
|
const trimmed = content.trim();
|
|||
|
|
return (trimmed.startsWith('{') || trimmed.startsWith('['));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static replaceVars(tmpl, vars) {
|
|||
|
|
return tmpl?.replace(/\[\[([^\]]+)\]\]/g, (match, varName) => {
|
|||
|
|
const cleanVarName = varName.trim();
|
|||
|
|
let value = vars[cleanVarName];
|
|||
|
|
if (value === null || value === undefined) value = '';
|
|||
|
|
else if (Array.isArray(value)) value = value.join(', ');
|
|||
|
|
else if (typeof value === 'object') value = JSON.stringify(value);
|
|||
|
|
else value = String(value);
|
|||
|
|
return `<bdi data-xiaobaix-var="${cleanVarName}">${value}</bdi>`;
|
|||
|
|
}) || '';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static getTemplateVarNames(tmpl) {
|
|||
|
|
if (!tmpl || typeof tmpl !== 'string') return [];
|
|||
|
|
const names = new Set();
|
|||
|
|
const regex = /\[\[([^\]]+)\]\]/g;
|
|||
|
|
let match;
|
|||
|
|
while ((match = regex.exec(tmpl))) {
|
|||
|
|
const name = String(match[1] || '').trim();
|
|||
|
|
if (name) names.add(name);
|
|||
|
|
}
|
|||
|
|
return Array.from(names);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static buildVarsFromWholeText(tmpl, text) {
|
|||
|
|
const vars = {};
|
|||
|
|
const names = this.getTemplateVarNames(tmpl);
|
|||
|
|
for (const n of names) vars[n] = String(text ?? '');
|
|||
|
|
return vars;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static extractVarsWithOption(content, tmpl, settings) {
|
|||
|
|
if (!content || typeof content !== 'string') return {};
|
|||
|
|
if (settings && settings.disableParsers) return this.buildVarsFromWholeText(tmpl, content);
|
|||
|
|
const customRegex = settings ? settings.customRegex : null;
|
|||
|
|
return this.extractVars(content, customRegex);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|||
|
|
// iframe 客户端脚本
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
function buildWrappedHtml(content) {
|
|||
|
|
const origin = (typeof location !== 'undefined' && location.origin) ? location.origin : '';
|
|||
|
|
const baseTag = `<base href="${origin}/">`;
|
|||
|
|
const wrapperToggle = !!(extension_settings && extension_settings[EXT_ID] && extension_settings[EXT_ID].wrapperIframe);
|
|||
|
|
|
|||
|
|
// 内联脚本:wrapper + base + template extras
|
|||
|
|
const scripts = wrapperToggle
|
|||
|
|
? `<script>${getWrapperScript()}${getIframeBaseScript()}${getTemplateExtrasScript()}</script>`
|
|||
|
|
: `<script>${getIframeBaseScript()}${getTemplateExtrasScript()}</script>`;
|
|||
|
|
|
|||
|
|
const vhFix = `<style>html,body{height:auto!important;min-height:0!important;max-height:none!important}[style*="100vh"]{height:auto!important;min-height:600px!important}[style*="height:100%"]{height:auto!important;min-height:100%!important}</style>`;
|
|||
|
|
const reset = `<style>html,body{margin:0;padding:0;background:transparent}</style>`;
|
|||
|
|
|
|||
|
|
const headBits = `
|
|||
|
|
<meta charset="UTF-8">
|
|||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|||
|
|
${scripts}
|
|||
|
|
${baseTag}
|
|||
|
|
${vhFix}
|
|||
|
|
${reset}
|
|||
|
|
`;
|
|||
|
|
if (content.includes('<html') && content.includes('</html')) {
|
|||
|
|
if (content.includes('<head>')) return content.replace('<head>', `<head>${headBits}`);
|
|||
|
|
if (content.includes('</head>')) return content.replace('</head>', `${headBits}</head>`);
|
|||
|
|
return content.replace('<body', `<head>${headBits}</head><body`);
|
|||
|
|
}
|
|||
|
|
return `<!DOCTYPE html><html><head>${headBits}</head><body>${content}</body></html>`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|||
|
|
// IframeManager(无改动)
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
class IframeManager {
|
|||
|
|
static createWrapper(content) {
|
|||
|
|
let processed = content;
|
|||
|
|
try {
|
|||
|
|
const { substituteParams } = getContext() || {};
|
|||
|
|
if (typeof substituteParams === 'function') processed = substituteParams(content);
|
|||
|
|
} catch {}
|
|||
|
|
|
|||
|
|
const iframeId = `xiaobaix-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|||
|
|
const wrapperHtml = `
|
|||
|
|
<div class="xiaobaix-iframe-wrapper" style="margin:0;">
|
|||
|
|
<iframe id="${iframeId}" class="xiaobaix-iframe"
|
|||
|
|
style="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"
|
|||
|
|
frameborder="0" scrolling="no" loading="eager"></iframe>
|
|||
|
|
</div>`;
|
|||
|
|
|
|||
|
|
setTimeout(() => {
|
|||
|
|
const iframe = document.getElementById(iframeId);
|
|||
|
|
if (iframe) this.writeContentToIframe(iframe, processed);
|
|||
|
|
}, 0);
|
|||
|
|
|
|||
|
|
return wrapperHtml;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static writeContentToIframe(iframe, content) {
|
|||
|
|
try {
|
|||
|
|
const html = buildWrappedHtml(content);
|
|||
|
|
const sbox = !!(extension_settings && extension_settings[EXT_ID] && extension_settings[EXT_ID].sandboxMode);
|
|||
|
|
if (sbox) iframe.setAttribute('sandbox', 'allow-scripts allow-modals');
|
|||
|
|
iframe.srcdoc = html;
|
|||
|
|
const probe = () => {
|
|||
|
|
const targetOrigin = getIframeTargetOrigin(iframe);
|
|||
|
|
try { postToIframe(iframe, { type: 'probe' }, null, targetOrigin); } catch {}
|
|||
|
|
};
|
|||
|
|
if (iframe.complete) setTimeout(probe, 0);
|
|||
|
|
else iframe.addEventListener('load', () => setTimeout(probe, 0), { once: true });
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error('[Template Editor] 写入 iframe 内容失败:', err);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static async sendUpdate(messageId, vars) {
|
|||
|
|
const iframe = await this.waitForIframe(messageId);
|
|||
|
|
if (!iframe?.contentWindow) return;
|
|||
|
|
try {
|
|||
|
|
const targetOrigin = getIframeTargetOrigin(iframe);
|
|||
|
|
postToIframe(iframe, {
|
|||
|
|
type: 'VARIABLE_UPDATE',
|
|||
|
|
messageId,
|
|||
|
|
timestamp: Date.now(),
|
|||
|
|
variables: vars,
|
|||
|
|
}, 'xiaobaix-host', targetOrigin);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('[LittleWhiteBox] Failed to send iframe message:', error);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static async waitForIframe(messageId, maxAttempts = 20, delay = 50) {
|
|||
|
|
const selector = `#chat .mes[mesid="${messageId}"] iframe.xiaobaix-iframe`;
|
|||
|
|
const cachedIframe = state.getElement(selector);
|
|||
|
|
if (cachedIframe?.contentWindow && cachedIframe.contentDocument?.readyState === 'complete') return cachedIframe;
|
|||
|
|
|
|||
|
|
return new Promise((resolve) => {
|
|||
|
|
const checkIframe = () => {
|
|||
|
|
const iframe = document.querySelector(selector);
|
|||
|
|
if (iframe?.contentWindow && iframe instanceof HTMLIFrameElement) {
|
|||
|
|
const doc = iframe.contentDocument;
|
|||
|
|
if (doc && doc.readyState === 'complete') resolve(iframe);
|
|||
|
|
else iframe.addEventListener('load', () => resolve(iframe), { once: true });
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
return false;
|
|||
|
|
};
|
|||
|
|
if (checkIframe()) return;
|
|||
|
|
const messageElement = document.querySelector(`#chat .mes[mesid="${messageId}"]`);
|
|||
|
|
if (!messageElement) { resolve(null); return; }
|
|||
|
|
const observer = new MutationObserver(() => { if (checkIframe()) observer.disconnect(); });
|
|||
|
|
observer.observe(messageElement, { childList: true, subtree: true });
|
|||
|
|
setTimeout(() => { observer.disconnect(); resolve(null); }, maxAttempts * delay);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static updateVariables(messageId, vars) {
|
|||
|
|
const selector = `#chat .mes[mesid="${messageId}"] iframe.xiaobaix-iframe`;
|
|||
|
|
const iframe = state.getElement(selector) || document.querySelector(selector);
|
|||
|
|
if (!iframe?.contentWindow) return;
|
|||
|
|
const update = () => {
|
|||
|
|
try { if (iframe.contentWindow.updateTemplateVariables) iframe.contentWindow.updateTemplateVariables(vars); }
|
|||
|
|
catch (error) { console.error('[LittleWhiteBox] Failed to update iframe variables:', error); }
|
|||
|
|
};
|
|||
|
|
if (iframe.contentDocument?.readyState === 'complete') update();
|
|||
|
|
else iframe.addEventListener('load', update, { once: true });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|||
|
|
// MessageHandler(无改动)
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
class MessageHandler {
|
|||
|
|
static async process(messageId) {
|
|||
|
|
if (!TemplateSettings.get().enabled) return;
|
|||
|
|
const ctx = getContext();
|
|||
|
|
const msg = ctx.chat?.[messageId];
|
|||
|
|
if (!msg || msg.force_avatar || msg.is_user || msg.is_system) return;
|
|||
|
|
const avatar = utils.getCharAvatar(msg);
|
|||
|
|
const tmplSettings = TemplateSettings.getCharTemplate(avatar);
|
|||
|
|
if (!tmplSettings) return;
|
|||
|
|
if (tmplSettings.skipFirstMessage && messageId === 0) return;
|
|||
|
|
if (tmplSettings.limitToRecentMessages) {
|
|||
|
|
const recentCount = tmplSettings.recentMessageCount || 5;
|
|||
|
|
const minMessageId = Math.max(0, ctx.chat.length - recentCount);
|
|||
|
|
if (messageId < minMessageId) {
|
|||
|
|
this.clearTemplate(messageId, msg);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
const effectiveVars = TemplateProcessor.extractVarsWithOption(msg.mes, tmplSettings.template, tmplSettings);
|
|||
|
|
state.messageVariables.set(messageId, effectiveVars);
|
|||
|
|
this.updateHistory(messageId, effectiveVars);
|
|||
|
|
let displayText = TemplateProcessor.replaceVars(tmplSettings.template, effectiveVars);
|
|||
|
|
if (utils.isCustomTemplate(displayText)) {
|
|||
|
|
displayText = IframeManager.createWrapper(displayText);
|
|||
|
|
if (tmplSettings.limitToRecentMessages) this.clearPreviousIframes(messageId, avatar);
|
|||
|
|
setTimeout(() => IframeManager.updateVariables(messageId, effectiveVars), 300);
|
|||
|
|
}
|
|||
|
|
if (displayText) {
|
|||
|
|
msg.extra = msg.extra || {};
|
|||
|
|
msg.extra.display_text = displayText;
|
|||
|
|
updateMessageBlock(messageId, msg, { rerenderMessage: true });
|
|||
|
|
}
|
|||
|
|
setTimeout(async () => { await IframeManager.sendUpdate(messageId, effectiveVars); }, 300);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static clearPreviousIframes(currentMessageId, currentAvatar) {
|
|||
|
|
const ctx = getContext();
|
|||
|
|
if (!ctx.chat?.length) return;
|
|||
|
|
for (let i = currentMessageId - 1; i >= 0; i--) {
|
|||
|
|
const msg = ctx.chat[i];
|
|||
|
|
if (!msg || msg.is_system || msg.is_user) continue;
|
|||
|
|
const msgAvatar = utils.getCharAvatar(msg);
|
|||
|
|
if (msgAvatar !== currentAvatar) continue;
|
|||
|
|
const messageElement = document.querySelector(`#chat .mes[mesid="${i}"]`);
|
|||
|
|
const iframe = messageElement?.querySelector('iframe.xiaobaix-iframe');
|
|||
|
|
if (iframe) {
|
|||
|
|
if (msg.extra?.display_text) {
|
|||
|
|
delete msg.extra.display_text;
|
|||
|
|
updateMessageBlock(i, msg, { rerenderMessage: true });
|
|||
|
|
}
|
|||
|
|
state.messageVariables.delete(i);
|
|||
|
|
state.variableHistory.delete(i);
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static clearTemplate(messageId, msg) {
|
|||
|
|
if (msg.extra?.display_text) {
|
|||
|
|
delete msg.extra.display_text;
|
|||
|
|
updateMessageBlock(messageId, msg, { rerenderMessage: true });
|
|||
|
|
}
|
|||
|
|
state.messageVariables.delete(messageId);
|
|||
|
|
state.variableHistory.delete(messageId);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static updateHistory(messageId, variables) {
|
|||
|
|
const history = state.variableHistory.get(messageId) || new Map();
|
|||
|
|
Object.entries(variables).forEach(([varName, value]) => {
|
|||
|
|
const varHistory = history.get(varName) || [];
|
|||
|
|
if (!varHistory.length || varHistory[varHistory.length - 1] !== value) {
|
|||
|
|
varHistory.push(value);
|
|||
|
|
if (varHistory.length > 5) varHistory.shift();
|
|||
|
|
}
|
|||
|
|
history.set(varName, varHistory);
|
|||
|
|
});
|
|||
|
|
state.variableHistory.set(messageId, history);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static reapplyAll() {
|
|||
|
|
if (!TemplateSettings.get().enabled) return;
|
|||
|
|
const ctx = getContext();
|
|||
|
|
if (!ctx.chat?.length) return;
|
|||
|
|
this.clearAll();
|
|||
|
|
const messagesToProcess = ctx.chat.reduce((acc, msg, id) => {
|
|||
|
|
if (msg.is_system || msg.is_user) return acc;
|
|||
|
|
const avatar = utils.getCharAvatar(msg);
|
|||
|
|
const tmplSettings = TemplateSettings.getCharTemplate(avatar);
|
|||
|
|
if (!tmplSettings?.enabled || !tmplSettings?.template) return acc;
|
|||
|
|
if (tmplSettings.limitToRecentMessages) {
|
|||
|
|
const recentCount = tmplSettings.recentMessageCount || 5;
|
|||
|
|
const minMessageId = Math.max(0, ctx.chat.length - recentCount);
|
|||
|
|
if (id < minMessageId) return acc;
|
|||
|
|
}
|
|||
|
|
return [...acc, id];
|
|||
|
|
}, []);
|
|||
|
|
this.processBatch(messagesToProcess);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static processBatch(messageIds) {
|
|||
|
|
const processNextBatch = (deadline) => {
|
|||
|
|
while (messageIds.length > 0 && deadline.timeRemaining() > 0) {
|
|||
|
|
this.process(messageIds.shift());
|
|||
|
|
}
|
|||
|
|
if (messageIds.length > 0) requestIdleCallback(processNextBatch);
|
|||
|
|
};
|
|||
|
|
if ('requestIdleCallback' in window) requestIdleCallback(processNextBatch);
|
|||
|
|
else {
|
|||
|
|
const batchSize = 10;
|
|||
|
|
const processBatch = () => {
|
|||
|
|
messageIds.splice(0, batchSize).forEach(id => this.process(id));
|
|||
|
|
if (messageIds.length > 0) setTimeout(processBatch, 16);
|
|||
|
|
};
|
|||
|
|
processBatch();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static clearAll() {
|
|||
|
|
const ctx = getContext();
|
|||
|
|
if (!ctx.chat?.length) return;
|
|||
|
|
ctx.chat.forEach((msg, id) => {
|
|||
|
|
if (msg.extra?.display_text) {
|
|||
|
|
delete msg.extra.display_text;
|
|||
|
|
state.pendingUpdates.set(id, () => updateMessageBlock(id, msg, { rerenderMessage: true }));
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
if (state.pendingUpdates.size > 0) {
|
|||
|
|
requestAnimationFrame(() => {
|
|||
|
|
state.pendingUpdates.forEach((fn) => fn());
|
|||
|
|
state.pendingUpdates.clear();
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
state.messageVariables.clear();
|
|||
|
|
state.variableHistory.clear();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static startStreamingCheck() {
|
|||
|
|
if (state.observers.streaming) return;
|
|||
|
|
state.observers.streaming = setInterval(() => {
|
|||
|
|
if (!state.isGenerating) return;
|
|||
|
|
const ctx = getContext();
|
|||
|
|
const lastId = ctx.chat?.length - 1;
|
|||
|
|
if (lastId < 0) return;
|
|||
|
|
const lastMsg = ctx.chat[lastId];
|
|||
|
|
if (lastMsg && !lastMsg.is_system && !lastMsg.is_user) {
|
|||
|
|
const avatar = utils.getCharAvatar(lastMsg);
|
|||
|
|
const tmplSettings = TemplateSettings.getCharTemplate(avatar);
|
|||
|
|
if (tmplSettings) this.process(lastId);
|
|||
|
|
}
|
|||
|
|
}, 2000);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static stopStreamingCheck() {
|
|||
|
|
if (state.observers.streaming) {
|
|||
|
|
clearInterval(state.observers.streaming);
|
|||
|
|
state.observers.streaming = null;
|
|||
|
|
state.isGenerating = false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|||
|
|
// innerHTML 拦截器(无改动)
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
const interceptor = {
|
|||
|
|
originalSetter: null,
|
|||
|
|
setup() {
|
|||
|
|
if (this.originalSetter) return;
|
|||
|
|
const descriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'innerHTML');
|
|||
|
|
if (!descriptor?.set) return;
|
|||
|
|
this.originalSetter = descriptor.set;
|
|||
|
|
Object.defineProperty(Element.prototype, 'innerHTML', {
|
|||
|
|
set(value) {
|
|||
|
|
if (TemplateSettings.get().enabled && this.classList?.contains('mes_text')) {
|
|||
|
|
const mesElement = this.closest('.mes');
|
|||
|
|
if (mesElement) {
|
|||
|
|
const id = parseInt(mesElement.getAttribute('mesid'));
|
|||
|
|
if (!isNaN(id)) {
|
|||
|
|
const ctx = getContext();
|
|||
|
|
const msg = ctx.chat?.[id];
|
|||
|
|
if (msg && !msg.is_system && !msg.is_user) {
|
|||
|
|
const avatar = utils.getCharAvatar(msg);
|
|||
|
|
const tmplSettings = TemplateSettings.getCharTemplate(avatar);
|
|||
|
|
if (tmplSettings && tmplSettings.skipFirstMessage && id === 0) return;
|
|||
|
|
if (tmplSettings) {
|
|||
|
|
if (tmplSettings.limitToRecentMessages) {
|
|||
|
|
const recentCount = tmplSettings.recentMessageCount || 5;
|
|||
|
|
const minMessageId = Math.max(0, ctx.chat.length - recentCount);
|
|||
|
|
if (id < minMessageId) {
|
|||
|
|
if (msg.extra?.display_text) delete msg.extra.display_text;
|
|||
|
|
interceptor.originalSetter.call(this, msg.mes || '');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (this.querySelector('.xiaobaix-iframe-wrapper')) return;
|
|||
|
|
const vars = TemplateProcessor.extractVarsWithOption(msg.mes, tmplSettings.template, tmplSettings);
|
|||
|
|
state.messageVariables.set(id, vars);
|
|||
|
|
MessageHandler.updateHistory(id, vars);
|
|||
|
|
let displayText = TemplateProcessor.replaceVars(tmplSettings.template, vars);
|
|||
|
|
if (displayText?.trim()) {
|
|||
|
|
if (utils.isCustomTemplate(displayText)) {
|
|||
|
|
displayText = IframeManager.createWrapper(displayText);
|
|||
|
|
interceptor.originalSetter.call(this, displayText);
|
|||
|
|
setTimeout(() => IframeManager.updateVariables(id, vars), 150);
|
|||
|
|
return;
|
|||
|
|
} else {
|
|||
|
|
msg.extra = msg.extra || {};
|
|||
|
|
msg.extra.display_text = displayText;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
interceptor.originalSetter.call(this, value);
|
|||
|
|
},
|
|||
|
|
get: descriptor.get,
|
|||
|
|
enumerable: descriptor.enumerable,
|
|||
|
|
configurable: descriptor.configurable
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|||
|
|
// 事件处理(无改动)
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
const eventHandlers = {
|
|||
|
|
MESSAGE_UPDATED: id => setTimeout(() => MessageHandler.process(id), 150),
|
|||
|
|
MESSAGE_SWIPED: id => {
|
|||
|
|
MessageHandler.stopStreamingCheck();
|
|||
|
|
state.isStreamingCheckActive = false;
|
|||
|
|
setTimeout(() => {
|
|||
|
|
MessageHandler.process(id);
|
|||
|
|
const ctx = getContext();
|
|||
|
|
const msg = ctx.chat?.[id];
|
|||
|
|
if (msg && !msg.is_system && !msg.is_user) {
|
|||
|
|
const avatar = utils.getCharAvatar(msg);
|
|||
|
|
const tmplSettings = TemplateSettings.getCharTemplate(avatar);
|
|||
|
|
if (tmplSettings) {
|
|||
|
|
const vars = TemplateProcessor.extractVarsWithOption(msg.mes, tmplSettings.template, tmplSettings);
|
|||
|
|
setTimeout(() => IframeManager.updateVariables(id, vars), 300);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}, 150);
|
|||
|
|
},
|
|||
|
|
STREAM_TOKEN_RECEIVED: () => {
|
|||
|
|
if (!state.isStreamingCheckActive) {
|
|||
|
|
state.isStreamingCheckActive = true;
|
|||
|
|
state.isGenerating = true;
|
|||
|
|
MessageHandler.startStreamingCheck();
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
GENERATION_ENDED: () => {
|
|||
|
|
MessageHandler.stopStreamingCheck();
|
|||
|
|
state.isStreamingCheckActive = false;
|
|||
|
|
const ctx = getContext();
|
|||
|
|
const lastId = ctx.chat?.length - 1;
|
|||
|
|
if (lastId >= 0) setTimeout(() => MessageHandler.process(lastId), 150);
|
|||
|
|
},
|
|||
|
|
CHAT_CHANGED: () => {
|
|||
|
|
state.clear();
|
|||
|
|
setTimeout(() => {
|
|||
|
|
updateStatus();
|
|||
|
|
MessageHandler.reapplyAll();
|
|||
|
|
}, 300);
|
|||
|
|
},
|
|||
|
|
CHARACTER_SELECTED: () => {
|
|||
|
|
state.clear();
|
|||
|
|
setTimeout(() => {
|
|||
|
|
updateStatus();
|
|||
|
|
MessageHandler.reapplyAll();
|
|||
|
|
checkEmbeddedTemplate();
|
|||
|
|
}, 300);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|||
|
|
// UI 函数
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
function updateStatus() {
|
|||
|
|
const $status = $('#template_character_status');
|
|||
|
|
if (!$status.length) return;
|
|||
|
|
if (this_chid === undefined || !characters[this_chid]) {
|
|||
|
|
$status.removeClass('has-settings').addClass('no-character').text('请选择一个角色');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const name = characters[this_chid].name;
|
|||
|
|
const charSettings = TemplateSettings.getCurrentChar();
|
|||
|
|
if (charSettings.enabled && charSettings.template) {
|
|||
|
|
$status.removeClass('no-character').addClass('has-settings').text(`${name} - 已启用模板功能`);
|
|||
|
|
} else {
|
|||
|
|
$status.removeClass('has-settings').addClass('no-character').text(`${name} - 未设置模板`);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function openEditor() {
|
|||
|
|
if (this_chid === undefined || !characters[this_chid]) {
|
|||
|
|
toastr.error('请先选择一个角色');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const name = characters[this_chid].name;
|
|||
|
|
const response = await fetch(`${extensionFolderPath}/modules/template-editor/template-editor.html`);
|
|||
|
|
const $html = $(await response.text());
|
|||
|
|
const charSettings = TemplateSettings.getCurrentChar();
|
|||
|
|
|
|||
|
|
$html.find('h3 strong').text(`模板编辑器 - ${name}`);
|
|||
|
|
$html.find('#fixed_text_template').val(charSettings.template);
|
|||
|
|
$html.find('#fixed_text_custom_regex').val(charSettings.customRegex || DEFAULT_CHAR_SETTINGS.customRegex);
|
|||
|
|
$html.find('#disable_parsers').prop('checked', !!charSettings.disableParsers);
|
|||
|
|
$html.find('#limit_to_recent_messages').prop('checked', charSettings.limitToRecentMessages || false);
|
|||
|
|
$html.find('#recent_message_count').val(charSettings.recentMessageCount || 5);
|
|||
|
|
$html.find('#skip_first_message').prop('checked', charSettings.skipFirstMessage || false);
|
|||
|
|
|
|||
|
|
$html.find('#export_character_settings').on('click', () => {
|
|||
|
|
const data = {
|
|||
|
|
template: $html.find('#fixed_text_template').val() || '',
|
|||
|
|
customRegex: $html.find('#fixed_text_custom_regex').val() || DEFAULT_CHAR_SETTINGS.customRegex,
|
|||
|
|
disableParsers: $html.find('#disable_parsers').prop('checked'),
|
|||
|
|
limitToRecentMessages: $html.find('#limit_to_recent_messages').prop('checked'),
|
|||
|
|
recentMessageCount: parseInt(String($html.find('#recent_message_count').val())) || 5,
|
|||
|
|
skipFirstMessage: $html.find('#skip_first_message').prop('checked')
|
|||
|
|
};
|
|||
|
|
download(`xiaobai-template-${characters[this_chid].name}.json`, JSON.stringify(data, null, 2), 'text/plain');
|
|||
|
|
toastr.success('模板设置已导出');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
$html.find('#import_character_settings').on('change', function(e) {
|
|||
|
|
const file = e.target?.files?.[0];
|
|||
|
|
if (!file) return;
|
|||
|
|
const reader = new FileReader();
|
|||
|
|
reader.onload = function(e) {
|
|||
|
|
try {
|
|||
|
|
const data = JSON.parse(typeof e.target.result === 'string' ? e.target.result : '');
|
|||
|
|
$html.find('#fixed_text_template').val(data.template || '');
|
|||
|
|
$html.find('#fixed_text_custom_regex').val(data.customRegex || DEFAULT_CHAR_SETTINGS.customRegex);
|
|||
|
|
$html.find('#disable_parsers').prop('checked', !!data.disableParsers);
|
|||
|
|
$html.find('#limit_to_recent_messages').prop('checked', data.limitToRecentMessages || false);
|
|||
|
|
$html.find('#recent_message_count').val(data.recentMessageCount || 5);
|
|||
|
|
$html.find('#skip_first_message').prop('checked', data.skipFirstMessage || false);
|
|||
|
|
toastr.success('模板设置已导入');
|
|||
|
|
} catch { toastr.error('文件格式错误'); }
|
|||
|
|
};
|
|||
|
|
reader.readAsText(file);
|
|||
|
|
if (e.target) e.target.value = '';
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const result = await callGenericPopup($html, POPUP_TYPE.CONFIRM, '', { okButton: '保存', cancelButton: '取消' });
|
|||
|
|
if (result) {
|
|||
|
|
await TemplateSettings.saveCurrentChar({
|
|||
|
|
enabled: true,
|
|||
|
|
template: $html.find('#fixed_text_template').val() || '',
|
|||
|
|
customRegex: $html.find('#fixed_text_custom_regex').val() || DEFAULT_CHAR_SETTINGS.customRegex,
|
|||
|
|
disableParsers: $html.find('#disable_parsers').prop('checked'),
|
|||
|
|
limitToRecentMessages: $html.find('#limit_to_recent_messages').prop('checked'),
|
|||
|
|
recentMessageCount: parseInt(String($html.find('#recent_message_count').val())) || 5,
|
|||
|
|
skipFirstMessage: $html.find('#skip_first_message').prop('checked')
|
|||
|
|
});
|
|||
|
|
state.clear();
|
|||
|
|
updateStatus();
|
|||
|
|
setTimeout(() => MessageHandler.reapplyAll(), 300);
|
|||
|
|
toastr.success(`已保存 ${name} 的模板设置`);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function exportGlobal() {
|
|||
|
|
// 导出时只导出有模板的角色(从角色卡读取)
|
|||
|
|
const bindings = {};
|
|||
|
|
for (const char of characters) {
|
|||
|
|
const embedded = char.data?.extensions?.[TEMPLATE_MODULE_NAME];
|
|||
|
|
if (embedded?.enabled && embedded?.template) {
|
|||
|
|
bindings[char.avatar] = embedded;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
const exportData = {
|
|||
|
|
enabled: TemplateSettings.get().enabled,
|
|||
|
|
characterBindings: bindings
|
|||
|
|
};
|
|||
|
|
download('xiaobai-template-global-settings.json', JSON.stringify(exportData, null, 2), 'text/plain');
|
|||
|
|
toastr.success('全局模板设置已导出');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function importGlobal(event) {
|
|||
|
|
const file = event.target.files[0];
|
|||
|
|
if (!file) return;
|
|||
|
|
const reader = new FileReader();
|
|||
|
|
reader.onload = e => {
|
|||
|
|
try {
|
|||
|
|
const data = JSON.parse(typeof e.target.result === 'string' ? e.target.result : '');
|
|||
|
|
// 只导入 enabled 状态
|
|||
|
|
TemplateSettings.get().enabled = !!data.enabled;
|
|||
|
|
saveSettingsDebounced();
|
|||
|
|
$("#xiaobaix_template_enabled").prop("checked", data.enabled);
|
|||
|
|
state.clear();
|
|||
|
|
updateStatus();
|
|||
|
|
setTimeout(() => MessageHandler.reapplyAll(), 150);
|
|||
|
|
toastr.success('全局模板设置已导入(注:角色模板需在角色编辑器中单独导入)');
|
|||
|
|
} catch { toastr.error('文件格式错误'); }
|
|||
|
|
};
|
|||
|
|
reader.readAsText(file);
|
|||
|
|
event.target.value = '';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function checkEmbeddedTemplate() {
|
|||
|
|
if (!this_chid || !characters[this_chid]) return;
|
|||
|
|
const embeddedSettings = characters[this_chid].data?.extensions?.[TEMPLATE_MODULE_NAME];
|
|||
|
|
if (embeddedSettings?.enabled && embeddedSettings?.template) {
|
|||
|
|
setTimeout(() => {
|
|||
|
|
updateStatus();
|
|||
|
|
if (utils.isEnabled()) MessageHandler.reapplyAll();
|
|||
|
|
}, 150);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|||
|
|
// 生命周期
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
function cleanup() {
|
|||
|
|
try { xbLog.info('templateEditor', 'cleanup'); } catch {}
|
|||
|
|
events.cleanup();
|
|||
|
|
MessageHandler.stopStreamingCheck();
|
|||
|
|
state.observers.message?.disconnect();
|
|||
|
|
state.observers.message = null;
|
|||
|
|
if (interceptor.originalSetter) {
|
|||
|
|
Object.defineProperty(Element.prototype, 'innerHTML', {
|
|||
|
|
...Object.getOwnPropertyDescriptor(Element.prototype, 'innerHTML'),
|
|||
|
|
set: interceptor.originalSetter
|
|||
|
|
});
|
|||
|
|
interceptor.originalSetter = null;
|
|||
|
|
}
|
|||
|
|
state.clear();
|
|||
|
|
state.variableHistory.clear();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function initTemplateEditor() {
|
|||
|
|
try { xbLog.info('templateEditor', 'initTemplateEditor'); } catch {}
|
|||
|
|
|
|||
|
|
// 启动时执行一次数据迁移
|
|||
|
|
TemplateSettings.migrateAndCleanup();
|
|||
|
|
|
|||
|
|
const setupObserver = () => {
|
|||
|
|
if (state.observers.message) state.observers.message.disconnect();
|
|||
|
|
const chatElement = document.querySelector('#chat');
|
|||
|
|
if (!chatElement) return;
|
|||
|
|
state.observers.message = new MutationObserver(mutations => {
|
|||
|
|
if (!TemplateSettings.get().enabled) return;
|
|||
|
|
const newMessages = mutations.flatMap(mutation =>
|
|||
|
|
Array.from(mutation.addedNodes)
|
|||
|
|
.filter(node => node.nodeType === Node.ELEMENT_NODE && node instanceof Element && node.classList?.contains('mes'))
|
|||
|
|
.map(node => node instanceof Element ? parseInt(node.getAttribute('mesid')) : NaN)
|
|||
|
|
.filter(id => !isNaN(id))
|
|||
|
|
);
|
|||
|
|
if (newMessages.length > 0) MessageHandler.processBatch(newMessages);
|
|||
|
|
});
|
|||
|
|
state.observers.message.observe(chatElement, { childList: true, subtree: false });
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
Object.entries(eventHandlers).forEach(([event, handler]) => {
|
|||
|
|
if (event_types[event]) events.on(event_types[event], handler);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
document.addEventListener('xiaobaixEnabledChanged', function(event) {
|
|||
|
|
const enabled = event?.detail?.enabled;
|
|||
|
|
if (!enabled) cleanup();
|
|||
|
|
else {
|
|||
|
|
setTimeout(() => {
|
|||
|
|
if (TemplateSettings.get().enabled) {
|
|||
|
|
interceptor.setup();
|
|||
|
|
setupObserver();
|
|||
|
|
MessageHandler.reapplyAll();
|
|||
|
|
}
|
|||
|
|
}, 150);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
$("#xiaobaix_template_enabled").on("input", e => {
|
|||
|
|
const enabled = $(e.target).prop('checked');
|
|||
|
|
TemplateSettings.get().enabled = enabled;
|
|||
|
|
saveSettingsDebounced();
|
|||
|
|
updateStatus();
|
|||
|
|
if (enabled) {
|
|||
|
|
interceptor.setup();
|
|||
|
|
setupObserver();
|
|||
|
|
setTimeout(() => MessageHandler.reapplyAll(), 150);
|
|||
|
|
} else cleanup();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
$("#open_template_editor").on("click", openEditor);
|
|||
|
|
$("#export_template_settings").on("click", exportGlobal);
|
|||
|
|
$("#import_template_settings").on("click", () => $("#import_template_file").click());
|
|||
|
|
$("#import_template_file").on("change", importGlobal);
|
|||
|
|
$("#xiaobaix_template_enabled").prop("checked", TemplateSettings.get().enabled);
|
|||
|
|
|
|||
|
|
updateStatus();
|
|||
|
|
|
|||
|
|
if (typeof window['registerModuleCleanup'] === 'function') {
|
|||
|
|
window['registerModuleCleanup']('templateEditor', cleanup);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (utils.isEnabled()) {
|
|||
|
|
setTimeout(() => {
|
|||
|
|
interceptor.setup();
|
|||
|
|
setupObserver();
|
|||
|
|
MessageHandler.reapplyAll();
|
|||
|
|
}, 600);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
setTimeout(checkEmbeddedTemplate, 1200);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export {
|
|||
|
|
initTemplateEditor,
|
|||
|
|
TemplateSettings as templateSettings,
|
|||
|
|
updateStatus,
|
|||
|
|
openEditor,
|
|||
|
|
cleanup,
|
|||
|
|
checkEmbeddedTemplate,
|
|||
|
|
STscript
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|||
|
|
// 缓存注册(无改动)
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
CacheRegistry.register('templateEditor', {
|
|||
|
|
name: '模板编辑器缓存',
|
|||
|
|
getSize: () => {
|
|||
|
|
const a = state.messageVariables?.size || 0;
|
|||
|
|
const b = state.caches?.template?.size || 0;
|
|||
|
|
const c = state.caches?.regex?.size || 0;
|
|||
|
|
const d = state.caches?.dom?.size || 0;
|
|||
|
|
const e = state.variableHistory?.size || 0;
|
|||
|
|
const f = state.pendingUpdates?.size || 0;
|
|||
|
|
return a + b + c + d + e + f;
|
|||
|
|
},
|
|||
|
|
getBytes: () => {
|
|||
|
|
try {
|
|||
|
|
let total = 0;
|
|||
|
|
const addStr = (v) => { total += String(v ?? '').length * 2; };
|
|||
|
|
const addJson = (v) => { try { total += JSON.stringify(v).length * 2; } catch { addStr(v?.toString?.() ?? v); } };
|
|||
|
|
const addMap = (m, addValue) => { if (!m?.forEach) return; m.forEach((v, k) => { addStr(k); if (typeof addValue === 'function') addValue(v); }); };
|
|||
|
|
addMap(state.messageVariables, addJson);
|
|||
|
|
addMap(state.caches?.template, (v) => (typeof v === 'string' ? addStr(v) : addJson(v)));
|
|||
|
|
addMap(state.caches?.regex, (v) => addStr(v?.source ?? v));
|
|||
|
|
addMap(state.caches?.dom, (v) => { const html = (typeof v?.outerHTML === 'string') ? v.outerHTML : null; if (html) addStr(html); else addStr(v?.toString?.() ?? v); });
|
|||
|
|
addMap(state.variableHistory, addJson);
|
|||
|
|
addMap(state.pendingUpdates, addJson);
|
|||
|
|
return total;
|
|||
|
|
} catch { return 0; }
|
|||
|
|
},
|
|||
|
|
clear: () => {
|
|||
|
|
state.clear();
|
|||
|
|
state.variableHistory.clear();
|
|||
|
|
state.pendingUpdates.clear();
|
|||
|
|
},
|
|||
|
|
getDetail: () => ({
|
|||
|
|
messageVariables: state.messageVariables.size,
|
|||
|
|
templateCache: state.caches.template.size,
|
|||
|
|
regexCache: state.caches.regex.size,
|
|||
|
|
domCache: state.caches.dom.size,
|
|||
|
|
variableHistory: state.variableHistory.size,
|
|||
|
|
pendingUpdates: state.pendingUpdates.size,
|
|||
|
|
}),
|
|||
|
|
});
|