mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 01:56:46 +00:00
feat: Respond to chat and wait for response (#12546)
Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in> Co-authored-by: Shireen Missi <94372015+ShireenMissi@users.noreply.github.com>
This commit is contained in:
@@ -4,7 +4,7 @@ import { onMounted } from 'vue';
|
||||
import { createChat } from '@n8n/chat/index';
|
||||
import type { ChatOptions } from '@n8n/chat/types';
|
||||
|
||||
const webhookUrl = 'http://localhost:5678/webhook/f406671e-c954-4691-b39a-66c90aa2f103/chat';
|
||||
const webhookUrl = 'http://localhost:5678/webhook/ad712f8b-3546-4d08-b049-e0d035334a4c/chat';
|
||||
|
||||
const meta = {
|
||||
title: 'Chat',
|
||||
|
||||
157
packages/frontend/@n8n/chat/src/__tests__/Input.spec.ts
Normal file
157
packages/frontend/@n8n/chat/src/__tests__/Input.spec.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import type { VueWrapper } from '@vue/test-utils';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import Input from '../components/Input.vue';
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useFileDialog: vi.fn(() => ({
|
||||
open: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
onChange: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('uuid', () => ({
|
||||
v4: vi.fn(() => 'mock-uuid-123'),
|
||||
}));
|
||||
|
||||
vi.mock('virtual:icons/mdi/paperclip', () => ({
|
||||
default: { name: 'IconPaperclip' },
|
||||
}));
|
||||
|
||||
vi.mock('virtual:icons/mdi/send', () => ({
|
||||
default: { name: 'IconSend' },
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/chat/composables', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
useChat: () => ({
|
||||
waitingForResponse: { value: false },
|
||||
currentSessionId: { value: 'session-123' },
|
||||
messages: { value: [] },
|
||||
sendMessage: vi.fn(),
|
||||
ws: null,
|
||||
}),
|
||||
useOptions: () => ({
|
||||
options: {
|
||||
disabled: { value: false },
|
||||
allowFileUploads: { value: true },
|
||||
allowedFilesMimeTypes: { value: 'image/*,text/*' },
|
||||
webhookUrl: 'https://example.com/webhook',
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/chat/event-buses', () => ({
|
||||
chatEventBus: {
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./ChatFile.vue', () => ({
|
||||
default: { name: 'ChatFile' },
|
||||
}));
|
||||
|
||||
describe('ChatInput', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let wrapper: VueWrapper<any>;
|
||||
|
||||
beforeEach(() => {
|
||||
// @ts-expect-error - mock WebSocket
|
||||
global.WebSocket = vi.fn().mockImplementation(
|
||||
() =>
|
||||
({
|
||||
send: vi.fn(),
|
||||
close: vi.fn(),
|
||||
onmessage: null,
|
||||
onclose: null,
|
||||
}) as unknown as WebSocket,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (wrapper) {
|
||||
wrapper.unmount();
|
||||
}
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders the component with default props', () => {
|
||||
wrapper = mount(Input);
|
||||
|
||||
expect(wrapper.find('textarea').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-test-id="chat-input"]').exists()).toBe(true);
|
||||
expect(wrapper.find('.chat-input-send-button').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('applies custom placeholder', () => {
|
||||
wrapper = mount(Input, {
|
||||
props: {
|
||||
placeholder: 'customPlaceholder',
|
||||
},
|
||||
});
|
||||
|
||||
const textarea = wrapper.find('textarea');
|
||||
expect(textarea.attributes('placeholder')).toBe('customPlaceholder');
|
||||
});
|
||||
|
||||
it('updates input value when typing', async () => {
|
||||
const textarea = wrapper.find('textarea');
|
||||
|
||||
await textarea.setValue('Hello world');
|
||||
|
||||
expect(wrapper.vm.input).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('does not submit on Shift+Enter', async () => {
|
||||
const textarea = wrapper.find('textarea');
|
||||
const onSubmitSpy = vi.spyOn(wrapper.vm, 'onSubmit');
|
||||
|
||||
await textarea.setValue('Test message');
|
||||
await textarea.trigger('keydown.enter', { shiftKey: true });
|
||||
|
||||
expect(onSubmitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sets up WebSocket connection with execution ID', () => {
|
||||
const executionId = 'exec-123';
|
||||
|
||||
wrapper.vm.setupWebsocketConnection(executionId);
|
||||
|
||||
expect(global.WebSocket).toHaveBeenCalledWith(expect.stringContaining('sessionId=session-123'));
|
||||
expect(global.WebSocket).toHaveBeenCalledWith(expect.stringContaining('executionId=exec-123'));
|
||||
});
|
||||
|
||||
it('handles WebSocket messages correctly', async () => {
|
||||
const mockWs = {
|
||||
send: vi.fn(),
|
||||
onmessage: null,
|
||||
onclose: null,
|
||||
};
|
||||
wrapper.vm.chatStore.ws = mockWs;
|
||||
wrapper.vm.waitingForChatResponse = true;
|
||||
|
||||
await wrapper.vm.respondToChatNode(mockWs, 'Test message');
|
||||
|
||||
expect(mockWs.send).toHaveBeenCalledWith(expect.stringContaining('"chatInput":"Test message"'));
|
||||
});
|
||||
|
||||
it('handles empty file list gracefully', () => {
|
||||
wrapper.vm.files = null;
|
||||
|
||||
expect(() => wrapper.vm.attachFiles()).not.toThrow();
|
||||
expect(wrapper.vm.attachFiles()).toEqual([]);
|
||||
});
|
||||
|
||||
it('prevents submit when disabled', async () => {
|
||||
const submitButton = wrapper.find('.chat-input-send-button');
|
||||
|
||||
await submitButton.trigger('click');
|
||||
|
||||
expect(wrapper.vm.isSubmitting).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { vi, describe, it, expect } from 'vitest';
|
||||
import { createApp } from 'vue';
|
||||
|
||||
import * as api from '@n8n/chat/api';
|
||||
|
||||
import { ChatPlugin } from '../../plugins/chat';
|
||||
|
||||
vi.mock('@n8n/chat/api');
|
||||
|
||||
describe('ChatPlugin', () => {
|
||||
it('should return sendMessageResponse when executionStarted is true', async () => {
|
||||
const app = createApp({});
|
||||
const options = {
|
||||
webhookUrl: 'test',
|
||||
i18n: {
|
||||
en: {
|
||||
message: 'message',
|
||||
title: 'title',
|
||||
subtitle: 'subtitle',
|
||||
footer: 'footer',
|
||||
getStarted: 'getStarted',
|
||||
inputPlaceholder: 'inputPlaceholder',
|
||||
closeButtonTooltip: 'closeButtonTooltip',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
(api.sendMessage as jest.Mock).mockResolvedValue({ executionStarted: true });
|
||||
|
||||
app.use(ChatPlugin, options);
|
||||
|
||||
const chatStore = app.config.globalProperties.$chat;
|
||||
|
||||
const result = await chatStore.sendMessage('test message');
|
||||
|
||||
expect(result).toEqual({ executionStarted: true });
|
||||
});
|
||||
|
||||
it('should return null when sendMessageResponse is null', async () => {
|
||||
const app = createApp({});
|
||||
const options = {
|
||||
webhookUrl: 'test',
|
||||
i18n: {
|
||||
en: {
|
||||
message: 'message',
|
||||
title: 'title',
|
||||
subtitle: 'subtitle',
|
||||
footer: 'footer',
|
||||
getStarted: 'getStarted',
|
||||
inputPlaceholder: 'inputPlaceholder',
|
||||
closeButtonTooltip: 'closeButtonTooltip',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
(api.sendMessage as jest.Mock).mockResolvedValue({});
|
||||
|
||||
app.use(ChatPlugin, options);
|
||||
|
||||
const chatStore = app.config.globalProperties.$chat;
|
||||
|
||||
const result = await chatStore.sendMessage('test message');
|
||||
|
||||
expect(result).toEqual(null);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,16 @@
|
||||
import type { LoadPreviousSessionResponse, SendMessageResponse } from '@n8n/chat/types';
|
||||
|
||||
export function createFetchResponse<T>(data: T) {
|
||||
const jsonData = JSON.stringify(data);
|
||||
|
||||
return async () =>
|
||||
({
|
||||
json: async () => await new Promise<T>((resolve) => resolve(data)),
|
||||
}) as Response;
|
||||
text: async () => jsonData,
|
||||
clone() {
|
||||
return this;
|
||||
},
|
||||
}) as unknown as Response;
|
||||
}
|
||||
|
||||
export const createGetLatestMessagesResponse = (
|
||||
|
||||
@@ -24,7 +24,15 @@ export async function authenticatedFetch<T>(...args: Parameters<typeof fetch>):
|
||||
headers,
|
||||
});
|
||||
|
||||
return (await response.json()) as T;
|
||||
let responseData;
|
||||
|
||||
try {
|
||||
responseData = await response.clone().json();
|
||||
} catch (error) {
|
||||
responseData = await response.text();
|
||||
}
|
||||
|
||||
return responseData as T;
|
||||
}
|
||||
|
||||
export async function get<T>(url: string, query: object = {}, options: RequestInit = {}) {
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { useFileDialog } from '@vueuse/core';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import IconPaperclip from 'virtual:icons/mdi/paperclip';
|
||||
import IconSend from 'virtual:icons/mdi/send';
|
||||
import { computed, onMounted, onUnmounted, ref, unref } from 'vue';
|
||||
|
||||
import { useI18n, useChat, useOptions } from '@n8n/chat/composables';
|
||||
import { chatEventBus } from '@n8n/chat/event-buses';
|
||||
import { constructChatWebsocketUrl } from '@n8n/chat/utils';
|
||||
|
||||
import ChatFile from './ChatFile.vue';
|
||||
import type { ChatMessage } from '../types';
|
||||
|
||||
export interface ChatInputProps {
|
||||
placeholder?: string;
|
||||
@@ -36,8 +39,10 @@ const chatTextArea = ref<HTMLTextAreaElement | null>(null);
|
||||
const input = ref('');
|
||||
const isSubmitting = ref(false);
|
||||
const resizeObserver = ref<ResizeObserver | null>(null);
|
||||
const waitingForChatResponse = ref(false);
|
||||
|
||||
const isSubmitDisabled = computed(() => {
|
||||
if (waitingForChatResponse.value) return false;
|
||||
return input.value === '' || unref(waitingForResponse) || options.disabled?.value === true;
|
||||
});
|
||||
|
||||
@@ -127,6 +132,110 @@ function setInputValue(value: string) {
|
||||
focusChatInput();
|
||||
}
|
||||
|
||||
function attachFiles() {
|
||||
if (files.value) {
|
||||
const filesToAttach = Array.from(files.value);
|
||||
resetFileDialog();
|
||||
files.value = null;
|
||||
return filesToAttach;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function setupWebsocketConnection(executionId: string) {
|
||||
// if webhookUrl is not defined onSubmit is called from integrated chat
|
||||
// do not setup websocket as it would be handled by the integrated chat
|
||||
if (options.webhookUrl && chatStore.currentSessionId.value) {
|
||||
try {
|
||||
const wsUrl = constructChatWebsocketUrl(
|
||||
options.webhookUrl,
|
||||
executionId,
|
||||
chatStore.currentSessionId.value,
|
||||
true,
|
||||
);
|
||||
chatStore.ws = new WebSocket(wsUrl);
|
||||
chatStore.ws.onmessage = (e) => {
|
||||
if (e.data === 'n8n|heartbeat') {
|
||||
chatStore.ws?.send('n8n|heartbeat-ack');
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.data === 'n8n|continue') {
|
||||
waitingForChatResponse.value = false;
|
||||
chatStore.waitingForResponse.value = true;
|
||||
return;
|
||||
}
|
||||
const newMessage: ChatMessage = {
|
||||
id: uuidv4(),
|
||||
text: e.data,
|
||||
sender: 'bot',
|
||||
};
|
||||
|
||||
chatStore.messages.value.push(newMessage);
|
||||
waitingForChatResponse.value = true;
|
||||
chatStore.waitingForResponse.value = false;
|
||||
};
|
||||
|
||||
chatStore.ws.onclose = () => {
|
||||
chatStore.ws = null;
|
||||
waitingForChatResponse.value = false;
|
||||
chatStore.waitingForResponse.value = false;
|
||||
};
|
||||
} catch (error) {
|
||||
// do not throw error here as it should work with n8n versions that do not support websockets
|
||||
console.error('Error setting up websocket connection', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function processFiles(data: File[] | undefined) {
|
||||
if (!data || data.length === 0) return [];
|
||||
|
||||
const filePromises = data.map(async (file) => {
|
||||
// We do not need to await here as it will be awaited on the return by Promise.all
|
||||
// eslint-disable-next-line @typescript-eslint/return-await
|
||||
return new Promise<{ name: string; type: string; data: string }>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = () =>
|
||||
resolve({
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
data: reader.result as string,
|
||||
});
|
||||
|
||||
reader.onerror = () =>
|
||||
reject(new Error(`Error reading file: ${reader.error?.message ?? 'Unknown error'}`));
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
});
|
||||
|
||||
return await Promise.all(filePromises);
|
||||
}
|
||||
|
||||
async function respondToChatNode(ws: WebSocket, messageText: string) {
|
||||
const sentMessage: ChatMessage = {
|
||||
id: uuidv4(),
|
||||
text: messageText,
|
||||
sender: 'user',
|
||||
files: files.value ? attachFiles() : undefined,
|
||||
};
|
||||
|
||||
chatStore.messages.value.push(sentMessage);
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
sessionId: chatStore.currentSessionId.value,
|
||||
action: 'sendMessage',
|
||||
chatInput: messageText,
|
||||
files: await processFiles(sentMessage.files),
|
||||
}),
|
||||
);
|
||||
chatStore.waitingForResponse.value = true;
|
||||
waitingForChatResponse.value = false;
|
||||
}
|
||||
|
||||
async function onSubmit(event: MouseEvent | KeyboardEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -137,10 +246,19 @@ async function onSubmit(event: MouseEvent | KeyboardEvent) {
|
||||
const messageText = input.value;
|
||||
input.value = '';
|
||||
isSubmitting.value = true;
|
||||
await chatStore.sendMessage(messageText, Array.from(files.value ?? []));
|
||||
|
||||
if (chatStore.ws && waitingForChatResponse.value) {
|
||||
await respondToChatNode(chatStore.ws, messageText);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await chatStore.sendMessage(messageText, attachFiles());
|
||||
|
||||
if (response?.executionId) {
|
||||
setupWebsocketConnection(response.executionId);
|
||||
}
|
||||
|
||||
isSubmitting.value = false;
|
||||
resetFileDialog();
|
||||
files.value = null;
|
||||
}
|
||||
|
||||
async function onSubmitKeydown(event: KeyboardEvent) {
|
||||
@@ -225,7 +343,7 @@ function adjustTextAreaHeight() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="files?.length && !isSubmitting" class="chat-files">
|
||||
<div v-if="files?.length && (!isSubmitting || waitingForChatResponse)" class="chat-files">
|
||||
<ChatFile
|
||||
v-for="file in files"
|
||||
:key="file.name"
|
||||
|
||||
@@ -85,7 +85,15 @@ export const ChatPlugin: Plugin<ChatOptions> = {
|
||||
options,
|
||||
);
|
||||
|
||||
let textMessage = sendMessageResponse.output ?? sendMessageResponse.text ?? '';
|
||||
if (sendMessageResponse?.executionStarted) {
|
||||
return sendMessageResponse;
|
||||
}
|
||||
|
||||
let textMessage =
|
||||
sendMessageResponse.output ??
|
||||
sendMessageResponse.text ??
|
||||
sendMessageResponse.message ??
|
||||
'';
|
||||
|
||||
if (textMessage === '' && Object.keys(sendMessageResponse).length > 0) {
|
||||
try {
|
||||
@@ -107,13 +115,16 @@ export const ChatPlugin: Plugin<ChatOptions> = {
|
||||
receivedMessage.value.text = 'Error: Failed to receive response';
|
||||
}
|
||||
console.error('Chat API error:', error);
|
||||
} finally {
|
||||
waitingForResponse.value = false;
|
||||
}
|
||||
|
||||
waitingForResponse.value = false;
|
||||
|
||||
void nextTick(() => {
|
||||
chatEventBus.emit('scrollToBottom');
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function loadPreviousSession() {
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { Ref } from 'vue';
|
||||
|
||||
import type { ChatMessage } from '@n8n/chat/types/messages';
|
||||
|
||||
import type { SendMessageResponse } from './webhook';
|
||||
|
||||
export interface Chat {
|
||||
initialMessages: Ref<ChatMessage[]>;
|
||||
messages: Ref<ChatMessage[]>;
|
||||
@@ -9,5 +11,6 @@ export interface Chat {
|
||||
waitingForResponse: Ref<boolean>;
|
||||
loadPreviousSession?: () => Promise<string | undefined>;
|
||||
startNewSession?: () => Promise<void>;
|
||||
sendMessage: (text: string, files: File[]) => Promise<void>;
|
||||
sendMessage: (text: string, files: File[]) => Promise<SendMessageResponse | null>;
|
||||
ws?: WebSocket | null;
|
||||
}
|
||||
|
||||
@@ -15,4 +15,7 @@ export interface LoadPreviousSessionResponse {
|
||||
export interface SendMessageResponse {
|
||||
output?: string;
|
||||
text?: string;
|
||||
message?: string;
|
||||
executionId?: string;
|
||||
executionStarted?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './event-bus';
|
||||
export * from './mount';
|
||||
export * from './utils';
|
||||
|
||||
11
packages/frontend/@n8n/chat/src/utils/utils.ts
Normal file
11
packages/frontend/@n8n/chat/src/utils/utils.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export function constructChatWebsocketUrl(
|
||||
url: string,
|
||||
executionId: string,
|
||||
sessionId: string,
|
||||
isPublic: boolean,
|
||||
) {
|
||||
const baseUrl = new URL(url).origin;
|
||||
const wsProtocol = baseUrl.startsWith('https') ? 'wss' : 'ws';
|
||||
const wsUrl = baseUrl.replace(/^https?/, wsProtocol);
|
||||
return `${wsUrl}/chat?sessionId=${sessionId}&executionId=${executionId}${isPublic ? '&isPublic=true' : ''}`;
|
||||
}
|
||||
@@ -577,7 +577,6 @@ describe('RunData', () => {
|
||||
executionTime: 3,
|
||||
// @ts-expect-error allow missing properties in test
|
||||
source: [{ previousNode: 'Execute Workflow Trigger' }],
|
||||
// @ts-expect-error allow missing properties in test
|
||||
executionStatus: 'error',
|
||||
// @ts-expect-error allow missing properties in test
|
||||
error: {
|
||||
|
||||
@@ -1043,6 +1043,7 @@ describe('useRunWorkflow({ router })', () => {
|
||||
|
||||
workflowsStore.activeWorkflows = ['test-wf-id'];
|
||||
workflowsStore.setActiveExecutionId('test-exec-id');
|
||||
workflowsStore.executionWaitingForWebhook = false;
|
||||
|
||||
getExecutionSpy.mockResolvedValue(executionData);
|
||||
|
||||
|
||||
@@ -155,6 +155,7 @@ export const MANUAL_TRIGGER_NODE_TYPE = 'n8n-nodes-base.manualTrigger';
|
||||
export const MANUAL_CHAT_TRIGGER_NODE_TYPE = '@n8n/n8n-nodes-langchain.manualChatTrigger';
|
||||
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 AGENT_NODE_TYPE = '@n8n/n8n-nodes-langchain.agent';
|
||||
export const OPEN_AI_NODE_TYPE = '@n8n/n8n-nodes-langchain.openAi';
|
||||
export const OPEN_AI_NODE_MESSAGE_ASSISTANT_TYPE =
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { useChatMessaging } from '../composables/useChatMessaging';
|
||||
import { ref, computed } from 'vue';
|
||||
import type { Ref, ComputedRef } from 'vue';
|
||||
import type { IRunExecutionData } from 'n8n-workflow';
|
||||
import type { IExecutionPushResponse, INodeUi } from '@/Interface';
|
||||
import type { RunWorkflowChatPayload } from '../composables/useChatMessaging';
|
||||
import { vi } from 'vitest';
|
||||
import type { ChatMessage } from '@n8n/chat/types';
|
||||
|
||||
vi.mock('../logs.utils', () => {
|
||||
return {
|
||||
extractBotResponse: vi.fn(() => 'Last node response'),
|
||||
getInputKey: vi.fn(),
|
||||
processFiles: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('useChatMessaging', () => {
|
||||
let chatMessaging: ReturnType<typeof useChatMessaging>;
|
||||
let chatTrigger: Ref<INodeUi | null>;
|
||||
let messages: Ref<ChatMessage[]>;
|
||||
let sessionId: Ref<string>;
|
||||
let executionResultData: ComputedRef<IRunExecutionData['resultData'] | undefined>;
|
||||
let onRunChatWorkflow: (
|
||||
payload: RunWorkflowChatPayload,
|
||||
) => Promise<IExecutionPushResponse | undefined>;
|
||||
let ws: Ref<WebSocket | null>;
|
||||
let executionData: IRunExecutionData['resultData'] | undefined = undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
executionData = undefined;
|
||||
createTestingPinia();
|
||||
chatTrigger = ref(null);
|
||||
messages = ref([]);
|
||||
sessionId = ref('session-id');
|
||||
executionResultData = computed(() => executionData);
|
||||
onRunChatWorkflow = vi.fn().mockResolvedValue({
|
||||
executionId: 'execution-id',
|
||||
} as IExecutionPushResponse);
|
||||
ws = ref(null);
|
||||
|
||||
chatMessaging = useChatMessaging({
|
||||
chatTrigger,
|
||||
messages,
|
||||
sessionId,
|
||||
executionResultData,
|
||||
onRunChatWorkflow,
|
||||
ws,
|
||||
});
|
||||
});
|
||||
|
||||
it('should initialize correctly', () => {
|
||||
expect(chatMessaging).toBeDefined();
|
||||
expect(chatMessaging.previousMessageIndex.value).toBe(0);
|
||||
expect(chatMessaging.isLoading.value).toBe(false);
|
||||
});
|
||||
|
||||
it('should send a message and add it to messages', async () => {
|
||||
const messageText = 'Hello, world!';
|
||||
await chatMessaging.sendMessage(messageText);
|
||||
|
||||
expect(messages.value).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should send message via WebSocket if open', async () => {
|
||||
const messageText = 'Hello, WebSocket!';
|
||||
ws.value = {
|
||||
readyState: WebSocket.OPEN,
|
||||
send: vi.fn(),
|
||||
} as unknown as WebSocket;
|
||||
|
||||
await chatMessaging.sendMessage(messageText);
|
||||
|
||||
expect(ws.value.send).toHaveBeenCalledWith(
|
||||
JSON.stringify({
|
||||
sessionId: sessionId.value,
|
||||
action: 'sendMessage',
|
||||
chatInput: messageText,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should startWorkflowWithMessage and add message to messages with final message', async () => {
|
||||
const messageText = 'Hola!';
|
||||
chatTrigger.value = {
|
||||
id: 'trigger-id',
|
||||
name: 'Trigger',
|
||||
typeVersion: 1.1,
|
||||
parameters: { options: {} },
|
||||
} as unknown as INodeUi;
|
||||
|
||||
(onRunChatWorkflow as jest.Mock).mockResolvedValue({
|
||||
executionId: 'execution-id',
|
||||
} as IExecutionPushResponse);
|
||||
|
||||
executionData = {
|
||||
runData: {},
|
||||
} as unknown as IRunExecutionData['resultData'];
|
||||
|
||||
await chatMessaging.sendMessage(messageText);
|
||||
expect(messages.value).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should startWorkflowWithMessage and not add final message if responseMode is responseNode and version is 1.3', async () => {
|
||||
const messageText = 'Hola!';
|
||||
chatTrigger.value = {
|
||||
id: 'trigger-id',
|
||||
name: 'Trigger',
|
||||
typeVersion: 1.3,
|
||||
parameters: { options: { responseMode: 'responseNodes' } },
|
||||
} as unknown as INodeUi;
|
||||
|
||||
(onRunChatWorkflow as jest.Mock).mockResolvedValue({
|
||||
executionId: 'execution-id',
|
||||
} as IExecutionPushResponse);
|
||||
|
||||
executionData = {
|
||||
runData: {},
|
||||
} as unknown as IRunExecutionData['resultData'];
|
||||
|
||||
await chatMessaging.sendMessage(messageText);
|
||||
expect(messages.value).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -658,6 +658,7 @@ describe('LogsPanel', () => {
|
||||
sendMessage: vi.fn(),
|
||||
previousMessageIndex: ref(0),
|
||||
isLoading: computed(() => false),
|
||||
setLoadingState: vi.fn(),
|
||||
};
|
||||
});
|
||||
});
|
||||
@@ -693,6 +694,7 @@ describe('LogsPanel', () => {
|
||||
sendMessage: vi.fn(),
|
||||
previousMessageIndex: ref(0),
|
||||
isLoading: computed(() => false),
|
||||
setLoadingState: vi.fn(),
|
||||
});
|
||||
|
||||
logsStore.state = LOGS_PANEL_STATE.ATTACHED;
|
||||
@@ -800,6 +802,7 @@ describe('LogsPanel', () => {
|
||||
sendMessage: sendMessageSpy,
|
||||
previousMessageIndex: ref(0),
|
||||
isLoading: computed(() => false),
|
||||
setLoadingState: vi.fn(),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,7 +17,8 @@ import { usePinnedData } from '@/composables/usePinnedData';
|
||||
import { MODAL_CONFIRM } from '@/constants';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import type { IExecutionPushResponse, INodeUi } from '@/Interface';
|
||||
import { extractBotResponse, getInputKey } from '@/features/logs/logs.utils';
|
||||
|
||||
import { extractBotResponse, getInputKey, processFiles } from '@/features/logs/logs.utils';
|
||||
|
||||
export type RunWorkflowChatPayload = {
|
||||
triggerNode: string;
|
||||
@@ -33,6 +34,7 @@ export interface ChatMessagingDependencies {
|
||||
onRunChatWorkflow: (
|
||||
payload: RunWorkflowChatPayload,
|
||||
) => Promise<IExecutionPushResponse | undefined>;
|
||||
ws: Ref<WebSocket | null>;
|
||||
}
|
||||
|
||||
export function useChatMessaging({
|
||||
@@ -41,12 +43,17 @@ export function useChatMessaging({
|
||||
sessionId,
|
||||
executionResultData,
|
||||
onRunChatWorkflow,
|
||||
ws,
|
||||
}: ChatMessagingDependencies) {
|
||||
const locale = useI18n();
|
||||
const { showError } = useToast();
|
||||
const previousMessageIndex = ref(0);
|
||||
const isLoading = ref(false);
|
||||
|
||||
const setLoadingState = (loading: boolean) => {
|
||||
isLoading.value = loading;
|
||||
};
|
||||
|
||||
/** Converts a file to binary data */
|
||||
async function convertFileToBinaryData(file: File): Promise<IBinaryData> {
|
||||
const reader = new FileReader();
|
||||
@@ -140,10 +147,16 @@ export function useChatMessaging({
|
||||
message,
|
||||
});
|
||||
isLoading.value = false;
|
||||
ws.value = null;
|
||||
if (!response?.executionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Response Node mode should not return last node result if responseMode is "responseNodes"
|
||||
const responseMode = (triggerNode.parameters.options as { responseMode?: string })
|
||||
?.responseMode;
|
||||
if (responseMode === 'responseNodes') return;
|
||||
|
||||
const chatMessage = executionResultData.value
|
||||
? extractBotResponse(
|
||||
executionResultData.value,
|
||||
@@ -193,12 +206,25 @@ export function useChatMessaging({
|
||||
};
|
||||
messages.value.push(newMessage);
|
||||
|
||||
await startWorkflowWithMessage(newMessage.text, files);
|
||||
if (ws.value?.readyState === WebSocket.OPEN && !isLoading.value) {
|
||||
ws.value.send(
|
||||
JSON.stringify({
|
||||
sessionId: sessionId.value,
|
||||
action: 'sendMessage',
|
||||
chatInput: message,
|
||||
files: await processFiles(files),
|
||||
}),
|
||||
);
|
||||
isLoading.value = true;
|
||||
} else {
|
||||
await startWorkflowWithMessage(newMessage.text, files);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
previousMessageIndex,
|
||||
isLoading: computed(() => isLoading.value),
|
||||
setLoadingState,
|
||||
sendMessage,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,17 +5,25 @@ import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
||||
import { useRunWorkflow } from '@/composables/useRunWorkflow';
|
||||
import { VIEWS } from '@/constants';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { ChatOptionsSymbol, ChatSymbol } from '@n8n/chat/constants';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
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 { Ref } from 'vue';
|
||||
import type { InjectionKey, Ref } from 'vue';
|
||||
import { computed, provide, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useLogsStore } from '@/stores/logs.store';
|
||||
import { restoreChatHistory } from '@/features/logs/logs.utils';
|
||||
import type { INodeParameters } from 'n8n-workflow';
|
||||
import { isChatNode } from '@/utils/aiUtils';
|
||||
import { constructChatWebsocketUrl } from '@n8n/chat/utils';
|
||||
|
||||
type IntegratedChat = Omit<Chat, 'sendMessage'> & {
|
||||
sendMessage: (text: string, files: File[]) => Promise<void>;
|
||||
};
|
||||
|
||||
const ChatSymbol = 'Chat' as unknown as InjectionKey<IntegratedChat>;
|
||||
|
||||
interface ChatState {
|
||||
currentSessionId: Ref<string>;
|
||||
@@ -29,11 +37,13 @@ interface ChatState {
|
||||
export function useChatState(isReadOnly: boolean): ChatState {
|
||||
const locale = useI18n();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const rootStore = useRootStore();
|
||||
const logsStore = useLogsStore();
|
||||
const router = useRouter();
|
||||
const nodeHelpers = useNodeHelpers();
|
||||
const { runWorkflow } = useRunWorkflow({ router });
|
||||
|
||||
const ws = ref<WebSocket | null>(null);
|
||||
const messages = ref<ChatMessage[]>([]);
|
||||
const currentSessionId = ref<string>(uuid().replace(/-/g, ''));
|
||||
|
||||
@@ -52,25 +62,32 @@ export function useChatState(isReadOnly: boolean): ChatState {
|
||||
)?.allowedFilesMimeTypes?.toString() ?? '',
|
||||
);
|
||||
|
||||
const { sendMessage, isLoading } = useChatMessaging({
|
||||
const respondNodesResponseMode = computed(
|
||||
() =>
|
||||
(chatTriggerNode.value?.parameters?.options as { responseMode?: string })?.responseMode ===
|
||||
'responseNodes',
|
||||
);
|
||||
|
||||
const { sendMessage, isLoading, setLoadingState } = useChatMessaging({
|
||||
chatTrigger: chatTriggerNode,
|
||||
messages,
|
||||
sessionId: currentSessionId,
|
||||
executionResultData: computed(() => workflowsStore.getWorkflowExecution?.data?.resultData),
|
||||
onRunChatWorkflow,
|
||||
ws,
|
||||
});
|
||||
|
||||
// Extracted pure functions for better testability
|
||||
function createChatConfig(params: {
|
||||
messages: Chat['messages'];
|
||||
sendMessage: Chat['sendMessage'];
|
||||
sendMessage: IntegratedChat['sendMessage'];
|
||||
currentSessionId: Chat['currentSessionId'];
|
||||
isLoading: Ref<boolean>;
|
||||
isDisabled: Ref<boolean>;
|
||||
allowFileUploads: Ref<boolean>;
|
||||
locale: ReturnType<typeof useI18n>;
|
||||
}): { chatConfig: Chat; chatOptions: ChatOptions } {
|
||||
const chatConfig: Chat = {
|
||||
}): { chatConfig: IntegratedChat; chatOptions: ChatOptions } {
|
||||
const chatConfig: IntegratedChat = {
|
||||
messages: params.messages,
|
||||
sendMessage: params.sendMessage,
|
||||
initialMessages: ref([]),
|
||||
@@ -154,6 +171,43 @@ export function useChatState(isReadOnly: boolean): ChatState {
|
||||
const response = await runWorkflow(runWorkflowOptions);
|
||||
|
||||
if (response) {
|
||||
if (respondNodesResponseMode.value) {
|
||||
const wsUrl = constructChatWebsocketUrl(
|
||||
rootStore.urlBaseEditor,
|
||||
response.executionId as string,
|
||||
currentSessionId.value,
|
||||
false,
|
||||
);
|
||||
|
||||
ws.value = new WebSocket(wsUrl);
|
||||
ws.value.onmessage = (event) => {
|
||||
if (event.data === 'n8n|heartbeat') {
|
||||
ws.value?.send('n8n|heartbeat-ack');
|
||||
return;
|
||||
}
|
||||
if (event.data === 'n8n|continue') {
|
||||
setLoadingState(true);
|
||||
return;
|
||||
}
|
||||
setLoadingState(false);
|
||||
const newMessage: ChatMessage & { sessionId: string } = {
|
||||
text: event.data,
|
||||
sender: 'bot',
|
||||
sessionId: currentSessionId.value,
|
||||
id: uuid(),
|
||||
};
|
||||
messages.value.push(newMessage);
|
||||
|
||||
if (logsStore.isOpen) {
|
||||
chatEventBus.emit('focusInput');
|
||||
}
|
||||
};
|
||||
ws.value.onclose = () => {
|
||||
setLoadingState(false);
|
||||
ws.value = null;
|
||||
};
|
||||
}
|
||||
|
||||
await createExecutionPromise();
|
||||
workflowsStore.appendChatMessage(payload.message);
|
||||
return response;
|
||||
|
||||
@@ -14,13 +14,11 @@ import {
|
||||
getTreeNodeData,
|
||||
mergeStartData,
|
||||
restoreChatHistory,
|
||||
processFiles,
|
||||
extractBotResponse,
|
||||
} from './logs.utils';
|
||||
import {
|
||||
AGENT_LANGCHAIN_NODE_TYPE,
|
||||
NodeConnectionTypes,
|
||||
type ExecutionError,
|
||||
type ITaskStartedData,
|
||||
} from 'n8n-workflow';
|
||||
import { AGENT_LANGCHAIN_NODE_TYPE, NodeConnectionTypes } from 'n8n-workflow';
|
||||
import type { ExecutionError, ITaskStartedData, IRunExecutionData } from 'n8n-workflow';
|
||||
import {
|
||||
aiAgentNode,
|
||||
aiChatWorkflow,
|
||||
@@ -1170,6 +1168,115 @@ describe(createLogTree, () => {
|
||||
expect(logs[0].children).toHaveLength(1);
|
||||
expect(logs[0].children[0].node.name).toBe(aiModelNode.name);
|
||||
});
|
||||
|
||||
it('should process files correctly', async () => {
|
||||
const mockFile = new File(['test content'], 'test.txt', { type: 'text/plain' });
|
||||
const result = await processFiles([mockFile]);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
name: 'test.txt',
|
||||
type: 'text/plain',
|
||||
data: 'data:text/plain;base64,dGVzdCBjb250ZW50',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return an empty array if no files are provided', async () => {
|
||||
expect(await processFiles(undefined)).toEqual([]);
|
||||
expect(await processFiles([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractBotResponse', () => {
|
||||
it('should extract a successful bot response', () => {
|
||||
const resultData: IRunExecutionData['resultData'] = {
|
||||
lastNodeExecuted: 'nodeA',
|
||||
runData: {
|
||||
nodeA: [
|
||||
{
|
||||
executionTime: 1,
|
||||
startTime: 1,
|
||||
executionIndex: 1,
|
||||
source: [],
|
||||
data: {
|
||||
main: [[{ json: { message: 'Test output' } }]],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const executionId = 'test-exec-id';
|
||||
const result = extractBotResponse(resultData, executionId);
|
||||
expect(result).toEqual({
|
||||
text: 'Test output',
|
||||
sender: 'bot',
|
||||
id: executionId,
|
||||
});
|
||||
});
|
||||
|
||||
it('should extract an error bot response', () => {
|
||||
const resultData: IRunExecutionData['resultData'] = {
|
||||
lastNodeExecuted: 'nodeA',
|
||||
runData: {
|
||||
nodeA: [
|
||||
{
|
||||
executionTime: 1,
|
||||
startTime: 1,
|
||||
executionIndex: 1,
|
||||
source: [],
|
||||
error: {
|
||||
message: 'Test error',
|
||||
} as unknown as ExecutionError,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const executionId = 'test-exec-id';
|
||||
const result = extractBotResponse(resultData, executionId);
|
||||
expect(result).toEqual({
|
||||
text: '[ERROR: Test error]',
|
||||
sender: 'bot',
|
||||
id: 'test-exec-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined if no response data is available', () => {
|
||||
const resultData = {
|
||||
lastNodeExecuted: 'nodeA',
|
||||
runData: {
|
||||
nodeA: [
|
||||
{
|
||||
executionTime: 1,
|
||||
startTime: 1,
|
||||
executionIndex: 1,
|
||||
source: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const executionId = 'test-exec-id';
|
||||
const result = extractBotResponse(resultData, executionId);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined if lastNodeExecuted is not available', () => {
|
||||
const resultData = {
|
||||
runData: {
|
||||
nodeA: [
|
||||
{
|
||||
executionTime: 1,
|
||||
startTime: 1,
|
||||
executionIndex: 1,
|
||||
source: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const executionId = 'test-exec-id';
|
||||
const result = extractBotResponse(resultData, executionId);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe(deepToRaw, () => {
|
||||
|
||||
@@ -565,7 +565,7 @@ function extractResponseText(responseData?: IDataObject): string | undefined {
|
||||
}
|
||||
|
||||
// Paths where the response message might be located
|
||||
const paths = ['output', 'text', 'response.text'];
|
||||
const paths = ['output', 'text', 'response.text', 'message'];
|
||||
const matchedPath = paths.find((path) => get(responseData, path));
|
||||
|
||||
if (!matchedPath) return JSON.stringify(responseData, null, 2);
|
||||
@@ -599,6 +599,32 @@ export function restoreChatHistory(
|
||||
return [...(userMessage ? [userMessage] : []), ...(botMessage ? [botMessage] : [])];
|
||||
}
|
||||
|
||||
export async function processFiles(data: File[] | undefined) {
|
||||
if (!data || data.length === 0) return [];
|
||||
|
||||
const filePromises = data.map(async (file) => {
|
||||
// We do not need to await here as it will be awaited on the return by Promise.all
|
||||
// eslint-disable-next-line @typescript-eslint/return-await
|
||||
return new Promise<{ name: string; type: string; data: string }>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = () =>
|
||||
resolve({
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
data: reader.result as string,
|
||||
});
|
||||
|
||||
reader.onerror = () =>
|
||||
reject(new Error(`Error reading file: ${reader.error?.message ?? 'Unknown error'}`));
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
});
|
||||
|
||||
return await Promise.all(filePromises);
|
||||
}
|
||||
|
||||
export function isSubNodeLog(logEntry: LogEntry): boolean {
|
||||
return logEntry.parent !== undefined && logEntry.parent.executionId === logEntry.executionId;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user