diff --git a/Enums/Copy.ts b/Enums/Copy.ts index 5dde17f..85218f8 100644 --- a/Enums/Copy.ts +++ b/Enums/Copy.ts @@ -84,6 +84,12 @@ export enum Copy { TooltipLearnMoreFileMonitoring = "Learn more in Plugin Guide", TooltipAccessMemories = "View Memories", + SettingQuickActions = "Quick Actions", + SettingEnableContextMenuActions = "Enable Context Menu Actions", + SettingEnableContextMenuActionsDesc = "Show quick actions in the right-click editor context menu.", + SettingEnableToolbarActions = "Enable Toolbar Actions", + SettingEnableToolbarActionsDesc = "Show a quick actions button in the editor toolbar (mobile friendly).", + AIThoughtMessage = "Thinking...", AIThoughtGeneratingNote = "Generating note contents...", AIThoughtPreparingQuery = "Preparing user query...", diff --git a/Enums/Event.ts b/Enums/Event.ts index 64389e0..49abd6f 100644 --- a/Enums/Event.ts +++ b/Enums/Event.ts @@ -1,5 +1,6 @@ export enum Event { DiffOpened = "diffOpened", DiffClosed = "diffClosed", - RateLimitCountdown = "rateLimitCountdown" + RateLimitCountdown = "rateLimitCountdown", + QuickActionsSettingsChanged = "quickActionsSettingsChanged" } \ No newline at end of file diff --git a/Services/EventService.ts b/Services/EventService.ts index b7f6502..06fbe4a 100644 --- a/Services/EventService.ts +++ b/Services/EventService.ts @@ -6,6 +6,7 @@ export class EventService extends Events { public trigger(name: Event.DiffOpened, data?: unknown): void; public trigger(name: Event.DiffClosed, data?: unknown): void; public trigger(name: Event.RateLimitCountdown, delayMs: number): void; + public trigger(name: Event.QuickActionsSettingsChanged, data?: unknown): void; public trigger(name: string, ...data: unknown[]): void { super.trigger(name, ...data); diff --git a/Services/QuickActionsService.ts b/Services/QuickActionsService.ts index 963338e..1b8bd86 100644 --- a/Services/QuickActionsService.ts +++ b/Services/QuickActionsService.ts @@ -2,7 +2,7 @@ import { Resolve } from "./DependencyService"; import { Services } from "./Services"; import type { QuickAgent } from "./AIServices/QuickAgent"; import type VaultkeeperAIPlugin from "main"; -import { FuzzySuggestModal, MarkdownView, Menu, Notice, setIcon, TFile, type Editor, type MarkdownFileInfo } from "obsidian"; +import { FuzzySuggestModal, MarkdownView, Menu, Notice, setIcon, TFile, type Editor, type EventRef, type MarkdownFileInfo } from "obsidian"; import { FileSystemService } from "./FileSystemService"; import { BeautifyPrompt } from "AIPrompts/QuickActionPrompts/Beautify"; import Spinner from "Components/Spinner.svelte"; @@ -10,25 +10,40 @@ import { mount } from "svelte"; import { ApplyTemplatePrompt } from "AIPrompts/QuickActionPrompts/ApplyTemplatePrompt"; import { Copy } from "Enums/Copy"; import type { WorkSpaceService } from "./WorkSpaceService"; +import type { SettingsService } from "./SettingsService"; +import type { EventService } from "./EventService"; +import { Event } from "Enums/Event"; +import { openPluginSettings } from "Helpers/Helpers"; export class QuickActionsService { - private readonly actionTimeout: number = 30000; - private plugin: VaultkeeperAIPlugin; - private workSpaceService: WorkSpaceService + private workSpaceService: WorkSpaceService; private fileSystemService: FileSystemService; + private settingsService: SettingsService; + private eventService: EventService; + + private editorMenuEventRef: EventRef | null = null; + private layoutChangeEventRef: EventRef | null = null; public constructor() { this.plugin = Resolve(Services.VaultkeeperAIPlugin); this.workSpaceService = Resolve(Services.WorkSpaceService); this.fileSystemService = Resolve(Services.FileSystemService); + this.settingsService = Resolve(Services.SettingsService); + this.eventService = Resolve(Services.EventService); + + this.plugin.registerEvent( + this.eventService.on(Event.QuickActionsSettingsChanged, () => { + this.updateRegistrations(); + }) + ); - this.registerViewActions(); this.registerEditorMenuActions(); + this.registerViewActions(); } - /* Edit Menu Action Definitions */ + /* Action Definitions */ private async beautify(menu: Menu, editor: Editor, view: MarkdownView | MarkdownFileInfo) { const file = view.file; @@ -38,7 +53,7 @@ export class QuickActionsService { const selection = editor.getSelection(); const content = await this.fileSystemService.readFile(file); - + if (content instanceof Error || (selection.trim() === "" && content.trim() === "")) { return; // Either an excluded file or nothing to beautify } @@ -92,65 +107,90 @@ export class QuickActionsService { }); } - /* Registered Edit Menu Actions */ + /* Action Registration */ private registerEditorMenuActions() { - // Beautify - this.plugin.registerEvent( - this.plugin.app.workspace.on("editor-menu", (menu, editor, view) => { - menu.addItem((item) => { + if (!this.settingsService.settings.enableContextMenuActions) { + if (this.editorMenuEventRef) { + this.plugin.app.workspace.offref(this.editorMenuEventRef); + this.editorMenuEventRef = null; + } + return; + } + if (this.editorMenuEventRef) { + return; + } + + this.editorMenuEventRef = this.plugin.app.workspace.on("editor-menu", (menu, editor, view) => { + menu.addItem((item) => { item.setTitle("Beautify") .setIcon("palette") .onClick(async () => this.beautify(menu, editor, view)); - }); - }) - ); - - // Apply Template - this.plugin.registerEvent( - this.plugin.app.workspace.on("editor-menu", (menu, editor, view) => { - menu.addItem((item) => { + }); + menu.addItem((item) => { item.setTitle("Apply template") .setIcon("notepad-text-dashed") .onClick(async () => this.applyTemplate(menu, editor, view)); - }); - }) - ); + }); + }); + this.plugin.registerEvent(this.editorMenuEventRef); } private registerViewActions() { - this.plugin.registerEvent( - this.plugin.app.workspace.on("layout-change", () => { - const actionsView = this.workSpaceService.getViewActionsView(); - if (actionsView) { - if (actionsView.querySelector(".vault-keeper-ai-actions")) { - return; - } - const button = createEl("button", { cls: "clickable-icon view-action vault-keeper-ai-actions" }); - button.setAttribute('aria-label', 'AI Quick Actions'); - button.addEventListener('click', (evt) => { - const view = this.workSpaceService.getActiveViewOfType(MarkdownView); - if (view) { - const { editor } = view; - const menu = new Menu(); - menu.addItem((item) => - item.setTitle("Beautify") - .setIcon("palette") - .onClick(async () => this.beautify(menu, editor, view)) - ); - menu.addItem((item) => - item.setTitle("Apply template") - .setIcon("notepad-text-dashed") - .onClick(async () => this.applyTemplate(menu, editor, view)) - ); - menu.showAtMouseEvent(evt); - } - }); - setIcon(button, "sparkles"); - actionsView.prepend(button); - } - }) - ); + if (!this.settingsService.settings.enableToolbarActions) { + if (this.layoutChangeEventRef) { + this.plugin.app.workspace.offref(this.layoutChangeEventRef); + this.layoutChangeEventRef = null; + // Remove any existing toolbar buttons + this.plugin.app.workspace.containerEl + .querySelectorAll(".vault-keeper-ai-actions") + .forEach(el => el.remove()); + } + return; + } + if (this.layoutChangeEventRef) { + return; + } + + this.layoutChangeEventRef = this.plugin.app.workspace.on("layout-change", () => { + this.injectToolbarButton(); + }); + this.plugin.registerEvent(this.layoutChangeEventRef); + this.injectToolbarButton(); + } + + private injectToolbarButton() { + const actionsView = this.workSpaceService.getViewActionsView(); + if (!actionsView || actionsView.querySelector(".vault-keeper-ai-actions")) { + return; + } + const button = createEl("button", { cls: "clickable-icon view-action vault-keeper-ai-actions" }); + button.setAttribute('aria-label', 'AI quick actions'); + button.addEventListener('click', (evt) => { + const view = this.workSpaceService.getActiveViewOfType(MarkdownView); + if (view) { + const { editor } = view; + const menu = new Menu(); + menu.addItem((item) => + item.setTitle("Beautify") + .setIcon("palette") + .onClick(async () => this.beautify(menu, editor, view)) + ); + menu.addItem((item) => + item.setTitle("Apply template") + .setIcon("notepad-text-dashed") + .onClick(async () => this.applyTemplate(menu, editor, view)) + ); + menu.showAtMouseEvent(evt); + } + }); + setIcon(button, "sparkles"); + actionsView.prepend(button); + } + + private updateRegistrations() { + this.registerEditorMenuActions(); + this.registerViewActions(); } /* Helpers */ @@ -166,14 +206,13 @@ export class QuickActionsService { } private async newAction(action: string, context: string): Promise { + if (this.settingsService.getApiKeyForCurrentModel().trim() == "") { + openPluginSettings(this.plugin); + return null; + } const agent = Resolve(Services.QuickAgent); agent.resolveAIProvider(); - - const timeoutPromise = new Promise((_, reject) => - activeWindow.setTimeout(() => reject(new DOMException(`Action timed out after ${this.actionTimeout}s`, "AbortError")), this.actionTimeout) - ); - - return Promise.race([agent.quickAction(action, context), timeoutPromise]); + return agent.quickAction(action, context); } private showNotice(message: string): Notice { @@ -186,10 +225,10 @@ export class QuickActionsService { height: "var(--size-4-4)", background: "var(--background-modifier-message)" }}); - + container.createSpan({ text: message }); fragment.appendChild(container); - + return new Notice(fragment, 0); } } \ No newline at end of file diff --git a/Services/SettingsService.ts b/Services/SettingsService.ts index 3f8359f..dfae81c 100644 --- a/Services/SettingsService.ts +++ b/Services/SettingsService.ts @@ -27,7 +27,10 @@ const DEFAULT_SETTINGS: IVaultkeeperAISettings = { allowUpdatingMemories: true, enableWebSearch: true, - enableWebViewer: false + enableWebViewer: false, + + enableContextMenuActions: true, + enableToolbarActions: true } export interface IVaultkeeperAISettings { @@ -55,6 +58,9 @@ export interface IVaultkeeperAISettings { enableWebSearch: boolean; enableWebViewer: boolean; + + enableContextMenuActions: boolean; + enableToolbarActions: boolean; } export class SettingsService { diff --git a/Services/WorkSpaceService.ts b/Services/WorkSpaceService.ts index 2ccca7b..a850960 100644 --- a/Services/WorkSpaceService.ts +++ b/Services/WorkSpaceService.ts @@ -40,7 +40,7 @@ export class WorkSpaceService { return activeFile; } - public getActiveViewOfType(type: new (...args: any[]) => ViewType): ViewType | null { + public getActiveViewOfType(type: new (...args: WorkspaceLeaf[]) => ViewType): ViewType | null { return this.plugin.app.workspace.getActiveViewOfType(type); } diff --git a/Views/VaultkeeperAISettingTab.ts b/Views/VaultkeeperAISettingTab.ts index 61e7452..0cfb2e4 100644 --- a/Views/VaultkeeperAISettingTab.ts +++ b/Views/VaultkeeperAISettingTab.ts @@ -1,10 +1,12 @@ import { AIProvider, AIProviderModel, fromModel, isValidProviderModel } from "Enums/ApiProvider"; import { Copy } from "Enums/Copy"; +import { Event } from "Enums/Event"; import { Selector } from "Enums/Selector"; import type VaultkeeperAIPlugin from "main"; import { HelpModal } from "Modals/HelpModal"; import { DropdownComponent, PluginSettingTab, Setting, ToggleComponent, setIcon, setTooltip } from "obsidian"; 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"; @@ -15,6 +17,7 @@ export class VaultkeeperAISettingTab extends PluginSettingTab { private readonly plugin: VaultkeeperAIPlugin; private readonly settingsService: SettingsService; private readonly memoriesService: MemoriesService; + private readonly eventService: EventService; private apiKeySetting: Setting | null = null; private apiKeyInputEl: HTMLInputElement | null = null; @@ -32,6 +35,7 @@ export class VaultkeeperAISettingTab extends PluginSettingTab { this.settingsService = Resolve(Services.SettingsService); this.memoriesService = Resolve(Services.MemoriesService); + this.eventService = Resolve(Services.EventService); } public display() { @@ -263,7 +267,42 @@ export class VaultkeeperAISettingTab extends PluginSettingTab { }); setIcon(button.extraSettingsEl, "clipboard-clock"); }); - this.updateFileDisclaimer(); + this.updateFileDisclaimer(); + + /* Quick Actions Header */ + new Setting(containerEl) + .setHeading() + .setName(Copy.SettingQuickActions); + + /* Enable Context Menu Actions */ + new Setting(containerEl) + .setName(Copy.SettingEnableContextMenuActions) + .setDesc(Copy.SettingEnableContextMenuActionsDesc) + .addToggle(toggle => { + toggle + .setValue(this.settingsService.settings.enableContextMenuActions) + .onChange(async (value) => { + this.settingsService.settings.enableContextMenuActions = value; + await this.settingsService.saveSettings(() => + this.eventService.trigger(Event.QuickActionsSettingsChanged) + ); + }); + }); + + /* Enable Toolbar Actions */ + new Setting(containerEl) + .setName(Copy.SettingEnableToolbarActions) + .setDesc(Copy.SettingEnableToolbarActionsDesc) + .addToggle(toggle => { + toggle + .setValue(this.settingsService.settings.enableToolbarActions) + .onChange(async (value) => { + this.settingsService.settings.enableToolbarActions = value; + await this.settingsService.saveSettings(() => + this.eventService.trigger(Event.QuickActionsSettingsChanged) + ); + }); + }); } private populateModelDropdown(dropdown: DropdownComponent, providerFilter?: AIProvider): void { diff --git a/__tests__/Services/SettingsService.test.ts b/__tests__/Services/SettingsService.test.ts index 6d3139a..dad8b63 100644 --- a/__tests__/Services/SettingsService.test.ts +++ b/__tests__/Services/SettingsService.test.ts @@ -107,7 +107,9 @@ describe('SettingsService', () => { enableWebSearch: true, enableWebViewer: false, provider: AIProvider.Claude, - quickActionModel: AIProviderModel.ClaudeSonnet_4_6 + quickActionModel: AIProviderModel.ClaudeSonnet_4_6, + enableContextMenuActions: false, + enableToolbarActions: false }; settingsService = new SettingsService(loadedSettings); }); @@ -154,7 +156,9 @@ describe('SettingsService', () => { enableWebSearch: true, enableWebViewer: false, provider: AIProvider.Claude, - quickActionModel: AIProviderModel.ClaudeSonnet_4_6 + quickActionModel: AIProviderModel.ClaudeSonnet_4_6, + enableContextMenuActions: false, + enableToolbarActions: false }; settingsService = new SettingsService(loadedSettings); @@ -181,7 +185,9 @@ describe('SettingsService', () => { enableWebSearch: true, enableWebViewer: false, provider: AIProvider.OpenAI, - quickActionModel: AIProviderModel.GPT_5_4_Mini + quickActionModel: AIProviderModel.GPT_5_4_Mini, + enableContextMenuActions: false, + enableToolbarActions: false }; settingsService = new SettingsService(loadedSettings); @@ -208,7 +214,9 @@ describe('SettingsService', () => { enableWebSearch: true, enableWebViewer: false, provider: AIProvider.Gemini, - quickActionModel: AIProviderModel.GeminiFlash_2_5 + quickActionModel: AIProviderModel.GeminiFlash_2_5, + enableContextMenuActions: false, + enableToolbarActions: false }; settingsService = new SettingsService(loadedSettings); @@ -263,7 +271,9 @@ describe('SettingsService', () => { enableWebSearch: true, enableWebViewer: false, provider: AIProvider.Claude, - quickActionModel: AIProviderModel.ClaudeSonnet_4_6 + quickActionModel: AIProviderModel.ClaudeSonnet_4_6, + enableContextMenuActions: false, + enableToolbarActions: false }; settingsService = new SettingsService(loadedSettings); }); @@ -324,7 +334,9 @@ describe('SettingsService', () => { enableWebSearch: true, enableWebViewer: false, provider: AIProvider.Claude, - quickActionModel: AIProviderModel.ClaudeSonnet_4_6 + quickActionModel: AIProviderModel.ClaudeSonnet_4_6, + enableContextMenuActions: false, + enableToolbarActions: false }; settingsService = new SettingsService(loadedSettings); }); diff --git a/__tests__/Services/VaultCacheService.performance.test.ts b/__tests__/Services/VaultCacheService.performance.test.ts index 44d048e..f085aed 100644 --- a/__tests__/Services/VaultCacheService.performance.test.ts +++ b/__tests__/Services/VaultCacheService.performance.test.ts @@ -105,7 +105,9 @@ const mockSettings: IVaultkeeperAISettings = { enableWebSearch: true, enableWebViewer: false, provider: AIProvider.Claude, - quickActionModel: AIProviderModel.ClaudeSonnet_4_6 + quickActionModel: AIProviderModel.ClaudeSonnet_4_6, + enableContextMenuActions: false, + enableToolbarActions: false }; let settingsService: SettingsService; diff --git a/__tests__/Services/VaultService.pdf.test.ts b/__tests__/Services/VaultService.pdf.test.ts index b22be2c..6eafc5c 100644 --- a/__tests__/Services/VaultService.pdf.test.ts +++ b/__tests__/Services/VaultService.pdf.test.ts @@ -65,7 +65,9 @@ const mockSettings: IVaultkeeperAISettings = { enableWebSearch: true, enableWebViewer: false, provider: AIProvider.Claude, - quickActionModel: AIProviderModel.ClaudeSonnet_4_6 + quickActionModel: AIProviderModel.ClaudeSonnet_4_6, + enableContextMenuActions: false, + enableToolbarActions: false }; const mockPlugin = { diff --git a/__tests__/Services/VaultService.performance.test.ts b/__tests__/Services/VaultService.performance.test.ts index 89b5033..132772a 100644 --- a/__tests__/Services/VaultService.performance.test.ts +++ b/__tests__/Services/VaultService.performance.test.ts @@ -67,7 +67,9 @@ const mockSettings: IVaultkeeperAISettings = { enableWebSearch: true, enableWebViewer: false, provider: AIProvider.Claude, - quickActionModel: AIProviderModel.ClaudeSonnet_4_6 + quickActionModel: AIProviderModel.ClaudeSonnet_4_6, + enableContextMenuActions: false, + enableToolbarActions: false }; let settingsService: SettingsService; diff --git a/__tests__/Services/VaultService.test.ts b/__tests__/Services/VaultService.test.ts index c339f2e..512f64c 100644 --- a/__tests__/Services/VaultService.test.ts +++ b/__tests__/Services/VaultService.test.ts @@ -64,7 +64,9 @@ const mockSettings: IVaultkeeperAISettings = { enableWebSearch: true, enableWebViewer: false, provider: AIProvider.Claude, - quickActionModel: AIProviderModel.ClaudeSonnet_4_6 + quickActionModel: AIProviderModel.ClaudeSonnet_4_6, + enableContextMenuActions: false, + enableToolbarActions: false }; const mockPlugin = { diff --git a/package-lock.json b/package-lock.json index 5785f07..2f0d4ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "MIT", "dependencies": { - "@anthropic-ai/sdk": "^0.90.0", + "@anthropic-ai/sdk": "^0.91.0", "@google/genai": "^1.50.1", "@shikijs/rehype": "^4.0.2", "core-js": "^3.49.0", @@ -55,7 +55,7 @@ "eslint-plugin-obsidianmd": "^0.2.4", "happy-dom": "^20.9.0", "obsidian": "latest", - "svelte": "^5.55.4", + "svelte": "^5.55.5", "svelte-check": "^4.4.6", "svelte-preprocess": "^6.0.3", "tslib": "2.8.1", @@ -64,9 +64,9 @@ } }, "node_modules/@anthropic-ai/sdk": { - "version": "0.90.0", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.90.0.tgz", - "integrity": "sha512-MzZtPabJF1b0FTDl6Z6H5ljphPwACLGP13lu8MTiB8jXaW/YXlpOp+Po2cVou3MPM5+f5toyLnul9whKCy7fBg==", + "version": "0.91.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.0.tgz", + "integrity": "sha512-hybd/DOI3ujG4gZyqqcWnSekYxkdjr1JbZYqP2Lb4AmcsU6HCTHSrTOgqedPSsQAruBVucHNAoD1vTQnpPzedw==", "license": "MIT", "dependencies": { "json-schema-to-ts": "^3.1.1" @@ -3725,14 +3725,14 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.20.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", - "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "version": "5.21.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", + "integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==", "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", - "tapable": "^2.3.0" + "tapable": "^2.3.3" }, "engines": { "node": ">=10.13.0" @@ -10270,9 +10270,9 @@ } }, "node_modules/svelte": { - "version": "5.55.4", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.4.tgz", - "integrity": "sha512-q8DFohk6vUswSng95IZb9nzWJnbINZsK7OiM1snAa3qCjJBL0ZQpvMyAaVXjUukdM75J/m8UE8xwqat8Ors/zQ==", + "version": "5.55.5", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.5.tgz", + "integrity": "sha512-2uCs/LZ9us+AktdzYJM8OcxQ8qnPS1kpaO7syGT/MgO+6Qr1Ybl+TqPq+97u7PHqmmMlye5ZkoyXONy5mjjAbw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index e275521..50900a1 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "eslint-plugin-obsidianmd": "^0.2.4", "happy-dom": "^20.9.0", "obsidian": "latest", - "svelte": "^5.55.4", + "svelte": "^5.55.5", "svelte-check": "^4.4.6", "svelte-preprocess": "^6.0.3", "tslib": "2.8.1", @@ -44,7 +44,7 @@ "vitest": "^4.1.5" }, "dependencies": { - "@anthropic-ai/sdk": "^0.90.0", + "@anthropic-ai/sdk": "^0.91.0", "@google/genai": "^1.50.1", "@shikijs/rehype": "^4.0.2", "core-js": "^3.49.0",