// chatbot-utils.js /** * 转义 HTML 特殊字符,防止 XSS 攻击。 * 将 &, <, >, ", ' 替换为相应的 HTML 实体。 * @param {string} str 需要转义的原始字符串。 * @returns {string} 转义后的安全字符串。 */ function escapeHtml(str) { return str.replace(/[&<>"']/g, function (c) { return {'&':'&','<':'<','>':'>','"':'"','\'':'''}[c]; }); } /** * 显示一个短暂的浮动提示消息 (Toast)。 * Toast 用于向用户反馈操作结果,如"已复制到剪贴板"。 * 如果页面上不存在 ID 为 `chatbot-toast` 的元素,则会创建一个。 * Toast 会在显示约2秒后自动淡出消失。 * * @param {string} message 要在 Toast 中显示的消息文本。 */ function showToast(message) { let toast = document.getElementById('chatbot-toast'); if (!toast) { toast = document.createElement('div'); toast.id = 'chatbot-toast'; toast.style.position = 'fixed'; toast.style.bottom = '100px'; toast.style.left = '50%'; toast.style.transform = 'translateX(-50%)'; toast.style.padding = '8px 16px'; toast.style.background = 'rgba(0,0,0,0.7)'; toast.style.color = 'white'; toast.style.borderRadius = '4px'; toast.style.fontSize = '14px'; toast.style.zIndex = '100001'; toast.style.opacity = '0'; toast.style.transition = 'opacity 0.3s'; document.body.appendChild(toast); } toast.textContent = message; toast.style.opacity = '1'; setTimeout(() => { toast.style.opacity = '0'; }, 2000); } /** * 复制指定索引的助手消息内容到用户剪贴板。 * 使用 `navigator.clipboard.writeText` API。 * 成功或失败时会调用 `showToast` 显示反馈。 * * @param {number} messageIndex `ChatbotCore.chatHistory` 数组中目标助手消息的索引。 */ function copyAssistantMessage(messageIndex) { if (!window.ChatbotCore || !window.ChatbotCore.chatHistory[messageIndex]) return; const text = window.ChatbotCore.chatHistory[messageIndex].content; navigator.clipboard.writeText(text).then(() => { showToast('已复制到剪贴板'); }).catch(err => { showToast('复制失败,请手动选择文本复制'); }); } /** * 将指定索引的助手消息内容导出为 PNG 图片。 * 依赖 `html2canvas` 库。如果该库未加载,则会尝试动态加载它。 * 实际的导出操作由 `doExportAsPng` 函数执行。 * * @param {number} messageIndex `ChatbotCore.chatHistory` 数组中目标助手消息的索引。 */ function exportMessageAsPng(messageIndex) { if (!window.ChatbotCore || !window.ChatbotCore.chatHistory[messageIndex]) return; if (typeof html2canvas === 'undefined') { showToast('正在加载图片导出组件...'); const script = document.createElement('script'); script.src = 'https://registry.npmmirror.com/html2canvas/1.4.1/files/dist/html2canvas.min.js'; script.onload = function() { showToast('组件加载完成,正在生成图片...'); doExportAsPng(messageIndex); }; script.onerror = function() { showToast('导出组件加载失败,请检查网络连接'); }; document.head.appendChild(script); return; } doExportAsPng(messageIndex); } /** * 执行将指定消息元素导出为 PNG 图片的核心逻辑。 * 此函数在 `html2canvas` 加载完成后被 `exportMessageAsPng` 调用,或者直接被调用(如果库已加载)。 * 它会查找对应的消息 DOM 元素,如果找不到,则会尝试使用 `exportContentDirectly` 作为后备方案。 * * @param {number} messageIndex 目标助手消息在 `ChatbotCore.chatHistory` 中的索引。 */ function doExportAsPng(messageIndex) { // Phase 3.5: 添加导出状态锁,防止快速点击导致内存泄漏 if (window.ChatbotRenderState && window.ChatbotRenderState.isExporting) { if (typeof showToast === 'function') { showToast('正在导出,请稍候...'); } if (window.PerfLogger) { window.PerfLogger.warn('导出已在进行中,忽略重复请求'); } return; } // 设置导出锁 if (window.ChatbotRenderState) { window.ChatbotRenderState.isExporting = true; } try { const messageElements = document.querySelectorAll('.assistant-message'); const targetElement = document.querySelector(`.assistant-message[data-message-index="${messageIndex}"]`); if ((!messageElements || messageElements.length <= messageIndex) && !targetElement) { exportContentDirectly(window.ChatbotCore.chatHistory[messageIndex].content); return; } const element = targetElement || messageElements[messageIndex]; processExport(element); } catch (error) { if (window.PerfLogger) { window.PerfLogger.error('导出失败:', error); } // 确保即使出错也释放锁 if (window.ChatbotRenderState) { window.ChatbotRenderState.isExporting = false; } if (typeof showToast === 'function') { showToast('导出失败,请重试'); } } } /** * 为导出优化:内联 KaTeX 关键样式 * 基于你的 hot-fix v3,只内联最关键的属性 */ function inlineKatexStyles(container) { // 首先处理顶层 KaTeX 容器 const katexContainers = container.querySelectorAll('.katex'); katexContainers.forEach(el => { const computed = window.getComputedStyle(el); // 判断是行内还是行间公式(检查父容器) const parent = el.parentElement; const isInline = parent && ( parent.classList.contains('katex-inline') || parent.getAttribute('data-formula-display') === 'inline' || parent.tagName === 'SPAN' ); const containerProps = [ 'position', 'vertical-align', 'font-size', 'line-height', 'font-family', 'margin', 'padding', 'text-align' ]; const inlineStyles = []; // 根据类型手动设置 display if (isInline) { inlineStyles.push('display: inline'); } else { inlineStyles.push('display: block'); } containerProps.forEach(prop => { const value = computed.getPropertyValue(prop); if (value && value !== 'auto' && value !== 'normal' && value !== 'none' && value !== '0px') { inlineStyles.push(`${prop}: ${value}`); } }); if (inlineStyles.length > 0) { const existing = el.getAttribute('style') || ''; el.setAttribute('style', existing + '; ' + inlineStyles.join('; ')); } }); // 然后处理所有子元素 const katexElements = container.querySelectorAll('.katex *'); katexElements.forEach(el => { const computed = window.getComputedStyle(el); const critical = [ 'display', 'position', 'vertical-align', 'font-size', 'line-height', 'font-family', 'font-weight', 'font-style', 'margin-top', 'margin-bottom', 'margin-left', 'margin-right', 'padding-top', 'padding-bottom', 'padding-left', 'padding-right', 'width', 'height', 'top', 'bottom', 'left', 'right', 'transform', 'color', 'border-bottom' ]; const inlineStyles = []; const existing = el.getAttribute('style') || ''; // Special fix for html2canvas vertical alignment issue const verticalAlign = computed.getPropertyValue('vertical-align'); if (verticalAlign && verticalAlign.endsWith('em')) { // Convert vertical-align to relative positioning for html2canvas const val = parseFloat(verticalAlign); if (!isNaN(val) && val !== 0) { // Negative vertical-align means "down", so positive top // However, html2canvas often renders text slightly lower, making elements look "high" // Experimentally, we add a small offset if it's baseline aligned or negative // Phase 10.15: 终极调整 // 用户反馈"好了点,但还没完全好",且"不够朝下" // 1. 进一步加大 KaTeX 下沉倍率到 1.7 (从 1.4) inlineStyles.push(`position: relative`); inlineStyles.push(`top: ${-1 * val * 1.7}em`); inlineStyles.push(`vertical-align: baseline`); // 保持基线重置 } } critical.forEach(prop => { const value = computed.getPropertyValue(prop); // Skip vertical-align if we handled it above, or just let it be overwritten if we push it? // Actually, let's just copy everything else. if (prop === 'vertical-align') return; if (value && value !== 'auto' && value !== 'normal' && value !== 'none' && value !== '0px' && value !== 'rgba(0, 0, 0, 0)') { inlineStyles.push(`${prop}: ${value}`); } }); if (inlineStyles.length > 0) { const existing = el.getAttribute('style') || ''; el.setAttribute('style', existing + '; ' + inlineStyles.join('; ')); } }); } /** * 智能内联关键样式(仅用于特定元素) * 与旧版不同,这个版本不会盲目复制所有样式 * @param {HTMLElement} element 需要内联样式的元素 * @param {string[]} props 需要内联的属性列表 */ function inlineSpecificStyles(element, props) { const computed = window.getComputedStyle(element); const existingStyle = element.getAttribute('style') || ''; const inlineStyle = []; props.forEach(prop => { const value = computed.getPropertyValue(prop); if (value && value !== 'none' && value !== 'normal' && value !== 'auto' && value !== 'initial') { // 避免覆盖已有的内联样式 if (!existingStyle.includes(prop + ':')) { inlineStyle.push(`${prop}: ${value}`); } } }); if (inlineStyle.length > 0) { element.setAttribute('style', existingStyle + '; ' + inlineStyle.join('; ')); } } /** * Phase 10.9: 内联部分 Markdown 元素的关键样式(简化版) * 只处理不会影响垂直对齐的元素,避免破坏列表等元素的布局 * @param {HTMLElement} container 导出容器 */ function inlineMarkdownStyles(container) { // 只内联不会影响垂直对齐的元素 // 不处理 ul、ol、li,避免破坏列表的原生布局 const markdownElements = [ { selector: 'code', props: ['background-color', 'padding', 'border-radius', 'font-family', 'font-size', 'color'] }, { selector: 'pre', props: ['background-color', 'padding', 'border-radius', 'overflow-x', 'margin-top', 'margin-bottom'] }, { selector: 'blockquote', props: ['margin-left', 'margin-right', 'padding-left', 'border-left', 'color', 'font-style'] } ]; markdownElements.forEach(({ selector, props }) => { const elements = container.querySelectorAll(selector); elements.forEach(el => { const computed = window.getComputedStyle(el); const existingStyle = el.getAttribute('style') || ''; const inlineStyles = []; props.forEach(prop => { const value = computed.getPropertyValue(prop); if (value && value !== 'none' && value !== 'normal' && value !== 'auto' && value !== 'initial' && value !== '0px') { // 避免覆盖已有的内联样式 if (!existingStyle.includes(prop + ':')) { inlineStyles.push(`${prop}: ${value}`); } } }); if (inlineStyles.length > 0) { el.setAttribute('style', existingStyle + '; ' + inlineStyles.join('; ')); } }); }); } /** * 处理将消息 DOM 元素转换为 PNG 图片并下载的详细过程。 * * Phase 10: 修复 CSS 重构后的导出问题 * - 在导出容器中加载完整的 KaTeX CSS * * @param {HTMLElement} messageElement 要导出为图片的助手消息的 DOM 元素。 */ function processExport(messageElement) { let questionText = "未知问题"; try { const index = parseInt(messageElement.getAttribute('data-message-index')); if (!isNaN(index) && index > 0 && window.ChatbotCore.chatHistory[index-1] && window.ChatbotCore.chatHistory[index-1].role === 'user') { questionText = window.ChatbotCore.chatHistory[index-1].content; if (questionText.length > 60) { questionText = questionText.substring(0, 57) + '...'; } } } catch (e) {} // Phase 10.5: 检查是否有未渲染的公式占位符 const placeholders = messageElement.querySelectorAll('.katex-placeholder, .katex-block-placeholder, .katex-inline-placeholder'); if (placeholders.length > 0) { showToast(`检测到 ${placeholders.length} 个公式正在渲染,请稍候...`); console.log(`[Export] 检测到 ${placeholders.length} 个占位符,等待渲染完成...`); // 等待渐进式渲染完成 const checkInterval = setInterval(() => { const remaining = messageElement.querySelectorAll('.katex-placeholder, .katex-block-placeholder, .katex-inline-placeholder'); if (remaining.length === 0) { clearInterval(checkInterval); console.log('[Export] 公式渲染完成,开始导出'); showToast('开始导出...'); // 短暂延迟确保 DOM 稳定 setTimeout(() => doActualExport(messageElement), 100); } }, 200); // 超时保护:最多等待 10 秒 setTimeout(() => { clearInterval(checkInterval); const remaining = messageElement.querySelectorAll('.katex-placeholder, .katex-block-placeholder, .katex-inline-placeholder'); if (remaining.length > 0) { console.warn(`[Export] 等待超时,仍有 ${remaining.length} 个公式未渲染,强制导出`); } doActualExport(messageElement); }, 10000); return; } // 没有占位符,直接导出 doActualExport(messageElement); } /** * 实际执行导出的函数 */ function doActualExport(messageElement) { const exportContainer = document.createElement('div'); exportContainer.style.position = 'absolute'; exportContainer.style.left = '-9999px'; exportContainer.style.padding = '20px'; exportContainer.style.background = 'white'; exportContainer.style.borderRadius = '8px'; exportContainer.style.boxShadow = '0 4px 12px rgba(0,0,0,0.1)'; exportContainer.style.maxWidth = '800px'; exportContainer.style.width = '800px'; exportContainer.style.boxSizing = 'border-box'; exportContainer.style.color = '#111827'; exportContainer.style.fontSize = '15px'; exportContainer.style.lineHeight = '1.6'; // 显式设置字体,确保 html2canvas 渲染时度量一致 // 使用与 variables.css 中 --font-family-base 一致的字体栈 exportContainer.style.fontFamily = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif'; exportContainer.style.overflow = 'visible'; exportContainer.style.height = 'auto'; const watermark = document.createElement('div'); watermark.style.position = 'absolute'; watermark.style.bottom = '10px'; watermark.style.right = '15px'; watermark.style.fontSize = '8px'; watermark.style.opacity = '0.4'; watermark.textContent = 'Created with Paper Burner'; // Phase 10.5: 使用 cloneNode 而不是 innerHTML,完整保留 KaTeX 结构 const contentContainer = messageElement.cloneNode(true); // 移除克隆元素的 data-message-index 避免 ID 冲突 contentContainer.removeAttribute('data-message-index'); contentContainer.style.maxWidth = 'none'; exportContainer.appendChild(contentContainer); exportContainer.appendChild(watermark); document.body.appendChild(exportContainer); // Phase 10: 内联 KaTeX 样式(hot-fix v3 优化版) inlineKatexStyles(exportContainer); // Phase 10.9: 内联所有 Markdown 元素的关键样式 inlineMarkdownStyles(exportContainer); // Phase 10.10: 修复列表样式(从原始元素复制计算样式) const originalLists = messageElement.querySelectorAll('ul, ol'); const exportLists = exportContainer.querySelectorAll('ul, ol'); exportLists.forEach((list, index) => { if (originalLists[index]) { const computed = window.getComputedStyle(originalLists[index]); list.style.paddingLeft = computed.paddingLeft; list.style.marginBottom = computed.marginBottom; list.style.listStyleType = computed.listStyleType; list.style.listStylePosition = 'inside'; // 改回 inside } }); const originalItems = messageElement.querySelectorAll('li'); const exportItems = exportContainer.querySelectorAll('li'); exportItems.forEach((li, index) => { if (originalItems[index]) { const computed = window.getComputedStyle(originalItems[index]); li.style.display = 'list-item'; li.style.lineHeight = computed.lineHeight; li.style.marginBottom = computed.marginBottom; // Fix for list number alignment: // Phase 10.15: 继续微调 // 1. 保持 line-height 1.6 和 vertical-align baseline // 2. 加大 padding-top 到 4px,确保明显下移 li.style.lineHeight = '1.6'; li.style.verticalAlign = 'baseline'; li.style.paddingTop = '4px'; // 加大物理下推力度 } }); // Phase 10.7: 强制修正父容器的 display 属性 const inlineContainers = exportContainer.querySelectorAll('.katex-inline, [data-formula-display="inline"]'); inlineContainers.forEach(container => { container.style.display = 'inline'; // container.style.verticalAlign = 'baseline'; // 移除强制基线对齐,保留 KaTeX 原生对齐 (通常是负值) container.style.margin = '0 2px'; }); const blockContainers = exportContainer.querySelectorAll('.katex-block, [data-formula-display="block"]'); blockContainers.forEach(container => { container.style.display = 'block'; container.style.margin = '16px auto'; container.style.textAlign = 'center'; }); // Phase 10.6: 强制移除所有 KaTeX 的高度和溢出限制 const katexBlocks = exportContainer.querySelectorAll('.katex-display, .katex-display-fixed, .katex-block'); katexBlocks.forEach(block => { block.style.overflow = 'visible'; block.style.overflowY = 'visible'; block.style.maxHeight = 'none'; block.style.height = 'auto'; }); // 移除所有 KaTeX 元素的高度限制 const allKatexElements = exportContainer.querySelectorAll('.katex, .katex *'); allKatexElements.forEach(el => { if (el.style.overflow === 'hidden') el.style.overflow = 'visible'; if (el.style.overflowY === 'hidden') el.style.overflowY = 'visible'; if (el.style.maxHeight && el.style.maxHeight !== 'none') el.style.maxHeight = 'none'; }); // Phase 3.5: 导出前临时展开所有表格,确保完整显示 const tables = exportContainer.querySelectorAll('.markdown-content table'); const originalTableStyles = []; tables.forEach((table, index) => { originalTableStyles[index] = { overflow: table.style.overflow || '', maxWidth: table.style.maxWidth || '', display: table.style.display || '' }; table.style.overflow = 'visible'; table.style.maxWidth = 'none'; table.style.display = 'table'; }); // 同时移除表格容器的宽度限制 const messageContainer = exportContainer.querySelector('.assistant-message'); let originalContainerMaxWidth = ''; if (messageContainer) { originalContainerMaxWidth = messageContainer.style.maxWidth || ''; messageContainer.style.maxWidth = 'none'; } // Phase 3.5: 动态计算导出容器的最大宽度 const originalExportContainerMaxWidth = exportContainer.style.maxWidth; const originalExportContainerWidth = exportContainer.style.width; // 计算所有表格的最大宽度 let maxTableWidth = 0; tables.forEach(table => { const tableWidth = table.scrollWidth || 0; if (maxTableWidth < tableWidth) { maxTableWidth = tableWidth; } }); // 根据实际内容动态设置宽度 const config = window.PerformanceConfig?.EXPORT || { MAX_WIDTH: 800, ABSOLUTE_MAX_WIDTH: 1000 }; const calculatedWidth = maxTableWidth > 0 && maxTableWidth > 800 ? Math.min(maxTableWidth + 40, config.ABSOLUTE_MAX_WIDTH) : 800; exportContainer.style.maxWidth = `${calculatedWidth}px`; exportContainer.style.width = `${calculatedWidth}px`; // Phase 10: KaTeX 已完全渲染,短暂延迟让 DOM 布局稳定 const hasKatex = exportContainer.querySelectorAll('.katex').length > 0; const layoutDelay = hasKatex ? 300 : 50; // 等待DOM重新布局 setTimeout(() => { showToast('正在生成图片...'); // 强制重排 exportContainer.offsetHeight; const scale = window.PerformanceConfig?.EXPORT?.SCALE || 2; html2canvas(exportContainer, { scale: scale, useCORS: true, backgroundColor: 'white', logging: false, allowTaint: false, foreignObjectRendering: false }).then(canvas => { try { const link = document.createElement('a'); link.download = `paper-burner-ai-${new Date().toISOString().slice(0,10)}.png`; link.href = canvas.toDataURL('image/png'); link.click(); showToast('图片已导出'); } catch (err) { showToast('导出图片失败'); } finally { // 清理前恢复所有样式 tables.forEach((table, index) => { if (originalTableStyles[index]) { table.style.overflow = originalTableStyles[index].overflow; table.style.maxWidth = originalTableStyles[index].maxWidth; table.style.display = originalTableStyles[index].display; } }); if (messageContainer && originalContainerMaxWidth) { messageContainer.style.maxWidth = originalContainerMaxWidth; } exportContainer.style.maxWidth = originalExportContainerMaxWidth; exportContainer.style.width = originalExportContainerWidth; document.body.removeChild(exportContainer); // 释放导出锁 if (window.ChatbotRenderState) { window.ChatbotRenderState.isExporting = false; } } }).catch(err => { if (window.PerfLogger) { window.PerfLogger.error('生成图片失败:', err); } showToast('生成图片失败'); // 错误时也要清理所有样式 tables.forEach((table, index) => { if (originalTableStyles[index]) { table.style.overflow = originalTableStyles[index].overflow; table.style.maxWidth = originalTableStyles[index].maxWidth; table.style.display = originalTableStyles[index].display; } }); if (messageContainer && originalContainerMaxWidth) { messageContainer.style.maxWidth = originalContainerMaxWidth; } exportContainer.style.maxWidth = originalExportContainerMaxWidth; exportContainer.style.width = originalExportContainerWidth; document.body.removeChild(exportContainer); // 释放导出锁 if (window.ChatbotRenderState) { window.ChatbotRenderState.isExporting = false; } }); }, layoutDelay); } /** * 当无法直接定位到消息的 DOM 元素时,提供一个后备方案来导出纯文本内容为图片。 * 这通常发生在 `doExportAsPng` 找不到对应的 `.assistant-message` 元素时。 * 它会创建一个包含纯文本内容的容器,并尝试将其导出为图片,流程与 `processExport` 类似, * 但内容源是直接的字符串而不是 DOM 元素的 innerHTML。 * * @param {string} content 要导出为图片的纯文本内容。 */ function exportContentDirectly(content) { let questionText = "未知问题"; try { // Try to find the last user question in history to associate with this content const history = window.ChatbotCore && window.ChatbotCore.chatHistory ? window.ChatbotCore.chatHistory : []; for (let i = history.length - 1; i >= 0; i--) { if (history[i].role === 'user' && history[i+1] && history[i+1].content === content) { questionText = history[i].content; if (questionText.length > 60) { questionText = questionText.substring(0, 57) + '...'; } break; } else if (history[i].role === 'user' && i === history.length -2) { // Fallback if current content is the last one questionText = history[i].content; if (questionText.length > 60) { questionText = questionText.substring(0, 57) + '...'; } break; } } } catch (e) { console.error("Error finding question for direct export:", e); } const exportContainer = document.createElement('div'); exportContainer.style.position = 'absolute'; exportContainer.style.left = '-9999px'; exportContainer.style.padding = '20px'; exportContainer.style.background = 'white'; exportContainer.style.borderRadius = '8px'; exportContainer.style.boxShadow = '0 4px 12px rgba(0,0,0,0.1)'; exportContainer.style.maxWidth = '720px'; exportContainer.style.width = '80vw'; exportContainer.style.color = '#111827'; exportContainer.style.fontSize = '15px'; exportContainer.style.lineHeight = '1.5'; const watermark = document.createElement('div'); watermark.style.position = 'absolute'; watermark.style.bottom = '10px'; watermark.style.right = '15px'; watermark.style.fontSize = '8px'; watermark.style.opacity = '0.4'; watermark.textContent = 'Created with Paper Burner'; const contentContainer = document.createElement('div'); // For direct content, we should escape it before setting textContent if it might contain HTML // However, if the 'content' is already supposed to be plain text, textContent is fine. // If 'content' is markdown that was rendered to HTML, we'd need a different approach // Assuming 'content' here is mostly plain text or pre-formatted for display. contentContainer.style.whiteSpace = 'pre-wrap'; // Preserve line breaks contentContainer.style.wordBreak = 'break-word'; contentContainer.textContent = content; exportContainer.appendChild(contentContainer); exportContainer.appendChild(watermark); document.body.appendChild(exportContainer); showToast('正在生成图片...'); html2canvas(exportContainer, { scale: 2, useCORS: true, backgroundColor: 'white', logging: false }).then(canvas => { try { const link = document.createElement('a'); link.download = `paper-burner-ai-${new Date().toISOString().slice(0,10)}.png`; link.href = canvas.toDataURL('image/png'); link.click(); showToast('图片已导出'); } catch (err) { showToast('导出图片失败'); } finally { document.body.removeChild(exportContainer); } }).catch(err => { showToast('生成图片失败'); document.body.removeChild(exportContainer); }); } /** * 根据 Markdown 文本生成思维导图的静态 HTML 预览 (虚影效果)。 * 主要用于在聊天界面快速展示思维导图的结构概览。 * * 实现逻辑: * 1. **解析 Markdown 为树结构 (`parseTree`)**: * - 按行分割 Markdown 文本。 * - 识别 `#` (一级)、`##` (二级)、`###` (三级) 标题,构建层级关系。 * - 返回一个包含 `text` 和 `children` 属性的树状对象。 * 2. **递归渲染树节点 (`renderNode`)**: * - 接受节点对象、当前层级和是否为最后一个兄弟节点的标记。 * - 为不同层级的节点应用不同的背景色、圆点颜色和字体样式,以区分层级。 * - 使用绝对定位和相对定位创建连接线和层级缩进的视觉效果。 * - 递归渲染子节点。 * 3. **调用与返回**: * - 调用 `parseTree` 解析传入的 `md` 文本。 * - 调用 `renderNode` 渲染根节点。 * - 如果生成的 HTML 为空或解析失败,返回一个提示"暂无结构化内容"的 div。 * * @param {string} md Markdown 格式的思维导图文本。 * @returns {string} 生成的思维导图预览 HTML 字符串。 */ function renderMindmapShadow(md) { // 解析 markdown 为树结构 function parseTree(md) { const lines = md.split(/\r?\n/).filter(l => l.trim()); const root = { text: '', children: [] }; let last1 = null, last2 = null; lines.forEach(line => { let m1 = line.match(/^# (.+)/); let m2 = line.match(/^## (.+)/); let m3 = line.match(/^### (.+)/); if (m1) { last1 = { text: m1[1], children: [] }; root.children.push(last1); last2 = null; } else if (m2 && last1) { last2 = { text: m2[1], children: [] }; last1.children.push(last2); } else if (m3 && last2) { last2.children.push({ text: m3[1], children: [] }); } }); return root; } // 递归渲染树状结构 function renderNode(node, level = 0, isLast = true) { if (!node.text && node.children.length === 0) return ''; if (!node.text) { // 根节点 return `
${node.children.map((c,i,a)=>renderNode(c,0,i===a.length-1)).join('')}
`; } // 节点样式 const colors = [ 'rgba(59,130,246,0.13)', // 主节点 'rgba(59,130,246,0.09)', // 二级 'rgba(59,130,246,0.06)' // 三级 ]; const dotColors = [ 'rgba(59,130,246,0.35)', 'rgba(59,130,246,0.22)', 'rgba(59,130,246,0.15)' ]; let html = `
`; // 圆点 html += ``; // 线条(如果不是根节点且不是最后一个兄弟) if (level > 0) { html += ``; } // Use escapeHtml from the same utils file html += `${escapeHtml(node.text)}`; if (node.children && node.children.length > 0) { html += `
${node.children.map((c,i,a)=>renderNode(c,level+1,i===a.length-1)).join('')}
`; } html += '
'; return html; } const tree = parseTree(md); const html = renderNode(tree); return html || '
暂无结构化内容
'; } /** * 压缩图片到目标大小和尺寸。 * @param {string} base64Src - Base64 编码的源图片数据。 * @param {number} targetSizeBytes - 目标文件大小(字节)。 * @param {number} maxDimension - 图片的最大宽度/高度。 * @param {number} initialQuality - 初始压缩质量 (0-1)。 * @returns {Promise} - 压缩后的 Base64 图片数据。 */ async function compressImage(base64Src, targetSizeBytes, maxDimension, initialQuality = 0.85) { return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => { let canvas = document.createElement('canvas'); let ctx = canvas.getContext('2d'); let width = img.width; let height = img.height; if (width > height) { if (width > maxDimension) { height = Math.round(height * (maxDimension / width)); width = maxDimension; } } else { if (height > maxDimension) { width = Math.round(width * (maxDimension / height)); height = maxDimension; } } canvas.width = width; canvas.height = height; ctx.drawImage(img, 0, 0, width, height); let quality = initialQuality; let compressedBase64 = canvas.toDataURL('image/jpeg', quality); let iterations = 0; const maxIterations = 10; // Prevent infinite loop // Iteratively reduce quality to meet size target (simplified) while (compressedBase64.length * 0.75 > targetSizeBytes && quality > 0.1 && iterations < maxIterations) { quality -= 0.1; compressedBase64 = canvas.toDataURL('image/jpeg', Math.max(0.1, quality)); iterations++; } if (compressedBase64.length * 0.75 > targetSizeBytes && targetSizeBytes < 100 * 1024) { // if still too large for small targets, warn but proceed console.warn(`Image compression for small target (${targetSizeBytes}B) resulted in ${Math.round(compressedBase64.length * 0.75 / 1024)}KB. Quality: ${quality.toFixed(2)}`); } resolve(compressedBase64); }; img.onerror = (err) => { console.error("Image loading error for compression:", err, base64Src.substring(0,100)); reject(new Error('无法加载图片进行压缩')); }; img.src = base64Src; }); } /** * 显示带进度条的Toast提示 * @param {string} message 初始消息 * @param {number} percent 初始进度 (0-100) * @returns {object} 包含update和close方法的对象 */ function showProgressToast(message, percent = 0) { let toast = document.getElementById('chatbot-progress-toast'); if (!toast) { toast = document.createElement('div'); toast.id = 'chatbot-progress-toast'; toast.style.cssText = ` position: fixed; top: 20px; right: 20px; min-width: 300px; background: white; border: 1px solid #e5e7eb; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); padding: 16px; z-index: 100002; font-family: system-ui, -apple-system, sans-serif; `; const messageEl = document.createElement('div'); messageEl.id = 'progress-toast-message'; messageEl.style.cssText = 'font-size: 14px; color: #374151; margin-bottom: 8px;'; const progressBg = document.createElement('div'); progressBg.style.cssText = 'width: 100%; height: 8px; background: #e5e7eb; border-radius: 4px; overflow: hidden;'; const progressBar = document.createElement('div'); progressBar.id = 'progress-toast-bar'; progressBar.style.cssText = 'height: 100%; background: linear-gradient(90deg, #3b82f6, #2563eb); transition: width 0.3s ease; width: 0%;'; const percentEl = document.createElement('div'); percentEl.id = 'progress-toast-percent'; percentEl.style.cssText = 'font-size: 12px; color: #6b7280; margin-top: 4px; text-align: right;'; progressBg.appendChild(progressBar); toast.appendChild(messageEl); toast.appendChild(progressBg); toast.appendChild(percentEl); document.body.appendChild(toast); } const messageEl = document.getElementById('progress-toast-message'); const progressBar = document.getElementById('progress-toast-bar'); const percentEl = document.getElementById('progress-toast-percent'); messageEl.textContent = message; progressBar.style.width = percent + '%'; percentEl.textContent = percent + '%'; return { update: function(newMessage, newPercent) { if (messageEl) messageEl.textContent = newMessage; if (progressBar) progressBar.style.width = newPercent + '%'; if (percentEl) percentEl.textContent = newPercent + '%'; }, close: function() { if (toast && toast.parentNode) { toast.style.opacity = '0'; toast.style.transition = 'opacity 0.3s'; setTimeout(() => { if (toast.parentNode) toast.parentNode.removeChild(toast); }, 300); } } }; } window.ChatbotUtils = { escapeHtml, showToast, showProgressToast, copyAssistantMessage, exportMessageAsPng, doExportAsPng, exportContentDirectly, renderMindmapShadow, compressImage };