mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 06:42:03 +00:00
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:
parent
86241a819d
commit
679a94d173
26 changed files with 754 additions and 295 deletions
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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]") {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
48
Components/ChatSearchResults.svelte
Normal file
48
Components/ChatSearchResults.svelte
Normal 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>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
31
Enums/SearchTrigger.ts
Normal 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
142
Helpers/InputHelpers.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
48
Services/UserInputService.ts
Normal file
48
Services/UserInputService.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
118
Stores/SearchStateStore.ts
Normal 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: ""
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue