feat: add artifact tracking system for agent file operations

Implement comprehensive artifact tracking to record all file modifications made by the AI agent during conversations. Add artifact persistence, garbage collection, and UI updates with new "Discuss" button styling.
This commit is contained in:
Andrew Beal 2026-07-10 21:23:44 +01:00
parent bfa8360037
commit 10ddb1da28
35 changed files with 497 additions and 192 deletions

View file

@ -10,25 +10,25 @@ export class AIToolResponse {
public readonly toolId?: string;
public static readonly UserRejectionMessage: string = `The user has explicitly rejected this change.
They may have changed their mind about the requested change.
They may have changed their mind about the requested change.
**CRITICAL:** Immediately stop all further actions and consult with the user`;
**CRITICAL:** Immediately stop all further actions and consult with the user`;
public static readonly UserSuggestionMessage: string = `**USER MODIFICATION REQUEST:**
The user has reviewed your proposed action and provided a modification or alternative direction.
The user has reviewed your proposed action and provided a modification or alternative direction.
**Critical Instructions:**
1. This is NOT an error or failure - this is valuable user guidance that should be taken seriously
2. The user may want to:
- Adjust the SAME action with different parameters (e.g., write to a different file)
- Change to a DIFFERENT action entirely (e.g., delete instead of write)
- Add context or constraints you didn't initially consider
3. Carefully analyze the user's suggestion below to understand their true intent
4. Acknowledge their feedback and explain how you'll adjust your approach
5. Then proceed with the modified action that aligns with their guidance
**Critical Instructions:**
1. This is NOT an error or failure - this is valuable user guidance that should be taken seriously
2. The user may want to:
- Adjust the SAME action with different parameters (e.g., write to a different file)
- Change to a DIFFERENT action entirely (e.g., delete instead of write)
- Add context or constraints you didn't initially consider
3. Carefully analyze the user's suggestion below to understand their true intent
4. Acknowledge their feedback and explain how you'll adjust your approach
5. Then proceed with the modified action that aligns with their guidance
**User's Suggestion:**`;
**User's Suggestion:**`;
constructor(name: AITool, payload: AIToolResponsePayload, toolId?: string) {
this.name = name;

View file

@ -1,11 +1,14 @@
import type { Artifact } from "Conversations/Artifact";
import type { Attachment } from "Conversations/Attachment";
export class AIToolResponsePayload {
public readonly response: object;
public readonly artifacts: Artifact[];
public readonly attachments: Attachment[];
constructor(response: object, attachments: Attachment[] = []) {
constructor(response: object, artifacts: Artifact[] = [], attachments: Attachment[] = []) {
this.response = response;
this.artifacts = artifacts;
this.attachments = attachments;
}
}

View file

@ -21,6 +21,9 @@
import { AITool, fromString } from "Enums/AITool";
import { AIProvider } from "Enums/ApiProvider";
import type { PlanApprovalService } from "Services/PlanApprovalService";
import type { Artifact } from "Conversations/Artifact";
import { ConversationContent } from "Conversations/ConversationContent";
import { Role } from "Enums/Role";
const plugin: VaultkeeperAIPlugin = Resolve<VaultkeeperAIPlugin>(Services.VaultkeeperAIPlugin);
const executionPlanStore: ExecutionPlanStore = Resolve<ExecutionPlanStore>(Services.ExecutionPlanStore);
@ -32,6 +35,8 @@
const streamingMarkdownService: StreamingMarkdownService = Resolve<StreamingMarkdownService>(Services.StreamingMarkdownService);
const abortService: AbortService = Resolve<AbortService>(Services.AbortService);
let collectedArtifacts: Artifact[] = [];
let chatContainer: HTMLDivElement;
let chatArea: ChatArea;
let chatInput: ChatInput;
@ -98,6 +103,7 @@
if (handleNoApiKey()) {
return;
}
collectedArtifacts = [];
const currentRequest = userRequest;
@ -132,6 +138,14 @@
break;
}
},
onArtifactProduced: (artifact: Artifact) => {
const collectedArtifact = collectedArtifacts.find(a => a.filePath === artifact.filePath);
if (!collectedArtifact) {
collectedArtifacts.push(artifact);
return;
}
collectedArtifact.updatedContent = artifact.updatedContent;
},
onPlanningStarted: () => {
busyPlanning = true;
},
@ -159,6 +173,8 @@
executionPlanStore.clearPlan();
},
onComplete: async () => {
saveCollectedArtifects(conversation);
conversationService.saveConversation(conversation);
isSubmitting = false;
busyPlanning = false;
currentThought = null;
@ -170,6 +186,17 @@
});
}
function saveCollectedArtifects(conversation: Conversation): void {
let lastMessage = conversation.contents.last();
if (lastMessage?.role !== Role.Assistant || !lastMessage.shouldDisplayContent) {
lastMessage = new ConversationContent({ role: Role.Assistant });
conversation.contents.push(lastMessage);
}
lastMessage.artifacts = collectedArtifacts;
}
$: if ($conversationStore.shouldReset) {
conversation = new Conversation();
conversationService.resetCurrentConversation();

View file

@ -57,36 +57,52 @@
#diff-accept {
grid-column: 1;
background-color: color-mix(
in srgb,
var(--color-green) 75%,
var(--background-primary) 25%
);
color: var(--color-green);
border: solid;
border-color: var(--color-green);
border-width: var(--size-2-1);
background-color: var(--background-primary);
}
#diff-accept:hover {
background-color: var(--color-green);
background-color: color-mix(
in srgb,
var(--color-green) 25%,
white 10%
);
}
#diff-accept:focus {
background-color: var(--color-green);
background-color: color-mix(
in srgb,
var(--color-green) 25%,
white 10%
);
}
#diff-reject {
grid-column: 3;
background-color: color-mix(
in srgb,
var(--color-red) 75%,
var(--background-primary) 25%
);
color: var(--color-red);
border: solid;
border-color: var(--color-red);
border-width: var(--size-2-1);
background-color: var(--background-primary);
}
#diff-reject:hover {
background-color: var(--color-red);
background-color: color-mix(
in srgb,
var(--color-red) 25%,
white 10%
);
}
#diff-reject:focus {
background-color: var(--color-red);
background-color: color-mix(
in srgb,
var(--color-red) 25%,
white 10%
);
}
.diff-button {

View file

@ -58,36 +58,52 @@
#plan-approve {
grid-column: 1;
background-color: color-mix(
in srgb,
var(--color-green) 75%,
var(--background-primary) 25%
);
color: var(--color-green);
border: solid;
border-color: var(--color-green);
border-width: var(--size-2-1);
background-color: var(--background-primary);
}
#plan-approve:hover {
background-color: var(--color-green);
background-color: color-mix(
in srgb,
var(--color-green) 25%,
white 10%
);
}
#plan-approve:focus {
background-color: var(--color-green);
background-color: color-mix(
in srgb,
var(--color-green) 25%,
white 10%
);
}
#plan-reject {
grid-column: 3;
background-color: color-mix(
in srgb,
var(--color-red) 75%,
var(--background-primary) 25%
);
color: var(--color-red);
border: solid;
border-color: var(--color-red);
border-width: var(--size-2-1);
background-color: var(--background-primary);
}
#plan-reject:hover {
background-color: var(--color-red);
background-color: color-mix(
in srgb,
var(--color-red) 25%,
white 10%
);
}
#plan-reject:focus {
background-color: var(--color-red);
background-color: color-mix(
in srgb,
var(--color-red) 25%,
white 10%
);
}
.plan-approval-button {

53
Conversations/Artifact.ts Normal file
View file

@ -0,0 +1,53 @@
import type { IBinaryFile } from "Conversations/IBinaryFile";
export class Artifact implements IBinaryFile {
public filePath: string;
public mimeType: string;
public originalContent: string;
public updatedContent: string;
public base64: string | undefined;
public artifactPath?: string;
public constructor(filePath: string, mimeType: string, originalContent: string, updatedContent: string, base64?: string, artifactPath?: string) {
this.filePath = filePath;
this.mimeType = mimeType;
this.originalContent = originalContent;
this.updatedContent = updatedContent;
this.base64 = base64;
this.artifactPath = artifactPath;
}
public getStoragePath(): string | undefined {
return this.artifactPath;
}
public setStoragePath(path: string): void {
this.artifactPath = path;
}
public static isArtifactData(this: void, data: unknown): data is {
filePath: string;
mimeType: string;
originalContent: string;
updatedContent: string;
base64?: string;
artifactPath?: string;
} {
return (
data !== null &&
typeof data === "object" &&
"filePath" in data &&
typeof data.filePath === "string" &&
"mimeType" in data &&
typeof data.mimeType === "string" &&
"originalContent" in data &&
typeof data.originalContent === "string" &&
"updatedContent" in data &&
typeof data.updatedContent === "string" &&
(!("base64" in data) || typeof data.base64 === "string") &&
(!("artifactPath" in data) || typeof data.artifactPath === "string")
);
}
}

View file

@ -3,8 +3,9 @@ import { isAudioFile, isImageFile, isKnownFileType, isTextFile, isVideoFile } fr
import { MimeTypeToFileTypes } from "Enums/FileTypeMimeTypeMapping";
import { isImageMimeType, isTextMimeType, MimeType, toMimeType } from "Enums/MimeType";
import { StringTools } from "Helpers/StringTools";
import type { IBinaryFile } from "Conversations/IBinaryFile";
export class Attachment {
export class Attachment implements IBinaryFile {
public fileName: string;
public mimeType: string;
@ -41,6 +42,14 @@ export class Attachment {
return this.base64;
}
public getStoragePath(): string | undefined {
return this.filePath;
}
public setStoragePath(path: string): void {
this.filePath = path;
}
public getFileID(provider: AIProvider): string | undefined {
return this.fileID[provider];
}

View file

@ -3,6 +3,7 @@ import { ApiErrorType } from "Types/ApiError";
import type { Attachment } from "./Attachment";
import type { Reference } from "./Reference";
import { Copy } from "Enums/Copy";
import type { Artifact } from "./Artifact";
type ConversationContentInit = {
role: Role;
@ -11,6 +12,7 @@ type ConversationContentInit = {
displayContent?: string;
toolCall?: string;
functionResponse?: string;
artifacts?: Artifact[];
attachments?: Attachment[];
references?: Reference[];
shouldDisplayContent?: boolean;
@ -30,6 +32,7 @@ export class ConversationContent {
public displayContent: string | undefined;
public toolCall: string | undefined;
public functionResponse: string | undefined;
public artifacts: Artifact[];
public attachments: Attachment[];
public references: Reference[];
public shouldDisplayContent: boolean;
@ -47,6 +50,7 @@ export class ConversationContent {
* @param init.displayContent - Display content is used when content needs to be formatted differently when displayed versus as a prompt
* @param init.toolCall - 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.artifacts - Array of artefacts that track edits to files made by the agent during the turn
* @param init.attachments - Array of file attachments associated with this message (defaults to empty array)
* @param init.references - Array of file references, used to display attachment's to the user associated with attachments
* @param init.shouldDisplayContent - Whether this content should be displayed in the UI (defaults to true, false for system-generated messages)
@ -62,6 +66,7 @@ export class ConversationContent {
this.displayContent = init.displayContent;
this.toolCall = init.toolCall;
this.functionResponse = init.functionResponse;
this.artifacts = init.artifacts ?? [];
this.attachments = init.attachments ?? [];
this.references = init.references ?? [];
this.shouldDisplayContent = init.shouldDisplayContent ?? true;
@ -84,6 +89,7 @@ export class ConversationContent {
displayContent?: string;
toolCall?: string;
functionResponse?: string;
artifacts?: unknown[];
attachments?: unknown[];
references?: unknown[];
shouldDisplayContent?: boolean;
@ -103,6 +109,7 @@ export class ConversationContent {
(!("displayContent" in data) || typeof data.displayContent === "string") &&
(!("toolCall" in data) || typeof data.toolCall === "string") &&
(!("functionResponse" in data) || typeof data.functionResponse === "string") &&
(!("artifacts" in data) || Array.isArray(data.artifacts)) &&
(!("attachments" in data) || Array.isArray(data.attachments)) &&
(!("references" in data) || Array.isArray(data.references)) &&
(!("shouldDisplayContent" in data) || typeof data.shouldDisplayContent === "boolean") &&

View file

@ -0,0 +1,5 @@
export interface IBinaryFile {
base64: string | undefined;
getStoragePath(): string | undefined;
setStoragePath(path: string): void;
}

View file

@ -150,7 +150,7 @@ export enum Copy {
ButtonApprove = "Approve",
ButtonReject = "Reject",
ButtonSuggest = "Suggest",
ButtonDiscuss = "Discuss",
// Agent file message
AttachedFile = `The file {fileName} is attached and its full contents follow below. This is the actual content of the file — read it directly to answer the user. This attachment may be a file the user uploaded to the chat, or a vault file you retrieved with a tool; either way, the content below is authoritative and you do NOT need to read or fetch this file again.`,

View file

@ -151,8 +151,9 @@ export enum FileType {
}
export function toFileType(fileType: string): FileType {
if (isKnownFileType(fileType)) {
return fileType;
const normalized = fileType.startsWith('.') ? fileType.slice(1) : fileType;
if (isKnownFileType(normalized)) {
return normalized;
}
return FileType.UNKNOWN;
}

View file

@ -3,6 +3,7 @@ export enum Path {
VaultkeeperAIDir = "Vaultkeeper AI",
Conversations = `${Path.VaultkeeperAIDir}/Conversations`,
Attachments = `${Path.Conversations}/Attachments`,
Artifacts = `${Path.Conversations}/Artifacts`,
UserInstructions = `${Path.VaultkeeperAIDir}/User Instructions`,
ExampleUserInstructions = `${Path.UserInstructions}/EXAMPLE_INSTRUCTIONS.md`,
Memories = `${Path.VaultkeeperAIDir}/Memories.md`

View file

@ -6,7 +6,7 @@ import { AIToolResponse } from "AIClasses/ToolDefinitions/AIToolResponse";
import { AIToolCall } from "AIClasses/AIToolCall";
import type { ISearchMatch } from "../../Types/SearchTypes";
import { AbortService } from "../AbortService";
import { normalizePath, TAbstractFile, TFile } from "obsidian";
import { arrayBufferToBase64, normalizePath, TAbstractFile, TFile } from "obsidian";
import { Exception } from "Helpers/Exception";
import { Copy } from "Enums/Copy";
import { pathExtname, replaceCopy } from "Helpers/Helpers";
@ -16,7 +16,7 @@ import type { WebViewerService } from "Services/WebViewerService";
import { AIToolResponsePayload } from "AIClasses/ToolDefinitions/AIToolResponsePayload";
import { Attachment } from "Conversations/Attachment";
import { isDocumentMimeType, MimeType } from "Enums/MimeType";
import { isTextFile, toFileType } from "Enums/FileType";
import { isBinaryFile, isTextFile, toFileType } from "Enums/FileType";
import { FileTypeToMimeType } from "Enums/FileTypeMimeTypeMapping";
import { StringTools } from "Helpers/StringTools";
import { AIToolDefinitions } from "AIClasses/ToolDefinitions/AIToolDefinitions";
@ -36,6 +36,8 @@ import {
DeleteVaultFolderArgsSchema,
MoveVaultFolderArgsSchema
} from "AIClasses/Schemas/AIToolSchemas";
import { Artifact } from "Conversations/Artifact";
import { extname } from "path-browserify";
export class AIToolService {
@ -337,23 +339,19 @@ export class AIToolService {
count: binaryResults.length
};
return new AIToolResponsePayload(response, attachments);
return new AIToolResponsePayload(response, [], attachments);
}
private async writeVaultFile(filePath: string, content: string): Promise<AIToolResponsePayload> {
const result = await this.fileSystemService.writeToFilePath(normalizePath(filePath), content);
if (result instanceof Error) {
return new AIToolResponsePayload({ success: false, error: result.message });
}
return new AIToolResponsePayload({ success: true });
return await this.asTrackedAction(filePath, async () => {
return await this.fileSystemService.writeToFilePath(normalizePath(filePath), content);
});
}
private async patchVaultFile(filePath: string, oldContent: string[], newContent: string[]): Promise<AIToolResponsePayload> {
const result = await this.fileSystemService.patchFileAtPath(normalizePath(filePath), oldContent, newContent);
if (result instanceof Error) {
return new AIToolResponsePayload({ success: false, error: result.message });
}
return new AIToolResponsePayload({ success: true });
return await this.asTrackedAction(filePath, async () => {
return await this.fileSystemService.patchFileAtPath(normalizePath(filePath), oldContent, newContent);
});
}
private async deleteVaultFiles(filePaths: string[], confirmation: boolean): Promise<AIToolResponsePayload> {
@ -361,15 +359,19 @@ export class AIToolService {
return new AIToolResponsePayload({ error: "Confirmation was false, no action taken" });
}
const results = await Promise.all(filePaths.map(async filePath => {
const result = await this.fileSystemService.deleteFile(filePath);
if (result instanceof Error) {
return { path: filePath, success: false, error: result.message };
}
return { path: filePath, success: true };
}));
const results: object[] = [];
const artifacts = await this.collectDeletionCandidatesArtifacts(filePaths);
return new AIToolResponsePayload({ results });
for (const filePath of filePaths) {
const deleteResult = await this.fileSystemService.deleteFile(filePath);
if (deleteResult instanceof Error) {
results.push({ path: filePath, success: false, error: deleteResult.message });
continue;
}
results.push({ path: filePath, success: true });
}
return new AIToolResponsePayload({ results }, artifacts);
}
private async moveVaultFiles(sourcePaths: string[], destinationPaths: string[]): Promise<AIToolResponsePayload> {
@ -401,10 +403,38 @@ export class AIToolService {
if (!confirmation) {
return new AIToolResponsePayload({ error: "Confirmation was false, no action taken" });
}
const contents = await this.fileSystemService.listDirectoryContents(path, true);
const filePaths = contents.filter(content => content instanceof TFile).map(file => file.path);
const artifacts = await this.collectDeletionCandidatesArtifacts(filePaths);
const result = await this.fileSystemService.deleteFolder(path);
return result instanceof Error
? new AIToolResponsePayload({ path: path, success: false, error: result.message })
: new AIToolResponsePayload({ path: path, success: true });
? new AIToolResponsePayload({ path: path, success: false, error: result.message }, artifacts)
: new AIToolResponsePayload({ path: path, success: true }, artifacts);
}
private async collectDeletionCandidatesArtifacts(filePaths: string[]): Promise<Artifact[]> {
const artifacts: Artifact[] = [];
for (const filePath of filePaths) {
const fileType = toFileType(extname(filePath));
// Anything that isn't a note we will just store as a binary artifact
if (isBinaryFile(fileType) || isDocumentMimeType(FileTypeToMimeType[fileType])) {
const result = await this.fileSystemService.readBinaryFile(filePath);
if (result instanceof ArrayBuffer) {
artifacts.push(new Artifact(filePath, FileTypeToMimeType[fileType], "", "", arrayBufferToBase64(result)));
}
} else {
const result = await this.fileSystemService.readFilePath(filePath);
if (typeof result === "string") {
artifacts.push(new Artifact(filePath, FileTypeToMimeType[fileType], result, ""));
}
}
}
return artifacts;
}
private async moveVaultFolder(sourcePath: string, destinationPath: string): Promise<AIToolResponsePayload> {
@ -437,7 +467,7 @@ export class AIToolService {
return format === "text"
? new AIToolResponsePayload({ content: result })
: new AIToolResponsePayload({ success: true }, [new Attachment("screenshot.png", MimeType.IMAGE_PNG, result)]);
: new AIToolResponsePayload({ success: true }, [], [new Attachment("screenshot.png", MimeType.IMAGE_PNG, result)]);
}
private async readMemories(): Promise<AIToolResponsePayload> {
@ -460,4 +490,24 @@ export class AIToolService {
return new AIToolResponsePayload({ result: await this.memoriesService.updateMemories(content) });
}
private async asTrackedAction(filePath: string, action: () => Promise<TFile|Error|void>): Promise<AIToolResponsePayload> {
let preActionResult = await this.fileSystemService.readFilePath(filePath);
if (preActionResult instanceof Error) {
preActionResult = ""; // The file does not exist yet
}
const actionResult = await action();
if (actionResult instanceof Error) {
return new AIToolResponsePayload({ success: false, error: actionResult.message });
}
let postActionResult = actionResult ? await this.fileSystemService.readFile(actionResult) : "";
if (postActionResult instanceof Error) {
postActionResult = ""; // The file has been deleted
}
const fileType = toFileType(extname(filePath));
return new AIToolResponsePayload({ success: true },
[new Artifact(filePath, FileTypeToMimeType[fileType], preActionResult, postActionResult)]);
}
}

View file

@ -208,12 +208,18 @@ export abstract class BaseAgent {
return { toolCall: capturedToolCall, shouldContinue: capturedShouldContinue };
}
protected async performAITool(toolCall: AIToolCall): Promise<AIToolResponse> {
protected async performAITool(toolCall: AIToolCall, callbacks: IChatServiceCallbacks): Promise<AIToolResponse> {
const providerResult = await this.ai?.resolveToolCall?.(toolCall) ?? null;
if (providerResult !== null) {
return providerResult;
}
return this.aiToolService.performAITool(toolCall);
const result = await this.aiToolService.performAITool(toolCall);
for (const artifact of result.payload.artifacts) {
callbacks.onArtifactProduced(artifact);
}
return result;
}
private async withToolCallingDisabled<T>(callback: () => Promise<T>): Promise<T> {

View file

@ -73,7 +73,7 @@ export class ExecutionAgent extends BaseAgent {
this.debugService?.log("ExecutionAgent", `Executing function: ${toolCall.name}`);
this.updateThought(toolCall, callbacks);
const functionResponse = await this.performAITool(toolCall);
const functionResponse = await this.performAITool(toolCall, callbacks);
this.conversation.addFunctionResponse(functionResponse);
return { shouldExit: false };
});

View file

@ -79,7 +79,7 @@ export class MainAgent extends BaseAgent {
this.debugService?.log("MainAgent", `Executing function: ${toolCall.name}`);
this.updateThought(toolCall, callbacks);
const functionResponse = await this.performAITool(toolCall);
const functionResponse = await this.performAITool(toolCall, callbacks);
conversation.addFunctionResponse(functionResponse);
return { shouldExit: false };
});

View file

@ -220,7 +220,7 @@ export class OrchestrationAgent extends BaseAgent {
isAITool(toolCallName, AITool.ListVaultFiles)) {
this.debugService?.log("Orchestration", `Vault tool called for recovery: ${toolCallName}`);
this.updateThought(toolCall, callbacks);
const toolResponse = await this.performAITool(toolCall);
const toolResponse = await this.performAITool(toolCall, callbacks);
planningConversation.addFunctionResponse(toolResponse);
return Promise.resolve({ shouldExit: false });
}

View file

@ -90,7 +90,7 @@ export class PlanningAgent extends BaseAgent {
}
this.updateThought(toolCall, callbacks);
const functionResponse = await this.performAITool(toolCall);
const functionResponse = await this.performAITool(toolCall, callbacks);
conversation.addFunctionResponse(functionResponse);
return { shouldExit: false };
});

View file

@ -44,6 +44,7 @@ export class QuickAgent extends BaseAgent {
onStreamingUpdate: () => {},
onThoughtUpdate: () => {},
onToolCallStarted: () => {},
onArtifactProduced: () => {},
onPlanningStarted: () => {},
onPlanningFinished: () => {},
onUserQuestion: async () => new Promise<string>(() => {}),

View file

@ -18,12 +18,14 @@ import type { ExecutionPlan } from "Types/ExecutionPlan";
import type { MainAgent } from "./AIServices/MainAgent";
import type { ChatMode } from "Enums/ChatMode";
import type { PlanApprovalResponse } from "Types/PlanApprovalResponse";
import type { Artifact } from "Conversations/Artifact";
export interface IChatServiceCallbacks {
onSubmit: () => void;
onStreamingUpdate: () => void;
onThoughtUpdate: (thought: string | null) => void;
onToolCallStarted: (toolName: string) => void;
onArtifactProduced: (artifact: Artifact) => void;
onPlanningStarted: () => void;
onPlanningFinished: () => void;
onUserQuestion: (question: string) => Promise<string>;
@ -125,13 +127,12 @@ export class ChatService {
}
} finally {
this.eventService.trigger(Event.DiffClosed);
await this.saveConversation(conversation);
callbacks.onThoughtUpdate(null);
callbacks.onComplete();
if (this.semaphoreHeld) {
this.semaphoreHeld = false;
this.semaphore.release();
}
callbacks.onThoughtUpdate(null);
callbacks.onComplete();
}
}
@ -146,9 +147,6 @@ export class ChatService {
}
private async saveConversation(conversation: Conversation) {
const result = await this.conversationService.saveConversation(conversation);
if (result instanceof Error) {
new Notice(`Failed to save conversation data for '${conversation.title}'`);
}
await this.conversationService.saveConversation(conversation);
}
}

View file

@ -8,7 +8,9 @@ import { Attachment } from "Conversations/Attachment";
import { Exception } from "Helpers/Exception";
import type { IAIFileService } from "AIClasses/IAIFileService";
import { Reference } from "Conversations/Reference";
import { arrayBufferToBase64 } from "obsidian";
import { Artifact } from "Conversations/Artifact";
import type { IBinaryFile } from "Conversations/IBinaryFile";
import { arrayBufferToBase64, Notice } from "obsidian";
import { StringTools } from "Helpers/StringTools";
export class ConversationFileSystemService {
@ -32,9 +34,9 @@ export class ConversationFileSystemService {
return `${Path.Conversations}/${conversation.title}.json`;
}
public async saveConversation(conversation: Conversation): Promise<string | Error> {
public async saveConversation(conversation: Conversation): Promise<void> {
if (this.isDeleted) {
return ""; // Return empty string to indicate silent skip (not an error)
return;
}
if (!this.currentConversationPath) {
@ -43,21 +45,19 @@ export class ConversationFileSystemService {
// 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;
return;
}
}
conversation.updated = new Date();
// Save attachment files and update filePaths
// Save binary files (attachments and artifacts) and update their storage paths
for (const content of conversation.contents) {
for (const attachment of content.attachments) {
if (!attachment.filePath && attachment.base64) {
const filePath = await this.saveAttachmentFile(attachment);
if (!(filePath instanceof Error)) {
attachment.filePath = filePath.replace(`${Path.Conversations}/`, '');
}
}
await this.saveBinaryFile(attachment, Path.Attachments);
}
for (const artifact of content.artifacts) {
await this.saveBinaryFile(artifact, Path.Artifacts);
}
}
@ -73,6 +73,13 @@ export class ConversationFileSystemService {
displayContent: content.displayContent,
toolCall: content.toolCall,
functionResponse: content.functionResponse,
artifacts: content.artifacts.map(artifact => ({
filePath: artifact.filePath,
mimeType: artifact.mimeType,
originalContent: artifact.originalContent,
updatedContent: artifact.updatedContent,
artifactPath: artifact.artifactPath
})),
attachments: content.attachments.map(att => ({
fileName: att.fileName,
mimeType: att.mimeType,
@ -90,10 +97,8 @@ export class ConversationFileSystemService {
const result = await this.fileSystemService.writeObjectToFile(this.currentConversationPath, conversationData, true, false);
if (result instanceof Error) {
return result;
new Notice(`Failed to save conversation data for '${conversation.title}'`);
}
return this.currentConversationPath;
}
public resetCurrentConversation() {
@ -137,6 +142,7 @@ export class ConversationFileSystemService {
// Queue garbage collection after AI file deletion
this.deletionQueue = this.deletionQueue.then(async () => {
await this.garbageCollectAttachments();
await this.garbageCollectArtifacts();
});
}
@ -205,6 +211,57 @@ export class ConversationFileSystemService {
}
}
public async garbageCollectArtifacts(): Promise<void | Error> {
try {
// 1. Get all artifact files
const artifactFiles = await this.fileSystemService.listFilesInDirectory(
Path.Artifacts,
false,
true
);
if (artifactFiles.length === 0) {
return;
}
// 2. Build reference count map
const referenceCount = new Map<string, number>();
const conversations = await this.getAllConversations();
for (const conversation of conversations) {
for (const content of conversation.contents) {
for (const artifact of content.artifacts) {
if (artifact.artifactPath) {
const count = referenceCount.get(artifact.artifactPath) || 0;
referenceCount.set(artifact.artifactPath, count + 1);
}
}
}
}
// 3. Delete unreferenced files
for (const file of artifactFiles) {
const relativePath = file.path.replace(`${Path.Conversations}/`, '');
const refCount = referenceCount.get(relativePath) || 0;
if (refCount === 0) {
const deleteResult = await this.fileSystemService.deleteFile(
file.path,
true,
false
);
if (deleteResult instanceof Error) {
Exception.log(deleteResult);
}
}
}
} catch (error) {
Exception.log(error);
return Exception.new(error);
}
}
public async updateConversationTitle(oldPath: string, newTitle: string): Promise<void | Error> {
const newPath = `${Path.Conversations}/${newTitle}.json`;
@ -219,30 +276,31 @@ export class ConversationFileSystemService {
}
}
private async saveAttachmentFile(attachment: Attachment): Promise<string | Error> {
const hash = await StringTools.computeSHA256Hash(attachment.base64);
private async saveBinaryFile(file: IBinaryFile, storageFolder: Path): Promise<void> {
if (file.getStoragePath() || !file.base64) {
return;
}
const hash = await StringTools.computeSHA256Hash(file.base64);
const fileName = `${hash}.bin`;
const filePath = `${Path.Attachments}/${fileName}`;
const filePath = `${storageFolder}/${fileName}`;
const exists = await this.fileSystemService.exists(filePath, true);
if (exists) {
return filePath;
if (!exists) {
const arrayBuffer = StringTools.toBuffer(file.base64);
const result = await this.fileSystemService.writeBinaryFile(filePath, arrayBuffer, true);
if (result instanceof Error) {
Exception.log(result);
return;
}
}
const arrayBuffer = StringTools.toBuffer(attachment.base64);
const result = await this.fileSystemService.writeBinaryFile(filePath, arrayBuffer, true);
if (result instanceof Error) {
Exception.log(result);
return filePath;
}
return filePath;
file.setStoragePath(filePath.replace(`${Path.Conversations}/`, ''));
}
private async loadAttachmentFile(filePath: string): Promise<string> {
const fullPath = `${Path.Conversations}/${filePath}`;
private async loadBinaryFile(storagePath: string): Promise<string> {
const fullPath = `${Path.Conversations}/${storagePath}`;
const arrayBuffer = await this.fileSystemService.readBinaryFile(fullPath, true);
if (arrayBuffer instanceof Error) {
@ -271,6 +329,7 @@ export class ConversationFileSystemService {
const contentPromises = result.contents.map(async content => {
const attachments = await this.deserializeAttachments(content.attachments);
const references = this.deserializeReferences(content.references);
const artifacts = await this.deserializeArtifacts(content.artifacts);
return new ConversationContent({
role: content.role,
@ -279,6 +338,7 @@ export class ConversationFileSystemService {
displayContent: content.displayContent,
toolCall: content.toolCall,
functionResponse: content.functionResponse,
artifacts: artifacts,
attachments: attachments,
references: references,
shouldDisplayContent: content.shouldDisplayContent,
@ -306,7 +366,7 @@ export class ConversationFileSystemService {
continue;
}
const base64 = await this.loadAttachmentFile(attachmentData.filePath);
const base64 = await this.loadBinaryFile(attachmentData.filePath);
if (!base64) {
Exception.warn(`Skipping attachment with missing file: ${attachmentData.fileName} (${attachmentData.filePath})`);
@ -327,6 +387,35 @@ export class ConversationFileSystemService {
return attachments;
}
private async deserializeArtifacts(artifactsData: unknown): Promise<Artifact[]> {
if (!Array.isArray(artifactsData)) {
return [];
}
const artifacts: Artifact[] = [];
for (const artifactData of artifactsData) {
if (!Artifact.isArtifactData(artifactData)) {
continue;
}
const base64 = artifactData.artifactPath
? await this.loadBinaryFile(artifactData.artifactPath)
: undefined;
artifacts.push(new Artifact(
artifactData.filePath,
artifactData.mimeType,
artifactData.originalContent,
artifactData.updatedContent,
base64,
artifactData.artifactPath
));
}
return artifacts;
}
private deserializeReferences(referencesData: unknown): Reference[] {
if (!Array.isArray(referencesData)) {
return [];

View file

@ -55,12 +55,8 @@ export class ConversationNamingService {
}
conversation.title = validatedName;
const saveResult = await this.conversationService.saveConversation(conversation);
await this.conversationService.saveConversation(conversation);
if (saveResult instanceof Error) {
Exception.throw(saveResult);
}
onNameChanged?.(conversation.title);
} catch (error) {
if (!AbortService.isAbortError(error)) {

View file

@ -48,7 +48,7 @@ export class FileSystemService {
if (file instanceof TFile) {
const arrayBuffer = await this.vaultService.readBinaryData(file, allowAccessToPluginRoot);
if (!arrayBuffer) {
return Exception.new(`Failed to read binary dta for: ${filePath}`);
return Exception.new(`Failed to read binary data for: ${filePath}`);
}
return arrayBuffer;
}

View file

@ -116,13 +116,15 @@ export class VaultService {
public async create(filePath: string, content: string, allowAccessToPluginRoot: boolean = false, requiresConfirmation: boolean = true): Promise<TFile | Error> {
filePath = this.sanitiserService.sanitize(filePath);
const fileExtension = pathExtname(filePath);
if (this.isExclusion(filePath, allowAccessToPluginRoot)) {
Exception.log(`Plugin attempted to create a file that is in the exclusion list: ${filePath}`);
return Exception.new(`Failed to create file, permission denied: ${filePath}`);
}
if (isFileType(pathExtname(filePath), FileType.PDF)) {
return Exception.new("Creating PDF files is not supported");
if (isBinaryFile(pathExtname(fileExtension)) || isDocumentFile(pathExtname(fileExtension))) {
return Exception.new(`Creating ${pathExtname(filePath)} files is not supported`);
}
const fileName = path.basename(filePath);
@ -134,13 +136,15 @@ export class VaultService {
public async modify(file: TFile, content: string, allowAccessToPluginRoot: boolean = false, requiresConfirmation: boolean = true): Promise<TFile | Error> {
const filePath = this.sanitiserService.sanitize(file.path);
const fileExtension = pathExtname(filePath);
if (this.isExclusion(file.path, allowAccessToPluginRoot)) {
Exception.log(`Plugin attempted to modify a file that is in the exclusion list: ${filePath}`);
return Exception.new(`File does not exist: ${filePath}`);
}
if (isFileType(file.extension.toLocaleLowerCase(), FileType.PDF)) {
return Exception.new("Modifying PDF files is not supported");
if (isBinaryFile(pathExtname(filePath)) || isDocumentFile(pathExtname(fileExtension))) {
return Exception.new(`Modifying ${pathExtname(filePath)} files is not supported`);
}
const currentContent = await this.read(file, allowAccessToPluginRoot);
@ -157,13 +161,15 @@ export class VaultService {
public async updateFrontmatter(file: TFile, mutate: (frontmatter: Record<string, unknown>) => void, allowAccessToPluginRoot: boolean = false): Promise<TFile | Error> {
const filePath = this.sanitiserService.sanitize(file.path);
const fileExtension = pathExtname(filePath);
if (this.isExclusion(file.path, allowAccessToPluginRoot)) {
Exception.log(`Plugin attempted to update frontmatter of a file that is in the exclusion list: ${filePath}`);
return Exception.new(`File does not exist: ${filePath}`);
}
if (isFileType(file.extension.toLocaleLowerCase(), FileType.PDF)) {
return Exception.new("Modifying PDF files is not supported");
if (isBinaryFile(pathExtname(filePath)) || isDocumentFile(pathExtname(fileExtension))) {
return Exception.new(`Modifying ${pathExtname(filePath)} files is not supported`);
}
try {
@ -183,8 +189,8 @@ export class VaultService {
return Exception.new(`File does not exist: ${filePath}`);
}
if (isFileType(pathExtname(filePath), FileType.PDF)) {
return Exception.new("Patching PDF files is not supported");
if (isBinaryFile(pathExtname(filePath))) {
return Exception.new(`Patching ${pathExtname(filePath)} files is not supported`);
}
if (oldContent.length !== newContent.length) {
@ -236,10 +242,6 @@ export class VaultService {
return currentContent;
}
if (isFileType(pathExtname(filePath), FileType.PDF)) {
await this.fileManager.trashFile(file);
}
return this.proposeChange(file.name, file.name, currentContent, "", requiresConfirmation, async () => {
await this.fileManager.trashFile(file);
});

View file

@ -134,40 +134,48 @@
font-weight: var(--font-semibold);
border-radius: var(--button-radius);
cursor: pointer;
border: none;
color: var(--text-on-accent);
border-width: var(--size-2-1);
border-style: solid;
background-color: var(--background-primary);
}
.diff-mobile-controls .diff-mobile-accept {
background-color: color-mix(
in srgb,
var(--color-green) 75%,
var(--background-primary) 25%
);
color: var(--color-green);
border-color: var(--color-green);
}
.diff-mobile-controls .diff-mobile-accept:active {
background-color: var(--color-green);
}
.diff-mobile-controls .diff-mobile-suggest {
background-color: var(--interactive-accent);
}
.diff-mobile-controls .diff-mobile-suggest:active {
background-color: var(--interactive-accent-hover);
}
.diff-mobile-controls .diff-mobile-reject {
background-color: color-mix(
in srgb,
var(--color-red) 75%,
var(--background-primary) 25%
var(--color-green) 25%,
white 10%
);
}
.diff-mobile-controls .diff-mobile-discuss {
color: var(--interactive-accent);
border-color: var(--interactive-accent);
}
.diff-mobile-controls .diff-mobile-discuss:active {
background-color: color-mix(
in srgb,
var(--interactive-accent) 25%,
white 10%
);
}
.diff-mobile-controls .diff-mobile-reject {
color: var(--color-red);
border-color: var(--color-red);
}
.diff-mobile-controls .diff-mobile-reject:active {
background-color: var(--color-red);
background-color: color-mix(
in srgb,
var(--color-red) 25%,
white 10%
);
}
/* Adjust diff height on mobile to account for buttons */

View file

@ -43,40 +43,48 @@
font-weight: var(--font-semibold);
border-radius: var(--button-radius);
cursor: pointer;
border: none;
color: var(--text-on-accent);
border-width: var(--size-2-1);
border-style: solid;
background-color: var(--background-primary);
}
.plan-approval-mobile-controls .plan-approval-mobile-approve {
background-color: color-mix(
in srgb,
var(--color-green) 75%,
var(--background-primary) 25%
);
color: var(--color-green);
border-color: var(--color-green);
}
.plan-approval-mobile-controls .plan-approval-mobile-approve:active {
background-color: var(--color-green);
}
.plan-approval-mobile-controls .plan-approval-mobile-suggest {
background-color: var(--interactive-accent);
}
.plan-approval-mobile-controls .plan-approval-mobile-suggest:active {
background-color: var(--interactive-accent-hover);
}
.plan-approval-mobile-controls .plan-approval-mobile-reject {
background-color: color-mix(
in srgb,
var(--color-red) 75%,
var(--background-primary) 25%
var(--color-green) 25%,
white 10%
);
}
.plan-approval-mobile-controls .plan-approval-mobile-discuss {
color: var(--interactive-accent);
border-color: var(--interactive-accent);
}
.plan-approval-mobile-controls .plan-approval-mobile-discuss:active {
background-color: color-mix(
in srgb,
var(--interactive-accent) 25%,
white 10%
);
}
.plan-approval-mobile-controls .plan-approval-mobile-reject {
color: var(--color-red);
border-color: var(--color-red);
}
.plan-approval-mobile-controls .plan-approval-mobile-reject:active {
background-color: var(--color-red);
background-color: color-mix(
in srgb,
var(--color-red) 25%,
white 10%
);
}
/* Adjust plan height on mobile to account for buttons */

View file

@ -116,11 +116,11 @@ export class DiffView extends ItemView {
});
acceptButton.setAttribute('aria-label', Copy.ButtonApprove);
const suggestButton = container.createEl('button', {
cls: 'diff-mobile-button diff-mobile-suggest',
text: Copy.ButtonSuggest
const discussButton = container.createEl('button', {
cls: 'diff-mobile-button diff-mobile-discuss',
text: Copy.ButtonDiscuss
});
suggestButton.setAttribute('aria-label', Copy.ButtonSuggest);
discussButton.setAttribute('aria-label', Copy.ButtonDiscuss);
const rejectButton = container.createEl('button', {
cls: 'diff-mobile-button diff-mobile-reject',
@ -133,7 +133,7 @@ export class DiffView extends ItemView {
await this.refocusMainView();
});
this.registerDomEvent(suggestButton, 'click', async () => {
this.registerDomEvent(discussButton, 'click', async () => {
await this.refocusMainView(true);
});

View file

@ -129,11 +129,11 @@ export class PlanApprovalView extends ItemView {
});
approveButton.setAttribute('aria-label', Copy.ButtonApprove);
const suggestButton = container.createEl('button', {
cls: 'plan-approval-mobile-button plan-approval-mobile-suggest',
text: Copy.ButtonSuggest
const discussButton = container.createEl('button', {
cls: 'plan-approval-mobile-button plan-approval-mobile-discuss',
text: Copy.ButtonDiscuss
});
suggestButton.setAttribute('aria-label', Copy.ButtonSuggest);
discussButton.setAttribute('aria-label', Copy.ButtonDiscuss);
const rejectButton = container.createEl('button', {
cls: 'plan-approval-mobile-button plan-approval-mobile-reject',
@ -146,7 +146,7 @@ export class PlanApprovalView extends ItemView {
await this.refocusMainView();
});
this.registerDomEvent(suggestButton, 'click', async () => {
this.registerDomEvent(discussButton, 'click', async () => {
await this.refocusMainView(true);
});

View file

@ -479,6 +479,7 @@ describe('Conversation', () => {
AITool.ReadVaultFiles,
new AIToolResponsePayload(
{ results: [{ path: 'image.png', error: 'File does not exist: image.png' }] },
[],
[validAttachment]
),
'tool-123'
@ -527,6 +528,7 @@ describe('Conversation', () => {
AITool.ReadVaultFiles,
new AIToolResponsePayload(
{ message: 'Files retrieved successfully. The contents of the files are included below.', count: 1 },
[],
[attachment]
),
'tool-123'
@ -550,6 +552,7 @@ describe('Conversation', () => {
AITool.ReadVaultFiles,
new AIToolResponsePayload(
{ message: 'Files retrieved successfully. The contents of the files are included below.', count: 1 },
[],
[docAttachment]
),
'tool-123'
@ -577,6 +580,7 @@ describe('Conversation', () => {
AITool.ReadVaultFiles,
new AIToolResponsePayload(
{ results: [{ type: 'md', path: 'notes.md', contents: '# Notes' }], message: 'The contents of the files are included below.' },
[],
[xlsxAttachment, imgAttachment]
),
'tool-123'

View file

@ -33,6 +33,7 @@ describe('AIControllerService - Integration Tests', () => {
onStreamingUpdate: vi.fn(),
onThoughtUpdate: vi.fn(),
onToolCallStarted: vi.fn(),
onArtifactProduced: vi.fn(),
onPlanningStarted: vi.fn(),
onPlanningFinished: vi.fn(),
onUserQuestion: vi.fn(),

View file

@ -35,6 +35,8 @@ describe('AIToolService - Integration Tests', () => {
searchVaultFiles: vi.fn(),
listFilesInDirectory: vi.fn(),
readFilePath: vi.fn(),
readFile: vi.fn(),
readBinaryFile: vi.fn(),
writeToFilePath: vi.fn(),
patchFileAtPath: vi.fn(),
deleteFile: vi.fn(),

View file

@ -105,9 +105,9 @@ describe('ConversationFileSystemService - Integration Tests', () => {
it('should save conversation with correct structure', async () => {
const conversation = createTestConversation('Test Save');
const path = await service.saveConversation(conversation);
await service.saveConversation(conversation);
expect(path).toBe('Vaultkeeper AI/Conversations/Test Save.json');
expect(service.getCurrentConversationPath()).toBe('Vaultkeeper AI/Conversations/Test Save.json');
expect(mockFileSystemService.writeObjectToFile).toHaveBeenCalledWith(
'Vaultkeeper AI/Conversations/Test Save.json',
expect.objectContaining({
@ -341,10 +341,9 @@ describe('ConversationFileSystemService - Integration Tests', () => {
await service.deleteCurrentConversation();
// Try to save the same conversation again (simulates what happens in finally block)
const result = await service.saveConversation(conversation);
await service.saveConversation(conversation);
// Should return empty string (silent skip), not save the file
expect(result).toBe('');
// Should silently skip, not save the file again
expect(mockFileSystemService.writeObjectToFile).toHaveBeenCalledTimes(1); // Only the initial save
});
});

View file

@ -30,6 +30,7 @@ describe('ExecutionAgent - Unit Tests', () => {
onStreamingUpdate: vi.fn(),
onThoughtUpdate: vi.fn(),
onToolCallStarted: vi.fn(),
onArtifactProduced: vi.fn(),
onPlanningStarted: vi.fn(),
onPlanningFinished: vi.fn(),
onUserQuestion: vi.fn().mockResolvedValue('User answer'),

View file

@ -39,6 +39,7 @@ describe('Multi-Agent Integration Tests', () => {
onStreamingUpdate: vi.fn(),
onThoughtUpdate: vi.fn(),
onToolCallStarted: vi.fn(),
onArtifactProduced: vi.fn(),
onPlanningStarted: vi.fn(),
onPlanningFinished: vi.fn(),
onUserQuestion: vi.fn().mockResolvedValue('User answer'),

View file

@ -32,6 +32,7 @@ describe('PlanningAgent - Unit Tests', () => {
onStreamingUpdate: vi.fn(),
onThoughtUpdate: vi.fn(),
onToolCallStarted: vi.fn(),
onArtifactProduced: vi.fn(),
onPlanningStarted: vi.fn(),
onPlanningFinished: vi.fn(),
onUserQuestion: vi.fn().mockResolvedValue('User answer'),