From 49e422cb57f764fcedcbc74f516c54be4150af0b Mon Sep 17 00:00:00 2001 From: Riqwan Thamir Date: Wed, 20 May 2026 14:43:04 +0200 Subject: [PATCH] feat(editor): Add fix with AI action to instance ai chat on execution errors (no-changelog) (#30705) --- .../frontend/@n8n/i18n/src/locales/en.json | 10 ++ .../useReportWorkflowFailuresToParent.ts | 81 ++++++++++ .../editor-ui/src/app/layouts/DemoLayout.vue | 3 + .../ai/instanceAi/InstanceAiThreadView.vue | 74 ++++++++- .../InstanceAiFixWithAiPanel.test.ts | 47 ++++++ .../InstanceAiWorkflowPreview.test.ts | 105 ++++++++++-- .../instanceAi.threadRuntime.test.ts | 11 ++ .../__tests__/useFixWithAiOffer.test.ts | 100 ++++++++++++ .../components/InstanceAiFixWithAiPanel.vue | 152 ++++++++++++++++++ .../components/InstanceAiWorkflowPreview.vue | 52 +++++- .../ai/instanceAi/instanceAi.threadRuntime.ts | 6 +- .../ai/instanceAi/useFixWithAiOffer.ts | 95 +++++++++++ 12 files changed, 715 insertions(+), 21 deletions(-) create mode 100644 packages/frontend/editor-ui/src/app/composables/useReportWorkflowFailuresToParent.ts create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiFixWithAiPanel.test.ts create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useFixWithAiOffer.test.ts create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiFixWithAiPanel.vue create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/useFixWithAiOffer.ts diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 5dd03359c62..f1440a48421 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -5699,6 +5699,16 @@ "instanceAi.planReview.approved": "Plan approved", "instanceAi.message.retry": "Retry", "instanceAi.input.amendPlaceholder": "Amend the {role} agent...", + "instanceAi.fixWithAi.button": "Fix with AI", + "instanceAi.fixWithAi.notice.title.single": "Execution failed in ‘{nodeName}’ node", + "instanceAi.fixWithAi.notice.title.multiple": "{count} nodes failed", + "instanceAi.fixWithAi.notice.dismiss": "Dismiss", + "instanceAi.fixWithAi.errorDetails": "Error details", + "instanceAi.fixWithAi.prompt.single": "The \"{nodeName}\" node failed with: \"{errorMessage}\". Help me debug and fix it", + "instanceAi.fixWithAi.prompt.singleInWorkflow": "The \"{nodeName}\" node in workflow \"{workflowName}\" failed with: \"{errorMessage}\". Help me debug and fix it", + "instanceAi.fixWithAi.prompt.multiple": "These nodes failed:\n{errorList}\n\nHelp me debug and fix them", + "instanceAi.fixWithAi.prompt.multipleInWorkflow": "These nodes in workflow \"{workflowName}\" failed:\n{errorList}\n\nHelp me debug and fix them", + "instanceAi.fixWithAi.prompt.errorLine": "- \"{nodeName}\": {errorMessage}", "instanceAi.feedback.success": "Thanks for your feedback!", "instanceAi.emptyState.title": "AI Assistant", "instanceAi.emptyState.suggestions.buildWorkflow.label": "Build a workflow", diff --git a/packages/frontend/editor-ui/src/app/composables/useReportWorkflowFailuresToParent.ts b/packages/frontend/editor-ui/src/app/composables/useReportWorkflowFailuresToParent.ts new file mode 100644 index 00000000000..c2ad37ba839 --- /dev/null +++ b/packages/frontend/editor-ui/src/app/composables/useReportWorkflowFailuresToParent.ts @@ -0,0 +1,81 @@ +import { computed, watch } from 'vue'; +import { useRoute } from 'vue-router'; +import { VIEWS } from '@/app/constants'; +import { useWorkflowsStore } from '@/app/stores/workflows.store'; +import type { FixWithAiError } from '@/features/ai/instanceAi/useFixWithAiOffer'; +import type { IRunData } from 'n8n-workflow'; + +function collectErrorsFromRunData(runData: IRunData): FixWithAiError[] { + const errors: FixWithAiError[] = []; + + for (const [nodeName, tasks] of Object.entries(runData)) { + const lastTask = tasks?.at(-1); + const error = lastTask?.error; + + if (!error) continue; + + const description = error.description ? ` (${error.description})` : ''; + + errors.push({ + nodeName, + errorMessage: `${error.message ?? 'Unknown error'}${description}`, + }); + } + + return errors; +} + +export function useReportWorkflowFailuresToParent(workflowName?: () => string | undefined) { + if (window.parent === window) return; + + const route = useRoute(); + const workflowsStore = useWorkflowsStore(); + const isEnabled = computed(() => route.name === VIEWS.DEMO && route.query.canExecute === 'true'); + let lastReportedKey: string | null = null; + + watch( + [ + isEnabled, + () => workflowsStore.getWorkflowRunData, + () => workflowsStore.isWorkflowRunning, + () => workflowsStore.workflowId, + () => workflowsStore.getWorkflowExecution?.id, + ], + () => { + if (!isEnabled.value || workflowsStore.isWorkflowRunning) return; + + const workflowId = workflowsStore.workflowId; + const runData = workflowsStore.getWorkflowRunData; + + if (!workflowId || !runData) return; + + const errors = collectErrorsFromRunData(runData); + + if (errors.length === 0) return; + + const executionId = workflowsStore.getWorkflowExecution?.id; + const fingerprint = errors + .map((e) => `${e.nodeName}:${e.errorMessage}`) + .sort() + .join('|'); + + const dismissKey = executionId ?? fingerprint; + + if (!dismissKey || lastReportedKey === dismissKey) return; + + lastReportedKey = dismissKey; + + window.parent.postMessage( + JSON.stringify({ + command: 'reportWorkflowFailures', + workflowId, + workflowName: workflowName?.(), + executionId: dismissKey, + errors, + }), + window.location.origin, + ); + }, + { deep: true }, + ); +} diff --git a/packages/frontend/editor-ui/src/app/layouts/DemoLayout.vue b/packages/frontend/editor-ui/src/app/layouts/DemoLayout.vue index 558ce287de0..829b0155870 100644 --- a/packages/frontend/editor-ui/src/app/layouts/DemoLayout.vue +++ b/packages/frontend/editor-ui/src/app/layouts/DemoLayout.vue @@ -7,6 +7,7 @@ import { NDVStoreKey, WorkflowStateKey } from '@/app/constants/injectionKeys'; import { useWorkflowState } from '@/app/composables/useWorkflowState'; import { useWorkflowInitialization } from '@/app/composables/useWorkflowInitialization'; import { usePostMessageHandler } from '@/app/composables/usePostMessageHandler'; +import { useReportWorkflowFailuresToParent } from '@/app/composables/useReportWorkflowFailuresToParent'; import { usePushConnection } from '@/app/composables/usePushConnection/usePushConnection'; import { usePushConnectionStore } from '@/app/stores/pushConnection.store'; import { useRootStore } from '@n8n/stores/useRootStore'; @@ -42,6 +43,8 @@ const { setup: setupPostMessages, cleanup: cleanupPostMessages } = usePostMessag currentNDVStore, }); +useReportWorkflowFailuresToParent(() => currentWorkflowDocumentStore.value?.name ?? undefined); + // Initialize push event handlers so relayed execution events (via postMessage // from the parent) are processed for node highlighting, execution state, etc. // When canExecute is enabled, the iframe also establishes its own WebSocket diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadView.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadView.vue index 526db88b01e..8fa353ee185 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadView.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadView.vue @@ -40,12 +40,16 @@ import InstanceAiDebugPanel from './components/InstanceAiDebugPanel.vue'; import InstanceAiArtifactsPanel from './components/InstanceAiArtifactsPanel.vue'; import InstanceAiStatusBar from './components/InstanceAiStatusBar.vue'; import InstanceAiConfirmationPanel from './components/InstanceAiConfirmationPanel.vue'; +import InstanceAiFixWithAiPanel from './components/InstanceAiFixWithAiPanel.vue'; import InstanceAiPreviewTabBar from './components/InstanceAiPreviewTabBar.vue'; import InstanceAiViewHeader from './components/InstanceAiViewHeader.vue'; import AgentSection from './components/AgentSection.vue'; import { collectActiveBuilderAgents, messageHasVisibleContent } from './builderAgents'; import CreditWarningBanner from '@/features/ai/assistant/components/Agent/CreditWarningBanner.vue'; -import InstanceAiWorkflowPreview from './components/InstanceAiWorkflowPreview.vue'; +import InstanceAiWorkflowPreview, { + type WorkflowFailuresReport, +} from './components/InstanceAiWorkflowPreview.vue'; +import { buildFixWithAiPrompt, useFixWithAiOffer } from './useFixWithAiOffer'; import InstanceAiDataTablePreview from './components/InstanceAiDataTablePreview.vue'; import { TabsRoot } from 'reka-ui'; @@ -77,6 +81,39 @@ const displayedMessages = computed(() => thread.messages.filter(messageHasVisibl // --- Execution tracking via push events --- const executionTracking = useExecutionPushEvents(); +const fixWithAiOffer = useFixWithAiOffer(); + +const isChatInProgress = computed( + () => thread.isStreaming || thread.isSendingMessage || thread.isAwaitingConfirmation, +); + +function findReadyFixWithAiOffer(workflowId?: string | null) { + const offers = fixWithAiOffer.offersByWorkflow.value; + + if (workflowId) { + const preferred = offers.get(workflowId); + if ( + preferred && + preferred.errors.length > 0 && + !fixWithAiOffer.isDismissed(preferred.executionId) + ) { + return preferred; + } + } + + for (const offer of offers.values()) { + if (offer.errors.length === 0) continue; + if (fixWithAiOffer.isDismissed(offer.executionId)) continue; + return offer; + } + + return null; +} + +const activeFixWithAiOffer = computed(() => { + if (isChatInProgress.value) return null; + return findReadyFixWithAiOffer(preview.activeWorkflowId.value); +}); // --- Header title --- // Returns the resolved title once we have one, or undefined while we're still @@ -454,6 +491,7 @@ onUnmounted(() => { contentResizeObserver?.disconnect(); resizeObserver?.disconnect(); executionTracking.cleanup(); + fixWithAiOffer.cleanup(); }); // --- Workflow preview ref for iframe relay --- @@ -478,6 +516,29 @@ function handleSubmit(message: string, attachments?: InstanceAiAttachment[]) { function handleStop() { void thread.cancelRun(); } + +function handleFixWithAiFromOffer() { + const offer = activeFixWithAiOffer.value; + if (!offer) return; + + fixWithAiOffer.dismiss(offer.executionId); + userScrolledUp.value = false; + void thread.sendMessage( + buildFixWithAiPrompt({ workflowName: offer.workflowName, errors: offer.errors }), + undefined, + rootStore.pushRef, + ); +} + +function dismissFixWithAiOffer() { + const offer = activeFixWithAiOffer.value; + if (!offer) return; + fixWithAiOffer.dismiss(offer.executionId); +} + +function handleWorkflowFailures(report: WorkflowFailuresReport) { + fixWithAiOffer.registerOffer(report); +}