feat: Abort AI builder requests on chat stop (#17854)

This commit is contained in:
oleg
2025-08-04 09:55:07 +02:00
committed by GitHub
parent 1554e76500
commit ce98f7c175
19 changed files with 585 additions and 91 deletions

View File

@@ -13,6 +13,7 @@ export function chatWithBuilder(
onMessageUpdated: (data: ChatRequest.ResponsePayload) => void,
onDone: () => void,
onError: (e: Error) => void,
abortSignal?: AbortSignal,
): void {
void streamRequest<ChatRequest.ResponsePayload>(
ctx,
@@ -21,6 +22,8 @@ export function chatWithBuilder(
onMessageUpdated,
onDone,
onError,
undefined,
abortSignal,
);
}

View File

@@ -132,11 +132,13 @@ watch(currentRoute, () => {
:loading-message="loadingMessage"
:mode="i18n.baseText('aiAssistant.builder.mode')"
:title="'n8n AI'"
:show-stop="true"
:scroll-on-new-message="true"
:placeholder="i18n.baseText('aiAssistant.builder.placeholder')"
@close="emit('close')"
@message="onUserMessage"
@feedback="onFeedback"
@stop="builderStore.stopStreaming"
>
<template #header>
<slot name="header" />

View File

@@ -30,6 +30,7 @@ const workflowSaver = useWorkflowSaving({ router });
const prompt = ref('');
const userEditedPrompt = ref(false);
const isFocused = ref(false);
const isLoading = ref(false);
// Computed properties
const hasContent = computed(() => prompt.value.trim().length > 0);
@@ -42,6 +43,7 @@ const suggestions = ref(WORKFLOW_SUGGESTIONS);
*/
async function onSubmit() {
if (!hasContent.value || builderStore.streaming) return;
isLoading.value = true;
const isNewWorkflow = workflowsStore.isNewWorkflow;
@@ -52,6 +54,7 @@ async function onSubmit() {
// Here we need to await for chat to open and session to be loaded
await builderStore.openChat();
isLoading.value = false;
builderStore.sendChatMessage({ text: prompt.value, source: 'canvas' });
}
@@ -120,7 +123,7 @@ function onAddNodeClick() {
name="aiBuilderPrompt"
:class="$style.formTextarea"
type="textarea"
:disabled="builderStore.streaming"
:disabled="isLoading || builderStore.streaming"
:placeholder="i18n.baseText('aiAssistant.builder.placeholder')"
:read-only="false"
:rows="15"
@@ -133,6 +136,7 @@ function onAddNodeClick() {
<n8n-button
native-type="submit"
:disabled="!hasContent || builderStore.streaming"
:loading="isLoading"
@keydown.enter="onSubmit"
>
{{ i18n.baseText('aiAssistant.builder.canvasPrompt.buildWorkflow') }}

View File

@@ -262,6 +262,16 @@ export function useBuilderMessages() {
} as ChatUI.AssistantMessage;
}
function createAssistantMessage(content: string, id: string): ChatUI.AssistantMessage {
return {
id,
role: 'assistant',
type: 'text',
content,
read: true,
} as ChatUI.AssistantMessage;
}
function createErrorMessage(
content: string,
id: string,
@@ -346,6 +356,7 @@ export function useBuilderMessages() {
return {
processAssistantMessages,
createUserMessage,
createAssistantMessage,
createErrorMessage,
clearMessages,
addMessages,

View File

@@ -1,3 +1,6 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { setActivePinia, createPinia } from 'pinia';
import { ENABLED_VIEWS, useBuilderStore } from '@/stores/builder.store';
@@ -505,4 +508,205 @@ describe('AI Builder store', () => {
'I can help you build a workflow',
);
});
describe('Abort functionality', () => {
it('should create and manage abort controller', () => {
const builderStore = useBuilderStore();
// Initially no abort controller (might be undefined or null)
expect(builderStore.streamingAbortController).toBeFalsy();
// Start streaming creates abort controller
apiSpy.mockImplementationOnce((_ctx, _payload, onMessage, _onDone, _onError, _signal) => {
// Simulate successful start of streaming
setTimeout(() => {
onMessage({
messages: [
{
type: 'message',
role: 'assistant',
text: 'Processing...',
},
],
sessionId: 'test-session',
});
}, 0);
});
builderStore.sendChatMessage({ text: 'test' });
expect(builderStore.streamingAbortController).not.toBeNull();
expect(builderStore.streamingAbortController).toBeInstanceOf(AbortController);
});
it('should call abort on existing controller when stopStreaming is called', () => {
const builderStore = useBuilderStore();
// First start a request to create an abort controller
apiSpy.mockImplementationOnce(() => {});
builderStore.sendChatMessage({ text: 'test' });
// Verify controller was created
const controller = builderStore.streamingAbortController;
expect(controller).toBeInstanceOf(AbortController);
// Spy on the abort method
const abortSpy = vi.spyOn(controller!, 'abort');
// Call stopStreaming
builderStore.stopStreaming();
// Verify abort was called
expect(abortSpy).toHaveBeenCalled();
expect(builderStore.streamingAbortController).toBeNull();
expect(builderStore.streaming).toBe(false);
});
it('should handle AbortError gracefully', async () => {
const builderStore = useBuilderStore();
// Simulate an abort error
const abortError = new Error('AbortError');
abortError.name = 'AbortError';
apiSpy.mockImplementationOnce((_ctx, _payload, _onMessage, _onDone, onError) => {
onError(abortError);
});
builderStore.sendChatMessage({ text: 'test message' });
await vi.waitFor(() => expect(builderStore.chatMessages.length).toBe(2));
// Should have user message and aborted message
expect(builderStore.chatMessages[0].role).toBe('user');
expect(builderStore.chatMessages[1].role).toBe('assistant');
expect(builderStore.chatMessages[1].type).toBe('text');
expect((builderStore.chatMessages[1] as ChatUI.TextMessage).content).toBe('[Task aborted]');
// Verify streaming state was reset
expect(builderStore.streaming).toBe(false);
expect(builderStore.assistantThinkingMessage).toBeUndefined();
});
it('should abort previous request when sending new message', () => {
const builderStore = useBuilderStore();
// The current implementation prevents sending a new message while streaming
// by checking if streaming.value is true and returning early.
// Mock for first request - keep it pending
apiSpy.mockImplementationOnce((_ctx, _payload, onMessage, _onDone) => {
// Don't call onDone to keep streaming active
setTimeout(() => {
onMessage({
messages: [
{
type: 'message',
role: 'assistant',
text: 'Processing first message...',
},
],
sessionId: 'test-session',
});
}, 10);
});
// Start first request
builderStore.sendChatMessage({ text: 'first message' });
// Verify streaming is active and controller was created
expect(builderStore.streaming).toBe(true);
const firstController = builderStore.streamingAbortController;
expect(firstController).not.toBeNull();
expect(firstController).toBeInstanceOf(AbortController);
// Track if abort was called
const abortSpy = vi.spyOn(firstController!, 'abort');
// Try to send second message while streaming - it should be ignored
builderStore.sendChatMessage({ text: 'second message ignored' });
// Verify the abort was NOT called and controller is the same
expect(abortSpy).not.toHaveBeenCalled();
expect(builderStore.streamingAbortController).toBe(firstController);
// Now properly stop streaming first
builderStore.stopStreaming();
// Verify abort was called and controller was cleared
expect(abortSpy).toHaveBeenCalled();
expect(builderStore.streamingAbortController).toBeNull();
expect(builderStore.streaming).toBe(false);
// Mock for second request
apiSpy.mockImplementationOnce(() => {});
// Now we can send a new message
builderStore.sendChatMessage({ text: 'second message' });
// New controller should be created
const secondController = builderStore.streamingAbortController;
expect(secondController).not.toBe(firstController);
expect(secondController).not.toBeNull();
expect(secondController).toBeInstanceOf(AbortController);
});
it('should pass abort signal to API call', () => {
const builderStore = useBuilderStore();
// Mock the API to prevent actual network calls
apiSpy.mockImplementationOnce(() => {});
builderStore.sendChatMessage({ text: 'test' });
// Verify the API was called with correct parameters
expect(apiSpy).toHaveBeenCalled();
const callArgs = apiSpy.mock.calls[0];
expect(callArgs).toHaveLength(6); // Should have 6 arguments
const signal = callArgs[5]; // The 6th argument is the abort signal
expect(signal).toBeDefined();
expect(signal).toBeInstanceOf(AbortSignal);
// Check that it's the same signal from the controller
const controller = builderStore.streamingAbortController;
expect(controller).not.toBeNull();
expect(controller).toBeInstanceOf(AbortController);
expect(signal).toBe(controller!.signal);
});
it('should not create error message for aborted requests', async () => {
const builderStore = useBuilderStore();
// Track telemetry calls
const telemetryTrackSpy = vi.fn();
track.mockImplementation(telemetryTrackSpy);
// Simulate abort error
const abortError = new Error('AbortError');
abortError.name = 'AbortError';
apiSpy.mockImplementationOnce((_ctx, _payload, _onMessage, _onDone, onError) => {
// Call error handler immediately
onError(abortError);
});
// Clear messages before test
builderStore.chatMessages.length = 0;
builderStore.sendChatMessage({ text: 'test' });
// Wait for the error to be processed
await vi.waitFor(() => expect(builderStore.chatMessages.length).toBeGreaterThan(1));
// Should not track error for abort
expect(telemetryTrackSpy).not.toHaveBeenCalledWith(
'Workflow generation errored',
expect.anything(),
);
// Find the assistant messages (skip user message)
const assistantMessages = builderStore.chatMessages.filter((msg) => msg.role === 'assistant');
expect(assistantMessages).toHaveLength(1);
expect(assistantMessages[0].type).toBe('text');
expect((assistantMessages[0] as ChatUI.TextMessage).content).toBe('[Task aborted]');
});
});
});

View File

@@ -36,6 +36,7 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
const chatWindowOpen = ref<boolean>(false);
const streaming = ref<boolean>(false);
const assistantThinkingMessage = ref<string | undefined>();
const streamingAbortController = ref<AbortController | null>(null);
// Store dependencies
const settings = useSettingsStore();
@@ -51,6 +52,7 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
const {
processAssistantMessages,
createUserMessage,
createAssistantMessage,
createErrorMessage,
clearMessages,
mapAssistantMessageToUI,
@@ -151,6 +153,10 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
function stopStreaming() {
streaming.value = false;
if (streamingAbortController.value) {
streamingAbortController.value.abort();
streamingAbortController.value = null;
}
}
// Error handling
@@ -166,11 +172,19 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
stopStreaming();
assistantThinkingMessage.value = undefined;
if (e.name === 'AbortError') {
// Handle abort errors as they are expected when stopping streaming
const userMsg = createAssistantMessage('[Task aborted]', 'aborted-streaming');
chatMessages.value = [...chatMessages.value, userMsg];
return;
}
const errorMessage = createErrorMessage(
locale.baseText('aiAssistant.serviceError.message', { interpolate: { message: e.message } }),
id,
retry,
);
chatMessages.value = [...chatMessages.value, errorMessage];
telemetry.track('Workflow generation errored', {
@@ -247,6 +261,12 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
});
const retry = createRetryHandler(messageId, async () => sendChatMessage(options));
// Abort previous streaming request if any
if (streamingAbortController.value) {
streamingAbortController.value.abort();
}
streamingAbortController.value = new AbortController();
try {
chatWithBuilder(
rootStore.restApiContext,
@@ -269,6 +289,7 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
},
() => stopStreaming(),
(e) => handleServiceError(e, messageId, retry),
streamingAbortController.value?.signal,
);
} catch (e: unknown) {
handleServiceError(e, messageId, retry);
@@ -393,9 +414,11 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
toolMessages,
workflowMessages,
trackingSessionId,
streamingAbortController,
// Methods
updateWindowWidth,
stopStreaming,
closeChat,
openChat,
resetBuilderChat,

View File

@@ -2132,7 +2132,12 @@ onBeforeUnmount(() => {
{{ i18n.baseText('readOnlyEnv.cantEditOrRun') }}
</N8nCallout>
<CanvasThinkingPill v-if="builderStore.streaming" :class="$style.thinkingPill" />
<CanvasThinkingPill
v-if="builderStore.streaming"
:class="$style.thinkingPill"
show-stop
@stop="builderStore.stopStreaming"
/>
<Suspense>
<LazyNodeCreation