Merge branch 'master' of github.com:khoj-ai/khoj into features/chat-ui-updates-big

This commit is contained in:
sabaimran
2024-07-08 17:00:42 +05:30
60 changed files with 1419 additions and 492 deletions

View File

@@ -19,7 +19,7 @@ const textFileTypes = [
'org', 'md', 'markdown', 'txt', 'html', 'xml',
// Other valid text file extensions from https://google.github.io/magika/model/config.json
'appleplist', 'asm', 'asp', 'batch', 'c', 'cs', 'css', 'csv', 'eml', 'go', 'html', 'ini', 'internetshortcut', 'java', 'javascript', 'json', 'latex', 'lisp', 'makefile', 'markdown', 'mht', 'mum', 'pem', 'perl', 'php', 'powershell', 'python', 'rdf', 'rst', 'rtf', 'ruby', 'rust', 'scala', 'shell', 'smali', 'sql', 'svg', 'symlinktext', 'txt', 'vba', 'winregistry', 'xml', 'yaml']
const binaryFileTypes = ['pdf']
const binaryFileTypes = ['pdf', 'jpg', 'jpeg', 'png']
const validFileTypes = textFileTypes.concat(binaryFileTypes);
const schema = {

View File

@@ -1,6 +1,6 @@
{
"name": "Khoj",
"version": "1.15.0",
"version": "1.16.0",
"description": "An AI copilot for your Second Brain",
"author": "Saba Imran, Debanjum Singh Solanky <team@khoj.dev>",
"license": "GPL-3.0-or-later",

View File

@@ -6,7 +6,7 @@
;; Saba Imran <saba@khoj.dev>
;; Description: An AI copilot for your Second Brain
;; Keywords: search, chat, org-mode, outlines, markdown, pdf, image
;; Version: 1.15.0
;; Version: 1.16.0
;; Package-Requires: ((emacs "27.1") (transient "0.3.0") (dash "2.19.1"))
;; URL: https://github.com/khoj-ai/khoj/tree/master/src/interface/emacs

View File

@@ -1,7 +1,7 @@
{
"id": "khoj",
"name": "Khoj",
"version": "1.15.0",
"version": "1.16.0",
"minAppVersion": "0.15.0",
"description": "An AI copilot for your Second Brain",
"author": "Khoj Inc.",

View File

@@ -1,6 +1,6 @@
{
"name": "Khoj",
"version": "1.15.0",
"version": "1.16.0",
"description": "An AI copilot for your Second Brain",
"author": "Debanjum Singh Solanky, Saba Imran <team@khoj.dev>",
"license": "GPL-3.0-or-later",

View File

@@ -1,8 +1,9 @@
import { ItemView, MarkdownRenderer, WorkspaceLeaf, request, requestUrl, setIcon } from 'obsidian';
import { ItemView, MarkdownRenderer, Scope, WorkspaceLeaf, request, requestUrl, setIcon } from 'obsidian';
import * as DOMPurify from 'dompurify';
import { KhojSetting } from 'src/settings';
import { KhojPaneView } from 'src/pane_view';
import { KhojView, createCopyParentText, getLinkToEntry, pasteTextAtCursor } from 'src/utils';
import { KhojSearchModal } from './search_modal';
export interface ChatJsonResult {
image?: string;
@@ -24,10 +25,18 @@ export class KhojChatView extends KhojPaneView {
setting: KhojSetting;
waitingForLocation: boolean;
location: Location;
keyPressTimeout: NodeJS.Timeout | null = null;
constructor(leaf: WorkspaceLeaf, setting: KhojSetting) {
super(leaf, setting);
// Register chat view keybindings
this.scope = new Scope(this.app.scope);
this.scope.register(["Ctrl"], 'n', (_) => this.createNewConversation());
this.scope.register(["Ctrl"], 'o', async (_) => await this.toggleChatSessions());
this.scope.register(["Ctrl"], 'f', (_) => new KhojSearchModal(this.app, this.setting).open());
this.scope.register(["Ctrl"], 'r', (_) => new KhojSearchModal(this.app, this.setting, true).open());
this.waitingForLocation = true;
fetch("https://ipapi.co/json")
@@ -61,8 +70,7 @@ export class KhojChatView extends KhojPaneView {
return "message-circle";
}
async chat() {
async chat(isVoice: boolean = false) {
// Get text in chat input element
let input_el = <HTMLTextAreaElement>this.contentEl.getElementsByClassName("khoj-chat-input")[0];
@@ -72,7 +80,7 @@ export class KhojChatView extends KhojPaneView {
this.autoResize();
// Get and render chat response to user message
await this.getChatResponse(user_message);
await this.getChatResponse(user_message, isVoice);
}
async onOpen() {
@@ -92,8 +100,9 @@ export class KhojChatView extends KhojPaneView {
const objectSrc = `object-src 'none';`;
const csp = `${defaultSrc} ${scriptSrc} ${connectSrc} ${styleSrc} ${imgSrc} ${childSrc} ${objectSrc}`;
// Add CSP meta tag to the Khoj Chat modal
document.head.createEl("meta", { attr: { "http-equiv": "Content-Security-Policy", "content": `${csp}` } });
// WARNING: CSP DISABLED for now as it breaks other Obsidian plugins. Enable when can scope CSP to only Khoj plugin.
// CSP meta tag for the Khoj Chat modal
// document.head.createEl("meta", { attr: { "http-equiv": "Content-Security-Policy", "content": `${csp}` } });
// Create area for chat logs
let chatBodyEl = contentEl.createDiv({ attr: { id: "khoj-chat-body", class: "khoj-chat-body" } });
@@ -104,9 +113,10 @@ export class KhojChatView extends KhojPaneView {
text: "Chat Sessions",
attr: {
class: "khoj-input-row-button clickable-icon",
title: "Show Conversations (^O)",
},
})
chatSessions.addEventListener('click', async (_) => { await this.toggleChatSessions(chatBodyEl) });
chatSessions.addEventListener('click', async (_) => { await this.toggleChatSessions() });
setIcon(chatSessions, "history");
let chatInput = inputRow.createEl("textarea", {
@@ -119,14 +129,20 @@ export class KhojChatView extends KhojPaneView {
chatInput.addEventListener('input', (_) => { this.onChatInput() });
chatInput.addEventListener('keydown', (event) => { this.incrementalChat(event) });
// Add event listeners for long press keybinding
this.contentEl.addEventListener('keydown', this.handleKeyDown.bind(this));
this.contentEl.addEventListener('keyup', this.handleKeyUp.bind(this));
let transcribe = inputRow.createEl("button", {
text: "Transcribe",
attr: {
id: "khoj-transcribe",
class: "khoj-transcribe khoj-input-row-button clickable-icon ",
title: "Start Voice Chat (^S)",
},
})
transcribe.addEventListener('mousedown', async (event) => { await this.speechToText(event) });
transcribe.addEventListener('mousedown', (event) => { this.startSpeechToText(event) });
transcribe.addEventListener('mouseup', async (event) => { await this.stopSpeechToText(event) });
transcribe.addEventListener('touchstart', async (event) => { await this.speechToText(event) });
transcribe.addEventListener('touchend', async (event) => { await this.speechToText(event) });
transcribe.addEventListener('touchcancel', async (event) => { await this.speechToText(event) });
@@ -160,6 +176,46 @@ export class KhojChatView extends KhojPaneView {
});
}
startSpeechToText(event: KeyboardEvent | MouseEvent | TouchEvent, timeout=200) {
if (!this.keyPressTimeout) {
this.keyPressTimeout = setTimeout(async () => {
// Reset auto send voice message timer, UI if running
if (this.sendMessageTimeout) {
// Stop the auto send voice message countdown timer UI
clearTimeout(this.sendMessageTimeout);
const sendButton = <HTMLButtonElement>this.contentEl.getElementsByClassName("khoj-chat-send")[0]
setIcon(sendButton, "arrow-up-circle")
let sendImg = <SVGElement>sendButton.getElementsByClassName("lucide-arrow-up-circle")[0]
sendImg.addEventListener('click', async (_) => { await this.chat() });
// Reset chat input value
const chatInput = <HTMLTextAreaElement>this.contentEl.getElementsByClassName("khoj-chat-input")[0];
chatInput.value = "";
}
// Start new voice message
await this.speechToText(event);
}, timeout);
}
}
async stopSpeechToText(event: KeyboardEvent | MouseEvent | TouchEvent) {
if (this.mediaRecorder) {
await this.speechToText(event);
}
if (this.keyPressTimeout) {
clearTimeout(this.keyPressTimeout);
this.keyPressTimeout = null;
}
}
handleKeyDown(event: KeyboardEvent) {
// Start speech to text if keyboard shortcut is pressed
if (event.key === 's' && event.getModifierState('Control')) this.startSpeechToText(event);
}
async handleKeyUp(event: KeyboardEvent) {
// Stop speech to text if keyboard shortcut is released
if (event.key === 's' && event.getModifierState('Control')) await this.stopSpeechToText(event);
}
processOnlineReferences(referenceSection: HTMLElement, onlineContext: any) {
let numOnlineReferences = 0;
for (let subquery in onlineContext) {
@@ -294,6 +350,57 @@ export class KhojChatView extends KhojPaneView {
return referenceButton;
}
textToSpeech(message: string, event: MouseEvent | null = null): void {
// Replace the speaker with a loading icon.
let loader = document.createElement("span");
loader.classList.add("loader");
let speechButton: HTMLButtonElement;
let speechIcon: Element;
if (event === null) {
// Pick the last speech button if none is provided
let speechButtons = document.getElementsByClassName("speech-button");
speechButton = speechButtons[speechButtons.length - 1] as HTMLButtonElement;
let speechIcons = document.getElementsByClassName("speech-icon");
speechIcon = speechIcons[speechIcons.length - 1];
} else {
speechButton = event.currentTarget as HTMLButtonElement;
speechIcon = event.target as Element;
}
speechButton.appendChild(loader);
speechButton.disabled = true;
const context = new AudioContext();
let textToSpeechApi = `${this.setting.khojUrl}/api/chat/speech?text=${encodeURIComponent(message)}`;
fetch(textToSpeechApi, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
"Authorization": `Bearer ${this.setting.khojApiKey}`,
},
})
.then(response => response.arrayBuffer())
.then(arrayBuffer => context.decodeAudioData(arrayBuffer))
.then(audioBuffer => {
const source = context.createBufferSource();
source.buffer = audioBuffer;
source.connect(context.destination);
source.start(0);
source.onended = function() {
speechButton.removeChild(loader);
speechButton.disabled = false;
};
})
.catch(err => {
console.error("Error playing speech:", err);
speechButton.removeChild(loader);
speechButton.disabled = false; // Consider enabling the button again to allow retrying
});
}
formatHTMLMessage(message: string, raw = false, willReplace = true) {
// Remove any text between <s>[INST] and </s> tags. These are spurious instructions for some AI chat model.
message = message.replace(/<s>\[INST\].+(<\/s>)?/g, '');
@@ -461,19 +568,36 @@ export class KhojChatView extends KhojPaneView {
renderActionButtons(message: string, chat_message_body_text_el: HTMLElement) {
let copyButton = this.contentEl.createEl('button');
copyButton.classList.add("copy-button");
copyButton.classList.add("chat-action-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.classList.add("chat-action-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);
// Only enable the speech feature if the user is subscribed
let speechButton = null;
if (this.setting.userInfo?.is_active) {
// Create a speech button icon to play the message out loud
speechButton = this.contentEl.createEl('button');
speechButton.classList.add("chat-action-button", "speech-button");
speechButton.title = "Listen to Message";
setIcon(speechButton, "speech")
speechButton.addEventListener('click', (event) => this.textToSpeech(message, event));
}
// Append buttons to parent element
chat_message_body_text_el.append(copyButton, pasteToFile);
if (speechButton) {
chat_message_body_text_el.append(speechButton);
}
}
formatDate(date: Date): string {
@@ -483,14 +607,16 @@ export class KhojChatView extends KhojPaneView {
return `${time_string}, ${date_string}`;
}
createNewConversation(chatBodyEl: HTMLElement) {
createNewConversation() {
let chatBodyEl = this.contentEl.getElementsByClassName("khoj-chat-body")[0] as HTMLElement;
chatBodyEl.innerHTML = "";
chatBodyEl.dataset.conversationId = "";
chatBodyEl.dataset.conversationTitle = "";
this.renderMessage(chatBodyEl, "Hey 👋🏾, what's up?", "khoj");
}
async toggleChatSessions(chatBodyEl: HTMLElement, forceShow: boolean = false): Promise<boolean> {
async toggleChatSessions(forceShow: boolean = false): Promise<boolean> {
let chatBodyEl = this.contentEl.getElementsByClassName("khoj-chat-body")[0] as HTMLElement;
if (!forceShow && this.contentEl.getElementsByClassName("side-panel")?.length > 0) {
chatBodyEl.innerHTML = "";
return this.getChatHistory(chatBodyEl);
@@ -504,9 +630,10 @@ export class KhojChatView extends KhojPaneView {
const newConversationButtonEl = newConversationEl.createEl("button");
newConversationButtonEl.classList.add("new-conversation-button");
newConversationButtonEl.classList.add("side-panel-button");
newConversationButtonEl.addEventListener('click', (_) => this.createNewConversation(chatBodyEl));
newConversationButtonEl.addEventListener('click', (_) => this.createNewConversation());
setIcon(newConversationButtonEl, "plus");
newConversationButtonEl.innerHTML += "New";
newConversationButtonEl.title = "New Conversation (^N)";
const existingConversationsEl = sidePanelEl.createDiv("existing-conversations");
const conversationListEl = existingConversationsEl.createDiv("conversation-list");
@@ -666,7 +793,7 @@ export class KhojChatView extends KhojPaneView {
chatBodyEl.innerHTML = "";
chatBodyEl.dataset.conversationId = "";
chatBodyEl.dataset.conversationTitle = "";
this.toggleChatSessions(chatBodyEl, true);
this.toggleChatSessions(true);
})
.catch(err => {
return;
@@ -727,7 +854,7 @@ export class KhojChatView extends KhojPaneView {
return true;
}
async readChatStream(response: Response, responseElement: HTMLDivElement): Promise<void> {
async readChatStream(response: Response, responseElement: HTMLDivElement, isVoice: boolean = false): Promise<void> {
// Exit if response body is empty
if (response.body == null) return;
@@ -737,8 +864,12 @@ export class KhojChatView extends KhojPaneView {
while (true) {
const { value, done } = await reader.read();
// Break if the stream is done
if (done) break;
if (done) {
// Automatically respond with voice if the subscribed user has sent voice message
if (isVoice && this.setting.userInfo?.is_active) this.textToSpeech(this.result);
// Break if the stream is done
break;
}
let responseText = decoder.decode(value);
if (responseText.includes("### compiled references:")) {
@@ -756,7 +887,7 @@ export class KhojChatView extends KhojPaneView {
}
}
async getChatResponse(query: string | undefined | null): Promise<void> {
async getChatResponse(query: string | undefined | null, isVoice: boolean = false): Promise<void> {
// Exit if query is empty
if (!query || query === "") return;
@@ -835,7 +966,7 @@ export class KhojChatView extends KhojPaneView {
}
} else {
// Stream and render chat response
await this.readChatStream(response, responseElement);
await this.readChatStream(response, responseElement, isVoice);
}
} catch (err) {
console.log(`Khoj chat response failed with\n${err}`);
@@ -883,7 +1014,7 @@ export class KhojChatView extends KhojPaneView {
sendMessageTimeout: NodeJS.Timeout | undefined;
mediaRecorder: MediaRecorder | undefined;
async speechToText(event: MouseEvent | TouchEvent) {
async speechToText(event: MouseEvent | TouchEvent | KeyboardEvent) {
event.preventDefault();
const transcribeButton = <HTMLButtonElement>this.contentEl.getElementsByClassName("khoj-transcribe")[0];
const chatInput = <HTMLTextAreaElement>this.contentEl.getElementsByClassName("khoj-chat-input")[0];
@@ -916,9 +1047,19 @@ export class KhojChatView extends KhojPaneView {
});
// Parse response from Khoj backend
let noSpeechText: string[] = [
"Thanks for watching!",
"Thanks for watching.",
"Thank you for watching!",
"Thank you for watching.",
"You",
"Bye."
];
let noSpeech: boolean = false;
if (response.status === 200) {
console.log(response);
chatInput.value += response.json.text.trimStart();
noSpeech = noSpeechText.includes(response.json.text.trimStart());
if (!noSpeech) chatInput.value += response.json.text.trimStart();
this.autoResize();
} else if (response.status === 501) {
throw new Error("⛔️ Configure speech-to-text model on server.");
@@ -928,8 +1069,8 @@ export class KhojChatView extends KhojPaneView {
throw new Error("⛔️ Failed to transcribe audio.");
}
// Don't auto-send empty messages
if (chatInput.value.length === 0) return;
// Don't auto-send empty messages or when no speech is detected
if (chatInput.value.length === 0 || noSpeech) return;
// Show stop auto-send button. It stops auto-send when clicked
setIcon(sendButton, "stop-circle");
@@ -938,6 +1079,7 @@ export class KhojChatView extends KhojPaneView {
// Start the countdown timer UI
stopSendButtonImg.getElementsByTagName("circle")[0].style.animation = "countdown 3s linear 1 forwards";
stopSendButtonImg.getElementsByTagName("circle")[0].style.color = "var(--icon-color-active)";
// Auto send message after 3 seconds
this.sendMessageTimeout = setTimeout(() => {
@@ -947,7 +1089,7 @@ export class KhojChatView extends KhojPaneView {
sendImg.addEventListener('click', async (_) => { await this.chat() });
// Send message
this.chat();
this.chat(true);
}, 3000);
};
@@ -966,21 +1108,23 @@ export class KhojChatView extends KhojPaneView {
});
this.mediaRecorder.start();
setIcon(transcribeButton, "mic-off");
// setIcon(transcribeButton, "mic-off");
transcribeButton.classList.add("loading-encircle")
};
// Toggle recording
if (!this.mediaRecorder || this.mediaRecorder.state === 'inactive' || event.type === 'touchstart') {
if (!this.mediaRecorder || this.mediaRecorder.state === 'inactive' || event.type === 'touchstart' || event.type === 'mousedown' || event.type === 'keydown') {
navigator.mediaDevices
.getUserMedia({ audio: true })
?.then(handleRecording)
.catch((e) => {
this.flashStatusInChatInput("⛔️ Failed to access microphone");
});
} else if (this.mediaRecorder.state === 'recording' || event.type === 'touchend' || event.type === 'touchcancel') {
} else if (this.mediaRecorder?.state === 'recording' || event.type === 'touchend' || event.type === 'touchcancel' || event.type === 'mouseup' || event.type === 'keyup') {
this.mediaRecorder.stop();
this.mediaRecorder.stream.getTracks().forEach(track => track.stop());
this.mediaRecorder = undefined;
transcribeButton.classList.remove("loading-encircle");
setIcon(transcribeButton, "mic");
}
}

View File

@@ -2,7 +2,8 @@ import { Plugin, WorkspaceLeaf } from 'obsidian';
import { KhojSetting, KhojSettingTab, DEFAULT_SETTINGS } from 'src/settings'
import { KhojSearchModal } from 'src/search_modal'
import { KhojChatView } from 'src/chat_view'
import { updateContentIndex, canConnectToBackend, KhojView } from './utils';
import { updateContentIndex, canConnectToBackend, KhojView, jumpToPreviousView } from './utils';
import { KhojPaneView } from './pane_view';
export default class Khoj extends Plugin {
@@ -79,16 +80,30 @@ export default class Khoj extends Plugin {
const leaves = workspace.getLeavesOfType(viewType);
if (leaves.length > 0) {
// A leaf with our view already exists, use that
leaf = leaves[0];
// A leaf with our view already exists, use that
leaf = leaves[0];
} else {
// Our view could not be found in the workspace, create a new leaf
// in the right sidebar for it
leaf = workspace.getRightLeaf(false);
await leaf?.setViewState({ type: viewType, active: true });
// Our view could not be found in the workspace, create a new leaf
// in the right sidebar for it
leaf = workspace.getRightLeaf(false);
await leaf?.setViewState({ type: viewType, active: true });
}
// "Reveal" the leaf in case it is in a collapsed sidebar
if (leaf) workspace.revealLeaf(leaf);
}
if (leaf) {
const activeKhojLeaf = workspace.getActiveViewOfType(KhojPaneView)?.leaf;
// Jump to the previous view if the current view is Khoj Side Pane
if (activeKhojLeaf === leaf) jumpToPreviousView();
// Else Reveal the leaf in case it is in a collapsed sidebar
else {
workspace.revealLeaf(leaf);
if (viewType === KhojView.CHAT) {
// focus on the chat input when the chat view is opened
let chatView = leaf.view as KhojChatView;
let chatInput = <HTMLTextAreaElement>chatView.contentEl.getElementsByClassName("khoj-chat-input")[0];
if (chatInput) chatInput.focus();
}
}
}
}
}

View File

@@ -38,16 +38,24 @@ export abstract class KhojPaneView extends ItemView {
const leaves = workspace.getLeavesOfType(viewType);
if (leaves.length > 0) {
// A leaf with our view already exists, use that
leaf = leaves[0];
// A leaf with our view already exists, use that
leaf = leaves[0];
} else {
// Our view could not be found in the workspace, create a new leaf
// in the right sidebar for it
leaf = workspace.getRightLeaf(false);
await leaf?.setViewState({ type: viewType, active: true });
// Our view could not be found in the workspace, create a new leaf
// in the right sidebar for it
leaf = workspace.getRightLeaf(false);
await leaf?.setViewState({ type: viewType, active: true });
}
// "Reveal" the leaf in case it is in a collapsed sidebar
if (leaf) workspace.revealLeaf(leaf);
}
if (leaf) {
if (viewType === KhojView.CHAT) {
// focus on the chat input when the chat view is opened
let chatInput = <HTMLTextAreaElement>this.contentEl.getElementsByClassName("khoj-chat-input")[0];
if (chatInput) chatInput.focus();
}
// "Reveal" the leaf in case it is in a collapsed sidebar
workspace.revealLeaf(leaf);
}
}
}

View File

@@ -333,6 +333,12 @@ export function createCopyParentText(message: string, originalButton: string = '
}
}
export function jumpToPreviousView() {
const editor: Editor = this.app.workspace.getActiveFileView()?.editor
if (!editor) return;
editor.focus();
}
export function pasteTextAtCursor(text: string | undefined) {
// Get the current active file's editor
const editor: Editor = this.app.workspace.getActiveFileView()?.editor

View File

@@ -477,7 +477,7 @@ span.khoj-nav-item-text {
}
/* Copy button */
button.copy-button {
button.chat-action-button {
display: block;
border-radius: 4px;
color: var(--text-muted);
@@ -491,20 +491,54 @@ button.copy-button {
margin-top: 8px;
float: right;
}
button.copy-button span {
button.chat-action-button span {
cursor: pointer;
display: inline-block;
position: relative;
transition: 0.5s;
}
button.chat-action-button:hover {
background-color: var(--background-modifier-active-hover);
color: var(--text-normal);
}
img.copy-icon {
width: 16px;
height: 16px;
}
button.copy-button:hover {
background-color: var(--background-modifier-active-hover);
color: var(--text-normal);
/* Circular Loading Spinner */
.loader {
width: 18px;
height: 18px;
border: 3px solid #FFF;
border-radius: 50%;
display: inline-block;
position: relative;
box-sizing: border-box;
animation: rotation 1s linear infinite;
}
.loader::after {
content: '';
box-sizing: border-box;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 18px;
height: 18px;
border-radius: 50%;
border: 3px solid transparent;
border-bottom-color: var(--flower);
}
@keyframes rotation {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* Loading Spinner */
@@ -564,6 +598,44 @@ button.copy-button:hover {
}
}
/* Loading Encircle */
.loading-encircle {
position: relative;
}
.loading-encircle::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 24px;
height: 24px;
margin-top: -16px;
margin-left: -16px;
border: 4px solid transparent;
border-color: var(--icon-color-active);
border-radius: 50%;
animation: pulse 3s ease-in-out infinite;
}
@keyframes pulse {
0% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.2);
opacity: 0.2;
}
100% {
transform: scale(1);
opacity: 1;
}
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@media only screen and (max-width: 600px) {
div.khoj-header {
display: grid;

View File

@@ -52,5 +52,6 @@
"1.12.1": "0.15.0",
"1.13.0": "0.15.0",
"1.14.0": "0.15.0",
"1.15.0": "0.15.0"
"1.15.0": "0.15.0",
"1.16.0": "0.15.0"
}