mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-24 23:22:38 +08:00
fix(editor): Previous message does not exist error when sending chat message (#23298)
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user