diff --git a/packages/@n8n/ai-workflow-builder.ee/src/chains/workflow-name.ts b/packages/@n8n/ai-workflow-builder.ee/src/chains/workflow-name.ts new file mode 100644 index 0000000000..d03285dd50 --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/src/chains/workflow-name.ts @@ -0,0 +1,33 @@ +import type { BaseChatModel } from '@langchain/core/language_models/chat_models'; +import { PromptTemplate } from '@langchain/core/prompts'; +import z from 'zod'; + +const workflowNamingPromptTemplate = PromptTemplate.fromTemplate( + `Based on the initial user prompt, please generate a name for the workflow that captures its essence and purpose. + + +{initialPrompt} + + +This name should be concise, descriptive, and suitable for a workflow that automates tasks related to the given prompt. The name should be in a format that is easy to read and understand. +`, +); + +export async function workflowNameChain(llm: BaseChatModel, initialPrompt: string) { + // Use structured output for the workflow name to ensure it meets the required format and length + const nameSchema = z.object({ + name: z.string().min(10).max(128).describe('Name of the workflow based on the prompt'), + }); + + const modelWithStructure = llm.withStructuredOutput(nameSchema); + + const prompt = await workflowNamingPromptTemplate.invoke({ + initialPrompt, + }); + + const structuredOutput = (await modelWithStructure.invoke(prompt)) as z.infer; + + return { + name: structuredOutput.name, + }; +} diff --git a/packages/@n8n/ai-workflow-builder.ee/src/types/workflow.ts b/packages/@n8n/ai-workflow-builder.ee/src/types/workflow.ts index 7f1fddb4c9..635e92bb81 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/types/workflow.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/types/workflow.ts @@ -3,7 +3,7 @@ import type { IWorkflowBase, INode, IConnections } from 'n8n-workflow'; /** * Simplified workflow representation containing only nodes and connections */ -export type SimpleWorkflow = Pick; +export type SimpleWorkflow = Pick; /** * Workflow operation types that can be applied to the workflow state @@ -14,4 +14,5 @@ export type WorkflowOperation = | { type: 'addNodes'; nodes: INode[] } | { type: 'updateNode'; nodeId: string; updates: Partial } | { type: 'setConnections'; connections: IConnections } - | { type: 'mergeConnections'; connections: IConnections }; + | { type: 'mergeConnections'; connections: IConnections } + | { type: 'setName'; name: string }; diff --git a/packages/@n8n/ai-workflow-builder.ee/src/utils/operations-processor.ts b/packages/@n8n/ai-workflow-builder.ee/src/utils/operations-processor.ts index 4de54825e2..10470392b3 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/utils/operations-processor.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/utils/operations-processor.ts @@ -15,13 +15,14 @@ export function applyOperations( let result: SimpleWorkflow = { nodes: [...workflow.nodes], connections: { ...workflow.connections }, + name: workflow.name || '', }; // Apply each operation in sequence for (const operation of operations) { switch (operation.type) { case 'clear': - result = { nodes: [], connections: {} }; + result = { nodes: [], connections: {}, name: '' }; break; case 'removeNode': { diff --git a/packages/@n8n/ai-workflow-builder.ee/src/utils/test/operations-processor.test.ts b/packages/@n8n/ai-workflow-builder.ee/src/utils/test/operations-processor.test.ts index 6c67395165..4feca4305f 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/utils/test/operations-processor.test.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/utils/test/operations-processor.test.ts @@ -18,6 +18,7 @@ describe('operations-processor', () => { node3 = createNode({ id: 'node3', name: 'Node 3', position: [500, 100] }); baseWorkflow = { + name: 'Test Workflow', nodes: [node1, node2, node3], connections: { node1: { diff --git a/packages/@n8n/ai-workflow-builder.ee/src/workflow-builder-agent.ts b/packages/@n8n/ai-workflow-builder.ee/src/workflow-builder-agent.ts index 2bfa7bca2f..8a5aaa5c98 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/workflow-builder-agent.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/workflow-builder-agent.ts @@ -12,6 +12,7 @@ import type { NodeExecutionSchema, } from 'n8n-workflow'; +import { workflowNameChain } from '@/chains/workflow-name'; import { DEFAULT_AUTO_COMPACT_THRESHOLD_TOKENS, MAX_AI_BUILDER_PROMPT_LENGTH } from '@/constants'; import { conversationCompactChain } from './chains/conversation-compact'; @@ -122,7 +123,7 @@ export class WorkflowBuilderAgent { }; const shouldModifyState = (state: typeof WorkflowState.State) => { - const { messages } = state; + const { messages, workflowContext } = state; const lastHumanMessage = messages.findLast((m) => m instanceof HumanMessage)!; // There always should be at least one human message in the array if (lastHumanMessage.content === '/compact') { @@ -133,6 +134,12 @@ export class WorkflowBuilderAgent { return 'delete_messages'; } + // If the workflow is empty (no nodes), + // we consider it initial generation request and auto-generate a name for the workflow. + if (workflowContext?.currentWorkflow?.nodes?.length === 0 && messages.length === 1) { + return 'create_workflow_name'; + } + if (shouldAutoCompact(state)) { return 'auto_compact_messages'; } @@ -162,6 +169,7 @@ export class WorkflowBuilderAgent { workflowJSON: { nodes: [], connections: {}, + name: '', }, }; @@ -180,7 +188,7 @@ export class WorkflowBuilderAgent { } const { messages, previousSummary } = state; - const lastHumanMessage = messages[messages.length - 1] as HumanMessage; + const lastHumanMessage = messages[messages.length - 1] satisfies HumanMessage; const isAutoCompact = lastHumanMessage.content !== '/compact'; this.logger?.debug('Compacting conversation history', { @@ -209,6 +217,40 @@ export class WorkflowBuilderAgent { }; }; + /** + * Creates a workflow name based on the initial user message. + */ + const createWorkflowName = async (state: typeof WorkflowState.State) => { + if (!this.llmSimpleTask) { + throw new LLMServiceError('LLM not setup'); + } + + const { workflowJSON, messages } = state; + + if (messages.length === 1 && messages[0] instanceof HumanMessage) { + const initialMessage = messages[0] satisfies HumanMessage; + + if (typeof initialMessage.content !== 'string') { + this.logger?.debug( + 'Initial message content is not a string, skipping workflow name generation', + ); + return {}; + } + + this.logger?.debug('Generating workflow name'); + const { name } = await workflowNameChain(this.llmSimpleTask, initialMessage.content); + + return { + workflowJSON: { + ...workflowJSON, + name, + }, + }; + } + + return {}; + }; + const workflow = new StateGraph(WorkflowState) .addNode('agent', callModel) .addNode('tools', customToolExecutor) @@ -216,10 +258,12 @@ export class WorkflowBuilderAgent { .addNode('delete_messages', deleteMessages) .addNode('compact_messages', compactSession) .addNode('auto_compact_messages', compactSession) + .addNode('create_workflow_name', createWorkflowName) .addConditionalEdges('__start__', shouldModifyState) .addEdge('tools', 'process_operations') .addEdge('process_operations', 'agent') .addEdge('auto_compact_messages', 'agent') + .addEdge('create_workflow_name', 'agent') .addEdge('delete_messages', END) .addEdge('compact_messages', END) .addConditionalEdges('agent', shouldContinue); diff --git a/packages/@n8n/ai-workflow-builder.ee/src/workflow-state.ts b/packages/@n8n/ai-workflow-builder.ee/src/workflow-state.ts index 7332c63765..641a6a045c 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/workflow-state.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/workflow-state.ts @@ -68,7 +68,7 @@ export const WorkflowState = Annotation.Root({ // Now a simple field without custom reducer - all updates go through operations workflowJSON: Annotation({ reducer: (x, y) => y ?? x, - default: () => ({ nodes: [], connections: {} }), + default: () => ({ nodes: [], connections: {}, name: '' }), }), // Operations to apply to the workflow - processed by a separate node workflowOperations: Annotation({ diff --git a/packages/@n8n/ai-workflow-builder.ee/test/test-utils.ts b/packages/@n8n/ai-workflow-builder.ee/test/test-utils.ts index 5c0a23eb35..3b43f16a10 100644 --- a/packages/@n8n/ai-workflow-builder.ee/test/test-utils.ts +++ b/packages/@n8n/ai-workflow-builder.ee/test/test-utils.ts @@ -44,7 +44,7 @@ export const createNode = (overrides: Partial = {}): INode => ({ // Simple workflow builder export const createWorkflow = (nodes: INode[] = []): SimpleWorkflow => { - const workflow: SimpleWorkflow = { nodes, connections: {} }; + const workflow: SimpleWorkflow = { nodes, connections: {}, name: 'Test workflow' }; return workflow; }; diff --git a/packages/frontend/editor-ui/src/components/AskAssistant/Agent/AskAssistantBuild.test.ts b/packages/frontend/editor-ui/src/components/AskAssistant/Agent/AskAssistantBuild.test.ts index b37a493d5c..3d06354ef8 100644 --- a/packages/frontend/editor-ui/src/components/AskAssistant/Agent/AskAssistantBuild.test.ts +++ b/packages/frontend/editor-ui/src/components/AskAssistant/Agent/AskAssistantBuild.test.ts @@ -1,4 +1,17 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, beforeAll } from 'vitest'; + +// Mock workflow saving first before any other imports +const saveCurrentWorkflowMock = vi.hoisted(() => vi.fn()); +vi.mock('@/composables/useWorkflowSaving', () => ({ + useWorkflowSaving: vi.fn().mockReturnValue({ + saveCurrentWorkflow: saveCurrentWorkflowMock, + getWorkflowDataToSave: vi.fn(), + setDocumentTitle: vi.fn(), + executeData: vi.fn(), + getNodeTypes: vi.fn().mockReturnValue([]), + }), +})); + import { createComponentRenderer } from '@/__tests__/render'; import { setActivePinia } from 'pinia'; import { createTestingPinia } from '@pinia/testing'; @@ -12,6 +25,7 @@ import { useBuilderStore } from '@/stores/builder.store'; import { mockedStore } from '@/__tests__/utils'; import { STORES } from '@n8n/stores'; import { useWorkflowsStore } from '@/stores/workflows.store'; +import type { INodeUi } from '@/Interface'; vi.mock('@/event-bus', () => ({ nodeViewEventBus: { @@ -53,16 +67,6 @@ vi.mock('vue-router', () => { }; }); -vi.mock('@/composables/useWorkflowSaving', () => ({ - useWorkflowSaving: vi.fn().mockReturnValue({ - saveCurrentWorkflow: vi.fn(), - getWorkflowDataToSave: vi.fn(), - setDocumentTitle: vi.fn(), - executeData: vi.fn(), - getNodeTypes: vi.fn().mockReturnValue([]), - }), -})); - const workflowPrompt = 'Create a workflow'; describe('AskAssistantBuild', () => { const sessionId = faker.string.uuid(); @@ -140,7 +144,10 @@ describe('AskAssistantBuild', () => { await flushPromises(); - expect(builderStore.sendChatMessage).toHaveBeenCalledWith({ text: testMessage }); + expect(builderStore.sendChatMessage).toHaveBeenCalledWith({ + initialGeneration: true, + text: testMessage, + }); }); }); @@ -313,4 +320,660 @@ describe('AskAssistantBuild', () => { }); }); }); + + describe('workflow saving after generation', () => { + it('should save workflow after initial generation when workflow was empty', async () => { + // Setup: empty workflow + workflowsStore.$patch({ workflow: { nodes: [], connections: {} } }); + workflowsStore.isNewWorkflow = false; + + const { findByTestId } = renderComponent(); + + // Send initial message to start generation + const testMessage = 'Create a workflow to send emails'; + const chatInput = await findByTestId('chat-input'); + await fireEvent.update(chatInput, testMessage); + const sendButton = await findByTestId('send-message-button'); + await fireEvent.click(sendButton); + + expect(builderStore.sendChatMessage).toHaveBeenCalledWith({ + initialGeneration: true, + text: testMessage, + }); + + // Simulate streaming starts + builderStore.$patch({ streaming: true, initialGeneration: true }); + + await flushPromises(); + + // Simulate workflow update with nodes + workflowsStore.$patch({ + workflow: { + nodes: [ + { + id: 'node1', + name: 'Start', + type: 'n8n-nodes-base.start', + position: [0, 0], + typeVersion: 1, + parameters: {}, + } as INodeUi, + ], + connections: {}, + }, + }); + + // Add successful message to chat to indicate successful generation + builderStore.$patch({ + chatMessages: [ + { id: '1', role: 'user', type: 'text', content: testMessage }, + { id: '2', role: 'assistant', type: 'text', content: 'Workflow created successfully' }, + ], + }); + + // Simulate streaming ends + builderStore.$patch({ streaming: false }); + await flushPromises(); + + // Verify workflow was saved + expect(saveCurrentWorkflowMock).toHaveBeenCalled(); + }); + + it('should NOT save workflow after generation when workflow already had nodes', async () => { + // Setup: workflow with existing nodes + workflowsStore.$patch({ + workflow: { + nodes: [ + { + id: 'existing', + name: 'Existing', + type: 'n8n-nodes-base.start', + position: [0, 0], + typeVersion: 1, + parameters: {}, + } as INodeUi, + ], + connections: {}, + }, + }); + workflowsStore.isNewWorkflow = false; + + const { findByTestId } = renderComponent(); + + // Send message to modify existing workflow + const testMessage = 'Add an HTTP node'; + const chatInput = await findByTestId('chat-input'); + await fireEvent.update(chatInput, testMessage); + const sendButton = await findByTestId('send-message-button'); + await fireEvent.click(sendButton); + + expect(builderStore.sendChatMessage).toHaveBeenCalledWith({ + initialGeneration: false, + text: testMessage, + }); + + // Simulate streaming starts + builderStore.$patch({ streaming: true }); + await flushPromises(); + + // Simulate workflow update with additional nodes + workflowsStore.$patch({ + workflow: { + nodes: [ + { + id: 'existing', + name: 'Existing', + type: 'n8n-nodes-base.start', + position: [0, 0], + typeVersion: 1, + parameters: {}, + } as INodeUi, + { + id: 'node2', + name: 'HTTP', + type: 'n8n-nodes-base.httpRequest', + position: [100, 0], + typeVersion: 1, + parameters: {}, + } as INodeUi, + ], + connections: {}, + }, + }); + + // Simulate streaming ends + builderStore.$patch({ streaming: false }); + await flushPromises(); + + // Verify workflow was NOT saved + expect(saveCurrentWorkflowMock).toHaveBeenCalledTimes(0); + }); + + it('should NOT save workflow when generation ends with error', async () => { + // Setup: empty workflow + workflowsStore.$patch({ workflow: { nodes: [], connections: {} } }); + workflowsStore.isNewWorkflow = false; + + const { findByTestId } = renderComponent(); + + // Send initial message + const testMessage = 'Create a workflow'; + const chatInput = await findByTestId('chat-input'); + await fireEvent.update(chatInput, testMessage); + const sendButton = await findByTestId('send-message-button'); + await fireEvent.click(sendButton); + + // The component should have set initialGeneration to true since workflow was empty + await flushPromises(); + + // Simulate streaming starts + builderStore.$patch({ streaming: true }); + await flushPromises(); + + // Simulate workflow update with nodes BEFORE error occurs + workflowsStore.$patch({ + workflow: { + nodes: [ + { + id: 'node1', + name: 'Start', + type: 'n8n-nodes-base.start', + position: [0, 0], + typeVersion: 1, + parameters: {}, + } as INodeUi, + ], + connections: {}, + }, + }); + + // Simulate error message added to chat + builderStore.$patch({ + chatMessages: [ + { id: '1', role: 'user', type: 'text', content: testMessage }, + { id: '2', role: 'assistant', type: 'error', content: 'An error occurred' }, + ], + }); + + // Simulate streaming ends with error + builderStore.$patch({ streaming: false }); + await flushPromises(); + + // Verify workflow was NOT saved despite having nodes because of the error + expect(saveCurrentWorkflowMock).not.toHaveBeenCalled(); + }); + + it('should NOT save workflow when generation is cancelled', async () => { + // Setup: empty workflow + workflowsStore.$patch({ workflow: { nodes: [], connections: {} } }); + workflowsStore.isNewWorkflow = false; + + const { findByTestId } = renderComponent(); + + // Send initial message + const testMessage = 'Create a workflow'; + const chatInput = await findByTestId('chat-input'); + await fireEvent.update(chatInput, testMessage); + const sendButton = await findByTestId('send-message-button'); + await fireEvent.click(sendButton); + + // Simulate streaming starts + builderStore.$patch({ streaming: true }); + await flushPromises(); + + // Simulate workflow update with nodes BEFORE cancellation + workflowsStore.$patch({ + workflow: { + nodes: [ + { + id: 'node1', + name: 'Start', + type: 'n8n-nodes-base.start', + position: [0, 0], + typeVersion: 1, + parameters: {}, + } as INodeUi, + ], + connections: {}, + }, + }); + + // User cancels generation - this adds a "[Task aborted]" message + builderStore.$patch({ + chatMessages: [ + { id: '1', role: 'user', type: 'text', content: testMessage }, + { id: '2', role: 'assistant', type: 'text', content: '[Task aborted]' }, + ], + }); + + // Simulate streaming ends after cancellation + builderStore.$patch({ streaming: false }); + await flushPromises(); + + // Verify workflow was NOT saved despite having nodes because generation was cancelled + expect(saveCurrentWorkflowMock).not.toHaveBeenCalled(); + }); + + it('should save new workflow before sending first message', async () => { + // Setup: new workflow + workflowsStore.isNewWorkflow = true; + workflowsStore.$patch({ workflow: { nodes: [], connections: {} } }); + + const { findByTestId } = renderComponent(); + + // Send initial message + const testMessage = 'Create a workflow'; + const chatInput = await findByTestId('chat-input'); + await fireEvent.update(chatInput, testMessage); + const sendButton = await findByTestId('send-message-button'); + await fireEvent.click(sendButton); + + await flushPromises(); + + // Verify workflow was saved to get ID for session + expect(saveCurrentWorkflowMock).toHaveBeenCalledTimes(1); + }); + + it('should work when opening existing AI builder session', async () => { + // Setup: existing workflow with AI session + workflowsStore.workflowId = 'existing-id'; + workflowsStore.isNewWorkflow = false; + workflowsStore.$patch({ workflow: { nodes: [], connections: {} } }); + + // Simulate existing AI session messages + builderStore.$patch({ + chatMessages: [ + { id: '1', role: 'user', type: 'text', content: 'Previous message' }, + { id: '2', role: 'assistant', type: 'text', content: 'Previous response' }, + ], + }); + + const { findByTestId } = renderComponent(); + + // Send new message in existing session + const testMessage = 'Add email nodes'; + const chatInput = await findByTestId('chat-input'); + await fireEvent.update(chatInput, testMessage); + const sendButton = await findByTestId('send-message-button'); + await fireEvent.click(sendButton); + + // Simulate streaming starts + builderStore.$patch({ streaming: true, initialGeneration: true }); + await flushPromises(); + + // Add nodes to workflow + workflowsStore.$patch({ + workflow: { + nodes: [ + { + id: 'node1', + name: 'Email', + type: 'n8n-nodes-base.emailSend', + position: [0, 0], + typeVersion: 1, + parameters: {}, + } as INodeUi, + ], + connections: {}, + }, + }); + + // Add successful message to chat (building on existing session messages) + builderStore.$patch({ + chatMessages: [ + { id: '1', role: 'user', type: 'text', content: 'Previous message' }, + { id: '2', role: 'assistant', type: 'text', content: 'Previous response' }, + { id: '3', role: 'user', type: 'text', content: testMessage }, + { id: '4', role: 'assistant', type: 'text', content: 'Added email nodes successfully' }, + ], + }); + + // Simulate streaming ends + builderStore.$patch({ streaming: false }); + await flushPromises(); + + // Verify workflow was saved + expect(saveCurrentWorkflowMock).toHaveBeenCalled(); + }); + + it('should save workflow when user deletes all nodes then regenerates', async () => { + // Setup: workflow with existing nodes + workflowsStore.$patch({ + workflow: { + nodes: [ + { + id: 'existing', + name: 'Existing', + type: 'n8n-nodes-base.start', + position: [0, 0], + typeVersion: 1, + parameters: {}, + } as INodeUi, + ], + connections: {}, + }, + }); + workflowsStore.isNewWorkflow = false; + + const { findByTestId } = renderComponent(); + + // User manually deletes all nodes (simulated by clearing workflow) + workflowsStore.$patch({ workflow: { nodes: [], connections: {} } }); + await flushPromises(); + + // Send message to generate new workflow + const testMessage = 'Create a new workflow'; + const chatInput = await findByTestId('chat-input'); + await fireEvent.update(chatInput, testMessage); + const sendButton = await findByTestId('send-message-button'); + await fireEvent.click(sendButton); + + expect(builderStore.sendChatMessage).toHaveBeenCalledWith({ + initialGeneration: true, + text: testMessage, + }); + + // Simulate streaming starts + builderStore.$patch({ streaming: true, initialGeneration: true }); + await flushPromises(); + + // Add new nodes to workflow + workflowsStore.$patch({ + workflow: { + nodes: [ + { + id: 'new-node', + name: 'New Start', + type: 'n8n-nodes-base.start', + position: [0, 0], + typeVersion: 1, + parameters: {}, + } as INodeUi, + ], + connections: {}, + }, + }); + + // Add successful message to chat + builderStore.$patch({ + chatMessages: [ + { id: '1', role: 'user', type: 'text', content: testMessage }, + { + id: '2', + role: 'assistant', + type: 'text', + content: 'New workflow created successfully', + }, + ], + }); + + // Simulate streaming ends + builderStore.$patch({ streaming: false }); + await flushPromises(); + + // Verify workflow was saved after regeneration + expect(saveCurrentWorkflowMock).toHaveBeenCalled(); + }); + + it('should NOT save if workflow is still empty after generation ends', async () => { + // Setup: empty workflow + workflowsStore.$patch({ workflow: { nodes: [], connections: {} } }); + workflowsStore.isNewWorkflow = false; + + const { findByTestId } = renderComponent(); + + // Send message + const testMessage = 'Create a workflow'; + const chatInput = await findByTestId('chat-input'); + await fireEvent.update(chatInput, testMessage); + const sendButton = await findByTestId('send-message-button'); + await fireEvent.click(sendButton); + + // Simulate streaming starts + builderStore.$patch({ streaming: true }); + await flushPromises(); + + // Workflow remains empty (maybe AI couldn't generate anything) + workflowsStore.$patch({ workflow: { nodes: [], connections: {} } }); + + // Simulate streaming ends + builderStore.$patch({ streaming: false }); + await flushPromises(); + + // Verify workflow was NOT saved + expect(saveCurrentWorkflowMock).not.toHaveBeenCalled(); + }); + }); + + describe('canvas-initiated generation', () => { + it('should save workflow after initial generation from canvas', async () => { + // Setup: empty workflow + workflowsStore.$patch({ workflow: { nodes: [], connections: {} } }); + workflowsStore.isNewWorkflow = false; + + renderComponent(); + + // Simulate canvas-initiated generation with initialGeneration flag + builderStore.initialGeneration = true; + + // Simulate streaming starts + builderStore.$patch({ streaming: true }); + await flushPromises(); + + // Simulate workflow update with nodes + workflowsStore.$patch({ + workflow: { + nodes: [ + { + id: 'node1', + name: 'Start', + type: 'n8n-nodes-base.start', + position: [0, 0], + typeVersion: 1, + parameters: {}, + } as INodeUi, + ], + connections: {}, + }, + }); + + // Add successful message to chat + builderStore.$patch({ + chatMessages: [ + { id: '1', role: 'user', type: 'text', content: 'Create workflow from canvas' }, + { id: '2', role: 'assistant', type: 'text', content: 'Workflow created successfully' }, + ], + }); + + // Simulate streaming ends + builderStore.$patch({ streaming: false }); + await flushPromises(); + + // Verify workflow was saved + expect(saveCurrentWorkflowMock).toHaveBeenCalled(); + // Verify initialGeneration flag was reset + expect(builderStore.initialGeneration).toBe(false); + }); + + it('should NOT save workflow from canvas when generation fails', async () => { + // Setup: empty workflow + workflowsStore.$patch({ workflow: { nodes: [], connections: {} } }); + workflowsStore.isNewWorkflow = false; + + renderComponent(); + + // Simulate canvas-initiated generation with initialGeneration flag + builderStore.initialGeneration = true; + + // Simulate streaming starts + builderStore.$patch({ streaming: true }); + await flushPromises(); + + // Simulate workflow update with nodes + workflowsStore.$patch({ + workflow: { + nodes: [ + { + id: 'node1', + name: 'Start', + type: 'n8n-nodes-base.start', + position: [0, 0], + typeVersion: 1, + parameters: {}, + } as INodeUi, + ], + connections: {}, + }, + }); + + // Add error message to chat + builderStore.$patch({ + chatMessages: [ + { id: '1', role: 'user', type: 'text', content: 'Create workflow from canvas' }, + { id: '2', role: 'assistant', type: 'error', content: 'Generation failed' }, + ], + }); + + // Simulate streaming ends + builderStore.$patch({ streaming: false }); + await flushPromises(); + + // Verify workflow was NOT saved + expect(saveCurrentWorkflowMock).not.toHaveBeenCalled(); + // Verify initialGeneration flag was reset + expect(builderStore.initialGeneration).toBe(false); + }); + + it('should NOT save workflow from canvas when generation is cancelled', async () => { + // Setup: empty workflow + workflowsStore.$patch({ workflow: { nodes: [], connections: {} } }); + workflowsStore.isNewWorkflow = false; + + renderComponent(); + + // Simulate canvas-initiated generation with initialGeneration flag + builderStore.initialGeneration = true; + + // Simulate streaming starts + builderStore.$patch({ streaming: true }); + await flushPromises(); + + // Simulate workflow update with nodes + workflowsStore.$patch({ + workflow: { + nodes: [ + { + id: 'node1', + name: 'Start', + type: 'n8n-nodes-base.start', + position: [0, 0], + typeVersion: 1, + parameters: {}, + } as INodeUi, + ], + connections: {}, + }, + }); + + // Add cancellation message to chat + builderStore.$patch({ + chatMessages: [ + { id: '1', role: 'user', type: 'text', content: 'Create workflow from canvas' }, + { id: '2', role: 'assistant', type: 'text', content: '[Task aborted]' }, + ], + }); + + // Simulate streaming ends + builderStore.$patch({ streaming: false }); + await flushPromises(); + + // Verify workflow was NOT saved + expect(saveCurrentWorkflowMock).not.toHaveBeenCalled(); + // Verify initialGeneration flag was reset + expect(builderStore.initialGeneration).toBe(false); + }); + + it('should handle multiple canvas generations correctly', async () => { + // Setup: empty workflow + workflowsStore.$patch({ workflow: { nodes: [], connections: {} } }); + workflowsStore.isNewWorkflow = false; + + renderComponent(); + + // First canvas generation + builderStore.initialGeneration = true; + builderStore.$patch({ streaming: true }); + await flushPromises(); + + workflowsStore.$patch({ + workflow: { + nodes: [ + { + id: 'node1', + name: 'Start', + type: 'n8n-nodes-base.start', + position: [0, 0], + typeVersion: 1, + parameters: {}, + } as INodeUi, + ], + connections: {}, + }, + }); + + builderStore.$patch({ + chatMessages: [ + { id: '1', role: 'user', type: 'text', content: 'First generation' }, + { id: '2', role: 'assistant', type: 'text', content: 'Success' }, + ], + }); + + builderStore.$patch({ streaming: false }); + await flushPromises(); + + // Verify first generation saved + expect(saveCurrentWorkflowMock).toHaveBeenCalledTimes(1); + expect(builderStore.initialGeneration).toBe(false); + + // User clears workflow manually + workflowsStore.$patch({ workflow: { nodes: [], connections: {} } }); + + // Second canvas generation + builderStore.initialGeneration = true; + builderStore.$patch({ streaming: true }); + await flushPromises(); + + workflowsStore.$patch({ + workflow: { + nodes: [ + { + id: 'node2', + name: 'HTTP', + type: 'n8n-nodes-base.httpRequest', + position: [100, 0], + typeVersion: 1, + parameters: {}, + } as INodeUi, + ], + connections: {}, + }, + }); + + builderStore.$patch({ + chatMessages: [ + { id: '1', role: 'user', type: 'text', content: 'First generation' }, + { id: '2', role: 'assistant', type: 'text', content: 'Success' }, + { id: '3', role: 'user', type: 'text', content: 'Second generation' }, + { id: '4', role: 'assistant', type: 'text', content: 'Success again' }, + ], + }); + + builderStore.$patch({ streaming: false }); + await flushPromises(); + + // Verify second generation also saved + expect(saveCurrentWorkflowMock).toHaveBeenCalledTimes(2); + expect(builderStore.initialGeneration).toBe(false); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/components/AskAssistant/Agent/AskAssistantBuild.vue b/packages/frontend/editor-ui/src/components/AskAssistant/Agent/AskAssistantBuild.vue index e9623e898d..d308b39ce2 100644 --- a/packages/frontend/editor-ui/src/components/AskAssistant/Agent/AskAssistantBuild.vue +++ b/packages/frontend/editor-ui/src/components/AskAssistant/Agent/AskAssistantBuild.vue @@ -44,7 +44,11 @@ async function onUserMessage(content: string) { if (isNewWorkflow) { await workflowSaver.saveCurrentWorkflow(); } - builderStore.sendChatMessage({ text: content }); + + // If the workflow is empty, set the initial generation flag + const isInitialGeneration = workflowsStore.workflow.nodes.length === 0; + + builderStore.sendChatMessage({ text: content, initialGeneration: isInitialGeneration }); } // Watch for workflow updates and apply them @@ -70,6 +74,7 @@ watch( nodesIdsToTidyUp: result.newNodeIds, regenerateIds: false, }); + // Track tool usage for telemetry const newToolMessages = builderStore.toolMessages.filter( (toolMsg) => @@ -94,6 +99,33 @@ watch( { deep: true }, ); +// If this is the initial generation, streaming has ended, and there were workflow updates, +// we want to save the workflow +watch( + () => builderStore.streaming, + async () => { + if ( + builderStore.initialGeneration && + !builderStore.streaming && + workflowsStore.workflow.nodes.length > 0 + ) { + // Check if the generation completed successfully (no error or cancellation) + const lastMessage = builderStore.chatMessages[builderStore.chatMessages.length - 1]; + const successful = + lastMessage && + lastMessage.type !== 'error' && + !(lastMessage.type === 'text' && lastMessage.content === '[Task aborted]'); + + builderStore.initialGeneration = false; + + // Only save if generation completed successfully + if (successful) { + await workflowSaver.saveCurrentWorkflow(); + } + } + }, +); + function onNewWorkflow() { builderStore.resetBuilderChat(); processedWorkflowUpdates.value.clear(); 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 index 88198a343f..eea3c85a7b 100644 --- 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 @@ -129,6 +129,7 @@ describe('CanvasNodeAIPrompt', () => { await waitFor(() => { expect(openChat).toHaveBeenCalled(); expect(sendChatMessage).toHaveBeenCalledWith({ + initialGeneration: true, text: 'Test prompt', source: 'canvas', }); @@ -174,6 +175,7 @@ describe('CanvasNodeAIPrompt', () => { await waitFor(() => { expect(openChat).toHaveBeenCalled(); expect(sendChatMessage).toHaveBeenCalledWith({ + initialGeneration: true, text: 'Test workflow prompt', source: 'canvas', }); 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 a50c182b83..d771015d74 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 @@ -55,7 +55,8 @@ async function onSubmit() { // Here we need to await for chat to open and session to be loaded await builderStore.openChat(); isLoading.value = false; - builderStore.sendChatMessage({ text: prompt.value, source: 'canvas' }); + // Always pass initialGeneration as true from canvas since the prompt only shows on empty canvas + builderStore.sendChatMessage({ text: prompt.value, source: 'canvas', initialGeneration: true }); } /** diff --git a/packages/frontend/editor-ui/src/stores/builder.store.test.ts b/packages/frontend/editor-ui/src/stores/builder.store.test.ts index 5f3eed0405..36acaa9fae 100644 --- a/packages/frontend/editor-ui/src/stores/builder.store.test.ts +++ b/packages/frontend/editor-ui/src/stores/builder.store.test.ts @@ -9,7 +9,7 @@ import { useSettingsStore } from '@/stores/settings.store'; import { defaultSettings } from '../__tests__/defaults'; import merge from 'lodash/merge'; import { DEFAULT_POSTHOG_SETTINGS } from './posthog.test'; -import { WORKFLOW_BUILDER_EXPERIMENT } from '@/constants'; +import { WORKFLOW_BUILDER_EXPERIMENT, DEFAULT_NEW_WORKFLOW_NAME } from '@/constants'; import { reactive } from 'vue'; import * as chatAPI from '@/api/ai'; import * as telemetryModule from '@/composables/useTelemetry'; @@ -24,6 +24,36 @@ vi.mock('@n8n/i18n', () => ({ }), })); +// Mock useToast +vi.mock('@/composables/useToast', () => ({ + useToast: () => ({ + showMessage: vi.fn(), + }), +})); + +// Mock the workflows store +const mockSetWorkflowName = vi.fn(); +const mockRemoveAllConnections = vi.fn(); +const mockRemoveAllNodes = vi.fn(); +const mockWorkflow = { + name: DEFAULT_NEW_WORKFLOW_NAME, + nodes: [], + connections: {}, +}; + +vi.mock('./workflows.store', () => ({ + useWorkflowsStore: vi.fn(() => ({ + workflow: mockWorkflow, + workflowId: 'test-workflow-id', + allNodes: [], + nodesByName: {}, + workflowExecutionData: null, + setWorkflowName: mockSetWorkflowName, + removeAllConnections: mockRemoveAllConnections, + removeAllNodes: mockRemoveAllNodes, + })), +})); + let settingsStore: ReturnType; let posthogStore: ReturnType; @@ -76,6 +106,14 @@ describe('AI Builder store', () => { posthogStore = usePostHog(); posthogStore.init(); track.mockReset(); + // Reset workflow store mocks + mockSetWorkflowName.mockReset(); + mockRemoveAllConnections.mockReset(); + mockRemoveAllNodes.mockReset(); + // Reset workflow to default state + mockWorkflow.name = DEFAULT_NEW_WORKFLOW_NAME; + mockWorkflow.nodes = []; + mockWorkflow.connections = {}; }); afterEach(() => { @@ -778,4 +816,240 @@ describe('AI Builder store', () => { expect(userMessage).not.toHaveProperty('ratingStyle'); }); }); + + describe('applyWorkflowUpdate with workflow naming', () => { + it('should apply generated workflow name during initial generation when workflow has default name', () => { + const builderStore = useBuilderStore(); + + // Set initial generation flag + builderStore.initialGeneration = true; + + // Ensure workflow has default name + mockWorkflow.name = DEFAULT_NEW_WORKFLOW_NAME; + + // Create workflow JSON with a generated name + const workflowJson = JSON.stringify({ + name: 'Generated Workflow Name for Email Processing', + nodes: [ + { + id: 'node1', + name: 'Start', + type: 'n8n-nodes-base.start', + position: [250, 300], + parameters: {}, + }, + ], + connections: {}, + }); + + // Apply the workflow update + const result = builderStore.applyWorkflowUpdate(workflowJson); + + // Verify the update was successful + expect(result.success).toBe(true); + + // Verify setWorkflowName was called with the generated name + expect(mockSetWorkflowName).toHaveBeenCalledWith({ + newName: 'Generated Workflow Name for Email Processing', + setStateDirty: false, + }); + }); + + it('should NOT apply generated workflow name during initial generation when workflow has custom name', () => { + const builderStore = useBuilderStore(); + + // Set initial generation flag + builderStore.initialGeneration = true; + + // Set a custom workflow name (not the default) + mockWorkflow.name = 'My Custom Workflow'; + + // Create workflow JSON with a generated name + const workflowJson = JSON.stringify({ + name: 'Generated Workflow Name for Email Processing', + nodes: [ + { + id: 'node1', + name: 'Start', + type: 'n8n-nodes-base.start', + position: [250, 300], + parameters: {}, + }, + ], + connections: {}, + }); + + // Apply the workflow update + const result = builderStore.applyWorkflowUpdate(workflowJson); + + // Verify the update was successful + expect(result.success).toBe(true); + + // Verify setWorkflowName was NOT called + expect(mockSetWorkflowName).not.toHaveBeenCalled(); + }); + + it('should NOT apply generated workflow name when not initial generation', () => { + const builderStore = useBuilderStore(); + + // Ensure initial generation flag is false + builderStore.initialGeneration = false; + + // Ensure workflow has default name + mockWorkflow.name = DEFAULT_NEW_WORKFLOW_NAME; + + // Create workflow JSON with a generated name + const workflowJson = JSON.stringify({ + name: 'Generated Workflow Name for Email Processing', + nodes: [ + { + id: 'node1', + name: 'Start', + type: 'n8n-nodes-base.start', + position: [250, 300], + parameters: {}, + }, + ], + connections: {}, + }); + + // Apply the workflow update + const result = builderStore.applyWorkflowUpdate(workflowJson); + + // Verify the update was successful + expect(result.success).toBe(true); + + // Verify setWorkflowName was NOT called + expect(mockSetWorkflowName).not.toHaveBeenCalled(); + }); + + it('should handle workflow updates without name property', () => { + const builderStore = useBuilderStore(); + + // Set initial generation flag + builderStore.initialGeneration = true; + + // Ensure workflow has default name + mockWorkflow.name = DEFAULT_NEW_WORKFLOW_NAME; + + // Create workflow JSON without a name property + const workflowJson = JSON.stringify({ + nodes: [ + { + id: 'node1', + name: 'Start', + type: 'n8n-nodes-base.start', + position: [250, 300], + parameters: {}, + }, + ], + connections: {}, + }); + + // Apply the workflow update + const result = builderStore.applyWorkflowUpdate(workflowJson); + + // Verify the update was successful + expect(result.success).toBe(true); + + // Verify setWorkflowName was NOT called + expect(mockSetWorkflowName).not.toHaveBeenCalled(); + }); + + it('should handle workflow names that start with but are not exactly the default name', () => { + const builderStore = useBuilderStore(); + + // Set initial generation flag + builderStore.initialGeneration = true; + + // Set workflow name that starts with default but has more text + mockWorkflow.name = `${DEFAULT_NEW_WORKFLOW_NAME} - Copy`; + + // Create workflow JSON with a generated name + const workflowJson = JSON.stringify({ + name: 'Generated Workflow Name for Email Processing', + nodes: [ + { + id: 'node1', + name: 'Start', + type: 'n8n-nodes-base.start', + position: [250, 300], + parameters: {}, + }, + ], + connections: {}, + }); + + // Apply the workflow update + const result = builderStore.applyWorkflowUpdate(workflowJson); + + // Verify the update was successful + expect(result.success).toBe(true); + + // Verify setWorkflowName WAS called because the name starts with default + expect(mockSetWorkflowName).toHaveBeenCalledWith({ + newName: 'Generated Workflow Name for Email Processing', + setStateDirty: false, + }); + }); + + it('should handle malformed JSON gracefully', () => { + const builderStore = useBuilderStore(); + + // Set initial generation flag + builderStore.initialGeneration = true; + + // Create malformed JSON + const workflowJson = '{ invalid json }'; + + // Apply the workflow update + const result = builderStore.applyWorkflowUpdate(workflowJson); + + // Verify the update failed + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + }); + + it('should maintain initial generation flag state across multiple updates', () => { + const builderStore = useBuilderStore(); + + // Set initial generation flag + builderStore.initialGeneration = true; + + // Ensure workflow has default name + mockWorkflow.name = DEFAULT_NEW_WORKFLOW_NAME; + + // First update with name + const workflowJson1 = JSON.stringify({ + name: 'First Generated Name', + nodes: [], + connections: {}, + }); + + builderStore.applyWorkflowUpdate(workflowJson1); + expect(mockSetWorkflowName).toHaveBeenCalledTimes(1); + + // The flag should still be true for subsequent updates in the same generation + expect(builderStore.initialGeneration).toBe(true); + + // Second update without name (simulating further tool operations) + const workflowJson2 = JSON.stringify({ + nodes: [ + { + id: 'node2', + name: 'HTTP', + type: 'n8n-nodes-base.httpRequest', + position: [450, 300], + parameters: {}, + }, + ], + connections: {}, + }); + + builderStore.applyWorkflowUpdate(workflowJson2); + + // Should not call setWorkflowName again + expect(mockSetWorkflowName).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/stores/builder.store.ts b/packages/frontend/editor-ui/src/stores/builder.store.ts index d3f593dce2..14b82254c2 100644 --- a/packages/frontend/editor-ui/src/stores/builder.store.ts +++ b/packages/frontend/editor-ui/src/stores/builder.store.ts @@ -1,5 +1,6 @@ import type { VIEWS } from '@/constants'; import { + DEFAULT_NEW_WORKFLOW_NAME, ASK_AI_SLIDE_OUT_DURATION_MS, EDITABLE_CANVAS_VIEWS, WORKFLOW_BUILDER_EXPERIMENT, @@ -37,6 +38,7 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => { const streaming = ref(false); const assistantThinkingMessage = ref(); const streamingAbortController = ref(null); + const initialGeneration = ref(false); // Store dependencies const settings = useSettingsStore(); @@ -101,6 +103,7 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => { function resetBuilderChat() { chatMessages.value = clearMessages(); assistantThinkingMessage.value = undefined; + initialGeneration.value = false; } /** @@ -234,12 +237,18 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => { text: string; source?: 'chat' | 'canvas'; quickReplyType?: string; + initialGeneration?: boolean; }) { if (streaming.value) { return; } const { text, source = 'chat', quickReplyType } = options; + + // Set initial generation flag if provided + if (options.initialGeneration !== undefined) { + initialGeneration.value = options.initialGeneration; + } const messageId = generateMessageId(); const currentWorkflowJson = getWorkflowSnapshot(); @@ -370,12 +379,22 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => { } // Capture current state before clearing - const { nodePositions } = captureCurrentWorkflowState(); + const { nodePositions, existingNodeIds } = captureCurrentWorkflowState(); // Clear existing workflow workflowsStore.removeAllConnections({ setStateDirty: false }); workflowsStore.removeAllNodes({ setStateDirty: false, removePinData: true }); + // For the initial generation, we want to apply auto-generated workflow name + // but only if the workflow has default name + if ( + workflowData.name && + initialGeneration.value && + workflowsStore.workflow.name.startsWith(DEFAULT_NEW_WORKFLOW_NAME) + ) { + workflowsStore.setWorkflowName({ newName: workflowData.name, setStateDirty: false }); + } + // Restore positions for nodes that still exist and identify new nodes const nodesIdsToTidyUp: string[] = []; if (workflowData.nodes) { @@ -391,7 +410,12 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => { }); } - return { success: true, workflowData, newNodeIds: nodesIdsToTidyUp }; + return { + success: true, + workflowData, + newNodeIds: nodesIdsToTidyUp, + oldNodeIds: Array.from(existingNodeIds), + }; } function getWorkflowSnapshot() { @@ -416,6 +440,7 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => { workflowMessages, trackingSessionId, streamingAbortController, + initialGeneration, // Methods updateWindowWidth,