diff --git a/packages/@n8n/api-types/src/chat-hub.ts b/packages/@n8n/api-types/src/chat-hub.ts index a68adda3d69..6ba99c5ad98 100644 --- a/packages/@n8n/api-types/src/chat-hub.ts +++ b/packages/@n8n/api-types/src/chat-hub.ts @@ -494,3 +494,8 @@ export type ChatProviderSettingsDto = z.infer export class UpdateChatSettingsRequest extends Z.class({ payload: chatProviderSettingsSchema, }) {} + +export interface ChatHubModuleSettings { + enabled: boolean; + providers: Record; +} diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index 3eb56774180..0ddb9c46318 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -18,6 +18,7 @@ export { type ChatHubN8nModel, type ChatHubCustomAgentModel, type ChatHubConversationModel, + type ChatHubModuleSettings, chatHubProviderSchema, chatHubLLMProviderSchema, type ChatHubProvider, diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/ChatView.test.ts b/packages/frontend/editor-ui/src/features/ai/chatHub/ChatView.test.ts new file mode 100644 index 00000000000..a178b464fff --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/ChatView.test.ts @@ -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; query: Record }>({ + 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(); + + return { + ...actual, + useRoute: () => mockRoute, + useRouter: () => ({ + push: mockRouterPush, + resolve: vi.fn(), + }), + }; +}); + +const renderComponent = createComponentRenderer(ChatView); + +describe('ChatView', () => { + let pinia: ReturnType; + 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', + ); + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/ChatView.vue b/packages/frontend/editor-ui/src/features/ai/chatHub/ChatView.vue index 18fca5d8137..080b5f0f70c 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/ChatView.vue +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/ChatView.vue @@ -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 !credentialsForSelectedProvider.value); -const issue = computed(() => { - if (!chatStore.agentsReady) { - return null; +const messagingState = computed(() => { + 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(); @@ -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" diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/__test__/data.ts b/packages/frontend/editor-ui/src/features/ai/chatHub/__test__/data.ts index 1448f228d70..1a89315c531 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/__test__/data.ts +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/__test__/data.ts @@ -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 { +export function createMockAgent(overrides: Partial = {}): 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 { + return { + ...emptyChatModelsResponse, + openai: { + models: [ + createMockAgent({ + name: 'GPT-4', + model: { provider: 'openai', model: 'gpt-4' }, + }), + ], + }, + ...overrides, + }; +} + +export function createMockSession(overrides: Partial = {}): 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 { + 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 { + return { + ...createMockMessageDto(overrides), + responses: [], + alternatives: [], + attachments: [], + ...overrides, + }; +} + +export function createMockConversationResponse( + overrides: Partial = {}, +): ChatHubConversationResponse { + return { + session: createMockSession(), + conversation: { messages: {} }, + ...overrides, + }; +} + +export function createMockStreamChunk( + overrides: Partial> & { + metadata?: Partial; + } = {}, +): 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 { + 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, }; } diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/chat.api.ts b/packages/frontend/editor-ui/src/features/ai/chatHub/chat.api.ts index 05075c0fab3..cddf8ae8878 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/chat.api.ts +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/chat.api.ts @@ -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( 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( ctx, - `/chat/conversations/${sessionId}/messages/${retryId}/regenerate`, - payload, + `/chat/conversations/${request.sessionId}/messages/${request.retryId}/regenerate`, + request.payload, onMessageUpdated, onDone, onError, diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/chat.store.ts b/packages/frontend/editor-ui/src/features/ai/chatHub/chat.store.ts index fccf66fc08a..2bcbfbe8fea 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/chat.store.ts +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/chat.store.ts @@ -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) { diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/chat.types.ts b/packages/frontend/editor-ui/src/features/ai/chatHub/chat.types.ts index 0795144c824..95dec6d0eb4 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/chat.types.ts +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/chat.types.ts @@ -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 { + promptPreviousMessageId: ChatMessageId | null; + promptText: string; promptId: ChatMessageId; sessionId: ChatSessionId; retryOfMessageId: ChatMessageId | null; + revisionOfMessageId: ChatMessageId | null; tools: INode[]; + attachments: IBinaryData[]; agent: ChatModelDto; } diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/chat.utils.ts b/packages/frontend/editor-ui/src/features/ai/chatHub/chat.utils.ts index 70d34b03ae2..41abf0594b4 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/chat.utils.ts +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/chat.utils.ts @@ -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 = ( + 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( + streamingApi: StreamApi, +): (...args: Parameters>) => Promise { + return async (ctx, payload, onChunk, onDone, onError) => { + let settled = false; + let resolvePromise: () => void; + let rejectPromise: (reason?: unknown) => void; + + const promise = new Promise((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; + }; +} diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatMessage.test.ts b/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatMessage.test.ts index 69f1ff38e47..52790ca8733 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatMessage.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatMessage.test.ts @@ -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, }); diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatPrompt.vue b/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatPrompt.vue index 6ed57061aa5..6bc4b1b0a82 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatPrompt.vue +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatPrompt.vue @@ -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({