Merge branch 'master' of https://github.com/n8n-io/n8n into node-2015-google-calendar-confusing-errors

This commit is contained in:
Michael Kret 2025-01-06 10:12:50 +02:00
commit 85cb052ff9
16 changed files with 402 additions and 43 deletions

View file

@ -2,7 +2,7 @@
* Getters * Getters
*/ */
import { getVisibleSelect } from '../utils'; import { getVisibleSelect } from '../utils/popper';
export function getCredentialSelect(eq = 0) { export function getCredentialSelect(eq = 0) {
return cy.getByTestId('node-credentials-select').eq(eq); return cy.getByTestId('node-credentials-select').eq(eq);

View file

@ -1,4 +1,5 @@
import { getManualChatModal } from './modals/chat-modal'; import { getManualChatModal } from './modals/chat-modal';
import { clickGetBackToCanvas, getParameterInputByName } from './ndv';
import { ROUTES } from '../constants'; import { ROUTES } from '../constants';
/** /**
@ -127,7 +128,7 @@ export function navigateToNewWorkflowPage(preventNodeViewUnload = true) {
}); });
} }
export function addSupplementalNodeToParent( function connectNodeToParent(
nodeName: string, nodeName: string,
endpointType: EndpointType, endpointType: EndpointType,
parentNodeName: string, parentNodeName: string,
@ -141,6 +142,15 @@ export function addSupplementalNodeToParent(
} else { } else {
getNodeCreatorItems().contains(nodeName).click(); getNodeCreatorItems().contains(nodeName).click();
} }
}
export function addSupplementalNodeToParent(
nodeName: string,
endpointType: EndpointType,
parentNodeName: string,
exactMatch = false,
) {
connectNodeToParent(nodeName, endpointType, parentNodeName, exactMatch);
getConnectionBySourceAndTarget(parentNodeName, nodeName).should('exist'); getConnectionBySourceAndTarget(parentNodeName, nodeName).should('exist');
} }
@ -160,6 +170,15 @@ export function addToolNodeToParent(nodeName: string, parentNodeName: string) {
addSupplementalNodeToParent(nodeName, 'ai_tool', parentNodeName); addSupplementalNodeToParent(nodeName, 'ai_tool', parentNodeName);
} }
export function addVectorStoreToolToParent(nodeName: string, parentNodeName: string) {
connectNodeToParent(nodeName, 'ai_tool', parentNodeName, false);
getParameterInputByName('mode')
.find('input')
.should('have.value', 'Retrieve Documents (As Tool for AI Agent)');
clickGetBackToCanvas();
getConnectionBySourceAndTarget(nodeName, parentNodeName).should('exist');
}
export function addOutputParserNodeToParent(nodeName: string, parentNodeName: string) { export function addOutputParserNodeToParent(nodeName: string, parentNodeName: string) {
addSupplementalNodeToParent(nodeName, 'ai_outputParser', parentNodeName); addSupplementalNodeToParent(nodeName, 'ai_outputParser', parentNodeName);
} }

View file

@ -1,10 +1,12 @@
import { clickGetBackToCanvas } from '../composables/ndv';
import { import {
addNodeToCanvas, addNodeToCanvas,
addRetrieverNodeToParent, addRetrieverNodeToParent,
addVectorStoreNodeToParent, addVectorStoreNodeToParent,
addVectorStoreToolToParent,
getNodeCreatorItems, getNodeCreatorItems,
} from '../composables/workflow'; } from '../composables/workflow';
import { IF_NODE_NAME } from '../constants'; import { AGENT_NODE_NAME, IF_NODE_NAME, MANUAL_CHAT_TRIGGER_NODE_NAME } from '../constants';
import { NodeCreator } from '../pages/features/node-creator'; import { NodeCreator } from '../pages/features/node-creator';
import { NDV } from '../pages/ndv'; import { NDV } from '../pages/ndv';
import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; import { WorkflowPage as WorkflowPageClass } from '../pages/workflow';
@ -536,7 +538,7 @@ describe('Node Creator', () => {
}); });
}); });
it('should add node directly for sub-connection', () => { it('should add node directly for sub-connection as vector store', () => {
addNodeToCanvas('Question and Answer Chain', true); addNodeToCanvas('Question and Answer Chain', true);
addRetrieverNodeToParent('Vector Store Retriever', 'Question and Answer Chain'); addRetrieverNodeToParent('Vector Store Retriever', 'Question and Answer Chain');
cy.realPress('Escape'); cy.realPress('Escape');
@ -544,4 +546,12 @@ describe('Node Creator', () => {
cy.realPress('Escape'); cy.realPress('Escape');
WorkflowPage.getters.canvasNodes().should('have.length', 4); WorkflowPage.getters.canvasNodes().should('have.length', 4);
}); });
it('should add node directly for sub-connection as tool', () => {
addNodeToCanvas(MANUAL_CHAT_TRIGGER_NODE_NAME, true);
addNodeToCanvas(AGENT_NODE_NAME, true, true);
clickGetBackToCanvas();
addVectorStoreToolToParent('In-Memory Vector Store', AGENT_NODE_NAME);
});
}); });

View file

@ -15,15 +15,15 @@ import { getConnectionHintNoticeField } from '@utils/sharedFields';
export class ToolVectorStore implements INodeType { export class ToolVectorStore implements INodeType {
description: INodeTypeDescription = { description: INodeTypeDescription = {
displayName: 'Vector Store Tool', displayName: 'Vector Store Question Answer Tool',
name: 'toolVectorStore', name: 'toolVectorStore',
icon: 'fa:database', icon: 'fa:database',
iconColor: 'black', iconColor: 'black',
group: ['transform'], group: ['transform'],
version: [1], version: [1],
description: 'Retrieve context from vector store', description: 'Answer questions with a vector store',
defaults: { defaults: {
name: 'Vector Store Tool', name: 'Answer questions with a vector store',
}, },
codex: { codex: {
categories: ['AI'], categories: ['AI'],
@ -60,20 +60,23 @@ export class ToolVectorStore implements INodeType {
properties: [ properties: [
getConnectionHintNoticeField([NodeConnectionType.AiAgent]), getConnectionHintNoticeField([NodeConnectionType.AiAgent]),
{ {
displayName: 'Name', displayName: 'Data Name',
name: 'name', name: 'name',
type: 'string', type: 'string',
default: '', default: '',
placeholder: 'e.g. company_knowledge_base', placeholder: 'e.g. users_info',
validateType: 'string-alphanumeric', validateType: 'string-alphanumeric',
description: 'Name of the vector store', description:
'Name of the data in vector store. This will be used to fill this tool description: Useful for when you need to answer questions about [name]. Whenever you need information about [data description], you should ALWAYS use this. Input should be a fully formed question.',
}, },
{ {
displayName: 'Description', displayName: 'Description of Data',
name: 'description', name: 'description',
type: 'string', type: 'string',
default: '', default: '',
placeholder: 'Retrieves data about [insert information about your data here]...', placeholder: "[Describe your data here, e.g. a user's name, email, etc.]",
description:
'Describe the data in vector store. This will be used to fill this tool description: Useful for when you need to answer questions about [name]. Whenever you need information about [data description], you should ALWAYS use this. Input should be a fully formed question.',
typeOptions: { typeOptions: {
rows: 3, rows: 3,
}, },

View file

@ -228,7 +228,7 @@ export class VectorStorePGVector extends createVectorStoreNode({
testedBy: 'postgresConnectionTest', testedBy: 'postgresConnectionTest',
}, },
], ],
operationModes: ['load', 'insert', 'retrieve'], operationModes: ['load', 'insert', 'retrieve', 'retrieve-as-tool'],
}, },
sharedFields, sharedFields,
insertFields, insertFields,

View file

@ -65,7 +65,7 @@ export class VectorStorePinecone extends createVectorStoreNode({
required: true, required: true,
}, },
], ],
operationModes: ['load', 'insert', 'retrieve', 'update'], operationModes: ['load', 'insert', 'retrieve', 'update', 'retrieve-as-tool'],
}, },
methods: { listSearch: { pineconeIndexSearch } }, methods: { listSearch: { pineconeIndexSearch } },
retrieveFields, retrieveFields,

View file

@ -55,7 +55,7 @@ export class VectorStoreSupabase extends createVectorStoreNode({
required: true, required: true,
}, },
], ],
operationModes: ['load', 'insert', 'retrieve', 'update'], operationModes: ['load', 'insert', 'retrieve', 'update', 'retrieve-as-tool'],
}, },
methods: { methods: {
listSearch: { supabaseTableNameSearch }, listSearch: { supabaseTableNameSearch },

View file

@ -0,0 +1,161 @@
import type { DocumentInterface } from '@langchain/core/documents';
import type { Embeddings } from '@langchain/core/embeddings';
import type { VectorStore } from '@langchain/core/vectorstores';
import { mock } from 'jest-mock-extended';
import type { DynamicTool } from 'langchain/tools';
import type { ISupplyDataFunctions, NodeParameterValueType } from 'n8n-workflow';
import type { VectorStoreNodeConstructorArgs } from './createVectorStoreNode';
import { createVectorStoreNode } from './createVectorStoreNode';
jest.mock('@utils/logWrapper', () => ({
logWrapper: jest.fn().mockImplementation((val: DynamicTool) => ({ logWrapped: val })),
}));
const DEFAULT_PARAMETERS = {
options: {},
topK: 1,
};
const MOCK_DOCUMENTS: Array<[DocumentInterface, number]> = [
[
{
pageContent: 'first page',
metadata: {
id: 123,
},
},
0,
],
[
{
pageContent: 'second page',
metadata: {
id: 567,
},
},
0,
],
];
const MOCK_SEARCH_VALUE = 'search value';
const MOCK_EMBEDDED_SEARCH_VALUE = [1, 2, 3];
describe('createVectorStoreNode', () => {
const vectorStore = mock<VectorStore>({
similaritySearchVectorWithScore: jest.fn().mockResolvedValue(MOCK_DOCUMENTS),
});
const vectorStoreNodeArgs = mock<VectorStoreNodeConstructorArgs>({
sharedFields: [],
insertFields: [],
loadFields: [],
retrieveFields: [],
updateFields: [],
getVectorStoreClient: jest.fn().mockReturnValue(vectorStore),
});
const embeddings = mock<Embeddings>({
embedQuery: jest.fn().mockResolvedValue(MOCK_EMBEDDED_SEARCH_VALUE),
});
const context = mock<ISupplyDataFunctions>({
getNodeParameter: jest.fn(),
getInputConnectionData: jest.fn().mockReturnValue(embeddings),
});
describe('retrieve mode', () => {
it('supplies vector store as data', async () => {
// ARRANGE
const parameters: Record<string, NodeParameterValueType | object> = {
...DEFAULT_PARAMETERS,
mode: 'retrieve',
};
context.getNodeParameter.mockImplementation(
(parameterName: string): NodeParameterValueType | object => parameters[parameterName],
);
// ACT
const VectorStoreNodeType = createVectorStoreNode(vectorStoreNodeArgs);
const nodeType = new VectorStoreNodeType();
const data = await nodeType.supplyData.call(context, 1);
const wrappedVectorStore = (data.response as { logWrapped: VectorStore }).logWrapped;
// ASSERT
expect(wrappedVectorStore).toEqual(vectorStore);
expect(vectorStoreNodeArgs.getVectorStoreClient).toHaveBeenCalled();
});
});
describe('retrieve-as-tool mode', () => {
it('supplies DynamicTool that queries vector store and returns documents with metadata', async () => {
// ARRANGE
const parameters: Record<string, NodeParameterValueType | object> = {
...DEFAULT_PARAMETERS,
mode: 'retrieve-as-tool',
description: 'tool description',
toolName: 'tool name',
includeDocumentMetadata: true,
};
context.getNodeParameter.mockImplementation(
(parameterName: string): NodeParameterValueType | object => parameters[parameterName],
);
// ACT
const VectorStoreNodeType = createVectorStoreNode(vectorStoreNodeArgs);
const nodeType = new VectorStoreNodeType();
const data = await nodeType.supplyData.call(context, 1);
const tool = (data.response as { logWrapped: DynamicTool }).logWrapped;
const output = await tool?.func(MOCK_SEARCH_VALUE);
// ASSERT
expect(tool?.getName()).toEqual(parameters.toolName);
expect(tool?.description).toEqual(parameters.toolDescription);
expect(embeddings.embedQuery).toHaveBeenCalledWith(MOCK_SEARCH_VALUE);
expect(vectorStore.similaritySearchVectorWithScore).toHaveBeenCalledWith(
MOCK_EMBEDDED_SEARCH_VALUE,
parameters.topK,
parameters.filter,
);
expect(output).toEqual([
{ type: 'text', text: JSON.stringify(MOCK_DOCUMENTS[0][0]) },
{ type: 'text', text: JSON.stringify(MOCK_DOCUMENTS[1][0]) },
]);
});
it('supplies DynamicTool that queries vector store and returns documents without metadata', async () => {
// ARRANGE
const parameters: Record<string, NodeParameterValueType | object> = {
...DEFAULT_PARAMETERS,
mode: 'retrieve-as-tool',
description: 'tool description',
toolName: 'tool name',
includeDocumentMetadata: false,
};
context.getNodeParameter.mockImplementation(
(parameterName: string): NodeParameterValueType | object => parameters[parameterName],
);
// ACT
const VectorStoreNodeType = createVectorStoreNode(vectorStoreNodeArgs);
const nodeType = new VectorStoreNodeType();
const data = await nodeType.supplyData.call(context, 1);
const tool = (data.response as { logWrapped: DynamicTool }).logWrapped;
const output = await tool?.func(MOCK_SEARCH_VALUE);
// ASSERT
expect(tool?.getName()).toEqual(parameters.toolName);
expect(tool?.description).toEqual(parameters.toolDescription);
expect(embeddings.embedQuery).toHaveBeenCalledWith(MOCK_SEARCH_VALUE);
expect(vectorStore.similaritySearchVectorWithScore).toHaveBeenCalledWith(
MOCK_EMBEDDED_SEARCH_VALUE,
parameters.topK,
parameters.filter,
);
expect(output).toEqual([
{ type: 'text', text: JSON.stringify({ pageContent: MOCK_DOCUMENTS[0][0].pageContent }) },
{ type: 'text', text: JSON.stringify({ pageContent: MOCK_DOCUMENTS[1][0].pageContent }) },
]);
});
});
});

View file

@ -3,6 +3,7 @@
import type { Document } from '@langchain/core/documents'; import type { Document } from '@langchain/core/documents';
import type { Embeddings } from '@langchain/core/embeddings'; import type { Embeddings } from '@langchain/core/embeddings';
import type { VectorStore } from '@langchain/core/vectorstores'; import type { VectorStore } from '@langchain/core/vectorstores';
import { DynamicTool } from 'langchain/tools';
import { NodeConnectionType, NodeOperationError } from 'n8n-workflow'; import { NodeConnectionType, NodeOperationError } from 'n8n-workflow';
import type { import type {
IExecuteFunctions, IExecuteFunctions,
@ -28,9 +29,14 @@ import { getConnectionHintNoticeField } from '@utils/sharedFields';
import { processDocument } from './processDocuments'; import { processDocument } from './processDocuments';
type NodeOperationMode = 'insert' | 'load' | 'retrieve' | 'update'; type NodeOperationMode = 'insert' | 'load' | 'retrieve' | 'update' | 'retrieve-as-tool';
const DEFAULT_OPERATION_MODES: NodeOperationMode[] = ['load', 'insert', 'retrieve']; const DEFAULT_OPERATION_MODES: NodeOperationMode[] = [
'load',
'insert',
'retrieve',
'retrieve-as-tool',
];
interface NodeMeta { interface NodeMeta {
displayName: string; displayName: string;
@ -43,7 +49,7 @@ interface NodeMeta {
operationModes?: NodeOperationMode[]; operationModes?: NodeOperationMode[];
} }
interface VectorStoreNodeConstructorArgs { export interface VectorStoreNodeConstructorArgs {
meta: NodeMeta; meta: NodeMeta;
methods?: { methods?: {
listSearch?: { listSearch?: {
@ -102,10 +108,18 @@ function getOperationModeOptions(args: VectorStoreNodeConstructorArgs): INodePro
action: 'Add documents to vector store', action: 'Add documents to vector store',
}, },
{ {
name: 'Retrieve Documents (For Agent/Chain)', name: 'Retrieve Documents (As Vector Store for AI Agent)',
value: 'retrieve', value: 'retrieve',
description: 'Retrieve documents from vector store to be used with AI nodes', description: 'Retrieve documents from vector store to be used as vector store with AI nodes',
action: 'Retrieve documents for AI processing', action: 'Retrieve documents for AI processing as Vector Store',
outputConnectionType: NodeConnectionType.AiVectorStore,
},
{
name: 'Retrieve Documents (As Tool for AI Agent)',
value: 'retrieve-as-tool',
description: 'Retrieve documents from vector store to be used as tool with AI nodes',
action: 'Retrieve documents for AI processing as Tool',
outputConnectionType: NodeConnectionType.AiTool,
}, },
{ {
name: 'Update Documents', name: 'Update Documents',
@ -136,7 +150,8 @@ export const createVectorStoreNode = (args: VectorStoreNodeConstructorArgs) =>
codex: { codex: {
categories: ['AI'], categories: ['AI'],
subcategories: { subcategories: {
AI: ['Vector Stores', 'Root Nodes'], AI: ['Vector Stores', 'Tools', 'Root Nodes'],
Tools: ['Other Tools'],
}, },
resources: { resources: {
primaryDocumentation: [ primaryDocumentation: [
@ -153,6 +168,10 @@ export const createVectorStoreNode = (args: VectorStoreNodeConstructorArgs) =>
const mode = parameters?.mode; const mode = parameters?.mode;
const inputs = [{ displayName: "Embedding", type: "${NodeConnectionType.AiEmbedding}", required: true, maxConnections: 1}] const inputs = [{ displayName: "Embedding", type: "${NodeConnectionType.AiEmbedding}", required: true, maxConnections: 1}]
if (mode === 'retrieve-as-tool') {
return inputs;
}
if (['insert', 'load', 'update'].includes(mode)) { if (['insert', 'load', 'update'].includes(mode)) {
inputs.push({ displayName: "", type: "${NodeConnectionType.Main}"}) inputs.push({ displayName: "", type: "${NodeConnectionType.Main}"})
} }
@ -166,6 +185,11 @@ export const createVectorStoreNode = (args: VectorStoreNodeConstructorArgs) =>
outputs: `={{ outputs: `={{
((parameters) => { ((parameters) => {
const mode = parameters?.mode ?? 'retrieve'; const mode = parameters?.mode ?? 'retrieve';
if (mode === 'retrieve-as-tool') {
return [{ displayName: "Tool", type: "${NodeConnectionType.AiTool}"}]
}
if (mode === 'retrieve') { if (mode === 'retrieve') {
return [{ displayName: "Vector Store", type: "${NodeConnectionType.AiVectorStore}"}] return [{ displayName: "Vector Store", type: "${NodeConnectionType.AiVectorStore}"}]
} }
@ -189,6 +213,37 @@ export const createVectorStoreNode = (args: VectorStoreNodeConstructorArgs) =>
}, },
}, },
}, },
{
displayName: 'Name',
name: 'toolName',
type: 'string',
default: '',
required: true,
description: 'Name of the vector store',
placeholder: 'e.g. company_knowledge_base',
validateType: 'string-alphanumeric',
displayOptions: {
show: {
mode: ['retrieve-as-tool'],
},
},
},
{
displayName: 'Description',
name: 'toolDescription',
type: 'string',
default: '',
required: true,
typeOptions: { rows: 2 },
description:
'Explain to the LLM what this tool does, a good, specific description would allow LLMs to produce expected results much more often',
placeholder: `e.g. ${args.meta.description}`,
displayOptions: {
show: {
mode: ['retrieve-as-tool'],
},
},
},
...args.sharedFields, ...args.sharedFields,
...transformDescriptionForOperationMode(args.insertFields ?? [], 'insert'), ...transformDescriptionForOperationMode(args.insertFields ?? [], 'insert'),
// Prompt and topK are always used for the load operation // Prompt and topK are always used for the load operation
@ -214,7 +269,19 @@ export const createVectorStoreNode = (args: VectorStoreNodeConstructorArgs) =>
description: 'Number of top results to fetch from vector store', description: 'Number of top results to fetch from vector store',
displayOptions: { displayOptions: {
show: { show: {
mode: ['load'], mode: ['load', 'retrieve-as-tool'],
},
},
},
{
displayName: 'Include Metadata',
name: 'includeDocumentMetadata',
type: 'boolean',
default: true,
description: 'Whether or not to include document metadata',
displayOptions: {
show: {
mode: ['load', 'retrieve-as-tool'],
}, },
}, },
}, },
@ -271,10 +338,16 @@ export const createVectorStoreNode = (args: VectorStoreNodeConstructorArgs) =>
filter, filter,
); );
const includeDocumentMetadata = this.getNodeParameter(
'includeDocumentMetadata',
itemIndex,
true,
) as boolean;
const serializedDocs = docs.map(([doc, score]) => { const serializedDocs = docs.map(([doc, score]) => {
const document = { const document = {
metadata: doc.metadata,
pageContent: doc.pageContent, pageContent: doc.pageContent,
...(includeDocumentMetadata ? { metadata: doc.metadata } : {}),
}; };
return { return {
@ -381,12 +454,12 @@ export const createVectorStoreNode = (args: VectorStoreNodeConstructorArgs) =>
throw new NodeOperationError( throw new NodeOperationError(
this.getNode(), this.getNode(),
'Only the "load" and "insert" operation modes are supported with execute', 'Only the "load", "update" and "insert" operation modes are supported with execute',
); );
} }
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> { async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
const mode = this.getNodeParameter('mode', 0) as 'load' | 'insert' | 'retrieve'; const mode = this.getNodeParameter('mode', 0) as NodeOperationMode;
const filter = getMetadataFiltersValues(this, itemIndex); const filter = getMetadataFiltersValues(this, itemIndex);
const embeddings = (await this.getInputConnectionData( const embeddings = (await this.getInputConnectionData(
NodeConnectionType.AiEmbedding, NodeConnectionType.AiEmbedding,
@ -400,9 +473,54 @@ export const createVectorStoreNode = (args: VectorStoreNodeConstructorArgs) =>
}; };
} }
if (mode === 'retrieve-as-tool') {
const toolDescription = this.getNodeParameter('toolDescription', itemIndex) as string;
const toolName = this.getNodeParameter('toolName', itemIndex) as string;
const topK = this.getNodeParameter('topK', itemIndex, 4) as number;
const includeDocumentMetadata = this.getNodeParameter(
'includeDocumentMetadata',
itemIndex,
true,
) as boolean;
const vectorStoreTool = new DynamicTool({
name: toolName,
description: toolDescription,
func: async (input) => {
const vectorStore = await args.getVectorStoreClient(
this,
filter,
embeddings,
itemIndex,
);
const embeddedPrompt = await embeddings.embedQuery(input);
const documents = await vectorStore.similaritySearchVectorWithScore(
embeddedPrompt,
topK,
filter,
);
return documents
.map((document) => {
if (includeDocumentMetadata) {
return { type: 'text', text: JSON.stringify(document[0]) };
}
return {
type: 'text',
text: JSON.stringify({ pageContent: document[0].pageContent }),
};
})
.filter((document) => !!document);
},
});
return {
response: logWrapper(vectorStoreTool, this),
};
}
throw new NodeOperationError( throw new NodeOperationError(
this.getNode(), this.getNode(),
'Only the "retrieve" operation mode is supported to supply data', 'Only the "retrieve" and "retrieve-as-tool" operation mode is supported to supply data',
); );
} }
}; };

View file

@ -720,6 +720,7 @@ export interface ActionTypeDescription extends SimplifiedNodeType {
displayOptions?: IDisplayOptions; displayOptions?: IDisplayOptions;
values?: IDataObject; values?: IDataObject;
actionKey: string; actionKey: string;
outputConnectionType?: NodeConnectionType;
codex: { codex: {
label: string; label: string;
categories: string[]; categories: string[];

View file

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { camelCase } from 'lodash-es'; import { camelCase } from 'lodash-es';
import { computed } from 'vue'; import { computed } from 'vue';
import type { INodeCreateElement, NodeFilterType } from '@/Interface'; import type { INodeCreateElement, NodeCreateElement, NodeFilterType } from '@/Interface';
import { import {
TRIGGER_NODE_CREATOR_VIEW, TRIGGER_NODE_CREATOR_VIEW,
HTTP_REQUEST_NODE_TYPE, HTTP_REQUEST_NODE_TYPE,
@ -25,6 +25,8 @@ import NoResults from '../Panel/NoResults.vue';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { getNodeIcon, getNodeIconColor, getNodeIconUrl } from '@/utils/nodeTypesUtils'; import { getNodeIcon, getNodeIconColor, getNodeIconUrl } from '@/utils/nodeTypesUtils';
import { useUIStore } from '@/stores/ui.store'; import { useUIStore } from '@/stores/ui.store';
import { useActions } from '../composables/useActions';
import type { INodeParameters } from 'n8n-workflow';
export interface Props { export interface Props {
rootView: 'trigger' | 'action'; rootView: 'trigger' | 'action';
@ -40,12 +42,21 @@ const rootStore = useRootStore();
const { mergedNodes, actions, onSubcategorySelected } = useNodeCreatorStore(); const { mergedNodes, actions, onSubcategorySelected } = useNodeCreatorStore();
const { pushViewStack, popViewStack } = useViewStacks(); const { pushViewStack, popViewStack } = useViewStacks();
const { setAddedNodeActionParameters } = useActions();
const { registerKeyHook } = useKeyboardNavigation(); const { registerKeyHook } = useKeyboardNavigation();
const activeViewStack = computed(() => useViewStacks().activeViewStack); const activeViewStack = computed(() => useViewStacks().activeViewStack);
const globalSearchItemsDiff = computed(() => useViewStacks().globalSearchItemsDiff); const globalSearchItemsDiff = computed(() => useViewStacks().globalSearchItemsDiff);
function getFilteredActions(node: NodeCreateElement) {
const nodeActions = actions?.[node.key] || [];
if (activeViewStack.value.actionsFilter) {
return activeViewStack.value.actionsFilter(nodeActions);
}
return nodeActions;
}
function selectNodeType(nodeTypes: string[]) { function selectNodeType(nodeTypes: string[]) {
emit('nodeTypeSelected', nodeTypes); emit('nodeTypeSelected', nodeTypes);
} }
@ -87,9 +98,21 @@ function onSelected(item: INodeCreateElement) {
} }
if (item.type === 'node') { if (item.type === 'node') {
const nodeActions = actions?.[item.key] || []; const nodeActions = getFilteredActions(item);
// If there is only one action, use it
if (nodeActions.length === 1) {
selectNodeType([item.key]);
setAddedNodeActionParameters({
name: nodeActions[0].defaults.name ?? item.properties.displayName,
key: item.key,
value: nodeActions[0].values as INodeParameters,
});
return;
}
// Only show actions if there are more than one or if the view is not an AI subcategory // Only show actions if there are more than one or if the view is not an AI subcategory
if (nodeActions.length <= 1 || activeViewStack.value.hideActions) { if (nodeActions.length === 0 || activeViewStack.value.hideActions) {
selectNodeType([item.key]); selectNodeType([item.key]);
return; return;
} }
@ -158,7 +181,7 @@ function subcategoriesMapper(item: INodeCreateElement) {
if (item.type !== 'node') return item; if (item.type !== 'node') return item;
const hasTriggerGroup = item.properties.group.includes('trigger'); const hasTriggerGroup = item.properties.group.includes('trigger');
const nodeActions = actions?.[item.key] || []; const nodeActions = getFilteredActions(item);
const hasActions = nodeActions.length > 0; const hasActions = nodeActions.length > 0;
if (hasTriggerGroup && hasActions) { if (hasTriggerGroup && hasActions) {
@ -179,7 +202,7 @@ function baseSubcategoriesFilter(item: INodeCreateElement): boolean {
if (item.type !== 'node') return false; if (item.type !== 'node') return false;
const hasTriggerGroup = item.properties.group.includes('trigger'); const hasTriggerGroup = item.properties.group.includes('trigger');
const nodeActions = actions?.[item.key] || []; const nodeActions = getFilteredActions(item);
const hasActions = nodeActions.length > 0; const hasActions = nodeActions.length > 0;
const isTriggerRootView = activeViewStack.value.rootView === TRIGGER_NODE_CREATOR_VIEW; const isTriggerRootView = activeViewStack.value.rootView === TRIGGER_NODE_CREATOR_VIEW;

View file

@ -1,5 +1,11 @@
import type { ActionTypeDescription, ActionsRecord, SimplifiedNodeType } from '@/Interface'; import type { ActionTypeDescription, ActionsRecord, SimplifiedNodeType } from '@/Interface';
import { AI_SUBCATEGORY, CUSTOM_API_CALL_KEY, HTTP_REQUEST_NODE_TYPE } from '@/constants'; import {
AI_CATEGORY_ROOT_NODES,
AI_CATEGORY_TOOLS,
AI_SUBCATEGORY,
CUSTOM_API_CALL_KEY,
HTTP_REQUEST_NODE_TYPE,
} from '@/constants';
import { memoize, startCase } from 'lodash-es'; import { memoize, startCase } from 'lodash-es';
import type { import type {
ICredentialType, ICredentialType,
@ -87,6 +93,7 @@ function operationsCategory(nodeTypeDescription: INodeTypeDescription): ActionTy
displayName: item.action ?? startCase(item.name), displayName: item.action ?? startCase(item.name),
description: item.description ?? '', description: item.description ?? '',
displayOptions: matchedProperty.displayOptions, displayOptions: matchedProperty.displayOptions,
outputConnectionType: item.outputConnectionType,
values: { values: {
[matchedProperty.name]: matchedProperty.type === 'multiOptions' ? [item.value] : item.value, [matchedProperty.name]: matchedProperty.type === 'multiOptions' ? [item.value] : item.value,
}, },
@ -117,6 +124,7 @@ function modeCategory(nodeTypeDescription: INodeTypeDescription): ActionTypeDesc
displayName: item.action ?? startCase(item.name), displayName: item.action ?? startCase(item.name),
description: item.description ?? '', description: item.description ?? '',
displayOptions: matchedProperty.displayOptions, displayOptions: matchedProperty.displayOptions,
outputConnectionType: item.outputConnectionType,
values: { values: {
[matchedProperty.name]: item.value, [matchedProperty.name]: item.value,
}, },
@ -261,7 +269,11 @@ function resourceCategories(nodeTypeDescription: INodeTypeDescription): ActionTy
export function useActionsGenerator() { export function useActionsGenerator() {
function generateNodeActions(node: INodeTypeDescription | undefined) { function generateNodeActions(node: INodeTypeDescription | undefined) {
if (!node) return []; if (!node) return [];
if (node.codex?.subcategories?.AI?.includes('Tools')) return []; if (
node.codex?.subcategories?.AI?.includes(AI_CATEGORY_TOOLS) &&
!node.codex?.subcategories?.AI?.includes(AI_CATEGORY_ROOT_NODES)
)
return [];
return [ return [
...triggersCategory(node), ...triggersCategory(node),
...operationsCategory(node), ...operationsCategory(node),
@ -269,6 +281,7 @@ export function useActionsGenerator() {
...modeCategory(node), ...modeCategory(node),
]; ];
} }
function filterActions(actions: ActionTypeDescription[]) { function filterActions(actions: ActionTypeDescription[]) {
// Do not show single action nodes // Do not show single action nodes
if (actions.length <= 1) return []; if (actions.length <= 1) return [];
@ -320,7 +333,6 @@ export function useActionsGenerator() {
const visibleNodeTypes = [...nodeTypes]; const visibleNodeTypes = [...nodeTypes];
const actions: ActionsRecord<typeof mergedNodes> = {}; const actions: ActionsRecord<typeof mergedNodes> = {};
const mergedNodes: SimplifiedNodeType[] = []; const mergedNodes: SimplifiedNodeType[] = [];
visibleNodeTypes visibleNodeTypes
.filter((node) => !node.group.includes('trigger')) .filter((node) => !node.group.includes('trigger'))
.forEach((app) => { .forEach((app) => {

View file

@ -1,4 +1,5 @@
import type { import type {
ActionTypeDescription,
INodeCreateElement, INodeCreateElement,
NodeCreateElement, NodeCreateElement,
NodeFilterType, NodeFilterType,
@ -6,6 +7,7 @@ import type {
} from '@/Interface'; } from '@/Interface';
import { import {
AI_CATEGORY_ROOT_NODES, AI_CATEGORY_ROOT_NODES,
AI_CATEGORY_TOOLS,
AI_CODE_NODE_TYPE, AI_CODE_NODE_TYPE,
AI_NODE_CREATOR_VIEW, AI_NODE_CREATOR_VIEW,
AI_OTHERS_NODE_CREATOR_VIEW, AI_OTHERS_NODE_CREATOR_VIEW,
@ -36,12 +38,8 @@ import { useI18n } from '@/composables/useI18n';
import { useKeyboardNavigation } from './useKeyboardNavigation'; import { useKeyboardNavigation } from './useKeyboardNavigation';
import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { import { AI_TRANSFORM_NODE_TYPE } from 'n8n-workflow';
AI_TRANSFORM_NODE_TYPE, import type { NodeConnectionType, INodeInputFilter, Themed } from 'n8n-workflow';
type INodeInputFilter,
type NodeConnectionType,
type Themed,
} from 'n8n-workflow';
import { useCanvasStore } from '@/stores/canvas.store'; import { useCanvasStore } from '@/stores/canvas.store';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
@ -71,6 +69,7 @@ interface ViewStack {
hideActions?: boolean; hideActions?: boolean;
baseFilter?: (item: INodeCreateElement) => boolean; baseFilter?: (item: INodeCreateElement) => boolean;
itemsMapper?: (item: INodeCreateElement) => INodeCreateElement; itemsMapper?: (item: INodeCreateElement) => INodeCreateElement;
actionsFilter?: (items: ActionTypeDescription[]) => ActionTypeDescription[];
panelClass?: string; panelClass?: string;
sections?: string[] | NodeViewItemSection[]; sections?: string[] | NodeViewItemSection[];
} }
@ -207,8 +206,10 @@ export const useViewStacks = defineStore('nodeCreatorViewStacks', () => {
return items.filter((node) => { return items.filter((node) => {
if (node.type !== 'node') return false; if (node.type !== 'node') return false;
return node.properties.codex?.subcategories?.[AI_SUBCATEGORY].includes( const subcategories = node.properties.codex?.subcategories?.[AI_SUBCATEGORY] ?? [];
AI_CATEGORY_ROOT_NODES, return (
subcategories.includes(AI_CATEGORY_ROOT_NODES) &&
!subcategories?.includes(AI_CATEGORY_TOOLS)
); );
}); });
} }
@ -346,6 +347,13 @@ export const useViewStacks = defineStore('nodeCreatorViewStacks', () => {
subcategory: connectionType, subcategory: connectionType,
}; };
}, },
actionsFilter: (items: ActionTypeDescription[]) => {
// Filter out actions that are not compatible with the connection type
if (items.some((item) => item.outputConnectionType)) {
return items.filter((item) => item.outputConnectionType === connectionType);
}
return items;
},
hideActions: true, hideActions: true,
preventBack: true, preventBack: true,
}); });

View file

@ -8,6 +8,7 @@ import type {
} from '@/Interface'; } from '@/Interface';
import { import {
AI_CATEGORY_AGENTS, AI_CATEGORY_AGENTS,
AI_CATEGORY_OTHER_TOOLS,
AI_SUBCATEGORY, AI_SUBCATEGORY,
AI_TRANSFORM_NODE_TYPE, AI_TRANSFORM_NODE_TYPE,
CORE_NODES_CATEGORY, CORE_NODES_CATEGORY,
@ -169,6 +170,7 @@ export function groupItemsInSections(
result.sort((a, b) => { result.sort((a, b) => {
if (a.key.toLowerCase().includes('recommended')) return -1; if (a.key.toLowerCase().includes('recommended')) return -1;
if (b.key.toLowerCase().includes('recommended')) return 1; if (b.key.toLowerCase().includes('recommended')) return 1;
if (b.key === AI_CATEGORY_OTHER_TOOLS) return -1;
return 0; return 0;
}); });

View file

@ -283,6 +283,7 @@ export const AI_CATEGORY_RETRIEVERS = 'Retrievers';
export const AI_CATEGORY_EMBEDDING = 'Embeddings'; export const AI_CATEGORY_EMBEDDING = 'Embeddings';
export const AI_CATEGORY_DOCUMENT_LOADERS = 'Document Loaders'; export const AI_CATEGORY_DOCUMENT_LOADERS = 'Document Loaders';
export const AI_CATEGORY_TEXT_SPLITTERS = 'Text Splitters'; export const AI_CATEGORY_TEXT_SPLITTERS = 'Text Splitters';
export const AI_CATEGORY_OTHER_TOOLS = 'Other Tools';
export const AI_CATEGORY_ROOT_NODES = 'Root Nodes'; export const AI_CATEGORY_ROOT_NODES = 'Root Nodes';
export const AI_UNCATEGORIZED_CATEGORY = 'Miscellaneous'; export const AI_UNCATEGORIZED_CATEGORY = 'Miscellaneous';
export const AI_CODE_TOOL_LANGCHAIN_NODE_TYPE = '@n8n/n8n-nodes-langchain.toolCode'; export const AI_CODE_TOOL_LANGCHAIN_NODE_TYPE = '@n8n/n8n-nodes-langchain.toolCode';

View file

@ -1484,6 +1484,7 @@ export interface INodePropertyOptions {
action?: string; action?: string;
description?: string; description?: string;
routing?: INodePropertyRouting; routing?: INodePropertyRouting;
outputConnectionType?: NodeConnectionType;
} }
export interface INodeListSearchItems extends INodePropertyOptions { export interface INodeListSearchItems extends INodePropertyOptions {