feat: knowledge pipeline (#25360)

Signed-off-by: -LAN- <laipz8200@outlook.com>
Co-authored-by: twwu <twwu@dify.ai>
Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com>
Co-authored-by: jyong <718720800@qq.com>
Co-authored-by: Wu Tianwei <30284043+WTW0313@users.noreply.github.com>
Co-authored-by: QuantumGhost <obelisk.reg+git@gmail.com>
Co-authored-by: lyzno1 <yuanyouhuilyz@gmail.com>
Co-authored-by: quicksand <quicksandzn@gmail.com>
Co-authored-by: Jyong <76649700+JohnJyong@users.noreply.github.com>
Co-authored-by: lyzno1 <92089059+lyzno1@users.noreply.github.com>
Co-authored-by: zxhlyh <jasonapring2015@outlook.com>
Co-authored-by: Yongtao Huang <yongtaoh2022@gmail.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Joel <iamjoel007@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: nite-knite <nkCoding@gmail.com>
Co-authored-by: Hanqing Zhao <sherry9277@gmail.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Harry <xh001x@hotmail.com>
This commit is contained in:
-LAN-
2025-09-18 12:49:10 +08:00
committed by GitHub
parent 7dadb33003
commit 85cda47c70
1772 changed files with 102407 additions and 31710 deletions

View File

@@ -144,9 +144,11 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
})
const a = document.createElement('a')
const file = new Blob([data], { type: 'application/yaml' })
a.href = URL.createObjectURL(file)
const url = URL.createObjectURL(file)
a.href = url
a.download = `${appDetail.name}.yml`
a.click()
URL.revokeObjectURL(url)
}
catch {
notify({ type: 'error', message: t('app.exportFailed') })
@@ -313,7 +315,7 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
<div className='flex shrink-0 flex-col items-start justify-center gap-3 self-stretch p-4'>
<div className='flex items-center gap-3 self-stretch'>
<AppIcon
size="large"
size='large'
iconType={appDetail.icon_type}
icon={appDetail.icon}
background={appDetail.icon_background}

View File

@@ -1,47 +0,0 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '../base/app-icon'
const DatasetSvg = <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd" clipRule="evenodd" d="M0.833497 5.13481C0.833483 4.69553 0.83347 4.31654 0.858973 4.0044C0.88589 3.67495 0.94532 3.34727 1.10598 3.03195C1.34567 2.56155 1.72812 2.17909 2.19852 1.93941C2.51384 1.77875 2.84152 1.71932 3.17097 1.6924C3.48312 1.6669 3.86209 1.66691 4.30137 1.66693L7.62238 1.66684C8.11701 1.66618 8.55199 1.66561 8.95195 1.80356C9.30227 1.92439 9.62134 2.12159 9.88607 2.38088C10.1883 2.67692 10.3823 3.06624 10.603 3.50894L11.3484 5.00008H14.3679C15.0387 5.00007 15.5924 5.00006 16.0434 5.03691C16.5118 5.07518 16.9424 5.15732 17.3468 5.36339C17.974 5.68297 18.4839 6.19291 18.8035 6.82011C19.0096 7.22456 19.0917 7.65515 19.13 8.12356C19.1668 8.57455 19.1668 9.12818 19.1668 9.79898V13.5345C19.1668 14.2053 19.1668 14.7589 19.13 15.2099C19.0917 15.6784 19.0096 16.1089 18.8035 16.5134C18.4839 17.1406 17.974 17.6505 17.3468 17.9701C16.9424 18.1762 16.5118 18.2583 16.0434 18.2966C15.5924 18.3334 15.0387 18.3334 14.3679 18.3334H5.63243C4.96163 18.3334 4.40797 18.3334 3.95698 18.2966C3.48856 18.2583 3.05798 18.1762 2.65353 17.9701C2.02632 17.6505 1.51639 17.1406 1.19681 16.5134C0.990734 16.1089 0.908597 15.6784 0.870326 15.2099C0.833478 14.7589 0.833487 14.2053 0.833497 13.5345V5.13481ZM7.51874 3.33359C8.17742 3.33359 8.30798 3.34447 8.4085 3.37914C8.52527 3.41942 8.63163 3.48515 8.71987 3.57158C8.79584 3.64598 8.86396 3.7579 9.15852 4.34704L9.48505 5.00008L2.50023 5.00008C2.50059 4.61259 2.50314 4.34771 2.5201 4.14012C2.5386 3.91374 2.57 3.82981 2.59099 3.7886C2.67089 3.6318 2.79837 3.50432 2.95517 3.42442C2.99638 3.40343 3.08031 3.37203 3.30669 3.35353C3.54281 3.33424 3.85304 3.33359 4.3335 3.33359H7.51874Z" fill="#444CE7" />
</svg>
type Props = {
isExternal?: boolean
name: string
description: string
expand: boolean
extraInfo?: React.ReactNode
}
const DatasetInfo: FC<Props> = ({
name,
description,
isExternal,
expand,
extraInfo,
}) => {
const { t } = useTranslation()
return (
<div className='pl-1 pt-1'>
<div className='mr-3 shrink-0'>
<AppIcon innerIcon={DatasetSvg} className='!border-[0.5px] !border-indigo-100 !bg-indigo-25' />
</div>
<div className={`transition-all duration-200 ease-in-out ${
expand
? 'mt-2 w-auto opacity-100'
: 'pointer-events-none h-0 w-0 overflow-hidden opacity-0'
}`}>
<div className='system-md-semibold truncate whitespace-nowrap text-text-secondary'>
{name}
</div>
<div className='system-2xs-medium-uppercase mt-1 whitespace-nowrap text-text-tertiary'>{isExternal ? t('dataset.externalTag') : t('dataset.localDocs')}</div>
<div className='system-xs-regular my-3 whitespace-nowrap text-text-tertiary first-letter:capitalize'>{description}</div>
</div>
{extraInfo}
</div>
)
}
export default React.memo(DatasetInfo)

View File

@@ -0,0 +1,152 @@
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'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import type { DataSet } from '@/models/datasets'
import { datasetDetailQueryKeyPrefix, useInvalidDatasetList } from '@/service/knowledge/use-dataset'
import { useInvalid } from '@/service/use-base'
import { useExportPipelineDSL } from '@/service/use-pipeline'
import Toast from '../../base/toast'
import { useTranslation } from 'react-i18next'
import RenameDatasetModal from '../../datasets/rename-modal'
import { checkIsUsedInApp, deleteDataset } from '@/service/datasets'
import Confirm from '../../base/confirm'
import { useRouter } from 'next/navigation'
type DropDownProps = {
expand: boolean
}
const DropDown = ({
expand,
}: DropDownProps) => {
const { t } = useTranslation()
const { replace } = useRouter()
const [open, setOpen] = useState(false)
const [showRenameModal, setShowRenameModal] = useState(false)
const [confirmMessage, setConfirmMessage] = useState<string>('')
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
const isCurrentWorkspaceDatasetOperator = useAppContextWithSelector(state => state.isCurrentWorkspaceDatasetOperator)
const dataset = useDatasetDetailContextWithSelector(state => state.dataset) as DataSet
const handleTrigger = useCallback(() => {
setOpen(prev => !prev)
}, [])
const invalidDatasetList = useInvalidDatasetList()
const invalidDatasetDetail = useInvalid([...datasetDetailQueryKeyPrefix, dataset.id])
const refreshDataset = useCallback(() => {
invalidDatasetList()
invalidDatasetDetail()
}, [invalidDatasetDetail, invalidDatasetList])
const openRenameModal = useCallback(() => {
setShowRenameModal(true)
handleTrigger()
}, [handleTrigger])
const { mutateAsync: exportPipelineConfig } = useExportPipelineDSL()
const handleExportPipeline = useCallback(async (include = false) => {
const { pipeline_id, name } = dataset
if (!pipeline_id)
return
handleTrigger()
try {
const { data } = await exportPipelineConfig({
pipelineId: pipeline_id,
include,
})
const a = document.createElement('a')
const file = new Blob([data], { type: 'application/yaml' })
const url = URL.createObjectURL(file)
a.href = url
a.download = `${name}.pipeline`
a.click()
URL.revokeObjectURL(url)
}
catch {
Toast.notify({ type: 'error', message: t('app.exportFailed') })
}
}, [dataset, exportPipelineConfig, handleTrigger, t])
const detectIsUsedByApp = useCallback(async () => {
try {
const { is_using: isUsedByApp } = await checkIsUsedInApp(dataset.id)
setConfirmMessage(isUsedByApp ? t('dataset.datasetUsedByApp')! : t('dataset.deleteDatasetConfirmContent')!)
setShowConfirmDelete(true)
}
catch (e: any) {
const res = await e.json()
Toast.notify({ type: 'error', message: res?.message || 'Unknown error' })
}
finally {
handleTrigger()
}
}, [dataset.id, handleTrigger, t])
const onConfirmDelete = useCallback(async () => {
try {
await deleteDataset(dataset.id)
Toast.notify({ type: 'success', message: t('dataset.datasetDeleted') })
invalidDatasetList()
replace('/datasets')
}
finally {
setShowConfirmDelete(false)
}
}, [dataset.id, replace, invalidDatasetList, t])
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 className='z-[60]'>
<Menu
showDelete={!isCurrentWorkspaceDatasetOperator}
openRenameModal={openRenameModal}
handleExportPipeline={handleExportPipeline}
detectIsUsedByApp={detectIsUsedByApp}
/>
</PortalToFollowElemContent>
{showRenameModal && (
<RenameDatasetModal
show={showRenameModal}
dataset={dataset!}
onClose={() => setShowRenameModal(false)}
onSuccess={refreshDataset}
/>
)}
{showConfirmDelete && (
<Confirm
title={t('dataset.deleteDatasetConfirmTitle')}
content={confirmMessage}
isShow={showConfirmDelete}
onConfirm={onConfirmDelete}
onCancel={() => setShowConfirmDelete(false)}
/>
)}
</PortalToFollowElem>
)
}
export default React.memo(DropDown)

View File

@@ -0,0 +1,91 @@
'use client'
import type { FC } from 'react'
import React, { useMemo } 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 isPipelinePublished = useMemo(() => {
return dataset.runtime_mode === 'rag_pipeline' && dataset.is_published
}, [dataset.runtime_mode, dataset.is_published])
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-2 p-2'>
<div className='flex items-center gap-1'>
<div className={cn(!expand && '-ml-1')}>
<AppIcon
size={expand ? 'large' : 'small'}
iconType={iconInfo.icon_type}
icon={iconInfo.icon}
background={iconInfo.icon_background}
imageUrl={iconInfo.icon_url}
/>
</div>
{expand && (
<div className='ml-auto'>
<Dropdown expand />
</div>
)}
</div>
{!expand && (
<div className='-mb-2 -mt-1 flex items-center justify-center'>
<Dropdown expand={false} />
</div>
)}
{expand && (
<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 && isPipelinePublished && 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>
{!!dataset.description && (
<p className='system-xs-regular line-clamp-3 text-text-tertiary first-letter:capitalize'>
{dataset.description}
</p>
)}
</div>
)}
</div>
</div>
)
}
export default React.memo(DatasetInfo)

View File

@@ -0,0 +1,30 @@
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={(e) => {
e.preventDefault()
e.stopPropagation()
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,52 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import MenuItem from './menu-item'
import { RiDeleteBinLine, RiEditLine, RiFileDownloadLine } from '@remixicon/react'
import Divider from '../../base/divider'
type MenuProps = {
showDelete: boolean
openRenameModal: () => void
handleExportPipeline: () => void
detectIsUsedByApp: () => void
}
const Menu = ({
showDelete,
openRenameModal,
handleExportPipeline,
detectIsUsedByApp,
}: MenuProps) => {
const { t } = useTranslation()
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={openRenameModal}
/>
<MenuItem
Icon={RiFileDownloadLine}
name={t('datasetPipeline.operations.exportPipeline')}
handleClick={handleExportPipeline}
/>
</div>
{showDelete && (
<>
<Divider type='horizontal' className='my-0 bg-divider-subtle' />
<div className='flex flex-col p-1'>
<MenuItem
Icon={RiDeleteBinLine}
name={t('common.operation.delete')}
handleClick={detectIsUsedByApp}
/>
</div>
</>
)}
</div>
)
}
export default React.memo(Menu)

View File

@@ -0,0 +1,164 @@
import React, { useCallback, useRef, useState } from 'react'
import {
RiMenuLine,
} from '@remixicon/react'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import AppIcon from '../base/app-icon'
import Divider from '../base/divider'
import NavLink from './navLink'
import type { NavIcon } from './navLink'
import cn from '@/utils/classnames'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import Effect from '../base/effect'
import Dropdown from './dataset-info/dropdown'
import type { DataSet } from '@/models/datasets'
import { DOC_FORM_TEXT } from '@/models/datasets'
import { useKnowledge } from '@/hooks/use-knowledge'
import { useTranslation } from 'react-i18next'
import { useDatasetRelatedApps } from '@/service/knowledge/use-dataset'
import ExtraInfo from '../datasets/extra-info'
type DatasetSidebarDropdownProps = {
navigation: Array<{
name: string
href: string
icon: NavIcon
selectedIcon: NavIcon
disabled?: boolean
}>
}
const DatasetSidebarDropdown = ({
navigation,
}: DatasetSidebarDropdownProps) => {
const { t } = useTranslation()
const dataset = useDatasetDetailContextWithSelector(state => state.dataset) as DataSet
const { data: relatedApps } = useDatasetRelatedApps(dataset.id)
const [open, doSetOpen] = useState(false)
const openRef = useRef(open)
const setOpen = useCallback((v: boolean) => {
doSetOpen(v)
openRef.current = v
}, [doSetOpen])
const handleTrigger = useCallback(() => {
setOpen(!openRef.current)
}, [setOpen])
const iconInfo = dataset.icon_info || {
icon: '📙',
icon_type: 'emoji',
icon_background: '#FFF4ED',
icon_url: '',
}
const isExternalProvider = dataset.provider === 'external'
const { formatIndexingTechniqueAndMethod } = useKnowledge()
if (!dataset)
return null
return (
<>
<div className='fixed left-2 top-2 z-20'>
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-start'
offset={{
mainAxis: -41,
}}
>
<PortalToFollowElemTrigger onClick={handleTrigger}>
<div
className={cn(
'flex cursor-pointer items-center rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-1 shadow-lg backdrop-blur-sm hover:bg-background-default-hover',
open && 'bg-background-default-hover',
)}
>
<AppIcon
size='small'
iconType={iconInfo.icon_type}
icon={iconInfo.icon}
background={iconInfo.icon_background}
imageUrl={iconInfo.icon_url}
/>
<RiMenuLine className='size-4 text-text-tertiary' />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-50'>
<div className='relative w-[216px] rounded-xl border-[0.5px] border-components-panel-border bg-background-default-subtle shadow-lg'>
<Effect className='-left-5 top-[-22px] opacity-15' />
<div className='flex flex-col gap-y-2 p-4'>
<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>
<div className='px-4 py-2'>
<Divider
type='horizontal'
bgStyle='gradient'
className='my-0 h-px bg-gradient-to-r from-divider-subtle to-background-gradient-mask-transparent'
/>
</div>
<nav className='flex min-h-[200px] grow flex-col gap-y-0.5 px-3 py-2'>
{navigation.map((item, index) => {
return (
<NavLink
key={index}
mode='expand'
iconMap={{ selected: item.selectedIcon, normal: item.icon }}
name={item.name}
href={item.href}
disabled={!!item.disabled}
/>
)
})}
</nav>
<ExtraInfo
relatedApps={relatedApps}
expand
documentCount={dataset.document_count}
/>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</div>
</>
)
}
export default DatasetSidebarDropdown

View File

@@ -1,10 +1,8 @@
import React, { useEffect, useState } from 'react'
import React, { useCallback, useEffect, useState } from 'react'
import { usePathname } from 'next/navigation'
import { useShallow } from 'zustand/react/shallow'
import { RiLayoutLeft2Line, RiLayoutRight2Line } from '@remixicon/react'
import NavLink from './navLink'
import type { NavIcon } from './navLink'
import AppBasic from './basic'
import AppInfo from './app-info'
import DatasetInfo from './dataset-info'
import AppSidebarDropdown from './app-sidebar-dropdown'
@@ -12,39 +10,48 @@ import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import cn from '@/utils/classnames'
import Divider from '../base/divider'
import { useHover, useKeyPress } from 'ahooks'
import ToggleButton from './toggle-button'
import { getKeyboardKeyCodeBySystem } from '../workflow/utils'
import DatasetSidebarDropdown from './dataset-sidebar-dropdown'
export type IAppDetailNavProps = {
iconType?: 'app' | 'dataset' | 'notion'
title: string
desc: string
isExternal?: boolean
icon: string
icon_background: string | null
iconType?: 'app' | 'dataset'
navigation: Array<{
name: string
href: string
icon: NavIcon
selectedIcon: NavIcon
disabled?: boolean
}>
extraInfo?: (modeState: string) => React.ReactNode
}
const AppDetailNav = ({ title, desc, isExternal, icon, icon_background, navigation, extraInfo, iconType = 'app' }: IAppDetailNavProps) => {
const { appSidebarExpand, setAppSiderbarExpand } = useAppStore(useShallow(state => ({
const AppDetailNav = ({
navigation,
extraInfo,
iconType = 'app',
}: IAppDetailNavProps) => {
const { appSidebarExpand, setAppSidebarExpand } = useAppStore(useShallow(state => ({
appSidebarExpand: state.appSidebarExpand,
setAppSiderbarExpand: state.setAppSiderbarExpand,
setAppSidebarExpand: state.setAppSidebarExpand,
})))
const sidebarRef = React.useRef<HTMLDivElement>(null)
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const expand = appSidebarExpand === 'expand'
const handleToggle = (state: string) => {
setAppSiderbarExpand(state === 'expand' ? 'collapse' : 'expand')
}
const handleToggle = useCallback(() => {
setAppSidebarExpand(appSidebarExpand === 'expand' ? 'collapse' : 'expand')
}, [appSidebarExpand, setAppSidebarExpand])
// // Check if the current path is a workflow canvas & fullscreen
const isHoveringSidebar = useHover(sidebarRef)
// Check if the current path is a workflow canvas & fullscreen
const pathname = usePathname()
const inWorkflowCanvas = pathname.endsWith('/workflow')
const isPipelineCanvas = pathname.endsWith('/pipeline')
const workflowCanvasMaximize = localStorage.getItem('workflow-canvas-maximize') === 'true'
const [hideHeader, setHideHeader] = useState(workflowCanvasMaximize)
const { eventEmitter } = useEventEmitterContextContext()
@@ -57,9 +64,14 @@ const AppDetailNav = ({ title, desc, isExternal, icon, icon_background, navigati
useEffect(() => {
if (appSidebarExpand) {
localStorage.setItem('app-detail-collapse-or-expand', appSidebarExpand)
setAppSiderbarExpand(appSidebarExpand)
setAppSidebarExpand(appSidebarExpand)
}
}, [appSidebarExpand, setAppSiderbarExpand])
}, [appSidebarExpand, setAppSidebarExpand])
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.b`, (e) => {
e.preventDefault()
handleToggle()
}, { exactMatch: true, useCapture: true })
if (inWorkflowCanvas && hideHeader) {
return (
@@ -69,76 +81,74 @@ const AppDetailNav = ({ title, desc, isExternal, icon, icon_background, navigati
)
}
if (isPipelineCanvas && hideHeader) {
return (
<div className='flex w-0 shrink-0'>
<DatasetSidebarDropdown navigation={navigation} />
</div>
)
}
return (
<div
className={`
flex shrink-0 flex-col border-r border-divider-burn bg-background-default-subtle transition-all
${expand ? 'w-[216px]' : 'w-14'}
`}
ref={sidebarRef}
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 === 'dataset' && (
<DatasetInfo
name={title}
description={desc}
isExternal={isExternal}
expand={expand}
extraInfo={extraInfo && extraInfo(appSidebarExpand)}
/>
)}
{!['app', 'dataset'].includes(iconType) && (
<AppBasic
mode={appSidebarExpand}
iconType={iconType}
icon={icon}
icon_background={icon_background}
name={title}
type={desc}
isExternal={isExternal}
/>
{iconType !== 'app' && (
<DatasetInfo expand={expand} />
)}
</div>
<div className='px-4'>
<div className={cn('mx-auto mt-1 h-px bg-divider-subtle', !expand && 'w-6')} />
<div className='relative px-4 py-2'>
<Divider
type='horizontal'
bgStyle={expand ? 'gradient' : 'solid'}
className={cn(
'my-0 h-px',
expand
? 'bg-gradient-to-r from-divider-subtle to-background-gradient-mask-transparent'
: 'bg-divider-subtle',
)}
/>
{!isMobile && isHoveringSidebar && (
<ToggleButton
className='absolute -right-3 top-[-3.5px] z-20'
expand={expand}
handleToggle={handleToggle}
/>
)}
</div>
<nav
className={`
grow space-y-1
${expand ? 'p-4' : 'px-2.5 py-4'}
`}
className={cn(
'flex grow flex-col gap-y-0.5',
expand ? 'px-3 py-2' : 'p-3',
)}
>
{navigation.map((item, index) => {
return (
<NavLink key={index} mode={appSidebarExpand} iconMap={{ selected: item.selectedIcon, normal: item.icon }} name={item.name} href={item.href} />
<NavLink
key={index}
mode={appSidebarExpand}
iconMap={{ selected: item.selectedIcon, normal: item.icon }}
name={item.name}
href={item.href}
disabled={!!item.disabled}
/>
)
})}
</nav>
{
!isMobile && (
<div
className="shrink-0 px-4 py-3"
>
<div
className='flex h-6 w-6 cursor-pointer items-center justify-center'
onClick={() => handleToggle(appSidebarExpand)}
>
{
expand
? <RiLayoutRight2Line className='h-5 w-5 text-components-menu-item-text' />
: <RiLayoutLeft2Line className='h-5 w-5 text-components-menu-item-text' />
}
</div>
</div>
)
}
{iconType !== 'app' && extraInfo && extraInfo(appSidebarExpand)}
</div>
)
}

View File

@@ -25,7 +25,7 @@ const MockIcon = ({ className }: { className?: string }) => (
<svg className={className} data-testid="nav-icon" />
)
describe('NavLink Text Animation Issues', () => {
describe('NavLink Animation and Layout Issues', () => {
const mockProps: NavLinkProps = {
name: 'Orchestrate',
href: '/app/123/workflow',
@@ -61,108 +61,129 @@ describe('NavLink Text Animation Issues', () => {
const textElement = screen.getByText('Orchestrate')
expect(textElement).toBeInTheDocument()
expect(textElement).toHaveClass('opacity-0')
expect(textElement).toHaveClass('w-0')
expect(textElement).toHaveClass('max-w-0')
expect(textElement).toHaveClass('overflow-hidden')
// Icon should still be present
expect(screen.getByTestId('nav-icon')).toBeInTheDocument()
// Check padding in collapse mode
// Check consistent padding in collapse mode
const linkElement = screen.getByTestId('nav-link')
expect(linkElement).toHaveClass('px-2.5')
expect(linkElement).toHaveClass('pl-3')
expect(linkElement).toHaveClass('pr-1')
// Switch to expand mode - this is where the squeeze effect occurs
// Switch to expand mode - should have smooth text transition
rerender(<NavLink {...mockProps} mode="expand" />)
// Text should now appear
// Text should now be visible with opacity animation
expect(screen.getByText('Orchestrate')).toBeInTheDocument()
// Check padding change - this contributes to the squeeze effect
expect(linkElement).toHaveClass('px-3')
// Check padding remains consistent - no layout shift
expect(linkElement).toHaveClass('pl-3')
expect(linkElement).toHaveClass('pr-1')
// The bug: text appears abruptly without smooth transition
// This test documents the current behavior that causes the squeeze effect
// Fixed: text now uses max-width animation instead of abrupt show/hide
const expandedTextElement = screen.getByText('Orchestrate')
expect(expandedTextElement).toBeInTheDocument()
expect(expandedTextElement).toHaveClass('max-w-none')
expect(expandedTextElement).toHaveClass('opacity-100')
// In a properly animated version, we would expect:
// The fix provides:
// - Opacity transition from 0 to 1
// - Width transition from 0 to auto
// - No layout shift from padding changes
// - Max-width transition from 0 to none (prevents squashing)
// - No layout shift from consistent padding
})
it('should maintain icon position consistency during text appearance', () => {
it('should maintain icon position consistency using wrapper div', () => {
const { rerender } = render(<NavLink {...mockProps} mode="collapse" />)
const iconElement = screen.getByTestId('nav-icon')
const initialIconClasses = iconElement.className
const iconWrapper = iconElement.parentElement
// Icon should have mr-0 in collapse mode
expect(iconElement).toHaveClass('mr-0')
// Icon wrapper should have -ml-1 micro-adjustment in collapse mode for centering
expect(iconWrapper).toHaveClass('-ml-1')
rerender(<NavLink {...mockProps} mode="expand" />)
const expandedIconClasses = iconElement.className
// In expand mode, wrapper should not have the micro-adjustment
const expandedIconWrapper = screen.getByTestId('nav-icon').parentElement
expect(expandedIconWrapper).not.toHaveClass('-ml-1')
// Icon should have mr-2 in expand mode - this shift contributes to the squeeze effect
expect(iconElement).toHaveClass('mr-2')
// Icon itself maintains consistent classes - no margin changes
expect(iconElement).toHaveClass('h-4')
expect(iconElement).toHaveClass('w-4')
expect(iconElement).toHaveClass('shrink-0')
console.log('Collapsed icon classes:', initialIconClasses)
console.log('Expanded icon classes:', expandedIconClasses)
// This margin change causes the icon to shift when text appears
// This wrapper approach eliminates the icon margin shift issue
})
it('should document the abrupt text rendering issue', () => {
it('should provide smooth text transition with max-width animation', () => {
const { rerender } = render(<NavLink {...mockProps} mode="collapse" />)
// Text is present in DOM but hidden via CSS classes
// Text is always in DOM but controlled via CSS classes
const collapsedText = screen.getByText('Orchestrate')
expect(collapsedText).toBeInTheDocument()
expect(collapsedText).toHaveClass('opacity-0')
expect(collapsedText).toHaveClass('pointer-events-none')
expect(collapsedText).toHaveClass('max-w-0')
expect(collapsedText).toHaveClass('overflow-hidden')
rerender(<NavLink {...mockProps} mode="expand" />)
// Text suddenly appears in DOM - no transition
expect(screen.getByText('Orchestrate')).toBeInTheDocument()
// Text smoothly transitions to visible state
const expandedText = screen.getByText('Orchestrate')
expect(expandedText).toBeInTheDocument()
expect(expandedText).toHaveClass('opacity-100')
expect(expandedText).toHaveClass('max-w-none')
// The issue: {mode === 'expand' && name} causes abrupt show/hide
// instead of smooth opacity/width transition
// Fixed: Always present in DOM with smooth CSS transitions
// instead of abrupt conditional rendering
})
})
describe('Layout Shift Issues', () => {
it('should detect padding differences causing layout shifts', () => {
describe('Layout Consistency Improvements', () => {
it('should maintain consistent padding across all states', () => {
const { rerender } = render(<NavLink {...mockProps} mode="collapse" />)
const linkElement = screen.getByTestId('nav-link')
// Collapsed state padding
expect(linkElement).toHaveClass('px-2.5')
// Consistent padding in collapsed state
expect(linkElement).toHaveClass('pl-3')
expect(linkElement).toHaveClass('pr-1')
rerender(<NavLink {...mockProps} mode="expand" />)
// Expanded state padding - different value causes layout shift
expect(linkElement).toHaveClass('px-3')
// Same padding in expanded state - no layout shift
expect(linkElement).toHaveClass('pl-3')
expect(linkElement).toHaveClass('pr-1')
// This 2px difference (10px vs 12px) contributes to the squeeze effect
// This consistency eliminates the layout shift issue
})
it('should detect icon margin changes causing shifts', () => {
it('should use wrapper-based icon positioning instead of margin changes', () => {
const { rerender } = render(<NavLink {...mockProps} mode="collapse" />)
const iconElement = screen.getByTestId('nav-icon')
const iconWrapper = iconElement.parentElement
// Collapsed: no right margin
expect(iconElement).toHaveClass('mr-0')
// Collapsed: wrapper has micro-adjustment for centering
expect(iconWrapper).toHaveClass('-ml-1')
// Icon itself has consistent classes
expect(iconElement).toHaveClass('h-4')
expect(iconElement).toHaveClass('w-4')
expect(iconElement).toHaveClass('shrink-0')
rerender(<NavLink {...mockProps} mode="expand" />)
// Expanded: 8px right margin (mr-2)
expect(iconElement).toHaveClass('mr-2')
const expandedIconWrapper = screen.getByTestId('nav-icon').parentElement
// This sudden margin appearance causes the squeeze effect
// Expanded: no wrapper adjustment needed
expect(expandedIconWrapper).not.toHaveClass('-ml-1')
// Icon classes remain consistent - no margin shifts
expect(iconElement).toHaveClass('h-4')
expect(iconElement).toHaveClass('w-4')
expect(iconElement).toHaveClass('shrink-0')
})
})
@@ -172,7 +193,7 @@ describe('NavLink Text Animation Issues', () => {
const { rerender } = render(<NavLink {...mockProps} mode="collapse" />)
let linkElement = screen.getByTestId('nav-link')
expect(linkElement).not.toHaveClass('bg-state-accent-active')
expect(linkElement).not.toHaveClass('bg-components-menu-item-bg-active')
// Test with active state (when href matches current segment)
const activeProps = {
@@ -183,7 +204,63 @@ describe('NavLink Text Animation Issues', () => {
rerender(<NavLink {...activeProps} mode="expand" />)
linkElement = screen.getByTestId('nav-link')
expect(linkElement).toHaveClass('bg-state-accent-active')
expect(linkElement).toHaveClass('bg-components-menu-item-bg-active')
expect(linkElement).toHaveClass('text-text-accent-light-mode-only')
})
})
describe('Text Animation Classes', () => {
it('should have proper text classes in collapsed mode', () => {
render(<NavLink {...mockProps} mode="collapse" />)
const textElement = screen.getByText('Orchestrate')
expect(textElement).toHaveClass('overflow-hidden')
expect(textElement).toHaveClass('whitespace-nowrap')
expect(textElement).toHaveClass('transition-all')
expect(textElement).toHaveClass('duration-200')
expect(textElement).toHaveClass('ease-in-out')
expect(textElement).toHaveClass('ml-0')
expect(textElement).toHaveClass('max-w-0')
expect(textElement).toHaveClass('opacity-0')
})
it('should have proper text classes in expanded mode', () => {
render(<NavLink {...mockProps} mode="expand" />)
const textElement = screen.getByText('Orchestrate')
expect(textElement).toHaveClass('overflow-hidden')
expect(textElement).toHaveClass('whitespace-nowrap')
expect(textElement).toHaveClass('transition-all')
expect(textElement).toHaveClass('duration-200')
expect(textElement).toHaveClass('ease-in-out')
expect(textElement).toHaveClass('ml-2')
expect(textElement).toHaveClass('max-w-none')
expect(textElement).toHaveClass('opacity-100')
})
})
describe('Disabled State', () => {
it('should render as button when disabled', () => {
render(<NavLink {...mockProps} mode="expand" disabled={true} />)
const buttonElement = screen.getByRole('button')
expect(buttonElement).toBeInTheDocument()
expect(buttonElement).toBeDisabled()
expect(buttonElement).toHaveClass('cursor-not-allowed')
expect(buttonElement).toHaveClass('opacity-30')
})
it('should maintain consistent styling in disabled state', () => {
render(<NavLink {...mockProps} mode="collapse" disabled={true} />)
const buttonElement = screen.getByRole('button')
expect(buttonElement).toHaveClass('pl-3')
expect(buttonElement).toHaveClass('pr-1')
const iconWrapper = screen.getByTestId('nav-icon').parentElement
expect(iconWrapper).toHaveClass('-ml-1')
})
})
})

View File

@@ -1,15 +1,15 @@
'use client'
import React from 'react'
import { useSelectedLayoutSegment } from 'next/navigation'
import Link from 'next/link'
import classNames from '@/utils/classnames'
import type { RemixiconComponentType } from '@remixicon/react'
export type NavIcon = React.ComponentType<
React.PropsWithoutRef<React.ComponentProps<'svg'>> & {
title?: string | undefined
titleId?: string | undefined
}> | RemixiconComponentType
React.PropsWithoutRef<React.ComponentProps<'svg'>> & {
title?: string | undefined
titleId?: string | undefined
}> | RemixiconComponentType
export type NavLinkProps = {
name: string
@@ -19,14 +19,16 @@ export type NavLinkProps = {
normal: NavIcon
}
mode?: string
disabled?: boolean
}
export default function NavLink({
const NavLink = ({
name,
href,
iconMap,
mode = 'expand',
}: NavLinkProps) {
disabled = false,
}: NavLinkProps) => {
const segment = useSelectedLayoutSegment()
const formattedSegment = (() => {
let res = segment?.toLowerCase()
@@ -39,30 +41,59 @@ export default function NavLink({
const isActive = href.toLowerCase().split('/')?.pop() === formattedSegment
const NavIcon = isActive ? iconMap.selected : iconMap.normal
const renderIcon = () => (
<div className={classNames(mode !== 'expand' && '-ml-1')}>
<NavIcon className="h-4 w-4 shrink-0" aria-hidden="true" />
</div>
)
if (disabled) {
return (
<button
key={name}
type='button'
disabled
className={classNames(
'system-sm-medium flex h-8 cursor-not-allowed items-center rounded-lg text-components-menu-item-text opacity-30 hover:bg-components-menu-item-bg-hover',
'pl-3 pr-1',
)}
title={mode === 'collapse' ? name : ''}
aria-disabled
>
{renderIcon()}
<span
className={classNames(
'overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out',
mode === 'expand'
? 'ml-2 max-w-none opacity-100'
: 'ml-0 max-w-0 opacity-0',
)}
>
{name}
</span>
</button>
)
}
return (
<Link
key={name}
href={href}
className={classNames(
isActive ? 'bg-state-accent-active font-semibold text-text-accent' : 'text-components-menu-item-text hover:bg-state-base-hover hover:text-components-menu-item-text-hover',
'group flex h-9 items-center rounded-md py-2 text-sm font-normal',
mode === 'expand' ? 'px-3' : 'px-2.5',
isActive
? 'system-sm-semibold border-b-[0.25px] border-l-[0.75px] border-r-[0.25px] border-t-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active text-text-accent-light-mode-only'
: 'system-sm-medium text-components-menu-item-text hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover',
'flex h-8 items-center rounded-lg pl-3 pr-1',
)}
title={mode === 'collapse' ? name : ''}
>
<NavIcon
className={classNames(
'h-4 w-4 shrink-0',
mode === 'expand' ? 'mr-2' : 'mr-0',
)}
aria-hidden="true"
/>
{renderIcon()}
<span
className={classNames(
'whitespace-nowrap transition-all duration-200 ease-in-out',
'overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out',
mode === 'expand'
? 'w-auto opacity-100'
: 'pointer-events-none w-0 overflow-hidden opacity-0',
? 'ml-2 max-w-none opacity-100'
: 'ml-0 max-w-0 opacity-0',
)}
>
{name}
@@ -70,3 +101,5 @@ export default function NavLink({
</Link>
)
}
export default React.memo(NavLink)

View File

@@ -0,0 +1,71 @@
import React from 'react'
import Button from '../base/button'
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
import cn from '@/utils/classnames'
import Tooltip from '../base/tooltip'
import { useTranslation } from 'react-i18next'
import { getKeyboardKeyNameBySystem } from '../workflow/utils'
type TooltipContentProps = {
expand: boolean
}
const TOGGLE_SHORTCUT = ['ctrl', 'B']
const TooltipContent = ({
expand,
}: TooltipContentProps) => {
const { t } = useTranslation()
return (
<div className='flex items-center gap-x-1'>
<span className='system-xs-medium px-0.5 text-text-secondary'>{expand ? t('layout.sidebar.collapseSidebar') : t('layout.sidebar.expandSidebar')}</span>
<div className='flex items-center gap-x-0.5'>
{
TOGGLE_SHORTCUT.map(key => (
<span
key={key}
className='system-kbd inline-flex items-center justify-center rounded-[4px] bg-components-kbd-bg-gray px-1 text-text-tertiary'
>
{getKeyboardKeyNameBySystem(key)}
</span>
))
}
</div>
</div>
)
}
type ToggleButtonProps = {
expand: boolean
handleToggle: () => void
className?: string
}
const ToggleButton = ({
expand,
handleToggle,
className,
}: ToggleButtonProps) => {
return (
<Tooltip
popupContent={<TooltipContent expand={expand} />}
popupClassName='p-1.5 rounded-lg'
position='right'
>
<Button
size='small'
onClick={handleToggle}
className={cn('rounded-full px-1', className)}
>
{
expand
? <RiArrowLeftSLine className='size-4' />
: <RiArrowRightSLine className='size-4' />
}
</Button>
</Tooltip>
)
}
export default React.memo(ToggleButton)