feat(editor): Add fix with AI action to instance ai chat on execution errors (no-changelog) (#30705)

This commit is contained in:
Riqwan Thamir 2026-05-20 14:43:04 +02:00 committed by GitHub
parent d8ef975101
commit 49e422cb57
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 715 additions and 21 deletions

View File

@ -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",

View File

@ -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 },
);
}

View File

@ -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

View File

@ -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);
}
</script>
<template>
@ -571,6 +632,16 @@ function handleStop() {
/>
</div>
<InstanceAiConfirmationPanel />
<Transition name="confirmation-slide">
<InstanceAiFixWithAiPanel
v-if="activeFixWithAiOffer"
:node-name="activeFixWithAiOffer.errors[0].nodeName"
:error-message="activeFixWithAiOffer.errors[0].errorMessage"
:failed-count="activeFixWithAiOffer.errors.length"
@fix-with-ai="handleFixWithAiFromOffer"
@dismiss="dismissFixWithAiOffer"
/>
</Transition>
</div>
</N8nScrollArea>
@ -714,6 +785,7 @@ function handleStop() {
:refresh-key="preview.workflowRefreshKey.value"
@iframe-ready="eventRelay.handleIframeReady"
@workflow-loaded="eventRelay.handleWorkflowLoaded"
@workflow-failures="handleWorkflowFailures"
/>
<InstanceAiDataTablePreview
v-if="preview.activeDataTableId.value"

View File

@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import userEvent from '@testing-library/user-event';
import { createComponentRenderer } from '@/__tests__/render';
import InstanceAiFixWithAiPanel from '../components/InstanceAiFixWithAiPanel.vue';
const renderComponent = createComponentRenderer(InstanceAiFixWithAiPanel, {
props: {
nodeName: 'Extract Emails',
errorMessage: 'the emails were not extracted',
},
});
describe('InstanceAiFixWithAiPanel', () => {
it('renders the node failure title and fix action', () => {
const { getByTestId } = renderComponent();
const panel = getByTestId('instance-ai-fix-with-ai-panel');
expect(panel).toHaveTextContent('Execution failed in Extract Emails node');
expect(getByTestId('instance-ai-fix-with-ai-dismiss')).toHaveTextContent('Dismiss');
expect(getByTestId('instance-ai-fix-with-ai-button')).toHaveTextContent('Fix with AI');
});
it('emits dismiss when the dismiss button is clicked', async () => {
const user = userEvent.setup();
const { emitted, getByTestId } = renderComponent();
await user.click(getByTestId('instance-ai-fix-with-ai-dismiss'));
expect(emitted().dismiss).toEqual([[]]);
});
it('keeps error details collapsed by default', () => {
const { getByTestId, queryByText } = renderComponent();
expect(getByTestId('instance-ai-fix-with-ai-error-toggle')).toHaveTextContent('Error details');
expect(queryByText('the emails were not extracted')).not.toBeInTheDocument();
});
it('expands the code view when error details is toggled', async () => {
const user = userEvent.setup();
const { getByTestId, getByText } = renderComponent();
await user.click(getByTestId('instance-ai-fix-with-ai-error-toggle'));
expect(getByText('the emails were not extracted')).toBeVisible();
});
});

View File

@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { defineComponent } from 'vue';
import { createComponentRenderer } from '@/__tests__/render';
import { createTestingPinia } from '@pinia/testing';
import { waitFor } from '@testing-library/vue';
@ -12,10 +13,23 @@ vi.mock('@/app/stores/workflowsList.store', () => ({
})),
}));
let mockIframeWindow: Window;
const mockIframeElement = { contentWindow: null as Window | null };
function dispatchIframeMessage(data: unknown, source: Window | null = mockIframeWindow) {
window.dispatchEvent(
new MessageEvent('message', {
data: typeof data === 'string' ? data : JSON.stringify(data),
origin: window.location.origin,
source,
}),
);
}
const renderComponent = createComponentRenderer(InstanceAiWorkflowPreview, {
global: {
stubs: {
WorkflowPreview: {
WorkflowPreview: defineComponent({
template:
'<div data-test-id="workflow-preview" :data-can-execute="canExecute" :data-suppress-notifications="suppressNotifications" :data-allow-error-notifications="allowErrorNotifications" />',
props: [
@ -28,7 +42,10 @@ const renderComponent = createComponentRenderer(InstanceAiWorkflowPreview, {
'allowErrorNotifications',
'loaderType',
],
},
setup(_, { expose }) {
expose({ iframeRef: mockIframeElement });
},
}),
},
},
});
@ -48,6 +65,8 @@ describe('InstanceAiWorkflowPreview', () => {
beforeEach(() => {
createTestingPinia();
vi.clearAllMocks();
mockIframeWindow = { postMessage: vi.fn() } as unknown as Window;
mockIframeElement.contentWindow = mockIframeWindow;
});
it('should render without throwing', () => {
@ -103,14 +122,10 @@ describe('InstanceAiWorkflowPreview', () => {
props: { workflowId: null },
});
window.dispatchEvent(
new MessageEvent('message', {
data: JSON.stringify({
command: 'n8nReady',
pushRef: 'iframe-push-ref-abc',
}),
}),
);
dispatchIframeMessage({
command: 'n8nReady',
pushRef: 'iframe-push-ref-abc',
});
await waitFor(() => {
expect(emitted('iframe-ready')).toBeTruthy();
@ -148,14 +163,74 @@ describe('InstanceAiWorkflowPreview', () => {
props: { workflowId: null },
});
window.dispatchEvent(
new MessageEvent('message', {
data: JSON.stringify({ command: 'n8nReady' }),
}),
);
dispatchIframeMessage({ command: 'n8nReady' });
await waitFor(() => {
expect(emitted('iframe-ready')).toBeTruthy();
});
});
it('should emit workflow-failures on reportWorkflowFailures postMessage', async () => {
const { emitted } = renderComponent({ props: { workflowId: null } });
dispatchIframeMessage({
command: 'reportWorkflowFailures',
workflowId: 'wf-1',
workflowName: 'My Workflow',
executionId: 'exec-1',
errors: [{ nodeName: 'Extract Emails', errorMessage: 'Intentional break' }],
});
await waitFor(() => {
expect(emitted('workflow-failures')).toBeTruthy();
});
expect(emitted('workflow-failures')).toEqual([
[
{
workflowId: 'wf-1',
workflowName: 'My Workflow',
executionId: 'exec-1',
errors: [{ nodeName: 'Extract Emails', errorMessage: 'Intentional break' }],
},
],
]);
});
it('should ignore reportWorkflowFailures when payload is invalid', async () => {
const { emitted } = renderComponent({ props: { workflowId: null } });
dispatchIframeMessage({ command: 'reportWorkflowFailures', errors: [] });
dispatchIframeMessage({
command: 'reportWorkflowFailures',
workflowId: 'wf-1',
errors: [{ nodeName: 'HTTP Request', errorMessage: 'Connection refused' }],
});
await waitFor(() => {
expect(emitted('workflow-failures')).toBeUndefined();
});
});
it('should ignore postMessages from unexpected origins or sources', async () => {
const { emitted } = renderComponent({ props: { workflowId: null } });
window.dispatchEvent(
new MessageEvent('message', {
data: JSON.stringify({
command: 'reportWorkflowFailures',
workflowId: 'wf-1',
executionId: 'exec-1',
errors: [{ nodeName: 'HTTP Request', errorMessage: 'Connection refused' }],
}),
origin: 'https://evil.example',
source: mockIframeWindow,
}),
);
await waitFor(() => {
expect(emitted('workflow-failures')).toBeUndefined();
expect(emitted('iframe-ready')).toBeUndefined();
});
});
});

View File

@ -856,6 +856,17 @@ describe('createThreadRuntime - SSE and hydration', () => {
expect(activeRuntime(registry).messages).toHaveLength(0);
expect(activeRuntime(registry).isSendingMessage).toBe(false);
});
test('sendMessage sets activeRunId from postMessage response before run-start', async () => {
mockPostMessage.mockResolvedValue({ runId: 'run-from-post' });
const sendPromise = activeRuntime(registry).sendMessage('hello');
await vi.waitFor(() => {
expect(activeRuntime(registry).activeRunId).toBe('run-from-post');
});
await sendPromise;
});
});
// ---------------------------------------------------------------------------

View File

@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest';
import { buildFixWithAiPrompt, isFixWithAiError, useFixWithAiOffer } from '../useFixWithAiOffer';
vi.mock('@n8n/i18n', () => ({
useI18n: () => ({
baseText: (key: string, options?: { interpolate?: Record<string, string | number> }) => {
if (!options?.interpolate) return key;
return `${key}:${JSON.stringify(options.interpolate)}`;
},
}),
}));
describe('isFixWithAiError', () => {
it('accepts valid error objects', () => {
expect(isFixWithAiError({ nodeName: 'HTTP Request', errorMessage: 'failed' })).toBe(true);
});
it('rejects invalid values', () => {
expect(isFixWithAiError(null)).toBe(false);
expect(isFixWithAiError({ nodeName: 'HTTP Request' })).toBe(false);
});
});
describe('buildFixWithAiPrompt', () => {
it('builds a single-node prompt with workflow name', () => {
expect(
buildFixWithAiPrompt({
workflowName: 'My Workflow',
errors: [{ nodeName: 'Extract Emails', errorMessage: 'boom' }],
}),
).toContain('instanceAi.fixWithAi.prompt.singleInWorkflow');
});
it('builds a multi-node prompt', () => {
const prompt = buildFixWithAiPrompt({
workflowName: 'My Workflow',
errors: [
{ nodeName: 'Node A', errorMessage: 'first' },
{ nodeName: 'Node B', errorMessage: 'second' },
],
});
expect(prompt).toContain('instanceAi.fixWithAi.prompt.multipleInWorkflow');
});
});
describe('useFixWithAiOffer', () => {
it('registerOffer stores failures reported from the preview iframe', () => {
const { offersByWorkflow, registerOffer } = useFixWithAiOffer();
registerOffer({
workflowId: 'wf-1',
workflowName: 'My Workflow',
executionId: 'exec-99',
errors: [{ nodeName: 'Extract Emails', errorMessage: 'boom' }],
});
expect(offersByWorkflow.value.get('wf-1')).toEqual({
workflowId: 'wf-1',
workflowName: 'My Workflow',
executionId: 'exec-99',
errors: [{ nodeName: 'Extract Emails', errorMessage: 'boom' }],
});
});
it('ignores registerOffer when errors array is empty', () => {
const { offersByWorkflow, registerOffer } = useFixWithAiOffer();
registerOffer({
workflowId: 'wf-1',
executionId: 'exec-99',
errors: [],
});
expect(offersByWorkflow.value.has('wf-1')).toBe(false);
});
it('tracks dismiss state by execution id', () => {
const { dismiss, isDismissed } = useFixWithAiOffer();
dismiss('exec-1');
expect(isDismissed('exec-1')).toBe(true);
expect(isDismissed('exec-2')).toBe(false);
});
it('cleanup clears offers and dismiss state', () => {
const { offersByWorkflow, registerOffer, dismiss, cleanup, isDismissed } = useFixWithAiOffer();
registerOffer({
workflowId: 'wf-1',
executionId: 'exec-1',
errors: [{ nodeName: 'Node', errorMessage: 'failed' }],
});
dismiss('exec-1');
cleanup();
expect(offersByWorkflow.value.size).toBe(0);
expect(isDismissed('exec-1')).toBe(false);
});
});

View File

@ -0,0 +1,152 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useI18n } from '@n8n/i18n';
import { N8nButton, N8nIcon, N8nText } from '@n8n/design-system';
import { CollapsibleRoot, CollapsibleTrigger } from 'reka-ui';
import ConfirmationFooter from './ConfirmationFooter.vue';
import AnimatedCollapsibleContent from './AnimatedCollapsibleContent.vue';
const props = withDefaults(
defineProps<{
nodeName: string;
errorMessage: string;
failedCount?: number;
}>(),
{ failedCount: 1 },
);
defineEmits<{
'fix-with-ai': [];
dismiss: [];
}>();
const i18n = useI18n();
const isErrorDetailsOpen = ref(false);
const title = computed(() =>
props.failedCount > 1
? i18n.baseText('instanceAi.fixWithAi.notice.title.multiple', {
interpolate: { count: props.failedCount },
})
: i18n.baseText('instanceAi.fixWithAi.notice.title.single', {
interpolate: { nodeName: props.nodeName },
}),
);
</script>
<template>
<div :class="$style.root" role="alert" data-test-id="instance-ai-fix-with-ai-panel">
<div :class="$style.header">
<N8nIcon icon="circle-x" size="medium" :class="$style.headerIcon" />
<N8nText bold tag="span" :class="$style.title">{{ title }}</N8nText>
</div>
<div :class="$style.body">
<CollapsibleRoot v-model:open="isErrorDetailsOpen">
<CollapsibleTrigger as-child>
<button
type="button"
:class="$style.toggle"
data-test-id="instance-ai-fix-with-ai-error-toggle"
>
<N8nIcon :icon="isErrorDetailsOpen ? 'chevron-down' : 'chevron-right'" size="small" />
<N8nText size="small">{{ i18n.baseText('instanceAi.fixWithAi.errorDetails') }}</N8nText>
</button>
</CollapsibleTrigger>
<AnimatedCollapsibleContent>
<pre :class="$style.codeBlock"><code>{{ errorMessage }}</code></pre>
</AnimatedCollapsibleContent>
</CollapsibleRoot>
</div>
<ConfirmationFooter layout="row-end" bordered>
<N8nButton
variant="outline"
size="medium"
data-test-id="instance-ai-fix-with-ai-dismiss"
@click="$emit('dismiss')"
>
{{ i18n.baseText('instanceAi.fixWithAi.notice.dismiss') }}
</N8nButton>
<N8nButton
variant="solid"
size="medium"
icon="sparkles"
data-test-id="instance-ai-fix-with-ai-button"
@click="$emit('fix-with-ai')"
>
{{ i18n.baseText('instanceAi.fixWithAi.button') }}
</N8nButton>
</ConfirmationFooter>
</div>
</template>
<style module lang="scss">
.root {
border: var(--border);
border-radius: var(--radius--lg);
margin: var(--spacing--2xs) 0;
overflow: hidden;
background-color: var(--color--background--light-3);
max-width: 90%;
}
.header {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
padding: var(--spacing--xs) var(--spacing--sm);
border-bottom: var(--border);
}
.headerIcon {
flex-shrink: 0;
color: var(--color--danger);
}
.title {
flex: 1;
min-width: 0;
}
.body {
padding: var(--spacing--2xs) var(--spacing--sm);
}
.toggle {
display: flex;
align-items: center;
gap: var(--spacing--3xs);
width: 100%;
padding: var(--spacing--4xs) 0;
border: none;
background: transparent;
cursor: pointer;
color: var(--color--text--tint-1);
text-align: left;
&:hover {
color: var(--color--text);
}
}
.codeBlock {
margin: var(--spacing--3xs) 0 0;
padding: var(--spacing--2xs);
font-family: monospace;
font-size: var(--font-size--sm);
line-height: var(--line-height--lg);
color: var(--color--text);
background: light-dark(var(--color--background), var(--color--neutral-850));
border: var(--border);
border-radius: var(--radius);
white-space: pre-wrap;
word-break: break-word;
max-height: 12rem;
overflow: auto;
overscroll-behavior: contain;
}
.codeBlock code {
font-family: inherit;
font-size: inherit;
}
</style>

View File

@ -6,6 +6,11 @@ import type { PushMessage } from '@n8n/api-types';
import WorkflowPreview from '@/app/components/WorkflowPreview.vue';
import { useWorkflowsListStore } from '@/app/stores/workflowsList.store';
import type { IWorkflowDb } from '@/Interface';
import {
isFixWithAiError,
type FixWithAiError,
type FixWithAiOfferState,
} from '../useFixWithAiOffer';
const props = withDefaults(
defineProps<{
@ -16,6 +21,8 @@ const props = withDefaults(
{ refreshKey: 0 },
);
export type WorkflowFailuresReport = FixWithAiOfferState;
const emit = defineEmits<{
'iframe-ready': [];
/** Fires after a workflow fetch resolves and the new workflow has been
@ -23,6 +30,8 @@ const emit = defineEmits<{
* the iframe). Used by `useEventRelay` to gate buffered-event replay so the
* iframe always receives `openWorkflow` before the `executionEvent`s. */
'workflow-loaded': [workflowId: string];
/** Embedded canvas finished a run with node failures (iframe push is isolated). */
'workflow-failures': [report: WorkflowFailuresReport];
}>();
const i18n = useI18n();
@ -34,12 +43,48 @@ const isLoading = ref(false);
const fetchError = ref<string | null>(null);
let fetchGeneration = 0;
function getPreviewIframe(): HTMLIFrameElement | null {
return (
(previewRef.value as { iframeRef?: HTMLIFrameElement | null } | undefined)?.iframeRef ?? null
);
}
function parseWorkflowFailureErrors(errors: unknown): FixWithAiError[] {
if (!Array.isArray(errors)) return [];
return errors.filter(isFixWithAiError);
}
function isMessageFromPreviewIframe(event: MessageEvent): boolean {
if (event.origin !== window.location.origin) return false;
const iframeWindow = getPreviewIframe()?.contentWindow;
if (!iframeWindow) return false;
return event.source === iframeWindow;
}
function handleIframeMessage(event: MessageEvent) {
if (!isMessageFromPreviewIframe(event)) return;
if (typeof event.data !== 'string' || !event.data.includes('"command"')) return;
try {
const json = JSON.parse(event.data);
if (json.command === 'n8nReady') {
emit('iframe-ready');
} else if (json.command === 'reportWorkflowFailures') {
const errors = parseWorkflowFailureErrors(json.errors);
if (
errors.length === 0 ||
typeof json.workflowId !== 'string' ||
typeof json.executionId !== 'string'
) {
return;
}
emit('workflow-failures', {
workflowId: json.workflowId,
workflowName: typeof json.workflowName === 'string' ? json.workflowName : undefined,
executionId: json.executionId,
errors,
});
}
} catch {
// Ignore parse errors
@ -47,10 +92,9 @@ function handleIframeMessage(event: MessageEvent) {
}
function relayPushEvent(event: PushMessage) {
const iframe = (previewRef.value as { iframeRef?: HTMLIFrameElement | null } | undefined)
?.iframeRef;
if (!iframe?.contentWindow) return;
iframe.contentWindow.postMessage(
const iframeWindow = getPreviewIframe()?.contentWindow;
if (!iframeWindow) return;
iframeWindow.postMessage(
JSON.stringify({ command: 'executionEvent', event }),
window.location.origin,
);

View File

@ -692,7 +692,7 @@ export function createThreadRuntime(threadId: string, hooks: ThreadRuntimeHooks)
pushRef?: string,
): Promise<boolean> {
try {
await postMessage(
const { runId } = await postMessage(
rootStore.restApiContext,
threadId,
message,
@ -700,6 +700,10 @@ export function createThreadRuntime(threadId: string, hooks: ThreadRuntimeHooks)
Intl.DateTimeFormat().resolvedOptions().timeZone,
pushRef,
);
if (runId) {
activeRunId.value = runId;
}
return true;
} catch (error: unknown) {
const status = error instanceof ResponseError ? error.httpStatusCode : undefined;

View File

@ -0,0 +1,95 @@
import { ref } from 'vue';
import { useI18n } from '@n8n/i18n';
export interface FixWithAiError {
nodeName: string;
errorMessage: string;
}
export interface FixWithAiOfferState {
workflowId: string;
workflowName?: string;
executionId: string;
errors: FixWithAiError[];
}
export function isFixWithAiError(value: unknown): value is FixWithAiError {
return (
typeof value === 'object' &&
value !== null &&
typeof (value as { nodeName?: unknown }).nodeName === 'string' &&
typeof (value as { errorMessage?: unknown }).errorMessage === 'string'
);
}
export function buildFixWithAiPrompt(
context: Pick<FixWithAiOfferState, 'workflowName' | 'errors'>,
): string {
const i18n = useI18n();
if (context.errors.length === 1) {
const { nodeName, errorMessage } = context.errors[0];
if (context.workflowName) {
return i18n.baseText('instanceAi.fixWithAi.prompt.singleInWorkflow', {
interpolate: { nodeName, errorMessage, workflowName: context.workflowName },
});
}
return i18n.baseText('instanceAi.fixWithAi.prompt.single', {
interpolate: { nodeName, errorMessage },
});
}
const errorList = context.errors
.map(({ nodeName, errorMessage }) =>
i18n.baseText('instanceAi.fixWithAi.prompt.errorLine', {
interpolate: { nodeName, errorMessage },
}),
)
.join('\n');
if (context.workflowName) {
return i18n.baseText('instanceAi.fixWithAi.prompt.multipleInWorkflow', {
interpolate: { errorList, workflowName: context.workflowName },
});
}
return i18n.baseText('instanceAi.fixWithAi.prompt.multiple', {
interpolate: { errorList },
});
}
export function useFixWithAiOffer() {
const offersByWorkflow = ref(new Map<string, FixWithAiOfferState>());
const dismissedExecutionIds = ref(new Set<string>());
function registerOffer(input: FixWithAiOfferState) {
if (input.errors.length === 0) return;
const next = new Map(offersByWorkflow.value);
next.set(input.workflowId, input);
offersByWorkflow.value = next;
}
function dismiss(executionId: string) {
dismissedExecutionIds.value = new Set([...dismissedExecutionIds.value, executionId]);
}
function isDismissed(executionId: string): boolean {
return dismissedExecutionIds.value.has(executionId);
}
function cleanup() {
offersByWorkflow.value = new Map();
dismissedExecutionIds.value = new Set();
}
return {
offersByWorkflow,
registerOffer,
dismiss,
isDismissed,
cleanup,
};
}