diff --git a/cypress/e2e/45-ai-assistant.cy.ts b/cypress/e2e/45-ai-assistant.cy.ts new file mode 100644 index 0000000000..4431007df0 --- /dev/null +++ b/cypress/e2e/45-ai-assistant.cy.ts @@ -0,0 +1,247 @@ +import { NDV, WorkflowPage } from '../pages'; +import { AIAssistant } from '../pages/features/ai-assistant'; + +const wf = new WorkflowPage(); +const ndv = new NDV(); +const aiAssistant = new AIAssistant(); + +describe('AI Assistant::disabled', () => { + beforeEach(() => { + aiAssistant.actions.disableAssistant(); + wf.actions.visit(); + }); + + it('does not show assistant button if feature is disabled', () => { + aiAssistant.getters.askAssistantFloatingButton().should('not.exist'); + }); +}); + +describe('AI Assistant::enabled', () => { + beforeEach(() => { + aiAssistant.actions.enableAssistant(); + wf.actions.visit(); + }); + + after(() => { + aiAssistant.actions.disableAssistant(); + }); + + it('renders placeholder UI', () => { + aiAssistant.getters.askAssistantFloatingButton().should('be.visible'); + aiAssistant.getters.askAssistantFloatingButton().click(); + aiAssistant.getters.askAssistantChat().should('be.visible'); + aiAssistant.getters.placeholderMessage().should('be.visible'); + aiAssistant.getters.chatInputWrapper().should('not.exist'); + aiAssistant.getters.closeChatButton().should('be.visible'); + aiAssistant.getters.closeChatButton().click(); + aiAssistant.getters.askAssistantChat().should('not.exist'); + }); + + it('should resize assistant chat up', () => { + aiAssistant.getters.askAssistantFloatingButton().click(); + aiAssistant.getters.askAssistantSidebarResizer().should('be.visible'); + aiAssistant.getters.askAssistantChat().then((element) => { + const { width, left } = element[0].getBoundingClientRect(); + cy.drag(aiAssistant.getters.askAssistantSidebarResizer(), [left - 10, 0], { + abs: true, + clickToFinish: true, + }); + aiAssistant.getters.askAssistantChat().then((newElement) => { + const newWidth = newElement[0].getBoundingClientRect().width; + expect(newWidth).to.be.greaterThan(width); + }); + }); + }); + + it('should resize assistant chat down', () => { + aiAssistant.getters.askAssistantFloatingButton().click(); + aiAssistant.getters.askAssistantSidebarResizer().should('be.visible'); + aiAssistant.getters.askAssistantChat().then((element) => { + const { width, left } = element[0].getBoundingClientRect(); + cy.drag(aiAssistant.getters.askAssistantSidebarResizer(), [left + 10, 0], { + abs: true, + clickToFinish: true, + }); + aiAssistant.getters.askAssistantChat().then((newElement) => { + const newWidth = newElement[0].getBoundingClientRect().width; + expect(newWidth).to.be.lessThan(width); + }); + }); + }); + + it('should start chat session from node error view', () => { + cy.intercept('POST', '/rest/ai-assistant/chat', { + statusCode: 200, + fixture: 'aiAssistant/simple_message_response.json', + }).as('chatRequest'); + cy.createFixtureWorkflow('aiAssistant/test_workflow.json'); + wf.actions.openNode('Stop and Error'); + ndv.getters.nodeExecuteButton().click(); + aiAssistant.getters.nodeErrorViewAssistantButton().click(); + cy.wait('@chatRequest'); + aiAssistant.getters.chatMessagesAll().should('have.length', 1); + aiAssistant.getters + .chatMessagesAll() + .eq(0) + .should('contain.text', 'Hey, this is an assistant message'); + aiAssistant.getters.nodeErrorViewAssistantButton().should('be.disabled'); + }); + + it('should render chat input correctly', () => { + cy.intercept('POST', '/rest/ai-assistant/chat', { + statusCode: 200, + fixture: 'aiAssistant/simple_message_response.json', + }).as('chatRequest'); + cy.createFixtureWorkflow('aiAssistant/test_workflow.json'); + wf.actions.openNode('Stop and Error'); + ndv.getters.nodeExecuteButton().click(); + aiAssistant.getters.nodeErrorViewAssistantButton().click(); + cy.wait('@chatRequest'); + // Send button should be disabled when input is empty + aiAssistant.getters.sendMessageButton().should('be.disabled'); + aiAssistant.getters.chatInput().type('Yo '); + aiAssistant.getters.sendMessageButton().should('not.be.disabled'); + aiAssistant.getters.chatInput().then((element) => { + const { height } = element[0].getBoundingClientRect(); + // Shift + Enter should add a new line + aiAssistant.getters.chatInput().type('Hello{shift+enter}there'); + aiAssistant.getters.chatInput().then((newElement) => { + const newHeight = newElement[0].getBoundingClientRect().height; + // Chat input should grow as user adds new lines + expect(newHeight).to.be.greaterThan(height); + aiAssistant.getters.sendMessageButton().click(); + cy.wait('@chatRequest'); + // New lines should be rendered as
in the chat + aiAssistant.getters.chatMessagesUser().should('have.length', 1); + aiAssistant.getters.chatMessagesUser().eq(0).find('br').should('have.length', 1); + // Chat input should be cleared now + aiAssistant.getters.chatInput().should('have.value', ''); + }); + }); + }); + + it('should render and handle quick replies', () => { + cy.intercept('POST', '/rest/ai-assistant/chat', { + statusCode: 200, + fixture: 'aiAssistant/quick_reply_message_response.json', + }).as('chatRequest'); + cy.createFixtureWorkflow('aiAssistant/test_workflow.json'); + wf.actions.openNode('Stop and Error'); + ndv.getters.nodeExecuteButton().click(); + aiAssistant.getters.nodeErrorViewAssistantButton().click(); + cy.wait('@chatRequest'); + aiAssistant.getters.quickReplies().should('have.length', 2); + aiAssistant.getters.quickReplies().eq(0).click(); + cy.wait('@chatRequest'); + aiAssistant.getters.chatMessagesUser().should('have.length', 1); + aiAssistant.getters.chatMessagesUser().eq(0).should('contain.text', "Sure, let's do it"); + }); + + it('should send message to assistant when node is executed', () => { + cy.intercept('POST', '/rest/ai-assistant/chat', { + statusCode: 200, + fixture: 'aiAssistant/simple_message_response.json', + }).as('chatRequest'); + cy.createFixtureWorkflow('aiAssistant/test_workflow.json'); + wf.actions.openNode('Edit Fields'); + ndv.getters.nodeExecuteButton().click(); + aiAssistant.getters.nodeErrorViewAssistantButton().click(); + cy.wait('@chatRequest'); + aiAssistant.getters.chatMessagesAssistant().should('have.length', 1); + // Executing the same node should sende a new message to the assistant automatically + ndv.getters.nodeExecuteButton().click(); + cy.wait('@chatRequest'); + aiAssistant.getters.chatMessagesAssistant().should('have.length', 2); + }); + + it('should warn before starting a new session', () => { + cy.intercept('POST', '/rest/ai-assistant/chat', { + statusCode: 200, + fixture: 'aiAssistant/simple_message_response.json', + }).as('chatRequest'); + cy.createFixtureWorkflow('aiAssistant/test_workflow.json'); + wf.actions.openNode('Edit Fields'); + ndv.getters.nodeExecuteButton().click(); + aiAssistant.getters.nodeErrorViewAssistantButton().click(); + cy.wait('@chatRequest'); + aiAssistant.getters.closeChatButton().click(); + ndv.getters.backToCanvas().click(); + wf.actions.openNode('Stop and Error'); + ndv.getters.nodeExecuteButton().click(); + aiAssistant.getters.nodeErrorViewAssistantButton().click(); + // Since we already have an active session, a warning should be shown + aiAssistant.getters.newAssistantSessionModal().should('be.visible'); + aiAssistant.getters + .newAssistantSessionModal() + .find('button') + .contains('Start new session') + .click(); + cy.wait('@chatRequest'); + // New session should start with initial assistant message + aiAssistant.getters.chatMessagesAll().should('have.length', 1); + }); + + it('should apply code diff to code node', () => { + cy.intercept('POST', '/rest/ai-assistant/chat', { + statusCode: 200, + fixture: 'aiAssistant/code_diff_suggestion_response.json', + }).as('chatRequest'); + cy.intercept('POST', '/rest/ai-assistant/chat/apply-suggestion', { + statusCode: 200, + fixture: 'aiAssistant/apply_code_diff_response.json', + }).as('applySuggestion'); + cy.createFixtureWorkflow('aiAssistant/test_workflow.json'); + wf.actions.openNode('Code'); + ndv.getters.nodeExecuteButton().click(); + aiAssistant.getters.nodeErrorViewAssistantButton().click({ force: true }); + cy.wait('@chatRequest'); + // Should have two assistant messages + aiAssistant.getters.chatMessagesAll().should('have.length', 2); + aiAssistant.getters.codeDiffs().should('have.length', 1); + aiAssistant.getters.applyCodeDiffButtons().should('have.length', 1); + aiAssistant.getters.applyCodeDiffButtons().first().click(); + cy.wait('@applySuggestion'); + aiAssistant.getters.applyCodeDiffButtons().should('have.length', 0); + aiAssistant.getters.undoReplaceCodeButtons().should('have.length', 1); + aiAssistant.getters.codeReplacedMessage().should('be.visible'); + ndv.getters + .parameterInput('jsCode') + .get('.cm-content') + .should('contain.text', 'item.json.myNewField = 1'); + // Clicking undo should revert the code back but not call the assistant + aiAssistant.getters.undoReplaceCodeButtons().first().click(); + aiAssistant.getters.applyCodeDiffButtons().should('have.length', 1); + aiAssistant.getters.codeReplacedMessage().should('not.exist'); + cy.get('@applySuggestion.all').then((interceptions) => { + expect(interceptions).to.have.length(1); + }); + ndv.getters + .parameterInput('jsCode') + .get('.cm-content') + .should('contain.text', 'item.json.myNewField = 1aaa'); + // Replacing the code again should also not call the assistant + cy.get('@applySuggestion.all').then((interceptions) => { + expect(interceptions).to.have.length(1); + }); + aiAssistant.getters.applyCodeDiffButtons().should('have.length', 1); + aiAssistant.getters.applyCodeDiffButtons().first().click(); + ndv.getters + .parameterInput('jsCode') + .get('.cm-content') + .should('contain.text', 'item.json.myNewField = 1'); + }); + + it('should end chat session when `end_session` event is received', () => { + cy.intercept('POST', '/rest/ai-assistant/chat', { + statusCode: 200, + fixture: 'aiAssistant/end_session_response.json', + }).as('chatRequest'); + cy.createFixtureWorkflow('aiAssistant/test_workflow.json'); + wf.actions.openNode('Stop and Error'); + ndv.getters.nodeExecuteButton().click(); + aiAssistant.getters.nodeErrorViewAssistantButton().click(); + cy.wait('@chatRequest'); + aiAssistant.getters.chatMessagesSystem().should('have.length', 1); + aiAssistant.getters.chatMessagesSystem().first().should('contain.text', 'session has ended'); + }); +}); diff --git a/cypress/fixtures/aiAssistant/apply_code_diff_response.json b/cypress/fixtures/aiAssistant/apply_code_diff_response.json new file mode 100644 index 0000000000..8d7ada0b40 --- /dev/null +++ b/cypress/fixtures/aiAssistant/apply_code_diff_response.json @@ -0,0 +1,8 @@ +{ + "data": { + "sessionId": "f9130bd7-c078-4862-a38a-369b27b0ff20-e96eb9f7-d581-4684-b6a9-fd3dfe9fe1fb-emTezIGat7bQsDdtIlbti", + "parameters": { + "jsCode": "// Loop over input items and add a new field called 'myNewField' to the JSON of each one\nfor (const item of $input.all()) {\n item.json.myNewField = 1;\n}\n\nreturn $input.all();" + } + } +} diff --git a/cypress/fixtures/aiAssistant/code_diff_suggestion_response.json b/cypress/fixtures/aiAssistant/code_diff_suggestion_response.json new file mode 100644 index 0000000000..8ee5d647fd --- /dev/null +++ b/cypress/fixtures/aiAssistant/code_diff_suggestion_response.json @@ -0,0 +1,23 @@ +{ + "sessionId": "1", + "messages": [ + { + "role": "assistant", + "type": "message", + "text": "Hi there! Here is my top solution to fix the error in your **Code** node 👇" + }, + { + "type": "code-diff", + "description": "Fix the syntax error by changing '1asd' to a valid value. In this case, it seems like '1' was intended.", + "suggestionId": "1", + "codeDiff": "@@ -2,2 +2,2 @@\n item.json.myNewField = 1asd;\n+ item.json.myNewField = 1;\n", + "role": "assistant", + "quickReplies": [ + { + "text": "Give me another solution", + "type": "new-suggestion" + } + ] + } + ] +} diff --git a/cypress/fixtures/aiAssistant/end_session_response.json b/cypress/fixtures/aiAssistant/end_session_response.json new file mode 100644 index 0000000000..c53574d93a --- /dev/null +++ b/cypress/fixtures/aiAssistant/end_session_response.json @@ -0,0 +1,16 @@ +{ + "sessionId": "f9130bd7-c078-4862-a38a-369b27b0ff20-e96eb9f7-d581-4684-b6a9-fd3dfe9fe1fb-XCldJLlusGrEVku5I9cYT", + "messages": [ + { + "role": "assistant", + "type": "agent-suggestion", + "title": "Glad to Help", + "text": "I'm glad I could help. If you have any more questions or need further assistance with your n8n workflows, feel free to ask!" + }, + { + "role": "assistant", + "type": "event", + "eventName": "end-session" + } + ] +} diff --git a/cypress/fixtures/aiAssistant/quick_reply_message_response.json b/cypress/fixtures/aiAssistant/quick_reply_message_response.json new file mode 100644 index 0000000000..a3c1b958c4 --- /dev/null +++ b/cypress/fixtures/aiAssistant/quick_reply_message_response.json @@ -0,0 +1,20 @@ +{ + "sessionId": "1", + "messages": [ + { + "role": "assistant", + "type": "message", + "text": "Hey, this is an assistant message", + "quickReplies": [ + { + "text": "Sure, let's do it", + "type": "yes" + }, + { + "text": "Nah, doesn't sound good", + "type": "no" + } + ] + } + ] +} diff --git a/cypress/fixtures/aiAssistant/simple_message_response.json b/cypress/fixtures/aiAssistant/simple_message_response.json new file mode 100644 index 0000000000..11299b91f9 --- /dev/null +++ b/cypress/fixtures/aiAssistant/simple_message_response.json @@ -0,0 +1,10 @@ +{ + "sessionId": "1", + "messages": [ + { + "role": "assistant", + "type": "message", + "text": "Hey, this is an assistant message" + } + ] +} diff --git a/cypress/fixtures/aiAssistant/test_workflow.json b/cypress/fixtures/aiAssistant/test_workflow.json new file mode 100644 index 0000000000..da930ea489 --- /dev/null +++ b/cypress/fixtures/aiAssistant/test_workflow.json @@ -0,0 +1,88 @@ +{ + "nodes": [ + { + "parameters": {}, + "id": "ebfced75-2ce1-4c41-a971-6c3b83522c4d", + "name": "When clicking ‘Test workflow’", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + 360, + 220 + ] + }, + { + "parameters": { + "errorMessage": "This is an error message" + }, + "id": "f2e60459-401a-49d5-acfc-7b2b31cfdcf7", + "name": "Stop and Error", + "type": "n8n-nodes-base.stopAndError", + "typeVersion": 1, + "position": [ + 1020, + 220 + ] + }, + { + "parameters": { + "jsCode": "// Loop over input items and add a new field called 'myNewField' to the JSON of each one\nfor (const item of $input.all()) {\n item.json.myNewField = 1aaa;\n}\n\nreturn $input.all();" + }, + "id": "b54d4db9-b257-41a8-862f-26d293115bad", + "name": "Code", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 840, + 320 + ] + }, + { + "parameters": { + "assignments": { + "assignments": [ + { + "id": "053ada73-f7db-4e6a-8cc8-85756cc6ca4e", + "name": "age", + "value": "={{ 32sad }}", + "type": "number" + } + ] + }, + "options": {} + }, + "id": "5fd89612-a871-4679-b7b0-d659e09c6a0e", + "name": "Edit Fields", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 600, + 100 + ] + } + ], + "connections": { + "When clicking ‘Test workflow’": { + "main": [ + [ + { + "node": "Stop and Error", + "type": "main", + "index": 0 + }, + { + "node": "Code", + "type": "main", + "index": 0 + }, + { + "node": "Edit Fields", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "pinData": {} +} diff --git a/cypress/pages/features/ai-assistant.ts b/cypress/pages/features/ai-assistant.ts new file mode 100644 index 0000000000..abca07fbbe --- /dev/null +++ b/cypress/pages/features/ai-assistant.ts @@ -0,0 +1,49 @@ +import { overrideFeatureFlag } from '../../composables/featureFlags'; +import { BasePage } from '../base'; + +const AI_ASSISTANT_FEATURE = { + name: 'aiAssistant', + experimentName: '021_ai_debug_helper', + enabledFor: 'variant', + disabledFor: 'control', +}; + +export class AIAssistant extends BasePage { + url = '/workflows/new'; + + getters = { + askAssistantFloatingButton: () => cy.getByTestId('ask-assistant-floating-button'), + askAssistantSidebar: () => cy.getByTestId('ask-assistant-sidebar'), + askAssistantSidebarResizer: () => + this.getters.askAssistantSidebar().find('[class^=_resizer][data-dir=left]').first(), + askAssistantChat: () => cy.getByTestId('ask-assistant-chat'), + placeholderMessage: () => cy.getByTestId('placeholder-message'), + closeChatButton: () => cy.getByTestId('close-chat-button'), + chatInputWrapper: () => cy.getByTestId('chat-input-wrapper'), + chatInput: () => cy.getByTestId('chat-input'), + sendMessageButton: () => cy.getByTestId('send-message-button'), + chatMessagesAll: () => cy.get('[data-test-id^=chat-message]'), + chatMessagesAssistant: () => cy.getByTestId('chat-message-assistant'), + chatMessagesUser: () => cy.getByTestId('chat-message-user'), + chatMessagesSystem: () => cy.getByTestId('chat-message-system'), + quickReplies: () => cy.getByTestId('quick-replies').find('button'), + newAssistantSessionModal: () => cy.getByTestId('new-assistant-session-modal'), + codeDiffs: () => cy.getByTestId('code-diff-suggestion'), + applyCodeDiffButtons: () => cy.getByTestId('replace-code-button'), + undoReplaceCodeButtons: () => cy.getByTestId('undo-replace-button'), + codeReplacedMessage: () => cy.getByTestId('code-replaced-message'), + nodeErrorViewAssistantButton: () => + cy.getByTestId('node-error-view-ask-assistant-button').find('button').first(), + }; + + actions = { + enableAssistant(): void { + overrideFeatureFlag(AI_ASSISTANT_FEATURE.experimentName, AI_ASSISTANT_FEATURE.enabledFor); + cy.enableFeature(AI_ASSISTANT_FEATURE.name); + }, + disableAssistant(): void { + overrideFeatureFlag(AI_ASSISTANT_FEATURE.experimentName, AI_ASSISTANT_FEATURE.disabledFor); + cy.disableFeature(AI_ASSISTANT_FEATURE.name); + }, + }; +} diff --git a/packages/design-system/src/components/AskAssistantChat/AskAssistantChat.vue b/packages/design-system/src/components/AskAssistantChat/AskAssistantChat.vue index 30bdf81351..ff7ba2147b 100644 --- a/packages/design-system/src/components/AskAssistantChat/AskAssistantChat.vue +++ b/packages/design-system/src/components/AskAssistantChat/AskAssistantChat.vue @@ -102,13 +102,20 @@ function growInput() { -
+
-
+
-
+
⚠️ {{ message.content }}
@@ -173,7 +184,11 @@ function growInput() { @undo="() => emit('codeUndo', i)" />
-
+
{{ t('assistantChat.sessionEndMessage.1') }} @@ -193,7 +208,7 @@ function growInput() { :class="$style.quickReplies" >
{{ t('assistantChat.quickRepliesTitle') }}
-
+
-
+
Hi {{ user?.firstName }} 👋

@@ -232,6 +247,7 @@ function growInput() {