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
This commit is contained in:
Andrew Beal 2025-10-12 13:33:25 +01:00
parent 8cc9441276
commit 9c45a05abc
5 changed files with 53 additions and 16 deletions

View file

@ -27,7 +27,7 @@ export class Gemini implements IAIClass {
this.apiKey = this.plugin.settings.apiKey;
}
public async* streamRequest(conversation: Conversation): AsyncGenerator<StreamChunk, void, unknown> {
public async* streamRequest(conversation: Conversation, abortSignal?: AbortSignal): AsyncGenerator<StreamChunk, void, unknown> {
// 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
);
}

View file

@ -2,5 +2,5 @@ import type { StreamChunk } from "Services/StreamingService";
import type { Conversation } from "Conversations/Conversation";
export interface IAIClass {
streamRequest(conversation: Conversation): AsyncGenerator<StreamChunk, void, unknown>;
streamRequest(conversation: Conversation, abortSignal?: AbortSignal): AsyncGenerator<StreamChunk, void, unknown>;
}

View file

@ -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 @@
<button
id="submit"
bind:this={submitButton}
on:click={() => { handleSubmit() }}
disabled={isSubmitting || userRequest.trim() === ""}
aria-label="Send Message">
on:click={() => { isSubmitting ? handleStop() : handleSubmit() }}
disabled={!isSubmitting && userRequest.trim() === ""}
aria-label={isSubmitting ? "Cancel" : "Send Message"}>
</button>
</div>
</main>

View file

@ -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<StreamChunk, void, unknown> {
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: '<span class="ai-request-cancelled">Request has been cancelled</span>',
};
} else {
console.error("Stream request error:", error);
yield {
content: "",
isComplete: true,
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
}
}

View file

@ -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 */
/* ============================== */