diff --git a/packages/frontend/editor-ui/src/__tests__/mocks.ts b/packages/frontend/editor-ui/src/__tests__/mocks.ts index 8ea52b974f0..641db884f82 100644 --- a/packages/frontend/editor-ui/src/__tests__/mocks.ts +++ b/packages/frontend/editor-ui/src/__tests__/mocks.ts @@ -30,6 +30,7 @@ import { EXECUTABLE_TRIGGER_NODE_TYPES, MANUAL_TRIGGER_NODE_TYPE, NO_OP_NODE_TYPE, + OPEN_AI_CHAT_MODEL_NODE_TYPE, SET_NODE_TYPE, SIMULATE_NODE_TYPE, STICKY_NODE_TYPE, @@ -121,6 +122,7 @@ export const mockNodes = [ mockNode({ name: 'Chat Trigger', type: CHAT_TRIGGER_NODE_TYPE }), mockNode({ name: 'Form Trigger', type: FORM_TRIGGER_NODE_TYPE }), mockNode({ name: 'Agent', type: AGENT_NODE_TYPE }), + mockNode({ name: 'OpenAI Model', type: OPEN_AI_CHAT_MODEL_NODE_TYPE }), mockNode({ name: 'Sticky', type: STICKY_NODE_TYPE }), mockNode({ name: 'Simulate', type: SIMULATE_NODE_TYPE }), mockNode({ name: CanvasNodeRenderType.AddNodes, type: CanvasNodeRenderType.AddNodes }), diff --git a/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.test.ts b/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.test.ts index 536b8d2f101..cde017564a9 100644 --- a/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.test.ts +++ b/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.test.ts @@ -39,6 +39,7 @@ import { AGENT_NODE_TYPE, EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE, FORM_TRIGGER_NODE_TYPE, + OPEN_AI_CHAT_MODEL_NODE_TYPE, SET_NODE_TYPE, STICKY_NODE_TYPE, VIEWS, @@ -2874,6 +2875,134 @@ describe('useCanvasOperations', () => { expect(workflowsStore.removeConnection).not.toHaveBeenCalled(); }); + + it('should remove connections if the input port index is no longer valid for the type', async () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const nodeTypesStore = mockedStore(useNodeTypesStore); + + workflowsStore.removeConnection = vi.fn(); + + const targetNodeId = 'target'; + const targetNode = createTestNode({ + id: targetNodeId, + name: 'Target Node', + type: AGENT_NODE_TYPE, + }); + const targetNodeType = mockNodeTypeDescription({ + name: AGENT_NODE_TYPE, + inputs: [NodeConnectionTypes.Main, NodeConnectionTypes.AiLanguageModel], + }); + + const sourceNodeId = 'source'; + const sourceNode = createTestNode({ + id: sourceNodeId, + name: 'Source Node', + type: OPEN_AI_CHAT_MODEL_NODE_TYPE, + }); + const sourceNodeType = mockNodeTypeDescription({ + name: OPEN_AI_CHAT_MODEL_NODE_TYPE, + outputs: [NodeConnectionTypes.AiLanguageModel], + }); + + workflowsStore.workflow.nodes = [sourceNode, targetNode]; + workflowsStore.workflow.connections = { + [sourceNode.name]: { + [NodeConnectionTypes.AiLanguageModel]: [ + [{ node: targetNode.name, type: NodeConnectionTypes.AiLanguageModel, index: 1 }], + ], + }, + }; + + workflowsStore.getNodeById.mockImplementation((id) => { + if (id === sourceNodeId) return sourceNode; + if (id === targetNodeId) return targetNode; + return undefined; + }); + + nodeTypesStore.getNodeType = vi + .fn() + .mockReturnValueOnce(targetNodeType) + .mockReturnValueOnce(sourceNodeType); + + const workflowObject = createTestWorkflowObject(workflowsStore.workflow); + workflowsStore.workflowObject = workflowObject; + + const { revalidateNodeInputConnections } = useCanvasOperations(); + revalidateNodeInputConnections(targetNodeId); + + await nextTick(); + + expect(workflowsStore.removeConnection).toHaveBeenCalledWith({ + connection: [ + { node: sourceNode.name, type: NodeConnectionTypes.AiLanguageModel, index: 0 }, + { node: targetNode.name, type: NodeConnectionTypes.AiLanguageModel, index: 1 }, + ], + }); + }); + + it('should keep connections if the input port index is still valid for the type', async () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const nodeTypesStore = mockedStore(useNodeTypesStore); + + workflowsStore.removeConnection = vi.fn(); + + const targetNodeId = 'target'; + const targetNode = createTestNode({ + id: targetNodeId, + name: 'Target Node', + type: AGENT_NODE_TYPE, + }); + const targetNodeType = mockNodeTypeDescription({ + name: AGENT_NODE_TYPE, + inputs: [ + NodeConnectionTypes.Main, + NodeConnectionTypes.AiLanguageModel, + NodeConnectionTypes.AiLanguageModel, + ], + }); + + const sourceNodeId = 'source'; + const sourceNode = createTestNode({ + id: sourceNodeId, + name: 'Source Node', + type: OPEN_AI_CHAT_MODEL_NODE_TYPE, + }); + const sourceNodeType = mockNodeTypeDescription({ + name: OPEN_AI_CHAT_MODEL_NODE_TYPE, + outputs: [NodeConnectionTypes.AiLanguageModel], + }); + + workflowsStore.workflow.nodes = [sourceNode, targetNode]; + workflowsStore.workflow.connections = { + [sourceNode.name]: { + [NodeConnectionTypes.AiLanguageModel]: [ + [{ node: targetNode.name, type: NodeConnectionTypes.AiLanguageModel, index: 1 }], + ], + }, + }; + + workflowsStore.getNodeById.mockImplementation((id) => { + if (id === sourceNodeId) return sourceNode; + if (id === targetNodeId) return targetNode; + return undefined; + }); + + nodeTypesStore.getNodeType = vi.fn().mockImplementation((type) => { + if (type === AGENT_NODE_TYPE) return targetNodeType; + if (type === OPEN_AI_CHAT_MODEL_NODE_TYPE) return sourceNodeType; + return undefined; + }); + + const workflowObject = createTestWorkflowObject(workflowsStore.workflow); + workflowsStore.workflowObject = workflowObject; + + const { revalidateNodeInputConnections } = useCanvasOperations(); + revalidateNodeInputConnections(targetNodeId); + + await nextTick(); + + expect(workflowsStore.removeConnection).not.toHaveBeenCalled(); + }); }); describe('revalidateNodeOutputConnections', () => { diff --git a/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.ts b/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.ts index 13508e32e9a..2efdb0c3b31 100644 --- a/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.ts +++ b/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.ts @@ -1589,6 +1589,25 @@ export function useCanvasOperations() { ); }; + const filterConnectionsByType = ( + connections: Array, + type: NodeConnectionType, + ) => + connections.filter((connection) => { + const connectionType = typeof connection === 'string' ? connection : connection.type; + return connectionType === type; + }); + + const getInputFilter = ( + connection?: NodeConnectionType | INodeInputConfiguration | INodeOutputConfiguration, + ) => { + if (connection && typeof connection === 'object' && 'filter' in connection) { + return connection.filter; + } + + return undefined; + }; + if (sourceConnection.type !== targetConnection.type) { return false; } @@ -1613,13 +1632,11 @@ export function useCanvasOperations() { ) || []; } - const sourceNodeHasOutputConnectionOfType = !!sourceNodeOutputs.find((output) => { - const outputType = typeof output === 'string' ? output : output.type; - return outputType === sourceConnection.type; - }); + const sourceOutputsOfType = filterConnectionsByType(sourceNodeOutputs, sourceConnection.type); + const sourceNodeHasOutputConnectionOfType = sourceOutputsOfType.length > 0; const sourceNodeHasOutputConnectionPortOfType = - sourceConnection.index < sourceNodeOutputs.length; + sourceConnection.index < sourceOutputsOfType.length; const isMissingOutputConnection = !sourceNodeHasOutputConnectionOfType || !sourceNodeHasOutputConnectionPortOfType; @@ -1644,31 +1661,33 @@ export function useCanvasOperations() { ) || []; } - const targetNodeHasInputConnectionOfType = !!targetNodeInputs.find((input) => { - const inputType = typeof input === 'string' ? input : input.type; - if (inputType !== targetConnection.type) return false; + const targetInputsOfType = filterConnectionsByType(targetNodeInputs, targetConnection.type); + const targetNodeHasInputConnectionOfType = targetInputsOfType.length > 0; + const targetNodeHasInputConnectionPortOfType = + targetConnection.index < targetInputsOfType.length; - const filter = typeof input === 'object' && 'filter' in input ? input.filter : undefined; - if ( - (filter?.nodes?.length && !filter.nodes?.includes(sourceNode.type)) || - (filter?.excludedNodes?.length && filter.excludedNodes?.includes(sourceNode.type)) - ) { - toast.showToast({ - title: i18n.baseText('nodeView.showError.nodeNodeCompatible.title'), - message: i18n.baseText('nodeView.showError.nodeNodeCompatible.message', { - interpolate: { sourceNodeName: sourceNode.name, targetNodeName: targetNode.name }, - }), - type: 'error', - duration: 5000, - }); + const targetConnectionDefinition = targetNodeHasInputConnectionPortOfType + ? targetInputsOfType[targetConnection.index] + : undefined; + const targetConnectionFilter = getInputFilter(targetConnectionDefinition); - return false; - } + if ( + (targetConnectionFilter?.nodes?.length && + !targetConnectionFilter.nodes.includes(sourceNode.type)) || + (targetConnectionFilter?.excludedNodes?.length && + targetConnectionFilter.excludedNodes.includes(sourceNode.type)) + ) { + toast.showToast({ + title: i18n.baseText('nodeView.showError.nodeNodeCompatible.title'), + message: i18n.baseText('nodeView.showError.nodeNodeCompatible.message', { + interpolate: { sourceNodeName: sourceNode.name, targetNodeName: targetNode.name }, + }), + type: 'error', + duration: 5000, + }); - return true; - }); - - const targetNodeHasInputConnectionPortOfType = targetConnection.index < targetNodeInputs.length; + return false; + } const isMissingInputConnection = !targetNodeHasInputConnectionOfType || !targetNodeHasInputConnectionPortOfType; diff --git a/packages/frontend/editor-ui/src/app/constants/nodeTypes.ts b/packages/frontend/editor-ui/src/app/constants/nodeTypes.ts index 5c922e25506..3f03863ef41 100644 --- a/packages/frontend/editor-ui/src/app/constants/nodeTypes.ts +++ b/packages/frontend/editor-ui/src/app/constants/nodeTypes.ts @@ -36,6 +36,7 @@ export const MCP_TRIGGER_NODE_TYPE = '@n8n/n8n-nodes-langchain.mcpTrigger'; export const CHAT_TRIGGER_NODE_TYPE = '@n8n/n8n-nodes-langchain.chatTrigger'; export const CHAT_NODE_TYPE = '@n8n/n8n-nodes-langchain.chat'; export const AGENT_NODE_TYPE = '@n8n/n8n-nodes-langchain.agent'; +export const OPEN_AI_CHAT_MODEL_NODE_TYPE = '@n8n/n8n-nodes-langchain.lmChatOpenAi'; export const OPEN_AI_NODE_TYPE = '@n8n/n8n-nodes-langchain.openAi'; export const OPEN_AI_NODE_MESSAGE_ASSISTANT_TYPE = '@n8n/n8n-nodes-langchain.openAi.assistant.message';