diff --git a/cypress/composables/modals/chat-modal.ts b/cypress/composables/modals/chat-modal.ts new file mode 100644 index 0000000000..268419f3c8 --- /dev/null +++ b/cypress/composables/modals/chat-modal.ts @@ -0,0 +1,48 @@ +/** + * Getters + */ + +export function getManualChatModal() { + return cy.getByTestId('lmChat-modal'); +} + +export function getManualChatInput() { + return cy.getByTestId('workflow-chat-input'); +} + +export function getManualChatSendButton() { + return getManualChatModal().getByTestId('workflow-chat-send-button'); +} + +export function getManualChatMessages() { + return getManualChatModal().get('.messages .message'); +} + +export function getManualChatModalCloseButton() { + return getManualChatModal().get('.el-dialog__close'); +} + +export function getManualChatModalLogs() { + return getManualChatModal().getByTestId('lm-chat-logs'); +} + +export function getManualChatModalLogsTree() { + return getManualChatModalLogs().getByTestId('lm-chat-logs-tree'); +} + +export function getManualChatModalLogsEntries() { + return getManualChatModalLogs().getByTestId('lm-chat-logs-entry'); +} + +/** + * Actions + */ + +export function sendManualChatMessage(message: string) { + getManualChatInput().type(message); + getManualChatSendButton().click(); +} + +export function closeManualChatModal() { + getManualChatModalCloseButton().click(); +} diff --git a/cypress/composables/modals/credential-modal.ts b/cypress/composables/modals/credential-modal.ts new file mode 100644 index 0000000000..bfcbf89251 --- /dev/null +++ b/cypress/composables/modals/credential-modal.ts @@ -0,0 +1,54 @@ +/** + * Getters + */ + +export function getCredentialConnectionParameterInputs() { + return cy.getByTestId('credential-connection-parameter'); +} + +export function getCredentialConnectionParameterInputByName(name: string) { + return cy.getByTestId(`parameter-input-${name}`); +} + +export function getEditCredentialModal() { + return cy.getByTestId('editCredential-modal', { timeout: 5000 }); +} + +export function getCredentialSaveButton() { + return cy.getByTestId('credential-save-button', { timeout: 5000 }); +} + +export function getCredentialDeleteButton() { + return cy.getByTestId('credential-delete-button'); +} + +export function getCredentialModalCloseButton() { + return getEditCredentialModal().find('.el-dialog__close').first(); +} + +/** + * Actions + */ + +export function setCredentialConnectionParameterInputByName(name: string, value: string) { + getCredentialConnectionParameterInputByName(name).type(value); +} + +export function saveCredential() { + getCredentialSaveButton().click({ force: true }); +} + +export function closeCredentialModal() { + getCredentialModalCloseButton().click(); +} + +export function setCredentialValues(values: Record, save = true) { + Object.entries(values).forEach(([key, value]) => { + setCredentialConnectionParameterInputByName(key, value); + }); + + if (save) { + saveCredential(); + closeCredentialModal(); + } +} diff --git a/cypress/composables/ndv.ts b/cypress/composables/ndv.ts new file mode 100644 index 0000000000..02edb29d55 --- /dev/null +++ b/cypress/composables/ndv.ts @@ -0,0 +1,73 @@ +/** + * Getters + */ + +export function getCredentialSelect(eq = 0) { + return cy.getByTestId('node-credentials-select').eq(eq); +} + +export function getCreateNewCredentialOption() { + return cy.getByTestId('node-credentials-select-item-new'); +} + +export function getBackToCanvasButton() { + return cy.getByTestId('back-to-canvas'); +} + +export function getExecuteNodeButton() { + return cy.getByTestId('node-execute-button'); +} + +export function getParameterInputByName(name: string) { + return cy.getByTestId(`parameter-input-${name}`); +} + +export function getInputPanel() { + return cy.getByTestId('input-panel'); +} + +export function getMainPanel() { + return cy.getByTestId('node-parameters'); +} + +export function getOutputPanel() { + return cy.getByTestId('output-panel'); +} + +export function getOutputPanelDataContainer() { + return getOutputPanel().getByTestId('ndv-data-container'); +} + +export function getOutputPanelTable() { + return getOutputPanelDataContainer().get('table'); +} + +/** + * Actions + */ + +export function openCredentialSelect(eq = 0) { + getCredentialSelect(eq).click(); +} + +export function setCredentialByName(name: string) { + openCredentialSelect(); + getCredentialSelect().contains(name).click(); +} + +export function clickCreateNewCredential() { + openCredentialSelect(); + getCreateNewCredentialOption().click(); +} + +export function clickGetBackToCanvas() { + getBackToCanvasButton().click(); +} + +export function clickExecuteNode() { + getExecuteNodeButton().click(); +} + +export function setParameterInputByName(name: string, value: string) { + getParameterInputByName(name).clear().type(value); +} diff --git a/cypress/composables/workflow.ts b/cypress/composables/workflow.ts new file mode 100644 index 0000000000..b1810943a3 --- /dev/null +++ b/cypress/composables/workflow.ts @@ -0,0 +1,142 @@ +import { ROUTES } from '../constants'; +import { getManualChatModal } from './modals/chat-modal'; + +/** + * Types + */ + +export type EndpointType = + | 'ai_chain' + | 'ai_document' + | 'ai_embedding' + | 'ai_languageModel' + | 'ai_memory' + | 'ai_outputParser' + | 'ai_tool' + | 'ai_retriever' + | 'ai_textSplitter' + | 'ai_vectorRetriever' + | 'ai_vectorStore'; + +/** + * Getters + */ + +export function getAddInputEndpointByType(nodeName: string, endpointType: EndpointType) { + return cy.get( + `.add-input-endpoint[data-jtk-scope-${endpointType}][data-endpoint-name="${nodeName}"]`, + ); +} + +export function getNodeCreatorItems() { + return cy.getByTestId('item-iterator-item'); +} + +export function getExecuteWorkflowButton() { + return cy.getByTestId('execute-workflow-button'); +} + +export function getManualChatButton() { + return cy.getByTestId('workflow-chat-button'); +} + +export function getNodes() { + return cy.getByTestId('canvas-node'); +} + +export function getNodeByName(name: string) { + return cy.getByTestId('canvas-node').filter(`[data-name="${name}"]`).eq(0); +} + +export function getConnectionBySourceAndTarget(source: string, target: string) { + return cy + .get('.jtk-connector') + .filter(`[data-source-node="${source}"][data-target-node="${target}"]`) + .eq(0); +} + +export function getNodeCreatorSearchBar() { + return cy.getByTestId('node-creator-search-bar'); +} + +export function getNodeCreatorPlusButton() { + return cy.getByTestId('node-creator-plus-button'); +} + +/** + * Actions + */ + +export function addNodeToCanvas( + nodeDisplayName: string, + plusButtonClick = true, + preventNdvClose?: boolean, + action?: string, +) { + if (plusButtonClick) { + getNodeCreatorPlusButton().click(); + } + + getNodeCreatorSearchBar().type(nodeDisplayName); + getNodeCreatorSearchBar().type('{enter}'); + cy.wait(500); + cy.get('body').then((body) => { + if (body.find('[data-test-id=node-creator]').length > 0) { + if (action) { + cy.contains(action).click(); + } else { + // Select the first action + cy.get('[data-keyboard-nav-type="action"]').eq(0).click(); + } + } + }); + + if (!preventNdvClose) cy.get('body').type('{esc}'); +} + +export function navigateToNewWorkflowPage(preventNodeViewUnload = true) { + cy.visit(ROUTES.NEW_WORKFLOW_PAGE); + cy.waitForLoad(); + cy.window().then((win) => { + win.preventNodeViewBeforeUnload = preventNodeViewUnload; + }); +} + +export function addSupplementalNodeToParent( + nodeName: string, + endpointType: EndpointType, + parentNodeName: string, +) { + getAddInputEndpointByType(parentNodeName, endpointType).click({ force: true }); + getNodeCreatorItems().contains(nodeName).click(); + getConnectionBySourceAndTarget(parentNodeName, nodeName).should('exist'); +} + +export function addLanguageModelNodeToParent(nodeName: string, parentNodeName: string) { + addSupplementalNodeToParent(nodeName, 'ai_languageModel', parentNodeName); +} + +export function addMemoryNodeToParent(nodeName: string, parentNodeName: string) { + addSupplementalNodeToParent(nodeName, 'ai_memory', parentNodeName); +} + +export function addToolNodeToParent(nodeName: string, parentNodeName: string) { + addSupplementalNodeToParent(nodeName, 'ai_tool', parentNodeName); +} + +export function addOutputParserNodeToParent(nodeName: string, parentNodeName: string) { + addSupplementalNodeToParent(nodeName, 'ai_outputParser', parentNodeName); +} + +export function clickExecuteWorkflowButton() { + getExecuteWorkflowButton().click(); +} + +export function clickManualChatButton() { + getManualChatButton().click(); + getManualChatModal().should('be.visible'); +} + +export function openNode(nodeName: string) { + getNodeByName(nodeName).dblclick(); +} diff --git a/cypress/constants.ts b/cypress/constants.ts index 352dbb36c3..28dfc43cf3 100644 --- a/cypress/constants.ts +++ b/cypress/constants.ts @@ -29,6 +29,7 @@ export const INSTANCE_MEMBERS = [ export const MANUAL_TRIGGER_NODE_NAME = 'Manual Trigger'; export const MANUAL_TRIGGER_NODE_DISPLAY_NAME = 'When clicking "Execute Workflow"'; +export const MANUAL_CHAT_TRIGGER_NODE_NAME = 'Manual Chat Trigger'; export const SCHEDULE_TRIGGER_NODE_NAME = 'Schedule Trigger'; export const CODE_NODE_NAME = 'Code'; export const SET_NODE_NAME = 'Set'; @@ -41,6 +42,14 @@ export const TRELLO_NODE_NAME = 'Trello'; export const NOTION_NODE_NAME = 'Notion'; export const PIPEDRIVE_NODE_NAME = 'Pipedrive'; export const HTTP_REQUEST_NODE_NAME = 'HTTP Request'; +export const AGENT_NODE_NAME = 'AI Agent'; +export const BASIC_LLM_CHAIN_NODE_NAME = 'Basic LLM Chain'; +export const AI_MEMORY_WINDOW_BUFFER_MEMORY_NODE_NAME = 'Window Buffer Memory'; +export const AI_TOOL_CALCULATOR_NODE_NAME = 'Calculator'; +export const AI_TOOL_CODE_NODE_NAME = 'Custom Code Tool'; +export const AI_TOOL_WIKIPEDIA_NODE_NAME = 'Wikipedia'; +export const AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME = 'OpenAI Chat Model'; +export const AI_OUTPUT_PARSER_AUTO_FIXING_NODE_NAME = 'Auto-fixing Output Parser'; export const META_KEY = Cypress.platform === 'darwin' ? '{meta}' : '{ctrl}'; @@ -48,3 +57,7 @@ export const NEW_GOOGLE_ACCOUNT_NAME = 'Gmail account'; export const NEW_TRELLO_ACCOUNT_NAME = 'Trello account'; export const NEW_NOTION_ACCOUNT_NAME = 'Notion account'; export const NEW_QUERY_AUTH_ACCOUNT_NAME = 'Query Auth account'; + +export const ROUTES = { + NEW_WORKFLOW_PAGE: '/workflow/new', +}; diff --git a/cypress/e2e/30-langchain.cy.ts b/cypress/e2e/30-langchain.cy.ts new file mode 100644 index 0000000000..9140acdef2 --- /dev/null +++ b/cypress/e2e/30-langchain.cy.ts @@ -0,0 +1,278 @@ +import { + AGENT_NODE_NAME, + MANUAL_CHAT_TRIGGER_NODE_NAME, + AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME, + MANUAL_TRIGGER_NODE_NAME, + AI_MEMORY_WINDOW_BUFFER_MEMORY_NODE_NAME, + AI_TOOL_CALCULATOR_NODE_NAME, + AI_OUTPUT_PARSER_AUTO_FIXING_NODE_NAME, + AI_TOOL_CODE_NODE_NAME, + AI_TOOL_WIKIPEDIA_NODE_NAME, + BASIC_LLM_CHAIN_NODE_NAME, +} from './../constants'; +import { createMockNodeExecutionData, runMockWorkflowExcution } from '../utils'; +import { + addLanguageModelNodeToParent, + addMemoryNodeToParent, + addNodeToCanvas, + addOutputParserNodeToParent, + addToolNodeToParent, + clickManualChatButton, + navigateToNewWorkflowPage, + openNode, +} from '../composables/workflow'; +import { + clickCreateNewCredential, + clickExecuteNode, + clickGetBackToCanvas, + getOutputPanelTable, + setParameterInputByName, +} from '../composables/ndv'; +import { setCredentialValues } from '../composables/modals/credential-modal'; +import { + closeManualChatModal, + getManualChatMessages, + getManualChatModalLogs, + getManualChatModalLogsEntries, + getManualChatModalLogsTree, + sendManualChatMessage, +} from '../composables/modals/chat-modal'; + +describe('Langchain Integration', () => { + beforeEach(() => { + navigateToNewWorkflowPage(); + }); + + it('should add nodes to all Agent node input types', () => { + addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME, true); + addNodeToCanvas(AGENT_NODE_NAME, true); + + addLanguageModelNodeToParent(AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME, AGENT_NODE_NAME); + clickGetBackToCanvas(); + + addMemoryNodeToParent(AI_MEMORY_WINDOW_BUFFER_MEMORY_NODE_NAME, AGENT_NODE_NAME); + clickGetBackToCanvas(); + + addToolNodeToParent(AI_TOOL_CALCULATOR_NODE_NAME, AGENT_NODE_NAME); + clickGetBackToCanvas(); + + addOutputParserNodeToParent(AI_OUTPUT_PARSER_AUTO_FIXING_NODE_NAME, AGENT_NODE_NAME); + clickGetBackToCanvas(); + }); + + it('should add multiple tool nodes to Agent node tool input type', () => { + addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME, true); + addNodeToCanvas(AGENT_NODE_NAME, true); + + [ + AI_TOOL_CALCULATOR_NODE_NAME, + AI_TOOL_CODE_NODE_NAME, + AI_TOOL_CODE_NODE_NAME, + AI_TOOL_CODE_NODE_NAME, + AI_TOOL_WIKIPEDIA_NODE_NAME, + ].forEach((tool) => { + addToolNodeToParent(tool, AGENT_NODE_NAME); + clickGetBackToCanvas(); + }); + }); + + it('should be able to open and execute Basic LLM Chain node', () => { + addNodeToCanvas(MANUAL_CHAT_TRIGGER_NODE_NAME, true); + addNodeToCanvas(BASIC_LLM_CHAIN_NODE_NAME, true); + + addLanguageModelNodeToParent( + AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME, + BASIC_LLM_CHAIN_NODE_NAME, + ); + + clickCreateNewCredential(); + setCredentialValues({ + apiKey: 'sk_test_123', + }); + clickGetBackToCanvas(); + + openNode(BASIC_LLM_CHAIN_NODE_NAME); + + const inputMessage = 'Hello!'; + const outputMessage = 'Hi there! How can I assist you today?'; + + setParameterInputByName('prompt', inputMessage); + + runMockWorkflowExcution({ + trigger: () => clickExecuteNode(), + runData: [ + createMockNodeExecutionData(BASIC_LLM_CHAIN_NODE_NAME, { + jsonData: { + main: { output: outputMessage }, + }, + metadata: { + subRun: [{ node: AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME, runIndex: 0 }], + }, + }), + ], + lastNodeExecuted: BASIC_LLM_CHAIN_NODE_NAME, + }); + + getOutputPanelTable().should('contain', 'output'); + getOutputPanelTable().should('contain', outputMessage); + }); + + it('should be able to open and execute Agent node', () => { + addNodeToCanvas(MANUAL_CHAT_TRIGGER_NODE_NAME, true); + addNodeToCanvas(AGENT_NODE_NAME, true); + + addLanguageModelNodeToParent(AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME, AGENT_NODE_NAME); + + clickCreateNewCredential(); + setCredentialValues({ + apiKey: 'sk_test_123', + }); + clickGetBackToCanvas(); + + openNode(AGENT_NODE_NAME); + + const inputMessage = 'Hello!'; + const outputMessage = 'Hi there! How can I assist you today?'; + + setParameterInputByName('text', inputMessage); + + runMockWorkflowExcution({ + trigger: () => clickExecuteNode(), + runData: [ + createMockNodeExecutionData(AGENT_NODE_NAME, { + jsonData: { + main: { output: outputMessage }, + }, + metadata: { + subRun: [{ node: AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME, runIndex: 0 }], + }, + }), + ], + lastNodeExecuted: AGENT_NODE_NAME, + }); + + getOutputPanelTable().should('contain', 'output'); + getOutputPanelTable().should('contain', outputMessage); + }); + + it('should add and use Manual Chat Trigger node together with Agent node', () => { + addNodeToCanvas(MANUAL_CHAT_TRIGGER_NODE_NAME, true); + addNodeToCanvas(AGENT_NODE_NAME, true); + + addLanguageModelNodeToParent(AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME, AGENT_NODE_NAME); + + clickCreateNewCredential(); + setCredentialValues({ + apiKey: 'sk_test_123', + }); + clickGetBackToCanvas(); + + clickManualChatButton(); + + getManualChatModalLogs().should('not.exist'); + + const inputMessage = 'Hello!'; + const outputMessage = 'Hi there! How can I assist you today?'; + + runMockWorkflowExcution({ + trigger: () => { + sendManualChatMessage(inputMessage); + }, + runData: [ + createMockNodeExecutionData(MANUAL_CHAT_TRIGGER_NODE_NAME, { + jsonData: { + main: { input: inputMessage }, + }, + }), + createMockNodeExecutionData(AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME, { + jsonData: { + ai_languageModel: { + response: { + generations: [ + { + text: `{ + "action": "Final Answer", + "action_input": "${outputMessage}" +}`, + message: { + lc: 1, + type: 'constructor', + id: ['langchain', 'schema', 'AIMessage'], + kwargs: { + content: `{ + "action": "Final Answer", + "action_input": "${outputMessage}" +}`, + additional_kwargs: {}, + }, + }, + generationInfo: { finish_reason: 'stop' }, + }, + ], + llmOutput: { + tokenUsage: { + completionTokens: 26, + promptTokens: 519, + totalTokens: 545, + }, + }, + }, + }, + }, + inputOverride: { + ai_languageModel: [ + [ + { + json: { + messages: [ + { + lc: 1, + type: 'constructor', + id: ['langchain', 'schema', 'SystemMessage'], + kwargs: { + content: + 'Assistant is a large language model trained by OpenAI.\n\nAssistant is designed to be able to assist with a wide range of tasks, from answering simple questions to providing in-depth explanations and discussions on a wide range of topics. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand.\n\nAssistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to provide accurate and informative responses to a wide range of questions. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in discussions and provide explanations and descriptions on a wide range of topics.\n\nOverall, Assistant is a powerful system that can help with a wide range of tasks and provide valuable insights and information on a wide range of topics. Whether you need help with a specific question or just want to have a conversation about a particular topic, Assistant is here to assist. However, above all else, all responses must adhere to the format of RESPONSE FORMAT INSTRUCTIONS.', + additional_kwargs: {}, + }, + }, + { + lc: 1, + type: 'constructor', + id: ['langchain', 'schema', 'HumanMessage'], + kwargs: { + content: + 'TOOLS\n------\nAssistant can ask the user to use tools to look up information that may be helpful in answering the users original question. The tools the human can use are:\n\n\n\nRESPONSE FORMAT INSTRUCTIONS\n----------------------------\n\nOutput a JSON markdown code snippet containing a valid JSON object in one of two formats:\n\n**Option 1:**\nUse this if you want the human to use a tool.\nMarkdown code snippet formatted in the following schema:\n\n```json\n{\n "action": string, // The action to take. Must be one of []\n "action_input": string // The input to the action. May be a stringified object.\n}\n```\n\n**Option #2:**\nUse this if you want to respond directly and conversationally to the human. Markdown code snippet formatted in the following schema:\n\n```json\n{\n "action": "Final Answer",\n "action_input": string // You should put what you want to return to use here and make sure to use valid json newline characters.\n}\n```\n\nFor both options, remember to always include the surrounding markdown code snippet delimiters (begin with "```json" and end with "```")!\n\n\nUSER\'S INPUT\n--------------------\nHere is the user\'s input (remember to respond with a markdown code snippet of a json blob with a single action, and NOTHING else):\n\nHello!', + additional_kwargs: {}, + }, + }, + ], + options: { stop: ['Observation:'], promptIndex: 0 }, + }, + }, + ], + ], + }, + }), + createMockNodeExecutionData(AGENT_NODE_NAME, { + jsonData: { + main: { output: 'Hi there! How can I assist you today?' }, + }, + metadata: { + subRun: [{ node: AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME, runIndex: 0 }], + }, + }), + ], + lastNodeExecuted: AGENT_NODE_NAME, + }); + + const messages = getManualChatMessages(); + messages.should('have.length', 2); + messages.should('contain', inputMessage); + messages.should('contain', outputMessage); + + getManualChatModalLogsTree().should('be.visible'); + getManualChatModalLogsEntries().should('have.length', 1); + + closeManualChatModal(); + }); +}); diff --git a/cypress/e2e/4-node-creator.cy.ts b/cypress/e2e/4-node-creator.cy.ts index 5deb04913b..45ad3ac1fd 100644 --- a/cypress/e2e/4-node-creator.cy.ts +++ b/cypress/e2e/4-node-creator.cy.ts @@ -34,7 +34,7 @@ describe('Node Creator', () => { nodeCreatorFeature.actions.openNodeCreator(); nodeCreatorFeature.getters.searchBar().find('input').type('manual'); - nodeCreatorFeature.getters.creatorItem().should('have.length', 1); + nodeCreatorFeature.getters.creatorItem().should('have.length', 3); nodeCreatorFeature.getters.searchBar().find('input').clear().type('manual123'); nodeCreatorFeature.getters.creatorItem().should('have.length', 0); nodeCreatorFeature.getters diff --git a/cypress/e2e/5-ndv.cy.ts b/cypress/e2e/5-ndv.cy.ts index 6043f14d93..b7711b36e8 100644 --- a/cypress/e2e/5-ndv.cy.ts +++ b/cypress/e2e/5-ndv.cy.ts @@ -1,6 +1,7 @@ import { v4 as uuid } from 'uuid'; -import { NDV, WorkflowPage } from '../pages'; import { getVisibleSelect } from '../utils'; +import { MANUAL_TRIGGER_NODE_DISPLAY_NAME } from '../constants'; +import { NDV, WorkflowPage } from '../pages'; const workflowPage = new WorkflowPage(); const ndv = new NDV(); @@ -368,6 +369,109 @@ describe('NDV', () => { cy.get('@fetchParameterOptions').should('have.been.calledOnce'); }); + describe('floating nodes', () => { + function getFloatingNodeByPosition(position: 'inputMain' | 'outputMain' | 'outputSub'| 'inputSub') { + return cy.get(`[data-node-placement=${position}]`); + } + beforeEach(() => { + cy.createFixtureWorkflow('Floating_Nodes.json', `Floating Nodes`); + workflowPage.getters.canvasNodes().first().dblclick() + getFloatingNodeByPosition("inputMain").should('not.exist'); + getFloatingNodeByPosition("outputMain").should('exist'); + }); + + it('should traverse floating nodes with mouse', () => { + // Traverse 4 connected node forwards + Array.from(Array(4).keys()).forEach(i => { + getFloatingNodeByPosition("outputMain").click({ force: true}); + ndv.getters.nodeNameContainer().should('contain', `Node ${i + 1}`); + getFloatingNodeByPosition("inputMain").should('exist'); + getFloatingNodeByPosition("outputMain").should('exist'); + ndv.actions.close(); + workflowPage.getters.selectedNodes().should('have.length', 1); + workflowPage.getters.selectedNodes().first().should('contain', `Node ${i + 1}`); + workflowPage.getters.selectedNodes().first().dblclick(); + }) + + getFloatingNodeByPosition("outputMain").click({ force: true}); + ndv.getters.nodeNameContainer().should('contain', 'Chain'); + getFloatingNodeByPosition("inputSub").should('exist'); + getFloatingNodeByPosition("inputSub").click({ force: true}); + ndv.getters.nodeNameContainer().should('contain', 'Model'); + getFloatingNodeByPosition("inputSub").should('not.exist'); + getFloatingNodeByPosition("inputMain").should('not.exist'); + getFloatingNodeByPosition("outputMain").should('not.exist'); + getFloatingNodeByPosition("outputSub").should('exist'); + ndv.actions.close(); + workflowPage.getters.selectedNodes().should('have.length', 1); + workflowPage.getters.selectedNodes().first().should('contain', 'Model'); + workflowPage.getters.selectedNodes().first().dblclick(); + getFloatingNodeByPosition("outputSub").click({ force: true}); + ndv.getters.nodeNameContainer().should('contain', 'Chain'); + + // Traverse 4 connected node backwards + Array.from(Array(4).keys()).forEach(i => { + getFloatingNodeByPosition("inputMain").click({ force: true}); + ndv.getters.nodeNameContainer().should('contain', `Node ${4 - (i)}`); + getFloatingNodeByPosition("outputMain").should('exist'); + getFloatingNodeByPosition("inputMain").should('exist'); + }) + getFloatingNodeByPosition("inputMain").click({ force: true}); + workflowPage.getters.selectedNodes().first().should('contain', MANUAL_TRIGGER_NODE_DISPLAY_NAME); + getFloatingNodeByPosition("inputMain").should('not.exist'); + getFloatingNodeByPosition("inputSub").should('not.exist'); + getFloatingNodeByPosition("outputSub").should('not.exist'); + ndv.actions.close(); + workflowPage.getters.selectedNodes().should('have.length', 1); + workflowPage.getters.selectedNodes().first().should('contain', MANUAL_TRIGGER_NODE_DISPLAY_NAME); + }); + + it('should traverse floating nodes with mouse', () => { + // Traverse 4 connected node forwards + Array.from(Array(4).keys()).forEach(i => { + cy.realPress(['ShiftLeft', 'Meta', 'AltLeft', 'ArrowRight']) + ndv.getters.nodeNameContainer().should('contain', `Node ${i + 1}`); + getFloatingNodeByPosition("inputMain").should('exist'); + getFloatingNodeByPosition("outputMain").should('exist'); + ndv.actions.close(); + workflowPage.getters.selectedNodes().should('have.length', 1); + workflowPage.getters.selectedNodes().first().should('contain', `Node ${i + 1}`); + workflowPage.getters.selectedNodes().first().dblclick(); + }) + + cy.realPress(['ShiftLeft', 'Meta', 'AltLeft', 'ArrowRight']) + ndv.getters.nodeNameContainer().should('contain', 'Chain'); + getFloatingNodeByPosition("inputSub").should('exist'); + cy.realPress(['ShiftLeft', 'Meta', 'AltLeft', 'ArrowDown']) + ndv.getters.nodeNameContainer().should('contain', 'Model'); + getFloatingNodeByPosition("inputSub").should('not.exist'); + getFloatingNodeByPosition("inputMain").should('not.exist'); + getFloatingNodeByPosition("outputMain").should('not.exist'); + getFloatingNodeByPosition("outputSub").should('exist'); + ndv.actions.close(); + workflowPage.getters.selectedNodes().should('have.length', 1); + workflowPage.getters.selectedNodes().first().should('contain', 'Model'); + workflowPage.getters.selectedNodes().first().dblclick(); + cy.realPress(['ShiftLeft', 'Meta', 'AltLeft', 'ArrowUp']) + ndv.getters.nodeNameContainer().should('contain', 'Chain'); + + // Traverse 4 connected node backwards + Array.from(Array(4).keys()).forEach(i => { + cy.realPress(['ShiftLeft', 'Meta', 'AltLeft', 'ArrowLeft']) + ndv.getters.nodeNameContainer().should('contain', `Node ${4 - (i)}`); + getFloatingNodeByPosition("outputMain").should('exist'); + getFloatingNodeByPosition("inputMain").should('exist'); + }) + cy.realPress(['ShiftLeft', 'Meta', 'AltLeft', 'ArrowLeft']) + workflowPage.getters.selectedNodes().first().should('contain', MANUAL_TRIGGER_NODE_DISPLAY_NAME); + getFloatingNodeByPosition("inputMain").should('not.exist'); + getFloatingNodeByPosition("inputSub").should('not.exist'); + getFloatingNodeByPosition("outputSub").should('not.exist'); + ndv.actions.close(); + workflowPage.getters.selectedNodes().should('have.length', 1); + workflowPage.getters.selectedNodes().first().should('contain', MANUAL_TRIGGER_NODE_DISPLAY_NAME); + }); + }) it('should show node name and version in settings', () => { cy.createFixtureWorkflow('Test_workflow_ndv_version.json', `NDV test version ${uuid()}`); diff --git a/cypress/fixtures/Floating_Nodes.json b/cypress/fixtures/Floating_Nodes.json new file mode 100644 index 0000000000..baecac16d2 --- /dev/null +++ b/cypress/fixtures/Floating_Nodes.json @@ -0,0 +1,176 @@ +{ + "name": "Floating Nodes", + "nodes": [ + { + "parameters": {}, + "id": "d0eda550-2526-42a1-aa19-dee411c8acf9", + "name": "When clicking \"Execute Workflow\"", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + 700, + 560 + ] + }, + { + "parameters": { + "options": {} + }, + "id": "30412165-1229-4b21-9890-05bfbd9952ab", + "name": "Node 1", + "type": "n8n-nodes-base.set", + "typeVersion": 3.2, + "position": [ + 920, + 560 + ] + }, + { + "parameters": { + "options": {} + }, + "id": "201cc8fc-3124-47a3-bc08-b3917c1ddcd9", + "name": "Node 2", + "type": "n8n-nodes-base.set", + "typeVersion": 3.2, + "position": [ + 1100, + 560 + ] + }, + { + "parameters": { + "options": {} + }, + "id": "a29802bb-a284-495d-9917-6c6e42fef01e", + "name": "Node 3", + "type": "n8n-nodes-base.set", + "typeVersion": 3.2, + "position": [ + 1280, + 560 + ] + }, + { + "parameters": { + "options": {} + }, + "id": "a95a72b3-8b39-44e2-a05b-d8d677741c80", + "name": "Node 4", + "type": "n8n-nodes-base.set", + "typeVersion": 3.2, + "position": [ + 1440, + 560 + ] + }, + { + "parameters": {}, + "id": "4674f10d-6144-4a17-bbbb-350c3974438e", + "name": "Chain", + "type": "@n8n/n8n-nodes-langchain.chainLlm", + "typeVersion": 1, + "position": [ + 1580, + 560 + ] + }, + { + "parameters": { + "options": {} + }, + "id": "58e12ea5-bd3e-4abf-abec-fcfb5c0a7955", + "name": "Model", + "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi", + "typeVersion": 1, + "position": [ + 1600, + 740 + ] + } + ], + "pinData": {}, + "connections": { + "When clicking \"Execute Workflow\"": { + "main": [ + [ + { + "node": "Node 1", + "type": "main", + "index": 0 + } + ] + ] + }, + "Node 1": { + "main": [ + [ + { + "node": "Node 2", + "type": "main", + "index": 0 + } + ] + ] + }, + "Node 3": { + "main": [ + [ + { + "node": "Node 4", + "type": "main", + "index": 0 + } + ] + ] + }, + "Node 2": { + "main": [ + [ + { + "node": "Node 3", + "type": "main", + "index": 0 + } + ] + ] + }, + "Chain": { + "main": [ + [] + ] + }, + "Model": { + "ai_languageModel": [ + [ + { + "node": "Chain", + "type": "ai_languageModel", + "index": 0 + } + ] + ] + }, + "Node 4": { + "main": [ + [ + { + "node": "Chain", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1" + }, + "versionId": "2730d156-a98a-4ac8-b481-5c16361fdba2", + "id": "6bzXMGxHuxeEaqsA", + "meta": { + "instanceId": "1838be0fa0389fbaf5e2e4aaedab4ddc79abc4175b433401abb22a281001b853" + }, + "tags": [] +} diff --git a/cypress/pages/workflow.ts b/cypress/pages/workflow.ts index a33ad0faa4..7613167f5e 100644 --- a/cypress/pages/workflow.ts +++ b/cypress/pages/workflow.ts @@ -2,7 +2,6 @@ import { META_KEY } from '../constants'; import { BasePage } from './base'; import { getVisibleSelect } from '../utils'; import { NodeCreator } from './features/node-creator'; -import Chainable = Cypress.Chainable; const nodeCreator = new NodeCreator(); export class WorkflowPage extends BasePage { diff --git a/cypress/utils/executions.ts b/cypress/utils/executions.ts new file mode 100644 index 0000000000..81748af505 --- /dev/null +++ b/cypress/utils/executions.ts @@ -0,0 +1,135 @@ +import { ITaskData } from '../../packages/workflow/src'; +import { IPinData } from '../../packages/workflow'; +import { clickExecuteWorkflowButton } from '../composables/workflow'; + +export function createMockNodeExecutionData( + name: string, + { + data, + inputOverride, + executionStatus = 'success', + jsonData, + ...rest + }: Partial & { jsonData?: Record }, +): Record { + return { + [name]: { + startTime: new Date().getTime(), + executionTime: 0, + executionStatus, + data: jsonData + ? Object.keys(jsonData).reduce((acc, key) => { + acc[key] = [ + [ + { + json: jsonData[key], + pairedItem: { item: 0 }, + }, + ], + ]; + + return acc; + }, {}) + : data, + source: [null], + ...rest, + }, + }; +} + +export function createMockWorkflowExecutionData({ + executionId, + runData, + pinData = {}, + lastNodeExecuted, +}: { + executionId: string; + runData: Record; + pinData?: IPinData; + lastNodeExecuted: string; +}) { + return { + executionId, + data: { + data: { + startData: {}, + resultData: { + runData, + pinData, + lastNodeExecuted, + }, + executionData: { + contextData: {}, + nodeExecutionStack: [], + metadata: {}, + waitingExecution: {}, + waitingExecutionSource: {}, + }, + }, + mode: 'manual', + startedAt: new Date().toISOString(), + stoppedAt: new Date().toISOString(), + status: 'success', + finished: true, + }, + }; +} + +export function runMockWorkflowExcution({ + trigger, + lastNodeExecuted, + runData, + workflowExecutionData, +}: { + trigger?: () => void; + lastNodeExecuted: string; + runData: Array>; + workflowExecutionData?: ReturnType; +}) { + const executionId = Math.random().toString(36).substring(4); + + cy.intercept('POST', '/rest/workflows/run', { + statusCode: 201, + body: { + data: { + executionId, + }, + }, + }).as('runWorkflow'); + + if (trigger) { + trigger(); + } else { + clickExecuteWorkflowButton(); + } + + cy.wait('@runWorkflow'); + + const resolvedRunData = {}; + runData.forEach((nodeExecution) => { + const nodeName = Object.keys(nodeExecution)[0]; + const nodeRunData = nodeExecution[nodeName]; + + cy.push('nodeExecuteBefore', { + executionId, + nodeName, + }); + cy.push('nodeExecuteAfter', { + executionId, + nodeName, + data: nodeRunData, + }); + + resolvedRunData[nodeName] = nodeExecution[nodeName]; + }); + + cy.push( + 'executionFinished', + createMockWorkflowExecutionData({ + executionId, + lastNodeExecuted, + runData: resolvedRunData, + ...workflowExecutionData, + }), + ); +} diff --git a/cypress/utils/index.ts b/cypress/utils/index.ts index 1929454b18..3cfa5a7449 100644 --- a/cypress/utils/index.ts +++ b/cypress/utils/index.ts @@ -1 +1,3 @@ +export * from './executions'; +export * from './modal'; export * from './popper'; diff --git a/package.json b/package.json index 9348b49f1a..3a4af9d630 100644 --- a/package.json +++ b/package.json @@ -11,10 +11,11 @@ "scripts": { "preinstall": "node scripts/block-npm-install.js", "build": "turbo run build", - "build:backend": "pnpm --filter=!n8n-design-system --filter=!n8n-editor-ui build", - "build:frontend": "pnpm --filter=n8n-design-system --filter=n8n-editor-ui build", + "build:backend": "pnpm --filter=!@n8n/chat --filter=!n8n-design-system --filter=!n8n-editor-ui build", + "build:frontend": "pnpm --filter=@n8n/chat --filter=n8n-design-system --filter=n8n-editor-ui build", "typecheck": "turbo run typecheck", "dev": "turbo run dev --parallel", + "dev:ai": "turbo run dev --parallel --filter=@n8n/nodes-langchain --filter=n8n --filter=n8n-core", "clean": "turbo run clean --parallel", "format": "turbo run format && node scripts/format.mjs", "lint": "turbo run lint", @@ -25,9 +26,9 @@ "start:tunnel": "./packages/cli/bin/n8n start --tunnel", "start:windows": "cd packages/cli/bin && n8n", "test": "turbo run test", - "test:backend": "pnpm --filter=!n8n-design-system --filter=!n8n-editor-ui --filter=!n8n-nodes-base test", + "test:backend": "pnpm --filter=!@n8n/chat --filter=!n8n-design-system --filter=!n8n-editor-ui --filter=!n8n-nodes-base test", "test:nodes": "pnpm --filter=n8n-nodes-base test", - "test:frontend": "pnpm --filter=n8n-design-system --filter=n8n-editor-ui test", + "test:frontend": "pnpm --filter=@n8n/chat --filter=n8n-design-system --filter=n8n-editor-ui test", "watch": "turbo run watch", "webhook": "./packages/cli/bin/n8n webhook", "worker": "./packages/cli/bin/n8n worker", diff --git a/packages/@n8n/chat/.eslintignore b/packages/@n8n/chat/.eslintignore new file mode 100644 index 0000000000..40a7b4122b --- /dev/null +++ b/packages/@n8n/chat/.eslintignore @@ -0,0 +1,2 @@ +.eslintrc.cjs +vitest.config.ts diff --git a/packages/@n8n/chat/.eslintrc.cjs b/packages/@n8n/chat/.eslintrc.cjs new file mode 100644 index 0000000000..45e532bdee --- /dev/null +++ b/packages/@n8n/chat/.eslintrc.cjs @@ -0,0 +1,53 @@ +const sharedOptions = require('@n8n_io/eslint-config/shared'); + +/** + * @type {import('@types/eslint').ESLint.ConfigData} + */ +module.exports = { + extends: ['@n8n_io/eslint-config/frontend'], + + ...sharedOptions(__dirname, 'frontend'), + + rules: { + // TODO: Remove these + 'id-denylist': 'warn', + 'import/extensions': 'warn', + 'import/no-default-export': 'warn', + 'import/no-extraneous-dependencies': 'warn', + 'import/order': 'off', + 'import/no-cycle': 'warn', + 'import/no-duplicates': 'warn', + '@typescript-eslint/ban-types': 'warn', + '@typescript-eslint/dot-notation': 'warn', + '@typescript-eslint/lines-between-class-members': 'warn', + '@typescript-eslint/member-delimiter-style': 'warn', + '@typescript-eslint/naming-convention': 'warn', + '@typescript-eslint/no-empty-interface': 'warn', + '@typescript-eslint/no-for-in-array': 'warn', + '@typescript-eslint/no-loop-func': 'warn', + '@typescript-eslint/no-non-null-assertion': 'warn', + '@typescript-eslint/no-shadow': 'warn', + '@typescript-eslint/no-this-alias': 'warn', + '@typescript-eslint/no-unnecessary-boolean-literal-compare': 'warn', + '@typescript-eslint/no-unnecessary-type-assertion': 'warn', + '@typescript-eslint/no-unsafe-argument': 'warn', + '@typescript-eslint/no-unsafe-assignment': 'warn', + '@typescript-eslint/no-unsafe-call': 'warn', + '@typescript-eslint/no-unsafe-member-access': 'warn', + '@typescript-eslint/no-unsafe-return': 'warn', + '@typescript-eslint/no-unused-expressions': 'warn', + '@typescript-eslint/no-unused-vars': 'warn', + '@typescript-eslint/no-use-before-define': 'warn', + '@typescript-eslint/no-var-requires': 'warn', + '@typescript-eslint/prefer-nullish-coalescing': 'warn', + '@typescript-eslint/prefer-optional-chain': 'warn', + '@typescript-eslint/restrict-plus-operands': 'warn', + '@typescript-eslint/restrict-template-expressions': 'warn', + '@typescript-eslint/unbound-method': 'warn', + '@typescript-eslint/ban-ts-comment': ['warn', { 'ts-ignore': true }], + '@typescript-eslint/no-redundant-type-constituents': 'warn', + '@typescript-eslint/no-base-to-string': 'warn', + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-unsafe-enum-comparison': 'warn', + }, +}; diff --git a/packages/@n8n/chat/.gitignore b/packages/@n8n/chat/.gitignore new file mode 100644 index 0000000000..38adffa64e --- /dev/null +++ b/packages/@n8n/chat/.gitignore @@ -0,0 +1,28 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +.DS_Store +dist +dist-ssr +coverage +*.local + +/cypress/videos/ +/cypress/screenshots/ + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/packages/@n8n/chat/.np-config.json b/packages/@n8n/chat/.np-config.json new file mode 100644 index 0000000000..5b1cb0a96a --- /dev/null +++ b/packages/@n8n/chat/.np-config.json @@ -0,0 +1,5 @@ +{ + "yarn": false, + "tests": false, + "contents": "./dist" +} diff --git a/packages/@n8n/chat/.storybook/main.ts b/packages/@n8n/chat/.storybook/main.ts new file mode 100644 index 0000000000..c826aea088 --- /dev/null +++ b/packages/@n8n/chat/.storybook/main.ts @@ -0,0 +1,27 @@ +import type { StorybookConfig } from '@storybook/vue3-vite'; + +import { join, dirname } from 'path'; + +/** + * This function is used to resolve the absolute path of a package. + * It is needed in projects that use Yarn PnP or are set up within a monorepo. + */ +function getAbsolutePath(value: string): any { + return dirname(require.resolve(join(value, 'package.json'))); +} +const config: StorybookConfig = { + stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], + addons: [ + getAbsolutePath('@storybook/addon-links'), + getAbsolutePath('@storybook/addon-essentials'), + getAbsolutePath('@storybook/addon-interactions'), + ], + framework: { + name: getAbsolutePath('@storybook/vue3-vite'), + options: {}, + }, + docs: { + autodocs: 'tag', + }, +}; +export default config; diff --git a/packages/@n8n/chat/.storybook/preview.scss b/packages/@n8n/chat/.storybook/preview.scss new file mode 100644 index 0000000000..abaf406f8d --- /dev/null +++ b/packages/@n8n/chat/.storybook/preview.scss @@ -0,0 +1,4 @@ +html, body, #storybook-root, #n8n-chat { + width: 100%; + height: 100%; +} diff --git a/packages/@n8n/chat/.storybook/preview.ts b/packages/@n8n/chat/.storybook/preview.ts new file mode 100644 index 0000000000..0a92b65cb2 --- /dev/null +++ b/packages/@n8n/chat/.storybook/preview.ts @@ -0,0 +1,16 @@ +import type { Preview } from '@storybook/vue3'; +import './preview.scss'; + +const preview: Preview = { + parameters: { + actions: { argTypesRegex: '^on[A-Z].*' }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/, + }, + }, + }, +}; + +export default preview; diff --git a/packages/@n8n/chat/.vscode/extensions.json b/packages/@n8n/chat/.vscode/extensions.json new file mode 100644 index 0000000000..c0a6e5a481 --- /dev/null +++ b/packages/@n8n/chat/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"] +} diff --git a/packages/@n8n/chat/LICENSE.md b/packages/@n8n/chat/LICENSE.md new file mode 100644 index 0000000000..c1d7423975 --- /dev/null +++ b/packages/@n8n/chat/LICENSE.md @@ -0,0 +1,85 @@ +# License + +Portions of this software are licensed as follows: + +- Content of branches other than the main branch (i.e. "master") are not licensed. +- All source code files that contain ".ee." in their filename are licensed under the + "n8n Enterprise License" defined in "LICENSE_EE.md". +- All third party components incorporated into the n8n Software are licensed under the original license + provided by the owner of the applicable component. +- Content outside of the above mentioned files or restrictions is available under the "Sustainable Use + License" as defined below. + +## Sustainable Use License + +Version 1.0 + +### Acceptance + +By using the software, you agree to all of the terms and conditions below. + +### Copyright License + +The licensor grants you a non-exclusive, royalty-free, worldwide, non-sublicensable, non-transferable license +to use, copy, distribute, make available, and prepare derivative works of the software, in each case subject +to the limitations below. + +### Limitations + +You may use or modify the software only for your own internal business purposes or for non-commercial or +personal use. You may distribute the software or provide it to others only if you do so free of charge for +non-commercial purposes. You may not alter, remove, or obscure any licensing, copyright, or other notices of +the licensor in the software. Any use of the licensor’s trademarks is subject to applicable law. + +### Patents + +The licensor grants you a license, under any patent claims the licensor can license, or becomes able to +license, to make, have made, use, sell, offer for sale, import and have imported the software, in each case +subject to the limitations and conditions in this license. This license does not cover any patent claims that +you cause to be infringed by modifications or additions to the software. If you or your company make any +written claim that the software infringes or contributes to infringement of any patent, your patent license +for the software granted under these terms ends immediately. If your company makes such a claim, your patent +license ends immediately for work on behalf of your company. + +### Notices + +You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these +terms. If you modify the software, you must include in any modified copies of the software a prominent notice +stating that you have modified the software. + +### No Other Rights + +These terms do not imply any licenses other than those expressly granted in these terms. + +### Termination + +If you use the software in violation of these terms, such use is not licensed, and your license will +automatically terminate. If the licensor provides you with a notice of your violation, and you cease all +violation of this license no later than 30 days after you receive that notice, your license will be reinstated +retroactively. However, if you violate these terms after such reinstatement, any additional violation of these +terms will cause your license to terminate automatically and permanently. + +### No Liability + +As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will +not be liable to you for any damages arising out of these terms or the use or nature of the software, under +any kind of legal claim. + +### Definitions + +The “licensor” is the entity offering these terms. + +The “software” is the software the licensor makes available under these terms, including any portion of it. + +“You” refers to the individual or entity agreeing to these terms. + +“Your company” is any legal entity, sole proprietorship, or other kind of organization that you work for, plus +all organizations that have control over, are under the control of, or are under common control with that +organization. Control means ownership of substantially all the assets of an entity, or the power to direct its +management and policies by vote, contract, or otherwise. Control can be direct or indirect. + +“Your license” is the license granted to you for the software under these terms. + +“Use” means anything you do with the software requiring your license. + +“Trademark” means trademarks, service marks, and similar rights. diff --git a/packages/@n8n/chat/README.md b/packages/@n8n/chat/README.md new file mode 100644 index 0000000000..2c62798b93 --- /dev/null +++ b/packages/@n8n/chat/README.md @@ -0,0 +1,227 @@ +# n8n Chat +This is an embeddable Chat widget for n8n. It allows the execution of AI-Powered Workflows through a Chat window. + +## Prerequisites +Create a n8n workflow which you want to execute via chat. The workflow has to be triggered using a **Webhook** node and return data using the **Respond to Webhook** node. + +Open the **Webhook** node and add your domain to the **Domain Allowlist** field. This makes sure that only requests from your domain are accepted. + +[See example workflow](https://github.com/n8n-io/n8n/blob/ai-beta/packages/%40n8n/chat/resources/workflow.json) + +> Make sure the workflow is **Active.** + +### How it works +Each Chat request is sent to the n8n Webhook endpoint, which then sends back a response. + +Each request is accompanied by an `action` query parameter, where `action` can be one of: +- `loadPreviousSession` - When the user opens the Chatbot again and the previous chat session should be loaded +- `sendMessage` - When the user sends a message + +We use the `Switch` node to handle the different actions. + +## Installation + +Open the **Webhook** node and replace `YOUR_PRODUCTION_WEBHOOK_URL` with your production URL. This is the URL that the Chat widget will use to send requests to. + +### a. CDN Embed +Add the following code to your HTML page. + +```html + + +``` + +### b. Import Embed +Install and save n8n Chat as a production dependency. + +```sh +npm install @n8n/chat +``` + +Import the CSS and use the `createChat` function to initialize your Chat window. + +```ts +import '@n8n/chat/style.css'; +import { createChat } from '@n8n/chat'; + +createChat({ + webhookUrl: 'YOUR_PRODUCTION_WEBHOOK_URL' +}); +``` + +##### Vue.js + +```html + + +``` + +##### React + +```tsx +// App.tsx +import { useEffect } from 'react'; +import '@n8n/chat/style.css'; +import { createChat } from '@n8n/chat'; + +export const App = () => { + useEffect(() => { + createChat({ + webhookUrl: 'YOUR_PRODUCTION_WEBHOOK_URL' + }); + }, []); + + return (
); +}; +``` + +## Options +The default options are: + +```ts +createChat({ + webhookUrl: '', + webhookConfig: { + method: 'POST', + headers: {} + }, + target: '#n8n-chat', + mode: 'window', + defaultLanguage: 'en', + initialMessages: [ + 'Hi there! 👋', + 'My name is Nathan. How can I assist you today?' + ], + i18n: { + en: { + title: 'Hi there! 👋', + subtitle: "Start a chat. We're here to help you 24/7.", + footer: '', + getStarted: 'New Conversation', + inputPlaceholder: 'Type your question..', + }, + }, +}); +``` + +### `webhookUrl` +- **Type**: `string` +- **Required**: `true` +- **Examples**: + - `https://yourname.app.n8n.cloud/webhook/513107b3-6f3a-4a1e-af21-659f0ed14183` + - `http://localhost:5678/webhook/513107b3-6f3a-4a1e-af21-659f0ed14183` +- **Description**: The URL of the n8n Webhook endpoint. Should be the production URL. + +### `webhookConfig` +- **Type**: `{ method: string, headers: Record }` +- **Default**: `{ method: 'POST', headers: {} }` +- **Description**: The configuration for the Webhook request. + +### `target` +- **Type**: `string` +- **Default**: `'#n8n-chat'` +- **Description**: The CSS selector of the element where the Chat window should be embedded. + +### `mode` +- **Type**: `'window' | 'fullscreen'` +- **Default**: `'window'` +- **Description**: The render mode of the Chat window. + - In `window` mode, the Chat window will be embedded in the target element as a chat toggle button and a fixed size chat window. + - In `fullscreen` mode, the Chat will take up the entire width and height of its target container. + +### `defaultLanguage` +- **Type**: `string` +- **Default**: `'en'` +- **Description**: The default language of the Chat window. Currently only `en` is supported. + +### `i18n` +- **Type**: `{ [key: string]: Record }` +- **Description**: The i18n configuration for the Chat window. Currently only `en` is supported. + +### `initialMessages` +- **Type**: `string[]` +- **Description**: The initial messages to be displayed in the Chat window. + +## Customization +The Chat window is entirely customizable using CSS variables. + +```css +:root { + --chat--color-primary: #e74266; + --chat--color-primary-shade-50: #db4061; + --chat--color-primary-shade-100: #cf3c5c; + --chat--color-secondary: #20b69e; + --chat--color-secondary-shade-50: #1ca08a; + --chat--color-white: #ffffff; + --chat--color-light: #f2f4f8; + --chat--color-light-shade-50: #e6e9f1; + --chat--color-light-shade-100: #c2c5cc; + --chat--color-medium: #d2d4d9; + --chat--color-dark: #101330; + --chat--color-disabled: #777980; + --chat--color-typing: #404040; + + --chat--spacing: 1rem; + --chat--border-radius: 0.25rem; + --chat--transition-duration: 0.15s; + + --chat--window--width: 400px; + --chat--window--height: 600px; + + --chat--textarea--height: 50px; + + --chat--message--bot--background: var(--chat--color-white); + --chat--message--bot--color: var(--chat--color-dark); + --chat--message--user--background: var(--chat--color-secondary); + --chat--message--user--color: var(--chat--color-white); + --chat--message--pre--background: rgba(0, 0, 0, 0.05); + + --chat--toggle--background: var(--chat--color-primary); + --chat--toggle--hover--background: var(--chat--color-primary-shade-50); + --chat--toggle--active--background: var(--chat--color-primary-shade-100); + --chat--toggle--color: var(--chat--color-white); + --chat--toggle--size: 64px; +} +``` + +## Caveats + +### Fullscreen mode +In fullscreen mode, the Chat window will take up the entire width and height of its target container. Make sure that the container has a set width and height. + +```css +html, +body, +#n8n-chat { + width: 100%; + height: 100%; +} +``` + +## License +n8n Chat is [fair-code](http://faircode.io) distributed under the +[**Sustainable Use License**](https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md). + +Proprietary licenses are available for enterprise customers. [Get in touch](mailto:license@n8n.io) + +Additional information about the license model can be found in the +[docs](https://docs.n8n.io/reference/license/). diff --git a/packages/@n8n/chat/env.d.ts b/packages/@n8n/chat/env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/packages/@n8n/chat/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/@n8n/chat/index.html b/packages/@n8n/chat/index.html new file mode 100644 index 0000000000..a888544898 --- /dev/null +++ b/packages/@n8n/chat/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite App + + +
+ + + diff --git a/packages/@n8n/chat/package.json b/packages/@n8n/chat/package.json new file mode 100644 index 0000000000..ad165479b9 --- /dev/null +++ b/packages/@n8n/chat/package.json @@ -0,0 +1,83 @@ +{ + "name": "@n8n/chat", + "version": "0.1.5", + "scripts": { + "dev": "npm run storybook", + "build": "run-p type-check build:vite && npm run build:prepare", + "build:vite": "vite build && npm run build:vite:full", + "build:vite:full": "INCLUDE_VUE=true vite build", + "build:prepare": "node scripts/postbuild.js", + "build:pack": "node scripts/pack.js", + "preview": "vite preview", + "test:dev": "vitest", + "test": "vitest run --coverage", + "type-check": "vue-tsc --noEmit -p tsconfig.json --composite false", + "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore --ignore-path .eslintignore", + "format": "prettier --write src/", + "storybook": "storybook dev -p 6006 --no-open", + "build:storybook": "storybook build", + "release": "pnpm run build && cd dist && pnpm publish" + }, + "main": "./chat.umd.cjs", + "module": "./chat.es.js", + "types": "./types/index.d.ts", + "exports": { + ".": { + "import": "./chat.es.js", + "require": "./chat.umd.cjs" + }, + "./style.css": { + "import": "./style.css", + "require": "./style.css" + } + }, + "dependencies": { + "highlight.js": "^11.8.0", + "uuid": "^8.3.2", + "vue": "^3.3.4", + "vue-markdown-render": "^2.0.1" + }, + "devDependencies": { + "@iconify-json/mdi": "^1.1.54", + "@rushstack/eslint-patch": "^1.3.2", + "@storybook/addon-essentials": "^7.4.0", + "@storybook/addon-interactions": "^7.4.0", + "@storybook/addon-links": "^7.4.0", + "@storybook/blocks": "^7.4.0", + "@storybook/testing-library": "^0.2.0", + "@storybook/vue3": "^7.4.0", + "@storybook/vue3-vite": "^7.4.0", + "@testing-library/jest-dom": "^5.17.0", + "@testing-library/vue": "^7.0.0", + "@tsconfig/node18": "^18.2.0", + "@types/jsdom": "^21.1.1", + "@types/markdown-it": "^12.2.3", + "@types/node": "^18.17.0", + "@vitejs/plugin-vue": "^4.2.3", + "@vue/eslint-config-prettier": "^8.0.0", + "@vue/eslint-config-typescript": "^11.0.3", + "@vue/test-utils": "^2.4.1", + "@vue/tsconfig": "^0.4.0", + "eslint": "^8.45.0", + "eslint-plugin-vue": "^9.15.1", + "jsdom": "^22.1.0", + "npm-run-all": "^4.1.5", + "prettier": "^3.0.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "shelljs": "^0.8.5", + "storybook": "^7.4.0", + "typescript": "~5.1.6", + "unplugin-icons": "^0.17.0", + "vite": "^4.4.6", + "vite-plugin-dts": "^3.6.0", + "vitest": "^0.33.0", + "vue-tsc": "^1.8.6" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/n8n-io/n8n.git" + }, + "license": "SEE LICENSE IN LICENSE.md", + "homepage": "https://n8n.io" +} diff --git a/packages/@n8n/chat/public/favicon.ico b/packages/@n8n/chat/public/favicon.ico new file mode 100644 index 0000000000..df36fcfb72 Binary files /dev/null and b/packages/@n8n/chat/public/favicon.ico differ diff --git a/packages/@n8n/chat/resources/workflow.json b/packages/@n8n/chat/resources/workflow.json new file mode 100644 index 0000000000..f063f4d992 --- /dev/null +++ b/packages/@n8n/chat/resources/workflow.json @@ -0,0 +1,293 @@ +{ + "name": "AI Webhook Chat", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "513107b3-6f3a-4a1e-af21-659f0ed14183", + "responseMode": "responseNode", + "options": { + "domainAllowlist": "*.localhost" + } + }, + "id": "51ab2689-647d-4cff-9d6f-0ba4df45e904", + "name": "Webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 1, + "position": [ + 900, + 200 + ], + "webhookId": "513107b3-6f3a-4a1e-af21-659f0ed14183" + }, + { + "parameters": { + "options": {} + }, + "id": "3c7fd563-f610-41fa-b198-7fcf100e2815", + "name": "Chat OpenAI", + "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi", + "typeVersion": 1, + "position": [ + 1720, + 620 + ], + "credentials": { + "openAiApi": { + "id": "B5Fiv70Adfg6htxn", + "name": "Alex's OpenAI Account" + } + } + }, + { + "parameters": { + "sessionKey": "={{ $json.body.sessionId }}" + }, + "id": "ebc23ffa-3bcf-494f-bcb8-51a5fff91885", + "name": "Window Buffer Memory", + "type": "@n8n/n8n-nodes-langchain.memoryBufferWindow", + "typeVersion": 1, + "position": [ + 1920, + 620 + ] + }, + { + "parameters": { + "simplifyOutput": false + }, + "id": "d6721a60-159b-4a93-ac6b-b81e16d9f16f", + "name": "Memory Chat Retriever", + "type": "@n8n/n8n-nodes-langchain.memoryChatRetriever", + "typeVersion": 1, + "position": [ + 1780, + -40 + ] + }, + { + "parameters": { + "sessionKey": "={{ $json.body.sessionId }}" + }, + "id": "347edc3a-1dda-4996-b778-dcdc447ecfd8", + "name": "Memory Chat Retriever Window Buffer Memory", + "type": "@n8n/n8n-nodes-langchain.memoryBufferWindow", + "typeVersion": 1, + "position": [ + 1800, + 160 + ] + }, + { + "parameters": { + "options": { + "responseCode": 200, + "responseHeaders": { + "entries": [ + { + "name": "sessionId", + "value": "={{ $json.body.sessionId }}" + }, + { + "name": "Access-Control-Allow-Headers", + "value": "*" + } + ] + } + } + }, + "id": "d229963e-e2f1-4381-87d2-47043bd6ccc7", + "name": "Respond to Webhook", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1, + "position": [ + 2460, + 220 + ] + }, + { + "parameters": { + "dataType": "string", + "value1": "={{ $json.body.action }}", + "rules": { + "rules": [ + { + "value2": "loadPreviousSession" + }, + { + "value2": "sendMessage", + "output": 1 + } + ] + } + }, + "id": "fc4ad994-5f38-4dce-b1e5-397acc512687", + "name": "Chatbot Action", + "type": "n8n-nodes-base.switch", + "typeVersion": 1, + "position": [ + 1320, + 200 + ] + }, + { + "parameters": { + "jsCode": "const response = { data: [] };\n\nfor (const item of $input.all()) {\n response.data.push(item.json);\n}\n\nreturn {\n json: response,\n pairedItem: 0\n};" + }, + "id": "e1a80bdc-411a-42df-88dd-36915b1ae8f4", + "name": "Code", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2160, + -40 + ] + }, + { + "parameters": { + "text": "={{ $json.body.message }}", + "options": {} + }, + "id": "f28f5c00-c742-41d5-8ddb-f0f59ab111a3", + "name": "Agent", + "type": "@n8n/n8n-nodes-langchain.agent", + "typeVersion": 1, + "position": [ + 1780, + 340 + ] + }, + { + "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.body = JSON.parse(item.json.body);\n}\n\nreturn $input.all();" + }, + "id": "415c071b-18b2-4ac5-8634-e3d939bf36ac", + "name": "Transform request body", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1120, + 200 + ] + } + ], + "pinData": {}, + "connections": { + "Webhook": { + "main": [ + [ + { + "node": "Transform request body", + "type": "main", + "index": 0 + } + ] + ] + }, + "Memory Chat Retriever": { + "main": [ + [ + { + "node": "Code", + "type": "main", + "index": 0 + } + ] + ] + }, + "Memory Chat Retriever Window Buffer Memory": { + "ai_memory": [ + [ + { + "node": "Memory Chat Retriever", + "type": "ai_memory", + "index": 0 + } + ] + ] + }, + "Chatbot Action": { + "main": [ + [ + { + "node": "Memory Chat Retriever", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Agent", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code": { + "main": [ + [ + { + "node": "Respond to Webhook", + "type": "main", + "index": 0 + } + ] + ] + }, + "Chat OpenAI": { + "ai_languageModel": [ + [ + { + "node": "Agent", + "type": "ai_languageModel", + "index": 0 + } + ] + ] + }, + "Window Buffer Memory": { + "ai_memory": [ + [ + { + "node": "Agent", + "type": "ai_memory", + "index": 0 + } + ] + ] + }, + "Agent": { + "main": [ + [ + { + "node": "Respond to Webhook", + "type": "main", + "index": 0 + } + ] + ] + }, + "Transform request body": { + "main": [ + [ + { + "node": "Chatbot Action", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": true, + "settings": { + "executionOrder": "v1" + }, + "versionId": "12c145a2-74bf-48b5-a87a-ba707949eaed", + "id": "L3FlJuFOxZcHtoFT", + "meta": { + "instanceId": "374b43d8b8d6299cc777811a4ad220fc688ee2d54a308cfb0de4450a5233ca9e" + }, + "tags": [] +} diff --git a/packages/@n8n/chat/scripts/pack.js b/packages/@n8n/chat/scripts/pack.js new file mode 100644 index 0000000000..0dffb892e4 --- /dev/null +++ b/packages/@n8n/chat/scripts/pack.js @@ -0,0 +1,11 @@ +const path = require('path'); +const shelljs = require('shelljs'); + +const rootDirPath = path.resolve(__dirname, '..'); +const distDirPath = path.resolve(rootDirPath, 'dist'); + +shelljs.cd(rootDirPath); +shelljs.exec('npm run build'); + +shelljs.cd(distDirPath); +shelljs.exec('npm pack'); diff --git a/packages/@n8n/chat/scripts/postbuild.js b/packages/@n8n/chat/scripts/postbuild.js new file mode 100644 index 0000000000..f60ef525d0 --- /dev/null +++ b/packages/@n8n/chat/scripts/postbuild.js @@ -0,0 +1,16 @@ +const path = require('path'); +const shelljs = require('shelljs'); + +const rootDirPath = path.resolve(__dirname, '..'); +const n8nRootDirPath = path.resolve(rootDirPath, '..', '..', '..'); +const distDirPath = path.resolve(rootDirPath, 'dist'); + +const packageJsonFilePath = path.resolve(rootDirPath, 'package.json'); +const readmeFilePath = path.resolve(rootDirPath, 'README.md'); +const licenseFilePath = path.resolve(n8nRootDirPath, 'LICENSE.md'); + +shelljs.cp(packageJsonFilePath, distDirPath); +shelljs.cp(readmeFilePath, distDirPath); +shelljs.cp(licenseFilePath, distDirPath); + +shelljs.mv(path.resolve(distDirPath, 'src'), path.resolve(distDirPath, 'types')); diff --git a/packages/@n8n/chat/src/App.vue b/packages/@n8n/chat/src/App.vue new file mode 100644 index 0000000000..6a00331316 --- /dev/null +++ b/packages/@n8n/chat/src/App.vue @@ -0,0 +1,23 @@ + + diff --git a/packages/@n8n/chat/src/__stories__/App.stories.ts b/packages/@n8n/chat/src/__stories__/App.stories.ts new file mode 100644 index 0000000000..c50e94b985 --- /dev/null +++ b/packages/@n8n/chat/src/__stories__/App.stories.ts @@ -0,0 +1,43 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import type { StoryObj } from '@storybook/vue3'; +import type { ChatOptions } from '@/types'; +import { createChat } from '@/index'; +import { onMounted } from 'vue'; + +const webhookUrl = 'http://localhost:5678/webhook/513107b3-6f3a-4a1e-af21-659f0ed14183'; + +const meta = { + title: 'Chat', + render: (args: Partial) => ({ + setup() { + onMounted(() => { + createChat(args); + }); + + return {}; + }, + template: '
', + }), + parameters: { + layout: 'fullscreen', + }, + tags: ['autodocs'], +}; + +// eslint-disable-next-line import/no-default-export +export default meta; +type Story = StoryObj; + +export const Fullscreen: Story = { + args: { + webhookUrl, + mode: 'fullscreen', + } satisfies Partial, +}; + +export const Windowed: Story = { + args: { + webhookUrl, + mode: 'window', + } satisfies Partial, +}; diff --git a/packages/@n8n/chat/src/__tests__/index.spec.ts b/packages/@n8n/chat/src/__tests__/index.spec.ts new file mode 100644 index 0000000000..6a63ae02f1 --- /dev/null +++ b/packages/@n8n/chat/src/__tests__/index.spec.ts @@ -0,0 +1,223 @@ +import { fireEvent, waitFor } from '@testing-library/vue'; +import { + createFetchResponse, + createGetLatestMessagesResponse, + createSendMessageResponse, + getChatInputSendButton, + getChatInputTextarea, + getChatMessage, + getChatMessageByText, + getChatMessages, + getChatMessageTyping, + getChatWindowToggle, + getChatWindowWrapper, + getChatWrapper, + getGetStartedButton, + getMountingTarget, +} from '@/__tests__/utils'; +import { createChat } from '@/index'; +import { useChat } from '@/composables'; + +describe('createChat()', () => { + let app: ReturnType; + + afterEach(() => { + vi.clearAllMocks(); + + app.unmount(); + }); + + describe('mode', () => { + it('should create fullscreen chat app with default options', () => { + const fetchSpy = vi.spyOn(window, 'fetch'); + fetchSpy.mockImplementationOnce(createFetchResponse(createGetLatestMessagesResponse())); + + app = createChat({ + mode: 'fullscreen', + }); + + expect(getMountingTarget()).toBeVisible(); + expect(getChatWrapper()).toBeVisible(); + expect(getChatWindowWrapper()).not.toBeInTheDocument(); + }); + + it('should create window chat app with default options', () => { + const fetchSpy = vi.spyOn(window, 'fetch'); + fetchSpy.mockImplementationOnce(createFetchResponse(createGetLatestMessagesResponse())); + + app = createChat({ + mode: 'window', + }); + + expect(getMountingTarget()).toBeDefined(); + expect(getChatWindowWrapper()).toBeVisible(); + expect(getChatWrapper()).not.toBeVisible(); + }); + + it('should open window chat app using toggle button', async () => { + const fetchSpy = vi.spyOn(window, 'fetch'); + fetchSpy.mockImplementationOnce(createFetchResponse(createGetLatestMessagesResponse())); + + app = createChat(); + + expect(getMountingTarget()).toBeVisible(); + expect(getChatWindowWrapper()).toBeVisible(); + + const trigger = getChatWindowToggle(); + await fireEvent.click(trigger as HTMLElement); + + expect(getChatWrapper()).toBeVisible(); + }); + }); + + describe('loadPreviousMessages', () => { + it('should load previous messages on mount', async () => { + const fetchSpy = vi.spyOn(global, 'fetch'); + fetchSpy.mockImplementation(createFetchResponse(createGetLatestMessagesResponse())); + + app = createChat({ + mode: 'fullscreen', + }); + + const getStartedButton = getGetStartedButton(); + await fireEvent.click(getStartedButton as HTMLElement); + + expect(fetchSpy.mock.calls[0][1]).toEqual( + expect.objectContaining({ + method: 'POST', + headers: {}, + body: expect.stringMatching(/"action":"loadPreviousSession"/), + mode: 'cors', + cache: 'no-cache', + }), + ); + }); + }); + + describe('initialMessages', () => { + it.each(['fullscreen', 'window'] as Array<'fullscreen' | 'window'>)( + 'should show initial default messages in %s mode', + async (mode) => { + const fetchSpy = vi.spyOn(window, 'fetch'); + fetchSpy.mockImplementationOnce(createFetchResponse(createGetLatestMessagesResponse())); + + const initialMessages = ['Hello tester!', 'How are you?']; + app = createChat({ + mode, + initialMessages, + }); + + if (mode === 'window') { + const trigger = getChatWindowToggle(); + await fireEvent.click(trigger as HTMLElement); + } + + const getStartedButton = getGetStartedButton(); + await fireEvent.click(getStartedButton as HTMLElement); + + expect(getChatMessages().length).toBe(initialMessages.length); + expect(getChatMessageByText(initialMessages[0])).toBeInTheDocument(); + expect(getChatMessageByText(initialMessages[1])).toBeInTheDocument(); + }, + ); + }); + + describe('sendMessage', () => { + it.each(['window', 'fullscreen'] as Array<'fullscreen' | 'window'>)( + 'should send a message and render a text message in %s mode', + async (mode) => { + const input = 'Hello User World!'; + const output = 'Hello Bot World!'; + + const fetchSpy = vi.spyOn(window, 'fetch'); + fetchSpy + .mockImplementationOnce(createFetchResponse(createGetLatestMessagesResponse)) + .mockImplementationOnce(createFetchResponse(createSendMessageResponse(output))); + + app = createChat({ + mode, + }); + + if (mode === 'window') { + const trigger = getChatWindowToggle(); + await fireEvent.click(trigger as HTMLElement); + } + + expect(getChatMessageTyping()).not.toBeInTheDocument(); + + const getStartedButton = getGetStartedButton(); + await fireEvent.click(getStartedButton as HTMLElement); + + expect(getChatMessages().length).toBe(2); + + const textarea = getChatInputTextarea(); + const sendButton = getChatInputSendButton(); + await fireEvent.update(textarea as HTMLElement, input); + expect(sendButton).not.toBeDisabled(); + await fireEvent.click(sendButton as HTMLElement); + + expect(fetchSpy.mock.calls[1][1]).toEqual( + expect.objectContaining({ + method: 'POST', + headers: {}, + body: expect.stringMatching(/"action":"sendMessage"/), + mode: 'cors', + cache: 'no-cache', + }), + ); + expect(fetchSpy.mock.calls[1][1]?.body).toContain(`"${input}"`); + + expect(getChatMessages().length).toBe(3); + expect(getChatMessageByText(input)).toBeInTheDocument(); + expect(getChatMessageTyping()).toBeVisible(); + + await waitFor(() => expect(getChatMessageTyping()).not.toBeInTheDocument()); + expect(getChatMessageByText(output)).toBeInTheDocument(); + }, + ); + + it.each(['fullscreen', 'window'] as Array<'fullscreen' | 'window'>)( + 'should send a message and render a code markdown message in %s mode', + async (mode) => { + const input = 'Teach me javascript!'; + const output = '# Code\n```js\nconsole.log("Hello World!");\n```'; + + const chatStore = useChat(); + console.log(chatStore); + + const fetchSpy = vi.spyOn(window, 'fetch'); + fetchSpy + .mockImplementationOnce(createFetchResponse(createGetLatestMessagesResponse)) + .mockImplementationOnce(createFetchResponse(createSendMessageResponse(output))); + + app = createChat({ + mode, + }); + + if (mode === 'window') { + const trigger = getChatWindowToggle(); + await fireEvent.click(trigger as HTMLElement); + } + + const getStartedButton = getGetStartedButton(); + await fireEvent.click(getStartedButton as HTMLElement); + + const textarea = getChatInputTextarea(); + const sendButton = getChatInputSendButton(); + await fireEvent.update(textarea as HTMLElement, input); + await fireEvent.click(sendButton as HTMLElement); + + expect(getChatMessageByText(input)).toBeInTheDocument(); + expect(getChatMessages().length).toBe(3); + + await waitFor(() => expect(getChatMessageTyping()).not.toBeInTheDocument()); + + const lastMessage = getChatMessage(-1); + expect(lastMessage).toBeInTheDocument(); + + expect(lastMessage.querySelector('h1')).toHaveTextContent('Code'); + expect(lastMessage.querySelector('code')).toHaveTextContent('console.log("Hello World!");'); + }, + ); + }); +}); diff --git a/packages/@n8n/chat/src/__tests__/setup.ts b/packages/@n8n/chat/src/__tests__/setup.ts new file mode 100644 index 0000000000..7b0828bfa8 --- /dev/null +++ b/packages/@n8n/chat/src/__tests__/setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/packages/@n8n/chat/src/__tests__/utils/create.ts b/packages/@n8n/chat/src/__tests__/utils/create.ts new file mode 100644 index 0000000000..9c418a68e3 --- /dev/null +++ b/packages/@n8n/chat/src/__tests__/utils/create.ts @@ -0,0 +1,16 @@ +import { createChat } from '@/index'; + +export function createTestChat(options: Parameters[0] = {}): { + unmount: () => void; + container: Element; +} { + const app = createChat(options); + + const container = app._container as Element; + const unmount = () => app.unmount(); + + return { + unmount, + container, + }; +} diff --git a/packages/@n8n/chat/src/__tests__/utils/fetch.ts b/packages/@n8n/chat/src/__tests__/utils/fetch.ts new file mode 100644 index 0000000000..686062c05e --- /dev/null +++ b/packages/@n8n/chat/src/__tests__/utils/fetch.ts @@ -0,0 +1,18 @@ +import type { LoadPreviousSessionResponse, SendMessageResponse } from '@/types'; + +export function createFetchResponse(data: T) { + return async () => + ({ + json: async () => new Promise((resolve) => resolve(data)), + }) as Response; +} + +export const createGetLatestMessagesResponse = ( + data: LoadPreviousSessionResponse['data'] = [], +): LoadPreviousSessionResponse => ({ data }); + +export const createSendMessageResponse = ( + output: SendMessageResponse['output'], +): SendMessageResponse => ({ + output, +}); diff --git a/packages/@n8n/chat/src/__tests__/utils/index.ts b/packages/@n8n/chat/src/__tests__/utils/index.ts new file mode 100644 index 0000000000..6dc234f1d0 --- /dev/null +++ b/packages/@n8n/chat/src/__tests__/utils/index.ts @@ -0,0 +1,3 @@ +export * from './create'; +export * from './fetch'; +export * from './selectors'; diff --git a/packages/@n8n/chat/src/__tests__/utils/selectors.ts b/packages/@n8n/chat/src/__tests__/utils/selectors.ts new file mode 100644 index 0000000000..d2a5f87203 --- /dev/null +++ b/packages/@n8n/chat/src/__tests__/utils/selectors.ts @@ -0,0 +1,53 @@ +import { screen } from '@testing-library/vue'; +import { defaultMountingTarget } from '@/constants'; + +export function getMountingTarget(target = defaultMountingTarget) { + return document.querySelector(target); +} + +export function getChatWindowWrapper() { + return document.querySelector('.chat-window-wrapper'); +} + +export function getChatWindowToggle() { + return document.querySelector('.chat-window-toggle'); +} + +export function getChatWrapper() { + return document.querySelector('.chat-wrapper'); +} + +export function getChatMessages() { + return document.querySelectorAll('.chat-message:not(.chat-message-typing)'); +} + +export function getChatMessage(index: number) { + const messages = getChatMessages(); + return index < 0 ? messages[messages.length + index] : messages[index]; +} + +export function getChatMessageByText(text: string) { + return screen.queryByText(text, { + selector: '.chat-message:not(.chat-message-typing) .chat-message-markdown p', + }); +} + +export function getChatMessageTyping() { + return document.querySelector('.chat-message-typing'); +} + +export function getGetStartedButton() { + return document.querySelector('.chat-get-started .chat-button'); +} + +export function getChatInput() { + return document.querySelector('.chat-input'); +} + +export function getChatInputTextarea() { + return document.querySelector('.chat-input textarea'); +} + +export function getChatInputSendButton() { + return document.querySelector('.chat-input .chat-input-send-button'); +} diff --git a/packages/@n8n/chat/src/api/generic.ts b/packages/@n8n/chat/src/api/generic.ts new file mode 100644 index 0000000000..24acbde003 --- /dev/null +++ b/packages/@n8n/chat/src/api/generic.ts @@ -0,0 +1,64 @@ +async function getAccessToken() { + return ''; +} + +export async function authenticatedFetch(...args: Parameters): Promise { + const accessToken = await getAccessToken(); + + const response = await fetch(args[0], { + ...args[1], + mode: 'cors', + cache: 'no-cache', + headers: { + ...(accessToken ? { authorization: `Bearer ${accessToken}` } : {}), + ...args[1]?.headers, + }, + }); + + return (await response.json()) as Promise; +} + +export async function get( + url: string, + query: Record = {}, + options: RequestInit = {}, +) { + let resolvedUrl = url; + if (Object.keys(query).length > 0) { + resolvedUrl = `${resolvedUrl}?${new URLSearchParams(query).toString()}`; + } + + return authenticatedFetch(resolvedUrl, { ...options, method: 'GET' }); +} + +export async function post(url: string, body: object = {}, options: RequestInit = {}) { + return authenticatedFetch(url, { + ...options, + method: 'POST', + body: JSON.stringify(body), + }); +} + +export async function put(url: string, body: object = {}, options: RequestInit = {}) { + return authenticatedFetch(url, { + ...options, + method: 'PUT', + body: JSON.stringify(body), + }); +} + +export async function patch(url: string, body: object = {}, options: RequestInit = {}) { + return authenticatedFetch(url, { + ...options, + method: 'PATCH', + body: JSON.stringify(body), + }); +} + +export async function del(url: string, body: object = {}, options: RequestInit = {}) { + return authenticatedFetch(url, { + ...options, + method: 'DELETE', + body: JSON.stringify(body), + }); +} diff --git a/packages/@n8n/chat/src/api/index.ts b/packages/@n8n/chat/src/api/index.ts new file mode 100644 index 0000000000..a78afb92f2 --- /dev/null +++ b/packages/@n8n/chat/src/api/index.ts @@ -0,0 +1,2 @@ +export * from './generic'; +export * from './message'; diff --git a/packages/@n8n/chat/src/api/message.ts b/packages/@n8n/chat/src/api/message.ts new file mode 100644 index 0000000000..dc53b9d1f4 --- /dev/null +++ b/packages/@n8n/chat/src/api/message.ts @@ -0,0 +1,31 @@ +import { get, post } from '@/api/generic'; +import type { ChatOptions, LoadPreviousSessionResponse, SendMessageResponse } from '@/types'; + +export async function loadPreviousSession(sessionId: string, options: ChatOptions) { + const method = options.webhookConfig?.method === 'POST' ? post : get; + return method( + `${options.webhookUrl}`, + { + action: 'loadPreviousSession', + sessionId, + }, + { + headers: options.webhookConfig?.headers, + }, + ); +} + +export async function sendMessage(message: string, sessionId: string, options: ChatOptions) { + const method = options.webhookConfig?.method === 'POST' ? post : get; + return method( + `${options.webhookUrl}`, + { + action: 'sendMessage', + sessionId, + message, + }, + { + headers: options.webhookConfig?.headers, + }, + ); +} diff --git a/packages/@n8n/chat/src/components/Button.vue b/packages/@n8n/chat/src/components/Button.vue new file mode 100644 index 0000000000..ca35153fc6 --- /dev/null +++ b/packages/@n8n/chat/src/components/Button.vue @@ -0,0 +1,41 @@ + + diff --git a/packages/@n8n/chat/src/components/Chat.vue b/packages/@n8n/chat/src/components/Chat.vue new file mode 100644 index 0000000000..dbf2589ad0 --- /dev/null +++ b/packages/@n8n/chat/src/components/Chat.vue @@ -0,0 +1,48 @@ + + + diff --git a/packages/@n8n/chat/src/components/ChatWindow.vue b/packages/@n8n/chat/src/components/ChatWindow.vue new file mode 100644 index 0000000000..a5cd46a4fb --- /dev/null +++ b/packages/@n8n/chat/src/components/ChatWindow.vue @@ -0,0 +1,125 @@ + + + + + diff --git a/packages/@n8n/chat/src/components/GetStarted.vue b/packages/@n8n/chat/src/components/GetStarted.vue new file mode 100644 index 0000000000..c629621c82 --- /dev/null +++ b/packages/@n8n/chat/src/components/GetStarted.vue @@ -0,0 +1,24 @@ + + + + diff --git a/packages/@n8n/chat/src/components/GetStartedFooter.vue b/packages/@n8n/chat/src/components/GetStartedFooter.vue new file mode 100644 index 0000000000..df49b65d77 --- /dev/null +++ b/packages/@n8n/chat/src/components/GetStartedFooter.vue @@ -0,0 +1,20 @@ + + + + diff --git a/packages/@n8n/chat/src/components/Input.vue b/packages/@n8n/chat/src/components/Input.vue new file mode 100644 index 0000000000..508db35add --- /dev/null +++ b/packages/@n8n/chat/src/components/Input.vue @@ -0,0 +1,93 @@ + + +