From 4a42b1f84cd440bac969a4e01157aec9ddf6cb9d Mon Sep 17 00:00:00 2001 From: Andrew Beal Date: Fri, 24 Oct 2025 17:25:19 +0100 Subject: [PATCH] Fix function call parsing and conversation loading issues - Extract functionCall/functionResponse objects properly in Gemini - Reset chat area state when loading conversations - Add SanitiserService for path validation - Prevent duplicate conversation names - Improve chat scrolling and padding logic - Clear streaming state on submission complete --- AIClasses/Gemini/Gemini.ts | 19 +- Components/ChatArea.svelte | 297 +++++++------------------- Components/ChatWindow.svelte | 48 +++-- Services/ChatService.ts | 5 + Services/ConversationNamingService.ts | 15 +- Services/SanitiserService.ts | 130 +++++++++++ Services/ServiceRegistration.ts | 2 + Services/Services.ts | 1 + Services/VaultService.ts | 24 ++- Views/MainView.ts | 5 +- 10 files changed, 302 insertions(+), 244 deletions(-) create mode 100644 Services/SanitiserService.ts diff --git a/AIClasses/Gemini/Gemini.ts b/AIClasses/Gemini/Gemini.ts index fe105c9..a7297f2 100644 --- a/AIClasses/Gemini/Gemini.ts +++ b/AIClasses/Gemini/Gemini.ts @@ -164,7 +164,14 @@ export class Gemini implements IAIClass { if (content.isFunctionCallResponse) { if (isValidJson(content.content)) { try { - parts.push(JSON.parse(content.content)); + const parsedContent = JSON.parse(content.content); + if (parsedContent.functionResponse) { + parts.push({ + functionResponse: parsedContent.functionResponse + }); + } else { + parts.push({ text: content.content }); + } } catch (error) { console.error("Failed to parse function response:", error); parts.push({ text: content.content }); @@ -181,7 +188,15 @@ export class Gemini implements IAIClass { if (content.isFunctionCall && content.functionCall.trim() !== "") { if (isValidJson(content.functionCall)) { try { - parts.push(JSON.parse(content.functionCall)); + const parsedContent = JSON.parse(content.functionCall); + if (parsedContent.functionCall) { + parts.push({ + functionCall: { + name: parsedContent.functionCall.name, + args: parsedContent.functionCall.args + } + }); + } } catch (error) { console.error("Failed to parse function call:", error); } diff --git a/Components/ChatArea.svelte b/Components/ChatArea.svelte index 5c936f1..5aa608f 100644 --- a/Components/ChatArea.svelte +++ b/Components/ChatArea.svelte @@ -8,7 +8,6 @@ import { Role } from "Enums/Role"; import type { ConversationContent } from "Conversations/ConversationContent"; import { tick } from "svelte"; - import type AIAgentPlugin from "main"; import { Copy } from "Enums/Copy"; import { Selector } from "Enums/Selector"; @@ -19,47 +18,66 @@ export let currentStreamingMessageId: string | null = null; export let editModeActive: boolean = false; - export function onFinishedSubmitting() { - setTimeout(() => { - if (lastAssistantMessageElement && lastAssistantMessageElement.offsetHeight < - chatContainer.offsetHeight - parseFloat(getComputedStyle(chatContainer).padding) * 2) { - // Recalculate padding when streaming ends to fix race condition with streaming indicator removal - tick().then(() => { - if (lastAssistantMessageElement) { - assistantMessageAction(lastAssistantMessageElement); - } - }); + let conversationKey = 0; - // use an interval to complete scrolling once the dom has finished updating - const scrollInterval: number = plugin.registerInterval(window.setInterval(() => { - tick().then(() => { - chatContainer.scroll({ top: chatContainer.scrollHeight, behavior: "smooth" }); - }); - }, 50)); - - setTimeout(() => { - if (scrollInterval) { - clearInterval(scrollInterval); - } - }, 1000); + export function resetChatArea() { + messageElements = []; + lastProcessedContent.clear(); + currentStreamFinalized = false; + conversationKey++; // Force complete re-render + if (chatAreaPaddingElement) { + chatAreaPaddingElement.style.padding = "0px"; } - }, 500); + chatContainer.scroll({ top: 0, behavior: "instant" }); } + export function scrollChatArea() { + tick().then(() => { + settled = false; + + const lastMessage = messageElements.sort((a, b) => a.index - b.index).last(); + if (!lastMessage || !chatAreaPaddingElement) { + if (chatAreaPaddingElement) { + chatAreaPaddingElement.style.padding = "0px"; + } + return; + } + + const gap = parseFloat(getComputedStyle(chatContainer).gap) || 0; + const paddingTop = parseFloat(getComputedStyle(chatContainer).paddingTop) || 0; + const paddingBottom = parseFloat(getComputedStyle(chatContainer).paddingBottom) || 0; + + let usedSpace = 0; + for (let i = messageElements.length - 1; i >= 0; i--) { + const messageElement = messageElements[i]; + usedSpace += messageElement.element.offsetHeight + gap; + if (messageElement.element.classList.contains(Role.User)) { + break; + } + } + const padding = chatContainer.offsetHeight - paddingTop - paddingBottom - usedSpace; + + chatAreaPaddingElement.style.padding = `${Math.max(0, padding / 2)}px`; + + tick().then(() => { + chatContainer.scroll({ top: chatContainer.scrollHeight, behavior: "smooth" }) + tick().then(() => settled = true); + }); + }); + } + + let settled: boolean = false; + let thoughtElement: HTMLElement | undefined; let streamingElement: HTMLElement | undefined; + let chatAreaPaddingElement: HTMLElement | undefined; - let plugin: AIAgentPlugin = Resolve(Services.AIAgentPlugin); let streamingMarkdownService: StreamingMarkdownService = Resolve(Services.StreamingMarkdownService); - let messageElements: Map = new Map(); + let messageElements: { index: number, element: HTMLElement }[] = []; let lastProcessedContent: Map = new Map(); let currentStreamFinalized: boolean = false; - let messagePadding: number = 0; - let staticMessagePadding: number = 0; - let lastAssistantMessageElement: HTMLElement | undefined; - function getGreetingByTime(): string { const hour = new Date().getHours(); @@ -81,31 +99,7 @@ } } - // Track streaming messages and update them incrementally - $: { - messages.forEach((message) => { - if (message.role !== Role.User) { - const messageId = message.timestamp.getTime().toString(); - const lastContent = lastProcessedContent.get(messageId) || ""; - - // Only update if content has changed - if (message.content !== lastContent) { - // Check if this specific message is currently streaming - const isCurrentlyStreaming = currentStreamingMessageId === messageId; - - updateMessageContent({ ...message, id: messageId, isCurrentlyStreaming }); - lastProcessedContent.set(messageId, message.content); - } - } - }); - } - function updateMessageContent(message: {id: string, content: string, role: string, isCurrentlyStreaming: boolean}) { - const element = messageElements.get(message.id); - if (!element) { - return; - } - if (message.isCurrentlyStreaming) { streamingMarkdownService.streamChunk(message.id, message.content); currentStreamFinalized = false; @@ -115,22 +109,6 @@ } } - function initializeMessageElement(messageId: string, element: HTMLElement) { - messageElements.set(messageId, element); - streamingMarkdownService.initializeStream(messageId, element); - } - - // Svelte action to handle element initialization - function streamingAction(element: HTMLElement, messageId: string) { - initializeMessageElement(messageId, element); - - return { - destroy() { - messageElements.delete(messageId); - } - }; - } - // Process static messages (user messages and initial load) function getStaticHTML(message: ConversationContent): string { if (message.role === Role.User) { @@ -159,160 +137,47 @@ return ""; // Streaming messages will be handled by the streaming service } - // Make sure to clean up when messages are removed + function streamingAction(element: HTMLElement, messageId: string) { + streamingMarkdownService.initializeStream(messageId, element); + } + + function trackingAction(element: HTMLElement, index: number) { + messageElements.push({ index: index, element: element }); + } + + // Track streaming messages and update them incrementally $: { - const currentMessageIds = new Set(messages.map((message) => message.timestamp.getTime().toString())); - - // Remove tracking for messages that no longer exist - for (const [id] of messageElements) { - if (!currentMessageIds.has(id)) { - messageElements.delete(id); - lastProcessedContent.delete(id); - } - } - } - - /** - * Chat area padding logic during message streaming - */ - - function messageContainerAction(element: HTMLElement) { - tick().then(() => { - if (element.classList.contains(Role.Assistant)) { - assistantMessageAction(element); - } - else { - userMessageAction(element); - } - }); - } - - function userMessageAction(element: HTMLElement) { - requestAnimationFrame(() => { - const paddingTop = parseFloat(getComputedStyle(chatContainer).paddingTop) || 0; - - if (element.offsetHeight > chatContainer.offsetHeight / 2) { - messagePadding = chatContainer.offsetHeight * 0.75; - staticMessagePadding = messagePadding; - } else { - staticMessagePadding = Math.max(0, - chatContainer.offsetHeight - - element.offsetHeight - - paddingTop); - - messagePadding = Math.max(0, - chatContainer.offsetHeight - - element.offsetHeight - - calculateStreamingThoughtSize() - - paddingTop); - } - chatContainer.style.paddingBottom = messagePadding == 0 ? "" : `${messagePadding}px`; - - tick().then(() => { - chatContainer.scroll({ top: chatContainer.scrollHeight, behavior: "smooth" }); - }); - }); - } - - function assistantMessageAction(element: HTMLElement) { - lastAssistantMessageElement = element; - - const resizeObserver = new ResizeObserver(() => { - requestAnimationFrame(() => { - const previousAssistantMessagesHeight = calculatePreviousAssistantMessagesHeight(element); - - messagePadding = Math.max(0, - staticMessagePadding - - previousAssistantMessagesHeight - - element.offsetHeight - - calculateStreamingThoughtSize() - - parseFloat(getComputedStyle(chatContainer).gap) || 0); - - chatContainer.style.paddingBottom = messagePadding == 0 ? "" : `${messagePadding}px`; - }); - }); - - resizeObserver.observe(element); - - return { - destroy() { - resizeObserver.disconnect(); - } - } - } - - function calculatePreviousAssistantMessagesHeight(currentElement: HTMLElement): number { - // Find the index of the current message in the messages array - const currentMessageIndex = messages.findIndex((message) => { - const messageId = message.timestamp.getTime().toString(); - const messageElement = messageElements.get(messageId); - return messageElement?.parentElement === currentElement; - }); - - if (currentMessageIndex === -1) { - return 0; - } - - // Walk backward from current message to find the last user message - let totalHeight = 0; - const gap = parseFloat(getComputedStyle(chatContainer).gap) || 0; - - for (let i = currentMessageIndex - 1; i >= 0; i--) { - const message = messages[i]; - - // Stop when we hit a user message - if (message.role === Role.User) { - break; - } - - // Sum up heights of all assistant messages before the current one - if (message.role === Role.Assistant && !message.isFunctionCall && !message.isFunctionCallResponse && message.content) { + messages.forEach((message) => { + if (message.role !== Role.User) { const messageId = message.timestamp.getTime().toString(); - const messageElement = messageElements.get(messageId); - if (messageElement) { - const containerElement = messageElement.parentElement; - if (containerElement) { - totalHeight += containerElement.offsetHeight + gap; + const lastContent = lastProcessedContent.get(messageId) || ""; + + // Only update if content has changed + if (message.content !== lastContent) { + // Check if this specific message is currently streaming + const isCurrentlyStreaming = currentStreamingMessageId === messageId; + + // Only process through streaming service if actively streaming + if (isCurrentlyStreaming) { + updateMessageContent({ ...message, id: messageId, isCurrentlyStreaming }); } + lastProcessedContent.set(messageId, message.content); } } - } - - return totalHeight; + }); } $: { - if (chatContainer && messages.length == 0) { - chatContainer.style.paddingBottom = ""; + if (messages.length === 0 && chatAreaPaddingElement) { + chatAreaPaddingElement.style.padding = "0px"; } } - - function calculateStreamingThoughtSize() { - const thoughtHeight = thoughtElement?.offsetHeight || 0; - const streamingHeight = streamingElement?.offsetHeight || 0; - - let thoughtMargins = 0; - if (thoughtElement) { - thoughtMargins = parseFloat(getComputedStyle(thoughtElement).marginTop) || 0 + - parseFloat(getComputedStyle(thoughtElement).marginBottom) || 0; - } - let indicatorMargins = 0; - if (streamingElement) { - indicatorMargins = parseFloat(getComputedStyle(streamingElement).marginTop) || 0 + - parseFloat(getComputedStyle(streamingElement).marginBottom) || 0; - } - - const gap = parseFloat(getComputedStyle(chatContainer).gap) || 0; - const gaps = thoughtElement && streamingElement ? 2 : thoughtElement || streamingElement ? 1 : 0; - - return thoughtHeight + streamingHeight + thoughtMargins + indicatorMargins + (gaps * gap); - }
- {#each messages as message} + {#each messages as message, index} {#if !message.isFunctionCallResponse && message.content} -
+
{#if message.role === Role.User}
{message.content}
@@ -330,11 +195,15 @@
{/if} {/each} - - - {#if isSubmitting} - + + {#if settled} + + {#if isSubmitting} + + {/if} {/if} + +
{#if messages.length === 0}
diff --git a/Components/ChatWindow.svelte b/Components/ChatWindow.svelte index a98f961..b811ef6 100644 --- a/Components/ChatWindow.svelte +++ b/Components/ChatWindow.svelte @@ -30,7 +30,7 @@ let editModeActive = false; let currentStreamingMessageId: string | null = null; - let conversation = new Conversation(); + let conversation: Conversation = new Conversation(); let currentThought: string | null = null; @@ -40,6 +40,10 @@ }); } + export function resetChatArea() { + chatArea.resetChatArea(); + } + onMount(() => { if (chatContainer) { plugin.registerDomEvent(chatContainer, 'click', handleLinkClick); @@ -85,7 +89,7 @@ chatService.stop(); currentThought = null; isSubmitting = false; - chatArea.onFinishedSubmitting(); + chatArea.scrollChatArea(); } async function handleSubmit() { @@ -99,15 +103,17 @@ return; } - isSubmitting = true; const currentRequest = userRequest; textareaElement.value = ""; userRequest = ""; autoResize(); - scrollToBottom(); await chatService.submit(conversation, editModeActive, currentRequest, { + onSubmit: () => { + chatArea.scrollChatArea(); + isSubmitting = true; + }, onStreamingUpdate: (streamingId) => { conversation = conversation; currentStreamingMessageId = streamingId; @@ -117,7 +123,7 @@ }, onComplete: () => { isSubmitting = false; - chatArea.onFinishedSubmitting(); + chatArea.scrollChatArea(); chatService.updateTokenDisplay(conversation); } }); @@ -141,17 +147,6 @@ } } - function scrollToBottom() { - tick().then(() => { - if (chatContainer) { - chatContainer.scroll({ - top: chatContainer.scrollHeight, - behavior: 'smooth' - }); - } - }); - } - $: if (submitButton) { setIcon(submitButton, isSubmitting ? "square" : "send-horizontal"); } @@ -167,13 +162,20 @@ } $: if ($conversationStore.conversationToLoad) { - const { conversation: loadedConversation, filePath } = $conversationStore.conversationToLoad; - conversation = loadedConversation; - conversationService.setCurrentConversationPath(filePath); - chatService.onNameChanged?.(loadedConversation.title); - chatService.updateTokenDisplay(loadedConversation); - conversationStore.clearLoadFlag(); - scrollToBottom(); + conversation.contents = []; + chatArea.resetChatArea(); + + tick().then(() => { + if ($conversationStore.conversationToLoad) { + const { conversation: loadedConversation, filePath } = $conversationStore.conversationToLoad; + conversation = loadedConversation; + conversationService.setCurrentConversationPath(filePath); + chatService.onNameChanged?.(loadedConversation.title); + chatService.updateTokenDisplay(loadedConversation); + conversationStore.clearLoadFlag(); + chatArea.scrollChatArea(); + } + }); } $: if ($conversationStore.shouldDeactivateEditMode) { diff --git a/Services/ChatService.ts b/Services/ChatService.ts index 8763beb..c2565d2 100644 --- a/Services/ChatService.ts +++ b/Services/ChatService.ts @@ -14,6 +14,7 @@ import { Role } from "Enums/Role"; import type { AIFunctionCall } from "AIClasses/AIFunctionCall"; export interface ChatServiceCallbacks { + onSubmit: () => void; onStreamingUpdate: (streamingMessageId: string | null) => void; onThoughtUpdate: (thought: string | null) => void; onComplete: () => void; @@ -64,6 +65,8 @@ export class ChatService { conversation.contents.push(new ConversationContent(Role.User, userRequest)); this.conversationService.saveConversation(conversation); + + callbacks.onSubmit(); callbacks.onStreamingUpdate(null); if (conversation.contents.length === 1) { @@ -189,6 +192,8 @@ export class ChatService { callbacks.onStreamingUpdate(aiMessage.timestamp.getTime().toString()); } + callbacks.onStreamingUpdate(null); + return { functionCall: capturedFunctionCall, shouldContinue: capturedShouldContinue }; } } diff --git a/Services/ConversationNamingService.ts b/Services/ConversationNamingService.ts index 41fe4ae..f043092 100644 --- a/Services/ConversationNamingService.ts +++ b/Services/ConversationNamingService.ts @@ -3,13 +3,17 @@ import { Services } from "./Services"; import type { IConversationNamingService } from "AIClasses/IConversationNamingService"; import type { ConversationFileSystemService } from "./ConversationFileSystemService"; import type { Conversation } from "Conversations/Conversation"; +import type { VaultService } from "./VaultService"; +import { Path } from "Enums/Path"; export class ConversationNamingService { private namingProvider: IConversationNamingService | undefined; private conversationService: ConversationFileSystemService; + private vaultService: VaultService; constructor() { this.conversationService = Resolve(Services.ConversationFileSystemService); + this.vaultService = Resolve(Services.VaultService); } public resolveNamingProvider() { @@ -49,7 +53,14 @@ export class ConversationNamingService { } private validateName(generatedName: string): string { - const cleanedTitle = generatedName.trim().replace(/^["']|["']$/g, ""); - return cleanedTitle.split(/\s+/).slice(0, 6).join(" "); + let cleanedTitle = generatedName.trim().replace(/^["']|["']$/g, "").split(/\s+/).slice(0, 6).join(" "); + + let index = 1; + let availableTitle = cleanedTitle; + while (this.vaultService.exists(`${Path.Conversations}/${availableTitle}.json`, true)) { + availableTitle = `${cleanedTitle}(${index})`; + index++; + } + return availableTitle; } } diff --git a/Services/SanitiserService.ts b/Services/SanitiserService.ts new file mode 100644 index 0000000..27d48af --- /dev/null +++ b/Services/SanitiserService.ts @@ -0,0 +1,130 @@ +export interface SanitizeOptions { + replacement?: string; + separator?: string; +} + +export class SanitiserService { + // Regular expressions for different character classes + private readonly illegalRe = /[\?<>\\:\*\|"]/g; + private readonly controlRe = /[\x00-\x1f\x80-\x9f]/g; + private readonly reservedRe = /^\.+$/; + private readonly windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i; + private readonly windowsTrailingRe = /[\. ]+$/; + + constructor() {} + + /** + * Sanitizes a file path with directories, removing illegal characters and ensuring cross-platform compatibility + * @param input - The file path to sanitize + * @param options - Optional configuration for replacement character and output separator + * @returns Sanitized file path + */ + public sanitize(input: string, options: SanitizeOptions = {}): string { + // Type check + if (typeof input !== 'string') { + throw new Error('Input must be a string'); + } + + // Default options + const replacement = options.replacement || ''; + const outputSeparator = options.separator || '/'; + + // Detect if this is an absolute path + const isAbsolute = input.startsWith('/') || /^[a-zA-Z]:[\\\/]/.test(input); + + // Detect Windows drive letter (e.g., C:) + const driveMatch = input.match(/^([a-zA-Z]:)[\\\/]/); + const driveLetter = driveMatch ? driveMatch[1] : ''; + + // Split by both forward and back slashes + // This handles mixed separators like: /home/user\docs/file.txt + let segments = input.split(/[\\\/]+/); + + // Remove empty segments (from leading/trailing slashes or multiple consecutive slashes) + // But keep track of whether we started with a slash + segments = segments.filter((seg, index) => { + // Keep the first segment even if empty (for absolute paths like /home/...) + if (index === 0 && seg === '' && isAbsolute && !driveLetter) { + return true; + } + return seg !== ''; + }); + + // Sanitize each segment + const sanitizedSegments = segments.map((segment, index) => { + // Don't sanitize the drive letter (first segment if it's something like "C:") + if (index === 0 && driveLetter && segment === driveLetter.replace(':', '')) { + return driveLetter; + } + + // For the first empty segment of an absolute path, keep it empty + if (index === 0 && segment === '' && isAbsolute) { + return ''; + } + + return this.sanitizeSegment(segment, replacement); + }); + + // Rejoin with the desired separator + let result = sanitizedSegments.join(outputSeparator); + + // For absolute Unix paths, ensure leading slash + if (isAbsolute && !driveLetter && !result.startsWith(outputSeparator)) { + result = outputSeparator + result; + } + + // Truncate the entire path if needed (paths can be up to 4096 bytes on most systems) + // But for individual filenames, most systems have a 255-byte limit + // We'll apply the 255-byte limit to the filename part only + const lastSeparatorIndex = result.lastIndexOf(outputSeparator); + if (lastSeparatorIndex !== -1) { + const directory = result.substring(0, lastSeparatorIndex + 1); + const filename = result.substring(lastSeparatorIndex + 1); + const truncatedFilename = this.truncateToByteLength(filename, 255); + result = directory + truncatedFilename; + } else { + // If there's no separator, the whole thing is a filename + result = this.truncateToByteLength(result, 255); + } + + return result; + } + + /** + * Sanitizes a single path segment (filename or directory name) + */ + private sanitizeSegment(segment: string, replacement: string): string { + if (!segment || segment === '') { + return segment; + } + + let sanitized = segment + .replace(this.illegalRe, replacement) + .replace(this.controlRe, replacement) + .replace(this.reservedRe, replacement) + .replace(this.windowsReservedRe, replacement) + .replace(this.windowsTrailingRe, replacement); + + return sanitized; + } + + /** + * Truncates a string to a maximum byte length while preserving UTF-8 character integrity + * @param str - String to truncate + * @param maxBytes - Maximum byte length + * @returns Truncated string + */ + private truncateToByteLength(str: string, maxBytes: number): string { + const encoder = new TextEncoder(); + const decoder = new TextDecoder('utf-8'); + + const encoded = encoder.encode(str); + + if (encoded.length <= maxBytes) { + return str; + } + + const truncated = encoded.slice(0, maxBytes); + return decoder.decode(truncated); + } +} diff --git a/Services/ServiceRegistration.ts b/Services/ServiceRegistration.ts index f52585e..6211396 100644 --- a/Services/ServiceRegistration.ts +++ b/Services/ServiceRegistration.ts @@ -28,11 +28,13 @@ import { ClaudeConversationNamingService } from "AIClasses/Claude/ClaudeConversa import { Claude } from "AIClasses/Claude/Claude"; import { OpenAIConversationNamingService } from "AIClasses/OpenAI/OpenAIConversationNamingService"; import { OpenAI } from "AIClasses/OpenAI/OpenAI"; +import { SanitiserService } from "./SanitiserService"; export function RegisterDependencies(plugin: AIAgentPlugin) { RegisterSingleton(Services.AIAgentPlugin, plugin); RegisterSingleton(Services.FileManager, plugin.app.fileManager); RegisterSingleton(Services.StatusBarService, new StatusBarService()); + RegisterSingleton(Services.SanitiserService, new SanitiserService()); RegisterSingleton(Services.VaultService, new VaultService()); RegisterSingleton(Services.WorkSpaceService, new WorkSpaceService()); RegisterSingleton(Services.FileSystemService, new FileSystemService()); diff --git a/Services/Services.ts b/Services/Services.ts index 562de84..474a0e5 100644 --- a/Services/Services.ts +++ b/Services/Services.ts @@ -13,6 +13,7 @@ export class Services { static AIFunctionDefinitions = Symbol("AIFunctionDefinitions"); static AIFunctionService = Symbol("AIFunctionService"); static ChatService = Symbol("ChatService"); + static SanitiserService = Symbol("SanitiserService"); // interfaces static IAIClass = Symbol("IAIClass"); diff --git a/Services/VaultService.ts b/Services/VaultService.ts index 0c6f4b6..c2215a7 100644 --- a/Services/VaultService.ts +++ b/Services/VaultService.ts @@ -5,6 +5,7 @@ import type AIAgentPlugin from "main"; import { Path } from "Enums/Path"; import { escapeRegex, randomSample } from "Helpers/Helpers"; import type { SearchMatch, SearchSnippet } from "../Helpers/SearchTypes"; +import type { SanitiserService } from "./SanitiserService"; /* This service protects the users vault through their exclusions. The plugin root is excluded by default */ export class VaultService { @@ -15,11 +16,13 @@ export class VaultService { private readonly plugin: AIAgentPlugin; private readonly fileManager: FileManager; private readonly vault: Vault; + private readonly sanitiserService: SanitiserService; public constructor() { this.plugin = Resolve(Services.AIAgentPlugin); this.fileManager = Resolve(Services.FileManager); this.vault = this.plugin.app.vault; + this.sanitiserService = Resolve(Services.SanitiserService); } public getMarkdownFiles(allowAccessToPluginRoot: boolean = false): TFile[] { @@ -27,6 +30,8 @@ export class VaultService { } public getAbstractFileByPath(filePath: string, allowAccessToPluginRoot: boolean = false): TAbstractFile | null { + filePath = this.sanitiserService.sanitize(filePath); + if (this.isExclusion(filePath, allowAccessToPluginRoot)) { console.error(`Plugin attempted to retrieve a file that is in the exclusions list: ${filePath}`); return null; @@ -34,6 +39,14 @@ export class VaultService { return this.vault.getAbstractFileByPath(filePath); } + public exists(filePath: string, allowAccessToPluginRoot: boolean = false): boolean { + if (this.isExclusion(filePath, allowAccessToPluginRoot)) { + console.error(`Plugin attempted to access a file that is in the exclusions list: ${filePath}`); + return false; + } + return this.vault.getAbstractFileByPath(filePath) instanceof TFile; + } + public async read(file: TFile, allowAccessToPluginRoot: boolean = false): Promise { if (this.isExclusion(file.path, allowAccessToPluginRoot)) { console.error(`Plugin attempted to read a file that is in the exclusions list: ${file.path}`); @@ -43,6 +56,8 @@ export class VaultService { } public async create(filePath: string, content: string, allowAccessToPluginRoot: boolean = false): Promise { + filePath = this.sanitiserService.sanitize(filePath); + if (this.isExclusion(filePath, allowAccessToPluginRoot)) { throw new Error(`Plugin attempted to create a file that is in the exclusion list: ${filePath}`); } @@ -73,6 +88,9 @@ export class VaultService { } public async move(sourcePath: string, destinationPath: string, allowAccessToPluginRoot: boolean = false): Promise<{ success: true } | { success: false, error: string }> { + sourcePath = this.sanitiserService.sanitize(sourcePath); + destinationPath = this.sanitiserService.sanitize(destinationPath); + if (this.isExclusion(sourcePath, allowAccessToPluginRoot)) { console.error(`Plugin attempted to move a file that is in the exclusions list: ${sourcePath}`) return { success: false, error: "Source file is in exclusion list" }; @@ -94,6 +112,8 @@ export class VaultService { } public async createFolder(path: string, allowAccessToPluginRoot: boolean = false): Promise { + path = this.sanitiserService.sanitize(path); + if (this.isExclusion(path, allowAccessToPluginRoot)) { throw new Error(`Plugin attempted to create a folder that is in the exclusion list: ${path}`); } @@ -101,7 +121,7 @@ export class VaultService { } public async listFilesInDirectory(dirPath: string, recursive: boolean = true, allowAccessToPluginRoot: boolean = false): Promise { - const dir: TAbstractFile | null = this.getAbstractFileByPath(dirPath, true); + const dir: TAbstractFile | null = this.getAbstractFileByPath(this.sanitiserService.sanitize(dirPath), true); if (dir == null || !(dir instanceof TFolder)) { return []; @@ -177,7 +197,7 @@ export class VaultService { for (const file of files) { if (file.basename.match(regex) && !results.some(result => result.file.basename === file.basename)) { - results.push({ file, snippets: [] }); + results.push({ file, snippets: [] }); } } diff --git a/Views/MainView.ts b/Views/MainView.ts index f7191fd..1ffb7cf 100644 --- a/Views/MainView.ts +++ b/Views/MainView.ts @@ -40,7 +40,10 @@ export class MainView extends ItemView { target: container, props: { leaf: this.leaf, - onNewConversation: () => this.input?.focusInput() + onNewConversation: () => { + this.input?.resetChatArea(); + this.input?.focusInput(); + } } });