mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
* fix(anthropic): use thinking.type=adaptive for claude-opus-4-7+
claude-opus-4-7 rejects the legacy thinking shape with a 400:
`thinking.type.enabled` is not supported for this model.
Use `thinking.type.adaptive` and `output_config.effort`.
Detect opus-4 minor version in getModelInfo and emit
{ type: "adaptive" } for >=7, keeping { type: "enabled", budget_tokens }
for opus-4-6 and earlier, sonnet-4, and 3-7-sonnet.
Bumps @langchain/anthropic ^1.0.0 -> ^1.3.29 to pick up the adaptive
ThinkingConfigParam variant and an updated tool-schema typing that lets
BedrockChatModel.convertTools drop its now-redundant Record cast.
Fixes #2361. Replaces stale PR #2362.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(bedrock): use thinking.type=adaptive for claude-opus-4-7+
Same 400 from claude-opus-4-7+ as the direct Anthropic path (fixed in
the previous commit), but the Bedrock provider has its own payload
builder in BedrockChatModel.buildRequestBody that was unaffected.
Add the same minor-version detection inside that payload builder.
Uses an unanchored regex /claude-opus-4-(\d+)/ because Bedrock model
IDs carry a provider/profile prefix (e.g.
"global.anthropic.claude-opus-4-7-20260115-v1:0").
Fixes #2384.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: use real sonnet model IDs
claude-sonnet-4-7 doesn't exist; tests were asserting against a
fabricated ID. Switch the non-opus negative cases to a real
claude-sonnet-4-5 (and keep the existing claude-3-7-sonnet test).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(thinking): set output_config.display=summarized for claude-opus-4-7+
Adaptive thinking on Opus 4.7+ alone restores the request shape but
leaves the UI empty — Anthropic flipped the default for
output_config.display from "summarized" to "omitted" on this model
line, so the API stops emitting visible thinking content blocks.
Pre-4.7 models still default to "summarized" server-side.
Set display: "summarized" alongside thinking: { type: "adaptive" } on
both the direct Anthropic and Bedrock paths so opus-4-7 renders
thinking summaries the same way sonnet 4.6 does today.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(thinking): nest display=summarized inside thinking config
The Anthropic API places `display: 'summarized' | 'omitted'` on the
thinking config itself (ThinkingConfigAdaptive / ThinkingConfigEnabled),
not on a top-level `output_config`. The previous commit set
`outputConfig: { display: "summarized" }` on the ChatAnthropic config and
`output_config: { display: "summarized" }` on the Bedrock request body,
which broke the TS build (the SDK's OutputConfig only exposes
effort/format) and would not have produced the intended server behavior
for the adaptive thinking summarization toggle.
- chatModelManager.ts: emit `thinking: { type: "adaptive", display: "summarized" }`
- BedrockChatModel.ts: same, on the request payload
- BedrockChatModel.test.ts: update assertions to the new shape
* fix(thinking): constrain Opus minor regex so dated snapshots don't match
The previous `^claude-opus-4-(\d+)` (Anthropic) and unanchored
`claude-opus-4-(\d+)` (Bedrock) regex captured the date portion of
snapshot IDs like `claude-opus-4-20250514` as if it were the minor
version, so `usesAdaptiveThinking` evaluated as `true` (20250514 >= 7)
for pre-4.7 Opus models. That would send `thinking: { type: "adaptive" }`
for the legacy 4.0 snapshot and could trigger 400s on REASONING-enabled
runs.
Constrain the minor capture to 1-2 digits followed by `-`/`.` or end of
string. Real Anthropic minor versions are single/double digit; dated
snapshots have an 8-digit date that this regex now rejects.
- src/utils.ts: anchored fix for the Anthropic path
- src/LLMProviders/BedrockChatModel.ts: unanchored fix for Bedrock IDs
- regression tests for dated 4.0/4.1 snapshots in both src/utils.test.ts
and src/LLMProviders/BedrockChatModel.test.ts
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1421 lines
46 KiB
TypeScript
1421 lines
46 KiB
TypeScript
// Reason: `buffer` is the npm polyfill (browser-compatible), bundled by esbuild
|
|
// so the same Buffer code path works on desktop (Electron) and mobile (WebView).
|
|
// eslint-disable-next-line import/no-nodejs-modules
|
|
import { Buffer } from "buffer";
|
|
|
|
import { ChainType } from "@/chainType";
|
|
import {
|
|
ALLOWED_NOTE_CONTEXT_EXTENSIONS,
|
|
ChatModelProviders,
|
|
EmbeddingModelProviders,
|
|
NOMIC_EMBED_TEXT,
|
|
Provider,
|
|
ProviderInfo,
|
|
ProviderMetadata,
|
|
SettingKeyProviders,
|
|
TEXT_READABLE_EXTENSIONS,
|
|
} from "@/constants";
|
|
import { logInfo, logWarn } from "@/logger";
|
|
import { CopilotSettings } from "@/settings/model";
|
|
import { BaseChatModel } from "@langchain/core/language_models/chat_models";
|
|
import { Document } from "@langchain/core/documents";
|
|
import { MemoryVariables } from "@langchain/core/memory";
|
|
import { DateTime } from "luxon";
|
|
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?: unknown;
|
|
}
|
|
|
|
// Error message constants
|
|
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
|
|
interface ErrorDetail {
|
|
status?: number;
|
|
message?: string;
|
|
reason?: string;
|
|
}
|
|
|
|
function extractErrorDetail(error: unknown): ErrorDetail {
|
|
const err = error as Record<string, unknown> | null | undefined;
|
|
const errorDetail: ErrorDetail = (err?.detail as ErrorDetail) || {};
|
|
return {
|
|
status: errorDetail.status,
|
|
message: errorDetail.message || (err?.message as string | undefined),
|
|
reason: errorDetail.reason,
|
|
};
|
|
}
|
|
|
|
function isLicenseKeyError(error: unknown): boolean {
|
|
const errorDetail = extractErrorDetail(error);
|
|
const err = error as Record<string, unknown> | null | undefined;
|
|
const message = err?.message as string | undefined;
|
|
return Boolean(
|
|
errorDetail.reason === "Invalid license key" ||
|
|
message === "Invalid license key" ||
|
|
message?.includes("status 403") ||
|
|
errorDetail.status === 403
|
|
);
|
|
}
|
|
|
|
export function getApiErrorMessage(error: unknown): 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 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}.
|
|
*/
|
|
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;
|
|
}
|
|
|
|
export interface FormattedDateTime {
|
|
fileName: string;
|
|
display: string;
|
|
epoch: number;
|
|
}
|
|
|
|
export const formatDateTime = (
|
|
now: Date,
|
|
timezone: "local" | "utc" = "local"
|
|
): FormattedDateTime => {
|
|
const dt = timezone === "utc" ? DateTime.fromJSDate(now).toUTC() : DateTime.fromJSDate(now);
|
|
|
|
return {
|
|
fileName: dt.toFormat("yyyyMMdd_HHmmss"),
|
|
display: dt.toFormat("yyyy/MM/dd HH:mm:ss"),
|
|
epoch: dt.toMillis(),
|
|
};
|
|
};
|
|
|
|
/**
|
|
* 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 = DateTime.fromFormat(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.toFormat("yyyyMMdd_HHmmss"),
|
|
display: date.toFormat("yyyy/MM/dd HH:mm:ss"),
|
|
epoch: date.toMillis(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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 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;
|
|
}
|
|
|
|
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
|
|
function isNoteTitleUnique(title: string, vault: Vault): boolean {
|
|
const files = vault.getMarkdownFiles();
|
|
return files.filter((f) => f.basename === title).length === 1;
|
|
}
|
|
|
|
/**
|
|
* 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 as string);
|
|
}
|
|
});
|
|
|
|
return Array.from(titlesSet);
|
|
}
|
|
|
|
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 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) &&
|
|
typeof options.body === "string" && { body: options.body }),
|
|
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: (): Promise<unknown> => Promise.resolve(response.json as unknown),
|
|
text: async () => response.text,
|
|
arrayBuffer: async () => {
|
|
if (response.arrayBuffer) {
|
|
return response.arrayBuffer;
|
|
}
|
|
const base64 = response.text.replace(/^data:.*;base64,/, "");
|
|
// Reason: Buffer (from the `buffer` polyfill imported above) is the
|
|
// cross-platform path — bare global Buffer is undefined in mobile WebView.
|
|
const buf = Buffer.from(base64, "base64");
|
|
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer;
|
|
},
|
|
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 object, 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)" : "");
|
|
}
|
|
|
|
/**
|
|
* 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 { debounce } from "@/utils/debounce";
|
|
|
|
/**
|
|
* 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 m = model as unknown as Record<string, unknown>;
|
|
const modelName: string = (m.modelName as string) || (m.model as string) || "";
|
|
return modelName.startsWith("o1") || modelName.startsWith("o3") || modelName.startsWith("o4");
|
|
}
|
|
|
|
function isGPT5Model(model: BaseChatModel | string): boolean {
|
|
if (typeof model === "string") {
|
|
return model.startsWith("gpt-5");
|
|
}
|
|
|
|
// For BaseChatModel instances
|
|
const m = model as unknown as Record<string, unknown>;
|
|
const modelName: string = (m.modelName as string) || (m.model as string) || "";
|
|
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.
|
|
*/
|
|
function isCodexModel(model: BaseChatModel | string): boolean {
|
|
const m = model as unknown as Record<string, unknown>;
|
|
const modelName: string =
|
|
typeof model === "string" ? model : (m.modelName as string) || (m.model as string) || "";
|
|
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 as ChatModelProviders) !== 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;
|
|
usesAdaptiveThinking: boolean;
|
|
}
|
|
|
|
export function getModelInfo(model: BaseChatModel | string): ModelInfo {
|
|
const m = model as unknown as Record<string, unknown>;
|
|
const modelName: string =
|
|
typeof model === "string" ? model : (m.modelName as string) || (m.model as string) || "";
|
|
|
|
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");
|
|
|
|
// claude-opus-4-7 and later reject the legacy { type: "enabled", budget_tokens } shape
|
|
// with a 400 and require { type: "adaptive" }. Detect by minor version on the opus-4 line.
|
|
// Constrain the minor to 1-2 digits followed by a delimiter or end-of-string so dated
|
|
// snapshot IDs (e.g. "claude-opus-4-20250514") aren't misread as Opus 4.20250514.
|
|
const opusMinorMatch = modelName.match(/^claude-opus-4-(\d{1,2})(?:[-.]|$)/);
|
|
const usesAdaptiveThinking = opusMinorMatch ? parseInt(opusMinorMatch[1], 10) >= 7 : false;
|
|
|
|
return {
|
|
isOSeries,
|
|
isGPT5,
|
|
isThinkingEnabled,
|
|
usesAdaptiveThinking,
|
|
};
|
|
}
|
|
|
|
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;
|
|
} {
|
|
const provider = model.provider as ChatModelProviders;
|
|
if (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 (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((p) => p === 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: unknown): string {
|
|
if (typeof content === "string") {
|
|
return content;
|
|
}
|
|
if (Array.isArray(content)) {
|
|
return content
|
|
.filter((item) => item.type === "text")
|
|
.map((item) => item.text as string)
|
|
.join("");
|
|
}
|
|
// For any other type, return empty string
|
|
return "";
|
|
}
|
|
|
|
/**
|
|
* 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: unknown): 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: unknown): 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
|
|
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 = window.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 {
|
|
window.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 unknown as Record<string, unknown>).file as
|
|
| { path: string }
|
|
| undefined;
|
|
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);
|
|
}
|
|
}
|