diff --git a/src/interface/obsidian/package.json b/src/interface/obsidian/package.json index ac8a0f60..dd597d72 100644 --- a/src/interface/obsidian/package.json +++ b/src/interface/obsidian/package.json @@ -25,9 +25,5 @@ "obsidian": "latest", "tslib": "2.4.0", "typescript": "4.7.4" - }, - "dependencies": { - "@types/node-fetch": "^2.6.4", - "node-fetch": "^3.1.0" } } diff --git a/src/interface/obsidian/src/chat_modal.ts b/src/interface/obsidian/src/chat_modal.ts index 1b35f499..685fd91e 100644 --- a/src/interface/obsidian/src/chat_modal.ts +++ b/src/interface/obsidian/src/chat_modal.ts @@ -1,6 +1,5 @@ import { App, MarkdownRenderer, Modal, request, requestUrl, setIcon } from 'obsidian'; import { KhojSetting } from 'src/settings'; -import fetch from "node-fetch"; export interface ChatJsonResult { image?: string; @@ -43,10 +42,6 @@ export class KhojChatModal extends Modal { // Create area for chat logs let chatBodyEl = contentEl.createDiv({ attr: { id: "khoj-chat-body", class: "khoj-chat-body" } }); - // Get chat history from Khoj backend - let getChatHistorySucessfully = await this.getChatHistory(chatBodyEl); - let placeholderText = getChatHistorySucessfully ? "Message" : "Configure Khoj to enable chat"; - // Add chat input field let inputRow = contentEl.createDiv("khoj-input-row"); let clearChat = inputRow.createEl("button", { @@ -62,9 +57,7 @@ export class KhojChatModal extends Modal { attr: { id: "khoj-chat-input", autofocus: "autofocus", - placeholder: placeholderText, class: "khoj-chat-input option", - disabled: !getChatHistorySucessfully ? "disabled" : null }, }) chatInput.addEventListener('input', (_) => { this.onChatInput() }); @@ -94,8 +87,14 @@ export class KhojChatModal extends Modal { let sendImg = send.getElementsByClassName("lucide-arrow-up-circle")[0] sendImg.addEventListener('click', async (_) => { await this.chat() }); + // Get chat history from Khoj backend and set chat input state + let getChatHistorySucessfully = await this.getChatHistory(chatBodyEl); + let placeholderText = getChatHistorySucessfully ? "Message" : "Configure Khoj to enable chat"; + chatInput.placeholder = placeholderText; + chatInput.disabled = !getChatHistorySucessfully; + // Scroll to bottom of modal, till the send message input box - this.modalEl.scrollTop = this.modalEl.scrollHeight; + this.scrollChatToBottom(); chatInput.focus(); } @@ -207,7 +206,7 @@ export class KhojChatModal extends Modal { chatMessageEl.style.userSelect = "text"; // Scroll to bottom after inserting chat messages - this.modalEl.scrollTop = this.modalEl.scrollHeight; + this.scrollChatToBottom(); return chatMessageEl } @@ -230,7 +229,7 @@ export class KhojChatModal extends Modal { }) // Scroll to bottom after inserting chat messages - this.modalEl.scrollTop = this.modalEl.scrollHeight; + this.scrollChatToBottom(); return chat_message_el } @@ -241,7 +240,7 @@ export class KhojChatModal extends Modal { // @ts-ignore await MarkdownRenderer.renderMarkdown(this.result, htmlElement, '', null); // Scroll to bottom of modal, till the send message input box - this.modalEl.scrollTop = this.modalEl.scrollHeight; + this.scrollChatToBottom(); } formatDate(date: Date): string { @@ -254,10 +253,13 @@ export class KhojChatModal extends Modal { async getChatHistory(chatBodyEl: Element): Promise { // Get chat history from Khoj backend let chatUrl = `${this.setting.khojUrl}/api/chat/history?client=obsidian`; - let headers = { "Authorization": `Bearer ${this.setting.khojApiKey}` }; try { - let response = await fetch(chatUrl, { method: "GET", headers: headers }); + let response = await fetch(chatUrl, { + method: "GET", + headers: { "Authorization": `Bearer ${this.setting.khojApiKey}` }, + }); + let responseJson: any = await response.json(); if (responseJson.detail) { @@ -280,6 +282,68 @@ export class KhojChatModal extends Modal { return true; } + async readChatStream(response: Response, responseElement: HTMLDivElement): Promise { + // Exit if response body is empty + if (response.body == null) return; + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + + while (true) { + const { value, done } = await reader.read(); + + // Break if the stream is done + if (done) break; + + let responseText = decoder.decode(value); + if (responseText.includes("### compiled references:")) { + // Render any references used to generate the response + const [additionalResponse, rawReference] = responseText.split("### compiled references:", 2); + await this.renderIncrementalMessage(responseElement, additionalResponse); + + const rawReferenceAsJson = JSON.parse(rawReference); + let references = responseElement.createDiv(); + references.classList.add("references"); + + let referenceExpandButton = references.createEl('button'); + referenceExpandButton.classList.add("reference-expand-button"); + + let referenceSection = references.createDiv(); + referenceSection.classList.add("reference-section"); + referenceSection.classList.add("collapsed"); + + let numReferences = 0; + + // If rawReferenceAsJson is a list, then count the length + if (Array.isArray(rawReferenceAsJson)) { + numReferences = rawReferenceAsJson.length; + + rawReferenceAsJson.forEach((reference, index) => { + this.generateReference(referenceSection, reference, index); + }); + } + references.appendChild(referenceExpandButton); + + 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 expandButtonText = numReferences == 1 ? "1 reference" : `${numReferences} references`; + referenceExpandButton.innerHTML = expandButtonText; + references.appendChild(referenceSection); + } else { + // Render incremental chat response + await this.renderIncrementalMessage(responseElement, responseText); + } + } + } + async getChatResponse(query: string | undefined | null): Promise { // Exit if query is empty if (!query || query === "") return; @@ -300,21 +364,22 @@ export class KhojChatModal extends Modal { let response = await fetch(chatUrl, { method: "GET", headers: { - "Access-Control-Allow-Origin": "*", "Content-Type": "text/event-stream", "Authorization": `Bearer ${this.setting.khojApiKey}`, }, }) try { - if (response.body == null) { + if (response.body === null) { throw new Error("Response body is null"); } + // Clear thinking status message if (responseElement.innerHTML === "🤔") { responseElement.innerHTML = ""; } + // Reset collated chat result to empty string this.result = ""; responseElement.innerHTML = ""; if (response.headers.get("content-type") == "application/json") { @@ -328,60 +393,17 @@ export class KhojChatModal extends Modal { } } catch (error) { // If the chunk is not a JSON object, just display it as is - responseText = response.body.read().toString() + responseText = await response.text(); } finally { await this.renderIncrementalMessage(responseElement, responseText); } } - for await (const chunk of response.body) { - let responseText = chunk.toString(); - if (responseText.includes("### compiled references:")) { - const [additionalResponse, rawReference] = responseText.split("### compiled references:", 2); - await this.renderIncrementalMessage(responseElement, additionalResponse); - - const rawReferenceAsJson = JSON.parse(rawReference); - let references = responseElement.createDiv(); - references.classList.add("references"); - - let referenceExpandButton = references.createEl('button'); - referenceExpandButton.classList.add("reference-expand-button"); - - let referenceSection = references.createDiv(); - referenceSection.classList.add("reference-section"); - referenceSection.classList.add("collapsed"); - - let numReferences = 0; - - // If rawReferenceAsJson is a list, then count the length - if (Array.isArray(rawReferenceAsJson)) { - numReferences = rawReferenceAsJson.length; - - rawReferenceAsJson.forEach((reference, index) => { - this.generateReference(referenceSection, reference, index); - }); - } - references.appendChild(referenceExpandButton); - - 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 expandButtonText = numReferences == 1 ? "1 reference" : `${numReferences} references`; - referenceExpandButton.innerHTML = expandButtonText; - references.appendChild(referenceSection); - } else { - await this.renderIncrementalMessage(responseElement, responseText); - } - } + // Stream and render chat response + await this.readChatStream(response, responseElement); } catch (err) { - let errorMsg = "Sorry, unable to get response from Khoj backend ❤️‍🩹. Contact developer for help at team@khoj.dev or [in Discord](https://discord.gg/BDgyabRM6e)"; + console.log(`Khoj chat response failed with\n${err}`); + let errorMsg = "Sorry, unable to get response from Khoj backend ❤️‍🩹. Retry or contact developers for help at team@khoj.dev or on Discord"; responseElement.innerHTML = errorMsg } } @@ -402,7 +424,7 @@ export class KhojChatModal extends Modal { let chatBody = this.contentEl.getElementsByClassName("khoj-chat-body")[0]; let response = await request({ - url: `${this.setting.khojUrl}/api/chat/history?client=web`, + url: `${this.setting.khojUrl}/api/chat/history?client=obsidian`, method: "DELETE", headers: { "Authorization": `Bearer ${this.setting.khojApiKey}` }, }) @@ -559,6 +581,11 @@ export class KhojChatModal extends Modal { const scrollHeight = chatInput.scrollHeight + 8; // +8 accounts for padding chatInput.style.height = Math.min(scrollHeight, 200) + 'px'; chatInput.scrollTop = scrollTop; - this.modalEl.scrollTop = this.modalEl.scrollHeight; + this.scrollChatToBottom(); + } + + scrollChatToBottom() { + let sendButton = this.modalEl.getElementsByClassName("khoj-chat-send")[0]; + sendButton.scrollIntoView({ behavior: "auto", block: "center" }); } } diff --git a/src/interface/obsidian/yarn.lock b/src/interface/obsidian/yarn.lock index 5074ab18..a11ea15e 100644 --- a/src/interface/obsidian/yarn.lock +++ b/src/interface/obsidian/yarn.lock @@ -40,19 +40,6 @@ resolved "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz" integrity sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ== -"@types/node-fetch@^2.6.4": - version "2.6.4" - resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.4.tgz#1bc3a26de814f6bf466b25aeb1473fa1afe6a660" - integrity sha512-1ZX9fcN4Rvkvgv4E6PAY5WXUFWFcRWxZa3EW83UjycOB9ljJCedb2CupIP4RZMEwF/M3eTcCihbBRgwtGbg5Rg== - dependencies: - "@types/node" "*" - form-data "^3.0.0" - -"@types/node@*": - version "20.3.3" - resolved "https://registry.yarnpkg.com/@types/node/-/node-20.3.3.tgz#329842940042d2b280897150e023e604d11657d6" - integrity sha512-wheIYdr4NYML61AjC8MKj/2jrR/kDQri/CIpVoZwldwhnIrD/j9jIU5bJ8yBKuB2VhpFV7Ab6G2XkBjv9r9Zzw== - "@types/node@^16.11.6": version "16.18.12" resolved "https://registry.npmjs.org/@types/node/-/node-16.18.12.tgz" @@ -150,11 +137,6 @@ array-union@^2.1.0: resolved "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz" integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw== -asynckit@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" - integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== - braces@^3.0.2: version "3.0.2" resolved "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz" @@ -167,18 +149,6 @@ builtin-modules@3.3.0: resolved "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz" integrity sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw== -combined-stream@^1.0.8: - version "1.0.8" - resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" - integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== - dependencies: - delayed-stream "~1.0.0" - -data-uri-to-buffer@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz#d8feb2b2881e6a4f58c2e08acfd0e2834e26222e" - integrity sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A== - debug@^4.3.4: version "4.3.4" resolved "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz" @@ -186,11 +156,6 @@ debug@^4.3.4: dependencies: ms "2.1.2" -delayed-stream@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" - integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== - dir-glob@^3.0.1: version "3.0.1" resolved "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz" @@ -384,14 +349,6 @@ fastq@^1.6.0: dependencies: reusify "^1.0.4" -fetch-blob@^3.1.2, fetch-blob@^3.1.4: - version "3.2.0" - resolved "https://registry.yarnpkg.com/fetch-blob/-/fetch-blob-3.2.0.tgz#f09b8d4bbd45adc6f0c20b7e787e793e309dcce9" - integrity sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ== - dependencies: - node-domexception "^1.0.0" - web-streams-polyfill "^3.0.3" - fill-range@^7.0.1: version "7.0.1" resolved "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz" @@ -399,22 +356,6 @@ fill-range@^7.0.1: dependencies: to-regex-range "^5.0.1" -form-data@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/form-data/-/form-data-3.0.1.tgz#ebd53791b78356a99af9a300d4282c4d5eb9755f" - integrity sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg== - dependencies: - asynckit "^0.4.0" - combined-stream "^1.0.8" - mime-types "^2.1.12" - -formdata-polyfill@^4.0.10: - version "4.0.10" - resolved "https://registry.yarnpkg.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz#24807c31c9d402e002ab3d8c720144ceb8848423" - integrity sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g== - dependencies: - fetch-blob "^3.1.2" - functional-red-black-tree@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz" @@ -481,18 +422,6 @@ micromatch@^4.0.4: braces "^3.0.2" picomatch "^2.3.1" -mime-db@1.52.0: - version "1.52.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" - integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== - -mime-types@^2.1.12: - version "2.1.35" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" - integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== - dependencies: - mime-db "1.52.0" - moment@2.29.4: version "2.29.4" resolved "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz" @@ -503,20 +432,6 @@ ms@2.1.2: resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== -node-domexception@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5" - integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ== - -node-fetch@^3.1.0: - version "3.3.1" - resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-3.3.1.tgz#b3eea7b54b3a48020e46f4f88b9c5a7430d20b2e" - integrity sha512-cRVc/kyto/7E5shrWca1Wsea4y6tL9iYJE5FBCius3JQfb/4P4I295PfhgbJQBLTx6lATE4z+wK0rPM4VS2uow== - dependencies: - data-uri-to-buffer "^4.0.0" - fetch-blob "^3.1.4" - formdata-polyfill "^4.0.10" - obsidian@latest: version "1.1.1" resolved "https://registry.npmjs.org/obsidian/-/obsidian-1.1.1.tgz" @@ -598,11 +513,6 @@ typescript@4.7.4: resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.7.4.tgz#1a88596d1cf47d59507a1bcdfb5b9dfe4d488235" integrity sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ== -web-streams-polyfill@^3.0.3: - version "3.2.1" - resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz#71c2718c52b45fd49dbeee88634b3a60ceab42a6" - integrity sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q== - yallist@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz" diff --git a/src/khoj/main.py b/src/khoj/main.py index 72036695..0ca6442c 100644 --- a/src/khoj/main.py +++ b/src/khoj/main.py @@ -61,6 +61,8 @@ app.add_middleware( CORSMiddleware, allow_origins=[ "app://obsidian.md", + "capacitor://localhost", # To allow access from Obsidian iOS app using Capacitor.JS + "http://localhost", # To allow access from Obsidian Android app "http://localhost:*", "http://127.0.0.1:*", f"https://{KHOJ_DOMAIN}",