From dbfac89a0cf0223b85743579c138400d7e975073 Mon Sep 17 00:00:00 2001 From: Henri Jamet <42291955+hjamet@users.noreply.github.com> Date: Sun, 1 Jun 2025 07:12:36 +0200 Subject: [PATCH] Major updates to Obsidian Khoj plugin chat interface and editing features (#1109) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description This PR introduces significant improvements to the Obsidian Khoj plugin's chat interface and editing capabilities, enhancing the overall user experience and content management functionality. ## Features ### πŸ” Enhanced Communication Mode I've implemented radio buttons below the chat window for easier communication mode selection. The modes are now displayed as emojis in the conversation for a cleaner interface, replacing the previous text-based system (e.g., /default, /research). I've also documented the search mode functionality in the help command. #### Screenshots - Radio buttons for mode selection - Emoji display in conversations ![Recording 2025-02-11 at 18 56 10](https://github.com/user-attachments/assets/798d15df-ad32-45bd-b03f-581f6093575a) ### πŸ’¬ Revamped Message Interaction I've redesigned the message buttons with improved spacing and color coding for better visual differentiation. The new edit button allows quick message modifications - clicking it removes the conversation up to that point and copies the message to the input field for easy editing or retrying questions. #### Screenshots - New message styling and color scheme ![Recording 2025-02-11 at 18 44 48](https://github.com/user-attachments/assets/159ece3d-2d80-4583-a7a8-2ef1f253adcc) - Edit button functionality ![Recording 2025-02-11 at 18 47 52](https://github.com/user-attachments/assets/82ee7221-bc49-4088-9a98-744ef74d1e58) ### πŸ€– Advanced Agent Selection System I've added a new chat creation button with agent selection capability. Users can now choose from their available agents when starting a new chat. While agents can't be switched mid-conversation to maintain context, users can easily start fresh conversations with different agents. #### Screenshots - Agent selection dropdown ![Recording 2025-02-11 at 18 51 27](https://github.com/user-attachments/assets/be4208df-224c-45bf-a5b4-cf0a8068b102) ### πŸ‘οΈ Real-Time Context Awareness I've added a button that gives Khoj access to read Obsidian opened tabs. This allows Khoj to read open notes and track changes in real-time, maintaining a history of previous versions to provide more contextual assistance. #### Screenshots - Window access toggle ![Recording 2025-02-11 at 18 59 01](https://github.com/user-attachments/assets/b596bfca-f622-41b7-b826-25a8e254d4a2) ### ✏️ Smart Document Editing Inspired by Cursor IDE's intelligent editing and ChatGPT's Canvas functionality, I've implemented a first version of a content creation system we've been discussing. Using a JSON-based modification system, Khoj can now make precise changes to specific parts of files, with changes previewed in yellow highlighting before application. Modification code blocks are neatly organized in collapsible sections with clear action summaries. While this is just a first step, it's working remarkably well and I have several ideas for expanding this functionality to make Khoj an even more powerful content creation assistant. #### Screenshots - JSON modification preview - Change highlighting system - Collapsible code blocks - Accept/cancel controls ![Recording 2025-02-11 at 19 02 32](https://github.com/user-attachments/assets/88826c9e-d0c9-40da-ab78-9976c786aa9e) --------- Co-authored-by: Debanjum --- src/interface/obsidian/.gitignore | 6 + src/interface/obsidian/package.json | 5 +- src/interface/obsidian/src/chat_view.ts | 1265 +++++++++++++-- .../obsidian/src/interact_with_files.ts | 976 +++++++++++ src/interface/obsidian/src/main.ts | 149 +- src/interface/obsidian/src/pane_view.ts | 18 +- src/interface/obsidian/src/settings.ts | 257 ++- src/interface/obsidian/src/similar_view.ts | 434 +++++ src/interface/obsidian/src/utils.ts | 233 ++- src/interface/obsidian/styles.css | 1422 +++++++++++++++-- src/interface/obsidian/tsconfig.json | 7 +- src/interface/obsidian/yarn.lock | 371 ++++- 12 files changed, 4697 insertions(+), 446 deletions(-) create mode 100644 src/interface/obsidian/src/interact_with_files.ts create mode 100644 src/interface/obsidian/src/similar_view.ts 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==