<html> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0 maximum-scale=1.0"> <title>Khoj - Chat</title> <link rel="icon" type="image/png" sizes="128x128" href="./assets/icons/favicon-128x128.png"> <link rel="manifest" href="/static/khoj_chat.webmanifest"> <link rel="stylesheet" href="./assets/khoj.css"> </head> <script src="./utils.js"></script> <script> let chatOptions = []; function copyProgrammaticOutput(event) { // Remove the first 4 characters which are the "Copy" button const programmaticOutput = event.target.parentNode.textContent.trim().slice(4); navigator.clipboard.writeText(programmaticOutput).then(() => { console.log("Programmatic output copied to clipboard"); }).catch((error) => { console.error("Error copying programmatic output to clipboard:", error); }); } function formatDate(date) { // Format date in HH:MM, DD MMM YYYY format let time_string = date.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit', hour12: false }); let date_string = date.toLocaleString('en-IN', { year: 'numeric', month: 'short', day: '2-digit'}).replaceAll('-', ' '); return `${time_string}, ${date_string}`; } function generateReference(reference, index) { // Escape reference for HTML rendering let escaped_ref = reference.replaceAll('"', '"'); // Generate HTML for Chat Reference let short_ref = escaped_ref.slice(0, 100); short_ref = short_ref.length < escaped_ref.length ? short_ref + "..." : short_ref; let referenceButton = document.createElement('button'); referenceButton.innerHTML = short_ref; 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() { console.log(`Toggling ref-${index}`) if (this.classList.contains("collapsed")) { this.classList.remove("collapsed"); this.classList.add("expanded"); this.innerHTML = escaped_ref; } else { this.classList.add("collapsed"); this.classList.remove("expanded"); this.innerHTML = short_ref; } }); return referenceButton; } function renderMessage(message, by, dt=null, annotations=null) { let message_time = formatDate(dt ?? new Date()); let by_name = by == "khoj" ? "🏮 Khoj" : "🤔 You"; let formattedMessage = formatHTMLMessage(message); let chatBody = document.getElementById("chat-body"); // Create a new div for the chat message let chatMessage = document.createElement('div'); chatMessage.className = `chat-message ${by}`; chatMessage.dataset.meta = `${by_name} at ${message_time}`; // Create a new div for the chat message text and append it to the chat message let chatMessageText = document.createElement('div'); chatMessageText.className = `chat-message-text ${by}`; chatMessageText.innerHTML = formattedMessage; chatMessage.appendChild(chatMessageText); // Append annotations div to the chat message if (annotations) { chatMessageText.appendChild(annotations); } // Append chat message div to chat body chatBody.appendChild(chatMessage); // Scroll to bottom of chat-body element chatBody.scrollTop = chatBody.scrollHeight; } function renderMessageWithReference(message, by, context=null, dt=null) { if (context == null || context.length == 0) { renderMessage(message, by, dt); return; } let references = document.createElement('div'); let referenceExpandButton = document.createElement('button'); referenceExpandButton.classList.add("reference-expand-button"); let expandButtonText = context.length == 1 ? "1 reference" : `${context.length} references`; referenceExpandButton.innerHTML = expandButtonText; references.appendChild(referenceExpandButton); let referenceSection = document.createElement('div'); referenceSection.classList.add("reference-section"); referenceSection.classList.add("collapsed"); 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"); } }); references.classList.add("references"); if (context) { for (let index in context) { let reference = context[index]; let polishedReference = generateReference(reference, index); referenceSection.appendChild(polishedReference); } } references.appendChild(referenceSection); renderMessage(message, by, dt, references); } function formatHTMLMessage(htmlMessage) { // Replace any ``` with <div class="programmatic-output"> let newHTML = htmlMessage.replace(/```([\s\S]*?)```/g, '<div class="programmatic-output"><button class="copy-button" onclick="copyProgrammaticOutput(event)">Copy</button>$1</div>'); // Replace any ** with <b> and __ with <u> newHTML = newHTML.replace(/\*\*([\s\S]*?)\*\*/g, '<b>$1</b>'); newHTML = newHTML.replace(/__([\s\S]*?)__/g, '<u>$1</u>'); // Remove any text between <s>[INST] and </s> tags. These are spurious instructions for the AI chat model. newHTML = newHTML.replace(/<s>\[INST\].+(<\/s>)?/g, ''); return newHTML; } async function chat() { // Extract required fields for search from form let query = document.getElementById("chat-input").value.trim(); let resultsCount = localStorage.getItem("khojResultsCount") || 5; console.log(`Query: ${query}`); // Short circuit on empty query if (query.length === 0) return; // Add message by user to chat body renderMessage(query, "you"); document.getElementById("chat-input").value = ""; autoResize(); document.getElementById("chat-input").setAttribute("disabled", "disabled"); let hostURL = await window.hostURLAPI.getURL(); // Generate backend API URL to execute query let url = `${hostURL}/api/chat?q=${encodeURIComponent(query)}&n=${resultsCount}&client=web&stream=true`; const khojToken = await window.tokenAPI.getToken(); const headers = { 'Authorization': `Bearer ${khojToken}` }; let chat_body = document.getElementById("chat-body"); let new_response = document.createElement("div"); new_response.classList.add("chat-message", "khoj"); new_response.attributes["data-meta"] = "🏮 Khoj at " + formatDate(new Date()); chat_body.appendChild(new_response); let newResponseText = document.createElement("div"); newResponseText.classList.add("chat-message-text", "khoj"); new_response.appendChild(newResponseText); // Temporary status message to indicate that Khoj is thinking let loadingSpinner = document.createElement("div"); loadingSpinner.classList.add("spinner"); newResponseText.appendChild(loadingSpinner); document.getElementById("chat-body").scrollTop = document.getElementById("chat-body").scrollHeight; let chatTooltip = document.getElementById("chat-tooltip"); chatTooltip.style.display = "none"; let chatInput = document.getElementById("chat-input"); chatInput.classList.remove("option-enabled"); // Call specified Khoj API which returns a streamed response of type text/plain fetch(url, { headers }) .then(response => { const reader = response.body.getReader(); const decoder = new TextDecoder(); function readStream() { let references = null; reader.read().then(({ done, value }) => { if (done) { // Evaluate the contents of new_response_text.innerHTML after all the data has been streamed const currentHTML = newResponseText.innerHTML; newResponseText.innerHTML = formatHTMLMessage(currentHTML); newResponseText.appendChild(references); document.getElementById("chat-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]; newResponseText.innerHTML += additionalResponse; const rawReference = chunk.split("### compiled references:")[1]; const rawReferenceAsJson = JSON.parse(rawReference); references = document.createElement('div'); references.classList.add("references"); let referenceExpandButton = document.createElement('button'); referenceExpandButton.classList.add("reference-expand-button"); let expandButtonText = rawReferenceAsJson.length == 1 ? "1 reference" : `${rawReferenceAsJson.length} references`; referenceExpandButton.innerHTML = expandButtonText; references.appendChild(referenceExpandButton); let referenceSection = document.createElement('div'); referenceSection.classList.add("reference-section"); referenceSection.classList.add("collapsed"); 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"); } }); rawReferenceAsJson.forEach((reference, index) => { let polishedReference = generateReference(reference, index); referenceSection.appendChild(polishedReference); }); references.appendChild(referenceSection); readStream(); } else { // Display response from Khoj if (newResponseText.getElementsByClassName("spinner").length > 0) { newResponseText.removeChild(loadingSpinner); } newResponseText.innerHTML += chunk; readStream(); } // Scroll to bottom of chat window as chat response is streamed document.getElementById("chat-body").scrollTop = document.getElementById("chat-body").scrollHeight; }); } readStream(); document.getElementById("chat-input").removeAttribute("disabled"); }); } function incrementalChat(event) { if (!event.shiftKey && event.key === 'Enter') { event.preventDefault(); chat(); } } function onChatInput() { let chatInput = document.getElementById("chat-input"); chatInput.value = chatInput.value.trimStart(); if (chatInput.value.startsWith("/") && chatInput.value.split(" ").length === 1) { let chatTooltip = document.getElementById("chat-tooltip"); chatTooltip.style.display = "block"; let helpText = "<div>"; const command = chatInput.value.split(" ")[0].substring(1); for (let key in chatOptions) { if (!!!command || key.startsWith(command)) { helpText += "<b>/" + key + "</b>: " + chatOptions[key] + "<br>"; } } chatTooltip.innerHTML = helpText; } else if (chatInput.value.startsWith("/")) { const firstWord = chatInput.value.split(" ")[0]; if (firstWord.substring(1) in chatOptions) { chatInput.classList.add("option-enabled"); } else { chatInput.classList.remove("option-enabled"); } let chatTooltip = document.getElementById("chat-tooltip"); chatTooltip.style.display = "none"; } else { let chatTooltip = document.getElementById("chat-tooltip"); chatTooltip.style.display = "none"; chatInput.classList.remove("option-enabled"); } autoResize(); } function autoResize() { const textarea = document.getElementById('chat-input'); const scrollTop = textarea.scrollTop; textarea.style.height = '0'; const scrollHeight = textarea.scrollHeight; textarea.style.height = Math.min(scrollHeight, 200) + 'px'; textarea.scrollTop = scrollTop; document.getElementById("chat-body").scrollTop = document.getElementById("chat-body").scrollHeight; } window.addEventListener('load', async() => { await loadChat(); }); async function loadChat() { const hostURL = await window.hostURLAPI.getURL(); const khojToken = await window.tokenAPI.getToken(); const headers = { 'Authorization': `Bearer ${khojToken}` }; fetch(`${hostURL}/api/chat/history?client=web`, { headers }) .then(response => response.json()) .then(data => { if (data.detail) { // If the server returns a 500 error with detail, render a setup hint. renderMessage("Hi 👋🏾, to get started you have two options:<ol><li><b>Use OpenAI</b>: <ol><li>Get your <a class='inline-chat-link' href='https://platform.openai.com/account/api-keys'>OpenAI API key</a></li><li>Save it in the Khoj <a class='inline-chat-link' href='/config/processor/conversation/openai'>chat settings</a></li><li>Click Configure on the Khoj <a class='inline-chat-link' href='/config'>settings page</a></li></ol></li><li><b>Enable offline chat</b>: <ol><li>Go to the Khoj <a class='inline-chat-link' href='/config'>settings page</a> and enable offline chat</li></ol></li></ol>", "khoj"); // Disable chat input field and update placeholder text document.getElementById("chat-input").setAttribute("disabled", "disabled"); document.getElementById("chat-input").setAttribute("placeholder", "Configure Khoj to enable chat"); } else { // Set welcome message on load renderMessage("Hey 👋🏾, what's up?", "khoj"); } return data.response; }) .then(response => { // Render conversation history, if any response.forEach(chat_log => { renderMessageWithReference(chat_log.message, chat_log.by, chat_log.context, new Date(chat_log.created)); }); }) .catch(err => { return; }); fetch(`${hostURL}/api/chat/options`, { headers }) .then(response => response.json()) .then(data => { // Render chat options, if any if (data) { chatOptions = data; } }) .catch(err => { return; }); // Fill query field with value passed in URL query parameters, if any. var query_via_url = new URLSearchParams(window.location.search).get("q"); if (query_via_url) { document.getElementById("chat-input").value = query_via_url; chat(); } } </script> <body> <div id="khoj-empty-container" class="khoj-empty-container"> </div> <!--Add Header Logo and Nav Pane--> <div class="khoj-header"> <a class="khoj-logo" href="/"> <img class="khoj-logo" src="./assets/icons/khoj-logo-sideways-500.png" alt="Khoj"></img> </a> <nav class="khoj-nav"> <a class="khoj-nav khoj-nav-selected" href="./chat.html">💬 Chat</a> <a class="khoj-nav" href="./search.html">🔎 Search</a> <a class="khoj-nav" href="./config.html">⚙️ Settings</a> </nav> </div> <!-- Chat Body --> <div id="chat-body"></div> <!-- Chat Footer --> <div id="chat-footer"> <div id="chat-tooltip" style="display: none;"></div> <textarea id="chat-input" class="option" oninput="onChatInput()" onkeydown=incrementalChat(event) autofocus="autofocus" placeholder="Type / to see a list of commands, or just type your questions and hit enter."></textarea> </div> </body> <style> html, body { height: 100%; width: 100%; padding: 0px; margin: 0px; } body { display: grid; background: var(--background-color); color: var(--main-text-color); text-align: center; font-family: roboto, karma, segoe ui, sans-serif; font-size: small; font-weight: 300; line-height: 1.5em; } body > * { padding: 10px; margin: 10px; } #chat-body { font-size: small; margin: 0px; line-height: 20px; overflow-y: scroll; /* Make chat body scroll to see history */ } /* add chat metatdata to bottom of bubble */ .chat-message::after { content: attr(data-meta); display: block; font-size: x-small; color: #475569; margin: -8px 4px 0 -5px; } /* move message by khoj to left */ .chat-message.khoj { margin-left: auto; text-align: left; } /* move message by you to right */ .chat-message.you { margin-right: auto; text-align: right; white-space: pre-line; } /* basic style chat message text */ .chat-message-text { margin: 10px; border-radius: 10px; padding: 10px; position: relative; display: inline-block; max-width: 80%; text-align: left; } /* color chat bubble by khoj blue */ .chat-message-text.khoj { color: var(--primary-inverse); background: var(--primary); margin-left: auto; white-space: pre-line; } /* Spinner symbol when the chat message is loading */ .spinner { border: 4px solid #f3f3f3; border-top: 4px solid var(--primary-inverse); border-radius: 50%; width: 12px; height: 12px; animation: spin 2s linear infinite; margin: 0px 0px 0px 10px; display: inline-block; } @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } /* add left protrusion to khoj chat bubble */ .chat-message-text.khoj:after { content: ''; position: absolute; bottom: -2px; left: -7px; border: 10px solid transparent; border-top-color: var(--primary); border-bottom: 0; transform: rotate(-60deg); } /* color chat bubble by you dark grey */ .chat-message-text.you { color: #f8fafc; background: #475569; margin-right: auto; } /* add right protrusion to you chat bubble */ .chat-message-text.you:after { content: ''; position: absolute; top: 91%; right: -2px; border: 10px solid transparent; border-left-color: #475569; border-right: 0; margin-top: -10px; transform: rotate(-60deg) } #chat-footer { padding: 0; display: grid; grid-template-columns: minmax(70px, 100%); grid-column-gap: 10px; grid-row-gap: 10px; } #chat-footer > * { padding: 15px; border-radius: 5px; border: 1px solid #475569; background: #f9fafc } .option:hover { box-shadow: 0 0 11px #aaa; } #chat-input { font-family: roboto, karma, segoe ui, sans-serif; font-size: small; height: 54px; resize: none; overflow-y: hidden; max-height: 200px; box-sizing: border-box; padding: 15px; line-height: 1.5em; margin: 0; } #chat-input:focus { outline: none !important; } .option-enabled { box-shadow: 0 0 12px rgb(119, 156, 46); } div.collapsed { display: none; } div.expanded { display: block; } div.reference { display: grid; grid-template-rows: auto; grid-auto-flow: row; grid-column-gap: 10px; grid-row-gap: 10px; margin: 10px; } div.expanded.reference-section { display: grid; grid-template-rows: auto; grid-auto-flow: row; grid-column-gap: 10px; grid-row-gap: 10px; margin: 10px; } button.reference-button { background: var(--background-color); color: var(--main-text-color); border: 1px solid var(--main-text-color); border-radius: 5px; padding: 5px; font-size: 14px; font-weight: 300; line-height: 1.5em; cursor: pointer; transition: background 0.2s ease-in-out; text-align: left; max-height: 50px; transition: max-height 0.3s ease-in-out; overflow: hidden; } button.reference-button.expanded { max-height: 200px; } button.reference-button::before { content: "▶"; margin-right: 5px; display: inline-block; transition: transform 0.3s ease-in-out; } button.reference-button:active:before, button.reference-button[aria-expanded="true"]::before { transform: rotate(90deg); } button.reference-expand-button { background: var(--background-color); color: var(--main-text-color); border: 1px dotted var(--main-text-color); border-radius: 5px; padding: 5px; font-size: 14px; font-weight: 300; line-height: 1.5em; cursor: pointer; transition: background 0.4s ease-in-out; text-align: left; } button.reference-expand-button:hover { background: var(--primary-hover); } .option-enabled:focus { outline: none !important; border:1px solid #475569; box-shadow: 0 0 16px var(--primary); } a.inline-chat-link { color: #475569; text-decoration: none; border-bottom: 1px dotted #475569; } div.khoj-empty-container { padding: 0; margin: 0; } @media (pointer: coarse), (hover: none) { abbr[title] { position: relative; padding-left: 4px; /* space references out to ease tapping */ } abbr[title]:focus:after { content: attr(title); /* position tooltip */ position: absolute; left: 16px; /* open tooltip to right of ref link, instead of on top of it */ width: auto; z-index: 1; /* show tooltip above chat messages */ /* style tooltip */ background-color: #aaa; color: #f8fafc; border-radius: 2px; box-shadow: 1px 1px 4px 0 rgba(0, 0, 0, 0.4); font-size small; padding: 2px 4px; } } @media only screen and (max-width: 600px) { body { grid-template-columns: 1fr; grid-template-rows: auto auto minmax(80px, 100%) auto; } body > * { grid-column: 1; } #chat-footer { padding: 0; margin: 4px; grid-template-columns: auto; } } @media only screen and (min-width: 600px) { body { grid-template-columns: auto min(70vw, 100%) auto; grid-template-rows: auto auto minmax(80px, 100%) auto; } body > * { grid-column: 2; } } div#chat-tooltip { text-align: left; font-size: medium; } @keyframes gradient { 0% { background-position: 0% 50%; } 50% { background-position: 100% 50%; } 100% { background-position: 0% 50%; } } a.khoj-logo { text-align: center; } div.programmatic-output { background-color: #f5f5f5; border: 1px solid #ddd; border-radius: 3px; box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05); color: #333; font-family: monospace; font-size: small; line-height: 1.5; margin: 10px 0; overflow-x: auto; padding: 10px; white-space: pre-wrap; } </style> </html>