mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 10:02:05 +00:00
feat: Add workflow name generation and save after initial generation (no-changelog) (#18348)
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
|
<initial_prompt>
|
||||||
|
{initialPrompt}
|
||||||
|
</initial_prompt>
|
||||||
|
|
||||||
|
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<typeof nameSchema>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: structuredOutput.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import type { IWorkflowBase, INode, IConnections } from 'n8n-workflow';
|
|||||||
/**
|
/**
|
||||||
* Simplified workflow representation containing only nodes and connections
|
* Simplified workflow representation containing only nodes and connections
|
||||||
*/
|
*/
|
||||||
export type SimpleWorkflow = Pick<IWorkflowBase, 'nodes' | 'connections'>;
|
export type SimpleWorkflow = Pick<IWorkflowBase, 'name' | 'nodes' | 'connections'>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Workflow operation types that can be applied to the workflow state
|
* Workflow operation types that can be applied to the workflow state
|
||||||
@@ -14,4 +14,5 @@ export type WorkflowOperation =
|
|||||||
| { type: 'addNodes'; nodes: INode[] }
|
| { type: 'addNodes'; nodes: INode[] }
|
||||||
| { type: 'updateNode'; nodeId: string; updates: Partial<INode> }
|
| { type: 'updateNode'; nodeId: string; updates: Partial<INode> }
|
||||||
| { type: 'setConnections'; connections: IConnections }
|
| { type: 'setConnections'; connections: IConnections }
|
||||||
| { type: 'mergeConnections'; connections: IConnections };
|
| { type: 'mergeConnections'; connections: IConnections }
|
||||||
|
| { type: 'setName'; name: string };
|
||||||
|
|||||||
@@ -15,13 +15,14 @@ export function applyOperations(
|
|||||||
let result: SimpleWorkflow = {
|
let result: SimpleWorkflow = {
|
||||||
nodes: [...workflow.nodes],
|
nodes: [...workflow.nodes],
|
||||||
connections: { ...workflow.connections },
|
connections: { ...workflow.connections },
|
||||||
|
name: workflow.name || '',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Apply each operation in sequence
|
// Apply each operation in sequence
|
||||||
for (const operation of operations) {
|
for (const operation of operations) {
|
||||||
switch (operation.type) {
|
switch (operation.type) {
|
||||||
case 'clear':
|
case 'clear':
|
||||||
result = { nodes: [], connections: {} };
|
result = { nodes: [], connections: {}, name: '' };
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'removeNode': {
|
case 'removeNode': {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ describe('operations-processor', () => {
|
|||||||
node3 = createNode({ id: 'node3', name: 'Node 3', position: [500, 100] });
|
node3 = createNode({ id: 'node3', name: 'Node 3', position: [500, 100] });
|
||||||
|
|
||||||
baseWorkflow = {
|
baseWorkflow = {
|
||||||
|
name: 'Test Workflow',
|
||||||
nodes: [node1, node2, node3],
|
nodes: [node1, node2, node3],
|
||||||
connections: {
|
connections: {
|
||||||
node1: {
|
node1: {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
NodeExecutionSchema,
|
NodeExecutionSchema,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
|
import { workflowNameChain } from '@/chains/workflow-name';
|
||||||
import { DEFAULT_AUTO_COMPACT_THRESHOLD_TOKENS, MAX_AI_BUILDER_PROMPT_LENGTH } from '@/constants';
|
import { DEFAULT_AUTO_COMPACT_THRESHOLD_TOKENS, MAX_AI_BUILDER_PROMPT_LENGTH } from '@/constants';
|
||||||
|
|
||||||
import { conversationCompactChain } from './chains/conversation-compact';
|
import { conversationCompactChain } from './chains/conversation-compact';
|
||||||
@@ -122,7 +123,7 @@ export class WorkflowBuilderAgent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const shouldModifyState = (state: typeof WorkflowState.State) => {
|
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
|
const lastHumanMessage = messages.findLast((m) => m instanceof HumanMessage)!; // There always should be at least one human message in the array
|
||||||
|
|
||||||
if (lastHumanMessage.content === '/compact') {
|
if (lastHumanMessage.content === '/compact') {
|
||||||
@@ -133,6 +134,12 @@ export class WorkflowBuilderAgent {
|
|||||||
return 'delete_messages';
|
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)) {
|
if (shouldAutoCompact(state)) {
|
||||||
return 'auto_compact_messages';
|
return 'auto_compact_messages';
|
||||||
}
|
}
|
||||||
@@ -162,6 +169,7 @@ export class WorkflowBuilderAgent {
|
|||||||
workflowJSON: {
|
workflowJSON: {
|
||||||
nodes: [],
|
nodes: [],
|
||||||
connections: {},
|
connections: {},
|
||||||
|
name: '',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -180,7 +188,7 @@ export class WorkflowBuilderAgent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { messages, previousSummary } = state;
|
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';
|
const isAutoCompact = lastHumanMessage.content !== '/compact';
|
||||||
|
|
||||||
this.logger?.debug('Compacting conversation history', {
|
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)
|
const workflow = new StateGraph(WorkflowState)
|
||||||
.addNode('agent', callModel)
|
.addNode('agent', callModel)
|
||||||
.addNode('tools', customToolExecutor)
|
.addNode('tools', customToolExecutor)
|
||||||
@@ -216,10 +258,12 @@ export class WorkflowBuilderAgent {
|
|||||||
.addNode('delete_messages', deleteMessages)
|
.addNode('delete_messages', deleteMessages)
|
||||||
.addNode('compact_messages', compactSession)
|
.addNode('compact_messages', compactSession)
|
||||||
.addNode('auto_compact_messages', compactSession)
|
.addNode('auto_compact_messages', compactSession)
|
||||||
|
.addNode('create_workflow_name', createWorkflowName)
|
||||||
.addConditionalEdges('__start__', shouldModifyState)
|
.addConditionalEdges('__start__', shouldModifyState)
|
||||||
.addEdge('tools', 'process_operations')
|
.addEdge('tools', 'process_operations')
|
||||||
.addEdge('process_operations', 'agent')
|
.addEdge('process_operations', 'agent')
|
||||||
.addEdge('auto_compact_messages', 'agent')
|
.addEdge('auto_compact_messages', 'agent')
|
||||||
|
.addEdge('create_workflow_name', 'agent')
|
||||||
.addEdge('delete_messages', END)
|
.addEdge('delete_messages', END)
|
||||||
.addEdge('compact_messages', END)
|
.addEdge('compact_messages', END)
|
||||||
.addConditionalEdges('agent', shouldContinue);
|
.addConditionalEdges('agent', shouldContinue);
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export const WorkflowState = Annotation.Root({
|
|||||||
// Now a simple field without custom reducer - all updates go through operations
|
// Now a simple field without custom reducer - all updates go through operations
|
||||||
workflowJSON: Annotation<SimpleWorkflow>({
|
workflowJSON: Annotation<SimpleWorkflow>({
|
||||||
reducer: (x, y) => y ?? x,
|
reducer: (x, y) => y ?? x,
|
||||||
default: () => ({ nodes: [], connections: {} }),
|
default: () => ({ nodes: [], connections: {}, name: '' }),
|
||||||
}),
|
}),
|
||||||
// Operations to apply to the workflow - processed by a separate node
|
// Operations to apply to the workflow - processed by a separate node
|
||||||
workflowOperations: Annotation<WorkflowOperation[] | null>({
|
workflowOperations: Annotation<WorkflowOperation[] | null>({
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export const createNode = (overrides: Partial<INode> = {}): INode => ({
|
|||||||
|
|
||||||
// Simple workflow builder
|
// Simple workflow builder
|
||||||
export const createWorkflow = (nodes: INode[] = []): SimpleWorkflow => {
|
export const createWorkflow = (nodes: INode[] = []): SimpleWorkflow => {
|
||||||
const workflow: SimpleWorkflow = { nodes, connections: {} };
|
const workflow: SimpleWorkflow = { nodes, connections: {}, name: 'Test workflow' };
|
||||||
return workflow;
|
return workflow;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 { createComponentRenderer } from '@/__tests__/render';
|
||||||
import { setActivePinia } from 'pinia';
|
import { setActivePinia } from 'pinia';
|
||||||
import { createTestingPinia } from '@pinia/testing';
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
@@ -12,6 +25,7 @@ import { useBuilderStore } from '@/stores/builder.store';
|
|||||||
import { mockedStore } from '@/__tests__/utils';
|
import { mockedStore } from '@/__tests__/utils';
|
||||||
import { STORES } from '@n8n/stores';
|
import { STORES } from '@n8n/stores';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
|
import type { INodeUi } from '@/Interface';
|
||||||
|
|
||||||
vi.mock('@/event-bus', () => ({
|
vi.mock('@/event-bus', () => ({
|
||||||
nodeViewEventBus: {
|
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';
|
const workflowPrompt = 'Create a workflow';
|
||||||
describe('AskAssistantBuild', () => {
|
describe('AskAssistantBuild', () => {
|
||||||
const sessionId = faker.string.uuid();
|
const sessionId = faker.string.uuid();
|
||||||
@@ -140,7 +144,10 @@ describe('AskAssistantBuild', () => {
|
|||||||
|
|
||||||
await flushPromises();
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,7 +44,11 @@ async function onUserMessage(content: string) {
|
|||||||
if (isNewWorkflow) {
|
if (isNewWorkflow) {
|
||||||
await workflowSaver.saveCurrentWorkflow();
|
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
|
// Watch for workflow updates and apply them
|
||||||
@@ -70,6 +74,7 @@ watch(
|
|||||||
nodesIdsToTidyUp: result.newNodeIds,
|
nodesIdsToTidyUp: result.newNodeIds,
|
||||||
regenerateIds: false,
|
regenerateIds: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track tool usage for telemetry
|
// Track tool usage for telemetry
|
||||||
const newToolMessages = builderStore.toolMessages.filter(
|
const newToolMessages = builderStore.toolMessages.filter(
|
||||||
(toolMsg) =>
|
(toolMsg) =>
|
||||||
@@ -94,6 +99,33 @@ watch(
|
|||||||
{ deep: true },
|
{ 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() {
|
function onNewWorkflow() {
|
||||||
builderStore.resetBuilderChat();
|
builderStore.resetBuilderChat();
|
||||||
processedWorkflowUpdates.value.clear();
|
processedWorkflowUpdates.value.clear();
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ describe('CanvasNodeAIPrompt', () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(openChat).toHaveBeenCalled();
|
expect(openChat).toHaveBeenCalled();
|
||||||
expect(sendChatMessage).toHaveBeenCalledWith({
|
expect(sendChatMessage).toHaveBeenCalledWith({
|
||||||
|
initialGeneration: true,
|
||||||
text: 'Test prompt',
|
text: 'Test prompt',
|
||||||
source: 'canvas',
|
source: 'canvas',
|
||||||
});
|
});
|
||||||
@@ -174,6 +175,7 @@ describe('CanvasNodeAIPrompt', () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(openChat).toHaveBeenCalled();
|
expect(openChat).toHaveBeenCalled();
|
||||||
expect(sendChatMessage).toHaveBeenCalledWith({
|
expect(sendChatMessage).toHaveBeenCalledWith({
|
||||||
|
initialGeneration: true,
|
||||||
text: 'Test workflow prompt',
|
text: 'Test workflow prompt',
|
||||||
source: 'canvas',
|
source: 'canvas',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -55,7 +55,8 @@ async function onSubmit() {
|
|||||||
// Here we need to await for chat to open and session to be loaded
|
// Here we need to await for chat to open and session to be loaded
|
||||||
await builderStore.openChat();
|
await builderStore.openChat();
|
||||||
isLoading.value = false;
|
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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { useSettingsStore } from '@/stores/settings.store';
|
|||||||
import { defaultSettings } from '../__tests__/defaults';
|
import { defaultSettings } from '../__tests__/defaults';
|
||||||
import merge from 'lodash/merge';
|
import merge from 'lodash/merge';
|
||||||
import { DEFAULT_POSTHOG_SETTINGS } from './posthog.test';
|
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 { reactive } from 'vue';
|
||||||
import * as chatAPI from '@/api/ai';
|
import * as chatAPI from '@/api/ai';
|
||||||
import * as telemetryModule from '@/composables/useTelemetry';
|
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<typeof useSettingsStore>;
|
let settingsStore: ReturnType<typeof useSettingsStore>;
|
||||||
let posthogStore: ReturnType<typeof usePostHog>;
|
let posthogStore: ReturnType<typeof usePostHog>;
|
||||||
|
|
||||||
@@ -76,6 +106,14 @@ describe('AI Builder store', () => {
|
|||||||
posthogStore = usePostHog();
|
posthogStore = usePostHog();
|
||||||
posthogStore.init();
|
posthogStore.init();
|
||||||
track.mockReset();
|
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(() => {
|
afterEach(() => {
|
||||||
@@ -778,4 +816,240 @@ describe('AI Builder store', () => {
|
|||||||
expect(userMessage).not.toHaveProperty('ratingStyle');
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { VIEWS } from '@/constants';
|
import type { VIEWS } from '@/constants';
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_NEW_WORKFLOW_NAME,
|
||||||
ASK_AI_SLIDE_OUT_DURATION_MS,
|
ASK_AI_SLIDE_OUT_DURATION_MS,
|
||||||
EDITABLE_CANVAS_VIEWS,
|
EDITABLE_CANVAS_VIEWS,
|
||||||
WORKFLOW_BUILDER_EXPERIMENT,
|
WORKFLOW_BUILDER_EXPERIMENT,
|
||||||
@@ -37,6 +38,7 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
|
|||||||
const streaming = ref<boolean>(false);
|
const streaming = ref<boolean>(false);
|
||||||
const assistantThinkingMessage = ref<string | undefined>();
|
const assistantThinkingMessage = ref<string | undefined>();
|
||||||
const streamingAbortController = ref<AbortController | null>(null);
|
const streamingAbortController = ref<AbortController | null>(null);
|
||||||
|
const initialGeneration = ref<boolean>(false);
|
||||||
|
|
||||||
// Store dependencies
|
// Store dependencies
|
||||||
const settings = useSettingsStore();
|
const settings = useSettingsStore();
|
||||||
@@ -101,6 +103,7 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
|
|||||||
function resetBuilderChat() {
|
function resetBuilderChat() {
|
||||||
chatMessages.value = clearMessages();
|
chatMessages.value = clearMessages();
|
||||||
assistantThinkingMessage.value = undefined;
|
assistantThinkingMessage.value = undefined;
|
||||||
|
initialGeneration.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -234,12 +237,18 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
|
|||||||
text: string;
|
text: string;
|
||||||
source?: 'chat' | 'canvas';
|
source?: 'chat' | 'canvas';
|
||||||
quickReplyType?: string;
|
quickReplyType?: string;
|
||||||
|
initialGeneration?: boolean;
|
||||||
}) {
|
}) {
|
||||||
if (streaming.value) {
|
if (streaming.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { text, source = 'chat', quickReplyType } = options;
|
const { text, source = 'chat', quickReplyType } = options;
|
||||||
|
|
||||||
|
// Set initial generation flag if provided
|
||||||
|
if (options.initialGeneration !== undefined) {
|
||||||
|
initialGeneration.value = options.initialGeneration;
|
||||||
|
}
|
||||||
const messageId = generateMessageId();
|
const messageId = generateMessageId();
|
||||||
|
|
||||||
const currentWorkflowJson = getWorkflowSnapshot();
|
const currentWorkflowJson = getWorkflowSnapshot();
|
||||||
@@ -370,12 +379,22 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Capture current state before clearing
|
// Capture current state before clearing
|
||||||
const { nodePositions } = captureCurrentWorkflowState();
|
const { nodePositions, existingNodeIds } = captureCurrentWorkflowState();
|
||||||
|
|
||||||
// Clear existing workflow
|
// Clear existing workflow
|
||||||
workflowsStore.removeAllConnections({ setStateDirty: false });
|
workflowsStore.removeAllConnections({ setStateDirty: false });
|
||||||
workflowsStore.removeAllNodes({ setStateDirty: false, removePinData: true });
|
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
|
// Restore positions for nodes that still exist and identify new nodes
|
||||||
const nodesIdsToTidyUp: string[] = [];
|
const nodesIdsToTidyUp: string[] = [];
|
||||||
if (workflowData.nodes) {
|
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() {
|
function getWorkflowSnapshot() {
|
||||||
@@ -416,6 +440,7 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
|
|||||||
workflowMessages,
|
workflowMessages,
|
||||||
trackingSessionId,
|
trackingSessionId,
|
||||||
streamingAbortController,
|
streamingAbortController,
|
||||||
|
initialGeneration,
|
||||||
|
|
||||||
// Methods
|
// Methods
|
||||||
updateWindowWidth,
|
updateWindowWidth,
|
||||||
|
|||||||
Reference in New Issue
Block a user