feat: Refactor dataset info components and add export pipeline functionality

This commit is contained in:
twwu 2025-07-09 10:45:50 +08:00
parent a0942399cd
commit 8fc15c83d0
12 changed files with 282 additions and 168 deletions

View File

@ -5,7 +5,6 @@ import { usePathname } from 'next/navigation'
import { useTranslation } from 'react-i18next'
import type { RemixiconComponentType } from '@remixicon/react'
import {
RiAttachmentLine,
RiEqualizer2Fill,
RiEqualizer2Line,
RiFileTextFill,
@ -14,7 +13,6 @@ import {
RiFocus2Line,
} from '@remixicon/react'
import { RiInformation2Line } from '@remixicon/react'
import classNames from '@/utils/classnames'
import type { RelatedAppResponse } from '@/models/datasets'
import AppSideBar from '@/app/components/app-sidebar'
import Loading from '@/app/components/base/loading'
@ -22,7 +20,6 @@ import DatasetDetailContext from '@/context/dataset-detail'
import { DataSourceType } from '@/models/datasets'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { useStore } from '@/app/components/app/store'
import { useDocLink } from '@/context/i18n'
import { useAppContext } from '@/context/app-context'
import Tooltip from '@/app/components/base/tooltip'
import LinkedAppsPanel from '@/app/components/base/linked-apps-panel'
@ -49,7 +46,6 @@ const ExtraInfo = React.memo(({
expand,
}: IExtraInfoProps) => {
const { t } = useTranslation()
const docLink = useDocLink()
const hasRelatedApps = relatedApps?.data && relatedApps?.data?.length > 0
const relatedAppsTotal = relatedApps?.data?.length || 0
@ -57,7 +53,7 @@ const ExtraInfo = React.memo(({
return (
<>
{!expand && (
<div className='flex items-center gap-x-0.5'>
<div className='flex items-center gap-x-0.5 p-2 pb-3'>
<div className='flex grow flex-col px-2 pb-1.5 pt-1'>
<div className='system-md-semibold-uppercase text-text-secondary'>
{documentCount ?? '--'}
@ -74,7 +70,7 @@ const ExtraInfo = React.memo(({
{relatedAppsTotal ?? '--'}
</div>
<Tooltip
position='bottom-start'
position='top-start'
noDecoration
needsDelay
popupContent={
@ -94,13 +90,6 @@ const ExtraInfo = React.memo(({
</div>
</div>
)}
{expand && (
<div className={classNames('uppercase text-xs text-text-tertiary font-medium pb-2 pt-4', 'flex items-center justify-center !px-0 gap-1')}>
{relatedAppsTotal ?? '--'}
<RiAttachmentLine className='size-4 text-text-secondary' />
</div>
)}
</>
)
})

View File

@ -1,94 +0,0 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '../base/app-icon'
import Effect from '../base/effect'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import type { DataSet } from '@/models/datasets'
import { DOC_FORM_ICON_WITH_BG, DOC_FORM_TEXT } from '@/models/datasets'
import { useKnowledge } from '@/hooks/use-knowledge'
import Badge from '../base/badge'
import cn from '@/utils/classnames'
type Props = {
expand: boolean
extraInfo?: React.ReactNode
}
const DatasetInfo: FC<Props> = ({
expand,
extraInfo,
}) => {
const { t } = useTranslation()
const dataset = useDatasetDetailContextWithSelector(state => state.dataset) as DataSet
const iconInfo = dataset.icon_info || {
icon: '📙',
icon_type: 'emoji',
icon_background: '#FFF4ED',
icon_url: '',
}
const isExternalProvider = dataset.provider === 'external'
const { formatIndexingTechniqueAndMethod } = useKnowledge()
const chunkingModeIcon = dataset.doc_form ? DOC_FORM_ICON_WITH_BG[dataset.doc_form] : React.Fragment
const Icon = isExternalProvider ? DOC_FORM_ICON_WITH_BG.external : chunkingModeIcon
return (
<div className={cn('relative flex flex-col', expand ? '' : 'p-1')}>
{expand && (
<>
<Effect className='-left-5 top-[-22px] opacity-15' />
<div className='flex flex-col gap-y-2 p-2'>
<div className='relative w-fit'>
<AppIcon
size='medium'
iconType={iconInfo.icon_type}
icon={iconInfo.icon}
background={iconInfo.icon_background}
imageUrl={iconInfo.icon_url}
/>
{(dataset.doc_form || isExternalProvider) && (
<div className='absolute -bottom-1 -right-1 z-10'>
<Icon className='size-4' />
</div>
)}
</div>
<>
<div className='flex flex-col gap-y-1'>
<div
className='system-md-semibold truncate text-text-secondary'
title={dataset.name}
>
{dataset.name}
</div>
<div className='system-2xs-medium-uppercase text-text-tertiary'>
{isExternalProvider && t('dataset.externalTag')}
{!isExternalProvider && dataset.doc_form && dataset.indexing_technique && (
<div className='flex items-center gap-x-1'>
<Badge>{t(`dataset.chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}`)}</Badge>
<Badge>{formatIndexingTechniqueAndMethod(dataset.indexing_technique, dataset.retrieval_model_dict?.search_method)}</Badge>
</div>
)}
</div>
</div>
<p className='system-xs-regular line-clamp-3 text-text-tertiary first-letter:capitalize'>
{dataset.description}
</p>
</>
</div>
</>
)}
{!expand && (
<AppIcon
size='medium'
iconType={iconInfo.icon_type}
icon={iconInfo.icon}
background={iconInfo.icon_background}
imageUrl={iconInfo.icon_url}
/>
)}
{extraInfo}
</div>
)
}
export default React.memo(DatasetInfo)

View File

@ -0,0 +1,45 @@
import React, { useCallback, useState } from 'react'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../../base/portal-to-follow-elem'
import ActionButton from '../../base/action-button'
import { RiMoreFill } from '@remixicon/react'
import cn from '@/utils/classnames'
import Menu from './menu'
type DropDownProps = {
expand: boolean
}
const DropDown = ({
expand,
}: DropDownProps) => {
const [open, setOpen] = useState(false)
const handleTrigger = useCallback(() => {
setOpen(prev => !prev)
}, [])
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement={expand ? 'bottom-end' : 'right'}
offset={expand ? {
mainAxis: 4,
crossAxis: 10,
} : {
mainAxis: 4,
}}
>
<PortalToFollowElemTrigger onClick={handleTrigger}>
<ActionButton className={cn(expand ? 'size-8 rounded-lg' : 'size-6 rounded-md')}>
<RiMoreFill className='size-4' />
</ActionButton>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent>
<Menu />
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default React.memo(DropDown)

View File

@ -0,0 +1,88 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '../../base/app-icon'
import Effect from '../../base/effect'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import type { DataSet } from '@/models/datasets'
import { DOC_FORM_TEXT } from '@/models/datasets'
import { useKnowledge } from '@/hooks/use-knowledge'
import cn from '@/utils/classnames'
import Dropdown from './dropdown'
type DatasetInfoProps = {
expand: boolean
}
const DatasetInfo: FC<DatasetInfoProps> = ({
expand,
}) => {
const { t } = useTranslation()
const dataset = useDatasetDetailContextWithSelector(state => state.dataset) as DataSet
const iconInfo = dataset.icon_info || {
icon: '📙',
icon_type: 'emoji',
icon_background: '#FFF4ED',
icon_url: '',
}
const isExternalProvider = dataset.provider === 'external'
const { formatIndexingTechniqueAndMethod } = useKnowledge()
return (
<div className={cn('relative flex flex-col', expand ? '' : 'p-1')}>
{expand && (
<>
<Effect className='-left-5 top-[-22px] opacity-15' />
<div className='flex flex-col gap-y-2 p-2'>
<div className='flex items-center justify-between'>
<AppIcon
size='medium'
iconType={iconInfo.icon_type}
icon={iconInfo.icon}
background={iconInfo.icon_background}
imageUrl={iconInfo.icon_url}
/>
<Dropdown expand />
</div>
<div className='flex flex-col gap-y-1 pb-0.5'>
<div
className='system-md-semibold truncate text-text-secondary'
title={dataset.name}
>
{dataset.name}
</div>
<div className='system-2xs-medium-uppercase text-text-tertiary'>
{isExternalProvider && t('dataset.externalTag')}
{!isExternalProvider && dataset.doc_form && dataset.indexing_technique && (
<div className='flex items-center gap-x-2'>
<span>{t(`dataset.chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}`)}</span>
<span>{formatIndexingTechniqueAndMethod(dataset.indexing_technique, dataset.retrieval_model_dict?.search_method)}</span>
</div>
)}
</div>
</div>
{!!dataset.description && (
<p className='system-xs-regular line-clamp-3 text-text-tertiary first-letter:capitalize'>
{dataset.description}
</p>
)}
</div>
</>
)}
{!expand && (
<div className='flex flex-col items-center gap-y-1'>
<AppIcon
size='medium'
iconType={iconInfo.icon_type}
icon={iconInfo.icon}
background={iconInfo.icon_background}
imageUrl={iconInfo.icon_url}
/>
<Dropdown expand={false} />
</div>
)}
</div>
)
}
export default React.memo(DatasetInfo)

View File

@ -0,0 +1,28 @@
import React from 'react'
import type { RemixiconComponentType } from '@remixicon/react'
type MenuItemProps = {
name: string
Icon: RemixiconComponentType
handleClick?: () => void
}
const MenuItem = ({
Icon,
name,
handleClick,
}: MenuItemProps) => {
return (
<div
className='flex items-center gap-x-1 rounded-lg px-2 py-1.5 hover:bg-state-base-hover'
onClick={() => {
handleClick?.()
}}
>
<Icon className='size-4 text-text-tertiary' />
<span className='system-md-regular px-1 text-text-secondary'>{name}</span>
</div>
)
}
export default React.memo(MenuItem)

View File

@ -0,0 +1,21 @@
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import React from 'react'
import { useTranslation } from 'react-i18next'
import MenuItem from './menu-item'
import { RiEditLine } from '@remixicon/react'
import { noop } from 'lodash-es'
const Menu = () => {
const { t } = useTranslation()
const dataset = useDatasetDetailContextWithSelector(state => state.dataset)
return (
<div className='flex w-[200px] flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]'>
<div className='flex flex-col p-1'>
<MenuItem Icon={RiEditLine} name={t('common.operation.edit')} handleClick={noop} />
</div>
</div>
)
}
export default React.memo(Menu)

View File

@ -82,25 +82,22 @@ const AppDetailNav = ({
return (
<div
ref={sidebarRef}
className={`
flex shrink-0 flex-col border-r border-divider-burn bg-background-default-subtle transition-all
${expand ? 'w-[216px]' : 'w-14'}
`}
className={cn(
'flex shrink-0 flex-col border-r border-divider-burn bg-background-default-subtle transition-all',
expand ? 'w-[216px]' : 'w-14',
)}
>
<div
className={`
shrink-0
${expand ? 'p-2' : 'p-1'}
`}
className={cn(
'shrink-0',
expand ? 'p-2' : 'p-1',
)}
>
{iconType === 'app' && (
<AppInfo expand={expand} />
)}
{iconType !== 'app' && (
<DatasetInfo
expand={expand}
extraInfo={extraInfo && extraInfo(appSidebarExpand)}
/>
<DatasetInfo expand={expand} />
)}
</div>
<div className='relative px-4 py-2'>
@ -141,6 +138,7 @@ const AppDetailNav = ({
)
})}
</nav>
{iconType !== 'app' && extraInfo && extraInfo(appSidebarExpand)}
</div>
)
}

View File

@ -22,6 +22,7 @@ import Operations from './operations'
import AppIcon from '@/app/components/base/app-icon'
import CornerLabel from '@/app/components/base/corner-label'
import { DOC_FORM_ICON_WITH_BG, DOC_FORM_TEXT } from '@/models/datasets'
import { useExportPipelineDSL } from '@/service/use-pipeline'
const EXTERNAL_PROVIDER = 'external'
@ -45,6 +46,7 @@ const DatasetCard = ({
const [showRenameModal, setShowRenameModal] = useState(false)
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
const [confirmMessage, setConfirmMessage] = useState<string>('')
const [exporting, setExporting] = useState(false)
const isExternalProvider = useMemo(() => {
return dataset.provider === EXTERNAL_PROVIDER
@ -81,6 +83,36 @@ const DatasetCard = ({
return dayjs(time * 1_000).locale(language === 'zh_Hans' ? 'zh-cn' : language.replace('_', '-')).fromNow()
}, [language])
const { mutateAsync: exportPipelineConfig } = useExportPipelineDSL()
const handleExportPipeline = useCallback(async (include = false) => {
const { pipeline_id, name } = dataset
if (!pipeline_id)
return
if (exporting)
return
try {
setExporting(true)
const { data } = await exportPipelineConfig({
pipelineId: pipeline_id,
include,
})
const a = document.createElement('a')
const file = new Blob([data], { type: 'application/yaml' })
a.href = URL.createObjectURL(file)
a.download = `${name}.yml`
a.click()
}
catch {
Toast.notify({ type: 'error', message: t('app.exportFailed') })
}
finally {
setExporting(false)
}
}, [dataset, exportPipelineConfig, exporting, t])
const detectIsUsedByApp = useCallback(async () => {
try {
const { is_using: isUsedByApp } = await checkIsUsedInApp(dataset.id)
@ -234,6 +266,7 @@ const DatasetCard = ({
setShowRenameModal(true)
}}
detectIsUsedByApp={detectIsUsedByApp}
handleExportPipeline={handleExportPipeline}
/>
}
className={'z-20 min-w-[186px]'}

View File

@ -0,0 +1,28 @@
import React from 'react'
import type { RemixiconComponentType } from '@remixicon/react'
type OperationItemProps = {
Icon: RemixiconComponentType
name: string
handleClick?: (e: React.MouseEvent<HTMLDivElement>) => void
}
const OperationItem = ({
Icon,
name,
handleClick,
}: OperationItemProps) => {
return (
<div
className='flex cursor-pointer items-center gap-x-1 rounded-lg px-2 py-1.5 hover:bg-state-base-hover'
onClick={handleClick}
>
<Icon className='size-4 text-text-tertiary' />
<span className='system-md-regular px-1 text-text-secondary'>
{name}
</span>
</div>
)
}
export default React.memo(OperationItem)

View File

@ -1,17 +1,20 @@
import Divider from '@/app/components/base/divider'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { RiDeleteBinLine, RiEditLine } from '@remixicon/react'
import { RiDeleteBinLine, RiEditLine, RiFileDownloadLine } from '@remixicon/react'
import OperationItem from './operation-item'
type OperationsProps = {
showDelete: boolean
openRenameModal: () => void
handleExportPipeline: () => void
detectIsUsedByApp: () => void
}
const Operations = ({
showDelete,
openRenameModal,
handleExportPipeline,
detectIsUsedByApp,
}: OperationsProps) => {
const { t } = useTranslation()
@ -22,6 +25,12 @@ const Operations = ({
openRenameModal()
}
const onClickExport = async (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation()
e.preventDefault()
handleExportPipeline()
}
const onClickDelete = async (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation()
e.preventDefault()
@ -31,59 +40,26 @@ const Operations = ({
return (
<div className='relative flex w-full flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg shadow-shadow-shadow-5'>
<div className='flex flex-col p-1'>
<div
className='flex cursor-pointer items-center gap-x-1 rounded-lg px-2 py-1.5 hover:bg-state-base-hover'
onClick={onClickRename}
>
<RiEditLine className='size-4 text-text-tertiary' />
<span className='system-md-regular px-1 text-text-secondary'>
{t('common.operation.edit')}
</span>
</div>
{/* <div
className='flex cursor-pointer items-center gap-x-1 rounded-lg px-2 py-1.5 hover:bg-state-base-hover'
onClick={() => { console.log('duplicate') }}
>
<RiFileCopyLine className='size-4 text-text-tertiary' />
<span className='system-md-regular px-1 text-text-secondary'>
{t('common.operation.duplicate')}
</span>
</div> */}
<OperationItem
Icon={RiEditLine}
name={t('common.operation.edit')}
handleClick={onClickRename}
/>
<OperationItem
Icon={RiFileDownloadLine}
name={t('datasetPipeline.operations.exportPipeline')}
handleClick={onClickExport}
/>
</div>
{/* <Divider type='horizontal' className='my-0 bg-divider-subtle' />
<div className='flex flex-col p-1'>
<div
className='flex cursor-pointer items-center gap-x-1 rounded-lg px-2 py-1.5 hover:bg-state-base-hover'
onClick={() => { console.log('Export') }}
>
<RiEditLine className='size-4 text-text-tertiary' />
<span className='system-md-regular px-1 text-text-secondary'>
Export Solution
</span>
</div>
<div
className='flex cursor-pointer items-center gap-x-1 rounded-lg px-2 py-1.5 hover:bg-state-base-hover'
onClick={() => { console.log('Import') }}
>
<RiFileCopyLine className='size-4 text-text-tertiary' />
<span className='system-md-regular px-1 text-text-secondary'>
Import Solution
</span>
</div>
</div> */}
{showDelete && (
<>
<Divider type='horizontal' className='my-0 bg-divider-subtle' />
<div className='flex flex-col p-1'>
<div
className='group flex cursor-pointer items-center gap-x-1 rounded-lg px-2 py-1.5 hover:bg-state-destructive-hover'
onClick={onClickDelete}
>
<RiDeleteBinLine className='size-4 text-text-tertiary group-hover:text-text-destructive' />
<span className='system-md-regular px-1 text-text-secondary group-hover:text-text-destructive'>
{t('common.operation.delete')}
</span>
</div>
<OperationItem
Icon={RiDeleteBinLine}
name={t('common.operation.delete')}
handleClick={onClickDelete}
/>
</div>
</>
)}

View File

@ -29,6 +29,7 @@ const translation = {
dataSource: 'Data Source',
saveAndProcess: 'Save & Process',
preview: 'Preview',
exportPipeline: 'Export Pipeline',
},
knowledgeNameAndIcon: 'Knowledge name & icon',
knowledgeNameAndIconPlaceholder: 'Please enter the name of the Knowledge Base',

View File

@ -29,6 +29,7 @@ const translation = {
dataSource: '数据源',
saveAndProcess: '保存并处理',
preview: '预览',
exportPipeline: '导出 pipeline',
},
knowledgeNameAndIcon: '知识库名称和图标',
knowledgeNameAndIconPlaceholder: '请输入知识库名称',