dify
This commit is contained in:
46
dify/web/app/components/base/file-uploader/audio-preview.tsx
Normal file
46
dify/web/app/components/base/file-uploader/audio-preview.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { FC } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import React from 'react'
|
||||
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
|
||||
type AudioPreviewProps = {
|
||||
url: string
|
||||
title: string
|
||||
onCancel: () => void
|
||||
}
|
||||
const AudioPreview: FC<AudioPreviewProps> = ({
|
||||
url,
|
||||
title,
|
||||
onCancel,
|
||||
}) => {
|
||||
useHotkeys('esc', onCancel)
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className='fixed inset-0 z-[1000] flex items-center justify-center bg-black/80 p-8'
|
||||
onClick={e => e.stopPropagation()}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div>
|
||||
<audio controls title={title} autoPlay={false} preload="metadata">
|
||||
<source
|
||||
type="audio/mpeg"
|
||||
src={url}
|
||||
className='max-h-full max-w-full'
|
||||
/>
|
||||
</audio>
|
||||
</div>
|
||||
<div
|
||||
className='absolute right-6 top-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/[0.08] backdrop-blur-[2px]'
|
||||
onClick={onCancel}
|
||||
>
|
||||
<RiCloseLine className='h-4 w-4 text-gray-500'/>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
export default AudioPreview
|
||||
8
dify/web/app/components/base/file-uploader/constants.ts
Normal file
8
dify/web/app/components/base/file-uploader/constants.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// 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):\/\//
|
||||
@@ -0,0 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
type DynamicPdfPreviewProps = {
|
||||
url: string
|
||||
onCancel: () => void
|
||||
}
|
||||
const DynamicPdfPreview = dynamic<DynamicPdfPreviewProps>(
|
||||
(() => {
|
||||
if (typeof window !== 'undefined')
|
||||
return import('./pdf-preview')
|
||||
}) as any,
|
||||
{ ssr: false }, // This will prevent the module from being loaded on the server-side
|
||||
)
|
||||
|
||||
export default DynamicPdfPreview
|
||||
@@ -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-[1001]'>
|
||||
<div className='w-[280px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-3 shadow-lg'>
|
||||
{
|
||||
showFromLink && (
|
||||
<>
|
||||
<div className={cn(
|
||||
'flex h-8 items-center rounded-lg border border-components-input-border-active bg-components-input-bg-active p-1 shadow-xs',
|
||||
showError && 'border-components-input-border-destructive',
|
||||
)}>
|
||||
<input
|
||||
className='system-sm-regular mr-0.5 block grow appearance-none bg-transparent px-1 outline-none'
|
||||
placeholder={t('common.fileUploader.pasteFileLinkInputPlaceholder') || ''}
|
||||
value={url}
|
||||
onChange={(e) => {
|
||||
setShowError(false)
|
||||
setUrl(e.target.value.trim())
|
||||
}}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Button
|
||||
className='shrink-0'
|
||||
size='small'
|
||||
variant='primary'
|
||||
disabled={!url || disabled}
|
||||
onClick={handleSaveUrl}
|
||||
>
|
||||
{t('common.operation.ok')}
|
||||
</Button>
|
||||
</div>
|
||||
{
|
||||
showError && (
|
||||
<div className='body-xs-regular mt-0.5 text-text-destructive'>
|
||||
{t('common.fileUploader.pasteFileLinkInvalid')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
showFromLink && showFromLocal && (
|
||||
<div className='system-2xs-medium-uppercase flex h-7 items-center p-2 text-text-quaternary'>
|
||||
<div className='mr-2 h-px w-[93px] bg-gradient-to-l from-[rgba(16,24,40,0.08)]' />
|
||||
OR
|
||||
<div className='ml-2 h-px w-[93px] 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 h-4 w-4' />
|
||||
{t('common.fileUploader.uploadFromComputer')}
|
||||
<FileInput fileConfig={fileConfig} />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(FileFromLinkOrLocal)
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import FileImageRender from './file-image-render'
|
||||
|
||||
const SAMPLE_IMAGE = 'data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'320\' height=\'180\'><defs><linearGradient id=\'grad\' x1=\'0%\' y1=\'0%\' x2=\'100%\' y2=\'100%\'><stop offset=\'0%\' stop-color=\'#FEE2FF\'/><stop offset=\'100%\' stop-color=\'#E0EAFF\'/></linearGradient></defs><rect width=\'320\' height=\'180\' rx=\'18\' fill=\'url(#grad)\'/><text x=\'50%\' y=\'50%\' dominant-baseline=\'middle\' text-anchor=\'middle\' font-family=\'sans-serif\' font-size=\'24\' fill=\'#1F2937\'>Preview</text></svg>'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/General/FileImageRender',
|
||||
component: FileImageRender,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Renders image previews inside a bordered frame. Often used in upload galleries and logs.',
|
||||
},
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
<FileImageRender imageUrl="https://example.com/preview.png" className="h-32 w-52" />
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
imageUrl: SAMPLE_IMAGE,
|
||||
className: 'h-32 w-52',
|
||||
},
|
||||
} satisfies Meta<typeof FileImageRender>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Playground: Story = {}
|
||||
@@ -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('h-full w-full object-cover', showDownloadAction && 'cursor-pointer')}
|
||||
alt={alt || 'Preview'}
|
||||
onLoad={onLoad}
|
||||
onError={onError}
|
||||
src={imageUrl}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileImageRender
|
||||
49
dify/web/app/components/base/file-uploader/file-input.tsx
Normal file
49
dify/web/app/components/base/file-uploader/file-input.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
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 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 (
|
||||
<input
|
||||
className='absolute inset-0 block w-full cursor-pointer text-[0] opacity-0 disabled:cursor-not-allowed'
|
||||
onClick={e => ((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
|
||||
106
dify/web/app/components/base/file-uploader/file-list-in-log.tsx
Normal file
106
dify/web/app/components/base/file-uploader/file-list-in-log.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
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: {
|
||||
varName: string
|
||||
list: FileEntity[]
|
||||
}[]
|
||||
isExpanded?: boolean
|
||||
noBorder?: boolean
|
||||
noPadding?: boolean
|
||||
}
|
||||
|
||||
const FileListInLog = ({ fileList, isExpanded = false, noBorder = false, noPadding = false }: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const [expanded, setExpanded] = useState(isExpanded)
|
||||
const fullList = useMemo(() => {
|
||||
return fileList.reduce((acc: FileEntity[], { list }) => {
|
||||
return [...acc, ...list]
|
||||
}, [])
|
||||
}, [fileList])
|
||||
|
||||
if (!fileList.length)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className={cn('px-3 py-2', expanded && 'py-3', !noBorder && 'border-t border-divider-subtle', noPadding && '!p-0')}>
|
||||
<div className='flex justify-between gap-1'>
|
||||
{expanded && (
|
||||
<div className='system-xs-semibold-uppercase grow cursor-pointer py-1 text-text-secondary' onClick={() => setExpanded(!expanded)}>{t('appLog.runDetail.fileListLabel')}</div>
|
||||
)}
|
||||
{!expanded && (
|
||||
<div className='flex gap-1'>
|
||||
{fullList.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='h-8 w-8'
|
||||
imageUrl={base64Url || url || ''}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!isImageFile && (
|
||||
<Tooltip
|
||||
popupContent={name}
|
||||
>
|
||||
<div key={id} className='rounded-md border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg p-1.5 shadow-xs'>
|
||||
<FileTypeIcon
|
||||
type={getFileAppearanceType(name, type)}
|
||||
size='lg'
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className='flex cursor-pointer items-center gap-1' onClick={() => setExpanded(!expanded)}>
|
||||
{!expanded && <div className='system-xs-medium-uppercase text-text-tertiary'>{t('appLog.runDetail.fileListDetail')}</div>}
|
||||
<RiArrowRightSLine className={cn('h-4 w-4 text-text-tertiary', expanded && 'rotate-90')} />
|
||||
</div>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className='flex flex-col gap-3'>
|
||||
{fileList.map(item => (
|
||||
<div key={item.varName} className='system-xs-regular flex flex-col gap-1'>
|
||||
<div className='py-1 text-text-tertiary '>{item.varName}</div>
|
||||
{item.list.map(file => (
|
||||
<FileItem
|
||||
key={file.id}
|
||||
file={file}
|
||||
showDeleteAction={false}
|
||||
showDownloadAction
|
||||
canPreview
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileListInLog
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import { FileList } from './file-uploader-in-chat-input/file-list'
|
||||
import type { FileEntity } from './types'
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
|
||||
const SAMPLE_IMAGE = 'data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'160\' height=\'160\'><rect width=\'160\' height=\'160\' rx=\'16\' fill=\'#D1E9FF\'/><text x=\'50%\' y=\'50%\' dominant-baseline=\'middle\' text-anchor=\'middle\' font-family=\'sans-serif\' font-size=\'20\' fill=\'#1F2937\'>IMG</text></svg>'
|
||||
|
||||
const filesSample: FileEntity[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Project Brief.pdf',
|
||||
size: 256000,
|
||||
type: 'application/pdf',
|
||||
progress: 100,
|
||||
transferMethod: TransferMethod.local_file,
|
||||
supportFileType: SupportUploadFileTypes.document,
|
||||
url: '',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Design.png',
|
||||
size: 128000,
|
||||
type: 'image/png',
|
||||
progress: 100,
|
||||
transferMethod: TransferMethod.local_file,
|
||||
supportFileType: SupportUploadFileTypes.image,
|
||||
base64Url: SAMPLE_IMAGE,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Voiceover.mp3',
|
||||
size: 512000,
|
||||
type: 'audio/mpeg',
|
||||
progress: 45,
|
||||
transferMethod: TransferMethod.remote_url,
|
||||
supportFileType: SupportUploadFileTypes.audio,
|
||||
url: '',
|
||||
},
|
||||
]
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Display/FileList',
|
||||
component: FileList,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Renders a responsive gallery of uploaded files, handling icons, previews, and progress states.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
files: filesSample,
|
||||
},
|
||||
} satisfies Meta<typeof FileList>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const FileListPlayground = (args: React.ComponentProps<typeof FileList>) => {
|
||||
const [items, setItems] = useState<FileEntity[]>(args.files || [])
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-divider-subtle bg-components-panel-bg p-4">
|
||||
<FileList
|
||||
{...args}
|
||||
files={items}
|
||||
onRemove={fileId => setItems(list => list.filter(file => file.id !== fileId))}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Playground: Story = {
|
||||
render: args => <FileListPlayground {...args} />,
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
const [files, setFiles] = useState(initialFiles)
|
||||
|
||||
<FileList files={files} onRemove={(id) => setFiles(list => list.filter(file => file.id !== id))} />
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const UploadStates: Story = {
|
||||
args: {
|
||||
files: filesSample.map(file => ({ ...file, progress: file.id === '3' ? 45 : 100 })),
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import FileTypeIcon from './file-type-icon'
|
||||
import { FileAppearanceTypeEnum } from './types'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/General/FileTypeIcon',
|
||||
component: FileTypeIcon,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Displays the appropriate icon and accent colour for a file appearance type. Useful in lists and attachments.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
type: FileAppearanceTypeEnum.document,
|
||||
size: 'md',
|
||||
},
|
||||
} satisfies Meta<typeof FileTypeIcon>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Playground: Story = {}
|
||||
|
||||
export const Gallery: Story = {
|
||||
render: () => (
|
||||
<div className="grid grid-cols-4 gap-6 rounded-xl border border-divider-subtle bg-components-panel-bg p-6">
|
||||
{Object.values(FileAppearanceTypeEnum).map(type => (
|
||||
<div key={type} className="flex flex-col items-center gap-2 text-xs text-text-secondary">
|
||||
<FileTypeIcon type={type} size="xl" />
|
||||
<span className="capitalize">{type}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
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' | 'md' | 'lg' | 'xl'
|
||||
className?: string
|
||||
}
|
||||
const SizeMap = {
|
||||
sm: 'size-4',
|
||||
md: 'size-[18px]',
|
||||
lg: 'size-5',
|
||||
xl: 'size-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 <Icon className={cn('shrink-0', SizeMap[size], color, className)} />
|
||||
}
|
||||
|
||||
export default memo(FileTypeIcon)
|
||||
@@ -0,0 +1,163 @@
|
||||
import {
|
||||
memo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
RiDeleteBinLine,
|
||||
RiDownloadLine,
|
||||
RiEyeLine,
|
||||
} from '@remixicon/react'
|
||||
import FileTypeIcon from '../file-type-icon'
|
||||
import {
|
||||
downloadFile,
|
||||
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'
|
||||
import ImagePreview from '@/app/components/base/image-uploader/image-preview'
|
||||
import { PreviewMode } from '@/app/components/base/features/types'
|
||||
|
||||
type FileInAttachmentItemProps = {
|
||||
file: FileEntity
|
||||
showDeleteAction?: boolean
|
||||
showDownloadAction?: boolean
|
||||
onRemove?: (fileId: string) => void
|
||||
onReUpload?: (fileId: string) => void
|
||||
canPreview?: boolean
|
||||
previewMode?: PreviewMode
|
||||
}
|
||||
const FileInAttachmentItem = ({
|
||||
file,
|
||||
showDeleteAction,
|
||||
showDownloadAction = true,
|
||||
onRemove,
|
||||
onReUpload,
|
||||
canPreview,
|
||||
previewMode = PreviewMode.CurrentPage,
|
||||
}: 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 (
|
||||
<>
|
||||
<div className={cn(
|
||||
'flex h-12 items-center rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg pr-3 shadow-xs',
|
||||
progress === -1 && 'border-state-destructive-border bg-state-destructive-hover',
|
||||
canPreview && previewMode === PreviewMode.NewPage && 'cursor-pointer',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (canPreview && previewMode === PreviewMode.NewPage)
|
||||
window.open(url || base64Url || '', '_blank')
|
||||
}}
|
||||
>
|
||||
<div className='flex h-12 w-12 items-center justify-center'>
|
||||
{
|
||||
isImageFile && (
|
||||
<FileImageRender
|
||||
className='h-8 w-8'
|
||||
imageUrl={base64Url || url || ''}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!isImageFile && (
|
||||
<FileTypeIcon
|
||||
type={getFileAppearanceType(name, type)}
|
||||
size='xl'
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div className='mr-1 w-0 grow'>
|
||||
<div
|
||||
className='system-xs-medium mb-0.5 flex items-center truncate text-text-secondary'
|
||||
title={file.name}
|
||||
>
|
||||
<div className='truncate'>{name}</div>
|
||||
</div>
|
||||
<div className='system-2xs-medium-uppercase flex items-center text-text-tertiary'>
|
||||
{
|
||||
ext && (
|
||||
<span>{ext.toLowerCase()}</span>
|
||||
)
|
||||
}
|
||||
{
|
||||
ext && (
|
||||
<span className='system-2xs-medium mx-1'>•</span>
|
||||
)
|
||||
}
|
||||
{
|
||||
!!file.size && (
|
||||
<span>{formatFileSize(file.size)}</span>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex shrink-0 items-center'>
|
||||
{
|
||||
progress >= 0 && !fileIsUploaded(file) && (
|
||||
<ProgressCircle
|
||||
className='mr-2.5'
|
||||
percentage={progress}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
progress === -1 && (
|
||||
<ActionButton
|
||||
className='mr-1'
|
||||
onClick={() => onReUpload?.(id)}
|
||||
>
|
||||
<ReplayLine className='h-4 w-4 text-text-tertiary' />
|
||||
</ActionButton>
|
||||
)
|
||||
}
|
||||
{
|
||||
showDeleteAction && (
|
||||
<ActionButton onClick={() => onRemove?.(id)}>
|
||||
<RiDeleteBinLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
)
|
||||
}
|
||||
{
|
||||
canPreview && isImageFile && (
|
||||
<ActionButton className='mr-1' onClick={() => setImagePreviewUrl(url || '')}>
|
||||
<RiEyeLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
)
|
||||
}
|
||||
{
|
||||
showDownloadAction && (
|
||||
<ActionButton onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
downloadFile(url || base64Url || '', name)
|
||||
}}>
|
||||
<RiDownloadLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
imagePreviewUrl && canPreview && (
|
||||
<ImagePreview
|
||||
title={name}
|
||||
url={imagePreviewUrl}
|
||||
onCancel={() => setImagePreviewUrl('')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(FileInAttachmentItem)
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { fn } from 'storybook/test'
|
||||
import { useState } from 'react'
|
||||
import FileUploaderInAttachmentWrapper from './index'
|
||||
import type { FileEntity } from '../types'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import { PreviewMode } from '@/app/components/base/features/types'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { ToastProvider } from '@/app/components/base/toast'
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
|
||||
const SAMPLE_IMAGE = 'data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'128\' height=\'128\'><rect width=\'128\' height=\'128\' rx=\'16\' fill=\'#E0F2FE\'/><text x=\'50%\' y=\'50%\' dominant-baseline=\'middle\' text-anchor=\'middle\' font-family=\'sans-serif\' font-size=\'18\' fill=\'#1F2937\'>IMG</text></svg>'
|
||||
|
||||
const mockFiles: FileEntity[] = [
|
||||
{
|
||||
id: 'file-1',
|
||||
name: 'Requirements.pdf',
|
||||
size: 256000,
|
||||
type: 'application/pdf',
|
||||
progress: 100,
|
||||
transferMethod: TransferMethod.local_file,
|
||||
supportFileType: SupportUploadFileTypes.document,
|
||||
url: '',
|
||||
},
|
||||
{
|
||||
id: 'file-2',
|
||||
name: 'Interface.png',
|
||||
size: 128000,
|
||||
type: 'image/png',
|
||||
progress: 100,
|
||||
transferMethod: TransferMethod.local_file,
|
||||
supportFileType: SupportUploadFileTypes.image,
|
||||
base64Url: SAMPLE_IMAGE,
|
||||
},
|
||||
{
|
||||
id: 'file-3',
|
||||
name: 'Voiceover.mp3',
|
||||
size: 512000,
|
||||
type: 'audio/mpeg',
|
||||
progress: 35,
|
||||
transferMethod: TransferMethod.remote_url,
|
||||
supportFileType: SupportUploadFileTypes.audio,
|
||||
url: '',
|
||||
},
|
||||
]
|
||||
|
||||
const fileConfig: FileUpload = {
|
||||
enabled: true,
|
||||
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
|
||||
allowed_file_types: ['document', 'image', 'audio'],
|
||||
number_limits: 5,
|
||||
preview_config: { mode: PreviewMode.NewPage, file_type_list: ['pdf', 'png'] },
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Entry/FileUploaderInAttachment',
|
||||
component: FileUploaderInAttachmentWrapper,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Attachment-style uploader that supports local files and remote links. Demonstrates upload progress, re-upload, and preview actions.',
|
||||
},
|
||||
},
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
navigation: {
|
||||
pathname: '/apps/demo-app/uploads',
|
||||
params: { appId: 'demo-app' },
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
fileConfig,
|
||||
},
|
||||
} satisfies Meta<typeof FileUploaderInAttachmentWrapper>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const AttachmentDemo = (props: React.ComponentProps<typeof FileUploaderInAttachmentWrapper>) => {
|
||||
const [files, setFiles] = useState<FileEntity[]>(mockFiles)
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
<div className="w-[320px] rounded-2xl border border-divider-subtle bg-components-panel-bg p-4 shadow-xs">
|
||||
<FileUploaderInAttachmentWrapper
|
||||
{...props}
|
||||
value={files}
|
||||
onChange={setFiles}
|
||||
/>
|
||||
</div>
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export const Playground: Story = {
|
||||
render: args => <AttachmentDemo {...args} />,
|
||||
args: {
|
||||
onChange: fn(),
|
||||
},
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
render: args => <AttachmentDemo {...args} isDisabled />,
|
||||
args: {
|
||||
onChange: fn(),
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
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: React.JSX.Element
|
||||
}
|
||||
type FileUploaderInAttachmentProps = {
|
||||
isDisabled?: boolean
|
||||
fileConfig: FileUpload
|
||||
}
|
||||
const FileUploaderInAttachment = ({
|
||||
isDisabled,
|
||||
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='h-4 w-4' />,
|
||||
},
|
||||
{
|
||||
value: TransferMethod.remote_url,
|
||||
label: t('common.fileUploader.pasteFileLink'),
|
||||
icon: <RiLink className='h-4 w-4' />,
|
||||
},
|
||||
]
|
||||
|
||||
const renderButton = useCallback((option: Option, open?: boolean) => {
|
||||
return (
|
||||
<Button
|
||||
key={option.value}
|
||||
variant='tertiary'
|
||||
className={cn('relative grow', 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>
|
||||
{!isDisabled && (
|
||||
<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={!isDisabled}
|
||||
showDownloadAction={false}
|
||||
onRemove={() => handleRemoveFile(file.id)}
|
||||
onReUpload={() => handleReUploadFile(file.id)}
|
||||
canPreview={fileConfig.preview_config?.file_type_list?.includes(file.type)}
|
||||
previewMode={fileConfig.preview_config?.mode}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type FileUploaderInAttachmentWrapperProps = {
|
||||
value?: FileEntity[]
|
||||
onChange: (files: FileEntity[]) => void
|
||||
fileConfig: FileUpload
|
||||
isDisabled?: boolean
|
||||
}
|
||||
const FileUploaderInAttachmentWrapper = ({
|
||||
value,
|
||||
onChange,
|
||||
fileConfig,
|
||||
isDisabled,
|
||||
}: FileUploaderInAttachmentWrapperProps) => {
|
||||
return (
|
||||
<FileContextProvider
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
>
|
||||
<FileUploaderInAttachment isDisabled={isDisabled} fileConfig={fileConfig} />
|
||||
</FileContextProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileUploaderInAttachmentWrapper
|
||||
@@ -0,0 +1,110 @@
|
||||
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('')
|
||||
const download_url = url ? `${url}&as_attachment=true` : base64Url
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className='group/file-image relative cursor-pointer'
|
||||
onClick={() => canPreview && setImagePreviewUrl(base64Url || url || '')}
|
||||
>
|
||||
{
|
||||
showDeleteAction && (
|
||||
<Button
|
||||
className='absolute -right-1.5 -top-1.5 z-[11] hidden h-5 w-5 rounded-full p-0 group-hover/file-image:flex'
|
||||
onClick={() => onRemove?.(id)}
|
||||
>
|
||||
<RiCloseLine className='h-4 w-4 text-components-button-secondary-text' />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
<FileImageRender
|
||||
className='h-[68px] w-[68px] shadow-md'
|
||||
imageUrl={base64Url || url || ''}
|
||||
showDownloadAction={showDownloadAction}
|
||||
/>
|
||||
{
|
||||
progress >= 0 && !fileIsUploaded(file) && (
|
||||
<div className='absolute inset-0 z-10 flex items-center justify-center border-[2px] border-effects-image-frame bg-background-overlay-alt'>
|
||||
<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 z-10 flex items-center justify-center border-[2px] border-state-destructive-border bg-background-overlay-destructive'>
|
||||
<ReplayLine
|
||||
className='h-5 w-5'
|
||||
onClick={() => onReUpload?.(id)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
showDownloadAction && (
|
||||
<div className='absolute inset-0.5 z-10 hidden bg-background-overlay-alt bg-opacity-[0.3] group-hover/file-image:block'>
|
||||
<div
|
||||
className='absolute bottom-0.5 right-0.5 flex h-6 w-6 items-center justify-center rounded-lg bg-components-actionbar-bg shadow-md'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
downloadFile(download_url || '', name)
|
||||
}}
|
||||
>
|
||||
<RiDownloadLine className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{
|
||||
imagePreviewUrl && canPreview && (
|
||||
<ImagePreview
|
||||
title={name}
|
||||
url={imagePreviewUrl}
|
||||
onCancel={() => setImagePreviewUrl('')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileImageItem
|
||||
@@ -0,0 +1,156 @@
|
||||
import {
|
||||
RiCloseLine,
|
||||
RiDownloadLine,
|
||||
} from '@remixicon/react'
|
||||
import { useState } from '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'
|
||||
import PdfPreview from '@/app/components/base/file-uploader/dynamic-pdf-preview'
|
||||
import AudioPreview from '@/app/components/base/file-uploader/audio-preview'
|
||||
import VideoPreview from '@/app/components/base/file-uploader/video-preview'
|
||||
|
||||
type FileItemProps = {
|
||||
file: FileEntity
|
||||
showDeleteAction?: boolean
|
||||
showDownloadAction?: boolean
|
||||
canPreview?: boolean
|
||||
onRemove?: (fileId: string) => void
|
||||
onReUpload?: (fileId: string) => void
|
||||
}
|
||||
const FileItem = ({
|
||||
file,
|
||||
showDeleteAction,
|
||||
showDownloadAction = true,
|
||||
onRemove,
|
||||
onReUpload,
|
||||
canPreview,
|
||||
}: FileItemProps) => {
|
||||
const { id, name, type, progress, url, base64Url, isRemote } = file
|
||||
const [previewUrl, setPreviewUrl] = useState('')
|
||||
const ext = getFileExtension(name, type, isRemote)
|
||||
const uploadError = progress === -1
|
||||
|
||||
let tmp_preview_url = url || base64Url
|
||||
if (!tmp_preview_url && file?.originalFile)
|
||||
tmp_preview_url = URL.createObjectURL(file.originalFile.slice()).toString()
|
||||
const download_url = url ? `${url}&as_attachment=true` : base64Url
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'group/file-item relative h-[68px] w-[144px] rounded-lg border-[0.5px] border-components-panel-border bg-components-card-bg p-2 shadow-xs',
|
||||
!uploadError && 'hover:bg-components-card-bg-alt',
|
||||
uploadError && 'border border-state-destructive-border bg-state-destructive-hover',
|
||||
uploadError && 'bg-state-destructive-hover-alt hover:border-[0.5px] hover:border-state-destructive-border',
|
||||
)}
|
||||
>
|
||||
{
|
||||
showDeleteAction && (
|
||||
<Button
|
||||
className='absolute -right-1.5 -top-1.5 z-[11] hidden h-5 w-5 rounded-full p-0 group-hover/file-item:flex'
|
||||
onClick={() => onRemove?.(id)}
|
||||
>
|
||||
<RiCloseLine className='h-4 w-4 text-components-button-secondary-text' />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
<div
|
||||
className='system-xs-medium mb-1 line-clamp-2 h-8 cursor-pointer break-all text-text-tertiary'
|
||||
title={name}
|
||||
onClick={() => canPreview && setPreviewUrl(tmp_preview_url || '')}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
<div className='relative flex items-center justify-between'>
|
||||
<div className='system-2xs-medium-uppercase flex items-center text-text-tertiary'>
|
||||
<FileTypeIcon
|
||||
size='sm'
|
||||
type={getFileAppearanceType(name, type)}
|
||||
className='mr-1'
|
||||
/>
|
||||
{
|
||||
ext && (
|
||||
<>
|
||||
{ext}
|
||||
<div className='mx-1'>·</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
!!file.size && formatFileSize(file.size)
|
||||
}
|
||||
</div>
|
||||
{
|
||||
showDownloadAction && download_url && (
|
||||
<ActionButton
|
||||
size='m'
|
||||
className='absolute -right-1 -top-1 hidden group-hover/file-item:flex'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
downloadFile(download_url || '', name)
|
||||
}}
|
||||
>
|
||||
<RiDownloadLine className='h-3.5 w-3.5 text-text-tertiary' />
|
||||
</ActionButton>
|
||||
)
|
||||
}
|
||||
{
|
||||
progress >= 0 && !fileIsUploaded(file) && (
|
||||
<ProgressCircle
|
||||
percentage={progress}
|
||||
size={12}
|
||||
className='shrink-0'
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
uploadError && (
|
||||
<ReplayLine
|
||||
className='h-4 w-4 text-text-tertiary'
|
||||
onClick={() => onReUpload?.(id)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
type.split('/')[0] === 'audio' && canPreview && previewUrl && (
|
||||
<AudioPreview
|
||||
title={name}
|
||||
url={previewUrl}
|
||||
onCancel={() => setPreviewUrl('')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
type.split('/')[0] === 'video' && canPreview && previewUrl && (
|
||||
<VideoPreview
|
||||
title={name}
|
||||
url={previewUrl}
|
||||
onCancel={() => setPreviewUrl('')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
type.split('/')[1] === 'pdf' && canPreview && previewUrl && (
|
||||
<PdfPreview url={previewUrl} onCancel={() => { setPreviewUrl('') }} />
|
||||
)
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileItem
|
||||
@@ -0,0 +1,82 @@
|
||||
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 = true,
|
||||
}: 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}
|
||||
canPreview={canPreview}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
</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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import FileUploaderInChatInput from '.'
|
||||
import { FileContextProvider } from '../store'
|
||||
import type { FileEntity } from '../types'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { FileList } from '../file-uploader-in-chat-input/file-list'
|
||||
import { ToastProvider } from '@/app/components/base/toast'
|
||||
|
||||
const mockFiles: FileEntity[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Dataset.csv',
|
||||
size: 64000,
|
||||
type: 'text/csv',
|
||||
progress: 100,
|
||||
transferMethod: TransferMethod.local_file,
|
||||
supportFileType: SupportUploadFileTypes.document,
|
||||
},
|
||||
]
|
||||
|
||||
const chatUploadConfig: FileUpload = {
|
||||
enabled: true,
|
||||
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
|
||||
allowed_file_types: ['image', 'document'],
|
||||
number_limits: 3,
|
||||
}
|
||||
|
||||
type ChatInputDemoProps = React.ComponentProps<typeof FileUploaderInChatInput> & {
|
||||
initialFiles?: FileEntity[]
|
||||
}
|
||||
|
||||
const ChatInputDemo = ({ initialFiles = mockFiles, ...props }: ChatInputDemoProps) => {
|
||||
const [files, setFiles] = useState<FileEntity[]>(initialFiles)
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
<FileContextProvider value={files} onChange={setFiles}>
|
||||
<div className="w-[360px] rounded-2xl border border-divider-subtle bg-components-panel-bg p-4">
|
||||
<div className="mb-3 text-xs text-text-secondary">Simulated chat input</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileUploaderInChatInput {...props} />
|
||||
<div className="flex-1 rounded-lg border border-divider-subtle bg-background-default-subtle p-2 text-xs text-text-tertiary">Type a message...</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<FileList files={files} />
|
||||
</div>
|
||||
</div>
|
||||
</FileContextProvider>
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Entry/FileUploaderInChatInput',
|
||||
component: ChatInputDemo,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Attachment trigger suited for chat inputs. Demonstrates integration with the shared file store and preview list.',
|
||||
},
|
||||
},
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
navigation: {
|
||||
pathname: '/chats/demo',
|
||||
params: { appId: 'demo-app' },
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
fileConfig: chatUploadConfig,
|
||||
initialFiles: mockFiles,
|
||||
},
|
||||
} satisfies Meta<typeof ChatInputDemo>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Playground: Story = {
|
||||
render: args => <ChatInputDemo {...args} />,
|
||||
}
|
||||
|
||||
export const RemoteOnly: Story = {
|
||||
args: {
|
||||
fileConfig: {
|
||||
...chatUploadConfig,
|
||||
allowed_file_upload_methods: [TransferMethod.remote_url],
|
||||
},
|
||||
initialFiles: [],
|
||||
},
|
||||
}
|
||||
@@ -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='h-5 w-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)
|
||||
372
dify/web/app/components/base/file-uploader/hooks.ts
Normal file
372
dify/web/app/components/base/file-uploader/hooks.ts
Normal file
@@ -0,0 +1,372 @@
|
||||
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,
|
||||
getFileUploadErrorMessage,
|
||||
getSupportFileType,
|
||||
isAllowedFileExtension,
|
||||
} from './utils'
|
||||
import {
|
||||
AUDIO_SIZE_LIMIT,
|
||||
FILE_SIZE_LIMIT,
|
||||
IMG_SIZE_LIMIT,
|
||||
MAX_FILE_UPLOAD_LIMIT,
|
||||
VIDEO_SIZE_LIMIT,
|
||||
} from '@/app/components/base/file-uploader/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 { uploadRemoteFileInfo } from '@/service/common'
|
||||
import type { FileUploadConfigResponse } from '@/models/common'
|
||||
import { noop } from 'lodash-es'
|
||||
|
||||
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.custom:
|
||||
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
|
||||
}
|
||||
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: (error?: any) => {
|
||||
const errorMessage = getFileUploadErrorMessage(error, t('common.fileUploader.uploadFromComputerUploadError'), t)
|
||||
notify({ type: 'error', message: errorMessage })
|
||||
handleUpdateFile({ ...uploadingFile, progress: -1 })
|
||||
},
|
||||
}, !!params.token)
|
||||
}
|
||||
}, [fileStore, notify, t, handleUpdateFile, params])
|
||||
|
||||
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')} ${newFile.type}` })
|
||||
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')} ${file.type}` })
|
||||
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: (error?: any) => {
|
||||
const errorMessage = getFileUploadErrorMessage(error, t('common.fileUploader.uploadFromComputerUploadError'), t)
|
||||
notify({ type: 'error', message: errorMessage })
|
||||
handleUpdateFile({ ...uploadingFile, progress: -1 })
|
||||
},
|
||||
}, !!params.token)
|
||||
},
|
||||
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<HTMLTextAreaElement>) => {
|
||||
const file = e.clipboardData?.files[0]
|
||||
const text = e.clipboardData?.getData('text/plain')
|
||||
if (file && !text) {
|
||||
e.preventDefault()
|
||||
|
||||
const allowedFileTypes = fileConfig.allowed_file_types || []
|
||||
const fileType = getSupportFileType(file.name, file.type, allowedFileTypes?.includes(SupportUploadFileTypes.custom))
|
||||
const isFileTypeAllowed = allowedFileTypes.includes(fileType)
|
||||
|
||||
// Check if file type is in allowed list
|
||||
if (!isFileTypeAllowed || !fileConfig.enabled) {
|
||||
notify({
|
||||
type: 'error',
|
||||
message: t('common.fileUploader.fileExtensionNotSupport'),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
handleLocalFileUpload(file)
|
||||
}
|
||||
}, [handleLocalFileUpload, fileConfig, notify, t])
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
7
dify/web/app/components/base/file-uploader/index.ts
Normal file
7
dify/web/app/components/base/file-uploader/index.ts
Normal 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'
|
||||
103
dify/web/app/components/base/file-uploader/pdf-preview.tsx
Normal file
103
dify/web/app/components/base/file-uploader/pdf-preview.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { FC } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import 'react-pdf-highlighter/dist/style.css'
|
||||
import { PdfHighlighter, PdfLoader } from 'react-pdf-highlighter'
|
||||
import { t } from 'i18next'
|
||||
import { RiCloseLine, RiZoomInLine, RiZoomOutLine } from '@remixicon/react'
|
||||
import React, { useState } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { noop } from 'lodash-es'
|
||||
|
||||
type PdfPreviewProps = {
|
||||
url: string
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const PdfPreview: FC<PdfPreviewProps> = ({
|
||||
url,
|
||||
onCancel,
|
||||
}) => {
|
||||
const media = useBreakpoints()
|
||||
const [scale, setScale] = useState(1)
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 })
|
||||
const isMobile = media === MediaType.mobile
|
||||
|
||||
const zoomIn = () => {
|
||||
setScale(prevScale => Math.min(prevScale * 1.2, 15))
|
||||
setPosition({ x: position.x - 50, y: position.y - 50 })
|
||||
}
|
||||
|
||||
const zoomOut = () => {
|
||||
setScale((prevScale) => {
|
||||
const newScale = Math.max(prevScale / 1.2, 0.5)
|
||||
if (newScale === 1)
|
||||
setPosition({ x: 0, y: 0 })
|
||||
else
|
||||
setPosition({ x: position.x + 50, y: position.y + 50 })
|
||||
|
||||
return newScale
|
||||
})
|
||||
}
|
||||
|
||||
useHotkeys('esc', onCancel)
|
||||
useHotkeys('up', zoomIn)
|
||||
useHotkeys('down', zoomOut)
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={`fixed inset-0 z-[1000] flex items-center justify-center bg-black/80 ${!isMobile && 'p-8'}`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div
|
||||
className='h-[95vh] max-h-full w-[100vw] max-w-full overflow-hidden'
|
||||
style={{ transform: `scale(${scale})`, transformOrigin: 'center', scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
<PdfLoader
|
||||
workerSrc='/pdf.worker.min.mjs'
|
||||
url={url}
|
||||
beforeLoad={<div className='flex h-64 items-center justify-center'><Loading type='app' /></div>}
|
||||
>
|
||||
{(pdfDocument) => {
|
||||
return (
|
||||
<PdfHighlighter
|
||||
pdfDocument={pdfDocument}
|
||||
enableAreaSelection={event => event.altKey}
|
||||
scrollRef={noop}
|
||||
onScrollChange={noop}
|
||||
onSelectionFinished={() => null}
|
||||
highlightTransform={() => { return <div/> }}
|
||||
highlights={[]}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</PdfLoader>
|
||||
</div>
|
||||
<Tooltip popupContent={t('common.operation.zoomOut')}>
|
||||
<div className='absolute right-24 top-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg'
|
||||
onClick={zoomOut}>
|
||||
<RiZoomOutLine className='h-4 w-4 text-gray-500'/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip popupContent={t('common.operation.zoomIn')}>
|
||||
<div className='absolute right-16 top-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg'
|
||||
onClick={zoomIn}>
|
||||
<RiZoomInLine className='h-4 w-4 text-gray-500'/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip popupContent={t('common.operation.cancel')}>
|
||||
<div
|
||||
className='absolute right-6 top-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/8 backdrop-blur-[2px]'
|
||||
onClick={onCancel}>
|
||||
<RiCloseLine className='h-4 w-4 text-gray-500'/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
export default PdfPreview
|
||||
67
dify/web/app/components/base/file-uploader/store.tsx
Normal file
67
dify/web/app/components/base/file-uploader/store.tsx
Normal 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 ? [...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 | undefined>(undefined)
|
||||
|
||||
if (!storeRef.current)
|
||||
storeRef.current = createFileStore(value, onChange)
|
||||
|
||||
return (
|
||||
<FileContext.Provider value={storeRef.current}>
|
||||
{children}
|
||||
</FileContext.Provider>
|
||||
)
|
||||
}
|
||||
33
dify/web/app/components/base/file-uploader/types.ts
Normal file
33
dify/web/app/components/base/file-uploader/types.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
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
|
||||
}
|
||||
855
dify/web/app/components/base/file-uploader/utils.spec.ts
Normal file
855
dify/web/app/components/base/file-uploader/utils.spec.ts
Normal file
@@ -0,0 +1,855 @@
|
||||
import mime from 'mime'
|
||||
import { upload } from '@/service/base'
|
||||
import {
|
||||
downloadFile,
|
||||
fileIsUploaded,
|
||||
fileUpload,
|
||||
getFileAppearanceType,
|
||||
getFileExtension,
|
||||
getFileNameFromUrl,
|
||||
getFilesInLogs,
|
||||
getProcessedFiles,
|
||||
getProcessedFilesFromResponse,
|
||||
getSupportFileExtensionList,
|
||||
getSupportFileType,
|
||||
isAllowedFileExtension,
|
||||
} from './utils'
|
||||
import { FileAppearanceTypeEnum } from './types'
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { FILE_EXTS } from '../prompt-editor/constants'
|
||||
|
||||
jest.mock('mime', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
getAllExtensions: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/service/base', () => ({
|
||||
upload: jest.fn(),
|
||||
}))
|
||||
|
||||
describe('file-uploader utils', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('fileUpload', () => {
|
||||
it('should handle successful file upload', () => {
|
||||
const mockFile = new File(['test'], 'test.txt')
|
||||
const mockCallbacks = {
|
||||
onProgressCallback: jest.fn(),
|
||||
onSuccessCallback: jest.fn(),
|
||||
onErrorCallback: jest.fn(),
|
||||
}
|
||||
|
||||
jest.mocked(upload).mockResolvedValue({ id: '123' })
|
||||
|
||||
fileUpload({
|
||||
file: mockFile,
|
||||
...mockCallbacks,
|
||||
})
|
||||
|
||||
expect(upload).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFileExtension', () => {
|
||||
it('should get extension from mimetype', () => {
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['pdf']))
|
||||
expect(getFileExtension('file', 'application/pdf')).toBe('pdf')
|
||||
})
|
||||
|
||||
it('should get extension from mimetype and file name 1', () => {
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['pdf']))
|
||||
expect(getFileExtension('file.pdf', 'application/pdf')).toBe('pdf')
|
||||
})
|
||||
|
||||
it('should get extension from mimetype with multiple ext candidates with filename hint', () => {
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['der', 'crt', 'pem']))
|
||||
expect(getFileExtension('file.pem', 'application/x-x509-ca-cert')).toBe('pem')
|
||||
})
|
||||
|
||||
it('should get extension from mimetype with multiple ext candidates without filename hint', () => {
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['der', 'crt', 'pem']))
|
||||
expect(getFileExtension('file', 'application/x-x509-ca-cert')).toBe('der')
|
||||
})
|
||||
|
||||
it('should get extension from filename if mimetype fails', () => {
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(null)
|
||||
expect(getFileExtension('file.txt', '')).toBe('txt')
|
||||
expect(getFileExtension('file.txt.docx', '')).toBe('docx')
|
||||
expect(getFileExtension('file', '')).toBe('')
|
||||
})
|
||||
|
||||
it('should return empty string for remote files', () => {
|
||||
expect(getFileExtension('file.txt', '', true)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFileAppearanceType', () => {
|
||||
it('should identify gif files', () => {
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['gif']))
|
||||
expect(getFileAppearanceType('image.gif', 'image/gif'))
|
||||
.toBe(FileAppearanceTypeEnum.gif)
|
||||
})
|
||||
|
||||
it('should identify image files', () => {
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['jpg']))
|
||||
expect(getFileAppearanceType('image.jpg', 'image/jpeg'))
|
||||
.toBe(FileAppearanceTypeEnum.image)
|
||||
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['jpeg']))
|
||||
expect(getFileAppearanceType('image.jpeg', 'image/jpeg'))
|
||||
.toBe(FileAppearanceTypeEnum.image)
|
||||
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['png']))
|
||||
expect(getFileAppearanceType('image.png', 'image/png'))
|
||||
.toBe(FileAppearanceTypeEnum.image)
|
||||
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['webp']))
|
||||
expect(getFileAppearanceType('image.webp', 'image/webp'))
|
||||
.toBe(FileAppearanceTypeEnum.image)
|
||||
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['svg']))
|
||||
expect(getFileAppearanceType('image.svg', 'image/svgxml'))
|
||||
.toBe(FileAppearanceTypeEnum.image)
|
||||
})
|
||||
|
||||
it('should identify video files', () => {
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['mp4']))
|
||||
expect(getFileAppearanceType('video.mp4', 'video/mp4'))
|
||||
.toBe(FileAppearanceTypeEnum.video)
|
||||
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['mov']))
|
||||
expect(getFileAppearanceType('video.mov', 'video/quicktime'))
|
||||
.toBe(FileAppearanceTypeEnum.video)
|
||||
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['mpeg']))
|
||||
expect(getFileAppearanceType('video.mpeg', 'video/mpeg'))
|
||||
.toBe(FileAppearanceTypeEnum.video)
|
||||
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['webm']))
|
||||
expect(getFileAppearanceType('video.web', 'video/webm'))
|
||||
.toBe(FileAppearanceTypeEnum.video)
|
||||
})
|
||||
|
||||
it('should identify audio files', () => {
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['mp3']))
|
||||
expect(getFileAppearanceType('audio.mp3', 'audio/mpeg'))
|
||||
.toBe(FileAppearanceTypeEnum.audio)
|
||||
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['m4a']))
|
||||
expect(getFileAppearanceType('audio.m4a', 'audio/mp4'))
|
||||
.toBe(FileAppearanceTypeEnum.audio)
|
||||
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['wav']))
|
||||
expect(getFileAppearanceType('audio.wav', 'audio/vnd.wav'))
|
||||
.toBe(FileAppearanceTypeEnum.audio)
|
||||
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['amr']))
|
||||
expect(getFileAppearanceType('audio.amr', 'audio/AMR'))
|
||||
.toBe(FileAppearanceTypeEnum.audio)
|
||||
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['mpga']))
|
||||
expect(getFileAppearanceType('audio.mpga', 'audio/mpeg'))
|
||||
.toBe(FileAppearanceTypeEnum.audio)
|
||||
})
|
||||
|
||||
it('should identify code files', () => {
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['html']))
|
||||
expect(getFileAppearanceType('index.html', 'text/html'))
|
||||
.toBe(FileAppearanceTypeEnum.code)
|
||||
})
|
||||
|
||||
it('should identify PDF files', () => {
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['pdf']))
|
||||
expect(getFileAppearanceType('doc.pdf', 'application/pdf'))
|
||||
.toBe(FileAppearanceTypeEnum.pdf)
|
||||
})
|
||||
|
||||
it('should identify markdown files', () => {
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['md']))
|
||||
expect(getFileAppearanceType('file.md', 'text/markdown'))
|
||||
.toBe(FileAppearanceTypeEnum.markdown)
|
||||
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['markdown']))
|
||||
expect(getFileAppearanceType('file.markdown', 'text/markdown'))
|
||||
.toBe(FileAppearanceTypeEnum.markdown)
|
||||
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['mdx']))
|
||||
expect(getFileAppearanceType('file.mdx', 'text/mdx'))
|
||||
.toBe(FileAppearanceTypeEnum.markdown)
|
||||
})
|
||||
|
||||
it('should identify excel files', () => {
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['xlsx']))
|
||||
expect(getFileAppearanceType('doc.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'))
|
||||
.toBe(FileAppearanceTypeEnum.excel)
|
||||
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['xls']))
|
||||
expect(getFileAppearanceType('doc.xls', 'application/vnd.ms-excel'))
|
||||
.toBe(FileAppearanceTypeEnum.excel)
|
||||
})
|
||||
|
||||
it('should identify word files', () => {
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['doc']))
|
||||
expect(getFileAppearanceType('doc.doc', 'application/msword'))
|
||||
.toBe(FileAppearanceTypeEnum.word)
|
||||
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['docx']))
|
||||
expect(getFileAppearanceType('doc.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'))
|
||||
.toBe(FileAppearanceTypeEnum.word)
|
||||
})
|
||||
|
||||
it('should identify word files', () => {
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['ppt']))
|
||||
expect(getFileAppearanceType('doc.ppt', 'application/vnd.ms-powerpoint'))
|
||||
.toBe(FileAppearanceTypeEnum.ppt)
|
||||
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['pptx']))
|
||||
expect(getFileAppearanceType('doc.pptx', 'application/vnd.openxmlformats-officedocument.presentationml.presentation'))
|
||||
.toBe(FileAppearanceTypeEnum.ppt)
|
||||
})
|
||||
|
||||
it('should identify document files', () => {
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['txt']))
|
||||
expect(getFileAppearanceType('file.txt', 'text/plain'))
|
||||
.toBe(FileAppearanceTypeEnum.document)
|
||||
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['csv']))
|
||||
expect(getFileAppearanceType('file.csv', 'text/csv'))
|
||||
.toBe(FileAppearanceTypeEnum.document)
|
||||
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['msg']))
|
||||
expect(getFileAppearanceType('file.msg', 'application/vnd.ms-outlook'))
|
||||
.toBe(FileAppearanceTypeEnum.document)
|
||||
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['eml']))
|
||||
expect(getFileAppearanceType('file.eml', 'message/rfc822'))
|
||||
.toBe(FileAppearanceTypeEnum.document)
|
||||
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['xml']))
|
||||
expect(getFileAppearanceType('file.xml', 'application/rssxml'))
|
||||
.toBe(FileAppearanceTypeEnum.document)
|
||||
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['epub']))
|
||||
expect(getFileAppearanceType('file.epub', 'application/epubzip'))
|
||||
.toBe(FileAppearanceTypeEnum.document)
|
||||
})
|
||||
|
||||
it('should handle null mime extension', () => {
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(null)
|
||||
expect(getFileAppearanceType('file.txt', 'text/plain'))
|
||||
.toBe(FileAppearanceTypeEnum.document)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSupportFileType', () => {
|
||||
it('should return custom type when isCustom is true', () => {
|
||||
expect(getSupportFileType('file.txt', '', true))
|
||||
.toBe(SupportUploadFileTypes.custom)
|
||||
})
|
||||
|
||||
it('should return file type when isCustom is false', () => {
|
||||
expect(getSupportFileType('file.txt', 'text/plain'))
|
||||
.toBe(SupportUploadFileTypes.document)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getProcessedFiles', () => {
|
||||
it('should process files correctly', () => {
|
||||
const files = [{
|
||||
id: '123',
|
||||
name: 'test.txt',
|
||||
size: 1024,
|
||||
type: 'text/plain',
|
||||
progress: 100,
|
||||
supportFileType: 'document',
|
||||
transferMethod: TransferMethod.remote_url,
|
||||
url: 'http://example.com',
|
||||
uploadedId: '123',
|
||||
}]
|
||||
|
||||
const result = getProcessedFiles(files)
|
||||
expect(result[0]).toEqual({
|
||||
type: 'document',
|
||||
transfer_method: TransferMethod.remote_url,
|
||||
url: 'http://example.com',
|
||||
upload_file_id: '123',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getProcessedFilesFromResponse', () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(mime.getAllExtensions).mockImplementation((mimeType: string) => {
|
||||
const mimeMap: Record<string, Set<string>> = {
|
||||
'image/jpeg': new Set(['jpg', 'jpeg']),
|
||||
'image/png': new Set(['png']),
|
||||
'image/gif': new Set(['gif']),
|
||||
'video/mp4': new Set(['mp4']),
|
||||
'audio/mp3': new Set(['mp3']),
|
||||
'application/pdf': new Set(['pdf']),
|
||||
'text/plain': new Set(['txt']),
|
||||
'application/json': new Set(['json']),
|
||||
}
|
||||
return mimeMap[mimeType] || new Set()
|
||||
})
|
||||
})
|
||||
|
||||
it('should process files correctly without type correction', () => {
|
||||
const files = [{
|
||||
related_id: '2a38e2ca-1295-415d-a51d-65d4ff9912d9',
|
||||
extension: '.jpeg',
|
||||
filename: 'test.jpeg',
|
||||
size: 2881761,
|
||||
mime_type: 'image/jpeg',
|
||||
transfer_method: TransferMethod.local_file,
|
||||
type: 'image',
|
||||
url: 'https://upload.dify.dev/files/xxx/file-preview',
|
||||
upload_file_id: '2a38e2ca-1295-415d-a51d-65d4ff9912d9',
|
||||
remote_url: '',
|
||||
}]
|
||||
|
||||
const result = getProcessedFilesFromResponse(files)
|
||||
expect(result[0]).toEqual({
|
||||
id: '2a38e2ca-1295-415d-a51d-65d4ff9912d9',
|
||||
name: 'test.jpeg',
|
||||
size: 2881761,
|
||||
type: 'image/jpeg',
|
||||
progress: 100,
|
||||
transferMethod: TransferMethod.local_file,
|
||||
supportFileType: 'image',
|
||||
uploadedId: '2a38e2ca-1295-415d-a51d-65d4ff9912d9',
|
||||
url: 'https://upload.dify.dev/files/xxx/file-preview',
|
||||
})
|
||||
})
|
||||
|
||||
it('should correct image file misclassified as document', () => {
|
||||
const files = [{
|
||||
related_id: '123',
|
||||
extension: '.jpg',
|
||||
filename: 'image.jpg',
|
||||
size: 1024,
|
||||
mime_type: 'image/jpeg',
|
||||
transfer_method: TransferMethod.local_file,
|
||||
type: 'document',
|
||||
url: 'https://example.com/image.jpg',
|
||||
upload_file_id: '123',
|
||||
remote_url: '',
|
||||
}]
|
||||
|
||||
const result = getProcessedFilesFromResponse(files)
|
||||
expect(result[0].supportFileType).toBe('image')
|
||||
})
|
||||
|
||||
it('should correct video file misclassified as document', () => {
|
||||
const files = [{
|
||||
related_id: '123',
|
||||
extension: '.mp4',
|
||||
filename: 'video.mp4',
|
||||
size: 1024,
|
||||
mime_type: 'video/mp4',
|
||||
transfer_method: TransferMethod.local_file,
|
||||
type: 'document',
|
||||
url: 'https://example.com/video.mp4',
|
||||
upload_file_id: '123',
|
||||
remote_url: '',
|
||||
}]
|
||||
|
||||
const result = getProcessedFilesFromResponse(files)
|
||||
expect(result[0].supportFileType).toBe('video')
|
||||
})
|
||||
|
||||
it('should correct audio file misclassified as document', () => {
|
||||
const files = [{
|
||||
related_id: '123',
|
||||
extension: '.mp3',
|
||||
filename: 'audio.mp3',
|
||||
size: 1024,
|
||||
mime_type: 'audio/mp3',
|
||||
transfer_method: TransferMethod.local_file,
|
||||
type: 'document',
|
||||
url: 'https://example.com/audio.mp3',
|
||||
upload_file_id: '123',
|
||||
remote_url: '',
|
||||
}]
|
||||
|
||||
const result = getProcessedFilesFromResponse(files)
|
||||
expect(result[0].supportFileType).toBe('audio')
|
||||
})
|
||||
|
||||
it('should correct document file misclassified as image', () => {
|
||||
const files = [{
|
||||
related_id: '123',
|
||||
extension: '.pdf',
|
||||
filename: 'document.pdf',
|
||||
size: 1024,
|
||||
mime_type: 'application/pdf',
|
||||
transfer_method: TransferMethod.local_file,
|
||||
type: 'image',
|
||||
url: 'https://example.com/document.pdf',
|
||||
upload_file_id: '123',
|
||||
remote_url: '',
|
||||
}]
|
||||
|
||||
const result = getProcessedFilesFromResponse(files)
|
||||
expect(result[0].supportFileType).toBe('document')
|
||||
})
|
||||
|
||||
it('should NOT correct when filename and MIME type conflict', () => {
|
||||
const files = [{
|
||||
related_id: '123',
|
||||
extension: '.pdf',
|
||||
filename: 'document.pdf',
|
||||
size: 1024,
|
||||
mime_type: 'image/jpeg',
|
||||
transfer_method: TransferMethod.local_file,
|
||||
type: 'document',
|
||||
url: 'https://example.com/document.pdf',
|
||||
upload_file_id: '123',
|
||||
remote_url: '',
|
||||
}]
|
||||
|
||||
const result = getProcessedFilesFromResponse(files)
|
||||
expect(result[0].supportFileType).toBe('document')
|
||||
})
|
||||
|
||||
it('should NOT correct when filename and MIME type both point to wrong type', () => {
|
||||
const files = [{
|
||||
related_id: '123',
|
||||
extension: '.jpg',
|
||||
filename: 'image.jpg',
|
||||
size: 1024,
|
||||
mime_type: 'image/jpeg',
|
||||
transfer_method: TransferMethod.local_file,
|
||||
type: 'image',
|
||||
url: 'https://example.com/image.jpg',
|
||||
upload_file_id: '123',
|
||||
remote_url: '',
|
||||
}]
|
||||
|
||||
const result = getProcessedFilesFromResponse(files)
|
||||
expect(result[0].supportFileType).toBe('image')
|
||||
})
|
||||
|
||||
it('should handle files with missing filename', () => {
|
||||
const files = [{
|
||||
related_id: '123',
|
||||
extension: '',
|
||||
filename: '',
|
||||
size: 1024,
|
||||
mime_type: 'image/jpeg',
|
||||
transfer_method: TransferMethod.local_file,
|
||||
type: 'document',
|
||||
url: 'https://example.com/file',
|
||||
upload_file_id: '123',
|
||||
remote_url: '',
|
||||
}]
|
||||
|
||||
const result = getProcessedFilesFromResponse(files)
|
||||
expect(result[0].supportFileType).toBe('document')
|
||||
})
|
||||
|
||||
it('should handle files with missing MIME type', () => {
|
||||
const files = [{
|
||||
related_id: '123',
|
||||
extension: '.jpg',
|
||||
filename: 'image.jpg',
|
||||
size: 1024,
|
||||
mime_type: '',
|
||||
transfer_method: TransferMethod.local_file,
|
||||
type: 'document',
|
||||
url: 'https://example.com/image.jpg',
|
||||
upload_file_id: '123',
|
||||
remote_url: '',
|
||||
}]
|
||||
|
||||
const result = getProcessedFilesFromResponse(files)
|
||||
expect(result[0].supportFileType).toBe('document')
|
||||
})
|
||||
|
||||
it('should handle files with unknown extensions', () => {
|
||||
const files = [{
|
||||
related_id: '123',
|
||||
extension: '.unknown',
|
||||
filename: 'file.unknown',
|
||||
size: 1024,
|
||||
mime_type: 'application/unknown',
|
||||
transfer_method: TransferMethod.local_file,
|
||||
type: 'document',
|
||||
url: 'https://example.com/file.unknown',
|
||||
upload_file_id: '123',
|
||||
remote_url: '',
|
||||
}]
|
||||
|
||||
const result = getProcessedFilesFromResponse(files)
|
||||
expect(result[0].supportFileType).toBe('document')
|
||||
})
|
||||
|
||||
it('should handle multiple different file types correctly', () => {
|
||||
const files = [
|
||||
{
|
||||
related_id: '1',
|
||||
extension: '.jpg',
|
||||
filename: 'correct-image.jpg',
|
||||
mime_type: 'image/jpeg',
|
||||
type: 'image',
|
||||
size: 1024,
|
||||
transfer_method: TransferMethod.local_file,
|
||||
url: 'https://example.com/correct-image.jpg',
|
||||
upload_file_id: '1',
|
||||
remote_url: '',
|
||||
},
|
||||
{
|
||||
related_id: '2',
|
||||
extension: '.png',
|
||||
filename: 'misclassified-image.png',
|
||||
mime_type: 'image/png',
|
||||
type: 'document',
|
||||
size: 2048,
|
||||
transfer_method: TransferMethod.local_file,
|
||||
url: 'https://example.com/misclassified-image.png',
|
||||
upload_file_id: '2',
|
||||
remote_url: '',
|
||||
},
|
||||
{
|
||||
related_id: '3',
|
||||
extension: '.pdf',
|
||||
filename: 'conflicted.pdf',
|
||||
mime_type: 'image/jpeg',
|
||||
type: 'document',
|
||||
size: 3072,
|
||||
transfer_method: TransferMethod.local_file,
|
||||
url: 'https://example.com/conflicted.pdf',
|
||||
upload_file_id: '3',
|
||||
remote_url: '',
|
||||
},
|
||||
]
|
||||
|
||||
const result = getProcessedFilesFromResponse(files)
|
||||
|
||||
expect(result[0].supportFileType).toBe('image') // correct, no change
|
||||
expect(result[1].supportFileType).toBe('image') // corrected from document to image
|
||||
expect(result[2].supportFileType).toBe('document') // conflict, no change
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFileNameFromUrl', () => {
|
||||
it('should extract filename from URL', () => {
|
||||
expect(getFileNameFromUrl('http://example.com/path/file.txt'))
|
||||
.toBe('file.txt')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSupportFileExtensionList', () => {
|
||||
it('should handle custom file types', () => {
|
||||
const result = getSupportFileExtensionList(
|
||||
[SupportUploadFileTypes.custom],
|
||||
['.pdf', '.txt', '.doc'],
|
||||
)
|
||||
expect(result).toEqual(['PDF', 'TXT', 'DOC'])
|
||||
})
|
||||
|
||||
it('should handle standard file types', () => {
|
||||
const mockFileExts = {
|
||||
image: ['JPG', 'PNG'],
|
||||
document: ['PDF', 'TXT'],
|
||||
video: ['MP4', 'MOV'],
|
||||
}
|
||||
|
||||
// Temporarily mock FILE_EXTS
|
||||
const originalFileExts = { ...FILE_EXTS }
|
||||
Object.assign(FILE_EXTS, mockFileExts)
|
||||
|
||||
const result = getSupportFileExtensionList(
|
||||
['image', 'document'],
|
||||
[],
|
||||
)
|
||||
expect(result).toEqual(['JPG', 'PNG', 'PDF', 'TXT'])
|
||||
|
||||
// Restore original FILE_EXTS
|
||||
Object.assign(FILE_EXTS, originalFileExts)
|
||||
})
|
||||
|
||||
it('should return empty array for empty inputs', () => {
|
||||
const result = getSupportFileExtensionList([], [])
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should prioritize custom types over standard types', () => {
|
||||
const mockFileExts = {
|
||||
image: ['JPG', 'PNG'],
|
||||
}
|
||||
|
||||
// Temporarily mock FILE_EXTS
|
||||
const originalFileExts = { ...FILE_EXTS }
|
||||
Object.assign(FILE_EXTS, mockFileExts)
|
||||
|
||||
const result = getSupportFileExtensionList(
|
||||
[SupportUploadFileTypes.custom, 'image'],
|
||||
['.csv', '.xml'],
|
||||
)
|
||||
expect(result).toEqual(['CSV', 'XML'])
|
||||
|
||||
// Restore original FILE_EXTS
|
||||
Object.assign(FILE_EXTS, originalFileExts)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isAllowedFileExtension', () => {
|
||||
it('should validate allowed file extensions', () => {
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['pdf']))
|
||||
expect(isAllowedFileExtension(
|
||||
'test.pdf',
|
||||
'application/pdf',
|
||||
['document'],
|
||||
['.pdf'],
|
||||
)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFilesInLogs', () => {
|
||||
const mockFileData = {
|
||||
dify_model_identity: '__dify__file__',
|
||||
related_id: '123',
|
||||
filename: 'test.pdf',
|
||||
size: 1024,
|
||||
mime_type: 'application/pdf',
|
||||
transfer_method: 'local_file',
|
||||
type: 'document',
|
||||
url: 'http://example.com/test.pdf',
|
||||
}
|
||||
|
||||
it('should handle empty or null input', () => {
|
||||
expect(getFilesInLogs(null)).toEqual([])
|
||||
expect(getFilesInLogs({})).toEqual([])
|
||||
expect(getFilesInLogs(undefined)).toEqual([])
|
||||
})
|
||||
|
||||
it('should process single file object', () => {
|
||||
const input = {
|
||||
file1: mockFileData,
|
||||
}
|
||||
|
||||
const expected = [{
|
||||
varName: 'file1',
|
||||
list: [{
|
||||
id: '123',
|
||||
name: 'test.pdf',
|
||||
size: 1024,
|
||||
type: 'application/pdf',
|
||||
progress: 100,
|
||||
transferMethod: 'local_file',
|
||||
supportFileType: 'document',
|
||||
uploadedId: '123',
|
||||
url: 'http://example.com/test.pdf',
|
||||
}],
|
||||
}]
|
||||
|
||||
expect(getFilesInLogs(input)).toEqual(expected)
|
||||
})
|
||||
|
||||
it('should process array of files', () => {
|
||||
const input = {
|
||||
files: [mockFileData, mockFileData],
|
||||
}
|
||||
|
||||
const expected = [{
|
||||
varName: 'files',
|
||||
list: [
|
||||
{
|
||||
id: '123',
|
||||
name: 'test.pdf',
|
||||
size: 1024,
|
||||
type: 'application/pdf',
|
||||
progress: 100,
|
||||
transferMethod: 'local_file',
|
||||
supportFileType: 'document',
|
||||
uploadedId: '123',
|
||||
url: 'http://example.com/test.pdf',
|
||||
},
|
||||
{
|
||||
id: '123',
|
||||
name: 'test.pdf',
|
||||
size: 1024,
|
||||
type: 'application/pdf',
|
||||
progress: 100,
|
||||
transferMethod: 'local_file',
|
||||
supportFileType: 'document',
|
||||
uploadedId: '123',
|
||||
url: 'http://example.com/test.pdf',
|
||||
},
|
||||
],
|
||||
}]
|
||||
|
||||
expect(getFilesInLogs(input)).toEqual(expected)
|
||||
})
|
||||
|
||||
it('should ignore non-file objects and arrays', () => {
|
||||
const input = {
|
||||
regularString: 'not a file',
|
||||
regularNumber: 123,
|
||||
regularArray: [1, 2, 3],
|
||||
regularObject: { key: 'value' },
|
||||
file: mockFileData,
|
||||
}
|
||||
|
||||
const expected = [{
|
||||
varName: 'file',
|
||||
list: [{
|
||||
id: '123',
|
||||
name: 'test.pdf',
|
||||
size: 1024,
|
||||
type: 'application/pdf',
|
||||
progress: 100,
|
||||
transferMethod: 'local_file',
|
||||
supportFileType: 'document',
|
||||
uploadedId: '123',
|
||||
url: 'http://example.com/test.pdf',
|
||||
}],
|
||||
}]
|
||||
|
||||
expect(getFilesInLogs(input)).toEqual(expected)
|
||||
})
|
||||
|
||||
it('should handle mixed file types in array', () => {
|
||||
const input = {
|
||||
mixedFiles: [
|
||||
mockFileData,
|
||||
{ notAFile: true },
|
||||
mockFileData,
|
||||
],
|
||||
}
|
||||
|
||||
const expected = [{
|
||||
varName: 'mixedFiles',
|
||||
list: [
|
||||
{
|
||||
id: '123',
|
||||
name: 'test.pdf',
|
||||
size: 1024,
|
||||
type: 'application/pdf',
|
||||
progress: 100,
|
||||
transferMethod: 'local_file',
|
||||
supportFileType: 'document',
|
||||
uploadedId: '123',
|
||||
url: 'http://example.com/test.pdf',
|
||||
},
|
||||
{
|
||||
id: undefined,
|
||||
name: undefined,
|
||||
progress: 100,
|
||||
size: 0,
|
||||
supportFileType: undefined,
|
||||
transferMethod: undefined,
|
||||
type: undefined,
|
||||
uploadedId: undefined,
|
||||
url: undefined,
|
||||
},
|
||||
{
|
||||
id: '123',
|
||||
name: 'test.pdf',
|
||||
size: 1024,
|
||||
type: 'application/pdf',
|
||||
progress: 100,
|
||||
transferMethod: 'local_file',
|
||||
supportFileType: 'document',
|
||||
uploadedId: '123',
|
||||
url: 'http://example.com/test.pdf',
|
||||
},
|
||||
],
|
||||
}]
|
||||
|
||||
expect(getFilesInLogs(input)).toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fileIsUploaded', () => {
|
||||
it('should identify uploaded files', () => {
|
||||
expect(fileIsUploaded({
|
||||
uploadedId: '123',
|
||||
progress: 100,
|
||||
} as any)).toBe(true)
|
||||
})
|
||||
|
||||
it('should identify remote files as uploaded', () => {
|
||||
expect(fileIsUploaded({
|
||||
transferMethod: TransferMethod.remote_url,
|
||||
progress: 100,
|
||||
} as any)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('downloadFile', () => {
|
||||
let mockAnchor: HTMLAnchorElement
|
||||
let createElementMock: jest.SpyInstance
|
||||
let appendChildMock: jest.SpyInstance
|
||||
let removeChildMock: jest.SpyInstance
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock createElement and appendChild
|
||||
mockAnchor = {
|
||||
href: '',
|
||||
download: '',
|
||||
style: { display: '' },
|
||||
target: '',
|
||||
title: '',
|
||||
click: jest.fn(),
|
||||
} as unknown as HTMLAnchorElement
|
||||
|
||||
createElementMock = jest.spyOn(document, 'createElement').mockReturnValue(mockAnchor as any)
|
||||
appendChildMock = jest.spyOn(document.body, 'appendChild').mockImplementation((node: Node) => {
|
||||
return node
|
||||
})
|
||||
removeChildMock = jest.spyOn(document.body, 'removeChild').mockImplementation((node: Node) => {
|
||||
return node
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks()
|
||||
})
|
||||
|
||||
it('should create and trigger download with correct attributes', () => {
|
||||
const url = 'https://example.com/test.pdf'
|
||||
const filename = 'test.pdf'
|
||||
|
||||
downloadFile(url, filename)
|
||||
|
||||
// Verify anchor element was created with correct properties
|
||||
expect(createElementMock).toHaveBeenCalledWith('a')
|
||||
expect(mockAnchor.href).toBe(url)
|
||||
expect(mockAnchor.download).toBe(filename)
|
||||
expect(mockAnchor.style.display).toBe('none')
|
||||
expect(mockAnchor.target).toBe('_blank')
|
||||
expect(mockAnchor.title).toBe(filename)
|
||||
|
||||
// Verify DOM operations
|
||||
expect(appendChildMock).toHaveBeenCalledWith(mockAnchor)
|
||||
expect(mockAnchor.click).toHaveBeenCalled()
|
||||
expect(removeChildMock).toHaveBeenCalledWith(mockAnchor)
|
||||
})
|
||||
|
||||
it('should handle empty filename', () => {
|
||||
const url = 'https://example.com/test.pdf'
|
||||
const filename = ''
|
||||
|
||||
downloadFile(url, filename)
|
||||
|
||||
expect(mockAnchor.download).toBe('')
|
||||
expect(mockAnchor.title).toBe('')
|
||||
})
|
||||
|
||||
it('should handle empty url', () => {
|
||||
const url = ''
|
||||
const filename = 'test.pdf'
|
||||
|
||||
downloadFile(url, filename)
|
||||
|
||||
expect(mockAnchor.href).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
250
dify/web/app/components/base/file-uploader/utils.ts
Normal file
250
dify/web/app/components/base/file-uploader/utils.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import mime from 'mime'
|
||||
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'
|
||||
|
||||
/**
|
||||
* Get appropriate error message for file upload errors
|
||||
* @param error - The error object from upload failure
|
||||
* @param defaultMessage - Default error message to use if no specific error is matched
|
||||
* @param t - Translation function
|
||||
* @returns Localized error message
|
||||
*/
|
||||
export const getFileUploadErrorMessage = (error: any, defaultMessage: string, t: (key: string) => string): string => {
|
||||
const errorCode = error?.response?.code
|
||||
|
||||
if (errorCode === 'forbidden')
|
||||
return error?.response?.message
|
||||
|
||||
if (errorCode === 'file_extension_blocked')
|
||||
return t('common.fileUploader.fileExtensionBlocked')
|
||||
|
||||
return defaultMessage
|
||||
}
|
||||
|
||||
type FileUploadParams = {
|
||||
file: File
|
||||
onProgressCallback: (progress: number) => void
|
||||
onSuccessCallback: (res: { id: string }) => void
|
||||
onErrorCallback: (error?: any) => 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((error) => {
|
||||
onErrorCallback(error)
|
||||
})
|
||||
}
|
||||
|
||||
const additionalExtensionMap = new Map<string, string[]>([
|
||||
['text/x-markdown', ['md']],
|
||||
])
|
||||
|
||||
export const getFileExtension = (fileName: string, fileMimetype: string, isRemote?: boolean) => {
|
||||
let extension = ''
|
||||
let extensions = new Set<string>()
|
||||
if (fileMimetype) {
|
||||
const extensionsFromMimeType = mime.getAllExtensions(fileMimetype) || new Set<string>()
|
||||
const additionalExtensions = additionalExtensionMap.get(fileMimetype) || []
|
||||
extensions = new Set<string>([
|
||||
...extensionsFromMimeType,
|
||||
...additionalExtensions,
|
||||
])
|
||||
}
|
||||
|
||||
let extensionInFileName = ''
|
||||
if (fileName) {
|
||||
const fileNamePair = fileName.split('.')
|
||||
const fileNamePairLength = fileNamePair.length
|
||||
|
||||
if (fileNamePairLength > 1) {
|
||||
extensionInFileName = fileNamePair[fileNamePairLength - 1].toLowerCase()
|
||||
if (extensions.has(extensionInFileName))
|
||||
extension = extensionInFileName
|
||||
}
|
||||
}
|
||||
if (!extension) {
|
||||
if (extensions.size > 0) {
|
||||
const firstExtension = extensions.values().next().value
|
||||
extension = firstExtension ? firstExtension.toLowerCase() : ''
|
||||
}
|
||||
else {
|
||||
extension = extensionInFileName
|
||||
}
|
||||
}
|
||||
|
||||
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) => {
|
||||
let supportFileType = fileItem.type
|
||||
|
||||
if (fileItem.filename && fileItem.mime_type) {
|
||||
const detectedTypeFromFileName = getSupportFileType(fileItem.filename, '')
|
||||
const detectedTypeFromMime = getSupportFileType('', fileItem.mime_type)
|
||||
|
||||
if (detectedTypeFromFileName
|
||||
&& detectedTypeFromMime
|
||||
&& detectedTypeFromFileName === detectedTypeFromMime
|
||||
&& detectedTypeFromFileName !== fileItem.type)
|
||||
supportFileType = detectedTypeFromFileName
|
||||
}
|
||||
|
||||
return {
|
||||
id: fileItem.related_id,
|
||||
name: fileItem.filename,
|
||||
size: fileItem.size || 0,
|
||||
type: fileItem.mime_type,
|
||||
progress: 100,
|
||||
transferMethod: fileItem.transfer_method,
|
||||
supportFileType,
|
||||
uploadedId: fileItem.upload_file_id || fileItem.related_id,
|
||||
url: fileItem.url || fileItem.remote_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)
|
||||
}
|
||||
45
dify/web/app/components/base/file-uploader/video-preview.tsx
Normal file
45
dify/web/app/components/base/file-uploader/video-preview.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { FC } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import React from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
|
||||
type VideoPreviewProps = {
|
||||
url: string
|
||||
title: string
|
||||
onCancel: () => void
|
||||
}
|
||||
const VideoPreview: FC<VideoPreviewProps> = ({
|
||||
url,
|
||||
title,
|
||||
onCancel,
|
||||
}) => {
|
||||
useHotkeys('esc', onCancel)
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className='fixed inset-0 z-[1000] flex items-center justify-center bg-black/80 p-8'
|
||||
onClick={e => e.stopPropagation()}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div>
|
||||
<video controls title={title} autoPlay={false} preload="metadata">
|
||||
<source
|
||||
type="video/mp4"
|
||||
src={url}
|
||||
className='max-h-full max-w-full'
|
||||
/>
|
||||
</video>
|
||||
</div>
|
||||
<div
|
||||
className='absolute right-6 top-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/[0.08] backdrop-blur-[2px]'
|
||||
onClick={onCancel}
|
||||
>
|
||||
<RiCloseLine className='h-4 w-4 text-gray-500'/>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
export default VideoPreview
|
||||
Reference in New Issue
Block a user