From 9c45a05abc6ea76487a7248a74c66ac41e3d6545 Mon Sep 17 00:00:00 2001 From: Andrew Beal Date: Sun, 12 Oct 2025 13:33:25 +0100 Subject: [PATCH] feat: add abort controller to cancel streaming AI requests - Add AbortSignal parameter to streamRequest interface and implementations - Implement handleStop function to abort ongoing requests - Change submit button to stop button when streaming - Handle AbortError gracefully with user-friendly message - Add styling for cancellation notification --- AIClasses/Gemini/Gemini.ts | 5 +++-- AIClasses/IAIClass.ts | 2 +- Components/ChatWindow.svelte | 26 +++++++++++++++++++++----- Services/StreamingService.ts | 26 +++++++++++++++++++------- styles.css | 10 +++++++++- 5 files changed, 53 insertions(+), 16 deletions(-) diff --git a/AIClasses/Gemini/Gemini.ts b/AIClasses/Gemini/Gemini.ts index 89ea7b1..b3bab19 100644 --- a/AIClasses/Gemini/Gemini.ts +++ b/AIClasses/Gemini/Gemini.ts @@ -27,7 +27,7 @@ export class Gemini implements IAIClass { this.apiKey = this.plugin.settings.apiKey; } - public async* streamRequest(conversation: Conversation): AsyncGenerator { + public async* streamRequest(conversation: Conversation, abortSignal?: AbortSignal): AsyncGenerator { // next request should use web search only (gemini api doesn't support custom tooling and grounding at the same time) let requestWebSearch = this.accumulatedFunctionName == this.REQUEST_WEB_SEARCH; @@ -97,7 +97,8 @@ export class Gemini implements IAIClass { yield* this.streamingService.streamRequest( AIProviderURL.Gemini.replace("API_KEY", this.apiKey), requestBody, - this.parseStreamChunk.bind(this) + this.parseStreamChunk.bind(this), + abortSignal ); } diff --git a/AIClasses/IAIClass.ts b/AIClasses/IAIClass.ts index fdb2c45..5dd4a98 100644 --- a/AIClasses/IAIClass.ts +++ b/AIClasses/IAIClass.ts @@ -2,5 +2,5 @@ import type { StreamChunk } from "Services/StreamingService"; import type { Conversation } from "Conversations/Conversation"; export interface IAIClass { - streamRequest(conversation: Conversation): AsyncGenerator; + streamRequest(conversation: Conversation, abortSignal?: AbortSignal): AsyncGenerator; } \ No newline at end of file diff --git a/Components/ChatWindow.svelte b/Components/ChatWindow.svelte index b6f0d6c..ecb501b 100644 --- a/Components/ChatWindow.svelte +++ b/Components/ChatWindow.svelte @@ -37,6 +37,7 @@ let isSubmitting = false; let isStreaming = false; let currentStreamingMessageId: string | null = null; + let abortController: AbortController | null = null; let conversation = new Conversation(); @@ -83,6 +84,19 @@ return hasNoApiKey; } + function handleStop() { + if (abortController) { + abortController.abort(); + abortController = null; + } + currentThought = null; + isSubmitting = false; + semaphore.release(); + tick().then(() => { + chatArea.onFinishedSubmitting(); + }); + } + async function handleSubmit() { focusInput(); if (!await semaphore.wait()) { @@ -98,6 +112,7 @@ return; } isSubmitting = true; + abortController = new AbortController(); conversation.contents = [...conversation.contents, new ConversationContent(Role.User, userRequest)]; await conversationService.saveConversation(conversation); @@ -131,6 +146,7 @@ } finally { currentThought = null; isSubmitting = false; + abortController = null; semaphore.release(); tick().then(() => { chatArea.onFinishedSubmitting(); @@ -151,7 +167,7 @@ let capturedFunctionCall: AIFunctionCall | null = null; let capturedShouldContinue = false; - for await (const chunk of ai.streamRequest(conversation)) { + for await (const chunk of ai.streamRequest(conversation, abortController?.signal)) { if (chunk.error) { console.error("Streaming error:", chunk.error); conversation.contents = conversation.contents.map((msg) => @@ -238,7 +254,7 @@ } $: if (submitButton) { - setIcon(submitButton, 'send-horizontal'); + setIcon(submitButton, isSubmitting ? 'square' : 'send-horizontal'); } $: if ($conversationStore.shouldReset) { @@ -275,9 +291,9 @@ diff --git a/Services/StreamingService.ts b/Services/StreamingService.ts index 9b8b04c..cc39690 100644 --- a/Services/StreamingService.ts +++ b/Services/StreamingService.ts @@ -12,7 +12,8 @@ export class StreamingService { public async* streamRequest( url: string, requestBody: unknown, - parseStreamChunk: (chunk: string) => StreamChunk + parseStreamChunk: (chunk: string) => StreamChunk, + abortSignal?: AbortSignal ): AsyncGenerator { try { const response = await fetch( @@ -23,6 +24,7 @@ export class StreamingService { "Content-Type": "application/json", }, body: JSON.stringify(requestBody), + signal: abortSignal, } ); @@ -65,12 +67,22 @@ export class StreamingService { yield { content: "", isComplete: true }; } } catch (error) { - console.error("Stream request error:", error); - yield { - content: "", - isComplete: true, - error: error instanceof Error ? error.message : "Unknown error", - }; + // Don't log abort errors as they're intentional + if (error instanceof Error && error.name === 'AbortError') { + console.log("Stream request aborted by user"); + yield { + content: "", + isComplete: true, + error: 'Request has been cancelled', + }; + } else { + console.error("Stream request error:", error); + yield { + content: "", + isComplete: true, + error: error instanceof Error ? error.message : "Unknown error", + }; + } } } } \ No newline at end of file diff --git a/styles.css b/styles.css index 2c1dd08..f2adfea 100644 --- a/styles.css +++ b/styles.css @@ -59,7 +59,6 @@ /* CSS Variables for Common Components */ /* ============================== */ -/* does this affect all modals? */ .conversation-history-modal { padding: 0px; overflow: hidden; @@ -81,6 +80,15 @@ background-color: var(--color-base-35); } +.ai-request-cancelled { + color: var(--color-red); + border: var(--color-red); + border-style: solid; + border-width: var(--border-width); + border-radius: var(--radius-m); + padding: var(--size-2-3); +} + /* ============================== */ /* CSS Variables for Theming */ /* ============================== */