Files
LittleWhiteBox/modules/template-editor/template-editor.js

1314 lines
59 KiB
JavaScript
Raw Normal View History

2026-01-17 16:34:39 +08:00
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, '&amp;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')
};
// ═══════════════════════════════════════════════════════════════════════════
// 设置管理 - 核心改动:分离存储
// ═══════════════════════════════════════════════════════════════════════════
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,
}),
});