From 0277d16daf068894065fba73e0c924f25a90edc0 Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Tue, 23 Jul 2024 18:41:12 +0530 Subject: [PATCH] Share desktop chat streaming utility funcs across chat, shortcut views Null check menu, menuContainer to avoid errors on Khoj mini --- src/interface/desktop/chat.html | 216 ---------------------------- src/interface/desktop/chatutils.js | 216 ++++++++++++++++++++++++++++ src/interface/desktop/shortcut.html | 148 ++++--------------- src/interface/desktop/utils.js | 4 +- 4 files changed, 247 insertions(+), 337 deletions(-) diff --git a/src/interface/desktop/chat.html b/src/interface/desktop/chat.html index 3550799e..57657ef1 100644 --- a/src/interface/desktop/chat.html +++ b/src/interface/desktop/chat.html @@ -167,222 +167,6 @@ } } - function createLoadingEllipsis() { - let loadingEllipsis = document.createElement("div"); - loadingEllipsis.classList.add("lds-ellipsis"); - - let firstEllipsis = document.createElement("div"); - firstEllipsis.classList.add("lds-ellipsis-item"); - - let secondEllipsis = document.createElement("div"); - secondEllipsis.classList.add("lds-ellipsis-item"); - - let thirdEllipsis = document.createElement("div"); - thirdEllipsis.classList.add("lds-ellipsis-item"); - - let fourthEllipsis = document.createElement("div"); - fourthEllipsis.classList.add("lds-ellipsis-item"); - - loadingEllipsis.appendChild(firstEllipsis); - loadingEllipsis.appendChild(secondEllipsis); - loadingEllipsis.appendChild(thirdEllipsis); - loadingEllipsis.appendChild(fourthEllipsis); - - return loadingEllipsis; - } - - function handleStreamResponse(newResponseElement, rawResponse, rawQuery, loadingEllipsis, 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(formatHTMLMessage(rawResponse, false, replace, rawQuery)); - - // Append loading ellipsis if it exists - if (!replace && loadingEllipsis) newResponseElement.appendChild(loadingEllipsis); - // Scroll to bottom of chat view - document.getElementById("chat-body").scrollTop = document.getElementById("chat-body").scrollHeight; - } - - function handleImageResponse(imageJson, rawResponse) { - 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}`; - } - } - - // If response has detail field, response is an error message. - if (imageJson.detail) rawResponse += imageJson.detail; - - return rawResponse; - } - - function finalizeChatBodyResponse(references, newResponseElement) { - if (!!newResponseElement && references != null && Object.keys(references).length > 0) { - newResponseElement.appendChild(createReferenceSection(references)); - } - document.getElementById("chat-body").scrollTop = document.getElementById("chat-body").scrollHeight; - document.getElementById("chat-input")?.removeAttribute("disabled"); - } - - function collectJsonsInBufferedMessageChunk(chunk) { - // Collect list of JSON objects and raw strings in the chunk - // Return the list of objects and the remaining raw string - let startIndex = chunk.indexOf('{'); - if (startIndex === -1) return { objects: [chunk], remainder: '' }; - const objects = [chunk.slice(0, startIndex)]; - let openBraces = 0; - let currentObject = ''; - - for (let i = startIndex; i < chunk.length; i++) { - if (chunk[i] === '{') { - if (openBraces === 0) startIndex = i; - openBraces++; - } - if (chunk[i] === '}') { - openBraces--; - if (openBraces === 0) { - currentObject = chunk.slice(startIndex, i + 1); - objects.push(currentObject); - currentObject = ''; - } - } - } - - return { - objects: objects, - remainder: openBraces > 0 ? chunk.slice(startIndex) : '' - }; - } - - function convertMessageChunkToJson(rawChunk) { - // Split the chunk into lines - if (rawChunk?.startsWith("{") && rawChunk?.endsWith("}")) { - try { - let jsonChunk = JSON.parse(rawChunk); - if (!jsonChunk.type) - jsonChunk = {type: 'message', data: jsonChunk}; - return jsonChunk; - } catch (e) { - return {type: 'message', data: rawChunk}; - } - } else if (rawChunk.length > 0) { - return {type: 'message', data: rawChunk}; - } - } - - function processMessageChunk(rawChunk) { - const chunk = convertMessageChunkToJson(rawChunk); - console.debug("Chunk:", chunk); - if (!chunk || !chunk.type) return; - if (chunk.type ==='status') { - console.log(`status: ${chunk.data}`); - const statusMessage = chunk.data; - handleStreamResponse(chatMessageState.newResponseTextEl, statusMessage, chatMessageState.rawQuery, 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()); - - // Automatically respond with voice if the subscribed user has sent voice message - if (chatMessageState.isVoice && "{{ is_active }}" == "True") - textToSpeech(chatMessageState.rawResponse); - - // Append any references after all the data has been streamed - finalizeChatBodyResponse(chatMessageState.references, chatMessageState.newResponseTextEl); - - const liveQuery = chatMessageState.rawQuery; - // Reset variables - chatMessageState = { - newResponseTextEl: null, - newResponseEl: null, - loadingEllipsis: null, - references: {}, - rawResponse: "", - rawQuery: liveQuery, - isVoice: false, - } - } else if (chunk.type === "references") { - chatMessageState.references = {"notes": chunk.data.context, "online": chunk.data.online_results}; - } else if (chunk.type === 'message') { - const chunkData = chunk.data; - if (typeof chunkData === 'object' && chunkData !== null) { - // If chunkData is already a JSON object - 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()); - handleJsonResponse(jsonData); - } catch (e) { - chatMessageState.rawResponse += chunkData; - handleStreamResponse(chatMessageState.newResponseTextEl, chatMessageState.rawResponse, chatMessageState.rawQuery, chatMessageState.loadingEllipsis); - } - } else { - chatMessageState.rawResponse += chunkData; - handleStreamResponse(chatMessageState.newResponseTextEl, chatMessageState.rawResponse, chatMessageState.rawQuery, chatMessageState.loadingEllipsis); - } - } - } - - function handleJsonResponse(jsonData) { - if (jsonData.image || jsonData.detail) { - chatMessageState.rawResponse = handleImageResponse(jsonData, chatMessageState.rawResponse); - } else if (jsonData.response) { - chatMessageState.rawResponse = jsonData.response; - } - - if (chatMessageState.newResponseTextEl) { - chatMessageState.newResponseTextEl.innerHTML = ""; - chatMessageState.newResponseTextEl.appendChild(formatHTMLMessage(chatMessageState.rawResponse)); - } - } - - async function readChatStream(response) { - if (!response.body) return; - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - let netBracketCount = 0; - - while (true) { - const { value, done } = await reader.read(); - // If the stream is done - if (done) { - // Process the last chunk - processMessageChunk(buffer); - buffer = ''; - break; - } - - // Read chunk from stream and append it to the buffer - const chunk = decoder.decode(value, { stream: true }); - buffer += chunk; - - // Check if the buffer contains (0 or more) complete JSON objects - netBracketCount += (chunk.match(/{/g) || []).length - (chunk.match(/}/g) || []).length; - if (netBracketCount === 0) { - let chunks = collectJsonsInBufferedMessageChunk(buffer); - chunks.objects.forEach((chunk) => processMessageChunk(chunk)); - buffer = chunks.remainder; - } - } - } - function incrementalChat(event) { if (!event.shiftKey && event.key === 'Enter') { event.preventDefault(); diff --git a/src/interface/desktop/chatutils.js b/src/interface/desktop/chatutils.js index 42cfa986..84f5e431 100644 --- a/src/interface/desktop/chatutils.js +++ b/src/interface/desktop/chatutils.js @@ -364,3 +364,219 @@ function createReferenceSection(references, createLinkerSection=false) { return referencesDiv; } + +function createLoadingEllipsis() { + let loadingEllipsis = document.createElement("div"); + loadingEllipsis.classList.add("lds-ellipsis"); + + let firstEllipsis = document.createElement("div"); + firstEllipsis.classList.add("lds-ellipsis-item"); + + let secondEllipsis = document.createElement("div"); + secondEllipsis.classList.add("lds-ellipsis-item"); + + let thirdEllipsis = document.createElement("div"); + thirdEllipsis.classList.add("lds-ellipsis-item"); + + let fourthEllipsis = document.createElement("div"); + fourthEllipsis.classList.add("lds-ellipsis-item"); + + loadingEllipsis.appendChild(firstEllipsis); + loadingEllipsis.appendChild(secondEllipsis); + loadingEllipsis.appendChild(thirdEllipsis); + loadingEllipsis.appendChild(fourthEllipsis); + + return loadingEllipsis; +} + +function handleStreamResponse(newResponseElement, rawResponse, rawQuery, loadingEllipsis, 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(formatHTMLMessage(rawResponse, false, replace, rawQuery)); + + // Append loading ellipsis if it exists + if (!replace && loadingEllipsis) newResponseElement.appendChild(loadingEllipsis); + // Scroll to bottom of chat view + document.getElementById("chat-body").scrollTop = document.getElementById("chat-body").scrollHeight; +} + +function handleImageResponse(imageJson, rawResponse) { + 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}`; + } + } + + // If response has detail field, response is an error message. + if (imageJson.detail) rawResponse += imageJson.detail; + + return rawResponse; +} + +function finalizeChatBodyResponse(references, newResponseElement) { + if (!!newResponseElement && references != null && Object.keys(references).length > 0) { + newResponseElement.appendChild(createReferenceSection(references)); + } + document.getElementById("chat-body").scrollTop = document.getElementById("chat-body").scrollHeight; + document.getElementById("chat-input")?.removeAttribute("disabled"); +} + +function collectJsonsInBufferedMessageChunk(chunk) { + // Collect list of JSON objects and raw strings in the chunk + // Return the list of objects and the remaining raw string + let startIndex = chunk.indexOf('{'); + if (startIndex === -1) return { objects: [chunk], remainder: '' }; + const objects = [chunk.slice(0, startIndex)]; + let openBraces = 0; + let currentObject = ''; + + for (let i = startIndex; i < chunk.length; i++) { + if (chunk[i] === '{') { + if (openBraces === 0) startIndex = i; + openBraces++; + } + if (chunk[i] === '}') { + openBraces--; + if (openBraces === 0) { + currentObject = chunk.slice(startIndex, i + 1); + objects.push(currentObject); + currentObject = ''; + } + } + } + + return { + objects: objects, + remainder: openBraces > 0 ? chunk.slice(startIndex) : '' + }; +} + +function convertMessageChunkToJson(rawChunk) { + // Split the chunk into lines + if (rawChunk?.startsWith("{") && rawChunk?.endsWith("}")) { + try { + let jsonChunk = JSON.parse(rawChunk); + if (!jsonChunk.type) + jsonChunk = {type: 'message', data: jsonChunk}; + return jsonChunk; + } catch (e) { + return {type: 'message', data: rawChunk}; + } + } else if (rawChunk.length > 0) { + return {type: 'message', data: rawChunk}; + } +} + +function processMessageChunk(rawChunk) { + const chunk = convertMessageChunkToJson(rawChunk); + console.debug("Chunk:", chunk); + if (!chunk || !chunk.type) return; + if (chunk.type ==='status') { + console.log(`status: ${chunk.data}`); + const statusMessage = chunk.data; + handleStreamResponse(chatMessageState.newResponseTextEl, statusMessage, chatMessageState.rawQuery, 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()); + + // Automatically respond with voice if the subscribed user has sent voice message + if (chatMessageState.isVoice && "{{ is_active }}" == "True") + textToSpeech(chatMessageState.rawResponse); + + // Append any references after all the data has been streamed + finalizeChatBodyResponse(chatMessageState.references, chatMessageState.newResponseTextEl); + + const liveQuery = chatMessageState.rawQuery; + // Reset variables + chatMessageState = { + newResponseTextEl: null, + newResponseEl: null, + loadingEllipsis: null, + references: {}, + rawResponse: "", + rawQuery: liveQuery, + isVoice: false, + } + } else if (chunk.type === "references") { + chatMessageState.references = {"notes": chunk.data.context, "online": chunk.data.online_results}; + } else if (chunk.type === 'message') { + const chunkData = chunk.data; + if (typeof chunkData === 'object' && chunkData !== null) { + // If chunkData is already a JSON object + 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()); + handleJsonResponse(jsonData); + } catch (e) { + chatMessageState.rawResponse += chunkData; + handleStreamResponse(chatMessageState.newResponseTextEl, chatMessageState.rawResponse, chatMessageState.rawQuery, chatMessageState.loadingEllipsis); + } + } else { + chatMessageState.rawResponse += chunkData; + handleStreamResponse(chatMessageState.newResponseTextEl, chatMessageState.rawResponse, chatMessageState.rawQuery, chatMessageState.loadingEllipsis); + } + } +} + +function handleJsonResponse(jsonData) { + if (jsonData.image || jsonData.detail) { + chatMessageState.rawResponse = handleImageResponse(jsonData, chatMessageState.rawResponse); + } else if (jsonData.response) { + chatMessageState.rawResponse = jsonData.response; + } + + if (chatMessageState.newResponseTextEl) { + chatMessageState.newResponseTextEl.innerHTML = ""; + chatMessageState.newResponseTextEl.appendChild(formatHTMLMessage(chatMessageState.rawResponse)); + } +} + +async function readChatStream(response) { + if (!response.body) return; + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let netBracketCount = 0; + + while (true) { + const { value, done } = await reader.read(); + // If the stream is done + if (done) { + // Process the last chunk + processMessageChunk(buffer); + buffer = ''; + break; + } + + // Read chunk from stream and append it to the buffer + const chunk = decoder.decode(value, { stream: true }); + buffer += chunk; + + // Check if the buffer contains (0 or more) complete JSON objects + netBracketCount += (chunk.match(/{/g) || []).length - (chunk.match(/}/g) || []).length; + if (netBracketCount === 0) { + let chunks = collectJsonsInBufferedMessageChunk(buffer); + chunks.objects.forEach((chunk) => processMessageChunk(chunk)); + buffer = chunks.remainder; + } + } +} diff --git a/src/interface/desktop/shortcut.html b/src/interface/desktop/shortcut.html index 4af26f0d..52207f20 100644 --- a/src/interface/desktop/shortcut.html +++ b/src/interface/desktop/shortcut.html @@ -346,7 +346,7 @@ inp.focus(); } - async function chat() { + async function chat(isVoice=false) { //set chat body to empty let chatBody = document.getElementById("chat-body"); chatBody.innerHTML = ""; @@ -375,9 +375,6 @@ chat_body.dataset.conversationId = conversationID; } - // Generate backend API URL to execute query - let chatApi = `${hostURL}/api/chat?q=${encodeURIComponent(query)}&n=${resultsCount}&client=web&stream=true&conversation_id=${conversationID}®ion=${region}&city=${city}&country=${countryName}&timezone=${timezone}`; - let newResponseEl = document.createElement("div"); newResponseEl.classList.add("chat-message", "khoj"); newResponseEl.attributes["data-meta"] = "🏮 Khoj at " + formatDate(new Date()); @@ -388,128 +385,41 @@ newResponseEl.appendChild(newResponseTextEl); // Temporary status message to indicate that Khoj is thinking - let loadingEllipsis = document.createElement("div"); - loadingEllipsis.classList.add("lds-ellipsis"); - - let firstEllipsis = document.createElement("div"); - firstEllipsis.classList.add("lds-ellipsis-item"); - - let secondEllipsis = document.createElement("div"); - secondEllipsis.classList.add("lds-ellipsis-item"); - - let thirdEllipsis = document.createElement("div"); - thirdEllipsis.classList.add("lds-ellipsis-item"); - - let fourthEllipsis = document.createElement("div"); - fourthEllipsis.classList.add("lds-ellipsis-item"); - - loadingEllipsis.appendChild(firstEllipsis); - loadingEllipsis.appendChild(secondEllipsis); - loadingEllipsis.appendChild(thirdEllipsis); - loadingEllipsis.appendChild(fourthEllipsis); - - newResponseTextEl.appendChild(loadingEllipsis); + let loadingEllipsis = createLoadingEllipsis(); document.body.scrollTop = document.getElementById("chat-body").scrollHeight; - // Call Khoj chat API - let response = await fetch(chatApi, { headers }); - let rawResponse = ""; - let references = null; - const contentType = response.headers.get("content-type"); toggleLoading(); - if (contentType === "application/json") { - // Handle JSON response - try { - const responseAsJson = await response.json(); - if (responseAsJson.image) { - // If response has image field, response is a generated image. - if (responseAsJson.intentType === "text-to-image") { - rawResponse += `![${query}](data:image/png;base64,${responseAsJson.image})`; - } else if (responseAsJson.intentType === "text-to-image2") { - rawResponse += `![${query}](${responseAsJson.image})`; - } else if (responseAsJson.intentType === "text-to-image-v3") { - rawResponse += `![${query}](data:image/webp;base64,${responseAsJson.image})`; - } - const inferredQueries = responseAsJson.inferredQueries?.[0]; - if (inferredQueries) { - rawResponse += `\n\n**Inferred Query**:\n\n${inferredQueries}`; - } - } - if (responseAsJson.context) { - const rawReferenceAsJson = responseAsJson.context; - references = createReferenceSection(rawReferenceAsJson, createLinkerSection=true); - } - if (responseAsJson.detail) { - // If response has detail field, response is an error message. - rawResponse += responseAsJson.detail; - } - } catch (error) { - // If the chunk is not a JSON object, just display it as is - rawResponse += chunk; - } finally { - newResponseTextEl.innerHTML = ""; - newResponseTextEl.appendChild(formatHTMLMessage(rawResponse)); - if (references != null) { - newResponseTextEl.appendChild(references); - } + // Setup chat message state + chatMessageState = { + newResponseTextEl, + newResponseEl, + loadingEllipsis, + references: {}, + rawResponse: "", + rawQuery: query, + isVoice: isVoice, + } - document.body.scrollTop = document.getElementById("chat-body").scrollHeight; - } - } else { - // Handle streamed response of type text/event-stream or text/plain - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let references = {}; + // Construct API URL to execute chat query + let chatApi = `${hostURL}/api/chat?q=${encodeURIComponent(query)}&conversation_id=${conversationID}&stream=true&client=desktop`; + chatApi += (!!region && !!city && !!countryName && !!timezone) + ? `®ion=${region}&city=${city}&country=${countryName}&timezone=${timezone}` + : ''; - readStream(); + const response = await fetch(chatApi, { headers }); - function readStream() { - reader.read().then(({ done, value }) => { - if (done) { - // Append any references after all the data has been streamed - if (references != {}) { - newResponseTextEl.appendChild(createReferenceSection(references, createLinkerSection=true)); - } - document.body.scrollTop = document.getElementById("chat-body").scrollHeight; - return; - } - - // Decode message chunk from stream - const chunk = decoder.decode(value, { stream: true }); - - if (chunk.includes("### compiled references:")) { - const additionalResponse = chunk.split("### compiled references:")[0]; - rawResponse += additionalResponse; - newResponseTextEl.innerHTML = ""; - newResponseTextEl.appendChild(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; - } - readStream(); - } else { - // Display response from Khoj - if (newResponseTextEl.getElementsByClassName("lds-ellipsis").length > 0) { - newResponseTextEl.removeChild(loadingEllipsis); - } - - // If the chunk is not a JSON object, just display it as is - rawResponse += chunk; - newResponseTextEl.innerHTML = ""; - newResponseTextEl.appendChild(formatHTMLMessage(rawResponse)); - - readStream(); - } - - // Scroll to bottom of chat window as chat response is streamed - document.body.scrollTop = document.getElementById("chat-body").scrollHeight; - }); - } + try { + if (!response.ok) throw new Error(response.statusText); + if (!response.body) throw new Error("Response body is empty"); + // Stream and render chat response + await readChatStream(response); + } catch (err) { + console.error(`Khoj chat response failed with\n${err}`); + if (chatMessageState.newResponseEl.getElementsByClassName("lds-ellipsis").length > 0 && chatMessageState.loadingEllipsis) + chatMessageState.newResponseTextEl.removeChild(chatMessageState.loadingEllipsis); + let errorMsg = "Sorry, unable to get response from Khoj backend ❤️‍🩹. Retry or contact developers for help at team@khoj.dev or on Discord"; + newResponseTextEl.textContent = errorMsg; } document.body.scrollTop = document.getElementById("chat-body").scrollHeight; } diff --git a/src/interface/desktop/utils.js b/src/interface/desktop/utils.js index c880a7cd..af0234ea 100644 --- a/src/interface/desktop/utils.js +++ b/src/interface/desktop/utils.js @@ -34,8 +34,8 @@ function toggleNavMenu() { document.addEventListener('click', function(event) { let menu = document.getElementById("khoj-nav-menu"); let menuContainer = document.getElementById("khoj-nav-menu-container"); - let isClickOnMenu = menuContainer.contains(event.target) || menuContainer === event.target; - if (isClickOnMenu === false && menu.classList.contains("show")) { + let isClickOnMenu = menuContainer?.contains(event.target) || menuContainer === event.target; + if (menu && isClickOnMenu === false && menu.classList.contains("show")) { menu.classList.remove("show"); } });