logancyang_obsidian-copilot/src/utils.ts
Zero Liu 6f8861cabc
Add inline edit dialog (#1039)
* Add inline edit dialog
* Refactor commands
* Fix custom prompt
2025-01-14 16:48:03 -08:00

589 lines
19 KiB
TypeScript

import { ChainType, Document } from "@/chainFactory";
import {
NOMIC_EMBED_TEXT,
Provider,
ProviderInfo,
ProviderMetadata,
USER_SENDER,
} from "@/constants";
import { ChatMessage } from "@/sharedState";
import { MemoryVariables } from "@langchain/core/memory";
import { RunnableSequence } from "@langchain/core/runnables";
import { BaseChain, RetrievalQAChain } from "langchain/chains";
import moment from "moment";
import { MarkdownView, Notice, TFile, Vault, parseYaml, requestUrl } from "obsidian";
import { CustomModel } from "./aiParams";
export const getModelNameFromKey = (modelKey: string): string => {
return modelKey.split("|")[0];
};
export const isFolderMatch = (fileFullpath: string, inputPath: string): boolean => {
const fileSegments = fileFullpath.split("/").map((segment) => segment.toLowerCase());
return fileSegments.includes(inputPath.toLowerCase());
};
/**
* @deprecated File display title can be duplicated, so we should use file path
* instead of title to find the note file.
*/
export async function getNoteFileFromTitle(vault: Vault, noteTitle: string): Promise<TFile | null> {
// Get all markdown files in the vault
const files = vault.getMarkdownFiles();
// Iterate through all files to find a match by title
for (const file of files) {
// Extract the title from the filename by removing the extension
const title = file.basename;
if (title === noteTitle) {
// If a match is found, return the file path
return file;
}
}
// If no match is found, return null
return null;
}
/** TODO: Rewrite with app.vault.getAbstractFileByPath() */
export const getNotesFromPath = async (vault: Vault, path: string): Promise<TFile[]> => {
const files = vault.getMarkdownFiles();
// Special handling for the root path '/'
if (path === "/") {
return files;
}
// Normalize the input path
const normalizedPath = path.toLowerCase().replace(/^\/|\/$/g, "");
return files.filter((file) => {
// Normalize the file path
const normalizedFilePath = file.path.toLowerCase();
const filePathParts = normalizedFilePath.split("/");
const pathParts = normalizedPath.split("/");
// Check if the file path contains all parts of the input path in order
let filePathIndex = 0;
for (const pathPart of pathParts) {
while (filePathIndex < filePathParts.length) {
if (filePathParts[filePathIndex] === pathPart) {
break;
}
filePathIndex++;
}
if (filePathIndex >= filePathParts.length) {
return false;
}
}
return true;
});
};
export async function getTagsFromNote(file: TFile, vault: Vault): Promise<string[]> {
const fileContent = await vault.cachedRead(file);
// Check if the file starts with frontmatter delimiter
if (fileContent.startsWith("---")) {
const frontMatterBlock = fileContent.split("---", 3);
// Ensure there's a closing delimiter for frontmatter
if (frontMatterBlock.length >= 3) {
const frontMatterContent = frontMatterBlock[1];
try {
const frontMatter = parseYaml(frontMatterContent) || {};
const tags = frontMatter.tags || [];
// Handle both array and string formats of tags
const normalizedTags = Array.isArray(tags) ? tags : [tags];
// Strip any '#' from the frontmatter tags and convert to lowercase
return normalizedTags
.map((tag: string) => tag.toString().replace(/^#/, ""))
.map((tag: string) => tag.toLowerCase());
} catch (error) {
console.error("Error parsing YAML frontmatter:", error);
return [];
}
}
}
return [];
}
export async function getNotesFromTags(
vault: Vault,
tags: string[],
noteFiles?: TFile[]
): Promise<TFile[]> {
if (tags.length === 0) {
return [];
}
// Strip any '#' from the tags and convert to lowercase for consistent comparison
tags = tags.map((tag) => tag.replace(/^#/, "").toLowerCase());
const files = noteFiles && noteFiles.length > 0 ? noteFiles : await getNotesFromPath(vault, "/");
const filesWithTag = [];
for (const file of files) {
const noteTags = await getTagsFromNote(file, vault);
if (tags.some((tag) => noteTags.includes(tag))) {
filesWithTag.push(file);
}
}
return filesWithTag;
}
export function isPathInList(filePath: string, pathList: string): boolean {
if (!pathList) return false;
// Extract the file name from the filePath
const fileName = filePath.split("/").pop()?.toLowerCase();
// Normalize the file path for case-insensitive comparison
const normalizedFilePath = filePath.toLowerCase();
return pathList
.split(",")
.map(
(path) =>
path
.trim() // Trim whitespace
.replace(/^\[\[|\]\]$/g, "") // Remove surrounding [[ and ]]
.replace(/^\//, "") // Remove leading slash
.toLowerCase() // Convert to lowercase for case-insensitive comparison
)
.some((normalizedPath) => {
// Check for exact match or proper segmentation
const isExactMatch =
normalizedFilePath === normalizedPath ||
normalizedFilePath.startsWith(normalizedPath + "/") ||
normalizedFilePath.endsWith("/" + normalizedPath) ||
normalizedFilePath.includes("/" + normalizedPath + "/");
// Check for file name match (for cases like [[note1]])
const isFileNameMatch = fileName === normalizedPath + ".md";
return isExactMatch || isFileNameMatch;
});
}
export const stringToChainType = (chain: string): ChainType => {
switch (chain) {
case "llm_chain":
return ChainType.LLM_CHAIN;
case "vault_qa":
return ChainType.VAULT_QA_CHAIN;
case "copilot_plus":
return ChainType.COPILOT_PLUS_CHAIN;
default:
throw new Error(`Unknown chain type: ${chain}`);
}
};
export const isLLMChain = (chain: RunnableSequence): chain is RunnableSequence => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (chain as any).last.bound.modelName || (chain as any).last.bound.model;
};
export const isRetrievalQAChain = (chain: BaseChain): chain is RetrievalQAChain => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (chain as any).last.bound.retriever !== undefined;
};
export const isSupportedChain = (chain: RunnableSequence): chain is RunnableSequence => {
return isLLMChain(chain) || isRetrievalQAChain(chain);
};
// Returns the last N messages from the chat history,
// last one being the newest ai message
export const getChatContext = (chatHistory: ChatMessage[], contextSize: number) => {
if (chatHistory.length === 0) {
return [];
}
const lastAiMessageIndex = chatHistory
.slice()
.reverse()
.findIndex((msg) => msg.sender !== USER_SENDER);
if (lastAiMessageIndex === -1) {
// No ai messages found, return an empty array
return [];
}
const lastIndex = chatHistory.length - 1 - lastAiMessageIndex;
const startIndex = Math.max(0, lastIndex - contextSize + 1);
return chatHistory.slice(startIndex, lastIndex + 1);
};
export interface FormattedDateTime {
fileName: string;
display: string;
epoch: number;
}
export const formatDateTime = (
now: Date,
timezone: "local" | "utc" = "local"
): FormattedDateTime => {
const formattedDateTime = moment(now);
if (timezone === "utc") {
formattedDateTime.utc();
}
return {
fileName: formattedDateTime.format("YYYYMMDD_HHmmss"),
display: formattedDateTime.format("YYYY/MM/DD HH:mm:ss"),
epoch: formattedDateTime.valueOf(),
};
};
export function stringToFormattedDateTime(timestamp: string): FormattedDateTime {
const date = moment(timestamp, "YYYY/MM/DD HH:mm:ss");
if (!date.isValid()) {
// If the string is not in the expected format, return current date/time
return formatDateTime(new Date());
}
return {
fileName: date.format("YYYYMMDD_HHmmss"),
display: date.format("YYYY/MM/DD HH:mm:ss"),
epoch: date.valueOf(),
};
}
export async function getFileContent(file: TFile, vault: Vault): Promise<string | null> {
if (file.extension != "md") return null;
return await vault.cachedRead(file);
}
export function getFileName(file: TFile): string {
return file.basename;
}
export async function getAllNotesContent(vault: Vault): Promise<string> {
let allContent = "";
const markdownFiles = vault.getMarkdownFiles();
for (const file of markdownFiles) {
const fileContent = await vault.cachedRead(file);
allContent += fileContent + " ";
}
return allContent;
}
export function areEmbeddingModelsSame(
model1: string | undefined,
model2: string | undefined
): boolean {
if (!model1 || !model2) return false;
// TODO: Hacks to handle different embedding model names for the same model. Need better handling.
if (model1.includes(NOMIC_EMBED_TEXT) && model2.includes(NOMIC_EMBED_TEXT)) {
return true;
}
if (
(model1 === "small" && model2 === "cohereai") ||
(model1 === "cohereai" && model2 === "small")
) {
return true;
}
return model1 === model2;
}
// Basic prompts
export function sendNotesContentPrompt(notes: { name: string; content: string }[]): string {
const formattedNotes = notes.map((note) => `## ${note.name}\n\n${note.content}`).join("\n\n");
return (
`Please read the notes below and be ready to answer questions about them. ` +
`If there's no information about a certain topic, just say the note ` +
`does not mention it. ` +
`The content of the notes is between "/***/":\n\n/***/\n\n${formattedNotes}\n\n/***/\n\n` +
`Please reply with the following word for word:` +
`"OK I've read these notes. ` +
`Feel free to ask related questions, such as 'give me a summary of these notes in bullet points', 'what key questions do these notes answer', etc. "\n`
);
}
function getNoteTitleAndTags(noteWithTag: {
name: string;
content: string;
tags?: string[];
}): string {
return (
`[[${noteWithTag.name}]]` +
(noteWithTag.tags && noteWithTag.tags.length > 0 ? `\ntags: ${noteWithTag.tags.join(",")}` : "")
);
}
function getChatContextStr(chatNoteContextPath: string, chatNoteContextTags: string[]): string {
const pathStr = chatNoteContextPath ? `\nChat context by path: ${chatNoteContextPath}` : "";
const tagsStr =
chatNoteContextTags?.length > 0 ? `\nChat context by tags: ${chatNoteContextTags}` : "";
return pathStr + tagsStr;
}
export function getSendChatContextNotesPrompt(
notes: { name: string; content: string }[],
chatNoteContextPath: string,
chatNoteContextTags: string[]
): string {
const noteTitles = notes.map((note) => getNoteTitleAndTags(note)).join("\n\n");
return (
`Please read the notes below and be ready to answer questions about them. ` +
getChatContextStr(chatNoteContextPath, chatNoteContextTags) +
`\n\n${noteTitles}`
);
}
export function extractChatHistory(memoryVariables: MemoryVariables): [string, string][] {
const chatHistory: [string, string][] = [];
const { history } = memoryVariables;
for (let i = 0; i < history.length; i += 2) {
const userMessage = history[i]?.content || "";
const aiMessage = history[i + 1]?.content || "";
chatHistory.push([userMessage, aiMessage]);
}
return chatHistory;
}
// TODO: Deprecate this. Note mentions should be an object with title and path (optional).
// When user input `[[` the popup should show title and path for selection.
// The selected item has path to avoid duplicate titles. If user manually types
// the full title, path can still be missing. In that case title is used to retrieve
// the note.
export function extractNoteTitles(query: string): string[] {
// Use a regular expression to extract note titles wrapped in [[]]
const regex = /\[\[(.*?)\]\]/g;
const matches = query.match(regex);
const uniqueTitles = new Set(matches ? matches.map((match) => match.slice(2, -2)) : []);
return Array.from(uniqueTitles);
}
/**
* Process the variable name to generate a note path if it's enclosed in double brackets,
* otherwise return the variable name as is.
*
* @param {string} variableName - The name of the variable to process
* @return {string} The processed note path or the variable name itself
*/
export function processVariableNameForNotePath(variableName: string): string {
variableName = variableName.trim();
// Check if the variable name is enclosed in double brackets indicating it's a note
if (variableName.startsWith("[[") && variableName.endsWith("]]")) {
// It's a note, so we remove the brackets and append '.md'
return `${variableName.slice(2, -2).trim()}.md`;
}
// It's a path, so we just return it as is
return variableName;
}
export function extractUniqueTitlesFromDocs(docs: Document[]): string[] {
const titlesSet = new Set<string>();
docs.forEach((doc) => {
if (doc.metadata?.title) {
titlesSet.add(doc.metadata?.title);
}
});
return Array.from(titlesSet);
}
export async function getFilePathsFromPatterns(
patterns: string[],
vault: Vault
): Promise<string[]> {
const filePaths = new Set<string>();
for (const pattern of patterns) {
if (pattern.startsWith("#")) {
// Tag-based pattern
const taggedFiles = await getNotesFromTags(vault, [pattern]);
taggedFiles.forEach((file) => filePaths.add(file.path));
} else if (pattern.startsWith("*")) {
// File-extension-based pattern
const extensionName = pattern.slice(1);
vault.getFiles().forEach((file) => {
if (file.name.toLowerCase().endsWith(extensionName.toLowerCase())) {
filePaths.add(file.path);
}
});
} else {
// Path-based pattern
vault.getFiles().forEach((file) => {
if (isPathInList(file.path, pattern)) {
filePaths.add(file.path);
}
});
}
}
return Array.from(filePaths);
}
export function extractJsonFromCodeBlock(content: string): any {
const codeBlockMatch = content.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
const jsonContent = codeBlockMatch ? codeBlockMatch[1].trim() : content.trim();
return JSON.parse(jsonContent);
}
const YOUTUBE_URL_REGEX =
/(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:watch\?v=|shorts\/)|youtu\.be\/)([^\s&]+)/;
export function isYoutubeUrl(url: string): boolean {
return YOUTUBE_URL_REGEX.test(url);
}
export function extractYoutubeUrl(text: string): string | null {
const match = text.match(YOUTUBE_URL_REGEX);
return match ? match[0] : null;
}
/** Proxy function to use in place of fetch() to bypass CORS restrictions.
* It currently doesn't support streaming until this is implemented
* https://forum.obsidian.md/t/support-streaming-the-request-and-requesturl-response-body/87381 */
export async function safeFetch(url: string, options: RequestInit): Promise<Response> {
// Necessary to remove 'content-length' in order to make headers compatible with requestUrl()
delete (options.headers as Record<string, string>)["content-length"];
if (typeof options.body === "string") {
const newBody = JSON.parse(options.body ?? {});
// frequency_penalty: default 0, but perplexity.ai requires 1 by default.
// so, delete this argument for now
delete newBody["frequency_penalty"];
options.body = JSON.stringify(newBody);
}
const method = options.method?.toLowerCase() || "post";
const methodsWithBody = ["post", "put", "patch"];
const response = await requestUrl({
url,
contentType: "application/json",
headers: options.headers as Record<string, string>,
method: method,
...(methodsWithBody.includes(method) && { body: options.body?.toString() }),
});
return {
ok: response.status >= 200 && response.status < 300,
status: response.status,
statusText: response.status.toString(),
headers: new Headers(response.headers),
url: url,
type: "basic",
redirected: false,
bytes: () => Promise.resolve(new Uint8Array(0)),
body: createReadableStreamFromString(response.text),
bodyUsed: true,
json: () => response.json,
text: async () => response.text,
clone: () => {
throw new Error("not implemented");
},
arrayBuffer: () => {
throw new Error("not implemented");
},
blob: () => {
throw new Error("not implemented");
},
formData: () => {
throw new Error("not implemented");
},
};
}
function createReadableStreamFromString(input: string) {
return new ReadableStream({
start(controller) {
// Convert the input string to a Uint8Array
const encoder = new TextEncoder();
const uint8Array = encoder.encode(input);
// Push the data to the stream
controller.enqueue(uint8Array);
// Close the stream
controller.close();
},
});
}
export function err2String(err: any, stack = false) {
// maybe to be improved
return err instanceof Error
? err.message +
"\n" +
`${err?.cause ? "more message: " + (err.cause as Error).message : ""}` +
"\n" +
`${stack ? err.stack : ""}`
: JSON.stringify(err);
}
export function omit<T extends Record<string, any>, K extends keyof T>(
obj: T,
keys: K[]
): Omit<T, K> {
const result = { ...obj };
keys.forEach((key) => {
delete result[key];
});
return result;
}
export function findCustomModel(modelKey: string, activeModels: CustomModel[]): CustomModel {
const [modelName, provider] = modelKey.split("|");
const model = activeModels.find((m) => m.name === modelName && m.provider === provider);
if (!model) {
throw new Error(`No model configuration found for: ${modelKey}`);
}
return model;
}
export function getProviderInfo(provider: string): ProviderMetadata {
const info = ProviderInfo[provider as Provider];
return {
...info,
label: info.label || provider,
};
}
export function getProviderLabel(provider: string): string {
return ProviderInfo[provider as Provider]?.label || provider;
}
export function getProviderHost(provider: string): string {
return ProviderInfo[provider as Provider]?.host || "";
}
export function getProviderKeyManagementURL(provider: string): string {
return ProviderInfo[provider as Provider]?.keyManagementURL || "";
}
export async function insertIntoEditor(message: string, replace: boolean = false) {
let leaf = app.workspace.getMostRecentLeaf();
if (!leaf) {
new Notice("No active leaf found.");
return;
}
if (!(leaf.view instanceof MarkdownView)) {
leaf = app.workspace.getLeaf(false);
await leaf.setViewState({ type: "markdown", state: leaf.view.getState() });
}
if (!(leaf.view instanceof MarkdownView)) {
new Notice("Failed to open a markdown view.");
return;
}
const editor = leaf.view.editor;
const cursorFrom = editor.getCursor("from");
const cursorTo = editor.getCursor("to");
if (replace) {
editor.replaceRange(message, cursorFrom, cursorTo);
} else {
editor.replaceRange(message, cursorTo);
}
new Notice("Message inserted into the active note.");
}