|
| 1 | +/** |
| 2 | + * Enhanced Export - Export assistant messages as TXT, MD, PDF, or HTML |
| 3 | + * |
| 4 | + * Features: |
| 5 | + * - Export button next to Copy on each assistant message |
| 6 | + * - 4 format toggles in settings (TXT, MD, PDF, HTML) |
| 7 | + * - Batch download when multiple formats enabled |
| 8 | + * - TXT strips Markdown symbols for plain text |
| 9 | + * - Includes thinking block when present |
| 10 | + * |
| 11 | + * Architecture: MutationObserver watches messages container, injects Export button |
| 12 | + * into .message-actions when at least one format is enabled. |
| 13 | + */ |
| 14 | +(function(ChatRawPlugin) { |
| 15 | + 'use strict'; |
| 16 | + if (!ChatRawPlugin) { |
| 17 | + console.error('[EnhancedExport] ChatRawPlugin not found'); |
| 18 | + return; |
| 19 | + } |
| 20 | + |
| 21 | + const PLUGIN_ID = 'enhanced-export'; |
| 22 | + const LIB_BASE = `/api/plugins/${PLUGIN_ID}/lib`; |
| 23 | + const EXPORT_BTN_CLASS = 'btn-export-ee'; |
| 24 | + |
| 25 | + let settings = { exportTxt: true, exportMd: true, exportPdf: true, exportHtml: true }; |
| 26 | + let html2pdfLoaded = false; |
| 27 | + let observerInitialized = false; |
| 28 | + |
| 29 | + const STABILITY_DELAY = 50; |
| 30 | + |
| 31 | + const i18n = { |
| 32 | + en: { |
| 33 | + export: 'Export', |
| 34 | + exporting: 'Exporting...', |
| 35 | + exportSuccess: 'Exported', |
| 36 | + exportFailed: 'Export failed', |
| 37 | + noFormatEnabled: 'Enable at least one format in settings' |
| 38 | + }, |
| 39 | + zh: { |
| 40 | + export: '导出', |
| 41 | + exporting: '导出中...', |
| 42 | + exportSuccess: '已导出', |
| 43 | + exportFailed: '导出失败', |
| 44 | + noFormatEnabled: '请在设置中开启至少一种导出格式' |
| 45 | + } |
| 46 | + }; |
| 47 | + |
| 48 | + function t(key) { |
| 49 | + const lang = ChatRawPlugin?.utils?.getLanguage?.() || 'en'; |
| 50 | + return i18n[lang]?.[key] || i18n.en[key] || key; |
| 51 | + } |
| 52 | + |
| 53 | + function stripMarkdown(md) { |
| 54 | + if (typeof md !== 'string') return ''; |
| 55 | + return md |
| 56 | + .replace(/```[\s\S]*?```/g, '') // code blocks |
| 57 | + .replace(/`[^`]+`/g, (m) => m.slice(1, -1)) |
| 58 | + .replace(/\*\*([^*]+)\*\*/g, '$1') |
| 59 | + .replace(/\*([^*]+)\*/g, '$1') |
| 60 | + .replace(/__([^_]+)__/g, '$1') |
| 61 | + .replace(/_([^_]+)_/g, '$1') |
| 62 | + .replace(/^#{1,6}\s+/gm, '') |
| 63 | + .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') |
| 64 | + .replace(/^[-*+]\s+/gm, '') |
| 65 | + .replace(/^\d+\.\s+/gm, '') |
| 66 | + .replace(/^>\s+/gm, '') |
| 67 | + .replace(/^---+$/gm, '') |
| 68 | + .replace(/^\*\*\*+$/gm, '') |
| 69 | + .replace(/^___+$/gm, '') |
| 70 | + .replace(/\n{3,}/g, '\n\n') |
| 71 | + .trim(); |
| 72 | + } |
| 73 | + |
| 74 | + function buildContent(msg) { |
| 75 | + const parts = []; |
| 76 | + if (msg.thinking && msg.thinking.trim()) { |
| 77 | + parts.push(msg.thinking.trim()); |
| 78 | + } |
| 79 | + if (msg.content && msg.content.trim()) { |
| 80 | + parts.push(msg.content.trim()); |
| 81 | + } |
| 82 | + return parts.join('\n\n'); |
| 83 | + } |
| 84 | + |
| 85 | + function buildMdContent(msg) { |
| 86 | + const parts = []; |
| 87 | + if (msg.thinking && msg.thinking.trim()) { |
| 88 | + parts.push('## ' + t('thinking') + '\n\n' + msg.thinking.trim()); |
| 89 | + } |
| 90 | + if (msg.content && msg.content.trim()) { |
| 91 | + parts.push('## ' + t('reply') + '\n\n' + msg.content.trim()); |
| 92 | + } |
| 93 | + return parts.join('\n\n'); |
| 94 | + } |
| 95 | + |
| 96 | + const thinkingI18n = { en: { thinking: 'Thinking Process', reply: 'Reply' }, zh: { thinking: '思考过程', reply: '回复' } }; |
| 97 | + Object.assign(i18n.en, thinkingI18n.en); |
| 98 | + Object.assign(i18n.zh, thinkingI18n.zh); |
| 99 | + |
| 100 | + function downloadBlob(blob, filename) { |
| 101 | + const a = document.createElement('a'); |
| 102 | + a.href = URL.createObjectURL(blob); |
| 103 | + a.download = filename; |
| 104 | + a.click(); |
| 105 | + URL.revokeObjectURL(a.href); |
| 106 | + } |
| 107 | + |
| 108 | + function getMsgForActions(actionsEl) { |
| 109 | + var messages = ChatRawPlugin?.utils?.getMessages?.() || []; |
| 110 | + if (!messages.length) return null; |
| 111 | + var slot = actionsEl.querySelector('.message-actions-plugin-slot') || actionsEl.closest('.message-actions')?.querySelector('.message-actions-plugin-slot'); |
| 112 | + if (slot && slot.getAttribute('data-msg-index') != null) { |
| 113 | + var idx = parseInt(slot.getAttribute('data-msg-index'), 10); |
| 114 | + if (!isNaN(idx) && idx >= 0 && idx < messages.length) return messages[idx]; |
| 115 | + } |
| 116 | + var messageEl = actionsEl.closest('.message'); |
| 117 | + if (!messageEl || !messageEl.classList.contains('assistant')) return null; |
| 118 | + var container = messageEl.closest('.messages'); |
| 119 | + if (!container) return null; |
| 120 | + var assistantMsgs = messages.filter(function (m) { return m.role === 'assistant' && m.content; }); |
| 121 | + if (!assistantMsgs.length) return null; |
| 122 | + var assistantElements = Array.from(container.querySelectorAll('.message.assistant')).filter(function (el) { |
| 123 | + return el.querySelector('.message-actions'); |
| 124 | + }); |
| 125 | + var idx = assistantElements.indexOf(messageEl); |
| 126 | + if (idx < 0 || idx >= assistantMsgs.length) return null; |
| 127 | + return assistantMsgs[idx]; |
| 128 | + } |
| 129 | + |
| 130 | + async function loadHtml2pdf() { |
| 131 | + if (html2pdfLoaded || typeof window.html2pdf !== 'undefined') { |
| 132 | + html2pdfLoaded = true; |
| 133 | + return true; |
| 134 | + } |
| 135 | + try { |
| 136 | + await new Promise((resolve, reject) => { |
| 137 | + const s = document.createElement('script'); |
| 138 | + s.src = `${LIB_BASE}/html2pdf.bundle.min.js`; |
| 139 | + s.onload = resolve; |
| 140 | + s.onerror = () => reject(new Error('Failed to load html2pdf')); |
| 141 | + document.head.appendChild(s); |
| 142 | + }); |
| 143 | + html2pdfLoaded = true; |
| 144 | + return true; |
| 145 | + } catch (e) { |
| 146 | + console.error('[EnhancedExport] Failed to load html2pdf:', e); |
| 147 | + return false; |
| 148 | + } |
| 149 | + } |
| 150 | + |
| 151 | + function getHtmlTemplate(bodyContent) { |
| 152 | + return `<!DOCTYPE html> |
| 153 | +<html lang="zh-CN"> |
| 154 | +<head> |
| 155 | +<meta charset="UTF-8"> |
| 156 | +<meta name="viewport" content="width=device-width,initial-scale=1"> |
| 157 | +<title>Export</title> |
| 158 | +<style> |
| 159 | +body{font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;line-height:1.6;color:#333;max-width:720px;margin:0 auto;padding:24px} |
| 160 | +h1,h2,h3{font-weight:600;margin-top:1.5em} |
| 161 | +pre,code{background:#f5f5f5;border-radius:4px;padding:2px 6px;font-family:ui-monospace,monospace} |
| 162 | +pre{overflow-x:auto;padding:12px} |
| 163 | +blockquote{border-left:4px solid #ddd;margin:0;padding-left:16px;color:#666} |
| 164 | +</style> |
| 165 | +</head> |
| 166 | +<body> |
| 167 | +<article>${bodyContent}</article> |
| 168 | +</body> |
| 169 | +</html>`; |
| 170 | + } |
| 171 | + |
| 172 | + async function doExport(msg, formats, btn) { |
| 173 | + if (!msg || !msg.content) return; |
| 174 | + const hasAny = formats.exportTxt || formats.exportMd || formats.exportPdf || formats.exportHtml; |
| 175 | + if (!hasAny) { |
| 176 | + ChatRawPlugin?.utils?.showToast?.(t('noFormatEnabled'), 'info'); |
| 177 | + return; |
| 178 | + } |
| 179 | + const fullContent = buildContent(msg); |
| 180 | + if (!fullContent.trim()) return; |
| 181 | + |
| 182 | + const originalTitle = btn.getAttribute('title'); |
| 183 | + btn.setAttribute('title', t('exporting')); |
| 184 | + btn.disabled = true; |
| 185 | + if (btn.querySelector('i')) btn.querySelector('i').className = 'ri-loader-4-line'; |
| 186 | + |
| 187 | + try { |
| 188 | + const base = 'export'; |
| 189 | + const delay = 200; |
| 190 | + |
| 191 | + if (formats.exportTxt) { |
| 192 | + const plain = stripMarkdown(fullContent); |
| 193 | + downloadBlob(new Blob([plain], { type: 'text/plain;charset=utf-8' }), `${base}.txt`); |
| 194 | + await new Promise(r => setTimeout(r, delay)); |
| 195 | + } |
| 196 | + if (formats.exportMd) { |
| 197 | + const md = buildMdContent(msg); |
| 198 | + downloadBlob(new Blob([md], { type: 'text/markdown;charset=utf-8' }), `${base}.md`); |
| 199 | + await new Promise(r => setTimeout(r, delay)); |
| 200 | + } |
| 201 | + if (formats.exportHtml) { |
| 202 | + const marked = window.marked; |
| 203 | + const htmlContent = marked ? marked.parse(fullContent) : fullContent.replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>'); |
| 204 | + const doc = getHtmlTemplate(htmlContent); |
| 205 | + downloadBlob(new Blob([doc], { type: 'text/html;charset=utf-8' }), `${base}.html`); |
| 206 | + await new Promise(r => setTimeout(r, delay)); |
| 207 | + } |
| 208 | + if (formats.exportPdf) { |
| 209 | + const ok = await loadHtml2pdf(); |
| 210 | + if (ok && window.html2pdf) { |
| 211 | + const marked = window.marked; |
| 212 | + const htmlContent = marked ? marked.parse(fullContent) : fullContent.replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>'); |
| 213 | + const div = document.createElement('div'); |
| 214 | + div.style.cssText = 'font-family:system-ui,sans-serif;line-height:1.6;color:#333;padding:20px;font-size:14px;max-width:600px'; |
| 215 | + div.innerHTML = htmlContent; |
| 216 | + document.body.appendChild(div); |
| 217 | + await window.html2pdf().set({ |
| 218 | + margin: 10, |
| 219 | + filename: `${base}.pdf`, |
| 220 | + image: { type: 'jpeg', quality: 0.98 }, |
| 221 | + html2canvas: { scale: 2 }, |
| 222 | + jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' } |
| 223 | + }).from(div).save(); |
| 224 | + document.body.removeChild(div); |
| 225 | + } else { |
| 226 | + ChatRawPlugin?.utils?.showToast?.(t('exportFailed') + ' (PDF)', 'error'); |
| 227 | + } |
| 228 | + } |
| 229 | + |
| 230 | + ChatRawPlugin?.utils?.showToast?.(t('exportSuccess'), 'success'); |
| 231 | + } catch (e) { |
| 232 | + console.error('[EnhancedExport] Export error:', e); |
| 233 | + ChatRawPlugin?.utils?.showToast?.(t('exportFailed'), 'error'); |
| 234 | + } finally { |
| 235 | + btn.disabled = false; |
| 236 | + btn.setAttribute('title', originalTitle || t('export')); |
| 237 | + if (btn.querySelector('i')) btn.querySelector('i').className = 'ri-download-2-line'; |
| 238 | + } |
| 239 | + } |
| 240 | + |
| 241 | + function getContentFromDom(messageEl) { |
| 242 | + var mc = messageEl.querySelector('.message-content'); |
| 243 | + if (!mc) return null; |
| 244 | + var divs = Array.prototype.filter.call(mc.children, function (c) { return c.tagName === 'DIV'; }); |
| 245 | + for (var i = 0; i < divs.length; i++) { |
| 246 | + var d = divs[i]; |
| 247 | + if (!d.classList.contains('thinking-block') && !d.classList.contains('message-actions') && !d.classList.contains('rag-references') && !d.classList.contains('typing-indicator') && (d.innerHTML || d.innerText)) { |
| 248 | + return { content: d.innerText || d.textContent || '' }; |
| 249 | + } |
| 250 | + } |
| 251 | + return null; |
| 252 | + } |
| 253 | + |
| 254 | + function injectExportButton(actionsEl) { |
| 255 | + var container = actionsEl.querySelector('.message-actions-plugin-slot') || actionsEl; |
| 256 | + if (container.querySelector && container.querySelector('.' + EXPORT_BTN_CLASS)) return; |
| 257 | + |
| 258 | + var hasAny = settings.exportTxt || settings.exportMd || settings.exportPdf || settings.exportHtml; |
| 259 | + if (!hasAny) return; |
| 260 | + |
| 261 | + var messageEl = actionsEl.closest('.message'); |
| 262 | + if (!messageEl || !messageEl.classList.contains('assistant')) return; |
| 263 | + |
| 264 | + if (!actionsEl.querySelector('.btn-copy')) return; |
| 265 | + |
| 266 | + var btn = document.createElement('button'); |
| 267 | + btn.className = 'btn-copy ' + EXPORT_BTN_CLASS; |
| 268 | + btn.setAttribute('title', t('export')); |
| 269 | + btn.innerHTML = '<i class="ri-download-2-line"></i>'; |
| 270 | + btn.addEventListener('click', async function (e) { |
| 271 | + e.preventDefault(); |
| 272 | + e.stopPropagation(); |
| 273 | + var m = getMsgForActions(actionsEl); |
| 274 | + if (!m) { |
| 275 | + var domContent = getContentFromDom(messageEl); |
| 276 | + if (domContent && domContent.content) { |
| 277 | + m = { role: 'assistant', content: domContent.content }; |
| 278 | + } |
| 279 | + } |
| 280 | + if (m) { |
| 281 | + await doExport(m, settings, btn); |
| 282 | + } else { |
| 283 | + ChatRawPlugin?.utils?.showToast?.(t('exportFailed'), 'error'); |
| 284 | + } |
| 285 | + }); |
| 286 | + container.appendChild(btn); |
| 287 | + } |
| 288 | + |
| 289 | + function processActions(actionsEl) { |
| 290 | + if (!actionsEl || !document.body.contains(actionsEl)) return; |
| 291 | + var msg = actionsEl.closest('.message'); |
| 292 | + if (!msg || !msg.classList.contains('assistant')) return; |
| 293 | + if (!actionsEl.querySelector('.btn-copy')) return; |
| 294 | + if (actionsEl.querySelector('.' + EXPORT_BTN_CLASS)) return; |
| 295 | + injectExportButton(actionsEl); |
| 296 | + } |
| 297 | + |
| 298 | + function scheduleProcessing(el) { |
| 299 | + var actionsEl = el; |
| 300 | + if (!el.classList || !el.classList.contains('message-actions')) { |
| 301 | + actionsEl = el.querySelector ? el.querySelector('.message-actions') : null; |
| 302 | + } |
| 303 | + if (!actionsEl || !document.body.contains(actionsEl)) return; |
| 304 | + processActions(actionsEl); |
| 305 | + } |
| 306 | + |
| 307 | + function processAllMessageActions() { |
| 308 | + var els = document.querySelectorAll('.messages .message-actions'); |
| 309 | + els.forEach(function(el) { scheduleProcessing(el); }); |
| 310 | + } |
| 311 | + |
| 312 | + function initObserver() { |
| 313 | + if (observerInitialized) return; |
| 314 | + |
| 315 | + var container = document.querySelector('.messages') || document.body; |
| 316 | + var observer = new MutationObserver(function() { |
| 317 | + processAllMessageActions(); |
| 318 | + }); |
| 319 | + observer.observe(container, { childList: true, subtree: true }); |
| 320 | + |
| 321 | + processAllMessageActions(); |
| 322 | + [50, 200, 500, 1000, 2000, 4000].forEach(function(ms) { |
| 323 | + setTimeout(processAllMessageActions, ms); |
| 324 | + }); |
| 325 | + setInterval(processAllMessageActions, 1500); |
| 326 | + observerInitialized = true; |
| 327 | + } |
| 328 | + |
| 329 | + async function loadSettings() { |
| 330 | + try { |
| 331 | + const res = await fetch('/api/plugins'); |
| 332 | + if (res.ok) { |
| 333 | + const plugins = await res.json(); |
| 334 | + const p = plugins.find(x => x.id === PLUGIN_ID); |
| 335 | + if (p?.settings_values) { |
| 336 | + settings = { ...settings, ...p.settings_values }; |
| 337 | + } |
| 338 | + } |
| 339 | + } catch (e) { |
| 340 | + console.error('[EnhancedExport] Failed to load settings:', e); |
| 341 | + } |
| 342 | + } |
| 343 | + |
| 344 | + function onAfterReceive() { |
| 345 | + if (observerInitialized) { |
| 346 | + setTimeout(processAllMessageActions, 100); |
| 347 | + setTimeout(processAllMessageActions, 500); |
| 348 | + } |
| 349 | + return { success: false }; |
| 350 | + } |
| 351 | + |
| 352 | + async function init() { |
| 353 | + try { |
| 354 | + await loadSettings(); |
| 355 | + initObserver(); |
| 356 | + if (ChatRawPlugin?.hooks?.register) { |
| 357 | + ChatRawPlugin.hooks.register('after_receive', { handler: onAfterReceive }); |
| 358 | + } |
| 359 | + } catch (e) { |
| 360 | + console.error('[EnhancedExport] Init error:', e); |
| 361 | + } |
| 362 | + } |
| 363 | + |
| 364 | + if (document.readyState === 'loading') { |
| 365 | + document.addEventListener('DOMContentLoaded', init); |
| 366 | + } else { |
| 367 | + init(); |
| 368 | + } |
| 369 | +})(window.ChatRawPlugin); |
0 commit comments