diff --git a/web/app/components/rag-pipeline/hooks/index.spec.ts b/web/app/components/rag-pipeline/hooks/index.spec.ts
index 47f3f4a163..244d92b835 100644
--- a/web/app/components/rag-pipeline/hooks/index.spec.ts
+++ b/web/app/components/rag-pipeline/hooks/index.spec.ts
@@ -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', () => {
diff --git a/web/app/components/rag-pipeline/hooks/use-available-nodes-meta-data.ts b/web/app/components/rag-pipeline/hooks/use-available-nodes-meta-data.ts
index cb2f72603a..3e62d76021 100644
--- a/web/app/components/rag-pipeline/hooks/use-available-nodes-meta-data.ts
+++ b/web/app/components/rag-pipeline/hooks/use-available-nodes-meta-data.ts
@@ -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',
diff --git a/web/app/components/sub-graph/components/sub-graph-main.spec.tsx b/web/app/components/sub-graph/components/sub-graph-main.spec.tsx
new file mode 100644
index 0000000000..5629f931b3
--- /dev/null
+++ b/web/app/components/sub-graph/components/sub-graph-main.spec.tsx
@@ -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 }) => (
+
{children}
+ ),
+}))
+
+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: () => ,
+}))
+
+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(
+ ,
+ )
+
+ 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(
+ ,
+ )
+
+ expect(mockUseAvailableNodesMetaData).toHaveBeenCalledWith(FlowType.appFlow)
+ expect(mockUseSetWorkflowVarsWithValue).toHaveBeenCalledWith({
+ flowType: FlowType.appFlow,
+ flowId: '',
+ interactionMode: 'subgraph',
+ })
+ })
+})
diff --git a/web/app/components/sub-graph/components/sub-graph-main.tsx b/web/app/components/sub-graph/components/sub-graph-main.tsx
index 7f874f8151..376aef8021 100644
--- a/web/app/components/sub-graph/components/sub-graph-main.tsx
+++ b/web/app/components/sub-graph/components/sub-graph-main.tsx
@@ -62,9 +62,9 @@ const SubGraphMain: FC = (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,
diff --git a/web/app/components/sub-graph/hooks/use-available-nodes-meta-data.spec.ts b/web/app/components/sub-graph/hooks/use-available-nodes-meta-data.spec.ts
new file mode 100644
index 0000000000..555ee9a0fb
--- /dev/null
+++ b/web/app/components/sub-graph/hooks/use-available-nodes-meta-data.spec.ts
@@ -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])
+ })
+})
diff --git a/web/app/components/sub-graph/hooks/use-available-nodes-meta-data.ts b/web/app/components/sub-graph/hooks/use-available-nodes-meta-data.ts
index 195c099c5a..6622b4aa17 100644
--- a/web/app/components/sub-graph/hooks/use-available-nodes-meta-data.ts
+++ b/web/app/components/sub-graph/hooks/use-available-nodes-meta-data.ts
@@ -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(() => {
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
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
diff --git a/web/app/components/workflow-app/hooks/use-available-nodes-meta-data.spec.ts b/web/app/components/workflow-app/hooks/use-available-nodes-meta-data.spec.ts
new file mode 100644
index 0000000000..bbe1e2e93e
--- /dev/null
+++ b/web/app/components/workflow-app/hooks/use-available-nodes-meta-data.spec.ts
@@ -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])
+ })
+})
diff --git a/web/app/components/workflow-app/hooks/use-available-nodes-meta-data.ts b/web/app/components/workflow-app/hooks/use-available-nodes-meta-data.ts
index c7ca27331d..143005da6f 100644
--- a/web/app/components/workflow-app/hooks/use-available-nodes-meta-data.ts
+++ b/web/app/components/workflow-app/hooks/use-available-nodes-meta-data.ts
@@ -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> = {
[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)
diff --git a/web/app/components/workflow/constants/node-availability.spec.ts b/web/app/components/workflow/constants/node-availability.spec.ts
new file mode 100644
index 0000000000..151c40ef3e
--- /dev/null
+++ b/web/app/components/workflow/constants/node-availability.spec.ts
@@ -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)
+ })
+})
diff --git a/web/app/components/workflow/constants/node-availability.ts b/web/app/components/workflow/constants/node-availability.ts
new file mode 100644
index 0000000000..f679eb5ce4
--- /dev/null
+++ b/web/app/components/workflow/constants/node-availability.ts
@@ -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.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> = {
+ [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 = (
+ nodes: T[],
+ context: NodeSelectorAvailabilityContext,
+) => {
+ const filteredNodes = nodes.filter(node => isNodeAvailableInSelector(node.metaData.type, context))
+ if (filteredNodes.length === nodes.length)
+ return nodes
+
+ return filteredNodes
+}