2023-09-06 19:04:18 +00:00
< 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 >
2023-11-04 01:13:37 +00:00
< script src = "./utils.js" > < / script >
2023-11-03 12:11:41 +00:00
2023-09-06 19:04:18 +00:00
< 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
2023-11-07 00:18:41 +00:00
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;
2023-09-06 19:04:18 +00:00
}
2023-11-07 00:18:41 +00:00
function renderMessage(message, by, dt=null, annotations=null) {
2023-09-06 19:04:18 +00:00
let message_time = formatDate(dt ?? new Date());
let by_name = by == "khoj" ? "🏮 Khoj" : "🤔 You";
let formattedMessage = formatHTMLMessage(message);
2023-11-07 00:18:41 +00:00
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);
2023-09-06 19:04:18 +00:00
// Scroll to bottom of chat-body element
2023-11-07 00:18:41 +00:00
chatBody.scrollTop = chatBody.scrollHeight;
2023-09-06 19:04:18 +00:00
}
function renderMessageWithReference(message, by, context=null, dt=null) {
2023-11-07 00:18:41 +00:00
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");
2023-09-06 19:04:18 +00:00
if (context) {
2023-11-07 00:18:41 +00:00
for (let index in context) {
let reference = context[index];
let polishedReference = generateReference(reference, index);
referenceSection.appendChild(polishedReference);
}
2023-09-06 19:04:18 +00:00
}
2023-11-07 00:18:41 +00:00
references.appendChild(referenceSection);
2023-09-06 19:04:18 +00:00
2023-11-07 00:18:41 +00:00
renderMessage(message, by, dt, references);
2023-09-06 19:04:18 +00:00
}
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 > ');
2023-11-04 08:09:35 +00:00
// 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, '');
2023-11-17 19:04:36 +00:00
// For any text that has single backticks, replace them with < code > tags
newHTML = newHTML.replace(/`([^`]+)`/g, '< code class = "chat-response" > $1< / code > ');
2023-09-06 19:04:18 +00:00
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`;
2023-10-26 19:33:03 +00:00
const khojToken = await window.tokenAPI.getToken();
const headers = { 'Authorization': `Bearer ${khojToken}` };
2023-09-06 19:04:18 +00:00
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
2023-10-26 19:33:03 +00:00
fetch(url, { headers })
2023-11-07 00:18:41 +00:00
.then(response => {
2023-09-06 19:04:18 +00:00
const reader = response.body.getReader();
const decoder = new TextDecoder();
2023-11-08 00:44:41 +00:00
let references = null;
2023-09-06 19:04:18 +00:00
function readStream() {
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);
2023-11-07 00:18:41 +00:00
newResponseText.appendChild(references);
document.getElementById("chat-body").scrollTop = document.getElementById("chat-body").scrollHeight;
2023-09-06 19:04:18 +00:00
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);
2023-11-07 00:18:41 +00:00
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);
2023-09-06 19:04:18 +00:00
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') {
2023-11-04 08:09:35 +00:00
event.preventDefault();
2023-09-06 19:04:18 +00:00
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();
2023-10-26 19:33:03 +00:00
const khojToken = await window.tokenAPI.getToken();
const headers = { 'Authorization': `Bearer ${khojToken}` };
fetch(`${hostURL}/api/chat/history?client=web`, { headers })
2023-09-06 19:04:18 +00:00
.then(response => response.json())
.then(data => {
if (data.detail) {
// If the server returns a 500 error with detail, render a setup hint.
2023-11-16 06:33:50 +00:00
first_run_message = `Hi 👋🏾, to get started:
< ol >
< li > Generate an API token in the < a class = 'inline-chat-link' href = "#" onclick = "window.navigateAPI.navigateToWebSettings()" > Khoj Web settings< / a > < / li >
< li > Paste it into the API Key field in the < a class = 'inline-chat-link' href = "#" onclick = "window.navigateAPI.navigateToSettings()" > Khoj Desktop settings< / a > < / li >
< / ol > `
.trim()
.replace(/(\r\n|\n|\r)/gm, "");
renderMessage(first_run_message, "khoj");
2023-09-06 19:04:18 +00:00
// 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;
});
2023-10-26 19:33:03 +00:00
fetch(`${hostURL}/api/chat/options`, { headers })
2023-09-06 19:04:18 +00:00
.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 >
2023-11-05 00:17:04 +00:00
< div id = "khoj-empty-container" class = "khoj-empty-container" >
2023-09-06 19:04:18 +00:00
< / div >
2023-11-05 00:17:04 +00:00
2023-09-06 19:04:18 +00:00
<!-- 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" >
2023-10-26 19:33:03 +00:00
< a class = "khoj-nav khoj-nav-selected" href = "./chat.html" > 💬 Chat< / a >
2023-11-03 03:40:35 +00:00
< a class = "khoj-nav" href = "./search.html" > 🔎 Search< / a >
2023-10-26 19:33:03 +00:00
< a class = "khoj-nav" href = "./config.html" > ⚙️ Settings< / a >
2023-09-06 19:04:18 +00:00
< / nav >
< / div >
<!-- Chat Body -->
< div id = "chat-body" > < / div >
<!-- Chat Footer -->
< div id = "chat-footer" >
< div id = "chat-tooltip" style = "display: none;" > < / div >
2023-11-04 08:09:35 +00:00
< 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 >
2023-09-06 19:04:18 +00:00
< / div >
< / body >
< style >
html, body {
height: 100%;
width: 100%;
padding: 0px;
margin: 0px;
}
body {
display: grid;
2023-11-03 07:14:07 +00:00
background: var(--background-color);
2023-11-04 05:14:00 +00:00
color: var(--main-text-color);
2023-09-06 19:04:18 +00:00
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);
}
2023-11-07 00:18:41 +00:00
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;
}
2023-11-17 19:04:36 +00:00
code.chat-response {
background: var(--primary-hover);
color: var(--primary-inverse);
border-radius: 5px;
padding: 5px;
font-size: 14px;
font-weight: 300;
line-height: 1.5em;
}
2023-11-07 00:18:41 +00:00
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;
2023-11-11 02:29:52 +00:00
max-height: 75px;
2023-11-07 00:18:41 +00:00
transition: max-height 0.3s ease-in-out;
overflow: hidden;
}
button.reference-button.expanded {
2023-11-11 01:49:20 +00:00
max-height: none;
2023-11-07 00:18:41 +00:00
}
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);
}
2023-09-06 19:04:18 +00:00
.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;
}
2023-11-05 00:17:04 +00:00
div.khoj-empty-container {
padding: 0;
margin: 0;
}
2023-09-06 19:04:18 +00:00
@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 >