diff --git a/AIClasses/BaseAIClass.ts b/AIClasses/BaseAIClass.ts index af8da63..0576359 100644 --- a/AIClasses/BaseAIClass.ts +++ b/AIClasses/BaseAIClass.ts @@ -8,10 +8,10 @@ import type { AIProvider } from "Enums/ApiProvider"; import type { AIFunctionDefinitions } from "AIClasses/FunctionDefinitions/AIFunctionDefinitions"; import type { IAIFunctionDefinition } from "AIClasses/FunctionDefinitions/IAIFunctionDefinition"; import type { ConversationContent } from "Conversations/ConversationContent"; +import type { Attachment } from "Conversations/Attachment"; import type { SettingsService } from "Services/SettingsService"; import type { StreamingService } from "Services/StreamingService"; import type { StoredFunctionCall, StoredFunctionResponse } from "AIClasses/Schemas/AIFunctionTypes"; -import { Role } from "Enums/Role"; import { StringTools } from "Helpers/StringTools"; import { Exception } from "Helpers/Exception"; import { ApiError, ApiErrorType } from "Types/ApiError"; @@ -46,7 +46,7 @@ export abstract class BaseAIClass implements IAIClass { abortSignal?: AbortSignal ): AsyncGenerator; - public abstract formatBinaryFiles(files: Array<{type: string, path: string, contents: string}>): string; + public abstract formatBinaryFiles(attachments: Attachment[]): string; protected abstract parseStreamChunk(chunk: string): IStreamChunk; protected abstract extractContents(conversationContent: ConversationContent[]): unknown; @@ -54,25 +54,23 @@ export abstract class BaseAIClass implements IAIClass { protected filterConversationContents(conversationContent: ConversationContent[]): ConversationContent[] { return conversationContent.filter((content, index, array) => { - // Filter out empty content - if (content.content.trim() === "" && content.functionCall.trim() === "") return false; + if (!content.content && !content.functionCall && (!content.attachments || content.attachments.length === 0)) { + return false; // Filter out empty content + } - // Keep non-function-calls - if (!content.isFunctionCall) return true; + if (!content.functionCall) { + return true; // Keep non-function-calls + } // Keep if it's the last item (most recent) if (index === array.length - 1) return true; // Keep if next item is a function response const nextItem = array[index + 1]; - return nextItem && nextItem.isFunctionCallResponse; + return nextItem && nextItem.functionResponse; }); } - protected getContentToExtract(content: ConversationContent): string { - return content.role === Role.User ? content.promptContent : content.content; - } - protected parseFunctionCall(functionCallJson: string): StoredFunctionCall | null { if (!StringTools.isValidJson(functionCallJson)) { Exception.log(`Invalid JSON in functionCall field:\n${functionCallJson}`); diff --git a/AIClasses/BaseAIFileService.ts b/AIClasses/BaseAIFileService.ts new file mode 100644 index 0000000..92df8be --- /dev/null +++ b/AIClasses/BaseAIFileService.ts @@ -0,0 +1,124 @@ +import type { IAIFileService } from "AIClasses/IAIFileService"; +import { Resolve } from "Services/DependencyService"; +import { Services } from "Services/Services"; +import type { SettingsService } from "Services/SettingsService"; +import { AIProvider } from "Enums/ApiProvider"; +import { Exception } from "Helpers/Exception"; +import { ApiError } from "Types/ApiError"; +import { AbortService } from "Services/AbortService"; +import type { Attachment } from "Conversations/Attachment"; + +export abstract class BaseAIFileService implements IAIFileService { + + private static readonly RETRY_DELAYS = [1000, 2000, 4000]; // ms + private static readonly MAX_RETRIES = 3; + + protected readonly abortService: AbortService; + protected readonly settingsService: SettingsService; + protected readonly apiKey: string; + + private provider: AIProvider; + private fileIDs: string[] = []; + + protected constructor(provider: AIProvider) { + this.provider = provider; + this.settingsService = Resolve(Services.SettingsService); + this.apiKey = this.settingsService.getApiKeyForProvider(this.provider); + this.abortService = Resolve(Services.AbortService); + } + + protected abstract listFilesFromAPI(): Promise; + protected abstract uploadFileToAPI(data: string, mimeType: string, displayName?: string): Promise; + protected abstract deleteFileFromAPI(id: string): Promise; + + public async refreshCache() { + this.fileIDs = await this.listFilesFromAPI(); + } + + public async listFiles(): Promise { + return [...this.fileIDs]; + } + + public async uploadFile(attachment: Attachment): Promise { + const existingFileID = attachment.getFileID(this.provider); + + if (existingFileID && this.fileIDs.contains(existingFileID)) { + return existingFileID; + } + + const fileID = await this.uploadFileToAPI(attachment.base64, attachment.mimeType, attachment.fileName); + attachment.setFileID(this.provider, fileID); + + if (!this.fileIDs.contains(fileID)) { + this.fileIDs.push(fileID); + } + + return fileID; + } + + public async deleteFile(attachment: Attachment): Promise { + const id = attachment.getFileID(this.provider); + + if (id === undefined) { + return; + } + + if (this.fileIDs.contains(id)) { + await this.deleteFileFromAPI(id); + attachment.deleteFileID(this.provider); + this.fileIDs.remove(id); + } + } + + public async deleteFiles(fileIDs: Attachment[]) { + for (const fileID of fileIDs) { + await this.deleteFile(fileID); + } + } + + // Retries operation on retryable errors (500, 502, 503, 504) with exponential backoff + protected async withRetry(operationName: string, operation: () => Promise): Promise { + let lastError: ApiError | unknown; + + for (let attempt = 1; attempt <= BaseAIFileService.MAX_RETRIES; attempt++) { + try { + return await operation(); + } catch (error) { + lastError = error; + + // Don't retry on abort errors + if (AbortService.isAbortError(error)) { + throw error; + } + + if (ApiError.isApiError(error) && error.info.isRetryable) { + if (attempt === BaseAIFileService.MAX_RETRIES) { + Exception.log(`${operationName}: Max retries (${BaseAIFileService.MAX_RETRIES}) exhausted`); + throw error; + } + + const delay = BaseAIFileService.RETRY_DELAYS[attempt]; + Exception.warn(`${operationName}: Attempt ${attempt}/${BaseAIFileService.MAX_RETRIES} failed with ${error.info.type} (status ${error.info.statusCode}). Retrying in ${delay}ms...`); + + if (this.abortService.signal().aborted) { + this.abortService.throw(); + } + + await sleep(delay); + + if (this.abortService.signal().aborted) { + this.abortService.throw(); + } + } else { + throw error; + } + } + } + throw lastError; + } + + protected createBlob(bytes: Uint8Array, mimeType: string): Blob { + return new Blob([bytes], { type: mimeType }); + } + +} diff --git a/AIClasses/Claude/Claude.ts b/AIClasses/Claude/Claude.ts index bc1e1ea..1c9fc1c 100644 --- a/AIClasses/Claude/Claude.ts +++ b/AIClasses/Claude/Claude.ts @@ -1,6 +1,7 @@ import { BaseAIClass } from "AIClasses/BaseAIClass"; import type { IStreamChunk } from "Services/StreamingService"; import type { Conversation } from "Conversations/Conversation"; +import type { Attachment } from "Conversations/Attachment"; import { AIProvider, AIProviderURL } from "Enums/ApiProvider"; import { AIFunctionCall } from "AIClasses/AIFunctionCall"; import { fromString as aiFunctionFromString } from "Enums/AIFunction"; @@ -9,8 +10,6 @@ import type { ConversationContent } from "Conversations/ConversationContent"; import { Role } from "Enums/Role"; import type { RawMessageStreamEvent, ContentBlockParam, Tool } from '@anthropic-ai/sdk/resources/messages'; import { Exception } from "Helpers/Exception"; -import * as path from "path-browserify"; -import { FileType, getImageMimeType, isFileType } from "Enums/FileType"; export class Claude extends BaseAIClass { @@ -151,9 +150,9 @@ export class Claude extends BaseAIClass { return this.filterConversationContents(conversationContent) .map(content => { const contentBlocks: ContentBlockParam[] = []; - const contentToExtract = this.getContentToExtract(content); + const contentToExtract = content.content ?? ""; - if (contentToExtract.trim() !== "" && !content.isFunctionCallResponse && !content.isProviderSpecificContent) { + if (contentToExtract.trim() !== "" && !content.functionResponse && (!content.attachments || content.attachments.length === 0)) { contentBlocks.push({ type: "text", text: contentToExtract @@ -161,7 +160,7 @@ export class Claude extends BaseAIClass { } // Add function call if present - if (content.isFunctionCall && content.functionCall.trim() !== "") { + if (content.functionCall) { const parsedContent = this.parseFunctionCall(content.functionCall); if (parsedContent) { @@ -178,8 +177,7 @@ export class Claude extends BaseAIClass { text: this.convertFunctionCallToText(parsedContent) }); } - } else if (contentToExtract.trim() === "") { - // Fall back to treating as text + } else { contentBlocks.push({ type: "text", text: "Error parsing function call" @@ -187,15 +185,16 @@ export class Claude extends BaseAIClass { } } - // Add provider-specific content if present (e.g., binary files) - if (content.isProviderSpecificContent && contentToExtract.trim() !== "") { - const rawContent = JSON.parse(contentToExtract) as ContentBlockParam[]; + // Add binary file attachments if present + if (content.attachments && content.attachments.length > 0) { + const formattedContent = this.formatBinaryFiles(content.attachments); + const rawContent = JSON.parse(formattedContent) as ContentBlockParam[]; contentBlocks.push(...rawContent); } // Add function response if present - if (content.isFunctionCallResponse && contentToExtract.trim() !== "") { - const parsedContent = this.parseFunctionResponse(contentToExtract); + if (content.functionResponse) { + const parsedContent = this.parseFunctionResponse(content.functionResponse); if (parsedContent) { if (parsedContent.id && parsedContent.id.trim() !== "") { @@ -213,7 +212,7 @@ export class Claude extends BaseAIClass { } else { contentBlocks.push({ type: "text", - text: contentToExtract + text: content.functionResponse }); } } @@ -238,41 +237,32 @@ export class Claude extends BaseAIClass { })); } - public formatBinaryFiles(files: Array<{type: string, path: string, contents: string}>): string { - const contentBlocks = files.flatMap(file => { - const extension = path.extname(file.path).substring(1).toLowerCase(); - - let mimeType: string; + public formatBinaryFiles(attachments: Attachment[]): string { + const contentBlocks = attachments.flatMap(attachment => { let blockType: string; - if (isFileType(file.type, FileType.PDF)) { - mimeType = "application/pdf"; + if (attachment.mimeType === "application/pdf") { blockType = "document"; } else { - try { - mimeType = getImageMimeType(extension); - blockType = "image"; + // Image handling + blockType = "image"; - if (!this.SUPPORTED_IMAGE_TYPES.includes(mimeType)) { - return [ - { type: "text", text: `Unsupported image format: ${path.basename(file.path)}` } - ]; - } - } catch (error) { + // Validate supported image types + if (!this.SUPPORTED_IMAGE_TYPES.includes(attachment.mimeType)) { return [ - { type: "text", text: Exception.messageFrom(error) } + { type: "text", text: `Unsupported image format: ${attachment.fileName}` } ]; } } return [ - {type: "text", text: path.basename(file.path)}, + {type: "text", text: attachment.fileName}, { type: blockType, source: { type: "base64", - media_type: mimeType, - data: file.contents + media_type: attachment.mimeType, + data: attachment.base64 } } ]; diff --git a/AIClasses/Claude/ClaudeFileService.ts b/AIClasses/Claude/ClaudeFileService.ts index ae4525f..a2fd39a 100644 --- a/AIClasses/Claude/ClaudeFileService.ts +++ b/AIClasses/Claude/ClaudeFileService.ts @@ -1,103 +1,90 @@ -import type { IAIFileService } from "AIClasses/IAIFileService"; -import { Resolve } from "Services/DependencyService"; -import { Services } from "Services/Services"; -import type { SettingsService } from "Services/SettingsService"; +import { BaseAIFileService } from "AIClasses/BaseAIFileService"; import { AIFileServiceURL, AIProvider } from "Enums/ApiProvider"; -import { Exception } from "Helpers/Exception"; -import { requestUrl } from "obsidian"; import { StringTools } from "Helpers/StringTools"; +import { ApiError } from "Types/ApiError"; -export class ClaudeFileService implements IAIFileService { +export class ClaudeFileService extends BaseAIFileService { - private readonly settingsService: SettingsService; - private readonly apiKey: string; private readonly betaHeader = "files-api-2025-04-14"; public constructor() { - this.settingsService = Resolve(Services.SettingsService); - this.apiKey = this.settingsService.getApiKeyForProvider(AIProvider.Claude); + super(AIProvider.Claude); } - public async listFiles(): Promise { - try { - const response = await requestUrl({ - url: AIFileServiceURL.Claude, + protected async listFilesFromAPI(): Promise { + return this.withRetry("List files", async () => { + const response = await fetch(AIFileServiceURL.Claude, { method: "GET", headers: { "x-api-key": this.apiKey, "anthropic-version": "2023-06-01", "anthropic-beta": this.betaHeader - } + }, + signal: this.abortService.signal() }); - if (response.status !== 200) { - Exception.throw(`Failed to list files: ${response.status} ${response.text}`); + if (!response.ok) { + const responseBody = await response.text(); + throw ApiError.fromResponse(response.status, response.statusText, responseBody); } - const data = response.json as ClaudeListFilesResponse; + const data = await response.json() as ClaudeListFilesResponse; if (!data.data || data.data.length === 0) { return []; } return data.data.map(file => file.id); - } catch (error) { - Exception.log(error); - Exception.throw(error); - } + }); } - public async uploadFile(data: string, mimeType: string, displayName?: string): Promise { - try { + protected async uploadFileToAPI(data: string, mimeType: string, displayName?: string): Promise { + return this.withRetry("Upload file", async () => { const bytes = StringTools.toBytes(data); - const blob = new Blob([bytes], { type: mimeType }); + const blob = this.createBlob(bytes, mimeType); const formData = new FormData(); formData.append("file", blob, displayName || "file"); - const response = await requestUrl({ - url: AIFileServiceURL.Claude, + const response = await fetch(AIFileServiceURL.Claude, { method: "POST", headers: { "x-api-key": this.apiKey, "anthropic-version": "2023-06-01", "anthropic-beta": this.betaHeader }, - body: formData as unknown as string + body: formData, + signal: this.abortService.signal() }); - if (response.status !== 200 && response.status !== 201) { - Exception.throw(`Failed to upload file: ${response.status} ${response.text}`); + if (!response.ok) { + const responseBody = await response.text(); + throw ApiError.fromResponse(response.status, response.statusText, responseBody); } - const responseData = response.json as ClaudeFile; + const responseData = await response.json() as ClaudeFile; return responseData.id; - } catch (error) { - Exception.log(error); - Exception.throw(error); - } + }); } - public async deleteFile(id: string): Promise { - try { - const response = await requestUrl({ - url: `${AIFileServiceURL.Claude}/${id}`, + protected async deleteFileFromAPI(id: string): Promise { + return this.withRetry("Delete file", async () => { + const response = await fetch(`${AIFileServiceURL.Claude}/${id}`, { method: "DELETE", headers: { "x-api-key": this.apiKey, "anthropic-version": "2023-06-01", "anthropic-beta": this.betaHeader - } + }, + signal: this.abortService.signal() }); - if (response.status !== 200 && response.status !== 204) { - Exception.throw(`Failed to delete file: ${response.status} ${response.text}`); + if (!response.ok && response.status !== 204 && response.status !== 404) { + const responseBody = await response.text(); + throw ApiError.fromResponse(response.status, response.statusText, responseBody); } - } catch (error) { - Exception.log(error); - Exception.throw(error); - } + }); } } \ No newline at end of file diff --git a/AIClasses/Gemini/Gemini.ts b/AIClasses/Gemini/Gemini.ts index decad9a..eeb3eef 100644 --- a/AIClasses/Gemini/Gemini.ts +++ b/AIClasses/Gemini/Gemini.ts @@ -1,6 +1,7 @@ import { BaseAIClass } from "AIClasses/BaseAIClass"; import type { IStreamChunk } from "Services/StreamingService"; import type { Conversation } from "Conversations/Conversation"; +import type { Attachment } from "Conversations/Attachment"; import { Role } from "Enums/Role"; import { AIProvider, AIProviderURL } from "Enums/ApiProvider"; import { AIFunctionCall } from "AIClasses/AIFunctionCall"; @@ -9,9 +10,6 @@ import type { IAIFunctionDefinition } from "AIClasses/FunctionDefinitions/IAIFun import type { ConversationContent } from "Conversations/ConversationContent"; import type { Candidate, Part, FunctionDeclaration } from "@google/genai"; import { FinishReason } from "@google/genai"; -import * as path from "path-browserify"; -import { FileType, getImageMimeType, isFileType } from "Enums/FileType"; -import { Exception } from "Helpers/Exception"; export class Gemini extends BaseAIClass { @@ -158,15 +156,15 @@ export class Gemini extends BaseAIClass { return this.filterConversationContents(conversationContent) .map(content => { const parts: Part[] = []; - const contentToExtract = this.getContentToExtract(content); + const contentToExtract = content.content ?? ""; - // Add text content if not a function call response or provider-specific content - if (contentToExtract.trim() !== "" && !content.isFunctionCallResponse && !content.isProviderSpecificContent) { + // Add text content if not a function call response or attachment + if (contentToExtract.trim() !== "" && !content.functionResponse && (!content.attachments || content.attachments.length === 0)) { parts.push({ text: contentToExtract }); } // Add function call if present - if (content.isFunctionCall && content.functionCall.trim() !== "") { + if (content.functionCall) { const parsedContent = this.parseFunctionCall(content.functionCall); if (parsedContent) { @@ -186,23 +184,23 @@ export class Gemini extends BaseAIClass { text: this.convertFunctionCallToText(parsedContent) }); } - } else if (contentToExtract.trim() === "") { - // Fall back to treating as text + } else { parts.push({ text: "Error parsing function call" }); } } - // Add provider-specific content if present (e.g., binary files) - if (content.isProviderSpecificContent && contentToExtract.trim() !== "") { - const rawContent = JSON.parse(contentToExtract) as Part[]; + // Add binary file attachments if present + if (content.attachments && content.attachments.length > 0) { + const formattedContent = this.formatBinaryFiles(content.attachments); + const rawContent = JSON.parse(formattedContent) as Part[]; parts.push(...rawContent); } // Add function response if present - if (content.isFunctionCallResponse && contentToExtract.trim() !== "") { - const parsedContent = this.parseFunctionResponse(contentToExtract); + if (content.functionResponse) { + const parsedContent = this.parseFunctionResponse(content.functionResponse); if (parsedContent) { if (parsedContent.id && parsedContent.id.trim() !== "") { @@ -222,7 +220,7 @@ export class Gemini extends BaseAIClass { } else { // Fall back to text content parts.push({ - text: contentToExtract + text: content.functionResponse }); } } @@ -243,39 +241,26 @@ export class Gemini extends BaseAIClass { })); } - public formatBinaryFiles(files: Array<{type: string, path: string, contents: string}>): string { + public formatBinaryFiles(attachments: Attachment[]): string { const parts: unknown[] = []; - for (const file of files) { - const extension = path.extname(file.path).substring(1).toLowerCase(); - - let mimeType: string; - - if (isFileType(file.type, FileType.PDF)) { - mimeType = "application/pdf"; - } else { - try { - mimeType = getImageMimeType(extension); - - if (!this.SUPPORTED_IMAGE_TYPES.includes(mimeType)) { - parts.push({ - text: `Unsupported image format: ${path.basename(file.path)}` - }); - continue; - } - } catch (error) { + for (const attachment of attachments) { + // Validate image types (Gemini only supports JPEG and PNG) + if (attachment.mimeType.startsWith('image/')) { + if (!this.SUPPORTED_IMAGE_TYPES.includes(attachment.mimeType)) { parts.push({ - text: Exception.messageFrom(error) + text: `Unsupported image format: ${attachment.fileName}` }); continue; } } - parts.push({text: path.basename(file.path)}); + // Add filename text block, then binary data + parts.push({text: attachment.fileName}); parts.push({ inlineData: { - mimeType: mimeType, - data: file.contents + mimeType: attachment.mimeType, + data: attachment.base64 } }); } diff --git a/AIClasses/Gemini/GeminiFileService.ts b/AIClasses/Gemini/GeminiFileService.ts index 994311e..3bf849a 100644 --- a/AIClasses/Gemini/GeminiFileService.ts +++ b/AIClasses/Gemini/GeminiFileService.ts @@ -1,58 +1,49 @@ -import type { IAIFileService } from "AIClasses/IAIFileService"; -import { Resolve } from "Services/DependencyService"; -import { Services } from "Services/Services"; -import type { SettingsService } from "Services/SettingsService"; +import { BaseAIFileService } from "AIClasses/BaseAIFileService"; import { AIFileServiceURL, AIProvider } from "Enums/ApiProvider"; import { Exception } from "Helpers/Exception"; -import { requestUrl } from "obsidian"; import { StringTools } from "Helpers/StringTools"; +import { ApiError } from "Types/ApiError"; -export class GeminiFileService implements IAIFileService { - - private readonly settingsService: SettingsService; - private readonly apiKey: string; +export class GeminiFileService extends BaseAIFileService { public constructor() { - this.settingsService = Resolve(Services.SettingsService); - this.apiKey = this.settingsService.getApiKeyForProvider(AIProvider.Gemini); + super(AIProvider.Gemini); } - public async listFiles(): Promise { - try { - const response = await requestUrl({ - url: `${AIFileServiceURL.Gemini}/files?key=${this.apiKey}`, + protected async listFilesFromAPI(): Promise { + return this.withRetry("List files", async () => { + const response = await fetch(`${AIFileServiceURL.Gemini}/files?key=${this.apiKey}`, { method: "GET", headers: { "Content-Type": "application/json" - } + }, + signal: this.abortService.signal() }); - if (response.status !== 200) { - Exception.throw(`Failed to list files: ${response.status} ${response.text}`); + if (!response.ok) { + const responseBody = await response.text(); + throw ApiError.fromResponse(response.status, response.statusText, responseBody); } - const data = response.json as GeminiListFilesResponse; + const data = await response.json() as GeminiListFilesResponse; if (!data.files || data.files.length === 0) { return []; } return data.files.map(file => file.name); - } catch (error) { - Exception.log(error); - Exception.throw(error); - } + }); } - public async uploadFile(data: string, mimeType: string, displayName?: string): Promise { - try { + protected async uploadFileToAPI(data: string, mimeType: string, displayName?: string): Promise { + return this.withRetry("Upload file", async () => { const bytes = StringTools.toBytes(data); const numBytes = bytes.byteLength; const metadata = displayName ? { file: { displayName } } : {}; - const initiateResponse = await requestUrl({ - url: `${AIFileServiceURL.GeminiUpload}/files?key=${this.apiKey}`, + // Step 1: Initiate resumable upload + const initiateResponse = await fetch(`${AIFileServiceURL.GeminiUpload}/files?key=${this.apiKey}`, { method: "POST", headers: { "X-Goog-Upload-Protocol": "resumable", @@ -61,57 +52,56 @@ export class GeminiFileService implements IAIFileService { "X-Goog-Upload-Header-Content-Type": mimeType, "Content-Type": "application/json" }, - body: JSON.stringify(metadata) + body: JSON.stringify(metadata), + signal: this.abortService.signal() }); - if (initiateResponse.status !== 200) { - Exception.throw(`Failed to initiate upload: ${initiateResponse.status} ${initiateResponse.text}`); + if (!initiateResponse.ok) { + const responseBody = await initiateResponse.text(); + throw ApiError.fromResponse(initiateResponse.status, initiateResponse.statusText, responseBody); } - const uploadUrl = initiateResponse.headers["x-goog-upload-url"]; + const uploadUrl = initiateResponse.headers.get("x-goog-upload-url"); if (!uploadUrl) { Exception.throw("No upload URL received from initiate request"); } - const uploadResponse = await requestUrl({ - url: uploadUrl, + // Step 2: Upload file data + const blob = this.createBlob(bytes, mimeType); + const uploadResponse = await fetch(uploadUrl, { method: "POST", headers: { "Content-Length": numBytes.toString(), "X-Goog-Upload-Offset": "0", "X-Goog-Upload-Command": "upload, finalize" }, - body: bytes.buffer, - contentType: mimeType + body: blob, + signal: this.abortService.signal() }); - if (uploadResponse.status !== 200) { - Exception.throw(`Failed to upload file: ${uploadResponse.status} ${uploadResponse.text}`); + if (!uploadResponse.ok) { + const responseBody = await uploadResponse.text(); + throw ApiError.fromResponse(uploadResponse.status, uploadResponse.statusText, responseBody); } - const responseData = uploadResponse.json as GeminiUploadResponse; + const responseData = await uploadResponse.json() as GeminiUploadResponse; return responseData.file.uri; - } catch (error) { - Exception.log(error); - Exception.throw(error); - } + }); } - public async deleteFile(name: string): Promise { - try { - const response = await requestUrl({ - url: `${AIFileServiceURL.Gemini}/${name}?key=${this.apiKey}`, - method: "DELETE" + protected async deleteFileFromAPI(name: string): Promise { + return this.withRetry("Delete file", async () => { + const response = await fetch(`${AIFileServiceURL.Gemini}/${name}?key=${this.apiKey}`, { + method: "DELETE", + signal: this.abortService.signal() }); - if (response.status !== 200 && response.status !== 204) { - Exception.throw(`Failed to delete file: ${response.status} ${response.text}`); + if (!response.ok && response.status !== 204 && response.status !== 403) { + const responseBody = await response.text(); + throw ApiError.fromResponse(response.status, response.statusText, responseBody); } - } catch (error) { - Exception.log(error); - Exception.throw(error); - } + }); } } \ No newline at end of file diff --git a/AIClasses/IAIClass.ts b/AIClasses/IAIClass.ts index 2d4e008..9a979e1 100644 --- a/AIClasses/IAIClass.ts +++ b/AIClasses/IAIClass.ts @@ -1,7 +1,8 @@ import type { IStreamChunk } from "Services/StreamingService"; import type { Conversation } from "Conversations/Conversation"; +import type { Attachment } from "Conversations/Attachment"; export interface IAIClass { streamRequest(conversation: Conversation, allowDestructiveActions: boolean): AsyncGenerator; - formatBinaryFiles(files: Array<{type: string, path: string, contents: string}>): string; + formatBinaryFiles(attachments: Attachment[]): string; } \ No newline at end of file diff --git a/AIClasses/IAIFileService.ts b/AIClasses/IAIFileService.ts index c9c7093..edacdab 100644 --- a/AIClasses/IAIFileService.ts +++ b/AIClasses/IAIFileService.ts @@ -1,5 +1,8 @@ +import type { Attachment } from "Conversations/Attachment"; + export interface IAIFileService { + refreshCache(): Promise; listFiles(): Promise; - uploadFile(data: string, mimeType: string, displayName?: string): Promise; - deleteFile(id: string): Promise; + uploadFile(attachment: Attachment): Promise; + deleteFile(attachment: Attachment): Promise; } \ No newline at end of file diff --git a/AIClasses/OpenAI/OpenAI.ts b/AIClasses/OpenAI/OpenAI.ts index 39711b9..0927639 100644 --- a/AIClasses/OpenAI/OpenAI.ts +++ b/AIClasses/OpenAI/OpenAI.ts @@ -2,6 +2,7 @@ import { BaseAIClass } from "AIClasses/BaseAIClass"; import type { IStreamChunk } from "Services/StreamingService"; import type { Conversation } from "Conversations/Conversation"; import type { ConversationContent } from "Conversations/ConversationContent"; +import type { Attachment } from "Conversations/Attachment"; import { AIProvider, AIProviderURL, toProviderModel } from "Enums/ApiProvider"; import { AIFunctionCall } from "AIClasses/AIFunctionCall"; import { fromString as aiFunctionFromString } from "Enums/AIFunction"; @@ -9,8 +10,6 @@ import type { IAIFunctionDefinition } from "AIClasses/FunctionDefinitions/IAIFun import type { ResponseEvent, ResponseOutputTextDelta, ResponseOutputItemDone, ResponseErrorEvent, ResponseFailedEvent, OpenAIFunctionTool, ResponsesAPIInput } from "./OpenAITypes"; import { Exception } from "Helpers/Exception"; import { ApiErrorType } from "Types/ApiError"; -import * as path from "path-browserify"; -import { FileType, getImageMimeType, isFileType } from "Enums/FileType"; export class OpenAI extends BaseAIClass { @@ -184,21 +183,20 @@ export class OpenAI extends BaseAIClass { const results: ResponsesAPIInput[] = []; for (const content of this.filterConversationContents(conversationContent)) { - const contentToExtract = this.getContentToExtract(content); + const contentToExtract = content.content ?? ""; // Case 1: Assistant message with function call - if (content.isFunctionCall && content.functionCall.trim() !== "") { + if (content.functionCall) { const parsedContent = this.parseFunctionCall(content.functionCall); if (parsedContent) { // Check if function call has required id field (for OpenAI Responses API) if (parsedContent.functionCall.id && parsedContent.functionCall.id.trim() !== "") { // Add assistant text message if present - const messageContent = contentToExtract.trim(); - if (messageContent !== "") { + if (contentToExtract.trim() !== "") { results.push({ role: content.role as "user" | "assistant", - content: messageContent + content: contentToExtract }); } @@ -210,7 +208,7 @@ export class OpenAI extends BaseAIClass { arguments: JSON.stringify(parsedContent.functionCall.args) }); } else { - // No id (from Gemini or legacy) - convert to text message + // No id (from other provider or legacy) - convert to text message const legacyText = this.convertFunctionCallToText(parsedContent); const messageContent = contentToExtract.trim(); const combinedContent = messageContent !== "" @@ -232,16 +230,17 @@ export class OpenAI extends BaseAIClass { continue; } - // Case 2: Provider-specific content (e.g., binary files) - if (content.isProviderSpecificContent && contentToExtract.trim() !== "") { - const rawContent = JSON.parse(contentToExtract) as ResponsesAPIInput[]; + // Case 2: Binary file attachments + if (content.attachments && content.attachments.length > 0) { + const formattedContent = this.formatBinaryFiles(content.attachments); + const rawContent = JSON.parse(formattedContent) as ResponsesAPIInput[]; results.push(...rawContent); continue; } // Case 3: Function call response - if (content.isFunctionCallResponse && contentToExtract.trim() !== "") { - const parsedContent = this.parseFunctionResponse(contentToExtract); + if (content.functionResponse) { + const parsedContent = this.parseFunctionResponse(content.functionResponse); if (parsedContent) { // Check if response has required id field (for OpenAI Responses API) @@ -263,7 +262,7 @@ export class OpenAI extends BaseAIClass { // Fall back to regular user message if parsing fails results.push({ role: content.role as "user" | "assistant", - content: contentToExtract + content: content.functionResponse }); } continue; @@ -290,45 +289,33 @@ export class OpenAI extends BaseAIClass { })); } - public formatBinaryFiles(files: Array<{type: string, path: string, contents: string}>): string { + public formatBinaryFiles(attachments: Attachment[]): string { const contentBlocks: unknown[] = []; - - for (const file of files) { - const extension = path.extname(file.path).substring(1).toLowerCase(); - let mimeType: string; - - if (isFileType(file.type, FileType.PDF)) { - mimeType = "application/pdf"; + + for (const attachment of attachments) { + if (attachment.mimeType === "application/pdf") { contentBlocks.push({ type: "input_file", - filename: path.basename(file.path), - file_data: `data:${mimeType};base64,${file.contents}` + filename: attachment.fileName, + file_data: `data:${attachment.mimeType};base64,${attachment.base64}` }); } else { - try { - mimeType = getImageMimeType(extension); - if (!this.SUPPORTED_IMAGE_TYPES.includes(mimeType)) { - contentBlocks.push({ - type: "input_text", - text: `Unsupported image format: ${path.basename(file.path)}` - }); - continue; - } - - contentBlocks.push({ - type: "input_image", - image_url: `data:${mimeType};base64,${file.contents}` - }); - } catch (error) { + // Image handling - validate supported types + if (!this.SUPPORTED_IMAGE_TYPES.includes(attachment.mimeType)) { contentBlocks.push({ type: "input_text", - text: Exception.messageFrom(error) + text: `Unsupported image format: ${attachment.fileName}` }); continue; } + + contentBlocks.push({ + type: "input_image", + image_url: `data:${attachment.mimeType};base64,${attachment.base64}` + }); } } - + return JSON.stringify([{ role: "user", content: contentBlocks diff --git a/AIClasses/OpenAI/OpenAIFileService.ts b/AIClasses/OpenAI/OpenAIFileService.ts index cf68d96..e562110 100644 --- a/AIClasses/OpenAI/OpenAIFileService.ts +++ b/AIClasses/OpenAI/OpenAIFileService.ts @@ -1,98 +1,87 @@ -import type { IAIFileService } from "AIClasses/IAIFileService"; -import { Resolve } from "Services/DependencyService"; -import { Services } from "Services/Services"; -import type { SettingsService } from "Services/SettingsService"; +import { BaseAIFileService } from "AIClasses/BaseAIFileService"; import { AIFileServiceURL, AIProvider } from "Enums/ApiProvider"; -import { Exception } from "Helpers/Exception"; -import { requestUrl } from "obsidian"; import type { OpenAIFile, OpenAIListFilesResponse } from "./OpenAITypes"; import { StringTools } from "Helpers/StringTools"; +import { ApiError } from "Types/ApiError"; -export class OpenAIFileService implements IAIFileService { - - private readonly settingsService: SettingsService; - private readonly apiKey: string; +export class OpenAIFileService extends BaseAIFileService { public constructor() { - this.settingsService = Resolve(Services.SettingsService); - this.apiKey = this.settingsService.getApiKeyForProvider(AIProvider.OpenAI); + super(AIProvider.OpenAI); } - public async listFiles(): Promise { - try { - const response = await requestUrl({ - url: AIFileServiceURL.OpenAI, + protected async listFilesFromAPI(): Promise { + return this.withRetry("List files", async () => { + const response = await fetch(AIFileServiceURL.OpenAI, { method: "GET", headers: { "Authorization": `Bearer ${this.apiKey}` - } + }, + signal: this.abortService.signal() }); - if (response.status !== 200) { - Exception.throw(`Failed to list files: ${response.status} ${response.text}`); + if (!response.ok) { + const responseBody = await response.text(); + throw ApiError.fromResponse(response.status, response.statusText, responseBody); } - const data = response.json as OpenAIListFilesResponse; + const data = await response.json() as OpenAIListFilesResponse; if (!data.data || data.data.length === 0) { return []; } return data.data.map(file => file.id); - } catch (error) { - Exception.log(error); - Exception.throw(error); - } + }); } - public async uploadFile(data: string, mimeType: string, displayName?: string): Promise { - try { + protected async uploadFileToAPI(data: string, mimeType: string, displayName?: string): Promise { + return this.withRetry("Upload file", async () => { const bytes = StringTools.toBytes(data); - const blob = new Blob([bytes], { type: mimeType }); + const blob = this.createBlob(bytes, mimeType); const formData = new FormData(); formData.append("file", blob, displayName || "file"); - formData.append("purpose", "assistants"); // Default purpose for general use - const response = await requestUrl({ - url: AIFileServiceURL.OpenAI, + // Use 'vision' for images, 'user_data' for other files + const purpose = mimeType.startsWith('image/') ? 'vision' : 'user_data'; + formData.append("purpose", purpose); + + const response = await fetch(AIFileServiceURL.OpenAI, { method: "POST", headers: { "Authorization": `Bearer ${this.apiKey}` }, - body: formData as unknown as string + body: formData, + signal: this.abortService.signal() }); - if (response.status !== 200 && response.status !== 201) { - Exception.throw(`Failed to upload file: ${response.status} ${response.text}`); + if (!response.ok) { + const responseBody = await response.text(); + throw ApiError.fromResponse(response.status, response.statusText, responseBody); } - const responseData = response.json as OpenAIFile; + const responseData = await response.json() as OpenAIFile; return responseData.id; - } catch (error) { - Exception.log(error); - Exception.throw(error); - } + }); } - public async deleteFile(id: string): Promise { - try { - const response = await requestUrl({ - url: `${AIFileServiceURL.OpenAI}/${id}`, + protected async deleteFileFromAPI(id: string): Promise { + return this.withRetry("Delete file", async () => { + const response = await fetch(`${AIFileServiceURL.OpenAI}/${id}`, { method: "DELETE", headers: { "Authorization": `Bearer ${this.apiKey}` - } + }, + signal: this.abortService.signal() }); - if (response.status !== 200 && response.status !== 204) { - Exception.throw(`Failed to delete file: ${response.status} ${response.text}`); + if (!response.ok && response.status !== 204 && response.status !== 404) { + const responseBody = await response.text(); + throw ApiError.fromResponse(response.status, response.statusText, responseBody); } - } catch (error) { - Exception.log(error); - Exception.throw(error); - } + }); } } \ No newline at end of file diff --git a/Components/ChatArea.svelte b/Components/ChatArea.svelte index ed288a4..012a936 100644 --- a/Components/ChatArea.svelte +++ b/Components/ChatArea.svelte @@ -127,12 +127,12 @@ } } - function updateMessageContent(message: {id: string, content: string, role: string, isCurrentlyStreaming: boolean}) { - if (message.isCurrentlyStreaming) { - streamingMarkdownService.streamChunk(message.id, message.content); + function updateMessageContent(messageId: string, content: string, isCurrentlyStreaming: boolean) { + if (isCurrentlyStreaming) { + streamingMarkdownService.streamChunk(messageId, content); currentStreamFinalized = false; } else if (!currentStreamFinalized) { - streamingMarkdownService.finalizeStream(message.id, message.content); + streamingMarkdownService.finalizeStream(messageId, content); currentStreamFinalized = true; } } @@ -149,15 +149,16 @@ // For assistant messages that aren't streaming, use traditional parsing if (!isCurrentlyStreaming) { + const content = message.getDisplayContent(); if (message.errorType) { - return `
${message.content}
`; + return `
${content}
`; } try { - return streamingMarkdownService.formatText(message.content) || `
${message.content}
`; + return streamingMarkdownService.formatText(content) || `
${content}
`; } catch (error) { Exception.log(error); - return `
${message.content}
`; + return `
${content}
`; } } @@ -227,11 +228,12 @@ // Check if this specific message is currently streaming const isCurrentlyStreaming = currentStreamingMessageId === messageId; + const content = message.getDisplayContent(); // Only process through streaming service if actively streaming if (isCurrentlyStreaming) { - updateMessageContent({ ...message, id: messageId, isCurrentlyStreaming }); + updateMessageContent(messageId, content, isCurrentlyStreaming); } - lastProcessedContent.set(messageId, message.content); + lastProcessedContent.set(messageId, content); } } }); @@ -246,11 +248,14 @@
{#each messages as message, index} - {#if !message.isFunctionCallResponse && !message.isProviderSpecificContent && message.content.trim() !== ""} + {@const content = message.getDisplayContent()} + {#if message.shouldDisplayContent && content.trim() !== ""} {#if message.role === Role.User}
-
+
+ {@html content} +
{:else} diff --git a/Conversations/Attachment.ts b/Conversations/Attachment.ts new file mode 100644 index 0000000..4ca44b6 --- /dev/null +++ b/Conversations/Attachment.ts @@ -0,0 +1,55 @@ +import type { AIProvider } from "Enums/ApiProvider"; + +export class Attachment { + public fileName: string; + public mimeType: string; + public base64: string; + public fileID: Partial>; + + constructor( + fileName: string, + mimeType: string, + base64: string, + fileID: Partial> = {} + ) { + this.fileName = fileName; + this.mimeType = mimeType; + this.base64 = base64; + this.fileID = fileID; + } + + public getFileID(provider: AIProvider): string | undefined { + return this.fileID[provider]; + } + + public setFileID(provider: AIProvider, id: string): void { + this.fileID[provider] = id; + } + + public deleteFileID(provider: AIProvider): boolean { + if (provider in this.fileID) { + delete this.fileID[provider]; + return true; + } + return false; + } + + public static isAttachmentData(this: void, data: unknown): data is { + fileName: string; + mimeType: string; + base64: string; + fileID?: Partial>; + } { + return ( + data !== null && + typeof data === "object" && + "fileName" in data && + "mimeType" in data && + "base64" in data && + typeof data.fileName === "string" && + typeof data.mimeType === "string" && + typeof data.base64 === "string" && + (!("fileID" in data) || typeof data.fileID === "object") + ); + } +} \ No newline at end of file diff --git a/Conversations/Conversation.ts b/Conversations/Conversation.ts index aa69089..8284d68 100644 --- a/Conversations/Conversation.ts +++ b/Conversations/Conversation.ts @@ -1,9 +1,11 @@ import { StringTools } from "Helpers/StringTools"; import { ConversationContent } from "./ConversationContent"; +import { Attachment } from "./Attachment"; import type { AIFunctionResponse } from "AIClasses/FunctionDefinitions/AIFunctionResponse"; import { Role } from "Enums/Role"; import { AIFunction } from "Enums/AIFunction"; -import { isTextFile } from "Enums/FileType"; +import { isTextFile, FileType, getImageMimeType, isFileType } from "Enums/FileType"; +import { Exception } from "Helpers/Exception"; export class Conversation { @@ -20,23 +22,15 @@ export class Conversation { this.title = `${StringTools.dateToString(this.created)}`; } - public addFunctionResponse( - functionResponse: AIFunctionResponse, - formatBinaryFiles?: (files: Array<{type: string, path: string, contents: string}>) => string - ): void { + public addFunctionResponse(functionResponse: AIFunctionResponse): void { if (functionResponse.name !== AIFunction.ReadVaultFiles) { const functionResponseString = functionResponse.toConversationString(); - this.contents.push(new ConversationContent( - Role.User, - functionResponseString, - functionResponseString, - "", - new Date(), - false, - true, - false, - functionResponse.toolId - )); + this.contents.push(new ConversationContent({ + role: Role.User, + functionResponse: functionResponseString, + shouldDisplayContent: false, + toolId: functionResponse.toolId + })); return; } @@ -74,32 +68,47 @@ export class Conversation { }; } - this.contents.push(new ConversationContent( - Role.User, - JSON.stringify(functionResponseData), - JSON.stringify(functionResponseData), - "", - new Date(), - false, - true, - false, - functionResponse.toolId - )); + this.contents.push(new ConversationContent({ + role: Role.User, + functionResponse: JSON.stringify(functionResponseData), + shouldDisplayContent: false, + toolId: functionResponse.toolId + })); - // 2. If there are non-text files, add follow-up user message - if (binaryResults.length > 0 && formatBinaryFiles) { - const providerContent = formatBinaryFiles(binaryResults); + // 2. If there are binary files, create Attachments and add to conversation + if (binaryResults.length > 0) { + const attachments = binaryResults.map(file => { + // Extract filename from path + const fileName = file.path.split('/').pop() || file.path; - this.contents.push(new ConversationContent( - Role.User, - providerContent, - providerContent, - "", - new Date(), - false, - false, - true - )); + // Determine mimeType based on file.type + let mimeType: string; + if (isFileType(file.type, FileType.PDF)) { + mimeType = "application/pdf"; + } else { + // For images, derive from extension + const extension = fileName.split('.').pop()?.toLowerCase() || ''; + try { + mimeType = getImageMimeType(extension); + } catch (error) { + Exception.log(error); + Exception.throw(error); + } + } + + return new Attachment( + fileName, + mimeType, + file.contents, // base64 string + {} // empty fileID map (phase 2 feature) + ); + }); + + this.contents.push(new ConversationContent({ + role: Role.User, + attachments: attachments, + shouldDisplayContent: false + })); } } diff --git a/Conversations/ConversationContent.ts b/Conversations/ConversationContent.ts index c47bf5d..2649f46 100644 --- a/Conversations/ConversationContent.ts +++ b/Conversations/ConversationContent.ts @@ -1,100 +1,109 @@ import { Role } from "Enums/Role"; import { ApiErrorType } from "Types/ApiError"; +import type { Attachment } from "./Attachment"; -export class ConversationContent { +type ConversationContentInit = { role: Role; - content: string; - promptContent: string; - functionCall: string; - timestamp: Date; - isFunctionCall: boolean; - isFunctionCallResponse: boolean; - isProviderSpecificContent: boolean; + timestamp?: Date; + content?: string; + displayContent?: string; + functionCall?: string; + functionResponse?: string; + attachments?: Attachment[]; + shouldDisplayContent?: boolean; toolId?: string; thoughtSignature?: string; errorType?: ApiErrorType; +}; + +export class ConversationContent { + public role: Role; + public timestamp: Date; + public content: string | undefined; + public displayContent: string | undefined; + public functionCall: string | undefined; + public functionResponse: string | undefined; + public attachments: Attachment[]; + public shouldDisplayContent: boolean; + public toolId: string | undefined; + public thoughtSignature: string | undefined; + public errorType: ApiErrorType | undefined; /** * Creates a conversation content entry. * - * @param role - The role of the message sender (User or Assistant) - * @param content - The display content shown in the UI (for User messages, this is the user's input; for Assistant, it's the AI response) - * @param promptContent - The content sent to the AI provider (often same as content, but may differ for system-generated messages) - * @param functionCall - JSON string of the function call data (only set when isFunctionCall=true) - * @param timestamp - When this content was created - * @param isFunctionCall - True if this is an Assistant message containing a function/tool call - * @param isFunctionCallResponse - True if this is a User message containing the response to a function call (hides from UI) - * @param isProviderSpecificContent - True if this contains provider-specific formatted content (e.g., binary files in Claude/OpenAI/Gemini format; hides from UI) - * @param toolId - Unique identifier for tool calls/responses (used to match calls with their responses) - * @param thoughtSignature - Gemini-specific thought signature for extended thinking - * @param errorType - If present, indicates this message contains an error + * @param init - Initialization object + * @param init.role - The role of the message sender (User or Assistant) + * @param init.timestamp - Timestamp of the message (defaults to now) + * @param init.content - The content to be displayed and/or sent to the AI provider + * @param init.displayContent - Display content is used when content needs to be formatted differently when displayed versus as a prompt + * @param init.functionCall - JSON string of the function call data (only set for function/tool calls) + * @param init.functionResponse - JSON string of the function call response data (only set for function/tool responses) + * @param init.attachments - Array of file attachments associated with this message (defaults to empty array) + * @param init.shouldDisplayContent - Whether this content should be displayed in the UI (defaults to true, false for system-generated messages) + * @param init.toolId - Unique identifier for tool calls/responses (used to match calls with their responses) + * @param init.thoughtSignature - Gemini-specific thought signature for extended thinking + * @param init.errorType - Indicates that this contains an error of the given type */ - constructor( - role: Role, - content: string = "", - promptContent: string = "", - functionCall: string = "", - timestamp: Date = new Date(), - isFunctionCall = false, - isFunctionCallResponse = false, - isProviderSpecificContent = false, - toolId?: string, - thoughtSignature?: string, - errorType?: ApiErrorType - ) { - this.role = role; - this.content = content; - this.promptContent = promptContent; - this.functionCall = functionCall; - this.timestamp = timestamp; - this.isFunctionCall = isFunctionCall; - this.isFunctionCallResponse = isFunctionCallResponse; - this.isProviderSpecificContent = isProviderSpecificContent; - this.toolId = toolId; - this.thoughtSignature = thoughtSignature; - this.errorType = errorType; + constructor(init: ConversationContentInit) { + this.role = init.role; + this.timestamp = init.timestamp ?? new Date(); + this.content = init.content; + this.displayContent = init.displayContent; + this.functionCall = init.functionCall; + this.functionResponse = init.functionResponse; + this.attachments = init.attachments ?? []; + this.shouldDisplayContent = init.shouldDisplayContent ?? true; + this.toolId = init.toolId; + this.thoughtSignature = init.thoughtSignature; + this.errorType = init.errorType; } - public static isConversationContentData(this: void, data: unknown): data is { - role: string; content: string; promptContent: string; functionCall: string; timestamp: string, isFunctionCall: boolean, - isFunctionCallResponse: boolean, isProviderSpecificContent: boolean, toolId?: string, thoughtSignature?: string, errorType?: string + public getDisplayContent(): string { + return this.displayContent ?? this.content ?? ""; + } + + public static isConversationContentData( + this: void, + data: unknown + ): data is { + role: string; + timestamp: string; + content?: string; + displayContent?: string; + functionCall?: string; + functionResponse?: string; + attachments?: unknown[]; + shouldDisplayContent?: boolean; + toolId?: string; + thoughtSignature?: string; + errorType?: string; } { return ( data !== null && typeof data === "object" && - "role" in data && - "content" in data && - "promptContent" in data && - "functionCall" in data && "timestamp" in data && - "isFunctionCall" in data && - "isFunctionCallResponse" in data && - "isProviderSpecificContent" in data && - typeof data.role === "string" && - typeof data.content === "string" && - typeof data.promptContent === "string" && - typeof data.functionCall === "string" && + "role" in data && typeof data.timestamp === "string" && - typeof data.isFunctionCall === "boolean" && - typeof data.isFunctionCallResponse === "boolean" && - typeof data.isProviderSpecificContent === "boolean" && + typeof data.role === "string" && - // optional conversation data fields + (!("content" in data) || typeof data.content === "string") && + (!("displayContent" in data) || typeof data.displayContent === "string") && + (!("functionCall" in data) || typeof data.functionCall === "string") && + (!("functionResponse" in data) || typeof data.functionResponse === "string") && + (!("attachments" in data) || Array.isArray(data.attachments)) && + (!("shouldDisplayContent" in data) || typeof data.shouldDisplayContent === "boolean") && (!("toolId" in data) || typeof data.toolId === "string") && (!("thoughtSignature" in data) || typeof data.thoughtSignature === "string") && (!("errorType" in data) || typeof data.errorType === "string") ); } - public static safeContinue() { - return new ConversationContent( - Role.User, - "Continue", - "Continue", - "", - new Date(), - false, - true // isFunctionCallResponse = true (hides from UI) - ); + public static safeContinue(): ConversationContent { + return new ConversationContent({ + role: Role.User, + content: "Continue", + shouldDisplayContent: false + }); } } \ No newline at end of file diff --git a/Helpers/Exception.ts b/Helpers/Exception.ts index 892eea6..6d4c1c6 100644 --- a/Helpers/Exception.ts +++ b/Helpers/Exception.ts @@ -18,6 +18,13 @@ export abstract class Exception { } } + public static warn(error: unknown) { + if (process.env.NODE_ENV === Environment.DEV) { + const e: Error = this.new(error); + console.warn(e.message, e); + } + } + public static messageFrom(error: unknown): string { if (error instanceof Error) { return error.message; diff --git a/Services/ChatService.ts b/Services/ChatService.ts index b43a3d2..9216f81 100644 --- a/Services/ChatService.ts +++ b/Services/ChatService.ts @@ -51,10 +51,9 @@ export class ChatService { } public async submit(conversation: Conversation, allowDestructiveActions: boolean, userRequest: string, formattedRequest: string, callbacks: IChatServiceCallbacks) { - if (this.ai === undefined || !await this.semaphore.wait()) { + if (!await this.semaphore.wait()) { return; } - const ai = this.ai; this.semaphoreHeld = true; @@ -66,7 +65,11 @@ export class ChatService { this.abortService.initialiseAbortController(); await this.abortService.abortableOperation(async () => { - conversation.contents.push(new ConversationContent(Role.User, userRequest, formattedRequest)); + conversation.contents.push(new ConversationContent({ + role: Role.User, + content: formattedRequest, + displayContent: userRequest + })); await this.saveConversation(conversation); callbacks.onSubmit(); @@ -87,10 +90,7 @@ export class ChatService { } const functionResponse = await this.aiFunctionService.performAIFunction(response.functionCall); - conversation.addFunctionResponse( - functionResponse, - (files) => ai.formatBinaryFiles(files) - ); + conversation.addFunctionResponse(functionResponse); } else { callbacks.onThoughtUpdate(Copy.AIThoughtMessage); } @@ -149,7 +149,7 @@ export class ChatService { return { functionCall: null, shouldContinue: false };; } - const conversationContent = new ConversationContent(Role.Assistant); + const conversationContent = new ConversationContent({ role: Role.Assistant }); conversation.contents.push(conversationContent); let accumulatedContent = ""; @@ -189,7 +189,6 @@ export class ChatService { } else { conversationContent.content = sanitizedContent; if (capturedFunctionCall) { - conversationContent.isFunctionCall = true; conversationContent.functionCall = capturedFunctionCall.toConversationString(); if (capturedFunctionCall.thoughtSignature) { conversationContent.thoughtSignature = capturedFunctionCall.thoughtSignature; @@ -198,7 +197,7 @@ export class ChatService { } } - if (conversationContent.content.trim() !== "") { + if (conversationContent.content?.trim() !== "") { callbacks.onStreamingUpdate(conversationContent.timestamp.getTime().toString()); } } diff --git a/Services/ConversationFileSystemService.ts b/Services/ConversationFileSystemService.ts index 59477e6..d5ce1b3 100644 --- a/Services/ConversationFileSystemService.ts +++ b/Services/ConversationFileSystemService.ts @@ -4,17 +4,25 @@ import { FileSystemService } from "./FileSystemService"; import { Services } from "./Services"; import { Conversation } from "Conversations/Conversation"; import { ConversationContent } from "Conversations/ConversationContent"; +import { Attachment } from "Conversations/Attachment"; import { Exception } from "Helpers/Exception"; +import type { IAIFileService } from "AIClasses/IAIFileService"; export class ConversationFileSystemService { private fileSystemService: FileSystemService; + private aiFileService: IAIFileService | undefined; + private currentConversationPath: string | null = null; public constructor() { this.fileSystemService = Resolve(Services.FileSystemService); } + public resolveAIFileService() { + this.aiFileService = Resolve(Services.IAIFileService); + } + public generateConversationPath(conversation: Conversation): string { return `${Path.Conversations}/${conversation.title}.json`; } @@ -39,13 +47,13 @@ export class ConversationFileSystemService { contents: conversation.contents .map(content => ({ role: content.role, - content: content.content, - promptContent: content.promptContent, - functionCall: content.functionCall, timestamp: content.timestamp.toISOString(), - isFunctionCall: content.isFunctionCall, - isFunctionCallResponse: content.isFunctionCallResponse, - isProviderSpecificContent: content.isProviderSpecificContent, + content: content.content, + displayContent: content.displayContent, + functionCall: content.functionCall, + functionResponse: content.functionResponse, + attachments: content.attachments, + shouldDisplayContent: content.shouldDisplayContent, toolId: content.toolId, thoughtSignature: content.thoughtSignature, errorType: content.errorType @@ -78,10 +86,18 @@ export class ConversationFileSystemService { return; } - const result = await this.fileSystemService.deleteFile(this.currentConversationPath, true, false); + const readResult = await this.readConversation(this.currentConversationPath); - if (result instanceof Error) { - return result; + if (readResult instanceof Error) { + return readResult; + } + + await this.attemptAIFileDeletion(readResult); + + const deleteResult = await this.fileSystemService.deleteFile(this.currentConversationPath, true, false); + + if (deleteResult instanceof Error) { + return deleteResult; } this.resetCurrentConversation(); @@ -92,32 +108,9 @@ export class ConversationFileSystemService { const conversations: Conversation[] = []; for (const file of files) { - const result = await this.fileSystemService.readObjectFromFile(file.path, true); - if (result instanceof Error) { - Exception.log(`Failed to load conversation: ${file.path}`); - continue; - } - if (Conversation.isConversationData(result)) { - const conversation: Conversation = new Conversation(); - conversation.title = result.title; - conversation.created = new Date(result.created); - conversation.updated = new Date(result.updated); - conversation.contents = result.contents.map(content => { - return new ConversationContent( - content.role, - content.content, - content.promptContent, - content.functionCall, - new Date(content.timestamp), - content.isFunctionCall, - content.isFunctionCallResponse, - content.isProviderSpecificContent, - content.toolId, - content.thoughtSignature, - content.errorType - ); - }); - conversations.push(conversation); + const result = await this.readConversation(file.path); + if (result instanceof Conversation) { + conversations.push(result); } } @@ -138,4 +131,73 @@ export class ConversationFileSystemService { } } + private async readConversation(path: string): Promise { + const result = await this.fileSystemService.readObjectFromFile(path, true); + + if (result instanceof Error) { + Exception.log(result); + return result; + } + + const conversation: Conversation = new Conversation(); + + if (Conversation.isConversationData(result)) { + conversation.title = result.title; + conversation.created = new Date(result.created); + conversation.updated = new Date(result.updated); + conversation.contents = result.contents.map(content => { + // Reconstruct Attachment instances from plain objects + const attachments = this.deserializeAttachments(content.attachments); + + return new ConversationContent({ + role: content.role, + timestamp: new Date(content.timestamp), + content: content.content, + displayContent: content.displayContent, + functionCall: content.functionCall, + functionResponse: content.functionResponse, + attachments: attachments, + shouldDisplayContent: content.shouldDisplayContent, + toolId: content.toolId, + thoughtSignature: content.thoughtSignature, + errorType: content.errorType + }); + }); + } + + return conversation; + } + + private deserializeAttachments(attachmentsData: unknown): Attachment[] { + if (!Array.isArray(attachmentsData)) { + return []; + } + + return attachmentsData + .filter(Attachment.isAttachmentData) + .map(attachmentData => new Attachment( + attachmentData.fileName, + attachmentData.mimeType, + attachmentData.base64, + attachmentData.fileID || {} + )); + } + + private async attemptAIFileDeletion(conversation: Conversation) { + try { + await this.aiFileService?.refreshCache(); + } catch (error) { + Exception.log(error); + } + + const attachments = conversation.contents.map(c => c.attachments).flat(); + for (const attachment of attachments) { + try { + await this.aiFileService?.deleteFile(attachment); + } catch (error) { + Exception.log(error); + } + } + } + } diff --git a/Services/ServiceRegistration.ts b/Services/ServiceRegistration.ts index 4aa5283..0d34a0a 100644 --- a/Services/ServiceRegistration.ts +++ b/Services/ServiceRegistration.ts @@ -93,6 +93,7 @@ export function RegisterAiProvider() { Resolve(Services.ChatService).resolveAIProvider(); Resolve(Services.ConversationNamingService).resolveNamingProvider(); + Resolve(Services.ConversationFileSystemService).resolveAIFileService(); } function RegisterModals() { diff --git a/Services/StreamingService.ts b/Services/StreamingService.ts index 788265e..12b0b51 100644 --- a/Services/StreamingService.ts +++ b/Services/StreamingService.ts @@ -60,7 +60,7 @@ export class StreamingService { return; } - await this.sleep(StreamingService.RETRY_DELAYS[attempt]); + await sleep(StreamingService.RETRY_DELAYS[attempt]); } } @@ -177,7 +177,4 @@ export class StreamingService { return attempt < StreamingService.MAX_RETRIES; } - private async sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); - } } \ No newline at end of file