diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index e5b4e5db66..214a78f2d8 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -174,14 +174,21 @@ "aiAssistant.n8nAi": "n8n AI", "aiAssistant.builder.name": "Builder", "aiAssistant.builder.mode": "AI Builder", - "aiAssistant.builder.placeholder": "What would you like to automate?", + "aiAssistant.builder.placeholder": "Ask n8n to build...", "aiAssistant.builder.generateNew": "Generate new workflow", - "aiAssistant.builder.buildWorkflow": "Build workflow", "aiAssistant.builder.newWorkflowNotice": "The created workflow will be added to the editor", "aiAssistant.builder.feedbackPrompt": "Is this workflow helpful?", "aiAssistant.builder.invalidPrompt": "Prompt validation failed. Please try again with a clearer description of your workflow requirements and supported integrations.", "aiAssistant.builder.workflowParsingError.title": "Unable to insert workflow", "aiAssistant.builder.workflowParsingError.content": "The workflow returned by AI could not be parsed. Please try again.", + "aiAssistant.builder.canvasPrompt.buildWorkflow": "Create workflow", + "aiAssistant.builder.canvasPrompt.title": "What would you like to automate?", + "aiAssistant.builder.canvasPrompt.confirmTitle": "Replace current prompt?", + "aiAssistant.builder.canvasPrompt.confirmMessage": "This will replace your current prompt. Are you sure?", + "aiAssistant.builder.canvasPrompt.confirmButton": "Replace", + "aiAssistant.builder.canvasPrompt.cancelButton": "Cancel", + "aiAssistant.builder.canvasPrompt.startManually.title": "Start manually", + "aiAssistant.builder.canvasPrompt.startManually.subTitle": "Add the first node", "aiAssistant.assistant": "AI Assistant", "aiAssistant.newSessionModal.title.part1": "Start new", "aiAssistant.newSessionModal.title.part2": "session", diff --git a/packages/frontend/editor-ui/src/Interface.ts b/packages/frontend/editor-ui/src/Interface.ts index 8ee8df79b6..36223389c7 100644 --- a/packages/frontend/editor-ui/src/Interface.ts +++ b/packages/frontend/editor-ui/src/Interface.ts @@ -177,6 +177,7 @@ export interface INodeUi extends INode { issues?: INodeIssues; name: string; pinData?: IDataObject; + draggable?: boolean; } export interface INodeTypesMaxCount { diff --git a/packages/frontend/editor-ui/src/__tests__/mocks.ts b/packages/frontend/editor-ui/src/__tests__/mocks.ts index 85f8719fa4..64078721f2 100644 --- a/packages/frontend/editor-ui/src/__tests__/mocks.ts +++ b/packages/frontend/editor-ui/src/__tests__/mocks.ts @@ -40,6 +40,7 @@ export const mockNode = ({ issues = undefined, typeVersion = 1, parameters = {}, + draggable = true, }: { id?: INodeUi['id']; name: INodeUi['name']; @@ -49,7 +50,9 @@ export const mockNode = ({ issues?: INodeIssues; typeVersion?: INodeUi['typeVersion']; parameters?: INodeUi['parameters']; -}) => mock({ id, name, type, position, disabled, issues, typeVersion, parameters }); + draggable?: INodeUi['draggable']; +}) => + mock({ id, name, type, position, disabled, issues, typeVersion, parameters, draggable }); export const mockNodeTypeDescription = ({ name = SET_NODE_TYPE, diff --git a/packages/frontend/editor-ui/src/components/canvas/elements/nodes/CanvasNode.vue b/packages/frontend/editor-ui/src/components/canvas/elements/nodes/CanvasNode.vue index 5ced448610..1e7a7884a3 100644 --- a/packages/frontend/editor-ui/src/components/canvas/elements/nodes/CanvasNode.vue +++ b/packages/frontend/editor-ui/src/components/canvas/elements/nodes/CanvasNode.vue @@ -108,7 +108,11 @@ const classes = computed(() => ({ const renderType = computed(() => props.data.render.type); const dataTestId = computed(() => - [CanvasNodeRenderType.StickyNote, CanvasNodeRenderType.AddNodes].includes(renderType.value) + [ + CanvasNodeRenderType.StickyNote, + CanvasNodeRenderType.AddNodes, + CanvasNodeRenderType.AIPrompt, + ].includes(renderType.value) ? undefined : 'canvas-node', ); diff --git a/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeAIPrompt.test.ts b/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeAIPrompt.test.ts new file mode 100644 index 0000000000..7b5fd1f1e5 --- /dev/null +++ b/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeAIPrompt.test.ts @@ -0,0 +1,334 @@ +import { createComponentRenderer } from '@/__tests__/render'; +import { createTestingPinia } from '@pinia/testing'; +import { setActivePinia } from 'pinia'; +import { fireEvent, waitFor } from '@testing-library/vue'; +import { ref } from 'vue'; +import CanvasNodeAIPrompt from '@/components/canvas/elements/nodes/render-types/CanvasNodeAIPrompt.vue'; +import { MODAL_CONFIRM, NODE_CREATOR_OPEN_SOURCES } from '@/constants'; +import { WORKFLOW_SUGGESTIONS } from '@/constants/workflowSuggestions'; + +// Mock stores +const streaming = ref(false); +const openChat = vi.fn(); +const sendChatMessage = vi.fn(); +vi.mock('@/stores/builder.store', () => { + return { + useBuilderStore: vi.fn(() => ({ + get streaming() { + return streaming.value; + }, + openChat, + sendChatMessage, + })), + }; +}); + +const isNewWorkflow = ref(false); +vi.mock('@/stores/workflows.store', () => { + return { + useWorkflowsStore: vi.fn(() => ({ + get isNewWorkflow() { + return isNewWorkflow.value; + }, + })), + }; +}); + +const openNodeCreatorForTriggerNodes = vi.fn(); +vi.mock('@/stores/nodeCreator.store', () => ({ + useNodeCreatorStore: vi.fn(() => ({ + openNodeCreatorForTriggerNodes, + })), +})); + +// Mock composables +const saveCurrentWorkflow = vi.fn(); +vi.mock('@/composables/useWorkflowSaving', () => ({ + useWorkflowSaving: vi.fn(() => ({ + saveCurrentWorkflow, + })), +})); + +const confirmMock = vi.fn(); +vi.mock('@/composables/useMessage', () => ({ + useMessage: vi.fn(() => ({ + confirm: confirmMock, + })), +})); + +const telemetryTrack = vi.fn(); +vi.mock('@/composables/useTelemetry', () => ({ + useTelemetry: vi.fn(() => ({ + track: telemetryTrack, + })), +})); + +// Mock router +vi.mock('vue-router', () => ({ + useRouter: vi.fn(), + RouterLink: vi.fn(), +})); + +const renderComponent = createComponentRenderer(CanvasNodeAIPrompt); + +describe('CanvasNodeAIPrompt', () => { + beforeEach(() => { + const pinia = createTestingPinia(); + setActivePinia(pinia); + + // Reset all mocks + vi.clearAllMocks(); + streaming.value = false; + isNewWorkflow.value = false; + }); + + // Snapshot Test + it('should render component correctly', () => { + const { html } = renderComponent(); + expect(html()).toMatchSnapshot(); + }); + + describe('disabled state', () => { + it('should disable textarea when builder is streaming', () => { + streaming.value = true; + const { container } = renderComponent(); + + const textarea = container.querySelector('textarea'); + expect(textarea).toHaveAttribute('disabled'); + }); + + it('should disable submit button when builder is streaming', () => { + streaming.value = true; + const { container } = renderComponent(); + + const submitButton = container.querySelector('button[type="submit"]'); + expect(submitButton).toHaveAttribute('disabled'); + }); + + it('should disable submit button when prompt is empty', () => { + const { container } = renderComponent(); + + const submitButton = container.querySelector('button[type="submit"]'); + expect(submitButton).toHaveAttribute('disabled'); + }); + }); + + describe('form submission', () => { + it('should submit form on Cmd+Enter keyboard shortcut', async () => { + const { container } = renderComponent(); + const textarea = container.querySelector('textarea'); + + if (!textarea) throw new Error('Textarea not found'); + + // Type in textarea + await fireEvent.update(textarea, 'Test prompt'); + + // Fire Cmd+Enter + await fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true }); + + await waitFor(() => { + expect(openChat).toHaveBeenCalled(); + expect(sendChatMessage).toHaveBeenCalledWith({ + text: 'Test prompt', + source: 'canvas', + }); + }); + }); + + it('should not submit when prompt is empty', async () => { + const { container } = renderComponent(); + const form = container.querySelector('form'); + + if (!form) throw new Error('Form not found'); + + await fireEvent.submit(form); + + expect(openChat).not.toHaveBeenCalled(); + }); + + it('should not submit when streaming', async () => { + streaming.value = true; + const { container } = renderComponent(); + const textarea = container.querySelector('textarea'); + const form = container.querySelector('form'); + + if (!textarea || !form) throw new Error('Elements not found'); + + // Even with content, submission should be blocked + await fireEvent.update(textarea, 'Test prompt'); + await fireEvent.submit(form); + + expect(openChat).not.toHaveBeenCalled(); + }); + + it('should open AI assistant panel and send message on submit', async () => { + const { container } = renderComponent(); + const textarea = container.querySelector('textarea'); + const form = container.querySelector('form'); + + if (!textarea || !form) throw new Error('Elements not found'); + + await fireEvent.update(textarea, 'Test workflow prompt'); + await fireEvent.submit(form); + + await waitFor(() => { + expect(openChat).toHaveBeenCalled(); + expect(sendChatMessage).toHaveBeenCalledWith({ + text: 'Test workflow prompt', + source: 'canvas', + }); + }); + }); + + it('should save new workflow before opening chat', async () => { + isNewWorkflow.value = true; + const { container } = renderComponent(); + const textarea = container.querySelector('textarea'); + const form = container.querySelector('form'); + + if (!textarea || !form) throw new Error('Elements not found'); + + await fireEvent.update(textarea, 'Test prompt'); + await fireEvent.submit(form); + + await waitFor(() => { + expect(saveCurrentWorkflow).toHaveBeenCalled(); + expect(openChat).toHaveBeenCalled(); + // Ensure save is called before chat opens + expect(saveCurrentWorkflow.mock.invocationCallOrder[0]).toBeLessThan( + openChat.mock.invocationCallOrder[0], + ); + }); + }); + }); + + describe('suggestion pills', () => { + it('should render all workflow suggestions', () => { + const { container } = renderComponent(); + const pills = container.querySelectorAll('[role="group"] button'); + + expect(pills).toHaveLength(WORKFLOW_SUGGESTIONS.length); + + WORKFLOW_SUGGESTIONS.forEach((suggestion, index) => { + expect(pills[index]).toHaveTextContent(suggestion.summary); + }); + }); + + it('should replace prompt when suggestion is clicked', async () => { + const { container } = renderComponent(); + const firstPill = container.querySelector('[role="group"] button'); + const textarea = container.querySelector('textarea'); + + if (!firstPill || !textarea) throw new Error('Elements not found'); + + await fireEvent.click(firstPill); + + expect(textarea).toHaveValue(WORKFLOW_SUGGESTIONS[0].prompt); + }); + + it('should show confirmation dialog when user has edited prompt', async () => { + confirmMock.mockResolvedValue(MODAL_CONFIRM); + const { container } = renderComponent(); + const textarea = container.querySelector('textarea'); + const firstPill = container.querySelector('[role="group"] button'); + + if (!textarea || !firstPill) throw new Error('Elements not found'); + + // Type in textarea (triggers userEditedPrompt = true) + await fireEvent.update(textarea, 'My custom prompt'); + await fireEvent.input(textarea); + + // Click a suggestion + await fireEvent.click(firstPill); + + expect(confirmMock).toHaveBeenCalled(); + + // After confirmation, prompt should be replaced + await waitFor(() => { + expect(textarea).toHaveValue(WORKFLOW_SUGGESTIONS[0].prompt); + }); + }); + + it('should not show confirmation when prompt is empty', async () => { + const { container } = renderComponent(); + const firstPill = container.querySelector('[role="group"] button'); + const textarea = container.querySelector('textarea'); + + if (!firstPill || !textarea) throw new Error('Elements not found'); + + await fireEvent.click(firstPill); + + expect(confirmMock).not.toHaveBeenCalled(); + expect(textarea).toHaveValue(WORKFLOW_SUGGESTIONS[0].prompt); + }); + + it('should track telemetry when suggestion is clicked', async () => { + const { container } = renderComponent(); + const firstPill = container.querySelector('[role="group"] button'); + + if (!firstPill) throw new Error('Pill not found'); + + await fireEvent.click(firstPill); + + expect(telemetryTrack).toHaveBeenCalledWith('User clicked suggestion pill', { + prompt: '', + suggestion: WORKFLOW_SUGGESTIONS[0].id, + }); + }); + + it('should not replace prompt if user cancels confirmation', async () => { + confirmMock.mockResolvedValue('cancel'); // Not MODAL_CONFIRM + const { container } = renderComponent(); + const textarea = container.querySelector('textarea'); + const firstPill = container.querySelector('[role="group"] button'); + + if (!textarea || !firstPill) throw new Error('Elements not found'); + + // Type in textarea + await fireEvent.update(textarea, 'My custom prompt'); + await fireEvent.input(textarea); + + const originalValue = textarea.value; + + // Click suggestion + await fireEvent.click(firstPill); + + // Prompt should not be replaced + expect(textarea).toHaveValue(originalValue); + }); + }); + + describe('manual node creation', () => { + it('should open node creator when "Add node manually" is clicked', async () => { + const { container } = renderComponent(); + const addButton = container.querySelector('[aria-label="Add node manually"]'); + + if (!addButton) throw new Error('Add button not found'); + + await fireEvent.click(addButton); + + expect(openNodeCreatorForTriggerNodes).toHaveBeenCalledWith( + NODE_CREATOR_OPEN_SOURCES.TRIGGER_PLACEHOLDER_BUTTON, + ); + }); + }); + + describe('event propagation', () => { + it.each(['click', 'dblclick', 'mousedown', 'scroll', 'wheel'])( + 'should stop propagation of %s event on prompt container', + (eventType) => { + const { container } = renderComponent(); + const promptContainer = container.querySelector('.promptContainer'); + + if (!promptContainer) throw new Error('Prompt container not found'); + + const event = new Event(eventType, { bubbles: true }); + const stopPropagationSpy = vi.spyOn(event, 'stopPropagation'); + + promptContainer.dispatchEvent(event); + + expect(stopPropagationSpy).toHaveBeenCalled(); + }, + ); + }); +}); diff --git a/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeAIPrompt.vue b/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeAIPrompt.vue index 18cf8325cc..6014ec3d87 100644 --- a/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeAIPrompt.vue +++ b/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeAIPrompt.vue @@ -1,52 +1,122 @@ diff --git a/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/__snapshots__/CanvasNodeAIPrompt.test.ts.snap b/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/__snapshots__/CanvasNodeAIPrompt.test.ts.snap new file mode 100644 index 0000000000..ab3f9142a1 --- /dev/null +++ b/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/__snapshots__/CanvasNodeAIPrompt.test.ts.snap @@ -0,0 +1,28 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`CanvasNodeAIPrompt > should render component correctly 1`] = ` +"
+
+

What would you like to automate?

+
+
+
+
+ + + +
+
+
+
+
+ +
+
Start manuallyAdd the first node
+
+
" +`; diff --git a/packages/frontend/editor-ui/src/composables/__snapshots__/useCanvasOperations.test.ts.snap b/packages/frontend/editor-ui/src/composables/__snapshots__/useCanvasOperations.test.ts.snap index 821da444d4..f0f7f1b17e 100644 --- a/packages/frontend/editor-ui/src/composables/__snapshots__/useCanvasOperations.test.ts.snap +++ b/packages/frontend/editor-ui/src/composables/__snapshots__/useCanvasOperations.test.ts.snap @@ -14,7 +14,8 @@ exports[`useCanvasOperations > copyNodes > should copy nodes 1`] = ` 40, 40 ], - "typeVersion": 1 + "typeVersion": 1, + "draggable": true }, { "parameters": {}, @@ -25,7 +26,8 @@ exports[`useCanvasOperations > copyNodes > should copy nodes 1`] = ` 40, 40 ], - "typeVersion": 1 + "typeVersion": 1, + "draggable": true } ], "connections": {}, @@ -52,7 +54,8 @@ exports[`useCanvasOperations > cutNodes > should copy and delete nodes 1`] = ` 40, 40 ], - "typeVersion": 1 + "typeVersion": 1, + "draggable": true }, { "parameters": {}, @@ -63,7 +66,8 @@ exports[`useCanvasOperations > cutNodes > should copy and delete nodes 1`] = ` 40, 40 ], - "typeVersion": 1 + "typeVersion": 1, + "draggable": true } ], "connections": {}, diff --git a/packages/frontend/editor-ui/src/composables/useCanvasMapping.test.ts b/packages/frontend/editor-ui/src/composables/useCanvasMapping.test.ts index e0ad11087c..377b1a965d 100644 --- a/packages/frontend/editor-ui/src/composables/useCanvasMapping.test.ts +++ b/packages/frontend/editor-ui/src/composables/useCanvasMapping.test.ts @@ -101,6 +101,7 @@ describe('useCanvasMapping', () => { label: manualTriggerNode.name, type: 'canvas-node', position: expect.anything(), + draggable: true, data: { id: manualTriggerNode.id, name: manualTriggerNode.name, diff --git a/packages/frontend/editor-ui/src/composables/useCanvasMapping.ts b/packages/frontend/editor-ui/src/composables/useCanvasMapping.ts index 28d71b596c..6fae34f590 100644 --- a/packages/frontend/editor-ui/src/composables/useCanvasMapping.ts +++ b/packages/frontend/editor-ui/src/composables/useCanvasMapping.ts @@ -624,6 +624,7 @@ export function useCanvasMapping({ position: { x: node.position[0], y: node.position[1] }, data, ...additionalNodePropertiesById.value[node.id], + draggable: node.draggable ?? true, }; }), ]; diff --git a/packages/frontend/editor-ui/src/constants/workflowSuggestions.ts b/packages/frontend/editor-ui/src/constants/workflowSuggestions.ts new file mode 100644 index 0000000000..56aea83767 --- /dev/null +++ b/packages/frontend/editor-ui/src/constants/workflowSuggestions.ts @@ -0,0 +1,56 @@ +export interface WorkflowSuggestion { + id: string; + summary: string; // Short text shown on the pill + prompt: string; // Full prompt +} + +export const WORKFLOW_SUGGESTIONS: WorkflowSuggestion[] = [ + { + id: 'invoice-pipeline', + summary: 'Invoice processing pipeline', + prompt: + 'Create an invoice parsing workflow using n8n forms. Extract key information (vendor, date, amount, line items) using AI, validate the data, and store structured information in Airtable. Generate a weekly spending report every Sunday at 6 PM using AI analysis and send via email.', + }, + { + id: 'ai-news-digest', + summary: 'Daily AI news digest', + prompt: + 'Create a workflow that fetches the latest AI news every morning at 8 AM. It should aggregate news from multiple sources, use LLM to summarize the top 5 stories, generate a relevant image using AI, and send everything as a structured Telegram message with article links. I should be able to chat about the news with the LLM so at least 40 last messages should be stored.', + }, + { + id: 'rag-assistant', + summary: 'RAG knowledge assistant', + prompt: + 'Build a pipeline that accepts PDF, CSV, or JSON files through an n8n form. Chunk documents into 1000-token segments, generate embeddings, and store in a vector database. Use the filename as the document key and add metadata including upload date and file type. Include a chatbot that can answer questions based on a knowledge base.', + }, + { + id: 'email-summary', + summary: 'Summarize emails with AI', + prompt: + 'Build a workflow that retrieves the last 50 emails from multiple email accounts. Merge all emails, perform AI analysis to identify action items, priorities, and sentiment. Generate a brief summary and send to Slack with categorized insights and recommended actions.', + }, + { + id: 'youtube-auto-chapters', + summary: 'YouTube video chapters', + prompt: + "I want to build an n8n workflow that automatically creates YouTube chapter timestamps by analyzing the video captions. When I trigger it manually, it should take a video ID as input, fetch the existing video metadata and captions from YouTube, use an AI language model like Google Gemini to parse the transcript into chapters with timestamps, and then update the video's description with these chapters appended. The goal is to save time and improve SEO by automating the whole process.", + }, + { + id: 'pizza-delivery', + summary: 'Pizza delivery chatbot', + prompt: + "I need an n8n workflow that creates a chatbot for my pizza delivery service. The bot should be able to answer customer questions about our pizza menu, take their orders accurately by capturing pizza type, quantity, and customer details, and also provide real-time updates when customers ask about their order status. It should use OpenAI's gpt-4.1-mini to handle conversations and integrate with HTTP APIs to get product info and manage orders. The workflow must maintain conversation context so the chatbot feels natural and can process multiple user queries sequentially.", + }, + { + id: 'lead-qualification', + summary: 'Lead qualification and call scheduling', + prompt: + 'Create a form with fields for email, company, and role. Build an automation that processes form submissions, enrich with company data from their website, uses AI to qualify the lead, sends data to Google Sheets. For high-score leads it should also schedule a 15-min call in a free slot in my calendar and send a confirmation email to both me and the lead.', + }, + { + id: 'multi-agent-research', + summary: 'Multi-agent research workflow', + prompt: + 'Create a multi-agent AI workflow where different AI agents collaborate to research a topic, fact-check information, and compile comprehensive reports.', + }, +]; diff --git a/packages/frontend/editor-ui/src/views/NodeView.vue b/packages/frontend/editor-ui/src/views/NodeView.vue index 442ec4cbd1..d26eb0ef52 100644 --- a/packages/frontend/editor-ui/src/views/NodeView.vue +++ b/packages/frontend/editor-ui/src/views/NodeView.vue @@ -1836,29 +1836,34 @@ watch( return isLoading.value || isCanvasReadOnly.value || editableWorkflow.value.nodes.length !== 0; }, (isReadOnlyOrLoading) => { - const defaultFallbackNodes: INodeUi[] = [ - { - id: CanvasNodeRenderType.AddNodes, - name: CanvasNodeRenderType.AddNodes, - type: CanvasNodeRenderType.AddNodes, - typeVersion: 1, - position: [0, 0], - parameters: {}, - }, - ]; - - if (builderStore.isAIBuilderEnabled && builderStore.isAssistantEnabled) { - defaultFallbackNodes.unshift({ - id: CanvasNodeRenderType.AIPrompt, - name: CanvasNodeRenderType.AIPrompt, - type: CanvasNodeRenderType.AIPrompt, - typeVersion: 1, - position: [-690, -15], - parameters: {}, - }); + if (isReadOnlyOrLoading) { + fallbackNodes.value = []; + return; } - fallbackNodes.value = isReadOnlyOrLoading ? [] : defaultFallbackNodes; + const addNodesItem: INodeUi = { + id: CanvasNodeRenderType.AddNodes, + name: CanvasNodeRenderType.AddNodes, + type: CanvasNodeRenderType.AddNodes, + typeVersion: 1, + position: [0, 0], + parameters: {}, + }; + + const aiPromptItem: INodeUi = { + id: CanvasNodeRenderType.AIPrompt, + name: CanvasNodeRenderType.AIPrompt, + type: CanvasNodeRenderType.AIPrompt, + typeVersion: 1, + position: [-690, -15], + parameters: {}, + draggable: false, + }; + + fallbackNodes.value = + builderStore.isAIBuilderEnabled && builderStore.isAssistantEnabled + ? [aiPromptItem] + : [addNodesItem]; }, );