diff --git a/src/interface/desktop/chat.html b/src/interface/desktop/chat.html index 73e88159..3e719fe4 100644 --- a/src/interface/desktop/chat.html +++ b/src/interface/desktop/chat.html @@ -1075,11 +1075,12 @@ threeDotMenu.appendChild(conversationMenu); let deleteButton = document.createElement('button'); + deleteButton.type = "button"; deleteButton.innerHTML = "Delete"; deleteButton.classList.add("delete-conversation-button"); deleteButton.classList.add("three-dot-menu-button-item"); - deleteButton.addEventListener('click', function() { - // Ask for confirmation before deleting chat session + deleteButton.addEventListener('click', function(event) { + event.preventDefault(); let confirmation = confirm('Are you sure you want to delete this chat session?'); if (!confirmation) return; let deleteURL = `/api/chat/history?client=web&conversation_id=${incomingConversationId}`; @@ -1927,6 +1928,7 @@ text-align: left; display: flex; position: relative; + margin: 0 8px; } .three-dot-menu { diff --git a/src/khoj/database/adapters/__init__.py b/src/khoj/database/adapters/__init__.py index fac2ca27..10a8d146 100644 --- a/src/khoj/database/adapters/__init__.py +++ b/src/khoj/database/adapters/__init__.py @@ -36,6 +36,7 @@ from khoj.database.models import ( NotionConfig, OpenAIProcessorConversationConfig, ProcessLock, + PublicConversation, ReflectiveQuestion, SearchModelConfig, SpeechToTextModelOptions, @@ -560,7 +561,28 @@ class AgentAdapters: return await Agent.objects.filter(name=AgentAdapters.DEFAULT_AGENT_NAME).afirst() +class PublicConversationAdapters: + @staticmethod + def get_public_conversation_by_slug(slug: str): + return PublicConversation.objects.filter(slug=slug).first() + + @staticmethod + def get_public_conversation_url(public_conversation: PublicConversation): + # Public conversations are viewable by anyone, but not editable. + return f"/share/chat/{public_conversation.slug}/" + + class ConversationAdapters: + @staticmethod + def make_public_conversation_copy(conversation: Conversation): + return PublicConversation.objects.create( + source_owner=conversation.user, + agent=conversation.agent, + conversation_log=conversation.conversation_log, + slug=conversation.slug, + title=conversation.title, + ) + @staticmethod def get_conversation_by_user( user: KhojUser, client_application: ClientApplication = None, conversation_id: int = None @@ -680,6 +702,19 @@ class ConversationAdapters: async def aget_default_conversation_config(): return await ChatModelOptions.objects.filter().prefetch_related("openai_config").afirst() + @staticmethod + def create_conversation_from_public_conversation( + user: KhojUser, public_conversation: PublicConversation, client_app: ClientApplication + ): + return Conversation.objects.create( + user=user, + conversation_log=public_conversation.conversation_log, + client=client_app, + slug=public_conversation.slug, + title=public_conversation.title, + agent=public_conversation.agent, + ) + @staticmethod def save_conversation( user: KhojUser, diff --git a/src/khoj/database/migrations/0036_publicconversation.py b/src/khoj/database/migrations/0036_publicconversation.py new file mode 100644 index 00000000..54a98fa1 --- /dev/null +++ b/src/khoj/database/migrations/0036_publicconversation.py @@ -0,0 +1,42 @@ +# Generated by Django 4.2.10 on 2024-04-17 13:27 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("database", "0035_processlock"), + ] + + operations = [ + migrations.CreateModel( + name="PublicConversation", + 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(default=dict)), + ("slug", models.CharField(blank=True, default=None, max_length=200, null=True)), + ("title", models.CharField(blank=True, default=None, max_length=200, null=True)), + ( + "agent", + models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="database.agent", + ), + ), + ( + "source_owner", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/src/khoj/database/migrations/0040_merge_20240504_1010.py b/src/khoj/database/migrations/0040_merge_20240504_1010.py new file mode 100644 index 00000000..3abedb3c --- /dev/null +++ b/src/khoj/database/migrations/0040_merge_20240504_1010.py @@ -0,0 +1,14 @@ +# Generated by Django 4.2.10 on 2024-05-04 10:10 + +from typing import List + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("database", "0036_publicconversation"), + ("database", "0039_merge_20240501_0301"), + ] + + operations: List[str] = [] diff --git a/src/khoj/database/migrations/0041_merge_20240505_1234.py b/src/khoj/database/migrations/0041_merge_20240505_1234.py new file mode 100644 index 00000000..b3cea861 --- /dev/null +++ b/src/khoj/database/migrations/0041_merge_20240505_1234.py @@ -0,0 +1,14 @@ +# Generated by Django 4.2.10 on 2024-05-05 12:34 + +from typing import List + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("database", "0040_alter_processlock_name"), + ("database", "0040_merge_20240504_1010"), + ] + + operations: List[str] = [] diff --git a/src/khoj/database/models/__init__.py b/src/khoj/database/models/__init__.py index e654903d..6921fcae 100644 --- a/src/khoj/database/models/__init__.py +++ b/src/khoj/database/models/__init__.py @@ -1,3 +1,4 @@ +import re import uuid from random import choice @@ -249,6 +250,36 @@ class Conversation(BaseModel): agent = models.ForeignKey(Agent, on_delete=models.SET_NULL, default=None, null=True, blank=True) +class PublicConversation(BaseModel): + source_owner = models.ForeignKey(KhojUser, on_delete=models.CASCADE) + conversation_log = models.JSONField(default=dict) + slug = models.CharField(max_length=200, default=None, null=True, blank=True) + title = models.CharField(max_length=200, default=None, null=True, blank=True) + agent = models.ForeignKey(Agent, on_delete=models.SET_NULL, default=None, null=True, blank=True) + + +@receiver(pre_save, sender=PublicConversation) +def verify_public_conversation(sender, instance, **kwargs): + def generate_random_alphanumeric(length): + characters = "0123456789abcdefghijklmnopqrstuvwxyz" + return "".join(choice(characters) for _ in range(length)) + + # check if this is a new instance + if instance._state.adding: + slug = re.sub(r"\W+", "-", instance.slug.lower())[:50] + observed_random_id = set() + while PublicConversation.objects.filter(slug=slug).exists(): + try: + random_id = generate_random_alphanumeric(7) + except IndexError: + raise ValidationError( + "Unable to generate a unique slug for the Public Conversation. Please try again later." + ) + observed_random_id.add(random_id) + slug = f"{slug}-{random_id}" + instance.slug = slug + + class ReflectiveQuestion(BaseModel): question = models.CharField(max_length=500) user = models.ForeignKey(KhojUser, on_delete=models.CASCADE, default=None, null=True, blank=True) diff --git a/src/khoj/interface/web/assets/utils.js b/src/khoj/interface/web/assets/utils.js index 43eca02c..2945940c 100644 --- a/src/khoj/interface/web/assets/utils.js +++ b/src/khoj/interface/web/assets/utils.js @@ -8,9 +8,11 @@ function toggleMenu() { document.addEventListener('click', function(event) { let menu = document.getElementById("khoj-nav-menu"); let menuContainer = document.getElementById("khoj-nav-menu-container"); - let isClickOnMenu = menuContainer.contains(event.target) || menuContainer === event.target; - if (isClickOnMenu === false && menu.classList.contains("show")) { - menu.classList.remove("show"); + if (menuContainer) { + let isClickOnMenu = menuContainer.contains(event.target) || menuContainer === event.target; + if (isClickOnMenu === false && menu.classList.contains("show")) { + menu.classList.remove("show"); + } } }); diff --git a/src/khoj/interface/web/chat.html b/src/khoj/interface/web/chat.html index bcffc254..557998a5 100644 --- a/src/khoj/interface/web/chat.html +++ b/src/khoj/interface/web/chat.html @@ -1562,11 +1562,79 @@ To get started, just start typing below. You can also type / to see a list of co conversationMenu.appendChild(editTitleButton); threeDotMenu.appendChild(conversationMenu); + let shareButton = document.createElement('button'); + shareButton.innerHTML = "Share"; + shareButton.type = "button"; + shareButton.classList.add("share-conversation-button"); + shareButton.classList.add("three-dot-menu-button-item"); + shareButton.addEventListener('click', function(event) { + event.preventDefault(); + let confirmation = confirm('Are you sure you want to share this chat session? This will make the conversation public.'); + if (!confirmation) return; + let duplicateURL = `/api/chat/share?client=web&conversation_id=${incomingConversationId}`; + fetch(duplicateURL , { method: "POST" }) + .then(response => response.ok ? response.json() : Promise.reject(response)) + .then(data => { + if (data.status == "ok") { + flashStatusInChatInput("✅ Conversation shared successfully"); + } + // Make a pop-up that shows data.url to share the conversation + let shareURL = data.url; + let shareModal = document.createElement('div'); + shareModal.classList.add("modal"); + shareModal.id = "share-conversation-modal"; + let shareModalContent = document.createElement('div'); + shareModalContent.classList.add("modal-content"); + let shareModalHeader = document.createElement('div'); + shareModalHeader.classList.add("modal-header"); + let shareModalTitle = document.createElement('h2'); + shareModalTitle.textContent = "Share Conversation"; + let shareModalCloseButton = document.createElement('button'); + shareModalCloseButton.classList.add("modal-close-button"); + shareModalCloseButton.innerHTML = "×"; + shareModalCloseButton.addEventListener('click', function() { + shareModal.remove(); + }); + shareModalHeader.appendChild(shareModalTitle); + shareModalHeader.appendChild(shareModalCloseButton); + shareModalContent.appendChild(shareModalHeader); + let shareModalBody = document.createElement('div'); + shareModalBody.classList.add("modal-body"); + let shareModalText = document.createElement('p'); + shareModalText.textContent = "The link has been copied to your clipboard. Use it to share your conversation with others!"; + let shareModalLink = document.createElement('input'); + shareModalLink.setAttribute("value", shareURL); + shareModalLink.setAttribute("readonly", ""); + shareModalLink.classList.add("share-link"); + let copyButton = document.createElement('button'); + copyButton.textContent = "Copy"; + copyButton.addEventListener('click', function() { + shareModalLink.select(); + document.execCommand('copy'); + }); + copyButton.id = "copy-share-url-button"; + shareModalBody.appendChild(shareModalText); + shareModalBody.appendChild(shareModalLink); + shareModalBody.appendChild(copyButton); + shareModalContent.appendChild(shareModalBody); + shareModal.appendChild(shareModalContent); + document.body.appendChild(shareModal); + shareModalLink.select(); + document.execCommand('copy'); + }) + .catch(err => { + return; + }); + }); + conversationMenu.appendChild(shareButton); + let deleteButton = document.createElement('button'); + deleteButton.type = "button"; deleteButton.innerHTML = "Delete"; deleteButton.classList.add("delete-conversation-button"); deleteButton.classList.add("three-dot-menu-button-item"); - deleteButton.addEventListener('click', function() { + deleteButton.addEventListener('click', function(event) { + event.preventDefault(); // Ask for confirmation before deleting chat session let confirmation = confirm('Are you sure you want to delete this chat session?'); if (!confirmation) return; @@ -2225,7 +2293,7 @@ To get started, just start typing below. You can also type / to see a list of co } .side-panel-button { - background: var(--background-color); + background: none; border: none; box-shadow: none; font-size: 14px; @@ -2444,6 +2512,7 @@ To get started, just start typing below. You can also type / to see a list of co margin-bottom: 8px; } + button#copy-share-url-button, button#new-conversation-button { display: inline-flex; align-items: center; @@ -2464,14 +2533,12 @@ To get started, just start typing below. You can also type / to see a list of co text-align: left; display: flex; position: relative; + margin: 0 8px; } .three-dot-menu { display: block; - /* background: var(--background-color); */ - /* border: 1px solid var(--main-text-color); */ border-radius: 5px; - /* position: relative; */ position: absolute; right: 4px; top: 4px; @@ -2653,13 +2720,6 @@ To get started, just start typing below. You can also type / to see a list of co color: #333; } - #agent-instructions { - font-size: 14px; - color: #666; - height: 50px; - overflow: auto; - } - #agent-owned-by-user { font-size: 12px; color: #007BFF; @@ -2681,7 +2741,7 @@ To get started, just start typing below. You can also type / to see a list of co margin: 15% auto; /* 15% from the top and centered */ padding: 20px; border: 1px solid #888; - width: 250px; + width: 300px; text-align: left; background: var(--background-color); border-radius: 5px; @@ -2755,6 +2815,28 @@ To get started, just start typing below. You can also type / to see a list of co border: 1px solid var(--main-text-color); } + .share-link { + display: block; + width: 100%; + padding: 10px; + margin-top: 10px; + border: 1px solid #ccc; + border-radius: 4px; + background-color: #f9f9f9; + font-family: 'Courier New', monospace; + color: #333; + font-size: 16px; + box-sizing: border-box; + transition: all 0.3s ease; + } + + .share-link:focus { + outline: none; + border-color: #007BFF; + box-shadow: 0 0 0 0.2rem rgba(0,123,255,.25); + } + + button#copy-share-url-button, button#new-conversation-submit-button { background: var(--summer-sun); transition: background 0.2s ease-in-out; @@ -2765,6 +2847,7 @@ To get started, just start typing below. You can also type / to see a list of co transition: background 0.2s ease-in-out; } + button#copy-share-url-button:hover, button#new-conversation-submit-button:hover { background: var(--primary); } diff --git a/src/khoj/interface/web/login.html b/src/khoj/interface/web/login.html index 91ef6007..6443e6a4 100644 --- a/src/khoj/interface/web/login.html +++ b/src/khoj/interface/web/login.html @@ -16,7 +16,7 @@