Upgrade Khoj Obsidian: Chat from Side Pane, Stream Intermediate Steps, Copy Message to Clipboard (#736)

### Details
- **Chat with Khoj from right pane on Obsidian**
  - Modal was too ephemeral, couldn't have it open for reference, quick jump to Khoj chat
- **Stream intermediate steps taken by Khoj** for generating response to the chat pane
  Gives more transparency into Khoj 'thinking' process, e.g internet, notes searches performed, documents read etc. 
  The feedback allows us to tune our messages to elicit better responses by Khoj
- Add ability to **copy message to clipboard, paste chat messages directly into current file**
- Jump to **Search**, **Find Similar** functions from navigation bar on the Khoj Obsidian side pane
- Improve spacing, use consistent colors in chat message references and buttons

Resolves #789, #754
This commit is contained in:
Debanjum 2024-06-01 13:29:21 +05:30 committed by GitHub
commit b499b3fe2a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1803 additions and 884 deletions

View file

@ -62,7 +62,10 @@
return `${time_string}, ${date_string}`;
}
function generateReference(reference, index) {
function generateReference(referenceJson, index) {
let reference = referenceJson.hasOwnProperty("compiled") ? referenceJson.compiled : referenceJson;
let referenceFile = referenceJson.hasOwnProperty("file") ? referenceJson.file : null;
// Escape reference for HTML rendering
let escaped_ref = reference.replaceAll('"', '"');
@ -219,98 +222,44 @@
}
function renderMessageWithReference(message, by, context=null, dt=null, onlineContext=null, intentType=null, inferredQueries=null) {
let chatEl;
if (intentType?.includes("text-to-image")) {
let imageMarkdown = generateImageMarkdown(message, intentType, inferredQueries);
chatEl = renderMessage(imageMarkdown, by, dt, null, false, "return");
} else {
chatEl = renderMessage(message, by, dt, null, false, "return");
}
// If no document or online context is provided, render the message as is
if ((context == null || context.length == 0) && (onlineContext == null || (onlineContext && Object.keys(onlineContext).length == 0))) {
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})`;
} else if (intentType === "text-to-image-v3") {
imageMarkdown = `![](data:image/webp;base64,${message})`;
}
const inferredQuery = inferredQueries?.[0];
if (inferredQuery) {
imageMarkdown += `\n\n**Inferred Query**:\n\n${inferredQuery}`;
}
return renderMessage(imageMarkdown, by, dt, null, false, "return");
}
return renderMessage(message, by, dt, null, false, "return");
}
if (context == null && onlineContext == null) {
return renderMessage(message, by, dt, null, false, "return");
}
if ((context && context.length == 0) && (onlineContext == null || (onlineContext && Object.keys(onlineContext).length == 0))) {
return renderMessage(message, by, dt, null, false, "return");
if ((context == null || context?.length == 0)
&& (onlineContext == null || (onlineContext && Object.keys(onlineContext).length == 0))) {
return chatEl;
}
// If document or online context is provided, render the message with its references
let references = document.createElement('div');
let references = {};
if (!!context) references["notes"] = context;
if (!!onlineContext) references["online"] = onlineContext;
let chatMessageEl = chatEl.getElementsByClassName("chat-message-text")[0];
chatMessageEl.appendChild(createReferenceSection(references));
let referenceExpandButton = document.createElement('button');
referenceExpandButton.classList.add("reference-expand-button");
let numReferences = 0;
return chatEl;
}
if (context) {
numReferences += context.length;
function generateImageMarkdown(message, intentType, inferredQueries=null) {
let imageMarkdown;
if (intentType === "text-to-image") {
imageMarkdown = `![](data:image/png;base64,${message})`;
} else if (intentType === "text-to-image2") {
imageMarkdown = `![](${message})`;
} else if (intentType === "text-to-image-v3") {
imageMarkdown = `![](data:image/webp;base64,${message})`;
}
references.appendChild(referenceExpandButton);
let referenceSection = document.createElement('div');
referenceSection.classList.add("reference-section");
referenceSection.classList.add("collapsed");
referenceExpandButton.addEventListener('click', function() {
if (referenceSection.classList.contains("collapsed")) {
referenceSection.classList.remove("collapsed");
referenceSection.classList.add("expanded");
} else {
referenceSection.classList.add("collapsed");
referenceSection.classList.remove("expanded");
}
});
references.classList.add("references");
if (context) {
for (let index in context) {
let reference = context[index];
let polishedReference = generateReference(reference, index);
referenceSection.appendChild(polishedReference);
}
const inferredQuery = inferredQueries?.[0];
if (inferredQuery) {
imageMarkdown += `\n\n**Inferred Query**:\n\n${inferredQuery}`;
}
if (onlineContext) {
numReferences += processOnlineReferences(referenceSection, onlineContext);
}
let expandButtonText = numReferences == 1 ? "1 reference" : `${numReferences} references`;
referenceExpandButton.innerHTML = expandButtonText;
references.appendChild(referenceSection);
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})`;
} else if (intentType === "text-to-image-v3") {
imageMarkdown = `![](data:image/webp;base64,${message})`;
}
const inferredQuery = inferredQueries?.[0];
if (inferredQuery) {
imageMarkdown += `\n\n**Inferred Query**:\n\n${inferredQuery}`;
}
return renderMessage(imageMarkdown, by, dt, references, false, "return");
}
return renderMessage(message, by, dt, references, false, "return");
return imageMarkdown;
}
function formatHTMLMessage(message, raw=false, willReplace=true) {

View file

@ -1,643 +0,0 @@
import { App, MarkdownRenderer, Modal, request, requestUrl, setIcon } from 'obsidian';
import { KhojSetting } from 'src/settings';
export interface ChatJsonResult {
image?: string;
detail?: string;
intentType?: string;
inferredQueries?: string[];
}
export class KhojChatModal extends Modal {
result: string;
setting: KhojSetting;
region: string;
city: string;
countryName: string;
timezone: string;
constructor(app: App, setting: KhojSetting) {
super(app);
this.setting = setting;
// 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 chat() {
// Get text in chat input element
let input_el = <HTMLTextAreaElement>this.contentEl.getElementsByClassName("khoj-chat-input")[0];
// Clear text after extracting message to send
let user_message = input_el.value.trim();
input_el.value = "";
this.autoResize();
// Get and render chat response to user message
await this.getChatResponse(user_message);
}
async onOpen() {
let { contentEl } = this;
contentEl.addClass("khoj-chat");
// Add title to the Khoj Chat modal
contentEl.createEl("h1", ({ attr: { id: "khoj-chat-title" }, text: "Khoj Chat" }));
// Create area for chat logs
let chatBodyEl = contentEl.createDiv({ attr: { id: "khoj-chat-body", class: "khoj-chat-body" } });
// Add chat input field
let inputRow = contentEl.createDiv("khoj-input-row");
let clearChat = inputRow.createEl("button", {
text: "Clear History",
attr: {
class: "khoj-input-row-button clickable-icon",
},
})
clearChat.addEventListener('click', async (_) => { await this.clearConversationHistory() });
setIcon(clearChat, "trash");
let chatInput = inputRow.createEl("textarea", {
attr: {
id: "khoj-chat-input",
autofocus: "autofocus",
class: "khoj-chat-input option",
},
})
chatInput.addEventListener('input', (_) => { this.onChatInput() });
chatInput.addEventListener('keydown', (event) => { this.incrementalChat(event) });
let transcribe = inputRow.createEl("button", {
text: "Transcribe",
attr: {
id: "khoj-transcribe",
class: "khoj-transcribe khoj-input-row-button clickable-icon ",
},
})
transcribe.addEventListener('mousedown', async (event) => { await this.speechToText(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) });
setIcon(transcribe, "mic");
let send = inputRow.createEl("button", {
text: "Send",
attr: {
id: "khoj-chat-send",
class: "khoj-chat-send khoj-input-row-button clickable-icon",
},
})
setIcon(send, "arrow-up-circle");
let sendImg = <SVGElement>send.getElementsByClassName("lucide-arrow-up-circle")[0]
sendImg.addEventListener('click', async (_) => { await this.chat() });
// Get chat history from Khoj backend and set chat input state
let getChatHistorySucessfully = await this.getChatHistory(chatBodyEl);
let placeholderText = getChatHistorySucessfully ? "Message" : "Configure Khoj to enable chat";
chatInput.placeholder = placeholderText;
chatInput.disabled = !getChatHistorySucessfully;
// Scroll to bottom of modal, till the send message input box
this.scrollChatToBottom();
chatInput.focus();
}
generateReference(messageEl: Element, reference: string, index: number) {
// Escape reference for HTML rendering
let escaped_ref = reference.replace(/"/g, "&quot;")
// Generate HTML for Chat Reference
let short_ref = escaped_ref.slice(0, 100);
short_ref = short_ref.length < escaped_ref.length ? short_ref + "..." : short_ref;
let referenceButton = messageEl.createEl('button');
referenceButton.textContent = short_ref;
referenceButton.id = `ref-${index}`;
referenceButton.classList.add("reference-button");
referenceButton.classList.add("collapsed");
referenceButton.tabIndex = 0;
// Add event listener to toggle full reference on click
referenceButton.addEventListener('click', function() {
console.log(`Toggling ref-${index}`)
if (this.classList.contains("collapsed")) {
this.classList.remove("collapsed");
this.classList.add("expanded");
this.textContent = escaped_ref;
} else {
this.classList.add("collapsed");
this.classList.remove("expanded");
this.textContent = short_ref;
}
});
return referenceButton;
}
renderMessageWithReferences(chatEl: Element, message: string, sender: string, context?: string[], dt?: Date, intentType?: string, inferredQueries?: string) {
if (!message) {
return;
} else 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})`;
} else if (intentType === "text-to-image-v3") {
imageMarkdown = `![](data:image/webp;base64,${message})`;
}
if (inferredQueries) {
imageMarkdown += "\n\n**Inferred Query**:";
for (let inferredQuery of inferredQueries) {
imageMarkdown += `\n\n${inferredQuery}`;
}
}
this.renderMessage(chatEl, imageMarkdown, sender, dt);
return;
} else if (!context) {
this.renderMessage(chatEl, message, sender, dt);
return;
} else if (!!context && context?.length === 0) {
this.renderMessage(chatEl, message, sender, dt);
return;
}
let chatMessageEl = this.renderMessage(chatEl, message, sender, dt);
let chatMessageBodyEl = chatMessageEl.getElementsByClassName("khoj-chat-message-text")[0]
let references = chatMessageBodyEl.createDiv();
let referenceExpandButton = references.createEl('button');
referenceExpandButton.classList.add("reference-expand-button");
let numReferences = 0;
if (context) {
numReferences += context.length;
}
let referenceSection = references.createEl('div');
referenceSection.classList.add("reference-section");
referenceSection.classList.add("collapsed");
referenceExpandButton.addEventListener('click', function() {
if (referenceSection.classList.contains("collapsed")) {
referenceSection.classList.remove("collapsed");
referenceSection.classList.add("expanded");
} else {
referenceSection.classList.add("collapsed");
referenceSection.classList.remove("expanded");
}
});
references.classList.add("references");
if (context) {
context.map((reference, index) => {
this.generateReference(referenceSection, reference, index + 1);
});
}
let expandButtonText = numReferences == 1 ? "1 reference" : `${numReferences} references`;
referenceExpandButton.innerHTML = expandButtonText;
}
renderMessage(chatEl: Element, message: string, sender: string, dt?: Date, raw: boolean=false): Element {
let message_time = this.formatDate(dt ?? new Date());
let emojified_sender = sender == "khoj" ? "🏮 Khoj" : "🤔 You";
// Append message to conversation history HTML element.
// The chat logs should display above the message input box to follow standard UI semantics
let chatMessageEl = chatEl.createDiv({
attr: {
"data-meta": `${emojified_sender} at ${message_time}`,
class: `khoj-chat-message ${sender}`
},
})
let chat_message_body_el = chatMessageEl.createDiv();
chat_message_body_el.addClasses(["khoj-chat-message-text", sender]);
let chat_message_body_text_el = chat_message_body_el.createDiv();
if (raw) {
chat_message_body_text_el.innerHTML = message;
} else {
// @ts-ignore
MarkdownRenderer.renderMarkdown(message, chat_message_body_text_el, '', null);
}
// Remove user-select: none property to make text selectable
chatMessageEl.style.userSelect = "text";
// Scroll to bottom after inserting chat messages
this.scrollChatToBottom();
return chatMessageEl
}
createKhojResponseDiv(dt?: Date): HTMLDivElement {
let message_time = this.formatDate(dt ?? new Date());
// Append message to conversation history HTML element.
// The chat logs should display above the message input box to follow standard UI semantics
let chat_body_el = this.contentEl.getElementsByClassName("khoj-chat-body")[0];
let chat_message_el = chat_body_el.createDiv({
attr: {
"data-meta": `🏮 Khoj at ${message_time}`,
class: `khoj-chat-message khoj`
},
}).createDiv({
attr: {
class: `khoj-chat-message-text khoj`
},
})
// Scroll to bottom after inserting chat messages
this.scrollChatToBottom();
return chat_message_el
}
async renderIncrementalMessage(htmlElement: HTMLDivElement, additionalMessage: string) {
this.result += additionalMessage;
htmlElement.innerHTML = "";
// @ts-ignore
await MarkdownRenderer.renderMarkdown(this.result, htmlElement, '', null);
// Scroll to bottom of modal, till the send message input box
this.scrollChatToBottom();
}
formatDate(date: Date): string {
// 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' }).replace(/-/g, ' ');
return `${time_string}, ${date_string}`;
}
async getChatHistory(chatBodyEl: Element): Promise<boolean> {
// Get chat history from Khoj backend
let chatUrl = `${this.setting.khojUrl}/api/chat/history?client=obsidian`;
try {
let response = await fetch(chatUrl, {
method: "GET",
headers: { "Authorization": `Bearer ${this.setting.khojApiKey}` },
});
let responseJson: any = await response.json();
if (responseJson.detail) {
// If the server returns error details in response, render a setup hint.
let setupMsg = "Hi 👋🏾, to start chatting add available chat models options via [the Django Admin panel](/server/admin) on the Server";
this.renderMessage(chatBodyEl, setupMsg, "khoj", undefined);
return false;
} else if (responseJson.response) {
let chatLogs = responseJson.response?.conversation_id ? responseJson.response.chat ?? [] : responseJson.response;
chatLogs.forEach((chatLog: any) => {
this.renderMessageWithReferences(
chatBodyEl,
chatLog.message,
chatLog.by,
chatLog.context,
new Date(chatLog.created),
chatLog.intent?.type,
chatLog.intent?.["inferred-queries"],
);
});
}
} catch (err) {
let errorMsg = "Unable to get response from Khoj server ❤️‍🩹. Ensure server is running or contact developers for help at [team@khoj.dev](mailto:team@khoj.dev) or in [Discord](https://discord.gg/BDgyabRM6e)";
this.renderMessage(chatBodyEl, errorMsg, "khoj", undefined);
return false;
}
return true;
}
async readChatStream(response: Response, responseElement: HTMLDivElement): Promise<void> {
// Exit if response body is empty
if (response.body == null) return;
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { value, done } = await reader.read();
// Break if the stream is done
if (done) break;
let responseText = decoder.decode(value);
if (responseText.includes("### compiled references:")) {
// Render any references used to generate the response
const [additionalResponse, rawReference] = responseText.split("### compiled references:", 2);
await this.renderIncrementalMessage(responseElement, additionalResponse);
const rawReferenceAsJson = JSON.parse(rawReference);
let references = responseElement.createDiv();
references.classList.add("references");
let referenceExpandButton = references.createEl('button');
referenceExpandButton.classList.add("reference-expand-button");
let referenceSection = references.createDiv();
referenceSection.classList.add("reference-section");
referenceSection.classList.add("collapsed");
let numReferences = 0;
// If rawReferenceAsJson is a list, then count the length
if (Array.isArray(rawReferenceAsJson)) {
numReferences = rawReferenceAsJson.length;
rawReferenceAsJson.forEach((reference, index) => {
this.generateReference(referenceSection, reference, index);
});
}
references.appendChild(referenceExpandButton);
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 expandButtonText = numReferences == 1 ? "1 reference" : `${numReferences} references`;
referenceExpandButton.innerHTML = expandButtonText;
references.appendChild(referenceSection);
} else {
// Render incremental chat response
await this.renderIncrementalMessage(responseElement, responseText);
}
}
}
async getChatResponse(query: string | undefined | null): Promise<void> {
// Exit if query is empty
if (!query || query === "") return;
// Render user query as chat message
let chatBodyEl = this.contentEl.getElementsByClassName("khoj-chat-body")[0];
this.renderMessage(chatBodyEl, query, "you");
// 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 responseElement = this.createKhojResponseDiv();
// Temporary status message to indicate that Khoj is thinking
this.result = "";
await this.renderIncrementalMessage(responseElement, "🤔");
let response = await fetch(chatUrl, {
method: "GET",
headers: {
"Content-Type": "text/event-stream",
"Authorization": `Bearer ${this.setting.khojApiKey}`,
},
})
try {
if (response.body === null) {
throw new Error("Response body is null");
}
// Clear thinking status message
if (responseElement.innerHTML === "🤔") {
responseElement.innerHTML = "";
}
// Reset collated chat result to empty string
this.result = "";
responseElement.innerHTML = "";
if (response.headers.get("content-type") === "application/json") {
let responseText = ""
try {
const responseAsJson = await response.json() as ChatJsonResult;
if (responseAsJson.image) {
// If response has image field, response is a generated image.
if (responseAsJson.intentType === "text-to-image") {
responseText += `![${query}](data:image/png;base64,${responseAsJson.image})`;
} else if (responseAsJson.intentType === "text-to-image2") {
responseText += `![${query}](${responseAsJson.image})`;
} else if (responseAsJson.intentType === "text-to-image-v3") {
responseText += `![${query}](data:image/webp;base64,${responseAsJson.image})`;
}
const inferredQuery = responseAsJson.inferredQueries?.[0];
if (inferredQuery) {
responseText += `\n\n**Inferred Query**:\n\n${inferredQuery}`;
}
} else if (responseAsJson.detail) {
responseText = responseAsJson.detail;
}
} catch (error) {
// If the chunk is not a JSON object, just display it as is
responseText = await response.text();
} finally {
await this.renderIncrementalMessage(responseElement, responseText);
}
} else {
// Stream and render chat response
await this.readChatStream(response, responseElement);
}
} catch (err) {
console.log(`Khoj chat response failed with\n${err}`);
let errorMsg = "Sorry, unable to get response from Khoj backend ❤️‍🩹. Retry or contact developers for help at <a href=mailto:'team@khoj.dev'>team@khoj.dev</a> or <a href='https://discord.gg/BDgyabRM6e'>on Discord</a>";
responseElement.innerHTML = errorMsg
}
}
flashStatusInChatInput(message: string) {
// Get chat input element and original placeholder
let chatInput = <HTMLTextAreaElement>this.contentEl.getElementsByClassName("khoj-chat-input")[0];
let originalPlaceholder = chatInput.placeholder;
// Set placeholder to message
chatInput.placeholder = message;
// Reset placeholder after 2 seconds
setTimeout(() => {
chatInput.placeholder = originalPlaceholder;
}, 2000);
}
async clearConversationHistory() {
let chatBody = this.contentEl.getElementsByClassName("khoj-chat-body")[0];
let response = await request({
url: `${this.setting.khojUrl}/api/chat/history?client=obsidian`,
method: "DELETE",
headers: { "Authorization": `Bearer ${this.setting.khojApiKey}` },
})
try {
let result = JSON.parse(response);
if (result.status !== "ok") {
// Throw error if conversation history isn't cleared
throw new Error("Failed to clear conversation history");
} else {
let getChatHistoryStatus = await this.getChatHistory(chatBody);
// If conversation history is cleared successfully, clear chat logs from modal
if (getChatHistoryStatus) chatBody.innerHTML = "";
let statusMsg = getChatHistoryStatus ? result.message : "Failed to clear conversation history";
this.flashStatusInChatInput(statusMsg);
}
} catch (err) {
this.flashStatusInChatInput("Failed to clear conversation history");
}
}
sendMessageTimeout: NodeJS.Timeout | undefined;
mediaRecorder: MediaRecorder | undefined;
async speechToText(event: MouseEvent | TouchEvent) {
event.preventDefault();
const transcribeButton = <HTMLButtonElement>this.contentEl.getElementsByClassName("khoj-transcribe")[0];
const chatInput = <HTMLTextAreaElement>this.contentEl.getElementsByClassName("khoj-chat-input")[0];
const sendButton = <HTMLButtonElement>this.modalEl.getElementsByClassName("khoj-chat-send")[0]
const generateRequestBody = async (audioBlob: Blob, boundary_string: string) => {
const boundary = `------${boundary_string}`;
const chunks: ArrayBuffer[] = [];
chunks.push(new TextEncoder().encode(`${boundary}\r\n`));
chunks.push(new TextEncoder().encode(`Content-Disposition: form-data; name="file"; filename="blob"\r\nContent-Type: "application/octet-stream"\r\n\r\n`));
chunks.push(await audioBlob.arrayBuffer());
chunks.push(new TextEncoder().encode('\r\n'));
await Promise.all(chunks);
chunks.push(new TextEncoder().encode(`${boundary}--\r\n`));
return await new Blob(chunks).arrayBuffer();
};
const sendToServer = async (audioBlob: Blob) => {
const boundary_string = `Boundary${Math.random().toString(36).slice(2)}`;
const requestBody = await generateRequestBody(audioBlob, boundary_string);
const response = await requestUrl({
url: `${this.setting.khojUrl}/api/transcribe?client=obsidian`,
method: 'POST',
headers: { "Authorization": `Bearer ${this.setting.khojApiKey}` },
contentType: `multipart/form-data; boundary=----${boundary_string}`,
body: requestBody,
});
// Parse response from Khoj backend
if (response.status === 200) {
console.log(response);
chatInput.value += response.json.text.trimStart();
this.autoResize();
} else if (response.status === 501) {
throw new Error("⛔️ Configure speech-to-text model on server.");
} else if (response.status === 422) {
throw new Error("⛔️ Audio file to large to process.");
} else {
throw new Error("⛔️ Failed to transcribe audio.");
}
// Don't auto-send empty messages
if (chatInput.value.length === 0) return;
// Show stop auto-send button. It stops auto-send when clicked
setIcon(sendButton, "stop-circle");
let stopSendButtonImg = <SVGElement>sendButton.getElementsByClassName("lucide-stop-circle")[0]
stopSendButtonImg.addEventListener('click', (_) => { this.cancelSendMessage() });
// Start the countdown timer UI
stopSendButtonImg.getElementsByTagName("circle")[0].style.animation = "countdown 3s linear 1 forwards";
// Auto send message after 3 seconds
this.sendMessageTimeout = setTimeout(() => {
// Stop the countdown timer UI
setIcon(sendButton, "arrow-up-circle")
let sendImg = <SVGElement>sendButton.getElementsByClassName("lucide-arrow-up-circle")[0]
sendImg.addEventListener('click', async (_) => { await this.chat() });
// Send message
this.chat();
}, 3000);
};
const handleRecording = (stream: MediaStream) => {
const audioChunks: Blob[] = [];
const recordingConfig = { mimeType: 'audio/webm' };
this.mediaRecorder = new MediaRecorder(stream, recordingConfig);
this.mediaRecorder.addEventListener("dataavailable", function(event) {
if (event.data.size > 0) audioChunks.push(event.data);
});
this.mediaRecorder.addEventListener("stop", async function() {
const audioBlob = new Blob(audioChunks, { type: 'audio/webm' });
await sendToServer(audioBlob);
});
this.mediaRecorder.start();
setIcon(transcribeButton, "mic-off");
};
// Toggle recording
if (!this.mediaRecorder || this.mediaRecorder.state === 'inactive' || event.type === 'touchstart') {
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') {
this.mediaRecorder.stop();
this.mediaRecorder.stream.getTracks().forEach(track => track.stop());
this.mediaRecorder = undefined;
setIcon(transcribeButton, "mic");
}
}
cancelSendMessage() {
// Cancel the auto-send chat message timer if the stop-send-button is clicked
clearTimeout(this.sendMessageTimeout);
// Revert to showing send-button and hide the stop-send-button
let sendButton = <HTMLButtonElement>this.modalEl.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() });
};
incrementalChat(event: KeyboardEvent) {
if (!event.shiftKey && event.key === 'Enter') {
event.preventDefault();
this.chat();
}
}
onChatInput() {
const chatInput = <HTMLTextAreaElement>this.contentEl.getElementsByClassName("khoj-chat-input")[0];
chatInput.value = chatInput.value.trimStart();
this.autoResize();
}
autoResize() {
const chatInput = <HTMLTextAreaElement>this.contentEl.getElementsByClassName("khoj-chat-input")[0];
const scrollTop = chatInput.scrollTop;
chatInput.style.height = '0';
const scrollHeight = chatInput.scrollHeight + 8; // +8 accounts for padding
chatInput.style.height = Math.min(scrollHeight, 200) + 'px';
chatInput.scrollTop = scrollTop;
this.scrollChatToBottom();
}
scrollChatToBottom() {
let sendButton = <HTMLButtonElement>this.modalEl.getElementsByClassName("khoj-chat-send")[0];
sendButton.scrollIntoView({ behavior: "auto", block: "center" });
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,8 @@
import { Plugin } from 'obsidian';
import { Plugin, WorkspaceLeaf } from 'obsidian';
import { KhojSetting, KhojSettingTab, DEFAULT_SETTINGS } from 'src/settings'
import { KhojSearchModal } from 'src/search_modal'
import { KhojChatModal } from 'src/chat_modal'
import { updateContentIndex, canConnectToBackend } from './utils';
import { KhojChatView } from 'src/chat_view'
import { updateContentIndex, canConnectToBackend, KhojView } from './utils';
export default class Khoj extends Plugin {
@ -30,12 +30,14 @@ export default class Khoj extends Plugin {
this.addCommand({
id: 'chat',
name: 'Chat',
callback: () => { new KhojChatModal(this.app, this.settings).open(); }
callback: () => { this.activateView(KhojView.CHAT); }
});
this.registerView(KhojView.CHAT, (leaf) => new KhojChatView(leaf, this.settings));
// Create an icon in the left ribbon.
this.addRibbonIcon('message-circle', 'Khoj', (_: MouseEvent) => {
new KhojChatModal(this.app, this.settings).open()
this.activateView(KhojView.CHAT);
});
// Add a settings tab so the user can configure khoj
@ -69,4 +71,24 @@ export default class Khoj extends Plugin {
this.unload();
}
async activateView(viewType: KhojView) {
const { workspace } = this.app;
let leaf: WorkspaceLeaf | null = null;
const leaves = workspace.getLeavesOfType(viewType);
if (leaves.length > 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 });
}
// "Reveal" the leaf in case it is in a collapsed sidebar
workspace.revealLeaf(leaf);
}
}

View file

@ -0,0 +1,53 @@
import { ItemView, WorkspaceLeaf } from 'obsidian';
import { KhojSetting } from 'src/settings';
import { KhojSearchModal } from 'src/search_modal';
import { KhojView, populateHeaderPane } from './utils';
export abstract class KhojPaneView extends ItemView {
setting: KhojSetting;
constructor(leaf: WorkspaceLeaf, setting: KhojSetting) {
super(leaf);
this.setting = setting;
// Register Modal Keybindings to send user message
// this.scope.register([], 'Enter', async () => { await this.chat() });
}
async onOpen() {
let { contentEl } = this;
// Add title to the Khoj Chat modal
let headerEl = contentEl.createDiv(({ attr: { id: "khoj-header", class: "khoj-header" } }));
// Setup the header pane
await populateHeaderPane(headerEl, this.setting);
// Set the active nav pane
headerEl.getElementsByClassName("chat-nav")[0]?.classList.add("khoj-nav-selected");
headerEl.getElementsByClassName("chat-nav")[0]?.addEventListener("click", (_) => { this.activateView(KhojView.CHAT); });
headerEl.getElementsByClassName("search-nav")[0]?.addEventListener("click", (_) => { new KhojSearchModal(this.app, this.setting).open(); });
headerEl.getElementsByClassName("similar-nav")[0]?.addEventListener("click", (_) => { new KhojSearchModal(this.app, this.setting, true).open(); });
let similarNavSvgEl = headerEl.getElementsByClassName("khoj-nav-icon-similar")[0]?.firstElementChild;
if (!!similarNavSvgEl) similarNavSvgEl.id = "similar-nav-icon-svg";
}
async activateView(viewType: string) {
const { workspace } = this.app;
let leaf: WorkspaceLeaf | null = null;
const leaves = workspace.getLeavesOfType(viewType);
if (leaves.length > 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 });
}
// "Reveal" the leaf in case it is in a collapsed sidebar
workspace.revealLeaf(leaf);
}
}

View file

@ -1,6 +1,6 @@
import { App, SuggestModal, request, MarkdownRenderer, Instruction, Platform } from 'obsidian';
import { KhojSetting } from 'src/settings';
import { createNoteAndCloseModal } from 'src/utils';
import { createNoteAndCloseModal, getLinkToEntry } from 'src/utils';
export interface SearchResult {
entry: string;
@ -132,21 +132,8 @@ export class KhojSearchModal extends SuggestModal<SearchResult> {
const mdFiles = this.app.vault.getMarkdownFiles();
const pdfFiles = this.app.vault.getFiles().filter(file => file.extension === 'pdf');
// Find the vault file matching file of chosen search result
let file_match = mdFiles.concat(pdfFiles)
// Sort by descending length of path
// This finds longest path match when multiple files have same name
.sort((a, b) => b.path.length - a.path.length)
// The first match is the best file match across OS
// e.g Khoj server on Linux, Obsidian vault on Android
.find(file => result.file.replace(/\\/g, "/").endsWith(file.path))
// Open vault file at heading of chosen search result
if (file_match) {
let resultHeading = file_match.extension !== 'pdf' ? result.entry.split('\n', 1)[0] : '';
let linkToEntry = resultHeading.startsWith('#') ? `${file_match.path}${resultHeading}` : file_match.path;
this.app.workspace.openLinkText(linkToEntry, '');
console.log(`Link: ${linkToEntry}, File: ${file_match.path}, Heading: ${resultHeading}`);
}
// Find, Open vault file at heading of chosen search result
let linkToEntry = getLinkToEntry(mdFiles.concat(pdfFiles), result.file, result.entry);
if (linkToEntry) this.app.workspace.openLinkText(linkToEntry, '');
}
}

View file

@ -2,6 +2,15 @@ import { App, Notice, PluginSettingTab, Setting, TFile } from 'obsidian';
import Khoj from 'src/main';
import { canConnectToBackend, getBackendStatusMessage, updateContentIndex } from './utils';
export interface UserInfo {
username?: string;
photo?: string;
is_active?: boolean;
has_documents?: boolean;
email?: string;
}
export interface KhojSetting {
resultsCount: number;
khojUrl: string;
@ -9,7 +18,7 @@ export interface KhojSetting {
connectedToBackend: boolean;
autoConfigure: boolean;
lastSync: Map<TFile, number>;
userEmail: string;
userInfo: UserInfo | null;
}
export const DEFAULT_SETTINGS: KhojSetting = {
@ -19,7 +28,7 @@ export const DEFAULT_SETTINGS: KhojSetting = {
connectedToBackend: false,
autoConfigure: true,
lastSync: new Map(),
userEmail: '',
userInfo: null,
}
export class KhojSettingTab extends PluginSettingTab {
@ -38,7 +47,7 @@ export class KhojSettingTab extends PluginSettingTab {
let backendStatusEl = containerEl.createEl('small', {
text: getBackendStatusMessage(
this.plugin.settings.connectedToBackend,
this.plugin.settings.userEmail,
this.plugin.settings.userInfo?.email,
this.plugin.settings.khojUrl,
this.plugin.settings.khojApiKey
)}
@ -55,7 +64,7 @@ export class KhojSettingTab extends PluginSettingTab {
this.plugin.settings.khojUrl = value.trim().replace(/\/$/, '');
({
connectedToBackend: this.plugin.settings.connectedToBackend,
userEmail: this.plugin.settings.userEmail,
userInfo: this.plugin.settings.userInfo,
statusMessage: backendStatusMessage,
} = await canConnectToBackend(this.plugin.settings.khojUrl, this.plugin.settings.khojApiKey));
@ -71,7 +80,7 @@ export class KhojSettingTab extends PluginSettingTab {
this.plugin.settings.khojApiKey = value.trim();
({
connectedToBackend: this.plugin.settings.connectedToBackend,
userEmail: this.plugin.settings.userEmail,
userInfo: this.plugin.settings.userInfo,
statusMessage: backendStatusMessage,
} = await canConnectToBackend(this.plugin.settings.khojUrl, this.plugin.settings.khojApiKey));
await this.plugin.saveSettings();

View file

@ -1,5 +1,5 @@
import { FileSystemAdapter, Notice, Vault, Modal, TFile, request } from 'obsidian';
import { KhojSetting } from 'src/settings'
import { FileSystemAdapter, Notice, Vault, Modal, TFile, request, setIcon, Editor } from 'obsidian';
import { KhojSetting, UserInfo } from 'src/settings'
export function getVaultAbsolutePath(vault: Vault): string {
let adaptor = vault.adapter;
@ -173,31 +173,30 @@ export async function canConnectToBackend(
khojUrl: string,
khojApiKey: string,
showNotice: boolean = false
): Promise<{ connectedToBackend: boolean; statusMessage: string, userEmail: string }> {
): Promise<{ connectedToBackend: boolean; statusMessage: string, userInfo: UserInfo | null }> {
let connectedToBackend = false;
let userEmail: string = '';
let userInfo: UserInfo | null = null;
if (!!khojUrl) {
let headers = !!khojApiKey ? { "Authorization": `Bearer ${khojApiKey}` } : undefined;
await request({ url: `${khojUrl}/api/health`, method: "GET", headers: headers })
.then(response => {
try {
let response = await request({ url: `${khojUrl}/api/v1/user`, method: "GET", headers: headers })
connectedToBackend = true;
userEmail = JSON.parse(response)?.email;
})
.catch(error => {
userInfo = JSON.parse(response);
} catch (error) {
connectedToBackend = false;
console.log(`Khoj connection error:\n\n${error}`);
});
};
}
let statusMessage: string = getBackendStatusMessage(connectedToBackend, userEmail, khojUrl, khojApiKey);
let statusMessage: string = getBackendStatusMessage(connectedToBackend, userInfo?.email, khojUrl, khojApiKey);
if (showNotice) new Notice(statusMessage);
return { connectedToBackend, statusMessage, userEmail };
return { connectedToBackend, statusMessage, userInfo };
}
export function getBackendStatusMessage(
connectedToServer: boolean,
userEmail: string,
userEmail: string | undefined,
khojUrl: string,
khojApiKey: string
): string {
@ -215,3 +214,155 @@ export function getBackendStatusMessage(
else
return `✅ Signed in to Khoj as ${userEmail}`;
}
export async function populateHeaderPane(headerEl: Element, setting: KhojSetting): Promise<void> {
let userInfo: UserInfo | null = null;
try {
const { userInfo: extractedUserInfo } = await canConnectToBackend(setting.khojUrl, setting.khojApiKey, false);
userInfo = extractedUserInfo;
} catch (error) {
console.error("❗Could not connect to Khoj");
}
// Add Khoj title to header element
const titleEl = headerEl.createDiv();
titleEl.className = 'khoj-logo';
titleEl.textContent = "KHOJ"
// Populate the header element with the navigation pane
// Create the nav element
const nav = headerEl.createEl('nav');
nav.className = 'khoj-nav';
// Create the chat link
const chatLink = nav.createEl('a');
chatLink.id = 'chat-nav';
chatLink.className = 'khoj-nav chat-nav';
// Create the chat icon
const chatIcon = chatLink.createEl('span');
chatIcon.className = 'khoj-nav-icon khoj-nav-icon-chat';
setIcon(chatIcon, 'khoj-chat');
// Create the chat text
const chatText = chatLink.createEl('span');
chatText.className = 'khoj-nav-item-text';
chatText.textContent = 'Chat';
// Append the chat icon and text to the chat link
chatLink.appendChild(chatIcon);
chatLink.appendChild(chatText);
// Create the search link
const searchLink = nav.createEl('a');
searchLink.id = 'search-nav';
searchLink.className = 'khoj-nav search-nav';
// Create the search icon
const searchIcon = searchLink.createEl('span');
searchIcon.className = 'khoj-nav-icon khoj-nav-icon-search';
// Create the search text
const searchText = searchLink.createEl('span');
searchText.className = 'khoj-nav-item-text';
searchText.textContent = 'Search';
// Append the search icon and text to the search link
searchLink.appendChild(searchIcon);
searchLink.appendChild(searchText);
// Create the search link
const similarLink = nav.createEl('a');
similarLink.id = 'similar-nav';
similarLink.className = 'khoj-nav similar-nav';
// Create the search icon
const similarIcon = searchLink.createEl('span');
similarIcon.id = 'similar-nav-icon';
similarIcon.className = 'khoj-nav-icon khoj-nav-icon-similar';
setIcon(similarIcon, 'webhook');
// Create the search text
const similarText = searchLink.createEl('span');
similarText.className = 'khoj-nav-item-text';
similarText.textContent = 'Similar';
// Append the search icon and text to the search link
similarLink.appendChild(similarIcon);
similarLink.appendChild(similarText);
// Append the nav items to the nav element
nav.appendChild(chatLink);
nav.appendChild(searchLink);
nav.appendChild(similarLink);
// Append the title, nav items to the header element
headerEl.appendChild(titleEl);
headerEl.appendChild(nav);
}
export enum KhojView {
CHAT = "khoj-chat-view",
}
function copyParentText(event: MouseEvent, message: string, originalButton: string) {
const button = event.currentTarget as HTMLElement;
if (!button || !button?.parentNode?.textContent) return;
if (!!button.firstChild) button.removeChild(button.firstChild as HTMLImageElement);
const textContent = message ?? button.parentNode.textContent.trim();
navigator.clipboard.writeText(textContent).then(() => {
setIcon((button as HTMLElement), 'copy-check');
setTimeout(() => {
setIcon((button as HTMLElement), originalButton);
}, 1000);
}).catch((error) => {
console.error("Error copying text to clipboard:", error);
const originalButtonText = button.innerHTML;
button.innerHTML = "⛔️";
setTimeout(() => {
button.innerHTML = originalButtonText;
setIcon((button as HTMLElement), originalButton);
}, 2000);
});
return textContent;
}
export function createCopyParentText(message: string, originalButton: string = 'copy-plus') {
return function(event: MouseEvent) {
return copyParentText(event, message, originalButton);
}
}
export function pasteTextAtCursor(text: string | undefined) {
// Get the current active file's editor
const editor: Editor = this.app.workspace.getActiveFileView()?.editor
if (!editor || !text) return;
const cursor = editor.getCursor();
// If there is a selection, replace it with the text
if (editor?.getSelection()) {
editor.replaceSelection(text);
// If there is no selection, insert the text at the cursor position
} else if (cursor) {
editor.replaceRange(text, cursor);
}
}
export function getLinkToEntry(sourceFiles: TFile[], chosenFile: string, chosenEntry: string): string | undefined {
// Find the vault file matching file of chosen file, entry
let fileMatch = sourceFiles
// Sort by descending length of path
// This finds longest path match when multiple files have same name
.sort((a, b) => b.path.length - a.path.length)
// The first match is the best file match across OS
// e.g Khoj server on Linux, Obsidian vault on Android
.find(file => chosenFile.replace(/\\/g, "/").endsWith(file.path))
// Return link to vault file at heading of chosen search result
if (fileMatch) {
let resultHeading = fileMatch.extension !== 'pdf' ? chosenEntry.split('\n', 1)[0] : '';
let linkToEntry = resultHeading.startsWith('#') ? `${fileMatch.path}${resultHeading}` : fileMatch.path;
console.log(`Link: ${linkToEntry}, File: ${fileMatch.path}, Heading: ${resultHeading}`);
return linkToEntry;
}
}

View file

@ -11,6 +11,8 @@ If your plugin does not need CSS, delete this file.
--khoj-winter-sun: #f9f5de;
--khoj-sun: #fee285;
--khoj-storm-grey: #475569;
--chat-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24px' height='24px' viewBox='0 0 24 24' fill='currentColor' stroke-linecap='round' stroke-linejoin='round' class='svg-icon' version='1.1'%3E%3Cpath d='m 14.024348,9.8497703 0.04627,1.9750167' stroke='%231c274c' stroke-width='2' stroke-linecap='round' /%3E%3Cpath d='m 9.6453624,9.7953624 0.046275,1.9750166' stroke='%231c274c' stroke-width='2' stroke-linecap='round' /%3E%3Cpath d='m 11.90538,2.3619994 c -5.4939109,0 -9.6890976,4.0608185 -9.6890976,9.8578926 0,1.477202 0.2658016,2.542848 0.6989332,3.331408 0.433559,0.789293 1.0740097,1.372483 1.9230615,1.798517 1.7362861,0.87132 4.1946007,1.018626 7.0671029,1.018626 0.317997,0 0.593711,0.167879 0.784844,0.458501 0.166463,0.253124 0.238617,0.552748 0.275566,0.787233 0.07263,0.460801 0.05871,1.030165 0.04785,1.474824 v 4.8e-5 l -2.26e-4,0.0091 c -0.0085,0.348246 -0.01538,0.634247 -0.0085,0.861186 0.105589,-0.07971 0.227925,-0.185287 0.36735,-0.31735 0.348613,-0.330307 0.743513,-0.767362 1.176607,-1.246635 l 0.07837,-0.08673 c 0.452675,-0.500762 0.941688,-1.037938 1.41216,-1.473209 0.453774,-0.419787 0.969948,-0.822472 1.476003,-0.953853 1.323661,-0.343655 2.330132,-0.904027 3.005749,-1.76381 0.658957,-0.838568 1.073167,-2.051868 1.073167,-3.898667 0,-5.7970748 -4.195186,-9.8578946 -9.689097,-9.8578946 z M 0.92440678,12.219892 c 0,-7.0067939 5.05909412,-11.47090892 10.98097322,-11.47090892 5.921878,0 10.980972,4.46411502 10.980972,11.47090892 0,2.172259 -0.497596,3.825405 -1.442862,5.028357 -0.928601,1.181693 -2.218843,1.837914 -3.664937,2.213334 -0.211641,0.05502 -0.53529,0.268579 -0.969874,0.670658 -0.417861,0.386604 -0.865628,0.876836 -1.324566,1.384504 l -0.09131,0.101202 c -0.419252,0.464136 -0.849637,0.94059 -1.239338,1.309807 -0.210187,0.199169 -0.425281,0.383422 -0.635348,0.523424 -0.200911,0.133819 -0.449635,0.263369 -0.716376,0.281474 -0.327812,0.02226 -0.61539,-0.149209 -0.804998,-0.457293 -0.157614,-0.255993 -0.217622,-0.557143 -0.246564,-0.778198 -0.0542,-0.414027 -0.04101,-0.933065 -0.03027,-1.355183 l 0.0024,-0.0922 c 0.01099,-0.463865 0.01489,-0.820507 -0.01611,-1.06842 C 8.9434608,19.975238 6.3139711,19.828758 4.356743,18.84659 3.3355029,18.334136 2.4624526,17.578678 1.8500164,16.463713 1.2372016,15.348029 0.92459928,13.943803 0.92459928,12.219967 Z' clip-rule='evenodd' stroke-width='2' fill='currentColor' fill-rule='evenodd' fill-opacity='1' /%3E%3C/svg%3E%0A");
--search-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24px' height='24px' viewBox='0 0 24 24' fill='currentColor' stroke-linecap='round' stroke-linejoin='round' class='svg-icon' version='1.1'%3E%3Cpath d='m 18.562765,17.147843 c 1.380497,-1.679442 2.307667,-4.013099 2.307667,-6.330999 C 20.870432,5.3951476 16.353958,1 10.782674,1 5.2113555,1 0.69491525,5.3951476 0.69491525,10.816844 c 0,5.421663 4.51644025,9.816844 10.08775875,9.816844 2.381867,0 4.570922,-0.803307 6.296712,-2.14673 0.508475,-0.508475 4.514633,4.192839 4.514633,4.192839 1.036377,1.008544 2.113087,-0.02559 1.07671,-1.034139 z m -7.780091,1.925408 c -4.3394583,0 -8.6708434,-4.033489 -8.6708434,-8.256407 0,-4.2229187 4.3313851,-8.2564401 8.6708434,-8.2564401 4.339458,0 8.670809,4.2369112 8.670809,8.4598301 0,4.222918 -4.331351,8.053017 -8.670809,8.053017 z' fill='currentColor' fill-rule='evenodd' clip-rule='evenodd' fill-opacity='1' stroke-width='1.10519' stroke-dasharray='none' /%3E%3Cpath d='m 13.337351,9.3402647 0.05184,2.1532893' stroke='%231c274c' stroke-width='2' stroke-linecap='round' /%3E%3Cpath d='M 8.431347,9.2809457 8.483191,11.434235' stroke='%231c274c' stroke-width='2' stroke-linecap='round' /%3E%3C/svg%3E%0A");
}
.khoj-chat p {
@ -22,6 +24,7 @@ If your plugin does not need CSS, delete this file.
.khoj-chat {
display: grid;
grid-template-rows: auto 1fr auto;
background: var(--background-primary);
color: var(--text-normal);
text-align: center;
@ -73,6 +76,7 @@ If your plugin does not need CSS, delete this file.
display: inline-block;
max-width: 80%;
text-align: left;
user-select: text;
}
/* color chat bubble by khoj blue */
.khoj-chat-message-text.khoj {
@ -113,7 +117,7 @@ If your plugin does not need CSS, delete this file.
.khoj-chat-message-text ul,
.khoj-chat-message-text ol {
margin: -20px 0 0;
margin: 0px 0 0;
}
.khoj-chat-message-text ol li {
white-space: normal;
@ -153,14 +157,14 @@ div.expanded.reference-section {
grid-auto-flow: row;
grid-column-gap: 10px;
grid-row-gap: 10px;
margin: 10px;
margin: 10px 0;
}
button.reference-button {
background: var(--color-base-00);
color: var(--color-base-100);
background: var(--khoj-winter-sun);
color: var(--khoj-storm-grey);
border: 1px solid var(--khoj-storm-grey);
border-radius: 5px;
padding: 5px;
padding: 4px;
font-size: 14px;
font-weight: 300;
line-height: 1.5em;
@ -179,6 +183,13 @@ button.reference-button.expanded {
max-height: none;
white-space: pre-wrap;
}
button.reference-button.expanded > :nth-child(2) {
display: block;
}
button.reference-button.collapsed > :nth-child(2) {
display: none;
}
button.reference-button::before {
content: "▶";
margin-right: 5px;
@ -191,11 +202,12 @@ button.reference-button[aria-expanded="true"]::before {
transform: rotate(90deg);
}
button.reference-expand-button {
background: var(--color-base-00);
color: var(--color-base-100);
background: var(--khoj-winter-sun);
color: var(--khoj-storm-grey);
border: 1px solid var(--khoj-storm-grey);
border-radius: 5px;
padding: 5px;
padding: 8px;
margin-top: 8px;
font-size: 14px;
font-weight: 300;
line-height: 1.5em;
@ -205,14 +217,14 @@ button.reference-expand-button {
}
button.reference-expand-button:hover {
background: var(--khoj-sun);
color: var(--color-base-00);
color: var(--khoj-storm-grey);
}
a.inline-chat-link {
color: #475569;
text-decoration: none;
border-bottom: 1px dotted #475569;
}
a.reference-link {
.reference-link {
color: var(--khoj-storm-grey);
border-bottom: 1px dotted var(--khoj-storm-grey);
}
@ -230,6 +242,98 @@ img {
max-width: 60%;
}
div.new-conversation {
display: grid;
grid-auto-flow: column;
grid-template-columns: 1fr auto;
margin-bottom: 16px;
}
div.conversation-header-title {
text-align: left;
font-size: larger;
line-height: 1.5em;
}
div.conversation-session {
color: var(--color-base-90);
border: 1px solid var(--khoj-storm-grey);
border-radius: 5px;
margin-top: 8px;
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: grid;
grid-auto-flow: column;
grid-template-columns: 1fr auto;
}
.three-dot-menu {
display: block;
/* background: var(--background-color); */
/* border: 1px solid var(--main-text-color); */
border-radius: 5px;
/* position: relative; */
}
button.selected-conversation {
background: var(--khoj-winter-sun);
}
button.three-dot-menu-button-item {
color: var(--color-base-90);
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(--khoj-storm-grey);
color: var(--khoj-winter-sun);
}
.three-dot-menu-button {
background: var(--khoj-winter-sun);
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 {
z-index: 1;
top: 100%;
right: 0;
text-align: right;
border-radius: 5px;
padding: 5px;
}
div.conversation-session:hover {
transform: scale(1.03);
}
div.selected-conversation {
background: var(--khoj-winter-sun) !important;
color: var(--khoj-storm-grey) !important;
}
#khoj-chat-footer {
padding: 0;
display: grid;
@ -345,3 +449,187 @@ img {
.khoj-result-entry p br {
display: none;
}
/* Khoj Header, Navigation Pane */
div.khoj-header {
display: grid;
grid-auto-flow: column;
gap: 20px;
padding: 0 0 10px 0;
margin: 0;
align-items: center;
user-select: none;
-webkit-user-select: none;
-webkit-app-region: drag;
}
/* Keeps the navigation menu clickable */
a.khoj-nav {
-webkit-app-region: no-drag;
}
div.khoj-nav {
-webkit-app-region: no-drag;
}
nav.khoj-nav {
display: grid;
grid-auto-flow: column;
grid-gap: 32px;
justify-self: right;
align-items: center;
}
a.khoj-nav {
display: flex;
align-items: center;
}
div.khoj-logo {
justify-self: left;
}
.khoj-nav a {
color: var(--main-text-color);
text-decoration: none;
font-size: small;
font-weight: normal;
padding: 0 4px;
border-radius: 4px;
justify-self: center;
margin: 0;
}
.khoj-nav a:hover {
background-color: var(--khoj-sun);
color: var(--main-text-color);
}
a.khoj-nav-selected {
background-color: var(--khoj-winter-sun);
}
#similar-nav-icon-svg,
.khoj-nav-icon {
width: 24px;
height: 24px;
}
.khoj-nav-icon-chat {
background-image: var(--chat-icon);
}
.khoj-nav-icon-search {
background-image: var(--search-icon);
}
span.khoj-nav-item-text {
padding-left: 8px;
}
/* Copy button */
button.copy-button {
border-radius: 4px;
background-color: var(--background-color);
border: 1px solid var(--main-text-color);
text-align: center;
font-size: 16px;
transition: all 0.5s;
cursor: pointer;
padding: 4px;
margin-top: 8px;
float: right;
}
button.copy-button span {
cursor: pointer;
display: inline-block;
position: relative;
transition: 0.5s;
}
img.copy-icon {
width: 16px;
height: 16px;
}
.you button.copy-button {
color: var(--text-on-accent);
}
.khoj button.copy-button {
color: var(--khoj-storm-grey);
}
.you button.copy-button:hover {
color: var(--khoj-storm-grey);
background: var(--text-on-accent);
}
.khoj button.copy-button:hover {
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(--color-base-70);
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;
grid-auto-flow: column;
gap: 20px;
padding: 24px 10px 10px 10px;
margin: 0 0 16px 0;
}
nav.khoj-nav {
grid-gap: 0px;
justify-content: space-between;
}
a.khoj-nav {
padding: 0 16px;
}
span.khoj-nav-item-text {
display: none;
}
}

View file

@ -102,7 +102,10 @@ To get started, just start typing below. You can also type / to see a list of co
return `${time_string}, ${date_string}`;
}
function generateReference(reference, index) {
function generateReference(referenceJson, index) {
let reference = referenceJson.hasOwnProperty("compiled") ? referenceJson.compiled : referenceJson;
let referenceFile = referenceJson.hasOwnProperty("file") ? referenceJson.file : null;
// Escape reference for HTML rendering
let escaped_ref = reference.replaceAll('"', '&quot;');
@ -277,95 +280,47 @@ To get started, just start typing below. You can also type / to see a list of co
return numOnlineReferences;
}
function renderMessageWithReference(message, by, context=null, dt=null, onlineContext=null, intentType=null, inferredQueries=null, userQuery) {
// If no document or online context is provided, render the message as is
if ((context == null || context.length == 0) && (onlineContext == null || (onlineContext && Object.keys(onlineContext).length == 0))) {
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})`;
} else if (intentType === "text-to-image-v3") {
imageMarkdown = `![](data:image/webp;base64,${message})`;
}
const inferredQuery = inferredQueries?.[0];
if (inferredQuery) {
imageMarkdown += `\n\n**Inferred Query**:\n\n${inferredQuery}`;
}
return renderMessage(imageMarkdown, by, dt, null, false, "return", userQuery);
}
return renderMessage(message, by, dt, null, false, "return", userQuery);
function renderMessageWithReference(message, by, context=null, dt=null, onlineContext=null, intentType=null, inferredQueries=null) {
let chatEl;
if (intentType?.includes("text-to-image")) {
let imageMarkdown = generateImageMarkdown(message, intentType, inferredQueries);
chatEl = renderMessage(imageMarkdown, by, dt, null, false, "return");
} else {
chatEl = renderMessage(message, by, dt, null, false, "return");
}
if ((context && context.length == 0) && (onlineContext == null || (onlineContext && Object.keys(onlineContext).length == 0))) {
return renderMessage(message, by, dt, null, false, "return", userQuery);
// If no document or online context is provided, render the message as is
if ((context == null || context?.length == 0)
&& (onlineContext == null || (onlineContext && Object.keys(onlineContext).length == 0))) {
return chatEl;
}
// If document or online context is provided, render the message with its references
let references = document.createElement('div');
let references = {};
if (!!context) references["notes"] = context;
if (!!onlineContext) references["online"] = onlineContext;
let chatMessageEl = chatEl.getElementsByClassName("chat-message-text")[0];
chatMessageEl.appendChild(createReferenceSection(references));
let referenceExpandButton = document.createElement('button');
referenceExpandButton.classList.add("reference-expand-button");
let numReferences = 0;
if (context) {
numReferences += context.length;
}
references.appendChild(referenceExpandButton);
let referenceSection = document.createElement('div');
referenceSection.classList.add("reference-section");
referenceSection.classList.add("collapsed");
referenceExpandButton.addEventListener('click', function() {
if (referenceSection.classList.contains("collapsed")) {
referenceSection.classList.remove("collapsed");
referenceSection.classList.add("expanded");
} else {
referenceSection.classList.add("collapsed");
referenceSection.classList.remove("expanded");
}
});
references.classList.add("references");
if (context) {
for (let index in context) {
let reference = context[index];
let polishedReference = generateReference(reference, index);
referenceSection.appendChild(polishedReference);
}
}
if (onlineContext) {
numReferences += processOnlineReferences(referenceSection, onlineContext);
}
let expandButtonText = numReferences == 1 ? "1 reference" : `${numReferences} references`;
referenceExpandButton.innerHTML = expandButtonText;
references.appendChild(referenceSection);
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})`;
} else if (intentType === "text-to-image-v3") {
imageMarkdown = `![](data:image/webp;base64,${message})`;
}
const inferredQuery = inferredQueries?.[0];
if (inferredQuery) {
imageMarkdown += `\n\n**Inferred Query**:\n\n${inferredQuery}`;
}
return renderMessage(imageMarkdown, by, dt, references, false, "return", userQuery);
}
return renderMessage(message, by, dt, references, false, "return", userQuery);
return chatEl;
}
function generateImageMarkdown(message, intentType, inferredQueries=null) {
let imageMarkdown;
if (intentType === "text-to-image") {
imageMarkdown = `![](data:image/png;base64,${message})`;
} else if (intentType === "text-to-image2") {
imageMarkdown = `![](${message})`;
} else if (intentType === "text-to-image-v3") {
imageMarkdown = `![](data:image/webp;base64,${message})`;
}
const inferredQuery = inferredQueries?.[0];
if (inferredQuery) {
imageMarkdown += `\n\n**Inferred Query**:\n\n${inferredQuery}`;
}
return imageMarkdown;
}
//handler function for posting feedback data to endpoint
function sendFeedback(_uquery="", _kquery="", _sentiment="") {
const uquery = _uquery;
@ -387,7 +342,7 @@ To get started, just start typing below. You can also type / to see a list of co
// Replace LaTeX delimiters with placeholders
newHTML = newHTML.replace(/\\\(/g, 'LEFTPAREN').replace(/\\\)/g, 'RIGHTPAREN')
.replace(/\\\[/g, 'LEFTBRACKET').replace(/\\\]/g, 'RIGHTBRACKET');
.replace(/\\\[/g, 'LEFTBRACKET').replace(/\\\]/g, 'RIGHTBRACKET');
// 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, '');
@ -408,7 +363,7 @@ To get started, just start typing below. You can also type / to see a list of co
// Replace placeholders with LaTeX delimiters
newHTML = newHTML.replace(/LEFTPAREN/g, '\\(').replace(/RIGHTPAREN/g, '\\)')
.replace(/LEFTBRACKET/g, '\\[').replace(/RIGHTBRACKET/g, '\\]');
.replace(/LEFTBRACKET/g, '\\[').replace(/RIGHTBRACKET/g, '\\]');
// Set rendered markdown to HTML DOM element
let element = document.createElement('div');
@ -1101,11 +1056,7 @@ To get started, just start typing below. You can also type / to see a list of co
}
}
function sendMessageViaWebSocket(event) {
if (event) {
event.preventDefault();
}
function sendMessageViaWebSocket() {
let chatBody = document.getElementById("chat-body");
var query = document.getElementById("chat-input").value.trim();
@ -2645,7 +2596,7 @@ To get started, just start typing below. You can also type / to see a list of co
text-align: left;
display: flex;
position: relative;
margin: 0 8px;
margin-right: 8px;
}
.three-dot-menu {
@ -3030,7 +2981,5 @@ To get started, just start typing below. You can also type / to see a list of co
transform: translate(24px, 0);
}
}
</style>
</html>

View file

@ -142,7 +142,7 @@ def converse_offline(
# Initialize Variables
assert loaded_model is None or isinstance(loaded_model, Llama), "loaded_model must be of type Llama, if configured"
offline_chat_model = loaded_model or download_model(model, max_tokens=max_prompt_size)
compiled_references_message = "\n\n".join({f"{item}" for item in references})
compiled_references_message = "\n\n".join({f"{item['compiled']}" for item in references})
current_date = datetime.now().strftime("%Y-%m-%d")

View file

@ -126,7 +126,7 @@ def converse(
"""
# Initialize Variables
current_date = datetime.now().strftime("%Y-%m-%d")
compiled_references = "\n\n".join({f"# {item}" for item in references})
compiled_references = "\n\n".join({f"# {item['compiled']}" for item in references})
conversation_primer = prompts.query_prompt.format(query=user_query)

View file

@ -96,7 +96,7 @@ def save_to_conversation_log(
user: KhojUser,
meta_log: Dict,
user_message_time: str = None,
compiled_references: List[str] = [],
compiled_references: List[Dict[str, Any]] = [],
online_results: Dict[str, Any] = {},
inferred_queries: List[str] = [],
intent_type: str = "remember",

View file

@ -358,14 +358,14 @@ async def extract_references_and_questions(
# Collate search results as context for GPT
with timer("Searching knowledge base took", logger):
result_list = []
search_results = []
logger.info(f"🔍 Searching knowledge base with queries: {inferred_queries}")
if send_status_func:
inferred_queries_str = "\n- " + "\n- ".join(inferred_queries)
await send_status_func(f"**🔍 Searching Documents for:** {inferred_queries_str}")
for query in inferred_queries:
n_items = min(n, 3) if using_offline_chat else n
result_list.extend(
search_results.extend(
await execute_search(
user,
f"{query} {filters_in_query}",
@ -376,8 +376,10 @@ async def extract_references_and_questions(
dedupe=False,
)
)
result_list = text_search.deduplicated_search_responses(result_list)
compiled_references = [item.additional["compiled"] for item in result_list]
search_results = text_search.deduplicated_search_responses(search_results)
compiled_references = [
{"compiled": item.additional["compiled"], "file": item.additional["file"]} for item in search_results
]
return compiled_references, inferred_queries, defiltered_query

View file

@ -586,7 +586,7 @@ async def websocket_endpoint(
if compiled_references:
headings = "\n- " + "\n- ".join(
set([" ".join(c.split("Path: ")[1:]).split("\n ")[0] for c in compiled_references])
set([" ".join(c.get("compiled", c).split("Path: ")[1:]).split("\n ")[0] for c in compiled_references])
)
await send_status_update(f"**📜 Found Relevant Notes**: {headings}")
@ -944,6 +944,12 @@ async def chat(
actual_response = aggregated_gpt_response.split("### compiled references:")[0]
response_obj = {"response": actual_response, "context": compiled_references}
response_obj = {
"response": actual_response,
"intentType": intent_type,
"inferredQueries": inferred_queries,
"context": compiled_references,
"online_results": online_results,
}
return Response(content=json.dumps(response_obj), media_type="application/json", status_code=200)

View file

@ -414,7 +414,7 @@ async def generate_better_image_prompt(
q: str,
conversation_history: str,
location_data: LocationData,
note_references: List[str],
note_references: List[Dict[str, Any]],
online_results: Optional[dict] = None,
) -> str:
"""
@ -429,7 +429,7 @@ async def generate_better_image_prompt(
else:
location_prompt = "Unknown"
user_references = "\n\n".join([f"# {item}" for item in note_references])
user_references = "\n\n".join([f"# {item['compiled']}" for item in note_references])
simplified_online_results = {}
@ -596,7 +596,7 @@ def generate_chat_response(
q: str,
meta_log: dict,
conversation: Conversation,
compiled_references: List[str] = [],
compiled_references: List[Dict] = [],
online_results: Dict[str, Dict] = {},
inferred_queries: List[str] = [],
conversation_commands: List[ConversationCommand] = [ConversationCommand.Default],
@ -698,7 +698,7 @@ async def text_to_image(
user: KhojUser,
conversation_log: dict,
location_data: LocationData,
references: List[str],
references: List[Dict[str, Any]],
online_results: Dict[str, Any],
send_status_func: Optional[Callable] = None,
) -> Tuple[Optional[str], int, Optional[str], str]: