logancyang_obsidian-copilot/src/utils.ts

1567 lines
50 KiB
TypeScript

import { ChainType, Document } from "@/chainFactory";
import {
ALLOWED_NOTE_CONTEXT_EXTENSIONS,
ChatModelProviders,
EmbeddingModelProviders,
NOMIC_EMBED_TEXT,
Provider,
ProviderInfo,
ProviderMetadata,
SettingKeyProviders,
TEXT_READABLE_EXTENSIONS,
USER_SENDER,
} from "@/constants";
import { logInfo, logWarn } from "@/logger";
import { CopilotSettings } from "@/settings/model";
import { ChatMessage } from "@/types/message";
import { BaseChatModel } from "@langchain/core/language_models/chat_models";
import { MemoryVariables } from "@langchain/core/memory";
import { RunnableSequence } from "@langchain/core/runnables";
import { BaseChain, RetrievalQAChain } from "@langchain/classic/chains";
import moment from "moment";
import { MarkdownView, Notice, TFile, Vault, normalizePath, requestUrl } from "obsidian";
import { CustomModel } from "./aiParams";
import { getApiKeyForProvider } from "@/utils/modelUtils";
export { err2String } from "@/errorFormat";
/**
* Unified type for fetch implementation.
* Used for dependency injection of fetch (e.g., safeFetch for CORS bypass).
*/
export type FetchImplementation = (url: string, init?: RequestInit) => Promise<Response>;
/**
* Extract domain from URL, removing 'www.' prefix.
* Returns the original URL string if parsing fails.
* @param url - The URL to extract domain from
* @returns Domain without 'www.' prefix, or original string on parse failure
*/
export function getDomainFromUrl(url: string): string {
try {
const urlObj = new URL(url);
return urlObj.hostname.replace(/^www\./, "");
} catch {
return url;
}
}
// Add custom error type at the top of the file
interface APIError extends Error {
json?: any;
}
// Error message constants
export const ERROR_MESSAGES = {
INVALID_LICENSE_KEY_USER:
"Invalid Copilot Plus license key. Please check your license key in settings.",
UNKNOWN_ERROR: "An unknown error occurred",
REQUEST_FAILED: (status: number) => `Request failed, status ${status}`,
} as const;
// Error handling utilities
export interface ErrorDetail {
status?: number;
message?: string;
reason?: string;
}
export function extractErrorDetail(error: any): ErrorDetail {
const errorDetail = error?.detail || {};
return {
status: errorDetail.status,
message: errorDetail.message || error?.message,
reason: errorDetail.reason,
};
}
export function isLicenseKeyError(error: any): boolean {
const errorDetail = extractErrorDetail(error);
return (
errorDetail.reason === "Invalid license key" ||
error?.message === "Invalid license key" ||
error?.message?.includes("status 403") ||
errorDetail.status === 403
);
}
export function getApiErrorMessage(error: any): string {
const errorDetail = extractErrorDetail(error);
if (isLicenseKeyError(error)) {
return ERROR_MESSAGES.INVALID_LICENSE_KEY_USER;
}
return (
errorDetail.message ||
(errorDetail.reason ? `Error: ${errorDetail.reason}` : ERROR_MESSAGES.UNKNOWN_ERROR)
);
}
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());
};
/** TODO: Rewrite with app.vault.getAbstractFileByPath() */
export const getNotesFromPath = (vault: Vault, path: string): 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;
});
};
/**
* @param tag - The tag to strip the hash symbol from.
* @returns The tag without the hash symbol.
*/
export function stripHash(tag: string): string {
return tag.replace(/^#/, "").trim();
}
/**
* Options for {@link stripFrontmatter}.
*/
export interface StripFrontmatterOptions {
/**
* When true (default), trims leading whitespace after the frontmatter block.
* When false, preserves leading whitespace from the body, but still removes
* the single newline that immediately follows the closing frontmatter marker.
*/
trimStart?: boolean;
}
/**
* Strip YAML frontmatter from markdown content.
* @param content - The markdown content to strip frontmatter from.
* @param options - Options controlling how leading whitespace is handled.
* @returns The content without the frontmatter block.
*/
export function stripFrontmatter(content: string, options: StripFrontmatterOptions = {}): string {
const { trimStart = true } = options;
if (content.startsWith("---")) {
const end = content.indexOf("---", 3);
if (end !== -1) {
const body = content.slice(end + 3);
if (trimStart) {
return body.trimStart();
}
// Preserve body whitespace, but remove the frontmatter/body separator newline.
if (body.startsWith("\r\n")) {
return body.slice(2);
}
if (body.startsWith("\n") || body.startsWith("\r")) {
return body.slice(1);
}
return body;
}
}
return content;
}
/**
* @param file - The note file to get tags from.
* @param frontmatterOnly - Whether to only get tags from frontmatter.
* @returns An array of lowercase tags without the hash symbol.
*/
export function getTagsFromNote(file: TFile, frontmatterOnly = true): string[] {
const metadata = app.metadataCache.getFileCache(file);
const frontmatterTags = metadata?.frontmatter?.tags;
const allTags = new Set<string>();
if (!frontmatterOnly) {
const inlineTags = metadata?.tags?.map((tag) => tag.tag);
if (inlineTags) {
inlineTags.forEach((tag) => allTags.add(stripHash(tag)));
}
}
// Add frontmatter tags
if (frontmatterTags) {
if (Array.isArray(frontmatterTags)) {
frontmatterTags.forEach((tag) => {
if (typeof tag === "string") {
allTags.add(stripHash(tag));
}
});
} else if (typeof frontmatterTags === "string") {
allTags.add(stripHash(frontmatterTags));
}
}
return Array.from(allTags);
}
/**
* Get notes from tags.
* @param vault - The vault to get notes from.
* @param tags - The tags to get notes from. Tags should be with the hash symbol.
* @param noteFiles - The notes to get notes from.
* @returns An array of note files.
*/
export function getNotesFromTags(vault: Vault, tags: string[], noteFiles?: TFile[]): TFile[] {
if (tags.length === 0) {
return [];
}
tags = tags.map((tag) => stripHash(tag));
const files = noteFiles && noteFiles.length > 0 ? noteFiles : getNotesFromPath(vault, "/");
const filesWithTag = [];
for (const file of files) {
const noteTags = getTagsFromNote(file);
if (tags.some((tag) => noteTags.includes(tag))) {
filesWithTag.push(file);
}
}
return filesWithTag;
}
// TODO: Chain type conversion still needed for chain runner selection
// This function is still used but the underlying chain infrastructure is deprecated
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}`);
}
};
// TODO: These chain validation functions are deprecated
// Remove after confirming chainManager no longer uses them
export const isLLMChain = (chain: RunnableSequence): chain is RunnableSequence => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (chain as any).last?.modelName || (chain as any).last?.model;
};
export const isRetrievalQAChain = (chain: BaseChain): chain is RetrievalQAChain => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (chain as any).last?.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(),
};
};
/**
* Ensure a folder path exists by creating any missing parent directories.
* Works across desktop and mobile. Safe to call repeatedly.
*
* Examples:
* - ensureFolderExists("copilot/copilot-conversations")
* - ensureFolderExists("some/deep/nested/path")
*
* Throws if any segment conflicts with an existing file.
*/
export async function ensureFolderExists(folderPath: string): Promise<void> {
const path = normalizePath(folderPath).replace(/^\/+/, "").replace(/\/+$/, "");
if (!path) return; // nothing to ensure
const parts = path.split("/").filter(Boolean);
let current = "";
for (const part of parts) {
current = current ? `${current}/${part}` : part;
const existing = app.vault.getAbstractFileByPath(current);
if (existing) {
if (existing instanceof TFile) {
throw new Error(`Path conflict: "${current}" exists as a file, expected folder.`);
}
// If it's a folder, continue to check/create the next segment
continue;
}
// Create this level; parents are guaranteed to exist from previous iterations
await app.vault.adapter.mkdir(current);
}
}
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(),
};
}
// Re-export for backward compatibility (constants now live in @/constants)
export { ALLOWED_NOTE_CONTEXT_EXTENSIONS, TEXT_READABLE_EXTENSIONS };
/**
* Check if a file has a text-readable extension (md, canvas, base).
*/
export function isTextReadableFile(file: TFile | null): boolean {
if (!file) return false;
return TEXT_READABLE_EXTENSIONS.includes(file.extension);
}
export async function getFileContent(file: TFile, vault: Vault): Promise<string | null> {
if (!isTextReadableFile(file)) return null;
return await vault.read(file);
}
export function getFileName(file: TFile): string {
return file.basename;
}
/**
* Check if a file is allowed for note context (text-readable files plus PDF).
* This does NOT include images - images are handled separately in the UI.
* @param file The file to check
* @returns true if the file is allowed for note context, false otherwise
*/
export function isAllowedFileForNoteContext(file: TFile | null): boolean {
if (!file) return false;
return ALLOWED_NOTE_CONTEXT_EXTENSIONS.includes(file.extension);
}
/**
* Checks if a chain type is a Plus mode chain (Copilot Plus or Project Chain).
* Plus mode chains have access to premium features like PDF processing and URL processing.
* @param chainType The chain type to check
* @returns true if this is a Plus mode chain, false otherwise
*/
export function isPlusChain(chainType: ChainType): boolean {
return chainType === ChainType.COPILOT_PLUS_CHAIN || chainType === ChainType.PROJECT_CHAIN;
}
/**
* Checks if a file extension is allowed for context based on the chain type.
* All chains support text-readable files (md, canvas, base).
* Plus chains additionally support PDF, EPUB, PPT, DOCX, etc.
* @param file The file to check
* @param chainType The current chain type
* @returns true if the file is allowed for this chain type, false otherwise
*/
export function isAllowedFileForChainContext(file: TFile | null, chainType: ChainType): boolean {
if (!file) return false;
if (isTextReadableFile(file)) {
return true;
}
// Plus chains support all other file types (PDF, EPUB, PPT, DOCX, etc.)
return isPlusChain(chainType);
}
export async function getAllNotesContent(vault: Vault): Promise<string> {
const vaultNotes: string[] = [];
const markdownFiles = vault.getMarkdownFiles();
for (const file of markdownFiles) {
const fileContent = await vault.cachedRead(file);
// Import is not available at the top level due to circular dependency
const { VAULT_NOTE_TAG } = await import("@/constants");
vaultNotes.push(
`<${VAULT_NOTE_TAG}>\n<path>${file.path}</path>\n<content>\n${fileContent}\n</content>\n</${VAULT_NOTE_TAG}>`
);
}
return vaultNotes.join("\n\n");
}
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.join(",")}`
: "";
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 interface ChatHistoryEntry {
role: "user" | "assistant";
content: string;
}
/**
* Extract text-only chat history from memory variables.
* This function pairs messages by index (i, i+1) and returns only string content.
*
* Note: For multimodal chains (CopilotPlus, AutonomousAgent), use
* chatHistoryUtils.processRawChatHistory instead to preserve image content.
*
* @param memoryVariables Memory variables from LangChain memory
* @returns Array of text-only chat history entries
*/
// TODO: Deprecated, use chatHistoryUtils.processRawChatHistory instead
export function extractChatHistory(memoryVariables: MemoryVariables): ChatHistoryEntry[] {
const chatHistory: ChatHistoryEntry[] = [];
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(
{ role: "user", content: userMessage },
{ role: "assistant", content: aiMessage }
);
}
return chatHistory;
}
/**
* Core logic for extracting note files from wikilink patterns.
* Resolves note titles/paths to TFile objects, handling both unique titles and full paths.
*
* @param noteTitles - Array of note title/path strings extracted from wikilinks
* @param vault - Obsidian vault instance
* @returns Array of unique TFile objects
*/
function resolveNoteFilesFromTitles(noteTitles: string[], vault: Vault): TFile[] {
const uniqueFiles = new Map<string, TFile>();
noteTitles.forEach((noteTitle) => {
// First try to get file by full path
const file = vault.getAbstractFileByPath(noteTitle);
if (file instanceof TFile) {
// Found by path, use it directly
uniqueFiles.set(file.path, file);
} else {
// Try to find by title
const files = vault.getMarkdownFiles();
const matchingFiles = files.filter((f) => f.basename === noteTitle);
if (matchingFiles.length > 0) {
if (isNoteTitleUnique(noteTitle, vault)) {
// Only one file with this title, use it
uniqueFiles.set(matchingFiles[0].path, matchingFiles[0]);
} else {
// Multiple files with same title - this shouldn't happen
// as we should be using full paths for duplicate titles
console.warn(
`Found multiple files with title "${noteTitle}". Expected a full path for duplicate titles.`
);
}
}
}
});
return Array.from(uniqueFiles.values());
}
/**
* Extract note files from text containing wikilinks: [[note title]]
* Used by search/retrieval systems to find explicitly mentioned notes.
*
* @param query - Text containing [[...]] patterns
* @param vault - Obsidian vault instance
* @returns Array of unique TFile objects matching the [[...]] patterns
*/
export function extractNoteFiles(query: string, vault: Vault): TFile[] {
// Use a regular expression to extract note titles and paths wrapped in [[]]
const regex = /\[\[(.*?)\]\]/g;
const matches = query.match(regex);
if (!matches) {
return [];
}
// Extract inner content from [[...]]
const noteTitles = matches.map((match) => match.slice(2, -2));
return resolveNoteFilesFromTitles(noteTitles, vault);
}
/**
* Extract note files from text containing wikilinks wrapped in curly braces: {[[note title]]}
* This is specifically for custom prompt templating where only {[[...]]} syntax should trigger
* note content inclusion.
*
* @param query - Text containing {[[...]]} patterns
* @param vault - Obsidian vault instance
* @returns Array of unique TFile objects matching the {[[...]]} patterns
*/
export function extractTemplateNoteFiles(query: string, vault: Vault): TFile[] {
// Use a regular expression to extract note titles and paths wrapped in {[[]]}
const regex = /\{\[\[(.*?)\]\]\}/g;
const matches = query.match(regex);
if (!matches) {
return [];
}
// Extract inner content from {[[...]]}
const noteTitles = matches.map((match) => match.slice(3, -3));
return resolveNoteFilesFromTitles(noteTitles, vault);
}
// Helper function to check if a note title is unique in the vault
export function isNoteTitleUnique(title: string, vault: Vault): boolean {
const files = vault.getMarkdownFiles();
return files.filter((f) => f.basename === title).length === 1;
}
// Helper function to determine if we should show the full path for a file
export function shouldShowPath(file: TFile): boolean {
return (file as any).needsPathDisplay === true;
}
/**
* 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 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&]+)/;
/**
* Validates a YouTube URL and returns detailed validation result
*/
export function validateYoutubeUrl(url: string): {
isValid: boolean;
error?: string;
videoId?: string;
} {
if (!url || typeof url !== "string") {
return { isValid: false, error: "URL is required" };
}
const trimmedUrl = url.trim();
if (!trimmedUrl) {
return { isValid: false, error: "URL cannot be empty" };
}
// Extract video ID
const videoId = extractYoutubeVideoId(trimmedUrl);
if (!videoId) {
return { isValid: false, error: "Invalid YouTube URL format" };
}
// Check if video ID is valid (11 characters, alphanumeric with dashes and underscores)
if (!/^[a-zA-Z0-9_-]{11}$/.test(videoId)) {
return { isValid: false, error: "Invalid YouTube video ID" };
}
return { isValid: true, videoId };
}
/**
* Extract YouTube video ID from various URL formats
*/
export function extractYoutubeVideoId(url: string): string | null {
try {
// Handle different YouTube URL formats
const patterns = [
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/v\/)([a-zA-Z0-9_-]{11})/,
/youtube\.com\/shorts\/([a-zA-Z0-9_-]{11})/,
];
for (const pattern of patterns) {
const match = url.match(pattern);
if (match && match[1]) {
return match[1];
}
}
return null;
} catch {
return null;
}
}
/**
* Create a standard YouTube URL from video ID
*/
export function formatYoutubeUrl(videoId: string): string {
return `https://www.youtube.com/watch?v=${videoId}`;
}
/**
* Check if a string is a valid YouTube URL (legacy function for backward compatibility)
*/
export function isYoutubeUrl(url: string): boolean {
return validateYoutubeUrl(url).isValid;
}
/**
* Check if a URL is a Twitter/X URL (e.g. tweet or post link)
*/
export function isTwitterUrl(url: string): boolean {
if (!url || typeof url !== "string") return false;
try {
const urlObj = new URL(url.trim());
return (
(urlObj.hostname === "x.com" ||
urlObj.hostname === "www.x.com" ||
urlObj.hostname === "twitter.com" ||
urlObj.hostname === "www.twitter.com") &&
urlObj.pathname.includes("/status/")
);
} catch {
return false;
}
}
/**
* Extract first YouTube URL from text (legacy function for backward compatibility)
*/
export function extractYoutubeUrl(text: string): string | null {
const match = text.match(YOUTUBE_URL_REGEX);
return match ? match[0] : null;
}
/**
* Extract all YouTube URLs from text (legacy function for backward compatibility)
*/
export function extractAllYoutubeUrls(text: string): string[] {
const matches = text.matchAll(new RegExp(YOUTUBE_URL_REGEX, "g"));
return Array.from(matches, (match) => match[0]);
}
/**
* Proxy function to use in place of fetch() to bypass CORS restrictions.
* Uses Obsidian's requestUrl which bypasses browser CORS restrictions.
*
* @param url - The URL to fetch
* @param options - Fetch options (subset of RequestInit)
* @param options.throwOnHttpError - If true (default), throws on HTTP >= 400. Set to false for fetch-like behavior.
*
* @remarks
* **AbortSignal Limitation**: The `signal` option is accepted for API compatibility
* but is NOT honored by the underlying `requestUrl` implementation. Requests made
* through this function cannot be cancelled via AbortSignal. If cancellation is
* required, use native `fetch` instead (which may encounter CORS issues on mobile).
*
* **Streaming Limitation**: This function does not support true streaming responses.
* The entire response body is buffered before being returned.
*
* @see https://forum.obsidian.md/t/support-streaming-the-request-and-requesturl-response-body/87381
*/
export async function safeFetch(
url: string,
options: RequestInit & { throwOnHttpError?: boolean } = {}
): Promise<Response> {
const { throwOnHttpError = true } = options;
// Initialize headers if not provided
const normalizedHeaders = new Headers(options.headers);
const headers = Object.fromEntries(normalizedHeaders.entries());
// Remove content-length if it exists
delete (headers as Record<string, string>)["content-length"];
logInfo("safeFetch request");
const method = options.method?.toUpperCase() || "POST";
const methodsWithBody = ["POST", "PUT", "PATCH"];
const response = await requestUrl({
url,
contentType: "application/json",
headers: headers,
method: method,
...(methodsWithBody.includes(method) && { body: options.body?.toString() }),
throw: false, // Don't throw so we can get the response body
});
// Check if response is error status (only throw if throwOnHttpError is true)
if (throwOnHttpError && response.status >= 400) {
let errorJson;
try {
errorJson = typeof response.json === "string" ? JSON.parse(response.json) : response.json;
} catch {
try {
errorJson = typeof response.text === "string" ? JSON.parse(response.text) : response.text;
} catch {
errorJson = null;
}
}
// Create error with proper structure
const error = new Error(ERROR_MESSAGES.REQUEST_FAILED(response.status)) as APIError;
error.json = errorJson;
// Handle nested error structure
if (
errorJson?.detail?.reason === "Invalid license key" ||
errorJson?.reason === "Invalid license key"
) {
error.message = "Invalid license key";
} else if (errorJson?.detail?.message || errorJson?.message) {
const message = errorJson?.detail?.message || errorJson?.message;
const reason = errorJson?.detail?.reason || errorJson?.reason;
error.message = reason ? `${message}: ${reason}` : message;
} else if (errorJson?.detail) {
error.message = JSON.stringify(errorJson.detail);
} else if (errorJson) {
// for external error, add more msg
error.message += ". " + JSON.stringify(errorJson);
}
throw error;
}
return {
ok: response.status >= 200 && response.status < 300,
status: response.status,
statusText: response.status.toString(),
headers: new Headers(response.headers),
url: url,
type: "basic" as ResponseType,
redirected: false,
bytes: () => Promise.resolve(new Uint8Array(0)),
body: createReadableStreamFromString(response.text),
bodyUsed: true,
json: () => response.json,
text: async () => response.text,
arrayBuffer: async () => {
if (response.arrayBuffer) {
return response.arrayBuffer;
}
const base64 = response.text.replace(/^data:.*;base64,/, "");
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
},
blob: () => {
throw new Error("not implemented");
},
formData: () => {
throw new Error("not implemented");
},
clone: () => {
throw new Error("not implemented");
},
};
}
/**
* Wrapper around safeFetch that doesn't throw on HTTP errors (fetch-like behavior).
* Use this when you need to check response.status for retry logic (e.g., 401 token refresh).
*
* @remarks
* Inherits all limitations from safeFetch:
* - AbortSignal is NOT honored (requests cannot be cancelled)
* - No true streaming support (response is fully buffered)
*
* @see safeFetch for full documentation
*/
export function safeFetchNoThrow(url: string, options: RequestInit = {}): Promise<Response> {
return safeFetch(url, { ...options, throwOnHttpError: false });
}
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();
},
});
}
// err2String is now exported from '@/errorFormat' to avoid circular dependencies and duplication.
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, model?: CustomModel): string {
const baseLabel = ProviderInfo[provider as Provider]?.label || provider;
return baseLabel + (model?.believerExclusive && baseLabel === "Copilot Plus" ? "(Believer)" : "");
}
export function getProviderHost(provider: string): string {
return ProviderInfo[provider as Provider]?.host || "";
}
export function getProviderKeyManagementURL(provider: string): string {
return ProviderInfo[provider as Provider]?.keyManagementURL || "";
}
/**
* Cleans a message by removing Think blocks, Action blocks (writeFile), tool call markers,
* and agent reasoning blocks for copying to clipboard or inserting at cursor.
* This is more comprehensive than removeThinkTags which is used for RAG.
*/
export function cleanMessageForCopy(message: string): string {
let cleanedMessage = message;
// First use the existing removeThinkTags function
cleanedMessage = removeThinkTags(cleanedMessage);
// Remove writeFile blocks wrapped in XML codeblocks (also handles legacy writeToFile tag)
cleanedMessage = cleanedMessage.replace(
/```xml\s*[\s\S]*?<write(?:File|ToFile)>[\s\S]*?<\/write(?:File|ToFile)>[\s\S]*?```/g,
""
);
// Remove standalone writeFile/writeToFile blocks
cleanedMessage = cleanedMessage.replace(
/<write(?:File|ToFile)>[\s\S]*?<\/write(?:File|ToFile)>/g,
""
);
// Remove tool call markers
// Format: <!--TOOL_CALL_START:id:toolName:displayName:emoji:confirmationMessage:isExecuting-->content<!--TOOL_CALL_END:id:result-->
cleanedMessage = cleanedMessage.replace(
/<!--TOOL_CALL_START:[^:]+:[^:]+:[^:]+:[^:]+:[^:]*:[^:]+-->[\s\S]*?<!--TOOL_CALL_END:[^:]+:[\s\S]*?-->/g,
""
);
// Remove agent reasoning blocks
// Format: <!--AGENT_REASONING:status:elapsed:["step1","step2"]-->
// Use greedy .* so we match to the real closing --> even if the JSON payload contains -->
cleanedMessage = cleanedMessage.replace(/<!--AGENT_REASONING:\w+:\d+:.*-->/g, "");
// Clean up any resulting multiple consecutive newlines (more than 2)
cleanedMessage = cleanedMessage.replace(/\n{3,}/g, "\n\n");
// Trim leading and trailing whitespace
cleanedMessage = cleanedMessage.trim();
return cleanedMessage;
}
/**
* Inserts a message into the active markdown editor, optionally replacing the current selection.
* Uses a single CM6 transaction to avoid undo stack splitting.
* Ensures the inserted/replaced range is selected after the operation.
*/
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");
// Clean the message before inserting (removes think tags, writeFile blocks, tool calls)
const cleanedMessage = cleanMessageForCopy(message);
const cleanedLines = cleanedMessage.split("\n");
/** Computes the end editor position after inserting text at start position */
const getEndPosition = (
start: { line: number; ch: number },
textLines: string[]
): { line: number; ch: number } => {
const lineDelta = textLines.length - 1;
if (lineDelta === 0) {
return { line: start.line, ch: start.ch + (textLines[0]?.length ?? 0) };
}
return { line: start.line + lineDelta, ch: textLines[textLines.length - 1]?.length ?? 0 };
};
/** Inserts via Obsidian Editor API (fallback when CM6 unavailable or dispatch fails) */
const insertWithEditorAPI = (): void => {
const changeFrom = replace ? cursorFrom : cursorTo;
editor.replaceRange(cleanedMessage, changeFrom, cursorTo);
editor.setSelection(changeFrom, getEndPosition(changeFrom, cleanedLines));
};
/** Focuses the editor and shows success notice */
const finalizeInsertion = (): void => {
editor.focus();
new Notice("Message inserted into the active note.");
};
// Check if CM6 EditorView is available and valid
const view = editor.cm;
const isCM6View = view?.state?.doc && typeof view.dispatch === "function";
if (!isCM6View) {
insertWithEditorAPI();
finalizeInsertion();
return;
}
// Use CM6 selection offsets directly (simpler than manual line/ch conversion)
const { from, to } = view.state.selection.main;
const changeFrom = replace ? from : to;
// Use CM6's toText to handle CRLF normalization correctly
// (CM6 treats \r\n as single newline, so string.length would be wrong)
const insertText = view.state.toText(cleanedMessage);
const endOffset = changeFrom + insertText.length;
try {
// Single transaction: text change + selection change
view.dispatch({
changes: { from: changeFrom, to, insert: insertText },
selection: { anchor: changeFrom, head: endOffset },
});
} catch (e) {
// Fallback to Obsidian API if CM6 dispatch fails
logWarn("CM6 dispatch failed, falling back to Obsidian API", e);
insertWithEditorAPI();
}
finalizeInsertion();
}
export function debounce<T extends (...args: any[]) => void>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout;
return (...args: Parameters<T>) => {
clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
}
/**
* Compare two semantic version strings.
* @returns true if latest version is newer than current version
*/
export function isNewerVersion(latest: string, current: string): boolean {
const latestParts = latest.split(".").map(Number);
const currentParts = current.split(".").map(Number);
for (let i = 0; i < 3; i++) {
if (latestParts[i] > currentParts[i]) return true;
if (latestParts[i] < currentParts[i]) return false;
}
return false;
}
/**
* Check for latest version from GitHub releases.
* @returns latest version string or error message
*/
export async function checkLatestVersion(): Promise<{
version: string | null;
error: string | null;
}> {
try {
const response = await requestUrl({
url: "https://api.github.com/repos/logancyang/obsidian-copilot/releases/latest",
method: "GET",
});
const version = response.json.tag_name.replace("v", "");
return { version, error: null };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Failed to check for updates";
return { version: null, error: errorMessage };
}
}
// Note: LangChain 0.6.6+ handles O-series and GPT-5 models automatically
// These functions are kept for backward compatibility and specific checks
export function isOSeriesModel(model: BaseChatModel | string): boolean {
if (typeof model === "string") {
return model.startsWith("o1") || model.startsWith("o3") || model.startsWith("o4");
}
// For BaseChatModel instances
const modelName = (model as any).modelName || (model as any).model || "";
return modelName.startsWith("o1") || modelName.startsWith("o3") || modelName.startsWith("o4");
}
export function isGPT5Model(model: BaseChatModel | string): boolean {
if (typeof model === "string") {
return model.startsWith("gpt-5");
}
// For BaseChatModel instances
const modelName = (model as any).modelName || (model as any).model || "";
return modelName.startsWith("gpt-5");
}
/**
* Checks whether a model belongs to the Codex family.
* Codex model identifiers consistently include the "codex" token.
* @param model - Model instance or model name string.
* @returns True when the model name indicates a Codex model.
*/
export function isCodexModel(model: BaseChatModel | string): boolean {
const modelName =
typeof model === "string" ? model : (model as any).modelName || (model as any).model || "";
return modelName.toLowerCase().includes("codex");
}
/**
* Determines whether a GitHub Copilot model should use the Responses API.
* Copilot Codex models reject `/chat/completions` and must be sent to `/responses`.
* @param model - Minimal model configuration used for routing.
* @returns True when the model should be routed to `/responses`.
*/
export function shouldUseGitHubCopilotResponsesApi(
model: Pick<CustomModel, "provider" | "name" | "useResponsesApi">
): boolean {
if (model.provider !== ChatModelProviders.GITHUB_COPILOT) {
return false;
}
if (model.useResponsesApi === true) {
return true;
}
return isCodexModel(model.name);
}
/**
* Utility for determining model characteristics
* Note: Most of this is handled by LangChain 0.6.6+ internally
*/
export interface ModelInfo {
isOSeries: boolean;
isGPT5: boolean;
isThinkingEnabled: boolean;
}
export function getModelInfo(model: BaseChatModel | string): ModelInfo {
const modelName =
typeof model === "string" ? model : (model as any).modelName || (model as any).model || "";
const isOSeries = isOSeriesModel(modelName);
const isGPT5 = isGPT5Model(modelName);
const isThinkingEnabled =
modelName.startsWith("claude-3-7-sonnet") ||
modelName.startsWith("claude-sonnet-4") ||
modelName.startsWith("claude-opus-4");
return {
isOSeries,
isGPT5,
isThinkingEnabled,
};
}
export function getMessageRole(
model: BaseChatModel | string,
defaultRole: "system" | "human" = "system"
): "system" | "human" {
return isOSeriesModel(model) ? "human" : defaultRole;
}
export function getNeedSetKeyProvider(): Provider[] {
// List of providers to exclude
const excludeProviders: Provider[] = [
ChatModelProviders.OPENAI_FORMAT,
ChatModelProviders.OLLAMA,
ChatModelProviders.LM_STUDIO,
ChatModelProviders.AZURE_OPENAI,
ChatModelProviders.GITHUB_COPILOT,
EmbeddingModelProviders.COPILOT_PLUS,
EmbeddingModelProviders.COPILOT_PLUS_JINA,
];
return (Object.keys(ProviderInfo) as Provider[]).filter((key) => !excludeProviders.includes(key));
}
export function checkModelApiKey(
model: CustomModel,
settings: Readonly<CopilotSettings>
): {
hasApiKey: boolean;
errorNotice?: string;
} {
if (model.provider === ChatModelProviders.AMAZON_BEDROCK) {
const apiKey = model.apiKey || settings.amazonBedrockApiKey;
if (!apiKey) {
return {
hasApiKey: false,
errorNotice:
"Amazon Bedrock API key is missing. Please add a key in Settings > API Keys or update the model configuration.",
};
}
// Region defaults to us-east-1 if not specified, so API key is the only required check
return { hasApiKey: true };
}
// GitHub Copilot uses OAuth, not API key
if (model.provider === ChatModelProviders.GITHUB_COPILOT) {
const hasAuth = Boolean(
model.apiKey || settings.githubCopilotToken || settings.githubCopilotAccessToken
);
if (!hasAuth) {
return {
hasApiKey: false,
errorNotice:
"GitHub Copilot is not authenticated. Please connect it in Settings > Copilot > Basic Tab > Set Keys.",
};
}
return { hasApiKey: true };
}
const needSetKeyPath = !!getNeedSetKeyProvider().find((provider) => provider === model.provider);
const hasNoApiKey = !getApiKeyForProvider(model.provider as SettingKeyProviders, model);
// For Providers that require setting a key in the dialog, an inspection is necessary.
if (needSetKeyPath && hasNoApiKey) {
const notice =
`Please configure API Key for ${model.name} in settings first.` +
"\nPath: Settings > copilot plugin > Basic Tab > Set Keys";
return {
hasApiKey: false,
errorNotice: notice,
};
}
return {
hasApiKey: true,
};
}
/**
* Extracts text content from a message chunk that could be either a string
* or an array of content objects (Claude 3.7 format)
*/
export function extractTextFromChunk(content: any): string {
if (typeof content === "string") {
return content;
}
if (Array.isArray(content)) {
return content
.filter((item) => item.type === "text")
.map((item) => item.text)
.join("");
}
// For any other type, try to convert to string or return empty
return String(content || "");
}
/**
* Removes any <think> tags and their content from the text.
* This is used to clean model outputs before using them for RAG.
* Handles both string content and array-based content (Claude 3.7 format)
* @param text - The text or content array to remove think tags from
* @returns The text with think tags removed
*/
export function removeThinkTags(text: any): string {
// First convert any content format to plain text
const plainText = extractTextFromChunk(text);
// Remove complete think tags and their content
let cleanedText = plainText.replace(/<think>[\s\S]*?<\/think>/g, "");
// Remove any remaining unclosed think tags (for streaming scenarios)
cleanedText = cleanedText.replace(/<think>[\s\S]*$/g, "");
return cleanedText.trim();
}
/**
* Removes any <errorChunk> tags and their content from the text.
* This is used to clean model outputs before using them for RAG.
* Handles both string content and array-based content (Claude 3.7 format)
* @param text - The text or content array to remove error tags from
* @returns The text with error tags removed
*/
export function removeErrorTags(text: any): string {
// First convert any content format to plain text
const plainText = extractTextFromChunk(text);
// Then remove error tags
return plainText.replace(/<errorChunk>[\s\S]*?<\/errorChunk>/g, "").trim();
}
export function randomUUID() {
return crypto.randomUUID();
}
/**
* Executes a function with token counting warnings suppressed
* This can be used anywhere in the codebase where token counting warnings should be suppressed
* @param fn The function to execute without token counting warnings
* @returns The result of the function
*/
export async function withSuppressedTokenWarnings<T>(fn: () => Promise<T>): Promise<T> {
// Store original console.warn
const originalWarn = console.warn;
try {
// Replace with filtered version
console.warn = function (...args) {
// Ignore token counting warnings
if (
args[0]?.includes &&
(args[0].includes("Failed to calculate number of tokens") ||
args[0].includes("Unknown model"))
) {
return;
}
// Pass through other warnings
return originalWarn.apply(console, args);
};
// Execute the provided function
return await fn();
} finally {
// Always restore original console.warn, even if an error occurs
console.warn = originalWarn;
}
}
/**
* Execute an operation with a timeout using AbortController for proper cancellation
* @param operation - Function that accepts an AbortSignal and returns a Promise
* @param timeoutMs - Timeout in milliseconds
* @param operationName - Name of the operation for error messages
* @returns Promise that resolves with the operation result or rejects with TimeoutError
*/
export async function withTimeout<T>(
operation: (signal: AbortSignal) => Promise<T>,
timeoutMs: number,
operationName: string = "Operation"
): Promise<T> {
const { TimeoutError } = await import("@/error");
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
}, timeoutMs);
try {
return await Promise.race([
operation(controller.signal),
new Promise<never>((_, reject) => {
controller.signal.addEventListener("abort", () => {
reject(new TimeoutError(operationName, timeoutMs));
});
}),
]);
} finally {
clearTimeout(timeoutId);
}
}
/**
* Check if the current Obsidian editor setting is in source mode
*/
export function isSourceModeOn(): boolean {
const view = app.workspace.getActiveViewOfType(MarkdownView);
if (!view) return true;
const state = view.getState() as { source?: boolean };
return state.source === true;
}
/**
* Calculate the UTF-8 byte length of a string.
* This is important for filesystem operations where filename limits are in bytes, not characters.
* @param str - The string to measure
* @returns The byte length when encoded as UTF-8
*/
export function getUtf8ByteLength(str: string): number {
// Use TextEncoder which always uses UTF-8 encoding
return new TextEncoder().encode(str).length;
}
/**
* Truncate a string to fit within a byte limit, ensuring UTF-8 character boundaries are respected.
* This prevents cutting multibyte UTF-8 sequences in the middle.
* @param str - The string to truncate
* @param byteLimit - Maximum number of bytes (not characters)
* @returns The truncated string that fits within the byte limit
*/
export function truncateToByteLimit(str: string, byteLimit: number): string {
if (byteLimit <= 0) {
return "";
}
// Fast path: if string already fits, return as-is
const encoder = new TextEncoder();
const bytes = encoder.encode(str);
if (bytes.length <= byteLimit) {
return str;
}
// Binary search to find the longest prefix that fits
let low = 0;
let high = str.length;
let result = "";
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const candidate = str.substring(0, mid);
const candidateBytes = encoder.encode(candidate);
if (candidateBytes.length <= byteLimit) {
// This candidate fits, try a longer one
result = candidate;
low = mid + 1;
} else {
// This candidate is too long, try a shorter one
high = mid - 1;
}
}
return result;
}
/** Maximum filename (basename) length in bytes for most filesystems (ext4, NTFS, APFS). */
const MAX_FILENAME_BYTES = 255;
/**
* Sanitize a vault-relative file path by truncating the basename if it exceeds
* filesystem filename length limits. Preserves the file extension.
* @param filePath - Vault-relative file path
* @returns The sanitized path with basename truncated if necessary
*/
export function sanitizeFilePath(filePath: string): string {
const parts = filePath.split("/");
const basename = parts[parts.length - 1];
if (getUtf8ByteLength(basename) <= MAX_FILENAME_BYTES) {
return filePath;
}
const extIndex = basename.lastIndexOf(".");
const ext = extIndex >= 0 ? basename.substring(extIndex) : "";
const name = extIndex >= 0 ? basename.substring(0, extIndex) : basename;
const extBytes = getUtf8ByteLength(ext);
const availableBytes = MAX_FILENAME_BYTES - extBytes;
if (availableBytes <= 0) {
parts[parts.length - 1] = truncateToByteLimit(basename, MAX_FILENAME_BYTES);
} else {
parts[parts.length - 1] = truncateToByteLimit(name, availableBytes) + ext;
}
return parts.join("/");
}
/**
* Opens a file in the workspace, reusing an existing tab if the file is already open.
* @param file - The TFile to open
* @param focusIfOpen - If true, focuses the existing leaf if the file is already open (default: true)
*/
export async function openFileInWorkspace(file: TFile, focusIfOpen: boolean = true): Promise<void> {
// Check if the file is already open in any leaf
let existingLeaf = null;
app.workspace.iterateAllLeaves((leaf) => {
if (
leaf.view.getViewType() === "markdown" ||
leaf.view.getViewType() === "pdf" ||
leaf.view.getViewType() === "canvas"
) {
const viewFile = (leaf.view as any).file;
if (viewFile && viewFile.path === file.path) {
existingLeaf = leaf;
}
}
});
if (existingLeaf && focusIfOpen) {
// File is already open, focus the existing leaf
app.workspace.setActiveLeaf(existingLeaf, { focus: true });
} else if (!existingLeaf) {
// File is not open, open it in a new tab
const leaf = app.workspace.getLeaf("tab");
await leaf.openFile(file);
}
}