mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-31 08:46:58 +02:00
refactor(editor): Streamline fix-with-AI offer state and reporting (#30827)
This commit is contained in:
parent
bd3aafce75
commit
bfb50dc630
|
|
@ -1,81 +1,62 @@
|
|||
import { computed, watch } from 'vue';
|
||||
import { onBeforeUnmount } 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';
|
||||
import { VIEWS } from '@/app/constants';
|
||||
import { createExecutionDataId, useExecutionDataStore } from '@/app/stores/executionData.store';
|
||||
import { usePushConnectionStore } from '@/app/stores/pushConnection.store';
|
||||
import type { FixWithAiError } from '@/features/ai/instanceAi/fixWithAi';
|
||||
|
||||
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;
|
||||
|
||||
const error = tasks?.at(-1)?.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) {
|
||||
/**
|
||||
* Lives in the embedded canvas iframe. Listens for `executionFinished` push
|
||||
* events on the iframe's own connection (parent has a separate `pushRef`, so
|
||||
* iframe-triggered runs are not visible there) and forwards per-node failures
|
||||
* to the parent via `postMessage` so the chat can surface a "Fix with AI" card.
|
||||
*
|
||||
* Reads the per-execution data store (keyed by the executionId we just got the
|
||||
* finish event for) once per failed run — by then the in-iframe push handlers
|
||||
* have already populated it. Avoids a deep `watch` over the whole run-data tree.
|
||||
*/
|
||||
export function useReportWorkflowFailuresToParent() {
|
||||
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;
|
||||
const pushStore = usePushConnectionStore();
|
||||
|
||||
watch(
|
||||
[
|
||||
isEnabled,
|
||||
() => workflowsStore.getWorkflowRunData,
|
||||
() => workflowsStore.isWorkflowRunning,
|
||||
() => workflowsStore.workflowId,
|
||||
() => workflowsStore.getWorkflowExecution?.id,
|
||||
],
|
||||
() => {
|
||||
if (!isEnabled.value || workflowsStore.isWorkflowRunning) return;
|
||||
const removeListener = pushStore.addEventListener((event) => {
|
||||
if (route.name !== VIEWS.DEMO || route.query.canExecute !== 'true') return;
|
||||
if (event.type !== 'executionFinished') return;
|
||||
if (event.data.status === 'success') return;
|
||||
|
||||
const workflowId = workflowsStore.workflowId;
|
||||
const runData = workflowsStore.getWorkflowRunData;
|
||||
const execStore = useExecutionDataStore(createExecutionDataId(event.data.executionId));
|
||||
const runData = execStore.executionRunData;
|
||||
if (!runData) return;
|
||||
const errors = collectErrorsFromRunData(runData);
|
||||
if (errors.length === 0) return;
|
||||
|
||||
if (!workflowId || !runData) return;
|
||||
window.parent.postMessage(
|
||||
JSON.stringify({
|
||||
command: 'reportWorkflowFailures',
|
||||
workflowId: event.data.workflowId,
|
||||
executionId: event.data.executionId,
|
||||
errors,
|
||||
}),
|
||||
window.location.origin,
|
||||
);
|
||||
});
|
||||
|
||||
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 },
|
||||
);
|
||||
onBeforeUnmount(removeListener);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ const { setup: setupPostMessages, cleanup: cleanupPostMessages } = usePostMessag
|
|||
currentNDVStore,
|
||||
});
|
||||
|
||||
useReportWorkflowFailuresToParent(() => currentWorkflowDocumentStore.value?.name ?? undefined);
|
||||
useReportWorkflowFailuresToParent();
|
||||
|
||||
// Initialize push event handlers so relayed execution events (via postMessage
|
||||
// from the parent) are processed for node highlighting, execution state, etc.
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ import CreditWarningBanner from '@/features/ai/assistant/components/Agent/Credit
|
|||
import InstanceAiWorkflowPreview, {
|
||||
type WorkflowFailuresReport,
|
||||
} from './components/InstanceAiWorkflowPreview.vue';
|
||||
import { buildFixWithAiPrompt, useFixWithAiOffer } from './useFixWithAiOffer';
|
||||
import { buildFixWithAiPrompt } from './fixWithAi';
|
||||
import InstanceAiDataTablePreview from './components/InstanceAiDataTablePreview.vue';
|
||||
import { TabsRoot } from 'reka-ui';
|
||||
|
||||
|
|
@ -87,40 +87,26 @@ const hasFloatingConfirmation = computed(() =>
|
|||
thread.pendingConfirmations.some(isPendingItemFloating),
|
||||
);
|
||||
|
||||
// --- Execution tracking via push events ---
|
||||
// --- Execution tracking via push events (drives canvas relay) ---
|
||||
const executionTracking = useExecutionPushEvents();
|
||||
const fixWithAiOffer = useFixWithAiOffer();
|
||||
|
||||
// --- Fix-with-AI offer (failure data sent by the iframe via postMessage) ---
|
||||
const failedRun = ref<WorkflowFailuresReport | null>(null);
|
||||
const dismissedExecutionId = ref<string | null>(null);
|
||||
|
||||
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(() => {
|
||||
const run = failedRun.value;
|
||||
if (!run) return null;
|
||||
if (run.executionId === dismissedExecutionId.value) return null;
|
||||
if (isChatInProgress.value) return null;
|
||||
return findReadyFixWithAiOffer(preview.activeWorkflowId.value);
|
||||
return {
|
||||
...run,
|
||||
workflowName: thread.producedArtifacts.get(run.workflowId)?.name,
|
||||
};
|
||||
});
|
||||
|
||||
// --- Header title ---
|
||||
|
|
@ -499,7 +485,6 @@ onUnmounted(() => {
|
|||
contentResizeObserver?.disconnect();
|
||||
resizeObserver?.disconnect();
|
||||
executionTracking.cleanup();
|
||||
fixWithAiOffer.cleanup();
|
||||
});
|
||||
|
||||
// --- Workflow preview ref for iframe relay ---
|
||||
|
|
@ -529,7 +514,7 @@ function handleFixWithAiFromOffer() {
|
|||
const offer = activeFixWithAiOffer.value;
|
||||
if (!offer) return;
|
||||
|
||||
fixWithAiOffer.dismiss(offer.executionId);
|
||||
dismissedExecutionId.value = offer.executionId;
|
||||
userScrolledUp.value = false;
|
||||
void thread.sendMessage(
|
||||
buildFixWithAiPrompt({ workflowName: offer.workflowName, errors: offer.errors }),
|
||||
|
|
@ -541,11 +526,11 @@ function handleFixWithAiFromOffer() {
|
|||
function dismissFixWithAiOffer() {
|
||||
const offer = activeFixWithAiOffer.value;
|
||||
if (!offer) return;
|
||||
fixWithAiOffer.dismiss(offer.executionId);
|
||||
dismissedExecutionId.value = offer.executionId;
|
||||
}
|
||||
|
||||
function handleWorkflowFailures(report: WorkflowFailuresReport) {
|
||||
fixWithAiOffer.registerOffer(report);
|
||||
failedRun.value = report;
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { defineComponent, h, ref } from 'vue';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { setActivePinia } from 'pinia';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
|
|
@ -8,6 +9,7 @@ import InstanceAiThreadView from '../InstanceAiThreadView.vue';
|
|||
import { useInstanceAiStore, type ThreadRuntime } from '../instanceAi.store';
|
||||
import { usePushConnectionStore } from '@/app/stores/pushConnection.store';
|
||||
import { SidebarStateKey } from '../instanceAiLayout';
|
||||
import type { WorkflowFailuresReport } from '../components/InstanceAiWorkflowPreview.vue';
|
||||
|
||||
const mockWindowSizeState = vi.hoisted(() => ({
|
||||
width: { value: 1200 },
|
||||
|
|
@ -54,6 +56,20 @@ const InstanceAiInputStub = defineComponent({
|
|||
},
|
||||
});
|
||||
|
||||
let workflowPreviewEmit:
|
||||
| ((event: 'workflow-failures', payload: WorkflowFailuresReport) => void)
|
||||
| null = null;
|
||||
|
||||
const InstanceAiWorkflowPreviewStub = defineComponent({
|
||||
name: 'InstanceAiWorkflowPreviewStub',
|
||||
emits: ['iframe-ready', 'workflow-loaded', 'workflow-failures'],
|
||||
setup(_, { emit, expose }) {
|
||||
workflowPreviewEmit = emit as typeof workflowPreviewEmit;
|
||||
expose({ relayPushEvent: vi.fn(), requestFitView: vi.fn() });
|
||||
return () => h('div', { 'data-test-id': 'instance-ai-workflow-preview-stub' });
|
||||
},
|
||||
});
|
||||
|
||||
const InstanceAiConfirmationPanelStub = defineComponent({
|
||||
name: 'InstanceAiConfirmationPanelStub',
|
||||
props: {
|
||||
|
|
@ -71,6 +87,7 @@ const renderView = createComponentRenderer(InstanceAiThreadView, {
|
|||
},
|
||||
stubs: {
|
||||
InstanceAiInput: InstanceAiInputStub,
|
||||
InstanceAiWorkflowPreview: InstanceAiWorkflowPreviewStub,
|
||||
InstanceAiConfirmationPanel: InstanceAiConfirmationPanelStub,
|
||||
},
|
||||
},
|
||||
|
|
@ -85,6 +102,8 @@ describe('InstanceAiThreadView', () => {
|
|||
const pinia = createTestingPinia();
|
||||
setActivePinia(pinia);
|
||||
|
||||
workflowPreviewEmit = null;
|
||||
|
||||
thread = {
|
||||
id: 'thread-1',
|
||||
messages: [],
|
||||
|
|
@ -263,4 +282,75 @@ describe('InstanceAiThreadView', () => {
|
|||
});
|
||||
expect(queryByTestId('instance-ai-artifacts-sidebar-slot')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('Fix with AI card', () => {
|
||||
const failureReport: WorkflowFailuresReport = {
|
||||
workflowId: 'wf-1',
|
||||
executionId: 'exec-1',
|
||||
errors: [{ nodeName: 'Extract Emails', errorMessage: 'Intentional break' }],
|
||||
};
|
||||
|
||||
function seedThreadArtifact(workflowId = 'wf-1', workflowName = 'My Workflow') {
|
||||
thread.producedArtifacts = new Map([
|
||||
[workflowId, { type: 'workflow', id: workflowId, name: workflowName }],
|
||||
]) as typeof thread.producedArtifacts;
|
||||
}
|
||||
|
||||
async function emitFailure(report: WorkflowFailuresReport = failureReport) {
|
||||
await vi.waitFor(() => {
|
||||
expect(workflowPreviewEmit).not.toBeNull();
|
||||
});
|
||||
workflowPreviewEmit?.('workflow-failures', report);
|
||||
}
|
||||
|
||||
it('renders the card when the iframe reports a workflow failure', async () => {
|
||||
seedThreadArtifact();
|
||||
const { getByTestId, findByTestId } = renderView({ props: { threadId: 'thread-1' } });
|
||||
|
||||
await emitFailure();
|
||||
|
||||
const panel = await findByTestId('instance-ai-fix-with-ai-panel');
|
||||
expect(panel).toHaveTextContent('Execution failed in ‘Extract Emails’ node');
|
||||
expect(getByTestId('instance-ai-fix-with-ai-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the card after dismiss', async () => {
|
||||
seedThreadArtifact();
|
||||
const user = userEvent.setup();
|
||||
const { findByTestId, queryByTestId } = renderView({ props: { threadId: 'thread-1' } });
|
||||
|
||||
await emitFailure();
|
||||
await user.click(await findByTestId('instance-ai-fix-with-ai-dismiss'));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(queryByTestId('instance-ai-fix-with-ai-panel')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('sends a fix prompt that names the failed node, error and workflow', async () => {
|
||||
seedThreadArtifact('wf-1', 'My Workflow');
|
||||
const user = userEvent.setup();
|
||||
const { findByTestId } = renderView({ props: { threadId: 'thread-1' } });
|
||||
|
||||
await emitFailure();
|
||||
await user.click(await findByTestId('instance-ai-fix-with-ai-button'));
|
||||
|
||||
expect(thread.sendMessage).toHaveBeenCalledOnce();
|
||||
const [prompt] = vi.mocked(thread.sendMessage).mock.calls[0];
|
||||
expect(prompt).toContain('Extract Emails');
|
||||
expect(prompt).toContain('Intentional break');
|
||||
expect(prompt).toContain('My Workflow');
|
||||
});
|
||||
|
||||
it('hides the card while the chat is busy', async () => {
|
||||
seedThreadArtifact();
|
||||
thread.isStreaming = true;
|
||||
|
||||
const { queryByTestId } = renderView({ props: { threadId: 'thread-1' } });
|
||||
|
||||
await emitFailure();
|
||||
|
||||
expect(queryByTestId('instance-ai-fix-with-ai-panel')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -176,7 +176,6 @@ describe('InstanceAiWorkflowPreview', () => {
|
|||
dispatchIframeMessage({
|
||||
command: 'reportWorkflowFailures',
|
||||
workflowId: 'wf-1',
|
||||
workflowName: 'My Workflow',
|
||||
executionId: 'exec-1',
|
||||
errors: [{ nodeName: 'Extract Emails', errorMessage: 'Intentional break' }],
|
||||
});
|
||||
|
|
@ -188,7 +187,6 @@ describe('InstanceAiWorkflowPreview', () => {
|
|||
[
|
||||
{
|
||||
workflowId: 'wf-1',
|
||||
workflowName: 'My Workflow',
|
||||
executionId: 'exec-1',
|
||||
errors: [{ nodeName: 'Extract Emails', errorMessage: 'Intentional break' }],
|
||||
},
|
||||
|
|
@ -200,7 +198,6 @@ describe('InstanceAiWorkflowPreview', () => {
|
|||
const { emitted } = renderComponent({ props: { workflowId: null } });
|
||||
|
||||
dispatchIframeMessage({ command: 'reportWorkflowFailures', errors: [] });
|
||||
|
||||
dispatchIframeMessage({
|
||||
command: 'reportWorkflowFailures',
|
||||
workflowId: 'wf-1',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { buildFixWithAiPrompt, isFixWithAiError } from '../fixWithAi';
|
||||
|
||||
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);
|
||||
expect(isFixWithAiError({ nodeName: 1, errorMessage: 'x' })).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 single-node prompt without workflow name', () => {
|
||||
expect(
|
||||
buildFixWithAiPrompt({
|
||||
errors: [{ nodeName: 'Extract Emails', errorMessage: 'boom' }],
|
||||
}),
|
||||
).toContain('instanceAi.fixWithAi.prompt.single');
|
||||
});
|
||||
|
||||
it('builds a multi-node prompt with workflow name', () => {
|
||||
expect(
|
||||
buildFixWithAiPrompt({
|
||||
workflowName: 'My Workflow',
|
||||
errors: [
|
||||
{ nodeName: 'Node A', errorMessage: 'first' },
|
||||
{ nodeName: 'Node B', errorMessage: 'second' },
|
||||
],
|
||||
}),
|
||||
).toContain('instanceAi.fixWithAi.prompt.multipleInWorkflow');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -6,11 +6,7 @@ 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';
|
||||
import { isFixWithAiError, type FixWithAiError } from '../fixWithAi';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
|
|
@ -21,7 +17,11 @@ const props = withDefaults(
|
|||
{ refreshKey: 0 },
|
||||
);
|
||||
|
||||
export type WorkflowFailuresReport = FixWithAiOfferState;
|
||||
export interface WorkflowFailuresReport {
|
||||
workflowId: string;
|
||||
executionId: string;
|
||||
errors: FixWithAiError[];
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
'iframe-ready': [];
|
||||
|
|
@ -49,11 +49,6 @@ function getPreviewIframe(): HTMLIFrameElement | 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;
|
||||
|
||||
|
|
@ -63,6 +58,11 @@ function isMessageFromPreviewIframe(event: MessageEvent): boolean {
|
|||
return event.source === iframeWindow;
|
||||
}
|
||||
|
||||
function parseWorkflowFailureErrors(errors: unknown): FixWithAiError[] {
|
||||
if (!Array.isArray(errors)) return [];
|
||||
return errors.filter(isFixWithAiError);
|
||||
}
|
||||
|
||||
function handleIframeMessage(event: MessageEvent) {
|
||||
if (!isMessageFromPreviewIframe(event)) return;
|
||||
if (typeof event.data !== 'string' || !event.data.includes('"command"')) return;
|
||||
|
|
@ -81,7 +81,6 @@ function handleIframeMessage(event: MessageEvent) {
|
|||
}
|
||||
emit('workflow-failures', {
|
||||
workflowId: json.workflowId,
|
||||
workflowName: typeof json.workflowName === 'string' ? json.workflowName : undefined,
|
||||
executionId: json.executionId,
|
||||
errors,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { ref } from 'vue';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
|
||||
export interface FixWithAiError {
|
||||
|
|
@ -6,13 +5,6 @@ export interface FixWithAiError {
|
|||
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' &&
|
||||
|
|
@ -22,9 +14,10 @@ export function isFixWithAiError(value: unknown): value is FixWithAiError {
|
|||
);
|
||||
}
|
||||
|
||||
export function buildFixWithAiPrompt(
|
||||
context: Pick<FixWithAiOfferState, 'workflowName' | 'errors'>,
|
||||
): string {
|
||||
export function buildFixWithAiPrompt(context: {
|
||||
workflowName?: string;
|
||||
errors: FixWithAiError[];
|
||||
}): string {
|
||||
const i18n = useI18n();
|
||||
|
||||
if (context.errors.length === 1) {
|
||||
|
|
@ -59,37 +52,3 @@ export function buildFixWithAiPrompt(
|
|||
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