diff --git a/src/main/ipc.ts b/src/main/ipc.ts index b62c576e..d88f7771 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -53,6 +53,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle('file:write', fileManager.writeFile) ipcMain.handle('file:saveImage', fileManager.saveImage) ipcMain.handle('file:base64Image', fileManager.base64Image) + ipcMain.handle('file:download', fileManager.downloadFile) ipcMain.handle('minapp', (_, args) => { createMinappWindow({ diff --git a/src/main/services/FileManager.ts b/src/main/services/FileManager.ts index eb1e0de6..5b1be56f 100644 --- a/src/main/services/FileManager.ts +++ b/src/main/services/FileManager.ts @@ -315,6 +315,86 @@ class FileManager { return null } } + + public downloadFile = async (_: Electron.IpcMainInvokeEvent, url: string): Promise => { + try { + const response = await fetch(url) + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + // 尝试从Content-Disposition获取文件名 + const contentDisposition = response.headers.get('Content-Disposition') + let filename = 'download' + + if (contentDisposition) { + const filenameMatch = contentDisposition.match(/filename="?(.+)"?/i) + if (filenameMatch) { + filename = filenameMatch[1] + } + } + + // 如果URL中有文件名,使用URL中的文件名 + const urlFilename = url.split('/').pop() + if (urlFilename && urlFilename.includes('.')) { + filename = urlFilename + } + + // 如果文件名没有后缀,根据Content-Type添加后缀 + if (!filename.includes('.')) { + const contentType = response.headers.get('Content-Type') + const ext = this.getExtensionFromMimeType(contentType) + filename += ext + } + + const uuid = uuidv4() + const ext = path.extname(filename) + const destPath = path.join(this.storageDir, uuid + ext) + + // 将响应内容写入文件 + const buffer = Buffer.from(await response.arrayBuffer()) + await fs.promises.writeFile(destPath, buffer) + + const stats = await fs.promises.stat(destPath) + const fileType = getFileType(ext) + + const fileMetadata: FileType = { + id: uuid, + origin_name: filename, + name: uuid + ext, + path: destPath, + created_at: stats.birthtime, + size: stats.size, + ext: ext, + type: fileType, + count: 1 + } + + return fileMetadata + } catch (error) { + logger.error('[FileManager] Download file error:', error) + throw error + } + } + + private getExtensionFromMimeType(mimeType: string | null): string { + if (!mimeType) return '.bin' + + const mimeToExtension: { [key: string]: string } = { + 'image/jpeg': '.jpg', + 'image/png': '.png', + 'image/gif': '.gif', + 'application/pdf': '.pdf', + 'text/plain': '.txt', + 'application/msword': '.doc', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '.docx', + 'application/zip': '.zip', + 'application/x-zip-compressed': '.zip', + 'application/octet-stream': '.bin' + } + + return mimeToExtension[mimeType] || '.bin' + } } export default FileManager diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 358556ca..37ada499 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -42,6 +42,7 @@ declare global { save: (path: string, content: string | NodeJS.ArrayBufferView, options?: SaveDialogOptions) => void saveImage: (name: string, data: string) => void base64Image: (fileId: string) => Promise<{ mime: string; base64: string; data: string }> + download: (url: string) => Promise } } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 0057d26c..ff43114d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -35,7 +35,8 @@ const api = { ipcRenderer.invoke('file:save', path, content, options), selectFolder: () => ipcRenderer.invoke('file:selectFolder'), saveImage: (name: string, data: string) => ipcRenderer.invoke('file:saveImage', name, data), - base64Image: (fileId: string) => ipcRenderer.invoke('file:base64Image', fileId) + base64Image: (fileId: string) => ipcRenderer.invoke('file:base64Image', fileId), + download: (url: string) => ipcRenderer.invoke('file:download', url) } } diff --git a/src/renderer/src/hooks/usePaintings.ts b/src/renderer/src/hooks/usePaintings.ts index c7f11014..4c7e0888 100644 --- a/src/renderer/src/hooks/usePaintings.ts +++ b/src/renderer/src/hooks/usePaintings.ts @@ -1,4 +1,5 @@ import { TEXT_TO_IMAGES_MODELS } from '@renderer/config/models' +import FileManager from '@renderer/services/file' import { useAppDispatch, useAppSelector } from '@renderer/store' import { addPainting, removePainting, updatePainting, updatePaintings } from '@renderer/store/paintings' import { Painting } from '@renderer/types' @@ -27,7 +28,8 @@ export function usePaintings() { dispatch(addPainting(newPainting)) return newPainting }, - removePainting: (painting: Painting) => { + removePainting: async (painting: Painting) => { + FileManager.deleteFiles(painting.files) dispatch(removePainting(painting)) }, updatePainting: (painting: Painting) => { diff --git a/src/renderer/src/pages/paintings/PaintingsList.tsx b/src/renderer/src/pages/paintings/PaintingsList.tsx index f056bb99..81ce0dd6 100644 --- a/src/renderer/src/pages/paintings/PaintingsList.tsx +++ b/src/renderer/src/pages/paintings/PaintingsList.tsx @@ -1,6 +1,7 @@ import { DeleteOutlined } from '@ant-design/icons' import DragableList from '@renderer/components/DragableList' import { usePaintings } from '@renderer/hooks/usePaintings' +import FileManager from '@renderer/services/file' import { Painting } from '@renderer/types' import { classNames } from '@renderer/utils' import { Popconfirm } from 'antd' @@ -31,9 +32,9 @@ const PaintingsList: FC = ({ paintings, selectedPainting, on onSelectPainting(item)} - thumbnail={item.urls[0]} - /> + onClick={() => onSelectPainting(item)}> + {item.files[0] && } + ` +const Canvas = styled.div` width: 80px; height: 80px; background-color: var(--color-background-soft); - background-image: ${(props) => (props.thumbnail ? `url(${props.thumbnail})` : 'none')}; - background-size: cover; - background-position: center; cursor: pointer; transition: background-color 0.2s ease; border: 1px solid var(--color-background-soft); + overflow: hidden; + position: relative; &.selected { border: 1px solid var(--color-primary); @@ -93,6 +93,13 @@ const Canvas = styled.div<{ thumbnail?: string }>` } ` +const ThumbnailImage = styled.img` + width: 100%; + height: 100%; + object-fit: cover; + display: block; +` + const DeleteButton = styled.div.attrs({ className: 'delete-button' })` position: absolute; top: 4px; diff --git a/src/renderer/src/pages/paintings/PaintingsPage.tsx b/src/renderer/src/pages/paintings/PaintingsPage.tsx index 32a34ae4..e6948885 100644 --- a/src/renderer/src/pages/paintings/PaintingsPage.tsx +++ b/src/renderer/src/pages/paintings/PaintingsPage.tsx @@ -14,8 +14,9 @@ import { usePaintings } from '@renderer/hooks/usePaintings' import { useProviders } from '@renderer/hooks/useProvider' import AiProvider from '@renderer/providers/AiProvider' import { getProviderByModel } from '@renderer/services/assistant' +import FileManager from '@renderer/services/file' import { DEFAULT_PAINTING } from '@renderer/store/paintings' -import { Painting } from '@renderer/types' +import { FileType, Painting } from '@renderer/types' import { getErrorMessage } from '@renderer/utils' import { Button, Input, InputNumber, Radio, Select, Slider, Spin, Tooltip } from 'antd' import TextArea from 'antd/es/input/TextArea' @@ -97,7 +98,7 @@ const PaintingsPage: FC = () => { } const onGenerate = async () => { - if (painting.urls.length > 0) { + if (painting.files.length > 0) { const confirmed = await window.modal.confirm({ content: t('images.regenerate.confirm'), centered: true @@ -106,9 +107,12 @@ const PaintingsPage: FC = () => { if (!confirmed) { return } + + await FileManager.deleteFiles(painting.files) } const prompt = textareaRef.current?.resizableTextArea?.textArea?.value || '' + updatePaintingState({ prompt }) const model = TEXT_TO_IMAGES_MODELS.find((m) => m.id === painting.model) @@ -137,7 +141,22 @@ const PaintingsPage: FC = () => { }) if (urls.length > 0) { - updatePaintingState({ urls }) + const downloadedFiles = await Promise.all( + urls.map(async (url) => { + try { + return await window.api.file.download(url) + } catch (error) { + console.error('Failed to download image:', error) + return null + } + }) + ) + + const validFiles = downloadedFiles.filter((file): file is FileType => file !== null) + + await FileManager.addFiles(validFiles) + + updatePaintingState({ files: validFiles }) } } catch (error: unknown) { if (error instanceof Error && error.name !== 'AbortError') { @@ -158,12 +177,17 @@ const PaintingsPage: FC = () => { size && updatePaintingState({ imageSize: size.value }) } + const getCurrentImageUrl = () => { + const currentFile = painting.files[currentImageIndex] + return currentFile ? FileManager.getFileUrl(currentFile) : '' + } + const nextImage = () => { - setCurrentImageIndex((prev) => (prev + 1) % painting.urls.length) + setCurrentImageIndex((prev) => (prev + 1) % painting.files.length) } const prevImage = () => { - setCurrentImageIndex((prev) => (prev - 1 + painting.urls.length) % painting.urls.length) + setCurrentImageIndex((prev) => (prev - 1 + painting.files.length) % painting.files.length) } const onDeletePainting = (paintingToDelete: Painting) => { @@ -299,15 +323,15 @@ const PaintingsPage: FC = () => { - {painting.urls.length > 0 ? ( + {painting.files.length > 0 ? ( - {painting.urls.length > 1 && ( + {painting.files.length > 1 && ( )} { cursor: 'pointer' }} /> - {painting.urls.length > 1 && ( + {painting.files.length > 1 && ( )} - {currentImageIndex + 1} / {painting.urls.length} + {currentImageIndex + 1} / {painting.files.length} ) : ( diff --git a/src/renderer/src/services/file.ts b/src/renderer/src/services/file.ts index a639a6a5..2d641e78 100644 --- a/src/renderer/src/services/file.ts +++ b/src/renderer/src/services/file.ts @@ -9,6 +9,23 @@ class FileManager { return files } + static async addFile(file: FileType): Promise { + const fileRecord = await db.files.get(file.id) + + if (fileRecord) { + await db.files.update(fileRecord.id, { ...fileRecord, count: fileRecord.count + 1 }) + return fileRecord + } + + await db.files.add(file) + + return file + } + + static async addFiles(files: FileType[]): Promise { + return Promise.all(files.map((file) => this.addFile(file))) + } + static async uploadFile(file: FileType): Promise { const uploadFile = await window.api.file.upload(file) const fileRecord = await db.files.get(uploadFile.id) @@ -50,12 +67,12 @@ class FileManager { return } - db.files.delete(id) + await db.files.delete(id) await window.api.file.delete(id + file.ext) } - static async deleteFiles(ids: string[]): Promise { - await Promise.all(ids.map((id) => this.deleteFile(id))) + static async deleteFiles(files: FileType[]): Promise { + await Promise.all(files.map((file) => this.deleteFile(file.id))) } static async allFiles(): Promise { diff --git a/src/renderer/src/services/messages.ts b/src/renderer/src/services/messages.ts index 7cdde4cb..6721d7b5 100644 --- a/src/renderer/src/services/messages.ts +++ b/src/renderer/src/services/messages.ts @@ -39,7 +39,7 @@ export function getContextCount(assistant: Assistant, messages: Message[]) { } export function deleteMessageFiles(message: Message) { - message.files && FileManager.deleteFiles(message.files.map((f) => f.id)) + message.files && FileManager.deleteFiles(message.files) } export async function locateToMessage(navigate: NavigateFunction, message: Message) { diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index 3cf01059..be721c99 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -99,6 +99,7 @@ export interface Painting { steps?: number guidanceScale?: number model?: string + status?: 'generating' | 'downloading' | 'completed' | 'error' } export type MinAppType = {