Feat/attachments (#9526)

Co-authored-by: Joel <iamjoel007@gmail.com>
Co-authored-by: JzoNg <jzongcode@gmail.com>
This commit is contained in:
zxhlyh
2024-10-21 10:32:37 +08:00
committed by GitHub
parent 4fd2743efa
commit 7a1d6fe509
445 changed files with 11759 additions and 6922 deletions

View File

@@ -0,0 +1,3 @@
export const FILE_SIZE_LIMIT = 15 * 1024 * 1024
export const FILE_URL_REGEX = /^(https?|ftp):\/\//

View File

@@ -0,0 +1,129 @@
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 {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import Button from '@/app/components/base/button'
import type { FileUpload } from '@/app/components/base/features/types'
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 (
<PortalToFollowElem
placement='top'
offset={4}
open={open}
onOpenChange={setOpen}
>
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)} asChild>
{trigger(open)}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-10'>
<div className='p-3 w-[280px] bg-components-panel-bg-blur border-[0.5px] border-components-panel-border rounded-xl shadow-lg'>
{
showFromLink && (
<>
<div className={cn(
'flex items-center p-1 h-8 bg-components-input-bg-active border border-components-input-border-active rounded-lg shadow-xs',
showError && 'border-components-input-border-destructive',
)}>
<input
className='grow block mr-0.5 px-1 bg-transparent system-sm-regular outline-none appearance-none'
placeholder={t('common.fileUploader.pasteFileLinkInputPlaceholder') || ''}
value={url}
onChange={(e) => {
setShowError(false)
setUrl(e.target.value)
}}
disabled={disabled}
/>
<Button
className='shrink-0'
size='small'
variant='primary'
disabled={!url || disabled}
onClick={handleSaveUrl}
>
{t('common.operation.ok')}
</Button>
</div>
{
showError && (
<div className='mt-0.5 body-xs-regular text-text-destructive'>
{t('common.fileUploader.pasteFileLinkInvalid')}
</div>
)
}
</>
)
}
{
showFromLink && showFromLocal && (
<div className='flex items-center p-2 h-7 system-2xs-medium-uppercase text-text-quaternary'>
<div className='mr-2 w-[93px] h-[1px] bg-gradient-to-l from-[rgba(16,24,40,0.08)]' />
OR
<div className='ml-2 w-[93px] h-[1px] bg-gradient-to-r from-[rgba(16,24,40,0.08)]' />
</div>
)
}
{
showFromLocal && (
<Button
className='relative w-full'
variant='secondary-accent'
disabled={disabled}
>
<RiUploadCloud2Line className='mr-1 w-4 h-4' />
{t('common.fileUploader.uploadFromComputer')}
<FileInput fileConfig={fileConfig} />
</Button>
)
}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default memo(FileFromLinkOrLocal)

View File

@@ -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 (
<div className={cn('border-[2px] border-effects-image-frame shadow-xs', className)}>
<img
className={cn('w-full h-full object-cover', showDownloadAction && 'cursor-pointer')}
alt={alt}
onLoad={onLoad}
onError={onError}
src={imageUrl}
/>
</div>
)
}
export default FileImageRender

View File

@@ -0,0 +1,39 @@
import { useFile } from './hooks'
import { useStore } from './store'
import type { FileUpload } from '@/app/components/base/features/types'
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
type FileInputProps = {
fileConfig: FileUpload
}
const FileInput = ({
fileConfig,
}: FileInputProps) => {
const files = useStore(s => s.files)
const { handleLocalFileUpload } = useFile(fileConfig)
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file)
handleLocalFileUpload(file)
}
const allowedFileTypes = fileConfig.allowed_file_types
const isCustom = allowedFileTypes?.includes(SupportUploadFileTypes.custom)
const exts = isCustom ? (fileConfig.allowed_file_extensions?.map(item => `.${item}`) || []) : (allowedFileTypes?.map(type => FILE_EXTS[type]) || []).flat().map(item => `.${item}`)
const accept = exts.join(',')
return (
<input
className='absolute block inset-0 opacity-0 text-[0] w-full disabled:cursor-not-allowed cursor-pointer'
onClick={e => ((e.target as HTMLInputElement).value = '')}
type='file'
onChange={handleChange}
accept={accept}
disabled={!!(fileConfig.number_limits && files.length >= fileConfig?.number_limits)}
/>
)
}
export default FileInput

View File

@@ -0,0 +1,86 @@
import React, { useState } from 'react'
import { RiArrowRightSLine } from '@remixicon/react'
import FileImageRender from './file-image-render'
import FileTypeIcon from './file-type-icon'
import FileItem from './file-uploader-in-attachment/file-item'
import type { FileEntity } from './types'
import {
getFileAppearanceType,
} from './utils'
import Tooltip from '@/app/components/base/tooltip'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import cn from '@/utils/classnames'
type Props = {
fileList: FileEntity[]
}
const FileListInLog = ({ fileList }: Props) => {
const [expanded, setExpanded] = useState(false)
if (!fileList.length)
return null
return (
<div className={cn('border-t border-divider-subtle px-3 py-2', expanded && 'py-3')}>
<div className='flex justify-between gap-1'>
{expanded && (
<div></div>
)}
{!expanded && (
<div className='flex'>
{fileList.map((file) => {
const { id, name, type, supportFileType, base64Url, url } = file
const isImageFile = supportFileType === SupportUploadFileTypes.image
return (
<>
{isImageFile && (
<Tooltip
popupContent={name}
>
<div key={id}>
<FileImageRender
className='w-8 h-8'
imageUrl={base64Url || url || ''}
/>
</div>
</Tooltip>
)}
{!isImageFile && (
<Tooltip
popupContent={name}
>
<div key={id} className='p-1.5 rounded-md bg-components-panel-on-panel-item-bg border-[0.5px] border-components-panel-border shadow-xs'>
<FileTypeIcon
type={getFileAppearanceType(name, type)}
size='md'
/>
</div>
</Tooltip>
)}
</>
)
})}
</div>
)}
<div className='flex items-center gap-1 cursor-pointer' onClick={() => setExpanded(!expanded)}>
{!expanded && <div className='text-text-tertiary system-xs-medium-uppercase'>DETAIL</div>}
<RiArrowRightSLine className={cn('w-4 h-4 text-text-tertiary', expanded && 'rotate-90')} />
</div>
</div>
{expanded && (
<div className='flex flex-col gap-1'>
{fileList.map(file => (
<FileItem
key={file.id}
file={file}
showDeleteAction={false}
showDownloadAction
/>
))}
</div>
)}
</div>
)
}
export default FileListInLog

View File

@@ -0,0 +1,94 @@
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
const color = FILE_TYPE_ICON_MAP[type].color
if (!Icon)
return null
return <Icon className={cn('shrink-0', SizeMap[size], color, className)} />
}
export default memo(FileTypeIcon)

View File

@@ -0,0 +1,139 @@
import {
memo,
useMemo,
} from 'react'
import {
RiDeleteBinLine,
RiDownloadLine,
} from '@remixicon/react'
import FileTypeIcon from '../file-type-icon'
import {
fileIsUploaded,
getFileAppearanceType,
getFileExtension,
} from '../utils'
import FileImageRender from '../file-image-render'
import type { FileEntity } 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/src/vender/other'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
type FileInAttachmentItemProps = {
file: FileEntity
showDeleteAction?: boolean
showDownloadAction?: boolean
onRemove?: (fileId: string) => void
onReUpload?: (fileId: string) => void
}
const FileInAttachmentItem = ({
file,
showDeleteAction,
showDownloadAction = true,
onRemove,
onReUpload,
}: FileInAttachmentItemProps) => {
const { id, name, type, progress, supportFileType, base64Url, url } = file
const ext = getFileExtension(name, type)
const isImageFile = supportFileType === SupportUploadFileTypes.image
const nameArr = useMemo(() => {
const nameMatch = name.match(/(.+)\.([^.]+)$/)
if (nameMatch)
return [nameMatch[1], nameMatch[2]]
return [name, '']
}, [name])
return (
<div className={cn(
'flex items-center pr-3 h-12 rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg shadow-xs',
progress === -1 && 'bg-state-destructive-hover border-state-destructive-border',
)}>
<div className='flex items-center justify-center w-12 h-12'>
{
isImageFile && (
<FileImageRender
className='w-8 h-8'
imageUrl={base64Url || url || ''}
/>
)
}
{
!isImageFile && (
<FileTypeIcon
type={getFileAppearanceType(name, type)}
size='lg'
/>
)
}
</div>
<div className='grow w-0 mr-1'>
<div
className='flex items-center mb-0.5 system-xs-medium text-text-secondary truncate'
title={file.name}
>
<div className='truncate'>{nameArr[0]}</div>
{
nameArr[1] && (
<span>.{nameArr[1]}</span>
)
}
</div>
<div className='flex items-center system-2xs-medium-uppercase text-text-tertiary'>
{
ext && (
<span>{ext.toLowerCase()}</span>
)
}
{
ext && (
<span className='mx-1 system-2xs-medium'></span>
)
}
<span>{formatFileSize(file.size || 0)}</span>
</div>
</div>
<div className='shrink-0 flex items-center'>
{
progress >= 0 && !fileIsUploaded(file) && (
<ProgressCircle
className='mr-2.5'
percentage={progress}
/>
)
}
{
progress === -1 && (
<ActionButton
className='mr-1'
onClick={() => onReUpload?.(id)}
>
<ReplayLine className='w-4 h-4 text-text-tertiary' />
</ActionButton>
)
}
{
showDeleteAction && (
<ActionButton onClick={() => onRemove?.(id)}>
<RiDeleteBinLine className='w-4 h-4' />
</ActionButton>
)
}
{
showDownloadAction && (
<ActionButton
size='xs'
>
<RiDownloadLine className='w-3.5 h-3.5 text-text-tertiary' />
</ActionButton>
)
}
</div>
</div>
)
}
export default memo(FileInAttachmentItem)

View File

@@ -0,0 +1,133 @@
import {
useCallback,
} from 'react'
import {
RiLink,
RiUploadCloud2Line,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import FileFromLinkOrLocal from '../file-from-link-or-local'
import {
FileContextProvider,
useStore,
} from '../store'
import type { FileEntity } from '../types'
import FileInput from '../file-input'
import { useFile } from '../hooks'
import FileItem from './file-item'
import Button from '@/app/components/base/button'
import cn from '@/utils/classnames'
import type { FileUpload } from '@/app/components/base/features/types'
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: <RiUploadCloud2Line className='w-4 h-4' />,
},
{
value: TransferMethod.remote_url,
label: t('common.fileUploader.pasteFileLink'),
icon: <RiLink className='w-4 h-4' />,
},
]
const renderButton = useCallback((option: Option, open?: boolean) => {
return (
<Button
key={option.value}
variant='tertiary'
className={cn('grow relative', open && 'bg-components-button-tertiary-bg-hover')}
disabled={!!(fileConfig.number_limits && files.length >= fileConfig.number_limits)}
>
{option.icon}
<span className='ml-1'>{option.label}</span>
{
option.value === TransferMethod.local_file && (
<FileInput fileConfig={fileConfig} />
)
}
</Button>
)
}, [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 (
<FileFromLinkOrLocal
key={option.value}
showFromLocal={false}
trigger={renderTrigger(option)}
fileConfig={fileConfig}
/>
)
}
}, [renderButton, renderTrigger, fileConfig])
return (
<div>
<div className='flex items-center space-x-1'>
{options.map(renderOption)}
</div>
<div className='mt-1 space-y-1'>
{
files.map(file => (
<FileItem
key={file.id}
file={file}
showDeleteAction
showDownloadAction={false}
onRemove={() => handleRemoveFile(file.id)}
onReUpload={() => handleReUploadFile(file.id)}
/>
))
}
</div>
</div>
)
}
type FileUploaderInAttachmentWrapperProps = {
value?: FileEntity[]
onChange: (files: FileEntity[]) => void
fileConfig: FileUpload
}
const FileUploaderInAttachmentWrapper = ({
value,
onChange,
fileConfig,
}: FileUploaderInAttachmentWrapperProps) => {
return (
<FileContextProvider
value={value}
onChange={onChange}
>
<FileUploaderInAttachment fileConfig={fileConfig} />
</FileContextProvider>
)
}
export default FileUploaderInAttachmentWrapper

View File

@@ -0,0 +1,109 @@
import { useState } from 'react'
import {
RiCloseLine,
RiDownloadLine,
} from '@remixicon/react'
import FileImageRender from '../file-image-render'
import type { FileEntity } from '../types'
import {
downloadFile,
fileIsUploaded,
} from '../utils'
import Button from '@/app/components/base/button'
import ProgressCircle from '@/app/components/base/progress-bar/progress-circle'
import { ReplayLine } from '@/app/components/base/icons/src/vender/other'
import ImagePreview from '@/app/components/base/image-uploader/image-preview'
type FileImageItemProps = {
file: FileEntity
showDeleteAction?: boolean
showDownloadAction?: boolean
canPreview?: boolean
onRemove?: (fileId: string) => void
onReUpload?: (fileId: string) => void
}
const FileImageItem = ({
file,
showDeleteAction,
showDownloadAction,
canPreview,
onRemove,
onReUpload,
}: FileImageItemProps) => {
const { id, progress, base64Url, url, name } = file
const [imagePreviewUrl, setImagePreviewUrl] = useState('')
return (
<>
<div
className='group/file-image relative cursor-pointer'
onClick={() => canPreview && setImagePreviewUrl(url || '')}
>
{
showDeleteAction && (
<Button
className='hidden group-hover/file-image:flex absolute -right-1.5 -top-1.5 p-0 w-5 h-5 rounded-full z-[11]'
onClick={() => onRemove?.(id)}
>
<RiCloseLine className='w-4 h-4 text-components-button-secondary-text' />
</Button>
)
}
<FileImageRender
className='w-[68px] h-[68px] shadow-md'
imageUrl={base64Url || url || ''}
showDownloadAction={showDownloadAction}
/>
{
progress >= 0 && !fileIsUploaded(file) && (
<div className='absolute inset-0 flex items-center justify-center border-[2px] border-effects-image-frame bg-background-overlay-alt z-10'>
<ProgressCircle
percentage={progress}
size={12}
circleStrokeColor='stroke-components-progress-white-border'
circleFillColor='fill-transparent'
sectorFillColor='fill-components-progress-white-progress'
/>
</div>
)
}
{
progress === -1 && (
<div className='absolute inset-0 flex items-center justify-center border-[2px] border-state-destructive-border bg-background-overlay-destructive z-10'>
<ReplayLine
className='w-5 h-5'
onClick={() => onReUpload?.(id)}
/>
</div>
)
}
{
showDownloadAction && (
<div className='hidden group-hover/file-image:block absolute inset-0.5 bg-background-overlay-alt bg-opacity-[0.3] z-10'>
<div
className='absolute bottom-0.5 right-0.5 flex items-center justify-center w-6 h-6 rounded-lg bg-components-actionbar-bg shadow-md'
onClick={(e) => {
e.stopPropagation()
downloadFile(url || '', name)
}}
>
<RiDownloadLine className='w-4 h-4 text-text-tertiary' />
</div>
</div>
)
}
</div>
{
imagePreviewUrl && canPreview && (
<ImagePreview
title={name}
url={imagePreviewUrl}
onCancel={() => setImagePreviewUrl('')}
/>
)
}
</>
)
}
export default FileImageItem

View File

@@ -0,0 +1,115 @@
import {
RiCloseLine,
RiDownloadLine,
} from '@remixicon/react'
import {
downloadFile,
fileIsUploaded,
getFileAppearanceType,
getFileExtension,
} from '../utils'
import FileTypeIcon from '../file-type-icon'
import type { FileEntity } from '../types'
import cn from '@/utils/classnames'
import { formatFileSize } from '@/utils/format'
import ProgressCircle from '@/app/components/base/progress-bar/progress-circle'
import { ReplayLine } from '@/app/components/base/icons/src/vender/other'
import ActionButton from '@/app/components/base/action-button'
import Button from '@/app/components/base/button'
type FileItemProps = {
file: FileEntity
showDeleteAction?: boolean
showDownloadAction?: boolean
onRemove?: (fileId: string) => void
onReUpload?: (fileId: string) => void
}
const FileItem = ({
file,
showDeleteAction,
showDownloadAction = true,
onRemove,
onReUpload,
}: FileItemProps) => {
const { id, name, type, progress, url } = file
const ext = getFileExtension(name, type)
const uploadError = progress === -1
return (
<div
className={cn(
'group/file-item relative p-2 w-[144px] h-[68px] rounded-lg border-[0.5px] border-components-panel-border bg-components-card-bg shadow-xs',
!uploadError && 'hover:bg-components-card-bg-alt',
uploadError && 'border border-state-destructive-border bg-state-destructive-hover',
uploadError && 'hover:border-[0.5px] hover:border-state-destructive-border bg-state-destructive-hover-alt',
)}
>
{
showDeleteAction && (
<Button
className='hidden group-hover/file-item:flex absolute -right-1.5 -top-1.5 p-0 w-5 h-5 rounded-full z-[11]'
onClick={() => onRemove?.(id)}
>
<RiCloseLine className='w-4 h-4 text-components-button-secondary-text' />
</Button>
)
}
<div
className='mb-1 h-8 line-clamp-2 system-xs-medium text-text-tertiary break-all'
title={name}
>
{name}
</div>
<div className='relative flex items-center justify-between'>
<div className='flex items-center system-2xs-medium-uppercase text-text-tertiary'>
<FileTypeIcon
size='sm'
type={getFileAppearanceType(name, type)}
className='mr-1'
/>
{
ext && (
<>
{ext}
<div className='mx-1'>·</div>
</>
)
}
{formatFileSize(file.size || 0)}
</div>
{
showDownloadAction && (
<ActionButton
size='m'
className='hidden group-hover/file-item:flex absolute -right-1 -top-1'
onClick={(e) => {
e.stopPropagation()
downloadFile(url || '', name)
}}
>
<RiDownloadLine className='w-3.5 h-3.5 text-text-tertiary' />
</ActionButton>
)
}
{
progress >= 0 && !fileIsUploaded(file) && (
<ProgressCircle
percentage={progress}
size={12}
/>
)
}
{
uploadError && (
<ReplayLine
className='w-4 h-4 text-text-tertiary'
onClick={() => onReUpload?.(id)}
/>
)
}
</div>
</div>
)
}
export default FileItem

View File

@@ -0,0 +1,81 @@
import { useFile } from '../hooks'
import { useStore } from '../store'
import type { FileEntity } from '../types'
import FileImageItem from './file-image-item'
import FileItem from './file-item'
import type { FileUpload } from '@/app/components/base/features/types'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import cn from '@/utils/classnames'
type FileListProps = {
className?: string
files: FileEntity[]
onRemove?: (fileId: string) => void
onReUpload?: (fileId: string) => void
showDeleteAction?: boolean
showDownloadAction?: boolean
canPreview?: boolean
}
export const FileList = ({
className,
files,
onReUpload,
onRemove,
showDeleteAction = true,
showDownloadAction = false,
canPreview,
}: FileListProps) => {
return (
<div className={cn('flex flex-wrap gap-2', className)}>
{
files.map((file) => {
if (file.supportFileType === SupportUploadFileTypes.image) {
return (
<FileImageItem
key={file.id}
file={file}
showDeleteAction={showDeleteAction}
showDownloadAction={showDownloadAction}
onRemove={onRemove}
onReUpload={onReUpload}
canPreview={canPreview}
/>
)
}
return (
<FileItem
key={file.id}
file={file}
showDeleteAction={showDeleteAction}
showDownloadAction={showDownloadAction}
onRemove={onRemove}
onReUpload={onReUpload}
/>
)
})
}
</div>
)
}
type FileListInChatInputProps = {
fileConfig: FileUpload
}
export const FileListInChatInput = ({
fileConfig,
}: FileListInChatInputProps) => {
const files = useStore(s => s.files)
const {
handleRemoveFile,
handleReUploadFile,
} = useFile(fileConfig)
return (
<FileList
files={files}
onReUpload={handleReUploadFile}
onRemove={handleRemoveFile}
/>
)
}

View File

@@ -0,0 +1,41 @@
import {
memo,
useCallback,
} from 'react'
import {
RiAttachmentLine,
} from '@remixicon/react'
import FileFromLinkOrLocal from '../file-from-link-or-local'
import ActionButton from '@/app/components/base/action-button'
import cn from '@/utils/classnames'
import type { FileUpload } from '@/app/components/base/features/types'
import { TransferMethod } from '@/types/app'
type FileUploaderInChatInputProps = {
fileConfig: FileUpload
}
const FileUploaderInChatInput = ({
fileConfig,
}: FileUploaderInChatInputProps) => {
const renderTrigger = useCallback((open: boolean) => {
return (
<ActionButton
size='l'
className={cn(open && 'bg-state-base-hover')}
>
<RiAttachmentLine className='w-5 h-5' />
</ActionButton>
)
}, [])
return (
<FileFromLinkOrLocal
trigger={renderTrigger}
fileConfig={fileConfig}
showFromLocal={fileConfig?.allowed_file_upload_methods?.includes(TransferMethod.local_file)}
showFromLink={fileConfig?.allowed_file_upload_methods?.includes(TransferMethod.remote_url)}
/>
)
}
export default memo(FileUploaderInChatInput)

View File

@@ -0,0 +1,246 @@
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 type { FileEntity } from './types'
import { useFileStore } from './store'
import {
fileUpload,
getSupportFileType,
isAllowedFileExtension,
} from './utils'
import { FILE_SIZE_LIMIT } from './constants'
import { useToastContext } from '@/app/components/base/toast'
import { TransferMethod } from '@/types/app'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import type { FileUpload } from '@/app/components/base/features/types'
import { formatFileSize } from '@/utils/format'
import { fetchRemoteFileInfo } from '@/service/common'
export const useFile = (fileConfig: FileUpload) => {
const { t } = useTranslation()
const { notify } = useToastContext()
const fileStore = useFileStore()
const params = useParams()
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 })
},
}, !!params.token)
}
}, [fileStore, notify, t, handleUpdateFile, params])
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,
}
handleAddFile(uploadingFile)
fetchRemoteFileInfo(url).then((res) => {
const newFile = {
...uploadingFile,
type: res.file_type,
size: res.file_length,
progress: 100,
supportFileType: getSupportFileType(url, res.file_type, allowedFileTypes?.includes(SupportUploadFileTypes.custom)),
}
handleUpdateFile(newFile)
}).catch(() => {
notify({ type: 'error', message: t('common.fileUploader.pasteFileLinkInvalid') })
handleRemoveFile(uploadingFile.id)
})
}, [handleAddFile, handleUpdateFile, notify, t, handleRemoveFile, fileConfig?.allowed_file_types])
const handleLoadFileFromLinkSuccess = useCallback(() => { }, [])
const handleLoadFileFromLinkError = useCallback(() => { }, [])
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
}
if (file.size > FILE_SIZE_LIMIT) {
notify({ type: 'error', message: t('common.fileUploader.uploadFromComputerLimit', { size: formatFileSize(FILE_SIZE_LIMIT) }) })
return
}
const reader = new FileReader()
const isImage = file.type.startsWith('image')
const allowedFileTypes = fileConfig.allowed_file_types
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 })
},
}, !!params.token)
},
false,
)
reader.addEventListener(
'error',
() => {
notify({ type: 'error', message: t('common.fileUploader.uploadFromComputerReadError') })
},
false,
)
reader.readAsDataURL(file)
}, [notify, t, handleAddFile, handleUpdateFile, params.token, fileConfig?.allowed_file_types, fileConfig?.allowed_file_extensions])
const handleClipboardPasteFile = useCallback((e: ClipboardEvent<HTMLTextAreaElement>) => {
const file = e.clipboardData?.files[0]
if (file) {
e.preventDefault()
handleLocalFileUpload(file)
}
}, [handleLocalFileUpload])
const [isDragActive, setIsDragActive] = useState(false)
const handleDragFileEnter = useCallback((e: React.DragEvent<HTMLElement>) => {
e.preventDefault()
e.stopPropagation()
setIsDragActive(true)
}, [])
const handleDragFileOver = useCallback((e: React.DragEvent<HTMLElement>) => {
e.preventDefault()
e.stopPropagation()
}, [])
const handleDragFileLeave = useCallback((e: React.DragEvent<HTMLElement>) => {
e.preventDefault()
e.stopPropagation()
setIsDragActive(false)
}, [])
const handleDropFile = useCallback((e: React.DragEvent<HTMLElement>) => {
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,
}
}

View File

@@ -0,0 +1,7 @@
export { default as FileUploaderInAttachmentWrapper } from './file-uploader-in-attachment'
export { default as FileItemInAttachment } from './file-uploader-in-attachment/file-item'
export { default as FileUploaderInChatInput } from './file-uploader-in-chat-input'
export { default as FileTypeIcon } from './file-type-icon'
export { FileListInChatInput } from './file-uploader-in-chat-input/file-list'
export { FileList } from './file-uploader-in-chat-input/file-list'
export { default as FileItem } from './file-uploader-in-chat-input/file-item'

View File

@@ -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<Shape>(set => ({
files: [...value],
setFiles: (files) => {
set({ files })
onChange?.(files)
},
}))
}
type FileStore = ReturnType<typeof createFileStore>
export const FileContext = createContext<FileStore | null>(null)
export function useStore<T>(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<FileStore>()
if (!storeRef.current)
storeRef.current = createFileStore(value, onChange)
return (
<FileContext.Provider value={storeRef.current}>
{children}
</FileContext.Provider>
)
}

View File

@@ -0,0 +1,32 @@
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
}

View File

@@ -0,0 +1,181 @@
import mime from 'mime'
import { flatten } from 'lodash-es'
import { FileAppearanceTypeEnum } from './types'
import type { FileEntity } from './types'
import { upload } from '@/service/base'
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import type { FileResponse } from '@/types/workflow'
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,
}, isPublic, url) => {
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,
}, isPublic, url)
.then((res: { id: string }) => {
onSuccessCallback(res)
})
.catch(() => {
onErrorCallback()
})
}
export const getFileExtension = (fileName: string, fileMimetype: string) => {
if (fileName) {
const fileNamePair = fileName.split('.')
const fileNamePairLength = fileNamePair.length
if (fileNamePairLength > 1)
return fileNamePair[fileNamePairLength - 1]
}
if (fileMimetype)
return mime.getExtension(fileMimetype) || ''
return ''
}
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')
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.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 originalFiles = flatten(Object.keys(rawData || {}).map((key) => {
if (typeof rawData[key] === 'object' || Array.isArray(rawData[key]))
return rawData[key]
return undefined
}).filter(Boolean)).filter(item => item?.model_identity === '__dify__file__')
return getProcessedFilesFromResponse(originalFiles)
}
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)
}