diff --git a/packages/@n8n/ai-workflow-builder.ee/src/tools/prompts/main-agent.prompt.ts b/packages/@n8n/ai-workflow-builder.ee/src/tools/prompts/main-agent.prompt.ts
index 4ec5a59248d..0f4c2d18c1b 100644
--- a/packages/@n8n/ai-workflow-builder.ee/src/tools/prompts/main-agent.prompt.ts
+++ b/packages/@n8n/ai-workflow-builder.ee/src/tools/prompts/main-agent.prompt.ts
@@ -362,24 +362,42 @@ update_node_parameters({{
const responsePatterns = `
-IMPORTANT: Only provide ONE response AFTER all tool execution is complete.
+IMPORTANT: Only provide ONE response AFTER all tool executions are complete.
-Response format:
+EXCEPTION - Error handling:
+When tool execution fails, provide a brief acknowledgment before attempting fixes:
+- "The workflow hit an error. Let me debug this."
+- "Execution failed. Let me trace the issue."
+- "Got a workflow error. Investigating now."
+- Or similar brief phrases
+Then proceed with debugging/fixing without additional commentary.
+
+Response format conditions:
+- Include "**âī¸ How to Setup**" section ONLY if this is the initial workflow creation
+- Include "**đ What's changed**" section ONLY for non-initial modifications (skip for first workflow creation)
+- Skip setup section for minor tweaks, bug fixes, or cosmetic changes
+
+When changes section is included:
+**đ What's changed**
+- Brief bullets highlighting key modifications made
+- Focus on functional changes, not technical implementation details
+
+When setup section is included:
**âī¸ How to Setup** (numbered format)
-- List credentials and parameters that need to configured
-- Only list incomplete tasks that need user action (skip what's already configured)
+- List only parameter placeholders requiring user configuration
+- Include only incomplete tasks needing user action (skip pre-configured items)
+- IMPORTANT: NEVER instruct user to set-up authentication or credentials for nodes - this will be handled in the UI
+- IMPORTANT: Focus on workflow-specific parameters/placeholders only
-**âšī¸ How to Use**
-- Only essential user actions (what to click, where to go)
-
-End with: "Let me know if you'd like to adjust anything."
+Always end with: "Let me know if you'd like to adjust anything."
ABSOLUTELY FORBIDDEN IN BUILDING MODE:
-- Any text between tool calls
+- Any text between tool calls (except error acknowledgments)
- Progress updates during execution
-- "Perfect!", "Now let me...", "Excellent!"
-- Describing what was built
-- Explaining workflow functionality
+- Celebratory phrases ("Perfect!", "Now let me...", "Excellent!", "Great!")
+- Describing what was built or explaining functionality
+- Workflow narration or step-by-step commentary
+- Status updates while tools are running
`;
diff --git a/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/AskAssistantChat.vue b/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/AskAssistantChat.vue
index dc2ccbb8546..4e31f79049b 100644
--- a/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/AskAssistantChat.vue
+++ b/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/AskAssistantChat.vue
@@ -377,6 +377,7 @@ defineExpose({
+
renders chat with messages correctly 1`] = `
+
+
@@ -445,6 +447,8 @@ exports[`AskAssistantChat > renders end of session chat correctly 1`] = `
+
+
@@ -530,6 +534,8 @@ exports[`AskAssistantChat > renders error message correctly with retry button 1`
+
+
@@ -615,6 +621,8 @@ exports[`AskAssistantChat > renders message with code snippet 1`] = `
+
+
@@ -700,6 +708,8 @@ exports[`AskAssistantChat > renders streaming chat correctly 1`] = `
+
+
diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json
index 71fc0b9d462..859b9b09cc4 100644
--- a/packages/frontend/@n8n/i18n/src/locales/en.json
+++ b/packages/frontend/@n8n/i18n/src/locales/en.json
@@ -217,6 +217,16 @@
"aiAssistant.builder.canvasPrompt.startManually.title": "Start manually",
"aiAssistant.builder.canvasPrompt.startManually.subTitle": "Add the first node",
"aiAssistant.builder.streamAbortedMessage": "[Task aborted]",
+ "aiAssistant.builder.executeMessage.description": "Complete these steps before executing your workflow:",
+ "aiAssistant.builder.executeMessage.noIssues": "Your workflow is ready to be executed",
+ "aiAssistant.builder.executeMessage.validationTooltip": "Complete the steps above before executing",
+ "aiAssistant.builder.executeMessage.execute": "Execute and refine",
+ "aiAssistant.builder.executeMessage.noExecutionData": "Workflow execution could not be started. Please try again.",
+ "aiAssistant.builder.executeMessage.executionSuccess": "Workflow executed successfully.",
+ "aiAssistant.builder.executeMessage.executionFailedOnNode": "Workflow execution failed on node \"{nodeName}\": {errorMessage}",
+ "aiAssistant.builder.executeMessage.executionFailed": "Workflow execution failed: {errorMessage}",
+ "aiAssistant.builder.toast.title": "Send chat message to start the execution",
+ "aiAssistant.builder.toast.description": "Please send a message in the chat panel to start the execution of your workflow",
"aiAssistant.assistant": "AI Assistant",
"aiAssistant.newSessionModal.title.part1": "Start new",
"aiAssistant.newSessionModal.title.part2": "session",
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 f3c0108cd80..42fec552b78 100644
--- a/packages/frontend/editor-ui/src/components/AskAssistant/Agent/AskAssistantBuild.vue
+++ b/packages/frontend/editor-ui/src/components/AskAssistant/Agent/AskAssistantBuild.vue
@@ -11,6 +11,7 @@ import { useWorkflowSaving } from '@/composables/useWorkflowSaving';
import type { RatingFeedback } from '@n8n/design-system/types/assistant';
import { isWorkflowUpdatedMessage } from '@n8n/design-system/types/assistant';
import { nodeViewEventBus } from '@/event-bus';
+import ExecuteMessage from './ExecuteMessage.vue';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
const emit = defineEmits<{
@@ -30,7 +31,6 @@ const { goToUpgrade } = usePageRedirectionHelper();
// Track processed workflow updates
const processedWorkflowUpdates = ref(new Set());
const trackedTools = ref(new Set());
-const assistantChatRef = ref | null>(null);
const workflowUpdated = ref<{ start: string; end: string } | undefined>();
const user = computed(() => ({
@@ -40,9 +40,19 @@ const user = computed(() => ({
const loadingMessage = computed(() => builderStore.assistantThinkingMessage);
const currentRoute = computed(() => route.name);
+const showExecuteMessage = computed(() => {
+ const builderUpdatedWorkflowMessageIndex = builderStore.chatMessages.findLastIndex(
+ (msg) => msg.type === 'workflow-updated',
+ );
+ return (
+ !builderStore.streaming &&
+ workflowsStore.workflow.nodes.length > 0 &&
+ builderUpdatedWorkflowMessageIndex > -1
+ );
+});
const creditsQuota = computed(() => builderStore.creditsQuota);
const creditsRemaining = computed(() => builderStore.creditsRemaining);
-const showAskOwnerTooltip = computed(() => usersStore.isInstanceOwner !== false);
+const showAskOwnerTooltip = computed(() => usersStore.isInstanceOwner);
async function onUserMessage(content: string) {
const isNewWorkflow = workflowsStore.isNewWorkflow;
@@ -109,6 +119,56 @@ function trackWorkflowModifications() {
}
}
+function onWorkflowExecuted() {
+ const executionData = workflowsStore.workflowExecutionData;
+ const executionStatus = executionData?.status ?? 'unknown';
+ const errorNodeName = executionData?.data?.resultData.lastNodeExecuted;
+ const errorNodeType = errorNodeName
+ ? workflowsStore.workflow.nodes.find((node) => node.name === errorNodeName)?.type
+ : undefined;
+
+ if (!executionData) {
+ builderStore.sendChatMessage({
+ text: i18n.baseText('aiAssistant.builder.executeMessage.noExecutionData'),
+ type: 'execution',
+ executionStatus: 'error',
+ errorMessage: 'Workflow execution data missing after run attempt.',
+ });
+ return;
+ }
+
+ if (executionStatus === 'success') {
+ builderStore.sendChatMessage({
+ text: i18n.baseText('aiAssistant.builder.executeMessage.executionSuccess'),
+ type: 'execution',
+ executionStatus,
+ });
+ return;
+ }
+
+ const executionError = executionData.data?.resultData.error?.message ?? 'Unknown error';
+ const scopedErrorMessage = errorNodeName
+ ? i18n.baseText('aiAssistant.builder.executeMessage.executionFailedOnNode', {
+ interpolate: {
+ nodeName: errorNodeName,
+ errorMessage: executionError,
+ },
+ })
+ : i18n.baseText('aiAssistant.builder.executeMessage.executionFailed', {
+ interpolate: { errorMessage: executionError },
+ });
+
+ const failureStatus = executionStatus === 'unknown' ? 'error' : executionStatus;
+
+ builderStore.sendChatMessage({
+ text: scopedErrorMessage,
+ type: 'execution',
+ errorMessage: executionError,
+ errorNodeType,
+ executionStatus: failureStatus,
+ });
+}
+
// Watch for workflow updates and apply them
watch(
() => builderStore.workflowMessages,
@@ -186,7 +246,6 @@ watch(currentRoute, () => {
{
:credits-quota="creditsQuota"
:credits-remaining="creditsRemaining"
:show-ask-owner-tooltip="showAskOwnerTooltip"
- :inputPlaceholder="i18n.baseText('aiAssistant.builder.assistantPlaceholder')"
+ :input-placeholder="i18n.baseText('aiAssistant.builder.assistantPlaceholder')"
@close="emit('close')"
@message="onUserMessage"
@upgrade-click="() => goToUpgrade('ai-builder-sidebar', 'upgrade-builder')"
@@ -208,6 +267,9 @@ watch(currentRoute, () => {
+
+
+
{{
i18n.baseText('aiAssistant.builder.assistantPlaceholder')
diff --git a/packages/frontend/editor-ui/src/components/AskAssistant/Agent/ExecuteMessage.test.ts b/packages/frontend/editor-ui/src/components/AskAssistant/Agent/ExecuteMessage.test.ts
new file mode 100644
index 00000000000..6062bbca491
--- /dev/null
+++ b/packages/frontend/editor-ui/src/components/AskAssistant/Agent/ExecuteMessage.test.ts
@@ -0,0 +1,245 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { reactive, ref, nextTick } from 'vue';
+import { fireEvent } from '@testing-library/vue';
+import { flushPromises } from '@vue/test-utils';
+import { createTestingPinia } from '@pinia/testing';
+import { setActivePinia } from 'pinia';
+
+import { createComponentRenderer } from '@/__tests__/render';
+import { mockedStore } from '@/__tests__/utils';
+import type { INodeUi } from '@/Interface';
+import ExecuteMessage from './ExecuteMessage.vue';
+import { CHAT_TRIGGER_NODE_TYPE } from '@/constants';
+import { useWorkflowsStore } from '@/stores/workflows.store';
+import { useNodeTypesStore } from '@/stores/nodeTypes.store';
+import { useLogsStore } from '@/stores/logs.store';
+
+const workflowValidationIssuesRef = ref<
+ Array<{ node: string; type: string; value: string | string[] }>
+>([]);
+const isWorkflowRunningRef = ref(false);
+const executionWaitingForWebhookRef = ref(false);
+const selectedTriggerNodeNameRef = ref(undefined);
+const workflowNodes = reactive([
+ {
+ id: '1',
+ name: 'Start Trigger',
+ type: 'n8n-nodes-base.manualTrigger',
+ position: [0, 0],
+ parameters: {},
+ typeVersion: 1,
+ issues: {},
+ },
+]);
+
+const showMessageMock = vi.fn();
+const runWorkflowMock = vi.fn();
+
+vi.mock('@n8n/i18n', async (importOriginal) => ({
+ ...(await importOriginal()),
+ useI18n: () => ({
+ baseText: (key: string) => key,
+ }),
+}));
+
+vi.mock('vue-router', () => ({
+ useRouter: () => ({
+ push: vi.fn(),
+ }),
+ useRoute: () => ({ params: {} }),
+ RouterLink: vi.fn(),
+}));
+
+vi.mock('@/composables/useRunWorkflow', () => ({
+ useRunWorkflow: () => ({
+ runWorkflow: runWorkflowMock,
+ }),
+}));
+
+vi.mock('@/composables/useToast', () => ({
+ useToast: () => ({
+ showMessage: showMessageMock,
+ }),
+}));
+
+const renderComponent = createComponentRenderer(ExecuteMessage);
+
+vi.mock('./NodeIssueItem.vue', () => ({
+ default: {
+ template: '',
+ },
+}));
+
+describe('ExecuteMessage', () => {
+ let workflowsStore: ReturnType>;
+ let nodeTypesStore: ReturnType>;
+ let logsStore: ReturnType>;
+ let renderExecuteMessage: () => ReturnType>;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ runWorkflowMock.mockReset();
+ showMessageMock.mockReset();
+ workflowValidationIssuesRef.value = [];
+ isWorkflowRunningRef.value = false;
+ executionWaitingForWebhookRef.value = false;
+ selectedTriggerNodeNameRef.value = undefined;
+ workflowNodes.splice(0, workflowNodes.length, {
+ id: '1',
+ name: 'Start Trigger',
+ type: 'n8n-nodes-base.manualTrigger',
+ position: [0, 0],
+ parameters: {},
+ typeVersion: 1,
+ issues: {},
+ });
+
+ const pinia = createTestingPinia({ stubActions: false });
+ setActivePinia(pinia);
+
+ workflowsStore = mockedStore(useWorkflowsStore);
+ nodeTypesStore = mockedStore(useNodeTypesStore);
+ logsStore = mockedStore(useLogsStore);
+
+ workflowsStore.workflow.nodes = workflowNodes as unknown as INodeUi[];
+ workflowsStore.workflow.connections = {} as never;
+ Object.defineProperty(workflowsStore, 'workflowValidationIssues', {
+ get: () => workflowValidationIssuesRef.value,
+ });
+ workflowsStore.formatIssueMessage = vi.fn((value: string | string[]) =>
+ Array.isArray(value) ? value.join(', ') : String(value),
+ );
+ Object.defineProperty(workflowsStore, 'isWorkflowRunning', {
+ get: () => isWorkflowRunningRef.value,
+ });
+ Object.defineProperty(workflowsStore, 'executionWaitingForWebhook', {
+ get: () => executionWaitingForWebhookRef.value,
+ set: (value: boolean) => {
+ executionWaitingForWebhookRef.value = value;
+ },
+ });
+ Object.defineProperty(workflowsStore, 'selectedTriggerNodeName', {
+ get: () => selectedTriggerNodeNameRef.value,
+ });
+ workflowsStore.setSelectedTriggerNodeName = vi.fn((name: string | undefined) => {
+ selectedTriggerNodeNameRef.value = name;
+ });
+ workflowsStore.getNodeByName = vi.fn(
+ (name: string) => workflowNodes.find((node) => node.name === name) ?? null,
+ );
+ logsStore.toggleOpen = vi.fn();
+ nodeTypesStore.isTriggerNode = vi
+ .fn()
+ .mockImplementation((type: string) => type.toLowerCase().includes('trigger'));
+
+ renderExecuteMessage = () => renderComponent({ pinia });
+ });
+
+ it('disables execution when validation issues exist', () => {
+ workflowValidationIssuesRef.value = [
+ { node: 'Start Trigger', type: 'parameters', value: 'Missing field' },
+ ];
+
+ const { getAllByTestId, getByText } = renderExecuteMessage();
+
+ expect(getByText('aiAssistant.builder.executeMessage.description')).toBeInTheDocument();
+ const button = getAllByTestId('execute-workflow-button')[0] as HTMLButtonElement;
+ expect(button.disabled).toBe(true);
+ });
+
+ it('runs workflow and emits completion event when execution finishes', async () => {
+ runWorkflowMock.mockImplementation(async () => {
+ isWorkflowRunningRef.value = true;
+ });
+
+ const { getAllByTestId, emitted } = renderExecuteMessage();
+ const button = getAllByTestId('execute-workflow-button')[0];
+ expect(button).not.toHaveAttribute('disabled');
+
+ await fireEvent.click(button);
+ await flushPromises();
+ isWorkflowRunningRef.value = false;
+ await nextTick();
+
+ expect(runWorkflowMock).toHaveBeenCalledWith({ triggerNode: 'Start Trigger' });
+ expect(emitted().workflowExecuted).toHaveLength(1);
+ });
+
+ it('opens chat logs and shows info toast for chat trigger nodes', async () => {
+ workflowNodes.push({
+ id: '2',
+ name: 'Chat Trigger',
+ type: CHAT_TRIGGER_NODE_TYPE,
+ position: [0, 0],
+ parameters: {},
+ typeVersion: 1,
+ issues: {},
+ });
+ selectedTriggerNodeNameRef.value = 'Chat Trigger';
+
+ const { getAllByTestId, emitted } = renderExecuteMessage();
+ const button = getAllByTestId('execute-workflow-button')[0];
+
+ await fireEvent.click(button);
+
+ expect(runWorkflowMock).not.toHaveBeenCalled();
+ expect(showMessageMock).toHaveBeenCalledWith({
+ title: 'aiAssistant.builder.toast.title',
+ message: 'aiAssistant.builder.toast.description',
+ type: 'info',
+ });
+ expect(logsStore.toggleOpen).toHaveBeenCalledWith(true);
+ expect(emitted().workflowExecuted).toBeUndefined();
+
+ isWorkflowRunningRef.value = true;
+ await nextTick();
+ isWorkflowRunningRef.value = false;
+ await nextTick();
+
+ expect(emitted().workflowExecuted).toHaveLength(1);
+ });
+
+ it('emits completion after multiple run state toggles', async () => {
+ runWorkflowMock.mockImplementation(async () => {
+ isWorkflowRunningRef.value = true;
+ await nextTick();
+ isWorkflowRunningRef.value = false;
+ });
+
+ const { getAllByTestId, emitted } = renderExecuteMessage();
+ const button = getAllByTestId('execute-workflow-button')[0];
+
+ await fireEvent.click(button);
+ await flushPromises();
+ await nextTick();
+
+ // Toggle again manually to ensure watcher was cleaned up
+ isWorkflowRunningRef.value = false;
+ await nextTick();
+
+ expect(emitted().workflowExecuted).toHaveLength(1);
+ });
+
+ it('supports consecutive manual executions', async () => {
+ runWorkflowMock.mockImplementation(async () => {
+ isWorkflowRunningRef.value = true;
+ await nextTick();
+ isWorkflowRunningRef.value = false;
+ await nextTick();
+ });
+
+ const { getAllByTestId, emitted } = renderExecuteMessage();
+ const button = getAllByTestId('execute-workflow-button')[0];
+
+ await fireEvent.click(button);
+ await flushPromises();
+ await nextTick();
+
+ await fireEvent.click(button);
+ await flushPromises();
+ await nextTick();
+
+ expect(runWorkflowMock).toHaveBeenCalledTimes(2);
+ expect(emitted().workflowExecuted).toHaveLength(2);
+ });
+});
diff --git a/packages/frontend/editor-ui/src/components/AskAssistant/Agent/ExecuteMessage.vue b/packages/frontend/editor-ui/src/components/AskAssistant/Agent/ExecuteMessage.vue
new file mode 100644
index 00000000000..44e3d51435c
--- /dev/null
+++ b/packages/frontend/editor-ui/src/components/AskAssistant/Agent/ExecuteMessage.vue
@@ -0,0 +1,260 @@
+
+
+
+
+
+
+
+
+ {{ i18n.baseText('aiAssistant.builder.executeMessage.description') }}
+
+
+
+
+
+
+
+
+
+ {{ i18n.baseText('aiAssistant.builder.executeMessage.noIssues') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/frontend/editor-ui/src/components/AskAssistant/Agent/NodeIssueItem.test.ts b/packages/frontend/editor-ui/src/components/AskAssistant/Agent/NodeIssueItem.test.ts
new file mode 100644
index 00000000000..26bcb6ac908
--- /dev/null
+++ b/packages/frontend/editor-ui/src/components/AskAssistant/Agent/NodeIssueItem.test.ts
@@ -0,0 +1,63 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { fireEvent } from '@testing-library/vue';
+
+import { createComponentRenderer } from '@/__tests__/render';
+import { mockedStore } from '@/__tests__/utils';
+import NodeIssueItem from './NodeIssueItem.vue';
+import type { INodeTypeDescription } from 'n8n-workflow';
+import { createTestingPinia } from '@pinia/testing';
+import { setActivePinia } from 'pinia';
+import { useNDVStore } from '@/stores/ndv.store';
+
+vi.mock('@/components/NodeIcon.vue', () => ({
+ default: {
+ template: '',
+ },
+}));
+
+const renderComponent = createComponentRenderer(NodeIssueItem);
+
+describe('NodeIssueItem', () => {
+ let ndvStore: ReturnType>;
+ let pinia: ReturnType;
+
+ beforeEach(() => {
+ pinia = createTestingPinia({ stubActions: false });
+ setActivePinia(pinia);
+ ndvStore = mockedStore(useNDVStore);
+ ndvStore.setActiveNodeName = vi.fn();
+ });
+
+ it('renders issue information using provided formatter', () => {
+ const { getByText } = renderComponent({
+ pinia,
+ props: {
+ issue: { node: 'Linear', type: 'parameters', value: 'Missing API key' },
+ getNodeType: vi.fn(),
+ formatIssueMessage: (value: string | string[]) =>
+ Array.isArray(value) ? value.join(', ') : value,
+ },
+ });
+
+ expect(getByText('Linear:')).toBeInTheDocument();
+ expect(getByText('Missing API key')).toBeInTheDocument();
+ });
+
+ it('opens NDV on edit button click', async () => {
+ const nodeType = {
+ name: 'n8n-nodes-base.linear',
+ displayName: 'Linear',
+ } as INodeTypeDescription;
+ const { getByLabelText } = renderComponent({
+ pinia,
+ props: {
+ issue: { node: 'Linear', type: 'parameters', value: 'Missing API key' },
+ getNodeType: vi.fn(() => nodeType),
+ formatIssueMessage: (value: string) => value,
+ },
+ });
+
+ await fireEvent.click(getByLabelText('Edit Linear node'));
+ expect(ndvStore.setActiveNodeName).toHaveBeenCalledWith('Linear', 'other');
+ });
+});
diff --git a/packages/frontend/editor-ui/src/components/AskAssistant/Agent/NodeIssueItem.vue b/packages/frontend/editor-ui/src/components/AskAssistant/Agent/NodeIssueItem.vue
new file mode 100644
index 00000000000..c8e2cd13d1f
--- /dev/null
+++ b/packages/frontend/editor-ui/src/components/AskAssistant/Agent/NodeIssueItem.vue
@@ -0,0 +1,104 @@
+
+
+
+
+
+
+
+
+
+ {{ issue.node }}:
+ {{ formatIssueMessage(issue.value) }}
+
+
+
+
+
+
+
+
diff --git a/packages/frontend/editor-ui/src/components/canvas/elements/buttons/CanvasRunWorkflowButton.vue b/packages/frontend/editor-ui/src/components/canvas/elements/buttons/CanvasRunWorkflowButton.vue
index d95cf901d29..1ac4da1f44d 100644
--- a/packages/frontend/editor-ui/src/components/canvas/elements/buttons/CanvasRunWorkflowButton.vue
+++ b/packages/frontend/editor-ui/src/components/canvas/elements/buttons/CanvasRunWorkflowButton.vue
@@ -22,17 +22,23 @@ const props = defineProps<{
waitingForWebhook?: boolean;
executing?: boolean;
disabled?: boolean;
+ hideTooltip?: boolean;
+ label?: string;
+ size?: 'small' | 'medium' | 'large';
+ includeChatTrigger?: boolean;
getNodeType: (type: string, typeVersion: number) => INodeTypeDescription | null;
}>();
const i18n = useI18n();
const selectableTriggerNodes = computed(() =>
- props.triggerNodes.filter((node) => !node.disabled && !isChatNode(node)),
+ props.triggerNodes.filter(
+ (node) => !node.disabled && (props.includeChatTrigger ? true : !isChatNode(node)),
+ ),
);
const label = computed(() => {
if (!props.executing) {
- return i18n.baseText('nodeView.runButtonText.executeWorkflow');
+ return props.label ?? i18n.baseText('nodeView.runButtonText.executeWorkflow');
}
if (props.waitingForWebhook) {
@@ -43,7 +49,7 @@ const label = computed(() => {
});
const actions = computed(() =>
props.triggerNodes
- .filter((node) => !isChatNode(node))
+ .filter((node) => (props.includeChatTrigger ? true : !isChatNode(node)))
.toSorted((a, b) => {
const [aX, aY] = a.position;
const [bX, bY] = b.position;
@@ -77,13 +83,13 @@ function getNodeTypeByName(name: string): INodeTypeDescription | null {
({
useI18n: () => ({
baseText: (key: string) => key,
}),
+ i18n: {
+ baseText: (key: string) => key,
+ },
}));
// Mock useToast
@@ -35,31 +44,16 @@ vi.mock('@/composables/useToast', () => ({
}),
}));
-// 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;
+let workflowsStore: ReturnType>;
+let nodeTypesStore: ReturnType>;
+let credentialsStore: ReturnType>;
+let pinia: ReturnType;
+
+let setWorkflowNameSpy: ReturnType;
+let getNodeTypeSpy: ReturnType;
+let getCredentialsByTypeSpy: ReturnType;
const apiSpy = vi.spyOn(chatAPI, 'chatWithBuilder');
@@ -96,7 +90,8 @@ vi.mock('vue-router', () => ({
describe('AI Builder store', () => {
beforeEach(() => {
vi.clearAllMocks();
- setActivePinia(createPinia());
+ pinia = createTestingPinia({ stubActions: false });
+ setActivePinia(pinia);
settingsStore = useSettingsStore();
settingsStore.setSettings(
merge({}, defaultSettings, {
@@ -110,14 +105,33 @@ 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 = {};
+
+ workflowsStore = mockedStore(useWorkflowsStore);
+ nodeTypesStore = mockedStore(useNodeTypesStore);
+ credentialsStore = mockedStore(useCredentialsStore);
+
+ workflowsStore.workflowId = 'test-workflow-id';
+ workflowsStore.workflow.name = DEFAULT_NEW_WORKFLOW_NAME;
+ workflowsStore.workflow.nodes = [];
+ workflowsStore.workflow.connections = {};
+ workflowsStore.allNodes = [];
+ workflowsStore.nodesByName = {};
+ workflowsStore.workflowExecutionData = null;
+
+ setWorkflowNameSpy = workflowsStore.setWorkflowName.mockImplementation(({ newName }) => {
+ workflowsStore.workflow.name = newName;
+ });
+
+ getNodeTypeSpy = vi.fn();
+ vi.spyOn(nodeTypesStore, 'getNodeType', 'get').mockReturnValue(getNodeTypeSpy);
+
+ getCredentialsByTypeSpy = vi.fn().mockReturnValue([]);
+ vi.spyOn(credentialsStore, 'getCredentialsByType', 'get').mockReturnValue(
+ getCredentialsByTypeSpy,
+ );
+ vi.spyOn(credentialsStore, 'getCredentialTypeByName', 'get').mockReturnValue(
+ vi.fn().mockReturnValue(undefined),
+ );
});
afterEach(() => {
@@ -855,7 +869,7 @@ describe('AI Builder store', () => {
builderStore.initialGeneration = true;
// Ensure workflow has default name
- mockWorkflow.name = DEFAULT_NEW_WORKFLOW_NAME;
+ workflowsStore.workflow.name = DEFAULT_NEW_WORKFLOW_NAME;
// Create workflow JSON with a generated name
const workflowJson = JSON.stringify({
@@ -879,7 +893,7 @@ describe('AI Builder store', () => {
expect(result.success).toBe(true);
// Verify setWorkflowName was called with the generated name
- expect(mockSetWorkflowName).toHaveBeenCalledWith({
+ expect(setWorkflowNameSpy).toHaveBeenCalledWith({
newName: 'Generated Workflow Name for Email Processing',
setStateDirty: false,
});
@@ -892,7 +906,7 @@ describe('AI Builder store', () => {
builderStore.initialGeneration = true;
// Set a custom workflow name (not the default)
- mockWorkflow.name = 'My Custom Workflow';
+ workflowsStore.workflow.name = 'My Custom Workflow';
// Create workflow JSON with a generated name
const workflowJson = JSON.stringify({
@@ -916,7 +930,7 @@ describe('AI Builder store', () => {
expect(result.success).toBe(true);
// Verify setWorkflowName was NOT called
- expect(mockSetWorkflowName).not.toHaveBeenCalled();
+ expect(setWorkflowNameSpy).not.toHaveBeenCalled();
});
it('should NOT apply generated workflow name when not initial generation', () => {
@@ -926,7 +940,7 @@ describe('AI Builder store', () => {
builderStore.initialGeneration = false;
// Ensure workflow has default name
- mockWorkflow.name = DEFAULT_NEW_WORKFLOW_NAME;
+ workflowsStore.workflow.name = DEFAULT_NEW_WORKFLOW_NAME;
// Create workflow JSON with a generated name
const workflowJson = JSON.stringify({
@@ -950,7 +964,7 @@ describe('AI Builder store', () => {
expect(result.success).toBe(true);
// Verify setWorkflowName was NOT called
- expect(mockSetWorkflowName).not.toHaveBeenCalled();
+ expect(setWorkflowNameSpy).not.toHaveBeenCalled();
});
it('should handle workflow updates without name property', () => {
@@ -960,7 +974,7 @@ describe('AI Builder store', () => {
builderStore.initialGeneration = true;
// Ensure workflow has default name
- mockWorkflow.name = DEFAULT_NEW_WORKFLOW_NAME;
+ workflowsStore.workflow.name = DEFAULT_NEW_WORKFLOW_NAME;
// Create workflow JSON without a name property
const workflowJson = JSON.stringify({
@@ -983,7 +997,7 @@ describe('AI Builder store', () => {
expect(result.success).toBe(true);
// Verify setWorkflowName was NOT called
- expect(mockSetWorkflowName).not.toHaveBeenCalled();
+ expect(setWorkflowNameSpy).not.toHaveBeenCalled();
});
it('should handle workflow names that start with but are not exactly the default name', () => {
@@ -993,7 +1007,7 @@ describe('AI Builder store', () => {
builderStore.initialGeneration = true;
// Set workflow name that starts with default but has more text
- mockWorkflow.name = `${DEFAULT_NEW_WORKFLOW_NAME} - Copy`;
+ workflowsStore.workflow.name = `${DEFAULT_NEW_WORKFLOW_NAME} - Copy`;
// Create workflow JSON with a generated name
const workflowJson = JSON.stringify({
@@ -1017,7 +1031,7 @@ describe('AI Builder store', () => {
expect(result.success).toBe(true);
// Verify setWorkflowName WAS called because the name starts with default
- expect(mockSetWorkflowName).toHaveBeenCalledWith({
+ expect(setWorkflowNameSpy).toHaveBeenCalledWith({
newName: 'Generated Workflow Name for Email Processing',
setStateDirty: false,
});
@@ -1047,7 +1061,7 @@ describe('AI Builder store', () => {
builderStore.initialGeneration = true;
// Ensure workflow has default name
- mockWorkflow.name = DEFAULT_NEW_WORKFLOW_NAME;
+ workflowsStore.workflow.name = DEFAULT_NEW_WORKFLOW_NAME;
// First update with name
const workflowJson1 = JSON.stringify({
@@ -1057,7 +1071,7 @@ describe('AI Builder store', () => {
});
builderStore.applyWorkflowUpdate(workflowJson1);
- expect(mockSetWorkflowName).toHaveBeenCalledTimes(1);
+ expect(setWorkflowNameSpy).toHaveBeenCalledTimes(1);
// The flag should still be true for subsequent updates in the same generation
expect(builderStore.initialGeneration).toBe(true);
@@ -1079,7 +1093,106 @@ describe('AI Builder store', () => {
builderStore.applyWorkflowUpdate(workflowJson2);
// Should not call setWorkflowName again
- expect(mockSetWorkflowName).toHaveBeenCalledTimes(1);
+ expect(setWorkflowNameSpy).toHaveBeenCalledTimes(1);
+ });
+
+ describe('applyWorkflowUpdate credential defaults', () => {
+ const createTestNodeType = (): INodeTypeDescription => ({
+ displayName: 'Test Node',
+ name: 'n8n-nodes-base.test',
+ description: 'Test node',
+ group: ['trigger'],
+ version: 1,
+ defaults: { name: 'Test Node' },
+ inputs: ['main'],
+ outputs: ['main'],
+ properties: [
+ {
+ displayName: 'Authentication',
+ name: 'authentication',
+ type: 'options',
+ options: [
+ {
+ name: 'API Key',
+ value: 'apiKey',
+ },
+ ],
+ default: 'apiKey',
+ required: true,
+ },
+ ],
+ credentials: [
+ {
+ name: 'testApi',
+ required: true,
+ displayOptions: {
+ show: {
+ authentication: ['apiKey'],
+ },
+ },
+ },
+ ],
+ });
+
+ it('assigns default credentials when available', () => {
+ const builderStore = useBuilderStore();
+ getNodeTypeSpy.mockReturnValue(createTestNodeType());
+ getCredentialsByTypeSpy.mockReturnValue([
+ { id: 'cred-id', name: 'API Credential', type: 'testApi' },
+ ]);
+
+ const workflowJson = JSON.stringify({
+ nodes: [
+ {
+ id: 'node1',
+ name: 'HTTP Request',
+ type: 'n8n-nodes-base.test',
+ position: [0, 0],
+ parameters: {},
+ },
+ ],
+ connections: {},
+ });
+
+ const result = builderStore.applyWorkflowUpdate(workflowJson);
+ expect(result.success).toBe(true);
+ const [node] = result.workflowData?.nodes ?? [];
+ expect(node.credentials).toEqual({
+ testApi: { id: 'cred-id', name: 'API Credential' },
+ });
+ expect(node.parameters.authentication).toBe('apiKey');
+ });
+
+ it('keeps existing credentials untouched', () => {
+ const builderStore = useBuilderStore();
+ getNodeTypeSpy.mockReturnValue(createTestNodeType());
+ getCredentialsByTypeSpy.mockReturnValue([
+ { id: 'cred-id', name: 'API Credential', type: 'testApi' },
+ ]);
+
+ const workflowJson = JSON.stringify({
+ nodes: [
+ {
+ id: 'node1',
+ name: 'HTTP Request',
+ type: 'n8n-nodes-base.test',
+ position: [0, 0],
+ parameters: { authentication: 'apiKey' },
+ credentials: {
+ testApi: { id: 'existing', name: 'Existing Credential' },
+ },
+ },
+ ],
+ connections: {},
+ });
+
+ const result = builderStore.applyWorkflowUpdate(workflowJson);
+ expect(result.success).toBe(true);
+ const [node] = result.workflowData?.nodes ?? [];
+ expect(node.credentials).toEqual({
+ testApi: { id: 'existing', name: 'Existing Credential' },
+ });
+ });
});
});
diff --git a/packages/frontend/editor-ui/src/stores/builder.store.ts b/packages/frontend/editor-ui/src/stores/builder.store.ts
index 4d6f5fcd53a..601c9ee17c3 100644
--- a/packages/frontend/editor-ui/src/stores/builder.store.ts
+++ b/packages/frontend/editor-ui/src/stores/builder.store.ts
@@ -28,6 +28,9 @@ import type { WorkflowDataUpdate } from '@n8n/rest-api-client/api/workflows';
import pick from 'lodash/pick';
import { jsonParse } from 'n8n-workflow';
import { useToast } from '@/composables/useToast';
+import { useNodeTypesStore } from './nodeTypes.store';
+import { useCredentialsStore } from './credentials.store';
+import { getAuthTypeForNodeCredential, getMainAuthField } from '@/utils/nodeTypesUtils';
const INFINITE_CREDITS = -1;
export const ENABLED_VIEWS = [...EDITABLE_CANVAS_VIEWS];
@@ -49,6 +52,9 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
const rootStore = useRootStore();
const workflowsStore = useWorkflowsStore();
const uiStore = useUIStore();
+ const credentialsStore = useCredentialsStore();
+ const nodeTypesStore = useNodeTypesStore();
+
const route = useRoute();
const locale = useI18n();
const telemetry = useTelemetry();
@@ -274,12 +280,24 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
source?: 'chat' | 'canvas';
quickReplyType?: string;
initialGeneration?: boolean;
+ type?: 'message' | 'execution';
+ errorMessage?: string;
+ errorNodeType?: string;
+ executionStatus?: string;
}) {
if (streaming.value) {
return;
}
- const { text, source = 'chat', quickReplyType } = options;
+ const {
+ text,
+ source = 'chat',
+ quickReplyType,
+ errorMessage,
+ type = 'message',
+ errorNodeType,
+ executionStatus,
+ } = options;
// Set initial generation flag if provided
if (options.initialGeneration !== undefined) {
@@ -288,13 +306,24 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
const messageId = generateMessageId();
const currentWorkflowJson = getWorkflowSnapshot();
- telemetry.track('User submitted builder message', {
+ const trackingPayload: Record = {
source,
message: text,
session_id: trackingSessionId.value,
start_workflow_json: currentWorkflowJson,
workflow_id: workflowsStore.workflowId,
- });
+ type,
+ };
+
+ if (type === 'execution') {
+ trackingPayload.execution_status = executionStatus ?? '';
+ if (executionStatus === 'error') {
+ trackingPayload.error_message = errorMessage ?? '';
+ trackingPayload.error_node_type = errorNodeType ?? '';
+ }
+ }
+
+ telemetry.track('User submitted builder message', trackingPayload);
prepareForStreaming(text, messageId);
@@ -410,6 +439,48 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
};
}
+ function setDefaultNodesCredentials(workflowData: WorkflowDataUpdate) {
+ // Set default credentials for new nodes if available
+ workflowData.nodes?.forEach((node) => {
+ const hasCredentials = node.credentials && Object.keys(node.credentials).length > 0;
+ if (hasCredentials) {
+ return;
+ }
+
+ const nodeType = nodeTypesStore.getNodeType(node.type);
+ if (!nodeType?.credentials) {
+ return;
+ }
+
+ // Try to find and set the first available credential
+ for (const credentialConfig of nodeType.credentials) {
+ const credentials = credentialsStore.getCredentialsByType(credentialConfig.name);
+ // No credentials of this type exist, try the next one
+ if (!credentials || credentials.length === 0) {
+ continue;
+ }
+
+ // Found valid credentials - set them and exit the loop
+ const credential = credentials[0];
+
+ node.credentials = {
+ [credential.type]: {
+ id: credential.id,
+ name: credential.name,
+ },
+ };
+
+ const authField = getMainAuthField(nodeType);
+ const authType = getAuthTypeForNodeCredential(nodeType, credentialConfig);
+ if (authField && authType) {
+ node.parameters[authField.name] = authType.value;
+ }
+
+ break; // Exit loop after setting the first valid credential
+ }
+ });
+ }
+
function applyWorkflowUpdate(workflowJson: string) {
let workflowData: WorkflowDataUpdate;
try {
@@ -455,6 +526,8 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
});
}
+ setDefaultNodesCredentials(workflowData);
+
return {
success: true,
workflowData,
diff --git a/packages/frontend/editor-ui/src/stores/workflows.store.test.ts b/packages/frontend/editor-ui/src/stores/workflows.store.test.ts
index 6bb289db007..8fdf0d6e6e4 100644
--- a/packages/frontend/editor-ui/src/stores/workflows.store.test.ts
+++ b/packages/frontend/editor-ui/src/stores/workflows.store.test.ts
@@ -15,6 +15,7 @@ import { deepCopy, SEND_AND_WAIT_OPERATION } from 'n8n-workflow';
import type {
IPinData,
IConnection,
+ IConnections,
INodeExecutionData,
INode,
INodeTypeDescription,
@@ -150,6 +151,92 @@ describe('useWorkflowsStore', () => {
});
});
+ describe('workflowValidationIssues', () => {
+ it('collects issues only from connected, enabled nodes', () => {
+ const connections: IConnections = {
+ Start: {
+ main: [
+ [
+ {
+ node: 'Fetch',
+ type: 'main',
+ index: 0,
+ },
+ ],
+ ],
+ },
+ };
+
+ workflowsStore.workflow.nodes = [
+ {
+ id: 'start',
+ name: 'Start',
+ type: 'n8n-nodes-base.start',
+ typeVersion: 1,
+ parameters: {},
+ position: [0, 0],
+ },
+ {
+ id: 'fetch',
+ name: 'Fetch',
+ type: 'n8n-nodes-base.httpRequest',
+ typeVersion: 1,
+ parameters: {},
+ issues: {
+ parameters: {
+ url: ['Missing URL', 'Invalid URL.'],
+ },
+ credentials: {
+ httpBasicAuth: ['Credentials not set'],
+ },
+ },
+ position: [300, 0],
+ },
+ {
+ id: 'orphan',
+ name: 'Disconnected',
+ type: 'n8n-nodes-base.set',
+ typeVersion: 1,
+ parameters: {},
+ issues: {
+ parameters: { field: ['Should be ignored'] },
+ },
+ position: [0, 400],
+ },
+ {
+ id: 'disabled',
+ name: 'Disabled Node',
+ type: 'n8n-nodes-base.set',
+ typeVersion: 1,
+ disabled: true,
+ parameters: {},
+ issues: {
+ parameters: { field: ['Disabled issue'] },
+ },
+ position: [0, 600],
+ },
+ ];
+ workflowsStore.workflow.connections = connections;
+
+ const issues = workflowsStore.workflowValidationIssues;
+ expect(issues).toEqual([
+ { node: 'Fetch', type: 'parameters', value: ['Missing URL', 'Invalid URL.'] },
+ { node: 'Fetch', type: 'credentials', value: ['Credentials not set'] },
+ ]);
+ });
+ });
+
+ describe('formatIssueMessage', () => {
+ it('joins array entries and trims trailing period', () => {
+ const message = workflowsStore.formatIssueMessage(['Missing URL', 'Invalid value.']);
+ expect(message).toBe('Missing URL, Invalid value');
+ });
+
+ it('returns string representation for non-array values', () => {
+ expect(workflowsStore.formatIssueMessage('Simple issue.')).toBe('Simple issue.');
+ });
+ });
+
describe('allWorkflows', () => {
it('should return sorted workflows by name', () => {
workflowsStore.setWorkflows([
diff --git a/packages/frontend/editor-ui/src/stores/workflows.store.ts b/packages/frontend/editor-ui/src/stores/workflows.store.ts
index e29aef41f50..1102a247d0b 100644
--- a/packages/frontend/editor-ui/src/stores/workflows.store.ts
+++ b/packages/frontend/editor-ui/src/stores/workflows.store.ts
@@ -277,6 +277,66 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
}),
);
+ /**
+ * Get detailed validation issues for all connected, enabled nodes
+ */
+ const workflowValidationIssues = computed(() => {
+ const issues: Array<{
+ node: string;
+ type: string;
+ value: string | string[];
+ }> = [];
+
+ const isStringOrStringArray = (value: unknown): value is string | string[] =>
+ typeof value === 'string' || Array.isArray(value);
+
+ workflow.value.nodes.forEach((node) => {
+ if (!node.issues || node.disabled) return;
+
+ const isConnected =
+ Object.keys(outgoingConnectionsByNodeName(node.name)).length > 0 ||
+ Object.keys(incomingConnectionsByNodeName(node.name)).length > 0;
+
+ if (!isConnected) return;
+
+ Object.entries(node.issues).forEach(([issueType, issueValue]) => {
+ if (!issueValue) return;
+
+ if (typeof issueValue === 'object' && !Array.isArray(issueValue)) {
+ // Handle nested issues (parameters, credentials)
+ Object.entries(issueValue).forEach(([_key, value]) => {
+ if (value) {
+ issues.push({
+ node: node.name,
+ type: issueType,
+ value,
+ });
+ }
+ });
+ } else {
+ // Handle direct issues
+ issues.push({
+ node: node.name,
+ type: issueType,
+ value: isStringOrStringArray(issueValue) ? issueValue : String(issueValue),
+ });
+ }
+ });
+ });
+
+ return issues;
+ });
+
+ /**
+ * Format issue message for display
+ */
+ function formatIssueMessage(issue: string | string[]): string {
+ if (Array.isArray(issue)) {
+ return issue.join(', ').replace(/\.$/, '');
+ }
+ return String(issue);
+ }
+
const pinnedWorkflowData = computed(() => workflow.value.pinData);
const executedNode = computed(() => workflowExecutionData.value?.executedNode);
@@ -2013,6 +2073,8 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
canvasNames,
nodesByName,
nodesIssuesExist,
+ workflowValidationIssues,
+ formatIssueMessage,
pinnedWorkflowData,
executedNode,
getAllLoadedFinishedExecutions,
diff --git a/packages/frontend/editor-ui/src/views/NodeView.vue b/packages/frontend/editor-ui/src/views/NodeView.vue
index 61f0fc01099..7ead55a81e5 100644
--- a/packages/frontend/editor-ui/src/views/NodeView.vue
+++ b/packages/frontend/editor-ui/src/views/NodeView.vue
@@ -1124,22 +1124,28 @@ async function importWorkflowExact({ workflow: workflowData }: { workflow: Workf
async function onImportWorkflowDataEvent(data: IDataObject) {
const workflowData = data.data as WorkflowDataUpdate;
const trackEvents = typeof data.trackEvents === 'boolean' ? data.trackEvents : undefined;
+
await importWorkflowData(workflowData, 'file', {
viewport: viewportBoundaries.value,
regenerateIds: data.regenerateIds === true || data.regenerateIds === undefined,
trackEvents,
});
+ await nextTick();
fitView();
+
selectNodes(workflowData.nodes?.map((node) => node.id) ?? []);
if (data.tidyUp) {
const nodesIdsToTidyUp = data.nodesIdsToTidyUp as string[];
- setTimeout(() => {
+ setTimeout(async () => {
canvasEventBus.emit('tidyUp', {
source: 'import-workflow-data',
nodeIdsFilter: nodesIdsToTidyUp,
trackEvents,
});
+
+ await nextTick();
+ fitView();
}, 0);
}
}