mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 06:42:03 +00:00
refactor: migrate from flag-based content types to typed properties
Replace boolean flags (isFunctionCall, isFunctionCallResponse, isProviderSpecificContent) with explicit typed properties (functionCall, functionResponse, attachments) in ConversationContent. Introduce Attachment class and BaseAIFileService. Update all AI providers (Claude, Gemini, OpenAI) to use new attachment-based binary file handling with proper error handling, retry logic, and AbortService integration. Implement new Files API service for all providers (not yet integrated).
This commit is contained in:
parent
05d6264e60
commit
3e013f6c9f
19 changed files with 644 additions and 446 deletions
|
|
@ -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<IStreamChunk, void, unknown>;
|
||||
|
||||
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}`);
|
||||
|
|
|
|||
124
AIClasses/BaseAIFileService.ts
Normal file
124
AIClasses/BaseAIFileService.ts
Normal file
|
|
@ -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<SettingsService>(Services.SettingsService);
|
||||
this.apiKey = this.settingsService.getApiKeyForProvider(this.provider);
|
||||
this.abortService = Resolve<AbortService>(Services.AbortService);
|
||||
}
|
||||
|
||||
protected abstract listFilesFromAPI(): Promise<string[]>;
|
||||
protected abstract uploadFileToAPI(data: string, mimeType: string, displayName?: string): Promise<string>;
|
||||
protected abstract deleteFileFromAPI(id: string): Promise<void>;
|
||||
|
||||
public async refreshCache() {
|
||||
this.fileIDs = await this.listFilesFromAPI();
|
||||
}
|
||||
|
||||
public async listFiles(): Promise<string[]> {
|
||||
return [...this.fileIDs];
|
||||
}
|
||||
|
||||
public async uploadFile(attachment: Attachment): Promise<string> {
|
||||
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<void> {
|
||||
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<T>(operationName: string, operation: () => Promise<T>): Promise<T> {
|
||||
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<ArrayBuffer>, mimeType: string): Blob {
|
||||
return new Blob([bytes], { type: mimeType });
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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<SettingsService>(Services.SettingsService);
|
||||
this.apiKey = this.settingsService.getApiKeyForProvider(AIProvider.Claude);
|
||||
super(AIProvider.Claude);
|
||||
}
|
||||
|
||||
public async listFiles(): Promise<string[]> {
|
||||
try {
|
||||
const response = await requestUrl({
|
||||
url: AIFileServiceURL.Claude,
|
||||
protected async listFilesFromAPI(): Promise<string[]> {
|
||||
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<string> {
|
||||
try {
|
||||
protected async uploadFileToAPI(data: string, mimeType: string, displayName?: string): Promise<string> {
|
||||
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<void> {
|
||||
try {
|
||||
const response = await requestUrl({
|
||||
url: `${AIFileServiceURL.Claude}/${id}`,
|
||||
protected async deleteFileFromAPI(id: string): Promise<void> {
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<SettingsService>(Services.SettingsService);
|
||||
this.apiKey = this.settingsService.getApiKeyForProvider(AIProvider.Gemini);
|
||||
super(AIProvider.Gemini);
|
||||
}
|
||||
|
||||
public async listFiles(): Promise<string[]> {
|
||||
try {
|
||||
const response = await requestUrl({
|
||||
url: `${AIFileServiceURL.Gemini}/files?key=${this.apiKey}`,
|
||||
protected async listFilesFromAPI(): Promise<string[]> {
|
||||
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<string> {
|
||||
try {
|
||||
protected async uploadFileToAPI(data: string, mimeType: string, displayName?: string): Promise<string> {
|
||||
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<void> {
|
||||
try {
|
||||
const response = await requestUrl({
|
||||
url: `${AIFileServiceURL.Gemini}/${name}?key=${this.apiKey}`,
|
||||
method: "DELETE"
|
||||
protected async deleteFileFromAPI(name: string): Promise<void> {
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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<IStreamChunk, void, unknown>;
|
||||
formatBinaryFiles(files: Array<{type: string, path: string, contents: string}>): string;
|
||||
formatBinaryFiles(attachments: Attachment[]): string;
|
||||
}
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
import type { Attachment } from "Conversations/Attachment";
|
||||
|
||||
export interface IAIFileService {
|
||||
refreshCache(): Promise<void>;
|
||||
listFiles(): Promise<string[]>;
|
||||
uploadFile(data: string, mimeType: string, displayName?: string): Promise<string>;
|
||||
deleteFile(id: string): Promise<void>;
|
||||
uploadFile(attachment: Attachment): Promise<string>;
|
||||
deleteFile(attachment: Attachment): Promise<void>;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<SettingsService>(Services.SettingsService);
|
||||
this.apiKey = this.settingsService.getApiKeyForProvider(AIProvider.OpenAI);
|
||||
super(AIProvider.OpenAI);
|
||||
}
|
||||
|
||||
public async listFiles(): Promise<string[]> {
|
||||
try {
|
||||
const response = await requestUrl({
|
||||
url: AIFileServiceURL.OpenAI,
|
||||
protected async listFilesFromAPI(): Promise<string[]> {
|
||||
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<string> {
|
||||
try {
|
||||
protected async uploadFileToAPI(data: string, mimeType: string, displayName?: string): Promise<string> {
|
||||
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<void> {
|
||||
try {
|
||||
const response = await requestUrl({
|
||||
url: `${AIFileServiceURL.OpenAI}/${id}`,
|
||||
protected async deleteFileFromAPI(id: string): Promise<void> {
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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 `<div class="${Selector.ErrorSelector}">${message.content}</div>`;
|
||||
return `<div class="${Selector.ErrorSelector}">${content}</div>`;
|
||||
}
|
||||
|
||||
try {
|
||||
return streamingMarkdownService.formatText(message.content) || `<div>${message.content}</div>`;
|
||||
return streamingMarkdownService.formatText(content) || `<div>${content}</div>`;
|
||||
} catch (error) {
|
||||
Exception.log(error);
|
||||
return `<div>${message.content}</div>`;
|
||||
return `<div>${content}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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 @@
|
|||
|
||||
<div class="chat-area" bind:this={chatContainer} on:scroll={handleScroll} use:observeResize>
|
||||
{#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}
|
||||
<div class="message-container {Role.User}" use:trackingAction={index}>
|
||||
<div class="message-bubble {Role.User}">
|
||||
<div class="message-text-user fade-in-fast" contenteditable="false" bind:innerHTML={message.content}></div>
|
||||
<div class="message-text-user fade-in-fast" contenteditable="false">
|
||||
{@html content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
|
|
|
|||
55
Conversations/Attachment.ts
Normal file
55
Conversations/Attachment.ts
Normal file
|
|
@ -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<Record<AIProvider, string>>;
|
||||
|
||||
constructor(
|
||||
fileName: string,
|
||||
mimeType: string,
|
||||
base64: string,
|
||||
fileID: Partial<Record<AIProvider, string>> = {}
|
||||
) {
|
||||
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<Record<AIProvider, string>>;
|
||||
} {
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<FileSystemService>(Services.FileSystemService);
|
||||
}
|
||||
|
||||
public resolveAIFileService() {
|
||||
this.aiFileService = Resolve<IAIFileService>(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<Conversation | Error> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ export function RegisterAiProvider() {
|
|||
|
||||
Resolve<ChatService>(Services.ChatService).resolveAIProvider();
|
||||
Resolve<ConversationNamingService>(Services.ConversationNamingService).resolveNamingProvider();
|
||||
Resolve<ConversationFileSystemService>(Services.ConversationFileSystemService).resolveAIFileService();
|
||||
}
|
||||
|
||||
function RegisterModals() {
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue