feat: added file download functionality and improved api
This commit is contained in:
parent
7401d85825
commit
3e049baaa4
@ -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({
|
||||
|
||||
@ -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
|
||||
|
||||
1
src/preload/index.d.ts
vendored
1
src/preload/index.d.ts
vendored
@ -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>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
) : (
|
||||
|
||||
@ -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[]> {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -99,6 +99,7 @@ export interface Painting {
|
||||
steps?: number
|
||||
guidanceScale?: number
|
||||
model?: string
|
||||
status?: 'generating' | 'downloading' | 'completed' | 'error'
|
||||
}
|
||||
|
||||
export type MinAppType = {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user