feat: added file download functionality and improved api

This commit is contained in:
kangfenmao 2024-10-29 23:56:35 +08:00
parent 7401d85825
commit 3e049baaa4
10 changed files with 157 additions and 23 deletions

View File

@ -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({

View File

@ -315,6 +315,86 @@ class FileManager {
return null
}
}
public downloadFile = async (_: Electron.IpcMainInvokeEvent, url: string): Promise<FileType> => {
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

View File

@ -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<FileType | null>
}
}
}

View File

@ -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)
}
}

View File

@ -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) => {

View File

@ -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<PaintingsListProps> = ({ paintings, selectedPainting, on
<CanvasWrapper key={item.id}>
<Canvas
className={classNames(selectedPainting.id === item.id && 'selected')}
onClick={() => onSelectPainting(item)}
thumbnail={item.urls[0]}
/>
onClick={() => onSelectPainting(item)}>
{item.files[0] && <ThumbnailImage src={FileManager.getFileUrl(item.files[0])} alt="" />}
</Canvas>
<DeleteButton>
<Popconfirm
title={t('images.button.delete.image.confirm')}
@ -73,16 +74,15 @@ const CanvasWrapper = styled.div`
}
`
const Canvas = styled.div<{ thumbnail?: string }>`
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;

View File

@ -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 = () => {
<MainContainer>
<Artboard>
<LoadingContainer spinning={isLoading}>
{painting.urls.length > 0 ? (
{painting.files.length > 0 ? (
<ImageContainer>
{painting.urls.length > 1 && (
{painting.files.length > 1 && (
<NavigationButton onClick={prevImage} style={{ left: 10 }}>
</NavigationButton>
)}
<ImagePreview
src={painting.urls[currentImageIndex]}
src={getCurrentImageUrl()}
preview={{ mask: false }}
style={{
width: '70vh',
@ -317,13 +341,13 @@ const PaintingsPage: FC = () => {
cursor: 'pointer'
}}
/>
{painting.urls.length > 1 && (
{painting.files.length > 1 && (
<NavigationButton onClick={nextImage} style={{ right: 10 }}>
</NavigationButton>
)}
<ImageCounter>
{currentImageIndex + 1} / {painting.urls.length}
{currentImageIndex + 1} / {painting.files.length}
</ImageCounter>
</ImageContainer>
) : (

View File

@ -9,6 +9,23 @@ class FileManager {
return files
}
static async addFile(file: FileType): Promise<FileType> {
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<FileType[]> {
return Promise.all(files.map((file) => this.addFile(file)))
}
static async uploadFile(file: FileType): Promise<FileType> {
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<void> {
await Promise.all(ids.map((id) => this.deleteFile(id)))
static async deleteFiles(files: FileType[]): Promise<void> {
await Promise.all(files.map((file) => this.deleteFile(file.id)))
}
static async allFiles(): Promise<FileType[]> {

View File

@ -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) {

View File

@ -99,6 +99,7 @@ export interface Painting {
steps?: number
guidanceScale?: number
model?: string
status?: 'generating' | 'downloading' | 'completed' | 'error'
}
export type MinAppType = {