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:write', fileManager.writeFile)
|
||||||
ipcMain.handle('file:saveImage', fileManager.saveImage)
|
ipcMain.handle('file:saveImage', fileManager.saveImage)
|
||||||
ipcMain.handle('file:base64Image', fileManager.base64Image)
|
ipcMain.handle('file:base64Image', fileManager.base64Image)
|
||||||
|
ipcMain.handle('file:download', fileManager.downloadFile)
|
||||||
|
|
||||||
ipcMain.handle('minapp', (_, args) => {
|
ipcMain.handle('minapp', (_, args) => {
|
||||||
createMinappWindow({
|
createMinappWindow({
|
||||||
|
|||||||
@ -315,6 +315,86 @@ class FileManager {
|
|||||||
return null
|
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
|
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
|
save: (path: string, content: string | NodeJS.ArrayBufferView, options?: SaveDialogOptions) => void
|
||||||
saveImage: (name: string, data: string) => void
|
saveImage: (name: string, data: string) => void
|
||||||
base64Image: (fileId: string) => Promise<{ mime: string; base64: string; data: string }>
|
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),
|
ipcRenderer.invoke('file:save', path, content, options),
|
||||||
selectFolder: () => ipcRenderer.invoke('file:selectFolder'),
|
selectFolder: () => ipcRenderer.invoke('file:selectFolder'),
|
||||||
saveImage: (name: string, data: string) => ipcRenderer.invoke('file:saveImage', name, data),
|
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 { TEXT_TO_IMAGES_MODELS } from '@renderer/config/models'
|
||||||
|
import FileManager from '@renderer/services/file'
|
||||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||||
import { addPainting, removePainting, updatePainting, updatePaintings } from '@renderer/store/paintings'
|
import { addPainting, removePainting, updatePainting, updatePaintings } from '@renderer/store/paintings'
|
||||||
import { Painting } from '@renderer/types'
|
import { Painting } from '@renderer/types'
|
||||||
@ -27,7 +28,8 @@ export function usePaintings() {
|
|||||||
dispatch(addPainting(newPainting))
|
dispatch(addPainting(newPainting))
|
||||||
return newPainting
|
return newPainting
|
||||||
},
|
},
|
||||||
removePainting: (painting: Painting) => {
|
removePainting: async (painting: Painting) => {
|
||||||
|
FileManager.deleteFiles(painting.files)
|
||||||
dispatch(removePainting(painting))
|
dispatch(removePainting(painting))
|
||||||
},
|
},
|
||||||
updatePainting: (painting: Painting) => {
|
updatePainting: (painting: Painting) => {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { DeleteOutlined } from '@ant-design/icons'
|
import { DeleteOutlined } from '@ant-design/icons'
|
||||||
import DragableList from '@renderer/components/DragableList'
|
import DragableList from '@renderer/components/DragableList'
|
||||||
import { usePaintings } from '@renderer/hooks/usePaintings'
|
import { usePaintings } from '@renderer/hooks/usePaintings'
|
||||||
|
import FileManager from '@renderer/services/file'
|
||||||
import { Painting } from '@renderer/types'
|
import { Painting } from '@renderer/types'
|
||||||
import { classNames } from '@renderer/utils'
|
import { classNames } from '@renderer/utils'
|
||||||
import { Popconfirm } from 'antd'
|
import { Popconfirm } from 'antd'
|
||||||
@ -31,9 +32,9 @@ const PaintingsList: FC<PaintingsListProps> = ({ paintings, selectedPainting, on
|
|||||||
<CanvasWrapper key={item.id}>
|
<CanvasWrapper key={item.id}>
|
||||||
<Canvas
|
<Canvas
|
||||||
className={classNames(selectedPainting.id === item.id && 'selected')}
|
className={classNames(selectedPainting.id === item.id && 'selected')}
|
||||||
onClick={() => onSelectPainting(item)}
|
onClick={() => onSelectPainting(item)}>
|
||||||
thumbnail={item.urls[0]}
|
{item.files[0] && <ThumbnailImage src={FileManager.getFileUrl(item.files[0])} alt="" />}
|
||||||
/>
|
</Canvas>
|
||||||
<DeleteButton>
|
<DeleteButton>
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title={t('images.button.delete.image.confirm')}
|
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;
|
width: 80px;
|
||||||
height: 80px;
|
height: 80px;
|
||||||
background-color: var(--color-background-soft);
|
background-color: var(--color-background-soft);
|
||||||
background-image: ${(props) => (props.thumbnail ? `url(${props.thumbnail})` : 'none')};
|
|
||||||
background-size: cover;
|
|
||||||
background-position: center;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 0.2s ease;
|
transition: background-color 0.2s ease;
|
||||||
border: 1px solid var(--color-background-soft);
|
border: 1px solid var(--color-background-soft);
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
&.selected {
|
&.selected {
|
||||||
border: 1px solid var(--color-primary);
|
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' })`
|
const DeleteButton = styled.div.attrs({ className: 'delete-button' })`
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 4px;
|
top: 4px;
|
||||||
|
|||||||
@ -14,8 +14,9 @@ import { usePaintings } from '@renderer/hooks/usePaintings'
|
|||||||
import { useProviders } from '@renderer/hooks/useProvider'
|
import { useProviders } from '@renderer/hooks/useProvider'
|
||||||
import AiProvider from '@renderer/providers/AiProvider'
|
import AiProvider from '@renderer/providers/AiProvider'
|
||||||
import { getProviderByModel } from '@renderer/services/assistant'
|
import { getProviderByModel } from '@renderer/services/assistant'
|
||||||
|
import FileManager from '@renderer/services/file'
|
||||||
import { DEFAULT_PAINTING } from '@renderer/store/paintings'
|
import { DEFAULT_PAINTING } from '@renderer/store/paintings'
|
||||||
import { Painting } from '@renderer/types'
|
import { FileType, Painting } from '@renderer/types'
|
||||||
import { getErrorMessage } from '@renderer/utils'
|
import { getErrorMessage } from '@renderer/utils'
|
||||||
import { Button, Input, InputNumber, Radio, Select, Slider, Spin, Tooltip } from 'antd'
|
import { Button, Input, InputNumber, Radio, Select, Slider, Spin, Tooltip } from 'antd'
|
||||||
import TextArea from 'antd/es/input/TextArea'
|
import TextArea from 'antd/es/input/TextArea'
|
||||||
@ -97,7 +98,7 @@ const PaintingsPage: FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onGenerate = async () => {
|
const onGenerate = async () => {
|
||||||
if (painting.urls.length > 0) {
|
if (painting.files.length > 0) {
|
||||||
const confirmed = await window.modal.confirm({
|
const confirmed = await window.modal.confirm({
|
||||||
content: t('images.regenerate.confirm'),
|
content: t('images.regenerate.confirm'),
|
||||||
centered: true
|
centered: true
|
||||||
@ -106,9 +107,12 @@ const PaintingsPage: FC = () => {
|
|||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await FileManager.deleteFiles(painting.files)
|
||||||
}
|
}
|
||||||
|
|
||||||
const prompt = textareaRef.current?.resizableTextArea?.textArea?.value || ''
|
const prompt = textareaRef.current?.resizableTextArea?.textArea?.value || ''
|
||||||
|
|
||||||
updatePaintingState({ prompt })
|
updatePaintingState({ prompt })
|
||||||
|
|
||||||
const model = TEXT_TO_IMAGES_MODELS.find((m) => m.id === painting.model)
|
const model = TEXT_TO_IMAGES_MODELS.find((m) => m.id === painting.model)
|
||||||
@ -137,7 +141,22 @@ const PaintingsPage: FC = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (urls.length > 0) {
|
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) {
|
} catch (error: unknown) {
|
||||||
if (error instanceof Error && error.name !== 'AbortError') {
|
if (error instanceof Error && error.name !== 'AbortError') {
|
||||||
@ -158,12 +177,17 @@ const PaintingsPage: FC = () => {
|
|||||||
size && updatePaintingState({ imageSize: size.value })
|
size && updatePaintingState({ imageSize: size.value })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getCurrentImageUrl = () => {
|
||||||
|
const currentFile = painting.files[currentImageIndex]
|
||||||
|
return currentFile ? FileManager.getFileUrl(currentFile) : ''
|
||||||
|
}
|
||||||
|
|
||||||
const nextImage = () => {
|
const nextImage = () => {
|
||||||
setCurrentImageIndex((prev) => (prev + 1) % painting.urls.length)
|
setCurrentImageIndex((prev) => (prev + 1) % painting.files.length)
|
||||||
}
|
}
|
||||||
|
|
||||||
const prevImage = () => {
|
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) => {
|
const onDeletePainting = (paintingToDelete: Painting) => {
|
||||||
@ -299,15 +323,15 @@ const PaintingsPage: FC = () => {
|
|||||||
<MainContainer>
|
<MainContainer>
|
||||||
<Artboard>
|
<Artboard>
|
||||||
<LoadingContainer spinning={isLoading}>
|
<LoadingContainer spinning={isLoading}>
|
||||||
{painting.urls.length > 0 ? (
|
{painting.files.length > 0 ? (
|
||||||
<ImageContainer>
|
<ImageContainer>
|
||||||
{painting.urls.length > 1 && (
|
{painting.files.length > 1 && (
|
||||||
<NavigationButton onClick={prevImage} style={{ left: 10 }}>
|
<NavigationButton onClick={prevImage} style={{ left: 10 }}>
|
||||||
←
|
←
|
||||||
</NavigationButton>
|
</NavigationButton>
|
||||||
)}
|
)}
|
||||||
<ImagePreview
|
<ImagePreview
|
||||||
src={painting.urls[currentImageIndex]}
|
src={getCurrentImageUrl()}
|
||||||
preview={{ mask: false }}
|
preview={{ mask: false }}
|
||||||
style={{
|
style={{
|
||||||
width: '70vh',
|
width: '70vh',
|
||||||
@ -317,13 +341,13 @@ const PaintingsPage: FC = () => {
|
|||||||
cursor: 'pointer'
|
cursor: 'pointer'
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{painting.urls.length > 1 && (
|
{painting.files.length > 1 && (
|
||||||
<NavigationButton onClick={nextImage} style={{ right: 10 }}>
|
<NavigationButton onClick={nextImage} style={{ right: 10 }}>
|
||||||
→
|
→
|
||||||
</NavigationButton>
|
</NavigationButton>
|
||||||
)}
|
)}
|
||||||
<ImageCounter>
|
<ImageCounter>
|
||||||
{currentImageIndex + 1} / {painting.urls.length}
|
{currentImageIndex + 1} / {painting.files.length}
|
||||||
</ImageCounter>
|
</ImageCounter>
|
||||||
</ImageContainer>
|
</ImageContainer>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -9,6 +9,23 @@ class FileManager {
|
|||||||
return files
|
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> {
|
static async uploadFile(file: FileType): Promise<FileType> {
|
||||||
const uploadFile = await window.api.file.upload(file)
|
const uploadFile = await window.api.file.upload(file)
|
||||||
const fileRecord = await db.files.get(uploadFile.id)
|
const fileRecord = await db.files.get(uploadFile.id)
|
||||||
@ -50,12 +67,12 @@ class FileManager {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
db.files.delete(id)
|
await db.files.delete(id)
|
||||||
await window.api.file.delete(id + file.ext)
|
await window.api.file.delete(id + file.ext)
|
||||||
}
|
}
|
||||||
|
|
||||||
static async deleteFiles(ids: string[]): Promise<void> {
|
static async deleteFiles(files: FileType[]): Promise<void> {
|
||||||
await Promise.all(ids.map((id) => this.deleteFile(id)))
|
await Promise.all(files.map((file) => this.deleteFile(file.id)))
|
||||||
}
|
}
|
||||||
|
|
||||||
static async allFiles(): Promise<FileType[]> {
|
static async allFiles(): Promise<FileType[]> {
|
||||||
|
|||||||
@ -39,7 +39,7 @@ export function getContextCount(assistant: Assistant, messages: Message[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function deleteMessageFiles(message: 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) {
|
export async function locateToMessage(navigate: NavigateFunction, message: Message) {
|
||||||
|
|||||||
@ -99,6 +99,7 @@ export interface Painting {
|
|||||||
steps?: number
|
steps?: number
|
||||||
guidanceScale?: number
|
guidanceScale?: number
|
||||||
model?: string
|
model?: string
|
||||||
|
status?: 'generating' | 'downloading' | 'completed' | 'error'
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MinAppType = {
|
export type MinAppType = {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user