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 */ /* ============================== */