diff --git a/src/interface/obsidian/.gitignore b/src/interface/obsidian/.gitignore index 70ec899c..29097dbf 100644 --- a/src/interface/obsidian/.gitignore +++ b/src/interface/obsidian/.gitignore @@ -5,6 +5,12 @@ *.iml .idea +# Cursor +.cursor + +# Copilot +.github + # npm node_modules diff --git a/src/interface/obsidian/package.json b/src/interface/obsidian/package.json index 938985cf..2cf3089f 100644 --- a/src/interface/obsidian/package.json +++ b/src/interface/obsidian/package.json @@ -18,7 +18,7 @@ "second brain" ], "devDependencies": { - "@types/dompurify": "^3.0.5", + "@types/dompurify": "3.2.0", "@types/node": "^16.11.6", "@typescript-eslint/eslint-plugin": "7.13.1", "@typescript-eslint/parser": "7.13.1", @@ -29,6 +29,7 @@ "typescript": "4.7.4" }, "dependencies": { - "dompurify": "^3.1.4" + "diff": "^8.0.2", + "isomorphic-dompurify": "^2.25.0" } } diff --git a/src/interface/obsidian/src/chat_view.ts b/src/interface/obsidian/src/chat_view.ts index 8fa09a23..c69fc708 100644 --- a/src/interface/obsidian/src/chat_view.ts +++ b/src/interface/obsidian/src/chat_view.ts @@ -1,9 +1,10 @@ -import { ItemView, MarkdownRenderer, Scope, WorkspaceLeaf, request, requestUrl, setIcon, Platform } from 'obsidian'; -import * as DOMPurify from 'dompurify'; +import { ItemView, MarkdownRenderer, Scope, WorkspaceLeaf, request, requestUrl, setIcon, Platform, TFile } from 'obsidian'; +import * as DOMPurify from 'isomorphic-dompurify'; import { KhojSetting } from 'src/settings'; import { KhojPaneView } from 'src/pane_view'; import { KhojView, createCopyParentText, getLinkToEntry, pasteTextAtCursor } from 'src/utils'; -import { KhojSearchModal } from './search_modal'; +import { KhojSearchModal } from 'src/search_modal'; +import { FileInteractions, EditBlock } from 'src/interact_with_files'; export interface ChatJsonResult { image?: string; @@ -12,26 +13,24 @@ export interface ChatJsonResult { inferredQueries?: string[]; } -interface ChunkResult { - objects: string[]; - remainder: string; -} - interface MessageChunk { type: string; data: any; } interface ChatMessageState { - newResponseTextEl: HTMLElement | null; - newResponseEl: HTMLElement | null; - loadingEllipsis: HTMLElement | null; - references: any; - generatedAssets: string; + newResponseTextEl: HTMLDivElement | null; + newResponseEl: HTMLDivElement | null; + loadingEllipsis: HTMLDivElement | null; + references: { [key: string]: any }; rawResponse: string; rawQuery: string; isVoice: boolean; + generatedAssets: string; turnId: string; + editBlocks: EditBlock[]; + editRetryCount: number; + parentRetryCount?: number; } interface Location { @@ -53,6 +52,19 @@ interface RenderMessageOptions { isSystemMessage?: boolean; } +interface ChatMode { + value: string; + label: string; + iconName: string; + command: string; +} + +interface Agent { + name: string; + slug: string; + description: string; +} + export class KhojChatView extends KhojPaneView { result: string; setting: KhojSetting; @@ -62,18 +74,36 @@ export class KhojChatView extends KhojPaneView { userMessages: string[] = []; // Store user sent messages for input history cycling currentMessageIndex: number = -1; // Track current message index in userMessages array private currentUserInput: string = ""; // Stores the current user input that is being typed in chat - private startingMessage: string = "Message"; + private startingMessage: string = this.getLearningMoment(); chatMessageState: ChatMessageState; + private agents: Agent[] = []; + private currentAgent: string | null = null; + private fileAccessMode: 'none' | 'read' | 'write' = 'none'; // Track the current file access mode + // TODO: Only show modes available on server and to current agent + private chatModes: ChatMode[] = [ + { value: "default", label: "Default", iconName: "target", command: "/default" }, + { value: "general", label: "General", iconName: "message-circle", command: "/general" }, + { value: "notes", label: "Notes", iconName: "file-text", command: "/notes" }, + { value: "online", label: "Online", iconName: "globe", command: "/online" }, + { value: "code", label: "Code", iconName: "code", command: "/code" }, + { value: "image", label: "Image", iconName: "image", command: "/image" }, + { value: "research", label: "Research", iconName: "microscope", command: "/research" }, + { value: "operator", label: "Operator", iconName: "laptop", command: "/operator" } + ]; + private editRetryCount: number = 0; // Track number of retries for edit blocks + private fileInteractions: FileInteractions; + private modeDropdown: HTMLElement | null = null; + private selectedOptionIndex: number = -1; + private isStreaming: boolean = false; // Flag to track streaming state + + // Disabled retry logic for now. Can re-enable once: + // 1. Handle chat history clutter + // 2. Higher invalid edit blocks than tolerable + private maxEditRetries: number = 1; // Maximum retries for edit blocks constructor(leaf: WorkspaceLeaf, setting: KhojSetting) { super(leaf, setting); - - // Register chat view keybindings - this.scope = new Scope(this.app.scope); - this.scope.register(["Ctrl"], 'n', (_) => this.createNewConversation()); - this.scope.register(["Ctrl"], 'o', async (_) => await this.toggleChatSessions()); - this.scope.register(["Ctrl"], 'f', (_) => new KhojSearchModal(this.app, this.setting).open()); - this.scope.register(["Ctrl"], 'r', (_) => new KhojSearchModal(this.app, this.setting, true).open()); + this.fileInteractions = new FileInteractions(this.app); this.waitingForLocation = true; @@ -95,6 +125,13 @@ export class KhojChatView extends KhojPaneView { this.waitingForLocation = false; }); + // Register chat view keybindings + this.scope = new Scope(this.app.scope); + this.scope.register(["Ctrl", "Alt"], 'n', (_) => this.createNewConversation()); + this.scope.register(["Ctrl", "Alt"], 'o', async (_) => await this.toggleChatSessions()); + this.scope.register(["Ctrl", "Alt"], 'v', (_) => this.speechToText(new KeyboardEvent('keydown'))); + this.scope.register(["Ctrl"], 'f', (_) => new KhojSearchModal(this.app, this.setting).open()); + this.scope.register(["Ctrl"], 'r', (_) => { this.activateView(KhojView.SIMILAR); }); } getViewType(): string { @@ -110,31 +147,54 @@ export class KhojChatView extends KhojPaneView { } async chat(isVoice: boolean = false) { + // Cancel any pending edits before sending a new message + await this.cancelPendingEdits(); + // Get text in chat input element let input_el = this.contentEl.getElementsByClassName("khoj-chat-input")[0]; // Clear text after extracting message to send let user_message = input_el.value.trim(); + // Store the message in the array if it's not empty if (user_message) { + // Get the selected mode + const selectedMode = this.chatModes.find(mode => + this.contentEl.querySelector(`#khoj-mode-${mode.value}:checked`) + ); + + // Check if message starts with a mode command + const modeMatch = this.chatModes.find(mode => user_message.startsWith(mode.command)); + + let displayMessage = user_message; + let apiMessage = user_message; + + if (modeMatch) { + // If message starts with a mode command, replace it with the icon in display + // We'll use a generic marker since we can't display SVG icons in messages + displayMessage = user_message.replace(modeMatch.command, `[${modeMatch.label}]`); + } else if (selectedMode) { + // If no mode in message but mode selected, add the mode command for API + displayMessage = `[${selectedMode.label}] ${user_message}`; + apiMessage = `${selectedMode.command} ${user_message}`; + } + this.userMessages.push(user_message); // Update starting message after sending a new message - const modifierKey = Platform.isMacOS ? '⌘' : '^'; - this.startingMessage = `(${modifierKey}+↑/↓) for prev messages`; + this.startingMessage = this.getLearningMoment(); input_el.placeholder = this.startingMessage; - } - input_el.value = ""; - this.autoResize(); - // Get and render chat response to user message - await this.getChatResponse(user_message, isVoice); + // Clear input and resize + input_el.value = ""; + this.autoResize(); + + // Get and render chat response to user message + await this.getChatResponse(apiMessage, displayMessage, isVoice); + } } async onOpen() { let { contentEl } = this; - contentEl.addClass("khoj-chat"); - - super.onOpen(); // Construct Content Security Policy let defaultDomains = `'self' ${this.setting.khojUrl} https://*.obsidian.md https://app.khoj.dev https://assets.khoj.dev`; @@ -151,21 +211,92 @@ export class KhojChatView extends KhojPaneView { // CSP meta tag for the Khoj Chat modal // document.head.createEl("meta", { attr: { "http-equiv": "Content-Security-Policy", "content": `${csp}` } }); - // Create area for chat logs - let chatBodyEl = contentEl.createDiv({ attr: { id: "khoj-chat-body", class: "khoj-chat-body" } }); + // The parent class handles creating the header and attaching the click on the "New Chat" button + // We handle the rest of the interface here + // Call the parent class's onOpen method first + await super.onOpen(); + // Fetch available agents + await this.fetchAgents(); + + // Populate the agent selector in the header + const headerAgentSelect = this.contentEl.querySelector('#khoj-header-agent-select') as HTMLSelectElement; + if (headerAgentSelect && this.agents.length > 0) { + // Clear existing options + headerAgentSelect.innerHTML = ''; + + // Add default option + headerAgentSelect.createEl("option", { + text: "Default Agent", + value: "khoj" + }); + + // Add options for all other agents + this.agents.forEach(agent => { + if (agent.slug === 'khoj') return; // Skip the default agent + const option = headerAgentSelect.createEl("option", { + text: agent.name, + value: agent.slug + }); + if (agent.description) { + option.title = agent.description; + } + }); + + // Add change event listener + headerAgentSelect.addEventListener('change', (event) => { + const select = event.target as HTMLSelectElement; + this.currentAgent = select.value || null; + }); + } + + contentEl.addClass("khoj-chat"); + + // Create the chat body + let chatBodyEl = contentEl.createDiv({ attr: { id: "khoj-chat-body", class: "khoj-chat-body" } }); // Add chat input field let inputRow = contentEl.createDiv("khoj-input-row"); + let chatSessions = inputRow.createEl("button", { text: "Chat Sessions", attr: { class: "khoj-input-row-button clickable-icon", - title: "Show Conversations (^O)", + title: "Show Conversations (Ctrl+Alt+O)", }, }) chatSessions.addEventListener('click', async (_) => { await this.toggleChatSessions() }); setIcon(chatSessions, "history"); + // Add file access mode button + let fileAccessButton = inputRow.createEl("button", { + text: "File Access", + attr: { + class: "khoj-input-row-button clickable-icon", + title: "Toggle file access mode (No Access)", + }, + }); + setIcon(fileAccessButton, "file-x"); + fileAccessButton.addEventListener('click', () => { + // Cycle through modes: none -> read -> write -> none + switch (this.fileAccessMode) { + case 'none': + this.fileAccessMode = 'read'; + setIcon(fileAccessButton, "file-search"); + fileAccessButton.title = "Toggle file access mode (Read Only)"; + break; + case 'read': + this.fileAccessMode = 'write'; + setIcon(fileAccessButton, "file-edit"); + fileAccessButton.title = "Toggle file access mode (Read & Write)"; + break; + case 'write': + this.fileAccessMode = 'none'; + setIcon(fileAccessButton, "file-x"); + fileAccessButton.title = "Toggle file access mode (No Access)"; + break; + } + }); + let chatInput = inputRow.createEl("textarea", { attr: { id: "khoj-chat-input", @@ -188,7 +319,7 @@ export class KhojChatView extends KhojPaneView { attr: { id: "khoj-transcribe", class: "khoj-transcribe khoj-input-row-button clickable-icon ", - title: "Start Voice Chat (^S)", + title: "Start Voice Chat (Ctrl+Alt+V)", }, }) transcribe.addEventListener('mousedown', (event) => { this.startSpeechToText(event) }); @@ -215,6 +346,7 @@ export class KhojChatView extends KhojPaneView { let placeholderText: string = getChatHistorySucessfully ? this.startingMessage : "Configure Khoj to enable chat"; chatInput.placeholder = placeholderText; chatInput.disabled = !getChatHistorySucessfully; + this.autoResize(); // Scroll to bottom of chat messages and focus on chat input field, once messages rendered requestAnimationFrame(() => { @@ -333,7 +465,7 @@ export class KhojChatView extends KhojPaneView { referenceButton.tabIndex = 0; // Add event listener to toggle full reference on click - referenceButton.addEventListener('click', function () { + referenceButton.addEventListener('click', function() { if (this.classList.contains("collapsed")) { this.classList.remove("collapsed"); this.classList.add("expanded"); @@ -388,7 +520,7 @@ export class KhojChatView extends KhojPaneView { referenceButton.tabIndex = 0; // Add event listener to toggle full reference on click - referenceButton.addEventListener('click', function () { + referenceButton.addEventListener('click', function() { if (this.classList.contains("collapsed")) { this.classList.remove("collapsed"); this.classList.add("expanded"); @@ -440,7 +572,7 @@ export class KhojChatView extends KhojPaneView { source.buffer = audioBuffer; source.connect(context.destination); source.start(0); - source.onended = function () { + source.onended = function() { speechButton.removeChild(loader); speechButton.disabled = false; }; @@ -456,6 +588,15 @@ export class KhojChatView extends KhojPaneView { // Remove any text between [INST] and tags. These are spurious instructions for some AI chat model. message = message.replace(/\[INST\].+(<\/s>)?/g, ''); + // Render train of thought messages + const { content, header } = this.processTrainOfThought(message); + message = content; + + if (!raw) { + // Render text edit blocks + message = this.transformEditBlocks(message); + } + // Sanitize the markdown message message = DOMPurify.sanitize(message); @@ -463,7 +604,7 @@ export class KhojChatView extends KhojPaneView { let chatMessageBodyTextEl = this.contentEl.createDiv(); chatMessageBodyTextEl.innerHTML = this.markdownTextToSanitizedHtml(message, this); - // Add a copy button to each chat message, if it doesn't already exist + // Add action buttons to each chat message, if they don't already exist if (willReplace === true) { this.renderActionButtons(message, chatMessageBodyTextEl); } @@ -488,6 +629,34 @@ export class KhojChatView extends KhojPaneView { return DOMPurify.sanitize(virtualChatMessageBodyTextEl.innerHTML); } + private processTrainOfThought(message: string): { content: string, header: string } { + // The train of thought comes in as a markdown-formatted string. It starts with a heading delimited by two asterisks at the start and end and a colon, followed by the message. Example: **header**: status. This function will parse the message and render it as a div. + let extractedHeader = message.match(/\*\*(.*)\*\*/); + let header = extractedHeader ? extractedHeader[1] : ""; + let content = message; + + // Render screenshot image in screenshot action message + let jsonMessage = null; + try { + const jsonMatch = message.match( + /\{.*("action": "screenshot"|"type": "screenshot"|"image": "data:image\/.*").*\}/, + ); + if (jsonMatch) { + jsonMessage = JSON.parse(jsonMatch[0]); + const screenshotHtmlString = `State of environment`; + content = content.replace( + `:\n**Action**: ${jsonMatch[0]}`, + `\n\n- ${jsonMessage.text}\n${screenshotHtmlString}`, + ); + } + } catch (e) { + console.error("Failed to parse screenshot data", e); + } + + return { content, header }; + } + + renderMessageWithReferences( chatEl: Element, message: string, @@ -581,13 +750,18 @@ export class KhojChatView extends KhojPaneView { attr: { "data-meta": message_time, class: `khoj-chat-message ${sender}`, - ...(turnId && { "data-turnId": turnId }) + ...(turnId && { "data-turnid": turnId }) }, }) let chatMessageBodyEl = chatMessageEl.createDiv(); chatMessageBodyEl.addClasses(["khoj-chat-message-text", sender]); let chatMessageBodyTextEl = chatMessageBodyEl.createDiv(); + // Remove Obsidian specific instructions sent alongside user query in between tags + if (sender === "you") { + message = message.replace(/.*?<\/SYSTEM>/s, ''); + } + // Sanitize the markdown to render message = DOMPurify.sanitize(message); @@ -633,13 +807,23 @@ export class KhojChatView extends KhojPaneView { async renderIncrementalMessage(htmlElement: HTMLDivElement, additionalMessage: string) { this.chatMessageState.rawResponse += additionalMessage; - htmlElement.innerHTML = ""; + // Sanitize the markdown to render - this.chatMessageState.rawResponse = DOMPurify.sanitize(this.chatMessageState.rawResponse); - // @ts-ignore - htmlElement.innerHTML = this.markdownTextToSanitizedHtml(this.chatMessageState.rawResponse, this); + let sanitizedResponse = DOMPurify.sanitize(this.chatMessageState.rawResponse); + + // Apply transformations including partial edit block detection + const transformedResponse = this.transformEditBlocks(sanitizedResponse); + + // Create a temporary element to get the rendered HTML + const tempElement = document.createElement('div'); + tempElement.innerHTML = this.markdownTextToSanitizedHtml(transformedResponse, this); + + // Update the content in separate step for a smoother transition + htmlElement.innerHTML = tempElement.innerHTML; + // Render action buttons for the message this.renderActionButtons(this.chatMessageState.rawResponse, htmlElement); + // Scroll to bottom of modal, till the send message input box this.scrollChatToBottom(); } @@ -658,6 +842,49 @@ export class KhojChatView extends KhojPaneView { setIcon(pasteToFile, "clipboard-paste"); pasteToFile.addEventListener('click', (event) => { pasteTextAtCursor(createCopyParentText(message, 'clipboard-paste')(event)); }); + // Add edit button only for user messages + let editButton = null; + if (!isSystemMessage && chatMessageBodyTextEl.closest('.khoj-chat-message.you')) { + editButton = this.contentEl.createEl('button'); + editButton.classList.add("chat-action-button"); + editButton.title = "Edit Message"; + setIcon(editButton, "edit-3"); + editButton.addEventListener('click', async () => { + const messageEl = chatMessageBodyTextEl.closest('.khoj-chat-message'); + if (messageEl) { + // Get all messages up to this one + const allMessages = Array.from(this.contentEl.getElementsByClassName('khoj-chat-message')); + const currentIndex = allMessages.indexOf(messageEl as HTMLElement); + + // Store reference to messages that need to be deleted from backend + const messagesToDelete = allMessages.slice(currentIndex); + + // Get the message content without the emoji if it exists + let messageContent = message; + const emojiRegex = /^[^\p{L}\p{N}]+\s*/u; + messageContent = messageContent.replace(emojiRegex, ''); + + // Set the message in the input field + const chatInput = this.contentEl.querySelector('.khoj-chat-input') as HTMLTextAreaElement; + if (chatInput) { + chatInput.value = messageContent; + chatInput.focus(); + } + + // Remove all messages from UI immediately for better UX + for (let i = messagesToDelete.length - 1; i >= 0; i--) { + messagesToDelete[i].remove(); + } + + // Then delete messages from backend in background + (async () => { + for (const msgToDelete of messagesToDelete) { + await this.deleteMessage(msgToDelete as HTMLElement, true, false); + } + })(); + } + }); + } // Add delete button let deleteButton = null; @@ -677,24 +904,21 @@ export class KhojChatView extends KhojPaneView { }); } - // Only enable the speech feature if the user is subscribed - let speechButton = null; - + // Append buttons to parent element + chatMessageBodyTextEl.append(copyButton, pasteToFile); + if (editButton) { + chatMessageBodyTextEl.append(editButton); + } + if (deleteButton) { + chatMessageBodyTextEl.append(deleteButton); + } if (this.setting.userInfo?.is_active) { // Create a speech button icon to play the message out loud - speechButton = this.contentEl.createEl('button'); + let speechButton = this.contentEl.createEl('button'); speechButton.classList.add("chat-action-button", "speech-button"); speechButton.title = "Listen to Message"; setIcon(speechButton, "speech") speechButton.addEventListener('click', (event) => this.textToSpeech(message, event)); - } - - // Append buttons to parent element - chatMessageBodyTextEl.append(copyButton, pasteToFile); - if (deleteButton) { - chatMessageBodyTextEl.append(deleteButton); - } - if (speechButton) { chatMessageBodyTextEl.append(speechButton); } } @@ -706,20 +930,67 @@ export class KhojChatView extends KhojPaneView { return `${time_string}, ${date_string}`; } - createNewConversation() { + getLearningMoment(): string { + const modifierKey = Platform.isMacOS ? '⌘' : '^'; + const learningMoments = [ + "Type '/' to select response mode.", + ]; + if (this.userMessages.length > 0) { + learningMoments.push(`Load previous messages with ${modifierKey}+↑/↓`); + } + + // Return a random learning moment + return learningMoments[Math.floor(Math.random() * learningMoments.length)]; + } + + async createNewConversation(agentSlug?: string) { let chatBodyEl = this.contentEl.getElementsByClassName("khoj-chat-body")[0] as HTMLElement; chatBodyEl.innerHTML = ""; chatBodyEl.dataset.conversationId = ""; chatBodyEl.dataset.conversationTitle = ""; this.userMessages = []; - this.startingMessage = "Message"; + this.startingMessage = this.getLearningMoment(); // Update the placeholder of the chat input const chatInput = this.contentEl.querySelector('.khoj-chat-input') as HTMLTextAreaElement; if (chatInput) { chatInput.placeholder = this.startingMessage; } - this.renderMessage({chatBodyEl, message: "Hey 👋🏾, what's up?", sender: "khoj", isSystemMessage: true}); + + try { + // Create a new conversation with or without an agent + let endpoint = `${this.setting.khojUrl}/api/chat/sessions`; + if (agentSlug) { + endpoint += `?agent_slug=${encodeURIComponent(agentSlug)}`; + } + + const response = await fetch(endpoint, { + method: "POST", + headers: { + "Authorization": `Bearer ${this.setting.khojApiKey}`, + "Content-Type": "application/json" + }, + body: JSON.stringify({}) // Empty body as agent_slug is in the URL + }); + + if (response.ok) { + const sessionInfo = await response.json(); + chatBodyEl.dataset.conversationId = sessionInfo.conversation_id; + this.currentAgent = agentSlug || null; + + // Update agent selector to reflect current agent + const agentSelect = this.contentEl.querySelector('.khoj-agent-select') as HTMLSelectElement; + if (agentSelect) { + agentSelect.value = this.currentAgent || ''; + } + } else { + console.error("Failed to create session:", response.statusText); + } + } catch (error) { + console.error("Error creating session:", error); + } + + this.renderMessage({ chatBodyEl, message: "Hey, what's up?", sender: "khoj", isSystemMessage: true }); } async toggleChatSessions(forceShow: boolean = false): Promise { @@ -741,7 +1012,7 @@ export class KhojChatView extends KhojPaneView { newConversationButtonEl.addEventListener('click', (_) => this.createNewConversation()); setIcon(newConversationButtonEl, "plus"); newConversationButtonEl.innerHTML += "New"; - newConversationButtonEl.title = "New Conversation (^N)"; + newConversationButtonEl.title = "New Conversation (Ctrl+Alt+N)"; const existingConversationsEl = sidePanelEl.createDiv("existing-conversations"); const conversationListEl = existingConversationsEl.createDiv("conversation-list"); @@ -828,10 +1099,10 @@ export class KhojChatView extends KhojPaneView { let editConversationTitleInputEl = this.contentEl.createEl('input'); editConversationTitleInputEl.classList.add("conversation-title-input"); editConversationTitleInputEl.value = conversationTitle; - editConversationTitleInputEl.addEventListener('click', function (event) { + editConversationTitleInputEl.addEventListener('click', function(event) { event.stopPropagation(); }); - editConversationTitleInputEl.addEventListener('keydown', function (event) { + editConversationTitleInputEl.addEventListener('keydown', function(event) { if (event.key === "Enter") { event.preventDefault(); editConversationTitleSaveButtonEl.click(); @@ -918,6 +1189,8 @@ export class KhojChatView extends KhojPaneView { chatUrl += `&conversation_id=${chatBodyEl.dataset.conversationId}`; } + console.log("Fetching chat history from:", chatUrl); + try { let response = await fetch(chatUrl, { method: "GET", @@ -925,6 +1198,8 @@ export class KhojChatView extends KhojPaneView { }); let responseJson: any = await response.json(); + console.log("Chat history response:", responseJson); + chatBodyEl.dataset.conversationId = responseJson.conversation_id; if (responseJson.detail) { @@ -943,8 +1218,30 @@ export class KhojChatView extends KhojPaneView { chatBodyEl.dataset.conversationId = responseJson.response.conversation_id; chatBodyEl.dataset.conversationTitle = responseJson.response.slug || `New conversation 🌱`; + // Update current agent from conversation history + if (responseJson.response.agent?.slug) { + console.log("Found agent in conversation history:", responseJson.response.agent); + this.currentAgent = responseJson.response.agent.slug; + // Update the agent selector if it exists + const agentSelect = this.contentEl.querySelector('.khoj-agent-select') as HTMLSelectElement; + if (agentSelect && this.currentAgent) { + agentSelect.value = this.currentAgent; + console.log("Updated agent selector to:", this.currentAgent); + } + } + let chatLogs = responseJson.response?.conversation_id ? responseJson.response.chat ?? [] : responseJson.response; chatLogs.forEach((chatLog: any) => { + // Convert commands to emojis for user messages + if (chatLog.by === "you") { + chatLog.message = this.convertCommandsToEmojis(chatLog.message); + } + + // Transform khoj-edit blocks into accordions + if (chatLog.by === "khoj") { + chatLog.message = this.transformEditBlocks(chatLog.message); + } + this.renderMessageWithReferences( chatBodyEl, chatLog.message, @@ -967,10 +1264,7 @@ export class KhojChatView extends KhojPaneView { }); // Update starting message after loading history - const modifierKey: string = Platform.isMacOS ? '⌘' : '^'; - this.startingMessage = this.userMessages.length > 0 - ? `(${modifierKey}+↑/↓) for prev messages` - : "Message"; + this.startingMessage = this.getLearningMoment(); // Update the placeholder of the chat input const chatInput = this.contentEl.querySelector('.khoj-chat-input') as HTMLTextAreaElement; @@ -1007,33 +1301,80 @@ export class KhojChatView extends KhojPaneView { return { type: '', data: '' }; } - processMessageChunk(rawChunk: string): void { + async processMessageChunk(rawChunk: string): Promise { const chunk = this.convertMessageChunkToJson(rawChunk); - console.debug("Chunk:", chunk); if (!chunk || !chunk.type) return; - if (chunk.type === 'status') { - console.log(`status: ${chunk.data}`); + + if (chunk.type === 'start_llm_response') { + // Start of streaming - set flag and ensure UI is stable + this.isStreaming = true; + + // Disable input resizing during streaming + const chatInput = this.contentEl.getElementsByClassName("khoj-chat-input")[0]; + if (chatInput) { + chatInput.style.overflowY = 'hidden'; + } + } + else if (chunk.type === 'status') { const statusMessage = chunk.data; this.handleStreamResponse(this.chatMessageState.newResponseTextEl, statusMessage, this.chatMessageState.loadingEllipsis, false); - } else if (chunk.type === 'generated_assets') { + } + else if (chunk.type === 'generated_assets') { const generatedAssets = chunk.data; const imageData = this.handleImageResponse(generatedAssets, this.chatMessageState.rawResponse); this.chatMessageState.generatedAssets = imageData; this.handleStreamResponse(this.chatMessageState.newResponseTextEl, imageData, this.chatMessageState.loadingEllipsis, false); - } else if (chunk.type === 'start_llm_response') { - console.log("Started streaming", new Date()); - } else if (chunk.type === 'end_llm_response') { - console.log("Stopped streaming", new Date()); - } else if (chunk.type === 'end_response') { + } + else if (chunk.type === 'end_llm_response') { + // End of streaming - reset flag and restore normal UI behavior + this.isStreaming = false; + + // Re-enable input resizing after streaming + const chatInput = this.contentEl.getElementsByClassName("khoj-chat-input")[0]; + if (chatInput) { + // Call autoResize to restore proper sizing + this.autoResize(); + } + } + else if (chunk.type === 'end_response') { + // Ensure streaming flag is reset at the end of the response + this.isStreaming = false; + + // Re-enable input resizing after streaming + const chatInput = this.contentEl.getElementsByClassName("khoj-chat-input")[0]; + if (chatInput) { + // Call autoResize to restore proper sizing + this.autoResize(); + } + + // Check for edit blocks if in write mode + if (this.fileAccessMode === 'write') { + const editBlocks = this.parseEditBlocks(this.chatMessageState.rawResponse); + + // Check for errors and retry if needed + if (editBlocks.length > 0 && editBlocks[0].hasError && this.editRetryCount < this.maxEditRetries) { + await this.handleEditRetry(editBlocks[0]); + return; + } + + // Reset retry count on success + this.editRetryCount = 0; + + // Apply edits if there are any + if (editBlocks.length > 0) { + await this.applyEditBlocks(editBlocks); + } + } + // Automatically respond with voice if the subscribed user has sent voice message - if (this.chatMessageState.isVoice && this.setting.userInfo?.is_active) + if (this.chatMessageState.isVoice && this.setting.userInfo?.is_active && this.setting.autoVoiceResponse) this.textToSpeech(this.chatMessageState.rawResponse); // Append any references after all the data has been streamed this.finalizeChatBodyResponse(this.chatMessageState.references, this.chatMessageState.newResponseTextEl, this.chatMessageState.turnId); + // Reset state including retry counts const liveQuery = this.chatMessageState.rawQuery; - // Reset variables this.chatMessageState = { newResponseTextEl: null, newResponseEl: null, @@ -1044,16 +1385,19 @@ export class KhojChatView extends KhojPaneView { isVoice: false, generatedAssets: "", turnId: "", + editBlocks: [], + editRetryCount: 0, + parentRetryCount: 0 // Reset parent retry count }; - } else if (chunk.type === "references") { + } + else if (chunk.type === "references") { this.chatMessageState.references = { "notes": chunk.data.context, "online": chunk.data.onlineContext }; - } else if (chunk.type === 'message') { + } + else if (chunk.type === 'message') { const chunkData = chunk.data; if (typeof chunkData === 'object' && chunkData !== null) { - // If chunkData is already a JSON object this.handleJsonResponse(chunkData); } else if (typeof chunkData === 'string' && chunkData.trim()?.startsWith("{") && chunkData.trim()?.endsWith("}")) { - // Try process chunk data as if it is a JSON object try { const jsonData = JSON.parse(chunkData.trim()); this.handleJsonResponse(jsonData); @@ -1065,10 +1409,10 @@ export class KhojChatView extends KhojPaneView { this.chatMessageState.rawResponse += chunkData; this.handleStreamResponse(this.chatMessageState.newResponseTextEl, this.chatMessageState.rawResponse + this.chatMessageState.generatedAssets, this.chatMessageState.loadingEllipsis); } - } else if (chunk.type === "metadata") { + } + else if (chunk.type === "metadata") { const { turnId } = chunk.data; if (turnId) { - // Append turnId to chatMessageState this.chatMessageState.turnId = turnId; } } @@ -1124,33 +1468,67 @@ export class KhojChatView extends KhojPaneView { } } - async getChatResponse(query: string | undefined | null, isVoice: boolean = false): Promise { + async getChatResponse(query: string | undefined | null, displayQuery: string | undefined | null, isVoice: boolean = false, displayUserMessage: boolean = true): Promise { // Exit if query is empty if (!query || query === "") return; - // Render user query as chat message + // Get chat body element let chatBodyEl = this.contentEl.getElementsByClassName("khoj-chat-body")[0] as HTMLElement; - this.renderMessage({chatBodyEl, message: query, sender: "you"}); + + // Render user query as chat message with display version only if displayUserMessage is true + if (displayUserMessage) { + this.renderMessage({ chatBodyEl, message: displayQuery || query, sender: "you" }); + } let conversationId = chatBodyEl.dataset.conversationId; + if (!conversationId) { - let chatUrl = `${this.setting.khojUrl}/api/chat/sessions?client=obsidian`; - let response = await fetch(chatUrl, { - method: "POST", - headers: { "Authorization": `Bearer ${this.setting.khojApiKey}` }, - }); - let data = await response.json(); - conversationId = data.conversation_id; - chatBodyEl.dataset.conversationId = conversationId; + try { + const requestBody = { + ...(this.currentAgent && { agent_slug: this.currentAgent }) + }; + + const response = await fetch(`${this.setting.khojUrl}/api/chat/sessions`, { + method: "POST", + headers: { + "Authorization": `Bearer ${this.setting.khojApiKey}`, + "Content-Type": "application/json" + }, + body: JSON.stringify(requestBody) + }); + if (response.ok) { + const data = await response.json(); + conversationId = data.conversation_id; + chatBodyEl.dataset.conversationId = conversationId; + } else { + console.error("Failed to create session:", response.statusText); + return; + } + } catch (error) { + console.error("Error creating session:", error); + return; + } } + // Get open files content if we have access + const openFilesContent = await this.getOpenFilesContent(); + + // Extract mode command if present + const modeMatch = this.chatModes.find(mode => query.startsWith(mode.command)); + const modeCommand = modeMatch ? query.substring(0, modeMatch.command.length) : ''; + const queryWithoutMode = modeMatch ? query.substring(modeMatch.command.length).trim() : query; + + // Combine mode, query and files content + const finalQuery = modeCommand + (modeCommand ? ' ' : '') + queryWithoutMode + openFilesContent; + // Get chat response from Khoj backend const chatUrl = `${this.setting.khojUrl}/api/chat?client=obsidian`; const body = { - q: query, + q: finalQuery, n: this.setting.resultsCount, stream: true, - ...(!!conversationId && { conversation_id: conversationId }), + conversation_id: conversationId, + ...(this.currentAgent && { agent_slug: this.currentAgent }), ...(!!this.location && this.location.city && { city: this.location.city }), ...(!!this.location && this.location.region && { region: this.location.region }), ...(!!this.location && this.location.countryName && { country: this.location.countryName }), @@ -1177,6 +1555,8 @@ export class KhojChatView extends KhojPaneView { isVoice: isVoice, generatedAssets: "", turnId: "", + editBlocks: [], + editRetryCount: 0 }; let response = await fetch(chatUrl, { @@ -1323,11 +1703,11 @@ export class KhojChatView extends KhojPaneView { const recordingConfig = { mimeType: 'audio/webm' }; this.mediaRecorder = new MediaRecorder(stream, recordingConfig); - this.mediaRecorder.addEventListener("dataavailable", function (event) { + this.mediaRecorder.addEventListener("dataavailable", function(event) { if (event.data.size > 0) audioChunks.push(event.data); }); - this.mediaRecorder.addEventListener("stop", async function () { + this.mediaRecorder.addEventListener("stop", async function() { const audioBlob = new Blob(audioChunks, { type: 'audio/webm' }); await sendToServer(audioBlob); }); @@ -1366,7 +1746,56 @@ export class KhojChatView extends KhojPaneView { }; incrementalChat(event: KeyboardEvent) { - if (!event.shiftKey && event.key === 'Enter') { + // If dropdown is visible and Enter is pressed, select the current option + if (this.modeDropdown && this.modeDropdown.style.display !== "none" && event.key === "Enter") { + event.preventDefault(); + + const options = this.modeDropdown.querySelectorAll(".khoj-mode-dropdown-option"); + const visibleOptions = Array.from(options).filter(option => + option.style.display !== "none" + ); + + // If any option is selected, use that one + if (this.selectedOptionIndex >= 0 && this.selectedOptionIndex < visibleOptions.length) { + const selectedOption = visibleOptions[this.selectedOptionIndex]; + const index = parseInt(selectedOption.getAttribute("data-index") || "0"); + const chatInput = this.contentEl.getElementsByClassName("khoj-chat-input")[0]; + + chatInput.value = this.chatModes[index].command + " "; + chatInput.focus(); + this.currentUserInput = chatInput.value; + this.hideModeDropdown(); + } + // If no option is selected but there's exactly one visible option, use that + else if (visibleOptions.length === 1) { + const onlyOption = visibleOptions[0]; + const index = parseInt(onlyOption.getAttribute("data-index") || "0"); + const chatInput = this.contentEl.getElementsByClassName("khoj-chat-input")[0]; + + chatInput.value = this.chatModes[index].command + " "; + chatInput.focus(); + this.currentUserInput = chatInput.value; + this.hideModeDropdown(); + } + return; + } + + const chatInput = this.contentEl.getElementsByClassName("khoj-chat-input")[0]; + const trimmedValue = chatInput.value.trim(); + + // Check if value is empty or just a mode command + const isOnlyModeCommand = this.chatModes.some(mode => + trimmedValue === mode.command || trimmedValue === mode.command + " " + ); + + if (event.key === 'Enter' && !event.shiftKey) { + // If message is empty or just a mode command, don't send + if (!trimmedValue || isOnlyModeCommand) { + event.preventDefault(); + return; + } + + // Otherwise, send message as normal event.preventDefault(); this.chat(); } @@ -1376,19 +1805,53 @@ export class KhojChatView extends KhojPaneView { const chatInput = this.contentEl.getElementsByClassName("khoj-chat-input")[0]; chatInput.value = chatInput.value.trimStart(); this.currentMessageIndex = -1; - // store the current input + + // Store the current input this.currentUserInput = chatInput.value; + + // Check if input starts with "/" and show dropdown + if (chatInput.value.startsWith("/")) { + this.showModeDropdown(chatInput); + this.selectedOptionIndex = -1; // Reset selected index + } else if (this.modeDropdown) { + // Hide dropdown if input doesn't start with "/" + this.hideModeDropdown(); + } + this.autoResize(); } autoResize() { const chatInput = this.contentEl.getElementsByClassName("khoj-chat-input")[0]; - const scrollTop = chatInput.scrollTop; - chatInput.style.height = '0'; - const scrollHeight = chatInput.scrollHeight + 8; // +8 accounts for padding - chatInput.style.height = Math.min(scrollHeight, 200) + 'px'; - chatInput.scrollTop = scrollTop; - this.scrollChatToBottom(); + + // Skip resizing completely during active streaming to avoid UI jumps + if (this.isStreaming) { + return; + } + + // Reset height to auto to get the correct scrollHeight + chatInput.style.height = 'auto'; + + // Calculate new height based on content with a larger maximum height + const maxHeight = 400; + const newHeight = Math.min(chatInput.scrollHeight, maxHeight); + chatInput.style.height = newHeight + 'px'; + // Add overflow-y: auto only if content exceeds max height + if (chatInput.scrollHeight > maxHeight) { + chatInput.style.overflowY = 'auto'; + } else { + chatInput.style.overflowY = 'hidden'; + } + + // Update dropdown position if it exists and is visible + if (this.modeDropdown && this.modeDropdown.style.display !== "none") { + const inputRect = chatInput.getBoundingClientRect(); + const containerRect = this.contentEl.getBoundingClientRect(); + + this.modeDropdown.style.left = `${inputRect.left - containerRect.left}px`; + this.modeDropdown.style.top = `${inputRect.top - containerRect.top - 4}px`; // Position above with small gap + this.modeDropdown.style.width = `${inputRect.width}px`; + } } scrollChatToBottom() { @@ -1423,19 +1886,21 @@ export class KhojChatView extends KhojPaneView { handleStreamResponse(newResponseElement: HTMLElement | null, rawResponse: string, loadingEllipsis: HTMLElement | null, replace = true) { if (!newResponseElement) return; + // Remove loading ellipsis if it exists if (newResponseElement.getElementsByClassName("lds-ellipsis").length > 0 && loadingEllipsis) newResponseElement.removeChild(loadingEllipsis); - // Clear the response element if replace is true - if (replace) newResponseElement.innerHTML = ""; - // Append response to the response element - newResponseElement.appendChild(this.formatHTMLMessage(rawResponse, false, replace)); + // Always replace the content completely + newResponseElement.innerHTML = ""; + const messageEl = this.formatHTMLMessage(rawResponse, false, true); + messageEl.classList.add('khoj-message-new-content'); + newResponseElement.appendChild(messageEl); - // Append loading ellipsis if it exists - if (!replace && loadingEllipsis) newResponseElement.appendChild(loadingEllipsis); - // Scroll to bottom of chat view - this.scrollChatToBottom(); + // Remove the animation class after the animation completes + setTimeout(() => { + newResponseElement.classList.remove('khoj-message-new-content'); + }, 300); } handleImageResponse(imageJson: any, rawResponse: string) { @@ -1482,8 +1947,8 @@ export class KhojChatView extends KhojPaneView { } if (!!newResponseElement && turnId) { // Set the turnId for the new response and the previous user message - newResponseElement.parentElement?.setAttribute("data-turnId", turnId); - newResponseElement.parentElement?.previousElementSibling?.setAttribute("data-turnId", turnId); + newResponseElement.parentElement?.setAttribute("data-turnid", turnId); + newResponseElement.parentElement?.previousElementSibling?.setAttribute("data-turnid", turnId); } this.scrollChatToBottom(); let chatInput = this.contentEl.getElementsByClassName("khoj-chat-input")[0]; @@ -1513,7 +1978,7 @@ export class KhojChatView extends KhojPaneView { referenceExpandButton.classList.add("reference-expand-button"); referenceExpandButton.innerHTML = numReferences == 1 ? "1 reference" : `${numReferences} references`; - referenceExpandButton.addEventListener('click', function () { + referenceExpandButton.addEventListener('click', function() { if (referenceSection.classList.contains("collapsed")) { referenceSection.classList.remove("collapsed"); referenceSection.classList.add("expanded"); @@ -1533,69 +1998,543 @@ export class KhojChatView extends KhojPaneView { // function to loop through the user's past messages handleArrowKeys(event: KeyboardEvent) { - const chatInput = event.target as HTMLTextAreaElement; - const isModKey = Platform.isMacOS ? event.metaKey : event.ctrlKey; + const chatInput = this.contentEl.getElementsByClassName("khoj-chat-input")[0]; - if (isModKey && event.key === 'ArrowUp') { - event.preventDefault(); - if (this.currentMessageIndex < this.userMessages.length - 1) { - this.currentMessageIndex++; - chatInput.value = this.userMessages[this.userMessages.length - 1 - this.currentMessageIndex]; + // Handle dropdown navigation with arrow keys + if (this.modeDropdown && this.modeDropdown.style.display !== "none") { + const options = this.modeDropdown.querySelectorAll(".khoj-mode-dropdown-option"); + // Only consider visible options + const visibleOptions = Array.from(options).filter(option => + option.style.display !== "none" + ); + + if (visibleOptions.length === 0) { + this.hideModeDropdown(); + return; } - } else if (isModKey && event.key === 'ArrowDown') { - event.preventDefault(); - if (this.currentMessageIndex > 0) { - this.currentMessageIndex--; - chatInput.value = this.userMessages[this.userMessages.length - 1 - this.currentMessageIndex]; - } else if (this.currentMessageIndex === 0) { - this.currentMessageIndex = -1; - chatInput.value = this.currentUserInput; + + switch (event.key) { + case "ArrowDown": + event.preventDefault(); + if (this.selectedOptionIndex < 0) { + this.selectedOptionIndex = 0; + } else { + this.selectedOptionIndex = Math.min(this.selectedOptionIndex + 1, visibleOptions.length - 1); + } + this.highlightVisibleOption(visibleOptions); + break; + + case "ArrowUp": + event.preventDefault(); + if (this.selectedOptionIndex < 0) { + this.selectedOptionIndex = visibleOptions.length - 1; + } else { + this.selectedOptionIndex = Math.max(this.selectedOptionIndex - 1, 0); + } + this.highlightVisibleOption(visibleOptions); + break; + + case "Enter": + // We handle Enter in incrementalChat now + break; + + case "Escape": + event.preventDefault(); + this.hideModeDropdown(); + break; + } + + // Don't process arrow keys for history navigation if dropdown is open + if (event.key === "ArrowUp" || event.key === "ArrowDown") { + return; + } + } + + // Original arrow key handling for message history + // (Le code existant pour gérer les touches fléchées) + } + + /** + * Highlights the selected option among visible options + * @param {HTMLElement[]} visibleOptions - Array of visible dropdown options + */ + private highlightVisibleOption(visibleOptions: HTMLElement[]) { + const allOptions = this.modeDropdown?.querySelectorAll(".khoj-mode-dropdown-option"); + if (!allOptions) return; + + // Clear highlighting on all options first + allOptions.forEach(option => { + option.classList.remove("khoj-mode-dropdown-option-selected"); + }); + + // Add highlighting to the selected visible option + if (this.selectedOptionIndex >= 0 && this.selectedOptionIndex < visibleOptions.length) { + const selectedOption = visibleOptions[this.selectedOptionIndex]; + selectedOption.classList.add("khoj-mode-dropdown-option-selected"); + + // Scroll to selected option if needed + if (this.modeDropdown) { + const container = this.modeDropdown; + if (selectedOption.offsetTop < container.scrollTop) { + container.scrollTop = selectedOption.offsetTop; + } else if (selectedOption.offsetTop + selectedOption.offsetHeight > container.scrollTop + container.offsetHeight) { + container.scrollTop = selectedOption.offsetTop + selectedOption.offsetHeight - container.offsetHeight; + } } } } // Add this new method to handle message deletion - async deleteMessage(messageEl: HTMLElement) { - const chatBodyEl = this.contentEl.getElementsByClassName("khoj-chat-body")[0] as HTMLElement; - const conversationId = chatBodyEl.dataset.conversationId; + async deleteMessage(messageEl: HTMLElement, skipPaired: boolean = false, skipBackend: boolean = false) { + // Find parent message container + const messageContainer = messageEl.closest('.khoj-chat-message'); + if (!messageContainer) return; - // Get the turnId from the message's data-turn attribute - const turnId = messageEl.getAttribute("data-turnId"); - if (!turnId || !conversationId) return; + // Get paired message to delete if needed + let pairedMessageContainer: Element | null = null; + if (!skipPaired) { + const messages = Array.from(document.getElementsByClassName('khoj-chat-message')); + const currentIndex = messages.indexOf(messageContainer as HTMLElement); + // If we're deleting a user message, also delete the subsequent khoj message (if any) + if (messageContainer.classList.contains('you') && currentIndex < messages.length - 1) { + pairedMessageContainer = messages[currentIndex + 1]; + } + // If we're deleting a khoj message, also delete the preceding user message (if any) + else if (messageContainer.classList.contains('khoj') && currentIndex > 0) { + pairedMessageContainer = messages[currentIndex - 1]; + } + } + + // Add animation class + messageContainer.classList.add('deleting'); + if (pairedMessageContainer) { + pairedMessageContainer.classList.add('deleting'); + } + + // Wait for animation to complete + setTimeout(async () => { + // Get turn ID for message + const turnId = messageContainer.getAttribute('data-turnid'); + + // Remove message(s) from DOM + messageContainer.remove(); + if (pairedMessageContainer) { + pairedMessageContainer.remove(); + } + + // Only delete in backend if not skipped + if (!skipBackend && turnId) { + const chatBodyEl = this.contentEl.getElementsByClassName("khoj-chat-body")[0] as HTMLElement; + const conversationId = chatBodyEl.dataset.conversationId; + + if (!conversationId) return; + + try { + // Delete from backend + const response = await fetch(`${this.setting.khojUrl}/api/chat/conversation/message`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.setting.khojApiKey}` + }, + body: JSON.stringify({ + conversation_id: conversationId, + turn_id: turnId + }), + }); + + if (!response.ok) { + console.error('Failed to delete message from backend:', await response.text()); + this.flashStatusInChatInput("Failed to delete message"); + } + } catch (error) { + console.error('Error deleting message:', error); + this.flashStatusInChatInput("Error deleting message"); + } + } + }, 300); // Matches the animation duration + } + + async fetchAgents() { try { - const response = await fetch(`${this.setting.khojUrl}/api/chat/conversation/message`, { - method: "DELETE", + const response = await fetch(`${this.setting.khojUrl}/api/agents`, { headers: { - "Content-Type": "application/json", "Authorization": `Bearer ${this.setting.khojApiKey}` - }, - body: JSON.stringify({ - conversation_id: conversationId, - turn_id: turnId - }) + } }); if (response.ok) { - // Remove both the user message and Khoj response (the conversation turn) - const isKhojMessage = messageEl.classList.contains("khoj"); - const messages = Array.from(chatBodyEl.getElementsByClassName("khoj-chat-message")); - const messageIndex = messages.indexOf(messageEl); - - if (isKhojMessage && messageIndex > 0) { - // If it is a Khoj message, remove the previous user message too - messages[messageIndex - 1].remove(); - } else if (!isKhojMessage && messageIndex < messages.length - 1) { - // If it is a user message, remove the next Khoj message too - messages[messageIndex + 1].remove(); - } - messageEl.remove(); + this.agents = await response.json(); } else { - this.flashStatusInChatInput("Failed to delete message"); + console.error("Failed to fetch agents:", response.statusText); } } catch (error) { - console.error("Error deleting message:", error); - this.flashStatusInChatInput("Error deleting message"); + console.error("Error fetching agents:", error); + } + } + + // Add this new method after the class declaration + private async getOpenFilesContent(): Promise { + return this.fileInteractions.getOpenFilesContent(this.fileAccessMode); + } + + private parseEditBlocks(message: string): EditBlock[] { + return this.fileInteractions.parseEditBlocks(message); + } + + private async applyEditBlocks(editBlocks: EditBlock[]) { + // Check for parsing errors first + if (editBlocks.length === 0) return; + + // Apply edits using the FileInteractions class + const { editResults, fileBackups } = await this.fileInteractions.applyEditBlocks( + editBlocks, + (blockToRetry) => { + if (this.editRetryCount < this.maxEditRetries) { + this.handleEditRetry(blockToRetry); + } + } + ); + + // Add confirmation buttons to the last message + const chatBodyEl = this.contentEl.getElementsByClassName("khoj-chat-body")[0]; + const lastMessage = chatBodyEl.lastElementChild; + + if (lastMessage) { + const buttonsContainer = lastMessage.createDiv({ cls: "edit-confirmation-buttons" }); + + // Create a dedicated container for status summary to ensure proper separation + const statusContainer = buttonsContainer.createDiv({ cls: "edit-status-container" }); + + // Add status summary as a separate element with its own styling + const statusSummary = statusContainer.createDiv({ cls: "edit-status-summary" }); + const successCount = editResults.filter(r => r.success).length; + + // Add appropriate status class based on success/failure + if (successCount === editResults.length) { + statusSummary.innerHTML = `All edits applied successfully`; + statusSummary.addClass("success"); + } else if (successCount === 0) { + statusSummary.innerHTML = `No edits were applied`; + statusSummary.addClass("error"); + } else { + // This should not happen with atomic approach, but keeping for safety + statusSummary.innerHTML = `${successCount}/${editResults.length} edits applied successfully`; + statusSummary.addClass(successCount > 0 ? "success" : "error"); + } + + if (editResults.some(r => !r.success)) { + const errorDetails = editResults + .filter(r => !r.success) + .map(r => { + // Check if the error is due to atomic validation failure + if (r.error && r.error.includes('Other edits in the group failed')) { + return `• ${r.block.note}: Not applied due to atomic validation failure`; + } + return `• ${r.block.note}: ${r.error}`; + }) + .join('\n'); + statusSummary.title = `Failed edits:\n${errorDetails}`; + } + + // Create Apply button + const applyButton = buttonsContainer.createEl("button", { + text: "Apply", + cls: ["edit-confirm-button", "edit-apply-button"], + }); + + // Create Cancel button + const cancelButton = buttonsContainer.createEl("button", { + text: "Cancel", + cls: ["edit-confirm-button", "edit-cancel-button"], + }); + + // Scroll to the buttons + buttonsContainer.scrollIntoView({ behavior: "smooth", block: "center" }); + + // Handle Apply/Cancel clicks + this.setupConfirmationButtons(applyButton, cancelButton, fileBackups, lastMessage, buttonsContainer); + } + } + + // Helper method to setup confirmation buttons + private setupConfirmationButtons( + applyButton: HTMLButtonElement, + cancelButton: HTMLButtonElement, + fileBackups: Map, + lastMessage: Element, + buttonsContainer: HTMLDivElement + ) { + applyButton.addEventListener("click", async () => { + try { + for (const [filePath, originalContent] of fileBackups) { + const file = this.app.vault.getAbstractFileByPath(filePath); + if (file && file instanceof TFile) { + const currentContent = await this.app.vault.read(file); + let finalContent = currentContent; + + // Remove diff markers + finalContent = finalContent.replace(/~~[^~]*~~\n?(?=~~)/g, ''); + finalContent = finalContent.replace(/~~[^~]*~~/g, ''); + finalContent = finalContent.replace(/==/g, ''); + + await this.app.vault.modify(file, finalContent); + } + } + + const successMessage = lastMessage.createDiv({ cls: "edit-status-message success" }); + successMessage.textContent = "Changes applied successfully"; + setTimeout(() => successMessage.remove(), 3000); + } catch (error) { + console.error("Error applying changes:", error); + const errorMessage = lastMessage.createDiv({ cls: "edit-status-message error" }); + errorMessage.textContent = "Error applying changes"; + setTimeout(() => errorMessage.remove(), 3000); + } finally { + buttonsContainer.remove(); + } + }); + + cancelButton.addEventListener("click", async () => { + try { + for (const [filePath, originalContent] of fileBackups) { + const file = this.app.vault.getAbstractFileByPath(filePath); + if (file && file instanceof TFile) { + await this.app.vault.modify(file, originalContent); + } + } + const successMessage = lastMessage.createDiv({ cls: "edit-status-message success" }); + successMessage.textContent = "Changes cancelled successfully"; + setTimeout(() => successMessage.remove(), 3000); + } catch (error) { + console.error("Error cancelling changes:", error); + const errorMessage = lastMessage.createDiv({ cls: "edit-status-message error" }); + errorMessage.textContent = "Error cancelling changes"; + setTimeout(() => errorMessage.remove(), 3000); + } finally { + buttonsContainer.remove(); + } + }); + } + + private convertCommandsToEmojis(message: string): string { + const modeMatch = this.chatModes.find(mode => message.startsWith(mode.command)); + if (modeMatch) { + return message.replace(modeMatch.command, `[${modeMatch.label}]`); + } + return message; + } + + // Make the method public and async + public async applyPendingEdits() { + const chatBodyEl = this.contentEl.getElementsByClassName("khoj-chat-body")[0]; + const lastMessage = chatBodyEl.lastElementChild; + if (!lastMessage) return; + + // Check for edit confirmation buttons + const buttonsContainer = lastMessage.querySelector(".edit-confirmation-buttons"); + if (!buttonsContainer) return; + + // Find and click the apply button if it exists + const applyButton = buttonsContainer.querySelector(".edit-apply-button"); + if (applyButton instanceof HTMLElement) { + applyButton.click(); + } + } + + // Make the method public + public async cancelPendingEdits() { + const chatBodyEl = this.contentEl.getElementsByClassName("khoj-chat-body")[0]; + const lastMessage = chatBodyEl.lastElementChild; + if (!lastMessage) return; + + // Check for edit confirmation buttons + const buttonsContainer = lastMessage.querySelector(".edit-confirmation-buttons"); + if (!buttonsContainer) return; + + // Find and click the cancel button if it exists + const cancelButton = buttonsContainer.querySelector(".edit-cancel-button"); + if (cancelButton instanceof HTMLElement) { + cancelButton.click(); + } + } + + // Add this new method to handle khoj-edit block transformation + private transformEditBlocks(message: string): string { + return this.fileInteractions.transformEditBlocks(message); + } + + private async handleEditRetry(errorBlock: EditBlock) { + this.editRetryCount++; + + // Format error message based on the error type + let errorDetails = ''; + if (errorBlock.error?.type === 'missing_field') { + errorDetails = `Missing required fields: ${errorBlock.error.message}\n`; + errorDetails += `Please include all required fields:\n${errorBlock.error.details}\n`; + } else if (errorBlock.error?.type === 'invalid_format') { + errorDetails = `The JSON format is invalid: ${errorBlock.error.message}\n`; + errorDetails += "Please check the syntax and provide a valid JSON edit block.\n"; + } else { + errorDetails = `Error: ${errorBlock.error?.message || 'Unknown error'}\n`; + if (errorBlock.error?.details) { + errorDetails += `Details: ${errorBlock.error.details}\n`; + } + } + + // Create retry badge - keep it simple and focused only on retry functionality + const chatBodyEl = this.contentEl.getElementsByClassName("khoj-chat-body")[0]; + + // Create a container for the retry badge to ensure proper separation + const retryContainer = chatBodyEl.createDiv({ cls: "khoj-retry-container" }); + + // Create the retry badge inside the container + const retryBadge = retryContainer.createDiv({ cls: "khoj-retry-badge" }); + + // Add retry icon + const retryIcon = retryBadge.createSpan({ cls: "retry-icon" }); + setIcon(retryIcon, "refresh-cw"); + + // Add main text - keep it focused only on retry action + retryBadge.createSpan({ text: "Try again to apply changes" }); + + // Add retry count + retryBadge.createSpan({ + cls: "retry-count", + text: `Attempt ${this.editRetryCount}/3` + }); + + // Add error details as a tooltip + retryBadge.setAttribute('aria-label', errorDetails); + // @ts-ignore - Obsidian's custom tooltip API + const hoverEditor = this.app.plugins.plugins["obsidian-hover-editor"]; + if (hoverEditor) { + new hoverEditor.HoverPopover(this.app, retryBadge, errorDetails); + } + + // Scroll to the badge + retryBadge.scrollIntoView({ behavior: "smooth", block: "center" }); + + // Create a retry prompt for the LLM + const retryPrompt = `/general I noticed some issues with the edit block. Please fix the following and provide a corrected version (retry ${this.editRetryCount}/3):\n\n${errorDetails}\n\nPlease provide a new edit block that fixes these issues. Make sure to follow the exact format required.`; + + // Send retry request without displaying the user message + await this.getChatResponse(retryPrompt, "", false, false); + } + + private showModeDropdown(inputEl: HTMLTextAreaElement) { + // Create dropdown if it doesn't exist + if (!this.modeDropdown) { + this.modeDropdown = this.contentEl.createDiv({ + cls: "khoj-mode-dropdown" + }); + + // Position the dropdown ABOVE the input (instead of below) + const inputRect = inputEl.getBoundingClientRect(); + const containerRect = this.contentEl.getBoundingClientRect(); + + this.modeDropdown.style.position = "absolute"; + this.modeDropdown.style.left = `${inputRect.left - containerRect.left}px`; + this.modeDropdown.style.top = `${inputRect.top - containerRect.top - 4}px`; // Position above with small gap + this.modeDropdown.style.width = `${inputRect.width}px`; + this.modeDropdown.style.zIndex = "1000"; + this.modeDropdown.style.transform = "translateY(-100%)"; // Move up by 100% of its height + + // Add mode options to dropdown - we'll create all initially then show/hide based on filter + this.chatModes.forEach((mode, index) => { + const option = this.modeDropdown!.createDiv({ + cls: "khoj-mode-dropdown-option", + attr: { + "data-index": index.toString(), + "data-command": mode.command, + } + }); + + // Create emoji span and label span for better styling control + const emojiSpan = option.createSpan({ + cls: "khoj-mode-dropdown-emoji" + }); + setIcon(emojiSpan, mode.iconName); + + option.createSpan({ + cls: "khoj-mode-dropdown-label", + text: ` ${mode.label} ` + }); + + option.createSpan({ + cls: "khoj-mode-dropdown-command", + text: `(${mode.command})` + }); + + // Select mode on click + option.addEventListener("click", () => { + inputEl.value = mode.command + " "; + inputEl.focus(); + this.currentUserInput = inputEl.value; + this.hideModeDropdown(); + }); + }); + + // Close dropdown when clicking outside + document.addEventListener("click", (e) => { + if (this.modeDropdown && !this.modeDropdown.contains(e.target as Node) && + e.target !== inputEl) { + this.hideModeDropdown(); + } + }); + } else { + // Show the dropdown if it already exists + this.modeDropdown.style.display = "block"; + } + + // Filter options based on current input + this.filterDropdownOptions(inputEl.value); + } + + /** + * Filters dropdown options based on user input + * @param {string} inputValue - Current input value from textarea + */ + private filterDropdownOptions(inputValue: string) { + if (!this.modeDropdown) return; + + // Get all options + const options = this.modeDropdown.querySelectorAll(".khoj-mode-dropdown-option"); + let visibleOptionsCount = 0; + + options.forEach((option) => { + const command = option.getAttribute("data-command") || ""; + + // If input starts with "/" and has additional characters, filter based on that + if (inputValue.startsWith("/") && inputValue.length > 1) { + // Check if command starts with the input value + if (command.toLowerCase().startsWith(inputValue.toLowerCase())) { + option.style.display = "flex"; + visibleOptionsCount++; + } else { + option.style.display = "none"; + } + } else { + // Show all options if just "/" is typed + option.style.display = "flex"; + visibleOptionsCount++; + } + }); + + // Hide dropdown if no matches + if (visibleOptionsCount === 0) { + this.hideModeDropdown(); + } + + // Reset selection since we filtered the options + this.selectedOptionIndex = -1; + } + + private hideModeDropdown() { + if (this.modeDropdown) { + this.modeDropdown.style.display = "none"; + this.selectedOptionIndex = -1; } } } diff --git a/src/interface/obsidian/src/interact_with_files.ts b/src/interface/obsidian/src/interact_with_files.ts new file mode 100644 index 00000000..ebd2b7a7 --- /dev/null +++ b/src/interface/obsidian/src/interact_with_files.ts @@ -0,0 +1,976 @@ +import { App, TFile } from 'obsidian'; +import { diffWords } from 'diff'; + +/** + * Interface representing a block of edit instructions for modifying files + */ +export interface EditBlock { + file: string; // Target file name [Required] + find: string; // Content to find in file [Required] + replace: string; // Content to replace with in file [Required] + note?: string; // Brief explanation of edit [Optional] + hasError?: boolean; // Flag to indicate parsing error [Optional] + error?: { + type: 'missing_field' | 'invalid_format' | 'preprocessing' | 'unknown'; + message: string; + details?: string; + }; +} + +/** + * Interface representing the result of parsing a Khoj edit block + */ +export interface ParsedEditBlock { + editData: EditBlock | null; + cleanContent: string; + inProgress?: boolean; + error?: { + type: 'missing_field' | 'invalid_format' | 'preprocessing' | 'unknown'; + message: string; + details?: string; + }; +} + +/** + * Interface representing the result of processing an edit block + */ +interface ProcessedEditResult { + preview: string; // The content with diff markers to be inserted + newContent: string; // The new content after replacement + error?: string; // Error message if processing failed (e.g., 'find' text not found) +} + +/** + * Interface representing the result of detecting a partial edit block + */ +interface PartialEditBlockResult { + content: string; + isComplete: boolean; +} + +/** + * Class that handles file operations for the Khoj plugin + */ +export class FileInteractions { + private app: App; + private readonly EDIT_BLOCK_START = ''; + private readonly EDIT_BLOCK_END = ''; + + /** + * Constructor for FileInteractions + * + * @param app - The Obsidian App instance + */ + constructor(app: App) { + this.app = app; + } + + /** + * Gets the content of all open files + * + * @param fileAccessMode - The access mode ('none', 'read', or 'write') + * @returns A string containing the content of all open files + */ + public async getOpenFilesContent(fileAccessMode: 'none' | 'read' | 'write'): Promise { + // Only proceed if we have read or write access + if (fileAccessMode === 'none') return ''; + + // Get all open markdown leaves + const leaves = this.app.workspace.getLeavesOfType('markdown'); + if (leaves.length === 0) return ''; + + // Instructions in write access mode + let editInstructions: string = ''; + if (fileAccessMode === 'write') { + editInstructions = ` +If the user requests, you can suggest edits to files provided in the WORKING_FILE_SET provided below. +Once you understand the user request you MUST: + +1. Decide if you need to propose *SEARCH/REPLACE* edits to any files that haven't been added to the chat. + +If you need to propose edits to existing files not already added to the chat, you *MUST* tell the user their full path names and ask them to *add the files to the chat*. +End your reply and wait for their approval. +You can keep asking if you then decide you need to edit more files. + +2. Think step-by-step and explain the needed changes in a few short sentences before each EDIT block. + +3. Describe each change with a *SEARCH/REPLACE block* like the examples below. + +All changes to files must use this *SEARCH/REPLACE block* format. +ONLY EVER RETURN EDIT TEXT IN A *SEARCH/REPLACE BLOCK*! + +# *SEARCH/REPLACE block* Rules: + +Every *SEARCH/REPLACE block* must use this format: +1. The opening fence: \`${this.EDIT_BLOCK_START}\` +2. The *FULL* file path alone on a line, verbatim. No bold asterisks, no quotes around it, no escaping of characters, etc. +3. The start of search block: <<<<<<< SEARCH +4. A contiguous chunk of lines to search for in the source file +5. The dividing line: ======= +6. The lines to replace into the source file +7. The end of the replace block: >>>>>>> REPLACE +8. The closing fence: \`${this.EDIT_BLOCK_END}\` + +Use the *FULL* file path, as shown to you by the user. + +Every *SEARCH* section must *EXACTLY MATCH* the existing file content, character for character, including all comments, docstrings, etc. +If the file contains code or other data wrapped/escaped in json/xml/quotes or other containers, you need to propose edits to the literal contents of the file, including the container markup. + +*SEARCH/REPLACE* blocks will *only* replace the first match occurrence. +Including multiple unique *SEARCH/REPLACE* blocks if needed. +Include enough lines in each SEARCH section to uniquely match each set of lines that need to change. + +Keep *SEARCH/REPLACE* blocks concise. +Break large *SEARCH/REPLACE* blocks into a series of smaller blocks that each change a small portion of the file. +Include just the changing lines, and a few surrounding lines if needed for uniqueness. +Do not include long runs of unchanging lines in *SEARCH/REPLACE* blocks. + +Only create *SEARCH/REPLACE* blocks for files that the user has added to the chat! + +To move text within a file, use 2 *SEARCH/REPLACE* blocks: 1 to delete it from its current location, 1 to insert it in the new location. + +Pay attention to which filenames the user wants you to edit, especially if they are asking you to create a new file. + +If you want to put text in a new file, use a *SEARCH/REPLACE block* with: +- A new file path, including dir name if needed +- An empty \`SEARCH\` section +- The new file's contents in the \`REPLACE\` section + +ONLY EVER RETURN EDIT TEXT IN A *SEARCH/REPLACE BLOCK*! + + +Suggest edits using targeted modifications. Use multiple edit blocks to make precise changes rather than rewriting entire sections. + +Here's how to use the *SEARCH/REPLACE block* format: + +${this.EDIT_BLOCK_START} +target-filename +<<<<<<< SEARCH +from flask import Flask +======= +import math +from flask import Flask +>>>>>>> REPLACE +${this.EDIT_BLOCK_END} + +⚠️ Important: +- The target-filename parameter is required and must match an open file name. +- The XML format ${this.EDIT_BLOCK_START}...${this.EDIT_BLOCK_END} ensures reliable parsing. +- The SEARCH block content must completely and uniquely identify the section to edit. +- The REPLACE block content will replace the first SEARCH block match in the specified \`target-filename\`. + +📝 Example note: + +\`\`\` +--- +date: 2024-01-20 +tags: meeting, planning +status: active +--- +# file: Meeting Notes.md + +Action items from today: +- Review Q4 metrics +- Schedule follow-up with marketing team about new campaign launch +- Update project timeline and milestones for Q1 2024 + +Next steps: +- Send summary to team +- Book conference room for next week +\`\`\` + +Examples of targeted edits: + +1. Using just a few words to identify long text (notice how "campaign launch" is kept in content): + +Add deadline and specificity to the marketing team follow-up. +${this.EDIT_BLOCK_START} +Meeting Notes.md +<<<<<<< SEARCH +- Schedule follow-up with marketing team about new campaign launch +======= +- Schedule follow-up with marketing team by Wednesday to discuss Q1 campaign launch +>>>>>>> REPLACE +${this.EDIT_BLOCK_END} + +2. Multiple targeted changes with escaped characters: + +Add HIGH priority flag with code reference to Q4 metrics review" +${this.EDIT_BLOCK_START} +Meeting Notes.md +<<<<<<< SEARCH +- Review Q4 metrics +======= +- [HIGH] Review Q4 metrics (see "metrics.ts" and \`calculateQ4Metrics()\`) +>>>>>>> REPLACE + + +Add resource allocation to project timeline task +<${this.EDIT_BLOCK_START}> +Meeting Notes.md +<<<<<<< SEARCH +- Update project timeline and milestones for Q1 2024 +======= +- Update project timeline and add resource allocation for Q1 2024 +>>>>>>> REPLACE + + +3. Adding new content between sections: +Insert a new section for discussion points after the action items section: +${this.EDIT_BLOCK_START} +Meeting Notes.md +<<<<<<< SEARCH +Action items from today: +- Review Q4 metrics +- Schedule follow-up with marketing team about new campaign launch +- Update project timeline and milestones for Q1 2024 +======= +Action items from today: +- Review Q4 metrics +- Schedule follow-up +- Update timeline + +Discussion Points: +- Budget review +- Team feedback +>>>>>>> REPLACE + + +4. Completely replacing a file content (preserving frontmatter): +Replace entire file content while keeping frontmatter metadata +${this.EDIT_BLOCK_START} +Meeting Notes.md +<<<<<<< SEARCH +======= +# Project Overview + +## Goals +- Increase user engagement by 25% +- Launch mobile app by Q3 +- Expand to 3 new markets + +## Timeline +1. Q1: Research & Planning +2. Q2: Development +3. Q3: Testing & Launch +4. Q4: Market Expansion +>>>>>>> REPLACE +${this.EDIT_BLOCK_END} + +- The SEARCH block must uniquely identify the section to edit +- The REPLACE block content replaces the first SEARCH block match in the specified file +- Frontmatter metadata (between --- markers at top of file) cannot be modified +- Use an empty SEARCH block to replace entire file content with content in REPLACE block (while preserving frontmatter). +- Remember to escape special characters: use \" for quotes in content +- Each edit block must be fenced in ${this.EDIT_BLOCK_START}...${this.EDIT_BLOCK_END} XML tags + + +`; + } + + let openFilesContent = ` +For context, the user is currently working on the following files: + + +`; + + for (const leaf of leaves) { + const view = leaf.view as any; + const file = view?.file; + if (!file || file.extension !== 'md') continue; + + // Read file content + let fileContent: string; + try { + fileContent = await this.app.vault.read(file); + } catch (error) { + console.error(`Error reading file ${file.path}:`, error); + continue; + } + + openFilesContent += `\n# file: ${file.basename}.md\n\n${fileContent}\n\n\n`; + } + + openFilesContent += "\n"; + + // Collate open files content with instructions + let context: string; + if (fileAccessMode === 'write') { + context = `\n\n${editInstructions + openFilesContent}`; + } else { + context = `\n\n${openFilesContent}`; + } + + return context; + } + + /** + * Calculates the Levenshtein distance between two strings + * + * @param a - First string + * @param b - Second string + * @returns The Levenshtein distance + */ + public levenshteinDistance(a: string, b: string): number { + if (a.length === 0) return b.length; + if (b.length === 0) return a.length; + + const matrix = Array(b.length + 1).fill(null).map(() => + Array(a.length + 1).fill(null) + ); + + for (let i = 0; i <= a.length; i++) matrix[0][i] = i; + for (let j = 0; j <= b.length; j++) matrix[j][0] = j; + + for (let j = 1; j <= b.length; j++) { + for (let i = 1; i <= a.length; i++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + matrix[j][i] = Math.min( + matrix[j][i - 1] + 1, // deletion + matrix[j - 1][i] + 1, // insertion + matrix[j - 1][i - 1] + cost // substitution + ); + } + } + + return matrix[b.length][a.length]; + } + + /** + * Finds the best matching file from a list of files based on the target name + * + * @param targetName - The name to match against + * @param files - Array of TFile objects to search + * @returns The best matching TFile or null if no matches found + */ + public findBestMatchingFile(targetName: string, files: TFile[]): TFile | null { + const MAX_DISTANCE = 10; + let bestMatch: { file: TFile, distance: number } | null = null; + + for (const file of files) { + // Try both with and without extension + const distanceWithExt = this.levenshteinDistance(targetName.toLowerCase(), file.name.toLowerCase()); + const distanceWithoutExt = this.levenshteinDistance(targetName.toLowerCase(), file.basename.toLowerCase()); + const distance = Math.min(distanceWithExt, distanceWithoutExt); + + if (distance <= MAX_DISTANCE && (!bestMatch || distance < bestMatch.distance)) { + bestMatch = { file, distance }; + } + } + + return bestMatch?.file || null; + } + + /** + * Parses a text edit block from the content string + * Enhanced to handle incomplete blocks and extract partial information + * + * @param content - The content from which to parse edit blocks + * @param isComplete - Whether the edit block is complete (has closing tag) + * @returns Object with the parsed edit data and cleaned content + */ + public parseEditBlock(content: string, isComplete: boolean = true): ParsedEditBlock { + let cleanContent = ''; + try { + // Normalize line breaks and clean control characters, but preserve empty lines + cleanContent = content + .replace(/\r\n/g, '\n') + .replace(/\r/g, '\n') + .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '') + .trim(); + + // For incomplete blocks, try to extract partial information + if (!isComplete) { + // Initialize with basic structure + const partialData: EditBlock = { + file: "", + find: "", + replace: "" + }; + + // Try to extract file name from the first line + const firstLineMatch = cleanContent.match(/^([^\n]+)/); + if (firstLineMatch) { + partialData.file = firstLineMatch[1].trim(); + } + + // Try to extract search content field + const searchStartMatch = cleanContent.match(/<<<<<<< SEARCH\n([\s\S]*)/); + if (searchStartMatch) { + partialData.find = searchStartMatch[1]; + if (!partialData.file) { // If file not on first line, try line before SEARCH + const lines = cleanContent.split('\n'); + const searchIndex = lines.findIndex(line => line.startsWith("<<<<<<< SEARCH")); + if (searchIndex > 0) { + partialData.file = lines[searchIndex - 1].trim(); + } + } + } + + return { + editData: partialData, + cleanContent, + inProgress: true + }; + } + + // Try parse SEARCH/REPLACE format for complete edit blocks + // Regex: file_path\n<<<<<<< SEARCH\nsearch_content\n=======\nreplacement_content\n>>>>>>> REPLACE + const newFormatRegex = /^([^\n]+)\n<<<<<<< SEARCH\n([\s\S]*?)\n=======\n([\s\S]*?)\n>>>>>>> REPLACE\s*$/; + const newFormatMatch = newFormatRegex.exec(cleanContent); + + let editData: EditBlock | null = null; + if (newFormatMatch) { + editData = { + file: newFormatMatch[1].trim(), + find: newFormatMatch[2], + replace: newFormatMatch[3], + }; + } + + // Validate required fields + let error: { type: 'missing_field' | 'invalid_format' | 'preprocessing' | 'unknown', message: string, details?: string } | null = null; + if (!editData) { + error = { + type: 'invalid_format', + message: 'Invalid edit block format', + details: 'The edit block does not match the expected format' + }; + } + else if (!editData.file) { + error = { + type: 'missing_field', + message: 'Missing "file" field in edit block', + details: 'The "file" field is required and should contain the target file name' + }; + } + else if (editData.find === undefined || editData.find === null) { + error = { + type: 'missing_field', + message: 'Missing "find" field markers', + details: 'The "find" field is required and should contain the content to find in the file' + }; + } + else if (!editData.replace) { + error = { + type: 'missing_field', + message: 'Missing "replace" field in edit block', + details: 'The "replace" field is required and should contain the replacement text' + }; + } + + return error + ? { editData, cleanContent, error } + : { editData, cleanContent }; + } catch (error) { + console.error("Error parsing edit block:", error); + console.error("Content causing error:", content); + return { + editData: null, + cleanContent, + error: { + type: 'invalid_format', + message: 'Invalid JSON format in edit block', + details: error.message + } + }; + } + } + + /** + * Parses all edit blocks from a message + * + * @param message - The message containing text edit blocks in XML format + * @returns Array of EditBlock objects + */ + public parseEditBlocks(message: string): EditBlock[] { + const editBlocks: EditBlock[] = []; + // Set regex to match edit blocks based on Edit Start, End XML tags in the message + const editBlockRegex = new RegExp(`${this.EDIT_BLOCK_START}([\\s\\S]*?)${this.EDIT_BLOCK_END}`, 'g'); + + let match; + while ((match = editBlockRegex.exec(message)) !== null) { + const { editData, cleanContent, error } = this.parseEditBlock(match[1]); + + if (error) { + console.error("Failed to parse edit block:", error); + console.debug("Content causing error:", match[1]); + editBlocks.push({ + file: "unknown", // Fallback value when editData is null + find: "", + replace: `Error: ${error.message}\nOriginal content:\n${match[1]}`, + note: "Error parsing edit block", + hasError: true, + error: error + }); + continue; + } + + if (!editData) { + console.error("No edit data parsed"); + continue; + } + + editBlocks.push({ + note: "Suggested edit", + file: editData.file, + find: editData.find, + replace: editData.replace, + hasError: !!error, + error: error || undefined + }); + } + + return editBlocks; + } + + /** + * Creates a preview with differences highlighted + * + * @param originalText - The original text + * @param newText - The modified text + * @returns A string with differences highlighted + */ + public createPreviewWithDiff(originalText: string, newText: string): string { + // Define unique tokens to temporarily replace existing formatting markers + const HIGHLIGHT_TOKEN = "___KHOJ_HIGHLIGHT_MARKER___"; + const STRIKETHROUGH_TOKEN = "___KHOJ_STRIKETHROUGH_MARKER___"; + + // Function to preserve existing formatting markers by replacing them with tokens + const preserveFormatting = (text: string): string => { + // Replace existing highlight markers with non-greedy pattern + let processed = text.replace(/==(.*?)==/g, `${HIGHLIGHT_TOKEN}$1${HIGHLIGHT_TOKEN}`); + // Replace existing strikethrough markers with non-greedy pattern + processed = processed.replace(/~~(.*?)~~/g, `${STRIKETHROUGH_TOKEN}$1${STRIKETHROUGH_TOKEN}`); + return processed; + }; + + // Function to restore original formatting markers + const restoreFormatting = (text: string): string => { + // Restore highlight markers + let processed = text.replace(new RegExp(HIGHLIGHT_TOKEN + "(.*?)" + HIGHLIGHT_TOKEN, "g"), "==$1=="); + // Restore strikethrough markers + processed = processed.replace(new RegExp(STRIKETHROUGH_TOKEN + "(.*?)" + STRIKETHROUGH_TOKEN, "g"), "~~$1~~"); + return processed; + }; + + // Preserve existing formatting in both texts + const preservedOriginal = preserveFormatting(originalText); + const preservedNew = preserveFormatting(newText); + + // Find common prefix and suffix + let prefixLength = 0; + const minLength = Math.min(preservedOriginal.length, preservedNew.length); + while (prefixLength < minLength && preservedOriginal[prefixLength] === preservedNew[prefixLength]) { + prefixLength++; + } + + let suffixLength = 0; + while ( + suffixLength < minLength - prefixLength && + preservedOriginal[preservedOriginal.length - 1 - suffixLength] === preservedNew[preservedNew.length - 1 - suffixLength] + ) { + suffixLength++; + } + + // Extract the parts + const commonPrefix = preservedOriginal.slice(0, prefixLength); + const commonSuffix = preservedOriginal.slice(preservedOriginal.length - suffixLength); + const originalDiff = preservedOriginal.slice(prefixLength, preservedOriginal.length - suffixLength); + const newDiff = preservedNew.slice(prefixLength, preservedNew.length - suffixLength); + + // Format the differences + const formatLines = (text: string, marker: string): string => { + if (!text) return ''; + return text.split('\n') + .map(line => { + line = line.trim(); + if (!line) { + return marker === '==' ? '' : '~~'; + } + return `${marker}${line}${marker}`; + }) + .filter(line => line !== '~~') + .join('\n'); + }; + + // Create the diff preview with preserved formatting tokens + const diffPreview = commonPrefix + + (originalDiff ? formatLines(originalDiff, '~~') : '') + + (newDiff ? formatLines(newDiff, '==') : '') + + commonSuffix; + + // Restore original formatting markers in the final result + return restoreFormatting(diffPreview); + } + + private textNormalize(text: string): string { + // Normalize whitespace and special characters + return text + .replace(/\u00A0/g, " ") // Replace non-breaking spaces with regular spaces + .replace(/[\u2002\u2003\u2007\u2008\u2009\u200A\u205F\u3000]/g, " ") // Replace various other Unicode spaces with regular spaces + .replace(/[\u2013\u2014]/g, '-') // Replace en-dash and em-dash with hyphen + .replace(/[\u2018\u2019]/g, "'") // Replace smart quotes with regular quotes + .replace(/[\u201C\u201D]/g, '"') // Replace smart double quotes with regular quotes + .replace(/\u2026/g, '...') // Replace ellipsis with three dots + .normalize('NFC') // Normalize to NFC form + } + + private processSingleEdit( + rawFindText: string, + replaceText: string, + rawCurrentFileContent: string, + frontmatterEndIndex: number + ): ProcessedEditResult { + let startIndex = -1; + let endIndex = -1; + // Normalize special characters before searching + const findText = this.textNormalize(rawFindText); + const currentFileContent = this.textNormalize(rawCurrentFileContent); + + if (findText === "") { + // Empty search means replace entire content after frontmatter + startIndex = frontmatterEndIndex; + endIndex = currentFileContent.length; + } else { + startIndex = currentFileContent.indexOf(findText, frontmatterEndIndex); + if (startIndex !== -1) { + endIndex = startIndex + findText.length; + } + } + + if (startIndex === -1 || endIndex === -1 || startIndex > endIndex) { + return { + preview: "", + newContent: currentFileContent, + error: `No matching text found in file.` + + }; + } + + const textToReplace = currentFileContent.substring(startIndex, endIndex); + const newText = replaceText.trim(); + const preview = this.createPreviewWithDiff(textToReplace, newText); + const newContent = + currentFileContent.substring(0, startIndex) + + preview + + currentFileContent.substring(endIndex); + + return { preview, newContent }; + } + + /** + * Applies edit blocks to modify files + * + * @param editBlocks - Array of EditBlock objects to apply + * @param addConfirmationButtons - Optional callback to add confirmation UI elements + * @returns Object containing edit results and file backups + */ + public async applyEditBlocks( + editBlocks: EditBlock[], + onRetryNeeded?: (blockToRetry: EditBlock) => void + ): Promise<{ + editResults: { block: EditBlock, success: boolean, error?: string }[], + fileBackups: Map, + }> { + // Check for parsing errors first + if (editBlocks.length === 0) { + return { editResults: [], fileBackups: new Map() }; + } + + // Store original content for each file in case we need to cancel + const fileBackups = new Map(); + + // Track current content for each file as we apply edits + const currentFileContents = new Map(); + + // Get all open markdown files + const files = this.app.workspace.getLeavesOfType('markdown') + .map(leaf => (leaf.view as any)?.file) + .filter(file => file && file.extension === 'md'); + + // Track success/failure for each edit + const editResults: { block: EditBlock, success: boolean, error?: string }[] = []; + const blocksNeedingRetry: EditBlock[] = []; + + // PHASE 1: Validation - Check all blocks before applying any changes + const validationResults: { block: EditBlock, valid: boolean, error?: string, targetFile?: TFile }[] = []; + + for (const block of editBlocks) { + try { + // Skip blocks with parsing errors + if (block.hasError) { + validationResults.push({ + block, + valid: false, + error: block.error?.message || 'Parsing error' + }); + continue; + } + + const targetFile = this.findBestMatchingFile(block.file, files); + if (!targetFile) { + validationResults.push({ + block, + valid: false, + error: `No matching file found for "${block.file}"` + }); + continue; + } + + // Read the file content if not already backed up + if (!fileBackups.has(targetFile.path)) { + const content = await this.app.vault.read(targetFile); + fileBackups.set(targetFile.path, content); + currentFileContents.set(targetFile.path, content); + } + + // Use current content (which may have been modified by previous validations) + const currentContent = currentFileContents.get(targetFile.path)!; + + // Find frontmatter boundaries + const frontmatterMatch = currentContent.match(/^---\n[\s\S]*?\n---\n/); + const frontmatterEndIndex = frontmatterMatch ? frontmatterMatch[0].length : 0; + + const processedEdit = this.processSingleEdit(block.find, block.replace, currentContent, frontmatterEndIndex); + + if (processedEdit.error) { + validationResults.push({ block, valid: false, error: processedEdit.error }); + continue; + } + + // Validation passed + validationResults.push({ block, valid: true, targetFile }); + + // Update the current content for this file for subsequent validations + currentFileContents.set(targetFile.path, processedEdit.newContent); + + } catch (error) { + validationResults.push({ block, valid: false, error: error.message }); + } + } + + // Check if all blocks are valid + const allValid = validationResults.every(result => result.valid); + + // If any block is invalid, don't apply any changes + if (!allValid) { + // Reset current file contents + currentFileContents.clear(); + + // Add all invalid blocks to retry list + for (const result of validationResults) { + if (!result.valid) { + blocksNeedingRetry.push({ + ...result.block, + hasError: true, + error: { + type: 'invalid_format', + message: result.error || 'Validation failed', + details: result.error || 'Could not validate edit' + } + }); + + editResults.push({ + block: result.block, + success: false, + error: result.error || 'Validation failed' + }); + } else { + // Even valid blocks are considered failed in atomic mode if any block fails + editResults.push({ + block: result.block, + success: false, + error: 'Other edits in the group failed validation' + }); + } + } + + // Trigger retry for the first failed block + if (blocksNeedingRetry.length > 0 && onRetryNeeded) { + onRetryNeeded(blocksNeedingRetry[0]); + } + + return { editResults, fileBackups }; + } + + // PHASE 2: Application - Apply all changes since all blocks are valid + try { + // Reset current file contents to original state + currentFileContents.clear(); + for (const [path, content] of fileBackups.entries()) { + currentFileContents.set(path, content); + } + + // Apply all edits + for (const result of validationResults) { + const block = result.block; + const targetFile = result.targetFile!; + + // Use current content (which may have been modified by previous edits) + const content = currentFileContents.get(targetFile.path)!; + + // Find frontmatter boundaries + const frontmatterMatch = content.match(/^---\n[\s\S]*?\n---\n/); + const frontmatterEndIndex = frontmatterMatch ? frontmatterMatch[0].length : 0; + + // Find the text to replace in original content + // Recalculate based on the current state of the file content for this phase + const processedEdit = this.processSingleEdit(block.find, block.replace, content, frontmatterEndIndex); + + if (processedEdit.error) { + throw new Error(`Failed to re-locate edit markers for file "${targetFile.basename}" during application. Content may have shifted.`); + } + + // Apply the changes to the file + await this.app.vault.modify(targetFile, processedEdit.newContent); + + // Update the current content for this file for subsequent edits + currentFileContents.set(targetFile.path, processedEdit.newContent); + + editResults.push({ block: {...block, replace: processedEdit.preview}, success: true }); + } + } catch (error) { + console.error(`Error applying edits:`, error); + + // Restore all files to their original state + for (const [path, content] of fileBackups.entries()) { + const file = this.app.vault.getAbstractFileByPath(path); + if (file && file instanceof TFile) { + await this.app.vault.modify(file, content); + } + } + + // Mark all blocks as failed + for (const block of editBlocks) { + blocksNeedingRetry.push(block); + editResults.push({ + block, + success: false, + error: `Failed to apply edits: ${error.message}` + }); + } + + // Trigger retry for the first block + if (blocksNeedingRetry.length > 0 && onRetryNeeded) { + onRetryNeeded(blocksNeedingRetry[0]); + } + } + + return { editResults, fileBackups }; + } + + /** + * Transforms content edit blocks in a message to HTML for display + * + * @param message - The message containing content edit blocks in XML format + * @returns The transformed message with HTML for edit blocks + */ + public transformEditBlocks(message: string): string { + // Get all open markdown files + const files = this.app.workspace.getLeavesOfType('markdown') + .map(leaf => (leaf.view as any)?.file) + .filter(file => file && file.extension === 'md'); + + // Detect all edit blocks, including partial ones + const partialBlocks = this.detectPartialEditBlocks(message); + + // Process each detected block + let transformedMessage = message; + for (const block of partialBlocks) { + const isComplete = block.isComplete; + const content = block.content; + + // Parse the block content + const { editData, cleanContent, error, inProgress } = this.parseEditBlock(content, isComplete); + + // Escape content for HTML display + const diff = diffWords(editData?.find || '', editData?.replace || ''); + let diffContent = diff.map(part => { + if (part.added) { + return `${part.value}`; + } else if (part.removed) { + return `${part.value}`; + } else { + return `${part.value}`; + } + } + ).join('').trim(); + + let htmlRender = ''; + if (error || !editData) { + // Error block + console.error("Error parsing khoj-edit block:", error); + console.error("Content causing error:", content); + + const errorTitle = `Error: ${error?.message || 'Parse error'}`; + const errorDetails = `Failed to parse edit block. Please check the JSON format and ensure all required fields are present.`; + + htmlRender = `
+ ${errorTitle} +
+

${errorDetails}

+
${diffContent}
+
+
`; + } else if (inProgress) { + // In-progress block + htmlRender = `
+ 📄 ${editData.file} In Progress +
+
${diffContent}
+
+
`; + } else { + // Success block + // Find the actual file that will be modified + const targetFile = this.findBestMatchingFile(editData.file, files); + const displayFileName = targetFile ? `${targetFile.basename}.${targetFile.extension}` : editData.file; + + htmlRender = `
+ 📄 ${displayFileName} +
+
${diffContent}
+
+
`; + } + + // Replace the block in the message + if (isComplete) { + transformedMessage = transformedMessage.replace(`${this.EDIT_BLOCK_START}${content}${this.EDIT_BLOCK_END}`, htmlRender); + } else { + transformedMessage = transformedMessage.replace(`${this.EDIT_BLOCK_START}${content}`, htmlRender); + } + } + + return transformedMessage; + } + + /** + * Detects partial edit blocks in a message + * This allows for early detection of edit blocks before they are complete + * + * @param message - The message to search for partial edit blocks + * @returns An array of detected blocks with their content and completion status + */ + public detectPartialEditBlocks(message: string): PartialEditBlockResult[] { + const results: PartialEditBlockResult[] = []; + + // This regex captures both complete and incomplete edit blocks + // It looks for EDIT_BLOCK_START tag followed by any content, and then either EDIT_BLOCK_END or the end of the string + const regex = new RegExp(`${this.EDIT_BLOCK_START}([\\s\\S]*?)(?:${this.EDIT_BLOCK_END}|$)`, 'g'); + + let match; + while ((match = regex.exec(message)) !== null) { + const content = match[1]; + const isComplete = match[0].endsWith(this.EDIT_BLOCK_END); + + results.push({ + content, + isComplete + }); + } + + return results; + } +} diff --git a/src/interface/obsidian/src/main.ts b/src/interface/obsidian/src/main.ts index a75d6040..57eab878 100644 --- a/src/interface/obsidian/src/main.ts +++ b/src/interface/obsidian/src/main.ts @@ -2,6 +2,7 @@ import { Plugin, WorkspaceLeaf } from 'obsidian'; import { KhojSetting, KhojSettingTab, DEFAULT_SETTINGS } from 'src/settings' import { KhojSearchModal } from 'src/search_modal' import { KhojChatView } from 'src/chat_view' +import { KhojSimilarView } from 'src/similar_view' import { updateContentIndex, canConnectToBackend, KhojView, jumpToPreviousView } from './utils'; import { KhojPaneView } from './pane_view'; @@ -17,6 +18,7 @@ export default class Khoj extends Plugin { this.addCommand({ id: 'search', name: 'Search', + hotkeys: [{ modifiers: ["Ctrl", "Alt"], key: "S" }], callback: () => { new KhojSearchModal(this.app, this.settings).open(); } }); @@ -24,7 +26,8 @@ export default class Khoj extends Plugin { this.addCommand({ id: 'similar', name: 'Find similar notes', - editorCallback: () => { new KhojSearchModal(this.app, this.settings, true).open(); } + hotkeys: [{ modifiers: ["Ctrl", "Alt"], key: "F" }], + editorCallback: () => { this.activateView(KhojView.SIMILAR); } }); // Add chat command. It can be triggered from anywhere @@ -34,6 +37,64 @@ export default class Khoj extends Plugin { callback: () => { this.activateView(KhojView.CHAT); } }); + // Add similar documents view command + this.addCommand({ + id: 'similar-view', + name: 'Open Similar Documents View', + callback: () => { this.activateView(KhojView.SIMILAR); } + }); + + // Add new chat command with hotkey + this.addCommand({ + id: 'new-chat', + name: 'New Chat', + hotkeys: [{ modifiers: ["Ctrl", "Alt"], key: "N" }], + callback: async () => { + // First, activate the chat view + await this.activateView(KhojView.CHAT); + + // Wait a short moment for the view to activate + setTimeout(() => { + // Try to get the active chat view + const chatView = this.app.workspace.getActiveViewOfType(KhojChatView); + if (chatView) { + chatView.createNewConversation(); + } + }, 100); + } + }); + + // Add conversation history command with hotkey + this.addCommand({ + id: 'conversation-history', + name: 'Show Conversation History', + hotkeys: [{ modifiers: ["Ctrl", "Alt"], key: "O" }], + callback: () => { + this.activateView(KhojView.CHAT).then(() => { + const chatView = this.app.workspace.getActiveViewOfType(KhojChatView); + if (chatView) { + chatView.toggleChatSessions(true); + } + }); + } + }); + + // Add voice capture command with hotkey + this.addCommand({ + id: 'voice-capture', + name: 'Start Voice Capture', + hotkeys: [{ modifiers: ["Ctrl", "Alt"], key: "V" }], + callback: () => { + this.activateView(KhojView.CHAT).then(() => { + const chatView = this.app.workspace.getActiveViewOfType(KhojChatView); + if (chatView) { + // Trigger speech to text functionality + chatView.speechToText(new KeyboardEvent('keydown')); + } + }); + } + }); + // Add sync command to manually sync new changes this.addCommand({ id: 'sync', @@ -49,7 +110,34 @@ export default class Khoj extends Plugin { } }); + // Add edit confirmation commands + this.addCommand({ + id: 'apply-edits', + name: 'Apply pending edits', + hotkeys: [{ modifiers: ["Ctrl", "Shift"], key: "Enter" }], + callback: () => { + const chatView = this.app.workspace.getActiveViewOfType(KhojChatView); + if (chatView) { + chatView.applyPendingEdits(); + } + } + }); + + this.addCommand({ + id: 'cancel-edits', + name: 'Cancel pending edits', + hotkeys: [{ modifiers: ["Ctrl", "Shift"], key: "Backspace" }], + callback: () => { + const chatView = this.app.workspace.getActiveViewOfType(KhojChatView); + if (chatView) { + chatView.cancelPendingEdits(); + } + } + }); + + // Register views this.registerView(KhojView.CHAT, (leaf) => new KhojChatView(leaf, this.settings)); + this.registerView(KhojView.SIMILAR, (leaf) => new KhojSimilarView(leaf, this.settings)); // Create an icon in the left ribbon. this.addRibbonIcon('message-circle', 'Khoj', (_: MouseEvent) => { @@ -108,35 +196,48 @@ export default class Khoj extends Plugin { this.unload(); } - async activateView(viewType: KhojView) { + async activateView(viewType: KhojView, existingLeaf?: WorkspaceLeaf) { const { workspace } = this.app; + let leafToUse: WorkspaceLeaf | null = null; - let leaf: WorkspaceLeaf | null = null; - const leaves = workspace.getLeavesOfType(viewType); - - if (leaves.length > 0) { - // A leaf with our view already exists, use that - leaf = leaves[0]; + // Check if an existingLeaf is provided and is suitable for a view type switch + if (existingLeaf && existingLeaf.view && + (existingLeaf.view.getViewType() === KhojView.CHAT || existingLeaf.view.getViewType() === KhojView.SIMILAR) && + existingLeaf.view.getViewType() !== viewType) { + // The existing leaf is a Khoj pane and we want to switch its type + leafToUse = existingLeaf; + await leafToUse.setViewState({ type: viewType, active: true }); } else { - // Our view could not be found in the workspace, create a new leaf - // in the right sidebar for it - leaf = workspace.getRightLeaf(false); - await leaf?.setViewState({ type: viewType, active: true }); + // Standard logic: find an existing leaf of the target type, or create a new one + const leaves = workspace.getLeavesOfType(viewType); + if (leaves.length > 0) { + leafToUse = leaves[0]; + } else { + // If we are not switching an existing Khoj leaf, + // and no leaf of the target type exists, create a new one. + // Use the provided existingLeaf if it's not a Khoj pane we're trying to switch, + // otherwise, get a new right leaf. + leafToUse = (existingLeaf && !(existingLeaf.view instanceof KhojPaneView)) ? existingLeaf : workspace.getRightLeaf(false); + if (leafToUse) { + await leafToUse.setViewState({ type: viewType, active: true }); + } else { + console.error("Khoj: Could not get a leaf to activate view."); + return; + } + } } - if (leaf) { - const activeKhojLeaf = workspace.getActiveViewOfType(KhojPaneView)?.leaf; - // Jump to the previous view if the current view is Khoj Side Pane - if (activeKhojLeaf === leaf) jumpToPreviousView(); - // Else Reveal the leaf in case it is in a collapsed sidebar - else { - workspace.revealLeaf(leaf); + if (leafToUse) { + workspace.revealLeaf(leafToUse); // Ensure the leaf is visible - if (viewType === KhojView.CHAT) { - // focus on the chat input when the chat view is opened - let chatView = leaf.view as KhojChatView; - let chatInput = chatView.contentEl.getElementsByClassName("khoj-chat-input")[0]; - if (chatInput) chatInput.focus(); + // Specific actions after revealing/switching + if (viewType === KhojView.CHAT) { + // Ensure the view instance is correct after potential setViewState + const chatView = leafToUse.view as KhojChatView; + if (chatView instanceof KhojChatView) { // Double check instance type + // Use a more robust way to get the input, or ensure it's always present after onOpen + const chatInput = chatView.containerEl.querySelector(".khoj-chat-input"); + chatInput?.focus(); } } } diff --git a/src/interface/obsidian/src/pane_view.ts b/src/interface/obsidian/src/pane_view.ts index 64a167dd..095dfd62 100644 --- a/src/interface/obsidian/src/pane_view.ts +++ b/src/interface/obsidian/src/pane_view.ts @@ -1,6 +1,5 @@ import { ItemView, WorkspaceLeaf } from 'obsidian'; import { KhojSetting } from 'src/settings'; -import { KhojSearchModal } from 'src/search_modal'; import { KhojView, populateHeaderPane } from './utils'; export abstract class KhojPaneView extends ItemView { @@ -20,13 +19,18 @@ export abstract class KhojPaneView extends ItemView { // Add title to the Khoj Chat modal let headerEl = contentEl.createDiv(({ attr: { id: "khoj-header", class: "khoj-header" } })); + // Setup the header pane - await populateHeaderPane(headerEl, this.setting); - // Set the active nav pane - headerEl.getElementsByClassName("chat-nav")[0]?.classList.add("khoj-nav-selected"); - headerEl.getElementsByClassName("chat-nav")[0]?.addEventListener("click", (_) => { this.activateView(KhojView.CHAT); }); - headerEl.getElementsByClassName("search-nav")[0]?.addEventListener("click", (_) => { new KhojSearchModal(this.app, this.setting).open(); }); - headerEl.getElementsByClassName("similar-nav")[0]?.addEventListener("click", (_) => { new KhojSearchModal(this.app, this.setting, true).open(); }); + const viewType = this.getViewType(); + await populateHeaderPane(headerEl, this.setting, viewType); + + // Set the active nav pane based on the current view's type + if (viewType === KhojView.CHAT) { + headerEl.querySelector(".chat-nav")?.classList.add("khoj-nav-selected"); + } else if (viewType === KhojView.SIMILAR) { + headerEl.querySelector(".similar-nav")?.classList.add("khoj-nav-selected"); + } + // The similar-nav event listener is already set in utils.ts let similarNavSvgEl = headerEl.getElementsByClassName("khoj-nav-icon-similar")[0]?.firstElementChild; if (!!similarNavSvgEl) similarNavSvgEl.id = "similar-nav-icon-svg"; } diff --git a/src/interface/obsidian/src/settings.ts b/src/interface/obsidian/src/settings.ts index 15bc5b01..e8cf4f51 100644 --- a/src/interface/obsidian/src/settings.ts +++ b/src/interface/obsidian/src/settings.ts @@ -1,6 +1,6 @@ import { App, Notice, PluginSettingTab, Setting, TFile, SuggestModal } from 'obsidian'; import Khoj from 'src/main'; -import { canConnectToBackend, getBackendStatusMessage, updateContentIndex } from './utils'; +import { canConnectToBackend, fetchChatModels, fetchUserServerSettings, getBackendStatusMessage, updateContentIndex, updateServerChatModel } from './utils'; export interface UserInfo { username?: string; @@ -16,6 +16,16 @@ interface SyncFileTypes { pdf: boolean; } +export interface ModelOption { + id: string; + name: string; +} + +export interface ServerUserConfig { + selected_chat_model_config?: number; // This is the ID from the server + // Add other fields from UserConfig if needed by the plugin elsewhere +} + export interface KhojSetting { resultsCount: number; khojUrl: string; @@ -27,10 +37,13 @@ export interface KhojSetting { userInfo: UserInfo | null; syncFolders: string[]; syncInterval: number; + autoVoiceResponse: boolean; + selectedChatModelId: string | null; // Mirrors server's selected_chat_model_config + availableChatModels: ModelOption[]; } export const DEFAULT_SETTINGS: KhojSetting = { - resultsCount: 6, + resultsCount: 15, khojUrl: 'https://app.khoj.dev', khojApiKey: '', connectedToBackend: false, @@ -44,10 +57,14 @@ export const DEFAULT_SETTINGS: KhojSetting = { userInfo: null, syncFolders: [], syncInterval: 60, + autoVoiceResponse: true, + selectedChatModelId: null, // Will be populated from server + availableChatModels: [], } export class KhojSettingTab extends PluginSettingTab { plugin: Khoj; + private chatModelSetting: Setting | null = null; constructor(app: App, plugin: Khoj) { super(app, plugin); @@ -57,20 +74,75 @@ export class KhojSettingTab extends PluginSettingTab { display(): void { const { containerEl } = this; containerEl.empty(); + this.chatModelSetting = null; // Reset when display is called // Add notice whether able to connect to khoj backend or not - let backendStatusEl = containerEl.createEl('small', { - text: getBackendStatusMessage( - this.plugin.settings.connectedToBackend, - this.plugin.settings.userInfo?.email, - this.plugin.settings.khojUrl, - this.plugin.settings.khojApiKey - ) - } + let backendStatusMessage = getBackendStatusMessage( + this.plugin.settings.connectedToBackend, + this.plugin.settings.userInfo?.email, + this.plugin.settings.khojUrl, + this.plugin.settings.khojApiKey ); - let backendStatusMessage: string = ''; + + const connectHeaderEl = containerEl.createEl('h3', { title: backendStatusMessage }); + const connectHeaderContentEl = connectHeaderEl.createSpan({ cls: 'khoj-connect-settings-header' }); + const connectTitleEl = connectHeaderContentEl.createSpan({ text: 'Connect' }); + const backendStatusEl = connectTitleEl.createSpan({ text: this.connectStatusIcon(), cls: 'khoj-connect-settings-header-status' }); + if (this.plugin.settings.userInfo && this.plugin.settings.connectedToBackend) { + if (this.plugin.settings.userInfo.photo) { + const profilePicEl = connectHeaderContentEl.createEl('img', { + attr: { src: this.plugin.settings.userInfo.photo }, + cls: 'khoj-profile' + }); + profilePicEl.addEventListener('click', () => { new Notice(backendStatusMessage); }); + } else if (this.plugin.settings.userInfo.email) { + const initial = this.plugin.settings.userInfo.email[0].toUpperCase(); + const profilePicEl = connectHeaderContentEl.createDiv({ + text: initial, + cls: 'khoj-profile khoj-profile-initial' + }); + profilePicEl.addEventListener('click', () => { new Notice(backendStatusMessage); }); + } + } + if (this.plugin.settings.userInfo && this.plugin.settings.userInfo.email) { + connectHeaderEl.title = this.plugin.settings.userInfo?.email === 'default@example.com' + ? "Signed in" + : `Signed in as ${this.plugin.settings.userInfo.email}`; + } // Add khoj settings configurable from the plugin settings tab + const apiKeySetting = new Setting(containerEl) + .setName('Khoj API Key') + .addText(text => text + .setValue(`${this.plugin.settings.khojApiKey}`) + .onChange(async (value) => { + this.plugin.settings.khojApiKey = value.trim(); + ({ + connectedToBackend: this.plugin.settings.connectedToBackend, + userInfo: this.plugin.settings.userInfo, + statusMessage: backendStatusMessage, + } = await canConnectToBackend(this.plugin.settings.khojUrl, this.plugin.settings.khojApiKey)); + + if (!this.plugin.settings.connectedToBackend) { + this.plugin.settings.availableChatModels = []; + this.plugin.settings.selectedChatModelId = null; + } + await this.plugin.saveSettings(); + backendStatusEl.setText(this.connectStatusIcon()) + connectHeaderEl.title = backendStatusMessage; + await this.refreshModelsAndServerPreference(); + })); + + // Add API key setting description with link to get API key + apiKeySetting.descEl.createEl('span', { + text: 'Connect your Khoj Cloud account. ', + }); + apiKeySetting.descEl.createEl('a', { + text: 'Get your API Key', + href: `${this.plugin.settings.khojUrl}/settings#clients`, + attr: { target: '_blank' } + }); + new Setting(containerEl) .setName('Khoj URL') .setDesc('The URL of the Khoj backend.') @@ -84,29 +156,46 @@ export class KhojSettingTab extends PluginSettingTab { statusMessage: backendStatusMessage, } = await canConnectToBackend(this.plugin.settings.khojUrl, this.plugin.settings.khojApiKey)); + if (!this.plugin.settings.connectedToBackend) { + this.plugin.settings.availableChatModels = []; + this.plugin.settings.selectedChatModelId = null; + } await this.plugin.saveSettings(); - backendStatusEl.setText(backendStatusMessage); + backendStatusEl.setText(this.connectStatusIcon()) + connectHeaderEl.title = backendStatusMessage; + await this.refreshModelsAndServerPreference(); })); + + // Interact section + containerEl.createEl('h3', { text: 'Interact' }); + + // Chat Model Dropdown + this.renderChatModelDropdown(); + + // Initial fetch of models and server preference if connected + if (this.plugin.settings.connectedToBackend) { + // Defer slightly to ensure UI is ready and avoid race conditions + setTimeout(async () => { + await this.refreshModelsAndServerPreference(); + }, 1000); + } + + // Add new setting for auto voice response after voice input new Setting(containerEl) - .setName('Khoj API Key') - .setDesc('Use Khoj Cloud with your Khoj API Key') - .addText(text => text - .setValue(`${this.plugin.settings.khojApiKey}`) + .setName('Auto Voice Response') + .setDesc('Automatically read responses after voice messages') + .addToggle(toggle => toggle + .setValue(this.plugin.settings.autoVoiceResponse) .onChange(async (value) => { - this.plugin.settings.khojApiKey = value.trim(); - ({ - connectedToBackend: this.plugin.settings.connectedToBackend, - userInfo: this.plugin.settings.userInfo, - statusMessage: backendStatusMessage, - } = await canConnectToBackend(this.plugin.settings.khojUrl, this.plugin.settings.khojApiKey)); + this.plugin.settings.autoVoiceResponse = value; await this.plugin.saveSettings(); - backendStatusEl.setText(backendStatusMessage); })); + new Setting(containerEl) .setName('Results Count') .setDesc('The number of results to show in search and use for chat.') .addSlider(slider => slider - .setLimits(1, 10, 1) + .setLimits(1, 30, 1) .setValue(this.plugin.settings.resultsCount) .setDynamicTooltip() .onChange(async (value) => { @@ -117,6 +206,16 @@ export class KhojSettingTab extends PluginSettingTab { // Add new "Sync" heading containerEl.createEl('h3', { text: 'Sync' }); + new Setting(containerEl) + .setName('Auto Sync') + .setDesc('Automatically index your vault with Khoj.') + .addToggle(toggle => toggle + .setValue(this.plugin.settings.autoConfigure) + .onChange(async (value) => { + this.plugin.settings.autoConfigure = value; + await this.plugin.saveSettings(); + })); + // Add setting to sync markdown notes new Setting(containerEl) .setName('Sync Notes') @@ -150,16 +249,6 @@ export class KhojSettingTab extends PluginSettingTab { await this.plugin.saveSettings(); })); - new Setting(containerEl) - .setName('Auto Sync') - .setDesc('Automatically index your vault with Khoj.') - .addToggle(toggle => toggle - .setValue(this.plugin.settings.autoConfigure) - .onChange(async (value) => { - this.plugin.settings.autoConfigure = value; - await this.plugin.saveSettings(); - })); - // Add setting for sync interval const syncIntervalValues = [1, 5, 10, 20, 30, 45, 60, 120, 1440]; new Setting(containerEl) @@ -184,7 +273,7 @@ export class KhojSettingTab extends PluginSettingTab { // Add setting to manage sync folders const syncFoldersContainer = containerEl.createDiv('sync-folders-container'); - const foldersSetting = new Setting(syncFoldersContainer) + new Setting(syncFoldersContainer) .setName('Sync Folders') .setDesc('Specify folders to sync (leave empty to sync entire vault)') .addButton(button => button @@ -252,6 +341,104 @@ export class KhojSettingTab extends PluginSettingTab { ); } + private connectStatusIcon() { + if (this.plugin.settings.connectedToBackend && this.plugin.settings.userInfo?.email) + return '🟢'; + else if (this.plugin.settings.connectedToBackend) + return '🟡' + else + return '🔴'; + } + + private async refreshModelsAndServerPreference() { + let serverSelectedModelId: string | null = null; + if (this.plugin.settings.connectedToBackend) { + const [availableModels, serverConfig] = await Promise.all([ + fetchChatModels(this.plugin.settings), + fetchUserServerSettings(this.plugin.settings) + ]); + + this.plugin.settings.availableChatModels = availableModels; + + if (serverConfig && serverConfig.selected_chat_model_config !== undefined && serverConfig.selected_chat_model_config !== null) { + const serverModelIdStr = serverConfig.selected_chat_model_config.toString(); + // Ensure the server's selected model is actually in the available list + if (this.plugin.settings.availableChatModels.some(m => m.id === serverModelIdStr)) { + serverSelectedModelId = serverModelIdStr; + } else { + // Server has a selection, but it's not in the options list (e.g. model removed, or different set of models) + // In this case, we might fall back to null (Khoj Default) + console.warn(`Khoj: Server's selected model ID ${serverModelIdStr} not in available models. Falling back to default.`); + serverSelectedModelId = null; + } + } else { + // No specific model configured on the server, or it's explicitly null + serverSelectedModelId = null; + } + this.plugin.settings.selectedChatModelId = serverSelectedModelId; + + } else { + this.plugin.settings.availableChatModels = []; + this.plugin.settings.selectedChatModelId = null; // Clear selection if disconnected + } + await this.plugin.saveSettings(); // Save the potentially updated selectedChatModelId + this.renderChatModelDropdown(); // Re-render the dropdown with new data + } + + private renderChatModelDropdown() { + if (!this.chatModelSetting) { + this.chatModelSetting = new Setting(this.containerEl) + .setName('Chat Model'); + } else { + // Clear previous description and controls to prepare for re-rendering + this.chatModelSetting.descEl.empty(); + this.chatModelSetting.controlEl.empty(); + } + // Use this.chatModelSetting directly for modifications + const modelSetting = this.chatModelSetting; + + if (!this.plugin.settings.connectedToBackend) { + modelSetting.setDesc('Connect to Khoj to load and set chat model options.'); + modelSetting.addText(text => text.setValue("Not connected").setDisabled(true)); + return; + } + + if (this.plugin.settings.availableChatModels.length === 0 && this.plugin.settings.connectedToBackend) { + modelSetting.setDesc('Fetching models or no models available. Check Khoj connection or try refreshing.'); + modelSetting.addButton(button => button + .setButtonText('Refresh Models') + .onClick(async () => { + button.setButtonText('Refreshing...').setDisabled(true); + await this.refreshModelsAndServerPreference(); + // Re-rendering happens inside refreshModelsAndServerPreference + })); + return; + } + + modelSetting.setDesc('The default AI model used for chat.'); + modelSetting.addDropdown(dropdown => { + dropdown.addOption('', 'Default'); // Placeholder when cannot retrieve chat model options from server. + this.plugin.settings.availableChatModels.forEach(model => { + dropdown.addOption(model.id, model.name); + }); + dropdown + .setValue(this.plugin.settings.selectedChatModelId || '') + .onChange(async (value) => { + // Attempt to update the server + const success = await updateServerChatModel(value, this.plugin.settings); + if (success) { + await this.plugin.saveSettings(); + } else { + // Server update failed, revert dropdown to the current setting value + // to avoid UI mismatch. + dropdown.setValue(this.plugin.settings.selectedChatModelId || ''); + } + // Potentially re-render or refresh if needed, though setValue should update UI. + // this.refreshModelsAndServerPreference(); // Could be called to ensure full sync, but might be too much + }); + }); + } + // Helper method to update the folder list display private updateFolderList(containerEl: HTMLElement) { containerEl.empty(); diff --git a/src/interface/obsidian/src/similar_view.ts b/src/interface/obsidian/src/similar_view.ts new file mode 100644 index 00000000..2f5306d2 --- /dev/null +++ b/src/interface/obsidian/src/similar_view.ts @@ -0,0 +1,434 @@ +import { WorkspaceLeaf, TFile, MarkdownRenderer, Notice, setIcon } from 'obsidian'; +import { KhojSetting } from 'src/settings'; +import { KhojPaneView } from 'src/pane_view'; +import { KhojView, getLinkToEntry, supportedBinaryFileTypes } from 'src/utils'; + +export interface SimilarResult { + entry: string; + file: string; + inVault: boolean; +} + +export class KhojSimilarView extends KhojPaneView { + static iconName: string = "search"; + setting: KhojSetting; + currentController: AbortController | null = null; + isLoading: boolean = false; + loadingEl: HTMLElement; + resultsContainerEl: HTMLElement; + searchInputEl: HTMLInputElement; + currentFile: TFile | null = null; + fileWatcher: any; + component: any; + + constructor(leaf: WorkspaceLeaf, setting: KhojSetting) { + super(leaf, setting); + this.setting = setting; + this.component = this; + } + + getViewType(): string { + return KhojView.SIMILAR; + } + + getDisplayText(): string { + return "Khoj Similar Documents"; + } + + getIcon(): string { + return "search"; + } + + async onOpen() { + await super.onOpen(); + const { contentEl } = this; + + // Create main container + const mainContainerEl = contentEl.createDiv({ cls: "khoj-similar-container" }); + + // Create search input container + const searchContainerEl = mainContainerEl.createDiv({ cls: "khoj-similar-search-container" }); + + // Create search input + this.searchInputEl = searchContainerEl.createEl("input", { + cls: "khoj-similar-search-input", + attr: { + type: "text", + placeholder: "Search or use current file" + } + }); + + // Create refresh button + const refreshButtonEl = searchContainerEl.createEl("button", { + cls: "khoj-similar-refresh-button" + }); + setIcon(refreshButtonEl, "refresh-cw"); + refreshButtonEl.createSpan({ text: "Refresh" }); + refreshButtonEl.addEventListener("click", () => { + this.updateSimilarDocuments(); + }); + + // Create results container + this.resultsContainerEl = mainContainerEl.createDiv({ cls: "khoj-similar-results" }); + + // Create loading element + this.loadingEl = mainContainerEl.createDiv({ cls: "search-loading" }); + const spinnerEl = this.loadingEl.createDiv({ cls: "search-loading-spinner" }); + + this.loadingEl.style.position = "absolute"; + this.loadingEl.style.top = "50%"; + this.loadingEl.style.left = "50%"; + this.loadingEl.style.transform = "translate(-50%, -50%)"; + this.loadingEl.style.zIndex = "1000"; + this.loadingEl.style.display = "none"; + + // Register event handlers + this.registerFileActiveHandler(); + + // Add event listener for search input + this.searchInputEl.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + this.getSimilarDocuments(this.searchInputEl.value); + } + }); + + // Update similar documents for current file + this.updateSimilarDocuments(); + } + + /** + * Register handler for file active events + */ + registerFileActiveHandler() { + // Clean up existing watcher if any + if (this.fileWatcher) { + this.app.workspace.off("file-open", this.fileWatcher); + } + + // Set up new watcher + this.fileWatcher = this.app.workspace.on("file-open", (file) => { + if (file) { + this.currentFile = file; + this.updateSimilarDocuments(); + } + }); + + // Register for cleanup when view is closed + this.register(() => { + this.app.workspace.off("file-open", this.fileWatcher); + }); + } + + /** + * Update similar documents based on current file + */ + async updateSimilarDocuments() { + const file = this.app.workspace.getActiveFile(); + + if (!file) { + this.updateUI("no-file"); + return; + } + + if (file.extension !== 'md') { + this.updateUI("unsupported-file"); + return; + } + + this.currentFile = file; + + // Read file content + const content = await this.app.vault.read(file); + + // Get similar documents + await this.getSimilarDocuments(content); + } + + /** + * Get similar documents from Khoj API + * + * @param query - The query text to find similar documents + */ + async getSimilarDocuments(query: string): Promise { + // Do not show loading if the query is empty + if (!query.trim()) { + this.isLoading = false; + this.updateLoadingState(); + this.updateUI("empty-query"); + return []; + } + + // Show loading state + this.isLoading = true; + this.updateLoadingState(); + this.updateUI("loading"); + + // Cancel previous request if it exists + if (this.currentController) { + this.currentController.abort(); + } + + try { + // Create a new controller for this request + this.currentController = new AbortController(); + + // Setup Query Khoj backend for search results + let encodedQuery = encodeURIComponent(query); + let searchUrl = `${this.setting.khojUrl}/api/search?q=${encodedQuery}&n=${this.setting.resultsCount}&r=true&client=obsidian`; + let headers = { + 'Authorization': `Bearer ${this.setting.khojApiKey}`, + } + + // Get search results from Khoj backend + const response = await fetch(searchUrl, { + headers: headers, + signal: this.currentController.signal + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + + // Parse search results + let results = data + .filter((result: any) => { + // Filter out the current file if it's in the results + if (this.currentFile && result.additional.file.endsWith(this.currentFile.path)) { + return false; + } + return true; + }) + .map((result: any) => { + return { + entry: result.entry, + file: result.additional.file, + inVault: this.isFileInVault(result.additional.file) + } as SimilarResult; + }) + .sort((a: SimilarResult, b: SimilarResult) => { + if (a.inVault === b.inVault) return 0; + return a.inVault ? -1 : 1; + }); + + // Hide loading state + this.isLoading = false; + this.updateLoadingState(); + + // Render results + this.renderResults(results); + + return results; + } catch (error) { + // Ignore cancellation errors + if (error.name === 'AbortError') { + return []; + } + + // For other errors, show error state + console.error('Search error:', error); + this.isLoading = false; + this.updateLoadingState(); + this.updateUI("error", error.message); + return []; + } + } + + /** + * Check if a file is in the vault + * + * @param filePath - The file path to check + * @returns True if the file is in the vault + */ + isFileInVault(filePath: string): boolean { + const files = this.app.vault.getFiles(); + return files.some(file => filePath.endsWith(file.path)); + } + + /** + * Render search results + * + * @param results - The search results to render + */ + renderResults(results: SimilarResult[]) { + // Clear previous results + this.resultsContainerEl.empty(); + + if (results.length === 0) { + this.updateUI("no-results"); + return; + } + + // Show results count + this.resultsContainerEl.createEl("div", { + cls: "khoj-results-count", + text: `Found ${results.length} similar document${results.length > 1 ? 's' : ''}` + }); + + // Create results list + const resultsListEl = this.resultsContainerEl.createEl("div", { cls: "khoj-similar-results-list" }); + + // Render each result + results.forEach(async (result) => { + const resultEl = resultsListEl.createEl("div", { cls: "khoj-similar-result-item" }); + + // Extract filename + let os_path_separator = result.file.includes('\\') ? '\\' : '/'; + let filename = result.file.split(os_path_separator).pop(); + + // Create header container for filename and more context button + const headerEl = resultEl.createEl("div", { cls: "khoj-similar-result-header" }); + + // Show filename with appropriate color + const fileEl = headerEl.createEl("div", { + cls: `khoj-result-file ${result.inVault ? 'in-vault' : 'not-in-vault'}` + }); + fileEl.setText(filename ?? ""); + + // Add indicator for files not in vault + if (!result.inVault) { + fileEl.createSpan({ + text: " (not in vault)", + cls: "khoj-result-file-status" + }); + } + + // Add "More context" button + const moreContextButton = headerEl.createEl("button", { + cls: "khoj-more-context-button" + }); + moreContextButton.createSpan({ text: "More context" }); + setIcon(moreContextButton.createSpan(), "chevron-down"); + + // Create content element (hidden by default) + const contentEl = resultEl.createEl("div", { + cls: "khoj-result-entry khoj-similar-content-hidden" + }); + + // Prepare content for rendering + let contentToRender = ""; + + // Remove YAML frontmatter + result.entry = result.entry.replace(/---[\n\r][\s\S]*---[\n\r]/, ''); + + // Truncate to 8 lines + const lines_to_render = 8; + let entry_snipped_indicator = result.entry.split('\n').length > lines_to_render ? ' **...**' : ''; + let snipped_entry = result.entry.split('\n').slice(0, lines_to_render).join('\n'); + contentToRender = `${snipped_entry}${entry_snipped_indicator}`; + + // Render markdown + await MarkdownRenderer.renderMarkdown( + contentToRender, + contentEl, + result.file, + this.component + ); + + // Add click handler to the more context button + moreContextButton.addEventListener("click", (e) => { + e.stopPropagation(); // Prevent opening the file + + // Toggle content visibility + if (contentEl.classList.contains("khoj-similar-content-hidden")) { + contentEl.classList.remove("khoj-similar-content-hidden"); + contentEl.classList.add("khoj-similar-content-visible"); + moreContextButton.empty(); + moreContextButton.createSpan({ text: "Less context" }); + setIcon(moreContextButton.createSpan(), "chevron-up"); + } else { + contentEl.classList.remove("khoj-similar-content-visible"); + contentEl.classList.add("khoj-similar-content-hidden"); + moreContextButton.empty(); + moreContextButton.createSpan({ text: "More context" }); + setIcon(moreContextButton.createSpan(), "chevron-down"); + } + }); + + // Add click handler to open the file + resultEl.addEventListener("click", (e) => { + // Don't open if clicking on the more context button + if (e.target === moreContextButton || moreContextButton.contains(e.target as Node)) { + return; + } + this.openResult(result); + }); + }); + } + + /** + * Open a search result + * + * @param result - The result to open + */ + async openResult(result: SimilarResult) { + // Only open files that are in the vault + if (!result.inVault) { + new Notice("This file is not in your vault"); + return; + } + + // Get all markdown and binary files in vault + const mdFiles = this.app.vault.getMarkdownFiles(); + const binaryFiles = this.app.vault.getFiles().filter(file => + supportedBinaryFileTypes.includes(file.extension) + ); + + // Find and open the file + let linkToEntry = getLinkToEntry( + mdFiles.concat(binaryFiles), + result.file, + result.entry + ); + + if (linkToEntry) { + this.app.workspace.openLinkText(linkToEntry, ''); + } + } + + /** + * Update the loading state + */ + private updateLoadingState() { + this.loadingEl.style.display = this.isLoading ? "block" : "none"; + } + + /** + * Update the UI based on the current state + * + * @param state - The current state + * @param message - Optional message for error states + */ + updateUI(state: "loading" | "no-file" | "unsupported-file" | "no-results" | "error" | "empty-query", message?: string) { + // Clear results container if not loading + if (state !== "loading") { + this.resultsContainerEl.empty(); + } + + // Create message element + const messageEl = this.resultsContainerEl.createEl("div", { cls: "khoj-similar-message" }); + + // Set message based on state + switch (state) { + case "loading": + // Loading is handled by the loading spinner + break; + case "no-file": + messageEl.setText("No file is currently open. Open a markdown file to see similar documents."); + break; + case "unsupported-file": + messageEl.setText("This file type is not supported. Only markdown files are supported."); + break; + case "no-results": + messageEl.setText("No similar documents found."); + break; + case "error": + messageEl.setText(`Error: ${message || "Failed to fetch similar documents"}`); + break; + case "empty-query": + messageEl.setText("Please enter a search query or open a markdown file."); + break; + } + } +} diff --git a/src/interface/obsidian/src/utils.ts b/src/interface/obsidian/src/utils.ts index 468e8c88..4a4faae4 100644 --- a/src/interface/obsidian/src/utils.ts +++ b/src/interface/obsidian/src/utils.ts @@ -1,5 +1,6 @@ -import { FileSystemAdapter, Notice, Vault, Modal, TFile, request, setIcon, Editor } from 'obsidian'; -import { KhojSetting, UserInfo } from 'src/settings' +import { FileSystemAdapter, Notice, Vault, Modal, TFile, request, setIcon, Editor, App, WorkspaceLeaf } from 'obsidian'; +import { KhojSetting, ModelOption, ServerUserConfig, UserInfo } from 'src/settings' +import { KhojSearchModal } from './search_modal'; export function getVaultAbsolutePath(vault: Vault): string { let adaptor = vault.adapter; @@ -162,8 +163,9 @@ export async function updateContentIndex(vault: Vault, setting: KhojSetting, las batch.forEach(fileItem => { formData.append('files', fileItem.blob, fileItem.path) }); // Call Khoj backend to sync index with updated files in vault + const method = regenerate ? "PUT" : "PATCH"; const response = await fetch(`${setting.khojUrl}/api/content?client=obsidian`, { - method: "PATCH", + method: method, headers: { 'Authorization': `Bearer ${setting.khojApiKey}`, }, @@ -317,12 +319,12 @@ export function getBackendStatusMessage( return `✅ Connected to Khoj. ❗️Get a valid API key from ${khojUrl}/settings#clients to log in`; else if (userEmail === 'default@example.com') // Logged in as default user in anonymous mode - return `✅ Signed in to Khoj`; + return `✅ Welcome back to Khoj`; else - return `✅ Signed in to Khoj as ${userEmail}`; + return `✅ Welcome back to Khoj, ${userEmail}`; } -export async function populateHeaderPane(headerEl: Element, setting: KhojSetting): Promise { +export async function populateHeaderPane(headerEl: Element, setting: KhojSetting, viewType: string): Promise { let userInfo: UserInfo | null = null; try { const { userInfo: extractedUserInfo } = await canConnectToBackend(setting.khojUrl, setting.khojApiKey, false); @@ -332,19 +334,26 @@ export async function populateHeaderPane(headerEl: Element, setting: KhojSetting } // Add Khoj title to header element - const titleEl = headerEl.createDiv(); + const titlePaneEl = headerEl.createDiv(); + titlePaneEl.className = 'khoj-header-title-pane'; + const titleEl = titlePaneEl.createDiv(); titleEl.className = 'khoj-logo'; - titleEl.textContent = "KHOJ" + titleEl.textContent = "Khoj"; // Populate the header element with the navigation pane // Create the nav element - const nav = headerEl.createEl('nav'); + const nav = titlePaneEl.createEl('nav'); nav.className = 'khoj-nav'; + // Create the title pane element + titlePaneEl.appendChild(titleEl); + titlePaneEl.appendChild(nav); + // Create the chat link const chatLink = nav.createEl('a'); chatLink.id = 'chat-nav'; chatLink.className = 'khoj-nav chat-nav'; + chatLink.dataset.view = KhojView.CHAT; // Create the chat icon const chatIcon = chatLink.createEl('span'); @@ -368,6 +377,7 @@ export async function populateHeaderPane(headerEl: Element, setting: KhojSetting // Create the search icon const searchIcon = searchLink.createEl('span'); searchIcon.className = 'khoj-nav-icon khoj-nav-icon-search'; + setIcon(searchIcon, 'khoj-search'); // Create the search text const searchText = searchLink.createEl('span'); @@ -378,38 +388,142 @@ export async function populateHeaderPane(headerEl: Element, setting: KhojSetting searchLink.appendChild(searchIcon); searchLink.appendChild(searchText); - // Create the search link + // Create the similar link const similarLink = nav.createEl('a'); similarLink.id = 'similar-nav'; similarLink.className = 'khoj-nav similar-nav'; + similarLink.dataset.view = KhojView.SIMILAR; - // Create the search icon - const similarIcon = searchLink.createEl('span'); + // Create the similar icon + const similarIcon = similarLink.createEl('span'); similarIcon.id = 'similar-nav-icon'; similarIcon.className = 'khoj-nav-icon khoj-nav-icon-similar'; setIcon(similarIcon, 'webhook'); - // Create the search text - const similarText = searchLink.createEl('span'); + // Create the similar text + const similarText = similarLink.createEl('span'); similarText.className = 'khoj-nav-item-text'; similarText.textContent = 'Similar'; - // Append the search icon and text to the search link + // Append the similar icon and text to the similar link similarLink.appendChild(similarIcon); similarLink.appendChild(similarText); + // Helper to get the current Khoj leaf if active + const getCurrentKhojLeaf = (): WorkspaceLeaf | undefined => { + const activeLeaf = this.app.workspace.activeLeaf; + if (activeLeaf && activeLeaf.view && + (activeLeaf.view.getViewType() === KhojView.CHAT || activeLeaf.view.getViewType() === KhojView.SIMILAR)) { + return activeLeaf; + } + return undefined; + }; + + // Add event listeners to the navigation links + // Chat link event listener + chatLink.addEventListener('click', () => { + // Get the activateView method from the plugin instance + const khojPlugin = this.app.plugins.plugins.khoj; + khojPlugin?.activateView(KhojView.CHAT, getCurrentKhojLeaf()); + }); + + // Search link event listener + searchLink.addEventListener('click', () => { + // Open the search modal + new KhojSearchModal(this.app, setting).open(); + }); + + // Similar link event listener + similarLink.addEventListener('click', () => { + // Get the activateView method from the plugin instance + const khojPlugin = this.app.plugins.plugins.khoj; + khojPlugin?.activateView(KhojView.SIMILAR, getCurrentKhojLeaf()); + }); + // Append the nav items to the nav element nav.appendChild(chatLink); nav.appendChild(searchLink); nav.appendChild(similarLink); - // Append the title, nav items to the header element - headerEl.appendChild(titleEl); - headerEl.appendChild(nav); + // Append the title and new chat container to the header element + headerEl.appendChild(titlePaneEl); + + if (viewType === KhojView.CHAT) { + // Create subtitle pane for New Chat button and agent selector + const newChatEl = headerEl.createDiv("khoj-header-right-container"); + + // Add agent selector container + const agentContainer = newChatEl.createDiv("khoj-header-agent-container"); + + // Add agent selector + agentContainer.createEl("select", { + attr: { + class: "khoj-header-agent-select", + id: "khoj-header-agent-select" + } + }); + + // Add New Chat button + const newChatButton = newChatEl.createEl('button'); + newChatButton.className = 'khoj-header-new-chat-button'; + newChatButton.title = 'Start New Chat (Ctrl+Alt+N)'; + setIcon(newChatButton, 'plus-circle'); + newChatButton.textContent = 'New Chat'; + + // Add event listener to the New Chat button + newChatButton.addEventListener('click', () => { + const khojPlugin = this.app.plugins.plugins.khoj; + if (khojPlugin) { + // First activate the chat view + khojPlugin.activateView(KhojView.CHAT).then(() => { + // Then create a new conversation + setTimeout(() => { + // Access the chat view directly from the leaf after activation + const leaves = this.app.workspace.getLeavesOfType(KhojView.CHAT); + if (leaves.length > 0) { + const chatView = leaves[0].view; + if (chatView && typeof chatView.createNewConversation === 'function') { + chatView.createNewConversation(); + } + } + }, 100); + }); + } + }); + + // Append the new chat container to the header element + headerEl.appendChild(newChatEl); + } + + // Update active state based on current view + const updateActiveState = () => { + const activeLeaf = this.app.workspace.activeLeaf; + if (!activeLeaf) return; + + const viewType = activeLeaf.view?.getViewType(); + + // Remove active class from all links + chatLink.classList.remove('khoj-nav-selected'); + similarLink.classList.remove('khoj-nav-selected'); + + // Add active class to the current view link + if (viewType === KhojView.CHAT) { + chatLink.classList.add('khoj-nav-selected'); + } else if (viewType === KhojView.SIMILAR) { + similarLink.classList.add('khoj-nav-selected'); + } + }; + + // Initial update + updateActiveState(); + + // Register event for workspace changes + this.app.workspace.on('active-leaf-change', updateActiveState); } export enum KhojView { CHAT = "khoj-chat-view", + SIMILAR = "khoj-similar-view", } function copyParentText(event: MouseEvent, message: string, originalButton: string) { @@ -425,7 +539,7 @@ function copyParentText(event: MouseEvent, message: string, originalButton: stri }).catch((error) => { console.error("Error copying text to clipboard:", error); const originalButtonText = button.innerHTML; - button.innerHTML = "⛔️"; + setIcon((button as HTMLElement), 'x-circle'); setTimeout(() => { button.innerHTML = originalButtonText; setIcon((button as HTMLElement), originalButton); @@ -437,7 +551,13 @@ function copyParentText(event: MouseEvent, message: string, originalButton: stri export function createCopyParentText(message: string, originalButton: string = 'copy-plus') { return function (event: MouseEvent) { - return copyParentText(event, message, originalButton); + let markdownMessage = copyParentText(event, message, originalButton); + // Convert edit blocks back to markdown format before pasting + const editRegex = /
[\s\S]*?
([\s\S]*?)<\/code><\/pre>[\s\S]*?<\/details>/g;
+        markdownMessage = markdownMessage?.replace(editRegex, (_, content) => {
+            return `\n${content}\n`;
+        });
+        return markdownMessage;
     }
 }
 
@@ -485,3 +605,76 @@ export function getLinkToEntry(sourceFiles: TFile[], chosenFile: string, chosenE
         return linkToEntry;
     }
 }
+
+export async function fetchChatModels(settings: KhojSetting): Promise {
+    if (!settings.connectedToBackend || !settings.khojUrl) {
+        return [];
+    }
+    try {
+        const response = await fetch(`${settings.khojUrl}/api/model/chat/options`, {
+            method: 'GET',
+            headers: settings.khojApiKey ? { 'Authorization': `Bearer ${settings.khojApiKey}` } : {},
+        });
+        if (response.ok) {
+            const modelsData = await response.json();
+            if (Array.isArray(modelsData)) {
+                return modelsData.map((model: any) => ({
+                    id: model.id.toString(),
+                    name: model.name,
+                }));
+            }
+        } else {
+            console.warn("Khoj: Failed to fetch chat models:", response.statusText);
+        }
+    } catch (error) {
+        console.error("Khoj: Error fetching chat models:", error);
+    }
+    return [];
+}
+
+export async function fetchUserServerSettings(settings: KhojSetting): Promise {
+    if (!settings.connectedToBackend || !settings.khojUrl) {
+        return null;
+    }
+    try {
+        const response = await fetch(`${settings.khojUrl}/api/settings?detailed=true`, {
+            method: 'GET',
+            headers: settings.khojApiKey ? { 'Authorization': `Bearer ${settings.khojApiKey}` } : {},
+        });
+        if (response.ok) {
+            return await response.json() as ServerUserConfig;
+        } else {
+            console.warn("Khoj: Failed to fetch user server settings:", response.statusText);
+        }
+    } catch (error) {
+        console.error("Khoj: Error fetching user server settings:", error);
+    }
+    return null;
+}
+
+export async function updateServerChatModel(modelId: string, settings: KhojSetting): Promise {
+    if (!settings.connectedToBackend || !settings.khojUrl) {
+        new Notice("️⛔️ Connect to Khoj to update chat model.");
+        return false;
+    }
+
+    try {
+        const response = await fetch(`${settings.khojUrl}/api/model/chat?id=${modelId}`, {
+            method: 'POST', // As per web app's updateModel function
+            headers: settings.khojApiKey ? { 'Authorization': `Bearer ${settings.khojApiKey}` } : {},
+        });
+        if (response.ok) {
+            settings.selectedChatModelId = modelId; // Update local mirror
+            return true;
+        } else {
+            const errorData = await response.text();
+            new Notice(`️⛔️ Failed to update chat model on server: ${response.status} ${errorData}`);
+            console.error("Khoj: Failed to update chat model:", response.status, errorData);
+            return false;
+        }
+    } catch (error) {
+        new Notice("️⛔️ Error updating chat model on server. See console.");
+        console.error("Khoj: Error updating chat model:", error);
+        return false;
+    }
+}
diff --git a/src/interface/obsidian/styles.css b/src/interface/obsidian/styles.css
index f7c067ed..b10d833f 100644
--- a/src/interface/obsidian/styles.css
+++ b/src/interface/obsidian/styles.css
@@ -17,90 +17,151 @@ If your plugin does not need CSS, delete this file.
 
 .khoj-chat p {
     margin: 0;
+    line-height: 1.6;
 }
 
 .khoj-chat pre {
     text-wrap: unset;
+    border-radius: 6px;
+    background-color: var(--background-modifier-hover);
+    margin: 12px 0;
 }
 
 .khoj-chat {
-    display: grid;
-    grid-template-rows: auto 1fr auto;
-    background: var(--background-primary);
+    display: flex;
+    flex-direction: column;
+    height: 100%;
+    overflow: hidden;
+    background-color: var(--background-primary);
     color: var(--text-normal);
-    text-align: center;
-    font-family: roboto, karma, segoe ui, sans-serif;
-    font-size: var(--font-ui-large);
-    font-weight: 300;
-    line-height: 1.5em;
+    font-size: 16px;
+    line-height: 1.5;
+    padding: 0;
+    margin: 0px;
 }
 
 .khoj-chat>* {
-    padding: 10px;
-    margin: 10px;
+    padding: 12px;
+    margin: 5px;
 }
 
 #khoj-chat-title {
-    font-weight: 200;
+    font-weight: 300;
     color: var(--khoj-sun);
+    font-size: 1.4em;
+    letter-spacing: 0.2px;
 }
 
 #khoj-chat-body {
-    font-size: var(--font-ui-medium);
-    margin: 0px;
-    line-height: 20px;
-    overflow-y: scroll;
-    /* Make chat body scroll to see history */
+    flex: 1;
+    overflow-y: auto;
+    padding: 20px;
+    padding-bottom: 30px;
+    /* Add extra padding at the bottom to prevent messages from reaching the input area */
+    height: calc(100% - 120px);
+    /* Fixed height calculation to prevent resizing */
+    position: relative;
 }
 
-/* add chat metatdata to bottom of bubble */
-.khoj-chat-message.khoj::after {
+/* add chat metadata to bottom of bubble */
+.khoj-chat-message::after {
     content: attr(data-meta);
     display: block;
     font-size: var(--font-ui-smaller);
     color: var(--text-muted);
-    margin: -12px 7px 0 0px;
+    margin: 4px 0;
+    text-align: right;
+    clear: both;
+}
+
+/* Specific adjustments for khoj messages */
+.khoj-chat-message.khoj::after {
+    text-align: left;
+}
+
+/* Specific adjustments for user messages */
+.khoj-chat-message.you::after {
+    text-align: right;
 }
 
 /* move message by khoj to left */
 .khoj-chat-message.khoj {
-    margin-left: auto;
+    margin-right: auto;
     text-align: left;
+    padding: 6px 0;
 }
 
 /* move message by you to right */
 .khoj-chat-message.you {
-    margin-right: auto;
+    margin-left: auto;
     text-align: right;
+    padding: 6px 0;
 }
 
 /* basic style chat message text */
 .khoj-chat-message-text {
     margin: 10px;
-    border-radius: 10px;
-    padding: 10px;
+    border-radius: 12px;
+    padding: 14px 16px;
     position: relative;
     display: inline-block;
     text-align: left;
     user-select: text;
     color: var(--text-normal);
-    background-color: var(--active-bg);
+    background-color: var(--background-modifier-hover);
     word-break: break-word;
+    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
+    max-width: 90%;
+    font-size: 0.8em;
 }
 
 /* color chat bubble by khoj blue */
 .khoj-chat-message-text.khoj {
-    border-left: 2px solid var(--khoj-sun);
-    border-radius: 0px;
-    margin-left: auto;
+    border-left: 4px solid var(--khoj-sun);
+    border-radius: 8px;
+    margin-right: auto;
+    padding-left: 18px;
     white-space: pre-line;
+    background-color: var(--background-secondary);
+    transition: opacity 0.1s ease-in-out, transform 0.1s ease-in-out;
 }
 
+/* Animation for new words during streaming */
+@keyframes fadeIn {
+    from {
+        opacity: 0;
+        transform: translateY(5px);
+    }
+
+    to {
+        opacity: 1;
+        transform: translateY(0);
+    }
+}
+
+/* Class to animate new content
+.khoj-message-new-content {
+    animation: fadeIn 0.3s ease-in-out;
+} */
+
 /* Override white-space for ul, ol, li under khoj-chat-message-text.khoj */
 .khoj-chat-message-text.khoj ul,
 .khoj-chat-message-text.khoj ol,
 .khoj-chat-message-text.khoj li {
     white-space: normal;
+    margin-left: 0;
+    padding-left: 1.2em;
+}
+
+/* fix list items display */
+.khoj-chat-message-text ul,
+.khoj-chat-message-text ol {
+    padding-left: 1.2em;
+    margin: 8px 0;
+}
+
+.khoj-chat-message-text li {
+    margin-bottom: 4px;
 }
 
 /* add left protrusion to khoj chat bubble */
@@ -109,7 +170,7 @@ If your plugin does not need CSS, delete this file.
     position: absolute;
     bottom: -2px;
     left: -7px;
-    border: 10px solid transparent;
+    border: 8px solid transparent;
     border-bottom: 0;
     transform: rotate(-60deg);
 }
@@ -117,7 +178,7 @@ If your plugin does not need CSS, delete this file.
 /* color chat bubble by you dark grey */
 .khoj-chat-message-text.you {
     color: var(--text-normal);
-    margin-right: auto;
+    margin-left: auto;
     background-color: var(--background-modifier-cover);
 }
 
@@ -133,27 +194,23 @@ If your plugin does not need CSS, delete this file.
     transform: rotate(-60deg)
 }
 
-.khoj-chat-message-text ul,
-.khoj-chat-message-text ol {
-    margin: 0px 0 0;
-}
-
-.khoj-chat-message-text ol li {
-    white-space: normal;
-}
-
 .option-enabled {
     box-shadow: 0 0 12px rgb(119, 156, 46);
 }
 
 code.chat-response {
-    background: var(--khoj-sun);
-    color: var(--khoj-storm-grey);
-    border-radius: 5px;
-    padding: 5px;
-    font-size: 14px;
-    font-weight: 300;
-    line-height: 1.5em;
+    background-color: var(--background-secondary-alt) !important;
+    border-radius: 6px;
+    padding: 8px 12px !important;
+    font-family: var(--font-monospace);
+    font-size: var(--font-ui-smaller);
+    line-height: 1.5;
+    white-space: pre-wrap;
+    word-break: break-word;
+    box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
+    border: 1px solid var(--background-modifier-border);
+    display: block;
+    margin: 8px 0;
 }
 
 div.collapsed {
@@ -165,12 +222,13 @@ div.expanded {
 }
 
 div.reference {
-    display: grid;
-    grid-template-rows: auto;
-    grid-auto-flow: row;
-    grid-column-gap: 10px;
-    grid-row-gap: 10px;
-    margin: 10px;
+    margin: 0.5em;
+    padding: 0.5em;
+    border: 1px solid var(--background-modifier-border);
+    border-radius: 8px;
+    background-color: var(--background-secondary);
+    transition: all 0.2s ease;
+    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
 }
 
 div.expanded.reference-section {
@@ -183,22 +241,26 @@ div.expanded.reference-section {
 }
 
 button.reference-button {
-    border: 1px solid var(--khoj-storm-grey);
-    background-color: transparent;
-    border-radius: 5px;
-    padding: 4px;
-    font-size: 14px;
-    font-weight: 300;
-    line-height: 1.5em;
-    cursor: pointer;
-    transition: background 0.2s ease-in-out;
+    background: var(--background-secondary-alt);
+    border: 1px solid var(--background-modifier-border);
+    border-radius: 6px;
+    padding: 8px 12px;
+    margin: 4px 0;
+    width: 100%;
     text-align: left;
-    max-height: 75px;
-    height: auto;
-    transition: max-height 0.3s ease-in-out;
+    cursor: pointer;
+    color: var(--text-normal);
+    font-size: calc(var(--font-ui-small) * 1.05);
+    line-height: 1.4;
+    transition: all 0.2s ease;
+    position: relative;
     overflow: hidden;
-    display: inline-block;
-    text-wrap: inherit;
+}
+
+button.reference-button:hover {
+    background-color: var(--background-modifier-hover);
+    transform: translateY(-1px);
+    box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
 }
 
 button.reference-button.expanded {
@@ -335,36 +397,59 @@ div.selected-conversation {
 }
 
 #khoj-chat-footer {
-    padding: 0;
-    display: grid;
-    grid-template-columns: minmax(70px, 100%);
-    grid-column-gap: 10px;
-    grid-row-gap: 10px;
+    position: relative;
+    bottom: 0;
+    width: 100%;
+    padding: 10px;
+    background-color: var(--background-primary);
+    border-top: 1px solid var(--background-modifier-border);
+    z-index: 10;
+    /* Ensure footer stays on top */
+    flex-shrink: 0;
+    /* Prevent footer from shrinking */
 }
 
 .khoj-input-row {
-    display: grid;
-    grid-template-columns: 32px auto 32px 32px;
-    grid-column-gap: 10px;
-    grid-row-gap: 10px;
-    background: var(--background-primary);
-    margin: 0 0 0 -8px;
+    display: flex;
+    flex-wrap: nowrap;
     align-items: center;
-    position: sticky;
-    bottom: 0;
-    z-index: 10;
+    background-color: var(--background-secondary-alt, var(--background-secondary));
+    border-radius: 16px;
+    padding: 4px 16px;
+    box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
+    transition: all 0.3s ease;
+    position: relative;
+    overflow: hidden;
+    margin-top: 0px;
+    margin-bottom: 0px;
+    transform: none;
 }
 
-#khoj-chat-input.option:hover {
-    box-shadow: 0 0 11px var(--background-modifier-box-shadow);
+/* Add focus effect */
+.khoj-input-row:focus-within {
+    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
+    background-color: var(--background-secondary-alt, var(--background-secondary));
 }
 
+
 #khoj-chat-input {
-    font-size: var(--font-ui-medium);
-    padding: 4px 0 0 12px;
-    border-radius: 16px;
-    height: 32px;
+    flex: 1;
+    min-width: 100px;
+    border: none;
+    background: transparent;
+    color: var(--text-normal);
+    font-size: calc(var(--font-ui-medium) * 1.05);
+    padding: 12px;
+    min-height: 24px;
+    max-height: 400px;
     resize: none;
+    border-radius: 8px;
+    outline: none;
+    font-family: inherit;
+    line-height: normal;
+    overflow: hidden;
+    transition: none;
+    align-self: flex-start;
 }
 
 .khoj-input-row-button {
@@ -373,6 +458,13 @@ div.selected-conversation {
     --icon-size: var(--icon-size);
     height: 32px;
     width: 32px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    position: relative;
+    margin: 4px;
+    align-self: center;
+    flex-shrink: 0;
 }
 
 #khoj-chat-send {
@@ -476,17 +568,26 @@ div.selected-conversation {
     display: none;
 }
 
-/* Khoj Header, Navigation Pane */
+/* Improved header styles for a more modern look */
 div.khoj-header {
     display: grid;
-    grid-auto-flow: column;
-    gap: 20px;
-    padding: 0 0 10px 0;
-    margin: 0;
+    grid-auto-flow: row;
+    padding: 12px 12px;
+    background-color: var(--background-secondary, var(--background-secondary));
+    border-bottom: 1px solid var(--border-color);
+    border-radius: 8px;
+    width: 100%;
+    box-sizing: border-box;
+    position: sticky;
+    top: 0;
+    z-index: 10;
+    /* Allow elements to wrap on smaller screens */
+}
+div.khoj-header-title-pane {
+    display: flex;
+    justify-content: space-between;
     align-items: center;
-    user-select: none;
-    -webkit-user-select: none;
-    -webkit-app-region: drag;
+    flex-wrap: wrap;
 }
 
 /* Keeps the navigation menu clickable */
@@ -501,44 +602,315 @@ div.khoj-nav {
 nav.khoj-nav {
     display: grid;
     grid-auto-flow: column;
-    grid-gap: 32px;
-    justify-self: right;
+    grid-gap: 36px;
+    justify-self: center;
+    justify-content: center;
     align-items: center;
+    padding: 4px 0;
 }
 
 a.khoj-nav {
     display: flex;
     align-items: center;
+    text-decoration: none;
+    color: var(--text-normal);
+    font-size: small;
+    font-weight: normal;
+    padding: 6px 10px;
+    border-radius: 6px;
+    justify-self: center;
+    margin: 0;
+    transition: all 0.2s ease;
 }
 
 div.khoj-logo {
     justify-self: left;
+    font-size: large;
+    transition: transform 0.2s ease;
+}
+
+div.khoj-logo:hover {
+    transform: scale(1.05);
+}
+
+/* Container for New Chat button and agent selector */
+.khoj-header-right-container {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    justify-self: right;
+    -webkit-app-region: no-drag;
+}
+
+/* New Chat button in header */
+.khoj-header-new-chat-button {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    padding: 6px 14px;
+    border-radius: 8px;
+    border: 1px solid var(--text-accent);
+    background-color: var(--background-primary);
+    color: var(--text-accent);
+    cursor: pointer;
+    font-size: 13px;
+    font-weight: 500;
+    white-space: nowrap;
+    transition: all 0.25s ease;
+    height: 32px;
+    -webkit-app-region: no-drag;
+    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
+    z-index: 10;
+}
+
+/* Media queries for responsive design */
+@media only screen and (max-width: 600px) {
+    div.khoj-header {
+        display: grid;
+        grid-template-columns: 1fr;
+        grid-template-rows: auto auto auto;
+        gap: 12px;
+        padding: 16px 12px 12px;
+        margin: 0 0 16px 0;
+    }
+
+    div.khoj-header h3 {
+        text-align: center;
+        margin-bottom: 8px;
+    }
+
+    nav.khoj-nav {
+        grid-gap: 8px;
+        justify-content: center;
+        order: 3;
+    }
+
+    .khoj-header-right-container {
+        flex-direction: column;
+        align-items: center;
+        gap: 8px;
+        order: 2;
+        margin: 0 auto;
+    }
+
+    #khoj-header-agent-select {
+        width: 100%;
+        max-width: 250px;
+    }
+
+    .khoj-header-new-chat-button {
+        width: 100%;
+        max-width: 250px;
+        justify-content: center;
+    }
+
+    .khoj-chat-message-text {
+        max-width: 95%;
+        font-size: 0.9em;
+    }
+
+    div.reference {
+        padding: 10px;
+    }
+
+    .reference-link {
+        max-width: 70%;
+    }
+
+    .khoj-edit-accordion summary {
+        padding: 8px 10px;
+    }
+
+    .khoj-edit-content pre {
+        padding: 8px;
+    }
+}
+
+/* Additional media query for very narrow screens */
+@media only screen and (max-width: 400px) {
+    div.khoj-header {
+        grid-template-columns: 1fr;
+        grid-template-rows: auto auto auto;
+        gap: 12px;
+        padding: 14px 10px 12px;
+    }
+
+    div.khoj-logo {
+        justify-self: center;
+    }
+
+    nav.khoj-nav {
+        justify-self: center;
+    }
+
+    .khoj-header-right-container {
+        justify-self: center;
+        width: 100%;
+        flex-direction: row;
+        flex-wrap: wrap;
+        justify-content: center;
+    }
+
+    .khoj-agent-select {
+        min-width: 120px;
+        font-size: 12px;
+    }
+
+    .khoj-header-new-chat-button {
+        font-size: 12px;
+        padding: 4px 10px;
+    }
+
+    .khoj-chat-message-text {
+        max-width: 98%;
+        padding: 10px 12px;
+        font-size: 0.85em;
+    }
+
+    span.khoj-nav-item-text {
+        font-size: 12px;
+    }
+
+    button.chat-action-button {
+        width: 24px;
+        height: 24px;
+    }
+
+    .khoj-input-row {
+        padding: 8px;
+    }
+
+    #khoj-chat-input {
+        padding: 8px;
+        min-height: 32px;
+        max-height: 300px;
+        /* Reduced maximum size for mobile */
+    }
+
+    .khoj-input-row-button {
+        width: 28px;
+        height: 28px;
+        align-self: center;
+        /* Ensures buttons remain vertically centered on mobile */
+        flex-shrink: 0;
+        /* Prevents buttons from shrinking on mobile */
+    }
+
+    #khoj-chat-send {
+        width: 28px;
+    }
+
+    .khoj-input-row-button {
+        width: 28px;
+        height: 28px;
+    }
+
+    .reference {
+        padding: 8px;
+    }
+}
+
+.khoj-header-new-chat-button:hover {
+    background-color: var(--text-accent);
+    color: var(--background-primary);
+    transform: translateY(-2px);
+    box-shadow: 0 3px 6px rgba(0, 0, 0, 0.15);
+}
+
+.khoj-header-new-chat-button:active {
+    transform: translateY(-1px);
+    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
+}
+
+.khoj-header-new-chat-button .lucide-plus-circle {
+    width: 16px;
+    height: 16px;
+    transition: transform 0.2s ease;
+}
+
+.khoj-header-new-chat-button:hover .lucide-plus-circle {
+    transform: rotate(90deg);
+}
+
+/* Agent selector in header */
+.khoj-header-agent-container {
+    display: flex;
+    flex-direction: column;
+    z-index: 10;
+    -webkit-app-region: no-drag;
+    width: 160px;
+}
+
+.khoj-header-agent-select {
+    font-family: inherit;
+    font-size: 14px;
+    padding: 4px 8px;
+    border-radius: 4px;
+    border: 1px solid var(--khoj-storm-grey);
+    background-color: var(--background-primary);
+    color: var(--text-normal);
+    cursor: pointer;
+    min-width: 120px;
+    height: 32px;
+    margin: 0;
+    width: 100%;
+}
+
+.khoj-header-agent-select:hover {
+    border-color: var(--text-accent);
+}
+
+.khoj-header-agent-select:focus {
+    outline: none;
+    border-color: var(--text-accent);
+    box-shadow: 0 0 0 2px rgba(var(--text-accent-rgb), 0.1);
+}
+
+.khoj-header-agent-select option {
+    background-color: var(--background-primary);
+    color: var(--text-normal);
+    padding: 8px;
 }
 
 .khoj-nav a {
     color: var(--text-normal);
     text-decoration: none;
     font-size: small;
-    font-weight: normal;
-    padding: 0 4px;
-    border-radius: 4px;
+    font-weight: 450;
+    padding: 8px 12px;
+    border-radius: 6px;
     justify-self: center;
     margin: 0;
+    transition: all 0.2s ease;
 }
 
 .khoj-nav a:hover {
-    background-color: var(--background-modifier-active-hover);
-    color: var(--main-text-color);
+    background-color: var(--background-modifier-hover);
+    color: var(--text-accent);
+    transform: translateY(-1px);
+}
+
+.khoj-nav a:active {
+    transform: translateY(0);
 }
 
 a.khoj-nav-selected {
     background-color: var(--background-modifier-active-hover);
+    color: var(--text-accent);
+    font-weight: 500;
+    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
 }
 
 #similar-nav-icon-svg,
 .khoj-nav-icon {
     width: 24px;
     height: 24px;
+    transition: transform 0.2s ease;
+}
+
+.khoj-nav a:hover .khoj-nav-icon,
+.khoj-nav a:hover #similar-nav-icon-svg {
+    transform: scale(1.1);
 }
 
 .khoj-nav-icon-chat {
@@ -553,32 +925,68 @@ span.khoj-nav-item-text {
     padding-left: 8px;
 }
 
-/* Copy button */
+/* Improved action buttons */
 button.chat-action-button {
     display: block;
-    border-radius: 4px;
+    border-radius: 6px;
     color: var(--text-muted);
     background-color: transparent;
     border: 1px solid var(--khoj-storm-grey);
     text-align: center;
     font-size: 16px;
-    transition: all 0.5s;
+    transition: all 0.3s;
     cursor: pointer;
-    padding: 4px;
+    padding: 6px 8px;
     margin-top: 8px;
+    margin-left: 6px;
     float: right;
+    box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
 }
 
 button.chat-action-button span {
     cursor: pointer;
     display: inline-block;
     position: relative;
-    transition: 0.5s;
+    transition: 0.3s;
+}
+
+.khoj-chat-message:hover button.chat-action-button {
+    opacity: 0.9;
 }
 
 button.chat-action-button:hover {
     background-color: var(--background-modifier-active-hover);
     color: var(--text-normal);
+    transform: translateY(-1px);
+    box-shadow: 0 2px 3px rgba(0, 0, 0, 0.1);
+}
+
+/* Add specific hover colors for each action button */
+button.chat-action-button[title="Copy Message to Clipboard"]:hover {
+    background-color: var(--interactive-accent-hover);
+    border-color: var(--interactive-accent);
+    color: var(--text-on-accent);
+}
+
+button.chat-action-button[title="Paste Message to File"]:hover {
+    background-color: rgba(52, 199, 89, 0.1);
+    /* Light green */
+    border-color: rgb(52, 199, 89);
+    color: rgb(52, 199, 89);
+}
+
+button.chat-action-button[title="Edit Message"]:hover {
+    background-color: rgba(255, 149, 0, 0.1);
+    /* Light orange */
+    border-color: rgb(255, 149, 0);
+    color: rgb(255, 149, 0);
+}
+
+button.chat-action-button[title="Delete Message"]:hover {
+    background-color: rgba(255, 59, 48, 0.1);
+    /* Light red */
+    border-color: rgb(255, 59, 48);
+    color: rgb(255, 59, 48);
 }
 
 img.copy-icon {
@@ -586,6 +994,34 @@ img.copy-icon {
     height: 16px;
 }
 
+/* Modern animations for transitions */
+@keyframes pulse {
+    0% {
+        transform: scale(1);
+    }
+
+    50% {
+        transform: scale(1.05);
+    }
+
+    100% {
+        transform: scale(1);
+    }
+}
+
+.khoj-chat-message {
+    display: flex;
+    flex-direction: column;
+    margin: 0;
+    max-width: 100%;
+    overflow-wrap: break-word;
+}
+
+.khoj-chat-message.deleting {
+    opacity: 0;
+    transform: translateX(20px);
+}
+
 /* Circular Loading Spinner */
 .loader {
     width: 18px;
@@ -636,47 +1072,64 @@ img.copy-icon {
     width: 8px;
     height: 8px;
     border-radius: 50%;
-    background: var(--color-base-70);
-    animation-timing-function: cubic-bezier(0, 1, 1, 0);
+    background: var(--text-muted);
+    animation-timing-function: cubic-bezier(0.3, 0.6, 0.8, 0.9);
+    /* Animation plus douce */
 }
 
 .lds-ellipsis div:nth-child(1) {
     left: 8px;
-    animation: lds-ellipsis1 0.6s infinite;
+    animation: lds-ellipsis1 0.9s infinite;
+    /* Animation plus lente et fluide */
 }
 
 .lds-ellipsis div:nth-child(2) {
     left: 8px;
-    animation: lds-ellipsis2 0.6s infinite;
+    animation: lds-ellipsis2 0.9s infinite;
+    /* Animation plus lente et fluide */
 }
 
 .lds-ellipsis div:nth-child(3) {
     left: 32px;
-    animation: lds-ellipsis2 0.6s infinite;
+    animation: lds-ellipsis2 0.9s infinite;
+    /* Animation plus lente et fluide */
 }
 
 .lds-ellipsis div:nth-child(4) {
     left: 56px;
-    animation: lds-ellipsis3 0.6s infinite;
+    animation: lds-ellipsis3 0.9s infinite;
+    /* Animation plus lente et fluide */
 }
 
 @keyframes lds-ellipsis1 {
     0% {
         transform: scale(0);
+        opacity: 0;
+    }
+
+    20% {
+        opacity: 1;
     }
 
     100% {
         transform: scale(1);
+        opacity: 1;
     }
 }
 
 @keyframes lds-ellipsis3 {
     0% {
         transform: scale(1);
+        opacity: 1;
+    }
+
+    80% {
+        opacity: 1;
     }
 
     100% {
         transform: scale(0);
+        opacity: 0;
     }
 }
 
@@ -737,29 +1190,6 @@ img.copy-icon {
     }
 }
 
-@media only screen and (max-width: 600px) {
-    div.khoj-header {
-        display: grid;
-        grid-auto-flow: column;
-        gap: 20px;
-        padding: 24px 10px 10px 10px;
-        margin: 0 0 16px 0;
-    }
-
-    nav.khoj-nav {
-        grid-gap: 0px;
-        justify-content: space-between;
-    }
-
-    a.khoj-nav {
-        padding: 0 16px;
-    }
-
-    span.khoj-nav-item-text {
-        display: none;
-    }
-}
-
 /* Folder list styles */
 .folder-list {
     list-style: none;
@@ -781,7 +1211,7 @@ img.copy-icon {
 .folder-list-remove {
     background: none;
     border: none;
-    color: #ff5555;
+    color: #e57373;
     cursor: pointer;
     font-size: 18px;
     width: 24px;
@@ -798,7 +1228,7 @@ img.copy-icon {
 
 .folder-list-remove:hover {
     opacity: 1;
-    background-color: rgba(255, 85, 85, 0.1);
+    background-color: rgba(229, 115, 115, 0.1);
 }
 
 .folder-list-empty {
@@ -841,9 +1271,19 @@ img.copy-icon {
 }
 
 /* Research Spinner */
+.search-loading {
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    padding: 20px;
+    background-color: rgba(0, 0, 0, 0.05);
+    border-radius: 8px;
+    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+}
+
 .search-loading-spinner {
-    width: 24px;
-    height: 24px;
+    width: 32px;
+    height: 32px;
     border: 3px solid var(--background-modifier-border);
     border-top: 3px solid var(--text-accent);
     border-radius: 50%;
@@ -859,3 +1299,709 @@ img.copy-icon {
         transform: rotate(360deg);
     }
 }
+
+.khoj-similar-message {
+    text-align: center;
+    padding: 20px;
+    color: var(--text-muted);
+    font-size: 14px;
+    background-color: var(--background-secondary);
+    border-radius: 8px;
+    margin: 20px 0;
+    box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05);
+}
+
+/* Chat Mode Selector Styles - Using dropdown only */
+/* Removed the .khoj-mode-row, .khoj-mode-container, .khoj-mode-input, and .khoj-mode-label classes */
+
+/* Chat Agent Selector Styles */
+.khoj-agent-select {
+    font-family: inherit;
+    font-size: 14px;
+    padding: 4px 8px;
+    border-radius: 4px;
+    border: 1px solid var(--khoj-storm-grey);
+    background-color: var(--background-primary);
+    color: var(--text-normal);
+    cursor: pointer;
+    min-width: 120px;
+    height: 32px;
+    margin: 0;
+    width: 100%;
+}
+
+.khoj-agent-select:hover {
+    border-color: var(--text-accent);
+}
+
+.khoj-agent-select:focus {
+    outline: none;
+    border-color: var(--text-accent);
+    box-shadow: 0 0 0 2px rgba(var(--text-accent-rgb), 0.1);
+}
+
+.khoj-agent-select option {
+    background-color: var(--background-primary);
+    color: var(--text-normal);
+    padding: 8px;
+}
+
+.khoj-agent-container {
+    display: flex;
+    flex-direction: column;
+    flex: 1;
+    max-width: 200px;
+}
+
+.khoj-agent-label {
+    font-size: 12px;
+    color: var(--text-muted);
+    margin-bottom: 4px;
+}
+
+.khoj-agent-hint {
+    width: 100%;
+    font-size: 12px;
+    color: var(--text-muted);
+    margin-top: 4px;
+    font-style: italic;
+    display: none;
+    opacity: 0;
+    transition: opacity 0.3s ease;
+}
+
+.khoj-agent-hint.visible {
+    display: block;
+    opacity: 1;
+    animation: fadeIn 0.3s ease-in-out;
+    color: var(--text-accent);
+}
+
+/* Edit Confirmation Buttons */
+.edit-confirmation-buttons {
+    display: flex;
+    gap: 8px;
+    margin-top: 8px;
+    justify-content: flex-end;
+}
+
+.edit-confirm-button {
+    padding: 4px 12px;
+    border-radius: 4px;
+    cursor: pointer;
+    font-size: 14px;
+    transition: background-color 0.2s ease;
+}
+
+.edit-apply-button {
+    background-color: rgba(52, 199, 89, 0.1);
+    border: 1px solid rgb(52, 199, 89);
+    color: rgb(52, 199, 89);
+}
+
+.edit-apply-button:hover {
+    background-color: rgba(52, 199, 89, 0.2);
+}
+
+.edit-cancel-button {
+    background-color: rgba(239, 154, 154, 0.1);
+    border: 1px solid #ef9a9a;
+    color: #d32f2f;
+}
+
+.edit-cancel-button:hover {
+    background-color: rgba(239, 154, 154, 0.2);
+}
+
+.edit-status-container {
+    display: flex;
+    justify-content: center;
+    width: 100%;
+    margin-top: 12px;
+    margin-bottom: 8px;
+}
+
+.edit-status-summary {
+    margin-top: 0;
+    padding: 6px 10px;
+    border-radius: 4px;
+    font-size: 15px;
+    font-weight: 450;
+    animation: fadeIn 0.3s ease;
+    text-align: center;
+    width: fit-content;
+    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
+    background-color: var(--background-secondary);
+    color: var(--text-normal);
+    border: 1px solid var(--background-modifier-border);
+}
+
+.edit-status-summary.success {
+    background-color: rgba(52, 199, 89, 0.1);
+    color: rgb(52, 199, 89);
+    border: 1px solid rgb(52, 199, 89);
+}
+
+.edit-status-summary.error {
+    background-color: rgba(239, 154, 154, 0.1);
+    color: #d32f2f;
+    border: 1px solid #ef9a9a;
+}
+
+@keyframes fadeIn {
+    from {
+        opacity: 0;
+        transform: translateY(-10px);
+    }
+
+    to {
+        opacity: 1;
+        transform: translateY(0);
+    }
+}
+
+/* Khoj Edit Accordion Styles */
+.khoj-edit-accordion {
+    margin: 1em 0;
+    border: 1px solid var(--background-modifier-border);
+    border-radius: 4px;
+    overflow: hidden;
+    transition: all 0.2s ease;
+}
+
+.khoj-edit-accordion summary {
+    padding: 8px 12px;
+    background-color: var(--background-secondary);
+    cursor: pointer;
+    user-select: none;
+    display: flex;
+    align-items: center;
+    gap: 8px;
+}
+
+/* Success state */
+.khoj-edit-accordion.success {
+    border-color: rgba(52, 199, 89, 0.5);
+    background-color: rgba(52, 199, 89, 0.05);
+}
+
+.khoj-edit-accordion.success summary {
+    background-color: rgba(52, 199, 89, 0.1);
+    color: rgb(39, 149, 67);
+}
+
+.khoj-edit-file {
+    margin-left: auto;
+    font-size: 0.9em;
+    opacity: 0.8;
+    font-style: italic;
+}
+
+/* Error state */
+.khoj-edit-accordion.error {
+    border-color: rgba(239, 68, 68, 0.5);
+    background-color: rgba(239, 68, 68, 0.05);
+}
+
+.khoj-edit-accordion.error summary {
+    background-color: rgba(239, 68, 68, 0.15);
+    color: rgb(185, 28, 28);
+}
+
+/* Hover effects */
+.khoj-edit-accordion.success:hover {
+    border-color: rgba(52, 199, 89, 0.8);
+    background-color: rgba(52, 199, 89, 0.1);
+}
+
+.khoj-edit-accordion.error:hover {
+    border-color: rgba(239, 68, 68, 0.8);
+    background-color: rgba(239, 68, 68, 0.1);
+}
+
+/* Content area */
+.khoj-edit-content {
+    padding: 12px;
+    background-color: var(--background-primary);
+}
+
+.khoj-edit-content pre {
+    margin: 0;
+    padding: 12px;
+    background-color: var(--background-secondary);
+    border-radius: 4px;
+    max-width: 100%;
+    overflow-x: auto;
+    white-space: pre-wrap;
+    font-size: 0.95em;
+}
+
+.khoj-edit-content code.error {
+    color: rgb(185, 28, 28);
+}
+
+/* Animations */
+.khoj-edit-accordion[open] summary {
+    border-bottom: 1px solid var(--background-modifier-border);
+}
+
+.khoj-edit-accordion:not([open]) summary:hover {
+    background-color: var(--background-modifier-hover);
+}
+
+.khoj-edit-accordion[open] .khoj-edit-content {
+    animation: slideDown 0.2s ease-out;
+}
+
+@keyframes slideDown {
+    from {
+        opacity: 0;
+        transform: translateY(-10px);
+    }
+
+    to {
+        opacity: 1;
+        transform: translateY(0);
+    }
+}
+
+/* Khoj Edit Block in Chat */
+.khoj-chat-message-text .khoj-edit-accordion {
+    margin: 0.3em 0;
+    border: 1px solid rgba(147, 112, 219, 0.3);
+    border-radius: 4px;
+    background-color: rgba(147, 112, 219, 0.05);
+}
+
+.khoj-chat-message-text .khoj-edit-accordion summary {
+    cursor: pointer;
+    padding: 0.5em;
+    font-weight: 500;
+    color: var(--text-muted);
+    display: flex;
+    align-items: center;
+    user-select: none;
+}
+
+.khoj-chat-message-text .khoj-edit-accordion summary:hover {
+    background-color: rgba(147, 112, 219, 0.1);
+}
+
+.khoj-chat-message-text .khoj-edit-content {
+    padding: 0.5em;
+    margin: 0;
+    background-color: var(--background-primary);
+    border-top: 1px solid rgba(147, 112, 219, 0.3);
+}
+
+.khoj-chat-message-text .khoj-edit-content pre {
+    margin: 0;
+    padding: 0;
+    background-color: transparent;
+}
+
+.khoj-chat-message-text .khoj-edit-content code {
+    display: block;
+    white-space: pre-wrap;
+    font-family: var(--font-monospace);
+    font-size: 0.95em;
+}
+
+/* Retry badge container */
+.khoj-retry-container {
+    display: flex;
+    justify-content: center;
+    width: 100%;
+    margin-top: 20px;
+    margin-bottom: 12px;
+}
+
+/* Retry badge styles */
+.khoj-retry-badge {
+    display: inline-flex;
+    align-items: center;
+    gap: 8px;
+    background-color: var(--background-modifier-error);
+    color: var(--text-on-accent);
+    padding: 4px 10px;
+    border-radius: 16px;
+    margin: 0;
+    font-size: 0.8em;
+    font-weight: 500;
+    animation: slideIn 0.3s ease-out;
+    box-shadow: 0 3px 6px rgba(0, 0, 0, 0.15);
+    width: fit-content;
+}
+
+.khoj-retry-badge .retry-icon {
+    font-size: 0.9em;
+}
+
+.khoj-retry-badge .retry-count {
+    background: rgba(255, 255, 255, 0.25);
+    padding: 2px 10px;
+    border-radius: 12px;
+    font-size: 0.75em;
+    font-weight: 600;
+    margin-left: auto;
+}
+
+@keyframes slideIn {
+    from {
+        transform: translateY(-10px);
+        opacity: 0;
+    }
+
+    to {
+        transform: translateY(0);
+        opacity: 1;
+    }
+}
+
+/* Improved references */
+div.reference {
+    margin: 0.5em;
+    padding: 0.5em;
+    border: 1px solid var(--background-modifier-border);
+    border-radius: 8px;
+    background-color: var(--background-secondary);
+    transition: all 0.2s ease;
+    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
+}
+
+button.reference-button {
+    background: var(--background-secondary-alt);
+    border: 1px solid var(--background-modifier-border);
+    border-radius: 6px;
+    padding: 8px 12px;
+    margin: 4px 0;
+    width: 100%;
+    text-align: left;
+    cursor: pointer;
+    color: var(--text-normal);
+    font-size: calc(var(--font-ui-small) * 1.05);
+    line-height: 1.4;
+    transition: all 0.2s ease;
+    position: relative;
+    overflow: hidden;
+}
+
+button.reference-button:hover {
+    background-color: var(--background-modifier-hover);
+    transform: translateY(-1px);
+    box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
+}
+
+/* Improved list formatting in messages */
+.khoj-chat-message-text ul li,
+.khoj-chat-message-text ol li {
+    margin-bottom: 8px;
+    line-height: 1.6;
+}
+
+.khoj-chat-message-text ul {
+    list-style-type: disc;
+}
+
+.khoj-chat-message-text ol {
+    list-style-type: decimal;
+}
+
+/* Improved scrollbar */
+.khoj-chat::-webkit-scrollbar {
+    width: 8px;
+}
+
+.khoj-chat::-webkit-scrollbar-track {
+    background: transparent;
+}
+
+.khoj-chat::-webkit-scrollbar-thumb {
+    background-color: var(--scrollbar-thumb-bg);
+    border-radius: 4px;
+}
+
+.khoj-chat::-webkit-scrollbar-thumb:hover {
+    background-color: var(--scrollbar-active-thumb-bg);
+}
+
+/* Improved message spacing */
+.khoj-chat-message {
+    margin-bottom: 8px;
+    max-width: 92%;
+}
+
+.khoj-chat-message.you {
+    margin-left: 8%;
+    margin-right: 0;
+}
+
+.khoj-chat-message.khoj {
+    margin-right: 8%;
+    margin-left: 0;
+}
+
+/* Modification of pre elements to avoid overflow */
+.khoj-chat-message-text pre {
+    max-width: 100%;
+    overflow-x: auto;
+    white-space: pre-wrap;
+    font-size: 0.95em;
+}
+
+.khoj-chat-message-text code {
+    font-size: 0.95em;
+}
+
+/* Selectors for visual elements in messages */
+.khoj-chat-message-text ul,
+.khoj-chat-message-text ol {
+    font-size: 0.95em;
+}
+
+.khoj-chat-message-text h1,
+.khoj-chat-message-text h2,
+.khoj-chat-message-text h3,
+.khoj-chat-message-text h4,
+.khoj-chat-message-text h5,
+.khoj-chat-message-text h6 {
+    font-size: 1.1em;
+    margin: 0.5em 0;
+}
+
+/* Ensures that images stay within the container */
+.khoj-chat-message-text img {
+    max-width: 100%;
+    height: auto;
+}
+
+/* Styles for the mode dropdown */
+.khoj-mode-dropdown {
+    display: block;
+    background-color: var(--background-primary);
+    border: 1px solid var(--background-modifier-border);
+    border-radius: 4px;
+    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+    max-height: 180px;
+    overflow-y: auto;
+    z-index: 1000;
+    font-size: 0.85em;
+    /* Reduced text */
+    padding: 2px 0;
+    transform-origin: bottom;
+}
+
+.khoj-mode-dropdown-option {
+    padding: 4px 8px;
+    cursor: pointer;
+    display: flex;
+    align-items: center;
+    min-height: 22px;
+}
+
+.khoj-mode-dropdown-option:hover {
+    background-color: var(--background-secondary);
+}
+
+.khoj-mode-dropdown-option-selected {
+    background-color: var(--background-modifier-hover);
+    border-left: 3px solid var(--interactive-accent);
+    font-weight: 600;
+    color: var(--text-normal);
+    padding-left: 5px;
+}
+
+.khoj-mode-dropdown-emoji {
+    margin-right: 8px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+}
+
+.khoj-mode-dropdown-emoji svg {
+    width: 16px;
+    height: 16px;
+}
+
+.khoj-mode-dropdown-label {
+    font-weight: 500;
+}
+
+.khoj-mode-dropdown-command {
+    color: var(--text-muted);
+    font-size: 0.8em;
+    margin-left: 2px;
+}
+
+.khoj-similar-content-hidden {
+    display: none;
+}
+
+.khoj-similar-content-visible {
+    display: block;
+}
+
+.khoj-similar-result-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    padding: 8px 0;
+}
+
+.khoj-more-context-button {
+    display: flex;
+    align-items: center;
+    gap: 6px;
+    background-color: var(--background-secondary-alt);
+    color: var(--text-normal);
+    border: 1px solid var(--background-modifier-border);
+    border-radius: 6px;
+    padding: 6px 10px;
+    font-size: 12px;
+    font-weight: 500;
+    cursor: pointer;
+    transition: all 0.2s ease;
+    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+}
+
+.khoj-more-context-button:hover {
+    background-color: var(--background-modifier-hover);
+    color: var(--text-accent);
+    transform: translateY(-2px);
+    box-shadow: 0 3px 6px rgba(0, 0, 0, 0.15);
+}
+
+.khoj-more-context-button svg {
+    width: 16px;
+    height: 16px;
+    transition: transform 0.3s ease;
+}
+
+.khoj-more-context-button:hover svg {
+    transform: rotate(180deg);
+}
+
+.khoj-similar-container {
+    background-color: var(--background-primary);
+    padding: 16px;
+    height: 100%;
+}
+
+.khoj-results-count {
+    font-size: 14px;
+    color: var(--text-muted);
+    margin-bottom: 12px;
+    padding: 6px 10px;
+    background-color: var(--background-secondary-alt);
+    border-radius: 4px;
+    display: inline-block;
+}
+
+.khoj-similar-results-list {
+    max-height: 70vh;
+    overflow-y: auto;
+    display: flex;
+    flex-direction: column;
+    gap: 12px;
+    padding: 4px;
+}
+
+.khoj-similar-result-item {
+    border: 1px solid var(--background-modifier-border);
+    border-radius: 6px;
+    padding: 10px 14px;
+    background-color: var(--background-secondary);
+    transition: all 0.2s ease;
+    cursor: pointer;
+    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
+}
+
+.khoj-similar-result-item:hover {
+    background-color: var(--background-modifier-hover);
+    transform: translateY(-2px);
+    box-shadow: 0 3px 6px rgba(0, 0, 0, 0.1);
+}
+
+.khoj-similar-search-container {
+    display: flex;
+    gap: 8px;
+    margin-bottom: 16px;
+    align-items: center;
+    margin-top: 8px;
+}
+
+.khoj-similar-search-input {
+    flex: 1;
+    padding: 10px 14px;
+    border-radius: 6px;
+    border: 1px solid var(--background-modifier-border);
+    background-color: var(--background-primary);
+    color: var(--text-normal);
+    font-size: 14px;
+    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
+}
+
+.khoj-similar-refresh-button {
+    display: flex;
+    align-items: center;
+    gap: 6px;
+    background-color: var(--background-secondary-alt);
+    color: var(--text-normal);
+    border: 1px solid var(--background-modifier-border);
+    border-radius: 6px;
+    padding: 8px 12px;
+    font-size: 14px;
+    font-weight: 500;
+    cursor: pointer;
+    transition: all 0.2s ease;
+    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+}
+
+.khoj-similar-refresh-button:hover {
+    background-color: var(--background-modifier-hover);
+    color: var(--text-accent);
+    transform: translateY(-2px);
+    box-shadow: 0 3px 6px rgba(0, 0, 0, 0.15);
+}
+
+.khoj-similar-refresh-button svg {
+    width: 16px;
+    height: 16px;
+    transition: transform 0.3s ease;
+}
+
+.khoj-similar-refresh-button:hover svg {
+    transform: rotate(180deg);
+}
+
+.khoj-profile {
+  width: 32px;
+  height: 32px;
+  border-radius: 50%;
+  vertical-align: middle;
+  border: 2px solid var(--interactive-accent);
+  border-radius: 50%;
+}
+
+.khoj-profile-initial {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 20px;
+  font-weight: bold;
+  color: var(--text-accent);
+  background-color: var(--background-primary);
+}
+
+.khoj-connect-settings-header {
+  display: flex;
+  align-items: center;
+  /* Spread items to take available space */
+  justify-content: space-between;
+}
+
+.khoj-connect-settings-header-status {
+    margin-left: 8px;
+    font-size: x-small;
+    vertical-align: middle;
+}
diff --git a/src/interface/obsidian/tsconfig.json b/src/interface/obsidian/tsconfig.json
index 2d6fbdf3..77f3e5ab 100644
--- a/src/interface/obsidian/tsconfig.json
+++ b/src/interface/obsidian/tsconfig.json
@@ -4,18 +4,19 @@
     "inlineSourceMap": true,
     "inlineSources": true,
     "module": "ESNext",
-    "target": "ES6",
+    "target": "ES2018",
     "allowJs": true,
     "noImplicitAny": true,
     "moduleResolution": "node",
     "importHelpers": true,
     "isolatedModules": true,
-	"strictNullChecks": true,
+    "strictNullChecks": true,
     "lib": [
       "DOM",
       "ES5",
       "ES6",
-      "ES7"
+      "ES7",
+      "ES2018"
     ]
   },
   "include": [
diff --git a/src/interface/obsidian/yarn.lock b/src/interface/obsidian/yarn.lock
index 42503905..b44915f3 100644
--- a/src/interface/obsidian/yarn.lock
+++ b/src/interface/obsidian/yarn.lock
@@ -2,17 +2,56 @@
 # yarn lockfile v1
 
 
-"@eslint-community/eslint-utils@^4.4.0":
-  version "4.4.0"
-  resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz#a23514e8fb9af1269d5f7788aa556798d61c6b59"
-  integrity sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==
+"@asamuzakjp/css-color@^3.1.2":
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/@asamuzakjp/css-color/-/css-color-3.2.0.tgz#cc42f5b85c593f79f1fa4f25d2b9b321e61d1794"
+  integrity sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==
   dependencies:
-    eslint-visitor-keys "^3.3.0"
+    "@csstools/css-calc" "^2.1.3"
+    "@csstools/css-color-parser" "^3.0.9"
+    "@csstools/css-parser-algorithms" "^3.0.4"
+    "@csstools/css-tokenizer" "^3.0.3"
+    lru-cache "^10.4.3"
+
+"@csstools/color-helpers@^5.0.2":
+  version "5.0.2"
+  resolved "https://registry.yarnpkg.com/@csstools/color-helpers/-/color-helpers-5.0.2.tgz#82592c9a7c2b83c293d9161894e2a6471feb97b8"
+  integrity sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==
+
+"@csstools/css-calc@^2.1.3":
+  version "2.1.3"
+  resolved "https://registry.yarnpkg.com/@csstools/css-calc/-/css-calc-2.1.3.tgz#6f68affcb569a86b91965e8622d644be35a08423"
+  integrity sha512-XBG3talrhid44BY1x3MHzUx/aTG8+x/Zi57M4aTKK9RFB4aLlF3TTSzfzn8nWVHWL3FgAXAxmupmDd6VWww+pw==
+
+"@csstools/css-color-parser@^3.0.9":
+  version "3.0.9"
+  resolved "https://registry.yarnpkg.com/@csstools/css-color-parser/-/css-color-parser-3.0.9.tgz#8d81b77d6f211495b5100ec4cad4c8828de49f6b"
+  integrity sha512-wILs5Zk7BU86UArYBJTPy/FMPPKVKHMj1ycCEyf3VUptol0JNRLFU/BZsJ4aiIHJEbSLiizzRrw8Pc1uAEDrXw==
+  dependencies:
+    "@csstools/color-helpers" "^5.0.2"
+    "@csstools/css-calc" "^2.1.3"
+
+"@csstools/css-parser-algorithms@^3.0.4":
+  version "3.0.4"
+  resolved "https://registry.yarnpkg.com/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.4.tgz#74426e93bd1c4dcab3e441f5cc7ba4fb35d94356"
+  integrity sha512-Up7rBoV77rv29d3uKHUIVubz1BTcgyUK72IvCQAbfbMv584xHcGKCKbWh7i8hPrRJ7qU4Y8IO3IY9m+iTB7P3A==
+
+"@csstools/css-tokenizer@^3.0.3":
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/@csstools/css-tokenizer/-/css-tokenizer-3.0.3.tgz#a5502c8539265fecbd873c1e395a890339f119c2"
+  integrity sha512-UJnjoFsmxfKUdNYdWgOB0mWUypuLvAfQPH1+pyvRJs6euowbFkFC6P13w1l8mJyi3vxYMxc9kld5jZEGRQs6bw==
+
+"@eslint-community/eslint-utils@^4.4.0":
+  version "4.7.0"
+  resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz#607084630c6c033992a082de6e6fbc1a8b52175a"
+  integrity sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==
+  dependencies:
+    eslint-visitor-keys "^3.4.3"
 
 "@eslint-community/regexpp@^4.10.0":
-  version "4.11.0"
-  resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.11.0.tgz#b0ffd0312b4a3fd2d6f77237e7248a5ad3a680ae"
-  integrity sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==
+  version "4.12.1"
+  resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.1.tgz#cfc6cffe39df390a3841cde2abccf92eaa7ae0e0"
+  integrity sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==
 
 "@nodelib/fs.scandir@2.1.5":
   version "2.1.5"
@@ -42,22 +81,22 @@
   dependencies:
     "@types/tern" "*"
 
-"@types/dompurify@^3.0.5":
-  version "3.0.5"
-  resolved "https://registry.yarnpkg.com/@types/dompurify/-/dompurify-3.0.5.tgz#02069a2fcb89a163bacf1a788f73cb415dd75cb7"
-  integrity sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==
+"@types/dompurify@3.2.0":
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/@types/dompurify/-/dompurify-3.2.0.tgz#56610bf3e4250df57744d61fbd95422e07dfb840"
+  integrity sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==
   dependencies:
-    "@types/trusted-types" "*"
+    dompurify "*"
 
 "@types/estree@*":
-  version "1.0.5"
-  resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4"
-  integrity sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==
+  version "1.0.7"
+  resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.7.tgz#4158d3105276773d5b7695cd4834b1722e4f37a8"
+  integrity sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==
 
 "@types/node@^16.11.6":
-  version "16.18.108"
-  resolved "https://registry.yarnpkg.com/@types/node/-/node-16.18.108.tgz#b794e2b2a85b4c12935ea7d0f18641be68b352f9"
-  integrity sha512-fj42LD82fSv6yN9C6Q4dzS+hujHj+pTv0IpRR3kI20fnYeS0ytBpjFO9OjmDowSPPt4lNKN46JLaKbCyP+BW2A==
+  version "16.18.126"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-16.18.126.tgz#27875faa2926c0f475b39a8bb1e546c0176f8d4b"
+  integrity sha512-OTcgaiwfGFBKacvfwuHzzn1KLxH/er8mluiy8/uM3sGXHaRe73RrSIj01jow9t4kJEW633Ov+cOexXeiApTyAw==
 
 "@types/tern@*":
   version "0.23.9"
@@ -66,7 +105,7 @@
   dependencies:
     "@types/estree" "*"
 
-"@types/trusted-types@*":
+"@types/trusted-types@^2.0.7":
   version "2.0.7"
   resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11"
   integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==
@@ -152,6 +191,11 @@
     "@typescript-eslint/types" "7.13.1"
     eslint-visitor-keys "^3.4.3"
 
+agent-base@^7.1.0, agent-base@^7.1.2:
+  version "7.1.3"
+  resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.3.tgz#29435eb821bc4194633a5b89e5bc4703bafc25a1"
+  integrity sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==
+
 array-union@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d"
@@ -181,12 +225,38 @@ builtin-modules@3.3.0:
   resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.3.0.tgz#cae62812b89801e9656336e46223e030386be7b6"
   integrity sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==
 
-debug@^4.3.4:
-  version "4.3.6"
-  resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.6.tgz#2ab2c38fbaffebf8aa95fdfe6d88438c7a13c52b"
-  integrity sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==
+cssstyle@^4.2.1:
+  version "4.3.1"
+  resolved "https://registry.yarnpkg.com/cssstyle/-/cssstyle-4.3.1.tgz#68a3c9f5a70aa97d5a6ebecc9805e511fc022eb8"
+  integrity sha512-ZgW+Jgdd7i52AaLYCriF8Mxqft0gD/R9i9wi6RWBhs1pqdPEzPjym7rvRKi397WmQFf3SlyUsszhw+VVCbx79Q==
   dependencies:
-    ms "2.1.2"
+    "@asamuzakjp/css-color" "^3.1.2"
+    rrweb-cssom "^0.8.0"
+
+data-urls@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-5.0.0.tgz#2f76906bce1824429ffecb6920f45a0b30f00dde"
+  integrity sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==
+  dependencies:
+    whatwg-mimetype "^4.0.0"
+    whatwg-url "^14.0.0"
+
+debug@4, debug@^4.3.4:
+  version "4.4.1"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.1.tgz#e5a8bc6cbc4c6cd3e64308b0693a3d4fa550189b"
+  integrity sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==
+  dependencies:
+    ms "^2.1.3"
+
+decimal.js@^10.5.0:
+  version "10.5.0"
+  resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.5.0.tgz#0f371c7cf6c4898ce0afb09836db73cd82010f22"
+  integrity sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==
+
+diff@^8.0.2:
+  version "8.0.2"
+  resolved "https://registry.yarnpkg.com/diff/-/diff-8.0.2.tgz#712156a6dd288e66ebb986864e190c2fc9eddfae"
+  integrity sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==
 
 dir-glob@^3.0.1:
   version "3.0.1"
@@ -195,10 +265,17 @@ dir-glob@^3.0.1:
   dependencies:
     path-type "^4.0.0"
 
-dompurify@^3.1.4:
-  version "3.1.6"
-  resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.1.6.tgz#43c714a94c6a7b8801850f82e756685300a027e2"
-  integrity sha512-cTOAhc36AalkjtBpfG6O8JimdTMWNXjiePT2xQH/ppBGi/4uIpmj8eKyIkMJErXWARyINV/sB38yf8JCLF5pbQ==
+dompurify@*, dompurify@^3.2.6:
+  version "3.2.6"
+  resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.2.6.tgz#ca040a6ad2b88e2a92dc45f38c79f84a714a1cad"
+  integrity sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==
+  optionalDependencies:
+    "@types/trusted-types" "^2.0.7"
+
+entities@^6.0.0:
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/entities/-/entities-6.0.0.tgz#09c9e29cb79b0a6459a9b9db9efb418ac5bb8e51"
+  integrity sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==
 
 esbuild-android-64@0.14.47:
   version "0.14.47"
@@ -326,26 +403,26 @@ esbuild@0.14.47:
     esbuild-windows-64 "0.14.47"
     esbuild-windows-arm64 "0.14.47"
 
-eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.3:
+eslint-visitor-keys@^3.4.3:
   version "3.4.3"
   resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800"
   integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==
 
 fast-glob@^3.2.9:
-  version "3.3.2"
-  resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.2.tgz#a904501e57cfdd2ffcded45e99a54fef55e46129"
-  integrity sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==
+  version "3.3.3"
+  resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.3.tgz#d06d585ce8dba90a16b0505c543c3ccfb3aeb818"
+  integrity sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==
   dependencies:
     "@nodelib/fs.stat" "^2.0.2"
     "@nodelib/fs.walk" "^1.2.3"
     glob-parent "^5.1.2"
     merge2 "^1.3.0"
-    micromatch "^4.0.4"
+    micromatch "^4.0.8"
 
 fastq@^1.6.0:
-  version "1.17.1"
-  resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.17.1.tgz#2a523f07a4e7b1e81a42b91b8bf2254107753b47"
-  integrity sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==
+  version "1.19.1"
+  resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.19.1.tgz#d50eaba803c8846a883c16492821ebcd2cda55f5"
+  integrity sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==
   dependencies:
     reusify "^1.0.4"
 
@@ -380,6 +457,36 @@ graphemer@^1.4.0:
   resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6"
   integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==
 
+html-encoding-sniffer@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz#696df529a7cfd82446369dc5193e590a3735b448"
+  integrity sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==
+  dependencies:
+    whatwg-encoding "^3.1.1"
+
+http-proxy-agent@^7.0.2:
+  version "7.0.2"
+  resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz#9a8b1f246866c028509486585f62b8f2c18c270e"
+  integrity sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==
+  dependencies:
+    agent-base "^7.1.0"
+    debug "^4.3.4"
+
+https-proxy-agent@^7.0.6:
+  version "7.0.6"
+  resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz#da8dfeac7da130b05c2ba4b59c9b6cd66611a6b9"
+  integrity sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==
+  dependencies:
+    agent-base "^7.1.2"
+    debug "4"
+
+iconv-lite@0.6.3:
+  version "0.6.3"
+  resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501"
+  integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==
+  dependencies:
+    safer-buffer ">= 2.1.2 < 3.0.0"
+
 ignore@^5.2.0, ignore@^5.3.1:
   version "5.3.2"
   resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5"
@@ -402,12 +509,56 @@ is-number@^7.0.0:
   resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b"
   integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==
 
+is-potential-custom-element-name@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz#171ed6f19e3ac554394edf78caa05784a45bebb5"
+  integrity sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==
+
+isomorphic-dompurify@^2.25.0:
+  version "2.25.0"
+  resolved "https://registry.yarnpkg.com/isomorphic-dompurify/-/isomorphic-dompurify-2.25.0.tgz#063e3ea7399bc1146783a9527be6c10baa25dc15"
+  integrity sha512-bcpJzu9DOjN21qaCVpcoCwUX1ytpvA6EFqCK5RNtPg5+F0Jz9PX50jl6jbEicBNeO87eDDfC7XtPs4zjDClZJg==
+  dependencies:
+    dompurify "^3.2.6"
+    jsdom "^26.1.0"
+
+jsdom@^26.1.0:
+  version "26.1.0"
+  resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-26.1.0.tgz#ab5f1c1cafc04bd878725490974ea5e8bf0c72b3"
+  integrity sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==
+  dependencies:
+    cssstyle "^4.2.1"
+    data-urls "^5.0.0"
+    decimal.js "^10.5.0"
+    html-encoding-sniffer "^4.0.0"
+    http-proxy-agent "^7.0.2"
+    https-proxy-agent "^7.0.6"
+    is-potential-custom-element-name "^1.0.1"
+    nwsapi "^2.2.16"
+    parse5 "^7.2.1"
+    rrweb-cssom "^0.8.0"
+    saxes "^6.0.0"
+    symbol-tree "^3.2.4"
+    tough-cookie "^5.1.1"
+    w3c-xmlserializer "^5.0.0"
+    webidl-conversions "^7.0.0"
+    whatwg-encoding "^3.1.1"
+    whatwg-mimetype "^4.0.0"
+    whatwg-url "^14.1.1"
+    ws "^8.18.0"
+    xml-name-validator "^5.0.0"
+
+lru-cache@^10.4.3:
+  version "10.4.3"
+  resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119"
+  integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==
+
 merge2@^1.3.0, merge2@^1.4.1:
   version "1.4.1"
   resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae"
   integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
 
-micromatch@^4.0.4:
+micromatch@^4.0.8:
   version "4.0.8"
   resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202"
   integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==
@@ -427,24 +578,36 @@ moment@2.29.4:
   resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.4.tgz#3dbe052889fe7c1b2ed966fcb3a77328964ef108"
   integrity sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==
 
-ms@2.1.2:
-  version "2.1.2"
-  resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
-  integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
+ms@^2.1.3:
+  version "2.1.3"
+  resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
+  integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
 
 natural-compare@^1.4.0:
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
   integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
 
+nwsapi@^2.2.16:
+  version "2.2.20"
+  resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.20.tgz#22e53253c61e7b0e7e93cef42c891154bcca11ef"
+  integrity sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==
+
 obsidian@^1.6.6:
-  version "1.6.6"
-  resolved "https://registry.yarnpkg.com/obsidian/-/obsidian-1.6.6.tgz#d45c4021c291765e1b77ed4a1c645e562ff6e77f"
-  integrity sha512-GZHzeOiwmw/wBjB5JwrsxAZBLqxGQmqtEKSvJJvT0LtTcqeOFnV8jv0ZK5kO7hBb44WxJc+LdS7mZgLXbb+qXQ==
+  version "1.8.7"
+  resolved "https://registry.yarnpkg.com/obsidian/-/obsidian-1.8.7.tgz#601e9ea1724289effa4c9bb3b4e20d327263634f"
+  integrity sha512-h4bWwNFAGRXlMlMAzdEiIM2ppTGlrh7uGOJS6w4gClrsjc+ei/3YAtU2VdFUlCiPuTHpY4aBpFJJW75S1Tl/JA==
   dependencies:
     "@types/codemirror" "5.60.8"
     moment "2.29.4"
 
+parse5@^7.2.1:
+  version "7.3.0"
+  resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.3.0.tgz#d7e224fa72399c7a175099f45fc2ad024b05ec05"
+  integrity sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==
+  dependencies:
+    entities "^6.0.0"
+
 path-type@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b"
@@ -455,15 +618,25 @@ picomatch@^2.3.1:
   resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
   integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
 
+punycode@^2.3.1:
+  version "2.3.1"
+  resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
+  integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
+
 queue-microtask@^1.2.2:
   version "1.2.3"
   resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
   integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
 
 reusify@^1.0.4:
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76"
-  integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.1.0.tgz#0fe13b9522e1473f51b558ee796e08f11f9b489f"
+  integrity sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==
+
+rrweb-cssom@^0.8.0:
+  version "0.8.0"
+  resolved "https://registry.yarnpkg.com/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz#3021d1b4352fbf3b614aaeed0bc0d5739abe0bc2"
+  integrity sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==
 
 run-parallel@^1.1.9:
   version "1.2.0"
@@ -472,16 +645,45 @@ run-parallel@^1.1.9:
   dependencies:
     queue-microtask "^1.2.2"
 
+"safer-buffer@>= 2.1.2 < 3.0.0":
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
+  integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
+
+saxes@^6.0.0:
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/saxes/-/saxes-6.0.0.tgz#fe5b4a4768df4f14a201b1ba6a65c1f3d9988cc5"
+  integrity sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==
+  dependencies:
+    xmlchars "^2.2.0"
+
 semver@^7.6.0:
-  version "7.6.3"
-  resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.3.tgz#980f7b5550bc175fb4dc09403085627f9eb33143"
-  integrity sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==
+  version "7.7.2"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58"
+  integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==
 
 slash@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634"
   integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==
 
+symbol-tree@^3.2.4:
+  version "3.2.4"
+  resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2"
+  integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==
+
+tldts-core@^6.1.86:
+  version "6.1.86"
+  resolved "https://registry.yarnpkg.com/tldts-core/-/tldts-core-6.1.86.tgz#a93e6ed9d505cb54c542ce43feb14c73913265d8"
+  integrity sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==
+
+tldts@^6.1.32:
+  version "6.1.86"
+  resolved "https://registry.yarnpkg.com/tldts/-/tldts-6.1.86.tgz#087e0555b31b9725ee48ca7e77edc56115cd82f7"
+  integrity sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==
+  dependencies:
+    tldts-core "^6.1.86"
+
 to-regex-range@^5.0.1:
   version "5.0.1"
   resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4"
@@ -489,10 +691,24 @@ to-regex-range@^5.0.1:
   dependencies:
     is-number "^7.0.0"
 
+tough-cookie@^5.1.1:
+  version "5.1.2"
+  resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-5.1.2.tgz#66d774b4a1d9e12dc75089725af3ac75ec31bed7"
+  integrity sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==
+  dependencies:
+    tldts "^6.1.32"
+
+tr46@^5.1.0:
+  version "5.1.1"
+  resolved "https://registry.yarnpkg.com/tr46/-/tr46-5.1.1.tgz#96ae867cddb8fdb64a49cc3059a8d428bcf238ca"
+  integrity sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==
+  dependencies:
+    punycode "^2.3.1"
+
 ts-api-utils@^1.3.0:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.3.0.tgz#4b490e27129f1e8e686b45cc4ab63714dc60eea1"
-  integrity sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==
+  version "1.4.3"
+  resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.4.3.tgz#bfc2215fe6528fecab2b0fba570a2e8a4263b064"
+  integrity sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==
 
 tslib@2.4.0:
   version "2.4.0"
@@ -503,3 +719,50 @@ typescript@4.7.4:
   version "4.7.4"
   resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.7.4.tgz#1a88596d1cf47d59507a1bcdfb5b9dfe4d488235"
   integrity sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==
+
+w3c-xmlserializer@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz#f925ba26855158594d907313cedd1476c5967f6c"
+  integrity sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==
+  dependencies:
+    xml-name-validator "^5.0.0"
+
+webidl-conversions@^7.0.0:
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz#256b4e1882be7debbf01d05f0aa2039778ea080a"
+  integrity sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==
+
+whatwg-encoding@^3.1.1:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz#d0f4ef769905d426e1688f3e34381a99b60b76e5"
+  integrity sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==
+  dependencies:
+    iconv-lite "0.6.3"
+
+whatwg-mimetype@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz#bc1bf94a985dc50388d54a9258ac405c3ca2fc0a"
+  integrity sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==
+
+whatwg-url@^14.0.0, whatwg-url@^14.1.1:
+  version "14.2.0"
+  resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-14.2.0.tgz#4ee02d5d725155dae004f6ae95c73e7ef5d95663"
+  integrity sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==
+  dependencies:
+    tr46 "^5.1.0"
+    webidl-conversions "^7.0.0"
+
+ws@^8.18.0:
+  version "8.18.2"
+  resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.2.tgz#42738b2be57ced85f46154320aabb51ab003705a"
+  integrity sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==
+
+xml-name-validator@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz#82be9b957f7afdacf961e5980f1bf227c0bf7673"
+  integrity sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==
+
+xmlchars@^2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb"
+  integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==