mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 06:42:03 +00:00
Restructure system prompt to enforce mandatory complexity evaluation before action. Replace verbose multi-step planning framework with concise gate-based decision model. Add UI for execution plan visibility. Improve planning/execution separation by blocking execution tools during planning phase. Remove cancellation indicator component. Fix conversation deletion bug preventing saves after delete. Strengthen type safety for AIFunction names. Simplify function summary format for planning agent. Update test mocks for new callback signatures.
231 lines
8.2 KiB
TypeScript
231 lines
8.2 KiB
TypeScript
import { Path } from "Enums/Path";
|
|
import { Resolve } from "./DependencyService";
|
|
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";
|
|
import { Reference } from "Conversations/Reference";
|
|
|
|
export class ConversationFileSystemService {
|
|
|
|
private fileSystemService: FileSystemService;
|
|
private aiFileService: IAIFileService | undefined;
|
|
|
|
private currentConversationPath: string | null = null;
|
|
private deletionQueue: Promise<void> = Promise.resolve();
|
|
private isDeleted: boolean = false;
|
|
|
|
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`;
|
|
}
|
|
|
|
public async saveConversation(conversation: Conversation): Promise<string | Error> {
|
|
if (this.isDeleted) {
|
|
return ""; // Return empty string to indicate silent skip (not an error)
|
|
}
|
|
|
|
if (!this.currentConversationPath) {
|
|
this.currentConversationPath = this.generateConversationPath(conversation);
|
|
} else {
|
|
// can happen if the conversation is deleted during an active request
|
|
const fileExists = await this.fileSystemService.exists(this.currentConversationPath, true);
|
|
if (!fileExists) {
|
|
return this.currentConversationPath;
|
|
}
|
|
}
|
|
|
|
conversation.updated = new Date();
|
|
|
|
const conversationData = {
|
|
title: conversation.title,
|
|
created: conversation.created.toISOString(),
|
|
updated: conversation.updated.toISOString(),
|
|
contents: conversation.contents
|
|
.map(content => ({
|
|
role: content.role,
|
|
timestamp: content.timestamp.toISOString(),
|
|
content: content.content,
|
|
displayContent: content.displayContent,
|
|
functionCall: content.functionCall,
|
|
functionResponse: content.functionResponse,
|
|
attachments: content.attachments,
|
|
references: content.references,
|
|
shouldDisplayContent: content.shouldDisplayContent,
|
|
toolId: content.toolId,
|
|
thoughtSignature: content.thoughtSignature,
|
|
errorType: content.errorType
|
|
}))
|
|
};
|
|
|
|
const result = await this.fileSystemService.writeObjectToFile(this.currentConversationPath, conversationData, true, false);
|
|
|
|
if (result instanceof Error) {
|
|
return result;
|
|
}
|
|
|
|
return this.currentConversationPath;
|
|
}
|
|
|
|
public resetCurrentConversation() {
|
|
this.currentConversationPath = null;
|
|
this.isDeleted = false;
|
|
}
|
|
|
|
public getCurrentConversationPath(): string | null {
|
|
return this.currentConversationPath;
|
|
}
|
|
|
|
public setCurrentConversationPath(filePath: string) {
|
|
this.currentConversationPath = filePath;
|
|
this.isDeleted = false;
|
|
}
|
|
|
|
public async deleteCurrentConversation(): Promise<void | Error> {
|
|
if (!this.currentConversationPath) {
|
|
return;
|
|
}
|
|
|
|
const readResult = await this.readConversation(this.currentConversationPath);
|
|
|
|
if (readResult instanceof Error) {
|
|
return readResult;
|
|
}
|
|
|
|
// Queue this to execute silently in the background - it's just a best effort
|
|
this.deletionQueue = this.deletionQueue.then(() => this.attemptAIFileDeletion(readResult));
|
|
|
|
const deleteResult = await this.fileSystemService.deleteFile(this.currentConversationPath, true, false);
|
|
|
|
if (deleteResult instanceof Error) {
|
|
return deleteResult;
|
|
}
|
|
|
|
// Mark as deleted to prevent subsequent saves during ongoing operations
|
|
this.isDeleted = true;
|
|
this.currentConversationPath = null;
|
|
}
|
|
|
|
public async getAllConversations(): Promise<Conversation[]> {
|
|
const files = await this.fileSystemService.listFilesInDirectory(Path.Conversations, false, true);
|
|
const conversations: Conversation[] = [];
|
|
|
|
for (const file of files) {
|
|
const result = await this.readConversation(file.path);
|
|
if (result instanceof Conversation) {
|
|
conversations.push(result);
|
|
}
|
|
}
|
|
|
|
return conversations;
|
|
}
|
|
|
|
public async updateConversationTitle(oldPath: string, newTitle: string): Promise<void | Error> {
|
|
const newPath = `${Path.Conversations}/${newTitle}.json`;
|
|
|
|
const result = await this.fileSystemService.moveFile(oldPath, newPath, true);
|
|
|
|
if (result instanceof Error) {
|
|
return result;
|
|
}
|
|
|
|
if (this.currentConversationPath === oldPath) {
|
|
this.currentConversationPath = newPath;
|
|
}
|
|
}
|
|
|
|
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);
|
|
const references = this.deserializeReferences(content.references);
|
|
|
|
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,
|
|
references: references,
|
|
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 deserializeReferences(referencesData: unknown): Reference[] {
|
|
if (!Array.isArray(referencesData)) {
|
|
return [];
|
|
}
|
|
|
|
return referencesData
|
|
.filter(Reference.isReferenceData)
|
|
.map(referenceData => new Reference(
|
|
referenceData.fileName,
|
|
referenceData.size
|
|
));
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|