mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-31 00:37:10 +02:00
feat(editor): Add fix with AI action to instance ai chat on execution errors (no-changelog) (#30705)
This commit is contained in:
parent
d8ef975101
commit
49e422cb57
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user