feat: Implement streaming response node on ChatTrigger and Webhook (no-changelog) (#16761)

This commit is contained in:
Benjamin Schroth
2025-07-01 11:03:23 +02:00
committed by GitHub
parent 8e62c80d48
commit 47bf4b77d3
5 changed files with 510 additions and 167 deletions

View File

@@ -35,6 +35,173 @@ const allowedFileMimeTypeOption: INodeProperties = {
'Allowed file types for upload. Comma-separated list of <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types" target="_blank">MIME types</a>.',
};
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 responseModeWithStreamingOptions = [
...responseModeOptions,
{
name: 'Streaming Response',
value: 'streaming',
description: 'Streaming response from specified nodes (e.g. Agents)',
},
];
const commonOptionsFields: INodeProperties[] = [
// CORS parameters are only valid for when chat is used in hosted or webhook mode
{
displayName: 'Allowed Origins (CORS)',
name: 'allowedOrigins',
type: 'string',
default: '*',
description:
'Comma-separated list of URLs allowed for cross-origin non-preflight requests. Use * (default) to allow all origins.',
displayOptions: {
show: {
'/mode': ['hostedChat', 'webhook'],
},
},
},
{
...allowFileUploadsOption,
displayOptions: {
show: {
'/mode': ['hostedChat'],
},
},
},
{
...allowedFileMimeTypeOption,
displayOptions: {
show: {
'/mode': ['hostedChat'],
},
},
},
{
displayName: 'Input Placeholder',
name: 'inputPlaceholder',
type: 'string',
displayOptions: {
show: {
'/mode': ['hostedChat'],
},
},
default: 'Type your question..',
placeholder: 'e.g. Type your message here',
description: 'Shown as placeholder text in the chat input field',
},
{
displayName: 'Load Previous Session',
name: 'loadPreviousSession',
type: 'options',
options: [
{
name: 'Off',
value: 'notSupported',
description: 'Loading messages of previous session is turned off',
},
{
name: 'From Memory',
value: 'memory',
description: 'Load session messages from memory',
},
{
name: 'Manually',
value: 'manually',
description: 'Manually return messages of session',
},
],
default: 'notSupported',
description: 'If loading messages of a previous session should be enabled',
},
{
displayName: 'Require Button Click to Start Chat',
name: 'showWelcomeScreen',
type: 'boolean',
displayOptions: {
show: {
'/mode': ['hostedChat'],
},
},
default: false,
description: 'Whether to show the welcome screen at the start of the chat',
},
{
displayName: 'Start Conversation Button Text',
name: 'getStarted',
type: 'string',
displayOptions: {
show: {
showWelcomeScreen: [true],
'/mode': ['hostedChat'],
},
},
default: 'New Conversation',
placeholder: 'e.g. New Conversation',
description: 'Shown as part of the welcome screen, in the middle of the chat window',
},
{
displayName: 'Subtitle',
name: 'subtitle',
type: 'string',
displayOptions: {
show: {
'/mode': ['hostedChat'],
},
},
default: "Start a chat. We're here to help you 24/7.",
placeholder: "e.g. We're here for you",
description: 'Shown at the top of the chat, under the title',
},
{
displayName: 'Title',
name: 'title',
type: 'string',
displayOptions: {
show: {
'/mode': ['hostedChat'],
},
},
default: 'Hi there! 👋',
placeholder: 'e.g. Welcome',
description: 'Shown at the top of the chat',
},
{
displayName: 'Custom Chat Styling',
name: 'customCss',
type: 'string',
typeOptions: {
rows: 10,
editor: 'cssEditor',
},
displayOptions: {
show: {
'/mode': ['hostedChat'],
},
},
default: `
${cssVariables}
/* You can override any class styles, too. Right-click inspect in Chat UI to find class to override. */
.chat-message {
max-width: 50%;
}
`.trim(),
description: 'Override default styling of the public chat interface with CSS',
},
];
export class ChatTrigger extends Node {
description: INodeTypeDescription = {
displayName: 'Chat Trigger',
@@ -42,7 +209,9 @@ export class ChatTrigger extends Node {
icon: 'fa:comments',
iconColor: 'black',
group: ['trigger'],
version: [1, 1.1],
version: [1, 1.1, 1.2],
// Keep the default version as 1.1 to avoid releasing streaming in broken state
defaultVersion: 1.1,
description: 'Runs the workflow when an n8n generated webchat is submitted',
defaults: {
name: 'When chat message received',
@@ -228,6 +397,7 @@ export class ChatTrigger extends Node {
default: {},
options: [allowFileUploadsOption, allowedFileMimeTypeOption],
},
// Options for versions 1.0 and 1.1 (without streaming)
{
displayName: 'Options',
name: 'options',
@@ -236,171 +406,46 @@ export class ChatTrigger extends Node {
show: {
mode: ['hostedChat', 'webhook'],
public: [true],
'@version': [1, 1.1],
},
},
placeholder: 'Add Field',
default: {},
options: [
// CORS parameters are only valid for when chat is used in hosted or webhook mode
{
displayName: 'Allowed Origins (CORS)',
name: 'allowedOrigins',
type: 'string',
default: '*',
description:
'Comma-separated list of URLs allowed for cross-origin non-preflight requests. Use * (default) to allow all origins.',
displayOptions: {
show: {
'/mode': ['hostedChat', 'webhook'],
},
},
},
{
...allowFileUploadsOption,
displayOptions: {
show: {
'/mode': ['hostedChat'],
},
},
},
{
...allowedFileMimeTypeOption,
displayOptions: {
show: {
'/mode': ['hostedChat'],
},
},
},
{
displayName: 'Input Placeholder',
name: 'inputPlaceholder',
type: 'string',
displayOptions: {
show: {
'/mode': ['hostedChat'],
},
},
default: 'Type your question..',
placeholder: 'e.g. Type your message here',
description: 'Shown as placeholder text in the chat input field',
},
{
displayName: 'Load Previous Session',
name: 'loadPreviousSession',
type: 'options',
options: [
{
name: 'Off',
value: 'notSupported',
description: 'Loading messages of previous session is turned off',
},
{
name: 'From Memory',
value: 'memory',
description: 'Load session messages from memory',
},
{
name: 'Manually',
value: 'manually',
description: 'Manually return messages of session',
},
],
default: 'notSupported',
description: 'If loading messages of a previous session should be enabled',
},
...commonOptionsFields,
{
displayName: 'Response Mode',
name: 'responseMode',
type: 'options',
options: [
{
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',
},
],
options: responseModeOptions,
default: 'lastNode',
description: 'When and how to respond to the webhook',
},
{
displayName: 'Require Button Click to Start Chat',
name: 'showWelcomeScreen',
type: 'boolean',
displayOptions: {
show: {
'/mode': ['hostedChat'],
},
},
default: false,
description: 'Whether to show the welcome screen at the start of the chat',
],
},
// Options for version 1.2+ (with streaming)
{
displayName: 'Options',
name: 'options',
type: 'collection',
displayOptions: {
show: {
mode: ['hostedChat', 'webhook'],
public: [true],
'@version': [{ _cnd: { gte: 1.2 } }],
},
},
placeholder: 'Add Field',
default: {},
options: [
...commonOptionsFields,
{
displayName: 'Start Conversation Button Text',
name: 'getStarted',
type: 'string',
displayOptions: {
show: {
showWelcomeScreen: [true],
'/mode': ['hostedChat'],
},
},
default: 'New Conversation',
placeholder: 'e.g. New Conversation',
description: 'Shown as part of the welcome screen, in the middle of the chat window',
},
{
displayName: 'Subtitle',
name: 'subtitle',
type: 'string',
displayOptions: {
show: {
'/mode': ['hostedChat'],
},
},
default: "Start a chat. We're here to help you 24/7.",
placeholder: "e.g. We're here for you",
description: 'Shown at the top of the chat, under the title',
},
{
displayName: 'Title',
name: 'title',
type: 'string',
displayOptions: {
show: {
'/mode': ['hostedChat'],
},
},
default: 'Hi there! 👋',
placeholder: 'e.g. Welcome',
description: 'Shown at the top of the chat',
},
{
displayName: 'Custom Chat Styling',
name: 'customCss',
type: 'string',
typeOptions: {
rows: 10,
editor: 'cssEditor',
},
displayOptions: {
show: {
'/mode': ['hostedChat'],
},
},
default: `
${cssVariables}
/* You can override any class styles, too. Right-click inspect in Chat UI to find class to override. */
.chat-message {
max-width: 50%;
}
`.trim(),
description: 'Override default styling of the public chat interface with CSS',
displayName: 'Response Mode',
name: 'responseMode',
type: 'options',
options: responseModeWithStreamingOptions,
default: 'lastNode',
description: 'When and how to respond to the webhook',
},
],
},
@@ -493,6 +538,9 @@ ${cssVariables}
customCss?: string;
};
const responseMode = ctx.getNodeParameter('options.responseMode', 'lastNode') as string;
const enableStreaming = responseMode === 'streaming';
const req = ctx.getRequestObject();
const webhookName = ctx.getWebhookName();
const mode = ctx.getMode() === 'manual' ? 'test' : 'production';
@@ -573,6 +621,32 @@ ${cssVariables}
let returnData: INodeExecutionData[];
const webhookResponse: IDataObject = { status: 200 };
// Handle streaming responses
if (enableStreaming) {
// Set up streaming response headers
res.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
'Transfer-Encoding': 'chunked',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
// Flush headers immediately
res.flushHeaders();
if (req.contentType === 'multipart/form-data') {
returnData = [await this.handleFormData(ctx)];
} else {
returnData = [{ json: bodyData }];
}
return {
workflowData: [ctx.helpers.returnJsonArray(returnData)],
noWebhookResponse: true,
};
}
if (req.contentType === 'multipart/form-data') {
returnData = [await this.handleFormData(ctx)];
return {