From 0076577764d36bba20eb1da56c78b98d9edc4c40 Mon Sep 17 00:00:00 2001 From: StyleZhang Date: Wed, 11 Sep 2024 18:25:14 +0800 Subject: [PATCH] file uploader --- .../base/chat/chat/chat-input-area/index.tsx | 4 +- .../file-from-link-or-local/index.tsx | 26 ++---- .../base/file-uploader/file-image-render.tsx | 30 +++++++ .../base/file-uploader/file-input.tsx | 21 +++++ .../file-list-flex-operation.tsx | 8 +- .../base/file-uploader/file-thumb.tsx | 7 ++ .../base/file-uploader/file-type-icon.tsx | 29 +++--- .../file-in-attachment-item.tsx | 89 +++++++++++++++---- .../file-uploader-in-attachment/index.tsx | 21 ++++- .../components/base/file-uploader/hooks.ts | 76 +++++++++------- .../components/base/file-uploader/store.tsx | 23 +++-- .../components/base/file-uploader/types.ts | 19 ++-- .../components/base/file-uploader/utils.ts | 40 ++++++++- .../share/text-generation/run-once/index.tsx | 2 +- 14 files changed, 284 insertions(+), 111 deletions(-) create mode 100644 web/app/components/base/file-uploader/file-image-render.tsx create mode 100644 web/app/components/base/file-uploader/file-input.tsx create mode 100644 web/app/components/base/file-uploader/file-thumb.tsx diff --git a/web/app/components/base/chat/chat/chat-input-area/index.tsx b/web/app/components/base/chat/chat/chat-input-area/index.tsx index 1cd0e87dbf..c8a4fcf59d 100644 --- a/web/app/components/base/chat/chat/chat-input-area/index.tsx +++ b/web/app/components/base/chat/chat/chat-input-area/index.tsx @@ -40,7 +40,7 @@ const ChatInputArea = ({ visionConfig, speechToTextConfig = { enabled: true }, onSend, - theme, + // theme, }: ChatInputAreaProps) => { const { t } = useTranslation() const { notify } = useToastContext() @@ -103,7 +103,7 @@ const ChatInputArea = ({ ) return ( - + {}}> <>
void showFromLocal?: boolean trigger: (open: boolean) => React.ReactNode } const FileFromLinkOrLocal = ({ showFromLink = true, - onLink, showFromLocal = true, trigger, }: FileFromLinkOrLocalProps) => { const { t } = useTranslation() const [open, setOpen] = useState(false) const [url, setUrl] = useState('') - const { handleLocalFileUpload } = useFile() - - const handleChange = (e: ChangeEvent) => { - const file = e.target.files?.[0] - - if (!file) - return - - handleLocalFileUpload(file) - } + const { handleLoadFileFromLink } = useFile() return ( setOpen(v => !v)} asChild> {trigger(open)} - +
{ showFromLink && ( @@ -65,7 +54,7 @@ const FileFromLinkOrLocal = ({ size='small' variant='primary' disabled={!url} - onClick={() => onLink?.(url)} + onClick={() => handleLoadFileFromLink()} > {t('common.operation.ok')} @@ -89,12 +78,7 @@ const FileFromLinkOrLocal = ({ > {t('common.fileUploader.uploadFromComputer')} - ((e.target as HTMLInputElement).value = '')} - type='file' - onChange={handleChange} - /> + ) } diff --git a/web/app/components/base/file-uploader/file-image-render.tsx b/web/app/components/base/file-uploader/file-image-render.tsx new file mode 100644 index 0000000000..864135af0f --- /dev/null +++ b/web/app/components/base/file-uploader/file-image-render.tsx @@ -0,0 +1,30 @@ +import cn from '@/utils/classnames' + +type FileImageRenderProps = { + imageUrl: string + className?: string + alt?: string + onLoad?: () => void + onError?: () => void +} +const FileImageRender = ({ + imageUrl, + className, + alt, + onLoad, + onError, +}: FileImageRenderProps) => { + return ( +
+ {alt} +
+ ) +} + +export default FileImageRender diff --git a/web/app/components/base/file-uploader/file-input.tsx b/web/app/components/base/file-uploader/file-input.tsx new file mode 100644 index 0000000000..42398a6a33 --- /dev/null +++ b/web/app/components/base/file-uploader/file-input.tsx @@ -0,0 +1,21 @@ +import { useFile } from './hooks' + +const FileInput = () => { + const { handleLocalFileUpload } = useFile() + const handleChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + + if (file) + handleLocalFileUpload(file) + } + return ( + ((e.target as HTMLInputElement).value = '')} + type='file' + onChange={handleChange} + /> + ) +} + +export default FileInput diff --git a/web/app/components/base/file-uploader/file-list-flex/file-list-flex-operation.tsx b/web/app/components/base/file-uploader/file-list-flex/file-list-flex-operation.tsx index 583a4ff556..eb3bafc847 100644 --- a/web/app/components/base/file-uploader/file-list-flex/file-list-flex-operation.tsx +++ b/web/app/components/base/file-uploader/file-list-flex/file-list-flex-operation.tsx @@ -21,23 +21,23 @@ const FileListFlexOperation = forwardRef((_, ref) => { { files.map(file => (
{ - file._progress !== 100 && ( + file.progress !== 100 && (
{ + return ( +
+ ) +} + +export default FileThumb diff --git a/web/app/components/base/file-uploader/file-type-icon.tsx b/web/app/components/base/file-uploader/file-type-icon.tsx index 73d733f0cd..0808d6b95f 100644 --- a/web/app/components/base/file-uploader/file-type-icon.tsx +++ b/web/app/components/base/file-uploader/file-type-icon.tsx @@ -13,61 +13,62 @@ import { RiFileWordFill, RiMarkdownFill, } from '@remixicon/react' -import { FileTypeEnum } from './types' +import { FileAppearanceTypeEnum } from './types' +import type { FileAppearanceType } from './types' import cn from '@/utils/classnames' const FILE_TYPE_ICON_MAP = { - [FileTypeEnum.PDF]: { + [FileAppearanceTypeEnum.PDF]: { component: RiFilePdf2Fill, color: 'text-[#EA3434]', }, - [FileTypeEnum.IMAGE]: { + [FileAppearanceTypeEnum.IMAGE]: { component: RiFileImageFill, color: 'text-[#00B2EA]', }, - [FileTypeEnum.VIDEO]: { + [FileAppearanceTypeEnum.VIDEO]: { component: RiFileVideoFill, color: 'text-[#844FDA]', }, - [FileTypeEnum.AUDIO]: { + [FileAppearanceTypeEnum.AUDIO]: { component: RiFileMusicFill, color: 'text-[#FF3093]', }, - [FileTypeEnum.DOCUMENT]: { + [FileAppearanceTypeEnum.DOCUMENT]: { component: RiFileTextFill, color: 'text-[#6F8BB5]', }, - [FileTypeEnum.CODE]: { + [FileAppearanceTypeEnum.CODE]: { component: RiFileCodeFill, color: 'text-[#BCC0D1]', }, - [FileTypeEnum.MARKDOWN]: { + [FileAppearanceTypeEnum.MARKDOWN]: { component: RiMarkdownFill, color: 'text-[#309BEC]', }, - [FileTypeEnum.OTHER]: { + [FileAppearanceTypeEnum.OTHER]: { component: RiFile3Fill, color: 'text-[#BCC0D1]', }, - [FileTypeEnum.EXCEL]: { + [FileAppearanceTypeEnum.EXCEL]: { component: RiFileExcelFill, color: 'text-[#01AC49]', }, - [FileTypeEnum.WORD]: { + [FileAppearanceTypeEnum.WORD]: { component: RiFileWordFill, color: 'text-[#2684FF]', }, - [FileTypeEnum.PPT]: { + [FileAppearanceTypeEnum.PPT]: { component: RiFilePpt2Fill, color: 'text-[#FF650F]', }, - [FileTypeEnum.GIF]: { + [FileAppearanceTypeEnum.GIF]: { component: RiFileGifFill, color: 'text-[#00B2EA]', }, } type FileTypeIconProps = { - type: keyof typeof FileTypeEnum + type: FileAppearanceType size?: 'sm' | 'lg' className?: string } diff --git a/web/app/components/base/file-uploader/file-uploader-in-attachment/file-in-attachment-item.tsx b/web/app/components/base/file-uploader/file-uploader-in-attachment/file-in-attachment-item.tsx index fe0d1a4987..4e617b8841 100644 --- a/web/app/components/base/file-uploader/file-uploader-in-attachment/file-in-attachment-item.tsx +++ b/web/app/components/base/file-uploader/file-uploader-in-attachment/file-in-attachment-item.tsx @@ -1,32 +1,91 @@ import { memo } from 'react' import { RiDeleteBinLine, + RiEditCircleFill, } from '@remixicon/react' import FileTypeIcon from '../file-type-icon' +import type { FileEntity } from '../types' +import { useFile } from '../hooks' +import { + getFileAppearanceType, + getFileExtension, + isImage, +} from '../utils' +import FileImageRender from '../file-image-render' import ActionButton from '@/app/components/base/action-button' import ProgressCircle from '@/app/components/base/progress-bar/progress-circle' +import { formatFileSize } from '@/utils/format' +import cn from '@/utils/classnames' + +type FileInAttachmentItemProps = { + file: FileEntity +} +const FileInAttachmentItem = ({ + file, +}: FileInAttachmentItemProps) => { + const { + handleRemoveFile, + handleReUploadFile, + } = useFile() + const ext = getFileExtension(file.file) -const FileInAttachmentItem = () => { return ( -
- -
-
Yellow mountain range.jpg
+
+
+ { + isImage(file?.file) && ( + + ) + } + { + !isImage(file.file) && ( + + ) + } +
+
+
+ {file.file?.name} +
- JPG + { + ext && ( + {ext.toLowerCase()} + ) + } - 21.5 MB + {formatFileSize(file.file?.size || 0)}
- - + { + file.progress >= 0 && file.progress < 100 && ( + + ) + } + { + file.progress === -1 && ( + handleReUploadFile(file.id)}> + + + ) + } + handleRemoveFile(file.id)}>
diff --git a/web/app/components/base/file-uploader/file-uploader-in-attachment/index.tsx b/web/app/components/base/file-uploader/file-uploader-in-attachment/index.tsx index c87dbab237..494a1101ae 100644 --- a/web/app/components/base/file-uploader/file-uploader-in-attachment/index.tsx +++ b/web/app/components/base/file-uploader/file-uploader-in-attachment/index.tsx @@ -11,6 +11,8 @@ import { FileContextProvider, useStore, } from '../store' +import type { FileEntity } from '../types' +import FileInput from '../file-input' import FileInAttachmentItem from './file-in-attachment-item' import Button from '@/app/components/base/button' import cn from '@/utils/classnames' @@ -41,10 +43,15 @@ const FileUploaderInAttachment = () => { ) }, []) @@ -75,7 +82,8 @@ const FileUploaderInAttachment = () => { { files.map(file => ( )) } @@ -84,9 +92,14 @@ const FileUploaderInAttachment = () => { ) } -const FileUploaderInAttachmentWrapper = () => { +type FileUploaderInAttachmentWrapperProps = { + onChange: (files: FileEntity[]) => void +} +const FileUploaderInAttachmentWrapper = ({ + onChange, +}: FileUploaderInAttachmentWrapperProps) => { return ( - + ) diff --git a/web/app/components/base/file-uploader/hooks.ts b/web/app/components/base/file-uploader/hooks.ts index fdae208e4e..1be14aaccd 100644 --- a/web/app/components/base/file-uploader/hooks.ts +++ b/web/app/components/base/file-uploader/hooks.ts @@ -3,6 +3,7 @@ import { useCallback, useState, } from 'react' +import { useParams } from 'next/navigation' import produce from 'immer' import { v4 as uuid4 } from 'uuid' import { useTranslation } from 'react-i18next' @@ -11,14 +12,11 @@ import { useFileStore } from './store' import { fileUpload } from './utils' import { useToastContext } from '@/app/components/base/toast' -type UseFileParams = { - isPublicAPI?: boolean - url?: string -} export const useFile = () => { const { t } = useTranslation() const { notify } = useToastContext() const fileStore = useFileStore() + const params = useParams() const handleAddOrUpdateFiles = useCallback((newFile: FileEntity) => { const { @@ -27,7 +25,7 @@ export const useFile = () => { } = fileStore.getState() const newFiles = produce(files, (draft) => { - const index = draft.findIndex(file => file._id === newFile._id) + const index = draft.findIndex(file => file.id === newFile.id) if (index > -1) draft[index] = newFile @@ -43,23 +41,44 @@ export const useFile = () => { setFiles, } = fileStore.getState() - const newFiles = files.filter(file => file._id !== fileId) + const newFiles = files.filter(file => file.id !== fileId) setFiles(newFiles) }, [fileStore]) - const handleLoadFileFromLink = useCallback((fileId: string, progress: number) => { + const handleReUploadFile = useCallback((fileId: string) => { const { files, setFiles, } = fileStore.getState() - const newFiles = produce(files, (draft) => { - const index = draft.findIndex(file => file._id === fileId) + const index = files.findIndex(file => file.id === fileId) - if (index > -1) - draft[index]._progress = progress - }) - setFiles(newFiles) - }, [fileStore]) + if (index > -1) { + const uploadingFile = files[index] + const newFiles = produce(files, (draft) => { + draft[index].progress = 0 + }) + setFiles(newFiles) + fileUpload({ + file: uploadingFile.file!, + onProgressCallback: (progress) => { + handleAddOrUpdateFiles({ ...uploadingFile, progress }) + }, + onSuccessCallback: (res) => { + handleAddOrUpdateFiles({ ...uploadingFile, fileId: res.id, progress: 100 }) + }, + onErrorCallback: () => { + notify({ type: 'error', message: t('common.imageUploader.uploadFromComputerUploadError') }) + handleAddOrUpdateFiles({ ...uploadingFile, progress: -1 }) + }, + }, !!params.token) + } + }, [fileStore, notify, t, handleAddOrUpdateFiles, params]) + + const handleLoadFileFromLink = useCallback(() => {}, []) + + const handleLoadFileFromLinkSuccess = useCallback(() => { }, []) + + const handleLoadFileFromLinkError = useCallback(() => { }, []) const handleClearFiles = useCallback(() => { const { @@ -68,39 +87,33 @@ export const useFile = () => { setFiles([]) }, [fileStore]) - const handleLocalFileUpload = useCallback(( - file: File, - { - isPublicAPI, - url, - }: UseFileParams = { isPublicAPI: false }, - ) => { + const handleLocalFileUpload = useCallback((file: File) => { const reader = new FileReader() const isImage = file.type.startsWith('image') reader.addEventListener( 'load', () => { const uploadingFile = { - _id: uuid4(), + id: uuid4(), file, - _url: reader.result as string, - _progress: 0, - _base64Url: isImage ? reader.result as string : '', + url: '', + progress: 0, + base64Url: isImage ? reader.result as string : '', } handleAddOrUpdateFiles(uploadingFile) fileUpload({ file: uploadingFile.file, onProgressCallback: (progress) => { - handleAddOrUpdateFiles({ ...uploadingFile, _progress: progress }) + handleAddOrUpdateFiles({ ...uploadingFile, progress }) }, onSuccessCallback: (res) => { - handleAddOrUpdateFiles({ ...uploadingFile, _fileId: res.id, _progress: 100 }) + handleAddOrUpdateFiles({ ...uploadingFile, fileId: res.id, progress: 100 }) }, onErrorCallback: () => { notify({ type: 'error', message: t('common.imageUploader.uploadFromComputerUploadError') }) - handleAddOrUpdateFiles({ ...uploadingFile, _progress: -1 }) + handleAddOrUpdateFiles({ ...uploadingFile, progress: -1 }) }, - }, isPublicAPI, url) + }, !!params.token) }, false, ) @@ -112,7 +125,7 @@ export const useFile = () => { false, ) reader.readAsDataURL(file) - }, [notify, t, handleAddOrUpdateFiles]) + }, [notify, t, handleAddOrUpdateFiles, params.token]) const handleClipboardPasteFile = useCallback((e: ClipboardEvent) => { const file = e.clipboardData?.files[0] @@ -154,7 +167,10 @@ export const useFile = () => { return { handleAddOrUpdateFiles, handleRemoveFile, + handleReUploadFile, handleLoadFileFromLink, + handleLoadFileFromLinkSuccess, + handleLoadFileFromLinkError, handleClearFiles, handleLocalFileUpload, handleClipboardPasteFile, diff --git a/web/app/components/base/file-uploader/store.tsx b/web/app/components/base/file-uploader/store.tsx index 1d58de145e..536fce1cf6 100644 --- a/web/app/components/base/file-uploader/store.tsx +++ b/web/app/components/base/file-uploader/store.tsx @@ -4,9 +4,9 @@ import { useRef, } from 'react' import { + create, useStore as useZustandStore, } from 'zustand' -import { createStore } from 'zustand/vanilla' import type { FileEntity } from './types' type Shape = { @@ -14,10 +14,15 @@ type Shape = { setFiles: (files: FileEntity[]) => void } -export const createFileStore = () => { - return createStore(set => ({ +export const createFileStore = ({ + onChange, +}: Pick) => { + return create(set => ({ files: [], - setFiles: files => set({ files }), + setFiles: (files) => { + set({ files }) + onChange(files) + }, })) } @@ -38,12 +43,18 @@ export const useFileStore = () => { type FileProviderProps = { children: React.ReactNode + onChange: (files: FileEntity[]) => void + isPublicAPI?: boolean + url?: string } -export const FileContextProvider = ({ children }: FileProviderProps) => { +export const FileContextProvider = ({ + children, + onChange, +}: FileProviderProps) => { const storeRef = useRef() if (!storeRef.current) - storeRef.current = createFileStore() + storeRef.current = createFileStore({ onChange }) return ( diff --git a/web/app/components/base/file-uploader/types.ts b/web/app/components/base/file-uploader/types.ts index a28152a4f8..237f444501 100644 --- a/web/app/components/base/file-uploader/types.ts +++ b/web/app/components/base/file-uploader/types.ts @@ -1,6 +1,4 @@ -import type { TransferMethod } from '@/types/app' - -export enum FileTypeEnum { +export enum FileAppearanceTypeEnum { IMAGE = 'IMAGE', VIDEO = 'VIDEO', AUDIO = 'AUDIO', @@ -15,12 +13,13 @@ export enum FileTypeEnum { OTHER = 'OTHER', } +export type FileAppearanceType = keyof typeof FileAppearanceTypeEnum + export type FileEntity = { - file: File - _id: string - _fileId?: string - _progress?: number - _url?: string - _base64Url?: string - _method?: TransferMethod + id: string + file?: File + fileId?: string + progress: number + url?: string + base64Url?: string } diff --git a/web/app/components/base/file-uploader/utils.ts b/web/app/components/base/file-uploader/utils.ts index 66e6668f76..10934c9fdf 100644 --- a/web/app/components/base/file-uploader/utils.ts +++ b/web/app/components/base/file-uploader/utils.ts @@ -1,3 +1,4 @@ +import { FileAppearanceTypeEnum } from './types' import { upload } from '@/service/base' type FileUploadParams = { @@ -35,8 +36,39 @@ export const fileUpload: FileUpload = ({ }) } -export const isFileType = (type: string) => { - return (file: File) => { - return file.type === type - } +export const getFileAppearanceType = (file?: File) => { + if (!file) + return FileAppearanceTypeEnum.OTHER + const mimeType = file.type + + if (mimeType.includes('image')) + return FileAppearanceTypeEnum.IMAGE + + if (mimeType.includes('video')) + return FileAppearanceTypeEnum.VIDEO + + if (mimeType.includes('audio')) + return FileAppearanceTypeEnum.AUDIO + + if (mimeType.includes('pdf')) + return FileAppearanceTypeEnum.PDF + + return FileAppearanceTypeEnum.OTHER +} + +export const isImage = (file?: File) => { + return file?.type.startsWith('image') +} + +export const getFileExtension = (file?: File) => { + if (!file) + return '' + + const fileNamePair = file.name.split('.') + const fileNamePairLength = fileNamePair.length + + if (fileNamePairLength > 1) + return fileNamePair[fileNamePairLength - 1] + + return '' } diff --git a/web/app/components/share/text-generation/run-once/index.tsx b/web/app/components/share/text-generation/run-once/index.tsx index 57ea2bea87..4924710be4 100644 --- a/web/app/components/share/text-generation/run-once/index.tsx +++ b/web/app/components/share/text-generation/run-once/index.tsx @@ -114,7 +114,7 @@ const RunOnce: FC = ({
) } - + {}} /> {promptConfig.prompt_variables.length > 0 && (
)}