fix(editor): Previous message does not exist error when sending chat message (#23298)

This commit is contained in:
Suguru Inoue
2025-12-18 14:58:02 +01:00
committed by GitHub
parent 378a32f3d3
commit 04636cc365
11 changed files with 1101 additions and 168 deletions
+5
View File
@@ -494,3 +494,8 @@ export type ChatProviderSettingsDto = z.infer<typeof chatProviderSettingsSchema>
export class UpdateChatSettingsRequest extends Z.class({
payload: chatProviderSettingsSchema,
}) {}
export interface ChatHubModuleSettings {
enabled: boolean;
providers: Record<ChatHubLLMProvider, ChatProviderSettingsDto>;
}
+1
View File
@@ -18,6 +18,7 @@ export {
type ChatHubN8nModel,
type ChatHubCustomAgentModel,
type ChatHubConversationModel,
type ChatHubModuleSettings,
chatHubProviderSchema,
chatHubLLMProviderSchema,
type ChatHubProvider,
@@ -0,0 +1,629 @@
import { createComponentRenderer } from '@/__tests__/render';
import { emptyChatModelsResponse, type EnrichedStructuredChunk } from '@n8n/api-types';
import userEvent from '@testing-library/user-event';
import { createPinia, setActivePinia } from 'pinia';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { reactive } from 'vue';
import {
createChatHubModuleSettings,
createMockAgent,
createMockConversationResponse,
createMockMessageDto,
createMockModelsResponse,
createMockSession,
createMockStreamChunk,
} from './__test__/data';
import * as chatApi from './chat.api';
import ChatView from './ChatView.vue';
// Mock external stores and modules
vi.mock('@/features/settings/users/users.store', () => ({
useUsersStore: () => ({
currentUserId: 'user-123',
currentUser: {
id: 'user-123',
firstName: 'Test',
fullName: 'Test User',
},
}),
}));
vi.mock('@/app/stores/ui.store', () => ({
useUIStore: () => ({
openModal: vi.fn(),
modalsById: {},
}),
}));
vi.mock('@/features/credentials/credentials.store', () => ({
useCredentialsStore: () => ({
fetchCredentialTypes: vi.fn().mockResolvedValue(undefined),
fetchAllCredentials: vi.fn().mockResolvedValue(undefined),
fetchAllCredentialsForWorkflow: vi.fn().mockResolvedValue([]),
getCredentialById: vi.fn().mockReturnValue(undefined),
getCredentialsByType: vi.fn().mockReturnValue([]),
getCredentialTypeByName: vi.fn().mockReturnValue(undefined),
allCredentials: [],
allCredentialTypes: [],
}),
}));
vi.mock('./chat.api');
vi.mock('@/app/stores/settings.store', () => ({
useSettingsStore: () => ({
settings: {},
moduleSettings: {
'chat-hub': createChatHubModuleSettings(),
},
}),
}));
vi.mock('@/features/collaboration/projects/projects.store', () => ({
useProjectsStore: () => ({
currentProjectId: 'project-123',
personalProject: { id: 'project-123', type: 'personal' },
}),
}));
vi.mock('@/app/stores/nodeTypes.store', () => ({
useNodeTypesStore: () => ({
loadNodeTypesIfNotLoaded: vi.fn().mockResolvedValue(undefined),
nodeTypes: [],
}),
}));
const mockRoute = reactive<{ params: Record<string, unknown>; query: Record<string, unknown> }>({
params: {},
query: {},
});
const mockRouterPush = vi.fn((route) => {
// Simulate route navigation by updating mockRoute
if (typeof route === 'object' && route.params) {
Object.assign(mockRoute.params, route.params);
}
});
vi.mock('vue-router', async (importOriginal) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
const actual = await importOriginal<typeof import('vue-router')>();
return {
...actual,
useRoute: () => mockRoute,
useRouter: () => ({
push: mockRouterPush,
resolve: vi.fn(),
}),
};
});
const renderComponent = createComponentRenderer(ChatView);
describe('ChatView', () => {
let pinia: ReturnType<typeof createPinia>;
let sendMessageUpdated: (chunk: EnrichedStructuredChunk) => void;
let sendDone: () => void;
beforeEach(() => {
pinia = createPinia();
setActivePinia(pinia);
sendMessageUpdated = () => {};
sendDone = () => {};
mockRoute.params = {};
mockRoute.query = {};
mockRouterPush.mockClear();
localStorage.clear();
vi.mocked(chatApi.sendMessageApi).mockClear();
vi.mocked(chatApi.sendMessageApi).mockImplementation((_ctx, _, onMessageUpdated_, onDone_) => {
sendMessageUpdated = onMessageUpdated_;
sendDone = onDone_;
});
vi.mocked(chatApi.editMessageApi).mockClear();
vi.mocked(chatApi.editMessageApi).mockImplementation(
(_ctx, _request, onMessageUpdated_, onDone_) => {
sendMessageUpdated = onMessageUpdated_;
sendDone = onDone_;
},
);
vi.mocked(chatApi.regenerateMessageApi).mockClear();
vi.mocked(chatApi.regenerateMessageApi).mockImplementation(
(_ctx, _request, onMessageUpdated_, onDone_) => {
sendMessageUpdated = onMessageUpdated_;
sendDone = onDone_;
},
);
vi.mocked(chatApi.stopGenerationApi).mockClear();
vi.mocked(chatApi.fetchChatModelsApi).mockResolvedValue(
createMockModelsResponse({
'custom-agent': {
models: [
createMockAgent({
name: 'Test Custom Agent',
description: 'A test custom agent',
model: { provider: 'custom-agent', agentId: 'agent-123' },
}),
createMockAgent({
name: 'My Custom Agent',
model: { provider: 'custom-agent', agentId: 'agent-456' },
}),
createMockAgent({
name: 'Another Custom Agent',
model: { provider: 'custom-agent', agentId: 'agent-789' },
}),
],
},
n8n: {
models: [
createMockAgent({
name: 'My Workflow Agent',
model: { provider: 'n8n', workflowId: 'workflow-789' },
}),
createMockAgent({
name: 'Another Workflow Agent',
model: { provider: 'n8n', workflowId: 'workflow-999' },
}),
],
},
openai: {
models: [
createMockAgent({
name: 'GPT-4',
model: { provider: 'openai', model: 'gpt-4' },
}),
createMockAgent({
name: 'GPT-3.5',
model: { provider: 'openai', model: 'gpt-3.5-turbo' },
}),
],
},
anthropic: {
models: [
createMockAgent({
name: 'Claude 3',
model: { provider: 'anthropic', model: 'claude-3' },
}),
],
},
}),
);
vi.mocked(chatApi.fetchSingleConversationApi).mockResolvedValue(
createMockConversationResponse({
session: createMockSession({
id: 'session-id',
provider: null,
model: null,
}),
}),
);
vi.mocked(chatApi.fetchConversationsApi).mockResolvedValue({
data: [],
nextCursor: null,
hasMore: false,
});
vi.mocked(chatApi.updateConversationApi).mockClear();
});
describe('Rendering the new chat UI', () => {
it('displays greeting message', async () => {
const rendered = renderComponent({ pinia });
expect(await rendered.findByText('Hello, Test!')).toBeInTheDocument();
});
it('preselects agent from agentId query parameter', async () => {
mockRoute.query = { agentId: 'agent-456' };
const rendered = renderComponent({ pinia });
expect(await rendered.findByRole('button', { name: /My Custom Agent/i })).toBeInTheDocument();
});
it('preselects agent from workflowId query parameter', async () => {
mockRoute.query = { workflowId: 'workflow-789' };
const rendered = renderComponent({ pinia });
expect(
await rendered.findByRole('button', { name: /My Workflow Agent/i }),
).toBeInTheDocument();
});
it('preselects agent from localStorage', async () => {
localStorage.setItem(
'user-123_N8N_CHAT_HUB_SELECTED_MODEL',
JSON.stringify({ provider: 'openai', model: 'gpt-4' }),
);
const rendered = renderComponent({ pinia });
expect(await rendered.findByRole('button', { name: /gpt-4/ })).toBeInTheDocument();
});
it('preselects first available agent when no preference exists', async () => {
vi.mocked(chatApi.fetchChatModelsApi).mockResolvedValueOnce(
createMockModelsResponse({
openai: {
models: [
createMockAgent({
name: 'GPT-4',
model: { provider: 'openai', model: 'gpt-4' },
}),
],
},
anthropic: {
models: [
createMockAgent({
name: 'Claude 3',
model: { provider: 'anthropic', model: 'claude-3' },
}),
],
},
}),
);
const rendered = renderComponent({ pinia });
expect(await rendered.findByRole('button', { name: /GPT-4/ })).toBeInTheDocument();
});
it('should show callout if no agent is available', async () => {
vi.mocked(chatApi.fetchChatModelsApi).mockResolvedValue(emptyChatModelsResponse);
const rendered = renderComponent({ pinia });
expect(await rendered.findByText('select a model')).toBeInTheDocument();
expect(await rendered.findByRole('textbox')).toBeDisabled();
});
});
describe('Rendering existing sessions', () => {
beforeEach(() => {
mockRoute.params = { id: 'existing-session-123' };
vi.mocked(chatApi.fetchSingleConversationApi).mockResolvedValue(
createMockConversationResponse({
session: createMockSession({
id: 'existing-session-123',
title: 'Test Conversation',
lastMessageAt: new Date().toISOString(),
provider: 'custom-agent',
agentId: 'agent-123',
}),
conversation: {
messages: {
'msg-1': createMockMessageDto({
id: 'msg-1',
sessionId: 'existing-session-123',
content: 'What is the weather today?',
}),
'msg-2': createMockMessageDto({
id: 'msg-2',
sessionId: 'existing-session-123',
type: 'ai',
name: 'Assistant',
content: 'The weather is sunny today.',
provider: 'custom-agent',
agentId: 'agent-123',
previousMessageId: 'msg-1',
}),
},
},
}),
);
});
it('preselects agent set for the session', async () => {
const rendered = renderComponent({ pinia });
expect(
await rendered.findByRole('button', { name: /Test Custom Agent/i }),
).toBeInTheDocument();
});
it('displays conversation with messages loaded from API', async () => {
const rendered = renderComponent({ pinia });
await vi.waitFor(() => {
const messages = rendered.container.querySelectorAll('[data-message-id]');
expect(messages).toHaveLength(2);
expect(messages[0]).toHaveTextContent('What is the weather today?');
expect(messages[1]).toHaveTextContent('The weather is sunny today.');
});
});
it('displays error toast and redirects to chat view when fetching conversation fails', async () => {
vi.mocked(chatApi.fetchSingleConversationApi).mockRejectedValue(
new Error('Conversation not found'),
);
const rendered = renderComponent({ pinia });
expect(await rendered.findByText(/Failed to load conversation/i)).toBeInTheDocument();
await vi.waitFor(() => expect(mockRouterPush).toHaveBeenCalledWith({ name: 'chat' }));
});
it.todo(
'handles when the agent selected for the conversation is not available anymore',
async () => {
vi.mocked(chatApi.fetchChatModelsApi).mockResolvedValue(emptyChatModelsResponse);
const rendered = renderComponent({ pinia });
expect(await rendered.findByText(/reselect a model/i)).toBeInTheDocument();
expect(await rendered.findByRole('textbox')).toBeDisabled();
},
);
});
describe('Sending messages', () => {
it('sends message in new session, calls API, navigates to conversation view, and displays user message', async () => {
const user = userEvent.setup();
mockRoute.query = { agentId: 'agent-123' };
const rendered = renderComponent({ pinia });
const textarea = await rendered.findByRole('textbox');
await user.click(textarea);
await user.type(textarea, 'What is n8n?');
await user.click(rendered.getByRole('button', { name: /send/i }));
expect(chatApi.sendMessageApi).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
message: 'What is n8n?',
model: { provider: 'custom-agent', agentId: 'agent-123' },
sessionId: expect.any(String),
credentials: {},
}),
expect.any(Function),
expect.any(Function),
expect.any(Function),
);
const apiCallArgs = vi.mocked(chatApi.sendMessageApi).mock.calls[0];
const messageIdFromApi = apiCallArgs[1].messageId;
const sessionIdFromApi = apiCallArgs[1].sessionId;
sendMessageUpdated(
createMockStreamChunk({
type: 'begin',
content: '',
metadata: {
messageId: 'ai-message-123',
previousMessageId: messageIdFromApi,
},
}),
);
await vi.waitFor(() => expect(textarea).toHaveValue(''));
await rendered.findByText('What is n8n?');
sendMessageUpdated(
createMockStreamChunk({
type: 'item',
content: 'n8n is',
metadata: {
messageId: 'ai-message-123',
previousMessageId: messageIdFromApi,
},
}),
);
expect(await rendered.findByText(/n8n is/)).toBeInTheDocument();
sendMessageUpdated(
createMockStreamChunk({
type: 'item',
content: ' a workflow',
metadata: {
messageId: 'ai-message-123',
previousMessageId: messageIdFromApi,
},
}),
);
expect(await rendered.findByText(/n8n is a workflow/)).toBeInTheDocument();
sendMessageUpdated(
createMockStreamChunk({
type: 'item',
content: ' automation tool.',
metadata: {
messageId: 'ai-message-123',
previousMessageId: messageIdFromApi,
},
}),
);
sendMessageUpdated(
createMockStreamChunk({
type: 'end',
content: '',
metadata: {
messageId: 'ai-message-123',
previousMessageId: messageIdFromApi,
},
}),
);
sendDone();
expect(await rendered.findByText('n8n is a workflow automation tool.')).toBeInTheDocument();
expect(mockRouterPush).toHaveBeenCalledWith({
name: 'chat-conversation',
params: { id: sessionIdFromApi },
});
const messages = rendered.container.querySelectorAll('[data-message-id]');
expect(messages).toHaveLength(2);
expect(messages[0]).toHaveTextContent('What is n8n?');
expect(messages[1]).toHaveTextContent('n8n is a workflow automation tool.');
});
it('sends message in existing session and displays both user and AI messages', async () => {
const user = userEvent.setup();
mockRoute.params = { id: 'existing-session-123' };
vi.mocked(chatApi.fetchSingleConversationApi).mockResolvedValue(
createMockConversationResponse({
session: createMockSession({
id: 'existing-session-123',
title: 'Existing Conversation',
lastMessageAt: new Date().toISOString(),
provider: 'custom-agent',
agentId: 'agent-123',
}),
conversation: {
messages: {
'msg-1': createMockMessageDto({
id: 'msg-1',
sessionId: 'existing-session-123',
content: 'Previous question',
}),
'msg-2': createMockMessageDto({
id: 'msg-2',
sessionId: 'existing-session-123',
type: 'ai',
name: 'Assistant',
content: 'Previous answer',
provider: 'openai',
model: 'gpt-4',
previousMessageId: 'msg-1',
}),
},
},
}),
);
const rendered = renderComponent({ pinia });
const textarea = await rendered.findByRole('textbox');
await user.click(textarea);
await user.type(textarea, 'New question');
await user.click(rendered.getByRole('button', { name: /send/i }));
expect(chatApi.sendMessageApi).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
message: 'New question',
model: { provider: 'custom-agent', agentId: 'agent-123' },
sessionId: 'existing-session-123',
credentials: {},
previousMessageId: 'msg-2',
}),
expect.any(Function),
expect.any(Function),
expect.any(Function),
);
const apiCallArgs = vi.mocked(chatApi.sendMessageApi).mock.calls[0];
const messageIdFromApi = apiCallArgs[1].messageId;
sendMessageUpdated(
createMockStreamChunk({
type: 'begin',
content: '',
metadata: {
messageId: 'ai-message-456',
previousMessageId: messageIdFromApi,
},
}),
);
await vi.waitFor(() => expect(textarea).toHaveValue(''));
sendMessageUpdated(
createMockStreamChunk({
type: 'item',
content: 'AI response here',
metadata: {
messageId: 'ai-message-456',
previousMessageId: messageIdFromApi,
},
}),
);
sendMessageUpdated(
createMockStreamChunk({
type: 'end',
content: '',
metadata: {
messageId: 'ai-message-456',
previousMessageId: messageIdFromApi,
},
}),
);
sendDone();
expect(await rendered.findByText('AI response here')).toBeInTheDocument();
const messages = rendered.container.querySelectorAll('[data-message-id]');
expect(messages).toHaveLength(4);
expect(messages[0]).toHaveTextContent('Previous question');
expect(messages[1]).toHaveTextContent('Previous answer');
expect(messages[2]).toHaveTextContent('New question');
expect(messages[3]).toHaveTextContent('AI response here');
expect(mockRouterPush).not.toHaveBeenCalled();
});
it('stops streaming when user clicks stop button and calls stopGeneration API', async () => {
const user = userEvent.setup();
mockRoute.query = { agentId: 'agent-123' };
vi.mocked(chatApi.stopGenerationApi).mockResolvedValue(undefined);
const rendered = renderComponent({ pinia });
const textarea = (await rendered.findByRole('textbox')) as HTMLTextAreaElement;
await user.click(textarea);
await user.type(textarea, 'Hello, AI!{Enter}');
const sendApiCall = vi.mocked(chatApi.sendMessageApi).mock.calls[0];
const messageIdFromApi = sendApiCall[1].messageId;
const sessionId = sendApiCall[1].sessionId;
sendMessageUpdated(
createMockStreamChunk({
type: 'begin',
content: '',
metadata: {
messageId: 'ai-message-123',
previousMessageId: messageIdFromApi,
},
}),
);
sendMessageUpdated(
createMockStreamChunk({
type: 'item',
content: 'Starting response...',
metadata: {
messageId: 'ai-message-123',
previousMessageId: messageIdFromApi,
},
}),
);
await user.click(await rendered.findByRole('button', { name: /stop generating/i }));
expect(chatApi.stopGenerationApi).toHaveBeenCalledWith(
expect.anything(),
sessionId,
'ai-message-123',
);
});
});
});
@@ -47,6 +47,7 @@ import { useFileDrop } from '@/features/ai/chatHub/composables/useFileDrop';
import {
type ChatHubConversationModelWithCachedDisplayName,
chatHubConversationModelWithCachedDisplayNameSchema,
type MessagingState,
} from '@/features/ai/chatHub/chat.types';
import { useI18n } from '@n8n/i18n';
import { useCustomAgent } from '@/features/ai/chatHub/composables/useCustomAgent';
@@ -250,20 +251,20 @@ const credentialsForSelectedProvider = computed<ChatHubSendMessageRequest['crede
},
);
const isMissingSelectedCredential = computed(() => !credentialsForSelectedProvider.value);
const issue = computed<null | 'missingCredentials' | 'missingAgent'>(() => {
if (!chatStore.agentsReady) {
return null;
const messagingState = computed<MessagingState>(() => {
if (chatStore.streaming?.sessionId === sessionId.value) {
return chatStore.streaming.messageId ? 'receiving' : 'waitingFirstChunk';
}
if (!selectedModel.value) {
if (chatStore.agentsReady && !selectedModel.value) {
return 'missingAgent';
}
if (isMissingSelectedCredential.value) {
if (chatStore.agentsReady && isMissingSelectedCredential.value) {
return 'missingCredentials';
}
return null;
return 'idle';
});
const editingMessageId = ref<string>();
@@ -431,7 +432,7 @@ async function onSubmit(message: string, attachments: File[]) {
attachments,
);
inputRef.value?.setText('');
inputRef.value?.reset();
if (isNewSession.value) {
// TODO: this should not happen when submit fails
@@ -451,7 +452,7 @@ function handleCancelEditMessage() {
editingMessageId.value = undefined;
}
function handleEditMessage(message: ChatHubMessageDto) {
async function handleEditMessage(message: ChatHubMessageDto) {
if (
isResponding.value ||
!['human', 'ai'].includes(message.type) ||
@@ -463,7 +464,7 @@ function handleEditMessage(message: ChatHubMessageDto) {
const messageToEdit = message.revisionOfMessageId ?? message.id;
chatStore.editMessage(
await chatStore.editMessage(
sessionId.value,
messageToEdit,
message.content,
@@ -473,7 +474,7 @@ function handleEditMessage(message: ChatHubMessageDto) {
editingMessageId.value = undefined;
}
function handleRegenerateMessage(message: ChatHubMessageDto) {
async function handleRegenerateMessage(message: ChatHubMessageDto) {
if (
isResponding.value ||
message.type !== 'ai' ||
@@ -485,7 +486,9 @@ function handleRegenerateMessage(message: ChatHubMessageDto) {
const messageToRetry = message.id;
chatStore.regenerateMessage(
editingMessageId.value = undefined;
await chatStore.regenerateMessage(
sessionId.value,
messageToRetry,
selectedModel.value,
@@ -667,11 +670,9 @@ function onFilesDropped(files: File[]) {
:class="$style.prompt"
:selected-model="selectedModel"
:selected-tools="selectedTools"
:is-responding="isResponding"
:messaging-state="messagingState"
:is-tools-selectable="canSelectTools"
:is-missing-credentials="isMissingSelectedCredential"
:is-new-session="isNewSession"
:issue="issue"
@submit="onSubmit"
@stop="onStop"
@select-model="handleConfigureModel"
@@ -1,27 +1,260 @@
import type {} from '@n8n/api-types';
import type {
ChatModelsResponse,
ChatModelDto,
ChatHubSessionDto,
ChatHubMessageDto,
ChatHubConversationResponse,
EnrichedStructuredChunk,
ChatHubModuleSettings,
} from '@n8n/api-types';
import { emptyChatModelsResponse } from '@n8n/api-types';
import type { ChatMessage } from '../chat.types';
export function createTestChatMessage(overrides: Partial<ChatMessage> = {}): ChatMessage {
export function createMockAgent(overrides: Partial<ChatModelDto> = {}): ChatModelDto {
return {
id: 'm0',
type: 'ai',
content: 'test message',
name: 'Test Agent',
description: 'A test agent',
model: { provider: 'openai', model: 'gpt-4' },
icon: null,
updatedAt: '2024-01-15T12:00:00Z',
createdAt: '2024-01-15T12:00:00Z',
metadata: {
inputModalities: ['text'],
capabilities: {
functionCalling: true,
},
available: true,
},
...overrides,
};
}
export function createMockModelsResponse(
overrides: Partial<ChatModelsResponse> = {},
): ChatModelsResponse {
return {
...emptyChatModelsResponse,
openai: {
models: [
createMockAgent({
name: 'GPT-4',
model: { provider: 'openai', model: 'gpt-4' },
}),
],
},
...overrides,
};
}
export function createMockSession(overrides: Partial<ChatHubSessionDto> = {}): ChatHubSessionDto {
return {
id: 'session-123',
title: 'Test Conversation',
ownerId: 'user-123',
lastMessageAt: null,
credentialId: null,
provider: 'openai',
model: 'gpt-4',
workflowId: null,
agentId: null,
agentName: 'gpt-4',
agentIcon: null,
createdAt: '2024-01-15T12:00:00Z',
updatedAt: '2024-01-15T12:00:00Z',
tools: [],
...overrides,
};
}
export function createMockMessageDto(
overrides: Partial<ChatHubMessageDto> = {},
): ChatHubMessageDto {
return {
id: 'message-123',
sessionId: 'session-123',
type: 'human',
name: 'User',
content: 'Test message',
status: 'success',
sessionId: 's0',
createdAt: new Date().toISOString(),
model: 'm0',
provider: 'anthropic',
alternatives: [],
attachments: [],
responses: [],
name: '',
provider: null,
model: null,
workflowId: null,
agentId: null,
executionId: null,
updatedAt: new Date().toISOString(),
previousMessageId: null,
retryOfMessageId: null,
revisionOfMessageId: null,
attachments: [],
createdAt: '2024-01-15T12:00:00Z',
updatedAt: '2024-01-15T12:00:00Z',
...overrides,
};
}
export function createMockMessage(overrides: Partial<ChatMessage> = {}): ChatMessage {
return {
...createMockMessageDto(overrides),
responses: [],
alternatives: [],
attachments: [],
...overrides,
};
}
export function createMockConversationResponse(
overrides: Partial<ChatHubConversationResponse> = {},
): ChatHubConversationResponse {
return {
session: createMockSession(),
conversation: { messages: {} },
...overrides,
};
}
export function createMockStreamChunk(
overrides: Partial<Omit<EnrichedStructuredChunk, 'metadata'>> & {
metadata?: Partial<EnrichedStructuredChunk['metadata']>;
} = {},
): EnrichedStructuredChunk {
const { metadata, ...rest } = overrides;
return {
type: 'item',
content: 'Test content',
...rest,
metadata: {
nodeId: 'test-node',
nodeName: 'Test Node',
runIndex: 0,
itemIndex: 0,
timestamp: Date.now(),
messageId: 'message-123',
previousMessageId: null,
retryOfMessageId: null,
executionId: null,
...metadata,
},
};
}
export function createChatHubModuleSettings(
overrides: Partial<ChatHubModuleSettings> = {},
): ChatHubModuleSettings {
return {
enabled: true,
providers: {
openai: {
provider: 'openai',
credentialId: null,
allowedModels: [],
createdAt: '2025-12-18T09:07:29.060Z',
updatedAt: null,
enabled: true,
},
anthropic: {
provider: 'anthropic',
credentialId: null,
allowedModels: [],
createdAt: '2025-12-18T09:07:29.060Z',
updatedAt: null,
enabled: true,
},
google: {
provider: 'google',
credentialId: null,
allowedModels: [],
createdAt: '2025-12-18T09:07:29.060Z',
updatedAt: null,
enabled: true,
},
azureOpenAi: {
provider: 'azureOpenAi',
credentialId: null,
allowedModels: [],
createdAt: '2025-12-18T09:07:29.060Z',
updatedAt: null,
enabled: true,
},
azureEntraId: {
provider: 'azureEntraId',
credentialId: null,
allowedModels: [],
createdAt: '2025-12-18T09:07:29.060Z',
updatedAt: null,
enabled: true,
},
ollama: {
provider: 'ollama',
credentialId: null,
allowedModels: [],
createdAt: '2025-12-18T09:07:29.060Z',
updatedAt: null,
enabled: true,
},
awsBedrock: {
provider: 'awsBedrock',
credentialId: null,
allowedModels: [],
createdAt: '2025-12-18T09:07:29.060Z',
updatedAt: null,
enabled: true,
},
vercelAiGateway: {
provider: 'vercelAiGateway',
credentialId: null,
allowedModels: [],
createdAt: '2025-12-18T09:07:29.060Z',
updatedAt: null,
enabled: true,
},
xAiGrok: {
provider: 'xAiGrok',
credentialId: null,
allowedModels: [],
createdAt: '2025-12-18T09:07:29.060Z',
updatedAt: null,
enabled: true,
},
groq: {
provider: 'groq',
credentialId: null,
allowedModels: [],
createdAt: '2025-12-18T09:07:29.060Z',
updatedAt: null,
enabled: true,
},
openRouter: {
provider: 'openRouter',
credentialId: null,
allowedModels: [],
createdAt: '2025-12-18T09:07:29.060Z',
updatedAt: null,
enabled: true,
},
deepSeek: {
provider: 'deepSeek',
credentialId: null,
allowedModels: [],
createdAt: '2025-12-18T09:07:29.060Z',
updatedAt: null,
enabled: true,
},
cohere: {
provider: 'cohere',
credentialId: null,
allowedModels: [],
createdAt: '2025-12-18T09:07:29.060Z',
updatedAt: null,
enabled: true,
},
mistralCloud: {
provider: 'mistralCloud',
credentialId: null,
allowedModels: [],
createdAt: '2025-12-18T09:07:29.060Z',
updatedAt: null,
enabled: true,
},
},
...overrides,
};
}
@@ -50,17 +50,15 @@ export function sendMessageApi(
export function editMessageApi(
ctx: IRestApiContext,
sessionId: ChatSessionId,
editId: ChatMessageId,
payload: ChatHubEditMessageRequest,
request: { sessionId: ChatSessionId; editId: ChatMessageId; payload: ChatHubEditMessageRequest },
onMessageUpdated: (data: EnrichedStructuredChunk) => void,
onDone: () => void,
onError: (e: Error) => void,
) {
void streamRequest<EnrichedStructuredChunk>(
ctx,
`/chat/conversations/${sessionId}/messages/${editId}/edit`,
payload,
`/chat/conversations/${request.sessionId}/messages/${request.editId}/edit`,
request.payload,
onMessageUpdated,
onDone,
onError,
@@ -70,17 +68,19 @@ export function editMessageApi(
export function regenerateMessageApi(
ctx: IRestApiContext,
sessionId: ChatSessionId,
retryId: ChatMessageId,
payload: ChatHubRegenerateMessageRequest,
request: {
sessionId: ChatSessionId;
retryId: ChatMessageId;
payload: ChatHubRegenerateMessageRequest;
},
onMessageUpdated: (data: EnrichedStructuredChunk) => void,
onDone: () => void,
onError: (e: Error) => void,
) {
void streamRequest<EnrichedStructuredChunk>(
ctx,
`/chat/conversations/${sessionId}/messages/${retryId}/regenerate`,
payload,
`/chat/conversations/${request.sessionId}/messages/${request.retryId}/regenerate`,
request.payload,
onMessageUpdated,
onDone,
onError,
@@ -42,6 +42,8 @@ import {
type ChatHubLLMProvider,
type ChatProviderSettingsDto,
type AgentIconOrEmoji,
type ChatHubEditMessageRequest,
type ChatHubRegenerateMessageRequest,
} from '@n8n/api-types';
import type {
CredentialsMap,
@@ -54,10 +56,11 @@ import { retry } from '@n8n/utils/retry';
import {
buildUiMessages,
createSessionFromStreamingState,
isLlmProviderModel,
isMatchedAgent,
createAiMessageFromStreamingState,
flattenModel,
createHumanMessageFromStreamingState,
promisifyStreamingApi,
} from './chat.utils';
import { useToast } from '@/app/composables/useToast';
import { useTelemetry } from '@/app/composables/useTelemetry';
@@ -391,6 +394,18 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
return;
}
if (!streaming.value.retryOfMessageId) {
addMessage(streaming.value.sessionId, createHumanMessageFromStreamingState(streaming.value));
}
if (!sessions.value.byId[streaming.value.sessionId]) {
sessions.value.byId[streaming.value.sessionId] = createSessionFromStreamingState(
streaming.value,
);
sessions.value.ids ??= [];
sessions.value.ids.unshift(streaming.value.sessionId);
}
const message = createAiMessageFromStreamingState(
streaming.value.sessionId,
streaming.value.messageId,
@@ -489,7 +504,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
);
}
async function onStreamError(error: Error) {
async function onStreamError(error: unknown) {
if (!streaming.value) {
return;
}
@@ -497,7 +512,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
const cause =
error instanceof ResponseError
? new Error(getErrorMessageByStatusCode(error.httpStatusCode, error.message))
: error.message.includes('Failed to fetch')
: error instanceof Error && error.message.includes('Failed to fetch')
? new Error(i18n.baseText('chatHub.error.noConnection'))
: error;
@@ -530,43 +545,25 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
data: attachment.data,
}));
addMessage(sessionId, {
id: messageId,
streaming.value = {
promptPreviousMessageId: previousMessageId,
promptId: messageId,
promptText: message,
sessionId,
type: 'human',
name: 'User',
content: message,
provider: null,
model: isLlmProviderModel(agent.model) ? agent.model.model : null,
workflowId: null,
executionId: null,
agentId: null,
status: 'success',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
previousMessageId,
retryOfMessageId: null,
revisionOfMessageId: null,
responses: [],
alternatives: [],
attachments,
});
streaming.value = {
promptId: messageId,
sessionId,
retryOfMessageId: null,
tools,
attachments,
agent,
};
if (!sessions.value.byId[sessionId]) {
sessions.value.byId[sessionId] = createSessionFromStreamingState(streaming.value);
sessions.value.ids ??= [];
sessions.value.ids.unshift(sessionId);
}
telemetry.track('User sent chat hub message', {
...flattenModel(agent.model),
is_custom: agent.model.provider === 'custom-agent',
chat_session_id: sessionId,
});
sendMessageApi(
await promisifyStreamingApi(sendMessageApi)(
rootStore.restApiContext,
{
model: agent.model,
@@ -584,15 +581,9 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
onStreamDone,
onStreamError,
);
telemetry.track('User sent chat hub message', {
...flattenModel(agent.model),
is_custom: agent.model.provider === 'custom-agent',
chat_session_id: sessionId,
});
}
function editMessage(
async function editMessage(
sessionId: ChatSessionId,
editId: ChatMessageId,
content: string,
@@ -604,66 +595,47 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
const conversation = ensureConversation(sessionId);
const message = conversation.messages[editId];
const previousMessageId = message?.previousMessageId ?? null;
const payload: ChatHubEditMessageRequest = {
model: agent.model,
messageId: promptId,
message: content,
credentials,
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
};
if (message?.type === 'human') {
addMessage(sessionId, {
id: promptId,
sessionId,
type: 'human',
name: message.name ?? 'User',
content,
provider: null,
model: null,
workflowId: null,
executionId: null,
agentId: null,
status: 'success',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
previousMessageId,
retryOfMessageId: null,
revisionOfMessageId: editId,
responses: [],
alternatives: [],
attachments: message.attachments ?? null,
});
} else if (message?.type === 'ai') {
if (message?.type === 'ai') {
replaceMessageContent(sessionId, editId, content);
}
streaming.value = {
promptPreviousMessageId: previousMessageId,
promptId,
promptText: content,
sessionId,
agent,
retryOfMessageId: null,
revisionOfMessageId: editId,
tools: [],
attachments: [],
};
editMessageApi(
rootStore.restApiContext,
sessionId,
editId,
{
model: agent.model,
messageId: promptId,
message: content,
credentials,
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
},
onStreamMessage,
onStreamDone,
onStreamError,
);
telemetry.track('User edited chat hub message', {
...flattenModel(agent.model),
is_custom: agent.model.provider === 'custom-agent',
chat_session_id: sessionId,
chat_message_id: editId,
});
await promisifyStreamingApi(editMessageApi)(
rootStore.restApiContext,
{ sessionId, editId, payload },
onStreamMessage,
onStreamDone,
onStreamError,
);
}
function regenerateMessage(
async function regenerateMessage(
sessionId: ChatSessionId,
retryId: ChatMessageId,
agent: ChatModelDto,
@@ -671,39 +643,42 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
) {
const conversation = ensureConversation(sessionId);
const previousMessageId = conversation.messages[retryId]?.previousMessageId ?? null;
const payload: ChatHubRegenerateMessageRequest = {
model: agent.model,
credentials,
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
};
if (!previousMessageId) {
throw new Error('No previous message to base regeneration on');
}
streaming.value = {
promptPreviousMessageId: previousMessageId,
promptId: retryId,
promptText: '',
sessionId,
agent,
retryOfMessageId: retryId,
revisionOfMessageId: null,
tools: [],
attachments: [],
};
regenerateMessageApi(
rootStore.restApiContext,
sessionId,
retryId,
{
model: agent.model,
credentials,
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
},
onStreamMessage,
onStreamDone,
onStreamError,
);
telemetry.track('User regenerated chat hub message', {
...flattenModel(agent.model),
is_custom: agent.model.provider === 'custom-agent',
chat_session_id: sessionId,
chat_message_id: retryId,
});
await promisifyStreamingApi(regenerateMessageApi)(
rootStore.restApiContext,
{ sessionId, retryId, payload },
onStreamMessage,
onStreamDone,
onStreamError,
);
}
async function stopStreamingMessage(sessionId: ChatSessionId) {
@@ -11,7 +11,7 @@ import {
type ChatModelDto,
agentIconOrEmojiSchema,
} from '@n8n/api-types';
import type { INode } from 'n8n-workflow';
import type { IBinaryData, INode } from 'n8n-workflow';
import { z } from 'zod';
import { isLlmProviderModel } from './chat.utils';
@@ -41,6 +41,13 @@ export interface ErrorMessage {
export type StreamChunk = AssistantMessage | ErrorMessage;
export type MessagingState =
| 'idle'
| 'waitingFirstChunk'
| 'receiving'
| 'missingCredentials'
| 'missingAgent';
export interface ChatMessage extends ChatHubMessageDto {
responses: ChatMessageId[];
alternatives: ChatMessageId[];
@@ -77,10 +84,14 @@ export interface ChatAgentFilter {
}
export interface ChatStreamingState extends Partial<EnrichedStructuredChunk['metadata']> {
promptPreviousMessageId: ChatMessageId | null;
promptText: string;
promptId: ChatMessageId;
sessionId: ChatSessionId;
retryOfMessageId: ChatMessageId | null;
revisionOfMessageId: ChatMessageId | null;
tools: INode[];
attachments: IBinaryData[];
agent: ChatModelDto;
}
@@ -10,6 +10,7 @@ import {
type ChatHubLLMProvider,
type ChatHubInputModality,
type AgentIconOrEmoji,
type EnrichedStructuredChunk,
type ChatProviderSettingsDto,
} from '@n8n/api-types';
import type {
@@ -21,8 +22,8 @@ import type {
ChatConversation,
} from './chat.types';
import { CHAT_VIEW } from './constants';
import { v4 as uuidv4 } from 'uuid';
import type { IconName } from '@n8n/design-system/components/N8nIcon/icons';
import type { IRestApiContext } from '@n8n/rest-api-client';
export function getRelativeDate(now: Date, dateString: string): string {
const date = new Date(dateString);
@@ -258,6 +259,30 @@ export function createAiMessageFromStreamingState(
};
}
export function createHumanMessageFromStreamingState(streaming: ChatStreamingState): ChatMessage {
return {
id: streaming.promptId,
sessionId: streaming.sessionId,
type: 'human',
name: 'User',
content: streaming.promptText,
provider: null,
model: null,
workflowId: null,
executionId: null,
agentId: null,
status: 'success',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
previousMessageId: streaming.promptPreviousMessageId,
retryOfMessageId: null,
revisionOfMessageId: streaming.revisionOfMessageId,
responses: [],
alternatives: [],
attachments: streaming.attachments,
};
}
export function buildUiMessages(
sessionId: string,
conversation: ChatConversation,
@@ -284,12 +309,17 @@ export function buildUiMessages(
if (streaming.retryOfMessageId === id && !streaming.messageId) {
// While waiting for streaming to start on regeneration, show previously generated message
// in running state as an immediate feedback
messagesToShow.push({ ...message, content: '', status: 'running' });
messagesToShow.push({
...message,
content: '',
status: 'running',
...flattenModel(streaming.agent.model),
});
foundRunning = true;
continue;
}
if (index === conversation.activeMessageChain.length - 1) {
if (streaming.messageId && index === conversation.activeMessageChain.length - 1) {
// When agent responds multiple messages (e.g. when tools are used),
// there's a noticeable time gap between messages.
// In order to indicate that agent is still responding, show the last AI message as running
@@ -301,18 +331,6 @@ export function buildUiMessages(
messagesToShow.push(message);
}
if (
!foundRunning &&
streaming?.sessionId === sessionId &&
!streaming.messageId &&
streaming.retryOfMessageId === null &&
streaming.promptId === messagesToShow[messagesToShow.length - 1]?.id
) {
// While waiting for streaming to start on sending new message/editing, append a fake message
// in running state as an immediate feedback
messagesToShow.push(createAiMessageFromStreamingState(sessionId, uuidv4(), streaming));
}
return messagesToShow;
}
@@ -404,3 +422,57 @@ export const workflowAgentDefaultIcon: AgentIconOrEmoji = {
type: 'icon',
value: 'bot' satisfies IconName,
};
type StreamApi<T> = (
ctx: IRestApiContext,
payload: T,
onChunk: (data: EnrichedStructuredChunk) => void,
onDone: () => void,
onError: (e: unknown) => void,
) => void;
/**
* Converts streaming API to return a promise that resolves when the first chunk is received.
*/
export function promisifyStreamingApi<T>(
streamingApi: StreamApi<T>,
): (...args: Parameters<StreamApi<T>>) => Promise<void> {
return async (ctx, payload, onChunk, onDone, onError) => {
let settled = false;
let resolvePromise: () => void;
let rejectPromise: (reason?: unknown) => void;
const promise = new Promise<void>((resolve, reject) => {
resolvePromise = resolve;
rejectPromise = reject;
});
streamingApi(
ctx,
payload,
(chunk) => {
if (!settled) {
settled = true;
resolvePromise();
}
onChunk(chunk);
},
() => {
if (!settled) {
settled = true;
resolvePromise();
}
onDone();
},
(error: unknown) => {
if (!settled) {
settled = true;
rejectPromise(error);
}
onError(error);
},
);
return await promise;
};
}
@@ -4,7 +4,7 @@ import { createTestingPinia } from '@pinia/testing';
import ChatMessage from './ChatMessage.vue';
import type { ChatMessage as ChatMessageType } from '../chat.types';
import { waitFor } from '@testing-library/vue';
import { createTestChatMessage } from '../__test__/data';
import { createMockMessage } from '../__test__/data';
const renderComponent = createComponentRenderer(ChatMessage);
@@ -16,7 +16,8 @@ describe('ChatMessage', () => {
});
it('should render syntax highlighting for code blocks', async () => {
const message: ChatMessageType = createTestChatMessage({
const message: ChatMessageType = createMockMessage({
type: 'ai',
content: '```javascript\nconst foo = "bar";\nfunction test() {\n return true;\n}\n```',
});
@@ -27,6 +28,8 @@ describe('ChatMessage', () => {
isEditing: false,
isStreaming: false,
cachedAgentDisplayName: null,
cachedAgentIcon: null,
containerWidth: 100,
},
pinia,
});
@@ -12,12 +12,12 @@ import { isLlmProviderModel, createMimeTypes } from '@/features/ai/chatHub/chat.
import { useI18n } from '@n8n/i18n';
import { I18nT } from 'vue-i18n';
import { useUIStore } from '@/app/stores/ui.store';
import type { MessagingState } from '@/features/ai/chatHub/chat.types';
const { selectedModel, selectedTools, issue } = defineProps<{
isResponding: boolean;
const { selectedModel, selectedTools, messagingState } = defineProps<{
messagingState: MessagingState;
isNewSession: boolean;
isToolsSelectable: boolean;
issue: 'missingCredentials' | 'missingAgent' | null;
selectedModel: ChatModelDto | null;
selectedTools: INode[] | null;
}>();
@@ -115,9 +115,6 @@ function handleSubmitForm() {
if (trimmed) {
speechInput.stop();
emit('submit', trimmed, attachments.value);
message.value = '';
committedSpokenMessage.value = '';
attachments.value = [];
}
}
@@ -130,9 +127,6 @@ function handleKeydownTextarea(e: KeyboardEvent) {
e.preventDefault();
speechInput.stop();
emit('submit', trimmed, attachments.value);
message.value = '';
committedSpokenMessage.value = '';
attachments.value = [];
}
}
@@ -188,6 +182,11 @@ function onSelectTools() {
defineExpose({
focus: () => inputRef.value?.focus(),
reset: () => {
message.value = '';
committedSpokenMessage.value = '';
attachments.value = [];
},
setText: (text: string) => {
message.value = text;
},
@@ -201,7 +200,7 @@ defineExpose({
<template>
<form :class="$style.prompt" @submit.prevent="handleSubmitForm">
<div :class="$style.inputWrap">
<N8nText v-if="issue === 'missingAgent'" :class="$style.callout">
<N8nText v-if="messagingState === 'missingAgent'" :class="$style.callout">
<I18nT
:keypath="
isNewSession
@@ -222,7 +221,10 @@ defineExpose({
</template>
</I18nT>
</N8nText>
<N8nText v-else-if="issue === 'missingCredentials' && llmProvider" :class="$style.callout">
<N8nText
v-else-if="messagingState === 'missingCredentials' && llmProvider"
:class="$style.callout"
>
<I18nT
:keypath="
isNewSession
@@ -262,7 +264,7 @@ defineExpose({
:key="index"
:file="file"
:is-previewable="true"
:is-removable="true"
:is-removable="messagingState === 'idle'"
@remove="removeAttachment"
/>
</div>
@@ -275,7 +277,7 @@ defineExpose({
autocomplete="off"
:autosize="{ minRows: 1, maxRows: 6 }"
autofocus
:disabled="!!issue"
:disabled="messagingState !== 'idle'"
@keydown="handleKeydownTextarea"
/>
@@ -284,7 +286,7 @@ defineExpose({
<ToolsSelector
:class="$style.toolsButton"
:selected="selectedTools ?? []"
:disabled="!!issue || isResponding || !isToolsSelectable"
:disabled="messagingState !== 'idle' || !isToolsSelectable"
:disabled-tooltip="
isToolsSelectable
? undefined
@@ -301,13 +303,13 @@ defineExpose({
? i18n.baseText('chatHub.chat.prompt.button.attach.disabled')
: i18n.baseText('chatHub.chat.prompt.button.attach')
"
:disabled="canUploadFiles && !issue && !isResponding"
:disabled="canUploadFiles && messagingState === 'idle'"
placement="top"
>
<N8nIconButton
native-type="button"
type="secondary"
:disabled="!!issue || isResponding || !canUploadFiles"
:disabled="messagingState !== 'idle' || !canUploadFiles"
icon="paperclip"
icon-size="large"
text
@@ -323,17 +325,18 @@ defineExpose({
: i18n.baseText('chatHub.chat.prompt.button.voiceInput')
"
type="secondary"
:disabled="!!issue || isResponding"
:disabled="messagingState !== 'idle'"
:icon="speechInput.isListening.value ? 'square' : 'mic'"
:class="{ [$style.recording]: speechInput.isListening.value }"
icon-size="large"
@click.stop="onMic"
/>
<N8nIconButton
v-if="!isResponding"
v-if="messagingState !== 'receiving'"
native-type="submit"
:disabled="!!issue || !message.trim()"
:disabled="messagingState !== 'idle' || !message.trim()"
:title="i18n.baseText('chatHub.chat.prompt.button.send')"
:loading="messagingState === 'waitingFirstChunk'"
icon="arrow-up"
icon-size="large"
@click.stop