mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
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:
parent
648412a914
commit
fe9b9311ba
9 changed files with 696 additions and 309 deletions
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
22
src/components/CustomPromptSyntaxInstruction.tsx
Normal file
22
src/components/CustomPromptSyntaxInstruction.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue