diff --git a/AIClasses/Claude/Claude.ts b/AIClasses/Claude/Claude.ts index 62ff20e..f63ae2a 100644 --- a/AIClasses/Claude/Claude.ts +++ b/AIClasses/Claude/Claude.ts @@ -15,6 +15,8 @@ import type { SettingsService } from "Services/SettingsService"; import type { RawMessageStreamEvent, ContentBlockParam, Tool } from '@anthropic-ai/sdk/resources/messages'; import type { StoredFunctionCall, StoredFunctionResponse } from "AIClasses/Schemas/AIFunctionTypes"; import { StringTools } from "Helpers/StringTools"; +import { Exception } from "Helpers/Exception"; +import { ApiErrorType } from "Types/ApiError"; export class Claude implements IAIClass { @@ -124,7 +126,7 @@ export class Claude implements IAIClass { this.accumulatedFunctionId || undefined ); } catch (error) { - console.error("Failed to parse accumulated function args:", error); + Exception.log(error); } // Reset accumulation for next potential tool use this.accumulatedFunctionName = null; @@ -155,9 +157,13 @@ export class Claude implements IAIClass { shouldContinue: shouldContinue, }; } catch (error) { - const message = error instanceof Error ? error.message : "Unknown parsing error"; - console.error("Failed to parse stream chunk:", message, "Chunk:", chunk); - return { content: "", isComplete: false, error: `Failed to parse chunk: ${message}` }; + Exception.log(error); + return { + content: "", + isComplete: true, + error: `Failed to parse chunk: ${Exception.messageFrom(error)}`, + errorType: ApiErrorType.UNKNOWN + }; } } @@ -194,7 +200,7 @@ export class Claude implements IAIClass { }); } } catch (error) { - console.error("Failed to parse function call:", error); + Exception.log(error); // Fall back to treating as text if (contentToExtract.trim() === "") { contentBlocks.push({ @@ -204,7 +210,7 @@ export class Claude implements IAIClass { } } } else { - console.error("Invalid JSON in functionCall field"); + Exception.log(`Invalid JSON in functionCall field:\n${content.functionCall}`); // Fall back to treating as text if (contentToExtract.trim() === "") { contentBlocks.push({ @@ -234,14 +240,14 @@ export class Claude implements IAIClass { }); } } catch (error) { - console.error("Failed to parse function response:", error); + Exception.log(error); contentBlocks.push({ type: "text", text: contentToExtract }); } } else { - console.error("Invalid JSON in function response content"); + Exception.log(`Invalid JSON in function response content:\n${contentToExtract}`); contentBlocks.push({ type: "text", text: contentToExtract diff --git a/AIClasses/Gemini/Gemini.ts b/AIClasses/Gemini/Gemini.ts index 5d4094c..f6b9110 100644 --- a/AIClasses/Gemini/Gemini.ts +++ b/AIClasses/Gemini/Gemini.ts @@ -16,6 +16,8 @@ import type { StoredFunctionCall, StoredFunctionResponse } from "AIClasses/Schem import type { Candidate, Part, FunctionDeclaration } from "@google/genai"; import { FinishReason } from "@google/genai"; import { StringTools } from "Helpers/StringTools"; +import { Exception } from "Helpers/Exception"; +import { ApiErrorType } from "Types/ApiError"; export class Gemini implements IAIClass { @@ -150,9 +152,13 @@ export class Gemini implements IAIClass { shouldContinue: shouldContinue, }; } catch (error) { - const message = error instanceof Error ? error.message : "Unknown parsing error"; - console.error("Failed to parse stream chunk:", message, "Chunk:", chunk); - return { content: "", isComplete: false, error: `Failed to parse chunk: ${message}` }; + Exception.log(error); + return { + content: "", + isComplete: true, + error: `Failed to parse chunk: ${Exception.messageFrom(error)}`, + errorType: ApiErrorType.UNKNOWN + }; } } @@ -178,11 +184,11 @@ export class Gemini implements IAIClass { parts.push({ text: contentToExtract }); } } catch (error) { - console.error("Failed to parse function response:", error); + Exception.log(error); parts.push({ text: contentToExtract }); } } else { - console.error("Invalid JSON in function response content"); + Exception.log(`Invalid JSON in function response content:\n${contentToExtract}`) parts.push({ text: contentToExtract }); } } else { @@ -203,10 +209,10 @@ export class Gemini implements IAIClass { }); } } catch (error) { - console.error("Failed to parse function call:", error); + Exception.log(error); } } else { - console.error("Invalid JSON in functionCall field"); + Exception.log(`Invalid JSON in functionCall field:\n${content.functionCall}`); } } diff --git a/AIClasses/OpenAI/OpenAI.ts b/AIClasses/OpenAI/OpenAI.ts index 5b7e36a..5e7611d 100644 --- a/AIClasses/OpenAI/OpenAI.ts +++ b/AIClasses/OpenAI/OpenAI.ts @@ -16,6 +16,7 @@ import type { StoredFunctionCall, StoredFunctionResponse } from "AIClasses/Schem import { StringTools } from "Helpers/StringTools"; import type { ResponseEvent, ResponseOutputTextDelta, ResponseFunctionCallArgumentsDone, ResponseDone, OpenAIFunctionTool } from "./OpenAITypes"; import { Exception } from "Helpers/Exception"; +import { ApiErrorType } from "Types/ApiError"; export class OpenAI implements IAIClass { @@ -170,7 +171,12 @@ export class OpenAI implements IAIClass { }; } catch (error) { Exception.log(error); - return { content: "", isComplete: false, error: Exception.messageFrom(error) }; + return { + content: "", + isComplete: true, + error: `Failed to parse chunk: ${Exception.messageFrom(error)}`, + errorType: ApiErrorType.UNKNOWN + }; } } @@ -206,7 +212,7 @@ export class OpenAI implements IAIClass { }; } } else { - Exception.log("Invalid JSON in functionCall field"); + Exception.log(`Invalid JSON in functionCall field:\n${content.functionCall}`); return { role: content.role, content: contentToExtract.trim() !== "" ? contentToExtract : "Error parsing function call" @@ -232,7 +238,7 @@ export class OpenAI implements IAIClass { }; } } else { - Exception.log("Invalid JSON in function response content"); + Exception.log(`Invalid JSON in function response content:\n${contentToExtract}`); return { role: content.role, content: contentToExtract diff --git a/AIClasses/SystemPrompt.ts b/AIClasses/SystemPrompt.ts index 9a92bb1..1cced48 100644 --- a/AIClasses/SystemPrompt.ts +++ b/AIClasses/SystemPrompt.ts @@ -5,12 +5,35 @@ You are a specialized AI assistant with direct access to the user's Obsidian vau ## Critical Operating Principles -### 1. Request Completion +### 1. ACTION-FIRST OPERATING PRINCIPLE + +**Execute user intent directly. Do not describe, offer, or explain before acting.** + +When users issue directives, their instruction IS your authorization. Respond by: +- ✅ **IMMEDIATELY invoking the appropriate function/tool** +- ❌ **NOT providing output as text with an offer to "save it"** +- ❌ **NOT showing content first, then asking permission to proceed** + +**Core Behavior:** +- User requests are commands, not proposals +- Tool availability implies intended use +- Function calls are your primary response mechanism +- Explanations follow execution, not precede it + +**Recognition Patterns:** +Interpret action-oriented language as direct instructions: +- Task verbs (create, generate, update, delete, send, fetch) → Execute corresponding function +- Implied actions ("I need X") → Call the function that produces X +- Outcome requests ("Show me Y") → Use tools to retrieve/generate Y + +**Your role is to act, not to ask.** When functions exist to fulfill user intent, use them immediately and seamlessly. + +### 2. Request Completion - Execute ALL necessary operations before concluding your turn - Ensure the user's complete request is fulfilled, not just the first step - For multi-step tasks, gather all information before presenting findings -### 2. Wiki-Link Everything from the Vault +### 3. Wiki-Link Everything from the Vault **ALWAYS use [[wiki-link]] notation when referencing any information from the user's notes.** - Every mention of a note, concept, person, or topic from the vault must be linked - This builds the knowledge graph and helps users navigate their information @@ -22,7 +45,7 @@ Examples: - "[[Sarah]] mentioned this in her meeting with [[John]]" - "This relates to your ideas about [[Machine Learning]] in [[Research Notes]]" -### 3. Vault-First Decision Framework +### 4. Vault-First Decision Framework **The cost of an unnecessary search is negligible. Missing relevant information is costly.** @@ -31,14 +54,14 @@ Regex is your most versatile search capability. Use it aggressively and creative - **Case-insensitive partial matching**: \`/maui/i\` finds MAUI, Maui, maui anywhere -- **Word boundary patterns**: \`/\bmaui\b/i\` matches whole words only +- **Word boundary patterns**: \`/\\bmaui\\b/i\` matches whole words only - **Prefix/suffix patterns**: \`/maui.*dev/i\` or \`/.*mobile.*app.*/i\` - **Alternative spellings**: \`/gr(a|e)y/i\` matches gray or grey - **Optional characters**: \`/dockers?/i\` matches docker or dockers - **Wildcard sequences**: \`/proj.*alpha/i\` matches "project alpha", "proj_alpha", etc. - **Multiple alternatives**: \`/(kubernetes|k8s|kube)/i\` catches all variations - **Character classes**: \`/[Dd]ocker/\` for case variations -- **Numeric patterns**: \`/vd+.d+/\` for version numbers like v1.2 +- **Numeric patterns**: \`/v\\d+\\.\\d+/\` for version numbers like v1.2 When to deploy regex (use frequently): @@ -55,6 +78,7 @@ Example workflow: - Search "Project Alpha" fails → Try \`/proj.*alpha/i\` for flexible matching #### IMMEDIATE VAULT SEARCH Required When: +- Query contains references to individuals who are not commonly known ("for Elika", "in the style of James") - Query contains definite articles suggesting specific reference ("the project", "the prices", "the data") - Query uses possessive pronouns ("my ideas", "our plans", "my notes about") - Query references potentially documented information (projects, data, decisions, meetings, research) @@ -72,7 +96,7 @@ Example workflow: Acknowledge the search, then provide general assistance: "I searched your vault but didn't find notes about [topic]. Here's what I can tell you: [general information]. Would you like me to create a note about this?" -### 4. Progressive Search Strategy +### 5. Progressive Search Strategy **NEVER accept a failed search as final. Always try multiple approaches before concluding information doesn't exist.** @@ -198,6 +222,8 @@ Integrate all findings into cohesive response: ❌ Giving up after first failed search attempt ❌ Searching exact literal phrases instead of extracting key entities ❌ Asking permission before searching ("Would you like me to search?") +❌ Asking "Would you like me to create this?" when user already said to create it +❌ Showing what a file would contain instead of actually creating it ❌ Providing incremental progress updates instead of complete results ❌ Missing obvious relationship inferences from found content ❌ Listing all matches when query had directory qualifiers @@ -207,12 +233,13 @@ Integrate all findings into cohesive response: ## Decision Framework **Always ask yourself:** -1. **"Am I using [[wiki-links]] for every vault reference?"** → Always required -2. **"Could this information exist in the user's notes?"** → Search vault first -3. **"Did my first search fail? Have I tried all progressive tiers?"** → Keep searching -4. **"Can I infer the answer from related content I found?"** → Read and reason about relationships -5. **"Does this query need multiple search approaches?"** → Scale to complexity -6. **"Should I suggest additional related notes?"** → Offer connections when helpful +1. **"Have I completed the users request?"** → Reflect on what can still be achieved +2. **"Am I using [[wiki-links]] for every vault reference?"** → Always required +3. **"Could this information exist in the user's notes?"** → Search vault first +4. **"Did my first search fail? Have I tried all progressive tiers?"** → Keep searching +5. **"Can I infer the answer from related content I found?"** → Read and reason about relationships +6. **"Does this query need multiple search approaches?"** → Scale to complexity +7. **"Should I suggest additional related notes?"** → Offer connections when helpful **When uncertain**: Always search the vault first. When search fails, always try alternative strategies before concluding "not found." @@ -265,5 +292,5 @@ Process: --- -**Core Philosophy**: Always use [[wiki-links]] for vault references to build the knowledge graph. Be proactive with vault searches using progressive multi-tier strategies—never give up after the first attempt. Respect the semantic meaning of the user's organizational structure. Infer relationships from context rather than requiring explicit statements. Scale your search complexity to match the query. Always complete the full request before concluding. +**Core Philosophy**: Use available tools to support assisting the user. Always use [[wiki-links]] for vault references to build the knowledge graph. Be proactive with vault searches using progressive multi-tier strategies—never give up after the first attempt. Respect the semantic meaning of the user's organizational structure. Infer relationships from context rather than requiring explicit statements. Scale your search complexity to match the query. Always complete the full request before concluding. `; \ No newline at end of file diff --git a/Components/ChatArea.svelte b/Components/ChatArea.svelte index 8bdf10a..849c465 100644 --- a/Components/ChatArea.svelte +++ b/Components/ChatArea.svelte @@ -120,9 +120,13 @@ // For assistant messages that aren't streaming, use traditional parsing if (!isCurrentlyStreaming) { - // Check if this is a cancelled request message + if (message.content.includes(Selector.ApiRequestAborted)) { - return `${Copy.ApiRequestAborted}`; + return `${Copy.ApiRequestAborted}`; + } + + if (message.errorType) { + return `
${message.content}
`; } try { diff --git a/Conversations/Conversation.ts b/Conversations/Conversation.ts index 7d6c372..442a8b1 100644 --- a/Conversations/Conversation.ts +++ b/Conversations/Conversation.ts @@ -1,5 +1,6 @@ import { StringTools } from "Helpers/StringTools"; import { ConversationContent } from "./ConversationContent"; +import { ApiErrorType } from "Types/ApiError"; export class Conversation { @@ -36,6 +37,15 @@ export class Conversation { const conversationContent: ConversationContent | undefined = this.contents[this.contents.length - 1]; if (conversationContent) { conversationContent.content = content; + conversationContent.errorType = undefined; + } + } + + public setMostRecentError(content: string, errorType: ApiErrorType) { + const conversationContent: ConversationContent | undefined = this.contents[this.contents.length - 1]; + if (conversationContent) { + conversationContent.content = content; + conversationContent.errorType = errorType; } } diff --git a/Conversations/ConversationContent.ts b/Conversations/ConversationContent.ts index 650c4f5..af92a1e 100644 --- a/Conversations/ConversationContent.ts +++ b/Conversations/ConversationContent.ts @@ -1,4 +1,5 @@ import { Role } from "Enums/Role"; +import { ApiErrorType } from "Types/ApiError"; export class ConversationContent { role: Role; @@ -9,8 +10,9 @@ export class ConversationContent { isFunctionCall: boolean; isFunctionCallResponse: boolean; toolId?: string; + errorType?: ApiErrorType; - constructor(role: Role, content: string = "", promptContent: string = "", functionCall: string = "", timestamp: Date = new Date(), isFunctionCall = false, isFunctionCallResponse = false, toolId?: string) { + constructor(role: Role, content: string = "", promptContent: string = "", functionCall: string = "", timestamp: Date = new Date(), isFunctionCall = false, isFunctionCallResponse = false, toolId?: string, errorType?: ApiErrorType) { this.role = role; this.content = content; this.promptContent = promptContent; @@ -19,10 +21,11 @@ export class ConversationContent { this.isFunctionCall = isFunctionCall; this.isFunctionCallResponse = isFunctionCallResponse; this.toolId = toolId; + this.errorType = errorType; } public static isConversationContentData(this: void, data: unknown): data is { - role: string; content: string; promptContent: string; functionCall: string; timestamp: string, isFunctionCall: boolean, isFunctionCallResponse: boolean, toolId?: string + role: string; content: string; promptContent: string; functionCall: string; timestamp: string, isFunctionCall: boolean, isFunctionCallResponse: boolean, toolId?: string, errorType?: string } { return ( data !== null && @@ -34,13 +37,15 @@ export class ConversationContent { "timestamp" in data && "isFunctionCall" in data && "isFunctionCallResponse" in data && + "errorType" in data && typeof data.role === "string" && typeof data.content === "string" && typeof data.promptContent === "string" && typeof data.functionCall === "string" && typeof data.timestamp === "string" && typeof data.isFunctionCall === "boolean" && - typeof data.isFunctionCallResponse === "boolean" + typeof data.isFunctionCallResponse === "boolean" && + (typeof data.errorType === "string" || data.errorType === undefined) ); } } \ No newline at end of file diff --git a/Enums/Selector.ts b/Enums/Selector.ts index 815ff04..d6ed2c3 100644 --- a/Enums/Selector.ts +++ b/Enums/Selector.ts @@ -3,8 +3,16 @@ export enum Selector { AIExclusionsInput = "ai-exclusions-input", ApiKeySettingOk = "api-key-setting-ok", ApiKeySettingError = "api-key-setting-error", - ApiRequestAborted = "api-request-aborted", ConversationHistoryModal = "conversation-history-modal", HelpModal = "help-modal", - ContextSettingItemDescription = "context-setting-item-description" + ContextSettingItemDescription = "context-setting-item-description", + + ApiRequestAborted = "api-request-aborted", + APIRequestError = "api-request-error", + + ErrorSelector = "error-selector" +} + +export function isErrorSelector(selector: Selector) { + return selector === Selector.ApiRequestAborted || selector === Selector.APIRequestError; } \ No newline at end of file diff --git a/Services/ChatService.ts b/Services/ChatService.ts index 9c1a66e..e9fc4f0 100644 --- a/Services/ChatService.ts +++ b/Services/ChatService.ts @@ -187,9 +187,8 @@ export class ChatService { let capturedShouldContinue = false; for await (const chunk of this.ai.streamRequest(conversation, allowDestructiveActions, this.abortController?.signal)) { - if (chunk.error) { - console.error("Streaming error:", chunk.error); - conversation.setMostRecentContent(`Error: ${chunk.error}`); + if (chunk.error && chunk.errorType) { + conversation.setMostRecentError(chunk.error, chunk.errorType); callbacks.onStreamingUpdate(aiMessage.timestamp.getTime().toString()); break; } diff --git a/Services/ConversationFileSystemService.ts b/Services/ConversationFileSystemService.ts index 6fc47d1..ceb5a03 100644 --- a/Services/ConversationFileSystemService.ts +++ b/Services/ConversationFileSystemService.ts @@ -41,7 +41,8 @@ export class ConversationFileSystemService { timestamp: content.timestamp.toISOString(), isFunctionCall: content.isFunctionCall, isFunctionCallResponse: content.isFunctionCallResponse, - toolId: content.toolId + toolId: content.toolId, + errorType: content.errorType })) }; @@ -104,7 +105,8 @@ export class ConversationFileSystemService { new Date(content.timestamp), content.isFunctionCall, content.isFunctionCallResponse, - content.toolId + content.toolId, + content.errorType ); }); conversations.push(conversation); diff --git a/Services/StreamingService.ts b/Services/StreamingService.ts index 0a43b71..f7dc23d 100644 --- a/Services/StreamingService.ts +++ b/Services/StreamingService.ts @@ -1,50 +1,96 @@ import type { AIFunctionCall } from "AIClasses/AIFunctionCall"; import { Selector } from "Enums/Selector"; import { Exception } from "Helpers/Exception"; +import { ApiError, ApiErrorType } from "Types/ApiError"; export interface IStreamChunk { content: string; isComplete: boolean; error?: string; + errorType?: ApiErrorType; functionCall?: AIFunctionCall; shouldContinue?: boolean; } export class StreamingService { - public async* streamRequest( - url: string, - requestBody: unknown, - parseStreamChunk: (chunk: string) => IStreamChunk, - abortSignal?: AbortSignal, - additionalHeaders?: Record - ): AsyncGenerator { - try { - const response = await fetch( - url, - { - method: "POST", - headers: { - "Content-Type": "application/json", - ...additionalHeaders, - }, - body: JSON.stringify(requestBody), - signal: abortSignal, + + private static readonly MAX_RETRIES = 3; + private static readonly RETRY_DELAYS = [1000, 2000, 4000]; // ms + + public async* streamRequest(url: string, requestBody: unknown, parseStreamChunk: (chunk: string) => IStreamChunk, + abortSignal?: AbortSignal, additionalHeaders?: Record): AsyncGenerator { + + let lastError: Error | null = null; + + for (let attempt = 0; attempt <= StreamingService.MAX_RETRIES; attempt++) { + try { + const response = await this.makeRequest(url, requestBody, additionalHeaders, abortSignal); + + const reader = response.body?.getReader(); + if (!reader) { + Exception.throw("Response body is not readable"); + } + + const streamCompleted = yield* this.processStream(reader, parseStreamChunk); + + if (!streamCompleted) { + yield { content: "", isComplete: true }; + } + + return; + + } catch (error) { + lastError = error instanceof Error ? error : Exception.new(error); + + if (error instanceof Error && error.name === 'AbortError') { + yield this.createErrorChunk(lastError, true); + return; + } + + if (!this.shouldRetry(error, attempt)) { + Exception.log(lastError); + yield this.createErrorChunk(lastError); + return; + } + + await this.sleep(StreamingService.RETRY_DELAYS[attempt]); } - ); + } + + if (lastError) { + Exception.log(lastError); + yield this.createErrorChunk(lastError); + } + } + + private async makeRequest(url: string, requestBody: unknown, + additionalHeaders?: Record, abortSignal?: AbortSignal): Promise { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...additionalHeaders, + }, + body: JSON.stringify(requestBody), + signal: abortSignal, + }); if (!response.ok) { - Exception.throw(`API request failed: ${response.status} - ${response.statusText} ${await response.text()}`); + const responseBody = await response.text(); + throw ApiError.fromResponse(response.status, response.statusText, responseBody); } - const reader = response.body?.getReader(); - if (!reader) { - Exception.throw("Response body is not readable"); - } + return response; + } - const decoder = new TextDecoder(); + private async* processStream(reader: ReadableStreamDefaultReader, + parseStreamChunk: (chunk: string) => IStreamChunk): AsyncGenerator { + let buffer = ""; let lastChunkWasComplete = false; + const decoder = new TextDecoder(); + while (true) { const { done, value } = await reader.read(); @@ -60,12 +106,13 @@ export class StreamingService { lastChunkWasComplete = chunk.isComplete; yield chunk; } catch (error) { - Exception.log(error); - yield { - content: "", - isComplete: true, - error: Exception.messageFrom(error) - }; + Exception.log(error); + yield { + content: "", + isComplete: true, + error: Exception.messageFrom(error), + errorType: ApiErrorType.UNKNOWN + }; } } } @@ -75,23 +122,49 @@ export class StreamingService { } } - if (!lastChunkWasComplete) { - yield { content: "", isComplete: true }; - } - } catch (error) { - // Don't log abort errors as they're intentional - if (error instanceof Error && error.name === 'AbortError') { - yield { - content: Selector.ApiRequestAborted, - isComplete: true - }; - } else { - yield { - content: "", - isComplete: true, - error: error instanceof Error ? error.message : "Unknown error", - }; - } + return lastChunkWasComplete; + } + + private createErrorChunk(error: Error | ApiError, isAborted = false): IStreamChunk { + if (isAborted) { + return { + content: Selector.ApiRequestAborted, + isComplete: true + }; } + + if (error instanceof ApiError) { + return { + content: "", + isComplete: true, + error: error.info.userMessage, + errorType: error.info.type + }; + } + + return { + content: "", + isComplete: true, + error: Exception.messageFrom(error), + errorType: ApiErrorType.UNKNOWN + }; + } + + private shouldRetry(error: unknown, attempt: number): boolean { + // Don't retry abort errors + if (error instanceof Error && error.name === 'AbortError') { + return false; + } + + // Don't retry non-retryable errors + if (error instanceof ApiError && !error.info.isRetryable) { + return false; + } + + return attempt < StreamingService.MAX_RETRIES; + } + + private async sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); } } \ No newline at end of file diff --git a/Types/ApiError.ts b/Types/ApiError.ts new file mode 100644 index 0000000..70f35fe --- /dev/null +++ b/Types/ApiError.ts @@ -0,0 +1,96 @@ +export enum ApiErrorType { + RATE_LIMIT = "RATE_LIMIT", + OVERLOADED = "OVERLOADED", + SERVER_ERROR = "SERVER_ERROR", + AUTH_ERROR = "AUTH_ERROR", + BAD_REQUEST = "BAD_REQUEST", + NETWORK_ERROR = "NETWORK_ERROR", + UNKNOWN = "UNKNOWN" +} + +export interface ApiErrorInfo { + type: ApiErrorType; + statusCode?: number; + message: string; + userMessage: string; + isRetryable: boolean; + retryAfter?: number; // seconds +} + +export class ApiError extends Error { + constructor( + public info: ApiErrorInfo + ) { + super(info.message); + this.name = "ApiError"; + } + + static fromResponse(status: number, statusText: string, responseBody: string): ApiError { + let type: ApiErrorType; + let userMessage: string; + let isRetryable: boolean; + + // Parse response body for provider-specific messages + let providerMessage = ""; + try { + const parsed = JSON.parse(responseBody) as { error?: { message?: string }; message?: string }; + providerMessage = parsed.error?.message || parsed.message || ""; + } catch { + providerMessage = responseBody; + } + + switch (status) { + case 429: + type = ApiErrorType.RATE_LIMIT; + userMessage = "Rate limit exceeded. Retrying..."; + isRetryable = true; + break; + case 503: + type = ApiErrorType.OVERLOADED; + userMessage = "Service overloaded. Retrying..."; + isRetryable = true; + break; + case 500: + case 502: + case 504: + type = ApiErrorType.SERVER_ERROR; + userMessage = "Server error. Retrying..."; + isRetryable = true; + break; + case 401: + case 403: + type = ApiErrorType.AUTH_ERROR; + userMessage = "Authentication failed. Please check your API key in settings."; + isRetryable = false; + break; + case 400: + type = ApiErrorType.BAD_REQUEST; + userMessage = providerMessage || "Invalid request."; + isRetryable = false; + break; + default: + type = ApiErrorType.UNKNOWN; + userMessage = providerMessage || `Request failed with status ${status}`; + isRetryable = false; + } + + const message = `API request failed: ${status} - ${statusText}${providerMessage ? ` - ${providerMessage}` : ""}`; + + return new ApiError({ + type, + statusCode: status, + message, + userMessage, + isRetryable + }); + } + + static fromNetworkError(error: Error): ApiError { + return new ApiError({ + type: ApiErrorType.NETWORK_ERROR, + message: `Network error: ${error.message}`, + userMessage: "Network error. Please check your connection.", + isRetryable: true + }); + } +} diff --git a/__tests__/AIClasses/Claude.test.ts b/__tests__/AIClasses/Claude.test.ts index 7344bf6..0830dff 100644 --- a/__tests__/AIClasses/Claude.test.ts +++ b/__tests__/AIClasses/Claude.test.ts @@ -259,16 +259,11 @@ describe('Claude', () => { }); it('should handle malformed chunk JSON', () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const result = (claude as any).parseStreamChunk('invalid json {{{'); expect(result.content).toBe(''); - expect(result.isComplete).toBe(false); + expect(result.isComplete).toBe(true); expect(result.error).toContain('Failed to parse chunk'); - expect(consoleSpy).toHaveBeenCalled(); - - consoleSpy.mockRestore(); }); }); diff --git a/__tests__/AIClasses/Gemini.test.ts b/__tests__/AIClasses/Gemini.test.ts index 555585d..08b2671 100644 --- a/__tests__/AIClasses/Gemini.test.ts +++ b/__tests__/AIClasses/Gemini.test.ts @@ -248,16 +248,11 @@ describe('Gemini', () => { }); it('should handle malformed chunk JSON', () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const result = (gemini as any).parseStreamChunk('invalid json {'); expect(result.content).toBe(''); - expect(result.isComplete).toBe(false); + expect(result.isComplete).toBe(true); expect(result.error).toContain('Failed to parse chunk'); - expect(consoleSpy).toHaveBeenCalled(); - - consoleSpy.mockRestore(); }); }); diff --git a/__tests__/AIClasses/OpenAI.test.ts b/__tests__/AIClasses/OpenAI.test.ts index 3cae2f4..99f558f 100644 --- a/__tests__/AIClasses/OpenAI.test.ts +++ b/__tests__/AIClasses/OpenAI.test.ts @@ -251,7 +251,7 @@ describe('OpenAI', () => { const result = (openai as any).parseStreamChunk('not valid json {{{'); expect(result.content).toBe(''); - expect(result.isComplete).toBe(false); + expect(result.isComplete).toBe(true); // The error message comes from Exception.messageFrom which extracts the actual JSON parse error expect(result.error).toBeDefined(); expect(exceptionSpy).toHaveBeenCalled(); diff --git a/__tests__/Conversations/Conversation.test.ts b/__tests__/Conversations/Conversation.test.ts index ce5882a..c839d9a 100644 --- a/__tests__/Conversations/Conversation.test.ts +++ b/__tests__/Conversations/Conversation.test.ts @@ -66,7 +66,8 @@ describe('Conversation', () => { functionCall: '', timestamp: '2024-01-01T00:00:00.000Z', isFunctionCall: false, - isFunctionCallResponse: false + isFunctionCallResponse: false, + errorType: undefined } ] }; diff --git a/__tests__/Conversations/ConversationContent.test.ts b/__tests__/Conversations/ConversationContent.test.ts index 188d49a..2cca689 100644 --- a/__tests__/Conversations/ConversationContent.test.ts +++ b/__tests__/Conversations/ConversationContent.test.ts @@ -124,7 +124,8 @@ describe('ConversationContent', () => { functionCall: '', timestamp: '2024-01-01T00:00:00.000Z', isFunctionCall: false, - isFunctionCallResponse: false + isFunctionCallResponse: false, + errorType: undefined }; expect(ConversationContent.isConversationContentData(validData)).toBe(true); @@ -139,7 +140,8 @@ describe('ConversationContent', () => { timestamp: '2024-01-01T00:00:00.000Z', isFunctionCall: true, isFunctionCallResponse: false, - toolId: 'tool-123' + toolId: 'tool-123', + errorType: undefined }; expect(ConversationContent.isConversationContentData(validData)).toBe(true); @@ -326,7 +328,8 @@ describe('ConversationContent', () => { functionCall: '', timestamp: '2024-01-01T00:00:00.000Z', isFunctionCall: false, - isFunctionCallResponse: false + isFunctionCallResponse: false, + errorType: undefined }; expect(ConversationContent.isConversationContentData(validData)).toBe(true); @@ -340,7 +343,8 @@ describe('ConversationContent', () => { functionCall: '', timestamp: '', isFunctionCall: false, - isFunctionCallResponse: false + isFunctionCallResponse: false, + errorType: undefined }; expect(ConversationContent.isConversationContentData(validData)).toBe(true); diff --git a/__tests__/Services/ConversationFileSystemService.test.ts b/__tests__/Services/ConversationFileSystemService.test.ts index f24d5f6..f76762d 100644 --- a/__tests__/Services/ConversationFileSystemService.test.ts +++ b/__tests__/Services/ConversationFileSystemService.test.ts @@ -377,7 +377,8 @@ describe('ConversationFileSystemService - Integration Tests', () => { functionCall: '', timestamp: '2024-01-01T10:00:00.000Z', isFunctionCall: false, - isFunctionCallResponse: false + isFunctionCallResponse: false, + errorType: undefined } ] }) @@ -393,7 +394,8 @@ describe('ConversationFileSystemService - Integration Tests', () => { functionCall: '', timestamp: '2024-01-02T10:00:00.000Z', isFunctionCall: false, - isFunctionCallResponse: false + isFunctionCallResponse: false, + errorType: undefined } ] }); @@ -426,7 +428,8 @@ describe('ConversationFileSystemService - Integration Tests', () => { functionCall: '', timestamp: '2024-01-01T10:00:00.000Z', isFunctionCall: false, - isFunctionCallResponse: false + isFunctionCallResponse: false, + errorType: undefined }, { role: Role.Assistant, @@ -435,7 +438,8 @@ describe('ConversationFileSystemService - Integration Tests', () => { functionCall: '', timestamp: '2024-01-01T10:01:00.000Z', isFunctionCall: false, - isFunctionCallResponse: false + isFunctionCallResponse: false, + errorType: undefined } ] }); @@ -502,7 +506,8 @@ describe('ConversationFileSystemService - Integration Tests', () => { timestamp: '2024-01-01T10:00:00.000Z', isFunctionCall: true, isFunctionCallResponse: false, - toolId: 'tool_1' + toolId: 'tool_1', + errorType: undefined }, { role: Role.User, @@ -512,7 +517,8 @@ describe('ConversationFileSystemService - Integration Tests', () => { timestamp: '2024-01-01T10:01:00.000Z', isFunctionCall: false, isFunctionCallResponse: true, - toolId: 'tool_1' + toolId: 'tool_1', + errorType: undefined } ] }); diff --git a/__tests__/Services/StreamingService.test.ts b/__tests__/Services/StreamingService.test.ts index a4368a2..a6911a7 100644 --- a/__tests__/Services/StreamingService.test.ts +++ b/__tests__/Services/StreamingService.test.ts @@ -498,8 +498,7 @@ describe('StreamingService', () => { expect(results).toHaveLength(1); expect(results[0].isComplete).toBe(true); - expect(results[0].error).toContain('404'); - expect(results[0].error).toContain('Not Found'); + expect(results[0].error).toBe('Resource not found'); }); it('should handle response with no body', async () => { @@ -553,7 +552,7 @@ describe('StreamingService', () => { expect(results).toHaveLength(1); expect(results[0].isComplete).toBe(true); - expect(results[0].error).toBe('Unknown error'); + expect(results[0].error).toBe('String error'); }); }); diff --git a/styles.css b/styles.css index 6db4309..b53f59f 100644 --- a/styles.css +++ b/styles.css @@ -115,7 +115,7 @@ background-color: var(--background-modifier-hover); } -.api-request-aborted { +.error-selector { display: inline-block; color: var(--color-red); border: var(--color-red); diff --git a/vitest.config.ts b/vitest.config.ts index 773266c..3b005c7 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -18,7 +18,8 @@ export default defineConfig({ "Components": path.resolve(__dirname, "Components"), "Stores": path.resolve(__dirname, "Stores"), "Views": path.resolve(__dirname, "Views"), - "Modals": path.resolve(__dirname, "Modals") + "Modals": path.resolve(__dirname, "Modals"), + "Types": path.resolve(__dirname, "Types") } }, test: {