diff --git a/cypress/composables/ndv.ts b/cypress/composables/ndv.ts
index 146a58bd24..7fbf07e3a5 100644
--- a/cypress/composables/ndv.ts
+++ b/cypress/composables/ndv.ts
@@ -208,6 +208,21 @@ export function typeIntoFixedCollectionItem(collectionName: string, index: numbe
);
}
+export function selectResourceLocatorAddResourceItem(
+ resourceLocator: string,
+ expectedText: string,
+) {
+ clickResourceLocatorInput(resourceLocator);
+
+ // getVisiblePopper().findChildByTestId('rlc-item-add-resource').eq(0).should('exist');
+ getVisiblePopper()
+ .findChildByTestId('rlc-item-add-resource')
+ .eq(0)
+ .find('span')
+ .should('contain.text', expectedText)
+ .click();
+}
+
export function selectResourceLocatorItem(
resourceLocator: string,
index: number,
diff --git a/cypress/e2e/39-projects.cy.ts b/cypress/e2e/39-projects.cy.ts
index 196dc060b1..7c54b367da 100644
--- a/cypress/e2e/39-projects.cy.ts
+++ b/cypress/e2e/39-projects.cy.ts
@@ -3,7 +3,7 @@ import { setCredentialValues } from '../composables/modals/credential-modal';
import {
clickCreateNewCredential,
getNdvContainer,
- selectResourceLocatorItem,
+ selectResourceLocatorAddResourceItem,
} from '../composables/ndv';
import * as projects from '../composables/projects';
import {
@@ -299,9 +299,11 @@ describe('Projects', { disableAutoLogin: true }, () => {
workflowPage.actions.saveWorkflowOnButtonClick();
workflowPage.actions.addNodeToCanvas('Execute Workflow', true, true);
+ // This mock fails when running with `test:e2e:dev` but works with `test:e2e:ui`,
+ // at least on macOS at version 1.94.0. ¯\_(ツ)_/¯
cy.window().then((win) => cy.stub(win, 'open').callsFake((url) => cy.visit(url)));
- selectResourceLocatorItem('workflowId', 0, 'Create a');
+ selectResourceLocatorAddResourceItem('workflowId', 'Create a');
// Need to wait for the trigger node to auto-open after a delay
getNdvContainer().should('be.visible');
cy.get('body').type('{esc}');
diff --git a/cypress/e2e/45-workflow-selector-parameter.cy.ts b/cypress/e2e/45-workflow-selector-parameter.cy.ts
index 40e5ca2488..f307b37c99 100644
--- a/cypress/e2e/45-workflow-selector-parameter.cy.ts
+++ b/cypress/e2e/45-workflow-selector-parameter.cy.ts
@@ -27,7 +27,9 @@ describe('Workflow Selector Parameter', () => {
getVisiblePopper()
.should('have.length', 1)
.findChildByTestId('rlc-item')
- .should('have.length', 3);
+ .should('have.length', 2);
+
+ getVisiblePopper().findChildByTestId('rlc-item-add-resource').should('have.length', 1);
});
it('should show required parameter warning', () => {
@@ -44,8 +46,8 @@ describe('Workflow Selector Parameter', () => {
getVisiblePopper()
.should('have.length', 1)
.findChildByTestId('rlc-item')
- .should('have.length', 2)
- .eq(1)
+ .should('have.length', 1)
+ .eq(0)
.click();
ndv.getters
@@ -91,14 +93,14 @@ describe('Workflow Selector Parameter', () => {
ndv.getters.resourceLocator('workflowId').should('be.visible');
ndv.getters.resourceLocatorInput('workflowId').click();
- getVisiblePopper().findChildByTestId('rlc-item').eq(0).should('exist');
+ getVisiblePopper().findChildByTestId('rlc-item-add-resource').eq(0).should('exist');
getVisiblePopper()
- .findChildByTestId('rlc-item')
+ .findChildByTestId('rlc-item-add-resource')
.eq(0)
.find('span')
.should('contain.text', 'Create a'); // Due to some inconsistency we're sometimes in a project and sometimes not, this covers both cases
- getVisiblePopper().findChildByTestId('rlc-item').eq(0).click();
+ getVisiblePopper().findChildByTestId('rlc-item-add-resource').eq(0).click();
cy.wait('@createSubworkflow').then((interception) => {
expect(interception.request.body).to.have.property('name').that.includes('Sub-Workflow');
diff --git a/cypress/e2e/48-subworkflow-inputs.cy.ts b/cypress/e2e/48-subworkflow-inputs.cy.ts
index bde27c9770..fa8d3e4510 100644
--- a/cypress/e2e/48-subworkflow-inputs.cy.ts
+++ b/cypress/e2e/48-subworkflow-inputs.cy.ts
@@ -8,6 +8,7 @@ import {
getParameterInputByName,
populateFixedCollection,
selectResourceLocatorItem,
+ selectResourceLocatorAddResourceItem,
typeIntoFixedCollectionItem,
clickWorkflowCardContent,
assertOutputTableContent,
@@ -63,7 +64,7 @@ describe('Sub-workflow creation and typed usage', () => {
openedUrl = url;
});
});
- selectResourceLocatorItem('workflowId', 0, 'Create a');
+ selectResourceLocatorAddResourceItem('workflowId', 'Create a');
cy.then(() => cy.visit(openedUrl));
// **************************
// NAVIGATE TO CHILD WORKFLOW
@@ -142,7 +143,7 @@ describe('Sub-workflow creation and typed usage', () => {
cy.window().then((win) => {
cy.stub(win, 'open').callsFake((url) => {
cy.visit(url);
- selectResourceLocatorItem('workflowId', 0, 'Create a');
+ selectResourceLocatorAddResourceItem('workflowId', 'Create a');
openNode('When Executed by Another Workflow');
@@ -218,7 +219,7 @@ function validateAndReturnToParent(targetChild: string, offset: number, fields:
// Note that outside of e2e tests this will be pre-selected correctly.
// Due to our workaround to remain in the same tab we need to select the correct tab manually
- selectResourceLocatorItem('workflowId', offset, targetChild);
+ selectResourceLocatorItem('workflowId', offset - 1, targetChild);
clickExecuteNode();
diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreInMemory/VectorStoreInMemory.node.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreInMemory/VectorStoreInMemory.node.ts
index 2f949db872..d5f6f4fb55 100644
--- a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreInMemory/VectorStoreInMemory.node.ts
+++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreInMemory/VectorStoreInMemory.node.ts
@@ -1,17 +1,28 @@
+import type { Embeddings } from '@langchain/core/embeddings';
import type { MemoryVectorStore } from 'langchain/vectorstores/memory';
-import type { INodeProperties } from 'n8n-workflow';
+import {
+ type INodeProperties,
+ type ILoadOptionsFunctions,
+ type INodeListSearchResult,
+ type IDataObject,
+ type NodeParameterValueType,
+ type IExecuteFunctions,
+ type ISupplyDataFunctions,
+ ApplicationError,
+} from 'n8n-workflow';
import { createVectorStoreNode } from '../shared/createVectorStoreNode/createVectorStoreNode';
import { MemoryVectorStoreManager } from '../shared/MemoryManager/MemoryVectorStoreManager';
+const warningBanner: INodeProperties = {
+ displayName:
+ 'For experimental use only: Data is stored in memory and will be lost if n8n restarts. Data may also be cleared if available memory gets low, and is accessible to all users of this instance. More info',
+ name: 'notice',
+ type: 'notice',
+ default: '',
+};
+
const insertFields: INodeProperties[] = [
- {
- displayName:
- 'For experimental use only: Data is stored in memory and will be lost if n8n restarts. Data may also be cleared if available memory gets low. More info',
- name: 'notice',
- type: 'notice',
- default: '',
- },
{
displayName: 'Clear Store',
name: 'clearStore',
@@ -19,8 +30,28 @@ const insertFields: INodeProperties[] = [
default: false,
description: 'Whether to clear the store before inserting new data',
},
+ warningBanner,
];
+const DEFAULT_MEMORY_KEY = 'vector_store_key';
+
+function getMemoryKey(context: IExecuteFunctions | ISupplyDataFunctions, itemIndex: number) {
+ const node = context.getNode();
+ if (node.typeVersion <= 1.1) {
+ const memoryKeyParam = context.getNodeParameter('memoryKey', itemIndex) as string;
+ const workflowId = context.getWorkflow().id;
+
+ return `${workflowId}__${memoryKeyParam}`;
+ } else {
+ const memoryKeyParam = context.getNodeParameter('memoryKey', itemIndex) as {
+ mode: string;
+ value: string;
+ };
+
+ return memoryKeyParam.value;
+ }
+}
+
export class VectorStoreInMemory extends createVectorStoreNode({
meta: {
displayName: 'Simple Vector Store',
@@ -37,27 +68,119 @@ export class VectorStoreInMemory extends createVectorStoreNode {
+ const vectorStoreSingleton = MemoryVectorStoreManager.getInstance(
+ {} as Embeddings, // Real Embeddings are provided when executing the node
+ this.logger,
+ );
+
+ const searchOptions: INodeListSearchResult['results'] = vectorStoreSingleton
+ .getMemoryKeysList()
+ .map((key) => {
+ return {
+ name: key,
+ value: key,
+ };
+ });
+
+ let results = searchOptions;
+ if (filter) {
+ results = results.filter((option) => option.name.includes(filter));
+ }
+
+ return {
+ results,
+ };
+ },
+ },
+ actionHandler: {
+ async createVectorStore(
+ this: ILoadOptionsFunctions,
+ payload: string | IDataObject | undefined,
+ ): Promise {
+ if (!payload || typeof payload === 'string') {
+ throw new ApplicationError('Invalid payload type');
+ }
+
+ const { name } = payload;
+
+ const vectorStoreSingleton = MemoryVectorStoreManager.getInstance(
+ {} as Embeddings, // Real Embeddings are provided when executing the node
+ this.logger,
+ );
+
+ const memoryKey = !!name ? (name as string) : DEFAULT_MEMORY_KEY;
+ await vectorStoreSingleton.getVectorStore(memoryKey);
+
+ return memoryKey;
+ },
+ },
+ },
insertFields,
- loadFields: [],
- retrieveFields: [],
+ loadFields: [warningBanner],
+ retrieveFields: [warningBanner],
async getVectorStoreClient(context, _filter, embeddings, itemIndex) {
- const workflowId = context.getWorkflow().id;
- const memoryKey = context.getNodeParameter('memoryKey', itemIndex) as string;
+ const memoryKey = getMemoryKey(context, itemIndex);
const vectorStoreSingleton = MemoryVectorStoreManager.getInstance(embeddings, context.logger);
- return await vectorStoreSingleton.getVectorStore(`${workflowId}__${memoryKey}`);
+ return await vectorStoreSingleton.getVectorStore(memoryKey);
},
async populateVectorStore(context, embeddings, documents, itemIndex) {
- const memoryKey = context.getNodeParameter('memoryKey', itemIndex) as string;
+ const memoryKey = getMemoryKey(context, itemIndex);
const clearStore = context.getNodeParameter('clearStore', itemIndex) as boolean;
- const workflowId = context.getWorkflow().id;
const vectorStoreInstance = MemoryVectorStoreManager.getInstance(embeddings, context.logger);
- await vectorStoreInstance.addDocuments(`${workflowId}__${memoryKey}`, documents, clearStore);
+ await vectorStoreInstance.addDocuments(memoryKey, documents, clearStore);
},
}) {}
diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/MemoryManager/MemoryVectorStoreManager.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/MemoryManager/MemoryVectorStoreManager.ts
index 7fad05bce7..bca19dc8c5 100644
--- a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/MemoryManager/MemoryVectorStoreManager.ts
+++ b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/MemoryManager/MemoryVectorStoreManager.ts
@@ -124,6 +124,10 @@ export class MemoryVectorStoreManager {
}
}
+ getMemoryKeysList(): string[] {
+ return Array.from(this.vectorStoreBuffer.keys());
+ }
+
/**
* Get or create a vector store by key
*/
diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/MemoryManager/test/MemoryVectorStoreManager.test.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/MemoryManager/test/MemoryVectorStoreManager.test.ts
index f8e82217c0..449c7f328a 100644
--- a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/MemoryManager/test/MemoryVectorStoreManager.test.ts
+++ b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/MemoryManager/test/MemoryVectorStoreManager.test.ts
@@ -246,4 +246,34 @@ describe('MemoryVectorStoreManager', () => {
expect(stats.stores.store1.vectors).toBe(50);
expect(stats.stores.store2.vectors).toBe(30);
});
+
+ it('should list all vector stores', async () => {
+ const embeddings = mock();
+ const instance = MemoryVectorStoreManager.getInstance(embeddings, logger);
+
+ const mockVectorStore1 = mock();
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+ mockVectorStore1.memoryVectors = new Array(50).fill({
+ embedding: createTestEmbedding(),
+ content: 'test1',
+ metadata: {},
+ });
+
+ const mockVectorStore2 = mock();
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+ mockVectorStore2.memoryVectors = new Array(30).fill({
+ embedding: createTestEmbedding(),
+ content: 'test2',
+ metadata: {},
+ });
+
+ // Mock internal state
+ instance['vectorStoreBuffer'].set('store1', mockVectorStore1);
+ instance['vectorStoreBuffer'].set('store2', mockVectorStore2);
+
+ const list = instance.getMemoryKeysList();
+ expect(list).toHaveLength(2);
+ expect(list[0]).toBe('store1');
+ expect(list[1]).toBe('store2');
+ });
});
diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/__snapshots__/createVectorStoreNode.test.ts.snap b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/__snapshots__/createVectorStoreNode.test.ts.snap
index c5367ea4f4..52bded860f 100644
--- a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/__snapshots__/createVectorStoreNode.test.ts.snap
+++ b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/__snapshots__/createVectorStoreNode.test.ts.snap
@@ -253,6 +253,7 @@ exports[`createVectorStoreNode retrieve mode supplies vector store as data 1`] =
"version": [
1,
1.1,
+ 1.2,
],
}
`;
diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/createVectorStoreNode.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/createVectorStoreNode.ts
index 31d0f8eb7a..1cb85b5268 100644
--- a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/createVectorStoreNode.ts
+++ b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/createVectorStoreNode.ts
@@ -43,7 +43,8 @@ export const createVectorStoreNode = (
icon: args.meta.icon,
iconColor: args.meta.iconColor,
group: ['transform'],
- version: [1, 1.1],
+ // 1.2 has changes to VectorStoreInMemory node.
+ version: [1, 1.1, 1.2],
defaults: {
name: args.meta.displayName,
},
diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/types.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/types.ts
index 15136f0b10..68c2c6ebc1 100644
--- a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/types.ts
+++ b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/types.ts
@@ -10,6 +10,8 @@ import type {
Icon,
ISupplyDataFunctions,
ThemeIconColor,
+ IDataObject,
+ NodeParameterValueType,
} from 'n8n-workflow';
export type NodeOperationMode = 'insert' | 'load' | 'retrieve' | 'update' | 'retrieve-as-tool';
@@ -35,6 +37,12 @@ export interface VectorStoreNodeConstructorArgs Promise;
};
+ actionHandler?: {
+ [functionName: string]: (
+ this: ILoadOptionsFunctions,
+ payload: IDataObject | string | undefined,
+ ) => Promise;
+ };
};
sharedFields: INodeProperties[];
diff --git a/packages/frontend/editor-ui/src/components/ResourceLocator/ResourceLocator.test.constants.ts b/packages/frontend/editor-ui/src/components/ResourceLocator/ResourceLocator.test.constants.ts
index 57d161b752..5829437aa7 100644
--- a/packages/frontend/editor-ui/src/components/ResourceLocator/ResourceLocator.test.constants.ts
+++ b/packages/frontend/editor-ui/src/components/ResourceLocator/ResourceLocator.test.constants.ts
@@ -49,6 +49,27 @@ export const TEST_PARAMETER_SINGLE_MODE: INodeProperties = {
],
};
+export const TEST_PARAMETER_ADD_RESOURCE: INodeProperties = {
+ ...TEST_PARAMETER_MULTI_MODE,
+ name: 'testParameterAddResource',
+ modes: [
+ {
+ displayName: 'From List',
+ name: 'list',
+ type: 'list',
+ typeOptions: {
+ searchListMethod: 'testSearch',
+ searchable: true,
+ allowNewResource: {
+ label: 'resourceLocator.mode.list.addNewResource.vectorStoreInMemory',
+ method: 'testAddResource',
+ defaultName: 'test',
+ },
+ },
+ },
+ ],
+};
+
export const TEST_NODE_MULTI_MODE: INode = {
type: 'n8n-nodes-base.airtable',
typeVersion: 2.1,
diff --git a/packages/frontend/editor-ui/src/components/ResourceLocator/ResourceLocator.test.ts b/packages/frontend/editor-ui/src/components/ResourceLocator/ResourceLocator.test.ts
index 77b97db597..1755547ee6 100644
--- a/packages/frontend/editor-ui/src/components/ResourceLocator/ResourceLocator.test.ts
+++ b/packages/frontend/editor-ui/src/components/ResourceLocator/ResourceLocator.test.ts
@@ -9,6 +9,7 @@ import {
TEST_MODEL_VALUE,
TEST_NODE_MULTI_MODE,
TEST_NODE_SINGLE_MODE,
+ TEST_PARAMETER_ADD_RESOURCE,
TEST_PARAMETER_MULTI_MODE,
TEST_PARAMETER_SINGLE_MODE,
} from './ResourceLocator.test.constants';
@@ -133,6 +134,69 @@ describe('ResourceLocator', () => {
});
});
+ it('renders add resource button', async () => {
+ nodeTypesStore.getResourceLocatorResults.mockResolvedValue({
+ results: [],
+ paginationToken: null,
+ });
+ const { getByTestId } = renderComponent({
+ props: {
+ modelValue: TEST_MODEL_VALUE,
+ parameter: TEST_PARAMETER_ADD_RESOURCE,
+ path: `parameters.${TEST_PARAMETER_ADD_RESOURCE.name}`,
+ node: TEST_NODE_SINGLE_MODE,
+ displayTitle: 'Test Resource Locator',
+ expressionComputedValue: '',
+ },
+ });
+
+ expect(getByTestId(`resource-locator-${TEST_PARAMETER_ADD_RESOURCE.name}`)).toBeInTheDocument();
+ // Click on the input to open it
+ await userEvent.click(getByTestId('rlc-input'));
+ // Expect the button to create a new resource to be rendered
+ expect(getByTestId('rlc-item-add-resource')).toBeInTheDocument();
+ });
+
+ it('creates new resource passing search filter as name', async () => {
+ nodeTypesStore.getResourceLocatorResults.mockResolvedValue({
+ results: [],
+ paginationToken: null,
+ });
+ nodeTypesStore.getNodeParameterActionResult.mockResolvedValue('new-resource');
+
+ const { getByTestId } = renderComponent({
+ props: {
+ modelValue: TEST_MODEL_VALUE,
+ parameter: TEST_PARAMETER_ADD_RESOURCE,
+ path: `parameters.${TEST_PARAMETER_ADD_RESOURCE.name}`,
+ node: TEST_NODE_SINGLE_MODE,
+ displayTitle: 'Test Resource Locator',
+ expressionComputedValue: '',
+ },
+ });
+
+ // Click on the input to open it
+ await userEvent.click(getByTestId('rlc-input'));
+ // Type in the input to name the resource
+ await userEvent.type(getByTestId('rlc-search'), 'Test Resource');
+ // Click on the add resource button
+ await userEvent.click(getByTestId('rlc-item-add-resource'));
+
+ expect(nodeTypesStore.getNodeParameterActionResult).toHaveBeenCalledWith({
+ nodeTypeAndVersion: {
+ name: TEST_NODE_SINGLE_MODE.type,
+ version: TEST_NODE_SINGLE_MODE.typeVersion,
+ },
+ path: `parameters.${TEST_PARAMETER_ADD_RESOURCE.name}`,
+ currentNodeParameters: expect.any(Object),
+ credentials: TEST_NODE_SINGLE_MODE.credentials,
+ handler: 'testAddResource',
+ payload: {
+ name: 'Test Resource',
+ },
+ });
+ });
+
// Testing error message deduplication
describe('ResourceLocator credentials error handling', () => {
it.each([
diff --git a/packages/frontend/editor-ui/src/components/ResourceLocator/ResourceLocator.vue b/packages/frontend/editor-ui/src/components/ResourceLocator/ResourceLocator.vue
index cae8984a6f..1f3d5fb0b5 100644
--- a/packages/frontend/editor-ui/src/components/ResourceLocator/ResourceLocator.vue
+++ b/packages/frontend/editor-ui/src/components/ResourceLocator/ResourceLocator.vue
@@ -1,5 +1,5 @@