fix(editor): Add missing node parameter values to AI Assistant request (#10788)

This commit is contained in:
Milorad FIlipović 2024-09-17 16:14:02 +02:00 committed by GitHub
parent a3335e0ecd
commit d65ade4e92
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 678 additions and 76 deletions

View file

@ -7,6 +7,7 @@ import { useWorkflowsStore } from '@/stores/workflows.store';
import { useWorkflowsEEStore } from '@/stores/workflows.ee.store'; import { useWorkflowsEEStore } from '@/stores/workflows.ee.store';
import { useTagsStore } from '@/stores/tags.store'; import { useTagsStore } from '@/stores/tags.store';
import { createTestWorkflow } from '@/__tests__/mocks'; import { createTestWorkflow } from '@/__tests__/mocks';
import type { AssignmentCollectionValue } from 'n8n-workflow';
const getDuplicateTestWorkflow = (): IWorkflowDataUpdate => ({ const getDuplicateTestWorkflow = (): IWorkflowDataUpdate => ({
name: 'Duplicate webhook test', name: 'Duplicate webhook test',
@ -70,6 +71,163 @@ describe('useWorkflowHelpers', () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
describe('getNodeParametersWithResolvedExpressions', () => {
it('should correctly detect and resolve expressions in a regular node ', () => {
const nodeParameters = {
curlImport: '',
method: 'GET',
url: '={{ $json.name }}',
authentication: 'none',
provideSslCertificates: false,
sendQuery: false,
sendHeaders: false,
sendBody: false,
options: {},
infoMessage: '',
};
const workflowHelpers = useWorkflowHelpers({ router });
const resolvedParameters =
workflowHelpers.getNodeParametersWithResolvedExpressions(nodeParameters);
expect(resolvedParameters.url).toHaveProperty('resolvedExpressionValue');
});
it('should correctly detect and resolve expressions in a node with assignments (set node) ', () => {
const nodeParameters = {
mode: 'manual',
duplicateItem: false,
assignments: {
assignments: [
{
id: '25d2d012-089b-424d-bfc6-642982a0711f',
name: 'date',
value:
"={{ DateTime.fromFormat('2023-12-12', 'dd/MM/yyyy').toISODate().plus({7, 'days' }) }}",
type: 'number',
},
],
},
includeOtherFields: false,
options: {},
};
const workflowHelpers = useWorkflowHelpers({ router });
const resolvedParameters =
workflowHelpers.getNodeParametersWithResolvedExpressions(nodeParameters);
expect(resolvedParameters).toHaveProperty('assignments');
const assignments = resolvedParameters.assignments as AssignmentCollectionValue;
expect(assignments).toHaveProperty('assignments');
expect(assignments.assignments[0].value).toHaveProperty('resolvedExpressionValue');
});
it('should correctly detect and resolve expressions in a node with filter component', () => {
const nodeParameters = {
mode: 'rules',
rules: {
values: [
{
conditions: {
options: {
caseSensitive: true,
leftValue: '',
typeValidation: 'strict',
version: 2,
},
conditions: [
{
leftValue: "={{ $('Edit Fields 1').item.json.name }}",
rightValue: 12,
operator: {
type: 'number',
operation: 'equals',
},
},
],
combinator: 'and',
},
renameOutput: false,
},
],
},
looseTypeValidation: false,
options: {},
};
const workflowHelpers = useWorkflowHelpers({ router });
const resolvedParameters = workflowHelpers.getNodeParametersWithResolvedExpressions(
nodeParameters,
) as typeof nodeParameters;
expect(resolvedParameters).toHaveProperty('rules');
expect(resolvedParameters.rules).toHaveProperty('values');
expect(resolvedParameters.rules.values[0].conditions.conditions[0].leftValue).toHaveProperty(
'resolvedExpressionValue',
);
});
it('should correctly detect and resolve expressions in a node with resource locator component', () => {
const nodeParameters = {
authentication: 'oAuth2',
resource: 'sheet',
operation: 'read',
documentId: {
__rl: true,
value: "={{ $('Edit Fields').item.json.document }}",
mode: 'id',
},
sheetName: {
__rl: true,
value: "={{ $('Edit Fields').item.json.sheet }}",
mode: 'id',
},
filtersUI: {},
combineFilters: 'AND',
options: {},
};
const workflowHelpers = useWorkflowHelpers({ router });
const resolvedParameters = workflowHelpers.getNodeParametersWithResolvedExpressions(
nodeParameters,
) as typeof nodeParameters;
expect(resolvedParameters.documentId.value).toHaveProperty('resolvedExpressionValue');
expect(resolvedParameters.sheetName.value).toHaveProperty('resolvedExpressionValue');
});
it('should correctly detect and resolve expressions in a node with resource mapper component', () => {
const nodeParameters = {
authentication: 'oAuth2',
resource: 'sheet',
operation: 'read',
documentId: {
__rl: true,
value: '1BAjxEhlUu5tXDCMQcjqjguIZDFuct3FYkdo7flxl3yc',
mode: 'list',
cachedResultName: 'Mapping sheet',
cachedResultUrl:
'https://docs.google.com/spreadsheets/d/1BAjxEhlUu5tXDCMQcjqjguIZDFuct3FYkdo7flxl3yc/edit?usp=drivesdk',
},
sheetName: {
__rl: true,
value: 'gid=0',
mode: 'list',
cachedResultName: 'Users',
cachedResultUrl:
'https://docs.google.com/spreadsheets/d/1BAjxEhlUu5tXDCMQcjqjguIZDFuct3FYkdo7flxl3yc/edit#gid=0',
},
filtersUI: {
values: [
{
lookupColumn: 'First name',
lookupValue: "={{ $('Edit Fields 1').item.json.userName }}",
},
],
},
combineFilters: 'AND',
options: {},
};
const workflowHelpers = useWorkflowHelpers({ router });
const resolvedParameters = workflowHelpers.getNodeParametersWithResolvedExpressions(
nodeParameters,
) as typeof nodeParameters;
expect(resolvedParameters.filtersUI.values[0].lookupValue).toHaveProperty(
'resolvedExpressionValue',
);
});
});
describe('saveAsNewWorkflow', () => { describe('saveAsNewWorkflow', () => {
it('should respect `resetWebhookUrls: false` when duplicating workflows', async () => { it('should respect `resetWebhookUrls: false` when duplicating workflows', async () => {
const workflow = getDuplicateTestWorkflow(); const workflow = getDuplicateTestWorkflow();

View file

@ -693,6 +693,42 @@ export function useWorkflowHelpers(options: { router: ReturnType<typeof useRoute
return NodeHelpers.getNodeWebhookUrl(baseUrl, workflowId, node, path, isFullPath); return NodeHelpers.getNodeWebhookUrl(baseUrl, workflowId, node, path, isFullPath);
} }
/**
* Returns a copy of provided node parameters with added resolvedExpressionValue
* @param nodeParameters
* @returns
*/
function getNodeParametersWithResolvedExpressions(
nodeParameters: INodeParameters,
): INodeParameters {
function recurse(currentObj: INodeParameters, currentPath: string): INodeParameters {
const newObj: INodeParameters = {};
for (const key in currentObj) {
const value = currentObj[key as keyof typeof currentObj];
const path = currentPath ? `${currentPath}.${key}` : key;
if (typeof value === 'object' && value !== null) {
newObj[key] = recurse(value as INodeParameters, path);
} else if (typeof value === 'string' && String(value).startsWith('=')) {
// Resolve the expression if it is one
let resolved;
try {
resolved = resolveExpression(value, undefined, { isForCredential: false });
} catch (error) {
resolved = `Error in expression: "${error.message}"`;
}
newObj[key] = {
value,
resolvedExpressionValue: String(resolved),
};
} else {
newObj[key] = value;
}
}
return newObj;
}
return recurse(nodeParameters, '');
}
function resolveExpression( function resolveExpression(
expression: string, expression: string,
siblingParameters: INodeParameters = {}, siblingParameters: INodeParameters = {},
@ -1159,5 +1195,6 @@ export function useWorkflowHelpers(options: { router: ReturnType<typeof useRoute
getWorkflowProjectRole, getWorkflowProjectRole,
promptSaveUnsavedWorkflowChanges, promptSaveUnsavedWorkflowChanges,
initState, initState,
getNodeParametersWithResolvedExpressions,
}; };
} }

View file

@ -49,6 +49,7 @@ vi.mock('vue-router', () => ({
name: ENABLED_VIEWS[0], name: ENABLED_VIEWS[0],
}), }),
), ),
useRouter: vi.fn(),
RouterLink: vi.fn(), RouterLink: vi.fn(),
})); }));

View file

@ -17,7 +17,7 @@ import { useRoute } from 'vue-router';
import { useSettingsStore } from './settings.store'; import { useSettingsStore } from './settings.store';
import { assert } from '@/utils/assert'; import { assert } from '@/utils/assert';
import { useWorkflowsStore } from './workflows.store'; import { useWorkflowsStore } from './workflows.store';
import type { ICredentialType, INodeParameters } from 'n8n-workflow'; import type { IDataObject, ICredentialType, INodeParameters } from 'n8n-workflow';
import { deepCopy } from 'n8n-workflow'; import { deepCopy } from 'n8n-workflow';
import { ndvEventBus, codeNodeEditorEventBus } from '@/event-bus'; import { ndvEventBus, codeNodeEditorEventBus } from '@/event-bus';
import { useNDVStore } from './ndv.store'; import { useNDVStore } from './ndv.store';
@ -27,7 +27,8 @@ import {
getNodeAuthOptions, getNodeAuthOptions,
getReferencedNodes, getReferencedNodes,
getNodesSchemas, getNodesSchemas,
pruneNodeProperties, processNodeForAssistant,
isNodeReferencingInputData,
} from '@/utils/nodeTypesUtils'; } from '@/utils/nodeTypesUtils';
import { useNodeTypesStore } from './nodeTypes.store'; import { useNodeTypesStore } from './nodeTypes.store';
import { usePostHog } from './posthog.store'; import { usePostHog } from './posthog.store';
@ -421,6 +422,16 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
const availableAuthOptions = getNodeAuthOptions(nodeType); const availableAuthOptions = getNodeAuthOptions(nodeType);
authType = availableAuthOptions.find((option) => option.value === credentialInUse); authType = availableAuthOptions.find((option) => option.value === credentialInUse);
} }
let nodeInputData: { inputNodeName?: string; inputData?: IDataObject } | undefined = undefined;
const ndvInput = ndvStore.ndvInputData;
if (isNodeReferencingInputData(context.node) && ndvInput?.length) {
const inputData = ndvStore.ndvInputData[0].json;
const inputNodeName = ndvStore.input.nodeName;
nodeInputData = {
inputNodeName,
inputData,
};
}
addLoadingAssistantMessage(locale.baseText('aiAssistant.thinkingSteps.analyzingError')); addLoadingAssistantMessage(locale.baseText('aiAssistant.thinkingSteps.analyzingError'));
openChat(); openChat();
@ -435,7 +446,8 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
firstName: usersStore.currentUser?.firstName ?? '', firstName: usersStore.currentUser?.firstName ?? '',
}, },
error: context.error, error: context.error,
node: pruneNodeProperties(context.node, ['position']), node: processNodeForAssistant(context.node, ['position']),
nodeInputData,
executionSchema: schemas, executionSchema: schemas,
authType, authType,
}, },

View file

@ -1,8 +1,8 @@
import type { Schema } from '@/Interface'; import type { Schema } from '@/Interface';
import type { INode, INodeParameters } from 'n8n-workflow'; import type { IDataObject, INode, INodeParameters } from 'n8n-workflow';
export namespace ChatRequest { export namespace ChatRequest {
interface NodeExecutionSchema { export interface NodeExecutionSchema {
nodeName: string; nodeName: string;
schema: Schema; schema: Schema;
} }
@ -21,6 +21,7 @@ export namespace ChatRequest {
stack?: string; stack?: string;
}; };
node: INode; node: INode;
nodeInputData?: IDataObject;
} }
export interface InitErrorHelper extends ErrorContext, WorkflowContext { export interface InitErrorHelper extends ErrorContext, WorkflowContext {

View file

@ -0,0 +1,383 @@
import { describe, it, expect } from 'vitest';
import { getReferencedNodes } from '../nodeTypesUtils';
import type { INode } from 'n8n-workflow';
const referencedNodesTestCases: Array<{ caseName: string; node: INode; expected: string[] }> = [
{
caseName: 'Should return an empty array if no referenced nodes',
node: {
parameters: {
curlImport: '',
method: 'GET',
url: 'https://httpbin.org/get1',
authentication: 'none',
provideSslCertificates: false,
sendQuery: false,
sendHeaders: false,
sendBody: false,
options: {},
infoMessage: '',
},
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.2,
position: [220, 220],
id: 'edc36001-aee7-4052-b66e-cf127f4b6ea5',
name: 'HTTP Request',
},
expected: [],
},
{
caseName: 'Should return an array of references for regular node',
node: {
parameters: {
authentication: 'oAuth2',
resource: 'sheet',
operation: 'read',
documentId: {
__rl: true,
value: "={{ $('Edit Fields').item.json.document }}",
mode: 'id',
},
sheetName: {
__rl: true,
value: "={{ $('Edit Fields 2').item.json.sheet }}",
mode: 'id',
},
filtersUI: {},
combineFilters: 'AND',
options: {},
},
type: 'n8n-nodes-base.googleSheets',
typeVersion: 4.5,
position: [440, 0],
id: '9a95ad27-06cf-4076-af6b-52846a109a8b',
name: 'Google Sheets',
credentials: {
googleSheetsOAuth2Api: {
id: '8QEpi028oHDLXntS',
name: 'milorad@n8n.io',
},
},
},
expected: ['Edit Fields', 'Edit Fields 2'],
},
{
caseName: 'Should return an array of references for set node',
node: {
parameters: {
mode: 'manual',
duplicateItem: false,
assignments: {
assignments: [
{
id: '135e0eb0-f412-430d-8990-731c57cf43ae',
name: 'document',
value: "={{ $('Edit Fields 2').item.json.document}}",
type: 'string',
},
],
},
includeOtherFields: false,
options: {},
},
type: 'n8n-nodes-base.set',
typeVersion: 3.4,
position: [560, -140],
id: '7306745f-ba8c-451d-ae1a-c627f60fbdd3',
name: 'Edit Fields 2',
},
expected: ['Edit Fields 2'],
},
{
caseName: 'Should handle expressions with single quotes, double quotes and backticks',
node: {
parameters: {
authentication: 'oAuth2',
resource: 'sheet',
operation: 'read',
documentId: {
__rl: true,
value: "={{ $('Edit Fields').item.json.document }}",
mode: 'id',
},
sheetName: {
__rl: true,
value: '={{ $("Edit Fields 2").item.json.sheet }}',
mode: 'id',
},
rowName: {
__rl: true,
value: '={{ $(`Edit Fields 3`).item.json.row }}',
mode: 'id',
},
filtersUI: {},
combineFilters: 'AND',
options: {},
},
type: 'n8n-nodes-base.googleSheets',
typeVersion: 4.5,
position: [440, 0],
id: '9a95ad27-06cf-4076-af6b-52846a109a8b',
name: 'Google Sheets',
credentials: {
googleSheetsOAuth2Api: {
id: '8QEpi028oHDLXntS',
name: 'milorad@n8n.io',
},
},
},
expected: ['Edit Fields', 'Edit Fields 2', 'Edit Fields 3'],
},
{
caseName: 'Should only add one reference for each referenced node',
node: {
parameters: {
authentication: 'oAuth2',
resource: 'sheet',
operation: 'read',
documentId: {
__rl: true,
value: "={{ $('Edit Fields').item.json.document }}",
mode: 'id',
},
sheetName: {
__rl: true,
value: "={{ $('Edit Fields').item.json.sheet }}",
mode: 'id',
},
filtersUI: {},
combineFilters: 'AND',
options: {},
},
type: 'n8n-nodes-base.googleSheets',
typeVersion: 4.5,
position: [440, 0],
id: '9a95ad27-06cf-4076-af6b-52846a109a8b',
name: 'Google Sheets',
credentials: {
googleSheetsOAuth2Api: {
id: '8QEpi028oHDLXntS',
name: 'milorad@n8n.io',
},
},
},
expected: ['Edit Fields'],
},
{
caseName: 'Should handle multiple node references in one expression',
node: {
parameters: {
curlImport: '',
method: 'GET',
url: "={{ $('Edit Fields').item.json.one }} {{ $('Edit Fields 2').item.json.two }} {{ $('Edit Fields').item.json.three }}",
authentication: 'none',
provideSslCertificates: false,
sendQuery: false,
sendHeaders: false,
sendBody: false,
options: {},
infoMessage: '',
},
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.2,
position: [220, 220],
id: 'edc36001-aee7-4052-b66e-cf127f4b6ea5',
name: 'HTTP Request',
},
expected: ['Edit Fields', 'Edit Fields 2'],
},
{
caseName: 'Should respect whitespace around node references',
node: {
parameters: {
curlImport: '',
method: 'GET',
url: "={{ $(' Edit Fields ').item.json.one }}",
authentication: 'none',
provideSslCertificates: false,
sendQuery: false,
sendHeaders: false,
sendBody: false,
options: {},
infoMessage: '',
},
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.2,
position: [220, 220],
id: 'edc36001-aee7-4052-b66e-cf127f4b6ea5',
name: 'HTTP Request',
},
expected: [' Edit Fields '],
},
{
caseName: 'Should ignore whitespace inside expressions',
node: {
parameters: {
curlImport: '',
method: 'GET',
url: "={{ $( 'Edit Fields' ).item.json.one }}",
authentication: 'none',
provideSslCertificates: false,
sendQuery: false,
sendHeaders: false,
sendBody: false,
options: {},
infoMessage: '',
},
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.2,
position: [220, 220],
id: 'edc36001-aee7-4052-b66e-cf127f4b6ea5',
name: 'HTTP Request',
},
expected: ['Edit Fields'],
},
{
caseName: 'Should ignore special characters in node references',
node: {
parameters: {
curlImport: '',
method: 'GET',
url: "={{ $( 'Ignore ' this' ).item.json.document }",
authentication: 'none',
provideSslCertificates: false,
sendQuery: false,
sendHeaders: false,
sendBody: false,
options: {},
infoMessage: '',
},
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.2,
position: [220, 220],
id: 'edc36001-aee7-4052-b66e-cf127f4b6ea5',
name: 'HTTP Request',
},
expected: [],
},
{
caseName: 'Should correctly detect node names that contain single quotes',
node: {
parameters: {
curlImport: '',
method: 'GET',
// In order to carry over backslashes to test function, the string needs to be double escaped
url: "={{ $('Edit \\'Fields\\' 2').item.json.name }}",
authentication: 'none',
provideSslCertificates: false,
sendQuery: false,
sendHeaders: false,
sendBody: false,
options: {},
infoMessage: '',
},
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.2,
position: [220, 220],
id: 'edc36001-aee7-4052-b66e-cf127f4b6ea5',
name: 'HTTP Request',
},
expected: ["Edit 'Fields' 2"],
},
{
caseName: 'Should correctly detect node names with inner backticks',
node: {
parameters: {
curlImport: '',
method: 'GET',
url: "={{ $('Edit `Fields` 2').item.json.name }}",
authentication: 'none',
provideSslCertificates: false,
sendQuery: false,
sendHeaders: false,
sendBody: false,
options: {},
infoMessage: '',
},
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.2,
position: [220, 220],
id: 'edc36001-aee7-4052-b66e-cf127f4b6ea5',
name: 'HTTP Request',
},
expected: ['Edit `Fields` 2'],
},
{
caseName: 'Should correctly detect node names with inner escaped backticks',
node: {
parameters: {
curlImport: '',
method: 'GET',
url: '={{ $(`Edit \\`Fields\\` 2`).item.json.name }}',
authentication: 'none',
provideSslCertificates: false,
sendQuery: false,
sendHeaders: false,
sendBody: false,
options: {},
infoMessage: '',
},
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.2,
position: [220, 220],
id: 'edc36001-aee7-4052-b66e-cf127f4b6ea5',
name: 'HTTP Request',
},
expected: ['Edit `Fields` 2'],
},
{
caseName: 'Should correctly detect node names with inner escaped double quotes',
node: {
parameters: {
curlImport: '',
method: 'GET',
// In order to carry over backslashes to test function, the string needs to be double escaped
url: '={{ $("Edit \\"Fields\\" 2").item.json.name }}',
authentication: 'none',
provideSslCertificates: false,
sendQuery: false,
sendHeaders: false,
sendBody: false,
options: {},
infoMessage: '',
},
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.2,
position: [220, 220],
id: 'edc36001-aee7-4052-b66e-cf127f4b6ea5',
name: 'HTTP Request',
},
expected: ['Edit "Fields" 2'],
},
{
caseName: 'Should not detect invalid expressions',
node: {
parameters: {
curlImport: '',
method: 'GET',
// String not closed properly
url: "={{ $('Edit ' fields').item.json.document }",
// Mixed quotes
url2: '{{ $("Edit \'Fields" 2").item.json.name }}',
url3: '{{ $("Edit `Fields" 2").item.json.name }}',
// Quotes not escaped
url4: '{{ $("Edit "Fields" 2").item.json.name }}',
url5: "{{ $('Edit 'Fields' 2').item.json.name }}",
url6: '{{ $(`Edit `Fields` 2`).item.json.name }}',
},
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.2,
position: [220, 220],
id: 'edc36001-aee7-4052-b66e-cf127f4b6ea5',
name: 'HTTP Request',
},
expected: [],
},
];
describe.each(referencedNodesTestCases)('getReferencedNodes', (testCase) => {
const caseName = testCase.caseName;
it(`${caseName}`, () => {
expect(getReferencedNodes(testCase.node)).toEqual(testCase.expected);
});
});

View file

@ -5,10 +5,10 @@ import type {
ITemplatesNode, ITemplatesNode,
IVersionNode, IVersionNode,
NodeAuthenticationOption, NodeAuthenticationOption,
Schema,
SimplifiedNodeType, SimplifiedNodeType,
} from '@/Interface'; } from '@/Interface';
import { useDataSchema } from '@/composables/useDataSchema'; import { useDataSchema } from '@/composables/useDataSchema';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { import {
CORE_NODES_CATEGORY, CORE_NODES_CATEGORY,
MAIN_AUTH_FIELD_NAME, MAIN_AUTH_FIELD_NAME,
@ -20,20 +20,22 @@ import { i18n as locale } from '@/plugins/i18n';
import { useCredentialsStore } from '@/stores/credentials.store'; import { useCredentialsStore } from '@/stores/credentials.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import type { ChatRequest } from '@/types/assistant.types';
import { isResourceLocatorValue } from '@/utils/typeGuards'; import { isResourceLocatorValue } from '@/utils/typeGuards';
import { isJsonKeyObject } from '@/utils/typesUtils'; import { isJsonKeyObject } from '@/utils/typesUtils';
import type { import {
AssignmentCollectionValue, deepCopy,
IDataObject, type IDataObject,
INode, type INode,
INodeCredentialDescription, type INodeCredentialDescription,
INodeExecutionData, type INodeExecutionData,
INodeProperties, type INodeProperties,
INodeTypeDescription, type INodeTypeDescription,
NodeParameterValueType, type NodeParameterValueType,
ResourceMapperField, type ResourceMapperField,
Themed, type Themed,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { useRouter } from 'vue-router';
/* /*
Constants and utility functions mainly used to get information about Constants and utility functions mainly used to get information about
@ -503,9 +505,9 @@ export const getNodeIconColor = (
/** /**
Regular expression to extract the node names from the expressions in the template. Regular expression to extract the node names from the expressions in the template.
Example: $(expression) => expression Supports single quotes, double quotes, and backticks.
*/ */
const entityRegex = /\$\((['"])(.*?)\1\)/g; const entityRegex = /\$\(\s*(\\?["'`])((?:\\.|(?!\1)[^\\])*)\1\s*\)/g;
/** /**
* Extract the node names from the expressions in the template. * Extract the node names from the expressions in the template.
@ -520,81 +522,89 @@ function extractNodeNames(template: string): string[] {
} }
/** /**
* Extract the node names from the expressions in the node parameters. * Unescape quotes in the string. Supports single quotes, double quotes, and backticks.
*/ */
export function getReferencedNodes(node: INode): string[] { export function unescapeQuotes(str: string): string {
const referencedNodes: string[] = []; return str.replace(/\\(['"`])/g, '$1');
if (!node) {
return referencedNodes;
}
// Special case for code node
if (node.type === 'n8n-nodes-base.set' && node.parameters.assignments) {
const assignments = node.parameters.assignments as AssignmentCollectionValue;
if (assignments.assignments?.length) {
assignments.assignments.forEach((assignment) => {
if (assignment.name && assignment.value && String(assignment.value).startsWith('=')) {
const nodeNames = extractNodeNames(String(assignment.value));
if (nodeNames.length) {
referencedNodes.push(...nodeNames);
}
}
});
}
} else {
Object.values(node.parameters).forEach((value) => {
if (!value) {
return;
}
let strValue = String(value);
// Handle resource locator
if (typeof value === 'object' && 'value' in value) {
strValue = String(value.value);
}
if (strValue.startsWith('=')) {
const nodeNames = extractNodeNames(strValue);
if (nodeNames.length) {
referencedNodes.push(...nodeNames);
}
}
});
}
return referencedNodes;
} }
/** /**
* Remove properties from a node based on the provided list of property names. * Extract the node names from the expressions in the node parameters.
* Reruns a new node object with the properties removed.
*/ */
export function pruneNodeProperties(node: INode, propsToRemove: string[]): INode { export function getReferencedNodes(node: INode): string[] {
const prunedNode = { ...node }; const referencedNodes: Set<string> = new Set();
if (!node) {
return [];
}
// Go through all parameters and check if they contain expressions on any level
for (const key in node.parameters) {
let names: string[] = [];
if (
node.parameters[key] &&
typeof node.parameters[key] === 'object' &&
Object.keys(node.parameters[key]).length
) {
names = extractNodeNames(JSON.stringify(node.parameters[key]));
} else if (typeof node.parameters[key] === 'string' && node.parameters[key]) {
names = extractNodeNames(node.parameters[key]);
}
if (names.length) {
names
.map((name) => unescapeQuotes(name))
.forEach((name) => {
referencedNodes.add(name);
});
}
}
return referencedNodes.size ? Array.from(referencedNodes) : [];
}
/**
* Processes node object before sending it to AI assistant
* - Removes unnecessary properties
* - Extracts expressions from the parameters and resolves them
* @param node original node object
* @param propsToRemove properties to remove from the node object
* @returns processed node
*/
export function processNodeForAssistant(node: INode, propsToRemove: string[]): INode {
// Make a copy of the node object so we don't modify the original
const nodeForLLM = deepCopy(node);
propsToRemove.forEach((key) => { propsToRemove.forEach((key) => {
delete prunedNode[key as keyof INode]; delete nodeForLLM[key as keyof INode];
}); });
return prunedNode; const workflowHelpers = useWorkflowHelpers({ router: useRouter() });
const resolvedParameters = workflowHelpers.getNodeParametersWithResolvedExpressions(
nodeForLLM.parameters,
);
nodeForLLM.parameters = resolvedParameters;
return nodeForLLM;
}
export function isNodeReferencingInputData(node: INode): boolean {
const parametersString = JSON.stringify(node.parameters);
const references = ['$json', '$input', '$binary'];
return references.some((ref) => parametersString.includes(ref));
} }
/** /**
* Get the schema for the referenced nodes as expected by the AI assistant * Get the schema for the referenced nodes as expected by the AI assistant
* @param nodeNames The names of the nodes to get the schema for * @param nodeNames The names of the nodes to get the schema for
* @returns An array of objects containing the node name and the schema * @returns An array of NodeExecutionSchema objects
*/ */
export function getNodesSchemas(nodeNames: string[]) { export function getNodesSchemas(nodeNames: string[]) {
return nodeNames.map((name) => { const schemas: ChatRequest.NodeExecutionSchema[] = [];
for (const name of nodeNames) {
const node = useWorkflowsStore().getNodeByName(name); const node = useWorkflowsStore().getNodeByName(name);
if (!node) { if (!node) {
return { continue;
nodeName: name,
schema: {} as Schema,
};
} }
const { getSchemaForExecutionData, getInputDataWithPinned } = useDataSchema(); const { getSchemaForExecutionData, getInputDataWithPinned } = useDataSchema();
const schema = getSchemaForExecutionData( const schema = getSchemaForExecutionData(executionDataToJson(getInputDataWithPinned(node)));
executionDataToJson(getInputDataWithPinned(node)), schemas.push({
true,
);
return {
nodeName: node.name, nodeName: node.name,
schema, schema,
}; });
}); }
return schemas;
} }