From 086f6a2bb3581a19da8d3c2df9684575b7fa1afc Mon Sep 17 00:00:00 2001 From: yyh Date: Fri, 13 Feb 2026 12:16:24 +0800 Subject: [PATCH] feat(web): unify node selector availability rules --- .../rag-pipeline/hooks/index.spec.ts | 48 +++++ .../hooks/use-available-nodes-meta-data.ts | 13 +- .../components/sub-graph-main.spec.tsx | 130 ++++++++++++++ .../sub-graph/components/sub-graph-main.tsx | 2 +- .../use-available-nodes-meta-data.spec.ts | 110 ++++++++++++ .../hooks/use-available-nodes-meta-data.ts | 26 ++- .../use-available-nodes-meta-data.spec.ts | 168 ++++++++++++++++++ .../hooks/use-available-nodes-meta-data.ts | 47 +++-- .../constants/node-availability.spec.ts | 142 +++++++++++++++ .../workflow/constants/node-availability.ts | 115 ++++++++++++ 10 files changed, 779 insertions(+), 22 deletions(-) create mode 100644 web/app/components/sub-graph/components/sub-graph-main.spec.tsx create mode 100644 web/app/components/sub-graph/hooks/use-available-nodes-meta-data.spec.ts create mode 100644 web/app/components/workflow-app/hooks/use-available-nodes-meta-data.spec.ts create mode 100644 web/app/components/workflow/constants/node-availability.spec.ts create mode 100644 web/app/components/workflow/constants/node-availability.ts 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 +}