fix(Chat Node): Correct descriptions, aliases, UX improvements (#24619)

This commit is contained in:
RomanDavydchuk 2026-01-23 14:01:37 +02:00 committed by Benjamin Schroth
parent a2908805d9
commit 55152ff93e
No known key found for this signature in database
11 changed files with 277 additions and 24 deletions

View File

@ -51,7 +51,7 @@ export class Chat implements INodeType {
subcategories: {
HITL: ['Human in the Loop'],
},
alias: ['human', 'wait', 'hitl'],
alias: ['human', 'wait', 'hitl', 'respond', 'approve', 'confirm', 'send', 'message'],
resources: {
primaryDocumentation: [
{
@ -295,7 +295,7 @@ export class Chat implements INodeType {
if (parameters.options.responseMode !== 'responseNodes') {
throw new NodeOperationError(
this.getNode(),
'"Response Mode" in the chat trigger node must be set to "Respond Nodes"',
'"Response Mode" in the chat trigger node must be set to "Using Response Nodes"',
);
}

View File

@ -95,12 +95,18 @@ export const cssVariables = `
--chat--button--color--primary--hover: var(--chat--color-light);
--chat--button--background--primary--hover: var(--chat--color-secondary-shade-50);
--chat--button--border--primary--hover: none;
--chat--button--color--primary--disabled: var(--chat--color-light);
--chat--button--background--primary--disabled: #81bbb1;
--chat--button--border--primary--disabled: none;
--chat--button--color--secondary: var(--chat--color-light);
--chat--button--background--secondary: hsl(0, 0%, 58%);
--chat--button--border--secondary: none;
--chat--button--color--secondary--hover: var(--chat--color-light);
--chat--button--background--secondary--hover: hsl(0, 0%, 51%);
--chat--button--border--secondary--hover: none;
--chat--button--color--secondary--disabled: var(--chat--color-light);
--chat--button--background--secondary--disabled: hsl(0, 0%, 78%);
--chat--button--border--secondary--disabled: none;
--chat--close--button--color-hover: var(--chat--color--primary);
/* Send and File Buttons */

View File

@ -310,12 +310,18 @@ The Chat window is entirely customizable using CSS variables.
--chat--button--color--primary--hover: var(--chat--color-light);
--chat--button--background--primary--hover: var(--chat--color-secondary-shade-50);
--chat--button--border--primary--hover: none;
--chat--button--color--primary--disabled: var(--chat--color-light);
--chat--button--background--primary--disabled: #81bbb1;
--chat--button--border--primary--disabled: none;
--chat--button--color--secondary: var(--chat--color-light);
--chat--button--background--secondary: hsl(0, 0%, 58%);
--chat--button--border--secondary: none;
--chat--button--color--secondary--hover: var(--chat--color-light);
--chat--button--background--secondary--hover: hsl(0, 0%, 51%);
--chat--button--border--secondary--hover: none;
--chat--button--color--secondary--disabled: var(--chat--color-light);
--chat--button--background--secondary--disabled: hsl(0, 0%, 78%);
--chat--button--border--secondary--disabled: none;
--chat--close--button--color-hover: var(--chat--color--primary);
/* Send and File Buttons */

View File

@ -1,28 +1,38 @@
<script setup lang="ts">
import { computed } from 'vue';
defineOptions({ inheritAttrs: false });
const props = withDefaults(
defineProps<{
type?: 'primary' | 'secondary';
element?: 'button' | 'a';
disabled?: boolean;
}>(),
{
type: 'primary',
element: 'button',
disabled: false,
},
);
const buttonTypeClass = computed(() => {
return `chat-button-${props.type}`;
return `chat-button-${props.type}${props.disabled ? '-disabled' : ''}`;
});
</script>
<template>
<component :is="element" :class="['chat-button', buttonTypeClass]">
<slot />
</component>
<span :class="{ 'chat-button-wrapper-disabled': props.disabled }">
<component :is="element" :class="['chat-button', buttonTypeClass]" v-bind="$attrs">
<slot />
</component>
</span>
</template>
<style lang="scss">
.chat-button-wrapper-disabled {
cursor: not-allowed;
}
.chat-button {
display: inline-flex;
text-align: center;
@ -44,10 +54,6 @@ const buttonTypeClass = computed(() => {
outline: 0;
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
&:disabled {
opacity: 0.65;
}
}
.chat-button-primary {
@ -62,6 +68,13 @@ const buttonTypeClass = computed(() => {
}
}
.chat-button-primary-disabled {
pointer-events: none;
color: var(--chat--button--color--primary--disabled);
background-color: var(--chat--button--background--primary--disabled);
border: var(--chat--button--border--primary--disabled);
}
.chat-button-secondary {
color: var(--chat--button--color--secondary);
background-color: var(--chat--button--background--secondary);
@ -73,4 +86,11 @@ const buttonTypeClass = computed(() => {
border: var(--chat--button--border--secondary--hover);
}
}
.chat-button-secondary-disabled {
pointer-events: none;
color: var(--chat--button--color--secondary--disabled);
background-color: var(--chat--button--background--secondary--disabled);
border: var(--chat--button--border--secondary--disabled);
}
</style>

View File

@ -1,4 +1,6 @@
<script setup lang="ts">
import { ref } from 'vue';
import Button from './Button.vue';
import MarkdownRenderer from './MarkdownRenderer.vue';
@ -10,19 +12,32 @@ defineProps<{
type: 'primary' | 'secondary';
}>;
}>();
const clickedButtonIndex = ref<number | null>(null);
const onClick = async (link: string, index: number) => {
if (clickedButtonIndex.value !== null) {
return;
}
const response = await fetch(link);
if (response.ok) {
clickedButtonIndex.value = index;
}
};
</script>
<template>
<div>
<MarkdownRenderer :text="text" />
<div :class="$style.buttons">
<template v-for="button in buttons" :key="button.text">
<template v-for="(button, index) in buttons" :key="button.text">
<Button
element="a"
:href="button.link"
v-if="clickedButtonIndex === null || index === clickedButtonIndex"
element="button"
:type="button.type"
target="_blank"
rel="noopener noreferrer"
:disabled="index === clickedButtonIndex"
@click="onClick(button.link, index)"
>{{ button.text }}</Button
>
</template>

View File

@ -95,12 +95,18 @@
--chat--button--color--primary--hover: var(--chat--color-light);
--chat--button--background--primary--hover: var(--chat--color-secondary-shade-50);
--chat--button--border--primary--hover: none;
--chat--button--color--primary--disabled: var(--chat--color-light);
--chat--button--background--primary--disabled: #81bbb1;
--chat--button--border--primary--disabled: none;
--chat--button--color--secondary: var(--chat--color-light);
--chat--button--background--secondary: hsl(0, 0%, 58%);
--chat--button--border--secondary: none;
--chat--button--color--secondary--hover: var(--chat--color-light);
--chat--button--background--secondary--hover: hsl(0, 0%, 51%);
--chat--button--border--secondary--hover: none;
--chat--button--color--secondary--disabled: var(--chat--color-light);
--chat--button--background--secondary--disabled: hsl(0, 0%, 78%);
--chat--button--border--secondary--disabled: none;
--chat--close--button--color-hover: var(--chat--color--primary);
/* Send and File Buttons */

View File

@ -3190,6 +3190,8 @@
"workflowRun.showError.resolveOutstandingIssues": "Please resolve outstanding issues before you activate it",
"workflowRun.showError.unsupportedExecutionLogic.title": "Unsupported Execution Logic",
"workflowRun.showError.unsupportedExecutionLogic.description": "Execution Logic \"v2\" is not supported when filesystem mode is \"default\". Please change the Execution Logic to \"v1\" in workflow settings and update expressions accordingly.",
"workflowRun.showWarning.noChatResponseNodes.title": "No response node detected",
"workflowRun.showWarning.noChatResponseNodes.description": "Add a Chat or Respond to Webhook node to send a reply, or change the response mode in the Chat Trigger.",
"workflowRun.showMessage.message": "Please fix them before executing",
"workflowRun.showMessage.title": "Workflow has issues",
"workflowSettings.executionLogic.v0.title": "v0 (legacy)",

View File

@ -33,6 +33,13 @@ import { waitFor } from '@testing-library/vue';
import { useAgentRequestStore } from '@n8n/stores/useAgentRequestStore';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useI18n } from '@n8n/i18n';
import {
CHAT_TRIGGER_NODE_TYPE,
MANUAL_TRIGGER_NODE_TYPE,
CHAT_NODE_TYPE,
CHAT_TOOL_NODE_TYPE,
RESPOND_TO_WEBHOOK_NODE_TYPE,
} from '../constants';
vi.mock('@/app/stores/workflows.store', () => {
const storeState: Partial<ReturnType<typeof useWorkflowsStore>> & {
@ -780,6 +787,166 @@ describe('useRunWorkflow({ router })', () => {
expect(workflowsStore.runWorkflow).toHaveBeenCalledTimes(1);
expect(setWorkflowExecutionData).lastCalledWith(null);
});
describe('Chat trigger warnings', () => {
const mockExecutionResponse = { executionId: '123' };
const toast = useToast();
const i18n = useI18n();
beforeEach(() => {
vi.mocked(pushConnectionStore).isConnected = true;
vi.mocked(workflowsStore).runWorkflow.mockResolvedValue(mockExecutionResponse);
vi.mocked(workflowsStore).nodesIssuesExist = false;
vi.mocked(workflowsStore).workflowObject = {
name: 'Test Workflow',
} as Workflow;
vi.mocked(workflowsStore).getWorkflowRunData = {
NodeName: [],
};
});
it("should show a warning if there are no chat response nodes and chat trigger's response mode is `responseNodes`", async () => {
const { runWorkflow } = useRunWorkflow({ router });
vi.mocked(workflowHelpers).getWorkflowDataToSave.mockResolvedValue({
id: 'workflowId',
nodes: [
{
name: 'Chat Trigger',
type: CHAT_TRIGGER_NODE_TYPE,
parameters: {
options: {
responseMode: 'responseNodes',
},
},
},
],
} as unknown as WorkflowData);
const result = await runWorkflow({ triggerNode: 'Chat Trigger' });
expect(result).toEqual(mockExecutionResponse);
expect(toast.showMessage).toHaveBeenCalledWith({
title: i18n.baseText('workflowRun.showWarning.noChatResponseNodes.title'),
message: i18n.baseText('workflowRun.showWarning.noChatResponseNodes.description'),
type: 'warning',
});
});
it.each([['lastNode'], ['streaming']])(
"should not show a warning if there are no chat response nodes but chat trigger's response mode is `%s`",
async (responseMode) => {
const { runWorkflow } = useRunWorkflow({ router });
vi.mocked(workflowHelpers).getWorkflowDataToSave.mockResolvedValue({
id: 'workflowId',
nodes: [
{
name: 'Chat Trigger',
type: CHAT_TRIGGER_NODE_TYPE,
parameters: {
options: {
responseMode,
},
},
},
],
} as unknown as WorkflowData);
const result = await runWorkflow({ triggerNode: 'Chat Trigger' });
expect(result).toEqual(mockExecutionResponse);
expect(toast.showMessage).not.toHaveBeenCalled();
},
);
it('should not show a warning if the workflow was started from a different trigger node', async () => {
const { runWorkflow } = useRunWorkflow({ router });
vi.mocked(workflowHelpers).getWorkflowDataToSave.mockResolvedValue({
id: 'workflowId',
nodes: [
{
name: 'Chat Trigger',
type: CHAT_TRIGGER_NODE_TYPE,
parameters: {
options: {
responseMode: 'responseNodes',
},
},
},
{
name: 'Manual Trigger',
type: MANUAL_TRIGGER_NODE_TYPE,
},
],
} as unknown as WorkflowData);
const result = await runWorkflow({ triggerNode: 'Manual Trigger' });
expect(result).toEqual(mockExecutionResponse);
expect(toast.showMessage).not.toHaveBeenCalled();
});
it.each([[CHAT_NODE_TYPE], [CHAT_TOOL_NODE_TYPE], [RESPOND_TO_WEBHOOK_NODE_TYPE]])(
'should not show a warning if the there are response nodes in the workflow (%s)',
async (responseNodeType) => {
const { runWorkflow } = useRunWorkflow({ router });
vi.mocked(workflowHelpers).getWorkflowDataToSave.mockResolvedValue({
id: 'workflowId',
nodes: [
{
name: 'Chat Trigger',
type: CHAT_TRIGGER_NODE_TYPE,
parameters: {
options: {
responseMode: 'responseNodes',
},
},
},
{
name: 'Response Node',
type: responseNodeType,
},
],
} as unknown as WorkflowData);
const result = await runWorkflow({ triggerNode: 'Chat Trigger' });
expect(result).toEqual(mockExecutionResponse);
expect(toast.showMessage).not.toHaveBeenCalled();
},
);
it('should show a warning if all the response nodes are disabled', async () => {
const { runWorkflow } = useRunWorkflow({ router });
vi.mocked(workflowHelpers).getWorkflowDataToSave.mockResolvedValue({
id: 'workflowId',
nodes: [
{
name: 'Chat Trigger',
type: CHAT_TRIGGER_NODE_TYPE,
parameters: {
options: {
responseMode: 'responseNodes',
},
},
},
{
name: 'Response Node',
type: CHAT_NODE_TYPE,
disabled: true,
},
],
} as unknown as WorkflowData);
const result = await runWorkflow({ triggerNode: 'Chat Trigger' });
expect(result).toEqual(mockExecutionResponse);
expect(toast.showMessage).toHaveBeenCalledWith({
title: i18n.baseText('workflowRun.showWarning.noChatResponseNodes.title'),
message: i18n.baseText('workflowRun.showWarning.noChatResponseNodes.description'),
type: 'warning',
});
});
});
});
describe('consolidateRunDataAndStartNodes()', () => {

View File

@ -27,7 +27,13 @@ import { retry } from '@n8n/utils/retry';
import { useToast } from '@/app/composables/useToast';
import { useNodeHelpers } from '@/app/composables/useNodeHelpers';
import { CHAT_TRIGGER_NODE_TYPE, IN_PROGRESS_EXECUTION_ID } from '@/app/constants';
import {
CHAT_NODE_TYPE,
CHAT_TOOL_NODE_TYPE,
CHAT_TRIGGER_NODE_TYPE,
IN_PROGRESS_EXECUTION_ID,
RESPOND_TO_WEBHOOK_NODE_TYPE,
} from '@/app/constants';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
@ -232,6 +238,27 @@ export function useRunWorkflow(useRunWorkflowOpts: {
const triggers = workflowData.nodes.filter(
(node) => node.type.toLowerCase().includes('trigger') && !node.disabled,
);
const chatTriggerNode = triggers.find((node) => node.type === CHAT_TRIGGER_NODE_TYPE);
const chatTriggerNodeOptions = chatTriggerNode?.parameters?.options as IDataObject;
if (
options.triggerNode === chatTriggerNode?.name &&
chatTriggerNodeOptions?.responseMode === 'responseNodes'
) {
const responseNodes = workflowData.nodes.filter(
(node) =>
!node.disabled &&
(node.type === CHAT_NODE_TYPE ||
node.type === CHAT_TOOL_NODE_TYPE ||
node.type === RESPOND_TO_WEBHOOK_NODE_TYPE),
);
if (!responseNodes?.length) {
toast.showMessage({
title: i18n.baseText('workflowRun.showWarning.noChatResponseNodes.title'),
message: i18n.baseText('workflowRun.showWarning.noChatResponseNodes.description'),
type: 'warning',
});
}
}
//if no destination node is specified
//and execution is not triggered from chat
@ -240,17 +267,11 @@ export function useRunWorkflow(useRunWorkflowOpts: {
if (
!options.destinationNode &&
options.source !== 'RunData.ManualChatMessage' &&
workflowData.nodes.some((node) => node.type === CHAT_TRIGGER_NODE_TYPE)
chatTriggerNode
) {
const otherTriggers = triggers.filter((node) => node.type !== CHAT_TRIGGER_NODE_TYPE);
if (otherTriggers.length) {
const chatTriggerNode = workflowData.nodes.find(
(node) => node.type === CHAT_TRIGGER_NODE_TYPE,
);
if (chatTriggerNode) {
chatTriggerNode.disabled = true;
}
chatTriggerNode.disabled = true;
}
}

View File

@ -35,6 +35,7 @@ export const MANUAL_CHAT_TRIGGER_NODE_TYPE = '@n8n/n8n-nodes-langchain.manualCha
export const MCP_TRIGGER_NODE_TYPE = '@n8n/n8n-nodes-langchain.mcpTrigger';
export const CHAT_TRIGGER_NODE_TYPE = '@n8n/n8n-nodes-langchain.chatTrigger';
export const CHAT_NODE_TYPE = '@n8n/n8n-nodes-langchain.chat';
export const CHAT_TOOL_NODE_TYPE = '@n8n/n8n-nodes-langchain.chatTool';
export const AGENT_NODE_TYPE = '@n8n/n8n-nodes-langchain.agent';
export const OPEN_AI_CHAT_MODEL_NODE_TYPE = '@n8n/n8n-nodes-langchain.lmChatOpenAi';
export const OPEN_AI_NODE_TYPE = '@n8n/n8n-nodes-langchain.openAi';

View File

@ -316,6 +316,11 @@ async function copySessionId() {
--button--color--background--primary--hover-active-focus
);
--chat--button--border--primary--hover: none;
--chat--button--color--primary--disabled: var(--button--color--text--primary--disabled);
--chat--button--background--primary--disabled: var(
--button--color--background--primary--disabled
);
--chat--button--border--primary--disabled: none;
--chat--button--color--secondary: var(--button--color--text--secondary);
--chat--button--background--secondary: var(--button--color--background--secondary);
--chat--button--border--secondary: var(--border-width) var(--button--border-color--secondary)
@ -326,6 +331,10 @@ async function copySessionId() {
--chat--button--background--secondary--hover: var(--button--color--background--secondary--hover);
--chat--button--border--secondary--hover: var(--border-width)
var(--button--border-color--secondary--hover-active-focus) var(--border-style);
--chat--button--color--secondary--disabled: var(--button--color--text--secondary--disabled);
--chat--button--background--secondary--disabled: var(--button--color--background--secondary);
--chat--button--border--secondary--disabled: var(--border-width)
var(--button--border-color--secondary--disabled) var(--border-style);
height: 100%;
display: flex;
flex-direction: column;