# pylint: disable=W0613,C0302
"""
Views for the React chat component used in the Smarter web application.
"""
import logging
import traceback
from http import HTTPStatus
from typing import Any, Optional, Union
import yaml
from django.conf import settings
from django.db import models
from django.http import (
HttpRequest,
HttpResponse,
HttpResponseNotAllowed,
HttpResponseNotFound,
JsonResponse,
)
from django.shortcuts import render
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_control
from smarter.apps.account.models import UserProfile
from smarter.apps.account.utils import get_cached_smarter_admin_user_profile
from smarter.apps.api.v1.manifests.enum import SAMKinds
from smarter.apps.chatbot.models import (
ChatBot,
ChatBotFunctions,
ChatBotHelper,
ChatBotPlugin,
ChatBotRequests,
ChatBotRequestsSerializer,
get_cached_chatbot_by_request,
)
from smarter.apps.chatbot.serializers import (
ChatBotConfigSerializer,
ChatBotFunctionsSerializer,
ChatBotPluginSerializer,
)
from smarter.apps.chatbot.utils import get_cached_chatbots_for_user_profile
from smarter.apps.docs.views.base import DocsBaseView
from smarter.apps.plugin.models import (
PluginSelectorHistory,
PluginSelectorHistorySerializer,
)
from smarter.apps.prompt.models import Chat, ChatHelper
from smarter.common.conf import smarter_settings
from smarter.common.const import (
SMARTER_CHAT_SESSION_KEY_NAME,
SmarterHttpMethods,
)
from smarter.common.exceptions import (
SmarterException,
)
from smarter.common.helpers.console_helpers import formatted_json
from smarter.common.helpers.url_helpers import clean_url
from smarter.common.mixins import SmarterHelperMixin
from smarter.common.utils import is_authenticated_request
from smarter.lib.django import waffle
from smarter.lib.django.http.shortcuts import (
SmarterHttpResponseForbidden,
SmarterHttpResponseNotFound,
SmarterHttpResponseServerError,
)
from smarter.lib.django.views import (
SmarterAuthenticatedNeverCachedWebView,
SmarterAuthenticatedWebView,
smarter_cache_page_by_user,
)
from smarter.lib.django.waffle import SmarterWaffleSwitches
from smarter.lib.drf.views.helpers import UnauthenticatedPermissionClass
from smarter.lib.journal.enum import SmarterJournalCliCommands, SmarterJournalThings
from smarter.lib.journal.http import (
SmarterJournaledJsonErrorResponse,
SmarterJournaledJsonResponse,
)
from smarter.lib.logging import WaffleSwitchedLoggerWrapper
from .signals import chat_config_invoked, chat_session_invoked
MAX_RETURNED_PLUGINS = 10
PROMPT_LIST_CACHE_TIMEOUT = smarter_settings.cache_expiration
WORKBENCH_CACHE_TIMEOUT = 10 # 10 seconds. keeps the workbench snappy while avoiding appearing stale.
[docs]
def should_log(level):
"""Check if logging should be done based on the waffle switch."""
return waffle.switch_is_active(SmarterWaffleSwitches.PROMPT_LOGGING)
base_logger = logging.getLogger(__name__)
logger = WaffleSwitchedLoggerWrapper(base_logger, should_log)
[docs]
def should_log_verbose(level):
"""Check if logging should be done based on the waffle switch."""
return smarter_settings.verbose_logging
verbose_logger = WaffleSwitchedLoggerWrapper(base_logger, should_log_verbose)
[docs]
class SmarterChatappViewError(SmarterException):
"""Base class for all SmarterChatapp errors."""
@property
def get_formatted_err_message(self):
return "Smarter Chatapp error"
[docs]
class SmarterChatSession(SmarterHelperMixin):
"""
Helper class that provides methods for creating a session key and client key.
"""
_chat: Optional[Chat] = None
_chat_helper: Optional[ChatHelper] = None
_chatbot: Optional[ChatBot] = None
request: Optional[HttpRequest] = None
_session_key: str
[docs]
def __init__(self, request: HttpRequest, session_key: str, *args, chatbot: Optional[ChatBot] = None, **kwargs):
super().__init__()
verbose_logger.debug(
"SmarterChatSession().__init__() called with session_key=%s, chatbot=%s", session_key, chatbot
)
self.request = request
if not isinstance(session_key, str):
logger.error("%s - session_key is not a string: %s", self.formatted_class_name, type(session_key))
self._session_key = session_key
if chatbot:
self._chatbot = chatbot
self.user_profile = chatbot.user_profile
# leaving this in place as a reminder that we need one or the other
if not self.session_key and not self.chatbot:
logger.error("%s - either session_key or chatbot must be provided", self.formatted_class_name)
self._chat_helper = ChatHelper(request, *args, session_key=self.session_key, chatbot=self.chatbot, **kwargs)
self._chat = self._chat_helper.chat
verbose_logger.debug("%s - session established: %s", self.formatted_class_name, self.session_key)
chat_session_invoked.send(sender=self.__class__, instance=self, request=request)
def __str__(self):
return f"{SmarterChatSession.__name__}[{id(self)}](chatbot={self.chatbot}, session_key={self.session_key})"
def __repr__(self):
return self.__str__()
def __eq__(self, value: object) -> bool:
return isinstance(value, SmarterChatSession) and self.session_key == value.session_key
@property
def session_key(self) -> str:
"""
The session key for this chat session. This is used to identify the chat session
and is generated by the /config/ endpoint.
"""
if self._session_key is None:
logger.error(
"%s - session_key is None. This should not happen. Please report this issue to the Smarter team.",
self.formatted_class_name,
)
return self._session_key
@property
def chatbot(self):
return self._chatbot
@property
def chat(self):
return self._chat
@property
def chat_helper(self):
return self._chat_helper
[docs]
def clean_url(self, url: str) -> str:
"""
Clean the url of any query strings and trailing '/config/' strings.
"""
retval = clean_url(url)
if retval.endswith("/config/"):
retval = retval[:-8]
return retval
[docs]
class ManifestDropZoneView(SmarterAuthenticatedNeverCachedWebView):
"""
A simple view that renders a page with a manifest drop zone
for plugin development.
"""
template_path = "prompt/manifest-apply.html"
[docs]
def get(self, request: HttpRequest, *args, **kwargs):
return render(request, self.template_path, context={})
[docs]
class ChatConfigView(SmarterAuthenticatedNeverCachedWebView):
"""
Chat configuration view for the Smarter web application.
This view is responsible for providing all configuration information required by the ReactJS chat UI component.
It is designed to be performant and efficient, as it is invoked on every user-facing browser load and refresh.
**Key Features:**
- **Django Template-Based:**
This view uses Django's template system for rendering and does not rely on Django REST Framework (DRF) serializers for its main response.
The configuration data is returned as a JSON response, but the view itself is structured as a Django class-based view.
- **CSRF Exempt:**
The view is decorated with `@csrf_exempt` because it is read-only and does not modify server-side data. This exemption is safe in this context
and avoids unnecessary CSRF validation for GET and POST requests that only retrieve configuration data.
- **ReactJS UI Integration:**
The endpoint provides all necessary configuration and context information for the ReactJS chat component. This includes chatbot metadata,
plugin information, session keys, and historical data relevant to the chat session. The React app consumes this configuration to initialize
and render the chat UI in the user's browser.
- **Session-Based Architecture:**
Each chat session is uniquely identified and managed by a globally unique 64-character session key. The session key is generated by
RequestHelper.generate_session_key() and is persisted by the browser in a domain and path-specific cookie, meaning that this design supports
an unlimited number of concurrent sessions per user, one per device per chatbot.
- **Performance Considerations:**
Since this endpoint is called on every browser load and refresh, performance is a primary concern. The view is optimized to minimize database
queries and serialization overhead. Only a limited number of plugins (as defined by `MAX_RETURNED_PLUGINS`) are returned to avoid excessive payload sizes.
Caching and efficient queryset usage are employed where possible to ensure fast response times for end users.
**Example Usage:**
https://smarter.3141-5926-5359.alpha.api.example.com/config/
**Returns:**
JSON response containing:
- `session_key`: Unique identifier for the chat session.
- `sandbox_mode`: Boolean indicating if the chatbot is running in sandbox mode.
- `debug_mode`: Boolean indicating if debug mode is enabled.
- `chatbot`: Serialized chatbot configuration.
- `history`: Chat and plugin selector history for the session.
- `meta_data`: Additional metadata for the chatbot.
- `plugins`: Plugin metadata and a limited list of plugins.
**Security:**
- The view is protected and requires the user to be authenticated, unless the chatbot is configured to allow unauthenticated access.
- If authentication is required and the user is not authenticated, a 403 Forbidden response is returned.
**See Also:**
- `SmarterChatSession`: Helper class for managing chat sessions.
- `ChatBotConfigSerializer`, `ChatBotPluginSerializer`: Serializers for chatbot and plugin data.
- `ChatBotHelper`: Helper for chatbot-related operations.
"""
authentication_classes = None
permission_classes = (UnauthenticatedPermissionClass,)
thing: Optional[SmarterJournalThings] = None
command: Optional[SmarterJournalCliCommands] = None
session: Optional[SmarterChatSession] = None
chatbot_name: Optional[str] = None
_chatbot_helper: Optional[ChatBotHelper] = None
_chatbot: Optional[ChatBot] = None
@property
def chatbot(self) -> Optional[ChatBot]:
return self._chatbot
@property
def chatbot_helper(self) -> Optional[ChatBotHelper]:
if self._chatbot_helper:
return self._chatbot_helper
if self.chatbot:
# throw everything but the kitchen sink at the ChatBotHelper
self._chatbot_helper = ChatBotHelper(
request=self.smarter_request,
session_key=self.session.session_key if self.session else None,
name=self._chatbot.name if self._chatbot else self.chatbot_name,
chatbot_id=self._chatbot.id if self._chatbot else None, # type: ignore[union-attr]
account=self.account,
user=self.user,
user_profile=self.user_profile,
)
else:
self._chatbot_helper = ChatBotHelper(
request=self.smarter_request,
name=self.chatbot_name or self.smarter_request_chatbot_name,
session_key=self.session.session_key if self.session else None,
account=self.account,
user=self.user,
user_profile=self.user_profile,
)
return self._chatbot_helper
@chatbot_helper.setter
def chatbot_helper(self, value: Optional[ChatBotHelper]):
self._chatbot_helper = value
if self._chatbot_helper and self._chatbot_helper.chatbot:
self._chatbot = self._chatbot_helper.chatbot
self.account = self._chatbot_helper.account
self.user = self._chatbot_helper.user
self.user_profile = self._chatbot_helper.user_profile
verbose_logger.debug("%s - chatbot_helper() setter chatbot=%s", self.formatted_class_name, self.chatbot)
else:
self._chatbot = None
verbose_logger.debug("%s - chatbot_helper() setter chatbot is unset", self.formatted_class_name)
[docs]
def clean_url(self, url: str) -> str:
"""
Clean the url of any query strings and trailing '/config/' strings.
"""
retval = clean_url(url)
if retval.endswith("/config/"):
retval = retval[:-8]
return retval
[docs]
def config(self) -> dict[str, Any]:
"""
Assemble and return the configuration dictionary required by the ReactJS chat UI component.
This method gathers all relevant context and configuration data for the chat session and chatbot,
and returns it as a dictionary suitable for JSON serialization. This configuration is consumed by
the ReactJS frontend to initialize and render the chat interface.
**Key Features:**
- **Django Template-Based:**
This logic is part of a Django class-based view and does not use Django REST Framework (DRF) serializers
for the main response, though serializers are used for some nested data.
- **CSRF Exempt:**
The parent view is CSRF exempt because this endpoint is strictly read-only and does not modify server-side data.
- **ReactJS UI Integration:**
The returned dictionary provides all configuration and context information needed by the ReactJS chat component,
including chatbot metadata, plugin information, session keys, and chat history.
- **Session-Based:**
The configuration is tied to a unique chat session, as defined by the :class:`SmarterChatSession` helper,
which uses a combination of the user's IP address and device-identifying information to uniquely identify
each session.
- **Performance:**
Since this endpoint is called on every browser load and refresh, the logic is optimized to minimize database
queries and serialization overhead. Only a limited number of plugins (see ``MAX_RETURNED_PLUGINS``) are returned
to keep payloads small and response times fast.
Returns
-------
dict
A dictionary containing all configuration data required by the ReactJS chat UI component. The structure includes:
- ``session_key``: Unique identifier for the chat session.
- ``sandbox_mode``: Boolean indicating if the chatbot is running in sandbox mode.
- ``debug_mode``: Boolean indicating if debug mode is enabled.
- ``chatbot``: Serialized chatbot configuration.
- ``history``: Chat and plugin selector history for the session.
- ``meta_data``: Additional metadata for the chatbot.
- ``plugins``: Plugin metadata and a limited list of plugins.
Raises
------
SmarterValueError
If the session or chatbot helper is not set.
See Also
--------
SmarterChatSession : Helper class for managing chat sessions.
ChatBotConfigSerializer, ChatBotPluginSerializer : Serializers for chatbot and plugin data.
ChatBotHelper : Helper for chatbot-related operations.
"""
# add chatbot_request_history and plugin_selector_history to history
# these have to be added here due to circular import issues.
if self.session is None:
logger.error(
"%s.config() - session is None. Cannot retrieve chatbot request history.", self.formatted_class_name
)
return {}
if self.chatbot_helper is None:
logger.error(
"%s.config() - ChatBotHelper is None. Cannot retrieve chatbot request history.",
self.formatted_class_name,
)
return {}
chatbot_serializer = (
ChatBotConfigSerializer(self.chatbot, context={"request": self.smarter_request}) if self.chatbot else None
)
# plugins context. the main thing we need here is to constrain the number of plugins
# returned to some reasonable number, since we'll probaably have cases where
# the chatbot has a lot of plugins (hundreds, thousands...).
chatbot_plugins_count = ChatBotPlugin.objects.filter(chatbot=self.chatbot).count()
chatbot_plugins = ChatBotPlugin.objects.filter(chatbot=self.chatbot).order_by("-pk")[:MAX_RETURNED_PLUGINS]
chatbot_plugin_serializer = ChatBotPluginSerializer(chatbot_plugins, many=True)
chatbot_functions_count = ChatBotFunctions.objects.filter(chatbot=self.chatbot).count()
chatbot_functions = ChatBotFunctions.objects.filter(chatbot=self.chatbot).order_by("-pk")[:MAX_RETURNED_PLUGINS]
chatbot_functions_serializer = ChatBotFunctionsSerializer(chatbot_functions, many=True)
history = self.session.chat_helper.history if self.session.chat_helper else {}
chatbot_requests_queryset = ChatBotRequests.objects.filter(session_key=self.session.session_key).order_by("-id")
chatbot_requests_serializer = ChatBotRequestsSerializer(chatbot_requests_queryset, many=True)
history["chatbot_request_history"] = chatbot_requests_serializer.data
plugin_selector_history_queryset = PluginSelectorHistory.objects.filter(session_key=self.session.session_key)
plugin_selector_history_serializer = PluginSelectorHistorySerializer(
plugin_selector_history_queryset, many=True
)
history["plugin_selector_history"] = plugin_selector_history_serializer.data
retval = {
"data": {
SMARTER_CHAT_SESSION_KEY_NAME: self.session.session_key,
"sandbox_mode": self.chatbot_helper.is_chatbot_sandbox_url,
"debug_mode": waffle.switch_is_active(SmarterWaffleSwitches.ENABLE_REACTAPP_DEBUG_MODE),
"chatbot": chatbot_serializer.data if chatbot_serializer else None,
"history": history,
"meta_data": self.chatbot_helper.to_json(),
"plugins": {
"meta_data": {
"total_plugins": chatbot_plugins_count,
"plugins_returned": len(chatbot_plugins),
},
"plugins": chatbot_plugin_serializer.data,
},
"functions": {
"meta_data": {
"total_functions": chatbot_functions_count,
"functions_returned": len(chatbot_functions),
},
"functions": chatbot_functions_serializer.data,
},
},
}
chat_config_invoked.send(sender=self.__class__, instance=self, request=self.smarter_request, data=retval)
return retval
[docs]
def dispatch(
self, request: HttpRequest, *args, **kwargs
) -> Union[JsonResponse, HttpResponse, SmarterJournaledJsonErrorResponse]:
"""
Handles incoming HTTP requests for the chat configuration endpoint.
This method is responsible for orchestrating the retrieval and assembly of all configuration data
required by the ReactJS chat UI component. It is invoked on every user-facing browser load and refresh,
making performance a critical concern.
**Key Details:**
- **Django Template-Based:**
This view uses Django's class-based view and template system, not Django REST Framework (DRF).
The response is a JSON object, but the view logic is not DRF-based.
- **CSRF Exempt:**
The view is decorated with ``@csrf_exempt`` because it is strictly read-only and does not modify server-side data.
This avoids unnecessary CSRF validation for GET and POST requests that only retrieve configuration data.
- **ReactJS UI Integration:**
The endpoint provides all configuration and context information needed by the ReactJS chat component,
including chatbot metadata, plugin information, session keys, and chat history.
- **Session-Based:**
Sessions are managed by the :class:`SmarterChatSession` helper, which uniquely defines a chat session
using a combination of the user's IP address and device-identifying information. This ensures each device/browser
instance receives a unique session key, which is used to track chat history and plugin usage.
- **Performance:**
Since this endpoint is called on every browser load and refresh, it is optimized to minimize database queries
and serialization overhead. Only a limited number of plugins are returned (see ``MAX_RETURNED_PLUGINS``)
to keep payloads small and response times fast.
Parameters
----------
request : HttpRequest
The incoming HTTP request object.
chatbot_id : Optional[int], default=None
The ID of the chatbot to retrieve configuration for, if specified.
*args
Additional positional arguments.
**kwargs
Additional keyword arguments.
Returns
-------
Union[JsonResponse, HttpResponse, SmarterJournaledJsonErrorResponse]
A JSON response containing all configuration data required by the ReactJS chat UI component,
or an error response if the request is invalid or unauthorized.
See Also
--------
SmarterChatSession : Helper class for managing chat sessions.
ChatBotConfigSerializer, ChatBotPluginSerializer : Serializers for chatbot and plugin data.
ChatBotHelper : Helper for chatbot-related operations.
"""
hashed_id = kwargs.pop("hashed_id", None)
if hashed_id:
chatbot_id = ChatBot.id_from_hashed_id(hashed_id)
else:
chatbot_id = kwargs.pop("chatbot_id", None)
logger.debug(
"%s.dispatch() called with request=%s, chatbot_id=%s, session_key=%s chatbot_name=%s user_profile=%s",
self.formatted_class_name,
request.build_absolute_uri(),
chatbot_id,
self.session_key,
self.chatbot_name,
self.user_profile,
)
if chatbot_id is not None:
try:
self._chatbot = ChatBot.get_cached_object(pk=chatbot_id)
self.chatbot_name = self._chatbot.name
verbose_logger.debug(
"%s.dispatch() - set chatbot=%s from chatbot_id=%s",
self.formatted_class_name,
self._chatbot,
chatbot_id,
)
except ChatBot.DoesNotExist:
verbose_logger.error(
"%s.dispatch() - ChatBot with id=%s does not exist. Returning 404.",
self.formatted_class_name,
chatbot_id,
)
return JsonResponse({"error": "Not found"}, status=HTTPStatus.NOT_FOUND.value)
else:
self.chatbot_name = kwargs.get("name")
try:
self._chatbot = get_cached_chatbot_by_request(request=request)
if not self._chatbot:
verbose_logger.debug(
"%s.dispatch() - get_cached_chatbot_by_request() returned None. Attempting to instantiate ChatBotHelper with additional info",
self.formatted_class_name,
)
self.chatbot_helper = ChatBotHelper(
request=self.smarter_request,
session_key=self.session_key,
chatbot_id=chatbot_id,
name=self.chatbot_name,
account=self.account,
user=self.user,
user_profile=self.user_profile,
)
except ChatBot.DoesNotExist:
return JsonResponse({"error": "Not found"}, status=HTTPStatus.NOT_FOUND.value)
# Initialize the chat session for this request. session_key is generated
# and managed by the /config/ endpoint for the chatbot
#
# examples:
# - https://customer-support.3141-5926-5359.api.example.com/workbench/config/?session_key=123456
# - http://localhost:9357/api/v1/chatbots/1556/chat/config/?session_key=0733c7b3a33e7eb733c95f6d7fc88d671b6c957e6857a04d05b3f5905167116f
#
# The React app calls this endpoint at app initialization to get a
# json dict that includes, among other pertinent info, this session_key
# which uniquely identifies the device and the individual chatbot session
# for the device.
self.session = SmarterChatSession(request, session_key=self.session_key, chatbot=self.chatbot)
if (
self.chatbot_helper
and self.chatbot_helper.is_authentication_required
and not is_authenticated_request(request)
):
return SmarterHttpResponseForbidden(
request,
"Authentication failed. Are you logged in? Smarter sessions automatically expire after 24 hours.",
)
verbose_logger.debug(
"%s - chatbot=%s - chatbot_helper=%s", self.formatted_class_name, self.chatbot, self.chatbot_helper
)
if not self.chatbot:
return JsonResponse({"error": "Not found"}, status=HTTPStatus.NOT_FOUND.value)
self.thing = SmarterJournalThings(SmarterJournalThings.CHAT_CONFIG)
self.command = SmarterJournalCliCommands(SmarterJournalCliCommands.CHAT_CONFIG)
verbose_logger.debug(
"%s.dispatch() completed with chatbot=%s, session_key=%s",
self.formatted_class_name,
self.chatbot,
self.session.session_key if self.session else "(Missing session)",
)
return super().dispatch(request, *args, **kwargs) # type: ignore[return-value]
def __str__(self):
return str(self.chatbot) if self.chatbot else "ChatConfigView"
# pylint: disable=unused-argument
[docs]
def post(self, request: HttpRequest, *args, **kwargs) -> SmarterJournaledJsonResponse:
"""
Get the chatbot configuration.
"""
logger.debug("%s - post()", self.formatted_class_name)
data = self.config()
return SmarterJournaledJsonResponse(request=request, data=data, thing=self.thing, command=self.command)
# pylint: disable=unused-argument
[docs]
def get(self, request: HttpRequest, *args, **kwargs) -> Union[SmarterJournaledJsonResponse, HttpResponseNotAllowed]:
"""
Get the chatbot configuration.
"""
logger.warning(
"%s - get() %s should be invoked via POST instead of GET.", self.formatted_class_name, request.path
)
if not waffle.switch_is_active(SmarterWaffleSwitches.ALLOW_API_GET):
logger.error(
"%s.get() %s is not allowed because %s switch is inactive.",
self.formatted_class_name,
request.path,
SmarterWaffleSwitches.ALLOW_API_GET,
)
return HttpResponseNotAllowed(permitted_methods=[SmarterHttpMethods.POST])
data = self.config()
return SmarterJournaledJsonResponse(request=request, data=data, thing=self.thing, command=self.command)
[docs]
class ChatAppWorkbenchView(SmarterAuthenticatedNeverCachedWebView):
"""
Chat app view for the Smarter web application.
This view is responsible for serving the main chat application page within the Smarter dashboard web app.
It integrates the ReactJS chat UI with the Django template system by injecting a React build artifact snippet
(served from an AWS Cloudfront CDN) into the Django-rendered HTML template. The React app then takes over the UI
from there.
**Key Features:**
- **Django Template Integration:**
The view uses Django's template system to render the main chat page. It injects the React app's loader script
and root div into the template, allowing seamless integration between Django and React.
- **ReactJS UI Bootstrapping:**
The React build (JavaScript and CSS) is loaded from a CDN and injected into the DOM. The React app is responsible
for rendering the interactive chat UI after the initial page load.
- **Flexible URL Patterns:**
The view supports both sandbox and production URL formats, allowing it to work with deployed and not-yet-deployed ChatBots.
- **Authentication Protected:**
This view requires the user to be authenticated. If the user is not authenticated, access is denied.
- **Cache Control:**
The view uses Django's `never_cache` decorator to ensure that the browser does not cache the chat page itself.
This prevents issues where a user logs out and then logs back in without a full page refresh.
**Example Usage:**
Sandbox mode:
- http://smarter.sh/workbench/hr/
- http://127.0.0.1:9357/workbench/<str:name>/
Production mode:
- https://hr.3141-5926-5359.alpha.api.example.com/workbench/
**Returns:**
Renders the Django template for the chat app, injecting the React loader and configuration context.
**See Also:**
- `ChatConfigView` — for the endpoint that provides configuration data to the React app.
"""
template_path = "prompt/workbench.html"
# The React app originates from
# - https://github.com/smarter-sh/smarter-chat and
# - https://github.com/smarter-sh/web-integration-example
# and is built-deployed to AWS Cloudfront. The React app is loaded from
# a url like: https://cdn.alpha.platform.smarter.sh/ui-chat/index.html
reactjs_cdn_path = smarter_settings.smarter_reactjs_app_loader_path
reactjs_loader_url = smarter_settings.smarter_reactjs_app_loader_url
chatbot: Optional[ChatBot] = None
chatbot_helper: Optional[ChatBotHelper] = None
[docs]
def dispatch(self, request: HttpRequest, *args, **kwargs):
"""
Dispatch method to handle the request for the main chat application page.
This method is responsible for preparing and serving the Django template that bootstraps the ReactJS chat UI
within the Smarter dashboard web app. It injects the React loader script and configuration context into the
template, enabling seamless integration between Django and React.
**Key Features:**
- **Django Template Integration:**
Uses Django's template system to render the main chat page, injecting the React app's loader script and root div.
- **ReactJS UI Bootstrapping:**
Loads the React build (JavaScript and CSS) from a CDN and injects it into the DOM. The React app then takes over
rendering the interactive chat UI after the initial page load.
- **Flexible URL Patterns:**
Supports both sandbox and production URL formats, allowing the view to work with both deployed and not-yet-deployed ChatBots.
- **Authentication Protected:**
Requires the user to be authenticated. If the user is not authenticated, access is denied.
- **Cache Control:**
Uses Django's `never_cache` decorator to prevent the browser from caching the chat page, ensuring session security.
Parameters
----------
request : HttpRequest
The incoming HTTP request object.
*args
Additional positional arguments.
**kwargs
Additional keyword arguments.
Returns
-------
HttpResponse
Renders the Django template for the chat app, injecting the React loader and configuration context.
See Also
--------
ChatConfigView : The endpoint that provides configuration data to the React app.
"""
if not self.user_profile:
logger.error(
"%s.dispatch() - user_profile is None. This should not happen. Returning 403.",
self.formatted_class_name,
)
return SmarterHttpResponseForbidden(request=request, error_message="Authentication required")
retval = super().dispatch(request, *args, **kwargs)
if retval.status_code >= HTTPStatus.BAD_REQUEST:
return retval
session_key = kwargs.pop(SMARTER_CHAT_SESSION_KEY_NAME, None)
if session_key is not None:
self._session_key = session_key
verbose_logger.debug(
"%s.dispatch() - setting session_key=%s from kwargs",
self.formatted_class_name,
self.session_key,
)
try:
verbose_logger.debug(
"%s.dispatch() - url=%s, account=%s, user=%s",
self.formatted_class_name,
self.url,
self.account,
self.user_profile.user,
)
# first try to avoid some quite-expensive steps by looking for the chatbot
# in the cache based on the request.
self.chatbot = get_cached_chatbot_by_request(request=self.smarter_request)
if not self.chatbot:
self.chatbot_helper = ChatBotHelper(
request=self.smarter_request,
session_key=self.session_key,
account=self.account,
user=self.user,
user_profile=self.user_profile,
)
self.chatbot = self.chatbot_helper.chatbot if self.chatbot_helper.chatbot else None
if self.chatbot:
verbose_logger.debug(
"%s.dispatch() - set chatbot=%s from self.chatbot_helper",
self.formatted_class_name,
self.chatbot,
)
else:
return SmarterHttpResponseNotFound(request=request, error_message="ChatBot not found")
except ChatBot.DoesNotExist:
return SmarterHttpResponseNotFound(request=request, error_message="ChatBot not found")
# pylint: disable=broad-except
except Exception as e:
logger.error(
"%s.dispatch() - Exception occurred while getting chatbot: %s. "
"Request URL: %s, Session Key: %s\nStack trace: %s",
self.formatted_class_name,
str(e),
request.build_absolute_uri(),
self.session_key,
traceback.format_exc(),
)
return SmarterHttpResponseServerError(request=request, error_message=str(e))
if not self.chatbot:
return SmarterHttpResponseNotFound(request=request, error_message="ChatBot not found")
# the basic idea is to pass the names of the necessary cookies to the React app, and then
# it is supposed to find and read the cookies to get the chat session key, csrf token, etc.
context = {
"chatapp_workbench": {
"div_id": smarter_settings.smarter_reactjs_root_div_id,
"app_loader_url": self.reactjs_loader_url,
"chatbot_api_url": self.chatbot.sandbox_url,
"toggle_metadata": True,
"csrf_cookie_name": settings.CSRF_COOKIE_NAME,
"smarter_session_cookie_name": SMARTER_CHAT_SESSION_KEY_NAME, # this is the Smarter chat session, not the Django session.
"django_session_cookie_name": settings.SESSION_COOKIE_NAME, # this is the Django session.
"cookie_domain": settings.SESSION_COOKIE_DOMAIN,
"debug_mode": waffle.switch_is_active(SmarterWaffleSwitches.ENABLE_REACTAPP_DEBUG_MODE),
}
}
verbose_logger.debug(
"%s.dispatch() - rendering template %s with context: %s",
self.formatted_class_name,
self.template_path,
formatted_json(context),
)
return render(request=request, template_name=self.template_path, context=context)
[docs]
class PromptLandingView(SmarterAuthenticatedNeverCachedWebView):
"""
Base url for the Smarter prompt application. Provides a logical
endpoint without actually implementing any functionality.
"""
[docs]
def dispatch(self, request: HttpRequest, *args, **kwargs):
return HttpResponseNotFound()
[docs]
class PromptManifestView(DocsBaseView):
"""
Renders the detail view for a Smarter chatbot.
This view renders a detailed manifest for a specific chatbot, including
its configuration and metadata, in YAML format. It is intended for
authenticated users and provides error handling for missing or
unsupported chatbot kinds and names.
:param request: Django HTTP request object.
:type request: WSGIRequest
:param args: Additional positional arguments.
:type args: tuple
:param kwargs: Keyword arguments, must include 'name' (chatbot name) and 'kind' (chatbot type).
:type kwargs: dict
:returns: Rendered HTML page with chatbot manifest details, or a 404 error page if the chatbot is not found or parameters are invalid.
:rtype: HttpResponse
.. note::
The chatbot name and kind must be provided and valid. Otherwise, a "not found" response is returned.
.. seealso::
:class:`ChatBot` for chatbot retrieval.
:class:`ApiV1CliDescribeApiView` for API details.
**Example usage**::
GET /chatbot/detail/?name=my_chatbot&kind=custom
"""
template_path = "prompt/manifest-detail.html"
chatbot: Optional[ChatBot] = None
chatbot_helper: Optional[ChatBotHelper] = None
[docs]
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
"""
Handle GET requests to render the chatbot manifest detail view.
This method processes the incoming request to retrieve the
specified chatbot's manifest details and renders them in a
user-friendly format. It performs validation on the provided chatbot
name and kind, retrieves the chatbot metadata, and handles any
errors that may arise during this process.
Process:
1. Extract and validate 'name' and 'kind' from kwargs.
2. Retrieve the chatbot metadata using the provided name and user context.
3. If the chatbot is found, call the API view to get the chatbot details
4. Convert the JSON response to YAML format for better readability.
5. Render the chatbot manifest detail template with the retrieved data.
6. Handle any errors that occur during the process and return appropriate error responses.
:param request: Django HTTP request object.
:type request: WSGIRequest
:param args: Additional positional arguments.
:type args: tuple
:param kwargs: Keyword arguments, must include 'name' (chatbot name) and 'kind' (chatbot type).
:type kwargs: dict
:returns: Rendered HTML page with chatbot manifest details, or an error response if the chatbot is not found or parameters are invalid.
:rtype: HttpResponse
"""
logger.debug(
"%s.dispatch() called with request=%s, args=%s, kwargs=%s",
self.formatted_class_name,
request.build_absolute_uri(),
args,
kwargs,
)
hashed_id = kwargs.pop("hashed_id", None)
chatbot_id = ChatBot.id_from_hashed_id(hashed_id) if hashed_id else None
try:
self.chatbot = ChatBot.get_cached_object(pk=chatbot_id)
if not isinstance(self.chatbot, ChatBot):
raise ChatBot.DoesNotExist(
f"ChatBot with id {chatbot_id} does not exist. Received {type(self.chatbot)} {self.chatbot}"
)
self.chatbot_helper = ChatBotHelper(request=request, chatbot=self.chatbot)
# we'll pass the chatbot name as a kwarge to the APICli
# along with ownership info which we'll set below.
kwargs["name"] = self.chatbot.name
# there are many ways that we could do this, but using the system
# const is easiest.
self.kind = SAMKinds.CHATBOT
except ChatBot.DoesNotExist:
return SmarterHttpResponseNotFound(request=request, error_message=f"ChatBot with id {chatbot_id} not found")
logger.debug(
"%s.dispatch() - url=%s, account=%s, user=%s, chatbot=%s",
self.formatted_class_name,
self.url,
self.account,
self.user_profile.user,
self.chatbot,
)
# we need to re-orchestrate the parameters that we'll send to
# self.get_brokered_json_response(), which marshals the request
# and kwargs to the ApiV1CliDescribeApiView view to get
# the json manifest for the chatbot. Since the chatbot has ownership
# that is not necessarily the same as the authenticated user, we need
# to spoof the request user to be the owner of the chatbot for the
# purposes of generating the manifest.
#
# things we know:
# - request.user was validated in the base classes.
# - self.chatbot.user_profile was validated in ChatBotHelper
# - user_profile always has a valid user.
request.user = self.chatbot.user_profile.user
logger.debug(
"%s.dispatch() - rendering template %s with kwargs: %s",
self.formatted_class_name,
self.template_path,
kwargs,
)
# to avoid circular imports at app startup.
# pylint: disable=import-outside-toplevel
from smarter.apps.api.v1.cli.urls import ApiV1CliReverseViews
from smarter.apps.api.v1.cli.views.describe import ApiV1CliDescribeApiView
# build the relative url path to the API CLI end point.
reverse_name = str(ApiV1CliReverseViews.namespace + ApiV1CliReverseViews.describe).lower()
view = ApiV1CliDescribeApiView.as_view()
json_response = self.get_brokered_json_response(
reverse_name=reverse_name,
view=view,
request=request,
*args,
**kwargs,
)
try:
yaml_response = yaml.dump(json_response, default_flow_style=False)
except yaml.YAMLError as e:
logger.error(
"%s.dispatch() - Error converting JSON response to YAML: %s. JSON response: %s",
self.formatted_class_name,
str(e),
formatted_json(json_response),
)
return SmarterHttpResponseServerError(request=request, error_message="Error converting manifest to YAML")
context = {
"manifest": yaml_response,
"page_title": self.chatbot.name,
"owner": self.chatbot.user_profile,
}
try:
response = render(request, self.template_path, context=context) # type: ignore
# pylint: disable=broad-except
except Exception as e:
logger.error(
"%s.dispatch() - Error rendering template: %s. context: %s",
self.formatted_class_name,
str(e),
formatted_json(context),
exec_info=True,
)
return SmarterHttpResponseServerError(request=request, error_message="Error rendering manifest page")
return response
[docs]
@method_decorator(cache_control(max_age=WORKBENCH_CACHE_TIMEOUT), name="dispatch")
@method_decorator(smarter_cache_page_by_user(WORKBENCH_CACHE_TIMEOUT), name="dispatch")
class PromptListView(SmarterAuthenticatedWebView):
"""
list view for smarter workbench web console. This view is protected and
requires the user to be authenticated. It generates cards for each
ChatBots.
"""
template_path = "prompt/listview.html"
chatbots: Optional[models.QuerySet[ChatBot]] = None
chatbot_helpers: list[ChatBotHelper] = []
[docs]
def dispatch(self, request: HttpRequest, *args, **kwargs):
# pylint: disable=C0415
if not isinstance(self.user_profile, UserProfile):
logger.error(
"%s.dispatch() - user_profile is not set or not an instance of UserProfile. This should not happen. Returning 403.",
self.formatted_class_name,
)
return SmarterHttpResponseForbidden(request=request, error_message="Authentication required")
from smarter.apps.prompt.urls import PromptReverseViews
logger.debug(
"%s.dispatch() called for %s with args %s, kwargs %s", self.formatted_class_name, request, args, kwargs
)
response = super().dispatch(request, *args, **kwargs)
if response.status_code >= 300:
return response
self.chatbot_helpers = get_cached_chatbots_for_user_profile(user_profile_id=self.user_profile.id) # type: ignore
user_chatbots = [
chatbot_helper
for chatbot_helper in self.chatbot_helpers
if chatbot_helper.chatbot.user_profile == self.user_profile # type: ignore
]
shared_chatbots = [
chatbot_helper
for chatbot_helper in self.chatbot_helpers
if chatbot_helper.chatbot.user_profile != self.user_profile # type: ignore
]
smarter_admin = get_cached_smarter_admin_user_profile()
context = {
"prompt_list": {
"smarter_admin": smarter_admin,
"user_chatbots": user_chatbots,
"user_chatbots_count": len(user_chatbots),
"shared_chatbots": shared_chatbots,
},
"reverse_views": {
"manifest": f"{PromptReverseViews.namespace}:{PromptReverseViews.prompt_manifest_by_hashed_id}",
"chat": f"{PromptReverseViews.namespace}:{PromptReverseViews.prompt_chat_by_hashed_id}",
"config": f"{PromptReverseViews.namespace}:{PromptReverseViews.prompt_config_by_hashed_id}",
},
}
verbose_logger.debug(
"%s.dispatch() rendering template %s with context: %s",
self.formatted_class_name,
self.template_path,
formatted_json(context),
)
return render(request, template_name=self.template_path, context=context)