mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 16:10:30 +02:00
fix(editor): Surface unofficial verified community node tools in AI Tools picker (#28985)
This commit is contained in:
parent
6175fd6f7b
commit
f77dfd1a11
|
|
@ -464,6 +464,7 @@ export interface SubcategoryItemProps {
|
|||
color?: string;
|
||||
};
|
||||
panelClass?: string;
|
||||
connectionType?: NodeConnectionType;
|
||||
title?: string;
|
||||
subcategory?: string;
|
||||
defaults?: INodeParameters;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user