mirror of https://github.com/langgenius/dify.git
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:
parent
a44b800c85
commit
14d1b3f9b3
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -0,0 +1,223 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { formatFileSize } from '@/utils/format'
|
||||
import { RiArrowLeftLine, RiArrowRightLine, RiCloseLine, RiRefreshLine } from '@remixicon/react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
|
||||
type CachedImage = {
|
||||
blobUrl?: string
|
||||
status: 'loading' | 'loaded' | 'error'
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
const imageCache = new Map<string, CachedImage>()
|
||||
|
||||
export type ImageInfo = {
|
||||
url: string
|
||||
name: string
|
||||
size: number
|
||||
}
|
||||
|
||||
type ImagePreviewerProps = {
|
||||
images: ImageInfo[]
|
||||
initialIndex?: number
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const ImagePreviewer = ({
|
||||
images,
|
||||
initialIndex = 0,
|
||||
onClose,
|
||||
}: ImagePreviewerProps) => {
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex)
|
||||
const [cachedImages, setCachedImages] = useState<Record<string, CachedImage>>(() => {
|
||||
return images.reduce((acc, image) => {
|
||||
acc[image.url] = {
|
||||
status: 'loading',
|
||||
width: 0,
|
||||
height: 0,
|
||||
}
|
||||
return acc
|
||||
}, {} as Record<string, CachedImage>)
|
||||
})
|
||||
const isMounted = useRef(false)
|
||||
|
||||
const fetchImage = useCallback(async (image: ImageInfo) => {
|
||||
const { url } = image
|
||||
// Skip if already cached
|
||||
if (imageCache.has(url)) return
|
||||
|
||||
try {
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) throw new Error(`Failed to load: ${url}`)
|
||||
const blob = await res.blob()
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
|
||||
const img = new Image()
|
||||
img.src = blobUrl
|
||||
img.onload = () => {
|
||||
if (!isMounted.current) return
|
||||
imageCache.set(url, {
|
||||
blobUrl,
|
||||
status: 'loaded',
|
||||
width: img.naturalWidth,
|
||||
height: img.naturalHeight,
|
||||
})
|
||||
setCachedImages((prev) => {
|
||||
return {
|
||||
...prev,
|
||||
[url]: {
|
||||
blobUrl,
|
||||
status: 'loaded',
|
||||
width: img.naturalWidth,
|
||||
height: img.naturalHeight,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
catch {
|
||||
if (isMounted.current) {
|
||||
setCachedImages((prev) => {
|
||||
return {
|
||||
...prev,
|
||||
[url]: {
|
||||
status: 'error',
|
||||
width: 0,
|
||||
height: 0,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
isMounted.current = true
|
||||
|
||||
images.forEach((image) => {
|
||||
fetchImage(image)
|
||||
})
|
||||
|
||||
return () => {
|
||||
isMounted.current = false
|
||||
// Cleanup released blob URLs not in current list
|
||||
imageCache.forEach(({ blobUrl }, key) => {
|
||||
if (blobUrl)
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
imageCache.delete(key)
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
const currentImage = useMemo(() => {
|
||||
return images[currentIndex]
|
||||
}, [images, currentIndex])
|
||||
|
||||
const prevImage = useCallback(() => {
|
||||
if (currentIndex === 0)
|
||||
return
|
||||
setCurrentIndex(prevIndex => prevIndex - 1)
|
||||
}, [currentIndex])
|
||||
|
||||
const nextImage = useCallback(() => {
|
||||
if (currentIndex === images.length - 1)
|
||||
return
|
||||
setCurrentIndex(prevIndex => prevIndex + 1)
|
||||
}, [currentIndex, images.length])
|
||||
|
||||
const retryImage = useCallback((image: ImageInfo) => {
|
||||
setCachedImages((prev) => {
|
||||
return {
|
||||
...prev,
|
||||
[image.url]: {
|
||||
...prev[image.url],
|
||||
status: 'loading',
|
||||
},
|
||||
}
|
||||
})
|
||||
fetchImage(image)
|
||||
}, [fetchImage])
|
||||
|
||||
useHotkeys('esc', onClose)
|
||||
useHotkeys('left', prevImage)
|
||||
useHotkeys('right', nextImage)
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className='image-previewer fixed inset-0 z-[10000] flex items-center justify-center bg-background-overlay-fullscreen p-5 pb-4 backdrop-blur-[6px]'
|
||||
onClick={e => e.stopPropagation()}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className='absolute right-6 top-6 z-10 flex cursor-pointer flex-col items-center gap-y-1'>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
onClick={onClose}
|
||||
className='size-9 rounded-[10px] p-0'
|
||||
size='large'
|
||||
>
|
||||
<RiCloseLine className='size-5' />
|
||||
</Button>
|
||||
<span className='system-2xs-medium-uppercase text-text-tertiary'>
|
||||
Esc
|
||||
</span>
|
||||
</div>
|
||||
{cachedImages[currentImage.url].status === 'loading' && (
|
||||
<Loading type='app' />
|
||||
)}
|
||||
{cachedImages[currentImage.url].status === 'error' && (
|
||||
<div className='system-sm-regular flex max-w-sm flex-col items-center gap-y-2 text-text-tertiary'>
|
||||
<span>{`Failed to load image: ${currentImage.url}. Please try again.`}</span>
|
||||
<Button
|
||||
variant='secondary'
|
||||
onClick={() => retryImage(currentImage)}
|
||||
className='size-9 rounded-full p-0'
|
||||
size='large'
|
||||
>
|
||||
<RiRefreshLine className='size-5' />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{cachedImages[currentImage.url].status === 'loaded' && (
|
||||
<div className='flex size-full flex-col items-center justify-center gap-y-2'>
|
||||
<img
|
||||
alt={currentImage.name}
|
||||
src={cachedImages[currentImage.url].blobUrl}
|
||||
className='max-h-[calc(100%-2.5rem)] max-w-full object-contain shadow-lg ring-8 ring-effects-image-frame backdrop-blur-[5px]'
|
||||
/>
|
||||
<div className='system-sm-regular flex shrink-0 gap-x-2 pb-1 pt-3 text-text-tertiary'>
|
||||
<span>{currentImage.name}</span>
|
||||
<span>·</span>
|
||||
<span>{`${cachedImages[currentImage.url].width} × ${cachedImages[currentImage.url].height}`}</span>
|
||||
<span>·</span>
|
||||
<span>{formatFileSize(currentImage.size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant='secondary'
|
||||
onClick={prevImage}
|
||||
className='absolute left-8 top-1/2 z-10 size-9 -translate-y-1/2 rounded-full p-0'
|
||||
disabled={currentIndex === 0}
|
||||
size='large'
|
||||
>
|
||||
<RiArrowLeftLine className='size-5' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='secondary'
|
||||
onClick={nextImage}
|
||||
className='absolute right-8 top-1/2 z-10 size-9 -translate-y-1/2 rounded-full p-0'
|
||||
disabled={currentIndex === images.length - 1}
|
||||
size='large'
|
||||
>
|
||||
<RiArrowRightLine className='size-5' />
|
||||
</Button>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
export default ImagePreviewer
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
export const ACCEPT_TYPES = ['jpg', 'jpeg', 'png', 'gif']
|
||||
|
||||
export const DEFAULT_IMAGE_FILE_SIZE_LIMIT = 2
|
||||
|
||||
export const DEFAULT_IMAGE_FILE_BATCH_LIMIT = 5
|
||||
|
||||
export const DEFAULT_SINGLE_CHUNK_ATTACHMENT_LIMIT = 10
|
||||
|
|
@ -0,0 +1,273 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useFileUploadConfig } from '@/service/use-common'
|
||||
import type { FileEntity, FileUploadConfig } from '../types'
|
||||
import { getFileType, getFileUploadConfig, traverseFileEntry } from '../utils'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ACCEPT_TYPES } from '../constants'
|
||||
import { useFileStore } from '../store'
|
||||
import { produce } from 'immer'
|
||||
import { fileUpload, getFileUploadErrorMessage } from '@/app/components/base/file-uploader/utils'
|
||||
import { v4 as uuid4 } from 'uuid'
|
||||
|
||||
export const useUpload = () => {
|
||||
const { t } = useTranslation()
|
||||
const fileStore = useFileStore()
|
||||
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const uploaderRef = useRef<HTMLInputElement>(null)
|
||||
const dragRef = useRef<HTMLDivElement>(null)
|
||||
const dropRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const { data: fileUploadConfigResponse } = useFileUploadConfig()
|
||||
|
||||
const fileUploadConfig: FileUploadConfig = useMemo(() => {
|
||||
return getFileUploadConfig(fileUploadConfigResponse)
|
||||
}, [fileUploadConfigResponse])
|
||||
|
||||
const handleDragEnter = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (e.target !== dragRef.current)
|
||||
setDragging(true)
|
||||
}
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
const handleDragLeave = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (e.target === dragRef.current)
|
||||
setDragging(false)
|
||||
}
|
||||
|
||||
const checkFileType = useCallback((file: File) => {
|
||||
const ext = getFileType(file)
|
||||
return ACCEPT_TYPES.includes(ext.toLowerCase())
|
||||
}, [])
|
||||
|
||||
const checkFileSize = useCallback((file: File) => {
|
||||
const { size } = file
|
||||
return size <= fileUploadConfig.imageFileSizeLimit * 1024 * 1024
|
||||
}, [fileUploadConfig])
|
||||
|
||||
const showErrorMessage = useCallback((type: 'type' | 'size') => {
|
||||
if (type === 'type')
|
||||
Toast.notify({ type: 'error', message: t('common.fileUploader.fileExtensionNotSupport') })
|
||||
else
|
||||
Toast.notify({ type: 'error', message: t('dataset.imageUploader.fileSizeLimitExceeded', { size: fileUploadConfig.imageFileSizeLimit }) })
|
||||
}, [fileUploadConfig, t])
|
||||
|
||||
const getValidFiles = useCallback((files: File[]) => {
|
||||
let validType = true
|
||||
let validSize = true
|
||||
const validFiles = files.filter((file) => {
|
||||
if (!checkFileType(file)) {
|
||||
validType = false
|
||||
return false
|
||||
}
|
||||
if (!checkFileSize(file)) {
|
||||
validSize = false
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
if (!validType)
|
||||
showErrorMessage('type')
|
||||
else if (!validSize)
|
||||
showErrorMessage('size')
|
||||
|
||||
return validFiles
|
||||
}, [checkFileType, checkFileSize, showErrorMessage])
|
||||
|
||||
const selectHandle = () => {
|
||||
if (uploaderRef.current)
|
||||
uploaderRef.current.click()
|
||||
}
|
||||
|
||||
const handleAddFile = useCallback((newFile: FileEntity) => {
|
||||
const {
|
||||
files,
|
||||
setFiles,
|
||||
} = fileStore.getState()
|
||||
|
||||
const newFiles = produce(files, (draft) => {
|
||||
draft.push(newFile)
|
||||
})
|
||||
setFiles(newFiles)
|
||||
}, [fileStore])
|
||||
|
||||
const handleUpdateFile = useCallback((newFile: FileEntity) => {
|
||||
const {
|
||||
files,
|
||||
setFiles,
|
||||
} = fileStore.getState()
|
||||
|
||||
const newFiles = produce(files, (draft) => {
|
||||
const index = draft.findIndex(file => file.id === newFile.id)
|
||||
|
||||
if (index > -1)
|
||||
draft[index] = newFile
|
||||
})
|
||||
setFiles(newFiles)
|
||||
}, [fileStore])
|
||||
|
||||
const handleRemoveFile = useCallback((fileId: string) => {
|
||||
const {
|
||||
files,
|
||||
setFiles,
|
||||
} = fileStore.getState()
|
||||
|
||||
const newFiles = files.filter(file => file.id !== fileId)
|
||||
setFiles(newFiles)
|
||||
}, [fileStore])
|
||||
|
||||
const handleReUploadFile = useCallback((fileId: string) => {
|
||||
const {
|
||||
files,
|
||||
setFiles,
|
||||
} = fileStore.getState()
|
||||
const index = files.findIndex(file => file.id === fileId)
|
||||
|
||||
if (index > -1) {
|
||||
const uploadingFile = files[index]
|
||||
const newFiles = produce(files, (draft) => {
|
||||
draft[index].progress = 0
|
||||
})
|
||||
setFiles(newFiles)
|
||||
fileUpload({
|
||||
file: uploadingFile.originalFile!,
|
||||
onProgressCallback: (progress) => {
|
||||
handleUpdateFile({ ...uploadingFile, progress })
|
||||
},
|
||||
onSuccessCallback: (res) => {
|
||||
handleUpdateFile({ ...uploadingFile, uploadedId: res.id, progress: 100 })
|
||||
},
|
||||
onErrorCallback: (error?: any) => {
|
||||
const errorMessage = getFileUploadErrorMessage(error, t('common.fileUploader.uploadFromComputerUploadError'), t)
|
||||
Toast.notify({ type: 'error', message: errorMessage })
|
||||
handleUpdateFile({ ...uploadingFile, progress: -1 })
|
||||
},
|
||||
})
|
||||
}
|
||||
}, [fileStore, t, handleUpdateFile])
|
||||
|
||||
const handleLocalFileUpload = useCallback((file: File) => {
|
||||
const reader = new FileReader()
|
||||
const isImage = file.type.startsWith('image')
|
||||
|
||||
reader.addEventListener(
|
||||
'load',
|
||||
() => {
|
||||
const uploadingFile = {
|
||||
id: uuid4(),
|
||||
name: file.name,
|
||||
extension: getFileType(file),
|
||||
mimeType: file.type,
|
||||
size: file.size,
|
||||
progress: 0,
|
||||
originalFile: file,
|
||||
base64Url: isImage ? reader.result as string : '',
|
||||
}
|
||||
handleAddFile(uploadingFile)
|
||||
fileUpload({
|
||||
file: uploadingFile.originalFile,
|
||||
onProgressCallback: (progress) => {
|
||||
handleUpdateFile({ ...uploadingFile, progress })
|
||||
},
|
||||
onSuccessCallback: (res) => {
|
||||
handleUpdateFile({
|
||||
...uploadingFile,
|
||||
extension: res.extension,
|
||||
mimeType: res.mime_type,
|
||||
size: res.size,
|
||||
uploadedId: res.id,
|
||||
progress: 100,
|
||||
})
|
||||
},
|
||||
onErrorCallback: (error?: any) => {
|
||||
const errorMessage = getFileUploadErrorMessage(error, t('common.fileUploader.uploadFromComputerUploadError'), t)
|
||||
Toast.notify({ type: 'error', message: errorMessage })
|
||||
handleUpdateFile({ ...uploadingFile, progress: -1 })
|
||||
},
|
||||
})
|
||||
},
|
||||
false,
|
||||
)
|
||||
reader.addEventListener(
|
||||
'error',
|
||||
() => {
|
||||
Toast.notify({ type: 'error', message: t('common.fileUploader.uploadFromComputerReadError') })
|
||||
},
|
||||
false,
|
||||
)
|
||||
reader.readAsDataURL(file)
|
||||
}, [t, handleAddFile, handleUpdateFile])
|
||||
|
||||
const handleFileUpload = useCallback((newFiles: File[]) => {
|
||||
const { files } = fileStore.getState()
|
||||
const { singleChunkAttachmentLimit } = fileUploadConfig
|
||||
if (newFiles.length === 0) return
|
||||
if (files.length + newFiles.length > singleChunkAttachmentLimit) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('datasetHitTesting.imageUploader.singleChunkAttachmentLimitTooltip', { limit: singleChunkAttachmentLimit }),
|
||||
})
|
||||
return
|
||||
}
|
||||
for (const file of newFiles)
|
||||
handleLocalFileUpload(file)
|
||||
}, [fileUploadConfig, fileStore, t, handleLocalFileUpload])
|
||||
|
||||
const fileChangeHandle = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { imageFileBatchLimit } = fileUploadConfig
|
||||
const files = Array.from(e.target.files ?? []).slice(0, imageFileBatchLimit)
|
||||
const validFiles = getValidFiles(files)
|
||||
handleFileUpload(validFiles)
|
||||
}, [getValidFiles, handleFileUpload, fileUploadConfig])
|
||||
|
||||
const handleDrop = useCallback(async (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragging(false)
|
||||
if (!e.dataTransfer) return
|
||||
const nested = await Promise.all(
|
||||
Array.from(e.dataTransfer.items).map((it) => {
|
||||
const entry = (it as any).webkitGetAsEntry?.()
|
||||
if (entry) return traverseFileEntry(entry)
|
||||
const f = it.getAsFile?.()
|
||||
return f ? Promise.resolve([f]) : Promise.resolve([])
|
||||
}),
|
||||
)
|
||||
const files = nested.flat().slice(0, fileUploadConfig.imageFileBatchLimit)
|
||||
const validFiles = getValidFiles(files)
|
||||
handleFileUpload(validFiles)
|
||||
}, [fileUploadConfig, handleFileUpload, getValidFiles])
|
||||
|
||||
useEffect(() => {
|
||||
dropRef.current?.addEventListener('dragenter', handleDragEnter)
|
||||
dropRef.current?.addEventListener('dragover', handleDragOver)
|
||||
dropRef.current?.addEventListener('dragleave', handleDragLeave)
|
||||
dropRef.current?.addEventListener('drop', handleDrop)
|
||||
return () => {
|
||||
dropRef.current?.removeEventListener('dragenter', handleDragEnter)
|
||||
dropRef.current?.removeEventListener('dragover', handleDragOver)
|
||||
dropRef.current?.removeEventListener('dragleave', handleDragLeave)
|
||||
dropRef.current?.removeEventListener('drop', handleDrop)
|
||||
}
|
||||
}, [handleDrop])
|
||||
|
||||
return {
|
||||
dragging,
|
||||
fileUploadConfig,
|
||||
dragRef,
|
||||
dropRef,
|
||||
uploaderRef,
|
||||
fileChangeHandle,
|
||||
selectHandle,
|
||||
handleRemoveFile,
|
||||
handleReUploadFile,
|
||||
handleLocalFileUpload,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import React from 'react'
|
||||
import cn from '@/utils/classnames'
|
||||
import { RiUploadCloud2Line } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useUpload } from '../hooks/use-upload'
|
||||
import { ACCEPT_TYPES } from '../constants'
|
||||
|
||||
const ImageUploader = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const {
|
||||
dragging,
|
||||
fileUploadConfig,
|
||||
dragRef,
|
||||
dropRef,
|
||||
uploaderRef,
|
||||
fileChangeHandle,
|
||||
selectHandle,
|
||||
} = useUpload()
|
||||
|
||||
return (
|
||||
<div className='w-full'>
|
||||
<input
|
||||
ref={uploaderRef}
|
||||
id='fileUploader'
|
||||
className='hidden'
|
||||
type='file'
|
||||
multiple
|
||||
accept={ACCEPT_TYPES.map(ext => `.${ext}`).join(',')}
|
||||
onChange={fileChangeHandle}
|
||||
/>
|
||||
<div
|
||||
ref={dropRef}
|
||||
className={cn(
|
||||
'relative flex h-16 flex-col items-center justify-center gap-1 rounded-[10px] border border-dashed border-components-dropzone-border bg-components-dropzone-bg px-4 py-3 text-text-tertiary',
|
||||
dragging && 'border-components-dropzone-border-accent bg-components-dropzone-bg-accent',
|
||||
)}
|
||||
>
|
||||
<div className='system-sm-medium flex items-center justify-center gap-x-2 text-text-secondary'>
|
||||
<RiUploadCloud2Line className='size-5 text-text-tertiary' />
|
||||
<div>
|
||||
<span>{t('dataset.imageUploader.button')}</span>
|
||||
<span
|
||||
className='ml-1 cursor-pointer text-text-accent'
|
||||
onClick={selectHandle}
|
||||
>
|
||||
{t('dataset.imageUploader.browse')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='system-xs-regular'>
|
||||
{t('dataset.imageUploader.tip', {
|
||||
size: fileUploadConfig.imageFileSizeLimit,
|
||||
supportTypes: ACCEPT_TYPES.join(', '),
|
||||
batchCount: fileUploadConfig.imageFileBatchLimit,
|
||||
})}
|
||||
</div>
|
||||
{dragging && <div ref={dragRef} className='absolute inset-0' />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(ImageUploader)
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
} from 'react'
|
||||
import {
|
||||
RiCloseLine,
|
||||
} from '@remixicon/react'
|
||||
import FileImageRender from '@/app/components/base/file-uploader/file-image-render'
|
||||
import type { FileEntity } from '../types'
|
||||
import ProgressCircle from '@/app/components/base/progress-bar/progress-circle'
|
||||
import { ReplayLine } from '@/app/components/base/icons/src/vender/other'
|
||||
import { fileIsUploaded } from '../utils'
|
||||
import Button from '@/app/components/base/button'
|
||||
|
||||
type ImageItemProps = {
|
||||
file: FileEntity
|
||||
showDeleteAction?: boolean
|
||||
onRemove?: (fileId: string) => void
|
||||
onReUpload?: (fileId: string) => void
|
||||
onPreview?: (fileId: string) => void
|
||||
}
|
||||
const ImageItem = ({
|
||||
file,
|
||||
showDeleteAction,
|
||||
onRemove,
|
||||
onReUpload,
|
||||
onPreview,
|
||||
}: ImageItemProps) => {
|
||||
const { id, progress, base64Url, sourceUrl } = file
|
||||
|
||||
const handlePreview = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
onPreview?.(id)
|
||||
}, [onPreview, id])
|
||||
|
||||
const handleRemove = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
onRemove?.(id)
|
||||
}, [onRemove, id])
|
||||
|
||||
const handleReUpload = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
onReUpload?.(id)
|
||||
}, [onReUpload, id])
|
||||
|
||||
return (
|
||||
<div
|
||||
className='group/file-image relative cursor-pointer'
|
||||
onClick={handlePreview}
|
||||
>
|
||||
{
|
||||
showDeleteAction && (
|
||||
<Button
|
||||
className='absolute -right-1.5 -top-1.5 z-[11] hidden h-5 w-5 rounded-full p-0 group-hover/file-image:flex'
|
||||
onClick={handleRemove}
|
||||
>
|
||||
<RiCloseLine className='h-4 w-4 text-components-button-secondary-text' />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
<FileImageRender
|
||||
className='h-[68px] w-[68px] shadow-md'
|
||||
imageUrl={base64Url || sourceUrl || ''}
|
||||
/>
|
||||
{
|
||||
progress >= 0 && !fileIsUploaded(file) && (
|
||||
<div className='absolute inset-0 z-10 flex items-center justify-center border-[2px] border-effects-image-frame bg-background-overlay-alt'>
|
||||
<ProgressCircle
|
||||
percentage={progress}
|
||||
size={12}
|
||||
circleStrokeColor='stroke-components-progress-white-border'
|
||||
circleFillColor='fill-transparent'
|
||||
sectorFillColor='fill-components-progress-white-progress'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
progress === -1 && (
|
||||
<div
|
||||
className='absolute inset-0 z-10 flex items-center justify-center border-[2px] border-state-destructive-border bg-background-overlay-destructive'
|
||||
onClick={handleReUpload}
|
||||
>
|
||||
<ReplayLine className='size-5 text-text-primary-on-surface' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ImageItem)
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
import {
|
||||
FileContextProvider,
|
||||
useFileStoreWithSelector,
|
||||
} from '../store'
|
||||
import type { FileEntity } from '../types'
|
||||
import FileItem from './image-item'
|
||||
import { useUpload } from '../hooks/use-upload'
|
||||
import ImageInput from './image-input'
|
||||
import cn from '@/utils/classnames'
|
||||
import { useCallback, useState } from 'react'
|
||||
import type { ImageInfo } from '@/app/components/datasets/common/image-previewer'
|
||||
import ImagePreviewer from '@/app/components/datasets/common/image-previewer'
|
||||
|
||||
type ImageUploaderInChunkProps = {
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
const ImageUploaderInChunk = ({
|
||||
disabled,
|
||||
className,
|
||||
}: ImageUploaderInChunkProps) => {
|
||||
const files = useFileStoreWithSelector(s => s.files)
|
||||
const [previewIndex, setPreviewIndex] = useState(0)
|
||||
const [previewImages, setPreviewImages] = useState<ImageInfo[]>([])
|
||||
|
||||
const handleImagePreview = useCallback((fileId: string) => {
|
||||
const index = files.findIndex(item => item.id === fileId)
|
||||
if (index === -1) return
|
||||
setPreviewIndex(index)
|
||||
setPreviewImages(files.map(item => ({
|
||||
url: item.base64Url || item.sourceUrl || '',
|
||||
name: item.name,
|
||||
size: item.size,
|
||||
})))
|
||||
}, [files])
|
||||
|
||||
const handleClosePreview = useCallback(() => {
|
||||
setPreviewImages([])
|
||||
}, [])
|
||||
|
||||
const {
|
||||
handleRemoveFile,
|
||||
handleReUploadFile,
|
||||
} = useUpload()
|
||||
|
||||
return (
|
||||
<div className={cn('w-full', className)}>
|
||||
{!disabled && <ImageInput />}
|
||||
<div className='flex flex-wrap gap-2 py-1'>
|
||||
{
|
||||
files.map(file => (
|
||||
<FileItem
|
||||
key={file.id}
|
||||
file={file}
|
||||
showDeleteAction={!disabled}
|
||||
onRemove={handleRemoveFile}
|
||||
onReUpload={handleReUploadFile}
|
||||
onPreview={handleImagePreview}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
{previewImages.length > 0 && (
|
||||
<ImagePreviewer
|
||||
images={previewImages}
|
||||
initialIndex={previewIndex}
|
||||
onClose={handleClosePreview}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type ImageUploaderInChunkWrapperProps = {
|
||||
value?: FileEntity[]
|
||||
onChange: (files: FileEntity[]) => void
|
||||
} & ImageUploaderInChunkProps
|
||||
|
||||
const ImageUploaderInChunkWrapper = ({
|
||||
value,
|
||||
onChange,
|
||||
...props
|
||||
}: ImageUploaderInChunkWrapperProps) => {
|
||||
return (
|
||||
<FileContextProvider
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
>
|
||||
<ImageUploaderInChunk {...props} />
|
||||
</FileContextProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default ImageUploaderInChunkWrapper
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useUpload } from '../hooks/use-upload'
|
||||
import { ACCEPT_TYPES } from '../constants'
|
||||
import { useFileStoreWithSelector } from '../store'
|
||||
import { RiImageAddLine } from '@remixicon/react'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
|
||||
const ImageUploader = () => {
|
||||
const { t } = useTranslation()
|
||||
const files = useFileStoreWithSelector(s => s.files)
|
||||
|
||||
const {
|
||||
fileUploadConfig,
|
||||
uploaderRef,
|
||||
fileChangeHandle,
|
||||
selectHandle,
|
||||
} = useUpload()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
ref={uploaderRef}
|
||||
id='fileUploader'
|
||||
className='hidden'
|
||||
type='file'
|
||||
multiple
|
||||
accept={ACCEPT_TYPES.map(ext => `.${ext}`).join(',')}
|
||||
onChange={fileChangeHandle}
|
||||
/>
|
||||
<div className='flex flex-wrap gap-1'>
|
||||
<Tooltip
|
||||
popupContent={t('datasetHitTesting.imageUploader.tooltip', {
|
||||
size: fileUploadConfig.imageFileSizeLimit,
|
||||
batchCount: fileUploadConfig.imageFileBatchLimit,
|
||||
})}
|
||||
popupClassName='system-xs-medium p-1.5 rounded-lg text-text-secondary'
|
||||
position='top'
|
||||
offset={4}
|
||||
disabled={files.length === 0}
|
||||
>
|
||||
<div
|
||||
className='group flex cursor-pointer items-center gap-x-2'
|
||||
onClick={selectHandle}
|
||||
>
|
||||
<div className='flex size-8 items-center justify-center rounded-lg border-[1px] border-dashed border-components-dropzone-border bg-components-button-tertiary-bg group-hover:bg-components-button-tertiary-bg-hover'>
|
||||
<RiImageAddLine className='size-4 text-text-tertiary' />
|
||||
</div>
|
||||
{files.length === 0 && (
|
||||
<span className='system-sm-regular text-text-quaternary group-hover:text-text-tertiary'>
|
||||
{t('datasetHitTesting.imageUploader.tip', {
|
||||
size: fileUploadConfig.imageFileSizeLimit,
|
||||
batchCount: fileUploadConfig.imageFileBatchLimit,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(ImageUploader)
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
} from 'react'
|
||||
import {
|
||||
RiCloseLine,
|
||||
} from '@remixicon/react'
|
||||
import FileImageRender from '@/app/components/base/file-uploader/file-image-render'
|
||||
import type { FileEntity } from '../types'
|
||||
import ProgressCircle from '@/app/components/base/progress-bar/progress-circle'
|
||||
import { ReplayLine } from '@/app/components/base/icons/src/vender/other'
|
||||
import { fileIsUploaded } from '../utils'
|
||||
import Button from '@/app/components/base/button'
|
||||
|
||||
type ImageItemProps = {
|
||||
file: FileEntity
|
||||
showDeleteAction?: boolean
|
||||
onRemove?: (fileId: string) => void
|
||||
onReUpload?: (fileId: string) => void
|
||||
onPreview?: (fileId: string) => void
|
||||
}
|
||||
const ImageItem = ({
|
||||
file,
|
||||
showDeleteAction,
|
||||
onRemove,
|
||||
onReUpload,
|
||||
onPreview,
|
||||
}: ImageItemProps) => {
|
||||
const { id, progress, base64Url, sourceUrl } = file
|
||||
|
||||
const handlePreview = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
onPreview?.(id)
|
||||
}, [onPreview, id])
|
||||
|
||||
const handleRemove = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
onRemove?.(id)
|
||||
}, [onRemove, id])
|
||||
|
||||
const handleReUpload = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
onReUpload?.(id)
|
||||
}, [onReUpload, id])
|
||||
|
||||
return (
|
||||
<div
|
||||
className='group/file-image relative cursor-pointer'
|
||||
onClick={handlePreview}
|
||||
>
|
||||
{
|
||||
showDeleteAction && (
|
||||
<Button
|
||||
className='absolute -right-1.5 -top-1.5 z-[11] hidden h-5 w-5 rounded-full p-0 group-hover/file-image:flex'
|
||||
onClick={handleRemove}
|
||||
>
|
||||
<RiCloseLine className='h-4 w-4 text-components-button-secondary-text' />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
<FileImageRender
|
||||
className='size-20 shadow-md'
|
||||
imageUrl={base64Url || sourceUrl || ''}
|
||||
/>
|
||||
{
|
||||
progress >= 0 && !fileIsUploaded(file) && (
|
||||
<div className='absolute inset-0 z-10 flex items-center justify-center border-[2px] border-effects-image-frame bg-background-overlay-alt'>
|
||||
<ProgressCircle
|
||||
percentage={progress}
|
||||
size={12}
|
||||
circleStrokeColor='stroke-components-progress-white-border'
|
||||
circleFillColor='fill-transparent'
|
||||
sectorFillColor='fill-components-progress-white-progress'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
progress === -1 && (
|
||||
<div
|
||||
className='absolute inset-0 z-10 flex items-center justify-center border-[2px] border-state-destructive-border bg-background-overlay-destructive'
|
||||
onClick={handleReUpload}
|
||||
>
|
||||
<ReplayLine className='size-5 text-text-primary-on-surface' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ImageItem)
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
import {
|
||||
useCallback,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
FileContextProvider,
|
||||
} from '../store'
|
||||
import type { FileEntity } from '../types'
|
||||
import { useUpload } from '../hooks/use-upload'
|
||||
import ImageInput from './image-input'
|
||||
import cn from '@/utils/classnames'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useFileStoreWithSelector } from '../store'
|
||||
import ImageItem from './image-item'
|
||||
import type { ImageInfo } from '@/app/components/datasets/common/image-previewer'
|
||||
import ImagePreviewer from '@/app/components/datasets/common/image-previewer'
|
||||
|
||||
type ImageUploaderInRetrievalTestingProps = {
|
||||
textArea: React.ReactNode
|
||||
actionButton: React.ReactNode
|
||||
showUploader?: boolean
|
||||
className?: string
|
||||
actionAreaClassName?: string
|
||||
}
|
||||
const ImageUploaderInRetrievalTesting = ({
|
||||
textArea,
|
||||
actionButton,
|
||||
showUploader = true,
|
||||
className,
|
||||
actionAreaClassName,
|
||||
}: ImageUploaderInRetrievalTestingProps) => {
|
||||
const { t } = useTranslation()
|
||||
const files = useFileStoreWithSelector(s => s.files)
|
||||
const [previewIndex, setPreviewIndex] = useState(0)
|
||||
const [previewImages, setPreviewImages] = useState<ImageInfo[]>([])
|
||||
const {
|
||||
dragging,
|
||||
dragRef,
|
||||
dropRef,
|
||||
handleRemoveFile,
|
||||
handleReUploadFile,
|
||||
} = useUpload()
|
||||
|
||||
const handleImagePreview = useCallback((fileId: string) => {
|
||||
const index = files.findIndex(item => item.id === fileId)
|
||||
if (index === -1) return
|
||||
setPreviewIndex(index)
|
||||
setPreviewImages(files.map(item => ({
|
||||
url: item.base64Url || item.sourceUrl || '',
|
||||
name: item.name,
|
||||
size: item.size,
|
||||
})))
|
||||
}, [files])
|
||||
|
||||
const handleClosePreview = useCallback(() => {
|
||||
setPreviewImages([])
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dropRef}
|
||||
className={cn('relative flex w-full flex-col', className)}
|
||||
>
|
||||
{dragging && (
|
||||
<div
|
||||
className='absolute inset-0.5 z-10 flex items-center justify-center rounded-lg border-[1.5px] border-dashed border-components-dropzone-border-accent bg-components-dropzone-bg-accent'
|
||||
>
|
||||
<div>{t('datasetHitTesting.imageUploader.dropZoneTip')}</div>
|
||||
<div ref={dragRef} className='absolute inset-0' />
|
||||
</div>
|
||||
)}
|
||||
{textArea}
|
||||
{
|
||||
showUploader && !!files.length && (
|
||||
<div className='flex flex-wrap gap-1 bg-background-default px-4 py-2'>
|
||||
{
|
||||
files.map(file => (
|
||||
<ImageItem
|
||||
key={file.id}
|
||||
file={file}
|
||||
showDeleteAction
|
||||
onRemove={handleRemoveFile}
|
||||
onReUpload={handleReUploadFile}
|
||||
onPreview={handleImagePreview}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div
|
||||
className={cn(
|
||||
'flex',
|
||||
showUploader ? 'justify-between' : 'justify-end',
|
||||
actionAreaClassName,
|
||||
)}>
|
||||
{showUploader && <ImageInput />}
|
||||
{actionButton}
|
||||
</div>
|
||||
{previewImages.length > 0 && (
|
||||
<ImagePreviewer
|
||||
images={previewImages}
|
||||
initialIndex={previewIndex}
|
||||
onClose={handleClosePreview}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type ImageUploaderInRetrievalTestingWrapperProps = {
|
||||
value?: FileEntity[]
|
||||
onChange: (files: FileEntity[]) => void
|
||||
} & ImageUploaderInRetrievalTestingProps
|
||||
|
||||
const ImageUploaderInRetrievalTestingWrapper = ({
|
||||
value,
|
||||
onChange,
|
||||
...props
|
||||
}: ImageUploaderInRetrievalTestingWrapperProps) => {
|
||||
return (
|
||||
<FileContextProvider
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
>
|
||||
<ImageUploaderInRetrievalTesting {...props} />
|
||||
</FileContextProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default ImageUploaderInRetrievalTestingWrapper
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import type { FileUploadConfigResponse } from '@/models/common'
|
||||
import type { FileEntity } from './types'
|
||||
import {
|
||||
DEFAULT_IMAGE_FILE_BATCH_LIMIT,
|
||||
DEFAULT_IMAGE_FILE_SIZE_LIMIT,
|
||||
DEFAULT_SINGLE_CHUNK_ATTACHMENT_LIMIT,
|
||||
} from './constants'
|
||||
|
||||
export const getFileType = (currentFile: File) => {
|
||||
if (!currentFile)
|
||||
return ''
|
||||
|
||||
const arr = currentFile.name.split('.')
|
||||
return arr[arr.length - 1]
|
||||
}
|
||||
|
||||
type FileWithPath = {
|
||||
relativePath?: string
|
||||
} & File
|
||||
|
||||
export const traverseFileEntry = (entry: any, prefix = ''): Promise<FileWithPath[]> => {
|
||||
return new Promise((resolve) => {
|
||||
if (entry.isFile) {
|
||||
entry.file((file: FileWithPath) => {
|
||||
file.relativePath = `${prefix}${file.name}`
|
||||
resolve([file])
|
||||
})
|
||||
}
|
||||
else if (entry.isDirectory) {
|
||||
const reader = entry.createReader()
|
||||
const entries: any[] = []
|
||||
const read = () => {
|
||||
reader.readEntries(async (results: FileSystemEntry[]) => {
|
||||
if (!results.length) {
|
||||
const files = await Promise.all(
|
||||
entries.map(ent =>
|
||||
traverseFileEntry(ent, `${prefix}${entry.name}/`),
|
||||
),
|
||||
)
|
||||
resolve(files.flat())
|
||||
}
|
||||
else {
|
||||
entries.push(...results)
|
||||
read()
|
||||
}
|
||||
})
|
||||
}
|
||||
read()
|
||||
}
|
||||
else {
|
||||
resolve([])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const fileIsUploaded = (file: FileEntity) => {
|
||||
if (file.uploadedId || file.progress === 100)
|
||||
return true
|
||||
}
|
||||
|
||||
const getNumberValue = (value: number | string | undefined | null): number => {
|
||||
if (value === undefined || value === null)
|
||||
return 0
|
||||
if (typeof value === 'number')
|
||||
return value
|
||||
if (typeof value === 'string')
|
||||
return Number(value)
|
||||
return 0
|
||||
}
|
||||
|
||||
export const getFileUploadConfig = (fileUploadConfigResponse: FileUploadConfigResponse | undefined) => {
|
||||
if (!fileUploadConfigResponse) {
|
||||
return {
|
||||
imageFileSizeLimit: DEFAULT_IMAGE_FILE_SIZE_LIMIT,
|
||||
imageFileBatchLimit: DEFAULT_IMAGE_FILE_BATCH_LIMIT,
|
||||
singleChunkAttachmentLimit: DEFAULT_SINGLE_CHUNK_ATTACHMENT_LIMIT,
|
||||
}
|
||||
}
|
||||
const {
|
||||
image_file_batch_limit,
|
||||
single_chunk_attachment_limit,
|
||||
attachment_image_file_size_limit,
|
||||
} = fileUploadConfigResponse
|
||||
const imageFileSizeLimit = getNumberValue(attachment_image_file_size_limit)
|
||||
const imageFileBatchLimit = getNumberValue(image_file_batch_limit)
|
||||
const singleChunkAttachmentLimit = getNumberValue(single_chunk_attachment_limit)
|
||||
return {
|
||||
imageFileSizeLimit: imageFileSizeLimit > 0 ? imageFileSizeLimit : DEFAULT_IMAGE_FILE_SIZE_LIMIT,
|
||||
imageFileBatchLimit: imageFileBatchLimit > 0 ? imageFileBatchLimit : DEFAULT_IMAGE_FILE_BATCH_LIMIT,
|
||||
singleChunkAttachmentLimit: singleChunkAttachmentLimit > 0 ? singleChunkAttachmentLimit : DEFAULT_SINGLE_CHUNK_ATTACHMENT_LIMIT,
|
||||
}
|
||||
}
|
||||
|
|
@ -20,12 +20,14 @@ import { EffectColor } from '../../settings/chunk-structure/types'
|
|||
type Props = {
|
||||
disabled?: boolean
|
||||
value: RetrievalConfig
|
||||
showMultiModalTip?: boolean
|
||||
onChange: (value: RetrievalConfig) => void
|
||||
}
|
||||
|
||||
const RetrievalMethodConfig: FC<Props> = ({
|
||||
disabled = false,
|
||||
value,
|
||||
showMultiModalTip = false,
|
||||
onChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
|
@ -110,6 +112,7 @@ const RetrievalMethodConfig: FC<Props> = ({
|
|||
type={RETRIEVE_METHOD.semantic}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
showMultiModalTip={showMultiModalTip}
|
||||
/>
|
||||
</OptionCard>
|
||||
)}
|
||||
|
|
@ -132,6 +135,7 @@ const RetrievalMethodConfig: FC<Props> = ({
|
|||
type={RETRIEVE_METHOD.fullText}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
showMultiModalTip={showMultiModalTip}
|
||||
/>
|
||||
</OptionCard>
|
||||
)}
|
||||
|
|
@ -155,6 +159,7 @@ const RetrievalMethodConfig: FC<Props> = ({
|
|||
type={RETRIEVE_METHOD.hybrid}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
showMultiModalTip={showMultiModalTip}
|
||||
/>
|
||||
</OptionCard>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -24,16 +24,19 @@ import {
|
|||
import WeightedScore from '@/app/components/app/configuration/dataset-config/params-config/weighted-score'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import RadioCard from '@/app/components/base/radio-card'
|
||||
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
|
||||
|
||||
type Props = {
|
||||
type: RETRIEVE_METHOD
|
||||
value: RetrievalConfig
|
||||
showMultiModalTip?: boolean
|
||||
onChange: (value: RetrievalConfig) => void
|
||||
}
|
||||
|
||||
const RetrievalParamConfig: FC<Props> = ({
|
||||
type,
|
||||
value,
|
||||
showMultiModalTip = false,
|
||||
onChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
|
@ -133,19 +136,32 @@ const RetrievalParamConfig: FC<Props> = ({
|
|||
</div>
|
||||
{
|
||||
value.reranking_enable && (
|
||||
<ModelSelector
|
||||
defaultModel={rerankModel && { provider: rerankModel.provider_name, model: rerankModel.model_name }}
|
||||
modelList={rerankModelList}
|
||||
onSelect={(v) => {
|
||||
onChange({
|
||||
...value,
|
||||
reranking_model: {
|
||||
reranking_provider_name: v.provider,
|
||||
reranking_model_name: v.model,
|
||||
},
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
<ModelSelector
|
||||
defaultModel={rerankModel && { provider: rerankModel.provider_name, model: rerankModel.model_name }}
|
||||
modelList={rerankModelList}
|
||||
onSelect={(v) => {
|
||||
onChange({
|
||||
...value,
|
||||
reranking_model: {
|
||||
reranking_provider_name: v.provider,
|
||||
reranking_model_name: v.model,
|
||||
},
|
||||
})
|
||||
}}
|
||||
/>
|
||||
{showMultiModalTip && (
|
||||
<div className='mt-2 flex h-10 items-center gap-x-0.5 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-2 shadow-xs backdrop-blur-[5px]'>
|
||||
<div className='absolute bottom-0 left-0 right-0 top-0 bg-dataset-warning-message-bg opacity-40' />
|
||||
<div className='p-1'>
|
||||
<AlertTriangle className='size-4 text-text-warning-secondary' />
|
||||
</div>
|
||||
<span className='system-xs-medium text-text-primary'>
|
||||
{t('datasetSettings.form.retrievalSetting.multiModalTip')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
|
@ -239,19 +255,32 @@ const RetrievalParamConfig: FC<Props> = ({
|
|||
}
|
||||
{
|
||||
value.reranking_mode !== RerankingModeEnum.WeightedScore && (
|
||||
<ModelSelector
|
||||
defaultModel={rerankModel && { provider: rerankModel.provider_name, model: rerankModel.model_name }}
|
||||
modelList={rerankModelList}
|
||||
onSelect={(v) => {
|
||||
onChange({
|
||||
...value,
|
||||
reranking_model: {
|
||||
reranking_provider_name: v.provider,
|
||||
reranking_model_name: v.model,
|
||||
},
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
<ModelSelector
|
||||
defaultModel={rerankModel && { provider: rerankModel.provider_name, model: rerankModel.model_name }}
|
||||
modelList={rerankModelList}
|
||||
onSelect={(v) => {
|
||||
onChange({
|
||||
...value,
|
||||
reranking_model: {
|
||||
reranking_provider_name: v.provider,
|
||||
reranking_model_name: v.model,
|
||||
},
|
||||
})
|
||||
}}
|
||||
/>
|
||||
{showMultiModalTip && (
|
||||
<div className='mt-2 flex h-10 items-center gap-x-0.5 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-2 shadow-xs backdrop-blur-[5px]'>
|
||||
<div className='absolute bottom-0 left-0 right-0 top-0 bg-dataset-warning-message-bg opacity-40' />
|
||||
<div className='p-1'>
|
||||
<AlertTriangle className='size-4 text-text-warning-secondary' />
|
||||
</div>
|
||||
<span className='system-xs-medium text-text-primary'>
|
||||
{t('datasetSettings.form.retrievalSetting.multiModalTip')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
<div className={cn(!isEconomical && 'mt-4', 'space-between flex space-x-6')}>
|
||||
|
|
|
|||
|
|
@ -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[]>([])
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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'>
|
||||
|
|
|
|||
|
|
@ -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' : '',
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -62,6 +62,8 @@ const ModelListItem = ({ model, provider, isConfigurable, onModifyLoadBalancing
|
|||
showModelType
|
||||
showMode
|
||||
showContextSize
|
||||
showFeatures
|
||||
showFeaturesLabel
|
||||
>
|
||||
</ModelName>
|
||||
<div className='flex shrink-0 items-center'>
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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`) })
|
||||
|
|
|
|||
|
|
@ -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`),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -375,6 +375,7 @@ const translation = {
|
|||
answerEmpty: '答案不能为空',
|
||||
contentPlaceholder: '在这里添加内容',
|
||||
contentEmpty: '内容不能为空',
|
||||
allFilesUploaded: '所有文件必须上传完成才能保存',
|
||||
newTextSegment: '新文本分段',
|
||||
newQaSegment: '新问答分段',
|
||||
addChunk: '新增分段',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ const translation = {
|
|||
learnMore: '了解更多',
|
||||
description: '关于检索方法。',
|
||||
longDescription: '关于检索方法,您可以随时在知识库设置中更改此设置。',
|
||||
multiModalTip: '当 Embedding 模型支持多模态时,请选择多模态 Rerank 模型以获得更好的检索效果。',
|
||||
},
|
||||
externalKnowledgeAPI: '外部知识 API',
|
||||
externalKnowledgeID: '外部知识库 ID',
|
||||
|
|
|
|||
|
|
@ -236,6 +236,14 @@ const translation = {
|
|||
apiReference: 'API 文档',
|
||||
},
|
||||
},
|
||||
cornerLabel: {
|
||||
unavailable: '不可用',
|
||||
pipeline: '流水线',
|
||||
},
|
||||
multimodal: '多模态',
|
||||
imageUploader: {
|
||||
tip: '支持 {{supportTypes}} (最多 {{batchCount}} 个,每个大小不超过 {{size}}MB)',
|
||||
},
|
||||
}
|
||||
|
||||
export default translation
|
||||
|
|
|
|||
|
|
@ -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: '元数据过滤',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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%);
|
||||
|
|
|
|||
|
|
@ -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%);
|
||||
|
|
|
|||
Loading…
Reference in New Issue