mirror of
https://github.com/khoj-ai/khoj.git
synced 2025-02-17 08:04:21 +00:00
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:
parent
b3c1507708
commit
5ccb01343e
6 changed files with 116 additions and 59 deletions
|
@ -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;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Add table
Reference in a new issue