mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
feat: Add model selector node (#16371)
This commit is contained in:
@@ -565,18 +565,20 @@ describe('NDV', () => {
|
|||||||
{
|
{
|
||||||
title: 'Language Models',
|
title: 'Language Models',
|
||||||
id: 'ai_languageModel',
|
id: 'ai_languageModel',
|
||||||
|
index: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Tools',
|
title: 'Tools',
|
||||||
id: 'ai_tool',
|
id: 'ai_tool',
|
||||||
|
index: 0,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
workflowPage.actions.addInitialNodeToCanvas('AI Agent', { keepNdvOpen: true });
|
workflowPage.actions.addInitialNodeToCanvas('AI Agent', { keepNdvOpen: true });
|
||||||
|
|
||||||
connectionGroups.forEach((group) => {
|
connectionGroups.forEach((group) => {
|
||||||
cy.getByTestId(`add-subnode-${group.id}`).should('exist');
|
cy.getByTestId(`add-subnode-${group.id}-${group.index}`).should('exist');
|
||||||
cy.getByTestId(`add-subnode-${group.id}`).click();
|
cy.getByTestId(`add-subnode-${group.id}-${group.index}`).click();
|
||||||
|
|
||||||
cy.getByTestId('nodes-list-header').contains(group.title).should('exist');
|
cy.getByTestId('nodes-list-header').contains(group.title).should('exist');
|
||||||
// Add HTTP Request tool
|
// Add HTTP Request tool
|
||||||
@@ -585,16 +587,16 @@ describe('NDV', () => {
|
|||||||
getFloatingNodeByPosition('outputSub').click({ force: true });
|
getFloatingNodeByPosition('outputSub').click({ force: true });
|
||||||
|
|
||||||
if (group.id === 'ai_languageModel') {
|
if (group.id === 'ai_languageModel') {
|
||||||
cy.getByTestId(`add-subnode-${group.id}`).should('not.exist');
|
cy.getByTestId(`add-subnode-${group.id}-${group.index}`).should('not.exist');
|
||||||
} else {
|
} else {
|
||||||
cy.getByTestId(`add-subnode-${group.id}`).should('exist');
|
cy.getByTestId(`add-subnode-${group.id}-${group.index}`).should('exist');
|
||||||
// Expand the subgroup
|
// Expand the subgroup
|
||||||
cy.getByTestId('subnode-connection-group-ai_tool').click();
|
cy.getByTestId('subnode-connection-group-ai_tool-0').click();
|
||||||
cy.getByTestId(`add-subnode-${group.id}`).click();
|
cy.getByTestId(`add-subnode-${group.id}-${group.index}`).click();
|
||||||
// Add HTTP Request tool
|
// Add HTTP Request tool
|
||||||
nodeCreator.getters.getNthCreatorItem(2).click();
|
nodeCreator.getters.getNthCreatorItem(2).click();
|
||||||
getFloatingNodeByPosition('outputSub').click({ force: true });
|
getFloatingNodeByPosition('outputSub').click({ force: true });
|
||||||
cy.getByTestId('subnode-connection-group-ai_tool')
|
cy.getByTestId('subnode-connection-group-ai_tool-0')
|
||||||
.findChildByTestId('floating-subnode')
|
.findChildByTestId('floating-subnode')
|
||||||
.should('have.length', 2);
|
.should('have.length', 2);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
/* eslint-disable n8n-nodes-base/node-param-description-wrong-for-dynamic-options */
|
||||||
|
/* eslint-disable n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options */
|
||||||
|
import type { BaseCallbackHandler, CallbackHandlerMethods } from '@langchain/core/callbacks/base';
|
||||||
|
import type { Callbacks } from '@langchain/core/callbacks/manager';
|
||||||
|
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||||
|
import {
|
||||||
|
NodeConnectionTypes,
|
||||||
|
type INodeType,
|
||||||
|
type INodeTypeDescription,
|
||||||
|
type ISupplyDataFunctions,
|
||||||
|
type SupplyData,
|
||||||
|
type ILoadOptionsFunctions,
|
||||||
|
NodeOperationError,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
|
import { numberInputsProperty, configuredInputs } from './helpers';
|
||||||
|
import { N8nLlmTracing } from '../llms/N8nLlmTracing';
|
||||||
|
import { N8nNonEstimatingTracing } from '../llms/N8nNonEstimatingTracing';
|
||||||
|
|
||||||
|
interface ModeleSelectionRule {
|
||||||
|
modelIndex: number;
|
||||||
|
conditions: {
|
||||||
|
options: {
|
||||||
|
caseSensitive: boolean;
|
||||||
|
typeValidation: 'strict' | 'loose';
|
||||||
|
leftValue: string;
|
||||||
|
version: 1 | 2;
|
||||||
|
};
|
||||||
|
conditions: Array<{
|
||||||
|
id: string;
|
||||||
|
leftValue: string;
|
||||||
|
rightValue: string;
|
||||||
|
operator: {
|
||||||
|
type: string;
|
||||||
|
operation: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
combinator: 'and' | 'or';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCallbacksArray(
|
||||||
|
callbacks: Callbacks | undefined,
|
||||||
|
): Array<BaseCallbackHandler | CallbackHandlerMethods> {
|
||||||
|
if (!callbacks) return [];
|
||||||
|
|
||||||
|
if (Array.isArray(callbacks)) {
|
||||||
|
return callbacks;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's a CallbackManager, extract its handlers
|
||||||
|
return callbacks.handlers || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ModelSelector implements INodeType {
|
||||||
|
description: INodeTypeDescription = {
|
||||||
|
displayName: 'Model Selector',
|
||||||
|
name: 'modelSelector',
|
||||||
|
icon: 'fa:map-signs',
|
||||||
|
iconColor: 'green',
|
||||||
|
defaults: {
|
||||||
|
name: 'Model Selector',
|
||||||
|
},
|
||||||
|
version: 1,
|
||||||
|
group: ['transform'],
|
||||||
|
description:
|
||||||
|
'Use this node to select one of the connected models to this node based on workflow data',
|
||||||
|
inputs: `={{
|
||||||
|
((parameters) => {
|
||||||
|
${configuredInputs.toString()};
|
||||||
|
return configuredInputs(parameters)
|
||||||
|
})($parameter)
|
||||||
|
}}`,
|
||||||
|
outputs: [NodeConnectionTypes.AiLanguageModel],
|
||||||
|
requiredInputs: 1,
|
||||||
|
properties: [
|
||||||
|
numberInputsProperty,
|
||||||
|
{
|
||||||
|
displayName: 'Rules',
|
||||||
|
name: 'rules',
|
||||||
|
placeholder: 'Add Rule',
|
||||||
|
type: 'fixedCollection',
|
||||||
|
typeOptions: {
|
||||||
|
multipleValues: true,
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
description: 'Rules to map workflow data to specific models',
|
||||||
|
default: {},
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
displayName: 'Rule',
|
||||||
|
name: 'rule',
|
||||||
|
values: [
|
||||||
|
{
|
||||||
|
displayName: 'Model',
|
||||||
|
name: 'modelIndex',
|
||||||
|
type: 'options',
|
||||||
|
description: 'Choose model input from the list',
|
||||||
|
default: 1,
|
||||||
|
required: true,
|
||||||
|
placeholder: 'Choose model input from the list',
|
||||||
|
typeOptions: {
|
||||||
|
loadOptionsMethod: 'getModels',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Conditions',
|
||||||
|
name: 'conditions',
|
||||||
|
placeholder: 'Add Condition',
|
||||||
|
type: 'filter',
|
||||||
|
default: {},
|
||||||
|
typeOptions: {
|
||||||
|
filter: {
|
||||||
|
caseSensitive: true,
|
||||||
|
typeValidation: 'strict',
|
||||||
|
version: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
description: 'Conditions that must be met to select this model',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
methods = {
|
||||||
|
loadOptions: {
|
||||||
|
async getModels(this: ILoadOptionsFunctions) {
|
||||||
|
const numberInputs = this.getCurrentNodeParameter('numberInputs') as number;
|
||||||
|
|
||||||
|
return Array.from({ length: numberInputs ?? 2 }, (_, i) => ({
|
||||||
|
value: i + 1,
|
||||||
|
name: `Model ${(i + 1).toString()}`,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
|
||||||
|
const models = (await this.getInputConnectionData(
|
||||||
|
NodeConnectionTypes.AiLanguageModel,
|
||||||
|
itemIndex,
|
||||||
|
)) as unknown[];
|
||||||
|
|
||||||
|
if (!models || models.length === 0) {
|
||||||
|
throw new NodeOperationError(this.getNode(), 'No models connected', {
|
||||||
|
itemIndex,
|
||||||
|
description: 'No models found in input connections',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
models.reverse();
|
||||||
|
|
||||||
|
const rules = this.getNodeParameter('rules.rule', itemIndex, []) as ModeleSelectionRule[];
|
||||||
|
|
||||||
|
if (!rules || rules.length === 0) {
|
||||||
|
throw new NodeOperationError(this.getNode(), 'No rules defined', {
|
||||||
|
itemIndex,
|
||||||
|
description: 'At least one rule must be defined to select a model',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < rules.length; i++) {
|
||||||
|
const rule = rules[i];
|
||||||
|
const modelIndex = rule.modelIndex;
|
||||||
|
|
||||||
|
if (modelIndex <= 0 || modelIndex > models.length) {
|
||||||
|
throw new NodeOperationError(this.getNode(), `Invalid model index ${modelIndex}`, {
|
||||||
|
itemIndex,
|
||||||
|
description: `Model index must be between 1 and ${models.length}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const conditionsMet = this.getNodeParameter(`rules.rule[${i}].conditions`, itemIndex, false, {
|
||||||
|
extractValue: true,
|
||||||
|
}) as boolean;
|
||||||
|
|
||||||
|
if (conditionsMet) {
|
||||||
|
const selectedModel = models[modelIndex - 1] as BaseChatModel;
|
||||||
|
|
||||||
|
const originalCallbacks = getCallbacksArray(selectedModel.callbacks);
|
||||||
|
|
||||||
|
for (const currentCallback of originalCallbacks) {
|
||||||
|
if (currentCallback instanceof N8nLlmTracing) {
|
||||||
|
currentCallback.setParentRunIndex(this.getNextRunIndex());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const modelSelectorTracing = new N8nNonEstimatingTracing(this);
|
||||||
|
selectedModel.callbacks = [...originalCallbacks, modelSelectorTracing];
|
||||||
|
|
||||||
|
return {
|
||||||
|
response: selectedModel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new NodeOperationError(this.getNode(), 'No matching rule found', {
|
||||||
|
itemIndex,
|
||||||
|
description: 'None of the defined rules matched the workflow data',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
59
packages/@n8n/nodes-langchain/nodes/ModelSelector/helpers.ts
Normal file
59
packages/@n8n/nodes-langchain/nodes/ModelSelector/helpers.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import type { INodeInputConfiguration, INodeParameters, INodeProperties } from 'n8n-workflow';
|
||||||
|
|
||||||
|
export const numberInputsProperty: INodeProperties = {
|
||||||
|
displayName: 'Number of Inputs',
|
||||||
|
name: 'numberInputs',
|
||||||
|
type: 'options',
|
||||||
|
noDataExpression: true,
|
||||||
|
default: 2,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
name: '2',
|
||||||
|
value: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '3',
|
||||||
|
value: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '4',
|
||||||
|
value: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '5',
|
||||||
|
value: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '6',
|
||||||
|
value: 6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '7',
|
||||||
|
value: 7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '8',
|
||||||
|
value: 8,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '9',
|
||||||
|
value: 9,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '10',
|
||||||
|
value: 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
validateType: 'number',
|
||||||
|
description:
|
||||||
|
'The number of data inputs you want to merge. The node waits for all connected inputs to be executed.',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function configuredInputs(parameters: INodeParameters): INodeInputConfiguration[] {
|
||||||
|
return Array.from({ length: (parameters.numberInputs as number) || 2 }, (_, i) => ({
|
||||||
|
type: 'ai_languageModel',
|
||||||
|
displayName: `Model ${(i + 1).toString()}`,
|
||||||
|
required: true,
|
||||||
|
maxConnections: 1,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||||
|
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||||
|
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||||
|
import { mock } from 'jest-mock-extended';
|
||||||
|
import type { ISupplyDataFunctions, INode, ILoadOptionsFunctions } from 'n8n-workflow';
|
||||||
|
import { NodeOperationError, NodeConnectionTypes } from 'n8n-workflow';
|
||||||
|
|
||||||
|
import { ModelSelector } from '../ModelSelector.node';
|
||||||
|
|
||||||
|
// Mock the N8nLlmTracing module completely to avoid module resolution issues
|
||||||
|
jest.mock('../../llms/N8nLlmTracing', () => ({
|
||||||
|
N8nLlmTracing: jest.fn().mockImplementation(() => ({
|
||||||
|
handleLLMStart: jest.fn(),
|
||||||
|
handleLLMEnd: jest.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('ModelSelector Node', () => {
|
||||||
|
let node: ModelSelector;
|
||||||
|
let mockSupplyDataFunction: jest.Mocked<ISupplyDataFunctions>;
|
||||||
|
let mockLoadOptionsFunction: jest.Mocked<ILoadOptionsFunctions>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
node = new ModelSelector();
|
||||||
|
mockSupplyDataFunction = mock<ISupplyDataFunctions>();
|
||||||
|
mockLoadOptionsFunction = mock<ILoadOptionsFunctions>();
|
||||||
|
|
||||||
|
mockSupplyDataFunction.getNode.mockReturnValue({
|
||||||
|
name: 'Model Selector',
|
||||||
|
typeVersion: 1,
|
||||||
|
parameters: {},
|
||||||
|
} as INode);
|
||||||
|
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('description', () => {
|
||||||
|
it('should have the expected properties', () => {
|
||||||
|
expect(node.description).toBeDefined();
|
||||||
|
expect(node.description.name).toBe('modelSelector');
|
||||||
|
expect(node.description.displayName).toBe('Model Selector');
|
||||||
|
expect(node.description.version).toBe(1);
|
||||||
|
expect(node.description.group).toEqual(['transform']);
|
||||||
|
expect(node.description.outputs).toEqual([NodeConnectionTypes.AiLanguageModel]);
|
||||||
|
expect(node.description.requiredInputs).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have the correct properties defined', () => {
|
||||||
|
expect(node.description.properties).toHaveLength(2);
|
||||||
|
expect(node.description.properties[0].name).toBe('numberInputs');
|
||||||
|
expect(node.description.properties[1].name).toBe('rules');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loadOptions methods', () => {
|
||||||
|
describe('getModels', () => {
|
||||||
|
it('should return correct number of models based on numberInputs parameter', async () => {
|
||||||
|
mockLoadOptionsFunction.getCurrentNodeParameter.mockReturnValue(3);
|
||||||
|
|
||||||
|
const result = await node.methods.loadOptions.getModels.call(mockLoadOptionsFunction);
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ value: 1, name: 'Model 1' },
|
||||||
|
{ value: 2, name: 'Model 2' },
|
||||||
|
{ value: 3, name: 'Model 3' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should default to 2 models when numberInputs is undefined', async () => {
|
||||||
|
mockLoadOptionsFunction.getCurrentNodeParameter.mockReturnValue(undefined);
|
||||||
|
|
||||||
|
const result = await node.methods.loadOptions.getModels.call(mockLoadOptionsFunction);
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ value: 1, name: 'Model 1' },
|
||||||
|
{ value: 2, name: 'Model 2' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('supplyData', () => {
|
||||||
|
const mockModel1: Partial<BaseChatModel> = {
|
||||||
|
_llmType: () => 'fake-llm',
|
||||||
|
callbacks: [],
|
||||||
|
};
|
||||||
|
const mockModel2: Partial<BaseChatModel> = {
|
||||||
|
_llmType: () => 'fake-llm-2',
|
||||||
|
callbacks: undefined,
|
||||||
|
};
|
||||||
|
const mockModel3: Partial<BaseChatModel> = {
|
||||||
|
_llmType: () => 'fake-llm-3',
|
||||||
|
callbacks: [{ handleLLMStart: jest.fn() }],
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Note: models array gets reversed in supplyData, so [model1, model2, model3] becomes [model3, model2, model1]
|
||||||
|
mockSupplyDataFunction.getInputConnectionData.mockResolvedValue([
|
||||||
|
mockModel1,
|
||||||
|
mockModel2,
|
||||||
|
mockModel3,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when no models are connected', async () => {
|
||||||
|
mockSupplyDataFunction.getInputConnectionData.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await expect(node.supplyData.call(mockSupplyDataFunction, 0)).rejects.toThrow(
|
||||||
|
NodeOperationError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when no rules are defined', async () => {
|
||||||
|
mockSupplyDataFunction.getNodeParameter.mockReturnValue([]);
|
||||||
|
|
||||||
|
await expect(node.supplyData.call(mockSupplyDataFunction, 0)).rejects.toThrow(
|
||||||
|
NodeOperationError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the correct model when rule conditions are met', async () => {
|
||||||
|
const rules = [
|
||||||
|
{
|
||||||
|
modelIndex: '2',
|
||||||
|
conditions: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockSupplyDataFunction.getNodeParameter
|
||||||
|
.mockReturnValueOnce(rules) // rules.rule parameter
|
||||||
|
.mockReturnValueOnce(true); // conditions evaluation
|
||||||
|
|
||||||
|
const result = await node.supplyData.call(mockSupplyDataFunction, 0);
|
||||||
|
|
||||||
|
// After reverse: [model3, model2, model1], so index 2 (1-based) = model2
|
||||||
|
expect(result.response).toBe(mockModel2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add N8nLlmTracing callback to selected model', async () => {
|
||||||
|
const rules = [
|
||||||
|
{
|
||||||
|
modelIndex: '1',
|
||||||
|
conditions: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockSupplyDataFunction.getNodeParameter
|
||||||
|
.mockReturnValueOnce(rules) // rules.rule parameter
|
||||||
|
.mockReturnValueOnce(true); // conditions evaluation
|
||||||
|
|
||||||
|
const result = await node.supplyData.call(mockSupplyDataFunction, 0);
|
||||||
|
|
||||||
|
// After reverse: [model3, model2, model1], so index 1 (1-based) = model3
|
||||||
|
expect(result.response).toBe(mockModel3);
|
||||||
|
expect((result.response as BaseChatModel).callbacks).toHaveLength(2); // original + N8nLlmTracing
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle models with undefined callbacks', async () => {
|
||||||
|
const rules = [
|
||||||
|
{
|
||||||
|
modelIndex: '2',
|
||||||
|
conditions: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockSupplyDataFunction.getNodeParameter
|
||||||
|
.mockReturnValueOnce(rules) // rules.rule parameter
|
||||||
|
.mockReturnValueOnce(true); // conditions evaluation
|
||||||
|
|
||||||
|
const result = await node.supplyData.call(mockSupplyDataFunction, 0);
|
||||||
|
|
||||||
|
// After reverse: [model3, model2, model1], so index 2 (1-based) = model2
|
||||||
|
expect(result.response).toBe(mockModel2);
|
||||||
|
// Should have 1 callback added (N8nLlmTracing)
|
||||||
|
expect(Array.isArray((result.response as BaseChatModel).callbacks)).toBe(true);
|
||||||
|
expect((result.response as BaseChatModel).callbacks).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should evaluate multiple rules and return first matching model', async () => {
|
||||||
|
const rules = [
|
||||||
|
{
|
||||||
|
modelIndex: '1',
|
||||||
|
conditions: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
modelIndex: '3',
|
||||||
|
conditions: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockSupplyDataFunction.getNodeParameter
|
||||||
|
.mockReturnValueOnce(rules) // rules.rule parameter
|
||||||
|
.mockReturnValueOnce(false) // first rule conditions evaluation
|
||||||
|
.mockReturnValueOnce(true); // second rule conditions evaluation
|
||||||
|
|
||||||
|
const result = await node.supplyData.call(mockSupplyDataFunction, 0);
|
||||||
|
|
||||||
|
// After reverse: [model3, model2, model1], so index 3 (1-based) = model1
|
||||||
|
expect(result.response).toBe(mockModel1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when no rules match', async () => {
|
||||||
|
const rules = [
|
||||||
|
{
|
||||||
|
modelIndex: '1',
|
||||||
|
conditions: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
modelIndex: '2',
|
||||||
|
conditions: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockSupplyDataFunction.getNodeParameter
|
||||||
|
.mockReturnValueOnce(rules) // rules.rule parameter
|
||||||
|
.mockReturnValueOnce(false) // first rule conditions evaluation
|
||||||
|
.mockReturnValueOnce(false); // second rule conditions evaluation
|
||||||
|
|
||||||
|
await expect(node.supplyData.call(mockSupplyDataFunction, 0)).rejects.toThrow(
|
||||||
|
NodeOperationError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when model index is invalid (too low)', async () => {
|
||||||
|
const rules = [
|
||||||
|
{
|
||||||
|
modelIndex: '0',
|
||||||
|
conditions: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockSupplyDataFunction.getNodeParameter
|
||||||
|
.mockReturnValueOnce(rules) // rules.rule parameter
|
||||||
|
.mockReturnValueOnce(true); // conditions evaluation
|
||||||
|
|
||||||
|
await expect(node.supplyData.call(mockSupplyDataFunction, 0)).rejects.toThrow(
|
||||||
|
NodeOperationError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when model index is invalid (too high)', async () => {
|
||||||
|
const rules = [
|
||||||
|
{
|
||||||
|
modelIndex: '5',
|
||||||
|
conditions: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockSupplyDataFunction.getNodeParameter
|
||||||
|
.mockReturnValueOnce(rules) // rules.rule parameter
|
||||||
|
.mockReturnValueOnce(true); // conditions evaluation
|
||||||
|
|
||||||
|
await expect(node.supplyData.call(mockSupplyDataFunction, 0)).rejects.toThrow(
|
||||||
|
NodeOperationError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle string model indices correctly', async () => {
|
||||||
|
const rules = [
|
||||||
|
{
|
||||||
|
modelIndex: '3',
|
||||||
|
conditions: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockSupplyDataFunction.getNodeParameter
|
||||||
|
.mockReturnValueOnce(rules) // rules.rule parameter
|
||||||
|
.mockReturnValueOnce(true); // conditions evaluation
|
||||||
|
|
||||||
|
const result = await node.supplyData.call(mockSupplyDataFunction, 0);
|
||||||
|
|
||||||
|
// After reverse: [model3, model2, model1], so index 3 (1-based) = model1
|
||||||
|
expect(result.response).toBe(mockModel1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call getNodeParameter with correct parameters for condition evaluation', async () => {
|
||||||
|
const rules = [
|
||||||
|
{
|
||||||
|
modelIndex: '1',
|
||||||
|
conditions: { field: 'value' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockSupplyDataFunction.getNodeParameter
|
||||||
|
.mockReturnValueOnce(rules) // rules.rule parameter
|
||||||
|
.mockReturnValueOnce(true); // conditions evaluation
|
||||||
|
|
||||||
|
await node.supplyData.call(mockSupplyDataFunction, 0);
|
||||||
|
|
||||||
|
expect(mockSupplyDataFunction.getNodeParameter).toHaveBeenCalledWith(
|
||||||
|
'rules.rule[0].conditions',
|
||||||
|
0,
|
||||||
|
false,
|
||||||
|
{ extractValue: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import type { INodeParameters, INodePropertyOptions } from 'n8n-workflow';
|
||||||
|
|
||||||
|
// Import the function and property
|
||||||
|
import { numberInputsProperty, configuredInputs } from '../helpers';
|
||||||
|
|
||||||
|
// We need to extract the configuredInputs function for testing
|
||||||
|
// Since it's not exported, we'll test it indirectly through the node's inputs property
|
||||||
|
|
||||||
|
describe('ModelSelector Configuration', () => {
|
||||||
|
describe('numberInputsProperty', () => {
|
||||||
|
it('should have correct configuration', () => {
|
||||||
|
expect(numberInputsProperty.displayName).toBe('Number of Inputs');
|
||||||
|
expect(numberInputsProperty.name).toBe('numberInputs');
|
||||||
|
expect(numberInputsProperty.type).toBe('options');
|
||||||
|
expect(numberInputsProperty.default).toBe(2);
|
||||||
|
expect(numberInputsProperty.validateType).toBe('number');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have options from 2 to 10', () => {
|
||||||
|
const options = numberInputsProperty.options as INodePropertyOptions[];
|
||||||
|
expect(options).toHaveLength(9);
|
||||||
|
expect(options[0]).toEqual({ name: '2', value: 2 });
|
||||||
|
expect(options[8]).toEqual({ name: '10', value: 10 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have all sequential values from 2 to 10', () => {
|
||||||
|
const expectedValues = [2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||||
|
const options = numberInputsProperty.options as INodePropertyOptions[];
|
||||||
|
const actualValues = options.map((option) => option.value);
|
||||||
|
expect(actualValues).toEqual(expectedValues);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('configuredInputs function', () => {
|
||||||
|
it('should generate correct input configuration for default value', () => {
|
||||||
|
const parameters: INodeParameters = { numberInputs: 2 };
|
||||||
|
const result = configuredInputs(parameters);
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ type: 'ai_languageModel', displayName: 'Model 1', required: true, maxConnections: 1 },
|
||||||
|
{ type: 'ai_languageModel', displayName: 'Model 2', required: true, maxConnections: 1 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate correct input configuration for custom value', () => {
|
||||||
|
const parameters: INodeParameters = { numberInputs: 5 };
|
||||||
|
const result = configuredInputs(parameters);
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ type: 'ai_languageModel', displayName: 'Model 1', required: true, maxConnections: 1 },
|
||||||
|
{ type: 'ai_languageModel', displayName: 'Model 2', required: true, maxConnections: 1 },
|
||||||
|
{ type: 'ai_languageModel', displayName: 'Model 3', required: true, maxConnections: 1 },
|
||||||
|
{ type: 'ai_languageModel', displayName: 'Model 4', required: true, maxConnections: 1 },
|
||||||
|
{ type: 'ai_languageModel', displayName: 'Model 5', required: true, maxConnections: 1 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle undefined numberInputs parameter', () => {
|
||||||
|
const parameters: INodeParameters = {};
|
||||||
|
const result = configuredInputs(parameters);
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ type: 'ai_languageModel', displayName: 'Model 1', required: true, maxConnections: 1 },
|
||||||
|
{ type: 'ai_languageModel', displayName: 'Model 2', required: true, maxConnections: 1 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -102,6 +102,7 @@ function getInputs(
|
|||||||
'@n8n/n8n-nodes-langchain.lmChatDeepSeek',
|
'@n8n/n8n-nodes-langchain.lmChatDeepSeek',
|
||||||
'@n8n/n8n-nodes-langchain.lmChatOpenRouter',
|
'@n8n/n8n-nodes-langchain.lmChatOpenRouter',
|
||||||
'@n8n/n8n-nodes-langchain.lmChatXAiGrok',
|
'@n8n/n8n-nodes-langchain.lmChatXAiGrok',
|
||||||
|
'@n8n/n8n-nodes-langchain.modelSelector',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ function getInputs(hasOutputParser?: boolean): Array<NodeConnectionType | INodeI
|
|||||||
'@n8n/n8n-nodes-langchain.lmChatOpenRouter',
|
'@n8n/n8n-nodes-langchain.lmChatOpenRouter',
|
||||||
'@n8n/n8n-nodes-langchain.lmChatXAiGrok',
|
'@n8n/n8n-nodes-langchain.lmChatXAiGrok',
|
||||||
'@n8n/n8n-nodes-langchain.code',
|
'@n8n/n8n-nodes-langchain.code',
|
||||||
|
'@n8n/n8n-nodes-langchain.modelSelector',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ export class N8nLlmTracing extends BaseCallbackHandler {
|
|||||||
|
|
||||||
completionTokensEstimate = 0;
|
completionTokensEstimate = 0;
|
||||||
|
|
||||||
|
#parentRunIndex?: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A map to associate LLM run IDs to run details.
|
* A map to associate LLM run IDs to run details.
|
||||||
* Key: Unique identifier for each LLM run (run ID)
|
* Key: Unique identifier for each LLM run (run ID)
|
||||||
@@ -141,9 +143,16 @@ export class N8nLlmTracing extends BaseCallbackHandler {
|
|||||||
return message;
|
return message;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.executionFunctions.addOutputData(this.connectionType, runDetails.index, [
|
const sourceNodeRunIndex =
|
||||||
[{ json: { ...response } }],
|
this.#parentRunIndex !== undefined ? this.#parentRunIndex + runDetails.index : undefined;
|
||||||
]);
|
|
||||||
|
this.executionFunctions.addOutputData(
|
||||||
|
this.connectionType,
|
||||||
|
runDetails.index,
|
||||||
|
[[{ json: { ...response } }]],
|
||||||
|
undefined,
|
||||||
|
sourceNodeRunIndex,
|
||||||
|
);
|
||||||
|
|
||||||
logAiEvent(this.executionFunctions, 'ai-llm-generated-output', {
|
logAiEvent(this.executionFunctions, 'ai-llm-generated-output', {
|
||||||
messages: parsedMessages,
|
messages: parsedMessages,
|
||||||
@@ -154,19 +163,27 @@ export class N8nLlmTracing extends BaseCallbackHandler {
|
|||||||
|
|
||||||
async handleLLMStart(llm: Serialized, prompts: string[], runId: string) {
|
async handleLLMStart(llm: Serialized, prompts: string[], runId: string) {
|
||||||
const estimatedTokens = await this.estimateTokensFromStringList(prompts);
|
const estimatedTokens = await this.estimateTokensFromStringList(prompts);
|
||||||
|
const sourceNodeRunIndex =
|
||||||
|
this.#parentRunIndex !== undefined
|
||||||
|
? this.#parentRunIndex + this.executionFunctions.getNextRunIndex()
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const options = llm.type === 'constructor' ? llm.kwargs : llm;
|
const options = llm.type === 'constructor' ? llm.kwargs : llm;
|
||||||
const { index } = this.executionFunctions.addInputData(this.connectionType, [
|
const { index } = this.executionFunctions.addInputData(
|
||||||
|
this.connectionType,
|
||||||
[
|
[
|
||||||
{
|
[
|
||||||
json: {
|
{
|
||||||
messages: prompts,
|
json: {
|
||||||
estimatedTokens,
|
messages: prompts,
|
||||||
options,
|
estimatedTokens,
|
||||||
|
options,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
],
|
||||||
],
|
],
|
||||||
]);
|
sourceNodeRunIndex,
|
||||||
|
);
|
||||||
|
|
||||||
// Save the run details for later use when processing `handleLLMEnd` event
|
// Save the run details for later use when processing `handleLLMEnd` event
|
||||||
this.runsMap[runId] = {
|
this.runsMap[runId] = {
|
||||||
@@ -218,4 +235,9 @@ export class N8nLlmTracing extends BaseCallbackHandler {
|
|||||||
parentRunId,
|
parentRunId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Used to associate subsequent runs with the correct parent run in subnodes of subnodes
|
||||||
|
setParentRunIndex(runIndex: number) {
|
||||||
|
this.#parentRunIndex = runIndex;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { BaseCallbackHandler } from '@langchain/core/callbacks/base';
|
||||||
|
import type { SerializedFields } from '@langchain/core/dist/load/map_keys';
|
||||||
|
import type {
|
||||||
|
Serialized,
|
||||||
|
SerializedNotImplemented,
|
||||||
|
SerializedSecret,
|
||||||
|
} from '@langchain/core/load/serializable';
|
||||||
|
import type { BaseMessage } from '@langchain/core/messages';
|
||||||
|
import type { LLMResult } from '@langchain/core/outputs';
|
||||||
|
import pick from 'lodash/pick';
|
||||||
|
import type { IDataObject, ISupplyDataFunctions, JsonObject } from 'n8n-workflow';
|
||||||
|
import { NodeConnectionTypes, NodeError, NodeOperationError } from 'n8n-workflow';
|
||||||
|
|
||||||
|
import { logAiEvent } from '@utils/helpers';
|
||||||
|
|
||||||
|
type RunDetail = {
|
||||||
|
index: number;
|
||||||
|
messages: BaseMessage[] | string[] | string;
|
||||||
|
options: SerializedSecret | SerializedNotImplemented | SerializedFields;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class N8nNonEstimatingTracing extends BaseCallbackHandler {
|
||||||
|
name = 'N8nNonEstimatingTracing';
|
||||||
|
|
||||||
|
// This flag makes sure that LangChain will wait for the handlers to finish before continuing
|
||||||
|
// This is crucial for the handleLLMError handler to work correctly (it should be called before the error is propagated to the root node)
|
||||||
|
awaitHandlers = true;
|
||||||
|
|
||||||
|
connectionType = NodeConnectionTypes.AiLanguageModel;
|
||||||
|
|
||||||
|
#parentRunIndex?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A map to associate LLM run IDs to run details.
|
||||||
|
* Key: Unique identifier for each LLM run (run ID)
|
||||||
|
* Value: RunDetails object
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
runsMap: Record<string, RunDetail> = {};
|
||||||
|
|
||||||
|
options = {
|
||||||
|
// Default(OpenAI format) parser
|
||||||
|
errorDescriptionMapper: (error: NodeError) => error.description,
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private executionFunctions: ISupplyDataFunctions,
|
||||||
|
options?: {
|
||||||
|
errorDescriptionMapper?: (error: NodeError) => string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
this.options = { ...this.options, ...options };
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleLLMEnd(output: LLMResult, runId: string) {
|
||||||
|
// The fallback should never happen since handleLLMStart should always set the run details
|
||||||
|
// but just in case, we set the index to the length of the runsMap
|
||||||
|
const runDetails = this.runsMap[runId] ?? { index: Object.keys(this.runsMap).length };
|
||||||
|
|
||||||
|
output.generations = output.generations.map((gen) =>
|
||||||
|
gen.map((g) => pick(g, ['text', 'generationInfo'])),
|
||||||
|
);
|
||||||
|
|
||||||
|
const tokenUsageEstimate = {
|
||||||
|
completionTokens: 0,
|
||||||
|
promptTokens: 0,
|
||||||
|
totalTokens: 0,
|
||||||
|
};
|
||||||
|
const response: {
|
||||||
|
response: { generations: LLMResult['generations'] };
|
||||||
|
tokenUsageEstimate?: typeof tokenUsageEstimate;
|
||||||
|
} = {
|
||||||
|
response: { generations: output.generations },
|
||||||
|
};
|
||||||
|
|
||||||
|
response.tokenUsageEstimate = tokenUsageEstimate;
|
||||||
|
|
||||||
|
const parsedMessages =
|
||||||
|
typeof runDetails.messages === 'string'
|
||||||
|
? runDetails.messages
|
||||||
|
: runDetails.messages.map((message) => {
|
||||||
|
if (typeof message === 'string') return message;
|
||||||
|
if (typeof message?.toJSON === 'function') return message.toJSON();
|
||||||
|
|
||||||
|
return message;
|
||||||
|
});
|
||||||
|
|
||||||
|
const sourceNodeRunIndex =
|
||||||
|
this.#parentRunIndex !== undefined ? this.#parentRunIndex + runDetails.index : undefined;
|
||||||
|
|
||||||
|
this.executionFunctions.addOutputData(
|
||||||
|
this.connectionType,
|
||||||
|
runDetails.index,
|
||||||
|
[[{ json: { ...response } }]],
|
||||||
|
undefined,
|
||||||
|
sourceNodeRunIndex,
|
||||||
|
);
|
||||||
|
|
||||||
|
logAiEvent(this.executionFunctions, 'ai-llm-generated-output', {
|
||||||
|
messages: parsedMessages,
|
||||||
|
options: runDetails.options,
|
||||||
|
response,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleLLMStart(llm: Serialized, prompts: string[], runId: string) {
|
||||||
|
const estimatedTokens = 0;
|
||||||
|
const sourceNodeRunIndex =
|
||||||
|
this.#parentRunIndex !== undefined
|
||||||
|
? this.#parentRunIndex + this.executionFunctions.getNextRunIndex()
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const options = llm.type === 'constructor' ? llm.kwargs : llm;
|
||||||
|
const { index } = this.executionFunctions.addInputData(
|
||||||
|
this.connectionType,
|
||||||
|
[
|
||||||
|
[
|
||||||
|
{
|
||||||
|
json: {
|
||||||
|
messages: prompts,
|
||||||
|
estimatedTokens,
|
||||||
|
options,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
sourceNodeRunIndex,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Save the run details for later use when processing `handleLLMEnd` event
|
||||||
|
this.runsMap[runId] = {
|
||||||
|
index,
|
||||||
|
options,
|
||||||
|
messages: prompts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleLLMError(
|
||||||
|
error: IDataObject | Error,
|
||||||
|
runId: string,
|
||||||
|
parentRunId?: string | undefined,
|
||||||
|
) {
|
||||||
|
const runDetails = this.runsMap[runId] ?? { index: Object.keys(this.runsMap).length };
|
||||||
|
|
||||||
|
// Filter out non-x- headers to avoid leaking sensitive information in logs
|
||||||
|
if (typeof error === 'object' && error?.hasOwnProperty('headers')) {
|
||||||
|
const errorWithHeaders = error as { headers: Record<string, unknown> };
|
||||||
|
|
||||||
|
Object.keys(errorWithHeaders.headers).forEach((key) => {
|
||||||
|
if (!key.startsWith('x-')) {
|
||||||
|
delete errorWithHeaders.headers[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof NodeError) {
|
||||||
|
if (this.options.errorDescriptionMapper) {
|
||||||
|
error.description = this.options.errorDescriptionMapper(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.executionFunctions.addOutputData(this.connectionType, runDetails.index, error);
|
||||||
|
} else {
|
||||||
|
// If the error is not a NodeError, we wrap it in a NodeOperationError
|
||||||
|
this.executionFunctions.addOutputData(
|
||||||
|
this.connectionType,
|
||||||
|
runDetails.index,
|
||||||
|
new NodeOperationError(this.executionFunctions.getNode(), error as JsonObject, {
|
||||||
|
functionality: 'configuration-node',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logAiEvent(this.executionFunctions, 'ai-llm-errored', {
|
||||||
|
error: Object.keys(error).length === 0 ? error.toString() : error,
|
||||||
|
runId,
|
||||||
|
parentRunId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Used to associate subsequent runs with the correct parent run in subnodes of subnodes
|
||||||
|
setParentRunIndex(runIndex: number) {
|
||||||
|
this.#parentRunIndex = runIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -135,7 +135,8 @@
|
|||||||
"dist/nodes/vector_store/VectorStoreZep/VectorStoreZep.node.js",
|
"dist/nodes/vector_store/VectorStoreZep/VectorStoreZep.node.js",
|
||||||
"dist/nodes/vector_store/VectorStoreZepInsert/VectorStoreZepInsert.node.js",
|
"dist/nodes/vector_store/VectorStoreZepInsert/VectorStoreZepInsert.node.js",
|
||||||
"dist/nodes/vector_store/VectorStoreZepLoad/VectorStoreZepLoad.node.js",
|
"dist/nodes/vector_store/VectorStoreZepLoad/VectorStoreZepLoad.node.js",
|
||||||
"dist/nodes/ToolExecutor/ToolExecutor.node.js"
|
"dist/nodes/ToolExecutor/ToolExecutor.node.js",
|
||||||
|
"dist/nodes/ModelSelector/ModelSelector.node.js"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type {
|
|||||||
IRunExecutionData,
|
IRunExecutionData,
|
||||||
IWorkflowExecuteAdditionalData,
|
IWorkflowExecuteAdditionalData,
|
||||||
NodeConnectionType,
|
NodeConnectionType,
|
||||||
|
NodeInputConnections,
|
||||||
NodeParameterValueType,
|
NodeParameterValueType,
|
||||||
NodeTypeAndVersion,
|
NodeTypeAndVersion,
|
||||||
Workflow,
|
Workflow,
|
||||||
@@ -153,6 +154,10 @@ export abstract class NodeExecutionContext implements Omit<FunctionsBase, 'getCr
|
|||||||
.filter((node) => node.disabled !== true);
|
.filter((node) => node.disabled !== true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getConnections(destination: INode, connectionType: NodeConnectionType): NodeInputConnections {
|
||||||
|
return this.workflow.connectionsByDestinationNode[destination.name]?.[connectionType] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
getNodeOutputs(): INodeOutputConfiguration[] {
|
getNodeOutputs(): INodeOutputConfiguration[] {
|
||||||
return this.nodeOutputs;
|
return this.nodeOutputs;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ export class SupplyDataContext extends BaseExecuteContext implements ISupplyData
|
|||||||
addInputData(
|
addInputData(
|
||||||
connectionType: AINodeConnectionType,
|
connectionType: AINodeConnectionType,
|
||||||
data: INodeExecutionData[][],
|
data: INodeExecutionData[][],
|
||||||
|
runIndex?: number,
|
||||||
): { index: number } {
|
): { index: number } {
|
||||||
const nodeName = this.node.name;
|
const nodeName = this.node.name;
|
||||||
const currentNodeRunIndex = this.getNextRunIndex();
|
const currentNodeRunIndex = this.getNextRunIndex();
|
||||||
@@ -186,6 +187,8 @@ export class SupplyDataContext extends BaseExecuteContext implements ISupplyData
|
|||||||
connectionType,
|
connectionType,
|
||||||
nodeName,
|
nodeName,
|
||||||
currentNodeRunIndex,
|
currentNodeRunIndex,
|
||||||
|
undefined,
|
||||||
|
runIndex,
|
||||||
).catch((error) => {
|
).catch((error) => {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`There was a problem logging input data of node "${nodeName}": ${
|
`There was a problem logging input data of node "${nodeName}": ${
|
||||||
@@ -204,6 +207,7 @@ export class SupplyDataContext extends BaseExecuteContext implements ISupplyData
|
|||||||
currentNodeRunIndex: number,
|
currentNodeRunIndex: number,
|
||||||
data: INodeExecutionData[][] | ExecutionBaseError,
|
data: INodeExecutionData[][] | ExecutionBaseError,
|
||||||
metadata?: ITaskMetadata,
|
metadata?: ITaskMetadata,
|
||||||
|
sourceNodeRunIndex?: number,
|
||||||
): void {
|
): void {
|
||||||
const nodeName = this.node.name;
|
const nodeName = this.node.name;
|
||||||
this.addExecutionDataFunctions(
|
this.addExecutionDataFunctions(
|
||||||
@@ -213,6 +217,7 @@ export class SupplyDataContext extends BaseExecuteContext implements ISupplyData
|
|||||||
nodeName,
|
nodeName,
|
||||||
currentNodeRunIndex,
|
currentNodeRunIndex,
|
||||||
metadata,
|
metadata,
|
||||||
|
sourceNodeRunIndex,
|
||||||
).catch((error) => {
|
).catch((error) => {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`There was a problem logging output data of node "${nodeName}": ${
|
`There was a problem logging output data of node "${nodeName}": ${
|
||||||
@@ -230,17 +235,23 @@ export class SupplyDataContext extends BaseExecuteContext implements ISupplyData
|
|||||||
sourceNodeName: string,
|
sourceNodeName: string,
|
||||||
currentNodeRunIndex: number,
|
currentNodeRunIndex: number,
|
||||||
metadata?: ITaskMetadata,
|
metadata?: ITaskMetadata,
|
||||||
|
sourceNodeRunIndex?: number,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const {
|
const {
|
||||||
additionalData,
|
additionalData,
|
||||||
runExecutionData,
|
runExecutionData,
|
||||||
runIndex: sourceNodeRunIndex,
|
runIndex: currentRunIndex,
|
||||||
node: { name: nodeName },
|
node: { name: nodeName },
|
||||||
} = this;
|
} = this;
|
||||||
|
|
||||||
let taskData: ITaskData | undefined;
|
let taskData: ITaskData | undefined;
|
||||||
const source: ISourceData[] = this.parentNode
|
const source: ISourceData[] = this.parentNode
|
||||||
? [{ previousNode: this.parentNode.name, previousNodeRun: sourceNodeRunIndex }]
|
? [
|
||||||
|
{
|
||||||
|
previousNode: this.parentNode.name,
|
||||||
|
previousNodeRun: sourceNodeRunIndex ?? currentRunIndex,
|
||||||
|
},
|
||||||
|
]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
if (type === 'input') {
|
if (type === 'input') {
|
||||||
@@ -313,14 +324,13 @@ export class SupplyDataContext extends BaseExecuteContext implements ISupplyData
|
|||||||
runExecutionData.executionData!.metadata[sourceNodeName] = [];
|
runExecutionData.executionData!.metadata[sourceNodeName] = [];
|
||||||
sourceTaskData = runExecutionData.executionData!.metadata[sourceNodeName];
|
sourceTaskData = runExecutionData.executionData!.metadata[sourceNodeName];
|
||||||
}
|
}
|
||||||
|
if (!sourceTaskData[currentNodeRunIndex]) {
|
||||||
if (!sourceTaskData[sourceNodeRunIndex]) {
|
sourceTaskData[currentNodeRunIndex] = {
|
||||||
sourceTaskData[sourceNodeRunIndex] = {
|
|
||||||
subRun: [],
|
subRun: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceTaskData[sourceNodeRunIndex].subRun!.push({
|
sourceTaskData[currentNodeRunIndex].subRun!.push({
|
||||||
node: nodeName,
|
node: nodeName,
|
||||||
runIndex: currentNodeRunIndex,
|
runIndex: currentNodeRunIndex,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ describe('getInputConnectionData', () => {
|
|||||||
nodeTypes.getByNameAndVersion
|
nodeTypes.getByNameAndVersion
|
||||||
.calledWith(agentNode.type, expect.anything())
|
.calledWith(agentNode.type, expect.anything())
|
||||||
.mockReturnValue(agentNodeType);
|
.mockReturnValue(agentNodeType);
|
||||||
|
|
||||||
|
// Mock getConnections method used by validateInputConfiguration
|
||||||
|
// This will be overridden in individual tests as needed
|
||||||
|
jest.spyOn(executeContext, 'getConnections').mockReturnValue([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe.each([
|
describe.each([
|
||||||
@@ -88,7 +92,7 @@ describe('getInputConnectionData', () => {
|
|||||||
type: 'test.type',
|
type: 'test.type',
|
||||||
disabled: false,
|
disabled: false,
|
||||||
});
|
});
|
||||||
const secondNode = mock<INode>({ name: 'Second Node', disabled: false });
|
const secondNode = mock<INode>({ name: 'Second Node', type: 'test.type', disabled: false });
|
||||||
const supplyData = jest.fn().mockResolvedValue({ response });
|
const supplyData = jest.fn().mockResolvedValue({ response });
|
||||||
const nodeType = mock<INodeType>({ supplyData });
|
const nodeType = mock<INodeType>({ supplyData });
|
||||||
|
|
||||||
@@ -121,6 +125,7 @@ describe('getInputConnectionData', () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
workflow.getParentNodes.mockReturnValueOnce([]);
|
workflow.getParentNodes.mockReturnValueOnce([]);
|
||||||
|
(executeContext.getConnections as jest.Mock).mockReturnValueOnce([]);
|
||||||
|
|
||||||
const result = await executeContext.getInputConnectionData(connectionType, 0);
|
const result = await executeContext.getInputConnectionData(connectionType, 0);
|
||||||
expect(result).toBeUndefined();
|
expect(result).toBeUndefined();
|
||||||
@@ -136,6 +141,12 @@ describe('getInputConnectionData', () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
workflow.getParentNodes.mockReturnValueOnce([node.name, secondNode.name]);
|
workflow.getParentNodes.mockReturnValueOnce([node.name, secondNode.name]);
|
||||||
|
jest
|
||||||
|
.spyOn(executeContext, 'getConnections')
|
||||||
|
.mockReturnValueOnce([
|
||||||
|
[{ node: node.name, type: connectionType, index: 0 }],
|
||||||
|
[{ node: secondNode.name, type: connectionType, index: 0 }],
|
||||||
|
]);
|
||||||
|
|
||||||
await expect(executeContext.getInputConnectionData(connectionType, 0)).rejects.toThrow(
|
await expect(executeContext.getInputConnectionData(connectionType, 0)).rejects.toThrow(
|
||||||
`Only 1 ${connectionType} sub-nodes are/is allowed to be connected`,
|
`Only 1 ${connectionType} sub-nodes are/is allowed to be connected`,
|
||||||
@@ -151,6 +162,7 @@ describe('getInputConnectionData', () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
workflow.getParentNodes.mockReturnValueOnce([]);
|
workflow.getParentNodes.mockReturnValueOnce([]);
|
||||||
|
jest.spyOn(executeContext, 'getConnections').mockReturnValueOnce([]);
|
||||||
|
|
||||||
await expect(executeContext.getInputConnectionData(connectionType, 0)).rejects.toThrow(
|
await expect(executeContext.getInputConnectionData(connectionType, 0)).rejects.toThrow(
|
||||||
'must be connected and enabled',
|
'must be connected and enabled',
|
||||||
@@ -173,6 +185,10 @@ describe('getInputConnectionData', () => {
|
|||||||
});
|
});
|
||||||
workflow.getParentNodes.mockReturnValueOnce([disabledNode.name]);
|
workflow.getParentNodes.mockReturnValueOnce([disabledNode.name]);
|
||||||
workflow.getNode.calledWith(disabledNode.name).mockReturnValue(disabledNode);
|
workflow.getNode.calledWith(disabledNode.name).mockReturnValue(disabledNode);
|
||||||
|
// Mock connections that include the disabled node, but getConnectedNodes will filter it out
|
||||||
|
jest
|
||||||
|
.spyOn(executeContext, 'getConnections')
|
||||||
|
.mockReturnValueOnce([[{ node: disabledNode.name, type: connectionType, index: 0 }]]);
|
||||||
|
|
||||||
await expect(executeContext.getInputConnectionData(connectionType, 0)).rejects.toThrow(
|
await expect(executeContext.getInputConnectionData(connectionType, 0)).rejects.toThrow(
|
||||||
'must be connected and enabled',
|
'must be connected and enabled',
|
||||||
@@ -187,6 +203,9 @@ describe('getInputConnectionData', () => {
|
|||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
jest
|
||||||
|
.spyOn(executeContext, 'getConnections')
|
||||||
|
.mockReturnValueOnce([[{ node: node.name, type: connectionType, index: 0 }]]);
|
||||||
|
|
||||||
supplyData.mockRejectedValueOnce(new Error('supplyData error'));
|
supplyData.mockRejectedValueOnce(new Error('supplyData error'));
|
||||||
|
|
||||||
@@ -203,6 +222,9 @@ describe('getInputConnectionData', () => {
|
|||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
jest
|
||||||
|
.spyOn(executeContext, 'getConnections')
|
||||||
|
.mockReturnValueOnce([[{ node: node.name, type: connectionType, index: 0 }]]);
|
||||||
|
|
||||||
const configError = new NodeOperationError(node, 'Config Error in node', {
|
const configError = new NodeOperationError(node, 'Config Error in node', {
|
||||||
functionality: 'configuration-node',
|
functionality: 'configuration-node',
|
||||||
@@ -223,6 +245,9 @@ describe('getInputConnectionData', () => {
|
|||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
jest
|
||||||
|
.spyOn(executeContext, 'getConnections')
|
||||||
|
.mockReturnValueOnce([[{ node: node.name, type: connectionType, index: 0 }]]);
|
||||||
|
|
||||||
const closeFunction = jest.fn();
|
const closeFunction = jest.fn();
|
||||||
supplyData.mockResolvedValueOnce({ response, closeFunction });
|
supplyData.mockResolvedValueOnce({ response, closeFunction });
|
||||||
@@ -233,6 +258,127 @@ describe('getInputConnectionData', () => {
|
|||||||
// @ts-expect-error private property
|
// @ts-expect-error private property
|
||||||
expect(executeContext.closeFunctions).toContain(closeFunction);
|
expect(executeContext.closeFunctions).toContain(closeFunction);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should handle multiple input configurations of the same type with different max connections', async () => {
|
||||||
|
agentNodeType.description.inputs = [
|
||||||
|
{
|
||||||
|
type: connectionType,
|
||||||
|
maxConnections: 2,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: connectionType,
|
||||||
|
maxConnections: 1,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const thirdNode = mock<INode>({ name: 'Third Node', type: 'test.type', disabled: false });
|
||||||
|
|
||||||
|
// Mock node types for all connected nodes
|
||||||
|
nodeTypes.getByNameAndVersion
|
||||||
|
.calledWith(secondNode.type, expect.anything())
|
||||||
|
.mockReturnValue(nodeType);
|
||||||
|
nodeTypes.getByNameAndVersion
|
||||||
|
.calledWith(thirdNode.type, expect.anything())
|
||||||
|
.mockReturnValue(nodeType);
|
||||||
|
|
||||||
|
workflow.getParentNodes.mockReturnValueOnce([node.name, secondNode.name, thirdNode.name]);
|
||||||
|
workflow.getNode.calledWith(thirdNode.name).mockReturnValue(thirdNode);
|
||||||
|
jest
|
||||||
|
.spyOn(executeContext, 'getConnections')
|
||||||
|
.mockReturnValueOnce([
|
||||||
|
[{ node: node.name, type: connectionType, index: 0 }],
|
||||||
|
[{ node: secondNode.name, type: connectionType, index: 0 }],
|
||||||
|
[{ node: thirdNode.name, type: connectionType, index: 0 }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await executeContext.getInputConnectionData(connectionType, 0);
|
||||||
|
expect(result).toEqual([response, response, response]);
|
||||||
|
expect(supplyData).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw when exceeding total max connections across multiple input configurations', async () => {
|
||||||
|
agentNodeType.description.inputs = [
|
||||||
|
{
|
||||||
|
type: connectionType,
|
||||||
|
maxConnections: 1,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: connectionType,
|
||||||
|
maxConnections: 1,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const thirdNode = mock<INode>({ name: 'Third Node', type: 'test.type', disabled: false });
|
||||||
|
|
||||||
|
// Mock node types for all connected nodes
|
||||||
|
nodeTypes.getByNameAndVersion
|
||||||
|
.calledWith(secondNode.type, expect.anything())
|
||||||
|
.mockReturnValue(nodeType);
|
||||||
|
nodeTypes.getByNameAndVersion
|
||||||
|
.calledWith(thirdNode.type, expect.anything())
|
||||||
|
.mockReturnValue(nodeType);
|
||||||
|
|
||||||
|
workflow.getParentNodes.mockReturnValueOnce([node.name, secondNode.name, thirdNode.name]);
|
||||||
|
jest
|
||||||
|
.spyOn(executeContext, 'getConnections')
|
||||||
|
.mockReturnValueOnce([
|
||||||
|
[{ node: node.name, type: connectionType, index: 0 }],
|
||||||
|
[{ node: secondNode.name, type: connectionType, index: 0 }],
|
||||||
|
[{ node: thirdNode.name, type: connectionType, index: 0 }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(executeContext.getInputConnectionData(connectionType, 0)).rejects.toThrow(
|
||||||
|
`Only 2 ${connectionType} sub-nodes are/is allowed to be connected`,
|
||||||
|
);
|
||||||
|
expect(supplyData).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return array when multiple input configurations exist even with single connection', async () => {
|
||||||
|
agentNodeType.description.inputs = [
|
||||||
|
{
|
||||||
|
type: connectionType,
|
||||||
|
maxConnections: 1,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: connectionType,
|
||||||
|
maxConnections: 2,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
jest
|
||||||
|
.spyOn(executeContext, 'getConnections')
|
||||||
|
.mockReturnValueOnce([[{ node: node.name, type: connectionType, index: 0 }]]);
|
||||||
|
|
||||||
|
const result = await executeContext.getInputConnectionData(connectionType, 0);
|
||||||
|
expect(result).toEqual([response]);
|
||||||
|
expect(supplyData).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array when no connections and multiple optional inputs', async () => {
|
||||||
|
agentNodeType.description.inputs = [
|
||||||
|
{
|
||||||
|
type: connectionType,
|
||||||
|
maxConnections: 1,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: connectionType,
|
||||||
|
maxConnections: 1,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
workflow.getParentNodes.mockReturnValueOnce([]);
|
||||||
|
jest.spyOn(executeContext, 'getConnections').mockReturnValueOnce([]);
|
||||||
|
|
||||||
|
const result = await executeContext.getInputConnectionData(connectionType, 0);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
expect(supplyData).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe(NodeConnectionTypes.AiTool, () => {
|
describe(NodeConnectionTypes.AiTool, () => {
|
||||||
@@ -270,6 +416,7 @@ describe('getInputConnectionData', () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
workflow.getParentNodes.mockReturnValueOnce([]);
|
workflow.getParentNodes.mockReturnValueOnce([]);
|
||||||
|
jest.spyOn(executeContext, 'getConnections').mockReturnValueOnce([]);
|
||||||
|
|
||||||
const result = await executeContext.getInputConnectionData(NodeConnectionTypes.AiTool, 0);
|
const result = await executeContext.getInputConnectionData(NodeConnectionTypes.AiTool, 0);
|
||||||
expect(result).toEqual([]);
|
expect(result).toEqual([]);
|
||||||
@@ -284,6 +431,7 @@ describe('getInputConnectionData', () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
workflow.getParentNodes.mockReturnValueOnce([]);
|
workflow.getParentNodes.mockReturnValueOnce([]);
|
||||||
|
jest.spyOn(executeContext, 'getConnections').mockReturnValueOnce([]);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
executeContext.getInputConnectionData(NodeConnectionTypes.AiTool, 0),
|
executeContext.getInputConnectionData(NodeConnectionTypes.AiTool, 0),
|
||||||
@@ -309,6 +457,11 @@ describe('getInputConnectionData', () => {
|
|||||||
.calledWith(agentNode.name, NodeConnectionTypes.AiTool)
|
.calledWith(agentNode.name, NodeConnectionTypes.AiTool)
|
||||||
.mockReturnValue([disabledToolNode.name]);
|
.mockReturnValue([disabledToolNode.name]);
|
||||||
workflow.getNode.calledWith(disabledToolNode.name).mockReturnValue(disabledToolNode);
|
workflow.getNode.calledWith(disabledToolNode.name).mockReturnValue(disabledToolNode);
|
||||||
|
jest
|
||||||
|
.spyOn(executeContext, 'getConnections')
|
||||||
|
.mockReturnValueOnce([
|
||||||
|
[{ node: disabledToolNode.name, type: NodeConnectionTypes.AiTool, index: 0 }],
|
||||||
|
]);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
executeContext.getInputConnectionData(NodeConnectionTypes.AiTool, 0),
|
executeContext.getInputConnectionData(NodeConnectionTypes.AiTool, 0),
|
||||||
@@ -331,6 +484,12 @@ describe('getInputConnectionData', () => {
|
|||||||
workflow.getParentNodes
|
workflow.getParentNodes
|
||||||
.calledWith(agentNode.name, NodeConnectionTypes.AiTool)
|
.calledWith(agentNode.name, NodeConnectionTypes.AiTool)
|
||||||
.mockReturnValue([toolNode.name, secondToolNode.name]);
|
.mockReturnValue([toolNode.name, secondToolNode.name]);
|
||||||
|
jest
|
||||||
|
.spyOn(executeContext, 'getConnections')
|
||||||
|
.mockReturnValueOnce([
|
||||||
|
[{ node: toolNode.name, type: NodeConnectionTypes.AiTool, index: 0 }],
|
||||||
|
[{ node: secondToolNode.name, type: NodeConnectionTypes.AiTool, index: 0 }],
|
||||||
|
]);
|
||||||
|
|
||||||
const result = await executeContext.getInputConnectionData(NodeConnectionTypes.AiTool, 0);
|
const result = await executeContext.getInputConnectionData(NodeConnectionTypes.AiTool, 0);
|
||||||
expect(result).toEqual([mockTool, secondMockTool]);
|
expect(result).toEqual([mockTool, secondMockTool]);
|
||||||
@@ -347,6 +506,11 @@ describe('getInputConnectionData', () => {
|
|||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
jest
|
||||||
|
.spyOn(executeContext, 'getConnections')
|
||||||
|
.mockReturnValueOnce([
|
||||||
|
[{ node: toolNode.name, type: NodeConnectionTypes.AiTool, index: 0 }],
|
||||||
|
]);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
executeContext.getInputConnectionData(NodeConnectionTypes.AiTool, 0),
|
executeContext.getInputConnectionData(NodeConnectionTypes.AiTool, 0),
|
||||||
@@ -361,6 +525,11 @@ describe('getInputConnectionData', () => {
|
|||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
jest
|
||||||
|
.spyOn(executeContext, 'getConnections')
|
||||||
|
.mockReturnValueOnce([
|
||||||
|
[{ node: toolNode.name, type: NodeConnectionTypes.AiTool, index: 0 }],
|
||||||
|
]);
|
||||||
|
|
||||||
const result = await executeContext.getInputConnectionData(NodeConnectionTypes.AiTool, 0);
|
const result = await executeContext.getInputConnectionData(NodeConnectionTypes.AiTool, 0);
|
||||||
expect(result).toEqual([mockTool]);
|
expect(result).toEqual([mockTool]);
|
||||||
|
|||||||
@@ -15,12 +15,15 @@ import type {
|
|||||||
ISupplyDataFunctions,
|
ISupplyDataFunctions,
|
||||||
INodeType,
|
INodeType,
|
||||||
INode,
|
INode,
|
||||||
|
INodeInputConfiguration,
|
||||||
|
NodeConnectionType,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import {
|
import {
|
||||||
NodeConnectionTypes,
|
NodeConnectionTypes,
|
||||||
NodeOperationError,
|
NodeOperationError,
|
||||||
ExecutionBaseError,
|
ExecutionBaseError,
|
||||||
ApplicationError,
|
ApplicationError,
|
||||||
|
UserError,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
import { createNodeAsTool } from './create-node-as-tool';
|
import { createNodeAsTool } from './create-node-as-tool';
|
||||||
@@ -85,6 +88,42 @@ export function makeHandleToolInvocation(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateInputConfiguration(
|
||||||
|
context: ExecuteContext | WebhookContext | SupplyDataContext,
|
||||||
|
connectionType: NodeConnectionType,
|
||||||
|
nodeInputs: INodeInputConfiguration[],
|
||||||
|
connectedNodes: INode[],
|
||||||
|
) {
|
||||||
|
const parentNode = context.getNode();
|
||||||
|
|
||||||
|
const connections = context.getConnections(parentNode, connectionType);
|
||||||
|
|
||||||
|
// Validate missing required connections
|
||||||
|
for (let index = 0; index < nodeInputs.length; index++) {
|
||||||
|
const inputConfiguration = nodeInputs[index];
|
||||||
|
|
||||||
|
if (inputConfiguration.required) {
|
||||||
|
// For required inputs, we need at least one enabled connected node
|
||||||
|
if (
|
||||||
|
connections.length === 0 ||
|
||||||
|
connections.length <= index ||
|
||||||
|
connections.at(index)?.length === 0 ||
|
||||||
|
!connectedNodes.find((node) =>
|
||||||
|
connections
|
||||||
|
.at(index)
|
||||||
|
?.map((value) => value.node)
|
||||||
|
.includes(node.name),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new NodeOperationError(
|
||||||
|
parentNode,
|
||||||
|
`A ${inputConfiguration?.displayName ?? connectionType} sub-node must be connected and enabled`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function getInputConnectionData(
|
export async function getInputConnectionData(
|
||||||
this: ExecuteContext | WebhookContext | SupplyDataContext,
|
this: ExecuteContext | WebhookContext | SupplyDataContext,
|
||||||
workflow: Workflow,
|
workflow: Workflow,
|
||||||
@@ -101,32 +140,37 @@ export async function getInputConnectionData(
|
|||||||
abortSignal?: AbortSignal,
|
abortSignal?: AbortSignal,
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
const parentNode = this.getNode();
|
const parentNode = this.getNode();
|
||||||
|
const inputConfigurations = this.nodeInputs.filter((input) => input.type === connectionType);
|
||||||
|
|
||||||
const inputConfiguration = this.nodeInputs.find((input) => input.type === connectionType);
|
if (inputConfigurations === undefined || inputConfigurations.length === 0) {
|
||||||
if (inputConfiguration === undefined) {
|
throw new UserError('Node does not have input of type', {
|
||||||
throw new ApplicationError('Node does not have input of type', {
|
|
||||||
extra: { nodeName: parentNode.name, connectionType },
|
extra: { nodeName: parentNode.name, connectionType },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const maxConnections = inputConfigurations.reduce(
|
||||||
|
(acc, currentItem) =>
|
||||||
|
currentItem.maxConnections !== undefined ? acc + currentItem.maxConnections : acc,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
const connectedNodes = this.getConnectedNodes(connectionType);
|
const connectedNodes = this.getConnectedNodes(connectionType);
|
||||||
|
validateInputConfiguration(this, connectionType, inputConfigurations, connectedNodes);
|
||||||
|
|
||||||
|
// Nothing is connected or required
|
||||||
if (connectedNodes.length === 0) {
|
if (connectedNodes.length === 0) {
|
||||||
if (inputConfiguration.required) {
|
return maxConnections === 1 ? undefined : [];
|
||||||
throw new NodeOperationError(
|
|
||||||
parentNode,
|
|
||||||
`A ${inputConfiguration?.displayName ?? connectionType} sub-node must be connected and enabled`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return inputConfiguration.maxConnections === 1 ? undefined : [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Too many connections
|
||||||
if (
|
if (
|
||||||
inputConfiguration.maxConnections !== undefined &&
|
maxConnections !== undefined &&
|
||||||
connectedNodes.length > inputConfiguration.maxConnections
|
maxConnections !== 0 &&
|
||||||
|
connectedNodes.length > maxConnections
|
||||||
) {
|
) {
|
||||||
throw new NodeOperationError(
|
throw new NodeOperationError(
|
||||||
parentNode,
|
parentNode,
|
||||||
`Only ${inputConfiguration.maxConnections} ${connectionType} sub-nodes are/is allowed to be connected`,
|
`Only ${maxConnections} ${connectionType} sub-nodes are/is allowed to be connected`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,7 +258,5 @@ export async function getInputConnectionData(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return inputConfiguration.maxConnections === 1
|
return maxConnections === 1 ? (nodes || [])[0]?.response : nodes.map((node) => node.response);
|
||||||
? (nodes || [])[0]?.response
|
|
||||||
: nodes.map((node) => node.response);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type INode, type IPinData, type IRunData } from 'n8n-workflow';
|
import { NodeConnectionTypes, type INode, type IPinData, type IRunData } from 'n8n-workflow';
|
||||||
|
|
||||||
import type { GraphConnection, DirectedGraph } from './directed-graph';
|
import type { GraphConnection, DirectedGraph } from './directed-graph';
|
||||||
|
|
||||||
@@ -99,7 +99,7 @@ export function getSourceDataGroups(
|
|||||||
|
|
||||||
if (hasData) {
|
if (hasData) {
|
||||||
sortedConnectionsWithData.push(connection);
|
sortedConnectionsWithData.push(connection);
|
||||||
} else {
|
} else if (connection.type === NodeConnectionTypes.Main) {
|
||||||
sortedConnectionsWithoutData.push(connection);
|
sortedConnectionsWithoutData.push(connection);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { createTestingPinia } from '@pinia/testing';
|
|||||||
import type { INodeUi } from '@/Interface';
|
import type { INodeUi } from '@/Interface';
|
||||||
import type { INodeTypeDescription, WorkflowParameters } from 'n8n-workflow';
|
import type { INodeTypeDescription, WorkflowParameters } from 'n8n-workflow';
|
||||||
import { NodeConnectionTypes, Workflow } from 'n8n-workflow';
|
import { NodeConnectionTypes, Workflow } from 'n8n-workflow';
|
||||||
|
import { nextTick } from 'vue';
|
||||||
|
|
||||||
const nodeType: INodeTypeDescription = {
|
const nodeType: INodeTypeDescription = {
|
||||||
displayName: 'OpenAI',
|
displayName: 'OpenAI',
|
||||||
@@ -57,6 +58,8 @@ const workflow: WorkflowParameters = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getNodeType = vi.fn();
|
const getNodeType = vi.fn();
|
||||||
|
let mockWorkflowData = workflow;
|
||||||
|
let mockGetNodeByName = vi.fn(() => node);
|
||||||
|
|
||||||
vi.mock('@/stores/nodeTypes.store', () => ({
|
vi.mock('@/stores/nodeTypes.store', () => ({
|
||||||
useNodeTypesStore: vi.fn(() => ({
|
useNodeTypesStore: vi.fn(() => ({
|
||||||
@@ -66,8 +69,8 @@ vi.mock('@/stores/nodeTypes.store', () => ({
|
|||||||
|
|
||||||
vi.mock('@/stores/workflows.store', () => ({
|
vi.mock('@/stores/workflows.store', () => ({
|
||||||
useWorkflowsStore: vi.fn(() => ({
|
useWorkflowsStore: vi.fn(() => ({
|
||||||
getCurrentWorkflow: vi.fn(() => new Workflow(workflow)),
|
getCurrentWorkflow: vi.fn(() => new Workflow(mockWorkflowData)),
|
||||||
getNodeByName: vi.fn(() => node),
|
getNodeByName: mockGetNodeByName,
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -88,17 +91,17 @@ describe('NDVSubConnections', () => {
|
|||||||
vi.advanceTimersByTime(1000); // Event debounce time
|
vi.advanceTimersByTime(1000); // Event debounce time
|
||||||
|
|
||||||
await waitFor(() => {});
|
await waitFor(() => {});
|
||||||
expect(getByTestId('subnode-connection-group-ai_tool')).toBeVisible();
|
expect(getByTestId('subnode-connection-group-ai_tool-0')).toBeVisible();
|
||||||
expect(html()).toEqual(
|
expect(html()).toEqual(
|
||||||
`<div class="container">
|
`<div class="container">
|
||||||
<div class="connections" style="--possible-connections: 1;">
|
<div class="connections" style="--possible-connections: 1;">
|
||||||
<div data-test-id="subnode-connection-group-ai_tool">
|
<div data-test-id="subnode-connection-group-ai_tool-0">
|
||||||
<div class="connectionType"><span class="connectionLabel">Tools</span>
|
<div class="connectionType"><span class="connectionLabel">Tools</span>
|
||||||
<div>
|
<div>
|
||||||
<div class="connectedNodesWrapper" style="--nodes-length: 0;">
|
<div class="connectedNodesWrapper" style="--nodes-length: 0;">
|
||||||
<div class="plusButton">
|
<div class="plusButton">
|
||||||
<n8n-tooltip placement="top" teleported="true" offset="10" show-after="300" disabled="false">
|
<n8n-tooltip placement="top" teleported="true" offset="10" show-after="300" disabled="false">
|
||||||
<n8n-icon-button size="medium" icon="plus" type="tertiary" data-test-id="add-subnode-ai_tool"></n8n-icon-button>
|
<n8n-icon-button size="medium" icon="plus" type="tertiary" data-test-id="add-subnode-ai_tool-0"></n8n-icon-button>
|
||||||
</n8n-tooltip>
|
</n8n-tooltip>
|
||||||
</div>
|
</div>
|
||||||
<!--v-if-->
|
<!--v-if-->
|
||||||
@@ -123,4 +126,101 @@ describe('NDVSubConnections', () => {
|
|||||||
await waitFor(() => {});
|
await waitFor(() => {});
|
||||||
expect(component.html()).toEqual('<!--v-if-->');
|
expect(component.html()).toEqual('<!--v-if-->');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should render multiple connections of the same type separately', async () => {
|
||||||
|
// Mock a ModelSelector-like node with multiple ai_languageModel connections
|
||||||
|
const multiConnectionNodeType: INodeTypeDescription = {
|
||||||
|
displayName: 'Model Selector',
|
||||||
|
name: 'modelSelector',
|
||||||
|
version: [1],
|
||||||
|
inputs: [
|
||||||
|
{ type: NodeConnectionTypes.Main },
|
||||||
|
{
|
||||||
|
type: NodeConnectionTypes.AiLanguageModel,
|
||||||
|
displayName: 'Model 1',
|
||||||
|
required: true,
|
||||||
|
maxConnections: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: NodeConnectionTypes.AiLanguageModel,
|
||||||
|
displayName: 'Model 2',
|
||||||
|
required: true,
|
||||||
|
maxConnections: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: NodeConnectionTypes.AiLanguageModel,
|
||||||
|
displayName: 'Model 3',
|
||||||
|
required: true,
|
||||||
|
maxConnections: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
outputs: [NodeConnectionTypes.AiLanguageModel],
|
||||||
|
properties: [],
|
||||||
|
defaults: { color: '', name: '' },
|
||||||
|
group: [],
|
||||||
|
description: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const multiConnectionNode: INodeUi = {
|
||||||
|
...node,
|
||||||
|
name: 'ModelSelector',
|
||||||
|
type: 'modelSelector',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock connected nodes
|
||||||
|
const mockWorkflow = {
|
||||||
|
...workflow,
|
||||||
|
nodes: [multiConnectionNode],
|
||||||
|
connectionsByDestinationNode: {
|
||||||
|
ModelSelector: {
|
||||||
|
[NodeConnectionTypes.AiLanguageModel]: [
|
||||||
|
null, // Main input (index 0) - no ai_languageModel connection
|
||||||
|
[{ node: 'OpenAI1', type: NodeConnectionTypes.AiLanguageModel, index: 0 }], // Model 1 (index 1)
|
||||||
|
[{ node: 'Claude', type: NodeConnectionTypes.AiLanguageModel, index: 0 }], // Model 2 (index 2)
|
||||||
|
[], // Model 3 (index 3) - no connection
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock additional nodes
|
||||||
|
const openAI1Node: INodeUi = {
|
||||||
|
...node,
|
||||||
|
name: 'OpenAI1',
|
||||||
|
type: '@n8n/n8n-nodes-langchain.openAi',
|
||||||
|
};
|
||||||
|
const claudeNode: INodeUi = {
|
||||||
|
...node,
|
||||||
|
name: 'Claude',
|
||||||
|
type: '@n8n/n8n-nodes-langchain.claude',
|
||||||
|
};
|
||||||
|
|
||||||
|
getNodeType.mockReturnValue(multiConnectionNodeType);
|
||||||
|
|
||||||
|
// Update mock data for this test
|
||||||
|
mockWorkflowData = mockWorkflow;
|
||||||
|
mockGetNodeByName = vi.fn((name: string) => {
|
||||||
|
if (name === 'ModelSelector') return multiConnectionNode;
|
||||||
|
if (name === 'OpenAI1') return openAI1Node;
|
||||||
|
if (name === 'Claude') return claudeNode;
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const { getByTestId } = render(NDVSubConnections, {
|
||||||
|
props: {
|
||||||
|
rootNode: multiConnectionNode,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(getByTestId('subnode-connection-group-ai_languageModel-0')).toBeVisible(); // Model 1
|
||||||
|
expect(getByTestId('subnode-connection-group-ai_languageModel-1')).toBeVisible(); // Model 2
|
||||||
|
expect(getByTestId('subnode-connection-group-ai_languageModel-2')).toBeVisible(); // Model 3
|
||||||
|
|
||||||
|
expect(getByTestId('add-subnode-ai_languageModel-0')).toBeVisible();
|
||||||
|
expect(getByTestId('add-subnode-ai_languageModel-1')).toBeVisible();
|
||||||
|
expect(getByTestId('add-subnode-ai_languageModel-2')).toBeVisible();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ interface NodeConfig {
|
|||||||
|
|
||||||
const possibleConnections = ref<INodeInputConfiguration[]>([]);
|
const possibleConnections = ref<INodeInputConfiguration[]>([]);
|
||||||
|
|
||||||
const expandedGroups = ref<NodeConnectionType[]>([]);
|
const expandedGroups = ref<string[]>([]);
|
||||||
const shouldShowNodeInputIssues = ref(false);
|
const shouldShowNodeInputIssues = ref(false);
|
||||||
|
|
||||||
const nodeType = computed(() =>
|
const nodeType = computed(() =>
|
||||||
@@ -61,41 +61,79 @@ const nodeInputIssues = computed(() => {
|
|||||||
return issues?.input ?? {};
|
return issues?.input ?? {};
|
||||||
});
|
});
|
||||||
|
|
||||||
const connectedNodes = computed<Record<NodeConnectionType, NodeConfig[]>>(() => {
|
const connectedNodes = computed<Record<string, NodeConfig[]>>(() => {
|
||||||
|
const typeIndexCounters: Record<string, number> = {};
|
||||||
|
|
||||||
return possibleConnections.value.reduce(
|
return possibleConnections.value.reduce(
|
||||||
(acc, connection) => {
|
(acc, connection) => {
|
||||||
const nodes = getINodesFromNames(
|
// Track index per connection type
|
||||||
workflow.value.getParentNodes(props.rootNode.name, connection.type),
|
const typeIndex = typeIndexCounters[connection.type] ?? 0;
|
||||||
);
|
typeIndexCounters[connection.type] = typeIndex + 1;
|
||||||
return { ...acc, [connection.type]: nodes };
|
|
||||||
|
// Get input-index-specific connections using the per-type index
|
||||||
|
const nodeConnections =
|
||||||
|
workflow.value.connectionsByDestinationNode[props.rootNode.name]?.[connection.type] ?? [];
|
||||||
|
const inputConnections = nodeConnections[typeIndex] ?? [];
|
||||||
|
const nodeNames = inputConnections.map((conn) => conn.node);
|
||||||
|
const nodes = getINodesFromNames(nodeNames);
|
||||||
|
|
||||||
|
// Use a unique key that combines connection type and per-type index
|
||||||
|
const connectionKey = `${connection.type}-${typeIndex}`;
|
||||||
|
return { ...acc, [connectionKey]: nodes };
|
||||||
},
|
},
|
||||||
{} as Record<NodeConnectionType, NodeConfig[]>,
|
{} as Record<string, NodeConfig[]>,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
function getConnectionConfig(connectionType: NodeConnectionType) {
|
function getConnectionKey(connection: INodeInputConfiguration, globalIndex: number): string {
|
||||||
return possibleConnections.value.find((c) => c.type === connectionType);
|
// Calculate the per-type index for this connection
|
||||||
|
let typeIndex = 0;
|
||||||
|
for (let i = 0; i < globalIndex; i++) {
|
||||||
|
if (possibleConnections.value[i].type === connection.type) {
|
||||||
|
typeIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `${connection.type}-${typeIndex}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isMultiConnection(connectionType: NodeConnectionType) {
|
function getConnectionConfig(connectionKey: string) {
|
||||||
const connectionConfig = getConnectionConfig(connectionType);
|
const [type, indexStr] = connectionKey.split('-');
|
||||||
|
const typeIndex = parseInt(indexStr, 10);
|
||||||
|
|
||||||
|
// Find the connection config by type and type-specific index
|
||||||
|
let currentTypeIndex = 0;
|
||||||
|
for (const connection of possibleConnections.value) {
|
||||||
|
if (connection.type === type) {
|
||||||
|
if (currentTypeIndex === typeIndex) {
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
currentTypeIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMultiConnection(connectionKey: string) {
|
||||||
|
const connectionConfig = getConnectionConfig(connectionKey);
|
||||||
return connectionConfig?.maxConnections !== 1;
|
return connectionConfig?.maxConnections !== 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldShowConnectionTooltip(connectionType: NodeConnectionType) {
|
function shouldShowConnectionTooltip(connectionKey: string) {
|
||||||
return isMultiConnection(connectionType) && !expandedGroups.value.includes(connectionType);
|
const [type] = connectionKey.split('-');
|
||||||
|
return isMultiConnection(connectionKey) && !expandedGroups.value.includes(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
function expandConnectionGroup(connectionType: NodeConnectionType, isExpanded: boolean) {
|
function expandConnectionGroup(connectionKey: string, isExpanded: boolean) {
|
||||||
|
const [type] = connectionKey.split('-');
|
||||||
// If the connection is a single connection, we don't need to expand the group
|
// If the connection is a single connection, we don't need to expand the group
|
||||||
if (!isMultiConnection(connectionType)) {
|
if (!isMultiConnection(connectionKey)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isExpanded) {
|
if (isExpanded) {
|
||||||
expandedGroups.value = [...expandedGroups.value, connectionType];
|
expandedGroups.value = [...expandedGroups.value, type];
|
||||||
} else {
|
} else {
|
||||||
expandedGroups.value = expandedGroups.value.filter((g) => g !== connectionType);
|
expandedGroups.value = expandedGroups.value.filter((g) => g !== type);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,10 +154,9 @@ function getINodesFromNames(names: string[]): NodeConfig[] {
|
|||||||
.filter((n): n is NodeConfig => n !== null);
|
.filter((n): n is NodeConfig => n !== null);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasInputIssues(connectionType: NodeConnectionType) {
|
function hasInputIssues(connectionKey: string) {
|
||||||
return (
|
const [type] = connectionKey.split('-');
|
||||||
shouldShowNodeInputIssues.value && (nodeInputIssues.value[connectionType] ?? []).length > 0
|
return shouldShowNodeInputIssues.value && (nodeInputIssues.value[type] ?? []).length > 0;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isNodeInputConfiguration(
|
function isNodeInputConfiguration(
|
||||||
@@ -144,27 +181,29 @@ function getPossibleSubInputConnections(): INodeInputConfiguration[] {
|
|||||||
return nonMainInputs;
|
return nonMainInputs;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onNodeClick(nodeName: string, connectionType: NodeConnectionType) {
|
function onNodeClick(nodeName: string, connectionKey: string) {
|
||||||
if (isMultiConnection(connectionType) && !expandedGroups.value.includes(connectionType)) {
|
const [type] = connectionKey.split('-');
|
||||||
expandConnectionGroup(connectionType, true);
|
if (isMultiConnection(connectionKey) && !expandedGroups.value.includes(type)) {
|
||||||
|
expandConnectionGroup(connectionKey, true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
emit('switchSelectedNode', nodeName);
|
emit('switchSelectedNode', nodeName);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPlusClick(connectionType: NodeConnectionType) {
|
function onPlusClick(connectionKey: string) {
|
||||||
const connectionNodes = connectedNodes.value[connectionType];
|
const [type] = connectionKey.split('-');
|
||||||
|
const connectionNodes = connectedNodes.value[connectionKey];
|
||||||
if (
|
if (
|
||||||
isMultiConnection(connectionType) &&
|
isMultiConnection(connectionKey) &&
|
||||||
!expandedGroups.value.includes(connectionType) &&
|
!expandedGroups.value.includes(type) &&
|
||||||
connectionNodes.length >= 1
|
connectionNodes.length >= 1
|
||||||
) {
|
) {
|
||||||
expandConnectionGroup(connectionType, true);
|
expandConnectionGroup(connectionKey, true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
emit('openConnectionNodeCreator', props.rootNode.name, connectionType);
|
emit('openConnectionNodeCreator', props.rootNode.name, type as NodeConnectionType);
|
||||||
}
|
}
|
||||||
|
|
||||||
function showNodeInputsIssues() {
|
function showNodeInputsIssues() {
|
||||||
@@ -200,39 +239,41 @@ defineExpose({
|
|||||||
:style="`--possible-connections: ${possibleConnections.length}`"
|
:style="`--possible-connections: ${possibleConnections.length}`"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="connection in possibleConnections"
|
v-for="(connection, index) in possibleConnections"
|
||||||
:key="connection.type"
|
:key="getConnectionKey(connection, index)"
|
||||||
:data-test-id="`subnode-connection-group-${connection.type}`"
|
:data-test-id="`subnode-connection-group-${getConnectionKey(connection, index)}`"
|
||||||
>
|
>
|
||||||
<div :class="$style.connectionType">
|
<div :class="$style.connectionType">
|
||||||
<span
|
<span
|
||||||
:class="{
|
:class="{
|
||||||
[$style.connectionLabel]: true,
|
[$style.connectionLabel]: true,
|
||||||
[$style.hasIssues]: hasInputIssues(connection.type),
|
[$style.hasIssues]: hasInputIssues(getConnectionKey(connection, index)),
|
||||||
}"
|
}"
|
||||||
v-text="`${connection.displayName}${connection.required ? ' *' : ''}`"
|
v-text="`${connection.displayName}${connection.required ? ' *' : ''}`"
|
||||||
/>
|
/>
|
||||||
<OnClickOutside @trigger="expandConnectionGroup(connection.type, false)">
|
<OnClickOutside
|
||||||
|
@trigger="expandConnectionGroup(getConnectionKey(connection, index), false)"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
ref="connectedNodesWrapper"
|
ref="connectedNodesWrapper"
|
||||||
:class="{
|
:class="{
|
||||||
[$style.connectedNodesWrapper]: true,
|
[$style.connectedNodesWrapper]: true,
|
||||||
[$style.connectedNodesWrapperExpanded]: expandedGroups.includes(connection.type),
|
[$style.connectedNodesWrapperExpanded]: expandedGroups.includes(connection.type),
|
||||||
}"
|
}"
|
||||||
:style="`--nodes-length: ${connectedNodes[connection.type].length}`"
|
:style="`--nodes-length: ${connectedNodes[getConnectionKey(connection, index)].length}`"
|
||||||
@click="expandConnectionGroup(connection.type, true)"
|
@click="expandConnectionGroup(getConnectionKey(connection, index), true)"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="
|
v-if="
|
||||||
connectedNodes[connection.type].length >= 1
|
connectedNodes[getConnectionKey(connection, index)].length >= 1
|
||||||
? connection.maxConnections !== 1
|
? connection.maxConnections !== 1
|
||||||
: true
|
: true
|
||||||
"
|
"
|
||||||
:class="{
|
:class="{
|
||||||
[$style.plusButton]: true,
|
[$style.plusButton]: true,
|
||||||
[$style.hasIssues]: hasInputIssues(connection.type),
|
[$style.hasIssues]: hasInputIssues(getConnectionKey(connection, index)),
|
||||||
}"
|
}"
|
||||||
@click="onPlusClick(connection.type)"
|
@click="onPlusClick(getConnectionKey(connection, index))"
|
||||||
>
|
>
|
||||||
<n8n-tooltip
|
<n8n-tooltip
|
||||||
placement="top"
|
placement="top"
|
||||||
@@ -240,13 +281,13 @@ defineExpose({
|
|||||||
:offset="10"
|
:offset="10"
|
||||||
:show-after="300"
|
:show-after="300"
|
||||||
:disabled="
|
:disabled="
|
||||||
shouldShowConnectionTooltip(connection.type) &&
|
shouldShowConnectionTooltip(getConnectionKey(connection, index)) &&
|
||||||
connectedNodes[connection.type].length >= 1
|
connectedNodes[getConnectionKey(connection, index)].length >= 1
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<template #content>
|
<template #content>
|
||||||
Add {{ connection.displayName }}
|
Add {{ connection.displayName }}
|
||||||
<template v-if="hasInputIssues(connection.type)">
|
<template v-if="hasInputIssues(getConnectionKey(connection, index))">
|
||||||
<TitledList
|
<TitledList
|
||||||
:title="`${i18n.baseText('node.issues')}:`"
|
:title="`${i18n.baseText('node.issues')}:`"
|
||||||
:items="nodeInputIssues[connection.type]"
|
:items="nodeInputIssues[connection.type]"
|
||||||
@@ -257,24 +298,25 @@ defineExpose({
|
|||||||
size="medium"
|
size="medium"
|
||||||
icon="plus"
|
icon="plus"
|
||||||
type="tertiary"
|
type="tertiary"
|
||||||
:data-test-id="`add-subnode-${connection.type}`"
|
:data-test-id="`add-subnode-${getConnectionKey(connection, index)}`"
|
||||||
/>
|
/>
|
||||||
</n8n-tooltip>
|
</n8n-tooltip>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="connectedNodes[connection.type].length > 0"
|
v-if="connectedNodes[getConnectionKey(connection, index)].length > 0"
|
||||||
:class="{
|
:class="{
|
||||||
[$style.connectedNodes]: true,
|
[$style.connectedNodes]: true,
|
||||||
[$style.connectedNodesMultiple]: connectedNodes[connection.type].length > 1,
|
[$style.connectedNodesMultiple]:
|
||||||
|
connectedNodes[getConnectionKey(connection, index)].length > 1,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="(node, index) in connectedNodes[connection.type]"
|
v-for="(node, nodeIndex) in connectedNodes[getConnectionKey(connection, index)]"
|
||||||
:key="node.node.name"
|
:key="node.node.name"
|
||||||
:class="{ [$style.nodeWrapper]: true, [$style.hasIssues]: node.issues }"
|
:class="{ [$style.nodeWrapper]: true, [$style.hasIssues]: node.issues }"
|
||||||
data-test-id="floating-subnode"
|
data-test-id="floating-subnode"
|
||||||
:data-node-name="node.node.name"
|
:data-node-name="node.node.name"
|
||||||
:style="`--node-index: ${index}`"
|
:style="`--node-index: ${nodeIndex}`"
|
||||||
>
|
>
|
||||||
<n8n-tooltip
|
<n8n-tooltip
|
||||||
:key="node.node.name"
|
:key="node.node.name"
|
||||||
@@ -282,7 +324,7 @@ defineExpose({
|
|||||||
:teleported="true"
|
:teleported="true"
|
||||||
:offset="10"
|
:offset="10"
|
||||||
:show-after="300"
|
:show-after="300"
|
||||||
:disabled="shouldShowConnectionTooltip(connection.type)"
|
:disabled="shouldShowConnectionTooltip(getConnectionKey(connection, index))"
|
||||||
>
|
>
|
||||||
<template #content>
|
<template #content>
|
||||||
{{ node.node.name }}
|
{{ node.node.name }}
|
||||||
@@ -296,7 +338,7 @@ defineExpose({
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
:class="$style.connectedNode"
|
:class="$style.connectedNode"
|
||||||
@click="onNodeClick(node.node.name, connection.type)"
|
@click="onNodeClick(node.node.name, getConnectionKey(connection, index))"
|
||||||
>
|
>
|
||||||
<NodeIcon
|
<NodeIcon
|
||||||
:node-type="node.nodeType"
|
:node-type="node.nodeType"
|
||||||
|
|||||||
@@ -394,13 +394,13 @@ describe(getTreeNodeData, () => {
|
|||||||
createTestTaskData({
|
createTestTaskData({
|
||||||
startTime: Date.parse('2025-02-26T00:00:02.000Z'),
|
startTime: Date.parse('2025-02-26T00:00:02.000Z'),
|
||||||
executionIndex: 2,
|
executionIndex: 2,
|
||||||
source: [{ previousNode: 'RootNode1' }],
|
source: [{ previousNode: 'RootNode1', previousNodeRun: 0 }],
|
||||||
data: { main: [[{ json: { result: 'from RootNode1' } }]] },
|
data: { main: [[{ json: { result: 'from RootNode1' } }]] },
|
||||||
}),
|
}),
|
||||||
createTestTaskData({
|
createTestTaskData({
|
||||||
startTime: Date.parse('2025-02-26T00:00:03.000Z'),
|
startTime: Date.parse('2025-02-26T00:00:03.000Z'),
|
||||||
executionIndex: 3,
|
executionIndex: 3,
|
||||||
source: [{ previousNode: 'RootNode2' }],
|
source: [{ previousNode: 'RootNode2', previousNodeRun: 0 }],
|
||||||
data: { main: [[{ json: { result: 'from RootNode2' } }]] },
|
data: { main: [[{ json: { result: 'from RootNode2' } }]] },
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
@@ -408,13 +408,13 @@ describe(getTreeNodeData, () => {
|
|||||||
createTestTaskData({
|
createTestTaskData({
|
||||||
startTime: Date.parse('2025-02-26T00:00:04.000Z'),
|
startTime: Date.parse('2025-02-26T00:00:04.000Z'),
|
||||||
executionIndex: 4,
|
executionIndex: 4,
|
||||||
source: [{ previousNode: 'SharedSubNode' }],
|
source: [{ previousNode: 'SharedSubNode', previousNodeRun: 0 }],
|
||||||
data: { main: [[{ json: { result: 'from SharedSubNode run 0' } }]] },
|
data: { main: [[{ json: { result: 'from SharedSubNode run 0' } }]] },
|
||||||
}),
|
}),
|
||||||
createTestTaskData({
|
createTestTaskData({
|
||||||
startTime: Date.parse('2025-02-26T00:00:05.000Z'),
|
startTime: Date.parse('2025-02-26T00:00:05.000Z'),
|
||||||
executionIndex: 5,
|
executionIndex: 5,
|
||||||
source: [{ previousNode: 'SharedSubNode' }],
|
source: [{ previousNode: 'SharedSubNode', previousNodeRun: 1 }],
|
||||||
data: { main: [[{ json: { result: 'from SharedSubNode run 1' } }]] },
|
data: { main: [[{ json: { result: 'from SharedSubNode run 1' } }]] },
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
@@ -494,7 +494,7 @@ describe(getTreeNodeData, () => {
|
|||||||
createTestTaskData({
|
createTestTaskData({
|
||||||
startTime: Date.parse('2025-02-26T00:00:02.000Z'),
|
startTime: Date.parse('2025-02-26T00:00:02.000Z'),
|
||||||
executionIndex: 2,
|
executionIndex: 2,
|
||||||
source: [{ previousNode: 'RootNode1' }],
|
source: [{ previousNode: 'RootNode1', previousNodeRun: 0 }],
|
||||||
data: { main: [[{ json: { result: 'from RootNode1' } }]] },
|
data: { main: [[{ json: { result: 'from RootNode1' } }]] },
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
@@ -502,7 +502,7 @@ describe(getTreeNodeData, () => {
|
|||||||
createTestTaskData({
|
createTestTaskData({
|
||||||
startTime: Date.parse('2025-02-26T00:00:03.000Z'),
|
startTime: Date.parse('2025-02-26T00:00:03.000Z'),
|
||||||
executionIndex: 3,
|
executionIndex: 3,
|
||||||
source: [{ previousNode: 'RootNode2' }],
|
source: [{ previousNode: 'RootNode2', previousNodeRun: 0 }],
|
||||||
data: { main: [[{ json: { result: 'from RootNode2' } }]] },
|
data: { main: [[{ json: { result: 'from RootNode2' } }]] },
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
@@ -510,13 +510,13 @@ describe(getTreeNodeData, () => {
|
|||||||
createTestTaskData({
|
createTestTaskData({
|
||||||
startTime: Date.parse('2025-02-26T00:00:04.000Z'),
|
startTime: Date.parse('2025-02-26T00:00:04.000Z'),
|
||||||
executionIndex: 4,
|
executionIndex: 4,
|
||||||
source: [{ previousNode: 'SubNodeA' }],
|
source: [{ previousNode: 'SubNodeA', previousNodeRun: 0 }],
|
||||||
data: { main: [[{ json: { result: 'from SubNodeA' } }]] },
|
data: { main: [[{ json: { result: 'from SubNodeA' } }]] },
|
||||||
}),
|
}),
|
||||||
createTestTaskData({
|
createTestTaskData({
|
||||||
startTime: Date.parse('2025-02-26T00:00:05.000Z'),
|
startTime: Date.parse('2025-02-26T00:00:05.000Z'),
|
||||||
executionIndex: 5,
|
executionIndex: 5,
|
||||||
source: [{ previousNode: 'SubNodeB' }],
|
source: [{ previousNode: 'SubNodeB', previousNodeRun: 0 }],
|
||||||
data: { main: [[{ json: { result: 'from SubNodeB' } }]] },
|
data: { main: [[{ json: { result: 'from SubNodeB' } }]] },
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -94,7 +94,6 @@ function getChildNodes(
|
|||||||
|
|
||||||
// Get the first level of children
|
// Get the first level of children
|
||||||
const connectedSubNodes = context.workflow.getParentNodes(node.name, 'ALL_NON_MAIN', 1);
|
const connectedSubNodes = context.workflow.getParentNodes(node.name, 'ALL_NON_MAIN', 1);
|
||||||
const isExecutionRoot = !isSubNodeLog(treeNode);
|
|
||||||
|
|
||||||
function isMatchedSource(source: ISourceData | null): boolean {
|
function isMatchedSource(source: ISourceData | null): boolean {
|
||||||
return (
|
return (
|
||||||
@@ -106,13 +105,12 @@ function getChildNodes(
|
|||||||
|
|
||||||
return connectedSubNodes.flatMap((subNodeName) =>
|
return connectedSubNodes.flatMap((subNodeName) =>
|
||||||
(context.data.resultData.runData[subNodeName] ?? []).flatMap((t, index) => {
|
(context.data.resultData.runData[subNodeName] ?? []).flatMap((t, index) => {
|
||||||
// At root depth, filter out node executions that weren't triggered by this node
|
// Filter out node executions that weren't triggered by this node
|
||||||
// This prevents showing duplicate executions when a sub-node is connected to multiple parents
|
// This prevents showing duplicate executions when a sub-node is connected to multiple parents
|
||||||
// Only filter nodes that have source information with valid previousNode references
|
// Only filter nodes that have source information with valid previousNode references
|
||||||
const isMatched =
|
const isMatched = t.source.some((source) => source !== null)
|
||||||
isExecutionRoot && t.source.some((source) => source !== null)
|
? t.source.some(isMatchedSource)
|
||||||
? t.source.some(isMatchedSource)
|
: runIndex === undefined || index === runIndex;
|
||||||
: runIndex === undefined || index === runIndex;
|
|
||||||
|
|
||||||
if (!isMatched) {
|
if (!isMatched) {
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -931,6 +931,7 @@ export type IExecuteFunctions = ExecuteFunctions.GetNodeParameterFn &
|
|||||||
currentNodeRunIndex: number,
|
currentNodeRunIndex: number,
|
||||||
data: INodeExecutionData[][] | ExecutionError,
|
data: INodeExecutionData[][] | ExecutionError,
|
||||||
metadata?: ITaskMetadata,
|
metadata?: ITaskMetadata,
|
||||||
|
sourceNodeRunIndex?: number,
|
||||||
): void;
|
): void;
|
||||||
|
|
||||||
addExecutionHints(...hints: NodeExecutionHint[]): void;
|
addExecutionHints(...hints: NodeExecutionHint[]): void;
|
||||||
|
|||||||
Reference in New Issue
Block a user