refactor: improve dependency injection type safety and add AI exclusions setting

- Add generic types to all Resolve() calls for type safety
- Introduce VaultService abstraction layer for file operations
- Add AI File Exclusions setting with textarea and glob pattern support
- Refactor FileSystemService to use VaultService instead of direct Vault access
- Improve code organization with comments and renamed variables
- Update styles for exclusions input field
This commit is contained in:
Andrew Beal 2025-10-12 21:26:01 +01:00
parent ae5a836c21
commit e543bf5351
15 changed files with 180 additions and 52 deletions

View file

@ -1,4 +1,5 @@
import { AIProvider } from "Enums/ApiProvider";
import { Path } from "Enums/Path";
import type AIAgentPlugin from "main";
import { PluginSettingTab, Setting, App, setIcon, setTooltip } from "obsidian";
@ -16,6 +17,7 @@ export class AIAgentSettingTab extends PluginSettingTab {
containerEl.empty();
/* API Provider Setting */
new Setting(containerEl)
.setName("API Provider")
.setDesc("Select the API provider to use.")
@ -28,9 +30,10 @@ export class AIAgentSettingTab extends PluginSettingTab {
this.plugin.settings.apiProvider = value;
await this.plugin.saveSettings();
})
);
);
let inputEl: HTMLInputElement;
/* API Key Setting */
let apiKeyInputEl: HTMLInputElement;
this.apiKeySetting = new Setting(containerEl)
.setName("API Key")
.setDesc("Enter your API key here.")
@ -43,29 +46,42 @@ export class AIAgentSettingTab extends PluginSettingTab {
this.highlightApiKey();
});
text.inputEl.type = "password";
inputEl = text.inputEl;
apiKeyInputEl = text.inputEl;
})
.addExtraButton(button => {
button
.setTooltip("Show API Key")
.onClick(() => {
if (inputEl.type === "password") {
inputEl.type = "text";
if (apiKeyInputEl.type === "password") {
apiKeyInputEl.type = "text";
setIcon(button.extraSettingsEl, "eye-off");
setTooltip(button.extraSettingsEl, "Hide API Key");
} else {
inputEl.type = "password";
apiKeyInputEl.type = "password";
setIcon(button.extraSettingsEl, "eye");
setTooltip(button.extraSettingsEl, "Show API Key");
}
});
setIcon(button.extraSettingsEl, "eye");
});
this.highlightApiKey();
/* Exclusions Setting */
new Setting(containerEl)
.setName("AI File Exclusions")
.setDesc("Set which directories and files the AI should ignore. Enter one path per line - supports glob patterns like folder/**, *.md")
.addTextArea(text => {
text.setPlaceholder(`Examples:\n\n${Path.UserInstruction}\n${Path.Conversations}/*.json\nPrivateNotes/**`)
.setValue(this.plugin.settings.exclusions.join("\n"))
.onChange(async (value) => {
this.plugin.settings.exclusions = value.split("\n").map(line => line.trim()).filter(line => line.length > 0);
await this.plugin.saveSettings();
});
text.inputEl.classList.add("ai-exclusions-input");
});
}
highlightApiKey(): void {
private highlightApiKey(): void {
if (this.apiKeySetting) {
if (this.plugin.settings.apiKey.trim() === "") {
this.apiKeySetting.settingEl.removeClass("api-key-setting-ok");

View file

@ -16,10 +16,10 @@ export class Gemini implements IAIClass {
private readonly STOP_REASON_STOP: string = "STOP";
private readonly apiKey: string;
private readonly aiPrompt: IPrompt = Resolve(Services.IPrompt);
private readonly plugin: AIAgentPlugin = Resolve(Services.AIAgentPlugin);
private readonly streamingService: StreamingService = Resolve(Services.StreamingService);
private readonly aiFunctionDefinitions: AIFunctionDefinitions = Resolve(Services.AIFunctionDefinitions);
private readonly aiPrompt: IPrompt = Resolve<IPrompt>(Services.IPrompt);
private readonly plugin: AIAgentPlugin = Resolve<AIAgentPlugin>(Services.AIAgentPlugin);
private readonly streamingService: StreamingService = Resolve<StreamingService>(Services.StreamingService);
private readonly aiFunctionDefinitions: AIFunctionDefinitions = Resolve<AIFunctionDefinitions>(Services.AIFunctionDefinitions);
private accumulatedFunctionName: string | null = null;
private accumulatedFunctionArgs: Record<string, any> = {};

View file

@ -30,7 +30,7 @@
let thoughtElement: HTMLElement | undefined;
let streamingElement: HTMLElement | undefined;
let streamingMarkdownService: StreamingMarkdownService = Resolve(Services.StreamingMarkdownService);
let streamingMarkdownService: StreamingMarkdownService = Resolve<StreamingMarkdownService>(Services.StreamingMarkdownService);
let messageElements: Map<string, HTMLElement> = new Map<string, HTMLElement>();
let lastProcessedContent: Map<string, string> = new Map<string, string>();

View file

@ -13,10 +13,10 @@
import type { ChatService } from "Services/ChatService";
import type { ConversationFileSystemService } from "Services/ConversationFileSystemService";
let plugin: AIAgentPlugin = Resolve(Services.AIAgentPlugin);
let chatService: ChatService = Resolve(Services.ChatService);
let workSpaceService: WorkSpaceService = Resolve(Services.WorkSpaceService);
let conversationService: ConversationFileSystemService = Resolve(Services.ConversationFileSystemService);
let plugin: AIAgentPlugin = Resolve<AIAgentPlugin>(Services.AIAgentPlugin);
let chatService: ChatService = Resolve<ChatService>(Services.ChatService);
let workSpaceService: WorkSpaceService = Resolve<WorkSpaceService>(Services.WorkSpaceService);
let conversationService: ConversationFileSystemService = Resolve<ConversationFileSystemService>(Services.ConversationFileSystemService);
let textareaElement: HTMLTextAreaElement;
let chatContainer: HTMLDivElement;

View file

@ -19,8 +19,8 @@ interface ListItem {
export class ConversationHistoryModal extends Modal {
private readonly conversationFileSystemService: ConversationFileSystemService = Resolve(Services.ConversationFileSystemService);
private readonly fileSystemService: FileSystemService = Resolve(Services.FileSystemService);
private readonly conversationFileSystemService: ConversationFileSystemService = Resolve<ConversationFileSystemService>(Services.ConversationFileSystemService);
private readonly fileSystemService: FileSystemService = Resolve<FileSystemService>(Services.FileSystemService);
private component: Record<string, any> | null = null;
private items: ListItem[];

View file

@ -8,7 +8,7 @@ import type { AIFunctionCall } from "AIClasses/AIFunctionCall";
export class AIFunctionService {
private fileSystemService: FileSystemService = Resolve(Services.FileSystemService);
private fileSystemService: FileSystemService = Resolve<FileSystemService>(Services.FileSystemService);
public async performAIFunction(functionCall: AIFunctionCall): Promise<AIFunctionResponse> {
switch (functionCall.name) {

View file

@ -23,13 +23,13 @@ export class ChatService {
private semaphore: Semaphore;
constructor() {
this.conversationService = Resolve(Services.ConversationFileSystemService);
this.aiFunctionService = Resolve(Services.AIFunctionService);
this.conversationService = Resolve<ConversationFileSystemService>(Services.ConversationFileSystemService);
this.aiFunctionService = Resolve<AIFunctionService>(Services.AIFunctionService);
this.semaphore = new Semaphore(1, false);
}
resolveAIProvider() {
this.ai = Resolve(Services.IAIClass);
this.ai = Resolve<IAIClass>(Services.IAIClass);
}
async submit(conversation: Conversation, userRequest: string, callbacks: ChatServiceCallbacks): Promise<Conversation> {

View file

@ -3,39 +3,41 @@ import { TAbstractFile, TFile, TFolder, type Vault } from "obsidian";
import { Resolve } from "./DependencyService";
import { Services } from "./Services";
import { isValidJson } from "Helpers/Helpers";
import { Path } from "Enums/Path";
import type { VaultService } from "./VaultService";
export class FileSystemService {
private vault: Vault;
private readonly vaultService: VaultService;
public constructor() {
this.vault = Resolve<AIAgentPlugin>(Services.AIAgentPlugin).app.vault;
this.vaultService = Resolve<VaultService>(Services.VaultService);
}
public getVaultFileListForMarkDown() {
const files: TFile[] = this.vault.getMarkdownFiles();
const files: TFile[] = this.vaultService.getMarkdownFiles();
return files.map(file => {
return file.path.replace(/\.md$/, "");
});
}
public async readFile(filePath: string): Promise<string | null> {
const file: TAbstractFile | null = this.vault.getAbstractFileByPath(filePath);
const file: TAbstractFile | null = this.vaultService.getAbstractFileByPath(filePath);
if (file && file instanceof TFile) {
return await this.vault.read(file);
return await this.vaultService.read(file);
}
return null;
}
public async writeFile(filePath: string, content: string): Promise<boolean> {
try {
let file: TAbstractFile | null = this.vault.getAbstractFileByPath(filePath);
let file: TAbstractFile | null = this.vaultService.getAbstractFileByPath(filePath);
if (file == null || !(file instanceof TFile)) {
await this.createDirectories(this.vault, filePath);
await this.vault.create(filePath, content);
await this.createDirectories(this.vaultService, filePath);
await this.vaultService.create(filePath, content);
return true;
}
this.vault.modify(file as TFile, content);
await this.vaultService.modify(file as TFile, content);
return true;
}
catch (error) {
@ -46,10 +48,10 @@ export class FileSystemService {
public async deleteFile(filePath: string): Promise<boolean> {
try {
const file: TAbstractFile | null = this.vault.getAbstractFileByPath(filePath);
const file: TAbstractFile | null = this.vaultService.getAbstractFileByPath(filePath);
if (file && file instanceof TFile) {
await this.vault.delete(file);
await this.vaultService.delete(file);
return true;
}
@ -61,7 +63,7 @@ export class FileSystemService {
}
public async listFilesInDirectory(dirPath: string, recursive: boolean = true): Promise<TFile[]> {
const dir: TAbstractFile | null = this.vault.getAbstractFileByPath(dirPath);
const dir: TAbstractFile | null = this.vaultService.getAbstractFileByPath(dirPath);
if (dir == null || !(dir instanceof TFolder)) {
return [];
@ -81,9 +83,9 @@ export class FileSystemService {
}
public async readObjectFromFile(filePath: string): Promise<object | null> {
const file: TAbstractFile | null = this.vault.getAbstractFileByPath(filePath);
const file: TAbstractFile | null = this.vaultService.getAbstractFileByPath(filePath);
if (file && file instanceof TFile) {
const content = await this.vault.read(file);
const content = await this.vaultService.read(file);
if (isValidJson(content) === true) {
return JSON.parse(content);
}
@ -93,14 +95,14 @@ export class FileSystemService {
public async writeObjectToFile(filePath: string, data: object): Promise<boolean> {
try {
let file: TAbstractFile | null = this.vault.getAbstractFileByPath(filePath);
let file: TAbstractFile | null = this.vaultService.getAbstractFileByPath(filePath);
if (file && file instanceof TFile) {
await this.vault.modify(file, JSON.stringify(data, null, 4));
await this.vaultService.modify(file, JSON.stringify(data, null, 4));
}
else {
await this.createDirectories(this.vault, filePath);
await this.vault.create(filePath, JSON.stringify(data, null, 4));
await this.createDirectories(this.vaultService, filePath);
await this.vaultService.create(filePath, JSON.stringify(data, null, 4));
}
return true;
@ -110,7 +112,7 @@ export class FileSystemService {
}
}
private async createDirectories(vault: Vault, filePath: string) {
private async createDirectories(vaultService: VaultService, filePath: string) {
const dirPath: string = filePath.substring(0, filePath.lastIndexOf('/'));
const dirs: string[] = dirPath.split('/');
@ -119,8 +121,8 @@ export class FileSystemService {
for (const dir of dirs) {
if (dir) {
currentPath = currentPath ? `${currentPath}/${dir}` : dir;
if (vault.getAbstractFileByPath(currentPath) == null) {
await vault.createFolder(currentPath);
if (vaultService.getAbstractFileByPath(currentPath) == null) {
await vaultService.createFolder(currentPath);
}
}
}

View file

@ -16,9 +16,11 @@ import { StreamingService } from "./StreamingService";
import { AIFunctionDefinitions } from "AIClasses/FunctionDefinitions/AIFunctionDefinitions";
import { WorkSpaceService } from "./WorkSpaceService";
import { ChatService } from "./ChatService";
import { VaultService } from "./VaultService";
export function RegisterDependencies(plugin: AIAgentPlugin) {
RegisterSingleton(Services.AIAgentPlugin, plugin);
RegisterSingleton(Services.VaultService, new VaultService());
RegisterSingleton(Services.MessageService, new MessageService());
RegisterSingleton(Services.WorkSpaceService, new WorkSpaceService());
RegisterSingleton(Services.FileSystemService, new FileSystemService());
@ -40,8 +42,7 @@ export function RegisterAiProvider(plugin: AIAgentPlugin) {
if (plugin.settings.apiProvider == AIProvider.Gemini) {
RegisterSingleton<IAIClass>(Services.IAIClass, new Gemini());
}
const chatService: ChatService = Resolve(Services.ChatService);
chatService.resolveAIProvider();
Resolve<ChatService>(Services.ChatService).resolveAIProvider();
}
function RegisterModals(app: App) {

View file

@ -1,6 +1,7 @@
export class Services {
static MessageService = Symbol("MessageService");
static AIAgentPlugin = Symbol("AIAgentPlugin");
static VaultService = Symbol("VaultService");
static MessageService = Symbol("MessageService");
static WorkSpaceService = Symbol("WorkSpaceService");
static FileSystemService = Symbol("FileSystemService");
static ConversationFileSystemService = Symbol("ConversationFileSystemService");

View file

@ -21,7 +21,7 @@ interface StreamingState {
}
export class StreamingMarkdownService {
private readonly fileSystemService: FileSystemService = Resolve(Services.FileSystemService);
private readonly fileSystemService: FileSystemService = Resolve<FileSystemService>(Services.FileSystemService);
private readonly processor: Processor<any, any, any, any, any> | null = null;
private streamingStates = new Map<string, StreamingState>();

99
Services/VaultService.ts Normal file
View file

@ -0,0 +1,99 @@
import type { TAbstractFile, TFile, TFolder, Vault } from "obsidian";
import { Resolve } from "./DependencyService";
import { Services } from "./Services";
import type AIAgentPlugin from "main";
import { Path } from "Enums/Path";
/* This service protects the users vault through their exclusions. The plugin root is excluded by default */
export class VaultService {
private readonly AGENT_ROOT = `${Path.Root}/**`;
private readonly plugin: AIAgentPlugin;
private readonly vault: Vault;
public constructor() {
this.plugin = Resolve<AIAgentPlugin>(Services.AIAgentPlugin);
this.vault = this.plugin.app.vault;
}
public getMarkdownFiles(): TFile[] {
return this.vault.getMarkdownFiles().filter(file => !this.isExclusion(file.path));
}
public getAbstractFileByPath(filePath: string): TAbstractFile | null {
if (this.isExclusion(filePath)) {
console.log(`Plugin attempted to retrieve a file that is in the exclusions list: ${filePath}`);
return null;
}
return this.vault.getAbstractFileByPath(filePath);
}
public async read(file: TFile): Promise<string> {
if (this.isExclusion(file.path)) {
console.log(`Plugin attempted to read a file that is in the exclusions list: ${file.path}`);
return "";
}
return await this.vault.read(file);
}
public async create(filePath: string, content: string): Promise<TFile> {
if (this.isExclusion(filePath)) {
throw new Error(`Plugin attempted to create a file that is in the exclusion list: ${filePath}`);
}
return await this.vault.create(filePath, content);
}
public async modify(file: TFile, content: string): Promise<void> {
if (this.isExclusion(file.path)) {
console.log(`Plugin attempted to modify a file that is in the exclusions list: ${file.path}`)
return;
}
await this.vault.modify(file, content);
}
public async delete(file: TAbstractFile, force?: boolean): Promise<void> {
if (this.isExclusion(file.path)) {
console.log(`Plugin attempted to delete a file that is in the exclusions list: ${file.path}`)
return;
}
await this.vault.delete(file, force);
}
public async createFolder(path: string): Promise<TFolder> {
if (this.isExclusion(path)) {
throw new Error(`Plugin attempted to create a folder that is in the exclusion list: ${path}`);
}
return await this.vault.createFolder(path);
}
/**
* Checks if a file should be excluded based on configured exclusion patterns.
* Supports exact matches and glob patterns (**, *).
*/
private isExclusion(filePath: string): boolean {
const exclusions = [this.AGENT_ROOT, ...this.plugin.settings.exclusions];
return exclusions.some(pattern => {
if (filePath === pattern) {
return true;
}
let regexPattern = pattern
.replace(/[.+?^${}()|[\]\\]/g, '\\$&') // Escape special chars
.replace(/\*\*/g, '::DOUBLESTAR::') // Temporarily replace **
.replace(/\*/g, '[^/]*') // * matches anything except /
.replace(/::DOUBLESTAR::/g, '.*'); // ** matches anything including /
// If pattern ends with /, match the directory and all its contents
if (pattern.endsWith('/')) {
regexPattern = regexPattern + '.*';
}
// Add anchors for full path matching
const regex = new RegExp('^' + regexPattern + '$');
return regex.test(filePath);
});
}
}

View file

@ -4,7 +4,7 @@ import { Services } from "./Services";
import type { TFile, WorkspaceLeaf } from "obsidian";
export class WorkSpaceService {
private readonly plugin: AIAgentPlugin = Resolve(Services.AIAgentPlugin);
private readonly plugin: AIAgentPlugin = Resolve<AIAgentPlugin>(Services.AIAgentPlugin);
public async openNote(noteName: string) {
const file: TFile | null = this.plugin.app.metadataCache.getFirstLinkpathDest(noteName, "");

View file

@ -8,11 +8,13 @@ import { AIAgentSettingTab } from 'AIAgentSettingTab';
interface AIAgentSettings {
apiProvider: string;
apiKey: string;
exclusions: string[];
}
const DEFAULT_SETTINGS: AIAgentSettings = {
apiProvider: AIProvider.Gemini,
apiKey: ""
apiKey: "",
exclusions: []
}
export default class AIAgentPlugin extends Plugin {

View file

@ -30,7 +30,7 @@
}
/* ============================== */
/* Settings API Key Highlighting */
/* Settings Styles */
/* ============================== */
.api-key-setting-ok input[type="text"],
@ -55,6 +55,13 @@
75% { transform: translateX(5px); }
}
.ai-exclusions-input {
width: 350px;
height: 110px;
font: var(--font-monospace);
font-size: 0.9em;
}
/* ============================== */
/* CSS Variables for Common Components */
/* ============================== */