mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 06:42:03 +00:00
refactor: convert ChatMode enum to string literals and improve state management
- Change ChatMode enum values from numeric (0,1,2) to string literals ("read_only", "edit", "planning")
- Move chat mode state management from ChatWindow to SettingsService with persistence
- Add chat mode awareness to system prompts and tool validation
- Replace Map with WeakMap for settings subscribers to prevent memory leaks
- Add type-safe event handlers to EventService
- Update esbuild and TypeScript targets from ES2018/ES2020 to ES2022
- Add requiresEditModeEnabled() validation to prevent tool hallucination in read-only mode
- Update all test fixtures to include chatMode in mock settings
This commit is contained in:
parent
31f0484114
commit
8823e3cf23
20 changed files with 161 additions and 91 deletions
|
|
@ -23,6 +23,7 @@ import { GetWebViewerContent } from "./Tools/GetWebViewerContent";
|
|||
import { DeleteVaultFolder } from "./Tools/DeleteVaultFolder";
|
||||
import { MoveVaultFolder } from "./Tools/MoveVaultFolder";
|
||||
import { ChatMode, chatModeAllowsEdits } from "Enums/ChatMode";
|
||||
import type { AITool } from "Enums/AITool";
|
||||
|
||||
export abstract class AIToolDefinitions {
|
||||
|
||||
|
|
@ -30,6 +31,9 @@ export abstract class AIToolDefinitions {
|
|||
private static readonly definitionsList = [SearchVaultFiles, ReadVaultFiles, ListVaultFiles, GetWebViewerContent,
|
||||
WriteVaultFile, PatchVaultFile, DeleteVaultFiles, MoveVaultFiles, CreateVaultFolder, DeleteVaultFolder, MoveVaultFolder];
|
||||
|
||||
public static readonly editModeDefinitionsList = [WriteVaultFile, PatchVaultFile, DeleteVaultFiles,
|
||||
MoveVaultFiles, CreateVaultFolder, DeleteVaultFolder, MoveVaultFolder];
|
||||
|
||||
public static agentDefinitions(chatMode: ChatMode, memories: boolean, updateMemories: boolean, webViewer: boolean): IAIToolDefinition[] {
|
||||
|
||||
if (chatMode === ChatMode.Planning) {
|
||||
|
|
@ -55,15 +59,7 @@ export abstract class AIToolDefinitions {
|
|||
}
|
||||
|
||||
if (chatModeAllowsEdits(chatMode)) {
|
||||
actions = actions.concat([
|
||||
WriteVaultFile,
|
||||
PatchVaultFile,
|
||||
DeleteVaultFiles,
|
||||
MoveVaultFiles,
|
||||
CreateVaultFolder,
|
||||
DeleteVaultFolder,
|
||||
MoveVaultFolder
|
||||
]);
|
||||
actions = actions.concat(this.editModeDefinitionsList);
|
||||
}
|
||||
|
||||
return actions;
|
||||
|
|
@ -111,6 +107,10 @@ export abstract class AIToolDefinitions {
|
|||
return [...this.agentDefinitions(ChatMode.Edit, false, false, false), CompleteTask];
|
||||
}
|
||||
|
||||
public static requiresEditModeEnabled(definition: AITool): boolean {
|
||||
return this.editModeDefinitionsList.map(definition => definition.name).includes(definition);
|
||||
}
|
||||
|
||||
public static compactSummaryForPlanningAgent(): string {
|
||||
return this.definitionsList.map(definition => {
|
||||
// Extract first line of description as brief purpose
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { ExecutionPrompt } from "./ExecutionPrompt";
|
|||
import { OrchestrationPrompt } from "./OrchestrationPrompt";
|
||||
import type { MemoriesService } from "Services/MemoriesService";
|
||||
import { Copy, replaceCopy } from "Enums/Copy";
|
||||
import { ChatMode } from "Enums/ChatMode";
|
||||
|
||||
export interface IPrompt {
|
||||
systemInstruction(): Promise<string>;
|
||||
|
|
@ -63,6 +64,12 @@ export class AIPrompt implements IPrompt {
|
|||
private buildActiveDirectives(): string {
|
||||
const s = this.settingsService.settings;
|
||||
|
||||
const chatModeDirective = s.chatMode === ChatMode.ReadOnly
|
||||
? Copy.DirectiveChatModeReadOnly
|
||||
: s.chatMode === ChatMode.Edit
|
||||
? Copy.DirectiveChatModeEdit
|
||||
: Copy.DirectiveChatModePlanning;
|
||||
|
||||
const memoriesDirective = !s.enableMemories
|
||||
? Copy.DirectiveMemoriesDisabled
|
||||
: s.allowUpdatingMemories
|
||||
|
|
@ -77,7 +84,7 @@ export class AIPrompt implements IPrompt {
|
|||
? Copy.DirectiveWebViewerEnabled
|
||||
: Copy.DirectiveWebViewerDisabled;
|
||||
|
||||
const directives = [memoriesDirective, webSearchDirective, webViewerDirective].join("\n");
|
||||
const directives = [chatModeDirective, memoriesDirective, webSearchDirective, webViewerDirective].join("\n");
|
||||
return replaceCopy(Copy.ActiveCapabilitiesHeader, [directives]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,23 @@ User: "Create a note about today's meeting with Sarah"
|
|||
|
||||
---
|
||||
|
||||
### 2. PLAN EXECUTION PROTOCOL
|
||||
### 2. CHAT MODE
|
||||
|
||||
**Your available actions are determined by the current chat mode, shown in the Active Capabilities section at the end of this prompt.**
|
||||
|
||||
| Mode | What you can do |
|
||||
|------|----------------|
|
||||
| **Read-Only** | Search, read, and list vault files only — no writes, moves, or deletes |
|
||||
| **Edit** | Full access — create, edit, move, and delete files |
|
||||
| **Planning** | Full access via planned workflow execution |
|
||||
|
||||
**In Read-Only mode:**
|
||||
- Do not attempt to create, edit, move, or delete files — the tools are not available
|
||||
- If the user asks you to make changes, acknowledge that chat mode is currently set to Read-Only and let them know they can change it using the chat mode button in the input bar
|
||||
|
||||
---
|
||||
|
||||
### 3. PLAN EXECUTION PROTOCOL
|
||||
|
||||
**When the user has enabled planning mode and you receive a plan, follow this protocol.**
|
||||
|
||||
|
|
@ -137,7 +153,7 @@ When asking questions during execution:
|
|||
|
||||
---
|
||||
|
||||
### 3. HISTORICAL CONTEXT INTERPRETATION
|
||||
### 4. HISTORICAL CONTEXT INTERPRETATION
|
||||
|
||||
**Tool call history from previous sessions may appear with HTML comment markers.**
|
||||
|
||||
|
|
@ -151,7 +167,7 @@ If you see JSON preceded by "Historical tool call/result", it documents a past a
|
|||
|
||||
---
|
||||
|
||||
### 4. WIKI-LINK EVERYTHING FROM THE VAULT
|
||||
### 5. WIKI-LINK EVERYTHING FROM THE VAULT
|
||||
|
||||
**ALWAYS use [[double-bracket]] wiki-link syntax when referencing anything from the user's vault.**
|
||||
|
||||
|
|
@ -194,7 +210,7 @@ This is a hard requirement, not a suggestion. The format is: \`[[Note Name]]\`
|
|||
|
||||
---
|
||||
|
||||
### 5. VAULT-FIRST DECISION FRAMEWORK
|
||||
### 6. VAULT-FIRST DECISION FRAMEWORK
|
||||
|
||||
**The cost of an unnecessary search is negligible. Missing relevant information is costly.**
|
||||
|
||||
|
|
@ -218,7 +234,7 @@ Acknowledge the search, then provide general assistance:
|
|||
|
||||
---
|
||||
|
||||
### 6. MEMORY SYSTEM
|
||||
### 7. MEMORY SYSTEM
|
||||
|
||||
**Memory gives you continuity across sessions. It is your record of how this user works in this vault.**
|
||||
|
||||
|
|
|
|||
|
|
@ -30,10 +30,11 @@
|
|||
|
||||
export let hasNoApiKey: boolean;
|
||||
export let isSubmitting: boolean;
|
||||
export let chatMode: ChatMode;
|
||||
export let onSubmit: (userRequest: string, formattedRequest: string) => void;
|
||||
export let onStop: () => void;
|
||||
|
||||
const componentToken = {};
|
||||
|
||||
const inputService: InputService = Resolve<InputService>(Services.InputService);
|
||||
const settingsService: SettingsService = Resolve<SettingsService>(Services.SettingsService);
|
||||
const userInputService: UserInputService = Resolve<UserInputService>(Services.UserInputService);
|
||||
|
|
@ -59,6 +60,7 @@
|
|||
|
||||
let userRequest: string = "";
|
||||
|
||||
let chatMode: ChatMode = settingsService.settings.chatMode;
|
||||
let inputMode: InputMode = InputMode.Normal;
|
||||
let questionResolver: ((answer: string) => void) | null = null;
|
||||
|
||||
|
|
@ -70,6 +72,8 @@
|
|||
const diffClosedRef: EventRef = eventService.on(Event.DiffClosed, () => { inputMode = InputMode.Normal; focusInput(); });
|
||||
const rateLimitCountdownRef: EventRef = eventService.on(Event.RateLimitCountdown, (delayMs: number) => { startCountdown(delayMs); });
|
||||
|
||||
settingsService.subscribeToSettingsChanged(componentToken, () => chatMode = settingsService.settings.chatMode);
|
||||
|
||||
onMount(async () => {
|
||||
userInstructionActive = (await aiPrompt.userInstruction()).trim() !== "";
|
||||
inputInitialHeight = textareaElement.innerHeight;
|
||||
|
|
@ -290,8 +294,9 @@
|
|||
}
|
||||
|
||||
function toggleWebSearch() {
|
||||
settingsService.settings.enableWebSearch = !settingsService.settings.enableWebSearch;
|
||||
settingsService.saveSettings();
|
||||
settingsService.updateSettings(settings => {
|
||||
settings.enableWebSearch = !settingsService.settings.enableWebSearch;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleChatModeSelectionArea() {
|
||||
|
|
@ -541,7 +546,7 @@
|
|||
</div>
|
||||
|
||||
<div id="chat-mode-selector-container" style:padding-top={chatModeSelectionAreaActive ? "var(--size-4-2)" : 0}>
|
||||
<ChatModeSelector focusInput={focusInput} bind:chatModeSelectionAreaActive={chatModeSelectionAreaActive} bind:currentChatMode={chatMode} />
|
||||
<ChatModeSelector focusInput={focusInput} bind:chatModeSelectionAreaActive={chatModeSelectionAreaActive} />
|
||||
</div>
|
||||
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -3,24 +3,34 @@
|
|||
import { Copy } from "Enums/Copy";
|
||||
import { tick } from "svelte";
|
||||
import { setIcon } from "obsidian";
|
||||
import type { SettingsService } from "Services/SettingsService";
|
||||
import { Resolve } from "Services/DependencyService";
|
||||
import { Services } from "Services/Services";
|
||||
|
||||
export let focusInput: () => void;
|
||||
export let chatModeSelectionAreaActive: boolean;
|
||||
export let currentChatMode: ChatMode;
|
||||
|
||||
const componentToken = {};
|
||||
|
||||
const settingsService: SettingsService = Resolve<SettingsService>(Services.SettingsService);
|
||||
|
||||
settingsService.subscribeToSettingsChanged(componentToken, () => currentChatMode = settingsService.settings.chatMode);
|
||||
|
||||
let height = 0;
|
||||
|
||||
let ChatModeSelectionContentDiv: HTMLDivElement;
|
||||
let chatModeSelectionContainer: HTMLDivElement;
|
||||
|
||||
let selectedChatMode: number = ChatMode.ReadOnly;
|
||||
let currentChatMode: ChatMode = settingsService.settings.chatMode;
|
||||
let selectedChatMode: ChatMode = ChatMode.ReadOnly;
|
||||
|
||||
let iconElements: (HTMLDivElement | null)[] = [null, null, null];
|
||||
let indexedModes: ChatMode[] = [ChatMode.ReadOnly, ChatMode.Edit, ChatMode.Planning];
|
||||
|
||||
$: if (iconElements.some(element => element !== null)) {
|
||||
iconElements.forEach((element, index) => {
|
||||
if (element) {
|
||||
setIcon(element, iconForChatMode(index));
|
||||
setIcon(element, iconForChatMode(indexedModes[index]));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -59,10 +69,14 @@
|
|||
}
|
||||
}
|
||||
|
||||
function handleChatModeSelect(e?: MouseEvent) {
|
||||
async function handleChatModeSelect(e?: MouseEvent) {
|
||||
currentChatMode = selectedChatMode;
|
||||
chatModeSelectionAreaActive = false;
|
||||
|
||||
await settingsService.updateSettings(settings => {
|
||||
settings.chatMode = currentChatMode
|
||||
});
|
||||
|
||||
e?.preventDefault();
|
||||
focusInput();
|
||||
}
|
||||
|
|
@ -74,11 +88,12 @@
|
|||
e.preventDefault();
|
||||
|
||||
if (e.key.startsWith("Arrow")) {
|
||||
const index = indexedModes.indexOf(selectedChatMode);
|
||||
if (e.key === "ArrowUp") {
|
||||
selectedChatMode = selectedChatMode <= 0 ? 2 : selectedChatMode - 1;
|
||||
selectedChatMode = index === 0 ? indexedModes[2] : indexedModes[index - 1];
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
selectedChatMode = selectedChatMode >= 2 ? 0 : selectedChatMode + 1;
|
||||
selectedChatMode = index >= 2 ? indexedModes[0] : indexedModes[index + 1];
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
@ -111,7 +126,7 @@
|
|||
on:mouseenter={() => selectedChatMode = ChatMode.ReadOnly}
|
||||
on:mousedown={handleChatModeSelect}
|
||||
on:keydown={() => {}}>
|
||||
<div class="chat-mode-selection-icon" bind:this={iconElements[ChatMode.ReadOnly]}></div>
|
||||
<div class="chat-mode-selection-icon" bind:this={iconElements[indexedModes.indexOf(ChatMode.ReadOnly)]}></div>
|
||||
<div class="chat-mode-selection-title">{Copy.ChatModeReadOnlyTitle}</div>
|
||||
<div class="chat-mode-selection-subtitle">{Copy.ChatModeReadOnlyDesc}</div>
|
||||
</div>
|
||||
|
|
@ -124,7 +139,7 @@
|
|||
on:mouseenter={() => selectedChatMode = ChatMode.Edit}
|
||||
on:mousedown={handleChatModeSelect}
|
||||
on:keydown={() => {}}>
|
||||
<div class="chat-mode-selection-icon" bind:this={iconElements[ChatMode.Edit]}></div>
|
||||
<div class="chat-mode-selection-icon" bind:this={iconElements[indexedModes.indexOf(ChatMode.Edit)]}></div>
|
||||
<div class="chat-mode-selection-title">{Copy.ChatModeEditTitle}</div>
|
||||
<div class="chat-mode-selection-subtitle">{Copy.ChatModeEditDesc}</div>
|
||||
</div>
|
||||
|
|
@ -137,7 +152,7 @@
|
|||
on:mouseenter={() => selectedChatMode = ChatMode.Planning}
|
||||
on:mousedown={handleChatModeSelect}
|
||||
on:keydown={() => {}}>
|
||||
<div class="chat-mode-selection-icon" bind:this={iconElements[ChatMode.Planning]}></div>
|
||||
<div class="chat-mode-selection-icon" bind:this={iconElements[indexedModes.indexOf(ChatMode.Planning)]}></div>
|
||||
<div class="chat-mode-selection-title">{Copy.ChatModePlanningTitle}</div>
|
||||
<div class="chat-mode-selection-subtitle">{Copy.ChatModePlanningDesc}</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@
|
|||
import type { StreamingMarkdownService } from "Services/StreamingMarkdownService";
|
||||
import { HTMLService } from "Services/HTMLService";
|
||||
import { AITool, fromString } from "Enums/AITool";
|
||||
import { ChatMode } from "Enums/ChatMode";
|
||||
|
||||
const plugin: VaultkeeperAIPlugin = Resolve<VaultkeeperAIPlugin>(Services.VaultkeeperAIPlugin);
|
||||
const executionPlanStore: ExecutionPlanStore = Resolve<ExecutionPlanStore>(Services.ExecutionPlanStore);
|
||||
|
|
@ -40,7 +39,6 @@
|
|||
let hasNoApiKey = false;
|
||||
let isSubmitting = false;
|
||||
let busyPlanning = false;
|
||||
let chatMode: ChatMode = ChatMode.ReadOnly;
|
||||
let currentStreamingMessageId: string | null = null;
|
||||
|
||||
let conversation: Conversation = new Conversation();
|
||||
|
|
@ -103,7 +101,7 @@
|
|||
|
||||
const currentRequest = userRequest;
|
||||
|
||||
await chatService.submit(conversation, chatMode, currentRequest, formattedRequest, attachments, {
|
||||
await chatService.submit(conversation, settingsService.settings.chatMode, currentRequest, formattedRequest, attachments, {
|
||||
onSubmit: () => {
|
||||
isSubmitting = true;
|
||||
attachments = [];
|
||||
|
|
@ -206,10 +204,6 @@
|
|||
});
|
||||
}
|
||||
|
||||
$: if ($conversationStore.shouldDeactivateEditMode) {
|
||||
conversationStore.clearEditModeFlag();
|
||||
chatMode = ChatMode.ReadOnly;
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="container">
|
||||
|
|
@ -223,7 +217,6 @@
|
|||
<ChatInput
|
||||
bind:this={chatInput}
|
||||
bind:attachments
|
||||
bind:chatMode={chatMode}
|
||||
{hasNoApiKey}
|
||||
{isSubmitting}
|
||||
onSubmit={handleSubmit}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
export enum ChatMode {
|
||||
ReadOnly = 0,
|
||||
Edit = 1,
|
||||
Planning = 2
|
||||
ReadOnly = "read_only",
|
||||
Edit = "edit",
|
||||
Planning = "planning"
|
||||
}
|
||||
|
||||
export function chatModeAllowsEdits(mode: ChatMode) {
|
||||
|
|
|
|||
|
|
@ -182,6 +182,9 @@ The following context explains why you are doing the task. It is NOT an instruct
|
|||
|
||||
// Active Capabilities
|
||||
ActiveCapabilitiesHeader = `\n\n---\n\n## Active Capabilities\n\nThe following reflects your current configuration. Follow these directives exactly.\n\n{directives}`,
|
||||
DirectiveChatModeReadOnly = "- **Chat Mode**: READ-ONLY — you can read and search the vault but cannot create, edit, move, or delete files; if the user asks you to make changes, inform them that edit mode is currently off",
|
||||
DirectiveChatModeEdit = "- **Chat Mode**: EDIT — you may create, edit, move, and delete files in the vault",
|
||||
DirectiveChatModePlanning = "- **Chat Mode**: PLANNING — you should request a planned workflow before executing tasks in the vault",
|
||||
DirectiveMemoriesDisabled = "- **Memory**: DISABLED — all memory tools are currently unavailable",
|
||||
DirectiveMemoriesEnabled = "- **Memory**: ENABLED — memories are injected above; you must always read and update them",
|
||||
DirectiveMemoriesReadOnly = "- **Memory**: ENABLED (read-only) — memories are injected above; you must read them but updating them is not possible",
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { Services } from "../Services";
|
|||
import type { FileSystemService } from "../FileSystemService";
|
||||
import { AITool, fromString } from "Enums/AITool";
|
||||
import { AIToolResponse } from "AIClasses/ToolDefinitions/AIToolResponse";
|
||||
import type { AIToolCall } from "AIClasses/AIToolCall";
|
||||
import { AIToolCall } from "AIClasses/AIToolCall";
|
||||
import type { ISearchMatch } from "../../Types/SearchTypes";
|
||||
import { AbortService } from "../AbortService";
|
||||
import { normalizePath, TAbstractFile, TFile } from "obsidian";
|
||||
|
|
@ -19,6 +19,8 @@ import { isDocumentMimeType, MimeType } from "Enums/MimeType";
|
|||
import { isTextFile, toFileType } from "Enums/FileType";
|
||||
import { FileTypeToMimeType } from "Enums/FileTypeMimeTypeMapping";
|
||||
import { StringTools } from "Helpers/StringTools";
|
||||
import { AIToolDefinitions } from "AIClasses/ToolDefinitions/AIToolDefinitions";
|
||||
import { chatModeAllowsEdits } from "Enums/ChatMode";
|
||||
import {
|
||||
SearchVaultFilesArgsSchema,
|
||||
ReadVaultFilesArgsSchema,
|
||||
|
|
@ -54,6 +56,16 @@ export class AIToolService {
|
|||
}
|
||||
|
||||
public async performAITool(toolCall: AIToolCall): Promise<AIToolResponse> {
|
||||
|
||||
// This can happen if the agent hallucinates a legitimate tool call it doesn't currently have access to
|
||||
if (!chatModeAllowsEdits(this.settingsService.settings.chatMode) && AIToolDefinitions.requiresEditModeEnabled(toolCall.name)) {
|
||||
Exception.log(`Invalid tool call: ${toolCall.name}. Agent has hallucinated a legitimate tool call that requires edit mode`);
|
||||
return new AIToolResponse(
|
||||
toolCall.name,
|
||||
new AIToolResponsePayload({ error: `Invalid tool call: ${toolCall.name}. The user has not allowed edits to be made.` }),
|
||||
toolCall.toolId
|
||||
);
|
||||
}
|
||||
|
||||
if (toolCall.name !== AITool.ReadMemories && toolCall.name !== AITool.UpdateMemories) {
|
||||
this.lastToolReadMemories = false;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,17 @@
|
|||
import type { Event } from "Enums/Event";
|
||||
import { Events } from "obsidian";
|
||||
import { Events, type EventRef } from "obsidian";
|
||||
|
||||
export class EventService extends Events {
|
||||
|
||||
public on(name: Event.DiffOpened, callback: () => void): EventRef;
|
||||
public on(name: Event.DiffClosed, callback: () => void): EventRef;
|
||||
public on(name: Event.RateLimitCountdown, callback: (delayMs: number) => void): EventRef;
|
||||
public on(name: Event.QuickActionsSettingsChanged, callback: (data?: unknown) => void): EventRef;
|
||||
|
||||
public on<T extends unknown[]>(name: string, callback: (...data: T) => unknown): EventRef {
|
||||
return super.on(name, callback as (...data: unknown[]) => unknown);
|
||||
}
|
||||
|
||||
public trigger(name: Event.DiffOpened, data?: unknown): void;
|
||||
public trigger(name: Event.DiffClosed, data?: unknown): void;
|
||||
public trigger(name: Event.RateLimitCountdown, delayMs: number): void;
|
||||
|
|
|
|||
|
|
@ -86,7 +86,8 @@ export class SettingsService {
|
|||
public readonly settings: Readonly<IVaultkeeperAISettings>;
|
||||
|
||||
private readonly plugin: VaultkeeperAIPlugin;
|
||||
private readonly subscribers: Map<object, (() => void) | (() => Promise<void>)> = new Map();
|
||||
private readonly subscribers: WeakMap<object, (() => void) | (() => Promise<void>)> = new WeakMap();
|
||||
private readonly subscriberRefs: Set<WeakRef<object>> = new Set();
|
||||
|
||||
private settingsSnapshot: string;
|
||||
|
||||
|
|
@ -99,6 +100,7 @@ export class SettingsService {
|
|||
|
||||
public subscribeToSettingsChanged(subscriber: object, callback: (() => void) | (() => Promise<void>)): void {
|
||||
this.subscribers.set(subscriber, callback);
|
||||
this.subscriberRefs.add(new WeakRef(subscriber));
|
||||
}
|
||||
|
||||
public unsubscribe(subscriber: object): void {
|
||||
|
|
@ -150,8 +152,13 @@ export class SettingsService {
|
|||
const snapshot = JSON.stringify(this.settings);
|
||||
if (this.settingsSnapshot !== snapshot) {
|
||||
this.settingsSnapshot = snapshot;
|
||||
for (const callback of this.subscribers.values()) {
|
||||
await callback();
|
||||
for (const ref of this.subscriberRefs) {
|
||||
const subscriber = ref.deref();
|
||||
if (!subscriber) {
|
||||
this.subscriberRefs.delete(ref);
|
||||
continue;
|
||||
}
|
||||
await this.subscribers.get(subscriber)?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,24 +4,21 @@ import type { Conversation } from 'Conversations/Conversation';
|
|||
interface IConversationStoreState {
|
||||
shouldReset: boolean;
|
||||
conversationToLoad: { conversation: Conversation; filePath: string } | null;
|
||||
shouldDeactivateEditMode: boolean;
|
||||
}
|
||||
|
||||
function createConversationStore() {
|
||||
const { subscribe, set, update } = writable<IConversationStoreState>({
|
||||
shouldReset: false,
|
||||
conversationToLoad: null,
|
||||
shouldDeactivateEditMode: false
|
||||
});
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
reset: () => set({ shouldReset: true, conversationToLoad: null, shouldDeactivateEditMode: true }),
|
||||
reset: () => set({ shouldReset: true, conversationToLoad: null }),
|
||||
clearResetFlag: () => update(state => ({ ...state, shouldReset: false })),
|
||||
loadConversation: (conversation: Conversation, filePath: string) =>
|
||||
set({ shouldReset: false, conversationToLoad: { conversation, filePath }, shouldDeactivateEditMode: true }),
|
||||
set({ shouldReset: false, conversationToLoad: { conversation, filePath } }),
|
||||
clearLoadFlag: () => update(state => ({ ...state, conversationToLoad: null })),
|
||||
clearEditModeFlag: () => update(state => ({ ...state, shouldDeactivateEditMode: false }))
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { AITool } from '../../Enums/AITool';
|
|||
import { TFile } from 'obsidian';
|
||||
import { Exception } from '../../Helpers/Exception';
|
||||
import { AbortService } from '../../Services/AbortService';
|
||||
import { ChatMode } from '../../Enums/ChatMode';
|
||||
|
||||
/**
|
||||
* INTEGRATION TESTS - AIToolService
|
||||
|
|
@ -51,7 +52,7 @@ describe('AIToolService - Integration Tests', () => {
|
|||
updateMemories: vi.fn().mockResolvedValue(undefined)
|
||||
});
|
||||
RegisterSingleton(Services.SettingsService, {
|
||||
settings: { enableMemories: false, allowUpdatingMemories: false }
|
||||
settings: { enableMemories: false, allowUpdatingMemories: false, chatMode: ChatMode.Edit }
|
||||
});
|
||||
RegisterSingleton(Services.WebViewerService, {
|
||||
getWebViewContent: vi.fn(),
|
||||
|
|
|
|||
|
|
@ -305,11 +305,14 @@ describe('SettingsService', () => {
|
|||
});
|
||||
|
||||
it('should not affect other provider keys when updating one', () => {
|
||||
settingsService.settings.apiKeys = {
|
||||
claude: 'existing-claude',
|
||||
openai: 'existing-openai',
|
||||
gemini: 'existing-gemini', mistral: ''
|
||||
};
|
||||
settingsService = new SettingsService({
|
||||
apiKeys: {
|
||||
claude: 'existing-claude',
|
||||
openai: 'existing-openai',
|
||||
gemini: 'existing-gemini',
|
||||
mistral: ''
|
||||
}
|
||||
});
|
||||
|
||||
settingsService.setApiKeyForProvider(AIProvider.Claude, 'updated-claude');
|
||||
|
||||
|
|
@ -455,17 +458,17 @@ describe('SettingsService', () => {
|
|||
expect(settingsRef.apiKeys.claude).toBe('new-key');
|
||||
});
|
||||
|
||||
it('should allow direct modification of settings properties', () => {
|
||||
it('should allow modification of settings properties via updateSettings', async () => {
|
||||
settingsService = new SettingsService({
|
||||
model: AIProviderModel.ClaudeSonnet_4_5,
|
||||
apiKeys: { claude: '', openai: '', gemini: '', mistral: '' },
|
||||
exclusions: []
|
||||
});
|
||||
|
||||
settingsService.settings.exclusions.push('test-exclusion');
|
||||
await settingsService.updateSettings(s => { s.exclusions.push('test-exclusion'); });
|
||||
expect(settingsService.settings.exclusions).toContain('test-exclusion');
|
||||
|
||||
settingsService.settings.userInstruction = 'Direct modification';
|
||||
await settingsService.updateSettings(s => { s.userInstruction = 'Direct modification'; });
|
||||
expect(settingsService.settings.userInstruction).toBe('Direct modification');
|
||||
});
|
||||
});
|
||||
|
|
@ -509,15 +512,15 @@ describe('SettingsService', () => {
|
|||
expect(settingsService.settings.snippetSizeLimit).toBe(0);
|
||||
});
|
||||
|
||||
it('should allow direct modification of searchResultsLimit', () => {
|
||||
it('should allow modification of searchResultsLimit via updateSettings', async () => {
|
||||
settingsService = new SettingsService({});
|
||||
settingsService.settings.searchResultsLimit = 50;
|
||||
await settingsService.updateSettings(s => { s.searchResultsLimit = 50; });
|
||||
expect(settingsService.settings.searchResultsLimit).toBe(50);
|
||||
});
|
||||
|
||||
it('should allow direct modification of snippetSizeLimit', () => {
|
||||
it('should allow modification of snippetSizeLimit via updateSettings', async () => {
|
||||
settingsService = new SettingsService({});
|
||||
settingsService.settings.snippetSizeLimit = 500;
|
||||
await settingsService.updateSettings(s => { s.snippetSizeLimit = 500; });
|
||||
expect(settingsService.settings.snippetSizeLimit).toBe(500);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { RegisterSingleton, DeregisterAllServices } from '../../Services/Depende
|
|||
import { Services } from '../../Services/Services';
|
||||
import { AIProvider, AIProviderModel } from '../../Enums/ApiProvider';
|
||||
import type VaultkeeperAIPlugin from '../../main';
|
||||
import { ChatMode } from '../../Enums/ChatMode';
|
||||
|
||||
// Mock getAllTags from obsidian
|
||||
vi.mock('obsidian', async () => {
|
||||
|
|
@ -108,7 +109,8 @@ const mockSettings: IVaultkeeperAISettings = {
|
|||
quickActionModel: AIProviderModel.ClaudeSonnet_4_6,
|
||||
enableContextMenuActions: false,
|
||||
enableToolbarActions: false,
|
||||
hideDrawerElements: false
|
||||
hideDrawerElements: false,
|
||||
chatMode: ChatMode.Edit
|
||||
};
|
||||
|
||||
let settingsService: SettingsService;
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { AIProvider, AIProviderModel } from '../../Enums/ApiProvider';
|
|||
import { Exception } from '../../Helpers/Exception';
|
||||
import * as PDFHelper from '../../Helpers/DocumentHelper';
|
||||
import type { IPageText } from '../../Types/SearchTypes';
|
||||
import { ChatMode } from '../../Enums/ChatMode';
|
||||
|
||||
/**
|
||||
* PDF-SPECIFIC TESTS FOR VAULTSERVICE
|
||||
|
|
@ -68,7 +69,8 @@ const mockSettings: IVaultkeeperAISettings = {
|
|||
quickActionModel: AIProviderModel.ClaudeSonnet_4_6,
|
||||
enableContextMenuActions: false,
|
||||
enableToolbarActions: false,
|
||||
hideDrawerElements: false
|
||||
hideDrawerElements: false,
|
||||
chatMode: ChatMode.Edit
|
||||
};
|
||||
|
||||
const mockPlugin = {
|
||||
|
|
@ -471,7 +473,7 @@ describe('VaultService - PDF Tests', () => {
|
|||
});
|
||||
|
||||
it('should respect snippetSizeLimit for PDF content', async () => {
|
||||
settingsService.settings.snippetSizeLimit = 20;
|
||||
await settingsService.updateSettings(s => { s.snippetSizeLimit = 20; });
|
||||
|
||||
const pdfFile = createMockPDFFile('limited.pdf');
|
||||
const folder = createMockFolder('/', [pdfFile]);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { RegisterSingleton, DeregisterAllServices } from '../../Services/Depende
|
|||
import { Services } from '../../Services/Services';
|
||||
import { AIProvider, AIProviderModel } from '../../Enums/ApiProvider';
|
||||
import type VaultkeeperAIPlugin from '../../main';
|
||||
import { ChatMode } from '../../Enums/ChatMode';
|
||||
|
||||
/**
|
||||
* Performance Test Suite for VaultService.searchVaultFiles()
|
||||
|
|
@ -70,7 +71,8 @@ const mockSettings: IVaultkeeperAISettings = {
|
|||
quickActionModel: AIProviderModel.ClaudeSonnet_4_6,
|
||||
enableContextMenuActions: false,
|
||||
enableToolbarActions: false,
|
||||
hideDrawerElements: false
|
||||
hideDrawerElements: false,
|
||||
chatMode: ChatMode.Edit
|
||||
};
|
||||
|
||||
let settingsService: SettingsService;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { SanitiserService } from '../../Services/SanitiserService';
|
|||
import { SettingsService, type IVaultkeeperAISettings } from '../../Services/SettingsService';
|
||||
import { AIProvider, AIProviderModel } from '../../Enums/ApiProvider';
|
||||
import { Exception } from '../../Helpers/Exception';
|
||||
import { ChatMode } from '../../Enums/ChatMode';
|
||||
|
||||
/**
|
||||
* INTEGRATION TESTS
|
||||
|
|
@ -67,7 +68,8 @@ const mockSettings: IVaultkeeperAISettings = {
|
|||
quickActionModel: AIProviderModel.ClaudeSonnet_4_6,
|
||||
enableContextMenuActions: false,
|
||||
enableToolbarActions: false,
|
||||
hideDrawerElements: false
|
||||
hideDrawerElements: false,
|
||||
chatMode: ChatMode.Edit
|
||||
};
|
||||
|
||||
const mockPlugin = {
|
||||
|
|
@ -116,7 +118,7 @@ describe('VaultService - Integration Tests', () => {
|
|||
// Reset all mocks
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Reset settings to defaults
|
||||
// Reset settings to defaults (mutating before SettingsService construction is fine)
|
||||
mockSettings.exclusions = [];
|
||||
mockSettings.searchResultsLimit = 15;
|
||||
mockSettings.snippetSizeLimit = 300;
|
||||
|
|
@ -201,7 +203,7 @@ describe('VaultService - Integration Tests', () => {
|
|||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should filter out user-defined exclusions', () => {
|
||||
it('should filter out user-defined exclusions', async () => {
|
||||
const files = [
|
||||
createMockFile('public/note1.md'),
|
||||
createMockFile('private/secret.md'),
|
||||
|
|
@ -209,8 +211,7 @@ describe('VaultService - Integration Tests', () => {
|
|||
];
|
||||
mockVault.getMarkdownFiles.mockReturnValue(files);
|
||||
|
||||
// Update settings to include exclusion
|
||||
settingsService.settings.exclusions = ['private/**'];
|
||||
await settingsService.updateSettings(s => { s.exclusions = ['private/**']; });
|
||||
|
||||
const result = vaultService.getMarkdownFiles();
|
||||
|
||||
|
|
@ -799,7 +800,7 @@ describe('VaultService - Integration Tests', () => {
|
|||
const folder = createMockFolder('folder', [file1, file2]);
|
||||
|
||||
mockVault.getAbstractFileByPath.mockReturnValue(folder);
|
||||
settingsService.settings.exclusions = ['**/private.md'];
|
||||
await settingsService.updateSettings(s => { s.exclusions = ['**/private.md']; });
|
||||
|
||||
const result = await vaultService.listFilesInDirectory('folder', false);
|
||||
|
||||
|
|
@ -910,7 +911,7 @@ describe('VaultService - Integration Tests', () => {
|
|||
const parentFolder = createMockFolder('parent', [publicFolder, privateFolder]);
|
||||
|
||||
mockVault.getAbstractFileByPath.mockReturnValue(parentFolder);
|
||||
settingsService.settings.exclusions = ['**/private'];
|
||||
await settingsService.updateSettings(s => { s.exclusions = ['**/private']; });
|
||||
|
||||
const result = await vaultService.listFoldersInDirectory('parent', false);
|
||||
|
||||
|
|
@ -979,8 +980,7 @@ describe('VaultService - Integration Tests', () => {
|
|||
return null;
|
||||
});
|
||||
|
||||
// Use pattern that matches the folder itself and its contents
|
||||
settingsService.settings.exclusions = ['parent/excluded/**', 'parent/excluded'];
|
||||
await settingsService.updateSettings(s => { s.exclusions = ['parent/excluded/**', 'parent/excluded']; });
|
||||
|
||||
const result = await vaultService.listFoldersInDirectory('parent', true);
|
||||
|
||||
|
|
@ -1092,8 +1092,7 @@ describe('VaultService - Integration Tests', () => {
|
|||
});
|
||||
|
||||
it('should respect custom searchResultsLimit setting', async () => {
|
||||
// Set custom limit
|
||||
settingsService.settings.searchResultsLimit = 5;
|
||||
await settingsService.updateSettings(s => { s.searchResultsLimit = 5; });
|
||||
|
||||
// Create 10 files, each with a match
|
||||
const files: TFile[] = [];
|
||||
|
|
@ -1113,8 +1112,7 @@ describe('VaultService - Integration Tests', () => {
|
|||
});
|
||||
|
||||
it('should respect custom snippetSizeLimit setting for snippet extraction', async () => {
|
||||
// Set custom snippet size
|
||||
settingsService.settings.snippetSizeLimit = 20;
|
||||
await settingsService.updateSettings(s => { s.snippetSizeLimit = 20; });
|
||||
|
||||
const file = createMockFile('note.md');
|
||||
const folder = createMockFolder('/', [file]);
|
||||
|
|
@ -1363,7 +1361,7 @@ describe('VaultService - Integration Tests', () => {
|
|||
|
||||
describe('isExclusion (private method behavior)', () => {
|
||||
it('should exclude exact path matches', async () => {
|
||||
settingsService.settings.exclusions = ['secret.md'];
|
||||
await settingsService.updateSettings(s => { s.exclusions = ['secret.md']; });
|
||||
|
||||
const result = await vaultService.exists('secret.md');
|
||||
|
||||
|
|
@ -1371,7 +1369,7 @@ describe('VaultService - Integration Tests', () => {
|
|||
});
|
||||
|
||||
it('should handle wildcard * (matches any non-slash)', async () => {
|
||||
settingsService.settings.exclusions = ['folder/*.md'];
|
||||
await settingsService.updateSettings(s => { s.exclusions = ['folder/*.md']; });
|
||||
|
||||
// Mock files to exist in vault
|
||||
mockVault.adapter.exists.mockResolvedValue(true);
|
||||
|
|
@ -1381,7 +1379,7 @@ describe('VaultService - Integration Tests', () => {
|
|||
});
|
||||
|
||||
it('should handle double wildcard ** (matches anything including slashes)', async () => {
|
||||
settingsService.settings.exclusions = ['private/**'];
|
||||
await settingsService.updateSettings(s => { s.exclusions = ['private/**']; });
|
||||
|
||||
// Mock files to exist in vault
|
||||
mockVault.adapter.exists.mockResolvedValue(true);
|
||||
|
|
@ -1392,7 +1390,7 @@ describe('VaultService - Integration Tests', () => {
|
|||
});
|
||||
|
||||
it('should handle patterns ending with / to match directory and contents', async () => {
|
||||
settingsService.settings.exclusions = ['temp/'];
|
||||
await settingsService.updateSettings(s => { s.exclusions = ['temp/']; });
|
||||
|
||||
expect(await vaultService.exists('temp/file.md')).toBe(false);
|
||||
expect(await vaultService.exists('temp/sub/file.md')).toBe(false);
|
||||
|
|
@ -1405,7 +1403,7 @@ describe('VaultService - Integration Tests', () => {
|
|||
});
|
||||
|
||||
it('should handle special regex characters in patterns', async () => {
|
||||
settingsService.settings.exclusions = ['folder[test].md'];
|
||||
await settingsService.updateSettings(s => { s.exclusions = ['folder[test].md']; });
|
||||
|
||||
// Mock files to exist in vault
|
||||
mockVault.adapter.exists.mockResolvedValue(true);
|
||||
|
|
@ -1416,7 +1414,7 @@ describe('VaultService - Integration Tests', () => {
|
|||
});
|
||||
|
||||
it('should handle multiple exclusion patterns', async () => {
|
||||
settingsService.settings.exclusions = ['private/**', 'temp/', '*.secret'];
|
||||
await settingsService.updateSettings(s => { s.exclusions = ['private/**', 'temp/', '*.secret']; });
|
||||
|
||||
// Mock files to exist in vault
|
||||
mockVault.adapter.exists.mockResolvedValue(true);
|
||||
|
|
@ -1522,7 +1520,7 @@ describe('VaultService - Integration Tests', () => {
|
|||
return null;
|
||||
});
|
||||
|
||||
settingsService.settings.exclusions = ['private/**'];
|
||||
await settingsService.updateSettings(s => { s.exclusions = ['private/**']; });
|
||||
|
||||
const result = await vaultService.listDirectoryContents(Path.Root, true, false);
|
||||
|
||||
|
|
|
|||
|
|
@ -167,7 +167,7 @@ const buildOptions = {
|
|||
"@lezer/lr",
|
||||
...builtins],
|
||||
format: "cjs",
|
||||
target: "es2018",
|
||||
target: "es2022",
|
||||
logLevel: "info",
|
||||
sourcemap: prod ? false : "inline",
|
||||
treeShaking: true,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
"inlineSourceMap": true,
|
||||
"inlineSources": true,
|
||||
"module": "ESNext",
|
||||
"target": "ES2020",
|
||||
"target": "ES2022",
|
||||
"allowJs": true,
|
||||
"noImplicitAny": true,
|
||||
"moduleResolution": "bundler",
|
||||
|
|
@ -14,9 +14,7 @@
|
|||
"strictNullChecks": true,
|
||||
"lib": [
|
||||
"DOM",
|
||||
"ES5",
|
||||
"ES6",
|
||||
"ES7"
|
||||
"ES2022"
|
||||
],
|
||||
"paths": {
|
||||
"main": ["./main.ts"],
|
||||
|
|
|
|||
Loading…
Reference in a new issue