Stream steps taken to generate response in Obsidian chat pane

- Setup websocket using Khoj web app as reference.
- Moved the geolocating code to chat view out from the general pane
  view
- Use loading spinner from web instead of the thinking emoji
This commit is contained in:
Debanjum Singh Solanky 2024-05-08 03:13:22 -07:00
parent afcd22d30c
commit 14a2006c76
4 changed files with 576 additions and 32 deletions

View file

@ -10,17 +10,59 @@ export interface ChatJsonResult {
inferredQueries?: string[];
}
interface WebSocketState {
newResponseTextEl: HTMLElement | null,
newResponseEl: HTMLElement | null,
loadingEllipsis: HTMLElement | null,
references: object,
rawResponse: string,
}
export class KhojChatView extends KhojPaneView {
result: string;
setting: KhojSetting;
interface Location {
region: string;
city: string;
countryName: string;
timezone: string;
}
export class KhojChatView extends KhojPaneView {
result: string;
setting: KhojSetting;
waitingForLocation: boolean;
websocket: WebSocket;
websocketState: WebSocketState;
location: Location;
constructor(leaf: WorkspaceLeaf, setting: KhojSetting) {
super(leaf, setting);
this.waitingForLocation = true;
this.websocketState = {
newResponseTextEl: null,
newResponseEl: null,
loadingEllipsis: null,
references: {},
rawResponse: "",
};
fetch("https://ipapi.co/json")
.then(response => response.json())
.then(data => {
this.location = {
region: data.region,
city: data.city,
countryName: data.country_name,
timezone: data.timezone,
};
})
.catch(err => {
console.log(err);
})
.finally(() => {
this.waitingForLocation = false;
this.setupWebSocket();
});
}
getViewType(): string {
@ -36,6 +78,11 @@ export class KhojChatView extends KhojPaneView {
}
async chat() {
if (this.websocket?.readyState === WebSocket.OPEN){
this.sendMessageViaWebSocket();
return;
}
// Get text in chat input element
let input_el = <HTMLTextAreaElement>this.contentEl.getElementsByClassName("khoj-chat-input")[0];
@ -119,6 +166,93 @@ export class KhojChatView extends KhojPaneView {
});
}
processOnlineReferences(referenceSection: HTMLElement, onlineContext: any) {
let numOnlineReferences = 0;
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 = this.generateOnlineReference(referenceSection, reference, index);
referenceSection.appendChild(polishedReference);
}
}
if (onlineReference.knowledgeGraph && onlineReference.knowledgeGraph.length > 0) {
numOnlineReferences += onlineReference.knowledgeGraph.length;
for (let index in onlineReference.knowledgeGraph) {
let reference = onlineReference.knowledgeGraph[index];
let polishedReference = this.generateOnlineReference(referenceSection, reference, index);
referenceSection.appendChild(polishedReference);
}
}
if (onlineReference.peopleAlsoAsk && onlineReference.peopleAlsoAsk.length > 0) {
numOnlineReferences += onlineReference.peopleAlsoAsk.length;
for (let index in onlineReference.peopleAlsoAsk) {
let reference = onlineReference.peopleAlsoAsk[index];
let polishedReference = this.generateOnlineReference(referenceSection, reference, index);
referenceSection.appendChild(polishedReference);
}
}
if (onlineReference.webpages && onlineReference.webpages.length > 0) {
numOnlineReferences += onlineReference.webpages.length;
for (let index in onlineReference.webpages) {
let reference = onlineReference.webpages[index];
let polishedReference = this.generateOnlineReference(referenceSection, reference, index);
referenceSection.appendChild(polishedReference);
}
}
}
return numOnlineReferences;
}
generateOnlineReference(messageEl: Element, reference: any, index: string) {
// Generate HTML for Chat Reference
let title = reference.title || reference.link;
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 = messageEl.createEl('a');
linkElement.setAttribute('href', link);
linkElement.setAttribute('target', '_blank');
linkElement.setAttribute('rel', 'noopener noreferrer');
linkElement.classList.add("reference-link");
linkElement.setAttribute('title', title);
linkElement.textContent = title;
let referenceButton = messageEl.createEl('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;
}
generateReference(messageEl: Element, reference: string, index: number) {
// Escape reference for HTML rendering
let escaped_ref = reference.replace(/"/g, "&quot;")
@ -150,6 +284,47 @@ export class KhojChatView extends KhojPaneView {
return referenceButton;
}
formatHTMLMessage(message: string, raw=false, willReplace=true) {
let rendered_msg = message;
// Replace LaTeX delimiters with placeholders
rendered_msg = rendered_msg.replace(/\\\(/g, 'LEFTPAREN').replace(/\\\)/g, 'RIGHTPAREN')
.replace(/\\\[/g, 'LEFTBRACKET').replace(/\\\]/g, 'RIGHTBRACKET');
// Remove any text between <s>[INST] and </s> tags. These are spurious instructions for the AI chat model.
rendered_msg = rendered_msg.replace(/<s>\[INST\].+(<\/s>)?/g, '');
// Render markdow to HTML DOM element
let chat_message_body_text_el = this.contentEl.createDiv();
chat_message_body_text_el.className = "chat-message-text-response";
MarkdownRenderer.renderMarkdown(message, chat_message_body_text_el, '', null);
// Replace placeholders with LaTeX delimiters
rendered_msg = chat_message_body_text_el.innerHTML;
chat_message_body_text_el.innerHTML = rendered_msg.replace(/LEFTPAREN/g, '\\(').replace(/RIGHTPAREN/g, '\\)')
.replace(/LEFTBRACKET/g, '\\[').replace(/RIGHTBRACKET/g, '\\]');
// Add a copy button to each chat message, if it doesn't already exist
if (willReplace === true) {
let copyButton = this.contentEl.createEl('button');
copyButton.classList.add("copy-button");
copyButton.title = "Copy Message to Clipboard";
setIcon(copyButton, "copy-plus");
copyButton.addEventListener('click', createCopyParentText(message));
chat_message_body_text_el.append(copyButton);
// Add button to paste into current buffer
let pasteToFile = this.contentEl.createEl('button');
pasteToFile.classList.add("copy-button");
pasteToFile.title = "Paste Message to File";
setIcon(pasteToFile, "clipboard-paste");
pasteToFile.addEventListener('click', (event) => { pasteTextAtCursor(createCopyParentText(message, 'clipboard-paste')(event)); });
chat_message_body_text_el.append(pasteToFile);
}
return chat_message_body_text_el;
}
renderMessageWithReferences(chatEl: Element, message: string, sender: string, context?: string[], dt?: Date, intentType?: string, inferredQueries?: string) {
if (!message) {
return;
@ -283,7 +458,7 @@ export class KhojChatView extends KhojPaneView {
// Scroll to bottom after inserting chat messages
this.scrollChatToBottom();
return chat_message_el
return chat_message_el;
}
async renderIncrementalMessage(htmlElement: HTMLDivElement, additionalMessage: string) {
@ -302,9 +477,13 @@ export class KhojChatView extends KhojPaneView {
return `${time_string}, ${date_string}`;
}
async getChatHistory(chatBodyEl: Element): Promise<boolean> {
async getChatHistory(chatBodyEl: HTMLElement): Promise<boolean> {
// Get chat history from Khoj backend
let chatUrl = `${this.setting.khojUrl}/api/chat/history?client=obsidian`;
if (chatBodyEl.dataset.conversationId) {
chatUrl += `&conversation_id=${chatBodyEl.dataset.conversationId}`;
this.setupWebSocket();
}
try {
let response = await fetch(chatUrl, {
@ -313,6 +492,7 @@ export class KhojChatView extends KhojPaneView {
});
let responseJson: any = await response.json();
chatBodyEl.dataset.conversationId = responseJson.conversation_id;
if (responseJson.detail) {
// If the server returns error details in response, render a setup hint.
@ -321,6 +501,12 @@ export class KhojChatView extends KhojPaneView {
return false;
} else if (responseJson.response) {
// Render conversation history, if any
chatBodyEl.dataset.conversationId = responseJson.response.conversation_id;
this.setupWebSocket();
chatBodyEl.dataset.conversationTitle = responseJson.response.slug || `New conversation 🌱`;
let chatLogs = responseJson.response?.conversation_id ? responseJson.response.chat ?? [] : responseJson.response;
chatLogs.forEach((chatLog: any) => {
this.renderMessageWithReferences(
@ -409,17 +595,30 @@ export class KhojChatView extends KhojPaneView {
if (!query || query === "") return;
// Render user query as chat message
let chatBodyEl = this.contentEl.getElementsByClassName("khoj-chat-body")[0];
let chatBodyEl = this.contentEl.getElementsByClassName("khoj-chat-body")[0] as HTMLElement;
this.renderMessage(chatBodyEl, query, "you");
let conversationID = chatBodyEl.dataset.conversationId;
if (!conversationID) {
let chatUrl = `${this.setting.khojUrl}/api/chat/sessions?client=obsidian`;
let response = await fetch(chatUrl, {
method: "POST",
headers: { "Authorization": `Bearer ${this.setting.khojApiKey}` },
});
let data = await response.json();
conversationID = data.conversation_id;
chatBodyEl.dataset.conversationId = conversationID;
}
// Get chat response from Khoj backend
let encodedQuery = encodeURIComponent(query);
let chatUrl = `${this.setting.khojUrl}/api/chat?q=${encodedQuery}&n=${this.setting.resultsCount}&client=obsidian&stream=true&region=${this.region}&city=${this.city}&country=${this.countryName}&timezone=${this.timezone}`;
let chatUrl = `${this.setting.khojUrl}/api/chat?q=${encodedQuery}&n=${this.setting.resultsCount}&client=obsidian&stream=true&region=${this.location.region}&city=${this.location.city}&country=${this.location.countryName}&timezone=${this.location.timezone}`;
let responseElement = this.createKhojResponseDiv();
// Temporary status message to indicate that Khoj is thinking
this.result = "";
await this.renderIncrementalMessage(responseElement, "🤔");
let loadingEllipsis = this.createLoadingEllipse();
responseElement.appendChild(loadingEllipsis);
let response = await fetch(chatUrl, {
method: "GET",
@ -434,9 +633,9 @@ export class KhojChatView extends KhojPaneView {
throw new Error("Response body is null");
}
// Clear thinking status message
if (responseElement.innerHTML === "🤔") {
responseElement.innerHTML = "";
// Clear loading status message
if (responseElement.getElementsByClassName("lds-ellipsis").length > 0 && loadingEllipsis) {
responseElement.removeChild(loadingEllipsis);
}
// Reset collated chat result to empty string
@ -492,7 +691,7 @@ export class KhojChatView extends KhojPaneView {
}
async clearConversationHistory() {
let chatBody = this.contentEl.getElementsByClassName("khoj-chat-body")[0];
let chatBody = this.contentEl.getElementsByClassName("khoj-chat-body")[0] as HTMLElement;
let response = await request({
url: `${this.setting.khojUrl}/api/chat/history?client=obsidian`,
@ -659,4 +858,312 @@ export class KhojChatView extends KhojPaneView {
const chat_body_el = this.contentEl.getElementsByClassName("khoj-chat-body")[0];
if (!!chat_body_el) chat_body_el.scrollTop = chat_body_el.scrollHeight;
}
createLoadingEllipse() {
// Temporary status message to indicate that Khoj is thinking
let loadingEllipsis = this.contentEl.createEl("div");
loadingEllipsis.classList.add("lds-ellipsis");
let firstEllipsis = this.contentEl.createEl("div");
firstEllipsis.classList.add("lds-ellipsis-item");
let secondEllipsis = this.contentEl.createEl("div");
secondEllipsis.classList.add("lds-ellipsis-item");
let thirdEllipsis = this.contentEl.createEl("div");
thirdEllipsis.classList.add("lds-ellipsis-item");
let fourthEllipsis = this.contentEl.createEl("div");
fourthEllipsis.classList.add("lds-ellipsis-item");
loadingEllipsis.appendChild(firstEllipsis);
loadingEllipsis.appendChild(secondEllipsis);
loadingEllipsis.appendChild(thirdEllipsis);
loadingEllipsis.appendChild(fourthEllipsis);
return loadingEllipsis;
}
handleStreamResponse(newResponseElement: HTMLElement | null, rawResponse: string, loadingEllipsis: HTMLElement | null, replace=true) {
if (!newResponseElement) return;
if (newResponseElement.getElementsByClassName("lds-ellipsis").length > 0 && loadingEllipsis) {
newResponseElement.removeChild(loadingEllipsis);
}
if (replace) {
newResponseElement.innerHTML = "";
}
newResponseElement.appendChild(this.formatHTMLMessage(rawResponse, false, replace));
this.scrollChatToBottom();
}
handleCompiledReferences(rawResponseElement: HTMLElement | null, chunk: string, references: any, rawResponse: string) {
if (!rawResponseElement || !chunk) return { rawResponse, references };
const additionalResponse = chunk.split("### compiled references:")[0];
rawResponse += additionalResponse;
rawResponseElement.innerHTML = "";
rawResponseElement.appendChild(this.formatHTMLMessage(rawResponse));
const rawReference = chunk.split("### compiled references:")[1];
const rawReferenceAsJson = JSON.parse(rawReference);
if (rawReferenceAsJson instanceof Array) {
references["notes"] = rawReferenceAsJson;
} else if (typeof rawReferenceAsJson === "object" && rawReferenceAsJson !== null) {
references["online"] = rawReferenceAsJson;
}
return { rawResponse, references };
}
handleImageResponse(imageJson: any, rawResponse: string) {
if (imageJson.image) {
const inferredQuery = imageJson.inferredQueries?.[0] ?? "generated image";
// If response has image field, response is a generated image.
if (imageJson.intentType === "text-to-image") {
rawResponse += `![generated_image](data:image/png;base64,${imageJson.image})`;
} else if (imageJson.intentType === "text-to-image2") {
rawResponse += `![generated_image](${imageJson.image})`;
} else if (imageJson.intentType === "text-to-image-v3") {
rawResponse = `![](data:image/webp;base64,${imageJson.image})`;
}
if (inferredQuery) {
rawResponse += `\n\n**Inferred Query**:\n\n${inferredQuery}`;
}
}
let references: any = {};
if (imageJson.context && imageJson.context.length > 0) {
const rawReferenceAsJson = imageJson.context;
if (rawReferenceAsJson instanceof Array) {
references["notes"] = rawReferenceAsJson;
} else if (typeof rawReferenceAsJson === "object" && rawReferenceAsJson !== null) {
references["online"] = rawReferenceAsJson;
}
}
if (imageJson.detail) {
// If response has detail field, response is an error message.
rawResponse += imageJson.detail;
}
return { rawResponse, references };
}
addMessageToChatBody(rawResponse: string, newResponseElement: HTMLElement | null, references: any) {
if (!newResponseElement) return;
newResponseElement.innerHTML = "";
newResponseElement.appendChild(this.formatHTMLMessage(rawResponse));
this.finalizeChatBodyResponse(references, newResponseElement);
}
finalizeChatBodyResponse(references: object, newResponseElement: HTMLElement | null) {
if (!!newResponseElement && references != null && Object.keys(references).length > 0) {
newResponseElement.appendChild(this.createReferenceSection(references));
}
this.scrollChatToBottom();
let chatInput = this.contentEl.getElementsByClassName("khoj-chat-input")[0];
if (chatInput) chatInput.removeAttribute("disabled");
}
createReferenceSection(references: any) {
let referenceSection = this.contentEl.createEl('div');
referenceSection.classList.add("reference-section");
referenceSection.classList.add("collapsed");
let numReferences = 0;
if (references.hasOwnProperty("notes")) {
numReferences += references["notes"].length;
references["notes"].forEach((reference: any, index: number) => {
let polishedReference = this.generateReference(referenceSection, reference, index);
referenceSection.appendChild(polishedReference);
});
}
if (references.hasOwnProperty("online")) {
numReferences += this.processOnlineReferences(referenceSection, references["online"]);
}
let referenceExpandButton = this.contentEl.createEl('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 = this.contentEl.createEl('div');
referencesDiv.classList.add("references");
referencesDiv.appendChild(referenceExpandButton);
referencesDiv.appendChild(referenceSection);
return referencesDiv;
}
setupWebSocket() {
let chatBody = this.contentEl.getElementsByClassName("khoj-chat-body")[0] as HTMLElement;
let wsProtocol = this.setting.khojUrl.startsWith('https:') ? 'wss:' : 'ws:';
let baseUrl = this.setting.khojUrl.replace(/^https?:\/\//, '');
let webSocketUrl = `${wsProtocol}//${baseUrl}/api/chat/ws`;
if (this.waitingForLocation) {
console.debug("Waiting for location data to be fetched. Will setup WebSocket once location data is available.");
return;
}
if (!chatBody) return;
this.websocketState = {
newResponseTextEl: null,
newResponseEl: null,
loadingEllipsis: null,
references: {},
rawResponse: "",
}
if (chatBody.dataset.conversationId) {
webSocketUrl += `?conversation_id=${chatBody.dataset.conversationId}`;
webSocketUrl += !!this.location ? `&region=${this.location.region}&city=${this.location.city}&country=${this.location.countryName}&timezone=${this.location.timezone}` : '';
this.websocket = new WebSocket(webSocketUrl);
this.websocket.onmessage = (event) => {
// Get the last element in the chat-body
let chunk = event.data;
if (chunk == "start_llm_response") {
console.log("Started streaming", new Date());
} else if(chunk == "end_llm_response") {
console.log("Stopped streaming", new Date());
// Append any references after all the data has been streamed
this.finalizeChatBodyResponse(this.websocketState.references, this.websocketState.newResponseTextEl);
// Reset variables
this.websocketState = {
newResponseTextEl: null,
newResponseEl: null,
loadingEllipsis: null,
references: {},
rawResponse: "",
}
} else {
try {
if (chunk.includes("application/json")) {
chunk = JSON.parse(chunk);
}
} catch (error) {
// If the chunk is not a JSON object, continue.
}
const contentType = chunk["content-type"]
if (contentType === "application/json") {
// Handle JSON response
try {
if (chunk.image || chunk.detail) {
const { rawResponse, references } = this.handleImageResponse(chunk, this.websocketState.rawResponse);
this.websocketState.rawResponse = rawResponse;
this.websocketState.references = references;
} else if (chunk.type == "status") {
this.handleStreamResponse(this.websocketState.newResponseTextEl, chunk.message, null, false);
} else if (chunk.type == "rate_limit") {
this.handleStreamResponse(this.websocketState.newResponseTextEl, chunk.message, this.websocketState.loadingEllipsis, true);
} else {
this.websocketState.rawResponse = chunk.response;
}
} catch (error) {
// If the chunk is not a JSON object, just display it as is
this.websocketState.rawResponse += chunk;
} finally {
if (chunk.type != "status" && chunk.type != "rate_limit") {
this.addMessageToChatBody(this.websocketState.rawResponse, this.websocketState.newResponseTextEl, this.websocketState.references);
}
}
} else {
// Handle streamed response of type text/event-stream or text/plain
if (chunk && chunk.includes("### compiled references:")) {
const { rawResponse, references } = this.handleCompiledReferences(this.websocketState.newResponseTextEl, chunk, this.websocketState.references, this.websocketState.rawResponse);
this.websocketState.rawResponse = rawResponse;
this.websocketState.references = references;
} else {
// If the chunk is not a JSON object, just display it as is
this.websocketState.rawResponse += chunk;
if (this.websocketState.newResponseTextEl) {
this.handleStreamResponse(this.websocketState.newResponseTextEl, this.websocketState.rawResponse, this.websocketState.loadingEllipsis);
}
}
// Scroll to bottom of chat window as chat response is streamed
chatBody.scrollTop = chatBody.scrollHeight;
};
}
}
};
if (!this.websocket) return;
this.websocket.onclose = (event: Event) => {
console.log("WebSocket is closed now.");
let statusDotIcon = document.getElementById("connection-status-icon");
let statusDotText = document.getElementById("connection-status-text");
if (!statusDotIcon || !statusDotText) return;
statusDotIcon.style.backgroundColor = "red";
statusDotText.style.marginTop = "5px";
statusDotText.innerHTML = '<button onclick="setupWebSocket()">Reconnect to Server</button>';
}
this.websocket.onerror = (event: Event) => {
console.log("WebSocket error observed:", event);
}
this.websocket.onopen = (event: Event) => {
console.log("WebSocket is open now.")
let statusDotIcon = document.getElementById("connection-status-icon");
let statusDotText = document.getElementById("connection-status-text");
if (!statusDotIcon || !statusDotText) return;
statusDotIcon.style.backgroundColor = "green";
statusDotText.style.marginTop = "10px";
statusDotText.textContent = "Connected to Server";
}
}
sendMessageViaWebSocket() {
let chatBody = this.contentEl.getElementsByClassName("khoj-chat-body")[0] as HTMLElement;
let chatInput = this.contentEl.getElementsByClassName("khoj-chat-input")[0] as HTMLTextAreaElement;
let query = chatInput?.value.trim();
if (!chatInput || !chatBody || !query) return;
console.log(`Query: ${query}`);
// Add message by user to chat body
this.renderMessage(chatBody, query, "you");
chatInput.value = "";
this.autoResize();
chatInput.setAttribute("disabled", "disabled");
let newResponseEl = this.contentEl.createDiv();
newResponseEl.classList.add("khoj-chat-message", "khoj");
newResponseEl.setAttribute("data-meta", "🏮 Khoj at " + this.formatDate(new Date()));
chatBody.appendChild(newResponseEl);
let newResponseTextEl = this.contentEl.createDiv();
newResponseTextEl.classList.add("khoj-chat-message-text", "khoj");
newResponseEl.appendChild(newResponseTextEl);
// Temporary status message to indicate that Khoj is thinking
let loadingEllipsis = this.createLoadingEllipse();
newResponseTextEl.appendChild(loadingEllipsis);
chatBody.scrollTop = chatBody.scrollHeight;
// let chatTooltip = document.getElementById("chat-tooltip");
// if (chatTooltip) chatTooltip.style.display = "none";
chatInput.classList.remove("option-enabled");
// Call specified Khoj API
this.websocket.send(query);
this.websocketState = {
newResponseTextEl,
newResponseEl,
loadingEllipsis,
references: [],
rawResponse: "",
}
}
}

View file

@ -4,12 +4,7 @@ import { KhojSearchModal } from 'src/search_modal';
import { KhojView, populateHeaderPane } from './utils';
export abstract class KhojPaneView extends ItemView {
result: string;
setting: KhojSetting;
region: string;
city: string;
countryName: string;
timezone: string;
constructor(leaf: WorkspaceLeaf, setting: KhojSetting) {
super(leaf);
@ -18,19 +13,6 @@ export abstract class KhojPaneView extends ItemView {
// Register Modal Keybindings to send user message
// this.scope.register([], 'Enter', async () => { await this.chat() });
fetch("https://ipapi.co/json")
.then(response => response.json())
.then(data => {
this.region = data.region;
this.city = data.city;
this.countryName = data.country_name;
this.timezone = data.timezone;
})
.catch(err => {
console.log(err);
return;
});
}
async onOpen() {

View file

@ -454,6 +454,63 @@ img.copy-icon {
background: var(--text-on-accent);
}
/* Loading Spinner */
.lds-ellipsis {
display: inline-block;
position: relative;
width: 60px;
height: 32px;
}
.lds-ellipsis div {
position: absolute;
top: 12px;
width: 8px;
height: 8px;
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);
}
}
@media only screen and (max-width: 600px) {
div.khoj-header {
display: grid;

View file

@ -2831,7 +2831,5 @@ To get started, just start typing below. You can also type / to see a list of co
transform: translate(24px, 0);
}
}
</style>
</html>