From 6c411438e0518cce3c7086d359db29636a8a7b37 Mon Sep 17 00:00:00 2001 From: gaoziman <2942894660@qq.com> Date: Wed, 24 Dec 2025 09:40:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=AF=BC=E5=87=BA=E5=8A=9F=E8=83=BD):=20?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=AF=B9=E8=AF=9D=E5=AF=BC=E5=87=BA=E6=A0=B8?= =?UTF-8?q?=E5=BF=83=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现 ExportData 和 ExportOptions 类型定义 - 实现 Markdown 格式导出器 - 实现 JSON 格式导出器 - 实现 HTML 格式导出器(含完整样式) - 实现 PDF 格式导出器(客户端生成) - 提供统一的导出入口和工具函数 --- src/lib/export/html.ts | 1236 ++++++++++++++++++++++++++++++++++++ src/lib/export/index.ts | 102 +++ src/lib/export/json.ts | 54 ++ src/lib/export/markdown.ts | 226 +++++++ src/lib/export/pdf.ts | 203 ++++++ src/lib/export/types.ts | 106 ++++ 6 files changed, 1927 insertions(+) create mode 100644 src/lib/export/html.ts create mode 100644 src/lib/export/index.ts create mode 100644 src/lib/export/json.ts create mode 100644 src/lib/export/markdown.ts create mode 100644 src/lib/export/pdf.ts create mode 100644 src/lib/export/types.ts diff --git a/src/lib/export/html.ts b/src/lib/export/html.ts new file mode 100644 index 0000000..3858399 --- /dev/null +++ b/src/lib/export/html.ts @@ -0,0 +1,1236 @@ +/** + * HTML 导出器 + * 将对话数据导出为完整的 HTML 文件 + * 设计主题:LionCode 橙色 (#DB6639) + */ + +import type { ExportData, ExportOptions, ExportMessageData, Exporter } from './types'; + +// 工具名称中文映射 +const TOOL_NAMES: Record = { + web_search: '网络搜索', + web_fetch: '网页读取', + mita_search: '秘塔搜索', + mita_reader: '秘塔阅读', + code_execution: '代码执行', + youdao_translate: '有道翻译', + image_search: '图片搜索', + video_search: '视频搜索', +}; + +// 工具图标映射 +const TOOL_ICONS: Record = { + web_search: '🔍', + web_fetch: '📄', + mita_search: '🔎', + mita_reader: '📖', + code_execution: '💻', + youdao_translate: '🌐', + image_search: '🖼️', + video_search: '🎬', +}; + +/** + * 格式化日期时间 + */ +function formatDateTime(dateString: string): string { + const date = new Date(dateString); + return date.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); +} + +/** + * 格式化简短日期 + */ +function formatShortDate(dateString: string): string { + const date = new Date(dateString); + return date.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + }); +} + +/** + * 格式化时间 + */ +function formatTime(dateString: string): string { + const date = new Date(dateString); + return date.toLocaleString('zh-CN', { + hour: '2-digit', + minute: '2-digit', + }); +} + +/** + * 格式化文件大小 + */ +function formatFileSize(bytes: number): string { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +} + +/** + * 转义 HTML 特殊字符 + */ +function escapeHtml(text: string): string { + const map: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }; + return text.replace(/[&<>"']/g, (m) => map[m]); +} + +/** + * 简单的 Markdown 转 HTML(基础支持) + */ +function markdownToHtml(content: string): string { + let html = escapeHtml(content); + + // 代码块 + html = html.replace(/```(\w*)\n([\s\S]*?)```/g, '
$2
'); + + // 行内代码 + html = html.replace(/`([^`]+)`/g, '$1'); + + // 粗体 + html = html.replace(/\*\*(.+?)\*\*/g, '$1'); + + // 斜体 + html = html.replace(/\*(.+?)\*/g, '$1'); + + // 链接 + html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); + + // 标题(从h6到h1,避免误匹配) + html = html.replace(/^###### (.+)$/gm, '
$1
'); + html = html.replace(/^##### (.+)$/gm, '
$1
'); + html = html.replace(/^#### (.+)$/gm, '

$1

'); + html = html.replace(/^### (.+)$/gm, '

$1

'); + html = html.replace(/^## (.+)$/gm, '

$1

'); + html = html.replace(/^# (.+)$/gm, '

$1

'); + + // 列表 + html = html.replace(/^- (.+)$/gm, '
  • $1
  • '); + html = html.replace(/(
  • .*<\/li>\n?)+/g, '
      $&
    '); + + // 段落 + html = html.replace(/\n\n/g, '

    '); + html = '

    ' + html + '

    '; + html = html.replace(/

    <\/p>/g, ''); + html = html.replace(/

    ()/g, '$1'); + html = html.replace(/(<\/h[1-6]>)<\/p>/g, '$1'); + html = html.replace(/

    (

      )/g, '$1'); + html = html.replace(/(<\/ul>)<\/p>/g, '$1'); + html = html.replace(/

      (

      )/g, '$1');
      +  html = html.replace(/(<\/pre>)<\/p>/g, '$1');
      +
      +  return html;
      +}
      +
      +/**
      + * 生成消息 HTML
      + */
      +function generateMessageHtml(
      +  message: ExportMessageData,
      +  options: ExportOptions
      +): string {
      +  const isUser = message.role === 'user';
      +  const roleClass = isUser ? 'user-message' : 'assistant-message';
      +  const time = formatTime(message.createdAt);
      +
      +  let html = `
      `; + + if (isUser) { + // 用户消息 - 气泡在左,圆形头像在右 + html += `
      `; + html += `
      `; + + // 用户消息:显示上传的图片 + if (options.includeImages && message.uploadedImages && message.uploadedImages.length > 0) { + html += `
      `; + html += `
      📷 上传的图片
      `; + html += `
      `; + message.uploadedImages.forEach((img, index) => { + html += `图片 ${index + 1}`; + }); + html += `
      `; + } + + // 用户消息:显示上传的文档 + if (message.uploadedDocuments && message.uploadedDocuments.length > 0) { + html += `
      `; + html += `
      📎 上传的文档
      `; + html += `
      `; + message.uploadedDocuments.forEach((doc) => { + html += `
      `; + html += `📄`; + html += `${escapeHtml(doc.name)}`; + html += `${formatFileSize(doc.size)}`; + html += `
      `; + }); + html += `
      `; + } + + // 消息内容 + if (message.content) { + html += `
      ${markdownToHtml(message.content)}
      `; + } + + html += `
      `; + // 右侧圆形头像 + html += `
      M
      `; + } else { + // AI 消息 - 保持原有结构 + html += `
      `; + html += `
      `; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += `
      `; + html += `LionCode AI`; + html += `${time}`; + html += `
      `; + + html += `
      `; + + // AI 消息:显示使用的工具(放在内容之前) + if (message.usedTools && message.usedTools.length > 0) { + html += `
      `; + message.usedTools.forEach((tool) => { + const icon = TOOL_ICONS[tool] || '🔧'; + const name = TOOL_NAMES[tool] || tool; + html += `${icon} ${name}`; + }); + html += `
      `; + } + + // AI 消息:显示思考过程 + if (options.includeThinking && message.thinkingContent) { + html += `
      `; + html += `💭 思考过程`; + html += `
      ${escapeHtml(message.thinkingContent)}
      `; + html += `
      `; + } + + // 消息内容 + if (message.content) { + html += `
      ${markdownToHtml(message.content)}
      `; + } + + // AI 消息:显示工具调用详情 + if (options.includeToolCalls && message.toolCalls && message.toolCalls.length > 0) { + html += `
      `; + html += `🛠️ 工具调用详情`; + html += `
      `; + message.toolCalls.forEach((call, index) => { + const icon = TOOL_ICONS[call.name] || '🔧'; + const name = TOOL_NAMES[call.name] || call.name; + html += `
      `; + html += `
      ${icon} 调用 ${index + 1}: ${name}
      `; + html += `
      ${escapeHtml(JSON.stringify(call.input, null, 2))}
      `; + html += `
      `; + }); + html += `
      `; + } + + // AI 消息:显示代码执行图片 + if (options.includeImages && message.images && message.images.length > 0) { + html += `
      `; + html += `
      📊 代码执行结果
      `; + html += `
      `; + message.images.forEach((img, index) => { + const imgSrc = img.startsWith('data:') ? img : `data:image/png;base64,${img}`; + html += `图表 ${index + 1}`; + }); + html += `
      `; + } + + // AI 消息:显示搜索到的图片 + if (options.includeImages && message.searchImages && message.searchImages.length > 0) { + html += `
      `; + html += `
      🖼️ 搜索结果图片
      `; + html += `
      `; + message.searchImages.forEach((img, index) => { + html += ``; + html += `${escapeHtml(img.title || `图片 ${index + 1}`)}`; + if (img.title) { + html += `${escapeHtml(img.title)}`; + } + html += ``; + }); + html += `
      `; + } + + // AI 消息:显示 Token 统计 + if (message.inputTokens || message.outputTokens) { + const input = message.inputTokens || 0; + const output = message.outputTokens || 0; + html += `
      `; + html += `Token 消耗:`; + html += `输入 ${input.toLocaleString()}`; + html += `/`; + html += `输出 ${output.toLocaleString()}`; + html += `
      `; + } + + html += `
      `; + } + + html += `
      `; + return html; +} + +/** + * 生成完整的 HTML 样式 - LionCode 主题 + */ +function generateStyles(): string { + return ` + /* ======================================== + LionCode HTML Export Theme + Primary Color: #DB6639 (LionCode Orange) + ======================================== */ + + :root { + /* 主色调 */ + --primary: #DB6639; + --primary-light: #F4D9CF; + --primary-lighter: #FDF5F2; + --primary-dark: #C25A33; + --primary-darker: #A84D2B; + + /* 浅色模式 */ + --bg-page: #F8F9FA; + --bg-card: #FFFFFF; + --bg-secondary: #F3F4F6; + --bg-tertiary: #E5E7EB; + --bg-user-bubble: #E8E8E6; + --bg-ai-message: #FFFFFF; + + --text-primary: #1F2937; + --text-secondary: #4B5563; + --text-tertiary: #9CA3AF; + --text-inverse: #FFFFFF; + + --border-color: #E5E7EB; + --border-light: #F3F4F6; + + /* 思考区域 */ + --thinking-bg: #FDF5F2; + --thinking-border: #F4D9CF; + + /* 代码区域 */ + --code-bg: #1E293B; + --code-text: #E2E8F0; + --code-inline-bg: #F1F5F9; + --code-inline-text: #334155; + + /* 阴影 */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1); + } + + @media (prefers-color-scheme: dark) { + :root { + --bg-page: #0F172A; + --bg-card: #1E293B; + --bg-secondary: #334155; + --bg-tertiary: #475569; + --bg-user-bubble: #334155; + --bg-ai-message: #1E293B; + + --text-primary: #F1F5F9; + --text-secondary: #CBD5E1; + --text-tertiary: #64748B; + --text-inverse: #FFFFFF; + + --border-color: #334155; + --border-light: #475569; + + --thinking-bg: #2D1F1A; + --thinking-border: #5C3D2E; + + --code-bg: #0D1117; + --code-text: #E6EDF3; + --code-inline-bg: #334155; + --code-inline-text: #E2E8F0; + } + } + + * { + margin: 0; + padding: 0; + box-sizing: border-box; + } + + body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif; + font-size: 15px; + line-height: 1.7; + background-color: var(--bg-page); + color: var(--text-primary); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + + .container { + max-width: 900px; + margin: 0 auto; + padding: 24px; + } + + /* ======== 头部区域 ======== */ + header { + background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%); + color: var(--text-inverse); + padding: 16px 20px; + border-radius: 10px; + margin-bottom: 20px; + box-shadow: var(--shadow-md); + position: relative; + overflow: hidden; + } + + header::before { + content: ''; + position: absolute; + top: -50%; + right: -20%; + width: 200px; + height: 200px; + background: radial-gradient(circle, rgba(255,255,255,0.06) 0%, transparent 70%); + pointer-events: none; + } + + .header-brand { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; + } + + .brand-logo { + width: 28px; + height: 28px; + background: rgba(255, 255, 255, 0.2); + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + backdrop-filter: blur(4px); + } + + .brand-text { + font-size: 12px; + font-weight: 500; + opacity: 0.9; + } + + header h1 { + font-size: 16px; + font-weight: 600; + margin-bottom: 12px; + line-height: 1.4; + position: relative; + } + + .meta-cards { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 8px; + } + + .meta-card { + background: rgba(255, 255, 255, 0.12); + backdrop-filter: blur(4px); + padding: 8px 10px; + border-radius: 6px; + } + + .meta-card-label { + font-size: 10px; + opacity: 0.75; + margin-bottom: 2px; + } + + .meta-card-value { + font-size: 12px; + font-weight: 600; + } + + /* ======== 消息区域 ======== */ + main { + display: flex; + flex-direction: column; + gap: 20px; + } + + .message { + border-radius: 12px; + overflow: hidden; + box-shadow: var(--shadow-sm); + } + + /* 用户消息 - 模仿原聊天界面样式 */ + .user-message { + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 12px; + background: transparent; + box-shadow: none; + margin-left: 80px; + } + + .user-message .message-wrapper { + flex: 1; + background: var(--bg-user-bubble); + border-radius: 12px; + overflow: hidden; + } + + .user-message .message-header { + display: none; + } + + .user-message .user-avatar-right { + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--primary); + color: white; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + font-weight: 600; + flex-shrink: 0; + } + + .user-message .message-body { + padding: 14px 16px; + } + + .user-message .message-content { + color: var(--text-primary); + } + + .user-message .message-content a { + color: var(--primary); + } + + .user-message .message-content code { + background: rgba(0, 0, 0, 0.06); + color: var(--text-primary); + } + + /* AI 消息 - 移除左侧边框 */ + .assistant-message { + background: var(--bg-ai-message); + border: 1px solid var(--border-color); + margin-right: 80px; + } + + .assistant-message .message-header { + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); + } + + /* 消息头部 */ + .message-header { + display: flex; + align-items: center; + gap: 10px; + padding: 14px 18px; + } + + .avatar { + width: 32px; + height: 32px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + font-weight: 600; + flex-shrink: 0; + } + + .user-avatar { + background: rgba(255, 255, 255, 0.25); + } + + .ai-avatar { + background: var(--primary); + color: white; + } + + .ai-avatar svg { + width: 18px; + height: 18px; + } + + .role-name { + font-weight: 600; + font-size: 14px; + color: var(--text-primary); + } + + .message-time { + margin-left: auto; + font-size: 12px; + color: var(--text-tertiary); + } + + /* 消息内容区 */ + .message-body { + padding: 18px; + } + + .message-content { + color: var(--text-primary); + line-height: 1.8; + } + + .message-content p { + margin-bottom: 14px; + } + + .message-content p:last-child { + margin-bottom: 0; + } + + .message-content h1, + .message-content h2, + .message-content h3, + .message-content h4, + .message-content h5, + .message-content h6 { + margin: 20px 0 12px; + color: var(--text-primary); + font-weight: 600; + } + + .message-content h1 { font-size: 1.5em; } + .message-content h2 { font-size: 1.35em; } + .message-content h3 { font-size: 1.2em; } + .message-content h4 { font-size: 1.1em; } + .message-content h5 { font-size: 1.05em; } + .message-content h6 { font-size: 1em; color: var(--text-secondary); } + + .message-content code { + background: var(--code-inline-bg); + color: var(--code-inline-text); + padding: 2px 8px; + border-radius: 4px; + font-family: 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace; + font-size: 0.9em; + } + + .message-content pre { + background: var(--code-bg); + color: var(--code-text); + padding: 18px; + border-radius: 10px; + overflow-x: auto; + margin: 16px 0; + font-family: 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace; + font-size: 13px; + line-height: 1.6; + } + + .message-content pre code { + background: none; + padding: 0; + color: inherit; + } + + .message-content ul, + .message-content ol { + margin: 12px 0; + padding-left: 24px; + } + + .message-content li { + margin-bottom: 6px; + } + + .message-content a { + color: var(--primary); + text-decoration: none; + font-weight: 500; + } + + .message-content a:hover { + text-decoration: underline; + } + + .message-content strong { + font-weight: 600; + } + + /* ======== 工具标签 ======== */ + .tools-used { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 14px; + } + + .tool-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 5px 12px; + background: var(--primary-lighter); + color: var(--primary-dark); + border-radius: 20px; + font-size: 12px; + font-weight: 500; + border: 1px solid var(--primary-light); + } + + /* ======== 思考区域 ======== */ + .thinking-section { + margin: 14px 0; + background: var(--thinking-bg); + border: 1px solid var(--thinking-border); + border-radius: 10px; + overflow: hidden; + } + + .thinking-section summary { + padding: 12px 16px; + cursor: pointer; + font-weight: 500; + color: var(--primary); + display: flex; + align-items: center; + gap: 8px; + user-select: none; + } + + .thinking-section summary:hover { + background: var(--primary-light); + } + + .thinking-section summary::marker, + .thinking-section summary::-webkit-details-marker { + display: none; + } + + .thinking-section summary::before { + content: '▶'; + font-size: 10px; + transition: transform 0.2s ease; + } + + .thinking-section[open] summary::before { + transform: rotate(90deg); + } + + .thinking-icon { + font-size: 16px; + } + + .thinking-content { + padding: 16px; + font-size: 13px; + line-height: 1.7; + color: var(--text-secondary); + white-space: pre-wrap; + word-wrap: break-word; + border-top: 1px solid var(--thinking-border); + background: rgba(255, 255, 255, 0.5); + } + + @media (prefers-color-scheme: dark) { + .thinking-content { + background: rgba(0, 0, 0, 0.2); + } + } + + /* ======== 工具调用详情 ======== */ + .tool-calls-section { + margin: 14px 0; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 10px; + overflow: hidden; + } + + .tool-calls-section summary { + padding: 12px 16px; + cursor: pointer; + font-weight: 500; + color: var(--text-secondary); + display: flex; + align-items: center; + gap: 8px; + } + + .tool-calls-section summary:hover { + background: var(--bg-tertiary); + } + + .tool-calls-section summary::marker, + .tool-calls-section summary::-webkit-details-marker { + display: none; + } + + .tool-calls-section summary::before { + content: '▶'; + font-size: 10px; + transition: transform 0.2s ease; + } + + .tool-calls-section[open] summary::before { + transform: rotate(90deg); + } + + .tool-icon { + font-size: 16px; + } + + .tool-calls { + padding: 16px; + border-top: 1px solid var(--border-color); + } + + .tool-call { + margin-bottom: 16px; + } + + .tool-call:last-child { + margin-bottom: 0; + } + + .tool-call-header { + font-weight: 600; + font-size: 13px; + margin-bottom: 8px; + color: var(--text-primary); + } + + .tool-call-input { + background: var(--code-bg); + color: var(--code-text); + padding: 14px; + border-radius: 8px; + font-size: 12px; + line-height: 1.5; + overflow-x: auto; + } + + /* ======== 附件区域 ======== */ + .attachment-section, + .result-section { + margin: 12px 0; + padding: 14px; + background: rgba(255, 255, 255, 0.5); + border-radius: 10px; + border: 1px solid rgba(0, 0, 0, 0.08); + } + + @media (prefers-color-scheme: dark) { + .attachment-section, + .result-section { + background: rgba(0, 0, 0, 0.2); + border-color: rgba(255, 255, 255, 0.08); + } + } + + .user-message .attachment-section { + background: rgba(0, 0, 0, 0.1); + border-color: rgba(255, 255, 255, 0.1); + } + + .attachment-title, + .result-title { + font-weight: 600; + font-size: 13px; + margin-bottom: 10px; + display: flex; + align-items: center; + gap: 6px; + } + + .user-message .attachment-title { + color: rgba(255, 255, 255, 0.9); + } + + .attachment-icon, + .result-icon { + font-size: 14px; + } + + /* 图片网格 */ + .images-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 12px; + } + + .uploaded-image, + .code-image { + width: 100%; + height: auto; + max-height: 240px; + object-fit: contain; + border-radius: 8px; + background: var(--bg-secondary); + } + + .user-message .uploaded-image { + background: rgba(0, 0, 0, 0.2); + } + + /* 搜索图片 */ + .search-images-grid { + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + } + + .search-image-link { + display: flex; + flex-direction: column; + text-decoration: none; + background: var(--bg-secondary); + border-radius: 8px; + overflow: hidden; + transition: transform 0.2s ease, box-shadow 0.2s ease; + } + + .search-image-link:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-md); + } + + .search-image { + width: 100%; + height: 120px; + object-fit: cover; + } + + .search-image-title { + padding: 8px 10px; + font-size: 12px; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + /* 文档列表 */ + .document-list { + display: flex; + flex-direction: column; + gap: 8px; + } + + .document-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + background: rgba(255, 255, 255, 0.6); + border-radius: 8px; + } + + .user-message .document-item { + background: rgba(0, 0, 0, 0.15); + } + + .doc-icon { + font-size: 16px; + flex-shrink: 0; + } + + .doc-name { + font-weight: 500; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .doc-size { + font-size: 12px; + opacity: 0.7; + flex-shrink: 0; + } + + /* ======== Token 统计 ======== */ + .token-stats { + margin-top: 14px; + padding: 10px 14px; + background: var(--bg-secondary); + border-radius: 8px; + font-size: 12px; + color: var(--text-tertiary); + display: flex; + align-items: center; + gap: 6px; + } + + .token-label { + font-weight: 500; + } + + .token-value { + color: var(--text-secondary); + } + + .token-divider { + opacity: 0.5; + } + + /* ======== 页脚 ======== */ + footer { + margin-top: 24px; + padding: 16px 20px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 10px; + text-align: center; + } + + .footer-brand { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin-bottom: 6px; + } + + .footer-logo { + width: 22px; + height: 22px; + background: var(--primary); + border-radius: 5px; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + } + + .footer-title { + font-weight: 600; + font-size: 13px; + color: var(--text-primary); + } + + .footer-desc { + font-size: 12px; + color: var(--text-tertiary); + margin-bottom: 4px; + } + + .footer-link { + color: var(--primary); + text-decoration: none; + font-weight: 500; + font-size: 12px; + } + + .footer-link:hover { + text-decoration: underline; + } + + /* ======== 打印样式 ======== */ + @media print { + body { + background: white; + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } + + .container { + max-width: 100%; + padding: 0; + } + + header { + border-radius: 0; + margin-bottom: 24px; + box-shadow: none; + } + + .message { + break-inside: avoid; + page-break-inside: avoid; + box-shadow: none; + } + + .thinking-section, + .tool-calls-section { + break-inside: avoid; + page-break-inside: avoid; + } + + footer { + border-radius: 0; + margin-top: 24px; + } + + .user-message { + margin-left: 40px; + } + + .assistant-message { + margin-right: 40px; + } + } + + /* ======== 响应式设计 ======== */ + @media (max-width: 640px) { + .container { + padding: 16px; + } + + header { + padding: 20px; + border-radius: 10px; + } + + header h1 { + font-size: 18px; + } + + .meta-cards { + grid-template-columns: repeat(2, 1fr); + gap: 8px; + } + + .meta-card { + padding: 8px 10px; + } + + .meta-card-value { + font-size: 13px; + } + + .user-message { + margin-left: 20px; + } + + .assistant-message { + margin-right: 20px; + } + + .message-header { + padding: 10px 12px; + } + + .message-body { + padding: 12px; + } + + .images-grid { + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 8px; + } + + .user-avatar-right { + width: 32px; + height: 32px; + font-size: 14px; + } + } + `; +} + +/** + * HTML 导出器 + */ +export const htmlExporter: Exporter = { + async export(data: ExportData, options: ExportOptions): Promise { + const { conversation, messages, exportInfo } = data; + + let html = '\n'; + html += '\n'; + html += '\n'; + html += '\n'; + html += '\n'; + html += `${escapeHtml(conversation.title)} - LionCode 对话导出\n`; + html += '\n'; + html += `\n`; + html += `\n`; + html += '\n'; + html += '\n'; + html += '
      \n'; + + // 头部 + html += '
      \n'; + html += '
      \n'; + html += '\n'; + html += 'LionCode AI\n'; + html += '
      \n'; + html += `

      ${escapeHtml(conversation.title)}

      \n`; + html += '
      \n'; + html += '
      \n'; + html += '
      导出时间
      \n'; + html += `
      ${formatShortDate(exportInfo.exportedAt)}
      \n`; + html += '
      \n'; + html += '
      \n'; + html += '
      使用模型
      \n'; + html += `
      ${escapeHtml(conversation.model)}
      \n`; + html += '
      \n'; + html += '
      \n'; + html += '
      消息总数
      \n'; + html += `
      ${conversation.messageCount}
      \n`; + html += '
      \n'; + html += '
      \n'; + html += '
      Token 消耗
      \n'; + html += `
      ${conversation.totalTokens.toLocaleString()}
      \n`; + html += '
      \n'; + html += '
      \n'; + html += '
      \n'; + + // 消息列表 + html += '
      \n'; + messages.forEach((message) => { + html += generateMessageHtml(message, options); + }); + html += '
      \n'; + + // 页脚 + html += '
      \n'; + html += '\n'; + html += '\n'; + html += '访问 LionCode\n'; + html += '
      \n'; + + html += '
      \n'; + html += '\n'; + html += ''; + + return html; + }, + + getContentType(): string { + return 'text/html; charset=utf-8'; + }, + + getFileExtension(): string { + return 'html'; + }, +}; diff --git a/src/lib/export/index.ts b/src/lib/export/index.ts new file mode 100644 index 0000000..e908d95 --- /dev/null +++ b/src/lib/export/index.ts @@ -0,0 +1,102 @@ +/** + * 对话导出功能 - 统一入口 + */ + +// 导出类型定义 +export type { + ExportFormat, + ExportOptions, + ExportData, + ExportConversationData, + ExportMessageData, + UploadedDocumentData, + SearchImageData, + ToolCallData, + ToolResultData, + Exporter, +} from './types'; + +export { TOOL_DISPLAY_NAMES } from './types'; + +// 导出各格式导出器 +export { markdownExporter } from './markdown'; +export { jsonExporter } from './json'; +export { htmlExporter } from './html'; +export { pdfExporter, generatePdfInBrowser } from './pdf'; + +import type { ExportFormat, ExportOptions, ExportData, Exporter } from './types'; +import { markdownExporter } from './markdown'; +import { jsonExporter } from './json'; +import { htmlExporter } from './html'; +import { pdfExporter } from './pdf'; + +/** + * 获取指定格式的导出器 + */ +export function getExporter(format: ExportFormat): Exporter { + switch (format) { + case 'markdown': + return markdownExporter; + case 'json': + return jsonExporter; + case 'html': + return htmlExporter; + case 'pdf': + return pdfExporter; + default: + throw new Error(`Unsupported export format: ${format}`); + } +} + +/** + * 导出对话数据 + */ +export async function exportConversation( + data: ExportData, + options: ExportOptions +): Promise { + const exporter = getExporter(options.format); + return exporter.export(data, options); +} + +/** + * 获取导出文件的 MIME 类型 + */ +export function getExportContentType(format: ExportFormat): string { + return getExporter(format).getContentType(); +} + +/** + * 获取导出文件的扩展名 + */ +export function getExportFileExtension(format: ExportFormat): string { + return getExporter(format).getFileExtension(); +} + +/** + * 生成导出文件名 + */ +export function generateExportFilename( + title: string, + format: ExportFormat +): string { + // 清理文件名中的特殊字符 + const cleanTitle = title + .replace(/[<>:"/\\|?*]/g, '') + .replace(/\s+/g, '_') + .slice(0, 50); + + const timestamp = new Date().toISOString().slice(0, 10); + const extension = getExportFileExtension(format); + + return `${cleanTitle}_${timestamp}.${extension}`; +} + +/** + * 默认导出选项 + */ +export const DEFAULT_EXPORT_OPTIONS: Omit = { + includeThinking: true, + includeToolCalls: true, + includeImages: true, +}; diff --git a/src/lib/export/json.ts b/src/lib/export/json.ts new file mode 100644 index 0000000..0e8df60 --- /dev/null +++ b/src/lib/export/json.ts @@ -0,0 +1,54 @@ +/** + * JSON 导出器 + * 将对话数据导出为 JSON 格式(完整数据备份) + */ + +import type { ExportData, ExportOptions, Exporter } from './types'; + +/** + * JSON 导出器 + */ +export const jsonExporter: Exporter = { + async export(data: ExportData, options: ExportOptions): Promise { + // 根据选项过滤数据 + const filteredData = { ...data }; + + // 如果不包含思考过程,移除 thinkingContent + if (!options.includeThinking) { + filteredData.messages = data.messages.map((msg) => ({ + ...msg, + thinkingContent: undefined, + })); + } + + // 如果不包含工具调用,移除 toolCalls 和 toolResults + if (!options.includeToolCalls) { + filteredData.messages = (filteredData.messages || data.messages).map((msg) => ({ + ...msg, + toolCalls: undefined, + toolResults: undefined, + })); + } + + // 如果不包含图片,移除图片数据 + if (!options.includeImages) { + filteredData.messages = (filteredData.messages || data.messages).map((msg) => ({ + ...msg, + images: undefined, + uploadedImages: undefined, + searchImages: undefined, + })); + } + + // 格式化输出 JSON + return JSON.stringify(filteredData, null, 2); + }, + + getContentType(): string { + return 'application/json; charset=utf-8'; + }, + + getFileExtension(): string { + return 'json'; + }, +}; diff --git a/src/lib/export/markdown.ts b/src/lib/export/markdown.ts new file mode 100644 index 0000000..42908cd --- /dev/null +++ b/src/lib/export/markdown.ts @@ -0,0 +1,226 @@ +/** + * Markdown 导出器 + * 将对话数据导出为 Markdown 格式 + */ + +import type { + ExportData, + ExportOptions, + ExportMessageData, + Exporter, + TOOL_DISPLAY_NAMES, +} from './types'; + +// 工具名称中文映射 +const TOOL_NAMES: Record = { + web_search: '网络搜索', + web_fetch: '网页读取', + mita_search: '秘塔搜索', + mita_reader: '秘塔阅读', + code_execution: '代码执行', + youdao_translate: '有道翻译', +}; + +/** + * 格式化日期时间 + */ +function formatDateTime(dateString: string): string { + const date = new Date(dateString); + return date.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); +} + +/** + * 格式化文件大小 + */ +function formatFileSize(bytes: number): string { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +} + +/** + * 生成消息的 Markdown 内容 + */ +function generateMessageMarkdown( + message: ExportMessageData, + options: ExportOptions, + messageIndex: number +): string { + const lines: string[] = []; + const isUser = message.role === 'user'; + const roleIcon = isUser ? '👤' : '🤖'; + const roleName = isUser ? '用户' : 'AI 助手'; + const time = formatDateTime(message.createdAt); + + // 消息头部 + lines.push(`## ${roleIcon} ${roleName} (${time})`); + lines.push(''); + + // 用户消息:显示上传的图片和文档 + if (isUser) { + // 上传的图片 + if (options.includeImages && message.uploadedImages && message.uploadedImages.length > 0) { + lines.push('**📷 上传的图片:**'); + lines.push(''); + message.uploadedImages.forEach((img, index) => { + // 如果是 Base64 图片,直接嵌入;否则作为链接 + if (img.startsWith('data:')) { + lines.push(`![图片 ${index + 1}](${img})`); + } else { + lines.push(`![图片 ${index + 1}](${img})`); + } + }); + lines.push(''); + } + + // 上传的文档 + if (message.uploadedDocuments && message.uploadedDocuments.length > 0) { + lines.push('**📎 上传的文档:**'); + lines.push(''); + message.uploadedDocuments.forEach((doc) => { + lines.push(`- **${doc.name}** (${formatFileSize(doc.size)}, ${doc.type})`); + }); + lines.push(''); + } + } + + // AI 消息:显示思考过程 + if (!isUser && options.includeThinking && message.thinkingContent) { + lines.push('
      '); + lines.push('💭 思考过程'); + lines.push(''); + lines.push('```'); + lines.push(message.thinkingContent); + lines.push('```'); + lines.push(''); + lines.push('
      '); + lines.push(''); + } + + // 消息内容 + if (message.content) { + lines.push(message.content); + lines.push(''); + } + + // AI 消息:显示使用的工具 + if (!isUser && message.usedTools && message.usedTools.length > 0) { + const toolNames = message.usedTools + .map((tool) => TOOL_NAMES[tool] || tool) + .join('、'); + lines.push(`🔧 **使用工具:** ${toolNames}`); + lines.push(''); + } + + // AI 消息:显示工具调用详情 + if (!isUser && options.includeToolCalls && message.toolCalls && message.toolCalls.length > 0) { + lines.push('
      '); + lines.push('🛠️ 工具调用详情'); + lines.push(''); + message.toolCalls.forEach((call, index) => { + lines.push(`**调用 ${index + 1}: ${TOOL_NAMES[call.name] || call.name}**`); + lines.push('```json'); + lines.push(JSON.stringify(call.input, null, 2)); + lines.push('```'); + lines.push(''); + }); + lines.push('
      '); + lines.push(''); + } + + // AI 消息:显示代码执行图片 + if (!isUser && options.includeImages && message.images && message.images.length > 0) { + lines.push('**📊 代码执行结果:**'); + lines.push(''); + message.images.forEach((img, index) => { + const imgSrc = img.startsWith('data:') ? img : `data:image/png;base64,${img}`; + lines.push(`![图表 ${index + 1}](${imgSrc})`); + }); + lines.push(''); + } + + // AI 消息:显示搜索到的图片 + if (!isUser && options.includeImages && message.searchImages && message.searchImages.length > 0) { + lines.push('**🔍 搜索结果图片:**'); + lines.push(''); + message.searchImages.forEach((img, index) => { + lines.push(`- [${img.title || `图片 ${index + 1}`}](${img.imageUrl})`); + }); + lines.push(''); + } + + // AI 消息:显示 Token 统计 + if (!isUser && (message.inputTokens || message.outputTokens)) { + const input = message.inputTokens || 0; + const output = message.outputTokens || 0; + lines.push(`*Token: 输入 ${input} / 输出 ${output}*`); + lines.push(''); + } + + // 分隔线 + lines.push('---'); + lines.push(''); + + return lines.join('\n'); +} + +/** + * Markdown 导出器 + */ +export const markdownExporter: Exporter = { + async export(data: ExportData, options: ExportOptions): Promise { + const lines: string[] = []; + const { conversation, messages, exportInfo } = data; + + // 标题 + lines.push(`# ${conversation.title}`); + lines.push(''); + + // 元信息 + lines.push('> **导出信息**'); + lines.push(`> - 导出时间: ${formatDateTime(exportInfo.exportedAt)}`); + lines.push(`> - 模型: ${conversation.model}`); + lines.push(`> - 消息数: ${conversation.messageCount}`); + lines.push(`> - Token 消耗: ${conversation.totalTokens.toLocaleString()}`); + lines.push(`> - 思考模式: ${conversation.enableThinking ? '开启' : '关闭'}`); + if (conversation.tools && conversation.tools.length > 0) { + const toolNames = conversation.tools + .map((tool) => TOOL_NAMES[tool] || tool) + .join('、'); + lines.push(`> - 启用工具: ${toolNames}`); + } + lines.push(`> - 创建时间: ${formatDateTime(conversation.createdAt)}`); + lines.push(''); + lines.push('---'); + lines.push(''); + + // 消息列表 + messages.forEach((message, index) => { + lines.push(generateMessageMarkdown(message, options, index)); + }); + + // 页脚 + lines.push('---'); + lines.push(''); + lines.push('*导出自 [LionCode AI](https://lioncode.com)*'); + + return lines.join('\n'); + }, + + getContentType(): string { + return 'text/markdown; charset=utf-8'; + }, + + getFileExtension(): string { + return 'md'; + }, +}; diff --git a/src/lib/export/pdf.ts b/src/lib/export/pdf.ts new file mode 100644 index 0000000..0907ac1 --- /dev/null +++ b/src/lib/export/pdf.ts @@ -0,0 +1,203 @@ +/** + * PDF 导出器 + * 使用 jspdf + html2canvas 在客户端生成 PDF + * 使用 iframe 方案确保HTML样式正确渲染 + */ + +import type { ExportData, ExportOptions, Exporter } from './types'; +import { htmlExporter } from './html'; + +/** + * 在客户端生成 PDF(使用 iframe 方案) + * 这个函数需要在浏览器环境中调用 + */ +export async function generatePdfInBrowser( + data: ExportData, + options: ExportOptions +): Promise { + // 动态导入 jspdf 和 html2canvas(仅在客户端) + const [{ default: jsPDF }, { default: html2canvas }] = await Promise.all([ + import('jspdf'), + import('html2canvas'), + ]); + + // 首先生成 HTML 内容 + const htmlContent = await htmlExporter.export(data, options) as string; + + // 创建隐藏的 iframe 来渲染完整的 HTML 文档 + const iframe = document.createElement('iframe'); + iframe.style.cssText = ` + position: fixed; + left: -9999px; + top: 0; + width: 900px; + height: 100%; + border: none; + visibility: hidden; + `; + document.body.appendChild(iframe); + + try { + // 等待 iframe 准备就绪并写入 HTML 内容 + await new Promise((resolve, reject) => { + const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document; + if (!iframeDoc) { + reject(new Error('无法访问 iframe 文档')); + return; + } + + // 写入完整的 HTML 文档 + iframeDoc.open(); + iframeDoc.write(htmlContent); + iframeDoc.close(); + + // 等待 iframe 加载完成 + iframe.onload = () => resolve(); + + // 设置超时,防止 onload 不触发 + setTimeout(() => resolve(), 1000); + }); + + // 获取 iframe 中的文档 + const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document; + if (!iframeDoc) { + throw new Error('无法访问 iframe 文档'); + } + + // 获取要渲染的容器(.container 或 body) + const container = iframeDoc.querySelector('.container') || iframeDoc.body; + + // 等待图片加载 + const images = container.querySelectorAll('img'); + await Promise.all( + Array.from(images).map( + (img) => + new Promise((resolve) => { + if ((img as HTMLImageElement).complete) { + resolve(); + } else { + (img as HTMLImageElement).onload = () => resolve(); + (img as HTMLImageElement).onerror = () => resolve(); + } + }) + ) + ); + + // 额外等待一下确保样式完全渲染 + await new Promise(resolve => setTimeout(resolve, 300)); + + // 使用 html2canvas 渲染 iframe 中的内容 + const canvas = await html2canvas(container as HTMLElement, { + scale: 2, + useCORS: true, + allowTaint: true, + backgroundColor: '#F8F9FA', // 与页面背景色一致 + logging: false, + width: 900, + windowWidth: 900, + }); + + // 创建 PDF(A4 尺寸) + const pdf = new jsPDF({ + orientation: 'portrait', + unit: 'mm', + format: 'a4', + }); + + const pdfWidth = pdf.internal.pageSize.getWidth(); + const pdfHeight = pdf.internal.pageSize.getHeight(); + + // 计算图片在 PDF 中的尺寸 + // 保持宽度铺满,按比例计算高度 + const imgWidth = canvas.width; + const imgHeight = canvas.height; + + // 图片缩放到 PDF 宽度 + const scaledWidth = pdfWidth; + const scaledHeight = (imgHeight * pdfWidth) / imgWidth; + + // 计算需要多少页 + const totalPages = Math.ceil(scaledHeight / pdfHeight); + + // 分页添加内容 + for (let page = 0; page < totalPages; page++) { + if (page > 0) { + pdf.addPage(); + } + + // 计算当前页在原始图片中的位置 + const sourceY = (page * pdfHeight * imgWidth) / pdfWidth; + const sourceHeight = Math.min( + (pdfHeight * imgWidth) / pdfWidth, + imgHeight - sourceY + ); + + // 如果没有更多内容,跳过 + if (sourceHeight <= 0) break; + + // 创建当前页的裁剪画布 + const pageCanvas = document.createElement('canvas'); + pageCanvas.width = imgWidth; + pageCanvas.height = Math.ceil(sourceHeight); + + const ctx = pageCanvas.getContext('2d'); + if (ctx) { + // 从原始画布中裁剪当前页的内容 + ctx.drawImage( + canvas, + 0, // 源 x + Math.floor(sourceY), // 源 y + imgWidth, // 源宽度 + Math.ceil(sourceHeight), // 源高度 + 0, // 目标 x + 0, // 目标 y + imgWidth, // 目标宽度 + Math.ceil(sourceHeight) // 目标高度 + ); + + // 将裁剪后的画布添加到 PDF + const pageImgData = pageCanvas.toDataURL('image/png'); + const pageScaledHeight = (sourceHeight * pdfWidth) / imgWidth; + + pdf.addImage( + pageImgData, + 'PNG', + 0, + 0, + pdfWidth, + pageScaledHeight + ); + } + } + + // 返回 Blob + return pdf.output('blob'); + } finally { + // 清理 iframe + document.body.removeChild(iframe); + } +} + +/** + * PDF 导出器(服务端占位符) + * 实际的 PDF 生成在客户端通过 generatePdfInBrowser 函数执行 + */ +export const pdfExporter: Exporter = { + async export(data: ExportData, options: ExportOptions): Promise { + // 检查是否在浏览器环境 + if (typeof window !== 'undefined') { + return generatePdfInBrowser(data, options); + } + + // 在服务端,返回一个错误提示 + throw new Error('PDF export must be performed on the client side'); + }, + + getContentType(): string { + return 'application/pdf'; + }, + + getFileExtension(): string { + return 'pdf'; + }, +}; diff --git a/src/lib/export/types.ts b/src/lib/export/types.ts new file mode 100644 index 0000000..0f8be04 --- /dev/null +++ b/src/lib/export/types.ts @@ -0,0 +1,106 @@ +/** + * 对话导出功能 - 类型定义 + */ + +// 导出格式 +export type ExportFormat = 'markdown' | 'json' | 'html' | 'pdf'; + +// 导出选项 +export interface ExportOptions { + format: ExportFormat; + includeThinking?: boolean; // 是否包含思考过程,默认 true + includeToolCalls?: boolean; // 是否包含工具调用详情,默认 true + includeImages?: boolean; // 是否包含图片(Base64),默认 true +} + +// 上传的文档数据 +export interface UploadedDocumentData { + name: string; + size: number; + type: string; + content: string; +} + +// 搜索图片数据 +export interface SearchImageData { + title: string; + imageUrl: string; + width: number; + height: number; + score: string; + position: number; + sourceUrl?: string; +} + +// 工具调用记录 +export interface ToolCallData { + id: string; + name: string; + input: Record; +} + +// 工具结果记录 +export interface ToolResultData { + toolUseId: string; + content: string; + isError?: boolean; +} + +// 消息数据(用于导出) +export interface ExportMessageData { + id: string; + role: 'user' | 'assistant' | 'system'; + content: string; + thinkingContent?: string | null; + toolCalls?: ToolCallData[] | null; + toolResults?: ToolResultData[] | null; + images?: string[] | null; // 代码执行产生的图片 + uploadedImages?: string[] | null; // 用户上传的图片 + uploadedDocuments?: UploadedDocumentData[] | null; // 用户上传的文档 + usedTools?: string[] | null; // 使用的工具列表 + searchImages?: SearchImageData[] | null; // 搜索到的图片 + inputTokens?: number | null; + outputTokens?: number | null; + createdAt: string; +} + +// 对话数据(用于导出) +export interface ExportConversationData { + id: string; + title: string; + model: string; + enableThinking: boolean; + tools: string[]; + messageCount: number; + totalTokens: number; + createdAt: string; + updatedAt: string; +} + +// 完整的导出数据 +export interface ExportData { + exportInfo: { + exportedAt: string; + format: ExportFormat; + version: string; + }; + conversation: ExportConversationData; + messages: ExportMessageData[]; +} + +// 导出器接口 +export interface Exporter { + export(data: ExportData, options: ExportOptions): Promise; + getContentType(): string; + getFileExtension(): string; +} + +// 工具名称中文映射 +export const TOOL_DISPLAY_NAMES: Record = { + web_search: '网络搜索', + web_fetch: '网页读取', + mita_search: '秘塔搜索', + mita_reader: '秘塔阅读', + code_execution: '代码执行', + youdao_translate: '有道翻译', +};