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 >
2024-04-15 08:44:05 +00:00
< link rel = "stylesheet" href = "./assets/khoj.css" >
2023-09-06 19:04:18 +00:00
< link rel = "icon" type = "image/png" sizes = "128x128" href = "./assets/icons/favicon-128x128.png" >
2024-02-23 12:59:31 +00:00
< link rel = "manifest" href = "/static/khoj.webmanifest" >
2023-09-06 19:04:18 +00:00
< / head >
2023-11-27 19:45:36 +00:00
< script type = "text/javascript" src = "./assets/markdown-it.min.js" > < / script >
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 = [];
2024-04-16 12:06:10 +00:00
function createCopyParentText(message) {
return function(event) {
copyParentText(event, message);
}
}
function copyParentText(event, message=null) {
2024-04-09 18:05:23 +00:00
const button = event.currentTarget;
2024-04-16 12:06:10 +00:00
const textContent = message ?? button.parentNode.textContent.trim();
2024-04-09 18:05:23 +00:00
navigator.clipboard.writeText(textContent).then(() => {
button.firstChild.src = "./assets/icons/copy-button-success.svg";
2023-11-27 21:05:31 +00:00
setTimeout(() => {
2024-04-09 18:05:23 +00:00
button.firstChild.src = "./assets/icons/copy-button.svg";
2023-11-27 21:05:31 +00:00
}, 1000);
2023-09-06 19:04:18 +00:00
}).catch((error) => {
2024-04-09 18:05:23 +00:00
console.error("Error copying text to clipboard:", error);
const originalButtonText = button.innerHTML;
button.innerHTML = "⛔️";
2023-11-27 21:05:31 +00:00
setTimeout(() => {
2024-04-09 18:05:23 +00:00
button.innerHTML = originalButtonText;
button.firstChild.src = "./assets/icons/copy-button.svg";
}, 2000);
2023-09-06 19:04:18 +00:00
});
}
2024-02-13 11:35:13 +00:00
let region = null;
let city = null;
let countryName = null;
2024-04-26 19:26:49 +00:00
let timezone = null;
2024-02-13 11:35:13 +00:00
fetch("https://ipapi.co/json")
.then(response => response.json())
.then(data => {
region = data.region;
city = data.city;
countryName = data.country_name;
2024-04-26 19:26:49 +00:00
timezone = data.timezone;
2024-02-13 11:35:13 +00:00
})
.catch(err => {
console.log(err);
return;
});
2023-09-06 19:04:18 +00:00
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');
2023-12-22 12:41:49 +00:00
referenceButton.textContent = short_ref;
2023-11-07 00:18:41 +00:00
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");
2023-12-22 12:41:49 +00:00
this.textContent = escaped_ref;
2023-11-07 00:18:41 +00:00
} else {
this.classList.add("collapsed");
this.classList.remove("expanded");
2023-12-22 12:41:49 +00:00
this.textContent = short_ref;
2023-11-07 00:18:41 +00:00
}
});
return referenceButton;
2023-09-06 19:04:18 +00:00
}
2023-11-18 00:41:28 +00:00
function generateOnlineReference(reference, index) {
// Generate HTML for Chat Reference
2024-03-14 12:55:54 +00:00
let title = reference.title || reference.link;
2023-11-18 00:41:28 +00:00
let link = reference.link;
let snippet = reference.snippet;
let question = reference.question;
if (question) {
question = `< b > Question:< / b > ${question}< br > < br > `;
} else {
question = "";
}
let linkElement = document.createElement('a');
linkElement.setAttribute('href', link);
linkElement.setAttribute('target', '_blank');
linkElement.setAttribute('rel', 'noopener noreferrer');
linkElement.classList.add("inline-chat-link");
linkElement.classList.add("reference-link");
linkElement.setAttribute('title', title);
2024-02-21 14:19:16 +00:00
linkElement.textContent = title;
2023-11-18 00:41:28 +00:00
let referenceButton = document.createElement('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 + `< br > < br > ${question + snippet}`;
} else {
this.classList.add("collapsed");
this.classList.remove("expanded");
this.innerHTML = linkElement.outerHTML;
}
});
return referenceButton;
}
2024-04-15 08:14:01 +00:00
function renderMessage(message, by, dt=null, annotations=null, raw=false, renderType="append") {
2023-09-06 19:04:18 +00:00
let message_time = formatDate(dt ?? new Date());
let by_name = by == "khoj" ? "🏮 Khoj" : "🤔 You";
2023-12-18 15:01:50 +00:00
let formattedMessage = formatHTMLMessage(message, raw);
2023-11-07 00:18:41 +00:00
// 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}`;
2023-11-27 19:45:36 +00:00
chatMessageText.appendChild(formattedMessage);
2023-11-07 00:18:41 +00:00
chatMessage.appendChild(chatMessageText);
// Append annotations div to the chat message
if (annotations) {
chatMessageText.appendChild(annotations);
}
// Append chat message div to chat body
2024-02-21 13:57:52 +00:00
let chatBody = document.getElementById("chat-body");
2024-04-15 08:14:01 +00:00
if (renderType === "append") {
chatBody.appendChild(chatMessage);
// Scroll to bottom of chat-body element
chatBody.scrollTop = chatBody.scrollHeight;
} else if (renderType === "prepend") {
chatBody.insertBefore(chatMessage, chatBody.firstChild);
} else if (renderType === "return") {
return chatMessage;
}
2024-02-11 10:35:28 +00:00
let chatBodyWrapper = document.getElementById("chat-body-wrapper");
chatBodyWrapperHeight = chatBodyWrapper.clientHeight;
2023-09-06 19:04:18 +00:00
}
2023-11-18 00:41:28 +00:00
function processOnlineReferences(referenceSection, onlineContext) {
let numOnlineReferences = 0;
2023-11-20 23:19:15 +00:00
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 = generateOnlineReference(reference, index);
referenceSection.appendChild(polishedReference);
}
2023-11-18 00:41:28 +00:00
}
2023-11-20 23:19:15 +00:00
if (onlineReference.knowledgeGraph & & onlineReference.knowledgeGraph.length > 0) {
numOnlineReferences += onlineReference.knowledgeGraph.length;
for (let index in onlineReference.knowledgeGraph) {
let reference = onlineReference.knowledgeGraph[index];
let polishedReference = generateOnlineReference(reference, index);
referenceSection.appendChild(polishedReference);
}
2023-11-18 00:41:28 +00:00
}
2023-11-20 23:19:15 +00:00
if (onlineReference.peopleAlsoAsk & & onlineReference.peopleAlsoAsk.length > 0) {
numOnlineReferences += onlineReference.peopleAlsoAsk.length;
for (let index in onlineReference.peopleAlsoAsk) {
let reference = onlineReference.peopleAlsoAsk[index];
let polishedReference = generateOnlineReference(reference, index);
referenceSection.appendChild(polishedReference);
}
2023-11-18 00:41:28 +00:00
}
2024-03-14 12:55:54 +00:00
if (onlineReference.webpages & & onlineReference.webpages.length > 0) {
numOnlineReferences += onlineReference.webpages.length;
for (let index in onlineReference.webpages) {
let reference = onlineReference.webpages[index];
let polishedReference = generateOnlineReference(reference, index);
2023-11-20 23:19:15 +00:00
referenceSection.appendChild(polishedReference);
}
2023-11-18 00:41:28 +00:00
}
}
return numOnlineReferences;
}
2023-12-17 15:32:55 +00:00
function renderMessageWithReference(message, by, context=null, dt=null, onlineContext=null, intentType=null, inferredQueries=null) {
2024-04-15 08:14:01 +00:00
// If no document or online context is provided, render the message as is
2024-03-06 08:18:41 +00:00
if ((context == null || context.length == 0) & & (onlineContext == null || (onlineContext & & Object.keys(onlineContext).length == 0))) {
2024-03-08 05:24:13 +00:00
if (intentType?.includes("text-to-image")) {
let imageMarkdown;
if (intentType === "text-to-image") {
imageMarkdown = `![](data:image/png;base64,${message})`;
} else if (intentType === "text-to-image2") {
imageMarkdown = `![](${message})`;
2024-04-13 07:41:18 +00:00
} else if (intentType === "text-to-image-v3") {
imageMarkdown = `![](data:image/webp;base64,${message})`;
2024-03-08 05:24:13 +00:00
}
2024-03-06 08:18:41 +00:00
const inferredQuery = inferredQueries?.[0];
if (inferredQuery) {
imageMarkdown += `\n\n**Inferred Query**:\n\n${inferredQuery}`;
}
2024-04-15 08:14:01 +00:00
return renderMessage(imageMarkdown, by, dt, null, false, "return");
2023-12-17 15:32:55 +00:00
}
2024-03-06 08:18:41 +00:00
2024-04-15 08:14:01 +00:00
return renderMessage(message, by, dt, null, false, "return");
2023-12-05 01:56:35 +00:00
}
2023-11-18 00:41:28 +00:00
if (context == null & & onlineContext == null) {
2024-04-15 08:14:01 +00:00
return renderMessage(message, by, dt, null, false, "return");
2023-11-18 00:41:28 +00:00
}
if ((context & & context.length == 0) & & (onlineContext == null || (onlineContext & & Object.keys(onlineContext).length == 0))) {
2024-04-15 08:14:01 +00:00
return renderMessage(message, by, dt, null, false, "return");
2023-11-07 00:18:41 +00:00
}
2024-04-15 08:14:01 +00:00
// If document or online context is provided, render the message with its references
2023-11-07 00:18:41 +00:00
let references = document.createElement('div');
let referenceExpandButton = document.createElement('button');
referenceExpandButton.classList.add("reference-expand-button");
2023-11-18 00:41:28 +00:00
let numReferences = 0;
if (context) {
numReferences += context.length;
}
2023-11-07 00:18:41 +00:00
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-18 00:41:28 +00:00
if (onlineContext) {
numReferences += processOnlineReferences(referenceSection, onlineContext);
}
let expandButtonText = numReferences == 1 ? "1 reference" : `${numReferences} references`;
referenceExpandButton.innerHTML = expandButtonText;
2023-11-07 00:18:41 +00:00
references.appendChild(referenceSection);
2023-09-06 19:04:18 +00:00
2024-03-08 05:24:13 +00:00
if (intentType?.includes("text-to-image")) {
let imageMarkdown;
if (intentType === "text-to-image") {
imageMarkdown = `![](data:image/png;base64,${message})`;
} else if (intentType === "text-to-image2") {
imageMarkdown = `![](${message})`;
2024-04-13 07:41:18 +00:00
} else if (intentType === "text-to-image-v3") {
imageMarkdown = `![](data:image/webp;base64,${message})`;
2024-03-08 05:24:13 +00:00
}
2024-03-06 08:18:41 +00:00
const inferredQuery = inferredQueries?.[0];
if (inferredQuery) {
imageMarkdown += `\n\n**Inferred Query**:\n\n${inferredQuery}`;
}
2024-04-15 08:14:01 +00:00
return renderMessage(imageMarkdown, by, dt, references, false, "return");
2024-03-06 08:18:41 +00:00
}
2024-04-15 08:14:01 +00:00
return renderMessage(message, by, dt, references, false, "return");
2023-09-06 19:04:18 +00:00
}
2024-04-16 12:06:10 +00:00
function formatHTMLMessage(message, raw=false, willReplace=true) {
2023-11-27 19:45:36 +00:00
var md = window.markdownit();
2024-04-16 12:06:10 +00:00
let newHTML = message;
2023-11-27 19:45:36 +00:00
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-27 19:45:36 +00:00
2023-12-05 01:56:35 +00:00
// Customize the rendering of images
md.renderer.rules.image = function(tokens, idx, options, env, self) {
let token = tokens[idx];
// Add class="text-to-image" to images
token.attrPush(['class', 'text-to-image']);
// Use the default renderer to render image markdown format
2023-12-05 03:55:22 +00:00
return self.renderToken(tokens, idx, options);
2023-12-05 01:56:35 +00:00
};
2023-11-27 19:45:36 +00:00
// Render markdown
2023-12-18 15:01:50 +00:00
newHTML = raw ? newHTML : md.render(newHTML);
2024-02-21 13:57:52 +00:00
// Set rendered markdown to HTML DOM element
2023-11-27 19:45:36 +00:00
let element = document.createElement('div');
element.innerHTML = newHTML;
2024-02-21 13:57:52 +00:00
element.className = "chat-message-text-response";
2024-04-09 18:05:52 +00:00
// Add a copy button to each chat message
2024-04-10 09:16:38 +00:00
if (willReplace === true) {
let copyButton = document.createElement('button');
copyButton.classList.add("copy-button");
copyButton.title = "Copy Message";
let copyIcon = document.createElement("img");
copyIcon.src = "./assets/icons/copy-button.svg";
copyIcon.classList.add("copy-icon");
copyButton.appendChild(copyIcon);
2024-04-16 12:06:10 +00:00
copyButton.addEventListener('click', createCopyParentText(message));
2024-04-10 09:16:38 +00:00
element.append(copyButton);
}
2024-04-09 18:05:52 +00:00
2024-02-21 13:57:52 +00:00
// Get any elements with a class that starts with "language"
2023-11-27 19:45:36 +00:00
let codeBlockElements = element.querySelectorAll('[class^="language-"]');
// For each element, add a parent div with the class "programmatic-output"
2024-04-10 09:16:38 +00:00
codeBlockElements.forEach((codeElement, key) => {
2023-11-27 19:45:36 +00:00
// Create the parent div
let parentDiv = document.createElement('div');
parentDiv.classList.add("programmatic-output");
// Add the parent div before the code element
codeElement.parentNode.insertBefore(parentDiv, codeElement);
// Move the code element into the parent div
parentDiv.appendChild(codeElement);
// Add a copy button to each element
2024-04-10 09:16:38 +00:00
if (willReplace === true) {
let copyButton = document.createElement('button');
copyButton.classList.add("copy-button");
copyButton.title = "Copy Code";
let copyIcon = document.createElement("img");
copyIcon.src = "./assets/icons/copy-button.svg";
copyIcon.classList.add("copy-icon");
copyButton.appendChild(copyIcon);
copyButton.addEventListener('click', copyParentText);
codeElement.prepend(copyButton);
}
2023-11-27 19:45:36 +00:00
});
// Get all code elements that have no class.
let codeElements = element.querySelectorAll('code:not([class])');
codeElements.forEach((codeElement) => {
// Add the class "chat-response" to each element
codeElement.classList.add("chat-response");
});
let anchorElements = element.querySelectorAll('a');
anchorElements.forEach((anchorElement) => {
// Add the class "inline-chat-link" to each element
anchorElement.classList.add("inline-chat-link");
});
return element
2023-09-06 19:04:18 +00:00
}
2024-03-06 08:18:41 +00:00
function createReferenceSection(references) {
let referenceSection = document.createElement('div');
referenceSection.classList.add("reference-section");
referenceSection.classList.add("collapsed");
let numReferences = 0;
2024-03-13 22:06:26 +00:00
if (references.hasOwnProperty("notes")) {
numReferences += references["notes"].length;
2024-03-06 08:18:41 +00:00
2024-03-13 22:06:26 +00:00
references["notes"].forEach((reference, index) => {
2024-03-06 08:18:41 +00:00
let polishedReference = generateReference(reference, index);
referenceSection.appendChild(polishedReference);
});
2024-03-13 22:06:26 +00:00
}
if (references.hasOwnProperty("online")){
numReferences += processOnlineReferences(referenceSection, references["online"]);
2024-03-06 08:18:41 +00:00
}
let referenceExpandButton = document.createElement('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 = document.createElement('div');
referencesDiv.classList.add("references");
referencesDiv.appendChild(referenceExpandButton);
referencesDiv.appendChild(referenceSection);
return referencesDiv;
}
2023-09-06 19:04:18 +00:00
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");
2024-02-11 10:35:28 +00:00
let chat_body = document.getElementById("chat-body");
let conversationID = chat_body.dataset.conversationId;
2023-09-06 19:04:18 +00:00
let hostURL = await window.hostURLAPI.getURL();
2024-02-21 14:05:06 +00:00
const khojToken = await window.tokenAPI.getToken();
const headers = { 'Authorization': `Bearer ${khojToken}` };
2023-09-06 19:04:18 +00:00
2024-02-11 10:35:28 +00:00
if (!conversationID) {
2024-02-21 14:05:06 +00:00
let response = await fetch(`${hostURL}/api/chat/sessions`, { method: "POST", headers });
2024-02-11 10:35:28 +00:00
let data = await response.json();
conversationID = data.conversation_id;
chat_body.dataset.conversationId = conversationID;
Miscellaneous bugs and fixes for chat sessions (#646)
* Display given_name field only if it is not None
* Add default slugs in the migration script
* Ensure that updated_at is saved appropriately, make sure most recent chat is returned for default history
* Remove the bin button from the chat interface, given deletion is handled in the drop-down menus
* Refresh the side panel when a new chat is created
* Improveme tool retrieval prompt, don't let /online fail, and improve parsing of extract questions
* Fix ending chat response by offline chat on hitting a stop phrase
Previously the whole phrase wouldn't be in the same response chunk, so
chat response wouldn't stop on hitting a stop phrase
Now use a queue to keep track of last 3 chunks, and to stop responding
when hit a stop phrase
* Make chat on Obsidian backward compatible post chat session API updates
- Make chat on Obsidian get chat history from
`responseJson.response.chat' when available (i.e when using new api)
- Else fallback to loading chat history from
responseJson.response (i.e when using old api)
* Fix detecting success of indexing update in khoj.el
When khoj.el attempts to index on a Khoj server served behind an https
endpoint, the success reponse status contains plist with certs. This
doesn't mean the update failed.
Look for :errors key in status instead to determine if indexing API
call failed. This fixes detecting indexing API call success on the
Khoj Emacs client, even for Khoj servers running behind SSL/HTTPS
* Fix the mechanism for populating notes references in the conversation primer for both offline and online chat
* Return conversation.default when empty list for dynamic prompt selection, send all cmds in telemetry
* Fix making chat on Obsidian backward compatible post chat session API updates
New API always has conversation_id set, not `chat' which can be unset
when chat session is empty.
So use conversation_id to decide whether to get chat logs from
`responseJson.response.chat' or `responseJson.response' instead
---------
Co-authored-by: Debanjum Singh Solanky <debanjum@gmail.com>
2024-02-20 21:55:35 +00:00
await refreshChatSessionsPanel();
2024-02-11 10:35:28 +00:00
}
2023-09-06 19:04:18 +00:00
// Generate backend API URL to execute query
2024-04-26 19:26:49 +00:00
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}`;
2023-09-06 19:04:18 +00:00
2024-04-17 16:51:51 +00:00
let newResponseEl = document.createElement("div");
newResponseEl.classList.add("chat-message", "khoj");
newResponseEl.attributes["data-meta"] = "🏮 Khoj at " + formatDate(new Date());
chat_body.appendChild(newResponseEl);
2023-09-06 19:04:18 +00:00
2024-04-17 16:51:51 +00:00
let newResponseTextEl = document.createElement("div");
newResponseTextEl.classList.add("chat-message-text", "khoj");
newResponseEl.appendChild(newResponseTextEl);
2023-09-06 19:04:18 +00:00
// Temporary status message to indicate that Khoj is thinking
2024-03-08 05:24:13 +00:00
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);
2024-04-17 16:51:51 +00:00
newResponseTextEl.appendChild(loadingEllipsis);
2023-09-06 19:04:18 +00:00
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");
2024-02-21 14:05:06 +00:00
// Call Khoj chat API
let response = await fetch(chatApi, { headers });
2023-12-05 05:41:16 +00:00
let rawResponse = "";
2024-03-06 08:18:41 +00:00
let references = null;
2023-12-05 06:03:52 +00:00
const contentType = response.headers.get("content-type");
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.
2024-03-08 05:24:13 +00:00
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})`;
2024-04-13 07:41:18 +00:00
} else if (responseAsJson.intentType === "text-to-image-v3") {
rawResponse += `![${query}](data:image/webp;base64,${responseAsJson.image})`;
2024-03-08 05:24:13 +00:00
}
2023-12-17 15:32:55 +00:00
const inferredQueries = responseAsJson.inferredQueries?.[0];
if (inferredQueries) {
2024-02-24 19:09:58 +00:00
rawResponse += `\n\n**Inferred Query**:\n\n${inferredQueries}`;
2023-12-17 15:32:55 +00:00
}
2023-12-05 05:41:16 +00:00
}
2024-03-06 08:18:41 +00:00
if (responseAsJson.context) {
const rawReferenceAsJson = responseAsJson.context;
references = createReferenceSection(rawReferenceAsJson);
}
2023-12-05 06:03:52 +00:00
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 {
2024-04-17 16:51:51 +00:00
newResponseTextEl.innerHTML = "";
newResponseTextEl.appendChild(formatHTMLMessage(rawResponse));
2023-09-06 19:04:18 +00:00
2024-03-06 08:18:41 +00:00
if (references != null) {
2024-04-17 16:51:51 +00:00
newResponseTextEl.appendChild(references);
2024-03-06 08:18:41 +00:00
}
2023-12-05 06:03:52 +00:00
document.getElementById("chat-body").scrollTop = document.getElementById("chat-body").scrollHeight;
document.getElementById("chat-input").removeAttribute("disabled");
}
} else {
// Handle streamed response of type text/event-stream or text/plain
const reader = response.body.getReader();
const decoder = new TextDecoder();
2024-03-13 22:06:26 +00:00
let references = {};
2023-12-05 06:03:52 +00:00
readStream();
function readStream() {
reader.read().then(({ done, value }) => {
if (done) {
// Append any references after all the data has been streamed
2024-03-13 22:06:26 +00:00
if (references != {}) {
2024-04-17 16:51:51 +00:00
newResponseTextEl.appendChild(createReferenceSection(references));
2023-12-05 06:03:52 +00:00
}
document.getElementById("chat-body").scrollTop = document.getElementById("chat-body").scrollHeight;
document.getElementById("chat-input").removeAttribute("disabled");
return;
}
2023-09-06 19:04:18 +00:00
2023-12-05 06:03:52 +00:00
// Decode message chunk from stream
const chunk = decoder.decode(value, { stream: true });
2023-11-07 00:18:41 +00:00
2023-12-05 06:03:52 +00:00
if (chunk.includes("### compiled references:")) {
const additionalResponse = chunk.split("### compiled references:")[0];
rawResponse += additionalResponse;
2024-04-17 16:51:51 +00:00
newResponseTextEl.innerHTML = "";
newResponseTextEl.appendChild(formatHTMLMessage(rawResponse));
2023-11-07 00:18:41 +00:00
2023-12-05 06:03:52 +00:00
const rawReference = chunk.split("### compiled references:")[1];
const rawReferenceAsJson = JSON.parse(rawReference);
2024-03-13 22:06:26 +00:00
if (rawReferenceAsJson instanceof Array) {
references["notes"] = rawReferenceAsJson;
} else if (typeof rawReferenceAsJson === "object" & & rawReferenceAsJson !== null) {
references["online"] = rawReferenceAsJson;
}
2023-12-05 06:03:52 +00:00
readStream();
} else {
// Display response from Khoj
2024-04-17 16:51:51 +00:00
if (newResponseTextEl.getElementsByClassName("lds-ellipsis").length > 0) {
newResponseTextEl.removeChild(loadingEllipsis);
2023-12-05 06:03:52 +00:00
}
2024-03-08 05:24:13 +00:00
// If the chunk is not a JSON object, just display it as is
rawResponse += chunk;
2024-04-17 16:51:51 +00:00
newResponseTextEl.innerHTML = "";
newResponseTextEl.appendChild(formatHTMLMessage(rawResponse));
2023-12-05 05:41:16 +00:00
2024-03-08 05:24:13 +00:00
readStream();
2023-12-05 05:41:16 +00:00
}
2023-12-05 06:03:52 +00:00
// Scroll to bottom of chat window as chat response is streamed
document.getElementById("chat-body").scrollTop = document.getElementById("chat-body").scrollHeight;
});
}
2023-12-05 05:41:16 +00:00
}
2023-09-06 19:04:18 +00:00
}
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();
2023-11-20 23:21:06 +00:00
let questionStarterSuggestions = document.getElementById("question-starters");
2024-02-22 14:41:03 +00:00
questionStarterSuggestions.innerHTML = "";
2023-11-20 23:21:06 +00:00
questionStarterSuggestions.style.display = "none";
2023-09-06 19:04:18 +00:00
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';
2024-01-19 15:10:04 +00:00
const scrollHeight = textarea.scrollHeight + 8; // +8 accounts for padding
2023-09-06 19:04:18 +00:00
textarea.style.height = Math.min(scrollHeight, 200) + 'px';
textarea.scrollTop = scrollTop;
document.getElementById("chat-body").scrollTop = document.getElementById("chat-body").scrollHeight;
}
2024-04-07 15:28:43 +00:00
window.addEventListener("DOMContentLoaded", async() => {
// Setup the header pane
document.getElementById("khoj-header").innerHTML = await populateHeaderPane();
// Setup the nav menu
document.getElementById("profile-picture").addEventListener("click", toggleNavMenu);
// Set the active nav pane
document.getElementById("chat-nav")?.classList.add("khoj-nav-selected");
})
2023-09-06 19:04:18 +00:00
window.addEventListener('load', async() => {
await loadChat();
});
2024-04-23 05:29:49 +00:00
async function getChatHistoryUrl() {
2023-09-06 19:04:18 +00:00
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}` };
2024-02-11 10:35:28 +00:00
let chatBody = document.getElementById("chat-body");
2024-02-22 14:41:03 +00:00
chatBody.innerHTML = "";
2024-04-15 08:14:01 +00:00
let chatHistoryUrl = `${hostURL}/api/chat/history?client=desktop`;
2024-03-09 14:29:30 +00:00
if (chatBody.dataset.conversationId) {
chatHistoryUrl += `&conversation_id=${chatBody.dataset.conversationId}`;
2024-02-11 10:35:28 +00:00
}
2024-04-23 05:29:49 +00:00
return { chatHistoryUrl, headers };
}
async function loadChat() {
// Load chat history and body
const hostURL = await window.hostURLAPI.getURL();
const { chatHistoryUrl, headers } = await getChatHistoryUrl();
2024-02-11 10:35:28 +00:00
2024-04-10 04:28:04 +00:00
// Create loading screen and add it to chat-body
let loadingScreen = document.createElement('div');
loadingScreen.classList.add("loading-spinner");
let yellowOrb = document.createElement('div');
loadingScreen.appendChild(yellowOrb);
2024-04-23 05:29:49 +00:00
let chatBody = document.getElementById("chat-body");
2024-04-10 04:28:04 +00:00
chatBody.appendChild(loadingScreen);
2024-04-15 08:14:01 +00:00
// Get the most recent 10 chat messages from conversation history
2024-04-23 05:29:49 +00:00
let firstRunSetupMessageRendered = false;
2024-04-15 08:14:01 +00:00
fetch(`${chatHistoryUrl}& n=10`, { 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.
2024-04-07 16:18:21 +00:00
renderFirstRunSetupMessage();
firstRunSetupMessageRendered = true;
2024-03-09 14:29:30 +00:00
} else if (data.status != "ok") {
throw new Error(data.message);
2023-09-06 19:04:18 +00:00
} else {
// Set welcome message on load
renderMessage("Hey 👋🏾, what's up?", "khoj");
}
return data.response;
})
.then(response => {
2024-02-11 10:35:28 +00:00
let chatBody = document.getElementById("chat-body");
2024-03-09 14:29:30 +00:00
chatBody.dataset.conversationId = response.conversation_id;
chatBody.dataset.conversationTitle = response.slug || `New conversation 🌱`;
2024-02-11 10:35:28 +00:00
2024-04-15 08:14:01 +00:00
// Create a new IntersectionObserver
let fetchRemainingMessagesObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
// If the element is in the viewport, fetch the remaining message and unobserve the element
if (entry.isIntersecting) {
2024-04-23 05:29:49 +00:00
fetchRemainingChatMessages(chatHistoryUrl, headers);
2024-04-15 08:14:01 +00:00
observer.unobserve(entry.target);
}
});
}, {rootMargin: '0px 0px 0px 0px'});
2024-02-11 10:18:28 +00:00
2024-04-15 08:14:01 +00:00
const fullChatLog = response.chat || [];
fullChatLog.forEach((chat_log, index) => {
2024-02-11 10:35:28 +00:00
if (chat_log.message != null) {
2024-04-15 08:14:01 +00:00
let messageElement = renderMessageWithReference(
2024-02-11 10:35:28 +00:00
chat_log.message,
chat_log.by,
chat_log.context,
new Date(chat_log.created),
chat_log.onlineContext,
chat_log.intent?.type,
chat_log.intent?.["inferred-queries"]);
2024-04-15 08:14:01 +00:00
chatBody.appendChild(messageElement);
// When the 4th oldest message is within viewing distance (~60% scrolled up)
// Fetch the remaining chat messages
if (index === 4) {
fetchRemainingMessagesObserver.observe(messageElement);
}
2024-02-11 10:35:28 +00:00
}
2024-04-10 04:28:04 +00:00
loadingScreen.style.height = chatBody.scrollHeight + 'px';
2024-02-11 10:35:28 +00:00
})
2024-04-10 04:28:04 +00:00
2024-04-15 08:14:01 +00:00
// Scroll to bottom of chat-body element
chatBody.scrollTop = chatBody.scrollHeight;
// Set height of chat-body element to the height of the chat-body-wrapper
let chatBodyWrapper = document.getElementById("chat-body-wrapper");
let chatBodyWrapperHeight = chatBodyWrapper.clientHeight;
chatBody.style.height = chatBodyWrapperHeight;
2024-04-10 04:28:04 +00:00
// Add fade out animation to loading screen and remove it after the animation ends
2024-04-12 06:20:02 +00:00
fadeOutLoadingAnimation(loadingScreen);
2023-09-06 19:04:18 +00:00
})
.catch(err => {
2024-02-11 10:35:28 +00:00
// If the server returns a 500 error with detail, render a setup hint.
2024-04-12 06:20:02 +00:00
if (!firstRunSetupMessageRendered) {
2024-04-07 16:18:21 +00:00
renderFirstRunSetupMessage();
2024-04-12 06:20:02 +00:00
}
2024-04-13 05:32:44 +00:00
fadeOutLoadingAnimation(loadingScreen);
return;
2023-09-06 19:04:18 +00:00
});
Miscellaneous bugs and fixes for chat sessions (#646)
* Display given_name field only if it is not None
* Add default slugs in the migration script
* Ensure that updated_at is saved appropriately, make sure most recent chat is returned for default history
* Remove the bin button from the chat interface, given deletion is handled in the drop-down menus
* Refresh the side panel when a new chat is created
* Improveme tool retrieval prompt, don't let /online fail, and improve parsing of extract questions
* Fix ending chat response by offline chat on hitting a stop phrase
Previously the whole phrase wouldn't be in the same response chunk, so
chat response wouldn't stop on hitting a stop phrase
Now use a queue to keep track of last 3 chunks, and to stop responding
when hit a stop phrase
* Make chat on Obsidian backward compatible post chat session API updates
- Make chat on Obsidian get chat history from
`responseJson.response.chat' when available (i.e when using new api)
- Else fallback to loading chat history from
responseJson.response (i.e when using old api)
* Fix detecting success of indexing update in khoj.el
When khoj.el attempts to index on a Khoj server served behind an https
endpoint, the success reponse status contains plist with certs. This
doesn't mean the update failed.
Look for :errors key in status instead to determine if indexing API
call failed. This fixes detecting indexing API call success on the
Khoj Emacs client, even for Khoj servers running behind SSL/HTTPS
* Fix the mechanism for populating notes references in the conversation primer for both offline and online chat
* Return conversation.default when empty list for dynamic prompt selection, send all cmds in telemetry
* Fix making chat on Obsidian backward compatible post chat session API updates
New API always has conversation_id set, not `chat' which can be unset
when chat session is empty.
So use conversation_id to decide whether to get chat logs from
`responseJson.response.chat' or `responseJson.response' instead
---------
Co-authored-by: Debanjum Singh Solanky <debanjum@gmail.com>
2024-02-20 21:55:35 +00:00
await refreshChatSessionsPanel();
fetch(`${hostURL}/api/chat/starters?client=desktop`, { headers })
.then(response => response.json())
.then(data => {
2024-02-22 14:41:03 +00:00
// Render conversation starters, if any
Miscellaneous bugs and fixes for chat sessions (#646)
* Display given_name field only if it is not None
* Add default slugs in the migration script
* Ensure that updated_at is saved appropriately, make sure most recent chat is returned for default history
* Remove the bin button from the chat interface, given deletion is handled in the drop-down menus
* Refresh the side panel when a new chat is created
* Improveme tool retrieval prompt, don't let /online fail, and improve parsing of extract questions
* Fix ending chat response by offline chat on hitting a stop phrase
Previously the whole phrase wouldn't be in the same response chunk, so
chat response wouldn't stop on hitting a stop phrase
Now use a queue to keep track of last 3 chunks, and to stop responding
when hit a stop phrase
* Make chat on Obsidian backward compatible post chat session API updates
- Make chat on Obsidian get chat history from
`responseJson.response.chat' when available (i.e when using new api)
- Else fallback to loading chat history from
responseJson.response (i.e when using old api)
* Fix detecting success of indexing update in khoj.el
When khoj.el attempts to index on a Khoj server served behind an https
endpoint, the success reponse status contains plist with certs. This
doesn't mean the update failed.
Look for :errors key in status instead to determine if indexing API
call failed. This fixes detecting indexing API call success on the
Khoj Emacs client, even for Khoj servers running behind SSL/HTTPS
* Fix the mechanism for populating notes references in the conversation primer for both offline and online chat
* Return conversation.default when empty list for dynamic prompt selection, send all cmds in telemetry
* Fix making chat on Obsidian backward compatible post chat session API updates
New API always has conversation_id set, not `chat' which can be unset
when chat session is empty.
So use conversation_id to decide whether to get chat logs from
`responseJson.response.chat' or `responseJson.response' instead
---------
Co-authored-by: Debanjum Singh Solanky <debanjum@gmail.com>
2024-02-20 21:55:35 +00:00
if (data.length > 0) {
let questionStarterSuggestions = document.getElementById("question-starters");
2024-02-22 14:41:03 +00:00
questionStarterSuggestions.innerHTML = "";
data.forEach((questionStarter) => {
Miscellaneous bugs and fixes for chat sessions (#646)
* Display given_name field only if it is not None
* Add default slugs in the migration script
* Ensure that updated_at is saved appropriately, make sure most recent chat is returned for default history
* Remove the bin button from the chat interface, given deletion is handled in the drop-down menus
* Refresh the side panel when a new chat is created
* Improveme tool retrieval prompt, don't let /online fail, and improve parsing of extract questions
* Fix ending chat response by offline chat on hitting a stop phrase
Previously the whole phrase wouldn't be in the same response chunk, so
chat response wouldn't stop on hitting a stop phrase
Now use a queue to keep track of last 3 chunks, and to stop responding
when hit a stop phrase
* Make chat on Obsidian backward compatible post chat session API updates
- Make chat on Obsidian get chat history from
`responseJson.response.chat' when available (i.e when using new api)
- Else fallback to loading chat history from
responseJson.response (i.e when using old api)
* Fix detecting success of indexing update in khoj.el
When khoj.el attempts to index on a Khoj server served behind an https
endpoint, the success reponse status contains plist with certs. This
doesn't mean the update failed.
Look for :errors key in status instead to determine if indexing API
call failed. This fixes detecting indexing API call success on the
Khoj Emacs client, even for Khoj servers running behind SSL/HTTPS
* Fix the mechanism for populating notes references in the conversation primer for both offline and online chat
* Return conversation.default when empty list for dynamic prompt selection, send all cmds in telemetry
* Fix making chat on Obsidian backward compatible post chat session API updates
New API always has conversation_id set, not `chat' which can be unset
when chat session is empty.
So use conversation_id to decide whether to get chat logs from
`responseJson.response.chat' or `responseJson.response' instead
---------
Co-authored-by: Debanjum Singh Solanky <debanjum@gmail.com>
2024-02-20 21:55:35 +00:00
let questionStarterButton = document.createElement('button');
questionStarterButton.innerHTML = questionStarter;
questionStarterButton.classList.add("question-starter");
questionStarterButton.addEventListener('click', function() {
questionStarterSuggestions.style.display = "none";
document.getElementById("chat-input").value = questionStarter;
chat();
});
questionStarterSuggestions.appendChild(questionStarterButton);
2024-02-22 14:41:03 +00:00
});
Miscellaneous bugs and fixes for chat sessions (#646)
* Display given_name field only if it is not None
* Add default slugs in the migration script
* Ensure that updated_at is saved appropriately, make sure most recent chat is returned for default history
* Remove the bin button from the chat interface, given deletion is handled in the drop-down menus
* Refresh the side panel when a new chat is created
* Improveme tool retrieval prompt, don't let /online fail, and improve parsing of extract questions
* Fix ending chat response by offline chat on hitting a stop phrase
Previously the whole phrase wouldn't be in the same response chunk, so
chat response wouldn't stop on hitting a stop phrase
Now use a queue to keep track of last 3 chunks, and to stop responding
when hit a stop phrase
* Make chat on Obsidian backward compatible post chat session API updates
- Make chat on Obsidian get chat history from
`responseJson.response.chat' when available (i.e when using new api)
- Else fallback to loading chat history from
responseJson.response (i.e when using old api)
* Fix detecting success of indexing update in khoj.el
When khoj.el attempts to index on a Khoj server served behind an https
endpoint, the success reponse status contains plist with certs. This
doesn't mean the update failed.
Look for :errors key in status instead to determine if indexing API
call failed. This fixes detecting indexing API call success on the
Khoj Emacs client, even for Khoj servers running behind SSL/HTTPS
* Fix the mechanism for populating notes references in the conversation primer for both offline and online chat
* Return conversation.default when empty list for dynamic prompt selection, send all cmds in telemetry
* Fix making chat on Obsidian backward compatible post chat session API updates
New API always has conversation_id set, not `chat' which can be unset
when chat session is empty.
So use conversation_id to decide whether to get chat logs from
`responseJson.response.chat' or `responseJson.response' instead
---------
Co-authored-by: Debanjum Singh Solanky <debanjum@gmail.com>
2024-02-20 21:55:35 +00:00
questionStarterSuggestions.style.display = "grid";
}
})
.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();
}
}
2024-04-23 05:29:49 +00:00
function fetchRemainingChatMessages(chatHistoryUrl, headers) {
2024-04-15 08:14:01 +00:00
// Create a new IntersectionObserver
let observer = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
// If the element is in the viewport, render the message and unobserve the element
if (entry.isIntersecting) {
let chat_log = entry.target.chat_log;
let messageElement = renderMessageWithReference(
chat_log.message,
chat_log.by,
chat_log.context,
new Date(chat_log.created),
chat_log.onlineContext,
chat_log.intent?.type,
chat_log.intent?.["inferred-queries"]
);
entry.target.replaceWith(messageElement);
// Remove the observer after the element has been rendered
observer.unobserve(entry.target);
}
});
}, {rootMargin: '0px 0px 200px 0px'}); // Trigger when the element is within 200px of the viewport
// Fetch remaining chat messages from conversation history
2024-04-23 05:29:49 +00:00
fetch(`${chatHistoryUrl}& n=-10`, { headers })
2024-04-15 08:14:01 +00:00
.then(response => response.json())
.then(data => {
if (data.status != "ok") {
throw new Error(data.message);
}
return data.response;
})
.then(response => {
const fullChatLog = response.chat || [];
let chatBody = document.getElementById("chat-body");
fullChatLog
.reverse()
.forEach(chat_log => {
if (chat_log.message != null) {
// Create a new element for each chat log
let placeholder = document.createElement('div');
placeholder.chat_log = chat_log;
// Insert the message placeholder as the first child of chat body after the welcome message
chatBody.insertBefore(placeholder, chatBody.firstChild.nextSibling);
// Observe the element
placeholder.style.height = "20px";
observer.observe(placeholder);
}
});
})
.catch(err => {
console.log(err);
return;
});
}
2024-04-12 06:20:02 +00:00
function fadeOutLoadingAnimation(loadingScreen) {
let chatBody = document.getElementById("chat-body");
let chatBodyWrapper = document.getElementById("chat-body-wrapper");
chatBodyWrapperHeight = chatBodyWrapper.clientHeight;
chatBody.style.height = chatBodyWrapperHeight;
setTimeout(() => {
loadingScreen.remove();
chatBody.classList.remove("relative-position");
}, 500);
}
2024-04-07 16:18:21 +00:00
function renderFirstRunSetupMessage() {
2024-04-23 11:13:48 +00:00
first_run_message = `< p class = "first-run-message-heading" > Hi 👋🏾, to get started:< p >
2024-04-07 16:18:21 +00:00
< ol >
2024-04-23 11:13:48 +00:00
< li class = "first-run-message-text" > Generate an API token < a class = 'first-run-message-link' href = "#" onclick = "window.navigateAPI.navigateToWebSettings()" > Khoj Web settings< / a > < / li >
< li class = "first-run-message-text" > Paste it into the API Key field < a class = 'first-run-message-link' href = "#" onclick = "window.navigateAPI.navigateToSettings()" > Khoj Desktop settings< / a > < / li >
2024-04-07 16:18:21 +00:00
< / ol > `
.trim()
.replace(/(\r\n|\n|\r)/gm, "");
renderMessage(first_run_message, "khoj", null, null, true);
// 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");
}
Miscellaneous bugs and fixes for chat sessions (#646)
* Display given_name field only if it is not None
* Add default slugs in the migration script
* Ensure that updated_at is saved appropriately, make sure most recent chat is returned for default history
* Remove the bin button from the chat interface, given deletion is handled in the drop-down menus
* Refresh the side panel when a new chat is created
* Improveme tool retrieval prompt, don't let /online fail, and improve parsing of extract questions
* Fix ending chat response by offline chat on hitting a stop phrase
Previously the whole phrase wouldn't be in the same response chunk, so
chat response wouldn't stop on hitting a stop phrase
Now use a queue to keep track of last 3 chunks, and to stop responding
when hit a stop phrase
* Make chat on Obsidian backward compatible post chat session API updates
- Make chat on Obsidian get chat history from
`responseJson.response.chat' when available (i.e when using new api)
- Else fallback to loading chat history from
responseJson.response (i.e when using old api)
* Fix detecting success of indexing update in khoj.el
When khoj.el attempts to index on a Khoj server served behind an https
endpoint, the success reponse status contains plist with certs. This
doesn't mean the update failed.
Look for :errors key in status instead to determine if indexing API
call failed. This fixes detecting indexing API call success on the
Khoj Emacs client, even for Khoj servers running behind SSL/HTTPS
* Fix the mechanism for populating notes references in the conversation primer for both offline and online chat
* Return conversation.default when empty list for dynamic prompt selection, send all cmds in telemetry
* Fix making chat on Obsidian backward compatible post chat session API updates
New API always has conversation_id set, not `chat' which can be unset
when chat session is empty.
So use conversation_id to decide whether to get chat logs from
`responseJson.response.chat' or `responseJson.response' instead
---------
Co-authored-by: Debanjum Singh Solanky <debanjum@gmail.com>
2024-02-20 21:55:35 +00:00
function flashStatusInChatInput(message) {
// Get chat input element and original placeholder
let chatInput = document.getElementById("chat-input");
let originalPlaceholder = chatInput.placeholder;
// Set placeholder to message
chatInput.placeholder = message;
// Reset placeholder after 2 seconds
setTimeout(() => {
chatInput.placeholder = originalPlaceholder;
}, 2000);
}
function createNewConversation() {
let chatBody = document.getElementById("chat-body");
chatBody.innerHTML = "";
flashStatusInChatInput("📝 New conversation started");
chatBody.dataset.conversationId = "";
chatBody.dataset.conversationTitle = "";
renderMessage("Hey 👋🏾, what's up?", "khoj");
}
async function clearConversationHistory() {
let chatInput = document.getElementById("chat-input");
let originalPlaceholder = chatInput.placeholder;
let chatBody = document.getElementById("chat-body");
let conversationId = chatBody.dataset.conversationId;
let deleteURL = `/api/chat/history?client=desktop`;
if (conversationId) {
deleteURL += `&conversation_id=${conversationId}`;
}
const hostURL = await window.hostURLAPI.getURL();
const khojToken = await window.tokenAPI.getToken();
const headers = { 'Authorization': `Bearer ${khojToken}` };
fetch(`${hostURL}${deleteURL}`, { method: "DELETE", headers })
.then(response => response.ok ? response.json() : Promise.reject(response))
.then(data => {
chatBody.innerHTML = "";
chatBody.dataset.conversationId = "";
chatBody.dataset.conversationTitle = "";
loadChat();
})
.catch(err => {
flashStatusInChatInput("⛔️ Failed to clear conversation history");
})
}
async function refreshChatSessionsPanel() {
const hostURL = await window.hostURLAPI.getURL();
const khojToken = await window.tokenAPI.getToken();
const headers = { 'Authorization': `Bearer ${khojToken}` };
2024-02-11 10:35:28 +00:00
fetch(`${hostURL}/api/chat/sessions`, { method: "GET", headers })
.then(response => response.json())
.then(data => {
let conversationListBody = document.getElementById("conversation-list-body");
conversationListBody.innerHTML = "";
let conversationListBodyHeader = document.getElementById("conversation-list-header");
let chatBody = document.getElementById("chat-body");
conversationId = chatBody.dataset.conversationId;
if (data.length > 0) {
conversationListBodyHeader.style.display = "block";
for (let index in data) {
let conversation = data[index];
let conversationButton = document.createElement('div');
let incomingConversationId = conversation["conversation_id"];
const conversationTitle = conversation["slug"] || `New conversation 🌱`;
2024-02-21 14:19:16 +00:00
conversationButton.textContent = conversationTitle;
2024-02-11 10:35:28 +00:00
conversationButton.classList.add("conversation-button");
if (incomingConversationId == conversationId) {
conversationButton.classList.add("selected-conversation");
}
conversationButton.addEventListener('click', function() {
let chatBody = document.getElementById("chat-body");
chatBody.innerHTML = "";
chatBody.dataset.conversationId = incomingConversationId;
chatBody.dataset.conversationTitle = conversationTitle;
loadChat();
});
let threeDotMenu = document.createElement('div');
threeDotMenu.classList.add("three-dot-menu");
let threeDotMenuButton = document.createElement('button');
threeDotMenuButton.innerHTML = "⋮";
threeDotMenuButton.classList.add("three-dot-menu-button");
threeDotMenuButton.addEventListener('click', function(event) {
event.stopPropagation();
let existingChildren = threeDotMenu.children;
if (existingChildren.length > 1) {
// Skip deleting the first, since that's the menu button.
for (let i = 1; i < existingChildren.length ; i + + ) {
existingChildren[i].remove();
}
return;
}
let conversationMenu = document.createElement('div');
conversationMenu.classList.add("conversation-menu");
let editTitleButton = document.createElement('button');
editTitleButton.innerHTML = "Rename";
editTitleButton.classList.add("edit-title-button");
editTitleButton.classList.add("three-dot-menu-button-item");
editTitleButton.addEventListener('click', function(event) {
event.stopPropagation();
let conversationMenuChildren = conversationMenu.children;
let totalItems = conversationMenuChildren.length;
for (let i = totalItems - 1; i >= 0; i--) {
conversationMenuChildren[i].remove();
}
// Create a dialog box to get new title for conversation
let conversationTitleInputBox = document.createElement('div');
conversationTitleInputBox.classList.add("conversation-title-input-box");
let conversationTitleInput = document.createElement('input');
conversationTitleInput.classList.add("conversation-title-input");
conversationTitleInput.value = conversationTitle;
conversationTitleInput.addEventListener('click', function(event) {
event.stopPropagation();
2024-03-15 08:19:47 +00:00
});
conversationTitleInput.addEventListener('keydown', function(event) {
2024-02-11 10:35:28 +00:00
if (event.key === "Enter") {
event.preventDefault();
conversationTitleInputButton.click();
}
});
conversationTitleInputBox.appendChild(conversationTitleInput);
let conversationTitleInputButton = document.createElement('button');
conversationTitleInputButton.innerHTML = "Save";
conversationTitleInputButton.classList.add("three-dot-menu-button-item");
conversationTitleInputButton.addEventListener('click', function(event) {
event.stopPropagation();
let newTitle = conversationTitleInput.value;
if (newTitle != null) {
let editURL = `/api/chat/title?client=web&conversation_id=${incomingConversationId}&title=${newTitle}`;
2024-03-15 07:36:08 +00:00
fetch(`${hostURL}${editURL}` , { method: "PATCH", headers })
2024-02-11 10:35:28 +00:00
.then(response => response.ok ? response.json() : Promise.reject(response))
.then(data => {
2024-02-21 14:19:16 +00:00
conversationButton.textContent = newTitle;
2024-02-11 10:35:28 +00:00
})
.catch(err => {
return;
});
conversationTitleInputBox.remove();
}});
conversationTitleInputBox.appendChild(conversationTitleInputButton);
conversationMenu.appendChild(conversationTitleInputBox);
});
2024-03-15 08:19:47 +00:00
2024-02-11 10:35:28 +00:00
conversationMenu.appendChild(editTitleButton);
threeDotMenu.appendChild(conversationMenu);
2024-03-15 08:19:47 +00:00
let deleteButton = document.createElement('button');
deleteButton.innerHTML = "Delete";
deleteButton.classList.add("delete-conversation-button");
deleteButton.classList.add("three-dot-menu-button-item");
deleteButton.addEventListener('click', function() {
// Ask for confirmation before deleting chat session
let confirmation = confirm('Are you sure you want to delete this chat session?');
if (!confirmation) return;
let deleteURL = `/api/chat/history?client=web&conversation_id=${incomingConversationId}`;
fetch(`${hostURL}${deleteURL}` , { method: "DELETE", headers })
.then(response => response.ok ? response.json() : Promise.reject(response))
.then(data => {
let chatBody = document.getElementById("chat-body");
chatBody.innerHTML = "";
chatBody.dataset.conversationId = "";
chatBody.dataset.conversationTitle = "";
loadChat();
})
.catch(err => {
return;
});
});
conversationMenu.appendChild(deleteButton);
threeDotMenu.appendChild(conversationMenu);
2024-02-11 10:35:28 +00:00
});
threeDotMenu.appendChild(threeDotMenuButton);
conversationButton.appendChild(threeDotMenu);
conversationListBody.appendChild(conversationButton);
}
}
Miscellaneous bugs and fixes for chat sessions (#646)
* Display given_name field only if it is not None
* Add default slugs in the migration script
* Ensure that updated_at is saved appropriately, make sure most recent chat is returned for default history
* Remove the bin button from the chat interface, given deletion is handled in the drop-down menus
* Refresh the side panel when a new chat is created
* Improveme tool retrieval prompt, don't let /online fail, and improve parsing of extract questions
* Fix ending chat response by offline chat on hitting a stop phrase
Previously the whole phrase wouldn't be in the same response chunk, so
chat response wouldn't stop on hitting a stop phrase
Now use a queue to keep track of last 3 chunks, and to stop responding
when hit a stop phrase
* Make chat on Obsidian backward compatible post chat session API updates
- Make chat on Obsidian get chat history from
`responseJson.response.chat' when available (i.e when using new api)
- Else fallback to loading chat history from
responseJson.response (i.e when using old api)
* Fix detecting success of indexing update in khoj.el
When khoj.el attempts to index on a Khoj server served behind an https
endpoint, the success reponse status contains plist with certs. This
doesn't mean the update failed.
Look for :errors key in status instead to determine if indexing API
call failed. This fixes detecting indexing API call success on the
Khoj Emacs client, even for Khoj servers running behind SSL/HTTPS
* Fix the mechanism for populating notes references in the conversation primer for both offline and online chat
* Return conversation.default when empty list for dynamic prompt selection, send all cmds in telemetry
* Fix making chat on Obsidian backward compatible post chat session API updates
New API always has conversation_id set, not `chat' which can be unset
when chat session is empty.
So use conversation_id to decide whether to get chat logs from
`responseJson.response.chat' or `responseJson.response' instead
---------
Co-authored-by: Debanjum Singh Solanky <debanjum@gmail.com>
2024-02-20 21:55:35 +00:00
}).catch(err => {
2023-11-20 23:21:06 +00:00
return;
Miscellaneous bugs and fixes for chat sessions (#646)
* Display given_name field only if it is not None
* Add default slugs in the migration script
* Ensure that updated_at is saved appropriately, make sure most recent chat is returned for default history
* Remove the bin button from the chat interface, given deletion is handled in the drop-down menus
* Refresh the side panel when a new chat is created
* Improveme tool retrieval prompt, don't let /online fail, and improve parsing of extract questions
* Fix ending chat response by offline chat on hitting a stop phrase
Previously the whole phrase wouldn't be in the same response chunk, so
chat response wouldn't stop on hitting a stop phrase
Now use a queue to keep track of last 3 chunks, and to stop responding
when hit a stop phrase
* Make chat on Obsidian backward compatible post chat session API updates
- Make chat on Obsidian get chat history from
`responseJson.response.chat' when available (i.e when using new api)
- Else fallback to loading chat history from
responseJson.response (i.e when using old api)
* Fix detecting success of indexing update in khoj.el
When khoj.el attempts to index on a Khoj server served behind an https
endpoint, the success reponse status contains plist with certs. This
doesn't mean the update failed.
Look for :errors key in status instead to determine if indexing API
call failed. This fixes detecting indexing API call success on the
Khoj Emacs client, even for Khoj servers running behind SSL/HTTPS
* Fix the mechanism for populating notes references in the conversation primer for both offline and online chat
* Return conversation.default when empty list for dynamic prompt selection, send all cmds in telemetry
* Fix making chat on Obsidian backward compatible post chat session API updates
New API always has conversation_id set, not `chat' which can be unset
when chat session is empty.
So use conversation_id to decide whether to get chat logs from
`responseJson.response.chat' or `responseJson.response' instead
---------
Co-authored-by: Debanjum Singh Solanky <debanjum@gmail.com>
2024-02-20 21:55:35 +00:00
});
2023-11-22 11:18:29 +00:00
}
2023-11-26 08:26:21 +00:00
2024-01-20 07:24:31 +00:00
let sendMessageTimeout;
2023-11-22 10:19:22 +00:00
let mediaRecorder;
2024-01-20 07:24:31 +00:00
async function speechToText(event) {
event.preventDefault();
2023-11-22 10:19:22 +00:00
const speakButtonImg = document.getElementById('speak-button-img');
2024-01-19 15:10:04 +00:00
const stopRecordButtonImg = document.getElementById('stop-record-button-img');
2024-01-20 07:24:31 +00:00
const sendButtonImg = document.getElementById('send-button-img');
const stopSendButtonImg = document.getElementById('stop-send-button-img');
2023-11-22 10:19:22 +00:00
const chatInput = document.getElementById('chat-input');
const hostURL = await window.hostURLAPI.getURL();
2023-11-26 13:58:07 +00:00
let url = `${hostURL}/api/transcribe?client=desktop`;
2023-11-22 10:19:22 +00:00
const khojToken = await window.tokenAPI.getToken();
const headers = { 'Authorization': `Bearer ${khojToken}` };
const sendToServer = (audioBlob) => {
const formData = new FormData();
formData.append('file', audioBlob);
fetch(url, { method: 'POST', body: formData, headers})
.then(response => response.ok ? response.json() : Promise.reject(response))
2024-01-19 14:35:29 +00:00
.then(data => { chatInput.value += data.text.trimStart(); autoResize(); })
2024-01-20 07:24:31 +00:00
.then(() => {
// Don't auto-send empty messages
if (chatInput.value.length === 0) return;
// Send message after 3 seconds, unless stop send button is clicked
sendButtonImg.style.display = 'none';
stopSendButtonImg.style.display = 'initial';
// Start the countdown timer UI
document.getElementById('countdown-circle').style.animation = "countdown 3s linear 1 forwards";
sendMessageTimeout = setTimeout(() => {
// Revert to showing send-button and hide the stop-send-button
sendButtonImg.style.display = 'initial';
stopSendButtonImg.style.display = 'none';
// Stop the countdown timer UI
document.getElementById('countdown-circle').style.animation = "none";
// Send message
chat();
}, 3000);
})
2023-11-26 09:08:38 +00:00
.catch(err => {
2023-12-05 06:29:36 +00:00
if (err.status === 501) {
flashStatusInChatInput("⛔️ Configure speech-to-text model on server.")
} else if (err.status === 422) {
flashStatusInChatInput("⛔️ Audio file to large to process.")
} else {
flashStatusInChatInput("⛔️ Failed to transcribe audio.")
}
2023-11-26 09:08:38 +00:00
});
2023-11-22 10:19:22 +00:00
};
const handleRecording = (stream) => {
const audioChunks = [];
const recordingConfig = { mimeType: 'audio/webm' };
mediaRecorder = new MediaRecorder(stream, recordingConfig);
mediaRecorder.addEventListener("dataavailable", function(event) {
if (event.data.size > 0) audioChunks.push(event.data);
});
mediaRecorder.addEventListener("stop", function() {
const audioBlob = new Blob(audioChunks, { type: 'audio/webm' });
sendToServer(audioBlob);
2023-11-22 11:18:29 +00:00
});
2023-11-22 10:19:22 +00:00
mediaRecorder.start();
2024-01-19 15:10:04 +00:00
speakButtonImg.style.display = 'none';
stopRecordButtonImg.style.display = 'initial';
2023-11-22 10:19:22 +00:00
};
// Toggle recording
2024-01-20 15:58:20 +00:00
if (!mediaRecorder || mediaRecorder.state === 'inactive' || event.type === 'touchstart') {
2023-11-22 10:19:22 +00:00
navigator.mediaDevices
2024-01-20 15:58:20 +00:00
?.getUserMedia({ audio: true })
2023-11-22 10:19:22 +00:00
.then(handleRecording)
.catch((e) => {
2023-11-26 09:08:38 +00:00
flashStatusInChatInput("⛔️ Failed to access microphone");
2023-11-22 10:19:22 +00:00
});
2024-01-20 15:58:20 +00:00
} else if (mediaRecorder.state === 'recording' || event.type === 'touchend' || event.type === 'touchcancel') {
2023-11-22 10:19:22 +00:00
mediaRecorder.stop();
2023-11-27 04:33:26 +00:00
mediaRecorder.stream.getTracks().forEach(track => track.stop());
mediaRecorder = null;
2024-01-19 15:10:04 +00:00
speakButtonImg.style.display = 'initial';
stopRecordButtonImg.style.display = 'none';
2023-11-22 10:19:22 +00:00
}
2023-11-22 11:18:29 +00:00
}
2023-11-22 10:19:22 +00:00
2024-01-20 07:24:31 +00:00
function cancelSendMessage() {
// Cancel the chat() call if the stop-send-button is clicked
clearTimeout(sendMessageTimeout);
// Revert to showing send-button and hide the stop-send-button
document.getElementById('stop-send-button-img').style.display = 'none';
document.getElementById('send-button-img').style.display = 'initial';
// Stop the countdown timer UI
document.getElementById('countdown-circle').style.animation = "none";
};
2024-02-11 10:35:28 +00:00
function handleCollapseSidePanel() {
document.getElementById('side-panel').classList.toggle('collapsed');
document.getElementById('new-conversation').classList.toggle('collapsed');
document.getElementById('existing-conversations').classList.toggle('collapsed');
2024-02-22 14:34:26 +00:00
document.getElementById('side-panel-collapse').style.transform = document.getElementById('side-panel').classList.contains('collapsed') ? 'rotate(0deg)' : 'rotate(180deg)';
2024-02-11 10:35:28 +00:00
}
2023-09-06 19:04:18 +00:00
< / script >
< body >
2024-04-07 15:28:43 +00:00
< div id = "khoj-empty-container" class = "khoj-empty-container" > < / div >
2023-11-05 00:17:04 +00:00
2023-09-06 19:04:18 +00:00
<!-- Add Header Logo and Nav Pane -->
2024-04-07 15:28:43 +00:00
< div id = "khoj-header" class = "khoj-header" > < / div >
2023-09-06 19:04:18 +00:00
2024-02-11 10:35:28 +00:00
< div id = "chat-section-wrapper" >
< div id = "side-panel-wrapper" >
< div id = "side-panel" >
< div id = "new-conversation" >
2024-04-10 04:28:04 +00:00
< div id = "conversation-list-header" style = "display: none;" > Conversations< / div >
< button class = "side-panel-button" id = "new-conversation-button" onclick = "createNewConversation()" >
New
< svg class = "new-convo-button" viewBox = "0 0 40 40" fill = "#000000" viewBox = "0 0 32 32" version = "1.1" xmlns = "http://www.w3.org/2000/svg" >
2024-02-11 10:35:28 +00:00
< path d = "M16 0c-8.836 0-16 7.163-16 16s7.163 16 16 16c8.837 0 16-7.163 16-16s-7.163-16-16-16zM16 30.032c-7.72 0-14-6.312-14-14.032s6.28-14 14-14 14 6.28 14 14-6.28 14.032-14 14.032zM23 15h-6v-6c0-0.552-0.448-1-1-1s-1 0.448-1 1v6h-6c-0.552 0-1 0.448-1 1s0.448 1 1 1h6v6c0 0.552 0.448 1 1 1s1-0.448 1-1v-6h6c0.552 0 1-0.448 1-1s-0.448-1-1-1z" > < / path >
< / svg >
< / button >
< / div >
< div id = "existing-conversations" >
< div id = "conversation-list" >
< div id = "conversation-list-header" style = "display: none;" > Recent Conversations< / div >
< div id = "conversation-list-body" > < / div >
< / div >
< / div >
< / div >
< div id = "collapse-side-panel" >
< button
class="side-panel-button"
id="collapse-side-panel-button"
onclick="handleCollapseSidePanel()"
>
2024-02-22 14:34:26 +00:00
< svg id = "side-panel-collapse" viewBox = "0 0 25 25" fill = "none" xmlns = "http://www.w3.org/2000/svg" >
2024-02-11 10:35:28 +00:00
< path d = "M7.82054 20.7313C8.21107 21.1218 8.84423 21.1218 9.23476 20.7313L15.8792 14.0868C17.0505 12.9155 17.0508 11.0167 15.88 9.84497L9.3097 3.26958C8.91918 2.87905 8.28601 2.87905 7.89549 3.26958C7.50497 3.6601 7.50497 4.29327 7.89549 4.68379L14.4675 11.2558C14.8581 11.6464 14.8581 12.2795 14.4675 12.67L7.82054 19.317C7.43002 19.7076 7.43002 20.3407 7.82054 20.7313Z" fill = "#0F0F0F" / >
< / svg >
< / button >
< / div >
< / div >
< div id = "chat-body-wrapper" >
<!-- Chat Body -->
< div id = "chat-body" > < / div >
<!-- Chat Suggestions -->
< div id = "question-starters" style = "display: none;" > < / div >
<!-- Chat Footer -->
< div id = "chat-footer" >
< div id = "chat-tooltip" style = "display: none;" > < / div >
< div id = "input-row" >
< textarea id = "chat-input" class = "option" oninput = "onChatInput()" onkeydown = incrementalChat(event) autofocus = "autofocus" placeholder = "Type / to see a list of commands" > < / textarea >
< button id = "speak-button" class = "input-row-button"
ontouchstart="speechToText(event)" ontouchend="speechToText(event)" ontouchcancel="speechToText(event)" onmousedown="speechToText(event)">
< svg id = "speak-button-img" class = "input-row-button-img" alt = "Transcribe" xmlns = "http://www.w3.org/2000/svg" width = "16" height = "16" fill = "currentColor" viewBox = "0 0 16 16" >
< path d = "M3.5 6.5A.5.5 0 0 1 4 7v1a4 4 0 0 0 8 0V7a.5.5 0 0 1 1 0v1a5 5 0 0 1-4.5 4.975V15h3a.5.5 0 0 1 0 1h-7a.5.5 0 0 1 0-1h3v-2.025A5 5 0 0 1 3 8V7a.5.5 0 0 1 .5-.5z" / >
< path d = "M10 8a2 2 0 1 1-4 0V3a2 2 0 1 1 4 0v5zM8 0a3 3 0 0 0-3 3v5a3 3 0 0 0 6 0V3a3 3 0 0 0-3-3z" / >
< / svg >
< svg id = "stop-record-button-img" style = "display: none" class = "input-row-button-img" alt = "Stop Transcribing" xmlns = "http://www.w3.org/2000/svg" width = "16" height = "16" fill = "currentColor" viewBox = "0 0 16 16" >
< path d = "M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z" / >
< path d = "M5 6.5A1.5 1.5 0 0 1 6.5 5h3A1.5 1.5 0 0 1 11 6.5v3A1.5 1.5 0 0 1 9.5 11h-3A1.5 1.5 0 0 1 5 9.5v-3z" / >
< / svg >
< / button >
< button id = "send-button" class = "input-row-button" alt = "Send message" >
< svg id = "send-button-img" onclick = "chat()" class = "input-row-button-img" xmlns = "http://www.w3.org/2000/svg" width = "16" height = "16" fill = "currentColor" viewBox = "0 0 16 16" >
< path fill-rule = "evenodd" d = "M1 8a7 7 0 1 0 14 0A7 7 0 0 0 1 8zm15 0A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-7.5 3.5a.5.5 0 0 1-1 0V5.707L5.354 7.854a.5.5 0 1 1-.708-.708l3-3a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 5.707V11.5z" / >
< / svg >
< svg id = "stop-send-button-img" onclick = "cancelSendMessage()" style = "display: none" class = "input-row-button-img" alt = "Stop Message Send" xmlns = "http://www.w3.org/2000/svg" width = "16" height = "16" fill = "currentColor" viewBox = "0 0 16 16" >
< circle id = "countdown-circle" class = "countdown-circle" cx = "8" cy = "8" r = "7" / >
< path d = "M5 6.5A1.5 1.5 0 0 1 6.5 5h3A1.5 1.5 0 0 1 11 6.5v3A1.5 1.5 0 0 1 9.5 11h-3A1.5 1.5 0 0 1 5 9.5v-3z" / >
< / svg >
< / button >
< / div >
< / div >
2023-11-22 11:18:29 +00:00
< / div >
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;
2024-01-20 17:43:33 +00:00
font-family: var(--font-family);
2023-09-06 19:04:18 +00:00
font-size: small;
font-weight: 300;
line-height: 1.5em;
}
2024-02-11 10:35:28 +00:00
2023-09-06 19:04:18 +00:00
body > * {
padding: 10px;
margin: 10px;
}
2024-02-11 10:35:28 +00:00
input.conversation-title-input {
font-family: var(--font-family);
font-size: 14px;
font-weight: 300;
line-height: 1.5em;
padding: 5px;
border: 1px solid var(--main-text-color);
border-radius: 5px;
margin: 4px;
}
input.conversation-title-input:focus {
outline: none;
}
#chat-section-wrapper {
display: grid;
2024-04-07 16:31:43 +00:00
grid-template-columns: auto 1fr;
2024-02-11 10:35:28 +00:00
grid-column-gap: 10px;
grid-row-gap: 10px;
padding: 10px;
margin: 10px;
overflow-y: scroll;
}
#chat-body-wrapper {
display: flex;
flex-direction: column;
overflow: hidden;
}
#side-panel {
2024-02-22 14:34:26 +00:00
width: 250px;
2024-02-11 10:35:28 +00:00
padding: 10px;
background: var(--background-color);
border-radius: 5px;
box-shadow: 0 0 11px #aaa;
overflow-y: scroll;
text-align: left;
transition: width 0.3s ease-in-out;
}
div#side-panel.collapsed {
2024-02-22 14:34:26 +00:00
width: 0;
padding: 0;
2024-02-11 10:35:28 +00:00
display: block;
overflow: hidden;
}
div#collapse-side-panel {
align-self: center;
padding: 8px;
}
div#conversation-list-body {
display: grid;
grid-template-columns: 1fr;
grid-gap: 8px;
}
div#side-panel-wrapper {
display: flex
}
2023-09-06 19:04:18 +00:00
#chat-body {
2024-02-25 08:40:54 +00:00
height: 100%;
2023-09-06 19:04:18 +00:00
font-size: small;
margin: 0px;
line-height: 20px;
2024-02-11 10:35:28 +00:00
overflow-y: scroll;
overflow-x: hidden;
2023-09-06 19:04:18 +00:00
}
/* add chat metatdata to bottom of bubble */
.chat-message::after {
content: attr(data-meta);
display: block;
font-size: x-small;
color: #475569;
2024-02-11 10:35:28 +00:00
margin: -8px 4px 0px 0px;
2023-09-06 19:04:18 +00:00
}
/* move message by khoj to left */
.chat-message.khoj {
margin-left: auto;
text-align: left;
2024-04-23 05:28:04 +00:00
height: fit-content;
2023-09-06 19:04:18 +00:00
}
/* move message by you to right */
.chat-message.you {
margin-right: auto;
text-align: right;
2024-04-23 05:28:04 +00:00
height: fit-content;
2023-09-06 19:04:18 +00:00
}
/* 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;
2024-02-21 13:57:52 +00:00
white-space: pre-line;
2023-09-06 19:04:18 +00:00
}
/* color chat bubble by khoj blue */
.chat-message-text.khoj {
color: var(--primary-inverse);
background: var(--primary);
margin-left: auto;
}
2024-02-21 13:57:52 +00:00
.chat-message-text ol,
.chat-message-text ul {
white-space: normal;
margin: 0;
}
.chat-message-text-response {
2024-02-23 19:32:00 +00:00
margin-bottom: 0px;
2024-02-21 13:57:52 +00:00
}
2023-09-06 19:04:18 +00:00
/* 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)
}
2023-12-05 01:56:35 +00:00
img.text-to-image {
max-width: 60%;
}
2023-09-06 19:04:18 +00:00
#chat-footer {
padding: 0;
2023-11-22 11:18:29 +00:00
margin: 8px;
2023-09-06 19:04:18 +00:00
display: grid;
grid-template-columns: minmax(70px, 100%);
grid-column-gap: 10px;
grid-row-gap: 10px;
}
2023-11-22 11:18:29 +00:00
#input-row {
display: grid;
Miscellaneous bugs and fixes for chat sessions (#646)
* Display given_name field only if it is not None
* Add default slugs in the migration script
* Ensure that updated_at is saved appropriately, make sure most recent chat is returned for default history
* Remove the bin button from the chat interface, given deletion is handled in the drop-down menus
* Refresh the side panel when a new chat is created
* Improveme tool retrieval prompt, don't let /online fail, and improve parsing of extract questions
* Fix ending chat response by offline chat on hitting a stop phrase
Previously the whole phrase wouldn't be in the same response chunk, so
chat response wouldn't stop on hitting a stop phrase
Now use a queue to keep track of last 3 chunks, and to stop responding
when hit a stop phrase
* Make chat on Obsidian backward compatible post chat session API updates
- Make chat on Obsidian get chat history from
`responseJson.response.chat' when available (i.e when using new api)
- Else fallback to loading chat history from
responseJson.response (i.e when using old api)
* Fix detecting success of indexing update in khoj.el
When khoj.el attempts to index on a Khoj server served behind an https
endpoint, the success reponse status contains plist with certs. This
doesn't mean the update failed.
Look for :errors key in status instead to determine if indexing API
call failed. This fixes detecting indexing API call success on the
Khoj Emacs client, even for Khoj servers running behind SSL/HTTPS
* Fix the mechanism for populating notes references in the conversation primer for both offline and online chat
* Return conversation.default when empty list for dynamic prompt selection, send all cmds in telemetry
* Fix making chat on Obsidian backward compatible post chat session API updates
New API always has conversation_id set, not `chat' which can be unset
when chat session is empty.
So use conversation_id to decide whether to get chat logs from
`responseJson.response.chat' or `responseJson.response' instead
---------
Co-authored-by: Debanjum Singh Solanky <debanjum@gmail.com>
2024-02-20 21:55:35 +00:00
grid-template-columns: auto 32px 40px;
2023-11-22 11:18:29 +00:00
grid-column-gap: 10px;
grid-row-gap: 10px;
2024-01-19 14:35:29 +00:00
background: #f9fafc;
align-items: center;
2024-02-11 10:35:28 +00:00
background-color: var(--background-color);
2023-09-06 19:04:18 +00:00
}
.option:hover {
box-shadow: 0 0 11px #aaa;
}
#chat-input {
2024-01-20 17:43:33 +00:00
font-family: var(--font-family);
2023-09-06 19:04:18 +00:00
font-size: small;
2024-05-01 08:49:47 +00:00
height: 48px;
2024-01-19 15:10:04 +00:00
border-radius: 16px;
2023-09-06 19:04:18 +00:00
resize: none;
overflow-y: hidden;
max-height: 200px;
box-sizing: border-box;
2024-01-19 15:10:04 +00:00
padding: 7px 0 0 12px;
2023-09-06 19:04:18 +00:00
line-height: 1.5em;
margin: 0;
}
#chat-input:focus {
outline: none !important;
}
2023-11-22 11:18:29 +00:00
.input-row-button {
background: var(--background-color);
2024-01-19 15:10:04 +00:00
border: none;
box-shadow: none;
border-radius: 50%;
2023-11-22 11:18:29 +00:00
font-size: 14px;
font-weight: 300;
2023-12-17 15:32:55 +00:00
padding: 0;
2023-11-22 11:18:29 +00:00
line-height: 1.5em;
cursor: pointer;
transition: background 0.3s ease-in-out;
2024-01-19 15:10:04 +00:00
width: 40px;
height: 40px;
margin-top: -2px;
margin-left: -5px;
2023-11-22 11:18:29 +00:00
}
2024-02-11 10:35:28 +00:00
.side-panel-button {
background: var(--background-color);
border: none;
box-shadow: none;
font-size: 14px;
font-weight: 300;
line-height: 1.5em;
cursor: pointer;
transition: background 0.3s ease-in-out;
border-radius: 5%;;
font-family: var(--font-family);
}
2024-02-22 14:34:26 +00:00
svg#side-panel-collapse {
2024-02-11 10:35:28 +00:00
width: 30px;
height: 30px;
}
.side-panel-button:hover,
2023-11-22 11:18:29 +00:00
.input-row-button:hover {
background: var(--primary-hover);
}
2024-02-11 10:35:28 +00:00
.side-panel-button:active,
2023-11-22 11:18:29 +00:00
.input-row-button:active {
background: var(--primary-active);
}
.input-row-button-img {
width: 24px;
2024-01-19 15:10:04 +00:00
height: 24px;
}
#send-button {
2024-01-20 07:24:31 +00:00
padding: 0;
position: relative;
2024-01-19 15:10:04 +00:00
}
#send-button-img {
width: 28px;
height: 28px;
background: var(--primary-hover);
border-radius: 50%;
2023-11-22 11:18:29 +00:00
}
2024-01-20 07:24:31 +00:00
#stop-send-button-img {
position: absolute;
top: 6px;
right: 6px;
width: 28px;
height: 28px;
transform: rotateY(-180deg) rotateZ(-90deg);
}
#countdown-circle {
stroke-dasharray: 44px; /* The circumference of the circle with 7px radius */
stroke-dashoffset: 0px;
stroke-linecap: round;
stroke-width: 1px;
stroke: var(--main-text-color);
fill: none;
}
@keyframes countdown {
from {
stroke-dashoffset: 0px;
}
to {
stroke-dashoffset: -44px; /* The circumference of the circle with 7px radius */
}
}
2023-09-06 19:04:18 +00:00
.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-20 23:21:06 +00:00
div#question-starters {
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
grid-column-gap: 8px;
}
button.question-starter {
background: var(--background-color);
color: var(--main-text-color);
border: 1px solid var(--main-text-color);
2024-01-19 19:19:22 +00:00
border-radius: 16px;
2023-11-20 23:21:06 +00:00
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: 75px;
transition: max-height 0.3s ease-in-out;
overflow: hidden;
}
2024-01-19 19:19:22 +00:00
button.question-starter:hover {
background: var(--primary-hover);
}
2023-11-20 23:21:06 +00:00
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;
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-30 21:16:48 +00:00
white-space: pre-wrap;
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);
}
2024-04-23 11:13:48 +00:00
.first-run-message-heading {
2024-04-27 04:26:47 +00:00
font-size: 20px;
font-weight: 300;
line-height: 1.5em;
color: var(--main-text-color);
margin: 0;
padding: 10px;
2024-04-23 11:13:48 +00:00
}
.first-run-message-text {
2024-04-27 04:26:47 +00:00
font-size: 18px;
font-weight: 300;
line-height: 1.5em;
color: var(--main-text-color);
margin: 0;
padding-bottom: 25px;
2024-04-23 11:13:48 +00:00
}
2023-09-06 19:04:18 +00:00
a.inline-chat-link {
2024-04-27 04:26:47 +00:00
color: #475569;
text-decoration: none;
border-bottom: 1px dotted #475569;
2024-04-23 11:13:48 +00:00
}
a.first-run-message-link {
2024-04-27 04:26:47 +00:00
display: block;
text-align: center;
font-size: 14px;
color: #fff;
padding: 6px 15px;
border-radius: 999px;
text-decoration: none;
background-color: rgba(71, 85, 105, 0.6);
transition: background-color 0.3s ease-in-out;
2024-04-23 11:13:48 +00:00
}
a.first-run-message-link:hover {
2024-04-27 04:26:47 +00:00
background-color: #475569;
2023-09-06 19:04:18 +00:00
}
2023-11-27 19:45:36 +00:00
a.reference-link {
color: var(--main-text-color);
border-bottom: 1px dotted var(--main-text-color);
}
button.copy-button {
border-radius: 4px;
background-color: var(--background-color);
2024-03-08 05:24:13 +00:00
border: 1px solid var(--main-text-color);
text-align: center;
font-size: 16px;
transition: all 0.5s;
cursor: pointer;
padding: 4px;
float: right;
2023-11-27 19:45:36 +00:00
}
2024-03-08 05:24:13 +00:00
button.copy-button span {
2023-11-27 19:45:36 +00:00
cursor: pointer;
2024-03-08 05:24:13 +00:00
display: inline-block;
position: relative;
transition: 0.5s;
}
2024-04-09 18:05:23 +00:00
img.copy-icon {
width: 16px;
height: 16px;
}
2024-03-08 05:24:13 +00:00
button.copy-button:hover {
2024-04-09 18:05:23 +00:00
background-color: var(--primary-hover);
2024-03-08 05:24:13 +00:00
color: #f5f5f5;
2023-11-27 19:45:36 +00:00
}
pre {
text-wrap: unset;
}
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);
2024-01-19 15:10:04 +00:00
font-size: small;
2023-09-06 19:04:18 +00:00
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;
}
2023-12-05 01:56:35 +00:00
img.text-to-image {
max-width: 100%;
}
2024-01-19 15:10:04 +00:00
#clear-chat-button {
margin-left: 0;
}
2024-02-11 10:35:28 +00:00
div#side-panel.collapsed {
width: 0px;
display: block;
overflow: hidden;
padding: 0;
}
2024-02-22 14:34:26 +00:00
svg#side-panel-collapse {
2024-02-11 10:35:28 +00:00
width: 24px;
height: 24px;
}
#chat-body-wrapper {
min-width: 0;
}
div#chat-section-wrapper {
padding: 4px;
margin: 4px;
grid-column-gap: 4px;
}
div#collapse-side-panel {
align-self: center;
padding: 0px;
}
2023-09-06 19:04:18 +00:00
}
@media only screen and (min-width: 600px) {
body {
2024-02-11 10:35:28 +00:00
grid-template-columns: auto min(90vw, 100%) auto;
2023-09-06 19:04:18 +00:00
grid-template-rows: auto auto minmax(80px, 100%) auto;
}
body > * {
grid-column: 2;
}
}
div#chat-tooltip {
text-align: left;
font-size: medium;
}
2024-02-11 10:35:28 +00:00
svg.new-convo-button {
width: 20px;
margin-left: 5px;
}
div#new-conversation {
2024-04-10 04:28:04 +00:00
display: grid;
grid-auto-flow: column;
font-size: large;
2024-02-11 10:35:28 +00:00
text-align: left;
border-bottom: 1px solid var(--main-text-color);
2024-04-10 04:28:04 +00:00
margin: 8px 0;
2024-02-11 10:35:28 +00:00
}
button#new-conversation-button {
display: inline-flex;
align-items: center;
2024-04-10 04:28:04 +00:00
justify-self: end;
2024-02-11 10:35:28 +00:00
}
div.conversation-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;
display: flex;
position: relative;
}
.three-dot-menu {
display: none;
/* background: var(--background-color); */
/* border: 1px solid var(--main-text-color); */
border-radius: 5px;
/* position: relative; */
position: absolute;
right: 4;
top: 4;
}
button.three-dot-menu-button-item {
background: var(--background-color);
color: var(--main-text-color);
border: none;
box-shadow: none;
font-size: 14px;
font-weight: 300;
line-height: 1.5em;
cursor: pointer;
transition: background 0.3s ease-in-out;
font-family: var(--font-family);
border-radius: 4px;
right: 0;
}
button.three-dot-menu-button-item:hover {
background: var(--primary-hover);
color: var(--primary-inverse);
}
.three-dot-menu-button {
background: var(--background-color);
border: none;
box-shadow: none;
font-size: 14px;
font-weight: 300;
line-height: 1.5em;
cursor: pointer;
transition: background 0.3s ease-in-out;
font-family: var(--font-family);
border-radius: 4px;
right: 0;
}
.conversation-button:hover .three-dot-menu {
display: block;
}
div.conversation-menu {
position: absolute;
z-index: 1;
top: 100%;
right: 0;
text-align: right;
background-color: var(--background-color);
border: 1px solid var(--main-text-color);
border-radius: 5px;
padding: 5px;
box-shadow: 0 0 11px #aaa;
}
div.conversation-button:hover {
background: var(--primary-hover);
color: var(--primary-inverse);
}
div.selected-conversation {
background: var(--primary-hover) !important;
color: var(--primary-inverse) !important;
}
2023-09-06 19:04:18 +00:00
@keyframes gradient {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
a.khoj-logo {
text-align: center;
}
2023-11-27 19:45:36 +00:00
p {
margin: 0;
}
2023-09-06 19:04:18 +00:00
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;
}
2024-03-08 05:24:13 +00:00
2024-04-10 04:28:04 +00:00
.loading-spinner {
display: inline-block;
position: relative;
width: 80px;
height: 80px;
}
.loading-spinner div {
position: absolute;
border: 4px solid var(--primary-hover);
opacity: 1;
border-radius: 50%;
animation: lds-ripple 0.5s cubic-bezier(0, 0.2, 0.8, 1) infinite;
}
.loading-spinner div:nth-child(2) {
animation-delay: -0.5s;
}
@keyframes lds-ripple {
0% {
top: 36px;
left: 36px;
width: 0;
height: 0;
opacity: 1;
border-color: var(--primary-hover);
}
50% {
border-color: var(--flower);
}
100% {
top: 0px;
left: 0px;
width: 72px;
height: 72px;
opacity: 0;
border-color: var(--water);
}
}
2024-03-08 05:24:13 +00:00
.lds-ellipsis {
display: inline-block;
position: relative;
width: 60px;
height: 32px;
}
.lds-ellipsis div {
position: absolute;
top: 12px;
2024-04-10 04:28:04 +00:00
width: 8px;
height: 8px;
2024-03-08 05:24:13 +00:00
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);
}
}
2023-09-06 19:04:18 +00:00
< / style >
< / html >