mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 16:30:27 +00:00
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
This commit is contained in:
parent
8347ddbdf2
commit
4a42b1f84c
10 changed files with 302 additions and 244 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<AIAgentPlugin>(Services.AIAgentPlugin);
|
||||
let streamingMarkdownService: StreamingMarkdownService = Resolve<StreamingMarkdownService>(Services.StreamingMarkdownService);
|
||||
|
||||
let messageElements: Map<string, HTMLElement> = new Map<string, HTMLElement>();
|
||||
let messageElements: { index: number, element: HTMLElement }[] = [];
|
||||
let lastProcessedContent: Map<string, string> = new Map<string, string>();
|
||||
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);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="chat-area" bind:this={chatContainer}>
|
||||
{#each messages as message}
|
||||
{#each messages as message, index}
|
||||
{#if !message.isFunctionCallResponse && message.content}
|
||||
<div class="message-container {message.role === Role.User ? Role.User : Role.Assistant}" use:messageContainerAction>
|
||||
<div class="message-container {message.role === Role.User ? Role.User : Role.Assistant}" use:trackingAction={index}>
|
||||
<div class="message-bubble {message.role === Role.User ? Role.User : Role.Assistant}">
|
||||
{#if message.role === Role.User}
|
||||
<div class="message-text-user fade-in-fast">{message.content}</div>
|
||||
|
|
@ -330,11 +195,15 @@
|
|||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<ChatAreaThought bind:thoughtElement thought={currentThought}/>
|
||||
{#if isSubmitting}
|
||||
<StreamingIndicator bind:streamingElement editModeActive={editModeActive}/>
|
||||
|
||||
{#if settled}
|
||||
<ChatAreaThought bind:thoughtElement thought={currentThought}/>
|
||||
{#if isSubmitting}
|
||||
<StreamingIndicator bind:streamingElement editModeActive={editModeActive}/>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div bind:this={chatAreaPaddingElement}></div>
|
||||
|
||||
{#if messages.length === 0}
|
||||
<div class="conversation-empty-state">
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ConversationFileSystemService>(Services.ConversationFileSystemService);
|
||||
this.vaultService = Resolve<VaultService>(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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
130
Services/SanitiserService.ts
Normal file
130
Services/SanitiserService.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<AIAgentPlugin>(Services.AIAgentPlugin, plugin);
|
||||
RegisterSingleton<FileManager>(Services.FileManager, plugin.app.fileManager);
|
||||
RegisterSingleton<StatusBarService>(Services.StatusBarService, new StatusBarService());
|
||||
RegisterSingleton<SanitiserService>(Services.SanitiserService, new SanitiserService());
|
||||
RegisterSingleton<VaultService>(Services.VaultService, new VaultService());
|
||||
RegisterSingleton<WorkSpaceService>(Services.WorkSpaceService, new WorkSpaceService());
|
||||
RegisterSingleton<FileSystemService>(Services.FileSystemService, new FileSystemService());
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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<AIAgentPlugin>(Services.AIAgentPlugin);
|
||||
this.fileManager = Resolve<FileManager>(Services.FileManager);
|
||||
this.vault = this.plugin.app.vault;
|
||||
this.sanitiserService = Resolve<SanitiserService>(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<string> {
|
||||
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<TFile> {
|
||||
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<TFolder> {
|
||||
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<TFile[]> {
|
||||
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: [] });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue