mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 06:42:03 +00:00
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:
parent
8cc9441276
commit
9c45a05abc
5 changed files with 53 additions and 16 deletions
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
10
styles.css
10
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 */
|
||||
/* ============================== */
|
||||
|
|
|
|||
Loading…
Reference in a new issue