Files
LittleWhiteBox/modules/template-editor/template-editor.js
2026-01-17 16:34:39 +08:00

1314 lines
59 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
}),
});