feat(workflow): add node relations panel to display dependencies

Introduce a new relations tab in workflow panel to visualize node dependencies and dependents. Includes:
- New RelationType enum for dependency types
- Internationalized UI text for multiple languages
- Components for displaying relations with arrows and empty states
- Logic to determine and display dependency relationships
pull/21998/head
Minamiyama 10 months ago committed by crazywoola
parent e4ae1e2b94
commit 4c27386ba0

@ -59,6 +59,8 @@ import { useLogs } from '@/app/components/workflow/run/hooks'
import PanelWrap from '../before-run-form/panel-wrap'
import SpecialResultPanel from '@/app/components/workflow/run/special-result-panel'
import { Stop } from '@/app/components/base/icons/src/vender/line/mediaAndDevices'
import Relations from './relations'
import { RelationType } from './relations/types'
type BasePanelProps = {
children: ReactNode
@ -426,6 +428,30 @@ const BasePanel: FC<BasePanelProps> = ({
{...passedLogParams}
/>
)}
{tabType === TabType.relations && (
<>
<div className='border-t-[0.5px] border-divider-regular p-4'>
<div className='system-sm-semibold-uppercase mb-1 flex items-center text-text-secondary'>
{t('workflow.debug.relations.dependencies').toLocaleUpperCase()}
</div>
<div className='system-xs-regular mb-2 text-text-tertiary'>
{t('workflow.debug.relations.dependenciesDescription')}
</div>
<Relations selectedNode={{ id, data } as Node} relationType={RelationType.dependencies}/>
</div>
<Split />
<div className='border-t-[0.5px] border-divider-regular p-4'>
<div className='system-sm-semibold-uppercase mb-1 flex items-center text-text-secondary'>
{t('workflow.debug.relations.dependents').toLocaleUpperCase()}
</div>
<div className='system-xs-regular mb-2 text-text-tertiary'>
{t('workflow.debug.relations.dependentsDescription')}
</div>
<Relations selectedNode={{ id, data } as Node} relationType={RelationType.dependents}/>
</div>
</>
)}
</div>
</div>
)

@ -0,0 +1,35 @@
import Empty from './empty'
import Item from './item'
import type { Node } from '@/app/components/workflow/types'
import cn from '@/utils/classnames'
import { RelationType } from './types'
import { useTranslation } from 'react-i18next'
type ContainerProps = {
nextNode?: Node
relationType: RelationType
}
const Container = ({
nextNode,
relationType,
}: ContainerProps) => {
const { t } = useTranslation()
return (
<div className={cn(
'space-y-0.5 rounded-[10px] bg-background-section-burn p-0.5',
)}>
{nextNode && (
<Item
key={nextNode.id}
nodeId={nextNode.id}
data={nextNode.data}
/>
)}
{!nextNode && (
<Empty display={relationType === RelationType.dependencies ? t('workflow.debug.relations.noDependencies') : t('workflow.debug.relations.noDependents')} />
)}
</div>
)
}
export default Container

@ -0,0 +1,27 @@
import { memo } from 'react'
type EmptyProps = {
display: string
}
const Empty = ({
display = '',
}: EmptyProps) => {
return (
<>
<div
className={`
bg-dropzone-bg hover:bg-dropzone-bg-hover relative flex h-9 cursor-default items-center rounded-lg border border-dashed
border-divider-regular !bg-components-dropzone-bg-alt px-2 text-xs
text-text-placeholder
`}
>
<div className='flex items-center uppercase'>
{display}
</div>
</div>
</>
)
}
export default memo(Empty)

@ -0,0 +1,121 @@
import { memo } from 'react'
import { isEqual } from 'lodash-es'
import {
getOutgoers,
useStore,
} from 'reactflow'
import { useToolIcon } from '@/app/components/workflow/hooks'
import BlockIcon from '@/app/components/workflow/block-icon'
import type {
Node,
} from '@/app/components/workflow/types'
import Line from './line'
import Container from './container'
import { getNodeUsedVars } from '../../variable/utils'
import { RelationType } from './types'
type RelationsProps = {
selectedNode: Node
relationType: RelationType
}
const Relations = ({
selectedNode,
relationType,
}: RelationsProps) => {
const data = selectedNode.data
const toolIcon = useToolIcon(data)
const edges = useStore(s => s.edges.map(edge => ({
id: edge.id,
source: edge.source,
sourceHandle: edge.sourceHandle,
target: edge.target,
targetHandle: edge.targetHandle,
})), isEqual)
const nodes = useStore(s => s.getNodes().map(node => ({
id: node.id,
data: node.data,
})), isEqual)
const workflowNodes = useStore(s => s.getNodes())
const list: Node[] = []
if (relationType === RelationType.dependencies) {
const usedVars = getNodeUsedVars(selectedNode)
const dependencyNodes: Node[] = []
usedVars.forEach((valueSelector) => {
const node = workflowNodes.find(node => node.id === valueSelector?.[0])
if (node) {
if (!dependencyNodes.includes(node))
dependencyNodes.push(node)
}
})
list.push(...dependencyNodes)
}
else {
const outgoers = getOutgoers(selectedNode as Node, nodes as Node[], edges)
for (let currIdx = 0; currIdx < outgoers.length; currIdx++) {
const node = outgoers[currIdx]
const outgoersForNode = getOutgoers(node, nodes as Node[], edges)
outgoersForNode.forEach((item) => {
const existed = outgoers.some(v => v.id === item.id)
if (!existed)
outgoers.push(item)
})
}
const dependentNodes: Node[] = []
outgoers.forEach((node) => {
const usedVars = getNodeUsedVars(node)
const used = usedVars.some(v => v?.[0] === selectedNode.id)
if (used) {
const existed = dependentNodes.some(v => v.id === node.id)
if (!existed)
dependentNodes.push(node)
}
})
list.push(...dependentNodes)
}
const getThisNode = () => {
return (
<div className='relative flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border-[0.5px] border-divider-regular bg-background-default shadow-xs'>
<BlockIcon
type={selectedNode!.data.type}
toolIcon={toolIcon}
/>
</div>
)
}
const getLinkedNodes = () => {
return (
<div className='grow space-y-2'>
{list.length > 0 ? (
list.map((item, index) => {
return (
<Container
key={index}
nextNode={item}
/>
)
})
) : (
<Container key={0} />
)}
</div>
)
}
return (
<div className='flex py-1'>
{getThisNode()}
<Line
rowCount={Math.max(1, list.length)}
relationType={relationType}
/>
{getLinkedNodes()}
</div>
)
}
export default memo(Relations)

@ -0,0 +1,59 @@
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import type {
CommonNodeType,
} from '@/app/components/workflow/types'
import BlockIcon from '@/app/components/workflow/block-icon'
import {
useNodesInteractions,
useNodesReadOnly,
useToolIcon,
} from '@/app/components/workflow/hooks'
import Button from '@/app/components/base/button'
type ItemProps = {
nodeId: string
data: CommonNodeType
}
const Item = ({
nodeId,
data,
}: ItemProps) => {
const { t } = useTranslation()
const { nodesReadOnly } = useNodesReadOnly()
const { handleNodeSelect } = useNodesInteractions()
const toolIcon = useToolIcon(data)
return (
<div
className='group relative flex h-9 cursor-pointer items-center rounded-lg border-[0.5px] border-divider-regular bg-background-default px-2 text-xs text-text-secondary shadow-xs last-of-type:mb-0 hover:bg-background-default-hover'
>
<BlockIcon
type={data.type}
toolIcon={toolIcon}
className='mr-1.5 shrink-0'
/>
<div
className='system-xs-medium grow truncate text-text-secondary'
title={data.title}
>
{data.title}
</div>
{
!nodesReadOnly && (
<>
<Button
className='mr-1 hidden shrink-0 group-hover:flex'
size='small'
onClick={() => handleNodeSelect(nodeId)}
>
{t('workflow.common.jumpToNode')}
</Button>
</>
)
}
</div>
)
}
export default memo(Item)

@ -0,0 +1,92 @@
import { memo } from 'react'
import { RelationType } from './types'
type LineProps = {
rowCount: number
relationType: RelationType
}
const Line = ({
rowCount = 1,
relationType,
}: LineProps) => {
const list: number[] = []
const listH = 40
const ySpacing = 8
const svgHeight = listH * rowCount + ySpacing * (rowCount - 1)
const lineWidth = 48
const arrowStrokeWidth = 1
const arrowLineLength = 6
return (
<svg className='w-12 shrink-0' style={{ height: svgHeight }}>
{
Array.from({ length: rowCount }).map((_, index) => {
const space = index * listH + index * ySpacing + 16
return (
<g key={index}>
{
index === 0 && (
<>
<path
d={`M0,18 L${lineWidth},18`}
strokeWidth={1}
fill='none'
className='stroke-divider-solid'
/>
<path
d={relationType === RelationType.dependencies
? `M${6 + arrowLineLength},${18 - arrowLineLength} L6,${18} L${6 + arrowLineLength},${18 + arrowLineLength}`
: `M${lineWidth - 6 - arrowLineLength},${18 - arrowLineLength} L${lineWidth - 6},${18} L${lineWidth - 6 - arrowLineLength},${18 + arrowLineLength}`
}
strokeWidth={arrowStrokeWidth}
fill='none'
className='stroke-divider-solid-alt'
/>
<rect
x={lineWidth - 1}
y={16}
width={1}
height={4}
className='fill-divider-solid-alt'
/>
</>
)
}
{
index > 0 && (
<>
<path
d={(`M0,18 L${lineWidth / 2 - 12},18 Q${lineWidth / 2},18 ${lineWidth / 2},28 L${lineWidth / 2},${space - 10 + 2} Q${lineWidth / 2},${space + 2} ${lineWidth / 2 + 12},${space + 2} L${lineWidth},${space + 2}`)}
strokeWidth={1}
fill='none'
className='stroke-divider-solid'
/>
{relationType === RelationType.dependents && (
<path
d={`M${lineWidth - 6 - arrowLineLength},${space + 2 - arrowLineLength} L${lineWidth - 6},${space + 2} L${lineWidth - 6 - arrowLineLength},${space + 2 + arrowLineLength}`}
strokeWidth={arrowStrokeWidth}
fill='none'
className='stroke-divider-solid-alt'
/>
)}
</>
)
}
<rect
x={lineWidth - 1}
y={space}
width={1}
height={4}
className='fill-divider-solid-alt'
/>
</g>
)
})
}
</svg>
)
}
export default memo(Line)

@ -0,0 +1,4 @@
export enum RelationType {
dependencies,
dependents,
}

@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next'
export enum TabType {
settings = 'settings',
lastRun = 'lastRun',
relations = 'relations',
}
type Props = {
@ -24,6 +25,7 @@ const Tab: FC<Props> = ({
items={[
{ id: TabType.settings, name: t('workflow.debug.settingsTab').toLocaleUpperCase() },
{ id: TabType.lastRun, name: t('workflow.debug.lastRunTab').toLocaleUpperCase() },
{ id: TabType.relations, name: t('workflow.debug.relationsTab').toLocaleUpperCase() },
]}
itemClassName='ml-0'
value={value}

@ -941,6 +941,7 @@ const translation = {
debug: {
settingsTab: 'Settings',
lastRunTab: 'Last Run',
relationsTab: 'Relations',
noData: {
description: 'The results of the last run will be displayed here',
runThisNode: 'Run this node',
@ -966,6 +967,14 @@ const translation = {
chatNode: 'Conversation',
systemNode: 'System',
},
relations: {
dependencies: 'Dependencies',
dependents: 'Dependents',
dependenciesDescription: 'Nodes that this node relies on',
dependentsDescription: 'Nodes that rely on this node',
noDependencies: 'No dependencies',
noDependents: 'No dependents',
},
},
}

@ -958,6 +958,15 @@ const translation = {
},
settingsTab: '設定',
lastRunTab: '最後の実行',
relationsTab: '関係',
relations: {
dependencies: '依存元',
dependents: '依存先',
dependenciesDescription: 'このノードが依存している他のノード',
dependentsDescription: 'このノードに依存している他のノード',
noDependencies: '依存元なし',
noDependents: '依存先なし',
},
},
}

@ -941,6 +941,7 @@ const translation = {
debug: {
settingsTab: '设置',
lastRunTab: '上次运行',
relationsTab: '关系',
noData: {
description: '上次运行的结果将显示在这里',
runThisNode: '运行此节点',
@ -966,6 +967,14 @@ const translation = {
chatNode: '会话变量',
systemNode: '系统变量',
},
relations: {
dependencies: '依赖',
dependents: '被依赖',
dependenciesDescription: '本节点依赖的其他节点',
dependentsDescription: '依赖于本节点的其他节点',
noDependencies: '无依赖',
noDependents: '无被依赖',
},
},
}

@ -932,6 +932,9 @@ const translation = {
defaultName: '未命名版本',
},
debug: {
settingsTab: '設定',
lastRunTab: '最後一次運行',
relationsTab: '關係',
noData: {
runThisNode: '運行此節點',
description: '上次運行的結果將顯示在這裡',
@ -957,8 +960,14 @@ const translation = {
emptyTip: '在畫布上逐步執行節點或逐步運行節點後,您可以在變數檢視中查看節點變數的當前值。',
resetConversationVar: '將對話變數重置為默認值',
},
settingsTab: '設定',
lastRunTab: '最後一次運行',
relations: {
dependencies: '依賴',
dependents: '被依賴',
dependenciesDescription: '此節點所依賴的其他節點',
dependentsDescription: '依賴此節點的其他節點',
noDependencies: '無依賴',
noDependents: '無被依賴',
},
},
}

Loading…
Cancel
Save