OpenWebUI Force HTML Image Renderer

OpenWebUI 强制 HTML 图片渲染, 识别 <img> 标签,基于 OpenWebUI v0.6.10 开发。

As of 25.05.2025. See ბოლო ვერსია.

// ==UserScript==
// @name         OpenWebUI Force HTML Image Renderer
// @version      1.0.0
// @description  OpenWebUI 强制 HTML 图片渲染, 识别 <img> 标签,基于 OpenWebUI v0.6.10 开发。
// @author       B3000Kcn
// @match        https://your.openwebui.url/*
// @run-at       document-idle
// @license      MIT
// @namespace https://greasyfork.org/users/1474401
// ==/UserScript==

(function() {
    'use strict';

    const SCRIPT_VERSION = '1.0.0';
    const TARGET_DIV_DEPTH = 22;
    // 移除了启动日志,如果需要可以取消下面一行的注释
    // console.log(`OpenWebUI 图片强制渲染脚本 v${SCRIPT_VERSION} 已启动。`);

    const PROCESSED_CLASS = `html-img-rendered-v1-0-0`; // 建议与脚本版本匹配

    const ALLOWED_INNERHTML_MOD_TAGS = ['P', 'SPAN', 'PRE', 'CODE', 'LI', 'BLOCKQUOTE', 'FIGCAPTION', 'LABEL', 'SMALL', 'STRONG', 'EM', 'B', 'I', 'U', 'SUB', 'SUP', 'MARK', 'DEL', 'INS', 'TD', 'TH', 'DT', 'DD'];
    const TAGS_TO_SKIP_PROCESSING = ['SCRIPT', 'STYLE', 'HEAD', 'META', 'LINK', 'TITLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'BUTTON', 'SELECT', 'FORM', 'IFRAME', 'VIDEO', 'AUDIO', 'CANVAS', 'SVG', 'IMG'];

    const escapedImgTagRegex = /&lt;img\s+([^&]*?)&gt;/gi;
    const directImgTagRegex = /<img\s+([^>]*)>/gi;

    function getElementDepth(element) {
        let depth = 0; let el = element;
        while (el) { depth++; el = el.parentElement; }
        return depth;
    }

    function getAttributeValue(attributesString, attributeName) {
        if (!attributesString || !attributeName) return null;
        try {
            const regex = new RegExp(`${attributeName}\\s*=\\s*(["'])(.*?)\\1`, 'i');
            const match = attributesString.match(regex);
            return match ? match[2] : null;
        } catch (e) {
            // 在生产版本中,可以选择只在内部处理错误,不向控制台输出,或者保留关键错误输出
            // console.error(`${SCRIPT_VERSION} 错误: getAttributeValue 执行失败 for ${attributeName}`, e);
            return null;
        }
    }

    function processImageTag(match, attributesStringOriginal, isEscaped) {
        let matchPreview = ''; // 主要用于日志,生产版中可以考虑移除或简化
        if (typeof match === 'string' && match.length > 0) {
            matchPreview = match.substring(0, 60).replace(/\s+/g,' ') + (match.length > 60 ? "..." : "");
        } else {
            return match; // 如果match无效,直接返回
        }

        if (!isEscaped && match.startsWith("<img") && match.includes('data-force-rendered="true"')) {
            return match;
        }
        if (!isEscaped && match.startsWith("<div class=\"userscript-image-paragraph\"") && match.includes('data-force-rendered="true"')) {
            return match;
        }

        const attributesString = attributesStringOriginal.trim();
        const src = getAttributeValue(attributesString, 'src');
        try {
            let isSrcInvalid = true;
            if (src && typeof src === 'string') {
                if (src.toLowerCase().startsWith('javascript:')) {
                    // console.warn(`警告: 检测到 javascript: SRC 并已阻止.`); // 移除具体日志前缀
                } else { isSrcInvalid = false; }
            } else {
                // if (src !== null || (attributesString && attributesString.includes("src="))) {
                //      console.warn(`警告: 无效或缺失 src. src: ${src}, 类型: ${typeof src}.`);
                // }
            }
            if (isSrcInvalid) {
                return match;
            }
            let alt, width, height;
            try {
                alt = getAttributeValue(attributesString, 'alt');
                width = getAttributeValue(attributesString, 'width');
                height = getAttributeValue(attributesString, 'height');

                const img = document.createElement('img');
                img.src = src;
                img.alt = alt ? alt : `用户HTML图片 (${isEscaped ? '转义' : '直接'}) v${SCRIPT_VERSION}`;
                img.setAttribute('data-force-rendered', 'true');
                if (width) { if (width.includes('%')) { img.style.width = width; } else { img.setAttribute('width', width.replace(/px$/i, '')); } }
                if (height) { if (height.includes('%')) { img.style.height = height; } else { img.setAttribute('height', height.replace(/px$/i, '')); } }
                img.style.maxWidth = '100%'; img.style.maxHeight = '300px'; img.style.display = 'block';
                img.onerror = function() {
                    // console.warn(`警告: 图片加载失败: ${this.src}. 将隐藏元素.`); // 移除具体日志前缀
                    this.style.display = 'none'; // 保留隐藏功能
                };
                const imgHTML = img.outerHTML;
                const pWrappedHTML = `<p class="userscript-image-paragraph" style="margin: 0.5em 0; line-height: normal;">${imgHTML}</p>`;
                return pWrappedHTML;
            } catch (eInner) {
                // console.error(`错误: 在主 try 块中发生错误, 返回原始match.`, eInner);
                return match;
            }
        } catch (eOuter) {
            // console.error(`错误: 在最外层 try 块中发生错误, 返回原始match.`, eOuter);
            return match;
        }
    }

    function renderImagesInElement(element, forcedByObserver = false) {
        if (!element || typeof element.classList === 'undefined' || TAGS_TO_SKIP_PROCESSING.includes(element.tagName.toUpperCase()) || element.isContentEditable) {
            return;
        }
        const isTargetDepthDiv = (element.tagName.toUpperCase() === 'DIV' && getElementDepth(element) === TARGET_DIV_DEPTH);
        let isAllowedNonDiv = ALLOWED_INNERHTML_MOD_TAGS.includes(element.tagName.toUpperCase());

        if (element.classList.contains(PROCESSED_CLASS)) {
            if ( (isTargetDepthDiv && forcedByObserver) || (isAllowedNonDiv && forcedByObserver) ) {
                 element.classList.remove(PROCESSED_CLASS);
            } else { return; }
        }
        if (typeof element.innerHTML !== 'string') { return; }

        let htmlContent = element.innerHTML;
        let madeChange = false;
        const originalHtmlContent = htmlContent;

        const replacerFunc = (type) => (fullMatch, attrs) => {
            const newImgHtml = processImageTag(fullMatch, attrs, type === 'escaped');
            if (newImgHtml !== fullMatch) { madeChange = true; }
            return newImgHtml;
        };

        escapedImgTagRegex.lastIndex = 0;
        if (htmlContent.includes('&lt;img')) {
            if (escapedImgTagRegex.test(htmlContent)) {
                escapedImgTagRegex.lastIndex = 0;
                htmlContent = htmlContent.replace(escapedImgTagRegex, replacerFunc('escaped'));
            }
        }
        directImgTagRegex.lastIndex = 0;
        if (htmlContent.includes('<img')) {
            if (directImgTagRegex.test(htmlContent)) {
                directImgTagRegex.lastIndex = 0;
                const tempHtmlContent = htmlContent.replace(directImgTagRegex, replacerFunc('direct'));
                if (htmlContent !== tempHtmlContent) { htmlContent = tempHtmlContent; }
            }
        }

        let allowFinalModification = isAllowedNonDiv || isTargetDepthDiv;
        if (madeChange) {
            if (allowFinalModification) {
                // if (isTargetDepthDiv && !isAllowedNonDiv) {
                //      console.info(`DIV 在目标深度 ${TARGET_DIV_DEPTH},允许修改.`);
                // }
                if (originalHtmlContent !== htmlContent) {
                    element.innerHTML = htmlContent;
                }
                if (!isTargetDepthDiv) {
                    if (!element.classList.contains(PROCESSED_CLASS)) { element.classList.add(PROCESSED_CLASS); }
                }
            } else {
                 if (!isTargetDepthDiv && !element.classList.contains(PROCESSED_CLASS)) { element.classList.add(PROCESSED_CLASS); }
            }
        } else if (!element.classList.contains(PROCESSED_CLASS) &&
                   (originalHtmlContent.includes('&lt;img') || originalHtmlContent.includes('<img'))) {
            if (!isTargetDepthDiv) { element.classList.add(PROCESSED_CLASS); }
        }
    }

    // 单独、正确地定义 observerCallback, initialScan, activateScript
    const observerCallback = (mutationsList) => {
        for (const mutation of mutationsList) {
            if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
                mutation.addedNodes.forEach(node => {
                    if (node.nodeType === Node.ELEMENT_NODE) { renderImagesInElement(node, true);
                        if (typeof node.querySelectorAll === 'function') {
                            node.querySelectorAll(ALLOWED_INNERHTML_MOD_TAGS.join(',')).forEach(child => {
                                if (child.nodeType === Node.ELEMENT_NODE) renderImagesInElement(child, true);
                            });
                            node.querySelectorAll('div').forEach(childDiv => {
                                if (childDiv.nodeType === Node.ELEMENT_NODE && getElementDepth(childDiv) === TARGET_DIV_DEPTH) {
                                    renderImagesInElement(childDiv, true);
                                }
                            });
                        }
                    }
                });
            } else if (mutation.type === 'characterData') {
                if (mutation.target && mutation.target.parentElement) { renderImagesInElement(mutation.target.parentElement, true); }
            }
        }
    };

    const observer = new MutationObserver(observerCallback);
    const observerConfig = { childList: true, subtree: true, characterData: true };

    function initialScan() {
        const allRelevantSelectors = ALLOWED_INNERHTML_MOD_TAGS.join(',') + ',div';
        document.querySelectorAll(allRelevantSelectors).forEach(el => {
            if (el.offsetParent !== null || document.body.contains(el)) {
                renderImagesInElement(el, false);
            }
        });
    }

    function activateScript() {
        setTimeout(() => {
            initialScan();
            observer.observe(document.body, observerConfig);
        }, 2000);
    }

    if (document.readyState === 'complete' || document.readyState === 'interactive') { activateScript(); }
    else { document.addEventListener('DOMContentLoaded', activateScript, { once: true }); }

})();