diff --git a/src/interface/obsidian/src/chat_view.ts b/src/interface/obsidian/src/chat_view.ts index 9549969e..4350a608 100644 --- a/src/interface/obsidian/src/chat_view.ts +++ b/src/interface/obsidian/src/chat_view.ts @@ -10,17 +10,59 @@ export interface ChatJsonResult { inferredQueries?: string[]; } +interface WebSocketState { + newResponseTextEl: HTMLElement | null, + newResponseEl: HTMLElement | null, + loadingEllipsis: HTMLElement | null, + references: object, + rawResponse: string, +} -export class KhojChatView extends KhojPaneView { - result: string; - setting: KhojSetting; +interface Location { region: string; city: string; countryName: string; timezone: string; +} + +export class KhojChatView extends KhojPaneView { + result: string; + setting: KhojSetting; + waitingForLocation: boolean; + websocket: WebSocket; + websocketState: WebSocketState; + location: Location; constructor(leaf: WorkspaceLeaf, setting: KhojSetting) { super(leaf, setting); + + this.waitingForLocation = true; + this.websocketState = { + newResponseTextEl: null, + newResponseEl: null, + loadingEllipsis: null, + references: {}, + rawResponse: "", + }; + + fetch("https://ipapi.co/json") + .then(response => response.json()) + .then(data => { + this.location = { + region: data.region, + city: data.city, + countryName: data.country_name, + timezone: data.timezone, + }; + }) + .catch(err => { + console.log(err); + }) + .finally(() => { + this.waitingForLocation = false; + this.setupWebSocket(); + }); + } getViewType(): string { @@ -36,6 +78,11 @@ export class KhojChatView extends KhojPaneView { } async chat() { + if (this.websocket?.readyState === WebSocket.OPEN){ + this.sendMessageViaWebSocket(); + return; + } + // Get text in chat input element let input_el = this.contentEl.getElementsByClassName("khoj-chat-input")[0]; @@ -119,6 +166,93 @@ export class KhojChatView extends KhojPaneView { }); } + processOnlineReferences(referenceSection: HTMLElement, onlineContext: any) { + let numOnlineReferences = 0; + for (let subquery in onlineContext) { + let onlineReference = onlineContext[subquery]; + if (onlineReference.organic && onlineReference.organic.length > 0) { + numOnlineReferences += onlineReference.organic.length; + for (let index in onlineReference.organic) { + let reference = onlineReference.organic[index]; + let polishedReference = this.generateOnlineReference(referenceSection, reference, index); + referenceSection.appendChild(polishedReference); + } + } + + if (onlineReference.knowledgeGraph && onlineReference.knowledgeGraph.length > 0) { + numOnlineReferences += onlineReference.knowledgeGraph.length; + for (let index in onlineReference.knowledgeGraph) { + let reference = onlineReference.knowledgeGraph[index]; + let polishedReference = this.generateOnlineReference(referenceSection, reference, index); + referenceSection.appendChild(polishedReference); + } + } + + if (onlineReference.peopleAlsoAsk && onlineReference.peopleAlsoAsk.length > 0) { + numOnlineReferences += onlineReference.peopleAlsoAsk.length; + for (let index in onlineReference.peopleAlsoAsk) { + let reference = onlineReference.peopleAlsoAsk[index]; + let polishedReference = this.generateOnlineReference(referenceSection, reference, index); + referenceSection.appendChild(polishedReference); + } + } + + if (onlineReference.webpages && onlineReference.webpages.length > 0) { + numOnlineReferences += onlineReference.webpages.length; + for (let index in onlineReference.webpages) { + let reference = onlineReference.webpages[index]; + let polishedReference = this.generateOnlineReference(referenceSection, reference, index); + referenceSection.appendChild(polishedReference); + } + } + } + + return numOnlineReferences; + } + + generateOnlineReference(messageEl: Element, reference: any, index: string) { + // Generate HTML for Chat Reference + let title = reference.title || reference.link; + let link = reference.link; + let snippet = reference.snippet; + let question = reference.question; + if (question) { + question = `Question: ${question}

`; + } else { + question = ""; + } + + let linkElement = messageEl.createEl('a'); + linkElement.setAttribute('href', link); + linkElement.setAttribute('target', '_blank'); + linkElement.setAttribute('rel', 'noopener noreferrer'); + linkElement.classList.add("reference-link"); + linkElement.setAttribute('title', title); + linkElement.textContent = title; + + let referenceButton = messageEl.createEl('button'); + referenceButton.innerHTML = linkElement.outerHTML; + referenceButton.id = `ref-${index}`; + referenceButton.classList.add("reference-button"); + referenceButton.classList.add("collapsed"); + referenceButton.tabIndex = 0; + + // Add event listener to toggle full reference on click + referenceButton.addEventListener('click', function() { + if (this.classList.contains("collapsed")) { + this.classList.remove("collapsed"); + this.classList.add("expanded"); + this.innerHTML = linkElement.outerHTML + `

${question + snippet}`; + } else { + this.classList.add("collapsed"); + this.classList.remove("expanded"); + this.innerHTML = linkElement.outerHTML; + } + }); + + return referenceButton; + } + generateReference(messageEl: Element, reference: string, index: number) { // Escape reference for HTML rendering let escaped_ref = reference.replace(/"/g, """) @@ -150,6 +284,47 @@ export class KhojChatView extends KhojPaneView { return referenceButton; } + formatHTMLMessage(message: string, raw=false, willReplace=true) { + let rendered_msg = message; + + // Replace LaTeX delimiters with placeholders + rendered_msg = rendered_msg.replace(/\\\(/g, 'LEFTPAREN').replace(/\\\)/g, 'RIGHTPAREN') + .replace(/\\\[/g, 'LEFTBRACKET').replace(/\\\]/g, 'RIGHTBRACKET'); + + // Remove any text between [INST] and tags. These are spurious instructions for the AI chat model. + rendered_msg = rendered_msg.replace(/\[INST\].+(<\/s>)?/g, ''); + + // Render markdow to HTML DOM element + let chat_message_body_text_el = this.contentEl.createDiv(); + chat_message_body_text_el.className = "chat-message-text-response"; + MarkdownRenderer.renderMarkdown(message, chat_message_body_text_el, '', null); + + // Replace placeholders with LaTeX delimiters + rendered_msg = chat_message_body_text_el.innerHTML; + chat_message_body_text_el.innerHTML = rendered_msg.replace(/LEFTPAREN/g, '\\(').replace(/RIGHTPAREN/g, '\\)') + .replace(/LEFTBRACKET/g, '\\[').replace(/RIGHTBRACKET/g, '\\]'); + + // Add a copy button to each chat message, if it doesn't already exist + if (willReplace === true) { + let copyButton = this.contentEl.createEl('button'); + copyButton.classList.add("copy-button"); + copyButton.title = "Copy Message to Clipboard"; + setIcon(copyButton, "copy-plus"); + copyButton.addEventListener('click', createCopyParentText(message)); + chat_message_body_text_el.append(copyButton); + + // Add button to paste into current buffer + let pasteToFile = this.contentEl.createEl('button'); + pasteToFile.classList.add("copy-button"); + pasteToFile.title = "Paste Message to File"; + setIcon(pasteToFile, "clipboard-paste"); + pasteToFile.addEventListener('click', (event) => { pasteTextAtCursor(createCopyParentText(message, 'clipboard-paste')(event)); }); + chat_message_body_text_el.append(pasteToFile); + } + + return chat_message_body_text_el; + } + renderMessageWithReferences(chatEl: Element, message: string, sender: string, context?: string[], dt?: Date, intentType?: string, inferredQueries?: string) { if (!message) { return; @@ -283,7 +458,7 @@ export class KhojChatView extends KhojPaneView { // Scroll to bottom after inserting chat messages this.scrollChatToBottom(); - return chat_message_el + return chat_message_el; } async renderIncrementalMessage(htmlElement: HTMLDivElement, additionalMessage: string) { @@ -302,9 +477,13 @@ export class KhojChatView extends KhojPaneView { return `${time_string}, ${date_string}`; } - async getChatHistory(chatBodyEl: Element): Promise { + async getChatHistory(chatBodyEl: HTMLElement): Promise { // Get chat history from Khoj backend let chatUrl = `${this.setting.khojUrl}/api/chat/history?client=obsidian`; + if (chatBodyEl.dataset.conversationId) { + chatUrl += `&conversation_id=${chatBodyEl.dataset.conversationId}`; + this.setupWebSocket(); + } try { let response = await fetch(chatUrl, { @@ -313,6 +492,7 @@ export class KhojChatView extends KhojPaneView { }); let responseJson: any = await response.json(); + chatBodyEl.dataset.conversationId = responseJson.conversation_id; if (responseJson.detail) { // If the server returns error details in response, render a setup hint. @@ -321,6 +501,12 @@ export class KhojChatView extends KhojPaneView { return false; } else if (responseJson.response) { + // Render conversation history, if any + chatBodyEl.dataset.conversationId = responseJson.response.conversation_id; + this.setupWebSocket(); + chatBodyEl.dataset.conversationTitle = responseJson.response.slug || `New conversation 🌱`; + + let chatLogs = responseJson.response?.conversation_id ? responseJson.response.chat ?? [] : responseJson.response; chatLogs.forEach((chatLog: any) => { this.renderMessageWithReferences( @@ -409,17 +595,30 @@ export class KhojChatView extends KhojPaneView { if (!query || query === "") return; // Render user query as chat message - let chatBodyEl = this.contentEl.getElementsByClassName("khoj-chat-body")[0]; + let chatBodyEl = this.contentEl.getElementsByClassName("khoj-chat-body")[0] as HTMLElement; this.renderMessage(chatBodyEl, query, "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; + } + // Get chat response from Khoj backend let encodedQuery = encodeURIComponent(query); - let chatUrl = `${this.setting.khojUrl}/api/chat?q=${encodedQuery}&n=${this.setting.resultsCount}&client=obsidian&stream=true®ion=${this.region}&city=${this.city}&country=${this.countryName}&timezone=${this.timezone}`; + let chatUrl = `${this.setting.khojUrl}/api/chat?q=${encodedQuery}&n=${this.setting.resultsCount}&client=obsidian&stream=true®ion=${this.location.region}&city=${this.location.city}&country=${this.location.countryName}&timezone=${this.location.timezone}`; let responseElement = this.createKhojResponseDiv(); // Temporary status message to indicate that Khoj is thinking this.result = ""; - await this.renderIncrementalMessage(responseElement, "🤔"); + let loadingEllipsis = this.createLoadingEllipse(); + responseElement.appendChild(loadingEllipsis); let response = await fetch(chatUrl, { method: "GET", @@ -434,9 +633,9 @@ export class KhojChatView extends KhojPaneView { throw new Error("Response body is null"); } - // Clear thinking status message - if (responseElement.innerHTML === "🤔") { - responseElement.innerHTML = ""; + // Clear loading status message + if (responseElement.getElementsByClassName("lds-ellipsis").length > 0 && loadingEllipsis) { + responseElement.removeChild(loadingEllipsis); } // Reset collated chat result to empty string @@ -492,7 +691,7 @@ export class KhojChatView extends KhojPaneView { } async clearConversationHistory() { - let chatBody = this.contentEl.getElementsByClassName("khoj-chat-body")[0]; + let chatBody = this.contentEl.getElementsByClassName("khoj-chat-body")[0] as HTMLElement; let response = await request({ url: `${this.setting.khojUrl}/api/chat/history?client=obsidian`, @@ -659,4 +858,312 @@ export class KhojChatView extends KhojPaneView { const chat_body_el = this.contentEl.getElementsByClassName("khoj-chat-body")[0]; if (!!chat_body_el) chat_body_el.scrollTop = chat_body_el.scrollHeight; } + + createLoadingEllipse() { + // Temporary status message to indicate that Khoj is thinking + let loadingEllipsis = this.contentEl.createEl("div"); + loadingEllipsis.classList.add("lds-ellipsis"); + + let firstEllipsis = this.contentEl.createEl("div"); + firstEllipsis.classList.add("lds-ellipsis-item"); + + let secondEllipsis = this.contentEl.createEl("div"); + secondEllipsis.classList.add("lds-ellipsis-item"); + + let thirdEllipsis = this.contentEl.createEl("div"); + thirdEllipsis.classList.add("lds-ellipsis-item"); + + let fourthEllipsis = this.contentEl.createEl("div"); + fourthEllipsis.classList.add("lds-ellipsis-item"); + + loadingEllipsis.appendChild(firstEllipsis); + loadingEllipsis.appendChild(secondEllipsis); + loadingEllipsis.appendChild(thirdEllipsis); + loadingEllipsis.appendChild(fourthEllipsis); + + return loadingEllipsis; + } + + handleStreamResponse(newResponseElement: HTMLElement | null, rawResponse: string, loadingEllipsis: HTMLElement | null, replace=true) { + if (!newResponseElement) return; + if (newResponseElement.getElementsByClassName("lds-ellipsis").length > 0 && loadingEllipsis) { + newResponseElement.removeChild(loadingEllipsis); + } + if (replace) { + newResponseElement.innerHTML = ""; + } + newResponseElement.appendChild(this.formatHTMLMessage(rawResponse, false, replace)); + this.scrollChatToBottom(); + } + + handleCompiledReferences(rawResponseElement: HTMLElement | null, chunk: string, references: any, rawResponse: string) { + if (!rawResponseElement || !chunk) return { rawResponse, references }; + const additionalResponse = chunk.split("### compiled references:")[0]; + rawResponse += additionalResponse; + rawResponseElement.innerHTML = ""; + rawResponseElement.appendChild(this.formatHTMLMessage(rawResponse)); + + const rawReference = chunk.split("### compiled references:")[1]; + const rawReferenceAsJson = JSON.parse(rawReference); + if (rawReferenceAsJson instanceof Array) { + references["notes"] = rawReferenceAsJson; + } else if (typeof rawReferenceAsJson === "object" && rawReferenceAsJson !== null) { + references["online"] = rawReferenceAsJson; + } + return { rawResponse, references }; + } + + handleImageResponse(imageJson: any, rawResponse: string) { + if (imageJson.image) { + const inferredQuery = imageJson.inferredQueries?.[0] ?? "generated image"; + + // If response has image field, response is a generated image. + if (imageJson.intentType === "text-to-image") { + rawResponse += `![generated_image](data:image/png;base64,${imageJson.image})`; + } else if (imageJson.intentType === "text-to-image2") { + rawResponse += `![generated_image](${imageJson.image})`; + } else if (imageJson.intentType === "text-to-image-v3") { + rawResponse = `![](data:image/webp;base64,${imageJson.image})`; + } + if (inferredQuery) { + rawResponse += `\n\n**Inferred Query**:\n\n${inferredQuery}`; + } + } + let references: any = {}; + if (imageJson.context && imageJson.context.length > 0) { + const rawReferenceAsJson = imageJson.context; + if (rawReferenceAsJson instanceof Array) { + references["notes"] = rawReferenceAsJson; + } else if (typeof rawReferenceAsJson === "object" && rawReferenceAsJson !== null) { + references["online"] = rawReferenceAsJson; + } + } + if (imageJson.detail) { + // If response has detail field, response is an error message. + rawResponse += imageJson.detail; + } + return { rawResponse, references }; + } + + addMessageToChatBody(rawResponse: string, newResponseElement: HTMLElement | null, references: any) { + if (!newResponseElement) return; + newResponseElement.innerHTML = ""; + newResponseElement.appendChild(this.formatHTMLMessage(rawResponse)); + + this.finalizeChatBodyResponse(references, newResponseElement); + } + + finalizeChatBodyResponse(references: object, newResponseElement: HTMLElement | null) { + if (!!newResponseElement && references != null && Object.keys(references).length > 0) { + newResponseElement.appendChild(this.createReferenceSection(references)); + } + this.scrollChatToBottom(); + let chatInput = this.contentEl.getElementsByClassName("khoj-chat-input")[0]; + if (chatInput) chatInput.removeAttribute("disabled"); + } + + createReferenceSection(references: any) { + let referenceSection = this.contentEl.createEl('div'); + referenceSection.classList.add("reference-section"); + referenceSection.classList.add("collapsed"); + + let numReferences = 0; + + if (references.hasOwnProperty("notes")) { + numReferences += references["notes"].length; + + references["notes"].forEach((reference: any, index: number) => { + let polishedReference = this.generateReference(referenceSection, reference, index); + referenceSection.appendChild(polishedReference); + }); + } + if (references.hasOwnProperty("online")) { + numReferences += this.processOnlineReferences(referenceSection, references["online"]); + } + + let referenceExpandButton = this.contentEl.createEl('button'); + referenceExpandButton.classList.add("reference-expand-button"); + referenceExpandButton.innerHTML = numReferences == 1 ? "1 reference" : `${numReferences} references`; + + referenceExpandButton.addEventListener('click', function() { + if (referenceSection.classList.contains("collapsed")) { + referenceSection.classList.remove("collapsed"); + referenceSection.classList.add("expanded"); + } else { + referenceSection.classList.add("collapsed"); + referenceSection.classList.remove("expanded"); + } + }); + + let referencesDiv = this.contentEl.createEl('div'); + referencesDiv.classList.add("references"); + referencesDiv.appendChild(referenceExpandButton); + referencesDiv.appendChild(referenceSection); + + return referencesDiv; + } + + setupWebSocket() { + let chatBody = this.contentEl.getElementsByClassName("khoj-chat-body")[0] as HTMLElement; + let wsProtocol = this.setting.khojUrl.startsWith('https:') ? 'wss:' : 'ws:'; + let baseUrl = this.setting.khojUrl.replace(/^https?:\/\//, ''); + let webSocketUrl = `${wsProtocol}//${baseUrl}/api/chat/ws`; + + if (this.waitingForLocation) { + console.debug("Waiting for location data to be fetched. Will setup WebSocket once location data is available."); + return; + } + if (!chatBody) return; + + this.websocketState = { + newResponseTextEl: null, + newResponseEl: null, + loadingEllipsis: null, + references: {}, + rawResponse: "", + } + + if (chatBody.dataset.conversationId) { + webSocketUrl += `?conversation_id=${chatBody.dataset.conversationId}`; + webSocketUrl += !!this.location ? `®ion=${this.location.region}&city=${this.location.city}&country=${this.location.countryName}&timezone=${this.location.timezone}` : ''; + + this.websocket = new WebSocket(webSocketUrl); + this.websocket.onmessage = (event) => { + // Get the last element in the chat-body + let chunk = event.data; + if (chunk == "start_llm_response") { + console.log("Started streaming", new Date()); + } else if(chunk == "end_llm_response") { + console.log("Stopped streaming", new Date()); + // Append any references after all the data has been streamed + this.finalizeChatBodyResponse(this.websocketState.references, this.websocketState.newResponseTextEl); + + // Reset variables + this.websocketState = { + newResponseTextEl: null, + newResponseEl: null, + loadingEllipsis: null, + references: {}, + rawResponse: "", + } + } else { + try { + if (chunk.includes("application/json")) { + chunk = JSON.parse(chunk); + } + } catch (error) { + // If the chunk is not a JSON object, continue. + } + + const contentType = chunk["content-type"] + if (contentType === "application/json") { + // Handle JSON response + try { + if (chunk.image || chunk.detail) { + const { rawResponse, references } = this.handleImageResponse(chunk, this.websocketState.rawResponse); + this.websocketState.rawResponse = rawResponse; + this.websocketState.references = references; + } else if (chunk.type == "status") { + this.handleStreamResponse(this.websocketState.newResponseTextEl, chunk.message, null, false); + } else if (chunk.type == "rate_limit") { + this.handleStreamResponse(this.websocketState.newResponseTextEl, chunk.message, this.websocketState.loadingEllipsis, true); + } else { + this.websocketState.rawResponse = chunk.response; + } + } catch (error) { + // If the chunk is not a JSON object, just display it as is + this.websocketState.rawResponse += chunk; + } finally { + if (chunk.type != "status" && chunk.type != "rate_limit") { + this.addMessageToChatBody(this.websocketState.rawResponse, this.websocketState.newResponseTextEl, this.websocketState.references); + } + } + } else { + // Handle streamed response of type text/event-stream or text/plain + if (chunk && chunk.includes("### compiled references:")) { + const { rawResponse, references } = this.handleCompiledReferences(this.websocketState.newResponseTextEl, chunk, this.websocketState.references, this.websocketState.rawResponse); + this.websocketState.rawResponse = rawResponse; + this.websocketState.references = references; + } else { + // If the chunk is not a JSON object, just display it as is + this.websocketState.rawResponse += chunk; + if (this.websocketState.newResponseTextEl) { + this.handleStreamResponse(this.websocketState.newResponseTextEl, this.websocketState.rawResponse, this.websocketState.loadingEllipsis); + } + } + + // Scroll to bottom of chat window as chat response is streamed + chatBody.scrollTop = chatBody.scrollHeight; + }; + } + } + }; + if (!this.websocket) return; + this.websocket.onclose = (event: Event) => { + console.log("WebSocket is closed now."); + let statusDotIcon = document.getElementById("connection-status-icon"); + let statusDotText = document.getElementById("connection-status-text"); + if (!statusDotIcon || !statusDotText) return; + statusDotIcon.style.backgroundColor = "red"; + statusDotText.style.marginTop = "5px"; + statusDotText.innerHTML = ''; + } + this.websocket.onerror = (event: Event) => { + console.log("WebSocket error observed:", event); + } + this.websocket.onopen = (event: Event) => { + console.log("WebSocket is open now.") + let statusDotIcon = document.getElementById("connection-status-icon"); + let statusDotText = document.getElementById("connection-status-text"); + if (!statusDotIcon || !statusDotText) return; + statusDotIcon.style.backgroundColor = "green"; + statusDotText.style.marginTop = "10px"; + statusDotText.textContent = "Connected to Server"; + } + } + + sendMessageViaWebSocket() { + let chatBody = this.contentEl.getElementsByClassName("khoj-chat-body")[0] as HTMLElement; + let chatInput = this.contentEl.getElementsByClassName("khoj-chat-input")[0] as HTMLTextAreaElement; + let query = chatInput?.value.trim(); + if (!chatInput || !chatBody || !query) return; + console.log(`Query: ${query}`); + + // Add message by user to chat body + this.renderMessage(chatBody, query, "you"); + chatInput.value = ""; + this.autoResize(); + chatInput.setAttribute("disabled", "disabled"); + + let newResponseEl = this.contentEl.createDiv(); + newResponseEl.classList.add("khoj-chat-message", "khoj"); + newResponseEl.setAttribute("data-meta", "🏮 Khoj at " + this.formatDate(new Date())); + chatBody.appendChild(newResponseEl); + + let newResponseTextEl = this.contentEl.createDiv(); + newResponseTextEl.classList.add("khoj-chat-message-text", "khoj"); + newResponseEl.appendChild(newResponseTextEl); + + // Temporary status message to indicate that Khoj is thinking + let loadingEllipsis = this.createLoadingEllipse(); + + newResponseTextEl.appendChild(loadingEllipsis); + chatBody.scrollTop = chatBody.scrollHeight; + + // let chatTooltip = document.getElementById("chat-tooltip"); + // if (chatTooltip) chatTooltip.style.display = "none"; + + chatInput.classList.remove("option-enabled"); + + // Call specified Khoj API + this.websocket.send(query); + + this.websocketState = { + newResponseTextEl, + newResponseEl, + loadingEllipsis, + references: [], + rawResponse: "", + } + } } diff --git a/src/interface/obsidian/src/pane_view.ts b/src/interface/obsidian/src/pane_view.ts index 27fbeef6..40659572 100644 --- a/src/interface/obsidian/src/pane_view.ts +++ b/src/interface/obsidian/src/pane_view.ts @@ -4,12 +4,7 @@ import { KhojSearchModal } from 'src/search_modal'; import { KhojView, populateHeaderPane } from './utils'; export abstract class KhojPaneView extends ItemView { - result: string; setting: KhojSetting; - region: string; - city: string; - countryName: string; - timezone: string; constructor(leaf: WorkspaceLeaf, setting: KhojSetting) { super(leaf); @@ -18,19 +13,6 @@ export abstract class KhojPaneView extends ItemView { // Register Modal Keybindings to send user message // this.scope.register([], 'Enter', async () => { await this.chat() }); - - fetch("https://ipapi.co/json") - .then(response => response.json()) - .then(data => { - this.region = data.region; - this.city = data.city; - this.countryName = data.country_name; - this.timezone = data.timezone; - }) - .catch(err => { - console.log(err); - return; - }); } async onOpen() { diff --git a/src/interface/obsidian/styles.css b/src/interface/obsidian/styles.css index 7b7b71d0..48f21b0d 100644 --- a/src/interface/obsidian/styles.css +++ b/src/interface/obsidian/styles.css @@ -454,6 +454,63 @@ img.copy-icon { background: var(--text-on-accent); } +/* Loading Spinner */ +.lds-ellipsis { + display: inline-block; + position: relative; + width: 60px; + height: 32px; +} +.lds-ellipsis div { + position: absolute; + top: 12px; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--main-text-color); + animation-timing-function: cubic-bezier(0, 1, 1, 0); +} +.lds-ellipsis div:nth-child(1) { + left: 8px; + animation: lds-ellipsis1 0.6s infinite; +} +.lds-ellipsis div:nth-child(2) { + left: 8px; + animation: lds-ellipsis2 0.6s infinite; +} +.lds-ellipsis div:nth-child(3) { + left: 32px; + animation: lds-ellipsis2 0.6s infinite; +} +.lds-ellipsis div:nth-child(4) { + left: 56px; + animation: lds-ellipsis3 0.6s infinite; +} +@keyframes lds-ellipsis1 { + 0% { + transform: scale(0); + } + 100% { + transform: scale(1); + } +} +@keyframes lds-ellipsis3 { + 0% { + transform: scale(1); + } + 100% { + transform: scale(0); + } +} +@keyframes lds-ellipsis2 { + 0% { + transform: translate(0, 0); + } + 100% { + transform: translate(24px, 0); + } +} + @media only screen and (max-width: 600px) { div.khoj-header { display: grid; diff --git a/src/khoj/interface/web/chat.html b/src/khoj/interface/web/chat.html index ae6d9b9b..95b5e19e 100644 --- a/src/khoj/interface/web/chat.html +++ b/src/khoj/interface/web/chat.html @@ -2831,7 +2831,5 @@ To get started, just start typing below. You can also type / to see a list of co transform: translate(24px, 0); } } - -