From 9a7e1be35d1de68d9fdf2dec5307be721a112bff Mon Sep 17 00:00:00 2001 From: Joel Date: Mon, 14 Apr 2025 16:17:52 +0800 Subject: [PATCH 1/6] fix: file type not support --- app/components/index.tsx | 1 - utils/prompt.ts | 15 +++------------ 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/app/components/index.tsx b/app/components/index.tsx index e2622c3..4d7b28e 100644 --- a/app/components/index.tsx +++ b/app/components/index.tsx @@ -225,7 +225,6 @@ const Main: FC = () => { (async () => { try { const [conversationData, appParams] = await Promise.all([fetchConversations(), fetchAppParams()]) - // handle current conversation id const { data: conversations, error } = conversationData as { data: ConversationItem[]; error: string } if (error) { diff --git a/utils/prompt.ts b/utils/prompt.ts index fc11182..a911d22 100644 --- a/utils/prompt.ts +++ b/utils/prompt.ts @@ -16,21 +16,12 @@ export const userInputsFormToPromptVariables = (useInputs: UserInputFormItem[] | return [] const promptVariables: PromptVariable[] = [] useInputs.forEach((item: any) => { - const isParagraph = !!item.paragraph const [type, content] = (() => { - if (isParagraph) - return ['paragraph', item.paragraph] - - if (item['text-input']) - return ['string', item['text-input']] - - if (item.number) - return ['number', item.number] - - return ['select', item.select] + const type = Object.keys(item)[0] + return [type, item[type]] })() - if (type === 'string' || type === 'paragraph') { + if (type === 'string' || type === 'paragraph' || type === 'file' || type === 'file-list') { promptVariables.push({ key: content.variable, name: content.label, From e9923e8220c72919d2bca7d346e6d497f29e1f22 Mon Sep 17 00:00:00 2001 From: Joel Date: Mon, 14 Apr 2025 16:33:06 +0800 Subject: [PATCH 2/6] fix: string type --- utils/prompt.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utils/prompt.ts b/utils/prompt.ts index a911d22..e099422 100644 --- a/utils/prompt.ts +++ b/utils/prompt.ts @@ -18,7 +18,7 @@ export const userInputsFormToPromptVariables = (useInputs: UserInputFormItem[] | useInputs.forEach((item: any) => { const [type, content] = (() => { const type = Object.keys(item)[0] - return [type, item[type]] + return [type === 'text-input' ? 'string' : type, item[type]] })() if (type === 'string' || type === 'paragraph' || type === 'file' || type === 'file-list') { From 7216f40beed9ee569eb1c1521112d39f0fb8732b Mon Sep 17 00:00:00 2001 From: Joel Date: Mon, 14 Apr 2025 18:38:27 +0800 Subject: [PATCH 3/6] feat: add single file upload --- .vscode/settings.json | 4 +- app/components/base/action-button/index.css | 45 +++ app/components/base/action-button/index.tsx | 73 ++++ .../file-uploader-in-attachment/constants.ts | 16 + .../file-from-link-or-local/index.tsx | 130 +++++++ .../file-image-render.tsx | 32 ++ .../file-input.tsx | 49 +++ .../file-uploader-in-attachment/file-item.tsx | 154 ++++++++ .../file-type-icon.tsx | 91 +++++ .../base/file-uploader-in-attachment/hooks.ts | 368 ++++++++++++++++++ .../file-uploader-in-attachment/index.tsx | 132 +++++++ .../file-uploader-in-attachment/store.tsx | 67 ++++ .../base/file-uploader-in-attachment/types.ts | 83 ++++ .../base/file-uploader-in-attachment/utils.ts | 194 +++++++++ .../base/icons/other/ReplayLine.json | 36 ++ .../base/icons/other/ReplayLine.tsx | 20 + app/components/base/progress-bar/index.tsx | 20 + .../base/progress-bar/progress-circle.tsx | 64 +++ app/components/welcome/index.tsx | 20 + package.json | 11 +- utils/classnames.ts | 8 + utils/format.ts | 58 +++ 22 files changed, 1671 insertions(+), 4 deletions(-) create mode 100644 app/components/base/action-button/index.css create mode 100644 app/components/base/action-button/index.tsx create mode 100644 app/components/base/file-uploader-in-attachment/constants.ts create mode 100644 app/components/base/file-uploader-in-attachment/file-from-link-or-local/index.tsx create mode 100644 app/components/base/file-uploader-in-attachment/file-image-render.tsx create mode 100644 app/components/base/file-uploader-in-attachment/file-input.tsx create mode 100644 app/components/base/file-uploader-in-attachment/file-item.tsx create mode 100644 app/components/base/file-uploader-in-attachment/file-type-icon.tsx create mode 100644 app/components/base/file-uploader-in-attachment/hooks.ts create mode 100644 app/components/base/file-uploader-in-attachment/index.tsx create mode 100644 app/components/base/file-uploader-in-attachment/store.tsx create mode 100644 app/components/base/file-uploader-in-attachment/types.ts create mode 100644 app/components/base/file-uploader-in-attachment/utils.ts create mode 100644 app/components/base/icons/other/ReplayLine.json create mode 100644 app/components/base/icons/other/ReplayLine.tsx create mode 100644 app/components/base/progress-bar/index.tsx create mode 100644 app/components/base/progress-bar/progress-circle.tsx create mode 100644 utils/classnames.ts create mode 100644 utils/format.ts 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) +} From 4ae03c2101f09ee39b95ad669a091b8fa9476d7b Mon Sep 17 00:00:00 2001 From: Joel Date: Tue, 15 Apr 2025 17:42:55 +0800 Subject: [PATCH 4/6] chore: file upload i18n --- i18n/lang/common.en.ts | 10 ++++++++++ i18n/lang/common.es.ts | 10 ++++++++++ i18n/lang/common.ja.ts | 10 ++++++++++ i18n/lang/common.vi.ts | 12 +++++++++++- i18n/lang/common.zh.ts | 10 ++++++++++ 5 files changed, 51 insertions(+), 1 deletion(-) diff --git a/i18n/lang/common.en.ts b/i18n/lang/common.en.ts index 934bd87..4856297 100644 --- a/i18n/lang/common.en.ts +++ b/i18n/lang/common.en.ts @@ -28,6 +28,16 @@ const translation = { pasteImageLinkInvalid: 'Invalid image link', imageUpload: 'Image Upload', }, + fileUploader: { + uploadFromComputer: 'Local upload', + pasteFileLink: 'Paste file link', + pasteFileLinkInputPlaceholder: 'Enter URL...', + uploadFromComputerReadError: 'File reading failed, please try again.', + uploadFromComputerUploadError: 'File upload failed, please upload again.', + uploadFromComputerLimit: 'Upload {{type}} cannot exceed {{size}}', + pasteFileLinkInvalid: 'Invalid file link', + fileExtensionNotSupport: 'File extension not supported', + }, } export default translation diff --git a/i18n/lang/common.es.ts b/i18n/lang/common.es.ts index 8b5472d..893d21e 100644 --- a/i18n/lang/common.es.ts +++ b/i18n/lang/common.es.ts @@ -28,6 +28,16 @@ const translation = { pasteImageLinkInvalid: 'Enlace de imagen no válido', imageUpload: 'Subir imagen', }, + fileUploader: { + uploadFromComputer: 'Carga local', + pasteFileLink: 'Pegar enlace de archivo', + uploadFromComputerReadError: 'Error en la lectura del archivo, inténtelo de nuevo.', + uploadFromComputerUploadError: 'Error en la carga del archivo, vuelva a cargarlo.', + pasteFileLinkInvalid: 'Enlace de archivo no válido', + fileExtensionNotSupport: 'Extensión de archivo no compatible', + pasteFileLinkInputPlaceholder: 'Introduzca la URL...', + uploadFromComputerLimit: 'El archivo de carga no puede exceder {{size}}', + }, } export default translation diff --git a/i18n/lang/common.ja.ts b/i18n/lang/common.ja.ts index 1fc9540..87532c2 100644 --- a/i18n/lang/common.ja.ts +++ b/i18n/lang/common.ja.ts @@ -28,6 +28,16 @@ const translation = { pasteImageLinkInvalid: '無効な画像リンクです', imageUpload: '画像アップロード', }, + fileUploader: { + uploadFromComputer: 'ローカルアップロード', + pasteFileLink: 'ファイルリンクの貼り付け', + pasteFileLinkInputPlaceholder: 'URLを入力...', + uploadFromComputerLimit: 'アップロードファイルは{{size}}を超えてはなりません', + uploadFromComputerUploadError: 'ファイルのアップロードに失敗しました。再度アップロードしてください。', + uploadFromComputerReadError: 'ファイルの読み取りに失敗しました。もう一度やり直してください。', + fileExtensionNotSupport: 'ファイル拡張子はサポートされていません', + pasteFileLinkInvalid: '無効なファイルリンク', + }, } export default translation diff --git a/i18n/lang/common.vi.ts b/i18n/lang/common.vi.ts index 83c5f54..2d62fe5 100644 --- a/i18n/lang/common.vi.ts +++ b/i18n/lang/common.vi.ts @@ -28,6 +28,16 @@ const translation = { pasteImageLinkInvalid: 'Liên kết ảnh không hợp lệ', imageUpload: 'Tải ảnh lên', }, + fileUploader: { + uploadFromComputer: 'Tải lên cục bộ', + pasteFileLink: 'Dán liên kết tệp', + pasteFileLinkInputPlaceholder: 'Nhập URL...', + uploadFromComputerLimit: 'Tải lên tệp không được vượt quá {{size}}', + fileExtensionNotSupport: 'Phần mở rộng tệp không được hỗ trợ', + pasteFileLinkInvalid: 'Liên kết tệp không hợp lệ', + uploadFromComputerUploadError: 'Tải lên tệp không thành công, vui lòng tải lên lại.', + uploadFromComputerReadError: 'Đọc tệp không thành công, vui lòng thử lại.', + }, } -export default translation; +export default translation diff --git a/i18n/lang/common.zh.ts b/i18n/lang/common.zh.ts index d3b50fd..bad2255 100644 --- a/i18n/lang/common.zh.ts +++ b/i18n/lang/common.zh.ts @@ -28,6 +28,16 @@ const translation = { pasteImageLinkInvalid: '图片链接无效', imageUpload: '图片上传', }, + fileUploader: { + uploadFromComputer: '从本地上传', + pasteFileLink: '粘贴文件链接', + pasteFileLinkInputPlaceholder: '输入文件链接', + uploadFromComputerReadError: '文件读取失败,请重新选择。', + uploadFromComputerUploadError: '文件上传失败,请重新上传。', + uploadFromComputerLimit: '上传 {{type}} 不能超过 {{size}}', + pasteFileLinkInvalid: '文件链接无效', + fileExtensionNotSupport: '文件类型不支持', + }, } export default translation From beda954867bbda890d9a70badaba343a70f5a1e7 Mon Sep 17 00:00:00 2001 From: Joel Date: Tue, 15 Apr 2025 18:18:46 +0800 Subject: [PATCH 5/6] chore: can use setting config --- app/components/welcome/index.tsx | 25 +++++++++++++++++++++---- types/app.ts | 3 +++ utils/prompt.ts | 13 ++++++++++++- 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/app/components/welcome/index.tsx b/app/components/welcome/index.tsx index b01e131..9b18105 100644 --- a/app/components/welcome/index.tsx +++ b/app/components/welcome/index.tsx @@ -37,6 +37,7 @@ const Welcome: FC = ({ savedInputs, onInputsChange, }) => { + console.log(promptConfig) const { t } = useTranslation() const hasVar = promptConfig.prompt_variables.length > 0 const [isFold, setIsFold] = useState(true) @@ -136,14 +137,30 @@ const Welcome: FC = ({ { item.type === 'file' && ( { + setInputs({ ...inputs, [item.key]: files[0] }) + }} + value={inputs?.[item.key] || []} + /> + ) + } + { + item.type === 'file-list' && ( + { setInputs({ ...inputs, [item.key]: files }) }} diff --git a/types/app.ts b/types/app.ts index 3d62c4f..06b287f 100644 --- a/types/app.ts +++ b/types/app.ts @@ -10,6 +10,9 @@ export type PromptVariable = { options?: string[] max_length?: number required: boolean + allowed_file_extensions?: string[] + allowed_file_types?: string[] + allowed_file_upload_methods?: TransferMethod[] } export type PromptConfig = { diff --git a/utils/prompt.ts b/utils/prompt.ts index e099422..be0a754 100644 --- a/utils/prompt.ts +++ b/utils/prompt.ts @@ -21,7 +21,7 @@ export const userInputsFormToPromptVariables = (useInputs: UserInputFormItem[] | return [type === 'text-input' ? 'string' : type, item[type]] })() - if (type === 'string' || type === 'paragraph' || type === 'file' || type === 'file-list') { + if (type === 'string' || type === 'paragraph') { promptVariables.push({ key: content.variable, name: content.label, @@ -40,6 +40,17 @@ export const userInputsFormToPromptVariables = (useInputs: UserInputFormItem[] | options: [], }) } + else if (type === 'file' || type === 'file-list') { + promptVariables.push({ + ...content, + key: content.variable, + name: content.label, + required: content.required, + type, + max_length: content.max_length, + options: [], + }) + } else { promptVariables.push({ key: content.variable, From d3482db74de0be7f00bdeb320dc5003e3f3633fe Mon Sep 17 00:00:00 2001 From: Joel Date: Tue, 15 Apr 2025 18:41:13 +0800 Subject: [PATCH 6/6] chore: can set the write input --- app/components/index.tsx | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/app/components/index.tsx b/app/components/index.tsx index 4d7b28e..df40f9a 100644 --- a/app/components/index.tsx +++ b/app/components/index.tsx @@ -325,13 +325,37 @@ const Main: FC = () => { setChatList(newListWithAnswer) } + const transformToServerFile = (fileItem: any) => { + return { + type: 'image', + transfer_method: fileItem.transferMethod, + url: fileItem.url, + upload_file_id: fileItem.id, + } + } + const handleSend = async (message: string, files?: VisionFile[]) => { if (isResponding) { notify({ type: 'info', message: t('app.errorMessage.waitForResponse') }) return } + const toServerInputs: Record = {} + if (currInputs) { + Object.keys(currInputs).forEach((key) => { + const value = currInputs[key] + if (value.supportFileType) + toServerInputs[key] = transformToServerFile(value) + + else if (value[0]?.supportFileType) + toServerInputs[key] = value.map((item: any) => transformToServerFile(item)) + + else + toServerInputs[key] = value + }) + } + const data: Record = { - inputs: currInputs, + inputs: toServerInputs, query: message, conversation_id: isNewConversation ? null : currConversationId, }