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/button/index.tsx b/app/components/base/button/index.tsx index 33aebb6..518a849 100644 --- a/app/components/base/button/index.tsx +++ b/app/components/base/button/index.tsx @@ -21,6 +21,9 @@ const Button: FC = ({ }) => { let style = 'cursor-pointer' switch (type) { + case 'link': + style = disabled ? 'border-solid border border-gray-200 bg-gray-200 cursor-not-allowed text-gray-800' : 'border-solid border border-gray-200 cursor-pointer text-blue-600 bg-white hover:shadow-sm hover:border-gray-300' + break case 'primary': style = (disabled || loading) ? 'bg-primary-600/75 cursor-not-allowed text-white' : 'bg-primary-600 hover:bg-primary-600/75 hover:shadow-md cursor-pointer text-white hover:shadow-sm' break 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/chat/answer/index.tsx b/app/components/chat/answer/index.tsx index 8505e65..2bce5b9 100644 --- a/app/components/chat/answer/index.tsx +++ b/app/components/chat/answer/index.tsx @@ -13,6 +13,7 @@ import type { ChatItem, MessageRating, VisionFile } from '@/types/app' import Tooltip from '@/app/components/base/tooltip' import WorkflowProcess from '@/app/components/workflow/workflow-process' import { Markdown } from '@/app/components/base/markdown' +import Button from '@/app/components/base/button' import type { Emoji } from '@/types/tools' const OperationBtn = ({ innerContent, onClick, className }: { innerContent: React.ReactNode; onClick?: () => void; className?: string }) => ( @@ -60,6 +61,7 @@ type IAnswerProps = { onFeedback?: FeedbackFunc isResponding?: boolean allToolIcons?: Record + suggestionClick?: (suggestion: string) => void } // The component needs to maintain its own state to control whether to display input component @@ -69,8 +71,9 @@ const Answer: FC = ({ onFeedback, isResponding, allToolIcons, + suggestionClick = () => { }, }) => { - const { id, content, feedback, agent_thoughts, workflowProcess } = item + const { id, content, feedback, agent_thoughts, workflowProcess, suggestedQuestions = [] } = item const isAgentMode = !!agent_thoughts && agent_thoughts.length > 0 const { t } = useTranslation() @@ -192,6 +195,17 @@ const Answer: FC = ({ : ( ))} + {suggestedQuestions.length > 0 && ( +
+
+ {suggestedQuestions.map((suggestion, index) => ( +
+ +
+ ))} +
+
+ )}
{!feedbackDisabled && !item.feedbackDisabled && renderItemOperation()} diff --git a/app/components/chat/index.tsx b/app/components/chat/index.tsx index e257a6c..0f95615 100644 --- a/app/components/chat/index.tsx +++ b/app/components/chat/index.tsx @@ -52,9 +52,12 @@ const Chat: FC = ({ const isUseInputMethod = useRef(false) const [query, setQuery] = React.useState('') + const queryRef = useRef('') + const handleContentChange = (e: any) => { const value = e.target.value setQuery(value) + queryRef.current = value } const logError = (message: string) => { @@ -62,16 +65,19 @@ const Chat: FC = ({ } const valid = () => { + const query = queryRef.current if (!query || query.trim() === '') { - logError('Message cannot be empty') + logError(t('app.errorMessage.valueOfVarRequired')) return false } return true } useEffect(() => { - if (controlClearQuery) + if (controlClearQuery) { setQuery('') + queryRef.current = '' + } }, [controlClearQuery]) const { files, @@ -86,7 +92,7 @@ const Chat: FC = ({ const handleSend = () => { if (!valid() || (checkCanSend && !checkCanSend())) return - onSend(query, files.filter(file => file.progress !== -1).map(fileItem => ({ + onSend(queryRef.current, files.filter(file => file.progress !== -1).map(fileItem => ({ type: 'image', transfer_method: fileItem.type, url: fileItem.url, @@ -95,8 +101,10 @@ const Chat: FC = ({ if (!files.find(item => item.type === TransferMethod.local_file && !item.fileId)) { if (files.length) onClear() - if (!isResponding) + if (!isResponding) { setQuery('') + queryRef.current = '' + } } } @@ -112,11 +120,19 @@ const Chat: FC = ({ const handleKeyDown = (e: any) => { isUseInputMethod.current = e.nativeEvent.isComposing if (e.code === 'Enter' && !e.shiftKey) { - setQuery(query.replace(/\n$/, '')) + const result = query.replace(/\n$/, '') + setQuery(result) + queryRef.current = result e.preventDefault() } } + const suggestionClick = (suggestion: string) => { + setQuery(suggestion) + queryRef.current = suggestion + handleSend() + } + return (
{/* Chat List */} @@ -130,6 +146,7 @@ const Chat: FC = ({ feedbackDisabled={feedbackDisabled} onFeedback={onFeedback} isResponding={isResponding && isLast} + suggestionClick={suggestionClick} /> } return ( diff --git a/app/components/index.tsx b/app/components/index.tsx index 4d7b28e..889fd3b 100644 --- a/app/components/index.tsx +++ b/app/components/index.tsx @@ -51,7 +51,7 @@ const Main: FC = () => { useEffect(() => { if (APP_INFO?.title) - document.title = `${APP_INFO.title} - Powered by Dify` + document.title = `${APP_INFO.title}` }, [APP_INFO?.title]) // onData change thought (the produce obj). https://github.com/immerjs/immer/issues/576 @@ -101,6 +101,7 @@ const Main: FC = () => { const conversationName = currConversationInfo?.name || t('app.chat.newChatDefaultName') as string const conversationIntroduction = currConversationInfo?.introduction || '' + const suggestedQuestions = currConversationInfo?.suggested_questions || [] const handleConversationSwitch = () => { if (!inited) @@ -117,6 +118,7 @@ const Main: FC = () => { setExistConversationInfo({ name: item?.name || '', introduction: notSyncToStateIntroduction, + suggested_questions: suggestedQuestions, }) } else { @@ -192,6 +194,7 @@ const Main: FC = () => { name: t('app.chat.newChatDefaultName'), inputs: newConversationInputs, introduction: conversationIntroduction, + suggested_questions: suggestedQuestions, }) })) } @@ -209,6 +212,7 @@ const Main: FC = () => { isAnswer: true, feedbackDisabled: true, isOpeningStatement: isShowPrompt, + suggestedQuestions: suggestedQuestions, } if (calculatedIntroduction) return [openStatement] @@ -233,15 +237,24 @@ const Main: FC = () => { return } const _conversationId = getConversationIdFromStorage(APP_ID) - const isNotNewConversation = conversations.some(item => item.id === _conversationId) + const currentConversation = conversations.find(item => item.id === _conversationId) + const isNotNewConversation = !!currentConversation // fetch new conversation info - const { user_input_form, opening_statement: introduction, file_upload, system_parameters }: any = appParams + const { user_input_form, opening_statement: introduction, file_upload, system_parameters, suggested_questions = [] }: any = appParams setLocaleOnClient(APP_INFO.default_language, true) setNewConversationInfo({ name: t('app.chat.newChatDefaultName'), introduction, + suggested_questions }) + if (isNotNewConversation) { + setExistConversationInfo({ + name: currentConversation.name || t('app.chat.newChatDefaultName'), + introduction, + suggested_questions + }) + } const prompt_variables = userInputsFormToPromptVariables(user_input_form) setPromptConfig({ prompt_template: promptTemplate, @@ -325,13 +338,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, } diff --git a/app/components/welcome/index.tsx b/app/components/welcome/index.tsx index 4edc25b..9365e24 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' @@ -36,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) @@ -131,6 +133,41 @@ const Welcome: FC = ({ onChange={(e) => { onInputsChange({ ...inputs, [item.key]: e.target.value }) }} /> )} + + { + item.type === 'file' && ( + { + setInputs({ ...inputs, [item.key]: files[0] }) + }} + value={inputs?.[item.key] || []} + /> + ) + } + { + item.type === 'file-list' && ( + { + setInputs({ ...inputs, [item.key]: files }) + }} + value={inputs?.[item.key] || []} + /> + ) + }
))}
@@ -140,7 +177,10 @@ const Welcome: FC = ({ const canChat = () => { const inputLens = Object.values(inputs).length const promptVariablesLens = promptConfig.prompt_variables.length - const emptyInput = inputLens < promptVariablesLens || Object.values(inputs).filter(v => v === '').length > 0 + const emptyInput = inputLens < promptVariablesLens || Object.entries(inputs).filter(([k, v]) => { + const isRequired = promptConfig.prompt_variables.find(item => item.key === k)?.required ?? true + return isRequired && v === '' + }).length > 0 if (emptyInput) { logError(t('app.errorMessage.valueOfVarRequired')) return false @@ -340,10 +380,7 @@ const Welcome: FC = ({
:
} - - {t('app.chat.powerBy')} - - +
)} 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 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/types/app.ts b/types/app.ts index 3d62c4f..58a1641 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 = { @@ -99,7 +102,8 @@ export type ConversationItem = { id: string name: string inputs: Record | null - introduction: string + introduction: string, + suggested_questions?: string[] } export type AppInfo = { 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) +} 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,