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:
Wu Tianwei 2025-12-09 11:44:50 +08:00 committed by GitHub
parent a44b800c85
commit 14d1b3f9b3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
77 changed files with 2932 additions and 579 deletions

View File

@ -77,7 +77,7 @@ const DatasetConfig: FC = () => {
const oldRetrievalConfig = {
top_k,
score_threshold,
reranking_model: (reranking_model.reranking_provider_name && reranking_model.reranking_model_name) ? {
reranking_model: (reranking_model && reranking_model.reranking_provider_name && reranking_model.reranking_model_name) ? {
provider: reranking_model.reranking_provider_name,
model: reranking_model.reranking_model_name,
} : undefined,

View File

@ -13,6 +13,8 @@ import Badge from '@/app/components/base/badge'
import { useKnowledge } from '@/hooks/use-knowledge'
import cn from '@/utils/classnames'
import AppIcon from '@/app/components/base/app-icon'
import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import FeatureIcon from '@/app/components/header/account-setting/model-provider-page/model-selector/feature-icon'
export type ISelectDataSetProps = {
isShow: boolean
@ -121,7 +123,7 @@ const SelectDataSet: FC<ISelectDataSetProps> = ({
<div
key={item.id}
className={cn(
'flex h-10 cursor-pointer items-center justify-between rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg px-2 shadow-xs hover:border-components-panel-border hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-sm',
'flex h-10 cursor-pointer items-center rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg px-2 shadow-xs hover:border-components-panel-border hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-sm',
selected.some(i => i.id === item.id) && 'border-[1.5px] border-components-option-card-option-selected-border bg-state-accent-hover shadow-xs hover:border-components-option-card-option-selected-border hover:bg-state-accent-hover hover:shadow-xs',
!item.embedding_available && 'hover:border-components-panel-border-subtle hover:bg-components-panel-on-panel-item-bg hover:shadow-xs',
)}
@ -131,7 +133,7 @@ const SelectDataSet: FC<ISelectDataSetProps> = ({
toggleSelect(item)
}}
>
<div className='mr-1 flex items-center overflow-hidden'>
<div className='mr-1 flex grow items-center overflow-hidden'>
<div className={cn('mr-2', !item.embedding_available && 'opacity-30')}>
<AppIcon
size='tiny'
@ -146,6 +148,11 @@ const SelectDataSet: FC<ISelectDataSetProps> = ({
<span className='ml-1 shrink-0 rounded-md border border-divider-deep px-1 text-xs font-normal leading-[18px] text-text-tertiary'>{t('dataset.unavailable')}</span>
)}
</div>
{item.is_multimodal && (
<div className='mr-1 shrink-0'>
<FeatureIcon feature={ModelFeatureEnum.vision} />
</div>
)}
{
item.indexing_technique && (
<Badge

View File

@ -1,5 +1,5 @@
import type { FC } from 'react'
import { useRef, useState } from 'react'
import { useMemo, useRef, useState } from 'react'
import { useMount } from 'ahooks'
import { useTranslation } from 'react-i18next'
import { isEqual } from 'lodash-es'
@ -25,15 +25,13 @@ import { isReRankModelSelected } from '@/app/components/datasets/common/check-re
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
import PermissionSelector from '@/app/components/datasets/settings/permission-selector'
import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector'
import {
useModelList,
useModelListAndDefaultModelAndCurrentProviderAndModel,
} from '@/app/components/header/account-setting/model-provider-page/hooks'
import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { fetchMembers } from '@/service/common'
import type { Member } from '@/models/common'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { useDocLink } from '@/context/i18n'
import { checkShowMultiModalTip } from '@/app/components/datasets/settings/utils'
type SettingsModalProps = {
currentDataset: DataSet
@ -54,10 +52,8 @@ const SettingsModal: FC<SettingsModalProps> = ({
onCancel,
onSave,
}) => {
const { data: embeddingsModelList } = useModelList(ModelTypeEnum.textEmbedding)
const {
modelList: rerankModelList,
} = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.rerank)
const { data: embeddingModelList } = useModelList(ModelTypeEnum.textEmbedding)
const { data: rerankModelList } = useModelList(ModelTypeEnum.rerank)
const { t } = useTranslation()
const docLink = useDocLink()
const { notify } = useToastContext()
@ -181,6 +177,23 @@ const SettingsModal: FC<SettingsModalProps> = ({
getMembers()
})
const showMultiModalTip = useMemo(() => {
return checkShowMultiModalTip({
embeddingModel: {
provider: localeCurrentDataset.embedding_model_provider,
model: localeCurrentDataset.embedding_model,
},
rerankingEnable: retrievalConfig.reranking_enable,
rerankModel: {
rerankingProviderName: retrievalConfig.reranking_model.reranking_provider_name,
rerankingModelName: retrievalConfig.reranking_model.reranking_model_name,
},
indexMethod,
embeddingModelList,
rerankModelList,
})
}, [localeCurrentDataset.embedding_model, localeCurrentDataset.embedding_model_provider, retrievalConfig.reranking_enable, retrievalConfig.reranking_model, indexMethod, embeddingModelList, rerankModelList])
return (
<div
className='flex w-full flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl'
@ -273,7 +286,7 @@ const SettingsModal: FC<SettingsModalProps> = ({
provider: localeCurrentDataset.embedding_model_provider,
model: localeCurrentDataset.embedding_model,
}}
modelList={embeddingsModelList}
modelList={embeddingModelList}
/>
</div>
<div className='mt-2 w-full text-xs leading-6 text-text-tertiary'>
@ -344,6 +357,7 @@ const SettingsModal: FC<SettingsModalProps> = ({
<RetrievalMethodConfig
value={retrievalConfig}
onChange={setRetrievalConfig}
showMultiModalTip={showMultiModalTip}
/>
)
: (

View File

@ -307,7 +307,7 @@ const Configuration: FC = () => {
const oldRetrievalConfig = {
top_k,
score_threshold,
reranking_model: (reranking_model.reranking_provider_name && reranking_model.reranking_model_name) ? {
reranking_model: (reranking_model?.reranking_provider_name && reranking_model?.reranking_model_name) ? {
provider: reranking_model.reranking_provider_name,
model: reranking_model.reranking_model_name,
} : undefined,

View File

@ -0,0 +1,23 @@
import React from 'react'
type ImageRenderProps = {
sourceUrl: string
name: string
}
const ImageRender = ({
sourceUrl,
name,
}: ImageRenderProps) => {
return (
<div className='size-full border-[2px] border-effects-image-frame shadow-xs'>
<img
className='size-full object-cover'
src={sourceUrl}
alt={name}
/>
</div>
)
}
export default React.memo(ImageRender)

View File

@ -0,0 +1,87 @@
import React, { useCallback } from 'react'
import ImageRender from './image-render'
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
import cn from '@/utils/classnames'
import { getFileAppearanceType } from '../file-uploader/utils'
import { FileTypeIcon } from '../file-uploader'
import Tooltip from '../tooltip'
const FileThumbVariants = cva(
'flex items-center justify-center cursor-pointer',
{
variants: {
size: {
sm: 'size-6',
md: 'size-8',
},
},
defaultVariants: {
size: 'sm',
},
},
)
export type FileEntity = {
name: string
size: number
extension: string
mimeType: string
sourceUrl: string
}
type FileThumbProps = {
file: FileEntity
className?: string
onClick?: (file: FileEntity) => void
} & VariantProps<typeof FileThumbVariants>
const FileThumb = ({
file,
size,
className,
onClick,
}: FileThumbProps) => {
const { name, mimeType, sourceUrl } = file
const isImage = mimeType.startsWith('image/')
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation()
e.preventDefault()
onClick?.(file)
}, [onClick, file])
return (
<Tooltip
popupContent={name}
popupClassName='p-1.5 rounded-lg system-xs-medium text-text-secondary'
position='top'
>
<div
className={cn(
FileThumbVariants({ size, className }),
isImage
? 'p-px'
: 'rounded-md border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg shadow-xs hover:bg-components-panel-on-panel-item-bg-alt',
)}
onClick={handleClick}
>
{
isImage ? (
<ImageRender
sourceUrl={sourceUrl}
name={name}
/>
) : (
<FileTypeIcon
type={getFileAppearanceType(name, mimeType)}
size='sm'
/>
)
}
</div>
</Tooltip>
)
}
export default React.memo(FileThumb)

View File

@ -26,10 +26,21 @@ export const getFileUploadErrorMessage = (error: any, defaultMessage: string, t:
return defaultMessage
}
type FileUploadResponse = {
created_at: number
created_by: string
extension: string
id: string
mime_type: string
name: string
preview_url: string | null
size: number
source_url: string
}
type FileUploadParams = {
file: File
onProgressCallback: (progress: number) => void
onSuccessCallback: (res: { id: string }) => void
onSuccessCallback: (res: FileUploadResponse) => void
onErrorCallback: (error?: any) => void
}
type FileUpload = (v: FileUploadParams, isPublic?: boolean, url?: string) => void
@ -53,8 +64,8 @@ export const fileUpload: FileUpload = ({
data: formData,
onprogress: onProgress,
}, isPublic, url)
.then((res: { id: string }) => {
onSuccessCallback(res)
.then((res) => {
onSuccessCallback(res as FileUploadResponse)
})
.catch((error) => {
onErrorCallback(error)
@ -174,9 +185,9 @@ export const getProcessedFilesFromResponse = (files: FileResponse[]) => {
const detectedTypeFromMime = getSupportFileType('', fileItem.mime_type)
if (detectedTypeFromFileName
&& detectedTypeFromMime
&& detectedTypeFromFileName === detectedTypeFromMime
&& detectedTypeFromFileName !== fileItem.type)
&& detectedTypeFromMime
&& detectedTypeFromFileName === detectedTypeFromMime
&& detectedTypeFromFileName !== fileItem.type)
supportFileType = detectedTypeFromFileName
}

View 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

View 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)

View 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

View File

@ -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

View File

@ -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,
}
}

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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

View 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>
)
}

View 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
}

View 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,
}
}

View File

@ -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>
)}

View File

@ -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')}>

View File

@ -68,11 +68,11 @@ const FileUploader = ({
.join(locale !== LanguagesSupported[1] ? ', ' : '、 ')
})()
const ACCEPTS = supportTypes.map((ext: string) => `.${ext}`)
const fileUploadConfig = useMemo(() => fileUploadConfigResponse ?? {
file_size_limit: 15,
batch_count_limit: 5,
file_upload_limit: 5,
}, [fileUploadConfigResponse])
const fileUploadConfig = useMemo(() => ({
file_size_limit: fileUploadConfigResponse?.file_size_limit ?? 15,
batch_count_limit: fileUploadConfigResponse?.batch_count_limit ?? 5,
file_upload_limit: fileUploadConfigResponse?.file_upload_limit ?? 5,
}), [fileUploadConfigResponse])
const fileListRef = useRef<FileItem[]>([])

View File

@ -1,6 +1,6 @@
'use client'
import type { FC, PropsWithChildren } from 'react'
import React, { useCallback, useEffect, useState } from 'react'
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import {
@ -63,6 +63,7 @@ import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/aler
import { noop } from 'lodash-es'
import { useDocLink } from '@/context/i18n'
import { useInvalidDatasetList } from '@/service/knowledge/use-dataset'
import { checkShowMultiModalTip } from '../../settings/utils'
const TextLabel: FC<PropsWithChildren> = (props) => {
return <label className='system-sm-semibold text-text-secondary'>{props.children}</label>
@ -495,12 +496,6 @@ const StepTwo = ({
setDefaultConfig(data.rules)
setLimitMaxChunkLength(data.limits.indexing_max_segmentation_tokens_length)
},
onError(error) {
Toast.notify({
type: 'error',
message: `${error}`,
})
},
})
const getRulesFromDetail = () => {
@ -538,22 +533,8 @@ const StepTwo = ({
setSegmentationType(documentDetail.dataset_process_rule.mode)
}
const createFirstDocumentMutation = useCreateFirstDocument({
onError(error) {
Toast.notify({
type: 'error',
message: `${error}`,
})
},
})
const createDocumentMutation = useCreateDocument(datasetId!, {
onError(error) {
Toast.notify({
type: 'error',
message: `${error}`,
})
},
})
const createFirstDocumentMutation = useCreateFirstDocument()
const createDocumentMutation = useCreateDocument(datasetId!)
const isCreating = createFirstDocumentMutation.isPending || createDocumentMutation.isPending
const invalidDatasetList = useInvalidDatasetList()
@ -613,6 +594,20 @@ const StepTwo = ({
const isModelAndRetrievalConfigDisabled = !!datasetId && !!currentDataset?.data_source_type
const showMultiModalTip = useMemo(() => {
return checkShowMultiModalTip({
embeddingModel,
rerankingEnable: retrievalConfig.reranking_enable,
rerankModel: {
rerankingProviderName: retrievalConfig.reranking_model.reranking_provider_name,
rerankingModelName: retrievalConfig.reranking_model.reranking_model_name,
},
indexMethod: indexType,
embeddingModelList,
rerankModelList,
})
}, [embeddingModel, retrievalConfig.reranking_enable, retrievalConfig.reranking_model, indexType, embeddingModelList, rerankModelList])
return (
<div className='flex h-full w-full'>
<div className={cn('relative h-full w-1/2 overflow-y-auto py-6', isMobile ? 'px-4' : 'px-12')}>
@ -1012,6 +1007,7 @@ const StepTwo = ({
disabled={isModelAndRetrievalConfigDisabled}
value={retrievalConfig}
onChange={setRetrievalConfig}
showMultiModalTip={showMultiModalTip}
/>
)
: (

View File

@ -21,8 +21,6 @@ import dynamic from 'next/dynamic'
const SimplePieChart = dynamic(() => import('@/app/components/base/simple-pie-chart'), { ssr: false })
const FILES_NUMBER_LIMIT = 20
export type LocalFileProps = {
allowedExtensions: string[]
notSupportBatchUpload?: boolean
@ -64,10 +62,11 @@ const LocalFile = ({
.join(locale !== LanguagesSupported[1] ? ', ' : '、 ')
}, [locale, allowedExtensions])
const ACCEPTS = allowedExtensions.map((ext: string) => `.${ext}`)
const fileUploadConfig = useMemo(() => fileUploadConfigResponse ?? {
file_size_limit: 15,
batch_count_limit: 5,
}, [fileUploadConfigResponse])
const fileUploadConfig = useMemo(() => ({
file_size_limit: fileUploadConfigResponse?.file_size_limit ?? 15,
batch_count_limit: fileUploadConfigResponse?.batch_count_limit ?? 5,
file_upload_limit: fileUploadConfigResponse?.file_upload_limit ?? 5,
}), [fileUploadConfigResponse])
const updateFile = useCallback((fileItem: FileItem, progress: number, list: FileItem[]) => {
const { setLocalFileList } = dataSourceStore.getState()
@ -186,11 +185,12 @@ const LocalFile = ({
}, [fileUploadConfig, uploadBatchFiles])
const initialUpload = useCallback((files: File[]) => {
const filesCountLimit = fileUploadConfig.file_upload_limit
if (!files.length)
return false
if (files.length + localFileList.length > FILES_NUMBER_LIMIT && !IS_CE_EDITION) {
notify({ type: 'error', message: t('datasetCreation.stepOne.uploader.validation.filesNumber', { filesNumber: FILES_NUMBER_LIMIT }) })
if (files.length + localFileList.length > filesCountLimit && !IS_CE_EDITION) {
notify({ type: 'error', message: t('datasetCreation.stepOne.uploader.validation.filesNumber', { filesNumber: filesCountLimit }) })
return false
}
@ -203,7 +203,7 @@ const LocalFile = ({
updateFileList(newFiles)
fileListRef.current = newFiles
uploadMultipleFiles(preparedFiles)
}, [updateFileList, uploadMultipleFiles, notify, t, localFileList])
}, [fileUploadConfig.file_upload_limit, localFileList.length, updateFileList, uploadMultipleFiles, notify, t])
const handleDragEnter = (e: DragEvent) => {
e.preventDefault()
@ -250,9 +250,10 @@ const LocalFile = ({
updateFileList([...fileListRef.current])
}
const fileChangeHandle = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const files = [...(e.target.files ?? [])] as File[]
let files = [...(e.target.files ?? [])] as File[]
files = files.slice(0, fileUploadConfig.batch_count_limit)
initialUpload(files.filter(isValid))
}, [isValid, initialUpload])
}, [isValid, initialUpload, fileUploadConfig.batch_count_limit])
const { theme } = useTheme()
const chartColor = useMemo(() => theme === Theme.dark ? '#5289ff' : '#296dff', [theme])
@ -305,6 +306,7 @@ const LocalFile = ({
size: fileUploadConfig.file_size_limit,
supportTypes: supportTypesShowNames,
batchCount: notSupportBatchUpload ? 1 : fileUploadConfig.batch_count_limit,
totalCount: fileUploadConfig.file_upload_limit,
})}</div>
{dragging && <div ref={dragRef} className='absolute left-0 top-0 h-full w-full' />}
</div>

View File

@ -13,6 +13,7 @@ type IActionButtonsProps = {
actionType?: 'edit' | 'add'
handleRegeneration?: () => void
isChildChunk?: boolean
showRegenerationButton?: boolean
}
const ActionButtons: FC<IActionButtonsProps> = ({
@ -22,6 +23,7 @@ const ActionButtons: FC<IActionButtonsProps> = ({
actionType = 'edit',
handleRegeneration,
isChildChunk = false,
showRegenerationButton = true,
}) => {
const { t } = useTranslation()
const docForm = useDocumentContext(s => s.docForm)
@ -54,7 +56,7 @@ const ActionButtons: FC<IActionButtonsProps> = ({
<span className='system-kbd rounded-[4px] bg-components-kbd-bg-gray px-[1px] text-text-tertiary'>ESC</span>
</div>
</Button>
{(isParentChildParagraphMode && actionType === 'edit' && !isChildChunk)
{(isParentChildParagraphMode && actionType === 'edit' && !isChildChunk && showRegenerationButton)
? <Button
onClick={handleRegeneration}
disabled={loading}

View File

@ -42,6 +42,7 @@ const Drawer = ({
if (!panelContent) return false
const chunks = document.querySelectorAll('.chunk-card')
const childChunks = document.querySelectorAll('.child-chunk')
const imagePreviewer = document.querySelector('.image-previewer')
const isClickOnChunk = Array.from(chunks).some((chunk) => {
return chunk && chunk.contains(target)
})
@ -50,7 +51,8 @@ const Drawer = ({
})
const reopenChunkDetail = (currSegment.showModal && isClickOnChildChunk)
|| (currChildChunk.showModal && isClickOnChunk && !isClickOnChildChunk) || (!isClickOnChunk && !isClickOnChildChunk)
return target && !panelContent.contains(target) && (!needCheckChunks || reopenChunkDetail)
const isClickOnImagePreviewer = imagePreviewer && imagePreviewer.contains(target)
return target && !panelContent.contains(target) && (!needCheckChunks || reopenChunkDetail) && !isClickOnImagePreviewer
}, [currSegment, currChildChunk, needCheckChunks])
const onDownCapture = useCallback((e: PointerEvent) => {

View File

@ -28,7 +28,7 @@ const FullScreenDrawer = ({
panelClassName={cn(
fullScreen
? 'w-full'
: 'w-[560px] pb-2 pr-2 pt-16',
: 'w-[568px] pb-2 pr-2 pt-16',
)}
panelContentClassName={cn(
'bg-components-panel-bg',

View File

@ -47,6 +47,7 @@ import {
} from '@/service/knowledge/use-segment'
import { useInvalid } from '@/service/use-base'
import { noop } from 'lodash-es'
import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types'
const DEFAULT_LIMIT = 10
@ -318,9 +319,10 @@ const Completed: FC<ICompletedProps> = ({
question: string,
answer: string,
keywords: string[],
attachments: FileEntity[],
needRegenerate = false,
) => {
const params: SegmentUpdater = { content: '' }
const params: SegmentUpdater = { content: '', attachment_ids: [] }
if (docForm === ChunkingMode.qa) {
if (!question.trim())
return notify({ type: 'error', message: t('datasetDocuments.segment.questionEmpty') })
@ -340,6 +342,13 @@ const Completed: FC<ICompletedProps> = ({
if (keywords.length)
params.keywords = keywords
if (attachments.length) {
const notAllUploaded = attachments.some(item => !item.uploadedId)
if (notAllUploaded)
return notify({ type: 'error', message: t('datasetDocuments.segment.allFilesUploaded') })
params.attachment_ids = attachments.map(item => item.uploadedId!)
}
if (needRegenerate)
params.regenerate_child_chunks = needRegenerate
@ -355,6 +364,7 @@ const Completed: FC<ICompletedProps> = ({
seg.content = res.data.content
seg.sign_content = res.data.sign_content
seg.keywords = res.data.keywords
seg.attachments = res.data.attachments
seg.word_count = res.data.word_count
seg.hit_count = res.data.hit_count
seg.enabled = res.data.enabled

View File

@ -18,6 +18,7 @@ import Badge from '@/app/components/base/badge'
import { isAfter } from '@/utils/time'
import Tooltip from '@/app/components/base/tooltip'
import ChunkContent from './chunk-content'
import ImageList from '@/app/components/datasets/common/image-list'
type ISegmentCardProps = {
loading: boolean
@ -67,6 +68,7 @@ const SegmentCard: FC<ISegmentCardProps> = ({
child_chunks = [],
created_at,
updated_at,
attachments = [],
} = detail as Required<ISegmentCardProps>['detail']
const [showModal, setShowModal] = useState(false)
const docForm = useDocumentContext(s => s.docForm)
@ -112,6 +114,16 @@ const SegmentCard: FC<ISegmentCardProps> = ({
return isParentChildMode ? t('datasetDocuments.segment.parentChunk') : t('datasetDocuments.segment.chunk')
}, [isParentChildMode, t])
const images = useMemo(() => {
return attachments.map(attachment => ({
name: attachment.name,
mimeType: attachment.mime_type,
sourceUrl: attachment.source_url,
size: attachment.size,
extension: attachment.extension,
}))
}, [attachments])
if (loading)
return <ParentChunkCardSkeleton />
@ -214,6 +226,7 @@ const SegmentCard: FC<ISegmentCardProps> = ({
isFullDocMode={isFullDocMode}
className={contentOpacity}
/>
{images.length > 0 && <ImageList images={images} size='md' className='py-1' />}
{isGeneralMode && <div className={cn('flex flex-wrap items-center gap-2 py-1.5', contentOpacity)}>
{keywords?.map(keyword => <Tag key={keyword} text={keyword} />)}
</div>}

View File

@ -19,11 +19,21 @@ import { formatNumber } from '@/utils/format'
import cn from '@/utils/classnames'
import Divider from '@/app/components/base/divider'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { IndexingType } from '../../../create/step-two'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import ImageUploaderInChunk from '@/app/components/datasets/common/image-uploader/image-uploader-in-chunk'
import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types'
import { v4 as uuid4 } from 'uuid'
type ISegmentDetailProps = {
segInfo?: Partial<SegmentDetailModel> & { id: string }
onUpdate: (segmentId: string, q: string, a: string, k: string[], needRegenerate?: boolean) => void
onUpdate: (
segmentId: string,
q: string,
a: string,
k: string[],
attachments: FileEntity[],
needRegenerate?: boolean,
) => void
onCancel: () => void
isEditMode?: boolean
docForm: ChunkingMode
@ -44,6 +54,18 @@ const SegmentDetail: FC<ISegmentDetailProps> = ({
const { t } = useTranslation()
const [question, setQuestion] = useState(isEditMode ? segInfo?.content || '' : segInfo?.sign_content || '')
const [answer, setAnswer] = useState(segInfo?.answer || '')
const [attachments, setAttachments] = useState<FileEntity[]>(() => {
return segInfo?.attachments?.map(item => ({
id: uuid4(),
name: item.name,
size: item.size,
mimeType: item.mime_type,
extension: item.extension,
sourceUrl: item.source_url,
uploadedId: item.id,
progress: 100,
})) || []
})
const [keywords, setKeywords] = useState<string[]>(segInfo?.keywords || [])
const { eventEmitter } = useEventEmitterContextContext()
const [loading, setLoading] = useState(false)
@ -52,6 +74,7 @@ const SegmentDetail: FC<ISegmentDetailProps> = ({
const toggleFullScreen = useSegmentListContext(s => s.toggleFullScreen)
const parentMode = useDocumentContext(s => s.parentMode)
const indexingTechnique = useDatasetDetailContextWithSelector(s => s.dataset?.indexing_technique)
const runtimeMode = useDatasetDetailContextWithSelector(s => s.dataset?.runtime_mode)
eventEmitter?.useSubscription((v) => {
if (v === 'update-segment')
@ -65,8 +88,8 @@ const SegmentDetail: FC<ISegmentDetailProps> = ({
}, [onCancel])
const handleSave = useCallback(() => {
onUpdate(segInfo?.id || '', question, answer, keywords)
}, [onUpdate, segInfo?.id, question, answer, keywords])
onUpdate(segInfo?.id || '', question, answer, keywords, attachments)
}, [onUpdate, segInfo?.id, question, answer, keywords, attachments])
const handleRegeneration = useCallback(() => {
setShowRegenerationModal(true)
@ -85,8 +108,12 @@ const SegmentDetail: FC<ISegmentDetailProps> = ({
}, [onCancel, onModalStateChange])
const onConfirmRegeneration = useCallback(() => {
onUpdate(segInfo?.id || '', question, answer, keywords, true)
}, [onUpdate, segInfo?.id, question, answer, keywords])
onUpdate(segInfo?.id || '', question, answer, keywords, attachments, true)
}, [onUpdate, segInfo?.id, question, answer, keywords, attachments])
const onAttachmentsChange = useCallback((attachments: FileEntity[]) => {
setAttachments(attachments)
}, [])
const wordCountText = useMemo(() => {
const contentLength = docForm === ChunkingMode.qa ? (question.length + answer.length) : question.length
@ -102,7 +129,10 @@ const SegmentDetail: FC<ISegmentDetailProps> = ({
return (
<div className={'flex h-full flex-col'}>
<div className={cn('flex items-center justify-between', fullScreen ? 'border border-divider-subtle py-3 pl-6 pr-4' : 'pl-4 pr-3 pt-3')}>
<div className={cn(
'flex shrink-0 items-center justify-between',
fullScreen ? 'border border-divider-subtle py-3 pl-6 pr-4' : 'pl-4 pr-3 pt-3',
)}>
<div className='flex flex-col'>
<div className='system-xl-semibold text-text-primary'>{titleText}</div>
<div className='flex items-center gap-x-2'>
@ -119,12 +149,17 @@ const SegmentDetail: FC<ISegmentDetailProps> = ({
handleRegeneration={handleRegeneration}
handleSave={handleSave}
loading={loading}
showRegenerationButton={runtimeMode === 'general'}
/>
<Divider type='vertical' className='ml-4 mr-2 h-3.5 bg-divider-regular' />
</>
)}
<div className='mr-1 flex h-8 w-8 cursor-pointer items-center justify-center p-1.5' onClick={toggleFullScreen}>
{fullScreen ? <RiCollapseDiagonalLine className='h-4 w-4 text-text-tertiary' /> : <RiExpandDiagonalLine className='h-4 w-4 text-text-tertiary' />}
{
fullScreen
? <RiCollapseDiagonalLine className='h-4 w-4 text-text-tertiary' />
: <RiExpandDiagonalLine className='h-4 w-4 text-text-tertiary' />
}
</div>
<div className='flex h-8 w-8 cursor-pointer items-center justify-center p-1.5' onClick={onCancel}>
<RiCloseLine className='h-4 w-4 text-text-tertiary' />
@ -132,11 +167,14 @@ const SegmentDetail: FC<ISegmentDetailProps> = ({
</div>
</div>
<div className={cn(
'flex grow',
'flex h-0 grow',
fullScreen ? 'w-full flex-row justify-center gap-x-8 px-6 pt-6' : 'flex-col gap-y-1 px-4 py-3',
!isEditMode && 'overflow-hidden pb-0',
!isEditMode && 'pb-0',
)}>
<div className={cn(isEditMode ? 'overflow-hidden whitespace-pre-line break-all' : 'overflow-y-auto', fullScreen ? 'w-1/2' : 'grow')}>
<div className={cn(
isEditMode ? 'overflow-hidden whitespace-pre-line break-all' : 'overflow-y-auto',
fullScreen ? 'w-1/2' : 'h-0 grow',
)}>
<ChunkContent
docForm={docForm}
question={question}
@ -146,14 +184,24 @@ const SegmentDetail: FC<ISegmentDetailProps> = ({
isEditMode={isEditMode}
/>
</div>
{isECOIndexing && <Keywords
className={fullScreen ? 'w-1/5' : ''}
actionType={isEditMode ? 'edit' : 'view'}
segInfo={segInfo}
keywords={keywords}
isEditMode={isEditMode}
onKeywordsChange={keywords => setKeywords(keywords)}
/>}
<div className={cn('flex shrink-0 flex-col', fullScreen ? 'w-[320px] gap-y-2' : 'w-full gap-y-1')}>
<ImageUploaderInChunk
disabled={!isEditMode}
value={attachments}
onChange={onAttachmentsChange}
/>
{isECOIndexing && (
<Keywords
className='w-full'
actionType={isEditMode ? 'edit' : 'view'}
segInfo={segInfo}
keywords={keywords}
isEditMode={isEditMode}
onKeywordsChange={keywords => setKeywords(keywords)}
/>
)}
</div>
</div>
{isEditMode && !fullScreen && (
<div className='flex items-center justify-end border-t-[1px] border-t-divider-subtle p-4 pt-3'>
@ -162,6 +210,7 @@ const SegmentDetail: FC<ISegmentDetailProps> = ({
handleRegeneration={handleRegeneration}
handleSave={handleSave}
loading={loading}
showRegenerationButton={runtimeMode === 'general'}
/>
</div>
)}

View File

@ -21,6 +21,8 @@ import Divider from '@/app/components/base/divider'
import { useAddSegment } from '@/service/knowledge/use-segment'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { IndexingType } from '../../create/step-two'
import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types'
import ImageUploaderInChunk from '@/app/components/datasets/common/image-uploader/image-uploader-in-chunk'
type NewSegmentModalProps = {
onCancel: () => void
@ -39,6 +41,7 @@ const NewSegmentModal: FC<NewSegmentModalProps> = ({
const { notify } = useContext(ToastContext)
const [question, setQuestion] = useState('')
const [answer, setAnswer] = useState('')
const [attachments, setAttachments] = useState<FileEntity[]>([])
const { datasetId, documentId } = useParams<{ datasetId: string; documentId: string }>()
const [keywords, setKeywords] = useState<string[]>([])
const [loading, setLoading] = useState(false)
@ -49,6 +52,7 @@ const NewSegmentModal: FC<NewSegmentModalProps> = ({
const { appSidebarExpand } = useAppStore(useShallow(state => ({
appSidebarExpand: state.appSidebarExpand,
})))
const [imageUploaderKey, setImageUploaderKey] = useState(Date.now())
const refreshTimer = useRef<any>(null)
const CustomButton = useMemo(() => (
@ -71,10 +75,14 @@ const NewSegmentModal: FC<NewSegmentModalProps> = ({
onCancel()
}, [onCancel, addAnother])
const onAttachmentsChange = useCallback((attachments: FileEntity[]) => {
setAttachments(attachments)
}, [])
const { mutateAsync: addSegment } = useAddSegment()
const handleSave = useCallback(async () => {
const params: SegmentUpdater = { content: '' }
const params: SegmentUpdater = { content: '', attachment_ids: [] }
if (docForm === ChunkingMode.qa) {
if (!question.trim()) {
return notify({
@ -106,6 +114,9 @@ const NewSegmentModal: FC<NewSegmentModalProps> = ({
if (keywords?.length)
params.keywords = keywords
if (attachments.length)
params.attachment_ids = attachments.filter(item => Boolean(item.uploadedId)).map(item => item.uploadedId!)
setLoading(true)
await addSegment({ datasetId, documentId, body: params }, {
onSuccess() {
@ -119,6 +130,8 @@ const NewSegmentModal: FC<NewSegmentModalProps> = ({
handleCancel('add')
setQuestion('')
setAnswer('')
setAttachments([])
setImageUploaderKey(Date.now())
setKeywords([])
refreshTimer.current = setTimeout(() => {
onSave()
@ -128,7 +141,7 @@ const NewSegmentModal: FC<NewSegmentModalProps> = ({
setLoading(false)
},
})
}, [docForm, keywords, addSegment, datasetId, documentId, question, answer, notify, t, appSidebarExpand, CustomButton, handleCancel, onSave])
}, [docForm, keywords, addSegment, datasetId, documentId, question, answer, attachments, notify, t, appSidebarExpand, CustomButton, handleCancel, onSave])
const wordCountText = useMemo(() => {
const count = docForm === ChunkingMode.qa ? (question.length + answer.length) : question.length
@ -187,13 +200,22 @@ const NewSegmentModal: FC<NewSegmentModalProps> = ({
isEditMode={true}
/>
</div>
{isECOIndexing && <Keywords
className={fullScreen ? 'w-1/5' : ''}
actionType='add'
keywords={keywords}
isEditMode={true}
onKeywordsChange={keywords => setKeywords(keywords)}
/>}
<div className={classNames('flex flex-col', fullScreen ? 'w-[320px] gap-y-2' : 'w-full gap-y-1')}>
<ImageUploaderInChunk
key={imageUploaderKey}
value={attachments}
onChange={onAttachmentsChange}
/>
{isECOIndexing && (
<Keywords
className={fullScreen ? 'w-1/5' : ''}
actionType='add'
keywords={keywords}
isEditMode={true}
onKeywordsChange={keywords => setKeywords(keywords)}
/>
)}
</div>
</div>
{!fullScreen && (
<div className='flex items-center justify-between border-t-[1px] border-t-divider-subtle p-4 pt-3'>

View File

@ -2,9 +2,9 @@
import type { FC } from 'react'
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { useBoolean } from 'ahooks'
import { ArrowDownIcon } from '@heroicons/react/24/outline'
import { pick, uniq } from 'lodash-es'
import {
RiArrowDownLine,
RiEditLine,
RiGlobalLine,
} from '@remixicon/react'
@ -181,8 +181,8 @@ const DocumentList: FC<IDocumentListProps> = ({
return (
<div className='flex cursor-pointer items-center hover:text-text-secondary' onClick={() => handleSort(field)}>
{label}
<ArrowDownIcon
className={cn('ml-0.5 h-3 w-3 stroke-current stroke-2 transition-all',
<RiArrowDownLine
className={cn('ml-0.5 h-3 w-3 transition-all',
isActive ? 'text-text-tertiary' : 'text-text-disabled',
isActive && !isDesc ? 'rotate-180' : '',
)}

View File

@ -1,6 +1,5 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import React, { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { SegmentIndexTag } from '../../documents/detail/completed/common/segment-index-tag'
import Dot from '../../documents/detail/completed/common/dot'
@ -13,25 +12,42 @@ import type { FileAppearanceTypeEnum } from '@/app/components/base/file-uploader
import cn from '@/utils/classnames'
import Tag from '@/app/components/datasets/documents/detail/completed/common/tag'
import { Markdown } from '@/app/components/base/markdown'
import ImageList from '../../common/image-list'
import Mask from './mask'
const i18nPrefix = 'datasetHitTesting'
type Props = {
type ChunkDetailModalProps = {
payload: HitTesting
onHide: () => void
}
const ChunkDetailModal: FC<Props> = ({
const ChunkDetailModal = ({
payload,
onHide,
}) => {
}: ChunkDetailModalProps) => {
const { t } = useTranslation()
const { segment, score, child_chunks } = payload
const { segment, score, child_chunks, files } = payload
const { position, content, sign_content, keywords, document, answer } = segment
const isParentChildRetrieval = !!(child_chunks && child_chunks.length > 0)
const extension = document.name.split('.').slice(-1)[0] as FileAppearanceTypeEnum
const heighClassName = isParentChildRetrieval ? 'h-[min(627px,_80vh)] overflow-y-auto' : 'h-[min(539px,_80vh)] overflow-y-auto'
const labelPrefix = isParentChildRetrieval ? t('datasetDocuments.segment.parentChunk') : t('datasetDocuments.segment.chunk')
const images = useMemo(() => {
if (!files) return []
return files.map(file => ({
name: file.name,
mimeType: file.mime_type,
sourceUrl: file.source_url,
size: file.size,
extension: file.extension,
}))
}, [files])
const showImages = images.length > 0
const showKeywords = !isParentChildRetrieval && keywords && keywords.length > 0
return (
<Modal
title={t(`${i18nPrefix}.chunkDetail`)}
@ -58,37 +74,49 @@ const ChunkDetailModal: FC<Props> = ({
</div>
<Score value={score} />
</div>
{!answer && (
<Markdown
className={cn('!mt-2 !text-text-secondary', heighClassName)}
content={sign_content || content}
customDisallowedElements={['input']}
/>
)}
{answer && (
<div className='break-all'>
<div className='flex gap-x-1'>
<div className='w-4 shrink-0 text-[13px] font-medium leading-[20px] text-text-tertiary'>Q</div>
<div className={cn('body-md-regular line-clamp-20 text-text-secondary')}>
{content}
{/* Content */}
<div className='relative'>
{!answer && (
<Markdown
className={cn('!mt-2 !text-text-secondary', heighClassName)}
content={sign_content || content}
customDisallowedElements={['input']}
/>
)}
{answer && (
<div className='break-all'>
<div className='flex gap-x-1'>
<div className='w-4 shrink-0 text-[13px] font-medium leading-[20px] text-text-tertiary'>Q</div>
<div className={cn('body-md-regular line-clamp-20 text-text-secondary')}>
{content}
</div>
</div>
<div className='flex gap-x-1'>
<div className='w-4 shrink-0 text-[13px] font-medium leading-[20px] text-text-tertiary'>A</div>
<div className={cn('body-md-regular line-clamp-20 text-text-secondary')}>
{answer}
</div>
</div>
</div>
<div className='flex gap-x-1'>
<div className='w-4 shrink-0 text-[13px] font-medium leading-[20px] text-text-tertiary'>A</div>
<div className={cn('body-md-regular line-clamp-20 text-text-secondary')}>
{answer}
)}
{/* Mask */}
<Mask className='absolute inset-x-0 bottom-0' />
</div>
{(showImages || showKeywords) && (
<div className='flex flex-col gap-y-3 pt-3'>
{showImages && (
<ImageList images={images} size='md' className='py-1' />
)}
{showKeywords && (
<div className='flex flex-col gap-y-1'>
<div className='text-xs font-medium uppercase text-text-tertiary'>{t(`${i18nPrefix}.keyword`)}</div>
<div className='flex flex-wrap gap-x-2'>
{keywords.map(keyword => (
<Tag key={keyword} text={keyword} />
))}
</div>
</div>
</div>
</div>
)}
{!isParentChildRetrieval && keywords && keywords.length > 0 && (
<div className='mt-6'>
<div className='text-xs font-medium uppercase text-text-tertiary'>{t(`${i18nPrefix}.keyword`)}</div>
<div className='mt-1 flex flex-wrap'>
{keywords.map(keyword => (
<Tag key={keyword} text={keyword} className='mr-2' />
))}
</div>
)}
</div>
)}
</div>

View File

@ -0,0 +1,15 @@
import { RiHistoryLine } from '@remixicon/react'
import React from 'react'
import { useTranslation } from 'react-i18next'
const EmptyRecords = () => {
const { t } = useTranslation()
return <div className='rounded-2xl bg-workflow-process-bg p-5'>
<div className='flex h-10 w-10 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg p-1 shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]'>
<RiHistoryLine className='h-5 w-5 text-text-tertiary' />
</div>
<div className='my-2 text-[13px] font-medium leading-4 text-text-tertiary'>{t('datasetHitTesting.noRecentTip')}</div>
</div>
}
export default React.memo(EmptyRecords)

View File

@ -0,0 +1,19 @@
import React from 'react'
import cn from '@/utils/classnames'
type MaskProps = {
className?: string
}
export const Mask = ({
className,
}: MaskProps) => {
return (
<div className={cn(
'h-12 bg-gradient-to-b from-components-panel-bg-transparent to-components-panel-bg',
className,
)} />
)
}
export default React.memo(Mask)

View File

@ -0,0 +1,257 @@
import type { ChangeEvent } from 'react'
import React, { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
RiEqualizer2Line,
RiPlayCircleLine,
} from '@remixicon/react'
import Image from 'next/image'
import Button from '@/app/components/base/button'
import { getIcon } from '@/app/components/datasets/common/retrieval-method-info'
import ModifyExternalRetrievalModal from '@/app/components/datasets/hit-testing/modify-external-retrieval-modal'
import cn from '@/utils/classnames'
import type {
Attachment,
ExternalKnowledgeBaseHitTestingRequest,
ExternalKnowledgeBaseHitTestingResponse,
HitTestingRequest,
HitTestingResponse,
Query,
} from '@/models/datasets'
import { RETRIEVE_METHOD, type RetrievalConfig } from '@/types/app'
import type { UseMutateAsyncFunction } from '@tanstack/react-query'
import ImageUploaderInRetrievalTesting from '@/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing'
import Textarea from './textarea'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types'
import { v4 as uuid4 } from 'uuid'
type QueryInputProps = {
onUpdateList: () => void
setHitResult: (res: HitTestingResponse) => void
setExternalHitResult: (res: ExternalKnowledgeBaseHitTestingResponse) => void
loading: boolean
queries: Query[]
setQueries: (v: Query[]) => void
isExternal?: boolean
onClickRetrievalMethod: () => void
retrievalConfig: RetrievalConfig
isEconomy: boolean
onSubmit?: () => void
hitTestingMutation: UseMutateAsyncFunction<HitTestingResponse, Error, HitTestingRequest, unknown>
externalKnowledgeBaseHitTestingMutation: UseMutateAsyncFunction<
ExternalKnowledgeBaseHitTestingResponse,
Error,
ExternalKnowledgeBaseHitTestingRequest,
unknown
>
}
const QueryInput = ({
onUpdateList,
setHitResult,
setExternalHitResult,
loading,
queries,
setQueries,
isExternal = false,
onClickRetrievalMethod,
retrievalConfig,
isEconomy,
onSubmit: _onSubmit,
hitTestingMutation,
externalKnowledgeBaseHitTestingMutation,
}: QueryInputProps) => {
const { t } = useTranslation()
const isMultimodal = useDatasetDetailContextWithSelector(s => !!s.dataset?.is_multimodal)
const [isSettingsOpen, setIsSettingsOpen] = useState(false)
const [externalRetrievalSettings, setExternalRetrievalSettings] = useState({
top_k: 4,
score_threshold: 0.5,
score_threshold_enabled: false,
})
const text = useMemo(() => {
return queries.find(query => query.content_type === 'text_query')?.content ?? ''
}, [queries])
const images = useMemo(() => {
const imageQueries = queries
.filter(query => query.content_type === 'image_query')
.map(query => query.file_info)
.filter(Boolean) as Attachment[]
return imageQueries.map(item => ({
id: uuid4(),
name: item.name,
size: item.size,
mimeType: item.mime_type,
extension: item.extension,
sourceUrl: item.source_url,
uploadedId: item.id,
progress: 100,
})) || []
}, [queries])
const isAllUploaded = useMemo(() => {
return images.every(image => !!image.uploadedId)
}, [images])
const handleSaveExternalRetrievalSettings = useCallback((data: {
top_k: number
score_threshold: number
score_threshold_enabled: boolean
}) => {
setExternalRetrievalSettings(data)
setIsSettingsOpen(false)
}, [])
const handleTextChange = useCallback((event: ChangeEvent<HTMLTextAreaElement>) => {
const newQueries = [...queries]
const textQuery = newQueries.find(query => query.content_type === 'text_query')
if (!textQuery) {
newQueries.push({
content: event.target.value,
content_type: 'text_query',
file_info: null,
})
}
else {
textQuery.content = event.target.value
}
setQueries(newQueries)
}, [queries, setQueries])
const handleImageChange = useCallback((files: FileEntity[]) => {
let newQueries = [...queries]
newQueries = newQueries.filter(query => query.content_type !== 'image_query')
files.forEach((file) => {
newQueries.push({
content: file.sourceUrl || '',
content_type: 'image_query',
file_info: {
id: file.uploadedId || '',
mime_type: file.mimeType,
source_url: file.sourceUrl || '',
name: file.name,
size: file.size,
extension: file.extension,
},
})
})
setQueries(newQueries)
}, [queries, setQueries])
const onSubmit = useCallback(async () => {
await hitTestingMutation({
query: text,
attachment_ids: images.map(image => image.uploadedId),
retrieval_model: {
...retrievalConfig,
search_method: isEconomy ? RETRIEVE_METHOD.keywordSearch : retrievalConfig.search_method,
},
}, {
onSuccess: (data) => {
setHitResult(data)
onUpdateList?.()
if (_onSubmit)
_onSubmit()
},
})
}, [text, retrievalConfig, isEconomy, hitTestingMutation, onUpdateList, _onSubmit, images, setHitResult])
const externalRetrievalTestingOnSubmit = useCallback(async () => {
await externalKnowledgeBaseHitTestingMutation({
query: text,
external_retrieval_model: {
top_k: externalRetrievalSettings.top_k,
score_threshold: externalRetrievalSettings.score_threshold,
score_threshold_enabled: externalRetrievalSettings.score_threshold_enabled,
},
}, {
onSuccess: (data) => {
setExternalHitResult(data)
onUpdateList?.()
},
})
}, [text, externalRetrievalSettings, externalKnowledgeBaseHitTestingMutation, onUpdateList, setExternalHitResult])
const retrievalMethod = isEconomy ? RETRIEVE_METHOD.keywordSearch : retrievalConfig.search_method
const icon = <Image className='size-3.5 text-util-colors-purple-purple-600' src={getIcon(retrievalMethod)} alt='' />
const TextAreaComp = useMemo(() => {
return (
<Textarea
text={text}
handleTextChange={handleTextChange}
/>
)
}, [text, handleTextChange])
const ActionButtonComp = useMemo(() => {
return (
<Button
onClick={isExternal ? externalRetrievalTestingOnSubmit : onSubmit}
variant='primary'
loading={loading}
disabled={(text.length === 0 && images.length === 0) || text.length > 200 || (images.length > 0 && !isAllUploaded)}
className='w-[88px]'
>
<RiPlayCircleLine className='mr-1 size-4' />
{t('datasetHitTesting.input.testing')}
</Button>
)
}, [isExternal, externalRetrievalTestingOnSubmit, onSubmit, text, loading, t, images, isAllUploaded])
return (
<div className={cn('relative flex h-80 shrink-0 flex-col overflow-hidden rounded-xl bg-gradient-to-r from-components-input-border-active-prompt-1 to-components-input-border-active-prompt-2 p-0.5 shadow-xs')}>
<div className='flex h-full flex-col overflow-hidden rounded-[10px] bg-background-section-burn'>
<div className='relative flex shrink-0 items-center justify-between p-1.5 pb-1 pl-3'>
<span className='system-sm-semibold-uppercase text-text-secondary'>
{t('datasetHitTesting.input.title')}
</span>
{isExternal ? (
<Button
variant='secondary'
size='small'
onClick={() => setIsSettingsOpen(!isSettingsOpen)}
>
<RiEqualizer2Line className='h-3.5 w-3.5 text-components-button-secondary-text' />
<div className='flex items-center justify-center gap-1 px-[3px]'>
<span className='system-xs-medium text-components-button-secondary-text'>{t('datasetHitTesting.settingTitle')}</span>
</div>
</Button>
) : (
<div
onClick={onClickRetrievalMethod}
className='flex h-7 cursor-pointer items-center space-x-0.5 rounded-lg border-[0.5px] border-components-button-secondary-bg bg-components-button-secondary-bg px-1.5 shadow-xs backdrop-blur-[5px] hover:bg-components-button-secondary-bg-hover'
>
{icon}
<div className='text-xs font-medium uppercase text-text-secondary'>{t(`dataset.retrieval.${retrievalMethod}.title`)}</div>
<RiEqualizer2Line className='size-4 text-components-menu-item-text'></RiEqualizer2Line>
</div>
)}
{
isSettingsOpen && (
<ModifyExternalRetrievalModal
onClose={() => setIsSettingsOpen(false)}
onSave={handleSaveExternalRetrievalSettings}
initialTopK={externalRetrievalSettings.top_k}
initialScoreThreshold={externalRetrievalSettings.score_threshold}
initialScoreThresholdEnabled={externalRetrievalSettings.score_threshold_enabled}
/>
)
}
</div>
<ImageUploaderInRetrievalTesting
textArea={TextAreaComp}
actionButton={ActionButtonComp}
onChange={handleImageChange}
value={images}
showUploader={isMultimodal}
className='grow'
actionAreaClassName='px-4 py-2 shrink-0 bg-background-default'
/>
</div>
</div>
)
}
export default QueryInput

View File

@ -0,0 +1,61 @@
import type { ChangeEvent } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import cn from '@/utils/classnames'
import { Corner } from '@/app/components/base/icons/src/vender/solid/shapes'
import Tooltip from '@/app/components/base/tooltip'
type TextareaProps = {
text: string
handleTextChange: (e: ChangeEvent<HTMLTextAreaElement>) => void
}
const Textarea = ({
text,
handleTextChange,
}: TextareaProps) => {
const { t } = useTranslation()
return (
<div className={cn(
'relative flex-1 overflow-hidden rounded-t-[10px] border-t-[0.5px] border-components-panel-border-subtle bg-background-default px-4 pb-0 pt-3',
text.length > 200 && 'border-state-destructive-active',
)}>
<textarea
className='system-md-regular h-full w-full resize-none border-none bg-transparent text-text-secondary caret-[#295EFF] placeholder:text-components-input-text-placeholder focus-visible:outline-none'
value={text}
onChange={handleTextChange}
placeholder={t('datasetHitTesting.input.placeholder') as string}
/>
<div className='absolute right-0 top-0 flex items-center'>
<Corner className={cn(
'text-background-section-burn',
text.length > 200 && 'text-util-colors-red-red-100',
)} />
{text.length > 200
? (
<Tooltip
popupContent={t('datasetHitTesting.input.countWarning')}
>
<div
className={cn('system-2xs-medium-uppercase bg-util-colors-red-red-100 py-1 pr-2 text-util-colors-red-red-600')}
>
{`${text.length}/200`}
</div>
</Tooltip>
)
: (
<div
className={cn(
'system-2xs-medium-uppercase bg-background-section-burn py-1 pr-2 text-text-tertiary',
)}
>
{`${text.length}/200`}
</div>
)}
</div>
</div>
)
}
export default React.memo(Textarea)

View File

@ -0,0 +1,117 @@
import React, { useCallback, useMemo, useState } from 'react'
import useTimestamp from '@/hooks/use-timestamp'
import type { Attachment, HitTestingRecord, Query } from '@/models/datasets'
import { RiApps2Line, RiArrowDownLine, RiFocus2Line } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import ImageList from '../../common/image-list'
import cn from '@/utils/classnames'
type RecordsProps = {
records: HitTestingRecord[]
onClickRecord: (record: HitTestingRecord) => void
}
const Records = ({
records,
onClickRecord,
}: RecordsProps) => {
const { t } = useTranslation()
const { formatTime } = useTimestamp()
const [sortTimeOrder, setTimeOrder] = useState<'asc' | 'desc'>('desc')
const handleSortTime = useCallback(() => {
setTimeOrder(prev => prev === 'asc' ? 'desc' : 'asc')
}, [])
const sortedRecords = useMemo(() => {
return [...records].sort((a, b) => {
return sortTimeOrder === 'asc' ? a.created_at - b.created_at : b.created_at - a.created_at
})
}, [records, sortTimeOrder])
const getImageList = (queries: Query[]) => {
const imageQueries = queries
.filter(query => query.content_type === 'image_query')
.map(query => query.file_info)
.filter(Boolean) as Attachment[]
return imageQueries.map(image => ({
name: image.name,
mimeType: image.mime_type,
sourceUrl: image.source_url,
size: image.size,
extension: image.extension,
}))
}
return (
<div className='grow overflow-y-auto'>
<table className={'w-full border-collapse border-0 text-[13px] leading-4 text-text-secondary '}>
<thead className='sticky top-0 h-7 text-xs font-medium uppercase leading-7 text-text-tertiary backdrop-blur-[5px]'>
<tr>
<td className='rounded-l-lg bg-background-section-burn pl-3'>{t('datasetHitTesting.table.header.queryContent')}</td>
<td className='w-[128px] bg-background-section-burn pl-3'>{t('datasetHitTesting.table.header.source')}</td>
<td className='w-48 rounded-r-lg bg-background-section-burn pl-3'>
<div
className='flex cursor-pointer items-center'
onClick={handleSortTime}
>
{t('datasetHitTesting.table.header.time')}
<RiArrowDownLine
className={cn(
'ml-0.5 size-3.5',
sortTimeOrder === 'asc' ? 'rotate-180' : '',
)}
/>
</div>
</td>
</tr>
</thead>
<tbody>
{sortedRecords.map((record) => {
const { id, source, created_at, queries } = record
const SourceIcon = record.source === 'app' ? RiApps2Line : RiFocus2Line
const content = queries.find(query => query.content_type === 'text_query')?.content || ''
const images = getImageList(queries)
return (
<tr
key={id}
className='group cursor-pointer border-b border-divider-subtle hover:bg-background-default-hover'
onClick={() => onClickRecord(record)}
>
<td className='max-w-xs p-3 pr-2'>
<div className='flex flex-col gap-y-1'>
{content && (
<div className='line-clamp-2'>
{content}
</div>
)}
{images.length > 0 && (
<ImageList
images={images}
size='md'
className='py-1'
limit={5}
/>
)}
</div>
</td>
<td className='w-[128px] p-3 pr-2'>
<div className='flex items-center'>
<SourceIcon className='mr-1 size-4 text-text-tertiary' />
<span className='capitalize'>{source.replace('_', ' ').replace('hit testing', 'retrieval test')}</span>
</div>
</td>
<td className='w-48 p-3 pr-2'>
{formatTime(created_at, t('datasetHitTesting.dateTimeFormat') as string)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}
export default React.memo(Records)

View File

@ -1,6 +1,5 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import React, { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react'
import { useBoolean } from 'ahooks'
@ -14,17 +13,18 @@ import type { FileAppearanceTypeEnum } from '@/app/components/base/file-uploader
import Tag from '@/app/components/datasets/documents/detail/completed/common/tag'
import { extensionToFileType } from '@/app/components/datasets/hit-testing/utils/extension-to-file-type'
import { Markdown } from '@/app/components/base/markdown'
import ImageList from '../../common/image-list'
const i18nPrefix = 'datasetHitTesting'
type Props = {
type ResultItemProps = {
payload: HitTesting
}
const ResultItem: FC<Props> = ({
const ResultItem = ({
payload,
}) => {
}: ResultItemProps) => {
const { t } = useTranslation()
const { segment, score, child_chunks } = payload
const { segment, score, child_chunks, files } = payload
const data = segment
const { position, word_count, content, sign_content, keywords, document } = data
const isParentChildRetrieval = !!(child_chunks && child_chunks.length > 0)
@ -40,6 +40,17 @@ const ResultItem: FC<Props> = ({
setFalse: hideDetailModal,
}] = useBoolean(false)
const images = useMemo(() => {
if (!files) return []
return files.map(file => ({
name: file.name,
mimeType: file.mime_type,
sourceUrl: file.source_url,
size: file.size,
extension: file.extension,
}))
}, [files])
return (
<div className={cn('cursor-pointer rounded-xl bg-chat-bubble-bg pt-3 hover:shadow-lg')} onClick={showDetailModal}>
{/* Meta info */}
@ -47,11 +58,14 @@ const ResultItem: FC<Props> = ({
{/* Main */}
<div className='mt-1 px-3'>
<Markdown
{<Markdown
className='line-clamp-2'
content={sign_content || content}
customDisallowedElements={['input']}
/>
/>}
{images.length > 0 && (
<ImageList images={images} size='md' className='py-1' />
)}
{isParentChildRetrieval && (
<div className='mt-1'>
<div

View File

@ -1,30 +1,40 @@
'use client'
import type { FC } from 'react'
import React, { useEffect, useState } from 'react'
import React, { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import useSWR from 'swr'
import { omit } from 'lodash-es'
import { useBoolean } from 'ahooks'
import { useContext } from 'use-context-selector'
import { RiApps2Line, RiFocus2Line, RiHistoryLine } from '@remixicon/react'
import Textarea from './textarea'
import QueryInput from './components/query-input'
import s from './style.module.css'
import ModifyRetrievalModal from './modify-retrieval-modal'
import ResultItem from './components/result-item'
import ResultItemExternal from './components/result-item-external'
import cn from '@/utils/classnames'
import type { ExternalKnowledgeBaseHitTesting, ExternalKnowledgeBaseHitTestingResponse, HitTesting, HitTestingResponse } from '@/models/datasets'
import type {
ExternalKnowledgeBaseHitTesting,
ExternalKnowledgeBaseHitTestingResponse,
HitTesting,
HitTestingRecord,
HitTestingResponse,
Query,
} from '@/models/datasets'
import Loading from '@/app/components/base/loading'
import Drawer from '@/app/components/base/drawer'
import Pagination from '@/app/components/base/pagination'
import FloatRightContainer from '@/app/components/base/float-right-container'
import { fetchTestingRecords } from '@/service/datasets'
import DatasetDetailContext from '@/context/dataset-detail'
import type { RetrievalConfig } from '@/types/app'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import useTimestamp from '@/hooks/use-timestamp'
import docStyle from '@/app/components/datasets/documents/detail/completed/style.module.css'
import { CardSkelton } from '../documents/detail/completed/skeleton/general-list-skeleton'
import EmptyRecords from './components/empty-records'
import Records from './components/records'
import {
useExternalKnowledgeBaseHitTesting,
useHitTesting,
useHitTestingRecords,
useInvalidateHitTestingRecords,
} from '@/service/knowledge/use-hit-testing'
const limit = 10
@ -32,34 +42,20 @@ type Props = {
datasetId: string
}
const RecordsEmpty: FC = () => {
const { t } = useTranslation()
return <div className='rounded-2xl bg-workflow-process-bg p-5'>
<div className='flex h-10 w-10 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg p-1 shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]'>
<RiHistoryLine className='h-5 w-5 text-text-tertiary' />
</div>
<div className='my-2 text-[13px] font-medium leading-4 text-text-tertiary'>{t('datasetHitTesting.noRecentTip')}</div>
</div>
}
const HitTestingPage: FC<Props> = ({ datasetId }: Props) => {
const { t } = useTranslation()
const { formatTime } = useTimestamp()
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const [hitResult, setHitResult] = useState<HitTestingResponse | undefined>() // 初始化记录为空数组
const [externalHitResult, setExternalHitResult] = useState<ExternalKnowledgeBaseHitTestingResponse | undefined>()
const [submitLoading, setSubmitLoading] = useState(false)
const [text, setText] = useState('')
const [queries, setQueries] = useState<Query[]>([])
const [queryInputKey, setQueryInputKey] = useState(Date.now())
const [currPage, setCurrPage] = React.useState<number>(0)
const { data: recordsRes, error, mutate: recordsMutate } = useSWR({
action: 'fetchTestingRecords',
datasetId,
params: { limit, page: currPage + 1 },
}, apiParams => fetchTestingRecords(omit(apiParams, 'action')))
const [currPage, setCurrPage] = useState<number>(0)
const { data: recordsRes, isLoading: isRecordsLoading } = useHitTestingRecords({ datasetId, page: currPage + 1, limit })
const invalidateHitTestingRecords = useInvalidateHitTestingRecords(datasetId)
const total = recordsRes?.total || 0
@ -69,6 +65,15 @@ const HitTestingPage: FC<Props> = ({ datasetId }: Props) => {
const [retrievalConfig, setRetrievalConfig] = useState(currentDataset?.retrieval_model_dict as RetrievalConfig)
const [isShowModifyRetrievalModal, setIsShowModifyRetrievalModal] = useState(false)
const [isShowRightPanel, { setTrue: showRightPanel, setFalse: hideRightPanel, set: setShowRightPanel }] = useBoolean(!isMobile)
const { mutateAsync: hitTestingMutation, isPending: isHitTestingPending } = useHitTesting(datasetId)
const {
mutateAsync: externalKnowledgeBaseHitTestingMutation,
isPending: isExternalKnowledgeBaseHitTestingPending,
} = useExternalKnowledgeBaseHitTesting(datasetId)
const isRetrievalLoading = isHitTestingPending || isExternalKnowledgeBaseHitTestingPending
const renderHitResults = (results: HitTesting[] | ExternalKnowledgeBaseHitTesting[]) => (
<div className='flex h-full flex-col rounded-tl-2xl bg-background-body px-4 py-3'>
<div className='mb-2 shrink-0 pl-2 font-semibold leading-6 text-text-primary'>
@ -101,6 +106,12 @@ const HitTestingPage: FC<Props> = ({ datasetId }: Props) => {
</div>
)
const handleClickRecord = useCallback((record: HitTestingRecord) => {
const { queries } = record
setQueries(queries)
setQueryInputKey(Date.now())
}, [])
useEffect(() => {
setShowRightPanel(!isMobile)
}, [isMobile, setShowRightPanel])
@ -112,74 +123,50 @@ const HitTestingPage: FC<Props> = ({ datasetId }: Props) => {
<h1 className='text-base font-semibold text-text-primary'>{t('datasetHitTesting.title')}</h1>
<p className='mt-0.5 text-[13px] font-normal leading-4 text-text-tertiary'>{t('datasetHitTesting.desc')}</p>
</div>
<Textarea
datasetId={datasetId}
<QueryInput
key={queryInputKey}
setHitResult={setHitResult}
setExternalHitResult={setExternalHitResult}
onSubmit={showRightPanel}
onUpdateList={recordsMutate}
loading={submitLoading}
setLoading={setSubmitLoading}
setText={setText}
text={text}
onUpdateList={invalidateHitTestingRecords}
loading={isRetrievalLoading}
queries={queries}
setQueries={setQueries}
isExternal={isExternal}
onClickRetrievalMethod={() => setIsShowModifyRetrievalModal(true)}
retrievalConfig={retrievalConfig}
isEconomy={currentDataset?.indexing_technique === 'economy'}
hitTestingMutation={hitTestingMutation}
externalKnowledgeBaseHitTestingMutation={externalKnowledgeBaseHitTestingMutation}
/>
<div className='mb-3 mt-6 text-base font-semibold text-text-primary'>{t('datasetHitTesting.records')}</div>
{(!recordsRes && !error)
? (
{isRecordsLoading
&& (
<div className='flex-1'><Loading type='app' /></div>
)
: recordsRes?.data?.length
? (
<>
<div className='grow overflow-y-auto'>
<table className={'w-full border-collapse border-0 text-[13px] leading-4 text-text-secondary '}>
<thead className='sticky top-0 h-7 text-xs font-medium uppercase leading-7 text-text-tertiary backdrop-blur-[5px]'>
<tr>
<td className='w-[128px] rounded-l-lg bg-background-section-burn pl-3'>{t('datasetHitTesting.table.header.source')}</td>
<td className='bg-background-section-burn'>{t('datasetHitTesting.table.header.text')}</td>
<td className='w-48 rounded-r-lg bg-background-section-burn pl-2'>{t('datasetHitTesting.table.header.time')}</td>
</tr>
</thead>
<tbody>
{recordsRes?.data?.map((record) => {
const SourceIcon = record.source === 'app' ? RiApps2Line : RiFocus2Line
return <tr
key={record.id}
className='group h-10 cursor-pointer border-b border-divider-subtle hover:bg-background-default-hover'
onClick={() => setText(record.content)}
>
<td className='w-[128px] pl-3'>
<div className='flex items-center'>
<SourceIcon className='mr-1 size-4 text-text-tertiary' />
<span className='capitalize'>{record.source.replace('_', ' ').replace('hit testing', 'retrieval test')}</span>
</div>
</td>
<td className='max-w-xs py-2'>{record.content}</td>
<td className='w-36 pl-2'>
{formatTime(record.created_at, t('datasetHitTesting.dateTimeFormat') as string)}
</td>
</tr>
})}
</tbody>
</table>
</div>
{(total && total > limit)
? <Pagination current={currPage} onChange={setCurrPage} total={total} limit={limit} />
: null}
</>
)
: (
<RecordsEmpty />
)}
}
{!isRecordsLoading && recordsRes?.data && recordsRes.data.length > 0 && (
<>
<Records records={recordsRes?.data} onClickRecord={handleClickRecord}/>
{(total && total > limit)
? <Pagination current={currPage} onChange={setCurrPage} total={total} limit={limit} />
: null}
</>
)}
{!isRecordsLoading && !recordsRes?.data?.length && (
<EmptyRecords />
)}
</div>
<FloatRightContainer panelClassName='!justify-start !overflow-y-auto' showClose isMobile={isMobile} isOpen={isShowRightPanel} onClose={hideRightPanel} footer={null}>
<FloatRightContainer
panelClassName='!justify-start !overflow-y-auto'
showClose
isMobile={isMobile}
isOpen={isShowRightPanel}
onClose={hideRightPanel}
footer={null}
>
<div className='flex flex-col pt-3'>
{/* {renderHitResults(generalResultData)} */}
{submitLoading
{isRetrievalLoading
? <div className='flex h-full flex-col rounded-tl-2xl bg-background-body px-4 py-3'>
<CardSkelton />
</div>
@ -197,7 +184,14 @@ const HitTestingPage: FC<Props> = ({ datasetId }: Props) => {
}
</div>
</FloatRightContainer>
<Drawer unmount={true} isOpen={isShowModifyRetrievalModal} onClose={() => setIsShowModifyRetrievalModal(false)} footer={null} mask={isMobile} panelClassName='mt-16 mx-2 sm:mr-2 mb-3 !p-0 !max-w-[640px] rounded-xl'>
<Drawer
unmount={true}
isOpen={isShowModifyRetrievalModal}
onClose={() => setIsShowModifyRetrievalModal(false)}
footer={null}
mask={isMobile}
panelClassName='mt-16 mx-2 sm:mr-2 mb-3 !p-0 !max-w-[640px] rounded-xl'
>
<ModifyRetrievalModal
indexMethod={currentDataset?.indexing_technique || ''}
value={retrievalConfig}

View File

@ -1,6 +1,6 @@
'use client'
import type { FC } from 'react'
import React, { useRef, useState } from 'react'
import React, { useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { RiCloseLine } from '@remixicon/react'
import Toast from '../../base/toast'
@ -10,8 +10,11 @@ import RetrievalMethodConfig from '@/app/components/datasets/common/retrieval-me
import EconomicalRetrievalMethodConfig from '@/app/components/datasets/common/economical-retrieval-method-config'
import Button from '@/app/components/base/button'
import { isReRankModelSelected } from '@/app/components/datasets/common/check-rerank-model'
import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { useDocLink } from '@/context/i18n'
import { checkShowMultiModalTip } from '../settings/utils'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import type { IndexingType } from '../create/step-two'
type Props = {
indexMethod: string
@ -32,15 +35,16 @@ const ModifyRetrievalModal: FC<Props> = ({
const { t } = useTranslation()
const docLink = useDocLink()
const [retrievalConfig, setRetrievalConfig] = useState(value)
const embeddingModel = useDatasetDetailContextWithSelector(state => state.dataset?.embedding_model)
const embeddingModelProvider = useDatasetDetailContextWithSelector(state => state.dataset?.embedding_model_provider)
// useClickAway(() => {
// if (ref)
// onHide()
// }, ref)
const {
modelList: rerankModelList,
} = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.rerank)
const { data: embeddingModelList } = useModelList(ModelTypeEnum.textEmbedding)
const { data: rerankModelList } = useModelList(ModelTypeEnum.rerank)
const handleSave = () => {
if (
@ -56,6 +60,23 @@ const ModifyRetrievalModal: FC<Props> = ({
onSave(retrievalConfig)
}
const showMultiModalTip = useMemo(() => {
return checkShowMultiModalTip({
embeddingModel: {
provider: embeddingModelProvider ?? '',
model: embeddingModel ?? '',
},
rerankingEnable: retrievalConfig.reranking_enable,
rerankModel: {
rerankingProviderName: retrievalConfig.reranking_model.reranking_provider_name,
rerankingModelName: retrievalConfig.reranking_model.reranking_model_name,
},
indexMethod: indexMethod as IndexingType,
embeddingModelList,
rerankModelList,
})
}, [embeddingModelProvider, embeddingModel, retrievalConfig.reranking_enable, retrievalConfig.reranking_model, indexMethod, embeddingModelList, rerankModelList])
if (!isShow)
return null
@ -104,6 +125,7 @@ const ModifyRetrievalModal: FC<Props> = ({
<RetrievalMethodConfig
value={retrievalConfig}
onChange={setRetrievalConfig}
showMultiModalTip={showMultiModalTip}
/>
)
: (

View File

@ -1,201 +0,0 @@
import type { ChangeEvent } from 'react'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
RiEqualizer2Line,
} from '@remixicon/react'
import Image from 'next/image'
import Button from '../../base/button'
import { getIcon } from '../common/retrieval-method-info'
import ModifyExternalRetrievalModal from './modify-external-retrieval-modal'
import Tooltip from '@/app/components/base/tooltip'
import cn from '@/utils/classnames'
import type { ExternalKnowledgeBaseHitTestingResponse, HitTestingResponse } from '@/models/datasets'
import { externalKnowledgeBaseHitTesting, hitTesting } from '@/service/datasets'
import { asyncRunSafe } from '@/utils'
import { RETRIEVE_METHOD, type RetrievalConfig } from '@/types/app'
type TextAreaWithButtonIProps = {
datasetId: string
onUpdateList: () => void
setHitResult: (res: HitTestingResponse) => void
setExternalHitResult: (res: ExternalKnowledgeBaseHitTestingResponse) => void
loading: boolean
setLoading: (v: boolean) => void
text: string
setText: (v: string) => void
isExternal?: boolean
onClickRetrievalMethod: () => void
retrievalConfig: RetrievalConfig
isEconomy: boolean
onSubmit?: () => void
}
const TextAreaWithButton = ({
datasetId,
onUpdateList,
setHitResult,
setExternalHitResult,
setLoading,
loading,
text,
setText,
isExternal = false,
onClickRetrievalMethod,
retrievalConfig,
isEconomy,
onSubmit: _onSubmit,
}: TextAreaWithButtonIProps) => {
const { t } = useTranslation()
const [isSettingsOpen, setIsSettingsOpen] = useState(false)
const [externalRetrievalSettings, setExternalRetrievalSettings] = useState({
top_k: 4,
score_threshold: 0.5,
score_threshold_enabled: false,
})
const handleSaveExternalRetrievalSettings = (data: { top_k: number; score_threshold: number; score_threshold_enabled: boolean }) => {
setExternalRetrievalSettings(data)
setIsSettingsOpen(false)
}
function handleTextChange(event: ChangeEvent<HTMLTextAreaElement>) {
setText(event.target.value)
}
const onSubmit = async () => {
setLoading(true)
const [e, res] = await asyncRunSafe<HitTestingResponse>(
hitTesting({
datasetId,
queryText: text,
retrieval_model: {
...retrievalConfig,
search_method: isEconomy ? RETRIEVE_METHOD.keywordSearch : retrievalConfig.search_method,
},
}) as Promise<HitTestingResponse>,
)
if (!e) {
setHitResult(res)
onUpdateList?.()
}
setLoading(false)
if (_onSubmit)
_onSubmit()
}
const externalRetrievalTestingOnSubmit = async () => {
setLoading(true)
const [e, res] = await asyncRunSafe<ExternalKnowledgeBaseHitTestingResponse>(
externalKnowledgeBaseHitTesting({
datasetId,
query: text,
external_retrieval_model: {
top_k: externalRetrievalSettings.top_k,
score_threshold: externalRetrievalSettings.score_threshold,
score_threshold_enabled: externalRetrievalSettings.score_threshold_enabled,
},
}) as Promise<ExternalKnowledgeBaseHitTestingResponse>,
)
if (!e) {
setExternalHitResult(res)
onUpdateList?.()
}
setLoading(false)
}
const retrievalMethod = isEconomy ? RETRIEVE_METHOD.keywordSearch : retrievalConfig.search_method
const icon = <Image className='size-3.5 text-util-colors-purple-purple-600' src={getIcon(retrievalMethod)} alt='' />
return (
<>
<div className={cn('relative rounded-xl bg-gradient-to-r from-components-input-border-active-prompt-1 to-components-input-border-active-prompt-2 p-0.5 shadow-xs')}>
<div className='relative rounded-t-xl bg-background-section-burn pt-1.5'>
<div className="flex h-8 items-center justify-between pb-1 pl-4 pr-1.5">
<span className="text-[13px] font-semibold uppercase leading-4 text-text-secondary">
{t('datasetHitTesting.input.title')}
</span>
{isExternal
? <Button
variant='secondary'
size='small'
onClick={() => setIsSettingsOpen(!isSettingsOpen)}
>
<RiEqualizer2Line className='h-3.5 w-3.5 text-components-button-secondary-text' />
<div className='flex items-center justify-center gap-1 px-[3px]'>
<span className='system-xs-medium text-components-button-secondary-text'>{t('datasetHitTesting.settingTitle')}</span>
</div>
</Button>
: <div
onClick={onClickRetrievalMethod}
className='flex h-7 cursor-pointer items-center space-x-0.5 rounded-lg border-[0.5px] border-components-button-secondary-bg bg-components-button-secondary-bg px-1.5 shadow-xs backdrop-blur-[5px] hover:bg-components-button-secondary-bg-hover'
>
{icon}
<div className='text-xs font-medium uppercase text-text-secondary'>{t(`dataset.retrieval.${retrievalMethod}.title`)}</div>
<RiEqualizer2Line className='size-4 text-components-menu-item-text'></RiEqualizer2Line>
</div>
}
</div>
{
isSettingsOpen && (
<ModifyExternalRetrievalModal
onClose={() => setIsSettingsOpen(false)}
onSave={handleSaveExternalRetrievalSettings}
initialTopK={externalRetrievalSettings.top_k}
initialScoreThreshold={externalRetrievalSettings.score_threshold}
initialScoreThresholdEnabled={externalRetrievalSettings.score_threshold_enabled}
/>
)
}
<div className='h-2 rounded-t-xl bg-background-default'></div>
</div>
<div className='rounded-b-xl bg-background-default px-4 pb-11'>
<textarea
className='h-[220px] w-full resize-none border-none bg-transparent text-sm font-normal text-text-secondary caret-[#295EFF] placeholder:text-sm placeholder:font-normal placeholder:text-components-input-text-placeholder focus-visible:outline-none'
value={text}
onChange={handleTextChange}
placeholder={t('datasetHitTesting.input.placeholder') as string}
/>
<div className="absolute inset-x-0 bottom-0 mx-4 mb-2 mt-2 flex items-center justify-between">
{text?.length > 200
? (
<Tooltip
popupContent={t('datasetHitTesting.input.countWarning')}
>
<div
className={cn('flex h-5 items-center rounded-md bg-background-section-burn px-1 text-xs font-medium text-red-600', !text?.length && 'opacity-50')}
>
{text?.length}
<span className="mx-0.5 text-red-300">/</span>
200
</div>
</Tooltip>
)
: (
<div
className={cn('flex h-5 items-center rounded-md bg-background-section-burn px-1 text-xs font-medium text-text-tertiary', !text?.length && 'opacity-50')}
>
{text?.length}
<span className="mx-0.5 text-divider-deep">/</span>
200
</div>
)}
<div>
<Button
onClick={isExternal ? externalRetrievalTestingOnSubmit : onSubmit}
variant="primary"
loading={loading}
disabled={(!text?.length || text?.length > 200)}
className='w-[88px]'
>
{t('datasetHitTesting.input.testing')}
</Button>
</div>
</div>
</div>
</div>
</>
)
}
export default TextAreaWithButton

View File

@ -156,7 +156,7 @@ const DatasetCard = ({
return (
<>
<div
className='group relative col-span-1 flex h-[166px] cursor-pointer flex-col rounded-xl border-[0.5px] border-solid border-components-card-border bg-components-card-bg shadow-xs shadow-shadow-shadow-3 transition-all duration-200 ease-in-out hover:bg-components-card-bg-alt hover:shadow-md hover:shadow-shadow-shadow-5'
className='group relative col-span-1 flex h-[190px] cursor-pointer flex-col rounded-xl border-[0.5px] border-solid border-components-card-border bg-components-card-bg shadow-xs shadow-shadow-shadow-3 transition-all duration-200 ease-in-out hover:bg-components-card-bg-alt hover:shadow-md hover:shadow-shadow-shadow-5'
data-disable-nprogress={true}
onClick={(e) => {
e.preventDefault()
@ -170,7 +170,13 @@ const DatasetCard = ({
>
{!dataset.embedding_available && (
<CornerLabel
label='Unavailable'
label={t('dataset.cornerLabel.unavailable')}
className='absolute right-0 top-0 z-10'
labelClassName='rounded-tr-xl' />
)}
{dataset.embedding_available && dataset.runtime_mode === 'rag_pipeline' && (
<CornerLabel
label={t('dataset.cornerLabel.pipeline')}
className='absolute right-0 top-0 z-10'
labelClassName='rounded-tr-xl' />
)}
@ -205,8 +211,30 @@ const DatasetCard = ({
{isExternalProvider && <span>{t('dataset.externalKnowledgeBase')}</span>}
{!isExternalProvider && isShowDocModeInfo && (
<>
{dataset.doc_form && <span>{t(`dataset.chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}`)}</span>}
{dataset.indexing_technique && <span>{formatIndexingTechniqueAndMethod(dataset.indexing_technique, dataset.retrieval_model_dict?.search_method)}</span>}
{dataset.doc_form && (
<span
className='min-w-0 max-w-full truncate'
title={t(`dataset.chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}`)}
>
{t(`dataset.chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}`)}
</span>
)}
{dataset.indexing_technique && (
<span
className='min-w-0 max-w-full truncate'
title={formatIndexingTechniqueAndMethod(dataset.indexing_technique, dataset.retrieval_model_dict?.search_method)}
>
{formatIndexingTechniqueAndMethod(dataset.indexing_technique, dataset.retrieval_model_dict?.search_method)}
</span>
)}
{dataset.is_multimodal && (
<span
className='min-w-0 max-w-full truncate'
title={t('dataset.multimodal')}
>
{t('dataset.multimodal')}
</span>
)}
</>
)}
</div>
@ -273,7 +301,7 @@ const DatasetCard = ({
<span className='system-xs-regular text-divider-deep'>/</span>
<span className='system-xs-regular'>{`${t('dataset.updated')} ${formatTimeFromNow(dataset.updated_at * 1000)}`}</span>
</div>
<div className='absolute right-2 top-2 z-[5] hidden group-hover:block'>
<div className='absolute right-2 top-2 z-[15] hidden group-hover:block'>
<CustomPopover
htmlContent={
<Operations

View File

@ -12,7 +12,7 @@ const CreateAppCard = () => {
const { t } = useTranslation()
return (
<div className='flex h-[166px] flex-col gap-y-0.5 rounded-xl bg-background-default-dimmed'>
<div className='flex h-[190px] flex-col gap-y-0.5 rounded-xl bg-background-default-dimmed'>
<div className='flex grow flex-col items-center justify-center p-2'>
<Option
href={'/datasets/create'}

View File

@ -1,5 +1,5 @@
'use client'
import { useCallback, useRef, useState } from 'react'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useMount } from 'ahooks'
import { useTranslation } from 'react-i18next'
import PermissionSelector from '../permission-selector'
@ -20,10 +20,7 @@ import type { AppIconType, RetrievalConfig } from '@/types/app'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { isReRankModelSelected } from '@/app/components/datasets/common/check-rerank-model'
import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector'
import {
useModelList,
useModelListAndDefaultModelAndCurrentProviderAndModel,
} from '@/app/components/header/account-setting/model-provider-page/hooks'
import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
import type { DefaultModel } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { fetchMembers } from '@/service/common'
@ -37,6 +34,7 @@ import Toast from '@/app/components/base/toast'
import { RiAlertFill } from '@remixicon/react'
import { useDocLink } from '@/context/i18n'
import { useInvalidDatasetList } from '@/service/knowledge/use-dataset'
import { checkShowMultiModalTip } from '../utils'
const rowClass = 'flex gap-x-1'
const labelClass = 'flex items-center shrink-0 w-[180px] h-7 pt-1'
@ -79,9 +77,7 @@ const Form = () => {
model: '',
},
)
const {
modelList: rerankModelList,
} = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.rerank)
const { data: rerankModelList } = useModelList(ModelTypeEnum.rerank)
const { data: embeddingModelList } = useModelList(ModelTypeEnum.textEmbedding)
const previousAppIcon = useRef(DEFAULT_APP_ICON)
@ -203,6 +199,20 @@ const Form = () => {
const isShowIndexMethod = currentDataset && currentDataset.doc_form !== ChunkingMode.parentChild && currentDataset.indexing_technique && indexMethod
const showMultiModalTip = useMemo(() => {
return checkShowMultiModalTip({
embeddingModel,
rerankingEnable: retrievalConfig.reranking_enable,
rerankModel: {
rerankingProviderName: retrievalConfig.reranking_model.reranking_provider_name,
rerankingModelName: retrievalConfig.reranking_model.reranking_model_name,
},
indexMethod,
embeddingModelList,
rerankModelList,
})
}, [embeddingModel, rerankModelList, retrievalConfig.reranking_enable, retrievalConfig.reranking_model, embeddingModelList, indexMethod])
return (
<div className='flex w-full flex-col gap-y-4 px-20 py-8 sm:w-[960px]'>
{/* Dataset name and icon */}
@ -434,6 +444,7 @@ const Form = () => {
<RetrievalMethodConfig
value={retrievalConfig}
onChange={setRetrievalConfig}
showMultiModalTip={showMultiModalTip}
/>
)
: (

View File

@ -0,0 +1,46 @@
import { type DefaultModel, type Model, ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { IndexingType } from '../../create/step-two'
type ShowMultiModalTipProps = {
embeddingModel: DefaultModel
rerankingEnable: boolean
rerankModel: {
rerankingProviderName: string
rerankingModelName: string
}
indexMethod: IndexingType | undefined
embeddingModelList: Model[]
rerankModelList: Model[]
}
export const checkShowMultiModalTip = ({
embeddingModel,
rerankingEnable,
rerankModel,
indexMethod,
embeddingModelList,
rerankModelList,
}: ShowMultiModalTipProps) => {
if (indexMethod !== IndexingType.QUALIFIED || !embeddingModel.provider || !embeddingModel.model)
return false
const currentEmbeddingModelProvider = embeddingModelList.find(model => model.provider === embeddingModel.provider)
if (!currentEmbeddingModelProvider)
return false
const currentEmbeddingModel = currentEmbeddingModelProvider.models.find(model => model.model === embeddingModel.model)
if (!currentEmbeddingModel)
return false
const isCurrentEmbeddingModelSupportMultiModal = !!currentEmbeddingModel.features?.includes(ModelFeatureEnum.vision)
if (!isCurrentEmbeddingModelSupportMultiModal)
return false
const { rerankingModelName, rerankingProviderName } = rerankModel
if (!rerankingEnable || !rerankingModelName || !rerankingProviderName)
return false
const currentRerankingModelProvider = rerankModelList.find(model => model.provider === rerankingProviderName)
if (!currentRerankingModelProvider)
return false
const currentRerankingModel = currentRerankingModelProvider.models.find(model => model.model === rerankingModelName)
if (!currentRerankingModel)
return false
const isRerankingModelSupportMultiModal = !!currentRerankingModel.features?.includes(ModelFeatureEnum.vision)
return !isRerankingModelSupportMultiModal
}

View File

@ -17,6 +17,7 @@ type ModelNameProps = PropsWithChildren<{
showMode?: boolean
modeClassName?: string
showFeatures?: boolean
showFeaturesLabel?: boolean
featuresClassName?: string
showContextSize?: boolean
}>
@ -28,6 +29,7 @@ const ModelName: FC<ModelNameProps> = ({
showMode,
modeClassName,
showFeatures,
showFeaturesLabel,
featuresClassName,
showContextSize,
children,
@ -59,15 +61,6 @@ const ModelName: FC<ModelNameProps> = ({
</ModelBadge>
)
}
{
showFeatures && modelItem.features?.map(feature => (
<FeatureIcon
key={feature}
feature={feature}
className={featuresClassName}
/>
))
}
{
showContextSize && modelItem.model_properties.context_size && (
<ModelBadge>
@ -75,6 +68,16 @@ const ModelName: FC<ModelNameProps> = ({
</ModelBadge>
)
}
{
showFeatures && modelItem.features?.map(feature => (
<FeatureIcon
key={feature}
feature={feature}
className={featuresClassName}
showFeaturesLabel={showFeaturesLabel}
/>
))
}
</div>
{children}
</div>

View File

@ -5,24 +5,24 @@ import {
ModelFeatureEnum,
ModelFeatureTextEnum,
} from '../declarations'
import {
AudioSupportIcon,
DocumentSupportIcon,
// MagicBox,
MagicEyes,
// MagicWand,
// Robot,
VideoSupportIcon,
} from '@/app/components/base/icons/src/vender/solid/mediaAndDevices'
import Tooltip from '@/app/components/base/tooltip'
import {
RiFileTextLine,
RiFilmAiLine,
RiImageCircleAiLine,
RiVoiceAiFill,
} from '@remixicon/react'
import cn from '@/utils/classnames'
type FeatureIconProps = {
feature: ModelFeatureEnum
className?: string
showFeaturesLabel?: boolean
}
const FeatureIcon: FC<FeatureIconProps> = ({
className,
feature,
showFeaturesLabel,
}) => {
const { t } = useTranslation()
@ -63,13 +63,29 @@ const FeatureIcon: FC<FeatureIconProps> = ({
// }
if (feature === ModelFeatureEnum.vision) {
if (showFeaturesLabel) {
return (
<ModelBadge
className={cn('gap-x-0.5', className)}
>
<RiImageCircleAiLine className='size-3' />
<span>{ModelFeatureTextEnum.vision}</span>
</ModelBadge>
)
}
return (
<Tooltip
popupContent={t('common.modelProvider.featureSupported', { feature: ModelFeatureTextEnum.vision })}
>
<div className='inline-block cursor-help'>
<ModelBadge className={`w-[18px] justify-center !px-0 text-text-tertiary ${className}`}>
<MagicEyes className='h-3 w-3' />
<ModelBadge
className={cn(
'w-[18px] justify-center !px-0',
className,
)}
>
<RiImageCircleAiLine className='size-3' />
</ModelBadge>
</div>
</Tooltip>
@ -77,13 +93,29 @@ const FeatureIcon: FC<FeatureIconProps> = ({
}
if (feature === ModelFeatureEnum.document) {
if (showFeaturesLabel) {
return (
<ModelBadge
className={cn('gap-x-0.5', className)}
>
<RiFileTextLine className='size-3' />
<span>{ModelFeatureTextEnum.document}</span>
</ModelBadge>
)
}
return (
<Tooltip
popupContent={t('common.modelProvider.featureSupported', { feature: ModelFeatureTextEnum.document })}
>
<div className='inline-block cursor-help'>
<ModelBadge className={`w-[18px] justify-center !px-0 text-text-tertiary ${className}`}>
<DocumentSupportIcon className='h-3 w-3' />
<ModelBadge
className={cn(
'w-[18px] justify-center !px-0',
className,
)}
>
<RiFileTextLine className='size-3' />
</ModelBadge>
</div>
</Tooltip>
@ -91,13 +123,29 @@ const FeatureIcon: FC<FeatureIconProps> = ({
}
if (feature === ModelFeatureEnum.audio) {
if (showFeaturesLabel) {
return (
<ModelBadge
className={cn('gap-x-0.5', className)}
>
<RiVoiceAiFill className='size-3' />
<span>{ModelFeatureTextEnum.audio}</span>
</ModelBadge>
)
}
return (
<Tooltip
popupContent={t('common.modelProvider.featureSupported', { feature: ModelFeatureTextEnum.audio })}
>
<div className='inline-block cursor-help'>
<ModelBadge className={`w-[18px] justify-center !px-0 text-text-tertiary ${className}`}>
<AudioSupportIcon className='h-3 w-3' />
<ModelBadge
className={cn(
'w-[18px] justify-center !px-0',
className,
)}
>
<RiVoiceAiFill className='size-3' />
</ModelBadge>
</div>
</Tooltip>
@ -105,13 +153,29 @@ const FeatureIcon: FC<FeatureIconProps> = ({
}
if (feature === ModelFeatureEnum.video) {
if (showFeaturesLabel) {
return (
<ModelBadge
className={cn('gap-x-0.5', className)}
>
<RiFilmAiLine className='size-3' />
<span>{ModelFeatureTextEnum.video}</span>
</ModelBadge>
)
}
return (
<Tooltip
popupContent={t('common.modelProvider.featureSupported', { feature: ModelFeatureTextEnum.video })}
>
<div className='inline-block cursor-help'>
<ModelBadge className={`w-[18px] justify-center !px-0 text-text-tertiary ${className}`}>
<VideoSupportIcon className='h-3 w-3' />
<ModelBadge
className={cn(
'w-[18px] justify-center !px-0',
className,
)}
>
<RiFilmAiLine className='size-3' />
</ModelBadge>
</div>
</Tooltip>

View File

@ -1,11 +1,6 @@
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import {
RiFileTextLine,
RiFilmAiLine,
RiImageCircleAiLine,
RiVoiceAiFill,
} from '@remixicon/react'
import type {
DefaultModel,
Model,
@ -13,7 +8,6 @@ import type {
} from '../declarations'
import {
ModelFeatureEnum,
ModelFeatureTextEnum,
ModelTypeEnum,
} from '../declarations'
import {
@ -37,6 +31,7 @@ import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import Tooltip from '@/app/components/base/tooltip'
import cn from '@/utils/classnames'
import FeatureIcon from './feature-icon'
type PopupItemProps = {
defaultModel?: DefaultModel
@ -119,37 +114,23 @@ const PopupItem: FC<PopupItemProps> = ({
</ModelBadge>
)}
</div>
{modelItem.model_type === ModelTypeEnum.textGeneration && modelItem.features?.some(feature => [ModelFeatureEnum.vision, ModelFeatureEnum.audio, ModelFeatureEnum.video, ModelFeatureEnum.document].includes(feature)) && (
<div className='pt-2'>
<div className='system-2xs-medium-uppercase mb-1 text-text-tertiary'>{t('common.model.capabilities')}</div>
<div className='flex flex-wrap gap-1'>
{modelItem.features?.includes(ModelFeatureEnum.vision) && (
<ModelBadge>
<RiImageCircleAiLine className='mr-0.5 h-3.5 w-3.5' />
<span>{ModelFeatureTextEnum.vision}</span>
</ModelBadge>
)}
{modelItem.features?.includes(ModelFeatureEnum.audio) && (
<ModelBadge>
<RiVoiceAiFill className='mr-0.5 h-3.5 w-3.5' />
<span>{ModelFeatureTextEnum.audio}</span>
</ModelBadge>
)}
{modelItem.features?.includes(ModelFeatureEnum.video) && (
<ModelBadge>
<RiFilmAiLine className='mr-0.5 h-3.5 w-3.5' />
<span>{ModelFeatureTextEnum.video}</span>
</ModelBadge>
)}
{modelItem.features?.includes(ModelFeatureEnum.document) && (
<ModelBadge>
<RiFileTextLine className='mr-0.5 h-3.5 w-3.5' />
<span>{ModelFeatureTextEnum.document}</span>
</ModelBadge>
)}
{[ModelTypeEnum.textGeneration, ModelTypeEnum.textEmbedding, ModelTypeEnum.rerank].includes(modelItem.model_type as ModelTypeEnum)
&& modelItem.features?.some(feature => [ModelFeatureEnum.vision, ModelFeatureEnum.audio, ModelFeatureEnum.video, ModelFeatureEnum.document].includes(feature))
&& (
<div className='pt-2'>
<div className='system-2xs-medium-uppercase mb-1 text-text-tertiary'>{t('common.model.capabilities')}</div>
<div className='flex flex-wrap gap-1'>
{modelItem.features?.map(feature => (
<FeatureIcon
key={feature}
feature={feature}
showFeaturesLabel
/>
))
}
</div>
</div>
</div>
)}
)}
</div>
}
>

View File

@ -62,6 +62,8 @@ const ModelListItem = ({ model, provider, isConfigurable, onModifyLoadBalancing
showModelType
showMode
showContextSize
showFeatures
showFeaturesLabel
>
</ModelName>
<div className='flex shrink-0 items-center'>

View File

@ -591,16 +591,8 @@ const formatItem = (
variable: outputKey,
type:
output.type === 'array'
? (`Array[${output.items?.type
? output.items.type.slice(0, 1).toLocaleUpperCase()
+ output.items.type.slice(1)
: 'Unknown'
}]` as VarType)
: (`${output.type
? output.type.slice(0, 1).toLocaleUpperCase()
+ output.type.slice(1)
: 'Unknown'
}` as VarType),
? (`Array[${output.items?.type ? output.items.type.slice(0, 1).toLocaleUpperCase() + output.items.type.slice(1) : 'Unknown'}]` as VarType)
: (`${output.type ? output.type.slice(0, 1).toLocaleUpperCase() + output.type.slice(1) : 'Unknown'}` as VarType),
})
},
)
@ -858,13 +850,14 @@ export const toNodeOutputVars = (
filterVar,
allPluginInfoList,
ragVariablesInDataSource.map(
(ragVariable: RAGPipelineVariable) =>
({
(ragVariable: RAGPipelineVariable) => {
return {
variable: `rag.${node.id}.${ragVariable.variable}`,
type: inputVarTypeToVarType(ragVariable.type as any),
description: ragVariable.label,
isRagVariable: true,
} as Var),
} as Var
},
),
schemaTypeDefinitions,
),
@ -1301,7 +1294,11 @@ export const getNodeUsedVars = (node: Node): ValueSelector[] => {
break
}
case BlockEnum.KnowledgeRetrieval: {
res = [(data as KnowledgeRetrievalNodeType).query_variable_selector]
const {
query_variable_selector,
query_attachment_selector,
} = data as KnowledgeRetrievalNodeType
res = [query_variable_selector, query_attachment_selector]
break
}
case BlockEnum.IfElse: {
@ -1640,6 +1637,10 @@ export const updateNodeVars = (
payload.query_variable_selector.join('.') === oldVarSelector.join('.')
)
payload.query_variable_selector = newVarSelector
if (
payload.query_attachment_selector.join('.') === oldVarSelector.join('.')
)
payload.query_attachment_selector = newVarSelector
break
}
case BlockEnum.IfElse: {

View File

@ -27,6 +27,7 @@ type RetrievalSettingProps = {
onRerankingModelEnabledChange?: (value: boolean) => void
weightedScore?: WeightedScore
onWeightedScoreChange: (value: { value: number[] }) => void
showMultiModalTip?: boolean
} & RerankingModelSelectorProps & TopKAndScoreThresholdProps
const RetrievalSetting = ({
@ -48,6 +49,7 @@ const RetrievalSetting = ({
onScoreThresholdChange,
isScoreThresholdEnabled,
onScoreThresholdEnabledChange,
showMultiModalTip,
}: RetrievalSettingProps) => {
const { t } = useTranslation()
const {
@ -91,6 +93,7 @@ const RetrievalSetting = ({
rerankingModel={rerankingModel}
onRerankingModelChange={onRerankingModelChange}
readonly={readonly}
showMultiModalTip={showMultiModalTip}
/>
))
}

View File

@ -25,6 +25,7 @@ import type { TopKAndScoreThresholdProps } from './top-k-and-score-threshold'
import TopKAndScoreThreshold from './top-k-and-score-threshold'
import type { RerankingModelSelectorProps } from './reranking-model-selector'
import RerankingModelSelector from './reranking-model-selector'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
type SearchMethodOptionProps = {
readonly?: boolean
@ -38,6 +39,7 @@ type SearchMethodOptionProps = {
onWeightedScoreChange: (value: { value: number[] }) => void
rerankingModelEnabled?: boolean
onRerankingModelEnabledChange?: (value: boolean) => void
showMultiModalTip?: boolean
} & RerankingModelSelectorProps & TopKAndScoreThresholdProps
const SearchMethodOption = ({
readonly,
@ -59,6 +61,7 @@ const SearchMethodOption = ({
onScoreThresholdChange,
isScoreThresholdEnabled,
onScoreThresholdEnabledChange,
showMultiModalTip = false,
}: SearchMethodOptionProps) => {
const { t } = useTranslation()
const Icon = option.icon
@ -183,6 +186,17 @@ const SearchMethodOption = ({
onRerankingModelChange={onRerankingModelChange}
readonly={readonly}
/>
{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>
)
}

View File

@ -25,6 +25,9 @@ import Split from '../_base/components/split'
import { useNodesReadOnly } from '@/app/components/workflow/hooks'
import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker'
import type { Var } from '@/app/components/workflow/types'
import { checkShowMultiModalTip } from '@/app/components/datasets/settings/utils'
import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
const Panel: FC<NodePanelProps<KnowledgeBaseNodeType>> = ({
id,
@ -32,6 +35,9 @@ const Panel: FC<NodePanelProps<KnowledgeBaseNodeType>> = ({
}) => {
const { t } = useTranslation()
const { nodesReadOnly } = useNodesReadOnly()
const { data: embeddingModelList } = useModelList(ModelTypeEnum.textEmbedding)
const { data: rerankModelList } = useModelList(ModelTypeEnum.rerank)
const {
handleChunkStructureChange,
handleIndexMethodChange,
@ -52,9 +58,9 @@ const Panel: FC<NodePanelProps<KnowledgeBaseNodeType>> = ({
if (!data.chunk_structure) return false
switch (data.chunk_structure) {
case ChunkStructureEnum.general:
return variable.schemaType === 'general_structure'
return variable.schemaType === 'general_structure' || variable.schemaType === 'multimodal_general_structure'
case ChunkStructureEnum.parent_child:
return variable.schemaType === 'parent_child_structure'
return variable.schemaType === 'parent_child_structure' || variable.schemaType === 'multimodal_parent_child_structure'
case ChunkStructureEnum.question_answer:
return variable.schemaType === 'qa_structure'
default:
@ -67,10 +73,10 @@ const Panel: FC<NodePanelProps<KnowledgeBaseNodeType>> = ({
let placeholder = ''
switch (data.chunk_structure) {
case ChunkStructureEnum.general:
placeholder = 'general_structure'
placeholder = '(multimodal_)general_structure'
break
case ChunkStructureEnum.parent_child:
placeholder = 'parent_child_structure'
placeholder = '(multimodal_)parent_child_structure'
break
case ChunkStructureEnum.question_answer:
placeholder = 'qa_structure'
@ -81,6 +87,23 @@ const Panel: FC<NodePanelProps<KnowledgeBaseNodeType>> = ({
return placeholder.charAt(0).toUpperCase() + placeholder.slice(1)
}, [data.chunk_structure])
const showMultiModalTip = useMemo(() => {
return checkShowMultiModalTip({
embeddingModel: {
provider: data.embedding_model_provider ?? '',
model: data.embedding_model ?? '',
},
rerankingEnable: !!data.retrieval_model?.reranking_enable,
rerankModel: {
rerankingProviderName: data.retrieval_model?.reranking_model?.reranking_provider_name ?? '',
rerankingModelName: data.retrieval_model?.reranking_model?.reranking_model_name ?? '',
},
indexMethod: data.indexing_technique,
embeddingModelList,
rerankModelList,
})
}, [data.embedding_model_provider, data.embedding_model, data.retrieval_model?.reranking_enable, data.retrieval_model?.reranking_model, data.indexing_technique, embeddingModelList, rerankModelList])
return (
<div>
<Group
@ -161,6 +184,7 @@ const Panel: FC<NodePanelProps<KnowledgeBaseNodeType>> = ({
onScoreThresholdChange={handleScoreThresholdChange}
isScoreThresholdEnabled={data.retrieval_model.score_threshold_enabled}
onScoreThresholdEnabledChange={handleScoreThresholdEnabledChange}
showMultiModalTip={showMultiModalTip}
readonly={nodesReadOnly}
/>
</div>

View File

@ -15,6 +15,8 @@ import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import Badge from '@/app/components/base/badge'
import { useKnowledge } from '@/hooks/use-knowledge'
import AppIcon from '@/app/components/base/app-icon'
import FeatureIcon from '@/app/components/header/account-setting/model-provider-page/model-selector/feature-icon'
import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
type Props = {
payload: DataSet
@ -98,6 +100,11 @@ const DatasetItem: FC<Props> = ({
</ActionButton>
</div>
)}
{payload.is_multimodal && (
<div className='mr-1 shrink-0 group-hover/dataset-item:hidden'>
<FeatureIcon feature={ModelFeatureEnum.vision} />
</div>
)}
{
payload.indexing_technique && <Badge
className='shrink-0 group-hover/dataset-item:hidden'

View File

@ -15,6 +15,7 @@ const nodeDefault: NodeDefault<KnowledgeRetrievalNodeType> = {
metaData,
defaultValue: {
query_variable_selector: [],
query_attachment_selector: [],
dataset_ids: [],
retrieval_mode: RETRIEVE_TYPE.multiWay,
multiple_retrieval_config: {
@ -25,8 +26,6 @@ const nodeDefault: NodeDefault<KnowledgeRetrievalNodeType> = {
},
checkValid(payload: KnowledgeRetrievalNodeType, t: any) {
let errorMessages = ''
if (!errorMessages && (!payload.query_variable_selector || payload.query_variable_selector.length === 0))
errorMessages = t(`${i18nPrefix}.errorMsg.fieldRequired`, { field: t(`${i18nPrefix}.nodes.knowledgeRetrieval.queryVariable`) })
if (!errorMessages && (!payload.dataset_ids || payload.dataset_ids.length === 0))
errorMessages = t(`${i18nPrefix}.errorMsg.fieldRequired`, { field: t(`${i18nPrefix}.nodes.knowledgeRetrieval.knowledge`) })

View File

@ -29,7 +29,9 @@ const Panel: FC<NodePanelProps<KnowledgeRetrievalNodeType>> = ({
readOnly,
inputs,
handleQueryVarChange,
filterVar,
handleQueryAttachmentChange,
filterStringVar,
filterFileVar,
handleModelChanged,
handleCompletionParamsChange,
handleRetrievalModeChange,
@ -50,6 +52,7 @@ const Panel: FC<NodePanelProps<KnowledgeRetrievalNodeType>> = ({
availableStringNodesWithParent,
availableNumberVars,
availableNumberNodesWithParent,
showImageQueryVarSelector,
} = useConfig(id, data)
const metadataList = useMemo(() => {
@ -63,20 +66,30 @@ const Panel: FC<NodePanelProps<KnowledgeRetrievalNodeType>> = ({
return (
<div className='pt-2'>
<div className='space-y-4 px-4 pb-2'>
<Field
title={t(`${i18nPrefix}.queryVariable`)}
required
>
<Field title={t(`${i18nPrefix}.queryText`)}>
<VarReferencePicker
nodeId={id}
readonly={readOnly}
isShowNodeName
value={inputs.query_variable_selector}
onChange={handleQueryVarChange}
filterVar={filterVar}
filterVar={filterStringVar}
/>
</Field>
{showImageQueryVarSelector && (
<Field title={t(`${i18nPrefix}.queryAttachment`)}>
<VarReferencePicker
nodeId={id}
readonly={readOnly}
isShowNodeName
value={inputs.query_attachment_selector}
onChange={handleQueryAttachmentChange}
filterVar={filterFileVar}
/>
</Field>
)}
<Field
title={t(`${i18nPrefix}.knowledge`)}
required
@ -170,6 +183,11 @@ const Panel: FC<NodePanelProps<KnowledgeRetrievalNodeType>> = ({
type: 'object',
description: t(`${i18nPrefix}.outputVars.metadata`),
},
{
name: 'files',
type: 'Array[File]',
description: t(`${i18nPrefix}.outputVars.files`),
},
]}
/>

View File

@ -97,6 +97,7 @@ export type MetadataFilteringConditions = {
export type KnowledgeRetrievalNodeType = CommonNodeType & {
query_variable_selector: ValueSelector
query_attachment_selector: ValueSelector
dataset_ids: string[]
retrieval_mode: RETRIEVE_TYPE
multiple_retrieval_config?: MultipleRetrievalConfig

View File

@ -1,6 +1,7 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
@ -72,6 +73,13 @@ const useConfig = (id: string, payload: KnowledgeRetrievalNodeType) => {
setInputs(newInputs)
}, [inputs, setInputs])
const handleQueryAttachmentChange = useCallback((newVar: ValueSelector | string) => {
const newInputs = produce(inputs, (draft) => {
draft.query_attachment_selector = newVar as ValueSelector
})
setInputs(newInputs)
}, [inputs, setInputs])
const {
currentProvider,
currentModel,
@ -250,6 +258,7 @@ const useConfig = (id: string, payload: KnowledgeRetrievalNodeType) => {
allInternal,
allExternal,
} = getSelectedDatasetsMode(newDatasets)
const noMultiModalDatasets = newDatasets.every(d => !d.is_multimodal)
const newInputs = produce(inputs, (draft) => {
draft.dataset_ids = newDatasets.map(d => d.id)
@ -261,6 +270,9 @@ const useConfig = (id: string, payload: KnowledgeRetrievalNodeType) => {
})
draft.multiple_retrieval_config = newMultipleRetrievalConfig
}
if (noMultiModalDatasets)
draft.query_attachment_selector = []
})
updateDatasetsDetail(newDatasets)
setInputs(newInputs)
@ -274,10 +286,18 @@ const useConfig = (id: string, payload: KnowledgeRetrievalNodeType) => {
setRerankModelOpen(true)
}, [inputs, setInputs, payload.retrieval_mode, selectedDatasets, currentRerankModel, currentRerankProvider, updateDatasetsDetail])
const filterVar = useCallback((varPayload: Var) => {
const filterStringVar = useCallback((varPayload: Var) => {
return varPayload.type === VarType.string
}, [])
const filterNumberVar = useCallback((varPayload: Var) => {
return varPayload.type === VarType.number
}, [])
const filterFileVar = useCallback((varPayload: Var) => {
return varPayload.type === VarType.file || varPayload.type === VarType.arrayFile
}, [])
const handleMetadataFilterModeChange = useCallback((newMode: MetadataFilteringModeEnum) => {
setInputs(produce(inputRef.current, (draft) => {
draft.metadata_filtering_mode = newMode
@ -361,10 +381,6 @@ const useConfig = (id: string, payload: KnowledgeRetrievalNodeType) => {
setInputs(newInputs)
}, [setInputs])
const filterStringVar = useCallback((varPayload: Var) => {
return [VarType.string].includes(varPayload.type)
}, [])
const {
availableVars: availableStringVars,
availableNodesWithParent: availableStringNodesWithParent,
@ -373,10 +389,6 @@ const useConfig = (id: string, payload: KnowledgeRetrievalNodeType) => {
filterVar: filterStringVar,
})
const filterNumberVar = useCallback((varPayload: Var) => {
return [VarType.number].includes(varPayload.type)
}, [])
const {
availableVars: availableNumberVars,
availableNodesWithParent: availableNumberNodesWithParent,
@ -385,11 +397,17 @@ const useConfig = (id: string, payload: KnowledgeRetrievalNodeType) => {
filterVar: filterNumberVar,
})
const showImageQueryVarSelector = useMemo(() => {
return selectedDatasets.some(d => d.is_multimodal)
}, [selectedDatasets])
return {
readOnly,
inputs,
handleQueryVarChange,
filterVar,
handleQueryAttachmentChange,
filterStringVar,
filterFileVar,
handleRetrievalModeChange,
handleMultipleRetrievalConfigChange,
handleModelChanged,
@ -410,6 +428,7 @@ const useConfig = (id: string, payload: KnowledgeRetrievalNodeType) => {
availableStringNodesWithParent,
availableNumberVars,
availableNumberNodesWithParent,
showImageQueryVarSelector,
}
}

View File

@ -1,9 +1,14 @@
import type { RefObject } from 'react'
import { useTranslation } from 'react-i18next'
import type { InputVar, Variable } from '@/app/components/workflow/types'
import { InputVarType } from '@/app/components/workflow/types'
import type { InputVar, Var, Variable } from '@/app/components/workflow/types'
import { InputVarType, VarType } from '@/app/components/workflow/types'
import { useCallback, useMemo } from 'react'
import type { KnowledgeRetrievalNodeType } from './types'
import type { Props as FormProps } from '@/app/components/workflow/nodes/_base/components/before-run-form/form'
import { useDatasetsDetailStore } from '../../datasets-detail-store/store'
import type { DataSet } from '@/models/datasets'
import useAvailableVarList from '../_base/hooks/use-available-var-list'
import { findVariableWhenOnLLMVision } from '../utils'
const i18nPrefix = 'workflow.nodes.knowledgeRetrieval'
@ -17,40 +22,89 @@ type Params = {
toVarInputs: (variables: Variable[]) => InputVar[]
}
const useSingleRunFormParams = ({
id,
payload,
runInputData,
runInputDataRef,
setRunInputData,
}: Params) => {
const { t } = useTranslation()
const datasetsDetail = useDatasetsDetailStore(s => s.datasetsDetail)
const query = runInputData.query
const queryAttachment = runInputData.queryAttachment
const setQuery = useCallback((newQuery: string) => {
setRunInputData({
...runInputData,
...runInputDataRef.current,
query: newQuery,
})
}, [runInputData, setRunInputData])
}, [runInputDataRef, setRunInputData])
const setQueryAttachment = useCallback((newQueryAttachment: string) => {
setRunInputData({
...runInputDataRef.current,
queryAttachment: newQueryAttachment,
})
}, [runInputDataRef, setRunInputData])
const filterFileVar = useCallback((varPayload: Var) => {
return [VarType.file, VarType.arrayFile].includes(varPayload.type)
}, [])
// Get all variables from previous nodes that are file or array of file
const {
availableVars: availableFileVars,
} = useAvailableVarList(id, {
onlyLeafNodeVar: false,
filterVar: filterFileVar,
})
const forms = useMemo(() => {
return [
const datasetIds = payload.dataset_ids
const datasets = datasetIds.reduce<DataSet[]>((acc, id) => {
if (datasetsDetail[id])
acc.push(datasetsDetail[id])
return acc
}, [])
const hasMultiModalDatasets = datasets.some(d => d.is_multimodal)
const inputFields: FormProps[] = [
{
inputs: [{
label: t(`${i18nPrefix}.queryVariable`)!,
label: t(`${i18nPrefix}.queryText`)!,
variable: 'query',
type: InputVarType.paragraph,
required: true,
required: false,
}],
values: { query },
onChange: (keyValue: Record<string, any>) => setQuery(keyValue.query),
},
]
}, [query, setQuery, t])
if (hasMultiModalDatasets) {
const currentVariable = findVariableWhenOnLLMVision(payload.query_attachment_selector, availableFileVars)
inputFields.push(
{
inputs: [{
label: t(`${i18nPrefix}.queryAttachment`)!,
variable: 'queryAttachment',
type: currentVariable?.formType as InputVarType,
required: false,
}],
values: { queryAttachment },
onChange: (keyValue: Record<string, any>) => setQueryAttachment(keyValue.queryAttachment),
},
)
}
return inputFields
}, [query, setQuery, t, datasetsDetail, payload.dataset_ids, payload.query_attachment_selector, availableFileVars, queryAttachment, setQueryAttachment])
const getDependentVars = () => {
return [payload.query_variable_selector]
return [payload.query_variable_selector, payload.query_attachment_selector]
}
const getDependentVar = (variable: string) => {
if(variable === 'query')
if (variable === 'query')
return payload.query_variable_selector
if (variable === 'queryAttachment')
return payload.query_attachment_selector
}
return {

View File

@ -378,6 +378,7 @@ const translation = {
answerEmpty: 'Answer can not be empty',
contentPlaceholder: 'Add content here',
contentEmpty: 'Content can not be empty',
allFilesUploaded: 'All files must be uploaded before saving',
newTextSegment: 'New Text Segment',
newQaSegment: 'New Q&A Segment',
addChunk: 'Add Chunk',

View File

@ -7,7 +7,7 @@ const translation = {
table: {
header: {
source: 'Source',
text: 'Text',
queryContent: 'Query Content',
time: 'Time',
},
},
@ -29,6 +29,12 @@ const translation = {
hitChunks: 'Hit {{num}} child chunks',
open: 'Open',
keyword: 'Keywords',
imageUploader: {
tip: 'Upload or drop images (Max {{batchCount}}, {{size}}MB each)',
tooltip: 'Upload images (Max {{batchCount}}, {{size}}MB each)',
dropZoneTip: 'Drag file here to upload',
singleChunkAttachmentLimitTooltip: 'The number of single chunk attachments cannot exceed {{limit}}',
},
}
export default translation

View File

@ -38,6 +38,7 @@ const translation = {
learnMore: 'Learn more',
description: ' about retrieval method.',
longDescription: ' about retrieval method, you can change this at any time in the Knowledge settings.',
multiModalTip: 'When embedding model supports multi-modal, please select a multi-modal rerank model for better performance.',
},
externalKnowledgeAPI: 'External Knowledge API',
externalKnowledgeID: 'External Knowledge ID',

View File

@ -236,6 +236,16 @@ const translation = {
apiReference: 'API Reference',
},
},
cornerLabel: {
unavailable: 'Unavailable',
pipeline: 'Pipeline',
},
multimodal: 'Multimodal',
imageUploader: {
button: 'Drag and drop file or folder, or',
browse: 'Browse',
tip: '{{supportTypes}} (Max {{batchCount}}, {{size}}MB each)',
},
}
export default translation

View File

@ -562,6 +562,8 @@ const translation = {
},
knowledgeRetrieval: {
queryVariable: 'Query Variable',
queryText: 'Query Text',
queryAttachment: 'Query Images',
knowledge: 'Knowledge',
outputVars: {
output: 'Retrieval segmented data',
@ -570,6 +572,7 @@ const translation = {
icon: 'Segmented icon',
url: 'Segmented URL',
metadata: 'Other metadata',
files: 'Retrieved files',
},
metadata: {
title: 'Metadata Filtering',

View File

@ -375,6 +375,7 @@ const translation = {
answerEmpty: '答案不能为空',
contentPlaceholder: '在这里添加内容',
contentEmpty: '内容不能为空',
allFilesUploaded: '所有文件必须上传完成才能保存',
newTextSegment: '新文本分段',
newQaSegment: '新问答分段',
addChunk: '新增分段',

View File

@ -7,6 +7,7 @@ const translation = {
table: {
header: {
source: '数据源',
queryContent: '查询内容',
text: '文本',
time: '时间',
},
@ -29,6 +30,12 @@ const translation = {
hitChunks: '命中 {{num}} 个子段落',
open: '打开',
keyword: '关键词',
imageUploader: {
tip: '上传或拖拽图片 (最多 {{batchCount}} 个,每个大小不超过 {{size}}MB)',
tooltip: '上传图片 (最多 {{batchCount}} 个,每个大小不超过 {{size}}MB)',
dropZoneTip: '拖拽文件到这里上传',
singleChunkAttachmentLimitTooltip: '单个分段附件数量不能超过 {{limit}}',
},
}
export default translation

View File

@ -38,6 +38,7 @@ const translation = {
learnMore: '了解更多',
description: '关于检索方法。',
longDescription: '关于检索方法,您可以随时在知识库设置中更改此设置。',
multiModalTip: '当 Embedding 模型支持多模态时,请选择多模态 Rerank 模型以获得更好的检索效果。',
},
externalKnowledgeAPI: '外部知识 API',
externalKnowledgeID: '外部知识库 ID',

View File

@ -236,6 +236,14 @@ const translation = {
apiReference: 'API 文档',
},
},
cornerLabel: {
unavailable: '不可用',
pipeline: '流水线',
},
multimodal: '多模态',
imageUploader: {
tip: '支持 {{supportTypes}} (最多 {{batchCount}} 个,每个大小不超过 {{size}}MB)',
},
}
export default translation

View File

@ -562,6 +562,8 @@ const translation = {
},
knowledgeRetrieval: {
queryVariable: '查询变量',
queryText: '查询文本',
queryAttachment: '查询图片',
knowledge: '知识库',
outputVars: {
output: '召回的分段',
@ -570,6 +572,7 @@ const translation = {
icon: '分段图标',
url: '分段链接',
metadata: '其他元数据',
files: '召回的文件',
},
metadata: {
title: '元数据过滤',

View File

@ -232,6 +232,9 @@ export type PluginProvider = {
export type FileUploadConfigResponse = {
batch_count_limit: number
image_file_size_limit?: number | string // default is 10MB
image_file_batch_limit: number // default is 10, for dataset attachment upload only
single_chunk_attachment_limit: number // default is 10, for dataset attachment upload only
attachment_image_file_size_limit: number // default is 2MB, for dataset attachment upload only
file_size_limit: number // default is 15MB
audio_file_size_limit?: number // default is 50MB
video_file_size_limit?: number // default is 100MB

View File

@ -85,7 +85,8 @@ export type DataSet = {
pipeline_id?: string
is_published?: boolean // Indicates if the pipeline is published
runtime_mode: 'rag_pipeline' | 'general'
enable_api: boolean
enable_api: boolean // Indicates if the service API is enabled
is_multimodal: boolean // Indicates if the dataset supports multimodal
}
export type ExternalAPIItem = {
@ -541,6 +542,15 @@ export type SegmentsQuery = {
enabled?: boolean | 'all'
}
export type Attachment = {
id: string
name: string
size: number
extension: string
mime_type: string
source_url: string
}
export type SegmentDetailModel = {
id: string
position: number
@ -566,6 +576,7 @@ export type SegmentDetailModel = {
answer?: string
child_chunks?: ChildChunkDetail[]
updated_at: number
attachments: Attachment[]
}
export type SegmentsResponse = {
@ -577,14 +588,20 @@ export type SegmentsResponse = {
page: number
}
export type Query = {
content: string
content_type: 'text_query' | 'image_query',
file_info: Attachment | null
}
export type HitTestingRecord = {
id: string
content: string
source: 'app' | 'hit_testing' | 'plugin'
source_app_id: string
created_by_role: 'account' | 'end_user'
created_by: string
created_at: number
queries: Query[]
}
export type HitTestingChildChunk = {
@ -598,7 +615,8 @@ export type HitTesting = {
content: Segment
score: number
tsne_position: TsnePosition
child_chunks?: HitTestingChildChunk[] | null
child_chunks: HitTestingChildChunk[] | null
files: Attachment[]
}
export type ExternalKnowledgeBaseHitTesting = {
@ -680,6 +698,7 @@ export type SegmentUpdater = {
answer?: string
keywords?: string[]
regenerate_child_chunks?: boolean
attachment_ids?: string[]
}
export type ErrorDocsResponse = {
@ -814,3 +833,24 @@ export type IndexingStatusBatchRequest = {
datasetId: string
batchId: string
}
export type HitTestingRecordsRequest = {
datasetId: string
page: number
limit: number
}
export type HitTestingRequest = {
query: string
attachment_ids: string[]
retrieval_model: RetrievalConfig
}
export type ExternalKnowledgeBaseHitTestingRequest = {
query: string
external_retrieval_model: {
top_k: number
score_threshold: number
score_threshold_enabled: boolean
}
}

View File

@ -1 +1,45 @@
export {}
import { useMutation, useQuery } from '@tanstack/react-query'
import { useInvalid } from '../use-base'
import type {
ExternalKnowledgeBaseHitTestingRequest,
ExternalKnowledgeBaseHitTestingResponse,
HitTestingRecordsRequest,
HitTestingRecordsResponse,
HitTestingRequest,
HitTestingResponse,
} from '@/models/datasets'
import { get, post } from '../base'
const NAME_SPACE = 'hit-testing'
const HitTestingRecordsKey = [NAME_SPACE, 'records']
export const useHitTestingRecords = (params: HitTestingRecordsRequest) => {
const { datasetId, page, limit } = params
return useQuery({
queryKey: [...HitTestingRecordsKey, datasetId, page, limit],
queryFn: () => get<HitTestingRecordsResponse>(`/datasets/${datasetId}/queries`, { params: { page, limit } }),
})
}
export const useInvalidateHitTestingRecords = (datasetId: string) => {
return useInvalid([...HitTestingRecordsKey, datasetId])
}
export const useHitTesting = (datasetId: string) => {
return useMutation({
mutationKey: [NAME_SPACE, 'hit-testing', datasetId],
mutationFn: (params: HitTestingRequest) => post<HitTestingResponse>(`/datasets/${datasetId}/hit-testing`, {
body: params,
}),
})
}
export const useExternalKnowledgeBaseHitTesting = (datasetId: string) => {
return useMutation({
mutationKey: [NAME_SPACE, 'external-knowledge-base-hit-testing', datasetId],
mutationFn: (params: ExternalKnowledgeBaseHitTestingRequest) => post<ExternalKnowledgeBaseHitTestingResponse>(`/datasets/${datasetId}/external-hit-testing`, {
body: params,
}),
})
}

View File

@ -44,6 +44,7 @@ html[data-theme="dark"] {
rgba(0, 0, 0, 0.00) 0%,
rgba(24, 24, 27, 0.02) 8%,
rgba(24, 24, 27, 0.54) 100%);
--color-dataset-warning-message-bg: linear-gradient(92deg, rgba(247, 144, 9, 0.30) 0%, rgba(0, 0, 0, 0.00) 100%);
--color-dataset-chunk-process-success-bg: linear-gradient(92deg, rgba(23, 178, 106, 0.30) 0%, rgba(0, 0, 0, 0.00) 100%);
--color-dataset-chunk-process-error-bg: linear-gradient(92deg, rgba(240, 68, 56, 0.30) 0%, rgba(0, 0, 0, 0.00) 100%);
--color-dataset-chunk-detail-card-hover-bg: linear-gradient(180deg, #1D1D20 0%, #222225 100%);

View File

@ -44,6 +44,7 @@ html[data-theme="light"] {
rgba(0, 0, 0, 0.00) 0%,
rgba(16, 24, 40, 0.01) 8%,
rgba(16, 24, 40, 0.18) 100%);
--color-dataset-warning-message-bg: linear-gradient(92deg, rgba(247, 144, 9, 0.25) 0%, rgba(255, 255, 255, 0.00) 100%);
--color-dataset-chunk-process-success-bg: linear-gradient(92deg, rgba(23, 178, 106, 0.25) 0%, rgba(255, 255, 255, 0.00) 100%);
--color-dataset-chunk-process-error-bg: linear-gradient(92deg, rgba(240, 68, 56, 0.25) 0%, rgba(255, 255, 255, 0.00) 100%);
--color-dataset-chunk-detail-card-hover-bg: linear-gradient(180deg, #F2F4F7 0%, #F9FAFB 100%);