diff --git a/src/khoj/database/adapters/__init__.py b/src/khoj/database/adapters/__init__.py index a0fc1be9..00ad75f1 100644 --- a/src/khoj/database/adapters/__init__.py +++ b/src/khoj/database/adapters/__init__.py @@ -47,6 +47,8 @@ from khoj.database.models import ( UserConversationConfig, UserRequests, UserSearchModelConfig, + UserVoiceModelConfig, + VoiceModelOption, ) from khoj.processor.conversation import prompts from khoj.search_filter.date_filter import DateFilter @@ -705,6 +707,14 @@ class ConversationAdapters: new_config = await UserConversationConfig.objects.aupdate_or_create(user=user, defaults={"setting": config}) return new_config + @staticmethod + async def aset_user_voice_model(user: KhojUser, model_id: str): + config = await VoiceModelOption.objects.filter(model_id=model_id).afirst() + if not config: + return None + new_config = await UserVoiceModelConfig.objects.aupdate_or_create(user=user, defaults={"setting": config}) + return new_config + @staticmethod def get_conversation_config(user: KhojUser): config = UserConversationConfig.objects.filter(user=user).first() @@ -719,6 +729,24 @@ class ConversationAdapters: return None return config.setting + @staticmethod + async def aget_voice_model_config(user: KhojUser) -> Optional[VoiceModelOption]: + voice_model_config = await UserVoiceModelConfig.objects.filter(user=user).prefetch_related("setting").afirst() + if voice_model_config: + return voice_model_config.setting + return None + + @staticmethod + def get_voice_model_options(): + return VoiceModelOption.objects.all() + + @staticmethod + def get_voice_model_config(user: KhojUser) -> Optional[VoiceModelOption]: + voice_model_config = UserVoiceModelConfig.objects.filter(user=user).prefetch_related("setting").first() + if voice_model_config: + return voice_model_config.setting + return None + @staticmethod def get_default_conversation_config(): server_chat_settings = ServerChatSettings.objects.first() diff --git a/src/khoj/database/admin.py b/src/khoj/database/admin.py index 64a0a7fd..3bc0f76d 100644 --- a/src/khoj/database/admin.py +++ b/src/khoj/database/admin.py @@ -27,6 +27,7 @@ from khoj.database.models import ( Subscription, TextToImageModelConfig, UserSearchModelConfig, + VoiceModelOption, ) from khoj.utils.helpers import ImageIntentType @@ -99,6 +100,7 @@ admin.site.register(TextToImageModelConfig) admin.site.register(ClientApplication) admin.site.register(GithubConfig) admin.site.register(NotionConfig) +admin.site.register(VoiceModelOption) @admin.register(Agent) diff --git a/src/khoj/database/migrations/0048_voicemodeloption_uservoicemodelconfig.py b/src/khoj/database/migrations/0048_voicemodeloption_uservoicemodelconfig.py new file mode 100644 index 00000000..8f86c88a --- /dev/null +++ b/src/khoj/database/migrations/0048_voicemodeloption_uservoicemodelconfig.py @@ -0,0 +1,52 @@ +# Generated by Django 4.2.11 on 2024-06-21 04:18 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("database", "0047_alter_entry_file_type"), + ] + + operations = [ + migrations.CreateModel( + name="VoiceModelOption", + 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)), + ("model_id", models.CharField(max_length=200)), + ("name", models.CharField(max_length=200)), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="UserVoiceModelConfig", + 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.voicemodeloption", + ), + ), + ( + "user", + models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/src/khoj/database/models/__init__.py b/src/khoj/database/models/__init__.py index 52685471..fcda8c10 100644 --- a/src/khoj/database/models/__init__.py +++ b/src/khoj/database/models/__init__.py @@ -97,6 +97,11 @@ class ChatModelOptions(BaseModel): ) +class VoiceModelOption(BaseModel): + model_id = models.CharField(max_length=200) + name = models.CharField(max_length=200) + + class Agent(BaseModel): creator = models.ForeignKey( KhojUser, on_delete=models.CASCADE, default=None, null=True, blank=True @@ -248,6 +253,11 @@ class UserConversationConfig(BaseModel): setting = models.ForeignKey(ChatModelOptions, on_delete=models.CASCADE, default=None, null=True, blank=True) +class UserVoiceModelConfig(BaseModel): + user = models.OneToOneField(KhojUser, on_delete=models.CASCADE) + setting = models.ForeignKey(VoiceModelOption, on_delete=models.CASCADE, default=None, null=True, blank=True) + + class UserSearchModelConfig(BaseModel): user = models.OneToOneField(KhojUser, on_delete=models.CASCADE) setting = models.ForeignKey(SearchModelConfig, on_delete=models.CASCADE) diff --git a/src/khoj/interface/web/assets/icons/speaker.svg b/src/khoj/interface/web/assets/icons/speaker.svg new file mode 100644 index 00000000..cfe4542c --- /dev/null +++ b/src/khoj/interface/web/assets/icons/speaker.svg @@ -0,0 +1,4 @@ + + diff --git a/src/khoj/interface/web/assets/icons/voice.svg b/src/khoj/interface/web/assets/icons/voice.svg new file mode 100644 index 00000000..e4e4649a --- /dev/null +++ b/src/khoj/interface/web/assets/icons/voice.svg @@ -0,0 +1,8 @@ + + diff --git a/src/khoj/interface/web/base_config.html b/src/khoj/interface/web/base_config.html index 9002d5c1..19ba1389 100644 --- a/src/khoj/interface/web/base_config.html +++ b/src/khoj/interface/web/base_config.html @@ -332,6 +332,7 @@ } select#search-models, + select#voice-models, select#chat-models { margin-bottom: 0; padding: 8px; diff --git a/src/khoj/interface/web/chat.html b/src/khoj/interface/web/chat.html index 71ccea07..62c91f7d 100644 --- a/src/khoj/interface/web/chat.html +++ b/src/khoj/interface/web/chat.html @@ -422,8 +422,65 @@ To get started, just start typing below. You can also type / to see a list of co sendFeedback(userQuery, khojQuery, "Bad Response"); }; + // Only enable the speech feature if the user is subscribed + let speechButton = null; + + if ("{{ is_active }}" == "True") { + // Create a speech button icon to play the message out loud + speechButton = document.createElement('button'); + speechButton.classList.add("speech-button"); + speechButton.title = "Listen to Message"; + let speechIcon = document.createElement("img"); + speechIcon.src = "/static/assets/icons/speaker.svg"; + speechIcon.classList.add("speech-icon"); + speechButton.appendChild(speechIcon); + speechButton.addEventListener('click', function() { + // Replace the speaker with a loading icon. + let loader = document.createElement("span"); + loader.classList.add("loader"); + + speechButton.innerHTML = ""; + speechButton.appendChild(loader); + speechButton.disabled = true; + + const context = new (window.AudioContext || window.webkitAudioContext)(); + fetch(`/api/chat/speech?text=${encodeURIComponent(message)}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + }) + .then(response => response.arrayBuffer()) + .then(arrayBuffer => { + return context.decodeAudioData(arrayBuffer); + }) + .then(audioBuffer => { + const source = context.createBufferSource(); + source.buffer = audioBuffer; + source.connect(context.destination); + source.start(0); + source.onended = function() { + speechButton.innerHTML = ""; + speechButton.appendChild(speechIcon); + speechButton.disabled = false; + }; + }) + .catch(err => { + console.error("Error playing speech:", err); + speechButton.innerHTML = ""; + speechButton.appendChild(speechIcon); + speechButton.disabled = true; + }); + }); + } + + // Append buttons to parent element element.append(copyButton, thumbsDownButton, thumbsUpButton); + + if (speechButton) { + element.append(speechButton); + } } renderMathInElement(element, { @@ -2830,7 +2887,13 @@ To get started, just start typing below. You can also type / to see a list of co float: right; } - button.thumbs-up-button { + img.speech-icon { + width: 18px; + } + + button.thumbs-up-button, + button.thumbs-down-button, + button.speech-button { border-radius: 4px; background-color: var(--background-color); border: 1px solid var(--main-text-color); @@ -2843,19 +2906,6 @@ To get started, just start typing below. You can also type / to see a list of co margin-right: 4px; } - button.thumbs-down-button { - border-radius: 4px; - background-color: var(--background-color); - border: 1px solid var(--main-text-color); - text-align: center; - font-size: medium; - transition: all 0.5s; - cursor: pointer; - padding: 4px; - float: right; - margin-right:4px; - } - button.copy-button span { cursor: pointer; display: inline-block; @@ -2878,20 +2928,14 @@ To get started, just start typing below. You can also type / to see a list of co height: 18px; } - button.copy-button:hover { + button.copy-button:hover, + button.thumbs-up-button:hover, + button.thumbs-down-button:hover, + button.speech-button:hover { background-color: var(--primary-hover); color: #f5f5f5; } - button.thumbs-up-button:hover { - background-color: var(--primary-hover); - color: #f5f5f5; - } - - button.thumbs-down-button:hover { - background-color: var(--primary-hover); - color: #f5f5f5; - } pre { text-wrap: unset; @@ -3156,6 +3200,40 @@ To get started, just start typing below. You can also type / to see a list of co white-space: pre-wrap; } + .loader { + width: 18px; + height: 18px; + border: 3px solid #FFF; + border-radius: 50%; + display: inline-block; + position: relative; + box-sizing: border-box; + animation: rotation 1s linear infinite; + } + .loader::after { + content: ''; + box-sizing: border-box; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: 18px; + height: 18px; + border-radius: 50%; + border: 3px solid transparent; + border-bottom-color: var(--flower); + } + + @keyframes rotation { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } + } + + .loading-spinner { display: inline-block; position: relative; diff --git a/src/khoj/interface/web/config.html b/src/khoj/interface/web/config.html index 64841f97..2c3c98db 100644 --- a/src/khoj/interface/web/config.html +++ b/src/khoj/interface/web/config.html @@ -202,6 +202,34 @@ {% endif %} + {% if is_eleven_labs_enabled %} +