feature: implement fuzzysearch service

refactor: rename type interfaces to use I prefix convention

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.
This commit is contained in:
Andrew Beal 2025-10-29 19:35:19 +00:00
parent 86241a819d
commit 679a94d173
26 changed files with 754 additions and 295 deletions

View file

@ -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<StreamChunk, void, unknown> {
): AsyncGenerator<IStreamChunk, void, unknown> {
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);

View file

@ -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<StreamChunk, void, unknown> {
): AsyncGenerator<IStreamChunk, void, unknown> {
// 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);

View file

@ -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<StreamChunk, void, unknown>;
streamRequest(conversation: Conversation, allowDestructiveActions: boolean, abortSignal?: AbortSignal): AsyncGenerator<IStreamChunk, void, unknown>;
}

View file

@ -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<AIFunctionDefinitions>(Services.AIFunctionDefinitions);
// OpenAI can have multiple tool calls, so we track them by index
private accumulatedToolCalls: Map<number, ToolCallAccumulator> = new Map();
private accumulatedToolCalls: Map<number, IToolCallAccumulator> = 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<StreamChunk, void, unknown> {
): AsyncGenerator<IStreamChunk, void, unknown> {
// 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]") {

View file

@ -1,14 +1,36 @@
<script lang="ts">
import { tick } from "svelte";
import { setIcon } from "obsidian";
import type { UserInputService } from "Services/UserInputService";
import type { ISearchState, SearchStateStore } from "Stores/SearchStateStore";
import { Resolve } from "Services/DependencyService";
import { Services } from "Services/Services";
import { SearchTrigger } from "Enums/SearchTrigger";
import ChatSearchResults from "./ChatSearchResults.svelte";
import {
getCharacterAtPosition,
getCursorPosition,
setCursorPosition,
getPlainTextFromClipboard,
insertTextAtCursor,
isPrintableKey,
isInSearchZone,
stripHtml
} from "Helpers/InputHelpers";
import type { Writable } from "svelte/store";
export let hasNoApiKey: boolean;
export let isSubmitting: boolean;
export let editModeActive: boolean;
export let onsubmit: (userRequest: string) => void;
export let onsubmit: (userRequest: string, formattedRequest: string) => void;
export let ontoggleeditmode: () => void;
export let onstop: () => void;
const userInputService: UserInputService = Resolve<UserInputService>(Services.UserInputService);
const searchStateStore: SearchStateStore = Resolve<SearchStateStore>(Services.SearchStateStore);
const searchState: Writable<ISearchState> = searchStateStore.searchState;
let textareaElement: HTMLDivElement;
let submitButton: HTMLButtonElement;
let editModeButton: HTMLButtonElement;
@ -20,6 +42,14 @@
});
}
$: if (submitButton) {
setIcon(submitButton, isSubmitting ? "square" : "send-horizontal");
}
$: if (editModeButton) {
setIcon(editModeButton, editModeActive ? "pencil" : "pencil-off");
}
function handleStop() {
onstop();
}
@ -33,43 +63,155 @@
textareaElement.textContent = "";
userRequest = "";
onsubmit(currentRequest);
onsubmit(currentRequest, "");
}
function toggleEditMode() {
ontoggleeditmode();
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
async function handleKeydown(e: KeyboardEvent) {
if ($searchState.active) {
await continueSearch(e);
return;
}
if (e.key === "Enter") {
if (e.shiftKey) {
return;
} else {
e.preventDefault();
handleSubmit();
}
e.preventDefault();
handleSubmit();
}
if (SearchTrigger.isSearchTrigger(e.key)) {
e.preventDefault();
const position = getCursorPosition(textareaElement)
const trigger = SearchTrigger.fromInput(e.key);
searchStateStore.initializeSearch(trigger, position);
textareaElement.textContent = textareaElement.textContent + e.key;
setCursorPosition(textareaElement, position + 1);
}
}
async function continueSearch(e: KeyboardEvent) {
if (e.key === "Escape") {
searchStateStore.resetSearch();
e.preventDefault();
return;
}
if (e.key === "Enter") {
// need to choose selected search query (or do nothing if there isn't one)
return;
}
if (e.key === "Backspace" || e.key === "Delete") {
return;
}
if (e.key.startsWith("Arrow")) {
if (e.key === "ArrowUp") {
e.preventDefault();
searchStateStore.setSelectedResultToPrevious();
}
if (e.key === "ArrowDown") {
e.preventDefault();
searchStateStore.setSelectedResultToNext()
}
return;
}
// Only append printable characters to the query
if (isPrintableKey(e.key, e.ctrlKey, e.metaKey)) {
searchStateStore.appendToQuery(e.key);
userInputService.performSearch();
}
}
function handleInput() {
if (textareaElement) {
userRequest = textareaElement.textContent || "";
if (textareaElement.innerHTML !== textareaElement.textContent) {
// HTML detected - sanitize by replacing with plain text
const plainText = textareaElement.textContent || "";
const cursorPos = getCursorPosition(textareaElement);
textareaElement.textContent = plainText;
// Restore cursor position after sanitization
setCursorPosition(textareaElement, cursorPos);
}
// If in search mode, synchronize the query with actual text content
if ($searchState.active && $searchState.position !== null) {
const fullText = textareaElement.textContent || "";
const triggerPos = $searchState.position;
// Extract the query portion (everything after the trigger)
const actualQuery = fullText.substring(triggerPos + 1);
// Only update if the query has changed
if (actualQuery !== $searchState.query) {
searchStateStore.setQuery(actualQuery);
userInputService.performSearch();
}
}
if (userRequest.trim() === "") {
textareaElement.textContent = "";
}
}
}
$: if (submitButton) {
setIcon(submitButton, isSubmitting ? "square" : "send-horizontal");
function handlePaste(e: ClipboardEvent) {
e.preventDefault();
const plainText = getPlainTextFromClipboard(e.clipboardData);
if (!plainText) {
return;
}
insertTextAtCursor(plainText);
handleInput();
}
$: if (editModeButton) {
setIcon(editModeButton, editModeActive ? "pencil" : "pencil-off");
function handleCopy(e: ClipboardEvent) {
e.preventDefault();
const selection = window.getSelection();
if (!selection) {
return;
}
const selectedText = selection.toString();
e.clipboardData?.setData('text/plain', selectedText);
}
function handleCursorPositionChange() {
if (!$searchState.active || $searchState.position === null) {
return;
}
const currentPosition = getCursorPosition(textareaElement);
if (!isInSearchZone(currentPosition, $searchState.position)) {
searchStateStore.resetSearch();
}
}
</script>
<div id="input-container" class:edit-mode={editModeActive}>
<div id="input-search-results-container">
<ChatSearchResults searchState={$searchState}/>
</div>
<div
id="input-field"
class:error={hasNoApiKey}
@ -78,6 +220,10 @@
contenteditable="true"
on:keydown={handleKeydown}
on:input={handleInput}
on:paste={handlePaste}
on:copy={handleCopy}
on:click={handleCursorPositionChange}
on:keyup={handleCursorPositionChange}
data-placeholder="Type a message..."
role="textbox"
aria-multiline="true"
@ -108,7 +254,7 @@
grid-row: 2;
grid-column: 1;
display: grid;
grid-template-rows: var(--size-4-3) 1fr var(--size-4-3);
grid-template-rows: auto var(--size-4-3) 1fr var(--size-4-3);
grid-template-columns: var(--size-4-3) 1fr var(--size-4-2) auto var(--size-4-2) auto var(--size-4-3);
border-radius: var(--modal-radius);
background-color: var(--background-primary);
@ -119,8 +265,13 @@
transition: border-color 0.5s ease-out;
}
#input-search-results-container {
grid-row: 1;
grid-column: 2 / 8;
}
#input-field {
grid-row: 2;
grid-row: 3;
grid-column: 2;
height: 100%;
max-height: 30vh;
@ -177,7 +328,7 @@
}
#edit-mode-button {
grid-row: 2;
grid-row: 3;
grid-column: 4;
border-radius: var(--button-radius);
align-self: end;
@ -185,7 +336,7 @@
}
#submit-button {
grid-row: 2;
grid-row: 3;
grid-column: 6;
border-radius: var(--button-radius);
padding-left: var(--size-4-5);

View file

@ -0,0 +1,48 @@
<script lang="ts">
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;
}
});
}
</script>
<div id="input-search-results" style:height="{height}px">
<div id="input-search-results-inner-container" bind:this={contentDiv}>
{#each searchState.results as searchResult}
<div style:background-color="{searchResult === searchState.selectedResult ? "red" : "transparent"}">{searchResult}</div>
{/each}
</div>
</div>
<style>
#input-search-results {
max-height: 15em;
transition: height 0.2s ease-out;
overflow: auto;
scroll-behavior: smooth;
scroll-snap-type: mandatory;
}
#input-search-results::-webkit-scrollbar {
display: none;
}
#input-search-results-inner-container {
display: flex;
flex-direction: column;
gap: 2px;
}
</style>

View file

@ -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;

View file

@ -3,262 +3,171 @@
export let editModeActive: boolean = false;
</script>
<div class="container" class:edit-mode={editModeActive} bind:this={streamingElement}>
<span class="📦"></span>
<span class="📦"></span>
<span class="📦"></span>
<span class="📦"></span>
<span class="📦"></span>
<div class="loader" class:edit-mode={editModeActive} bind:this={streamingElement}>
<div class="circle">
<div class="dot"></div>
<div class="outline"></div>
</div>
<div class="circle">
<div class="dot"></div>
<div class="outline"></div>
</div>
<div class="circle">
<div class="dot"></div>
<div class="outline"></div>
</div>
<div class="circle">
<div class="dot"></div>
<div class="outline"></div>
</div>
</div>
<style>
:root {
--duration: 1.5s;
--container-size: 100px;
--box-size: 12px;
--box-border-radius: 15%;
}
.container {
width: var(--container-size);
/* From Uiverse.io by Li-Deheng */
.loader {
display: flex;
justify-content: space-between;
justify-content: left;
align-items: center;
position: relative;
margin-left: 4px;
padding-top: 12px;
--streaming-color: var(--interactive-accent);
--color: var(--interactive-accent);
--animation: 2s ease-in-out infinite;
}
.container.edit-mode {
--streaming-color: var(--alt-interactive-accent);
.loader.edit-mode {
--color: var(--alt-interactive-accent);
}
.📦 {
width: var(--box-size);
height: var(--box-size);
.loader .circle {
display: flex;
align-items: center;
justify-content: center;
position: relative;
display: block;
transform-origin: -50% center;
border-radius: var(--box-border-radius);
width: 10px;
height: 10px;
border: solid 1px var(--color);
border-radius: 50%;
margin: 0 5px;
background-color: transparent;
animation: circle-keys var(--animation);
animation-fill-mode: backwards;
}
.📦:after {
content: "";
width: 100%;
height: 100%;
.loader .circle .dot {
position: absolute;
top: 0;
right: 0;
background-color: color-mix(in srgb, var(--streaming-color) 85%, white 15%);
border-radius: var(--box-border-radius);
box-shadow: 0px 0px 8px 4px color-mix(in srgb, var(--streaming-color) 40%, transparent 80%);
}
.📦:nth-child(1) {
animation: slide var(--duration) ease-in-out infinite alternate;
}
.📦:nth-child(1):after {
animation: color-change var(--duration) ease-in-out infinite alternate;
}
.📦:nth-child(2) {
animation: flip-1 var(--duration) ease-in-out infinite alternate;
}
.📦:nth-child(2):after {
animation: squidge-1 var(--duration) ease-in-out infinite alternate;
}
.📦:nth-child(3) {
animation: flip-2 var(--duration) ease-in-out infinite alternate;
}
.📦:nth-child(3):after {
animation: squidge-2 var(--duration) ease-in-out infinite alternate;
}
.📦:nth-child(4) {
animation: flip-3 var(--duration) ease-in-out infinite alternate;
}
.📦:nth-child(4):after {
animation: squidge-3 var(--duration) ease-in-out infinite alternate;
}
.📦:nth-child(5) {
animation: flip-4 var(--duration) ease-in-out infinite alternate;
}
.📦:nth-child(5):after {
animation: squidge-4 var(--duration) ease-in-out infinite alternate;
}
.📦:nth-child(2):after {
background-color: color-mix(in srgb, var(--streaming-color) 75%, white 25%);
}
.📦:nth-child(3):after {
background-color: color-mix(in srgb, var(--streaming-color) 80%, white 20%);
}
.📦:nth-child(4):after {
background-color: color-mix(in srgb, var(--streaming-color) 90%, white 10%);
}
.📦:nth-child(5):after {
background-color: color-mix(in srgb, var(--streaming-color) 95%, white 5%);
transform: translate(-50%, -50%);
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--color);
animation: dot-keys var(--animation);
animation-fill-mode: backwards;
}
@keyframes slide {
.loader .circle .outline {
position: absolute;
transform: translate(-50%, -50%);
width: 10px;
height: 10px;
border-radius: 50%;
animation: outline-keys var(--animation);
animation-fill-mode: backwards;
}
.circle:nth-child(2) {
animation-delay: 0.3s;
}
.circle:nth-child(3) {
animation-delay: 0.6s;
}
.circle:nth-child(4) {
animation-delay: 0.9s;
}
.circle:nth-child(5) {
animation-delay: 1.2s;
}
.circle:nth-child(2) .dot {
animation-delay: 0.3s;
}
.circle:nth-child(3) .dot {
animation-delay: 0.6s;
}
.circle:nth-child(4) .dot {
animation-delay: 0.9s;
}
.circle:nth-child(5) .dot {
animation-delay: 1.2s;
}
.circle:nth-child(1) .outline {
animation-delay: 0.9s;
}
.circle:nth-child(2) .outline {
animation-delay: 1.2s;
}
.circle:nth-child(3) .outline {
animation-delay: 1.5s;
}
.circle:nth-child(4) .outline {
animation-delay: 1.8s;
}
.circle:nth-child(5) .outline {
animation-delay: 2.1s;
}
@keyframes circle-keys {
0% {
background-color: color-mix(in srgb, var(--streaming-color) 70%, white 30%);
transform: translatex(0vw);
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.5);
opacity: 0.5;
}
100% {
background-color: color-mix(in srgb, var(--streaming-color) 95%, white 5%);
transform: translatex(
calc(var(--container-size) - (var(--box-size) * 1.25))
);
transform: scale(1);
opacity: 1;
}
}
@keyframes color-change {
@keyframes dot-keys {
0% {
background-color: color-mix(in srgb, var(--streaming-color) 70%, white 30%);
transform: scale(1);
}
100% {
background-color: color-mix(in srgb, var(--streaming-color) 95%, white 5%);
}
}
@keyframes flip-1 {
0%,
15% {
transform: rotate(0);
}
35%,
100% {
transform: rotate(-180deg);
}
}
@keyframes squidge-1 {
5% {
transform-origin: center bottom;
transform: scalex(1) scaley(1);
}
15% {
transform-origin: center bottom;
transform: scalex(1.3) scaley(0.7);
}
25%,
20% {
transform-origin: center bottom;
transform: scalex(0.8) scaley(1.4);
}
55%,
100% {
transform-origin: center top;
transform: scalex(1) scaley(1);
}
40% {
transform-origin: center top;
transform: scalex(1.3) scaley(0.7);
}
}
@keyframes flip-2 {
0%,
30% {
transform: rotate(0);
}
50%,
100% {
transform: rotate(-180deg);
}
}
@keyframes squidge-2 {
20% {
transform-origin: center bottom;
transform: scalex(1) scaley(1);
}
30% {
transform-origin: center bottom;
transform: scalex(1.3) scaley(0.7);
}
40%,
35% {
transform-origin: center bottom;
transform: scalex(0.8) scaley(1.4);
}
70%,
100% {
transform-origin: center top;
transform: scalex(1) scaley(1);
}
55% {
transform-origin: center top;
transform: scalex(1.3) scaley(0.7);
}
}
@keyframes flip-3 {
0%,
45% {
transform: rotate(0);
}
65%,
100% {
transform: rotate(-180deg);
}
}
@keyframes squidge-3 {
35% {
transform-origin: center bottom;
transform: scalex(1) scaley(1);
}
45% {
transform-origin: center bottom;
transform: scalex(1.3) scaley(0.7);
}
55%,
50% {
transform-origin: center bottom;
transform: scalex(0.8) scaley(1.4);
transform: scale(0);
}
85%,
100% {
transform-origin: center top;
transform: scalex(1) scaley(1);
}
70% {
transform-origin: center top;
transform: scalex(1.3) scaley(0.7);
transform: scale(1);
}
}
@keyframes flip-4 {
0%,
60% {
transform: rotate(0);
@keyframes outline-keys {
0% {
transform: scale(0);
outline: solid 10px var(--color);
outline-offset: 0;
opacity: 1;
}
80%,
100% {
transform: rotate(-180deg);
}
}
@keyframes squidge-4 {
50% {
transform-origin: center bottom;
transform: scalex(1) scaley(1);
}
60% {
transform-origin: center bottom;
transform: scalex(1.3) scaley(0.7);
}
70%,
65% {
transform-origin: center bottom;
transform: scalex(0.8) scaley(1.4);
}
100%,
100% {
transform-origin: center top;
transform: scalex(1) scaley(1);
}
85% {
transform-origin: center top;
transform: scalex(1.3) scaley(0.7);
transform: scale(1);
outline: solid 0 transparent;
outline-offset: 10px;
opacity: 0;
}
}
</style>

View file

@ -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';

View file

@ -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" &&

31
Enums/SearchTrigger.ts Normal file
View file

@ -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}`);
}
}
}

142
Helpers/InputHelpers.ts Normal file
View file

@ -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;
}

View file

@ -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[];
}

View file

@ -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<ChatService>(Services.ChatService);
private component: Record<string, any> | null = null;
private items: ListItem[];
private items: IListItem[];
private conversations: Conversation[];
public onModalClose?: () => void;

View file

@ -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<object> {
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);

View file

@ -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<ITokenService>(Services.ITokenService);
}
public async submit(conversation: Conversation, allowDestructiveActions: boolean, userRequest: string, callbacks: ChatServiceCallbacks): Promise<void> {
public async submit(conversation: Conversation, allowDestructiveActions: boolean, userRequest: string, formattedRequest: string, callbacks: IChatServiceCallbacks): Promise<void> {
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) {

View file

@ -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<SearchMatch[]> {
public async searchVaultFiles(searchTerm: string): Promise<ISearchMatch[]> {
return await this.vaultService.searchVaultFiles(searchTerm);
}
}

View file

@ -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');

View file

@ -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<AIAgentPlugin>(Services.AIAgentPlugin, plugin);
@ -38,6 +40,8 @@ export function RegisterDependencies(plugin: AIAgentPlugin) {
RegisterSingleton<SanitiserService>(Services.SanitiserService, new SanitiserService());
RegisterSingleton<VaultService>(Services.VaultService, new VaultService());
RegisterSingleton<VaultCacheService>(Services.VaultCacheService, new VaultCacheService());
RegisterSingleton<SearchStateStore>(Services.SearchStateStore, new SearchStateStore());
RegisterSingleton<UserInputService>(Services.UserInputService, new UserInputService());
RegisterSingleton<WorkSpaceService>(Services.WorkSpaceService, new WorkSpaceService());
RegisterSingleton<FileSystemService>(Services.FileSystemService, new FileSystemService());
RegisterSingleton<ConversationFileSystemService>(Services.ConversationFileSystemService, new ConversationFileSystemService());

View file

@ -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");

View file

@ -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<FileSystemService>(Services.FileSystemService);
private readonly processor: Processor<any, any, any, any, any> | null = null;
private streamingStates: Map<string, StreamingState> = new Map<string, StreamingState>();
private streamingStates: Map<string, IStreamingState> = new Map<string, IStreamingState>();
private cachedPermaLinks: string[];

View file

@ -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<string, string>
): AsyncGenerator<StreamChunk, void, unknown> {
): AsyncGenerator<IStreamChunk, void, unknown> {
try {
const response = await fetch(
url,

View file

@ -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<VaultCacheService>(Services.VaultCacheService);
this.searchStateStore = Resolve<SearchStateStore>(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);
}
}

View file

@ -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<SanitiserService>(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<SearchMatch[]> {
public async searchVaultFiles(searchTerm: string): Promise<ISearchMatch[]> {
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<TFile, SearchSnippet[]>();
const resultMap = new Map<TFile, ISearchSnippet[]>();
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;

View file

@ -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<ConversationStoreState>({
const { subscribe, set, update } = writable<IConversationStoreState>({
shouldReset: false,
conversationToLoad: null,
shouldDeactivateEditMode: false

118
Stores/SearchStateStore.ts Normal file
View file

@ -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<ISearchState>({
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: ""
});
}
}