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 @@
-
Log in to Khoj
+
Login to Khoj
-
-

- Chat - Chat -

-
- - - - - - - - - -
- - - -
- - - -
-
- - -
-
-
-
- -{% endblock %} diff --git a/src/khoj/interface/web/public_conversation.html b/src/khoj/interface/web/public_conversation.html new file mode 100644 index 00000000..7d599c44 --- /dev/null +++ b/src/khoj/interface/web/public_conversation.html @@ -0,0 +1,1918 @@ + + + + + + Khoj: {{ public_conversation_slug | replace("-", " ")}} + + + + + + + + + + + + + + + + + +
+
+ + + + + + {% import 'utils.html' as utils %} + {{ utils.heading_pane(user_photo, username, is_active, has_documents) }} +
+
+
+
+
Agents
+ +
+
+
+ {% for agent in agents %} + + + + {% endfor %} +
+
+ + + +
+
+ +
+
+
+ +
+ + + +
+
+ + + + + diff --git a/src/khoj/routers/api_chat.py b/src/khoj/routers/api_chat.py index b7fef39a..f2cc7329 100644 --- a/src/khoj/routers/api_chat.py +++ b/src/khoj/routers/api_chat.py @@ -13,7 +13,12 @@ from starlette.authentication import requires from starlette.websockets import WebSocketDisconnect from websockets import ConnectionClosedOK -from khoj.database.adapters import ConversationAdapters, EntryAdapters, aget_user_name +from khoj.database.adapters import ( + ConversationAdapters, + EntryAdapters, + PublicConversationAdapters, + aget_user_name, +) from khoj.database.models import KhojUser from khoj.processor.conversation.prompts import ( help_message, @@ -132,6 +137,60 @@ def chat_history( return {"status": "ok", "response": meta_log} +@api_chat.get("/share/history") +def get_shared_chat( + request: Request, + common: CommonQueryParams, + public_conversation_slug: str, + n: Optional[int] = None, +): + user = request.user.object if request.user.is_authenticated else None + + # Load Conversation History + conversation = PublicConversationAdapters.get_public_conversation_by_slug(public_conversation_slug) + + if conversation is None: + return Response( + content=json.dumps({"status": "error", "message": f"Conversation: {public_conversation_slug} not found"}), + status_code=404, + ) + + agent_metadata = None + if conversation.agent: + agent_metadata = { + "slug": conversation.agent.slug, + "name": conversation.agent.name, + "avatar": conversation.agent.avatar, + "isCreator": conversation.agent.creator == user, + } + + meta_log = conversation.conversation_log + meta_log.update( + { + "conversation_id": conversation.id, + "slug": conversation.title if conversation.title else conversation.slug, + "agent": agent_metadata, + } + ) + + if n: + # Get latest N messages if N > 0 + if n > 0 and meta_log.get("chat"): + meta_log["chat"] = meta_log["chat"][-n:] + # Else return all messages except latest N + elif n < 0 and meta_log.get("chat"): + meta_log["chat"] = meta_log["chat"][:n] + + update_telemetry_state( + request=request, + telemetry_type="api", + api="public_conversation_history", + **common.__dict__, + ) + + return {"status": "ok", "response": meta_log} + + @api_chat.delete("/history") @requires(["authenticated"]) async def clear_chat_history( @@ -154,6 +213,66 @@ async def clear_chat_history( return {"status": "ok", "message": "Conversation history cleared"} +@api_chat.post("/share/fork") +@requires(["authenticated"]) +def fork_public_conversation( + request: Request, + common: CommonQueryParams, + public_conversation_slug: str, +): + user = request.user.object + + # Load Conversation History + public_conversation = PublicConversationAdapters.get_public_conversation_by_slug(public_conversation_slug) + + # Duplicate Public Conversation to User's Private Conversation + ConversationAdapters.create_conversation_from_public_conversation( + user, public_conversation, request.user.client_app + ) + + chat_metadata = {"forked_conversation": public_conversation.slug} + + update_telemetry_state( + request=request, + telemetry_type="api", + api="fork_public_conversation", + **common.__dict__, + metadata=chat_metadata, + ) + + redirect_uri = str(request.app.url_path_for("chat_page")) + + return Response(status_code=200, content=json.dumps({"status": "ok", "next_url": redirect_uri})) + + +@api_chat.post("/share") +@requires(["authenticated"]) +def duplicate_chat_history_public_conversation( + request: Request, + common: CommonQueryParams, + conversation_id: int, +): + user = request.user.object + + # Duplicate Conversation History to Public Conversation + conversation = ConversationAdapters.get_conversation_by_user(user, request.user.client_app, conversation_id) + + public_conversation = ConversationAdapters.make_public_conversation_copy(conversation) + + public_conversation_url = PublicConversationAdapters.get_public_conversation_url(public_conversation) + + update_telemetry_state( + request=request, + telemetry_type="api", + api="post_chat_share", + **common.__dict__, + ) + + return Response( + status_code=200, content=json.dumps({"status": "ok", "url": f"{request.client.host}{public_conversation_url}"}) + ) + + @api_chat.get("/sessions") @requires(["authenticated"]) def chat_sessions( diff --git a/src/khoj/routers/web_client.py b/src/khoj/routers/web_client.py index 047273e9..8c3fcd2a 100644 --- a/src/khoj/routers/web_client.py +++ b/src/khoj/routers/web_client.py @@ -14,6 +14,7 @@ from khoj.database.adapters import ( AutomationAdapters, ConversationAdapters, EntryAdapters, + PublicConversationAdapters, get_user_github_config, get_user_name, get_user_notion_config, @@ -350,9 +351,9 @@ def notion_config_page(request: Request): @web_client.get("/config/content-source/computer", response_class=HTMLResponse) @requires(["authenticated"], redirect="login_page") def computer_config_page(request: Request): - user = request.user.object - user_picture = request.session.get("user", {}).get("picture") - has_documents = EntryAdapters.user_has_entries(user=user) + user = request.user.object if request.user.is_authenticated else None + user_picture = request.session.get("user", {}).get("picture") if user else None + has_documents = EntryAdapters.user_has_entries(user=user) if user else False return templates.TemplateResponse( "content_source_computer_input.html", @@ -367,6 +368,59 @@ def computer_config_page(request: Request): ) +@web_client.get("/share/chat/{public_conversation_slug}", response_class=HTMLResponse) +def view_public_conversation(request: Request): + public_conversation_slug = request.path_params.get("public_conversation_slug") + public_conversation = PublicConversationAdapters.get_public_conversation_by_slug(public_conversation_slug) + if not public_conversation: + return templates.TemplateResponse( + "404.html", + context={ + "request": request, + "khoj_version": state.khoj_version, + }, + ) + user = request.user.object if request.user.is_authenticated else None + user_picture = request.session.get("user", {}).get("picture") if user else None + has_documents = EntryAdapters.user_has_entries(user=user) if user else False + + all_agents = AgentAdapters.get_all_accessible_agents(request.user.object if request.user.is_authenticated else None) + + # Filter out the current agent + all_agents = [agent for agent in all_agents if agent != public_conversation.agent] + agents_packet = [] + for agent in all_agents: + agents_packet.append( + { + "slug": agent.slug, + "avatar": agent.avatar, + "name": agent.name, + } + ) + + google_client_id = os.environ.get("GOOGLE_CLIENT_ID") + redirect_uri = str(request.app.url_path_for("auth")) + next_url = str( + request.app.url_path_for("view_public_conversation", public_conversation_slug=public_conversation_slug) + ) + + return templates.TemplateResponse( + "public_conversation.html", + context={ + "request": request, + "username": user.username if user else None, + "user_photo": user_picture, + "is_active": has_required_scope(request, ["premium"]), + "has_documents": has_documents, + "khoj_version": state.khoj_version, + "public_conversation_slug": public_conversation_slug, + "agents": agents_packet, + "google_client_id": google_client_id, + "redirect_uri": f"{redirect_uri}?next={next_url}", + }, + ) + + @web_client.get("/automations", response_class=HTMLResponse) @requires(["authenticated"], redirect="login_page") def automations_config_page(request: Request):