mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
feat: Abort AI builder requests on chat stop (#17854)
This commit is contained in:
@@ -10,7 +10,6 @@ import AssistantText from '../AskAssistantText/AssistantText.vue';
|
||||
import InlineAskAssistantButton from '../InlineAskAssistantButton/InlineAskAssistantButton.vue';
|
||||
import N8nButton from '../N8nButton';
|
||||
import N8nIcon from '../N8nIcon';
|
||||
import N8nIconButton from '../N8nIconButton';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -28,10 +27,12 @@ interface Props {
|
||||
title?: string;
|
||||
placeholder?: string;
|
||||
scrollOnNewMessage?: boolean;
|
||||
showStop?: boolean;
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
stop: [];
|
||||
message: [string, string?, boolean?];
|
||||
codeReplace: [number];
|
||||
codeUndo: [number];
|
||||
@@ -253,11 +254,24 @@ watch(
|
||||
@input.prevent="growInput"
|
||||
@keydown.stop
|
||||
/>
|
||||
<N8nIconButton
|
||||
:class="{ [$style.sendButton]: true }"
|
||||
<N8nButton
|
||||
v-if="showStop && streaming"
|
||||
:class="$style.stopButton"
|
||||
icon="square"
|
||||
size="large"
|
||||
type="danger"
|
||||
outline
|
||||
square
|
||||
data-test-id="send-message-button"
|
||||
@click="emit('stop')"
|
||||
/>
|
||||
<N8nButton
|
||||
v-else
|
||||
:class="$style.sendButton"
|
||||
icon="send"
|
||||
:text="true"
|
||||
size="large"
|
||||
square
|
||||
data-test-id="send-message-button"
|
||||
:disabled="sendDisabled"
|
||||
@click="onSendMessage"
|
||||
@@ -274,7 +288,9 @@ watch(
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
}
|
||||
|
||||
:root .stopButton {
|
||||
--button-border-color: transparent;
|
||||
}
|
||||
.header {
|
||||
height: 65px; // same as header height in editor
|
||||
padding: 0 var(--spacing-l);
|
||||
|
||||
@@ -171,15 +171,19 @@ exports[`AskAssistantChat > does not render retry button if no error is present
|
||||
rows="1"
|
||||
wrap="hard"
|
||||
/>
|
||||
<n8n-icon-button-stub
|
||||
<n8n-button-stub
|
||||
active="false"
|
||||
block="false"
|
||||
class="sendButton"
|
||||
data-test-id="send-message-button"
|
||||
disabled="true"
|
||||
element="button"
|
||||
icon="send"
|
||||
label=""
|
||||
loading="false"
|
||||
outline="false"
|
||||
size="large"
|
||||
square="true"
|
||||
text="true"
|
||||
type="primary"
|
||||
/>
|
||||
@@ -988,15 +992,19 @@ Testing more code
|
||||
rows="1"
|
||||
wrap="hard"
|
||||
/>
|
||||
<n8n-icon-button-stub
|
||||
<n8n-button-stub
|
||||
active="false"
|
||||
block="false"
|
||||
class="sendButton"
|
||||
data-test-id="send-message-button"
|
||||
disabled="true"
|
||||
element="button"
|
||||
icon="send"
|
||||
label=""
|
||||
loading="false"
|
||||
outline="false"
|
||||
size="large"
|
||||
square="true"
|
||||
text="true"
|
||||
type="primary"
|
||||
/>
|
||||
@@ -1169,15 +1177,19 @@ exports[`AskAssistantChat > renders default placeholder chat correctly 1`] = `
|
||||
rows="1"
|
||||
wrap="hard"
|
||||
/>
|
||||
<n8n-icon-button-stub
|
||||
<n8n-button-stub
|
||||
active="false"
|
||||
block="false"
|
||||
class="sendButton"
|
||||
data-test-id="send-message-button"
|
||||
disabled="true"
|
||||
element="button"
|
||||
icon="send"
|
||||
label=""
|
||||
loading="false"
|
||||
outline="false"
|
||||
size="large"
|
||||
square="true"
|
||||
text="true"
|
||||
type="primary"
|
||||
/>
|
||||
@@ -1438,15 +1450,19 @@ exports[`AskAssistantChat > renders end of session chat correctly 1`] = `
|
||||
rows="1"
|
||||
wrap="hard"
|
||||
/>
|
||||
<n8n-icon-button-stub
|
||||
<n8n-button-stub
|
||||
active="false"
|
||||
block="false"
|
||||
class="sendButton"
|
||||
data-test-id="send-message-button"
|
||||
disabled="true"
|
||||
element="button"
|
||||
icon="send"
|
||||
label=""
|
||||
loading="false"
|
||||
outline="false"
|
||||
size="large"
|
||||
square="true"
|
||||
text="true"
|
||||
type="primary"
|
||||
/>
|
||||
@@ -1641,15 +1657,19 @@ exports[`AskAssistantChat > renders error message correctly with retry button 1`
|
||||
rows="1"
|
||||
wrap="hard"
|
||||
/>
|
||||
<n8n-icon-button-stub
|
||||
<n8n-button-stub
|
||||
active="false"
|
||||
block="false"
|
||||
class="sendButton"
|
||||
data-test-id="send-message-button"
|
||||
disabled="true"
|
||||
element="button"
|
||||
icon="send"
|
||||
label=""
|
||||
loading="false"
|
||||
outline="false"
|
||||
size="large"
|
||||
square="true"
|
||||
text="true"
|
||||
type="primary"
|
||||
/>
|
||||
@@ -1900,15 +1920,19 @@ catch(e) {
|
||||
rows="1"
|
||||
wrap="hard"
|
||||
/>
|
||||
<n8n-icon-button-stub
|
||||
<n8n-button-stub
|
||||
active="false"
|
||||
block="false"
|
||||
class="sendButton"
|
||||
data-test-id="send-message-button"
|
||||
disabled="true"
|
||||
element="button"
|
||||
icon="send"
|
||||
label=""
|
||||
loading="false"
|
||||
outline="false"
|
||||
size="large"
|
||||
square="true"
|
||||
text="true"
|
||||
type="primary"
|
||||
/>
|
||||
@@ -2092,15 +2116,19 @@ exports[`AskAssistantChat > renders streaming chat correctly 1`] = `
|
||||
rows="1"
|
||||
wrap="hard"
|
||||
/>
|
||||
<n8n-icon-button-stub
|
||||
<n8n-button-stub
|
||||
active="false"
|
||||
block="false"
|
||||
class="sendButton"
|
||||
data-test-id="send-message-button"
|
||||
disabled="true"
|
||||
element="button"
|
||||
icon="send"
|
||||
label=""
|
||||
loading="false"
|
||||
outline="false"
|
||||
size="large"
|
||||
square="true"
|
||||
text="true"
|
||||
type="primary"
|
||||
/>
|
||||
|
||||
@@ -3,11 +3,20 @@ import { useCssModule } from 'vue';
|
||||
|
||||
import { useI18n } from '../../composables/useI18n';
|
||||
import AssistantIcon from '../AskAssistantIcon/AssistantIcon.vue';
|
||||
import N8nButton from '../N8nButton';
|
||||
|
||||
defineOptions({
|
||||
name: 'CanvasThinkingPill',
|
||||
});
|
||||
|
||||
defineProps<{
|
||||
showStop?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
stop: [];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const $style = useCssModule();
|
||||
</script>
|
||||
@@ -17,7 +26,17 @@ const $style = useCssModule();
|
||||
<div :class="$style.iconWrapper">
|
||||
<AssistantIcon theme="blank" />
|
||||
</div>
|
||||
<span :class="$style.text">{{ t('aiAssistant.builder.canvas.thinking') }}</span>
|
||||
<span :class="$style.text"
|
||||
>{{ t('aiAssistant.builder.canvas.thinking') }}
|
||||
<N8nButton
|
||||
v-if="showStop"
|
||||
:class="$style.stopButton"
|
||||
:label="'Stop'"
|
||||
type="secondary"
|
||||
size="mini"
|
||||
@click="emit('stop')"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -28,7 +47,7 @@ const $style = useCssModule();
|
||||
padding: 0 var(--spacing-s) 0 var(--spacing-xs);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3xs);
|
||||
gap: var(--spacing-2xs);
|
||||
border-radius: 22px;
|
||||
border: 1px solid var(--prim-gray-740);
|
||||
background: rgba(65, 66, 68, 0.92);
|
||||
@@ -51,6 +70,9 @@ const $style = useCssModule();
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stopButton {
|
||||
margin-left: var(--spacing-xs);
|
||||
}
|
||||
.text {
|
||||
color: white;
|
||||
font-size: var(--font-size-s);
|
||||
|
||||
@@ -46,7 +46,8 @@ exports[`CanvasThinkingPill > renders canvas thinking pill correctly 1`] = `
|
||||
<span
|
||||
class="text"
|
||||
>
|
||||
Working...
|
||||
Working...
|
||||
<!--v-if-->
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -190,6 +190,7 @@
|
||||
"aiAssistant.builder.canvasPrompt.cancelButton": "Cancel",
|
||||
"aiAssistant.builder.canvasPrompt.startManually.title": "Start manually",
|
||||
"aiAssistant.builder.canvasPrompt.startManually.subTitle": "Add the first node",
|
||||
"aiAssistant.builder.streamAbortedMessage": "[Task aborted]",
|
||||
"aiAssistant.assistant": "AI Assistant",
|
||||
"aiAssistant.newSessionModal.title.part1": "Start new",
|
||||
"aiAssistant.newSessionModal.title.part2": "session",
|
||||
|
||||
@@ -219,6 +219,7 @@ export async function streamRequest<T extends object>(
|
||||
onDone?: () => void,
|
||||
onError?: (e: Error) => void,
|
||||
separator = STREAM_SEPERATOR,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const headers: Record<string, string> = {
|
||||
'browser-id': getBrowserId(),
|
||||
@@ -229,6 +230,7 @@ export async function streamRequest<T extends object>(
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(payload),
|
||||
signal: abortSignal,
|
||||
};
|
||||
try {
|
||||
const response = await fetch(`${context.baseUrl}${apiEndpoint}`, assistantRequest);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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') }}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user