diff --git a/src/interface/obsidian/README.md b/src/interface/obsidian/README.md index b3195bb0..8c6c1421 100644 --- a/src/interface/obsidian/README.md +++ b/src/interface/obsidian/README.md @@ -12,6 +12,7 @@ - [Setup Plugin](#2-Setup-Plugin) - [Use](#Use) - [Search](#search) + - [Chat](#chat) - [Find Similar Notes](#find-similar-notes) - [Upgrade](#Upgrade) - [Upgrade Backend](#1-Upgrade-Backend) @@ -21,9 +22,14 @@ - [Implementation](#Implementation) ## Features -- **Natural**: Advanced natural language understanding using Transformer based ML Models -- **Local**: Your personal data stays local. All search, indexing is done on your machine[\*](https://github.com/debanjum/khoj#miscellaneous) -- **Incremental**: Incremental search for a fast, search-as-you-type experience +- **Search** + - **Natural**: Advanced natural language understanding using Transformer based ML Models + - **Local**: Your personal data stays local. All search and indexing is done on your machine. *Unlike chat which requires access to GPT.* + - **Incremental**: Incremental search for a fast, search-as-you-type experience +- **Chat** + - **Faster answers**: Find answers faster and with less effort than search + - **Iterative discovery**: Iteratively explore and (re-)discover your notes + - **Assisted creativity**: Smoothly weave across answers retrieval and content generation ## Demo https://user-images.githubusercontent.com/6413477/210486007-36ee3407-e6aa-4185-8a26-b0bfc0a4344f.mp4 @@ -55,10 +61,21 @@ pip install khoj-assistant && khoj --no-gui ### 2. Setup Plugin 1. Open [Khoj](https://obsidian.md/plugins?id=khoj) from the *Community plugins* tab in Obsidian settings panel 2. Click *Install*, then *Enable* on the Khoj plugin page in Obsidian + 3. [Optional] To enable Khoj Chat, set your [OpenAI API key](https://platform.openai.com/account/api-keys) in the Khoj plugin settings See [official Obsidian plugin docs](https://help.obsidian.md/Extending+Obsidian/Community+plugins) for details ## Use +### Chat +Run *Khoj: Chat* from the [Command Palette](https://help.obsidian.md/Plugins/Command+palette) and ask questions in a natural, conversational style. +E.g "When did I file my taxes last year?" + +Notes: +- *Using Khoj Chat will result in query relevant notes being shared with OpenAI for ChatGPT to respond.* +- *To use Khoj Chat, ensure you've set your [OpenAI API key](https://platform.openai.com/account/api-keys) in the Khoj plugin settings.* + +See [[https://github.com/debanjum/khoj/tree/master/#Khoj-Chat][Khoj Chat]] for more details + ### Search Click the *Khoj search* icon 🔎 on the [Ribbon](https://help.obsidian.md/User+interface/Workspace/Ribbon) or run *Khoj: Search* from the [Command Palette](https://help.obsidian.md/Plugins/Command+palette) diff --git a/src/interface/obsidian/src/chat_modal.ts b/src/interface/obsidian/src/chat_modal.ts new file mode 100644 index 00000000..ffbdb80d --- /dev/null +++ b/src/interface/obsidian/src/chat_modal.ts @@ -0,0 +1,130 @@ +import { App, Modal, request, Setting } from 'obsidian'; +import { KhojSetting } from 'src/settings'; + + +export class KhojChatModal extends Modal { + result: string; + setting: KhojSetting; + + constructor(app: App, setting: KhojSetting) { + super(app); + this.setting = setting; + + // Register Modal Keybindings to send user message + this.scope.register([], 'Enter', async () => { + // Get text in chat input elmenet + let input_el = this.contentEl.getElementsByClassName("khoj-chat-input")[0]; + + // Clear text after extracting message to send + let user_message = input_el.value; + input_el.value = ""; + + // Get and render chat response to user message + await this.getChatResponse(user_message); + }); + } + + async onOpen() { + let { contentEl } = this; + contentEl.addClass("khoj-chat"); + + // Add title to the Khoj Chat modal + contentEl.createEl("h1", ({ attr: { id: "khoj-chat-title" }, text: "Khoj Chat" })); + + // Create area for chat logs + contentEl.createDiv({ attr: { id: "khoj-chat-body", class: "khoj-chat-body" } }); + + // Get conversation history from Khoj backend + let chatUrl = `${this.setting.khojUrl}/api/chat?`; + let response = await request(chatUrl); + let chatLogs = JSON.parse(response).response; + chatLogs.forEach((chatLog: any) => { + this.renderMessageWithReferences(chatLog.message, chatLog.by, chatLog.context, new Date(chatLog.created)); + }); + + // Add chat input field + contentEl.createEl("input", + { + attr: { + type: "text", + id: "khoj-chat-input", + autofocus: "autofocus", + placeholder: "Chat with Khoj 🦅 [Hit Enter to send message]", + class: "khoj-chat-input option" + } + }) + .addEventListener('change', (event) => { this.result = (event.target).value }); + + // Scroll to bottom of modal, till the send message input box + this.modalEl.scrollTop = this.modalEl.scrollHeight; + } + + generateReference(messageEl: any, reference: string, index: number) { + // Generate HTML for Chat Reference + // `${index}`; + let escaped_ref = reference.replace(/"/g, "\\\"") + return messageEl.createEl("sup").createEl("abbr", { + attr: { + title: escaped_ref, + tabindex: "0", + }, + text: `[${index}] `, + }); + } + + renderMessageWithReferences(message: string, sender: string, context?: [string], dt?: Date) { + let messageEl = this.renderMessage(message, sender, dt); + if (context && !!messageEl) { + context.map((reference, index) => this.generateReference(messageEl, reference, index+1)); + } + } + + renderMessage(message: string, sender: string, dt?: Date): Element | null { + 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 chat_body_el = this.contentEl.getElementsByClassName("khoj-chat-body")[0]; + let chat_message_el = chat_body_el.createDiv({ + attr: { + "data-meta": `${emojified_sender} at ${message_time}`, + class: `khoj-chat-message ${sender}` + }, + }).createDiv({ + attr: { + class: `khoj-chat-message-text ${sender}` + }, + text: `${message}` + }) + + // Scroll to bottom after inserting chat messages + this.modalEl.scrollTop = this.modalEl.scrollHeight; + + return chat_message_el + } + + formatDate(date: Date): string { + // Format date in HH:MM, DD MMM YYYY format + let time_string = date.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit', hour12: false }); + let date_string = date.toLocaleString('en-IN', { year: 'numeric', month: 'short', day: '2-digit' }).replace(/-/g, ' '); + return `${time_string}, ${date_string}`; + } + + async getChatResponse(query: string | undefined | null): Promise { + // Exit if query is empty + if (!query || query === "") return; + + // Render user query as chat message + this.renderMessage(query, "you"); + + // Get chat response from Khoj backend + let encodedQuery = encodeURIComponent(query); + let chatUrl = `${this.setting.khojUrl}/api/chat?q=${encodedQuery}`; + let response = await request(chatUrl); + let data = JSON.parse(response); + + // Render Khoj response as chat message + this.renderMessage(data.response, "khoj"); + } +} diff --git a/src/interface/obsidian/src/main.ts b/src/interface/obsidian/src/main.ts index b367037c..6d4af194 100644 --- a/src/interface/obsidian/src/main.ts +++ b/src/interface/obsidian/src/main.ts @@ -1,6 +1,7 @@ import { Notice, Plugin } from 'obsidian'; import { KhojSetting, KhojSettingTab, DEFAULT_SETTINGS } from 'src/settings' -import { KhojModal } from 'src/modal' +import { KhojSearchModal } from 'src/search_modal' +import { KhojChatModal } from 'src/chat_modal' import { configureKhojBackend } from './utils'; @@ -16,7 +17,7 @@ export default class Khoj extends Plugin { name: 'Search', checkCallback: (checking) => { if (!checking && this.settings.connectedToBackend) - new KhojModal(this.app, this.settings).open(); + new KhojSearchModal(this.app, this.settings).open(); return this.settings.connectedToBackend; } }); @@ -27,16 +28,27 @@ export default class Khoj extends Plugin { name: 'Find similar notes', editorCheckCallback: (checking) => { if (!checking && this.settings.connectedToBackend) - new KhojModal(this.app, this.settings, true).open(); + new KhojSearchModal(this.app, this.settings, true).open(); return this.settings.connectedToBackend; } }); + // Add chat command. It can be triggered from anywhere + this.addCommand({ + id: 'chat', + name: 'Chat', + checkCallback: (checking) => { + if (!checking && this.settings.connectedToBackend && !!this.settings.openaiApiKey) + new KhojChatModal(this.app, this.settings).open(); + return !!this.settings.openaiApiKey; + } + }); + // Create an icon in the left ribbon. this.addRibbonIcon('search', 'Khoj', (_: MouseEvent) => { // Called when the user clicks the icon. this.settings.connectedToBackend - ? new KhojModal(this.app, this.settings).open() + ? new KhojSearchModal(this.app, this.settings).open() : new Notice(`❗️Ensure Khoj backend is running and Khoj URL is pointing to it in the plugin settings`); }); @@ -59,5 +71,5 @@ export default class Khoj extends Plugin { await configureKhojBackend(this.app.vault, this.settings, false); } this.saveData(this.settings); - } + } } diff --git a/src/interface/obsidian/src/modal.ts b/src/interface/obsidian/src/search_modal.ts similarity index 98% rename from src/interface/obsidian/src/modal.ts rename to src/interface/obsidian/src/search_modal.ts index bf9ad7b7..db06caaa 100644 --- a/src/interface/obsidian/src/modal.ts +++ b/src/interface/obsidian/src/search_modal.ts @@ -6,7 +6,7 @@ export interface SearchResult { file: string; } -export class KhojModal extends SuggestModal { +export class KhojSearchModal extends SuggestModal { setting: KhojSetting; rerank: boolean = false; find_similar_notes: boolean; diff --git a/src/interface/obsidian/src/settings.ts b/src/interface/obsidian/src/settings.ts index d7f41ba7..2cdc79a5 100644 --- a/src/interface/obsidian/src/settings.ts +++ b/src/interface/obsidian/src/settings.ts @@ -2,6 +2,7 @@ import { App, Notice, PluginSettingTab, request, Setting } from 'obsidian'; import Khoj from 'src/main'; export interface KhojSetting { + openaiApiKey: string; resultsCount: number; khojUrl: string; connectedToBackend: boolean; @@ -13,6 +14,7 @@ export const DEFAULT_SETTINGS: KhojSetting = { khojUrl: 'http://localhost:8000', connectedToBackend: false, autoConfigure: true, + openaiApiKey: '', } export class KhojSettingTab extends PluginSettingTab { @@ -41,7 +43,16 @@ export class KhojSettingTab extends PluginSettingTab { await this.plugin.saveSettings(); containerEl.firstElementChild?.setText(this.getBackendStatusMessage()); })); - new Setting(containerEl) + new Setting(containerEl) + .setName('OpenAI API Key') + .setDesc('Your OpenAI API Key for Khoj Chat') + .addText(text => text + .setValue(`${this.plugin.settings.openaiApiKey}`) + .onChange(async (value) => { + this.plugin.settings.openaiApiKey = value.trim(); + await this.plugin.saveSettings(); + })); + new Setting(containerEl) .setName('Results Count') .setDesc('The number of search results to show') .addSlider(slider => slider @@ -110,7 +121,7 @@ export class KhojSettingTab extends PluginSettingTab { getBackendStatusMessage() { return !this.plugin.settings.connectedToBackend - ? '❗Disconnected from Khoj backend. Ensure Khoj backend is running and Khoj URL is correctly set below.' - : '✅ Connected to Khoj backend.'; + ? '❗Disconnected from Khoj backend. Ensure Khoj backend is running and Khoj URL is correctly set below.' + : '✅ Connected to Khoj backend.'; } } diff --git a/src/interface/obsidian/src/utils.ts b/src/interface/obsidian/src/utils.ts index 04e1919c..ba539179 100644 --- a/src/interface/obsidian/src/utils.ts +++ b/src/interface/obsidian/src/utils.ts @@ -29,10 +29,11 @@ export async function configureKhojBackend(vault: Vault, setting: KhojSetting, n // Set index name from the path of the current vault let indexName = getVaultAbsolutePath(vault).replace(/\//g, '_').replace(/ /g, '_'); - // Get default index directory from khoj backend - let khojDefaultIndexDirectory = await request(`${khojConfigUrl}/default`) - .then(response => JSON.parse(response)) - .then(data => { return getIndexDirectoryFromBackendConfig(data); }); + // Get default config fields from khoj backend + let defaultConfig = await request(`${khojConfigUrl}/default`).then(response => JSON.parse(response)); + let khojDefaultIndexDirectory = getIndexDirectoryFromBackendConfig(defaultConfig["content-type"]["markdown"]["embeddings-file"]); + let khojDefaultChatDirectory = getIndexDirectoryFromBackendConfig(defaultConfig["processor"]["conversation"]["conversation-logfile"]); + let khojDefaultChatModelName = defaultConfig["processor"]["conversation"]["model"]; // Get current config if khoj backend configured, else get default config from khoj backend await request(khoj_already_configured ? khojConfigUrl : `${khojConfigUrl}/default`) @@ -49,14 +50,7 @@ export async function configureKhojBackend(vault: Vault, setting: KhojSetting, n "compressed-jsonl": `${khojDefaultIndexDirectory}/${indexName}.jsonl.gz`, } } - // Disable khoj processors, as not required - delete data["processor"]; - - // Save new config and refresh index on khoj backend - updateKhojBackend(setting.khojUrl, data); - console.log(`Khoj: Created khoj backend config:\n${JSON.stringify(data)}`) } - // Else if khoj config has no markdown content config else if (!data["content-type"]["markdown"]) { // Add markdown config to khoj content-type config @@ -67,28 +61,59 @@ export async function configureKhojBackend(vault: Vault, setting: KhojSetting, n "embeddings-file": `${khojDefaultIndexDirectory}/${indexName}.pt`, "compressed-jsonl": `${khojDefaultIndexDirectory}/${indexName}.jsonl.gz`, } - - // Save updated config and refresh index on khoj backend - updateKhojBackend(setting.khojUrl, data); - console.log(`Khoj: Added markdown config to khoj backend config:\n${JSON.stringify(data["content-type"])}`) } - // Else if khoj is not configured to index markdown files in configured obsidian vault else if (data["content-type"]["markdown"]["input-filter"].length != 1 || data["content-type"]["markdown"]["input-filter"][0] !== mdInVault) { // Update markdown config in khoj content-type config // Set markdown config to only index markdown files in configured obsidian vault - let khojIndexDirectory = getIndexDirectoryFromBackendConfig(data); + let khojIndexDirectory = getIndexDirectoryFromBackendConfig(data["content-type"]["markdown"]["embeddings-file"]); data["content-type"]["markdown"] = { "input-filter": [mdInVault], "input-files": null, "embeddings-file": `${khojIndexDirectory}/${indexName}.pt`, "compressed-jsonl": `${khojIndexDirectory}/${indexName}.jsonl.gz`, } - // Save updated config and refresh index on khoj backend - updateKhojBackend(setting.khojUrl, data); - console.log(`Khoj: Updated markdown config in khoj backend config:\n${JSON.stringify(data["content-type"]["markdown"])}`) } + + // If OpenAI API key not set in Khoj plugin settings + if (!setting.openaiApiKey) { + // Disable khoj processors, as not required + delete data["processor"]; + } + // Else if khoj backend not configured yet + else if (!khoj_already_configured || !data["processor"]) { + data["processor"] = { + "conversation": { + "conversation-logfile": `${khojDefaultChatDirectory}/conversation.json`, + "model": khojDefaultChatModelName, + "openai-api-key": setting.openaiApiKey, + } + } + } + // Else if khoj config has no conversation processor config + else if (!data["processor"]["conversation"]) { + data["processor"]["conversation"] = { + "conversation-logfile": `${khojDefaultChatDirectory}/conversation.json`, + "model": khojDefaultChatModelName, + "openai-api-key": setting.openaiApiKey, + } + } + // Else if khoj is not configured with OpenAI API key from khoj plugin settings + else if (data["processor"]["conversation"]["openai-api-key"] !== setting.openaiApiKey) { + data["processor"]["conversation"] = { + "conversation-logfile": data["processor"]["conversation"]["conversation-logfile"], + "model": data["procesor"]["conversation"]["model"], + "openai-api-key": setting.openaiApiKey, + } + } + + // Save updated config and refresh index on khoj backend + updateKhojBackend(setting.khojUrl, data); + if (!khoj_already_configured) + console.log(`Khoj: Created khoj backend config:\n${JSON.stringify(data)}`) + else + console.log(`Khoj: Updated khoj backend config:\n${JSON.stringify(data)}`) }) .catch(error => { if (notify) @@ -111,6 +136,6 @@ export async function updateKhojBackend(khojUrl: string, khojConfig: Object) { .then(_ => request(`${khojUrl}/api/update?t=markdown`)); } -function getIndexDirectoryFromBackendConfig(khojConfig: any) { - return khojConfig["content-type"]["markdown"]["embeddings-file"].split("/").slice(0, -1).join("/"); +function getIndexDirectoryFromBackendConfig(filepath: string) { + return filepath.split("/").slice(0, -1).join("/"); } diff --git a/src/interface/obsidian/styles.css b/src/interface/obsidian/styles.css index 71cc60fd..cb4e002f 100644 --- a/src/interface/obsidian/styles.css +++ b/src/interface/obsidian/styles.css @@ -6,3 +6,142 @@ available in the app when your plugin is enabled. If your plugin does not need CSS, delete this file. */ + +:root { + --khoj-chat-blue: #017eff; + --khoj-chat-dark-grey: #475569; +} + +.khoj-chat { + display: grid; + background: var(--background-primary); + color: var(--text-normal); + text-align: center; + font-family: roboto, karma, segoe ui, sans-serif; + font-size: var(--font-ui-large); + font-weight: 300; + line-height: 1.5em; +} +.khoj-chat > * { + padding: 10px; + margin: 10px; +} + +#khoj-chat-title { + font-weight: 200; + color: var(--khoj-chat-blue); +} + +#khoj-chat-body { + font-size: var(--font-ui-medium); + margin: 0px; + line-height: 20px; + overflow-y: scroll; /* Make chat body scroll to see history */ +} +/* add chat metatdata to bottom of bubble */ +.khoj-chat-message::after { + content: attr(data-meta); + display: block; + font-size: var(--font-ui-smaller); + color: var(--text-muted); + margin: -12px 7px 0 -5px; +} +/* move message by khoj to left */ +.khoj-chat-message.khoj { + margin-left: auto; + text-align: left; +} +/* move message by you to right */ +.khoj-chat-message.you { + margin-right: auto; + text-align: right; +} +/* basic style chat message text */ +.khoj-chat-message-text { + margin: 10px; + border-radius: 10px; + padding: 10px; + position: relative; + display: inline-block; + max-width: 80%; + text-align: left; +} +/* color chat bubble by khoj blue */ +.khoj-chat-message-text.khoj { + color: var(--text-on-accent); + background: var(--khoj-chat-blue); + margin-left: auto; + white-space: pre-line; +} +/* add left protrusion to khoj chat bubble */ +.khoj-chat-message-text.khoj:after { + content: ''; + position: absolute; + bottom: -2px; + left: -7px; + border: 10px solid transparent; + border-top-color: var(--khoj-chat-blue); + border-bottom: 0; + transform: rotate(-60deg); +} +/* color chat bubble by you dark grey */ +.khoj-chat-message-text.you { + color: var(--text-on-accent); + background: var(--khoj-chat-dark-grey); + margin-right: auto; +} +/* add right protrusion to you chat bubble */ +.khoj-chat-message-text.you:after { + content: ''; + position: absolute; + top: 91%; + right: -2px; + border: 10px solid transparent; + border-left-color: var(--khoj-chat-dark-grey); + border-right: 0; + margin-top: -10px; + transform: rotate(-60deg) +} + +#khoj-chat-footer { + padding: 0; + display: grid; + grid-template-columns: minmax(70px, 100%); + grid-column-gap: 10px; + grid-row-gap: 10px; +} +#khoj-chat-footer > * { + padding: 15px; + background: #f9fafc +} +#khoj-chat-input.option:hover { + box-shadow: 0 0 11px var(--background-modifier-box-shadow); +} +#khoj-chat-input { + font-size: var(--font-ui-medium); + padding: 25px 20px; +} + +@media (pointer: coarse), (hover: none) { + #khoj-chat-body.abbr[title] { + position: relative; + padding-left: 4px; /* space references out to ease tapping */ + } + #khoj-chat-body.abbr[title]:focus:after { + content: attr(title); + + /* position tooltip */ + position: absolute; + left: 16px; /* open tooltip to right of ref link, instead of on top of it */ + width: auto; + z-index: 1; /* show tooltip above chat messages */ + + /* style tooltip */ + background-color: var(--background-secondary); + color: var(--text-muted); + border-radius: 2px; + box-shadow: 1px 1px 4px 0 var(--background-modifier-box-shadow); + font-size: var(--font-ui-small); + padding: 2px 4px; + } +}