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 `
${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
? `
`
: ''
}
${
isFinished && 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