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(' 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 += ``; hints += ``; } 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 += ``; } const css = (html.match(/https?:\/\/[^"'()\s]+\.css/i) || [])[0]; if (css) { preload += ``; } const img = (html.match(/https?:\/\/[^"'()\s]+\.(?:png|jpg|jpeg|webp|gif|svg)/i) || [])[0]; if (img) { preload += ``; } 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 ? `` : ""; const headHints = buildResourceHints(html); const vhFix = ``; // 内联脚本,按顺序:wrapper(callGenerate) -> base(高度+STscript) const scripts = wrapperToggle ? `` : ``; if (html.includes('')) return html.replace('', `${scripts}${baseTag}${headHints}${vhFix}`); if (html.includes('')) return html.replace('', `${scripts}${baseTag}${headHints}${vhFix}`); return html.replace('${scripts}${baseTag}${headHints}${vhFix} ${scripts} ${baseTag} ${headHints} ${vhFix} ${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 };