mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
feat: Hide generated AI tools listed in NODES_EXCLUDE and empty node creator entries (#39577)
This commit is contained in:
parent
45edf565df
commit
5e376b69ed
|
|
@ -31,8 +31,9 @@ export class NodesConfig {
|
|||
/**
|
||||
* Node types to exclude from loading. Default excludes `ExecuteCommand` and `LocalFileTrigger` for security.
|
||||
* Set to an empty array to allow all node types.
|
||||
* Generated tool variants are accepted too. The base node stays available.
|
||||
*
|
||||
* @example '["n8n-nodes-base.hackerNews"]'
|
||||
* @example '["n8n-nodes-base.hackerNews", "n8n-nodes-base.dateTimeTool"]'
|
||||
*/
|
||||
@Env('NODES_EXCLUDE')
|
||||
exclude: JsonStringArray = ['n8n-nodes-base.executeCommand', 'n8n-nodes-base.localFileTrigger'];
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import type {
|
|||
ICredentialType,
|
||||
INodeProperties,
|
||||
INodeTypeDescription,
|
||||
KnownNodesAndCredentials,
|
||||
NodeLoader,
|
||||
} from 'n8n-workflow';
|
||||
import { UserError } from 'n8n-workflow';
|
||||
|
|
@ -873,6 +874,7 @@ describe('LoadNodesAndCredentials', () => {
|
|||
createHitlTools.mockClear();
|
||||
|
||||
instance = new LoadNodesAndCredentials(mock(), mock(), mock(), mock(), mock(), mock());
|
||||
instance.excludeNodes = [];
|
||||
});
|
||||
|
||||
it('should keep types in memory after post-processing for post-processors to read', async () => {
|
||||
|
|
@ -926,6 +928,53 @@ describe('LoadNodesAndCredentials', () => {
|
|||
expect(createHitlTools).toHaveBeenCalledWith(instance.types, expectedKnown);
|
||||
});
|
||||
|
||||
it('should drop generated tool types that NODES_EXCLUDE lists', async () => {
|
||||
createAiTools.mockImplementationOnce(
|
||||
(types: { nodes: INodeTypeDescription[] }, known: KnownNodesAndCredentials) => {
|
||||
for (const name of ['test-package.testNodeTool', 'test-package.otherNodeTool']) {
|
||||
types.nodes.push({ name } as INodeTypeDescription);
|
||||
known.nodes[name] = { className: 'TestNode', sourcePath: 'Test.node.js' };
|
||||
}
|
||||
known.credentials.testApi = {
|
||||
className: 'TestApi',
|
||||
sourcePath: 'TestApi.credentials.js',
|
||||
supportedNodes: [
|
||||
'test-package.testNode',
|
||||
'test-package.testNodeTool',
|
||||
'test-package.otherNodeTool',
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
instance.excludeNodes = ['test-package.testNodeTool'];
|
||||
instance.loaders = {
|
||||
'test-package': mock<NodeLoader>({
|
||||
packageName: 'test-package',
|
||||
known: {
|
||||
nodes: { testNode: { className: 'TestNode', sourcePath: 'Test.node.js' } },
|
||||
credentials: {},
|
||||
},
|
||||
types: { nodes: [{ name: 'testNode' }], credentials: [] },
|
||||
ensureTypesLoaded: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
};
|
||||
|
||||
await instance.postProcessLoaders();
|
||||
|
||||
expect(instance.types.nodes.map(({ name }) => name)).toEqual([
|
||||
'test-package.testNode',
|
||||
'test-package.otherNodeTool',
|
||||
]);
|
||||
expect(Object.keys(instance.knownNodes)).toEqual([
|
||||
'test-package.testNode',
|
||||
'test-package.otherNodeTool',
|
||||
]);
|
||||
expect(instance.knownCredentials.testApi.supportedNodes).toEqual([
|
||||
'test-package.testNode',
|
||||
'test-package.otherNodeTool',
|
||||
]);
|
||||
});
|
||||
|
||||
describe('atomic registry swap (known, loaded, types)', () => {
|
||||
const createLoader = () =>
|
||||
mock<NodeLoader>({
|
||||
|
|
@ -975,6 +1024,7 @@ describe('LoadNodesAndCredentials', () => {
|
|||
|
||||
beforeEach(() => {
|
||||
instance = new LoadNodesAndCredentials(mock(), mock(), mock(), mock(), mock(), mock());
|
||||
instance.excludeNodes = [];
|
||||
});
|
||||
|
||||
it('should return a snapshot of types with package-namespaced node names', async () => {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { NodeTypes } from '@/node-types';
|
|||
|
||||
describe('NodeTypes', () => {
|
||||
const logger = mock<Logger>();
|
||||
const loadNodesAndCredentials = mock<LoadNodesAndCredentials>();
|
||||
const loadNodesAndCredentials = mock<LoadNodesAndCredentials>({ excludeNodes: [] });
|
||||
|
||||
const nodeTypes: NodeTypes = new NodeTypes(logger, loadNodesAndCredentials);
|
||||
|
||||
|
|
@ -433,6 +433,43 @@ describe('NodeTypes', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('tool variants listed in NODES_EXCLUDE', () => {
|
||||
beforeEach(() => {
|
||||
loadNodesAndCredentials.excludeNodes = ['n8n-nodes-base.testNodeTool'];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
loadNodesAndCredentials.excludeNodes = [];
|
||||
});
|
||||
|
||||
it('should not resolve the excluded tool variant', () => {
|
||||
expect(() => nodeTypes.getByNameAndVersion('n8n-nodes-base.testNodeTool')).toThrow(
|
||||
UnrecognizedNodeTypeError,
|
||||
);
|
||||
});
|
||||
|
||||
it('should still resolve the base node', () => {
|
||||
expect(() => nodeTypes.getByNameAndVersion('n8n-nodes-base.testNode')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should not list versions for the excluded tool variant', () => {
|
||||
expect(nodeTypes.getSupportedVersions('n8n-nodes-base.testNodeTool')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not describe the excluded tool variant', () => {
|
||||
expect(() =>
|
||||
nodeTypes.getNodeTypeDescriptions([{ name: 'n8n-nodes-base.testNodeTool', version: 1 }]),
|
||||
).toThrow(UnrecognizedNodeTypeError);
|
||||
});
|
||||
|
||||
it('should resolve the excluded tool variant to itself', () => {
|
||||
expect(nodeTypes.resolveBaseName('n8n-nodes-base.testNodeTool')).toEqual({
|
||||
baseName: 'n8n-nodes-base.testNodeTool',
|
||||
isSyntheticTool: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSupportedVersions', () => {
|
||||
it('should return the single version of a plain node type', () => {
|
||||
expect(nodeTypes.getSupportedVersions('n8n-nodes-base.hitlNode')).toEqual([1]);
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ function makeRealNodeTypes(): NodeTypes {
|
|||
recognizesNode: (name: string) => name in baseTypes,
|
||||
getNode: (name: string) => baseTypes[name],
|
||||
loadedNodes: {} as Record<string, LoadedClass<INodeType>>,
|
||||
excludeNodes: [] as string[],
|
||||
};
|
||||
|
||||
return new NodeTypes(mock(), loader as never);
|
||||
|
|
|
|||
|
|
@ -631,6 +631,23 @@ export class LoadNodesAndCredentials {
|
|||
createAiTools(this.types, this.known);
|
||||
createHitlTools(this.types, this.known);
|
||||
|
||||
// Loaders filter base nodes only. Generated tool variants are filtered here.
|
||||
this.types.nodes = this.types.nodes.filter(({ name }) => !this.excludeNodes.includes(name));
|
||||
this.known.nodes = Object.fromEntries(
|
||||
Object.entries(this.known.nodes).filter(([name]) => !this.excludeNodes.includes(name)),
|
||||
);
|
||||
this.known.credentials = Object.fromEntries(
|
||||
Object.entries(this.known.credentials).map(([name, credential]) => [
|
||||
name,
|
||||
{
|
||||
...credential,
|
||||
supportedNodes: credential.supportedNodes?.filter(
|
||||
(node) => !this.excludeNodes.includes(node),
|
||||
),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
this.injectCustomApiCallOptions();
|
||||
|
||||
this.injectContextEstablishmentHooks();
|
||||
|
|
|
|||
|
|
@ -28,10 +28,15 @@ export class NodeTypes implements INodeTypes {
|
|||
* A "synthetic tool" has no implementation of its own: workflows persist
|
||||
* names like `gmailTool`, and the registry fabricates that node on demand by
|
||||
* converting the `gmail` base node into an agent tool.
|
||||
*
|
||||
* A tool name listed in `NODES_EXCLUDE` is never synthetic. It resolves to
|
||||
* itself, so loading it fails as an unrecognized type.
|
||||
*/
|
||||
resolveBaseName(nodeTypeName: string): { baseName: string; isSyntheticTool: boolean } {
|
||||
const isSyntheticTool =
|
||||
nodeTypeName.endsWith('Tool') && !this.loadNodesAndCredentials.recognizesNode(nodeTypeName);
|
||||
nodeTypeName.endsWith('Tool') &&
|
||||
!this.loadNodesAndCredentials.recognizesNode(nodeTypeName) &&
|
||||
!this.loadNodesAndCredentials.excludeNodes.includes(nodeTypeName);
|
||||
return {
|
||||
baseName: isSyntheticTool ? stripToolSuffix(nodeTypeName) : nodeTypeName,
|
||||
isSyntheticTool,
|
||||
|
|
|
|||
|
|
@ -867,6 +867,7 @@ describe('WorkflowBuilderService - node type loading', () => {
|
|||
mock(),
|
||||
mock(),
|
||||
);
|
||||
loadNodesAndCredentials.excludeNodes = [];
|
||||
|
||||
// Real LazyPackageDirectoryLoader reading from the mocked filesystem
|
||||
const loader = new LazyPackageDirectoryLoader(packageDir);
|
||||
|
|
|
|||
|
|
@ -1,41 +1,25 @@
|
|||
<script setup lang="ts">
|
||||
import camelCase from 'lodash/camelCase';
|
||||
import { computed } from 'vue';
|
||||
import type {
|
||||
ActionTypeDescription,
|
||||
INodeCreateElement,
|
||||
NodeCreateElement,
|
||||
NodeFilterType,
|
||||
NodeTypeSelectedPayload,
|
||||
} from '@/Interface';
|
||||
import type { INodeCreateElement, NodeTypeSelectedPayload } from '@/Interface';
|
||||
import {
|
||||
TRIGGER_NODE_CREATOR_VIEW,
|
||||
HTTP_REQUEST_NODE_TYPE,
|
||||
WEBHOOK_NODE_TYPE,
|
||||
REGULAR_NODE_CREATOR_VIEW,
|
||||
AI_NODE_CREATOR_VIEW,
|
||||
AI_OTHERS_NODE_CREATOR_VIEW,
|
||||
AI_MCP_TOOL_NODE_TYPE,
|
||||
HITL_SUBCATEGORY,
|
||||
MESSAGE_AN_AGENT_NODE_TYPE,
|
||||
AI_CATEGORY_MCP_NODES,
|
||||
ADD_EMPTY_GROUP_NODE_CREATOR_ITEM,
|
||||
REQUEST_NODE_FORM_URL,
|
||||
} from '@/app/constants';
|
||||
|
||||
import type { BaseTextKey } from '@n8n/i18n';
|
||||
import { useNodeCreatorStore } from '@/features/shared/nodeCreator/nodeCreator.store';
|
||||
|
||||
import { TriggerView, RegularView, AIView, AINodesView } from '../../views/viewsData';
|
||||
import {
|
||||
flattenCreateElements,
|
||||
filterAndSearchNodes,
|
||||
getNodeCreatorSearchItems,
|
||||
prepareCommunityNodeDetailsViewStack,
|
||||
transformNodeType,
|
||||
getRootSearchCallouts,
|
||||
shouldShowCommunityNodeDetails,
|
||||
getHumanInTheLoopActions,
|
||||
isNodeItemRestricted,
|
||||
sinkRestrictedNodesLast,
|
||||
} from '../../nodeCreator.utils';
|
||||
|
|
@ -73,8 +57,16 @@ const i18n = useI18n();
|
|||
const { isRagStarterCalloutVisible, openSampleWorkflowTemplate } = useCalloutHelpers();
|
||||
|
||||
const nodeCreatorStore = useNodeCreatorStore();
|
||||
const { mergedNodes, actions, onSubcategorySelected } = nodeCreatorStore;
|
||||
const { pushViewStack, popViewStack, isAiSubcategoryView, isHitlSubcategoryView } = useViewStacks();
|
||||
const { mergedNodes, onSubcategorySelected } = nodeCreatorStore;
|
||||
const {
|
||||
pushViewStack,
|
||||
popViewStack,
|
||||
isAiSubcategoryView,
|
||||
isHitlSubcategoryView,
|
||||
getFilteredActions,
|
||||
subcategoryStack,
|
||||
viewStackByKey,
|
||||
} = useViewStacks();
|
||||
const { setAddedNodeActionParameters, nodeCreateElementToNodeTypeSelectedPayload } = useActions();
|
||||
const emptyCanvasGroupsEnabled = useEmptyCanvasGroupsFlag();
|
||||
|
||||
|
|
@ -127,20 +119,6 @@ const isSearchResultEmpty = computed(() => {
|
|||
});
|
||||
const showSuggestionFooter = computed(() => isMcpCategory.value || isSearchResultEmpty.value);
|
||||
|
||||
function getFilteredActions(
|
||||
node: NodeCreateElement,
|
||||
actions: Record<string, ActionTypeDescription[]>,
|
||||
) {
|
||||
const nodeActions = actions?.[node.key] || [];
|
||||
if (activeViewStack.value.subcategory === HITL_SUBCATEGORY) {
|
||||
return getHumanInTheLoopActions(nodeActions);
|
||||
}
|
||||
if (activeViewStack.value.actionsFilter) {
|
||||
return activeViewStack.value.actionsFilter(nodeActions);
|
||||
}
|
||||
return nodeActions;
|
||||
}
|
||||
|
||||
function onSelected(item: INodeCreateElement) {
|
||||
if (item.key === ADD_EMPTY_GROUP_NODE_CREATOR_ITEM && shouldHideEmptyGroupCommand.value) return;
|
||||
|
||||
|
|
@ -149,43 +127,13 @@ function onSelected(item: INodeCreateElement) {
|
|||
if (item.type === 'node' && isNodeItemRestricted(item.key)) return;
|
||||
|
||||
if (item.type === 'subcategory') {
|
||||
const subcategoryKey = camelCase(item.properties.title);
|
||||
const title = i18n.baseText(`nodeCreator.subcategoryNames.${subcategoryKey}` as BaseTextKey);
|
||||
|
||||
// If the info message exists in locale, add it to the info field of the view
|
||||
const infoKey = `nodeCreator.subcategoryInfos.${subcategoryKey}` as BaseTextKey;
|
||||
const info = i18n.baseText(infoKey);
|
||||
const extendedInfo = info !== infoKey ? { info } : {};
|
||||
const nodeIcon = item.properties.icon
|
||||
? ({ type: 'icon', name: item.properties.icon } as const)
|
||||
: undefined;
|
||||
|
||||
pushViewStack({
|
||||
subcategory: item.key,
|
||||
mode: 'nodes',
|
||||
title,
|
||||
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,
|
||||
itemsMapper: subcategoriesMapper,
|
||||
sections: item.properties.sections,
|
||||
items: item.properties.items,
|
||||
hideActions: item.properties.hideActions,
|
||||
actionsFilter: item.properties.actionsFilter,
|
||||
});
|
||||
|
||||
onSubcategorySelected({
|
||||
subcategory: item.key,
|
||||
});
|
||||
pushViewStack(subcategoryStack(item, activeViewStack.value.rootView));
|
||||
onSubcategorySelected({ subcategory: item.key });
|
||||
}
|
||||
|
||||
if (item.type === 'node') {
|
||||
const payload = nodeCreateElementToNodeTypeSelectedPayload(item);
|
||||
let nodeActions = getFilteredActions(item, actions);
|
||||
let nodeActions = getFilteredActions(activeViewStack.value, item, nodeCreatorStore.actions);
|
||||
const notInstalledCommunityNode =
|
||||
isCommunityPackageName(item.key) && !nodeTypesStore.getIsNodeInstalled(item.key);
|
||||
const nodeIcon = getNodeIconSource(
|
||||
|
|
@ -215,7 +163,11 @@ function onSelected(item: INodeCreateElement) {
|
|||
notInstalledCommunityNode
|
||||
) {
|
||||
if (!nodeActions.length) {
|
||||
nodeActions = getFilteredActions(item, communityNodesAndActions.value.actions);
|
||||
nodeActions = getFilteredActions(
|
||||
activeViewStack.value,
|
||||
item,
|
||||
communityNodesAndActions.value.actions,
|
||||
);
|
||||
}
|
||||
|
||||
const viewStack = prepareCommunityNodeDetailsViewStack(
|
||||
|
|
@ -269,33 +221,12 @@ function onSelected(item: INodeCreateElement) {
|
|||
}
|
||||
|
||||
if (item.type === 'view') {
|
||||
const views = {
|
||||
[TRIGGER_NODE_CREATOR_VIEW]: TriggerView,
|
||||
[REGULAR_NODE_CREATOR_VIEW]: RegularView,
|
||||
[AI_NODE_CREATOR_VIEW]: AIView,
|
||||
[AI_OTHERS_NODE_CREATOR_VIEW]: AINodesView,
|
||||
};
|
||||
|
||||
const itemKey = item.key as keyof typeof views;
|
||||
const matchedView = views[itemKey];
|
||||
|
||||
if (!matchedView) {
|
||||
console.warn(`No view found for ${itemKey}`);
|
||||
const stack = viewStackByKey(item.key);
|
||||
if (!stack) {
|
||||
console.warn(`No view found for ${item.key}`);
|
||||
return;
|
||||
}
|
||||
const view = matchedView(mergedNodes);
|
||||
|
||||
pushViewStack({
|
||||
title: view.title,
|
||||
subtitle: view?.subtitle ?? '',
|
||||
info: view?.info ?? '',
|
||||
items: view.items as INodeCreateElement[],
|
||||
hasSearch: true,
|
||||
rootView: view.value as NodeFilterType,
|
||||
mode: 'nodes',
|
||||
// Root search should include all nodes and command items.
|
||||
searchItems: getNodeCreatorSearchItems(mergedNodes, view.items),
|
||||
});
|
||||
pushViewStack(stack);
|
||||
}
|
||||
|
||||
if (item.type === 'link') {
|
||||
|
|
@ -311,42 +242,6 @@ function onSelected(item: INodeCreateElement) {
|
|||
}
|
||||
}
|
||||
|
||||
function subcategoriesMapper(item: INodeCreateElement) {
|
||||
if (item.type !== 'node') return item;
|
||||
|
||||
const hasTriggerGroup = item.properties.group.includes('trigger');
|
||||
const nodeActions = getFilteredActions(item, actions);
|
||||
const hasActions = nodeActions.length > 0;
|
||||
|
||||
if (hasTriggerGroup && hasActions) {
|
||||
if (item.properties?.codex) {
|
||||
// Store the original name in the alias so we can search for it
|
||||
item.properties.codex.alias = [
|
||||
...(item.properties.codex?.alias || []),
|
||||
item.properties.displayName,
|
||||
];
|
||||
}
|
||||
item.properties.displayName = item.properties.displayName.replace(' Trigger', '');
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
function baseSubcategoriesFilter(item: INodeCreateElement): boolean {
|
||||
if (item.type === 'section') return true;
|
||||
if (item.type !== 'node') return false;
|
||||
|
||||
const hasTriggerGroup = item.properties.group.includes('trigger');
|
||||
const nodeActions = getFilteredActions(item, actions);
|
||||
const hasActions = nodeActions.length > 0;
|
||||
|
||||
const isTriggerRootView = activeViewStack.value.rootView === TRIGGER_NODE_CREATOR_VIEW;
|
||||
if (isTriggerRootView) {
|
||||
return hasActions || hasTriggerGroup;
|
||||
}
|
||||
|
||||
return hasActions || !hasTriggerGroup;
|
||||
}
|
||||
|
||||
const globalCallouts = computed<INodeCreateElement[]>(() => [
|
||||
...getRootSearchCallouts(
|
||||
activeViewStack.value.search ?? '',
|
||||
|
|
|
|||
|
|
@ -6,16 +6,30 @@ import type { INodeTypeDescription } from 'n8n-workflow';
|
|||
import { useNodeCreatorStore } from '@/features/shared/nodeCreator/nodeCreator.store';
|
||||
import { useViewStacks } from '@/features/shared/nodeCreator/composables/useViewStacks';
|
||||
import { mockRestrictedNodeTypes } from '@n8n/frontend-module-type-availability-policies/__tests__/mocks';
|
||||
import { mockNodeTypeDescription } from '@/__tests__/mocks';
|
||||
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
|
||||
import { mockActionCreateElement, mockSimplifiedNodeType } from '../../__tests__/utils';
|
||||
import NodesListPanel from './NodesListPanel.vue';
|
||||
import {
|
||||
REGULAR_NODE_CREATOR_VIEW,
|
||||
AI_CATEGORY_AGENTS,
|
||||
AI_CATEGORY_LANGUAGE_MODELS,
|
||||
AI_CATEGORY_ROOT_NODES,
|
||||
AI_NODE_CREATOR_VIEW,
|
||||
AI_SUBCATEGORY,
|
||||
CORE_NODES_CATEGORY,
|
||||
DEBOUNCE_TIME,
|
||||
FLOWS_CONTROL_SUBCATEGORY,
|
||||
HELPERS_SUBCATEGORY,
|
||||
HITL_SUBCATEGORY,
|
||||
HUMAN_IN_THE_LOOP_CATEGORY,
|
||||
TRANSFORM_DATA_SUBCATEGORY,
|
||||
OTHER_TRIGGER_NODES_SUBCATEGORY,
|
||||
REGULAR_NODE_CREATOR_VIEW,
|
||||
SCHEDULE_TRIGGER_NODE_TYPE,
|
||||
CUSTOM_API_CALL_KEY,
|
||||
HTTP_REQUEST_NODE_TYPE,
|
||||
NODE_CREATOR_OPEN_SOURCES,
|
||||
TRIGGER_NODE_CREATOR_VIEW,
|
||||
} from '@/app/constants';
|
||||
import type { ActionTypeDescription, NodeFilterType, SimplifiedNodeType } from '@/Interface';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
|
|
@ -120,7 +134,7 @@ describe('NodesListPanel', () => {
|
|||
await fireEvent.click(container.querySelector('.backButton')!);
|
||||
await nextTick();
|
||||
|
||||
expect(screen.queryAllByTestId('item-iterator-item')).toHaveLength(10);
|
||||
expect(screen.queryAllByTestId('item-iterator-item')).toHaveLength(9);
|
||||
});
|
||||
|
||||
it('should hide a listed trigger whose node type is not loaded', async () => {
|
||||
|
|
@ -145,6 +159,20 @@ describe('NodesListPanel', () => {
|
|||
group: ['input'],
|
||||
}) as INodeTypeDescription,
|
||||
);
|
||||
const categorizedNodes = [
|
||||
[CORE_NODES_CATEGORY, TRANSFORM_DATA_SUBCATEGORY],
|
||||
[CORE_NODES_CATEGORY, FLOWS_CONTROL_SUBCATEGORY],
|
||||
[CORE_NODES_CATEGORY, HELPERS_SUBCATEGORY],
|
||||
[HUMAN_IN_THE_LOOP_CATEGORY, HITL_SUBCATEGORY],
|
||||
].map(
|
||||
([category, subcategory]) =>
|
||||
mockSimplifiedNodeType({
|
||||
name: subcategory,
|
||||
displayName: subcategory,
|
||||
group: ['input'],
|
||||
codex: { categories: [category], subcategories: { [category]: [subcategory] } },
|
||||
}) as INodeTypeDescription,
|
||||
);
|
||||
|
||||
const wrapperComponent = defineComponent({
|
||||
components: {
|
||||
|
|
@ -163,6 +191,7 @@ describe('NodesListPanel', () => {
|
|||
},
|
||||
setup(props) {
|
||||
const { setMergeNodes, setSelectedView } = useNodeCreatorStore();
|
||||
vi.spyOn(useNodeTypesStore(), 'isNodeTypeUnavailable').mockReturnValue(false);
|
||||
|
||||
watch(
|
||||
() => props.nodeTypes,
|
||||
|
|
@ -187,14 +216,24 @@ describe('NodesListPanel', () => {
|
|||
renderComponent({
|
||||
pinia: createPinia(),
|
||||
props: {
|
||||
nodeTypes: mockedNodes,
|
||||
nodeTypes: [...mockedNodes, ...categorizedNodes],
|
||||
selectedView: REGULAR_NODE_CREATOR_VIEW,
|
||||
},
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
expect(screen.getByText('What happens next?')).toBeInTheDocument();
|
||||
expect(screen.queryAllByTestId('item-iterator-item')).toHaveLength(7);
|
||||
expect(screen.queryAllByTestId('item-iterator-item').map((item) => item.textContent)).toEqual(
|
||||
[
|
||||
expect.stringContaining('Action in an app'),
|
||||
expect.stringContaining('Data transformation'),
|
||||
expect.stringContaining('Flow'),
|
||||
expect.stringContaining('Core'),
|
||||
expect.stringContaining('Human review'),
|
||||
expect.stringContaining('Add another trigger'),
|
||||
expect.stringContaining('Group'),
|
||||
],
|
||||
);
|
||||
|
||||
screen.getByText('Action in an app').click();
|
||||
await nextTick();
|
||||
|
|
@ -327,8 +366,8 @@ describe('NodesListPanel', () => {
|
|||
target: { value: ' Node 1' },
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.queryAllByTestId('item-iterator-item')).toHaveLength(1));
|
||||
expect(screen.queryByText('Node 1')).toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.queryByText('Node 1')).toBeInTheDocument());
|
||||
expect(screen.queryAllByTestId('item-iterator-item')).toHaveLength(1);
|
||||
|
||||
expect(screen.getByTestId('node-creator-search-bar')).toHaveValue('Node 1');
|
||||
});
|
||||
|
|
@ -390,7 +429,10 @@ describe('NodesListPanel', () => {
|
|||
const workflowDocumentStore = useWorkflowDocumentStore(createWorkflowDocumentId(''));
|
||||
workflowDocumentStore.setNodes([source]);
|
||||
|
||||
const appNode = mockNodeTypeDescription({ name: 'appNode', displayName: 'App Node' });
|
||||
useNodeTypesStore().setNodeTypes([appNode]);
|
||||
const nodeCreatorStore = useNodeCreatorStore();
|
||||
nodeCreatorStore.setMergeNodes([appNode]);
|
||||
nodeCreatorStore.openingContext = 'replacement';
|
||||
nodeCreatorStore.setSelectedView(REGULAR_NODE_CREATOR_VIEW);
|
||||
});
|
||||
|
|
@ -838,6 +880,107 @@ describe('NodesListPanel', () => {
|
|||
);
|
||||
});
|
||||
|
||||
describe('entries without nodes', () => {
|
||||
const otherTrigger = mockNodeTypeDescription({
|
||||
name: 'otherTrigger',
|
||||
displayName: 'Other Trigger',
|
||||
group: ['trigger'],
|
||||
codex: {
|
||||
categories: [CORE_NODES_CATEGORY],
|
||||
subcategories: { [CORE_NODES_CATEGORY]: [OTHER_TRIGGER_NODES_SUBCATEGORY] },
|
||||
},
|
||||
});
|
||||
const agent = mockNodeTypeDescription({
|
||||
name: 'agent',
|
||||
displayName: 'Agent',
|
||||
codex: { categories: [AI_SUBCATEGORY], subcategories: { AI: [AI_CATEGORY_AGENTS] } },
|
||||
});
|
||||
const languageModel = mockNodeTypeDescription({
|
||||
name: 'languageModel',
|
||||
displayName: 'Language Model',
|
||||
codex: { categories: [AI_SUBCATEGORY], subcategories: { AI: [AI_CATEGORY_LANGUAGE_MODELS] } },
|
||||
});
|
||||
const unlistedAiNode = mockNodeTypeDescription({
|
||||
name: 'unlistedAiNode',
|
||||
displayName: 'Unlisted AI Node',
|
||||
codex: { categories: [AI_SUBCATEGORY], subcategories: { AI: [AI_CATEGORY_ROOT_NODES] } },
|
||||
});
|
||||
|
||||
function renderView(
|
||||
view: NodeFilterType,
|
||||
nodes: INodeTypeDescription[],
|
||||
restricted: Record<string, 'instance'> = {},
|
||||
) {
|
||||
getWrapperComponent(() => {
|
||||
const { setMergeNodes, setSelectedView } = useNodeCreatorStore();
|
||||
useNodeTypesStore().setNodeTypes(nodes);
|
||||
mockRestrictedNodeTypes(restricted);
|
||||
setMergeNodes(nodes);
|
||||
setSelectedView(view);
|
||||
return {};
|
||||
});
|
||||
return nextTick();
|
||||
}
|
||||
|
||||
it('should not list a subcategory that contains no nodes', async () => {
|
||||
await renderView(TRIGGER_NODE_CREATOR_VIEW, []);
|
||||
|
||||
expect(screen.queryByText('Other ways...')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should list a subcategory that contains a node', async () => {
|
||||
await renderView(TRIGGER_NODE_CREATOR_VIEW, [otherTrigger]);
|
||||
|
||||
expect(screen.getByText('Other ways...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not list a view whose subcategories contain no nodes', async () => {
|
||||
await renderView(AI_NODE_CREATOR_VIEW, [agent]);
|
||||
|
||||
expect(screen.getByText('Agent')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Other AI Nodes')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not list a view inside a section that contains no nodes', async () => {
|
||||
await renderView(REGULAR_NODE_CREATOR_VIEW, [agent]);
|
||||
|
||||
expect(screen.queryByText('Add another trigger')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should list a view inside a section that contains a node', async () => {
|
||||
const trigger = mockSimplifiedNodeType({
|
||||
name: 'otherTrigger',
|
||||
group: ['trigger'],
|
||||
codex: {
|
||||
categories: [CORE_NODES_CATEGORY],
|
||||
subcategories: { [CORE_NODES_CATEGORY]: [OTHER_TRIGGER_NODES_SUBCATEGORY] },
|
||||
},
|
||||
}) as INodeTypeDescription;
|
||||
await renderView(REGULAR_NODE_CREATOR_VIEW, [agent, trigger]);
|
||||
|
||||
expect(screen.getByText('Add another trigger')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should list a view when one of its subcategories contains a node', async () => {
|
||||
await renderView(AI_NODE_CREATOR_VIEW, [agent, languageModel]);
|
||||
|
||||
expect(screen.getByText('Other AI Nodes')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not count links as content', async () => {
|
||||
await renderView(REGULAR_NODE_CREATOR_VIEW, [unlistedAiNode]);
|
||||
|
||||
expect(screen.getByText('What happens next?')).toBeInTheDocument();
|
||||
expect(screen.queryByText('AI')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not count restricted nodes as content', async () => {
|
||||
await renderView(TRIGGER_NODE_CREATOR_VIEW, [otherTrigger], { otherTrigger: 'instance' });
|
||||
|
||||
expect(screen.queryByText('Other ways...')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('restricted node types', () => {
|
||||
const triggerNodes = [...Array(9).keys()].map(
|
||||
(n) =>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,10 @@
|
|||
<script setup lang="ts">
|
||||
import type { INodeCreateElement, NodeFilterType, SimplifiedNodeType } from '@/Interface';
|
||||
import type { NodeFilterType } from '@/Interface';
|
||||
import {
|
||||
ADD_EMPTY_GROUP_NODE_CREATOR_ITEM,
|
||||
AI_EVALUATION,
|
||||
AI_NODE_CREATOR_VIEW,
|
||||
AI_OTHERS_NODE_CREATOR_VIEW,
|
||||
AI_UNCATEGORIZED_CATEGORY,
|
||||
DEBOUNCE_TIME,
|
||||
HUMAN_IN_THE_LOOP_CATEGORY,
|
||||
isNodeCreatorOpenFromConnection,
|
||||
REGULAR_NODE_CREATOR_VIEW,
|
||||
TRIGGER_NODE_CREATOR_VIEW,
|
||||
} from '@/app/constants';
|
||||
import { computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
|
||||
|
|
@ -22,15 +16,7 @@ import { useDebounce } from '@n8n/composables/useDebounce';
|
|||
import { useI18n } from '@n8n/i18n';
|
||||
import { useKeyboardNavigation } from '../../composables/useKeyboardNavigation';
|
||||
import { useViewStacks, type ViewStack } from '../../composables/useViewStacks';
|
||||
import {
|
||||
AINodesView,
|
||||
AIView,
|
||||
HitlToolView,
|
||||
RegularView,
|
||||
TriggerView,
|
||||
type NodeView,
|
||||
} from '../../views/viewsData';
|
||||
import { getNodeCreatorSearchItems } from '../../nodeCreator.utils';
|
||||
import { NODE_CREATOR_VIEWS } from '../../views/viewsData';
|
||||
import ActionsRenderer from '../Modes/ActionsMode.vue';
|
||||
import AgentsRenderer from '../Modes/AgentsMode.vue';
|
||||
import NodesRenderer from '../Modes/NodesMode.vue';
|
||||
|
|
@ -49,7 +35,7 @@ const i18n = useI18n();
|
|||
const { callDebounced, debounce } = useDebounce();
|
||||
|
||||
const { mergedNodes } = useNodeCreatorStore();
|
||||
const { pushViewStack, popViewStack, updateCurrentViewStack } = useViewStacks();
|
||||
const { pushViewStack, popViewStack, updateCurrentViewStack, viewStack } = useViewStacks();
|
||||
const { setActiveItemIndex, attachKeydownEvent, detachKeydownEvent } = useKeyboardNavigation();
|
||||
const nodeCreatorStore = useNodeCreatorStore();
|
||||
const uiStore = useUIStore();
|
||||
|
|
@ -211,16 +197,6 @@ onUnmounted(() => {
|
|||
watch(
|
||||
() => nodeCreatorView.value,
|
||||
(selectedView) => {
|
||||
const views: Record<NodeFilterType, (nodes: SimplifiedNodeType[]) => NodeView> = {
|
||||
[TRIGGER_NODE_CREATOR_VIEW]: TriggerView,
|
||||
[REGULAR_NODE_CREATOR_VIEW]: RegularView,
|
||||
[AI_NODE_CREATOR_VIEW]: AIView,
|
||||
[AI_OTHERS_NODE_CREATOR_VIEW]: AINodesView,
|
||||
[AI_UNCATEGORIZED_CATEGORY]: AINodesView,
|
||||
[AI_EVALUATION]: AINodesView,
|
||||
[HUMAN_IN_THE_LOOP_CATEGORY]: HitlToolView,
|
||||
};
|
||||
|
||||
const additionalOptions: Partial<Record<NodeFilterType, Partial<ViewStack>>> = {
|
||||
// is a root view, but it should behave like a subcategory view
|
||||
[HUMAN_IN_THE_LOOP_CATEGORY]: {
|
||||
|
|
@ -228,31 +204,15 @@ watch(
|
|||
},
|
||||
};
|
||||
|
||||
const matchedView = views[selectedView];
|
||||
|
||||
if (!matchedView) {
|
||||
console.warn(`No view found for ${selectedView}`);
|
||||
return;
|
||||
}
|
||||
const view = matchedView(mergedNodes);
|
||||
const viewItems = isAddingInsideGroup.value
|
||||
const view = NODE_CREATOR_VIEWS[selectedView](mergedNodes);
|
||||
const items = isAddingInsideGroup.value
|
||||
? // Forbid adding groups inside groups (nesting not supported)
|
||||
view.items.filter((item) => item.key !== ADD_EMPTY_GROUP_NODE_CREATOR_ITEM)
|
||||
: view.items;
|
||||
const viewStack: ViewStack = {
|
||||
title: view.title,
|
||||
subtitle: view?.subtitle ?? '',
|
||||
items: viewItems as INodeCreateElement[],
|
||||
nodeIcon: view.nodeIcon,
|
||||
info: view.info,
|
||||
hasSearch: true,
|
||||
mode: 'nodes',
|
||||
rootView: selectedView,
|
||||
// Root search should include all nodes and command items.
|
||||
searchItems: getNodeCreatorSearchItems(mergedNodes, viewItems),
|
||||
pushViewStack({
|
||||
...viewStack({ ...view, items }, selectedView),
|
||||
...additionalOptions[selectedView],
|
||||
};
|
||||
pushViewStack(viewStack);
|
||||
});
|
||||
|
||||
const pending = nodeCreatorStore.consumePendingInitialViewStack();
|
||||
if (pending) {
|
||||
|
|
|
|||
|
|
@ -19,10 +19,12 @@ import {
|
|||
AI_SECTION_RECOMMENDED_TOOLS,
|
||||
AI_SUBCATEGORY,
|
||||
DEFAULT_SUBCATEGORY,
|
||||
HITL_SUBCATEGORY,
|
||||
HUMAN_IN_THE_LOOP_CATEGORY,
|
||||
NEW_TOOL_CATEGORIES,
|
||||
TRIGGER_NODE_CREATOR_VIEW,
|
||||
} from '@/app/constants';
|
||||
import camelCase from 'lodash/camelCase';
|
||||
import { defineStore } from 'pinia';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { computed, nextTick, ref } from 'vue';
|
||||
|
|
@ -34,6 +36,8 @@ import {
|
|||
extractAiGatewaySection,
|
||||
finalizeItems,
|
||||
flattenCreateElements,
|
||||
getHumanInTheLoopActions,
|
||||
getNodeCreatorSearchItems,
|
||||
isNodeItemRestricted,
|
||||
sinkRestrictedNodesLast,
|
||||
withoutRestrictedNodes,
|
||||
|
|
@ -48,9 +52,14 @@ import {
|
|||
transformNodeType,
|
||||
} from '../nodeCreator.utils';
|
||||
|
||||
import { isNodeViewItem, type NodeViewItem, type NodeViewItemSection } from '../views/viewsData';
|
||||
import { AINodesView } from '../views/viewsData';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import {
|
||||
isNodeViewItem,
|
||||
type NodeView,
|
||||
type NodeViewItem,
|
||||
type NodeViewItemSection,
|
||||
} from '../views/viewsData';
|
||||
import { AINodesView, NODE_CREATOR_VIEWS, isNodeCreatorView } from '../views/viewsData';
|
||||
import { useI18n, type BaseTextKey } from '@n8n/i18n';
|
||||
import { useKeyboardNavigation } from './useKeyboardNavigation';
|
||||
|
||||
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
|
||||
|
|
@ -542,58 +551,175 @@ export const useViewStacks = defineStore('nodeCreatorViewStacks', () => {
|
|||
);
|
||||
}
|
||||
|
||||
function getFilteredActions(
|
||||
stack: ViewStack,
|
||||
node: NodeCreateElement,
|
||||
actions: Record<string, ActionTypeDescription[]>,
|
||||
) {
|
||||
const nodeActions = actions?.[node.key] || [];
|
||||
if (stack.subcategory === HITL_SUBCATEGORY) {
|
||||
return getHumanInTheLoopActions(nodeActions);
|
||||
}
|
||||
if (stack.actionsFilter) {
|
||||
return stack.actionsFilter(nodeActions);
|
||||
}
|
||||
return nodeActions;
|
||||
}
|
||||
|
||||
function isListedInSubcategory(stack: ViewStack, item: INodeCreateElement): boolean {
|
||||
if (item.type === 'section') return true;
|
||||
if (item.type !== 'node') return false;
|
||||
|
||||
const hasTriggerGroup = item.properties.group.includes('trigger');
|
||||
const hasActions = getFilteredActions(stack, item, nodeCreatorStore.actions).length > 0;
|
||||
|
||||
if (stack.rootView === TRIGGER_NODE_CREATOR_VIEW) {
|
||||
return hasActions || hasTriggerGroup;
|
||||
}
|
||||
|
||||
return hasActions || !hasTriggerGroup;
|
||||
}
|
||||
|
||||
function mapSubcategoryItem(stack: ViewStack, item: INodeCreateElement) {
|
||||
if (item.type !== 'node') return item;
|
||||
|
||||
const hasTriggerGroup = item.properties.group.includes('trigger');
|
||||
const hasActions = getFilteredActions(stack, item, nodeCreatorStore.actions).length > 0;
|
||||
|
||||
if (!hasTriggerGroup || !hasActions) return item;
|
||||
|
||||
// Items are cached and share `codex` with the node types, so copy before changing them
|
||||
const { properties } = item;
|
||||
return {
|
||||
...item,
|
||||
properties: {
|
||||
...properties,
|
||||
displayName: properties.displayName.replace(' Trigger', ''),
|
||||
...(properties.codex && {
|
||||
// Store the original name in the alias so we can search for it
|
||||
codex: {
|
||||
...properties.codex,
|
||||
alias: [...(properties.codex.alias ?? []), properties.displayName],
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function subcategoryStack(item: SubcategoryCreateElement, rootView?: NodeFilterType): ViewStack {
|
||||
const subcategoryKey = camelCase(item.properties.title);
|
||||
// If the info message exists in locale, add it to the info field of the view
|
||||
const infoKey = `nodeCreator.subcategoryInfos.${subcategoryKey}` as BaseTextKey;
|
||||
const info = i18n.baseText(infoKey);
|
||||
|
||||
const stack: ViewStack = {
|
||||
subcategory: item.key,
|
||||
mode: 'nodes',
|
||||
title: i18n.baseText(`nodeCreator.subcategoryNames.${subcategoryKey}` as BaseTextKey),
|
||||
nodeIcon: item.properties.icon ? { type: 'icon', name: item.properties.icon } : undefined,
|
||||
...(info !== infoKey ? { info } : {}),
|
||||
...(item.properties.panelClass ? { panelClass: item.properties.panelClass } : {}),
|
||||
...(item.properties.connectionType ? { connectionType: item.properties.connectionType } : {}),
|
||||
rootView,
|
||||
forceIncludeNodes: item.properties.forceIncludeNodes,
|
||||
sections: item.properties.sections,
|
||||
items: item.properties.items,
|
||||
hideActions: item.properties.hideActions,
|
||||
actionsFilter: item.properties.actionsFilter,
|
||||
};
|
||||
|
||||
return {
|
||||
...stack,
|
||||
baseFilter: (element) => isListedInSubcategory(stack, element),
|
||||
itemsMapper: (element) => mapSubcategoryItem(stack, element),
|
||||
};
|
||||
}
|
||||
|
||||
function viewStack(view: NodeView, rootView: NodeFilterType = view.value): ViewStack {
|
||||
return {
|
||||
title: view.title,
|
||||
subtitle: view.subtitle ?? '',
|
||||
info: view.info,
|
||||
nodeIcon: view.nodeIcon,
|
||||
items: view.items as INodeCreateElement[],
|
||||
hasSearch: true,
|
||||
mode: 'nodes',
|
||||
rootView,
|
||||
// Root search should include all nodes and command items.
|
||||
searchItems: getNodeCreatorSearchItems(nodeCreatorStore.mergedNodes, view.items),
|
||||
};
|
||||
}
|
||||
|
||||
function viewStackByKey(key: string): ViewStack | undefined {
|
||||
if (!isNodeCreatorView(key)) return undefined;
|
||||
return viewStack(NODE_CREATOR_VIEWS[key](nodeCreatorStore.mergedNodes));
|
||||
}
|
||||
|
||||
function subcategoryItems(stack: ViewStack): INodeCreateElement[] {
|
||||
const items = (itemsBySubcategory.value[stack.subcategory ?? DEFAULT_SUBCATEGORY] ?? []).filter(
|
||||
(item) => settingsStore.isAskAiEnabled || item.key !== AI_TRANSFORM_NODE_TYPE,
|
||||
);
|
||||
return stack.sections ? groupItemsInSections(items, stack.sections) : items;
|
||||
}
|
||||
|
||||
function listedStackItems(stack: ViewStack): INodeCreateElement[] {
|
||||
// Views list some nodes by hand. Every other list is built from the loaded node types.
|
||||
const listed =
|
||||
stack.items?.filter(
|
||||
(item) => item.type !== 'node' || !nodeTypesStore.isNodeTypeUnavailable(item.key),
|
||||
) ?? subcategoryItems(stack);
|
||||
|
||||
const forced = nodeCreatorStore.mergedNodes
|
||||
.filter((node) => stack.forceIncludeNodes?.includes(node.name))
|
||||
.map((node) => transformNodeType(node, stack.subcategory));
|
||||
|
||||
const items = [...listed, ...forced];
|
||||
return stack.baseFilter ? items.filter(stack.baseFilter) : items;
|
||||
}
|
||||
|
||||
/** A stack's items before mapping and sorting, without entries that open an empty list. */
|
||||
function collectStackItems(stack: ViewStack): INodeCreateElement[] {
|
||||
return listedStackItems(stack).flatMap((item): INodeCreateElement[] => {
|
||||
if (item.type !== 'section') return opensNodes(item, stack.rootView) ? [item] : [];
|
||||
const children = item.children.filter((child) => opensNodes(child, stack.rootView));
|
||||
return children.length > 0 ? [{ ...item, children }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function opensNodes(item: INodeCreateElement, rootView?: NodeFilterType): boolean {
|
||||
if (item.type === 'subcategory') return hasBrowsableNodes(subcategoryStack(item, rootView));
|
||||
if (item.type === 'view') {
|
||||
const stack = viewStackByKey(item.key);
|
||||
return stack !== undefined && hasBrowsableNodes(stack);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Links and callouts are not content. Direct nodes are checked first so that
|
||||
// nested lists are resolved only when the stack has no node of its own.
|
||||
function hasBrowsableNodes(stack: ViewStack): boolean {
|
||||
const items = flattenCreateElements(
|
||||
withoutRestrictedNodes(listedStackItems(stack), isNodeItemRestricted),
|
||||
);
|
||||
return (
|
||||
items.some((item) => item.type === 'node') ||
|
||||
items.some(
|
||||
(item) =>
|
||||
(item.type === 'subcategory' || item.type === 'view') && opensNodes(item, stack.rootView),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function setStackBaselineItems() {
|
||||
const stack = getLastActiveStack();
|
||||
if (!stack || !activeViewStack.value.uuid) return;
|
||||
|
||||
// Views list some nodes by hand. Every other list is built from the loaded node types.
|
||||
let stackItems = (stack?.items ?? []).filter(
|
||||
(item) => item.type !== 'node' || !nodeTypesStore.isNodeTypeUnavailable(item.key),
|
||||
);
|
||||
|
||||
if (!stack?.items) {
|
||||
const subcategory = stack?.subcategory ?? DEFAULT_SUBCATEGORY;
|
||||
let itemsInSubcategory: INodeCreateElement[] | undefined =
|
||||
itemsBySubcategory.value[subcategory];
|
||||
|
||||
const isAskAiEnabled = settingsStore.isAskAiEnabled;
|
||||
if (!isAskAiEnabled) {
|
||||
itemsInSubcategory =
|
||||
itemsInSubcategory?.filter((item) => item.key !== AI_TRANSFORM_NODE_TYPE) ?? [];
|
||||
}
|
||||
const sections = stack.sections;
|
||||
|
||||
if (sections) {
|
||||
stackItems = groupItemsInSections(itemsInSubcategory, sections);
|
||||
} else {
|
||||
stackItems = itemsInSubcategory;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure that the nodes specified in `stack.forceIncludeNodes` are always included,
|
||||
// regardless of whether the subcategory is matched
|
||||
if ((stack.forceIncludeNodes ?? []).length > 0) {
|
||||
const matchedNodes = nodeCreatorStore.mergedNodes
|
||||
.filter((item) => stack.forceIncludeNodes?.includes(item.name))
|
||||
.map((item) => transformNodeType(item, stack.subcategory));
|
||||
|
||||
stackItems.push(...matchedNodes);
|
||||
}
|
||||
|
||||
if (stack.baseFilter) {
|
||||
stackItems = stackItems.filter(stack.baseFilter);
|
||||
}
|
||||
|
||||
if (stack.itemsMapper) {
|
||||
stackItems = stackItems.map(stack.itemsMapper);
|
||||
}
|
||||
|
||||
const items = collectStackItems(stack);
|
||||
const mapped = stack.itemsMapper ? items.map(stack.itemsMapper) : items;
|
||||
// Sort only if non-root view
|
||||
if (!stack.items) {
|
||||
stackItems = sortNodeCreateElements(stackItems);
|
||||
}
|
||||
|
||||
updateCurrentViewStack({ baselineItems: stackItems });
|
||||
updateCurrentViewStack({
|
||||
baselineItems: stack.items ? mapped : sortNodeCreateElements(mapped),
|
||||
});
|
||||
}
|
||||
|
||||
function pushViewStack(
|
||||
|
|
@ -651,6 +777,10 @@ export const useViewStacks = defineStore('nodeCreatorViewStacks', () => {
|
|||
globalSearchItemsDiff,
|
||||
isAiSubcategoryView,
|
||||
gotoCompatibleConnectionView,
|
||||
getFilteredActions,
|
||||
subcategoryStack,
|
||||
viewStack,
|
||||
viewStackByKey,
|
||||
resetViewStacks,
|
||||
updateCurrentViewStack,
|
||||
pushViewStack,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
AI_CATEGORY_TOOLS,
|
||||
AI_CATEGORY_VECTOR_STORES,
|
||||
AI_CODE_TOOL_LANGCHAIN_NODE_TYPE,
|
||||
AI_EVALUATION,
|
||||
AI_NODE_CREATOR_VIEW,
|
||||
AI_OTHERS_NODE_CREATOR_VIEW,
|
||||
AI_SUBCATEGORY,
|
||||
|
|
@ -69,6 +70,7 @@ import { useEvaluationStore } from '@/features/ai/evaluation.ee/evaluation.store
|
|||
import { useTemplatesStore } from '@/features/workflows/templates/templates.store';
|
||||
import type {
|
||||
CommandCreateElement,
|
||||
NodeFilterType,
|
||||
SectionCreateElement,
|
||||
SimplifiedNodeType,
|
||||
ViewCreateElement,
|
||||
|
|
@ -127,7 +129,7 @@ export function isNodeViewSection(item: NodeViewElement): item is SectionCreateE
|
|||
}
|
||||
|
||||
export interface NodeView {
|
||||
value: string;
|
||||
value: NodeFilterType;
|
||||
title: string;
|
||||
info?: string;
|
||||
subtitle?: string;
|
||||
|
|
@ -660,18 +662,16 @@ export function RegularView(nodes: SimplifiedNodeType[]) {
|
|||
],
|
||||
};
|
||||
|
||||
const hasAINodes = (nodes ?? []).some((node) => node.codex?.categories?.includes(AI_SUBCATEGORY));
|
||||
if (hasAINodes)
|
||||
view.items.unshift({
|
||||
key: AI_NODE_CREATOR_VIEW,
|
||||
type: 'view',
|
||||
properties: {
|
||||
title: i18n.baseText('nodeCreator.aiPanel.langchainAiNodes'),
|
||||
icon: 'robot',
|
||||
description: i18n.baseText('nodeCreator.aiPanel.nodesForAi'),
|
||||
borderless: true,
|
||||
},
|
||||
} as NodeViewItem);
|
||||
view.items.unshift({
|
||||
key: AI_NODE_CREATOR_VIEW,
|
||||
type: 'view',
|
||||
properties: {
|
||||
title: i18n.baseText('nodeCreator.aiPanel.langchainAiNodes'),
|
||||
icon: 'robot',
|
||||
description: i18n.baseText('nodeCreator.aiPanel.nodesForAi'),
|
||||
borderless: true,
|
||||
},
|
||||
} as NodeViewItem);
|
||||
|
||||
view.items.push({
|
||||
type: 'section',
|
||||
|
|
@ -716,3 +716,18 @@ export function HitlToolView(nodes: SimplifiedNodeType[]): NodeView {
|
|||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const NODE_CREATOR_VIEWS: Record<NodeFilterType, (nodes: SimplifiedNodeType[]) => NodeView> =
|
||||
{
|
||||
[TRIGGER_NODE_CREATOR_VIEW]: TriggerView,
|
||||
[REGULAR_NODE_CREATOR_VIEW]: RegularView,
|
||||
[AI_NODE_CREATOR_VIEW]: AIView,
|
||||
[AI_OTHERS_NODE_CREATOR_VIEW]: AINodesView,
|
||||
[AI_UNCATEGORIZED_CATEGORY]: AINodesView,
|
||||
[AI_EVALUATION]: AINodesView,
|
||||
[HUMAN_IN_THE_LOOP_CATEGORY]: HitlToolView,
|
||||
};
|
||||
|
||||
export function isNodeCreatorView(key: string): key is NodeFilterType {
|
||||
return Object.hasOwn(NODE_CREATOR_VIEWS, key);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user