fix(editor): Surface unofficial verified community node tools in AI Tools picker (#28985)

This commit is contained in:
Garrit Franke 2026-04-30 12:03:47 +02:00 committed by GitHub
parent 6175fd6f7b
commit f77dfd1a11
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 170 additions and 8 deletions

View File

@ -464,6 +464,7 @@ export interface SubcategoryItemProps {
color?: string;
};
panelClass?: string;
connectionType?: NodeConnectionType;
title?: string;
subcategory?: string;
defaults?: INodeParameters;

View File

@ -76,7 +76,11 @@ const moreFromCommunity = computed(() => {
return filterAndSearchNodes(
communityNodesAndActions.value.mergedNodes,
activeViewStack.value.search ?? '',
isAiSubcategoryView(activeViewStack.value) || isHitlSubcategoryView(activeViewStack.value),
{
isAiSubcategory: isAiSubcategoryView(activeViewStack.value),
isHitlSubcategory: isHitlSubcategoryView(activeViewStack.value),
aiConnectionType: activeViewStack.value.connectionType,
},
);
});
@ -124,6 +128,7 @@ function onSelected(item: INodeCreateElement) {
nodeIcon,
...extendedInfo,
...(item.properties.panelClass ? { panelClass: item.properties.panelClass } : {}),
...(item.properties.connectionType ? { connectionType: item.properties.connectionType } : {}),
rootView: activeViewStack.value.rootView,
forceIncludeNodes: item.properties.forceIncludeNodes,
baseFilter: baseSubcategoriesFilter,

View File

@ -96,6 +96,7 @@ export interface ViewStack {
itemsMapper?: (item: INodeCreateElement) => INodeCreateElement;
actionsFilter?: (items: ActionTypeDescription[]) => ActionTypeDescription[];
panelClass?: string;
connectionType?: NodeConnectionType;
sections?: string[] | NodeViewItemSection[];
communityNodeDetails?: CommunityNodeDetails;
}
@ -448,6 +449,7 @@ export const useViewStacks = defineStore('nodeCreatorViewStacks', () => {
}
: undefined,
panelClass: relatedAIView?.properties.panelClass,
connectionType,
baseFilter: (i: INodeCreateElement) => {
// AI Code node could have any connection type so we don't want to display it
// in the compatible connection view as it would be displayed in all of them

View File

@ -140,19 +140,140 @@ describe('NodeCreator - utils', () => {
];
test('should return only one node', () => {
const result = filterAndSearchNodes(mergedNodes, 'sample', false);
const result = filterAndSearchNodes(mergedNodes, 'sample');
expect(result.length).toEqual(1);
expect(result[0].key).toEqual('n8n-nodes-preview-test.SampleNode');
});
test('should return two nodes', () => {
const result = filterAndSearchNodes(mergedNodes, 'node', false);
const result = filterAndSearchNodes(mergedNodes, 'node');
expect(result.length).toEqual(2);
expect(result[1].key).toEqual('n8n-nodes-preview-test.SampleNode');
expect(result[0].key).toEqual('n8n-nodes-preview-test.OtherNode');
});
test('should return [] when in HITL subcategory', () => {
const result = filterAndSearchNodes(mergedNodes, 'node', { isHitlSubcategory: true });
expect(result).toEqual([]);
});
describe('AI subcategory pickers', () => {
const aiNodes: SimplifiedNodeType[] = [
{
displayName: 'Instagram Tool',
defaults: { name: 'Instagram' },
description: 'Instagram as tool',
name: '@mookielianhd/n8n-nodes-preview-instagram.instagramTool',
group: ['transform'],
outputs: ['ai_tool'],
},
{
displayName: 'Instagram',
defaults: { name: 'Instagram' },
description: 'Instagram node',
name: '@mookielianhd/n8n-nodes-preview-instagram.instagram',
group: ['transform'],
outputs: ['main'],
},
{
displayName: 'Other Tool',
defaults: { name: 'OtherTool' },
description: 'Other tool',
name: 'n8n-nodes-preview-other.otherTool',
group: ['transform'],
outputs: [{ type: 'ai_tool' }],
},
{
displayName: 'Acme Language Model',
defaults: { name: 'AcmeLM' },
description: 'Community language model',
name: 'n8n-nodes-preview-acme.acmeLanguageModel',
group: ['transform'],
outputs: ['ai_languageModel'],
},
];
test('in the Tools picker surfaces only AiTool-output community nodes', () => {
const result = filterAndSearchNodes(aiNodes, 'instagram', {
isAiSubcategory: true,
aiConnectionType: 'ai_tool',
});
expect(result).toHaveLength(1);
expect(result[0].key).toEqual('@mookielianhd/n8n-nodes-preview-instagram.instagramTool');
});
test('supports object-form outputs when matching the picker connection type', () => {
const result = filterAndSearchNodes(aiNodes, 'other', {
isAiSubcategory: true,
aiConnectionType: 'ai_tool',
});
expect(result).toHaveLength(1);
expect(result[0].key).toEqual('n8n-nodes-preview-other.otherTool');
});
test('in the Language Model picker surfaces only AiLanguageModel-output nodes', () => {
const result = filterAndSearchNodes(aiNodes, 'acme', {
isAiSubcategory: true,
aiConnectionType: 'ai_languageModel',
});
expect(result).toHaveLength(1);
expect(result[0].key).toEqual('n8n-nodes-preview-acme.acmeLanguageModel');
});
test('does not leak AiTool nodes into the Language Model picker', () => {
const result = filterAndSearchNodes(aiNodes, 'instagram', {
isAiSubcategory: true,
aiConnectionType: 'ai_languageModel',
});
expect(result).toEqual([]);
});
test('returns [] when AI subcategory is active but the connection type is unknown', () => {
const result = filterAndSearchNodes(aiNodes, 'instagram', {
isAiSubcategory: true,
});
expect(result).toEqual([]);
});
test('returns [] when search is empty even in AI subcategory', () => {
const result = filterAndSearchNodes(aiNodes, '', {
isAiSubcategory: true,
aiConnectionType: 'ai_tool',
});
expect(result).toEqual([]);
});
test('skips nodes with expression-string outputs without throwing', () => {
const nodesWithExpressionOutputs: SimplifiedNodeType[] = [
{
displayName: 'Dynamic Outputs Node',
defaults: { name: 'Dynamic' },
description: 'Node with dynamically computed outputs',
name: 'n8n-nodes-preview-dynamic.dynamic',
group: ['transform'],
// INodeTypeDescription.outputs can be an expression string, not an array
outputs:
'={{ $parameter["mode"] === "tool" ? ["ai_tool"] : ["main"] }}' as unknown as SimplifiedNodeType['outputs'],
},
...aiNodes,
];
const result = filterAndSearchNodes(nodesWithExpressionOutputs, 'dynamic', {
isAiSubcategory: true,
aiConnectionType: 'ai_tool',
});
expect(result).toEqual([]);
});
});
});
describe('prepareCommunityNodeDetailsViewStack', () => {
beforeEach(() => {

View File

@ -40,6 +40,7 @@ import { useSettingsStore } from '@/app/stores/settings.store';
import type { NodeIconSource } from '@/app/utils/nodeIcon';
import { SampleTemplates } from '@/features/workflows/templates/utils/workflowSamples';
import type { IconName } from '@n8n/design-system/components/N8nIcon/icons';
import type { INodeOutputConfiguration, NodeConnectionType } from 'n8n-workflow';
import { SEND_AND_WAIT_OPERATION } from 'n8n-workflow';
import type { CommunityNodeDetails, ViewStack } from './composables/useViewStacks';
@ -318,18 +319,50 @@ export function finalizeItems(items: INodeCreateElement[]): INodeCreateElement[]
.map(applyNodeTags);
}
const hasMatchingOutput = (
node: SimplifiedNodeType,
connectionType: NodeConnectionType,
): boolean => {
const outputs = node.outputs;
if (!Array.isArray(outputs)) return false;
return outputs.some((output: NodeConnectionType | INodeOutputConfiguration) =>
typeof output === 'string' ? output === connectionType : output?.type === connectionType,
);
};
export const filterAndSearchNodes = (
mergedNodes: SimplifiedNodeType[],
search: string,
isAgentSubcategory: boolean,
options: {
isAiSubcategory?: boolean;
isHitlSubcategory?: boolean;
aiConnectionType?: NodeConnectionType;
} = {},
) => {
if (!search || isAgentSubcategory) return [];
if (!search) return [];
const { isAiSubcategory = false, isHitlSubcategory = false, aiConnectionType } = options;
// HITL surfacing from community nodes is not supported yet — see
// CommunityNodeTypesService.createAiTools which only generates `...Tool`
// variants, never `...HitlTool` variants.
if (isHitlSubcategory) return [];
// AI sub-pickers (Tools, Language Model, Memory, Vector Store, …) all share
// rootView === AI_OTHERS_NODE_CREATOR_VIEW but target different connection
// types. Only surface community results when we know which connection type
// the picker is scoped to, and only for nodes whose outputs match it, so
// tool nodes don't leak into the Language Model / Memory / … pickers.
if (isAiSubcategory) {
if (!aiConnectionType) return [];
const candidates = mergedNodes.filter((node) => hasMatchingOutput(node, aiConnectionType));
const vettedNodes = candidates.map((item) => transformNodeType(item)) as NodeCreateElement[];
return finalizeItems(searchNodes(search, vettedNodes));
}
const vettedNodes = mergedNodes.map((item) => transformNodeType(item)) as NodeCreateElement[];
const searchResult: INodeCreateElement[] = finalizeItems(searchNodes(search || '', vettedNodes));
return searchResult;
return finalizeItems(searchNodes(search, vettedNodes));
};
export function prepareCommunityNodeDetailsViewStack(