From 83d60d58728e67e5667dbe7b640e072272f8ff93 Mon Sep 17 00:00:00 2001 From: NeonKirill <74428618+NeonKirill@users.noreply.github.com> Date: Wed, 20 Nov 2024 11:36:03 +0100 Subject: [PATCH] [FEAT] Support for displaying live conversation on dedicated URL (#105) * renamed klatchat page title to Chatbots Forum * Added logic of the `live` URL: destination which renders latest CCAI conversation * Added docstring annotation to `displayLiveChat` function * Refactored logic to have a dedicated type for the live conversations --- chat_client/blueprints/chat.py | 47 ++++------ chat_client/client_utils/template_utils.py | 42 ++++++++- chat_client/static/js/chat_utils.js | 85 ++++++++++++++++--- chat_client/static/js/http_utils.js | 2 +- chat_client/static/js/user_utils.js | 19 +++++ chat_client/templates/base.html | 2 + chat_client/templates/base_header.html | 3 +- .../components/modals/new_conversation.html | 10 +-- chat_client/templates/conversation/base.html | 6 ++ chat_server/blueprints/chat.py | 73 +++++++++++++++- .../api_dependencies/models/chats.py | 7 ++ .../api_dependencies/validators/users.py | 10 +-- .../mongo_utils/queries/dao/chats.py | 7 +- 13 files changed, 253 insertions(+), 60 deletions(-) diff --git a/chat_client/blueprints/chat.py b/chat_client/blueprints/chat.py index 954a9f65..ec446023 100644 --- a/chat_client/blueprints/chat.py +++ b/chat_client/blueprints/chat.py @@ -29,7 +29,10 @@ from fastapi import APIRouter, Request from fastapi.templating import Jinja2Templates -from chat_client.client_config import client_config +from chat_client.client_utils.template_utils import ( + render_conversation_page, + render_nano_page, +) router = APIRouter( prefix="/chats", @@ -48,15 +51,19 @@ async def chats(request: Request): :returns chats template response """ - return conversation_templates.TemplateResponse( - "conversation/base.html", - { - "request": request, - "section": "Followed Conversations", - "add_sio": True, - "redirect_to_https": client_config.get("FORCE_HTTPS", False), - }, - ) + return render_conversation_page(request=request) + + +@router.get("/live") +async def live_chats(request: Request): + """ + Renders live chats page HTML as a response related to the input request + + :param request: input Request object + + :returns chats template response + """ + return render_conversation_page(request=request, additional_context={"live": True}) @router.get("/nano_demo") @@ -64,22 +71,4 @@ async def nano_demo(request: Request): """ Minimal working Example of Nano """ - client_url = f'"{request.url.scheme}://{request.url.netloc}"' - server_url = f'"{client_config["SERVER_URL"]}"' - if client_config.get("FORCE_HTTPS", False): - client_url = client_url.replace("http://", "https://") - server_url = server_url.replace("http://", "https://") - client_url_unquoted = client_url.replace('"', "") - return conversation_templates.TemplateResponse( - "sample_nano.html", - { - "request": request, - "title": "Nano Demonstration", - "description": "Klatchat Nano is injectable JS module, " - "allowing to render Klat conversations on any third-party pages, " - "supporting essential features.", - "server_url": server_url, - "client_url": client_url, - "client_url_unquoted": client_url_unquoted, - }, - ) + return render_nano_page(request=request) diff --git a/chat_client/client_utils/template_utils.py b/chat_client/client_utils/template_utils.py index 79621c1a..60c00d47 100644 --- a/chat_client/client_utils/template_utils.py +++ b/chat_client/client_utils/template_utils.py @@ -31,12 +31,50 @@ from starlette.requests import Request from starlette.templating import Jinja2Templates +from chat_client.client_config import client_config -component_templates = Jinja2Templates( + +jinja_templates_factory = Jinja2Templates( directory=os.environ.get("TEMPLATES_DIR", "chat_client/templates") ) +def render_conversation_page(request: Request, additional_context: dict | None = None): + return jinja_templates_factory.TemplateResponse( + "conversation/base.html", + { + "request": request, + "section": "Followed Conversations", + "add_sio": True, + "redirect_to_https": client_config.get("FORCE_HTTPS", False), + **(additional_context or {}), + }, + ) + + +def render_nano_page(request: Request, additional_context: dict | None = None): + client_url = f'"{request.url.scheme}://{request.url.netloc}"' + server_url = f'"{client_config["SERVER_URL"]}"' + if client_config.get("FORCE_HTTPS", False): + client_url = client_url.replace("http://", "https://") + server_url = server_url.replace("http://", "https://") + client_url_unquoted = client_url.replace('"', "") + return jinja_templates_factory.TemplateResponse( + "sample_nano.html", + { + "request": request, + "title": "Nano Demonstration", + "description": "Klatchat Nano is injectable JS module, " + "allowing to render Klat conversations on any third-party pages, " + "supporting essential features.", + "server_url": server_url, + "client_url": client_url, + "client_url_unquoted": client_url_unquoted, + **(additional_context or {}), + }, + ) + + def callback_template(request: Request, template_name: str, context: dict = None): """ Returns template response based on provided params @@ -49,6 +87,6 @@ def callback_template(request: Request, template_name: str, context: dict = None context["request"] = request # Preventing exiting to the source code files template_name = template_name.replace("../", "").replace(".", "/") - return component_templates.TemplateResponse( + return jinja_templates_factory.TemplateResponse( f"components/{template_name}.html", context ) diff --git a/chat_client/static/js/chat_utils.js b/chat_client/static/js/chat_utils.js index d331796a..806c04a6 100644 --- a/chat_client/static/js/chat_utils.js +++ b/chat_client/static/js/chat_utils.js @@ -491,17 +491,52 @@ function updateCIDStoreProperty(cid, property, value){ } /** - * Custom Event fired on supported languages init - * @type {CustomEvent} + * Boolean function that checks whether live chats must be displayed based on page meta properties + * @returns {boolean} true if live chat should be displayed, false otherwise */ -const chatAlignmentRestoredEvent = new CustomEvent("chatAlignmentRestored", { "detail": "Event that is fired when chat alignment is restored" }); +const shouldDisplayLiveChat = () => { + const liveMetaElem = document.querySelector("meta[name='live']"); + if (liveMetaElem){ + return liveMetaElem.getAttribute("content") === "1" + } + return false +} /** - * Restores chats alignment from the local storage - * - * @param keyName: name of the local storage key -**/ -async function restoreChatAlignment(keyName=conversationAlignmentKey){ + * Fetches latest live conversation from the klat server API and builds its HTML + * @returns {Promise<*>} fetched conversation data + */ +const displayLiveChat = async () => { + return await fetchServer('chat_api/live') + .then(response => { + if(response.ok){ + return response.json(); + }else{ + throw response.statusText; + } + }) + .then(data => { + if (getUserMessages(data, null).length === 0){ + console.debug('All of the messages are already displayed'); + setDefault(setDefault(conversationState, data['_id'], {}), 'all_messages_displayed', true); + } + return data; + }) + .then( + async data => { + await buildConversation(data, data.skin, true); + return data; + } + ) + .catch(async err=> { + console.warn('Failed to display live chat:',err); + }); +} + +/** + * Restores chat alignment based on the page cache + */ +const restoreChatAlignmentFromCache = async () => { let cachedItems = await retrieveItemsLayout(); if (cachedItems.length === 0){ cachedItems = [{'cid': '1', 'added_on': getCurrentTimestamp(), 'skin': CONVERSATION_SKINS.BASE}] @@ -519,6 +554,23 @@ async function restoreChatAlignment(keyName=conversationAlignmentKey){ } }); } +} + +/** + * Custom Event fired on supported languages init + * @type {CustomEvent} + */ +const chatAlignmentRestoredEvent = new CustomEvent("chatAlignmentRestored", { "detail": "Event that is fired when chat alignment is restored" }); + +/** + * Restores chats alignment from the local storage +**/ +async function restoreChatAlignment(){ + if (shouldDisplayLiveChat()){ + await displayLiveChat(); + } else { + await restoreChatAlignmentFromCache(); + } console.log('Chat Alignment Restored'); document.dispatchEvent(chatAlignmentRestoredEvent); } @@ -689,12 +741,13 @@ async function displayConversation(searchStr, skin=CONVERSATION_SKINS.BASE, aler /** * Handles requests on creation new conversation by the user - * @param conversationName: New Conversation Name - * @param isPrivate: if conversation should be private (defaults to false) - * @param conversationID: New Conversation ID (optional) - * @param boundServiceID: id of the service to bind to conversation (optional) + * @param conversationName - New Conversation Name + * @param isPrivate - if conversation should be private (defaults to false) + * @param conversationID - New Conversation ID (optional) + * @param boundServiceID - id of the service to bind to conversation (optional) + * @param createLiveConversation - if conversation should be treated as live conversation (defaults to false) */ -async function createNewConversation(conversationName, isPrivate=false, conversationID=null, boundServiceID=null) { +async function createNewConversation(conversationName, isPrivate=false, conversationID=null, boundServiceID=null, createLiveConversation=false) { let formData = new FormData(); @@ -702,6 +755,7 @@ async function createNewConversation(conversationName, isPrivate=false, conversa formData.append('conversation_id', conversationID); formData.append('is_private', isPrivate? '1': '0') formData.append('bound_service', boundServiceID?boundServiceID: ''); + formData.append('is_live_conversation', createLiveConversation? '1': '0') await fetchServer(`chat_api/new`, REQUEST_METHODS.POST, formData).then(async response => { const responseJson = await response.json(); @@ -743,7 +797,9 @@ document.addEventListener('DOMContentLoaded', (e)=>{ const newConversationID = document.getElementById('conversationID'); const newConversationName = document.getElementById('conversationName'); const isPrivate = document.getElementById('isPrivate'); + const createLiveConversation = document.getElementById("createLiveConversation"); let boundServiceID = bindServiceSelect.value; + if (boundServiceID){ const targetItem = document.getElementById(boundServiceID); if (targetItem.value) { @@ -757,7 +813,8 @@ document.addEventListener('DOMContentLoaded', (e)=>{ return -1; } } - createNewConversation(newConversationName.value, isPrivate.checked, newConversationID ? newConversationID.value : null, boundServiceID).then(responseOk=>{ + + createNewConversation(newConversationName.value, isPrivate.checked, newConversationID ? newConversationID.value : null, boundServiceID, createLiveConversation.checked).then(responseOk=>{ newConversationName.value = ""; newConversationID.value = ""; isPrivate.checked = false; diff --git a/chat_client/static/js/http_utils.js b/chat_client/static/js/http_utils.js index 634d8b44..d8f8bc5f 100644 --- a/chat_client/static/js/http_utils.js +++ b/chat_client/static/js/http_utils.js @@ -36,7 +36,7 @@ const fetchServer = async (urlSuffix, method=REQUEST_METHODS.GET, body=null, jso return fetch(`${configData["CHAT_SERVER_URL_BASE"]}/${urlSuffix}`, options).then(async response => { if (response.status === 401){ const responseJson = await response.json(); - if (responseJson['msg'] === 'Session Expired'){ + if (responseJson['msg'] === 'Requested user is not authorized to perform this action'){ localStorage.removeItem('session'); location.reload(); } diff --git a/chat_client/static/js/user_utils.js b/chat_client/static/js/user_utils.js index 005c4b2c..dc512774 100644 --- a/chat_client/static/js/user_utils.js +++ b/chat_client/static/js/user_utils.js @@ -249,6 +249,24 @@ function updateNavbar(forceUpdate=false){ } } + +/** + * Refreshes HTML components appearance based on the current user + * NOTE: this must have only visual impact, the actual validation is done on the backend + */ +const refreshComponentsAppearance = () => { + const currentUserRoles = currentUser?.roles ?? []; + const isAdmin = currentUserRoles.includes("admin"); + + const createLiveConversationWrapper = document.getElementById("createLiveConversationWrapper"); + + if (isAdmin){ + createLiveConversationWrapper.style.display = ""; + }else{ + createLiveConversationWrapper.style.display = "none"; + } +} + /** * Custom Event fired on current user loaded * @type {CustomEvent} @@ -268,6 +286,7 @@ async function refreshCurrentUser(refreshChats=false, conversationContainer=null if(refreshChats) { refreshChatView(conversationContainer); } + refreshComponentsAppearance() console.log('current user loaded'); document.dispatchEvent(currentUserLoaded); return data; diff --git a/chat_client/templates/base.html b/chat_client/templates/base.html index da8e12b4..efac0209 100644 --- a/chat_client/templates/base.html +++ b/chat_client/templates/base.html @@ -3,6 +3,8 @@ + {% block meta %} + {% endblock %} {% if redirect_to_https is defined and redirect_to_https %} {% endif %} diff --git a/chat_client/templates/base_header.html b/chat_client/templates/base_header.html index 0d820162..5bda4d7f 100644 --- a/chat_client/templates/base_header.html +++ b/chat_client/templates/base_header.html @@ -6,7 +6,7 @@ id="home-link" href="#" target="_parent"> - Klatchat + Chatbots Forum