diff --git a/src/interface/desktop/assets/khoj.css b/src/interface/desktop/assets/khoj.css index 940018ce..83bde750 100644 --- a/src/interface/desktop/assets/khoj.css +++ b/src/interface/desktop/assets/khoj.css @@ -107,7 +107,7 @@ a.khoj-nav-selected { background-color: var(--primary); } img.khoj-logo { - width: min(60vw, 111px); + width: min(60vw, 90px); max-width: 100%; justify-self: center; } @@ -117,7 +117,7 @@ img.khoj-logo { display: grid; grid-auto-flow: column; gap: 20px; - padding: 16px 10px; + padding: 12px 10px; margin: 0 0 16px 0; } diff --git a/src/interface/desktop/chat.html b/src/interface/desktop/chat.html index 11ccc466..f24990e8 100644 --- a/src/interface/desktop/chat.html +++ b/src/interface/desktop/chat.html @@ -54,7 +54,6 @@ // Add event listener to toggle full reference on click referenceButton.addEventListener('click', function() { - console.log(`Toggling ref-${index}`) if (this.classList.contains("collapsed")) { this.classList.remove("collapsed"); this.classList.add("expanded"); @@ -100,7 +99,6 @@ // Add event listener to toggle full reference on click referenceButton.addEventListener('click', function() { - console.log(`Toggling ref-${index}`) if (this.classList.contains("collapsed")) { this.classList.remove("collapsed"); this.classList.add("expanded"); @@ -586,8 +584,10 @@ return data.response; }) .then(response => { + + const fullChatLog = response.chat; // Render conversation history, if any - response.forEach(chat_log => { + fullChatLog.forEach(chat_log => { renderMessageWithReference(chat_log.message, chat_log.by, chat_log.context, new Date(chat_log.created), chat_log.onlineContext, chat_log.intent?.type, chat_log.intent?.["inferred-queries"]); }); }) diff --git a/src/interface/obsidian/src/chat_modal.ts b/src/interface/obsidian/src/chat_modal.ts index 685fd91e..6ad88b69 100644 --- a/src/interface/obsidian/src/chat_modal.ts +++ b/src/interface/obsidian/src/chat_modal.ts @@ -269,7 +269,7 @@ export class KhojChatModal extends Modal { return false; } else if (responseJson.response) { - let chatLogs = responseJson.response; + let chatLogs = responseJson.response.chat; chatLogs.forEach((chatLog: any) => { this.renderMessageWithReferences(chatBodyEl, chatLog.message, chatLog.by, chatLog.context, new Date(chatLog.created), chatLog.intent?.type); }); diff --git a/src/khoj/configure.py b/src/khoj/configure.py index 80902080..4fdf6f99 100644 --- a/src/khoj/configure.py +++ b/src/khoj/configure.py @@ -256,6 +256,7 @@ def initialize_content(regenerate: bool, search_type: Optional[SearchType] = Non def configure_routes(app): # Import APIs here to setup search types before while configuring server from khoj.routers.api import api + from khoj.routers.api_chat import api_chat from khoj.routers.api_config import api_config from khoj.routers.auth import auth_router from khoj.routers.indexer import indexer @@ -266,6 +267,7 @@ def configure_routes(app): app.include_router(indexer, prefix="/api/v1/index") app.include_router(web_client) app.include_router(auth_router, prefix="/auth") + app.include_router(api_chat, prefix="/api/chat") if state.billing_enabled: from khoj.routers.subscription import subscription_router diff --git a/src/khoj/database/adapters/__init__.py b/src/khoj/database/adapters/__init__.py index 7b36b40e..40527abd 100644 --- a/src/khoj/database/adapters/__init__.py +++ b/src/khoj/database/adapters/__init__.py @@ -357,22 +357,61 @@ class ClientApplicationAdapters: class ConversationAdapters: @staticmethod - def get_conversation_by_user(user: KhojUser, client_application: ClientApplication = None): - conversation = Conversation.objects.filter(user=user, client=client_application) + def get_conversation_by_user( + user: KhojUser, client_application: ClientApplication = None, conversation_id: int = None + ): + if conversation_id: + conversation = Conversation.objects.filter(user=user, client=client_application, id=conversation_id) + if not conversation_id or not conversation.exists(): + conversation = Conversation.objects.filter(user=user, client=client_application) if conversation.exists(): return conversation.first() return Conversation.objects.create(user=user, client=client_application) @staticmethod - async def aget_conversation_by_user(user: KhojUser, client_application: ClientApplication = None): - conversation = Conversation.objects.filter(user=user, client=client_application) - if await conversation.aexists(): - return await conversation.afirst() + def get_conversation_sessions(user: KhojUser, client_application: ClientApplication = None): + return Conversation.objects.filter(user=user, client=client_application).order_by("-updated_at") + + @staticmethod + async def aset_conversation_title( + user: KhojUser, client_application: ClientApplication, conversation_id: int, title: str + ): + conversation = await Conversation.objects.filter( + user=user, client=client_application, id=conversation_id + ).afirst() + if conversation: + conversation.title = title + await conversation.asave() + return conversation + return None + + @staticmethod + def get_conversation_by_id(conversation_id: int): + return Conversation.objects.filter(id=conversation_id).first() + + @staticmethod + async def acreate_conversation_session(user: KhojUser, client_application: ClientApplication = None): return await Conversation.objects.acreate(user=user, client=client_application) @staticmethod - async def adelete_conversation_by_user(user: KhojUser): - return await Conversation.objects.filter(user=user).adelete() + async def aget_conversation_by_user( + user: KhojUser, client_application: ClientApplication = None, conversation_id: int = None, slug: str = None + ): + if conversation_id: + conversation = Conversation.objects.filter(user=user, client=client_application, id=conversation_id) + else: + conversation = Conversation.objects.filter(user=user, client=client_application, slug=slug) + if await conversation.aexists(): + return await conversation.afirst() + return await Conversation.objects.acreate(user=user, client=client_application, slug=slug) + + @staticmethod + async def adelete_conversation_by_user( + user: KhojUser, client_application: ClientApplication = None, conversation_id: int = None + ): + if conversation_id: + return await Conversation.objects.filter(user=user, client=client_application, id=conversation_id).adelete() + return await Conversation.objects.filter(user=user, client=client_application).adelete() @staticmethod def has_any_conversation_config(user: KhojUser): @@ -433,12 +472,24 @@ class ConversationAdapters: return await ChatModelOptions.objects.filter().afirst() @staticmethod - def save_conversation(user: KhojUser, conversation_log: dict, client_application: ClientApplication = None): - conversation = Conversation.objects.filter(user=user, client=client_application) - if conversation.exists(): - conversation.update(conversation_log=conversation_log) + def save_conversation( + user: KhojUser, + conversation_log: dict, + client_application: ClientApplication = None, + conversation_id: int = None, + user_message: str = None, + ): + slug = user_message.strip()[:200] if not is_none_or_empty(user_message) else None + if conversation_id: + conversation = Conversation.objects.filter(user=user, client=client_application, id=conversation_id) else: - Conversation.objects.create(user=user, conversation_log=conversation_log, client=client_application) + conversation = Conversation.objects.filter(user=user, client=client_application) + if conversation.exists(): + conversation.update(conversation_log=conversation_log, slug=slug) + else: + Conversation.objects.create( + user=user, conversation_log=conversation_log, client=client_application, slug=slug + ) @staticmethod def get_conversation_processor_options(): diff --git a/src/khoj/database/migrations/0030_conversation_slug_and_title.py b/src/khoj/database/migrations/0030_conversation_slug_and_title.py new file mode 100644 index 00000000..7e9dff81 --- /dev/null +++ b/src/khoj/database/migrations/0030_conversation_slug_and_title.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.7 on 2024-02-05 04:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("database", "0029_userrequests"), + ] + + operations = [ + migrations.AddField( + model_name="conversation", + name="slug", + field=models.CharField(blank=True, default=None, max_length=200, null=True), + ), + migrations.AddField( + model_name="conversation", + name="title", + field=models.CharField(blank=True, default=None, max_length=200, null=True), + ), + ] diff --git a/src/khoj/database/models/__init__.py b/src/khoj/database/models/__init__.py index 5a7ccd23..b74aaa11 100644 --- a/src/khoj/database/models/__init__.py +++ b/src/khoj/database/models/__init__.py @@ -178,6 +178,8 @@ class Conversation(BaseModel): user = models.ForeignKey(KhojUser, on_delete=models.CASCADE) conversation_log = models.JSONField(default=dict) client = models.ForeignKey(ClientApplication, on_delete=models.CASCADE, default=None, null=True, blank=True) + slug = models.CharField(max_length=200, default=None, null=True, blank=True) + title = models.CharField(max_length=200, default=None, null=True, blank=True) class ReflectiveQuestion(BaseModel): diff --git a/src/khoj/interface/web/assets/khoj.css b/src/khoj/interface/web/assets/khoj.css index 9c19bec8..0aa11d78 100644 --- a/src/khoj/interface/web/assets/khoj.css +++ b/src/khoj/interface/web/assets/khoj.css @@ -110,7 +110,7 @@ a.khoj-logo { background-color: var(--primary); } img.khoj-logo { - width: min(60vw, 111px); + width: min(60vw, 90px); max-width: 100%; justify-self: center; } @@ -202,7 +202,7 @@ img.khoj-logo { grid-auto-flow: column; gap: 20px; padding: 16px 10px; - margin: 0 0 16px 0; + margin: 0; } nav.khoj-nav { diff --git a/src/khoj/interface/web/chat.html b/src/khoj/interface/web/chat.html index 462ae05d..c6896e04 100644 --- a/src/khoj/interface/web/chat.html +++ b/src/khoj/interface/web/chat.html @@ -66,7 +66,6 @@ To get started, just start typing below. You can also type / to see a list of co // Add event listener to toggle full reference on click referenceButton.addEventListener('click', function() { - console.log(`Toggling ref-${index}`) if (this.classList.contains("collapsed")) { this.classList.remove("collapsed"); this.classList.add("expanded"); @@ -112,7 +111,6 @@ To get started, just start typing below. You can also type / to see a list of co // Add event listener to toggle full reference on click referenceButton.addEventListener('click', function() { - console.log(`Toggling ref-${index}`) if (this.classList.contains("collapsed")) { this.classList.remove("collapsed"); this.classList.add("expanded"); @@ -154,6 +152,9 @@ To get started, just start typing below. You can also type / to see a list of co // Scroll to bottom of chat-body element chatBody.scrollTop = chatBody.scrollHeight; + + let chatBodyWrapper = document.getElementById("chat-body-wrapper"); + chatBodyWrapperHeight = chatBodyWrapper.clientHeight; } function processOnlineReferences(referenceSection, onlineContext) { @@ -332,11 +333,20 @@ To get started, just start typing below. You can also type / to see a list of co document.getElementById("chat-input").value = ""; autoResize(); document.getElementById("chat-input").setAttribute("disabled", "disabled"); + let chat_body = document.getElementById("chat-body"); + + let conversationID = chat_body.dataset.conversationId; + + if (!conversationID) { + let response = await fetch('/api/chat/sessions', { method: "POST" }); + let data = await response.json(); + conversationID = data.conversation_id; + chat_body.dataset.conversationId = conversationID; + } // Generate backend API URL to execute query - let url = `/api/chat?q=${encodeURIComponent(query)}&n=${resultsCount}&client=web&stream=true`; + let url = `/api/chat?q=${encodeURIComponent(query)}&n=${resultsCount}&client=web&stream=true&conversation_id=${conversationID}`; - let chat_body = document.getElementById("chat-body"); let new_response = document.createElement("div"); new_response.classList.add("chat-message", "khoj"); new_response.attributes["data-meta"] = "🏮 Khoj at " + formatDate(new Date()); @@ -537,7 +547,18 @@ To get started, just start typing below. You can also type / to see a list of co window.onload = loadChat; function loadChat() { - fetch('/api/chat/history?client=web') + let chatBody = document.getElementById("chat-body"); + let conversationId = chatBody.dataset.conversationId; + let chatHistoryUrl = `/api/chat/history?client=web`; + if (conversationId) { + chatHistoryUrl += `&conversation_id=${conversationId}`; + } + + if (window.screen.width < 700) { + handleCollapseSidePanel(); + } + + fetch(chatHistoryUrl, { method: "GET" }) .then(response => response.json()) .then(data => { if (data.detail) { @@ -556,9 +577,172 @@ To get started, just start typing below. You can also type / to see a list of co }) .then(response => { // Render conversation history, if any - response.forEach(chat_log => { - renderMessageWithReference(chat_log.message, chat_log.by, chat_log.context, new Date(chat_log.created), chat_log.onlineContext, chat_log.intent?.type, chat_log.intent?.["inferred-queries"]); + conversationId = response.conversation_id; + const conversationTitle = response.slug || `New conversation 🌱`; + + let chatBody = document.getElementById("chat-body"); + chatBody.dataset.conversationId = conversationId; + chatBody.dataset.conversationTitle = conversationTitle; + + const fullChatLog = response.chat || []; + + fullChatLog.forEach(chat_log => { + if (chat_log.message != null){ + renderMessageWithReference( + chat_log.message, + chat_log.by, + chat_log.context, + new Date(chat_log.created), + chat_log.onlineContext, + chat_log.intent?.type, + chat_log.intent?.["inferred-queries"]); + } + }); + + let chatBodyWrapper = document.getElementById("chat-body-wrapper"); + chatBodyWrapperHeight = chatBodyWrapper.clientHeight; + + chatBody.style.height = chatBodyWrapperHeight; + }) + .catch(err => { + console.log(err); + return; + }); + + fetch('/api/chat/sessions', { method: "GET" }) + .then(response => response.json()) + .then(data => { + let conversationListBody = document.getElementById("conversation-list-body"); + conversationListBody.innerHTML = ""; + let conversationListBodyHeader = document.getElementById("conversation-list-header"); + + let chatBody = document.getElementById("chat-body"); + conversationId = chatBody.dataset.conversationId; + + if (data.length > 0) { + conversationListBodyHeader.style.display = "block"; + for (let index in data) { + let conversation = data[index]; + let conversationButton = document.createElement('div'); + let incomingConversationId = conversation["conversation_id"]; + const conversationTitle = conversation["slug"] || `New conversation 🌱`; + conversationButton.innerHTML = conversationTitle; + conversationButton.classList.add("conversation-button"); + if (incomingConversationId == conversationId) { + conversationButton.classList.add("selected-conversation"); + } + conversationButton.addEventListener('click', function() { + let chatBody = document.getElementById("chat-body"); + chatBody.innerHTML = ""; + chatBody.dataset.conversationId = incomingConversationId; + chatBody.dataset.conversationTitle = conversationTitle; + loadChat(); + }); + let threeDotMenu = document.createElement('div'); + threeDotMenu.classList.add("three-dot-menu"); + let threeDotMenuButton = document.createElement('button'); + threeDotMenuButton.innerHTML = "⋮"; + threeDotMenuButton.classList.add("three-dot-menu-button"); + threeDotMenuButton.addEventListener('click', function(event) { + event.stopPropagation(); + + let existingChildren = threeDotMenu.children; + + if (existingChildren.length > 1) { + // Skip deleting the first, since that's the menu button. + for (let i = 1; i < existingChildren.length; i++) { + existingChildren[i].remove(); + } + return; + } + + let conversationMenu = document.createElement('div'); + conversationMenu.classList.add("conversation-menu"); + + let deleteButton = document.createElement('button'); + deleteButton.innerHTML = "Delete"; + deleteButton.classList.add("delete-conversation-button"); + deleteButton.classList.add("three-dot-menu-button-item"); + deleteButton.addEventListener('click', function() { + let deleteURL = `/api/chat/history?client=web&conversation_id=${incomingConversationId}`; + fetch(deleteURL , { method: "DELETE" }) + .then(response => response.ok ? response.json() : Promise.reject(response)) + .then(data => { + let chatBody = document.getElementById("chat-body"); + chatBody.innerHTML = ""; + chatBody.dataset.conversationId = ""; + chatBody.dataset.conversationTitle = ""; + loadChat(); + }) + .catch(err => { + return; + }); + }); + conversationMenu.appendChild(deleteButton); + threeDotMenu.appendChild(conversationMenu); + + let editTitleButton = document.createElement('button'); + editTitleButton.innerHTML = "Rename"; + editTitleButton.classList.add("edit-title-button"); + editTitleButton.classList.add("three-dot-menu-button-item"); + editTitleButton.addEventListener('click', function(event) { + event.stopPropagation(); + + let conversationMenuChildren = conversationMenu.children; + + let totalItems = conversationMenuChildren.length; + + for (let i = totalItems - 1; i >= 0; i--) { + conversationMenuChildren[i].remove(); + } + + // Create a dialog box to get new title for conversation + let conversationTitleInputBox = document.createElement('div'); + conversationTitleInputBox.classList.add("conversation-title-input-box"); + let conversationTitleInput = document.createElement('input'); + conversationTitleInput.classList.add("conversation-title-input"); + + conversationTitleInput.value = conversationTitle; + + conversationTitleInput.addEventListener('click', function(event) { + event.stopPropagation(); + if (event.key === "Enter") { + event.preventDefault(); + conversationTitleInputButton.click(); + } + }); + + conversationTitleInputBox.appendChild(conversationTitleInput); + let conversationTitleInputButton = document.createElement('button'); + conversationTitleInputButton.innerHTML = "Save"; + conversationTitleInputButton.classList.add("three-dot-menu-button-item"); + conversationTitleInputButton.addEventListener('click', function(event) { + event.stopPropagation(); + let newTitle = conversationTitleInput.value; + if (newTitle != null) { + let editURL = `/api/chat/title?client=web&conversation_id=${incomingConversationId}&title=${newTitle}`; + fetch(editURL , { method: "PATCH" }) + .then(response => response.ok ? response.json() : Promise.reject(response)) + .then(data => { + conversationButton.innerHTML = newTitle; + }) + .catch(err => { + return; + }); + conversationTitleInputBox.remove(); + }}); + conversationTitleInputBox.appendChild(conversationTitleInputButton); + conversationMenu.appendChild(conversationTitleInputBox); + }); + conversationMenu.appendChild(editTitleButton); + threeDotMenu.appendChild(conversationMenu); + }); + threeDotMenu.appendChild(threeDotMenuButton); + conversationButton.appendChild(threeDotMenu); + conversationListBody.appendChild(conversationButton); + } + } }) .catch(err => { console.log(err); @@ -622,15 +806,32 @@ To get started, just start typing below. You can also type / to see a list of co }, 2000); } + function createNewConversation() { + let chatBody = document.getElementById("chat-body"); + chatBody.innerHTML = ""; + flashStatusInChatInput("📝 New conversation started"); + chatBody.dataset.conversationId = ""; + chatBody.dataset.conversationTitle = ""; + renderMessage(welcome_message, "khoj"); + } + function clearConversationHistory() { let chatInput = document.getElementById("chat-input"); let originalPlaceholder = chatInput.placeholder; let chatBody = document.getElementById("chat-body"); + let conversationId = chatBody.dataset.conversationId; - fetch(`/api/chat/history?client=web`, { method: "DELETE" }) + let deleteURL = `/api/chat/history?client=web`; + if (conversationId) { + deleteURL += `&conversation_id=${conversationId}`; + } + + fetch(deleteURL , { method: "DELETE" }) .then(response => response.ok ? response.json() : Promise.reject(response)) .then(data => { chatBody.innerHTML = ""; + chatBody.dataset.conversationId = ""; + chatBody.dataset.conversationTitle = ""; loadChat(); flashStatusInChatInput("🗑 Cleared conversation history"); }) @@ -739,6 +940,14 @@ To get started, just start typing below. You can also type / to see a list of co // Stop the countdown timer UI document.getElementById('countdown-circle').style.animation = "none"; }; + + function handleCollapseSidePanel() { + document.getElementById('side-panel').classList.toggle('collapsed'); + document.getElementById('new-conversation').classList.toggle('collapsed'); + document.getElementById('existing-conversations').classList.toggle('collapsed'); + + document.getElementById('chat-section-wrapper').classList.toggle('mobile-friendly'); + }