import { marked } from 'marked' import dayjs, { parseToShanghai } from '@/utils/time' import chatExportTemplate from './templates/chat-export-template.html?raw' // 统一的 Markdown 渲染配置 marked.setOptions({ gfm: true, breaks: true, mangle: false, headerIds: false }) export class ChatExporter { /** * 导出聊天对话为 HTML 文件 * @param {Object} options 导出选项 */ static async exportToHTML(options = {}) { const { chatTitle = '新对话', agentName = '智能助手', agentDescription = '', messages = [] } = options || {} try { const htmlContent = this.generateHTML({ chatTitle, agentName, agentDescription, messages }) const blob = new Blob([htmlContent], { type: 'text/html;charset=utf-8' }) const url = URL.createObjectURL(blob) const link = document.createElement('a') const timestamp = dayjs().tz('Asia/Shanghai').format('YYYYMMDD-HHmmss') const safeTitle = chatTitle.replace(/[\\/:*?"<>|]/g, '_') const filename = `${safeTitle}-${timestamp}.html` link.href = url link.download = filename link.style.display = 'none' document.body.appendChild(link) link.click() document.body.removeChild(link) URL.revokeObjectURL(url) return { success: true, filename } } catch (error) { console.error('导出对话失败:', error) throw new Error(`导出失败: ${error.message}`, { cause: error }) } } /** * 生成完整 HTML 内容 */ static generateHTML(options) { const { chatTitle, agentName, agentDescription, messages } = options const flattenedMessages = this.flattenMessages(messages) if (flattenedMessages.length === 0) { throw new Error('没有可导出的对话内容') } const messagesHTML = this.generateMessagesHTML(flattenedMessages, agentName) return this.generateHTMLTemplate({ chatTitle, agentName, agentDescription, exportTime: dayjs().tz('Asia/Shanghai').format('YYYY年MM月DD日 HH:mm:ss'), messagesHTML }) } /** * 扁平化消息列表 */ static flattenMessages(messages = []) { const result = [] console.log('[ChatExporter] flattenMessages input:', { messagesLength: messages?.length || 0, messagesType: Array.isArray(messages) ? 'array' : typeof messages, firstMessage: messages?.[0] ? { hasMessages: Array.isArray(messages[0].messages), hasType: !!messages[0].type, hasRole: !!messages[0].role, hasContent: !!messages[0].content, keys: Object.keys(messages[0]) } : null }) ;(messages || []).forEach((item) => { if (!item) return if (Array.isArray(item.messages)) { item.messages.forEach((msg) => { if (msg) result.push(msg) }) return } // 支持直接传入消息扁平数组 if (item.type || item.role || item.content) { result.push(item) } }) return result } /** * 生成对话消息的 HTML 片段 */ static generateMessagesHTML(messages, agentName) { return messages .map((msg) => { const isUserMessage = ['human', 'user'].includes(msg?.type) || msg?.role === 'user' const avatar = isUserMessage ? '👤' : '🤖' const senderLabel = isUserMessage ? '用户' : agentName || '智能助手' const messageClass = isUserMessage ? 'user-message' : 'ai-message' const timestampRaw = this.getMessageTimestamp(msg) const timestamp = this.escapeHtml(this.formatTimestamp(timestampRaw)) const { content, reasoning } = this.extractMessageContent(msg) const contentHTML = content ? this.renderMarkdown(content) : '' const reasoningHTML = !isUserMessage ? this.generateReasoningHTML(reasoning) : '' const toolCallsHTML = !isUserMessage ? this.generateToolCallsHTML(msg) : '' const bodySegments = [ reasoningHTML, contentHTML ? `
${contentHTML}
` : '', toolCallsHTML ].filter(Boolean) return `
${avatar} ${this.escapeHtml(senderLabel)} ${timestamp}
${bodySegments.length > 0 ? bodySegments.join('') : '
(此消息暂无可展示内容)
'}
` }) .join('') } /** * 拆分消息内容与推理文本 */ static extractMessageContent(msg = {}) { const content = this.normalizeContent(msg?.content) let reasoning = msg?.additional_kwargs?.reasoning_content || msg?.reasoning_content || '' let visibleContent = content if (!reasoning && content.includes('([\s\S]*?)<\/think>|([\s\S]*)$/i const match = content.match(thinkRegex) if (match) { reasoning = (match[1] || match[2] || '').trim() visibleContent = content.replace(match[0], '').trim() } } return { content: visibleContent, reasoning } } /** * 标准化消息内容 */ static normalizeContent(raw) { if (raw == null) return '' if (typeof raw === 'string') return raw if (Array.isArray(raw)) { return raw .map((item) => { if (!item) return '' if (typeof item === 'string') return item if (typeof item === 'object') { return item.text || item.content || item.value || '' } return String(item) }) .filter(Boolean) .join('\n') .trim() } if (typeof raw === 'object') { if (typeof raw.text === 'string') return raw.text if (typeof raw.content === 'string') return raw.content if (Array.isArray(raw.content)) return this.normalizeContent(raw.content) try { return JSON.stringify(raw, null, 2) } catch { return String(raw) } } return String(raw) } /** * 生成推理过程 HTML */ static generateReasoningHTML(reasoning) { if (!reasoning) return '' const reasoningHTML = this.renderMarkdown(reasoning) if (!reasoningHTML) return '' return `
💭 思考过程
${reasoningHTML}
` } /** * 生成工具调用 HTML */ static generateToolCallsHTML(msg = {}) { const toolCalls = this.normalizeToolCalls(msg) if (toolCalls.length === 0) return '' const sections = toolCalls .map((toolCall) => { const toolName = this.escapeHtml(toolCall?.function?.name || toolCall?.name || '工具调用') const argsSource = toolCall?.args ?? toolCall?.function?.arguments const args = this.stringifyToolArgs(argsSource) const result = this.normalizeToolResult(toolCall?.tool_call_result?.content) const isFinished = toolCall?.status === 'success' const stateClass = isFinished ? 'done' : 'pending' const stateLabel = isFinished ? '已完成' : '执行中' return `
🔧 ${toolName} ${stateLabel}
${ args ? `
参数
${this.escapeHtml(args)}
` : '' } ${ isFinished && result ? `
结果
${this.escapeHtml(result)}
` : '' }
` }) .join('') return `
${sections}
` } static normalizeToolCalls(msg = {}) { const rawCalls = msg.tool_calls || msg.additional_kwargs?.tool_calls if (!rawCalls) return [] if (Array.isArray(rawCalls)) return rawCalls.filter(Boolean) if (typeof rawCalls === 'object') { return Object.values(rawCalls).filter(Boolean) } return [] } static stringifyToolArgs(rawArgs) { if (rawArgs == null || rawArgs === '') return '' if (typeof rawArgs === 'string') { const trimmed = rawArgs.trim() if (!trimmed) return '' try { return JSON.stringify(JSON.parse(trimmed), null, 2) } catch { return trimmed } } if (typeof rawArgs === 'object') { try { return JSON.stringify(rawArgs, null, 2) } catch { return String(rawArgs) } } return String(rawArgs) } static normalizeToolResult(result) { if (!result) return '' if (typeof result === 'string') return result.trim() if (Array.isArray(result)) { return result .map((item) => { if (!item) return '' if (typeof item === 'string') return item if (typeof item === 'object') { return item.text || item.content || JSON.stringify(item, null, 2) } return String(item) }) .filter(Boolean) .join('\n\n') .trim() } if (typeof result === 'object') { if (typeof result.content !== 'undefined') { return this.normalizeToolResult(result.content) } try { return JSON.stringify(result, null, 2) } catch { return String(result) } } return String(result) } /** * 统一的 Markdown 渲染,失败时回退到简单换行 */ static renderMarkdown(content) { if (!content) return '' try { return marked.parse(content).trim() } catch (error) { console.warn('Markdown 渲染失败,回退为纯文本:', error) return this.escapeHtml(content).replace(/\n/g, '
') } } /** * HTML 转义 */ static escapeHtml(value) { if (value == null) return '' return String(value) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') } /** * 提取消息时间戳 */ static getMessageTimestamp(msg = {}) { const candidates = [ msg.timestamp, msg.created_at, msg.createdAt, msg.createdTime, msg.time, msg.datetime, msg.date, msg.additional_kwargs?.timestamp, msg.additional_kwargs?.created_at ] return candidates.find((value) => value !== undefined && value !== null) } /** * 格式化时间戳 */ static formatTimestamp(raw) { const fallback = dayjs().tz('Asia/Shanghai') if (raw instanceof Date) { return dayjs(raw).tz('Asia/Shanghai').format('YYYY年MM月DD日 HH:mm:ss') } if (raw || raw === 0) { if (typeof raw === 'number') { const value = raw < 1e12 ? raw * 1000 : raw return dayjs(value).tz('Asia/Shanghai').format('YYYY年MM月DD日 HH:mm:ss') } const parsed = parseToShanghai(raw) if (parsed) { return parsed.format('YYYY年MM月DD日 HH:mm:ss') } } return fallback.format('YYYY年MM月DD日 HH:mm:ss') } /** * 生成完整 HTML 文档骨架 */ static generateHTMLTemplate(options) { const { chatTitle, agentName, agentDescription, exportTime, messagesHTML } = options const safeTitle = this.escapeHtml(chatTitle) const safeAgentName = this.escapeHtml(agentName) const safeDescription = this.escapeHtml(agentDescription).replace(/\n/g, '
') const safeExportTime = this.escapeHtml(exportTime) const descriptionBlock = agentDescription ? `
描述: ${safeDescription}` : '' return chatExportTemplate .replace(/{{TITLE}}/g, safeTitle) .replace('{{AGENT_NAME}}', safeAgentName) .replace('{{DESCRIPTION_BLOCK}}', descriptionBlock) .replace('{{EXPORT_TIME}}', safeExportTime) .replace('{{MESSAGES}}', messagesHTML) } } export default ChatExporter