Compare commits

...

2 Commits

Author SHA1 Message Date
GeorgeDong32
42ee8a68ce feat(markdown): add image export controls and hook into export logic 2025-10-24 17:39:11 +08:00
GeorgeDong32
d521a88d30 feat(export): add image export options to markdown settings 2025-10-24 17:39:11 +08:00
8 changed files with 922 additions and 85 deletions

305
docs/EXPORT_IMAGES_PLAN.md Normal file
View File

@@ -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 导出模式设计
提供两种图片导出模式供用户选择:
**模式1Base64嵌入模式**
```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<ImageExportResult[]>
// 将file://协议的图片转换为Base64
export async function convertFileToBase64(filePath: string): Promise<string>
// 保存图片到指定文件夹
export async function saveImageToFolder(
image: string | Buffer,
outputDir: string,
fileName: string
): Promise<string>
// 在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<string> {
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<string> {
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, string>
): 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*

View File

@@ -191,6 +191,11 @@ export enum IpcChannel {
File_StartWatcher = 'file:startWatcher',
File_StopWatcher = 'file:stopWatcher',
File_ShowInFolder = 'file:showInFolder',
// Image export specific channels
File_ReadBinary = 'file:readBinary',
File_WriteBinary = 'file:writeBinary',
File_CopyFile = 'file:copyFile',
File_CreateDirectory = 'file:createDirectory',
// file service
FileService_Upload = 'file-service:upload',

View File

@@ -531,6 +531,23 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.File_StopWatcher, fileManager.stopFileWatcher.bind(fileManager))
ipcMain.handle(IpcChannel.File_ShowInFolder, fileManager.showInFolder.bind(fileManager))
// Image export specific handlers
ipcMain.handle(IpcChannel.File_ReadBinary, async (_, filePath: string) => {
return fs.promises.readFile(filePath)
})
ipcMain.handle(IpcChannel.File_WriteBinary, async (_, filePath: string, buffer: Buffer) => {
return fs.promises.writeFile(filePath, buffer)
})
ipcMain.handle(IpcChannel.File_CopyFile, async (_, sourcePath: string, destPath: string) => {
return fs.promises.copyFile(sourcePath, destPath)
})
ipcMain.handle(IpcChannel.File_CreateDirectory, async (_, dirPath: string) => {
return fs.promises.mkdir(dirPath, { recursive: true })
})
// file service
ipcMain.handle(IpcChannel.FileService_Upload, async (_, provider: Provider, file: FileMetadata) => {
const service = FileServiceManager.getInstance().getService(provider)

View File

@@ -205,7 +205,14 @@ const api = {
ipcRenderer.on('file-change', listener)
return () => ipcRenderer.off('file-change', listener)
},
showInFolder: (path: string): Promise<void> => ipcRenderer.invoke(IpcChannel.File_ShowInFolder, path)
showInFolder: (path: string): Promise<void> => ipcRenderer.invoke(IpcChannel.File_ShowInFolder, path),
// Image export specific methods
readBinary: (filePath: string): Promise<Buffer> => ipcRenderer.invoke(IpcChannel.File_ReadBinary, filePath),
writeBinary: (filePath: string, buffer: Buffer): Promise<void> =>
ipcRenderer.invoke(IpcChannel.File_WriteBinary, filePath, buffer),
copyFile: (sourcePath: string, destPath: string): Promise<void> =>
ipcRenderer.invoke(IpcChannel.File_CopyFile, sourcePath, destPath),
createDirectory: (dirPath: string): Promise<void> => ipcRenderer.invoke(IpcChannel.File_CreateDirectory, dirPath)
},
fs: {
read: (pathOrUrl: string, encoding?: BufferEncoding) => ipcRenderer.invoke(IpcChannel.Fs_Read, pathOrUrl, encoding),

View File

@@ -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 (
<SettingGroup theme={theme}>
<SettingTitle>{t('settings.data.markdown_export.title')}</SettingTitle>
@@ -142,6 +160,58 @@ const MarkdownExportSettings: FC = () => {
<SettingRow>
<SettingHelpText>{t('settings.data.markdown_export.standardize_citations.help')}</SettingHelpText>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.markdown_export.image_export_mode.title')}</SettingRowTitle>
<Select value={imageExportMode} onChange={handleImageExportModeChange} style={{ width: 200 }}>
<Select.Option value="none">{t('settings.data.markdown_export.image_export_mode.none')}</Select.Option>
<Select.Option value="base64">{t('settings.data.markdown_export.image_export_mode.base64')}</Select.Option>
<Select.Option value="folder">{t('settings.data.markdown_export.image_export_mode.folder')}</Select.Option>
</Select>
</SettingRow>
<SettingRow>
<SettingHelpText>{t('settings.data.markdown_export.image_export_mode.help')}</SettingHelpText>
</SettingRow>
{imageExportMode !== 'none' && (
<>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.markdown_export.image_quality.title')}</SettingRowTitle>
<HStack alignItems="center" gap="10px" style={{ width: 315 }}>
<Slider
min={10}
max={100}
step={5}
value={imageExportQuality}
onChange={handleImageExportQualityChange}
style={{ width: 200 }}
/>
<span>{imageExportQuality}%</span>
</HStack>
</SettingRow>
<SettingRow>
<SettingHelpText>{t('settings.data.markdown_export.image_quality.help')}</SettingHelpText>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.markdown_export.image_max_size.title')}</SettingRowTitle>
<HStack alignItems="center" gap="10px" style={{ width: 315 }}>
<Slider
min={512}
max={4096}
step={256}
value={imageExportMaxSize}
onChange={handleImageExportMaxSizeChange}
style={{ width: 200 }}
/>
<span>{imageExportMaxSize}px</span>
</HStack>
</SettingRow>
<SettingRow>
<SettingHelpText>{t('settings.data.markdown_export.image_max_size.help')}</SettingHelpText>
</SettingRow>
</>
)}
</SettingGroup>
)
}

View File

@@ -151,6 +151,10 @@ export interface SettingsState {
notionExportReasoning: boolean
excludeCitationsInExport: boolean
standardizeCitationsInExport: boolean
// Image export settings
imageExportMode: 'base64' | 'folder' | 'none'
imageExportQuality: number
imageExportMaxSize: number
yuqueToken: string | null
yuqueUrl: string | null
yuqueRepoId: string | null
@@ -333,6 +337,10 @@ export const initialState: SettingsState = {
notionExportReasoning: false,
excludeCitationsInExport: false,
standardizeCitationsInExport: false,
// Image export settings
imageExportMode: 'none',
imageExportQuality: 85,
imageExportMaxSize: 2048,
yuqueToken: '',
yuqueUrl: '',
yuqueRepoId: '',
@@ -716,6 +724,16 @@ const settingsSlice = createSlice({
setStandardizeCitationsInExport: (state, action: PayloadAction<boolean>) => {
state.standardizeCitationsInExport = action.payload
},
// Image export settings actions
setImageExportMode: (state, action: PayloadAction<'base64' | 'folder' | 'none'>) => {
state.imageExportMode = action.payload
},
setImageExportQuality: (state, action: PayloadAction<number>) => {
state.imageExportQuality = action.payload
},
setImageExportMaxSize: (state, action: PayloadAction<number>) => {
state.imageExportMaxSize = action.payload
},
setYuqueToken: (state, action: PayloadAction<string>) => {
state.yuqueToken = action.payload
},
@@ -940,6 +958,9 @@ export const {
setNotionExportReasoning,
setExcludeCitationsInExport,
setStandardizeCitationsInExport,
setImageExportMode,
setImageExportQuality,
setImageExportMaxSize,
setYuqueToken,
setYuqueRepoId,
setYuqueUrl,

View File

@@ -17,6 +17,8 @@ import dayjs from 'dayjs'
import DOMPurify from 'dompurify'
import { appendBlocks } from 'notion-helper'
import { createExportFolderStructure, processImageBlocks } from './exportImages'
const logger = loggerService.withContext('Utils:export')
// 全局的导出状态获取函数
@@ -263,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}`
@@ -310,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<string> => {
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<string> => {
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<string> => {
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 => {
@@ -370,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<string> => {
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
@@ -407,35 +479,44 @@ export const exportTopicAsMarkdown = async (
setExportingState(true)
const { markdownExportPath } = store.getState().settings
if (!markdownExportPath) {
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)
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)
}
}
}
export const exportMessageAsMarkdown = async (
@@ -450,41 +531,51 @@ export const exportMessageAsMarkdown = async (
setExportingState(true)
const { markdownExportPath } = store.getState().settings
if (!markdownExportPath) {
try {
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
? 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
? messageToMarkdownWithReasoning(message, excludeCitations)
: messageToMarkdown(message, excludeCitations)
? 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)
}
}
}
const convertMarkdownToNotionBlocks = async (markdown: string): Promise<any[]> => {

View File

@@ -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<string> {
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<string> {
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<ImageExportResult[]> {
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<Map<string, ImageExportResult[]>> {
const resultsMap = new Map<string, ImageExportResult[]>()
for (const message of messages) {
const imageResults = await processImageBlocks(message, mode, outputDir)
if (imageResults.length > 0) {
resultsMap.set(message.id, imageResults)
}
}
return resultsMap
}