From 93a6f858fa3eb53f8b48b2a3d6b7377279dd6ed1 Mon Sep 17 00:00:00 2001 From: Charlie Kolb Date: Wed, 13 Nov 2024 11:00:02 +0100 Subject: [PATCH 01/19] feat(editor): Restrict when a ChatTrigger Node is added automatically (#11523) --- cypress/e2e/30-langchain.cy.ts | 8 ++ .../NodeCreator/composables/useActions.ts | 25 +++-- .../Node/NodeCreator/useActions.test.ts | 100 ++++++++++++++++++ 3 files changed, 125 insertions(+), 8 deletions(-) diff --git a/cypress/e2e/30-langchain.cy.ts b/cypress/e2e/30-langchain.cy.ts index fb453816f6..43f6e0453c 100644 --- a/cypress/e2e/30-langchain.cy.ts +++ b/cypress/e2e/30-langchain.cy.ts @@ -359,6 +359,14 @@ describe('Langchain Integration', () => { getConnectionBySourceAndTarget(CHAT_TRIGGER_NODE_DISPLAY_NAME, AGENT_NODE_NAME).should('exist'); getNodes().should('have.length', 3); }); + it('should not auto-add nodes if ChatTrigger is already present', () => { + addNodeToCanvas(MANUAL_CHAT_TRIGGER_NODE_NAME, true); + addNodeToCanvas(AGENT_NODE_NAME, true); + + addNodeToCanvas(AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME, true); + getConnectionBySourceAndTarget(CHAT_TRIGGER_NODE_DISPLAY_NAME, AGENT_NODE_NAME).should('exist'); + getNodes().should('have.length', 3); + }); it('should render runItems for sub-nodes and allow switching between them', () => { const workflowPage = new WorkflowPage(); const ndv = new NDV(); diff --git a/packages/editor-ui/src/components/Node/NodeCreator/composables/useActions.ts b/packages/editor-ui/src/components/Node/NodeCreator/composables/useActions.ts index 87be1105b7..92a5563d80 100644 --- a/packages/editor-ui/src/components/Node/NodeCreator/composables/useActions.ts +++ b/packages/editor-ui/src/components/Node/NodeCreator/composables/useActions.ts @@ -19,7 +19,6 @@ import { AI_CATEGORY_LANGUAGE_MODELS, BASIC_CHAIN_NODE_TYPE, CHAT_TRIGGER_NODE_TYPE, - MANUAL_CHAT_TRIGGER_NODE_TYPE, MANUAL_TRIGGER_NODE_TYPE, NODE_CREATOR_OPEN_SOURCES, NO_OP_NODE_TYPE, @@ -204,8 +203,6 @@ export const useActions = () => { ); } function shouldPrependChatTrigger(addedNodes: AddedNode[]): boolean { - const { allNodes } = useWorkflowsStore(); - const COMPATIBLE_CHAT_NODES = [ QA_CHAIN_NODE_TYPE, AGENT_NODE_TYPE, @@ -214,13 +211,25 @@ export const useActions = () => { OPEN_AI_NODE_MESSAGE_ASSISTANT_TYPE, ]; - const isChatTriggerMissing = - allNodes.find((node) => - [MANUAL_CHAT_TRIGGER_NODE_TYPE, CHAT_TRIGGER_NODE_TYPE].includes(node.type), - ) === undefined; const isCompatibleNode = addedNodes.some((node) => COMPATIBLE_CHAT_NODES.includes(node.type)); - return isCompatibleNode && isChatTriggerMissing; + if (!isCompatibleNode) return false; + + const { allNodes, getNodeTypes } = useWorkflowsStore(); + const { getByNameAndVersion } = getNodeTypes(); + + // We want to add a trigger if there are no triggers other than Manual Triggers + // Performance here should be fine as `getByNameAndVersion` fetches nodeTypes once in bulk + // and `every` aborts on first `false` + const shouldAddChatTrigger = allNodes.every((node) => { + const nodeType = getByNameAndVersion(node.type, node.typeVersion); + + return ( + !nodeType.description.group.includes('trigger') || node.type === MANUAL_TRIGGER_NODE_TYPE + ); + }); + + return shouldAddChatTrigger; } // AI-226: Prepend LLM Chain node when adding a language model diff --git a/packages/editor-ui/src/components/Node/NodeCreator/useActions.test.ts b/packages/editor-ui/src/components/Node/NodeCreator/useActions.test.ts index 4b5682cbbd..cf8fa94014 100644 --- a/packages/editor-ui/src/components/Node/NodeCreator/useActions.test.ts +++ b/packages/editor-ui/src/components/Node/NodeCreator/useActions.test.ts @@ -5,6 +5,8 @@ import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useWorkflowsStore } from '@/stores/workflows.store'; import { useActions } from './composables/useActions'; import { + AGENT_NODE_TYPE, + GITHUB_TRIGGER_NODE_TYPE, HTTP_REQUEST_NODE_TYPE, MANUAL_TRIGGER_NODE_TYPE, NODE_CREATOR_OPEN_SOURCES, @@ -15,6 +17,7 @@ import { TRIGGER_NODE_CREATOR_VIEW, WEBHOOK_NODE_TYPE, } from '@/constants'; +import { CHAT_TRIGGER_NODE_TYPE } from 'n8n-workflow'; describe('useActions', () => { beforeAll(() => { @@ -54,6 +57,9 @@ describe('useActions', () => { vi.spyOn(workflowsStore, 'workflowTriggerNodes', 'get').mockReturnValue([ { type: SCHEDULE_TRIGGER_NODE_TYPE } as never, ]); + vi.spyOn(workflowsStore, 'getNodeTypes').mockReturnValue({ + getByNameAndVersion: () => ({ description: { group: ['trigger'] } }), + } as never); vi.spyOn(nodeCreatorStore, 'openSource', 'get').mockReturnValue( NODE_CREATOR_OPEN_SOURCES.ADD_NODE_BUTTON, ); @@ -67,6 +73,100 @@ describe('useActions', () => { }); }); + test('should insert a ChatTrigger node when an AI Agent is added without trigger', () => { + const workflowsStore = useWorkflowsStore(); + + vi.spyOn(workflowsStore, 'workflowTriggerNodes', 'get').mockReturnValue([]); + + const { getAddedNodesAndConnections } = useActions(); + + expect(getAddedNodesAndConnections([{ type: AGENT_NODE_TYPE }])).toEqual({ + connections: [ + { + from: { + nodeIndex: 0, + }, + to: { + nodeIndex: 1, + }, + }, + ], + nodes: [ + { type: CHAT_TRIGGER_NODE_TYPE, isAutoAdd: true }, + { type: AGENT_NODE_TYPE, openDetail: true }, + ], + }); + }); + + test('should insert a ChatTrigger node when an AI Agent is added with only a Manual Trigger', () => { + const workflowsStore = useWorkflowsStore(); + + vi.spyOn(workflowsStore, 'workflowTriggerNodes', 'get').mockReturnValue([ + { type: MANUAL_TRIGGER_NODE_TYPE } as never, + ]); + vi.spyOn(workflowsStore, 'getNodeTypes').mockReturnValue({ + getByNameAndVersion: () => ({ description: { group: ['trigger'] } }), + } as never); + + const { getAddedNodesAndConnections } = useActions(); + + expect(getAddedNodesAndConnections([{ type: AGENT_NODE_TYPE }])).toEqual({ + connections: [ + { + from: { + nodeIndex: 0, + }, + to: { + nodeIndex: 1, + }, + }, + ], + nodes: [ + { type: CHAT_TRIGGER_NODE_TYPE, isAutoAdd: true }, + { type: AGENT_NODE_TYPE, openDetail: true }, + ], + }); + }); + + test('should not insert a ChatTrigger node when an AI Agent is added with a trigger already present', () => { + const workflowsStore = useWorkflowsStore(); + + vi.spyOn(workflowsStore, 'allNodes', 'get').mockReturnValue([ + { type: GITHUB_TRIGGER_NODE_TYPE } as never, + ]); + vi.spyOn(workflowsStore, 'getNodeTypes').mockReturnValue({ + getByNameAndVersion: () => ({ description: { group: ['trigger'] } }), + } as never); + + const { getAddedNodesAndConnections } = useActions(); + + expect(getAddedNodesAndConnections([{ type: AGENT_NODE_TYPE }])).toEqual({ + connections: [], + nodes: [{ type: AGENT_NODE_TYPE, openDetail: true }], + }); + }); + + test('should not insert a ChatTrigger node when an AI Agent is added with a Chat Trigger already present', () => { + const workflowsStore = useWorkflowsStore(); + + vi.spyOn(workflowsStore, 'workflowTriggerNodes', 'get').mockReturnValue([ + { type: CHAT_TRIGGER_NODE_TYPE } as never, + ]); + vi.spyOn(workflowsStore, 'allNodes', 'get').mockReturnValue([ + { type: CHAT_TRIGGER_NODE_TYPE } as never, + ]); + vi.spyOn(workflowsStore, 'getNodeTypes').mockReturnValue({ + getByNameAndVersion: () => ({ description: { group: ['trigger'] } }), + } as never); + + const { getAddedNodesAndConnections } = useActions(); + + expect(getAddedNodesAndConnections([{ type: AGENT_NODE_TYPE }])).toEqual({ + connections: [], + nodes: [{ type: AGENT_NODE_TYPE, openDetail: true }], + }); + }); + test('should insert a No Op node when a Loop Over Items Node is added', () => { const workflowsStore = useWorkflowsStore(); const nodeCreatorStore = useNodeCreatorStore(); From a412ab7ebfcd6aa9051a8ca36e34f1067102c998 Mon Sep 17 00:00:00 2001 From: oleg Date: Wed, 13 Nov 2024 11:05:19 +0100 Subject: [PATCH 02/19] feat(editor): Redesign Canvas Chat (#11634) --- cypress/composables/modals/chat-modal.ts | 6 +- cypress/e2e/30-langchain.cy.ts | 9 +- package.json | 1 + packages/@n8n/chat/src/__tests__/setup.ts | 12 + .../@n8n/chat/src/components/ChatFile.vue | 32 +- packages/@n8n/chat/src/components/Input.vue | 102 ++- packages/@n8n/chat/src/components/Message.vue | 13 +- .../chat/src/components/MessageTyping.vue | 7 +- packages/@n8n/chat/src/css/markdown.scss | 4 +- packages/editor-ui/src/App.vue | 63 +- packages/editor-ui/src/__tests__/mocks.ts | 3 + .../AskAssistantFloatingButton.vue | 5 +- .../components/CanvasChat/CanvasChat.test.ts | 586 +++++++++++++++ .../src/components/CanvasChat/CanvasChat.vue | 350 +++++++++ .../CanvasChat/components/ChatLogsPanel.vue | 91 +++ .../components/ChatMessagesPanel.vue | 348 +++++++++ .../components/MessageOptionAction.vue | 46 ++ .../components/MessageOptionTooltip.vue | 40 + .../composables/useChatMessaging.ts | 299 ++++++++ .../CanvasChat/composables/useChatTrigger.ts | 138 ++++ .../CanvasChat/composables/useResize.ts | 137 ++++ .../src/components/CanvasChat/types/chat.ts | 22 + packages/editor-ui/src/components/Modals.vue | 6 - .../RunDataAi/AiRunContentBlock.vue | 63 +- .../src/components/RunDataAi/RunDataAi.vue | 154 ++-- .../RunDataAi/useAiContentParsers.ts | 10 +- .../WorkflowLMChat/MessageOptionAction.vue | 31 - .../WorkflowLMChat/MessageOptionTooltip.vue | 15 - .../WorkflowLMChat/WorkflowLMChat.vue | 699 ------------------ .../components/WorkflowLMChatModal.test.ts | 127 ---- .../elements/buttons/CanvasChatButton.vue | 8 + .../src/composables/useRunWorkflow.ts | 10 +- .../editor-ui/src/composables/useToast.ts | 6 +- packages/editor-ui/src/constants.ts | 1 - .../src/plugins/i18n/locales/en.json | 13 +- packages/editor-ui/src/router.ts | 3 + packages/editor-ui/src/stores/canvas.store.ts | 10 +- packages/editor-ui/src/stores/ui.store.ts | 2 - .../editor-ui/src/stores/workflows.store.ts | 18 + packages/editor-ui/src/views/NodeView.v2.vue | 11 +- packages/editor-ui/src/views/NodeView.vue | 13 +- 41 files changed, 2451 insertions(+), 1063 deletions(-) create mode 100644 packages/editor-ui/src/components/CanvasChat/CanvasChat.test.ts create mode 100644 packages/editor-ui/src/components/CanvasChat/CanvasChat.vue create mode 100644 packages/editor-ui/src/components/CanvasChat/components/ChatLogsPanel.vue create mode 100644 packages/editor-ui/src/components/CanvasChat/components/ChatMessagesPanel.vue create mode 100644 packages/editor-ui/src/components/CanvasChat/components/MessageOptionAction.vue create mode 100644 packages/editor-ui/src/components/CanvasChat/components/MessageOptionTooltip.vue create mode 100644 packages/editor-ui/src/components/CanvasChat/composables/useChatMessaging.ts create mode 100644 packages/editor-ui/src/components/CanvasChat/composables/useChatTrigger.ts create mode 100644 packages/editor-ui/src/components/CanvasChat/composables/useResize.ts create mode 100644 packages/editor-ui/src/components/CanvasChat/types/chat.ts delete mode 100644 packages/editor-ui/src/components/WorkflowLMChat/MessageOptionAction.vue delete mode 100644 packages/editor-ui/src/components/WorkflowLMChat/MessageOptionTooltip.vue delete mode 100644 packages/editor-ui/src/components/WorkflowLMChat/WorkflowLMChat.vue delete mode 100644 packages/editor-ui/src/components/WorkflowLMChatModal.test.ts diff --git a/cypress/composables/modals/chat-modal.ts b/cypress/composables/modals/chat-modal.ts index 254d811a18..220c363dd1 100644 --- a/cypress/composables/modals/chat-modal.ts +++ b/cypress/composables/modals/chat-modal.ts @@ -3,7 +3,7 @@ */ export function getManualChatModal() { - return cy.getByTestId('lmChat-modal'); + return cy.getByTestId('canvas-chat'); } export function getManualChatInput() { @@ -19,11 +19,11 @@ export function getManualChatMessages() { } export function getManualChatModalCloseButton() { - return getManualChatModal().get('.el-dialog__close'); + return cy.getByTestId('workflow-chat-button'); } export function getManualChatModalLogs() { - return getManualChatModal().getByTestId('lm-chat-logs'); + return cy.getByTestId('canvas-chat-logs'); } export function getManualChatDialog() { return getManualChatModal().getByTestId('workflow-lm-chat-dialog'); diff --git a/cypress/e2e/30-langchain.cy.ts b/cypress/e2e/30-langchain.cy.ts index 43f6e0453c..78934c3ce5 100644 --- a/cypress/e2e/30-langchain.cy.ts +++ b/cypress/e2e/30-langchain.cy.ts @@ -14,7 +14,6 @@ import { } from './../constants'; import { closeManualChatModal, - getManualChatDialog, getManualChatMessages, getManualChatModal, getManualChatModalLogs, @@ -168,7 +167,7 @@ describe('Langchain Integration', () => { lastNodeExecuted: BASIC_LLM_CHAIN_NODE_NAME, }); - getManualChatDialog().should('contain', outputMessage); + getManualChatMessages().should('contain', outputMessage); }); it('should be able to open and execute Agent node', () => { @@ -208,7 +207,7 @@ describe('Langchain Integration', () => { lastNodeExecuted: AGENT_NODE_NAME, }); - getManualChatDialog().should('contain', outputMessage); + getManualChatMessages().should('contain', outputMessage); }); it('should add and use Manual Chat Trigger node together with Agent node', () => { @@ -229,8 +228,6 @@ describe('Langchain Integration', () => { clickManualChatButton(); - getManualChatModalLogs().should('not.exist'); - const inputMessage = 'Hello!'; const outputMessage = 'Hi there! How can I assist you today?'; const runData = [ @@ -335,6 +332,8 @@ describe('Langchain Integration', () => { getManualChatModalLogsEntries().should('have.length', 1); closeManualChatModal(); + getManualChatModalLogs().should('not.exist'); + getManualChatModal().should('not.exist'); }); it('should auto-add chat trigger and basic LLM chain when adding LLM node', () => { diff --git a/package.json b/package.json index 1b35d0384f..c7d4512b2e 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "build:nodes": "turbo run build:nodes", "typecheck": "turbo typecheck", "dev": "turbo run dev --parallel --env-mode=loose --filter=!n8n-design-system --filter=!@n8n/chat --filter=!@n8n/task-runner", + "dev:be": "turbo run dev --parallel --env-mode=loose --filter=!n8n-design-system --filter=!@n8n/chat --filter=!@n8n/task-runner --filter=!n8n-editor-ui", "dev:ai": "turbo run dev --parallel --env-mode=loose --filter=@n8n/nodes-langchain --filter=n8n --filter=n8n-core", "clean": "turbo run clean --parallel", "reset": "node scripts/ensure-zx.mjs && zx scripts/reset.mjs", diff --git a/packages/@n8n/chat/src/__tests__/setup.ts b/packages/@n8n/chat/src/__tests__/setup.ts index 7b0828bfa8..33e89fb68b 100644 --- a/packages/@n8n/chat/src/__tests__/setup.ts +++ b/packages/@n8n/chat/src/__tests__/setup.ts @@ -1 +1,13 @@ import '@testing-library/jest-dom'; +import '@testing-library/jest-dom'; +import { configure } from '@testing-library/vue'; + +configure({ testIdAttribute: 'data-test-id' }); + +window.ResizeObserver = + window.ResizeObserver || + vi.fn().mockImplementation(() => ({ + disconnect: vi.fn(), + observe: vi.fn(), + unobserve: vi.fn(), + })); diff --git a/packages/@n8n/chat/src/components/ChatFile.vue b/packages/@n8n/chat/src/components/ChatFile.vue index d3c3c2db7d..52b4acf789 100644 --- a/packages/@n8n/chat/src/components/ChatFile.vue +++ b/packages/@n8n/chat/src/components/ChatFile.vue @@ -30,22 +30,23 @@ const TypeIcon = computed(() => { }); function onClick() { - if (props.isRemovable) { - emit('remove', props.file); - } - if (props.isPreviewable) { window.open(URL.createObjectURL(props.file)); } } +function onDelete() { + emit('remove', props.file); +} @@ -80,12 +81,25 @@ function onClick() { .chat-file-preview { background: none; border: none; - display: none; + display: block; cursor: pointer; flex-shrink: 0; +} - .chat-file:hover & { - display: block; +.chat-file-delete { + position: relative; + &:hover { + color: red; + } + + /* Increase hit area for better clickability */ + &:before { + content: ''; + position: absolute; + top: -10px; + right: -10px; + bottom: -10px; + left: -10px; } } diff --git a/packages/@n8n/chat/src/components/Input.vue b/packages/@n8n/chat/src/components/Input.vue index 3e823917e0..4abfc76849 100644 --- a/packages/@n8n/chat/src/components/Input.vue +++ b/packages/@n8n/chat/src/components/Input.vue @@ -1,6 +1,6 @@