Add Offline chat to Obsidian (#359)

* Add support for configuring/using offline chat from within Obsidian
* Fix type checking for search type
* If Github is not configured, /update call should fail
* Fix regenerate tests same as the update ones
* Update help text for offline chat in obsidian
* Update relevant description for Khoj settings in Obsidian
* Simplify configuration logic and use smarter defaults
This commit is contained in:
sabaimran 2023-07-29 01:47:56 +00:00 committed by GitHub
parent b3c1507708
commit 5ccb01343e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 116 additions and 59 deletions

View file

@ -38,9 +38,9 @@ export default class Khoj extends Plugin {
id: 'chat', id: 'chat',
name: 'Chat', name: 'Chat',
checkCallback: (checking) => { checkCallback: (checking) => {
if (!checking && this.settings.connectedToBackend && !!this.settings.openaiApiKey) if (!checking && this.settings.connectedToBackend && (!!this.settings.openaiApiKey || this.settings.enableOfflineChat))
new KhojChatModal(this.app, this.settings).open(); new KhojChatModal(this.app, this.settings).open();
return !!this.settings.openaiApiKey; return !!this.settings.openaiApiKey || this.settings.enableOfflineChat;
} }
}); });

View file

@ -2,6 +2,7 @@ import { App, Notice, PluginSettingTab, request, Setting } from 'obsidian';
import Khoj from 'src/main'; import Khoj from 'src/main';
export interface KhojSetting { export interface KhojSetting {
enableOfflineChat: boolean;
openaiApiKey: string; openaiApiKey: string;
resultsCount: number; resultsCount: number;
khojUrl: string; khojUrl: string;
@ -10,6 +11,7 @@ export interface KhojSetting {
} }
export const DEFAULT_SETTINGS: KhojSetting = { export const DEFAULT_SETTINGS: KhojSetting = {
enableOfflineChat: false,
resultsCount: 6, resultsCount: 6,
khojUrl: 'http://127.0.0.1:42110', khojUrl: 'http://127.0.0.1:42110',
connectedToBackend: false, connectedToBackend: false,
@ -35,7 +37,7 @@ export class KhojSettingTab extends PluginSettingTab {
// Add khoj settings configurable from the plugin settings tab // Add khoj settings configurable from the plugin settings tab
new Setting(containerEl) new Setting(containerEl)
.setName('Khoj URL') .setName('Khoj URL')
.setDesc('The URL of the Khoj backend') .setDesc('The URL of the Khoj backend.')
.addText(text => text .addText(text => text
.setValue(`${this.plugin.settings.khojUrl}`) .setValue(`${this.plugin.settings.khojUrl}`)
.onChange(async (value) => { .onChange(async (value) => {
@ -45,16 +47,25 @@ export class KhojSettingTab extends PluginSettingTab {
})); }));
new Setting(containerEl) new Setting(containerEl)
.setName('OpenAI API Key') .setName('OpenAI API Key')
.setDesc('Your OpenAI API Key for Khoj Chat') .setDesc('Use OpenAI for Khoj Chat with your API key.')
.addText(text => text .addText(text => text
.setValue(`${this.plugin.settings.openaiApiKey}`) .setValue(`${this.plugin.settings.openaiApiKey}`)
.onChange(async (value) => { .onChange(async (value) => {
this.plugin.settings.openaiApiKey = value.trim(); this.plugin.settings.openaiApiKey = value.trim();
await this.plugin.saveSettings(); await this.plugin.saveSettings();
})); }));
new Setting(containerEl)
.setName('Enable Offline Chat')
.setDesc('Chat privately without an internet connection. Enabling this will use offline chat even if OpenAI is configured.')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.enableOfflineChat)
.onChange(async (value) => {
this.plugin.settings.enableOfflineChat = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl) new Setting(containerEl)
.setName('Results Count') .setName('Results Count')
.setDesc('The number of results to show in search and use for chat') .setDesc('The number of results to show in search and use for chat.')
.addSlider(slider => slider .addSlider(slider => slider
.setLimits(1, 10, 1) .setLimits(1, 10, 1)
.setValue(this.plugin.settings.resultsCount) .setValue(this.plugin.settings.resultsCount)
@ -65,7 +76,7 @@ export class KhojSettingTab extends PluginSettingTab {
})); }));
new Setting(containerEl) new Setting(containerEl)
.setName('Auto Configure') .setName('Auto Configure')
.setDesc('Automatically configure the Khoj backend') .setDesc('Automatically configure the Khoj backend.')
.addToggle(toggle => toggle .addToggle(toggle => toggle
.setValue(this.plugin.settings.autoConfigure) .setValue(this.plugin.settings.autoConfigure)
.onChange(async (value) => { .onChange(async (value) => {
@ -75,7 +86,7 @@ export class KhojSettingTab extends PluginSettingTab {
let indexVaultSetting = new Setting(containerEl); let indexVaultSetting = new Setting(containerEl);
indexVaultSetting indexVaultSetting
.setName('Index Vault') .setName('Index Vault')
.setDesc('Manually force Khoj to re-index your Obsidian Vault') .setDesc('Manually force Khoj to re-index your Obsidian Vault.')
.addButton(button => button .addButton(button => button
.setButtonText('Update') .setButtonText('Update')
.setCta() .setCta()

View file

@ -9,6 +9,19 @@ export function getVaultAbsolutePath(vault: Vault): string {
return ''; return '';
} }
type OpenAIType = null | {
"chat-model": string;
"api-key": string;
};
interface ProcessorData {
conversation: {
"conversation-logfile": string;
openai: OpenAIType;
"enable-offline-chat": boolean;
};
}
export async function configureKhojBackend(vault: Vault, setting: KhojSetting, notify: boolean = true) { export async function configureKhojBackend(vault: Vault, setting: KhojSetting, notify: boolean = true) {
let vaultPath = getVaultAbsolutePath(vault); let vaultPath = getVaultAbsolutePath(vault);
let mdInVault = `${vaultPath}/**/*.md`; let mdInVault = `${vaultPath}/**/*.md`;
@ -132,48 +145,36 @@ export async function configureKhojBackend(vault: Vault, setting: KhojSetting, n
} }
} }
// If OpenAI API key not set in Khoj plugin settings let conversationLogFile = data?.["processor"]?.["conversation"]?.["conversation-logfile"] ?? `${khojDefaultChatDirectory}/conversation.json`;
if (!setting.openaiApiKey) {
// Disable khoj processors, as not required let processorData: ProcessorData = {
delete data["processor"]; "conversation": {
"conversation-logfile": conversationLogFile,
"openai": null,
"enable-offline-chat": setting.enableOfflineChat,
}
} }
// Else if khoj backend not configured yet
else if (!khoj_already_configured || !data["processor"]) { // If the Open AI API Key was configured in the plugin settings
data["processor"] = { if (!!setting.openaiApiKey) {
let openAIChatModel = data?.["processor"]?.["conversation"]?.["openai"]?.["chat-model"] ?? khojDefaultChatModelName;
processorData = {
"conversation": { "conversation": {
"conversation-logfile": `${khojDefaultChatDirectory}/conversation.json`, "conversation-logfile": conversationLogFile,
"openai": { "openai": {
"chat-model": khojDefaultChatModelName, "chat-model": openAIChatModel,
"api-key": setting.openaiApiKey, "api-key": setting.openaiApiKey,
} },
}, "enable-offline-chat": setting.enableOfflineChat,
}
}
// Else if khoj config has no conversation processor config
else if (!data["processor"]["conversation"] || !data["processor"]["conversation"]["openai"]) {
data["processor"] = {
"conversation": {
"conversation-logfile": `${khojDefaultChatDirectory}/conversation.json`,
"openai": {
"chat-model": khojDefaultChatModelName,
"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"],
"openai": {
"chat-model": data["processor"]["conversation"]["openai"]["chat-model"],
"api-key": setting.openaiApiKey,
}
}, },
} }
} }
// Set khoj processor config to conversation processor config
data["processor"] = processorData;
// Save updated config and refresh index on khoj backend // Save updated config and refresh index on khoj backend
updateKhojBackend(setting.khojUrl, data); updateKhojBackend(setting.khojUrl, data);
if (!khoj_already_configured) if (!khoj_already_configured)

View file

@ -169,7 +169,7 @@ def configure_content(
try: try:
# Initialize Org Notes Search # Initialize Org Notes Search
if (t == state.SearchType.Org or t == None) and content_config.org and search_models.text_search: if (t == None or t.value == state.SearchType.Org.value) and content_config.org and search_models.text_search:
logger.info("🦄 Setting up search for orgmode notes") logger.info("🦄 Setting up search for orgmode notes")
# Extract Entries, Generate Notes Embeddings # Extract Entries, Generate Notes Embeddings
content_index.org = text_search.setup( content_index.org = text_search.setup(
@ -181,7 +181,11 @@ def configure_content(
) )
# Initialize Markdown Search # Initialize Markdown Search
if (t == state.SearchType.Markdown or t == None) and content_config.markdown and search_models.text_search: if (
(t == None or t.value == state.SearchType.Markdown.value)
and content_config.markdown
and search_models.text_search
):
logger.info("💎 Setting up search for markdown notes") logger.info("💎 Setting up search for markdown notes")
# Extract Entries, Generate Markdown Embeddings # Extract Entries, Generate Markdown Embeddings
content_index.markdown = text_search.setup( content_index.markdown = text_search.setup(
@ -193,7 +197,7 @@ def configure_content(
) )
# Initialize PDF Search # Initialize PDF Search
if (t == state.SearchType.Pdf or t == None) and content_config.pdf and search_models.text_search: if (t == None or t.value == state.SearchType.Pdf.value) and content_config.pdf and search_models.text_search:
logger.info("🖨️ Setting up search for pdf") logger.info("🖨️ Setting up search for pdf")
# Extract Entries, Generate PDF Embeddings # Extract Entries, Generate PDF Embeddings
content_index.pdf = text_search.setup( content_index.pdf = text_search.setup(
@ -205,14 +209,22 @@ def configure_content(
) )
# Initialize Image Search # Initialize Image Search
if (t == state.SearchType.Image or t == None) and content_config.image and search_models.image_search: if (
(t == None or t.value == state.SearchType.Image.value)
and content_config.image
and search_models.image_search
):
logger.info("🌄 Setting up search for images") logger.info("🌄 Setting up search for images")
# Extract Entries, Generate Image Embeddings # Extract Entries, Generate Image Embeddings
content_index.image = image_search.setup( content_index.image = image_search.setup(
content_config.image, search_models.image_search.image_encoder, regenerate=regenerate content_config.image, search_models.image_search.image_encoder, regenerate=regenerate
) )
if (t == state.SearchType.Github or t == None) and content_config.github and search_models.text_search: if (
(t == None or t.value == state.SearchType.Github.value)
and content_config.github
and search_models.text_search
):
logger.info("🐙 Setting up search for github") logger.info("🐙 Setting up search for github")
# Extract Entries, Generate Github Embeddings # Extract Entries, Generate Github Embeddings
content_index.github = text_search.setup( content_index.github = text_search.setup(
@ -223,6 +235,21 @@ def configure_content(
filters=[DateFilter(), WordFilter(), FileFilter()], filters=[DateFilter(), WordFilter(), FileFilter()],
) )
# Initialize Notion Search
if (
(t == None or t.value in state.SearchType.Notion.value)
and content_config.notion
and search_models.text_search
):
logger.info("🔌 Setting up search for notion")
content_index.notion = text_search.setup(
NotionToJsonl,
content_config.notion,
search_models.text_search.bi_encoder,
regenerate=regenerate,
filters=[DateFilter(), WordFilter(), FileFilter()],
)
# Initialize External Plugin Search # Initialize External Plugin Search
if (t == None or t in state.SearchType) and content_config.plugins and search_models.text_search: if (t == None or t in state.SearchType) and content_config.plugins and search_models.text_search:
logger.info("🔌 Setting up search for plugins") logger.info("🔌 Setting up search for plugins")
@ -236,17 +263,6 @@ def configure_content(
filters=[DateFilter(), WordFilter(), FileFilter()], filters=[DateFilter(), WordFilter(), FileFilter()],
) )
# Initialize Notion Search
if (t == None or t in state.SearchType) and content_config.notion and search_models.text_search:
logger.info("🔌 Setting up search for notion")
content_index.notion = text_search.setup(
NotionToJsonl,
content_config.notion,
search_models.text_search.bi_encoder,
regenerate=regenerate,
filters=[DateFilter(), WordFilter(), FileFilter()],
)
except Exception as e: except Exception as e:
logger.error(f"🚨 Failed to setup search: {e}", exc_info=True) logger.error(f"🚨 Failed to setup search: {e}", exc_info=True)
raise e raise e

View file

@ -38,6 +38,9 @@ class GithubToJsonl(TextToJsonl):
return return
def process(self, previous_entries=[]): def process(self, previous_entries=[]):
if self.config.pat_token is None or self.config.pat_token == "":
logger.error(f"Github PAT token is not set. Skipping github content")
raise ValueError("Github PAT token is not set. Skipping github content")
current_entries = [] current_entries = []
for repo in self.config.repos: for repo in self.config.repos:
current_entries += self.process_repo(repo) current_entries += self.process_repo(repo)

View file

@ -53,13 +53,26 @@ def test_update_with_invalid_content_type(client):
# ---------------------------------------------------------------------------------------------------- # ----------------------------------------------------------------------------------------------------
def test_update_with_valid_content_type(client): def test_update_with_valid_content_type(client):
for content_type in ["all", "org", "markdown", "image", "pdf", "github", "notion", "plugin1"]: for content_type in ["all", "org", "markdown", "image", "pdf", "notion", "plugin1"]:
# Act # Act
response = client.get(f"/api/update?t={content_type}") response = client.get(f"/api/update?t={content_type}")
# Assert # Assert
assert response.status_code == 200, f"Returned status: {response.status_code} for content type: {content_type}" assert response.status_code == 200, f"Returned status: {response.status_code} for content type: {content_type}"
# ----------------------------------------------------------------------------------------------------
def test_update_with_github_fails_without_pat(client):
# Act
response = client.get(f"/api/update?t=github")
# Assert
assert response.status_code == 500, f"Returned status: {response.status_code} for content type: github"
assert (
response.json()["detail"]
== "🚨 Failed to update server via API: Github PAT token is not set. Skipping github content"
)
# ---------------------------------------------------------------------------------------------------- # ----------------------------------------------------------------------------------------------------
def test_regenerate_with_invalid_content_type(client): def test_regenerate_with_invalid_content_type(client):
# Act # Act
@ -71,13 +84,26 @@ def test_regenerate_with_invalid_content_type(client):
# ---------------------------------------------------------------------------------------------------- # ----------------------------------------------------------------------------------------------------
def test_regenerate_with_valid_content_type(client): def test_regenerate_with_valid_content_type(client):
for content_type in ["all", "org", "markdown", "image", "pdf", "github", "notion", "plugin1"]: for content_type in ["all", "org", "markdown", "image", "pdf", "notion", "plugin1"]:
# Act # Act
response = client.get(f"/api/update?force=true&t={content_type}") response = client.get(f"/api/update?force=true&t={content_type}")
# Assert # Assert
assert response.status_code == 200, f"Returned status: {response.status_code} for content type: {content_type}" assert response.status_code == 200, f"Returned status: {response.status_code} for content type: {content_type}"
# ----------------------------------------------------------------------------------------------------
def test_regenerate_with_github_fails_without_pat(client):
# Act
response = client.get(f"/api/update?force=true&t=github")
# Assert
assert response.status_code == 500, f"Returned status: {response.status_code} for content type: github"
assert (
response.json()["detail"]
== "🚨 Failed to update server via API: Github PAT token is not set. Skipping github content"
)
# ---------------------------------------------------------------------------------------------------- # ----------------------------------------------------------------------------------------------------
def test_get_configured_types_via_api(client): def test_get_configured_types_via_api(client):
# Act # Act