paper-burner/js/chatbot/renderers/chatbot-mermaid-renderer.js

737 lines
36 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.

// js/chatbot/chatbot-mermaid-renderer.js
/**
* 本地 HTML 转义函数(用于错误消息防护)
* 如果 ChatbotUtils.escapeHtml 不可用时的降级方案
* @param {string} str - 需要转义的字符串
* @returns {string} 转义后的安全字符串
*/
function localEscapeHtml(str) {
if (typeof str !== 'string') return '';
return str.replace(/[&<>"']/g, function (c) {
return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;','\'':'&#39;'}[c];
});
}
/**
* 安全地转义 HTML优先使用 ChatbotUtils降级到本地实现
* @param {string} str - 需要转义的字符串
* @returns {string} 转义后的安全字符串
*/
function safeEscapeHtml(str) {
if (typeof window.ChatbotUtils !== 'undefined' && typeof window.ChatbotUtils.escapeHtml === 'function') {
return window.ChatbotUtils.escapeHtml(str);
}
return localEscapeHtml(str);
}
/**
* 渲染聊天内容中的所有 Mermaid 代码块。
*
* 主要流程:
* 1. 查找所有 code.language-mermaid 代码块。
* 2. 对每个代码块:
* - 清理和修正 Mermaid 代码(如去除 HTML 标签、<br>等)。
* - 创建 div.mermaid 容器设置唯一ID。
* - 使用 mermaid.init 渲染 SVG。
* - 渲染成功则显示 SVG并添加"放大"按钮。
* - 渲染失败则回退显示上一次成功的 SVG或显示错误信息。
* - 支持多次尝试渲染(异步加载 mermaid.js 时)。
* @param {HTMLElement} chatBodyElement - 聊天消息容器 DOM 元素。
*/
async function renderAllMermaidBlocksInternal(chatBodyElement) {
if (!window.mermaidLoaded || typeof window.mermaid === 'undefined') return;
if (!chatBodyElement) {
console.warn('ChatbotMermaidRenderer: chatBodyElement 为空,跳过 Mermaid 渲染。');
return;
}
// 按钮添加辅助函数(避免代码重复)
const addMermaidButtons = (mermaidDiv, currentCodeForError) => {
setTimeout(() => {
try {
// 查看代码按钮
if (!mermaidDiv.querySelector('.mermaid-code-btn')) {
const codeBtn = document.createElement('button');
codeBtn.className = 'mermaid-action-btn mermaid-code-btn';
codeBtn.title = '查看/隐藏代码';
codeBtn.innerHTML = `<svg viewBox="0 0 20 20" fill="currentColor" width="18" height="18"><path d="M10 12.5a2.5 2.5 0 100-5 2.5 2.5 0 000 5z"></path><path fill-rule="evenodd" d="M.664 10.59a1.651 1.651 0 010-1.186A10.004 10.004 0 0110 3c4.257 0 7.893 2.66 9.336 6.404a1.651 1.651 0 010 1.186A10.004 10.004 0 0110 17c-4.257 0-7.893-2.66-9.336-6.404zM10 15a5 5 0 100-10 5 5 0 000 10z" clip-rule="evenodd"></path></svg>`;
codeBtn.style.cssText = `
position:absolute; top:12px; right:48px;
background: transparent; color: #64748b;
border: none; border-radius: 6px; padding: 4px;
cursor: pointer; opacity:0.7; transition: opacity 0.2s, background-color 0.2s;
display:flex; align-items:center; justify-content:center;
`;
codeBtn.onmouseover = function() { this.style.opacity = '1'; this.style.backgroundColor = 'rgba(0,0,0,0.05)'; };
codeBtn.onmouseout = function() { this.style.opacity = '0.7'; this.style.backgroundColor = 'transparent'; };
codeBtn.onclick = function() {
const codeContainer = mermaidDiv.querySelector('.mermaid-original-code');
if (codeContainer) {
if (codeContainer.style.display === 'none') {
codeContainer.style.display = 'block';
} else {
codeContainer.style.display = 'none';
}
} else {
const originalCode = document.createElement('pre');
originalCode.className = 'mermaid-original-code';
originalCode.style.cssText = `
position:relative;
background:#f8f9fa;
border:1px solid #e9ecef;
border-radius:6px;
padding:12px;
margin-top:12px;
font-family:monospace;
font-size:13px;
white-space:pre-wrap;
word-break:break-all;
max-height:300px;
overflow-y:auto;
`;
originalCode.textContent = currentCodeForError;
const copyBtn = document.createElement('button');
copyBtn.textContent = '复制';
copyBtn.style.cssText = `
position:absolute;top:8px;right:8px;
background:#e9ecef;border:none;
border-radius:4px;padding:2px 8px;
font-size:12px;cursor:pointer;
`;
copyBtn.onclick = function(e) {
e.stopPropagation();
navigator.clipboard.writeText(currentCodeForError)
.then(() => {
const originalText = copyBtn.textContent;
copyBtn.textContent = '已复制!';
setTimeout(() => { copyBtn.textContent = originalText; }, 2000);
})
.catch(err => {
console.error('复制失败:', err);
alert('复制失败: ' + err);
});
};
originalCode.appendChild(copyBtn);
mermaidDiv.appendChild(originalCode);
}
};
mermaidDiv.appendChild(codeBtn);
}
// 放大按钮(完整功能复用)
if (!mermaidDiv.querySelector('.mermaid-zoom-btn')) {
const zoomBtn = document.createElement('button');
zoomBtn.className = 'mermaid-action-btn mermaid-zoom-btn';
zoomBtn.title = '放大查看';
zoomBtn.innerHTML = `<svg viewBox="0 0 20 20" fill="currentColor" width="18" height="18"><path fill-rule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clip-rule="evenodd"></path><path fill-rule="evenodd" d="M9 5.5a.5.5 0 01.5.5v2.5h2.5a.5.5 0 010 1h-2.5v2.5a.5.5 0 01-1 0v-2.5h-2.5a.5.5 0 010-1h2.5v-2.5a.5.5 0 01.5-.5z" clip-rule="evenodd"></path></svg>`;
zoomBtn.style.cssText = `
position:absolute; top:12px; right:12px;
background: transparent; color: #64748b;
border: none; border-radius: 6px; padding: 4px;
cursor: pointer; opacity:0.7; transition: opacity 0.2s, background-color 0.2s;
display:flex; align-items:center; justify-content:center;
`;
zoomBtn.onmouseover = function() { this.style.opacity = '1'; this.style.backgroundColor = 'rgba(0,0,0,0.05)'; };
zoomBtn.onmouseout = function() { this.style.opacity = '0.7'; this.style.backgroundColor = 'transparent'; };
zoomBtn.onclick = function() {
try {
// 创建遮罩和弹窗,显示 SVG 大图
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;z-index:999999;left:0;top:0;width:100vw;height:100vh;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;padding:20px;box-sizing:border-box;';
const popup = document.createElement('div');
popup.style.cssText = 'background:var(--chatbot-bg, #fff);padding:24px;border-radius:12px;box-shadow:0 10px 30px rgba(0,0,0,0.1), 0 0 0 1px rgba(0,0,0,0.05);width:95vw;max-width:1200px;height:90vh;overflow:auto;position:relative;display:flex;flex-direction:column;align-items:center;';
const title = document.createElement('div');
title.textContent = 'Mermaid 图表预览';
title.style.cssText = 'font-weight:bold;font-size:18px;margin-bottom:18px;';
popup.appendChild(title);
const svgInMermaidDiv = mermaidDiv.querySelector('svg');
if (svgInMermaidDiv) {
const svgClone = svgInMermaidDiv.cloneNode(true);
svgClone.style.width = '100%';
svgClone.style.maxWidth = '100%';
svgClone.style.height = 'auto';
svgClone.style.flexGrow = '1';
svgClone.style.display = 'block';
svgClone.style.margin = '0 auto 16px auto';
popup.appendChild(svgClone);
// 弹窗底部操作按钮导出PNG、SVG、代码、Mermaid.live
const popupActions = document.createElement('div');
popupActions.style.cssText = 'display:flex; flex-wrap:wrap; gap:12px; justify-content:center; padding-top:16px; border-top: 1px solid rgba(0,0,0,0.08); width:100%;';
// Helper function to create icon buttons for popup
const createPopupActionButton = (btnTitle, svgIcon, onClickAction) => {
const button = document.createElement('button');
button.title = btnTitle;
button.innerHTML = svgIcon;
button.style.cssText = `
background: rgba(0,0,0,0.05); color: #334155;
border: none; border-radius: 8px; padding: 8px 12px;
cursor: pointer; transition: background-color 0.2s, color 0.2s;
display: flex; align-items: center; gap: 6px; font-size: 13px;
`;
button.onmouseover = function() { this.style.backgroundColor = 'rgba(0,0,0,0.1)'; };
button.onmouseout = function() { this.style.backgroundColor = 'rgba(0,0,0,0.05)'; };
button.onclick = onClickAction;
popupActions.appendChild(button);
return button;
};
// Icons (Heroicons - Outline)
const iconDownload = '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" width="18" height="18"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" /></svg>';
const iconCode = '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" width="18" height="18"><path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" /></svg>';
const iconExternalLink = '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" width="18" height="18"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" /></svg>';
const iconPhoto = '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" width="18" height="18"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.158 0L10.5 9.75M16.5 12L18 10.5m-1.5 1.5l-1.5-1.5m0 0L13.5 9.75M12 12.75l1.5-1.5" /></svg>';
// Export PNG Button (使用 html2canvas)
const exportPngBtn = createPopupActionButton('导出PNG', iconPhoto + 'PNG', async function() {
try {
exportPngBtn.innerHTML = iconPhoto + '导出中...';
exportPngBtn.disabled = true;
// 检查 html2canvas 是否可用
if (typeof html2canvas === 'undefined') {
throw new Error('html2canvas 库未加载');
}
// 创建一个临时容器来包裹 SVG
const tempContainer = document.createElement('div');
tempContainer.style.cssText = 'position: absolute; left: -9999px; top: 0; background: white; padding: 20px;';
const svgForExport = svgClone.cloneNode(true);
// 获取 SVG 的原始尺寸
const svgRect = svgClone.getBoundingClientRect();
const originalWidth = svgRect.width || 800;
const originalHeight = svgRect.height || 600;
// 放大 SVG 以提高清晰度
const scaleFactor = 4; // 4倍放大
svgForExport.setAttribute('width', originalWidth * scaleFactor);
svgForExport.setAttribute('height', originalHeight * scaleFactor);
svgForExport.style.display = 'block';
svgForExport.style.width = (originalWidth * scaleFactor) + 'px';
svgForExport.style.height = (originalHeight * scaleFactor) + 'px';
tempContainer.appendChild(svgForExport);
document.body.appendChild(tempContainer);
// 使用 html2canvas 截图容器(高分辨率)
const canvas = await html2canvas(tempContainer, {
backgroundColor: '#ffffff',
scale: 1, // SVG 已经放大了,这里用 1 即可
logging: false,
useCORS: true,
allowTaint: true,
width: originalWidth * scaleFactor + 40, // 加上 padding
height: originalHeight * scaleFactor + 40
});
// 清理临时容器
document.body.removeChild(tempContainer);
// 下载 PNG
canvas.toBlob(function(blob) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'mermaid-diagram.png';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
exportPngBtn.innerHTML = iconPhoto + '导出PNG';
exportPngBtn.disabled = false;
}, 'image/png');
} catch (e) {
console.error('PNG 导出失败:', e);
alert('PNG 导出失败: ' + (e.message || e) + '\n\n建议先导出 SVG然后使用在线工具转换。');
exportPngBtn.innerHTML = iconPhoto + '导出PNG';
exportPngBtn.disabled = false;
}
});
exportPngBtn.innerHTML = iconPhoto + '导出PNG';
// Export SVG Button
const exportSvgBtn = createPopupActionButton('导出SVG', iconDownload + 'SVG', function() {
try {
// 克隆 SVG 用于导出
const svgCloneForExport = svgClone.cloneNode(true);
// 复制所有计算后的样式到内联样式
const copyComputedStyles = (source, target) => {
const sourceElements = source.querySelectorAll('*');
const targetElements = target.querySelectorAll('*');
for (let i = 0; i < sourceElements.length && i < targetElements.length; i++) {
const computedStyle = window.getComputedStyle(sourceElements[i]);
const cssText = computedStyle.cssText;
if (cssText) {
targetElements[i].setAttribute('style', cssText);
}
}
};
copyComputedStyles(svgClone, svgCloneForExport);
const serializer = new XMLSerializer();
let svgString = serializer.serializeToString(svgCloneForExport);
// 添加 XML 声明
svgString = '<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n' + svgString;
// 确保有正确的命名空间
if (!svgString.includes('xmlns="http://www.w3.org/2000/svg"')) {
svgString = svgString.replace(/<svg/, '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"');
}
const blob = new Blob([svgString], {type: 'image/svg+xml;charset=utf-8'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'mermaid-diagram.svg';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 100);
} catch (e) {
console.error('SVG 导出失败:', e);
alert('SVG 导出失败: ' + (e.message || e));
}
});
exportSvgBtn.innerHTML = iconDownload + '导出SVG';
// Export Code Button
const exportCodeBtn = createPopupActionButton('导出代码', iconCode + 'MMD', function() {
const blob = new Blob([currentCodeForError], {type: 'text/plain'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'mermaid-code.mmd';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
});
exportCodeBtn.innerHTML = iconCode + '导出MMD';
// Open in Mermaid.live Button
const openLiveBtn = createPopupActionButton('在Mermaid.live中打开', iconExternalLink + 'Mermaid.live', function() {
const data = { code: currentCodeForError, mermaid: { theme: 'default' } };
const json = JSON.stringify(data);
const encoded = btoa(unescape(encodeURIComponent(json)));
const liveUrl = `https://mermaid.live/edit#${encoded}`;
window.open(liveUrl, '_blank');
});
openLiveBtn.innerHTML = iconExternalLink + 'Mermaid.live';
popup.appendChild(popupActions);
}
// 关闭按钮
const closeBtn = document.createElement('button');
closeBtn.title = '关闭';
closeBtn.innerHTML = `<svg viewBox="0 0 20 20" fill="currentColor" width="20" height="20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clip-rule="evenodd"></path></svg>`;
closeBtn.style.cssText = `
position:absolute; top:16px; right:16px;
background: transparent; color: #64748b;
border: none; border-radius: 50%; padding: 6px;
cursor: pointer; opacity:0.7; transition: opacity 0.2s, background-color 0.2s;
display:flex; align-items:center; justify-content:center;
`;
closeBtn.onmouseover = function() { this.style.opacity = '1'; this.style.backgroundColor = 'rgba(0,0,0,0.05)';};
closeBtn.onmouseout = function() { this.style.opacity = '0.7'; this.style.backgroundColor = 'transparent';};
closeBtn.onclick = function() { document.body.removeChild(overlay); };
popup.appendChild(closeBtn);
overlay.appendChild(popup);
document.body.appendChild(overlay);
} catch (e) {
alert('放大预览弹窗出错:'+(e.message||e));
}
};
mermaidDiv.appendChild(zoomBtn);
}
} catch (btnError) {
console.error('添加按钮时发生错误:', btnError);
}
}, 100);
};
// 查找所有 mermaid 代码块code.language-mermaid 或 pre code.language-mermaid
const mermaidBlocks = chatBodyElement.querySelectorAll('code.language-mermaid, pre code.language-mermaid');
// 使用 for...of 替代 forEach确保顺序执行避免竞态条件
let blockIndex = 0;
for (const block of mermaidBlocks) {
const idx = blockIndex++;
let currentCodeForError = '';
try {
// 检查是否已经渲染过(防止重复渲染)
if (block.hasAttribute('data-mermaid-rendered')) {
console.log(`Mermaid 代码块 ${idx} 已渲染,跳过`);
continue;
}
// 标记为正在渲染
block.setAttribute('data-mermaid-rendered', 'true');
// 增强的代码清理和预处理
let rawCode = block.textContent || '';
if (!rawCode.trim()) {
console.warn('ChatbotMermaidRenderer: 空的Mermaid代码块跳过处理');
return;
}
currentCodeForError = rawCode; // Assign for potential error reporting
// 1. 将 <br> 标签替换为换行
rawCode = rawCode.replace(/<br\s*\/?\>/gi, '\n');
// 2. 移除所有 HTML 标签,但保留内容
rawCode = rawCode.replace(/<[^>]+>/g, '');
// 3. 移除零宽字符和特殊空白字符
rawCode = rawCode.replace(/[\u200B-\u200D\uFEFF]/g, '');
// 4. 统一换行符为 \n
rawCode = rawCode.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
// 5. 移除多余的空行超过2个连续换行
rawCode = rawCode.replace(/\n{3,}/g, '\n\n');
// 6. 移除每行首尾空白,但保持缩进结构
rawCode = rawCode.split('\n').map(line => line.trimEnd()).join('\n');
// 7. **关键修正:处理节点标签内的多行文本**
// Mermaid 不支持 [...] 内的换行符,需要将多行标签转为单行
rawCode = rawCode.replace(/([A-Za-z0-9_]+)\[([^\]]+)\]/g, (match, nodeId, labelContent) => {
// 将标签内的换行替换为空格
let cleanLabel = labelContent.replace(/\n+/g, ' ');
// 压缩多余空格
cleanLabel = cleanLabel.replace(/\s+/g, ' ').trim();
// **替换特殊符号为全角(避免 Mermaid 解析错误)**
cleanLabel = cleanLabel
.replace(/\[/g, '') // 方括号左
.replace(/\]/g, '') // 方括号右
.replace(/\(/g, '')
.replace(/\)/g, '')
.replace(/\|/g, '');
// 限制标签长度最多80字符
if (cleanLabel.length > 80) {
cleanLabel = cleanLabel.substring(0, 77) + '...';
}
return `${nodeId}[${cleanLabel}]`;
});
// 8. 修正常见的语法错误
// 8.1 修正箭头语法(-- > 改为 -->
rawCode = rawCode.replace(/--\s+>/g, '-->');
rawCode = rawCode.replace(/<\s+--/g, '<--');
// 8.2 修正节点定义后多余的节点名A[text]A --> B 改为 A[text] --> B
// 必须在 8.3 之前执行,避免误判
// 匹配节点ID + [ + 内容(可能包含全角]) + ] + 重复的节点ID
rawCode = rawCode.replace(/([A-Za-z0-9_]+)\[([^\]]*(?:[^\]]*)*)\]\1(?=\s|$)/g, '$1[$2]');
// 8.3 修正节点定义后缺少空格的问题(]D --> 改为 ]\nD -->
// 只处理半角右括号后紧跟字母的情况
rawCode = rawCode.replace(/\]([A-Za-z0-9_]+)(\s+-->)/g, ']\n$1$2');
rawCode = rawCode.replace(/\]([A-Za-z0-9_]+)(\s*$)/gm, ']\n$1');
// 8.4 修复缺少箭头的节点连接
// 修复菱形节点缺少结束花括号的情况(如 J{文本 K[...] 改为 J{文本} --> K[...]
rawCode = rawCode.replace(/\{([^}]*?)\s{2,}([A-Z][A-Za-z0-9_]*)\[/g, '{$1} --> $2[');
// 修复 } [ 或 }[ 的情况(菱形节点后缺少箭头)
rawCode = rawCode.replace(/\}(\s{2,}|\s*)\[/g, '} --> [');
// 修复 ] [ 的情况方括号节点后缺少箭头至少2个空格
rawCode = rawCode.replace(/\](\s{2,})\[/g, '] --> [');
// 修复 ) [ 的情况(圆括号节点后缺少箭头)
rawCode = rawCode.replace(/\)(\s{2,})\[/g, ') --> [');
// 8.5 移除空的 graph 声明
rawCode = rawCode.replace(/^\s*graph\s*$/gim, '');
// 9. 修正 subgraph 语法
rawCode = rawCode.replace(/^\s*subgraph\s+([^\n]+)$/gm, (match, name) => {
// 移除 subgraph 名称中的括号内容
let cleanName = name.replace(/\([^\)]*\)/g, '').trim();
// 移除逗号和句号
cleanName = cleanName.replace(/[.,,。]/g, '');
// 压缩多余空格
cleanName = cleanName.replace(/\s+/g, ' ');
return cleanName ? `subgraph ${cleanName}` : 'subgraph';
});
// 10. 修正 class 语句语法(移除多余的 class 关键字)
// 例如class A,B,C class stage1 → class A,B,C stage1
rawCode = rawCode.replace(/^\s*class\s+([A-Za-z0-9_,\s]+)\s+class\s+([A-Za-z0-9_]+)\s*$/gm, 'class $1 $2');
// 11. Trim 最终结果
rawCode = rawCode.trim();
// 选择父节点(兼容 pre > code 或单独 code
let parent;
try {
parent = block.parentElement.tagName === 'PRE' ? block.parentElement : block;
} catch (e) {
parent = block; // 兜底
}
const code = rawCode;
currentCodeForError = code;
// 创建 Mermaid 渲染容器
const mermaidDiv = document.createElement('div');
// 生成绝对唯一的 ID使用时间戳 + 索引 + 随机数 + 性能计时器)
const uniqueId = `mermaid-${Date.now()}-${idx}-${Math.floor(Math.random()*100000)}-${Math.floor(performance.now()*1000)}`;
mermaidDiv.className = 'mermaid';
mermaidDiv.id = uniqueId;
mermaidDiv.setAttribute('data-mermaid-index', idx.toString());
// 卡片样式
mermaidDiv.style.background = 'var(--chatbot-bg, #fff)';
mermaidDiv.style.borderRadius = '12px';
mermaidDiv.style.boxShadow = '0 2px 8px rgba(0,0,0,0.08), 0 0 0 1px rgba(0,0,0,0.03)';
mermaidDiv.style.padding = '20px';
mermaidDiv.style.position = 'relative';
mermaidDiv.style.width = 'fit-content';
mermaidDiv.style.maxWidth = '100%';
mermaidDiv.style.margin = '12px auto';
mermaidDiv.textContent = code;
// 检查内容有效性
const codeTrimmed = code.replace(/\s+/g, '');
if (!codeTrimmed || /^graph(TD|LR|RL|BT|TB)?$/i.test(codeTrimmed)) {
mermaidDiv.innerHTML = '<div style="color:#64748b;">无有效Mermaid内容</div>';
parent.replaceWith(mermaidDiv);
return;
}
// 记录上一次渲染成功的 SVG用于回退
let lastSVG = null;
if (parent.id && parent.id.startsWith('mermaid-') && parent.querySelector('svg')) {
lastSVG = parent.querySelector('svg').cloneNode(true);
} else if (parent.firstElementChild && parent.firstElementChild.id && parent.firstElementChild.id.startsWith('mermaid-') && parent.firstElementChild.querySelector('svg')){
lastSVG = parent.firstElementChild.querySelector('svg').cloneNode(true);
}
// 用新的 mermaidDiv 替换原代码块
parent.replaceWith(mermaidDiv);
try {
// 使用 mermaid.init 渲染 SVG
await window.mermaid.init(undefined, '#' + uniqueId);
// 渲染成功,清除错误边框
mermaidDiv.style.border = '';
const existingWarning = mermaidDiv.querySelector('.mermaid-render-warning');
if (existingWarning) existingWarning.remove();
// 添加按钮
addMermaidButtons(mermaidDiv, currentCodeForError);
} catch (renderError) {
/**
* Mermaid 渲染失败处理(增强版):
* 1. 自动尝试多级修正(无需 data-mermaid-final 标记)
* 2. 若修正成功则渲染,否则继续下一个修正
* 3. 若所有修正都失败,显示详细错误信息
* 4. 若有上一次成功 SVG可回退显示
*/
console.warn('Mermaid 初次渲染失败,尝试自动修正...', renderError);
// 增强的修正函数集合(从最保守到最激进)
const mermaidFixers = [
// 修正0a: 修复缺少箭头的节点连接(如 J{text} K[text] 改为 J{text} --> K[text]
code => {
// 修复菱形节点缺少结束花括号的情况
code = code.replace(/\{([^}]*?)\s{2,}([A-Z][A-Za-z0-9_]*)\[/g, '{$1} --> $2[');
// 修复 } [ 或 }[ 的情况(菱形节点后缺少箭头)
code = code.replace(/\}(\s{2,}|\s*)\[/g, '} --> [');
// 修复 ] [ 的情况(方括号节点后缺少箭头)
code = code.replace(/\](\s{2,})\[/g, '] --> [');
// 修复 ) [ 的情况(圆括号节点后缺少箭头)
code = code.replace(/\)(\s{2,})\[/g, ') --> [');
return code;
},
// 修正0b: 处理节点标签中的括号、方括号和管道符(最常见问题)
code => {
return code.replace(/\[([^\]]+)\]/g, (match, labelContent) => {
let cleanLabel = labelContent
.replace(/\[/g, '') // 方括号左
.replace(/\]/g, '') // 方括号右
.replace(/\(/g, '')
.replace(/\)/g, '')
.replace(/\|/g, '');
return `[${cleanLabel}]`;
});
},
// 修正1: 处理多行文本和长度限制
code => {
return code.replace(/\[([^\]]+)\]/g, (match, labelContent) => {
let cleanLabel = labelContent.replace(/\n+/g, ' ').replace(/\s+/g, ' ').trim();
if (cleanLabel.length > 60) {
cleanLabel = cleanLabel.substring(0, 57) + '...';
}
return `[${cleanLabel}]`;
});
},
// 修正2: 修正常见的引号问题
code => {
return code.replace(/["']/g, '');
},
// 修正3: 将节点标签中的斜杠替换为全角
code => {
return code.replace(/\[([^\]]*)\]/g, match => match.replace(/\//g, ''));
},
// 修正4: 移除节点标签中的冒号和破折号
code => {
return code.replace(/\[([^\]]*)\]/g, match => match.replace(/[:\-]/g, ''));
},
// 修正5: 将所有特殊符号替换为全角或空格
code => {
return code.replace(/\[([^\]]*)\]/g, match => {
return match
.replace(/\(/g, '')
.replace(/\)/g, '')
.replace(/\|/g, '')
.replace(/</g, '')
.replace(/>/g, '')
.replace(/\{/g, '')
.replace(/\}/g, '');
});
},
// 修正6: 极端修正 - 只保留节点内的中英文、数字和常见符号
code => {
return code.replace(/\[([^\]]*)\]/g, match => {
return match.replace(/[^\u4e00-\u9fa5a-zA-Z0-9\[\]\s\.]/g, '');
});
},
// 修正7: 最激进 - 简化所有节点名称为纯文本
code => {
return code.replace(/\[([^\]]*)\]/g, match => {
const inner = match.slice(1, -1).trim();
// 只保留前20个字符
return '[' + inner.substring(0, 20).replace(/[^\u4e00-\u9fa5a-zA-Z0-9\s]/g, '') + ']';
});
}
];
let fixSuccess = false;
let successfulCode = null;
for (let i = 0; i < mermaidFixers.length && !fixSuccess; i++) {
try {
const fixedCode = mermaidFixers[i](code);
if (fixedCode === code) continue; // 跳过没有改变的修正
// 重新设置 mermaid div 内容
mermaidDiv.textContent = fixedCode;
await window.mermaid.init(undefined, '#' + uniqueId);
// 验证 SVG 是否真正生成
const svgElement = mermaidDiv.querySelector('svg');
if (!svgElement) {
console.warn(`修正器 ${i + 1} 执行完成但未生成 SVG`);
continue; // 未生成 SVG尝试下一个修正器
}
// 修正成功!
fixSuccess = true;
successfulCode = fixedCode;
console.log(`Mermaid 修正成功 (修正器 ${i + 1})`, fixedCode);
// 清除错误边框
mermaidDiv.style.border = '';
// 显示警告提示
const warningDiv = document.createElement('div');
warningDiv.className = 'mermaid-auto-fix-warning';
warningDiv.style.cssText = `
color: #d97706;
font-size: 12px;
background: #fef3c7;
border: 1px solid #fcd34d;
border-radius: 6px;
padding: 8px 12px;
margin-top: 12px;
text-align: center;
`;
warningDiv.innerHTML = `
<strong>⚠️ 已自动修正语法</strong><br>
原始代码存在语法问题,已应用修正器 ${i + 1} 进行渲染
`;
mermaidDiv.appendChild(warningDiv);
// 更新 currentCodeForError 为修正后的代码
currentCodeForError = fixedCode;
// 添加按钮
addMermaidButtons(mermaidDiv, currentCodeForError);
break;
} catch (e) {
// 这个修正器失败了,继续尝试下一个
console.warn(`修正器 ${i + 1} 失败:`, e);
}
}
// 如果所有修正都失败
if (!fixSuccess) {
// XSS 防护:安全地转义错误消息和代码
const escapedErrorMessage = safeEscapeHtml(renderError.str || renderError.message);
const escapedCode = safeEscapeHtml(currentCodeForError);
if (lastSVG) {
// 回退到上一次成功的 SVG
mermaidDiv.innerHTML = '';
mermaidDiv.appendChild(lastSVG);
mermaidDiv.style.border = '2px dashed #f59e0b';
let warn = mermaidDiv.querySelector('.mermaid-render-warning');
if (!warn) {
warn = document.createElement('div');
warn.className = 'mermaid-render-warning';
warn.style.cssText = 'color:#d97706;font-size:12px;margin-top:4px;text-align:center;';
mermaidDiv.appendChild(warn);
}
warn.textContent = '⚠️ 当前代码解析失败,显示上一版本。错误: ' + escapedErrorMessage;
} else {
// 显示详细错误信息
mermaidDiv.innerHTML = `
<div style="color:#e53e3e;font-weight:bold;margin-bottom:8px;">
❌ Mermaid 渲染失败
</div>
<div style="color:#64748b;font-size:13px;margin-bottom:8px;">
错误信息: ${escapedErrorMessage}
</div>
<details style="margin-top:8px;">
<summary style="cursor:pointer;color:#6366f1;font-size:13px;font-weight:500;">
查看原始代码
</summary>
<pre style="color:#64748b;font-size:12px;background:#f3f4f6;border-radius:6px;padding:8px 12px;overflow-x:auto;margin-top:8px;white-space:pre-wrap;word-break:break-all;">${escapedCode}</pre>
</details>
<div style="margin-top:12px;font-size:12px;color:#64748b;">
💡 提示: 可尝试在 <a href="https://mermaid.live" target="_blank" style="color:#6366f1;text-decoration:underline;">Mermaid.live</a> 中调试代码
</div>
`;
mermaidDiv.style.border = '2px solid #e53e3e';
}
}
}
} catch (generalBlockError) {
// 兜底:处理 block 解析或 DOM 操作异常
// XSS 防护:安全地转义错误消息
const escapedGeneralErrorMessage = safeEscapeHtml(generalBlockError.message);
console.error('处理Mermaid block时发生一般错误:', generalBlockError, block);
let errorDisplayDiv = block.parentElement || document.createElement('div');
if (block.parentElement) {
let tempDiv = document.createElement('div');
tempDiv.innerHTML = '<div style="color:#e53e3e;">Mermaid block处理异常: ' + escapedGeneralErrorMessage + '</div>';
block.replaceWith(tempDiv);
} else {
block.innerHTML = '<div style="color:#e53e3e;">Mermaid block处理异常: ' + escapedGeneralErrorMessage + '</div>';
}
}
} // 结束 for...of 循环
}
// 挂载到全局命名空间
if (typeof window.ChatbotRenderingUtils === 'undefined') {
window.ChatbotRenderingUtils = {};
}
window.ChatbotRenderingUtils.renderAllMermaidBlocks = renderAllMermaidBlocksInternal;