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