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

714 lines
25 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 } from "../../../../extensions.js";
import { createModuleEvents, event_types } from "../core/event-manager.js";
import { EXT_ID } from "../core/constants.js";
import { xbLog, CacheRegistry } from "../core/debug-core.js";
import { replaceXbGetVarInString } from "./variables/var-commands.js";
import { executeSlashCommand } from "../core/slash-command.js";
import { default_user_avatar, default_avatar } from "../../../../../script.js";
import { getIframeBaseScript, getWrapperScript } from "../core/wrapper-inline.js";
import { postToIframe, getIframeTargetOrigin, getTrustedOrigin } from "../core/iframe-messaging.js";
const MODULE_ID = 'iframeRenderer';
const events = createModuleEvents(MODULE_ID);
let isGenerating = false;
const winMap = new Map();
let lastHeights = new WeakMap();
const blobUrls = new WeakMap();
const hashToBlobUrl = new Map();
const hashToBlobBytes = new Map();
const blobLRU = [];
const BLOB_CACHE_LIMIT = 32;
let lastApplyTs = 0;
let pendingHeight = null;
let pendingRec = null;
CacheRegistry.register(MODULE_ID, {
name: 'Blob URL 缓存',
getSize: () => hashToBlobUrl.size,
getBytes: () => {
let bytes = 0;
hashToBlobBytes.forEach(v => { bytes += Number(v) || 0; });
return bytes;
},
clear: () => {
clearBlobCaches();
hashToBlobBytes.clear();
},
getDetail: () => Array.from(hashToBlobUrl.keys()),
});
function getSettings() {
return extension_settings[EXT_ID] || {};
}
function ensureHideCodeStyle(enable) {
const id = 'xiaobaix-hide-code';
const old = document.getElementById(id);
if (!enable) {
old?.remove();
return;
}
if (old) return;
const hideCodeStyle = document.createElement('style');
hideCodeStyle.id = id;
hideCodeStyle.textContent = `
.xiaobaix-active .mes_text pre { display: none !important; }
.xiaobaix-active .mes_text pre.xb-show { display: block !important; }
`;
document.head.appendChild(hideCodeStyle);
}
function setActiveClass(enable) {
document.body.classList.toggle('xiaobaix-active', !!enable);
}
function djb2(str) {
let h = 5381;
for (let i = 0; i < str.length; i++) {
h = ((h << 5) + h) ^ str.charCodeAt(i);
}
return (h >>> 0).toString(16);
}
function shouldRenderContentByBlock(codeBlock) {
if (!codeBlock) return false;
const content = (codeBlock.textContent || '').trim().toLowerCase();
if (!content) return false;
return content.includes('<!doctype') || content.includes('<html') || content.includes('<script');
}
function generateUniqueId() {
return `xiaobaix-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
}
function setIframeBlobHTML(iframe, fullHTML, codeHash) {
const existing = hashToBlobUrl.get(codeHash);
if (existing) {
iframe.src = existing;
blobUrls.set(iframe, existing);
return;
}
const blob = new Blob([fullHTML], { type: 'text/html' });
const url = URL.createObjectURL(blob);
iframe.src = url;
blobUrls.set(iframe, url);
hashToBlobUrl.set(codeHash, url);
try { hashToBlobBytes.set(codeHash, blob.size || 0); } catch {}
blobLRU.push(codeHash);
while (blobLRU.length > BLOB_CACHE_LIMIT) {
const old = blobLRU.shift();
const u = hashToBlobUrl.get(old);
hashToBlobUrl.delete(old);
hashToBlobBytes.delete(old);
try { URL.revokeObjectURL(u); } catch (e) {}
}
}
function releaseIframeBlob(iframe) {
try {
const url = blobUrls.get(iframe);
if (url) URL.revokeObjectURL(url);
blobUrls.delete(iframe);
} catch (e) {}
}
export function clearBlobCaches() {
try { xbLog.info(MODULE_ID, '清空 Blob 缓存'); } catch {}
hashToBlobUrl.forEach(u => { try { URL.revokeObjectURL(u); } catch {} });
hashToBlobUrl.clear();
hashToBlobBytes.clear();
blobLRU.length = 0;
}
function buildResourceHints(html) {
const urls = Array.from(new Set((html.match(/https?:\/\/[^"'()\s]+/gi) || [])
.map(u => { try { return new URL(u).origin; } catch { return null; } })
.filter(Boolean)));
let hints = "";
const maxHosts = 6;
for (let i = 0; i < Math.min(urls.length, maxHosts); i++) {
const origin = urls[i];
hints += `<link rel="dns-prefetch" href="${origin}">`;
hints += `<link rel="preconnect" href="${origin}" crossorigin>`;
}
let preload = "";
const font = (html.match(/https?:\/\/[^"'()\s]+\.(?:woff2|woff|ttf|otf)/i) || [])[0];
if (font) {
const type = font.endsWith(".woff2") ? "font/woff2" : font.endsWith(".woff") ? "font/woff" : font.endsWith(".ttf") ? "font/ttf" : "font/otf";
preload += `<link rel="preload" as="font" href="${font}" type="${type}" crossorigin fetchpriority="high">`;
}
const css = (html.match(/https?:\/\/[^"'()\s]+\.css/i) || [])[0];
if (css) {
preload += `<link rel="preload" as="style" href="${css}" crossorigin fetchpriority="high">`;
}
const img = (html.match(/https?:\/\/[^"'()\s]+\.(?:png|jpg|jpeg|webp|gif|svg)/i) || [])[0];
if (img) {
preload += `<link rel="preload" as="image" href="${img}" crossorigin fetchpriority="high">`;
}
return hints + preload;
}
function buildWrappedHtml(html) {
const settings = getSettings();
const wrapperToggle = settings.wrapperIframe ?? true;
const origin = typeof location !== 'undefined' && location.origin ? location.origin : '';
const baseTag = settings.useBlob ? `<base href="${origin}/">` : "";
const headHints = buildResourceHints(html);
const vhFix = `<style>html,body{height:auto!important;min-height:0!important;max-height:none!important}.profile-container,[style*="100vh"]{height:auto!important;min-height:600px!important}[style*="height:100%"]{height:auto!important;min-height:100%!important}</style>`;
// 内联脚本按顺序wrapper(callGenerate) -> base(高度+STscript)
const scripts = wrapperToggle
? `<script>${getWrapperScript()}${getIframeBaseScript()}</script>`
: `<script>${getIframeBaseScript()}</script>`;
if (html.includes('<html') && html.includes('</html')) {
if (html.includes('<head>'))
return html.replace('<head>', `<head>${scripts}${baseTag}${headHints}${vhFix}`);
if (html.includes('</head>'))
return html.replace('</head>', `${scripts}${baseTag}${headHints}${vhFix}</head>`);
return html.replace('<body', `<head>${scripts}${baseTag}${headHints}${vhFix}</head><body`);
}
return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
${scripts}
${baseTag}
${headHints}
${vhFix}
<style>html,body{margin:0;padding:0;background:transparent}</style>
</head>
<body>${html}</body></html>`;
}
function getOrCreateWrapper(preEl) {
let wrapper = preEl.previousElementSibling;
if (!wrapper || !wrapper.classList.contains('xiaobaix-iframe-wrapper')) {
wrapper = document.createElement('div');
wrapper.className = 'xiaobaix-iframe-wrapper';
wrapper.style.cssText = 'margin:0;';
preEl.parentNode.insertBefore(wrapper, preEl);
}
return wrapper;
}
function registerIframeMapping(iframe, wrapper) {
const tryMap = () => {
try {
if (iframe && iframe.contentWindow) {
winMap.set(iframe.contentWindow, { iframe, wrapper });
return true;
}
} catch (e) {}
return false;
};
if (tryMap()) return;
let tries = 0;
const t = setInterval(() => {
tries++;
if (tryMap() || tries > 20) clearInterval(t);
}, 25);
}
function resolveAvatarUrls() {
const origin = typeof location !== 'undefined' && location.origin ? location.origin : '';
const toAbsUrl = (relOrUrl) => {
if (!relOrUrl) return '';
const s = String(relOrUrl);
if (/^(data:|blob:|https?:)/i.test(s)) return s;
if (s.startsWith('User Avatars/')) {
return `${origin}/${s}`;
}
const encoded = s.split('/').map(seg => encodeURIComponent(seg)).join('/');
return `${origin}/${encoded.replace(/^\/+/, '')}`;
};
const pickSrc = (selectors) => {
for (const sel of selectors) {
const el = document.querySelector(sel);
if (el) {
const highRes = el.getAttribute('data-izoomify-url');
if (highRes) return highRes;
if (el.src) return el.src;
}
}
return '';
};
let user = pickSrc([
'#user_avatar_block img',
'#avatar_user img',
'.user_avatar img',
'img#avatar_user',
'.st-user-avatar img'
]) || default_user_avatar;
const m = String(user).match(/\/thumbnail\?type=persona&file=([^&]+)/i);
if (m) {
user = `User Avatars/${decodeURIComponent(m[1])}`;
}
const ctx = getContext?.() || {};
const chId = ctx.characterId ?? ctx.this_chid;
const ch = Array.isArray(ctx.characters) ? ctx.characters[chId] : null;
let char = ch?.avatar || default_avatar;
if (char && !/^(data:|blob:|https?:)/i.test(char)) {
char = String(char).includes('/') ? char.replace(/^\/+/, '') : `characters/${char}`;
}
return { user: toAbsUrl(user), char: toAbsUrl(char) };
}
function handleIframeMessage(event) {
const data = event.data || {};
let rec = winMap.get(event.source);
if (!rec || !rec.iframe) {
const iframes = document.querySelectorAll('iframe.xiaobaix-iframe');
for (const iframe of iframes) {
if (iframe.contentWindow === event.source) {
rec = { iframe, wrapper: iframe.parentElement };
winMap.set(event.source, rec);
break;
}
}
}
if (rec && rec.iframe && typeof data.height === 'number') {
const next = Math.max(0, Number(data.height) || 0);
if (next < 1) return;
const prev = lastHeights.get(rec.iframe) || 0;
if (!data.force && Math.abs(next - prev) < 1) return;
if (data.force) {
lastHeights.set(rec.iframe, next);
requestAnimationFrame(() => { rec.iframe.style.height = `${next}px`; });
return;
}
pendingHeight = next;
pendingRec = rec;
const now = performance.now();
const dt = now - lastApplyTs;
if (dt >= 50) {
lastApplyTs = now;
const h = pendingHeight, r = pendingRec;
pendingHeight = null;
pendingRec = null;
lastHeights.set(r.iframe, h);
requestAnimationFrame(() => { r.iframe.style.height = `${h}px`; });
} else {
setTimeout(() => {
if (pendingRec && pendingHeight != null) {
lastApplyTs = performance.now();
const h = pendingHeight, r = pendingRec;
pendingHeight = null;
pendingRec = null;
lastHeights.set(r.iframe, h);
requestAnimationFrame(() => { r.iframe.style.height = `${h}px`; });
}
}, Math.max(0, 50 - dt));
}
return;
}
if (data && data.type === 'runCommand') {
const replyOrigin = (typeof event.origin === 'string' && event.origin) ? event.origin : getTrustedOrigin();
executeSlashCommand(data.command)
.then(result => event.source.postMessage({
source: 'xiaobaix-host',
type: 'commandResult',
id: data.id,
result
}, replyOrigin))
.catch(err => event.source.postMessage({
source: 'xiaobaix-host',
type: 'commandError',
id: data.id,
error: err.message || String(err)
}, replyOrigin));
return;
}
if (data && data.type === 'getAvatars') {
const replyOrigin = (typeof event.origin === 'string' && event.origin) ? event.origin : getTrustedOrigin();
try {
const urls = resolveAvatarUrls();
event.source?.postMessage({ source: 'xiaobaix-host', type: 'avatars', urls }, replyOrigin);
} catch (e) {
event.source?.postMessage({ source: 'xiaobaix-host', type: 'avatars', urls: { user: '', char: '' } }, replyOrigin);
}
return;
}
}
export function renderHtmlInIframe(htmlContent, container, preElement) {
const settings = getSettings();
try {
const originalHash = djb2(htmlContent);
if (settings.variablesCore?.enabled && typeof replaceXbGetVarInString === 'function') {
try {
htmlContent = replaceXbGetVarInString(htmlContent);
} catch (e) {
console.warn('xbgetvar 宏替换失败:', e);
}
}
const iframe = document.createElement('iframe');
iframe.id = generateUniqueId();
iframe.className = 'xiaobaix-iframe';
iframe.style.cssText = 'width:100%;border:none;background:transparent;overflow:hidden;height:0;margin:0;padding:0;display:block;contain:layout paint style;will-change:height;min-height:50px';
iframe.setAttribute('frameborder', '0');
iframe.setAttribute('scrolling', 'no');
iframe.loading = 'eager';
if (settings.sandboxMode) {
iframe.setAttribute('sandbox', 'allow-scripts');
}
const wrapper = getOrCreateWrapper(preElement);
wrapper.querySelectorAll('.xiaobaix-iframe').forEach(old => {
try { old.src = 'about:blank'; } catch (e) {}
releaseIframeBlob(old);
old.remove();
});
const codeHash = djb2(htmlContent);
const full = buildWrappedHtml(htmlContent);
if (settings.useBlob) {
setIframeBlobHTML(iframe, full, codeHash);
} else {
iframe.srcdoc = full;
}
wrapper.appendChild(iframe);
preElement.classList.remove('xb-show');
preElement.style.display = 'none';
registerIframeMapping(iframe, wrapper);
try {
const targetOrigin = getIframeTargetOrigin(iframe);
postToIframe(iframe, { type: 'probe' }, null, targetOrigin);
} catch (e) {}
preElement.dataset.xbFinal = 'true';
preElement.dataset.xbHash = originalHash;
return iframe;
} catch (err) {
console.error('[iframeRenderer] 渲染失败:', err);
return null;
}
}
export function processCodeBlocks(messageElement, forceFinal = true) {
const settings = getSettings();
if (!settings.enabled) return;
if (settings.renderEnabled === false) return;
try {
const codeBlocks = messageElement.querySelectorAll('pre > code');
const ctx = getContext();
const lastId = ctx.chat?.length - 1;
const mesEl = messageElement.closest('.mes');
const mesId = mesEl ? Number(mesEl.getAttribute('mesid')) : null;
if (isGenerating && mesId === lastId && !forceFinal) return;
codeBlocks.forEach(codeBlock => {
const preElement = codeBlock.parentElement;
const should = shouldRenderContentByBlock(codeBlock);
const html = codeBlock.textContent || '';
const hash = djb2(html);
const isFinal = preElement.dataset.xbFinal === 'true';
const same = preElement.dataset.xbHash === hash;
if (isFinal && same) return;
if (should) {
renderHtmlInIframe(html, preElement.parentNode, preElement);
} else {
preElement.classList.add('xb-show');
preElement.removeAttribute('data-xbfinal');
preElement.removeAttribute('data-xbhash');
preElement.style.display = '';
}
preElement.dataset.xiaobaixBound = 'true';
});
} catch (err) {
console.error('[iframeRenderer] processCodeBlocks 失败:', err);
}
}
export function processExistingMessages() {
const settings = getSettings();
if (!settings.enabled) return;
document.querySelectorAll('.mes_text').forEach(el => processCodeBlocks(el, true));
try { shrinkRenderedWindowFull(); } catch (e) {}
}
export function processMessageById(messageId, forceFinal = true) {
const messageElement = document.querySelector(`div.mes[mesid="${messageId}"] .mes_text`);
if (!messageElement) return;
processCodeBlocks(messageElement, forceFinal);
try { shrinkRenderedWindowForLastMessage(); } catch (e) {}
}
export function invalidateMessage(messageId) {
const el = document.querySelector(`div.mes[mesid="${messageId}"] .mes_text`);
if (!el) return;
el.querySelectorAll('.xiaobaix-iframe-wrapper').forEach(w => {
w.querySelectorAll('.xiaobaix-iframe').forEach(ifr => {
try { ifr.src = 'about:blank'; } catch (e) {}
releaseIframeBlob(ifr);
});
w.remove();
});
el.querySelectorAll('pre').forEach(pre => {
pre.classList.remove('xb-show');
pre.removeAttribute('data-xbfinal');
pre.removeAttribute('data-xbhash');
delete pre.dataset.xbFinal;
delete pre.dataset.xbHash;
pre.style.display = '';
delete pre.dataset.xiaobaixBound;
});
}
export function invalidateAll() {
document.querySelectorAll('.xiaobaix-iframe-wrapper').forEach(w => {
w.querySelectorAll('.xiaobaix-iframe').forEach(ifr => {
try { ifr.src = 'about:blank'; } catch (e) {}
releaseIframeBlob(ifr);
});
w.remove();
});
document.querySelectorAll('.mes_text pre').forEach(pre => {
pre.classList.remove('xb-show');
pre.removeAttribute('data-xbfinal');
pre.removeAttribute('data-xbhash');
delete pre.dataset.xbFinal;
delete pre.dataset.xbHash;
delete pre.dataset.xiaobaixBound;
pre.style.display = '';
});
clearBlobCaches();
winMap.clear();
lastHeights = new WeakMap();
}
function shrinkRenderedWindowForLastMessage() {
const settings = getSettings();
if (!settings.enabled) return;
if (settings.renderEnabled === false) return;
const max = Number.isFinite(settings.maxRenderedMessages) && settings.maxRenderedMessages > 0
? settings.maxRenderedMessages
: 0;
if (max <= 0) return;
const ctx = getContext?.();
const chatArr = ctx?.chat;
if (!Array.isArray(chatArr) || chatArr.length === 0) return;
const lastId = chatArr.length - 1;
if (lastId < 0) return;
const keepFrom = Math.max(0, lastId - max + 1);
const mesList = document.querySelectorAll('div.mes');
for (const mes of mesList) {
const mesIdAttr = mes.getAttribute('mesid');
if (mesIdAttr == null) continue;
const mesId = Number(mesIdAttr);
if (!Number.isFinite(mesId)) continue;
if (mesId >= keepFrom) break;
const mesText = mes.querySelector('.mes_text');
if (!mesText) continue;
mesText.querySelectorAll('.xiaobaix-iframe-wrapper').forEach(w => {
w.querySelectorAll('.xiaobaix-iframe').forEach(ifr => {
try { ifr.src = 'about:blank'; } catch (e) {}
releaseIframeBlob(ifr);
});
w.remove();
});
mesText.querySelectorAll('pre[data-xiaobaix-bound="true"]').forEach(pre => {
pre.classList.remove('xb-show');
pre.removeAttribute('data-xbfinal');
pre.removeAttribute('data-xbhash');
delete pre.dataset.xbFinal;
delete pre.dataset.xbHash;
delete pre.dataset.xiaobaixBound;
pre.style.display = '';
});
}
}
function shrinkRenderedWindowFull() {
const settings = getSettings();
if (!settings.enabled) return;
if (settings.renderEnabled === false) return;
const max = Number.isFinite(settings.maxRenderedMessages) && settings.maxRenderedMessages > 0
? settings.maxRenderedMessages
: 0;
if (max <= 0) return;
const ctx = getContext?.();
const chatArr = ctx?.chat;
if (!Array.isArray(chatArr) || chatArr.length === 0) return;
const lastId = chatArr.length - 1;
const keepFrom = Math.max(0, lastId - max + 1);
const mesList = document.querySelectorAll('div.mes');
for (const mes of mesList) {
const mesIdAttr = mes.getAttribute('mesid');
if (mesIdAttr == null) continue;
const mesId = Number(mesIdAttr);
if (!Number.isFinite(mesId)) continue;
if (mesId >= keepFrom) continue;
const mesText = mes.querySelector('.mes_text');
if (!mesText) continue;
mesText.querySelectorAll('.xiaobaix-iframe-wrapper').forEach(w => {
w.querySelectorAll('.xiaobaix-iframe').forEach(ifr => {
try { ifr.src = 'about:blank'; } catch (e) {}
releaseIframeBlob(ifr);
});
w.remove();
});
mesText.querySelectorAll('pre[data-xiaobaix-bound="true"]').forEach(pre => {
pre.classList.remove('xb-show');
pre.removeAttribute('data-xbfinal');
pre.removeAttribute('data-xbhash');
delete pre.dataset.xbFinal;
delete pre.dataset.xbHash;
delete pre.dataset.xiaobaixBound;
pre.style.display = '';
});
}
}
let messageListenerBound = false;
export function initRenderer() {
const settings = getSettings();
if (!settings.enabled) return;
try { xbLog.info(MODULE_ID, 'initRenderer'); } catch {}
if (settings.renderEnabled !== false) {
ensureHideCodeStyle(true);
setActiveClass(true);
}
events.on(event_types.GENERATION_STARTED, () => {
isGenerating = true;
});
events.on(event_types.GENERATION_ENDED, () => {
isGenerating = false;
const ctx = getContext();
const lastId = ctx.chat?.length - 1;
if (lastId != null && lastId >= 0) {
setTimeout(() => {
processMessageById(lastId, true);
}, 60);
}
});
events.on(event_types.MESSAGE_RECEIVED, (data) => {
setTimeout(() => {
const messageId = typeof data === 'object' ? data.messageId : data;
if (messageId != null) {
processMessageById(messageId, true);
}
}, 300);
});
events.on(event_types.MESSAGE_UPDATED, (data) => {
const messageId = typeof data === 'object' ? data.messageId : data;
if (messageId != null) {
processMessageById(messageId, true);
}
});
events.on(event_types.MESSAGE_EDITED, (data) => {
const messageId = typeof data === 'object' ? data.messageId : data;
if (messageId != null) {
processMessageById(messageId, true);
}
});
events.on(event_types.MESSAGE_DELETED, (data) => {
const messageId = typeof data === 'object' ? data.messageId : data;
if (messageId != null) {
invalidateMessage(messageId);
}
});
events.on(event_types.MESSAGE_SWIPED, (data) => {
setTimeout(() => {
const messageId = typeof data === 'object' ? data.messageId : data;
if (messageId != null) {
processMessageById(messageId, true);
}
}, 10);
});
events.on(event_types.USER_MESSAGE_RENDERED, (data) => {
setTimeout(() => {
const messageId = typeof data === 'object' ? data.messageId : data;
if (messageId != null) {
processMessageById(messageId, true);
}
}, 10);
});
events.on(event_types.CHARACTER_MESSAGE_RENDERED, (data) => {
setTimeout(() => {
const messageId = typeof data === 'object' ? data.messageId : data;
if (messageId != null) {
processMessageById(messageId, true);
}
}, 10);
});
events.on(event_types.CHAT_CHANGED, () => {
isGenerating = false;
invalidateAll();
setTimeout(() => {
processExistingMessages();
}, 100);
});
if (!messageListenerBound) {
// eslint-disable-next-line no-restricted-syntax -- message bridge for iframe renderers.
window.addEventListener('message', handleIframeMessage);
messageListenerBound = true;
}
setTimeout(processExistingMessages, 100);
}
export function cleanupRenderer() {
try { xbLog.info(MODULE_ID, 'cleanupRenderer'); } catch {}
events.cleanup();
if (messageListenerBound) {
window.removeEventListener('message', handleIframeMessage);
messageListenerBound = false;
}
ensureHideCodeStyle(false);
setActiveClass(false);
document.querySelectorAll('pre[data-xiaobaix-bound="true"]').forEach(pre => {
pre.classList.remove('xb-show');
pre.removeAttribute('data-xbfinal');
pre.removeAttribute('data-xbhash');
delete pre.dataset.xbFinal;
delete pre.dataset.xbHash;
pre.style.display = '';
delete pre.dataset.xiaobaixBound;
});
invalidateAll();
isGenerating = false;
pendingHeight = null;
pendingRec = null;
lastApplyTs = 0;
}
export function isCurrentlyGenerating() {
return isGenerating;
}
export { shrinkRenderedWindowFull, shrinkRenderedWindowForLastMessage };