From 679a94d173b3d47e8fcd191d6c434c1e320ea374 Mon Sep 17 00:00:00 2001 From: Andrew Beal Date: Wed, 29 Oct 2025 19:35:19 +0000 Subject: [PATCH] feature: implement fuzzysearch service refactor: rename type interfaces to use I prefix convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standardize naming by adding I prefix to all interface types across the codebase (StreamChunk → IStreamChunk, SearchMatch → ISearchMatch, etc.). Also rename conversationStore.ts to ConversationStore.ts for consistency. --- AIClasses/Claude/Claude.ts | 6 +- AIClasses/Gemini/Gemini.ts | 6 +- AIClasses/IAIClass.ts | 4 +- AIClasses/OpenAI/OpenAI.ts | 10 +- Components/ChatInput.svelte | 181 ++++++++- Components/ChatSearchResults.svelte | 48 +++ Components/ChatWindow.svelte | 6 +- Components/StreamingIndicator.svelte | 357 +++++++----------- Components/TopBar.svelte | 2 +- Conversations/ConversationContent.ts | 10 +- Enums/SearchTrigger.ts | 31 ++ Helpers/InputHelpers.ts | 142 +++++++ Helpers/SearchTypes.ts | 6 +- Modals/ConversationHistoryModal.ts | 6 +- Services/AIFunctionService.ts | 4 +- Services/ChatService.ts | 8 +- Services/FileSystemService.ts | 4 +- Services/SanitiserService.ts | 4 +- Services/ServiceRegistration.ts | 4 + Services/Services.ts | 4 + Services/StreamingMarkdownService.ts | 4 +- Services/StreamingService.ts | 6 +- Services/UserInputService.ts | 48 +++ Services/VaultService.ts | 26 +- ...versationStore.ts => ConversationStore.ts} | 4 +- Stores/SearchStateStore.ts | 118 ++++++ 26 files changed, 754 insertions(+), 295 deletions(-) create mode 100644 Components/ChatSearchResults.svelte create mode 100644 Enums/SearchTrigger.ts create mode 100644 Helpers/InputHelpers.ts create mode 100644 Services/UserInputService.ts rename Stores/{conversationStore.ts => ConversationStore.ts} (91%) create mode 100644 Stores/SearchStateStore.ts diff --git a/AIClasses/Claude/Claude.ts b/AIClasses/Claude/Claude.ts index a297bd1..2a0f85a 100644 --- a/AIClasses/Claude/Claude.ts +++ b/AIClasses/Claude/Claude.ts @@ -2,7 +2,7 @@ import { Resolve } from "Services/DependencyService"; import { Services } from "Services/Services"; import type { IAIClass } from "AIClasses/IAIClass"; import type { IPrompt } from "AIClasses/IPrompt"; -import { StreamingService, type StreamChunk } from "Services/StreamingService"; +import { StreamingService, type IStreamChunk } from "Services/StreamingService"; import type { Conversation } from "Conversations/Conversation"; import { AIProviderURL } from "Enums/ApiProvider"; import { AIFunctionCall } from "AIClasses/AIFunctionCall"; @@ -32,7 +32,7 @@ export class Claude implements IAIClass { public async* streamRequest( conversation: Conversation, allowDestructiveActions: boolean, abortSignal?: AbortSignal - ): AsyncGenerator { + ): AsyncGenerator { this.accumulatedFunctionName = null; this.accumulatedFunctionArgs = ""; this.accumulatedFunctionId = null; @@ -73,7 +73,7 @@ export class Claude implements IAIClass { ); } - private parseStreamChunk(chunk: string): StreamChunk { + private parseStreamChunk(chunk: string): IStreamChunk { try { const data = JSON.parse(chunk); diff --git a/AIClasses/Gemini/Gemini.ts b/AIClasses/Gemini/Gemini.ts index a7297f2..7f92f66 100644 --- a/AIClasses/Gemini/Gemini.ts +++ b/AIClasses/Gemini/Gemini.ts @@ -2,7 +2,7 @@ import { Resolve } from "Services/DependencyService"; import { Services } from "Services/Services"; import type { IAIClass } from "AIClasses/IAIClass"; import type { IPrompt } from "AIClasses/IPrompt"; -import { StreamingService, type StreamChunk } from "Services/StreamingService"; +import { StreamingService, type IStreamChunk } from "Services/StreamingService"; import type { Conversation } from "Conversations/Conversation"; import { Role } from "Enums/Role"; import { AIProviderURL } from "Enums/ApiProvider"; @@ -32,7 +32,7 @@ export class Gemini implements IAIClass { public async* streamRequest( conversation: Conversation, allowDestructiveActions: boolean, abortSignal?: AbortSignal - ): AsyncGenerator { + ): AsyncGenerator { // next request should use web search only (gemini api doesn't support custom tooling and grounding at the same time) const requestWebSearch = this.accumulatedFunctionName == this.REQUEST_WEB_SEARCH; @@ -92,7 +92,7 @@ export class Gemini implements IAIClass { ); } - private parseStreamChunk(chunk: string): StreamChunk { + private parseStreamChunk(chunk: string): IStreamChunk { try { const data = JSON.parse(chunk); diff --git a/AIClasses/IAIClass.ts b/AIClasses/IAIClass.ts index c57e460..a4de5ec 100644 --- a/AIClasses/IAIClass.ts +++ b/AIClasses/IAIClass.ts @@ -1,6 +1,6 @@ -import type { StreamChunk } from "Services/StreamingService"; +import type { IStreamChunk } from "Services/StreamingService"; import type { Conversation } from "Conversations/Conversation"; export interface IAIClass { - streamRequest(conversation: Conversation, allowDestructiveActions: boolean, abortSignal?: AbortSignal): AsyncGenerator; + streamRequest(conversation: Conversation, allowDestructiveActions: boolean, abortSignal?: AbortSignal): AsyncGenerator; } \ No newline at end of file diff --git a/AIClasses/OpenAI/OpenAI.ts b/AIClasses/OpenAI/OpenAI.ts index fb22d4f..89d0b87 100644 --- a/AIClasses/OpenAI/OpenAI.ts +++ b/AIClasses/OpenAI/OpenAI.ts @@ -2,7 +2,7 @@ import { Resolve } from "Services/DependencyService"; import { Services } from "Services/Services"; import type { IAIClass } from "AIClasses/IAIClass"; import type { IPrompt } from "AIClasses/IPrompt"; -import { StreamingService, type StreamChunk } from "Services/StreamingService"; +import { StreamingService, type IStreamChunk } from "Services/StreamingService"; import type { Conversation } from "Conversations/Conversation"; import { AIProviderURL } from "Enums/ApiProvider"; import { AIFunctionCall } from "AIClasses/AIFunctionCall"; @@ -12,7 +12,7 @@ import type { AIFunctionDefinitions } from "AIClasses/FunctionDefinitions/AIFunc import { Role } from "Enums/Role"; import { isValidJson } from "Helpers/Helpers"; -interface ToolCallAccumulator { +interface IToolCallAccumulator { id: string | null; name: string | null; arguments: string; @@ -29,7 +29,7 @@ export class OpenAI implements IAIClass { private readonly aiFunctionDefinitions: AIFunctionDefinitions = Resolve(Services.AIFunctionDefinitions); // OpenAI can have multiple tool calls, so we track them by index - private accumulatedToolCalls: Map = new Map(); + private accumulatedToolCalls: Map = new Map(); public constructor() { this.apiKey = this.plugin.settings.apiKey; @@ -37,7 +37,7 @@ export class OpenAI implements IAIClass { public async* streamRequest( conversation: Conversation, allowDestructiveActions: boolean, abortSignal?: AbortSignal - ): AsyncGenerator { + ): AsyncGenerator { // Reset tool call accumulation state for new request this.accumulatedToolCalls.clear(); @@ -150,7 +150,7 @@ export class OpenAI implements IAIClass { ); } - private parseStreamChunk(chunk: string): StreamChunk { + private parseStreamChunk(chunk: string): IStreamChunk { try { // OpenAI sends "[DONE]" as the final message, which is not valid JSON if (chunk.trim() === "[DONE]") { diff --git a/Components/ChatInput.svelte b/Components/ChatInput.svelte index decef3e..f44e067 100644 --- a/Components/ChatInput.svelte +++ b/Components/ChatInput.svelte @@ -1,14 +1,36 @@
+
+ +
+
+ import type { ISearchState } from 'Stores/SearchStateStore'; + import { tick } from 'svelte'; + + export let searchState: ISearchState; + + let contentDiv: HTMLDivElement; + let height = 0; + + $: searchState.results, updateHeight(); + + function updateHeight() { + tick().then(() => { + if (contentDiv) { + height = contentDiv.scrollHeight; + } + }); + } + + + +
+
+ {#each searchState.results as searchResult} +
{searchResult}
+ {/each} +
+
+ + \ No newline at end of file diff --git a/Components/ChatWindow.svelte b/Components/ChatWindow.svelte index 4453b20..4b671c4 100644 --- a/Components/ChatWindow.svelte +++ b/Components/ChatWindow.svelte @@ -4,7 +4,7 @@ import ChatArea from "./ChatArea.svelte"; import ChatInput from "./ChatInput.svelte"; import { tick, onMount } from "svelte"; - import { conversationStore } from "../Stores/conversationStore"; + import { conversationStore } from "../Stores/ConversationStore"; import { Conversation } from "Conversations/Conversation"; import type AIAgentPlugin from "main"; import { openPluginSettings } from "Helpers/Helpers"; @@ -87,7 +87,7 @@ chatArea.scrollChatArea("smooth"); } - async function handleSubmit(userRequest: string) { + async function handleSubmit(userRequest: string, formattedRequest: string) { focusInput(); if (handleNoApiKey()) { @@ -96,7 +96,7 @@ const currentRequest = userRequest; - await chatService.submit(conversation, editModeActive, currentRequest, { + await chatService.submit(conversation, editModeActive, currentRequest, formattedRequest, { onSubmit: () => { chatArea.scrollChatArea("smooth"); isSubmitting = true; diff --git a/Components/StreamingIndicator.svelte b/Components/StreamingIndicator.svelte index 2f6cf6a..c5c478b 100644 --- a/Components/StreamingIndicator.svelte +++ b/Components/StreamingIndicator.svelte @@ -3,262 +3,171 @@ export let editModeActive: boolean = false; -
- - - - - +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file diff --git a/Components/TopBar.svelte b/Components/TopBar.svelte index 3587ee3..52c61f6 100644 --- a/Components/TopBar.svelte +++ b/Components/TopBar.svelte @@ -4,7 +4,7 @@ import type AIAgentPlugin from '../main'; import { setIcon, type WorkspaceLeaf } from 'obsidian'; import { ConversationFileSystemService } from '../Services/ConversationFileSystemService'; - import { conversationStore } from '../Stores/conversationStore'; + import { conversationStore } from '../Stores/ConversationStore'; import type { ConversationHistoryModal } from 'Modals/ConversationHistoryModal'; import { openPluginSettings } from 'Helpers/Helpers'; import type { ChatService } from 'Services/ChatService'; diff --git a/Conversations/ConversationContent.ts b/Conversations/ConversationContent.ts index d660199..2a4a953 100644 --- a/Conversations/ConversationContent.ts +++ b/Conversations/ConversationContent.ts @@ -1,15 +1,17 @@ export class ConversationContent { role: string; - content: string + content: string; + promptContent: string; functionCall: string; timestamp: Date; isFunctionCall: boolean; isFunctionCallResponse: boolean; toolId?: string; - constructor(role: string, content: string = "", functionCall: string = "", timestamp: Date = new Date(), isFunctionCall = false, isFunctionCallResponse = false, toolId?: string) { + constructor(role: string, content: string = "", promptContent: string = "", functionCall: string = "", timestamp: Date = new Date(), isFunctionCall = false, isFunctionCallResponse = false, toolId?: string) { this.role = role; this.content = content; + this.promptContent = promptContent; this.functionCall = functionCall; this.timestamp = timestamp; this.isFunctionCall = isFunctionCall; @@ -18,19 +20,21 @@ export class ConversationContent { } public static isConversationContentData(data: unknown): data is { - role: string; content: string; functionCall: string; timestamp: string, isFunctionCall: boolean, isFunctionCallResponse: boolean, toolId?: string + role: string; content: string; promptContent: string; functionCall: string; timestamp: string, isFunctionCall: boolean, isFunctionCallResponse: boolean, toolId?: string } { return ( data !== null && typeof data === "object" && "role" in data && "content" in data && + "promptContent" in data && "functionCall" in data && "timestamp" in data && "isFunctionCall" in data && "isFunctionCallResponse" in data && typeof data.role === "string" && typeof data.content === "string" && + typeof data.promptContent === "string" && typeof data.functionCall === "string" && typeof data.timestamp === "string" && typeof data.isFunctionCall === "boolean" && diff --git a/Enums/SearchTrigger.ts b/Enums/SearchTrigger.ts new file mode 100644 index 0000000..8c1e65d --- /dev/null +++ b/Enums/SearchTrigger.ts @@ -0,0 +1,31 @@ +export enum SearchTrigger { + Tag = "#", + File = "@", + Folder = "/" +} + +export namespace SearchTrigger { + + const values: string[] = [ + SearchTrigger.Tag, + SearchTrigger.File, + SearchTrigger.Folder + ]; + + export function isSearchTrigger(input: string): boolean { + return values.includes(input); + } + + export function fromInput(input: string): SearchTrigger { + switch(input) { + case SearchTrigger.Tag: + return SearchTrigger.Tag; + case SearchTrigger.File: + return SearchTrigger.File; + case SearchTrigger.Folder: + return SearchTrigger.Folder; + default: + throw new Error(`Unknown search trigger: ${input}`); + } + } +} \ No newline at end of file diff --git a/Helpers/InputHelpers.ts b/Helpers/InputHelpers.ts new file mode 100644 index 0000000..17dfc9a --- /dev/null +++ b/Helpers/InputHelpers.ts @@ -0,0 +1,142 @@ +export function getCursorPosition(element: HTMLElement): number { + const selection = window.getSelection(); + + if (!selection || selection.rangeCount === 0) { + return 0; + } + + const range = selection.getRangeAt(0); + + if (!element.contains(range.commonAncestorContainer)) { + return 0; + } + + const preCaretRange = range.cloneRange(); + preCaretRange.selectNodeContents(element); + preCaretRange.setEnd(range.endContainer, range.endOffset); + + return preCaretRange.toString().length; +} + +export function setCursorPosition(element: HTMLElement, position: number): void { + const textContent = element.textContent || ""; + const clampedPosition = Math.max(0, Math.min(position, textContent.length)); + + const nodeAndOffset = getTextNodeAtPosition(element, clampedPosition); + + if (nodeAndOffset) { + const range = document.createRange(); + const selection = window.getSelection(); + + if (selection) { + range.setStart(nodeAndOffset.node, nodeAndOffset.offset); + range.setEnd(nodeAndOffset.node, nodeAndOffset.offset); + selection.removeAllRanges(); + selection.addRange(range); + } + } +} + +export function getCharacterAtPosition(position: number, element: HTMLElement): string { + const text = element.textContent || ""; + if (position < 0 || position > text.length) { + return ""; + } + return text.charAt(position); +} + +/** + * Checks if a keyboard key represents a printable character + * @param key The key from KeyboardEvent.key + * @param ctrlKey Whether Ctrl/Cmd is pressed + * @param metaKey Whether Meta/Cmd is pressed + * @returns true if the key is a printable character + */ +export function isPrintableKey(key: string, ctrlKey: boolean = false, metaKey: boolean = false): boolean { + // Control or meta keys are not printable + if (ctrlKey || metaKey) { + return false; + } + + // Single character keys are printable + if (key.length === 1) { + return true; + } + + // Special printable keys + return key === "Enter" || key === "Tab"; +} + +/** + * Checks if cursor is in a valid search zone (after the trigger position) + * @param currentPosition Current cursor position + * @param triggerPosition Position where search trigger was typed + * @returns true if cursor is in valid search zone + */ +export function isInSearchZone(currentPosition: number, triggerPosition: number): boolean { + return currentPosition > triggerPosition; +} + +export function getPlainTextFromClipboard(clipboardData: DataTransfer | null): string { + if (!clipboardData) { + return ""; + } + return clipboardData.getData('text/plain') || ""; +} + +export function stripHtml(html: string): string { + const temp = document.createElement('div'); + temp.innerHTML = html; + return temp.textContent || temp.innerText || ""; +} + +export function insertTextAtCursor(text: string): void { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) { + return; + } + + const range = selection.getRangeAt(0); + range.deleteContents(); + + const textNode = document.createTextNode(text); + range.insertNode(textNode); + + // Move cursor to end of inserted text + range.setStartAfter(textNode); + range.setEndAfter(textNode); + selection.removeAllRanges(); + selection.addRange(range); +} + +function getTextNodeAtPosition(node: Node, targetPosition: number): { node: Node; offset: number } | null { + if (node.nodeType === Node.TEXT_NODE) { + return { node, offset: targetPosition }; + } + + let currentPosition = 0; + + for (let i = 0; i < node.childNodes.length; i++) { + const child = node.childNodes[i]; + + if (child.nodeType === Node.TEXT_NODE) { + const textLength = child.textContent?.length || 0; + + if (currentPosition + textLength >= targetPosition) { + return { node: child, offset: targetPosition - currentPosition }; + } + + currentPosition += textLength; + } else { + const childTextLength = child.textContent?.length || 0; + + if (currentPosition + childTextLength >= targetPosition) { + return getTextNodeAtPosition(child, targetPosition - currentPosition); + } + + currentPosition += childTextLength; + } + } + + return null; +} \ No newline at end of file diff --git a/Helpers/SearchTypes.ts b/Helpers/SearchTypes.ts index 8f288ac..6b35fb1 100644 --- a/Helpers/SearchTypes.ts +++ b/Helpers/SearchTypes.ts @@ -3,7 +3,7 @@ import { TFile } from "obsidian"; /** * Represents a single snippet of matched content from a file */ -export interface SearchSnippet { +export interface ISearchSnippet { text: string; matchIndex: number; matchLength: number; @@ -12,7 +12,7 @@ export interface SearchSnippet { /** * Represents all matches found in a single file */ -export interface SearchMatch { +export interface ISearchMatch { file: TFile; - snippets: SearchSnippet[]; + snippets: ISearchSnippet[]; } diff --git a/Modals/ConversationHistoryModal.ts b/Modals/ConversationHistoryModal.ts index fb4bd29..81e8477 100644 --- a/Modals/ConversationHistoryModal.ts +++ b/Modals/ConversationHistoryModal.ts @@ -7,11 +7,11 @@ import { Services } from 'Services/Services'; import type { ConversationFileSystemService } from 'Services/ConversationFileSystemService'; import type { FileSystemService } from 'Services/FileSystemService'; import { dateToString } from 'Helpers/Helpers'; -import { conversationStore } from 'Stores/conversationStore'; +import { conversationStore } from 'Stores/ConversationStore'; import { Selector } from 'Enums/Selector'; import type { ChatService } from 'Services/ChatService'; -interface ListItem { +interface IListItem { id: string; date: string; updated: Date; @@ -27,7 +27,7 @@ export class ConversationHistoryModal extends Modal { private readonly chatService: ChatService = Resolve(Services.ChatService); private component: Record | null = null; - private items: ListItem[]; + private items: IListItem[]; private conversations: Conversation[]; public onModalClose?: () => void; diff --git a/Services/AIFunctionService.ts b/Services/AIFunctionService.ts index 34ddfb1..01eaddf 100644 --- a/Services/AIFunctionService.ts +++ b/Services/AIFunctionService.ts @@ -4,7 +4,7 @@ import type { FileSystemService } from "./FileSystemService"; import { AIFunction } from "Enums/AIFunction"; import { AIFunctionResponse } from "AIClasses/FunctionDefinitions/AIFunctionResponse"; import type { AIFunctionCall } from "AIClasses/AIFunctionCall"; -import type { SearchMatch } from "../Helpers/SearchTypes"; +import type { ISearchMatch } from "../Helpers/SearchTypes"; import { normalizePath, TFile } from "obsidian"; import { Path } from "Enums/Path"; @@ -45,7 +45,7 @@ export class AIFunctionService { } private async searchVaultFiles(searchTerm: string): Promise { - const matches: SearchMatch[] = searchTerm.trim() === "" ? [] : await this.fileSystemService.searchVaultFiles(searchTerm); + const matches: ISearchMatch[] = searchTerm.trim() === "" ? [] : await this.fileSystemService.searchVaultFiles(searchTerm); if (matches.length === 0) { const files: TFile[] = await this.fileSystemService.listFilesInDirectory(Path.Root); diff --git a/Services/ChatService.ts b/Services/ChatService.ts index c2565d2..b426cef 100644 --- a/Services/ChatService.ts +++ b/Services/ChatService.ts @@ -13,7 +13,7 @@ import { ConversationContent } from "Conversations/ConversationContent"; import { Role } from "Enums/Role"; import type { AIFunctionCall } from "AIClasses/AIFunctionCall"; -export interface ChatServiceCallbacks { +export interface IChatServiceCallbacks { onSubmit: () => void; onStreamingUpdate: (streamingMessageId: string | null) => void; onThoughtUpdate: (thought: string | null) => void; @@ -49,7 +49,7 @@ export class ChatService { this.tokenService = Resolve(Services.ITokenService); } - public async submit(conversation: Conversation, allowDestructiveActions: boolean, userRequest: string, callbacks: ChatServiceCallbacks): Promise { + public async submit(conversation: Conversation, allowDestructiveActions: boolean, userRequest: string, formattedRequest: string, callbacks: IChatServiceCallbacks): Promise { if (!await this.semaphore.wait()) { return; } @@ -85,7 +85,7 @@ export class ChatService { const functionResponse = await this.aiFunctionService.performAIFunction(response.functionCall); conversation.contents.push(new ConversationContent( - Role.User, functionResponse.toConversationString(), "", new Date(), false, true, functionResponse.toolId + Role.User, functionResponse.toConversationString(), "", "", new Date(), false, true, functionResponse.toolId )); } @@ -141,7 +141,7 @@ export class ChatService { } private async streamRequestResponse( - conversation: Conversation, allowDestructiveActions: boolean, callbacks: ChatServiceCallbacks + conversation: Conversation, allowDestructiveActions: boolean, callbacks: IChatServiceCallbacks ): Promise<{ functionCall: AIFunctionCall | null, shouldContinue: boolean }> { // this should never happen if (!this.ai) { diff --git a/Services/FileSystemService.ts b/Services/FileSystemService.ts index f0d73ed..0e72151 100644 --- a/Services/FileSystemService.ts +++ b/Services/FileSystemService.ts @@ -5,7 +5,7 @@ import { Services } from "./Services"; import { isValidJson } from "Helpers/Helpers"; import { Path } from "Enums/Path"; import type { VaultService } from "./VaultService"; -import type { SearchMatch } from "../Helpers/SearchTypes"; +import type { ISearchMatch } from "../Helpers/SearchTypes"; export class FileSystemService { @@ -93,7 +93,7 @@ export class FileSystemService { } } - public async searchVaultFiles(searchTerm: string): Promise { + public async searchVaultFiles(searchTerm: string): Promise { return await this.vaultService.searchVaultFiles(searchTerm); } } \ No newline at end of file diff --git a/Services/SanitiserService.ts b/Services/SanitiserService.ts index 0594951..b0c026f 100644 --- a/Services/SanitiserService.ts +++ b/Services/SanitiserService.ts @@ -1,4 +1,4 @@ -export interface SanitizeOptions { +export interface ISanitizeOptions { replacement?: string; separator?: string; } @@ -19,7 +19,7 @@ export class SanitiserService { * @param options - Optional configuration for replacement character and output separator * @returns Sanitized file path */ - public sanitize(input: string, options: SanitizeOptions = {}): string { + public sanitize(input: string, options: ISanitizeOptions = {}): string { // Type check if (typeof input !== 'string') { throw new Error('Input must be a string'); diff --git a/Services/ServiceRegistration.ts b/Services/ServiceRegistration.ts index a8d533e..a755954 100644 --- a/Services/ServiceRegistration.ts +++ b/Services/ServiceRegistration.ts @@ -30,6 +30,8 @@ import { OpenAIConversationNamingService } from "AIClasses/OpenAI/OpenAIConversa import { OpenAI } from "AIClasses/OpenAI/OpenAI"; import { SanitiserService } from "./SanitiserService"; import { VaultCacheService } from "./VaultCacheService"; +import { UserInputService } from "./UserInputService"; +import { SearchStateStore } from "Stores/SearchStateStore"; export function RegisterDependencies(plugin: AIAgentPlugin) { RegisterSingleton(Services.AIAgentPlugin, plugin); @@ -38,6 +40,8 @@ export function RegisterDependencies(plugin: AIAgentPlugin) { RegisterSingleton(Services.SanitiserService, new SanitiserService()); RegisterSingleton(Services.VaultService, new VaultService()); RegisterSingleton(Services.VaultCacheService, new VaultCacheService()); + RegisterSingleton(Services.SearchStateStore, new SearchStateStore()); + RegisterSingleton(Services.UserInputService, new UserInputService()); RegisterSingleton(Services.WorkSpaceService, new WorkSpaceService()); RegisterSingleton(Services.FileSystemService, new FileSystemService()); RegisterSingleton(Services.ConversationFileSystemService, new ConversationFileSystemService()); diff --git a/Services/Services.ts b/Services/Services.ts index c531e62..b78cbd9 100644 --- a/Services/Services.ts +++ b/Services/Services.ts @@ -4,6 +4,7 @@ export class Services { static FileManager = Symbol("FileManager"); static VaultService = Symbol("VaultService"); static VaultCacheService = Symbol("VaultCacheService"); + static UserInputService = Symbol("UserInputService"); static WorkSpaceService = Symbol("WorkSpaceService"); static FileSystemService = Symbol("FileSystemService"); static ConversationFileSystemService = Symbol("ConversationFileSystemService"); @@ -16,6 +17,9 @@ export class Services { static ChatService = Symbol("ChatService"); static SanitiserService = Symbol("SanitiserService"); + // stores + static SearchStateStore = Symbol("SearchStateStore"); + // interfaces static IAIClass = Symbol("IAIClass"); static IPrompt = Symbol("IPrompt"); diff --git a/Services/StreamingMarkdownService.ts b/Services/StreamingMarkdownService.ts index 1d29b3a..9d8eb89 100644 --- a/Services/StreamingMarkdownService.ts +++ b/Services/StreamingMarkdownService.ts @@ -13,7 +13,7 @@ import { Resolve } from "./DependencyService"; import { Services } from "./Services"; import { Selector } from "Enums/Selector"; -interface StreamingState { +interface IStreamingState { element: HTMLElement; buffer: string; lastProcessedLength: number; @@ -24,7 +24,7 @@ export class StreamingMarkdownService { private readonly fileSystemService: FileSystemService = Resolve(Services.FileSystemService); private readonly processor: Processor | null = null; - private streamingStates: Map = new Map(); + private streamingStates: Map = new Map(); private cachedPermaLinks: string[]; diff --git a/Services/StreamingService.ts b/Services/StreamingService.ts index 807a8e5..04f23c7 100644 --- a/Services/StreamingService.ts +++ b/Services/StreamingService.ts @@ -2,7 +2,7 @@ import type { AIFunctionCall } from "AIClasses/AIFunctionCall"; import type { IAIClass } from "AIClasses/IAIClass"; import { Selector } from "Enums/Selector"; -export interface StreamChunk { +export interface IStreamChunk { content: string; isComplete: boolean; error?: string; @@ -14,10 +14,10 @@ export class StreamingService { public async* streamRequest( url: string, requestBody: unknown, - parseStreamChunk: (chunk: string) => StreamChunk, + parseStreamChunk: (chunk: string) => IStreamChunk, abortSignal?: AbortSignal, additionalHeaders?: Record - ): AsyncGenerator { + ): AsyncGenerator { try { const response = await fetch( url, diff --git a/Services/UserInputService.ts b/Services/UserInputService.ts new file mode 100644 index 0000000..729fd7e --- /dev/null +++ b/Services/UserInputService.ts @@ -0,0 +1,48 @@ +import { SearchTrigger } from "Enums/SearchTrigger"; +import { Resolve } from "./DependencyService"; +import { Services } from "./Services"; +import type { VaultCacheService } from "./VaultCacheService"; +import type { SearchStateStore } from "Stores/SearchStateStore"; +import { get } from "svelte/store"; + +export class UserInputService { + + private readonly vaultCacheService: VaultCacheService; + private readonly searchStateStore: SearchStateStore; + + public constructor() { + this.vaultCacheService = Resolve(Services.VaultCacheService); + this.searchStateStore = Resolve(Services.SearchStateStore); + } + + public get searchState() { + return this.searchStateStore.searchState; + } + + public performSearch() { + const state = get(this.searchStateStore.searchState); + + if (!state.active || + state.trigger == null || + state.query.trim().length < 3) { + this.searchStateStore.setResults([]); + return; + } + + let results: string[] = []; + + switch (state.trigger) { + case SearchTrigger.Tag: + results = this.vaultCacheService.matchTag(state.query).map(result => result.obj.tag); + break; + case SearchTrigger.File: + results = this.vaultCacheService.matchFile(state.query).map(result => result.obj.file.path); + break; + case SearchTrigger.Folder: + results = this.vaultCacheService.matchFolder(state.query).map(result => result.obj.folder.path); + break; + } + + this.searchStateStore.setResults(results); + } +} \ No newline at end of file diff --git a/Services/VaultService.ts b/Services/VaultService.ts index 47ec67d..4e42703 100644 --- a/Services/VaultService.ts +++ b/Services/VaultService.ts @@ -4,11 +4,11 @@ import { Services } from "./Services"; 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 { ISearchMatch, ISearchSnippet } from "../Helpers/SearchTypes"; import type { SanitiserService } from "./SanitiserService"; import { FileEvent } from "Enums/FileEvent"; -interface FileEventArgs { +interface IFileEventArgs { oldPath: string; } @@ -30,7 +30,7 @@ export class VaultService { this.sanitiserService = Resolve(Services.SanitiserService); } - public registerFileEvents(handleFileEvent: (event: FileEvent, file: TAbstractFile, args: FileEventArgs) => void) { + public registerFileEvents(handleFileEvent: (event: FileEvent, file: TAbstractFile, args: IFileEventArgs) => void) { this.plugin.registerEvent(this.vault.on(FileEvent.Create, file => handleFileEvent(FileEvent.Create, file, { oldPath: "" }))); this.plugin.registerEvent(this.vault.on(FileEvent.Modify, file => handleFileEvent(FileEvent.Modify, file, { oldPath: "" }))); this.plugin.registerEvent(this.vault.on(FileEvent.Rename, (file, oldPath) => handleFileEvent(FileEvent.Rename, file, { oldPath: oldPath }))); @@ -164,7 +164,7 @@ export class VaultService { return files.filter(file => !this.isExclusion(file.path, allowAccessToPluginRoot)); } - public async searchVaultFiles(searchTerm: string): Promise { + public async searchVaultFiles(searchTerm: string): Promise { let regex: RegExp; try { regex = new RegExp(searchTerm, "ig"); @@ -174,7 +174,7 @@ export class VaultService { const files: TFile[] = await this.listFilesInDirectory(Path.Root); - const allMatches: SearchMatch[] = []; + const allMatches: ISearchMatch[] = []; for (const file of files) { const content = await this.vault.cachedRead(file); @@ -185,7 +185,7 @@ export class VaultService { } } - const flatMatches: { file: TFile; snippet: SearchSnippet }[] = []; + const flatMatches: { file: TFile; snippet: ISearchSnippet }[] = []; for (const match of allMatches) { for (const snippet of match.snippets) { flatMatches.push({ file: match.file, snippet }); @@ -193,14 +193,14 @@ export class VaultService { } // If more than 20 matches, randomly sample 20 - let selectedMatches: { file: TFile; snippet: SearchSnippet }[]; + let selectedMatches: { file: TFile; snippet: ISearchSnippet }[]; if (flatMatches.length > 20) { selectedMatches = randomSample(flatMatches, 20); } else { selectedMatches = flatMatches; } - const resultMap = new Map(); + const resultMap = new Map(); for (const match of selectedMatches) { const existing = resultMap.get(match.file); if (existing) { @@ -210,7 +210,7 @@ export class VaultService { } } - const results: SearchMatch[] = []; + const results: ISearchMatch[] = []; for (const [file, snippets] of resultMap.entries()) { results.push({ file, snippets }); } @@ -242,8 +242,8 @@ export class VaultService { } } - private extractSnippets(content: string, regex: RegExp): SearchSnippet[] { - const snippets: SearchSnippet[] = []; + private extractSnippets(content: string, regex: RegExp): ISearchSnippet[] { + const snippets: ISearchSnippet[] = []; const maxContextLength = 300; let match: RegExpExecArray | null; @@ -267,12 +267,12 @@ export class VaultService { return this.mergeOverlappingSnippets(snippets, content); } - private mergeOverlappingSnippets(snippets: SearchSnippet[], content: string): SearchSnippet[] { + private mergeOverlappingSnippets(snippets: ISearchSnippet[], content: string): ISearchSnippet[] { if (snippets.length === 0) return snippets; snippets.sort((a, b) => a.matchIndex - b.matchIndex); - const merged: SearchSnippet[] = []; + const merged: ISearchSnippet[] = []; let current = snippets[0]; const maxContextLength = 300; diff --git a/Stores/conversationStore.ts b/Stores/ConversationStore.ts similarity index 91% rename from Stores/conversationStore.ts rename to Stores/ConversationStore.ts index b2cb020..d54be6c 100644 --- a/Stores/conversationStore.ts +++ b/Stores/ConversationStore.ts @@ -1,14 +1,14 @@ import { writable } from 'svelte/store'; import type { Conversation } from 'Conversations/Conversation'; -interface ConversationStoreState { +interface IConversationStoreState { shouldReset: boolean; conversationToLoad: { conversation: Conversation; filePath: string } | null; shouldDeactivateEditMode: boolean; } function createConversationStore() { - const { subscribe, set, update } = writable({ + const { subscribe, set, update } = writable({ shouldReset: false, conversationToLoad: null, shouldDeactivateEditMode: false diff --git a/Stores/SearchStateStore.ts b/Stores/SearchStateStore.ts new file mode 100644 index 0000000..6996e46 --- /dev/null +++ b/Stores/SearchStateStore.ts @@ -0,0 +1,118 @@ +import type { SearchTrigger } from 'Enums/SearchTrigger'; +import { writable } from 'svelte/store'; + +export interface ISearchState { + active: boolean, + trigger: SearchTrigger | null, + position: number | null, + query: string, + results: string[], + selectedResult: string +} + +export class SearchStateStore { + public searchState = writable({ + active: false, + trigger: null, + position: null, + query: "", + results: [], + selectedResult: "" + }); + + public setActive(active: boolean) { + this.searchState.update(state => ({ ...state, active })); + } + + public setTrigger(trigger: SearchTrigger | null) { + this.searchState.update(state => ({ ...state, trigger })); + } + + public setPosition(position: number | null) { + this.searchState.update(state => ({ ...state, position })); + } + + public setQuery(query: string) { + this.searchState.update(state => ({ ...state, query })); + } + + public appendToQuery(char: string) { + this.searchState.update(state => ({ ...state, query: state.query + char })); + } + + public removeLastCharFromQuery() { + this.searchState.update(state => ({ ...state, query: state.query.slice(0, -1) })); + } + + public removeCharAtPosition(position: number) { + this.searchState.update(state => ({ + ...state, + query: state.query.slice(0, position) + state.query.slice(position + 1) + })); + } + + public setResults(results: string[]) { + this.searchState.update(state => ({ ...state, results })); + this.setSelectedResultToFirst(); + } + + public setSelectedResultToFirst() { + this.searchState.update(state => ({ ...state, selectedResult: state.results.length > 0 ? state.results[0] : "" })); + } + + public setSelectedResultToNext() { + this.searchState.update(state => { + if (state.results.length === 0) { + return state; + } + + const currentIndex = state.results.indexOf(state.selectedResult); + const nextIndex = (currentIndex + 1) % state.results.length; + + return { + ...state, + selectedResult: state.results[nextIndex] + }; + }); + } + + public setSelectedResultToPrevious() { + this.searchState.update(state => { + if (state.results.length === 0) { + return state; + } + + const currentIndex = state.results.indexOf(state.selectedResult); + const previousIndex = currentIndex <= 0 + ? state.results.length - 1 + : currentIndex - 1; + + return { + ...state, + selectedResult: state.results[previousIndex] + }; + }); + } + + public initializeSearch(trigger: SearchTrigger, position: number) { + this.searchState.update(state => ({ + ...state, + active: true, + trigger, + position, + query: "", + results: [] + })); + } + + public resetSearch() { + this.searchState.set({ + active: false, + trigger: null, + position: null, + query: "", + results: [], + selectedResult: "" + }); + } +} \ No newline at end of file