feat: multimodal support (image) (#27793)
Co-authored-by: zxhlyh <jasonapring2015@outlook.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
88
web/app/components/datasets/common/image-list/index.tsx
Normal file
88
web/app/components/datasets/common/image-list/index.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import type { FileEntity } from '@/app/components/base/file-thumb'
|
||||
import FileThumb from '@/app/components/base/file-thumb'
|
||||
import cn from '@/utils/classnames'
|
||||
import More from './more'
|
||||
import type { ImageInfo } from '../image-previewer'
|
||||
import ImagePreviewer from '../image-previewer'
|
||||
|
||||
type Image = {
|
||||
name: string
|
||||
mimeType: string
|
||||
sourceUrl: string
|
||||
size: number
|
||||
extension: string
|
||||
}
|
||||
|
||||
type ImageListProps = {
|
||||
images: Image[]
|
||||
size: 'sm' | 'md'
|
||||
limit?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
const ImageList = ({
|
||||
images,
|
||||
size,
|
||||
limit = 9,
|
||||
className,
|
||||
}: ImageListProps) => {
|
||||
const [showMore, setShowMore] = useState(false)
|
||||
const [previewIndex, setPreviewIndex] = useState(0)
|
||||
const [previewImages, setPreviewImages] = useState<ImageInfo[]>([])
|
||||
|
||||
const limitedImages = useMemo(() => {
|
||||
return showMore ? images : images.slice(0, limit)
|
||||
}, [images, limit, showMore])
|
||||
|
||||
const handleShowMore = useCallback(() => {
|
||||
setShowMore(true)
|
||||
}, [])
|
||||
|
||||
const handleImageClick = useCallback((file: FileEntity) => {
|
||||
const index = limitedImages.findIndex(image => image.sourceUrl === file.sourceUrl)
|
||||
if (index === -1) return
|
||||
setPreviewIndex(index)
|
||||
setPreviewImages(limitedImages.map(image => ({
|
||||
url: image.sourceUrl,
|
||||
name: image.name,
|
||||
size: image.size,
|
||||
})))
|
||||
}, [limitedImages])
|
||||
|
||||
const handleClosePreview = useCallback(() => {
|
||||
setPreviewImages([])
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn('flex flex-wrap gap-1', className)}>
|
||||
{
|
||||
limitedImages.map(image => (
|
||||
<FileThumb
|
||||
key={image.sourceUrl}
|
||||
file={image}
|
||||
size={size}
|
||||
onClick={handleImageClick}
|
||||
/>
|
||||
))
|
||||
}
|
||||
{images.length > limit && !showMore && (
|
||||
<More
|
||||
count={images.length - limitedImages.length}
|
||||
onClick={handleShowMore}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{previewImages.length > 0 && (
|
||||
<ImagePreviewer
|
||||
images={previewImages}
|
||||
initialIndex={previewIndex}
|
||||
onClose={handleClosePreview}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ImageList
|
||||
39
web/app/components/datasets/common/image-list/more.tsx
Normal file
39
web/app/components/datasets/common/image-list/more.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React, { useCallback } from 'react'
|
||||
|
||||
type MoreProps = {
|
||||
count: number
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const More = ({ count, onClick }: MoreProps) => {
|
||||
const formatNumber = (num: number) => {
|
||||
if (num === 0)
|
||||
return '0'
|
||||
if (num < 1000)
|
||||
return num.toString()
|
||||
if (num < 1000000)
|
||||
return `${(num / 1000).toFixed(1)}k`
|
||||
return `${(num / 1000000).toFixed(1)}M`
|
||||
}
|
||||
|
||||
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
onClick?.()
|
||||
}, [onClick])
|
||||
|
||||
return (
|
||||
<div className='relative size-8 cursor-pointer p-[0.5px]' onClick={handleClick}>
|
||||
<div className='relative z-10 size-full rounded-md border-[1.5px] border-components-panel-bg bg-divider-regular'>
|
||||
<div className='flex size-full items-center justify-center'>
|
||||
<span className='system-xs-regular text-text-tertiary'>
|
||||
{`+${formatNumber(count)}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='absolute -right-0.5 top-1 z-0 h-6 w-1 rounded-r-md bg-divider-regular' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(More)
|
||||
223
web/app/components/datasets/common/image-previewer/index.tsx
Normal file
223
web/app/components/datasets/common/image-previewer/index.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { formatFileSize } from '@/utils/format'
|
||||
import { RiArrowLeftLine, RiArrowRightLine, RiCloseLine, RiRefreshLine } from '@remixicon/react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
|
||||
type CachedImage = {
|
||||
blobUrl?: string
|
||||
status: 'loading' | 'loaded' | 'error'
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
const imageCache = new Map<string, CachedImage>()
|
||||
|
||||
export type ImageInfo = {
|
||||
url: string
|
||||
name: string
|
||||
size: number
|
||||
}
|
||||
|
||||
type ImagePreviewerProps = {
|
||||
images: ImageInfo[]
|
||||
initialIndex?: number
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const ImagePreviewer = ({
|
||||
images,
|
||||
initialIndex = 0,
|
||||
onClose,
|
||||
}: ImagePreviewerProps) => {
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex)
|
||||
const [cachedImages, setCachedImages] = useState<Record<string, CachedImage>>(() => {
|
||||
return images.reduce((acc, image) => {
|
||||
acc[image.url] = {
|
||||
status: 'loading',
|
||||
width: 0,
|
||||
height: 0,
|
||||
}
|
||||
return acc
|
||||
}, {} as Record<string, CachedImage>)
|
||||
})
|
||||
const isMounted = useRef(false)
|
||||
|
||||
const fetchImage = useCallback(async (image: ImageInfo) => {
|
||||
const { url } = image
|
||||
// Skip if already cached
|
||||
if (imageCache.has(url)) return
|
||||
|
||||
try {
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) throw new Error(`Failed to load: ${url}`)
|
||||
const blob = await res.blob()
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
|
||||
const img = new Image()
|
||||
img.src = blobUrl
|
||||
img.onload = () => {
|
||||
if (!isMounted.current) return
|
||||
imageCache.set(url, {
|
||||
blobUrl,
|
||||
status: 'loaded',
|
||||
width: img.naturalWidth,
|
||||
height: img.naturalHeight,
|
||||
})
|
||||
setCachedImages((prev) => {
|
||||
return {
|
||||
...prev,
|
||||
[url]: {
|
||||
blobUrl,
|
||||
status: 'loaded',
|
||||
width: img.naturalWidth,
|
||||
height: img.naturalHeight,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
catch {
|
||||
if (isMounted.current) {
|
||||
setCachedImages((prev) => {
|
||||
return {
|
||||
...prev,
|
||||
[url]: {
|
||||
status: 'error',
|
||||
width: 0,
|
||||
height: 0,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
isMounted.current = true
|
||||
|
||||
images.forEach((image) => {
|
||||
fetchImage(image)
|
||||
})
|
||||
|
||||
return () => {
|
||||
isMounted.current = false
|
||||
// Cleanup released blob URLs not in current list
|
||||
imageCache.forEach(({ blobUrl }, key) => {
|
||||
if (blobUrl)
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
imageCache.delete(key)
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
const currentImage = useMemo(() => {
|
||||
return images[currentIndex]
|
||||
}, [images, currentIndex])
|
||||
|
||||
const prevImage = useCallback(() => {
|
||||
if (currentIndex === 0)
|
||||
return
|
||||
setCurrentIndex(prevIndex => prevIndex - 1)
|
||||
}, [currentIndex])
|
||||
|
||||
const nextImage = useCallback(() => {
|
||||
if (currentIndex === images.length - 1)
|
||||
return
|
||||
setCurrentIndex(prevIndex => prevIndex + 1)
|
||||
}, [currentIndex, images.length])
|
||||
|
||||
const retryImage = useCallback((image: ImageInfo) => {
|
||||
setCachedImages((prev) => {
|
||||
return {
|
||||
...prev,
|
||||
[image.url]: {
|
||||
...prev[image.url],
|
||||
status: 'loading',
|
||||
},
|
||||
}
|
||||
})
|
||||
fetchImage(image)
|
||||
}, [fetchImage])
|
||||
|
||||
useHotkeys('esc', onClose)
|
||||
useHotkeys('left', prevImage)
|
||||
useHotkeys('right', nextImage)
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className='image-previewer fixed inset-0 z-[10000] flex items-center justify-center bg-background-overlay-fullscreen p-5 pb-4 backdrop-blur-[6px]'
|
||||
onClick={e => e.stopPropagation()}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className='absolute right-6 top-6 z-10 flex cursor-pointer flex-col items-center gap-y-1'>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
onClick={onClose}
|
||||
className='size-9 rounded-[10px] p-0'
|
||||
size='large'
|
||||
>
|
||||
<RiCloseLine className='size-5' />
|
||||
</Button>
|
||||
<span className='system-2xs-medium-uppercase text-text-tertiary'>
|
||||
Esc
|
||||
</span>
|
||||
</div>
|
||||
{cachedImages[currentImage.url].status === 'loading' && (
|
||||
<Loading type='app' />
|
||||
)}
|
||||
{cachedImages[currentImage.url].status === 'error' && (
|
||||
<div className='system-sm-regular flex max-w-sm flex-col items-center gap-y-2 text-text-tertiary'>
|
||||
<span>{`Failed to load image: ${currentImage.url}. Please try again.`}</span>
|
||||
<Button
|
||||
variant='secondary'
|
||||
onClick={() => retryImage(currentImage)}
|
||||
className='size-9 rounded-full p-0'
|
||||
size='large'
|
||||
>
|
||||
<RiRefreshLine className='size-5' />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{cachedImages[currentImage.url].status === 'loaded' && (
|
||||
<div className='flex size-full flex-col items-center justify-center gap-y-2'>
|
||||
<img
|
||||
alt={currentImage.name}
|
||||
src={cachedImages[currentImage.url].blobUrl}
|
||||
className='max-h-[calc(100%-2.5rem)] max-w-full object-contain shadow-lg ring-8 ring-effects-image-frame backdrop-blur-[5px]'
|
||||
/>
|
||||
<div className='system-sm-regular flex shrink-0 gap-x-2 pb-1 pt-3 text-text-tertiary'>
|
||||
<span>{currentImage.name}</span>
|
||||
<span>·</span>
|
||||
<span>{`${cachedImages[currentImage.url].width} × ${cachedImages[currentImage.url].height}`}</span>
|
||||
<span>·</span>
|
||||
<span>{formatFileSize(currentImage.size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant='secondary'
|
||||
onClick={prevImage}
|
||||
className='absolute left-8 top-1/2 z-10 size-9 -translate-y-1/2 rounded-full p-0'
|
||||
disabled={currentIndex === 0}
|
||||
size='large'
|
||||
>
|
||||
<RiArrowLeftLine className='size-5' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='secondary'
|
||||
onClick={nextImage}
|
||||
className='absolute right-8 top-1/2 z-10 size-9 -translate-y-1/2 rounded-full p-0'
|
||||
disabled={currentIndex === images.length - 1}
|
||||
size='large'
|
||||
>
|
||||
<RiArrowRightLine className='size-5' />
|
||||
</Button>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
export default ImagePreviewer
|
||||
@@ -0,0 +1,7 @@
|
||||
export const ACCEPT_TYPES = ['jpg', 'jpeg', 'png', 'gif']
|
||||
|
||||
export const DEFAULT_IMAGE_FILE_SIZE_LIMIT = 2
|
||||
|
||||
export const DEFAULT_IMAGE_FILE_BATCH_LIMIT = 5
|
||||
|
||||
export const DEFAULT_SINGLE_CHUNK_ATTACHMENT_LIMIT = 10
|
||||
@@ -0,0 +1,273 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useFileUploadConfig } from '@/service/use-common'
|
||||
import type { FileEntity, FileUploadConfig } from '../types'
|
||||
import { getFileType, getFileUploadConfig, traverseFileEntry } from '../utils'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ACCEPT_TYPES } from '../constants'
|
||||
import { useFileStore } from '../store'
|
||||
import { produce } from 'immer'
|
||||
import { fileUpload, getFileUploadErrorMessage } from '@/app/components/base/file-uploader/utils'
|
||||
import { v4 as uuid4 } from 'uuid'
|
||||
|
||||
export const useUpload = () => {
|
||||
const { t } = useTranslation()
|
||||
const fileStore = useFileStore()
|
||||
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const uploaderRef = useRef<HTMLInputElement>(null)
|
||||
const dragRef = useRef<HTMLDivElement>(null)
|
||||
const dropRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const { data: fileUploadConfigResponse } = useFileUploadConfig()
|
||||
|
||||
const fileUploadConfig: FileUploadConfig = useMemo(() => {
|
||||
return getFileUploadConfig(fileUploadConfigResponse)
|
||||
}, [fileUploadConfigResponse])
|
||||
|
||||
const handleDragEnter = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (e.target !== dragRef.current)
|
||||
setDragging(true)
|
||||
}
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
const handleDragLeave = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (e.target === dragRef.current)
|
||||
setDragging(false)
|
||||
}
|
||||
|
||||
const checkFileType = useCallback((file: File) => {
|
||||
const ext = getFileType(file)
|
||||
return ACCEPT_TYPES.includes(ext.toLowerCase())
|
||||
}, [])
|
||||
|
||||
const checkFileSize = useCallback((file: File) => {
|
||||
const { size } = file
|
||||
return size <= fileUploadConfig.imageFileSizeLimit * 1024 * 1024
|
||||
}, [fileUploadConfig])
|
||||
|
||||
const showErrorMessage = useCallback((type: 'type' | 'size') => {
|
||||
if (type === 'type')
|
||||
Toast.notify({ type: 'error', message: t('common.fileUploader.fileExtensionNotSupport') })
|
||||
else
|
||||
Toast.notify({ type: 'error', message: t('dataset.imageUploader.fileSizeLimitExceeded', { size: fileUploadConfig.imageFileSizeLimit }) })
|
||||
}, [fileUploadConfig, t])
|
||||
|
||||
const getValidFiles = useCallback((files: File[]) => {
|
||||
let validType = true
|
||||
let validSize = true
|
||||
const validFiles = files.filter((file) => {
|
||||
if (!checkFileType(file)) {
|
||||
validType = false
|
||||
return false
|
||||
}
|
||||
if (!checkFileSize(file)) {
|
||||
validSize = false
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
if (!validType)
|
||||
showErrorMessage('type')
|
||||
else if (!validSize)
|
||||
showErrorMessage('size')
|
||||
|
||||
return validFiles
|
||||
}, [checkFileType, checkFileSize, showErrorMessage])
|
||||
|
||||
const selectHandle = () => {
|
||||
if (uploaderRef.current)
|
||||
uploaderRef.current.click()
|
||||
}
|
||||
|
||||
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)
|
||||
Toast.notify({ type: 'error', message: errorMessage })
|
||||
handleUpdateFile({ ...uploadingFile, progress: -1 })
|
||||
},
|
||||
})
|
||||
}
|
||||
}, [fileStore, t, handleUpdateFile])
|
||||
|
||||
const handleLocalFileUpload = useCallback((file: File) => {
|
||||
const reader = new FileReader()
|
||||
const isImage = file.type.startsWith('image')
|
||||
|
||||
reader.addEventListener(
|
||||
'load',
|
||||
() => {
|
||||
const uploadingFile = {
|
||||
id: uuid4(),
|
||||
name: file.name,
|
||||
extension: getFileType(file),
|
||||
mimeType: file.type,
|
||||
size: file.size,
|
||||
progress: 0,
|
||||
originalFile: file,
|
||||
base64Url: isImage ? reader.result as string : '',
|
||||
}
|
||||
handleAddFile(uploadingFile)
|
||||
fileUpload({
|
||||
file: uploadingFile.originalFile,
|
||||
onProgressCallback: (progress) => {
|
||||
handleUpdateFile({ ...uploadingFile, progress })
|
||||
},
|
||||
onSuccessCallback: (res) => {
|
||||
handleUpdateFile({
|
||||
...uploadingFile,
|
||||
extension: res.extension,
|
||||
mimeType: res.mime_type,
|
||||
size: res.size,
|
||||
uploadedId: res.id,
|
||||
progress: 100,
|
||||
})
|
||||
},
|
||||
onErrorCallback: (error?: any) => {
|
||||
const errorMessage = getFileUploadErrorMessage(error, t('common.fileUploader.uploadFromComputerUploadError'), t)
|
||||
Toast.notify({ type: 'error', message: errorMessage })
|
||||
handleUpdateFile({ ...uploadingFile, progress: -1 })
|
||||
},
|
||||
})
|
||||
},
|
||||
false,
|
||||
)
|
||||
reader.addEventListener(
|
||||
'error',
|
||||
() => {
|
||||
Toast.notify({ type: 'error', message: t('common.fileUploader.uploadFromComputerReadError') })
|
||||
},
|
||||
false,
|
||||
)
|
||||
reader.readAsDataURL(file)
|
||||
}, [t, handleAddFile, handleUpdateFile])
|
||||
|
||||
const handleFileUpload = useCallback((newFiles: File[]) => {
|
||||
const { files } = fileStore.getState()
|
||||
const { singleChunkAttachmentLimit } = fileUploadConfig
|
||||
if (newFiles.length === 0) return
|
||||
if (files.length + newFiles.length > singleChunkAttachmentLimit) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('datasetHitTesting.imageUploader.singleChunkAttachmentLimitTooltip', { limit: singleChunkAttachmentLimit }),
|
||||
})
|
||||
return
|
||||
}
|
||||
for (const file of newFiles)
|
||||
handleLocalFileUpload(file)
|
||||
}, [fileUploadConfig, fileStore, t, handleLocalFileUpload])
|
||||
|
||||
const fileChangeHandle = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { imageFileBatchLimit } = fileUploadConfig
|
||||
const files = Array.from(e.target.files ?? []).slice(0, imageFileBatchLimit)
|
||||
const validFiles = getValidFiles(files)
|
||||
handleFileUpload(validFiles)
|
||||
}, [getValidFiles, handleFileUpload, fileUploadConfig])
|
||||
|
||||
const handleDrop = useCallback(async (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragging(false)
|
||||
if (!e.dataTransfer) return
|
||||
const nested = await Promise.all(
|
||||
Array.from(e.dataTransfer.items).map((it) => {
|
||||
const entry = (it as any).webkitGetAsEntry?.()
|
||||
if (entry) return traverseFileEntry(entry)
|
||||
const f = it.getAsFile?.()
|
||||
return f ? Promise.resolve([f]) : Promise.resolve([])
|
||||
}),
|
||||
)
|
||||
const files = nested.flat().slice(0, fileUploadConfig.imageFileBatchLimit)
|
||||
const validFiles = getValidFiles(files)
|
||||
handleFileUpload(validFiles)
|
||||
}, [fileUploadConfig, handleFileUpload, getValidFiles])
|
||||
|
||||
useEffect(() => {
|
||||
dropRef.current?.addEventListener('dragenter', handleDragEnter)
|
||||
dropRef.current?.addEventListener('dragover', handleDragOver)
|
||||
dropRef.current?.addEventListener('dragleave', handleDragLeave)
|
||||
dropRef.current?.addEventListener('drop', handleDrop)
|
||||
return () => {
|
||||
dropRef.current?.removeEventListener('dragenter', handleDragEnter)
|
||||
dropRef.current?.removeEventListener('dragover', handleDragOver)
|
||||
dropRef.current?.removeEventListener('dragleave', handleDragLeave)
|
||||
dropRef.current?.removeEventListener('drop', handleDrop)
|
||||
}
|
||||
}, [handleDrop])
|
||||
|
||||
return {
|
||||
dragging,
|
||||
fileUploadConfig,
|
||||
dragRef,
|
||||
dropRef,
|
||||
uploaderRef,
|
||||
fileChangeHandle,
|
||||
selectHandle,
|
||||
handleRemoveFile,
|
||||
handleReUploadFile,
|
||||
handleLocalFileUpload,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import React from 'react'
|
||||
import cn from '@/utils/classnames'
|
||||
import { RiUploadCloud2Line } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useUpload } from '../hooks/use-upload'
|
||||
import { ACCEPT_TYPES } from '../constants'
|
||||
|
||||
const ImageUploader = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const {
|
||||
dragging,
|
||||
fileUploadConfig,
|
||||
dragRef,
|
||||
dropRef,
|
||||
uploaderRef,
|
||||
fileChangeHandle,
|
||||
selectHandle,
|
||||
} = useUpload()
|
||||
|
||||
return (
|
||||
<div className='w-full'>
|
||||
<input
|
||||
ref={uploaderRef}
|
||||
id='fileUploader'
|
||||
className='hidden'
|
||||
type='file'
|
||||
multiple
|
||||
accept={ACCEPT_TYPES.map(ext => `.${ext}`).join(',')}
|
||||
onChange={fileChangeHandle}
|
||||
/>
|
||||
<div
|
||||
ref={dropRef}
|
||||
className={cn(
|
||||
'relative flex h-16 flex-col items-center justify-center gap-1 rounded-[10px] border border-dashed border-components-dropzone-border bg-components-dropzone-bg px-4 py-3 text-text-tertiary',
|
||||
dragging && 'border-components-dropzone-border-accent bg-components-dropzone-bg-accent',
|
||||
)}
|
||||
>
|
||||
<div className='system-sm-medium flex items-center justify-center gap-x-2 text-text-secondary'>
|
||||
<RiUploadCloud2Line className='size-5 text-text-tertiary' />
|
||||
<div>
|
||||
<span>{t('dataset.imageUploader.button')}</span>
|
||||
<span
|
||||
className='ml-1 cursor-pointer text-text-accent'
|
||||
onClick={selectHandle}
|
||||
>
|
||||
{t('dataset.imageUploader.browse')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='system-xs-regular'>
|
||||
{t('dataset.imageUploader.tip', {
|
||||
size: fileUploadConfig.imageFileSizeLimit,
|
||||
supportTypes: ACCEPT_TYPES.join(', '),
|
||||
batchCount: fileUploadConfig.imageFileBatchLimit,
|
||||
})}
|
||||
</div>
|
||||
{dragging && <div ref={dragRef} className='absolute inset-0' />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(ImageUploader)
|
||||
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
} from 'react'
|
||||
import {
|
||||
RiCloseLine,
|
||||
} from '@remixicon/react'
|
||||
import FileImageRender from '@/app/components/base/file-uploader/file-image-render'
|
||||
import type { FileEntity } from '../types'
|
||||
import ProgressCircle from '@/app/components/base/progress-bar/progress-circle'
|
||||
import { ReplayLine } from '@/app/components/base/icons/src/vender/other'
|
||||
import { fileIsUploaded } from '../utils'
|
||||
import Button from '@/app/components/base/button'
|
||||
|
||||
type ImageItemProps = {
|
||||
file: FileEntity
|
||||
showDeleteAction?: boolean
|
||||
onRemove?: (fileId: string) => void
|
||||
onReUpload?: (fileId: string) => void
|
||||
onPreview?: (fileId: string) => void
|
||||
}
|
||||
const ImageItem = ({
|
||||
file,
|
||||
showDeleteAction,
|
||||
onRemove,
|
||||
onReUpload,
|
||||
onPreview,
|
||||
}: ImageItemProps) => {
|
||||
const { id, progress, base64Url, sourceUrl } = file
|
||||
|
||||
const handlePreview = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
onPreview?.(id)
|
||||
}, [onPreview, id])
|
||||
|
||||
const handleRemove = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
onRemove?.(id)
|
||||
}, [onRemove, id])
|
||||
|
||||
const handleReUpload = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
onReUpload?.(id)
|
||||
}, [onReUpload, id])
|
||||
|
||||
return (
|
||||
<div
|
||||
className='group/file-image relative cursor-pointer'
|
||||
onClick={handlePreview}
|
||||
>
|
||||
{
|
||||
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={handleRemove}
|
||||
>
|
||||
<RiCloseLine className='h-4 w-4 text-components-button-secondary-text' />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
<FileImageRender
|
||||
className='h-[68px] w-[68px] shadow-md'
|
||||
imageUrl={base64Url || sourceUrl || ''}
|
||||
/>
|
||||
{
|
||||
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'
|
||||
onClick={handleReUpload}
|
||||
>
|
||||
<ReplayLine className='size-5 text-text-primary-on-surface' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ImageItem)
|
||||
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
FileContextProvider,
|
||||
useFileStoreWithSelector,
|
||||
} from '../store'
|
||||
import type { FileEntity } from '../types'
|
||||
import FileItem from './image-item'
|
||||
import { useUpload } from '../hooks/use-upload'
|
||||
import ImageInput from './image-input'
|
||||
import cn from '@/utils/classnames'
|
||||
import { useCallback, useState } from 'react'
|
||||
import type { ImageInfo } from '@/app/components/datasets/common/image-previewer'
|
||||
import ImagePreviewer from '@/app/components/datasets/common/image-previewer'
|
||||
|
||||
type ImageUploaderInChunkProps = {
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
const ImageUploaderInChunk = ({
|
||||
disabled,
|
||||
className,
|
||||
}: ImageUploaderInChunkProps) => {
|
||||
const files = useFileStoreWithSelector(s => s.files)
|
||||
const [previewIndex, setPreviewIndex] = useState(0)
|
||||
const [previewImages, setPreviewImages] = useState<ImageInfo[]>([])
|
||||
|
||||
const handleImagePreview = useCallback((fileId: string) => {
|
||||
const index = files.findIndex(item => item.id === fileId)
|
||||
if (index === -1) return
|
||||
setPreviewIndex(index)
|
||||
setPreviewImages(files.map(item => ({
|
||||
url: item.base64Url || item.sourceUrl || '',
|
||||
name: item.name,
|
||||
size: item.size,
|
||||
})))
|
||||
}, [files])
|
||||
|
||||
const handleClosePreview = useCallback(() => {
|
||||
setPreviewImages([])
|
||||
}, [])
|
||||
|
||||
const {
|
||||
handleRemoveFile,
|
||||
handleReUploadFile,
|
||||
} = useUpload()
|
||||
|
||||
return (
|
||||
<div className={cn('w-full', className)}>
|
||||
{!disabled && <ImageInput />}
|
||||
<div className='flex flex-wrap gap-2 py-1'>
|
||||
{
|
||||
files.map(file => (
|
||||
<FileItem
|
||||
key={file.id}
|
||||
file={file}
|
||||
showDeleteAction={!disabled}
|
||||
onRemove={handleRemoveFile}
|
||||
onReUpload={handleReUploadFile}
|
||||
onPreview={handleImagePreview}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
{previewImages.length > 0 && (
|
||||
<ImagePreviewer
|
||||
images={previewImages}
|
||||
initialIndex={previewIndex}
|
||||
onClose={handleClosePreview}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type ImageUploaderInChunkWrapperProps = {
|
||||
value?: FileEntity[]
|
||||
onChange: (files: FileEntity[]) => void
|
||||
} & ImageUploaderInChunkProps
|
||||
|
||||
const ImageUploaderInChunkWrapper = ({
|
||||
value,
|
||||
onChange,
|
||||
...props
|
||||
}: ImageUploaderInChunkWrapperProps) => {
|
||||
return (
|
||||
<FileContextProvider
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
>
|
||||
<ImageUploaderInChunk {...props} />
|
||||
</FileContextProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default ImageUploaderInChunkWrapper
|
||||
@@ -0,0 +1,64 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useUpload } from '../hooks/use-upload'
|
||||
import { ACCEPT_TYPES } from '../constants'
|
||||
import { useFileStoreWithSelector } from '../store'
|
||||
import { RiImageAddLine } from '@remixicon/react'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
|
||||
const ImageUploader = () => {
|
||||
const { t } = useTranslation()
|
||||
const files = useFileStoreWithSelector(s => s.files)
|
||||
|
||||
const {
|
||||
fileUploadConfig,
|
||||
uploaderRef,
|
||||
fileChangeHandle,
|
||||
selectHandle,
|
||||
} = useUpload()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
ref={uploaderRef}
|
||||
id='fileUploader'
|
||||
className='hidden'
|
||||
type='file'
|
||||
multiple
|
||||
accept={ACCEPT_TYPES.map(ext => `.${ext}`).join(',')}
|
||||
onChange={fileChangeHandle}
|
||||
/>
|
||||
<div className='flex flex-wrap gap-1'>
|
||||
<Tooltip
|
||||
popupContent={t('datasetHitTesting.imageUploader.tooltip', {
|
||||
size: fileUploadConfig.imageFileSizeLimit,
|
||||
batchCount: fileUploadConfig.imageFileBatchLimit,
|
||||
})}
|
||||
popupClassName='system-xs-medium p-1.5 rounded-lg text-text-secondary'
|
||||
position='top'
|
||||
offset={4}
|
||||
disabled={files.length === 0}
|
||||
>
|
||||
<div
|
||||
className='group flex cursor-pointer items-center gap-x-2'
|
||||
onClick={selectHandle}
|
||||
>
|
||||
<div className='flex size-8 items-center justify-center rounded-lg border-[1px] border-dashed border-components-dropzone-border bg-components-button-tertiary-bg group-hover:bg-components-button-tertiary-bg-hover'>
|
||||
<RiImageAddLine className='size-4 text-text-tertiary' />
|
||||
</div>
|
||||
{files.length === 0 && (
|
||||
<span className='system-sm-regular text-text-quaternary group-hover:text-text-tertiary'>
|
||||
{t('datasetHitTesting.imageUploader.tip', {
|
||||
size: fileUploadConfig.imageFileSizeLimit,
|
||||
batchCount: fileUploadConfig.imageFileBatchLimit,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(ImageUploader)
|
||||
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
} from 'react'
|
||||
import {
|
||||
RiCloseLine,
|
||||
} from '@remixicon/react'
|
||||
import FileImageRender from '@/app/components/base/file-uploader/file-image-render'
|
||||
import type { FileEntity } from '../types'
|
||||
import ProgressCircle from '@/app/components/base/progress-bar/progress-circle'
|
||||
import { ReplayLine } from '@/app/components/base/icons/src/vender/other'
|
||||
import { fileIsUploaded } from '../utils'
|
||||
import Button from '@/app/components/base/button'
|
||||
|
||||
type ImageItemProps = {
|
||||
file: FileEntity
|
||||
showDeleteAction?: boolean
|
||||
onRemove?: (fileId: string) => void
|
||||
onReUpload?: (fileId: string) => void
|
||||
onPreview?: (fileId: string) => void
|
||||
}
|
||||
const ImageItem = ({
|
||||
file,
|
||||
showDeleteAction,
|
||||
onRemove,
|
||||
onReUpload,
|
||||
onPreview,
|
||||
}: ImageItemProps) => {
|
||||
const { id, progress, base64Url, sourceUrl } = file
|
||||
|
||||
const handlePreview = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
onPreview?.(id)
|
||||
}, [onPreview, id])
|
||||
|
||||
const handleRemove = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
onRemove?.(id)
|
||||
}, [onRemove, id])
|
||||
|
||||
const handleReUpload = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
onReUpload?.(id)
|
||||
}, [onReUpload, id])
|
||||
|
||||
return (
|
||||
<div
|
||||
className='group/file-image relative cursor-pointer'
|
||||
onClick={handlePreview}
|
||||
>
|
||||
{
|
||||
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={handleRemove}
|
||||
>
|
||||
<RiCloseLine className='h-4 w-4 text-components-button-secondary-text' />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
<FileImageRender
|
||||
className='size-20 shadow-md'
|
||||
imageUrl={base64Url || sourceUrl || ''}
|
||||
/>
|
||||
{
|
||||
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'
|
||||
onClick={handleReUpload}
|
||||
>
|
||||
<ReplayLine className='size-5 text-text-primary-on-surface' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ImageItem)
|
||||
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
useCallback,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
FileContextProvider,
|
||||
} from '../store'
|
||||
import type { FileEntity } from '../types'
|
||||
import { useUpload } from '../hooks/use-upload'
|
||||
import ImageInput from './image-input'
|
||||
import cn from '@/utils/classnames'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useFileStoreWithSelector } from '../store'
|
||||
import ImageItem from './image-item'
|
||||
import type { ImageInfo } from '@/app/components/datasets/common/image-previewer'
|
||||
import ImagePreviewer from '@/app/components/datasets/common/image-previewer'
|
||||
|
||||
type ImageUploaderInRetrievalTestingProps = {
|
||||
textArea: React.ReactNode
|
||||
actionButton: React.ReactNode
|
||||
showUploader?: boolean
|
||||
className?: string
|
||||
actionAreaClassName?: string
|
||||
}
|
||||
const ImageUploaderInRetrievalTesting = ({
|
||||
textArea,
|
||||
actionButton,
|
||||
showUploader = true,
|
||||
className,
|
||||
actionAreaClassName,
|
||||
}: ImageUploaderInRetrievalTestingProps) => {
|
||||
const { t } = useTranslation()
|
||||
const files = useFileStoreWithSelector(s => s.files)
|
||||
const [previewIndex, setPreviewIndex] = useState(0)
|
||||
const [previewImages, setPreviewImages] = useState<ImageInfo[]>([])
|
||||
const {
|
||||
dragging,
|
||||
dragRef,
|
||||
dropRef,
|
||||
handleRemoveFile,
|
||||
handleReUploadFile,
|
||||
} = useUpload()
|
||||
|
||||
const handleImagePreview = useCallback((fileId: string) => {
|
||||
const index = files.findIndex(item => item.id === fileId)
|
||||
if (index === -1) return
|
||||
setPreviewIndex(index)
|
||||
setPreviewImages(files.map(item => ({
|
||||
url: item.base64Url || item.sourceUrl || '',
|
||||
name: item.name,
|
||||
size: item.size,
|
||||
})))
|
||||
}, [files])
|
||||
|
||||
const handleClosePreview = useCallback(() => {
|
||||
setPreviewImages([])
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dropRef}
|
||||
className={cn('relative flex w-full flex-col', className)}
|
||||
>
|
||||
{dragging && (
|
||||
<div
|
||||
className='absolute inset-0.5 z-10 flex items-center justify-center rounded-lg border-[1.5px] border-dashed border-components-dropzone-border-accent bg-components-dropzone-bg-accent'
|
||||
>
|
||||
<div>{t('datasetHitTesting.imageUploader.dropZoneTip')}</div>
|
||||
<div ref={dragRef} className='absolute inset-0' />
|
||||
</div>
|
||||
)}
|
||||
{textArea}
|
||||
{
|
||||
showUploader && !!files.length && (
|
||||
<div className='flex flex-wrap gap-1 bg-background-default px-4 py-2'>
|
||||
{
|
||||
files.map(file => (
|
||||
<ImageItem
|
||||
key={file.id}
|
||||
file={file}
|
||||
showDeleteAction
|
||||
onRemove={handleRemoveFile}
|
||||
onReUpload={handleReUploadFile}
|
||||
onPreview={handleImagePreview}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div
|
||||
className={cn(
|
||||
'flex',
|
||||
showUploader ? 'justify-between' : 'justify-end',
|
||||
actionAreaClassName,
|
||||
)}>
|
||||
{showUploader && <ImageInput />}
|
||||
{actionButton}
|
||||
</div>
|
||||
{previewImages.length > 0 && (
|
||||
<ImagePreviewer
|
||||
images={previewImages}
|
||||
initialIndex={previewIndex}
|
||||
onClose={handleClosePreview}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type ImageUploaderInRetrievalTestingWrapperProps = {
|
||||
value?: FileEntity[]
|
||||
onChange: (files: FileEntity[]) => void
|
||||
} & ImageUploaderInRetrievalTestingProps
|
||||
|
||||
const ImageUploaderInRetrievalTestingWrapper = ({
|
||||
value,
|
||||
onChange,
|
||||
...props
|
||||
}: ImageUploaderInRetrievalTestingWrapperProps) => {
|
||||
return (
|
||||
<FileContextProvider
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
>
|
||||
<ImageUploaderInRetrievalTesting {...props} />
|
||||
</FileContextProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default ImageUploaderInRetrievalTestingWrapper
|
||||
67
web/app/components/datasets/common/image-uploader/store.tsx
Normal file
67
web/app/components/datasets/common/image-uploader/store.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useRef,
|
||||
} from 'react'
|
||||
import {
|
||||
create,
|
||||
useStore,
|
||||
} 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 useFileStoreWithSelector<T>(selector: (state: Shape) => T): T {
|
||||
const store = useContext(FileContext)
|
||||
if (!store)
|
||||
throw new Error('Missing FileContext.Provider in the tree')
|
||||
|
||||
return useStore(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>
|
||||
)
|
||||
}
|
||||
18
web/app/components/datasets/common/image-uploader/types.ts
Normal file
18
web/app/components/datasets/common/image-uploader/types.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export type FileEntity = {
|
||||
id: string
|
||||
name: string
|
||||
size: number
|
||||
extension: string
|
||||
mimeType: string
|
||||
progress: number // -1: error, 0 ~ 99: uploading, 100: uploaded
|
||||
originalFile?: File // used for re-uploading
|
||||
uploadedId?: string // for uploaded image id
|
||||
sourceUrl?: string // for uploaded image
|
||||
base64Url?: string // for image preview during uploading
|
||||
}
|
||||
|
||||
export type FileUploadConfig = {
|
||||
imageFileSizeLimit: number
|
||||
imageFileBatchLimit: number
|
||||
singleChunkAttachmentLimit: number
|
||||
}
|
||||
92
web/app/components/datasets/common/image-uploader/utils.ts
Normal file
92
web/app/components/datasets/common/image-uploader/utils.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { FileUploadConfigResponse } from '@/models/common'
|
||||
import type { FileEntity } from './types'
|
||||
import {
|
||||
DEFAULT_IMAGE_FILE_BATCH_LIMIT,
|
||||
DEFAULT_IMAGE_FILE_SIZE_LIMIT,
|
||||
DEFAULT_SINGLE_CHUNK_ATTACHMENT_LIMIT,
|
||||
} from './constants'
|
||||
|
||||
export const getFileType = (currentFile: File) => {
|
||||
if (!currentFile)
|
||||
return ''
|
||||
|
||||
const arr = currentFile.name.split('.')
|
||||
return arr[arr.length - 1]
|
||||
}
|
||||
|
||||
type FileWithPath = {
|
||||
relativePath?: string
|
||||
} & File
|
||||
|
||||
export const traverseFileEntry = (entry: any, prefix = ''): Promise<FileWithPath[]> => {
|
||||
return new Promise((resolve) => {
|
||||
if (entry.isFile) {
|
||||
entry.file((file: FileWithPath) => {
|
||||
file.relativePath = `${prefix}${file.name}`
|
||||
resolve([file])
|
||||
})
|
||||
}
|
||||
else if (entry.isDirectory) {
|
||||
const reader = entry.createReader()
|
||||
const entries: any[] = []
|
||||
const read = () => {
|
||||
reader.readEntries(async (results: FileSystemEntry[]) => {
|
||||
if (!results.length) {
|
||||
const files = await Promise.all(
|
||||
entries.map(ent =>
|
||||
traverseFileEntry(ent, `${prefix}${entry.name}/`),
|
||||
),
|
||||
)
|
||||
resolve(files.flat())
|
||||
}
|
||||
else {
|
||||
entries.push(...results)
|
||||
read()
|
||||
}
|
||||
})
|
||||
}
|
||||
read()
|
||||
}
|
||||
else {
|
||||
resolve([])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const fileIsUploaded = (file: FileEntity) => {
|
||||
if (file.uploadedId || file.progress === 100)
|
||||
return true
|
||||
}
|
||||
|
||||
const getNumberValue = (value: number | string | undefined | null): number => {
|
||||
if (value === undefined || value === null)
|
||||
return 0
|
||||
if (typeof value === 'number')
|
||||
return value
|
||||
if (typeof value === 'string')
|
||||
return Number(value)
|
||||
return 0
|
||||
}
|
||||
|
||||
export const getFileUploadConfig = (fileUploadConfigResponse: FileUploadConfigResponse | undefined) => {
|
||||
if (!fileUploadConfigResponse) {
|
||||
return {
|
||||
imageFileSizeLimit: DEFAULT_IMAGE_FILE_SIZE_LIMIT,
|
||||
imageFileBatchLimit: DEFAULT_IMAGE_FILE_BATCH_LIMIT,
|
||||
singleChunkAttachmentLimit: DEFAULT_SINGLE_CHUNK_ATTACHMENT_LIMIT,
|
||||
}
|
||||
}
|
||||
const {
|
||||
image_file_batch_limit,
|
||||
single_chunk_attachment_limit,
|
||||
attachment_image_file_size_limit,
|
||||
} = fileUploadConfigResponse
|
||||
const imageFileSizeLimit = getNumberValue(attachment_image_file_size_limit)
|
||||
const imageFileBatchLimit = getNumberValue(image_file_batch_limit)
|
||||
const singleChunkAttachmentLimit = getNumberValue(single_chunk_attachment_limit)
|
||||
return {
|
||||
imageFileSizeLimit: imageFileSizeLimit > 0 ? imageFileSizeLimit : DEFAULT_IMAGE_FILE_SIZE_LIMIT,
|
||||
imageFileBatchLimit: imageFileBatchLimit > 0 ? imageFileBatchLimit : DEFAULT_IMAGE_FILE_BATCH_LIMIT,
|
||||
singleChunkAttachmentLimit: singleChunkAttachmentLimit > 0 ? singleChunkAttachmentLimit : DEFAULT_SINGLE_CHUNK_ATTACHMENT_LIMIT,
|
||||
}
|
||||
}
|
||||
@@ -20,12 +20,14 @@ import { EffectColor } from '../../settings/chunk-structure/types'
|
||||
type Props = {
|
||||
disabled?: boolean
|
||||
value: RetrievalConfig
|
||||
showMultiModalTip?: boolean
|
||||
onChange: (value: RetrievalConfig) => void
|
||||
}
|
||||
|
||||
const RetrievalMethodConfig: FC<Props> = ({
|
||||
disabled = false,
|
||||
value,
|
||||
showMultiModalTip = false,
|
||||
onChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
@@ -110,6 +112,7 @@ const RetrievalMethodConfig: FC<Props> = ({
|
||||
type={RETRIEVE_METHOD.semantic}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
showMultiModalTip={showMultiModalTip}
|
||||
/>
|
||||
</OptionCard>
|
||||
)}
|
||||
@@ -132,6 +135,7 @@ const RetrievalMethodConfig: FC<Props> = ({
|
||||
type={RETRIEVE_METHOD.fullText}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
showMultiModalTip={showMultiModalTip}
|
||||
/>
|
||||
</OptionCard>
|
||||
)}
|
||||
@@ -155,6 +159,7 @@ const RetrievalMethodConfig: FC<Props> = ({
|
||||
type={RETRIEVE_METHOD.hybrid}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
showMultiModalTip={showMultiModalTip}
|
||||
/>
|
||||
</OptionCard>
|
||||
)}
|
||||
|
||||
@@ -24,16 +24,19 @@ import {
|
||||
import WeightedScore from '@/app/components/app/configuration/dataset-config/params-config/weighted-score'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import RadioCard from '@/app/components/base/radio-card'
|
||||
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
|
||||
|
||||
type Props = {
|
||||
type: RETRIEVE_METHOD
|
||||
value: RetrievalConfig
|
||||
showMultiModalTip?: boolean
|
||||
onChange: (value: RetrievalConfig) => void
|
||||
}
|
||||
|
||||
const RetrievalParamConfig: FC<Props> = ({
|
||||
type,
|
||||
value,
|
||||
showMultiModalTip = false,
|
||||
onChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
@@ -133,19 +136,32 @@ const RetrievalParamConfig: FC<Props> = ({
|
||||
</div>
|
||||
{
|
||||
value.reranking_enable && (
|
||||
<ModelSelector
|
||||
defaultModel={rerankModel && { provider: rerankModel.provider_name, model: rerankModel.model_name }}
|
||||
modelList={rerankModelList}
|
||||
onSelect={(v) => {
|
||||
onChange({
|
||||
...value,
|
||||
reranking_model: {
|
||||
reranking_provider_name: v.provider,
|
||||
reranking_model_name: v.model,
|
||||
},
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
<ModelSelector
|
||||
defaultModel={rerankModel && { provider: rerankModel.provider_name, model: rerankModel.model_name }}
|
||||
modelList={rerankModelList}
|
||||
onSelect={(v) => {
|
||||
onChange({
|
||||
...value,
|
||||
reranking_model: {
|
||||
reranking_provider_name: v.provider,
|
||||
reranking_model_name: v.model,
|
||||
},
|
||||
})
|
||||
}}
|
||||
/>
|
||||
{showMultiModalTip && (
|
||||
<div className='mt-2 flex h-10 items-center gap-x-0.5 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-2 shadow-xs backdrop-blur-[5px]'>
|
||||
<div className='absolute bottom-0 left-0 right-0 top-0 bg-dataset-warning-message-bg opacity-40' />
|
||||
<div className='p-1'>
|
||||
<AlertTriangle className='size-4 text-text-warning-secondary' />
|
||||
</div>
|
||||
<span className='system-xs-medium text-text-primary'>
|
||||
{t('datasetSettings.form.retrievalSetting.multiModalTip')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
@@ -239,19 +255,32 @@ const RetrievalParamConfig: FC<Props> = ({
|
||||
}
|
||||
{
|
||||
value.reranking_mode !== RerankingModeEnum.WeightedScore && (
|
||||
<ModelSelector
|
||||
defaultModel={rerankModel && { provider: rerankModel.provider_name, model: rerankModel.model_name }}
|
||||
modelList={rerankModelList}
|
||||
onSelect={(v) => {
|
||||
onChange({
|
||||
...value,
|
||||
reranking_model: {
|
||||
reranking_provider_name: v.provider,
|
||||
reranking_model_name: v.model,
|
||||
},
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
<ModelSelector
|
||||
defaultModel={rerankModel && { provider: rerankModel.provider_name, model: rerankModel.model_name }}
|
||||
modelList={rerankModelList}
|
||||
onSelect={(v) => {
|
||||
onChange({
|
||||
...value,
|
||||
reranking_model: {
|
||||
reranking_provider_name: v.provider,
|
||||
reranking_model_name: v.model,
|
||||
},
|
||||
})
|
||||
}}
|
||||
/>
|
||||
{showMultiModalTip && (
|
||||
<div className='mt-2 flex h-10 items-center gap-x-0.5 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-2 shadow-xs backdrop-blur-[5px]'>
|
||||
<div className='absolute bottom-0 left-0 right-0 top-0 bg-dataset-warning-message-bg opacity-40' />
|
||||
<div className='p-1'>
|
||||
<AlertTriangle className='size-4 text-text-warning-secondary' />
|
||||
</div>
|
||||
<span className='system-xs-medium text-text-primary'>
|
||||
{t('datasetSettings.form.retrievalSetting.multiModalTip')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
<div className={cn(!isEconomical && 'mt-4', 'space-between flex space-x-6')}>
|
||||
|
||||
Reference in New Issue
Block a user