Improve custom command (v3) (#1446)

* Trim responses and auto focus replace on finishing
* Make textarea auto scroll to bottom when generating
* Support custom prompt syntax in custom command
* Support follow up instruction
* Fix unit test
* Allow followup instruction to use custom prompt syntax
This commit is contained in:
Zero Liu 2025-04-13 17:40:42 -07:00 committed by GitHub
parent 648412a914
commit fe9b9311ba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 696 additions and 309 deletions

View file

@ -6,132 +6,107 @@ export const COMMAND_NAME_MAX_LENGTH = 50;
export const DEFAULT_INLINE_EDIT_COMMANDS: InlineEditCommandSettings[] = [
{
name: "Fix grammar and spelling",
prompt:
`<instruction>Fix the grammar and spelling of the text below. Preserve all formatting, line breaks, and special characters. Do not add or remove any content. Return only the corrected text.</instruction>\n\n` +
`<text>${SELECTED_TEXT_PLACEHOLDER}</text>`,
prompt: `Fix the grammar and spelling of {}. Preserve all formatting, line breaks, and special characters. Do not add or remove any content. Return only the corrected text.`,
showInContextMenu: true,
},
{
name: "Translate to Chinese",
prompt:
`<instruction>Translate the text below into Chinese:
prompt: `Translate {} into Chinese:
1. Preserve the meaning and tone
2. Maintain appropriate cultural context
3. Keep formatting and structure
Return only the translated text.</instruction>\n\n` +
`<text>${SELECTED_TEXT_PLACEHOLDER}</text>`,
Return only the translated text.`,
showInContextMenu: true,
},
{
name: "Summarize",
prompt:
`<instruction>Create a bullet-point summary of the text below. Each bullet point should capture a key point. Return only the bullet-point summary.</instruction>\n\n` +
`<text>${SELECTED_TEXT_PLACEHOLDER}</text>`,
prompt: `Create a bullet-point summary of {}. Each bullet point should capture a key point. Return only the bullet-point summary.`,
showInContextMenu: true,
},
{
name: "Simplify",
prompt:
`<instruction>Simplify the text below to a 6th-grade reading level (ages 11-12). Use simple sentences, common words, and clear explanations. Maintain the original key concepts. Return only the simplified text.</instruction>\n\n` +
`<text>{copilot-selection}</text>`,
prompt: `Simplify {} to a 6th-grade reading level (ages 11-12). Use simple sentences, common words, and clear explanations. Maintain the original key concepts. Return only the simplified text.`,
showInContextMenu: true,
},
{
name: "Emojify",
prompt:
`<instruction>Add relevant emojis to enhance the text below. Follow these rules:
prompt: `Add relevant emojis to enhance {}. Follow these rules:
1. Insert emojis at natural breaks in the text
2. Never place two emojis next to each other
3. Keep all original text unchanged
4. Choose emojis that match the context and tone
Return only the emojified text.</instruction>\n\n` + `<text>{copilot-selection}</text>`,
Return only the emojified text.`,
showInContextMenu: true,
},
{
name: "Make shorter",
prompt:
`<instruction>Reduce the text below to half its length while preserving these elements:
prompt: `Reduce {} to half its length while preserving these elements:
1. Main ideas and key points
2. Essential details
3. Original tone and style
Return only the shortened text.</instruction>\n\n` +
`<text>${SELECTED_TEXT_PLACEHOLDER}</text>`,
Return only the shortened text.`,
showInContextMenu: true,
},
{
name: "Make longer",
prompt:
`<instruction>Expand the text below to twice its length by:
prompt: `Expand {} to twice its length by:
1. Adding relevant details and examples
2. Elaborating on key points
3. Maintaining the original tone and style
Return only the expanded text.</instruction>\n\n` + `<text>${SELECTED_TEXT_PLACEHOLDER}</text>`,
Return only the expanded text.`,
showInContextMenu: true,
},
{
name: "Generate table of contents",
prompt:
`<instruction>Generate a hierarchical table of contents for the text below. Use appropriate heading levels (H1, H2, H3, etc.). Include page numbers if present. Return only the table of contents.</instruction>\n\n` +
`<text>${SELECTED_TEXT_PLACEHOLDER}</text>`,
prompt: `Generate a hierarchical table of contents for {}. Use appropriate heading levels (H1, H2, H3, etc.). Include page numbers if present. Return only the table of contents.`,
showInContextMenu: false,
},
{
name: "Generate glossary",
prompt:
`<instruction>Create a glossary of important terms, concepts, and phrases from the text below. Format each entry as "Term: Definition". Sort entries alphabetically. Return only the glossary.</instruction>\n\n` +
`<text>{copilot-selection}</text>`,
prompt: `Create a glossary of important terms, concepts, and phrases from {}. Format each entry as "Term: Definition". Sort entries alphabetically. Return only the glossary.`,
showInContextMenu: false,
},
{
name: "Remove URLs",
prompt:
`<instruction>Remove all URLs from the text below. Preserve all other content and formatting. URLs may be in various formats (http, https, www). Return only the text with URLs removed.</instruction>\n\n` +
`<text>${SELECTED_TEXT_PLACEHOLDER}</text>`,
prompt: `Remove all URLs from {}. Preserve all other content and formatting. URLs may be in various formats (http, https, www). Return only the text with URLs removed.`,
showInContextMenu: false,
},
{
name: "Rewrite as tweet",
prompt:
`<instruction>Rewrite the text below as a single tweet with these requirements:
prompt: `Rewrite {} as a single tweet with these requirements:
1. Maximum 280 characters
2. Use concise, impactful language
3. Maintain the core message
Return only the tweet text.</instruction>\n\n` + `<text>${SELECTED_TEXT_PLACEHOLDER}</text>`,
Return only the tweet text.`,
showInContextMenu: false,
},
{
name: "Rewrite as tweet thread",
prompt:
`<instruction>Convert the text below into a Twitter thread following these rules:
prompt: `Convert {} into a Twitter thread following these rules:
1. Each tweet must be under 240 characters
2. Start with "THREAD START" on its own line
3. Separate tweets with "\n\n---\n\n"
4. End with "THREAD END" on its own line
5. Make content engaging and clear
Return only the formatted thread.</instruction>\n\n` +
`<text>${SELECTED_TEXT_PLACEHOLDER}</text>`,
Return only the formatted thread.`,
showInContextMenu: false,
},
{
name: "Explain like I am 5",
prompt:
`<instruction>Explain the text below in simple terms that a 5-year-old would understand:
prompt: `Explain {} in simple terms that a 5-year-old would understand:
1. Use basic vocabulary
2. Include simple analogies
3. Break down complex concepts
Return only the simplified explanation.</instruction>\n\n` +
`<text>${SELECTED_TEXT_PLACEHOLDER}</text>`,
Return only the simplified explanation.`,
showInContextMenu: false,
},
{
name: "Rewrite as press release",
prompt:
`<instruction>Transform the text below into a professional press release:
prompt: `Transform {} into a professional press release:
1. Use formal, journalistic style
2. Include headline and dateline
3. Follow inverted pyramid structure
Return only the press release format.</instruction>\n\n` +
`<text>${SELECTED_TEXT_PLACEHOLDER}</text>`,
Return only the press release format.`,
showInContextMenu: false,
},
];

View file

@ -1,8 +1,9 @@
import {
COMMAND_NAME_MAX_LENGTH,
DEFAULT_INLINE_EDIT_COMMANDS,
SELECTED_TEXT_PLACEHOLDER,
SELECTED_TEXT_PLACEHOLDER as LEGACY_SELECTED_TEXT_PLACEHOLDER,
} from "@/commands/constants";
import { processPrompt } from "@/customPromptProcessor";
import { InlineEditCommandSettings, getSettings, useSettingsValue } from "@/settings/model";
export function getCommandId(commandName: string) {
@ -54,16 +55,42 @@ export function useInlineEditCommands(): InlineEditCommandSettings[] {
}
/**
* Replace the {copilot-selection} placeholder with the selected text.
* If the placeholder is not found, append the selected text to the prompt.
* Process the command prompt.
*/
export function processCommandPrompt(prompt: string, selectedText: string) {
const index = prompt.indexOf(SELECTED_TEXT_PLACEHOLDER);
export async function processCommandPrompt(
prompt: string,
selectedText: string,
skipAppendingSelectedText = false
) {
const processedPrompt = await processPrompt(
prompt,
selectedText,
app.vault,
app.workspace.getActiveFile()
);
if (processedPrompt.includes("{selectedText}") || skipAppendingSelectedText) {
// Containing {selectedText} means the prompt was using the custom prompt
// processor way of handling the selected text. No need to go through the
// legacy placeholder.
return processedPrompt;
}
// This is the legacy custom command selected text placeholder. It replaced
// {copilot-selection} in the prompt with the selected text. This is different
// from the custom prompt processor which uses {} in the prompt and appends
// the selected text to the prompt. We cannot change user's custom commands
// that have the old placeholder, so we need to support both.
// Also, selected text is required for custom commands. If neither `{}` nor
// `{copilot-selection}` is found, append the selected text to the prompt.
const index = processedPrompt.indexOf(LEGACY_SELECTED_TEXT_PLACEHOLDER);
if (index === -1) {
return prompt + "\n\n" + selectedText;
return processedPrompt + "\n\n" + selectedText;
}
return (
prompt.slice(0, index) + selectedText + prompt.slice(index + SELECTED_TEXT_PLACEHOLDER.length)
processedPrompt.slice(0, index) +
selectedText +
processedPrompt.slice(index + LEGACY_SELECTED_TEXT_PLACEHOLDER.length)
);
}

View file

@ -0,0 +1,22 @@
import React from "react";
export function CustomPromptSyntaxInstruction() {
return (
<ul className="text-sm px-4 m-0">
<li>
<span className="font-medium text-accent">{"{}"}</span> represents the selected text.
</li>
<li>
<span className="font-medium text-accent">{`{[[Note Title]]}`}</span> represents a note.
</li>
<li>
<span className="font-medium text-accent">{`{activeNote}`}</span> represents the active
note.
</li>
<li>
<span className="font-medium text-accent">{`{#tag1, #tag2}`}</span> represents ALL notes
with ANY of the specified tags in their property (an OR operation).
</li>
</ul>
);
}

View file

@ -5,6 +5,7 @@ import { err2String } from "@/utils";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { CustomPromptSyntaxInstruction } from "@/components/CustomPromptSyntaxInstruction";
interface AddPromptModalContentProps {
initialTitle?: string;
@ -97,21 +98,7 @@ function AddPromptModalContent({
</div>
<div className="text-sm text-muted -mt-1">Use the following syntax in your prompt:</div>
</div>
<div className="text-sm flex flex-col gap-1 bg-secondary/30 rounded-md p-2">
<strong>- {"{}"} represents the selected text (not required). </strong>
<strong>- {`{[[Note Title]]}`} represents a note. </strong>
<strong>- {`{activeNote}`} represents the active note. </strong>
<strong>- {`{FolderPath}`} represents a folder of notes. </strong>
<strong>
- {`{#tag1, #tag2}`} represents ALL notes with ANY of the specified tags in their
property (an OR operation).{" "}
</strong>
<div className="mt-1">
<span className="text-muted">
Tip: turn on debug mode to show the processed prompt in the chat window.
</span>
</div>
</div>
<CustomPromptSyntaxInstruction />
<Textarea
value={prompt}

View file

@ -16,6 +16,7 @@ import { getModelDisplayText } from "@/components/ui/model-display";
import { cn } from "@/lib/utils";
import { ChevronDown } from "lucide-react";
import { logError } from "@/logger";
import { CustomPromptSyntaxInstruction } from "@/components/CustomPromptSyntaxInstruction";
type FormErrors = {
name?: string;
@ -90,10 +91,7 @@ function InlineEditCommandSettingsModalContent({
<div className="flex flex-col gap-2">
<Label htmlFor="prompt">Prompt</Label>
<div className="text-sm text-muted mb-2">
Use <code>{"{copilot-selection}"}</code> as a placeholder for the selected text. If not
included, the selected text will be appended to the prompt.
</div>
<CustomPromptSyntaxInstruction />
<Textarea
id="prompt"
value={command.prompt}

View file

@ -1,21 +1,72 @@
import { useModelKey } from "@/aiParams";
import { processCommandPrompt } from "@/commands/inlineEditCommandUtils";
import { Button } from "@/components/ui/button";
import { getModelDisplayText } from "@/components/ui/model-display";
import ChatModelManager from "@/LLMProviders/chatModelManager";
import { InlineEditCommandSettings, useSettingsValue } from "@/settings/model";
import { ChatMessage } from "@/sharedState";
import { findCustomModel, insertIntoEditor } from "@/utils";
import { Bot, Copy, PenLine } from "lucide-react";
import { App, Modal, Notice } from "obsidian";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Bot, Copy, PenLine, CornerDownLeft, Command, ArrowBigUp } from "lucide-react";
import { App, Modal, Notice, Platform } from "obsidian";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createRoot, Root } from "react-dom/client";
import {
ChatPromptTemplate,
HumanMessagePromptTemplate,
MessagesPlaceholder,
SystemMessagePromptTemplate,
} from "@langchain/core/prompts";
import { BaseChatMemory, BufferMemory } from "langchain/memory";
import { RunnableSequence } from "@langchain/core/runnables";
import { CustomModel } from "@/aiParams";
import { logError } from "@/logger";
// Custom hook for managing chat chain
function useChatChain(selectedModel: CustomModel) {
const [chatMemory] = useState<BaseChatMemory>(
new BufferMemory({ returnMessages: true, memoryKey: "history" })
);
const [chatChain, setChatChain] = useState<RunnableSequence | null>(null);
// Initialize chat chain
useEffect(() => {
async function initChatChain() {
const chatModel = await ChatModelManager.getInstance().createModelInstance(selectedModel);
const chatPrompt = ChatPromptTemplate.fromMessages([
SystemMessagePromptTemplate.fromTemplate(
"You are a helpful assistant. You'll help the user with their content editing needs."
),
new MessagesPlaceholder("history"),
HumanMessagePromptTemplate.fromTemplate("{input}"),
]);
const newChatChain = RunnableSequence.from([
{
input: (initialInput) => initialInput.input,
memory: () => chatMemory.loadMemoryVariables({}),
},
{
input: (previousOutput) => previousOutput.input,
history: (previousOutput) => previousOutput.memory.history,
},
chatPrompt,
chatModel,
]);
setChatChain(newChatChain);
}
initChatChain();
}, [selectedModel, chatMemory]);
return { chatChain, chatMemory };
}
interface InlineEditModalContentProps {
originalText: string;
command: InlineEditCommandSettings;
onInsert: (message: string) => void;
onReplace: (message: string) => void;
onClose: () => void;
}
function InlineEditModalContent({
@ -23,10 +74,13 @@ function InlineEditModalContent({
command,
onInsert,
onReplace,
onClose,
}: InlineEditModalContentProps) {
const [aiCurrentMessage, setAiCurrentMessage] = useState<string | null>(null);
const [processedMessage, setProcessedMessage] = useState<string | null>(null);
const [followupInstruction, setFollowupInstruction] = useState<string>("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const followupRef = useRef<HTMLTextAreaElement>(null);
const [generating, setGenerating] = useState(true);
const [modelKey] = useModelKey();
const settings = useSettingsValue();
const selectedModel = useMemo(
@ -34,35 +88,163 @@ function InlineEditModalContent({
[command.modelKey, modelKey, settings.activeModels]
);
const commandName = command.name;
const handleAddMessage = useCallback((message: ChatMessage) => {
setProcessedMessage(message.message);
}, []);
const { chatChain, chatMemory } = useChatChain(selectedModel);
const commandName = command.name;
// Reusable function to handle streaming responses wrapped in useCallback
const streamResponse = useCallback(
async (input: string, abortController: AbortController) => {
if (!chatChain) {
console.error("Chat chain not initialized");
new Notice("Chat engine not ready. Please try again.");
setGenerating(false);
return null;
}
try {
setAiCurrentMessage(null);
setProcessedMessage(null);
setGenerating(true);
let fullResponse = "";
const chainWithSignal = chatChain.bind({ signal: abortController.signal });
const stream = await chainWithSignal.stream({ input });
for await (const chunk of stream) {
if (abortController.signal.aborted) break;
const chunkContent = typeof chunk.content === "string" ? chunk.content : "";
fullResponse += chunkContent;
setAiCurrentMessage(fullResponse);
}
if (!abortController.signal.aborted) {
const trimmedResponse = fullResponse.trim();
setProcessedMessage(trimmedResponse);
setGenerating(false);
await chatMemory.saveContext({ input }, { output: trimmedResponse });
return trimmedResponse;
}
return null;
} catch (error) {
logError("Error generating response:", error);
setGenerating(false);
return null;
}
},
[chatChain, chatMemory]
);
// Generate initial response
useEffect(() => {
const abortController = new AbortController();
async function stream() {
const prompt = processCommandPrompt(command.prompt, originalText);
let fullAIResponse = "";
const chatModel = await ChatModelManager.getInstance().createModelInstance(selectedModel);
const chatStream = await chatModel.stream(prompt);
for await (const chunk of chatStream) {
if (abortController?.signal.aborted) break;
fullAIResponse += chunk.content;
setAiCurrentMessage(fullAIResponse);
abortControllerRef.current = abortController;
async function generateInitialResponse() {
if (!chatChain) {
// We'll wait for the chain to be ready
return;
}
if (!abortController?.signal.aborted) {
setProcessedMessage(fullAIResponse);
try {
const prompt = await processCommandPrompt(command.prompt, originalText);
await streamResponse(prompt, abortController);
} catch (error) {
logError("Error in initial response:", error);
setGenerating(false);
}
}
stream();
generateInitialResponse();
return () => {
abortController.abort();
};
}, [command.prompt, originalText, handleAddMessage, selectedModel]);
}, [command.prompt, originalText, chatChain, streamResponse]);
const abortControllerRef = useRef<AbortController | null>(null);
const handleFollowupSubmit = async () => {
if (!followupInstruction.trim() || !chatChain) {
if (!chatChain) {
new Notice("Chat engine not ready. Please try again.");
}
return;
}
const abortController = new AbortController();
abortControllerRef.current = abortController;
// Skip appending the selected text to the prompt because it's already
// included in the original prompt.
const prompt = await processCommandPrompt(followupInstruction, originalText, true);
try {
const result = await streamResponse(prompt, abortController);
if (result) {
// Reset follow-up instruction on success
setFollowupInstruction("");
}
} finally {
if (abortController.signal.aborted) {
setGenerating(false);
setProcessedMessage(aiCurrentMessage ?? "");
}
abortControllerRef.current = null;
}
};
// Handle stopping generation
const handleStopGeneration = () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
setGenerating(false);
}
};
// Handle keyboard shortcuts
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.nativeEvent.isComposing) return;
// For insert/replace buttons
if (!generating && processedMessage && !showFollowupSubmit) {
// Command+Enter (Mac) or Ctrl+Enter (Windows) for Replace
if (e.key === "Enter" && (Platform.isMacOS ? e.metaKey : e.ctrlKey) && !e.shiftKey) {
e.preventDefault();
onReplace(processedMessage);
}
// Command+Shift+Enter (Mac) or Ctrl+Shift+Enter (Windows) for Insert
if (e.key === "Enter" && (Platform.isMacOS ? e.metaKey : e.ctrlKey) && e.shiftKey) {
e.preventDefault();
onInsert(processedMessage);
}
}
// For submit button (follow-up instruction)
if (showFollowupSubmit && e.key === "Enter" && !e.shiftKey && !e.metaKey && !e.ctrlKey) {
e.preventDefault();
handleFollowupSubmit();
}
};
// Scroll textarea to bottom when generating content
useEffect(() => {
if (textareaRef.current && aiCurrentMessage && generating) {
const textarea = textareaRef.current;
textarea.scrollTop = textarea.scrollHeight;
}
}, [aiCurrentMessage, generating]);
// Determine if follow-up submit button should be shown
const showFollowupSubmit = !generating && followupInstruction.trim().length > 0;
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4" onKeyDown={handleKeyDown}>
<div className="max-h-60 overflow-y-auto text-muted whitespace-pre-wrap">{originalText}</div>
<div className="flex flex-col gap-2">
{commandName && (
@ -71,13 +253,10 @@ function InlineEditModalContent({
{commandName}
</div>
)}
<div className="text-muted flex items-center gap-2 font-bold">
<Bot className="w-4 h-4" />
{getModelDisplayText(selectedModel)}
</div>
</div>
<div className="relative group">
<textarea
ref={textareaRef}
className="w-full h-60 text-text peer"
value={processedMessage ?? aiCurrentMessage ?? "loading..."}
disabled={processedMessage == null}
@ -95,22 +274,83 @@ function InlineEditModalContent({
</button>
)}
</div>
<div className="flex justify-end gap-2">
<button onClick={onClose}>Close</button>
<button
disabled={processedMessage == null}
className="!bg-interactive-accent !text-on-accent cursor-pointer"
onClick={() => onInsert(processedMessage ?? "")}
>
Insert
</button>
<button
disabled={processedMessage == null}
className="!bg-interactive-accent !text-on-accent cursor-pointer"
onClick={() => onReplace(processedMessage ?? "")}
>
Replace
</button>
{!generating && processedMessage && (
<div className="flex flex-col gap-2">
<textarea
autoFocus
ref={followupRef}
className="w-full h-20 text-text"
placeholder="Enter follow-up instructions..."
value={followupInstruction}
onChange={(e) => setFollowupInstruction(e.target.value)}
/>
</div>
)}
<div className="flex justify-between gap-2">
<div className="text-faint text-xs flex items-center gap-2 font-bold">
<Bot className="w-4 h-4" />
{getModelDisplayText(selectedModel)}
</div>
<div className="flex gap-2">
{generating ? (
// When generating, show Stop button
<Button variant="secondary" onClick={handleStopGeneration}>
Stop
</Button>
) : showFollowupSubmit ? (
// When follow-up instruction has content, show Submit button with Enter shortcut
<Button onClick={handleFollowupSubmit} className="flex items-center gap-1">
<span>Submit</span>
<CornerDownLeft className="size-3" />
</Button>
) : (
// Otherwise, show Insert and Replace buttons with shortcut indicators
<>
<Button
onClick={() => onInsert(processedMessage ?? "")}
className="flex items-center gap-1"
>
<span>Insert</span>
<div className="flex items-center text-xs text-muted">
{Platform.isMacOS ? (
<>
<Command className="size-3" />
<ArrowBigUp className="size-3" />
<CornerDownLeft className="size-3" />
</>
) : (
<>
<span className="text-xs">Ctrl</span>
<ArrowBigUp className="size-3" />
<CornerDownLeft className="size-3" />
</>
)}
</div>
</Button>
<Button
onClick={() => onReplace(processedMessage ?? "")}
className="flex items-center gap-1"
>
<span>Replace</span>
<div className="flex items-center text-xs text-muted">
{Platform.isMacOS ? (
<>
<Command className="size-3" />
<CornerDownLeft className="size-3" />
</>
) : (
<>
<span className="text-xs">Ctrl</span>
<CornerDownLeft className="size-3" />
</>
)}
</div>
</Button>
</>
)}
</div>
</div>
</div>
);
@ -144,17 +384,12 @@ export class InlineEditModal extends Modal {
this.close();
};
const handleClose = () => {
this.close();
};
this.root.render(
<InlineEditModalContent
originalText={selectedText}
command={command}
onInsert={handleInsert}
onReplace={handleReplace}
onClose={handleClose}
/>
);
}

View file

@ -10,7 +10,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
className={cn(
"!h-9 !min-w-[50px] !border border-border border-solid !rounded-md !bg-transparent !px-3 !py-1 md:!text-base !text-sm !transition-colors",
"focus-visible:!shadow-sm focus-visible:!outline-none focus-visible:!ring-1 focus-visible:!ring-ring",
"placeholder:text-xs", // custom styles
"placeholder:text-sm",
"flex w-full shadow-sm placeholder:text-muted disabled:cursor-not-allowed disabled:opacity-50",
className
)}

View file

@ -1,5 +1,11 @@
import { CustomPrompt, CustomPromptProcessor } from "@/customPromptProcessor";
import { extractNoteFiles, getFileContent, getNotesFromPath } from "@/utils";
import {
extractNoteFiles,
getFileContent,
getFileName,
getNotesFromPath,
getNotesFromTags,
} from "@/utils";
import { Notice, TFile, Vault } from "obsidian";
// Mock Obsidian
@ -27,6 +33,10 @@ describe("CustomPromptProcessor", () => {
beforeEach(() => {
// Reset mocks before each test
jest.clearAllMocks();
jest.resetAllMocks();
// Set default implementations for critical mocks
(extractNoteFiles as jest.Mock).mockReturnValue([]);
// Create mock objects
mockVault = {} as Vault;
@ -48,13 +58,14 @@ describe("CustomPromptProcessor", () => {
// Mock getFileContent to return content for {variable}
(getFileContent as jest.Mock).mockResolvedValueOnce("here is the note content for note0");
(getFileName as jest.Mock).mockReturnValueOnce("Variable Note");
(getNotesFromPath as jest.Mock).mockResolvedValueOnce([mockActiveNote]);
const result = await processor.processCustomPrompt(doc.content, selectedText, mockActiveNote);
expect(result).toContain("This is a {variable} and {selectedText}.");
expect(result).toContain("here is some selected text 12345");
expect(result).toContain("here is the note content for note0");
expect(result).toBe(
"This is a {variable} and {selectedText}.\n\nselectedText:\n\nhere is some selected text 12345\n\nvariable:\n\n## Variable Note\n\nhere is the note content for note0"
);
});
it("should add 2 context and no selectedText", async () => {
@ -140,33 +151,64 @@ describe("CustomPromptProcessor", () => {
const customPrompt = "Notes related to {#tag} are:";
const selectedText = "";
// Mock the extractVariablesFromPrompt method to simulate tag processing
jest
.spyOn(processor, "extractVariablesFromPrompt")
.mockResolvedValue(['[{"name":"note","content":"Note content for #tag"}]']);
// Mock note file for the tag
const mockNoteForTag = {
path: "path/to/tagged/note.md",
basename: "Tagged Note",
} as TFile;
// Mock getNotesFromTags to return our mock note
(getNotesFromTags as jest.Mock).mockReturnValue([mockNoteForTag]);
// Mock getFileName to return the basename
const { getFileName } = jest.requireMock("@/utils") as any;
getFileName.mockReturnValue("Tagged Note");
// Mock getFileContent to return content for the note
(getFileContent as jest.Mock).mockResolvedValue("Note content for #tag");
const result = await processor.processCustomPrompt(customPrompt, selectedText, mockActiveNote);
expect(result).toContain("Notes related to {#tag} are:");
expect(result).toContain('[{"name":"note","content":"Note content for #tag"}]');
expect(result).toContain(
"Notes related to {#tag} are:\n\n\n\n#tag:\n\n## Tagged Note\n\nNote content for #tag"
);
});
it("should process multiple tag variables correctly", async () => {
const customPrompt = "Notes related to {#tag1,#tag2,#tag3} are:";
const selectedText = "";
// Mock the extractVariablesFromPrompt method to simulate processing of multiple tags
jest
.spyOn(processor, "extractVariablesFromPrompt")
.mockResolvedValue([
'[{"name":"note1","content":"Note content for #tag1"},{"name":"note2","content":"Note content for #tag2"}]',
]);
// Mock note files for the tags
const mockNoteForTag1 = {
path: "path/to/tagged/note1.md",
basename: "Tagged Note 1",
} as TFile;
const mockNoteForTag2 = {
path: "path/to/tagged/note2.md",
basename: "Tagged Note 2",
} as TFile;
// Mock getNotesFromTags to return our mock notes
(getNotesFromTags as jest.Mock).mockReturnValue([mockNoteForTag1, mockNoteForTag2]);
// Mock getFileName to return the basename
const { getFileName } = jest.requireMock("@/utils") as any;
getFileName.mockImplementation((file: TFile) => file.basename);
// Mock getFileContent to return content for each note
(getFileContent as jest.Mock).mockImplementation((file: TFile) => {
if (file.basename === "Tagged Note 1") {
return "Note content for #tag1";
} else if (file.basename === "Tagged Note 2") {
return "Note content for #tag2";
}
return "";
});
const result = await processor.processCustomPrompt(customPrompt, selectedText, mockActiveNote);
expect(result).toContain("Notes related to {#tag1,#tag2,#tag3} are:");
expect(result).toContain(
'[{"name":"note1","content":"Note content for #tag1"},{"name":"note2","content":"Note content for #tag2"}]'
expect(result).toBe(
"Notes related to {#tag1,#tag2,#tag3} are:\n\n\n\n#tag1,#tag2,#tag3:\n\n## Tagged Note 1\n\nNote content for #tag1\n\n## Tagged Note 2\n\nNote content for #tag2"
);
});
@ -182,29 +224,36 @@ describe("CustomPromptProcessor", () => {
const result = await processor.processCustomPrompt(customPrompt, selectedText, mockActiveNote);
expect(result).toContain("Content of [[Test Note]] is important.");
expect(result).toContain("Title: [[Test Note]]\nPath: Test Note.md\n\nTest note content");
expect(result).toBe(
"Content of [[Test Note]] is important.\n\n\n\nTitle: [[Test Note]]\nPath: Test Note.md\n\nTest note content"
);
});
it("should process {[[note title]]} syntax correctly without duplication", async () => {
const customPrompt = "Content of {[[Test Note]]} is important.";
const customPrompt = "Content of {[[Test Note]]} is important. Look at [[Test Note]].";
const selectedText = "";
// Mock the necessary functions
jest
.spyOn(processor, "extractVariablesFromPrompt")
.mockResolvedValue([JSON.stringify([{ name: "Test Note", content: "Test note content" }])]);
(extractNoteFiles as jest.Mock).mockReturnValue([
{ basename: "Test Note", path: "Test Note.md" },
]);
const mockNoteFile = {
basename: "Test Note",
path: "Test Note.md",
} as TFile;
(extractNoteFiles as jest.Mock).mockReturnValue([mockNoteFile]);
const { getFileName } = jest.requireMock("@/utils") as any;
getFileName.mockReturnValue("Test Note");
(getFileContent as jest.Mock).mockResolvedValue("Test note content");
// Mock getNotesFromPath to return our mock note
(getNotesFromPath as jest.Mock).mockResolvedValue([mockNoteFile]);
const result = await processor.processCustomPrompt(customPrompt, selectedText, mockActiveNote);
expect(result).toContain("Content of {[[Test Note]]} is important.");
expect(result).toContain(
'[[Test Note]]:\n\n[{"name":"Test Note","content":"Test note content"}]'
expect(result).toBe(
"Content of {[[Test Note]]} is important. Look at [[Test Note]].\n\n\n\n[[Test Note]]:\n\n## Test Note\n\nTest note content"
);
expect((result.match(/Test note content/g) || []).length).toBe(1);
});
it("should process both {[[note title]]} and [[note title]] syntax correctly", async () => {
@ -212,20 +261,38 @@ describe("CustomPromptProcessor", () => {
const selectedText = "";
// Mock the necessary functions
jest
.spyOn(processor, "extractVariablesFromPrompt")
.mockResolvedValue([JSON.stringify([{ name: "Note1", content: "Note1 content" }])]);
(extractNoteFiles as jest.Mock).mockReturnValue([
{ basename: "Note1", path: "Note1.md" },
{ basename: "Note2", path: "Note2.md" },
]);
(getFileContent as jest.Mock).mockResolvedValue("Note2 content");
const mockNote1 = {
basename: "Note1",
path: "Note1.md",
} as TFile;
const mockNote2 = {
basename: "Note2",
path: "Note2.md",
} as TFile;
(extractNoteFiles as jest.Mock).mockReturnValue([mockNote1, mockNote2]);
const { getFileName } = jest.requireMock("@/utils") as any;
getFileName.mockImplementation((file: TFile) => file.basename);
(getFileContent as jest.Mock).mockImplementation((file: TFile) => {
if (file.basename === "Note1") {
return "Note1 content";
} else if (file.basename === "Note2") {
return "Note2 content";
}
return "";
});
// Mock getNotesFromPath to return our mock note
(getNotesFromPath as jest.Mock).mockResolvedValue([mockNote1]);
const result = await processor.processCustomPrompt(customPrompt, selectedText, mockActiveNote);
expect(result).toContain("{[[Note1]]} content and [[Note2]] are both important.");
expect(result).toContain('[[Note1]]:\n\n[{"name":"Note1","content":"Note1 content"}]');
expect(result).toContain("Title: [[Note2]]\nPath: Note2.md\n\nNote2 content");
expect(result).toBe(
"{[[Note1]]} content and [[Note2]] are both important.\n\n\n\n[[Note1]]:\n\n## Note1\n\nNote1 content\n\nTitle: [[Note2]]\nPath: Note2.md\n\nNote2 content"
);
});
it("should handle multiple occurrences of [[note title]] syntax", async () => {
@ -238,17 +305,22 @@ describe("CustomPromptProcessor", () => {
{ basename: "Note2", path: "Note2.md" },
{ basename: "Note3", path: "Note3.md" },
]);
(getFileContent as jest.Mock)
.mockResolvedValueOnce("Note1 content")
.mockResolvedValueOnce("Note2 content")
.mockResolvedValueOnce("Note3 content");
(getFileContent as jest.Mock).mockImplementation((file: TFile) => {
if (file.basename === "Note1") {
return "Note1 content";
} else if (file.basename === "Note2") {
return "Note2 content";
} else if (file.basename === "Note3") {
return "Note3 content";
}
return "";
});
const result = await processor.processCustomPrompt(customPrompt, selectedText, mockActiveNote);
expect(result).toContain("[[Note1]] is related to [[Note2]] and [[Note3]].");
expect(result).toContain("Title: [[Note1]]\nPath: Note1.md\n\nNote1 content");
expect(result).toContain("Title: [[Note2]]\nPath: Note2.md\n\nNote2 content");
expect(result).toContain("Title: [[Note3]]\nPath: Note3.md\n\nNote3 content");
expect(result).toBe(
"[[Note1]] is related to [[Note2]] and [[Note3]].\n\n\n\nTitle: [[Note1]]\nPath: Note1.md\n\nNote1 content\n\nTitle: [[Note2]]\nPath: Note2.md\n\nNote2 content\n\nTitle: [[Note3]]\nPath: Note3.md\n\nNote3 content"
);
});
it("should handle non-existent note titles gracefully", async () => {
@ -260,8 +332,7 @@ describe("CustomPromptProcessor", () => {
const result = await processor.processCustomPrompt(customPrompt, selectedText, mockActiveNote);
expect(result).toContain("[[Non-existent Note]] should not cause errors.");
expect(result).not.toContain("[[Non-existent Note]]:");
expect(result).toBe("[[Non-existent Note]] should not cause errors.\n\n");
});
it("should process {activenote} only once when it appears multiple times", async () => {
@ -271,19 +342,19 @@ describe("CustomPromptProcessor", () => {
};
const selectedText = "";
// Mock the extractVariablesFromPrompt method to simulate processing of {activeNote}
jest
.spyOn(processor, "extractVariablesFromPrompt")
.mockResolvedValue([
JSON.stringify([{ name: "Active Note", content: "Content of the active note" }]),
]);
// Mock getFileName and getFileContent
const { getFileName } = jest.requireMock("@/utils") as any;
getFileName.mockReturnValue("Active Note");
(getFileContent as jest.Mock).mockResolvedValue("Content of the active note");
const result = await processor.processCustomPrompt(doc.content, selectedText, mockActiveNote);
expect(result).toContain("This is the active note: {activeNote}. And again: {activeNote}");
expect(result).toContain("Content of the active note");
expect((result.match(/activeNote:/g) || []).length).toBe(1);
expect(processor.extractVariablesFromPrompt).toHaveBeenCalledTimes(1);
// Check that getFileContent was called with the active note at least once
expect(getFileContent).toHaveBeenCalledWith(mockActiveNote, mockVault);
expect(result).toBe(
"This is the active note: {activeNote}. And again: {activeNote}\n\n\n\nactiveNote:\n\n## Active Note\n\nContent of the active note"
);
});
it("should use active note content when {} is present and no selected text", async () => {
@ -297,9 +368,9 @@ describe("CustomPromptProcessor", () => {
const result = await processor.processCustomPrompt(doc.content, selectedText, mockActiveNote);
expect(result).toContain("Summarize this: {selectedText}");
expect(result).toContain("selectedText (entire active note):\n\n Content of the active note");
expect(getFileContent).toHaveBeenCalledWith(mockActiveNote, mockVault);
expect(result).toBe(
"Summarize this: {selectedText}\n\nselectedText (entire active note):\n\nContent of the active note"
);
});
it("should not duplicate active note content when both {} and {activeNote} are present", async () => {
@ -312,15 +383,13 @@ describe("CustomPromptProcessor", () => {
(getFileContent as jest.Mock).mockResolvedValue("Content of the active note");
jest
.spyOn(processor, "extractVariablesFromPrompt")
.mockResolvedValue([
JSON.stringify([{ name: "Active Note", content: "Content of the active note" }]),
]);
.mockResolvedValue(new Map([["Active Note", "Content of the active note"]]));
const result = await processor.processCustomPrompt(doc.content, selectedText, mockActiveNote);
expect(result).toContain("Summarize this: {selectedText}. Additional info: {activeNote}");
expect(result).toContain("selectedText (entire active note):\n\n Content of the active note");
expect(result).not.toContain("activeNote:");
expect(result).toBe(
"Summarize this: {selectedText}. Additional info: {activeNote}\n\nselectedText (entire active note):\n\nContent of the active note"
);
});
it("should prioritize selected text over active note when both are available", async () => {
@ -334,8 +403,38 @@ describe("CustomPromptProcessor", () => {
const result = await processor.processCustomPrompt(doc.content, selectedText, mockActiveNote);
expect(result).toContain("Analyze this: {selectedText}");
expect(result).toContain("selectedText:\n\n This is the selected text");
expect(result).not.toContain("selectedText (entire active note):");
expect(result).toBe(
"Analyze this: {selectedText}\n\nselectedText:\n\nThis is the selected text"
);
});
it("should handle invalid variable names correctly", async () => {
const doc: CustomPrompt = {
title: "test-prompt",
content: "This is a test prompt with {invalidVariable} name and {activeNote}",
};
const selectedText = "";
(getNotesFromPath as jest.Mock).mockImplementation((_vault: Vault, variableName: string) => {
if (variableName === "Active Note") {
return [mockActiveNote];
}
return [];
});
(getFileName as jest.Mock).mockReturnValue(mockActiveNote.basename);
(getFileContent as jest.Mock).mockImplementation((file: TFile) => {
if (file.basename === "Active Note") {
return "Active Note Content";
}
return "";
});
const result = await processor.processCustomPrompt(doc.content, selectedText, mockActiveNote);
expect(result).toBe(
"This is a test prompt with {invalidVariable} name and {activeNote}\n\n\n\nactiveNote:\n\n## Active Note\n\nActive Note Content"
);
});
});

View file

@ -16,6 +16,126 @@ export interface CustomPrompt {
content: string;
}
/**
* {copilot-selection} is the legacy custom command special placeholder. It must
* be skipped when processing custom prompts because it's handled differently
* by the custom command prompt processor.
*/
const VARIABLE_REGEX = /\{(?!copilot-selection\})([^}]+)\}/g;
/**
* Extract variables from a custom prompt and get their content.
*/
async function extractVariablesFromPrompt(
customPrompt: string,
vault: Vault,
activeNote?: TFile | null
): Promise<Map<string, string>> {
const variablesMap = new Map<string, string>();
let match;
while ((match = VARIABLE_REGEX.exec(customPrompt)) !== null) {
const variableName = match[1].trim();
const notes = [];
if (variableName.toLowerCase() === "activenote") {
if (activeNote) {
const content = await getFileContent(activeNote, vault);
if (content) {
notes.push({ name: getFileName(activeNote), content });
}
} else {
new Notice("No active note found.");
}
} else if (variableName.startsWith("#")) {
// Handle tag-based variable for multiple tags
const tagNames = variableName
.slice(1)
.split(",")
.map((tag) => tag.trim());
const noteFiles = await getNotesFromTags(vault, tagNames);
for (const file of noteFiles) {
const content = await getFileContent(file, vault);
if (content) {
notes.push({ name: getFileName(file), content });
}
}
} else {
const processedVariableName = processVariableNameForNotePath(variableName);
const noteFiles = await getNotesFromPath(vault, processedVariableName);
for (const file of noteFiles) {
const content = await getFileContent(file, vault);
if (content) {
notes.push({ name: getFileName(file), content });
}
}
}
if (notes.length > 0) {
const markdownContent = notes
.map((note) => `## ${note.name}\n\n${note.content}`)
.join("\n\n");
variablesMap.set(variableName, markdownContent);
} else {
console.warn(`No notes found for variable: ${variableName}`);
}
}
return variablesMap;
}
/**
* Process a custom prompt by replacing variables and adding note contents.
*/
export async function processPrompt(
customPrompt: string,
selectedText: string,
vault: Vault,
activeNote?: TFile | null
): Promise<string> {
const variablesMap = await extractVariablesFromPrompt(customPrompt, vault, activeNote);
let processedPrompt = customPrompt;
let additionalInfo = "";
let activeNoteContent: string | null = null;
if (processedPrompt.includes("{}")) {
processedPrompt = processedPrompt.replace(/\{\}/g, "{selectedText}");
if (selectedText) {
additionalInfo += `selectedText:\n\n${selectedText}`;
} else if (activeNote) {
activeNoteContent = await getFileContent(activeNote, vault);
additionalInfo += `selectedText (entire active note):\n\n${activeNoteContent}`;
} else {
additionalInfo += `selectedText:\n\n(No selected text or active note available)`;
}
}
// Add variable contents to the additional info
for (const [varName, content] of variablesMap.entries()) {
if (varName.toLowerCase() === "activenote" && activeNoteContent) {
// Skip adding activeNote content if it's already added as selectedText
continue;
}
additionalInfo += `\n\n${varName}:\n\n${content}`;
}
// Process [[note title]] syntax with new reference system
const noteFiles = extractNoteFiles(processedPrompt, vault);
for (const noteFile of noteFiles) {
// Check if this note wasn't already processed in extractVariablesFromPrompt
const noteName = `[[${noteFile.basename}]]`;
if (!variablesMap.has(noteName)) {
const noteContent = await getFileContent(noteFile, vault);
if (noteContent) {
additionalInfo += `\n\nTitle: ${noteName}\nPath: ${noteFile.path}\n\n${noteContent}`;
}
}
}
return processedPrompt + "\n\n" + additionalInfo;
}
export class CustomPromptProcessor {
private static instance: CustomPromptProcessor;
private usageStrategy: TimestampUsageStrategy;
@ -116,136 +236,60 @@ export class CustomPromptProcessor {
}
/**
* Extract variables and get their content.
* Extract variables from a custom prompt and get their content.
* This is a wrapper around the module-level extractVariablesFromPrompt function.
*
* @param {CustomPrompt} doc - the custom prompt to process
* @return {Promise<string[]>} the processed custom prompt
* @param {string} customPrompt - the custom prompt to process
* @param {TFile} [activeNote] - the currently active note (optional)
* @return {Promise<Map<string, string>>} map of variable name to content
*/
public async extractVariablesFromPrompt(
customPrompt: string,
activeNote?: TFile
): Promise<string[]> {
const variablesWithContent: string[] = [];
const variableRegex = /\{([^}]+)\}/g;
let match;
while ((match = variableRegex.exec(customPrompt)) !== null) {
const variableName = match[1].trim();
const notes = [];
if (variableName.toLowerCase() === "activenote") {
if (activeNote) {
const content = await getFileContent(activeNote, this.vault);
if (content) {
notes.push({ name: getFileName(activeNote), content });
}
} else {
new Notice("No active note found.");
}
} else if (variableName.startsWith("#")) {
// Handle tag-based variable for multiple tags
const tagNames = variableName
.slice(1)
.split(",")
.map((tag) => tag.trim());
const noteFiles = await getNotesFromTags(this.vault, tagNames);
for (const file of noteFiles) {
const content = await getFileContent(file, this.vault);
if (content) {
notes.push({ name: getFileName(file), content });
}
}
} else {
const processedVariableName = processVariableNameForNotePath(variableName);
const noteFiles = await getNotesFromPath(this.vault, processedVariableName);
for (const file of noteFiles) {
const content = await getFileContent(file, this.vault);
if (content) {
notes.push({ name: getFileName(file), content });
}
}
}
if (notes.length > 0) {
const markdownContent = notes
.map((note) => `## ${note.name}\n\n${note.content}`)
.join("\n\n");
variablesWithContent.push(markdownContent);
} else {
console.warn(`No notes found for variable: ${variableName}`);
}
}
return variablesWithContent;
): Promise<Map<string, string>> {
return extractVariablesFromPrompt(customPrompt, this.vault, activeNote);
}
// TODO: return the processed variables along with the processed prompt and
// remove getProcessedVariables
/**
* Process a custom prompt by tracking it and delegating to the module-level function.
* This method updates the lastProcessedPrompt property before processing.
*
* @param {string} customPrompt - the custom prompt to process
* @param {string} selectedText - the text selected by the user
* @param {TFile} [activeNote] - the currently active note (optional)
* @return {Promise<string>} the processed prompt with all variables replaced
*/
async processCustomPrompt(
customPrompt: string,
selectedText: string,
activeNote?: TFile
): Promise<string> {
this.lastProcessedPrompt = customPrompt;
const variablesWithContent = await this.extractVariablesFromPrompt(customPrompt, activeNote);
let processedPrompt = customPrompt;
const matches = [...processedPrompt.matchAll(/\{([^}]+)\}/g)];
let additionalInfo = "";
let activeNoteContent: string | null = null;
if (processedPrompt.includes("{}")) {
processedPrompt = processedPrompt.replace(/\{\}/g, "{selectedText}");
if (selectedText) {
additionalInfo += `selectedText:\n\n ${selectedText}`;
} else if (activeNote) {
activeNoteContent = await getFileContent(activeNote, this.vault);
additionalInfo += `selectedText (entire active note):\n\n ${activeNoteContent}`;
} else {
additionalInfo += `selectedText:\n\n (No selected text or active note available)`;
}
}
for (let i = 0; i < variablesWithContent.length; i++) {
if (matches[i]) {
const varname = matches[i][1];
if (varname.toLowerCase() === "activenote" && activeNoteContent) {
// Skip adding activeNote content if it's already added as selectedText
continue;
}
additionalInfo += `\n\n${varname}:\n\n${variablesWithContent[i]}`;
}
}
// Process [[note title]] syntax with new reference system
const noteFiles = extractNoteFiles(processedPrompt, this.vault);
for (const noteFile of noteFiles) {
// Check if this note wasn't already processed in extractVariablesFromPrompt
if (!matches.some((match) => match[1].includes(`[[${noteFile.basename}]]`))) {
const noteContent = await getFileContent(noteFile, this.vault);
if (noteContent) {
additionalInfo += `\n\nTitle: [[${noteFile.basename}]]\nPath: ${noteFile.path}\n\n${noteContent}`;
}
}
}
return processedPrompt + "\n\n" + additionalInfo;
return processPrompt(customPrompt, selectedText, this.vault, activeNote);
}
// TODO: remove this
/**
* Get a set of all variables that were processed in the last prompt.
* @return {Promise<Set<string>>} A set of variable names that were processed
* @deprecated
*/
async getProcessedVariables(): Promise<Set<string>> {
const processedVars = new Set<string>();
// Add variables from the last processed prompt
const matches = this.lastProcessedPrompt?.matchAll(/\{([^}]+)\}/g) || [];
for (const match of matches) {
processedVars.add(match[1]);
}
if (this.lastProcessedPrompt) {
// Extract variables using the same method as in processing
const variablesMap = await extractVariablesFromPrompt(this.lastProcessedPrompt, this.vault);
// Add explicitly referenced note titles
const noteFiles = extractNoteFiles(this.lastProcessedPrompt || "", this.vault);
for (const file of noteFiles) {
processedVars.add(`[[${file.basename}]]`);
// Add all variable names from the map
for (const varName of variablesMap.keys()) {
processedVars.add(varName);
}
// Add explicitly referenced note titles
const noteFiles = extractNoteFiles(this.lastProcessedPrompt, this.vault);
for (const file of noteFiles) {
processedVars.add(`[[${file.basename}]]`);
}
}
return processedVars;