refactor: Improves abort signal handling
This commit is contained in:
parent
55e73cdde8
commit
f40b377e34
|
|
@ -55,6 +55,7 @@ import type {
|
|||
DatabaseMessageExtraImageFile
|
||||
} from '$lib/types/database';
|
||||
import { AttachmentType, MessageRole } from '$lib/enums';
|
||||
import { isAbortError } from '$lib/utils';
|
||||
|
||||
/**
|
||||
* Converts API messages to agentic format.
|
||||
|
|
@ -507,7 +508,7 @@ export class AgenticClient {
|
|||
const executionResult = await mcpClient.executeTool(mcpCall, signal);
|
||||
result = executionResult.content;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
if (isAbortError(error)) {
|
||||
onComplete?.(
|
||||
'',
|
||||
undefined,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ import {
|
|||
normalizeModelName,
|
||||
filterByLeafNodeId,
|
||||
findDescendantMessages,
|
||||
findLeafNode
|
||||
findLeafNode,
|
||||
isAbortError
|
||||
} from '$lib/utils';
|
||||
import { agenticStore } from '$lib/stores/agentic.svelte';
|
||||
import { DEFAULT_CONTEXT } from '$lib/constants/default-context';
|
||||
|
|
@ -132,7 +133,8 @@ export class ChatClient {
|
|||
* @param type - Message type (text or root)
|
||||
* @param parent - Parent message ID, or '-1' to append to conversation end
|
||||
* @param extras - Optional attachments (images, files, etc.)
|
||||
* @returns The created message or null if failed
|
||||
* @returns The created message
|
||||
* @throws Error if no active conversation or database operation fails
|
||||
*/
|
||||
async addMessage(
|
||||
role: MessageRole,
|
||||
|
|
@ -140,56 +142,50 @@ export class ChatClient {
|
|||
type: MessageType = MessageType.TEXT,
|
||||
parent: string = '-1',
|
||||
extras?: DatabaseMessageExtra[]
|
||||
): Promise<DatabaseMessage | null> {
|
||||
): Promise<DatabaseMessage> {
|
||||
const activeConv = conversationsStore.activeConversation;
|
||||
if (!activeConv) {
|
||||
console.error('No active conversation when trying to add message');
|
||||
return null;
|
||||
throw new Error('No active conversation when trying to add message');
|
||||
}
|
||||
|
||||
try {
|
||||
let parentId: string | null = null;
|
||||
let parentId: string | null = null;
|
||||
|
||||
if (parent === '-1') {
|
||||
const activeMessages = conversationsStore.activeMessages;
|
||||
if (activeMessages.length > 0) {
|
||||
parentId = activeMessages[activeMessages.length - 1].id;
|
||||
} else {
|
||||
const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
|
||||
const rootMessage = allMessages.find((m) => m.parent === null && m.type === 'root');
|
||||
if (!rootMessage) {
|
||||
parentId = await DatabaseService.createRootMessage(activeConv.id);
|
||||
} else {
|
||||
parentId = rootMessage.id;
|
||||
}
|
||||
}
|
||||
if (parent === '-1') {
|
||||
const activeMessages = conversationsStore.activeMessages;
|
||||
if (activeMessages.length > 0) {
|
||||
parentId = activeMessages[activeMessages.length - 1].id;
|
||||
} else {
|
||||
parentId = parent;
|
||||
const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
|
||||
const rootMessage = allMessages.find((m) => m.parent === null && m.type === 'root');
|
||||
if (!rootMessage) {
|
||||
parentId = await DatabaseService.createRootMessage(activeConv.id);
|
||||
} else {
|
||||
parentId = rootMessage.id;
|
||||
}
|
||||
}
|
||||
|
||||
const message = await DatabaseService.createMessageBranch(
|
||||
{
|
||||
convId: activeConv.id,
|
||||
role,
|
||||
content,
|
||||
type,
|
||||
timestamp: Date.now(),
|
||||
toolCalls: '',
|
||||
children: [],
|
||||
extra: extras
|
||||
},
|
||||
parentId
|
||||
);
|
||||
|
||||
conversationsStore.addMessageToActive(message);
|
||||
await conversationsStore.updateCurrentNode(message.id);
|
||||
conversationsStore.updateConversationTimestamp();
|
||||
|
||||
return message;
|
||||
} catch (error) {
|
||||
console.error('Failed to add message:', error);
|
||||
return null;
|
||||
} else {
|
||||
parentId = parent;
|
||||
}
|
||||
|
||||
const message = await DatabaseService.createMessageBranch(
|
||||
{
|
||||
convId: activeConv.id,
|
||||
role,
|
||||
content,
|
||||
type,
|
||||
timestamp: Date.now(),
|
||||
toolCalls: '',
|
||||
children: [],
|
||||
extra: extras
|
||||
},
|
||||
parentId
|
||||
);
|
||||
|
||||
conversationsStore.addMessageToActive(message);
|
||||
await conversationsStore.updateCurrentNode(message.id);
|
||||
conversationsStore.updateConversationTimestamp();
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -334,9 +330,11 @@ export class ChatClient {
|
|||
*
|
||||
*/
|
||||
|
||||
private async createAssistantMessage(parentId?: string): Promise<DatabaseMessage | null> {
|
||||
private async createAssistantMessage(parentId?: string): Promise<DatabaseMessage> {
|
||||
const activeConv = conversationsStore.activeConversation;
|
||||
if (!activeConv) return null;
|
||||
if (!activeConv) {
|
||||
throw new Error('No active conversation when creating assistant message');
|
||||
}
|
||||
|
||||
return await DatabaseService.createMessageBranch(
|
||||
{
|
||||
|
|
@ -406,12 +404,10 @@ export class ChatClient {
|
|||
parentIdForUserMessage ?? '-1',
|
||||
extras
|
||||
);
|
||||
if (!userMessage) throw new Error('Failed to add user message');
|
||||
if (isNewConversation && content)
|
||||
await conversationsStore.updateConversationName(currentConv.id, content.trim());
|
||||
|
||||
const assistantMessage = await this.createAssistantMessage(userMessage.id);
|
||||
if (!assistantMessage) throw new Error('Failed to create assistant message');
|
||||
|
||||
conversationsStore.addMessageToActive(assistantMessage);
|
||||
await this.streamChatCompletion(
|
||||
|
|
@ -419,7 +415,7 @@ export class ChatClient {
|
|||
assistantMessage
|
||||
);
|
||||
} catch (error) {
|
||||
if (this.isAbortError(error)) {
|
||||
if (isAbortError(error)) {
|
||||
this.store.setChatLoading(currentConv.id, false);
|
||||
return;
|
||||
}
|
||||
|
|
@ -611,7 +607,7 @@ export class ChatClient {
|
|||
onError: (error: Error) => {
|
||||
this.store.setStreamingActive(false);
|
||||
|
||||
if (this.isAbortError(error)) {
|
||||
if (isAbortError(error)) {
|
||||
this.store.setChatLoading(assistantMessage.convId, false);
|
||||
this.store.clearChatStreaming(assistantMessage.convId);
|
||||
this.store.setProcessingState(assistantMessage.convId, null);
|
||||
|
|
@ -806,7 +802,6 @@ export class ChatClient {
|
|||
this.store.clearChatStreaming(activeConv.id);
|
||||
|
||||
const assistantMessage = await this.createAssistantMessage();
|
||||
if (!assistantMessage) throw new Error('Failed to create assistant message');
|
||||
|
||||
conversationsStore.addMessageToActive(assistantMessage);
|
||||
|
||||
|
|
@ -822,7 +817,7 @@ export class ChatClient {
|
|||
}
|
||||
);
|
||||
} catch (error) {
|
||||
if (!this.isAbortError(error)) console.error('Failed to update message:', error);
|
||||
if (!isAbortError(error)) console.error('Failed to update message:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -861,14 +856,13 @@ export class ChatClient {
|
|||
? conversationsStore.activeMessages[conversationsStore.activeMessages.length - 1].id
|
||||
: undefined;
|
||||
const assistantMessage = await this.createAssistantMessage(parentMessageId);
|
||||
if (!assistantMessage) throw new Error('Failed to create assistant message');
|
||||
conversationsStore.addMessageToActive(assistantMessage);
|
||||
await this.streamChatCompletion(
|
||||
conversationsStore.activeMessages.slice(0, -1),
|
||||
assistantMessage
|
||||
);
|
||||
} catch (error) {
|
||||
if (!this.isAbortError(error)) console.error('Failed to regenerate message:', error);
|
||||
if (!isAbortError(error)) console.error('Failed to regenerate message:', error);
|
||||
this.store.setChatLoading(activeConv?.id || '', false);
|
||||
}
|
||||
}
|
||||
|
|
@ -926,7 +920,7 @@ export class ChatClient {
|
|||
modelToUse
|
||||
);
|
||||
} catch (error) {
|
||||
if (!this.isAbortError(error))
|
||||
if (!isAbortError(error))
|
||||
console.error('Failed to regenerate message with branching:', error);
|
||||
this.store.setChatLoading(activeConv?.id || '', false);
|
||||
}
|
||||
|
|
@ -1153,7 +1147,7 @@ export class ChatClient {
|
|||
},
|
||||
|
||||
onError: async (error: Error) => {
|
||||
if (this.isAbortError(error)) {
|
||||
if (isAbortError(error)) {
|
||||
if (hasReceivedContent && appendedContent) {
|
||||
await DatabaseService.updateMessage(msg.id, {
|
||||
content: originalContent + appendedContent,
|
||||
|
|
@ -1189,7 +1183,7 @@ export class ChatClient {
|
|||
abortController.signal
|
||||
);
|
||||
} catch (error) {
|
||||
if (!this.isAbortError(error)) console.error('Failed to continue message:', error);
|
||||
if (!isAbortError(error)) console.error('Failed to continue message:', error);
|
||||
if (activeConv) this.store.setChatLoading(activeConv.id, false);
|
||||
}
|
||||
}
|
||||
|
|
@ -1564,10 +1558,6 @@ export class ChatClient {
|
|||
*
|
||||
*/
|
||||
|
||||
private isAbortError(error: unknown): boolean {
|
||||
return error instanceof Error && (error.name === 'AbortError' || error instanceof DOMException);
|
||||
}
|
||||
|
||||
private isChatLoading(convId: string): boolean {
|
||||
const streamingState = this.store.getChatStreaming(convId);
|
||||
return streamingState !== undefined;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { getJsonHeaders, formatAttachmentText } from '$lib/utils';
|
||||
import { getJsonHeaders, formatAttachmentText, isAbortError } from '$lib/utils';
|
||||
import { AGENTIC_REGEX } from '$lib/constants/agentic';
|
||||
import { AttachmentType, MessageRole, ReasoningFormat } from '$lib/enums';
|
||||
import type { ApiChatMessageContentPart } from '$lib/types/api';
|
||||
|
|
@ -257,7 +257,7 @@ export class ChatService {
|
|||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
if (isAbortError(error)) {
|
||||
console.log('Chat completion request was aborted');
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import type {
|
|||
} from '$lib/types';
|
||||
import { MCPConnectionPhase, MCPLogLevel, MCPTransportType } from '$lib/enums';
|
||||
import { DEFAULT_MCP_CONFIG } from '$lib/constants/mcp';
|
||||
import { throwIfAborted, isAbortError } from '$lib/utils';
|
||||
|
||||
interface ToolResultContentItem {
|
||||
type: string;
|
||||
|
|
@ -341,9 +342,7 @@ export class MCPService {
|
|||
params: ToolCallParams,
|
||||
signal?: AbortSignal
|
||||
): Promise<ToolExecutionResult> {
|
||||
if (signal?.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
throwIfAborted(signal);
|
||||
|
||||
try {
|
||||
const result = await connection.client.callTool(
|
||||
|
|
@ -357,7 +356,7 @@ export class MCPService {
|
|||
isError: (result as ToolCallResult).isError ?? false
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
if (isAbortError(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -395,7 +395,7 @@ class ChatStore {
|
|||
type: MessageType = MessageType.TEXT,
|
||||
parent: string = '-1',
|
||||
extras?: DatabaseMessageExtra[]
|
||||
): Promise<DatabaseMessage | null> {
|
||||
): Promise<DatabaseMessage> {
|
||||
return chatClient.addMessage(role, content, type, parent, extras);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,151 @@
|
|||
/**
|
||||
* Abort Signal Utilities
|
||||
*
|
||||
* Provides utilities for consistent AbortSignal propagation across the application.
|
||||
* These utilities help ensure that async operations can be properly cancelled
|
||||
* when needed (e.g., user stops generation, navigates away, etc.).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Throws an AbortError if the signal is aborted.
|
||||
* Use this at the start of async operations to fail fast.
|
||||
*
|
||||
* @param signal - Optional AbortSignal to check
|
||||
* @throws DOMException with name 'AbortError' if signal is aborted
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* async function fetchData(signal?: AbortSignal) {
|
||||
* throwIfAborted(signal);
|
||||
* // ... proceed with operation
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function throwIfAborted(signal?: AbortSignal): void {
|
||||
if (signal?.aborted) {
|
||||
throw new DOMException('Operation was aborted', 'AbortError');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an error is an AbortError.
|
||||
* Use this to distinguish between user-initiated cancellation and actual errors.
|
||||
*
|
||||
* @param error - Error to check
|
||||
* @returns true if the error is an AbortError
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* try {
|
||||
* await fetchData(signal);
|
||||
* } catch (error) {
|
||||
* if (isAbortError(error)) {
|
||||
* // User cancelled - no error dialog needed
|
||||
* return;
|
||||
* }
|
||||
* // Handle actual error
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function isAbortError(error: unknown): boolean {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
return true;
|
||||
}
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new AbortController that is linked to one or more parent signals.
|
||||
* When any parent signal aborts, the returned controller also aborts.
|
||||
*
|
||||
* Useful for creating child operations that should be cancelled when
|
||||
* either the parent operation or their own timeout/condition triggers.
|
||||
*
|
||||
* @param signals - Parent signals to link to (undefined signals are ignored)
|
||||
* @returns A new AbortController linked to all provided signals
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Link to user's abort signal and add a timeout
|
||||
* const linked = createLinkedController(userSignal, timeoutSignal);
|
||||
* await fetch(url, { signal: linked.signal });
|
||||
* ```
|
||||
*/
|
||||
export function createLinkedController(...signals: (AbortSignal | undefined)[]): AbortController {
|
||||
const controller = new AbortController();
|
||||
|
||||
for (const signal of signals) {
|
||||
if (!signal) continue;
|
||||
|
||||
// If already aborted, abort immediately
|
||||
if (signal.aborted) {
|
||||
controller.abort(signal.reason);
|
||||
return controller;
|
||||
}
|
||||
|
||||
// Link to parent signal
|
||||
signal.addEventListener('abort', () => controller.abort(signal.reason), { once: true });
|
||||
}
|
||||
|
||||
return controller;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an AbortSignal that times out after the specified duration.
|
||||
*
|
||||
* @param ms - Timeout duration in milliseconds
|
||||
* @returns AbortSignal that will abort after the timeout
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const signal = createTimeoutSignal(5000); // 5 second timeout
|
||||
* await fetch(url, { signal });
|
||||
* ```
|
||||
*/
|
||||
export function createTimeoutSignal(ms: number): AbortSignal {
|
||||
return AbortSignal.timeout(ms);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a promise to reject if the signal is aborted.
|
||||
* Useful for making non-abortable promises respect an AbortSignal.
|
||||
*
|
||||
* @param promise - Promise to wrap
|
||||
* @param signal - AbortSignal to respect
|
||||
* @returns Promise that rejects with AbortError if signal aborts
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Make a non-abortable operation respect abort signal
|
||||
* const result = await withAbortSignal(
|
||||
* someNonAbortableOperation(),
|
||||
* signal
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export async function withAbortSignal<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||
if (!signal) return promise;
|
||||
|
||||
throwIfAborted(signal);
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const abortHandler = () => {
|
||||
reject(new DOMException('Operation was aborted', 'AbortError'));
|
||||
};
|
||||
|
||||
signal.addEventListener('abort', abortHandler, { once: true });
|
||||
|
||||
promise
|
||||
.then((value) => {
|
||||
signal.removeEventListener('abort', abortHandler);
|
||||
resolve(value);
|
||||
})
|
||||
.catch((error) => {
|
||||
signal.removeEventListener('abort', abortHandler);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -121,3 +121,12 @@ export { parseAgenticContent, type AgenticSection } from './agentic';
|
|||
|
||||
// Cache utilities
|
||||
export { TTLCache, ReactiveTTLMap, type TTLCacheOptions } from './cache-ttl';
|
||||
|
||||
// Abort signal utilities
|
||||
export {
|
||||
throwIfAborted,
|
||||
isAbortError,
|
||||
createLinkedController,
|
||||
createTimeoutSignal,
|
||||
withAbortSignal
|
||||
} from './abort';
|
||||
|
|
|
|||
Loading…
Reference in New Issue