mirror of
https://github.com/n8n-io/n8n.git
synced 2025-11-20 17:46:34 +00:00
Merge 333cc8334c into 27eeec0157
This commit is contained in:
commit
c6f6e49ab5
@ -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 {
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -40,6 +40,7 @@ export declare namespace WorkflowRequest {
|
||||
data?: ITaskData;
|
||||
};
|
||||
agentRequest?: AiAgentRequest;
|
||||
chatSessionId?: string;
|
||||
};
|
||||
|
||||
type Create = AuthenticatedRequest<{}, {}, CreateUpdatePayload>;
|
||||
|
||||
337
packages/frontend/@n8n/chat/src/__tests__/Chat.spec.ts
Normal file
337
packages/frontend/@n8n/chat/src/__tests__/Chat.spec.ts
Normal 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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
176
packages/frontend/@n8n/chat/src/__tests__/MessageActions.spec.ts
Normal file
176
packages/frontend/@n8n/chat/src/__tests__/MessageActions.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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]">
|
||||
|
||||
@ -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>
|
||||
@ -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: {},
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -190,6 +190,7 @@ export interface IStartRunData {
|
||||
name: string;
|
||||
data?: ITaskData;
|
||||
};
|
||||
chatSessionId?: string;
|
||||
agentRequest?: {
|
||||
query: NodeParameterValueType;
|
||||
tool: {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user