feat: Human in the loop section (#12883)

Co-authored-by: Dana <152518854+dana-gill@users.noreply.github.com>
Co-authored-by: Jonathan Bennetts <jonathan.bennetts@gmail.com>
This commit is contained in:
Michael Kret
2025-01-30 16:16:42 +02:00
committed by GitHub
parent 0d8a544975
commit 9590e5d58b
15 changed files with 106 additions and 18 deletions

View File

@@ -571,4 +571,13 @@ describe('Node Creator', () => {
addVectorStoreToolToParent('In-Memory Vector Store', AGENT_NODE_NAME);
});
it('should insert node to canvas with sendAndWait operation selected', () => {
nodeCreatorFeature.getters.canvasAddButton().click();
WorkflowPage.actions.addNodeToCanvas('Manual', false);
nodeCreatorFeature.actions.openNodeCreator();
cy.contains('Human in the loop').click();
nodeCreatorFeature.getters.getCreatorItem('Slack').click();
cy.contains('Send and Wait for Response').should('exist');
});
});

View File

@@ -6,6 +6,7 @@ import {
CREDENTIAL_ONLY_NODE_PREFIX,
DEFAULT_SUBCATEGORY,
DRAG_EVENT_DATA_KEY,
HITL_SUBCATEGORY,
} from '@/constants';
import { isCommunityPackageName } from '@/utils/nodeTypesUtils';
@@ -44,6 +45,9 @@ const draggablePosition = ref({ x: -100, y: -100 });
const draggableDataTransfer = ref(null as Element | null);
const description = computed<string>(() => {
if (isSendAndWaitCategory.value) {
return '';
}
if (
props.subcategory === DEFAULT_SUBCATEGORY &&
!props.nodeType.name.startsWith(CREDENTIAL_ONLY_NODE_PREFIX)
@@ -56,7 +60,8 @@ const description = computed<string>(() => {
fallback: props.nodeType.description,
});
});
const showActionArrow = computed(() => hasActions.value);
const showActionArrow = computed(() => hasActions.value && !isSendAndWaitCategory.value);
const isSendAndWaitCategory = computed(() => activeViewStack.subcategory === HITL_SUBCATEGORY);
const dataTestId = computed(() =>
hasActions.value ? 'node-creator-action-item' : 'node-creator-node-item',
);

View File

@@ -1,7 +1,12 @@
<script setup lang="ts">
import { camelCase } from 'lodash-es';
import { computed } from 'vue';
import type { INodeCreateElement, NodeCreateElement, NodeFilterType } from '@/Interface';
import type {
ActionTypeDescription,
INodeCreateElement,
NodeCreateElement,
NodeFilterType,
} from '@/Interface';
import {
TRIGGER_NODE_CREATOR_VIEW,
HTTP_REQUEST_NODE_TYPE,
@@ -9,6 +14,7 @@ import {
REGULAR_NODE_CREATOR_VIEW,
AI_NODE_CREATOR_VIEW,
AI_OTHERS_NODE_CREATOR_VIEW,
HITL_SUBCATEGORY,
} from '@/constants';
import type { BaseTextKey } from '@/plugins/i18n';
@@ -26,7 +32,7 @@ import { useI18n } from '@/composables/useI18n';
import { getNodeIcon, getNodeIconColor, getNodeIconUrl } from '@/utils/nodeTypesUtils';
import { useUIStore } from '@/stores/ui.store';
import { useActions } from '../composables/useActions';
import type { INodeParameters } from 'n8n-workflow';
import { SEND_AND_WAIT_OPERATION, type INodeParameters } from 'n8n-workflow';
export interface Props {
rootView: 'trigger' | 'action';
@@ -51,12 +57,19 @@ const globalSearchItemsDiff = computed(() => useViewStacks().globalSearchItemsDi
function getFilteredActions(node: NodeCreateElement) {
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 getHumanInTheLoopActions(nodeActions: ActionTypeDescription[]) {
return nodeActions.filter((action) => action.actionKey === SEND_AND_WAIT_OPERATION);
}
function selectNodeType(nodeTypes: string[]) {
emit('nodeTypeSelected', nodeTypes);
}

View File

@@ -136,7 +136,7 @@ describe('NodesListPanel', () => {
await nextTick();
expect(screen.getByText('What happens next?')).toBeInTheDocument();
expect(screen.queryAllByTestId('item-iterator-item')).toHaveLength(5);
expect(screen.queryAllByTestId('item-iterator-item')).toHaveLength(6);
screen.getByText('Action in an app').click();
await nextTick();

View File

@@ -13,6 +13,7 @@ import {
AI_TRANSFORM_NODE_TYPE,
CORE_NODES_CATEGORY,
DEFAULT_SUBCATEGORY,
HUMAN_IN_THE_LOOP_CATEGORY,
} from '@/constants';
import { v4 as uuidv4 } from 'uuid';
@@ -23,6 +24,7 @@ import { sortBy } from 'lodash-es';
import * as changeCase from 'change-case';
import { useSettingsStore } from '@/stores/settings.store';
import { SEND_AND_WAIT_OPERATION } from 'n8n-workflow';
export function transformNodeType(
node: SimplifiedNodeType,
@@ -46,7 +48,11 @@ export function transformNodeType(
}
export function subcategorizeItems(items: SimplifiedNodeType[]) {
const WHITE_LISTED_SUBCATEGORIES = [CORE_NODES_CATEGORY, AI_SUBCATEGORY];
const WHITE_LISTED_SUBCATEGORIES = [
CORE_NODES_CATEGORY,
AI_SUBCATEGORY,
HUMAN_IN_THE_LOOP_CATEGORY,
];
return items.reduce((acc: SubcategorizedNodeTypes, item) => {
// Only some subcategories are allowed
let subcategories: string[] = [DEFAULT_SUBCATEGORY];
@@ -174,13 +180,21 @@ export function groupItemsInSections(
return 0;
});
if (result.length <= 1) {
if (!shouldRenderSectionSubtitle(result)) {
return items;
}
return result;
}
const shouldRenderSectionSubtitle = (sections: SectionCreateElement[]) => {
if (!sections.length) return false;
if (sections.length > 1) return true;
if (sections[0].key === SEND_AND_WAIT_OPERATION) return true;
return false;
};
export const formatTriggerActionName = (actionPropertyName: string) => {
let name = actionPropertyName;
if (actionPropertyName.includes('.')) {

View File

@@ -49,12 +49,14 @@ import {
SPLIT_IN_BATCHES_NODE_TYPE,
HTTP_REQUEST_NODE_TYPE,
HELPERS_SUBCATEGORY,
HITL_SUBCATEGORY,
RSS_READ_NODE_TYPE,
EMAIL_SEND_NODE_TYPE,
EDIT_IMAGE_NODE_TYPE,
COMPRESSION_NODE_TYPE,
AI_CODE_TOOL_LANGCHAIN_NODE_TYPE,
AI_WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE,
HUMAN_IN_THE_LOOP_CATEGORY,
} from '@/constants';
import { useI18n } from '@/composables/useI18n';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
@@ -442,6 +444,12 @@ export function RegularView(nodes: SimplifiedNodeType[]) {
AI_TRANSFORM_NODE_TYPE,
];
const getSendAndWaitNodes = (nodes: SimplifiedNodeType[]) => {
return (nodes ?? [])
.filter((node) => node.codex?.categories?.includes(HUMAN_IN_THE_LOOP_CATEGORY))
.map((node) => node.name);
};
const view: NodeView = {
value: REGULAR_NODE_CREATOR_VIEW,
title: i18n.baseText('nodeCreator.triggerHelperPanel.whatHappensNext'),
@@ -532,22 +540,39 @@ export function RegularView(nodes: SimplifiedNodeType[]) {
],
},
},
// To add node to this subcategory:
// - add "HITL" to the "categories" property of the node's codex
// - add "HITL": ["Human in the Loop"] to the "subcategories" property of the node's codex
// node has to have the "sendAndWait" operation, if a new operation needs to be included here:
// - update getHumanInTheLoopActions in packages/editor-ui/src/components/Node/NodeCreator/Modes/NodesMode.vue
{
type: 'subcategory',
key: HITL_SUBCATEGORY,
category: HUMAN_IN_THE_LOOP_CATEGORY,
properties: {
title: HITL_SUBCATEGORY,
icon: 'user-check',
sections: [
{
key: 'sendAndWait',
title: i18n.baseText('nodeCreator.sectionNames.sendAndWait'),
items: getSendAndWaitNodes(nodes),
},
],
},
},
],
};
const hasAINodes = (nodes ?? []).some((node) => node.codex?.categories?.includes(AI_SUBCATEGORY));
if (hasAINodes)
view.items.push({
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'),
tag: {
type: 'success',
text: i18n.baseText('nodeCreator.aiPanel.newTag'),
},
borderless: true,
},
} as NodeViewItem);

View File

@@ -262,6 +262,7 @@ export const NODE_CREATOR_OPEN_SOURCES: Record<
'': '',
};
export const CORE_NODES_CATEGORY = 'Core Nodes';
export const HUMAN_IN_THE_LOOP_CATEGORY = 'HITL';
export const CUSTOM_NODES_CATEGORY = 'Custom Nodes';
export const DEFAULT_SUBCATEGORY = '*';
export const AI_OTHERS_NODE_CREATOR_VIEW = 'AI Other';
@@ -274,6 +275,7 @@ export const FILES_SUBCATEGORY = 'Files';
export const FLOWS_CONTROL_SUBCATEGORY = 'Flow';
export const AI_SUBCATEGORY = 'AI';
export const HELPERS_SUBCATEGORY = 'Helpers';
export const HITL_SUBCATEGORY = 'Human in the Loop';
export const AI_CATEGORY_AGENTS = 'Agents';
export const AI_CATEGORY_CHAINS = 'Chains';
export const AI_CATEGORY_LANGUAGE_MODELS = 'Language Models';

View File

@@ -1115,6 +1115,7 @@
"nodeCreator.subcategoryDescriptions.tools": "Utility components providing various functionalities.",
"nodeCreator.subcategoryDescriptions.vectorStores": "Handles storage and retrieval of vector representations.",
"nodeCreator.subcategoryDescriptions.miscellaneous": "Other AI related nodes.",
"nodeCreator.subcategoryDescriptions.humanInTheLoop": "Wait for approval or human input before continuing",
"nodeCreator.subcategoryInfos.languageModels": "Chat models are designed for interactive conversations and follow instructions well, while text completion models focus on generating continuations of a given text input",
"nodeCreator.subcategoryInfos.memory": "Memory allows an AI model to remember and reference past interactions with it",
"nodeCreator.subcategoryInfos.vectorStores": "Vector stores allow an AI model to reference relevant pieces of documents, useful for question answering and document search",
@@ -1137,8 +1138,10 @@
"nodeCreator.subcategoryNames.tools": "Tools",
"nodeCreator.subcategoryNames.vectorStores": "Vector Stores",
"nodeCreator.subcategoryNames.miscellaneous": "Miscellaneous",
"nodeCreator.subcategoryNames.humanInTheLoop": "Human in the loop",
"nodeCreator.sectionNames.popular": "Popular",
"nodeCreator.sectionNames.other": "Other",
"nodeCreator.sectionNames.sendAndWait": "Send and wait for response",
"nodeCreator.sectionNames.transform.combine": "Combine items",
"nodeCreator.sectionNames.transform.addOrRemove": "Add or remove items",
"nodeCreator.sectionNames.transform.convert": "Convert data",

View File

@@ -82,6 +82,7 @@ import {
faHandScissors,
faHandPointLeft,
faHandshake,
faUserCheck,
faHashtag,
faHdd,
faHistory,
@@ -258,6 +259,7 @@ export const FontAwesomePlugin: Plugin = {
addIcon(faHandshake);
addIcon(faHandPointLeft);
addIcon(faHashtag);
addIcon(faUserCheck);
addIcon(faHdd);
addIcon(faHistory);
addIcon(faHome);

View File

@@ -2,7 +2,10 @@
"node": "n8n-nodes-base.emailSend",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Communication", "Core Nodes"],
"categories": ["Communication", "HITL", "Core Nodes"],
"subcategories": {
"HITL": ["Human in the Loop"]
},
"resources": {
"credentialDocumentation": [
{

View File

@@ -2,9 +2,9 @@
"node": "n8n-nodes-base.googleChat",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Communication", "HILT"],
"categories": ["Communication", "HITL"],
"subcategories": {
"HILT": ["Human in the Loop"]
"HITL": ["Human in the Loop"]
},
"resources": {
"credentialDocumentation": [

View File

@@ -2,7 +2,10 @@
"node": "n8n-nodes-base.gmail",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Communication"],
"categories": ["Communication", "HITL"],
"subcategories": {
"HITL": ["Human in the Loop"]
},
"resources": {
"credentialDocumentation": [
{

View File

@@ -2,7 +2,10 @@
"node": "n8n-nodes-base.microsoftOutlook",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Communication"],
"categories": ["Communication", "HITL"],
"subcategories": {
"HITL": ["Human in the Loop"]
},
"resources": {
"credentialDocumentation": [
{

View File

@@ -2,7 +2,10 @@
"node": "n8n-nodes-base.slack",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Communication"],
"categories": ["Communication", "HITL"],
"subcategories": {
"HITL": ["Human in the Loop"]
},
"alias": ["human", "form", "wait"],
"resources": {
"credentialDocumentation": [

View File

@@ -2,7 +2,10 @@
"node": "n8n-nodes-base.telegram",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Communication"],
"categories": ["Communication", "HITL"],
"subcategories": {
"HITL": ["Human in the Loop"]
},
"resources": {
"credentialDocumentation": [
{