diff --git a/docs/EXPORT_IMAGES_PLAN.md b/docs/EXPORT_IMAGES_PLAN.md new file mode 100644 index 0000000000..88cb1a5644 --- /dev/null +++ b/docs/EXPORT_IMAGES_PLAN.md @@ -0,0 +1,305 @@ +# 对话图片导出功能设计方案 + +## 一、需求背景 + +随着多模态AI模型的普及,用户在对话中使用图片的频率增加。当前的导出功能只处理文本内容,图片被完全忽略,需要增加图片导出能力。 + +## 二、现状分析 + +### 2.1 图片存储机制 + +当前系统中图片有两种存储方式: + +1. **用户上传的图片** + - 存储位置:本地文件系统 + - 访问方式:通过 `FileMetadata.path` 字段,使用 `file://` 协议 + - 数据结构:`ImageMessageBlock.file` + +2. **AI生成的图片** + - 存储位置:内存中的Base64字符串 + - 访问方式:`ImageMessageBlock.metadata.generateImageResponse.images` 数组 + - 数据格式:Base64编码的图片数据 + +### 2.2 现有导出功能 + +当前支持的导出格式: +- Markdown(本地文件/指定路径) +- Word文档(.docx) +- Notion(需要API配置) +- 语雀(需要API配置) +- Obsidian(带弹窗配置) +- Joplin(需要API配置) +- 思源笔记(需要API配置) +- 笔记工作区 +- 纯文本 +- 图片截图 + +### 2.3 导出菜单问题 + +1. **设置分散**:导出相关设置分布在多个地方 +2. **每次导出可能需要不同配置**:如是否包含推理内容、是否包含引用等 +3. **缺乏统一的导出界面**:除Obsidian外,其他格式直接执行导出 + +## 三、解决方案 + +### 3.1 第一阶段:图片导出功能实现 + +#### 3.1.1 导出模式设计 + +提供两种图片导出模式供用户选择: + +**模式1:Base64嵌入模式** +```markdown +![图片描述](data:image/png;base64,iVBORw0KGg...) +``` +- 优点:单文件、便于分享、保证完整性 +- 缺点:文件体积大、部分编辑器不支持、性能较差 + +**模式2:文件夹模式** +``` +导出结构: +conversation_2024-01-21/ +├── conversation.md +└── images/ + ├── user_upload_1.png + ├── ai_generated_1.png + └── ... +``` +Markdown中使用相对路径: +```markdown +![图片描述](./images/user_upload_1.png) +``` +- 优点:文件体积小、兼容性好、性能优秀 +- 缺点:需要管理多个文件、分享需打包 + +#### 3.1.2 核心功能实现 + +1. **新增图片处理工具函数** (`utils/export.ts`) + ```typescript + // 处理消息中的所有图片块 + export async function processImageBlocks( + message: Message, + mode: 'base64' | 'folder', + outputDir?: string + ): Promise + + // 将file://协议的图片转换为Base64 + export async function convertFileToBase64(filePath: string): Promise + + // 保存图片到指定文件夹 + export async function saveImageToFolder( + image: string | Buffer, + outputDir: string, + fileName: string + ): Promise + + // 在Markdown中插入图片引用 + export function insertImageIntoMarkdown( + markdown: string, + images: ImageExportResult[] + ): string + ``` + +2. **更新现有导出函数** + - `messageToMarkdown()`: 增加图片处理参数 + - `topicToMarkdown()`: 批量处理话题中的图片 + - `exportTopicAsMarkdown()`: 支持图片导出选项 + +3. **图片元数据保留** + - AI生成图片:保存prompt信息 + - 用户上传图片:保留原始文件名 + - 添加图片索引和时间戳 + +### 3.2 第二阶段:统一导出弹窗(后续实施) + +#### 3.2.1 弹窗设计 + +创建统一的导出配置弹窗 `UnifiedExportDialog`: + +```typescript +interface ExportDialogProps { + // 导出内容 + content: { + message?: Message + messages?: Message[] + topic?: Topic + rawContent?: string + } + + // 导出格式 + format: ExportFormat + + // 通用配置 + options: { + includeReasoning?: boolean // 包含推理内容 + excludeCitations?: boolean // 排除引用 + imageExportMode?: 'base64' | 'folder' | 'none' // 图片导出模式 + imageQuality?: number // 图片质量(0-100) + maxImageSize?: number // 最大图片尺寸 + } + + // 格式特定配置 + formatOptions?: { + // Markdown特定 + markdownPath?: string + + // Notion特定 + notionDatabase?: string + notionPageName?: string + + // Obsidian特定 + obsidianVault?: string + obsidianFolder?: string + processingMethod?: string + + // 其他格式配置... + } +} +``` + +#### 3.2.2 交互流程 + +1. 用户点击导出按钮 +2. 弹出统一导出弹窗 +3. 用户选择导出格式 +4. 根据格式显示相应配置选项 +5. 用户调整配置 +6. 点击确认执行导出 + +#### 3.2.3 优势 + +1. **配置集中管理**:所有导出配置在一个界面完成 +2. **动态配置**:每次导出可以调整不同设置 +3. **用户体验统一**:所有格式使用相同的交互模式 +4. **易于扩展**:新增导出格式只需添加配置项 + +## 四、实施计划 + +### Phase 1: 基础图片导出(本次实施) +- [x] 创建设计文档 +- [ ] 实现图片处理工具函数 +- [ ] 更新Markdown导出支持图片 +- [ ] 添加图片导出模式设置 +- [ ] 测试不同场景 + +### Phase 2: 扩展格式支持 +- [ ] Word文档图片嵌入 +- [ ] Obsidian图片处理 +- [ ] Joplin图片上传 +- [ ] 思源笔记图片支持 + +### Phase 3: 统一导出弹窗 +- [ ] 设计弹窗UI组件 +- [ ] 实现配置管理逻辑 +- [ ] 迁移现有导出功能 +- [ ] 添加配置持久化 + +### Phase 4: 高级功能 +- [ ] 图片压缩优化 +- [ ] 批量导出进度显示 +- [ ] 导出历史记录 +- [ ] 导出模板系统 + +## 五、技术细节 + +### 5.1 图片格式转换 + +```typescript +// Base64转换示例 +async function imageToBase64(imagePath: string): Promise { + if (imagePath.startsWith('file://')) { + const actualPath = imagePath.slice(7) + const buffer = await fs.readFile(actualPath) + const mimeType = getMimeType(actualPath) + return `data:${mimeType};base64,${buffer.toString('base64')}` + } + return imagePath // 已经是Base64 +} +``` + +### 5.2 文件夹结构生成 + +```typescript +async function createExportFolder(topicName: string): Promise { + const timestamp = dayjs().format('YYYY-MM-DD-HH-mm-ss') + const folderName = `${sanitizeFileName(topicName)}_${timestamp}` + const exportPath = path.join(getExportDir(), folderName) + + await fs.mkdir(path.join(exportPath, 'images'), { recursive: true }) + return exportPath +} +``` + +### 5.3 Markdown图片引用更新 + +```typescript +function updateMarkdownImages( + markdown: string, + imageMap: Map +): string { + let updatedMarkdown = markdown + + for (const [originalPath, newPath] of imageMap) { + // 替换图片引用 + const regex = new RegExp(`!\\[([^\\]]*)\\]\\(${escapeRegex(originalPath)}\\)`, 'g') + updatedMarkdown = updatedMarkdown.replace( + regex, + `![$1](${newPath})` + ) + } + + return updatedMarkdown +} +``` + +## 六、注意事项 + +1. **性能考虑** + - 大量图片时使用异步处理 + - 提供进度反馈 + - 实现取消操作 + +2. **兼容性** + - 检测目标应用对图片格式的支持 + - 提供降级方案 + +3. **安全性** + - 验证文件路径合法性 + - 限制图片大小 + - 清理临时文件 + +4. **用户体验** + - 清晰的配置说明 + - 合理的默认值 + - 错误提示友好 + +## 七、后续优化 + +1. **Notion图片支持**(需要调研) + - 研究Notion API的图片上传能力 + - 评估 `@notionhq/client` 库的图片处理功能 + - 可能需要先上传到图床再引用 + +2. **智能压缩** + - 根据图片内容自动选择压缩算法 + - 保持图片质量的同时减小体积 + +3. **批量导出** + - 支持多个话题同时导出 + - 生成导出报告 + +4. **云存储集成** + - 支持直接上传到云存储 + - 生成分享链接 + +## 八、参考资料 + +- [Notion API Documentation](https://developers.notion.com/) +- [Obsidian URI Protocol](https://help.obsidian.md/Extending+Obsidian/Obsidian+URI) +- [Joplin Web Clipper API](https://joplinapp.org/api/references/rest_api/) +- [思源笔记 API](https://github.com/siyuan-note/siyuan/blob/master/API.md) + +--- + +*文档创建日期:2025-01-21* +*最后更新:2025-01-21* \ No newline at end of file diff --git a/src/preload/index.ts b/src/preload/index.ts index 9004560045..e706e14d58 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -205,7 +205,14 @@ const api = { ipcRenderer.on('file-change', listener) return () => ipcRenderer.off('file-change', listener) }, - showInFolder: (path: string): Promise => ipcRenderer.invoke(IpcChannel.File_ShowInFolder, path) + showInFolder: (path: string): Promise => ipcRenderer.invoke(IpcChannel.File_ShowInFolder, path), + // Image export specific methods + readBinary: (filePath: string): Promise => ipcRenderer.invoke(IpcChannel.File_ReadBinary, filePath), + writeBinary: (filePath: string, buffer: Buffer): Promise => + ipcRenderer.invoke(IpcChannel.File_WriteBinary, filePath, buffer), + copyFile: (sourcePath: string, destPath: string): Promise => + ipcRenderer.invoke(IpcChannel.File_CopyFile, sourcePath, destPath), + createDirectory: (dirPath: string): Promise => ipcRenderer.invoke(IpcChannel.File_CreateDirectory, dirPath) }, fs: { read: (pathOrUrl: string, encoding?: BufferEncoding) => ipcRenderer.invoke(IpcChannel.Fs_Read, pathOrUrl, encoding), diff --git a/src/renderer/src/pages/settings/DataSettings/MarkdownExportSettings.tsx b/src/renderer/src/pages/settings/DataSettings/MarkdownExportSettings.tsx index 50b03b9ae4..9574133945 100644 --- a/src/renderer/src/pages/settings/DataSettings/MarkdownExportSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/MarkdownExportSettings.tsx @@ -5,13 +5,16 @@ import { RootState, useAppDispatch } from '@renderer/store' import { setExcludeCitationsInExport, setForceDollarMathInMarkdown, + setImageExportMaxSize, + setImageExportMode, + setImageExportQuality, setmarkdownExportPath, setShowModelNameInMarkdown, setShowModelProviderInMarkdown, setStandardizeCitationsInExport, setUseTopicNamingForMessageTitle } from '@renderer/store/settings' -import { Button, Switch } from 'antd' +import { Button, Select, Slider, Switch } from 'antd' import Input from 'antd/es/input/Input' import { FC } from 'react' import { useTranslation } from 'react-i18next' @@ -31,6 +34,9 @@ const MarkdownExportSettings: FC = () => { const showModelProviderInMarkdown = useSelector((state: RootState) => state.settings.showModelProviderInMarkdown) const excludeCitationsInExport = useSelector((state: RootState) => state.settings.excludeCitationsInExport) const standardizeCitationsInExport = useSelector((state: RootState) => state.settings.standardizeCitationsInExport) + const imageExportMode = useSelector((state: RootState) => state.settings.imageExportMode) + const imageExportQuality = useSelector((state: RootState) => state.settings.imageExportQuality) + const imageExportMaxSize = useSelector((state: RootState) => state.settings.imageExportMaxSize) const handleSelectFolder = async () => { const path = await window.api.file.selectFolder() @@ -67,6 +73,18 @@ const MarkdownExportSettings: FC = () => { dispatch(setStandardizeCitationsInExport(checked)) } + const handleImageExportModeChange = (value: 'base64' | 'folder' | 'none') => { + dispatch(setImageExportMode(value)) + } + + const handleImageExportQualityChange = (value: number) => { + dispatch(setImageExportQuality(value)) + } + + const handleImageExportMaxSizeChange = (value: number) => { + dispatch(setImageExportMaxSize(value)) + } + return ( {t('settings.data.markdown_export.title')} @@ -142,6 +160,58 @@ const MarkdownExportSettings: FC = () => { {t('settings.data.markdown_export.standardize_citations.help')} + + + {t('settings.data.markdown_export.image_export_mode.title')} + + + + {t('settings.data.markdown_export.image_export_mode.help')} + + {imageExportMode !== 'none' && ( + <> + + + {t('settings.data.markdown_export.image_quality.title')} + + + {imageExportQuality}% + + + + {t('settings.data.markdown_export.image_quality.help')} + + + + {t('settings.data.markdown_export.image_max_size.title')} + + + {imageExportMaxSize}px + + + + {t('settings.data.markdown_export.image_max_size.help')} + + + )} ) } diff --git a/src/renderer/src/utils/export.ts b/src/renderer/src/utils/export.ts index 5f7abd0265..0d79078ff3 100644 --- a/src/renderer/src/utils/export.ts +++ b/src/renderer/src/utils/export.ts @@ -265,12 +265,20 @@ const formatCitationsAsFootnotes = (citations: string): string => { return footnotes.join('\n\n') } -const createBaseMarkdown = ( +const createBaseMarkdown = async ( message: Message, includeReasoning: boolean = false, excludeCitations: boolean = false, - normalizeCitations: boolean = true -): { titleSection: string; reasoningSection: string; contentSection: string; citation: string } => { + normalizeCitations: boolean = true, + imageMode: 'base64' | 'folder' | 'none' = 'none', + imageOutputDir?: string +): Promise<{ + titleSection: string + reasoningSection: string + contentSection: string + citation: string + imageSection: string +}> => { const { forceDollarMathInMarkdown } = store.getState().settings const roleText = getRoleText(message.role, message.model?.name, message.model?.provider) const titleSection = `## ${roleText}` @@ -312,45 +320,98 @@ const createBaseMarkdown = ( citation = formatCitationsAsFootnotes(citation) } - return { titleSection, reasoningSection, contentSection: processedContent, citation } + // 处理图片 + let imageSection = '' + if (imageMode !== 'none') { + try { + const imageResults = await processImageBlocks(message, imageMode, imageOutputDir) + if (imageResults.length > 0) { + imageSection = imageResults.map((img) => `![${img.alt}](${img.exportedPath})`).join('\n\n') + } + } catch (error) { + logger.error('Failed to process images:', error as Error) + } + } + + return { titleSection, reasoningSection, contentSection: processedContent, citation, imageSection } } -export const messageToMarkdown = (message: Message, excludeCitations?: boolean): string => { - const { excludeCitationsInExport, standardizeCitationsInExport } = store.getState().settings +export const messageToMarkdown = async ( + message: Message, + excludeCitations?: boolean, + imageMode?: 'base64' | 'folder' | 'none', + imageOutputDir?: string +): Promise => { + const { excludeCitationsInExport, standardizeCitationsInExport, imageExportMode } = store.getState().settings const shouldExcludeCitations = excludeCitations ?? excludeCitationsInExport - const { titleSection, contentSection, citation } = createBaseMarkdown( + const actualImageMode = imageMode ?? imageExportMode ?? 'none' + const { titleSection, contentSection, citation, imageSection } = await createBaseMarkdown( message, false, shouldExcludeCitations, - standardizeCitationsInExport + standardizeCitationsInExport, + actualImageMode, + imageOutputDir ) - return [titleSection, '', contentSection, citation].join('\n') + // Place images after the title and before content + const sections = [titleSection] + if (imageSection) { + sections.push('', imageSection) + } + sections.push('', contentSection) + if (citation) { + sections.push(citation) + } + return sections.join('\n') } -export const messageToMarkdownWithReasoning = (message: Message, excludeCitations?: boolean): string => { - const { excludeCitationsInExport, standardizeCitationsInExport } = store.getState().settings +export const messageToMarkdownWithReasoning = async ( + message: Message, + excludeCitations?: boolean, + imageMode?: 'base64' | 'folder' | 'none', + imageOutputDir?: string +): Promise => { + const { excludeCitationsInExport, standardizeCitationsInExport, imageExportMode } = store.getState().settings const shouldExcludeCitations = excludeCitations ?? excludeCitationsInExport - const { titleSection, reasoningSection, contentSection, citation } = createBaseMarkdown( + const actualImageMode = imageMode ?? imageExportMode ?? 'none' + const { titleSection, reasoningSection, contentSection, citation, imageSection } = await createBaseMarkdown( message, true, shouldExcludeCitations, - standardizeCitationsInExport + standardizeCitationsInExport, + actualImageMode, + imageOutputDir ) - return [titleSection, '', reasoningSection, contentSection, citation].join('\n') + // Place images after the title and before reasoning + const sections = [titleSection] + if (imageSection) { + sections.push('', imageSection) + } + if (reasoningSection) { + sections.push('', reasoningSection) + } + sections.push(contentSection) + if (citation) { + sections.push(citation) + } + return sections.join('\n') } -export const messagesToMarkdown = ( +export const messagesToMarkdown = async ( messages: Message[], exportReasoning?: boolean, - excludeCitations?: boolean -): string => { - return messages - .map((message) => - exportReasoning - ? messageToMarkdownWithReasoning(message, excludeCitations) - : messageToMarkdown(message, excludeCitations) - ) - .join('\n---\n') + excludeCitations?: boolean, + imageMode?: 'base64' | 'folder' | 'none', + imageOutputDir?: string +): Promise => { + const markdownParts: string[] = [] + for (const message of messages) { + const markdown = exportReasoning + ? await messageToMarkdownWithReasoning(message, excludeCitations, imageMode, imageOutputDir) + : await messageToMarkdown(message, excludeCitations, imageMode, imageOutputDir) + markdownParts.push(markdown) + } + return markdownParts.join('\n---\n') } const formatMessageAsPlainText = (message: Message): string => { @@ -372,14 +433,23 @@ const messagesToPlainText = (messages: Message[]): string => { export const topicToMarkdown = async ( topic: Topic, exportReasoning?: boolean, - excludeCitations?: boolean + excludeCitations?: boolean, + imageMode?: 'base64' | 'folder' | 'none', + imageOutputDir?: string ): Promise => { const topicName = `# ${topic.name}` const messages = await fetchTopicMessages(topic.id) if (messages && messages.length > 0) { - return topicName + '\n\n' + messagesToMarkdown(messages, exportReasoning, excludeCitations) + const messagesMarkdown = await messagesToMarkdown( + messages, + exportReasoning, + excludeCitations, + imageMode, + imageOutputDir + ) + return topicName + '\n\n' + messagesMarkdown } return topicName @@ -409,34 +479,43 @@ export const exportTopicAsMarkdown = async ( setExportingState(true) - const { markdownExportPath } = store.getState().settings - if (!markdownExportPath) { - try { - const fileName = removeSpecialCharactersForFileName(topic.name) + '.md' - const markdown = await topicToMarkdown(topic, exportReasoning, excludeCitations) - const result = await window.api.file.save(fileName, markdown) - if (result) { - window.toast.success(i18n.t('message.success.markdown.export.specified')) + const { markdownExportPath, imageExportMode } = store.getState().settings + + try { + // Handle folder mode - create folder structure + if (imageExportMode === 'folder') { + const { rootDir, imagesDir } = await createExportFolderStructure(topic.name, markdownExportPath || undefined) + + // Generate markdown with images in folder mode + const markdown = await topicToMarkdown(topic, exportReasoning, excludeCitations, 'folder', imagesDir) + + // Save markdown to the root directory + const markdownPath = `${rootDir}/conversation.md` + await window.api.file.write(markdownPath, markdown) + + window.toast.success(i18n.t('message.success.markdown.export.specified')) + } else { + // Base64 mode or no images - traditional export + if (!markdownExportPath) { + const fileName = removeSpecialCharactersForFileName(topic.name) + '.md' + const markdown = await topicToMarkdown(topic, exportReasoning, excludeCitations, imageExportMode) + const result = await window.api.file.save(fileName, markdown) + if (result) { + window.toast.success(i18n.t('message.success.markdown.export.specified')) + } + } else { + const timestamp = dayjs().format('YYYY-MM-DD-HH-mm-ss') + const fileName = removeSpecialCharactersForFileName(topic.name) + ` ${timestamp}.md` + const markdown = await topicToMarkdown(topic, exportReasoning, excludeCitations, imageExportMode) + await window.api.file.write(markdownExportPath + '/' + fileName, markdown) + window.toast.success(i18n.t('message.success.markdown.export.preconf')) } - } catch (error: any) { - window.toast.error(i18n.t('message.error.markdown.export.specified')) - logger.error('Failed to export topic as markdown:', error) - } finally { - setExportingState(false) - } - } else { - try { - const timestamp = dayjs().format('YYYY-MM-DD-HH-mm-ss') - const fileName = removeSpecialCharactersForFileName(topic.name) + ` ${timestamp}.md` - const markdown = await topicToMarkdown(topic, exportReasoning, excludeCitations) - await window.api.file.write(markdownExportPath + '/' + fileName, markdown) - window.toast.success(i18n.t('message.success.markdown.export.preconf')) - } catch (error: any) { - window.toast.error(i18n.t('message.error.markdown.export.preconf')) - logger.error('Failed to export topic as markdown:', error) - } finally { - setExportingState(false) } + } catch (error: any) { + window.toast.error(i18n.t('message.error.markdown.export.specified')) + logger.error('Failed to export topic as markdown:', error) + } finally { + setExportingState(false) } } @@ -452,40 +531,50 @@ export const exportMessageAsMarkdown = async ( setExportingState(true) - const { markdownExportPath } = store.getState().settings - if (!markdownExportPath) { - try { - const title = await getMessageTitle(message) - const fileName = removeSpecialCharactersForFileName(title) + '.md' + const { markdownExportPath, imageExportMode } = store.getState().settings + const title = await getMessageTitle(message) + + try { + // Handle folder mode for single message + if (imageExportMode === 'folder') { + const { rootDir, imagesDir } = await createExportFolderStructure(title, markdownExportPath || undefined) + + // Generate markdown with images in folder mode const markdown = exportReasoning - ? messageToMarkdownWithReasoning(message, excludeCitations) - : messageToMarkdown(message, excludeCitations) - const result = await window.api.file.save(fileName, markdown) - if (result) { - window.toast.success(i18n.t('message.success.markdown.export.specified')) + ? await messageToMarkdownWithReasoning(message, excludeCitations, 'folder', imagesDir) + : await messageToMarkdown(message, excludeCitations, 'folder', imagesDir) + + // Save markdown to the root directory + const markdownPath = `${rootDir}/message.md` + await window.api.file.write(markdownPath, markdown) + + window.toast.success(i18n.t('message.success.markdown.export.specified')) + } else { + // Base64 mode or no images - traditional export + if (!markdownExportPath) { + const fileName = removeSpecialCharactersForFileName(title) + '.md' + const markdown = exportReasoning + ? await messageToMarkdownWithReasoning(message, excludeCitations, imageExportMode) + : await messageToMarkdown(message, excludeCitations, imageExportMode) + const result = await window.api.file.save(fileName, markdown) + if (result) { + window.toast.success(i18n.t('message.success.markdown.export.specified')) + } + } else { + const timestamp = dayjs().format('YYYY-MM-DD-HH-mm-ss') + const fileName = removeSpecialCharactersForFileName(title) + ` ${timestamp}.md` + const markdown = exportReasoning + ? await messageToMarkdownWithReasoning(message, excludeCitations, imageExportMode) + : await messageToMarkdown(message, excludeCitations, imageExportMode) + await window.api.file.write(markdownExportPath + '/' + fileName, markdown) + window.toast.success(i18n.t('message.success.markdown.export.preconf')) } - } catch (error: any) { - window.toast.error(i18n.t('message.error.markdown.export.specified')) - logger.error('Failed to export message as markdown:', error) - } finally { - setExportingState(false) - } - } else { - try { - const timestamp = dayjs().format('YYYY-MM-DD-HH-mm-ss') - const title = await getMessageTitle(message) - const fileName = removeSpecialCharactersForFileName(title) + ` ${timestamp}.md` - const markdown = exportReasoning - ? messageToMarkdownWithReasoning(message, excludeCitations) - : messageToMarkdown(message, excludeCitations) - await window.api.file.write(markdownExportPath + '/' + fileName, markdown) - window.toast.success(i18n.t('message.success.markdown.export.preconf')) - } catch (error: any) { - window.toast.error(i18n.t('message.error.markdown.export.preconf')) - logger.error('Failed to export message as markdown:', error) - } finally { - setExportingState(false) } + } catch (error: any) { + window.toast.error(i18n.t('message.error.markdown.export.specified')) + logger.error('Failed to export message as markdown:', error) + } finally { + setExportingState(false) } } diff --git a/src/renderer/src/utils/exportImages.ts b/src/renderer/src/utils/exportImages.ts new file mode 100644 index 0000000000..f54c58ccc2 --- /dev/null +++ b/src/renderer/src/utils/exportImages.ts @@ -0,0 +1,321 @@ +import { loggerService } from '@logger' +import type { ImageMessageBlock, Message } from '@renderer/types/newMessage' +import { findImageBlocks } from '@renderer/utils/messageUtils/find' +import dayjs from 'dayjs' +import * as path from 'path' + +const logger = loggerService.withContext('Utils:exportImages') + +export interface ImageExportResult { + originalPath: string + exportedPath: string + alt: string + isBase64: boolean +} + +/** + * Convert a file:// protocol image to Base64 + * @param filePath The file:// protocol path + * @returns Base64 encoded image string + */ +export async function convertFileToBase64(filePath: string): Promise { + try { + if (!filePath.startsWith('file://')) { + throw new Error('Invalid file protocol') + } + + const actualPath = filePath.slice(7) // Remove 'file://' prefix + const fileContent = await window.api.file.readBinary(actualPath) + + // Determine MIME type based on file extension + const ext = path.extname(actualPath).toLowerCase() + let mimeType = 'image/jpeg' + switch (ext) { + case '.png': + mimeType = 'image/png' + break + case '.jpg': + case '.jpeg': + mimeType = 'image/jpeg' + break + case '.gif': + mimeType = 'image/gif' + break + case '.webp': + mimeType = 'image/webp' + break + case '.svg': + mimeType = 'image/svg+xml' + break + } + + return `data:${mimeType};base64,${fileContent.toString('base64')}` + } catch (error) { + logger.error('Failed to convert file to Base64:', error as Error) + throw error + } +} + +/** + * Save an image to a specified folder + * @param image Image data (Base64 or file path) + * @param outputDir Output directory + * @param fileName File name for the saved image + * @returns Path to the saved image + */ +export async function saveImageToFolder(image: string, outputDir: string, fileName: string): Promise { + try { + const imagePath = path.join(outputDir, fileName) + + if (image.startsWith('data:')) { + // Base64 image - write directly as Base64 string, let main process handle conversion + await window.api.file.write(imagePath, image) + } else if (image.startsWith('file://')) { + // File protocol image - copy file + const sourcePath = image.slice(7) + await window.api.file.copyFile(sourcePath, imagePath) + } else { + throw new Error('Unsupported image format') + } + + return imagePath + } catch (error) { + logger.error('Failed to save image to folder:', error as Error) + throw error + } +} + +/** + * Generate a unique filename for an image + * @param index Image index + * @param isUserUpload Whether the image was uploaded by user + * @param originalName Original filename (if available) + * @returns Generated filename + */ +function generateImageFileName(index: number, isUserUpload: boolean, originalName?: string): string { + const prefix = isUserUpload ? 'user_' : 'ai_' + + if (originalName && isUserUpload) { + // Try to preserve original filename for user uploads + const sanitized = originalName.replace(/[^a-zA-Z0-9._-]/g, '_') + return `${prefix}${index}_${sanitized}` + } + + // Generate timestamp-based name + const timestamp = Date.now() + return `${prefix}${index}_${timestamp}.png` +} + +/** + * Extract image alt text from metadata + * @param block Image block + * @returns Alt text for the image + */ +function getImageAltText(block: ImageMessageBlock): string { + // Try to use prompt for AI generated images + if (block.metadata?.prompt) { + return block.metadata.prompt.slice(0, 100) // Limit alt text length + } + + // Use original filename for user uploads + if (block.file?.origin_name) { + return block.file.origin_name + } + + return 'Image' +} + +/** + * Process image blocks from a message + * @param message Message containing image blocks + * @param mode Export mode: 'base64' | 'folder' | 'none' + * @param outputDir Output directory (required for 'folder' mode) + * @returns Array of processed image results + */ +export async function processImageBlocks( + message: Message, + mode: 'base64' | 'folder' | 'none', + outputDir?: string +): Promise { + if (mode === 'none') { + return [] + } + + const imageBlocks = findImageBlocks(message) + if (imageBlocks.length === 0) { + return [] + } + + const results: ImageExportResult[] = [] + // For future image quality and size optimization + // const { imageExportQuality, imageExportMaxSize } = store.getState().settings + + for (let i = 0; i < imageBlocks.length; i++) { + const block = imageBlocks[i] + const alt = getImageAltText(block) + + try { + // Handle AI generated images (stored as Base64) + if (block.metadata?.generateImageResponse?.images) { + const images = block.metadata.generateImageResponse.images + + for (let j = 0; j < images.length; j++) { + const imageData = images[j] + + if (mode === 'base64') { + // Already in Base64 format + results.push({ + originalPath: imageData, + exportedPath: imageData, + alt: `${alt} ${j + 1}`, + isBase64: true + }) + } else if (mode === 'folder' && outputDir) { + // Save Base64 to file + const fileName = generateImageFileName(i * 10 + j, false) + await saveImageToFolder(imageData, outputDir, fileName) + results.push({ + originalPath: imageData, + exportedPath: `./images/${fileName}`, + alt: `${alt} ${j + 1}`, + isBase64: false + }) + } + } + } + + // Handle user uploaded images (stored as file paths) + if (block.file?.path) { + const filePath = `file://${block.file.path}` + + if (mode === 'base64') { + // Convert to Base64 + const base64Data = await convertFileToBase64(filePath) + results.push({ + originalPath: filePath, + exportedPath: base64Data, + alt, + isBase64: true + }) + } else if (mode === 'folder' && outputDir) { + // Copy to folder + const fileName = generateImageFileName(i, true, block.file.origin_name) + await saveImageToFolder(filePath, outputDir, fileName) + results.push({ + originalPath: filePath, + exportedPath: `./images/${fileName}`, + alt, + isBase64: false + }) + } + } + + // Handle URL images (if any) + if (block.url) { + if (mode === 'base64') { + // If it's already a data URL, use it directly + if (block.url.startsWith('data:')) { + results.push({ + originalPath: block.url, + exportedPath: block.url, + alt, + isBase64: true + }) + } else { + // For HTTP URLs, we'd need to fetch and convert + // This is left as a future enhancement + logger.warn('HTTP URL images not yet supported:', block.url) + } + } else if (mode === 'folder' && outputDir) { + // Save URL image to file (future enhancement) + logger.warn('Saving HTTP URL images not yet supported:', block.url) + } + } + } catch (error) { + logger.error(`Failed to process image block ${i}:`, error as Error) + // Continue processing other images even if one fails + } + } + + return results +} + +/** + * Insert images into Markdown content + * @param markdown Original markdown content + * @param images Processed image results + * @param messageId Message ID for reference + * @returns Markdown with images inserted + */ +export function insertImagesIntoMarkdown(markdown: string, images: ImageExportResult[]): string { + if (images.length === 0) { + return markdown + } + + // Build image markdown + const imageMarkdown = images.map((img) => `![${img.alt}](${img.exportedPath})`).join('\n\n') + + // Insert images after the message header + // Look for the first line break after ## header + const headerMatch = markdown.match(/^##\s+.+\n/) + if (headerMatch) { + const insertPos = headerMatch[0].length + return markdown.slice(0, insertPos) + '\n' + imageMarkdown + '\n' + markdown.slice(insertPos) + } + + // If no header found, prepend images + return imageMarkdown + '\n\n' + markdown +} + +/** + * Create export folder structure for topic/conversation + * @param topicName Topic name + * @param baseExportPath Base export path + * @returns Created folder paths + */ +export async function createExportFolderStructure( + topicName: string, + baseExportPath?: string +): Promise<{ rootDir: string; imagesDir: string }> { + const timestamp = dayjs().format('YYYY-MM-DD-HH-mm-ss') + const sanitizedName = topicName.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 50) + const folderName = `${sanitizedName}_${timestamp}` + + const exportPath = baseExportPath || (await window.api.file.selectFolder()) + if (!exportPath) { + throw new Error('No export path selected') + } + + const rootDir = path.join(exportPath, folderName) + const imagesDir = path.join(rootDir, 'images') + + // Create directories + await window.api.file.createDirectory(rootDir) + await window.api.file.createDirectory(imagesDir) + + return { rootDir, imagesDir } +} + +/** + * Process all images in multiple messages + * @param messages Array of messages + * @param mode Export mode + * @param outputDir Output directory for folder mode + * @returns Map of message ID to image results + */ +export async function processMessagesImages( + messages: Message[], + mode: 'base64' | 'folder' | 'none', + outputDir?: string +): Promise> { + const resultsMap = new Map() + + for (const message of messages) { + const imageResults = await processImageBlocks(message, mode, outputDir) + if (imageResults.length > 0) { + resultsMap.set(message.id, imageResults) + } + } + + return resultsMap +}