This commit is contained in:
Benjamin Schroth 2025-11-20 17:50:12 +01:00 committed by GitHub
commit c6f6e49ab5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 1345 additions and 683 deletions

View File

@ -521,7 +521,7 @@ export class ChatTrigger extends Node {
displayName: 'Response Mode',
name: 'responseMode',
type: 'options',
options: [lastNodeResponseMode, respondNodesResponseMode],
options: [lastNodeResponseMode, respondNodesResponseMode, streamingResponseMode],
default: 'lastNode',
description: 'When and how to respond to the chat',
},
@ -637,7 +637,10 @@ export class ChatTrigger extends Node {
const nodeMode = ctx.getNodeParameter('mode', 'hostedChat');
assertParamIsString('mode', nodeMode, ctx.getNode());
if (!isPublic) {
const mode = ctx.getMode() === 'manual' ? 'test' : 'production';
// Allow execution in manual mode (test) even when not public
if (!isPublic && mode !== 'test') {
res.status(404).end();
return {
noWebhookResponse: true,
@ -669,7 +672,6 @@ export class ChatTrigger extends Node {
const req = ctx.getRequestObject();
const webhookName = ctx.getWebhookName();
const mode = ctx.getMode() === 'manual' ? 'test' : 'production';
const bodyData = ctx.getBodyData() ?? {};
try {

View File

@ -5,7 +5,10 @@ import { ChatTriggerAuthorizationError } from './error';
import type { AuthenticationChatOption } from './types';
export async function validateAuth(context: IWebhookFunctions) {
const authentication = context.getNodeParameter('authentication') as AuthenticationChatOption;
const authentication = context.getNodeParameter(
'authentication',
'none',
) as AuthenticationChatOption;
const req = context.getRequestObject();
const headers = context.getHeaderData();

View File

@ -171,7 +171,20 @@ export class TestWebhooks implements IWebhookManager {
this.clearTimeout(key);
await this.deactivateWebhooks(workflow);
const isChatTrigger = Object.values(workflow.nodes).some(
(node: any) =>
node.type === 'n8n-nodes-langchain.chatTrigger' || node.type.includes('chatTrigger'),
);
if (!isChatTrigger) {
await this.deactivateWebhooks(workflow);
} else {
// For ChatTrigger, set a longer timeout before deactivating
const chatWebhookKey = `${workflowEntity.id}:chat-session`;
this.clearTimeout(chatWebhookKey); // Clear any existing timeout
await this.deactivateWebhooks(workflow);
}
});
}
@ -271,6 +284,7 @@ export class TestWebhooks implements IWebhookManager {
pushRef?: string;
destinationNode?: string;
triggerToStartFrom?: WorkflowRequest.ManualRunPayload['triggerToStartFrom'];
chatSessionId?: string;
}) {
const {
userId,
@ -280,6 +294,7 @@ export class TestWebhooks implements IWebhookManager {
pushRef,
destinationNode,
triggerToStartFrom,
chatSessionId,
} = options;
if (!workflowEntity.id) throw new WorkflowMissingIdError(workflowEntity);
@ -309,12 +324,24 @@ export class TestWebhooks implements IWebhookManager {
return false; // no webhooks found to start a workflow
}
const timeout = setTimeout(
async () => await this.cancelWebhook(workflow.id),
TEST_WEBHOOK_TIMEOUT,
);
const timeoutDuration = TEST_WEBHOOK_TIMEOUT; // 10 minutes for ChatTrigger, normal for others
const timeout = setTimeout(async () => await this.cancelWebhook(workflow.id), timeoutDuration);
for (const webhook of webhooks) {
webhook.path = removeTrailingSlash(webhook.path);
// Use sessionId-based path for ChatTrigger nodes when sessionId is provided
// IMPORTANT: This must happen BEFORE key generation
if (
chatSessionId &&
webhook.node &&
workflow.nodes[webhook.node]?.type === '@n8n/n8n-nodes-langchain.chatTrigger'
) {
// Generate predictable path using workflowId and sessionId (without leading slash to match lookup format)
webhook.path = `${workflow.id}/${chatSessionId}`;
}
const key = this.registrations.toKey(webhook);
const registrationByKey = await this.registrations.get(key);
@ -333,7 +360,6 @@ export class TestWebhooks implements IWebhookManager {
throw new WebhookPathTakenError(webhook.node);
}
webhook.path = removeTrailingSlash(webhook.path);
webhook.isTest = true;
/**

View File

@ -100,6 +100,7 @@ export class WorkflowExecutionService {
dirtyNodeNames,
triggerToStartFrom,
agentRequest,
chatSessionId,
}: WorkflowRequest.ManualRunPayload,
user: User,
pushRef?: string,
@ -158,6 +159,7 @@ export class WorkflowExecutionService {
pushRef,
destinationNode,
triggerToStartFrom,
chatSessionId,
});
if (needsWebhook) return { waitingForWebhook: true };

View File

@ -40,6 +40,7 @@ export declare namespace WorkflowRequest {
data?: ITaskData;
};
agentRequest?: AiAgentRequest;
chatSessionId?: string;
};
type Create = AuthenticatedRequest<{}, {}, CreateUpdatePayload>;

View File

@ -0,0 +1,337 @@
import type { VueWrapper } from '@vue/test-utils';
import { mount } from '@vue/test-utils';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { chatEventBus } from '@n8n/chat/event-buses';
import Chat from '../components/Chat.vue';
import type { ChatMessage } from '../types/messages';
// Mock child components
vi.mock('../components/GetStarted.vue', () => ({
default: { name: 'GetStarted', template: '<div>GetStarted</div>' },
}));
vi.mock('../components/GetStartedFooter.vue', () => ({
default: { name: 'GetStartedFooter', template: '<div>GetStartedFooter</div>' },
}));
vi.mock('../components/Input.vue', () => ({
default: {
name: 'Input',
template:
'<div data-test-id="chat-input" @arrow-key-down="$emit(\'arrowKeyDown\', $event)" @escape-key-down="$emit(\'escapeKeyDown\', $event)"></div>',
},
}));
vi.mock('../components/Layout.vue', () => ({
default: {
name: 'Layout',
template: '<div><slot /><slot name="footer" /></div>',
},
}));
vi.mock('../components/MessagesList.vue', () => ({
default: {
name: 'MessagesList',
template: '<div>MessagesList</div>',
props: ['messages'],
},
}));
vi.mock('virtual:icons/mdi/close', () => ({
default: { name: 'IconClose' },
}));
const mockChatStore = {
initialize: vi.fn().mockResolvedValue(undefined),
startNewSession: vi.fn(),
messages: [] as ChatMessage[],
};
const mockOptions = {
mode: 'window' as const,
showWindowCloseButton: true,
showWelcomeScreen: false,
};
vi.mock('@n8n/chat/composables', () => ({
useI18n: () => ({
t: (key: string) => key,
}),
useChat: () => ({
messages: { value: mockChatStore.messages },
initialize: mockChatStore.initialize,
startNewSession: mockChatStore.startNewSession,
currentSessionId: { value: 'test-session' },
}),
useOptions: () => ({ options: mockOptions }),
}));
vi.mock('@n8n/chat/event-buses', () => ({
chatEventBus: {
emit: vi.fn(),
on: vi.fn(),
},
}));
describe('Chat', () => {
let wrapper: VueWrapper;
beforeEach(() => {
vi.clearAllMocks();
mockChatStore.messages = [];
});
afterEach(() => {
if (wrapper) {
wrapper.unmount();
}
});
describe('arrow key navigation', () => {
beforeEach(() => {
// Set up messages for testing navigation
mockChatStore.messages = [
{
id: '1',
text: 'First message',
sender: 'user',
type: 'text',
},
{
id: '2',
text: 'Bot response',
sender: 'bot',
type: 'text',
},
{
id: '3',
text: 'Second message',
sender: 'user',
type: 'text',
},
{
id: '4',
text: 'Third message',
sender: 'user',
type: 'text',
},
];
});
it('should navigate to previous message on ArrowUp', async () => {
wrapper = mount(Chat);
// Trigger ArrowUp
const input = wrapper.findComponent({ name: 'Input' });
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: '' });
expect(vi.mocked(chatEventBus.emit)).toHaveBeenCalledWith('setInputValue', 'Third message');
});
it('should navigate through message history on multiple ArrowUp presses', async () => {
wrapper = mount(Chat);
const input = wrapper.findComponent({ name: 'Input' });
// First ArrowUp - should get most recent message
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: '' });
expect(vi.mocked(chatEventBus.emit)).toHaveBeenCalledWith('setInputValue', 'Third message');
// Second ArrowUp - should get second most recent
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: 'Third message' });
expect(vi.mocked(chatEventBus.emit)).toHaveBeenCalledWith('setInputValue', 'Second message');
// Third ArrowUp - should get oldest
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: 'Second message' });
expect(vi.mocked(chatEventBus.emit)).toHaveBeenCalledWith('setInputValue', 'First message');
});
it('should not go beyond the oldest message', async () => {
wrapper = mount(Chat);
const input = wrapper.findComponent({ name: 'Input' });
// Navigate to the end
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: '' });
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: 'Third message' });
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: 'Second message' });
vi.clearAllMocks();
// Try to go beyond the oldest message
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: 'First message' });
// Should still emit blur/focus, but not setInputValue
expect(vi.mocked(chatEventBus.emit)).toHaveBeenCalledWith('blurInput');
expect(vi.mocked(chatEventBus.emit)).toHaveBeenCalledWith('focusInput');
expect(vi.mocked(chatEventBus.emit)).not.toHaveBeenCalledWith(
'setInputValue',
expect.anything(),
);
});
it('should navigate forward on ArrowDown', async () => {
wrapper = mount(Chat);
const input = wrapper.findComponent({ name: 'Input' });
// Navigate back first
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: '' });
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: 'Third message' });
vi.clearAllMocks();
// Navigate forward
await input.vm.$emit('arrowKeyDown', {
key: 'ArrowDown',
currentInputValue: 'Second message',
});
expect(vi.mocked(chatEventBus.emit)).toHaveBeenCalledWith('setInputValue', 'Third message');
});
it('should clear input when navigating past the newest message', async () => {
wrapper = mount(Chat);
const input = wrapper.findComponent({ name: 'Input' });
// Navigate back
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: '' });
vi.clearAllMocks();
// Navigate forward to clear
await input.vm.$emit('arrowKeyDown', {
key: 'ArrowDown',
currentInputValue: 'Third message',
});
expect(vi.mocked(chatEventBus.emit)).toHaveBeenCalledWith('setInputValue', '');
});
it('should handle empty message history gracefully', async () => {
mockChatStore.messages = [];
wrapper = mount(Chat);
const input = wrapper.findComponent({ name: 'Input' });
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: '' });
expect(vi.mocked(chatEventBus.emit)).not.toHaveBeenCalled();
});
it('should only include user messages in navigation', async () => {
wrapper = mount(Chat);
const input = wrapper.findComponent({ name: 'Input' });
// First ArrowUp should skip bot messages
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: '' });
expect(vi.mocked(chatEventBus.emit)).toHaveBeenCalledWith('setInputValue', 'Third message');
// Second ArrowUp should also skip bot messages
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: 'Third message' });
expect(vi.mocked(chatEventBus.emit)).toHaveBeenCalledWith('setInputValue', 'Second message');
});
it('should reset history index when messageSent event is emitted', async () => {
wrapper = mount(Chat);
const input = wrapper.findComponent({ name: 'Input' });
// Navigate back in history
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: '' });
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: 'Third message' });
// Get the messageSent callback
const messageSentCallback = vi
.mocked(chatEventBus.on)
.mock.calls.find((call) => call[0] === 'messageSent')?.[1];
expect(messageSentCallback).toBeDefined();
// Trigger messageSent event
if (messageSentCallback) {
messageSentCallback();
}
vi.clearAllMocks();
// After reset, ArrowUp should start from the beginning again
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: '' });
expect(vi.mocked(chatEventBus.emit)).toHaveBeenCalledWith('setInputValue', 'Third message');
});
it('should preserve current input when starting navigation', async () => {
wrapper = mount(Chat);
const input = wrapper.findComponent({ name: 'Input' });
// Start navigation with some input
await input.vm.$emit('arrowKeyDown', {
key: 'ArrowUp',
currentInputValue: 'My partial message',
});
// Navigate down to restore
await input.vm.$emit('arrowKeyDown', {
key: 'ArrowDown',
currentInputValue: 'Third message',
});
await input.vm.$emit('arrowKeyDown', {
key: 'ArrowDown',
currentInputValue: 'Third message',
});
// Should restore the original input
expect(vi.mocked(chatEventBus.emit)).toHaveBeenCalledWith(
'setInputValue',
'My partial message',
);
});
it('should emit blur and focus events during navigation', async () => {
wrapper = mount(Chat);
const input = wrapper.findComponent({ name: 'Input' });
vi.clearAllMocks();
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: '' });
// Should blur before setting value
expect(vi.mocked(chatEventBus.emit)).toHaveBeenCalledWith('blurInput');
// Should focus after setting value
expect(vi.mocked(chatEventBus.emit)).toHaveBeenCalledWith('focusInput');
});
it('should handle escape key to exit navigation mode', async () => {
wrapper = mount(Chat);
const input = wrapper.findComponent({ name: 'Input' });
// Navigate back in history
await input.vm.$emit('arrowKeyDown', {
key: 'ArrowUp',
currentInputValue: 'My draft message',
});
vi.clearAllMocks();
// Press escape
await input.vm.$emit('escapeKeyDown', {});
// Should restore original input
expect(vi.mocked(chatEventBus.emit)).toHaveBeenCalledWith(
'setInputValue',
'My draft message',
);
});
it('should not handle escape when not in navigation mode', async () => {
wrapper = mount(Chat);
const input = wrapper.findComponent({ name: 'Input' });
vi.clearAllMocks();
// Press escape without being in navigation mode
await input.vm.$emit('escapeKeyDown', {});
// Should not emit setInputValue
expect(vi.mocked(chatEventBus.emit)).not.toHaveBeenCalledWith(
'setInputValue',
expect.anything(),
);
});
});
});

View File

@ -0,0 +1,176 @@
import type { VueWrapper } from '@vue/test-utils';
import { mount } from '@vue/test-utils';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { useChat } from '@n8n/chat/composables';
import { chatEventBus } from '@n8n/chat/event-buses';
import type { ChatMessage } from '@n8n/chat/types';
import MessageActions from '../components/MessageActions.vue';
vi.mock('@n8n/design-system', () => ({
N8nTooltip: {
name: 'N8nTooltip',
template: '<div><slot /></div>',
},
N8nIcon: {
name: 'N8nIcon',
template: '<div :icon="icon" :size="size" @click="$emit(\'click\')"></div>',
props: ['icon', 'size'],
},
}));
vi.mock('@n8n/chat/composables', () => ({
useChat: vi.fn(() => ({
sendMessage: vi.fn(),
})),
useOptions: () => ({
options: {
enableMessageActions: true,
},
}),
useI18n: () => ({
t: vi.fn(),
}),
}));
vi.mock('@n8n/chat/event-buses', () => ({
chatEventBus: {
emit: vi.fn(),
},
}));
describe('MessageActions', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let wrapper: VueWrapper<any>;
const userMessage: ChatMessage = {
id: '1',
text: 'Hello, world!',
sender: 'user',
type: 'text',
};
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
if (wrapper) {
wrapper.unmount();
}
});
it('should render message actions for user messages when enabled', () => {
wrapper = mount(MessageActions, {
props: {
message: userMessage,
},
});
expect(wrapper.find('.message-actions').exists()).toBe(true);
});
it('should call sendMessage when repost icon is clicked', async () => {
const mockSendMessage = vi.fn();
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
vi.mocked(useChat).mockReturnValue({ sendMessage: mockSendMessage } as any);
wrapper = mount(MessageActions, {
props: {
message: userMessage,
},
});
const repostIcon = wrapper.find('[icon="redo-2"]');
expect(repostIcon.exists()).toBe(true);
await repostIcon.trigger('click');
expect(mockSendMessage).toHaveBeenCalledWith('Hello, world!', []);
});
it('should emit setInputValue event when copy to input icon is clicked', async () => {
wrapper = mount(MessageActions, {
props: {
message: userMessage,
},
});
const copyIcon = wrapper.find('[icon="files"]');
expect(copyIcon.exists()).toBe(true);
await copyIcon.trigger('click');
expect(vi.mocked(chatEventBus.emit)).toHaveBeenCalledWith('setInputValue', 'Hello, world!');
});
it('should handle messages with files when reposting', async () => {
const mockSendMessage = vi.fn();
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
vi.mocked(useChat).mockReturnValue({ sendMessage: mockSendMessage } as any);
const messageWithFiles: ChatMessage = {
id: '3',
text: 'Message with files',
sender: 'user',
type: 'text',
files: [new File(['test'], 'test.txt')],
};
wrapper = mount(MessageActions, {
props: {
message: messageWithFiles,
},
});
const repostIcon = wrapper.find('[icon="redo-2"]');
await repostIcon.trigger('click');
expect(mockSendMessage).toHaveBeenCalledWith('Message with files', [expect.any(File)]);
});
it('should not repost empty messages', async () => {
const mockSendMessage = vi.fn();
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
vi.mocked(useChat).mockReturnValue({ sendMessage: mockSendMessage } as any);
const emptyMessage: ChatMessage = {
id: '4',
text: ' ',
sender: 'user',
type: 'text',
};
wrapper = mount(MessageActions, {
props: {
message: emptyMessage,
},
});
const repostIcon = wrapper.find('[icon="redo-2"]');
await repostIcon.trigger('click');
expect(mockSendMessage).not.toHaveBeenCalled();
});
it('should not copy empty messages to input', async () => {
const emptyMessage: ChatMessage = {
id: '5',
text: ' ',
sender: 'user',
type: 'text',
};
wrapper = mount(MessageActions, {
props: {
message: emptyMessage,
},
});
const copyIcon = wrapper.find('[icon="files"]');
await copyIcon.trigger('click');
expect(vi.mocked(chatEventBus.emit)).not.toHaveBeenCalled();
});
});

View File

@ -27,8 +27,15 @@ export async function sendMessage(
sessionId: string,
options: ChatOptions,
) {
// Call beforeMessageSent handler if provided
if (options.beforeMessageSent) {
await options.beforeMessageSent(message);
}
let response: SendMessageResponse;
if (files.length > 0) {
return await postWithFiles<SendMessageResponse>(
response = await postWithFiles<SendMessageResponse>(
`${options.webhookUrl}`,
{
action: 'sendMessage',
@ -41,20 +48,28 @@ export async function sendMessage(
headers: options.webhookConfig?.headers,
},
);
} else {
const method = options.webhookConfig?.method === 'POST' ? post : get;
response = await method<SendMessageResponse>(
`${options.webhookUrl}`,
{
action: 'sendMessage',
[options.chatSessionKey as string]: sessionId,
[options.chatInputKey as string]: message,
...(options.metadata ? { metadata: options.metadata } : {}),
},
{
headers: options.webhookConfig?.headers,
},
);
}
const method = options.webhookConfig?.method === 'POST' ? post : get;
return await method<SendMessageResponse>(
`${options.webhookUrl}`,
{
action: 'sendMessage',
[options.chatSessionKey as string]: sessionId,
[options.chatInputKey as string]: message,
...(options.metadata ? { metadata: options.metadata } : {}),
},
{
headers: options.webhookConfig?.headers,
},
);
// Call afterMessageSent handler if provided
if (options.afterMessageSent) {
await options.afterMessageSent(message, response);
}
return response;
}
// Create a transform stream that parses newline-delimited JSON
@ -116,6 +131,11 @@ export async function sendMessageStreaming(
options: ChatOptions,
handlers: StreamingEventHandlers,
): Promise<{ hasReceivedChunks: boolean }> {
// Call beforeMessageSent handler if provided
if (options.beforeMessageSent) {
await options.beforeMessageSent(message);
}
// Build request
const response = await (files.length > 0
? sendWithFiles(message, files, sessionId, options)
@ -165,6 +185,11 @@ export async function sendMessageStreaming(
reader.releaseLock();
}
// Call afterMessageSent handler if provided
if (options.afterMessageSent) {
await options.afterMessageSent(message, { hasReceivedChunks });
}
return { hasReceivedChunks };
}

View File

@ -1,10 +1,11 @@
<script setup lang="ts">
import Close from 'virtual:icons/mdi/close';
import { computed, nextTick, onMounted } from 'vue';
import { computed, nextTick, onMounted, ref } from 'vue';
import GetStarted from '@n8n/chat/components/GetStarted.vue';
import GetStartedFooter from '@n8n/chat/components/GetStartedFooter.vue';
import Input from '@n8n/chat/components/Input.vue';
import type { ArrowKeyDownPayload } from '@n8n/chat/components/Input.vue';
import Layout from '@n8n/chat/components/Layout.vue';
import MessagesList from '@n8n/chat/components/MessagesList.vue';
import { useI18n, useChat, useOptions } from '@n8n/chat/composables';
@ -18,7 +19,16 @@ const { options } = useOptions();
const showCloseButton = computed(() => options.mode === 'window' && options.showWindowCloseButton);
async function getStarted() {
// Message history navigation
const messageHistoryIndex = ref(-1);
const currentInputBuffer = ref('');
const userMessages = computed(() =>
messages.value
.filter((m) => m.sender === 'user')
.map((m) => ('text' in m && typeof m.text === 'string' ? m.text : '')),
);
function getStarted() {
if (!chatStore.startNewSession) {
return;
}
@ -42,11 +52,79 @@ function closeChat() {
chatEventBus.emit('close');
}
function onArrowKeyDown(payload: ArrowKeyDownPayload) {
const userMessagesList = userMessages.value;
if (userMessagesList.length === 0) {
return;
}
// Save current input if we're starting navigation
if (messageHistoryIndex.value === -1 && payload.currentInputValue.length > 0) {
currentInputBuffer.value = payload.currentInputValue;
}
if (payload.key === 'ArrowUp') {
// Temporarily blur to avoid cursor position issues
chatEventBus.emit('blurInput');
// Navigate to previous message
if (messageHistoryIndex.value < userMessagesList.length - 1) {
messageHistoryIndex.value++;
const messageText = userMessagesList[userMessagesList.length - 1 - messageHistoryIndex.value];
chatEventBus.emit('setInputValue', messageText);
}
// Refocus and move cursor to end
chatEventBus.emit('focusInput');
} else if (payload.key === 'ArrowDown') {
// Only navigate if we're in history mode
if (messageHistoryIndex.value === -1) return;
// Temporarily blur to avoid cursor position issues
chatEventBus.emit('blurInput');
// Navigate to next message or restore original input
if (messageHistoryIndex.value > 0) {
messageHistoryIndex.value--;
const messageText = userMessagesList[userMessagesList.length - 1 - messageHistoryIndex.value];
chatEventBus.emit('setInputValue', messageText);
} else if (messageHistoryIndex.value === 0) {
// Reached the end - restore original input or clear
messageHistoryIndex.value = -1;
chatEventBus.emit('setInputValue', currentInputBuffer.value);
currentInputBuffer.value = '';
}
// Refocus and move cursor to end
chatEventBus.emit('focusInput');
}
}
function onEscapeKeyDown() {
// Only handle escape if we're in history navigation mode
if (messageHistoryIndex.value === -1) return;
// Exit history mode and restore original input
messageHistoryIndex.value = -1;
chatEventBus.emit('setInputValue', currentInputBuffer.value);
currentInputBuffer.value = '';
}
onMounted(async () => {
if (!messages.value.length && options.messageHistory) {
messages.value = options.messageHistory.map((m) => ({ ...m }));
}
await initialize();
if (!options.showWelcomeScreen && !currentSessionId.value) {
await getStarted();
getStarted();
}
// Reset history index and buffer when a new message is sent
chatEventBus.on('messageSent', () => {
messageHistoryIndex.value = -1;
currentInputBuffer.value = '';
});
});
</script>
@ -71,7 +149,11 @@ onMounted(async () => {
<GetStarted v-if="!currentSessionId && options.showWelcomeScreen" @click:button="getStarted" />
<MessagesList v-else :messages="messages" />
<template #footer>
<Input v-if="currentSessionId" />
<Input
v-if="currentSessionId"
@arrow-key-down="onArrowKeyDown"
@escape-key-down="onEscapeKeyDown"
/>
<GetStartedFooter v-else />
</template>
</Layout>

View File

@ -254,6 +254,8 @@ async function onSubmit(event: MouseEvent | KeyboardEvent) {
if (chatStore.ws && waitingForChatResponse.value) {
await respondToChatNode(chatStore.ws, messageText);
// Emit event to reset message history navigation
chatEventBus.emit('messageSent');
return;
}
@ -263,6 +265,9 @@ async function onSubmit(event: MouseEvent | KeyboardEvent) {
setupWebsocketConnection(response.executionId);
}
// Emit event to reset message history navigation
chatEventBus.emit('messageSent');
isSubmitting.value = false;
}
@ -322,7 +327,7 @@ function adjustTextAreaHeight() {
</script>
<template>
<div class="chat-input" :style="styleVars" @keydown.stop="onKeyDown">
<div class="chat-input" :style="styleVars">
<div class="chat-inputs">
<div v-if="$slots.leftPanel" class="chat-input-left-panel">
<slot name="leftPanel" />
@ -334,6 +339,7 @@ function adjustTextAreaHeight() {
:disabled="isInputDisabled"
:placeholder="t(props.placeholder)"
@keydown.enter="onSubmitKeydown"
@keydown="onKeyDown"
@input="adjustTextAreaHeight"
@mousedown="adjustTextAreaHeight"
@focus="adjustTextAreaHeight"
@ -381,21 +387,25 @@ function adjustTextAreaHeight() {
}
}
.chat-inputs {
width: 100%;
width: var(--chat--input--width, 100%);
display: flex;
justify-content: center;
align-items: flex-end;
background: var(--chat--input--container--background, var(--color--background--light-2));
border: var(--chat--input--container--border, 1px solid var(--color--foreground--tint-1));
border-radius: var(--chat--input--container--border-radius, 24px);
padding: var(--chat--input--container--padding, 12px);
textarea {
font-family: inherit;
font-size: var(--chat--input--font-size);
width: 100%;
border: var(--chat--input--border, 0);
border: none;
border-radius: var(--chat--input--border-radius);
padding: var(--chat--input--padding);
min-height: var(--chat--textarea--height, 2.5rem); // Set a smaller initial height
min-height: var(--chat--textarea--height);
max-height: var(--chat--textarea--max-height);
height: var(--chat--textarea--height, 2.5rem); // Set initial height same as min-height
height: var(--chat--textarea--height);
resize: none;
overflow-y: auto;
background: var(--chat--input--background, white);
@ -406,10 +416,6 @@ function adjustTextAreaHeight() {
&::placeholder {
font-size: var(--chat--input--placeholder--font-size, var(--chat--input--font-size));
}
&:focus,
&:hover {
border-color: var(--chat--input--border-active, 0);
}
}
}
.chat-inputs-controls {
@ -419,15 +425,17 @@ function adjustTextAreaHeight() {
.chat-input-file-button {
height: var(--chat--textarea--height);
width: var(--chat--textarea--height);
background: var(--chat--input--send--button--background, white);
background: var(--chat--input--send--button--background, transparent);
cursor: pointer;
color: var(--chat--input--send--button--color, var(--chat--color--secondary));
border: 0;
border: none;
border-radius: var(--chat--input--button--border-radius, 16px);
font-size: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
transition: color var(--chat--transition-duration) ease;
transition: all var(--chat--transition-duration, 0.15s) ease;
margin: 8px;
svg {
min-width: fit-content;
@ -438,24 +446,18 @@ function adjustTextAreaHeight() {
color: var(--chat--color-disabled);
}
.chat-input-send-button {
&:hover,
&:focus {
background: var(
--chat--input--send--button--background-hover,
var(--chat--input--send--button--background)
);
color: var(--chat--input--send--button--color-hover);
}
&:hover:not([disabled]) {
background: var(--chat--input--send--button--background-hover, rgba(0, 0, 0, 0.05));
color: var(--chat--input--send--button--color-hover, var(--chat--color--secondary));
}
}
.chat-input-file-button {
background: var(--chat--input--file--button--background, white);
color: var(--chat--input--file--button--color);
background: var(--chat--input--file--button--background, transparent);
color: var(--chat--input--file--button--color, var(--chat--color--secondary));
&:hover {
background: var(--chat--input--file--button--background-hover);
color: var(--chat--input--file--button--color-hover);
&:hover:not([disabled]) {
background: var(--chat--input--file--button--background-hover, rgba(0, 0, 0, 0.05));
color: var(--chat--input--file--button--color-hover, var(--chat--color--secondary));
}
}

View File

@ -79,8 +79,9 @@ onBeforeUnmount(() => {
}
.chat-footer {
border-top: 1px solid var(--chat--color-light-shade-100);
border-top: var(--chat--footer--border-top, 1px solid var(--chat--color-light-shade-100));
background: var(--chat--footer--background);
padding: var(--chat--footer--padding);
color: var(--chat--footer--color);
}
}

View File

@ -15,6 +15,7 @@ import { useOptions } from '@n8n/chat/composables';
import type { ChatMessage, ChatMessageText } from '@n8n/chat/types';
import ChatFile from './ChatFile.vue';
import MessageActions from './MessageActions.vue';
const props = defineProps<{
message: ChatMessage;
@ -105,8 +106,12 @@ onMounted(async () => {
<template>
<div ref="messageContainer" class="chat-message" :class="classes">
<div v-if="!!$slots.beforeMessage" class="chat-message-actions">
<div
v-if="!!$slots.beforeMessage || options?.enableMessageActions"
class="chat-message-actions"
>
<slot name="beforeMessage" v-bind="{ message }" />
<MessageActions :message="message" />
</div>
<slot>
<template v-if="message.type === 'component' && messageComponents[message.key]">

View File

@ -0,0 +1,68 @@
<script setup lang="ts">
import { N8nTooltip, N8nIcon } from '@n8n/design-system';
import { useI18n, useChat, useOptions } from '@n8n/chat/composables';
import { chatEventBus } from '@n8n/chat/event-buses';
import type { ChatMessage } from '@n8n/chat/types';
const props = defineProps<{
message: ChatMessage;
}>();
const { options } = useOptions();
const chatStore = useChat();
const { t } = useI18n();
async function repostMessage() {
if (props.message.sender === 'user') {
// Repost user message by sending it again
const messageText =
'text' in props.message && typeof props.message.text === 'string' ? props.message.text : '';
if (messageText.trim()) {
await chatStore.sendMessage(
messageText,
props.message.files ? Array.from(props.message.files) : [],
);
}
}
}
function copyToInput() {
const messageText =
'text' in props.message && typeof props.message?.text === 'string' ? props.message?.text : '';
if (messageText.trim()) {
chatEventBus.emit('setInputValue', messageText);
}
}
</script>
<template>
<div v-if="options.enableMessageActions" class="message-actions">
<N8nTooltip v-if="message.sender === 'user'">
<N8nIcon icon="redo-2" size="medium" class="icon" @click="repostMessage" />
<template #content>{{ t('repostButton') }}</template>
</N8nTooltip>
<N8nTooltip v-if="message.sender === 'user'">
<N8nIcon icon="files" size="medium" class="icon" @click="copyToInput" />
<template #content>{{ t('reuseButton') }}</template>
</N8nTooltip>
</div>
</template>
<style lang="scss" scoped>
.message-actions {
display: inline-flex;
gap: var(--chat--message-actions--gap, 4rem);
margin: 0 var(--chat--message-actions--gap, 4rem);
align-items: center;
}
.icon {
color: var(--chat--color-light);
cursor: pointer;
&:hover {
color: var(--chat--color--primary);
}
}
</style>

View File

@ -22,6 +22,8 @@ export const defaultOptions: ChatOptions = {
getStarted: 'New Conversation',
inputPlaceholder: 'Type your question..',
closeButtonTooltip: 'Close chat',
repostButton: 'Repost message',
reuseButton: 'Reuse message',
},
},
theme: {},

View File

@ -81,6 +81,7 @@
/* Input Area */
--chat--textarea--height: 50px;
--chat--textarea--max-height: 30rem;
--chat--input--width: 100%;
--chat--input--font-size: inherit;
--chat--input--border: 0;
--chat--input--border-radius: 0;
@ -91,6 +92,11 @@
--chat--input--placeholder--font-size: var(--chat--input--font-size);
--chat--input--border-active: 0;
--chat--input--left--panel--width: 2rem;
--chat--input--container--background: var(--chat--color-white);
--chat--input--container--border: 0;
--chat--input--container--border-radius: 0;
--chat--input--container--padding: 0;
--chat--input--button--border-radius: 16px;
/* Button Styles */
--chat--button--color: var(--chat--color-light);
@ -101,6 +107,10 @@
--chat--button--hover--background: var(--chat--color--primary-shade-50);
--chat--close--button--color-hover: var(--chat--color--primary);
/* Message Action Buttons */
--chat--message-actions--gap: var(--chat--spacing);
--chat--message-actions--icon-size: var(--chat--toggle--size);
/* Send and File Buttons */
--chat--input--send--button--background: var(--chat--color-white);
--chat--input--send--button--color: var(--chat--color--secondary);
@ -114,6 +124,8 @@
/* Body and Footer */
--chat--body--background: var(--chat--color-light);
--chat--footer--padding: 0;
--chat--footer--background: var(--chat--color-light);
--chat--footer--color: var(--chat--color-dark);
--chat--footer--border-top: 1px solid var(--chat--color-light-shade-100);
}

View File

@ -259,7 +259,9 @@ export const ChatPlugin: Plugin<ChatOptions> = {
return;
}
const sessionId = localStorage.getItem(localStorageSessionIdKey) ?? uuidv4();
// Use provided sessionId if available, otherwise check localStorage or generate new one
const sessionId =
options.sessionId ?? localStorage.getItem(localStorageSessionIdKey) ?? uuidv4();
const previousMessagesResponse = await api.loadPreviousSession(sessionId, options);
messages.value = (previousMessagesResponse?.data || []).map((message, index) => ({
@ -272,12 +274,16 @@ export const ChatPlugin: Plugin<ChatOptions> = {
currentSessionId.value = sessionId;
}
// Store the sessionId in localStorage for future use
localStorage.setItem(localStorageSessionIdKey, sessionId);
return sessionId;
}
// eslint-disable-next-line @typescript-eslint/require-await
async function startNewSession() {
currentSessionId.value = uuidv4();
// Use provided sessionId if available, otherwise generate new one
currentSessionId.value = options.sessionId ?? uuidv4();
localStorage.setItem(localStorageSessionIdKey, currentSessionId.value);
}

View File

@ -1,5 +1,8 @@
import type { Component, Ref } from 'vue';
import type { ChatMessage } from './messages';
import type { SendMessageResponse } from './webhook';
export interface ChatOptions {
webhookUrl: string;
webhookConfig?: {
@ -11,10 +14,12 @@ export interface ChatOptions {
showWindowCloseButton?: boolean;
showWelcomeScreen?: boolean;
loadPreviousSession?: boolean;
sessionId?: string;
chatInputKey?: string;
chatSessionKey?: string;
defaultLanguage?: 'en';
initialMessages?: string[];
messageHistory?: ChatMessage[];
metadata?: Record<string, unknown>;
i18n: Record<
string,
@ -34,4 +39,12 @@ export interface ChatOptions {
allowFileUploads?: Ref<boolean> | boolean;
allowedFilesMimeTypes?: Ref<string> | string;
enableStreaming?: boolean;
// Event handlers for message lifecycle
beforeMessageSent?: (message: string) => void | Promise<void>;
afterMessageSent?: (
message: string,
response?: SendMessageResponse | { hasReceivedChunks: boolean },
) => void | Promise<void>;
// Message action options
enableMessageActions?: boolean;
}

View File

@ -190,6 +190,7 @@ export interface IStartRunData {
name: string;
data?: ITaskData;
};
chatSessionId?: string;
agentRequest?: {
query: NodeParameterValueType;
tool: {

View File

@ -134,6 +134,7 @@ export function useRunWorkflow(useRunWorkflowOpts: {
rerunTriggerNode?: boolean;
nodeData?: ITaskData;
source?: string;
sessionId?: string;
}): Promise<IExecutionPushResponse | undefined> {
if (workflowsStore.activeExecutionId) {
return;
@ -226,27 +227,6 @@ export function useRunWorkflow(useRunWorkflowOpts: {
(node) => node.type.toLowerCase().includes('trigger') && !node.disabled,
);
//if no destination node is specified
//and execution is not triggered from chat
//and there are other triggers in the workflow
//disable chat trigger node to avoid modal opening and webhook creation
if (
!options.destinationNode &&
options.source !== 'RunData.ManualChatMessage' &&
workflowData.nodes.some((node) => node.type === CHAT_TRIGGER_NODE_TYPE)
) {
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;
}
}
}
// partial executions must have a destination node
const isPartialExecution = options.destinationNode !== undefined;
@ -318,6 +298,7 @@ export function useRunWorkflow(useRunWorkflowOpts: {
: undefined, // if it's a full execution we don't want to send any run data
startNodes,
triggerToStartFrom,
chatSessionId: options.sessionId,
};
if ('destinationNode' in options) {

View File

@ -1,20 +1,25 @@
<script setup lang="ts">
import type { ChatMessage, ChatMessageText } from '@n8n/chat/types';
import { useI18n } from '@n8n/i18n';
import MessagesList from '@n8n/chat/components/MessagesList.vue';
import MessageOptionTooltip from './MessageOptionTooltip.vue';
import MessageOptionAction from './MessageOptionAction.vue';
import { chatEventBus } from '@n8n/chat/event-buses';
import type { ArrowKeyDownPayload } from '@n8n/chat/components/Input.vue';
import ChatInput from '@n8n/chat/components/Input.vue';
import { computed, ref } from 'vue';
import { useClipboard } from '@/app/composables/useClipboard';
import { useToast } from '@/app/composables/useToast';
import Chat from '@n8n/chat/components/Chat.vue';
import { ChatPlugin } from '@n8n/chat/plugins';
import {
computed,
createApp,
nextTick,
onMounted,
onUnmounted,
useTemplateRef,
watch,
type App,
} from 'vue';
import LogsPanelHeader from '@/features/execution/logs/components/LogsPanelHeader.vue';
import { N8nButton, N8nIconButton, N8nTooltip } from '@n8n/design-system';
import { useClipboard } from '@vueuse/core';
import { useToast } from '@/app/composables/useToast';
import { useChatState } from '@/features/execution/logs/composables/useChatState';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
interface Props {
pastChatMessages: string[];
messages: ChatMessage[];
sessionId: string;
showCloseButton?: boolean;
isOpen?: boolean;
@ -29,23 +34,29 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<{
displayExecution: [id: string];
sendMessage: [message: string];
refreshSession: [];
close: [];
clickHeader: [];
hideChatPanel: [];
}>();
const clipboard = useClipboard();
const locale = useI18n();
const clipboard = useClipboard();
const toast = useToast();
const workflowsStore = useWorkflowsStore();
const chatContainer = useTemplateRef<HTMLElement>('chatContainer');
// -1 is a special value meaning we are not navigating history,
// 0 is the oldest message, pastChatMessages.length - 1 is the most recent message
const previousMessageIndex = ref(-1);
// Use the chat state composable
const {
chatTriggerNode,
isStreamingEnabled,
isFileUploadsAllowed,
allowedFilesMimeTypes,
isWorkflowReadyForChat,
chatOptions,
} = useChatState(props.isReadOnly, props.sessionId);
// Buffer to hold current input when navigating history
const currentInputBuffer = ref('');
let chatApp: App | null = null;
const sessionIdText = computed(() =>
locale.baseText('chat.window.session.id', {
@ -53,106 +64,6 @@ const sessionIdText = computed(() =>
}),
);
const inputPlaceholder = computed(() => {
if (props.messages.length > 0) {
return locale.baseText('chat.window.chat.placeholder');
}
return locale.baseText('chat.window.chat.placeholderPristine');
});
/** Checks if message is a text message */
function isTextMessage(message: ChatMessage): message is ChatMessageText {
return message.type === 'text' || !message.type;
}
/** Reposts the message */
function repostMessage(message: ChatMessageText) {
void sendMessage(message.text);
}
/** Sets the message in input for reuse */
function reuseMessage(message: ChatMessageText) {
chatEventBus.emit('setInputValue', message.text);
}
function sendMessage(message: string) {
previousMessageIndex.value = -1;
currentInputBuffer.value = '';
emit('sendMessage', message);
}
function onRefreshSession() {
emit('refreshSession');
}
function onArrowKeyDown({ currentInputValue, key }: ArrowKeyDownPayload) {
const pastMessages = props.pastChatMessages;
// Exit if no messages
if (pastMessages.length === 0) return;
// Reset navigation if input is empty (message was just sent)
if (currentInputValue.length === 0 && previousMessageIndex.value !== -1) {
previousMessageIndex.value = -1;
currentInputBuffer.value = '';
}
// Save current input if we're starting navigation
if (previousMessageIndex.value === -1 && currentInputValue.length > 0) {
currentInputBuffer.value = currentInputValue;
}
if (key === 'ArrowUp') {
// Temporarily blur to avoid cursor position issues
chatEventBus.emit('blurInput');
if (previousMessageIndex.value === -1) {
// Start with most recent message (last in array)
previousMessageIndex.value = pastMessages.length - 1;
} else if (previousMessageIndex.value > 0) {
// Move backwards through history (older messages)
previousMessageIndex.value--;
}
// Get message at current index
const selectedMessage = pastMessages[previousMessageIndex.value];
chatEventBus.emit('setInputValue', selectedMessage);
// Refocus and move cursor to end
chatEventBus.emit('focusInput');
} else if (key === 'ArrowDown') {
// Only navigate if we're in history mode
if (previousMessageIndex.value === -1) return;
// Temporarily blur to avoid cursor position issues
chatEventBus.emit('blurInput');
if (previousMessageIndex.value < pastMessages.length - 1) {
// Move forward through history (newer messages)
previousMessageIndex.value++;
const selectedMessage = pastMessages[previousMessageIndex.value];
chatEventBus.emit('setInputValue', selectedMessage);
} else {
// Reached the end - restore original input or clear
previousMessageIndex.value = -1;
chatEventBus.emit('setInputValue', currentInputBuffer.value);
currentInputBuffer.value = '';
}
// Refocus and move cursor to end
chatEventBus.emit('focusInput');
}
}
function onEscapeKey() {
// Only handle escape if we're in history navigation mode
if (previousMessageIndex.value === -1) return;
// Exit history mode and restore original input
previousMessageIndex.value = -1;
chatEventBus.emit('setInputValue', currentInputBuffer.value);
currentInputBuffer.value = '';
}
async function copySessionId() {
await clipboard.copy(props.sessionId);
toast.showMessage({
@ -161,6 +72,129 @@ async function copySessionId() {
type: 'success',
});
}
function initializeChat() {
if (!isWorkflowReadyForChat.value) {
return;
}
if (!chatContainer.value) {
return;
}
// Don't initialize if already initialized
if (chatApp) {
return;
}
// Create Vue app instance with chat SDK
chatApp = createApp(Chat);
chatApp.use(ChatPlugin, chatOptions.value);
chatApp.mount(chatContainer.value);
}
function destroyChat() {
if (chatApp && chatContainer.value) {
chatApp.unmount();
chatApp = null;
chatContainer.value.innerHTML = '';
}
}
// Watch for isOpen changes - but don't destroy/recreate the chat
// Just let CSS handle visibility to preserve chat state
watch(
() => props.isOpen,
async (newIsOpen) => {
if (newIsOpen && !chatApp) {
// Panel opened and chat not yet initialized - initialize it
destroyChat();
initializeChat();
}
// Note: We don't destroy when closing to preserve chat state
},
);
// Watch for ChatTrigger node changes
watch(
() => chatTriggerNode.value,
async (newChatTrigger, oldChatTrigger) => {
if (props.isOpen) {
if (newChatTrigger && !oldChatTrigger) {
// ChatTrigger was added - initialize chat
await nextTick();
initializeChat();
} else if (!newChatTrigger && oldChatTrigger) {
// ChatTrigger was removed - destroy chat and hide panel
destroyChat();
emit('hideChatPanel');
}
}
},
);
// Watch for chatContainer ref becoming available
watch(
() => chatContainer.value,
async (newContainer) => {
if (newContainer && props.isOpen && isWorkflowReadyForChat.value) {
initializeChat();
}
},
);
// Watch for workflow ID changes (important for webhook URL)
watch(
() => workflowsStore.workflowId,
async (newWorkflowId, oldWorkflowId) => {
if (props.isOpen && isWorkflowReadyForChat.value && newWorkflowId !== oldWorkflowId) {
// Workflow ID changed and workflow is ready - reinitialize chat with new webhook URL
destroyChat();
initializeChat();
}
},
);
// Watch for streaming configuration changes
watch(
() => isStreamingEnabled.value,
async (newStreaming, oldStreaming) => {
if (props.isOpen && isWorkflowReadyForChat.value && chatApp && newStreaming !== oldStreaming) {
// Reinitialize chat when streaming configuration changes and workflow is ready
destroyChat();
initializeChat();
}
},
);
// Watch for file upload configuration changes
watch(
() => [isFileUploadsAllowed.value, allowedFilesMimeTypes.value],
async (newConfig, oldConfig) => {
if (
props.isOpen &&
isWorkflowReadyForChat.value &&
chatApp &&
JSON.stringify(newConfig) !== JSON.stringify(oldConfig)
) {
// Reinitialize chat when file upload configuration changes and workflow is ready
destroyChat();
initializeChat();
}
},
);
onMounted(async () => {
if (props.isOpen) {
// Wait for next tick to ensure DOM is ready
await nextTick();
void initializeChat();
}
});
onUnmounted(() => {
destroyChat();
});
</script>
<template>
@ -177,7 +211,7 @@ async function copySessionId() {
@click="emit('clickHeader')"
>
<template #actions>
<N8nTooltip v-if="clipboard.isSupported && !isReadOnly">
<N8nTooltip v-if="!isReadOnly">
<template #content>
{{ sessionId }}
<br />
@ -193,7 +227,7 @@ async function copySessionId() {
>
</N8nTooltip>
<N8nTooltip
v-if="messages.length > 0 && !isReadOnly"
v-if="!isReadOnly"
:content="locale.baseText('chat.window.session.resetSession')"
>
<N8nIconButton
@ -205,104 +239,25 @@ async function copySessionId() {
icon-size="medium"
icon="undo-2"
:title="locale.baseText('chat.window.session.reset')"
@click.stop="onRefreshSession"
@click.stop="emit('refreshSession')"
/>
</N8nTooltip>
</template>
</LogsPanelHeader>
<main v-if="isOpen" :class="$style.chatBody" data-test-id="canvas-chat-body">
<MessagesList
:messages="messages"
:class="$style.messages"
:empty-text="locale.baseText('chat.window.chat.emptyChatMessage.v2')"
>
<template #beforeMessage="{ message }">
<MessageOptionTooltip
v-if="!isReadOnly && message.sender === 'bot' && !message.id.includes('preload')"
placement="right"
data-test-id="execution-id-tooltip"
>
{{ locale.baseText('chat.window.chat.chatMessageOptions.executionId') }}:
<a href="#" @click="emit('displayExecution', message.id)">{{ message.id }}</a>
</MessageOptionTooltip>
<MessageOptionAction
v-if="!isReadOnly && isTextMessage(message) && message.sender === 'user'"
data-test-id="repost-message-button"
icon="redo-2"
:label="locale.baseText('chat.window.chat.chatMessageOptions.repostMessage')"
placement="left"
@click.once="repostMessage(message)"
/>
<MessageOptionAction
v-if="!isReadOnly && isTextMessage(message) && message.sender === 'user'"
data-test-id="reuse-message-button"
icon="files"
:label="locale.baseText('chat.window.chat.chatMessageOptions.reuseMessage')"
placement="left"
@click="reuseMessage(message)"
/>
</template>
</MessagesList>
<!-- Chat SDK Container -->
<main
v-show="isOpen && chatTriggerNode"
:class="$style.chatSdkContainer"
data-test-id="canvas-chat-body"
>
<div ref="chatContainer" :class="$style.chatContainer" />
</main>
<div v-if="isOpen" :class="$style.messagesInput">
<ChatInput
data-test-id="lm-chat-inputs"
:placeholder="inputPlaceholder"
@arrow-key-down="onArrowKeyDown"
@escape-key-down="onEscapeKey"
>
<template v-if="pastChatMessages.length > 0" #leftPanel>
<div :class="$style.messagesHistory">
<N8nButton
title="Navigate to previous message"
icon="chevron-up"
type="tertiary"
text
size="mini"
:disabled="previousMessageIndex === 0"
@click="onArrowKeyDown({ currentInputValue: '', key: 'ArrowUp' })"
/>
<N8nButton
title="Navigate to next message"
icon="chevron-down"
type="tertiary"
text
size="mini"
:disabled="previousMessageIndex === -1"
@click="onArrowKeyDown({ currentInputValue: '', key: 'ArrowDown' })"
/>
</div>
</template>
</ChatInput>
</div>
</div>
</template>
<style lang="scss" module>
.chat {
--chat--spacing: var(--spacing--xs);
--chat--message--padding: var(--spacing--2xs);
--chat--message--font-size: var(--font-size--2xs);
--chat--input--font-size: var(--font-size--sm);
--chat--input--placeholder--font-size: var(--font-size--xs);
--chat--message--bot--background: transparent;
--chat--message--user--background: var(--color--text--tint-2);
--chat--message--bot--color: var(--color--text--shade-1);
--chat--message--user--color: var(--color--text--shade-1);
--chat--message--bot--border: none;
--chat--message--user--border: none;
--chat--message--user--border: none;
--chat--input--padding: var(--spacing--xs);
--chat--color-typing: var(--color--text--tint-1);
--chat--textarea--max-height: calc(var(--logs-panel--height) * 0.3);
--chat--message--pre--background: var(--color--foreground--tint-1);
--chat--textarea--height: calc(
var(--chat--input--padding) * 2 + var(--chat--input--font-size) *
var(--chat--input--line-height)
);
height: 100%;
display: flex;
flex-direction: column;
@ -310,42 +265,6 @@ async function copySessionId() {
background-color: var(--color--background--light-2);
}
.chatHeader {
font-size: var(--font-size--sm);
font-weight: var(--font-weight--regular);
line-height: 18px;
text-align: left;
border-bottom: 1px solid var(--color--foreground);
padding: var(--chat--spacing);
background-color: var(--color--foreground--tint-2);
display: flex;
justify-content: space-between;
align-items: center;
}
.chatTitle {
font-weight: var(--font-weight--medium);
}
.session {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
color: var(--color--text);
max-width: 70%;
}
.sessionId {
display: inline-block;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
&.copyable {
cursor: pointer;
}
}
.headerButton {
max-height: 1.1rem;
border: none;
@ -356,69 +275,142 @@ async function copySessionId() {
color: var(--color--text--tint-1);
}
.chatBody {
.chatSdkContainer {
display: flex;
height: 100%;
overflow: auto;
overflow: hidden;
flex-direction: column;
align-items: center;
justify-content: center;
}
.messages {
border-radius: var(--radius);
.chatContainer {
height: 100%;
width: 100%;
overflow: auto;
padding-top: var(--spacing--lg);
border-radius: 0;
&:not(:last-child) {
margin-right: 1em;
}
}
:global(.chat-layout) {
/* Font and Basic Styling */
--chat--font-family: var(--font-family);
--chat--border-radius: var(--radius);
--chat--spacing: var(--spacing--md);
--chat--transition-duration: 0.15s;
.messagesInput {
--input--border-color: var(--border-color);
--chat--input--border: none;
/* Colors - Primary and Secondary */
--chat--color--primary: var(--color--primary);
--chat--color--secondary: var(--color--secondary);
--chat--color-light-shade-100: var(--color--foreground);
--chat--color-disabled: var(--color--text--tint-1);
--chat--input--border-radius: 0.5rem;
--chat--input--send--button--background: transparent;
--chat--input--send--button--color: var(--color--primary);
--chat--input--file--button--background: transparent;
--chat--input--file--button--color: var(--color--primary);
--chat--input--border-active: var(--input--border-color--focus, var(--color--secondary));
--chat--files-spacing: var(--spacing--2xs);
--chat--input--background: transparent;
--chat--input--file--button--color: var(--button--color--text--secondary);
--chat--input--file--button--color-hover: var(--color--primary);
/* Body and Footer */
--chat--body--background: var(--color--background--light-2);
--chat--footer--background: var(--color--background--light-2);
--chat--footer--color: var(--color--text);
padding: var(--spacing--5xs);
margin: 0 var(--chat--spacing) var(--chat--spacing);
flex-grow: 1;
display: flex;
background: var(--lm-chat--bot--color--background);
border-radius: var(--chat--input--border-radius);
transition: border-color 200ms ease-in-out;
border: var(--input--border-color, var(--border-color))
var(--input--border-style, var(--border-style)) var(--input--border-width, var(--border-width));
/* Messages List */
--chat--messages-list--padding: var(--spacing--md);
[data-theme='dark'] & {
--chat--input--text-color: var(--input--color--text, var(--color--text--shade-1));
}
@media (prefers-color-scheme: dark) {
--chat--input--text-color: var(--input--color--text, var(--color--text--shade-1));
/* Message Styling */
--chat--message--font-size: var(--font-size--sm);
--chat--message--padding: var(--spacing--sm) var(--spacing--md);
--chat--message--border-radius: var(--radius);
--chat--message-line-height: var(--line-height--md);
--chat--message--margin-bottom: var(--spacing--xs);
/* Bot Messages */
--chat--message--bot--background: none;
--chat--message--bot--color: var(--color--text--shade-1);
--chat--message--bot--border: none;
/* User Messages */
--chat--message--user--background: var(--color--text--tint-2);
--chat--message--user--color: var(--color--text--shade-1);
--chat--message--user--border: none;
/* Code blocks in messages */
--chat--message--pre--background: var(--color--background--light-3);
/* Footer Container */
--chat--footer--padding: var(--spacing--md);
--chat--footer--border-top: none;
/* Input Container - unified rounded container */
--chat--input--width: 95%;
--chat--input--background: transparent;
--chat--input--container--background: var(--color--background--light-3);
--chat--input--container--border: 1px solid var(--color--foreground--tint-1);
--chat--input--container--border-radius: 24px;
--chat--input--container--padding: 12px;
/* Input Textarea */
--chat--input--font-size: var(--font-size--sm);
--chat--input--padding: 12px 16px;
--chat--input--border-radius: 20px;
--chat--input--border: none;
--chat--input--border-active: none;
--chat--input--background: transparent;
--chat--input--text-color: var(--color--text--shade-1);
--chat--input--line-height: var(--line-height--md);
--chat--input--placeholder--font-size: var(--font-size--sm);
--chat--textarea--height: 44px;
--chat--textarea--max-height: 200px;
/* Send Button - integrated into container */
--chat--input--send--button--color: var(--color--primary);
--chat--input--send--button--color-hover: var(--color--primary--shade-1);
--chat--input--send--button--background: transparent;
--chat--input--send--button--background-hover: var(--color--primary--tint-2);
--chat--input--send--button--border-radius: 20px;
--chat--input--send--button--size: 36px;
--chat--input--send--button--margin: 4px;
/* File Button */
--chat--input--file--button--color: var(--color--text--tint-1);
--chat--input--file--button--color-hover: var(--color--text);
--chat--input--file--button--background: transparent;
--chat--input--file--button--background-hover: transparent;
/* Message Action Buttons */
--chat--message-actions--gap: var(--spacing--sm);
--chat--message-actions--icon-size: 32px;
}
&:focus-within {
--input--border-color: #4538a3;
/* Hide the default chat header since we use our own */
:global(.chat-header) {
display: none;
}
}
.messagesHistory {
height: var(--chat--textarea--height);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
/* Fix typing indicator width */
:global(.chat-message-typing.chat-message) {
max-width: 100px;
}
/* Dark Mode Overrides */
body[data-theme='dark'] & {
:global(.chat-layout) {
/* Body and Footer - darker background like the old design */
--chat--body--background: var(--color--background--light-2);
--chat--footer--background: var(--color--background--light-2);
--chat--footer--color: var(--color--text);
--chat--footer--border-top: none;
/* Bot Messages - darker background with subtle border */
--chat--message--bot--background: transparent;
--chat--message--bot--color: var(--color--text);
--chat--message--bot--border: 0;
/* User Messages - darker user message background */
--chat--message--user--background: var(--color--foreground);
--chat--message--user--color: white;
/* Code blocks */
--chat--message--pre--background: var(--color--background);
/* Input Area - match the old design's input styling */
--chat--input--background: transparent;
--chat--input--text-color: var(--color--text);
--chat--input--border: 1px solid var(--color--foreground);
--chat--input--border-active: 1px solid var(--color--primary);
--chat--color--primary-shade-50: var(--color--primary--shade-50);
}
}
}
</style>

View File

@ -13,7 +13,6 @@ import {
aiChatWorkflow,
aiManualExecutionResponse,
aiManualWorkflow,
chatTriggerNode,
nodeTypes,
} from '../__test__/data';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
@ -29,7 +28,6 @@ import { ChatOptionsSymbol, ChatSymbol } from '@n8n/chat/constants';
import { userEvent } from '@testing-library/user-event';
import type { ChatMessage } from '@n8n/chat/types';
import * as useChatMessaging from '@/features/execution/logs/composables/useChatMessaging';
import { chatEventBus } from '@n8n/chat/event-buses';
import { useToast } from '@/app/composables/useToast';
import { useWorkflowState, type WorkflowState } from '@/app/composables/useWorkflowState';
@ -47,7 +45,20 @@ vi.mock('@/app/composables/useToast', () => {
};
});
vi.mock('@/app/stores/pushConnection.store', () => ({
const mockCopy = vi.fn();
vi.mock('@vueuse/core', async () => {
const actual = await vi.importActual('@vueuse/core');
return {
...actual,
useClipboard: () => {
return {
copy: mockCopy,
};
},
};
});
vi.mock('@/stores/pushConnection.store', () => ({
usePushConnectionStore: vi.fn().mockReturnValue({
isConnected: true,
}),
@ -243,14 +254,12 @@ describe('LogsPanel', () => {
await fireEvent.click(
within(rendered.getByTestId('log-details')).getByLabelText('Collapse panel'),
);
expect(rendered.queryByTestId('chat-messages-empty')).not.toBeInTheDocument();
expect(rendered.queryByTestId('logs-overview-body')).not.toBeInTheDocument();
// Click again to open the panel
await fireEvent.click(
within(rendered.getByTestId('logs-overview')).getByLabelText('Open panel'),
);
expect(await rendered.findByTestId('chat-messages-empty')).toBeInTheDocument();
expect(await rendered.findByTestId('logs-overview-body')).toBeInTheDocument();
});
@ -589,7 +598,7 @@ describe('LogsPanel', () => {
it('should not render chat when panel is closed', async () => {
const { queryByTestId } = render();
logsStore.toggleOpen(false);
await waitFor(() => expect(queryByTestId('canvas-chat-body')).not.toBeInTheDocument());
await waitFor(() => expect(queryByTestId('canvas-chat-body')).not.toBeVisible());
});
it('should show correct input placeholder', async () => {
@ -598,90 +607,6 @@ describe('LogsPanel', () => {
});
});
describe('message handling', () => {
beforeEach(() => {
vi.spyOn(chatEventBus, 'emit');
workflowsStore.runWorkflow.mockResolvedValue({ executionId: 'test-execution-id' });
});
it('should send message and show response', async () => {
const { findByTestId, findByText, getByText } = render();
// Send message
const input = await findByTestId('chat-input');
await userEvent.type(input, 'Hello AI!');
await userEvent.keyboard('{Enter}');
// Verify message and response
expect(await findByText('Hello AI!')).toBeInTheDocument();
workflowState.setWorkflowExecutionData({
...aiChatExecutionResponse,
status: 'success',
});
await waitFor(() => expect(getByText('AI response message')).toBeInTheDocument());
// Verify workflow execution
expect(workflowsStore.runWorkflow).toHaveBeenCalledWith(
expect.objectContaining({
runData: undefined,
triggerToStartFrom: {
name: 'Chat',
data: {
data: {
main: [
[
{
json: {
action: 'sendMessage',
chatInput: 'Hello AI!',
sessionId: expect.any(String),
},
},
],
],
},
executionIndex: 0,
executionStatus: 'success',
executionTime: 0,
source: [null],
startTime: expect.any(Number),
},
},
}),
);
});
it('should show loading state during message processing', async () => {
const { findByTestId, queryByTestId } = render();
// Send message
const input = await findByTestId('chat-input');
await userEvent.type(input, 'Test message');
await userEvent.keyboard('{Enter}');
await waitFor(() => expect(queryByTestId('chat-message-typing')).toBeInTheDocument());
workflowState.setActiveExecutionId(undefined);
workflowState.setWorkflowExecutionData({ ...aiChatExecutionResponse, status: 'success' });
await waitFor(() => expect(queryByTestId('chat-message-typing')).not.toBeInTheDocument());
});
it('should handle workflow execution errors', async () => {
workflowsStore.runWorkflow.mockRejectedValueOnce(new Error());
const { findByTestId } = render();
const input = await findByTestId('chat-input');
await userEvent.type(input, 'Hello AI!');
await userEvent.keyboard('{Enter}');
const toast = useToast();
expect(toast.showError).toHaveBeenCalledWith(new Error(), 'Problem running workflow');
});
});
describe('session management', () => {
const mockMessages: ChatMessage[] = [
{
@ -707,13 +632,19 @@ describe('LogsPanel', () => {
});
it('should allow copying session ID', async () => {
const clipboardSpy = vi.fn();
document.execCommand = clipboardSpy;
const { getByTestId } = render();
await userEvent.click(getByTestId('chat-session-id'));
const sessionIdElement = getByTestId('chat-session-id');
await userEvent.click(sessionIdElement);
// Verify clipboard was called with the full session ID
expect(mockCopy).toHaveBeenCalledTimes(1);
const copiedSessionId = mockCopy.mock.calls[0][0];
expect(typeof copiedSessionId).toBe('string');
// Verify toast was shown
const toast = useToast();
expect(clipboardSpy).toHaveBeenCalledWith('copy');
expect(toast.showMessage).toHaveBeenCalledWith({
message: '',
title: 'Copied to clipboard',
@ -730,205 +661,5 @@ describe('LogsPanel', () => {
expect(getByTestId('chat-session-id').textContent).not.toEqual(originalSessionId);
});
});
describe('file handling', () => {
beforeEach(() => {
vi.spyOn(useChatMessaging, 'useChatMessaging').mockReturnValue({
sendMessage: vi.fn(),
previousMessageIndex: ref(0),
isLoading: computed(() => false),
setLoadingState: vi.fn(),
});
logsStore.state = LOGS_PANEL_STATE.ATTACHED;
workflowsStore.allowFileUploads = true;
});
it('should enable file uploads when allowed by chat trigger node', async () => {
workflowsStore.setNodes(aiChatWorkflow.nodes);
workflowState.setNodeParameters({
name: chatTriggerNode.name,
value: { options: { allowFileUploads: true } },
});
const { getByTestId, queryByTestId } = render();
expect(getByTestId('canvas-chat')).toBeInTheDocument();
expect(queryByTestId('chat-attach-file-button')).toBeInTheDocument();
// workflowsStore.setNodeParameters({
// name: chatTriggerNode.name,
// value: { options: { allowFileUploads: false } },
// });
// await waitFor(() =>
// expect(queryByTestId('chat-attach-file-button')).not.toBeInTheDocument(),
// );
});
});
describe('message history handling', () => {
it('should properly navigate through message history without wrap-around', async () => {
workflowsStore.resetChatMessages();
workflowsStore.appendChatMessage('Message 1');
workflowsStore.appendChatMessage('Message 2');
workflowsStore.appendChatMessage('Message 3');
const { findByTestId } = render();
const input = await findByTestId('chat-input');
chatEventBus.emit('focusInput');
// First up should show most recent message
await userEvent.keyboard('{ArrowUp}');
expect(input).toHaveValue('Message 3');
// Second up should show second most recent
await userEvent.keyboard('{ArrowUp}');
expect(input).toHaveValue('Message 2');
// Third up should show oldest message
await userEvent.keyboard('{ArrowUp}');
expect(input).toHaveValue('Message 1');
// Fourth up should stay at oldest message (no wrap-around)
await userEvent.keyboard('{ArrowUp}');
expect(input).toHaveValue('Message 1');
// Down arrow should move forward through history
await userEvent.keyboard('{ArrowDown}');
expect(input).toHaveValue('Message 2');
// Continue forward
await userEvent.keyboard('{ArrowDown}');
expect(input).toHaveValue('Message 3');
// Down at the end should clear input
await userEvent.keyboard('{ArrowDown}');
expect(input).toHaveValue('');
});
it('should reset message history navigation when message is sent', async () => {
workflowsStore.resetChatMessages();
workflowsStore.appendChatMessage('Message 1');
workflowsStore.appendChatMessage('Message 2');
const { findByTestId } = render();
const input = await findByTestId('chat-input');
chatEventBus.emit('focusInput');
// Navigate to oldest message
await userEvent.keyboard('{ArrowUp}'); // Most recent (Message 2)
await userEvent.keyboard('{ArrowUp}'); // Oldest (Message 1)
expect(input).toHaveValue('Message 1');
// Clear and type new message
await userEvent.clear(input);
await userEvent.type(input, 'New message');
await userEvent.keyboard('{Enter}');
// After sending, pressing up should show most recent message
await userEvent.keyboard('{ArrowUp}');
expect(input).toHaveValue('Message 2');
});
it('should exit history mode and restore input on escape key', async () => {
workflowsStore.resetChatMessages();
workflowsStore.appendChatMessage('Message 1');
workflowsStore.appendChatMessage('Message 2');
const { findByTestId } = render();
const input = await findByTestId('chat-input');
chatEventBus.emit('focusInput');
// Type some text first
await userEvent.type(input, 'Current input');
// Navigate to a history message
await userEvent.keyboard('{ArrowUp}');
expect(input).toHaveValue('Message 2');
// Press escape to restore original input
await userEvent.keyboard('{Escape}');
expect(input).toHaveValue('Current input');
});
});
describe('message reuse and repost', () => {
const sendMessageSpy = vi.fn();
beforeEach(() => {
const mockMessages: ChatMessage[] = [
{
id: '1',
text: 'Original message',
sender: 'user',
},
{
id: '2',
text: 'AI response',
sender: 'bot',
},
];
vi.spyOn(useChatMessaging, 'useChatMessaging').mockImplementation(
({ onNewMessage: addChatMessage }) => {
addChatMessage(mockMessages[0]);
addChatMessage(mockMessages[1]);
return {
sendMessage: sendMessageSpy,
previousMessageIndex: ref(0),
isLoading: computed(() => false),
setLoadingState: vi.fn(),
};
},
);
});
it('should repost user message with new execution', async () => {
const { findByTestId } = render();
const repostButton = await findByTestId('repost-message-button');
await userEvent.click(repostButton);
expect(sendMessageSpy).toHaveBeenCalledWith('Original message');
});
it('should show message options only for appropriate messages', async () => {
const { findByText, container } = render();
await findByText('Original message');
const userMessage = container.querySelector('.chat-message-from-user');
expect(
userMessage?.querySelector('[data-test-id="repost-message-button"]'),
).toBeInTheDocument();
expect(
userMessage?.querySelector('[data-test-id="reuse-message-button"]'),
).toBeInTheDocument();
await findByText('AI response');
const botMessage = container.querySelector('.chat-message-from-bot');
expect(
botMessage?.querySelector('[data-test-id="repost-message-button"]'),
).not.toBeInTheDocument();
expect(
botMessage?.querySelector('[data-test-id="reuse-message-button"]'),
).not.toBeInTheDocument();
});
});
describe('keyboard shortcuts', () => {
it('should handle Enter key with modifier to start new line', async () => {
const { findByTestId } = render();
const input = await findByTestId('chat-input');
await userEvent.type(input, 'Line 1');
await userEvent.keyboard('{Shift>}{Enter}{/Shift}');
await userEvent.type(input, 'Line 2');
expect(input).toHaveValue('Line 1\nLine 2');
});
});
});
});

View File

@ -50,14 +50,9 @@ const {
onOverviewPanelResizeEnd,
} = useLogsPanelLayout(workflowName, popOutContainer, popOutContent, container, logsContainer);
const {
currentSessionId,
messages,
previousChatMessages,
sendMessage,
refreshSession,
displayExecution,
} = useChatState(props.isReadOnly);
const { currentSessionId, messages, refreshSession, displayExecution } = useChatState(
props.isReadOnly,
);
const { entries, execution, hasChat, latestNodeNameById, resetExecutionData, loadSubExecution } =
useLogsExecutionData({ isEnabled: isOpen });
@ -144,6 +139,13 @@ function handleChangeOutputTableColumnCollapsing(columnName: string | null) {
outputTableColumnCollapsing.value =
columnName && selected.value ? { nodeName: selected.value.node.name, columnName } : undefined;
}
function onHideChatPanel() {
// Note: We don't reset execution data here because this event is only emitted
// when the ChatTrigger node is removed from the workflow, not when the panel is closed.
// The execution data will be properly cleaned up when switching workflows via
// the watcher in useLogsExecutionData composable (watching workflowId changes).
}
</script>
<template>
@ -181,17 +183,15 @@ function handleChangeOutputTableColumnCollapsing(columnName: string | null) {
data-test-id="canvas-chat"
:is-open="isOpen"
:is-read-only="isReadOnly"
:messages="messages"
:session-id="currentSessionId"
:past-chat-messages="previousChatMessages"
:show-close-button="false"
:is-new-logs-enabled="true"
:is-header-clickable="!isPoppedOut"
@close="onToggleOpen"
@refresh-session="refreshSession"
@display-execution="displayExecution"
@send-message="sendMessage"
@click-header="onToggleOpen"
@hide-chat-panel="onHideChatPanel"
/>
</N8nResizeWrapper>
<div ref="logsContainer" :class="$style.logsContainer">

View File

@ -10,12 +10,12 @@ import { ChatOptionsSymbol } from '@n8n/chat/constants';
import { chatEventBus } from '@n8n/chat/event-buses';
import type { Chat, ChatMessage, ChatOptions } from '@n8n/chat/types';
import { v4 as uuid } from 'uuid';
import type { InjectionKey, Ref } from 'vue';
import type { ComputedRef, InjectionKey, Ref } from 'vue';
import { computed, provide, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useLogsStore } from '@/app/stores/logs.store';
import { restoreChatHistory } from '@/features/execution/logs/logs.utils';
import type { INodeParameters } from 'n8n-workflow';
import type { INode, INodeParameters } from 'n8n-workflow';
import { isChatNode } from '@/app/utils/aiUtils';
import { constructChatWebsocketUrl } from '@n8n/chat/utils';
import { injectWorkflowState } from '@/app/composables/useWorkflowState';
@ -27,15 +27,25 @@ type IntegratedChat = Omit<Chat, 'sendMessage'> & {
const ChatSymbol = 'Chat' as unknown as InjectionKey<IntegratedChat>;
interface ChatState {
currentSessionId: Ref<string>;
messages: Ref<ChatMessage[]>;
previousChatMessages: Ref<string[]>;
currentSessionId: ComputedRef<string>;
messages: ComputedRef<ChatMessage[]>;
previousChatMessages: ComputedRef<string[]>;
sendMessage: (message: string, files?: File[]) => Promise<void>;
refreshSession: () => void;
displayExecution: (executionId: string) => void;
chatTriggerNode: ComputedRef<INode | null>;
isStreamingEnabled: ComputedRef<boolean>;
isFileUploadsAllowed: ComputedRef<boolean>;
allowedFilesMimeTypes: ComputedRef<string>;
isWorkflowReadyForChat: ComputedRef<boolean>;
webhookUrl: ComputedRef<string>;
chatOptions: ComputedRef<ChatOptions>;
registerChatWebhook: () => Promise<void>;
webhookRegistered: Ref<boolean>;
isRegistering: Ref<boolean>;
}
export function useChatState(isReadOnly: boolean): ChatState {
export function useChatState(isReadOnly: boolean, sessionId?: string): ChatState {
const locale = useI18n();
const workflowsStore = useWorkflowsStore();
const workflowState = injectWorkflowState();
@ -46,9 +56,14 @@ export function useChatState(isReadOnly: boolean): ChatState {
const { runWorkflow } = useRunWorkflow({ router });
const ws = ref<WebSocket | null>(null);
const webhookRegistered = ref(false);
const isRegistering = ref(false);
const messages = computed(() => logsStore.chatSessionMessages);
const currentSessionId = computed(() => logsStore.chatSessionId);
// Use provided sessionId or fall back to logsStore sessionId
const effectiveSessionId = computed(() => sessionId ?? currentSessionId.value);
const previousChatMessages = computed(() => workflowsStore.getPastChatMessages);
const chatTriggerNode = computed(() => workflowsStore.allNodes.find(isChatNode) ?? null);
const allowFileUploads = computed(
@ -68,6 +83,175 @@ export function useChatState(isReadOnly: boolean): ChatState {
'responseNodes',
);
// Check if streaming is enabled in ChatTrigger node
const isStreamingEnabled = computed(() => {
const options = chatTriggerNode.value?.parameters?.options;
if (options && typeof options === 'object' && 'responseMode' in options) {
const responseMode = options.responseMode;
return responseMode === 'streaming';
}
return false;
});
// Check if file uploads are allowed in ChatTrigger node
const isFileUploadsAllowed = computed(() => {
const options = chatTriggerNode.value?.parameters?.options;
if (options && typeof options === 'object' && 'allowFileUploads' in options) {
return !!options.allowFileUploads;
}
return false;
});
// Get allowed file MIME types from ChatTrigger node
const allowedFilesMimeTypesComputed = computed(() => {
const options = chatTriggerNode.value?.parameters?.options;
if (options && typeof options === 'object' && 'allowedFilesMimeTypes' in options) {
const result = options.allowedFilesMimeTypes;
if (typeof result === 'string') {
return result;
}
}
return 'image/*';
});
// Check if workflow is ready for chat execution
const isWorkflowReadyForChat = computed(() => {
// Must have a ChatTrigger node
if (!chatTriggerNode.value) {
return false;
}
// Must have a valid workflow ID (for new workflows, this might not be set until saved)
if (!workflowsStore.workflowId && !workflowsStore.isNewWorkflow) {
return false;
}
return true;
});
const webhookUrl = computed(() => {
if (!chatTriggerNode.value) {
return '';
}
const workflowId = workflowsStore.workflowId;
if (!workflowId) {
return '';
}
const url = `${rootStore.webhookTestUrl}/${workflowId}/${effectiveSessionId.value}`;
return url;
});
// Register ChatTrigger webhook for test execution
async function registerChatWebhook(): Promise<void> {
if (isRegistering.value || !chatTriggerNode.value) {
return;
}
isRegistering.value = true;
try {
// Use the useRunWorkflow composable to properly register the webhook
await runWorkflow({
triggerNode: chatTriggerNode.value.name,
source: 'RunData.ManualChatTrigger',
sessionId: effectiveSessionId.value,
});
webhookRegistered.value = true;
} finally {
isRegistering.value = false;
}
}
const chatOptionsComputed = computed<ChatOptions>(() => {
const options: ChatOptions = {
webhookUrl: webhookUrl.value,
webhookConfig: {
method: 'POST' as const,
headers: {
'Content-Type': 'application/json',
},
},
mode: 'fullscreen' as const,
showWindowCloseButton: false,
showWelcomeScreen: false,
// Force the chat SDK to use our canvas session ID
sessionId: effectiveSessionId.value,
// Enable streaming based on ChatTrigger node configuration
enableStreaming: isStreamingEnabled.value,
// Enable message actions (repost and copy to input)
enableMessageActions: true,
// Enable file uploads based on ChatTrigger node configuration
allowFileUploads: isFileUploadsAllowed.value,
allowedFilesMimeTypes: allowedFilesMimeTypesComputed.value,
// Use the correct field names that ChatTrigger expects
chatInputKey: 'chatInput',
chatSessionKey: 'sessionId',
defaultLanguage: 'en' as const,
messageHistory: messages.value,
i18n: {
en: {
title: locale.baseText('chat.window.title') || 'Chat',
repostButton:
locale.baseText('chat.window.chat.chatMessageOptions.repostMessage') ||
'Repost message',
reuseButton:
locale.baseText('chat.window.chat.chatMessageOptions.reuseMessage') || 'Reuse message',
subtitle: '',
footer: '',
getStarted: '',
inputPlaceholder:
locale.baseText('chat.window.chat.placeholder') || 'Type your message...',
closeButtonTooltip: '',
},
},
beforeMessageSent: async (message: string) => {
// Register fresh webhook before each message to ensure it's active
// This gives us a fresh webhook with full timeout for each message
await registerChatWebhook();
// Store user message for persistence
if (!isReadOnly) {
logsStore.addChatMessage({
id: uuid(),
text: message,
sender: 'user',
});
}
},
afterMessageSent: async (_message: string, response) => {
// Store bot response for persistence
if (!isReadOnly && response) {
// For streaming, response is { hasReceivedChunks: boolean }
// For non-streaming, it's SendMessageResponse
if ('hasReceivedChunks' in response) {
return;
}
// Extract bot message from non-streaming response
const botMessage = response.output ?? response.text ?? response.message;
if (botMessage && typeof botMessage === 'string') {
logsStore.addChatMessage({
id: uuid(),
text: botMessage,
sender: 'bot',
});
}
}
},
};
return options;
});
const { sendMessage, isLoading, setLoadingState } = useChatMessaging({
chatTrigger: chatTriggerNode,
sessionId: currentSessionId.value,
@ -254,5 +438,15 @@ export function useChatState(isReadOnly: boolean): ChatState {
sendMessage,
refreshSession,
displayExecution,
chatTriggerNode,
isStreamingEnabled,
isFileUploadsAllowed,
allowedFilesMimeTypes: allowedFilesMimeTypesComputed,
isWorkflowReadyForChat,
webhookUrl,
chatOptions: chatOptionsComputed,
registerChatWebhook,
webhookRegistered,
isRegistering,
};
}