feat(web): unify node selector availability rules

This commit is contained in:
yyh
2026-02-13 12:16:24 +08:00
parent 49b115b1ea
commit 086f6a2bb3
10 changed files with 779 additions and 22 deletions

View File

@ -29,6 +29,8 @@ import { usePipelineTemplate } from './use-pipeline-template'
// Mocks
// ============================================================================
let mockSandboxEnabled = false
// Mock the workflow store
const _mockGetState = vi.fn()
const mockUseStore = vi.fn()
@ -46,6 +48,16 @@ vi.mock('react-i18next', () => ({
}),
}))
vi.mock('@/app/components/base/features/hooks', () => ({
useFeatures: (selector: (state: { features: { sandbox: { enabled: boolean } } }) => unknown) => selector({
features: {
sandbox: {
enabled: mockSandboxEnabled,
},
},
}),
}))
// Mock toast context
const mockNotify = vi.fn()
vi.mock('@/app/components/base/toast', () => ({
@ -91,6 +103,18 @@ vi.mock('@/app/components/workflow/constants/node', () => ({
metaData: { type: BlockEnum.End },
defaultValue: { type: BlockEnum.End },
},
{
metaData: { type: BlockEnum.Command },
defaultValue: { type: BlockEnum.Command },
},
{
metaData: { type: BlockEnum.FileUpload },
defaultValue: { type: BlockEnum.FileUpload },
},
{
metaData: { type: BlockEnum.HumanInput },
defaultValue: { type: BlockEnum.HumanInput },
},
],
}))
@ -147,6 +171,10 @@ vi.mock('@/service/workflow', () => ({
// Tests
// ============================================================================
beforeEach(() => {
mockSandboxEnabled = false
})
describe('useConfigsMap', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -468,6 +496,26 @@ describe('useAvailableNodesMetaData', () => {
expect(result.current.nodesMap).toBeDefined()
expect(typeof result.current.nodesMap).toBe('object')
})
it('should hide sandbox-only nodes when sandbox is disabled', () => {
mockSandboxEnabled = false
const { result } = renderHook(() => useAvailableNodesMetaData())
const nodeTypes = result.current.nodes.map(node => node.metaData.type)
expect(nodeTypes).not.toContain(BlockEnum.Command)
expect(nodeTypes).not.toContain(BlockEnum.FileUpload)
expect(nodeTypes).not.toContain(BlockEnum.HumanInput)
})
it('should keep sandbox-only nodes hidden even when sandbox is enabled', () => {
mockSandboxEnabled = true
const { result } = renderHook(() => useAvailableNodesMetaData())
const nodeTypes = result.current.nodes.map(node => node.metaData.type)
expect(nodeTypes).not.toContain(BlockEnum.Command)
expect(nodeTypes).not.toContain(BlockEnum.FileUpload)
expect(nodeTypes).not.toContain(BlockEnum.HumanInput)
})
})
describe('usePipelineTemplate', () => {

View File

@ -3,6 +3,11 @@ import type { CommonNodeType, NodeDefault, NodeDefaultBase } from '@/app/compone
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { WORKFLOW_COMMON_NODES } from '@/app/components/workflow/constants/node'
import {
buildNodeSelectorAvailabilityContext,
filterNodesForSelector,
NodeSelectorScene,
} from '@/app/components/workflow/constants/node-availability'
import dataSourceEmptyDefault from '@/app/components/workflow/nodes/data-source-empty/default'
import dataSourceDefault from '@/app/components/workflow/nodes/data-source/default'
import knowledgeBaseDefault from '@/app/components/workflow/nodes/knowledge-base/default'
@ -12,10 +17,12 @@ import { useDocLink } from '@/context/i18n'
export const useAvailableNodesMetaData = () => {
const { t } = useTranslation()
const docLink = useDocLink()
const nodeAvailabilityContext = useMemo(() => buildNodeSelectorAvailabilityContext({
scene: NodeSelectorScene.RagPipeline,
}), [])
const mergedNodesMetaData = useMemo(() => [
// RAG pipeline doesn't support human-input node temporarily
...WORKFLOW_COMMON_NODES.filter(node => node.metaData.type !== BlockEnum.HumanInput),
...filterNodesForSelector(WORKFLOW_COMMON_NODES, nodeAvailabilityContext),
{
...dataSourceDefault,
defaultValue: {
@ -25,7 +32,7 @@ export const useAvailableNodesMetaData = () => {
},
knowledgeBaseDefault,
dataSourceEmptyDefault,
] as AvailableNodesMetaData['nodes'], [])
] as AvailableNodesMetaData['nodes'], [nodeAvailabilityContext])
const helpLinkUri = useMemo(() => docLink(
'/use-dify/knowledge/knowledge-pipeline/knowledge-pipeline-orchestration',

View File

@ -0,0 +1,130 @@
import type { NestedNodeConfig } from '@/app/components/workflow/nodes/_base/types'
import { render } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { NULL_STRATEGY } from '@/app/components/workflow/nodes/_base/constants'
import { FlowType } from '@/types/common'
import SubGraphMain from './sub-graph-main'
const mockUseAvailableNodesMetaData = vi.fn()
const mockUseSetWorkflowVarsWithValue = vi.fn()
const mockUseInspectVarsCrudCommon = vi.fn()
const mockSetPendingSingleRun = vi.fn()
vi.mock('@/app/components/workflow', () => ({
InteractionMode: {
Subgraph: 'subgraph',
},
WorkflowWithInnerContext: ({ children }: { children: React.ReactNode }) => (
<div data-testid="workflow-with-inner-context">{children}</div>
),
}))
vi.mock('reactflow', () => ({
useStoreApi: () => ({
getState: () => ({
getNodes: () => [],
edges: [],
}),
}),
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useNodesInteractions: () => ({
handleNodeSelect: vi.fn(),
}),
}))
vi.mock('@/app/components/workflow/hooks/use-fetch-workflow-inspect-vars', () => ({
useSetWorkflowVarsWithValue: (params: unknown) => mockUseSetWorkflowVarsWithValue(params),
}))
vi.mock('@/app/components/workflow/hooks/use-inspect-vars-crud-common', () => ({
useInspectVarsCrudCommon: (params: unknown) => mockUseInspectVarsCrudCommon(params),
}))
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({
getState: () => ({
setPendingSingleRun: mockSetPendingSingleRun,
}),
}),
}))
vi.mock('../hooks', () => ({
useAvailableNodesMetaData: (flowType: FlowType) => mockUseAvailableNodesMetaData(flowType),
}))
vi.mock('./sub-graph-children', () => ({
default: () => <div data-testid="sub-graph-children" />,
}))
const nestedNodeConfig: NestedNodeConfig = {
extractor_node_id: 'extractor-1',
output_selector: ['extractor-1', 'output'],
null_strategy: NULL_STRATEGY.RAISE_ERROR,
default_value: '',
}
describe('SubGraphMain flowType wiring', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseAvailableNodesMetaData.mockReturnValue({
nodes: [],
nodesMap: {},
})
mockUseSetWorkflowVarsWithValue.mockReturnValue({
fetchInspectVars: vi.fn(),
})
mockUseInspectVarsCrudCommon.mockReturnValue({})
})
it('should pass configs map flow type to useAvailableNodesMetaData', () => {
render(
<SubGraphMain
variant="assemble"
nodes={[]}
edges={[]}
viewport={{ x: 0, y: 0, zoom: 1 }}
title="Subgraph"
extractorNodeId="extractor-1"
configsMap={{
flowId: 'pipeline-1',
flowType: FlowType.ragPipeline,
}}
isOpen={false}
nestedNodeConfig={nestedNodeConfig}
onNestedNodeConfigChange={vi.fn()}
/>,
)
expect(mockUseAvailableNodesMetaData).toHaveBeenCalledWith(FlowType.ragPipeline)
expect(mockUseSetWorkflowVarsWithValue).toHaveBeenCalledWith({
flowType: FlowType.ragPipeline,
flowId: 'pipeline-1',
interactionMode: 'subgraph',
})
})
it('should fall back to app flow when configs map is missing', () => {
render(
<SubGraphMain
variant="assemble"
nodes={[]}
edges={[]}
viewport={{ x: 0, y: 0, zoom: 1 }}
title="Subgraph"
extractorNodeId="extractor-1"
isOpen={false}
nestedNodeConfig={nestedNodeConfig}
onNestedNodeConfigChange={vi.fn()}
/>,
)
expect(mockUseAvailableNodesMetaData).toHaveBeenCalledWith(FlowType.appFlow)
expect(mockUseSetWorkflowVarsWithValue).toHaveBeenCalledWith({
flowType: FlowType.appFlow,
flowId: '',
interactionMode: 'subgraph',
})
})
})

View File

@ -62,9 +62,9 @@ const SubGraphMain: FC<SubGraphMainProps> = (props) => {
const reactFlowStore = useStoreApi()
const workflowStore = useWorkflowStore()
const { handleNodeSelect } = useNodesInteractions()
const availableNodesMetaData = useAvailableNodesMetaData()
const flowType = configsMap?.flowType ?? FlowType.appFlow
const flowId = configsMap?.flowId ?? ''
const availableNodesMetaData = useAvailableNodesMetaData(flowType)
const { fetchInspectVars } = useSetWorkflowVarsWithValue({
flowType,
flowId,

View File

@ -0,0 +1,110 @@
import { renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { BlockEnum } from '@/app/components/workflow/types'
import { FlowType } from '@/types/common'
import { useAvailableNodesMetaData } from './use-available-nodes-meta-data'
let mockSandboxEnabled = false
let mockRuntimeType: 'sandboxed' | 'classic' = 'classic'
function createNodeDefault(type: BlockEnum) {
return {
metaData: { type },
defaultValue: { type },
}
}
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/app/components/base/features/hooks', () => ({
useFeatures: (selector: (state: { features: { sandbox: { enabled: boolean } } }) => unknown) => selector({
features: {
sandbox: {
enabled: mockSandboxEnabled,
},
},
}),
}))
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: { appDetail: { runtime_type: 'sandboxed' | 'classic' } }) => unknown) => selector({
appDetail: {
runtime_type: mockRuntimeType,
},
}),
}))
vi.mock('@/app/components/workflow/constants/node', () => ({
WORKFLOW_COMMON_NODES: [
createNodeDefault(BlockEnum.Agent),
createNodeDefault(BlockEnum.Command),
createNodeDefault(BlockEnum.FileUpload),
createNodeDefault(BlockEnum.HumanInput),
createNodeDefault(BlockEnum.VariableAggregator),
],
}))
describe('sub-graph/useAvailableNodesMetaData', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSandboxEnabled = false
mockRuntimeType = 'classic'
})
it('should hide sandbox-only nodes in app flow when sandbox is disabled', () => {
const { result } = renderHook(() => useAvailableNodesMetaData(FlowType.appFlow))
const nodeTypes = result.current.nodes.map(node => node.metaData.type)
expect(nodeTypes).not.toContain(BlockEnum.Command)
expect(nodeTypes).not.toContain(BlockEnum.FileUpload)
expect(nodeTypes).toContain(BlockEnum.Agent)
expect(nodeTypes).toContain(BlockEnum.HumanInput)
})
it('should show sandbox-only nodes in app flow when sandbox feature is enabled', () => {
mockSandboxEnabled = true
const { result } = renderHook(() => useAvailableNodesMetaData(FlowType.appFlow))
const nodeTypes = result.current.nodes.map(node => node.metaData.type)
expect(nodeTypes).toContain(BlockEnum.Command)
expect(nodeTypes).toContain(BlockEnum.FileUpload)
expect(nodeTypes).not.toContain(BlockEnum.Agent)
expect(nodeTypes).toContain(BlockEnum.HumanInput)
})
it('should show sandbox-only nodes in app flow when runtime type is sandboxed', () => {
mockRuntimeType = 'sandboxed'
const { result } = renderHook(() => useAvailableNodesMetaData(FlowType.appFlow))
const nodeTypes = result.current.nodes.map(node => node.metaData.type)
expect(nodeTypes).toContain(BlockEnum.Command)
expect(nodeTypes).toContain(BlockEnum.FileUpload)
expect(nodeTypes).not.toContain(BlockEnum.Agent)
})
it('should ignore sandbox flags in rag pipeline flow', () => {
mockSandboxEnabled = true
mockRuntimeType = 'sandboxed'
const { result } = renderHook(() => useAvailableNodesMetaData(FlowType.ragPipeline))
const nodeTypes = result.current.nodes.map(node => node.metaData.type)
expect(nodeTypes).not.toContain(BlockEnum.Command)
expect(nodeTypes).not.toContain(BlockEnum.FileUpload)
expect(nodeTypes).toContain(BlockEnum.Agent)
expect(nodeTypes).not.toContain(BlockEnum.HumanInput)
})
it('should map variable assigner to variable aggregator metadata', () => {
const { result } = renderHook(() => useAvailableNodesMetaData(FlowType.appFlow))
expect(result.current.nodesMap[BlockEnum.VariableAggregator]).toBeDefined()
expect(result.current.nodesMap[BlockEnum.VariableAssigner]).toBe(result.current.nodesMap[BlockEnum.VariableAggregator])
})
})

View File

@ -2,11 +2,31 @@ import type { AvailableNodesMetaData } from '@/app/components/workflow/hooks-sto
import type { CommonNodeType, NodeDefault, NodeDefaultBase } from '@/app/components/workflow/types'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useFeatures } from '@/app/components/base/features/hooks'
import { WORKFLOW_COMMON_NODES } from '@/app/components/workflow/constants/node'
import {
buildNodeSelectorAvailabilityContext,
filterNodesForSelector,
NodeSelectorScene,
} from '@/app/components/workflow/constants/node-availability'
import { BlockEnum } from '@/app/components/workflow/types'
import { FlowType } from '@/types/common'
export const useAvailableNodesMetaData = () => {
export const useAvailableNodesMetaData = (flowType: FlowType = FlowType.appFlow) => {
const { t } = useTranslation()
const isSandboxFeatureEnabled = useFeatures(s => s.features.sandbox?.enabled) ?? false
const isSandboxRuntime = useAppStore(s => s.appDetail?.runtime_type === 'sandboxed')
const scene = useMemo(() => {
if (flowType === FlowType.ragPipeline)
return NodeSelectorScene.RagPipeline
return NodeSelectorScene.Subgraph
}, [flowType])
const nodeAvailabilityContext = useMemo(() => buildNodeSelectorAvailabilityContext({
scene,
isSandboxRuntime,
isSandboxFeatureEnabled,
}), [scene, isSandboxFeatureEnabled, isSandboxRuntime])
const availableNodesMetaData = useMemo<NodeDefaultBase[]>(() => {
const toNodeDefaultBase = (
@ -31,7 +51,7 @@ export const useAvailableNodesMetaData = () => {
}
}
return WORKFLOW_COMMON_NODES.map((node) => {
return filterNodesForSelector(WORKFLOW_COMMON_NODES, nodeAvailabilityContext).map((node) => {
// normalize per-node defaults into a shared metadata shape.
const typedNode = node as NodeDefault<CommonNodeType>
const { metaData } = typedNode
@ -47,7 +67,7 @@ export const useAvailableNodesMetaData = () => {
title,
})
})
}, [t])
}, [nodeAvailabilityContext, t])
const availableNodesMetaDataMap = useMemo(() => availableNodesMetaData.reduce((acc, node) => {
acc![node.metaData.type] = node

View File

@ -0,0 +1,168 @@
import { renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { BlockEnum } from '@/app/components/workflow/types'
import { useAvailableNodesMetaData } from './use-available-nodes-meta-data'
let mockIsChatMode = false
let mockSandboxEnabled = false
let mockRuntimeType: 'sandboxed' | 'classic' = 'classic'
function createNodeDefault(type: BlockEnum, helpLinkUri?: string) {
return {
metaData: {
type,
...(helpLinkUri ? { helpLinkUri } : {}),
},
defaultValue: { type },
}
}
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.dify.ai${path}`,
}))
vi.mock('./use-is-chat-mode', () => ({
useIsChatMode: () => mockIsChatMode,
}))
vi.mock('@/app/components/base/features/hooks', () => ({
useFeatures: (selector: (state: { features: { sandbox: { enabled: boolean } } }) => unknown) => selector({
features: {
sandbox: {
enabled: mockSandboxEnabled,
},
},
}),
}))
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: { appDetail: { runtime_type: 'sandboxed' | 'classic' } }) => unknown) => selector({
appDetail: {
runtime_type: mockRuntimeType,
},
}),
}))
vi.mock('@/app/components/workflow/constants/node', () => ({
WORKFLOW_COMMON_NODES: [
createNodeDefault(BlockEnum.LLM, 'llm-help'),
createNodeDefault(BlockEnum.Agent),
createNodeDefault(BlockEnum.Command),
createNodeDefault(BlockEnum.FileUpload),
createNodeDefault(BlockEnum.VariableAggregator),
],
}))
vi.mock('@/app/components/workflow/nodes/start/default', () => ({
default: createNodeDefault(BlockEnum.Start),
}))
vi.mock('@/app/components/workflow/nodes/end/default', () => ({
default: createNodeDefault(BlockEnum.End),
}))
vi.mock('@/app/components/workflow/nodes/answer/default', () => ({
default: createNodeDefault(BlockEnum.Answer),
}))
vi.mock('@/app/components/workflow/nodes/trigger-webhook/default', () => ({
default: createNodeDefault(BlockEnum.TriggerWebhook),
}))
vi.mock('@/app/components/workflow/nodes/trigger-schedule/default', () => ({
default: createNodeDefault(BlockEnum.TriggerSchedule),
}))
vi.mock('@/app/components/workflow/nodes/trigger-plugin/default', () => ({
default: createNodeDefault(BlockEnum.TriggerPlugin),
}))
describe('workflow-app/useAvailableNodesMetaData', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsChatMode = false
mockSandboxEnabled = false
mockRuntimeType = 'classic'
})
it('should include workflow-only nodes when chat mode is disabled', () => {
const { result } = renderHook(() => useAvailableNodesMetaData())
const nodeTypes = result.current.nodes.map(node => node.metaData.type)
const startNode = result.current.nodes.find(node => node.metaData.type === BlockEnum.Start)
expect(nodeTypes).toContain(BlockEnum.End)
expect(nodeTypes).toContain(BlockEnum.TriggerWebhook)
expect(nodeTypes).toContain(BlockEnum.TriggerSchedule)
expect(nodeTypes).toContain(BlockEnum.TriggerPlugin)
expect(nodeTypes).not.toContain(BlockEnum.Answer)
expect(startNode?.metaData.isUndeletable).toBe(false)
})
it('should include chatflow-only nodes when chat mode is enabled', () => {
mockIsChatMode = true
const { result } = renderHook(() => useAvailableNodesMetaData())
const nodeTypes = result.current.nodes.map(node => node.metaData.type)
const startNode = result.current.nodes.find(node => node.metaData.type === BlockEnum.Start)
expect(nodeTypes).toContain(BlockEnum.Answer)
expect(nodeTypes).not.toContain(BlockEnum.End)
expect(nodeTypes).not.toContain(BlockEnum.TriggerWebhook)
expect(nodeTypes).not.toContain(BlockEnum.TriggerSchedule)
expect(nodeTypes).not.toContain(BlockEnum.TriggerPlugin)
expect(startNode?.metaData.isUndeletable).toBe(true)
})
it('should hide sandbox-only nodes and keep agent when sandbox is disabled', () => {
const { result } = renderHook(() => useAvailableNodesMetaData())
const nodeTypes = result.current.nodes.map(node => node.metaData.type)
const llmNode = result.current.nodesMap[BlockEnum.LLM]
expect(nodeTypes).not.toContain(BlockEnum.Command)
expect(nodeTypes).not.toContain(BlockEnum.FileUpload)
expect(nodeTypes).toContain(BlockEnum.Agent)
expect(llmNode.metaData.title).toBe('blocks.llm')
expect(llmNode.metaData.iconType).toBeUndefined()
})
it('should show sandbox-only nodes and hide agent when sandbox feature is enabled', () => {
mockSandboxEnabled = true
const { result } = renderHook(() => useAvailableNodesMetaData())
const nodeTypes = result.current.nodes.map(node => node.metaData.type)
const llmNode = result.current.nodesMap[BlockEnum.LLM]
const fileUploadNode = result.current.nodesMap[BlockEnum.FileUpload]
expect(nodeTypes).toContain(BlockEnum.Command)
expect(nodeTypes).toContain(BlockEnum.FileUpload)
expect(nodeTypes).not.toContain(BlockEnum.Agent)
expect(llmNode.metaData.title).toBe('blocks.agent')
expect(llmNode.metaData.iconType).toBe(BlockEnum.Agent)
expect(llmNode.defaultValue._iconTypeOverride).toBe(BlockEnum.Agent)
expect(fileUploadNode.metaData.helpLinkUri).toBe('https://docs.dify.ai/use-dify/nodes/upload-file-to-sandbox')
})
it('should enable sandbox behavior when runtime type is sandboxed', () => {
mockRuntimeType = 'sandboxed'
const { result } = renderHook(() => useAvailableNodesMetaData())
const nodeTypes = result.current.nodes.map(node => node.metaData.type)
expect(nodeTypes).toContain(BlockEnum.Command)
expect(nodeTypes).toContain(BlockEnum.FileUpload)
expect(nodeTypes).not.toContain(BlockEnum.Agent)
})
it('should map variable assigner to variable aggregator metadata', () => {
const { result } = renderHook(() => useAvailableNodesMetaData())
expect(result.current.nodesMap[BlockEnum.VariableAggregator]).toBeDefined()
expect(result.current.nodesMap[BlockEnum.VariableAssigner]).toBe(result.current.nodesMap[BlockEnum.VariableAggregator])
})
})

View File

@ -6,6 +6,12 @@ import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useFeatures } from '@/app/components/base/features/hooks'
import { WORKFLOW_COMMON_NODES } from '@/app/components/workflow/constants/node'
import {
buildNodeSelectorAvailabilityContext,
filterNodesForSelector,
NodeSelectorSandboxMode,
NodeSelectorScene,
} from '@/app/components/workflow/constants/node-availability'
import AnswerDefault from '@/app/components/workflow/nodes/answer/default'
import EndDefault from '@/app/components/workflow/nodes/end/default'
import StartDefault from '@/app/components/workflow/nodes/start/default'
@ -20,12 +26,30 @@ const NODE_HELP_LINK_OVERRIDES: Partial<Record<BlockEnum, string>> = {
[BlockEnum.FileUpload]: 'upload-file-to-sandbox',
}
type WorkflowAppScene = NodeSelectorScene.Workflow | NodeSelectorScene.Chatflow
const WORKFLOW_APP_SCENE_NODES = {
[NodeSelectorScene.Workflow]: [
EndDefault,
TriggerWebhookDefault,
TriggerScheduleDefault,
TriggerPluginDefault,
],
[NodeSelectorScene.Chatflow]: [AnswerDefault],
} as const
export const useAvailableNodesMetaData = () => {
const { t } = useTranslation()
const isChatMode = useIsChatMode()
const isSandboxFeatureEnabled = useFeatures(s => s.features.sandbox?.enabled) ?? false
const isSandboxRuntime = useAppStore(s => s.appDetail?.runtime_type === 'sandboxed')
const isSandboxed = isSandboxFeatureEnabled || isSandboxRuntime
const scene: WorkflowAppScene = isChatMode ? NodeSelectorScene.Chatflow : NodeSelectorScene.Workflow
const nodeAvailabilityContext = useMemo(() => buildNodeSelectorAvailabilityContext({
scene,
isSandboxRuntime,
isSandboxFeatureEnabled,
}), [scene, isSandboxFeatureEnabled, isSandboxRuntime])
const isSandboxed = nodeAvailabilityContext.sandboxMode === NodeSelectorSandboxMode.Enabled
const docLink = useDocLink()
const startNodeMetaData = useMemo(() => ({
@ -36,22 +60,15 @@ export const useAvailableNodesMetaData = () => {
},
}), [isChatMode])
const availableWorkflowCommonNodes = useMemo(() => {
return filterNodesForSelector(WORKFLOW_COMMON_NODES, nodeAvailabilityContext)
}, [nodeAvailabilityContext])
const mergedNodesMetaData = useMemo(() => [
...(isSandboxed
? WORKFLOW_COMMON_NODES.filter(node => node.metaData.type !== BlockEnum.Agent)
: WORKFLOW_COMMON_NODES),
...availableWorkflowCommonNodes,
startNodeMetaData,
...(
isChatMode
? [AnswerDefault]
: [
EndDefault,
TriggerWebhookDefault,
TriggerScheduleDefault,
TriggerPluginDefault,
]
),
] as AvailableNodesMetaData['nodes'], [isChatMode, isSandboxed, startNodeMetaData])
...WORKFLOW_APP_SCENE_NODES[scene],
] as AvailableNodesMetaData['nodes'], [availableWorkflowCommonNodes, scene, startNodeMetaData])
const getHelpLinkSlug = useCallback((nodeType: BlockEnum, helpLinkUri?: string) => {
if (isSandboxed && nodeType === BlockEnum.LLM)

View File

@ -0,0 +1,142 @@
import { describe, expect, it } from 'vitest'
import { BlockEnum } from '@/app/components/workflow/types'
import {
buildNodeSelectorAvailabilityContext,
filterNodesForSelector,
isNodeAvailableInSelector,
NodeSelectorSandboxMode,
NodeSelectorScene,
} from './node-availability'
type MockNode = {
metaData: {
type: BlockEnum
}
}
describe('node-availability', () => {
it('should hide command and file-upload when sandbox is disabled', () => {
const mockNodes: MockNode[] = [
{ metaData: { type: BlockEnum.Start } },
{ metaData: { type: BlockEnum.Command } },
{ metaData: { type: BlockEnum.FileUpload } },
]
const result = filterNodesForSelector(mockNodes, {
scene: NodeSelectorScene.Workflow,
sandboxMode: NodeSelectorSandboxMode.Disabled,
})
const nodeTypes = result.map(node => node.metaData.type)
expect(nodeTypes).toEqual([BlockEnum.Start])
})
it('should keep command and file-upload when sandbox is enabled', () => {
const mockNodes: MockNode[] = [
{ metaData: { type: BlockEnum.Start } },
{ metaData: { type: BlockEnum.Command } },
{ metaData: { type: BlockEnum.FileUpload } },
]
const result = filterNodesForSelector(mockNodes, {
scene: NodeSelectorScene.Workflow,
sandboxMode: NodeSelectorSandboxMode.Enabled,
})
const nodeTypes = result.map(node => node.metaData.type)
expect(nodeTypes).toEqual([BlockEnum.Start, BlockEnum.Command, BlockEnum.FileUpload])
})
it('should return original reference when no filtering is needed', () => {
const mockNodes: MockNode[] = [
{ metaData: { type: BlockEnum.Start } },
{ metaData: { type: BlockEnum.End } },
]
const result = filterNodesForSelector(mockNodes, {
scene: NodeSelectorScene.Workflow,
sandboxMode: NodeSelectorSandboxMode.Disabled,
})
expect(result).toBe(mockNodes)
})
it('should mark command and file-upload as sandbox-only', () => {
expect(isNodeAvailableInSelector(BlockEnum.Command, {
scene: NodeSelectorScene.Workflow,
sandboxMode: NodeSelectorSandboxMode.Disabled,
})).toBe(false)
expect(isNodeAvailableInSelector(BlockEnum.FileUpload, {
scene: NodeSelectorScene.Workflow,
sandboxMode: NodeSelectorSandboxMode.Disabled,
})).toBe(false)
expect(isNodeAvailableInSelector(BlockEnum.Start, {
scene: NodeSelectorScene.Workflow,
sandboxMode: NodeSelectorSandboxMode.Disabled,
})).toBe(true)
})
it('should hide agent when sandbox is enabled', () => {
expect(isNodeAvailableInSelector(BlockEnum.Agent, {
scene: NodeSelectorScene.Workflow,
sandboxMode: NodeSelectorSandboxMode.Enabled,
})).toBe(false)
expect(isNodeAvailableInSelector(BlockEnum.Agent, {
scene: NodeSelectorScene.Workflow,
sandboxMode: NodeSelectorSandboxMode.Disabled,
})).toBe(true)
})
it('should hide human-input in rag pipeline flow', () => {
expect(isNodeAvailableInSelector(BlockEnum.HumanInput, {
scene: NodeSelectorScene.RagPipeline,
sandboxMode: NodeSelectorSandboxMode.Enabled,
})).toBe(false)
expect(isNodeAvailableInSelector(BlockEnum.HumanInput, {
scene: NodeSelectorScene.RagPipeline,
sandboxMode: NodeSelectorSandboxMode.Disabled,
})).toBe(false)
expect(isNodeAvailableInSelector(BlockEnum.HumanInput, {
scene: NodeSelectorScene.Workflow,
sandboxMode: NodeSelectorSandboxMode.Disabled,
})).toBe(true)
})
it('should build unsupported sandbox mode for scenes that do not support sandbox', () => {
const context = buildNodeSelectorAvailabilityContext({
scene: NodeSelectorScene.RagPipeline,
isSandboxRuntime: true,
isSandboxFeatureEnabled: true,
})
expect(context.scene).toBe(NodeSelectorScene.RagPipeline)
expect(context.sandboxMode).toBe(NodeSelectorSandboxMode.Unsupported)
})
it('should allow explicit scene sandbox support override', () => {
const context = buildNodeSelectorAvailabilityContext({
scene: NodeSelectorScene.RagPipeline,
supportsSandbox: true,
isSandboxRuntime: true,
isSandboxFeatureEnabled: false,
})
expect(context.sandboxMode).toBe(NodeSelectorSandboxMode.Enabled)
})
it('should build enabled sandbox mode when runtime or feature enables sandbox', () => {
const contextByRuntime = buildNodeSelectorAvailabilityContext({
scene: NodeSelectorScene.Workflow,
isSandboxRuntime: true,
isSandboxFeatureEnabled: false,
})
const contextByFeature = buildNodeSelectorAvailabilityContext({
scene: NodeSelectorScene.Workflow,
isSandboxRuntime: false,
isSandboxFeatureEnabled: true,
})
expect(contextByRuntime.sandboxMode).toBe(NodeSelectorSandboxMode.Enabled)
expect(contextByFeature.sandboxMode).toBe(NodeSelectorSandboxMode.Enabled)
})
})

View File

@ -0,0 +1,115 @@
import { BlockEnum } from '@/app/components/workflow/types'
export enum NodeSelectorScene {
Workflow = 'workflow',
Chatflow = 'chatflow',
RagPipeline = 'rag-pipeline',
Subgraph = 'subgraph',
}
export enum NodeSelectorSandboxMode {
Enabled = 'enabled',
Disabled = 'disabled',
Unsupported = 'unsupported',
}
export const NODE_SELECTOR_SCENE_SUPPORTS_SANDBOX: Record<NodeSelectorScene, boolean> = {
[NodeSelectorScene.Workflow]: true,
[NodeSelectorScene.Chatflow]: true,
[NodeSelectorScene.RagPipeline]: false,
[NodeSelectorScene.Subgraph]: true,
}
type NodeAvailabilityRule = {
sandboxOnly?: boolean
hiddenWhenSandboxEnabled?: boolean
hiddenInScenes?: NodeSelectorScene[]
}
export const NODE_SELECTOR_AVAILABILITY_RULES: Partial<Record<BlockEnum, NodeAvailabilityRule>> = {
[BlockEnum.Command]: { sandboxOnly: true },
[BlockEnum.FileUpload]: { sandboxOnly: true },
[BlockEnum.Agent]: { hiddenWhenSandboxEnabled: true },
[BlockEnum.HumanInput]: { hiddenInScenes: [NodeSelectorScene.RagPipeline] },
}
export type NodeSelectorAvailabilityContext = {
scene: NodeSelectorScene
sandboxMode: NodeSelectorSandboxMode
}
type BuildNodeSelectorAvailabilityContextProps = {
scene: NodeSelectorScene
isSandboxRuntime?: boolean
isSandboxFeatureEnabled?: boolean
supportsSandbox?: boolean
}
const resolveSandboxMode = ({
scene,
isSandboxRuntime = false,
isSandboxFeatureEnabled = false,
supportsSandbox = NODE_SELECTOR_SCENE_SUPPORTS_SANDBOX[scene],
}: BuildNodeSelectorAvailabilityContextProps): NodeSelectorSandboxMode => {
if (!supportsSandbox)
return NodeSelectorSandboxMode.Unsupported
return (isSandboxRuntime || isSandboxFeatureEnabled)
? NodeSelectorSandboxMode.Enabled
: NodeSelectorSandboxMode.Disabled
}
export const buildNodeSelectorAvailabilityContext = ({
scene,
isSandboxRuntime,
isSandboxFeatureEnabled,
supportsSandbox,
}: BuildNodeSelectorAvailabilityContextProps): NodeSelectorAvailabilityContext => {
return {
scene,
sandboxMode: resolveSandboxMode({
scene,
isSandboxRuntime,
isSandboxFeatureEnabled,
supportsSandbox,
}),
}
}
export const isNodeAvailableInSelector = (
nodeType: BlockEnum,
{ scene, sandboxMode }: NodeSelectorAvailabilityContext,
) => {
const rule = NODE_SELECTOR_AVAILABILITY_RULES[nodeType]
if (!rule)
return true
const sandboxEnabled = sandboxMode === NodeSelectorSandboxMode.Enabled
if (rule.sandboxOnly && !sandboxEnabled)
return false
if (rule.hiddenWhenSandboxEnabled && sandboxEnabled)
return false
if (rule.hiddenInScenes?.includes(scene))
return false
return true
}
type NodeLike = {
metaData: {
type: BlockEnum
}
}
export const filterNodesForSelector = <T extends NodeLike>(
nodes: T[],
context: NodeSelectorAvailabilityContext,
) => {
const filteredNodes = nodes.filter(node => isNodeAvailableInSelector(node.metaData.type, context))
if (filteredNodes.length === nodes.length)
return nodes
return filteredNodes
}