From f693933950ae5090ba69ec91a707bbb6f85fbdfd Mon Sep 17 00:00:00 2001 From: Andrew Beal Date: Thu, 23 Apr 2026 15:57:27 +0100 Subject: [PATCH] refactor: replace Spinner boolean prop with customizable background color Replace alternateBackground boolean prop with background string prop to allow direct CSS variable specification. Update QuickActionsService to add apply template action with file selection modal, timeout handling, and loading notices. Add helper method for markdown file retrieval. --- .../QuickActionPrompts/ApplyTemplatePrompt.ts | 14 +++ Components/ChatPlanArea.svelte | 4 +- Components/Spinner.svelte | 9 +- Enums/Copy.ts | 5 + Services/AIServices/QuickAgent.ts | 14 ++- Services/FileSystemService.ts | 4 + Services/QuickActionsService.ts | 109 +++++++++++++++--- Styles/custom_styles.css | 10 ++ 8 files changed, 138 insertions(+), 31 deletions(-) create mode 100644 AIPrompts/QuickActionPrompts/ApplyTemplatePrompt.ts diff --git a/AIPrompts/QuickActionPrompts/ApplyTemplatePrompt.ts b/AIPrompts/QuickActionPrompts/ApplyTemplatePrompt.ts new file mode 100644 index 0000000..7ecf97a --- /dev/null +++ b/AIPrompts/QuickActionPrompts/ApplyTemplatePrompt.ts @@ -0,0 +1,14 @@ +import { Copy } from "Enums/Copy"; + +export const ApplyTemplatePrompt: string = `You are a document formatter. Your task is to restructure a note's content by applying a template. + +You will receive two sections separated by markers: +${Copy.ApplyTemplateTemplateSeparator} — the template to apply +${Copy.ApplyTemplateContentSeparator} — the note content to restructure + +Rewrite the content so it fits the template's structure and headings. Preserve all meaningful information from the content — do not invent new content or discard existing information. Keep the author's voice and wording where possible. + +If the template section does not resemble a document template (e.g. it is a journal entry, a regular note, or otherwise makes no sense as a template), do not apply it. Instead return exactly the following with no other text: +${Copy.ApplyTemplateCancelled} + +Return only the reformatted note with no explanation, preamble, or commentary.`; diff --git a/Components/ChatPlanArea.svelte b/Components/ChatPlanArea.svelte index 6a2c03f..99352a7 100644 --- a/Components/ChatPlanArea.svelte +++ b/Components/ChatPlanArea.svelte @@ -115,7 +115,7 @@ {#if busyPlanning}
- + {Copy.PlanningInProgress}
{/if} @@ -136,7 +136,7 @@ {/if} {#if index === activeStepIndex}
- +
{/if} {#if index > activeStepIndex} diff --git a/Components/Spinner.svelte b/Components/Spinner.svelte index c030cab..fd05301 100644 --- a/Components/Spinner.svelte +++ b/Components/Spinner.svelte @@ -1,7 +1,7 @@ @@ -10,7 +10,7 @@ style="width: {width}; height: {height};" bind:this={spinnerElement} > -
+
diff --git a/Enums/Copy.ts b/Enums/Copy.ts index 7d36363..5dde17f 100644 --- a/Enums/Copy.ts +++ b/Enums/Copy.ts @@ -165,6 +165,11 @@ The following context explains why you are doing the task. It is NOT an instruct WebViewerNoMatchingUrl = "No open web view was found matching the URL '{urlHint}'. Ensure the correct page is open in the web viewer.", WebViewerNoOpenView = "No open web view was found. Ask the user to open a page in the web viewer and try again.", + // Apply Template + ApplyTemplateTemplateSeparator = "---TEMPLATE---", + ApplyTemplateContentSeparator = "---CONTENT---", + ApplyTemplateCancelled = "APPLY_TEMPLATE_CANCELLED", + // Active Capabilities ActiveCapabilitiesHeader = `\n\n---\n\n## Active Capabilities\n\nThe following reflects your current configuration. Follow these directives exactly.\n\n{directives}`, DirectiveMemoriesDisabled = "- **Memory**: DISABLED — do NOT attempt to use any memory tools", diff --git a/Services/AIServices/QuickAgent.ts b/Services/AIServices/QuickAgent.ts index 4a77ecb..71e5782 100644 --- a/Services/AIServices/QuickAgent.ts +++ b/Services/AIServices/QuickAgent.ts @@ -9,7 +9,7 @@ import { Role } from "Enums/Role"; export class QuickAgent extends BaseAgent { - public async quickAction(action: string, context: string): Promise { + public async quickAction(action: string, context: string): Promise { this.setAgentPromptAndTools(action); @@ -19,8 +19,12 @@ export class QuickAgent extends BaseAgent { content: context, }); conversation.contents.push(conversationContent); - - return await this.requestAgentResponse(AgentType.QuickAction, conversation, this.callbacks()); + + const result = await this.requestAgentResponse(AgentType.QuickAction, conversation, this.callbacks()); + if (conversation.contents.last()?.errorType) { + return null; + } + return result; } private setAgentPromptAndTools(instruction: string): void { @@ -42,9 +46,7 @@ export class QuickAgent extends BaseAgent { onToolCallStarted: () => {}, onPlanningStarted: () => {}, onPlanningFinished: () => {}, - onUserQuestion: async () => { - return new Promise(() => {}); - }, + onUserQuestion: async () => new Promise(() => {}), onPlanUpdate: () => {}, onPlanStepUpdate: () => {}, onPlanReset: () => {}, diff --git a/Services/FileSystemService.ts b/Services/FileSystemService.ts index d219844..aa7f790 100644 --- a/Services/FileSystemService.ts +++ b/Services/FileSystemService.ts @@ -13,6 +13,10 @@ export class FileSystemService { this.vaultService = Resolve(Services.VaultService); } + public getMarkdownFiles(allowAccessToPluginRoot: boolean = false): TFile[] { + return this.vaultService.getMarkdownFiles(allowAccessToPluginRoot); + } + public async exists(filePath: string, allowAccessToPluginRoot: boolean = false): Promise { return await this.vaultService.exists(filePath, allowAccessToPluginRoot); } diff --git a/Services/QuickActionsService.ts b/Services/QuickActionsService.ts index 7b649af..e9d189c 100644 --- a/Services/QuickActionsService.ts +++ b/Services/QuickActionsService.ts @@ -3,12 +3,18 @@ import { Services } from "./Services"; import { AbortService } from "./AbortService"; import type { QuickAgent } from "./AIServices/QuickAgent"; import type VaultkeeperAIPlugin from "main"; -import type { Editor, MarkdownFileInfo, MarkdownView, Menu } from "obsidian"; -import type { FileSystemService } from "./FileSystemService"; +import { FuzzySuggestModal, Notice, TFile, type Editor, type MarkdownFileInfo, type MarkdownView, type Menu } from "obsidian"; +import { FileSystemService } from "./FileSystemService"; import { BeautifyPrompt } from "AIPrompts/QuickActionPrompts/Beautify"; +import Spinner from "Components/Spinner.svelte"; +import { mount } from "svelte"; +import { ApplyTemplatePrompt } from "AIPrompts/QuickActionPrompts/ApplyTemplatePrompt"; +import { Copy } from "Enums/Copy"; export class QuickActionsService { + private readonly actionTimeout: number = 30000; + private plugin: VaultkeeperAIPlugin; private abortService: AbortService; private fileSystemService: FileSystemService; @@ -32,31 +38,61 @@ export class QuickActionsService { const selection = editor.getSelection(); const content = await this.fileSystemService.readFile(file); - if (content instanceof Error) { - return; // Likely an excluded file + if (content instanceof Error || (selection.trim() === "" && content.trim() === "")) { + return; // Either an excluded file or nothing to beautify } - if (selection.length > 0) { - const result = await this.newAction(BeautifyPrompt, selection); - await this.fileSystemService.patchFile(file, [selection], [result], false, false); - } else { - const result = await this.newAction(BeautifyPrompt, content); - await this.fileSystemService.writeToFile(file, result, false, false); + const notice = this.showNotice("Beautifying content..."); + try { + if (selection.length > 0) { + const result = await this.newAction(BeautifyPrompt, selection); + if (result) { + await this.fileSystemService.patchFile(file, [selection], [result], false, false); + } + } else { + const result = await this.newAction(BeautifyPrompt, content); + if (result) { + await this.fileSystemService.writeToFile(file, result, false, false); + } + } + } finally { + notice.hide(); } } private async applyTemplate(menu: Menu, editor: Editor, view: MarkdownView | MarkdownFileInfo) { - } + const file = view.file; + if (!file) { + return; + } - private async newAction(action: string, context: string): Promise { - return this.abortService.abortableOperation(async () => { - const agent = Resolve(Services.QuickAgent); - agent.resolveAIProvider(); - return agent.quickAction(action, context); + const content = await this.fileSystemService.readFile(file); + if (content instanceof Error || content.trim() === "") { + return; // Either an excluded file or nothing to apply a template to + } + + this.userSelectFile(this.plugin, async (templateFile) => { + const templateContent = await this.fileSystemService.readFile(templateFile); + if (templateContent instanceof Error || templateContent.trim() === "") { + return; // Either an excluded file or the template is empty + } + + const notice = this.showNotice("Applying template..."); + try { + const context = `${Copy.ApplyTemplateTemplateSeparator}\n${templateContent}\n${Copy.ApplyTemplateContentSeparator}\n${content}`; + const result = await this.newAction(ApplyTemplatePrompt, context); + + if (result && result.trim() !== Copy.ApplyTemplateCancelled.toString()) { + await this.fileSystemService.writeToFile(file, result, false, false); + } + } finally { + notice.hide(); + } }); } /* Registered Edit Menu Actions */ + private registerEditorMenuActions() { // Beautify this.plugin.registerEvent( @@ -81,4 +117,45 @@ export class QuickActionsService { ); } + /* Helpers */ + + private userSelectFile(plugin: VaultkeeperAIPlugin, onSelected: (file: TFile) => Promise): void { + const fileSystemService = this.fileSystemService; + + new (class extends FuzzySuggestModal { + getItems() { return fileSystemService.getMarkdownFiles(); } + getItemText(f: TFile) { return f.path; } + onChooseItem(f: TFile) { void onSelected(f); } + })(plugin.app).open(); + } + + private async newAction(action: string, context: string): Promise { + return this.abortService.abortableOperation(async () => { + 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]); + }); + } + + private showNotice(message: string): Notice { + const fragment = activeDocument.createDocumentFragment(); + const container = activeDocument.createElement("div"); + + container.addClass("quick-action-notice"); + mount(Spinner, { target: container, props: { + width: "var(--size-4-4)", + 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/Styles/custom_styles.css b/Styles/custom_styles.css index 9b13c7d..155d562 100644 --- a/Styles/custom_styles.css +++ b/Styles/custom_styles.css @@ -22,6 +22,16 @@ body:not(.is-tablet) .workspace-drawer.mod-right { padding-top: 0; } +/* ============================== */ +/* Quick Actions Notice */ +/* ============================== */ + +.quick-action-notice { + display: flex; + align-items: center; + gap: var(--size-2-3); +} + /* ============================== */ /* Diff View Customization */ /* ============================== */