From 4713827813809065c8800adc7c0cd4bf42f54eeb Mon Sep 17 00:00:00 2001 From: oleg Date: Thu, 24 Jul 2025 09:40:10 +0200 Subject: [PATCH 1/9] fix: Prevent error when importing nodes with malformed collection params (#17580) --- packages/workflow/src/node-helpers.ts | 4 + packages/workflow/test/node-helpers.test.ts | 98 +++++++++++++++++++++ 2 files changed, 102 insertions(+) diff --git a/packages/workflow/src/node-helpers.ts b/packages/workflow/src/node-helpers.ts index 920011028a7..5edcc2f0dc8 100644 --- a/packages/workflow/src/node-helpers.ts +++ b/packages/workflow/src/node-helpers.ts @@ -835,6 +835,10 @@ export function getNodeParameters( // Multiple can be set so will be an array const tempArrayValue: INodeParameters[] = []; + // Collection values should always be an object + if (typeof propertyValues !== 'object' || Array.isArray(propertyValues)) { + continue; + } // Iterate over all items as it contains multiple ones for (const nodeValue of (propertyValues as INodeParameters)[ itemName diff --git a/packages/workflow/test/node-helpers.test.ts b/packages/workflow/test/node-helpers.test.ts index b9e893d6260..156b7cf2dfa 100644 --- a/packages/workflow/test/node-helpers.test.ts +++ b/packages/workflow/test/node-helpers.test.ts @@ -3416,6 +3416,104 @@ describe('NodeHelpers', () => { }, }, }, + { + description: + 'fixedCollection with multipleValues: true - skip when propertyValues is not an object or is an array', + input: { + nodePropertiesArray: [ + { + displayName: 'Values', + name: 'values', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + default: {}, + options: [ + { + name: 'option1', + displayName: 'Option 1', + values: [ + { + displayName: 'String', + name: 'string1', + type: 'string', + default: 'default string', + }, + ], + }, + ], + }, + ], + nodeValues: { + // This simulates when propertyValues is incorrectly set as an array instead of an object + values: [] as any, + }, + }, + output: { + noneDisplayedFalse: { + defaultsFalse: {}, + defaultsTrue: { + values: {}, + }, + }, + noneDisplayedTrue: { + defaultsFalse: {}, + defaultsTrue: { + values: {}, + }, + }, + }, + }, + { + description: + 'fixedCollection with multipleValues: true - skip when propertyValues is a string', + input: { + nodePropertiesArray: [ + { + displayName: 'Values', + name: 'values', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + default: {}, + options: [ + { + name: 'option1', + displayName: 'Option 1', + values: [ + { + displayName: 'String', + name: 'string1', + type: 'string', + default: 'default string', + }, + ], + }, + ], + }, + ], + nodeValues: { + // This simulates when propertyValues is incorrectly set as a string + values: 'invalid value' as any, + }, + }, + output: { + noneDisplayedFalse: { + defaultsFalse: {}, + defaultsTrue: { + values: {}, + }, + }, + noneDisplayedTrue: { + defaultsFalse: {}, + defaultsTrue: { + values: {}, + }, + }, + }, + }, ]; for (const testData of tests) { From 46635c59418630c2f24fce5cb8c25e425eddc3c2 Mon Sep 17 00:00:00 2001 From: Suguru Inoue Date: Thu, 24 Jul 2025 10:21:41 +0200 Subject: [PATCH 2/9] fix(editor): Render HTML in the log view (#17586) --- .../src/components/N8nIcon/custom/schema.svg | 7 ++- .../src/components/N8nIcon/icons.ts | 6 +-- .../frontend/editor-ui/src/__tests__/mocks.ts | 24 +++++---- .../frontend/editor-ui/src/__tests__/setup.ts | 5 ++ .../editor-ui/src/components/RunData.vue | 24 ++++----- .../logs/components/LogDetailsPanel.test.ts | 49 ++++++++++++++++++- 6 files changed, 89 insertions(+), 26 deletions(-) diff --git a/packages/frontend/@n8n/design-system/src/components/N8nIcon/custom/schema.svg b/packages/frontend/@n8n/design-system/src/components/N8nIcon/custom/schema.svg index 41e27026753..f9f657519e1 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nIcon/custom/schema.svg +++ b/packages/frontend/@n8n/design-system/src/components/N8nIcon/custom/schema.svg @@ -1 +1,6 @@ - + + + diff --git a/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts b/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts index 18eff4e3c93..ea0ae92de59 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts @@ -3,7 +3,6 @@ import BoltFilled from './custom/bolt-filled.svg'; import Continue from './custom/Continue.svg'; import EmptyOutput from './custom/EmptyOutput.svg'; import GripLinesVertical from './custom/grip-lines-vertical.svg'; -import Json from './custom/json.svg'; import PopOut from './custom/pop-out.svg'; import Retry from './custom/Retry.svg'; import RunOnce from './custom/RunOnce.svg'; @@ -36,6 +35,7 @@ import IconLucideBell from '~icons/lucide/bell'; import IconLucideBook from '~icons/lucide/book'; import IconLucideBot from '~icons/lucide/bot'; import IconLucideBox from '~icons/lucide/box'; +import IconLucideBraces from '~icons/lucide/braces'; import IconLucideBrain from '~icons/lucide/brain'; import IconLucideBug from '~icons/lucide/bug'; import IconLucideCalculator from '~icons/lucide/calculator'; @@ -208,7 +208,7 @@ export const deprecatedIconSet = { 'status-warning': StatusWarning, 'vector-square': VectorSquare, schema: Schema, - json: Json, + json: IconLucideBraces, binary: Binary, text: Text, toolbox: Toolbox, @@ -415,7 +415,7 @@ export const updatedIconSet = { 'retry-on-fail': Retry, 'execute-once': RunOnce, schema: Schema, - json: Json, + json: IconLucideBraces, binary: Binary, text: Text, toolbox: Toolbox, diff --git a/packages/frontend/editor-ui/src/__tests__/mocks.ts b/packages/frontend/editor-ui/src/__tests__/mocks.ts index abff8d36aac..85f8719fa49 100644 --- a/packages/frontend/editor-ui/src/__tests__/mocks.ts +++ b/packages/frontend/editor-ui/src/__tests__/mocks.ts @@ -130,14 +130,18 @@ export const defaultNodeDescriptions = Object.values(defaultNodeTypes).map( ({ type }) => type.description, ) as INodeTypeDescription[]; -const nodeTypes = mock({ - getByName(nodeType) { - return defaultNodeTypes[nodeType].type; - }, - getByNameAndVersion(nodeType: string, version?: number): INodeType { - return NodeHelpers.getVersionedNodeType(defaultNodeTypes[nodeType].type, version); - }, -}); +export function createMockNodeTypes(data: INodeTypeData) { + return mock({ + getByName(nodeType) { + return data[nodeType].type; + }, + getByNameAndVersion(nodeType: string, version?: number): INodeType { + return NodeHelpers.getVersionedNodeType(data[nodeType].type, version); + }, + }); +} + +const nodeTypes = createMockNodeTypes(defaultNodeTypes); export function createTestWorkflowObject({ id = uuid(), @@ -148,6 +152,7 @@ export function createTestWorkflowObject({ staticData = {}, settings = {}, pinData = {}, + ...rest }: { id?: string; name?: string; @@ -157,6 +162,7 @@ export function createTestWorkflowObject({ staticData?: IDataObject; settings?: IWorkflowSettings; pinData?: IPinData; + nodeTypes?: INodeTypes; } = {}) { return new Workflow({ id, @@ -167,7 +173,7 @@ export function createTestWorkflowObject({ staticData, settings, pinData, - nodeTypes, + nodeTypes: rest.nodeTypes ?? nodeTypes, }); } diff --git a/packages/frontend/editor-ui/src/__tests__/setup.ts b/packages/frontend/editor-ui/src/__tests__/setup.ts index 5aae98112db..b168727df1a 100644 --- a/packages/frontend/editor-ui/src/__tests__/setup.ts +++ b/packages/frontend/editor-ui/src/__tests__/setup.ts @@ -112,3 +112,8 @@ Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', { writable: true, value: vi.fn(), }); + +Object.defineProperty(HTMLElement.prototype, 'scrollTo', { + writable: true, + value: vi.fn(), +}); diff --git a/packages/frontend/editor-ui/src/components/RunData.vue b/packages/frontend/editor-ui/src/components/RunData.vue index 83e3b701d3c..23586185a44 100644 --- a/packages/frontend/editor-ui/src/components/RunData.vue +++ b/packages/frontend/editor-ui/src/components/RunData.vue @@ -721,7 +721,6 @@ onMounted(() => { }); if (props.paneType === 'output') { - setDisplayMode(); activatePane(); } @@ -1231,6 +1230,10 @@ function init() { if (isNDVV2.value) { pageSize.value = RUN_DATA_DEFAULT_PAGE_SIZE; } + + if (props.paneType === 'output') { + setDisplayMode(); + } } function closeBinaryDataDisplay() { @@ -1337,14 +1340,14 @@ function enableNode() { } } +const shouldDisplayHtml = computed( + () => + node.value?.type === HTML_NODE_TYPE && + node.value.parameters.operation === 'generateHtmlTemplate', +); + function setDisplayMode() { - if (!activeNode.value) return; - - const shouldDisplayHtml = - activeNode.value.type === HTML_NODE_TYPE && - activeNode.value.parameters.operation === 'generateHtmlTemplate'; - - if (shouldDisplayHtml) { + if (shouldDisplayHtml.value) { emit('displayModeChange', 'html'); } } @@ -1461,10 +1464,7 @@ defineExpose({ enterEditMode }); :value="displayMode" :has-binary-data="binaryData.length > 0" :pane-type="paneType" - :node-generates-html=" - activeNode?.type === HTML_NODE_TYPE && - activeNode.parameters.operation === 'generateHtmlTemplate' - " + :node-generates-html="shouldDisplayHtml" :has-renderable-data="hasParsedAiContent" @change="onDisplayModeChange" /> diff --git a/packages/frontend/editor-ui/src/features/logs/components/LogDetailsPanel.test.ts b/packages/frontend/editor-ui/src/features/logs/components/LogDetailsPanel.test.ts index 2dd5ebc254c..e566ee5d5e2 100644 --- a/packages/frontend/editor-ui/src/features/logs/components/LogDetailsPanel.test.ts +++ b/packages/frontend/editor-ui/src/features/logs/components/LogDetailsPanel.test.ts @@ -1,19 +1,23 @@ -import { fireEvent, within } from '@testing-library/vue'; +import { fireEvent, waitFor, within } from '@testing-library/vue'; import { renderComponent } from '@/__tests__/render'; import LogDetailsPanel from './LogDetailsPanel.vue'; import { createRouter, createWebHistory } from 'vue-router'; import { createTestingPinia, type TestingPinia } from '@pinia/testing'; import { h } from 'vue'; import { + createMockNodeTypes, createTestNode, createTestTaskData, createTestWorkflow, createTestWorkflowObject, + defaultNodeTypes, + mockLoadedNodeType, } from '@/__tests__/mocks'; import { LOG_DETAILS_PANEL_STATE } from '@/features/logs/logs.constants'; import type { LogEntry } from '../logs.types'; import { createTestLogEntry } from '../__test__/mocks'; import { NodeConnectionTypes } from 'n8n-workflow'; +import { HTML_NODE_TYPE } from '@/constants'; describe('LogDetailsPanel', () => { let pinia: TestingPinia; @@ -182,4 +186,47 @@ describe('LogDetailsPanel', () => { ), ).toBeInTheDocument(); }); + + it('should render output data in HTML mode for HTML node', async () => { + const nodeA = createTestNode({ name: 'A' }); + const nodeB = createTestNode({ + name: 'B', + type: HTML_NODE_TYPE, + }); + const runDataA = createTestTaskData({ data: { [NodeConnectionTypes.Main]: [[{ json: {} }]] } }); + const runDataB = createTestTaskData({ + data: { [NodeConnectionTypes.Main]: [[{ json: { html: '

Hi!

' } }]] }, + source: [{ previousNode: 'A' }], + }); + const workflow = createTestWorkflowObject({ + nodes: [nodeA, nodeB], + nodeTypes: createMockNodeTypes({ + ...defaultNodeTypes, + [HTML_NODE_TYPE]: mockLoadedNodeType(HTML_NODE_TYPE), + }), + }); + const execution = { resultData: { runData: { A: [runDataA], B: [runDataB] } } }; + const logA = createLogEntry({ node: nodeA, runData: runDataA, workflow, execution }); + const logB = createLogEntry({ node: nodeB, runData: runDataB, workflow, execution }); + + // HACK: Setting parameters after creating workflow because validation removes parameters that are not define in node types. + nodeB.parameters = { operation: 'generateHtmlTemplate' }; + + const props = { + isOpen: true, + panels: LOG_DETAILS_PANEL_STATE.BOTH, + collapsingInputTableColumnName: null, + collapsingOutputTableColumnName: null, + }; + + const rendered = render({ ...props, logEntry: logB }); + + await waitFor(() => expect(rendered.container.querySelectorAll('iframe')).toHaveLength(1)); + await rendered.rerender({ ...props, logEntry: logA }); + await waitFor(() => expect(rendered.container.querySelectorAll('iframe')).toHaveLength(0)); + + // Re-selecting node B should render HTML again + await rendered.rerender({ ...props, logEntry: logB }); + await waitFor(() => expect(rendered.container.querySelectorAll('iframe')).toHaveLength(1)); + }); }); From e61b25c53fe795a5baf793c0372fb3c8ff732de9 Mon Sep 17 00:00:00 2001 From: Marc Littlemore Date: Thu, 24 Jul 2025 09:24:24 +0100 Subject: [PATCH 3/9] chore(core): Fix typo (#17611) --- .../ExecuteWorkflow/ExecuteWorkflow.node.ts | 4 +-- .../utils/workflow-backtracking.test.ts | 26 +++++++++---------- .../nodes-base/utils/workflow-backtracking.ts | 2 +- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow/ExecuteWorkflow.node.ts b/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow/ExecuteWorkflow.node.ts index e73cc5c2c80..fd09b0df4c6 100644 --- a/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow/ExecuteWorkflow.node.ts +++ b/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow/ExecuteWorkflow.node.ts @@ -7,7 +7,7 @@ import type { INodeTypeDescription, } from 'n8n-workflow'; -import { findPairedItemTroughWorkflowData } from './../../../utils/workflow-backtracking'; +import { findPairedItemThroughWorkflowData } from './../../../utils/workflow-backtracking'; import { getWorkflowInfo } from './GenericFunctions'; import { localResourceMapping } from './methods'; import { generatePairedItemData } from '../../../utils/utilities'; @@ -440,7 +440,7 @@ export class ExecuteWorkflow implements INodeType { if (item.pairedItem) { // If the item already has a paired item, we need to follow these to the start of the child workflow if (workflowRunData !== undefined) { - const pairedItem = findPairedItemTroughWorkflowData( + const pairedItem = findPairedItemThroughWorkflowData( workflowRunData, item, itemIndex, diff --git a/packages/nodes-base/utils/workflow-backtracking.test.ts b/packages/nodes-base/utils/workflow-backtracking.test.ts index 8045e2a5513..80b7b28de1e 100644 --- a/packages/nodes-base/utils/workflow-backtracking.test.ts +++ b/packages/nodes-base/utils/workflow-backtracking.test.ts @@ -6,7 +6,7 @@ import type { ITaskData, } from 'n8n-workflow'; -import { previousTaskData, findPairedItemTroughWorkflowData } from './workflow-backtracking'; +import { previousTaskData, findPairedItemThroughWorkflowData } from './workflow-backtracking'; describe('backtracking.ts', () => { describe('previousTaskData', () => { @@ -186,7 +186,7 @@ describe('backtracking.ts', () => { }); }); - describe('findPairedItemTroughWorkflowData', () => { + describe('findPairedItemThroughWorkflowData', () => { it('should return undefined when lastNodeExecuted is undefined', () => { const workflowRunData: IRunExecutionData = { resultData: { @@ -199,7 +199,7 @@ describe('backtracking.ts', () => { pairedItem: { item: 0 }, }; - const result = findPairedItemTroughWorkflowData(workflowRunData, item, 0); + const result = findPairedItemThroughWorkflowData(workflowRunData, item, 0); expect(result).toBeUndefined(); }); @@ -216,7 +216,7 @@ describe('backtracking.ts', () => { pairedItem: { item: 0 }, }; - const result = findPairedItemTroughWorkflowData(workflowRunData, item, 0); + const result = findPairedItemThroughWorkflowData(workflowRunData, item, 0); expect(result).toBeUndefined(); }); @@ -235,7 +235,7 @@ describe('backtracking.ts', () => { pairedItem: { item: 0 }, }; - const result = findPairedItemTroughWorkflowData(workflowRunData, item, 0); + const result = findPairedItemThroughWorkflowData(workflowRunData, item, 0); expect(result).toBeUndefined(); }); @@ -254,7 +254,7 @@ describe('backtracking.ts', () => { pairedItem: { item: 0 }, }; - const result = findPairedItemTroughWorkflowData(workflowRunData, item, 0); + const result = findPairedItemThroughWorkflowData(workflowRunData, item, 0); expect(result).toBeUndefined(); }); @@ -281,7 +281,7 @@ describe('backtracking.ts', () => { pairedItem: expectedPairedItem, }; - const result = findPairedItemTroughWorkflowData(workflowRunData, item, 0); + const result = findPairedItemThroughWorkflowData(workflowRunData, item, 0); expect(result).toBe(expectedPairedItem); }); @@ -319,7 +319,7 @@ describe('backtracking.ts', () => { }, }; - const result = findPairedItemTroughWorkflowData(workflowRunData, item, 0); + const result = findPairedItemThroughWorkflowData(workflowRunData, item, 0); expect(result).toBe(finalPairedItem); }); @@ -355,7 +355,7 @@ describe('backtracking.ts', () => { }, }; - const result = findPairedItemTroughWorkflowData(workflowRunData, item, 0); + const result = findPairedItemThroughWorkflowData(workflowRunData, item, 0); expect(result).toBe(finalPairedItem); }); @@ -401,7 +401,7 @@ describe('backtracking.ts', () => { }, }; - const result = findPairedItemTroughWorkflowData(workflowRunData, item, 5); + const result = findPairedItemThroughWorkflowData(workflowRunData, item, 5); expect(result).toBe(finalPairedItem); }); @@ -446,7 +446,7 @@ describe('backtracking.ts', () => { pairedItem: { item: 0 }, }; - const result = findPairedItemTroughWorkflowData(workflowRunData, item, 0); + const result = findPairedItemThroughWorkflowData(workflowRunData, item, 0); expect(result).toBe(finalPairedItem); }); @@ -489,7 +489,7 @@ describe('backtracking.ts', () => { pairedItem: { item: 0 }, }; - const result = findPairedItemTroughWorkflowData(workflowRunData, item, 0); + const result = findPairedItemThroughWorkflowData(workflowRunData, item, 0); expect(result).toBe(finalPairedItem); }); @@ -524,7 +524,7 @@ describe('backtracking.ts', () => { pairedItem: { item: 0 }, }; - const result = findPairedItemTroughWorkflowData(workflowRunData, item, 0); + const result = findPairedItemThroughWorkflowData(workflowRunData, item, 0); expect(result).toBeUndefined(); }); diff --git a/packages/nodes-base/utils/workflow-backtracking.ts b/packages/nodes-base/utils/workflow-backtracking.ts index 43e20d1e586..d7510989a72 100644 --- a/packages/nodes-base/utils/workflow-backtracking.ts +++ b/packages/nodes-base/utils/workflow-backtracking.ts @@ -41,7 +41,7 @@ export function previousTaskData( return nextRunData[nextRunIndex]; // Return the first run data for the next node } -export function findPairedItemTroughWorkflowData( +export function findPairedItemThroughWorkflowData( workflowRunData: IRunExecutionData, item: INodeExecutionData, itemIndex: number, From a98ed2ca495d5c86ebb61baad049592ba1bce3a6 Mon Sep 17 00:00:00 2001 From: Michael Kret <88898367+michael-radency@users.noreply.github.com> Date: Thu, 24 Jul 2025 11:48:40 +0300 Subject: [PATCH 4/9] feat: Respond to chat and wait for response (#12546) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ Co-authored-by: Shireen Missi <94372015+ShireenMissi@users.noreply.github.com> --- .../nodes/trigger/ChatTrigger/Chat.node.ts | 273 ++++++++++++ .../trigger/ChatTrigger/ChatTrigger.node.ts | 122 ++++-- .../ChatTrigger/__test__/Chat.node.test.ts | 143 +++++++ .../__test__/ChatTrigger.node.test.ts | 9 +- .../nodes/trigger/ChatTrigger/templates.ts | 2 +- .../nodes/trigger/ChatTrigger/util.ts | 67 +++ packages/@n8n/nodes-langchain/package.json | 1 + .../@n8n/nodes-langchain/utils/helpers.ts | 14 + .../utils/tests/helpers.test.ts | 47 +++ .../__tests__/chat-execution-manager.test.ts | 292 +++++++++++++ .../src/chat/__tests__/chat-server.test.ts | 86 ++++ .../src/chat/__tests__/chat-service.test.ts | 399 ++++++++++++++++++ packages/cli/src/chat/__tests__/utils.test.ts | 303 +++++++++++++ .../cli/src/chat/chat-execution-manager.ts | 156 +++++++ packages/cli/src/chat/chat-server.ts | 54 +++ packages/cli/src/chat/chat-service.ts | 339 +++++++++++++++ packages/cli/src/chat/chat-service.types.ts | 41 ++ packages/cli/src/chat/utils.ts | 48 +++ packages/cli/src/server.ts | 4 + .../__tests__/webhook-helpers.test.ts | 72 +++- packages/cli/src/webhooks/webhook-helpers.ts | 44 +- .../__tests__/node-execution-context.test.ts | 27 +- .../node-execution-context.ts | 28 +- .../@n8n/chat/src/__stories__/App.stories.ts | 2 +- .../@n8n/chat/src/__tests__/Input.spec.ts | 157 +++++++ .../chat/src/__tests__/plugins/chat.test.ts | 66 +++ .../@n8n/chat/src/__tests__/utils/fetch.ts | 8 +- .../frontend/@n8n/chat/src/api/generic.ts | 10 +- .../@n8n/chat/src/components/Input.vue | 126 +++++- .../frontend/@n8n/chat/src/plugins/chat.ts | 15 +- packages/frontend/@n8n/chat/src/types/chat.ts | 5 +- .../frontend/@n8n/chat/src/types/webhook.ts | 3 + .../frontend/@n8n/chat/src/utils/index.ts | 1 + .../frontend/@n8n/chat/src/utils/utils.ts | 11 + .../editor-ui/src/components/RunData.test.ts | 1 - .../src/composables/useRunWorkflow.test.ts | 1 + packages/frontend/editor-ui/src/constants.ts | 1 + .../logs/__test__/useChatMessaging.test.ts | 125 ++++++ .../logs/components/LogsPanel.test.ts | 3 + .../logs/composables/useChatMessaging.ts | 30 +- .../features/logs/composables/useChatState.ts | 66 ++- .../src/features/logs/logs.utils.test.ts | 119 +++++- .../editor-ui/src/features/logs/logs.utils.ts | 28 +- .../RespondToWebhook/RespondToWebhook.node.ts | 48 ++- .../test/RespondToWebhook.test.ts | 73 ++++ packages/workflow/src/constants.ts | 3 + packages/workflow/src/interfaces.ts | 39 +- 47 files changed, 3441 insertions(+), 71 deletions(-) create mode 100644 packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/Chat.node.ts create mode 100644 packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/Chat.node.test.ts create mode 100644 packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/util.ts create mode 100644 packages/cli/src/chat/__tests__/chat-execution-manager.test.ts create mode 100644 packages/cli/src/chat/__tests__/chat-server.test.ts create mode 100644 packages/cli/src/chat/__tests__/chat-service.test.ts create mode 100644 packages/cli/src/chat/__tests__/utils.test.ts create mode 100644 packages/cli/src/chat/chat-execution-manager.ts create mode 100644 packages/cli/src/chat/chat-server.ts create mode 100644 packages/cli/src/chat/chat-service.ts create mode 100644 packages/cli/src/chat/chat-service.types.ts create mode 100644 packages/cli/src/chat/utils.ts create mode 100644 packages/frontend/@n8n/chat/src/__tests__/Input.spec.ts create mode 100644 packages/frontend/@n8n/chat/src/__tests__/plugins/chat.test.ts create mode 100644 packages/frontend/@n8n/chat/src/utils/utils.ts create mode 100644 packages/frontend/editor-ui/src/features/logs/__test__/useChatMessaging.test.ts diff --git a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/Chat.node.ts b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/Chat.node.ts new file mode 100644 index 00000000000..042211ab898 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/Chat.node.ts @@ -0,0 +1,273 @@ +/* eslint-disable n8n-nodes-base/node-dirname-against-convention */ +import type { BaseChatMemory } from 'langchain/memory'; +import { + CHAT_TRIGGER_NODE_TYPE, + CHAT_WAIT_USER_REPLY, + NodeConnectionTypes, + NodeOperationError, +} from 'n8n-workflow'; +import type { + IExecuteFunctions, + INodeExecutionData, + INodeTypeDescription, + INodeType, + INodeProperties, +} from 'n8n-workflow'; + +import { configureInputs, configureWaitTillDate } from './util'; + +const limitWaitTimeProperties: INodeProperties[] = [ + { + displayName: 'Limit Type', + name: 'limitType', + type: 'options', + default: 'afterTimeInterval', + description: + 'Sets the condition for the execution to resume. Can be a specified date or after some time.', + options: [ + { + name: 'After Time Interval', + description: 'Waits for a certain amount of time', + value: 'afterTimeInterval', + }, + { + name: 'At Specified Time', + description: 'Waits until the set date and time to continue', + value: 'atSpecifiedTime', + }, + ], + }, + { + displayName: 'Amount', + name: 'resumeAmount', + type: 'number', + displayOptions: { + show: { + limitType: ['afterTimeInterval'], + }, + }, + typeOptions: { + minValue: 0, + numberPrecision: 2, + }, + default: 1, + description: 'The time to wait', + }, + { + displayName: 'Unit', + name: 'resumeUnit', + type: 'options', + displayOptions: { + show: { + limitType: ['afterTimeInterval'], + }, + }, + options: [ + { + name: 'Minutes', + value: 'minutes', + }, + { + name: 'Hours', + value: 'hours', + }, + { + name: 'Days', + value: 'days', + }, + ], + default: 'hours', + description: 'Unit of the interval value', + }, + { + displayName: 'Max Date and Time', + name: 'maxDateAndTime', + type: 'dateTime', + displayOptions: { + show: { + limitType: ['atSpecifiedTime'], + }, + }, + default: '', + description: 'Continue execution after the specified date and time', + }, +]; + +const limitWaitTimeOption: INodeProperties = { + displayName: 'Limit Wait Time', + name: 'limitWaitTime', + type: 'fixedCollection', + description: + 'Whether to limit the time this node should wait for a user response before execution resumes', + default: { values: { limitType: 'afterTimeInterval', resumeAmount: 45, resumeUnit: 'minutes' } }, + options: [ + { + displayName: 'Values', + name: 'values', + values: limitWaitTimeProperties, + }, + ], + displayOptions: { + show: { + [`/${CHAT_WAIT_USER_REPLY}`]: [true], + }, + }, +}; + +export class Chat implements INodeType { + description: INodeTypeDescription = { + displayName: 'Respond to Chat', + name: 'chat', + icon: 'fa:comments', + iconColor: 'black', + group: ['input'], + version: 1, + description: 'Send a message to a chat', + defaults: { + name: 'Respond to Chat', + }, + codex: { + categories: ['Core Nodes', 'HITL'], + subcategories: { + HITL: ['Human in the Loop'], + }, + alias: ['human', 'wait', 'hitl'], + resources: { + primaryDocumentation: [ + { + url: 'https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-langchain.chat/', + }, + ], + }, + }, + inputs: `={{ (${configureInputs})($parameter) }}`, + outputs: [NodeConnectionTypes.Main], + properties: [ + { + displayName: + "Verify you're using a chat trigger with the 'Response Mode' option set to 'Using Response Nodes'", + name: 'generalNotice', + type: 'notice', + default: '', + }, + { + displayName: 'Message', + name: 'message', + type: 'string', + default: '', + required: true, + typeOptions: { + rows: 6, + }, + }, + { + displayName: 'Wait for User Reply', + name: CHAT_WAIT_USER_REPLY, + type: 'boolean', + default: true, + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + options: [ + { + displayName: 'Add Memory Input Connection', + name: 'memoryConnection', + type: 'boolean', + default: false, + }, + limitWaitTimeOption, + ], + }, + ], + }; + + async onMessage( + context: IExecuteFunctions, + data: INodeExecutionData, + ): Promise { + const options = context.getNodeParameter('options', 0, {}) as { + memoryConnection?: boolean; + }; + + const waitForReply = context.getNodeParameter(CHAT_WAIT_USER_REPLY, 0, true) as boolean; + + if (!waitForReply) { + const inputData = context.getInputData(); + return [inputData]; + } + + if (options.memoryConnection) { + const memory = (await context.getInputConnectionData(NodeConnectionTypes.AiMemory, 0)) as + | BaseChatMemory + | undefined; + + const message = data.json?.chatInput; + + if (memory && message) { + await memory.chatHistory.addUserMessage(message as string); + } + } + + return [[data]]; + } + + async execute(this: IExecuteFunctions): Promise { + const connectedNodes = this.getParentNodes(this.getNode().name, { + includeNodeParameters: true, + }); + + const chatTrigger = connectedNodes.find( + (node) => node.type === CHAT_TRIGGER_NODE_TYPE && !node.disabled, + ); + + if (!chatTrigger) { + throw new NodeOperationError( + this.getNode(), + 'Workflow must be started from a chat trigger node', + ); + } + + const parameters = chatTrigger.parameters as { + mode?: 'hostedChat' | 'webhook'; + options: { responseMode: 'lastNode' | 'responseNodes' | 'streaming' | 'responseNode' }; + }; + + if (parameters.mode === 'webhook') { + throw new NodeOperationError( + this.getNode(), + '"Embeded chat" is not supported, change the "Mode" in the chat trigger node to the "Hosted Chat"', + ); + } + + if (parameters.options.responseMode !== 'responseNodes') { + throw new NodeOperationError( + this.getNode(), + '"Response Mode" in the chat trigger node must be set to "Respond Nodes"', + ); + } + + const message = (this.getNodeParameter('message', 0) as string) ?? ''; + const options = this.getNodeParameter('options', 0, {}) as { + memoryConnection?: boolean; + }; + + if (options.memoryConnection) { + const memory = (await this.getInputConnectionData(NodeConnectionTypes.AiMemory, 0)) as + | BaseChatMemory + | undefined; + + if (memory) { + await memory.chatHistory.addAIChatMessage(message); + } + } + + const waitTill = configureWaitTillDate(this); + + await this.putExecutionToWait(waitTill); + return [[{ json: {}, sendMessage: message }]]; + } +} diff --git a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/ChatTrigger.node.ts b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/ChatTrigger.node.ts index a1c36f8db73..938f03a2329 100644 --- a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/ChatTrigger.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/ChatTrigger.node.ts @@ -35,27 +35,30 @@ const allowedFileMimeTypeOption: INodeProperties = { 'Allowed file types for upload. Comma-separated list of MIME types.', }; -const responseModeOptions = [ - { - name: 'When Last Node Finishes', - value: 'lastNode', - description: 'Returns data of the last-executed node', - }, - { - name: "Using 'Respond to Webhook' Node", - value: 'responseNode', - description: 'Response defined in that node', - }, -]; +const respondToWebhookResponseMode = { + name: "Using 'Respond to Webhook' Node", + value: 'responseNode', + description: 'Response defined in that node', +}; -const responseModeWithStreamingOptions = [ - ...responseModeOptions, - { - name: 'Streaming Response', - value: 'streaming', - description: 'Streaming response from specified nodes (e.g. Agents)', - }, -]; +const lastNodeResponseMode = { + name: 'When Last Node Finishes', + value: 'lastNode', + description: 'Returns data of the last-executed node', +}; + +const streamingResponseMode = { + name: 'Streaming Response', + value: 'streaming', + description: 'Streaming response from specified nodes (e.g. Agents)', +}; + +const respondNodesResponseMode = { + name: 'Using Response Nodes', + value: 'responseNodes', + description: + "Send responses to the chat by using 'Respond to Chat' or 'Respond to Webhook' nodes", +}; const commonOptionsFields: INodeProperties[] = [ // CORS parameters are only valid for when chat is used in hosted or webhook mode @@ -209,9 +212,8 @@ export class ChatTrigger extends Node { icon: 'fa:comments', iconColor: 'black', group: ['trigger'], - version: [1, 1.1, 1.2], - // Keep the default version as 1.1 to avoid releasing streaming in broken state - defaultVersion: 1.1, + version: [1, 1.1, 1.2, 1.3], + defaultVersion: 1.3, description: 'Runs the workflow when an n8n generated webchat is submitted', defaults: { name: 'When chat message received', @@ -390,7 +392,7 @@ export class ChatTrigger extends Node { displayOptions: { show: { public: [false], - '@version': [{ _cnd: { gte: 1.1 } }], + '@version': [1, 1.1], }, }, placeholder: 'Add Field', @@ -417,13 +419,13 @@ export class ChatTrigger extends Node { displayName: 'Response Mode', name: 'responseMode', type: 'options', - options: responseModeOptions, + options: [lastNodeResponseMode, respondToWebhookResponseMode], default: 'lastNode', description: 'When and how to respond to the webhook', }, ], }, - // Options for version 1.2+ (with streaming) + // Options for version 1.2 (with streaming) { displayName: 'Options', name: 'options', @@ -432,7 +434,7 @@ export class ChatTrigger extends Node { show: { mode: ['hostedChat', 'webhook'], public: [true], - '@version': [{ _cnd: { gte: 1.2 } }], + '@version': [1.2], }, }, placeholder: 'Add Field', @@ -443,12 +445,72 @@ export class ChatTrigger extends Node { displayName: 'Response Mode', name: 'responseMode', type: 'options', - options: responseModeWithStreamingOptions, + options: [lastNodeResponseMode, respondToWebhookResponseMode, streamingResponseMode], default: 'lastNode', description: 'When and how to respond to the webhook', }, ], }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + displayOptions: { + show: { + public: [false], + '@version': [{ _cnd: { gte: 1.3 } }], + }, + }, + placeholder: 'Add Field', + default: {}, + options: [ + allowFileUploadsOption, + allowedFileMimeTypeOption, + { + displayName: 'Response Mode', + name: 'responseMode', + type: 'options', + options: [lastNodeResponseMode, respondNodesResponseMode], + default: 'lastNode', + description: 'When and how to respond to the chat', + }, + ], + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + displayOptions: { + show: { + mode: ['hostedChat', 'webhook'], + public: [true], + '@version': [{ _cnd: { gte: 1.3 } }], + }, + }, + placeholder: 'Add Field', + default: {}, + options: [ + ...commonOptionsFields, + { + displayName: 'Response Mode', + name: 'responseMode', + type: 'options', + options: [lastNodeResponseMode, respondToWebhookResponseMode], + default: 'lastNode', + description: 'When and how to respond to the chat', + displayOptions: { show: { '/mode': ['webhook'] } }, + }, + { + displayName: 'Response Mode', + name: 'responseMode', + type: 'options', + options: [lastNodeResponseMode, respondNodesResponseMode], + default: 'lastNode', + description: 'When and how to respond to the webhook', + displayOptions: { show: { '/mode': ['hostedChat'] } }, + }, + ], + }, ], }; @@ -536,10 +598,10 @@ export class ChatTrigger extends Node { allowFileUploads?: boolean; allowedFilesMimeTypes?: string; customCss?: string; + responseMode?: string; }; - const responseMode = ctx.getNodeParameter('options.responseMode', 'lastNode') as string; - const enableStreaming = responseMode === 'streaming'; + const enableStreaming = options.responseMode === 'streaming'; const req = ctx.getRequestObject(); const webhookName = ctx.getWebhookName(); diff --git a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/Chat.node.test.ts b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/Chat.node.test.ts new file mode 100644 index 00000000000..dc47838eb1a --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/Chat.node.test.ts @@ -0,0 +1,143 @@ +import type { MockProxy } from 'jest-mock-extended'; +import { mock } from 'jest-mock-extended'; +import type { INode, IExecuteFunctions } from 'n8n-workflow'; +import { CHAT_TRIGGER_NODE_TYPE } from 'n8n-workflow'; + +import { Chat } from '../Chat.node'; + +describe('Test Chat Node', () => { + let chat: Chat; + let mockExecuteFunctions: MockProxy; + + const chatNode = mock({ + name: 'Chat', + type: CHAT_TRIGGER_NODE_TYPE, + parameters: {}, + }); + + beforeEach(() => { + chat = new Chat(); + mockExecuteFunctions = mock(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should execute and send message', async () => { + const items = [{ json: { data: 'test' } }]; + mockExecuteFunctions.getInputData.mockReturnValue(items); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('message'); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(false); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({ + limitType: 'afterTimeInterval', + resumeAmount: 1, + resumeUnit: 'minutes', + }); + mockExecuteFunctions.getNode.mockReturnValue(chatNode); + mockExecuteFunctions.getParentNodes.mockReturnValue([ + { + type: CHAT_TRIGGER_NODE_TYPE, + disabled: false, + parameters: { mode: 'hostedChat', options: { responseMode: 'responseNodes' } }, + } as any, + ]); + + const result = await chat.execute.call(mockExecuteFunctions); + + expect(result).toEqual([[{ json: {}, sendMessage: 'message' }]]); + }); + + it('should execute and handle memory connection', async () => { + const items = [{ json: { data: 'test' } }]; + mockExecuteFunctions.getInputData.mockReturnValue(items); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('message'); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({ memoryConnection: true }); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({ + limitType: 'afterTimeInterval', + resumeAmount: 1, + resumeUnit: 'minutes', + }); + mockExecuteFunctions.getNode.mockReturnValue(chatNode); + mockExecuteFunctions.getParentNodes.mockReturnValue([ + { + type: CHAT_TRIGGER_NODE_TYPE, + disabled: false, + parameters: { mode: 'hostedChat', options: { responseMode: 'responseNodes' } }, + } as any, + ]); + + const memory = { chatHistory: { addAIChatMessage: jest.fn() } }; + mockExecuteFunctions.getInputConnectionData.mockResolvedValueOnce(memory); + + await chat.execute.call(mockExecuteFunctions); + + expect(memory.chatHistory.addAIChatMessage).toHaveBeenCalledWith('message'); + }); + + it('should execute without memory connection', async () => { + const items = [{ json: { data: 'test' } }]; + mockExecuteFunctions.getInputData.mockReturnValue(items); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('message'); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(false); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({ + limitType: 'afterTimeInterval', + resumeAmount: 1, + resumeUnit: 'minutes', + }); + mockExecuteFunctions.getNode.mockReturnValue(chatNode); + mockExecuteFunctions.getParentNodes.mockReturnValue([ + { + type: CHAT_TRIGGER_NODE_TYPE, + disabled: false, + parameters: { mode: 'hostedChat', options: { responseMode: 'responseNodes' } }, + } as any, + ]); + + const result = await chat.execute.call(mockExecuteFunctions); + + expect(result).toEqual([[{ json: {}, sendMessage: 'message' }]]); + }); + + it('should execute with specified time limit', async () => { + const items = [{ json: { data: 'test' } }]; + mockExecuteFunctions.getInputData.mockReturnValue(items); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('message'); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(false); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({ + limitType: 'atSpecifiedTime', + maxDateAndTime: new Date().toISOString(), + }); + mockExecuteFunctions.getNode.mockReturnValue(chatNode); + mockExecuteFunctions.getParentNodes.mockReturnValue([ + { + type: CHAT_TRIGGER_NODE_TYPE, + disabled: false, + parameters: { mode: 'hostedChat', options: { responseMode: 'responseNodes' } }, + } as any, + ]); + + const result = await chat.execute.call(mockExecuteFunctions); + + expect(result).toEqual([[{ json: {}, sendMessage: 'message' }]]); + }); + + it('should process onMessage without waiting for reply', async () => { + const data = { json: { chatInput: 'user message' } }; + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({ memoryConnection: true }); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(false); + mockExecuteFunctions.getInputData.mockReturnValue([data]); + mockExecuteFunctions.getNode.mockReturnValue(chatNode); + mockExecuteFunctions.getParentNodes.mockReturnValue([ + { + type: CHAT_TRIGGER_NODE_TYPE, + disabled: false, + parameters: { mode: 'hostedChat', options: { responseMode: 'responseNodes' } }, + } as any, + ]); + + const result = await chat.onMessage(mockExecuteFunctions, data); + + expect(result).toEqual([[data]]); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/ChatTrigger.node.test.ts b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/ChatTrigger.node.test.ts index 1dd55ff0b5b..a9480c7fa91 100644 --- a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/ChatTrigger.node.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/ChatTrigger.node.test.ts @@ -150,8 +150,7 @@ describe('ChatTrigger Node', () => { ): boolean | string | object | undefined => { if (paramName === 'public') return true; if (paramName === 'mode') return 'hostedChat'; - if (paramName === 'options') return {}; - if (paramName === 'options.responseMode') return 'streaming'; + if (paramName === 'options') return { responseMode: 'streaming' }; return defaultValue; }, ); @@ -184,8 +183,7 @@ describe('ChatTrigger Node', () => { ): boolean | string | object | undefined => { if (paramName === 'public') return true; if (paramName === 'mode') return 'hostedChat'; - if (paramName === 'options') return {}; - if (paramName === 'options.responseMode') return 'lastNode'; + if (paramName === 'options') return { responseMode: 'lastNode' }; return defaultValue; }, ); @@ -220,8 +218,7 @@ describe('ChatTrigger Node', () => { ): boolean | string | object | undefined => { if (paramName === 'public') return true; if (paramName === 'mode') return 'hostedChat'; - if (paramName === 'options') return {}; - if (paramName === 'options.responseMode') return 'streaming'; + if (paramName === 'options') return { responseMode: 'streaming' }; return defaultValue; }, ); diff --git a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/templates.ts b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/templates.ts index 7b5d4964f47..a10f377aea8 100644 --- a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/templates.ts +++ b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/templates.ts @@ -77,7 +77,7 @@ export function createPage({ + + + + diff --git a/packages/frontend/editor-ui/src/components/__snapshots__/InputPanel.test.ts.snap b/packages/frontend/editor-ui/src/components/__snapshots__/InputPanel.test.ts.snap index aefdd8a7da0..4962c2a32f4 100644 --- a/packages/frontend/editor-ui/src/components/__snapshots__/InputPanel.test.ts.snap +++ b/packages/frontend/editor-ui/src/components/__snapshots__/InputPanel.test.ts.snap @@ -7,7 +7,6 @@ exports[`InputPanel > should render 1`] = ` data-test-id="ndv-input-panel" data-v-2e5cd75c="" > -
should render 1`] = `
- - - - - - - - +
+ + + + + + + + +
+
diff --git a/packages/frontend/editor-ui/src/features/logs/components/LogsViewRunData.vue b/packages/frontend/editor-ui/src/features/logs/components/LogsViewRunData.vue index 53acb20b17e..33d3b2e98b7 100644 --- a/packages/frontend/editor-ui/src/features/logs/components/LogsViewRunData.vue +++ b/packages/frontend/editor-ui/src/features/logs/components/LogsViewRunData.vue @@ -75,6 +75,7 @@ function handleChangeDisplayMode(value: IRunDataDisplayMode) { v-if="runDataProps" v-bind="runDataProps" :key="`run-data${pipWindow ? '-pip' : ''}`" + :class="$style.component" :workflow="logEntry.workflow" :workflow-execution="logEntry.execution" :too-much-data-title="locale.baseText('ndv.output.tooMuchData.title')" @@ -130,6 +131,10 @@ function handleChangeDisplayMode(value: IRunDataDisplayMode) {