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 => [' 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 `${value}`;
}) || '';
}
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 = `