mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 06:42:03 +00:00
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:
parent
bfa8360037
commit
10ddb1da28
35 changed files with 497 additions and 192 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
53
Conversations/Artifact.ts
Normal 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")
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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") &&
|
||||
|
|
|
|||
5
Conversations/IBinaryFile.ts
Normal file
5
Conversations/IBinaryFile.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export interface IBinaryFile {
|
||||
base64: string | undefined;
|
||||
getStoragePath(): string | undefined;
|
||||
setStoragePath(path: string): void;
|
||||
}
|
||||
|
|
@ -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.`,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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)]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
});
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ export class QuickAgent extends BaseAgent {
|
|||
onStreamingUpdate: () => {},
|
||||
onThoughtUpdate: () => {},
|
||||
onToolCallStarted: () => {},
|
||||
onArtifactProduced: () => {},
|
||||
onPlanningStarted: () => {},
|
||||
onPlanningFinished: () => {},
|
||||
onUserQuestion: async () => new Promise<string>(() => {}),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
Loading…
Reference in a new issue