paper-burner/js/chatbot/utils/chatbot-utils.js

909 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.

// chatbot-utils.js
/**
* 转义 HTML 特殊字符,防止 XSS 攻击。
* 将 &, <, >, ", ' 替换为相应的 HTML 实体。
* @param {string} str 需要转义的原始字符串。
* @returns {string} 转义后的安全字符串。
*/
function escapeHtml(str) {
return str.replace(/[&<>"']/g, function (c) {
return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;','\'':'&#39;'}[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 `<div class=\"mindmap-shadow-root\">${node.children.map((c,i,a)=>renderNode(c,0,i===a.length-1)).join('')}</div>`;
}
// 节点样式
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 = `<div class=\"mindmap-shadow-node level${level}\" style=\"position:relative;margin-left:${level*28}px;padding:3px 8px 3px 12px;background:${colors[level]||colors[2]};border-radius:8px;min-width:60px;max-width:260px;margin-bottom:2px;opacity:0.7;border:1px dashed rgba(59,130,246,0.2);\">`;
// 圆点
html += `<span style=\"position:absolute;left:-10px;top:50%;transform:translateY(-50%);width:7px;height:7px;border-radius:4px;background:${dotColors[level]||dotColors[2]};box-shadow:0 0 0 1px #e0e7ef;\"></span>`;
// 线条(如果不是根节点且不是最后一个兄弟)
if (level > 0) {
html += `<span style=\"position:absolute;left:-6px;top:0;height:100%;width:1.5px;background:linear-gradient(to bottom,rgba(59,130,246,0.10),rgba(59,130,246,0.03));z-index:0;\"></span>`;
}
// Use escapeHtml from the same utils file
html += `<span style=\"color:#2563eb;font-weight:${level===0?'bold':'normal'};font-size:${level===0?'1.08em':'1em'};\">${escapeHtml(node.text)}</span>`;
if (node.children && node.children.length > 0) {
html += `<div class=\"mindmap-shadow-children\" style=\"margin-top:4px;\">${node.children.map((c,i,a)=>renderNode(c,level+1,i===a.length-1)).join('')}</div>`;
}
html += '</div>';
return html;
}
const tree = parseTree(md);
const html = renderNode(tree);
return html || '<div style=\"color:#94a3b8;opacity:0.5;\">暂无结构化内容</div>';
}
/**
* 压缩图片到目标大小和尺寸。
* @param {string} base64Src - Base64 编码的源图片数据。
* @param {number} targetSizeBytes - 目标文件大小(字节)。
* @param {number} maxDimension - 图片的最大宽度/高度。
* @param {number} initialQuality - 初始压缩质量 (0-1)。
* @returns {Promise<string>} - 压缩后的 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
};