AI摘要

文章介绍了在使用AISummary插件时,因多容器导致摘要内容不显示的问题,并提供了遍历所有容器的增强代码及样式调整方案。

引言

在为博客文章实现AI摘要工具时,我选择了 大佬--旋的来AISummary插件增强功能(非常好用)。然而,在按大佬的教程配置完毕后,我发现摘要正文并没有输出,经研究后得知,因为阅读模式的存在,出现了两个摘要容器,而大佬的自定义样式不会处理多个摘要容器,导致第二个摘要容器正文无法正常显示(当时不知道(⊙︿⊙))。本文将详细记录这一问题的排查与解决方案,并在文末给出增强后的完整自定义样式代码。

插件文章链接:Typecho添加文章AI摘要功能(Handsome等全主题适配)By xuan

问题描述

使用环境

  • 博客平台: Typecho
  • 博客主题: bearsimple

具体问题现象

摘要正文未能正常加载

排查过程

分析文章页的HTML文档

可以看到,同时存在两个摘要容器,分别位于阅读模式页面处和文章页面处,并且阅读模式的容器为1号位,且1号位容器内容正常加载,而2号位为空值。故产生的现象为文章处摘要不加载,而阅读模式不会自动被使用,本人对该主题也是刚接触使用,不甚熟悉,故对本人的排查造成了不小的困扰。


分析源代码

主要的逻辑在executeAiSummaryTyping()中,其只处理了第一个容器

function executeAiSummaryTyping() {
    // 【未增强版本】
    // 使用 document.querySelector() 只会获取页面上第一个匹配 '.ai-typewriter-text' 的元素。
    // 这意味着如果页面上有多个AI摘要容器,只有第一个摘要会执行打字机效果。
    const typewriterElement = document.querySelector('.ai-typewriter-text');
    // 获取隐藏的源文本元素。
    const sourceTextElement = document.querySelector('.ai-hidden-text');
    // 定义打字速度。
    const typingSpeed = 50; 
    
    // 如果没有找到对应的元素,则直接返回。
    if (!typewriterElement || !sourceTextElement) return;
    
    // 【未增强版本】
    // 使用全局变量 aiSummaryTypingTimeoutId 来管理定时器。
    // 当页面上有多个摘要时,如果前一个摘要的打字机效果还在进行中,新的打字机效果可能会清除掉旧的定时器,
    // 导致效果异常或只显示一个摘要的打字机效果。
    if (aiSummaryTypingTimeoutId) {
        clearTimeout(aiSummaryTypingTimeoutId);
    }

    // 获取并处理源文本,添加首行缩进。
    let textToType = sourceTextElement.textContent.trim();
    
    if (textToType.length > 0) {
        textToType = '  ' + textToType; // 首行缩进
    }

    // 清空打字机文本内容。
    typewriterElement.textContent = '';
    // 初始化字符索引。
    let charIndex = 0;

    // 定义打字机效果的内部函数。
    function typeNextCharacter() {
        // 如果还有字符未输出。
        if (charIndex < textToType.length) {
            // 逐个添加字符。
            typewriterElement.textContent += textToType.charAt(charIndex);
            charIndex++;
            // 设置定时器,继续输出下一个字符。
            // 【未增强版本】这里仍然使用全局变量来存储定时器ID。
            aiSummaryTypingTimeoutId = setTimeout(typeNextCharacter, typingSpeed);
        } else {
            // 所有字符输出完毕,清除全局定时器ID。
            aiSummaryTypingTimeoutId = null;
        }
    }
    // 启动打字机效果。
    typeNextCharacter();
}

解决方案

对函数做多容器增强

function executeAiSummaryTyping() {
    // 获取页面上所有带有 'aisummary' 类的元素,这些元素代表了不同的AI摘要容器。
    const aiSummaryContainers = document.querySelectorAll('.aisummary');

    // 遍历每一个找到的AI摘要容器。
    aiSummaryContainers.forEach(container => {
        // 在当前容器内部,查找带有 'ai-typewriter-text' 类的元素,这是显示打字机效果的文本区域。
        const typewriterElement = container.querySelector('.ai-typewriter-text');
        // 在当前容器内部,查找带有 'ai-hidden-text' 类的元素,这是存储原始摘要文本的隐藏区域。
        const sourceTextElement = container.querySelector('.ai-hidden-text');
        // 定义打字速度,数值越小,打字速度越快。
        const typingSpeed = 50; 

        // 如果在当前容器中没有找到打字机文本元素或源文本元素,则跳过当前容器,不执行打字机效果。
        if (!typewriterElement || !sourceTextElement) return;

        // 清除可能存在的旧定时器。
        // 这里将定时器ID存储在 'typewriterElement' 自身的属性 'typingTimeoutId' 上,
        // 这样每个打字机元素都有独立的定时器,避免相互干扰,支持多摘要同时运行。
        if (typewriterElement.typingTimeoutId) {
            clearTimeout(typewriterElement.typingTimeoutId);
        }

        // 获取源文本内容并去除首尾空格。
        let textToType = sourceTextElement.textContent.trim();
        
        // 如果源文本不为空,则在文本开头添加两个空格,实现首行缩进效果。
        if (textToType.length > 0) {
            textToType = '  ' + textToType; 
        }

        // 清空打字机文本元素的当前内容,准备开始打字。
        typewriterElement.textContent = '';
        // 初始化字符索引,用于追踪当前打字到哪个字符。
        let charIndex = 0;

        // 定义核心的打字机效果函数。
        const typeNextCharacter = () => {
            // 如果还有未输出的字符。
            if (charIndex < textToType.length) {
                // 将当前字符添加到打字机文本元素中。
                typewriterElement.textContent += textToType.charAt(charIndex);
                // 字符索引递增。
                charIndex++;
                // 设置一个定时器,在指定延迟后再次调用 'typeNextCharacter' 函数,实现逐字输出。
                typewriterElement.typingTimeoutId = setTimeout(typeNextCharacter, typingSpeed);
            } else {
                // 所有字符都已输出完毕,清除该打字机元素的定时器ID。
                typewriterElement.typingTimeoutId = null;
            }
        };
        // 启动打字机效果,开始输出第一个字符。
        typeNextCharacter();
    });
}

并用伪元素实现字符缩进

.ai-typewriter-text {
    display: inline;
    word-wrap: break-word;
    white-space: pre-wrap;
}

/* 缩进样式 */
.ai-typewriter-text::before {
    content: '';
    width: 2em; /* 调整宽度以匹配所需的缩进 */
    height: 1em; /* 高度与字体大小相匹配 */
    float: left;
    margin-top: -0.5em; /* 调整这个值以对齐文本基线 */
}

验证修复效果

可以看到,两个容器都能正常加载数据,且缩进会更为合适
文章链接:效果展示


总结

通过这次经历,我记住了在排查前端页面的渲染加载问题时,可以直接从HTML文档出发,从元素本身出发,这样方便我们由果溯因,从而少走弯路,节省时间和精力。再次感谢大佬,不但开发了如此好用的插件(有类似插件付费提供商),还会积极讨论解决问题,希望我的经验能够帮助到其他遇到类似问题的朋友。

完整自定义样式

<!-- AI摘要样式 - 阅读模式多摘要容器增强版 -->
<style>
/* 摘要容器样式 */
.aisummary {
    background: #f7f7f9;
    border-radius: 12px;
    padding: 12px;
    box-shadow: 0 8px 16px -4px rgba(44, 45, 48, 0.047);
    border: 1px solid #e3e8f7;
    margin: 25px 0 30px;
    color: #333;
    position: relative;
    overflow: hidden;
}

/* 标题样式 */
.ai-header {
    margin-bottom: 10px !important;
    color: #465CEB !important;
    text-align: left !important;
    display: flex !important;
    align-items: center !important;
    text-indent: 0 !important;
    font-weight: bold !important;
    font-size: 17px !important;
}

.ai-header svg {
    margin-right: 8px;
    width: 24px;
    height: 24px;
    stroke: currentColor;
}

/* 文本容器样式 */
.ai-text-container {
    background: #fff;
    border-radius: 8px;
    padding: 12px 15px;
    border: 1px solid #e3e8f7;
    margin-bottom: 10px;
    font-size: 15px;
    line-height: 1.7;
    color: #333;
}

.ai-hidden-text {
    display: none;
}

.ai-typewriter-text {
    display: inline;
    word-wrap: break-word;
    white-space: pre-wrap;
}

/* 缩进样式 */
.ai-typewriter-text::before {
    content: '';
    width: 2em; /* 调整宽度以匹配所需的缩进 */
    height: 1em; /* 高度与字体大小相匹配 */
    float: left;
    margin-top: -0.5em; /* 调整这个值以对齐文本基线 */
}

/* 光标样式及动画 */
.ai-cursor {
    display: inline-block;
    width: 2px;
    height: 1em;
    background-color: #465CEB;
    margin-left: 3px;
    animation: ai-blink 0.7s infinite;
    vertical-align: middle;
}

@keyframes ai-blink {
    0%, 100% { opacity: 1; }
    50% { opacity: 0; }
}

/* 页脚样式 */
.ai-footer {
    font-size: 13px !important;
    color: rgba(60, 60, 67, 0.65) !important;
    font-style: italic !important;
    margin-bottom: 0 !important;
    padding: 0 5px !important;
    text-align: left !important;
    text-indent: 0 !important;
    margin-top: 10px !important;
}

/* 响应式调整 */
@media (max-width: 768px) {
    .aisummary {
        padding: 10px;
        margin: 20px 0 25px;
    }
    .ai-header {
        font-size: 16px !important;
    }
    .ai-header svg {
        width: 22px;
        height: 22px;
        margin-right: 6px;
    }
    .ai-text-container {
        font-size: 14px;
        padding: 10px 12px;
        line-height: 1.65;
    }
    .ai-footer {
        font-size: 12px !important;
        margin-top: 8px !important;
    }
}

/* 暗色模式适配 */
[data-night="night"] .aisummary,
.dark-mode .aisummary,
body.dark .aisummary,
body.night .aisummary,
.night .aisummary,
.night-mode .aisummary,
html.night .aisummary,
.theme-dark .aisummary {
    background: #2c2c2e;
    border-color: #38383a;
    color: #d1d1d1;
    box-shadow: 0 8px 16px -4px rgba(0, 0, 0, 0.15);
}

[data-night="night"] .ai-text-container,
.dark-mode .ai-text-container,
body.dark .ai-text-container,
body.night .ai-text-container,
.night .ai-text-container,
.night-mode .ai-text-container,
html.night .ai-text-container,
.theme-dark .ai-text-container {
    background: #333333;
    border-color: #4a4a4a;
    color: #c8c8c8;
}

[data-night="night"] .ai-header,
.dark-mode .ai-header,
body.dark .ai-header,
body.night .ai-header,
.night .ai-header,
.night-mode .ai-header,
html.night .ai-header,
.theme-dark .ai-header {
    color: #7c89f1 !important;
}

[data-night="night"] .ai-cursor,
.dark-mode .ai-cursor,
body.dark .ai-cursor,
body.night .ai-cursor,
.night .ai-cursor,
.night-mode .ai-cursor,
html.night .ai-cursor,
.theme-dark .ai-cursor {
    background-color: #7c89f1;
}

[data-night="night"] .ai-footer,
.dark-mode .ai-footer,
body.dark .ai-footer,
body.night .ai-footer,
.night .ai-footer,
.night-mode .ai-footer,
html.night .ai-footer,
.theme-dark .ai-footer {
    color: rgba(200, 200, 200, 0.6) !important;
}

/* 手动添加的暗色模式类 */
.aisummary.ai-dark-theme {
    background: #2c2c2e;
    border-color: #38383a;
    color: #d1d1d1;
    box-shadow: 0 8px 16px -4px rgba(0, 0, 0, 0.15);
}

.ai-dark-theme .ai-text-container {
    background: #333333;
    border-color: #4a4a4a;
    color: #c8c8c8;
}

.ai-dark-theme .ai-header {
    color: #7c89f1 !important;
}

.ai-dark-theme .ai-cursor {
    background-color: #7c89f1;
}

.ai-dark-theme .ai-footer {
    color: rgba(200, 200, 200, 0.6) !important;
}
</style>

<!-- AI摘要打字机效果脚本 -->
<script>
    // 全局变量
    let aiSummaryTypingTimeoutId = null;
    let aiSummaryLastProcessedUrl = window.location.href;
    
    // AI摘要主题设置函数
    window.aiSummaryUser = {
        setAiSummaryTheme: function(theme) {
            const summaryElements = document.querySelectorAll('.aisummary');
            if (summaryElements.length > 0) {
                summaryElements.forEach(element => {
                    if (theme === 'dark') {
                        element.classList.add('ai-dark-theme');
                    } else {
                        element.classList.remove('ai-dark-theme');
                    }
                });
            }
        }
    };

    // 检测并同步Handsome主题的夜间模式
    function detectAndSyncTheme() {
        // 检查常见的夜间模式标识
        const isDarkMode = 
            document.body.classList.contains('night') || 
            document.body.classList.contains('dark') || 
            document.documentElement.classList.contains('night') ||
            document.documentElement.getAttribute('data-night') === 'night' ||
            document.querySelector('html').classList.contains('dark-mode') ||
            document.querySelector('[data-theme="dark"]') !== null;
            
        // 应用对应的主题
        if (isDarkMode) {
            window.aiSummaryUser.setAiSummaryTheme('dark');
        } else {
            window.aiSummaryUser.setAiSummaryTheme('light');
        }
    }

    // 打字机效果核心逻辑
    function executeAiSummaryTyping() {
        // 获取所有AI摘要容器
        const aiSummaryContainers = document.querySelectorAll('.aisummary');

        // 遍历每个AI摘要容器
        aiSummaryContainers.forEach(container => {
            // 获取当前容器内的打字机文本元素和隐藏的源文本元素
            const typewriterElement = container.querySelector('.ai-typewriter-text');
            const sourceTextElement = container.querySelector('.ai-hidden-text');
            const typingSpeed = 50; // 打字速度,数值越小越快

            // 如果没有找到对应的元素,则跳过当前容器
            if (!typewriterElement || !sourceTextElement) return;

            // 清除可能存在的旧定时器,防止重复执行
            if (typewriterElement.typingTimeoutId) {
                clearTimeout(typewriterElement.typingTimeoutId);
            }

            // 获取并处理源文本,添加首行缩进
            let textToType = sourceTextElement.textContent.trim();
            if (textToType.length > 0) {
                textToType = '  ' + textToType; // 首行缩进
            }

            // 初始化打字机文本内容和字符索引
            typewriterElement.textContent = '';
            let charIndex = 0;

            // 定义打字机效果函数
            const typeNextCharacter = () => {
                // 如果还有字符未输出
                if (charIndex < textToType.length) {
                    // 逐个添加字符到打字机文本元素
                    typewriterElement.textContent += textToType.charAt(charIndex);
                    charIndex++;
                    // 设置定时器,继续输出下一个字符
                    typewriterElement.typingTimeoutId = setTimeout(typeNextCharacter, typingSpeed);
                } else {
                    // 所有字符输出完毕,清除定时器ID
                    typewriterElement.typingTimeoutId = null;
                }
            };
            // 启动打字机效果
            typeNextCharacter();
        });
    }

    // 页面加载后执行
    document.addEventListener('DOMContentLoaded', function() {
        // 延迟执行,确保页面元素加载完毕
        setTimeout(() => {
            executeAiSummaryTyping(); // 执行打字机效果
            detectAndSyncTheme();     // 检测并同步主题
        }, 300);
    });

    // PJAX/SPA兼容处理
    setInterval(function() {
        if (window.location.href !== aiSummaryLastProcessedUrl) {
            aiSummaryLastProcessedUrl = window.location.href;
            // 延迟执行,确保新内容加载完毕
            setTimeout(() => {
                executeAiSummaryTyping(); // 重新执行打字机效果
                detectAndSyncTheme();     // 重新检测并同步主题
            }, 1000);
        }
    }, 100);
    
    // 监听主题切换
    const observer = new MutationObserver(function(mutations) {
        mutations.forEach(function(mutation) {
            if (mutation.attributeName === 'class' || mutation.attributeName === 'data-night') {
                detectAndSyncTheme();
            }
        });
    });
    
    // 开始观察文档和body元素上的class变化
    document.addEventListener('DOMContentLoaded', function() {
        observer.observe(document.documentElement, { attributes: true });
        observer.observe(document.body, { attributes: true });
    });
    
    // 兼容Handsome主题的夜间模式切换事件
    document.addEventListener('DOMContentLoaded', function() {
        // 尝试找到夜间模式切换按钮并监听点击事件
        const nightModeButtons = document.querySelectorAll('[data-toggle-theme], .theme-toggle, #nightmode, .night-mode-btn');
        if (nightModeButtons.length > 0) {
            nightModeButtons.forEach(button => {
                button.addEventListener('click', function() {
                    // 延迟检测,确保主题切换完成
                    setTimeout(detectAndSyncTheme, 100);
                });
            });
        }
    });

    // 如果存在全局主题切换函数,拦截它们以同步状态
    if (typeof window.switchNightMode === 'function') {
        const originalSwitchNightMode = window.switchNightMode;
        window.switchNightMode = function() {
            originalSwitchNightMode.apply(this, arguments);
            setTimeout(detectAndSyncTheme, 100);
        };
    }
</script>