feat(导出功能): 添加对话导出核心库
- 实现 ExportData 和 ExportOptions 类型定义 - 实现 Markdown 格式导出器 - 实现 JSON 格式导出器 - 实现 HTML 格式导出器(含完整样式) - 实现 PDF 格式导出器(客户端生成) - 提供统一的导出入口和工具函数
This commit is contained in:
parent
bd09e67988
commit
6c411438e0
1236
src/lib/export/html.ts
Normal file
1236
src/lib/export/html.ts
Normal file
File diff suppressed because it is too large
Load Diff
102
src/lib/export/index.ts
Normal file
102
src/lib/export/index.ts
Normal file
@ -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<string | Blob> {
|
||||||
|
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<ExportOptions, 'format'> = {
|
||||||
|
includeThinking: true,
|
||||||
|
includeToolCalls: true,
|
||||||
|
includeImages: true,
|
||||||
|
};
|
||||||
54
src/lib/export/json.ts
Normal file
54
src/lib/export/json.ts
Normal file
@ -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<string> {
|
||||||
|
// 根据选项过滤数据
|
||||||
|
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';
|
||||||
|
},
|
||||||
|
};
|
||||||
226
src/lib/export/markdown.ts
Normal file
226
src/lib/export/markdown.ts
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
/**
|
||||||
|
* Markdown 导出器
|
||||||
|
* 将对话数据导出为 Markdown 格式
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ExportData,
|
||||||
|
ExportOptions,
|
||||||
|
ExportMessageData,
|
||||||
|
Exporter,
|
||||||
|
TOOL_DISPLAY_NAMES,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
// 工具名称中文映射
|
||||||
|
const TOOL_NAMES: Record<string, string> = {
|
||||||
|
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(``);
|
||||||
|
} else {
|
||||||
|
lines.push(``);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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('<details>');
|
||||||
|
lines.push('<summary>💭 思考过程</summary>');
|
||||||
|
lines.push('');
|
||||||
|
lines.push('```');
|
||||||
|
lines.push(message.thinkingContent);
|
||||||
|
lines.push('```');
|
||||||
|
lines.push('');
|
||||||
|
lines.push('</details>');
|
||||||
|
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('<details>');
|
||||||
|
lines.push('<summary>🛠️ 工具调用详情</summary>');
|
||||||
|
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('</details>');
|
||||||
|
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(``);
|
||||||
|
});
|
||||||
|
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<string> {
|
||||||
|
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';
|
||||||
|
},
|
||||||
|
};
|
||||||
203
src/lib/export/pdf.ts
Normal file
203
src/lib/export/pdf.ts
Normal file
@ -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<Blob> {
|
||||||
|
// 动态导入 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<void>((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<void>((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<Blob> {
|
||||||
|
// 检查是否在浏览器环境
|
||||||
|
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';
|
||||||
|
},
|
||||||
|
};
|
||||||
106
src/lib/export/types.ts
Normal file
106
src/lib/export/types.ts
Normal file
@ -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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 工具结果记录
|
||||||
|
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<string | Blob>;
|
||||||
|
getContentType(): string;
|
||||||
|
getFileExtension(): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 工具名称中文映射
|
||||||
|
export const TOOL_DISPLAY_NAMES: Record<string, string> = {
|
||||||
|
web_search: '网络搜索',
|
||||||
|
web_fetch: '网页读取',
|
||||||
|
mita_search: '秘塔搜索',
|
||||||
|
mita_reader: '秘塔阅读',
|
||||||
|
code_execution: '代码执行',
|
||||||
|
youdao_translate: '有道翻译',
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user