diff --git a/tools/server/webui/src/lib/clients/agentic.client.ts b/tools/server/webui/src/lib/clients/agentic.client.ts index 0f8db8818a..9de0be19f8 100644 --- a/tools/server/webui/src/lib/clients/agentic.client.ts +++ b/tools/server/webui/src/lib/clients/agentic.client.ts @@ -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, diff --git a/tools/server/webui/src/lib/clients/chat.client.ts b/tools/server/webui/src/lib/clients/chat.client.ts index 09e8ee7663..3113b847f0 100644 --- a/tools/server/webui/src/lib/clients/chat.client.ts +++ b/tools/server/webui/src/lib/clients/chat.client.ts @@ -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 { + ): Promise { 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 { + private async createAssistantMessage(parentId?: string): Promise { 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; diff --git a/tools/server/webui/src/lib/services/chat.service.ts b/tools/server/webui/src/lib/services/chat.service.ts index 9696beffb4..ee575c42cf 100644 --- a/tools/server/webui/src/lib/services/chat.service.ts +++ b/tools/server/webui/src/lib/services/chat.service.ts @@ -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; } diff --git a/tools/server/webui/src/lib/services/mcp.service.ts b/tools/server/webui/src/lib/services/mcp.service.ts index 73ae01772d..2769364ad0 100644 --- a/tools/server/webui/src/lib/services/mcp.service.ts +++ b/tools/server/webui/src/lib/services/mcp.service.ts @@ -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 { - 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; } diff --git a/tools/server/webui/src/lib/stores/chat.svelte.ts b/tools/server/webui/src/lib/stores/chat.svelte.ts index 09852e5f60..08d133030b 100644 --- a/tools/server/webui/src/lib/stores/chat.svelte.ts +++ b/tools/server/webui/src/lib/stores/chat.svelte.ts @@ -395,7 +395,7 @@ class ChatStore { type: MessageType = MessageType.TEXT, parent: string = '-1', extras?: DatabaseMessageExtra[] - ): Promise { + ): Promise { return chatClient.addMessage(role, content, type, parent, extras); } diff --git a/tools/server/webui/src/lib/utils/abort.ts b/tools/server/webui/src/lib/utils/abort.ts new file mode 100644 index 0000000000..fc4f31ec69 --- /dev/null +++ b/tools/server/webui/src/lib/utils/abort.ts @@ -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(promise: Promise, signal?: AbortSignal): Promise { + if (!signal) return promise; + + throwIfAborted(signal); + + return new Promise((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); + }); + }); +} diff --git a/tools/server/webui/src/lib/utils/index.ts b/tools/server/webui/src/lib/utils/index.ts index 6bdf35ba2b..1e32dc71ed 100644 --- a/tools/server/webui/src/lib/utils/index.ts +++ b/tools/server/webui/src/lib/utils/index.ts @@ -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';