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
This commit is contained in:
Andrew Beal 2026-05-04 16:31:27 +01:00
parent d2edd167dc
commit da2d777087
6 changed files with 141 additions and 108 deletions

View file

@ -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<SettingsService>(Services.SettingsService);
this.streamingService = Resolve<StreamingService>(Services.StreamingService);
this.settingsService.subscribeToSettingsChanged(this, () => {
this.apiKey = this.settingsService.getApiKeyForProvider(provider);
});
this.apiKey = this.settingsService.getApiKeyForProvider(provider);
}

View file

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

View file

@ -102,7 +102,7 @@ export function RegisterDependencies() {
RegisterAiProvider();
}
export function RegisterAiProvider() {
function RegisterAiProvider() {
const settingsService = Resolve<SettingsService>(Services.SettingsService);
const provider = fromModel(settingsService.settings.model);

View file

@ -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<IVaultkeeperAISettings>;
private readonly plugin: VaultkeeperAIPlugin;
public readonly settings: IVaultkeeperAISettings;
private readonly subscribers: Map<object, (() => void) | (() => Promise<void>)> = new Map();
private settingsSnapshot: string;
public constructor(loadedSettings: Partial<IVaultkeeperAISettings>) {
this.plugin = Resolve<VaultkeeperAIPlugin>(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>)): 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<void>)) {
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];
}
});
}
}

View file

@ -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<void> {
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() {

View file

@ -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<VaultService>(Services.VaultService);
await vaultService.create(Path.ExampleUserInstructions, Copy.EXAMPLE_USER_INSTRUCTION, true, false);
}
}