diff --git a/.vscode/settings.json b/.vscode/settings.json index 45d5fcf..b30daf1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,7 +4,7 @@ "prettier.enable": false, "editor.formatOnSave": true, "editor.codeActionsOnSave": { - "source.fixAll.eslint": true + "source.fixAll.eslint": "explicit" }, "[python]": { "editor.formatOnType": true @@ -29,4 +29,4 @@ "i18n/lang", "app/api/messages" ] -} +} \ No newline at end of file diff --git a/app/components/base/action-button/index.css b/app/components/base/action-button/index.css new file mode 100644 index 0000000..2cabe7a --- /dev/null +++ b/app/components/base/action-button/index.css @@ -0,0 +1,45 @@ +@tailwind components; + +@layer components { + .action-btn { + @apply inline-flex justify-center items-center cursor-pointer text-text-tertiary hover:text-text-secondary hover:bg-state-base-hover + } + + .action-btn-hover { + @apply bg-state-base-hover + } + + .action-btn-disabled { + @apply cursor-not-allowed + } + + .action-btn-xl { + @apply p-2 w-9 h-9 rounded-lg + } + + .action-btn-l { + @apply p-1.5 w-8 h-8 rounded-lg + } + + /* m is for the regular button */ + .action-btn-m { + @apply p-0.5 w-6 h-6 rounded-lg + } + + .action-btn-xs { + @apply p-0 w-4 h-4 rounded + } + + .action-btn.action-btn-active { + @apply text-text-accent bg-state-accent-active hover:bg-state-accent-active-alt + } + + .action-btn.action-btn-disabled { + @apply text-text-disabled + } + + .action-btn.action-btn-destructive { + @apply text-text-destructive bg-state-destructive-hover + } + +} \ No newline at end of file diff --git a/app/components/base/action-button/index.tsx b/app/components/base/action-button/index.tsx new file mode 100644 index 0000000..c90d1a8 --- /dev/null +++ b/app/components/base/action-button/index.tsx @@ -0,0 +1,73 @@ +import type { CSSProperties } from 'react' +import React from 'react' +import { type VariantProps, cva } from 'class-variance-authority' +import classNames from '@/utils/classnames' + +enum ActionButtonState { + Destructive = 'destructive', + Active = 'active', + Disabled = 'disabled', + Default = '', + Hover = 'hover', +} + +const actionButtonVariants = cva( + 'action-btn', + { + variants: { + size: { + xs: 'action-btn-xs', + m: 'action-btn-m', + l: 'action-btn-l', + xl: 'action-btn-xl', + }, + }, + defaultVariants: { + size: 'm', + }, + }, +) + +export type ActionButtonProps = { + size?: 'xs' | 's' | 'm' | 'l' | 'xl' + state?: ActionButtonState + styleCss?: CSSProperties +} & React.ButtonHTMLAttributes & VariantProps + +function getActionButtonState(state: ActionButtonState) { + switch (state) { + case ActionButtonState.Destructive: + return 'action-btn-destructive' + case ActionButtonState.Active: + return 'action-btn-active' + case ActionButtonState.Disabled: + return 'action-btn-disabled' + case ActionButtonState.Hover: + return 'action-btn-hover' + default: + return '' + } +} + +const ActionButton = React.forwardRef( + ({ className, size, state = ActionButtonState.Default, styleCss, children, ...props }, ref) => { + return ( + + ) + }, +) +ActionButton.displayName = 'ActionButton' + +export default ActionButton +export { ActionButton, ActionButtonState, actionButtonVariants } diff --git a/app/components/base/file-uploader-in-attachment/constants.ts b/app/components/base/file-uploader-in-attachment/constants.ts new file mode 100644 index 0000000..47ea377 --- /dev/null +++ b/app/components/base/file-uploader-in-attachment/constants.ts @@ -0,0 +1,16 @@ +import { SupportUploadFileTypes } from './types' +// fallback for file size limit of dify_config +export const IMG_SIZE_LIMIT = 10 * 1024 * 1024 +export const FILE_SIZE_LIMIT = 15 * 1024 * 1024 +export const AUDIO_SIZE_LIMIT = 50 * 1024 * 1024 +export const VIDEO_SIZE_LIMIT = 100 * 1024 * 1024 +export const MAX_FILE_UPLOAD_LIMIT = 10 + +export const FILE_URL_REGEX = /^(https?|ftp):\/\// + +export const FILE_EXTS: Record = { + [SupportUploadFileTypes.image]: ['JPG', 'JPEG', 'PNG', 'GIF', 'WEBP', 'SVG'], + [SupportUploadFileTypes.document]: ['TXT', 'MD', 'MDX', 'MARKDOWN', 'PDF', 'HTML', 'XLSX', 'XLS', 'DOC', 'DOCX', 'CSV', 'EML', 'MSG', 'PPTX', 'PPT', 'XML', 'EPUB'], + [SupportUploadFileTypes.audio]: ['MP3', 'M4A', 'WAV', 'AMR', 'MPGA'], + [SupportUploadFileTypes.video]: ['MP4', 'MOV', 'MPEG', 'WEBM'], +} diff --git a/app/components/base/file-uploader-in-attachment/file-from-link-or-local/index.tsx b/app/components/base/file-uploader-in-attachment/file-from-link-or-local/index.tsx new file mode 100644 index 0000000..5da52a6 --- /dev/null +++ b/app/components/base/file-uploader-in-attachment/file-from-link-or-local/index.tsx @@ -0,0 +1,130 @@ +import { + memo, + useState, +} from 'react' +import { useTranslation } from 'react-i18next' +import { RiUploadCloud2Line } from '@remixicon/react' +import FileInput from '../file-input' +import { useFile } from '../hooks' +import { useStore } from '../store' +import { FILE_URL_REGEX } from '../constants' +import type { FileUpload } from '../types' +import { + PortalToFollowElem, + PortalToFollowElemContent, + PortalToFollowElemTrigger, +} from '@/app/components/base/portal-to-follow-elem' +import Button from '@/app/components/base/button' +import cn from '@/utils/classnames' + +type FileFromLinkOrLocalProps = { + showFromLink?: boolean + showFromLocal?: boolean + trigger: (open: boolean) => React.ReactNode + fileConfig: FileUpload +} +const FileFromLinkOrLocal = ({ + showFromLink = true, + showFromLocal = true, + trigger, + fileConfig, +}: FileFromLinkOrLocalProps) => { + const { t } = useTranslation() + const files = useStore(s => s.files) + const [open, setOpen] = useState(false) + const [url, setUrl] = useState('') + const [showError, setShowError] = useState(false) + const { handleLoadFileFromLink } = useFile(fileConfig) + const disabled = !!fileConfig.number_limits && files.length >= fileConfig.number_limits + + const handleSaveUrl = () => { + if (!url) + return + + if (!FILE_URL_REGEX.test(url)) { + setShowError(true) + return + } + handleLoadFileFromLink(url) + setUrl('') + } + + return ( + + setOpen(v => !v)} asChild> + {trigger(open)} + + +
+ { + showFromLink && ( + <> +
+ { + setShowError(false) + setUrl(e.target.value.trim()) + }} + disabled={disabled} + /> + +
+ { + showError && ( +
+ {t('common.fileUploader.pasteFileLinkInvalid')} +
+ ) + } + + ) + } + { + showFromLink && showFromLocal && ( +
+
+ OR +
+
+ ) + } + { + showFromLocal && ( + + ) + } +
+ + + ) +} + +export default memo(FileFromLinkOrLocal) diff --git a/app/components/base/file-uploader-in-attachment/file-image-render.tsx b/app/components/base/file-uploader-in-attachment/file-image-render.tsx new file mode 100644 index 0000000..d613505 --- /dev/null +++ b/app/components/base/file-uploader-in-attachment/file-image-render.tsx @@ -0,0 +1,32 @@ +import cn from '@/utils/classnames' + +type FileImageRenderProps = { + imageUrl: string + className?: string + alt?: string + onLoad?: () => void + onError?: () => void + showDownloadAction?: boolean +} +const FileImageRender = ({ + imageUrl, + className, + alt, + onLoad, + onError, + showDownloadAction, +}: FileImageRenderProps) => { + return ( +
+ {alt +
+ ) +} + +export default FileImageRender diff --git a/app/components/base/file-uploader-in-attachment/file-input.tsx b/app/components/base/file-uploader-in-attachment/file-input.tsx new file mode 100644 index 0000000..d4cca19 --- /dev/null +++ b/app/components/base/file-uploader-in-attachment/file-input.tsx @@ -0,0 +1,49 @@ +import { useFile } from './hooks' +import { useStore } from './store' +import type { FileUpload } from './types' +import { FILE_EXTS } from './constants' +import { SupportUploadFileTypes } from './types' + +type FileInputProps = { + fileConfig: FileUpload +} +const FileInput = ({ + fileConfig, +}: FileInputProps) => { + const files = useStore(s => s.files) + const { handleLocalFileUpload } = useFile(fileConfig) + const handleChange = (e: React.ChangeEvent) => { + const targetFiles = e.target.files + + if (targetFiles) { + if (fileConfig.number_limits) { + for (let i = 0; i < targetFiles.length; i++) { + if (i + 1 + files.length <= fileConfig.number_limits) + handleLocalFileUpload(targetFiles[i]) + } + } + else { + handleLocalFileUpload(targetFiles[0]) + } + } + } + + const allowedFileTypes = fileConfig.allowed_file_types + const isCustom = allowedFileTypes?.includes(SupportUploadFileTypes.custom) + const exts = isCustom ? (fileConfig.allowed_file_extensions || []) : (allowedFileTypes?.map(type => FILE_EXTS[type]) || []).flat().map(item => `.${item}`) + const accept = exts.join(',') + + return ( + ((e.target as HTMLInputElement).value = '')} + type='file' + onChange={handleChange} + accept={accept} + disabled={!!(fileConfig.number_limits && files.length >= fileConfig?.number_limits)} + multiple={!!fileConfig.number_limits && fileConfig.number_limits > 1} + /> + ) +} + +export default FileInput diff --git a/app/components/base/file-uploader-in-attachment/file-item.tsx b/app/components/base/file-uploader-in-attachment/file-item.tsx new file mode 100644 index 0000000..4517251 --- /dev/null +++ b/app/components/base/file-uploader-in-attachment/file-item.tsx @@ -0,0 +1,154 @@ +import { + memo, + useState, +} from 'react' +import { + RiDeleteBinLine, + RiDownloadLine, + RiEyeLine, +} from '@remixicon/react' +import FileTypeIcon from './file-type-icon' +import FileImageRender from './file-image-render' +import type { FileEntity } from './types' +import { + downloadFile, + fileIsUploaded, + getFileAppearanceType, + getFileExtension, +} from './utils' +import { SupportUploadFileTypes } from './types' +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' +import ReplayLine from '@/app/components/base/icons/other/ReplayLine' +import ImagePreview from '@/app/components/base/image-uploader/image-preview' + +type FileInAttachmentItemProps = { + file: FileEntity + showDeleteAction?: boolean + showDownloadAction?: boolean + onRemove?: (fileId: string) => void + onReUpload?: (fileId: string) => void + canPreview?: boolean +} +const FileInAttachmentItem = ({ + file, + showDeleteAction, + showDownloadAction = true, + onRemove, + onReUpload, + canPreview, +}: FileInAttachmentItemProps) => { + const { id, name, type, progress, supportFileType, base64Url, url, isRemote } = file + const ext = getFileExtension(name, type, isRemote) + const isImageFile = supportFileType === SupportUploadFileTypes.image + const [imagePreviewUrl, setImagePreviewUrl] = useState('') + return ( + <> +
+
+ { + isImageFile && ( + + ) + } + { + !isImageFile && ( + + ) + } +
+
+
+
{name}
+
+
+ { + ext && ( + {ext.toLowerCase()} + ) + } + { + ext && ( + + ) + } + { + !!file.size && ( + {formatFileSize(file.size)} + ) + } +
+
+
+ { + progress >= 0 && !fileIsUploaded(file) && ( + + ) + } + { + progress === -1 && ( + onReUpload?.(id)} + > + + + ) + } + { + showDeleteAction && ( + onRemove?.(id)}> + + + ) + } + { + canPreview && isImageFile && ( + setImagePreviewUrl(url || '')}> + + + ) + } + { + showDownloadAction && ( + { + e.stopPropagation() + downloadFile(url || base64Url || '', name) + }}> + + + ) + } +
+
+ { + imagePreviewUrl && canPreview && ( + setImagePreviewUrl('')} + /> + ) + } + + ) +} + +export default memo(FileInAttachmentItem) diff --git a/app/components/base/file-uploader-in-attachment/file-type-icon.tsx b/app/components/base/file-uploader-in-attachment/file-type-icon.tsx new file mode 100644 index 0000000..08d0131 --- /dev/null +++ b/app/components/base/file-uploader-in-attachment/file-type-icon.tsx @@ -0,0 +1,91 @@ +import { memo } from 'react' +import { + RiFile3Fill, + RiFileCodeFill, + RiFileExcelFill, + RiFileGifFill, + RiFileImageFill, + RiFileMusicFill, + RiFilePdf2Fill, + RiFilePpt2Fill, + RiFileTextFill, + RiFileVideoFill, + RiFileWordFill, + RiMarkdownFill, +} from '@remixicon/react' +import { FileAppearanceTypeEnum } from './types' +import type { FileAppearanceType } from './types' +import cn from '@/utils/classnames' + +const FILE_TYPE_ICON_MAP = { + [FileAppearanceTypeEnum.pdf]: { + component: RiFilePdf2Fill, + color: 'text-[#EA3434]', + }, + [FileAppearanceTypeEnum.image]: { + component: RiFileImageFill, + color: 'text-[#00B2EA]', + }, + [FileAppearanceTypeEnum.video]: { + component: RiFileVideoFill, + color: 'text-[#844FDA]', + }, + [FileAppearanceTypeEnum.audio]: { + component: RiFileMusicFill, + color: 'text-[#FF3093]', + }, + [FileAppearanceTypeEnum.document]: { + component: RiFileTextFill, + color: 'text-[#6F8BB5]', + }, + [FileAppearanceTypeEnum.code]: { + component: RiFileCodeFill, + color: 'text-[#BCC0D1]', + }, + [FileAppearanceTypeEnum.markdown]: { + component: RiMarkdownFill, + color: 'text-[#309BEC]', + }, + [FileAppearanceTypeEnum.custom]: { + component: RiFile3Fill, + color: 'text-[#BCC0D1]', + }, + [FileAppearanceTypeEnum.excel]: { + component: RiFileExcelFill, + color: 'text-[#01AC49]', + }, + [FileAppearanceTypeEnum.word]: { + component: RiFileWordFill, + color: 'text-[#2684FF]', + }, + [FileAppearanceTypeEnum.ppt]: { + component: RiFilePpt2Fill, + color: 'text-[#FF650F]', + }, + [FileAppearanceTypeEnum.gif]: { + component: RiFileGifFill, + color: 'text-[#00B2EA]', + }, +} +type FileTypeIconProps = { + type: FileAppearanceType + size?: 'sm' | 'lg' | 'md' + className?: string +} +const SizeMap = { + sm: 'w-4 h-4', + md: 'w-5 h-5', + lg: 'w-6 h-6', +} +const FileTypeIcon = ({ + type, + size = 'sm', + className, +}: FileTypeIconProps) => { + const Icon = FILE_TYPE_ICON_MAP[type]?.component || FILE_TYPE_ICON_MAP[FileAppearanceTypeEnum.document].component + const color = FILE_TYPE_ICON_MAP[type]?.color || FILE_TYPE_ICON_MAP[FileAppearanceTypeEnum.document].color + + return +} + +export default memo(FileTypeIcon) diff --git a/app/components/base/file-uploader-in-attachment/hooks.ts b/app/components/base/file-uploader-in-attachment/hooks.ts new file mode 100644 index 0000000..bd49b52 --- /dev/null +++ b/app/components/base/file-uploader-in-attachment/hooks.ts @@ -0,0 +1,368 @@ +import type { ClipboardEvent } from 'react' +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' +import { noop } from 'lodash-es' +import type { FileEntity, FileUpload, FileUploadConfigResponse } from './types' +import { useFileStore } from './store' +import { + fileUpload, + getSupportFileType, + isAllowedFileExtension, +} from './utils' +import { + AUDIO_SIZE_LIMIT, + FILE_SIZE_LIMIT, + IMG_SIZE_LIMIT, + MAX_FILE_UPLOAD_LIMIT, + VIDEO_SIZE_LIMIT, +} from './constants' +import { SupportUploadFileTypes } from './types' +import { useToastContext } from '@/app/components/base/toast' +import { TransferMethod } from '@/types/app' +import { formatFileSize } from '@/utils/format' + +const uploadRemoteFileInfo = () => { + console.log('TODO') +} + +export const useFileSizeLimit = (fileUploadConfig?: FileUploadConfigResponse) => { + const imgSizeLimit = Number(fileUploadConfig?.image_file_size_limit) * 1024 * 1024 || IMG_SIZE_LIMIT + const docSizeLimit = Number(fileUploadConfig?.file_size_limit) * 1024 * 1024 || FILE_SIZE_LIMIT + const audioSizeLimit = Number(fileUploadConfig?.audio_file_size_limit) * 1024 * 1024 || AUDIO_SIZE_LIMIT + const videoSizeLimit = Number(fileUploadConfig?.video_file_size_limit) * 1024 * 1024 || VIDEO_SIZE_LIMIT + const maxFileUploadLimit = Number(fileUploadConfig?.workflow_file_upload_limit) || MAX_FILE_UPLOAD_LIMIT + + return { + imgSizeLimit, + docSizeLimit, + audioSizeLimit, + videoSizeLimit, + maxFileUploadLimit, + } +} + +export const useFile = (fileConfig: FileUpload) => { + const { t } = useTranslation() + const { notify } = useToastContext() + const fileStore = useFileStore() + const params = useParams() + const { imgSizeLimit, docSizeLimit, audioSizeLimit, videoSizeLimit } = useFileSizeLimit(fileConfig.fileUploadConfig) + + const checkSizeLimit = useCallback((fileType: string, fileSize: number) => { + switch (fileType) { + case SupportUploadFileTypes.image: { + if (fileSize > imgSizeLimit) { + notify({ + type: 'error', + message: t('common.fileUploader.uploadFromComputerLimit', { + type: SupportUploadFileTypes.image, + size: formatFileSize(imgSizeLimit), + }), + }) + return false + } + return true + } + case SupportUploadFileTypes.document: { + if (fileSize > docSizeLimit) { + notify({ + type: 'error', + message: t('common.fileUploader.uploadFromComputerLimit', { + type: SupportUploadFileTypes.document, + size: formatFileSize(docSizeLimit), + }), + }) + return false + } + return true + } + case SupportUploadFileTypes.audio: { + if (fileSize > audioSizeLimit) { + notify({ + type: 'error', + message: t('common.fileUploader.uploadFromComputerLimit', { + type: SupportUploadFileTypes.audio, + size: formatFileSize(audioSizeLimit), + }), + }) + return false + } + return true + } + case SupportUploadFileTypes.video: { + if (fileSize > videoSizeLimit) { + notify({ + type: 'error', + message: t('common.fileUploader.uploadFromComputerLimit', { + type: SupportUploadFileTypes.video, + size: formatFileSize(videoSizeLimit), + }), + }) + return false + } + return true + } + case SupportUploadFileTypes.custom: { + if (fileSize > docSizeLimit) { + notify({ + type: 'error', + message: t('common.fileUploader.uploadFromComputerLimit', { + type: SupportUploadFileTypes.document, + size: formatFileSize(docSizeLimit), + }), + }) + return false + } + return true + } + default: { + return true + } + } + }, [audioSizeLimit, docSizeLimit, imgSizeLimit, notify, t, videoSizeLimit]) + + const handleAddFile = useCallback((newFile: FileEntity) => { + const { + files, + setFiles, + } = fileStore.getState() + + const newFiles = produce(files, (draft) => { + draft.push(newFile) + }) + setFiles(newFiles) + }, [fileStore]) + + const handleUpdateFile = useCallback((newFile: FileEntity) => { + const { + files, + setFiles, + } = fileStore.getState() + + const newFiles = produce(files, (draft) => { + const index = draft.findIndex(file => file.id === newFile.id) + + if (index > -1) + draft[index] = newFile + }) + setFiles(newFiles) + }, [fileStore]) + + const handleRemoveFile = useCallback((fileId: string) => { + const { + files, + setFiles, + } = fileStore.getState() + + const newFiles = files.filter(file => file.id !== fileId) + setFiles(newFiles) + }, [fileStore]) + + const handleReUploadFile = useCallback((fileId: string) => { + const { + files, + setFiles, + } = fileStore.getState() + const index = files.findIndex(file => file.id === fileId) + + if (index > -1) { + const uploadingFile = files[index] + const newFiles = produce(files, (draft) => { + draft[index].progress = 0 + }) + setFiles(newFiles) + fileUpload({ + file: uploadingFile.originalFile!, + onProgressCallback: (progress) => { + handleUpdateFile({ ...uploadingFile, progress }) + }, + onSuccessCallback: (res) => { + handleUpdateFile({ ...uploadingFile, uploadedId: res.id, progress: 100 }) + }, + onErrorCallback: () => { + notify({ type: 'error', message: t('common.fileUploader.uploadFromComputerUploadError') }) + handleUpdateFile({ ...uploadingFile, progress: -1 }) + }, + }) + } + }, [fileStore, notify, t, handleUpdateFile]) + + const startProgressTimer = useCallback((fileId: string) => { + const timer = setInterval(() => { + const files = fileStore.getState().files + const file = files.find(file => file.id === fileId) + + if (file && file.progress < 80 && file.progress >= 0) + handleUpdateFile({ ...file, progress: file.progress + 20 }) + else + clearTimeout(timer) + }, 200) + }, [fileStore, handleUpdateFile]) + const handleLoadFileFromLink = useCallback((url: string) => { + const allowedFileTypes = fileConfig.allowed_file_types + + const uploadingFile = { + id: uuid4(), + name: url, + type: '', + size: 0, + progress: 0, + transferMethod: TransferMethod.remote_url, + supportFileType: '', + url, + isRemote: true, + } + handleAddFile(uploadingFile) + startProgressTimer(uploadingFile.id) + + uploadRemoteFileInfo(url, !!params.token).then((res) => { + const newFile = { + ...uploadingFile, + type: res.mime_type, + size: res.size, + progress: 100, + supportFileType: getSupportFileType(res.name, res.mime_type, allowedFileTypes?.includes(SupportUploadFileTypes.custom)), + uploadedId: res.id, + url: res.url, + } + if (!isAllowedFileExtension(res.name, res.mime_type, fileConfig.allowed_file_types || [], fileConfig.allowed_file_extensions || [])) { + notify({ type: 'error', message: t('common.fileUploader.fileExtensionNotSupport') }) + handleRemoveFile(uploadingFile.id) + } + if (!checkSizeLimit(newFile.supportFileType, newFile.size)) + handleRemoveFile(uploadingFile.id) + else + handleUpdateFile(newFile) + }).catch(() => { + notify({ type: 'error', message: t('common.fileUploader.pasteFileLinkInvalid') }) + handleRemoveFile(uploadingFile.id) + }) + }, [checkSizeLimit, handleAddFile, handleUpdateFile, notify, t, handleRemoveFile, fileConfig?.allowed_file_types, fileConfig.allowed_file_extensions, startProgressTimer, params.token]) + + const handleLoadFileFromLinkSuccess = useCallback(noop, []) + + const handleLoadFileFromLinkError = useCallback(noop, []) + + const handleClearFiles = useCallback(() => { + const { + setFiles, + } = fileStore.getState() + setFiles([]) + }, [fileStore]) + + const handleLocalFileUpload = useCallback((file: File) => { + if (!isAllowedFileExtension(file.name, file.type, fileConfig.allowed_file_types || [], fileConfig.allowed_file_extensions || [])) { + notify({ type: 'error', message: t('common.fileUploader.fileExtensionNotSupport') }) + return + } + const allowedFileTypes = fileConfig.allowed_file_types + const fileType = getSupportFileType(file.name, file.type, allowedFileTypes?.includes(SupportUploadFileTypes.custom)) + if (!checkSizeLimit(fileType, file.size)) + return + + const reader = new FileReader() + const isImage = file.type.startsWith('image') + + reader.addEventListener( + 'load', + () => { + const uploadingFile = { + id: uuid4(), + name: file.name, + type: file.type, + size: file.size, + progress: 0, + transferMethod: TransferMethod.local_file, + supportFileType: getSupportFileType(file.name, file.type, allowedFileTypes?.includes(SupportUploadFileTypes.custom)), + originalFile: file, + base64Url: isImage ? reader.result as string : '', + } + handleAddFile(uploadingFile) + fileUpload({ + file: uploadingFile.originalFile, + onProgressCallback: (progress) => { + handleUpdateFile({ ...uploadingFile, progress }) + }, + onSuccessCallback: (res) => { + handleUpdateFile({ ...uploadingFile, uploadedId: res.id, progress: 100 }) + }, + onErrorCallback: () => { + notify({ type: 'error', message: t('common.fileUploader.uploadFromComputerUploadError') }) + handleUpdateFile({ ...uploadingFile, progress: -1 }) + }, + }) + }, + false, + ) + reader.addEventListener( + 'error', + () => { + notify({ type: 'error', message: t('common.fileUploader.uploadFromComputerReadError') }) + }, + false, + ) + reader.readAsDataURL(file) + }, [checkSizeLimit, notify, t, handleAddFile, handleUpdateFile, params.token, fileConfig?.allowed_file_types, fileConfig?.allowed_file_extensions]) + + const handleClipboardPasteFile = useCallback((e: ClipboardEvent) => { + const file = e.clipboardData?.files[0] + const text = e.clipboardData?.getData('text/plain') + if (file && !text) { + e.preventDefault() + handleLocalFileUpload(file) + } + }, [handleLocalFileUpload]) + + const [isDragActive, setIsDragActive] = useState(false) + const handleDragFileEnter = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragActive(true) + }, []) + + const handleDragFileOver = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + }, []) + + const handleDragFileLeave = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragActive(false) + }, []) + + const handleDropFile = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragActive(false) + + const file = e.dataTransfer.files[0] + + if (file) + handleLocalFileUpload(file) + }, [handleLocalFileUpload]) + + return { + handleAddFile, + handleUpdateFile, + handleRemoveFile, + handleReUploadFile, + handleLoadFileFromLink, + handleLoadFileFromLinkSuccess, + handleLoadFileFromLinkError, + handleClearFiles, + handleLocalFileUpload, + handleClipboardPasteFile, + isDragActive, + handleDragFileEnter, + handleDragFileOver, + handleDragFileLeave, + handleDropFile, + } +} diff --git a/app/components/base/file-uploader-in-attachment/index.tsx b/app/components/base/file-uploader-in-attachment/index.tsx new file mode 100644 index 0000000..697f65f --- /dev/null +++ b/app/components/base/file-uploader-in-attachment/index.tsx @@ -0,0 +1,132 @@ +import { + useCallback, +} from 'react' +import { + RiLink, + RiUploadCloud2Line, +} from '@remixicon/react' +import { useTranslation } from 'react-i18next' +import { useFile } from './hooks' +import type { FileEntity, FileUpload } from './types' +import FileFromLinkOrLocal from './file-from-link-or-local' +import { + FileContextProvider, + useStore, +} from './store' +import FileInput from './file-input' +import FileItem from './file-item' +import Button from '@/app/components/base/button' +import cn from '@/utils/classnames' +import { TransferMethod } from '@/types/app' + +type Option = { + value: string + label: string + icon: JSX.Element +} +type FileUploaderInAttachmentProps = { + fileConfig: FileUpload +} +const FileUploaderInAttachment = ({ + fileConfig, +}: FileUploaderInAttachmentProps) => { + const { t } = useTranslation() + const files = useStore(s => s.files) + const { + handleRemoveFile, + handleReUploadFile, + } = useFile(fileConfig) + const options = [ + { + value: TransferMethod.local_file, + label: t('common.fileUploader.uploadFromComputer'), + icon: , + }, + { + value: TransferMethod.remote_url, + label: t('common.fileUploader.pasteFileLink'), + icon: , + }, + ] + + const renderButton = useCallback((option: Option, open?: boolean) => { + return ( + + ) + }, [fileConfig, files.length]) + const renderTrigger = useCallback((option: Option) => { + return (open: boolean) => renderButton(option, open) + }, [renderButton]) + const renderOption = useCallback((option: Option) => { + if (option.value === TransferMethod.local_file && fileConfig?.allowed_file_upload_methods?.includes(TransferMethod.local_file)) + return renderButton(option) + + if (option.value === TransferMethod.remote_url && fileConfig?.allowed_file_upload_methods?.includes(TransferMethod.remote_url)) { + return ( + + ) + } + }, [renderButton, renderTrigger, fileConfig]) + + return ( +
+
+ {options.map(renderOption)} +
+
+ { + files.map(file => ( + handleRemoveFile(file.id)} + onReUpload={() => handleReUploadFile(file.id)} + /> + )) + } +
+
+ ) +} + +type FileUploaderInAttachmentWrapperProps = { + value?: FileEntity[] + onChange: (files: FileEntity[]) => void + fileConfig: FileUpload +} +const FileUploaderInAttachmentWrapper = ({ + value, + onChange, + fileConfig, +}: FileUploaderInAttachmentWrapperProps) => { + return ( + + + + ) +} + +export default FileUploaderInAttachmentWrapper diff --git a/app/components/base/file-uploader-in-attachment/store.tsx b/app/components/base/file-uploader-in-attachment/store.tsx new file mode 100644 index 0000000..cddfdf6 --- /dev/null +++ b/app/components/base/file-uploader-in-attachment/store.tsx @@ -0,0 +1,67 @@ +import { + createContext, + useContext, + useRef, +} from 'react' +import { + create, + useStore as useZustandStore, +} from 'zustand' +import type { + FileEntity, +} from './types' + +type Shape = { + files: FileEntity[] + setFiles: (files: FileEntity[]) => void +} + +export const createFileStore = ( + value: FileEntity[] = [], + onChange?: (files: FileEntity[]) => void, +) => { + return create(set => ({ + files: value ? [...value] : [], + setFiles: (files) => { + set({ files }) + onChange?.(files) + }, + })) +} + +type FileStore = ReturnType +export const FileContext = createContext(null) + +export function useStore(selector: (state: Shape) => T): T { + const store = useContext(FileContext) + if (!store) + throw new Error('Missing FileContext.Provider in the tree') + + return useZustandStore(store, selector) +} + +export const useFileStore = () => { + return useContext(FileContext)! +} + +type FileProviderProps = { + children: React.ReactNode + value?: FileEntity[] + onChange?: (files: FileEntity[]) => void +} +export const FileContextProvider = ({ + children, + value, + onChange, +}: FileProviderProps) => { + const storeRef = useRef(undefined) + + if (!storeRef.current) + storeRef.current = createFileStore(value, onChange) + + return ( + + {children} + + ) +} diff --git a/app/components/base/file-uploader-in-attachment/types.ts b/app/components/base/file-uploader-in-attachment/types.ts new file mode 100644 index 0000000..384355e --- /dev/null +++ b/app/components/base/file-uploader-in-attachment/types.ts @@ -0,0 +1,83 @@ +import type { TransferMethod } from '@/types/app' + +export enum FileAppearanceTypeEnum { + image = 'image', + video = 'video', + audio = 'audio', + document = 'document', + code = 'code', + pdf = 'pdf', + markdown = 'markdown', + excel = 'excel', + word = 'word', + ppt = 'ppt', + gif = 'gif', + custom = 'custom', +} + +export type FileAppearanceType = keyof typeof FileAppearanceTypeEnum + +export type FileEntity = { + id: string + name: string + size: number + type: string + progress: number + transferMethod: TransferMethod + supportFileType: string + originalFile?: File + uploadedId?: string + base64Url?: string + url?: string + isRemote?: boolean +} + +export type EnabledOrDisabled = { + enabled?: boolean +} + +export enum Resolution { + low = 'low', + high = 'high', +} + +export type FileUploadConfigResponse = { + batch_count_limit: number + image_file_size_limit?: number | string // default is 10MB + file_size_limit: number // default is 15MB + audio_file_size_limit?: number // default is 50MB + video_file_size_limit?: number // default is 100MB + workflow_file_upload_limit?: number // default is 10 +} + +export type FileUpload = { + image?: EnabledOrDisabled & { + detail?: Resolution + number_limits?: number + transfer_methods?: TransferMethod[] + } + allowed_file_types?: string[] + allowed_file_extensions?: string[] + allowed_file_upload_methods?: TransferMethod[] + number_limits?: number + fileUploadConfig?: FileUploadConfigResponse +} & EnabledOrDisabled + +export enum SupportUploadFileTypes { + image = 'image', + document = 'document', + audio = 'audio', + video = 'video', + custom = 'custom', +} + +export type FileResponse = { + related_id: string + extension: string + filename: string + size: number + mime_type: string + transfer_method: TransferMethod + type: string + url: string +} diff --git a/app/components/base/file-uploader-in-attachment/utils.ts b/app/components/base/file-uploader-in-attachment/utils.ts new file mode 100644 index 0000000..b63532e --- /dev/null +++ b/app/components/base/file-uploader-in-attachment/utils.ts @@ -0,0 +1,194 @@ +import mime from 'mime' +import { FileAppearanceTypeEnum, SupportUploadFileTypes } from './types' +import type { FileEntity, FileResponse } from './types' +import { FILE_EXTS } from './constants' +import { upload } from '@/service/base' +import { TransferMethod } from '@/types/app' + +type FileUploadParams = { + file: File + onProgressCallback: (progress: number) => void + onSuccessCallback: (res: { id: string }) => void + onErrorCallback: () => void +} +type FileUpload = (v: FileUploadParams, isPublic?: boolean, url?: string) => void +export const fileUpload: FileUpload = ({ + file, + onProgressCallback, + onSuccessCallback, + onErrorCallback, +}) => { + const formData = new FormData() + formData.append('file', file) + const onProgress = (e: ProgressEvent) => { + if (e.lengthComputable) { + const percent = Math.floor(e.loaded / e.total * 100) + onProgressCallback(percent) + } + } + + upload({ + xhr: new XMLHttpRequest(), + data: formData, + onprogress: onProgress, + }) + .then((res: { id: string }) => { + onSuccessCallback(res) + }) + .catch(() => { + onErrorCallback() + }) +} + +export const getFileExtension = (fileName: string, fileMimetype: string, isRemote?: boolean) => { + let extension = '' + if (fileMimetype) + extension = mime.getExtension(fileMimetype) || '' + + if (fileName && !extension) { + const fileNamePair = fileName.split('.') + const fileNamePairLength = fileNamePair.length + + if (fileNamePairLength > 1) + extension = fileNamePair[fileNamePairLength - 1] + else + extension = '' + } + + if (isRemote) + extension = '' + + return extension +} + +export const getFileAppearanceType = (fileName: string, fileMimetype: string) => { + const extension = getFileExtension(fileName, fileMimetype) + + if (extension === 'gif') + return FileAppearanceTypeEnum.gif + + if (FILE_EXTS.image.includes(extension.toUpperCase())) + return FileAppearanceTypeEnum.image + + if (FILE_EXTS.video.includes(extension.toUpperCase())) + return FileAppearanceTypeEnum.video + + if (FILE_EXTS.audio.includes(extension.toUpperCase())) + return FileAppearanceTypeEnum.audio + + if (extension === 'html') + return FileAppearanceTypeEnum.code + + if (extension === 'pdf') + return FileAppearanceTypeEnum.pdf + + if (extension === 'md' || extension === 'markdown' || extension === 'mdx') + return FileAppearanceTypeEnum.markdown + + if (extension === 'xlsx' || extension === 'xls') + return FileAppearanceTypeEnum.excel + + if (extension === 'docx' || extension === 'doc') + return FileAppearanceTypeEnum.word + + if (extension === 'pptx' || extension === 'ppt') + return FileAppearanceTypeEnum.ppt + + if (FILE_EXTS.document.includes(extension.toUpperCase())) + return FileAppearanceTypeEnum.document + + return FileAppearanceTypeEnum.custom +} + +export const getSupportFileType = (fileName: string, fileMimetype: string, isCustom?: boolean) => { + if (isCustom) + return SupportUploadFileTypes.custom + + const extension = getFileExtension(fileName, fileMimetype) + for (const key in FILE_EXTS) { + if ((FILE_EXTS[key]).includes(extension.toUpperCase())) + return key + } + + return '' +} + +export const getProcessedFiles = (files: FileEntity[]) => { + return files.filter(file => file.progress !== -1).map(fileItem => ({ + type: fileItem.supportFileType, + transfer_method: fileItem.transferMethod, + url: fileItem.url || '', + upload_file_id: fileItem.uploadedId || '', + })) +} + +export const getProcessedFilesFromResponse = (files: FileResponse[]) => { + return files.map((fileItem) => { + return { + id: fileItem.related_id, + name: fileItem.filename, + size: fileItem.size || 0, + type: fileItem.mime_type, + progress: 100, + transferMethod: fileItem.transfer_method, + supportFileType: fileItem.type, + uploadedId: fileItem.related_id, + url: fileItem.url, + } + }) +} + +export const getFileNameFromUrl = (url: string) => { + const urlParts = url.split('/') + return urlParts[urlParts.length - 1] || '' +} + +export const getSupportFileExtensionList = (allowFileTypes: string[], allowFileExtensions: string[]) => { + if (allowFileTypes.includes(SupportUploadFileTypes.custom)) + return allowFileExtensions.map(item => item.slice(1).toUpperCase()) + + return allowFileTypes.map(type => FILE_EXTS[type]).flat() +} + +export const isAllowedFileExtension = (fileName: string, fileMimetype: string, allowFileTypes: string[], allowFileExtensions: string[]) => { + return getSupportFileExtensionList(allowFileTypes, allowFileExtensions).includes(getFileExtension(fileName, fileMimetype).toUpperCase()) +} + +export const getFilesInLogs = (rawData: any) => { + const result = Object.keys(rawData || {}).map((key) => { + if (typeof rawData[key] === 'object' && rawData[key]?.dify_model_identity === '__dify__file__') { + return { + varName: key, + list: getProcessedFilesFromResponse([rawData[key]]), + } + } + if (Array.isArray(rawData[key]) && rawData[key].some(item => item?.dify_model_identity === '__dify__file__')) { + return { + varName: key, + list: getProcessedFilesFromResponse(rawData[key]), + } + } + return undefined + }).filter(Boolean) + return result +} + +export const fileIsUploaded = (file: FileEntity) => { + if (file.uploadedId) + return true + + if (file.transferMethod === TransferMethod.remote_url && file.progress === 100) + return true +} + +export const downloadFile = (url: string, filename: string) => { + const anchor = document.createElement('a') + anchor.href = url + anchor.download = filename + anchor.style.display = 'none' + anchor.target = '_blank' + anchor.title = filename + document.body.appendChild(anchor) + anchor.click() + document.body.removeChild(anchor) +} diff --git a/app/components/base/icons/other/ReplayLine.json b/app/components/base/icons/other/ReplayLine.json new file mode 100644 index 0000000..0fffbc9 --- /dev/null +++ b/app/components/base/icons/other/ReplayLine.json @@ -0,0 +1,36 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "width": "20", + "height": "20", + "viewBox": "0 0 20 20", + "fill": "none", + "xmlns": "http://www.w3.org/2000/svg" + }, + "children": [ + { + "type": "element", + "name": "g", + "attributes": { + "id": "Retry" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "id": "Vector", + "d": "M9.99996 1.66669C14.6023 1.66669 18.3333 5.39765 18.3333 10C18.3333 14.6024 14.6023 18.3334 9.99996 18.3334C5.39758 18.3334 1.66663 14.6024 1.66663 10H3.33329C3.33329 13.6819 6.31806 16.6667 9.99996 16.6667C13.6819 16.6667 16.6666 13.6819 16.6666 10C16.6666 6.31812 13.6819 3.33335 9.99996 3.33335C7.70848 3.33335 5.68702 4.48947 4.48705 6.25022L6.66663 6.25002V7.91669H1.66663V2.91669H3.33329L3.3332 4.99934C4.85358 2.97565 7.2739 1.66669 9.99996 1.66669Z", + "fill": "currentColor" + }, + "children": [] + } + ] + } + ] + }, + "name": "ReplayLine" +} \ No newline at end of file diff --git a/app/components/base/icons/other/ReplayLine.tsx b/app/components/base/icons/other/ReplayLine.tsx new file mode 100644 index 0000000..29f7137 --- /dev/null +++ b/app/components/base/icons/other/ReplayLine.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './ReplayLine.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'ReplayLine' + +export default Icon diff --git a/app/components/base/progress-bar/index.tsx b/app/components/base/progress-bar/index.tsx new file mode 100644 index 0000000..759c9ea --- /dev/null +++ b/app/components/base/progress-bar/index.tsx @@ -0,0 +1,20 @@ +type ProgressBarProps = { + percent: number +} +const ProgressBar = ({ + percent = 0, +}: ProgressBarProps) => { + return ( +
+
+
+
+
{percent}%
+
+ ) +} + +export default ProgressBar diff --git a/app/components/base/progress-bar/progress-circle.tsx b/app/components/base/progress-bar/progress-circle.tsx new file mode 100644 index 0000000..b9b280e --- /dev/null +++ b/app/components/base/progress-bar/progress-circle.tsx @@ -0,0 +1,64 @@ +import { memo } from 'react' +import cn from '@/utils/classnames' + +type ProgressCircleProps = { + className?: string + percentage?: number + size?: number + circleStrokeWidth?: number + circleStrokeColor?: string + circleFillColor?: string + sectorFillColor?: string +} + +const ProgressCircle: React.FC = ({ + className, + percentage = 0, + size = 12, + circleStrokeWidth = 1, + circleStrokeColor = 'stroke-components-progress-brand-border', + circleFillColor = 'fill-components-progress-brand-bg', + sectorFillColor = 'fill-components-progress-brand-progress', +}) => { + const radius = size / 2 + const center = size / 2 + const angle = (percentage / 101) * 360 + const radians = (angle * Math.PI) / 180 + const x = center + radius * Math.cos(radians - Math.PI / 2) + const y = center + radius * Math.sin(radians - Math.PI / 2) + const largeArcFlag = percentage > 50 ? 1 : 0 + + const pathData = ` + M ${center},${center} + L ${center},${center - radius} + A ${radius},${radius} 0 ${largeArcFlag} 1 ${x},${y} + Z + ` + + return ( + + + + + ) +} + +export default memo(ProgressCircle) diff --git a/app/components/welcome/index.tsx b/app/components/welcome/index.tsx index 4edc25b..b01e131 100644 --- a/app/components/welcome/index.tsx +++ b/app/components/welcome/index.tsx @@ -3,6 +3,7 @@ import type { FC } from 'react' import React, { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import TemplateVarPanel, { PanelTitle, VarOpBtnGroup } from '../value-panel' +import FileUploaderInAttachmentWrapper from '../base/file-uploader-in-attachment' import s from './style.module.css' import { AppInfoComp, ChatBtn, EditBtn, FootLogo, PromptTemplate } from './massive-component' import type { AppInfo, PromptConfig } from '@/types/app' @@ -131,6 +132,25 @@ const Welcome: FC = ({ onChange={(e) => { onInputsChange({ ...inputs, [item.key]: e.target.value }) }} /> )} + + { + item.type === 'file' && ( + { + setInputs({ ...inputs, [item.key]: files }) + }} + value={inputs?.[item.key] || []} + /> + ) + }
))}
diff --git a/package.json b/package.json index d0059b6..e7e20b5 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@mdx-js/loader": "^2.3.0", "@mdx-js/react": "^2.3.0", "@monaco-editor/react": "^4.6.0", + "@remixicon/react": "^4.6.0", "@tailwindcss/line-clamp": "^0.4.2", "@types/node": "18.15.0", "@types/react": "18.0.28", @@ -26,6 +27,7 @@ "@types/react-syntax-highlighter": "^15.5.6", "ahooks": "^3.7.5", "axios": "^1.3.5", + "class-variance-authority": "^0.7.1", "classnames": "^2.3.2", "copy-to-clipboard": "^3.3.3", "dify-client": "^2.3.1", @@ -38,6 +40,8 @@ "immer": "^9.0.19", "js-cookie": "^3.0.1", "katex": "^0.16.7", + "lodash-es": "^4.17.21", + "mime": "^4.0.7", "negotiator": "^0.6.3", "next": "^14.0.4", "rc-textarea": "^1.5.3", @@ -57,15 +61,18 @@ "scheduler": "^0.23.0", "server-only": "^0.0.1", "swr": "^2.1.0", + "tailwind-merge": "^3.2.0", "typescript": "4.9.5", "use-context-selector": "^1.4.1", - "uuid": "^9.0.0" + "uuid": "^9.0.0", + "zustand": "^4.5.2" }, "devDependencies": { "@antfu/eslint-config": "0.36.0", "@faker-js/faker": "^7.6.0", "@tailwindcss/typography": "^0.5.9", "@types/js-cookie": "^3.0.3", + "@types/lodash-es": "^4.17.12", "@types/negotiator": "^0.6.1", "autoprefixer": "^10.4.14", "eslint-plugin-react-hooks": "^4.6.0", @@ -81,4 +88,4 @@ "eslint --fix" ] } -} \ No newline at end of file +} diff --git a/utils/classnames.ts b/utils/classnames.ts new file mode 100644 index 0000000..6ce2284 --- /dev/null +++ b/utils/classnames.ts @@ -0,0 +1,8 @@ +import { twMerge } from 'tailwind-merge' +import cn from 'classnames' + +const classNames = (...cls: cn.ArgumentArray) => { + return twMerge(cn(cls)) +} + +export default classNames diff --git a/utils/format.ts b/utils/format.ts new file mode 100644 index 0000000..720c8f6 --- /dev/null +++ b/utils/format.ts @@ -0,0 +1,58 @@ +/** + * Formats a number with comma separators. + * @example formatNumber(1234567) will return '1,234,567' + * @example formatNumber(1234567.89) will return '1,234,567.89' + */ +export const formatNumber = (num: number | string) => { + if (!num) + return num + const parts = num.toString().split('.') + parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',') + return parts.join('.') +} + +/** + * Format file size into standard string format. + * @param fileSize file size (Byte) + * @example formatFileSize(1024) will return '1.00KB' + * @example formatFileSize(1024 * 1024) will return '1.00MB' + */ +export const formatFileSize = (fileSize: number) => { + if (!fileSize) + return fileSize + const units = ['', 'K', 'M', 'G', 'T', 'P'] + let index = 0 + while (fileSize >= 1024 && index < units.length) { + fileSize = fileSize / 1024 + index++ + } + return `${fileSize.toFixed(2)}${units[index]}B` +} + +/** + * Format time into standard string format. + * @example formatTime(60) will return '1.00 min' + * @example formatTime(60 * 60) will return '1.00 h' + */ +export const formatTime = (seconds: number) => { + if (!seconds) + return seconds + const units = ['sec', 'min', 'h'] + let index = 0 + while (seconds >= 60 && index < units.length) { + seconds = seconds / 60 + index++ + } + return `${seconds.toFixed(2)} ${units[index]}` +} + +export const downloadFile = ({ data, fileName }: { data: Blob; fileName: string }) => { + const url = window.URL.createObjectURL(data) + const a = document.createElement('a') + a.href = url + a.download = fileName + document.body.appendChild(a) + a.click() + a.remove() + window.URL.revokeObjectURL(url) +}