mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
* feat: Add Web Viewer bridge for referencing open web tabs in chat. * refactor: Simplify markdown image extraction parser。 * fix: Prevent web selection from auto-reappearing after removal or new chat. * fix: Suppress active web tab when web selection exists. * feat: Add YouTube transcript extraction for Web Viewer tabs. * feat: Built-in slash prompts for web clippers. * feat: Improve web selection tracking and simplify context settings - Merge context settings: combine note/web active content and selection settings - Add auto-clear for web selection badge when deselected on page - Make note and web selections mutually exclusive - Show website favicon in web selection badge - Add migration logic and tests for settings
697 lines
24 KiB
TypeScript
697 lines
24 KiB
TypeScript
import { getCurrentProject } from "@/aiParams";
|
|
import { AI_SENDER, USER_SENDER } from "@/constants";
|
|
import ChainManager from "@/LLMProviders/chainManager";
|
|
import { logError, logInfo, logWarn } from "@/logger";
|
|
import { getSettings } from "@/settings/model";
|
|
import { ChatMessage } from "@/types/message";
|
|
import {
|
|
ensureFolderExists,
|
|
extractTextFromChunk,
|
|
formatDateTime,
|
|
getUtf8ByteLength,
|
|
truncateToByteLimit,
|
|
} from "@/utils";
|
|
import { App, Notice, TFile, TFolder } from "obsidian";
|
|
import { MessageRepository } from "./MessageRepository";
|
|
|
|
const SAFE_FILENAME_BYTE_LIMIT = 100;
|
|
|
|
/**
|
|
* Escape a string for safe YAML double-quoted string value
|
|
* Escapes backslashes and double quotes to prevent YAML parsing errors
|
|
*/
|
|
function escapeYamlString(str: string): string {
|
|
return str.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
}
|
|
|
|
/**
|
|
* ChatPersistenceManager - Handles saving and loading chat messages
|
|
*
|
|
* This class is responsible for:
|
|
* - Saving chat history to markdown files in the vault
|
|
* - Loading chat history from markdown files
|
|
* - Managing project-aware file naming
|
|
* - Formatting chat content for storage
|
|
*/
|
|
export class ChatPersistenceManager {
|
|
constructor(
|
|
private app: App,
|
|
private messageRepo: MessageRepository,
|
|
private chainManager?: ChainManager
|
|
) {}
|
|
|
|
/**
|
|
* Save current chat history to a markdown file
|
|
*/
|
|
async saveChat(modelKey: string): Promise<void> {
|
|
try {
|
|
const messages = this.messageRepo.getDisplayMessages();
|
|
if (messages.length === 0) {
|
|
new Notice("No messages to save.");
|
|
return;
|
|
}
|
|
|
|
const settings = getSettings();
|
|
const chatContent = this.formatChatContent(messages);
|
|
const firstMessageEpoch = messages[0].timestamp?.epoch || Date.now();
|
|
|
|
// Ensure the save folder exists (supports nested paths) using utility helper.
|
|
await ensureFolderExists(settings.defaultSaveFolder);
|
|
|
|
// Check if a file with this epoch already exists
|
|
const existingFile = await this.findFileByEpoch(firstMessageEpoch);
|
|
let existingTopic = existingFile
|
|
? this.app.metadataCache.getFileCache(existingFile)?.frontmatter?.topic
|
|
: undefined;
|
|
|
|
const preferredFileName = existingFile
|
|
? existingFile.path
|
|
: this.generateFileName(messages, firstMessageEpoch, existingTopic);
|
|
|
|
const noteContent = this.generateNoteContent(
|
|
chatContent,
|
|
firstMessageEpoch,
|
|
modelKey,
|
|
existingTopic
|
|
);
|
|
let targetFile: TFile | null = existingFile;
|
|
|
|
if (existingFile) {
|
|
// If the file exists, update its content
|
|
await this.app.vault.modify(existingFile, noteContent);
|
|
logInfo(`[ChatPersistenceManager] Updated existing chat file: ${existingFile.path}`);
|
|
} else {
|
|
// If the file doesn't exist, create a new one
|
|
try {
|
|
targetFile = await this.app.vault.create(preferredFileName, noteContent);
|
|
new Notice(`Chat saved as note: ${preferredFileName}`);
|
|
logInfo(`[ChatPersistenceManager] Created new chat file: ${preferredFileName}`);
|
|
} catch (error) {
|
|
if (this.isFileAlreadyExistsError(error)) {
|
|
const conflictFile = this.app.vault.getAbstractFileByPath(preferredFileName);
|
|
if (conflictFile && conflictFile instanceof TFile) {
|
|
// Update existingTopic to prevent unnecessary regeneration
|
|
existingTopic =
|
|
this.app.metadataCache.getFileCache(conflictFile)?.frontmatter?.topic ??
|
|
existingTopic;
|
|
await this.app.vault.modify(conflictFile, noteContent);
|
|
targetFile = conflictFile;
|
|
new Notice("Existing chat note found - updating it now.");
|
|
logInfo(
|
|
`[ChatPersistenceManager] Resolved save conflict by updating existing chat file: ${conflictFile.path}`
|
|
);
|
|
} else {
|
|
throw error;
|
|
}
|
|
} else if (this.isNameTooLongError(error)) {
|
|
// Single fallback: minimal guaranteed-to-work filename with project prefix
|
|
const currentProject = getCurrentProject();
|
|
const filePrefix = currentProject ? `${currentProject.id}__` : "";
|
|
const fallbackName = `${settings.defaultSaveFolder}/${filePrefix}chat-${firstMessageEpoch}.md`;
|
|
|
|
try {
|
|
targetFile = await this.app.vault.create(fallbackName, noteContent);
|
|
new Notice(`Chat saved as note: ${fallbackName}`);
|
|
logWarn(
|
|
`[ChatPersistenceManager] Used minimal filename due to length constraints: ${fallbackName}`
|
|
);
|
|
} catch (fallbackError) {
|
|
if (this.isFileAlreadyExistsError(fallbackError)) {
|
|
const conflictFile = this.app.vault.getAbstractFileByPath(fallbackName);
|
|
if (conflictFile && conflictFile instanceof TFile) {
|
|
await this.app.vault.modify(conflictFile, noteContent);
|
|
targetFile = conflictFile;
|
|
new Notice("Existing chat note found - updating it now.");
|
|
logInfo(
|
|
`[ChatPersistenceManager] Resolved fallback save conflict by updating existing chat file: ${conflictFile.path}`
|
|
);
|
|
} else {
|
|
throw fallbackError;
|
|
}
|
|
} else {
|
|
throw fallbackError;
|
|
}
|
|
}
|
|
} else {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
this.generateTopicAsyncIfNeeded(messages, targetFile, existingTopic);
|
|
} catch (error) {
|
|
logError("[ChatPersistenceManager] Error saving chat:", error);
|
|
new Notice("Failed to save chat as note. Check console for details.");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load chat history from a markdown file
|
|
*/
|
|
async loadChat(file: TFile): Promise<ChatMessage[]> {
|
|
try {
|
|
const content = await this.app.vault.read(file);
|
|
const messages = this.parseChatContent(content);
|
|
logInfo(`[ChatPersistenceManager] Loaded ${messages.length} messages from ${file.path}`);
|
|
return messages;
|
|
} catch (error) {
|
|
logError("[ChatPersistenceManager] Error loading chat:", error);
|
|
new Notice("Failed to load chat history. Check console for details.");
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all chat history files from the vault
|
|
*/
|
|
async getChatHistoryFiles(): Promise<TFile[]> {
|
|
const settings = getSettings();
|
|
const folder = this.app.vault.getAbstractFileByPath(settings.defaultSaveFolder);
|
|
if (!(folder instanceof TFolder)) {
|
|
return [];
|
|
}
|
|
|
|
const files = this.app.vault.getMarkdownFiles();
|
|
const folderFiles = files.filter((file) => file.path.startsWith(folder.path));
|
|
|
|
// Get current project ID if in a project
|
|
const currentProject = getCurrentProject();
|
|
|
|
// Filter files based on project context
|
|
return folderFiles.filter((file) => {
|
|
if (currentProject) {
|
|
// In project mode, only show files for this project
|
|
return file.basename.startsWith(`${currentProject.id}__`);
|
|
} else {
|
|
// In non-project mode, only show files without project prefix
|
|
return !file.basename.includes("__") || !file.basename.split("__")[0];
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Format messages into markdown content
|
|
*/
|
|
private formatChatContent(messages: ChatMessage[]): string {
|
|
return messages
|
|
.map((message) => {
|
|
const timestamp = message.timestamp ? message.timestamp.display : "Unknown time";
|
|
let content = `**${message.sender}**: ${message.message}`;
|
|
|
|
// Include context information if present
|
|
if (message.context) {
|
|
const contextParts: string[] = [];
|
|
|
|
if (message.context.notes?.length) {
|
|
contextParts.push(
|
|
`Notes: ${message.context.notes.map((note) => note.path).join(", ")}`
|
|
);
|
|
}
|
|
|
|
if (message.context.urls?.length) {
|
|
contextParts.push(`URLs: ${message.context.urls.join(", ")}`);
|
|
}
|
|
|
|
if (message.context.webTabs?.length) {
|
|
contextParts.push(`Web Tabs: ${message.context.webTabs.map((tab) => tab.url).join(", ")}`);
|
|
}
|
|
|
|
if (message.context.tags?.length) {
|
|
contextParts.push(`Tags: ${message.context.tags.join(", ")}`);
|
|
}
|
|
|
|
if (message.context.folders?.length) {
|
|
contextParts.push(`Folders: ${message.context.folders.join(", ")}`);
|
|
}
|
|
|
|
if (contextParts.length > 0) {
|
|
content += `\n[Context: ${contextParts.join(" | ")}]`;
|
|
}
|
|
}
|
|
|
|
content += `\n[Timestamp: ${timestamp}]`;
|
|
return content;
|
|
})
|
|
.join("\n\n");
|
|
}
|
|
|
|
/**
|
|
* Parse markdown content back into messages
|
|
*/
|
|
private parseChatContent(content: string): ChatMessage[] {
|
|
const messages: ChatMessage[] = [];
|
|
|
|
// Extract the YAML frontmatter
|
|
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
let chatContent = content;
|
|
|
|
if (frontmatterMatch) {
|
|
chatContent = content.slice(frontmatterMatch[0].length).trim();
|
|
}
|
|
|
|
// Parse messages from the content
|
|
// Look for message pattern: **user**: or **ai**: followed by content
|
|
const messagePattern = /\*\*(user|ai)\*\*: ([\s\S]*?)(?=(?:\n\*\*(?:user|ai)\*\*: )|$)/g;
|
|
|
|
let match;
|
|
while ((match = messagePattern.exec(chatContent)) !== null) {
|
|
const sender = match[1] === "user" ? USER_SENDER : AI_SENDER;
|
|
const fullContent = match[2].trim();
|
|
|
|
// Split content into lines to extract timestamp, context, and message
|
|
const contentLines = fullContent.split("\n");
|
|
let messageText = fullContent;
|
|
let timestamp = "Unknown time";
|
|
let contextInfo: any = undefined;
|
|
|
|
// Check for context and timestamp lines
|
|
let endIndex = contentLines.length;
|
|
|
|
// Check if last line is a timestamp
|
|
if (contentLines[endIndex - 1]?.startsWith("[Timestamp: ")) {
|
|
const timestampMatch = contentLines[endIndex - 1].match(/\[Timestamp: (.*?)\]/);
|
|
if (timestampMatch) {
|
|
timestamp = timestampMatch[1];
|
|
endIndex--;
|
|
}
|
|
}
|
|
|
|
// Check if second-to-last line is context
|
|
if (endIndex > 0 && contentLines[endIndex - 1]?.startsWith("[Context: ")) {
|
|
const contextMatch = contentLines[endIndex - 1].match(/\[Context: (.*?)\]/);
|
|
if (contextMatch) {
|
|
const contextStr = contextMatch[1];
|
|
contextInfo = this.parseContextString(contextStr);
|
|
endIndex--;
|
|
}
|
|
}
|
|
|
|
// Message is everything before context and timestamp
|
|
messageText = contentLines.slice(0, endIndex).join("\n").trim();
|
|
|
|
// Parse the timestamp
|
|
let epoch: number | undefined;
|
|
if (timestamp !== "Unknown time") {
|
|
const date = new Date(timestamp);
|
|
if (!isNaN(date.getTime())) {
|
|
epoch = date.getTime();
|
|
}
|
|
}
|
|
|
|
messages.push({
|
|
message: messageText,
|
|
sender,
|
|
isVisible: true,
|
|
timestamp: epoch
|
|
? {
|
|
epoch,
|
|
display: timestamp,
|
|
fileName: "",
|
|
}
|
|
: null,
|
|
context: contextInfo,
|
|
});
|
|
}
|
|
|
|
return messages;
|
|
}
|
|
|
|
/**
|
|
* Parse context string back into context object
|
|
*/
|
|
private parseContextString(contextStr: string): any {
|
|
const context: any = {
|
|
notes: [],
|
|
urls: [],
|
|
tags: [],
|
|
folders: [],
|
|
webTabs: [],
|
|
};
|
|
|
|
// Split by | to get different context types
|
|
const parts = contextStr.split(" | ");
|
|
|
|
for (const part of parts) {
|
|
const trimmed = part.trim();
|
|
|
|
if (trimmed.startsWith("Notes: ")) {
|
|
const notesStr = trimmed.substring(7); // Remove "Notes: "
|
|
if (notesStr) {
|
|
// Parse note paths and resolve to TFile objects
|
|
context.notes = notesStr
|
|
.split(", ")
|
|
.map((pathStr) => {
|
|
const trimmedPath = pathStr.trim();
|
|
|
|
// Try to resolve by full path first (new format)
|
|
const file = this.app.vault.getAbstractFileByPath(trimmedPath);
|
|
if (file instanceof TFile) {
|
|
return file;
|
|
}
|
|
|
|
// Backward compatibility: If path not found, try basename resolution
|
|
const basename = trimmedPath.includes("/")
|
|
? trimmedPath.split("/").pop()!
|
|
: trimmedPath;
|
|
|
|
const matches = this.app.vault
|
|
.getMarkdownFiles()
|
|
.filter((f) => f.basename === basename);
|
|
|
|
if (matches.length === 1) {
|
|
logInfo(
|
|
`[ChatPersistenceManager] Resolved legacy basename "${basename}" to ${matches[0].path}`
|
|
);
|
|
return matches[0];
|
|
} else if (matches.length > 1) {
|
|
logWarn(
|
|
`[ChatPersistenceManager] Ambiguous basename "${basename}", skipping. Matches: ${matches.map((f) => f.path).join(", ")}`
|
|
);
|
|
} else {
|
|
logWarn(`[ChatPersistenceManager] Note not found: ${trimmedPath}`);
|
|
}
|
|
|
|
return null;
|
|
})
|
|
.filter((note): note is TFile => note !== null);
|
|
}
|
|
} else if (trimmed.startsWith("URLs: ")) {
|
|
const urlsStr = trimmed.substring(6); // Remove "URLs: "
|
|
if (urlsStr) {
|
|
context.urls = urlsStr.split(", ").map((url) => url.trim());
|
|
}
|
|
} else if (trimmed.startsWith("Web Tabs: ") || trimmed.startsWith("WebTabs: ")) {
|
|
const webTabsStr = trimmed.startsWith("Web Tabs: ")
|
|
? trimmed.substring(10) // Remove "Web Tabs: "
|
|
: trimmed.substring(9); // Remove "WebTabs: "
|
|
if (webTabsStr) {
|
|
context.webTabs = webTabsStr
|
|
.split(", ")
|
|
.map((url) => url.trim())
|
|
.filter((url) => url.length > 0)
|
|
.map((url) => ({ url }));
|
|
}
|
|
} else if (trimmed.startsWith("Tags: ")) {
|
|
const tagsStr = trimmed.substring(6); // Remove "Tags: "
|
|
if (tagsStr) {
|
|
context.tags = tagsStr.split(", ").map((tag) => tag.trim());
|
|
}
|
|
} else if (trimmed.startsWith("Folders: ")) {
|
|
const foldersStr = trimmed.substring(9); // Remove "Folders: "
|
|
if (foldersStr) {
|
|
context.folders = foldersStr.split(", ").map((folder) => folder.trim());
|
|
}
|
|
}
|
|
}
|
|
|
|
// Only return context if it has any content
|
|
if (
|
|
context.notes.length > 0 ||
|
|
context.urls.length > 0 ||
|
|
context.tags.length > 0 ||
|
|
context.folders.length > 0 ||
|
|
context.webTabs.length > 0
|
|
) {
|
|
return context;
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Find a file by its epoch in the frontmatter
|
|
*/
|
|
private async findFileByEpoch(epoch: number): Promise<TFile | null> {
|
|
const files = await this.getChatHistoryFiles();
|
|
|
|
for (const file of files) {
|
|
const frontmatter = this.app.metadataCache.getFileCache(file)?.frontmatter;
|
|
const frontmatterEpoch =
|
|
typeof frontmatter?.epoch === "number"
|
|
? frontmatter.epoch
|
|
: typeof frontmatter?.epoch === "string"
|
|
? Number(frontmatter.epoch)
|
|
: undefined;
|
|
if (
|
|
typeof frontmatterEpoch === "number" &&
|
|
!Number.isNaN(frontmatterEpoch) &&
|
|
frontmatterEpoch === epoch
|
|
) {
|
|
return file;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Generate AI topic for the conversation
|
|
*/
|
|
private async generateAITopic(messages: ChatMessage[]): Promise<string | undefined> {
|
|
if (!this.chainManager) {
|
|
return undefined;
|
|
}
|
|
|
|
try {
|
|
const chatModel = this.chainManager.chatModelManager.getChatModel();
|
|
if (!chatModel) {
|
|
return undefined;
|
|
}
|
|
|
|
// Constants for topic generation
|
|
const TOPIC_GENERATION_MESSAGE_LIMIT = 6;
|
|
const TOPIC_GENERATION_CHAR_LIMIT = 200;
|
|
|
|
// Get conversation content for topic generation - using reduce for efficiency
|
|
const conversationSummary = messages.reduce((acc, m, i) => {
|
|
if (i >= TOPIC_GENERATION_MESSAGE_LIMIT) return acc;
|
|
return (
|
|
acc +
|
|
(acc ? "\n" : "") +
|
|
`${m.sender}: ${m.message.slice(0, TOPIC_GENERATION_CHAR_LIMIT)}`
|
|
);
|
|
}, "");
|
|
|
|
const prompt = `Generate a concise title (max 5 words) for this conversation based on its content. Return only the title without any explanation or quotes.
|
|
|
|
Conversation:
|
|
${conversationSummary}`;
|
|
|
|
const response = await chatModel.invoke(prompt);
|
|
const responseContent =
|
|
typeof response === "string"
|
|
? response
|
|
: ((response as { content?: unknown; text?: unknown }).content ??
|
|
(response as { content?: unknown; text?: unknown }).text ??
|
|
response);
|
|
const topic = extractTextFromChunk(responseContent)
|
|
.trim()
|
|
.replace(/^["']|["']$/g, "") // Remove quotes if present
|
|
.replace(/[\\/:*?"<>|]/g, "") // Remove invalid filename characters
|
|
.slice(0, 50); // Limit length
|
|
|
|
return topic || undefined;
|
|
} catch (error) {
|
|
logError("[ChatPersistenceManager] Error generating AI topic:", error);
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate a file name for the chat.
|
|
* @param messages - The conversation messages used to derive the topic.
|
|
* @param firstMessageEpoch - Epoch timestamp of the first message in the chat.
|
|
* @param topic - Optional pre-computed topic to use for the filename.
|
|
*/
|
|
private generateFileName(
|
|
messages: ChatMessage[],
|
|
firstMessageEpoch: number,
|
|
topic?: string
|
|
): string {
|
|
const settings = getSettings();
|
|
const formattedDateTime = formatDateTime(new Date(firstMessageEpoch));
|
|
const timestampFileName = formattedDateTime.fileName;
|
|
|
|
// Use provided topic or fall back to first 10 words
|
|
let topicForFilename: string;
|
|
if (topic) {
|
|
topicForFilename = topic;
|
|
} else {
|
|
// Get the first user message
|
|
const firstUserMessage = messages.find((message) => message.sender === USER_SENDER);
|
|
|
|
// Get the first 10 words from the first user message and sanitize them
|
|
topicForFilename = firstUserMessage
|
|
? firstUserMessage.message
|
|
// Remove Obsidian wiki link brackets while preserving inner text: [[Title]] -> Title
|
|
.replace(/\[\[([^\]]+)\]\]/g, "$1")
|
|
// Remove any remaining square brackets or braces
|
|
.replace(/[{}[\]]/g, "")
|
|
// Now split to first 10 words
|
|
.split(/\s+/)
|
|
.slice(0, 10)
|
|
.join(" ")
|
|
// Remove invalid filename characters (including control chars)
|
|
// eslint-disable-next-line no-control-regex
|
|
.replace(/[\\/:*?"<>|\x00-\x1F]/g, "")
|
|
.trim() || "Untitled Chat"
|
|
: "Untitled Chat";
|
|
}
|
|
|
|
// Parse the custom format and replace variables
|
|
let customFileName = settings.defaultConversationNoteName || "{$date}_{$time}__{$topic}";
|
|
|
|
// Get the current project prefix if any
|
|
const currentProject = getCurrentProject();
|
|
const filePrefix = currentProject ? `${currentProject.id}__` : "";
|
|
|
|
// Calculate fixed components in bytes
|
|
const extensionBytes = getUtf8ByteLength(".md");
|
|
const filePrefixBytes = getUtf8ByteLength(filePrefix);
|
|
|
|
// Calculate the custom format overhead (everything except {$topic})
|
|
const formatOverhead = customFileName
|
|
.replace("{$topic}", "")
|
|
.replace("{$date}", timestampFileName.split("_")[0])
|
|
.replace("{$time}", timestampFileName.split("_")[1]);
|
|
const formatOverheadBytes = getUtf8ByteLength(formatOverhead);
|
|
|
|
// Calculate the maximum bytes available for the topic
|
|
const topicByteBudget = Math.max(
|
|
20, // Minimum 20 bytes for topic to ensure at least some meaningful text
|
|
SAFE_FILENAME_BYTE_LIMIT - extensionBytes - filePrefixBytes - formatOverheadBytes
|
|
);
|
|
|
|
// Replace spaces with underscores and truncate to byte limit
|
|
const topicWithUnderscores = topicForFilename.replace(/\s+/g, "_");
|
|
const truncatedTopic = truncateToByteLimit(topicWithUnderscores, topicByteBudget);
|
|
|
|
// Create the file name with the truncated topic
|
|
customFileName = customFileName
|
|
.replace("{$topic}", truncatedTopic)
|
|
.replace("{$date}", timestampFileName.split("_")[0])
|
|
.replace("{$time}", timestampFileName.split("_")[1]);
|
|
|
|
// Sanitize the final filename (replace any illegal chars with underscore)
|
|
// Also remove leftover square brackets which are illegal on some platforms
|
|
// eslint-disable-next-line no-control-regex
|
|
const sanitizedFileName = customFileName
|
|
.replace(/\[\[([^\]]+)\]\]/g, "$1")
|
|
.replace(/[{}[\]]/g, "_")
|
|
// eslint-disable-next-line no-control-regex
|
|
.replace(/[\\/:*?"<>|\x00-\x1F]/g, "_");
|
|
|
|
// Final safety check: ensure the complete basename fits within the limit
|
|
const baseNameWithPrefix = `${filePrefix}${sanitizedFileName}.md`;
|
|
if (getUtf8ByteLength(baseNameWithPrefix) > SAFE_FILENAME_BYTE_LIMIT) {
|
|
// If still too long, truncate the entire filename more aggressively
|
|
const availableForBasename = SAFE_FILENAME_BYTE_LIMIT - extensionBytes - filePrefixBytes;
|
|
const truncatedBasename = truncateToByteLimit(sanitizedFileName, availableForBasename);
|
|
return `${settings.defaultSaveFolder}/${filePrefix}${truncatedBasename}.md`;
|
|
}
|
|
|
|
return `${settings.defaultSaveFolder}/${baseNameWithPrefix}`;
|
|
}
|
|
|
|
/**
|
|
* Generate the full note content with frontmatter
|
|
*/
|
|
private generateNoteContent(
|
|
chatContent: string,
|
|
firstMessageEpoch: number,
|
|
modelKey: string,
|
|
topic?: string
|
|
): string {
|
|
const settings = getSettings();
|
|
const currentProject = getCurrentProject();
|
|
|
|
return `---
|
|
epoch: ${firstMessageEpoch}
|
|
modelKey: "${escapeYamlString(modelKey)}"
|
|
${topic ? `topic: "${topic}"` : ""}
|
|
${currentProject ? `projectId: ${currentProject.id}` : ""}
|
|
${currentProject ? `projectName: ${currentProject.name}` : ""}
|
|
tags:
|
|
- ${settings.defaultConversationTag}
|
|
---
|
|
|
|
${chatContent}`;
|
|
}
|
|
|
|
/**
|
|
* Trigger asynchronous topic generation and apply it to the saved note once available
|
|
*/
|
|
private generateTopicAsyncIfNeeded(
|
|
messages: ChatMessage[],
|
|
file: TFile | null,
|
|
existingTopic?: string
|
|
): void {
|
|
const settings = getSettings();
|
|
|
|
if (!settings.generateAIChatTitleOnSave || !file || existingTopic) {
|
|
return;
|
|
}
|
|
|
|
void (async () => {
|
|
try {
|
|
const topic = await this.generateAITopic(messages);
|
|
if (!topic) {
|
|
return;
|
|
}
|
|
await this.applyTopicToFrontmatter(file, topic);
|
|
} catch (error) {
|
|
logError("[ChatPersistenceManager] Error during async topic generation:", error);
|
|
}
|
|
})();
|
|
}
|
|
|
|
/**
|
|
* Apply the AI-generated topic to the note's YAML frontmatter
|
|
*/
|
|
private async applyTopicToFrontmatter(file: TFile, topic: string): Promise<void> {
|
|
try {
|
|
if (!this.app.fileManager?.processFrontMatter) {
|
|
return;
|
|
}
|
|
|
|
const sanitizedTopic = topic.trim();
|
|
|
|
await this.app.fileManager.processFrontMatter(file, (frontmatter) => {
|
|
if (frontmatter.topic === sanitizedTopic) {
|
|
return;
|
|
}
|
|
frontmatter.topic = sanitizedTopic;
|
|
});
|
|
|
|
logInfo(`[ChatPersistenceManager] Applied AI topic to chat file: ${file.path}`);
|
|
} catch (error) {
|
|
logError("[ChatPersistenceManager] Error applying AI topic to file:", error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Determine whether an error corresponds to an ENAMETOOLONG filesystem failure.
|
|
* @param error - The thrown error.
|
|
* @returns True when the error message indicates a name-length constraint violation.
|
|
*/
|
|
private isNameTooLongError(error: unknown): boolean {
|
|
if (!error) {
|
|
return false;
|
|
}
|
|
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
const normalized = message.toLowerCase();
|
|
return normalized.includes("enametoolong") || normalized.includes("name too long");
|
|
}
|
|
|
|
/**
|
|
* Determine if an error indicates an Obsidian file-exists conflict.
|
|
*/
|
|
private isFileAlreadyExistsError(error: unknown): boolean {
|
|
if (!error) {
|
|
return false;
|
|
}
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return message.toLowerCase().includes("already exists");
|
|
}
|
|
}
|