From 9f65e8de98074ed289fda1d4ef648568172f3df2 Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Mon, 6 May 2024 21:47:28 +0800 Subject: [PATCH 01/22] Open Khoj Chat as a Pane instead of a Modal - Allows having it open on the side as you traverse your Obsidian notes - Allow faster time to response, having responses visible for context - Enables ambient interactions --- .../src/{chat_modal.ts => chat_view.ts} | 32 +++++++++++++------ src/interface/obsidian/src/main.ts | 30 ++++++++++++++--- 2 files changed, 49 insertions(+), 13 deletions(-) rename src/interface/obsidian/src/{chat_modal.ts => chat_view.ts} (96%) diff --git a/src/interface/obsidian/src/chat_modal.ts b/src/interface/obsidian/src/chat_view.ts similarity index 96% rename from src/interface/obsidian/src/chat_modal.ts rename to src/interface/obsidian/src/chat_view.ts index 31b938a1..f9b67f12 100644 --- a/src/interface/obsidian/src/chat_modal.ts +++ b/src/interface/obsidian/src/chat_view.ts @@ -1,6 +1,8 @@ -import { App, MarkdownRenderer, Modal, request, requestUrl, setIcon } from 'obsidian'; +import { ItemView, MarkdownRenderer, WorkspaceLeaf, request, requestUrl, setIcon } from 'obsidian'; import { KhojSetting } from 'src/settings'; +export const KHOJ_CHAT_VIEW = "khoj-chat-view"; + export interface ChatJsonResult { image?: string; detail?: string; @@ -9,7 +11,7 @@ export interface ChatJsonResult { } -export class KhojChatModal extends Modal { +export class KhojChatView extends ItemView { result: string; setting: KhojSetting; region: string; @@ -17,13 +19,13 @@ export class KhojChatModal extends Modal { countryName: string; timezone: string; - constructor(app: App, setting: KhojSetting) { - super(app); + 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() }); - + // this.scope.register([], 'Enter', async () => { await this.chat() }); fetch("https://ipapi.co/json") .then(response => response.json()) @@ -39,6 +41,18 @@ export class KhojChatModal extends Modal { }); } + getViewType(): string { + return KHOJ_CHAT_VIEW; + } + + getDisplayText(): string { + return "Khoj"; + } + + getIcon(): string { + return "message-circle"; + } + async chat() { // Get text in chat input element let input_el = this.contentEl.getElementsByClassName("khoj-chat-input")[0]; @@ -503,7 +517,7 @@ export class KhojChatModal extends Modal { event.preventDefault(); const transcribeButton = this.contentEl.getElementsByClassName("khoj-transcribe")[0]; const chatInput = this.contentEl.getElementsByClassName("khoj-chat-input")[0]; - const sendButton = this.modalEl.getElementsByClassName("khoj-chat-send")[0] + const sendButton = this.contentEl.getElementsByClassName("khoj-chat-send")[0] const generateRequestBody = async (audioBlob: Blob, boundary_string: string) => { const boundary = `------${boundary_string}`; @@ -606,7 +620,7 @@ export class KhojChatModal extends Modal { clearTimeout(this.sendMessageTimeout); // Revert to showing send-button and hide the stop-send-button - let sendButton = this.modalEl.getElementsByClassName("khoj-chat-send")[0]; + let sendButton = this.contentEl.getElementsByClassName("khoj-chat-send")[0]; setIcon(sendButton, "arrow-up-circle"); let sendImg = sendButton.getElementsByClassName("lucide-arrow-up-circle")[0] sendImg.addEventListener('click', async (_) => { await this.chat() }); @@ -637,7 +651,7 @@ export class KhojChatModal extends Modal { } scrollChatToBottom() { - let sendButton = this.modalEl.getElementsByClassName("khoj-chat-send")[0]; + let sendButton = this.contentEl.getElementsByClassName("khoj-chat-send")[0]; sendButton.scrollIntoView({ behavior: "auto", block: "center" }); } } diff --git a/src/interface/obsidian/src/main.ts b/src/interface/obsidian/src/main.ts index 7e152c49..9f3420e7 100644 --- a/src/interface/obsidian/src/main.ts +++ b/src/interface/obsidian/src/main.ts @@ -1,7 +1,7 @@ -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 { KhojChatView, KHOJ_CHAT_VIEW } from 'src/chat_view' import { updateContentIndex, canConnectToBackend } from './utils'; @@ -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(KHOJ_CHAT_VIEW); } }); + this.registerView(KHOJ_CHAT_VIEW, (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(KHOJ_CHAT_VIEW); }); // 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: 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); + } } From f8f9d066db0622e96d6757b15c708dfc688fe177 Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Tue, 7 May 2024 00:54:01 +0800 Subject: [PATCH 02/22] Focus on input field, scroll to latest message on opening chat pane Previously scroll and chat input focus weren't applied as view hadn't been rendered yet --- src/interface/obsidian/src/chat_view.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/interface/obsidian/src/chat_view.ts b/src/interface/obsidian/src/chat_view.ts index f9b67f12..9f95a1e0 100644 --- a/src/interface/obsidian/src/chat_view.ts +++ b/src/interface/obsidian/src/chat_view.ts @@ -127,9 +127,15 @@ export class KhojChatView extends ItemView { chatInput.placeholder = placeholderText; chatInput.disabled = !getChatHistorySucessfully; - // Scroll to bottom of modal, till the send message input box - this.scrollChatToBottom(); - chatInput.focus(); + // Scroll to bottom of chat messages and focus on chat input field, once messages rendered + requestAnimationFrame(() => { + // Ensure layout and paint have occurred + requestAnimationFrame(() => { + this.scrollChatToBottom(); + const chatInput = this.contentEl.getElementsByClassName("khoj-chat-input")[0]; + chatInput?.focus(); + }); + }); } generateReference(messageEl: Element, reference: string, index: number) { @@ -651,7 +657,7 @@ export class KhojChatView extends ItemView { } scrollChatToBottom() { - let sendButton = this.contentEl.getElementsByClassName("khoj-chat-send")[0]; - sendButton.scrollIntoView({ behavior: "auto", block: "center" }); + const chat_body_el = this.contentEl.getElementsByClassName("khoj-chat-body")[0]; + if (!!chat_body_el) chat_body_el.scrollTop = chat_body_el.scrollHeight; } } From 0a1a6cd04116b9535a6f5751c20016bd2272dc4a Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Tue, 7 May 2024 01:47:35 +0800 Subject: [PATCH 03/22] Get detailed user info in Obsidian from the new v1/user API Previously we were just getting user email from the /health API Instead store the retrieved user info in the user settings --- src/interface/obsidian/src/settings.ts | 19 ++++++++++++++----- src/interface/obsidian/src/utils.ts | 23 +++++++++++------------ 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/src/interface/obsidian/src/settings.ts b/src/interface/obsidian/src/settings.ts index 875bd40a..5e0e3494 100644 --- a/src/interface/obsidian/src/settings.ts +++ b/src/interface/obsidian/src/settings.ts @@ -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; - 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(); diff --git a/src/interface/obsidian/src/utils.ts b/src/interface/obsidian/src/utils.ts index 11b6ac07..3f34864e 100644 --- a/src/interface/obsidian/src/utils.ts +++ b/src/interface/obsidian/src/utils.ts @@ -1,5 +1,5 @@ import { FileSystemAdapter, Notice, Vault, Modal, TFile, request } from 'obsidian'; -import { KhojSetting } from 'src/settings' +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 { From 57f1c532141b80ea3bc64a62a288b4e23755f7d0 Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Tue, 7 May 2024 04:28:25 +0800 Subject: [PATCH 04/22] Create Nav bar for Obsidian pane. Use abstract View class for reuse - Jump to chat, show similar actions from nav menu of Khoj side pane - Add chat, search icons from web, desktop app - Use lucide icon for find similar (for now) - Match proportions of find similar icon to khoj other icons via css, js - Use KhojPaneView abstract class to allow reuse of common functionality like - Creating the nav bar header in side pane views - Loading geo-location data for chat context This should make creating new views easier --- src/interface/obsidian/src/chat_view.ts | 35 +++------- src/interface/obsidian/src/main.ts | 12 ++-- src/interface/obsidian/src/pane_view.ts | 71 +++++++++++++++++++ src/interface/obsidian/src/utils.ts | 92 ++++++++++++++++++++++++- src/interface/obsidian/styles.css | 92 +++++++++++++++++++++++++ 5 files changed, 268 insertions(+), 34 deletions(-) create mode 100644 src/interface/obsidian/src/pane_view.ts diff --git a/src/interface/obsidian/src/chat_view.ts b/src/interface/obsidian/src/chat_view.ts index 9f95a1e0..190e2660 100644 --- a/src/interface/obsidian/src/chat_view.ts +++ b/src/interface/obsidian/src/chat_view.ts @@ -1,7 +1,7 @@ -import { ItemView, MarkdownRenderer, WorkspaceLeaf, request, requestUrl, setIcon } from 'obsidian'; +import { MarkdownRenderer, WorkspaceLeaf, request, requestUrl, setIcon } from 'obsidian'; import { KhojSetting } from 'src/settings'; - -export const KHOJ_CHAT_VIEW = "khoj-chat-view"; +import { KhojPaneView } from 'src/pane_view'; +import { KhojView } from 'src/utils'; export interface ChatJsonResult { image?: string; @@ -11,7 +11,7 @@ export interface ChatJsonResult { } -export class KhojChatView extends ItemView { +export class KhojChatView extends KhojPaneView { result: string; setting: KhojSetting; region: string; @@ -20,33 +20,15 @@ export class KhojChatView extends ItemView { timezone: string; 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() }); - - 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; - }); + super(leaf, setting); } getViewType(): string { - return KHOJ_CHAT_VIEW; + return KhojView.CHAT; } getDisplayText(): string { - return "Khoj"; + return "Khoj Chat"; } getIcon(): string { @@ -70,8 +52,7 @@ export class KhojChatView extends ItemView { 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" })); + super.onOpen(); // Create area for chat logs let chatBodyEl = contentEl.createDiv({ attr: { id: "khoj-chat-body", class: "khoj-chat-body" } }); diff --git a/src/interface/obsidian/src/main.ts b/src/interface/obsidian/src/main.ts index 9f3420e7..83ac17b8 100644 --- a/src/interface/obsidian/src/main.ts +++ b/src/interface/obsidian/src/main.ts @@ -1,8 +1,8 @@ import { Plugin, WorkspaceLeaf } from 'obsidian'; import { KhojSetting, KhojSettingTab, DEFAULT_SETTINGS } from 'src/settings' import { KhojSearchModal } from 'src/search_modal' -import { KhojChatView, KHOJ_CHAT_VIEW } from 'src/chat_view' -import { updateContentIndex, canConnectToBackend } from './utils'; +import { KhojChatView } from 'src/chat_view' +import { updateContentIndex, canConnectToBackend, KhojView } from './utils'; export default class Khoj extends Plugin { @@ -30,14 +30,14 @@ export default class Khoj extends Plugin { this.addCommand({ id: 'chat', name: 'Chat', - callback: () => { this.activateView(KHOJ_CHAT_VIEW); } + callback: () => { this.activateView(KhojView.CHAT); } }); - this.registerView(KHOJ_CHAT_VIEW, (leaf) => new KhojChatView(leaf, this.settings)); + this.registerView(KhojView.CHAT, (leaf) => new KhojChatView(leaf, this.settings)); // Create an icon in the left ribbon. this.addRibbonIcon('message-circle', 'Khoj', (_: MouseEvent) => { - this.activateView(KHOJ_CHAT_VIEW); + this.activateView(KhojView.CHAT); }); // Add a settings tab so the user can configure khoj @@ -72,7 +72,7 @@ export default class Khoj extends Plugin { this.unload(); } - async activateView(viewType: string) { + async activateView(viewType: KhojView) { const { workspace } = this.app; let leaf: WorkspaceLeaf | null = null; diff --git a/src/interface/obsidian/src/pane_view.ts b/src/interface/obsidian/src/pane_view.ts new file mode 100644 index 00000000..27fbeef6 --- /dev/null +++ b/src/interface/obsidian/src/pane_view.ts @@ -0,0 +1,71 @@ +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 { + result: string; + setting: KhojSetting; + region: string; + city: string; + countryName: string; + timezone: string; + + 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() }); + + 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() { + 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); + } +} diff --git a/src/interface/obsidian/src/utils.ts b/src/interface/obsidian/src/utils.ts index 3f34864e..aa25e5fe 100644 --- a/src/interface/obsidian/src/utils.ts +++ b/src/interface/obsidian/src/utils.ts @@ -1,4 +1,4 @@ -import { FileSystemAdapter, Notice, Vault, Modal, TFile, request } from 'obsidian'; +import { FileSystemAdapter, Notice, Vault, Modal, TFile, request, setIcon } from 'obsidian'; import { KhojSetting, UserInfo } from 'src/settings' export function getVaultAbsolutePath(vault: Vault): string { @@ -214,3 +214,93 @@ export function getBackendStatusMessage( else return `✅ Signed in to Khoj as ${userEmail}`; } + +export async function populateHeaderPane(headerEl: Element, setting: KhojSetting): Promise { + 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", +} diff --git a/src/interface/obsidian/styles.css b/src/interface/obsidian/styles.css index 67154035..4bab47ae 100644 --- a/src/interface/obsidian/styles.css +++ b/src/interface/obsidian/styles.css @@ -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 { @@ -344,3 +346,93 @@ 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; +} + +@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; + } +} From 032ad3b5211823488add35d65af3c6b1478fc171 Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Tue, 7 May 2024 08:33:17 +0800 Subject: [PATCH 05/22] Add ability to copy messages to clipboard from Obsidian Khoj chat --- src/interface/obsidian/src/chat_view.ts | 14 ++++++++-- src/interface/obsidian/src/utils.ts | 29 ++++++++++++++++++++ src/interface/obsidian/styles.css | 36 +++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 2 deletions(-) diff --git a/src/interface/obsidian/src/chat_view.ts b/src/interface/obsidian/src/chat_view.ts index 190e2660..14ca1aff 100644 --- a/src/interface/obsidian/src/chat_view.ts +++ b/src/interface/obsidian/src/chat_view.ts @@ -1,7 +1,7 @@ import { MarkdownRenderer, WorkspaceLeaf, request, requestUrl, setIcon } from 'obsidian'; import { KhojSetting } from 'src/settings'; import { KhojPaneView } from 'src/pane_view'; -import { KhojView } from 'src/utils'; +import { KhojView, createCopyParentText } from 'src/utils'; export interface ChatJsonResult { image?: string; @@ -214,7 +214,7 @@ export class KhojChatView extends KhojPaneView { referenceExpandButton.innerHTML = expandButtonText; } - renderMessage(chatEl: Element, message: string, sender: string, dt?: Date, raw: boolean=false): Element { + renderMessage(chatEl: Element, message: string, sender: string, dt?: Date, raw: boolean=false, willReplace: boolean=true): Element { let message_time = this.formatDate(dt ?? new Date()); let emojified_sender = sender == "khoj" ? "🏮 Khoj" : "🤔 You"; @@ -236,6 +236,16 @@ export class KhojChatView extends KhojPaneView { MarkdownRenderer.renderMarkdown(message, chat_message_body_text_el, '', null); } + // Add a copy button to each chat message + if (willReplace === true) { + let copyButton = chatMessageEl.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); + } + // Remove user-select: none property to make text selectable chatMessageEl.style.userSelect = "text"; diff --git a/src/interface/obsidian/src/utils.ts b/src/interface/obsidian/src/utils.ts index aa25e5fe..b0160c48 100644 --- a/src/interface/obsidian/src/utils.ts +++ b/src/interface/obsidian/src/utils.ts @@ -304,3 +304,32 @@ export async function populateHeaderPane(headerEl: Element, setting: KhojSetting 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); + } +} diff --git a/src/interface/obsidian/styles.css b/src/interface/obsidian/styles.css index 4bab47ae..e98dd753 100644 --- a/src/interface/obsidian/styles.css +++ b/src/interface/obsidian/styles.css @@ -416,6 +416,42 @@ 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; + 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); +} + @media only screen and (max-width: 600px) { div.khoj-header { display: grid; From bd4931e70b52ba566d44a4b72f207309f9451fe0 Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Tue, 7 May 2024 11:11:21 -0700 Subject: [PATCH 06/22] Add ability to paste chat messages directly into current file It'll replace any highlighted text with the chat message or if not text is highlighted, it'll insert the chat message at the last cursor position in the active file --- src/interface/obsidian/src/chat_view.ts | 10 +++++++++- src/interface/obsidian/src/utils.ts | 16 +++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/interface/obsidian/src/chat_view.ts b/src/interface/obsidian/src/chat_view.ts index 14ca1aff..9549969e 100644 --- a/src/interface/obsidian/src/chat_view.ts +++ b/src/interface/obsidian/src/chat_view.ts @@ -1,7 +1,7 @@ import { MarkdownRenderer, WorkspaceLeaf, request, requestUrl, setIcon } from 'obsidian'; import { KhojSetting } from 'src/settings'; import { KhojPaneView } from 'src/pane_view'; -import { KhojView, createCopyParentText } from 'src/utils'; +import { KhojView, createCopyParentText, pasteTextAtCursor } from 'src/utils'; export interface ChatJsonResult { image?: string; @@ -244,6 +244,14 @@ export class KhojChatView extends KhojPaneView { 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 = chatMessageEl.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); } // Remove user-select: none property to make text selectable diff --git a/src/interface/obsidian/src/utils.ts b/src/interface/obsidian/src/utils.ts index b0160c48..53df076f 100644 --- a/src/interface/obsidian/src/utils.ts +++ b/src/interface/obsidian/src/utils.ts @@ -1,4 +1,4 @@ -import { FileSystemAdapter, Notice, Vault, Modal, TFile, request, setIcon } from 'obsidian'; +import { FileSystemAdapter, Notice, Vault, Modal, TFile, request, setIcon, Editor } from 'obsidian'; import { KhojSetting, UserInfo } from 'src/settings' export function getVaultAbsolutePath(vault: Vault): string { @@ -333,3 +333,17 @@ export function createCopyParentText(message: string, originalButton: string = ' 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); + } +} From afcd22d30c1b6b9e07b886d907812e24ef865029 Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Tue, 7 May 2024 16:05:50 -0700 Subject: [PATCH 07/22] Improve spacing, colors of chat message references and buttons Works better with dark modes. References have more spacing and adhere to background color of the chat message itself --- src/interface/obsidian/styles.css | 18 ++++++++++-------- src/khoj/interface/web/chat.html | 10 +++------- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/src/interface/obsidian/styles.css b/src/interface/obsidian/styles.css index e98dd753..7b7b71d0 100644 --- a/src/interface/obsidian/styles.css +++ b/src/interface/obsidian/styles.css @@ -155,14 +155,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; @@ -192,11 +192,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; @@ -206,7 +207,7 @@ 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; @@ -426,6 +427,7 @@ button.copy-button { transition: all 0.5s; cursor: pointer; padding: 4px; + margin-top: 8px; float: right; } button.copy-button span { diff --git a/src/khoj/interface/web/chat.html b/src/khoj/interface/web/chat.html index bcffc254..ae6d9b9b 100644 --- a/src/khoj/interface/web/chat.html +++ b/src/khoj/interface/web/chat.html @@ -355,7 +355,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 [INST] and tags. These are spurious instructions for the AI chat model. newHTML = newHTML.replace(/\[INST\].+(<\/s>)?/g, ''); @@ -376,7 +376,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'); @@ -1039,11 +1039,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(); From 14a2006c767565e45648d4adea65f171edb69e7a Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Wed, 8 May 2024 03:13:22 -0700 Subject: [PATCH 08/22] 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 --- src/interface/obsidian/src/chat_view.ts | 531 +++++++++++++++++++++++- src/interface/obsidian/src/pane_view.ts | 18 - src/interface/obsidian/styles.css | 57 +++ src/khoj/interface/web/chat.html | 2 - 4 files changed, 576 insertions(+), 32 deletions(-) diff --git a/src/interface/obsidian/src/chat_view.ts b/src/interface/obsidian/src/chat_view.ts index 9549969e..4350a608 100644 --- a/src/interface/obsidian/src/chat_view.ts +++ b/src/interface/obsidian/src/chat_view.ts @@ -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 = 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 = `Question: ${question}

`; + } 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 + `

${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, """) @@ -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 [INST] and tags. These are spurious instructions for the AI chat model. + rendered_msg = rendered_msg.replace(/\[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 { + async getChatHistory(chatBodyEl: HTMLElement): Promise { // 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®ion=${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®ion=${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 ? `®ion=${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 = ''; + } + 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: "", + } + } } diff --git a/src/interface/obsidian/src/pane_view.ts b/src/interface/obsidian/src/pane_view.ts index 27fbeef6..40659572 100644 --- a/src/interface/obsidian/src/pane_view.ts +++ b/src/interface/obsidian/src/pane_view.ts @@ -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() { diff --git a/src/interface/obsidian/styles.css b/src/interface/obsidian/styles.css index 7b7b71d0..48f21b0d 100644 --- a/src/interface/obsidian/styles.css +++ b/src/interface/obsidian/styles.css @@ -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; diff --git a/src/khoj/interface/web/chat.html b/src/khoj/interface/web/chat.html index ae6d9b9b..95b5e19e 100644 --- a/src/khoj/interface/web/chat.html +++ b/src/khoj/interface/web/chat.html @@ -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); } } - - From f495d338ebd4df48750951feee983d7cb3da9ceb Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Mon, 20 May 2024 10:19:18 -0500 Subject: [PATCH 09/22] Modularize render message with references func in web based clients Simplify, reuse, standardize code to render messages with references in the obsidian, web and desktop clients. Specifically: - Reuse function to create reference section, dedupe code - Create reusable function to generate image markdown - Simplify logic to render message with references --- src/interface/desktop/chat.html | 114 ++++--------- src/interface/obsidian/src/chat_view.ts | 205 +++++++++--------------- src/khoj/interface/web/chat.html | 107 ++++--------- 3 files changed, 134 insertions(+), 292 deletions(-) diff --git a/src/interface/desktop/chat.html b/src/interface/desktop/chat.html index 73e88159..68ef9513 100644 --- a/src/interface/desktop/chat.html +++ b/src/interface/desktop/chat.html @@ -219,98 +219,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) { diff --git a/src/interface/obsidian/src/chat_view.ts b/src/interface/obsidian/src/chat_view.ts index 4350a608..97149b98 100644 --- a/src/interface/obsidian/src/chat_view.ts +++ b/src/interface/obsidian/src/chat_view.ts @@ -172,36 +172,36 @@ export class KhojChatView extends KhojPaneView { 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); + for (let key in onlineReference.organic) { + let reference = onlineReference.organic[key]; + let polishedReference = this.generateOnlineReference(referenceSection, reference, key); 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); + for (let key in onlineReference.knowledgeGraph) { + let reference = onlineReference.knowledgeGraph[key]; + let polishedReference = this.generateOnlineReference(referenceSection, reference, key); 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); + for (let key in onlineReference.peopleAlsoAsk) { + let reference = onlineReference.peopleAlsoAsk[key]; + let polishedReference = this.generateOnlineReference(referenceSection, reference, key); 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); + for (let key in onlineReference.webpages) { + let reference = onlineReference.webpages[key]; + let polishedReference = this.generateOnlineReference(referenceSection, reference, key); referenceSection.appendChild(polishedReference); } } @@ -215,14 +215,10 @@ export class KhojChatView extends KhojPaneView { let title = reference.title || reference.link; let link = reference.link; let snippet = reference.snippet; - let question = reference.question; - if (question) { - question = `Question: ${question}

`; - } else { - question = ""; - } + let question = reference.question ? `Question: ${reference.question}

` : ""; - let linkElement = messageEl.createEl('a'); + let referenceButton = messageEl.createEl('button'); + let linkElement = referenceButton.createEl('a'); linkElement.setAttribute('href', link); linkElement.setAttribute('target', '_blank'); linkElement.setAttribute('rel', 'noopener noreferrer'); @@ -230,8 +226,6 @@ export class KhojChatView extends KhojPaneView { 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"); @@ -325,68 +319,53 @@ export class KhojChatView extends KhojPaneView { return chat_message_body_text_el; } - 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(); + renderMessageWithReferences( + chatEl: Element, + message: string, + sender: string, + context?: string[], + dt?: Date, + intentType?: string, + inferredQueries?: string[], + ) { + if (!message) return; - let referenceExpandButton = references.createEl('button'); - referenceExpandButton.classList.add("reference-expand-button"); - let numReferences = 0; - - if (context) { - numReferences += context.length; + let chatMessageEl; + if (intentType?.includes("text-to-image")) { + let imageMarkdown = this.generateImageMarkdown(message, intentType, inferredQueries); + chatMessageEl = this.renderMessage(chatEl, imageMarkdown, sender, dt); + } else { + chatMessageEl = this.renderMessage(chatEl, message, sender, dt); } - 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); - }); + // If no document or online context is provided, skip rendering the reference section + if (context == null || context.length == 0) { + return; } - let expandButtonText = numReferences == 1 ? "1 reference" : `${numReferences} references`; - referenceExpandButton.innerHTML = expandButtonText; + // If document or online context is provided, render the message with its references + let references: any = {}; + if (!!context) references["notes"] = context; + let chatMessageBodyEl = chatMessageEl.getElementsByClassName("khoj-chat-message-text")[0]; + chatMessageBodyEl.appendChild(this.createReferenceSection(references)); + } + + generateImageMarkdown(message: string, intentType: string, inferredQueries?: string[]) { + 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}`; + } + } + return imageMarkdown; } renderMessage(chatEl: Element, message: string, sender: string, dt?: Date, raw: boolean=false, willReplace: boolean=true): Element { @@ -423,7 +402,7 @@ export class KhojChatView extends KhojPaneView { // Add button to paste into current buffer let pasteToFile = chatMessageEl.createEl('button'); pasteToFile.classList.add("copy-button"); - pasteToFile.title = "Paste Message to File"; + pasteToFile.title = "Paste Message to Current File"; setIcon(pasteToFile, "clipboard-paste"); pasteToFile.addEventListener('click', (event) => { pasteTextAtCursor(createCopyParentText(message, 'clipboard-paste')(event)); }); chat_message_body_text_el.append(pasteToFile); @@ -435,7 +414,7 @@ export class KhojChatView extends KhojPaneView { // Scroll to bottom after inserting chat messages this.scrollChatToBottom(); - return chatMessageEl + return chatMessageEl; } createKhojResponseDiv(dt?: Date): HTMLDivElement { @@ -548,41 +527,8 @@ export class KhojChatView extends KhojPaneView { 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); + let references = this.extractReferences(rawReferenceAsJson); + responseElement.appendChild(this.createReferenceSection(references)); } else { // Render incremental chat response await this.renderIncrementalMessage(responseElement, responseText); @@ -898,18 +844,15 @@ export class KhojChatView extends KhojPaneView { handleCompiledReferences(rawResponseElement: HTMLElement | null, chunk: string, references: any, rawResponse: string) { if (!rawResponseElement || !chunk) return { rawResponse, references }; - const additionalResponse = chunk.split("### compiled references:")[0]; + + const [additionalResponse, rawReference] = chunk.split("### compiled references:", 2); 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; - } + references = this.extractReferences(rawReferenceAsJson); + return { rawResponse, references }; } @@ -929,14 +872,9 @@ export class KhojChatView extends KhojPaneView { rawResponse += `\n\n**Inferred Query**:\n\n${inferredQuery}`; } } - let references: any = {}; + let references = {}; 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; - } + references = this.extractReferences(imageJson.context); } if (imageJson.detail) { // If response has detail field, response is an error message. @@ -945,6 +883,14 @@ export class KhojChatView extends KhojPaneView { return { rawResponse, references }; } + extractReferences(rawReferenceAsJson: any): object { + let references: any = {}; + if (rawReferenceAsJson instanceof Array) { + references["notes"] = rawReferenceAsJson; + } + return references; + } + addMessageToChatBody(rawResponse: string, newResponseElement: HTMLElement | null, references: any) { if (!newResponseElement) return; newResponseElement.innerHTML = ""; @@ -1146,7 +1092,6 @@ export class KhojChatView extends KhojPaneView { // Temporary status message to indicate that Khoj is thinking let loadingEllipsis = this.createLoadingEllipse(); - newResponseTextEl.appendChild(loadingEllipsis); chatBody.scrollTop = chatBody.scrollHeight; diff --git a/src/khoj/interface/web/chat.html b/src/khoj/interface/web/chat.html index 95b5e19e..f8f43770 100644 --- a/src/khoj/interface/web/chat.html +++ b/src/khoj/interface/web/chat.html @@ -260,93 +260,44 @@ To get started, just start typing below. You can also type / to see a list of co } function renderMessageWithReference(message, by, context=null, dt=null, onlineContext=null, intentType=null, inferredQueries=null) { - // 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"); + 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"); + // 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; + 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) { From 38d8d2bb56df4983f913c733616363a83f282b55 Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Mon, 20 May 2024 16:33:07 -0500 Subject: [PATCH 10/22] Show online references used to generate response in Obsidian chat view --- src/interface/obsidian/src/chat_view.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/interface/obsidian/src/chat_view.ts b/src/interface/obsidian/src/chat_view.ts index 97149b98..bd1d992d 100644 --- a/src/interface/obsidian/src/chat_view.ts +++ b/src/interface/obsidian/src/chat_view.ts @@ -324,6 +324,7 @@ export class KhojChatView extends KhojPaneView { message: string, sender: string, context?: string[], + onlineContext?: object, dt?: Date, intentType?: string, inferredQueries?: string[], @@ -339,13 +340,15 @@ export class KhojChatView extends KhojPaneView { } // If no document or online context is provided, skip rendering the reference section - if (context == null || context.length == 0) { + if ((context == null || context.length == 0) + && (onlineContext == null || (onlineContext && Object.keys(onlineContext).length == 0))) { return; } // If document or online context is provided, render the message with its references let references: any = {}; if (!!context) references["notes"] = context; + if (!!onlineContext) references["online"] = onlineContext; let chatMessageBodyEl = chatMessageEl.getElementsByClassName("khoj-chat-message-text")[0]; chatMessageBodyEl.appendChild(this.createReferenceSection(references)); } @@ -493,6 +496,7 @@ export class KhojChatView extends KhojPaneView { chatLog.message, chatLog.by, chatLog.context, + chatLog.onlineContext, new Date(chatLog.created), chatLog.intent?.type, chatLog.intent?.["inferred-queries"], @@ -887,6 +891,8 @@ export class KhojChatView extends KhojPaneView { let references: any = {}; if (rawReferenceAsJson instanceof Array) { references["notes"] = rawReferenceAsJson; + } else if (typeof rawReferenceAsJson === "object" && rawReferenceAsJson !== null) { + references["online"] = rawReferenceAsJson; } return references; } From ba330712f821c9224bb79dcc817f50e87dc18305 Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Sun, 26 May 2024 13:56:55 +0530 Subject: [PATCH 11/22] Fix to always pass online results in chat API response --- src/khoj/routers/api_chat.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/khoj/routers/api_chat.py b/src/khoj/routers/api_chat.py index b7fef39a..d7783528 100644 --- a/src/khoj/routers/api_chat.py +++ b/src/khoj/routers/api_chat.py @@ -780,6 +780,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) From e24ca9ec28a3d9a5ca6b3bf0ae5f0566347f71e5 Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Sun, 26 May 2024 17:18:38 +0530 Subject: [PATCH 12/22] Pass file path of each doc reference in references returned by API - Pass file path of reference along with the compiled reference in list of references returned by chat API converts - Update the structure of references from list of strings to list of dictionary (containing 'compiled' and 'file' keys) - Pull out the compiled reference from the new references data struct wherever it was is being used --- src/interface/desktop/chat.html | 5 ++++- src/interface/obsidian/src/chat_view.ts | 3 ++- src/khoj/interface/web/chat.html | 5 ++++- src/khoj/processor/conversation/offline/chat_model.py | 2 +- src/khoj/processor/conversation/openai/gpt.py | 2 +- src/khoj/processor/conversation/utils.py | 2 +- src/khoj/routers/api.py | 10 ++++++---- src/khoj/routers/api_chat.py | 2 +- src/khoj/routers/helpers.py | 8 ++++---- 9 files changed, 24 insertions(+), 15 deletions(-) diff --git a/src/interface/desktop/chat.html b/src/interface/desktop/chat.html index 68ef9513..b1b31b71 100644 --- a/src/interface/desktop/chat.html +++ b/src/interface/desktop/chat.html @@ -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('"', '"'); diff --git a/src/interface/obsidian/src/chat_view.ts b/src/interface/obsidian/src/chat_view.ts index bd1d992d..755285e4 100644 --- a/src/interface/obsidian/src/chat_view.ts +++ b/src/interface/obsidian/src/chat_view.ts @@ -247,7 +247,8 @@ export class KhojChatView extends KhojPaneView { return referenceButton; } - generateReference(messageEl: Element, reference: string, index: number) { + generateReference(messageEl: Element, referenceJson: any, index: number) { + let reference: string = referenceJson.hasOwnProperty("compiled") ? referenceJson.compiled : referenceJson; // Escape reference for HTML rendering let escaped_ref = reference.replace(/"/g, """) diff --git a/src/khoj/interface/web/chat.html b/src/khoj/interface/web/chat.html index f8f43770..ca4117c7 100644 --- a/src/khoj/interface/web/chat.html +++ b/src/khoj/interface/web/chat.html @@ -103,7 +103,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('"', '"'); diff --git a/src/khoj/processor/conversation/offline/chat_model.py b/src/khoj/processor/conversation/offline/chat_model.py index a559df22..edc2d9f0 100644 --- a/src/khoj/processor/conversation/offline/chat_model.py +++ b/src/khoj/processor/conversation/offline/chat_model.py @@ -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") diff --git a/src/khoj/processor/conversation/openai/gpt.py b/src/khoj/processor/conversation/openai/gpt.py index c25f05fd..8360a32e 100644 --- a/src/khoj/processor/conversation/openai/gpt.py +++ b/src/khoj/processor/conversation/openai/gpt.py @@ -127,7 +127,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) diff --git a/src/khoj/processor/conversation/utils.py b/src/khoj/processor/conversation/utils.py index 775848c8..5d68d17d 100644 --- a/src/khoj/processor/conversation/utils.py +++ b/src/khoj/processor/conversation/utils.py @@ -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", diff --git a/src/khoj/routers/api.py b/src/khoj/routers/api.py index 70245f72..067d18b3 100644 --- a/src/khoj/routers/api.py +++ b/src/khoj/routers/api.py @@ -342,14 +342,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}", @@ -360,8 +360,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 diff --git a/src/khoj/routers/api_chat.py b/src/khoj/routers/api_chat.py index d7783528..30534b13 100644 --- a/src/khoj/routers/api_chat.py +++ b/src/khoj/routers/api_chat.py @@ -434,7 +434,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}") diff --git a/src/khoj/routers/helpers.py b/src/khoj/routers/helpers.py index cb3f5491..85852331 100644 --- a/src/khoj/routers/helpers.py +++ b/src/khoj/routers/helpers.py @@ -400,7 +400,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: """ @@ -415,7 +415,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 = {} @@ -550,7 +550,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], @@ -634,7 +634,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]: From 0f796a79ec3d7389cd442cac82fef445af52d550 Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Sun, 26 May 2024 18:03:15 +0530 Subject: [PATCH 13/22] Extract function to get link to entry in Obsidian vault for reuse --- src/interface/obsidian/src/search_modal.ts | 21 ++++----------------- src/interface/obsidian/src/utils.ts | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/src/interface/obsidian/src/search_modal.ts b/src/interface/obsidian/src/search_modal.ts index c1b09db9..7d791204 100644 --- a/src/interface/obsidian/src/search_modal.ts +++ b/src/interface/obsidian/src/search_modal.ts @@ -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 { 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, ''); } } diff --git a/src/interface/obsidian/src/utils.ts b/src/interface/obsidian/src/utils.ts index 53df076f..cfdbc431 100644 --- a/src/interface/obsidian/src/utils.ts +++ b/src/interface/obsidian/src/utils.ts @@ -347,3 +347,22 @@ export function pasteTextAtCursor(text: string | undefined) { 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; + } +} From e86899eec4e95f9f811c23ee0b477da36a0119ec Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Sun, 26 May 2024 18:04:20 +0530 Subject: [PATCH 14/22] Click on referenced notes by Khoj chat to open it in Obsidian vault Allow opening Khoj chat references in Obsidian vault if the reference is a heading or file in the current Obsidian vault --- src/interface/obsidian/src/chat_view.ts | 36 ++++++++++++++++++++----- src/interface/obsidian/styles.css | 9 ++++++- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/src/interface/obsidian/src/chat_view.ts b/src/interface/obsidian/src/chat_view.ts index 755285e4..b38abdd2 100644 --- a/src/interface/obsidian/src/chat_view.ts +++ b/src/interface/obsidian/src/chat_view.ts @@ -1,7 +1,7 @@ import { MarkdownRenderer, WorkspaceLeaf, request, requestUrl, setIcon } from 'obsidian'; import { KhojSetting } from 'src/settings'; import { KhojPaneView } from 'src/pane_view'; -import { KhojView, createCopyParentText, pasteTextAtCursor } from 'src/utils'; +import { KhojView, createCopyParentText, getLinkToEntry, pasteTextAtCursor } from 'src/utils'; export interface ChatJsonResult { image?: string; @@ -249,14 +249,39 @@ export class KhojChatView extends KhojPaneView { generateReference(messageEl: Element, referenceJson: any, index: number) { let reference: string = referenceJson.hasOwnProperty("compiled") ? referenceJson.compiled : referenceJson; + let referenceFile = referenceJson.hasOwnProperty("file") ? referenceJson.file : null; + + // Get all markdown and PDF files in vault + const mdFiles = this.app.vault.getMarkdownFiles(); + const pdfFiles = this.app.vault.getFiles().filter(file => file.extension === 'pdf'); + // Escape reference for HTML rendering + reference = reference.split('\n').slice(1).join('\n'); let escaped_ref = reference.replace(/"/g, """) // 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; + + if (referenceFile) { + // Find vault file associated with current reference + let linkToEntry = getLinkToEntry(mdFiles.concat(pdfFiles), referenceFile, reference); + + let linkElement: Element; + linkElement = referenceButton.createEl('span'); + linkElement.setAttribute('title', escaped_ref); + linkElement.textContent = referenceFile; + if (linkToEntry && linkToEntry) { + linkElement.classList.add("reference-link"); + linkElement.addEventListener('click', (event) => { + event.stopPropagation(); + this.app.workspace.openLinkText(linkToEntry, ''); + }); + } + } + + let referenceText = referenceButton.createDiv(); + referenceText.textContent = escaped_ref; + referenceButton.id = `ref-${index}`; referenceButton.classList.add("reference-button"); referenceButton.classList.add("collapsed"); @@ -264,15 +289,12 @@ export class KhojChatView extends KhojPaneView { // 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; } }); diff --git a/src/interface/obsidian/styles.css b/src/interface/obsidian/styles.css index 48f21b0d..665dc4a8 100644 --- a/src/interface/obsidian/styles.css +++ b/src/interface/obsidian/styles.css @@ -181,6 +181,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; @@ -214,7 +221,7 @@ a.inline-chat-link { 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); } From 1ea7675fc965f39869c71d93c43f8f2b3acda744 Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Tue, 28 May 2024 10:15:46 +0530 Subject: [PATCH 15/22] View, switch chat sessions from Obsidian chat pane --- src/interface/obsidian/src/chat_view.ts | 175 +++++++++++++++++++++++- src/interface/obsidian/styles.css | 81 +++++++++++ 2 files changed, 250 insertions(+), 6 deletions(-) diff --git a/src/interface/obsidian/src/chat_view.ts b/src/interface/obsidian/src/chat_view.ts index b38abdd2..3aa5a947 100644 --- a/src/interface/obsidian/src/chat_view.ts +++ b/src/interface/obsidian/src/chat_view.ts @@ -106,14 +106,14 @@ export class KhojChatView extends KhojPaneView { // Add chat input field let inputRow = contentEl.createDiv("khoj-input-row"); - let clearChat = inputRow.createEl("button", { - text: "Clear History", + let chatSessions = inputRow.createEl("button", { + text: "Chat Sessions", attr: { class: "khoj-input-row-button clickable-icon", }, }) - clearChat.addEventListener('click', async (_) => { await this.clearConversationHistory() }); - setIcon(clearChat, "trash"); + chatSessions.addEventListener('click', async (_) => { await this.showChatSessions(chatBodyEl) }); + setIcon(chatSessions, "history"); let chatInput = inputRow.createEl("textarea", { attr: { @@ -394,13 +394,13 @@ export class KhojChatView extends KhojPaneView { return imageMarkdown; } - renderMessage(chatEl: Element, message: string, sender: string, dt?: Date, raw: boolean=false, willReplace: boolean=true): Element { + renderMessage(chatBodyEl: Element, message: string, sender: string, dt?: Date, raw: boolean=false, willReplace: boolean=true): 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({ + let chatMessageEl = chatBodyEl.createDiv({ attr: { "data-meta": `${emojified_sender} at ${message_time}`, class: `khoj-chat-message ${sender}` @@ -482,6 +482,169 @@ export class KhojChatView extends KhojPaneView { return `${time_string}, ${date_string}`; } + createNewConversation(chatBodyEl: HTMLElement) { + chatBodyEl.innerHTML = ""; + chatBodyEl.dataset.conversationId = ""; + chatBodyEl.dataset.conversationTitle = ""; + this.renderMessage(chatBodyEl, "Hey 👋🏾, what's up?", "khoj"); + } + + async showChatSessions(chatBodyEl: HTMLElement): Promise { + chatBodyEl.innerHTML = ""; + const sidePanelEl = this.contentEl.createDiv("side-panel"); + const newConversationEl = sidePanelEl.createDiv("new-conversation"); + const conversationHeaderTitleEl = newConversationEl.createDiv("conversation-header-title"); + conversationHeaderTitleEl.textContent = "Conversations"; + + const newConversationButtonEl = newConversationEl.createEl("button"); + newConversationButtonEl.classList.add("new-conversation-button"); + newConversationButtonEl.classList.add("side-panel-button"); + newConversationButtonEl.addEventListener('click', (_) => this.createNewConversation(chatBodyEl)); + setIcon(newConversationButtonEl, "plus"); + newConversationButtonEl.innerHTML += "New"; + + const existingConversationsEl = sidePanelEl.createDiv("existing-conversations"); + const conversationListEl = existingConversationsEl.createDiv("conversation-list"); + const conversationListBodyHeaderEl = conversationListEl.createDiv("conversation-list-header"); + const conversationListBodyEl = conversationListEl.createDiv("conversation-list-body"); + + const chatSessionsUrl = `${this.setting.khojUrl}/api/chat/sessions?client=obsidian`; + const headers = { 'Authorization': `Bearer ${this.setting.khojApiKey}` }; + try { + let response = await fetch(chatSessionsUrl, { method: "GET", headers: headers }); + let responseJson: any = await response.json(); + let conversationId = chatBodyEl.dataset.conversationId; + + if (responseJson.length > 0) { + conversationListBodyHeaderEl.style.display = "block"; + for (let key in responseJson) { + let conversation = responseJson[key]; + let conversationSessionEl = this.contentEl.createEl('div'); + let incomingConversationId = conversation["conversation_id"]; + const conversationTitle = conversation["slug"] || `New conversation 🌱`; + conversationSessionEl.textContent = conversationTitle; + conversationSessionEl.classList.add("conversation-session"); + if (incomingConversationId == conversationId) { + conversationSessionEl.classList.add("selected-conversation"); + } + conversationSessionEl.addEventListener('click', () => { + chatBodyEl.innerHTML = ""; + chatBodyEl.dataset.conversationId = incomingConversationId; + chatBodyEl.dataset.conversationTitle = conversationTitle; + this.getChatHistory(chatBodyEl); + }); + let threeDotMenuEl = this.contentEl.createEl('div'); + threeDotMenuEl.classList.add("three-dot-menu"); + let threeDotMenuButton = this.contentEl.createEl('button'); + threeDotMenuButton.innerHTML = "⋮"; + threeDotMenuButton.classList.add("three-dot-menu-button"); + threeDotMenuButton.addEventListener('click', (event) => { + event.stopPropagation(); + + let existingChildren = threeDotMenuEl.children; + if (existingChildren.length > 1) { + // Skip deleting the first, since that's the menu button. + for (let i = 1; i < existingChildren.length; i++) { + existingChildren[i].remove(); + } + return; + } + + let conversationMenuEl = this.contentEl.createEl('div'); + conversationMenuEl.classList.add("conversation-menu"); + + let editConversationTitleButtonEl = this.contentEl.createEl('button'); + editConversationTitleButtonEl.innerHTML = "Rename"; + editConversationTitleButtonEl.classList.add("edit-title-button"); + editConversationTitleButtonEl.classList.add("three-dot-menu-button-item"); + editConversationTitleButtonEl.addEventListener('click', (event) => { + event.stopPropagation(); + + let conversationMenuChildren = conversationMenuEl.children; + let totalItems = conversationMenuChildren.length; + + for (let i = totalItems - 1; i >= 0; i--) { + conversationMenuChildren[i].remove(); + } + + // Create a dialog box to get new title for conversation + let editConversationTitleInputBoxEl = this.contentEl.createEl('div'); + editConversationTitleInputBoxEl.classList.add("conversation-title-input-box"); + let editConversationTitleInputEl = this.contentEl.createEl('input'); + editConversationTitleInputEl.classList.add("conversation-title-input"); + editConversationTitleInputEl.value = conversationTitle; + editConversationTitleInputEl.addEventListener('click', function(event) { + event.stopPropagation(); + }); + editConversationTitleInputEl.addEventListener('keydown', function(event) { + if (event.key === "Enter") { + event.preventDefault(); + editConversationTitleSaveButtonEl.click(); + } + }); + let editConversationTitleSaveButtonEl = this.contentEl.createEl('button'); + editConversationTitleInputBoxEl.appendChild(editConversationTitleInputEl); + editConversationTitleSaveButtonEl.innerHTML = "Save"; + editConversationTitleSaveButtonEl.classList.add("three-dot-menu-button-item"); + editConversationTitleSaveButtonEl.addEventListener('click', (event) => { + event.stopPropagation(); + let newTitle = editConversationTitleInputEl.value; + if (newTitle != null) { + let editURL = `/api/chat/title?client=web&conversation_id=${incomingConversationId}&title=${newTitle}`; + fetch(`${this.setting.khojUrl}${editURL}` , { method: "PATCH", headers }) + .then(response => response.ok ? response.json() : Promise.reject(response)) + .then(data => { + conversationSessionEl.textContent = newTitle; + }) + .catch(err => { + return; + }); + editConversationTitleInputBoxEl.remove(); + }}); + editConversationTitleInputBoxEl.appendChild(editConversationTitleSaveButtonEl); + conversationMenuEl.appendChild(editConversationTitleInputBoxEl); + }); + + conversationMenuEl.appendChild(editConversationTitleButtonEl); + threeDotMenuEl.appendChild(conversationMenuEl); + + let deleteConversationButtonEl = this.contentEl.createEl('button'); + deleteConversationButtonEl.innerHTML = "Delete"; + deleteConversationButtonEl.classList.add("delete-conversation-button"); + deleteConversationButtonEl.classList.add("three-dot-menu-button-item"); + deleteConversationButtonEl.addEventListener('click', () => { + // Ask for confirmation before deleting chat session + let confirmation = confirm('Are you sure you want to delete this chat session?'); + if (!confirmation) return; + let deleteURL = `/api/chat/history?client=obsidian&conversation_id=${incomingConversationId}`; + fetch(`${this.setting.khojUrl}${deleteURL}` , { method: "DELETE", headers }) + .then(response => response.ok ? response.json() : Promise.reject(response)) + .then(data => { + chatBodyEl.innerHTML = ""; + chatBodyEl.dataset.conversationId = ""; + chatBodyEl.dataset.conversationTitle = ""; + this.getChatHistory(chatBodyEl); + }) + .catch(err => { + return; + }); + }); + + conversationMenuEl.appendChild(deleteConversationButtonEl); + threeDotMenuEl.appendChild(conversationMenuEl); + }); + threeDotMenuEl.appendChild(threeDotMenuButton); + conversationSessionEl.appendChild(threeDotMenuEl); + conversationListBodyEl.appendChild(conversationSessionEl); + chatBodyEl.appendChild(sidePanelEl); + } + } + } catch (err) { + return false; + } + return true; + } + async getChatHistory(chatBodyEl: HTMLElement): Promise { // Get chat history from Khoj backend let chatUrl = `${this.setting.khojUrl}/api/chat/history?client=obsidian`; diff --git a/src/interface/obsidian/styles.css b/src/interface/obsidian/styles.css index 665dc4a8..ba9489cd 100644 --- a/src/interface/obsidian/styles.css +++ b/src/interface/obsidian/styles.css @@ -239,6 +239,87 @@ img { max-width: 60%; } +div.conversation-session { + color: var(--color-base-90); + border: 1px solid var(--khoj-storm-grey); + border-radius: 5px; + padding: 5px; + font-size: 14px; + font-weight: 300; + line-height: 1.5em; + cursor: pointer; + transition: background 0.2s ease-in-out; + text-align: left; + display: 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.three-dot-menu-button-item { + background: var(--khoj-winter-sun); + color: var(--khoj-storm-grey); + 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; + background-color: var(--khoj-winter-sun); + border: 1px solid var(--khoj-storm-grey); + border-radius: 5px; + padding: 5px; + box-shadow: 0 0 11px #aaa; +} +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; From b27f59b12b75ec86fd5fd6c32c0ec83b18e83cf5 Mon Sep 17 00:00:00 2001 From: sabaimran Date: Thu, 30 May 2024 11:39:04 +0530 Subject: [PATCH 16/22] Remove all unused code related to websockets --- src/interface/obsidian/src/chat_view.ts | 186 ------------------------ 1 file changed, 186 deletions(-) diff --git a/src/interface/obsidian/src/chat_view.ts b/src/interface/obsidian/src/chat_view.ts index 3aa5a947..3bca574e 100644 --- a/src/interface/obsidian/src/chat_view.ts +++ b/src/interface/obsidian/src/chat_view.ts @@ -10,13 +10,6 @@ export interface ChatJsonResult { inferredQueries?: string[]; } -interface WebSocketState { - newResponseTextEl: HTMLElement | null, - newResponseEl: HTMLElement | null, - loadingEllipsis: HTMLElement | null, - references: object, - rawResponse: string, -} interface Location { region: string; @@ -29,21 +22,12 @@ 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()) @@ -60,7 +44,6 @@ export class KhojChatView extends KhojPaneView { }) .finally(() => { this.waitingForLocation = false; - this.setupWebSocket(); }); } @@ -78,10 +61,6 @@ 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 = this.contentEl.getElementsByClassName("khoj-chat-input")[0]; @@ -650,7 +629,6 @@ export class KhojChatView extends KhojPaneView { let chatUrl = `${this.setting.khojUrl}/api/chat/history?client=obsidian`; if (chatBodyEl.dataset.conversationId) { chatUrl += `&conversation_id=${chatBodyEl.dataset.conversationId}`; - this.setupWebSocket(); } try { @@ -671,7 +649,6 @@ export class KhojChatView extends KhojPaneView { } 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 🌱`; @@ -1140,167 +1117,4 @@ export class KhojChatView extends KhojPaneView { 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 ? `®ion=${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 = ''; - } - 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: "", - } - } } From 275d4877a6e9f4bfee1b7823f045b8383aa810b9 Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Wed, 29 May 2024 08:18:56 +0530 Subject: [PATCH 17/22] Fix loading spinner visibility by using contrasting background color Fix code formating of Khoj chat view in Obsidian --- src/interface/obsidian/src/chat_view.ts | 15 ++++++++------- src/interface/obsidian/styles.css | 2 +- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/interface/obsidian/src/chat_view.ts b/src/interface/obsidian/src/chat_view.ts index 3bca574e..d669e929 100644 --- a/src/interface/obsidian/src/chat_view.ts +++ b/src/interface/obsidian/src/chat_view.ts @@ -280,7 +280,7 @@ export class KhojChatView extends KhojPaneView { return referenceButton; } - formatHTMLMessage(message: string, raw=false, willReplace=true) { + formatHTMLMessage(message: string, raw = false, willReplace = true) { let rendered_msg = message; // Replace LaTeX delimiters with placeholders @@ -373,7 +373,7 @@ export class KhojChatView extends KhojPaneView { return imageMarkdown; } - renderMessage(chatBodyEl: Element, message: string, sender: string, dt?: Date, raw: boolean=false, willReplace: boolean=true): Element { + renderMessage(chatBodyEl: Element, message: string, sender: string, dt?: Date, raw: boolean = false, willReplace: boolean = true): Element { let message_time = this.formatDate(dt ?? new Date()); let emojified_sender = sender == "khoj" ? "🏮 Khoj" : "🤔 You"; @@ -570,7 +570,7 @@ export class KhojChatView extends KhojPaneView { let newTitle = editConversationTitleInputEl.value; if (newTitle != null) { let editURL = `/api/chat/title?client=web&conversation_id=${incomingConversationId}&title=${newTitle}`; - fetch(`${this.setting.khojUrl}${editURL}` , { method: "PATCH", headers }) + fetch(`${this.setting.khojUrl}${editURL}`, { method: "PATCH", headers }) .then(response => response.ok ? response.json() : Promise.reject(response)) .then(data => { conversationSessionEl.textContent = newTitle; @@ -578,8 +578,9 @@ export class KhojChatView extends KhojPaneView { .catch(err => { return; }); - editConversationTitleInputBoxEl.remove(); - }}); + editConversationTitleInputBoxEl.remove(); + } + }); editConversationTitleInputBoxEl.appendChild(editConversationTitleSaveButtonEl); conversationMenuEl.appendChild(editConversationTitleInputBoxEl); }); @@ -596,7 +597,7 @@ export class KhojChatView extends KhojPaneView { let confirmation = confirm('Are you sure you want to delete this chat session?'); if (!confirmation) return; let deleteURL = `/api/chat/history?client=obsidian&conversation_id=${incomingConversationId}`; - fetch(`${this.setting.khojUrl}${deleteURL}` , { method: "DELETE", headers }) + fetch(`${this.setting.khojUrl}${deleteURL}`, { method: "DELETE", headers }) .then(response => response.ok ? response.json() : Promise.reject(response)) .then(data => { chatBodyEl.innerHTML = ""; @@ -997,7 +998,7 @@ export class KhojChatView extends KhojPaneView { return loadingEllipsis; } - handleStreamResponse(newResponseElement: HTMLElement | null, rawResponse: string, loadingEllipsis: HTMLElement | null, replace=true) { + 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); diff --git a/src/interface/obsidian/styles.css b/src/interface/obsidian/styles.css index ba9489cd..a77416e0 100644 --- a/src/interface/obsidian/styles.css +++ b/src/interface/obsidian/styles.css @@ -555,7 +555,7 @@ img.copy-icon { width: 8px; height: 8px; border-radius: 50%; - background: var(--main-text-color); + background: var(--color-base-70); animation-timing-function: cubic-bezier(0, 1, 1, 0); } .lds-ellipsis div:nth-child(1) { From 2d010db83f14de3a961865c8fd85c50843a62861 Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Thu, 30 May 2024 13:47:26 +0530 Subject: [PATCH 18/22] Toggle chat session view on clicking the Obsidian chat sessions button --- src/interface/obsidian/src/chat_view.ts | 8 ++++++-- src/interface/obsidian/styles.css | 1 + 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/interface/obsidian/src/chat_view.ts b/src/interface/obsidian/src/chat_view.ts index d669e929..295464a8 100644 --- a/src/interface/obsidian/src/chat_view.ts +++ b/src/interface/obsidian/src/chat_view.ts @@ -91,7 +91,7 @@ export class KhojChatView extends KhojPaneView { class: "khoj-input-row-button clickable-icon", }, }) - chatSessions.addEventListener('click', async (_) => { await this.showChatSessions(chatBodyEl) }); + chatSessions.addEventListener('click', async (_) => { await this.toggleChatSessions(chatBodyEl) }); setIcon(chatSessions, "history"); let chatInput = inputRow.createEl("textarea", { @@ -468,7 +468,11 @@ export class KhojChatView extends KhojPaneView { this.renderMessage(chatBodyEl, "Hey 👋🏾, what's up?", "khoj"); } - async showChatSessions(chatBodyEl: HTMLElement): Promise { + async toggleChatSessions(chatBodyEl: HTMLElement): Promise { + if (this.contentEl.getElementsByClassName("side-panel")?.length > 0) { + chatBodyEl.innerHTML = ""; + return this.getChatHistory(chatBodyEl); + } chatBodyEl.innerHTML = ""; const sidePanelEl = this.contentEl.createDiv("side-panel"); const newConversationEl = sidePanelEl.createDiv("new-conversation"); diff --git a/src/interface/obsidian/styles.css b/src/interface/obsidian/styles.css index a77416e0..be6f2805 100644 --- a/src/interface/obsidian/styles.css +++ b/src/interface/obsidian/styles.css @@ -24,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; From 7fa42daf891abb05721e6375ae2ec7453f77ec33 Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Thu, 30 May 2024 19:30:21 +0530 Subject: [PATCH 19/22] Render action buttons for new Khoj chat responses in Obsidian - Dedupe the code to add action buttons to chat messages - Update the renderIncrementalMessage function to also add the action buttons to newly generated chat messages by Khoj --- src/interface/obsidian/src/chat_view.ts | 51 +++++++++++-------------- 1 file changed, 22 insertions(+), 29 deletions(-) diff --git a/src/interface/obsidian/src/chat_view.ts b/src/interface/obsidian/src/chat_view.ts index 295464a8..7e7b84c3 100644 --- a/src/interface/obsidian/src/chat_view.ts +++ b/src/interface/obsidian/src/chat_view.ts @@ -302,20 +302,7 @@ export class KhojChatView extends KhojPaneView { // 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); + this.renderActionButtons(message, chat_message_body_text_el); } return chat_message_body_text_el; @@ -395,22 +382,9 @@ export class KhojChatView extends KhojPaneView { MarkdownRenderer.renderMarkdown(message, chat_message_body_text_el, '', null); } - // Add a copy button to each chat message + // Add action buttons to each chat message element if (willReplace === true) { - let copyButton = chatMessageEl.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 = chatMessageEl.createEl('button'); - pasteToFile.classList.add("copy-button"); - pasteToFile.title = "Paste Message to Current File"; - setIcon(pasteToFile, "clipboard-paste"); - pasteToFile.addEventListener('click', (event) => { pasteTextAtCursor(createCopyParentText(message, 'clipboard-paste')(event)); }); - chat_message_body_text_el.append(pasteToFile); + this.renderActionButtons(message, chat_message_body_text_el); } // Remove user-select: none property to make text selectable @@ -450,10 +424,29 @@ export class KhojChatView extends KhojPaneView { htmlElement.innerHTML = ""; // @ts-ignore await MarkdownRenderer.renderMarkdown(this.result, htmlElement, '', null); + // Render action buttons for the message + this.renderActionButtons(this.result, htmlElement); // Scroll to bottom of modal, till the send message input box this.scrollChatToBottom(); } + renderActionButtons(message: string, chat_message_body_text_el: HTMLElement) { + 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); + } + 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 }); From 92bab9fa61fef1ac94b095fa00e12c8ff7a2cda3 Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Fri, 31 May 2024 07:36:44 +0530 Subject: [PATCH 20/22] Get Conversation session action buttons out from under the three dot menu --- src/interface/obsidian/src/chat_view.ts | 240 +++++++++++++----------- src/interface/obsidian/styles.css | 21 ++- 2 files changed, 150 insertions(+), 111 deletions(-) diff --git a/src/interface/obsidian/src/chat_view.ts b/src/interface/obsidian/src/chat_view.ts index 7e7b84c3..0d11330c 100644 --- a/src/interface/obsidian/src/chat_view.ts +++ b/src/interface/obsidian/src/chat_view.ts @@ -461,8 +461,8 @@ export class KhojChatView extends KhojPaneView { this.renderMessage(chatBodyEl, "Hey 👋🏾, what's up?", "khoj"); } - async toggleChatSessions(chatBodyEl: HTMLElement): Promise { - if (this.contentEl.getElementsByClassName("side-panel")?.length > 0) { + async toggleChatSessions(chatBodyEl: HTMLElement, forceShow: boolean = false): Promise { + if (!forceShow && this.contentEl.getElementsByClassName("side-panel")?.length > 0) { chatBodyEl.innerHTML = ""; return this.getChatHistory(chatBodyEl); } @@ -497,121 +497,32 @@ export class KhojChatView extends KhojPaneView { let conversation = responseJson[key]; let conversationSessionEl = this.contentEl.createEl('div'); let incomingConversationId = conversation["conversation_id"]; - const conversationTitle = conversation["slug"] || `New conversation 🌱`; - conversationSessionEl.textContent = conversationTitle; conversationSessionEl.classList.add("conversation-session"); if (incomingConversationId == conversationId) { conversationSessionEl.classList.add("selected-conversation"); } - conversationSessionEl.addEventListener('click', () => { + const conversationTitle = conversation["slug"] || `New conversation 🌱`; + const conversationSessionTitleEl = conversationSessionEl.createDiv("conversation-session-title"); + conversationSessionTitleEl.textContent = conversationTitle; + conversationSessionTitleEl.addEventListener('click', () => { chatBodyEl.innerHTML = ""; chatBodyEl.dataset.conversationId = incomingConversationId; chatBodyEl.dataset.conversationTitle = conversationTitle; this.getChatHistory(chatBodyEl); }); - let threeDotMenuEl = this.contentEl.createEl('div'); - threeDotMenuEl.classList.add("three-dot-menu"); - let threeDotMenuButton = this.contentEl.createEl('button'); - threeDotMenuButton.innerHTML = "⋮"; - threeDotMenuButton.classList.add("three-dot-menu-button"); - threeDotMenuButton.addEventListener('click', (event) => { - event.stopPropagation(); - let existingChildren = threeDotMenuEl.children; - if (existingChildren.length > 1) { - // Skip deleting the first, since that's the menu button. - for (let i = 1; i < existingChildren.length; i++) { - existingChildren[i].remove(); - } - return; - } + let conversationMenuEl = this.contentEl.createEl('div'); + conversationMenuEl = this.addConversationMenu( + conversationMenuEl, + conversationSessionEl, + conversationTitle, + conversationSessionTitleEl, + chatBodyEl, + incomingConversationId, + incomingConversationId == conversationId, + ); - let conversationMenuEl = this.contentEl.createEl('div'); - conversationMenuEl.classList.add("conversation-menu"); - - let editConversationTitleButtonEl = this.contentEl.createEl('button'); - editConversationTitleButtonEl.innerHTML = "Rename"; - editConversationTitleButtonEl.classList.add("edit-title-button"); - editConversationTitleButtonEl.classList.add("three-dot-menu-button-item"); - editConversationTitleButtonEl.addEventListener('click', (event) => { - event.stopPropagation(); - - let conversationMenuChildren = conversationMenuEl.children; - let totalItems = conversationMenuChildren.length; - - for (let i = totalItems - 1; i >= 0; i--) { - conversationMenuChildren[i].remove(); - } - - // Create a dialog box to get new title for conversation - let editConversationTitleInputBoxEl = this.contentEl.createEl('div'); - editConversationTitleInputBoxEl.classList.add("conversation-title-input-box"); - let editConversationTitleInputEl = this.contentEl.createEl('input'); - editConversationTitleInputEl.classList.add("conversation-title-input"); - editConversationTitleInputEl.value = conversationTitle; - editConversationTitleInputEl.addEventListener('click', function(event) { - event.stopPropagation(); - }); - editConversationTitleInputEl.addEventListener('keydown', function(event) { - if (event.key === "Enter") { - event.preventDefault(); - editConversationTitleSaveButtonEl.click(); - } - }); - let editConversationTitleSaveButtonEl = this.contentEl.createEl('button'); - editConversationTitleInputBoxEl.appendChild(editConversationTitleInputEl); - editConversationTitleSaveButtonEl.innerHTML = "Save"; - editConversationTitleSaveButtonEl.classList.add("three-dot-menu-button-item"); - editConversationTitleSaveButtonEl.addEventListener('click', (event) => { - event.stopPropagation(); - let newTitle = editConversationTitleInputEl.value; - if (newTitle != null) { - let editURL = `/api/chat/title?client=web&conversation_id=${incomingConversationId}&title=${newTitle}`; - fetch(`${this.setting.khojUrl}${editURL}`, { method: "PATCH", headers }) - .then(response => response.ok ? response.json() : Promise.reject(response)) - .then(data => { - conversationSessionEl.textContent = newTitle; - }) - .catch(err => { - return; - }); - editConversationTitleInputBoxEl.remove(); - } - }); - editConversationTitleInputBoxEl.appendChild(editConversationTitleSaveButtonEl); - conversationMenuEl.appendChild(editConversationTitleInputBoxEl); - }); - - conversationMenuEl.appendChild(editConversationTitleButtonEl); - threeDotMenuEl.appendChild(conversationMenuEl); - - let deleteConversationButtonEl = this.contentEl.createEl('button'); - deleteConversationButtonEl.innerHTML = "Delete"; - deleteConversationButtonEl.classList.add("delete-conversation-button"); - deleteConversationButtonEl.classList.add("three-dot-menu-button-item"); - deleteConversationButtonEl.addEventListener('click', () => { - // Ask for confirmation before deleting chat session - let confirmation = confirm('Are you sure you want to delete this chat session?'); - if (!confirmation) return; - let deleteURL = `/api/chat/history?client=obsidian&conversation_id=${incomingConversationId}`; - fetch(`${this.setting.khojUrl}${deleteURL}`, { method: "DELETE", headers }) - .then(response => response.ok ? response.json() : Promise.reject(response)) - .then(data => { - chatBodyEl.innerHTML = ""; - chatBodyEl.dataset.conversationId = ""; - chatBodyEl.dataset.conversationTitle = ""; - this.getChatHistory(chatBodyEl); - }) - .catch(err => { - return; - }); - }); - - conversationMenuEl.appendChild(deleteConversationButtonEl); - threeDotMenuEl.appendChild(conversationMenuEl); - }); - threeDotMenuEl.appendChild(threeDotMenuButton); - conversationSessionEl.appendChild(threeDotMenuEl); + conversationSessionEl.appendChild(conversationMenuEl); conversationListBodyEl.appendChild(conversationSessionEl); chatBodyEl.appendChild(sidePanelEl); } @@ -622,6 +533,123 @@ export class KhojChatView extends KhojPaneView { return true; } + addConversationMenu( + conversationMenuEl: HTMLDivElement, + conversationSessionEl: HTMLElement, + conversationTitle: string, + conversationSessionTitleEl: HTMLElement, + chatBodyEl: HTMLElement, + incomingConversationId: string, + selectedConversation: boolean, + ) { + conversationMenuEl.classList.add("conversation-menu"); + + const headers = { 'Authorization': `Bearer ${this.setting.khojApiKey}` }; + + let editConversationTitleButtonEl = this.contentEl.createEl('button'); + setIcon(editConversationTitleButtonEl, "edit"); + editConversationTitleButtonEl.title = "Rename"; + editConversationTitleButtonEl.classList.add("edit-title-button"); + editConversationTitleButtonEl.classList.add("three-dot-menu-button-item"); + if (selectedConversation) editConversationTitleButtonEl.classList.add("selected-conversation"); + editConversationTitleButtonEl.addEventListener('click', (event) => { + event.stopPropagation(); + + let conversationMenuChildren = conversationMenuEl.children; + let totalItems = conversationMenuChildren.length; + + for (let i = totalItems - 1; i >= 0; i--) { + conversationMenuChildren[i].remove(); + } + + // Create a dialog box to get new title for conversation + let editConversationTitleInputEl = this.contentEl.createEl('input'); + editConversationTitleInputEl.classList.add("conversation-title-input"); + editConversationTitleInputEl.value = conversationTitle; + editConversationTitleInputEl.addEventListener('click', function(event) { + event.stopPropagation(); + }); + editConversationTitleInputEl.addEventListener('keydown', function(event) { + if (event.key === "Enter") { + event.preventDefault(); + editConversationTitleSaveButtonEl.click(); + } + }); + let editConversationTitleSaveButtonEl = this.contentEl.createEl('button'); + conversationSessionTitleEl.replaceWith(editConversationTitleInputEl); + editConversationTitleSaveButtonEl.innerHTML = "Save"; + editConversationTitleSaveButtonEl.classList.add("three-dot-menu-button-item"); + if (selectedConversation) editConversationTitleSaveButtonEl.classList.add("selected-conversation"); + editConversationTitleSaveButtonEl.addEventListener('click', (event) => { + event.stopPropagation(); + let newTitle = editConversationTitleInputEl.value; + if (newTitle != null) { + let editURL = `/api/chat/title?client=web&conversation_id=${incomingConversationId}&title=${newTitle}`; + fetch(`${this.setting.khojUrl}${editURL}`, { method: "PATCH", headers }) + .then(response => response.ok ? response.json() : Promise.reject(response)) + .then(data => { + conversationSessionTitleEl.textContent = newTitle; + }) + .catch(err => { + return; + }); + const conversationSessionTitleEl = conversationSessionEl.createDiv("conversation-session-title"); + conversationSessionTitleEl.textContent = newTitle; + conversationSessionTitleEl.addEventListener('click', () => { + chatBodyEl.innerHTML = ""; + chatBodyEl.dataset.conversationId = incomingConversationId; + chatBodyEl.dataset.conversationTitle = conversationTitle; + this.getChatHistory(chatBodyEl); + }); + + let newConversationMenuEl = this.contentEl.createEl('div'); + newConversationMenuEl = this.addConversationMenu( + newConversationMenuEl, + conversationSessionEl, + newTitle, + conversationSessionTitleEl, + chatBodyEl, + incomingConversationId, + selectedConversation, + ); + + conversationMenuEl.replaceWith(newConversationMenuEl); + editConversationTitleInputEl.replaceWith(conversationSessionTitleEl); + } + }); + conversationMenuEl.appendChild(editConversationTitleSaveButtonEl); + }); + + conversationMenuEl.appendChild(editConversationTitleButtonEl); + + let deleteConversationButtonEl = this.contentEl.createEl('button'); + setIcon(deleteConversationButtonEl, "trash"); + deleteConversationButtonEl.title = "Delete"; + deleteConversationButtonEl.classList.add("delete-conversation-button"); + deleteConversationButtonEl.classList.add("three-dot-menu-button-item"); + if (selectedConversation) deleteConversationButtonEl.classList.add("selected-conversation"); + deleteConversationButtonEl.addEventListener('click', () => { + // Ask for confirmation before deleting chat session + let confirmation = confirm('Are you sure you want to delete this chat session?'); + if (!confirmation) return; + let deleteURL = `/api/chat/history?client=obsidian&conversation_id=${incomingConversationId}`; + fetch(`${this.setting.khojUrl}${deleteURL}`, { method: "DELETE", headers }) + .then(response => response.ok ? response.json() : Promise.reject(response)) + .then(data => { + chatBodyEl.innerHTML = ""; + chatBodyEl.dataset.conversationId = ""; + chatBodyEl.dataset.conversationTitle = ""; + this.toggleChatSessions(chatBodyEl, true); + }) + .catch(err => { + return; + }); + }); + + conversationMenuEl.appendChild(deleteConversationButtonEl); + return conversationMenuEl; + } + async getChatHistory(chatBodyEl: HTMLElement): Promise { // Get chat history from Khoj backend let chatUrl = `${this.setting.khojUrl}/api/chat/history?client=obsidian`; diff --git a/src/interface/obsidian/styles.css b/src/interface/obsidian/styles.css index be6f2805..f37e7608 100644 --- a/src/interface/obsidian/styles.css +++ b/src/interface/obsidian/styles.css @@ -240,10 +240,22 @@ 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; @@ -264,9 +276,11 @@ div.conversation-session { /* position: relative; */ } -button.three-dot-menu-button-item { +button.selected-conversation { background: var(--khoj-winter-sun); - color: var(--khoj-storm-grey); +} +button.three-dot-menu-button-item { + color: var(--color-base-90); border: none; box-shadow: none; font-size: 14px; @@ -307,11 +321,8 @@ div.conversation-menu { top: 100%; right: 0; text-align: right; - background-color: var(--khoj-winter-sun); - border: 1px solid var(--khoj-storm-grey); border-radius: 5px; padding: 5px; - box-shadow: 0 0 11px #aaa; } div.conversation-session:hover { transform: scale(1.03); From 7fb7f200b31e52493d2932cefce054598a47e93f Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Sat, 1 Jun 2024 10:51:22 +0530 Subject: [PATCH 21/22] Fix rendering text in chat messages with bulleted lists Improves #789 --- src/interface/obsidian/styles.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/interface/obsidian/styles.css b/src/interface/obsidian/styles.css index 86401a5e..fedecaa5 100644 --- a/src/interface/obsidian/styles.css +++ b/src/interface/obsidian/styles.css @@ -116,7 +116,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; From 7d7d4cf5c39683c59872d4789f7b3100b9e4ffd1 Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Sat, 1 Jun 2024 11:01:05 +0530 Subject: [PATCH 22/22] Make new chat message text selectable in Obsidian side pane Resolves #789 --- src/interface/obsidian/src/chat_view.ts | 2 +- src/interface/obsidian/styles.css | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/interface/obsidian/src/chat_view.ts b/src/interface/obsidian/src/chat_view.ts index 0d11330c..f56fd7fb 100644 --- a/src/interface/obsidian/src/chat_view.ts +++ b/src/interface/obsidian/src/chat_view.ts @@ -411,7 +411,7 @@ export class KhojChatView extends KhojPaneView { attr: { class: `khoj-chat-message-text khoj` }, - }) + }).createDiv(); // Scroll to bottom after inserting chat messages this.scrollChatToBottom(); diff --git a/src/interface/obsidian/styles.css b/src/interface/obsidian/styles.css index fedecaa5..31314271 100644 --- a/src/interface/obsidian/styles.css +++ b/src/interface/obsidian/styles.css @@ -76,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 {