Compare commits
2 Commits
copilot/fi
...
feat/image
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42ee8a68ce | ||
|
|
d521a88d30 |
305
docs/EXPORT_IMAGES_PLAN.md
Normal file
305
docs/EXPORT_IMAGES_PLAN.md
Normal 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 导出模式设计
|
||||
|
||||
提供两种图片导出模式供用户选择:
|
||||
|
||||
**模式1:Base64嵌入模式**
|
||||
```markdown
|
||||

|
||||
```
|
||||
- 优点:单文件、便于分享、保证完整性
|
||||
- 缺点:文件体积大、部分编辑器不支持、性能较差
|
||||
|
||||
**模式2:文件夹模式**
|
||||
```
|
||||
导出结构:
|
||||
conversation_2024-01-21/
|
||||
├── conversation.md
|
||||
└── images/
|
||||
├── user_upload_1.png
|
||||
├── ai_generated_1.png
|
||||
└── ...
|
||||
```
|
||||
Markdown中使用相对路径:
|
||||
```markdown
|
||||

|
||||
```
|
||||
- 优点:文件体积小、兼容性好、性能优秀
|
||||
- 缺点:需要管理多个文件、分享需打包
|
||||
|
||||
#### 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,
|
||||
``
|
||||
)
|
||||
}
|
||||
|
||||
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*
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => ``).join('\n\n')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to process images:', error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
export const messageToMarkdown = (message: Message, excludeCitations?: boolean): string => {
|
||||
const { excludeCitationsInExport, standardizeCitationsInExport } = store.getState().settings
|
||||
return { titleSection, reasoningSection, contentSection: processedContent, citation, imageSection }
|
||||
}
|
||||
|
||||
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[]> => {
|
||||
|
||||
321
src/renderer/src/utils/exportImages.ts
Normal file
321
src/renderer/src/utils/exportImages.ts
Normal 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) => ``).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
|
||||
}
|
||||
Reference in New Issue
Block a user