From fe6720fa06ab5a8271c1ad40ef70c9245c9b2282 Mon Sep 17 00:00:00 2001 From: sabaimran <65192171+sabaimran@users.noreply.github.com> Date: Thu, 2 Nov 2023 10:43:27 -0700 Subject: [PATCH] [Multi-User Part 8]: Make conversation processor settings server-wide (#529) - Rather than having each individual user configure their conversation settings, allow the server admin to configure the OpenAI API key or offline model once, and let all the users re-use that code. - To configure the settings, the admin should go to the `django/admin` page and configure the relevant chat settings. To create an admin, run `python3 src/manage.py createsuperuser` and enter in the details. For simplicity, the email and username should match. - Remove deprecated/unnecessary endpoints and views for configuring per-user chat settings --- src/database/adapters/__init__.py | 114 ++++----- src/database/admin.py | 11 +- ...more.py => 0004_content_types_and_more.py} | 13 - .../migrations/0005_embeddings_corpus_id.py | 2 +- .../migrations/0007_add_conversation.py | 27 +++ ...onprocessorconfig_conversation_and_more.py | 81 ------- ...008_alter_conversation_conversation_log.py | 2 +- .../0010_chatmodeloptions_and_more.py | 83 +++++++ .../migrations/0011_merge_20231102_0138.py | 12 + src/database/models/__init__.py | 20 +- src/khoj/configure.py | 3 +- src/khoj/interface/web/base_config.html | 17 ++ src/khoj/interface/web/config.html | 224 ++++-------------- src/khoj/migrations/migrate_server_pg.py | 117 +++++++++ src/khoj/processor/conversation/prompts.py | 2 +- src/khoj/routers/api.py | 104 ++------ src/khoj/routers/helpers.py | 39 +-- src/khoj/routers/web_client.py | 61 +---- src/khoj/utils/cli.py | 2 + tests/conftest.py | 12 +- tests/helpers.py | 21 +- 21 files changed, 458 insertions(+), 509 deletions(-) rename src/database/migrations/{0004_conversationprocessorconfig_githubconfig_and_more.py => 0004_content_types_and_more.py} (93%) create mode 100644 src/database/migrations/0007_add_conversation.py delete mode 100644 src/database/migrations/0007_remove_conversationprocessorconfig_conversation_and_more.py create mode 100644 src/database/migrations/0010_chatmodeloptions_and_more.py create mode 100644 src/database/migrations/0011_merge_20231102_0138.py create mode 100644 src/khoj/migrations/migrate_server_pg.py diff --git a/src/database/adapters/__init__.py b/src/database/adapters/__init__.py index 080e73d7..909a78e5 100644 --- a/src/database/adapters/__init__.py +++ b/src/database/adapters/__init__.py @@ -30,7 +30,8 @@ from database.models import ( Entry, GithubRepoConfig, Conversation, - ConversationProcessorConfig, + ChatModelOptions, + UserConversationConfig, OpenAIProcessorConversationConfig, OfflineChatProcessorConversationConfig, ) @@ -184,27 +185,42 @@ class ConversationAdapters: @staticmethod def has_any_conversation_config(user: KhojUser): - return ConversationProcessorConfig.objects.filter(user=user).exists() + return ChatModelOptions.objects.filter(user=user).exists() @staticmethod - def get_openai_conversation_config(user: KhojUser): - return OpenAIProcessorConversationConfig.objects.filter(user=user).first() + def get_openai_conversation_config(): + return OpenAIProcessorConversationConfig.objects.filter().first() @staticmethod - def get_offline_chat_conversation_config(user: KhojUser): - return OfflineChatProcessorConversationConfig.objects.filter(user=user).first() + def get_offline_chat_conversation_config(): + return OfflineChatProcessorConversationConfig.objects.filter().first() @staticmethod - def has_valid_offline_conversation_config(user: KhojUser): - return OfflineChatProcessorConversationConfig.objects.filter(user=user, enable_offline_chat=True).exists() + def has_valid_offline_conversation_config(): + return OfflineChatProcessorConversationConfig.objects.filter(enabled=True).exists() @staticmethod - def has_valid_openai_conversation_config(user: KhojUser): - return OpenAIProcessorConversationConfig.objects.filter(user=user).exists() + def has_valid_openai_conversation_config(): + return OpenAIProcessorConversationConfig.objects.filter().exists() + + @staticmethod + async def aset_user_conversation_processor(user: KhojUser, conversation_processor_config_id: int): + config = await ChatModelOptions.objects.filter(id=conversation_processor_config_id).afirst() + if not config: + return None + new_config = await UserConversationConfig.objects.aupdate_or_create(user=user, defaults={"setting": config}) + return new_config @staticmethod def get_conversation_config(user: KhojUser): - return ConversationProcessorConfig.objects.filter(user=user).first() + config = UserConversationConfig.objects.filter(user=user).first() + if not config: + return None + return config.setting + + @staticmethod + def get_default_conversation_config(): + return ChatModelOptions.objects.filter().first() @staticmethod def save_conversation(user: KhojUser, conversation_log: dict): @@ -215,75 +231,45 @@ class ConversationAdapters: Conversation.objects.create(user=user, conversation_log=conversation_log) @staticmethod - def set_conversation_processor_config(user: KhojUser, new_config: UserConversationProcessorConfig): - conversation_config, _ = ConversationProcessorConfig.objects.get_or_create(user=user) - conversation_config.max_prompt_size = new_config.max_prompt_size - conversation_config.tokenizer = new_config.tokenizer - conversation_config.save() - - if new_config.openai: - default_values = { - "api_key": new_config.openai.api_key, - } - if new_config.openai.chat_model: - default_values["chat_model"] = new_config.openai.chat_model - - OpenAIProcessorConversationConfig.objects.update_or_create(user=user, defaults=default_values) - - if new_config.offline_chat: - default_values = { - "enable_offline_chat": str(new_config.offline_chat.enable_offline_chat), - } - - if new_config.offline_chat.chat_model: - default_values["chat_model"] = new_config.offline_chat.chat_model - - OfflineChatProcessorConversationConfig.objects.update_or_create(user=user, defaults=default_values) + def get_conversation_processor_options(): + return ChatModelOptions.objects.all() @staticmethod - def get_enabled_conversation_settings(user: KhojUser): - openai_config = ConversationAdapters.get_openai_conversation_config(user) - - return { - "openai": True if openai_config is not None else False, - "offline_chat": ConversationAdapters.has_offline_chat(user), - } + def set_conversation_processor_config(user: KhojUser, new_config: ChatModelOptions): + user_conversation_config, _ = UserConversationConfig.objects.get_or_create(user=user) + user_conversation_config.setting = new_config + user_conversation_config.save() @staticmethod - def clear_conversation_config(user: KhojUser): - ConversationProcessorConfig.objects.filter(user=user).delete() - ConversationAdapters.clear_openai_conversation_config(user) - ConversationAdapters.clear_offline_chat_conversation_config(user) + def has_offline_chat(): + return OfflineChatProcessorConversationConfig.objects.filter(enabled=True).exists() @staticmethod - def clear_openai_conversation_config(user: KhojUser): - OpenAIProcessorConversationConfig.objects.filter(user=user).delete() + async def ahas_offline_chat(): + return await OfflineChatProcessorConversationConfig.objects.filter(enabled=True).aexists() @staticmethod - def clear_offline_chat_conversation_config(user: KhojUser): - OfflineChatProcessorConversationConfig.objects.filter(user=user).delete() + async def get_offline_chat(): + return await ChatModelOptions.objects.filter(model_type="offline").afirst() @staticmethod - def has_offline_chat(user: KhojUser): - return OfflineChatProcessorConversationConfig.objects.filter(user=user, enable_offline_chat=True).exists() + async def aget_user_conversation_config(user: KhojUser): + config = await UserConversationConfig.objects.filter(user=user).prefetch_related("setting").afirst() + if not config: + return None + return config.setting @staticmethod - async def ahas_offline_chat(user: KhojUser): - return await OfflineChatProcessorConversationConfig.objects.filter( - user=user, enable_offline_chat=True - ).aexists() + async def has_openai_chat(): + return await OpenAIProcessorConversationConfig.objects.filter().aexists() @staticmethod - async def get_offline_chat(user: KhojUser): - return await OfflineChatProcessorConversationConfig.objects.filter(user=user).afirst() + async def get_openai_chat(): + return await OpenAIProcessorConversationConfig.objects.filter().afirst() @staticmethod - async def has_openai_chat(user: KhojUser): - return await OpenAIProcessorConversationConfig.objects.filter(user=user).aexists() - - @staticmethod - async def get_openai_chat(user: KhojUser): - return await OpenAIProcessorConversationConfig.objects.filter(user=user).afirst() + async def aget_default_conversation_config(): + return await ChatModelOptions.objects.filter().afirst() class EntryAdapters: diff --git a/src/database/admin.py b/src/database/admin.py index d09b0ea6..5f41f54a 100644 --- a/src/database/admin.py +++ b/src/database/admin.py @@ -3,6 +3,15 @@ from django.contrib.auth.admin import UserAdmin # Register your models here. -from database.models import KhojUser +from database.models import ( + KhojUser, + ChatModelOptions, + OpenAIProcessorConversationConfig, + OfflineChatProcessorConversationConfig, +) admin.site.register(KhojUser, UserAdmin) + +admin.site.register(ChatModelOptions) +admin.site.register(OpenAIProcessorConversationConfig) +admin.site.register(OfflineChatProcessorConversationConfig) diff --git a/src/database/migrations/0004_conversationprocessorconfig_githubconfig_and_more.py b/src/database/migrations/0004_content_types_and_more.py similarity index 93% rename from src/database/migrations/0004_conversationprocessorconfig_githubconfig_and_more.py rename to src/database/migrations/0004_content_types_and_more.py index 294fc620..ec704e1f 100644 --- a/src/database/migrations/0004_conversationprocessorconfig_githubconfig_and_more.py +++ b/src/database/migrations/0004_content_types_and_more.py @@ -13,19 +13,6 @@ class Migration(migrations.Migration): ] operations = [ - migrations.CreateModel( - name="ConversationProcessorConfig", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ("conversation", models.JSONField()), - ("enable_offline_chat", models.BooleanField(default=False)), - ], - options={ - "abstract": False, - }, - ), migrations.CreateModel( name="GithubConfig", fields=[ diff --git a/src/database/migrations/0005_embeddings_corpus_id.py b/src/database/migrations/0005_embeddings_corpus_id.py index 47f5aa8c..984953d6 100644 --- a/src/database/migrations/0005_embeddings_corpus_id.py +++ b/src/database/migrations/0005_embeddings_corpus_id.py @@ -6,7 +6,7 @@ import uuid class Migration(migrations.Migration): dependencies = [ - ("database", "0004_conversationprocessorconfig_githubconfig_and_more"), + ("database", "0004_content_types_and_more"), ] operations = [ diff --git a/src/database/migrations/0007_add_conversation.py b/src/database/migrations/0007_add_conversation.py new file mode 100644 index 00000000..167b6cab --- /dev/null +++ b/src/database/migrations/0007_add_conversation.py @@ -0,0 +1,27 @@ +# Generated by Django 4.2.5 on 2023-10-18 05:31 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("database", "0006_embeddingsdates"), + ] + + operations = [ + migrations.CreateModel( + name="Conversation", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("conversation_log", models.JSONField()), + ("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/src/database/migrations/0007_remove_conversationprocessorconfig_conversation_and_more.py b/src/database/migrations/0007_remove_conversationprocessorconfig_conversation_and_more.py deleted file mode 100644 index d66b2bd0..00000000 --- a/src/database/migrations/0007_remove_conversationprocessorconfig_conversation_and_more.py +++ /dev/null @@ -1,81 +0,0 @@ -# Generated by Django 4.2.5 on 2023-10-18 05:31 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - dependencies = [ - ("database", "0006_embeddingsdates"), - ] - - operations = [ - migrations.RemoveField( - model_name="conversationprocessorconfig", - name="conversation", - ), - migrations.RemoveField( - model_name="conversationprocessorconfig", - name="enable_offline_chat", - ), - migrations.AddField( - model_name="conversationprocessorconfig", - name="max_prompt_size", - field=models.IntegerField(blank=True, default=None, null=True), - ), - migrations.AddField( - model_name="conversationprocessorconfig", - name="tokenizer", - field=models.CharField(blank=True, default=None, max_length=200, null=True), - ), - migrations.AddField( - model_name="conversationprocessorconfig", - name="user", - field=models.ForeignKey( - default=1, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL - ), - preserve_default=False, - ), - migrations.CreateModel( - name="OpenAIProcessorConversationConfig", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ("api_key", models.CharField(max_length=200)), - ("chat_model", models.CharField(max_length=200)), - ("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ], - options={ - "abstract": False, - }, - ), - migrations.CreateModel( - name="OfflineChatProcessorConversationConfig", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ("enable_offline_chat", models.BooleanField(default=False)), - ("chat_model", models.CharField(default="llama-2-7b-chat.ggmlv3.q4_0.bin", max_length=200)), - ("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ], - options={ - "abstract": False, - }, - ), - migrations.CreateModel( - name="Conversation", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ("conversation_log", models.JSONField()), - ("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ], - options={ - "abstract": False, - }, - ), - ] diff --git a/src/database/migrations/0008_alter_conversation_conversation_log.py b/src/database/migrations/0008_alter_conversation_conversation_log.py index 8c60489f..00f37385 100644 --- a/src/database/migrations/0008_alter_conversation_conversation_log.py +++ b/src/database/migrations/0008_alter_conversation_conversation_log.py @@ -5,7 +5,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ("database", "0007_remove_conversationprocessorconfig_conversation_and_more"), + ("database", "0007_add_conversation"), ] operations = [ diff --git a/src/database/migrations/0010_chatmodeloptions_and_more.py b/src/database/migrations/0010_chatmodeloptions_and_more.py new file mode 100644 index 00000000..9f3a491a --- /dev/null +++ b/src/database/migrations/0010_chatmodeloptions_and_more.py @@ -0,0 +1,83 @@ +# Generated by Django 4.2.4 on 2023-11-01 17:41 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("database", "0009_khojapiuser"), + ] + + operations = [ + migrations.CreateModel( + name="ChatModelOptions", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("max_prompt_size", models.IntegerField(blank=True, default=None, null=True)), + ("tokenizer", models.CharField(blank=True, default=None, max_length=200, null=True)), + ("chat_model", models.CharField(blank=True, default=None, max_length=200, null=True)), + ( + "model_type", + models.CharField( + choices=[("openai", "Openai"), ("offline", "Offline")], default="openai", max_length=200 + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="OfflineChatProcessorConversationConfig", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("enabled", models.BooleanField(default=False)), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="OpenAIProcessorConversationConfig", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("api_key", models.CharField(max_length=200)), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="UserConversationConfig", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "setting", + models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="database.chatmodeloptions", + ), + ), + ( + "user", + models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/src/database/migrations/0011_merge_20231102_0138.py b/src/database/migrations/0011_merge_20231102_0138.py new file mode 100644 index 00000000..112c76a2 --- /dev/null +++ b/src/database/migrations/0011_merge_20231102_0138.py @@ -0,0 +1,12 @@ +# Generated by Django 4.2.5 on 2023-11-02 01:38 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("database", "0010_chatmodeloptions_and_more"), + ("database", "0010_rename_embeddings_entry_and_more"), + ] + + operations = [] diff --git a/src/database/models/__init__.py b/src/database/models/__init__.py index fe020601..5dd9622b 100644 --- a/src/database/models/__init__.py +++ b/src/database/models/__init__.py @@ -93,20 +93,26 @@ class LocalPlaintextConfig(BaseModel): class OpenAIProcessorConversationConfig(BaseModel): api_key = models.CharField(max_length=200) - chat_model = models.CharField(max_length=200) - user = models.ForeignKey(KhojUser, on_delete=models.CASCADE) class OfflineChatProcessorConversationConfig(BaseModel): - enable_offline_chat = models.BooleanField(default=False) - chat_model = models.CharField(max_length=200, default="llama-2-7b-chat.ggmlv3.q4_0.bin") - user = models.ForeignKey(KhojUser, on_delete=models.CASCADE) + enabled = models.BooleanField(default=False) -class ConversationProcessorConfig(BaseModel): +class ChatModelOptions(BaseModel): + class ModelType(models.TextChoices): + OPENAI = "openai" + OFFLINE = "offline" + max_prompt_size = models.IntegerField(default=None, null=True, blank=True) tokenizer = models.CharField(max_length=200, default=None, null=True, blank=True) - user = models.ForeignKey(KhojUser, on_delete=models.CASCADE) + chat_model = models.CharField(max_length=200, default=None, null=True, blank=True) + model_type = models.CharField(max_length=200, choices=ModelType.choices, default=ModelType.OPENAI) + + +class UserConversationConfig(BaseModel): + user = models.OneToOneField(KhojUser, on_delete=models.CASCADE) + setting = models.ForeignKey(ChatModelOptions, on_delete=models.CASCADE, default=None, null=True, blank=True) class Conversation(BaseModel): diff --git a/src/khoj/configure.py b/src/khoj/configure.py index 5dec86e7..4ed9093a 100644 --- a/src/khoj/configure.py +++ b/src/khoj/configure.py @@ -12,6 +12,7 @@ import os import schedule from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.authentication import AuthenticationMiddleware +from starlette.requests import HTTPConnection from starlette.authentication import ( AuthCredentials, @@ -60,7 +61,7 @@ class UserAuthenticationBackend(AuthenticationBackend): password="default", ) - async def authenticate(self, request: Request): + async def authenticate(self, request: HTTPConnection): current_user = request.session.get("user") if current_user and current_user.get("email"): user = await self.khojuser_manager.filter(email=current_user.get("email")).afirst() diff --git a/src/khoj/interface/web/base_config.html b/src/khoj/interface/web/base_config.html index 3e9a604c..3ca8c7ec 100644 --- a/src/khoj/interface/web/base_config.html +++ b/src/khoj/interface/web/base_config.html @@ -234,6 +234,19 @@ height: 32px; } + select#chat-models { + margin-bottom: 0; + } + + + div.api-settings { + width: 640px; + } + + img.api-key-action:hover { + cursor: pointer; + } + @media screen and (max-width: 700px) { .section-cards { grid-template-columns: 1fr; @@ -268,6 +281,10 @@ div.khoj-header-wrapper { grid-template-columns: auto; } + + div.api-settings { + width: auto; + } } diff --git a/src/khoj/interface/web/config.html b/src/khoj/interface/web/config.html index 3a86549c..851a18d0 100644 --- a/src/khoj/interface/web/config.html +++ b/src/khoj/interface/web/config.html @@ -10,9 +10,7 @@ Github

Github - {% if current_model_state.github == False %} - Not Configured - {% else %} + {% if current_model_state.github == True %} Configured {% endif %}

@@ -43,9 +41,7 @@ Notion

Notion - {% if current_model_state.notion == False %} - Not Configured - {% else %} + {% if current_model_state.notion == True %} Configured {% endif %}

@@ -76,12 +72,8 @@ markdown

Markdown - {% if current_model_state.markdown %} - {% if current_model_state.markdown == False%} - Not Configured - {% else %} - Configured - {% endif %} + {% if current_model_state.markdown == True%} + Configured {% endif %}

@@ -111,12 +103,8 @@ org

Org - {% if current_model_state.org %} - {% if current_model_state.org == False %} - Not Configured - {% else %} - Configured - {% endif %} + {% if current_model_state.org == True %} + Configured {% endif %}

@@ -146,12 +134,8 @@ PDF

PDF - {% if current_model_state.pdf %} - {% if current_model_state.pdf == False %} - Not Configured - {% else %} - Configured - {% endif %} + {% if current_model_state.pdf == True %} + Configured {% endif %}

@@ -181,12 +165,8 @@ Plaintext

Plaintext - {% if current_model_state.plaintext %} - {% if current_model_state.plaintext == False %} - Not Configured - {% else %} - Configured - {% endif %} + {% if current_model_state.plaintext == True %} + Configured {% endif %}

@@ -217,79 +197,37 @@

Features

-
-
- Chat -

- Chat - {% if current_config.processor and current_config.processor.conversation.openai %} - {% if current_model_state.conversation_openai == False %} - Not Configured - {% else %} - Configured - {% endif %} - {% endif %} -

-
-
-

Setup online chat using OpenAI

-
- - {% if current_config.processor and current_config.processor.conversation.openai %} -
- -
- {% endif %} -
Chat

- Offline Chat - Configured - {% if current_model_state.enable_offline_model and not current_model_state.conversation_gpt4all %} - Not Configured - {% endif %} + Chat Model

-

Setup offline chat

+
-
-
-
- -
-
-
-
+

Clients

API Key

API Keys

-

Manage access to your Khoj from client apps

+

Manage access from your client apps to Khoj

@@ -328,13 +266,35 @@