From da2d777087c0cb5b613b9b4585fd1236c78d385b Mon Sep 17 00:00:00 2001 From: Andrew Beal Date: Mon, 4 May 2026 16:31:27 +0100 Subject: [PATCH] Refactor SettingsService to use immutable settings with update pattern - Change settings from mutable to readonly with updateSettings method - Add settings change subscription system for reactive updates - Remove direct property mutations throughout codebase - Make API key reactive to provider changes via subscription - Consolidate all settings updates through single async method - Remove unused RegisterAiProvider export and AIToolDefinitions.isGated --- AIClasses/BaseAIClass.ts | 6 +- .../ToolDefinitions/AIToolDefinitions.ts | 3 - Services/ServiceRegistration.ts | 2 +- Services/SettingsService.ts | 105 +++++++++------ Views/VaultkeeperAISettingTab.ts | 126 +++++++++--------- main.ts | 7 +- 6 files changed, 141 insertions(+), 108 deletions(-) diff --git a/AIClasses/BaseAIClass.ts b/AIClasses/BaseAIClass.ts index d381f05..9b8fb65 100644 --- a/AIClasses/BaseAIClass.ts +++ b/AIClasses/BaseAIClass.ts @@ -19,8 +19,9 @@ import { AIToolUsageMode } from "Enums/AIToolUsageMode"; export abstract class BaseAIClass implements IAIClass { + protected apiKey: string; + protected readonly provider: AIProvider; - protected readonly apiKey: string; protected readonly abortService: AbortService; protected readonly aiFileService: IAIFileService; protected readonly settingsService: SettingsService; @@ -39,6 +40,9 @@ export abstract class BaseAIClass implements IAIClass { this.settingsService = Resolve(Services.SettingsService); this.streamingService = Resolve(Services.StreamingService); + this.settingsService.subscribeToSettingsChanged(this, () => { + this.apiKey = this.settingsService.getApiKeyForProvider(provider); + }); this.apiKey = this.settingsService.getApiKeyForProvider(provider); } diff --git a/AIClasses/ToolDefinitions/AIToolDefinitions.ts b/AIClasses/ToolDefinitions/AIToolDefinitions.ts index 512569d..b405055 100644 --- a/AIClasses/ToolDefinitions/AIToolDefinitions.ts +++ b/AIClasses/ToolDefinitions/AIToolDefinitions.ts @@ -25,15 +25,12 @@ import { MoveVaultFolder } from "./Tools/MoveVaultFolder"; import { ChatMode, chatModeAllowsEdits } from "Enums/ChatMode"; export abstract class AIToolDefinitions { - - public static isGated: boolean = false; // Definitions list provides a list of function definitions that does not include any planning functions (used as reference in planning agent prompt) private static readonly definitionsList = [SearchVaultFiles, ReadVaultFiles, ListVaultFiles, GetWebViewerContent, WriteVaultFile, PatchVaultFile, DeleteVaultFiles, MoveVaultFiles, CreateVaultFolder, DeleteVaultFolder, MoveVaultFolder]; public static agentDefinitions(chatMode: ChatMode, memories: boolean, updateMemories: boolean, webViewer: boolean): IAIToolDefinition[] { - this.isGated = false; if (chatMode === ChatMode.Planning) { return [ExecuteWorkflow]; diff --git a/Services/ServiceRegistration.ts b/Services/ServiceRegistration.ts index 089da2d..d875bd0 100644 --- a/Services/ServiceRegistration.ts +++ b/Services/ServiceRegistration.ts @@ -102,7 +102,7 @@ export function RegisterDependencies() { RegisterAiProvider(); } -export function RegisterAiProvider() { +function RegisterAiProvider() { const settingsService = Resolve(Services.SettingsService); const provider = fromModel(settingsService.settings.model); diff --git a/Services/SettingsService.ts b/Services/SettingsService.ts index c7018f6..0170168 100644 --- a/Services/SettingsService.ts +++ b/Services/SettingsService.ts @@ -1,10 +1,22 @@ import type VaultkeeperAIPlugin from "main"; import { Resolve } from "./DependencyService"; import { Services } from "./Services"; -import { AIProvider, AIProviderModel, DEFAULT_MODEL_BY_PROVIDER, DEFAULT_PLANNING_MODEL_BY_PROVIDER, fromModel, isvalidProvider, isValidProviderModel, modelMatchesProvider } from "Enums/ApiProvider"; +import { ChatMode } from "Enums/ChatMode"; +import { + AIProvider, + AIProviderModel, + DEFAULT_MODEL_BY_PROVIDER, + DEFAULT_PLANNING_MODEL_BY_PROVIDER, + fromModel, + isvalidProvider, + isValidProviderModel, + modelMatchesProvider +} from "Enums/ApiProvider"; const DEFAULT_SETTINGS: IVaultkeeperAISettings = { firstTimeStart: true, + + chatMode: ChatMode.ReadOnly, userInstruction: "", provider: AIProvider.Claude, @@ -37,6 +49,8 @@ const DEFAULT_SETTINGS: IVaultkeeperAISettings = { export interface IVaultkeeperAISettings { firstTimeStart: boolean; + + chatMode: ChatMode; userInstruction: string; provider: AIProvider; @@ -69,21 +83,31 @@ export interface IVaultkeeperAISettings { export class SettingsService { + public readonly settings: Readonly; + private readonly plugin: VaultkeeperAIPlugin; - - public readonly settings: IVaultkeeperAISettings; + private readonly subscribers: Map void) | (() => Promise)> = new Map(); + + private settingsSnapshot: string; public constructor(loadedSettings: Partial) { this.plugin = Resolve(Services.VaultkeeperAIPlugin); this.settings = Object.assign({}, DEFAULT_SETTINGS, loadedSettings); + this.settingsSnapshot = JSON.stringify(this.settings); this.ensureValidModels(); } - public async saveSettings(onSave?: () => void) { - await this.plugin.saveData(this.settings); - if (onSave) { - onSave(); - } + public subscribeToSettingsChanged(subscriber: object, callback: (() => void) | (() => Promise)): void { + this.subscribers.set(subscriber, callback); + } + + public unsubscribe(subscriber: object): void { + this.subscribers.delete(subscriber); + } + + public async updateSettings(updateAction: ((settings: IVaultkeeperAISettings) => void) | ((settings: IVaultkeeperAISettings) => Promise)) { + await updateAction(this.settings); + await this.saveSettings(); } public getApiKeyForCurrentModel(): string { @@ -104,51 +128,54 @@ export class SettingsService { } } - public setApiKeyForProvider(provider: AIProvider, key: string) { + public async setApiKeyForProvider(provider: AIProvider, key: string) { switch (provider) { case AIProvider.Claude: - this.settings.apiKeys.claude = key; + await this.updateSettings(settings => settings.apiKeys.claude = key); break; case AIProvider.OpenAI: - this.settings.apiKeys.openai = key; + await this.updateSettings(settings => settings.apiKeys.openai = key); break; case AIProvider.Gemini: - this.settings.apiKeys.gemini = key; + await this.updateSettings(settings => settings.apiKeys.gemini = key); break; case AIProvider.Mistral: - this.settings.apiKeys.mistral = key; + await this.updateSettings(settings => settings.apiKeys.mistral = key); break; } } + private async saveSettings() { + await this.plugin.saveData(this.settings); + const snapshot = JSON.stringify(this.settings); + if (this.settingsSnapshot !== snapshot) { + this.settingsSnapshot = snapshot; + for (const callback of this.subscribers.values()) { + await callback(); + } + } + } + private ensureValidModels(): void { - let changed = false; + void this.updateSettings(settings => { + let provider = settings.provider; - let provider = this.settings.provider; - - if (!isvalidProvider(provider)) { - provider = DEFAULT_SETTINGS.provider; - changed = true; - } - - if (!isValidProviderModel(this.settings.model) || !modelMatchesProvider(this.settings.model, provider)) { - this.settings.model = DEFAULT_MODEL_BY_PROVIDER[provider]; - changed = true; - } - - if (!isValidProviderModel(this.settings.planningModel) || !modelMatchesProvider(this.settings.planningModel, provider)) { - this.settings.planningModel = DEFAULT_PLANNING_MODEL_BY_PROVIDER[provider]; - changed = true; - } - - if (!isValidProviderModel(this.settings.quickActionModel) || !modelMatchesProvider(this.settings.quickActionModel, provider)) { - this.settings.quickActionModel = DEFAULT_MODEL_BY_PROVIDER[provider]; - changed = true; - } - - if (changed) { - void this.saveSettings(); - } + if (!isvalidProvider(provider)) { + provider = DEFAULT_SETTINGS.provider; + } + + if (!isValidProviderModel(this.settings.model) || !modelMatchesProvider(this.settings.model, provider)) { + settings.model = DEFAULT_MODEL_BY_PROVIDER[provider]; + } + + if (!isValidProviderModel(this.settings.planningModel) || !modelMatchesProvider(this.settings.planningModel, provider)) { + settings.planningModel = DEFAULT_PLANNING_MODEL_BY_PROVIDER[provider]; + } + + if (!isValidProviderModel(this.settings.quickActionModel) || !modelMatchesProvider(this.settings.quickActionModel, provider)) { + settings.quickActionModel = DEFAULT_MODEL_BY_PROVIDER[provider]; + } + }); } } \ No newline at end of file diff --git a/Views/VaultkeeperAISettingTab.ts b/Views/VaultkeeperAISettingTab.ts index 012281d..00b08f5 100644 --- a/Views/VaultkeeperAISettingTab.ts +++ b/Views/VaultkeeperAISettingTab.ts @@ -9,7 +9,6 @@ import { Resolve } from "Services/DependencyService"; import type { EventService } from "Services/EventService"; import type { SettingsService } from "Services/SettingsService"; import { Services } from "Services/Services"; -import { RegisterAiProvider } from "Services/ServiceRegistration"; import { closePluginSettings } from "Helpers/Helpers"; import type { MemoriesService } from "Services/MemoriesService"; @@ -54,9 +53,10 @@ export class VaultkeeperAISettingTab extends PluginSettingTab { if (!isValidProviderModel(value)) { return; } - this.settingsService.settings.model = value; - this.settingsService.settings.provider = fromModel(value); - await this.settingsService.saveSettings(() => RegisterAiProvider()); + await this.settingsService.updateSettings(settings => { + settings.model = value; + settings.provider = fromModel(value); + }); if (this.apiKeyInputEl) { this.apiKeyInputEl.value = this.settingsService.getApiKeyForCurrentModel(); this.highlightApiKey(); @@ -84,8 +84,9 @@ export class VaultkeeperAISettingTab extends PluginSettingTab { if (!isValidProviderModel(value)) { return; } - this.settingsService.settings.planningModel = value; - await this.settingsService.saveSettings(); + await this.settingsService.updateSettings(settings => { + settings.planningModel = value; + }); }); }); @@ -101,8 +102,9 @@ export class VaultkeeperAISettingTab extends PluginSettingTab { if (!isValidProviderModel(value)) { return; } - this.settingsService.settings.quickActionModel = value; - await this.settingsService.saveSettings(); + await this.settingsService.updateSettings(settings => { + settings.quickActionModel = value; + }); }); }); @@ -114,9 +116,9 @@ export class VaultkeeperAISettingTab extends PluginSettingTab { text.setPlaceholder(Copy.PlaceholderEnterApiKey) .setValue(this.settingsService.getApiKeyForCurrentModel()) .onChange(async (value) => { - const provider = fromModel(this.settingsService.settings.model); - this.settingsService.setApiKeyForProvider(provider, value); - await this.settingsService.saveSettings(() => RegisterAiProvider()); + await this.settingsService.updateSettings(async settings => { + await this.settingsService.setApiKeyForProvider(fromModel(settings.model), value); + }); this.highlightApiKey(); }); text.inputEl.type = "password"; @@ -162,8 +164,9 @@ export class VaultkeeperAISettingTab extends PluginSettingTab { text.setPlaceholder(Copy.PlaceholderFileExclusions) .setValue(this.settingsService.settings.exclusions.join("\n")) .onChange(async (value) => { - this.settingsService.settings.exclusions = value.split("\n").map(line => line.trim()).filter(line => line.length > 0); - await this.settingsService.saveSettings(); + await this.settingsService.updateSettings(settings => { + settings.exclusions = value.split("\n").map(line => line.trim()).filter(line => line.length > 0); + }); }); text.inputEl.classList.add(Selector.AIExclusionsInput); }); @@ -183,8 +186,9 @@ export class VaultkeeperAISettingTab extends PluginSettingTab { .setValue(this.settingsService.settings.searchResultsLimit) .setDynamicTooltip() .onChange(async (value) => { - this.settingsService.settings.searchResultsLimit = value; - await this.settingsService.saveSettings(); + await this.settingsService.updateSettings(settings => { + settings.searchResultsLimit = value; + }); }); }); @@ -198,8 +202,9 @@ export class VaultkeeperAISettingTab extends PluginSettingTab { .setValue(this.settingsService.settings.snippetSizeLimit) .setDynamicTooltip() .onChange(async (value) => { - this.settingsService.settings.snippetSizeLimit = value; - await this.settingsService.saveSettings(); + await this.settingsService.updateSettings(settings => { + settings.snippetSizeLimit = value; + }); }); }); @@ -216,8 +221,9 @@ export class VaultkeeperAISettingTab extends PluginSettingTab { toggle .setValue(this.settingsService.settings.enableWebViewer) .onChange(async (value) => { - this.settingsService.settings.enableWebViewer = value; - await this.settingsService.saveSettings(); + await this.settingsService.updateSettings(settings => { + settings.enableWebViewer = value; + }); }); }); @@ -234,8 +240,9 @@ export class VaultkeeperAISettingTab extends PluginSettingTab { toggle .setValue(this.settingsService.settings.enableMemories) .onChange(async (value) => { - this.settingsService.settings.enableMemories = value; - await this.settingsService.saveSettings(); + await this.settingsService.updateSettings(settings => { + settings.enableMemories = value; + }); this.updateAllowUpdatingMemoriesSetting(); }); }); @@ -249,8 +256,9 @@ export class VaultkeeperAISettingTab extends PluginSettingTab { toggle .setValue(this.settingsService.settings.allowUpdatingMemories) .onChange(async (value) => { - this.settingsService.settings.allowUpdatingMemories = value; - await this.settingsService.saveSettings(); + await this.settingsService.updateSettings(settings => { + settings.allowUpdatingMemories = value; + }); }) }); this.updateAllowUpdatingMemoriesSetting(); @@ -282,10 +290,10 @@ export class VaultkeeperAISettingTab extends PluginSettingTab { toggle .setValue(this.settingsService.settings.enableContextMenuActions) .onChange(async (value) => { - this.settingsService.settings.enableContextMenuActions = value; - await this.settingsService.saveSettings(() => - this.eventService.trigger(Event.QuickActionsSettingsChanged) - ); + await this.settingsService.updateSettings(settings => { + settings.enableContextMenuActions = value; + }); + this.eventService.trigger(Event.QuickActionsSettingsChanged); }); }); @@ -297,10 +305,10 @@ export class VaultkeeperAISettingTab extends PluginSettingTab { toggle .setValue(this.settingsService.settings.enableToolbarActions) .onChange(async (value) => { - this.settingsService.settings.enableToolbarActions = value; - await this.settingsService.saveSettings(() => - this.eventService.trigger(Event.QuickActionsSettingsChanged) - ); + await this.settingsService.updateSettings(settings => { + settings.enableToolbarActions = value; + }); + this.eventService.trigger(Event.QuickActionsSettingsChanged); }); }); @@ -317,8 +325,9 @@ export class VaultkeeperAISettingTab extends PluginSettingTab { toggle .setValue(this.settingsService.settings.hideDrawerElements) .onChange(async (value) => { - this.settingsService.settings.hideDrawerElements = value; - await this.settingsService.saveSettings(); + await this.settingsService.updateSettings(settings => { + settings.hideDrawerElements = value; + }); }); }); } @@ -369,38 +378,33 @@ export class VaultkeeperAISettingTab extends PluginSettingTab { } private async updateModelDropdowns(): Promise { - const currentProvider = fromModel(this.settingsService.settings.model); - let shouldSave = false; + await this.settingsService.updateSettings(settings => { + const currentProvider = fromModel(settings.model); - if (this.planningModelDropdown) { - const planningProvider = fromModel(this.settingsService.settings.planningModel); - this.planningModelDropdown.selectEl.empty(); - this.populateModelDropdown(this.planningModelDropdown, currentProvider); - - if (planningProvider !== currentProvider) { - this.settingsService.settings.planningModel = this.settingsService.settings.model; - shouldSave = true; + if (this.planningModelDropdown) { + const planningProvider = fromModel(settings.planningModel); + this.planningModelDropdown.selectEl.empty(); + this.populateModelDropdown(this.planningModelDropdown, currentProvider); + + if (planningProvider !== currentProvider) { + settings.planningModel = settings.model; + } + + this.planningModelDropdown.setValue(settings.planningModel); } - this.planningModelDropdown.setValue(this.settingsService.settings.planningModel); - } - - if (this.quickActionModelDropdown) { - const quickActionProvider = fromModel(this.settingsService.settings.quickActionModel); - this.quickActionModelDropdown.selectEl.empty(); - this.populateModelDropdown(this.quickActionModelDropdown); - - if (quickActionProvider !== currentProvider) { - this.settingsService.settings.quickActionModel = this.settingsService.settings.model; - shouldSave = true; + if (this.quickActionModelDropdown) { + const quickActionProvider = fromModel(settings.quickActionModel); + this.quickActionModelDropdown.selectEl.empty(); + this.populateModelDropdown(this.quickActionModelDropdown); + + if (quickActionProvider !== currentProvider) { + settings.quickActionModel = settings.model; + } + + this.quickActionModelDropdown.setValue(settings.quickActionModel); } - - this.quickActionModelDropdown.setValue(this.settingsService.settings.quickActionModel); - } - - if (shouldSave) { - await this.settingsService.saveSettings(); - } + }); } private highlightApiKey() { diff --git a/main.ts b/main.ts index 366020d..c68ae82 100644 --- a/main.ts +++ b/main.ts @@ -94,11 +94,12 @@ export default class VaultkeeperAIPlugin extends Plugin { if (!settingsService.settings.firstTimeStart) { return; } - settingsService.settings.firstTimeStart = false; - await settingsService.saveSettings(); + + await settingsService.updateSettings(settings => { + settings.firstTimeStart = false; + }); const vaultService: VaultService = Resolve(Services.VaultService); - await vaultService.create(Path.ExampleUserInstructions, Copy.EXAMPLE_USER_INSTRUCTION, true, false); } } \ No newline at end of file